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

voxcity/generator.py CHANGED
@@ -1,1137 +1,1292 @@
1
- """Main module for voxcity.
2
-
3
- This module provides functions to generate 3D voxel representations of cities using various data sources.
4
- It handles land cover, building heights, canopy heights, and digital elevation models to create detailed
5
- 3D city models.
6
-
7
- The main functions are:
8
- - get_land_cover_grid: Creates a grid of land cover classifications
9
- - get_building_height_grid: Creates a grid of building heights (supports GeoDataFrame input)
10
- - get_canopy_height_grid: Creates a grid of tree canopy heights
11
- - get_dem_grid: Creates a digital elevation model grid
12
- - create_3d_voxel: Combines the grids into a 3D voxel representation
13
- - create_3d_voxel_individuals: Creates separate voxel grids for each component
14
- - get_voxcity: Main function to generate a complete voxel city model (supports GeoDataFrame input)
15
-
16
- Key Features:
17
- - Support for multiple data sources (OpenStreetMap, ESA WorldCover, Google Earth Engine, etc.)
18
- - Direct GeoDataFrame input for building data (useful for custom datasets)
19
- - 3D voxel generation with configurable resolution
20
- - Visualization capabilities for both 2D grids and 3D models
21
- - Data export in various formats (GeoTIFF, GeoJSON, pickle)
22
- """
23
-
24
- # Standard library imports
25
- import numpy as np
26
- import os
27
- try:
28
- from numba import jit, prange
29
- import numba
30
- NUMBA_AVAILABLE = True
31
- except ImportError:
32
- NUMBA_AVAILABLE = False
33
- print("Numba not available. Using optimized version without JIT compilation.")
34
- # Define dummy decorators
35
- def jit(*args, **kwargs):
36
- def decorator(func):
37
- return func
38
- return decorator
39
- prange = range
40
-
41
- # Local application/library specific imports
42
-
43
- # Data downloaders - modules for fetching geospatial data from various sources
44
- from .downloader.mbfp import get_mbfp_gdf
45
- from .downloader.osm import load_gdf_from_openstreetmap, load_land_cover_gdf_from_osm
46
- from .downloader.oemj import save_oemj_as_geotiff
47
- # from .downloader.omt import load_gdf_from_openmaptiles
48
- from .downloader.eubucco import load_gdf_from_eubucco
49
- from .downloader.overture import load_gdf_from_overture
50
- from .downloader.citygml import load_buid_dem_veg_from_citygml
51
-
52
- # Google Earth Engine related imports - for satellite and elevation data
53
- from .downloader.gee import (
54
- initialize_earth_engine,
55
- get_roi,
56
- get_ee_image_collection,
57
- get_ee_image,
58
- save_geotiff,
59
- get_dem_image,
60
- save_geotiff_esa_land_cover,
61
- save_geotiff_esri_landcover,
62
- save_geotiff_dynamic_world_v1,
63
- save_geotiff_open_buildings_temporal,
64
- save_geotiff_dsm_minus_dtm
65
- )
66
-
67
- # Grid processing functions - for converting geodata to raster grids
68
- from .geoprocessor.grid import (
69
- group_and_label_cells,
70
- process_grid,
71
- create_land_cover_grid_from_geotiff_polygon,
72
- create_height_grid_from_geotiff_polygon,
73
- create_building_height_grid_from_gdf_polygon,
74
- create_dem_grid_from_geotiff_polygon,
75
- create_land_cover_grid_from_gdf_polygon,
76
- create_building_height_grid_from_open_building_temporal_polygon,
77
- create_vegetation_height_grid_from_gdf_polygon,
78
- create_dem_grid_from_gdf_polygon
79
- )
80
-
81
- # Utility functions
82
- from .utils.lc import convert_land_cover, convert_land_cover_array
83
- from .geoprocessor.polygon import get_gdf_from_gpkg, save_geojson
84
-
85
- # Visualization functions - for creating plots and 3D visualizations
86
- from .utils.visualization import (
87
- get_land_cover_classes,
88
- visualize_land_cover_grid,
89
- visualize_numerical_grid,
90
- # visualize_land_cover_grid_on_map,
91
- # visualize_numerical_grid_on_map,
92
- # visualize_building_height_grid_on_map,
93
- visualize_3d_voxel,
94
- visualize_landcover_grid_on_basemap,
95
- visualize_numerical_grid_on_basemap,
96
- )
97
-
98
- def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
99
- """Creates a grid of land cover classifications.
100
-
101
- Args:
102
- rectangle_vertices: List of coordinates defining the area of interest
103
- meshsize: Size of each grid cell in meters
104
- source: Data source for land cover (e.g. 'ESA WorldCover', 'OpenStreetMap')
105
- output_dir: Directory to save output files
106
- **kwargs: Additional arguments including:
107
- - esri_landcover_year: Year for ESRI land cover data
108
- - dynamic_world_date: Date for Dynamic World data
109
- - gridvis: Whether to visualize the grid
110
- - default_land_cover_class: Default class for grid cells with no intersecting polygons (default: 'Developed space')
111
-
112
- Returns:
113
- numpy.ndarray: Grid of land cover classifications as integer values
114
- """
115
-
116
- print("Creating Land Use Land Cover grid\n ")
117
- print(f"Data source: {source}")
118
-
119
- # Initialize Earth Engine for satellite-based data sources
120
- # Skip initialization for local/vector data sources
121
- if source not in ["OpenStreetMap", "OpenEarthMapJapan"]:
122
- initialize_earth_engine()
123
-
124
- # Ensure output directory exists for saving intermediate files
125
- os.makedirs(output_dir, exist_ok=True)
126
- geotiff_path = os.path.join(output_dir, "land_cover.tif")
127
-
128
- # Handle different data sources - each requires specific processing
129
- # Satellite/raster-based sources are saved as GeoTIFF files
130
- if source == 'Urbanwatch':
131
- # Urban-focused land cover from satellite imagery
132
- roi = get_roi(rectangle_vertices)
133
- collection_name = "projects/sat-io/open-datasets/HRLC/urban-watch-cities"
134
- image = get_ee_image_collection(collection_name, roi)
135
- save_geotiff(image, geotiff_path)
136
- elif source == 'ESA WorldCover':
137
- # Global land cover from European Space Agency
138
- roi = get_roi(rectangle_vertices)
139
- save_geotiff_esa_land_cover(roi, geotiff_path)
140
- elif source == 'ESRI 10m Annual Land Cover':
141
- # High-resolution annual land cover from ESRI
142
- esri_landcover_year = kwargs.get("esri_landcover_year")
143
- roi = get_roi(rectangle_vertices)
144
- save_geotiff_esri_landcover(roi, geotiff_path, year=esri_landcover_year)
145
- elif source == 'Dynamic World V1':
146
- # Near real-time land cover from Google's Dynamic World
147
- dynamic_world_date = kwargs.get("dynamic_world_date")
148
- roi = get_roi(rectangle_vertices)
149
- save_geotiff_dynamic_world_v1(roi, geotiff_path, dynamic_world_date)
150
- elif source == 'OpenEarthMapJapan':
151
- # Japan-specific land cover dataset
152
- save_oemj_as_geotiff(rectangle_vertices, geotiff_path)
153
- elif source == 'OpenStreetMap':
154
- # Vector-based land cover from OpenStreetMap
155
- # This bypasses the GeoTIFF workflow and gets data directly as GeoJSON
156
- land_cover_gdf = load_land_cover_gdf_from_osm(rectangle_vertices)
157
-
158
- # Get the classification scheme for the selected data source
159
- # Each source has its own land cover categories and color coding
160
- land_cover_classes = get_land_cover_classes(source)
161
-
162
- # Convert geospatial data to regular grid format
163
- # Different processing for vector vs raster data sources
164
- if source == 'OpenStreetMap':
165
- # Process vector data directly from GeoDataFrame
166
- default_class = kwargs.get('default_land_cover_class', 'Developed space')
167
- land_cover_grid_str = create_land_cover_grid_from_gdf_polygon(land_cover_gdf, meshsize, source, rectangle_vertices, default_class=default_class)
168
- else:
169
- # Process raster data from GeoTIFF file
170
- land_cover_grid_str = create_land_cover_grid_from_geotiff_polygon(geotiff_path, meshsize, land_cover_classes, rectangle_vertices)
171
-
172
- # Prepare color mapping for visualization
173
- # Convert RGB values from 0-255 range to 0-1 range for matplotlib
174
- color_map = {cls: [r/255, g/255, b/255] for (r,g,b), cls in land_cover_classes.items()}
175
-
176
- # Generate visualization if requested
177
- grid_vis = kwargs.get("gridvis", True)
178
- if grid_vis:
179
- # Flip grid vertically for correct display orientation
180
- visualize_land_cover_grid(np.flipud(land_cover_grid_str), meshsize, color_map, land_cover_classes)
181
-
182
- # Convert string-based land cover labels to integer codes for processing
183
- # This enables efficient numerical operations on the grid
184
- land_cover_grid_int = convert_land_cover_array(land_cover_grid_str, land_cover_classes)
185
-
186
- return land_cover_grid_int
187
-
188
- # def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir="output", visualization=True, maptiler_API_key=None, file_path=None):
189
- def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, building_gdf=None, **kwargs):
190
- """Creates a grid of building heights.
191
-
192
- Args:
193
- rectangle_vertices: List of coordinates defining the area of interest
194
- meshsize: Size of each grid cell in meters
195
- source: Data source for buildings (e.g. 'OpenStreetMap', 'Microsoft Building Footprints', 'GeoDataFrame')
196
- output_dir: Directory to save output files
197
- building_gdf: Optional GeoDataFrame with building footprint, height and other information
198
- **kwargs: Additional arguments including:
199
- - maptiler_API_key: API key for MapTiler
200
- - building_path: Path to local building data file
201
- - building_complementary_source: Additional building data source
202
- - gridvis: Whether to visualize the grid
203
-
204
- Returns:
205
- tuple:
206
- - numpy.ndarray: Grid of building heights
207
- - numpy.ndarray: Grid of building minimum heights
208
- - numpy.ndarray: Grid of building IDs
209
- - list: Filtered building features
210
- """
211
-
212
- # Initialize Earth Engine for satellite-based building data sources
213
- if source not in ["OpenStreetMap", "Overture", "Local file", "GeoDataFrame"]:
214
- initialize_earth_engine()
215
-
216
- print("Creating Building Height grid\n ")
217
- print(f"Data source: {source}")
218
-
219
- os.makedirs(output_dir, exist_ok=True)
220
-
221
- # If building_gdf is provided, use it directly
222
- if building_gdf is not None:
223
- gdf = building_gdf
224
- print("Using provided GeoDataFrame for building data")
225
- else:
226
- # Fetch building data from primary source
227
- # Each source has different data formats and processing requirements
228
- if source == 'Microsoft Building Footprints':
229
- # Machine learning-derived building footprints from satellite imagery
230
- gdf = get_mbfp_gdf(output_dir, rectangle_vertices)
231
- elif source == 'OpenStreetMap':
232
- # Crowd-sourced building data with varying completeness
233
- gdf = load_gdf_from_openstreetmap(rectangle_vertices)
234
- elif source == "Open Building 2.5D Temporal":
235
- # Special case: this source provides both footprints and heights
236
- # Skip GeoDataFrame processing and create grids directly
237
- building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_open_building_temporal_polygon(meshsize, rectangle_vertices, output_dir)
238
- elif source == 'EUBUCCO v0.1':
239
- # European building database with height information
240
- gdf = load_gdf_from_eubucco(rectangle_vertices, output_dir)
241
- # elif source == "OpenMapTiles":
242
- # # Vector tiles service for building data
243
- # gdf = load_gdf_from_openmaptiles(rectangle_vertices, kwargs["maptiler_API_key"])
244
- elif source == "Overture":
245
- # Open building dataset from Overture Maps Foundation
246
- gdf = load_gdf_from_overture(rectangle_vertices)
247
- elif source == "Local file":
248
- # Handle user-provided local building data files
249
- _, extension = os.path.splitext(kwargs["building_path"])
250
- if extension == ".gpkg":
251
- gdf = get_gdf_from_gpkg(kwargs["building_path"], rectangle_vertices)
252
- elif source == "GeoDataFrame":
253
- # This case is handled by the building_gdf parameter above
254
- raise ValueError("When source is 'GeoDataFrame', building_gdf parameter must be provided")
255
-
256
- # Handle complementary data sources to fill gaps or provide additional information
257
- # This allows combining multiple sources for better coverage or accuracy
258
- building_complementary_source = kwargs.get("building_complementary_source")
259
- building_complement_height = kwargs.get("building_complement_height")
260
- overlapping_footprint = kwargs.get("overlapping_footprint")
261
-
262
- if (building_complementary_source is None) or (building_complementary_source=='None'):
263
- # Use only the primary data source
264
- if source != "Open Building 2.5D Temporal":
265
- building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
266
- else:
267
- # Combine primary source with complementary data
268
- if building_complementary_source == "Open Building 2.5D Temporal":
269
- # Use temporal height data to complement footprint data
270
- roi = get_roi(rectangle_vertices)
271
- os.makedirs(output_dir, exist_ok=True)
272
- geotiff_path_comp = os.path.join(output_dir, "building_height.tif")
273
- save_geotiff_open_buildings_temporal(roi, geotiff_path_comp)
274
- building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, geotiff_path_comp=geotiff_path_comp, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
275
- elif building_complementary_source in ["England 1m DSM - DTM", "Netherlands 0.5m DSM - DTM"]:
276
- # Use digital surface model minus digital terrain model for height estimation
277
- roi = get_roi(rectangle_vertices)
278
- os.makedirs(output_dir, exist_ok=True)
279
- geotiff_path_comp = os.path.join(output_dir, "building_height.tif")
280
- save_geotiff_dsm_minus_dtm(roi, geotiff_path_comp, meshsize, building_complementary_source)
281
- building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, geotiff_path_comp=geotiff_path_comp, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
282
- else:
283
- # Fetch complementary data from another vector source
284
- if building_complementary_source == 'Microsoft Building Footprints':
285
- gdf_comp = get_mbfp_gdf(output_dir, rectangle_vertices)
286
- elif building_complementary_source == 'OpenStreetMap':
287
- gdf_comp = load_gdf_from_openstreetmap(rectangle_vertices)
288
- elif building_complementary_source == 'EUBUCCO v0.1':
289
- gdf_comp = load_gdf_from_eubucco(rectangle_vertices, output_dir)
290
- # elif building_complementary_source == "OpenMapTiles":
291
- # gdf_comp = load_gdf_from_openmaptiles(rectangle_vertices, kwargs["maptiler_API_key"])
292
- elif building_complementary_source == "Overture":
293
- gdf_comp = load_gdf_from_overture(rectangle_vertices)
294
- elif building_complementary_source == "Local file":
295
- _, extension = os.path.splitext(kwargs["building_complementary_path"])
296
- if extension == ".gpkg":
297
- gdf_comp = get_gdf_from_gpkg(kwargs["building_complementary_path"], rectangle_vertices)
298
-
299
- # Configure how to combine the complementary data
300
- # Can complement footprints only or both footprints and heights
301
- complement_building_footprints = kwargs.get("complement_building_footprints")
302
- building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, gdf_comp=gdf_comp, complement_building_footprints=complement_building_footprints, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
303
-
304
- # Generate visualization if requested
305
- grid_vis = kwargs.get("gridvis", True)
306
- if grid_vis:
307
- # Replace zeros with NaN for better visualization (don't show empty areas)
308
- building_height_grid_nan = building_height_grid.copy()
309
- building_height_grid_nan[building_height_grid_nan == 0] = np.nan
310
- # Flip grid vertically for correct display orientation
311
- visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
312
-
313
- return building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings
314
-
315
- def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
316
- """Creates a grid of tree canopy heights.
317
-
318
- Args:
319
- rectangle_vertices: List of coordinates defining the area of interest
320
- meshsize: Size of each grid cell in meters
321
- source: Data source for canopy heights
322
- output_dir: Directory to save output files
323
- **kwargs: Additional arguments including:
324
- - gridvis: Whether to visualize the grid
325
-
326
- Returns:
327
- numpy.ndarray: Grid of canopy heights
328
- """
329
-
330
- print("Creating Canopy Height grid\n ")
331
- print(f"Data source: High Resolution Canopy Height Maps by WRI and Meta")
332
-
333
- # Initialize Earth Engine for satellite-based canopy height data
334
- initialize_earth_engine()
335
-
336
- os.makedirs(output_dir, exist_ok=True)
337
- geotiff_path = os.path.join(output_dir, "canopy_height.tif")
338
-
339
- # Get region of interest and fetch canopy height data
340
- roi = get_roi(rectangle_vertices)
341
- if source == 'High Resolution 1m Global Canopy Height Maps':
342
- # High-resolution (1m) global canopy height maps from Meta and WRI
343
- # Based on satellite imagery and machine learning models
344
- collection_name = "projects/meta-forest-monitoring-okw37/assets/CanopyHeight"
345
- image = get_ee_image_collection(collection_name, roi)
346
- elif source == 'ETH Global Sentinel-2 10m Canopy Height (2020)':
347
- # Medium-resolution (10m) canopy height from ETH Zurich
348
- # Derived from Sentinel-2 satellite data
349
- collection_name = "users/nlang/ETH_GlobalCanopyHeight_2020_10m_v1"
350
- image = get_ee_image(collection_name, roi)
351
-
352
- # Save canopy height data as GeoTIFF with specified resolution
353
- save_geotiff(image, geotiff_path, resolution=meshsize)
354
-
355
- # Convert GeoTIFF to regular grid format
356
- canopy_height_grid = create_height_grid_from_geotiff_polygon(geotiff_path, meshsize, rectangle_vertices)
357
-
358
- # Generate visualization if requested
359
- grid_vis = kwargs.get("gridvis", True)
360
- if grid_vis:
361
- # Replace zeros with NaN for better visualization (show only areas with trees)
362
- canopy_height_grid_nan = canopy_height_grid.copy()
363
- canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
364
- # Use green color scheme appropriate for vegetation
365
- visualize_numerical_grid(np.flipud(canopy_height_grid_nan), meshsize, "Tree canopy height", cmap='Greens', label='Tree canopy height (m)')
366
-
367
- return canopy_height_grid
368
-
369
- def get_dem_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
370
- """Creates a digital elevation model grid.
371
-
372
- Args:
373
- rectangle_vertices: List of coordinates defining the area of interest
374
- meshsize: Size of each grid cell in meters
375
- source: Data source for DEM
376
- output_dir: Directory to save output files
377
- **kwargs: Additional arguments including:
378
- - dem_interpolation: Interpolation method for DEM
379
- - gridvis: Whether to visualize the grid
380
-
381
- Returns:
382
- numpy.ndarray: Grid of elevation values
383
- """
384
-
385
- print("Creating Digital Elevation Model (DEM) grid\n ")
386
- print(f"Data source: {source}")
387
-
388
- if source == "Local file":
389
- # Use user-provided local DEM file
390
- geotiff_path = kwargs["dem_path"]
391
- else:
392
- # Fetch DEM data from various satellite/government sources
393
- initialize_earth_engine()
394
-
395
- geotiff_path = os.path.join(output_dir, "dem.tif")
396
-
397
- # Add buffer around region of interest to ensure smooth interpolation at edges
398
- # This prevents edge artifacts in the final grid
399
- buffer_distance = 100
400
- roi = get_roi(rectangle_vertices)
401
- roi_buffered = roi.buffer(buffer_distance)
402
-
403
- # Fetch elevation data from selected source
404
- image = get_dem_image(roi_buffered, source)
405
-
406
- # Save DEM data with appropriate resolution based on source capabilities
407
- if source in ["England 1m DTM", 'DEM France 1m', 'DEM France 5m', 'AUSTRALIA 5M DEM', 'Netherlands 0.5m DTM']:
408
- # High-resolution elevation models - use specified mesh size
409
- save_geotiff(image, geotiff_path, scale=meshsize, region=roi_buffered, crs='EPSG:4326')
410
- elif source == 'USGS 3DEP 1m':
411
- # US Geological Survey 3D Elevation Program
412
- # Ensure minimum scale of 1.25m due to data limitations
413
- scale = max(meshsize, 1.25)
414
- save_geotiff(image, geotiff_path, scale=scale, region=roi_buffered, crs='EPSG:4326')
415
- else:
416
- # Default to 30m resolution for global/lower resolution sources
417
- save_geotiff(image, geotiff_path, scale=30, region=roi_buffered)
418
-
419
- # Convert GeoTIFF to regular grid with optional interpolation
420
- # Interpolation helps fill gaps and smooth transitions
421
- dem_interpolation = kwargs.get("dem_interpolation")
422
- dem_grid = create_dem_grid_from_geotiff_polygon(geotiff_path, meshsize, rectangle_vertices, dem_interpolation=dem_interpolation)
423
-
424
- # Generate visualization if requested
425
- grid_vis = kwargs.get("gridvis", True)
426
- if grid_vis:
427
- # Use terrain color scheme appropriate for elevation data
428
- visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
429
-
430
- return dem_grid
431
-
432
- def create_3d_voxel(building_height_grid_ori, building_min_height_grid_ori,
433
- building_id_grid_ori, land_cover_grid_ori, dem_grid_ori,
434
- tree_grid_ori, voxel_size, land_cover_source, **kwargs):
435
- """Creates a 3D voxel representation combining all input grids.
436
- Args:
437
- building_height_grid_ori: Grid of building heights
438
- building_min_height_grid_ori: Grid of building minimum heights
439
- building_id_grid_ori: Grid of building IDs
440
- land_cover_grid_ori: Grid of land cover classifications
441
- dem_grid_ori: Grid of elevation values
442
- tree_grid_ori: Grid of tree heights
443
- voxel_size: Size of each voxel in meters
444
- land_cover_source: Source of land cover data
445
- kwargs: Additional arguments including:
446
- - trunk_height_ratio: Ratio of trunk height to total tree height
447
- Returns:
448
- numpy.ndarray: 3D voxel grid with encoded values for different features
449
- """
450
- print("Generating 3D voxel data")
451
-
452
- # Convert land cover values to standardized format if needed
453
- # OpenStreetMap data is already in the correct format
454
- if (land_cover_source == 'OpenStreetMap'):
455
- land_cover_grid_converted = land_cover_grid_ori
456
- else:
457
- land_cover_grid_converted = convert_land_cover(land_cover_grid_ori, land_cover_source=land_cover_source)
458
- # Prepare all input grids for 3D processing
459
- # Flip vertically to align with standard geographic orientation (north-up)
460
- # Handle missing data appropriately for each grid type
461
- building_height_grid = np.flipud(np.nan_to_num(building_height_grid_ori, nan=10.0)) # Replace NaN values with 10m height
462
- building_min_height_grid = np.flipud(replace_nan_in_nested(building_min_height_grid_ori)) # Replace NaN in nested arrays
463
- building_id_grid = np.flipud(building_id_grid_ori)
464
- land_cover_grid = np.flipud(land_cover_grid_converted.copy()) + 1 # Add 1 to avoid 0 values in land cover
465
- dem_grid = np.flipud(dem_grid_ori.copy()) - np.min(dem_grid_ori) # Normalize DEM to start at 0
466
- dem_grid = process_grid(building_id_grid, dem_grid) # Process DEM based on building footprints
467
- tree_grid = np.flipud(tree_grid_ori.copy())
468
- # Validate that all input grids have consistent dimensions
469
- assert building_height_grid.shape == land_cover_grid.shape == dem_grid.shape == tree_grid.shape, "Input grids must have the same shape"
470
- rows, cols = building_height_grid.shape
471
- # Calculate the required height for the 3D voxel grid
472
- # Add 1 voxel layer to ensure sufficient vertical space
473
- max_height = int(np.ceil(np.max(building_height_grid + dem_grid + tree_grid) / voxel_size))+1
474
- # Initialize the 3D voxel grid with zeros
475
- # Use int8 by default to reduce memory (values range from about -99 to small positives)
476
- # Allow override via kwarg 'voxel_dtype'
477
- voxel_dtype = kwargs.get("voxel_dtype", np.int8)
478
-
479
- # Optional: estimate memory and allow a soft limit before allocating
480
- try:
481
- bytes_per_elem = np.dtype(voxel_dtype).itemsize
482
- est_mb = rows * cols * max_height * bytes_per_elem / (1024 ** 2)
483
- print(f"Voxel grid shape: ({rows}, {cols}, {max_height}), dtype: {voxel_dtype}, ~{est_mb:.1f} MB")
484
- max_ram_mb = kwargs.get("max_voxel_ram_mb")
485
- if (max_ram_mb is not None) and (est_mb > max_ram_mb):
486
- raise MemoryError(f"Estimated voxel grid memory {est_mb:.1f} MB exceeds limit {max_ram_mb} MB. Increase mesh size or restrict ROI.")
487
- except Exception:
488
- pass
489
-
490
- voxel_grid = np.zeros((rows, cols, max_height), dtype=voxel_dtype)
491
- # Configure tree trunk-to-crown ratio
492
- # This determines how much of the tree is trunk vs canopy
493
- trunk_height_ratio = kwargs.get("trunk_height_ratio")
494
- if trunk_height_ratio is None:
495
- trunk_height_ratio = 11.76 / 19.98 # Default ratio based on typical tree proportions
496
- # Process each grid cell to build the 3D voxel representation
497
- for i in range(rows):
498
- for j in range(cols):
499
- # Calculate ground level in voxel units
500
- # Add 1 to ensure space for surface features
501
- ground_level = int(dem_grid[i, j] / voxel_size + 0.5) + 1
502
- # Extract current cell values
503
- tree_height = tree_grid[i, j]
504
- land_cover = land_cover_grid[i, j]
505
- # Fill underground voxels with -1 (represents subsurface)
506
- voxel_grid[i, j, :ground_level] = -1
507
- # Set the ground surface to the land cover type
508
- voxel_grid[i, j, ground_level-1] = land_cover
509
- # Process tree canopy if trees are present
510
- if tree_height > 0:
511
- # Calculate tree structure: trunk base to crown base to crown top
512
- crown_base_height = (tree_height * trunk_height_ratio)
513
- crown_base_height_level = int(crown_base_height / voxel_size + 0.5)
514
- crown_top_height = tree_height
515
- crown_top_height_level = int(crown_top_height / voxel_size + 0.5)
516
-
517
- # Ensure minimum crown height of 1 voxel
518
- # Prevent crown base and top from being at the same level
519
- if (crown_top_height_level == crown_base_height_level) and (crown_base_height_level>0):
520
- crown_base_height_level -= 1
521
-
522
- # Calculate absolute positions relative to ground level
523
- tree_start = ground_level + crown_base_height_level
524
- tree_end = ground_level + crown_top_height_level
525
-
526
- # Fill tree crown voxels with -2 (represents vegetation canopy)
527
- voxel_grid[i, j, tree_start:tree_end] = -2
528
- # Process buildings - handle multiple height segments per building
529
- # Some buildings may have multiple levels or complex height profiles
530
- for k in building_min_height_grid[i, j]:
531
- building_min_height = int(k[0] / voxel_size + 0.5) # Lower height of building segment
532
- building_height = int(k[1] / voxel_size + 0.5) # Upper height of building segment
533
- # Fill building voxels with -3 (represents built structures)
534
- voxel_grid[i, j, ground_level+building_min_height:ground_level+building_height] = -3
535
- return voxel_grid
536
-
537
- def create_3d_voxel_individuals(building_height_grid_ori, land_cover_grid_ori, dem_grid_ori, tree_grid_ori, voxel_size, land_cover_source, layered_interval=None):
538
- """Creates separate 3D voxel grids for each component.
539
-
540
- Args:
541
- building_height_grid_ori: Grid of building heights
542
- land_cover_grid_ori: Grid of land cover classifications
543
- dem_grid_ori: Grid of elevation values
544
- tree_grid_ori: Grid of tree heights
545
- voxel_size: Size of each voxel in meters
546
- land_cover_source: Source of land cover data
547
- layered_interval: Interval for layered output
548
-
549
- Returns:
550
- tuple:
551
- - numpy.ndarray: Land cover voxel grid
552
- - numpy.ndarray: Building voxel grid
553
- - numpy.ndarray: Tree voxel grid
554
- - numpy.ndarray: DEM voxel grid
555
- - numpy.ndarray: Combined layered voxel grid
556
- """
557
-
558
- print("Generating 3D voxel data")
559
- # Convert land cover values if not from OpenEarthMapJapan
560
- if land_cover_source != 'OpenEarthMapJapan':
561
- land_cover_grid_converted = convert_land_cover(land_cover_grid_ori, land_cover_source=land_cover_source)
562
- else:
563
- land_cover_grid_converted = land_cover_grid_ori
564
-
565
- # Prepare and flip all input grids vertically
566
- building_height_grid = np.flipud(building_height_grid_ori.copy())
567
- land_cover_grid = np.flipud(land_cover_grid_converted.copy()) + 1 # Add 1 to avoid 0 values
568
- dem_grid = np.flipud(dem_grid_ori.copy()) - np.min(dem_grid_ori) # Normalize DEM to start at 0
569
- building_nr_grid = group_and_label_cells(np.flipud(building_height_grid_ori.copy()))
570
- dem_grid = process_grid(building_nr_grid, dem_grid) # Process DEM based on building footprints
571
- tree_grid = np.flipud(tree_grid_ori.copy())
572
-
573
- # Validate input dimensions
574
- assert building_height_grid.shape == land_cover_grid.shape == dem_grid.shape == tree_grid.shape, "Input grids must have the same shape"
575
-
576
- rows, cols = building_height_grid.shape
577
-
578
- # Calculate required height for 3D grid
579
- max_height = int(np.ceil(np.max(building_height_grid + dem_grid + tree_grid) / voxel_size))
580
-
581
- # Initialize empty 3D grids for each component
582
- land_cover_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
583
- building_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
584
- tree_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
585
- dem_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
586
-
587
- # Fill individual component grids
588
- for i in range(rows):
589
- for j in range(cols):
590
- ground_level = int(dem_grid[i, j] / voxel_size + 0.5)
591
- building_height = int(building_height_grid[i, j] / voxel_size + 0.5)
592
- tree_height = int(tree_grid[i, j] / voxel_size + 0.5)
593
- land_cover = land_cover_grid[i, j]
594
-
595
- # Fill underground cells with -1
596
- dem_voxel_grid[i, j, :ground_level+1] = -1
597
-
598
- # Set ground level cell to land cover
599
- land_cover_voxel_grid[i, j, 0] = land_cover
600
-
601
- # Fill tree crown with value -2
602
- if tree_height > 0:
603
- tree_voxel_grid[i, j, :tree_height] = -2
604
-
605
- # Fill building with value -3
606
- if building_height > 0:
607
- building_voxel_grid[i, j, :building_height] = -3
608
-
609
- # Set default layered interval if not provided
610
- if not layered_interval:
611
- layered_interval = max(max_height, int(dem_grid.shape[0]/4 + 0.5))
612
-
613
- # Create combined layered visualization
614
- extract_height = min(layered_interval, max_height)
615
- layered_voxel_grid = np.zeros((rows, cols, layered_interval*4), dtype=np.int32)
616
-
617
- # Stack components in layers with equal spacing
618
- layered_voxel_grid[:, :, :extract_height] = dem_voxel_grid[:, :, :extract_height]
619
- layered_voxel_grid[:, :, layered_interval:layered_interval+extract_height] = land_cover_voxel_grid[:, :, :extract_height]
620
- layered_voxel_grid[:, :, 2*layered_interval:2*layered_interval+extract_height] = building_voxel_grid[:, :, :extract_height]
621
- layered_voxel_grid[:, :, 3*layered_interval:3*layered_interval+extract_height] = tree_voxel_grid[:, :, :extract_height]
622
-
623
- return land_cover_voxel_grid, building_voxel_grid, tree_voxel_grid, dem_voxel_grid, layered_voxel_grid
624
-
625
- def get_voxcity(rectangle_vertices, building_source, land_cover_source, canopy_height_source, dem_source, meshsize, building_gdf=None, **kwargs):
626
- """Main function to generate a complete voxel city model.
627
-
628
- Args:
629
- rectangle_vertices: List of coordinates defining the area of interest
630
- building_source: Source for building height data (e.g. 'OSM', 'EUBUCCO', 'GeoDataFrame')
631
- land_cover_source: Source for land cover data (e.g. 'ESA', 'ESRI')
632
- canopy_height_source: Source for tree canopy height data
633
- dem_source: Source for digital elevation model data ('Flat' or other source)
634
- meshsize: Size of each grid cell in meters
635
- building_gdf: Optional GeoDataFrame with building footprint, height and other information
636
- **kwargs: Additional keyword arguments including:
637
- - output_dir: Directory to save output files (default: 'output')
638
- - min_canopy_height: Minimum height threshold for tree canopy
639
- - remove_perimeter_object: Factor to remove objects near perimeter
640
- - mapvis: Whether to visualize grids on map
641
- - voxelvis: Whether to visualize 3D voxel model
642
- - voxelvis_img_save_path: Path to save 3D visualization
643
- - default_land_cover_class: Default class for land cover grid cells with no intersecting polygons (default: 'Developed space')
644
-
645
- Returns:
646
- tuple containing:
647
- - voxcity_grid: 3D voxel grid of the complete city model
648
- - building_height_grid: 2D grid of building heights
649
- - building_min_height_grid: 2D grid of minimum building heights
650
- - building_id_grid: 2D grid of building IDs
651
- - canopy_height_grid: 2D grid of tree canopy heights
652
- - land_cover_grid: 2D grid of land cover classifications
653
- - dem_grid: 2D grid of ground elevation
654
- - building_geojson: GeoJSON of building footprints and metadata
655
- """
656
- # Set up output directory for intermediate and final files
657
- output_dir = kwargs.get("output_dir", "output")
658
- os.makedirs(output_dir, exist_ok=True)
659
-
660
- # Remove 'output_dir' from kwargs to prevent duplication in function calls
661
- kwargs.pop('output_dir', None)
662
-
663
- # STEP 1: Generate all required 2D grids from various data sources
664
- # These grids form the foundation for the 3D voxel model
665
-
666
- # Land cover classification grid (e.g., urban, forest, water, agriculture)
667
- land_cover_grid = get_land_cover_grid(rectangle_vertices, meshsize, land_cover_source, output_dir, **kwargs)
668
-
669
- # Building footprints and height information
670
- building_height_grid, building_min_height_grid, building_id_grid, building_gdf = get_building_height_grid(rectangle_vertices, meshsize, building_source, output_dir, building_gdf=building_gdf, **kwargs)
671
-
672
- # Save building data to file for later analysis or visualization
673
- if not building_gdf.empty:
674
- save_path = f"{output_dir}/building.gpkg"
675
- building_gdf.to_file(save_path, driver='GPKG')
676
-
677
- # STEP 2: Handle canopy height data
678
- # Either use static values or fetch from satellite sources
679
- if canopy_height_source == "Static":
680
- # Create uniform canopy height for all tree-covered areas
681
- canopy_height_grid = np.zeros_like(land_cover_grid, dtype=float)
682
-
683
- # Apply static height to areas classified as trees
684
- # Default height represents typical urban tree height
685
- static_tree_height = kwargs.get("static_tree_height", 10.0)
686
- tree_mask = (land_cover_grid == 4)
687
-
688
- # Set static height for tree cells
689
- canopy_height_grid[tree_mask] = static_tree_height
690
- else:
691
- # Fetch canopy height from satellite/remote sensing sources
692
- canopy_height_grid = get_canopy_height_grid(rectangle_vertices, meshsize, canopy_height_source, output_dir, **kwargs)
693
-
694
- # STEP 3: Handle digital elevation model (terrain)
695
- if dem_source == "Flat":
696
- # Create flat terrain for simplified modeling
697
- dem_grid = np.zeros_like(land_cover_grid)
698
- else:
699
- # Fetch terrain elevation from various sources
700
- dem_grid = get_dem_grid(rectangle_vertices, meshsize, dem_source, output_dir, **kwargs)
701
-
702
- # STEP 4: Apply optional data filtering and cleaning
703
-
704
- # Filter out low vegetation that may be noise in the data
705
- min_canopy_height = kwargs.get("min_canopy_height")
706
- if min_canopy_height is not None:
707
- canopy_height_grid[canopy_height_grid < kwargs["min_canopy_height"]] = 0
708
-
709
- # Remove objects near the boundary to avoid edge effects
710
- # This is useful when the area of interest is part of a larger urban area
711
- remove_perimeter_object = kwargs.get("remove_perimeter_object")
712
- if (remove_perimeter_object is not None) and (remove_perimeter_object > 0):
713
- print("apply perimeter removal")
714
- # Calculate perimeter width based on grid dimensions
715
- w_peri = int(remove_perimeter_object * building_height_grid.shape[0] + 0.5)
716
- h_peri = int(remove_perimeter_object * building_height_grid.shape[1] + 0.5)
717
-
718
- # Clear canopy heights in perimeter areas
719
- canopy_height_grid[:w_peri, :] = canopy_height_grid[-w_peri:, :] = canopy_height_grid[:, :h_peri] = canopy_height_grid[:, -h_peri:] = 0
720
-
721
- # Identify buildings that intersect with perimeter areas
722
- ids1 = np.unique(building_id_grid[:w_peri, :][building_id_grid[:w_peri, :] > 0])
723
- ids2 = np.unique(building_id_grid[-w_peri:, :][building_id_grid[-w_peri:, :] > 0])
724
- ids3 = np.unique(building_id_grid[:, :h_peri][building_id_grid[:, :h_peri] > 0])
725
- ids4 = np.unique(building_id_grid[:, -h_peri:][building_id_grid[:, -h_peri:] > 0])
726
- remove_ids = np.concatenate((ids1, ids2, ids3, ids4))
727
-
728
- # Remove identified buildings from all grids
729
- for remove_id in remove_ids:
730
- positions = np.where(building_id_grid == remove_id)
731
- building_height_grid[positions] = 0
732
- building_min_height_grid[positions] = [[] for _ in range(len(building_min_height_grid[positions]))]
733
-
734
- # Visualize grids after optional perimeter removal
735
- grid_vis = kwargs.get("gridvis", True)
736
- if grid_vis:
737
- # Building height grid visualization (zeros hidden)
738
- building_height_grid_nan = building_height_grid.copy()
739
- building_height_grid_nan[building_height_grid_nan == 0] = np.nan
740
- visualize_numerical_grid(
741
- np.flipud(building_height_grid_nan),
742
- meshsize,
743
- "building height (m)",
744
- cmap='viridis',
745
- label='Value'
746
- )
747
-
748
- # Canopy height grid visualization (zeros hidden)
749
- canopy_height_grid_nan = canopy_height_grid.copy()
750
- canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
751
- visualize_numerical_grid(
752
- np.flipud(canopy_height_grid_nan),
753
- meshsize,
754
- "Tree canopy height (m)",
755
- cmap='Greens',
756
- label='Tree canopy height (m)'
757
- )
758
-
759
- # STEP 5: Generate optional 2D visualizations on interactive maps
760
- mapvis = kwargs.get("mapvis")
761
- if mapvis:
762
- # Create map-based visualizations of all data layers
763
- # These help users understand the input data before 3D modeling
764
-
765
- # Visualize land cover using the new function
766
- visualize_landcover_grid_on_basemap(
767
- land_cover_grid,
768
- rectangle_vertices,
769
- meshsize,
770
- source=land_cover_source,
771
- alpha=0.7,
772
- figsize=(12, 8),
773
- basemap='CartoDB light',
774
- show_edge=False
775
- )
776
-
777
- # Visualize building heights using the new function
778
- visualize_numerical_grid_on_basemap(
779
- building_height_grid,
780
- rectangle_vertices,
781
- meshsize,
782
- value_name="Building Heights (m)",
783
- cmap='viridis',
784
- alpha=0.7,
785
- figsize=(12, 8),
786
- basemap='CartoDB light',
787
- show_edge=False
788
- )
789
-
790
- # Visualize canopy heights using the new function
791
- visualize_numerical_grid_on_basemap(
792
- canopy_height_grid,
793
- rectangle_vertices,
794
- meshsize,
795
- value_name="Canopy Heights (m)",
796
- cmap='Greens',
797
- alpha=0.7,
798
- figsize=(12, 8),
799
- basemap='CartoDB light',
800
- show_edge=False
801
- )
802
-
803
- # Visualize DEM using the new function
804
- visualize_numerical_grid_on_basemap(
805
- dem_grid,
806
- rectangle_vertices,
807
- meshsize,
808
- value_name="Terrain Elevation (m)",
809
- cmap='terrain',
810
- alpha=0.7,
811
- figsize=(12, 8),
812
- basemap='CartoDB light',
813
- show_edge=False
814
- )
815
-
816
- # STEP 6: Generate the final 3D voxel model
817
- # This combines all 2D grids into a comprehensive 3D representation
818
- voxcity_grid = create_3d_voxel(building_height_grid, building_min_height_grid, building_id_grid, land_cover_grid, dem_grid, canopy_height_grid, meshsize, land_cover_source)
819
-
820
- # STEP 7: Generate optional 3D visualization
821
- voxelvis = kwargs.get("voxelvis")
822
- if voxelvis:
823
- # Create a taller grid for better visualization
824
- # Fixed height ensures consistent camera positioning
825
- new_height = int(550/meshsize+0.5)
826
- voxcity_grid_vis = np.zeros((voxcity_grid.shape[0], voxcity_grid.shape[1], new_height))
827
- voxcity_grid_vis[:, :, :voxcity_grid.shape[2]] = voxcity_grid
828
- voxcity_grid_vis[-1, -1, -1] = -99 # Add marker to fix camera location and angle of view
829
- visualize_3d_voxel(voxcity_grid_vis, voxel_size=meshsize, save_path=kwargs["voxelvis_img_save_path"])
830
-
831
- # STEP 8: Save all generated data for future use
832
- save_voxcity = kwargs.get("save_voxctiy_data", True)
833
- if save_voxcity:
834
- save_path = kwargs.get("save_data_path", f"{output_dir}/voxcity_data.pkl")
835
- save_voxcity_data(save_path, voxcity_grid, building_height_grid, building_min_height_grid,
836
- building_id_grid, canopy_height_grid, land_cover_grid, dem_grid,
837
- building_gdf, meshsize, rectangle_vertices)
838
-
839
- return voxcity_grid, building_height_grid, building_min_height_grid, building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, building_gdf
840
-
841
- def get_voxcity_CityGML(rectangle_vertices, land_cover_source, canopy_height_source, meshsize, url_citygml=None, citygml_path=None, **kwargs):
842
- """Main function to generate a complete voxel city model.
843
-
844
- Args:
845
- rectangle_vertices: List of coordinates defining the area of interest
846
- building_source: Source for building height data (e.g. 'OSM', 'EUBUCCO')
847
- land_cover_source: Source for land cover data (e.g. 'ESA', 'ESRI')
848
- canopy_height_source: Source for tree canopy height data
849
- dem_source: Source for digital elevation model data ('Flat' or other source)
850
- meshsize: Size of each grid cell in meters
851
- **kwargs: Additional keyword arguments including:
852
- - output_dir: Directory to save output files (default: 'output')
853
- - min_canopy_height: Minimum height threshold for tree canopy
854
- - remove_perimeter_object: Factor to remove objects near perimeter
855
- - mapvis: Whether to visualize grids on map
856
- - voxelvis: Whether to visualize 3D voxel model
857
- - voxelvis_img_save_path: Path to save 3D visualization
858
-
859
- Returns:
860
- tuple containing:
861
- - voxcity_grid: 3D voxel grid of the complete city model
862
- - building_height_grid: 2D grid of building heights
863
- - building_min_height_grid: 2D grid of minimum building heights
864
- - building_id_grid: 2D grid of building IDs
865
- - canopy_height_grid: 2D grid of tree canopy heights
866
- - land_cover_grid: 2D grid of land cover classifications
867
- - dem_grid: 2D grid of ground elevation
868
- - building_geojson: GeoJSON of building footprints and metadata
869
- """
870
- # Create output directory if it doesn't exist
871
- output_dir = kwargs.get("output_dir", "output")
872
- os.makedirs(output_dir, exist_ok=True)
873
-
874
- # Remove 'output_dir' from kwargs to prevent duplication
875
- kwargs.pop('output_dir', None)
876
-
877
- # get all required gdfs
878
- building_gdf, terrain_gdf, vegetation_gdf = load_buid_dem_veg_from_citygml(url=url_citygml, citygml_path=citygml_path, base_dir=output_dir, rectangle_vertices=rectangle_vertices)
879
-
880
- land_cover_grid = get_land_cover_grid(rectangle_vertices, meshsize, land_cover_source, output_dir, **kwargs)
881
-
882
- # building_height_grid, building_min_height_grid, building_id_grid, building_gdf = get_building_height_grid(rectangle_vertices, meshsize, building_source, output_dir, **kwargs)
883
- print("Creating building height grid")
884
- # Filter kwargs to only those accepted by create_building_height_grid_from_gdf_polygon
885
- _allowed_building_kwargs = {
886
- "overlapping_footprint",
887
- "gdf_comp",
888
- "geotiff_path_comp",
889
- "complement_building_footprints",
890
- "complement_height",
891
- }
892
- _building_kwargs = {k: v for k, v in kwargs.items() if k in _allowed_building_kwargs}
893
- building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(
894
- building_gdf, meshsize, rectangle_vertices, **_building_kwargs
895
- )
896
-
897
- # Visualize grid if requested
898
- grid_vis = kwargs.get("gridvis", True)
899
- if grid_vis:
900
- building_height_grid_nan = building_height_grid.copy()
901
- building_height_grid_nan[building_height_grid_nan == 0] = np.nan
902
- visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
903
-
904
- # Save building data to GeoJSON
905
- if not building_gdf.empty:
906
- save_path = f"{output_dir}/building.gpkg"
907
- building_gdf.to_file(save_path, driver='GPKG')
908
-
909
- # Get canopy height data
910
- if canopy_height_source == "Static":
911
- # Create canopy height grid with same shape as land cover grid
912
- canopy_height_grid_comp = np.zeros_like(land_cover_grid, dtype=float)
913
-
914
- # Set default static height for trees (20 meters is a typical average tree height)
915
- static_tree_height = kwargs.get("static_tree_height", 10.0)
916
- tree_mask = (land_cover_grid == 4)
917
-
918
- # Set static height for tree cells
919
- canopy_height_grid_comp[tree_mask] = static_tree_height
920
- else:
921
- canopy_height_grid_comp = get_canopy_height_grid(rectangle_vertices, meshsize, canopy_height_source, output_dir, **kwargs)
922
-
923
- # In the get_voxcity_CityGML function, modify it to handle None vegetation_gdf
924
- if vegetation_gdf is not None:
925
- canopy_height_grid = create_vegetation_height_grid_from_gdf_polygon(vegetation_gdf, meshsize, rectangle_vertices)
926
- else:
927
- # Create an empty canopy_height_grid with the same shape as your other grids
928
- # This depends on the expected shape, you might need to adjust
929
- canopy_height_grid = np.zeros_like(building_height_grid)
930
-
931
- mask = (canopy_height_grid == 0) & (canopy_height_grid_comp != 0)
932
- canopy_height_grid[mask] = canopy_height_grid_comp[mask]
933
-
934
- # Handle DEM - either flat or from source
935
- if kwargs.pop('flat_dem', None):
936
- dem_grid = np.zeros_like(land_cover_grid)
937
- else:
938
- print("Creating Digital Elevation Model (DEM) grid")
939
- dem_grid = create_dem_grid_from_gdf_polygon(terrain_gdf, meshsize, rectangle_vertices)
940
-
941
- # Visualize grid if requested
942
- grid_vis = kwargs.get("gridvis", True)
943
- if grid_vis:
944
- visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
945
-
946
-
947
- # Apply minimum canopy height threshold if specified
948
- min_canopy_height = kwargs.get("min_canopy_height")
949
- if min_canopy_height is not None:
950
- canopy_height_grid[canopy_height_grid < kwargs["min_canopy_height"]] = 0
951
-
952
- # Remove objects near perimeter if specified
953
- remove_perimeter_object = kwargs.get("remove_perimeter_object")
954
- if (remove_perimeter_object is not None) and (remove_perimeter_object > 0):
955
- print("apply perimeter removal")
956
- # Calculate perimeter width based on grid dimensions
957
- w_peri = int(remove_perimeter_object * building_height_grid.shape[0] + 0.5)
958
- h_peri = int(remove_perimeter_object * building_height_grid.shape[1] + 0.5)
959
-
960
- # Clear canopy heights in perimeter
961
- canopy_height_grid[:w_peri, :] = canopy_height_grid[-w_peri:, :] = canopy_height_grid[:, :h_peri] = canopy_height_grid[:, -h_peri:] = 0
962
-
963
- # Find building IDs in perimeter regions
964
- ids1 = np.unique(building_id_grid[:w_peri, :][building_id_grid[:w_peri, :] > 0])
965
- ids2 = np.unique(building_id_grid[-w_peri:, :][building_id_grid[-w_peri:, :] > 0])
966
- ids3 = np.unique(building_id_grid[:, :h_peri][building_id_grid[:, :h_peri] > 0])
967
- ids4 = np.unique(building_id_grid[:, -h_peri:][building_id_grid[:, -h_peri:] > 0])
968
- remove_ids = np.concatenate((ids1, ids2, ids3, ids4))
969
-
970
- # Remove buildings in perimeter
971
- for remove_id in remove_ids:
972
- positions = np.where(building_id_grid == remove_id)
973
- building_height_grid[positions] = 0
974
- building_min_height_grid[positions] = [[] for _ in range(len(building_min_height_grid[positions]))]
975
-
976
- # Visualize grids after optional perimeter removal
977
- grid_vis = kwargs.get("gridvis", True)
978
- if grid_vis:
979
- # Building height grid visualization (zeros hidden)
980
- building_height_grid_nan = building_height_grid.copy()
981
- building_height_grid_nan[building_height_grid_nan == 0] = np.nan
982
- visualize_numerical_grid(
983
- np.flipud(building_height_grid_nan),
984
- meshsize,
985
- "building height (m)",
986
- cmap='viridis',
987
- label='Value'
988
- )
989
-
990
- # Canopy height grid visualization (zeros hidden)
991
- canopy_height_grid_nan = canopy_height_grid.copy()
992
- canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
993
- visualize_numerical_grid(
994
- np.flipud(canopy_height_grid_nan),
995
- meshsize,
996
- "Tree canopy height (m)",
997
- cmap='Greens',
998
- label='Tree canopy height (m)'
999
- )
1000
-
1001
- # Generate 3D voxel grid
1002
- voxcity_grid = create_3d_voxel(building_height_grid, building_min_height_grid, building_id_grid, land_cover_grid, dem_grid, canopy_height_grid, meshsize, land_cover_source)
1003
-
1004
- # Save all data if a save path is provided
1005
- save_voxcity = kwargs.get("save_voxctiy_data", True)
1006
- if save_voxcity:
1007
- save_path = kwargs.get("save_data_path", f"{output_dir}/voxcity_data.pkl")
1008
- save_voxcity_data(save_path, voxcity_grid, building_height_grid, building_min_height_grid,
1009
- building_id_grid, canopy_height_grid, land_cover_grid, dem_grid,
1010
- building_gdf, meshsize, rectangle_vertices)
1011
-
1012
- return voxcity_grid, building_height_grid, building_min_height_grid, building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, filtered_buildings
1013
-
1014
- def replace_nan_in_nested(arr, replace_value=10.0):
1015
- """
1016
- Optimized version that avoids converting to Python lists.
1017
- Works directly with numpy arrays.
1018
- """
1019
- if not isinstance(arr, np.ndarray):
1020
- return arr
1021
-
1022
- # Create output array
1023
- result = np.empty_like(arr, dtype=object)
1024
-
1025
- # Vectorized operation for empty cells
1026
- for i in range(arr.shape[0]):
1027
- for j in range(arr.shape[1]):
1028
- cell = arr[i, j]
1029
-
1030
- if cell is None or (isinstance(cell, list) and len(cell) == 0):
1031
- result[i, j] = []
1032
- elif isinstance(cell, list):
1033
- # Process list without converting entire array
1034
- new_cell = []
1035
- for segment in cell:
1036
- if isinstance(segment, (list, np.ndarray)):
1037
- # Use numpy operations where possible
1038
- if isinstance(segment, np.ndarray):
1039
- new_segment = np.where(np.isnan(segment), replace_value, segment).tolist()
1040
- else:
1041
- new_segment = [replace_value if (isinstance(v, float) and np.isnan(v)) else v for v in segment]
1042
- new_cell.append(new_segment)
1043
- else:
1044
- new_cell.append(segment)
1045
- result[i, j] = new_cell
1046
- else:
1047
- result[i, j] = cell
1048
-
1049
- return result
1050
-
1051
- def save_voxcity_data(output_path, voxcity_grid, building_height_grid, building_min_height_grid,
1052
- building_id_grid, canopy_height_grid, land_cover_grid, dem_grid,
1053
- building_gdf, meshsize, rectangle_vertices):
1054
- """Save voxcity data to a file for later loading.
1055
-
1056
- Args:
1057
- output_path: Path to save the data file
1058
- voxcity_grid: 3D voxel grid of the complete city model
1059
- building_height_grid: 2D grid of building heights
1060
- building_min_height_grid: 2D grid of minimum building heights
1061
- building_id_grid: 2D grid of building IDs
1062
- canopy_height_grid: 2D grid of tree canopy heights
1063
- land_cover_grid: 2D grid of land cover classifications
1064
- dem_grid: 2D grid of ground elevation
1065
- building_gdf: GeoDataFrame of building footprints and metadata
1066
- meshsize: Size of each grid cell in meters
1067
- rectangle_vertices: List of coordinates defining the area of interest
1068
- """
1069
- import pickle
1070
- import os
1071
-
1072
- # Ensure the output directory exists
1073
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
1074
-
1075
- # Create a comprehensive dictionary containing all voxcity data
1076
- # This preserves all components needed to reconstruct or analyze the model
1077
- data_dict = {
1078
- 'voxcity_grid': voxcity_grid,
1079
- 'building_height_grid': building_height_grid,
1080
- 'building_min_height_grid': building_min_height_grid,
1081
- 'building_id_grid': building_id_grid,
1082
- 'canopy_height_grid': canopy_height_grid,
1083
- 'land_cover_grid': land_cover_grid,
1084
- 'dem_grid': dem_grid,
1085
- 'building_gdf': building_gdf,
1086
- 'meshsize': meshsize,
1087
- 'rectangle_vertices': rectangle_vertices
1088
- }
1089
-
1090
- # Serialize and save the data using pickle for efficient storage
1091
- # Pickle preserves exact data types and structures
1092
- with open(output_path, 'wb') as f:
1093
- pickle.dump(data_dict, f)
1094
-
1095
- print(f"Voxcity data saved to {output_path}")
1096
-
1097
- def load_voxcity_data(input_path):
1098
- """Load voxcity data from a saved file.
1099
-
1100
- Args:
1101
- input_path: Path to the saved data file
1102
-
1103
- Returns:
1104
- tuple: All the voxcity data components including:
1105
- - voxcity_grid: 3D voxel grid of the complete city model
1106
- - building_height_grid: 2D grid of building heights
1107
- - building_min_height_grid: 2D grid of minimum building heights
1108
- - building_id_grid: 2D grid of building IDs
1109
- - canopy_height_grid: 2D grid of tree canopy heights
1110
- - land_cover_grid: 2D grid of land cover classifications
1111
- - dem_grid: 2D grid of ground elevation
1112
- - building_gdf: GeoDataFrame of building footprints and metadata
1113
- - meshsize: Size of each grid cell in meters
1114
- - rectangle_vertices: List of coordinates defining the area of interest
1115
- """
1116
- import pickle
1117
-
1118
- # Deserialize the data from the saved file
1119
- with open(input_path, 'rb') as f:
1120
- data_dict = pickle.load(f)
1121
-
1122
- print(f"Voxcity data loaded from {input_path}")
1123
-
1124
- # Return all components as a tuple in the same order as the main function
1125
- # This ensures compatibility with existing code that expects this structure
1126
- return (
1127
- data_dict['voxcity_grid'],
1128
- data_dict['building_height_grid'],
1129
- data_dict['building_min_height_grid'],
1130
- data_dict['building_id_grid'],
1131
- data_dict['canopy_height_grid'],
1132
- data_dict['land_cover_grid'],
1133
- data_dict['dem_grid'],
1134
- data_dict['building_gdf'],
1135
- data_dict['meshsize'],
1136
- data_dict['rectangle_vertices']
1
+ """Main module for voxcity.
2
+
3
+ This module provides functions to generate 3D voxel representations of cities using various data sources.
4
+ It handles land cover, building heights, canopy heights, and digital elevation models to create detailed
5
+ 3D city models.
6
+
7
+ The main functions are:
8
+ - get_land_cover_grid: Creates a grid of land cover classifications
9
+ - get_building_height_grid: Creates a grid of building heights (supports GeoDataFrame input)
10
+ - get_canopy_height_grid: Creates a grid of tree canopy heights
11
+ - get_dem_grid: Creates a digital elevation model grid
12
+ - create_3d_voxel: Combines the grids into a 3D voxel representation
13
+ - create_3d_voxel_individuals: Creates separate voxel grids for each component
14
+ - get_voxcity: Main function to generate a complete voxel city model (supports GeoDataFrame input)
15
+
16
+ Key Features:
17
+ - Support for multiple data sources (OpenStreetMap, ESA WorldCover, Google Earth Engine, etc.)
18
+ - Direct GeoDataFrame input for building data (useful for custom datasets)
19
+ - 3D voxel generation with configurable resolution
20
+ - Visualization capabilities for both 2D grids and 3D models
21
+ - Data export in various formats (GeoTIFF, GeoJSON, pickle)
22
+ """
23
+
24
+ # Standard library imports
25
+ import numpy as np
26
+ import os
27
+ try:
28
+ from numba import jit, prange
29
+ import numba
30
+ NUMBA_AVAILABLE = True
31
+ except ImportError:
32
+ NUMBA_AVAILABLE = False
33
+ print("Numba not available. Using optimized version without JIT compilation.")
34
+ # Define dummy decorators
35
+ def jit(*args, **kwargs):
36
+ def decorator(func):
37
+ return func
38
+ return decorator
39
+ prange = range
40
+
41
+ # Local application/library specific imports
42
+
43
+ # Data downloaders - modules for fetching geospatial data from various sources
44
+ from .downloader.mbfp import get_mbfp_gdf
45
+ from .downloader.osm import load_gdf_from_openstreetmap, load_land_cover_gdf_from_osm
46
+ from .downloader.oemj import save_oemj_as_geotiff
47
+ # from .downloader.omt import load_gdf_from_openmaptiles
48
+ from .downloader.eubucco import load_gdf_from_eubucco
49
+ from .downloader.overture import load_gdf_from_overture
50
+ from .downloader.citygml import load_buid_dem_veg_from_citygml
51
+
52
+ # Google Earth Engine related imports - for satellite and elevation data
53
+ from .downloader.gee import (
54
+ initialize_earth_engine,
55
+ get_roi,
56
+ get_ee_image_collection,
57
+ get_ee_image,
58
+ save_geotiff,
59
+ get_dem_image,
60
+ save_geotiff_esa_land_cover,
61
+ save_geotiff_esri_landcover,
62
+ save_geotiff_dynamic_world_v1,
63
+ save_geotiff_open_buildings_temporal,
64
+ save_geotiff_dsm_minus_dtm
65
+ )
66
+
67
+ # Grid processing functions - for converting geodata to raster grids
68
+ from .geoprocessor.grid import (
69
+ group_and_label_cells,
70
+ process_grid,
71
+ create_land_cover_grid_from_geotiff_polygon,
72
+ create_height_grid_from_geotiff_polygon,
73
+ create_building_height_grid_from_gdf_polygon,
74
+ create_dem_grid_from_geotiff_polygon,
75
+ create_land_cover_grid_from_gdf_polygon,
76
+ create_building_height_grid_from_open_building_temporal_polygon,
77
+ create_vegetation_height_grid_from_gdf_polygon,
78
+ create_canopy_grids_from_tree_gdf,
79
+ create_dem_grid_from_gdf_polygon
80
+ )
81
+
82
+ # Utility functions
83
+ from .utils.lc import convert_land_cover, convert_land_cover_array
84
+ from .geoprocessor.polygon import get_gdf_from_gpkg, save_geojson
85
+
86
+ # Visualization functions - for creating plots and 3D visualizations
87
+ from .utils.visualization import (
88
+ get_land_cover_classes,
89
+ visualize_land_cover_grid,
90
+ visualize_numerical_grid,
91
+ # visualize_land_cover_grid_on_map,
92
+ # visualize_numerical_grid_on_map,
93
+ # visualize_building_height_grid_on_map,
94
+ visualize_3d_voxel,
95
+ visualize_landcover_grid_on_basemap,
96
+ visualize_numerical_grid_on_basemap,
97
+ )
98
+
99
+ def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
100
+ """Creates a grid of land cover classifications.
101
+
102
+ Args:
103
+ rectangle_vertices: List of coordinates defining the area of interest
104
+ meshsize: Size of each grid cell in meters
105
+ source: Data source for land cover (e.g. 'ESA WorldCover', 'OpenStreetMap')
106
+ output_dir: Directory to save output files
107
+ **kwargs: Additional arguments including:
108
+ - esri_landcover_year: Year for ESRI land cover data
109
+ - dynamic_world_date: Date for Dynamic World data
110
+ - gridvis: Whether to visualize the grid
111
+ - default_land_cover_class: Default class for grid cells with no intersecting polygons (default: 'Developed space')
112
+
113
+ Returns:
114
+ numpy.ndarray: Grid of land cover classifications as integer values
115
+ """
116
+
117
+ print("Creating Land Use Land Cover grid\n ")
118
+ print(f"Data source: {source}")
119
+
120
+ # Initialize Earth Engine for satellite-based data sources
121
+ # Skip initialization for local/vector data sources
122
+ if source not in ["OpenStreetMap", "OpenEarthMapJapan"]:
123
+ initialize_earth_engine()
124
+
125
+ # Ensure output directory exists for saving intermediate files
126
+ os.makedirs(output_dir, exist_ok=True)
127
+ geotiff_path = os.path.join(output_dir, "land_cover.tif")
128
+
129
+ # Handle different data sources - each requires specific processing
130
+ # Satellite/raster-based sources are saved as GeoTIFF files
131
+ if source == 'Urbanwatch':
132
+ # Urban-focused land cover from satellite imagery
133
+ roi = get_roi(rectangle_vertices)
134
+ collection_name = "projects/sat-io/open-datasets/HRLC/urban-watch-cities"
135
+ image = get_ee_image_collection(collection_name, roi)
136
+ save_geotiff(image, geotiff_path)
137
+ elif source == 'ESA WorldCover':
138
+ # Global land cover from European Space Agency
139
+ roi = get_roi(rectangle_vertices)
140
+ save_geotiff_esa_land_cover(roi, geotiff_path)
141
+ elif source == 'ESRI 10m Annual Land Cover':
142
+ # High-resolution annual land cover from ESRI
143
+ esri_landcover_year = kwargs.get("esri_landcover_year")
144
+ roi = get_roi(rectangle_vertices)
145
+ save_geotiff_esri_landcover(roi, geotiff_path, year=esri_landcover_year)
146
+ elif source == 'Dynamic World V1':
147
+ # Near real-time land cover from Google's Dynamic World
148
+ dynamic_world_date = kwargs.get("dynamic_world_date")
149
+ roi = get_roi(rectangle_vertices)
150
+ save_geotiff_dynamic_world_v1(roi, geotiff_path, dynamic_world_date)
151
+ elif source == 'OpenEarthMapJapan':
152
+ # Japan-specific land cover dataset
153
+ save_oemj_as_geotiff(rectangle_vertices, geotiff_path)
154
+ elif source == 'OpenStreetMap':
155
+ # Vector-based land cover from OpenStreetMap
156
+ # This bypasses the GeoTIFF workflow and gets data directly as GeoJSON
157
+ land_cover_gdf = load_land_cover_gdf_from_osm(rectangle_vertices)
158
+
159
+ # Get the classification scheme for the selected data source
160
+ # Each source has its own land cover categories and color coding
161
+ land_cover_classes = get_land_cover_classes(source)
162
+
163
+ # Convert geospatial data to regular grid format
164
+ # Different processing for vector vs raster data sources
165
+ if source == 'OpenStreetMap':
166
+ # Process vector data directly from GeoDataFrame
167
+ default_class = kwargs.get('default_land_cover_class', 'Developed space')
168
+ land_cover_grid_str = create_land_cover_grid_from_gdf_polygon(land_cover_gdf, meshsize, source, rectangle_vertices, default_class=default_class)
169
+ else:
170
+ # Process raster data from GeoTIFF file
171
+ land_cover_grid_str = create_land_cover_grid_from_geotiff_polygon(geotiff_path, meshsize, land_cover_classes, rectangle_vertices)
172
+
173
+ # Prepare color mapping for visualization
174
+ # Convert RGB values from 0-255 range to 0-1 range for matplotlib
175
+ color_map = {cls: [r/255, g/255, b/255] for (r,g,b), cls in land_cover_classes.items()}
176
+
177
+ # Generate visualization if requested
178
+ grid_vis = kwargs.get("gridvis", True)
179
+ if grid_vis:
180
+ # Flip grid vertically for correct display orientation
181
+ visualize_land_cover_grid(np.flipud(land_cover_grid_str), meshsize, color_map, land_cover_classes)
182
+
183
+ # Convert string-based land cover labels to integer codes for processing
184
+ # This enables efficient numerical operations on the grid
185
+ land_cover_grid_int = convert_land_cover_array(land_cover_grid_str, land_cover_classes)
186
+
187
+ return land_cover_grid_int
188
+
189
+ # def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir="output", visualization=True, maptiler_API_key=None, file_path=None):
190
+ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, building_gdf=None, **kwargs):
191
+ """Creates a grid of building heights.
192
+
193
+ Args:
194
+ rectangle_vertices: List of coordinates defining the area of interest
195
+ meshsize: Size of each grid cell in meters
196
+ source: Data source for buildings (e.g. 'OpenStreetMap', 'Microsoft Building Footprints', 'GeoDataFrame')
197
+ output_dir: Directory to save output files
198
+ building_gdf: Optional GeoDataFrame with building footprint, height and other information
199
+ **kwargs: Additional arguments including:
200
+ - maptiler_API_key: API key for MapTiler
201
+ - building_path: Path to local building data file
202
+ - building_complementary_source: Additional building data source
203
+ - gridvis: Whether to visualize the grid
204
+
205
+ Returns:
206
+ tuple:
207
+ - numpy.ndarray: Grid of building heights
208
+ - numpy.ndarray: Grid of building minimum heights
209
+ - numpy.ndarray: Grid of building IDs
210
+ - list: Filtered building features
211
+ """
212
+
213
+ # Initialize Earth Engine for satellite-based building data sources
214
+ if source not in ["OpenStreetMap", "Overture", "Local file", "GeoDataFrame"]:
215
+ initialize_earth_engine()
216
+
217
+ print("Creating Building Height grid\n ")
218
+ print(f"Data source: {source}")
219
+
220
+ os.makedirs(output_dir, exist_ok=True)
221
+
222
+ # If building_gdf is provided, use it directly
223
+ if building_gdf is not None:
224
+ gdf = building_gdf
225
+ print("Using provided GeoDataFrame for building data")
226
+ else:
227
+ # Fetch building data from primary source
228
+ # Each source has different data formats and processing requirements
229
+ # Floor height (m) for inferring heights from floors/levels
230
+ floor_height = kwargs.get("floor_height", 3.0)
231
+ if source == 'Microsoft Building Footprints':
232
+ # Machine learning-derived building footprints from satellite imagery
233
+ gdf = get_mbfp_gdf(output_dir, rectangle_vertices)
234
+ elif source == 'OpenStreetMap':
235
+ # Crowd-sourced building data with varying completeness
236
+ gdf = load_gdf_from_openstreetmap(rectangle_vertices, floor_height=floor_height)
237
+ elif source == "Open Building 2.5D Temporal":
238
+ # Special case: this source provides both footprints and heights
239
+ # Skip GeoDataFrame processing and create grids directly
240
+ building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_open_building_temporal_polygon(meshsize, rectangle_vertices, output_dir)
241
+ elif source == 'EUBUCCO v0.1':
242
+ # European building database with height information
243
+ gdf = load_gdf_from_eubucco(rectangle_vertices, output_dir)
244
+ # elif source == "OpenMapTiles":
245
+ # # Vector tiles service for building data
246
+ # gdf = load_gdf_from_openmaptiles(rectangle_vertices, kwargs["maptiler_API_key"])
247
+ elif source == "Overture":
248
+ # Open building dataset from Overture Maps Foundation
249
+ gdf = load_gdf_from_overture(rectangle_vertices, floor_height=floor_height)
250
+ elif source == "Local file":
251
+ # Handle user-provided local building data files
252
+ _, extension = os.path.splitext(kwargs["building_path"])
253
+ if extension == ".gpkg":
254
+ gdf = get_gdf_from_gpkg(kwargs["building_path"], rectangle_vertices)
255
+ elif source == "GeoDataFrame":
256
+ # This case is handled by the building_gdf parameter above
257
+ raise ValueError("When source is 'GeoDataFrame', building_gdf parameter must be provided")
258
+
259
+ # Handle complementary data sources to fill gaps or provide additional information
260
+ # This allows combining multiple sources for better coverage or accuracy
261
+ building_complementary_source = kwargs.get("building_complementary_source")
262
+ building_complement_height = kwargs.get("building_complement_height")
263
+ overlapping_footprint = kwargs.get("overlapping_footprint")
264
+
265
+ if (building_complementary_source is None) or (building_complementary_source=='None'):
266
+ # Use only the primary data source
267
+ if source != "Open Building 2.5D Temporal":
268
+ building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
269
+ else:
270
+ # Combine primary source with complementary data
271
+ if building_complementary_source == "Open Building 2.5D Temporal":
272
+ # Use temporal height data to complement footprint data
273
+ roi = get_roi(rectangle_vertices)
274
+ os.makedirs(output_dir, exist_ok=True)
275
+ geotiff_path_comp = os.path.join(output_dir, "building_height.tif")
276
+ save_geotiff_open_buildings_temporal(roi, geotiff_path_comp)
277
+ building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, geotiff_path_comp=geotiff_path_comp, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
278
+ elif building_complementary_source in ["England 1m DSM - DTM", "Netherlands 0.5m DSM - DTM"]:
279
+ # Use digital surface model minus digital terrain model for height estimation
280
+ roi = get_roi(rectangle_vertices)
281
+ os.makedirs(output_dir, exist_ok=True)
282
+ geotiff_path_comp = os.path.join(output_dir, "building_height.tif")
283
+ save_geotiff_dsm_minus_dtm(roi, geotiff_path_comp, meshsize, building_complementary_source)
284
+ building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, geotiff_path_comp=geotiff_path_comp, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
285
+ else:
286
+ # Fetch complementary data from another vector source
287
+ if building_complementary_source == 'Microsoft Building Footprints':
288
+ gdf_comp = get_mbfp_gdf(output_dir, rectangle_vertices)
289
+ elif building_complementary_source == 'OpenStreetMap':
290
+ gdf_comp = load_gdf_from_openstreetmap(rectangle_vertices, floor_height=floor_height)
291
+ elif building_complementary_source == 'EUBUCCO v0.1':
292
+ gdf_comp = load_gdf_from_eubucco(rectangle_vertices, output_dir)
293
+ # elif building_complementary_source == "OpenMapTiles":
294
+ # gdf_comp = load_gdf_from_openmaptiles(rectangle_vertices, kwargs["maptiler_API_key"])
295
+ elif building_complementary_source == "Overture":
296
+ gdf_comp = load_gdf_from_overture(rectangle_vertices, floor_height=floor_height)
297
+ elif building_complementary_source == "Local file":
298
+ _, extension = os.path.splitext(kwargs["building_complementary_path"])
299
+ if extension == ".gpkg":
300
+ gdf_comp = get_gdf_from_gpkg(kwargs["building_complementary_path"], rectangle_vertices)
301
+
302
+ # Configure how to combine the complementary data
303
+ # Can complement footprints only or both footprints and heights
304
+ complement_building_footprints = kwargs.get("complement_building_footprints")
305
+ building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, gdf_comp=gdf_comp, complement_building_footprints=complement_building_footprints, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
306
+
307
+ # Generate visualization if requested
308
+ grid_vis = kwargs.get("gridvis", True)
309
+ if grid_vis:
310
+ # Replace zeros with NaN for better visualization (don't show empty areas)
311
+ building_height_grid_nan = building_height_grid.copy()
312
+ building_height_grid_nan[building_height_grid_nan == 0] = np.nan
313
+ # Flip grid vertically for correct display orientation
314
+ visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
315
+
316
+ return building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings
317
+
318
+ def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
319
+ """Creates canopy top and bottom height grids.
320
+
321
+ Supports satellite sources and a GeoDataFrame source (from draw_additional_trees).
322
+
323
+ Args:
324
+ rectangle_vertices: List of coordinates defining the area of interest
325
+ meshsize: Size of each grid cell in meters
326
+ source: Data source for canopy heights. Use 'GeoDataFrame' or 'tree_gdf' for tree_gdf path/object.
327
+ output_dir: Directory to save output files
328
+ **kwargs: Additional arguments including:
329
+ - gridvis: Whether to visualize the grid
330
+ - tree_gdf: GeoDataFrame of trees (optional when source='GeoDataFrame')
331
+ - tree_gdf_path: Path to a local file (e.g., .gpkg) with trees when source='GeoDataFrame'
332
+ - trunk_height_ratio: Fraction of top height used as canopy bottom for non-GDF sources
333
+
334
+ Returns:
335
+ tuple[numpy.ndarray, numpy.ndarray]: (canopy_top_height_grid, canopy_bottom_height_grid)
336
+ """
337
+
338
+ print("Creating Canopy Height grid\n ")
339
+ print(f"Data source: {source}")
340
+
341
+ os.makedirs(output_dir, exist_ok=True)
342
+
343
+ # Branch: compute from a provided GeoDataFrame (draw_additional_trees output)
344
+ if source in ('GeoDataFrame', 'tree_gdf', 'Tree_GeoDataFrame', 'GDF'):
345
+ tree_gdf = kwargs.get('tree_gdf')
346
+ tree_gdf_path = kwargs.get('tree_gdf_path')
347
+ if tree_gdf is None and tree_gdf_path is not None:
348
+ _, ext = os.path.splitext(tree_gdf_path)
349
+ if ext.lower() == '.gpkg':
350
+ tree_gdf = get_gdf_from_gpkg(tree_gdf_path, rectangle_vertices)
351
+ else:
352
+ raise ValueError("Unsupported tree file format. Use .gpkg or pass a GeoDataFrame.")
353
+ if tree_gdf is None:
354
+ raise ValueError("When source='GeoDataFrame', provide 'tree_gdf' or 'tree_gdf_path'.")
355
+
356
+ canopy_top, canopy_bottom = create_canopy_grids_from_tree_gdf(tree_gdf, meshsize, rectangle_vertices)
357
+
358
+ # Visualization
359
+ grid_vis = kwargs.get("gridvis", True)
360
+ if grid_vis:
361
+ vis = canopy_top.copy()
362
+ vis[vis == 0] = np.nan
363
+ visualize_numerical_grid(np.flipud(vis), meshsize, "Tree canopy height (top)", cmap='Greens', label='Tree canopy height (m)')
364
+
365
+ return canopy_top, canopy_bottom
366
+
367
+ # Default: satellite/remote sensing sources
368
+ print("Data source: High Resolution Canopy Height Maps by WRI and Meta")
369
+ initialize_earth_engine()
370
+
371
+ geotiff_path = os.path.join(output_dir, "canopy_height.tif")
372
+
373
+ roi = get_roi(rectangle_vertices)
374
+ if source == 'High Resolution 1m Global Canopy Height Maps':
375
+ collection_name = "projects/meta-forest-monitoring-okw37/assets/CanopyHeight"
376
+ image = get_ee_image_collection(collection_name, roi)
377
+ elif source == 'ETH Global Sentinel-2 10m Canopy Height (2020)':
378
+ collection_name = "users/nlang/ETH_GlobalCanopyHeight_2020_10m_v1"
379
+ image = get_ee_image(collection_name, roi)
380
+ else:
381
+ raise ValueError(f"Unsupported canopy source: {source}")
382
+
383
+ save_geotiff(image, geotiff_path, resolution=meshsize)
384
+ canopy_height_grid = create_height_grid_from_geotiff_polygon(geotiff_path, meshsize, rectangle_vertices)
385
+
386
+ # Derive bottom grid using trunk_height_ratio (consistent with create_3d_voxel)
387
+ trunk_height_ratio = kwargs.get("trunk_height_ratio")
388
+ if trunk_height_ratio is None:
389
+ trunk_height_ratio = 11.76 / 19.98
390
+ canopy_bottom_grid = canopy_height_grid * float(trunk_height_ratio)
391
+
392
+ grid_vis = kwargs.get("gridvis", True)
393
+ if grid_vis:
394
+ canopy_height_grid_nan = canopy_height_grid.copy()
395
+ canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
396
+ visualize_numerical_grid(np.flipud(canopy_height_grid_nan), meshsize, "Tree canopy height", cmap='Greens', label='Tree canopy height (m)')
397
+ return canopy_height_grid, canopy_bottom_grid
398
+
399
+ def get_dem_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
400
+ """Creates a digital elevation model grid.
401
+
402
+ Args:
403
+ rectangle_vertices: List of coordinates defining the area of interest
404
+ meshsize: Size of each grid cell in meters
405
+ source: Data source for DEM
406
+ output_dir: Directory to save output files
407
+ **kwargs: Additional arguments including:
408
+ - dem_interpolation: Interpolation method for DEM
409
+ - gridvis: Whether to visualize the grid
410
+
411
+ Returns:
412
+ numpy.ndarray: Grid of elevation values
413
+ """
414
+
415
+ print("Creating Digital Elevation Model (DEM) grid\n ")
416
+ print(f"Data source: {source}")
417
+
418
+ if source == "Local file":
419
+ # Use user-provided local DEM file
420
+ geotiff_path = kwargs["dem_path"]
421
+ else:
422
+ # Fetch DEM data from various satellite/government sources
423
+ initialize_earth_engine()
424
+
425
+ geotiff_path = os.path.join(output_dir, "dem.tif")
426
+
427
+ # Add buffer around region of interest to ensure smooth interpolation at edges
428
+ # This prevents edge artifacts in the final grid
429
+ buffer_distance = 100
430
+ roi = get_roi(rectangle_vertices)
431
+ roi_buffered = roi.buffer(buffer_distance)
432
+
433
+ # Fetch elevation data from selected source
434
+ image = get_dem_image(roi_buffered, source)
435
+
436
+ # Save DEM data with appropriate resolution based on source capabilities
437
+ if source in ["England 1m DTM", 'DEM France 1m', 'DEM France 5m', 'AUSTRALIA 5M DEM', 'Netherlands 0.5m DTM']:
438
+ # High-resolution elevation models - use specified mesh size
439
+ save_geotiff(image, geotiff_path, scale=meshsize, region=roi_buffered, crs='EPSG:4326')
440
+ elif source == 'USGS 3DEP 1m':
441
+ # US Geological Survey 3D Elevation Program
442
+ # Ensure minimum scale of 1.25m due to data limitations
443
+ scale = max(meshsize, 1.25)
444
+ save_geotiff(image, geotiff_path, scale=scale, region=roi_buffered, crs='EPSG:4326')
445
+ else:
446
+ # Default to 30m resolution for global/lower resolution sources
447
+ save_geotiff(image, geotiff_path, scale=30, region=roi_buffered)
448
+
449
+ # Convert GeoTIFF to regular grid with optional interpolation
450
+ # Interpolation helps fill gaps and smooth transitions
451
+ dem_interpolation = kwargs.get("dem_interpolation")
452
+ dem_grid = create_dem_grid_from_geotiff_polygon(geotiff_path, meshsize, rectangle_vertices, dem_interpolation=dem_interpolation)
453
+
454
+ # Generate visualization if requested
455
+ grid_vis = kwargs.get("gridvis", True)
456
+ if grid_vis:
457
+ # Use terrain color scheme appropriate for elevation data
458
+ visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
459
+
460
+ return dem_grid
461
+
462
+ def create_3d_voxel(building_height_grid_ori, building_min_height_grid_ori,
463
+ building_id_grid_ori, land_cover_grid_ori, dem_grid_ori,
464
+ tree_grid_ori, voxel_size, land_cover_source, canopy_bottom_height_grid_ori=None, **kwargs):
465
+ """Creates a 3D voxel representation combining all input grids.
466
+ Args:
467
+ building_height_grid_ori: Grid of building heights
468
+ building_min_height_grid_ori: Grid of building minimum heights
469
+ building_id_grid_ori: Grid of building IDs
470
+ land_cover_grid_ori: Grid of land cover classifications
471
+ dem_grid_ori: Grid of elevation values
472
+ tree_grid_ori: Grid of tree heights
473
+ voxel_size: Size of each voxel in meters
474
+ land_cover_source: Source of land cover data
475
+ kwargs: Additional arguments including:
476
+ - trunk_height_ratio: Ratio of trunk height to total tree height
477
+ Returns:
478
+ numpy.ndarray: 3D voxel grid with encoded values for different features
479
+ """
480
+ print("Generating 3D voxel data")
481
+
482
+ # Convert land cover values to standardized format if needed
483
+ # OpenStreetMap data is already in the correct format
484
+ if (land_cover_source == 'OpenStreetMap'):
485
+ land_cover_grid_converted = land_cover_grid_ori
486
+ else:
487
+ land_cover_grid_converted = convert_land_cover(land_cover_grid_ori, land_cover_source=land_cover_source)
488
+ # Prepare all input grids for 3D processing
489
+ # Flip vertically to align with standard geographic orientation (north-up)
490
+ # Handle missing data appropriately for each grid type
491
+ building_height_grid = np.flipud(np.nan_to_num(building_height_grid_ori, nan=10.0)) # Replace NaN values with 10m height
492
+ building_min_height_grid = np.flipud(replace_nan_in_nested(building_min_height_grid_ori)) # Replace NaN in nested arrays
493
+ building_id_grid = np.flipud(building_id_grid_ori)
494
+ land_cover_grid = np.flipud(land_cover_grid_converted.copy()) + 1 # Add 1 to avoid 0 values in land cover
495
+ dem_grid = np.flipud(dem_grid_ori.copy()) - np.min(dem_grid_ori) # Normalize DEM to start at 0
496
+ dem_grid = process_grid(building_id_grid, dem_grid) # Process DEM based on building footprints
497
+ tree_grid = np.flipud(tree_grid_ori.copy())
498
+ canopy_bottom_grid = None
499
+ if canopy_bottom_height_grid_ori is not None:
500
+ canopy_bottom_grid = np.flipud(canopy_bottom_height_grid_ori.copy())
501
+ # Validate that all input grids have consistent dimensions
502
+ assert building_height_grid.shape == land_cover_grid.shape == dem_grid.shape == tree_grid.shape, "Input grids must have the same shape"
503
+ rows, cols = building_height_grid.shape
504
+ # Calculate the required height for the 3D voxel grid
505
+ # Add 1 voxel layer to ensure sufficient vertical space
506
+ max_height = int(np.ceil(np.max(building_height_grid + dem_grid + tree_grid) / voxel_size))+1
507
+ # Initialize the 3D voxel grid with zeros
508
+ # Use int8 by default to reduce memory (values range from about -99 to small positives)
509
+ # Allow override via kwarg 'voxel_dtype'
510
+ voxel_dtype = kwargs.get("voxel_dtype", np.int8)
511
+
512
+ # Optional: estimate memory and allow a soft limit before allocating
513
+ try:
514
+ bytes_per_elem = np.dtype(voxel_dtype).itemsize
515
+ est_mb = rows * cols * max_height * bytes_per_elem / (1024 ** 2)
516
+ print(f"Voxel grid shape: ({rows}, {cols}, {max_height}), dtype: {voxel_dtype}, ~{est_mb:.1f} MB")
517
+ max_ram_mb = kwargs.get("max_voxel_ram_mb")
518
+ if (max_ram_mb is not None) and (est_mb > max_ram_mb):
519
+ raise MemoryError(f"Estimated voxel grid memory {est_mb:.1f} MB exceeds limit {max_ram_mb} MB. Increase mesh size or restrict ROI.")
520
+ except Exception:
521
+ pass
522
+
523
+ voxel_grid = np.zeros((rows, cols, max_height), dtype=voxel_dtype)
524
+ # Configure tree trunk-to-crown ratio
525
+ # This determines how much of the tree is trunk vs canopy
526
+ trunk_height_ratio = kwargs.get("trunk_height_ratio")
527
+ if trunk_height_ratio is None:
528
+ trunk_height_ratio = 11.76 / 19.98 # Default ratio based on typical tree proportions
529
+ # Process each grid cell to build the 3D voxel representation
530
+ for i in range(rows):
531
+ for j in range(cols):
532
+ # Calculate ground level in voxel units
533
+ # Add 1 to ensure space for surface features
534
+ ground_level = int(dem_grid[i, j] / voxel_size + 0.5) + 1
535
+ # Extract current cell values
536
+ tree_height = tree_grid[i, j]
537
+ land_cover = land_cover_grid[i, j]
538
+ # Fill underground voxels with -1 (represents subsurface)
539
+ voxel_grid[i, j, :ground_level] = -1
540
+ # Set the ground surface to the land cover type
541
+ voxel_grid[i, j, ground_level-1] = land_cover
542
+ # Process tree canopy if trees are present
543
+ if tree_height > 0:
544
+ # Calculate tree structure: trunk base to crown base to crown top
545
+ if canopy_bottom_grid is not None:
546
+ crown_base_height = canopy_bottom_grid[i, j]
547
+ else:
548
+ crown_base_height = (tree_height * trunk_height_ratio)
549
+ crown_base_height_level = int(crown_base_height / voxel_size + 0.5)
550
+ crown_top_height = tree_height
551
+ crown_top_height_level = int(crown_top_height / voxel_size + 0.5)
552
+
553
+ # Ensure minimum crown height of 1 voxel
554
+ # Prevent crown base and top from being at the same level
555
+ if (crown_top_height_level == crown_base_height_level) and (crown_base_height_level>0):
556
+ crown_base_height_level -= 1
557
+
558
+ # Calculate absolute positions relative to ground level
559
+ tree_start = ground_level + crown_base_height_level
560
+ tree_end = ground_level + crown_top_height_level
561
+
562
+ # Fill tree crown voxels with -2 (represents vegetation canopy)
563
+ voxel_grid[i, j, tree_start:tree_end] = -2
564
+ # Process buildings - handle multiple height segments per building
565
+ # Some buildings may have multiple levels or complex height profiles
566
+ for k in building_min_height_grid[i, j]:
567
+ building_min_height = int(k[0] / voxel_size + 0.5) # Lower height of building segment
568
+ building_height = int(k[1] / voxel_size + 0.5) # Upper height of building segment
569
+ # Fill building voxels with -3 (represents built structures)
570
+ voxel_grid[i, j, ground_level+building_min_height:ground_level+building_height] = -3
571
+ return voxel_grid
572
+
573
+ def create_3d_voxel_individuals(building_height_grid_ori, land_cover_grid_ori, dem_grid_ori, tree_grid_ori, voxel_size, land_cover_source, layered_interval=None):
574
+ """Creates separate 3D voxel grids for each component.
575
+
576
+ Args:
577
+ building_height_grid_ori: Grid of building heights
578
+ land_cover_grid_ori: Grid of land cover classifications
579
+ dem_grid_ori: Grid of elevation values
580
+ tree_grid_ori: Grid of tree heights
581
+ voxel_size: Size of each voxel in meters
582
+ land_cover_source: Source of land cover data
583
+ layered_interval: Interval for layered output
584
+
585
+ Returns:
586
+ tuple:
587
+ - numpy.ndarray: Land cover voxel grid
588
+ - numpy.ndarray: Building voxel grid
589
+ - numpy.ndarray: Tree voxel grid
590
+ - numpy.ndarray: DEM voxel grid
591
+ - numpy.ndarray: Combined layered voxel grid
592
+ """
593
+
594
+ print("Generating 3D voxel data")
595
+ # Convert land cover values if not from OpenEarthMapJapan
596
+ if land_cover_source != 'OpenEarthMapJapan':
597
+ land_cover_grid_converted = convert_land_cover(land_cover_grid_ori, land_cover_source=land_cover_source)
598
+ else:
599
+ land_cover_grid_converted = land_cover_grid_ori
600
+
601
+ # Prepare and flip all input grids vertically
602
+ building_height_grid = np.flipud(building_height_grid_ori.copy())
603
+ land_cover_grid = np.flipud(land_cover_grid_converted.copy()) + 1 # Add 1 to avoid 0 values
604
+ dem_grid = np.flipud(dem_grid_ori.copy()) - np.min(dem_grid_ori) # Normalize DEM to start at 0
605
+ building_nr_grid = group_and_label_cells(np.flipud(building_height_grid_ori.copy()))
606
+ dem_grid = process_grid(building_nr_grid, dem_grid) # Process DEM based on building footprints
607
+ tree_grid = np.flipud(tree_grid_ori.copy())
608
+
609
+ # Validate input dimensions
610
+ assert building_height_grid.shape == land_cover_grid.shape == dem_grid.shape == tree_grid.shape, "Input grids must have the same shape"
611
+
612
+ rows, cols = building_height_grid.shape
613
+
614
+ # Calculate required height for 3D grid
615
+ max_height = int(np.ceil(np.max(building_height_grid + dem_grid + tree_grid) / voxel_size))
616
+
617
+ # Initialize empty 3D grids for each component
618
+ land_cover_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
619
+ building_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
620
+ tree_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
621
+ dem_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
622
+
623
+ # Fill individual component grids
624
+ for i in range(rows):
625
+ for j in range(cols):
626
+ ground_level = int(dem_grid[i, j] / voxel_size + 0.5)
627
+ building_height = int(building_height_grid[i, j] / voxel_size + 0.5)
628
+ tree_height = int(tree_grid[i, j] / voxel_size + 0.5)
629
+ land_cover = land_cover_grid[i, j]
630
+
631
+ # Fill underground cells with -1
632
+ dem_voxel_grid[i, j, :ground_level+1] = -1
633
+
634
+ # Set ground level cell to land cover
635
+ land_cover_voxel_grid[i, j, 0] = land_cover
636
+
637
+ # Fill tree crown with value -2
638
+ if tree_height > 0:
639
+ tree_voxel_grid[i, j, :tree_height] = -2
640
+
641
+ # Fill building with value -3
642
+ if building_height > 0:
643
+ building_voxel_grid[i, j, :building_height] = -3
644
+
645
+ # Set default layered interval if not provided
646
+ if not layered_interval:
647
+ layered_interval = max(max_height, int(dem_grid.shape[0]/4 + 0.5))
648
+
649
+ # Create combined layered visualization
650
+ extract_height = min(layered_interval, max_height)
651
+ layered_voxel_grid = np.zeros((rows, cols, layered_interval*4), dtype=np.int32)
652
+
653
+ # Stack components in layers with equal spacing
654
+ layered_voxel_grid[:, :, :extract_height] = dem_voxel_grid[:, :, :extract_height]
655
+ layered_voxel_grid[:, :, layered_interval:layered_interval+extract_height] = land_cover_voxel_grid[:, :, :extract_height]
656
+ layered_voxel_grid[:, :, 2*layered_interval:2*layered_interval+extract_height] = building_voxel_grid[:, :, :extract_height]
657
+ layered_voxel_grid[:, :, 3*layered_interval:3*layered_interval+extract_height] = tree_voxel_grid[:, :, :extract_height]
658
+
659
+ return land_cover_voxel_grid, building_voxel_grid, tree_voxel_grid, dem_voxel_grid, layered_voxel_grid
660
+
661
+ def get_voxcity(rectangle_vertices, building_source, land_cover_source, canopy_height_source, dem_source, meshsize, building_gdf=None, **kwargs):
662
+ """Main function to generate a complete voxel city model.
663
+
664
+ Args:
665
+ rectangle_vertices: List of coordinates defining the area of interest
666
+ building_source: Source for building height data (e.g. 'OSM', 'EUBUCCO', 'GeoDataFrame')
667
+ land_cover_source: Source for land cover data (e.g. 'ESA', 'ESRI')
668
+ canopy_height_source: Source for tree canopy height data
669
+ dem_source: Source for digital elevation model data ('Flat' or other source)
670
+ meshsize: Size of each grid cell in meters
671
+ building_gdf: Optional GeoDataFrame with building footprint, height and other information
672
+ **kwargs: Additional keyword arguments including:
673
+ - output_dir: Directory to save output files (default: 'output')
674
+ - min_canopy_height: Minimum height threshold for tree canopy
675
+ - remove_perimeter_object: Factor to remove objects near perimeter
676
+ - mapvis: Whether to visualize grids on map
677
+ - voxelvis: Whether to visualize 3D voxel model
678
+ - voxelvis_img_save_path: Path to save 3D visualization
679
+ - default_land_cover_class: Default class for land cover grid cells with no intersecting polygons (default: 'Developed space')
680
+
681
+ Returns:
682
+ tuple containing:
683
+ - voxcity_grid: 3D voxel grid of the complete city model
684
+ - building_height_grid: 2D grid of building heights
685
+ - building_min_height_grid: 2D grid of minimum building heights
686
+ - building_id_grid: 2D grid of building IDs
687
+ - canopy_height_grid: 2D grid of tree canopy top heights
688
+ - canopy_bottom_height_grid: 2D grid of tree canopy bottom heights
689
+ - land_cover_grid: 2D grid of land cover classifications
690
+ - dem_grid: 2D grid of ground elevation
691
+ - building_geojson: GeoJSON of building footprints and metadata
692
+ """
693
+ # Set up output directory for intermediate and final files
694
+ output_dir = kwargs.get("output_dir", "output")
695
+ os.makedirs(output_dir, exist_ok=True)
696
+
697
+ # Remove 'output_dir' from kwargs to prevent duplication in function calls
698
+ kwargs.pop('output_dir', None)
699
+
700
+ # STEP 1: Generate all required 2D grids from various data sources
701
+ # These grids form the foundation for the 3D voxel model
702
+
703
+ # Land cover classification grid (e.g., urban, forest, water, agriculture)
704
+ land_cover_grid = get_land_cover_grid(rectangle_vertices, meshsize, land_cover_source, output_dir, **kwargs)
705
+
706
+ # Building footprints and height information
707
+ building_height_grid, building_min_height_grid, building_id_grid, building_gdf = get_building_height_grid(rectangle_vertices, meshsize, building_source, output_dir, building_gdf=building_gdf, **kwargs)
708
+
709
+ # Save building data to file for later analysis or visualization
710
+ if not building_gdf.empty:
711
+ save_path = f"{output_dir}/building.gpkg"
712
+ building_gdf.to_file(save_path, driver='GPKG')
713
+
714
+ # STEP 2: Handle canopy height data
715
+ # Either use static values or fetch from satellite sources
716
+ if canopy_height_source == "Static":
717
+ # Create uniform canopy height for all tree-covered areas (top grid)
718
+ canopy_height_grid = np.zeros_like(land_cover_grid, dtype=float)
719
+ static_tree_height = kwargs.get("static_tree_height", 10.0)
720
+ # Determine tree class indices based on source-specific class names
721
+ _classes = get_land_cover_classes(land_cover_source)
722
+ _class_to_int = {name: i for i, name in enumerate(_classes.values())}
723
+ _tree_labels = ["Tree", "Trees", "Tree Canopy"]
724
+ _tree_indices = [_class_to_int[label] for label in _tree_labels if label in _class_to_int]
725
+ tree_mask = np.isin(land_cover_grid, _tree_indices) if _tree_indices else np.zeros_like(land_cover_grid, dtype=bool)
726
+ canopy_height_grid[tree_mask] = static_tree_height
727
+
728
+ # Derive bottom from trunk_height_ratio
729
+ trunk_height_ratio = kwargs.get("trunk_height_ratio")
730
+ if trunk_height_ratio is None:
731
+ trunk_height_ratio = 11.76 / 19.98
732
+ canopy_bottom_height_grid = canopy_height_grid * float(trunk_height_ratio)
733
+ else:
734
+ # Fetch canopy top/bottom from source
735
+ canopy_height_grid, canopy_bottom_height_grid = get_canopy_height_grid(rectangle_vertices, meshsize, canopy_height_source, output_dir, **kwargs)
736
+
737
+ # STEP 3: Handle digital elevation model (terrain)
738
+ if dem_source == "Flat":
739
+ # Create flat terrain for simplified modeling
740
+ dem_grid = np.zeros_like(land_cover_grid)
741
+ else:
742
+ # Fetch terrain elevation from various sources
743
+ dem_grid = get_dem_grid(rectangle_vertices, meshsize, dem_source, output_dir, **kwargs)
744
+
745
+ # STEP 4: Apply optional data filtering and cleaning
746
+
747
+ # Filter out low vegetation that may be noise in the data
748
+ min_canopy_height = kwargs.get("min_canopy_height")
749
+ if min_canopy_height is not None:
750
+ canopy_height_grid[canopy_height_grid < kwargs["min_canopy_height"]] = 0
751
+ if 'canopy_bottom_height_grid' in locals():
752
+ canopy_bottom_height_grid[canopy_height_grid == 0] = 0
753
+
754
+ # Remove objects near the boundary to avoid edge effects
755
+ # This is useful when the area of interest is part of a larger urban area
756
+ remove_perimeter_object = kwargs.get("remove_perimeter_object")
757
+ if (remove_perimeter_object is not None) and (remove_perimeter_object > 0):
758
+ print("apply perimeter removal")
759
+ # Calculate perimeter width based on grid dimensions
760
+ w_peri = int(remove_perimeter_object * building_height_grid.shape[0] + 0.5)
761
+ h_peri = int(remove_perimeter_object * building_height_grid.shape[1] + 0.5)
762
+
763
+ # Clear canopy heights in perimeter areas
764
+ canopy_height_grid[:w_peri, :] = canopy_height_grid[-w_peri:, :] = canopy_height_grid[:, :h_peri] = canopy_height_grid[:, -h_peri:] = 0
765
+ if 'canopy_bottom_height_grid' in locals():
766
+ canopy_bottom_height_grid[:w_peri, :] = canopy_bottom_height_grid[-w_peri:, :] = canopy_bottom_height_grid[:, :h_peri] = canopy_bottom_height_grid[:, -h_peri:] = 0
767
+
768
+ # Identify buildings that intersect with perimeter areas
769
+ ids1 = np.unique(building_id_grid[:w_peri, :][building_id_grid[:w_peri, :] > 0])
770
+ ids2 = np.unique(building_id_grid[-w_peri:, :][building_id_grid[-w_peri:, :] > 0])
771
+ ids3 = np.unique(building_id_grid[:, :h_peri][building_id_grid[:, :h_peri] > 0])
772
+ ids4 = np.unique(building_id_grid[:, -h_peri:][building_id_grid[:, -h_peri:] > 0])
773
+ remove_ids = np.concatenate((ids1, ids2, ids3, ids4))
774
+
775
+ # Remove identified buildings from all grids
776
+ for remove_id in remove_ids:
777
+ positions = np.where(building_id_grid == remove_id)
778
+ building_height_grid[positions] = 0
779
+ building_min_height_grid[positions] = [[] for _ in range(len(building_min_height_grid[positions]))]
780
+
781
+ # Visualize grids after optional perimeter removal
782
+ grid_vis = kwargs.get("gridvis", True)
783
+ if grid_vis:
784
+ # Building height grid visualization (zeros hidden)
785
+ building_height_grid_nan = building_height_grid.copy()
786
+ building_height_grid_nan[building_height_grid_nan == 0] = np.nan
787
+ visualize_numerical_grid(
788
+ np.flipud(building_height_grid_nan),
789
+ meshsize,
790
+ "building height (m)",
791
+ cmap='viridis',
792
+ label='Value'
793
+ )
794
+
795
+ # Canopy height grid visualization (zeros hidden)
796
+ canopy_height_grid_nan = canopy_height_grid.copy()
797
+ canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
798
+ visualize_numerical_grid(
799
+ np.flipud(canopy_height_grid_nan),
800
+ meshsize,
801
+ "Tree canopy height (m)",
802
+ cmap='Greens',
803
+ label='Tree canopy height (m)'
804
+ )
805
+
806
+ # STEP 5: Generate optional 2D visualizations on interactive maps
807
+ mapvis = kwargs.get("mapvis")
808
+ if mapvis:
809
+ # Create map-based visualizations of all data layers
810
+ # These help users understand the input data before 3D modeling
811
+
812
+ # Visualize land cover using the new function
813
+ visualize_landcover_grid_on_basemap(
814
+ land_cover_grid,
815
+ rectangle_vertices,
816
+ meshsize,
817
+ source=land_cover_source,
818
+ alpha=0.7,
819
+ figsize=(12, 8),
820
+ basemap='CartoDB light',
821
+ show_edge=False
822
+ )
823
+
824
+ # Visualize building heights using the new function
825
+ visualize_numerical_grid_on_basemap(
826
+ building_height_grid,
827
+ rectangle_vertices,
828
+ meshsize,
829
+ value_name="Building Heights (m)",
830
+ cmap='viridis',
831
+ alpha=0.7,
832
+ figsize=(12, 8),
833
+ basemap='CartoDB light',
834
+ show_edge=False
835
+ )
836
+
837
+ # Visualize canopy heights using the new function
838
+ visualize_numerical_grid_on_basemap(
839
+ canopy_height_grid,
840
+ rectangle_vertices,
841
+ meshsize,
842
+ value_name="Canopy Heights (m)",
843
+ cmap='Greens',
844
+ alpha=0.7,
845
+ figsize=(12, 8),
846
+ basemap='CartoDB light',
847
+ show_edge=False
848
+ )
849
+
850
+ # Visualize DEM using the new function
851
+ visualize_numerical_grid_on_basemap(
852
+ dem_grid,
853
+ rectangle_vertices,
854
+ meshsize,
855
+ value_name="Terrain Elevation (m)",
856
+ cmap='terrain',
857
+ alpha=0.7,
858
+ figsize=(12, 8),
859
+ basemap='CartoDB light',
860
+ show_edge=False
861
+ )
862
+
863
+ # STEP 6: Generate the final 3D voxel model
864
+ # This combines all 2D grids into a comprehensive 3D representation
865
+ voxcity_grid = create_3d_voxel(building_height_grid, building_min_height_grid, building_id_grid, land_cover_grid, dem_grid, canopy_height_grid, meshsize, land_cover_source, canopy_bottom_height_grid)
866
+
867
+ # STEP 7: Generate optional 3D visualization
868
+ voxelvis = kwargs.get("voxelvis")
869
+ if voxelvis:
870
+ # Create a taller grid for better visualization
871
+ # Fixed height ensures consistent camera positioning
872
+ new_height = int(550/meshsize+0.5)
873
+ voxcity_grid_vis = np.zeros((voxcity_grid.shape[0], voxcity_grid.shape[1], new_height))
874
+ voxcity_grid_vis[:, :, :voxcity_grid.shape[2]] = voxcity_grid
875
+ voxcity_grid_vis[-1, -1, -1] = -99 # Add marker to fix camera location and angle of view
876
+ visualize_3d_voxel(voxcity_grid_vis, voxel_size=meshsize, save_path=kwargs["voxelvis_img_save_path"])
877
+
878
+ # STEP 8: Save all generated data for future use
879
+ save_voxcity = kwargs.get("save_voxctiy_data", True)
880
+ if save_voxcity:
881
+ save_path = kwargs.get("save_data_path", f"{output_dir}/voxcity_data.pkl")
882
+ save_voxcity_data(save_path, voxcity_grid, building_height_grid, building_min_height_grid,
883
+ building_id_grid, canopy_height_grid, land_cover_grid, dem_grid,
884
+ building_gdf, meshsize, rectangle_vertices)
885
+
886
+ return voxcity_grid, building_height_grid, building_min_height_grid, building_id_grid, canopy_height_grid, canopy_bottom_height_grid, land_cover_grid, dem_grid, building_gdf
887
+
888
+ def get_voxcity_CityGML(rectangle_vertices, land_cover_source, canopy_height_source, meshsize, url_citygml=None, citygml_path=None, **kwargs):
889
+ """Main function to generate a complete voxel city model.
890
+
891
+ Args:
892
+ rectangle_vertices: List of coordinates defining the area of interest
893
+ building_source: Source for building height data (e.g. 'OSM', 'EUBUCCO')
894
+ land_cover_source: Source for land cover data (e.g. 'ESA', 'ESRI')
895
+ canopy_height_source: Source for tree canopy height data
896
+ dem_source: Source for digital elevation model data ('Flat' or other source)
897
+ meshsize: Size of each grid cell in meters
898
+ **kwargs: Additional keyword arguments including:
899
+ - output_dir: Directory to save output files (default: 'output')
900
+ - min_canopy_height: Minimum height threshold for tree canopy
901
+ - remove_perimeter_object: Factor to remove objects near perimeter
902
+ - mapvis: Whether to visualize grids on map
903
+ - voxelvis: Whether to visualize 3D voxel model
904
+ - voxelvis_img_save_path: Path to save 3D visualization
905
+
906
+ Returns:
907
+ tuple containing:
908
+ - voxcity_grid: 3D voxel grid of the complete city model
909
+ - building_height_grid: 2D grid of building heights
910
+ - building_min_height_grid: 2D grid of minimum building heights
911
+ - building_id_grid: 2D grid of building IDs
912
+ - canopy_height_grid: 2D grid of tree canopy heights
913
+ - land_cover_grid: 2D grid of land cover classifications
914
+ - dem_grid: 2D grid of ground elevation
915
+ - building_geojson: GeoJSON of building footprints and metadata
916
+ """
917
+ # Create output directory if it doesn't exist
918
+ output_dir = kwargs.get("output_dir", "output")
919
+ os.makedirs(output_dir, exist_ok=True)
920
+
921
+ # Remove 'output_dir' from kwargs to prevent duplication
922
+ kwargs.pop('output_dir', None)
923
+
924
+ # get all required gdfs
925
+ building_gdf, terrain_gdf, vegetation_gdf = load_buid_dem_veg_from_citygml(url=url_citygml, citygml_path=citygml_path, base_dir=output_dir, rectangle_vertices=rectangle_vertices)
926
+
927
+ # Normalize CRS to WGS84 (EPSG:4326) to ensure consistent operations downstream
928
+ try:
929
+ import geopandas as gpd # noqa: F401
930
+ if building_gdf is not None:
931
+ if building_gdf.crs is None:
932
+ building_gdf = building_gdf.set_crs(epsg=4326)
933
+ elif getattr(building_gdf.crs, 'to_epsg', lambda: None)() != 4326 and building_gdf.crs != "EPSG:4326":
934
+ building_gdf = building_gdf.to_crs(epsg=4326)
935
+ if terrain_gdf is not None:
936
+ if terrain_gdf.crs is None:
937
+ terrain_gdf = terrain_gdf.set_crs(epsg=4326)
938
+ elif getattr(terrain_gdf.crs, 'to_epsg', lambda: None)() != 4326 and terrain_gdf.crs != "EPSG:4326":
939
+ terrain_gdf = terrain_gdf.to_crs(epsg=4326)
940
+ if vegetation_gdf is not None:
941
+ if vegetation_gdf.crs is None:
942
+ vegetation_gdf = vegetation_gdf.set_crs(epsg=4326)
943
+ elif getattr(vegetation_gdf.crs, 'to_epsg', lambda: None)() != 4326 and vegetation_gdf.crs != "EPSG:4326":
944
+ vegetation_gdf = vegetation_gdf.to_crs(epsg=4326)
945
+ except Exception:
946
+ pass
947
+
948
+ land_cover_grid = get_land_cover_grid(rectangle_vertices, meshsize, land_cover_source, output_dir, **kwargs)
949
+
950
+ # building_height_grid, building_min_height_grid, building_id_grid, building_gdf = get_building_height_grid(rectangle_vertices, meshsize, building_source, output_dir, **kwargs)
951
+ print("Creating building height grid")
952
+ # Prepare complementary building source if provided
953
+ building_complementary_source = kwargs.get("building_complementary_source")
954
+ gdf_comp = None
955
+ geotiff_path_comp = None
956
+ complement_building_footprints = kwargs.get("complement_building_footprints")
957
+ # Default to complement footprints when a complementary source is specified
958
+ if complement_building_footprints is None and (building_complementary_source not in (None, "None")):
959
+ complement_building_footprints = True
960
+
961
+ if (building_complementary_source is not None) and (building_complementary_source != "None"):
962
+ # Vector complementary sources
963
+ floor_height = kwargs.get("floor_height", 3.0)
964
+ if building_complementary_source == 'Microsoft Building Footprints':
965
+ gdf_comp = get_mbfp_gdf(kwargs.get("output_dir", "output"), rectangle_vertices)
966
+ elif building_complementary_source == 'OpenStreetMap':
967
+ gdf_comp = load_gdf_from_openstreetmap(rectangle_vertices, floor_height=floor_height)
968
+ elif building_complementary_source == 'EUBUCCO v0.1':
969
+ gdf_comp = load_gdf_from_eubucco(rectangle_vertices, kwargs.get("output_dir", "output"))
970
+ elif building_complementary_source == 'Overture':
971
+ gdf_comp = load_gdf_from_overture(rectangle_vertices, floor_height=floor_height)
972
+ elif building_complementary_source == 'Local file':
973
+ comp_path = kwargs.get("building_complementary_path")
974
+ if comp_path is not None:
975
+ _, extension = os.path.splitext(comp_path)
976
+ if extension == ".gpkg":
977
+ gdf_comp = get_gdf_from_gpkg(comp_path, rectangle_vertices)
978
+ # Ensure complementary GDF uses WGS84
979
+ if gdf_comp is not None:
980
+ try:
981
+ if gdf_comp.crs is None:
982
+ gdf_comp = gdf_comp.set_crs(epsg=4326)
983
+ elif getattr(gdf_comp.crs, 'to_epsg', lambda: None)() != 4326 and gdf_comp.crs != "EPSG:4326":
984
+ gdf_comp = gdf_comp.to_crs(epsg=4326)
985
+ except Exception:
986
+ pass
987
+ # Raster complementary sources (height only)
988
+ elif building_complementary_source == "Open Building 2.5D Temporal":
989
+ roi = get_roi(rectangle_vertices)
990
+ os.makedirs(kwargs.get("output_dir", "output"), exist_ok=True)
991
+ geotiff_path_comp = os.path.join(kwargs.get("output_dir", "output"), "building_height.tif")
992
+ save_geotiff_open_buildings_temporal(roi, geotiff_path_comp)
993
+ elif building_complementary_source in ["England 1m DSM - DTM", "Netherlands 0.5m DSM - DTM"]:
994
+ roi = get_roi(rectangle_vertices)
995
+ os.makedirs(kwargs.get("output_dir", "output"), exist_ok=True)
996
+ geotiff_path_comp = os.path.join(kwargs.get("output_dir", "output"), "building_height.tif")
997
+ save_geotiff_dsm_minus_dtm(roi, geotiff_path_comp, meshsize, building_complementary_source)
998
+
999
+ # Filter and assemble kwargs accepted by the grid function
1000
+ _allowed_building_kwargs = {
1001
+ "overlapping_footprint",
1002
+ "gdf_comp",
1003
+ "geotiff_path_comp",
1004
+ "complement_building_footprints",
1005
+ "complement_height",
1006
+ }
1007
+ _building_kwargs = {k: v for k, v in kwargs.items() if k in _allowed_building_kwargs}
1008
+ if gdf_comp is not None:
1009
+ _building_kwargs["gdf_comp"] = gdf_comp
1010
+ if geotiff_path_comp is not None:
1011
+ _building_kwargs["geotiff_path_comp"] = geotiff_path_comp
1012
+ if complement_building_footprints is not None:
1013
+ _building_kwargs["complement_building_footprints"] = complement_building_footprints
1014
+
1015
+ # Map user-provided building_complement_height -> complement_height for grid builder
1016
+ comp_height_user = kwargs.get("building_complement_height")
1017
+ if comp_height_user is not None:
1018
+ _building_kwargs["complement_height"] = comp_height_user
1019
+ # If footprints are being complemented and no height provided, default to 10
1020
+ if _building_kwargs.get("complement_building_footprints") and ("complement_height" not in _building_kwargs):
1021
+ _building_kwargs["complement_height"] = 10.0
1022
+
1023
+ building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(
1024
+ building_gdf, meshsize, rectangle_vertices, **_building_kwargs
1025
+ )
1026
+
1027
+ # Visualize grid if requested
1028
+ grid_vis = kwargs.get("gridvis", True)
1029
+ if grid_vis:
1030
+ building_height_grid_nan = building_height_grid.copy()
1031
+ building_height_grid_nan[building_height_grid_nan == 0] = np.nan
1032
+ visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
1033
+
1034
+ # Save building data to GeoJSON
1035
+ if not building_gdf.empty:
1036
+ save_path = f"{output_dir}/building.gpkg"
1037
+ building_gdf.to_file(save_path, driver='GPKG')
1038
+
1039
+ # Get canopy height data
1040
+ if canopy_height_source == "Static":
1041
+ # Create canopy height grid with same shape as land cover grid
1042
+ canopy_height_grid_comp = np.zeros_like(land_cover_grid, dtype=float)
1043
+
1044
+ # Set default static height for trees (20 meters is a typical average tree height)
1045
+ static_tree_height = kwargs.get("static_tree_height", 10.0)
1046
+ # Determine tree class indices based on source-specific class names
1047
+ _classes = get_land_cover_classes(land_cover_source)
1048
+ _class_to_int = {name: i for i, name in enumerate(_classes.values())}
1049
+ _tree_labels = ["Tree", "Trees", "Tree Canopy"]
1050
+ _tree_indices = [_class_to_int[label] for label in _tree_labels if label in _class_to_int]
1051
+ tree_mask = np.isin(land_cover_grid, _tree_indices) if _tree_indices else np.zeros_like(land_cover_grid, dtype=bool)
1052
+
1053
+ # Set static height for tree cells
1054
+ canopy_height_grid_comp[tree_mask] = static_tree_height
1055
+
1056
+ # Bottom comp from trunk ratio
1057
+ trunk_height_ratio = kwargs.get("trunk_height_ratio")
1058
+ if trunk_height_ratio is None:
1059
+ trunk_height_ratio = 11.76 / 19.98
1060
+ canopy_bottom_height_grid_comp = canopy_height_grid_comp * float(trunk_height_ratio)
1061
+ else:
1062
+ canopy_height_grid_comp, canopy_bottom_height_grid_comp = get_canopy_height_grid(rectangle_vertices, meshsize, canopy_height_source, output_dir, **kwargs)
1063
+
1064
+ # In the get_voxcity_CityGML function, modify it to handle None vegetation_gdf
1065
+ if vegetation_gdf is not None:
1066
+ canopy_height_grid = create_vegetation_height_grid_from_gdf_polygon(vegetation_gdf, meshsize, rectangle_vertices)
1067
+ # Base bottom grid from ratio
1068
+ trunk_height_ratio = kwargs.get("trunk_height_ratio")
1069
+ if trunk_height_ratio is None:
1070
+ trunk_height_ratio = 11.76 / 19.98
1071
+ canopy_bottom_height_grid = canopy_height_grid * float(trunk_height_ratio)
1072
+ else:
1073
+ # Create an empty canopy_height_grid with the same shape as your other grids
1074
+ # This depends on the expected shape, you might need to adjust
1075
+ canopy_height_grid = np.zeros_like(building_height_grid)
1076
+ canopy_bottom_height_grid = np.zeros_like(building_height_grid)
1077
+
1078
+ mask = (canopy_height_grid == 0) & (canopy_height_grid_comp != 0)
1079
+ canopy_height_grid[mask] = canopy_height_grid_comp[mask]
1080
+ # Apply same complementation to bottom grid
1081
+ mask_b = (canopy_bottom_height_grid == 0) & (canopy_bottom_height_grid_comp != 0)
1082
+ canopy_bottom_height_grid[mask_b] = canopy_bottom_height_grid_comp[mask_b]
1083
+
1084
+ # Ensure bottom <= top
1085
+ canopy_bottom_height_grid = np.minimum(canopy_bottom_height_grid, canopy_height_grid)
1086
+
1087
+ # Handle DEM - either flat or from source
1088
+ if kwargs.pop('flat_dem', None):
1089
+ dem_grid = np.zeros_like(land_cover_grid)
1090
+ else:
1091
+ print("Creating Digital Elevation Model (DEM) grid")
1092
+ dem_grid = create_dem_grid_from_gdf_polygon(terrain_gdf, meshsize, rectangle_vertices)
1093
+
1094
+ # Visualize grid if requested
1095
+ grid_vis = kwargs.get("gridvis", True)
1096
+ if grid_vis:
1097
+ visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
1098
+
1099
+
1100
+ # Apply minimum canopy height threshold if specified
1101
+ min_canopy_height = kwargs.get("min_canopy_height")
1102
+ if min_canopy_height is not None:
1103
+ canopy_height_grid[canopy_height_grid < kwargs["min_canopy_height"]] = 0
1104
+ canopy_bottom_height_grid[canopy_height_grid == 0] = 0
1105
+
1106
+ # Remove objects near perimeter if specified
1107
+ remove_perimeter_object = kwargs.get("remove_perimeter_object")
1108
+ if (remove_perimeter_object is not None) and (remove_perimeter_object > 0):
1109
+ print("apply perimeter removal")
1110
+ # Calculate perimeter width based on grid dimensions
1111
+ w_peri = int(remove_perimeter_object * building_height_grid.shape[0] + 0.5)
1112
+ h_peri = int(remove_perimeter_object * building_height_grid.shape[1] + 0.5)
1113
+
1114
+ # Clear canopy heights in perimeter
1115
+ canopy_height_grid[:w_peri, :] = canopy_height_grid[-w_peri:, :] = canopy_height_grid[:, :h_peri] = canopy_height_grid[:, -h_peri:] = 0
1116
+ canopy_bottom_height_grid[:w_peri, :] = canopy_bottom_height_grid[-w_peri:, :] = canopy_bottom_height_grid[:, :h_peri] = canopy_bottom_height_grid[:, -h_peri:] = 0
1117
+
1118
+ # Find building IDs in perimeter regions
1119
+ ids1 = np.unique(building_id_grid[:w_peri, :][building_id_grid[:w_peri, :] > 0])
1120
+ ids2 = np.unique(building_id_grid[-w_peri:, :][building_id_grid[-w_peri:, :] > 0])
1121
+ ids3 = np.unique(building_id_grid[:, :h_peri][building_id_grid[:, :h_peri] > 0])
1122
+ ids4 = np.unique(building_id_grid[:, -h_peri:][building_id_grid[:, -h_peri:] > 0])
1123
+ remove_ids = np.concatenate((ids1, ids2, ids3, ids4))
1124
+
1125
+ # Remove buildings in perimeter
1126
+ for remove_id in remove_ids:
1127
+ positions = np.where(building_id_grid == remove_id)
1128
+ building_height_grid[positions] = 0
1129
+ building_min_height_grid[positions] = [[] for _ in range(len(building_min_height_grid[positions]))]
1130
+
1131
+ # Visualize grids after optional perimeter removal
1132
+ grid_vis = kwargs.get("gridvis", True)
1133
+ if grid_vis:
1134
+ # Building height grid visualization (zeros hidden)
1135
+ building_height_grid_nan = building_height_grid.copy()
1136
+ building_height_grid_nan[building_height_grid_nan == 0] = np.nan
1137
+ visualize_numerical_grid(
1138
+ np.flipud(building_height_grid_nan),
1139
+ meshsize,
1140
+ "building height (m)",
1141
+ cmap='viridis',
1142
+ label='Value'
1143
+ )
1144
+
1145
+ # Canopy height grid visualization (zeros hidden)
1146
+ canopy_height_grid_nan = canopy_height_grid.copy()
1147
+ canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
1148
+ visualize_numerical_grid(
1149
+ np.flipud(canopy_height_grid_nan),
1150
+ meshsize,
1151
+ "Tree canopy height (m)",
1152
+ cmap='Greens',
1153
+ label='Tree canopy height (m)'
1154
+ )
1155
+
1156
+ # Generate 3D voxel grid
1157
+ voxcity_grid = create_3d_voxel(building_height_grid, building_min_height_grid, building_id_grid, land_cover_grid, dem_grid, canopy_height_grid, meshsize, land_cover_source, canopy_bottom_height_grid)
1158
+
1159
+ # Save all data if a save path is provided
1160
+ save_voxcity = kwargs.get("save_voxctiy_data", True)
1161
+ if save_voxcity:
1162
+ save_path = kwargs.get("save_data_path", f"{output_dir}/voxcity_data.pkl")
1163
+ save_voxcity_data(save_path, voxcity_grid, building_height_grid, building_min_height_grid,
1164
+ building_id_grid, canopy_height_grid, land_cover_grid, dem_grid,
1165
+ building_gdf, meshsize, rectangle_vertices)
1166
+
1167
+ return voxcity_grid, building_height_grid, building_min_height_grid, building_id_grid, canopy_height_grid, canopy_bottom_height_grid, land_cover_grid, dem_grid, filtered_buildings
1168
+
1169
+ def replace_nan_in_nested(arr, replace_value=10.0):
1170
+ """
1171
+ Optimized version that avoids converting to Python lists.
1172
+ Works directly with numpy arrays.
1173
+ """
1174
+ if not isinstance(arr, np.ndarray):
1175
+ return arr
1176
+
1177
+ # Create output array
1178
+ result = np.empty_like(arr, dtype=object)
1179
+
1180
+ # Vectorized operation for empty cells
1181
+ for i in range(arr.shape[0]):
1182
+ for j in range(arr.shape[1]):
1183
+ cell = arr[i, j]
1184
+
1185
+ if cell is None or (isinstance(cell, list) and len(cell) == 0):
1186
+ result[i, j] = []
1187
+ elif isinstance(cell, list):
1188
+ # Process list without converting entire array
1189
+ new_cell = []
1190
+ for segment in cell:
1191
+ if isinstance(segment, (list, np.ndarray)):
1192
+ # Use numpy operations where possible
1193
+ if isinstance(segment, np.ndarray):
1194
+ new_segment = np.where(np.isnan(segment), replace_value, segment).tolist()
1195
+ else:
1196
+ new_segment = [replace_value if (isinstance(v, float) and np.isnan(v)) else v for v in segment]
1197
+ new_cell.append(new_segment)
1198
+ else:
1199
+ new_cell.append(segment)
1200
+ result[i, j] = new_cell
1201
+ else:
1202
+ result[i, j] = cell
1203
+
1204
+ return result
1205
+
1206
+ def save_voxcity_data(output_path, voxcity_grid, building_height_grid, building_min_height_grid,
1207
+ building_id_grid, canopy_height_grid, land_cover_grid, dem_grid,
1208
+ building_gdf, meshsize, rectangle_vertices):
1209
+ """Save voxcity data to a file for later loading.
1210
+
1211
+ Args:
1212
+ output_path: Path to save the data file
1213
+ voxcity_grid: 3D voxel grid of the complete city model
1214
+ building_height_grid: 2D grid of building heights
1215
+ building_min_height_grid: 2D grid of minimum building heights
1216
+ building_id_grid: 2D grid of building IDs
1217
+ canopy_height_grid: 2D grid of tree canopy heights
1218
+ land_cover_grid: 2D grid of land cover classifications
1219
+ dem_grid: 2D grid of ground elevation
1220
+ building_gdf: GeoDataFrame of building footprints and metadata
1221
+ meshsize: Size of each grid cell in meters
1222
+ rectangle_vertices: List of coordinates defining the area of interest
1223
+ """
1224
+ import pickle
1225
+ import os
1226
+
1227
+ # Ensure the output directory exists
1228
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
1229
+
1230
+ # Create a comprehensive dictionary containing all voxcity data
1231
+ # This preserves all components needed to reconstruct or analyze the model
1232
+ data_dict = {
1233
+ 'voxcity_grid': voxcity_grid,
1234
+ 'building_height_grid': building_height_grid,
1235
+ 'building_min_height_grid': building_min_height_grid,
1236
+ 'building_id_grid': building_id_grid,
1237
+ 'canopy_height_grid': canopy_height_grid,
1238
+ 'land_cover_grid': land_cover_grid,
1239
+ 'dem_grid': dem_grid,
1240
+ 'building_gdf': building_gdf,
1241
+ 'meshsize': meshsize,
1242
+ 'rectangle_vertices': rectangle_vertices
1243
+ }
1244
+
1245
+ # Serialize and save the data using pickle for efficient storage
1246
+ # Pickle preserves exact data types and structures
1247
+ with open(output_path, 'wb') as f:
1248
+ pickle.dump(data_dict, f)
1249
+
1250
+ print(f"Voxcity data saved to {output_path}")
1251
+
1252
+ def load_voxcity_data(input_path):
1253
+ """Load voxcity data from a saved file.
1254
+
1255
+ Args:
1256
+ input_path: Path to the saved data file
1257
+
1258
+ Returns:
1259
+ tuple: All the voxcity data components including:
1260
+ - voxcity_grid: 3D voxel grid of the complete city model
1261
+ - building_height_grid: 2D grid of building heights
1262
+ - building_min_height_grid: 2D grid of minimum building heights
1263
+ - building_id_grid: 2D grid of building IDs
1264
+ - canopy_height_grid: 2D grid of tree canopy heights
1265
+ - land_cover_grid: 2D grid of land cover classifications
1266
+ - dem_grid: 2D grid of ground elevation
1267
+ - building_gdf: GeoDataFrame of building footprints and metadata
1268
+ - meshsize: Size of each grid cell in meters
1269
+ - rectangle_vertices: List of coordinates defining the area of interest
1270
+ """
1271
+ import pickle
1272
+
1273
+ # Deserialize the data from the saved file
1274
+ with open(input_path, 'rb') as f:
1275
+ data_dict = pickle.load(f)
1276
+
1277
+ print(f"Voxcity data loaded from {input_path}")
1278
+
1279
+ # Return all components as a tuple in the same order as the main function
1280
+ # This ensures compatibility with existing code that expects this structure
1281
+ return (
1282
+ data_dict['voxcity_grid'],
1283
+ data_dict['building_height_grid'],
1284
+ data_dict['building_min_height_grid'],
1285
+ data_dict['building_id_grid'],
1286
+ data_dict['canopy_height_grid'],
1287
+ data_dict['land_cover_grid'],
1288
+ data_dict['dem_grid'],
1289
+ data_dict['building_gdf'],
1290
+ data_dict['meshsize'],
1291
+ data_dict['rectangle_vertices']
1137
1292
  )