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/mosaic.py ADDED
@@ -0,0 +1,191 @@
1
+ """Stitch tiles into one image and resample it to an exact bbox / CRS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import math
7
+ from typing import Callable, Iterable
8
+
9
+ from PIL import Image
10
+
11
+ from .tiles import EARTH_RADIUS, BBox, Tile, lonlat_to_mercator, tile_bounds_mercator
12
+
13
+ __all__ = ["stitch", "crop_mercator", "warp_to_lonlat", "decode_tile", "RESAMPLING"]
14
+
15
+ RESAMPLING = {
16
+ "nearest": Image.Resampling.NEAREST,
17
+ "bilinear": Image.Resampling.BILINEAR,
18
+ "bicubic": Image.Resampling.BICUBIC,
19
+ }
20
+
21
+
22
+ def _resampling(name: str) -> Image.Resampling:
23
+ try:
24
+ return RESAMPLING[name]
25
+ except KeyError:
26
+ raise ValueError(f"resampling must be one of {sorted(RESAMPLING)}") from None
27
+
28
+
29
+ def decode_tile(data: bytes) -> Image.Image | None:
30
+ """Decode tile bytes to RGBA, or ``None`` if they are not an image."""
31
+ try:
32
+ with Image.open(io.BytesIO(data)) as im:
33
+ im.load()
34
+ return im.convert("RGBA")
35
+ except Exception:
36
+ return None
37
+
38
+
39
+ def stitch(
40
+ tiles: Iterable[Tile],
41
+ get: Callable[[Tile], bytes | None],
42
+ tile_size: int = 256,
43
+ ) -> tuple[Image.Image, BBox, int]:
44
+ """Paste the tiles of one zoom level into an RGBA canvas.
45
+
46
+ Returns ``(image, bounds_3857, missing)``; missing or undecodable tiles
47
+ stay transparent.
48
+ """
49
+ tiles = list(tiles)
50
+ if not tiles:
51
+ raise ValueError("no tiles to stitch")
52
+ zs = {t.z for t in tiles}
53
+ if len(zs) != 1:
54
+ raise ValueError("stitch() needs tiles from a single zoom level")
55
+ xmin = min(t.x for t in tiles)
56
+ xmax = max(t.x for t in tiles)
57
+ ymin = min(t.y for t in tiles)
58
+ ymax = max(t.y for t in tiles)
59
+ decoded: dict[Tile, Image.Image] = {}
60
+ missing = 0
61
+ for t in tiles:
62
+ data = get(t)
63
+ im = decode_tile(data) if data else None
64
+ if im is None:
65
+ missing += 1
66
+ else:
67
+ decoded[t] = im
68
+ if decoded:
69
+ tile_size = next(iter(decoded.values())).width
70
+ canvas = Image.new(
71
+ "RGBA", ((xmax - xmin + 1) * tile_size, (ymax - ymin + 1) * tile_size), (0, 0, 0, 0)
72
+ )
73
+ for t, im in decoded.items():
74
+ if im.size != (tile_size, tile_size):
75
+ im = im.resize((tile_size, tile_size), Image.Resampling.BILINEAR)
76
+ canvas.paste(im, ((t.x - xmin) * tile_size, (t.y - ymin) * tile_size))
77
+ z = zs.pop()
78
+ tl = tile_bounds_mercator((z, xmin, ymin))
79
+ br = tile_bounds_mercator((z, xmax, ymax))
80
+ return canvas, BBox(tl.minx, br.miny, br.maxx, tl.maxy), missing
81
+
82
+
83
+ def _clip(target: BBox, grid: BBox) -> BBox:
84
+ b = BBox(
85
+ max(target.minx, grid.minx),
86
+ max(target.miny, grid.miny),
87
+ min(target.maxx, grid.maxx),
88
+ min(target.maxy, grid.maxy),
89
+ )
90
+ if b.minx >= b.maxx or b.miny >= b.maxy:
91
+ raise ValueError("target bbox does not overlap the mosaic")
92
+ return b
93
+
94
+
95
+ def crop_mercator(
96
+ image: Image.Image,
97
+ grid: BBox,
98
+ target: BBox,
99
+ *,
100
+ resampling: str = "bilinear",
101
+ snap: bool = False,
102
+ ) -> tuple[Image.Image, BBox]:
103
+ """Crop a Web Mercator mosaic to ``target`` (EPSG:3857 metres).
104
+
105
+ With ``snap=True`` the window is widened to whole source pixels and copied
106
+ without resampling. Otherwise the output covers ``target`` exactly, with
107
+ the source resolution rounded so that an integer number of pixels fits,
108
+ and is resampled.
109
+ """
110
+ res_x = (grid.maxx - grid.minx) / image.width
111
+ res_y = (grid.maxy - grid.miny) / image.height
112
+ t = _clip(target, grid)
113
+ if snap:
114
+ c0 = int(math.floor((t.minx - grid.minx) / res_x + 1e-9))
115
+ c1 = int(math.ceil((t.maxx - grid.minx) / res_x - 1e-9))
116
+ r0 = int(math.floor((grid.maxy - t.maxy) / res_y + 1e-9))
117
+ r1 = int(math.ceil((grid.maxy - t.miny) / res_y - 1e-9))
118
+ out = image.crop((c0, r0, c1, r1))
119
+ return out, BBox(
120
+ grid.minx + c0 * res_x,
121
+ grid.maxy - r1 * res_y,
122
+ grid.minx + c1 * res_x,
123
+ grid.maxy - r0 * res_y,
124
+ )
125
+ w = max(1, round((t.maxx - t.minx) / res_x))
126
+ h = max(1, round((t.maxy - t.miny) / res_y))
127
+ px = (t.maxx - t.minx) / w
128
+ py = (t.maxy - t.miny) / h
129
+ # Pillow maps output pixel centres through the affine matrix, in pixel-edge coordinates.
130
+ coeffs = (
131
+ px / res_x,
132
+ 0.0,
133
+ (t.minx - grid.minx) / res_x,
134
+ 0.0,
135
+ py / res_y,
136
+ (grid.maxy - t.maxy) / res_y,
137
+ )
138
+ out = image.transform((w, h), Image.Transform.AFFINE, coeffs, resample=_resampling(resampling))
139
+ return out, t
140
+
141
+
142
+ def _lat_from_merc(y: float) -> float:
143
+ return math.degrees(2.0 * math.atan(math.exp(y / EARTH_RADIUS)) - math.pi / 2.0)
144
+
145
+
146
+ def warp_to_lonlat(
147
+ image: Image.Image,
148
+ grid: BBox,
149
+ target: BBox,
150
+ *,
151
+ resampling: str = "bilinear",
152
+ width: int | None = None,
153
+ ) -> tuple[Image.Image, BBox]:
154
+ """Resample a Web Mercator mosaic to a lon/lat (EPSG:4326) grid covering ``target``.
155
+
156
+ ``target`` is ``(west, south, east, north)`` in degrees. Output pixels are
157
+ square in degrees; by default the width keeps the source resolution at the
158
+ bbox. The inverse mapping is exact per output row: longitude is linear in
159
+ Mercator x, and each row's latitude is projected to its source row.
160
+ """
161
+ res_x = (grid.maxx - grid.minx) / image.width
162
+ res_y = (grid.maxy - grid.miny) / image.height
163
+ gw = math.degrees(grid.minx / EARTH_RADIUS)
164
+ ge = math.degrees(grid.maxx / EARTH_RADIUS)
165
+ gs = _lat_from_merc(grid.miny)
166
+ gn = _lat_from_merc(grid.maxy)
167
+ t = _clip(target, BBox(gw, gs, ge, gn))
168
+ mx0, _ = lonlat_to_mercator(t.minx, 0.0)
169
+ mx1, _ = lonlat_to_mercator(t.maxx, 0.0)
170
+ if width is None:
171
+ width = max(1, round((mx1 - mx0) / res_x))
172
+ deg = (t.maxx - t.minx) / width
173
+ height = max(1, round((t.maxy - t.miny) / deg))
174
+ deg_y = (t.maxy - t.miny) / height
175
+ sx0 = (mx0 - grid.minx) / res_x
176
+ sx1 = (mx1 - grid.minx) / res_x
177
+
178
+ def src_row(lat: float) -> float:
179
+ _, my = lonlat_to_mercator(0.0, lat)
180
+ return (grid.maxy - my) / res_y
181
+
182
+ mesh = []
183
+ for r in range(height):
184
+ top = src_row(t.maxy - r * deg_y)
185
+ bottom = src_row(t.maxy - (r + 1) * deg_y)
186
+ # quad corners: upper-left, lower-left, lower-right, upper-right
187
+ mesh.append(((0, r, width, r + 1), (sx0, top, sx0, bottom, sx1, bottom, sx1, top)))
188
+ out = image.transform(
189
+ (width, height), Image.Transform.MESH, mesh, resample=_resampling(resampling)
190
+ )
191
+ return out, t