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.
- voxcity/__init__.py +10 -4
- 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 +9 -1
- voxcity/exporter/cityles.py +129 -34
- voxcity/exporter/envimet.py +51 -26
- voxcity/exporter/magicavoxel.py +42 -5
- voxcity/exporter/netcdf.py +27 -0
- voxcity/exporter/obj.py +103 -28
- voxcity/generator/__init__.py +47 -0
- voxcity/generator/api.py +721 -0
- voxcity/generator/grids.py +381 -0
- voxcity/generator/io.py +94 -0
- voxcity/generator/pipeline.py +282 -0
- voxcity/generator/update.py +429 -0
- voxcity/generator/voxelizer.py +392 -0
- voxcity/geoprocessor/__init__.py +75 -6
- voxcity/geoprocessor/conversion.py +153 -0
- voxcity/geoprocessor/draw.py +1488 -1169
- voxcity/geoprocessor/heights.py +199 -0
- voxcity/geoprocessor/io.py +101 -0
- voxcity/geoprocessor/merge_utils.py +91 -0
- voxcity/geoprocessor/mesh.py +26 -10
- voxcity/geoprocessor/network.py +35 -6
- voxcity/geoprocessor/overlap.py +84 -0
- voxcity/geoprocessor/raster/__init__.py +82 -0
- voxcity/geoprocessor/raster/buildings.py +435 -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 +159 -0
- voxcity/geoprocessor/raster/raster.py +110 -0
- voxcity/geoprocessor/selection.py +85 -0
- voxcity/geoprocessor/utils.py +824 -820
- 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 +66 -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/sky.py +668 -0
- voxcity/simulator/solar/temporal.py +792 -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/__init__.py +11 -0
- voxcity/utils/classes.py +194 -0
- voxcity/utils/lc.py +80 -39
- voxcity/utils/logging.py +61 -0
- voxcity/utils/orientation.py +51 -0
- voxcity/utils/shape.py +230 -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 +1145 -0
- {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/METADATA +162 -48
- voxcity-1.0.2.dist-info/RECORD +81 -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-1.0.2.dist-info}/WHEEL +0 -0
- {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,392 @@
|
|
|
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
|
+
from ..utils.classes import VOXEL_CODE_DESCRIPTIONS, LAND_COVER_DESCRIPTIONS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# -----------------------------
|
|
29
|
+
# Voxel class codes (semantics)
|
|
30
|
+
# -----------------------------
|
|
31
|
+
GROUND_CODE = -1
|
|
32
|
+
TREE_CODE = -2
|
|
33
|
+
BUILDING_CODE = -3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@jit(nopython=True, parallel=True)
|
|
37
|
+
def _voxelize_kernel(
|
|
38
|
+
voxel_grid,
|
|
39
|
+
land_cover_grid,
|
|
40
|
+
dem_grid,
|
|
41
|
+
tree_grid,
|
|
42
|
+
canopy_bottom_grid,
|
|
43
|
+
has_canopy_bottom,
|
|
44
|
+
seg_starts,
|
|
45
|
+
seg_ends,
|
|
46
|
+
seg_offsets,
|
|
47
|
+
seg_counts,
|
|
48
|
+
trunk_height_ratio,
|
|
49
|
+
voxel_size,
|
|
50
|
+
):
|
|
51
|
+
rows, cols = land_cover_grid.shape
|
|
52
|
+
for i in prange(rows):
|
|
53
|
+
for j in range(cols):
|
|
54
|
+
ground_level = int(dem_grid[i, j] / voxel_size + 0.5) + 1
|
|
55
|
+
|
|
56
|
+
# ground and land cover layer
|
|
57
|
+
if ground_level > 0:
|
|
58
|
+
voxel_grid[i, j, :ground_level] = GROUND_CODE
|
|
59
|
+
voxel_grid[i, j, ground_level - 1] = land_cover_grid[i, j]
|
|
60
|
+
|
|
61
|
+
# trees
|
|
62
|
+
tree_height = tree_grid[i, j]
|
|
63
|
+
if tree_height > 0.0:
|
|
64
|
+
if has_canopy_bottom:
|
|
65
|
+
crown_base_height = canopy_bottom_grid[i, j]
|
|
66
|
+
else:
|
|
67
|
+
crown_base_height = tree_height * trunk_height_ratio
|
|
68
|
+
crown_base_level = int(crown_base_height / voxel_size + 0.5)
|
|
69
|
+
crown_top_level = int(tree_height / voxel_size + 0.5)
|
|
70
|
+
if (crown_top_level == crown_base_level) and (crown_base_level > 0):
|
|
71
|
+
crown_base_level -= 1
|
|
72
|
+
tree_start = ground_level + crown_base_level
|
|
73
|
+
tree_end = ground_level + crown_top_level
|
|
74
|
+
if tree_end > tree_start:
|
|
75
|
+
voxel_grid[i, j, tree_start:tree_end] = TREE_CODE
|
|
76
|
+
|
|
77
|
+
# buildings (packed segments)
|
|
78
|
+
base = seg_offsets[i, j]
|
|
79
|
+
count = seg_counts[i, j]
|
|
80
|
+
for k in range(count):
|
|
81
|
+
s = seg_starts[base + k]
|
|
82
|
+
e = seg_ends[base + k]
|
|
83
|
+
start = ground_level + s
|
|
84
|
+
end = ground_level + e
|
|
85
|
+
if end > start:
|
|
86
|
+
voxel_grid[i, j, start:end] = BUILDING_CODE
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _flatten_building_segments(building_min_height_grid: np.ndarray, voxel_size: float):
|
|
90
|
+
rows, cols = building_min_height_grid.shape
|
|
91
|
+
counts = np.zeros((rows, cols), dtype=np.int32)
|
|
92
|
+
# First pass: count segments per cell
|
|
93
|
+
for i in range(rows):
|
|
94
|
+
for j in range(cols):
|
|
95
|
+
cell = building_min_height_grid[i, j]
|
|
96
|
+
n = 0
|
|
97
|
+
if isinstance(cell, list):
|
|
98
|
+
n = len(cell)
|
|
99
|
+
counts[i, j] = np.int32(n)
|
|
100
|
+
|
|
101
|
+
# Prefix sum to compute offsets
|
|
102
|
+
offsets = np.zeros((rows, cols), dtype=np.int32)
|
|
103
|
+
total = 0
|
|
104
|
+
for i in range(rows):
|
|
105
|
+
for j in range(cols):
|
|
106
|
+
offsets[i, j] = total
|
|
107
|
+
total += int(counts[i, j])
|
|
108
|
+
|
|
109
|
+
seg_starts = np.zeros(total, dtype=np.int32)
|
|
110
|
+
seg_ends = np.zeros(total, dtype=np.int32)
|
|
111
|
+
|
|
112
|
+
# Second pass: fill flattened arrays
|
|
113
|
+
for i in range(rows):
|
|
114
|
+
for j in range(cols):
|
|
115
|
+
base = offsets[i, j]
|
|
116
|
+
n = counts[i, j]
|
|
117
|
+
if n == 0:
|
|
118
|
+
continue
|
|
119
|
+
cell = building_min_height_grid[i, j]
|
|
120
|
+
for k in range(int(n)):
|
|
121
|
+
mh = cell[k][0]
|
|
122
|
+
mx = cell[k][1]
|
|
123
|
+
seg_starts[base + k] = int(mh / voxel_size + 0.5)
|
|
124
|
+
seg_ends[base + k] = int(mx / voxel_size + 0.5)
|
|
125
|
+
|
|
126
|
+
return seg_starts, seg_ends, offsets, counts
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Voxelizer:
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
voxel_size: float,
|
|
133
|
+
land_cover_source: str,
|
|
134
|
+
trunk_height_ratio: Optional[float] = None,
|
|
135
|
+
voxel_dtype=np.int8,
|
|
136
|
+
max_voxel_ram_mb: Optional[float] = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
self.voxel_size = float(voxel_size)
|
|
139
|
+
self.land_cover_source = land_cover_source
|
|
140
|
+
self.trunk_height_ratio = float(trunk_height_ratio) if trunk_height_ratio is not None else (11.76 / 19.98)
|
|
141
|
+
self.voxel_dtype = voxel_dtype
|
|
142
|
+
self.max_voxel_ram_mb = max_voxel_ram_mb
|
|
143
|
+
|
|
144
|
+
def _estimate_and_allocate(self, rows: int, cols: int, max_height: int) -> np.ndarray:
|
|
145
|
+
try:
|
|
146
|
+
bytes_per_elem = np.dtype(self.voxel_dtype).itemsize
|
|
147
|
+
est_mb = rows * cols * max_height * bytes_per_elem / (1024 ** 2)
|
|
148
|
+
print(f"Voxel grid shape: ({rows}, {cols}, {max_height}), dtype: {self.voxel_dtype}, ~{est_mb:.1f} MB")
|
|
149
|
+
if (self.max_voxel_ram_mb is not None) and (est_mb > self.max_voxel_ram_mb):
|
|
150
|
+
raise MemoryError(
|
|
151
|
+
f"Estimated voxel grid memory {est_mb:.1f} MB exceeds limit {self.max_voxel_ram_mb} MB. Increase mesh size or restrict ROI."
|
|
152
|
+
)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
return np.zeros((rows, cols, max_height), dtype=self.voxel_dtype)
|
|
156
|
+
|
|
157
|
+
def _convert_land_cover(self, land_cover_grid_ori: np.ndarray) -> np.ndarray:
|
|
158
|
+
if self.land_cover_source == 'OpenStreetMap':
|
|
159
|
+
return land_cover_grid_ori + 1 # Shift to 1-based indices
|
|
160
|
+
return convert_land_cover(land_cover_grid_ori, land_cover_source=self.land_cover_source)
|
|
161
|
+
|
|
162
|
+
def generate_combined(
|
|
163
|
+
self,
|
|
164
|
+
building_height_grid_ori: np.ndarray,
|
|
165
|
+
building_min_height_grid_ori: np.ndarray,
|
|
166
|
+
building_id_grid_ori: np.ndarray,
|
|
167
|
+
land_cover_grid_ori: np.ndarray,
|
|
168
|
+
dem_grid_ori: np.ndarray,
|
|
169
|
+
tree_grid_ori: np.ndarray,
|
|
170
|
+
canopy_bottom_height_grid_ori: Optional[np.ndarray] = None,
|
|
171
|
+
**kwargs,
|
|
172
|
+
) -> np.ndarray:
|
|
173
|
+
print("Generating 3D voxel data")
|
|
174
|
+
|
|
175
|
+
# Print class definitions if requested
|
|
176
|
+
if kwargs.get("print_class_info", True):
|
|
177
|
+
print(VOXEL_CODE_DESCRIPTIONS)
|
|
178
|
+
print(LAND_COVER_DESCRIPTIONS)
|
|
179
|
+
|
|
180
|
+
land_cover_grid_converted = self._convert_land_cover(land_cover_grid_ori)
|
|
181
|
+
|
|
182
|
+
building_height_grid = ensure_orientation(
|
|
183
|
+
np.nan_to_num(building_height_grid_ori, nan=10.0),
|
|
184
|
+
ORIENTATION_NORTH_UP,
|
|
185
|
+
ORIENTATION_SOUTH_UP,
|
|
186
|
+
)
|
|
187
|
+
building_min_height_grid = ensure_orientation(
|
|
188
|
+
replace_nan_in_nested(building_min_height_grid_ori),
|
|
189
|
+
ORIENTATION_NORTH_UP,
|
|
190
|
+
ORIENTATION_SOUTH_UP,
|
|
191
|
+
)
|
|
192
|
+
building_id_grid = ensure_orientation(
|
|
193
|
+
building_id_grid_ori,
|
|
194
|
+
ORIENTATION_NORTH_UP,
|
|
195
|
+
ORIENTATION_SOUTH_UP,
|
|
196
|
+
)
|
|
197
|
+
land_cover_grid = ensure_orientation(
|
|
198
|
+
land_cover_grid_converted.copy(),
|
|
199
|
+
ORIENTATION_NORTH_UP,
|
|
200
|
+
ORIENTATION_SOUTH_UP,
|
|
201
|
+
)
|
|
202
|
+
dem_grid = ensure_orientation(
|
|
203
|
+
dem_grid_ori.copy(),
|
|
204
|
+
ORIENTATION_NORTH_UP,
|
|
205
|
+
ORIENTATION_SOUTH_UP,
|
|
206
|
+
) - np.min(dem_grid_ori)
|
|
207
|
+
dem_grid = process_grid(building_id_grid, dem_grid)
|
|
208
|
+
tree_grid = ensure_orientation(
|
|
209
|
+
tree_grid_ori.copy(),
|
|
210
|
+
ORIENTATION_NORTH_UP,
|
|
211
|
+
ORIENTATION_SOUTH_UP,
|
|
212
|
+
)
|
|
213
|
+
canopy_bottom_grid = None
|
|
214
|
+
if canopy_bottom_height_grid_ori is not None:
|
|
215
|
+
canopy_bottom_grid = ensure_orientation(
|
|
216
|
+
canopy_bottom_height_grid_ori.copy(),
|
|
217
|
+
ORIENTATION_NORTH_UP,
|
|
218
|
+
ORIENTATION_SOUTH_UP,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
assert building_height_grid.shape == land_cover_grid.shape == dem_grid.shape == tree_grid.shape, "Input grids must have the same shape"
|
|
222
|
+
rows, cols = building_height_grid.shape
|
|
223
|
+
max_height = int(np.ceil(np.max(building_height_grid + dem_grid + tree_grid) / self.voxel_size)) + 1
|
|
224
|
+
|
|
225
|
+
voxel_grid = self._estimate_and_allocate(rows, cols, max_height)
|
|
226
|
+
|
|
227
|
+
trunk_height_ratio = float(kwargs.get("trunk_height_ratio", self.trunk_height_ratio))
|
|
228
|
+
|
|
229
|
+
if NUMBA_AVAILABLE:
|
|
230
|
+
has_canopy = canopy_bottom_grid is not None
|
|
231
|
+
canopy_in = canopy_bottom_grid if has_canopy else np.zeros_like(tree_grid)
|
|
232
|
+
seg_starts, seg_ends, seg_offsets, seg_counts = _flatten_building_segments(
|
|
233
|
+
building_min_height_grid, self.voxel_size
|
|
234
|
+
)
|
|
235
|
+
_voxelize_kernel(
|
|
236
|
+
voxel_grid,
|
|
237
|
+
land_cover_grid.astype(np.int32, copy=False),
|
|
238
|
+
dem_grid.astype(np.float32, copy=False),
|
|
239
|
+
tree_grid.astype(np.float32, copy=False),
|
|
240
|
+
canopy_in.astype(np.float32, copy=False),
|
|
241
|
+
has_canopy,
|
|
242
|
+
seg_starts,
|
|
243
|
+
seg_ends,
|
|
244
|
+
seg_offsets,
|
|
245
|
+
seg_counts,
|
|
246
|
+
float(trunk_height_ratio),
|
|
247
|
+
float(self.voxel_size),
|
|
248
|
+
)
|
|
249
|
+
return voxel_grid
|
|
250
|
+
|
|
251
|
+
for i in range(rows):
|
|
252
|
+
for j in range(cols):
|
|
253
|
+
ground_level = int(dem_grid[i, j] / self.voxel_size + 0.5) + 1
|
|
254
|
+
tree_height = tree_grid[i, j]
|
|
255
|
+
land_cover = land_cover_grid[i, j]
|
|
256
|
+
|
|
257
|
+
voxel_grid[i, j, :ground_level] = GROUND_CODE
|
|
258
|
+
voxel_grid[i, j, ground_level - 1] = land_cover
|
|
259
|
+
|
|
260
|
+
if tree_height > 0:
|
|
261
|
+
if canopy_bottom_grid is not None:
|
|
262
|
+
crown_base_height = canopy_bottom_grid[i, j]
|
|
263
|
+
else:
|
|
264
|
+
crown_base_height = (tree_height * trunk_height_ratio)
|
|
265
|
+
crown_base_height_level = int(crown_base_height / self.voxel_size + 0.5)
|
|
266
|
+
crown_top_height_level = int(tree_height / self.voxel_size + 0.5)
|
|
267
|
+
if (crown_top_height_level == crown_base_height_level) and (crown_base_height_level > 0):
|
|
268
|
+
crown_base_height_level -= 1
|
|
269
|
+
tree_start = ground_level + crown_base_height_level
|
|
270
|
+
tree_end = ground_level + crown_top_height_level
|
|
271
|
+
voxel_grid[i, j, tree_start:tree_end] = TREE_CODE
|
|
272
|
+
|
|
273
|
+
for k in building_min_height_grid[i, j]:
|
|
274
|
+
building_min_height = int(k[0] / self.voxel_size + 0.5)
|
|
275
|
+
building_height = int(k[1] / self.voxel_size + 0.5)
|
|
276
|
+
voxel_grid[i, j, ground_level + building_min_height:ground_level + building_height] = BUILDING_CODE
|
|
277
|
+
|
|
278
|
+
return voxel_grid
|
|
279
|
+
|
|
280
|
+
def generate_components(
|
|
281
|
+
self,
|
|
282
|
+
building_height_grid_ori: np.ndarray,
|
|
283
|
+
land_cover_grid_ori: np.ndarray,
|
|
284
|
+
dem_grid_ori: np.ndarray,
|
|
285
|
+
tree_grid_ori: np.ndarray,
|
|
286
|
+
layered_interval: Optional[int] = None,
|
|
287
|
+
print_class_info: bool = True,
|
|
288
|
+
):
|
|
289
|
+
print("Generating 3D voxel data")
|
|
290
|
+
if print_class_info:
|
|
291
|
+
print(VOXEL_CODE_DESCRIPTIONS)
|
|
292
|
+
print(LAND_COVER_DESCRIPTIONS)
|
|
293
|
+
|
|
294
|
+
if self.land_cover_source == 'OpenStreetMap':
|
|
295
|
+
# OpenStreetMap uses Standard classification, just shift to 1-based
|
|
296
|
+
land_cover_grid_converted = land_cover_grid_ori + 1
|
|
297
|
+
else:
|
|
298
|
+
# All other sources need remapping to standard indices
|
|
299
|
+
land_cover_grid_converted = convert_land_cover(land_cover_grid_ori, land_cover_source=self.land_cover_source)
|
|
300
|
+
|
|
301
|
+
building_height_grid = ensure_orientation(
|
|
302
|
+
building_height_grid_ori.copy(),
|
|
303
|
+
ORIENTATION_NORTH_UP,
|
|
304
|
+
ORIENTATION_SOUTH_UP,
|
|
305
|
+
)
|
|
306
|
+
land_cover_grid = ensure_orientation(
|
|
307
|
+
land_cover_grid_converted.copy(),
|
|
308
|
+
ORIENTATION_NORTH_UP,
|
|
309
|
+
ORIENTATION_SOUTH_UP,
|
|
310
|
+
)
|
|
311
|
+
dem_grid = ensure_orientation(
|
|
312
|
+
dem_grid_ori.copy(),
|
|
313
|
+
ORIENTATION_NORTH_UP,
|
|
314
|
+
ORIENTATION_SOUTH_UP,
|
|
315
|
+
) - np.min(dem_grid_ori)
|
|
316
|
+
building_nr_grid = group_and_label_cells(
|
|
317
|
+
ensure_orientation(
|
|
318
|
+
building_height_grid_ori.copy(),
|
|
319
|
+
ORIENTATION_NORTH_UP,
|
|
320
|
+
ORIENTATION_SOUTH_UP,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
dem_grid = process_grid(building_nr_grid, dem_grid)
|
|
324
|
+
tree_grid = ensure_orientation(
|
|
325
|
+
tree_grid_ori.copy(),
|
|
326
|
+
ORIENTATION_NORTH_UP,
|
|
327
|
+
ORIENTATION_SOUTH_UP,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
assert building_height_grid.shape == land_cover_grid.shape == dem_grid.shape == tree_grid.shape, "Input grids must have the same shape"
|
|
331
|
+
rows, cols = building_height_grid.shape
|
|
332
|
+
max_height = int(np.ceil(np.max(building_height_grid + dem_grid + tree_grid) / self.voxel_size))
|
|
333
|
+
|
|
334
|
+
land_cover_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
|
|
335
|
+
building_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
|
|
336
|
+
tree_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
|
|
337
|
+
dem_voxel_grid = np.zeros((rows, cols, max_height), dtype=np.int32)
|
|
338
|
+
|
|
339
|
+
for i in range(rows):
|
|
340
|
+
for j in range(cols):
|
|
341
|
+
ground_level = int(dem_grid[i, j] / self.voxel_size + 0.5)
|
|
342
|
+
building_height = int(building_height_grid[i, j] / self.voxel_size + 0.5)
|
|
343
|
+
tree_height = int(tree_grid[i, j] / self.voxel_size + 0.5)
|
|
344
|
+
land_cover = land_cover_grid[i, j]
|
|
345
|
+
|
|
346
|
+
dem_voxel_grid[i, j, :ground_level + 1] = -1
|
|
347
|
+
land_cover_voxel_grid[i, j, 0] = land_cover
|
|
348
|
+
if tree_height > 0:
|
|
349
|
+
tree_voxel_grid[i, j, :tree_height] = -2
|
|
350
|
+
if building_height > 0:
|
|
351
|
+
building_voxel_grid[i, j, :building_height] = -3
|
|
352
|
+
|
|
353
|
+
if not layered_interval:
|
|
354
|
+
layered_interval = max(max_height, int(dem_grid.shape[0] / 4 + 0.5))
|
|
355
|
+
|
|
356
|
+
extract_height = min(layered_interval, max_height)
|
|
357
|
+
layered_voxel_grid = np.zeros((rows, cols, layered_interval * 4), dtype=np.int32)
|
|
358
|
+
layered_voxel_grid[:, :, :extract_height] = dem_voxel_grid[:, :, :extract_height]
|
|
359
|
+
layered_voxel_grid[:, :, layered_interval:layered_interval + extract_height] = land_cover_voxel_grid[:, :, :extract_height]
|
|
360
|
+
layered_voxel_grid[:, :, 2 * layered_interval:2 * layered_interval + extract_height] = building_voxel_grid[:, :, :extract_height]
|
|
361
|
+
layered_voxel_grid[:, :, 3 * layered_interval:3 * layered_interval + extract_height] = tree_voxel_grid[:, :, :extract_height]
|
|
362
|
+
|
|
363
|
+
return land_cover_voxel_grid, building_voxel_grid, tree_voxel_grid, dem_voxel_grid, layered_voxel_grid
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def replace_nan_in_nested(arr, replace_value=10.0):
|
|
367
|
+
if not isinstance(arr, np.ndarray):
|
|
368
|
+
return arr
|
|
369
|
+
|
|
370
|
+
result = np.empty_like(arr, dtype=object)
|
|
371
|
+
for i in range(arr.shape[0]):
|
|
372
|
+
for j in range(arr.shape[1]):
|
|
373
|
+
cell = arr[i, j]
|
|
374
|
+
if cell is None or (isinstance(cell, list) and len(cell) == 0):
|
|
375
|
+
result[i, j] = []
|
|
376
|
+
elif isinstance(cell, list):
|
|
377
|
+
new_cell = []
|
|
378
|
+
for segment in cell:
|
|
379
|
+
if isinstance(segment, (list, np.ndarray)):
|
|
380
|
+
if isinstance(segment, np.ndarray):
|
|
381
|
+
new_segment = np.where(np.isnan(segment), replace_value, segment).tolist()
|
|
382
|
+
else:
|
|
383
|
+
new_segment = [replace_value if (isinstance(v, float) and np.isnan(v)) else v for v in segment]
|
|
384
|
+
new_cell.append(new_segment)
|
|
385
|
+
else:
|
|
386
|
+
new_cell.append(segment)
|
|
387
|
+
result[i, j] = new_cell
|
|
388
|
+
else:
|
|
389
|
+
result[i, j] = cell
|
|
390
|
+
return result
|
|
391
|
+
|
|
392
|
+
|
voxcity/geoprocessor/__init__.py
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
|
-
from .
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
from . import (
|
|
2
|
+
draw,
|
|
3
|
+
utils,
|
|
4
|
+
network,
|
|
5
|
+
mesh,
|
|
6
|
+
raster,
|
|
7
|
+
conversion,
|
|
8
|
+
io,
|
|
9
|
+
heights,
|
|
10
|
+
selection,
|
|
11
|
+
overlap,
|
|
12
|
+
merge_utils,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Re-export frequently used functions at package level for convenience
|
|
16
|
+
from .conversion import (
|
|
17
|
+
filter_and_convert_gdf_to_geojson,
|
|
18
|
+
geojson_to_gdf,
|
|
19
|
+
gdf_to_geojson_dicts,
|
|
20
|
+
)
|
|
21
|
+
from .io import (
|
|
22
|
+
get_geojson_from_gpkg,
|
|
23
|
+
get_gdf_from_gpkg,
|
|
24
|
+
load_gdf_from_multiple_gz,
|
|
25
|
+
swap_coordinates,
|
|
26
|
+
save_geojson,
|
|
27
|
+
)
|
|
28
|
+
from .heights import (
|
|
29
|
+
extract_building_heights_from_gdf,
|
|
30
|
+
extract_building_heights_from_geotiff,
|
|
31
|
+
complement_building_heights_from_gdf,
|
|
32
|
+
)
|
|
33
|
+
from .selection import (
|
|
34
|
+
filter_buildings,
|
|
35
|
+
find_building_containing_point,
|
|
36
|
+
get_buildings_in_drawn_polygon,
|
|
37
|
+
)
|
|
38
|
+
from .overlap import (
|
|
39
|
+
process_building_footprints_by_overlap,
|
|
40
|
+
)
|
|
41
|
+
from .merge_utils import (
|
|
42
|
+
merge_gdfs_with_id_conflict_resolution,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# submodules
|
|
47
|
+
"draw",
|
|
48
|
+
"utils",
|
|
49
|
+
"network",
|
|
50
|
+
"mesh",
|
|
51
|
+
"raster",
|
|
52
|
+
"conversion",
|
|
53
|
+
"io",
|
|
54
|
+
"heights",
|
|
55
|
+
"selection",
|
|
56
|
+
"overlap",
|
|
57
|
+
"merge_utils",
|
|
58
|
+
# functions
|
|
59
|
+
"filter_and_convert_gdf_to_geojson",
|
|
60
|
+
"geojson_to_gdf",
|
|
61
|
+
"gdf_to_geojson_dicts",
|
|
62
|
+
"get_geojson_from_gpkg",
|
|
63
|
+
"get_gdf_from_gpkg",
|
|
64
|
+
"load_gdf_from_multiple_gz",
|
|
65
|
+
"swap_coordinates",
|
|
66
|
+
"save_geojson",
|
|
67
|
+
"extract_building_heights_from_gdf",
|
|
68
|
+
"extract_building_heights_from_geotiff",
|
|
69
|
+
"complement_building_heights_from_gdf",
|
|
70
|
+
"filter_buildings",
|
|
71
|
+
"find_building_containing_point",
|
|
72
|
+
"get_buildings_in_drawn_polygon",
|
|
73
|
+
"process_building_footprints_by_overlap",
|
|
74
|
+
"merge_gdfs_with_id_conflict_resolution",
|
|
75
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversion utilities between GeoJSON-like features and GeoPandas GeoDataFrames,
|
|
3
|
+
plus helpers to filter and transform geometries for export.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import List, Dict
|
|
8
|
+
|
|
9
|
+
import geopandas as gpd
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from shapely.geometry import Polygon, shape
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def filter_and_convert_gdf_to_geojson(gdf, rectangle_vertices):
|
|
15
|
+
"""
|
|
16
|
+
Filter a GeoDataFrame by a bounding rectangle and convert to GeoJSON format.
|
|
17
|
+
|
|
18
|
+
This function performs spatial filtering on a GeoDataFrame using a bounding rectangle,
|
|
19
|
+
and converts the filtered data to GeoJSON format. It handles both Polygon and MultiPolygon
|
|
20
|
+
geometries, splitting MultiPolygons into separate Polygon features.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
gdf (GeoDataFrame): Input GeoDataFrame containing building data
|
|
24
|
+
Must have 'geometry' and 'height' columns
|
|
25
|
+
Any CRS is accepted, will be converted to WGS84 if needed
|
|
26
|
+
rectangle_vertices (list): List of (lon, lat) tuples defining the bounding rectangle
|
|
27
|
+
Must be in WGS84 (EPSG:4326) coordinate system
|
|
28
|
+
Must form a valid rectangle (4 vertices, clockwise or counterclockwise)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
list: List of GeoJSON features within the bounding rectangle
|
|
32
|
+
Each feature contains:
|
|
33
|
+
- geometry: Polygon coordinates in WGS84
|
|
34
|
+
- properties: Dictionary with 'height', 'confidence', and 'id'
|
|
35
|
+
- type: Always "Feature"
|
|
36
|
+
|
|
37
|
+
Memory Optimization:
|
|
38
|
+
- Uses spatial indexing for efficient filtering
|
|
39
|
+
- Downcasts numeric columns to save memory
|
|
40
|
+
- Cleans up intermediate data structures
|
|
41
|
+
- Splits MultiPolygons into separate features
|
|
42
|
+
"""
|
|
43
|
+
if gdf.crs != 'EPSG:4326':
|
|
44
|
+
gdf = gdf.to_crs(epsg=4326)
|
|
45
|
+
|
|
46
|
+
gdf['height'] = pd.to_numeric(gdf['height'], downcast='float')
|
|
47
|
+
gdf['confidence'] = -1.0
|
|
48
|
+
|
|
49
|
+
rectangle_polygon = Polygon(rectangle_vertices)
|
|
50
|
+
|
|
51
|
+
gdf.sindex
|
|
52
|
+
possible_matches_index = list(gdf.sindex.intersection(rectangle_polygon.bounds))
|
|
53
|
+
possible_matches = gdf.iloc[possible_matches_index]
|
|
54
|
+
precise_matches = possible_matches[possible_matches.intersects(rectangle_polygon)]
|
|
55
|
+
filtered_gdf = precise_matches.copy()
|
|
56
|
+
|
|
57
|
+
del gdf, possible_matches, precise_matches
|
|
58
|
+
|
|
59
|
+
features = []
|
|
60
|
+
feature_id = 1
|
|
61
|
+
for _, row in filtered_gdf.iterrows():
|
|
62
|
+
geom = row['geometry'].__geo_interface__
|
|
63
|
+
properties = {
|
|
64
|
+
'height': row['height'],
|
|
65
|
+
'confidence': row['confidence'],
|
|
66
|
+
'id': feature_id
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if geom['type'] == 'MultiPolygon':
|
|
70
|
+
for polygon_coords in geom['coordinates']:
|
|
71
|
+
single_geom = {
|
|
72
|
+
'type': 'Polygon',
|
|
73
|
+
'coordinates': polygon_coords
|
|
74
|
+
}
|
|
75
|
+
feature = {
|
|
76
|
+
'type': 'Feature',
|
|
77
|
+
'properties': properties.copy(),
|
|
78
|
+
'geometry': single_geom
|
|
79
|
+
}
|
|
80
|
+
features.append(feature)
|
|
81
|
+
feature_id += 1
|
|
82
|
+
elif geom['type'] == 'Polygon':
|
|
83
|
+
feature = {
|
|
84
|
+
'type': 'Feature',
|
|
85
|
+
'properties': properties,
|
|
86
|
+
'geometry': geom
|
|
87
|
+
}
|
|
88
|
+
features.append(feature)
|
|
89
|
+
feature_id += 1
|
|
90
|
+
else:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
geojson = {
|
|
94
|
+
'type': 'FeatureCollection',
|
|
95
|
+
'features': features
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
del filtered_gdf, features
|
|
99
|
+
|
|
100
|
+
return geojson["features"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def geojson_to_gdf(geojson_data, id_col='id'):
|
|
104
|
+
"""
|
|
105
|
+
Convert a list of GeoJSON-like dict features into a GeoDataFrame.
|
|
106
|
+
|
|
107
|
+
This function takes a list of GeoJSON feature dictionaries (Fiona-like format)
|
|
108
|
+
and converts them into a GeoDataFrame, handling geometry conversion and property
|
|
109
|
+
extraction. It ensures each feature has a unique identifier.
|
|
110
|
+
"""
|
|
111
|
+
geometries = []
|
|
112
|
+
all_props = []
|
|
113
|
+
|
|
114
|
+
for i, feature in enumerate(geojson_data):
|
|
115
|
+
geom = feature.get('geometry')
|
|
116
|
+
shapely_geom = shape(geom) if geom else None
|
|
117
|
+
|
|
118
|
+
props = feature.get('properties', {})
|
|
119
|
+
if id_col not in props:
|
|
120
|
+
props[id_col] = i
|
|
121
|
+
|
|
122
|
+
geometries.append(shapely_geom)
|
|
123
|
+
all_props.append(props)
|
|
124
|
+
|
|
125
|
+
gdf = gpd.GeoDataFrame(all_props, geometry=geometries, crs="EPSG:4326")
|
|
126
|
+
return gdf
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def gdf_to_geojson_dicts(gdf, id_col='id'):
|
|
130
|
+
"""
|
|
131
|
+
Convert a GeoDataFrame to a list of dicts similar to GeoJSON features.
|
|
132
|
+
"""
|
|
133
|
+
records = gdf.to_dict(orient='records')
|
|
134
|
+
features = []
|
|
135
|
+
|
|
136
|
+
for rec in records:
|
|
137
|
+
geom = rec.pop('geometry', None)
|
|
138
|
+
if geom is not None:
|
|
139
|
+
geom = geom.__geo_interface__
|
|
140
|
+
|
|
141
|
+
_ = rec.get(id_col, None)
|
|
142
|
+
props = {k: v for k, v in rec.items() if k != id_col}
|
|
143
|
+
|
|
144
|
+
feature = {
|
|
145
|
+
'type': 'Feature',
|
|
146
|
+
'properties': props,
|
|
147
|
+
'geometry': geom
|
|
148
|
+
}
|
|
149
|
+
features.append(feature)
|
|
150
|
+
|
|
151
|
+
return features
|
|
152
|
+
|
|
153
|
+
|