GeoPakTile 2.1.1__tar.gz → 2.1.3__tar.gz
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-2.1.1 → geopaktile-2.1.3}/PKG-INFO +1 -1
- {geopaktile-2.1.1 → geopaktile-2.1.3}/pyproject.toml +1 -1
- {geopaktile-2.1.1 → geopaktile-2.1.3}/src/geopaktile/gpkg_to_cog.py +238 -14
- {geopaktile-2.1.1 → geopaktile-2.1.3}/LICENSE.md +0 -0
- {geopaktile-2.1.1 → geopaktile-2.1.3}/README.md +0 -0
- {geopaktile-2.1.1 → geopaktile-2.1.3}/src/geopaktile/__init__.py +0 -0
- {geopaktile-2.1.1 → geopaktile-2.1.3}/src/geopaktile/__main__.py +0 -0
- {geopaktile-2.1.1 → geopaktile-2.1.3}/src/geopaktile/geopaktile.py +0 -0
- {geopaktile-2.1.1 → geopaktile-2.1.3}/src/geopaktile/xml_parser.py +0 -0
|
@@ -34,7 +34,7 @@ from xml.sax.saxutils import escape
|
|
|
34
34
|
import numpy as np
|
|
35
35
|
import rasterio
|
|
36
36
|
import rasterio.shutil
|
|
37
|
-
from PIL import Image
|
|
37
|
+
from PIL import Image, UnidentifiedImageError
|
|
38
38
|
from rasterio.crs import CRS
|
|
39
39
|
from rasterio.enums import Resampling
|
|
40
40
|
from rasterio.transform import Affine, from_origin
|
|
@@ -68,6 +68,152 @@ class _Plan:
|
|
|
68
68
|
resampled: bool
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
class BadTileError(ValueError):
|
|
72
|
+
"""A tile's data couldn't be decoded as an image."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Leading bytes of formats a tile is likely to contain, for diagnostics.
|
|
76
|
+
_SIGNATURES = [
|
|
77
|
+
(b"\x89PNG\r\n\x1a\n", "PNG"),
|
|
78
|
+
(b"\xff\xd8\xff", "JPEG"),
|
|
79
|
+
(b"GIF87a", "GIF"),
|
|
80
|
+
(b"GIF89a", "GIF"),
|
|
81
|
+
(b"II*\x00", "TIFF"),
|
|
82
|
+
(b"MM\x00*", "TIFF"),
|
|
83
|
+
(b"\x1f\x8b", "gzip-compressed data (often a vector tile, not an image)"),
|
|
84
|
+
(b"\x78\x9c", "zlib-compressed data"),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _describe_tile(data: object) -> str:
|
|
89
|
+
"""Summarise what a tile_data value contains, to explain a decode failure."""
|
|
90
|
+
if data is None:
|
|
91
|
+
return "tile_data is NULL"
|
|
92
|
+
if isinstance(data, str):
|
|
93
|
+
return f"tile_data is stored as TEXT, not a BLOB: {data[:80]!r}"
|
|
94
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
95
|
+
return f"tile_data has unexpected type {type(data).__name__}"
|
|
96
|
+
data = bytes(data)
|
|
97
|
+
if not data:
|
|
98
|
+
return "tile_data is empty (0 bytes)"
|
|
99
|
+
|
|
100
|
+
size = f"{len(data):,} bytes"
|
|
101
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
102
|
+
return f"{size}, starts like WebP"
|
|
103
|
+
for signature, name in _SIGNATURES:
|
|
104
|
+
if data.startswith(signature):
|
|
105
|
+
return f"{size}, starts like {name}"
|
|
106
|
+
|
|
107
|
+
# Error responses saved in place of tiles (HTML, XML, JSON) are text.
|
|
108
|
+
head = data[:200]
|
|
109
|
+
try:
|
|
110
|
+
text = head.decode("utf-8")
|
|
111
|
+
except UnicodeDecodeError:
|
|
112
|
+
text = None
|
|
113
|
+
if text is not None and all(c.isprintable() or c.isspace() for c in text):
|
|
114
|
+
return f"{size} of text, not image data: {text.strip()[:150]!r}"
|
|
115
|
+
return f"{size}, unrecognised format; first bytes: {data[:16].hex(' ')}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _decode_tile(
|
|
119
|
+
data: object, bands: str, tile_size: tuple[int, int], where: str
|
|
120
|
+
) -> np.ndarray:
|
|
121
|
+
"""Decode one tile to a (height, width, bands) array, or raise BadTileError."""
|
|
122
|
+
try:
|
|
123
|
+
with Image.open(io.BytesIO(data)) as img: # type: ignore[arg-type]
|
|
124
|
+
if img.size != tile_size:
|
|
125
|
+
raise BadTileError(
|
|
126
|
+
f"{where} is {img.size[0]}x{img.size[1]} pixels, "
|
|
127
|
+
f"expected {tile_size[0]}x{tile_size[1]}"
|
|
128
|
+
)
|
|
129
|
+
# Image.open is lazy: truncated data only fails when decoded here.
|
|
130
|
+
return np.asarray(img.convert(bands))
|
|
131
|
+
except BadTileError:
|
|
132
|
+
raise
|
|
133
|
+
except (UnidentifiedImageError, OSError, SyntaxError, TypeError, ValueError) as exc:
|
|
134
|
+
message = f"{where} could not be decoded: {_describe_tile(data)}"
|
|
135
|
+
# Pillow's "cannot identify image file <BytesIO ...>" adds nothing to
|
|
136
|
+
# the description; other errors (e.g. truncation) are worth keeping.
|
|
137
|
+
if not isinstance(exc, (UnidentifiedImageError, TypeError)):
|
|
138
|
+
message += f" ({type(exc).__name__}: {exc})"
|
|
139
|
+
raise BadTileError(message) from exc
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_tiles(
|
|
143
|
+
gpkg_path: str | Path, table: str | None = None, bands: str = "RGBA"
|
|
144
|
+
) -> list[str]:
|
|
145
|
+
"""Try to decode every tile in a layer and describe each one that fails.
|
|
146
|
+
|
|
147
|
+
Useful before a long conversion, or to investigate a BadTileError.
|
|
148
|
+
Returns an empty list if every tile decodes.
|
|
149
|
+
"""
|
|
150
|
+
con = _connect_readonly(gpkg_path)
|
|
151
|
+
try:
|
|
152
|
+
if table is None:
|
|
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 len(tables) != 1:
|
|
160
|
+
raise ValueError(f"Specify table=; tile tables found: {tables}")
|
|
161
|
+
table = tables[0]
|
|
162
|
+
sizes = {
|
|
163
|
+
z: (w, h)
|
|
164
|
+
for z, w, h in con.execute(
|
|
165
|
+
"SELECT zoom_level, tile_width, tile_height FROM gpkg_tile_matrix "
|
|
166
|
+
"WHERE table_name = ?",
|
|
167
|
+
(table,),
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
problems = []
|
|
171
|
+
for z, col, row, data in con.execute(
|
|
172
|
+
f"SELECT zoom_level, tile_column, tile_row, tile_data "
|
|
173
|
+
f"FROM {_quote_ident(table)} ORDER BY zoom_level, tile_row, tile_column"
|
|
174
|
+
):
|
|
175
|
+
where = f"Tile z={z} col={col} row={row}"
|
|
176
|
+
if z not in sizes:
|
|
177
|
+
problems.append(f"{where} has no gpkg_tile_matrix entry")
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
_decode_tile(data, bands, sizes[z], where)
|
|
181
|
+
except BadTileError as exc:
|
|
182
|
+
problems.append(str(exc))
|
|
183
|
+
return problems
|
|
184
|
+
finally:
|
|
185
|
+
con.close()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _connect_readonly(path: str | Path) -> sqlite3.Connection:
|
|
189
|
+
r"""Open a GeoPackage read-only, trying resolve() first, then absolute().
|
|
190
|
+
|
|
191
|
+
resolve() can fail on network drives: it may raise, or turn a mapped
|
|
192
|
+
drive letter into a UNC path (\\server\share) whose file:// URI SQLite
|
|
193
|
+
rejects. absolute() keeps the path as given, which often works instead.
|
|
194
|
+
"""
|
|
195
|
+
path = Path(path)
|
|
196
|
+
errors: list[str] = []
|
|
197
|
+
tried: set[str] = set()
|
|
198
|
+
for method in (Path.resolve, Path.absolute):
|
|
199
|
+
con = None
|
|
200
|
+
try:
|
|
201
|
+
uri = method(path).as_uri() + "?mode=ro"
|
|
202
|
+
if uri in tried:
|
|
203
|
+
continue
|
|
204
|
+
tried.add(uri)
|
|
205
|
+
con = sqlite3.connect(uri, uri=True)
|
|
206
|
+
con.execute("PRAGMA schema_version") # make SQLite actually open the file
|
|
207
|
+
return con
|
|
208
|
+
except (OSError, RuntimeError, ValueError, sqlite3.Error) as exc:
|
|
209
|
+
if con is not None:
|
|
210
|
+
con.close()
|
|
211
|
+
errors.append(f"{method.__name__}(): {exc}")
|
|
212
|
+
raise sqlite3.OperationalError(
|
|
213
|
+
f"Could not open GeoPackage {path}: " + "; ".join(errors)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
71
217
|
def _quote_ident(name: str) -> str:
|
|
72
218
|
return '"' + name.replace('"', '""') + '"'
|
|
73
219
|
|
|
@@ -84,7 +230,7 @@ def _as_int(value: float, what: str) -> int:
|
|
|
84
230
|
"Zoom levels may not be exact multiples of each other.",
|
|
85
231
|
stacklevel=3,
|
|
86
232
|
)
|
|
87
|
-
return round(value)
|
|
233
|
+
return int(round(value))
|
|
88
234
|
|
|
89
235
|
|
|
90
236
|
def gpkg_tiles_to_cog(
|
|
@@ -102,6 +248,8 @@ def gpkg_tiles_to_cog(
|
|
|
102
248
|
compress: str = "DEFLATE",
|
|
103
249
|
blocksize: int | None = None,
|
|
104
250
|
overwrite: bool = False,
|
|
251
|
+
tmp_dir: str | Path | None = None,
|
|
252
|
+
skip_bad_tiles: bool = False,
|
|
105
253
|
) -> Path:
|
|
106
254
|
"""Convert a GeoPackage tile layer to a COG, using zoom levels as overviews.
|
|
107
255
|
|
|
@@ -128,6 +276,11 @@ def gpkg_tiles_to_cog(
|
|
|
128
276
|
compress: COG compression, e.g. "DEFLATE", "ZSTD", "LZW", "WEBP", "JPEG".
|
|
129
277
|
blocksize: COG internal tile size. Defaults to the GeoPackage tile width.
|
|
130
278
|
overwrite: Replace an existing output file.
|
|
279
|
+
tmp_dir: Directory for the temporary per-zoom mosaics, which can need
|
|
280
|
+
about as much space as the output. Defaults to the output file's
|
|
281
|
+
directory, so they go on the same drive as the COG.
|
|
282
|
+
skip_bad_tiles: Leave tiles that can't be decoded empty and warn,
|
|
283
|
+
instead of raising BadTileError at the first one.
|
|
131
284
|
|
|
132
285
|
Returns:
|
|
133
286
|
Path to the written COG.
|
|
@@ -146,8 +299,7 @@ def gpkg_tiles_to_cog(
|
|
|
146
299
|
if cog_path.exists() and not overwrite:
|
|
147
300
|
raise FileExistsError(f"{cog_path} exists; pass overwrite=True")
|
|
148
301
|
|
|
149
|
-
|
|
150
|
-
con = sqlite3.connect(uri, uri=True)
|
|
302
|
+
con = _connect_readonly(gpkg_path)
|
|
151
303
|
try:
|
|
152
304
|
# --- Tile table, tile matrix set and CRS ----------------------------
|
|
153
305
|
tables = [
|
|
@@ -293,9 +445,12 @@ def gpkg_tiles_to_cog(
|
|
|
293
445
|
if blocksize is None:
|
|
294
446
|
blocksize = base.tile_width
|
|
295
447
|
|
|
296
|
-
|
|
448
|
+
if tmp_dir is None:
|
|
449
|
+
tmp_dir = cog_path.absolute().parent # absolute(): resolve() can fail on network drives
|
|
450
|
+
with tempfile.TemporaryDirectory(prefix="gpkg2cog_", dir=tmp_dir) as tmp:
|
|
297
451
|
tmpdir = Path(tmp)
|
|
298
452
|
level_files: list[Path] = []
|
|
453
|
+
bad_tiles: list[str] | None = [] if skip_bad_tiles else None
|
|
299
454
|
|
|
300
455
|
for p in plan:
|
|
301
456
|
lv = p.level
|
|
@@ -305,15 +460,26 @@ def gpkg_tiles_to_cog(
|
|
|
305
460
|
con, qtable, lv, path, crs, tmpdir,
|
|
306
461
|
(tms_min_x, tms_max_y), (x0, y0, x1, y1),
|
|
307
462
|
p.width, p.height, bands, n_bands, resampling_method,
|
|
463
|
+
bad_tiles,
|
|
308
464
|
)
|
|
309
465
|
else:
|
|
310
466
|
_mosaic_level(
|
|
311
467
|
con, qtable, lv, path, crs,
|
|
312
468
|
p.col_off, p.row_off, p.width, p.height,
|
|
313
469
|
from_origin(x0, y1, lv.px, lv.py), bands, n_bands,
|
|
470
|
+
bad_tiles,
|
|
314
471
|
)
|
|
315
472
|
level_files.append(path)
|
|
316
473
|
|
|
474
|
+
if bad_tiles:
|
|
475
|
+
shown = "\n ".join(bad_tiles[:10])
|
|
476
|
+
more = f"\n ...and {len(bad_tiles) - 10} more" if len(bad_tiles) > 10 else ""
|
|
477
|
+
warnings.warn(
|
|
478
|
+
f"Skipped {len(bad_tiles)} tile(s) that could not be decoded; "
|
|
479
|
+
f"they are left empty in the COG:\n {shown}{more}",
|
|
480
|
+
stacklevel=2,
|
|
481
|
+
)
|
|
482
|
+
|
|
317
483
|
vrt_path = tmpdir / "pyramid.vrt"
|
|
318
484
|
vrt_path.write_text(
|
|
319
485
|
_pyramid_vrt(
|
|
@@ -376,8 +542,13 @@ def _mosaic_level(
|
|
|
376
542
|
transform: Affine,
|
|
377
543
|
bands: str,
|
|
378
544
|
n_bands: int,
|
|
545
|
+
bad_tiles: list[str] | None = None,
|
|
379
546
|
) -> None:
|
|
380
|
-
"""Copy one zoom level's pixels inside a pixel window into a GeoTIFF.
|
|
547
|
+
"""Copy one zoom level's pixels inside a pixel window into a GeoTIFF.
|
|
548
|
+
|
|
549
|
+
Tiles that can't be decoded raise BadTileError, or, if bad_tiles is a
|
|
550
|
+
list, are described in it and left empty.
|
|
551
|
+
"""
|
|
381
552
|
tw, th = lv.tile_width, lv.tile_height
|
|
382
553
|
first_col, last_col = col_off // tw, (col_off + width - 1) // tw
|
|
383
554
|
first_row, last_row = row_off // th, (row_off + height - 1) // th
|
|
@@ -392,13 +563,14 @@ def _mosaic_level(
|
|
|
392
563
|
(lv.zoom, first_col, last_col, first_row, last_row),
|
|
393
564
|
)
|
|
394
565
|
for col, row, blob in rows:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
566
|
+
where = f"Tile z={lv.zoom} col={col} row={row}"
|
|
567
|
+
try:
|
|
568
|
+
pixels = _decode_tile(blob, bands, (tw, th), where)
|
|
569
|
+
except BadTileError as exc:
|
|
570
|
+
if bad_tiles is None:
|
|
571
|
+
raise
|
|
572
|
+
bad_tiles.append(str(exc))
|
|
573
|
+
continue
|
|
402
574
|
|
|
403
575
|
# Tile position relative to the output window, then clip to it.
|
|
404
576
|
tx, ty = col * tw - col_off, row * th - row_off
|
|
@@ -427,6 +599,7 @@ def _resample_level(
|
|
|
427
599
|
bands: str,
|
|
428
600
|
n_bands: int,
|
|
429
601
|
resampling: Resampling,
|
|
602
|
+
bad_tiles: list[str] | None = None,
|
|
430
603
|
) -> None:
|
|
431
604
|
"""Crop a level on its own grid, then resample it onto the output extent.
|
|
432
605
|
|
|
@@ -450,6 +623,7 @@ def _resample_level(
|
|
|
450
623
|
_mosaic_level(
|
|
451
624
|
con, qtable, lv, native_path, crs,
|
|
452
625
|
c_lo, r_lo, c_hi - c_lo, r_hi - r_lo, native_transform, bands, n_bands,
|
|
626
|
+
bad_tiles,
|
|
453
627
|
)
|
|
454
628
|
with rasterio.open(native_path) as src:
|
|
455
629
|
source = src.read()
|
|
@@ -505,4 +679,54 @@ def _pyramid_vrt(
|
|
|
505
679
|
]
|
|
506
680
|
parts.append(" </VRTRasterBand>")
|
|
507
681
|
parts.append("</VRTDataset>")
|
|
508
|
-
return "\n".join(parts)
|
|
682
|
+
return "\n".join(parts)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
if __name__ == "__main__":
|
|
686
|
+
import argparse
|
|
687
|
+
|
|
688
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
689
|
+
parser.add_argument("gpkg")
|
|
690
|
+
parser.add_argument("cog")
|
|
691
|
+
parser.add_argument("--table")
|
|
692
|
+
parser.add_argument(
|
|
693
|
+
"--bbox", type=float, nargs=4, metavar=("MINX", "MINY", "MAXX", "MAXY")
|
|
694
|
+
)
|
|
695
|
+
parser.add_argument("--bbox-crs")
|
|
696
|
+
parser.add_argument("--min-zoom", type=int)
|
|
697
|
+
parser.add_argument("--max-zoom", type=int)
|
|
698
|
+
parser.add_argument("--resample-unaligned", action="store_true")
|
|
699
|
+
parser.add_argument("--resampling", default="bilinear")
|
|
700
|
+
parser.add_argument("--bands", default="RGBA", choices=sorted(SUPPORTED_BANDS))
|
|
701
|
+
parser.add_argument("--compress", default="DEFLATE")
|
|
702
|
+
parser.add_argument("--overwrite", action="store_true")
|
|
703
|
+
parser.add_argument("--tmp-dir", help="directory for temporary files")
|
|
704
|
+
parser.add_argument(
|
|
705
|
+
"--skip-bad-tiles", action="store_true",
|
|
706
|
+
help="leave tiles that can't be decoded empty instead of failing",
|
|
707
|
+
)
|
|
708
|
+
parser.add_argument(
|
|
709
|
+
"--check", action="store_true",
|
|
710
|
+
help="only report tiles that can't be decoded; don't convert",
|
|
711
|
+
)
|
|
712
|
+
args = parser.parse_args()
|
|
713
|
+
if args.check:
|
|
714
|
+
problems = check_tiles(args.gpkg, args.table, args.bands)
|
|
715
|
+
print("\n".join(problems) or "All tiles decoded successfully.")
|
|
716
|
+
raise SystemExit(1 if problems else 0)
|
|
717
|
+
gpkg_tiles_to_cog(
|
|
718
|
+
args.gpkg,
|
|
719
|
+
args.cog,
|
|
720
|
+
args.table,
|
|
721
|
+
bbox=args.bbox,
|
|
722
|
+
bbox_crs=args.bbox_crs,
|
|
723
|
+
min_zoom=args.min_zoom,
|
|
724
|
+
max_zoom=args.max_zoom,
|
|
725
|
+
resample_unaligned=args.resample_unaligned,
|
|
726
|
+
resampling=args.resampling,
|
|
727
|
+
bands=args.bands,
|
|
728
|
+
compress=args.compress,
|
|
729
|
+
overwrite=args.overwrite,
|
|
730
|
+
tmp_dir=args.tmp_dir,
|
|
731
|
+
skip_bad_tiles=args.skip_bad_tiles,
|
|
732
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|