topo_map_processor 0.1.16__tar.gz → 0.1.18__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: topo_map_processor
3
- Version: 0.1.16
3
+ Version: 0.1.18
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
@@ -249,28 +249,29 @@ This project provides several command-line tools to work with topographic maps.
249
249
  Collects individual GeoJSONL bound files into a single GeoJSON FeatureCollection.
250
250
 
251
251
  ```bash
252
- collect-bounds --bounds-dir <directory> --output-file <file> [--preexisting-file <file>]
252
+ collect-bounds -b <directory> -o <file> [-u] [-d <id1> <id2> ...]
253
253
  ```
254
254
 
255
- - `--bounds-dir`: Directory containing GeoJSONL files. (Required)
256
- - `--output-file`: Output GeoJSON file path. (Required)
257
- - `--preexisting-file`: Pre existing GeoJSON file path. (Optional)
255
+ - `-b`, `--bounds-dir`: Directory containing GeoJSONL files. (Required)
256
+ - `-o`, `--output-file`: Output GeoJSON file path. (Required)
257
+ - `-u`, `--update`: Update the output file in place. (Optional)
258
+ - `-d`, `--delete`: List of ids to delete from the output file (only available in update mode). (Optional)
258
259
 
259
260
  #### `retile`
260
261
 
261
- Retiles a specific list of sheets (GeoTIFFs) and updates the corresponding tiles in an existing tile set. It can determine which other sheets are affected and need to be pulled in to correctly retile the area.
262
+ Retiles a specific list of sheets (GeoTIFFs) or a specific area and updates the corresponding tiles in an existing tile set. It can determine which other sheets are affected and need to be pulled in to correctly retile the area.
262
263
 
263
264
  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.
264
265
 
265
266
  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.
266
267
 
267
268
  ```bash
268
- retile --retile-list-file <file> --bounds-file <file> [--dummy-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>]
269
+ 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>]
269
270
  ```
270
271
 
271
- - `--retile-list-file`: File containing the list of sheets to retile. (Required)
272
+ - `--retile-list-file`: File containing the list of sheets to retile. (Optional)
272
273
  - `--bounds-file`: GeoJSON file containing the list of available sheets and their geographic bounds. (Required)
273
- - `--dummy-bounds-file`: GeoJSON file containing geographic bounds of areas that need to be updated. These areas might not have any new sheets. (Optional)
274
+ - `--force-redo-bounds-file`: GeoJSON file containing geographic bounds of areas that need to be updated. These areas might not have any new sheets. (Optional)
274
275
  - `--max-zoom`: Maximum zoom level to create tiles for. Defaults to the max zoom from the source if `--from-source` is provided, otherwise it is required.
275
276
  - `--tiffs-dir`: Directory where the GeoTIFFs are located. (Required if not in pull-list generation mode)
276
277
  - `--tiles-dir`: Directory where the tiles will be created. (Required if not in pull-list generation mode)
