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,282 @@
1
+ import os
2
+ from ..utils.logging import get_logger
3
+ from typing import Optional
4
+ import numpy as np
5
+
6
+ from ..models import (
7
+ GridMetadata,
8
+ BuildingGrid,
9
+ LandCoverGrid,
10
+ DemGrid,
11
+ VoxelGrid,
12
+ CanopyGrid,
13
+ VoxCity,
14
+ PipelineConfig,
15
+ )
16
+
17
+ from .grids import (
18
+ get_land_cover_grid,
19
+ get_building_height_grid,
20
+ get_canopy_height_grid,
21
+ get_dem_grid,
22
+ )
23
+ from .voxelizer import Voxelizer
24
+
25
+
26
+ class VoxCityPipeline:
27
+ def __init__(self, meshsize: float, rectangle_vertices, crs: str = "EPSG:4326") -> None:
28
+ self.meshsize = float(meshsize)
29
+ self.rectangle_vertices = rectangle_vertices
30
+ self.crs = crs
31
+
32
+ def _bounds(self):
33
+ xs = [p[0] for p in self.rectangle_vertices]
34
+ ys = [p[1] for p in self.rectangle_vertices]
35
+ return (min(xs), min(ys), max(xs), max(ys))
36
+
37
+ def _meta(self) -> GridMetadata:
38
+ return GridMetadata(crs=self.crs, bounds=self._bounds(), meshsize=self.meshsize)
39
+
40
+ def assemble_voxcity(
41
+ self,
42
+ voxcity_grid: np.ndarray,
43
+ building_height_grid: np.ndarray,
44
+ building_min_height_grid: np.ndarray,
45
+ building_id_grid: np.ndarray,
46
+ land_cover_grid: np.ndarray,
47
+ dem_grid: np.ndarray,
48
+ canopy_height_top: Optional[np.ndarray] = None,
49
+ canopy_height_bottom: Optional[np.ndarray] = None,
50
+ extras: Optional[dict] = None,
51
+ ) -> VoxCity:
52
+ meta = self._meta()
53
+ buildings = BuildingGrid(
54
+ heights=building_height_grid,
55
+ min_heights=building_min_height_grid,
56
+ ids=building_id_grid,
57
+ meta=meta,
58
+ )
59
+ land = LandCoverGrid(classes=land_cover_grid, meta=meta)
60
+ dem = DemGrid(elevation=dem_grid, meta=meta)
61
+ voxels = VoxelGrid(classes=voxcity_grid, meta=meta)
62
+ canopy = CanopyGrid(top=canopy_height_top if canopy_height_top is not None else np.zeros_like(land_cover_grid, dtype=float),
63
+ bottom=canopy_height_bottom,
64
+ meta=meta)
65
+ _extras = {
66
+ "rectangle_vertices": self.rectangle_vertices,
67
+ "canopy_top": canopy.top,
68
+ "canopy_bottom": canopy.bottom,
69
+ }
70
+ if extras:
71
+ _extras.update(extras)
72
+ return VoxCity(voxels=voxels, buildings=buildings, land_cover=land, dem=dem, tree_canopy=canopy, extras=_extras)
73
+
74
+ def run(self, cfg: PipelineConfig, building_gdf=None, terrain_gdf=None, **kwargs) -> VoxCity:
75
+ os.makedirs(cfg.output_dir, exist_ok=True)
76
+ land_strategy = LandCoverSourceFactory.create(cfg.land_cover_source)
77
+ build_strategy = BuildingSourceFactory.create(cfg.building_source)
78
+ canopy_strategy = CanopySourceFactory.create(cfg.canopy_height_source, cfg)
79
+ dem_strategy = DemSourceFactory.create(cfg.dem_source)
80
+
81
+ # Prefer structured options from cfg; allow legacy kwargs for back-compat
82
+ land_cover_grid = land_strategy.build_grid(
83
+ cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
84
+ **{**cfg.land_cover_options, **kwargs}
85
+ )
86
+ # Detect effective land cover source (e.g., Urbanwatch -> OpenStreetMap fallback)
87
+ try:
88
+ from .grids import get_last_effective_land_cover_source
89
+ lc_src_effective = get_last_effective_land_cover_source() or cfg.land_cover_source
90
+ except Exception:
91
+ lc_src_effective = cfg.land_cover_source
92
+ bh, bmin, bid, building_gdf_out = build_strategy.build_grids(
93
+ cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
94
+ building_gdf=building_gdf,
95
+ **{**cfg.building_options, **kwargs}
96
+ )
97
+ canopy_top, canopy_bottom = canopy_strategy.build_grids(
98
+ cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
99
+ land_cover_source=lc_src_effective,
100
+ **{**cfg.canopy_options, **kwargs}
101
+ )
102
+ dem = dem_strategy.build_grid(
103
+ cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
104
+ terrain_gdf=terrain_gdf,
105
+ land_cover_like=land_cover_grid,
106
+ **{**cfg.dem_options, **kwargs}
107
+ )
108
+
109
+ ro = cfg.remove_perimeter_object
110
+ if (ro is not None) and (ro > 0):
111
+ w_peri = int(ro * bh.shape[0] + 0.5)
112
+ h_peri = int(ro * bh.shape[1] + 0.5)
113
+ canopy_top[:w_peri, :] = canopy_top[-w_peri:, :] = canopy_top[:, :h_peri] = canopy_top[:, -h_peri:] = 0
114
+ canopy_bottom[:w_peri, :] = canopy_bottom[-w_peri:, :] = canopy_bottom[:, :h_peri] = canopy_bottom[:, -h_peri:] = 0
115
+ ids1 = np.unique(bid[:w_peri, :][bid[:w_peri, :] > 0]); ids2 = np.unique(bid[-w_peri:, :][bid[-w_peri:, :] > 0])
116
+ ids3 = np.unique(bid[:, :h_peri][bid[:, :h_peri] > 0]); ids4 = np.unique(bid[:, -h_peri:][bid[:, -h_peri:] > 0])
117
+ for rid in np.concatenate((ids1, ids2, ids3, ids4)):
118
+ pos = np.where(bid == rid)
119
+ bh[pos] = 0
120
+ bmin[pos] = [[] for _ in range(len(bmin[pos]))]
121
+
122
+ voxelizer = Voxelizer(
123
+ voxel_size=cfg.meshsize,
124
+ land_cover_source=lc_src_effective,
125
+ trunk_height_ratio=cfg.trunk_height_ratio,
126
+ voxel_dtype=kwargs.get("voxel_dtype", np.int8),
127
+ max_voxel_ram_mb=kwargs.get("max_voxel_ram_mb"),
128
+ )
129
+ vox = voxelizer.generate_combined(
130
+ building_height_grid_ori=bh,
131
+ building_min_height_grid_ori=bmin,
132
+ building_id_grid_ori=bid,
133
+ land_cover_grid_ori=land_cover_grid,
134
+ dem_grid_ori=dem,
135
+ tree_grid_ori=canopy_top,
136
+ canopy_bottom_height_grid_ori=canopy_bottom,
137
+ )
138
+ return self.assemble_voxcity(
139
+ voxcity_grid=vox,
140
+ building_height_grid=bh,
141
+ building_min_height_grid=bmin,
142
+ building_id_grid=bid,
143
+ land_cover_grid=land_cover_grid,
144
+ dem_grid=dem,
145
+ canopy_height_top=canopy_top,
146
+ canopy_height_bottom=canopy_bottom,
147
+ extras={
148
+ "building_gdf": building_gdf_out,
149
+ "land_cover_source": lc_src_effective,
150
+ "building_source": cfg.building_source,
151
+ "dem_source": cfg.dem_source,
152
+ },
153
+ )
154
+
155
+
156
+ class LandCoverSourceStrategy: # ABC simplified to avoid dependency in split
157
+ def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
158
+ raise NotImplementedError
159
+
160
+
161
+ class DefaultLandCoverStrategy(LandCoverSourceStrategy):
162
+ def __init__(self, source: str) -> None:
163
+ self.source = source
164
+
165
+ def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs):
166
+ return get_land_cover_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
167
+
168
+
169
+ class LandCoverSourceFactory:
170
+ @staticmethod
171
+ def create(source: str) -> LandCoverSourceStrategy:
172
+ return DefaultLandCoverStrategy(source)
173
+
174
+
175
+ class BuildingSourceStrategy: # ABC simplified
176
+ def build_grids(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
177
+ raise NotImplementedError
178
+
179
+
180
+ class DefaultBuildingSourceStrategy(BuildingSourceStrategy):
181
+ def __init__(self, source: str) -> None:
182
+ self.source = source
183
+
184
+ def build_grids(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs):
185
+ return get_building_height_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
186
+
187
+
188
+ class BuildingSourceFactory:
189
+ @staticmethod
190
+ def create(source: str) -> BuildingSourceStrategy:
191
+ return DefaultBuildingSourceStrategy(source)
192
+
193
+
194
+ class CanopySourceStrategy: # ABC simplified
195
+ def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs): # pragma: no cover
196
+ raise NotImplementedError
197
+
198
+
199
+ class StaticCanopyStrategy(CanopySourceStrategy):
200
+ def __init__(self, cfg: PipelineConfig) -> None:
201
+ self.cfg = cfg
202
+
203
+ def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
204
+ canopy_top = np.zeros_like(land_cover_grid, dtype=float)
205
+ static_h = self.cfg.static_tree_height if self.cfg.static_tree_height is not None else kwargs.get("static_tree_height", 10.0)
206
+ from ..utils.lc import get_land_cover_classes
207
+ _classes = get_land_cover_classes(self.cfg.land_cover_source)
208
+ _class_to_int = {name: i for i, name in enumerate(_classes.values())}
209
+ _tree_labels = ["Tree", "Trees", "Tree Canopy"]
210
+ _tree_idx = [_class_to_int[label] for label in _tree_labels if label in _class_to_int]
211
+ tree_mask = np.isin(land_cover_grid, _tree_idx) if _tree_idx else np.zeros_like(land_cover_grid, dtype=bool)
212
+ canopy_top[tree_mask] = static_h
213
+ tr = self.cfg.trunk_height_ratio if self.cfg.trunk_height_ratio is not None else (11.76 / 19.98)
214
+ canopy_bottom = canopy_top * float(tr)
215
+ return canopy_top, canopy_bottom
216
+
217
+
218
+ class SourceCanopyStrategy(CanopySourceStrategy):
219
+ def __init__(self, source: str) -> None:
220
+ self.source = source
221
+
222
+ def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
223
+ # Provide land_cover_like for graceful fallback sizing without EE
224
+ return get_canopy_height_grid(
225
+ rectangle_vertices,
226
+ meshsize,
227
+ self.source,
228
+ output_dir,
229
+ land_cover_like=land_cover_grid,
230
+ **kwargs,
231
+ )
232
+
233
+
234
+ class CanopySourceFactory:
235
+ @staticmethod
236
+ def create(source: str, cfg: PipelineConfig) -> CanopySourceStrategy:
237
+ if source == "Static":
238
+ return StaticCanopyStrategy(cfg)
239
+ return SourceCanopyStrategy(source)
240
+
241
+
242
+ class DemSourceStrategy: # ABC simplified
243
+ def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs): # pragma: no cover
244
+ raise NotImplementedError
245
+
246
+
247
+ class FlatDemStrategy(DemSourceStrategy):
248
+ def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
249
+ return np.zeros_like(land_cover_grid)
250
+
251
+
252
+ class SourceDemStrategy(DemSourceStrategy):
253
+ def __init__(self, source: str) -> None:
254
+ self.source = source
255
+
256
+ def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
257
+ terrain_gdf = kwargs.get("terrain_gdf")
258
+ if terrain_gdf is not None:
259
+ from ..geoprocessor.raster import create_dem_grid_from_gdf_polygon
260
+ return create_dem_grid_from_gdf_polygon(terrain_gdf, meshsize, rectangle_vertices)
261
+ try:
262
+ return get_dem_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
263
+ except Exception as e:
264
+ # Fallback to flat DEM if source fails or unsupported
265
+ logger = get_logger(__name__)
266
+ logger.warning("DEM source '%s' failed (%s). Falling back to flat DEM.", self.source, e)
267
+ return np.zeros_like(land_cover_grid)
268
+
269
+
270
+ class DemSourceFactory:
271
+ @staticmethod
272
+ def create(source: str) -> DemSourceStrategy:
273
+ # Normalize and auto-fallback: None/"none" -> Flat
274
+ try:
275
+ src_norm = (source or "").strip().lower()
276
+ except Exception:
277
+ src_norm = ""
278
+ if (not source) or (src_norm in {"flat", "none", "null"}):
279
+ return FlatDemStrategy()
280
+ return SourceDemStrategy(source)
281
+
282
+
@@ -0,0 +1,380 @@
1
+ import numpy as np
2
+ from typing import Optional
3
+
4
+ try:
5
+ from numba import jit, prange
6
+ import numba # noqa: F401
7
+ NUMBA_AVAILABLE = True
8
+ except ImportError: # pragma: no cover - optional accel
9
+ NUMBA_AVAILABLE = False
10
+ print("Numba not available. Using optimized version without JIT compilation.")
11
+
12
+ def jit(*args, **kwargs):
13
+ def decorator(func):
14
+ return func
15
+ return decorator
16
+
17
+ prange = range
18
+
19
+ from ..geoprocessor.raster import (
20
+ group_and_label_cells,
21
+ process_grid,
22
+ )
23
+ from ..utils.orientation import ensure_orientation, ORIENTATION_NORTH_UP, ORIENTATION_SOUTH_UP
24
+ from ..utils.lc import convert_land_cover
25
+
26
+
27
+ # -----------------------------
28
+ # Voxel class codes (semantics)
29
+ # -----------------------------
30
+ GROUND_CODE = -1
31
+ TREE_CODE = -2
32
+ BUILDING_CODE = -3
33
+
34
+
35
+ @jit(nopython=True, parallel=True)
36
+ def _voxelize_kernel(
37
+ voxel_grid,
38
+ land_cover_grid,
39
+ dem_grid,
40
+ tree_grid,
41
+ canopy_bottom_grid,
42
+ has_canopy_bottom,
43
+ seg_starts,
44
+ seg_ends,
45
+ seg_offsets,
46
+ seg_counts,
47
+ trunk_height_ratio,
48
+ voxel_size,
49
+ ):
50
+ rows, cols = land_cover_grid.shape
51
+ for i in prange(rows):
52
+ for j in range(cols):
53
+ ground_level = int(dem_grid[i, j] / voxel_size + 0.5) + 1
54
+
55
+ # ground and land cover layer
56
+ if ground_level > 0:
57
+ voxel_grid[i, j, :ground_level] = GROUND_CODE
58
+ voxel_grid[i, j, ground_level - 1] = land_cover_grid[i, j]
59
+
60
+ # trees
61
+ tree_height = tree_grid[i, j]
62
+ if tree_height > 0.0:
63
+ if has_canopy_bottom:
64
+ crown_base_height = canopy_bottom_grid[i, j]
65
+ else:
66
+ crown_base_height = tree_height * trunk_height_ratio
67
+ crown_base_level = int(crown_base_height / voxel_size + 0.5)
68
+ crown_top_level = int(tree_height / voxel_size + 0.5)
69
+ if (crown_top_level == crown_base_level) and (crown_base_level > 0):
70
+ crown_base_level -= 1
71
+ tree_start = ground_level + crown_base_level
72
+ tree_end = ground_level + crown_top_level
73
+ if tree_end > tree_start:
74
+ voxel_grid[i, j, tree_start:tree_end] = TREE_CODE
75
+
76
+ # buildings (packed segments)
77
+ base = seg_offsets[i, j]
78
+ count = seg_counts[i, j]
79
+ for k in range(count):
80
+ s = seg_starts[base + k]
81
+ e = seg_ends[base + k]
82
+ start = ground_level + s
83
+ end = ground_level + e
84
+ if end > start:
85
+ voxel_grid[i, j, start:end] = BUILDING_CODE
86
+
87
+
88
+ def _flatten_building_segments(building_min_height_grid: np.ndarray, voxel_size: float):
89
+ rows, cols = building_min_height_grid.shape
90
+ counts = np.zeros((rows, cols), dtype=np.int32)
91
+ # First pass: count segments per cell
92
+ for i in range(rows):
93
+ for j in range(cols):
94
+ cell = building_min_height_grid[i, j]
95
+ n = 0
96
+ if isinstance(cell, list):
97
+ n = len(cell)
98
+ counts[i, j] = np.int32(n)
99
+
100
+ # Prefix sum to compute offsets
101
+ offsets = np.zeros((rows, cols), dtype=np.int32)
102
+ total = 0
103
+ for i in range(rows):
104
+ for j in range(cols):
105
+ offsets[i, j] = total
106
+ total += int(counts[i, j])
107
+
108
+ seg_starts = np.zeros(total, dtype=np.int32)
109
+ seg_ends = np.zeros(total, dtype=np.int32)
110
+
111
+ # Second pass: fill flattened arrays
112
+ for i in range(rows):
113
+ for j in range(cols):
114
+ base = offsets[i, j]
115
+ n = counts[i, j]
116
+ if n == 0:
117
+ continue
118
+ cell = building_min_height_grid[i, j]
119
+ for k in range(int(n)):
120
+ mh = cell[k][0]
121
+ mx = cell[k][1]
122
+ seg_starts[base + k] = int(mh / voxel_size + 0.5)
123
+ seg_ends[base + k] = int(mx / voxel_size + 0.5)
124
+
125
+ return seg_starts, seg_ends, offsets, counts
126
+
127
+
128
+ class Voxelizer:
129
+ def __init__(
130
+ self,
131
+ voxel_size: float,
132
+ land_cover_source: str,
133
+ trunk_height_ratio: Optional[float] = None,
134
+ voxel_dtype=np.int8,
135
+ max_voxel_ram_mb: Optional[float] = None,
136
+ ) -> None:
137
+ self.voxel_size = float(voxel_size)
138
+ self.land_cover_source = land_cover_source
139
+ self.trunk_height_ratio = float(trunk_height_ratio) if trunk_height_ratio is not None else (11.76 / 19.98)
140
+ self.voxel_dtype = voxel_dtype
141
+ self.max_voxel_ram_mb = max_voxel_ram_mb
142
+
143
+ def _estimate_and_allocate(self, rows: int, cols: int, max_height: int) -> np.ndarray:
144
+ try:
145
+ bytes_per_elem = np.dtype(self.voxel_dtype).itemsize
146
+ est_mb = rows * cols * max_height * bytes_per_elem / (1024 ** 2)
147
+ print(f"Voxel grid shape: ({rows}, {cols}, {max_height}), dtype: {self.voxel_dtype}, ~{est_mb:.1f} MB")
148
+ if (self.max_voxel_ram_mb is not None) and (est_mb > self.max_voxel_ram_mb):
149
+ raise MemoryError(
150
+ f"Estimated voxel grid memory {est_mb:.1f} MB exceeds limit {self.max_voxel_ram_mb} MB. Increase mesh size or restrict ROI."
151
+ )
152
+ except Exception:
153
+ pass
154
+ return np.zeros((rows, cols, max_height), dtype=self.voxel_dtype)
155
+
156
+ def _convert_land_cover(self, land_cover_grid_ori: np.ndarray) -> np.ndarray:
157
+ if self.land_cover_source == 'OpenStreetMap':
158
+ return land_cover_grid_ori
159
+ return convert_land_cover(land_cover_grid_ori, land_cover_source=self.land_cover_source)
160
+
161
+ def generate_combined(
162
+ self,
163
+ building_height_grid_ori: np.ndarray,
164
+ building_min_height_grid_ori: np.ndarray,
165
+ building_id_grid_ori: np.ndarray,
166
+ land_cover_grid_ori: np.ndarray,
167
+ dem_grid_ori: np.ndarray,
168
+ tree_grid_ori: np.ndarray,
169
+ canopy_bottom_height_grid_ori: Optional[np.ndarray] = None,
170
+ **kwargs,
171
+ ) -> np.ndarray:
172
+ print("Generating 3D voxel data")
173
+
174
+ land_cover_grid_converted = self._convert_land_cover(land_cover_grid_ori)
175
+
176
+ building_height_grid = ensure_orientation(
177
+ np.nan_to_num(building_height_grid_ori, nan=10.0),
178
+ ORIENTATION_NORTH_UP,
179
+ ORIENTATION_SOUTH_UP,
180
+ )
181
+ building_min_height_grid = ensure_orientation(
182
+ replace_nan_in_nested(building_min_height_grid_ori),
183
+ ORIENTATION_NORTH_UP,
184
+ ORIENTATION_SOUTH_UP,
185
+ )
186
+ building_id_grid = ensure_orientation(
187
+ building_id_grid_ori,
188
+ ORIENTATION_NORTH_UP,
189
+ ORIENTATION_SOUTH_UP,
190
+ )
191
+ land_cover_grid = ensure_orientation(
192
+ land_cover_grid_converted.copy(),
193
+ ORIENTATION_NORTH_UP,
194
+ ORIENTATION_SOUTH_UP,
195
+ ) + 1
196
+ dem_grid = ensure_orientation(
197
+ dem_grid_ori.copy(),
198
+ ORIENTATION_NORTH_UP,
199
+ ORIENTATION_SOUTH_UP,
200
+ ) - np.min(dem_grid_ori)
201
+ dem_grid = process_grid(building_id_grid, dem_grid)
202
+ tree_grid = ensure_orientation(
203
+ tree_grid_ori.copy(),
204
+ ORIENTATION_NORTH_UP,
205
+ ORIENTATION_SOUTH_UP,
206
+ )
207
+ canopy_bottom_grid = None
208
+ if canopy_bottom_height_grid_ori is not None:
209
+ canopy_bottom_grid = ensure_orientation(
210
+ canopy_bottom_height_grid_ori.copy(),
211
+ ORIENTATION_NORTH_UP,
212
+ ORIENTATION_SOUTH_UP,
213
+ )
214
+
215
+ assert building_height_grid.shape == land_cover_grid.shape == dem_grid.shape == tree_grid.shape, "Input grids must have the same shape"
216
+ rows, cols = building_height_grid.shape
217
+ max_height = int(np.ceil(np.max(building_height_grid + dem_grid + tree_grid) / self.voxel_size)) + 1
218
+
219
+ voxel_grid = self._estimate_and_allocate(rows, cols, max_height)
220
+
221
+ trunk_height_ratio = float(kwargs.get("trunk_height_ratio", self.trunk_height_ratio))
222
+
223
+ if NUMBA_AVAILABLE:
224
+ has_canopy = canopy_bottom_grid is not None
225
+ canopy_in = canopy_bottom_grid if has_canopy else np.zeros_like(tree_grid)
226
+ seg_starts, seg_ends, seg_offsets, seg_counts = _flatten_building_segments(
227
+ building_min_height_grid, self.voxel_size
228
+ )
229
+ _voxelize_kernel(
230
+ voxel_grid,
231
+ land_cover_grid.astype(np.int32, copy=False),
232
+ dem_grid.astype(np.float32, copy=False),
233
+ tree_grid.astype(np.float32, copy=False),
234
+ canopy_in.astype(np.float32, copy=False),
235
+ has_canopy,
236
+ seg_starts,
237
+ seg_ends,
238
+ seg_offsets,
239
+ seg_counts,
240
+ float(trunk_height_ratio),
241
+ float(self.voxel_size),
242
+ )
243
+ return voxel_grid
244
+
245
+ for i in range(rows):
246
+ for j in range(cols):
247
+ ground_level = int(dem_grid[i, j] / self.voxel_size + 0.5) + 1
248
+ tree_height = tree_grid[i, j]
249
+ land_cover = land_cover_grid[i, j]
250
+
251
+ voxel_grid[i, j, :ground_level] = GROUND_CODE
252
+ voxel_grid[i, j, ground_level - 1] = land_cover
253
+
254
+ if tree_height > 0:
255
+ if canopy_bottom_grid is not None:
256
+ crown_base_height = canopy_bottom_grid[i, j]
257
+ else:
258
+ crown_base_height = (tree_height * trunk_height_ratio)
259
+ crown_base_height_level = int(crown_base_height / self.voxel_size + 0.5)
260
+ crown_top_height_level = int(tree_height / self.voxel_size + 0.5)
261
+ if (crown_top_height_level == crown_base_height_level) and (crown_base_height_level > 0):
262
+ crown_base_height_level -= 1
263
+ tree_start = ground_level + crown_base_height_level
264
+ tree_end = ground_level + crown_top_height_level
265
+ voxel_grid[i, j, tree_start:tree_end] = TREE_CODE
266
+
267
+ for k in building_min_height_grid[i, j]:
268
+ building_min_height = int(k[0] / self.voxel_size + 0.5)
269
+ building_height = int(k[1] / self.voxel_size + 0.5)
270
+ voxel_grid[i, j, ground_level + building_min_height:ground_level + building_height] = BUILDING_CODE
271
+
272
+ return voxel_grid
273
+
274
+ def generate_components(
275
+ self,
276
+ building_height_grid_ori: np.ndarray,
277
+ land_cover_grid_ori: np.ndarray,
278
+ dem_grid_ori: np.ndarray,
279
+ tree_grid_ori: np.ndarray,
280
+ layered_interval: Optional[int] = None,
281
+ ):
282
+ print("Generating 3D voxel data")
283
+
284
+ if self.land_cover_source != 'OpenEarthMapJapan':
285
+ land_cover_grid_converted = convert_land_cover(land_cover_grid_ori, land_cover_source=self.land_cover_source)
286
+ else:
287
+ land_cover_grid_converted = land_cover_grid_ori
288
+
289
+ building_height_grid = ensure_orientation(
290
+ building_height_grid_ori.copy(),
291
+ ORIENTATION_NORTH_UP,
292
+ ORIENTATION_SOUTH_UP,
293
+ )
294
+ land_cover_grid = ensure_orientation(
295
+ land_cover_grid_converted.copy(),
296
+ ORIENTATION_NORTH_UP,
297
+ ORIENTATION_SOUTH_UP,
298
+ ) + 1
299
+ dem_grid = ensure_orientation(
300
+ dem_grid_ori.copy(),
301
+ ORIENTATION_NORTH_UP,
302
+ ORIENTATION_SOUTH_UP,
303
+ ) - np.min(dem_grid_ori)
304
+ building_nr_grid = group_and_label_cells(
305
+ ensure_orientation(
306
+ building_height_grid_ori.copy(),
307
+ ORIENTATION_NORTH_UP,
308
+ ORIENTATION_SOUTH_UP,
309
+ )
310
+ )
311
+ dem_grid = process_grid(building_nr_grid, dem_grid)
312
+ tree_grid = ensure_orientation(
313
+ tree_grid_ori.copy(),
314
+ ORIENTATION_NORTH_UP,
315
+ ORIENTATION_SOUTH_UP,
316
+ )
317
+
318
+ assert building_height_grid.shape == land_cover_grid.shape == dem_grid.shape == tree_grid.shape, "Input grids must have the same shape"
319
+ rows, cols = building_height_grid.shape
320
+ max_height = int(np.ceil(np.max(building_height_grid + dem_grid + tree_grid) / self.voxel_size))
321
+
322
+ land_cover_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
323
+ building_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
324
+ tree_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
325
+ dem_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
326
+
327
+ for i in range(rows):
328
+ for j in range(cols):
329
+ ground_level = int(dem_grid[i, j] / self.voxel_size + 0.5)
330
+ building_height = int(building_height_grid[i, j] / self.voxel_size + 0.5)
331
+ tree_height = int(tree_grid[i, j] / self.voxel_size + 0.5)
332
+ land_cover = land_cover_grid[i, j]
333
+
334
+ dem_voxel_grid[i, j, :ground_level + 1] = -1
335
+ land_cover_voxel_grid[i, j, 0] = land_cover
336
+ if tree_height > 0:
337
+ tree_voxel_grid[i, j, :tree_height] = -2
338
+ if building_height > 0:
339
+ building_voxel_grid[i, j, :building_height] = -3
340
+
341
+ if not layered_interval:
342
+ layered_interval = max(max_height, int(dem_grid.shape[0] / 4 + 0.5))
343
+
344
+ extract_height = min(layered_interval, max_height)
345
+ layered_voxel_grid = np.zeros((rows, cols, layered_interval * 4), dtype=np.int32)
346
+ layered_voxel_grid[:, :, :extract_height] = dem_voxel_grid[:, :, :extract_height]
347
+ layered_voxel_grid[:, :, layered_interval:layered_interval + extract_height] = land_cover_voxel_grid[:, :, :extract_height]
348
+ layered_voxel_grid[:, :, 2 * layered_interval:2 * layered_interval + extract_height] = building_voxel_grid[:, :, :extract_height]
349
+ layered_voxel_grid[:, :, 3 * layered_interval:3 * layered_interval + extract_height] = tree_voxel_grid[:, :, :extract_height]
350
+
351
+ return land_cover_voxel_grid, building_voxel_grid, tree_voxel_grid, dem_voxel_grid, layered_voxel_grid
352
+
353
+
354
+ def replace_nan_in_nested(arr, replace_value=10.0):
355
+ if not isinstance(arr, np.ndarray):
356
+ return arr
357
+
358
+ result = np.empty_like(arr, dtype=object)
359
+ for i in range(arr.shape[0]):
360
+ for j in range(arr.shape[1]):
361
+ cell = arr[i, j]
362
+ if cell is None or (isinstance(cell, list) and len(cell) == 0):
363
+ result[i, j] = []
364
+ elif isinstance(cell, list):
365
+ new_cell = []
366
+ for segment in cell:
367
+ if isinstance(segment, (list, np.ndarray)):
368
+ if isinstance(segment, np.ndarray):
369
+ new_segment = np.where(np.isnan(segment), replace_value, segment).tolist()
370
+ else:
371
+ new_segment = [replace_value if (isinstance(v, float) and np.isnan(v)) else v for v in segment]
372
+ new_cell.append(new_segment)
373
+ else:
374
+ new_cell.append(segment)
375
+ result[i, j] = new_cell
376
+ else:
377
+ result[i, j] = cell
378
+ return result
379
+
380
+