voxcity 0.6.26__py3-none-any.whl → 1.0.2__py3-none-any.whl

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