@@ -279,6 +280,18 @@ retile --retile-list-file <file> --bounds-file <file> [--dummy-bounds-file <file
279
280
  - `--num-parallel`: Number of parallel processes to use for tiling (default: number of CPU cores).
280
281
  - `--tile-quality`: Quality of compression for webp and jpeg (default: 75).
281
282
 
283
+ #### `create-force-redo-bounds`
284
+
285
+ Creates a GeoJSON file that defines areas to be forcibly re-tiled, based on the differences between an old and a new bounds file. This is useful for identifying regions where map sheets may have been updated, moved, or removed.
286
+
287
+ ```bash
288
+ create-force-redo-bounds --old-bounds-file <file> --new-bounds-file <file> --output-file <file>
289
+ ```
290
+
291
+ - `--old-bounds-file`: The previous version of the bounds GeoJSON file. (Required)
292
+ - `--new-bounds-file`: The new version of the bounds GeoJSON file. (Required)
293
+ - `--output-file`: The path to write the output GeoJSON file, which will contain the bounds of the areas to be force-retiled. (Required)
294
+
282
295
  #### `tile`
283
296
 
284
297
  Creates web map tiles from a directory of GeoTIFF files. It combines them into a virtual raster (`.vrt`) for efficient processing.
@@ -316,6 +329,49 @@ GDAL_VERSION=$(gdalinfo --version | cut -d"," -f1 | cut -d" " -f2)
316
329
  uvx --with numpy --with pillow --with gdal==$GDAL_VERSION --from topo_map_processor tile --help
317
330
  ```
318
331
 
332
+ ### Map Update Workflow
333
+
334
+ This project provides a robust workflow for updating an existing tile set when map sheets are added, removed, or modified. The primary tools for this are `retile` and `create-force-redo-bounds`.
335
+
336
+ The general workflow is as follows:
337
+
338
+ 1. **Generate New Bounds**: After processing new or updated map scans with the library, you will have a new set of COGs and `.geojsonl` bound files. Use the `collect-bounds` tool to create an updated `bounds.geojson` file that reflects the current state of your map collection.
339
+
340
+ 2. **Identify Changes**: Use the `create-force-redo-bounds` tool to compare your old `bounds.geojson` with the new one.
341
+
342
+ ```bash
343
+ create-force-redo-bounds --old-bounds-file old-bounds.geojson --new-bounds-file new-bounds.geojson --output-file force-redo.geojson
344
+ ```
345
+
346
+ This command analyzes the differences:
347
+ * **Added Sheets**: If a map sheet exists in `new-bounds.geojson` but not in `old-bounds.geojson`.
348
+ * **Deleted Sheets**: If a sheet is in `old-bounds.geojson` but has been removed from `new-bounds.geojson`.
349
+ * **Modified Sheets**: If a sheet's geometry (boundary) or its content (indicated by a change in the `digest` property) has been updated.
350
+
351
+ The output, `force-redo.geojson`, will contain the geographic areas for all these changes, which tells the `retile` tool exactly which regions of the map need to be regenerated.
352
+
353
+ 3. **Retile Affected Areas**: Use the `retile` tool to perform the update. The `force-redo.geojson` file you created contains all the information about what has changed, so a separate list of sheets is not required.
354
+
355
+ ```bash
356
+ retile --bounds-file new-bounds.geojson \
357
+ --force-redo-bounds-file force-redo.geojson \
358
+ --from-source "existing-tiles/*.pmtiles" \
359
+ --tiffs-dir /path/to/cogs \
360
+ --tiles-dir /path/to/output/tiles
361
+ ```
362
+
363
+ The `retile` tool will:
364
+ * Identify all GeoTIFFs needed to regenerate the affected tiles (including adjacent, unchanged sheets).
365
+ * Generate new base tiles for the changed areas.
366
+ * Copy unchanged tiles from your existing tile set (`--from-source`).
367
+ * Re-create parent tiles up the zoom levels.
368
+
369
+ This workflow ensures that your tile set is updated efficiently, without having to re-process the entire map collection.
370
+
371
+ ## Alternate Tools
372
+
373
+ For a more saner/cleaner version of doing the georeferencing/tiling/retiling without using GDAL check out [wladich/map-tiler](https://github.com/wladich/map-tiler)
374
+
319
375
  ## Contributing
320
376
 
321
377
  Contributions are welcome! Please feel free to submit a pull request.
@@ -174,28 +174,29 @@ This project provides several command-line tools to work with topographic maps.
174
174
  Collects individual GeoJSONL bound files into a single GeoJSON FeatureCollection.
175
175
 
176
176
  ```bash
177
- collect-bounds --bounds-dir <directory> --output-file <file> [--preexisting-file <file>]
177
+ collect-bounds -b <directory> -o <file> [-u] [-d <id1> <id2> ...]
178
178
  ```
179
179
 
180
- - `--bounds-dir`: Directory containing GeoJSONL files. (Required)
181
- - `--output-file`: Output GeoJSON file path. (Required)
182
- - `--preexisting-file`: Pre existing GeoJSON file path. (Optional)
180
+ - `-b`, `--bounds-dir`: Directory containing GeoJSONL files. (Required)
181
+ - `-o`, `--output-file`: Output GeoJSON file path. (Required)
182
+ - `-u`, `--update`: Update the output file in place. (Optional)
183
+ - `-d`, `--delete`: List of ids to delete from the output file (only available in update mode). (Optional)
183
184
 
184
185
  #### `retile`
185
186
 
186
- Retiles a specific list of sheets (GeoTIFFs) and updates the corresponding tiles in an existing tile set. It can determine which other sheets are affected and need to be pulled in to correctly retile the area.
187
+ Retiles a specific list of sheets (GeoTIFFs) or a specific area and updates the corresponding tiles in an existing tile set. It can determine which other sheets are affected and need to be pulled in to correctly retile the area.
187
188
 
188
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.
189
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
192
 
192
193
  ```bash
193
- retile --retile-list-file <file> --bounds-file <file> [--dummy-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>]
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>]
194
195
  ```
195
196
 
196
- - `--retile-list-file`: File containing the list of sheets to retile. (Required)
197
+ - `--retile-list-file`: File containing the list of sheets to retile. (Optional)
197
198
  - `--bounds-file`: GeoJSON file containing the list of available sheets and their geographic bounds. (Required)
198
- - `--dummy-bounds-file`: GeoJSON file containing geographic bounds of areas that need to be updated. These areas might not have any new sheets. (Optional)
199
+ - `--force-redo-bounds-file`: GeoJSON file containing geographic bounds of areas that need to be updated. These areas might not have any new sheets. (Optional)
199
200
  - `--max-zoom`: Maximum zoom level to create tiles for. Defaults to the max zoom from the source if `--from-source` is provided, otherwise it is required.
200
201
  - `--tiffs-dir`: Directory where the GeoTIFFs are located. (Required if not in pull-list generation mode)
201
202
  - `--tiles-dir`: Directory where the tiles will be created. (Required if not in pull-list generation mode)
@@ -204,6 +205,18 @@ retile --retile-list-file <file> --bounds-file <file> [--dummy-bounds-file <file
204
205
  - `--num-parallel`: Number of parallel processes to use for tiling (default: number of CPU cores).
205
206
  - `--tile-quality`: Quality of compression for webp and jpeg (default: 75).
206
207
 
208
+ #### `create-force-redo-bounds`
209
+
210
+ Creates a GeoJSON file that defines areas to be forcibly re-tiled, based on the differences between an old and a new bounds file. This is useful for identifying regions where map sheets may have been updated, moved, or removed.
211
+
212
+ ```bash
213
+ create-force-redo-bounds --old-bounds-file <file> --new-bounds-file <file> --output-file <file>
214
+ ```
215
+
216
+ - `--old-bounds-file`: The previous version of the bounds GeoJSON file. (Required)
217
+ - `--new-bounds-file`: The new version of the bounds GeoJSON file. (Required)
218
+ - `--output-file`: The path to write the output GeoJSON file, which will contain the bounds of the areas to be force-retiled. (Required)
219
+
207
220
  #### `tile`
208
221
 
209
222
  Creates web map tiles from a directory of GeoTIFF files. It combines them into a virtual raster (`.vrt`) for efficient processing.
@@ -241,6 +254,49 @@ GDAL_VERSION=$(gdalinfo --version | cut -d"," -f1 | cut -d" " -f2)
241
254
  uvx --with numpy --with pillow --with gdal==$GDAL_VERSION --from topo_map_processor tile --help
242
255
  ```
