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