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.
@@ -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))