GeoPakTile 2.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.
- geopaktile/__init__.py +8 -0
- geopaktile/__main__.py +4 -0
- geopaktile/geopaktile.py +463 -0
- geopaktile/gpkg_to_cog.py +508 -0
- geopaktile/xml_parser.py +191 -0
- geopaktile-2.1.0.dist-info/METADATA +121 -0
- geopaktile-2.1.0.dist-info/RECORD +10 -0
- geopaktile-2.1.0.dist-info/WHEEL +4 -0
- geopaktile-2.1.0.dist-info/entry_points.txt +3 -0
- geopaktile-2.1.0.dist-info/licenses/LICENSE.md +9 -0
geopaktile/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from geopaktile.geopaktile import GeoPakTile as GeoPakTile
|
|
2
|
+
from geopaktile.gpkg_to_cog import gpkg_tiles_to_cog as gpkg_tiles_to_cog
|
|
3
|
+
from geopaktile.xml_parser import get_tile_matrix_set as get_tile_matrix_set
|
|
4
|
+
from geopaktile.xml_parser import parse_capabilities as parse_capabilities
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
print("Command line usage has not been implemented.")
|
geopaktile/__main__.py
ADDED
geopaktile/geopaktile.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
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 = None,
|
|
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
|
+
`pygeopkgxyz.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
|
+
if mode in ["rw", "rwc"] and self.tms is None:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"A TileMatrixSet is required to create or write to a geopackage."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self._mode = mode
|
|
73
|
+
file_string = f"{self.filepath.absolute().as_uri()}?mode={mode}"
|
|
74
|
+
self._conn = sqlite3.connect(file_string, uri=True)
|
|
75
|
+
self._cursor = self._conn.cursor()
|
|
76
|
+
self._apply_pragmas(pragmas)
|
|
77
|
+
|
|
78
|
+
# initialize tables if needed and in create mode
|
|
79
|
+
if mode == "rwc":
|
|
80
|
+
self._create_tables()
|
|
81
|
+
|
|
82
|
+
def __enter__(self) -> Self:
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
86
|
+
self.close()
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
"""Close the connection, leaving a single self-contained .gpkg file."""
|
|
90
|
+
if self._conn is None:
|
|
91
|
+
return
|
|
92
|
+
try:
|
|
93
|
+
if self._mode != "ro":
|
|
94
|
+
self._conn.commit()
|
|
95
|
+
# Default journal mode, prevents WAL file from being left behind and creation by future read operations
|
|
96
|
+
self._conn.execute("PRAGMA journal_mode=DELETE")
|
|
97
|
+
finally:
|
|
98
|
+
self._cursor.close()
|
|
99
|
+
self._conn.close()
|
|
100
|
+
self._conn = None
|
|
101
|
+
|
|
102
|
+
def has_tile(self, z: int, x: int, y: int):
|
|
103
|
+
"""Check if a tile is present, using the zxy coordinates"""
|
|
104
|
+
|
|
105
|
+
self._cursor.execute(
|
|
106
|
+
f"SELECT EXISTS (SELECT 1 FROM {self.table_name} "
|
|
107
|
+
"where zoom_level=? and tile_column=? and tile_row=? LIMIT 1)",
|
|
108
|
+
(z, x, y),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
row = self._cursor.fetchone()
|
|
112
|
+
return row[0] == 1
|
|
113
|
+
|
|
114
|
+
def has_tiles(self, zxys: list[tuple[int, int, int]] | np.ndarray) -> np.ndarray:
|
|
115
|
+
"""Check many tiles efficiently."""
|
|
116
|
+
|
|
117
|
+
requested = np.asarray(zxys, dtype=np.uint64)
|
|
118
|
+
|
|
119
|
+
self._cursor.execute(
|
|
120
|
+
f"SELECT zoom_level, tile_column, tile_row FROM {self.table_name}"
|
|
121
|
+
)
|
|
122
|
+
rows = self._cursor.fetchall()
|
|
123
|
+
existing = (
|
|
124
|
+
np.array(rows, dtype=np.uint64)
|
|
125
|
+
if rows
|
|
126
|
+
else np.empty((0, 3), dtype=np.uint64)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Combine z, x, y into a single array using bit shifting
|
|
130
|
+
# [ 8 bits unused ][ 8 bits z ][ 28 bits x ][ 28 bits y ]
|
|
131
|
+
def pack(arr: np.ndarray) -> np.ndarray:
|
|
132
|
+
z, x, y = arr[:, 0], arr[:, 1], arr[:, 2]
|
|
133
|
+
return (z << np.uint64(56)) | (x << np.uint64(28)) | y
|
|
134
|
+
|
|
135
|
+
req_keys = pack(requested)
|
|
136
|
+
exist_keys = pack(existing)
|
|
137
|
+
|
|
138
|
+
return np.isin(req_keys, exist_keys, assume_unique=False)
|
|
139
|
+
|
|
140
|
+
def add_tiles(
|
|
141
|
+
self,
|
|
142
|
+
tiles: Iterable[tuple[int, int, int, bytes]],
|
|
143
|
+
batch_size: int = 1000,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Add tiles to the geopackage
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
tiles (Iterable[tuple[int, int, int, bytes]]): List of tiles - [z, x, y, image data in bytes]
|
|
149
|
+
batch_size (int, optional): Add images in batches for speed. Defaults to 1000.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
insert_sql = f"""
|
|
153
|
+
INSERT OR IGNORE INTO "{self.table_name}"
|
|
154
|
+
(
|
|
155
|
+
zoom_level,
|
|
156
|
+
tile_column,
|
|
157
|
+
tile_row,
|
|
158
|
+
tile_data
|
|
159
|
+
)
|
|
160
|
+
VALUES (?, ?, ?, ?)
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
zs = {t[0] for t in tiles}
|
|
164
|
+
for z in zs:
|
|
165
|
+
self._ensure_zoom_level(z)
|
|
166
|
+
|
|
167
|
+
batch = []
|
|
168
|
+
for z, x, y, tile_bytes in tiles:
|
|
169
|
+
z = int(z)
|
|
170
|
+
x = int(x)
|
|
171
|
+
y = int(y)
|
|
172
|
+
|
|
173
|
+
if self.has_tile(z, x, y):
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
batch.append((z, x, y, sqlite3.Binary(tile_bytes)))
|
|
177
|
+
if len(batch) >= batch_size:
|
|
178
|
+
self._cursor.executemany(insert_sql, batch)
|
|
179
|
+
self._conn.commit()
|
|
180
|
+
batch.clear()
|
|
181
|
+
|
|
182
|
+
if batch:
|
|
183
|
+
self._cursor.executemany(insert_sql, batch)
|
|
184
|
+
self._conn.commit()
|
|
185
|
+
|
|
186
|
+
# Pragmas that need write access; skipped for read-only connections.
|
|
187
|
+
_WRITE_PRAGMAS: ClassVar[frozenset[str]] = frozenset({"journal_mode", "synchronous"})
|
|
188
|
+
|
|
189
|
+
def _apply_pragmas(self, overrides: dict[str, str] | None) -> None:
|
|
190
|
+
merged = {**self.DEFAULT_PRAGMAS, **(overrides or {})}
|
|
191
|
+
for name, value in merged.items():
|
|
192
|
+
if self._mode == "ro" and name in self._WRITE_PRAGMAS:
|
|
193
|
+
# skip pragmas that require write access when in read-only mode
|
|
194
|
+
continue
|
|
195
|
+
if not str(value).replace("_", "").isalnum():
|
|
196
|
+
# Ensure malicious values aren't parsed.
|
|
197
|
+
# Passes: "WAL", "NORMAL", "OFF", "EXCLUSIVE", "1000" (a plausible wal_autocheckpoint value)
|
|
198
|
+
# Blocked: "WAL; DROP TABLE tiles;--", "OFF' OR '1'='1", anything with spaces, quotes, or punctuation
|
|
199
|
+
raise ValueError(f"Unsafe PRAGMA value for {name!r}: {value!r}")
|
|
200
|
+
self._cursor.execute(f"PRAGMA {name}={value}")
|
|
201
|
+
|
|
202
|
+
def _create_tables(self):
|
|
203
|
+
"""Create the tables for a valid geopackage XYZ layer"""
|
|
204
|
+
|
|
205
|
+
self._cursor.execute("PRAGMA application_id = 1196437808")
|
|
206
|
+
self._cursor.execute("PRAGMA user_version = 10400")
|
|
207
|
+
|
|
208
|
+
self._cursor.execute(
|
|
209
|
+
"""
|
|
210
|
+
CREATE TABLE IF NOT EXISTS gpkg_spatial_ref_sys (
|
|
211
|
+
srs_name TEXT NOT NULL,
|
|
212
|
+
srs_id INTEGER NOT NULL PRIMARY KEY,
|
|
213
|
+
organization TEXT NOT NULL,
|
|
214
|
+
organization_coordsys_id INTEGER NOT NULL,
|
|
215
|
+
definition TEXT NOT NULL,
|
|
216
|
+
description TEXT
|
|
217
|
+
)
|
|
218
|
+
"""
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
self._cursor.execute(
|
|
222
|
+
"""
|
|
223
|
+
INSERT OR IGNORE INTO gpkg_spatial_ref_sys
|
|
224
|
+
VALUES
|
|
225
|
+
(
|
|
226
|
+
'Undefined Cartesian SRS',
|
|
227
|
+
-1,
|
|
228
|
+
'NONE',
|
|
229
|
+
-1,
|
|
230
|
+
'undefined',
|
|
231
|
+
'undefined Cartesian coordinate reference system'
|
|
232
|
+
)
|
|
233
|
+
"""
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
self._cursor.execute(
|
|
237
|
+
"""
|
|
238
|
+
INSERT OR IGNORE INTO gpkg_spatial_ref_sys
|
|
239
|
+
VALUES
|
|
240
|
+
(
|
|
241
|
+
'Undefined Geographic SRS',
|
|
242
|
+
0,
|
|
243
|
+
'NONE',
|
|
244
|
+
0,
|
|
245
|
+
'undefined',
|
|
246
|
+
'undefined geographic coordinate reference system'
|
|
247
|
+
)
|
|
248
|
+
"""
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
epsg = self.tms.crs.to_epsg()
|
|
252
|
+
if epsg is None:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"TileMatrixSet CRS {self.tms.crs} does not have an EPSG code."
|
|
255
|
+
)
|
|
256
|
+
crs = CRS.from_epsg(epsg)
|
|
257
|
+
srs_name = crs.name
|
|
258
|
+
definition = crs.to_wkt()
|
|
259
|
+
|
|
260
|
+
self._cursor.execute(
|
|
261
|
+
"""
|
|
262
|
+
INSERT OR IGNORE INTO gpkg_spatial_ref_sys
|
|
263
|
+
(
|
|
264
|
+
srs_name,
|
|
265
|
+
srs_id,
|
|
266
|
+
organization,
|
|
267
|
+
organization_coordsys_id,
|
|
268
|
+
definition,
|
|
269
|
+
description
|
|
270
|
+
)
|
|
271
|
+
VALUES (?, ?, 'EPSG', ?, ?, ?)
|
|
272
|
+
""",
|
|
273
|
+
(
|
|
274
|
+
srs_name,
|
|
275
|
+
epsg,
|
|
276
|
+
epsg,
|
|
277
|
+
definition,
|
|
278
|
+
srs_name,
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
self._cursor.execute(
|
|
283
|
+
"""
|
|
284
|
+
CREATE TABLE IF NOT EXISTS gpkg_contents (
|
|
285
|
+
table_name TEXT NOT NULL PRIMARY KEY,
|
|
286
|
+
data_type TEXT NOT NULL,
|
|
287
|
+
identifier TEXT UNIQUE,
|
|
288
|
+
description TEXT DEFAULT '',
|
|
289
|
+
last_change DATETIME NOT NULL DEFAULT
|
|
290
|
+
(strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
291
|
+
min_x DOUBLE,
|
|
292
|
+
min_y DOUBLE,
|
|
293
|
+
max_x DOUBLE,
|
|
294
|
+
max_y DOUBLE,
|
|
295
|
+
srs_id INTEGER,
|
|
296
|
+
CONSTRAINT fk_gc_r_srs_id
|
|
297
|
+
FOREIGN KEY (srs_id)
|
|
298
|
+
REFERENCES gpkg_spatial_ref_sys(srs_id)
|
|
299
|
+
)
|
|
300
|
+
"""
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
self._cursor.execute(
|
|
304
|
+
"""
|
|
305
|
+
CREATE TABLE IF NOT EXISTS gpkg_tile_matrix_set (
|
|
306
|
+
table_name TEXT NOT NULL PRIMARY KEY,
|
|
307
|
+
srs_id INTEGER NOT NULL,
|
|
308
|
+
min_x DOUBLE NOT NULL,
|
|
309
|
+
min_y DOUBLE NOT NULL,
|
|
310
|
+
max_x DOUBLE NOT NULL,
|
|
311
|
+
max_y DOUBLE NOT NULL,
|
|
312
|
+
CONSTRAINT fk_gtms_table_name
|
|
313
|
+
FOREIGN KEY (table_name)
|
|
314
|
+
REFERENCES gpkg_contents(table_name),
|
|
315
|
+
CONSTRAINT fk_gtms_srs
|
|
316
|
+
FOREIGN KEY (srs_id)
|
|
317
|
+
REFERENCES gpkg_spatial_ref_sys(srs_id)
|
|
318
|
+
)
|
|
319
|
+
"""
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
self._cursor.execute(
|
|
323
|
+
"""
|
|
324
|
+
CREATE TABLE IF NOT EXISTS gpkg_tile_matrix (
|
|
325
|
+
table_name TEXT NOT NULL,
|
|
326
|
+
zoom_level INTEGER NOT NULL,
|
|
327
|
+
matrix_width INTEGER NOT NULL,
|
|
328
|
+
matrix_height INTEGER NOT NULL,
|
|
329
|
+
tile_width INTEGER NOT NULL,
|
|
330
|
+
tile_height INTEGER NOT NULL,
|
|
331
|
+
pixel_x_size DOUBLE NOT NULL,
|
|
332
|
+
pixel_y_size DOUBLE NOT NULL,
|
|
333
|
+
CONSTRAINT pk_ttm PRIMARY KEY (table_name, zoom_level),
|
|
334
|
+
CONSTRAINT fk_tmm_table_name
|
|
335
|
+
FOREIGN KEY (table_name)
|
|
336
|
+
REFERENCES gpkg_contents(table_name)
|
|
337
|
+
)
|
|
338
|
+
"""
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
self._cursor.execute(
|
|
342
|
+
f"""
|
|
343
|
+
CREATE TABLE IF NOT EXISTS "{self.table_name}" (
|
|
344
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
345
|
+
zoom_level INTEGER NOT NULL,
|
|
346
|
+
tile_column INTEGER NOT NULL,
|
|
347
|
+
tile_row INTEGER NOT NULL,
|
|
348
|
+
tile_data BLOB NOT NULL,
|
|
349
|
+
UNIQUE (zoom_level, tile_column, tile_row)
|
|
350
|
+
)
|
|
351
|
+
"""
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
self._cursor.execute(
|
|
355
|
+
f"""
|
|
356
|
+
CREATE INDEX IF NOT EXISTS "{self.table_name}_zxy_idx"
|
|
357
|
+
ON "{self.table_name}" (zoom_level, tile_column, tile_row)
|
|
358
|
+
"""
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
bb = self.tms.xy_bbox
|
|
362
|
+
|
|
363
|
+
self._cursor.execute(
|
|
364
|
+
"""
|
|
365
|
+
INSERT OR REPLACE INTO gpkg_contents
|
|
366
|
+
(
|
|
367
|
+
table_name,
|
|
368
|
+
data_type,
|
|
369
|
+
identifier,
|
|
370
|
+
description,
|
|
371
|
+
min_x,
|
|
372
|
+
min_y,
|
|
373
|
+
max_x,
|
|
374
|
+
max_y,
|
|
375
|
+
srs_id
|
|
376
|
+
)
|
|
377
|
+
VALUES (?, 'tiles', ?, '', ?, ?, ?, ?, ?)
|
|
378
|
+
""",
|
|
379
|
+
(
|
|
380
|
+
self.table_name,
|
|
381
|
+
self.table_name,
|
|
382
|
+
bb.left,
|
|
383
|
+
bb.bottom,
|
|
384
|
+
bb.right,
|
|
385
|
+
bb.top,
|
|
386
|
+
epsg,
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
self._cursor.execute(
|
|
391
|
+
"""
|
|
392
|
+
INSERT OR REPLACE INTO gpkg_tile_matrix_set
|
|
393
|
+
(
|
|
394
|
+
table_name,
|
|
395
|
+
srs_id,
|
|
396
|
+
min_x,
|
|
397
|
+
min_y,
|
|
398
|
+
max_x,
|
|
399
|
+
max_y
|
|
400
|
+
)
|
|
401
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
402
|
+
""",
|
|
403
|
+
(
|
|
404
|
+
self.table_name,
|
|
405
|
+
epsg,
|
|
406
|
+
bb.left,
|
|
407
|
+
bb.bottom,
|
|
408
|
+
bb.right,
|
|
409
|
+
bb.top,
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
self._conn.commit()
|
|
414
|
+
|
|
415
|
+
def _ensure_zoom_level(
|
|
416
|
+
self,
|
|
417
|
+
z: int,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""
|
|
420
|
+
Add one gpkg_tile_matrix row for a zoom level.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
tm = self.tms.matrix(z)
|
|
424
|
+
self._cursor.execute(
|
|
425
|
+
"""
|
|
426
|
+
INSERT OR IGNORE INTO gpkg_tile_matrix
|
|
427
|
+
(
|
|
428
|
+
table_name,
|
|
429
|
+
zoom_level,
|
|
430
|
+
matrix_width,
|
|
431
|
+
matrix_height,
|
|
432
|
+
tile_width,
|
|
433
|
+
tile_height,
|
|
434
|
+
pixel_x_size,
|
|
435
|
+
pixel_y_size
|
|
436
|
+
)
|
|
437
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
438
|
+
""",
|
|
439
|
+
(
|
|
440
|
+
self.table_name,
|
|
441
|
+
z,
|
|
442
|
+
tm.matrixWidth,
|
|
443
|
+
tm.matrixHeight,
|
|
444
|
+
tm.tileWidth,
|
|
445
|
+
tm.tileHeight,
|
|
446
|
+
tm.cellSize,
|
|
447
|
+
tm.cellSize,
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def _validate_table_name(self) -> None:
|
|
452
|
+
"""Ensure table_name is safe to interpolate into SQL and spec-compliant."""
|
|
453
|
+
if not _IDENTIFIER_RE.match(self.table_name):
|
|
454
|
+
raise ValueError(
|
|
455
|
+
f"Invalid table_name {self.table_name!r}: must match "
|
|
456
|
+
f"[A-Za-z_][A-Za-z0-9_]* (letters, digits, underscore only, "
|
|
457
|
+
f"not starting with a digit)."
|
|
458
|
+
)
|
|
459
|
+
if self.table_name.lower().startswith("gpkg_"):
|
|
460
|
+
raise ValueError(
|
|
461
|
+
f"Invalid table_name {self.table_name!r}: the 'gpkg_' prefix is "
|
|
462
|
+
f"reserved by the GeoPackage spec."
|
|
463
|
+
)
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Convert a GeoPackage tile pyramid layer to a Cloud Optimized GeoTIFF.
|
|
2
|
+
|
|
3
|
+
The highest selected zoom level becomes the full-resolution image and each
|
|
4
|
+
lower zoom level becomes an overview, so the COG keeps the GeoPackage's own
|
|
5
|
+
scales rather than GDAL resampling new overviews.
|
|
6
|
+
|
|
7
|
+
Requires: numpy, pillow, rasterio (with GDAL >= 3.1 for the COG driver)
|
|
8
|
+
|
|
9
|
+
How it works:
|
|
10
|
+
1. The output extent is snapped to the pixel grid of a coarse zoom level
|
|
11
|
+
so that the finer levels line up with it exactly.
|
|
12
|
+
2. Each zoom level whose pixel grid lines up with that extent is
|
|
13
|
+
mosaicked, unresampled, into a temporary GeoTIFF.
|
|
14
|
+
3. With resample_unaligned=True, coarser levels whose grid doesn't line
|
|
15
|
+
up are cropped from their own tiles and resampled onto the extent,
|
|
16
|
+
down to overviews 1 pixel across. A warning lists those levels.
|
|
17
|
+
4. A VRT lists the base level plus the overview levels as explicit
|
|
18
|
+
<Overview> elements, and GDAL's COG driver copies it with
|
|
19
|
+
OVERVIEWS=FORCE_USE_EXISTING.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import io
|
|
25
|
+
import math
|
|
26
|
+
import sqlite3
|
|
27
|
+
import tempfile
|
|
28
|
+
import warnings
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from xml.sax.saxutils import escape
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import rasterio
|
|
36
|
+
import rasterio.shutil
|
|
37
|
+
from PIL import Image
|
|
38
|
+
from rasterio.crs import CRS
|
|
39
|
+
from rasterio.enums import Resampling
|
|
40
|
+
from rasterio.transform import Affine, from_origin
|
|
41
|
+
from rasterio.warp import reproject, transform_bounds
|
|
42
|
+
from rasterio.windows import Window
|
|
43
|
+
|
|
44
|
+
SUPPORTED_BANDS = {"RGBA": 4, "RGB": 3}
|
|
45
|
+
COLOR_INTERP = ["Red", "Green", "Blue", "Alpha"]
|
|
46
|
+
_EPS = 1e-6
|
|
47
|
+
_PIXEL_TOL = 1e-3 # how close to a whole pixel counts as "aligned"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class _Level:
|
|
52
|
+
zoom: int
|
|
53
|
+
matrix_width: int
|
|
54
|
+
matrix_height: int
|
|
55
|
+
tile_width: int
|
|
56
|
+
tile_height: int
|
|
57
|
+
px: float # pixel width in CRS units
|
|
58
|
+
py: float # pixel height in CRS units
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class _Plan:
|
|
63
|
+
level: _Level
|
|
64
|
+
width: int
|
|
65
|
+
height: int
|
|
66
|
+
col_off: int # used only when not resampled
|
|
67
|
+
row_off: int
|
|
68
|
+
resampled: bool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _quote_ident(name: str) -> str:
|
|
72
|
+
return '"' + name.replace('"', '""') + '"'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_whole(value: float) -> bool:
|
|
76
|
+
return abs(value - round(value)) < _PIXEL_TOL
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _as_int(value: float, what: str) -> int:
|
|
80
|
+
"""Round to int, warning if the value wasn't (nearly) integral."""
|
|
81
|
+
if not _is_whole(value):
|
|
82
|
+
warnings.warn(
|
|
83
|
+
f"{what} is {value:.4f} pixels, not a whole number; rounding. "
|
|
84
|
+
"Zoom levels may not be exact multiples of each other.",
|
|
85
|
+
stacklevel=3,
|
|
86
|
+
)
|
|
87
|
+
return round(value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def gpkg_tiles_to_cog(
|
|
91
|
+
gpkg_path: str | Path,
|
|
92
|
+
cog_path: str | Path,
|
|
93
|
+
table: str | None = None,
|
|
94
|
+
*,
|
|
95
|
+
bbox: Sequence[float] | None = None,
|
|
96
|
+
bbox_crs: str | CRS | None = None,
|
|
97
|
+
min_zoom: int | None = None,
|
|
98
|
+
max_zoom: int | None = None,
|
|
99
|
+
resample_unaligned: bool = False,
|
|
100
|
+
resampling: str = "bilinear",
|
|
101
|
+
bands: str = "RGBA",
|
|
102
|
+
compress: str = "DEFLATE",
|
|
103
|
+
blocksize: int | None = None,
|
|
104
|
+
overwrite: bool = False,
|
|
105
|
+
) -> Path:
|
|
106
|
+
"""Convert a GeoPackage tile layer to a COG, using zoom levels as overviews.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
gpkg_path: Path to the .gpkg file.
|
|
110
|
+
cog_path: Output GeoTIFF path.
|
|
111
|
+
table: Tile table name. Optional if the GeoPackage has exactly one.
|
|
112
|
+
bbox: (min_x, min_y, max_x, max_y) to clip to. Defaults to the extent
|
|
113
|
+
of the tiles present at max_zoom.
|
|
114
|
+
bbox_crs: CRS of bbox (e.g. "EPSG:4326"). Defaults to the layer's CRS.
|
|
115
|
+
min_zoom: Coarsest zoom level to include. Without resample_unaligned,
|
|
116
|
+
the extent is snapped to this level's pixel grid, which can
|
|
117
|
+
enlarge it a lot; with resample_unaligned it only limits which
|
|
118
|
+
levels are included. Defaults to as many levels as possible.
|
|
119
|
+
max_zoom: Zoom level used for full resolution. Defaults to the
|
|
120
|
+
highest zoom level that has tiles.
|
|
121
|
+
resample_unaligned: Also include coarser levels whose pixel grid
|
|
122
|
+
doesn't line up with the output extent, by resampling them onto
|
|
123
|
+
it. Emits a warning naming the resampled levels.
|
|
124
|
+
resampling: Resampling method for those levels: a rasterio
|
|
125
|
+
Resampling name such as "nearest", "bilinear", "cubic", "average".
|
|
126
|
+
bands: "RGBA" (missing tiles become transparent) or "RGB" (missing
|
|
127
|
+
tiles become black; required for JPEG compression).
|
|
128
|
+
compress: COG compression, e.g. "DEFLATE", "ZSTD", "LZW", "WEBP", "JPEG".
|
|
129
|
+
blocksize: COG internal tile size. Defaults to the GeoPackage tile width.
|
|
130
|
+
overwrite: Replace an existing output file.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Path to the written COG.
|
|
134
|
+
"""
|
|
135
|
+
if bands not in SUPPORTED_BANDS:
|
|
136
|
+
raise ValueError(f"bands must be one of {sorted(SUPPORTED_BANDS)}")
|
|
137
|
+
if compress.upper() == "JPEG" and bands != "RGB":
|
|
138
|
+
raise ValueError('JPEG compression requires bands="RGB"')
|
|
139
|
+
try:
|
|
140
|
+
resampling_method = Resampling[resampling]
|
|
141
|
+
except KeyError:
|
|
142
|
+
raise ValueError(f"Unknown resampling method {resampling!r}") from None
|
|
143
|
+
n_bands = SUPPORTED_BANDS[bands]
|
|
144
|
+
|
|
145
|
+
cog_path = Path(cog_path)
|
|
146
|
+
if cog_path.exists() and not overwrite:
|
|
147
|
+
raise FileExistsError(f"{cog_path} exists; pass overwrite=True")
|
|
148
|
+
|
|
149
|
+
uri = Path(gpkg_path).resolve().as_uri() + "?mode=ro"
|
|
150
|
+
con = sqlite3.connect(uri, uri=True)
|
|
151
|
+
try:
|
|
152
|
+
# --- Tile table, tile matrix set and CRS ----------------------------
|
|
153
|
+
tables = [
|
|
154
|
+
r[0]
|
|
155
|
+
for r in con.execute(
|
|
156
|
+
"SELECT table_name FROM gpkg_contents WHERE data_type = 'tiles'"
|
|
157
|
+
)
|
|
158
|
+
]
|
|
159
|
+
if table is None:
|
|
160
|
+
if len(tables) != 1:
|
|
161
|
+
raise ValueError(f"Specify table=; tile tables found: {tables}")
|
|
162
|
+
table = tables[0]
|
|
163
|
+
elif table not in tables:
|
|
164
|
+
raise ValueError(f"{table!r} is not a tile table; found: {tables}")
|
|
165
|
+
qtable = _quote_ident(table)
|
|
166
|
+
|
|
167
|
+
srs_id, tms_min_x, tms_min_y, tms_max_x, tms_max_y = con.execute(
|
|
168
|
+
"SELECT srs_id, min_x, min_y, max_x, max_y "
|
|
169
|
+
"FROM gpkg_tile_matrix_set WHERE table_name = ?",
|
|
170
|
+
(table,),
|
|
171
|
+
).fetchone()
|
|
172
|
+
org, org_id, wkt = con.execute(
|
|
173
|
+
"SELECT organization, organization_coordsys_id, definition "
|
|
174
|
+
"FROM gpkg_spatial_ref_sys WHERE srs_id = ?",
|
|
175
|
+
(srs_id,),
|
|
176
|
+
).fetchone()
|
|
177
|
+
if org and org.upper() == "EPSG":
|
|
178
|
+
crs = CRS.from_epsg(int(org_id))
|
|
179
|
+
else:
|
|
180
|
+
crs = CRS.from_wkt(wkt)
|
|
181
|
+
|
|
182
|
+
# --- Zoom levels that actually contain tiles ------------------------
|
|
183
|
+
present = {
|
|
184
|
+
z for (z,) in con.execute(f"SELECT DISTINCT zoom_level FROM {qtable}")
|
|
185
|
+
}
|
|
186
|
+
levels = {
|
|
187
|
+
row[0]: _Level(*row)
|
|
188
|
+
for row in con.execute(
|
|
189
|
+
"SELECT zoom_level, matrix_width, matrix_height, tile_width, "
|
|
190
|
+
"tile_height, pixel_x_size, pixel_y_size "
|
|
191
|
+
"FROM gpkg_tile_matrix WHERE table_name = ? ORDER BY zoom_level",
|
|
192
|
+
(table,),
|
|
193
|
+
)
|
|
194
|
+
if row[0] in present
|
|
195
|
+
}
|
|
196
|
+
if not levels:
|
|
197
|
+
raise ValueError(f"Table {table!r} contains no tiles")
|
|
198
|
+
|
|
199
|
+
if max_zoom is None:
|
|
200
|
+
max_zoom = max(levels)
|
|
201
|
+
if max_zoom not in levels:
|
|
202
|
+
raise ValueError(f"max_zoom {max_zoom} has no tiles; have {sorted(levels)}")
|
|
203
|
+
base = levels[max_zoom]
|
|
204
|
+
|
|
205
|
+
# Finest to coarsest.
|
|
206
|
+
candidates = sorted(
|
|
207
|
+
(z for z in levels
|
|
208
|
+
if z <= max_zoom and (min_zoom is None or z >= min_zoom)),
|
|
209
|
+
reverse=True,
|
|
210
|
+
)
|
|
211
|
+
if not candidates or candidates[0] != max_zoom:
|
|
212
|
+
raise ValueError("min_zoom must not be greater than max_zoom")
|
|
213
|
+
|
|
214
|
+
# --- Requested extent, in the layer's CRS ---------------------------
|
|
215
|
+
if bbox is None:
|
|
216
|
+
cmin, cmax, rmin, rmax = con.execute(
|
|
217
|
+
f"SELECT MIN(tile_column), MAX(tile_column), MIN(tile_row), "
|
|
218
|
+
f"MAX(tile_row) FROM {qtable} WHERE zoom_level = ?",
|
|
219
|
+
(max_zoom,),
|
|
220
|
+
).fetchone()
|
|
221
|
+
bx0 = tms_min_x + cmin * base.tile_width * base.px
|
|
222
|
+
bx1 = tms_min_x + (cmax + 1) * base.tile_width * base.px
|
|
223
|
+
by1 = tms_max_y - rmin * base.tile_height * base.py
|
|
224
|
+
by0 = tms_max_y - (rmax + 1) * base.tile_height * base.py
|
|
225
|
+
else:
|
|
226
|
+
bx0, by0, bx1, by1 = bbox
|
|
227
|
+
if bbox_crs is not None and CRS.from_user_input(bbox_crs) != crs:
|
|
228
|
+
bx0, by0, bx1, by1 = transform_bounds(
|
|
229
|
+
bbox_crs, crs, bx0, by0, bx1, by1, densify_pts=21
|
|
230
|
+
)
|
|
231
|
+
bx0, by0 = max(bx0, tms_min_x), max(by0, tms_min_y)
|
|
232
|
+
bx1, by1 = min(bx1, tms_max_x), min(by1, tms_max_y)
|
|
233
|
+
if bx0 >= bx1 or by0 >= by1:
|
|
234
|
+
raise ValueError("bbox does not overlap the tile matrix set")
|
|
235
|
+
|
|
236
|
+
# --- Level whose pixel grid the extent is snapped to ----------------
|
|
237
|
+
if min_zoom is not None and not resample_unaligned:
|
|
238
|
+
snap_zoom = candidates[-1]
|
|
239
|
+
else:
|
|
240
|
+
# Coarsest level at which the extent is still about one tile,
|
|
241
|
+
# which keeps the snapping to a small fraction of the extent.
|
|
242
|
+
snap_zoom = max_zoom
|
|
243
|
+
for z in candidates:
|
|
244
|
+
lv = levels[z]
|
|
245
|
+
snap_zoom = z
|
|
246
|
+
if (bx1 - bx0) / lv.px <= lv.tile_width * (1 + _EPS) and (
|
|
247
|
+
by1 - by0
|
|
248
|
+
) / lv.py <= lv.tile_height * (1 + _EPS):
|
|
249
|
+
break
|
|
250
|
+
snap = levels[snap_zoom]
|
|
251
|
+
x0 = tms_min_x + math.floor((bx0 - tms_min_x) / snap.px + _EPS) * snap.px
|
|
252
|
+
x1 = tms_min_x + math.ceil((bx1 - tms_min_x) / snap.px - _EPS) * snap.px
|
|
253
|
+
y1 = tms_max_y - math.floor((tms_max_y - by1) / snap.py + _EPS) * snap.py
|
|
254
|
+
y0 = tms_max_y - math.ceil((tms_max_y - by0) / snap.py - _EPS) * snap.py
|
|
255
|
+
|
|
256
|
+
# --- Decide how each level is produced ------------------------------
|
|
257
|
+
plan: list[_Plan] = []
|
|
258
|
+
for z in candidates:
|
|
259
|
+
lv = levels[z]
|
|
260
|
+
fcol = (x0 - tms_min_x) / lv.px
|
|
261
|
+
frow = (tms_max_y - y1) / lv.py
|
|
262
|
+
fwidth = (x1 - x0) / lv.px
|
|
263
|
+
fheight = (y1 - y0) / lv.py
|
|
264
|
+
|
|
265
|
+
if z >= snap_zoom:
|
|
266
|
+
# Aligned by construction (barring non-doubling pyramids).
|
|
267
|
+
width = _as_int(fwidth, f"z{z} width")
|
|
268
|
+
height = _as_int(fheight, f"z{z} height")
|
|
269
|
+
col_off = _as_int(fcol, f"z{z} column offset")
|
|
270
|
+
row_off = _as_int(frow, f"z{z} row offset")
|
|
271
|
+
aligned = True
|
|
272
|
+
else:
|
|
273
|
+
width, height = round(fwidth), round(fheight)
|
|
274
|
+
col_off, row_off = round(fcol), round(frow)
|
|
275
|
+
aligned = all(_is_whole(v) for v in (fcol, frow, fwidth, fheight))
|
|
276
|
+
|
|
277
|
+
if width < 1 or height < 1:
|
|
278
|
+
break # smaller than a pixel: no useful overview
|
|
279
|
+
if plan and width >= plan[-1].width and height >= plan[-1].height:
|
|
280
|
+
continue # overviews must get smaller
|
|
281
|
+
if not aligned and not resample_unaligned:
|
|
282
|
+
break
|
|
283
|
+
plan.append(_Plan(lv, width, height, col_off, row_off, not aligned))
|
|
284
|
+
|
|
285
|
+
resampled = [p.level.zoom for p in plan if p.resampled]
|
|
286
|
+
if resampled:
|
|
287
|
+
warnings.warn(
|
|
288
|
+
f"Zoom levels {resampled} don't line up with the output extent "
|
|
289
|
+
f"and were resampled ({resampling}) rather than copied.",
|
|
290
|
+
stacklevel=2,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if blocksize is None:
|
|
294
|
+
blocksize = base.tile_width
|
|
295
|
+
|
|
296
|
+
with tempfile.TemporaryDirectory(prefix="gpkg2cog_") as tmp:
|
|
297
|
+
tmpdir = Path(tmp)
|
|
298
|
+
level_files: list[Path] = []
|
|
299
|
+
|
|
300
|
+
for p in plan:
|
|
301
|
+
lv = p.level
|
|
302
|
+
path = tmpdir / f"z{lv.zoom}.tif"
|
|
303
|
+
if p.resampled:
|
|
304
|
+
_resample_level(
|
|
305
|
+
con, qtable, lv, path, crs, tmpdir,
|
|
306
|
+
(tms_min_x, tms_max_y), (x0, y0, x1, y1),
|
|
307
|
+
p.width, p.height, bands, n_bands, resampling_method,
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
_mosaic_level(
|
|
311
|
+
con, qtable, lv, path, crs,
|
|
312
|
+
p.col_off, p.row_off, p.width, p.height,
|
|
313
|
+
from_origin(x0, y1, lv.px, lv.py), bands, n_bands,
|
|
314
|
+
)
|
|
315
|
+
level_files.append(path)
|
|
316
|
+
|
|
317
|
+
vrt_path = tmpdir / "pyramid.vrt"
|
|
318
|
+
vrt_path.write_text(
|
|
319
|
+
_pyramid_vrt(
|
|
320
|
+
level_files, plan[0].width, plan[0].height, crs,
|
|
321
|
+
(x0, base.px, 0, y1, 0, -base.py), n_bands,
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
options = {
|
|
326
|
+
"COMPRESS": compress,
|
|
327
|
+
"BLOCKSIZE": blocksize,
|
|
328
|
+
"OVERVIEWS": "FORCE_USE_EXISTING",
|
|
329
|
+
"BIGTIFF": "IF_SAFER",
|
|
330
|
+
}
|
|
331
|
+
if compress.upper() in {"DEFLATE", "ZSTD", "LZW"}:
|
|
332
|
+
options["PREDICTOR"] = "YES"
|
|
333
|
+
if cog_path.exists():
|
|
334
|
+
cog_path.unlink()
|
|
335
|
+
rasterio.shutil.copy(str(vrt_path), str(cog_path), driver="COG", **options)
|
|
336
|
+
finally:
|
|
337
|
+
con.close()
|
|
338
|
+
|
|
339
|
+
return cog_path
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _profile(
|
|
343
|
+
width: int, height: int, n_bands: int, crs: CRS, transform: Affine, bands: str
|
|
344
|
+
) -> dict:
|
|
345
|
+
profile = {
|
|
346
|
+
"driver": "GTiff",
|
|
347
|
+
"width": width,
|
|
348
|
+
"height": height,
|
|
349
|
+
"count": n_bands,
|
|
350
|
+
"dtype": "uint8",
|
|
351
|
+
"crs": crs,
|
|
352
|
+
"transform": transform,
|
|
353
|
+
"tiled": True,
|
|
354
|
+
"blockxsize": 256,
|
|
355
|
+
"blockysize": 256,
|
|
356
|
+
"compress": "DEFLATE",
|
|
357
|
+
"sparse_ok": True, # leave missing tiles unwritten
|
|
358
|
+
"bigtiff": "IF_SAFER",
|
|
359
|
+
}
|
|
360
|
+
if bands == "RGBA":
|
|
361
|
+
profile["photometric"] = "RGB"
|
|
362
|
+
profile["alpha"] = "UNASSOCIATED"
|
|
363
|
+
return profile
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _mosaic_level(
|
|
367
|
+
con: sqlite3.Connection,
|
|
368
|
+
qtable: str,
|
|
369
|
+
lv: _Level,
|
|
370
|
+
path: Path,
|
|
371
|
+
crs: CRS,
|
|
372
|
+
col_off: int,
|
|
373
|
+
row_off: int,
|
|
374
|
+
width: int,
|
|
375
|
+
height: int,
|
|
376
|
+
transform: Affine,
|
|
377
|
+
bands: str,
|
|
378
|
+
n_bands: int,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Copy one zoom level's pixels inside a pixel window into a GeoTIFF."""
|
|
381
|
+
tw, th = lv.tile_width, lv.tile_height
|
|
382
|
+
first_col, last_col = col_off // tw, (col_off + width - 1) // tw
|
|
383
|
+
first_row, last_row = row_off // th, (row_off + height - 1) // th
|
|
384
|
+
|
|
385
|
+
with rasterio.open(
|
|
386
|
+
path, "w", **_profile(width, height, n_bands, crs, transform, bands)
|
|
387
|
+
) as dst:
|
|
388
|
+
rows = con.execute(
|
|
389
|
+
f"SELECT tile_column, tile_row, tile_data FROM {qtable} "
|
|
390
|
+
"WHERE zoom_level = ? AND tile_column BETWEEN ? AND ? "
|
|
391
|
+
"AND tile_row BETWEEN ? AND ?",
|
|
392
|
+
(lv.zoom, first_col, last_col, first_row, last_row),
|
|
393
|
+
)
|
|
394
|
+
for col, row, blob in rows:
|
|
395
|
+
with Image.open(io.BytesIO(blob)) as img:
|
|
396
|
+
if img.size != (tw, th):
|
|
397
|
+
raise ValueError(
|
|
398
|
+
f"Tile z={lv.zoom} col={col} row={row} is {img.size}, "
|
|
399
|
+
f"expected {(tw, th)}"
|
|
400
|
+
)
|
|
401
|
+
pixels = np.asarray(img.convert(bands))
|
|
402
|
+
|
|
403
|
+
# Tile position relative to the output window, then clip to it.
|
|
404
|
+
tx, ty = col * tw - col_off, row * th - row_off
|
|
405
|
+
dx0, dy0 = max(tx, 0), max(ty, 0)
|
|
406
|
+
dx1, dy1 = min(tx + tw, width), min(ty + th, height)
|
|
407
|
+
if dx0 >= dx1 or dy0 >= dy1:
|
|
408
|
+
continue
|
|
409
|
+
block = pixels[dy0 - ty : dy1 - ty, dx0 - tx : dx1 - tx]
|
|
410
|
+
dst.write(
|
|
411
|
+
np.moveaxis(block, -1, 0),
|
|
412
|
+
window=Window(dx0, dy0, dx1 - dx0, dy1 - dy0),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _resample_level(
|
|
417
|
+
con: sqlite3.Connection,
|
|
418
|
+
qtable: str,
|
|
419
|
+
lv: _Level,
|
|
420
|
+
path: Path,
|
|
421
|
+
crs: CRS,
|
|
422
|
+
tmpdir: Path,
|
|
423
|
+
origin: tuple[float, float],
|
|
424
|
+
extent: tuple[float, float, float, float],
|
|
425
|
+
width: int,
|
|
426
|
+
height: int,
|
|
427
|
+
bands: str,
|
|
428
|
+
n_bands: int,
|
|
429
|
+
resampling: Resampling,
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Crop a level on its own grid, then resample it onto the output extent.
|
|
432
|
+
|
|
433
|
+
Only used for levels coarser than the snap level, where the extent is
|
|
434
|
+
about one tile or less, so the arrays involved are small.
|
|
435
|
+
"""
|
|
436
|
+
tms_min_x, tms_max_y = origin
|
|
437
|
+
x0, y0, x1, y1 = extent
|
|
438
|
+
|
|
439
|
+
# Native-grid window covering the extent, plus a 1-pixel margin so
|
|
440
|
+
# interpolating methods have neighbours at the edges.
|
|
441
|
+
c_lo = max(math.floor((x0 - tms_min_x) / lv.px) - 1, 0)
|
|
442
|
+
c_hi = min(math.ceil((x1 - tms_min_x) / lv.px) + 1, lv.matrix_width * lv.tile_width)
|
|
443
|
+
r_lo = max(math.floor((tms_max_y - y1) / lv.py) - 1, 0)
|
|
444
|
+
r_hi = min(math.ceil((tms_max_y - y0) / lv.py) + 1, lv.matrix_height * lv.tile_height)
|
|
445
|
+
|
|
446
|
+
native_path = tmpdir / f"z{lv.zoom}_native.tif"
|
|
447
|
+
native_transform = from_origin(
|
|
448
|
+
tms_min_x + c_lo * lv.px, tms_max_y - r_lo * lv.py, lv.px, lv.py
|
|
449
|
+
)
|
|
450
|
+
_mosaic_level(
|
|
451
|
+
con, qtable, lv, native_path, crs,
|
|
452
|
+
c_lo, r_lo, c_hi - c_lo, r_hi - r_lo, native_transform, bands, n_bands,
|
|
453
|
+
)
|
|
454
|
+
with rasterio.open(native_path) as src:
|
|
455
|
+
source = src.read()
|
|
456
|
+
|
|
457
|
+
dst_transform = from_origin(x0, y1, (x1 - x0) / width, (y1 - y0) / height)
|
|
458
|
+
out = np.zeros((n_bands, height, width), dtype="uint8")
|
|
459
|
+
reproject(
|
|
460
|
+
source,
|
|
461
|
+
out,
|
|
462
|
+
src_transform=native_transform,
|
|
463
|
+
src_crs=crs,
|
|
464
|
+
dst_transform=dst_transform,
|
|
465
|
+
dst_crs=crs,
|
|
466
|
+
resampling=resampling,
|
|
467
|
+
)
|
|
468
|
+
with rasterio.open(
|
|
469
|
+
path, "w", **_profile(width, height, n_bands, crs, dst_transform, bands)
|
|
470
|
+
) as dst:
|
|
471
|
+
dst.write(out)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _pyramid_vrt(
|
|
475
|
+
level_files: list[Path],
|
|
476
|
+
width: int,
|
|
477
|
+
height: int,
|
|
478
|
+
crs: CRS,
|
|
479
|
+
geotransform: tuple[float, ...],
|
|
480
|
+
n_bands: int,
|
|
481
|
+
) -> str:
|
|
482
|
+
"""VRT whose bands read the first file and expose the rest as overviews."""
|
|
483
|
+
base_path, overviews = level_files[0], level_files[1:]
|
|
484
|
+
gt = ", ".join(repr(v) for v in geotransform)
|
|
485
|
+
parts = [
|
|
486
|
+
f'<VRTDataset rasterXSize="{width}" rasterYSize="{height}">',
|
|
487
|
+
f" <SRS>{escape(crs.to_wkt())}</SRS>",
|
|
488
|
+
f" <GeoTransform>{gt}</GeoTransform>",
|
|
489
|
+
]
|
|
490
|
+
for b in range(1, n_bands + 1):
|
|
491
|
+
parts += [
|
|
492
|
+
f' <VRTRasterBand dataType="Byte" band="{b}">',
|
|
493
|
+
f" <ColorInterp>{COLOR_INTERP[b - 1]}</ColorInterp>",
|
|
494
|
+
" <SimpleSource>",
|
|
495
|
+
f' <SourceFilename relativeToVRT="0">{escape(str(base_path))}</SourceFilename>',
|
|
496
|
+
f" <SourceBand>{b}</SourceBand>",
|
|
497
|
+
" </SimpleSource>",
|
|
498
|
+
]
|
|
499
|
+
for ov_path in overviews:
|
|
500
|
+
parts += [
|
|
501
|
+
" <Overview>",
|
|
502
|
+
f' <SourceFilename relativeToVRT="0">{escape(str(ov_path))}</SourceFilename>',
|
|
503
|
+
f" <SourceBand>{b}</SourceBand>",
|
|
504
|
+
" </Overview>",
|
|
505
|
+
]
|
|
506
|
+
parts.append(" </VRTRasterBand>")
|
|
507
|
+
parts.append("</VRTDataset>")
|
|
508
|
+
return "\n".join(parts)
|
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,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: GeoPakTile
|
|
3
|
+
Version: 2.1.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-Dist: pillow>=12.3.0
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Project-URL: Homepage, https://github.com/apwebber/GeoPakTile
|
|
23
|
+
Project-URL: Documentation, https://github.com/apwebber/GeoPakTile
|
|
24
|
+
Project-URL: Repository, https://github.com/apwebber/GeoPakTile.git
|
|
25
|
+
Project-URL: Issues, https://github.com/apwebber/GeoPakTile/issues
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# GeoPakTile
|
|
29
|
+
|
|
30
|
+
For writing geopackage XYZ raster tile layers, with support for using arbitrary grid systems, and for converting them to Cloud Optimized GeoTIFFs (COGs).
|
|
31
|
+
|
|
32
|
+
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.
|
|
33
|
+
|
|
34
|
+
## Examples
|
|
35
|
+
|
|
36
|
+
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')`
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from geopaktile import GeoPakTile
|
|
40
|
+
|
|
41
|
+
# Here, a list of tiles are read from a local path, the bytes and co-ordinates are stored as tuples in a list:
|
|
42
|
+
# You might get these tiles from a tile server or anywhere else. Adjust accordingly.
|
|
43
|
+
|
|
44
|
+
tiles = [
|
|
45
|
+
(z, x, y, path.read_bytes()),
|
|
46
|
+
(z2, x2, y2, path2.read_bytes()),
|
|
47
|
+
(z3, x3, y3, path3.read_bytes()),
|
|
48
|
+
...
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
with GeoPakTile(test_gpkg, tms=morecantile.tms.get('WebMercatorQuad'), mode='rwc') as gpkg:
|
|
52
|
+
gpkg.add_tiles(tiles)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Open a geopackage in read only mode and see if it contains certain tiles. A TileMatrixSet isn't required for read-only mode.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from geopaktile import GeoPakTile
|
|
59
|
+
|
|
60
|
+
with GeoPakTile(test_gpkg, mode='ro') as gpkg:
|
|
61
|
+
assert gpkg.has_tile(z, x, y)
|
|
62
|
+
result = gpkg.has_tiles([(z, x, y), (z1, x1, y1)])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
Parse a GetCapabilties xml to provide the TileMatrixSet for writing a geopackage:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from geopaktile import parse_capabilities
|
|
70
|
+
|
|
71
|
+
tms_set = parse_capabilities(xml)
|
|
72
|
+
tms = tms_set[0] # you'll need to know which one you want, or use:
|
|
73
|
+
|
|
74
|
+
tms = get_tile_matrix_set(xml, identifier = '1km') # an identifier that you know is defined in the xml
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
Convert a geopackage tile layer to a Cloud Optimized GeoTIFF. The highest zoom level becomes the full resolution image, and the lower zoom levels become the COG's overviews:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from geopaktile import gpkg_tiles_to_cog
|
|
82
|
+
|
|
83
|
+
gpkg_tiles_to_cog("tiles.gpkg", "tiles.tif")
|
|
84
|
+
|
|
85
|
+
# Clip to a bounding box, given here in longitude/latitude
|
|
86
|
+
gpkg_tiles_to_cog(
|
|
87
|
+
"tiles.gpkg",
|
|
88
|
+
"clipped.tif",
|
|
89
|
+
bbox=(-0.1, 51.45, 0.05, 51.52),
|
|
90
|
+
bbox_crs="EPSG:4326",
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### TileMatrixSet models
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
### XML Parser
|
|
99
|
+
|
|
100
|
+
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.
|
|
101
|
+
|
|
102
|
+
### Converting to COG
|
|
103
|
+
|
|
104
|
+
`gpkg_tiles_to_cog` copies each zoom level's own pixels into the COG as an overview, rather than letting GDAL resample new overviews from the full resolution image, so every overview keeps the scale of the zoom level it came from. It works with any tile grid where each zoom level halves the pixel size of the one below, such as `WebMercatorQuad` and most grids from GetCapabilities documents.
|
|
105
|
+
|
|
106
|
+
The output extent defaults to the tiles present at the highest zoom level, or can be set with `bbox` (in the layer's CRS, or in `bbox_crs` if given). The extent is expanded slightly so that it lines up with whole pixels at the zoom levels used as overviews.
|
|
107
|
+
|
|
108
|
+
A zoom level can only be copied directly if its pixel grid lines up with the output extent. For small extents, the coarsest zoom levels often don't line up, and are left out by default. Pass `resample_unaligned=True` to include them by resampling them onto the extent, down to an overview 1 pixel across. A warning lists any zoom levels that were resampled. The method is set with `resampling` (`"bilinear"` by default; any [rasterio Resampling](https://rasterio.readthedocs.io/en/stable/api/rasterio.enums.html#rasterio.enums.Resampling) name such as `"nearest"` or `"average"` works).
|
|
109
|
+
|
|
110
|
+
Other options:
|
|
111
|
+
|
|
112
|
+
| Argument | Default | Description |
|
|
113
|
+
| --- | --- | --- |
|
|
114
|
+
| `table` | the only tile table | Tile table to convert, needed if the geopackage has more than one. |
|
|
115
|
+
| `min_zoom`, `max_zoom` | all zoom levels with tiles | Zoom levels to include. `max_zoom` sets the full resolution. Without `resample_unaligned`, setting `min_zoom` expands the extent to line up with that zoom level's pixels, which can make the output much larger. |
|
|
116
|
+
| `bands` | `"RGBA"` | `"RGBA"` makes missing tiles transparent. `"RGB"` makes them black, and is required for JPEG compression. |
|
|
117
|
+
| `compress` | `"DEFLATE"` | Any GDAL COG compression, such as `"ZSTD"`, `"LZW"`, `"WEBP"` or `"JPEG"`. |
|
|
118
|
+
| `blocksize` | the geopackage tile width | Internal tile size of the COG. |
|
|
119
|
+
| `overwrite` | `False` | Replace an existing output file. |
|
|
120
|
+
|
|
121
|
+
COG conversion requires [rasterio](https://rasterio.readthedocs.io/), which includes GDAL's COG driver in its wheels.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
geopaktile/__init__.py,sha256=LOPsKscNgO2NqDz0SE8nDReAf6svu1md0PI4tNn8OPE,363
|
|
2
|
+
geopaktile/__main__.py,sha256=MD118vcJhpneDLGbjmErx-_ug1lrjRsZzlmOj0LG8bY,69
|
|
3
|
+
geopaktile/geopaktile.py,sha256=exSygyA-jhLOSii0ea95S7WLiwR1SVQOi64H7yKzNew,15404
|
|
4
|
+
geopaktile/gpkg_to_cog.py,sha256=qXrhaaKYnlrUQR4Ja0EwU48fli1iHhCQMWn0tqlU-JY,19558
|
|
5
|
+
geopaktile/xml_parser.py,sha256=eNEbJtHo885F5cQmAKPBOci7fz7f4E7D1z_jvTOCPKo,7791
|
|
6
|
+
geopaktile-2.1.0.dist-info/licenses/LICENSE.md,sha256=l_OJCOs5Hli7ri30MKfXMFO58k8juM3sT1aTvR3fRBM,1085
|
|
7
|
+
geopaktile-2.1.0.dist-info/WHEEL,sha256=M4DeIjVCA49okfALADZoWX5JOGwnmHb-JOpQHtI-1c0,80
|
|
8
|
+
geopaktile-2.1.0.dist-info/entry_points.txt,sha256=cNf1TFW8KuDXF6Mmi424dwlTm2pq5NLg-sntV37tePs,48
|
|
9
|
+
geopaktile-2.1.0.dist-info/METADATA,sha256=G5_CKyb8DBiWRGgjUxm0sRDbtGAX9qnHA7MZopqO_Sk,6441
|
|
10
|
+
geopaktile-2.1.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.
|