topo_map_processor 0.1.25__tar.gz → 0.1.26__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.5
2
2
  Name: topo_map_processor
3
- Version: 0.1.25
3
+ Version: 0.1.26
4
4
  Summary: Library for processing topographic maps
5
5
  Project-URL: homepage, https://github.com/ramSeraph/topo_map_processor
6
6
  Project-URL: repository, https://github.com/ramSeraph/topo_map_processor.git
@@ -265,10 +265,12 @@ Retiles a specific list of sheets (GeoTIFFs) or a specific area and updates the
265
265
 
266
266
  The tool works in two stages. First, it calculates the full set of sheets that need to be processed (the "pull list"), which includes the sheets from the input list plus any adjacent sheets that share tiles. You can run the tool with `--sheets-to-pull-list-outfile` to generate this list and then exit. This is useful for fetching the required GeoTIFFs before proceeding.
267
267
 
268
- In the second stage, it retiles the affected area. It uses the pull list to create a temporary virtual raster (`.vrt`), generates new base tiles for the affected region, and then reconstructs the upper-level tiles by pulling existing, unaffected tiles from the PMTiles source.
268
+ In the second stage, it uses the pull list to create a common virtual raster (`.vrt`) and renders only affected base tiles in geographic batches. Each batch spans at most 32 × 32 tiles by default. All batches read the same source mosaic, so overlapping sheets have consistent priority and resampling does not introduce batch boundaries. Completed batches move their tiles into a shared staging directory; every base tile belongs to exactly one batch. After all batches complete, the tool reconstructs upper-level tiles using the staged results and existing, unaffected tiles from the PMTiles source. Transparent replacement tiles are retained to clear removed content.
269
+
270
+ `retile-e2e` uses the same batching automatically and packages and publishes the combined result once. Both commands accept `--batch-size` to set the maximum width and height of a base-tile batch. This is batching within one invocation; it does not persist a checkpoint across workflow runs.
269
271
 
270
272
  ```bash
271
- retile [--retile-list-file <file>] --bounds-file <file> [--force-redo-bounds-file <file>] [--max-zoom <zoom>] [--tiffs-dir <dir>] [--tiles-dir <dir>] [--from-source <source>] [--sheets-to-pull-list-outfile <file>] [--num-parallel <processes>] [--tile-quality <quality>]
273
+ retile [--retile-list-file <file>] --bounds-file <file> [--force-redo-bounds-file <file>] [--max-zoom <zoom>] [--tiffs-dir <dir>] [--tiles-dir <dir>] [--from-source <source>] [--sheets-to-pull-list-outfile <file>] [--num-parallel <processes>] [--tile-quality <quality>] [--batch-size <tiles>]
272
274
  ```
273
275
 
274
276
  - `--retile-list-file`: File containing the list of sheets to retile. (Optional)
