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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GeoPakTile
3
- Version: 2.1.1
3
+ Version: 2.1.3
4
4
  Summary: Create a Geopackage XYZ Tile layer.
5
5
  Keywords: GIS,Geopackage,Tiles,XYZ,gpkg
6
6
  Author: apwebber
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "GeoPakTile"
3
- version = "2.1.1"
3
+ version = "2.1.3"
4
4
  description = "Create a Geopackage XYZ Tile layer."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
- uri = Path(gpkg_path).resolve().as_uri() + "?mode=ro"
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
- with tempfile.TemporaryDirectory(prefix="gpkg2cog_") as tmp:
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
- 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))
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