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