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/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)]