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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
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
+ )