243
256
 
257
+ ### Map Update Workflow
258
+
259
+ This project provides a robust workflow for updating an existing tile set when map sheets are added, removed, or modified. The primary tools for this are `retile` and `create-force-redo-bounds`.
260
+
261
+ The general workflow is as follows:
262
+
263
+ 1. **Generate New Bounds**: After processing new or updated map scans with the library, you will have a new set of COGs and `.geojsonl` bound files. Use the `collect-bounds` tool to create an updated `bounds.geojson` file that reflects the current state of your map collection.
264
+
265
+ 2. **Identify Changes**: Use the `create-force-redo-bounds` tool to compare your old `bounds.geojson` with the new one.
266
+
267
+ ```bash
268
+ create-force-redo-bounds --old-bounds-file old-bounds.geojson --new-bounds-file new-bounds.geojson --output-file force-redo.geojson
269
+ ```
270
+
271
+ This command analyzes the differences:
272
+ * **Added Sheets**: If a map sheet exists in `new-bounds.geojson` but not in `old-bounds.geojson`.
273
+ * **Deleted Sheets**: If a sheet is in `old-bounds.geojson` but has been removed from `new-bounds.geojson`.
274
+ * **Modified Sheets**: If a sheet's geometry (boundary) or its content (indicated by a change in the `digest` property) has been updated.
275
+
276
+ The output, `force-redo.geojson`, will contain the geographic areas for all these changes, which tells the `retile` tool exactly which regions of the map need to be regenerated.
277
+
278
+ 3. **Retile Affected Areas**: Use the `retile` tool to perform the update. The `force-redo.geojson` file you created contains all the information about what has changed, so a separate list of sheets is not required.
279
+
280
+ ```bash
281
+ retile --bounds-file new-bounds.geojson \
282
+ --force-redo-bounds-file force-redo.geojson \
283
+ --from-source "existing-tiles/*.pmtiles" \
284
+ --tiffs-dir /path/to/cogs \
285
+ --tiles-dir /path/to/output/tiles
286
+ ```
287
+
288
+ The `retile` tool will:
289
+ * Identify all GeoTIFFs needed to regenerate the affected tiles (including adjacent, unchanged sheets).
290
+ * Generate new base tiles for the changed areas.
291
+ * Copy unchanged tiles from your existing tile set (`--from-source`).
292
+ * Re-create parent tiles up the zoom levels.
293
+
294
+ This workflow ensures that your tile set is updated efficiently, without having to re-process the entire map collection.
295
+
296
+ ## Alternate Tools
297
+
298
+ For a more saner/cleaner version of doing the georeferencing/tiling/retiling without using GDAL check out [wladich/map-tiler](https://github.com/wladich/map-tiler)
299
+
244
300
  ## Contributing
245
301
 
246
302
  Contributions are welcome! Please feel free to submit a pull request.
@@ -68,6 +68,7 @@ collect-bounds = "topo_map_processor.tools.collect_bounds:cli"
68
68
  retile = "topo_map_processor.tools.retile:cli"
69
69
  tile = "topo_map_processor.tools.tile:cli"
70
70
  retile-e2e = "topo_map_processor.tools.retile_e2e:cli"
71
+ create-force-redo-bounds = "topo_map_processor.tools.create_force_redo_bounds:cli"
71
72
 
72
73
  [build-system]
73
74
  requires = [
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.1.16'
32
- __version_tuple__ = version_tuple = (0, 1, 16)
31
+ __version__ = version = '0.1.18'
32
+ __version_tuple__ = version_tuple = (0, 1, 18)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -4,6 +4,7 @@ import time
4
4
  import json
5
5
  import pickle
6
6
  import shutil
7
+ import hashlib
7
8
  import subprocess
8
9
 
9
10
  from pathlib import Path
@@ -81,6 +82,7 @@ class TopoMapProcessor:
81
82
  self.small_img = None
82
83
  self.mapbox_corners = None
83
84
  self.rotation_reversal_params = None
85
+ self.digest = None
84
86
 
85
87
  self.extents = extra.get('extents', None)
86
88
  self.pixel_cutlines = extra.get('pixel_cutlines', [])
@@ -106,6 +108,7 @@ class TopoMapProcessor:
106
108
  self.use_bbox_area = extra.get('use_bbox_area', True)
107
109
  self.shrunk_map_area_corners = extra.get('shrunk_map_area_corners', None)
108
110
  self.poly_approx_factor = extra.get('poly_approx_factor', 0.001)
111
+ self.map_area_ratio_thresh = extra.get('map_area_ratio_thresh', 0.5)
109
112
 
110
113
  # corners related
111
114
  self.corner_ratio = extra.get('corner_ratio', 400.0 / 9000.0)
@@ -132,6 +135,7 @@ class TopoMapProcessor:
132
135
  self.warp_jpeg_export_quality = extra.get('warp_jpeg_export_quality', 75)
133
136
  self.warp_output_height = extra.get('warp_output_height', 6500)
134
137
 
138
+
135
139
  self.color_map = {
136
140
  'black': ((0, 0, 0), (179, 255, 130)),
137
141
  'greyish': ((0, 0, 50), (179, 150, 192)),
@@ -484,6 +488,10 @@ class TopoMapProcessor:
484
488
  ls = LineString(line)
485
489
  line_poly = ls.buffer(line_buf, resolution=1, cap_style=CAP_STYLE.flat).intersection(limits)
486
490
  blur_poly = ls.buffer(blur_buf, resolution=1, cap_style=CAP_STYLE.flat).intersection(limits)
491
+ print(f'removing line {line} with line_buf {line_buf}, blur_buf {blur_buf}, blur_kern {blur_kern}, blur_repeat {blur_repeat}')
492
+ if blur_poly.is_empty:
493
+ print('blur poly empty, nothing to do')
494
+ return
487
495
  bb = blur_poly.bounds
488
496
  bb = [ round(x) for x in bb ]
489
497
  # restrict to a small img strip to make things less costly
@@ -810,7 +818,7 @@ class TopoMapProcessor:
810
818
 
811
819
  h, w = img.shape[:2]
812
820
  total_area = w * h
813
- if total_area / map_area > 2:
821
+ if map_area / total_area < self.map_area_ratio_thresh:
814
822
  raise Exception(f'map area less than expected, {map_area=}, {total_area=}')
815
823
 
816
824
  self.show_contours(img_mask, [map_contour])
@@ -1088,7 +1096,8 @@ class TopoMapProcessor:
1088
1096
  if remove_text:
1089
1097
  self.remove_text(img_mask)
1090
1098
 
1091
- self.remove_corner_edges(img_mask, direction, remove_corner_edges_ratio)
1099
+ if remove_corner_edges_ratio is not None:
1100
+ self.remove_corner_edges(img_mask, direction, remove_corner_edges_ratio)
1092
1101
 
1093
1102
  h, w = img_mask.shape[:2]
1094
1103
  self.show_image(img_mask)
@@ -1166,7 +1175,8 @@ class TopoMapProcessor:
1166
1175
 
1167
1176
  anchor_corner = (ax, ay)
1168
1177
 
1169
- self.remove_corner_edges(img_mask, direction, remove_corner_edges_ratio)
1178
+ if remove_corner_edges_ratio is not None:
1179
+ self.remove_corner_edges(img_mask, direction, remove_corner_edges_ratio)
1170
1180
 
1171
1181
  frame_contour = self.get_biggest_contour(img_mask, corner_erode, True, max_corner_contour_area_ratio)
1172
1182
 
@@ -1230,7 +1240,8 @@ class TopoMapProcessor:
1230
1240
 
1231
1241
  img_c_mask = self.get_color_mask(img, corner_contour_color)
1232
1242
  self.remove_text(img_c_mask)
1233
- self.remove_corner_edges(img_c_mask, direction, self.remove_corner_edges_ratio)
1243
+ if remove_corner_edges_ratio is not None:
1244
+ self.remove_corner_edges(img_c_mask, direction, remove_corner_edges_ratio)
1234
1245
  frame_contour = self.get_biggest_contour(img_c_mask, self.corner_erode, True, max_corner_contour_area_ratio)
1235
1246
 
1236
1247
  frame_contour_area = self.get_bbox_area_simple(frame_contour)
@@ -1250,6 +1261,7 @@ class TopoMapProcessor:
1250
1261
  fx + fw * ( 1 if direction[0] < 0 else 0),
1251
1262
  fy + fh * ( 1 if direction[1] < 0 else 0),
1252
1263
  ]
1264
+
1253
1265
  frame_diag_len = self.get_distance((0,0), (fh,fw))
1254
1266
  self.show_points([frame_anchor_point], img_c_mask, [0,0,255])
1255
1267
 
@@ -1294,10 +1306,24 @@ class TopoMapProcessor:
1294
1306
  map_poly_points = self.reorder_poly_points(map_poly_points)
1295
1307
  print(f'{map_poly_points=}')
1296
1308
 
1309
+ all_corner_overrides_given = all([ c is not None for c in corner_overrides ])
1310
+ if all_corner_overrides_given:
1311
+ return corner_overrides
1312
+
1313
+ some_corner_overrides_given = any([ c is not None for c in corner_overrides ])
1314
+ if some_corner_overrides_given and len(corner_overrides) != len(map_poly_points):
1315
+ raise ValueError("If some corner overrides are given, either all must be given or the number of overrides must match the number of polygon points.")
1316
+
1297
1317
  #num_corners = len(map_poly_points)
1298
1318
  box = LinearRing(map_poly_points + [map_poly_points[0]])
1299
1319
  buffered_poly = box.buffer(-cw/2, single_sided=True, join_style=JOIN_STYLE.mitre)
1300
- inner_ring = buffered_poly.interiors[0]
1320
+
1321
+ # TODO: this is a hack.. understand why this happens and fix it properly
1322
+ if len(buffered_poly.interiors) == 0:
1323
+ inner_ring = buffered_poly.exterior
1324
+ else:
1325
+ inner_ring = buffered_poly.interiors[0]
1326
+
1301
1327
  corner_centers = list(inner_ring.coords)
1302
1328
  corner_centers.reverse()
1303
1329
  print(f'{corner_centers=}')
@@ -1390,7 +1416,7 @@ class TopoMapProcessor:
1390
1416
  corner_overrides = [ (c[0] - map_bbox_scaled[0], c[1] - map_bbox_scaled[1]) if c is not None else None for c in corner_overrides_full ]
1391
1417
  map_poly_points_scaled = [ (p[0] - map_bbox_scaled[0], p[1] - map_bbox_scaled[1]) for p in map_poly_points_scaled ]
1392
1418
 
1393
- if self.extents is None:
1419
+ if len(corner_overrides) == 4:
1394
1420
  corners = self.locate_corners(map_img, corner_overrides)
1395
1421
  else:
1396
1422
  corners = self.locate_corners_generic(map_img, map_poly_points_scaled, corner_overrides)
@@ -1610,6 +1636,21 @@ class TopoMapProcessor:
1610
1636
  translate_cmd = f'gdal_translate {creation_options} {perf_options} {gcp_str} -a_srs "{crs_proj}" -of GTiff {str(from_file)} {str(georef_file)}'
1611
1637
  self.run_external(translate_cmd)
1612
1638
 
1639
+ def get_digest(self):
1640
+ if self.digest is not None:
1641
+ return self.digest
1642
+
1643
+ hasher = hashlib.sha256()
1644
+ export_file = self.get_export_file()
1645
+ with open(export_file, 'rb') as f:
1646
+ while True:
1647
+ data = f.read(65536)
1648
+ if not data:
1649
+ break
1650
+ hasher.update(data)
1651
+ self.digest = hasher.hexdigest()
1652
+ return self.digest
1653
+
1613
1654
  def get_cutline_props(self):
1614
1655
  crs_proj = self.get_crs_proj()
1615
1656
 
@@ -1687,7 +1728,7 @@ class TopoMapProcessor:
1687
1728
  workdir = self.get_workdir()
1688
1729
  rotated_info_file = workdir.joinpath('rotated_info.txt')
1689
1730
  if not rotated_info_file.exists():
1690
- raise Exception(f'rotated_info.txt does not exist in {workdir}')
1731
+ return RotationReversalParams(False, 0.0, None, None)
1691
1732
 
1692
1733
  parts = rotated_info_file.read_text().strip().split(',')
1693
1734
  angle = float(parts[0])
@@ -1813,6 +1854,32 @@ class TopoMapProcessor:
1813
1854
 
1814
1855
  self.run_external(f'ogr2ogr -t_srs EPSG:4326 -s_srs "{crs_proj}" -f GeoJSONSeq {str(bounds_file)} {cutline_file}')
1815
1856
 
1857
+ def add_digest_to_bounds_file(self):
1858
+ bounds_dir = self.get_bounds_dir()
1859
+
1860
+ bounds_file = bounds_dir.joinpath(f'{self.get_id()}.geojsonl')
1861
+ if not bounds_file.exists():
1862
+ print(f'{bounds_file} does not exist.. cannot add digest')
1863
+ return
1864
+
1865
+ digest = self.get_digest()
1866
+
1867
+ with open(bounds_file, 'r') as f:
1868
+ lines = f.readlines()
1869
+
1870
+ if len(lines) != 1:
1871
+ print(f'expected 1 line in {bounds_file}, got {len(lines)}')
1872
+ return
1873
+
1874
+ feature = json.loads(lines[0])
1875
+ if 'properties' not in feature:
1876
+ feature['properties'] = {}
1877
+
1878
+ feature['properties']['digest'] = digest
1879
+
1880
+ with open(bounds_file, 'w') as f:
1881
+ f.write(json.dumps(feature) + '\n')
1882
+
1816
1883
  def warp(self):
1817
1884
  workdir = self.get_workdir()
1818
1885
 
@@ -1851,6 +1918,9 @@ class TopoMapProcessor:
1851
1918
 
1852
1919
  self.export_gtiff(str(final_file), str(export_file), self.jpeg_export_quality)
1853
1920
 
1921
+ self.add_digest_to_bounds_file()
1922
+
1923
+
1854
1924
  def remove_insets(self):
1855
1925
  if self.inset_pixel_cutlines is None or len(self.inset_pixel_cutlines) == 0:
1856
1926
  return
@@ -5,23 +5,32 @@ from pathlib import Path
5
5
 
6
6
  def cli():
7
7
  parser = argparse.ArgumentParser(description='Collect GeoJSONL files into a single GeoJSON file.')
8
- parser.add_argument('--bounds-dir', required=True, help='Directory containing GeoJSONL files')
9
- parser.add_argument('--output-file', required=True, help='Output GeoJSON file path')
10
- parser.add_argument('--preexisting-file', help='preexisting GeoJSON file to base things on', default=None)
8
+ parser.add_argument('-b', '--bounds-dir', required=True, help='Directory containing GeoJSONL files')
9
+ parser.add_argument('-o', '--output-file', required=True, help='Output GeoJSON file path')
10
+ parser.add_argument('-u', '--update', action='store_true', help='Update the output file in place')
11
+ parser.add_argument('-d', '--delete', nargs='+', help='List of ids to delete from the output file (only in update mode)')
11
12
 
12
13
  args = parser.parse_args()
13
14
 
15
+ if args.delete and not args.update:
16
+ raise parser.error("--delete can only be used with --update")
17
+
14
18
  feat_map = {}
15
- if args.preexisting_file:
16
- preexisting_file = Path(args.preexisting_file)
17
- if not preexisting_file.exists():
18
- raise FileNotFoundError(f"Preexisting file does not exist: {preexisting_file}")
19
+ if args.update:
20
+ output_file = Path(args.output_file)
21
+ if not output_file.exists():
22
+ raise FileNotFoundError(f"Output file does not exist for updating: {output_file}")
19
23
 
20
- existing_data = json.loads(preexisting_file.read_text())
24
+ existing_data = json.loads(output_file.read_text())
21
25
  for feature in existing_data.get('features', []):
22
26
  if 'properties' in feature and 'id' in feature['properties']:
23
27
  sheet_name = feature['properties']['id']
24
28
  feat_map[sheet_name] = feature
29
+
30
+ if args.delete:
31
+ for sheet_id in args.delete:
32
+ if sheet_id in feat_map:
33
+ del feat_map[sheet_id]
25
34
 
26
35
  bounds_dir = Path(args.bounds_dir)
27
36
 
@@ -0,0 +1,100 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ def get_sheet_map(bounds_file):
7
+ """Reads a bounds file and returns a map of sheet ID to feature."""
8
+ with open(bounds_file, 'r') as f:
9
+ data = json.load(f)
10
+
11
+ sheet_map = {}
12
+ for feature in data.get('features', []):
13
+ if 'properties' in feature and 'id' in feature['properties']:
14
+ sheet_id = feature['properties']['id']
15
+ sheet_map[sheet_id] = feature
16
+ return sheet_map
17
+
18
+ def main(argv):
19
+ parser = argparse.ArgumentParser(description='Create a force redo bounds file by comparing old and new bounds.')
20
+ parser.add_argument('--old-bounds-file', required=True, help='Path to the old GeoJSON bounds file.')
21
+ parser.add_argument('--new-bounds-file', required=True, help='Path to the new GeoJSON bounds file.')
22
+ parser.add_argument('--output-file', required=True, help='Path to write the output GeoJSON file with force redo bounds.')
23
+
24
+ args = parser.parse_args(argv)
25
+
26
+ old_sheets = get_sheet_map(args.old_bounds_file)
27
+ new_sheets = get_sheet_map(args.new_bounds_file)
28
+
29
+ old_ids = set(old_sheets.keys())
30
+ new_ids = set(new_sheets.keys())
31
+
32
+ added_ids = new_ids - old_ids
33
+ deleted_ids = old_ids - new_ids
34
+ common_ids = old_ids.intersection(new_ids)
35
+
36
+ output_features = []
37
+
38
+ # Added sheets
39
+ for sheet_id in added_ids:
40
+ feature = new_sheets[sheet_id]
41
+ output_features.append({
42
+ "type": "Feature",
43
+ "geometry": feature['geometry'],
44
+ "properties": { "reason": f"Sheet {sheet_id} added" }
45
+ })
46
+
47
+ # Deleted sheets
48
+ for sheet_id in deleted_ids:
49
+ feature = old_sheets[sheet_id]
50
+ output_features.append({
51
+ "type": "Feature",
52
+ "geometry": feature['geometry'],
53
+ "properties": { "reason": f"Sheet {sheet_id} deleted" }
54
+ })
55
+
56
+ # Common sheets (check for modifications)
57
+ for sheet_id in common_ids:
58
+ old_feature = old_sheets[sheet_id]
59
+ new_feature = new_sheets[sheet_id]
60
+ old_geom = old_feature.get('geometry')
61
+ new_geom = new_feature.get('geometry')
62
+ geom_changed = json.dumps(old_geom, sort_keys=True) != json.dumps(new_geom, sort_keys=True)
63
+ old_digest = old_feature.get('properties', {}).get('digest')
64
+ new_digest = new_feature.get('properties', {}).get('digest')
65
+ digest_changed = old_digest != new_digest
66
+ if geom_changed:
67
+ output_features.append({
68
+ "type": "Feature",
69
+ "geometry": old_geom,
70
+ "properties": { "reason": f"Sheet {sheet_id} geometry changed (old)" }
71
+ })
72
+ output_features.append({
73
+ "type": "Feature",
74
+ "geometry": new_geom,
75
+ "properties": { "reason": f"Sheet {sheet_id} geometry changed (new)" }
76
+ })
77
+ elif digest_changed:
78
+ output_features.append({
79
+ "type": "Feature",
80
+ "geometry": new_geom,
81
+ "properties": { "reason": f"Sheet {sheet_id} digest changed" }
82
+ })
83
+
84
+ with open(args.output_file, 'w') as f:
85
+ f.write('{ "type": "FeatureCollection", "features": [\n')
86
+ for i, feature in enumerate(output_features):
87
+ line = json.dumps(feature)
88
+ if i < len(output_features) - 1:
89
+ line += ','
90
+ line += '\n'
91
+ f.write(line)
92
+ f.write(']}\n')
93
+
94
+ print(f"Created force redo bounds file at {args.output_file} with {len(output_features)} features.")
95
+
96
+ def cli():
97
+ main(sys.argv[1:])
98
+
99
+ if __name__ == "__main__":
100
+ cli()
@@ -364,8 +364,10 @@ def get_base_tile_sheet_mappings(sheets_to_box, base_zoom):
364
364
 
365
365
  def assess_sheet_requirements(retile_list_file, bounds_file, force_redo_bounds_file, max_zoom):
366
366
 
367
- retile_sheets = retile_list_file.read_text().split('\n')
368
- retile_sheets = set([ r.strip().replace('.tif', '') for r in retile_sheets if r.strip() != '' ])
367
+ retile_sheets = set()
368
+ if retile_list_file:
369
+ retile_sheets = retile_list_file.read_text().split('\n')
370
+ retile_sheets = set([ r.strip().replace('.tif', '') for r in retile_sheets if r.strip() != '' ])
369
371
 
370
372
  print('getting base tiles to sheet mapping')
371
373
  sheets_to_box = get_sheet_data(bounds_file)
@@ -414,7 +416,7 @@ def create_dummy_sheets(dummy_bounds_file, tiffs_dir):
414
416
  def retile_main(args):
415
417
 
416
418
  parser = argparse.ArgumentParser()
417
- parser.add_argument('--retile-list-file', required=True, help='File containing list of sheets to retile')
419
+ parser.add_argument('--retile-list-file', required=False, default=None, help='File containing list of sheets to retile')
418
420
  parser.add_argument('--bounds-file', required=True, help='Geojson file containing list of available sheets and their georaphic bounds')
419
421
  parser.add_argument('--force-redo-bounds-file', required=False, help='Geojson file containing georaphic bounds of areas that need to be updated, these areas might not have any new sheets')
420
422
  parser.add_argument('--max-zoom', type=int, help='Maximum zoom level to create tiles for, needed only when --from-source is not provided')
@@ -428,8 +430,11 @@ def retile_main(args):
428
430
 
429
431
  args = parser.parse_args(args)
430
432
 
431
- retile_list_file = Path(args.retile_list_file)
432
- if not retile_list_file.exists():
433
+ if not args.retile_list_file and not args.force_redo_bounds_file:
434
+ parser.error('At least one of --retile-list-file or --force-redo-bounds-file must be provided.')
435
+
436
+ retile_list_file = Path(args.retile_list_file) if args.retile_list_file else None
437
+ if retile_list_file and not retile_list_file.exists():
433
438
  parser.error(f'Retile list file {args.retile_list_file} does not exist')
434
439
 
435
440
  bounds_file = Path(args.bounds_file)
@@ -1,7 +1,6 @@
1
-
2
1
  import argparse
3
- import csv
4
2
  import glob
3
+ import json
5
4
  import shutil
6
5
  import subprocess
7
6
  import sys
@@ -11,6 +10,7 @@ from multiprocessing import set_start_method
11
10
  from pmtiles_mosaic.partition import partition_main
12
11
 
13
12
  from .retile import retile_main
13
+ from .create_force_redo_bounds import main as create_force_redo_bounds_main
14
14
 
15
15
 
16
16
  def run_command(cmd, check=True, cwd=None):
@@ -50,7 +50,7 @@ def cli():
50
50
  parser.add_argument("-p", "--pmtiles-release", required=True, help="PMTiles release tag")
51
51
  parser.add_argument("-g", "--gtiffs-release", required=True, help="GeoTIFFs release tag")
52
52
  parser.add_argument("-x", "--pmtiles-prefix", required=True, help="Prefix for PMTiles files")
53
- parser.add_argument("-l", "--listing-files-tiled", required=True, help="Name of the tiled listing file")
53
+ parser.add_argument("--bounds-file-tiled", required=True, help="Name of the bounds file in the pmtiles release (the old bounds file)")
54
54
  args = parser.parse_args()
55
55
 
56
56
  # Define paths and filenames
@@ -59,7 +59,10 @@ def cli():
59
59
  tiffs_dir = Path("staging/gtiffs/")
60
60
  from_pmtiles_dir = Path("staging/pmtiles")
61
61
  to_pmtiles_dir = Path("export/pmtiles")
62
- retile_list_file = Path("to_retile.txt")
62
+
63
+ old_bounds_file = Path("old_bounds.geojson")
64
+ new_bounds_file = Path("new_bounds.geojson")
65
+ force_redo_bounds_file = Path("force_redo_bounds.geojson")
63
66
 
64
67
  from_pmtiles_prefix = from_pmtiles_dir / args.pmtiles_prefix
65
68
  to_pmtiles_prefix = to_pmtiles_dir / args.pmtiles_prefix
@@ -68,90 +71,71 @@ def cli():
68
71
  for d in [tiles_dir, tiffs_dir, from_pmtiles_dir, to_pmtiles_dir]:
69
72
  d.mkdir(parents=True, exist_ok=True)
70
73
 
71
- # Download listing files
72
- print("Downloading listing files...")
73
- run_command(["gh", "release", "download", args.gtiffs_release, "-p", "listing_files.csv", "--clobber"])
74
- run_command(["gh", "release", "download", args.pmtiles_release, "-p", args.listing_files_tiled, "--clobber", "-O", "listing_files_tiled.csv"])
75
-
76
- print("Download force redo bounds")
77
- force_redo_bounds_file = Path("force_redo_bounds.geojson")
78
- try:
79
- run_command(["gh", "release", "download", args.pmtiles_release, "-p", f"{force_redo_bounds_file}", "--clobber"])
80
- print("Force redo bounds file found and downloaded.")
81
- except subprocess.CalledProcessError:
82
- print("No force redo bounds file found; proceeding without it.")
83
- force_redo_bounds_file = None
84
-
85
- # Determine sheets to retile
86
- print("Determining sheets to retile...")
87
- with open("listing_files.csv") as f:
88
- gtiffs_sheets = {row['name'] for row in csv.DictReader(f)}
89
- with open("listing_files_tiled.csv") as f:
90
- pmtiles_sheets = {row['name'] for row in csv.DictReader(f)}
91
-
92
- sheets_to_retile = sorted(list(gtiffs_sheets - pmtiles_sheets))
74
+ # Download bounds files
75
+ print("Downloading new bounds file from gtiffs release...")
76
+ run_command(["gh", "release", "download", args.gtiffs_release, "-p", "bounds.geojson", "--clobber", "-O", str(new_bounds_file)])
93
77
 
94
- with open(retile_list_file, "w") as f:
95
- for sheet in sheets_to_retile:
96
- f.write(f"{sheet}\n")
97
-
98
- print(f"Found {len(sheets_to_retile)} sheets to retile.")
99
- Path("listing_files_tiled.csv").unlink()
78
+ print("Downloading old bounds file from pmtiles release...")
79
+ run_command(["gh", "release", "download", args.pmtiles_release, "-p", args.bounds_file_tiled, "--clobber", "-O", str(old_bounds_file)])
80
+
81
+ # Create force redo bounds file
82
+ print("Creating force redo bounds file...")
83
+ create_force_redo_bounds_main([
84
+ "--old-bounds-file", str(old_bounds_file),
85
+ "--new-bounds-file", str(new_bounds_file),
86
+ "--output-file", str(force_redo_bounds_file)
87
+ ])
100
88
 
101
- if not sheets_to_retile:
102
- print("No sheets to retile found. Cleaning up and exiting.")
103
- Path("listing_files.csv").unlink()
104
- retile_list_file.unlink()
89
+ # Check if there are any changes
90
+ with open(force_redo_bounds_file) as f:
91
+ force_redo_data = json.load(f)
92
+
93
+ if not force_redo_data.get("features"):
94
+ print("No changes detected between bounds files. Exiting.")
105
95
  sys.exit(0)
106
96
 
107
- # Download bounds and original PMTiles
108
- print("Downloading bounds file...")
109
- run_command(["gh", "release", "download", args.gtiffs_release, "-p", "bounds.geojson"])
97
+ # Download original PMTiles
110
98
  print("Getting original PMTiles files...")
111
99
  run_command(["gh", "release", "download", args.pmtiles_release, "-D", str(from_pmtiles_dir), "-p", f"{args.pmtiles_prefix}*"])
112
100
 
113
- extra_bounds_args = []
114
- if force_redo_bounds_file:
115
- extra_bounds_args = ["--force-redo-bounds-file", str(force_redo_bounds_file)]
116
101
  # Get list of sheets to pull
117
102
  print("Getting list of sheets to pull...")
118
103
  retile_main([
119
- "--retile-list-file", str(retile_list_file),
120
- "--bounds-file", "bounds.geojson",
104
+ "--bounds-file", str(new_bounds_file),
105
+ "--force-redo-bounds-file", str(force_redo_bounds_file),
121
106
  "--sheets-to-pull-list-outfile", str(sheets_to_pull_list_outfile),
122
107
  "--from-source", f"{from_pmtiles_prefix}*.pmtiles",
123
- ] + extra_bounds_args)
108
+ ])
124
109
 
