GeoPakTile 2.0.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.
- geopaktile/__init__.py +5 -0
- geopaktile/__main__.py +4 -0
- geopaktile/geopaktile.py +442 -0
- geopaktile/xml_parser.py +191 -0
- geopaktile-2.0.0.dist-info/METADATA +69 -0
- geopaktile-2.0.0.dist-info/RECORD +9 -0
- geopaktile-2.0.0.dist-info/WHEEL +4 -0
- geopaktile-2.0.0.dist-info/entry_points.txt +3 -0
- geopaktile-2.0.0.dist-info/licenses/LICENSE.md +9 -0
geopaktile/__init__.py
ADDED
geopaktile/__main__.py
ADDED
geopaktile/geopaktile.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sqlite3
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from os import PathLike
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
from typing import Self
|
|
10
|
+
else:
|
|
11
|
+
from typing_extensions import Self
|
|
12
|
+
|
|
13
|
+
from typing import ClassVar, Literal
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from morecantile.models import TileMatrixSet
|
|
17
|
+
from pyproj import CRS
|
|
18
|
+
|
|
19
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GeoPakTile:
|
|
23
|
+
DEFAULT_PRAGMAS: ClassVar[dict[str, str]] = {
|
|
24
|
+
"synchronous": "NORMAL",
|
|
25
|
+
"journal_mode": "WAL",
|
|
26
|
+
"locking_mode": "EXCLUSIVE",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
filepath: str | PathLike,
|
|
32
|
+
tms: TileMatrixSet,
|
|
33
|
+
table_name: str = "tiles",
|
|
34
|
+
mode: Literal["ro", "rw", "rwc"] = "ro",
|
|
35
|
+
pragmas: dict[str, str] | None = None,
|
|
36
|
+
):
|
|
37
|
+
"""Create and or write tiles to a geopackage XYZ tile layer.
|
|
38
|
+
|
|
39
|
+
A tile matrix set is required in order to get the correct co-ordinates for the tiles and data extents.
|
|
40
|
+
These are provided by the `morecantile` library, or can be parsed from a WMTS capabilities document using the
|
|
41
|
+
`xml_parser.parse_tile_matrix_set` function.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
filepath (str | PathLike): where the geopackage is or should be created
|
|
45
|
+
tms (TileMatrixSet): Describes the tile coordinates and data extent
|
|
46
|
+
table_name (str, optional): The name of the tile table/layer. Defaults to "tiles".
|
|
47
|
+
mode (Literal[ro, rw, rwc], optional): Sqlite3 connection modes. ro = read only, rw is
|
|
48
|
+
read-write, rwc is read, write or create. Defaults to 'ro' for safety.
|
|
49
|
+
pragmas (dict[str, str], optional): PRAGMA overrides/additions applied
|
|
50
|
+
on top of DEFAULT_PRAGMAS after connecting. Pass e.g.
|
|
51
|
+
{"synchronous": "FULL"} to override a default, or add new keys
|
|
52
|
+
for pragmas not set by default. Defaults to None (no changes).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
self.filepath = Path(filepath)
|
|
56
|
+
self.tms = tms
|
|
57
|
+
self.table_name = table_name
|
|
58
|
+
|
|
59
|
+
self._validate_table_name()
|
|
60
|
+
|
|
61
|
+
if mode not in ("ro", "rw", "rwc"):
|
|
62
|
+
raise ValueError("Mode must be ro, rw or rwc")
|
|
63
|
+
|
|
64
|
+
if mode in ["ro", "rw"] and not self.filepath.exists():
|
|
65
|
+
raise FileNotFoundError("geopackage not found")
|
|
66
|
+
|
|
67
|
+
file_string = f"{self.filepath.absolute().as_uri()}?mode={mode}"
|
|
68
|
+
self._conn = sqlite3.connect(file_string, uri=True)
|
|
69
|
+
self._cursor = self._conn.cursor()
|
|
70
|
+
self._apply_pragmas(pragmas)
|
|
71
|
+
|
|
72
|
+
# initialize tables if needed and in create mode
|
|
73
|
+
if mode == "rwc":
|
|
74
|
+
self._create_tables()
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> Self:
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
80
|
+
self.close()
|
|
81
|
+
|
|
82
|
+
def close(self):
|
|
83
|
+
"""Close the connection"""
|
|
84
|
+
self._cursor.close()
|
|
85
|
+
self._conn.close()
|
|
86
|
+
|
|
87
|
+
def has_tile(self, z: int, x: int, y: int):
|
|
88
|
+
"""Check if a tile is present, using the zxy coordinates"""
|
|
89
|
+
|
|
90
|
+
self._cursor.execute(
|
|
91
|
+
f"SELECT EXISTS (SELECT 1 FROM {self.table_name} "
|
|
92
|
+
"where zoom_level=? and tile_column=? and tile_row=? LIMIT 1)",
|
|
93
|
+
(z, x, y),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
row = self._cursor.fetchone()
|
|
97
|
+
return row[0] == 1
|
|
98
|
+
|
|
99
|
+
def has_tiles(self, zxys: list[tuple[int, int, int]] | np.ndarray) -> np.ndarray:
|
|
100
|
+
"""Check many tiles efficiently."""
|
|
101
|
+
|
|
102
|
+
requested = np.asarray(zxys, dtype=np.uint64)
|
|
103
|
+
|
|
104
|
+
self._cursor.execute(
|
|
105
|
+
f"SELECT zoom_level, tile_column, tile_row FROM {self.table_name}"
|
|
106
|
+
)
|
|
107
|
+
rows = self._cursor.fetchall()
|
|
108
|
+
existing = (
|
|
109
|
+
np.array(rows, dtype=np.uint64)
|
|
110
|
+
if rows
|
|
111
|
+
else np.empty((0, 3), dtype=np.uint64)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Combine z, x, y into a single array using bit shifting
|
|
115
|
+
# [ 8 bits unused ][ 8 bits z ][ 28 bits x ][ 28 bits y ]
|
|
116
|
+
def pack(arr: np.ndarray) -> np.ndarray:
|
|
117
|
+
z, x, y = arr[:, 0], arr[:, 1], arr[:, 2]
|
|
118
|
+
return (z << np.uint64(56)) | (x << np.uint64(28)) | y
|
|
119
|
+
|
|
120
|
+
req_keys = pack(requested)
|
|
121
|
+
exist_keys = pack(existing)
|
|
122
|
+
|
|
123
|
+
return np.isin(req_keys, exist_keys, assume_unique=False)
|
|
124
|
+
|
|
125
|
+
def add_tiles(
|
|
126
|
+
self,
|
|
127
|
+
tiles: Iterable[tuple[int, int, int, bytes]],
|
|
128
|
+
batch_size: int = 1000,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Add tiles to the geopackage
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
tiles (Iterable[tuple[int, int, int, bytes]]): List of tiles - [z, x, y, image data in bytes]
|
|
134
|
+
batch_size (int, optional): Add images in batches for speed. Defaults to 1000.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
insert_sql = f"""
|
|
138
|
+
INSERT OR IGNORE INTO "{self.table_name}"
|
|
139
|
+
(
|
|
140
|
+
zoom_level,
|
|
141
|
+
tile_column,
|
|
142
|
+
tile_row,
|
|
143
|
+
tile_data
|
|
144
|
+
)
|
|
145
|
+
VALUES (?, ?, ?, ?)
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
zs = {t[0] for t in tiles}
|
|
149
|
+
for z in zs:
|
|
150
|
+
self._ensure_zoom_level(z)
|
|
151
|
+
|
|
152
|
+
batch = []
|
|
153
|
+
for z, x, y, tile_bytes in tiles:
|
|
154
|
+
z = int(z)
|
|
155
|
+
x = int(x)
|
|
156
|
+
y = int(y)
|
|
157
|
+
|
|
158
|
+
if self.has_tile(z, x, y):
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
batch.append((z, x, y, sqlite3.Binary(tile_bytes)))
|
|
162
|
+
if len(batch) >= batch_size:
|
|
163
|
+
self._cursor.executemany(insert_sql, batch)
|
|
164
|
+
self._conn.commit()
|
|
165
|
+
batch.clear()
|
|
166
|
+
|
|
167
|
+
if batch:
|
|
168
|
+
self._cursor.executemany(insert_sql, batch)
|
|
169
|
+
self._conn.commit()
|
|
170
|
+
|
|
171
|
+
def _apply_pragmas(self, overrides: dict[str, str] | None) -> None:
|
|
172
|
+
merged = {**self.DEFAULT_PRAGMAS, **(overrides or {})}
|
|
173
|
+
for name, value in merged.items():
|
|
174
|
+
# Ensure malicious values aren't parsed.
|
|
175
|
+
# Passes: "WAL", "NORMAL", "OFF", "EXCLUSIVE", "1000" (a plausible wal_autocheckpoint value)
|
|
176
|
+
# Blocked: "WAL; DROP TABLE tiles;--", "OFF' OR '1'='1", anything with spaces, quotes, or punctuation
|
|
177
|
+
if not str(value).replace("_", "").isalnum():
|
|
178
|
+
raise ValueError(f"Unsafe PRAGMA value for {name!r}: {value!r}")
|
|
179
|
+
self._cursor.execute(f"PRAGMA {name}={value}")
|
|
180
|
+
|
|
181
|
+
def _create_tables(self):
|
|
182
|
+
"""Create the tables for a valid geopackage XYZ layer"""
|
|
183
|
+
|
|
184
|
+
self._cursor.execute("PRAGMA application_id = 1196437808")
|
|
185
|
+
self._cursor.execute("PRAGMA user_version = 10400")
|
|
186
|
+
|
|
187
|
+
self._cursor.execute(
|
|
188
|
+
"""
|
|
189
|
+
CREATE TABLE IF NOT EXISTS gpkg_spatial_ref_sys (
|
|
190
|
+
srs_name TEXT NOT NULL,
|
|
191
|
+
srs_id INTEGER NOT NULL PRIMARY KEY,
|
|
192
|
+
organization TEXT NOT NULL,
|
|
193
|
+
organization_coordsys_id INTEGER NOT NULL,
|
|
194
|
+
definition TEXT NOT NULL,
|
|
195
|
+
description TEXT
|
|
196
|
+
)
|
|
197
|
+
"""
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
self._cursor.execute(
|
|
201
|
+
"""
|
|
202
|
+
INSERT OR IGNORE INTO gpkg_spatial_ref_sys
|
|
203
|
+
VALUES
|
|
204
|
+
(
|
|
205
|
+
'Undefined Cartesian SRS',
|
|
206
|
+
-1,
|
|
207
|
+
'NONE',
|
|
208
|
+
-1,
|
|
209
|
+
'undefined',
|
|
210
|
+
'undefined Cartesian coordinate reference system'
|
|
211
|
+
)
|
|
212
|
+
"""
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self._cursor.execute(
|
|
216
|
+
"""
|
|
217
|
+
INSERT OR IGNORE INTO gpkg_spatial_ref_sys
|
|
218
|
+
VALUES
|
|
219
|
+
(
|
|
220
|
+
'Undefined Geographic SRS',
|
|
221
|
+
0,
|
|
222
|
+
'NONE',
|
|
223
|
+
0,
|
|
224
|
+
'undefined',
|
|
225
|
+
'undefined geographic coordinate reference system'
|
|
226
|
+
)
|
|
227
|
+
"""
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
epsg = self.tms.crs.to_epsg()
|
|
231
|
+
if epsg is None:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"TileMatrixSet CRS {self.tms.crs} does not have an EPSG code."
|
|
234
|
+
)
|
|
235
|
+
crs = CRS.from_epsg(epsg)
|
|
236
|
+
srs_name = crs.name
|
|
237
|
+
definition = crs.to_wkt()
|
|
238
|
+
|
|
239
|
+
self._cursor.execute(
|
|
240
|
+
"""
|
|
241
|
+
INSERT OR IGNORE INTO gpkg_spatial_ref_sys
|
|
242
|
+
(
|
|
243
|
+
srs_name,
|
|
244
|
+
srs_id,
|
|
245
|
+
organization,
|
|
246
|
+
organization_coordsys_id,
|
|
247
|
+
definition,
|
|
248
|
+
description
|
|
249
|
+
)
|
|
250
|
+
VALUES (?, ?, 'EPSG', ?, ?, ?)
|
|
251
|
+
""",
|
|
252
|
+
(
|
|
253
|
+
srs_name,
|
|
254
|
+
epsg,
|
|
255
|
+
epsg,
|
|
256
|
+
definition,
|
|
257
|
+
srs_name,
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
self._cursor.execute(
|
|
262
|
+
"""
|
|
263
|
+
CREATE TABLE IF NOT EXISTS gpkg_contents (
|
|
264
|
+
table_name TEXT NOT NULL PRIMARY KEY,
|
|
265
|
+
data_type TEXT NOT NULL,
|
|
266
|
+
identifier TEXT UNIQUE,
|
|
267
|
+
description TEXT DEFAULT '',
|
|
268
|
+
last_change DATETIME NOT NULL DEFAULT
|
|
269
|
+
(strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
270
|
+
min_x DOUBLE,
|
|
271
|
+
min_y DOUBLE,
|
|
272
|
+
max_x DOUBLE,
|
|
273
|
+
max_y DOUBLE,
|
|
274
|
+
srs_id INTEGER,
|
|
275
|
+
CONSTRAINT fk_gc_r_srs_id
|
|
276
|
+
FOREIGN KEY (srs_id)
|
|
277
|
+
REFERENCES gpkg_spatial_ref_sys(srs_id)
|
|
278
|
+
)
|
|
279
|
+
"""
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
self._cursor.execute(
|
|
283
|
+
"""
|
|
284
|
+
CREATE TABLE IF NOT EXISTS gpkg_tile_matrix_set (
|
|
285
|
+
table_name TEXT NOT NULL PRIMARY KEY,
|
|
286
|
+
srs_id INTEGER NOT NULL,
|
|
287
|
+
min_x DOUBLE NOT NULL,
|
|
288
|
+
min_y DOUBLE NOT NULL,
|
|
289
|
+
max_x DOUBLE NOT NULL,
|
|
290
|
+
max_y DOUBLE NOT NULL,
|
|
291
|
+
CONSTRAINT fk_gtms_table_name
|
|
292
|
+
FOREIGN KEY (table_name)
|
|
293
|
+
REFERENCES gpkg_contents(table_name),
|
|
294
|
+
CONSTRAINT fk_gtms_srs
|
|
295
|
+
FOREIGN KEY (srs_id)
|
|
296
|
+
REFERENCES gpkg_spatial_ref_sys(srs_id)
|
|
297
|
+
)
|
|
298
|
+
"""
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
self._cursor.execute(
|
|
302
|
+
"""
|
|
303
|
+
CREATE TABLE IF NOT EXISTS gpkg_tile_matrix (
|
|
304
|
+
table_name TEXT NOT NULL,
|
|
305
|
+
zoom_level INTEGER NOT NULL,
|
|
306
|
+
matrix_width INTEGER NOT NULL,
|
|
307
|
+
matrix_height INTEGER NOT NULL,
|
|
308
|
+
tile_width INTEGER NOT NULL,
|
|
309
|
+
tile_height INTEGER NOT NULL,
|
|
310
|
+
pixel_x_size DOUBLE NOT NULL,
|
|
311
|
+
pixel_y_size DOUBLE NOT NULL,
|
|
312
|
+
CONSTRAINT pk_ttm PRIMARY KEY (table_name, zoom_level),
|
|
313
|
+
CONSTRAINT fk_tmm_table_name
|
|
314
|
+
FOREIGN KEY (table_name)
|
|
315
|
+
REFERENCES gpkg_contents(table_name)
|
|
316
|
+
)
|
|
317
|
+
"""
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
self._cursor.execute(
|
|
321
|
+
f"""
|
|
322
|
+
CREATE TABLE IF NOT EXISTS "{self.table_name}" (
|
|
323
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
324
|
+
zoom_level INTEGER NOT NULL,
|
|
325
|
+
tile_column INTEGER NOT NULL,
|
|
326
|
+
tile_row INTEGER NOT NULL,
|
|
327
|
+
tile_data BLOB NOT NULL,
|
|
328
|
+
UNIQUE (zoom_level, tile_column, tile_row)
|
|
329
|
+
)
|
|
330
|
+
"""
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
self._cursor.execute(
|
|
334
|
+
f"""
|
|
335
|
+
CREATE INDEX IF NOT EXISTS "{self.table_name}_zxy_idx"
|
|
336
|
+
ON "{self.table_name}" (zoom_level, tile_column, tile_row)
|
|
337
|
+
"""
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
bb = self.tms.xy_bbox
|
|
341
|
+
|
|
342
|
+
self._cursor.execute(
|
|
343
|
+
"""
|
|
344
|
+
INSERT OR REPLACE INTO gpkg_contents
|
|
345
|
+
(
|
|
346
|
+
table_name,
|
|
347
|
+
data_type,
|
|
348
|
+
identifier,
|
|
349
|
+
description,
|
|
350
|
+
min_x,
|
|
351
|
+
min_y,
|
|
352
|
+
max_x,
|
|
353
|
+
max_y,
|
|
354
|
+
srs_id
|
|
355
|
+
)
|
|
356
|
+
VALUES (?, 'tiles', ?, '', ?, ?, ?, ?, ?)
|
|
357
|
+
""",
|
|
358
|
+
(
|
|
359
|
+
self.table_name,
|
|
360
|
+
self.table_name,
|
|
361
|
+
bb.left,
|
|
362
|
+
bb.bottom,
|
|
363
|
+
bb.right,
|
|
364
|
+
bb.top,
|
|
365
|
+
epsg,
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
self._cursor.execute(
|
|
370
|
+
"""
|
|
371
|
+
INSERT OR REPLACE INTO gpkg_tile_matrix_set
|
|
372
|
+
(
|
|
373
|
+
table_name,
|
|
374
|
+
srs_id,
|
|
375
|
+
min_x,
|
|
376
|
+
min_y,
|
|
377
|
+
max_x,
|
|
378
|
+
max_y
|
|
379
|
+
)
|
|
380
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
381
|
+
""",
|
|
382
|
+
(
|
|
383
|
+
self.table_name,
|
|
384
|
+
epsg,
|
|
385
|
+
bb.left,
|
|
386
|
+
bb.bottom,
|
|
387
|
+
bb.right,
|
|
388
|
+
bb.top,
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
self._conn.commit()
|
|
393
|
+
|
|
394
|
+
def _ensure_zoom_level(
|
|
395
|
+
self,
|
|
396
|
+
z: int,
|
|
397
|
+
) -> None:
|
|
398
|
+
"""
|
|
399
|
+
Add one gpkg_tile_matrix row for a zoom level.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
tm = self.tms.matrix(z)
|
|
403
|
+
self._cursor.execute(
|
|
404
|
+
"""
|
|
405
|
+
INSERT OR IGNORE INTO gpkg_tile_matrix
|
|
406
|
+
(
|
|
407
|
+
table_name,
|
|
408
|
+
zoom_level,
|
|
409
|
+
matrix_width,
|
|
410
|
+
matrix_height,
|
|
411
|
+
tile_width,
|
|
412
|
+
tile_height,
|
|
413
|
+
pixel_x_size,
|
|
414
|
+
pixel_y_size
|
|
415
|
+
)
|
|
416
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
417
|
+
""",
|
|
418
|
+
(
|
|
419
|
+
self.table_name,
|
|
420
|
+
z,
|
|
421
|
+
tm.matrixWidth,
|
|
422
|
+
tm.matrixHeight,
|
|
423
|
+
tm.tileWidth,
|
|
424
|
+
tm.tileHeight,
|
|
425
|
+
tm.cellSize,
|
|
426
|
+
tm.cellSize,
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def _validate_table_name(self) -> None:
|
|
431
|
+
"""Ensure table_name is safe to interpolate into SQL and spec-compliant."""
|
|
432
|
+
if not _IDENTIFIER_RE.match(self.table_name):
|
|
433
|
+
raise ValueError(
|
|
434
|
+
f"Invalid table_name {self.table_name!r}: must match "
|
|
435
|
+
f"[A-Za-z_][A-Za-z0-9_]* (letters, digits, underscore only, "
|
|
436
|
+
f"not starting with a digit)."
|
|
437
|
+
)
|
|
438
|
+
if self.table_name.lower().startswith("gpkg_"):
|
|
439
|
+
raise ValueError(
|
|
440
|
+
f"Invalid table_name {self.table_name!r}: the 'gpkg_' prefix is "
|
|
441
|
+
f"reserved by the GeoPackage spec."
|
|
442
|
+
)
|
geopaktile/xml_parser.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Parse OGC WMTS GetCapabilities XML into TileMatrix / TileMatrixSet models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from xml.etree import ElementTree as ET
|
|
7
|
+
|
|
8
|
+
import pyproj
|
|
9
|
+
from morecantile.models import CRS, TileMatrix, TileMatrixSet, TMSBoundingBox
|
|
10
|
+
from morecantile.utils import meters_per_unit
|
|
11
|
+
|
|
12
|
+
NS = {
|
|
13
|
+
"wmts": "http://www.opengis.net/wmts/1.0",
|
|
14
|
+
"ows": "http://www.opengis.net/ows/1.1",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# Matches the EPSG code out of any of the common ways a CRS URN/URI is
|
|
18
|
+
# written in WMTS capabilities, e.g.:
|
|
19
|
+
# "urn:ogc:def:crs:EPSG::27700"
|
|
20
|
+
# "urn:ogc:def:crs:EPSG:6.3:27700"
|
|
21
|
+
# "urn:ogc:def:crs:EPSG:6.18:3:3857" (multiple version/codespace segments)
|
|
22
|
+
# "EPSG:27700"
|
|
23
|
+
# "http://www.opengis.net/def/crs/EPSG/0/27700"
|
|
24
|
+
# The middle segment is repeated (*, not ?) since some URN authorities
|
|
25
|
+
# chain more than one version/codespace token before the actual code.
|
|
26
|
+
_EPSG_RE = re.compile(r"EPSG[:/]+(?:[\w.]+[:/]+)*(\d+)\s*$", re.IGNORECASE)
|
|
27
|
+
|
|
28
|
+
# CRS84 ("urn:ogc:def:crs:OGC:1.3:CRS84") is WGS84 with (lon, lat) axis
|
|
29
|
+
# order
|
|
30
|
+
_CRS84_RE = re.compile(r"\bCRS84\b", re.IGNORECASE)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_crs(crs_text: str) -> str:
|
|
34
|
+
"""Parse a SupportedCRS string into a CRS string pyproj/morecantile accept.
|
|
35
|
+
|
|
36
|
+
Returns "OGC:CRS84" for CRS84 and "EPSG:<code>" otherwise.
|
|
37
|
+
"""
|
|
38
|
+
crs_text = crs_text.strip()
|
|
39
|
+
if _CRS84_RE.search(crs_text):
|
|
40
|
+
return "OGC:CRS84"
|
|
41
|
+
|
|
42
|
+
match = _EPSG_RE.search(crs_text)
|
|
43
|
+
if not match:
|
|
44
|
+
raise ValueError(f"Could not parse EPSG code from SupportedCRS: {crs_text!r}")
|
|
45
|
+
return f"EPSG:{int(match.group(1))}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _text(elem: ET.Element, path: str, ns: dict[str, str] = NS) -> str:
|
|
49
|
+
found = elem.find(path, ns)
|
|
50
|
+
if found is None or found.text is None:
|
|
51
|
+
raise ValueError(f"Missing required element {path!r} in {elem.tag}")
|
|
52
|
+
return found.text.strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _text_any_ns(elem: ET.Element, local_name: str) -> str:
|
|
56
|
+
"""Look up a WMTS-namespaced child, tolerating servers that omit the
|
|
57
|
+
default xmlns declaration and emit bare (no-namespace) element names."""
|
|
58
|
+
found = elem.find(f"wmts:{local_name}", NS)
|
|
59
|
+
if found is None:
|
|
60
|
+
found = elem.find(local_name)
|
|
61
|
+
if found is None or found.text is None:
|
|
62
|
+
raise ValueError(f"Missing required element {local_name!r} in {elem.tag}")
|
|
63
|
+
return found.text.strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_tile_matrix(elem: ET.Element, fallback_zoom: int, meters_per_crs_unit: float) -> TileMatrix:
|
|
67
|
+
"""Parse a single <TileMatrix> element.
|
|
68
|
+
|
|
69
|
+
The WMTS spec doesn't require ows:Identifier to be an integer zoom
|
|
70
|
+
level, just a unique label. morecantile requires an integer id, so we
|
|
71
|
+
try the identifier first and fall back to the element's position in the
|
|
72
|
+
(already document-ordered) TileMatrixSet.
|
|
73
|
+
|
|
74
|
+
pointOfOrigin is kept in the order written in the capabilities (the
|
|
75
|
+
CRS axis order); TileMatrixSet handles any lat/lon inversion itself.
|
|
76
|
+
"""
|
|
77
|
+
raw_id = _text(elem, "ows:Identifier")
|
|
78
|
+
try:
|
|
79
|
+
zoom_level = int(raw_id)
|
|
80
|
+
except ValueError:
|
|
81
|
+
zoom_level = fallback_zoom
|
|
82
|
+
|
|
83
|
+
first, second = _text_any_ns(elem, "TopLeftCorner").split()
|
|
84
|
+
scale_denominator = float(_text_any_ns(elem, "ScaleDenominator"))
|
|
85
|
+
|
|
86
|
+
return TileMatrix(
|
|
87
|
+
id=str(zoom_level),
|
|
88
|
+
scaleDenominator=scale_denominator,
|
|
89
|
+
cellSize=scale_denominator * 0.28e-3 / meters_per_crs_unit,
|
|
90
|
+
cornerOfOrigin="topLeft",
|
|
91
|
+
pointOfOrigin=(float(first), float(second)),
|
|
92
|
+
tileWidth=int(_text_any_ns(elem, "TileWidth")),
|
|
93
|
+
tileHeight=int(_text_any_ns(elem, "TileHeight")),
|
|
94
|
+
matrixWidth=int(_text_any_ns(elem, "MatrixWidth")),
|
|
95
|
+
matrixHeight=int(_text_any_ns(elem, "MatrixHeight")),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_bounding_box(elem: ET.Element) -> TMSBoundingBox | None:
|
|
100
|
+
"""Parse an optional ows:BoundingBox (coordinates in CRS axis order)."""
|
|
101
|
+
bbox = elem.find("ows:BoundingBox", NS)
|
|
102
|
+
if bbox is None:
|
|
103
|
+
return None
|
|
104
|
+
lower = tuple(float(v) for v in _text(bbox, "ows:LowerCorner").split())
|
|
105
|
+
upper = tuple(float(v) for v in _text(bbox, "ows:UpperCorner").split())
|
|
106
|
+
return TMSBoundingBox(lowerLeft=lower, upperRight=upper) # type: ignore morecantile uses NumType = int | float
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_tile_matrix_set(elem: ET.Element, swap_xy: bool | None = None) -> TileMatrixSet:
|
|
110
|
+
"""Parse a single <TileMatrixSet> element (found under <Contents>).
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
swap_xy : bool | None
|
|
115
|
+
Whether TopLeftCorner/BoundingBox coordinates are written as
|
|
116
|
+
(lat, lon) instead of (lon, lat) / (x, y). If None (default),
|
|
117
|
+
morecantile infers this from the CRS's axis order (e.g. True for
|
|
118
|
+
EPSG:4326, False for CRS84). WMTS servers are inconsistent about
|
|
119
|
+
this in practice, so if your parsed bounds look implausible, try
|
|
120
|
+
passing the opposite explicitly; this sets the TileMatrixSet's
|
|
121
|
+
orderedAxes.
|
|
122
|
+
"""
|
|
123
|
+
identifier = _text(elem, "ows:Identifier")
|
|
124
|
+
crs_str = _parse_crs(_text(elem, "ows:SupportedCRS"))
|
|
125
|
+
mpu = meters_per_unit(pyproj.CRS.from_user_input(crs_str))
|
|
126
|
+
|
|
127
|
+
tile_matrix_elems = elem.findall("wmts:TileMatrix", NS) or elem.findall("TileMatrix")
|
|
128
|
+
if not tile_matrix_elems:
|
|
129
|
+
raise ValueError(f"TileMatrixSet {identifier!r} has no TileMatrix levels")
|
|
130
|
+
|
|
131
|
+
tile_matrices = [
|
|
132
|
+
_parse_tile_matrix(tm_elem, fallback_zoom=i, meters_per_crs_unit=mpu)
|
|
133
|
+
for i, tm_elem in enumerate(tile_matrix_elems)
|
|
134
|
+
]
|
|
135
|
+
tile_matrices.sort(key=lambda tm: int(tm.id))
|
|
136
|
+
|
|
137
|
+
ordered_axes = None if swap_xy is None else (["Y", "X"] if swap_xy else ["X", "Y"])
|
|
138
|
+
|
|
139
|
+
return TileMatrixSet(
|
|
140
|
+
# morecantile restricts ids to [\w\d_-]; keep the original as title.
|
|
141
|
+
id=re.sub(r"[^\w\-]", "_", identifier),
|
|
142
|
+
title=identifier,
|
|
143
|
+
crs=CRS(crs_str),
|
|
144
|
+
orderedAxes=ordered_axes,
|
|
145
|
+
boundingBox=_parse_bounding_box(elem),
|
|
146
|
+
tileMatrices=tile_matrices,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_capabilities(xml_source: str | bytes, swap_xy: bool | None = None) -> list[TileMatrixSet]:
|
|
151
|
+
"""Parse a full WMTS GetCapabilities document into its TileMatrixSets.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
xml_source : str | bytes
|
|
156
|
+
The raw GetCapabilities XML, as returned by the WMTS endpoint.
|
|
157
|
+
swap_xy : bool | None
|
|
158
|
+
See parse_tile_matrix_set. Applied to every TileMatrixSet found;
|
|
159
|
+
pass None (default) to auto-detect per-set based on CRS.
|
|
160
|
+
|
|
161
|
+
Returns
|
|
162
|
+
-------
|
|
163
|
+
list[TileMatrixSet]
|
|
164
|
+
One entry per <TileMatrixSet> found under <Contents>. A capabilities
|
|
165
|
+
document commonly advertises several (e.g. one per CRS, or several
|
|
166
|
+
resolutions of the same CRS) even if you only care about one.
|
|
167
|
+
"""
|
|
168
|
+
root = ET.fromstring(xml_source)
|
|
169
|
+
contents = root.find("wmts:Contents", NS)
|
|
170
|
+
if contents is None:
|
|
171
|
+
raise ValueError("GetCapabilities document has no <Contents> element")
|
|
172
|
+
|
|
173
|
+
tms_elems = contents.findall("wmts:TileMatrixSet", NS)
|
|
174
|
+
return [parse_tile_matrix_set(elem, swap_xy=swap_xy) for elem in tms_elems]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_tile_matrix_set(
|
|
178
|
+
xml_source: str | bytes, identifier: str, swap_xy: bool | None = None
|
|
179
|
+
) -> TileMatrixSet:
|
|
180
|
+
"""Convenience wrapper: parse and return one named TileMatrixSet.
|
|
181
|
+
|
|
182
|
+
Raises KeyError if no TileMatrixSet with that identifier is present,
|
|
183
|
+
listing what was actually found so mismatches (e.g. guessing "2km"
|
|
184
|
+
when the service calls it "2km_epsg4326") are easy to debug.
|
|
185
|
+
"""
|
|
186
|
+
all_sets = parse_capabilities(xml_source, swap_xy=swap_xy)
|
|
187
|
+
for tms in all_sets:
|
|
188
|
+
if identifier in (tms.id, tms.title):
|
|
189
|
+
return tms
|
|
190
|
+
available = [tms.title or tms.id for tms in all_sets]
|
|
191
|
+
raise KeyError(f"No TileMatrixSet {identifier!r} found. Available: {available}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: GeoPakTile
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Create a Geopackage XYZ Tile layer.
|
|
5
|
+
Keywords: GIS,Geopackage,Tiles,XYZ,gpkg
|
|
6
|
+
Author: apwebber
|
|
7
|
+
Author-email: apwebber <39490780+apwebber@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE.md
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: typing-extensions>=4.0 ; python_full_version < '3.11'
|
|
17
|
+
Requires-Dist: morecantile>=6.2.0
|
|
18
|
+
Requires-Dist: numpy>=2.2.6
|
|
19
|
+
Requires-Dist: requests>=2.34.2
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Project-URL: Homepage, https://github.com/apwebber/GeoPakTile
|
|
22
|
+
Project-URL: Documentation, https://github.com/apwebber/GeoPakTile
|
|
23
|
+
Project-URL: Repository, https://github.com/apwebber/GeoPakTile.git
|
|
24
|
+
Project-URL: Issues, https://github.com/apwebber/GeoPakTile/issues
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# GeoPakTile
|
|
28
|
+
|
|
29
|
+
For writing geopackage XYZ raster tile layers, with support for using arbitrary grid systems.
|
|
30
|
+
|
|
31
|
+
Geopackage XYZ files can use any tile grid system, this is often defined by a server's GetCapabilities response where a TileMatrixSet defines the data co-ordinate system, the extent, the number of rows and columns in each zoom lever, and the corner co-ordinates for each zoom level. This package contains a tool for parsing the GetCapabilities XML into a [morecantile](https://github.com/developmentseed/morecantile) `TileMatrixSet`, and then for writing tiles to a new or existing geopackage.
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
|
|
35
|
+
Write tiles to a new geopackage using web mercator (similar to mbtiles), where tiles is a list of (z, x, y, bytes). `tms` is a morecantile `TileMatrixSet` - the standard web mercator can be obtained with `morecantile.tms.get('WebMercatorQuad')`
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
# Here, a list of tiles are read from a local path, the bytes and co-ordinates are stored as tuples in a list:
|
|
39
|
+
# You might get these tiles from a tile server or anywhere else. Adjust accordingly.
|
|
40
|
+
|
|
41
|
+
tiles = [
|
|
42
|
+
(z, x, y, path.read_bytes()),
|
|
43
|
+
(z2, x2, y2, path2.read_bytes()),
|
|
44
|
+
(z3, x3, y3, path3.read_bytes()),
|
|
45
|
+
...
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
with GeoPakTile(test_gpkg, tms=morecantile.tms.get('WebMercatorQuad'), mode='rwc') as gpkg:
|
|
49
|
+
gpkg.add_tiles(tiles)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Parse a GetCapabilties xml to provide the TileMatrixSet for writing a geopackage:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
tms_set = parse_capabilities(xml)
|
|
56
|
+
tms = tms_set[0] # you'll need to know which one you want, or use:
|
|
57
|
+
|
|
58
|
+
tms = get_tile_matrix_set(xml, identifier = '1km') # an identifier that you know is defined in the xml
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
### TileMatrixSet models
|
|
63
|
+
|
|
64
|
+
Tile Matrix definitions use the [morecantile](https://github.com/developmentseed/morecantile) package (`morecantile.TileMatrixSet`, `TileMatrix`, etc.) so any morecantile TileMatrixSet (built-in or custom-loaded) can be passed to `GeoPakTile`. The XML parser returns morecantile `TileMatrixSet` objects.
|
|
65
|
+
|
|
66
|
+
### XML Parser
|
|
67
|
+
|
|
68
|
+
The xml parser has been tested on NASA GetCapabilties XML and some others, if you come across any that doesn't work then please raise an issue or fix and pull request.
|
|
69
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
geopaktile/__init__.py,sha256=pon2_W2DtAs6vnxyJyN-Ig4iaNVTyufrfAg8yH0V77s,189
|
|
2
|
+
geopaktile/__main__.py,sha256=MD118vcJhpneDLGbjmErx-_ug1lrjRsZzlmOj0LG8bY,69
|
|
3
|
+
geopaktile/geopaktile.py,sha256=wT5jX0XKRzyixLOfvJY358Nm0IUHwtaau_DylCoPw9g,14380
|
|
4
|
+
geopaktile/xml_parser.py,sha256=eNEbJtHo885F5cQmAKPBOci7fz7f4E7D1z_jvTOCPKo,7791
|
|
5
|
+
geopaktile-2.0.0.dist-info/licenses/LICENSE.md,sha256=l_OJCOs5Hli7ri30MKfXMFO58k8juM3sT1aTvR3fRBM,1085
|
|
6
|
+
geopaktile-2.0.0.dist-info/WHEEL,sha256=M4DeIjVCA49okfALADZoWX5JOGwnmHb-JOpQHtI-1c0,80
|
|
7
|
+
geopaktile-2.0.0.dist-info/entry_points.txt,sha256=cNf1TFW8KuDXF6Mmi424dwlTm2pq5NLg-sntV37tePs,48
|
|
8
|
+
geopaktile-2.0.0.dist-info/METADATA,sha256=ZFKdaaUJzJZAWu9g0VhsJH8U4mmzWyD5XIukI2hJFjg,3238
|
|
9
|
+
geopaktile-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Webber
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|