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/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""tilefetch: download map tiles for an area, politely and correctly.
|
|
2
|
+
|
|
3
|
+
Fetch XYZ / TMS / WMTS tiles for a bbox or GeoJSON polygon into MBTiles,
|
|
4
|
+
PMTiles v3, a ``z/x/y`` folder, or one georeferenced image (GeoTIFF, or
|
|
5
|
+
PNG/JPEG + world file + ``.prj``).
|
|
6
|
+
|
|
7
|
+
>>> import tilefetch
|
|
8
|
+
>>> tilefetch.tiles_for_bbox((28.97, 41.00, 28.99, 41.02), 14)[0]
|
|
9
|
+
Tile(z=14, x=9510, y=6141)
|
|
10
|
+
|
|
11
|
+
Respect each provider's terms of use: see ``tilefetch.list_providers()``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .api import (
|
|
16
|
+
Estimate,
|
|
17
|
+
FetchResult,
|
|
18
|
+
MosaicResult,
|
|
19
|
+
estimate,
|
|
20
|
+
fetch,
|
|
21
|
+
mosaic,
|
|
22
|
+
plan_tiles,
|
|
23
|
+
resolve_source,
|
|
24
|
+
)
|
|
25
|
+
from .download import DEFAULT_USER_AGENT, Downloader, FetchStats
|
|
26
|
+
from .georef import GeoTransform, save_georeferenced, write_geotiff
|
|
27
|
+
from .pmtiles import PMTilesReader, PMTilesWriter, tileid_to_zxy, zxy_to_tileid
|
|
28
|
+
from .providers import PROVIDERS, get_provider, list_providers, register_provider
|
|
29
|
+
from .sources import (
|
|
30
|
+
RESTRICTED_HOSTS,
|
|
31
|
+
PolicyError,
|
|
32
|
+
TileLimitError,
|
|
33
|
+
TileSource,
|
|
34
|
+
check_url_policy,
|
|
35
|
+
parse_wmts_capabilities,
|
|
36
|
+
wmts_source,
|
|
37
|
+
)
|
|
38
|
+
from .storage import DirectoryStore, MBTilesStore, PMTilesStore, open_reader, open_store
|
|
39
|
+
from .tiles import (
|
|
40
|
+
BBox,
|
|
41
|
+
Tile,
|
|
42
|
+
flip_y,
|
|
43
|
+
lonlat_to_mercator,
|
|
44
|
+
lonlat_to_tile,
|
|
45
|
+
mercator_to_lonlat,
|
|
46
|
+
parse_bbox,
|
|
47
|
+
parse_zoom,
|
|
48
|
+
quadkey_to_tile,
|
|
49
|
+
resolution,
|
|
50
|
+
tile_bounds_lonlat,
|
|
51
|
+
tile_bounds_mercator,
|
|
52
|
+
tile_count,
|
|
53
|
+
tile_to_quadkey,
|
|
54
|
+
tiles_for_bbox,
|
|
55
|
+
tiles_for_geometry,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"__version__",
|
|
60
|
+
"fetch",
|
|
61
|
+
"mosaic",
|
|
62
|
+
"estimate",
|
|
63
|
+
"tiles_for_bbox",
|
|
64
|
+
"tiles_for_geometry",
|
|
65
|
+
"tile_count",
|
|
66
|
+
"plan_tiles",
|
|
67
|
+
"resolve_source",
|
|
68
|
+
"FetchResult",
|
|
69
|
+
"MosaicResult",
|
|
70
|
+
"Estimate",
|
|
71
|
+
"FetchStats",
|
|
72
|
+
"Downloader",
|
|
73
|
+
"DEFAULT_USER_AGENT",
|
|
74
|
+
"TileSource",
|
|
75
|
+
"wmts_source",
|
|
76
|
+
"parse_wmts_capabilities",
|
|
77
|
+
"PolicyError",
|
|
78
|
+
"TileLimitError",
|
|
79
|
+
"RESTRICTED_HOSTS",
|
|
80
|
+
"check_url_policy",
|
|
81
|
+
"PROVIDERS",
|
|
82
|
+
"get_provider",
|
|
83
|
+
"list_providers",
|
|
84
|
+
"register_provider",
|
|
85
|
+
"MBTilesStore",
|
|
86
|
+
"PMTilesStore",
|
|
87
|
+
"DirectoryStore",
|
|
88
|
+
"PMTilesReader",
|
|
89
|
+
"PMTilesWriter",
|
|
90
|
+
"open_store",
|
|
91
|
+
"open_reader",
|
|
92
|
+
"zxy_to_tileid",
|
|
93
|
+
"tileid_to_zxy",
|
|
94
|
+
"GeoTransform",
|
|
95
|
+
"save_georeferenced",
|
|
96
|
+
"write_geotiff",
|
|
97
|
+
"Tile",
|
|
98
|
+
"BBox",
|
|
99
|
+
"lonlat_to_tile",
|
|
100
|
+
"lonlat_to_mercator",
|
|
101
|
+
"mercator_to_lonlat",
|
|
102
|
+
"tile_bounds_mercator",
|
|
103
|
+
"tile_bounds_lonlat",
|
|
104
|
+
"tile_to_quadkey",
|
|
105
|
+
"quadkey_to_tile",
|
|
106
|
+
"flip_y",
|
|
107
|
+
"resolution",
|
|
108
|
+
"parse_bbox",
|
|
109
|
+
"parse_zoom",
|
|
110
|
+
]
|
tilefetch/__main__.py
ADDED
tilefetch/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tilefetch/api.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""High-level API: :func:`fetch`, :func:`mosaic`, :func:`estimate`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Callable, Mapping
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .download import Downloader, FetchStats, TileResult
|
|
14
|
+
from .georef import GeoTransform, save_georeferenced
|
|
15
|
+
from .mosaic import crop_mercator, stitch, warp_to_lonlat
|
|
16
|
+
from .providers import PROVIDERS
|
|
17
|
+
from .sources import TileLimitError, TileSource
|
|
18
|
+
from .storage import TileStore, open_reader, open_store, store_kind
|
|
19
|
+
from .tiles import (
|
|
20
|
+
BBox,
|
|
21
|
+
BBoxLike,
|
|
22
|
+
Tile,
|
|
23
|
+
ZoomLike,
|
|
24
|
+
_normalise_crs,
|
|
25
|
+
bbox_to_mercator,
|
|
26
|
+
geometry_bbox,
|
|
27
|
+
load_geometry,
|
|
28
|
+
mercator_to_lonlat,
|
|
29
|
+
parse_bbox,
|
|
30
|
+
parse_zoom,
|
|
31
|
+
resolution,
|
|
32
|
+
tiles_for_bbox,
|
|
33
|
+
tiles_for_polygons,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"resolve_source",
|
|
38
|
+
"plan_tiles",
|
|
39
|
+
"fetch",
|
|
40
|
+
"mosaic",
|
|
41
|
+
"estimate",
|
|
42
|
+
"FetchResult",
|
|
43
|
+
"MosaicResult",
|
|
44
|
+
"Estimate",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
ProgressFn = Callable[[int, int], None]
|
|
48
|
+
|
|
49
|
+
#: Typical tile sizes used by :func:`estimate` when no sample is taken.
|
|
50
|
+
TYPICAL_TILE_BYTES = {"png": 30_000, "jpg": 20_000, "webp": 14_000, None: 25_000}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_source(
|
|
54
|
+
source: Any, *, params: Mapping[str, str] | None = None, **overrides: Any
|
|
55
|
+
) -> TileSource:
|
|
56
|
+
"""Turn a provider name, URL template or :class:`TileSource` into a :class:`TileSource`.
|
|
57
|
+
|
|
58
|
+
``params`` fills or overrides template parameters (e.g. ``{"time": "2025-07-01"}``).
|
|
59
|
+
``overrides`` replace other fields (``attribution``, ``scheme``, ...); on
|
|
60
|
+
built-in providers only ``headers`` and ``params`` may be changed.
|
|
61
|
+
"""
|
|
62
|
+
if isinstance(source, TileSource):
|
|
63
|
+
src = source
|
|
64
|
+
elif isinstance(source, str) and source in PROVIDERS:
|
|
65
|
+
src = PROVIDERS[source]
|
|
66
|
+
elif isinstance(source, str) and "{" in source and "://" in source:
|
|
67
|
+
merged = dict(params or {})
|
|
68
|
+
params = None
|
|
69
|
+
src = TileSource.from_url(source, params=merged, **overrides)
|
|
70
|
+
overrides = {}
|
|
71
|
+
else:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"cannot interpret source {source!r}: use a provider name "
|
|
74
|
+
f"({', '.join(sorted(PROVIDERS))}), a URL template containing {{z}}/{{x}}/{{y}}, "
|
|
75
|
+
"or a TileSource (see tilefetch.wmts_source for WMTS)"
|
|
76
|
+
)
|
|
77
|
+
if params:
|
|
78
|
+
src = src.replace(params={**src.params, **params})
|
|
79
|
+
if overrides:
|
|
80
|
+
if src.builtin:
|
|
81
|
+
bad = set(overrides) - {"headers"}
|
|
82
|
+
if bad:
|
|
83
|
+
raise ValueError(f"built-in provider settings cannot be overridden: {sorted(bad)}")
|
|
84
|
+
src = src.replace(**overrides)
|
|
85
|
+
return src
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _area(
|
|
89
|
+
bbox: BBoxLike | None, geometry: Any, zooms: list[int], bbox_crs: str
|
|
90
|
+
) -> tuple[list[Tile], BBox]:
|
|
91
|
+
"""Tiles to fetch and the lon/lat bounds of the request."""
|
|
92
|
+
if geometry is not None:
|
|
93
|
+
polys = load_geometry(geometry)
|
|
94
|
+
return tiles_for_polygons(polys, zooms), geometry_bbox(polys)
|
|
95
|
+
if bbox is None:
|
|
96
|
+
raise ValueError("give either bbox or geometry")
|
|
97
|
+
crs = _normalise_crs(bbox_crs)
|
|
98
|
+
b = parse_bbox(bbox)
|
|
99
|
+
tiles = tiles_for_bbox(b, zooms, crs)
|
|
100
|
+
if crs == "EPSG:3857":
|
|
101
|
+
w, s = mercator_to_lonlat(b.minx, b.miny)
|
|
102
|
+
e, n = mercator_to_lonlat(b.maxx, b.maxy)
|
|
103
|
+
b = BBox(w, s, e, n)
|
|
104
|
+
return tiles, b
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def plan_tiles(
|
|
108
|
+
source: TileSource,
|
|
109
|
+
bbox: BBoxLike | None,
|
|
110
|
+
zoom: ZoomLike,
|
|
111
|
+
*,
|
|
112
|
+
geometry: Any = None,
|
|
113
|
+
bbox_crs: str = "EPSG:4326",
|
|
114
|
+
) -> tuple[list[Tile], BBox, list[int]]:
|
|
115
|
+
"""Validate zooms against the source and list the tiles of a request."""
|
|
116
|
+
zooms = parse_zoom(zoom)
|
|
117
|
+
bad = [z for z in zooms if z < source.min_zoom or z > source.max_zoom]
|
|
118
|
+
if bad:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"zoom {bad} outside the range of {source.name!r} ({source.min_zoom}-{source.max_zoom})"
|
|
121
|
+
)
|
|
122
|
+
tiles, bounds = _area(bbox, geometry, zooms, bbox_crs)
|
|
123
|
+
return tiles, bounds, zooms
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _tile_cap(source: TileSource, max_tiles: int | None) -> int | None:
|
|
127
|
+
if source.builtin and source.max_tiles is not None:
|
|
128
|
+
return source.max_tiles if max_tiles is None else min(max_tiles, source.max_tiles)
|
|
129
|
+
return source.max_tiles if max_tiles is None else max_tiles
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_cap(source: TileSource, n: int, max_tiles: int | None) -> None:
|
|
133
|
+
cap = _tile_cap(source, max_tiles)
|
|
134
|
+
if cap is not None and n > cap:
|
|
135
|
+
hint = (
|
|
136
|
+
"This limit is part of the provider's entry and cannot be raised; split the job "
|
|
137
|
+
"or lower the zoom."
|
|
138
|
+
if source.builtin
|
|
139
|
+
else "Pass max_tiles=... (CLI: --max-tiles) if you operate the server or its terms allow it."
|
|
140
|
+
)
|
|
141
|
+
raise TileLimitError(
|
|
142
|
+
f"request needs {n} tiles but {source.name!r} allows {cap} per job. {hint}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _announce(source: TileSource, quiet: bool) -> None:
|
|
147
|
+
if quiet:
|
|
148
|
+
return
|
|
149
|
+
lines = []
|
|
150
|
+
if source.attribution:
|
|
151
|
+
lines.append(f"Attribution: {source.attribution}")
|
|
152
|
+
if source.terms_url:
|
|
153
|
+
lines.append(f"Terms of use: {source.terms_url}")
|
|
154
|
+
if not source.builtin and not source.attribution:
|
|
155
|
+
lines.append(
|
|
156
|
+
"Note: no attribution given for this custom source; check the provider's terms and "
|
|
157
|
+
"credit it in your outputs."
|
|
158
|
+
)
|
|
159
|
+
for line in lines:
|
|
160
|
+
print(f"[tilefetch] {line}", file=sys.stderr)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _metadata(
|
|
164
|
+
source: TileSource, bounds: BBox, zooms: list[int], fmt: str | None, name: str | None
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
w, s, e, n = bounds
|
|
167
|
+
w, e = max(-180.0, w), min(180.0, e)
|
|
168
|
+
s, n = max(-85.0511287798, s), min(85.0511287798, n)
|
|
169
|
+
if w > e: # antimeridian: MBTiles bounds cannot express it
|
|
170
|
+
w, e = -180.0, 180.0
|
|
171
|
+
cz = zooms[0]
|
|
172
|
+
return {
|
|
173
|
+
"name": name or source.name,
|
|
174
|
+
"format": fmt or source.format or "png",
|
|
175
|
+
"bounds": [round(w, 7), round(s, 7), round(e, 7), round(n, 7)],
|
|
176
|
+
"center": [round((w + e) / 2, 7), round((s + n) / 2, 7), cz],
|
|
177
|
+
"minzoom": zooms[0],
|
|
178
|
+
"maxzoom": zooms[-1],
|
|
179
|
+
"attribution": source.attribution,
|
|
180
|
+
"description": source.description or f"Tiles from {source.name}",
|
|
181
|
+
"type": "baselayer",
|
|
182
|
+
"version": "1",
|
|
183
|
+
"generator": f"tilefetch {__version__}",
|
|
184
|
+
**({"terms_url": source.terms_url} if source.terms_url else {}),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class FetchResult:
|
|
190
|
+
"""Outcome of :func:`fetch`."""
|
|
191
|
+
|
|
192
|
+
path: str
|
|
193
|
+
stats: FetchStats
|
|
194
|
+
tiles: int
|
|
195
|
+
zooms: list[int]
|
|
196
|
+
bounds: BBox
|
|
197
|
+
attribution: str
|
|
198
|
+
terms_url: str | None
|
|
199
|
+
files: list[str] = field(default_factory=list)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def ok(self) -> bool:
|
|
203
|
+
return self.stats.failed == 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _run_download(
|
|
207
|
+
dl: Downloader,
|
|
208
|
+
tiles: list[Tile],
|
|
209
|
+
store: TileStore | None,
|
|
210
|
+
resume: bool,
|
|
211
|
+
progress: ProgressFn | None,
|
|
212
|
+
sink: Callable[[TileResult], None] | None = None,
|
|
213
|
+
) -> FetchStats:
|
|
214
|
+
stats = FetchStats(total=len(tiles))
|
|
215
|
+
todo = tiles
|
|
216
|
+
if store is not None and resume:
|
|
217
|
+
todo = [t for t in tiles if not store.has(t)]
|
|
218
|
+
stats.skipped = len(tiles) - len(todo)
|
|
219
|
+
done = stats.skipped
|
|
220
|
+
if progress:
|
|
221
|
+
progress(done, stats.total)
|
|
222
|
+
for r in dl.iter_fetch(todo):
|
|
223
|
+
stats.add(r)
|
|
224
|
+
if r.data is not None:
|
|
225
|
+
if store is not None:
|
|
226
|
+
store.put(r.tile, r.data)
|
|
227
|
+
if sink is not None:
|
|
228
|
+
sink(r)
|
|
229
|
+
done += 1
|
|
230
|
+
if progress:
|
|
231
|
+
progress(done, stats.total)
|
|
232
|
+
return stats
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def fetch(
|
|
236
|
+
source: Any,
|
|
237
|
+
bbox: BBoxLike | None = None,
|
|
238
|
+
zoom: ZoomLike = 12,
|
|
239
|
+
out: str | os.PathLike[str] = "tiles.mbtiles",
|
|
240
|
+
*,
|
|
241
|
+
geometry: Any = None,
|
|
242
|
+
bbox_crs: str = "EPSG:4326",
|
|
243
|
+
threads: int = 2,
|
|
244
|
+
rate: float | None = None,
|
|
245
|
+
max_tiles: int | None = None,
|
|
246
|
+
retries: int = 4,
|
|
247
|
+
backoff: float = 0.5,
|
|
248
|
+
timeout: float = 30.0,
|
|
249
|
+
user_agent: str | None = None,
|
|
250
|
+
headers: Mapping[str, str] | None = None,
|
|
251
|
+
params: Mapping[str, str] | None = None,
|
|
252
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
253
|
+
revalidate: bool = False,
|
|
254
|
+
resume: bool = True,
|
|
255
|
+
overwrite: bool = False,
|
|
256
|
+
name: str | None = None,
|
|
257
|
+
quiet: bool = False,
|
|
258
|
+
progress: ProgressFn | None = None,
|
|
259
|
+
**mosaic_kwargs: Any,
|
|
260
|
+
) -> FetchResult:
|
|
261
|
+
"""Download the tiles of an area into ``out``.
|
|
262
|
+
|
|
263
|
+
The output type follows the extension: ``.mbtiles``, ``.pmtiles``, an
|
|
264
|
+
image (``.tif``, ``.png``, ``.jpg``, ``.webp``: a georeferenced mosaic of
|
|
265
|
+
the highest zoom, see :func:`mosaic`), or anything else for a ``z/x/y``
|
|
266
|
+
folder. Existing tiles in the output are kept and skipped (``resume``)
|
|
267
|
+
unless ``overwrite=True``.
|
|
268
|
+
|
|
269
|
+
``source`` is a provider name (see :func:`tilefetch.list_providers`), a
|
|
270
|
+
URL template such as ``"https://tiles.example.org/{z}/{x}/{y}.png"``, or a
|
|
271
|
+
:class:`TileSource`. The area is ``bbox`` (``west, south, east, north`` in
|
|
272
|
+
degrees, or EPSG:3857 with ``bbox_crs``) or a GeoJSON ``geometry``.
|
|
273
|
+
"""
|
|
274
|
+
src = resolve_source(source, params=params)
|
|
275
|
+
out_s = os.fspath(out)
|
|
276
|
+
if store_kind(out_s) == "image":
|
|
277
|
+
res = mosaic(
|
|
278
|
+
src,
|
|
279
|
+
bbox,
|
|
280
|
+
max(parse_zoom(zoom)),
|
|
281
|
+
out=out_s,
|
|
282
|
+
geometry=geometry,
|
|
283
|
+
bbox_crs=bbox_crs,
|
|
284
|
+
threads=threads,
|
|
285
|
+
rate=rate,
|
|
286
|
+
max_tiles=max_tiles,
|
|
287
|
+
retries=retries,
|
|
288
|
+
backoff=backoff,
|
|
289
|
+
timeout=timeout,
|
|
290
|
+
user_agent=user_agent,
|
|
291
|
+
headers=headers,
|
|
292
|
+
cache_dir=cache_dir,
|
|
293
|
+
revalidate=revalidate,
|
|
294
|
+
quiet=quiet,
|
|
295
|
+
progress=progress,
|
|
296
|
+
**mosaic_kwargs,
|
|
297
|
+
)
|
|
298
|
+
assert res.stats is not None
|
|
299
|
+
return FetchResult(
|
|
300
|
+
out_s,
|
|
301
|
+
res.stats,
|
|
302
|
+
res.stats.total,
|
|
303
|
+
[res.zoom],
|
|
304
|
+
res.request_bounds,
|
|
305
|
+
src.attribution,
|
|
306
|
+
src.terms_url,
|
|
307
|
+
res.files,
|
|
308
|
+
)
|
|
309
|
+
if mosaic_kwargs:
|
|
310
|
+
raise TypeError(f"unexpected arguments for a tile output: {sorted(mosaic_kwargs)}")
|
|
311
|
+
tiles, bounds, zooms = plan_tiles(src, bbox, zoom, geometry=geometry, bbox_crs=bbox_crs)
|
|
312
|
+
_check_cap(src, len(tiles), max_tiles)
|
|
313
|
+
dl = Downloader(
|
|
314
|
+
src,
|
|
315
|
+
threads=threads,
|
|
316
|
+
rate=rate,
|
|
317
|
+
retries=retries,
|
|
318
|
+
backoff=backoff,
|
|
319
|
+
timeout=timeout,
|
|
320
|
+
user_agent=user_agent,
|
|
321
|
+
headers=headers,
|
|
322
|
+
cache_dir=cache_dir,
|
|
323
|
+
revalidate=revalidate,
|
|
324
|
+
)
|
|
325
|
+
_announce(src, quiet)
|
|
326
|
+
store = open_store(out_s, "w" if overwrite else "a")
|
|
327
|
+
fmt: list[str | None] = [None]
|
|
328
|
+
|
|
329
|
+
def sniff(r: TileResult) -> None:
|
|
330
|
+
if fmt[0] is None and r.data:
|
|
331
|
+
from .storage import image_format
|
|
332
|
+
|
|
333
|
+
fmt[0] = image_format(r.data)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
stats = _run_download(dl, tiles, store, resume and not overwrite, progress, sniff)
|
|
337
|
+
if fmt[0] is None:
|
|
338
|
+
first = next((store.get(t) for t in tiles if store.has(t)), None)
|
|
339
|
+
if first:
|
|
340
|
+
from .storage import image_format
|
|
341
|
+
|
|
342
|
+
fmt[0] = image_format(first)
|
|
343
|
+
store.set_metadata(_metadata(src, bounds, zooms, fmt[0], name))
|
|
344
|
+
except BaseException:
|
|
345
|
+
abort = getattr(store, "abort", None)
|
|
346
|
+
if abort is not None:
|
|
347
|
+
abort()
|
|
348
|
+
else:
|
|
349
|
+
store.close()
|
|
350
|
+
raise
|
|
351
|
+
store.close()
|
|
352
|
+
return FetchResult(
|
|
353
|
+
out_s, stats, len(tiles), zooms, bounds, src.attribution, src.terms_url, [out_s]
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass
|
|
358
|
+
class MosaicResult:
|
|
359
|
+
"""Outcome of :func:`mosaic`."""
|
|
360
|
+
|
|
361
|
+
image: Image.Image
|
|
362
|
+
bounds: BBox
|
|
363
|
+
crs: str
|
|
364
|
+
zoom: int
|
|
365
|
+
request_bounds: BBox
|
|
366
|
+
missing: int
|
|
367
|
+
attribution: str
|
|
368
|
+
path: str | None = None
|
|
369
|
+
files: list[str] = field(default_factory=list)
|
|
370
|
+
stats: FetchStats | None = None
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def transform(self) -> GeoTransform:
|
|
374
|
+
return GeoTransform.from_bounds(self.bounds, self.image.width, self.image.height)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def mosaic(
|
|
378
|
+
source: Any,
|
|
379
|
+
bbox: BBoxLike | None = None,
|
|
380
|
+
zoom: int = 12,
|
|
381
|
+
out: str | os.PathLike[str] | None = None,
|
|
382
|
+
*,
|
|
383
|
+
geometry: Any = None,
|
|
384
|
+
bbox_crs: str = "EPSG:4326",
|
|
385
|
+
crs: str = "EPSG:3857",
|
|
386
|
+
crop: bool | str = True,
|
|
387
|
+
resampling: str = "bilinear",
|
|
388
|
+
width: int | None = None,
|
|
389
|
+
engine: str = "pillow",
|
|
390
|
+
threads: int = 2,
|
|
391
|
+
rate: float | None = None,
|
|
392
|
+
max_tiles: int | None = None,
|
|
393
|
+
retries: int = 4,
|
|
394
|
+
backoff: float = 0.5,
|
|
395
|
+
timeout: float = 30.0,
|
|
396
|
+
user_agent: str | None = None,
|
|
397
|
+
headers: Mapping[str, str] | None = None,
|
|
398
|
+
params: Mapping[str, str] | None = None,
|
|
399
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
400
|
+
revalidate: bool = False,
|
|
401
|
+
quiet: bool = False,
|
|
402
|
+
progress: ProgressFn | None = None,
|
|
403
|
+
) -> MosaicResult:
|
|
404
|
+
"""Build one georeferenced image of an area at one zoom level.
|
|
405
|
+
|
|
406
|
+
``source`` is anything :func:`fetch` accepts, or an existing ``.mbtiles``,
|
|
407
|
+
``.pmtiles`` or ``z/x/y`` folder (no network then).
|
|
408
|
+
|
|
409
|
+
``crop``: ``True`` resamples to exactly the requested bbox, ``"snap"``
|
|
410
|
+
crops to the enclosing source pixels without resampling, ``False`` keeps
|
|
411
|
+
whole tiles. ``crs="EPSG:4326"`` resamples to a lon/lat grid (``width``
|
|
412
|
+
sets its pixel width). ``out`` (``.tif``, ``.png``, ``.jpg``, ``.webp``)
|
|
413
|
+
is optional; PNG/JPEG/WebP get a world file and ``.prj``, ``.tif`` is a
|
|
414
|
+
GeoTIFF (``engine="pillow"`` or ``"rasterio"``).
|
|
415
|
+
"""
|
|
416
|
+
if not isinstance(zoom, int):
|
|
417
|
+
zs = parse_zoom(zoom)
|
|
418
|
+
if len(zs) != 1:
|
|
419
|
+
raise ValueError("mosaic() needs a single zoom level")
|
|
420
|
+
zoom = zs[0]
|
|
421
|
+
out_crs = _normalise_crs(crs)
|
|
422
|
+
local = isinstance(source, (str, os.PathLike)) and not (
|
|
423
|
+
isinstance(source, str) and (source in PROVIDERS or "{" in source)
|
|
424
|
+
)
|
|
425
|
+
stats: FetchStats | None = None
|
|
426
|
+
if local:
|
|
427
|
+
reader = open_reader(source)
|
|
428
|
+
try:
|
|
429
|
+
meta = getattr(reader, "metadata", {})
|
|
430
|
+
attribution = str(meta.get("attribution", "")) if isinstance(meta, dict) else ""
|
|
431
|
+
tiles, bounds = _area(bbox, geometry, [zoom], bbox_crs)
|
|
432
|
+
cache: dict[Tile, bytes] = {}
|
|
433
|
+
for t in tiles:
|
|
434
|
+
d = reader.get(t)
|
|
435
|
+
if d is not None:
|
|
436
|
+
cache[t] = d
|
|
437
|
+
finally:
|
|
438
|
+
reader.close()
|
|
439
|
+
tile_size = 256
|
|
440
|
+
else:
|
|
441
|
+
src = resolve_source(source, params=params)
|
|
442
|
+
tiles, bounds, _ = plan_tiles(src, bbox, zoom, geometry=geometry, bbox_crs=bbox_crs)
|
|
443
|
+
_check_cap(src, len(tiles), max_tiles)
|
|
444
|
+
dl = Downloader(
|
|
445
|
+
src,
|
|
446
|
+
threads=threads,
|
|
447
|
+
rate=rate,
|
|
448
|
+
retries=retries,
|
|
449
|
+
backoff=backoff,
|
|
450
|
+
timeout=timeout,
|
|
451
|
+
user_agent=user_agent,
|
|
452
|
+
headers=headers,
|
|
453
|
+
cache_dir=cache_dir,
|
|
454
|
+
revalidate=revalidate,
|
|
455
|
+
)
|
|
456
|
+
_announce(src, quiet)
|
|
457
|
+
cache = {}
|
|
458
|
+
|
|
459
|
+
def keep(r: TileResult) -> None:
|
|
460
|
+
assert r.data is not None
|
|
461
|
+
cache[r.tile] = r.data
|
|
462
|
+
|
|
463
|
+
stats = _run_download(dl, tiles, None, False, progress, keep)
|
|
464
|
+
attribution = src.attribution
|
|
465
|
+
tile_size = src.tile_size
|
|
466
|
+
if bounds.minx > bounds.maxx:
|
|
467
|
+
raise ValueError("mosaics across the antimeridian are not supported; split the bbox")
|
|
468
|
+
image, grid, missing = stitch(tiles, cache.get, tile_size)
|
|
469
|
+
if geometry is not None or _normalise_crs(bbox_crs) == "EPSG:4326":
|
|
470
|
+
target_m = bbox_to_mercator(bounds)
|
|
471
|
+
else:
|
|
472
|
+
target_m = parse_bbox(bbox) if bbox is not None else bbox_to_mercator(bounds)
|
|
473
|
+
if out_crs == "EPSG:4326":
|
|
474
|
+
image, out_bounds = warp_to_lonlat(image, grid, bounds, resampling=resampling, width=width)
|
|
475
|
+
elif crop is False:
|
|
476
|
+
out_bounds = grid
|
|
477
|
+
else:
|
|
478
|
+
image, out_bounds = crop_mercator(
|
|
479
|
+
image, grid, target_m, resampling=resampling, snap=crop == "snap"
|
|
480
|
+
)
|
|
481
|
+
result = MosaicResult(
|
|
482
|
+
image, out_bounds, out_crs, zoom, bounds, missing, attribution, stats=stats
|
|
483
|
+
)
|
|
484
|
+
if out is not None:
|
|
485
|
+
result.path = os.fspath(out)
|
|
486
|
+
result.files = save_georeferenced(
|
|
487
|
+
result.path, image, result.transform, out_crs, attribution=attribution, engine=engine
|
|
488
|
+
)
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@dataclass
|
|
493
|
+
class Estimate:
|
|
494
|
+
"""Outcome of :func:`estimate`."""
|
|
495
|
+
|
|
496
|
+
source: str
|
|
497
|
+
tiles_per_zoom: dict[int, int]
|
|
498
|
+
total_tiles: int
|
|
499
|
+
bytes_per_tile: int
|
|
500
|
+
estimated_bytes: int
|
|
501
|
+
max_tiles: int | None
|
|
502
|
+
within_limit: bool
|
|
503
|
+
rate: float | None
|
|
504
|
+
estimated_seconds: float | None
|
|
505
|
+
mosaic_size: dict[int, tuple[int, int]]
|
|
506
|
+
resolution_m: dict[int, float]
|
|
507
|
+
sampled: int = 0
|
|
508
|
+
|
|
509
|
+
def as_dict(self) -> dict[str, Any]:
|
|
510
|
+
from dataclasses import asdict
|
|
511
|
+
|
|
512
|
+
return asdict(self)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def estimate(
|
|
516
|
+
source: Any,
|
|
517
|
+
bbox: BBoxLike | None = None,
|
|
518
|
+
zoom: ZoomLike = 12,
|
|
519
|
+
*,
|
|
520
|
+
geometry: Any = None,
|
|
521
|
+
bbox_crs: str = "EPSG:4326",
|
|
522
|
+
max_tiles: int | None = None,
|
|
523
|
+
rate: float | None = None,
|
|
524
|
+
threads: int = 2,
|
|
525
|
+
params: Mapping[str, str] | None = None,
|
|
526
|
+
sample: int = 0,
|
|
527
|
+
timeout: float = 30.0,
|
|
528
|
+
user_agent: str | None = None,
|
|
529
|
+
headers: Mapping[str, str] | None = None,
|
|
530
|
+
) -> Estimate:
|
|
531
|
+
"""Count tiles and estimate download size and time, without downloading.
|
|
532
|
+
|
|
533
|
+
With ``sample=N`` up to N tiles of the highest zoom are downloaded to
|
|
534
|
+
measure the real average tile size; otherwise a typical size for the
|
|
535
|
+
source's format is assumed.
|
|
536
|
+
"""
|
|
537
|
+
src = resolve_source(source, params=params)
|
|
538
|
+
tiles, bounds, zooms = plan_tiles(src, bbox, zoom, geometry=geometry, bbox_crs=bbox_crs)
|
|
539
|
+
per_zoom: dict[int, int] = dict.fromkeys(zooms, 0)
|
|
540
|
+
for t in tiles:
|
|
541
|
+
per_zoom[t.z] += 1
|
|
542
|
+
bpt = TYPICAL_TILE_BYTES.get(src.format, TYPICAL_TILE_BYTES[None])
|
|
543
|
+
sampled = 0
|
|
544
|
+
if sample > 0 and tiles:
|
|
545
|
+
top = [t for t in tiles if t.z == zooms[-1]]
|
|
546
|
+
step = max(1, len(top) // sample)
|
|
547
|
+
picks = top[::step][:sample]
|
|
548
|
+
dl = Downloader(
|
|
549
|
+
src,
|
|
550
|
+
threads=threads,
|
|
551
|
+
rate=rate,
|
|
552
|
+
timeout=timeout,
|
|
553
|
+
retries=1,
|
|
554
|
+
user_agent=user_agent,
|
|
555
|
+
headers=headers,
|
|
556
|
+
)
|
|
557
|
+
sizes = [len(r.data) for r in dl.iter_fetch(picks) if r.data]
|
|
558
|
+
if sizes:
|
|
559
|
+
sampled = len(sizes)
|
|
560
|
+
bpt = int(sum(sizes) / len(sizes))
|
|
561
|
+
cap = _tile_cap(src, max_tiles)
|
|
562
|
+
eff_rate = rate if rate is not None else src.max_rate
|
|
563
|
+
if src.builtin and src.max_rate and rate is not None:
|
|
564
|
+
eff_rate = min(rate, src.max_rate)
|
|
565
|
+
sizes_px: dict[int, tuple[int, int]] = {}
|
|
566
|
+
res: dict[int, float] = {}
|
|
567
|
+
if bounds.minx <= bounds.maxx:
|
|
568
|
+
mb = bbox_to_mercator(bounds)
|
|
569
|
+
for z in zooms:
|
|
570
|
+
r = resolution(z, src.tile_size)
|
|
571
|
+
res[z] = r
|
|
572
|
+
sizes_px[z] = (
|
|
573
|
+
max(1, round((mb.maxx - mb.minx) / r)),
|
|
574
|
+
max(1, round((mb.maxy - mb.miny) / r)),
|
|
575
|
+
)
|
|
576
|
+
else:
|
|
577
|
+
for z in zooms:
|
|
578
|
+
res[z] = resolution(z, src.tile_size)
|
|
579
|
+
return Estimate(
|
|
580
|
+
source=src.name,
|
|
581
|
+
tiles_per_zoom=per_zoom,
|
|
582
|
+
total_tiles=len(tiles),
|
|
583
|
+
bytes_per_tile=bpt,
|
|
584
|
+
estimated_bytes=bpt * len(tiles),
|
|
585
|
+
max_tiles=cap,
|
|
586
|
+
within_limit=cap is None or len(tiles) <= cap,
|
|
587
|
+
rate=eff_rate,
|
|
588
|
+
estimated_seconds=(len(tiles) / eff_rate) if eff_rate else None,
|
|
589
|
+
mosaic_size=sizes_px,
|
|
590
|
+
resolution_m=res,
|
|
591
|
+
sampled=sampled,
|
|
592
|
+
)
|