125
110
  print("Sheets to pull:")
126
111
  print(sheets_to_pull_list_outfile.read_text())
127
112
 
128
113
  # Download TIFFs
129
114
  print("Downloading TIFFs...")
130
- with open("listing_files.csv") as f:
131
- url_map = {row[0]: row[2] for row in csv.reader(f)}
132
-
133
115
  with open(sheets_to_pull_list_outfile) as f:
134
116
  for line in f:
135
117
  fname = line.strip()
136
- if fname in url_map:
137
- url = url_map[fname]
118
+ if fname:
138
119
  print(f"Pulling {fname}")
139
- run_command(["wget", "-q", "-P", str(tiffs_dir), url])
120
+ try:
121
+ run_command(["gh", "release", "download", args.gtiffs_release, "-p", fname, "-D", str(tiffs_dir), "--clobber"])
122
+ except subprocess.CalledProcessError as e:
123
+ print(f"Failed to download {fname}. It might be a dummy sheet from force redo. Continuing...")
140
124
 
141
125
  sheets_to_pull_list_outfile.unlink()
142
126
 
143
127
  # Retile sheets
144
128
  print("Retiling the sheets...")
145
129
  retile_main([
146
- "--retile-list-file", str(retile_list_file),
147
- "--bounds-file", "bounds.geojson",
130
+ "--bounds-file", str(new_bounds_file),
131
+ "--force-redo-bounds-file", str(force_redo_bounds_file),
148
132
  "--from-source", f"{from_pmtiles_prefix}*.pmtiles",
149
133
  "--tiles-dir", str(tiles_dir),
150
134
  "--tiffs-dir", str(tiffs_dir),
151
- ] + extra_bounds_args)
135
+ ])
152
136
 
