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