voxcity 0.6.26__py3-none-any.whl → 0.7.0__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.
Files changed (75) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/gba.py +210 -0
  4. voxcity/downloader/gee.py +5 -1
  5. voxcity/downloader/mbfp.py +1 -1
  6. voxcity/downloader/oemj.py +80 -8
  7. voxcity/downloader/utils.py +73 -73
  8. voxcity/errors.py +30 -0
  9. voxcity/exporter/__init__.py +13 -5
  10. voxcity/exporter/cityles.py +633 -538
  11. voxcity/exporter/envimet.py +728 -708
  12. voxcity/exporter/magicavoxel.py +334 -297
  13. voxcity/exporter/netcdf.py +238 -211
  14. voxcity/exporter/obj.py +1481 -1406
  15. voxcity/generator/__init__.py +44 -0
  16. voxcity/generator/api.py +675 -0
  17. voxcity/generator/grids.py +379 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/voxelizer.py +380 -0
  21. voxcity/geoprocessor/__init__.py +75 -6
  22. voxcity/geoprocessor/conversion.py +153 -0
  23. voxcity/geoprocessor/draw.py +62 -12
  24. voxcity/geoprocessor/heights.py +199 -0
  25. voxcity/geoprocessor/io.py +101 -0
  26. voxcity/geoprocessor/merge_utils.py +91 -0
  27. voxcity/geoprocessor/mesh.py +806 -790
  28. voxcity/geoprocessor/network.py +708 -679
  29. voxcity/geoprocessor/overlap.py +84 -0
  30. voxcity/geoprocessor/raster/__init__.py +82 -0
  31. voxcity/geoprocessor/raster/buildings.py +428 -0
  32. voxcity/geoprocessor/raster/canopy.py +258 -0
  33. voxcity/geoprocessor/raster/core.py +150 -0
  34. voxcity/geoprocessor/raster/export.py +93 -0
  35. voxcity/geoprocessor/raster/landcover.py +156 -0
  36. voxcity/geoprocessor/raster/raster.py +110 -0
  37. voxcity/geoprocessor/selection.py +85 -0
  38. voxcity/geoprocessor/utils.py +18 -14
  39. voxcity/models.py +113 -0
  40. voxcity/simulator/common/__init__.py +22 -0
  41. voxcity/simulator/common/geometry.py +98 -0
  42. voxcity/simulator/common/raytracing.py +450 -0
  43. voxcity/simulator/solar/__init__.py +43 -0
  44. voxcity/simulator/solar/integration.py +336 -0
  45. voxcity/simulator/solar/kernels.py +62 -0
  46. voxcity/simulator/solar/radiation.py +648 -0
  47. voxcity/simulator/solar/temporal.py +434 -0
  48. voxcity/simulator/view.py +36 -2286
  49. voxcity/simulator/visibility/__init__.py +29 -0
  50. voxcity/simulator/visibility/landmark.py +392 -0
  51. voxcity/simulator/visibility/view.py +508 -0
  52. voxcity/utils/logging.py +61 -0
  53. voxcity/utils/orientation.py +51 -0
  54. voxcity/utils/weather/__init__.py +26 -0
  55. voxcity/utils/weather/epw.py +146 -0
  56. voxcity/utils/weather/files.py +36 -0
  57. voxcity/utils/weather/onebuilding.py +486 -0
  58. voxcity/visualizer/__init__.py +24 -0
  59. voxcity/visualizer/builder.py +43 -0
  60. voxcity/visualizer/grids.py +141 -0
  61. voxcity/visualizer/maps.py +187 -0
  62. voxcity/visualizer/palette.py +228 -0
  63. voxcity/visualizer/renderer.py +928 -0
  64. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/METADATA +107 -34
  65. voxcity-0.7.0.dist-info/RECORD +77 -0
  66. voxcity/generator.py +0 -1302
  67. voxcity/geoprocessor/grid.py +0 -1739
  68. voxcity/geoprocessor/polygon.py +0 -1344
  69. voxcity/simulator/solar.py +0 -2339
  70. voxcity/utils/visualization.py +0 -2849
  71. voxcity/utils/weather.py +0 -1038
  72. voxcity-0.6.26.dist-info/RECORD +0 -38
  73. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/WHEEL +0 -0
  74. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/licenses/AUTHORS.rst +0 -0
  75. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,379 @@