153
- retile_list_file.unlink()
154
- Path("bounds.geojson").unlink()
137
+ old_bounds_file.unlink()
138
+ force_redo_bounds_file.unlink()
155
139
 
156
140
  # Create new PMTiles files
157
141
  print("Creating new pmtiles files...")
@@ -159,6 +143,7 @@ def cli():
159
143
  "--from-source", str(tiles_dir),
160
144
  "--from-source", f"{from_pmtiles_prefix}*.pmtiles",
161
145
  "--to-pmtiles", f"{to_pmtiles_prefix}.pmtiles",
146
+ "--exclude-transparent"
162
147
  ])
163
148
 
164
149
  # Upload new PMTiles and manage release assets
@@ -171,20 +156,14 @@ def cli():
171
156
  new_pmtiles = glob.glob(f"{to_pmtiles_prefix}*")
172
157
  run_command(["gh", "release", "upload", args.pmtiles_release, "--clobber"] + new_pmtiles)
173
158
 
174
- # Handle listing files
175
- if args.listing_files_tiled != "listing_files.csv":
176
- print(f"Renaming listing_files.csv to {args.listing_files_tiled}")
177
- Path("listing_files.csv").rename(args.listing_files_tiled)
159
+ # Handle bounds file
160
+ print("Uploading new bounds file to pmtiles release...")
161
+ upload_bounds_file = Path(args.bounds_file_tiled)
162
+ new_bounds_file.rename(upload_bounds_file)
163
+
164
+ run_command(["gh", "release", "upload", args.pmtiles_release, str(upload_bounds_file), "--clobber"])
178
165
 
179
- print("Uploading new listing file...")
180
- run_command(["gh", "release", "upload", args.pmtiles_release, args.listing_files_tiled, "--clobber"])
181
- Path(args.listing_files_tiled).unlink()
182
-
183
- # Handle force redo bounds file
184
- if force_redo_bounds_file:
185
- print("Deleting force redo bounds file...")
186
- run_command(["gh", "release", "delete-asset", args.gtiffs_release, str(force_redo_bounds_file), '-y'])
187
- force_redo_bounds_file.unlink()
166
+ upload_bounds_file.unlink()
188
167
 
189
168
  # Cleanup
190
169
  print("Cleaning up staging directories...")