@@ -281,6 +283,7 @@ retile [--retile-list-file <file>] --bounds-file <file> [--force-redo-bounds-fil
281
283
  - `--sheets-to-pull-list-outfile`: If provided, the script will only calculate the full list of sheets that need to be processed (including adjacent ones) and write it to this file, then exit.
282
284
  - `--num-parallel`: Number of parallel processes to use for tiling (default: number of CPU cores).
283
285
  - `--tile-quality`: Quality of compression for webp and jpeg (default: 75).
286
+ - `--batch-size`: Maximum width and height of each base-tile batch in tiles (default: 32). Only affected tiles inside each batch are rendered.
284
287
 
285
288
  #### `create-force-redo-bounds`
286
289
 
@@ -188,10 +188,12 @@ Retiles a specific list of sheets (GeoTIFFs) or a specific area and updates the
188
188
 
189
189
  The tool works in two stages. First, it calculates the full set of sheets that need to be processed (the "pull list"), which includes the sheets from the input list plus any adjacent sheets that share tiles. You can run the tool with `--sheets-to-pull-list-outfile` to generate this list and then exit. This is useful for fetching the required GeoTIFFs before proceeding.
190
190
 
191
- In the second stage, it retiles the affected area. It uses the pull list to create a temporary virtual raster (`.vrt`), generates new base tiles for the affected region, and then reconstructs the upper-level tiles by pulling existing, unaffected tiles from the PMTiles source.
191
+ In the second stage, it uses the pull list to create a common virtual raster (`.vrt`) and renders only affected base tiles in geographic batches. Each batch spans at most 32 × 32 tiles by default. All batches read the same source mosaic, so overlapping sheets have consistent priority and resampling does not introduce batch boundaries. Completed batches move their tiles into a shared staging directory; every base tile belongs to exactly one batch. After all batches complete, the tool reconstructs upper-level tiles using the staged results and existing, unaffected tiles from the PMTiles source. Transparent replacement tiles are retained to clear removed content.
192
+
193
+ `retile-e2e` uses the same batching automatically and packages and publishes the combined result once. Both commands accept `--batch-size` to set the maximum width and height of a base-tile batch. This is batching within one invocation; it does not persist a checkpoint across workflow runs.
192
194
 
193
195
  ```bash
194
- retile [--retile-list-file <file>] --bounds-file <file> [--force-redo-bounds-file <file>] [--max-zoom <zoom>] [--tiffs-dir <dir>] [--tiles-dir <dir>] [--from-source <source>] [--sheets-to-pull-list-outfile <file>] [--num-parallel <processes>] [--tile-quality <quality>]
196
+ retile [--retile-list-file <file>] --bounds-file <file> [--force-redo-bounds-file <file>] [--max-zoom <zoom>] [--tiffs-dir <dir>] [--tiles-dir <dir>] [--from-source <source>] [--sheets-to-pull-list-outfile <file>] [--num-parallel <processes>] [--tile-quality <quality>] [--batch-size <tiles>]
195
197
  ```
196
198
 
197
199
  - `--retile-list-file`: File containing the list of sheets to retile. (Optional)
@@ -204,6 +206,7 @@ retile [--retile-list-file <file>] --bounds-file <file> [--force-redo-bounds-fil
204
206
  - `--sheets-to-pull-list-outfile`: If provided, the script will only calculate the full list of sheets that need to be processed (including adjacent ones) and write it to this file, then exit.
205
207
  - `--num-parallel`: Number of parallel processes to use for tiling (default: number of CPU cores).
206
208
  - `--tile-quality`: Quality of compression for webp and jpeg (default: 75).
209
+ - `--batch-size`: Maximum width and height of each base-tile batch in tiles (default: 32). Only affected tiles inside each batch are rendered.
207
210
 
208
211
  #### `create-force-redo-bounds`
209
212
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.25'
22
- __version_tuple__ = version_tuple = (0, 1, 25)
21
+ __version__ = version = '0.1.26'
22
+ __version_tuple__ = version_tuple = (0, 1, 26)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -5,6 +5,10 @@ import sys
5
5
  import json
6
6
  import argparse
7
7
  import tempfile
8
+ import shlex
9
+ import shutil
10
+ from collections import defaultdict
11
+ from contextlib import contextmanager
8
12
 
9
13
  import time
10
14
  import subprocess
@@ -16,10 +20,13 @@ from multiprocessing import cpu_count, Pool, set_start_method
16
20
 
17
21
  import mercantile
18
22
  from shapely.geometry import shape
23
+ from osgeo import gdal
19
24
 
20
25
  from osgeo_utils.gdal2tiles import (
21
- submain as gdal2tiles_main,
26
+ create_base_tile,
22
27
  create_overview_tile,
28
+ process_args as gdal2tiles_process_args,
29
+ TileDetail,
23
30
  TileJobInfo,
24
31
  GDAL2Tiles,
25
32
  DividedCache,
@@ -47,6 +54,35 @@ class AttrDict(dict):
47
54
  super(AttrDict, self).__init__(*args, **kwargs)
48
55
  self.__dict__ = self
49
56
 
57
+
58
+ def base_tile_batches(tiles, batch_size=32):
59
+ """Assign each XYZ tile to one bounded geographic batch."""
60
+ if batch_size < 1:
61
+ raise ValueError('batch size must be positive')
62
+ batches = defaultdict(set)
63
+ for tile in tiles:
64
+ batches[(tile.z, tile.x // batch_size, tile.y // batch_size)].add(tile)
65
+ return [batches[key] for key in sorted(batches)]
66
+
67
+
68
+ @contextmanager
69
+ def gdal_exceptions():
70
+ """Restore the caller's error handling, including on GDAL 3.8."""
71
+ previously_enabled = gdal.GetUseExceptions()
72
+ gdal.UseExceptions()
73
+ try:
74
+ yield
75
+ finally:
76
+ if not previously_enabled:
77
+ gdal.DontUseExceptions()
78
+
79
+
80
+ def render_base_tile(conf, detail):
81
+ # Spawned workers do not inherit the parent's GDAL exception setting.
82
+ with gdal_exceptions():
83
+ create_base_tile(conf, detail)
84
+
85
+
50
86
  class DiskTilesHandler:
51
87
  def __init__(self, tiles_dir, tile_extension):
52
88
  self.dir = Path(tiles_dir)
@@ -92,7 +128,7 @@ class DiskTilesHandler:
92
128
 
93
129
  class Tiler:
94
130
  def __init__(self, tiles_dir, tiffs_dir,
95
- orig_tile_source, tile_extension, tile_quality, pool):
131
+ orig_tile_source, tile_extension, tile_quality, pool, batch_size=32):
96
132
 
97
133
  self.disk_handler = DiskTilesHandler(tiles_dir, tile_extension)
98
134
  self.orig_tile_source = orig_tile_source
@@ -100,6 +136,7 @@ class Tiler:
100
136
  self.tile_extension = tile_extension
101
137
  self.tile_quality = tile_quality
102
138
  self.pool = pool
139
+ self.batch_size = batch_size
103
140
 
104
141
  def convert_paths_in_vrt(self, vrt_file):
105
142
  # <SourceFilename relativeToVRT="1">40M_15.tif</SourceFilename>
@@ -113,16 +150,14 @@ class Tiler:
113
150
  vrt_file.write_text(replaced)
114
151
 
115
152
 
116
- def create_vrt_file(self, sheets):
153
+ def create_vrt_file(self, sheets, background_sheets=()):
117
154
  vrt_file = self.tiffs_dir.joinpath('combined.vrt')
118
- if vrt_file.exists():
119
- return vrt_file
120
- tiff_list = [ self.tiffs_dir.joinpath(f'{sheet}').resolve() for sheet in sheets ]
121
- tiff_list = [ str(f) for f in tiff_list if f.exists() ]
122
-
123
- tiff_list_str = ' '.join(tiff_list)
124
-
125
- run_external(f'gdalbuildvrt {vrt_file} {tiff_list_str}')
155
+ # Use the same source order and native pixel grid for every batch.
156
+ # Do not mix coarse dummy rasters into the mosaic's average resolution.
157
+ # A dummy provides an empty source only when all real sheets were removed.
158
+ ordered_sheets = sorted(sheets) or sorted(background_sheets)
159
+ tiff_list = [str(self.tiffs_dir.joinpath(sheet).resolve()) for sheet in ordered_sheets]
160
+ run_external(shlex.join(['gdalbuildvrt', str(vrt_file), *tiff_list]))
126
161
  self.convert_paths_in_vrt(vrt_file)
127
162
 
128
163
  return vrt_file
@@ -176,34 +211,108 @@ class Tiler:
176
211
 
177
212
  raise ValueError(f'Unsupported tile extension: {self.tile_extension}')
178
213
 
179
- def create_base_tiles(self, sheets, max_zoom):
214
+ def create_base_tiles(self, sheets, max_zoom, affected_tiles, background_sheets=()):
215
+ batches = base_tile_batches(affected_tiles, self.batch_size)
216
+ if not batches:
217
+ return
180
218
  print('creating vrt file from sheets involved')
181
- vrt_file = self.create_vrt_file(sheets)
182
-
183
- print('start creating base tiles')
184
- cmd = [
185
- 'gdal2tiles.py',
186
- '-r', 'lanczos',
187
- '--verbose',
188
- '-w', 'none',
189
- # N.B. if transperent tiles are excluded, the old data might show through
190
- #'--exclude',
191
- '--resume',
192
- '--xyz',
193
- '--processes', f'{self.pool._processes}',
194
- '-z', f'{max_zoom}'
195
- ] + self.get_tiler_cmd_params() + [
196
- str(vrt_file), str(self.disk_handler.dir)
197
- ]
198
-
199
- gdal2tiles_main(cmd, pool=self.pool, called_from_main=True)
200
- vrt_file.unlink()
219
+ vrt_file = self.create_vrt_file(sheets, background_sheets)
220
+ print(f'Creating {len(affected_tiles)} base tiles in {len(batches)} geographic batches', flush=True)
221
+
222
+ # The common VRT preserves overlap priority, the pixel grid, and all
223
+ # neighboring source pixels. Only the output job bounds change; cropping
224
+ # the input raster separately for each batch could introduce seams.
225
+ with (
226
+ tempfile.TemporaryDirectory(
227
+ prefix='.retile-batches-', dir=self.disk_handler.dir.parent,
228
+ ) as workdir,
229
+ gdal_exceptions(),
230
+ ):
231
+ input_file, output_folder, options = gdal2tiles_process_args([
232
+ '-r', 'lanczos', '--quiet', '-w', 'none', '--xyz',
233
+ '-z', str(max_zoom),
234
+ ] + self.get_tiler_cmd_params() + [str(vrt_file), workdir])
235
+ tiler = GDAL2Tiles(input_file, output_folder, options)
236
+ try:
237
+ tiler.open_input()
238
+ source_minx, source_miny, source_maxx, source_maxy = tiler.tminmax[max_zoom]
239
+ completed = 0
240
+ for index, batch in enumerate(batches, 1):
241
+ batch_dir = Path(workdir) / str(index)
242
+ tiler.output_folder = str(batch_dir)
243
+ # GDAL stores planning bounds in TMS coordinates, while
244
+ # TileDetail.ty and our ownership sets use XYZ coordinates.
245
+ minx, maxx = min(t.x for t in batch), max(t.x for t in batch)
246
+ xyz_miny, xyz_maxy = min(t.y for t in batch), max(t.y for t in batch)
247
+ miny = (1 << max_zoom) - 1 - xyz_maxy
248
+ maxy = (1 << max_zoom) - 1 - xyz_miny
249
+ # Cleared areas can lie beyond the remaining source mosaic.
250
+ # Plan reads only inside it, then explicitly write transparent
251
+ # replacement tiles outside it instead of retaining old tiles.
252
+ tiler.tminmax[max_zoom] = (
253
+ max(minx, source_minx), max(miny, source_miny),
254
+ min(maxx, source_maxx), min(maxy, source_maxy),
255
+ )
256
+ conf, details = tiler.generate_base_tiles()
257
+ details = [d for d in details if mercantile.Tile(d.tx, d.ty, d.tz) in batch]
258
+ covered = {mercantile.Tile(d.tx, d.ty, d.tz) for d in details}
259
+ expected_covered = {
260
+ t for t in batch
261
+ if source_minx <= t.x <= source_maxx and
262
+ source_miny <= (1 << max_zoom) - 1 - t.y <= source_maxy
263
+ }
264
+ if covered != expected_covered:
265
+ raise RuntimeError('GDAL did not plan every affected tile inside the source mosaic')
266
+ for tile in sorted(batch - covered):
267
+ batch_dir.joinpath(str(tile.z), str(tile.x)).mkdir(parents=True, exist_ok=True)
268
+ details.append(TileDetail(
269
+ tx=tile.x, ty=tile.y, tz=tile.z,
270
+ rx=0, ry=0, rxsize=0, rysize=0,
271
+ wx=0, wy=0, wxsize=0, wysize=0,
272
+ querysize=tiler.querysize,
273
+ ))
274
+ if len(details) != len(batch):
275
+ raise RuntimeError('GDAL did not plan every tile assigned to the batch')
276
+
277
+ print(
278
+ f'Base batch {index}/{len(batches)}: {len(details)} tiles, '
279
+ f'XYZ x={minx}..{maxx}, y={xyz_miny}..{xyz_maxy}',
280
+ flush=True,
281
+ )
282
+ chunksize = max(1, min(128, len(details) // self.pool._processes))
283
+ for _ in self.pool.imap_unordered(
284
+ partial(render_base_tile, conf), details, chunksize=chunksize,
285
+ ):
286
+ pass
287
+
288
+ # A batch never reads partially rendered output from another
289
+ # batch. Publish only its owned tiles after every job succeeds.
290
+ # Keep transparent tiles: they replace old content in PMTiles.
291
+ output = DiskTilesHandler(batch_dir, self.tile_extension)
292
+ for tile in batch:
293
+ path = output.get_tile_file(tile)
294
+ if not path.is_file() or path.stat().st_size == 0:
295
+ raise RuntimeError(f'Base batch did not produce tile {tile}')
296
+ for tile in batch:
297
+ self.disk_handler.preprare_dir(tile)
298
+ output.get_tile_file(tile).replace(self.disk_handler.get_tile_file(tile))
299
+ shutil.rmtree(batch_dir)
300
+ completed += len(batch)
301
+ print(
302
+ f'Completed base batch {index}/{len(batches)} '
303
+ f'({completed}/{len(affected_tiles)} tiles)',
304
+ flush=True,
305
+ )
306
+ finally:
307
+ tiler.warped_input_dataset = None
308
+ shutil.rmtree(tiler.tmp_dir, ignore_errors=True)
309
+ vrt_file.unlink(missing_ok=True)
201
310
 
202
311
  def create_upper_tiles(self, z, tiles_to_create):
203
312
  tile_driver = self.get_tile_driver()
204
313
 
205
314
  options_dict = {
206
- 'resume': True,
315
+ 'resume': False,
207
316
  'verbose': False,
208
317
  'quiet': False,
209
318
  'xyz': True,
@@ -268,14 +377,14 @@ class Tiler:
268
377
  tile_file.write_bytes(t_data)
269
378
 
270
379
 
271
- def retile(self, sheet_list, affected_base_tiles):
380
+ def retile(self, sheet_list, affected_base_tiles, background_sheets=()):
272
381
 
273
382
  max_zoom = self.orig_tile_source.max_zoom
274
383
  min_zoom = self.orig_tile_source.min_zoom
275
384
 
276
385
  self.disk_handler.init()
277
386
 
278
- self.create_base_tiles(sheet_list, max_zoom)
387
+ self.create_base_tiles(sheet_list, max_zoom, affected_base_tiles, background_sheets)
279
388
 
280
389
  print('deleting unwanted base tiles')
281
390
  self.disk_handler.delete_unwanted_tiles(affected_base_tiles, max_zoom)
@@ -398,8 +507,10 @@ def create_dummy_sheets(dummy_bounds_file, tiffs_dir):
398
507
  print('creating dummy sheets for force redo areas')
399
508
  dummy_sheets_to_box = get_sheet_data(dummy_bounds_file, dummy=True)
400
509
 
510
+ sheets = set()
401
511
  for sheet_no, box in dummy_sheets_to_box.items():
402
512
  file = tiffs_dir.joinpath(f'{sheet_no}.tif')
513
+ sheets.add(file.name)
403
514
  if file.exists():
404
515
  continue
405
516
 
@@ -413,6 +524,8 @@ def create_dummy_sheets(dummy_bounds_file, tiffs_dir):
413
524
  cmd = f'gdal_translate -co TILING_SCHEME=GoogleMapsCompatible -co COMPRESS=JPEG -co QUALITY=100 --config GDAL_TIFF_INTERNAL_MASK YES -b 1 -b 2 -b 3 -mask 4 --config GDAL_CACHEMAX 512 -of COG {temp_file_1} {file}'
414
525
  run_external(cmd)
415
526
 
527
+ return sheets
528
+
416
529
  def retile_main(args):
417
530
 
418
531
  parser = argparse.ArgumentParser()
@@ -427,8 +540,11 @@ def retile_main(args):
427
540
  parser.add_argument('--tiffs-dir', help='Directory where the tiffs are present')
428
541
  parser.add_argument('--num-parallel', type=int, default=cpu_count(), help='Number of parallel processes to use for tiling (default: number of CPU cores)')
429
542
  parser.add_argument('--tile-quality', default=75, help='quality of compression for webp and jpeg (default: 75)')
543
+ parser.add_argument('--batch-size', type=int, default=32, help='Maximum width and height of a base-tile batch in tiles (default: 32)')
430
544
 
431
545
  args = parser.parse_args(args)
546
+ if args.batch_size < 1:
547
+ parser.error('--batch-size must be positive')
432
548
 
433
549
  if not args.retile_list_file and not args.force_redo_bounds_file:
434
550
  parser.error('At least one of --retile-list-file or --force-redo-bounds-file must be provided.')
@@ -490,8 +606,11 @@ def retile_main(args):
490
606
  print('check the sheets availability')
491
607
  check_sheets(sheets_to_pull, tiffs_dir)
492
608
 
493
- if force_redo_bounds_file is not None:
494
- create_dummy_sheets(force_redo_bounds_file, tiffs_dir)
609
+ background_sheets = set()
610
+ # Ordinary batches read the real mosaic. Empty replacement jobs handle
611
+ # removed areas outside it; a dummy source is needed only if none remain.
612
+ if force_redo_bounds_file is not None and not sheets_to_pull:
613
+ background_sheets = create_dummy_sheets(force_redo_bounds_file, tiffs_dir)
495
614
 
496
615
  metadata = orig_source.get_metadata()
497
616
  tile_extension = metadata['format']
@@ -512,8 +631,9 @@ def retile_main(args):
512
631
  orig_source,
513
632
  tile_extension,
514
633
  args.tile_quality,
515
- pool)
516
- tiler.retile(sheets_to_pull, affected_base_tiles)
634
+ pool,
635
+ batch_size=args.batch_size)
636
+ tiler.retile(sheets_to_pull, affected_base_tiles, background_sheets)
517
637
 
518
638
  return 0
519
639
 
@@ -54,7 +54,10 @@ def cli():
54
54
  parser.add_argument("-x", "--pmtiles-prefix", required=True, help="Prefix for PMTiles files")
55
55
  parser.add_argument("--bounds-file-tiled", required=True, help="Name of the bounds file in the pmtiles release (the old bounds file)")
56
56
  parser.add_argument("--no-cache", action='store_true', required=False, help="Don't cache files locally during pmtiles partitioning")
57
+ parser.add_argument("--batch-size", type=int, default=32, help="Maximum width and height of a base-tile batch in tiles (default: 32)")
57
58
  args = parser.parse_args()
59
+ if args.batch_size < 1:
60
+ parser.error('--batch-size must be positive')
58
61
 
59
62
  repo_arg = ["--repo", args.repo] if args.repo else []
60
63
 
@@ -136,6 +139,7 @@ def cli():
136
139
  "--from-source", f"{from_pmtiles_prefix}*.pmtiles",
137
140
  "--tiles-dir", str(tiles_dir),
138
141
  "--tiffs-dir", str(tiffs_dir),
142
+ "--batch-size", str(args.batch_size),
139
143
  ])
140
144
 
141
145
  old_bounds_file.unlink()