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/tiles.py ADDED
@@ -0,0 +1,469 @@
1
+ """Web Mercator tile math: XYZ/TMS addressing, quadkeys, bounds and coverage.
2
+
3
+ All tiles use the "Google" XYZ convention (origin at the top-left, y grows
4
+ southwards) unless a function says otherwise. Longitudes and latitudes are in
5
+ degrees (EPSG:4326); projected coordinates are EPSG:3857 metres.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import os
13
+ from typing import Any, Iterable, Iterator, NamedTuple, Sequence, Union
14
+
15
+ __all__ = [
16
+ "EARTH_RADIUS",
17
+ "ORIGIN_SHIFT",
18
+ "MAX_LATITUDE",
19
+ "Tile",
20
+ "BBox",
21
+ "lonlat_to_mercator",
22
+ "mercator_to_lonlat",
23
+ "lonlat_to_tile",
24
+ "tile_bounds_mercator",
25
+ "tile_bounds_lonlat",
26
+ "resolution",
27
+ "flip_y",
28
+ "tile_to_quadkey",
29
+ "quadkey_to_tile",
30
+ "parse_bbox",
31
+ "parse_zoom",
32
+ "bbox_to_mercator",
33
+ "tiles_for_bbox",
34
+ "tiles_for_geometry",
35
+ "tiles_for_polygons",
36
+ "load_geometry",
37
+ "geometry_bbox",
38
+ "tile_count",
39
+ ]
40
+
41
+ EARTH_RADIUS = 6378137.0
42
+ #: Half the width of the Web Mercator world, in metres.
43
+ ORIGIN_SHIFT = math.pi * EARTH_RADIUS # 20037508.342789244
44
+ #: Latitude at which the Web Mercator square ends.
45
+ MAX_LATITUDE = math.degrees(2.0 * math.atan(math.exp(math.pi)) - math.pi / 2.0) # 85.0511287798
46
+ #: Highest zoom level accepted anywhere in the package.
47
+ MAX_ZOOM = 30
48
+
49
+
50
+ class Tile(NamedTuple):
51
+ """An XYZ tile address (top-left origin)."""
52
+
53
+ z: int
54
+ x: int
55
+ y: int
56
+
57
+ @property
58
+ def tms_y(self) -> int:
59
+ """Row in the TMS scheme (bottom-left origin), as used by MBTiles."""
60
+ return flip_y(self.z, self.y)
61
+
62
+ @property
63
+ def quadkey(self) -> str:
64
+ return tile_to_quadkey(self)
65
+
66
+
67
+ class BBox(NamedTuple):
68
+ """A bounding box ``(minx, miny, maxx, maxy)``."""
69
+
70
+ minx: float
71
+ miny: float
72
+ maxx: float
73
+ maxy: float
74
+
75
+
76
+ BBoxLike = Union[BBox, Sequence[float], str]
77
+ ZoomLike = Union[int, str, Iterable[int]]
78
+
79
+
80
+ def _check_zoom(z: int) -> None:
81
+ if not isinstance(z, int) or isinstance(z, bool) or z < 0 or z > MAX_ZOOM:
82
+ raise ValueError(f"zoom must be an integer between 0 and {MAX_ZOOM}, got {z!r}")
83
+
84
+
85
+ def _clamp_lat(lat: float) -> float:
86
+ return max(-MAX_LATITUDE, min(MAX_LATITUDE, lat))
87
+
88
+
89
+ def lonlat_to_mercator(lon: float, lat: float) -> tuple[float, float]:
90
+ """Project longitude/latitude (degrees) to EPSG:3857 metres.
91
+
92
+ Latitudes beyond +/-85.0511 degrees are clamped to the edge of the square.
93
+ """
94
+ lat = _clamp_lat(lat)
95
+ x = math.radians(lon) * EARTH_RADIUS
96
+ y = math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0)) * EARTH_RADIUS
97
+ return x, y
98
+
99
+
100
+ def mercator_to_lonlat(x: float, y: float) -> tuple[float, float]:
101
+ """Inverse of :func:`lonlat_to_mercator`."""
102
+ lon = math.degrees(x / EARTH_RADIUS)
103
+ lat = math.degrees(2.0 * math.atan(math.exp(y / EARTH_RADIUS)) - math.pi / 2.0)
104
+ return lon, lat
105
+
106
+
107
+ def resolution(z: int, tile_size: int = 256) -> float:
108
+ """Ground resolution in metres per pixel (EPSG:3857 units) at zoom ``z``."""
109
+ _check_zoom(z)
110
+ return 2.0 * ORIGIN_SHIFT / (tile_size * (1 << z))
111
+
112
+
113
+ def lonlat_to_tile(lon: float, lat: float, z: int) -> Tile:
114
+ """Return the tile containing a point. Points on the east/south edge of
115
+ the world map to the last tile instead of falling outside the grid."""
116
+ _check_zoom(z)
117
+ n = 1 << z
118
+ x, y = lonlat_to_mercator(lon, lat)
119
+ fx = (x + ORIGIN_SHIFT) / (2.0 * ORIGIN_SHIFT) * n
120
+ fy = (ORIGIN_SHIFT - y) / (2.0 * ORIGIN_SHIFT) * n
121
+ tx = min(n - 1, max(0, int(math.floor(fx))))
122
+ ty = min(n - 1, max(0, int(math.floor(fy))))
123
+ return Tile(z, tx, ty)
124
+
125
+
126
+ def tile_bounds_mercator(tile: Tile | tuple[int, int, int]) -> BBox:
127
+ """EPSG:3857 bounds ``(minx, miny, maxx, maxy)`` of an XYZ tile."""
128
+ z, x, y = tile
129
+ _check_zoom(z)
130
+ size = 2.0 * ORIGIN_SHIFT / (1 << z)
131
+ minx = -ORIGIN_SHIFT + x * size
132
+ maxy = ORIGIN_SHIFT - y * size
133
+ return BBox(minx, maxy - size, minx + size, maxy)
134
+
135
+
136
+ def tile_bounds_lonlat(tile: Tile | tuple[int, int, int]) -> BBox:
137
+ """Longitude/latitude bounds ``(west, south, east, north)`` of an XYZ tile."""
138
+ b = tile_bounds_mercator(tile)
139
+ w, s = mercator_to_lonlat(b.minx, b.miny)
140
+ e, n = mercator_to_lonlat(b.maxx, b.maxy)
141
+ return BBox(w, s, e, n)
142
+
143
+
144
+ def flip_y(z: int, y: int) -> int:
145
+ """Convert a row between the XYZ and TMS schemes (the operation is its own inverse)."""
146
+ _check_zoom(z)
147
+ return (1 << z) - 1 - y
148
+
149
+
150
+ def tile_to_quadkey(tile: Tile | tuple[int, int, int]) -> str:
151
+ """Bing-style quadkey of an XYZ tile (empty string at zoom 0)."""
152
+ z, x, y = tile
153
+ _check_zoom(z)
154
+ digits = []
155
+ for i in range(z, 0, -1):
156
+ mask = 1 << (i - 1)
157
+ d = 0
158
+ if x & mask:
159
+ d += 1
160
+ if y & mask:
161
+ d += 2
162
+ digits.append(str(d))
163
+ return "".join(digits)
164
+
165
+
166
+ def quadkey_to_tile(quadkey: str) -> Tile:
167
+ """Inverse of :func:`tile_to_quadkey`."""
168
+ x = y = 0
169
+ z = len(quadkey)
170
+ _check_zoom(z)
171
+ for i, ch in enumerate(quadkey):
172
+ mask = 1 << (z - i - 1)
173
+ if ch == "1":
174
+ x |= mask
175
+ elif ch == "2":
176
+ y |= mask
177
+ elif ch == "3":
178
+ x |= mask
179
+ y |= mask
180
+ elif ch != "0":
181
+ raise ValueError(f"invalid quadkey digit {ch!r} in {quadkey!r}")
182
+ return Tile(z, x, y)
183
+
184
+
185
+ def parse_bbox(bbox: BBoxLike) -> BBox:
186
+ """Normalise a bbox given as a sequence or a ``"minx,miny,maxx,maxy"`` string.
187
+
188
+ For lon/lat boxes ``minx > maxx`` is allowed and means the box crosses the
189
+ antimeridian. ``miny`` must not exceed ``maxy``.
190
+ """
191
+ if isinstance(bbox, str):
192
+ parts = [p for p in bbox.replace(" ", ",").split(",") if p]
193
+ values = [float(p) for p in parts]
194
+ else:
195
+ values = [float(v) for v in bbox]
196
+ if len(values) != 4:
197
+ raise ValueError(f"bbox needs 4 numbers (minx, miny, maxx, maxy), got {len(values)}")
198
+ if any(math.isnan(v) or math.isinf(v) for v in values):
199
+ raise ValueError("bbox values must be finite")
200
+ b = BBox(*values)
201
+ if b.miny > b.maxy:
202
+ raise ValueError(f"bbox miny ({b.miny}) is greater than maxy ({b.maxy})")
203
+ return b
204
+
205
+
206
+ def parse_zoom(zoom: ZoomLike) -> list[int]:
207
+ """Parse ``12``, ``"12"``, ``"12-15"``, ``"10,12,14"`` or ``[12, 13]`` into a sorted list."""
208
+ levels: set[int] = set()
209
+ if isinstance(zoom, bool):
210
+ raise ValueError("zoom must be an int, a range string or a list of ints")
211
+ if isinstance(zoom, int):
212
+ levels.add(zoom)
213
+ elif isinstance(zoom, str):
214
+ for part in zoom.split(","):
215
+ part = part.strip()
216
+ if not part:
217
+ continue
218
+ if "-" in part:
219
+ a, b = part.split("-", 1)
220
+ lo, hi = int(a), int(b)
221
+ if lo > hi:
222
+ raise ValueError(f"empty zoom range {part!r}")
223
+ levels.update(range(lo, hi + 1))
224
+ else:
225
+ levels.add(int(part))
226
+ else:
227
+ levels.update(int(z) for z in zoom)
228
+ if not levels:
229
+ raise ValueError("no zoom levels given")
230
+ for z in levels:
231
+ _check_zoom(z)
232
+ return sorted(levels)
233
+
234
+
235
+ def _split_antimeridian(b: BBox) -> list[BBox]:
236
+ if b.minx <= b.maxx:
237
+ return [b]
238
+ return [BBox(b.minx, b.miny, 180.0, b.maxy), BBox(-180.0, b.miny, b.maxx, b.maxy)]
239
+
240
+
241
+ def bbox_to_mercator(bbox: BBoxLike) -> BBox:
242
+ """Project a lon/lat bbox to EPSG:3857 (latitudes clamped to the Mercator square)."""
243
+ b = parse_bbox(bbox)
244
+ if b.minx > b.maxx:
245
+ raise ValueError("bbox crosses the antimeridian; split it before projecting")
246
+ x0, y0 = lonlat_to_mercator(b.minx, b.miny)
247
+ x1, y1 = lonlat_to_mercator(b.maxx, b.maxy)
248
+ return BBox(x0, y0, x1, y1)
249
+
250
+
251
+ def _tile_range(b: BBox, z: int, crs: str) -> tuple[int, int, int, int]:
252
+ """Inclusive (xmin, ymin, xmax, ymax) tile indices covering a bbox at zoom z."""
253
+ n = 1 << z
254
+ if crs == "EPSG:3857":
255
+ mb = b
256
+ else:
257
+ x0, y0 = lonlat_to_mercator(b.minx, b.miny)
258
+ x1, y1 = lonlat_to_mercator(b.maxx, b.maxy)
259
+ mb = BBox(x0, y0, x1, y1)
260
+ size = 2.0 * ORIGIN_SHIFT / n
261
+ eps = 1e-9 * size
262
+
263
+ def col(x: float, upper: bool) -> int:
264
+ f = (x + ORIGIN_SHIFT) / size
265
+ # A box edge exactly on a tile edge must not pull in the neighbour tile.
266
+ i = math.ceil(f - eps) - 1 if upper else math.floor(f + eps)
267
+ return min(n - 1, max(0, int(i)))
268
+
269
+ def row(y: float, upper: bool) -> int:
270
+ f = (ORIGIN_SHIFT - y) / size
271
+ i = math.ceil(f - eps) - 1 if upper else math.floor(f + eps)
272
+ return min(n - 1, max(0, int(i)))
273
+
274
+ xmin, xmax = col(mb.minx, False), col(mb.maxx, True)
275
+ ymin, ymax = row(mb.maxy, False), row(mb.miny, True)
276
+ # Degenerate (zero-width) boxes still cover one tile.
277
+ xmax = max(xmax, xmin)
278
+ ymax = max(ymax, ymin)
279
+ return xmin, ymin, xmax, ymax
280
+
281
+
282
+ def _normalise_crs(crs: str | int) -> str:
283
+ s = str(crs).upper().replace("EPSG:", "")
284
+ if s in ("4326", "CRS84", "OGC:CRS84"):
285
+ return "EPSG:4326"
286
+ if s in ("3857", "900913", "3785", "102100"):
287
+ return "EPSG:3857"
288
+ raise ValueError(f"unsupported CRS {crs!r}; use EPSG:4326 or EPSG:3857")
289
+
290
+
291
+ def _iter_bbox_tiles(bbox: BBoxLike, zooms: list[int], crs: str) -> Iterator[Tile]:
292
+ b = parse_bbox(bbox)
293
+ boxes = _split_antimeridian(b) if crs == "EPSG:4326" else [b]
294
+ if crs == "EPSG:3857" and b.minx > b.maxx:
295
+ raise ValueError("EPSG:3857 bbox must have minx <= maxx")
296
+ for z in zooms:
297
+ seen: set[tuple[int, int]] = set()
298
+ for part in boxes:
299
+ xmin, ymin, xmax, ymax = _tile_range(part, z, crs)
300
+ for x in range(xmin, xmax + 1):
301
+ for y in range(ymin, ymax + 1):
302
+ if (x, y) not in seen:
303
+ seen.add((x, y))
304
+ yield Tile(z, x, y)
305
+
306
+
307
+ def tile_count(bbox: BBoxLike, zoom: ZoomLike, crs: str | int = "EPSG:4326") -> int:
308
+ """Number of tiles covering a bbox, without materialising the list."""
309
+ zooms = parse_zoom(zoom)
310
+ c = _normalise_crs(crs)
311
+ b = parse_bbox(bbox)
312
+ total = 0
313
+ for z in zooms:
314
+ cols: set[int] = set()
315
+ rows = 0
316
+ for part in _split_antimeridian(b) if c == "EPSG:4326" else [b]:
317
+ xmin, ymin, xmax, ymax = _tile_range(part, z, c)
318
+ cols.update(range(xmin, xmax + 1))
319
+ rows = ymax - ymin + 1
320
+ total += len(cols) * rows
321
+ return total
322
+
323
+
324
+ def tiles_for_bbox(bbox: BBoxLike, zoom: ZoomLike, crs: str | int = "EPSG:4326") -> list[Tile]:
325
+ """All XYZ tiles intersecting a bbox at the given zoom level(s).
326
+
327
+ ``bbox`` is ``(west, south, east, north)`` in degrees by default, or EPSG:3857
328
+ metres with ``crs="EPSG:3857"``. Lon/lat boxes with ``west > east`` cross
329
+ the antimeridian. Tiles are ordered by zoom, then column, then row.
330
+ """
331
+ return list(_iter_bbox_tiles(bbox, parse_zoom(zoom), _normalise_crs(crs)))
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # GeoJSON polygons
336
+ # ---------------------------------------------------------------------------
337
+
338
+ Ring = list[tuple[float, float]]
339
+ Polygon = list[Ring] # exterior ring followed by holes
340
+
341
+
342
+ def load_geometry(geojson: Any) -> list[Polygon]:
343
+ """Read Polygon/MultiPolygon geometry from a GeoJSON object, string or file path.
344
+
345
+ Accepts a geometry, a Feature, a FeatureCollection or a path to a
346
+ ``.geojson`` file. Coordinates are longitude/latitude (RFC 7946).
347
+ Returns a list of polygons, each a list of rings of ``(lon, lat)``.
348
+ """
349
+ obj = geojson
350
+ if isinstance(obj, (str, os.PathLike)):
351
+ text = str(obj)
352
+ if text.lstrip().startswith("{"):
353
+ obj = json.loads(text)
354
+ else:
355
+ with open(obj, encoding="utf-8") as fh:
356
+ obj = json.load(fh)
357
+ if not isinstance(obj, dict):
358
+ raise ValueError("GeoJSON must be a dict, a JSON string or a file path")
359
+ polygons: list[Polygon] = []
360
+
361
+ def walk(o: dict[str, Any]) -> None:
362
+ t = o.get("type")
363
+ if t == "FeatureCollection":
364
+ for f in o.get("features", []):
365
+ walk(f)
366
+ elif t == "Feature":
367
+ if o.get("geometry"):
368
+ walk(o["geometry"])
369
+ elif t == "GeometryCollection":
370
+ for g in o.get("geometries", []):
371
+ walk(g)
372
+ elif t == "Polygon":
373
+ polygons.append(
374
+ [[(float(p[0]), float(p[1])) for p in ring] for ring in o["coordinates"]]
375
+ )
376
+ elif t == "MultiPolygon":
377
+ for poly in o["coordinates"]:
378
+ polygons.append([[(float(p[0]), float(p[1])) for p in ring] for ring in poly])
379
+ else:
380
+ raise ValueError(f"unsupported GeoJSON type {t!r}; expected (Multi)Polygon")
381
+
382
+ walk(obj)
383
+ if not polygons:
384
+ raise ValueError("GeoJSON contains no polygons")
385
+ return polygons
386
+
387
+
388
+ def geometry_bbox(polygons: list[Polygon]) -> BBox:
389
+ """Lon/lat bounds of the exterior rings of a list of polygons."""
390
+ xs = [p[0] for poly in polygons for p in poly[0]]
391
+ ys = [p[1] for poly in polygons for p in poly[0]]
392
+ return BBox(min(xs), min(ys), max(xs), max(ys))
393
+
394
+
395
+ def _point_in_ring(x: float, y: float, ring: Ring) -> bool:
396
+ inside = False
397
+ n = len(ring)
398
+ j = n - 1
399
+ for i in range(n):
400
+ xi, yi = ring[i]
401
+ xj, yj = ring[j]
402
+ if (yi > y) != (yj > y):
403
+ xc = (xj - xi) * (y - yi) / (yj - yi) + xi
404
+ if x < xc:
405
+ inside = not inside
406
+ j = i
407
+ return inside
408
+
409
+
410
+ def _point_in_polygon(x: float, y: float, poly: Polygon) -> bool:
411
+ if not _point_in_ring(x, y, poly[0]):
412
+ return False
413
+ return not any(_point_in_ring(x, y, hole) for hole in poly[1:])
414
+
415
+
416
+ def _segment_hits_rect(a: tuple[float, float], b: tuple[float, float], r: BBox) -> bool:
417
+ """Liang-Barsky clip test: does segment ab touch rectangle r?"""
418
+ x0, y0 = a
419
+ dx, dy = b[0] - x0, b[1] - y0
420
+ t0, t1 = 0.0, 1.0
421
+ for p, q in ((-dx, x0 - r.minx), (dx, r.maxx - x0), (-dy, y0 - r.miny), (dy, r.maxy - y0)):
422
+ if p == 0:
423
+ if q < 0:
424
+ return False
425
+ else:
426
+ t = q / p
427
+ if p < 0:
428
+ t0 = max(t0, t)
429
+ else:
430
+ t1 = min(t1, t)
431
+ if t0 > t1:
432
+ return False
433
+ return True
434
+
435
+
436
+ def _poly_hits_rect(poly: Polygon, r: BBox) -> bool:
437
+ for ring in poly:
438
+ for i in range(len(ring) - 1):
439
+ if _segment_hits_rect(ring[i], ring[i + 1], r):
440
+ return True
441
+ cx, cy = (r.minx + r.maxx) / 2.0, (r.miny + r.maxy) / 2.0
442
+ # Rectangle entirely inside the polygon (and not inside a hole).
443
+ return _point_in_polygon(cx, cy, poly)
444
+
445
+
446
+ def tiles_for_geometry(geojson: Any, zoom: ZoomLike) -> list[Tile]:
447
+ """Tiles that intersect GeoJSON (Multi)Polygon geometry.
448
+
449
+ Polygon edges are treated as straight lines in Web Mercator, which is the
450
+ space the tiles live in. Candidate tiles from the bbox are tested exactly
451
+ against every polygon (including holes).
452
+ """
453
+ return tiles_for_polygons(load_geometry(geojson), zoom)
454
+
455
+
456
+ def tiles_for_polygons(polygons: list[Polygon], zoom: ZoomLike) -> list[Tile]:
457
+ """Like :func:`tiles_for_geometry` for polygons already read by :func:`load_geometry`."""
458
+ merc = [
459
+ [[lonlat_to_mercator(lon, lat) for lon, lat in ring] for ring in poly] for poly in polygons
460
+ ]
461
+ bounds = geometry_bbox(polygons)
462
+ out: list[Tile] = []
463
+ for t in _iter_bbox_tiles(bounds, parse_zoom(zoom), "EPSG:4326"):
464
+ r = tile_bounds_mercator(t)
465
+ for poly in merc:
466
+ if _poly_hits_rect(poly, r):
467
+ out.append(t)
468
+ break
469
+ return out