voxcity 0.7.0__py3-none-any.whl → 1.0.13__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 +14 -14
  2. voxcity/downloader/ocean.py +559 -0
  3. voxcity/exporter/__init__.py +12 -12
  4. voxcity/exporter/cityles.py +633 -633
  5. voxcity/exporter/envimet.py +733 -728
  6. voxcity/exporter/magicavoxel.py +333 -333
  7. voxcity/exporter/netcdf.py +238 -238
  8. voxcity/exporter/obj.py +1480 -1480
  9. voxcity/generator/__init__.py +47 -44
  10. voxcity/generator/api.py +727 -675
  11. voxcity/generator/grids.py +394 -379
  12. voxcity/generator/io.py +94 -94
  13. voxcity/generator/pipeline.py +582 -282
  14. voxcity/generator/update.py +429 -0
  15. voxcity/generator/voxelizer.py +18 -6
  16. voxcity/geoprocessor/__init__.py +75 -75
  17. voxcity/geoprocessor/draw.py +1494 -1219
  18. voxcity/geoprocessor/merge_utils.py +91 -91
  19. voxcity/geoprocessor/mesh.py +806 -806
  20. voxcity/geoprocessor/network.py +708 -708
  21. voxcity/geoprocessor/raster/__init__.py +2 -0
  22. voxcity/geoprocessor/raster/buildings.py +435 -428
  23. voxcity/geoprocessor/raster/core.py +31 -0
  24. voxcity/geoprocessor/raster/export.py +93 -93
  25. voxcity/geoprocessor/raster/landcover.py +178 -51
  26. voxcity/geoprocessor/raster/raster.py +1 -1
  27. voxcity/geoprocessor/utils.py +824 -824
  28. voxcity/models.py +115 -113
  29. voxcity/simulator/solar/__init__.py +66 -43
  30. voxcity/simulator/solar/integration.py +336 -336
  31. voxcity/simulator/solar/sky.py +668 -0
  32. voxcity/simulator/solar/temporal.py +792 -434
  33. voxcity/simulator_gpu/__init__.py +115 -0
  34. voxcity/simulator_gpu/common/__init__.py +9 -0
  35. voxcity/simulator_gpu/common/geometry.py +11 -0
  36. voxcity/simulator_gpu/core.py +322 -0
  37. voxcity/simulator_gpu/domain.py +262 -0
  38. voxcity/simulator_gpu/environment.yml +11 -0
  39. voxcity/simulator_gpu/init_taichi.py +154 -0
  40. voxcity/simulator_gpu/integration.py +15 -0
  41. voxcity/simulator_gpu/kernels.py +56 -0
  42. voxcity/simulator_gpu/radiation.py +28 -0
  43. voxcity/simulator_gpu/raytracing.py +623 -0
  44. voxcity/simulator_gpu/sky.py +9 -0
  45. voxcity/simulator_gpu/solar/__init__.py +178 -0
  46. voxcity/simulator_gpu/solar/core.py +66 -0
  47. voxcity/simulator_gpu/solar/csf.py +1249 -0
  48. voxcity/simulator_gpu/solar/domain.py +561 -0
  49. voxcity/simulator_gpu/solar/epw.py +421 -0
  50. voxcity/simulator_gpu/solar/integration.py +2953 -0
  51. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  52. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  53. voxcity/simulator_gpu/solar/reflection.py +533 -0
  54. voxcity/simulator_gpu/solar/sky.py +907 -0
  55. voxcity/simulator_gpu/solar/solar.py +337 -0
  56. voxcity/simulator_gpu/solar/svf.py +446 -0
  57. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  58. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  59. voxcity/simulator_gpu/temporal.py +13 -0
  60. voxcity/simulator_gpu/utils.py +25 -0
  61. voxcity/simulator_gpu/view.py +32 -0
  62. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  63. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  64. voxcity/simulator_gpu/visibility/integration.py +808 -0
  65. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  66. voxcity/simulator_gpu/visibility/view.py +944 -0
  67. voxcity/utils/__init__.py +11 -0
  68. voxcity/utils/classes.py +194 -0
  69. voxcity/utils/lc.py +80 -39
  70. voxcity/utils/shape.py +230 -0
  71. voxcity/visualizer/__init__.py +24 -24
  72. voxcity/visualizer/builder.py +43 -43
  73. voxcity/visualizer/grids.py +141 -141
  74. voxcity/visualizer/maps.py +187 -187
  75. voxcity/visualizer/renderer.py +1146 -928
  76. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
  77. voxcity-1.0.13.dist-info/RECORD +116 -0
  78. voxcity-0.7.0.dist-info/RECORD +0 -77
  79. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  80. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
