tilefetch 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tilefetch/__init__.py +110 -0
- tilefetch/__main__.py +5 -0
- tilefetch/_version.py +1 -0
- tilefetch/api.py +592 -0
- tilefetch/cli.py +419 -0
- tilefetch/download.py +334 -0
- tilefetch/georef.py +291 -0
- tilefetch/mosaic.py +191 -0
- tilefetch/pmtiles.py +657 -0
- tilefetch/providers.py +137 -0
- tilefetch/py.typed +0 -0
- tilefetch/sources.py +546 -0
- tilefetch/storage.py +338 -0
- tilefetch/tiles.py +469 -0
- tilefetch-0.1.0.dist-info/METADATA +264 -0
- tilefetch-0.1.0.dist-info/RECORD +20 -0
- tilefetch-0.1.0.dist-info/WHEEL +5 -0
- tilefetch-0.1.0.dist-info/entry_points.txt +2 -0
- tilefetch-0.1.0.dist-info/licenses/LICENSE +21 -0
- tilefetch-0.1.0.dist-info/top_level.txt +1 -0
tilefetch/download.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Polite, resumable tile downloading.
|
|
2
|
+
|
|
3
|
+
* a small thread pool (2 threads by default) with a shared request-rate limit;
|
|
4
|
+
* retries with exponential backoff and jitter on timeouts, connection errors,
|
|
5
|
+
HTTP 408/425/429/5xx, honouring ``Retry-After``;
|
|
6
|
+
* an optional on-disk cache with ``ETag`` / ``Last-Modified`` revalidation
|
|
7
|
+
(``If-None-Match`` / ``If-Modified-Since``);
|
|
8
|
+
* an honest ``User-Agent`` (``tilefetch/<version>``) - browser strings are refused.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import email.utils
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import random
|
|
17
|
+
import socket
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Callable, Iterable, Mapping
|
|
25
|
+
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
from .sources import PolicyError, TileSource
|
|
28
|
+
from .tiles import Tile
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"DEFAULT_USER_AGENT",
|
|
32
|
+
"RateLimiter",
|
|
33
|
+
"TileCache",
|
|
34
|
+
"TileResult",
|
|
35
|
+
"FetchStats",
|
|
36
|
+
"Downloader",
|
|
37
|
+
"check_user_agent",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
DEFAULT_USER_AGENT = f"tilefetch/{__version__} (+https://github.com/YusufEminoglu/tilefetch_sdk)"
|
|
41
|
+
_RETRY_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
|
42
|
+
_MISSING_STATUS = {204, 404, 410}
|
|
43
|
+
_BROWSER_MARKERS = ("mozilla/", "applewebkit", "chrome/", "safari/", "firefox/", "edg/", "trident/")
|
|
44
|
+
ProgressFn = Callable[[int, int], None]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_user_agent(user_agent: str) -> str:
|
|
48
|
+
"""Reject empty or browser-imitating User-Agent strings.
|
|
49
|
+
|
|
50
|
+
Tile operators identify and contact heavy users through the User-Agent;
|
|
51
|
+
pretending to be a browser defeats that and breaks most tile policies.
|
|
52
|
+
"""
|
|
53
|
+
ua = user_agent.strip()
|
|
54
|
+
if not ua:
|
|
55
|
+
raise PolicyError("User-Agent must not be empty; identify your application")
|
|
56
|
+
low = ua.lower()
|
|
57
|
+
if any(m in low for m in _BROWSER_MARKERS):
|
|
58
|
+
raise PolicyError(
|
|
59
|
+
"tilefetch does not send browser User-Agent strings. Identify your application "
|
|
60
|
+
"instead, e.g. 'myproject/1.0 (contact@example.org)'."
|
|
61
|
+
)
|
|
62
|
+
return ua
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RateLimiter:
|
|
66
|
+
"""Spaces requests at least ``1 / rate`` seconds apart across all threads."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, rate: float | None) -> None:
|
|
69
|
+
self.interval = 1.0 / rate if rate and rate > 0 else 0.0
|
|
70
|
+
self._lock = threading.Lock()
|
|
71
|
+
self._next = 0.0
|
|
72
|
+
|
|
73
|
+
def wait(self) -> None:
|
|
74
|
+
if not self.interval:
|
|
75
|
+
return
|
|
76
|
+
with self._lock:
|
|
77
|
+
now = time.monotonic()
|
|
78
|
+
slot = max(now, self._next)
|
|
79
|
+
self._next = slot + self.interval
|
|
80
|
+
delay = slot - now
|
|
81
|
+
if delay > 0:
|
|
82
|
+
time.sleep(delay)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TileCache:
|
|
86
|
+
"""On-disk HTTP cache: ``<root>/<source key>/<z>/<x>/<y>.tile`` plus a JSON sidecar."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, root: str | os.PathLike[str], source: TileSource) -> None:
|
|
89
|
+
self.root = os.path.join(os.fspath(root), source.cache_key)
|
|
90
|
+
|
|
91
|
+
def _paths(self, tile: Tile) -> tuple[str, str]:
|
|
92
|
+
z, x, y = tile
|
|
93
|
+
base = os.path.join(self.root, str(z), str(x), str(y))
|
|
94
|
+
return base + ".tile", base + ".json"
|
|
95
|
+
|
|
96
|
+
def get(self, tile: Tile) -> tuple[bytes, dict[str, str]] | None:
|
|
97
|
+
data_path, meta_path = self._paths(tile)
|
|
98
|
+
try:
|
|
99
|
+
with open(data_path, "rb") as fh:
|
|
100
|
+
data = fh.read()
|
|
101
|
+
meta: dict[str, str] = {}
|
|
102
|
+
if os.path.exists(meta_path):
|
|
103
|
+
with open(meta_path, encoding="utf-8") as fh:
|
|
104
|
+
meta = json.load(fh)
|
|
105
|
+
return data, meta
|
|
106
|
+
except OSError:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def put(self, tile: Tile, data: bytes, meta: Mapping[str, str]) -> None:
|
|
110
|
+
data_path, meta_path = self._paths(tile)
|
|
111
|
+
os.makedirs(os.path.dirname(data_path), exist_ok=True)
|
|
112
|
+
tmp = f"{data_path}.{threading.get_ident()}.tmp"
|
|
113
|
+
with open(tmp, "wb") as fh:
|
|
114
|
+
fh.write(data)
|
|
115
|
+
os.replace(tmp, data_path)
|
|
116
|
+
with open(meta_path, "w", encoding="utf-8") as fh:
|
|
117
|
+
json.dump(dict(meta), fh)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class TileResult:
|
|
122
|
+
tile: Tile
|
|
123
|
+
status: str # downloaded | cached | not_modified | missing | failed
|
|
124
|
+
data: bytes | None = None
|
|
125
|
+
error: str | None = None
|
|
126
|
+
attempts: int = 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class FetchStats:
|
|
131
|
+
total: int = 0
|
|
132
|
+
downloaded: int = 0
|
|
133
|
+
cached: int = 0
|
|
134
|
+
skipped: int = 0
|
|
135
|
+
missing: int = 0
|
|
136
|
+
failed: int = 0
|
|
137
|
+
bytes: int = 0
|
|
138
|
+
errors: list[tuple[Tile, str]] = field(default_factory=list)
|
|
139
|
+
|
|
140
|
+
def add(self, r: TileResult) -> None:
|
|
141
|
+
if r.status in ("cached", "not_modified"):
|
|
142
|
+
self.cached += 1
|
|
143
|
+
elif r.status == "downloaded":
|
|
144
|
+
self.downloaded += 1
|
|
145
|
+
elif r.status == "missing":
|
|
146
|
+
self.missing += 1
|
|
147
|
+
else:
|
|
148
|
+
self.failed += 1
|
|
149
|
+
if len(self.errors) < 100:
|
|
150
|
+
self.errors.append((r.tile, r.error or "unknown error"))
|
|
151
|
+
if r.data is not None and r.status == "downloaded":
|
|
152
|
+
self.bytes += len(r.data)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _retry_after(value: str | None) -> float | None:
|
|
156
|
+
if not value:
|
|
157
|
+
return None
|
|
158
|
+
try:
|
|
159
|
+
return max(0.0, float(value))
|
|
160
|
+
except ValueError:
|
|
161
|
+
pass
|
|
162
|
+
try:
|
|
163
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
164
|
+
except (TypeError, ValueError):
|
|
165
|
+
return None
|
|
166
|
+
return max(0.0, when.timestamp() - time.time())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Downloader:
|
|
170
|
+
"""Download tiles of one :class:`TileSource`.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
threads:
|
|
175
|
+
Worker threads (default 2). Capped at ``source.max_threads`` for
|
|
176
|
+
built-in providers.
|
|
177
|
+
rate:
|
|
178
|
+
Maximum requests per second across all threads (default: the
|
|
179
|
+
source's ``max_rate``).
|
|
180
|
+
retries:
|
|
181
|
+
Extra attempts after the first for retryable failures.
|
|
182
|
+
backoff:
|
|
183
|
+
Base delay in seconds; attempt ``n`` waits ``backoff * 2**n`` plus jitter.
|
|
184
|
+
cache_dir:
|
|
185
|
+
Folder for the on-disk HTTP cache, or ``None`` to disable it.
|
|
186
|
+
revalidate:
|
|
187
|
+
With a cache, send conditional requests (``If-None-Match`` /
|
|
188
|
+
``If-Modified-Since``) instead of trusting cached tiles blindly.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
source: TileSource,
|
|
194
|
+
*,
|
|
195
|
+
threads: int = 2,
|
|
196
|
+
rate: float | None = None,
|
|
197
|
+
retries: int = 4,
|
|
198
|
+
backoff: float = 0.5,
|
|
199
|
+
timeout: float = 30.0,
|
|
200
|
+
user_agent: str | None = None,
|
|
201
|
+
headers: Mapping[str, str] | None = None,
|
|
202
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
203
|
+
revalidate: bool = False,
|
|
204
|
+
max_backoff: float = 60.0,
|
|
205
|
+
) -> None:
|
|
206
|
+
if threads < 1:
|
|
207
|
+
raise ValueError("threads must be at least 1")
|
|
208
|
+
self.source = source
|
|
209
|
+
self.threads = min(threads, source.max_threads) if source.builtin else threads
|
|
210
|
+
if rate is None:
|
|
211
|
+
rate = source.max_rate
|
|
212
|
+
elif source.builtin and source.max_rate:
|
|
213
|
+
rate = min(rate, source.max_rate)
|
|
214
|
+
self.limiter = RateLimiter(rate)
|
|
215
|
+
self.retries = max(0, retries)
|
|
216
|
+
self.backoff = backoff
|
|
217
|
+
self.max_backoff = max_backoff
|
|
218
|
+
self.timeout = timeout
|
|
219
|
+
hdrs = {"User-Agent": DEFAULT_USER_AGENT, "Accept": "image/*,*/*;q=0.8"}
|
|
220
|
+
hdrs.update(source.headers)
|
|
221
|
+
hdrs.update(headers or {})
|
|
222
|
+
for k in list(hdrs):
|
|
223
|
+
if k.lower() == "user-agent" and k != "User-Agent":
|
|
224
|
+
hdrs["User-Agent"] = hdrs.pop(k)
|
|
225
|
+
if user_agent is not None:
|
|
226
|
+
hdrs["User-Agent"] = user_agent
|
|
227
|
+
check_user_agent(hdrs["User-Agent"])
|
|
228
|
+
self.headers = hdrs
|
|
229
|
+
self.cache = TileCache(cache_dir, source) if cache_dir else None
|
|
230
|
+
self.revalidate = revalidate
|
|
231
|
+
self.requests = 0
|
|
232
|
+
self._count_lock = threading.Lock()
|
|
233
|
+
|
|
234
|
+
# -- single tile ------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _request(self, url: str, extra: Mapping[str, str]) -> tuple[int, bytes, dict[str, str]]:
|
|
237
|
+
headers = dict(self.headers)
|
|
238
|
+
headers.update(extra)
|
|
239
|
+
req = urllib.request.Request(url, headers=headers)
|
|
240
|
+
self.limiter.wait()
|
|
241
|
+
with self._count_lock:
|
|
242
|
+
self.requests += 1
|
|
243
|
+
try:
|
|
244
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # noqa: S310
|
|
245
|
+
body = resp.read()
|
|
246
|
+
return resp.status, body, {k.lower(): v for k, v in resp.headers.items()}
|
|
247
|
+
except urllib.error.HTTPError as err:
|
|
248
|
+
hdrs = {k.lower(): v for k, v in (err.headers or {}).items()}
|
|
249
|
+
try:
|
|
250
|
+
body = err.read()
|
|
251
|
+
except Exception: # pragma: no cover - depends on the server
|
|
252
|
+
body = b""
|
|
253
|
+
return err.code, body, hdrs
|
|
254
|
+
|
|
255
|
+
def fetch_tile(self, tile: Tile) -> TileResult:
|
|
256
|
+
"""Fetch one tile (from cache, revalidated, or over HTTP)."""
|
|
257
|
+
tile = Tile(*tile)
|
|
258
|
+
cached = self.cache.get(tile) if self.cache else None
|
|
259
|
+
if cached is not None and not self.revalidate:
|
|
260
|
+
return TileResult(tile, "cached", cached[0])
|
|
261
|
+
try:
|
|
262
|
+
url = self.source.tile_url(tile)
|
|
263
|
+
except ValueError as exc:
|
|
264
|
+
return TileResult(tile, "failed", error=str(exc))
|
|
265
|
+
extra: dict[str, str] = {}
|
|
266
|
+
if cached is not None:
|
|
267
|
+
if cached[1].get("etag"):
|
|
268
|
+
extra["If-None-Match"] = cached[1]["etag"]
|
|
269
|
+
if cached[1].get("last-modified"):
|
|
270
|
+
extra["If-Modified-Since"] = cached[1]["last-modified"]
|
|
271
|
+
attempt = 0
|
|
272
|
+
last_error = ""
|
|
273
|
+
while True:
|
|
274
|
+
retry_after: float | None = None
|
|
275
|
+
try:
|
|
276
|
+
status, body, hdrs = self._request(url, extra)
|
|
277
|
+
except (
|
|
278
|
+
urllib.error.URLError,
|
|
279
|
+
socket.timeout,
|
|
280
|
+
TimeoutError,
|
|
281
|
+
ConnectionError,
|
|
282
|
+
OSError,
|
|
283
|
+
) as exc:
|
|
284
|
+
status, body, hdrs = -1, b"", {}
|
|
285
|
+
last_error = f"{type(exc).__name__}: {getattr(exc, 'reason', exc)}"
|
|
286
|
+
if status == 304 and cached is not None:
|
|
287
|
+
return TileResult(tile, "not_modified", cached[0], attempts=attempt + 1)
|
|
288
|
+
if status == 200:
|
|
289
|
+
ctype = hdrs.get("content-type", "").lower()
|
|
290
|
+
if ("html" in ctype or "xml" in ctype) and body.lstrip()[:1] == b"<":
|
|
291
|
+
snippet = body[:120].decode("utf-8", "replace").strip()
|
|
292
|
+
return TileResult(
|
|
293
|
+
tile,
|
|
294
|
+
"failed",
|
|
295
|
+
error=f"server returned {ctype} instead of a tile: {snippet}",
|
|
296
|
+
)
|
|
297
|
+
if self.cache is not None:
|
|
298
|
+
meta: dict[str, str] = {
|
|
299
|
+
k: hdrs[k] for k in ("etag", "last-modified", "content-type") if k in hdrs
|
|
300
|
+
}
|
|
301
|
+
self.cache.put(tile, body, meta)
|
|
302
|
+
return TileResult(tile, "downloaded", body, attempts=attempt + 1)
|
|
303
|
+
if status in _MISSING_STATUS:
|
|
304
|
+
return TileResult(tile, "missing", attempts=attempt + 1)
|
|
305
|
+
if status != -1:
|
|
306
|
+
last_error = f"HTTP {status}"
|
|
307
|
+
if status not in _RETRY_STATUS:
|
|
308
|
+
return TileResult(tile, "failed", error=last_error, attempts=attempt + 1)
|
|
309
|
+
retry_after = _retry_after(hdrs.get("retry-after"))
|
|
310
|
+
if attempt >= self.retries:
|
|
311
|
+
return TileResult(tile, "failed", error=last_error, attempts=attempt + 1)
|
|
312
|
+
delay = self.backoff * (2**attempt) + random.uniform(0, self.backoff)
|
|
313
|
+
if retry_after is not None:
|
|
314
|
+
delay = max(delay, retry_after)
|
|
315
|
+
time.sleep(min(delay, self.max_backoff))
|
|
316
|
+
attempt += 1
|
|
317
|
+
|
|
318
|
+
# -- many tiles -------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def iter_fetch(self, tiles: Iterable[Tile]) -> Iterable[TileResult]:
|
|
321
|
+
"""Yield results as they complete, keeping a bounded number in flight."""
|
|
322
|
+
window = self.threads * 4
|
|
323
|
+
with ThreadPoolExecutor(max_workers=self.threads, thread_name_prefix="tilefetch") as pool:
|
|
324
|
+
pending: set[Future[TileResult]] = set()
|
|
325
|
+
for t in tiles:
|
|
326
|
+
pending.add(pool.submit(self.fetch_tile, t))
|
|
327
|
+
if len(pending) >= window:
|
|
328
|
+
done, pending = wait(pending, return_when=FIRST_COMPLETED)
|
|
329
|
+
for f in done:
|
|
330
|
+
yield f.result()
|
|
331
|
+
while pending:
|
|
332
|
+
done, pending = wait(pending, return_when=FIRST_COMPLETED)
|
|
333
|
+
for f in done:
|
|
334
|
+
yield f.result()
|
tilefetch/georef.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Write georeferenced images: PNG/JPEG/WebP + world file + .prj, and GeoTIFF.
|
|
2
|
+
|
|
3
|
+
The GeoTIFF writer needs only Pillow: it stores ModelPixelScaleTag,
|
|
4
|
+
ModelTiepointTag and a GeoKeyDirectoryTag (GeoTIFF 1.0 keys) describing
|
|
5
|
+
EPSG:3857 or EPSG:4326, which GDAL/rasterio/QGIS read as a proper CRS. With
|
|
6
|
+
``engine="rasterio"`` the file is written through GDAL instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, NamedTuple
|
|
13
|
+
|
|
14
|
+
from PIL import Image, TiffImagePlugin, TiffTags
|
|
15
|
+
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"GeoTransform",
|
|
20
|
+
"WKT_3857",
|
|
21
|
+
"WKT_4326",
|
|
22
|
+
"world_file_lines",
|
|
23
|
+
"world_file_path",
|
|
24
|
+
"write_world_file",
|
|
25
|
+
"write_prj",
|
|
26
|
+
"write_geotiff",
|
|
27
|
+
"save_georeferenced",
|
|
28
|
+
"geokeys",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
WKT_3857 = (
|
|
32
|
+
'PROJCS["WGS_1984_Web_Mercator_Auxiliary_Sphere",GEOGCS["GCS_WGS_1984",'
|
|
33
|
+
'DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],'
|
|
34
|
+
'PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],'
|
|
35
|
+
'PROJECTION["Mercator_Auxiliary_Sphere"],PARAMETER["False_Easting",0.0],'
|
|
36
|
+
'PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",0.0],'
|
|
37
|
+
'PARAMETER["Standard_Parallel_1",0.0],PARAMETER["Auxiliary_Sphere_Type",0.0],'
|
|
38
|
+
'UNIT["Meter",1.0]]'
|
|
39
|
+
)
|
|
40
|
+
WKT_4326 = (
|
|
41
|
+
'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],'
|
|
42
|
+
'PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# GeoTIFF tag numbers
|
|
46
|
+
_MODEL_PIXEL_SCALE = 33550
|
|
47
|
+
_MODEL_TIEPOINT = 33922
|
|
48
|
+
_GEO_KEY_DIRECTORY = 34735
|
|
49
|
+
_GEO_ASCII_PARAMS = 34737
|
|
50
|
+
_COPYRIGHT = 33432
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GeoTransform(NamedTuple):
|
|
54
|
+
"""North-up raster georeferencing: top-left corner and pixel size."""
|
|
55
|
+
|
|
56
|
+
minx: float
|
|
57
|
+
maxy: float
|
|
58
|
+
pixel_width: float
|
|
59
|
+
pixel_height: float # positive; rows go downwards
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_bounds(
|
|
63
|
+
cls, bounds: tuple[float, float, float, float], width: int, height: int
|
|
64
|
+
) -> GeoTransform:
|
|
65
|
+
minx, miny, maxx, maxy = bounds
|
|
66
|
+
return cls(minx, maxy, (maxx - minx) / width, (maxy - miny) / height)
|
|
67
|
+
|
|
68
|
+
def bounds(self, width: int, height: int) -> tuple[float, float, float, float]:
|
|
69
|
+
return (
|
|
70
|
+
self.minx,
|
|
71
|
+
self.maxy - self.pixel_height * height,
|
|
72
|
+
self.minx + self.pixel_width * width,
|
|
73
|
+
self.maxy,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def to_gdal(self) -> tuple[float, float, float, float, float, float]:
|
|
77
|
+
"""GDAL-style geotransform ``(c, a, b, f, d, e)``."""
|
|
78
|
+
return (self.minx, self.pixel_width, 0.0, self.maxy, 0.0, -self.pixel_height)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _epsg(crs: str | int) -> int:
|
|
82
|
+
s = str(crs).upper().replace("EPSG:", "")
|
|
83
|
+
if s in ("3857", "900913"):
|
|
84
|
+
return 3857
|
|
85
|
+
if s == "4326":
|
|
86
|
+
return 4326
|
|
87
|
+
raise ValueError(f"unsupported CRS {crs!r}; use EPSG:3857 or EPSG:4326")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def world_file_lines(gt: GeoTransform) -> list[str]:
|
|
91
|
+
"""The six world-file lines; C/F refer to the centre of the top-left pixel."""
|
|
92
|
+
return [
|
|
93
|
+
repr(gt.pixel_width),
|
|
94
|
+
"0.0",
|
|
95
|
+
"0.0",
|
|
96
|
+
repr(-gt.pixel_height),
|
|
97
|
+
repr(gt.minx + gt.pixel_width / 2.0),
|
|
98
|
+
repr(gt.maxy - gt.pixel_height / 2.0),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def world_file_path(image_path: str | os.PathLike[str]) -> str:
|
|
103
|
+
"""``x.png`` -> ``x.pgw``, ``x.jpg`` -> ``x.jgw``, ``x.tif`` -> ``x.tfw``, else ``.wld``."""
|
|
104
|
+
root, ext = os.path.splitext(os.fspath(image_path))
|
|
105
|
+
ext = ext.lower().lstrip(".")
|
|
106
|
+
special = {"png": "pgw", "jpg": "jgw", "jpeg": "jgw", "tif": "tfw", "tiff": "tfw"}
|
|
107
|
+
return f"{root}.{special.get(ext, 'wld')}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def write_world_file(image_path: str | os.PathLike[str], gt: GeoTransform) -> str:
|
|
111
|
+
path = world_file_path(image_path)
|
|
112
|
+
with open(path, "w", encoding="ascii", newline="\n") as fh:
|
|
113
|
+
fh.write("\n".join(world_file_lines(gt)) + "\n")
|
|
114
|
+
return path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def write_prj(image_path: str | os.PathLike[str], crs: str | int = "EPSG:3857") -> str:
|
|
118
|
+
path = os.path.splitext(os.fspath(image_path))[0] + ".prj"
|
|
119
|
+
with open(path, "w", encoding="ascii", newline="\n") as fh:
|
|
120
|
+
fh.write(WKT_3857 if _epsg(crs) == 3857 else WKT_4326)
|
|
121
|
+
return path
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def geokeys(crs: str | int) -> tuple[int, ...]:
|
|
125
|
+
"""GeoKeyDirectoryTag values (KeyDirectoryVersion 1, revision 1.0)."""
|
|
126
|
+
epsg = _epsg(crs)
|
|
127
|
+
if epsg == 3857:
|
|
128
|
+
keys = [
|
|
129
|
+
(1024, 0, 1, 1), # GTModelTypeGeoKey = ModelTypeProjected
|
|
130
|
+
(1025, 0, 1, 1), # GTRasterTypeGeoKey = RasterPixelIsArea
|
|
131
|
+
(3072, 0, 1, 3857), # ProjectedCSTypeGeoKey
|
|
132
|
+
(3076, 0, 1, 9001), # ProjLinearUnitsGeoKey = metre
|
|
133
|
+
]
|
|
134
|
+
else:
|
|
135
|
+
keys = [
|
|
136
|
+
(1024, 0, 1, 2), # ModelTypeGeographic
|
|
137
|
+
(1025, 0, 1, 1),
|
|
138
|
+
(2048, 0, 1, 4326), # GeographicTypeGeoKey
|
|
139
|
+
(2054, 0, 1, 9102), # GeogAngularUnitsGeoKey = degree
|
|
140
|
+
]
|
|
141
|
+
out = [1, 1, 0, len(keys)]
|
|
142
|
+
for k in keys:
|
|
143
|
+
out.extend(k)
|
|
144
|
+
return tuple(out)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _prepare(image: Image.Image, fmt: str) -> Image.Image:
|
|
148
|
+
if image.mode not in ("RGB", "RGBA", "L", "LA"):
|
|
149
|
+
image = image.convert("RGBA")
|
|
150
|
+
if image.mode in ("RGBA", "LA"):
|
|
151
|
+
alpha = image.getchannel("A")
|
|
152
|
+
if alpha.getextrema()[0] == 255: # fully opaque: drop the alpha band
|
|
153
|
+
image = image.convert("RGB" if image.mode == "RGBA" else "L")
|
|
154
|
+
elif fmt == "jpeg":
|
|
155
|
+
bg = Image.new("RGB", image.size, (255, 255, 255))
|
|
156
|
+
bg.paste(image.convert("RGBA"), mask=alpha)
|
|
157
|
+
image = bg
|
|
158
|
+
return image
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def write_geotiff(
|
|
162
|
+
path: str | os.PathLike[str],
|
|
163
|
+
image: Image.Image,
|
|
164
|
+
gt: GeoTransform,
|
|
165
|
+
crs: str | int = "EPSG:3857",
|
|
166
|
+
*,
|
|
167
|
+
attribution: str = "",
|
|
168
|
+
engine: str = "pillow",
|
|
169
|
+
compression: str | None = "deflate",
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Write ``image`` as a GeoTIFF.
|
|
172
|
+
|
|
173
|
+
``engine="pillow"`` (default) needs nothing beyond Pillow;
|
|
174
|
+
``engine="rasterio"`` writes through GDAL. ``compression`` is
|
|
175
|
+
``"deflate"``, ``"lzw"`` or ``None``.
|
|
176
|
+
"""
|
|
177
|
+
path = os.fspath(path)
|
|
178
|
+
image = _prepare(image, "tiff")
|
|
179
|
+
epsg = _epsg(crs)
|
|
180
|
+
if engine == "rasterio":
|
|
181
|
+
return _write_geotiff_rasterio(path, image, gt, epsg, attribution, compression)
|
|
182
|
+
if engine != "pillow":
|
|
183
|
+
raise ValueError("engine must be 'pillow' or 'rasterio'")
|
|
184
|
+
ifd = TiffImagePlugin.ImageFileDirectory_v2()
|
|
185
|
+
ifd[_MODEL_PIXEL_SCALE] = (float(gt.pixel_width), float(gt.pixel_height), 0.0)
|
|
186
|
+
ifd.tagtype[_MODEL_PIXEL_SCALE] = TiffTags.DOUBLE
|
|
187
|
+
ifd[_MODEL_TIEPOINT] = (0.0, 0.0, 0.0, float(gt.minx), float(gt.maxy), 0.0)
|
|
188
|
+
ifd.tagtype[_MODEL_TIEPOINT] = TiffTags.DOUBLE
|
|
189
|
+
ifd[_GEO_KEY_DIRECTORY] = geokeys(epsg)
|
|
190
|
+
ifd.tagtype[_GEO_KEY_DIRECTORY] = TiffTags.SHORT
|
|
191
|
+
ifd[305] = f"tilefetch {__version__}" # Software
|
|
192
|
+
if attribution:
|
|
193
|
+
ifd[270] = attribution # ImageDescription
|
|
194
|
+
ifd[_COPYRIGHT] = attribution
|
|
195
|
+
ifd.tagtype[_COPYRIGHT] = TiffTags.ASCII
|
|
196
|
+
comp = {"deflate": "tiff_deflate", "lzw": "tiff_lzw", None: None}[compression]
|
|
197
|
+
tmp = path + ".tmp.tif"
|
|
198
|
+
image.save(tmp, format="TIFF", tiffinfo=ifd, compression=comp)
|
|
199
|
+
os.replace(tmp, path)
|
|
200
|
+
return path
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _write_geotiff_rasterio(
|
|
204
|
+
path: str,
|
|
205
|
+
image: Image.Image,
|
|
206
|
+
gt: GeoTransform,
|
|
207
|
+
epsg: int,
|
|
208
|
+
attribution: str,
|
|
209
|
+
compression: str | None,
|
|
210
|
+
) -> str:
|
|
211
|
+
try:
|
|
212
|
+
import numpy as np
|
|
213
|
+
import rasterio
|
|
214
|
+
from rasterio.enums import ColorInterp
|
|
215
|
+
from rasterio.transform import Affine
|
|
216
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
217
|
+
raise ImportError("engine='rasterio' needs: pip install 'tilefetch[rasterio]'") from exc
|
|
218
|
+
arr = np.asarray(image)
|
|
219
|
+
if arr.ndim == 2:
|
|
220
|
+
arr = arr[np.newaxis, :, :]
|
|
221
|
+
else:
|
|
222
|
+
arr = np.transpose(arr, (2, 0, 1))
|
|
223
|
+
profile: dict[str, Any] = {
|
|
224
|
+
"driver": "GTiff",
|
|
225
|
+
"width": image.width,
|
|
226
|
+
"height": image.height,
|
|
227
|
+
"count": arr.shape[0],
|
|
228
|
+
"dtype": "uint8",
|
|
229
|
+
"crs": f"EPSG:{epsg}",
|
|
230
|
+
"transform": Affine(gt.pixel_width, 0.0, gt.minx, 0.0, -gt.pixel_height, gt.maxy),
|
|
231
|
+
"tiled": True,
|
|
232
|
+
"blockxsize": 256,
|
|
233
|
+
"blockysize": 256,
|
|
234
|
+
}
|
|
235
|
+
if compression:
|
|
236
|
+
profile["compress"] = compression
|
|
237
|
+
if arr.shape[0] >= 3:
|
|
238
|
+
profile["photometric"] = "RGB"
|
|
239
|
+
with rasterio.open(path, "w", **profile) as ds:
|
|
240
|
+
ds.write(arr)
|
|
241
|
+
if arr.shape[0] == 4:
|
|
242
|
+
ds.colorinterp = [
|
|
243
|
+
ColorInterp.red,
|
|
244
|
+
ColorInterp.green,
|
|
245
|
+
ColorInterp.blue,
|
|
246
|
+
ColorInterp.alpha,
|
|
247
|
+
]
|
|
248
|
+
tags = {"TIFFTAG_SOFTWARE": f"tilefetch {__version__}"}
|
|
249
|
+
if attribution:
|
|
250
|
+
tags["TIFFTAG_COPYRIGHT"] = attribution
|
|
251
|
+
tags["TIFFTAG_IMAGEDESCRIPTION"] = attribution
|
|
252
|
+
ds.update_tags(**tags)
|
|
253
|
+
return path
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def save_georeferenced(
|
|
257
|
+
path: str | os.PathLike[str],
|
|
258
|
+
image: Image.Image,
|
|
259
|
+
gt: GeoTransform,
|
|
260
|
+
crs: str | int = "EPSG:3857",
|
|
261
|
+
*,
|
|
262
|
+
attribution: str = "",
|
|
263
|
+
engine: str = "pillow",
|
|
264
|
+
) -> list[str]:
|
|
265
|
+
"""Save an image with georeferencing chosen by extension.
|
|
266
|
+
|
|
267
|
+
``.tif``/``.tiff`` -> GeoTIFF; ``.png``/``.jpg``/``.webp`` -> image plus
|
|
268
|
+
world file and ``.prj``. Returns the paths written.
|
|
269
|
+
"""
|
|
270
|
+
p = os.fspath(path)
|
|
271
|
+
ext = os.path.splitext(p)[1].lower()
|
|
272
|
+
if ext in (".tif", ".tiff"):
|
|
273
|
+
return [write_geotiff(p, image, gt, crs, attribution=attribution, engine=engine)]
|
|
274
|
+
fmt = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".webp": "webp"}.get(ext)
|
|
275
|
+
if fmt is None:
|
|
276
|
+
raise ValueError(f"unsupported image extension {ext!r}")
|
|
277
|
+
img = _prepare(image, fmt)
|
|
278
|
+
kwargs: dict[str, Any] = {}
|
|
279
|
+
if fmt == "png":
|
|
280
|
+
from PIL import PngImagePlugin
|
|
281
|
+
|
|
282
|
+
info = PngImagePlugin.PngInfo()
|
|
283
|
+
info.add_text("Software", f"tilefetch {__version__}")
|
|
284
|
+
if attribution:
|
|
285
|
+
info.add_text("Copyright", attribution)
|
|
286
|
+
kwargs["pnginfo"] = info
|
|
287
|
+
kwargs["optimize"] = True
|
|
288
|
+
elif fmt == "jpeg":
|
|
289
|
+
kwargs["quality"] = 92
|
|
290
|
+
img.save(p, format=fmt.upper(), **kwargs)
|
|
291
|
+
return [p, write_world_file(p, gt), write_prj(p, crs)]
|