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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GeoPakTile
3
- Version: 2.1.2
3
+ Version: 2.1.4
4
4
  Summary: Create a Geopackage XYZ Tile layer.
5
5
  Keywords: GIS,Geopackage,Tiles,XYZ,gpkg
6
6
  Author: apwebber
@@ -1,50 +1,50 @@
1
- [project]
2
- name = "GeoPakTile"
3
- version = "2.1.2"
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
- with Image.open(io.BytesIO(blob)) as img:
430
- if img.size != (tw, th):
431
- raise ValueError(
432
- f"Tile z={lv.zoom} col={col} row={row} is {img.size}, "
433
- f"expected {(tw, th)}"
434
- )
435
- pixels = np.asarray(img.convert(bands))
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