voxcity/utils/__init__.py CHANGED
@@ -1,3 +1,14 @@
1
1
  from .lc import *
2
2
  from .weather import *
3
3
  from .material import *
4
+ from .classes import (
5
+ VOXEL_CODES,
6
+ LAND_COVER_CLASSES,
7
+ print_voxel_codes,
8
+ print_land_cover_classes,
9
+ print_class_definitions,
10
+ get_land_cover_name,
11
+ get_voxel_code_name,
12
+ summarize_voxel_grid,
13
+ summarize_land_cover_grid,
14
+ )
@@ -0,0 +1,194 @@
1
+ """
2
+ VoxCity Class Definitions
3
+
4
+ This module provides standard class definitions for voxel grid semantics
5
+ and land cover classification used throughout VoxCity.
6
+
7
+ Voxel Grid Semantics:
8
+ The 3D voxel grid uses integer codes to represent different urban elements.
9
+ Negative values represent structural elements, while positive values
10
+ represent land cover classes at the ground surface layer.
11
+
12
+ Land Cover Classes:
13
+ Land cover is standardized to a 1-based indexing system (1-14) for
14
+ consistency across different data sources. This standard classification
15
+ is used in the voxel representation and exports.
16
+ """
17
+
18
+ import numpy as np
19
+ from typing import Dict, Optional
20
+
21
+
22
+ # =============================================================================
23
+ # Voxel Semantic Codes
24
+ # =============================================================================
25
+
26
+ VOXEL_CODES = {
27
+ -3: "Building",
28
+ -2: "Tree Canopy",
29
+ -1: "Ground/Subsurface",
30
+ # Positive values (>=1) represent land cover classes at ground surface
31
+ }
32
+
33
+ VOXEL_CODE_DESCRIPTIONS = """
34
+ Voxel Grid Semantic Codes:
35
+ -3 : Building volume
36
+ -2 : Tree canopy (vegetation)
37
+ -1 : Ground/Subsurface
38
+ >=1: Land cover class at ground surface (see Land Cover Classes)
39
+ """
40
+
41
+
42
+ # =============================================================================
43
+ # Standard Land Cover Classes (1-based indices)
44
+ # =============================================================================
45
+
46
+ LAND_COVER_CLASSES = {
47
+ 1: "Bareland",
48
+ 2: "Rangeland",
49
+ 3: "Shrub",
50
+ 4: "Agriculture land",
51
+ 5: "Tree",
52
+ 6: "Moss and lichen",
53
+ 7: "Wet land",
54
+ 8: "Mangrove",
55
+ 9: "Water",
56
+ 10: "Snow and ice",
57
+ 11: "Developed space",
58
+ 12: "Road",
59
+ 13: "Building",
60
+ 14: "No Data",
61
+ }
62
+
63
+ LAND_COVER_DESCRIPTIONS = """
64
+ VoxCity Standard Land Cover Classes (1-based indices, used in voxel grids):
65
+ --------------------------------------------------
66
+ 1: Bareland - Bare soil, rocks, desert
67
+ 2: Rangeland - Grassland, pasture
68
+ 3: Shrub - Shrubland, bushes
69
+ 4: Agriculture land - Cropland, farmland
70
+ 5: Tree - Forest, tree cover
71
+ 6: Moss and lichen - Moss, lichen cover
72
+ 7: Wet land - Wetland, marsh
73
+ 8: Mangrove - Mangrove forest
74
+ 9: Water - Water bodies
75
+ 10: Snow and ice - Snow, ice, glaciers
76
+ 11: Developed space - Urban areas, parking
77
+ 12: Road - Roads, paved surfaces
78
+ 13: Building - Building footprints
79
+ 14: No Data - Missing or invalid data
80
+ --------------------------------------------------
81
+ Note: Source-specific land cover classes are converted to these
82
+ standard classes during voxelization.
83
+ """
84
+
85
+
86
+ # =============================================================================
87
+ # Print Helper Functions
88
+ # =============================================================================
89
+
90
+ def print_voxel_codes() -> None:
91
+ """Print voxel semantic codes to console."""
92
+ print(VOXEL_CODE_DESCRIPTIONS)
93
+
94
+
95
+ def print_land_cover_classes() -> None:
96
+ """Print standard land cover class definitions to console."""
97
+ print(LAND_COVER_DESCRIPTIONS)
98
+
99
+
100
+ def print_class_definitions() -> None:
101
+ """Print both voxel codes and land cover class definitions."""
102
+ print("\n" + "=" * 60)
103
+ print("VoxCity Class Definitions")
104
+ print("=" * 60)
105
+ print(VOXEL_CODE_DESCRIPTIONS)
106
+ print(LAND_COVER_DESCRIPTIONS)
107
+ print("=" * 60 + "\n")
108
+
109
+
110
+ def get_land_cover_name(index: int) -> str:
111
+ """
112
+ Get the land cover class name for a given index.
113
+
114
+ Args:
115
+ index: Land cover class index (1-14)
116
+
117
+ Returns:
118
+ Class name string, or "Unknown" if index is invalid
119
+ """
120
+ return LAND_COVER_CLASSES.get(index, "Unknown")
121
+
122
+
123
+ def get_voxel_code_name(code: int) -> str:
124
+ """
125
+ Get the semantic name for a voxel code.
126
+
127
+ Args:
128
+ code: Voxel code (negative for structures, positive for land cover)
129
+
130
+ Returns:
131
+ Semantic name string
132
+ """
133
+ if code in VOXEL_CODES:
134
+ return VOXEL_CODES[code]
135
+ elif code >= 1:
136
+ return f"Land Cover: {get_land_cover_name(code)}"
137
+ elif code == 0:
138
+ return "Empty/Air"
139
+ else:
140
+ return "Unknown"
141
+
142
+
143
+ def summarize_voxel_grid(voxel_grid: np.ndarray, print_output: bool = True) -> Dict[int, int]:
144
+ """
145
+ Summarize the contents of a voxel grid.
146
+
147
+ Args:
148
+ voxel_grid: 3D numpy array of voxel codes
149
+ print_output: Whether to print the summary
150
+
151
+ Returns:
152
+ Dictionary mapping voxel codes to counts
153
+ """
154
+ unique, counts = np.unique(voxel_grid, return_counts=True)
155
+ summary = dict(zip(unique.tolist(), counts.tolist()))
156
+
157
+ if print_output:
158
+ print("\nVoxel Grid Summary:")
159
+ print("-" * 40)
160
+ for code in sorted(summary.keys()):
161
+ name = get_voxel_code_name(code)
162
+ count = summary[code]
163
+ percentage = 100.0 * count / voxel_grid.size
164
+ print(f" {code:4d}: {name:25s} - {count:,} voxels ({percentage:.2f}%)")
165
+ print("-" * 40)
166
+
167
+ return summary
168
+
169
+
170
+ def summarize_land_cover_grid(land_cover_grid: np.ndarray, print_output: bool = True) -> Dict[int, int]:
171
+ """
172
+ Summarize the contents of a land cover grid.
173
+
174
+ Args:
175
+ land_cover_grid: 2D numpy array of land cover class indices
176
+ print_output: Whether to print the summary
177
+
178
+ Returns:
179
+ Dictionary mapping class indices to counts
180
+ """
181
+ unique, counts = np.unique(land_cover_grid, return_counts=True)
182
+ summary = dict(zip(unique.tolist(), counts.tolist()))
183
+
184
+ if print_output:
185
+ print("\nLand Cover Grid Summary:")
186
+ print("-" * 40)
187
+ for idx in sorted(summary.keys()):
188
+ name = get_land_cover_name(idx) if idx >= 1 else f"Source-specific ({idx})"
189
+ count = summary[idx]
190
+ percentage = 100.0 * count / land_cover_grid.size
191
+ print(f" {idx:4d}: {name:25s} - {count:,} cells ({percentage:.2f}%)")
192
+ print("-" * 40)
193
+
194
+ return summary
voxcity/utils/lc.py CHANGED
@@ -34,21 +34,22 @@ def rgb_distance(color1, color2):
34
34
  return np.sqrt(np.sum((np.array(color1) - np.array(color2))**2))
