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.
- voxcity/__init__.py +14 -8
- voxcity/downloader/__init__.py +2 -1
- voxcity/downloader/gba.py +210 -0
- voxcity/downloader/gee.py +5 -1
- voxcity/downloader/mbfp.py +1 -1
- voxcity/downloader/oemj.py +80 -8
- voxcity/downloader/utils.py +73 -73
- voxcity/errors.py +30 -0
- voxcity/exporter/__init__.py +13 -5
- voxcity/exporter/cityles.py +633 -538
- voxcity/exporter/envimet.py +728 -708
- voxcity/exporter/magicavoxel.py +334 -297
- voxcity/exporter/netcdf.py +238 -211
- voxcity/exporter/obj.py +1481 -1406
- voxcity/generator/__init__.py +44 -0
- voxcity/generator/api.py +675 -0
- voxcity/generator/grids.py +379 -0
- voxcity/generator/io.py +94 -0
- voxcity/generator/pipeline.py +282 -0
- voxcity/generator/voxelizer.py +380 -0
- voxcity/geoprocessor/__init__.py +75 -6
- voxcity/geoprocessor/conversion.py +153 -0
- voxcity/geoprocessor/draw.py +62 -12
- voxcity/geoprocessor/heights.py +199 -0
- voxcity/geoprocessor/io.py +101 -0
- voxcity/geoprocessor/merge_utils.py +91 -0
- voxcity/geoprocessor/mesh.py +806 -790
- voxcity/geoprocessor/network.py +708 -679
- voxcity/geoprocessor/overlap.py +84 -0
- voxcity/geoprocessor/raster/__init__.py +82 -0
- voxcity/geoprocessor/raster/buildings.py +428 -0
- voxcity/geoprocessor/raster/canopy.py +258 -0
- voxcity/geoprocessor/raster/core.py +150 -0
- voxcity/geoprocessor/raster/export.py +93 -0
- voxcity/geoprocessor/raster/landcover.py +156 -0
- voxcity/geoprocessor/raster/raster.py +110 -0
- voxcity/geoprocessor/selection.py +85 -0
- voxcity/geoprocessor/utils.py +18 -14
- voxcity/models.py +113 -0
- voxcity/simulator/common/__init__.py +22 -0
- voxcity/simulator/common/geometry.py +98 -0
- voxcity/simulator/common/raytracing.py +450 -0
- voxcity/simulator/solar/__init__.py +43 -0
- voxcity/simulator/solar/integration.py +336 -0
- voxcity/simulator/solar/kernels.py +62 -0
- voxcity/simulator/solar/radiation.py +648 -0
- voxcity/simulator/solar/temporal.py +434 -0
- voxcity/simulator/view.py +36 -2286
- voxcity/simulator/visibility/__init__.py +29 -0
- voxcity/simulator/visibility/landmark.py +392 -0
- voxcity/simulator/visibility/view.py +508 -0
- voxcity/utils/logging.py +61 -0
- voxcity/utils/orientation.py +51 -0
- voxcity/utils/weather/__init__.py +26 -0
- voxcity/utils/weather/epw.py +146 -0
- voxcity/utils/weather/files.py +36 -0
- voxcity/utils/weather/onebuilding.py +486 -0
- voxcity/visualizer/__init__.py +24 -0
- voxcity/visualizer/builder.py +43 -0
- voxcity/visualizer/grids.py +141 -0
- voxcity/visualizer/maps.py +187 -0
- voxcity/visualizer/palette.py +228 -0
- voxcity/visualizer/renderer.py +928 -0
- {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/METADATA +107 -34
- voxcity-0.7.0.dist-info/RECORD +77 -0
- voxcity/generator.py +0 -1302
- voxcity/geoprocessor/grid.py +0 -1739
- voxcity/geoprocessor/polygon.py +0 -1344
- voxcity/simulator/solar.py +0 -2339
- voxcity/utils/visualization.py +0 -2849
- voxcity/utils/weather.py +0 -1038
- voxcity-0.6.26.dist-info/RECORD +0 -38
- {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/WHEEL +0 -0
- {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/licenses/AUTHORS.rst +0 -0
- {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
|
+
|