1
+ import os
2
+ import numpy as np
3
+
4
+ from ..downloader.mbfp import get_mbfp_gdf
5
+ from ..downloader.osm import load_gdf_from_openstreetmap, load_land_cover_gdf_from_osm
6
+ from ..downloader.oemj import save_oemj_as_geotiff
7
+ from ..downloader.eubucco import load_gdf_from_eubucco
8
+ from ..downloader.overture import load_gdf_from_overture
9
+ from ..downloader.gba import load_gdf_from_gba
10
+
11
+ from ..downloader.gee import (
12
+ initialize_earth_engine,
13
+ get_roi,
14
+ get_ee_image_collection,
15
+ get_ee_image,
16
+ save_geotiff,
17
+ get_dem_image,
18
+ save_geotiff_esa_land_cover,
19
+ save_geotiff_esri_landcover,
20
+ save_geotiff_dynamic_world_v1,
21
+ save_geotiff_open_buildings_temporal,
22
+ save_geotiff_dsm_minus_dtm,
23
+ )
24
+
25
+ from ..geoprocessor.raster import (
26
+ process_grid,
27
+ create_land_cover_grid_from_geotiff_polygon,
28
+ create_height_grid_from_geotiff_polygon,
29
+ create_building_height_grid_from_gdf_polygon,
30
+ create_dem_grid_from_geotiff_polygon,
31
+ create_land_cover_grid_from_gdf_polygon,
32
+ create_building_height_grid_from_open_building_temporal_polygon,
33
+ create_canopy_grids_from_tree_gdf,
34
+ )
35
+
36
+ from ..utils.lc import convert_land_cover_array, get_land_cover_classes
37
+ from ..geoprocessor.io import get_gdf_from_gpkg
38
+ from ..visualizer.grids import visualize_land_cover_grid, visualize_numerical_grid
39
+
40
+
41
+ # Track last effective land cover source to help downstream components (e.g., voxelizer)
42
+ _LAST_EFFECTIVE_LC_SOURCE = None
43
+
44
+ def get_last_effective_land_cover_source():
45
+ return _LAST_EFFECTIVE_LC_SOURCE
46
+
47
+
48
+ def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
49
+ print("Creating Land Use Land Cover grid\n ")
50
+ print(f"Data source: {source}")
51
+
52
+ if source not in ["OpenStreetMap", "OpenEarthMapJapan"]:
53
+ try:
54
+ initialize_earth_engine()
55
+ except Exception as e:
56
+ print("Earth Engine unavailable (", str(e), ") — falling back to OpenStreetMap for land cover.")
57
+ source = 'OpenStreetMap'
58
+
59
+ os.makedirs(output_dir, exist_ok=True)
60
+ geotiff_path = os.path.join(output_dir, "land_cover.tif")
61
+
62
+ # Track effective source to allow fallback behavior
63
+ effective_source = source
64
+
65
+ if source == 'Urbanwatch':
66
+ roi = get_roi(rectangle_vertices)
67
+ collection_name = "projects/sat-io/open-datasets/HRLC/urban-watch-cities"
68
+ try:
69
+ image = get_ee_image_collection(collection_name, roi)
70
+ # If collection is empty, image operations may fail; guard with try/except
71
+ save_geotiff(image, geotiff_path)
72
+ if (not os.path.exists(geotiff_path)) or (os.path.getsize(geotiff_path) == 0):
73
+ raise RuntimeError("Urbanwatch export produced no file")
74
+ except Exception as e:
75
+ print("Urbanwatch coverage not found for AOI; falling back to OpenStreetMap (reason:", str(e), ")")
76
+ effective_source = 'OpenStreetMap'
77
+ land_cover_gdf = load_land_cover_gdf_from_osm(rectangle_vertices)
78
+ elif source == 'ESA WorldCover':
79
+ roi = get_roi(rectangle_vertices)
80
+ save_geotiff_esa_land_cover(roi, geotiff_path)
81
+ elif source == 'ESRI 10m Annual Land Cover':
82
+ esri_landcover_year = kwargs.get("esri_landcover_year")
83
+ roi = get_roi(rectangle_vertices)
84
+ save_geotiff_esri_landcover(roi, geotiff_path, year=esri_landcover_year)
85
+ elif source == 'Dynamic World V1':
86
+ dynamic_world_date = kwargs.get("dynamic_world_date")
87
+ roi = get_roi(rectangle_vertices)
88
+ save_geotiff_dynamic_world_v1(roi, geotiff_path, dynamic_world_date)
89
+ elif source == 'OpenEarthMapJapan':
90
+ ssl_verify = kwargs.get('ssl_verify', kwargs.get('verify', True))
91
+ allow_insecure_ssl = kwargs.get('allow_insecure_ssl', False)
92
+ allow_http_fallback = kwargs.get('allow_http_fallback', False)
93
+ timeout_s = kwargs.get('timeout', 30)
94
+
95
+ save_oemj_as_geotiff(
96
+ rectangle_vertices,
97
+ geotiff_path,
98
+ ssl_verify=ssl_verify,
99
+ allow_insecure_ssl=allow_insecure_ssl,
100
+ allow_http_fallback=allow_http_fallback,
101
+ timeout_s=timeout_s,
102
+ )
103
+ if not os.path.exists(geotiff_path):
104
+ raise FileNotFoundError(
105
+ f"OEMJ download failed; expected GeoTIFF not found: {geotiff_path}. "
106
+ "You can try setting ssl_verify=False or allow_http_fallback=True in kwargs."
107
+ )
108
+ elif source == 'OpenStreetMap':
109
+ land_cover_gdf = load_land_cover_gdf_from_osm(rectangle_vertices)
110
+
111
+ land_cover_classes = get_land_cover_classes(effective_source)
112
+
113
+ if effective_source == 'OpenStreetMap':
114
+ default_class = kwargs.get('default_land_cover_class', 'Developed space')
115
+ land_cover_grid_str = create_land_cover_grid_from_gdf_polygon(land_cover_gdf, meshsize, effective_source, rectangle_vertices, default_class=default_class)
116
+ else:
117
+ land_cover_grid_str = create_land_cover_grid_from_geotiff_polygon(geotiff_path, meshsize, land_cover_classes, rectangle_vertices)
118
+
119
+ color_map = {cls: [r/255, g/255, b/255] for (r,g,b), cls in land_cover_classes.items()}
120
+
121
+ grid_vis = kwargs.get("gridvis", True)
122
+ if grid_vis:
123
+ visualize_land_cover_grid(np.flipud(land_cover_grid_str), meshsize, color_map, land_cover_classes)
124
+
125
+ # Record effective source for downstream consumers
126
+ global _LAST_EFFECTIVE_LC_SOURCE
127
+ _LAST_EFFECTIVE_LC_SOURCE = effective_source
128
+
129
+ land_cover_grid_int = convert_land_cover_array(land_cover_grid_str, land_cover_classes)
130
+ return land_cover_grid_int
131
+
132
+
133
+ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, building_gdf=None, **kwargs):
134
+ ee_required_sources = {"Open Building 2.5D Temporal"}
135
+ if source in ee_required_sources:
136
+ initialize_earth_engine()
137
+
138
+ print("Creating Building Height grid\n ")
139
+ print(f"Base data source: {source}")
140
+
141
+ os.makedirs(output_dir, exist_ok=True)
142
+
143
+ if building_gdf is not None:
144
+ gdf = building_gdf
145
+ print("Using provided GeoDataFrame for building data")
146
+ else:
147
+ floor_height = kwargs.get("floor_height", 3.0)
148
+ if source == 'Microsoft Building Footprints':
149
+ gdf = get_mbfp_gdf(output_dir, rectangle_vertices)
150
+ elif source == 'OpenStreetMap':
151
+ gdf = load_gdf_from_openstreetmap(rectangle_vertices, floor_height=floor_height)
152
+ elif source == "Open Building 2.5D Temporal":
153
+ 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)
154
+ elif source == 'EUBUCCO v0.1':
155
+ gdf = load_gdf_from_eubucco(rectangle_vertices, output_dir)
156
+ elif source == "Overture":
157
+ gdf = load_gdf_from_overture(rectangle_vertices, floor_height=floor_height)
158
+ elif source in ("GBA", "Global Building Atlas"):
159
+ clip_gba = kwargs.get("gba_clip", False)
160
+ gba_download_dir = kwargs.get("gba_download_dir")
161
+ gdf = load_gdf_from_gba(rectangle_vertices, download_dir=gba_download_dir, clip_to_rectangle=clip_gba)
162
+ elif source == "Local file":
163
+ _, extension = os.path.splitext(kwargs["building_path"])
164
+ if extension == ".gpkg":
165
+ gdf = get_gdf_from_gpkg(kwargs["building_path"], rectangle_vertices)
166
+ elif source == "GeoDataFrame":
167
+ raise ValueError("When source is 'GeoDataFrame', building_gdf parameter must be provided")
168
+
169
+ building_complementary_source = kwargs.get("building_complementary_source")
170
+ try:
171
+ comp_label = building_complementary_source if building_complementary_source not in (None, "") else "None"
172
+ print(f"Complementary data source: {comp_label}")
173
+ except Exception:
174
+ pass
175
+ building_complement_height = kwargs.get("building_complement_height")
176
+ overlapping_footprint = kwargs.get("overlapping_footprint", "auto")
177
+
178
+ if (building_complementary_source is None) or (building_complementary_source=='None'):
179
+ if source != "Open Building 2.5D Temporal":
180
+ 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)
181
+ else:
182
+ if building_complementary_source == "Open Building 2.5D Temporal":
183
+ try:
184
+ roi = get_roi(rectangle_vertices)
185
+ os.makedirs(output_dir, exist_ok=True)
186
+ geotiff_path_comp = os.path.join(output_dir, "building_height.tif")
187
+ save_geotiff_open_buildings_temporal(roi, geotiff_path_comp)
188
+ 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)
189
+ except Exception as e:
190
+ print("Open Building 2.5D Temporal requires Earth Engine (", str(e), ") — proceeding without complementary raster.")
191
+ 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)
192
+ elif building_complementary_source in ["England 1m DSM - DTM", "Netherlands 0.5m DSM - DTM"]:
193
+ try:
194
+ roi = get_roi(rectangle_vertices)
195
+ os.makedirs(output_dir, exist_ok=True)
196
+ geotiff_path_comp = os.path.join(output_dir, "building_height.tif")
197
+ save_geotiff_dsm_minus_dtm(roi, geotiff_path_comp, meshsize, building_complementary_source)
198
+ 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)
199
+ except Exception as e:
200
+ print("DSM-DTM complementary raster requires Earth Engine (", str(e), ") — proceeding without complementary raster.")
201
+ 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)
202
+ else:
203
+ if building_complementary_source == 'Microsoft Building Footprints':
204
+ gdf_comp = get_mbfp_gdf(output_dir, rectangle_vertices)
205
+ elif building_complementary_source == 'OpenStreetMap':
206
+ gdf_comp = load_gdf_from_openstreetmap(rectangle_vertices, floor_height=floor_height)
207
+ elif building_complementary_source == 'EUBUCCO v0.1':
208
+ gdf_comp = load_gdf_from_eubucco(rectangle_vertices, output_dir)
209
+ elif building_complementary_source == "Overture":
210
+ gdf_comp = load_gdf_from_overture(rectangle_vertices, floor_height=floor_height)
211
+ elif building_complementary_source in ("GBA", "Global Building Atlas"):
212
+ clip_gba = kwargs.get("gba_clip", False)
213
+ gba_download_dir = kwargs.get("gba_download_dir")
214
+ gdf_comp = load_gdf_from_gba(rectangle_vertices, download_dir=gba_download_dir, clip_to_rectangle=clip_gba)
215
+ elif building_complementary_source == "Local file":
216
+ _, extension = os.path.splitext(kwargs["building_complementary_path"])
217
+ if extension == ".gpkg":
218
+ gdf_comp = get_gdf_from_gpkg(kwargs["building_complementary_path"], rectangle_vertices)
219
+
220
+ complement_building_footprints = kwargs.get("complement_building_footprints")
221
+ 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)
222
+
223
+ grid_vis = kwargs.get("gridvis", True)
224
+ if grid_vis:
225
+ building_height_grid_nan = building_height_grid.copy()
226
+ building_height_grid_nan[building_height_grid_nan == 0] = np.nan
227
+ visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
228
+
229
+ return building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings
230
+
231
+
232
+ def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
233
+ print("Creating Canopy Height grid\n ")
234
+ print(f"Data source: {source}")
235
+
236
+ os.makedirs(output_dir, exist_ok=True)
237
+
238
+ # Explicit static path (no EE): use land cover mask with static height
239
+ if source == 'Static':
240
+ land_cover_grid = kwargs.get('land_cover_like')
241
+ if land_cover_grid is None:
242
+ # Minimal fallback if caller didn't provide land_cover_like
243
+ canopy_top = np.zeros((1, 1), dtype=float)
244
+ trunk_height_ratio = kwargs.get('trunk_height_ratio')
245
+ if trunk_height_ratio is None:
246
+ trunk_height_ratio = 11.76 / 19.98
247
+ canopy_bottom = canopy_top * float(trunk_height_ratio)
248
+ return canopy_top, canopy_bottom
249
+
250
+ from ..utils.lc import get_land_cover_classes
251
+ land_cover_source = kwargs.get('land_cover_source', 'OpenStreetMap')
252
+ classes_map = get_land_cover_classes(land_cover_source)
253
+ class_to_int = {name: i for i, name in enumerate(classes_map.values())}
254
+ tree_labels = ["Tree", "Trees", "Tree Canopy"]
255
+ tree_indices = [class_to_int[label] for label in tree_labels if label in class_to_int]
256
+
257
+ canopy_top = np.zeros_like(land_cover_grid, dtype=float)
258
+ static_tree_height = kwargs.get('static_tree_height', 10.0)
259
+ tree_mask = np.isin(land_cover_grid, tree_indices) if tree_indices else np.zeros_like(land_cover_grid, dtype=bool)
260
+ canopy_top[tree_mask] = static_tree_height
261
+
262
+ trunk_height_ratio = kwargs.get('trunk_height_ratio')
263
+ if trunk_height_ratio is None:
264
+ trunk_height_ratio = 11.76 / 19.98
265
+ canopy_bottom = canopy_top * float(trunk_height_ratio)
266
+
267
+ grid_vis = kwargs.get("gridvis", True)
268
+ if grid_vis:
269
+ vis = canopy_top.copy(); vis[vis == 0] = np.nan
270
+ visualize_numerical_grid(np.flipud(vis), meshsize, "Tree canopy height (top)", cmap='Greens', label='Tree canopy height (m)')
271
+ return canopy_top, canopy_bottom
272
+
273
+ if source in ('GeoDataFrame', 'tree_gdf', 'Tree_GeoDataFrame', 'GDF'):
274
+ tree_gdf = kwargs.get('tree_gdf')
275
+ tree_gdf_path = kwargs.get('tree_gdf_path')
276
+ if tree_gdf is None and tree_gdf_path is not None:
277
+ _, ext = os.path.splitext(tree_gdf_path)
278
+ if ext.lower() == '.gpkg':
279
+ tree_gdf = get_gdf_from_gpkg(tree_gdf_path, rectangle_vertices)
280
+ else:
281
+ raise ValueError("Unsupported tree file format. Use .gpkg or pass a GeoDataFrame.")
282
+ if tree_gdf is None:
283
+ raise ValueError("When source='GeoDataFrame', provide 'tree_gdf' or 'tree_gdf_path'.")
284
+
285
+ canopy_top, canopy_bottom = create_canopy_grids_from_tree_gdf(tree_gdf, meshsize, rectangle_vertices)
286
+
287
+ grid_vis = kwargs.get("gridvis", True)
288
+ if grid_vis:
289
+ vis = canopy_top.copy()
290
+ vis[vis == 0] = np.nan
291
+ visualize_numerical_grid(np.flipud(vis), meshsize, "Tree canopy height (top)", cmap='Greens', label='Tree canopy height (m)')
292
+
293
+ return canopy_top, canopy_bottom
294
+
295
+ try:
296
+ initialize_earth_engine()
297
+ except Exception as e:
298
+ print("Earth Engine unavailable (", str(e), ") — falling back to Static canopy heights.")
299
+ # Re-enter with explicit Static logic using land cover mask
300
+ return get_canopy_height_grid(rectangle_vertices, meshsize, 'Static', output_dir, **kwargs)
301
+
302
+ geotiff_path = os.path.join(output_dir, "canopy_height.tif")
303
+
304
+ roi = get_roi(rectangle_vertices)
305
+ if source == 'High Resolution 1m Global Canopy Height Maps':
306
+ collection_name = "projects/meta-forest-monitoring-okw37/assets/CanopyHeight"
307
+ image = get_ee_image_collection(collection_name, roi)
308
+ elif source == 'ETH Global Sentinel-2 10m Canopy Height (2020)':
309
+ collection_name = "users/nlang/ETH_GlobalCanopyHeight_2020_10m_v1"
310
+ image = get_ee_image(collection_name, roi)
311
+ else:
312
+ raise ValueError(f"Unsupported canopy source: {source}")
313
+
314
+ save_geotiff(image, geotiff_path, resolution=meshsize)
315
+ canopy_height_grid = create_height_grid_from_geotiff_polygon(geotiff_path, meshsize, rectangle_vertices)
316
+
317
+ trunk_height_ratio = kwargs.get("trunk_height_ratio")
318
+ if trunk_height_ratio is None:
319
+ trunk_height_ratio = 11.76 / 19.98
320
+ canopy_bottom_grid = canopy_height_grid * float(trunk_height_ratio)
321
+
322
+ grid_vis = kwargs.get("gridvis", True)
323
+ if grid_vis:
324
+ canopy_height_grid_nan = canopy_height_grid.copy()
325
+ canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
326
+ visualize_numerical_grid(np.flipud(canopy_height_grid_nan), meshsize, "Tree canopy height", cmap='Greens', label='Tree canopy height (m)')
327
+ return canopy_height_grid, canopy_bottom_grid
328
+
329
+
330
+ def get_dem_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
331
+ print("Creating Digital Elevation Model (DEM) grid\n ")
332
+ print(f"Data source: {source}")
333
+
334
+ if source == "Local file":
335
+ geotiff_path = kwargs["dem_path"]
336
+ else:
337
+ try:
338
+ initialize_earth_engine()
339
+ except Exception as e:
340
+ print("Earth Engine unavailable (", str(e), ") — falling back to flat DEM.")
341
+ dem_interpolation = kwargs.get("dem_interpolation")
342
+ # Return flat DEM (zeros) with same shape as would be produced after rasterization
343
+ # We defer to downstream to handle zeros appropriately.
344
+ # To avoid shape inference here, we'll build after default path below.
345
+ geotiff_path = None
346
+ # Bypass EE path and produce zeros later
347
+ dem_grid = np.zeros((1, 1), dtype=float)
348
+ # Build shape using land cover grid shape if provided via kwargs for robustness
349
+ lc_like = kwargs.get("land_cover_like")
350
+ if lc_like is not None:
351
+ dem_grid = np.zeros_like(lc_like)
352
+ return dem_grid
353
+
354
+ geotiff_path = os.path.join(output_dir, "dem.tif")
355
+
356
+ buffer_distance = 100
357
+ roi = get_roi(rectangle_vertices)
358
+ roi_buffered = roi.buffer(buffer_distance)
359
+
360
+ image = get_dem_image(roi_buffered, source)
361
+
362
+ if source in ["England 1m DTM", 'DEM France 1m', 'DEM France 5m', 'AUSTRALIA 5M DEM', 'Netherlands 0.5m DTM']:
363
+ save_geotiff(image, geotiff_path, scale=meshsize, region=roi_buffered, crs='EPSG:4326')
364
+ elif source == 'USGS 3DEP 1m':
365
+ scale = max(meshsize, 1.25)
366
+ save_geotiff(image, geotiff_path, scale=scale, region=roi_buffered, crs='EPSG:4326')
367
+ else:
368
+ save_geotiff(image, geotiff_path, scale=30, region=roi_buffered)
369
+
370
+ dem_interpolation = kwargs.get("dem_interpolation")
371
+ dem_grid = create_dem_grid_from_geotiff_polygon(geotiff_path, meshsize, rectangle_vertices, dem_interpolation=dem_interpolation)
372
+
373
+ grid_vis = kwargs.get("gridvis", True)
374
+ if grid_vis:
375
+ visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
376
+
377
+ return dem_grid
378
+
379
+
@@ -0,0 +1,94 @@
1
+ def save_voxcity_data(output_path, voxcity_grid, building_height_grid, building_min_height_grid,
2
+ building_id_grid, canopy_height_grid, land_cover_grid, dem_grid,
3
+ building_gdf, meshsize, rectangle_vertices):
4
+ import pickle
5
+ import os
6
+
7
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
8
+
9
+ data_dict = {
10
+ 'voxcity_grid': voxcity_grid,
11
+ 'building_height_grid': building_height_grid,
12
+ 'building_min_height_grid': building_min_height_grid,
13
+ 'building_id_grid': building_id_grid,
14
+ 'canopy_height_grid': canopy_height_grid,
15
+ 'land_cover_grid': land_cover_grid,
16
+ 'dem_grid': dem_grid,
17
+ 'building_gdf': building_gdf,
18
+ 'meshsize': meshsize,
19
+ 'rectangle_vertices': rectangle_vertices
20
+ }
21
+
22
+ with open(output_path, 'wb') as f:
23
+ pickle.dump(data_dict, f)
24
+
25
+ print(f"Voxcity data saved to {output_path}")
26
+
27
+
28
+ def load_voxcity(input_path):
29
+ import pickle
30
+ import numpy as np
31
+ from ..models import GridMetadata, VoxelGrid, BuildingGrid, LandCoverGrid, DemGrid, CanopyGrid, VoxCity
32
+
33
+ with open(input_path, 'rb') as f:
34
+ obj = pickle.load(f)
35
+
36
+ # New format: the entire VoxCity object (optionally wrapped)
37
+ if isinstance(obj, VoxCity):
38
+ return obj
39
+ if isinstance(obj, dict) and obj.get('__format__') == 'voxcity.v2' and isinstance(obj.get('voxcity'), VoxCity):
40
+ return obj['voxcity']
41
+
42
+ # Legacy dict format fallback
43
+ d = obj
44
+ rv = d.get('rectangle_vertices') or []
45
+ if rv:
46
+ xs = [p[0] for p in rv]
47
+ ys = [p[1] for p in rv]
48
+ bounds = (min(xs), min(ys), max(xs), max(ys))
49
+ else:
50
+ ny, nx = d['land_cover_grid'].shape
51
+ ms = float(d['meshsize'])
52
+ bounds = (0.0, 0.0, nx * ms, ny * ms)
53
+
54
+ meta = GridMetadata(crs='EPSG:4326', bounds=bounds, meshsize=float(d['meshsize']))
55
+
56
+ voxels = VoxelGrid(classes=d['voxcity_grid'], meta=meta)
57
+ buildings = BuildingGrid(
58
+ heights=d['building_height_grid'],
59
+ min_heights=d['building_min_height_grid'],
60
+ ids=d['building_id_grid'],
61
+ meta=meta,
62
+ )
63
+ land = LandCoverGrid(classes=d['land_cover_grid'], meta=meta)
64
+ dem = DemGrid(elevation=d['dem_grid'], meta=meta)
65
+ canopy = CanopyGrid(top=d.get('canopy_height_grid'), bottom=None, meta=meta)
66
+
67
+ extras = {
68
+ 'rectangle_vertices': d.get('rectangle_vertices'),
69
+ 'building_gdf': d.get('building_gdf'),
70
+ }
71
+
72
+ return VoxCity(voxels=voxels, buildings=buildings, land_cover=land, dem=dem, tree_canopy=canopy, extras=extras)
73
+
74
+
75
+
76
+ def save_voxcity(output_path, city):
77
+ """Save a VoxCity instance to disk, preserving the entire object."""
78
+ import pickle
79
+ import os
80
+ from ..models import VoxCity as _VoxCity
81
+
82
+ if not isinstance(city, _VoxCity):
83
+ raise TypeError("save_voxcity expects a VoxCity instance")
84
+
85
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
86
+
87
+ with open(output_path, 'wb') as f:
88
+ payload = {
89
+ '__format__': 'voxcity.v2',
90
+ 'voxcity': city,
91
+ }
92
+ pickle.dump(payload, f)
93
+
94
+ print(f"Voxcity data saved to {output_path}")