35
35
 
36
36
 
37
- # Legacy land cover classes mapping - kept for reference
37
+ # Standard land cover classes mapping (1-based indices for voxel representation)
38
38
  # land_cover_classes = {
39
- # (128, 0, 0): 'Bareland', 0
40
- # (0, 255, 36): 'Rangeland', 1
41
- # (97, 140, 86): 'Shrub', 2
42
- # (75, 181, 73): 'Agriculture land', 3
43
- # (34, 97, 38): 'Tree', 4
44
- # (77, 118, 99): 'Wet land', 5
45
- # (22, 61, 51): 'Mangrove', 6
46
- # (0, 69, 255): 'Water', 7
47
- # (205, 215, 224): 'Snow and ice', 8
48
- # (148, 148, 148): 'Developed space', 9
49
- # (255, 255, 255): 'Road', 10
50
- # (222, 31, 7): 'Building', 11
51
- # (128, 0, 0): 'No Data', 12
39
+ # (128, 0, 0): 'Bareland', 1
40
+ # (0, 255, 36): 'Rangeland', 2
41
+ # (97, 140, 86): 'Shrub', 3
42
+ # (75, 181, 73): 'Agriculture land', 4
43
+ # (34, 97, 38): 'Tree', 5
44
+ # (255, 255, 0): 'Moss and lichen', 6
45
+ # (77, 118, 99): 'Wet land', 7
46
+ # (22, 61, 51): 'Mangrove', 8
47
+ # (0, 69, 255): 'Water', 9
48
+ # (205, 215, 224): 'Snow and ice', 10
49
+ # (148, 148, 148): 'Developed space', 11
50
+ # (255, 255, 255): 'Road', 12
51
+ # (222, 31, 7): 'Building', 13
52
+ # (128, 0, 0): 'No Data', 14
52
53
  # }
