voxcity 0.6.31__py3-none-any.whl → 0.6.33__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.

Potentially problematic release.


This version of voxcity might be problematic. Click here for more details.

@@ -1,539 +1,602 @@
1
- """
2
- CityLES export module for VoxCity
3
- Exports VoxCity grid data to CityLES input file format
4
- Updated 2025/08/05 with corrected land use and building material codes
5
- Integrated with VoxCity land cover utilities
6
-
7
- Notes:
8
- - This module expects raw land cover grids as produced per-source by VoxCity, not
9
- standardized/converted indices. Supported sources:
10
- 'OpenStreetMap', 'Urbanwatch', 'OpenEarthMapJapan', 'ESA WorldCover',
11
- 'ESRI 10m Annual Land Cover', 'Dynamic World V1'.
12
- """
13
-
14
- import os
15
- import numpy as np
16
- from pathlib import Path
17
-
18
-
19
- # VoxCity standard land cover classes after conversion
20
- # Based on convert_land_cover function output
21
- VOXCITY_STANDARD_CLASSES = {
22
- 0: 'Bareland',
23
- 1: 'Rangeland',
24
- 2: 'Shrub',
25
- 3: 'Agriculture land',
26
- 4: 'Tree',
27
- 5: 'Moss and lichen',
28
- 6: 'Wet land',
29
- 7: 'Mangrove',
30
- 8: 'Water',
31
- 9: 'Snow and ice',
32
- 10: 'Developed space',
33
- 11: 'Road',
34
- 12: 'Building',
35
- 13: 'No Data'
36
- }
37
-
38
- ## Source-specific class name to CityLES land use mappings
39
- # CityLES land use codes: 1=Water, 2=Rice Paddy, 3=Crops, 4=Grassland, 5=Deciduous Broadleaf Forest,
40
- # 9=Bare Land, 10=Building, 16=Asphalt (road), etc.
41
-
42
- # OpenStreetMap / Standard
43
- OSM_CLASS_TO_CITYLES = {
44
- 'Bareland': 9,
45
- 'Rangeland': 4,
46
- 'Shrub': 4,
47
- 'Moss and lichen': 4,
48
- 'Agriculture land': 3,
49
- 'Tree': 5,
50
- 'Wet land': 2,
51
- 'Mangroves': 5,
52
- 'Water': 1,
53
- 'Snow and ice': 9,
54
- 'Developed space': 10,
55
- 'Road': 16,
56
- 'Building': 10,
57
- 'No Data': 4
58
- }
59
-
60
- # Urbanwatch
61
- URBANWATCH_CLASS_TO_CITYLES = {
62
- 'Building': 10,
63
- 'Road': 16,
64
- 'Parking Lot': 16,
65
- 'Tree Canopy': 5,
66
- 'Grass/Shrub': 4,
67
- 'Agriculture': 3,
68
- 'Water': 1,
69
- 'Barren': 9,
70
- 'Unknown': 4,
71
- 'Sea': 1
72
- }
73
-
74
- # OpenEarthMapJapan
75
- OEMJ_CLASS_TO_CITYLES = {
76
- 'Bareland': 9,
77
- 'Rangeland': 4,
78
- 'Developed space': 10,
79
- 'Road': 16,
80
- 'Tree': 5,
81
- 'Water': 1,
82
- 'Agriculture land': 3,
83
- 'Building': 10
84
- }
85
-
86
- # ESA WorldCover
87
- ESA_CLASS_TO_CITYLES = {
88
- 'Trees': 5,
89
- 'Shrubland': 4,
90
- 'Grassland': 4,
91
- 'Cropland': 3,
92
- 'Built-up': 10,
93
- 'Barren / sparse vegetation': 9,
94
- 'Snow and ice': 9,
95
- 'Open water': 1,
96
- 'Herbaceous wetland': 2,
97
- 'Mangroves': 5,
98
- 'Moss and lichen': 9
99
- }
100
-
101
- # ESRI 10m Annual Land Cover
102
- ESRI_CLASS_TO_CITYLES = {
103
- 'No Data': 4,
104
- 'Water': 1,
105
- 'Trees': 5,
106
- 'Grass': 4,
107
- 'Flooded Vegetation': 2,
108
- 'Crops': 3,
109
- 'Scrub/Shrub': 4,
110
- 'Built Area': 10,
111
- 'Bare Ground': 9,
112
- 'Snow/Ice': 9,
113
- 'Clouds': 4
114
- }
115
-
116
- # Dynamic World V1
117
- DYNAMIC_WORLD_CLASS_TO_CITYLES = {
118
- 'Water': 1,
119
- 'Trees': 5,
120
- 'Grass': 4,
121
- 'Flooded Vegetation': 2,
122
- 'Crops': 3,
123
- 'Shrub and Scrub': 4,
124
- 'Built': 10,
125
- 'Bare': 9,
126
- 'Snow and Ice': 9
127
- }
128
-
129
- # Building material mapping based on corrected documentation
130
- BUILDING_MATERIAL_MAPPING = {
131
- 'building': 110, # Building (general)
132
- 'concrete': 110, # Building (concrete)
133
- 'residential': 111, # Old wooden house
134
- 'wooden': 111, # Old wooden house
135
- 'commercial': 110, # Building (commercial)
136
- 'industrial': 110, # Building (industrial)
137
- 'default': 110 # Default to general building
138
- }
139
-
140
- # Tree type mapping for vmap.txt
141
- TREE_TYPE_MAPPING = {
142
- 'deciduous': 101, # Leaf
143
- 'evergreen': 101, # Leaf (simplified)
144
- 'leaf': 101, # Leaf
145
- 'shade': 102, # Shade
146
- 'default': 101 # Default to leaf
147
- }
148
-
149
-
150
- def create_cityles_directories(output_directory):
151
- """Create necessary directories for CityLES output"""
152
- output_path = Path(output_directory)
153
- output_path.mkdir(parents=True, exist_ok=True)
154
- return output_path
155
-
156
-
157
- def _get_source_name_mapping(land_cover_source):
158
- """Return the class-name-to-CityLES mapping dictionary for the given source."""
159
- if land_cover_source == 'OpenStreetMap' or land_cover_source == 'Standard':
160
- return OSM_CLASS_TO_CITYLES
161
- if land_cover_source == 'Urbanwatch':
162
- return URBANWATCH_CLASS_TO_CITYLES
163
- if land_cover_source == 'OpenEarthMapJapan':
164
- return OEMJ_CLASS_TO_CITYLES
165
- if land_cover_source == 'ESA WorldCover':
166
- return ESA_CLASS_TO_CITYLES
167
- if land_cover_source == 'ESRI 10m Annual Land Cover':
168
- return ESRI_CLASS_TO_CITYLES
169
- if land_cover_source == 'Dynamic World V1':
170
- return DYNAMIC_WORLD_CLASS_TO_CITYLES
171
- # Default fallback
172
- return OSM_CLASS_TO_CITYLES
173
-
174
-
175
- def _build_index_to_cityles_map(land_cover_source):
176
- """Build mapping: raw per-source index -> CityLES code, using source class order."""
177
- try:
178
- from voxcity.utils.lc import get_land_cover_classes
179
- class_dict = get_land_cover_classes(land_cover_source)
180
- class_names = list(class_dict.values())
181
- except Exception:
182
- # Fallback: no class list; return empty so default is used
183
- class_names = []
184
-
185
- name_to_code = _get_source_name_mapping(land_cover_source)
186
- index_to_code = {}
187
- for idx, class_name in enumerate(class_names):
188
- index_to_code[idx] = name_to_code.get(class_name, 4)
189
- return index_to_code, class_names
190
-
191
-
192
- def export_topog(building_height_grid, building_id_grid, output_path,
193
- building_material='default', cityles_landuse_grid=None):
194
- """
195
- Export topog.txt file for CityLES
196
-
197
- Parameters:
198
- -----------
199
- building_height_grid : numpy.ndarray
200
- 2D array of building heights
201
- building_id_grid : numpy.ndarray
202
- 2D array of building IDs
203
- output_path : Path
204
- Output directory path
205
- building_material : str
206
- Building material type for mapping
207
- """
208
- filename = output_path / 'topog.txt'
209
-
210
- ny, nx = building_height_grid.shape
211
- material_code = BUILDING_MATERIAL_MAPPING.get(building_material,
212
- BUILDING_MATERIAL_MAPPING['default'])
213
-
214
- # Count only cells with building height > 0
215
- building_mask = building_height_grid > 0
216
- n_buildings = int(np.count_nonzero(building_mask))
217
-
218
- with open(filename, 'w') as f:
219
- # Write number of buildings
220
- f.write(f"{n_buildings}\n")
221
-
222
- # Write data for ALL grid points (buildings and non-buildings)
223
- for j in range(ny):
224
- for i in range(nx):
225
- # CityLES uses 1-based indexing
226
- i_1based = i + 1
227
- j_1based = j + 1
228
- height = float(building_height_grid[j, i])
229
- # Decide material code per cell
230
- if cityles_landuse_grid is not None:
231
- cell_lu = int(cityles_landuse_grid[j, i])
232
- material_code_cell = cell_lu + 100
233
- else:
234
- if height > 0:
235
- material_code_cell = material_code
236
- else:
237
- material_code_cell = 102
238
- # Format: i j height material_code depth1 depth2 changed_material
239
- f.write(f"{i_1based} {j_1based} {height:.1f} {material_code_cell} 0.0 0.0 102\n")
240
-
241
-
242
- def export_landuse(land_cover_grid, output_path, land_cover_source=None):
243
- """
244
- Export landuse.txt file for CityLES
245
-
246
- Parameters:
247
- -----------
248
- land_cover_grid : numpy.ndarray
249
- 2D array of land cover values (may be raw or converted)
250
- output_path : Path
251
- Output directory path
252
- land_cover_source : str, optional
253
- Source of land cover data
254
- """
255
- filename = output_path / 'landuse.txt'
256
-
257
- ny, nx = land_cover_grid.shape
258
-
259
- # Build per-source index mapping
260
- index_to_code, class_names = _build_index_to_cityles_map(land_cover_source)
261
-
262
- print(f"Land cover source: {land_cover_source} (raw indices)")
263
-
264
- # Create mapping statistics
265
- mapping_stats = {}
266
- # Prepare grid to return
267
- cityles_landuse_grid = np.zeros((ny, nx), dtype=int)
268
-
269
- with open(filename, 'w') as f:
270
- # Write in row-major order (j varies first, then i)
271
- for j in range(ny):
272
- for i in range(nx):
273
- idx = int(land_cover_grid[j, i])
274
- cityles_code = index_to_code.get(idx, 4)
275
- f.write(f"{cityles_code}\n")
276
-
277
- cityles_landuse_grid[j, i] = cityles_code
278
-
279
- # Track mapping statistics
280
- if idx not in mapping_stats:
281
- mapping_stats[idx] = {'cityles_code': cityles_code, 'count': 0}
282
- mapping_stats[idx]['count'] += 1
283
-
284
- # Print mapping summary
285
- print("\nLand cover mapping summary (by source class):")
286
- total = ny * nx
287
- for idx in sorted(mapping_stats.keys()):
288
- stats = mapping_stats[idx]
289
- percentage = (stats['count'] / total) * 100
290
- class_name = class_names[idx] if 0 <= idx < len(class_names) else 'Unknown'
291
- print(f" {idx}: {class_name} -> CityLES {stats['cityles_code']}: "
292
- f"{stats['count']} cells ({percentage:.1f}%)")
293
-
294
- return cityles_landuse_grid
295
-
296
-
297
- def export_dem(dem_grid, output_path):
298
- """
299
- Export dem.txt file for CityLES
300
-
301
- Parameters:
302
- -----------
303
- dem_grid : numpy.ndarray
304
- 2D array of elevation values
305
- output_path : Path
306
- Output directory path
307
- """
308
- filename = output_path / 'dem.txt'
309
-
310
- ny, nx = dem_grid.shape
311
-
312
- with open(filename, 'w') as f:
313
- for j in range(ny):
314
- for i in range(nx):
315
- # CityLES uses 1-based indexing
316
- i_1based = i + 1
317
- j_1based = j + 1
318
- elevation = float(dem_grid[j, i])
319
- # Clamp negative elevations to 0.0 meters
320
- if elevation < 0.0:
321
- elevation = 0.0
322
- f.write(f"{i_1based} {j_1based} {elevation:.1f}\n")
323
-
324
-
325
- def export_vmap(canopy_height_grid, output_path, tree_base_ratio=0.3, tree_type='default', building_height_grid=None, canopy_bottom_height_grid=None):
326
- """
327
- Export vmap.txt file for CityLES
328
-
329
- Parameters:
330
- -----------
331
- canopy_height_grid : numpy.ndarray
332
- 2D array of canopy heights
333
- output_path : Path
334
- Output directory path
335
- tree_base_ratio : float
336
- Ratio of tree base height to total canopy height
337
- tree_type : str
338
- Tree type for mapping
339
- """
340
- filename = output_path / 'vmap.txt'
341
-
342
- ny, nx = canopy_height_grid.shape
343
- tree_code = TREE_TYPE_MAPPING.get(tree_type, TREE_TYPE_MAPPING['default'])
344
-
345
- # If building heights are provided, remove trees where buildings exist
346
- if building_height_grid is not None:
347
- effective_canopy = np.where(building_height_grid > 0, 0.0, canopy_height_grid)
348
- else:
349
- effective_canopy = canopy_height_grid
350
-
351
- # Count only cells with canopy height > 0
352
- vegetation_mask = effective_canopy > 0
353
- n_trees = int(np.count_nonzero(vegetation_mask))
354
-
355
- with open(filename, 'w') as f:
356
- # Write number of trees
357
- f.write(f"{n_trees}\n")
358
-
359
- # Write data for ALL grid points (vegetation and non-vegetation)
360
- for j in range(ny):
361
- for i in range(nx):
362
- # CityLES uses 1-based indexing
363
- i_1based = i + 1
364
- j_1based = j + 1
365
- total_height = float(effective_canopy[j, i])
366
- if canopy_bottom_height_grid is not None:
367
- lower_height = float(np.clip(canopy_bottom_height_grid[j, i], 0.0, total_height))
368
- else:
369
- lower_height = total_height * tree_base_ratio
370
- upper_height = total_height
371
- # Format: i j lower_height upper_height tree_type
372
- f.write(f"{i_1based} {j_1based} {lower_height:.1f} {upper_height:.1f} {tree_code}\n")
373
-
374
-
375
- def export_lonlat(rectangle_vertices, grid_shape, output_path):
376
- """
377
- Export lonlat.txt file for CityLES
378
-
379
- Parameters:
380
- -----------
381
- rectangle_vertices : list of tuples
382
- List of (lon, lat) vertices defining the area
383
- grid_shape : tuple
384
- Shape of the grid (ny, nx)
385
- output_path : Path
386
- Output directory path
387
- """
388
- filename = output_path / 'lonlat.txt'
389
-
390
- ny, nx = grid_shape
391
-
392
- # Extract bounds from vertices
393
- lons = [v[0] for v in rectangle_vertices]
394
- lats = [v[1] for v in rectangle_vertices]
395
- min_lon, max_lon = min(lons), max(lons)
396
- min_lat, max_lat = min(lats), max(lats)
397
-
398
- # Create coordinate grids
399
- lon_vals = np.linspace(min_lon, max_lon, nx)
400
- lat_vals = np.linspace(min_lat, max_lat, ny)
401
-
402
- with open(filename, 'w') as f:
403
- for j in range(ny):
404
- for i in range(nx):
405
- # CityLES uses 1-based indexing
406
- i_1based = i + 1
407
- j_1based = j + 1
408
- lon = lon_vals[i]
409
- lat = lat_vals[j]
410
-
411
- # Note: Format is i j longitude latitude (not latitude longitude)
412
- f.write(f"{i_1based} {j_1based} {lon:.7f} {lat:.8f}\n")
413
-
414
-
415
- def export_cityles(building_height_grid, building_id_grid, canopy_height_grid,
416
- land_cover_grid, dem_grid, meshsize, land_cover_source,
417
- rectangle_vertices, output_directory="output/cityles",
418
- building_material='default', tree_type='default',
419
- tree_base_ratio=0.3, canopy_bottom_height_grid=None, **kwargs):
420
- """
421
- Export VoxCity data to CityLES format
422
-
423
- Parameters:
424
- -----------
425
- building_height_grid : numpy.ndarray
426
- 2D array of building heights
427
- building_id_grid : numpy.ndarray
428
- 2D array of building IDs
429
- canopy_height_grid : numpy.ndarray
430
- 2D array of canopy heights
431
- land_cover_grid : numpy.ndarray
432
- 2D array of land cover values (may be raw or VoxCity standard)
433
- dem_grid : numpy.ndarray
434
- 2D array of elevation values
435
- meshsize : float
436
- Grid cell size in meters
437
- land_cover_source : str
438
- Source of land cover data (e.g., 'ESRI 10m Annual Land Cover', 'ESA WorldCover')
439
- rectangle_vertices : list of tuples
440
- List of (lon, lat) vertices defining the area
441
- output_directory : str
442
- Output directory path
443
- building_material : str
444
- Building material type for mapping
445
- tree_type : str
446
- Tree type for mapping
447
- tree_base_ratio : float
448
- Ratio of tree base height to total canopy height
449
- **kwargs : dict
450
- Additional parameters (for compatibility)
451
-
452
- Returns:
453
- --------
454
- str : Path to output directory
455
- """
456
- # Create output directory
457
- output_path = create_cityles_directories(output_directory)
458
-
459
- print(f"Exporting CityLES files to: {output_path}")
460
- print(f"Land cover source: {land_cover_source}")
461
-
462
- # Export individual files
463
- print("\nExporting landuse.txt...")
464
- cityles_landuse_grid = export_landuse(land_cover_grid, output_path, land_cover_source)
465
-
466
- print("\nExporting topog.txt...")
467
- export_topog(
468
- building_height_grid,
469
- building_id_grid,
470
- output_path,
471
- building_material,
472
- cityles_landuse_grid=cityles_landuse_grid,
473
- )
474
-
475
- print("\nExporting dem.txt...")
476
- export_dem(dem_grid, output_path)
477
-
478
- print("\nExporting vmap.txt...")
479
- export_vmap(canopy_height_grid, output_path, tree_base_ratio, tree_type, building_height_grid=building_height_grid, canopy_bottom_height_grid=canopy_bottom_height_grid)
480
-
481
- print("\nExporting lonlat.txt...")
482
- export_lonlat(rectangle_vertices, building_height_grid.shape, output_path)
483
-
484
- # Create metadata file for reference
485
- metadata_file = output_path / 'cityles_metadata.txt'
486
- with open(metadata_file, 'w') as f:
487
- f.write("CityLES Export Metadata\n")
488
- f.write("====================\n")
489
- f.write(f"Export date: 2025/08/05\n")
490
- f.write(f"Grid shape: {building_height_grid.shape}\n")
491
- f.write(f"Mesh size: {meshsize} m\n")
492
- f.write(f"Land cover source: {land_cover_source}\n")
493
- f.write(f"Building material: {building_material}\n")
494
- f.write(f"Tree type: {tree_type}\n")
495
- f.write(f"Bounds: {rectangle_vertices}\n")
496
- f.write(f"Buildings: {np.sum(building_height_grid > 0)}\n")
497
- # Trees count after removing overlaps with buildings
498
- trees_count = int(np.sum(np.where(building_height_grid > 0, 0.0, canopy_height_grid) > 0))
499
- f.write(f"Trees: {trees_count}\n")
500
-
501
- # Add land use value ranges
502
- f.write(f"\nLand cover value range: {land_cover_grid.min()} - {land_cover_grid.max()}\n")
503
- unique_values = np.unique(land_cover_grid)
504
- f.write(f"Unique land cover values: {unique_values}\n")
505
-
506
- print(f"\nCityLES export completed successfully!")
507
- return str(output_path)
508
-
509
-
510
- # Helper function to apply VoxCity's convert_land_cover if needed
511
- def ensure_converted_land_cover(land_cover_grid, land_cover_source):
512
- """
513
- Ensure land cover grid uses VoxCity standard indices
514
-
515
- This function checks if the land cover data needs conversion and applies
516
- VoxCity's convert_land_cover function if necessary.
517
-
518
- Parameters:
519
- -----------
520
- land_cover_grid : numpy.ndarray
521
- 2D array of land cover values
522
- land_cover_source : str
523
- Source of land cover data
524
-
525
- Returns:
526
- --------
527
- numpy.ndarray : Land cover grid with VoxCity standard indices (0-13)
528
- """
529
- # Import VoxCity's convert function if available
530
- try:
531
- from voxcity.utils.lc import convert_land_cover
532
-
533
- # Apply conversion
534
- converted_grid = convert_land_cover(land_cover_grid, land_cover_source)
535
- print(f"Applied VoxCity land cover conversion for {land_cover_source}")
536
- return converted_grid
537
- except ImportError:
538
- print("Warning: Could not import VoxCity land cover utilities. Using direct mapping.")
1
+ """
2
+ CityLES export module for VoxCity
3
+ Exports VoxCity grid data to CityLES input file format
4
+ Updated 2025/08/05 with corrected land use and building material codes
5
+ Integrated with VoxCity land cover utilities
6
+
7
+ Notes:
8
+ - This module expects raw land cover grids as produced per-source by VoxCity, not
9
+ standardized/converted indices. Supported sources:
10
+ 'OpenStreetMap', 'Urbanwatch', 'OpenEarthMapJapan', 'ESA WorldCover',
11
+ 'ESRI 10m Annual Land Cover', 'Dynamic World V1'.
12
+ """
13
+
14
+ import os
15
+ import numpy as np
16
+ from pathlib import Path
17
+
18
+
19
+ # VoxCity standard land cover classes after conversion
20
+ # Based on convert_land_cover function output
21
+ VOXCITY_STANDARD_CLASSES = {
22
+ 0: 'Bareland',
23
+ 1: 'Rangeland',
24
+ 2: 'Shrub',
25
+ 3: 'Agriculture land',
26
+ 4: 'Tree',
27
+ 5: 'Moss and lichen',
28
+ 6: 'Wet land',
29
+ 7: 'Mangrove',
30
+ 8: 'Water',
31
+ 9: 'Snow and ice',
32
+ 10: 'Developed space',
33
+ 11: 'Road',
34
+ 12: 'Building',
35
+ 13: 'No Data'
36
+ }
37
+
38
+ ## Source-specific class name to CityLES land use mappings
39
+ # CityLES land use codes: 1=Water, 2=Rice Paddy, 3=Crops, 4=Grassland, 5=Deciduous Broadleaf Forest,
40
+ # 9=Bare Land, 10=Building, 16=Asphalt (road), etc.
41
+
42
+ # OpenStreetMap / Standard
43
+ OSM_CLASS_TO_CITYLES = {
44
+ 'Bareland': 9,
45
+ 'Rangeland': 4,
46
+ 'Shrub': 4,
47
+ 'Moss and lichen': 4,
48
+ 'Agriculture land': 3,
49
+ 'Tree': 5,
50
+ 'Wet land': 2,
51
+ 'Mangroves': 5,
52
+ 'Water': 1,
53
+ 'Snow and ice': 9,
54
+ 'Developed space': 10,
55
+ 'Road': 16,
56
+ 'Building': 10,
57
+ 'No Data': 4
58
+ }
59
+
60
+ # Urbanwatch
61
+ URBANWATCH_CLASS_TO_CITYLES = {
62
+ 'Building': 10,
63
+ 'Road': 16,
64
+ 'Parking Lot': 16,
65
+ 'Tree Canopy': 5,
66
+ 'Grass/Shrub': 4,
67
+ 'Agriculture': 3,
68
+ 'Water': 1,
69
+ 'Barren': 9,
70
+ 'Unknown': 4,
71
+ 'Sea': 1
72
+ }
73
+
74
+ # OpenEarthMapJapan
75
+ OEMJ_CLASS_TO_CITYLES = {
76
+ 'Bareland': 9,
77
+ 'Rangeland': 4,
78
+ 'Developed space': 10,
79
+ 'Road': 16,
80
+ 'Tree': 5,
81
+ 'Water': 1,
82
+ 'Agriculture land': 3,
83
+ 'Building': 10
84
+ }
85
+
86
+ # ESA WorldCover
87
+ ESA_CLASS_TO_CITYLES = {
88
+ 'Trees': 5,
89
+ 'Shrubland': 4,
90
+ 'Grassland': 4,
91
+ 'Cropland': 3,
92
+ 'Built-up': 10,
93
+ 'Barren / sparse vegetation': 9,
94
+ 'Snow and ice': 9,
95
+ 'Open water': 1,
96
+ 'Herbaceous wetland': 2,
97
+ 'Mangroves': 5,
98
+ 'Moss and lichen': 9
99
+ }
100
+
101
+ # ESRI 10m Annual Land Cover
102
+ ESRI_CLASS_TO_CITYLES = {
103
+ 'No Data': 4,
104
+ 'Water': 1,
105
+ 'Trees': 5,
106
+ 'Grass': 4,
107
+ 'Flooded Vegetation': 2,
108
+ 'Crops': 3,
109
+ 'Scrub/Shrub': 4,
110
+ 'Built Area': 10,
111
+ 'Bare Ground': 9,
112
+ 'Snow/Ice': 9,
113
+ 'Clouds': 4
114
+ }
115
+
116
+ # Dynamic World V1
117
+ DYNAMIC_WORLD_CLASS_TO_CITYLES = {
118
+ 'Water': 1,
119
+ 'Trees': 5,
120
+ 'Grass': 4,
121
+ 'Flooded Vegetation': 2,
122
+ 'Crops': 3,
123
+ 'Shrub and Scrub': 4,
124
+ 'Built': 10,
125
+ 'Bare': 9,
126
+ 'Snow and Ice': 9
127
+ }
128
+
129
+ # Building material mapping based on corrected documentation
130
+ BUILDING_MATERIAL_MAPPING = {
131
+ 'building': 110, # Building (general)
132
+ 'concrete': 110, # Building (concrete)
133
+ 'residential': 111, # Old wooden house
134
+ 'wooden': 111, # Old wooden house
135
+ 'commercial': 110, # Building (commercial)
136
+ 'industrial': 110, # Building (industrial)
137
+ 'default': 110 # Default to general building
138
+ }
139
+
140
+ # Tree type mapping for vmap.txt
141
+ TREE_TYPE_MAPPING = {
142
+ 'deciduous': 101, # Leaf
143
+ 'evergreen': 101, # Leaf (simplified)
144
+ 'leaf': 101, # Leaf
145
+ 'shade': 102, # Shade
146
+ 'default': 101 # Default to leaf
147
+ }
148
+
149
+
150
+ def create_cityles_directories(output_directory):
151
+ """Create necessary directories for CityLES output"""
152
+ output_path = Path(output_directory)
153
+ output_path.mkdir(parents=True, exist_ok=True)
154
+ return output_path
155
+
156
+
157
+ def _get_source_name_mapping(land_cover_source):
158
+ """Return the class-name-to-CityLES mapping dictionary for the given source."""
159
+ if land_cover_source == 'OpenStreetMap' or land_cover_source == 'Standard':
160
+ return OSM_CLASS_TO_CITYLES
161
+ if land_cover_source == 'Urbanwatch':
162
+ return URBANWATCH_CLASS_TO_CITYLES
163
+ if land_cover_source == 'OpenEarthMapJapan':
164
+ return OEMJ_CLASS_TO_CITYLES
165
+ if land_cover_source == 'ESA WorldCover':
166
+ return ESA_CLASS_TO_CITYLES
167
+ if land_cover_source == 'ESRI 10m Annual Land Cover':
168
+ return ESRI_CLASS_TO_CITYLES
169
+ if land_cover_source == 'Dynamic World V1':
170
+ return DYNAMIC_WORLD_CLASS_TO_CITYLES
171
+ # Default fallback
172
+ return OSM_CLASS_TO_CITYLES
173
+
174
+
175
+ def _build_index_to_cityles_map(land_cover_source):
176
+ """Build mapping: raw per-source index -> CityLES code, using source class order."""
177
+ try:
178
+ from voxcity.utils.lc import get_land_cover_classes
179
+ class_dict = get_land_cover_classes(land_cover_source)
180
+ class_names = list(class_dict.values())
181
+ except Exception:
182
+ # Fallback: no class list; return empty so default is used
183
+ class_names = []
184
+
185
+ name_to_code = _get_source_name_mapping(land_cover_source)
186
+ index_to_code = {}
187
+ for idx, class_name in enumerate(class_names):
188
+ index_to_code[idx] = name_to_code.get(class_name, 4)
189
+ return index_to_code, class_names
190
+
191
+
192
+ def _resolve_under_tree_code(under_tree_class_name, under_tree_cityles_code, land_cover_source):
193
+ """Resolve the CityLES land-use code used under tree canopy.
194
+
195
+ Priority:
196
+ 1) Explicit numeric code if provided
197
+ 2) Class name using the source-specific mapping
198
+ 3) Class name using the standard (OSM) mapping
199
+ 4) Default to 9 (Bare Land)
200
+ """
201
+ if under_tree_cityles_code is not None:
202
+ try:
203
+ return int(under_tree_cityles_code)
204
+ except Exception:
205
+ pass
206
+ name_to_code = _get_source_name_mapping(land_cover_source)
207
+ code = name_to_code.get(under_tree_class_name)
208
+ if code is None:
209
+ code = OSM_CLASS_TO_CITYLES.get(under_tree_class_name, 9)
210
+ return code
211
+
212
+
213
+ def export_topog(building_height_grid, building_id_grid, output_path,
214
+ building_material='default', cityles_landuse_grid=None):
215
+ """
216
+ Export topog.txt file for CityLES
217
+
218
+ Parameters:
219
+ -----------
220
+ building_height_grid : numpy.ndarray
221
+ 2D array of building heights
222
+ building_id_grid : numpy.ndarray
223
+ 2D array of building IDs
224
+ output_path : Path
225
+ Output directory path
226
+ building_material : str
227
+ Building material type for mapping
228
+ """
229
+ filename = output_path / 'topog.txt'
230
+
231
+ ny, nx = building_height_grid.shape
232
+ material_code = BUILDING_MATERIAL_MAPPING.get(building_material,
233
+ BUILDING_MATERIAL_MAPPING['default'])
234
+
235
+ # Count only cells with building height > 0
236
+ building_mask = building_height_grid > 0
237
+ n_buildings = int(np.count_nonzero(building_mask))
238
+
239
+ with open(filename, 'w') as f:
240
+ # Write number of buildings
241
+ f.write(f"{n_buildings}\n")
242
+
243
+ # Write data for ALL grid points (buildings and non-buildings)
244
+ for j in range(ny):
245
+ for i in range(nx):
246
+ # CityLES uses 1-based indexing
247
+ i_1based = i + 1
248
+ j_1based = j + 1
249
+ height = float(building_height_grid[j, i])
250
+ # Decide material code per cell
251
+ if cityles_landuse_grid is not None:
252
+ cell_lu = int(cityles_landuse_grid[j, i])
253
+ material_code_cell = cell_lu + 100
254
+ else:
255
+ if height > 0:
256
+ material_code_cell = material_code
257
+ else:
258
+ material_code_cell = 102
259
+ # Format: i j height material_code depth1 depth2 changed_material
260
+ f.write(f"{i_1based} {j_1based} {height:.1f} {material_code_cell} 0.0 0.0 102\n")
261
+
262
+
263
+ def export_landuse(land_cover_grid, output_path, land_cover_source=None,
264
+ canopy_height_grid=None, building_height_grid=None,
265
+ under_tree_class_name='Bareland', under_tree_cityles_code=None):
266
+ """
267
+ Export landuse.txt file for CityLES
268
+
269
+ Parameters:
270
+ -----------
271
+ land_cover_grid : numpy.ndarray
272
+ 2D array of land cover values (may be raw or converted)
273
+ output_path : Path
274
+ Output directory path
275
+ land_cover_source : str, optional
276
+ Source of land cover data
277
+ canopy_height_grid : numpy.ndarray, optional
278
+ 2D array of canopy heights; if provided, cells with canopy (>0) will be
279
+ assigned the ground class under the canopy instead of a tree class.
280
+ building_height_grid : numpy.ndarray, optional
281
+ 2D array of building heights; if provided, canopy overrides will not be
282
+ applied where buildings exist (height > 0).
283
+ under_tree_class_name : str, optional
284
+ Name of ground land-cover class to use under tree canopy. Defaults to 'Bareland'.
285
+ under_tree_cityles_code : int, optional
286
+ Explicit CityLES land-use code to use under canopy; if provided it takes
287
+ precedence over under_tree_class_name.
288
+ """
289
+ filename = output_path / 'landuse.txt'
290
+
291
+ ny, nx = land_cover_grid.shape
292
+
293
+ # Build per-source index mapping
294
+ index_to_code, class_names = _build_index_to_cityles_map(land_cover_source)
295
+
296
+ print(f"Land cover source: {land_cover_source} (raw indices)")
297
+
298
+ # Resolve the CityLES code to use under tree canopy
299
+ under_tree_code = _resolve_under_tree_code(
300
+ under_tree_class_name, under_tree_cityles_code, land_cover_source
301
+ )
302
+
303
+ # Create mapping statistics: per raw index, count per resulting CityLES code
304
+ mapping_stats = {}
305
+ # Prepare grid to return
306
+ cityles_landuse_grid = np.zeros((ny, nx), dtype=int)
307
+
308
+ with open(filename, 'w') as f:
309
+ # Write in row-major order (j varies first, then i)
310
+ for j in range(ny):
311
+ for i in range(nx):
312
+ idx = int(land_cover_grid[j, i])
313
+ cityles_code = index_to_code.get(idx, 4)
314
+
315
+ # If a canopy grid is provided, override tree canopy cells to the
316
+ # specified ground class, optionally skipping where buildings exist.
317
+ if canopy_height_grid is not None:
318
+ has_canopy = float(canopy_height_grid[j, i]) > 0.0
319
+ has_building = False
320
+ if building_height_grid is not None:
321
+ has_building = float(building_height_grid[j, i]) > 0.0
322
+ if has_canopy and not has_building:
323
+ cityles_code = under_tree_code
324
+ f.write(f"{cityles_code}\n")
325
+
326
+ cityles_landuse_grid[j, i] = cityles_code
327
+
328
+ # Track mapping statistics
329
+ if idx not in mapping_stats:
330
+ mapping_stats[idx] = {}
331
+ mapping_stats[idx][cityles_code] = mapping_stats[idx].get(cityles_code, 0) + 1
332
+
333
+ # Print mapping summary
334
+ print("\nLand cover mapping summary (by source class):")
335
+ total = ny * nx
336
+ for idx in sorted(mapping_stats.keys()):
337
+ class_name = class_names[idx] if 0 <= idx < len(class_names) else 'Unknown'
338
+ for code, count in sorted(mapping_stats[idx].items()):
339
+ percentage = (count / total) * 100
340
+ print(f" {idx}: {class_name} -> CityLES {code}: {count} cells ({percentage:.1f}%)")
341
+
342
+ return cityles_landuse_grid
343
+
344
+
345
+ def export_dem(dem_grid, output_path):
346
+ """
347
+ Export dem.txt file for CityLES
348
+
349
+ Parameters:
350
+ -----------
351
+ dem_grid : numpy.ndarray
352
+ 2D array of elevation values
353
+ output_path : Path
354
+ Output directory path
355
+ """
356
+ filename = output_path / 'dem.txt'
357
+
358
+ ny, nx = dem_grid.shape
359
+
360
+ with open(filename, 'w') as f:
361
+ for j in range(ny):
362
+ for i in range(nx):
363
+ # CityLES uses 1-based indexing
364
+ i_1based = i + 1
365
+ j_1based = j + 1
366
+ elevation = float(dem_grid[j, i])
367
+ # Clamp negative elevations to 0.0 meters
368
+ if elevation < 0.0:
369
+ elevation = 0.0
370
+ f.write(f"{i_1based} {j_1based} {elevation:.1f}\n")
371
+
372
+
373
+ def export_vmap(canopy_height_grid, output_path, tree_base_ratio=0.3, tree_type='default', building_height_grid=None, canopy_bottom_height_grid=None):
374
+ """
375
+ Export vmap.txt file for CityLES
376
+
377
+ Parameters:
378
+ -----------
379
+ canopy_height_grid : numpy.ndarray
380
+ 2D array of canopy heights
381
+ output_path : Path
382
+ Output directory path
383
+ tree_base_ratio : float
384
+ Ratio of tree base height to total canopy height
385
+ tree_type : str
386
+ Tree type for mapping
387
+ """
388
+ filename = output_path / 'vmap.txt'
389
+
390
+ ny, nx = canopy_height_grid.shape
391
+ tree_code = TREE_TYPE_MAPPING.get(tree_type, TREE_TYPE_MAPPING['default'])
392
+
393
+ # If building heights are provided, remove trees where buildings exist
394
+ if building_height_grid is not None:
395
+ effective_canopy = np.where(building_height_grid > 0, 0.0, canopy_height_grid)
396
+ else:
397
+ effective_canopy = canopy_height_grid
398
+
399
+ # Count only cells with canopy height > 0
400
+ vegetation_mask = effective_canopy > 0
401
+ n_trees = int(np.count_nonzero(vegetation_mask))
402
+
403
+ with open(filename, 'w') as f:
404
+ # Write number of trees
405
+ f.write(f"{n_trees}\n")
406
+
407
+ # Write data for ALL grid points (vegetation and non-vegetation)
408
+ for j in range(ny):
409
+ for i in range(nx):
410
+ # CityLES uses 1-based indexing
411
+ i_1based = i + 1
412
+ j_1based = j + 1
413
+ total_height = float(effective_canopy[j, i])
414
+ if canopy_bottom_height_grid is not None:
415
+ lower_height = float(np.clip(canopy_bottom_height_grid[j, i], 0.0, total_height))
416
+ else:
417
+ lower_height = total_height * tree_base_ratio
418
+ upper_height = total_height
419
+ # Format: i j lower_height upper_height tree_type
420
+ f.write(f"{i_1based} {j_1based} {lower_height:.1f} {upper_height:.1f} {tree_code}\n")
421
+
422
+
423
+ def export_lonlat(rectangle_vertices, grid_shape, output_path):
424
+ """
425
+ Export lonlat.txt file for CityLES
426
+
427
+ Parameters:
428
+ -----------
429
+ rectangle_vertices : list of tuples
430
+ List of (lon, lat) vertices defining the area
431
+ grid_shape : tuple
432
+ Shape of the grid (ny, nx)
433
+ output_path : Path
434
+ Output directory path
435
+ """
436
+ filename = output_path / 'lonlat.txt'
437
+
438
+ ny, nx = grid_shape
439
+
440
+ # Extract bounds from vertices
441
+ lons = [v[0] for v in rectangle_vertices]
442
+ lats = [v[1] for v in rectangle_vertices]
443
+ min_lon, max_lon = min(lons), max(lons)
444
+ min_lat, max_lat = min(lats), max(lats)
445
+
446
+ # Create coordinate grids
447
+ lon_vals = np.linspace(min_lon, max_lon, nx)
448
+ lat_vals = np.linspace(min_lat, max_lat, ny)
449
+
450
+ with open(filename, 'w') as f:
451
+ for j in range(ny):
452
+ for i in range(nx):
453
+ # CityLES uses 1-based indexing
454
+ i_1based = i + 1
455
+ j_1based = j + 1
456
+ lon = lon_vals[i]
457
+ lat = lat_vals[j]
458
+
459
+ # Note: Format is i j longitude latitude (not latitude longitude)
460
+ f.write(f"{i_1based} {j_1based} {lon:.7f} {lat:.8f}\n")
461
+
462
+
463
+ def export_cityles(building_height_grid, building_id_grid, canopy_height_grid,
464
+ land_cover_grid, dem_grid, meshsize, land_cover_source,
465
+ rectangle_vertices, output_directory="output/cityles",
466
+ building_material='default', tree_type='default',
467
+ tree_base_ratio=0.3, canopy_bottom_height_grid=None,
468
+ under_tree_class_name='Bareland', under_tree_cityles_code=None,
469
+ **kwargs):
470
+ """
471
+ Export VoxCity data to CityLES format
472
+
473
+ Parameters:
474
+ -----------
475
+ building_height_grid : numpy.ndarray
476
+ 2D array of building heights
477
+ building_id_grid : numpy.ndarray
478
+ 2D array of building IDs
479
+ canopy_height_grid : numpy.ndarray
480
+ 2D array of canopy heights
481
+ land_cover_grid : numpy.ndarray
482
+ 2D array of land cover values (may be raw or VoxCity standard)
483
+ dem_grid : numpy.ndarray
484
+ 2D array of elevation values
485
+ meshsize : float
486
+ Grid cell size in meters
487
+ land_cover_source : str
488
+ Source of land cover data (e.g., 'ESRI 10m Annual Land Cover', 'ESA WorldCover')
489
+ rectangle_vertices : list of tuples
490
+ List of (lon, lat) vertices defining the area
491
+ output_directory : str
492
+ Output directory path
493
+ building_material : str
494
+ Building material type for mapping
495
+ tree_type : str
496
+ Tree type for mapping
497
+ tree_base_ratio : float
498
+ Ratio of tree base height to total canopy height
499
+ **kwargs : dict
500
+ Additional parameters (for compatibility)
501
+
502
+ Returns:
503
+ --------
504
+ str : Path to output directory
505
+ """
506
+ # Create output directory
507
+ output_path = create_cityles_directories(output_directory)
508
+
509
+ print(f"Exporting CityLES files to: {output_path}")
510
+ print(f"Land cover source: {land_cover_source}")
511
+
512
+ # Export individual files
513
+ print("\nExporting landuse.txt...")
514
+ cityles_landuse_grid = export_landuse(
515
+ land_cover_grid,
516
+ output_path,
517
+ land_cover_source,
518
+ canopy_height_grid=canopy_height_grid,
519
+ building_height_grid=building_height_grid,
520
+ under_tree_class_name=under_tree_class_name,
521
+ under_tree_cityles_code=under_tree_cityles_code,
522
+ )
523
+
524
+ print("\nExporting topog.txt...")
525
+ export_topog(
526
+ building_height_grid,
527
+ building_id_grid,
528
+ output_path,
529
+ building_material,
530
+ cityles_landuse_grid=cityles_landuse_grid,
531
+ )
532
+
533
+ print("\nExporting dem.txt...")
534
+ export_dem(dem_grid, output_path)
535
+
536
+ print("\nExporting vmap.txt...")
537
+ export_vmap(canopy_height_grid, output_path, tree_base_ratio, tree_type, building_height_grid=building_height_grid, canopy_bottom_height_grid=canopy_bottom_height_grid)
538
+
539
+ print("\nExporting lonlat.txt...")
540
+ export_lonlat(rectangle_vertices, building_height_grid.shape, output_path)
541
+
542
+ # Create metadata file for reference
543
+ metadata_file = output_path / 'cityles_metadata.txt'
544
+ with open(metadata_file, 'w') as f:
545
+ f.write("CityLES Export Metadata\n")
546
+ f.write("====================\n")
547
+ f.write(f"Export date: 2025/08/05\n")
548
+ f.write(f"Grid shape: {building_height_grid.shape}\n")
549
+ f.write(f"Mesh size: {meshsize} m\n")
550
+ f.write(f"Land cover source: {land_cover_source}\n")
551
+ f.write(f"Building material: {building_material}\n")
552
+ f.write(f"Tree type: {tree_type}\n")
553
+ f.write(f"Bounds: {rectangle_vertices}\n")
554
+ f.write(f"Buildings: {np.sum(building_height_grid > 0)}\n")
555
+ # Trees count after removing overlaps with buildings
556
+ trees_count = int(np.sum(np.where(building_height_grid > 0, 0.0, canopy_height_grid) > 0))
557
+ f.write(f"Trees: {trees_count}\n")
558
+ # Under-tree land-use selection
559
+ under_tree_code = _resolve_under_tree_code(
560
+ under_tree_class_name, under_tree_cityles_code, land_cover_source
561
+ )
562
+ f.write(f"Under-tree land use: {under_tree_class_name} (CityLES {under_tree_code})\n")
563
+
564
+ # Add land use value ranges
565
+ f.write(f"\nLand cover value range: {land_cover_grid.min()} - {land_cover_grid.max()}\n")
566
+ unique_values = np.unique(land_cover_grid)
567
+ f.write(f"Unique land cover values: {unique_values}\n")
568
+
569
+ print(f"\nCityLES export completed successfully!")
570
+ return str(output_path)
571
+
572
+
573
+ # Helper function to apply VoxCity's convert_land_cover if needed
574
+ def ensure_converted_land_cover(land_cover_grid, land_cover_source):
575
+ """
576
+ Ensure land cover grid uses VoxCity standard indices
577
+
578
+ This function checks if the land cover data needs conversion and applies
579
+ VoxCity's convert_land_cover function if necessary.
580
+
581
+ Parameters:
582
+ -----------
583
+ land_cover_grid : numpy.ndarray
584
+ 2D array of land cover values
585
+ land_cover_source : str
586
+ Source of land cover data
587
+
588
+ Returns:
589
+ --------
590
+ numpy.ndarray : Land cover grid with VoxCity standard indices (0-13)
591
+ """
592
+ # Import VoxCity's convert function if available
593
+ try:
594
+ from voxcity.utils.lc import convert_land_cover
595
+
596
+ # Apply conversion
597
+ converted_grid = convert_land_cover(land_cover_grid, land_cover_source)
598
+ print(f"Applied VoxCity land cover conversion for {land_cover_source}")
599
+ return converted_grid
600
+ except ImportError:
601
+ print("Warning: Could not import VoxCity land cover utilities. Using direct mapping.")
539
602
  return land_cover_grid