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