53
54
 
54
55
  def get_land_cover_classes(source):
@@ -161,22 +162,58 @@ def get_land_cover_classes(source):
161
162
  }
162
163
  return land_cover_classes
163
164
 
164
- # Legacy land cover classes with numeric indices - kept for reference
165
+
166
+ def get_source_class_descriptions(source):
167
+ """
168
+ Get a formatted string describing land cover classes for a specific source.
169
+
170
+ Args:
171
+ source (str): Name of the land cover data source.
172
+
173
+ Returns:
174
+ str: Formatted string describing the source's land cover classes.
175
+ """
176
+ land_cover_classes = get_land_cover_classes(source)
177
+ # Get unique class names (values from the dict)
178
+ class_names = list(dict.fromkeys(land_cover_classes.values()))
179
+
180
+ lines = [f"\n{source} Land Cover Classes (source-specific, 0-based indices):"]
181
+ lines.append("-" * 55)
182
+ for idx, name in enumerate(class_names):
183
+ lines.append(f" {idx:2d}: {name}")
184
+ lines.append("-" * 55)
185
+
186
+ # Special note for OpenStreetMap/Standard which uses same class names
187
+ if source in ("OpenStreetMap", "Standard"):
188
+ lines.append("Note: OpenStreetMap uses VoxCity Standard class names.")
189
+ lines.append(" Indices shift from 0-based to 1-based during voxelization.")
190
+ else:
191
+ lines.append("Note: These source-specific classes will be converted to")
192
+ lines.append(" VoxCity Standard Classes (1-14) during voxelization.")
193
+
194
+ lines.append("")
195
+ lines.append("Access 2D land cover grid from VoxCity object:")
196
+ lines.append(" land_cover_grid = voxcity.land_cover.classes")
197
+ lines.append("")
198
+ return "\n".join(lines)
199
+
200
+
201
+ # Standard land cover classes with numeric indices (1-based for voxel representation)
165
202
  # land_cover_classes = {
166
- # (128, 0, 0): 'Bareland', 0
167
- # (0, 255, 36): 'Rangeland', 1
168
- # (97, 140, 86): 'Shrub', 2
169
- # (75, 181, 73): 'Agriculture land', 3
170
- # (34, 97, 38): 'Tree', 4
171
- # (34, 97, 38): 'Moss and lichen', 5
172
- # (77, 118, 99): 'Wet land', 6
173
- # (22, 61, 51): 'Mangrove', 7
174
- # (0, 69, 255): 'Water', 8
175
- # (205, 215, 224): 'Snow and ice', 9
176
- # (148, 148, 148): 'Developed space', 10
177
- # (255, 255, 255): 'Road', 11
178
- # (222, 31, 7): 'Building', 12
179
- # (128, 0, 0): 'No Data', 13
203
+ # (128, 0, 0): 'Bareland', 1
204
+ # (0, 255, 36): 'Rangeland', 2
205
+ # (97, 140, 86): 'Shrub', 3
206
+ # (75, 181, 73): 'Agriculture land', 4
207
+ # (34, 97, 38): 'Tree', 5
208
+ # (255, 255, 0): 'Moss and lichen', 6
209
+ # (77, 118, 99): 'Wet land', 7
210
+ # (22, 61, 51): 'Mangrove', 8
211
+ # (0, 69, 255): 'Water', 9
212
+ # (205, 215, 224): 'Snow and ice', 10
213
+ # (148, 148, 148): 'Developed space', 11
214
+ # (255, 255, 255): 'Road', 12
215
+ # (222, 31, 7): 'Building', 13
216
+ # (128, 0, 0): 'No Data', 14
180
217
  # }
