GeoPakTile 2.1.2__tar.gz → 2.1.4__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.2 → geopaktile-2.1.4}/PKG-INFO +1 -1
- {geopaktile-2.1.2 → geopaktile-2.1.4}/pyproject.toml +50 -50
- {geopaktile-2.1.2 → geopaktile-2.1.4}/src/geopaktile/geopaktile.py +12 -0
- {geopaktile-2.1.2 → geopaktile-2.1.4}/src/geopaktile/gpkg_to_cog.py +162 -9
- {geopaktile-2.1.2 → geopaktile-2.1.4}/LICENSE.md +0 -0
- {geopaktile-2.1.2 → geopaktile-2.1.4}/README.md +0 -0
- {geopaktile-2.1.2 → geopaktile-2.1.4}/src/geopaktile/__init__.py +0 -0
- {geopaktile-2.1.2 → geopaktile-2.1.4}/src/geopaktile/__main__.py +0 -0
- {geopaktile-2.1.2 → geopaktile-2.1.4}/src/geopaktile/xml_parser.py +0 -0
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "GeoPakTile"
|
|
3
|
-
version = "2.1.
|
|
4
|
-
description = "Create a Geopackage XYZ Tile layer."
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
authors = [
|
|
7
|
-
{ name = "apwebber", email = "39490780+apwebber@users.noreply.github.com" }
|
|
8
|
-
]
|
|
9
|
-
license = "MIT"
|
|
10
|
-
license-files = ["LICENSE.md"]
|
|
11
|
-
keywords = ["GIS", "Geopackage", "Tiles", "XYZ", "gpkg"]
|
|
12
|
-
requires-python = ">=3.10"
|
|
13
|
-
classifiers = [
|
|
14
|
-
"Development Status :: 4 - Beta",
|
|
15
|
-
|
|
16
|
-
# Specify the Python versions you support here.
|
|
17
|
-
"Programming Language :: Python :: 3.10",
|
|
18
|
-
"Programming Language :: Python :: 3.11",
|
|
19
|
-
"Programming Language :: Python :: 3.12",
|
|
20
|
-
"Programming Language :: Python :: 3.13",
|
|
21
|
-
"Programming Language :: Python :: 3.14",
|
|
22
|
-
]
|
|
23
|
-
dependencies = [
|
|
24
|
-
"typing_extensions>=4.0; python_version < '3.11'",
|
|
25
|
-
"morecantile>=6.2.0",
|
|
26
|
-
"numpy>=2.2.6",
|
|
27
|
-
"requests>=2.34.2",
|
|
28
|
-
"pillow>=12.3.0",
|
|
29
|
-
]
|
|
30
|
-
|
|
31
|
-
[project.urls]
|
|
32
|
-
Homepage = "https://github.com/apwebber/GeoPakTile"
|
|
33
|
-
Documentation = "https://github.com/apwebber/GeoPakTile"
|
|
34
|
-
Repository = "https://github.com/apwebber/GeoPakTile.git"
|
|
35
|
-
Issues = "https://github.com/apwebber/GeoPakTile/issues"
|
|
36
|
-
|
|
37
|
-
[project.scripts]
|
|
38
|
-
geopaktile = "geopaktile:main"
|
|
39
|
-
|
|
40
|
-
[build-system]
|
|
41
|
-
requires = ["uv_build>=0.11.2,<0.12.0"]
|
|
42
|
-
build-backend = "uv_build"
|
|
43
|
-
|
|
44
|
-
[dependency-groups]
|
|
45
|
-
dev = [
|
|
46
|
-
"pytest",
|
|
47
|
-
"diskcache",
|
|
48
|
-
"rasterio",
|
|
49
|
-
"rio-cogeo>=6.0.0",
|
|
50
|
-
]
|
|
1
|
+
[project]
|
|
2
|
+
name = "GeoPakTile"
|
|
3
|
+
version = "2.1.4"
|
|
4
|
+
description = "Create a Geopackage XYZ Tile layer."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "apwebber", email = "39490780+apwebber@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE.md"]
|
|
11
|
+
keywords = ["GIS", "Geopackage", "Tiles", "XYZ", "gpkg"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
|
|
16
|
+
# Specify the Python versions you support here.
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"typing_extensions>=4.0; python_version < '3.11'",
|
|
25
|
+
"morecantile>=6.2.0",
|
|
26
|
+
"numpy>=2.2.6",
|
|
27
|
+
"requests>=2.34.2",
|
|
28
|
+
"pillow>=12.3.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/apwebber/GeoPakTile"
|
|
33
|
+
Documentation = "https://github.com/apwebber/GeoPakTile"
|
|
34
|
+
Repository = "https://github.com/apwebber/GeoPakTile.git"
|
|
35
|
+
Issues = "https://github.com/apwebber/GeoPakTile/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
geopaktile = "geopaktile:main"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["uv_build>=0.11.2,<0.12.0"]
|
|
42
|
+
build-backend = "uv_build"
|
|
43
|
+
|
|
44
|
+
[dependency-groups]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest",
|
|
47
|
+
"diskcache",
|
|
48
|
+
"rasterio",
|
|
49
|
+
"rio-cogeo>=6.0.0",
|
|
50
|
+
]
|
|
@@ -99,6 +99,18 @@ class GeoPakTile:
|
|
|
99
99
|
self._conn.close()
|
|
100
100
|
self._conn = None
|
|
101
101
|
|
|
102
|
+
def get_tile(self, z: int, x: int, y: int) -> bytes | None:
|
|
103
|
+
"""Get a tile's image data using the zxy coordinates, or None if absent."""
|
|
104
|
+
|
|
105
|
+
self._cursor.execute(
|
|
106
|
+
f'SELECT tile_data 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 None if row is None else row[0]
|
|
113
|
+
|
|
102
114
|
def has_tile(self, z: int, x: int, y: int):
|
|
103
115
|
"""Check if a tile is present, using the zxy coordinates"""
|
|
104
116
|
|
|
@@ -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,123 @@ 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
|
+
|
|
71
188
|
def _connect_readonly(path: str | Path) -> sqlite3.Connection:
|
|
72
189
|
r"""Open a GeoPackage read-only, trying resolve() first, then absolute().
|
|
73
190
|
|
|
@@ -132,6 +249,7 @@ def gpkg_tiles_to_cog(
|
|
|
132
249
|
blocksize: int | None = None,
|
|
133
250
|
overwrite: bool = False,
|
|
134
251
|
tmp_dir: str | Path | None = None,
|
|
252
|
+
skip_bad_tiles: bool = False,
|
|
135
253
|
) -> Path:
|
|
136
254
|
"""Convert a GeoPackage tile layer to a COG, using zoom levels as overviews.
|
|
137
255
|
|
|
@@ -161,6 +279,8 @@ def gpkg_tiles_to_cog(
|
|
|
161
279
|
tmp_dir: Directory for the temporary per-zoom mosaics, which can need
|
|
162
280
|
about as much space as the output. Defaults to the output file's
|
|
163
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.
|
|
164
284
|
|
|
165
285
|
Returns:
|
|
166
286
|
Path to the written COG.
|
|
@@ -330,6 +450,7 @@ def gpkg_tiles_to_cog(
|
|
|
330
450
|
with tempfile.TemporaryDirectory(prefix="gpkg2cog_", dir=tmp_dir) as tmp:
|
|
331
451
|
tmpdir = Path(tmp)
|
|
332
452
|
level_files: list[Path] = []
|
|
453
|
+
bad_tiles: list[str] | None = [] if skip_bad_tiles else None
|
|
333
454
|
|
|
334
455
|
for p in plan:
|
|
335
456
|
lv = p.level
|
|
@@ -339,15 +460,26 @@ def gpkg_tiles_to_cog(
|
|
|
339
460
|
con, qtable, lv, path, crs, tmpdir,
|
|
340
461
|
(tms_min_x, tms_max_y), (x0, y0, x1, y1),
|
|
341
462
|
p.width, p.height, bands, n_bands, resampling_method,
|
|
463
|
+
bad_tiles,
|
|
342
464
|
)
|
|
343
465
|
else:
|
|
344
466
|
_mosaic_level(
|
|
345
467
|
con, qtable, lv, path, crs,
|
|
346
468
|
p.col_off, p.row_off, p.width, p.height,
|
|
347
469
|
from_origin(x0, y1, lv.px, lv.py), bands, n_bands,
|
|
470
|
+
bad_tiles,
|
|
348
471
|
)
|
|
349
472
|
level_files.append(path)
|
|
350
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
|
+
|
|
351
483
|
vrt_path = tmpdir / "pyramid.vrt"
|
|
352
484
|
vrt_path.write_text(
|
|
353
485
|
_pyramid_vrt(
|
|
@@ -410,8 +542,13 @@ def _mosaic_level(
|
|
|
410
542
|
transform: Affine,
|
|
411
543
|
bands: str,
|
|
412
544
|
n_bands: int,
|
|
545
|
+
bad_tiles: list[str] | None = None,
|
|
413
546
|
) -> None:
|
|
414
|
-
"""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
|
+
"""
|
|
415
552
|
tw, th = lv.tile_width, lv.tile_height
|
|
416
553
|
first_col, last_col = col_off // tw, (col_off + width - 1) // tw
|
|
417
554
|
first_row, last_row = row_off // th, (row_off + height - 1) // th
|
|
@@ -426,13 +563,14 @@ def _mosaic_level(
|
|
|
426
563
|
(lv.zoom, first_col, last_col, first_row, last_row),
|
|
427
564
|
)
|
|
428
565
|
for col, row, blob in rows:
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
436
574
|
|
|
437
575
|
# Tile position relative to the output window, then clip to it.
|
|
438
576
|
tx, ty = col * tw - col_off, row * th - row_off
|
|
@@ -461,6 +599,7 @@ def _resample_level(
|
|
|
461
599
|
bands: str,
|
|
462
600
|
n_bands: int,
|
|
463
601
|
resampling: Resampling,
|
|
602
|
+
bad_tiles: list[str] | None = None,
|
|
464
603
|
) -> None:
|
|
465
604
|
"""Crop a level on its own grid, then resample it onto the output extent.
|
|
466
605
|
|
|
@@ -484,6 +623,7 @@ def _resample_level(
|
|
|
484
623
|
_mosaic_level(
|
|
485
624
|
con, qtable, lv, native_path, crs,
|
|
486
625
|
c_lo, r_lo, c_hi - c_lo, r_hi - r_lo, native_transform, bands, n_bands,
|
|
626
|
+
bad_tiles,
|
|
487
627
|
)
|
|
488
628
|
with rasterio.open(native_path) as src:
|
|
489
629
|
source = src.read()
|
|
@@ -561,7 +701,19 @@ if __name__ == "__main__":
|
|
|
561
701
|
parser.add_argument("--compress", default="DEFLATE")
|
|
562
702
|
parser.add_argument("--overwrite", action="store_true")
|
|
563
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
|
+
)
|
|
564
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)
|
|
565
717
|
gpkg_tiles_to_cog(
|
|
566
718
|
args.gpkg,
|
|
567
719
|
args.cog,
|
|
@@ -576,4 +728,5 @@ if __name__ == "__main__":
|
|
|
576
728
|
compress=args.compress,
|
|
577
729
|
overwrite=args.overwrite,
|
|
578
730
|
tmp_dir=args.tmp_dir,
|
|
731
|
+
skip_bad_tiles=args.skip_bad_tiles,
|
|
579
732
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|