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,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
+
@@ -1,6 +1,75 @@
1
- from .draw import *
2
- from .grid import *
3
- from .utils import *
4
- from .network import *
5
- from .polygon import *
6
- from .mesh import *
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
+