topo_map_processor 0.1.3__py3-none-any.whl

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.
@@ -0,0 +1,311 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = [
4
+ # "mercantile",
5
+ # "pmtiles",
6
+ # ]
7
+ # ///
8
+
9
+ import os
10
+ import re
11
+ import json
12
+ import argparse
13
+
14
+ import time
15
+ import subprocess
16
+
17
+ from pathlib import Path
18
+
19
+
20
+ import mercantile
21
+ from shapely.geometry import mapping
22
+
23
+ from osgeo_utils.gdal2tiles import main as gdal2tiles_main
24
+ from osgeo_utils.gdal2tiles import create_overview_tile, TileJobInfo, GDAL2Tiles
25
+
26
+
27
+ from tile_sources import PartitionedPMTilesSource, MissingTileError
28
+
29
+ WEBP_QUALITY = 75
30
+
31
+
32
+
33
+ class AttrDict(dict):
34
+ def __init__(self, *args, **kwargs):
35
+ super(AttrDict, self).__init__(*args, **kwargs)
36
+ self.__dict__ = self
37
+
38
+
39
+ def run_external(cmd):
40
+ print(f'running cmd - {cmd}')
41
+ start = time.time()
42
+ res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
43
+ end = time.time()
44
+ print(f'STDOUT: {res.stdout}')
45
+ print(f'STDERR: {res.stderr}')
46
+ print(f'command took {end - start} secs to run')
47
+ if res.returncode != 0:
48
+ raise Exception(f'command {cmd} failed')
49
+
50
+
51
+ def convert_paths_in_vrt(vrt_file):
52
+ # <SourceFilename relativeToVRT="1">40M_15.tif</SourceFilename>
53
+ vrt_dirname = str(vrt_file.resolve().parent)
54
+ vrt_text = vrt_file.read_text()
55
+ replaced = re.sub(
56
+ r'<SourceFilename relativeToVRT="1">(.*)</SourceFilename>',
57
+ rf'<SourceFilename relativeToVRT="0">{vrt_dirname}/\1</SourceFilename>',
58
+ vrt_text
59
+ )
60
+ vrt_file.write_text(replaced)
61
+
62
+
63
+ def create_base_tiles(inp_file, output_dir, zoom_levels):
64
+ print('start tiling')
65
+ os.environ['GDAL_CACHEMAX'] = '2048'
66
+ os.environ['GDAL_MAX_DATASET_POOL_SIZE'] = '5000'
67
+ os.environ['GDAL_DISABLE_READDIR_ON_OPEN'] = 'TRUE'
68
+ #os.environ['VRT_SHARED_SOURCE'] = '1'
69
+ #os.environ['GTIFF_VIRTUAL_MEM_IO'] = 'TRUE'
70
+ gdal2tiles_main(['gdal2tiles.py',
71
+ '-r', 'antialias',
72
+ '--verbose',
73
+ '-w', 'none',
74
+ '--exclude',
75
+ '--resume',
76
+ '--xyz',
77
+ '--processes=8',
78
+ '-z', zoom_levels,
79
+ '--tiledriver', 'WEBP',
80
+ '--webp-quality', f'{WEBP_QUALITY}',
81
+ inp_file, output_dir])
82
+
83
+
84
+
85
+ pmtiles_reader = None
86
+ def get_pmtiles_reader(from_pmtiles_prefix):
87
+ global pmtiles_reader
88
+ if pmtiles_reader is None:
89
+ pmtiles_reader = PartitionedPMTilesSource(from_pmtiles_prefix)
90
+ return pmtiles_reader
91
+
92
+
93
+ def pull_from_pmtiles(file, from_pmtiles_prefix):
94
+ reader = get_pmtiles_reader(from_pmtiles_prefix)
95
+ fname = str(file)
96
+ pieces = fname.split('/')
97
+ tile = mercantile.Tile(x=int(pieces[-2]),
98
+ y=int(pieces[-1].replace('.webp', '')),
99
+ z=int(pieces[-3]))
100
+ try:
101
+ t_data = reader.get_tile_data(tile)
102
+ except MissingTileError:
103
+ return
104
+ file.parent.mkdir(exist_ok=True, parents=True)
105
+ file.write_bytes(t_data)
106
+
107
+
108
+ def get_tile_file(tile, tiles_dir):
109
+ return f'{tiles_dir}/{tile.z}/{tile.x}/{tile.y}.webp'
110
+
111
+ def check_sheets(sheets_to_pull, tiffs_dir):
112
+ for sheet_no in sheets_to_pull:
113
+ to = tiffs_dir.joinpath(f'{sheet_no}.tif')
114
+ if not to.exists():
115
+ raise Exception(f'missing file {to}')
116
+
117
+ def copy_tiles_over(tiles_to_pull, tiles_dir, from_pmtiles_prefix):
118
+ for tile in tiles_to_pull:
119
+ to = Path(get_tile_file(tile, tiles_dir))
120
+ if to.exists():
121
+ continue
122
+ pull_from_pmtiles(to, from_pmtiles_prefix)
123
+
124
+
125
+ def create_upper_tiles(tiles_to_create, tiles_dir):
126
+ options = AttrDict({
127
+ 'resume': True,
128
+ 'verbose': False,
129
+ 'quiet': False,
130
+ 'xyz': True,
131
+ 'exclude_transparent': True,
132
+ 'profile': 'mercator',
133
+ 'resampling': 'antialias',
134
+ 'tiledriver': 'WEBP',
135
+ 'webp_quality': WEBP_QUALITY,
136
+ 'webp_lossless': False
137
+ })
138
+ tile_job_info = TileJobInfo(
139
+ tile_driver='WEBP',
140
+ nb_data_bands=3,
141
+ tile_size=256,
142
+ tile_extension='webp',
143
+ kml=False
144
+ )
145
+
146
+ for tile in tiles_to_create:
147
+ ctiles = mercantile.children(tile)
148
+ tiles_dir.joinpath(str(tile.z), str(tile.x)).mkdir(parents=True, exist_ok=True)
149
+ base_tile_group = [ (t.x, GDAL2Tiles.getYTile(t.y, t.z, options)) for t in ctiles ]
150
+ #print(f'{tile=}, {base_tile_group=}')
151
+ create_overview_tile(z + 1, output_folder=str(tiles_dir), tile_job_info=tile_job_info, options=options, base_tiles=base_tile_group)
152
+
153
+
154
+ def get_sheet_data(bounds_fname):
155
+
156
+ index_file = Path(bounds_fname)
157
+ if not index_file.exists():
158
+ raise Exception(F'missing index file at {bounds_fname}')
159
+
160
+ index_data = json.loads(index_file.read_text())
161
+
162
+ sheets_to_box = {}
163
+ for f in index_data['features']:
164
+ sheet_no = f['properties']['id']
165
+ geom = mapping(f['geometry'])
166
+ xmin, ymin, xmax, ymax = geom.bounds
167
+ box = mercantile.LngLatBbox(xmin, ymin, xmax, ymax)
168
+ sheets_to_box[sheet_no] = box
169
+
170
+ return sheets_to_box
171
+
172
+
173
+ def get_base_tile_sheet_mappings(sheets_to_box, base_zoom):
174
+ sheets_to_base_tiles = {}
175
+ base_tiles_to_sheets = {}
176
+
177
+ for sheet_no, box in sheets_to_box.items():
178
+ tiles = set(mercantile.tiles(box.west, box.south, box.east, box.north, [base_zoom]))
179
+ sheets_to_base_tiles[sheet_no] = tiles
180
+ for tile in tiles:
181
+ if tile not in base_tiles_to_sheets:
182
+ base_tiles_to_sheets[tile] = set()
183
+ base_tiles_to_sheets[tile].add(sheet_no)
184
+
185
+ return sheets_to_base_tiles, base_tiles_to_sheets
186
+
187
+
188
+ def delete_unwanted_tiles(tiles_to_keep, z, tiles_dir):
189
+ delete_count = 0
190
+ for file in tiles_dir.glob(f'{z}/*/*.webp'):
191
+ y = int(file.name[:-5])
192
+ x = int(file.parent.name)
193
+
194
+ disk_tile = mercantile.Tile(z=z, x=x, y=y)
195
+ if disk_tile not in tiles_to_keep:
196
+ #print(f'deleting {disk_tile}')
197
+ file.unlink()
198
+ delete_count += 1
199
+ print(f'deleted {delete_count} files at level {z}')
200
+
201
+
202
+ def create_vrt_file(sheets, tiffs_dir):
203
+ vrt_file = Path(f'{tiffs_dir}/combined.vrt')
204
+ if vrt_file.exists():
205
+ return vrt_file
206
+ tiff_list = [ tiffs_dir.joinpath(f'{p_sheet}.tif').resolve() for p_sheet in sheets ]
207
+ tiff_list = [ str(f) for f in tiff_list if f.exists() ]
208
+
209
+ tiff_list_str = ' '.join(tiff_list)
210
+
211
+ run_external(f'gdalbuildvrt {vrt_file} {tiff_list_str}')
212
+ convert_paths_in_vrt(vrt_file)
213
+
214
+ return vrt_file
215
+
216
+ def cli():
217
+ parser = argparse.ArgumentParser()
218
+ parser.add_argument('--retile-list-file', required=True, help='File containing list of sheets to retile')
219
+ parser.add_argument('--bounds-file', required=True, help='Geojson file containing list of available sheets and their georaphic bounds')
220
+ parser.add_argument('--min-zoom', default=0, type=int)
221
+ parser.add_argument('--max-zoom', required=True, type=int)
222
+ parser.add_argument('--sheets-to-pull-list-outfile', default=None,
223
+ help='Output into which we write the list of sheet that need to be pulled, if set the script ends after created the list file')
224
+ parser.add_argument('--from-pmtiles-prefix')
225
+ parser.add_argument('--tiles-dir', required=True)
226
+ parser.add_argument('--tiffs-dir', required=True)
227
+
228
+ args = parser.parse_args()
229
+ if not args.sheets_to_pull_list_outfile:
230
+ missing = []
231
+ if not args.from_pmtiles_prefix:
232
+ missing.append('--from-pmtiles-prefix')
233
+
234
+ if missing:
235
+ parser.error(f"The following arguments are required when --sheets-to-pull-list-outfile is not provided: {', '.join(missing)}")
236
+
237
+ retile_sheets = Path(args.retile_list_file).read_text().split('\n')
238
+ retile_sheets = set([ r.strip() for r in retile_sheets if r.strip() != '' ])
239
+
240
+ print('getting base tiles to sheet mapping')
241
+ sheets_to_box = get_sheet_data(args.bounds_file)
242
+ sheets_to_base_tiles, base_tiles_to_sheets = get_base_tile_sheet_mappings(sheets_to_box, args.max_zoom)
243
+
244
+ all_affected_tiles = set()
245
+
246
+ print('calculating sheets to pull')
247
+ affected_base_tiles = set()
248
+ for sheet_no in retile_sheets:
249
+ affected_base_tiles.update(sheets_to_base_tiles[sheet_no])
250
+ all_affected_tiles.update(affected_base_tiles)
251
+
252
+ sheets_to_pull = set()
253
+ for tile in affected_base_tiles:
254
+ to_add = base_tiles_to_sheets[tile]
255
+ for sheet in to_add:
256
+ sheets_to_pull.add(sheet)
257
+
258
+ Path(args.tiffs_dir).mkdir(exist_ok=True, parents=True)
259
+ Path(args.tiles_dir).mkdir(exist_ok=True, parents=True)
260
+ print(f'{sheets_to_pull=}')
261
+ if args.sheets_to_pull_list_outfile is not None:
262
+ Path(args.sheets_to_pull_list_outfile).write_text('\n'.join(sheets_to_pull) + '\n')
263
+ exit(0)
264
+
265
+
266
+ print('check the sheets availability')
267
+ check_sheets(sheets_to_pull, args.tiffs_dir)
268
+
269
+ print('creating vrt file from sheets involved')
270
+ vrt_file = create_vrt_file(sheets_to_pull, args.tiffs_dir)
271
+
272
+ print('creating tiles for base zoom with a vrt')
273
+ create_base_tiles(f'{vrt_file}', str(args.tiles_dir), f'{args.max_zoom}')
274
+
275
+ print('deleting unwanted base tiles')
276
+ delete_unwanted_tiles(affected_base_tiles, args.max_zoom, args.tiles_dir)
277
+
278
+ prev_affected_tiles = affected_base_tiles
279
+ for z in range(args.max_zoom-1, args.min_zoom-1, -1):
280
+ print(f'handling level {z}')
281
+
282
+ curr_affected_tiles = set()
283
+ for tile in prev_affected_tiles:
284
+ curr_affected_tiles.add(mercantile.parent(tile))
285
+ all_affected_tiles.update(curr_affected_tiles)
286
+
287
+ child_tiles_to_pull = set()
288
+ for ptile in curr_affected_tiles:
289
+ for ctile in mercantile.children(ptile):
290
+ if ctile not in prev_affected_tiles:
291
+ child_tiles_to_pull.add(ctile)
292
+
293
+ print('copying additional child tiles required for curr level')
294
+ copy_tiles_over(child_tiles_to_pull, args.tiles_dir, args.from_pmtiles_prefix)
295
+
296
+ print('creating tiles for current level')
297
+ create_upper_tiles(curr_affected_tiles, args.tiles_dir)
298
+
299
+ print('removing unwanted child tiles')
300
+ delete_unwanted_tiles(prev_affected_tiles, z+1, args.tiles_dir)
301
+
302
+ prev_affected_tiles = curr_affected_tiles
303
+
304
+ print('All Done!!!')
305
+
306
+
307
+ if __name__ == '__main__':
308
+ cli()
309
+
310
+
311
+
@@ -0,0 +1,86 @@
1
+ import re
2
+ import os
3
+ import glob
4
+ import argparse
5
+
6
+ import time
7
+ import subprocess
8
+
9
+ from pathlib import Path
10
+
11
+ from osgeo_utils.gdal2tiles import main as gdal2tiles_main
12
+
13
+ def run_external(cmd):
14
+ print(f'running cmd - {cmd}')
15
+ start = time.time()
16
+ res = subprocess.run(cmd, shell=True, capture_output=True, text=True)
17
+ end = time.time()
18
+ print(f'STDOUT: {res.stdout}')
19
+ print(f'STDERR: {res.stderr}')
20
+ print(f'command took {end - start} secs to run')
21
+ if res.returncode != 0:
22
+ raise Exception(f'command {cmd} failed')
23
+
24
+ def convert_paths_in_vrt(vrt_file):
25
+ vrt_dirname = str(vrt_file.resolve().parent)
26
+ vrt_text = vrt_file.read_text()
27
+ replaced = re.sub(
28
+ r'<SourceFilename relativeToVRT="1">(.*)</SourceFilename>',
29
+ rf'<SourceFilename relativeToVRT="0">{vrt_dirname}/\1</SourceFilename>',
30
+ vrt_text
31
+ )
32
+ vrt_file.write_text(replaced)
33
+
34
+ def cli():
35
+ parser = argparse.ArgumentParser(description='Tile the GTiffs')
36
+ parser.add_argument('tiles-dir', required=True, help='Directory to store the tiles')
37
+ parser.add_argument('tiffs-dir', required=True, help='Directory with GTiffs to tile')
38
+ parser.add_argument('max-zoom', type=int, required=True, help='Maximum zoom level for tiling')
39
+ parser.add_argument('min-zoom', type=int, default=0, help='Minimum zoom level for tiling')
40
+
41
+ args = parser.parse_args()
42
+
43
+ tiles_dir = Path(args.tiles_dir)
44
+ tiles_dir.mkdir(parents=True, exist_ok=True)
45
+
46
+
47
+ file_names = list(glob.glob(f'{args.tiffs_dir}/*.tif'))
48
+ file_names = [ str(Path(f).resolve()) for f in file_names ]
49
+ print(f'total files: {len(file_names)}')
50
+
51
+ file_list_file = Path('files_to_tile.txt')
52
+ file_list_file.write_text('\n'.join(file_names))
53
+
54
+ vrt_file = Path('files_to_tile.vrt')
55
+
56
+ # create vrt file
57
+ run_external(f'gdalbuildvrt -input_file_list {str(file_list_file)} {str(vrt_file)}')
58
+ convert_paths_in_vrt(vrt_file)
59
+ file_list_file.unlink()
60
+
61
+ print('start tiling')
62
+ os.environ['GDAL_CACHEMAX'] = '2048'
63
+ os.environ['GDAL_MAX_DATASET_POOL_SIZE'] = '5000'
64
+ os.environ['GDAL_DISABLE_READDIR_ON_OPEN'] = 'TRUE'
65
+ #os.environ['VRT_SHARED_SOURCE'] = '1'
66
+ #os.environ['GTIFF_VIRTUAL_MEM_IO'] = 'TRUE'
67
+ gdal2tiles_main(['gdal2tiles.py',
68
+ '-r', 'antialias',
69
+ '--verbose',
70
+ '--exclude',
71
+ '--resume',
72
+ '--xyz',
73
+ '--processes=8',
74
+ '-z', f'{args.min_zoom}-{args.max_zoom}',
75
+ '--tiledriver', 'WEBP',
76
+ '--webp-quality', '75',
77
+ str(vrt_file), str(tiles_dir)])
78
+
79
+ vrt_file.unlink()
80
+
81
+ print('All Done!!')
82
+
83
+ if __name__ == '__main__':
84
+ cli()
85
+
86
+
@@ -0,0 +1,170 @@
1
+ import os
2
+ import glob
3
+ import json
4
+
5
+ from pathlib import Path
6
+
7
+ import mercantile
8
+
9
+ from pmtiles.reader import MmapSource, Reader as PMTilesReader, all_tiles
10
+
11
+ class MissingTileError(Exception):
12
+ pass
13
+
14
+
15
+ class DiskSource:
16
+ def __init__(self, directory):
17
+ self.dir = directory
18
+
19
+ def get_tile_from_file(self, fname):
20
+ parts = fname.split('/')
21
+ tile = mercantile.Tile(z=int(parts[-3]),
22
+ x=int(parts[-2]),
23
+ y=int(parts[-1].replace('.webp', '')))
24
+ return tile
25
+
26
+ def file_from_tile(self, tile):
27
+ return f'{self.dir}/{tile.z}/{tile.x}/{tile.y}.webp'
28
+
29
+ def get_tile_data(self, tile):
30
+ fname = self.file_from_tile(tile)
31
+ try:
32
+ os.stat(fname)
33
+ except FileNotFoundError:
34
+ raise MissingTileError()
35
+
36
+ with open(fname, 'rb') as f:
37
+ return f.read()
38
+
39
+ def get_tile_size(self, tile):
40
+ fname = self.file_from_tile(tile)
41
+ try:
42
+ fstats = os.stat(fname)
43
+ except FileNotFoundError:
44
+ raise MissingTileError()
45
+ return fstats.st_size
46
+
47
+ def for_all_z(self, z):
48
+ fnames = glob.glob(f'{self.dir}/{z}/*/*.webp')
49
+ for fname in fnames:
50
+ tile = self.get_tile_from_file(fname)
51
+ fstats = os.stat(fname)
52
+ yield (tile, fstats.st_size)
53
+
54
+ def all(self):
55
+ fnames = glob.glob(f'{self.dir}/*/*/*.webp')
56
+ for fname in fnames:
57
+ tile = self.get_tile_from_file(fname)
58
+ t_data = Path(fname).read_bytes()
59
+ yield (tile, t_data)
60
+
61
+ def cleanup(self):
62
+ pass
63
+
64
+ class PartitionedPMTilesSource:
65
+ def __init__(self, pmtiles_prefix):
66
+ pmtiles_files = glob.glob(f'{pmtiles_prefix}*.pmtiles')
67
+ self.files = []
68
+ self.readers = {}
69
+ for fname in pmtiles_files:
70
+ file = open(fname, 'rb')
71
+ self.files.append(file)
72
+ src = MmapSource(open(fname, 'rb'))
73
+ reader = PMTilesReader(src)
74
+ self.readers[fname] = reader
75
+
76
+ self.fnames = list(pmtiles_files)
77
+ fname_to_index = { fname:i for i,fname in enumerate(self.fnames) }
78
+ self.tiles_to_suffix = {}
79
+ self.tiles_to_size = {}
80
+ for fname in self.fnames:
81
+ print(f'collecting tile sizes from {fname}')
82
+ reader = self.readers[fname]
83
+ for t, t_data in all_tiles(reader.get_bytes):
84
+ tile = mercantile.Tile(x=t[1], y=t[2], z=t[0])
85
+ self.tiles_to_findex[tile] = fname_to_index[fname]
86
+ self.tiles_to_size[tile] = len(t_data)
87
+
88
+ def get_reader_from_tile(self, tile):
89
+ if tile not in self.tiles_to_suffix:
90
+ raise MissingTileError()
91
+ s = self.tiles_to_findex[tile]
92
+ fname = self.fnames[s]
93
+ reader = self.readers[fname]
94
+ return reader
95
+
96
+
97
+ def get_tile_data(self, tile):
98
+ reader = self.get_reader_from_tile(tile)
99
+ data = reader.get(tile.z, tile.x, tile.y)
100
+ if data is None:
101
+ raise MissingTileError()
102
+ return data
103
+
104
+ def get_tile_size(self, tile):
105
+ if tile not in self.tiles_to_size:
106
+ raise MissingTileError()
107
+ return self.tiles_to_size[tile]
108
+
109
+ def for_all_z(self, z):
110
+ for tile, size in self.tiles_to_size.items():
111
+ if tile.z == z:
112
+ yield (tile, size)
113
+
114
+ def all(self):
115
+ for suffix, reader in self.readers.items():
116
+ print(f'yielding from {suffix}')
117
+ for t, t_data in all_tiles(reader.get_bytes):
118
+ tile = mercantile.Tile(x=t[1], y=t[2], z=t[0])
119
+ yield tile, t_data
120
+
121
+ def cleanup(self):
122
+ for f in self.files:
123
+ f.close()
124
+
125
+
126
+
127
+ class DiskAndPartitionedPMTilesSource:
128
+ def __init__(self, directory, pmtiles_prefix):
129
+ self.dsrc = DiskSource(directory)
130
+ self.psrc = PartitionedPMTilesSource(pmtiles_prefix)
131
+
132
+ def get_tile_data(self, tile):
133
+ try:
134
+ return self.dsrc.get_tile_data(tile)
135
+ except MissingTileError:
136
+ return self.psrc.get_tile_data(tile)
137
+
138
+ def get_tile_size(self, tile):
139
+ try:
140
+ return self.dsrc.get_tile_size(tile)
141
+ except MissingTileError:
142
+ return self.psrc.get_tile_size(tile)
143
+
144
+ def for_all_z(self, z):
145
+ print(f'iterating over all {z}')
146
+ seen = set()
147
+ for (tile, size) in self.dsrc.for_all_z(z):
148
+ seen.add(tile)
149
+ yield (tile, size)
150
+
151
+ print(f'iterated over all {z} disk')
152
+ for (tile, size) in self.psrc.for_all_z(z):
153
+ if tile in seen:
154
+ continue
155
+ yield (tile, size)
156
+ print(f'iterated over all {z} pmtiles')
157
+
158
+ def all(self):
159
+ print('yielding from disk')
160
+ for res in self.dsrc.all():
161
+ yield res
162
+ print('yielding from pmtiles files')
163
+ for res in self.psrc.all():
164
+ yield res
165
+
166
+ def cleanup(self):
167
+ self.dsrc.cleanup()
168
+ self.psrc.cleanup()
169
+
170
+
@@ -0,0 +1,53 @@
1
+
2
+ import json
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ def cli():
7
+ parser = argparse.ArgumentParser(description="Update bounds in a GeoJSON file.")
8
+ parser.add_argument("bounds-file", help="Input GeoJSON file with bounds.")
9
+ parser.add_argument("bounds-dir", help="Directory containing the individual bounds files")
10
+ parser.add_argument("retile-list-file", help="File containing the list of sheets to update.")
11
+
12
+ args = parser.parse_args()
13
+
14
+ bounds_file = Path(args.bounds_file)
15
+ bounds_dir = Path(args.bounds_dir)
16
+ retile_list_file = Path(args.retile_list_file)
17
+
18
+ existing_map = {}
19
+ if not bounds_file.exists():
20
+ raise FileNotFoundError(f"Bounds file does not exist: {bounds_file}")
21
+
22
+ # Read the existing bounds file
23
+ existing_data = json.loads(bounds_file.read_text())
24
+ for feature in existing_data.get('features', []):
25
+ if 'properties' in feature and 'id' in feature['properties']:
26
+ sheet_name = feature['properties']['id']
27
+ existing_map[sheet_name] = feature
28
+
29
+ sheets = retile_list_file.read_text().strip().splitlines()
30
+ sheets = [s.strip() for s in sheets if s.strip()]
31
+ for s in sheets:
32
+ bfile = bounds_dir / f'{s}.geojsonl'
33
+ if not bfile.exists():
34
+ raise FileNotFoundError(f"Bounds file for sheet '{s}' does not exist: {bfile}")
35
+
36
+ feature = json.loads(bfile.read_text())
37
+ existing_data[s] = feature
38
+
39
+
40
+ all_feats = list(existing_data.values())
41
+
42
+ with open(bounds_file, 'w') as f:
43
+ f.write('{ "type": "FeatureCollection", "features": [\n')
44
+ for i,feat in enumerate(all_feats):
45
+ line = json.dumps(feat)
46
+ if i != len(all_feats) - 1:
47
+ line += ','
48
+ line += '\n'
49
+ f.write(line)
50
+ f.write(']}\n')
51
+
52
+ if __name__ == "__main__":
53
+ cli()