tilearray 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.
- tilearray/__init__.py +89 -0
- tilearray/_version.py +3 -0
- tilearray/array.py +899 -0
- tilearray/decode.py +353 -0
- tilearray/errors.py +41 -0
- tilearray/fetch.py +772 -0
- tilearray/fetch_presets.py +77 -0
- tilearray/pressure.py +129 -0
- tilearray/py.typed +0 -0
- tilearray/service/__init__.py +33 -0
- tilearray/service/base.py +282 -0
- tilearray/service/config.py +455 -0
- tilearray/service/requests.py +70 -0
- tilearray/service/wcs.py +657 -0
- tilearray/service/wms.py +127 -0
- tilearray/service/wmts.py +590 -0
- tilearray/service/xyz.py +145 -0
- tilearray/tiles.py +142 -0
- tilearray/types.py +235 -0
- tilearray-0.1.0.dist-info/METADATA +345 -0
- tilearray-0.1.0.dist-info/RECORD +23 -0
- tilearray-0.1.0.dist-info/WHEEL +4 -0
- tilearray-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""WMTS (Web Map Tile Service) parsing and tile request functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from ..types import CRS, BoundingBox, Format, ServiceTypeEnum, TileRequest
|
|
16
|
+
from .base import BaseService, TileGeometry, register_service
|
|
17
|
+
from .xyz import _tile_bounds, _tile_range_for_bbox
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class WMTSTileMatrix:
|
|
24
|
+
"""One zoom level within a WMTS TileMatrixSet."""
|
|
25
|
+
|
|
26
|
+
identifier: str
|
|
27
|
+
scale_denominator: float
|
|
28
|
+
top_left_x: float
|
|
29
|
+
top_left_y: float
|
|
30
|
+
matrix_width: int
|
|
31
|
+
matrix_height: int
|
|
32
|
+
tile_width: int
|
|
33
|
+
tile_height: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class WMTSTileMatrixSet:
|
|
38
|
+
"""Tile matrix set metadata used for tile planning."""
|
|
39
|
+
|
|
40
|
+
identifier: str
|
|
41
|
+
supported_crs: CRS
|
|
42
|
+
matrices: dict[str, WMTSTileMatrix] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class WMTSLayerInfo:
|
|
47
|
+
"""Layer metadata extracted from GetCapabilities."""
|
|
48
|
+
|
|
49
|
+
identifier: str
|
|
50
|
+
resource_url_template: str | None = None
|
|
51
|
+
tile_matrix_set_links: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class WMTSCapabilities:
|
|
56
|
+
"""Parsed WMTS GetCapabilities payload."""
|
|
57
|
+
|
|
58
|
+
layers: dict[str, WMTSLayerInfo] = field(default_factory=dict)
|
|
59
|
+
tile_matrix_sets: dict[str, WMTSTileMatrixSet] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WMTSParser:
|
|
63
|
+
"""Parser for WMTS GetCapabilities XML documents."""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self.namespaces = {
|
|
67
|
+
"wmts": "http://www.opengis.net/wmts/1.0",
|
|
68
|
+
"ows": "http://www.opengis.net/ows/1.1",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
def parse_get_capabilities(self, xml_content: str) -> WMTSCapabilities:
|
|
72
|
+
try:
|
|
73
|
+
root = ET.fromstring(xml_content)
|
|
74
|
+
except ET.ParseError as exc: # pragma: no cover - defensive
|
|
75
|
+
raise ValueError(f"Invalid XML content: {exc}") from exc
|
|
76
|
+
|
|
77
|
+
capabilities = WMTSCapabilities()
|
|
78
|
+
for tms_elem in root.findall(".//wmts:TileMatrixSet", self.namespaces):
|
|
79
|
+
tms = self._parse_tile_matrix_set(tms_elem)
|
|
80
|
+
if tms is not None:
|
|
81
|
+
capabilities.tile_matrix_sets[tms.identifier] = tms
|
|
82
|
+
|
|
83
|
+
for layer_elem in root.findall(".//wmts:Layer", self.namespaces):
|
|
84
|
+
layer = self._parse_layer(layer_elem)
|
|
85
|
+
if layer is not None:
|
|
86
|
+
capabilities.layers[layer.identifier] = layer
|
|
87
|
+
|
|
88
|
+
return capabilities
|
|
89
|
+
|
|
90
|
+
def _parse_tile_matrix_set(self, element: ET.Element) -> WMTSTileMatrixSet | None:
|
|
91
|
+
identifier = self._text(element, "ows:Identifier")
|
|
92
|
+
if not identifier:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
crs_text = self._text(element, "ows:SupportedCRS")
|
|
96
|
+
supported_crs = CRS.EPSG_3857
|
|
97
|
+
if crs_text:
|
|
98
|
+
normalized = crs_text.strip()
|
|
99
|
+
if normalized.upper().endswith("4326") or "CRS:84" in normalized.upper():
|
|
100
|
+
supported_crs = CRS.EPSG_4326
|
|
101
|
+
elif "3857" in normalized:
|
|
102
|
+
supported_crs = CRS.EPSG_3857
|
|
103
|
+
|
|
104
|
+
tms = WMTSTileMatrixSet(identifier=identifier, supported_crs=supported_crs)
|
|
105
|
+
for matrix_elem in element.findall("wmts:TileMatrix", self.namespaces):
|
|
106
|
+
matrix = self._parse_tile_matrix(matrix_elem)
|
|
107
|
+
if matrix is not None:
|
|
108
|
+
tms.matrices[matrix.identifier] = matrix
|
|
109
|
+
return tms
|
|
110
|
+
|
|
111
|
+
def _parse_tile_matrix(self, element: ET.Element) -> WMTSTileMatrix | None:
|
|
112
|
+
identifier = self._text(element, "ows:Identifier")
|
|
113
|
+
if not identifier:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
scale_text = self._text(element, "wmts:ScaleDenominator")
|
|
117
|
+
top_left_text = self._text(element, "wmts:TopLeftCorner")
|
|
118
|
+
if not scale_text or not top_left_text:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
scale_denominator = float(scale_text)
|
|
123
|
+
top_left_parts = [float(part) for part in top_left_text.split()]
|
|
124
|
+
matrix_width = int(self._text(element, "wmts:MatrixWidth") or "0")
|
|
125
|
+
matrix_height = int(self._text(element, "wmts:MatrixHeight") or "0")
|
|
126
|
+
tile_width = int(self._text(element, "wmts:TileWidth") or "256")
|
|
127
|
+
tile_height = int(self._text(element, "wmts:TileHeight") or "256")
|
|
128
|
+
except ValueError:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
if len(top_left_parts) < 2 or matrix_width <= 0 or matrix_height <= 0:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
return WMTSTileMatrix(
|
|
135
|
+
identifier=identifier,
|
|
136
|
+
scale_denominator=scale_denominator,
|
|
137
|
+
top_left_x=top_left_parts[0],
|
|
138
|
+
top_left_y=top_left_parts[1],
|
|
139
|
+
matrix_width=matrix_width,
|
|
140
|
+
matrix_height=matrix_height,
|
|
141
|
+
tile_width=tile_width,
|
|
142
|
+
tile_height=tile_height,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def _parse_layer(self, element: ET.Element) -> WMTSLayerInfo | None:
|
|
146
|
+
identifier = self._text(element, "ows:Identifier")
|
|
147
|
+
if not identifier:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
resource_template: str | None = None
|
|
151
|
+
for resource_elem in element.findall("wmts:ResourceURL", self.namespaces):
|
|
152
|
+
if resource_elem.get("resourceType") == "tile":
|
|
153
|
+
resource_template = resource_elem.get("template")
|
|
154
|
+
if resource_template:
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
tms_links = [
|
|
158
|
+
link.text.strip()
|
|
159
|
+
for link in element.findall(
|
|
160
|
+
"wmts:TileMatrixSetLink/wmts:TileMatrixSet", self.namespaces
|
|
161
|
+
)
|
|
162
|
+
if link.text
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
return WMTSLayerInfo(
|
|
166
|
+
identifier=identifier,
|
|
167
|
+
resource_url_template=resource_template,
|
|
168
|
+
tile_matrix_set_links=tms_links,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _text(self, element: ET.Element, xpath: str) -> str | None:
|
|
172
|
+
child = element.find(xpath, self.namespaces)
|
|
173
|
+
if child is not None and child.text:
|
|
174
|
+
return child.text.strip()
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@register_service(ServiceTypeEnum.WMTS)
|
|
179
|
+
class WMTSService(BaseService):
|
|
180
|
+
"""Client for WMTS GetTile endpoints (REST template or KVP)."""
|
|
181
|
+
|
|
182
|
+
inferred_grid_shape: tuple[int, int] | None = None
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
base_url: str,
|
|
187
|
+
*,
|
|
188
|
+
layer: str,
|
|
189
|
+
tile_matrix_set: str,
|
|
190
|
+
tile_matrix: int,
|
|
191
|
+
style: str = "",
|
|
192
|
+
output_format: Format | None = Format.PNG,
|
|
193
|
+
crs: CRS | None = CRS.EPSG_4326,
|
|
194
|
+
url_template: str | None = None,
|
|
195
|
+
version: str = "1.0.0",
|
|
196
|
+
session: requests.Session | None = None,
|
|
197
|
+
**config: Any,
|
|
198
|
+
) -> None:
|
|
199
|
+
super().__init__(
|
|
200
|
+
base_url,
|
|
201
|
+
layer=layer,
|
|
202
|
+
tile_matrix_set=tile_matrix_set,
|
|
203
|
+
tile_matrix=tile_matrix,
|
|
204
|
+
**config,
|
|
205
|
+
)
|
|
206
|
+
if not layer:
|
|
207
|
+
raise ValueError("WMTS layer must be provided")
|
|
208
|
+
if not tile_matrix_set:
|
|
209
|
+
raise ValueError("WMTS tile_matrix_set must be provided")
|
|
210
|
+
if tile_matrix < 0:
|
|
211
|
+
raise ValueError("tile_matrix must be non-negative")
|
|
212
|
+
|
|
213
|
+
self.layer = layer
|
|
214
|
+
self.tile_matrix_set = tile_matrix_set
|
|
215
|
+
self.tile_matrix = tile_matrix
|
|
216
|
+
self.style = style
|
|
217
|
+
self.version = version
|
|
218
|
+
self.output_format = self._coerce_format(
|
|
219
|
+
output_format or config.get("format") or Format.PNG
|
|
220
|
+
)
|
|
221
|
+
self.native_crs = self._coerce_crs(crs or config.get("crs") or CRS.EPSG_4326)
|
|
222
|
+
self.url_template = url_template or config.get("url_template")
|
|
223
|
+
self.session = session or requests.Session()
|
|
224
|
+
self.parser = WMTSParser()
|
|
225
|
+
self._capabilities: WMTSCapabilities | None = None
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def from_url(cls, url: str, **config: Any) -> WMTSService:
|
|
229
|
+
if config.get("layer") is None:
|
|
230
|
+
raise ValueError("WMTS services require a layer")
|
|
231
|
+
if config.get("tile_matrix_set") is None:
|
|
232
|
+
raise ValueError("WMTS services require a tile_matrix_set")
|
|
233
|
+
if config.get("tile_matrix") is None:
|
|
234
|
+
raise ValueError("WMTS services require a tile_matrix")
|
|
235
|
+
return cls(url, **config)
|
|
236
|
+
|
|
237
|
+
def get_capabilities(self, **params: Any) -> WMTSCapabilities:
|
|
238
|
+
response = self.session.get(
|
|
239
|
+
self.base_url,
|
|
240
|
+
params={
|
|
241
|
+
"service": "WMTS",
|
|
242
|
+
"version": self.version,
|
|
243
|
+
"request": "GetCapabilities",
|
|
244
|
+
**params,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
response.raise_for_status()
|
|
248
|
+
self._capabilities = self.parser.parse_get_capabilities(response.text)
|
|
249
|
+
return self._capabilities
|
|
250
|
+
|
|
251
|
+
def apply_capabilities(self, capabilities: WMTSCapabilities | None = None) -> None:
|
|
252
|
+
"""Fill missing REST template / matrix metadata from GetCapabilities."""
|
|
253
|
+
|
|
254
|
+
caps = capabilities or self._capabilities
|
|
255
|
+
if caps is None:
|
|
256
|
+
caps = self.get_capabilities()
|
|
257
|
+
|
|
258
|
+
layer_info = caps.layers.get(self.layer)
|
|
259
|
+
if layer_info and self.url_template is None:
|
|
260
|
+
self.url_template = layer_info.resource_url_template
|
|
261
|
+
|
|
262
|
+
def plan_tiles(
|
|
263
|
+
self,
|
|
264
|
+
bbox: BoundingBox,
|
|
265
|
+
chunk_size: tuple[int, int],
|
|
266
|
+
**options: object,
|
|
267
|
+
) -> Iterable[TileGeometry]:
|
|
268
|
+
tile_matrix_value = options.get("tile_matrix", self.tile_matrix)
|
|
269
|
+
tile_matrix = (
|
|
270
|
+
int(tile_matrix_value)
|
|
271
|
+
if isinstance(tile_matrix_value, (int, str))
|
|
272
|
+
else self.tile_matrix
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
tms_id = str(options.get("tile_matrix_set") or self.tile_matrix_set)
|
|
276
|
+
matrix = self._resolve_matrix(tms_id, tile_matrix, options)
|
|
277
|
+
|
|
278
|
+
tile_width = matrix.tile_width if matrix else chunk_size[0]
|
|
279
|
+
tile_height = matrix.tile_height if matrix else chunk_size[1]
|
|
280
|
+
target_crs = options.get("crs", self.native_crs)
|
|
281
|
+
if not isinstance(target_crs, CRS):
|
|
282
|
+
target_crs = self.native_crs
|
|
283
|
+
|
|
284
|
+
use_web_mercator = matrix is None or self._is_web_mercator_matrix(
|
|
285
|
+
matrix, target_crs
|
|
286
|
+
)
|
|
287
|
+
if use_web_mercator and target_crs in {CRS.EPSG_4326, CRS.EPSG_3857}:
|
|
288
|
+
return self._plan_web_mercator_tiles(
|
|
289
|
+
bbox,
|
|
290
|
+
tile_matrix,
|
|
291
|
+
tile_width,
|
|
292
|
+
tile_height,
|
|
293
|
+
target_crs,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if matrix is not None:
|
|
297
|
+
return self._plan_matrix_tiles(bbox, matrix, target_crs)
|
|
298
|
+
|
|
299
|
+
return super().plan_tiles(bbox, chunk_size, **options)
|
|
300
|
+
|
|
301
|
+
def build_tile_request(self, tile: TileGeometry, **options: Any) -> TileRequest:
|
|
302
|
+
layer = str(options.get("layer") or self.layer)
|
|
303
|
+
tms = str(options.get("tile_matrix_set") or self.tile_matrix_set)
|
|
304
|
+
style = str(
|
|
305
|
+
options.get("style") if options.get("style") is not None else self.style
|
|
306
|
+
)
|
|
307
|
+
fmt = self._coerce_format(options.get("output_format") or self.output_format)
|
|
308
|
+
crs = self._coerce_crs(options.get("crs") or tile.crs or self.native_crs)
|
|
309
|
+
|
|
310
|
+
tile_col = _require_index(tile.tile_x, options.get("tile_col"))
|
|
311
|
+
tile_row = _require_index(tile.tile_y, options.get("tile_row"))
|
|
312
|
+
matrix_level = int(
|
|
313
|
+
options.get(
|
|
314
|
+
"tile_matrix",
|
|
315
|
+
tile.zoom if tile.zoom is not None else self.tile_matrix,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
template = options.get("url_template") or self.url_template
|
|
320
|
+
if template:
|
|
321
|
+
url = _format_rest_template(
|
|
322
|
+
template,
|
|
323
|
+
layer=layer,
|
|
324
|
+
style=style,
|
|
325
|
+
tile_matrix_set=tms,
|
|
326
|
+
tile_matrix=str(matrix_level),
|
|
327
|
+
tile_row=str(tile_row),
|
|
328
|
+
tile_col=str(tile_col),
|
|
329
|
+
fmt=fmt,
|
|
330
|
+
)
|
|
331
|
+
passthrough = {
|
|
332
|
+
key: value
|
|
333
|
+
for key, value in options.items()
|
|
334
|
+
if key
|
|
335
|
+
not in {
|
|
336
|
+
"output_format",
|
|
337
|
+
"crs",
|
|
338
|
+
"layer",
|
|
339
|
+
"style",
|
|
340
|
+
"tile_matrix_set",
|
|
341
|
+
"tile_matrix",
|
|
342
|
+
"url_template",
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return self.compose_tile_request(
|
|
346
|
+
tile,
|
|
347
|
+
url=url,
|
|
348
|
+
output_format=fmt,
|
|
349
|
+
crs=crs,
|
|
350
|
+
**passthrough,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
params: dict[str, Any] = {
|
|
354
|
+
"service": "WMTS",
|
|
355
|
+
"request": "GetTile",
|
|
356
|
+
"version": self.version,
|
|
357
|
+
"layer": layer,
|
|
358
|
+
"style": style,
|
|
359
|
+
"format": fmt.value,
|
|
360
|
+
"TileMatrixSet": tms,
|
|
361
|
+
"TileMatrix": str(matrix_level),
|
|
362
|
+
"TileRow": str(tile_row),
|
|
363
|
+
"TileCol": str(tile_col),
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
passthrough = {
|
|
367
|
+
key: value
|
|
368
|
+
for key, value in options.items()
|
|
369
|
+
if key
|
|
370
|
+
not in {
|
|
371
|
+
"output_format",
|
|
372
|
+
"crs",
|
|
373
|
+
"layer",
|
|
374
|
+
"style",
|
|
375
|
+
"tile_matrix_set",
|
|
376
|
+
"tile_matrix",
|
|
377
|
+
"url_template",
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return self.compose_tile_request(
|
|
381
|
+
tile,
|
|
382
|
+
url=self.base_url,
|
|
383
|
+
params=params,
|
|
384
|
+
output_format=fmt,
|
|
385
|
+
crs=crs,
|
|
386
|
+
**passthrough,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _resolve_matrix(
|
|
390
|
+
self,
|
|
391
|
+
tile_matrix_set: str,
|
|
392
|
+
tile_matrix: int,
|
|
393
|
+
options: object,
|
|
394
|
+
) -> WMTSTileMatrix | None:
|
|
395
|
+
if isinstance(options, dict):
|
|
396
|
+
matrix_override = options.get("matrix")
|
|
397
|
+
if isinstance(matrix_override, WMTSTileMatrix):
|
|
398
|
+
return matrix_override
|
|
399
|
+
|
|
400
|
+
caps = self._capabilities
|
|
401
|
+
if caps is None:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
tms = caps.tile_matrix_sets.get(tile_matrix_set)
|
|
405
|
+
if tms is None:
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
matrix_key = str(tile_matrix)
|
|
409
|
+
if matrix_key in tms.matrices:
|
|
410
|
+
return tms.matrices[matrix_key]
|
|
411
|
+
return tms.matrices.get(f"EPSG4326:{tile_matrix}") or tms.matrices.get(
|
|
412
|
+
f"GoogleMapsCompatible:{tile_matrix}"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def _is_web_mercator_matrix(self, matrix: WMTSTileMatrix, target_crs: CRS) -> bool:
|
|
416
|
+
return target_crs in {CRS.EPSG_4326, CRS.EPSG_3857} and (
|
|
417
|
+
matrix.top_left_x <= -180.0 or matrix.top_left_x <= -20037508.0
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def _plan_web_mercator_tiles(
|
|
421
|
+
self,
|
|
422
|
+
bbox: BoundingBox,
|
|
423
|
+
zoom: int,
|
|
424
|
+
tile_width: int,
|
|
425
|
+
tile_height: int,
|
|
426
|
+
target_crs: CRS,
|
|
427
|
+
) -> Iterable[TileGeometry]:
|
|
428
|
+
working_bbox = bbox if bbox.crs == CRS.EPSG_4326 else bbox.to_crs(CRS.EPSG_4326)
|
|
429
|
+
x_min, y_min, x_max, y_max = _tile_range_for_bbox(working_bbox, zoom)
|
|
430
|
+
|
|
431
|
+
rows = y_max - y_min + 1
|
|
432
|
+
cols = x_max - x_min + 1
|
|
433
|
+
self.inferred_grid_shape = (rows, cols)
|
|
434
|
+
|
|
435
|
+
for tile_y in range(y_min, y_max + 1):
|
|
436
|
+
for tile_x in range(x_min, x_max + 1):
|
|
437
|
+
tile_bbox = _tile_bounds(tile_x, tile_y, zoom, target_crs)
|
|
438
|
+
yield TileGeometry(
|
|
439
|
+
bbox=tile_bbox,
|
|
440
|
+
width=tile_width,
|
|
441
|
+
height=tile_height,
|
|
442
|
+
crs=target_crs,
|
|
443
|
+
tile_x=tile_x,
|
|
444
|
+
tile_y=tile_y,
|
|
445
|
+
zoom=zoom,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def _plan_matrix_tiles(
|
|
449
|
+
self,
|
|
450
|
+
bbox: BoundingBox,
|
|
451
|
+
matrix: WMTSTileMatrix,
|
|
452
|
+
target_crs: CRS,
|
|
453
|
+
) -> Iterable[TileGeometry]:
|
|
454
|
+
pixel_size = matrix.scale_denominator * 0.00028 # OGC standard pixel size (mm)
|
|
455
|
+
|
|
456
|
+
span_x = abs(pixel_size * matrix.tile_width)
|
|
457
|
+
span_y = abs(pixel_size * matrix.tile_height)
|
|
458
|
+
|
|
459
|
+
min_col = _matrix_index(
|
|
460
|
+
bbox.min_x, matrix.top_left_x, span_x, matrix.matrix_width, reverse=False
|
|
461
|
+
)
|
|
462
|
+
max_col = _matrix_index(
|
|
463
|
+
bbox.max_x, matrix.top_left_x, span_x, matrix.matrix_width, reverse=False
|
|
464
|
+
)
|
|
465
|
+
min_row = _matrix_index(
|
|
466
|
+
bbox.max_y, matrix.top_left_y, span_y, matrix.matrix_height, reverse=True
|
|
467
|
+
)
|
|
468
|
+
max_row = _matrix_index(
|
|
469
|
+
bbox.min_y, matrix.top_left_y, span_y, matrix.matrix_height, reverse=True
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
rows = max_row - min_row + 1
|
|
473
|
+
cols = max_col - min_col + 1
|
|
474
|
+
self.inferred_grid_shape = (rows, cols)
|
|
475
|
+
|
|
476
|
+
for row in range(min_row, max_row + 1):
|
|
477
|
+
for col in range(min_col, max_col + 1):
|
|
478
|
+
if matrix.top_left_y >= 0:
|
|
479
|
+
max_y = matrix.top_left_y - row * span_y
|
|
480
|
+
min_y = max_y - span_y
|
|
481
|
+
else:
|
|
482
|
+
min_y = matrix.top_left_y + row * span_y
|
|
483
|
+
max_y = min_y + span_y
|
|
484
|
+
|
|
485
|
+
min_x = matrix.top_left_x + col * span_x
|
|
486
|
+
max_x = min_x + span_x
|
|
487
|
+
|
|
488
|
+
yield TileGeometry(
|
|
489
|
+
bbox=BoundingBox(
|
|
490
|
+
min_x=min_x,
|
|
491
|
+
min_y=min_y,
|
|
492
|
+
max_x=max_x,
|
|
493
|
+
max_y=max_y,
|
|
494
|
+
crs=target_crs,
|
|
495
|
+
),
|
|
496
|
+
width=matrix.tile_width,
|
|
497
|
+
height=matrix.tile_height,
|
|
498
|
+
crs=target_crs,
|
|
499
|
+
tile_x=col,
|
|
500
|
+
tile_y=row,
|
|
501
|
+
zoom=int(matrix.identifier)
|
|
502
|
+
if matrix.identifier.isdigit()
|
|
503
|
+
else self.tile_matrix,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def _coerce_format(self, fmt: Any) -> Format:
|
|
507
|
+
if isinstance(fmt, Format):
|
|
508
|
+
return fmt
|
|
509
|
+
if isinstance(fmt, str):
|
|
510
|
+
try:
|
|
511
|
+
return Format(fmt)
|
|
512
|
+
except ValueError as exc:
|
|
513
|
+
raise ValueError(f"Unsupported WMTS format: {fmt}") from exc
|
|
514
|
+
raise ValueError(f"Invalid WMTS format value: {fmt!r}")
|
|
515
|
+
|
|
516
|
+
def _coerce_crs(self, crs: Any) -> CRS:
|
|
517
|
+
if isinstance(crs, CRS):
|
|
518
|
+
return crs
|
|
519
|
+
if isinstance(crs, str):
|
|
520
|
+
return (
|
|
521
|
+
CRS.from_epsg(crs)
|
|
522
|
+
if crs.upper().startswith("EPSG:")
|
|
523
|
+
else CRS.from_integer(int(crs))
|
|
524
|
+
)
|
|
525
|
+
if isinstance(crs, int):
|
|
526
|
+
return CRS.from_integer(crs)
|
|
527
|
+
raise ValueError(f"Invalid CRS value: {crs!r}")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _require_index(value: int | None, override: int | None) -> int:
|
|
531
|
+
resolved = override if override is not None else value
|
|
532
|
+
if resolved is None:
|
|
533
|
+
raise ValueError("WMTS tile indices are required")
|
|
534
|
+
return int(resolved)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _format_rest_template(
|
|
538
|
+
template: str,
|
|
539
|
+
*,
|
|
540
|
+
layer: str,
|
|
541
|
+
style: str,
|
|
542
|
+
tile_matrix_set: str,
|
|
543
|
+
tile_matrix: str,
|
|
544
|
+
tile_row: str,
|
|
545
|
+
tile_col: str,
|
|
546
|
+
fmt: Format,
|
|
547
|
+
) -> str:
|
|
548
|
+
extension = _format_extension(fmt)
|
|
549
|
+
replacements = {
|
|
550
|
+
"{Layer}": quote(layer, safe=""),
|
|
551
|
+
"{Style}": quote(style, safe=""),
|
|
552
|
+
"{TileMatrixSet}": quote(tile_matrix_set, safe=""),
|
|
553
|
+
"{TileMatrix}": tile_matrix,
|
|
554
|
+
"{TileRow}": tile_row,
|
|
555
|
+
"{TileCol}": tile_col,
|
|
556
|
+
"{Format}": quote(fmt.value, safe=""),
|
|
557
|
+
}
|
|
558
|
+
url = template
|
|
559
|
+
for token, value in replacements.items():
|
|
560
|
+
url = url.replace(token, value)
|
|
561
|
+
if "{FormatExtension}" in url:
|
|
562
|
+
url = url.replace("{FormatExtension}", extension)
|
|
563
|
+
return url
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _format_extension(fmt: Format) -> str:
|
|
567
|
+
if fmt == Format.PNG:
|
|
568
|
+
return "png"
|
|
569
|
+
if fmt == Format.JPEG:
|
|
570
|
+
return "jpg"
|
|
571
|
+
if fmt == Format.GEOTIFF:
|
|
572
|
+
return "tif"
|
|
573
|
+
raise ValueError(f"Unsupported format: {fmt}")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _matrix_index(
|
|
577
|
+
coordinate: float,
|
|
578
|
+
origin: float,
|
|
579
|
+
span: float,
|
|
580
|
+
limit: int,
|
|
581
|
+
*,
|
|
582
|
+
reverse: bool,
|
|
583
|
+
) -> int:
|
|
584
|
+
if span <= 0:
|
|
585
|
+
return 0
|
|
586
|
+
if reverse:
|
|
587
|
+
raw = math.floor((origin - coordinate) / span)
|
|
588
|
+
else:
|
|
589
|
+
raw = math.floor((coordinate - origin) / span)
|
|
590
|
+
return max(0, min(int(raw), limit - 1))
|