181
218
 
182
219
 
@@ -185,21 +222,23 @@ def convert_land_cover(input_array, land_cover_source='Urbanwatch'):
185
222
  """
186
223
  Optimized version using direct numpy array indexing instead of np.vectorize.
187
224
  This is 10-100x faster than the original.
225
+
226
+ Returns 1-based class indices (1-14) for consistency with voxel representations.
188
227
  """
189
- # Define mappings
228
+ # Define mappings (1-based indices: 1=Bareland, 2=Rangeland, ..., 14=No Data)
190
229
  if land_cover_source == 'Urbanwatch':
191
- mapping = {0: 12, 1: 11, 2: 10, 3: 4, 4: 1, 5: 3, 6: 8, 7: 0, 8: 13, 9: 8}
230
+ mapping = {0: 13, 1: 12, 2: 11, 3: 5, 4: 2, 5: 4, 6: 9, 7: 1, 8: 14, 9: 9}
192
231
  elif land_cover_source == 'ESA WorldCover':
193
- mapping = {0: 4, 1: 2, 2: 1, 3: 3, 4: 10, 5: 0, 6: 9, 7: 8, 8: 6, 9: 7, 10: 5}
232
+ mapping = {0: 5, 1: 3, 2: 2, 3: 4, 4: 11, 5: 1, 6: 10, 7: 9, 8: 7, 9: 8, 10: 6}
194
233
  elif land_cover_source == "ESRI 10m Annual Land Cover":
195
- mapping = {0: 13, 1: 8, 2: 4, 3: 1, 4: 6, 5: 3, 6: 2, 7: 10, 8: 0, 9: 9, 10: 13}
234
+ mapping = {0: 14, 1: 9, 2: 5, 3: 2, 4: 7, 5: 4, 6: 3, 7: 11, 8: 1, 9: 10, 10: 14}
196
235
  elif land_cover_source == "Dynamic World V1":
197
- mapping = {0: 8, 1: 4, 2: 1, 3: 6, 4: 3, 5: 2, 6: 10, 7: 0, 8: 9}
236
+ mapping = {0: 9, 1: 5, 2: 2, 3: 7, 4: 4, 5: 3, 6: 11, 7: 1, 8: 10}
198
237
  elif land_cover_source == "OpenEarthMapJapan":
199
- mapping = {0: 0, 1: 1, 2: 10, 3: 11, 4: 4, 5: 8, 6: 3, 7: 12}
238
+ mapping = {0: 1, 1: 2, 2: 11, 3: 12, 4: 5, 5: 9, 6: 4, 7: 13}
200
239
  else:
201
- # If unknown source, return as-is
202
- return input_array.copy()
240
+ # If unknown source, return as-is with +1 offset for consistency
241
+ return input_array.copy() + 1
203
242
 
204
243
  # Create a full mapping array for all possible values (0-255 for uint8)
205
244
  max_val = max(max(mapping.keys()), input_array.max()) + 1
@@ -374,12 +413,14 @@ def convert_land_cover_array(input_array, land_cover_classes):
374
413
  land_cover_classes (dict): Dictionary mapping RGB tuples to class names
375
414
 
376
415
  Returns:
377
- numpy.ndarray: Array with integer indices corresponding to land cover classes
416
+ numpy.ndarray: Array with 0-based integer indices corresponding to land cover classes
378
417
 
379
418
  Note:
380
419
  Classes not found in the mapping are assigned index -1
420
+ Indices are 0-based as source-specific indices. Use convert_land_cover() to
421
+ remap to standard 1-based indices for voxel representation.
381
422
  """
382
- # Create a mapping of class names to integers
423
+ # Create a mapping of class names to integers (0-based, source-specific order)
383
424
  class_to_int = {name: i for i, name in enumerate(land_cover_classes.values())}
384
425
 
385
426
  # Create a vectorized function to map string values to integers
voxcity/utils/shape.py ADDED
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Tuple, Optional, Dict, Any, Callable
4
+
5
+ import numpy as np
6
+
7
+ from ..models import VoxCity, VoxelGrid, BuildingGrid, LandCoverGrid, DemGrid, CanopyGrid
8
+
9
+
10
+ def _compute_center_crop_indices(size: int, target: int) -> Tuple[int, int]:
11
+ if size <= target:
12
+ return 0, size
13
+ start = max(0, (size - target) // 2)
14
+ end = start + target
15
+ return start, end
16
+
17
+
18
+ def _pad_split(total_pad: int) -> Tuple[int, int]:
19
+ # Split padding for centering; put extra on the bottom/right side
20
+ a = total_pad // 2
21
+ b = total_pad - a
22
+ return a, b
23
+
24
+
25
+ def _pad_crop_2d(
26
+ arr: np.ndarray,
27
+ target_xy: Tuple[int, int],
28
+ pad_value: Any,
29
+ align_xy: str = "center",
30
+ allow_crop_xy: bool = True,
31
+ ) -> np.ndarray:
32
+ x, y = arr.shape[:2]
33
+ tx, ty = int(target_xy[0]), int(target_xy[1])
34
+
35
+ # Crop (center) if needed and allowed
36
+ if allow_crop_xy and (x > tx or y > ty):
37
+ if align_xy == "center":
38
+ xs, xe = _compute_center_crop_indices(x, tx) if x > tx else (0, x)
39
+ ys, ye = _compute_center_crop_indices(y, ty) if y > ty else (0, y)
40
+ else:
41
+ # top-left alignment: crop from bottom/right only
42
+ xs, xe = (0, tx) if x > tx else (0, x)
43
+ ys, ye = (0, ty) if y > ty else (0, y)
44
+ arr = arr[xs:xe, ys:ye]
45
+ x, y = arr.shape[:2]
46
+
47
+ # Pad to target
48
+ px = max(0, tx - x)
49
+ py = max(0, ty - y)
50
+
51
+ if px == 0 and py == 0:
52
+ return arr
53
+
54
+ if align_xy == "center":
55
+ px0, px1 = _pad_split(px)
56
+ py0, py1 = _pad_split(py)
57
+ else:
58
+ # top-left: pad only on bottom/right
59
+ px0, px1 = 0, px
60
+ py0, py1 = 0, py
61
+
62
+ if arr.ndim == 2:
63
+ pad_width = ((px0, px1), (py0, py1))
64
+ else:
65
+ # Preserve trailing dims (e.g., channels)
66
+ pad_width = ((px0, px1), (py0, py1)) + tuple((0, 0) for _ in range(arr.ndim - 2))
67
+
68
+ return np.pad(arr, pad_width, mode="constant", constant_values=pad_value)
69
+
70
+
71
+ def _pad_crop_3d_zbottom(
72
+ arr: np.ndarray,
73
+ target_shape: Tuple[int, int, int],
74
+ pad_value: Any,
75
+ align_xy: str = "center",
76
+ allow_crop_xy: bool = True,
77
+ allow_crop_z: bool = False,
78
+ ) -> np.ndarray:
79
+ nx, ny, nz = arr.shape
80
+ tx, ty, tz = int(target_shape[0]), int(target_shape[1]), int(target_shape[2])
81
+
82
+ # XY crop/pad
83
+ arr_xy = _pad_crop_2d(arr, (tx, ty), pad_value, align_xy=align_xy, allow_crop_xy=allow_crop_xy)
84
+ nx, ny, nz = arr_xy.shape
85
+
86
+ # Z handling: keep ground at z=0; pad only at the top by default
87
+ if nz > tz:
88
+ if allow_crop_z:
89
+ arr_xy = arr_xy[:, :, :tz]
90
+ else:
91
+ tz = nz # expand target to avoid cropping
92
+ elif nz < tz:
93
+ pad_top = tz - nz # add empty air above
94
+ arr_xy = np.pad(arr_xy, ((0, 0), (0, 0), (0, pad_top)), mode="constant", constant_values=pad_value)
95
+
96
+ return arr_xy
97
+
98
+
99
+ def normalize_voxcity_shape(
100
+ city: VoxCity,
101
+ target_shape: Tuple[int, int, int],
102
+ *,
103
+ align_xy: str = "center",
104
+ pad_values: Optional[Dict[str, Any]] = None,
105
+ allow_crop_xy: bool = True,
106
+ allow_crop_z: bool = False,
107
+ ) -> VoxCity:
108
+ """
109
+ Return a new VoxCity with arrays padded/cropped to target (x, y, z).
110
+
111
+ - XY alignment can be 'center' (default) or 'top-left'.
112
+ - Z padding is added at the TOP to preserve ground level at z=0.
113
+ - By default, Z is never cropped (allow_crop_z=False). If target z is smaller than current,
114
+ target z is expanded to current to avoid losing data.
115
+ """
116
+ if pad_values is None:
117
+ pad_values = {}
118
+
119
+ # Resolve pad values for each layer
120
+ pv_vox = pad_values.get("voxels", 0)
121
+ pv_lc = pad_values.get("land_cover", 0)
122
+ pv_dem = pad_values.get("dem", 0.0)
123
+ pv_bh = pad_values.get("building_heights", 0.0)
124
+ pv_bid = pad_values.get("building_ids", 0)
125
+ pv_canopy = pad_values.get("canopy", 0.0)
126
+ pv_bmin = pad_values.get("building_min_heights_factory", None) # callable creating empty cell, default []
127
+ if pv_bmin is None:
128
+ def _empty_list() -> list:
129
+ return []
130
+ pv_bmin = _empty_list
131
+ elif not callable(pv_bmin):
132
+ const_val = pv_bmin
133
+ pv_bmin = (lambda v=const_val: v)
134
+
135
+ # Source arrays
136
+ vox = city.voxels.classes
137
+ bh = city.buildings.heights
138
+ bmin = city.buildings.min_heights
139
+ bid = city.buildings.ids
140
+ lc = city.land_cover.classes
141
+ dem = city.dem.elevation
142
+ can_top = city.tree_canopy.top
143
+ can_bot = city.tree_canopy.bottom if city.tree_canopy.bottom is not None else None
144
+
145
+ # Normalize shapes
146
+ vox_n = _pad_crop_3d_zbottom(
147
+ vox.astype(vox.dtype, copy=False),
148
+ target_shape,
149
+ pad_value=np.array(pv_vox, dtype=vox.dtype),
150
+ align_xy=align_xy,
151
+ allow_crop_xy=allow_crop_xy,
152
+ allow_crop_z=allow_crop_z,
153
+ )
154
+ tx, ty, tz = vox_n.shape
155
+
156
+ def _pad2d(a: np.ndarray, pad_val: Any) -> np.ndarray:
157
+ return _pad_crop_2d(
158
+ a.astype(a.dtype, copy=False),
159
+ (tx, ty),
160
+ pad_value=np.array(pad_val, dtype=a.dtype) if not isinstance(pad_val, (list, tuple, dict)) else pad_val,
161
+ align_xy=align_xy,
162
+ allow_crop_xy=allow_crop_xy,
163
+ )
164
+
165
+ bh_n = _pad2d(bh, pv_bh)
166
+ bid_n = _pad2d(bid, pv_bid)
167
+ lc_n = _pad2d(lc, pv_lc)
168
+ dem_n = _pad2d(dem, pv_dem)
169
+ can_top_n = _pad2d(can_top, pv_canopy)
170
+ can_bot_n = _pad2d(can_bot, pv_canopy) if can_bot is not None else None # type: ignore
171
+
172
+ # Object-dtype 2D padding/cropping for min_heights
173
+ # Center-crop if needed, then pad with empty lists
174
+ bx, by = bmin.shape
175
+ if bx > tx or by > ty:
176
+ if align_xy == "center":
177
+ xs, xe = _compute_center_crop_indices(bx, tx) if bx > tx else (0, bx)
178
+ ys, ye = _compute_center_crop_indices(by, ty) if by > ty else (0, by)
179
+ else:
180
+ xs, xe = (0, tx) if bx > tx else (0, bx)
181
+ ys, ye = (0, ty) if by > ty else (0, by)
182
+ bmin_c = bmin[xs:xe, ys:ye]
183
+ else:
184
+ bmin_c = bmin
185
+ bx, by = bmin_c.shape
186
+ px = max(0, tx - bx)
187
+ py = max(0, ty - by)
188
+ if px or py:
189
+ out = np.empty((tx, ty), dtype=object)
190
+ # Fill with empty factory values
191
+ for i in range(tx):
192
+ for j in range(ty):
193
+ out[i, j] = pv_bmin()
194
+ if align_xy == "center":
195
+ px0, px1 = _pad_split(px)
196
+ py0, py1 = _pad_split(py)
197
+ else:
198
+ px0, py0 = 0, 0
199
+ px1, py1 = px, py
200
+ out[px0:px0 + bx, py0:py0 + by] = bmin_c
201
+ bmin_n = out
202
+ else:
203
+ bmin_n = bmin_c
204
+
205
+ # Rebuild VoxCity with normalized arrays and same metadata (meshsize/bounds)
206
+ meta = city.voxels.meta
207
+ voxels_new = VoxelGrid(classes=vox_n, meta=meta)
208
+ buildings_new = BuildingGrid(heights=bh_n, min_heights=bmin_n, ids=bid_n, meta=meta)
209
+ land_new = LandCoverGrid(classes=lc_n, meta=meta)
210
+ dem_new = DemGrid(elevation=dem_n, meta=meta)
211
+ canopy_new = CanopyGrid(top=can_top_n, bottom=can_bot_n, meta=meta)
212
+
213
+ city_new = VoxCity(
214
+ voxels=voxels_new,
215
+ buildings=buildings_new,
216
+ land_cover=land_new,
217
+ dem=dem_new,
218
+ tree_canopy=canopy_new,
219
+ extras=dict(city.extras) if city.extras is not None else {},
220
+ )
221
+ # Keep extras canopy mirrors in sync if present
222
+ try:
223
+ city_new.extras["canopy_top"] = can_top_n
224
+ if can_bot_n is not None:
225
+ city_new.extras["canopy_bottom"] = can_bot_n
226
+ except Exception:
227
+ pass
228
+ return city_new
229
+
230
+
@@ -1,24 +1,24 @@
1
- from .builder import MeshBuilder
2
- from .renderer import PyVistaRenderer, create_multi_view_scene, visualize_voxcity_plotly, visualize_voxcity
3
- from .palette import get_voxel_color_map
4
- from .grids import visualize_landcover_grid_on_basemap, visualize_numerical_grid_on_basemap, visualize_numerical_gdf_on_basemap, visualize_point_gdf_on_basemap
5
- from .maps import plot_grid, visualize_land_cover_grid_on_map, visualize_building_height_grid_on_map, visualize_numerical_grid_on_map
6
-
7
- __all__ = [
8
- "MeshBuilder",
9
- "PyVistaRenderer",
10
- "create_multi_view_scene",
11
- "visualize_voxcity_plotly",
12
- "visualize_voxcity",
13
- "get_voxel_color_map",
14
- "visualize_landcover_grid_on_basemap",
15
- "visualize_numerical_grid_on_basemap",
16
- "visualize_numerical_gdf_on_basemap",
17
- "visualize_point_gdf_on_basemap",
18
- "plot_grid",
19
- "visualize_land_cover_grid_on_map",
20
- "visualize_building_height_grid_on_map",
21
- "visualize_numerical_grid_on_map",
22
- ]
23
-
24
-
1
+ from .builder import MeshBuilder
2
+ from .renderer import PyVistaRenderer, create_multi_view_scene, visualize_voxcity_plotly, visualize_voxcity
3
+ from .palette import get_voxel_color_map
4
+ from .grids import visualize_landcover_grid_on_basemap, visualize_numerical_grid_on_basemap, visualize_numerical_gdf_on_basemap, visualize_point_gdf_on_basemap
5
+ from .maps import plot_grid, visualize_land_cover_grid_on_map, visualize_building_height_grid_on_map, visualize_numerical_grid_on_map
6
+
7
+ __all__ = [
8
+ "MeshBuilder",
9
+ "PyVistaRenderer",
10
+ "create_multi_view_scene",
11
+ "visualize_voxcity_plotly",
12
+ "visualize_voxcity",
13
+ "get_voxel_color_map",
14
+ "visualize_landcover_grid_on_basemap",
15
+ "visualize_numerical_grid_on_basemap",
16
+ "visualize_numerical_gdf_on_basemap",
17
+ "visualize_point_gdf_on_basemap",
18
+ "plot_grid",
19
+ "visualize_land_cover_grid_on_map",
20
+ "visualize_building_height_grid_on_map",
21
+ "visualize_numerical_grid_on_map",
22
+ ]
23
+
24
+