voxcity 0.6.15__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/citygml.py +32 -18
  4. voxcity/downloader/gba.py +210 -0
  5. voxcity/downloader/gee.py +5 -1
  6. voxcity/downloader/mbfp.py +1 -1
  7. voxcity/downloader/oemj.py +80 -8
  8. voxcity/downloader/osm.py +23 -7
  9. voxcity/downloader/overture.py +26 -1
  10. voxcity/downloader/utils.py +73 -73
  11. voxcity/errors.py +30 -0
  12. voxcity/exporter/__init__.py +13 -4
  13. voxcity/exporter/cityles.py +633 -535
  14. voxcity/exporter/envimet.py +728 -708
  15. voxcity/exporter/magicavoxel.py +334 -297
  16. voxcity/exporter/netcdf.py +238 -0
  17. voxcity/exporter/obj.py +1481 -655
  18. voxcity/generator/__init__.py +44 -0
  19. voxcity/generator/api.py +675 -0
  20. voxcity/generator/grids.py +379 -0
  21. voxcity/generator/io.py +94 -0
  22. voxcity/generator/pipeline.py +282 -0
  23. voxcity/generator/voxelizer.py +380 -0
  24. voxcity/geoprocessor/__init__.py +75 -6
  25. voxcity/geoprocessor/conversion.py +153 -0
  26. voxcity/geoprocessor/draw.py +62 -12
  27. voxcity/geoprocessor/heights.py +199 -0
  28. voxcity/geoprocessor/io.py +101 -0
  29. voxcity/geoprocessor/merge_utils.py +91 -0
  30. voxcity/geoprocessor/mesh.py +806 -790
  31. voxcity/geoprocessor/network.py +708 -679
  32. voxcity/geoprocessor/overlap.py +84 -0
  33. voxcity/geoprocessor/raster/__init__.py +82 -0
  34. voxcity/geoprocessor/raster/buildings.py +428 -0
  35. voxcity/geoprocessor/raster/canopy.py +258 -0
  36. voxcity/geoprocessor/raster/core.py +150 -0
  37. voxcity/geoprocessor/raster/export.py +93 -0
  38. voxcity/geoprocessor/raster/landcover.py +156 -0
  39. voxcity/geoprocessor/raster/raster.py +110 -0
  40. voxcity/geoprocessor/selection.py +85 -0
  41. voxcity/geoprocessor/utils.py +18 -14
  42. voxcity/models.py +113 -0
  43. voxcity/simulator/common/__init__.py +22 -0
  44. voxcity/simulator/common/geometry.py +98 -0
  45. voxcity/simulator/common/raytracing.py +450 -0
  46. voxcity/simulator/solar/__init__.py +43 -0
  47. voxcity/simulator/solar/integration.py +336 -0
  48. voxcity/simulator/solar/kernels.py +62 -0
  49. voxcity/simulator/solar/radiation.py +648 -0
  50. voxcity/simulator/solar/temporal.py +434 -0
  51. voxcity/simulator/view.py +36 -2286
  52. voxcity/simulator/visibility/__init__.py +29 -0
  53. voxcity/simulator/visibility/landmark.py +392 -0
  54. voxcity/simulator/visibility/view.py +508 -0
  55. voxcity/utils/logging.py +61 -0
  56. voxcity/utils/orientation.py +51 -0
  57. voxcity/utils/weather/__init__.py +26 -0
  58. voxcity/utils/weather/epw.py +146 -0
  59. voxcity/utils/weather/files.py +36 -0
  60. voxcity/utils/weather/onebuilding.py +486 -0
  61. voxcity/visualizer/__init__.py +24 -0
  62. voxcity/visualizer/builder.py +43 -0
  63. voxcity/visualizer/grids.py +141 -0
  64. voxcity/visualizer/maps.py +187 -0
  65. voxcity/visualizer/palette.py +228 -0
  66. voxcity/visualizer/renderer.py +928 -0
  67. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
  68. voxcity-0.7.0.dist-info/RECORD +77 -0
  69. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
  70. voxcity/generator.py +0 -1137
  71. voxcity/geoprocessor/grid.py +0 -1568
  72. voxcity/geoprocessor/polygon.py +0 -1344
  73. voxcity/simulator/solar.py +0 -2329
  74. voxcity/utils/visualization.py +0 -2660
  75. voxcity/utils/weather.py +0 -817
  76. voxcity-0.6.15.dist-info/RECORD +0 -37
  77. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
  78. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/LICENSE +0 -0
voxcity/simulator/view.py CHANGED
@@ -1,2286 +1,36 @@
1
- """Functions for computing and visualizing various view indices in a voxel city model.
2
-
3
- This module provides functionality to compute and visualize:
4
- - Green View Index (GVI): Measures visibility of green elements like trees and vegetation
5
- - Sky View Index (SVI): Measures visibility of open sky from street level
6
- - Sky View Factor (SVF): Measures the ratio of visible sky hemisphere to total hemisphere
7
- - Landmark Visibility: Measures visibility of specified landmark buildings from different locations
8
-
9
- The module uses optimized ray tracing techniques with Numba JIT compilation for efficient computation.
10
- Key features:
11
- - Generic ray tracing framework that can be customized for different view indices
12
- - Parallel processing for fast computation of view maps
13
- - Tree transmittance modeling using Beer-Lambert law
14
- - Visualization tools including matplotlib plots and OBJ exports
15
- - Support for both inclusion and exclusion based visibility checks
16
-
17
- The module provides several key functions:
18
- - trace_ray_generic(): Core ray tracing function that handles tree transmittance
19
- - compute_vi_generic(): Computes view indices by casting rays in specified directions
20
- - compute_vi_map_generic(): Generates 2D maps of view indices
21
- - get_view_index(): High-level function to compute various view indices
22
- - compute_landmark_visibility(): Computes visibility of landmark buildings
23
- - get_sky_view_factor_map(): Computes sky view factor maps
24
-
25
- The module uses a voxel-based representation where:
26
- - Empty space is represented by 0
27
- - Trees are represented by -2
28
- - Buildings are represented by -3
29
- - Other values can be used for different features
30
-
31
- Tree transmittance is modeled using the Beer-Lambert law with configurable parameters:
32
- - tree_k: Static extinction coefficient (default 0.6)
33
- - tree_lad: Leaf area density in m^-1 (default 1.0)
34
-
35
- Additional implementation details:
36
- - Uses DDA (Digital Differential Analyzer) algorithm for efficient ray traversal
37
- - Handles edge cases like zero-length rays and division by zero
38
- - Supports early exit optimizations for performance
39
- - Provides flexible observer placement rules
40
- - Includes comprehensive error checking and validation
41
- - Allows customization of visualization parameters
42
- """
43
-
44
- import numpy as np
45
- import matplotlib.pyplot as plt
46
- import matplotlib.patches as mpatches
47
- from numba import njit, prange
48
- import time
49
- import trimesh
50
- import math
51
-
52
- from ..geoprocessor.polygon import find_building_containing_point, get_buildings_in_drawn_polygon
53
- from ..geoprocessor.mesh import create_voxel_mesh
54
- from ..exporter.obj import grid_to_obj, export_obj
55
-
56
-
57
- def _generate_ray_directions_grid(N_azimuth: int,
58
- N_elevation: int,
59
- elevation_min_degrees: float,
60
- elevation_max_degrees: float) -> np.ndarray:
61
- """Generate ray directions using azimuth/elevation grid sampling.
62
-
63
- Elevation is measured from the horizontal plane: 0 deg at horizon, +90 at zenith.
64
- """
65
- azimuth_angles = np.linspace(0.0, 2.0 * np.pi, int(N_azimuth), endpoint=False)
66
- elevation_angles = np.deg2rad(
67
- np.linspace(float(elevation_min_degrees), float(elevation_max_degrees), int(N_elevation))
68
- )
69
-
70
- ray_directions = np.empty((len(azimuth_angles) * len(elevation_angles), 3), dtype=np.float64)
71
- out_idx = 0
72
- for elevation in elevation_angles:
73
- cos_elev = np.cos(elevation)
74
- sin_elev = np.sin(elevation)
75
- for azimuth in azimuth_angles:
76
- dx = cos_elev * np.cos(azimuth)
77
- dy = cos_elev * np.sin(azimuth)
78
- dz = sin_elev
79
- ray_directions[out_idx, 0] = dx
80
- ray_directions[out_idx, 1] = dy
81
- ray_directions[out_idx, 2] = dz
82
- out_idx += 1
83
- return ray_directions
84
-
85
-
86
- def _generate_ray_directions_fibonacci(N_rays: int,
87
- elevation_min_degrees: float,
88
- elevation_max_degrees: float) -> np.ndarray:
89
- """Generate ray directions with near-uniform solid-angle spacing using a Fibonacci lattice.
90
-
91
- Elevation is measured from the horizontal plane. Uniform solid-angle sampling over an
92
- elevation band [emin, emax] is achieved by sampling z = sin(elev) uniformly over
93
- [sin(emin), sin(emax)] and using a golden-angle azimuth sequence.
94
- """
95
- N = int(max(1, N_rays))
96
- emin = np.deg2rad(float(elevation_min_degrees))
97
- emax = np.deg2rad(float(elevation_max_degrees))
98
- # Map to z-range where z = sin(elevation)
99
- z_min = np.sin(min(emin, emax))
100
- z_max = np.sin(max(emin, emax))
101
- # Golden angle in radians
102
- golden_angle = np.pi * (3.0 - np.sqrt(5.0))
103
-
104
- i = np.arange(N, dtype=np.float64)
105
- # Uniform in z over the band (equal solid angle within the band)
106
- z = z_min + (i + 0.5) * (z_max - z_min) / N
107
- # Wrap azimuth via golden-angle progression
108
- phi = i * golden_angle
109
- r = np.sqrt(np.clip(1.0 - z * z, 0.0, 1.0))
110
- x = r * np.cos(phi)
111
- y = r * np.sin(phi)
112
- return np.stack((x, y, z), axis=1).astype(np.float64)
113
-
114
- @njit
115
- def calculate_transmittance(length, tree_k=0.6, tree_lad=1.0):
116
- """Calculate tree transmittance using the Beer-Lambert law.
117
-
118
- Uses the Beer-Lambert law to model light attenuation through tree canopy:
119
- transmittance = exp(-k * LAD * L)
120
- where:
121
- - k is the extinction coefficient
122
- - LAD is the leaf area density
123
- - L is the path length through the canopy
124
-
125
- Args:
126
- length (float): Path length through tree voxel in meters
127
- tree_k (float): Static extinction coefficient (default: 0.6)
128
- Controls overall light attenuation strength
129
- tree_lad (float): Leaf area density in m^-1 (default: 1.0)
130
- Higher values = denser foliage = more attenuation
131
-
132
- Returns:
133
- float: Transmittance value between 0 and 1
134
- 1.0 = fully transparent
135
- 0.0 = fully opaque
136
- """
137
- return np.exp(-tree_k * tree_lad * length)
138
-
139
- @njit
140
- def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_k, tree_lad, inclusion_mode=True):
141
- """Trace a ray through a voxel grid and check for hits with specified values.
142
-
143
- Uses DDA algorithm to efficiently traverse voxels along ray path.
144
- Handles tree transmittance using Beer-Lambert law.
145
-
146
- The DDA algorithm:
147
- 1. Initializes ray at origin voxel
148
- 2. Calculates distances to next voxel boundaries in each direction
149
- 3. Steps to next voxel by choosing smallest distance
150
- 4. Repeats until hit or out of bounds
151
-
152
- Tree transmittance:
153
- - When ray passes through tree voxels (-2), transmittance is accumulated
154
- - Uses Beer-Lambert law with configurable extinction coefficient and leaf area density
155
- - Ray is considered blocked if cumulative transmittance falls below 0.01
156
-
157
- Args:
158
- voxel_data (ndarray): 3D array of voxel values
159
- origin (ndarray): Starting point (x,y,z) of ray in voxel coordinates
160
- direction (ndarray): Direction vector of ray (will be normalized)
161
- hit_values (tuple): Values to check for hits
162
- meshsize (float): Size of each voxel in meters
163
- tree_k (float): Tree extinction coefficient
164
- tree_lad (float): Leaf area density in m^-1
165
- inclusion_mode (bool): If True, hit_values are hits. If False, hit_values are allowed values.
166
-
167
- Returns:
168
- tuple: (hit_detected, transmittance_value)
169
- hit_detected (bool): Whether ray hit a target voxel
170
- transmittance_value (float): Cumulative transmittance through trees
171
- """
172
- nx, ny, nz = voxel_data.shape
173
- x0, y0, z0 = origin
174
- dx, dy, dz = direction
175
-
176
- # Normalize direction vector to ensure consistent step sizes
177
- length = np.sqrt(dx*dx + dy*dy + dz*dz)
178
- if length == 0.0:
179
- return False, 1.0
180
- dx /= length
181
- dy /= length
182
- dz /= length
183
-
184
- # Initialize ray position at center of starting voxel
185
- x, y, z = x0 + 0.5, y0 + 0.5, z0 + 0.5
186
- i, j, k = int(x0), int(y0), int(z0)
187
-
188
- # Determine step direction for each axis (-1 or +1)
189
- step_x = 1 if dx >= 0 else -1
190
- step_y = 1 if dy >= 0 else -1
191
- step_z = 1 if dz >= 0 else -1
192
-
193
- # Calculate DDA parameters with safety checks to prevent division by zero
194
- EPSILON = 1e-10 # Small value to prevent division by zero
195
-
196
- # Calculate distances to next voxel boundaries and step sizes for X-axis
197
- if abs(dx) > EPSILON:
198
- t_max_x = ((i + (step_x > 0)) - x) / dx
199
- t_delta_x = abs(1 / dx)
200
- else:
201
- t_max_x = np.inf
202
- t_delta_x = np.inf
203
-
204
- # Calculate distances to next voxel boundaries and step sizes for Y-axis
205
- if abs(dy) > EPSILON:
206
- t_max_y = ((j + (step_y > 0)) - y) / dy
207
- t_delta_y = abs(1 / dy)
208
- else:
209
- t_max_y = np.inf
210
- t_delta_y = np.inf
211
-
212
- # Calculate distances to next voxel boundaries and step sizes for Z-axis
213
- if abs(dz) > EPSILON:
214
- t_max_z = ((k + (step_z > 0)) - z) / dz
215
- t_delta_z = abs(1 / dz)
216
- else:
217
- t_max_z = np.inf
218
- t_delta_z = np.inf
219
-
220
- # Track cumulative values for tree transmittance calculation
221
- cumulative_transmittance = 1.0
222
- cumulative_hit_contribution = 0.0
223
- last_t = 0.0
224
-
225
- # Main ray traversal loop using DDA algorithm
226
- while (0 <= i < nx) and (0 <= j < ny) and (0 <= k < nz):
227
- voxel_value = voxel_data[i, j, k]
228
-
229
- # Find next intersection point along the ray
230
- t_next = min(t_max_x, t_max_y, t_max_z)
231
-
232
- # Calculate segment length in current voxel (in real world units)
233
- segment_length = (t_next - last_t) * meshsize
234
- segment_length = max(0.0, segment_length)
235
-
236
- # Handle tree voxels (value -2) with Beer-Lambert law transmittance
237
- if voxel_value == -2:
238
- transmittance = calculate_transmittance(segment_length, tree_k, tree_lad)
239
- cumulative_transmittance *= transmittance
240
-
241
- # If transmittance becomes too low, consider the ray blocked.
242
- # In exclusion mode (e.g., sky view), a blocked ray counts as a hit (obstruction).
243
- # In inclusion mode (e.g., building view), trees should NOT count as a target hit;
244
- # we terminate traversal early but report no hit so callers can treat it as 0 visibility.
245
- if cumulative_transmittance < 0.01:
246
- if inclusion_mode:
247
- return False, cumulative_transmittance
248
- else:
249
- return True, cumulative_transmittance
250
-
251
- # Check for hits with target objects based on inclusion/exclusion mode
252
- if inclusion_mode:
253
- # Inclusion mode: hit if voxel value is in the target set
254
- for hv in hit_values:
255
- if voxel_value == hv:
256
- return True, cumulative_transmittance
257
- # Opaque blockers (anything non-air, non-tree, and not a target) stop visibility
258
- if voxel_value != 0 and voxel_value != -2:
259
- return False, cumulative_transmittance
260
- else:
261
- # Exclusion mode: hit if voxel value is NOT in the allowed set
262
- in_set = False
263
- for hv in hit_values:
264
- if voxel_value == hv:
265
- in_set = True
266
- break
267
- if not in_set and voxel_value != -2: # Exclude trees from regular hits
268
- return True, cumulative_transmittance
269
-
270
- # Update for next iteration
271
- last_t = t_next
272
-
273
- # Tie-aware DDA stepping to reduce corner leaks
274
- TIE_EPS = 1e-12
275
- eq_x = abs(t_max_x - t_next) <= TIE_EPS
276
- eq_y = abs(t_max_y - t_next) <= TIE_EPS
277
- eq_z = abs(t_max_z - t_next) <= TIE_EPS
278
-
279
- # Conservative occlusion at exact grid corner crossings in inclusion mode
280
- if inclusion_mode and ((eq_x and eq_y) or (eq_x and eq_z) or (eq_y and eq_z)):
281
- # Probe neighbor cells we are about to enter on tied axes; if any is opaque non-target, block
282
- # Note: bounds checks guard against out-of-grid probes
283
- if eq_x:
284
- ii = i + step_x
285
- if 0 <= ii < nx:
286
- val = voxel_data[ii, j, k]
287
- is_target = False
288
- for hv in hit_values:
289
- if val == hv:
290
- is_target = True
291
- break
292
- if (val != 0) and (val != -2) and (not is_target):
293
- return False, cumulative_transmittance
294
- if eq_y:
295
- jj = j + step_y
296
- if 0 <= jj < ny:
297
- val = voxel_data[i, jj, k]
298
- is_target = False
299
- for hv in hit_values:
300
- if val == hv:
301
- is_target = True
302
- break
303
- if (val != 0) and (val != -2) and (not is_target):
304
- return False, cumulative_transmittance
305
- if eq_z:
306
- kk = k + step_z
307
- if 0 <= kk < nz:
308
- val = voxel_data[i, j, kk]
309
- is_target = False
310
- for hv in hit_values:
311
- if val == hv:
312
- is_target = True
313
- break
314
- if (val != 0) and (val != -2) and (not is_target):
315
- return False, cumulative_transmittance
316
-
317
- # Step along all axes that hit at t_next (handles ties robustly)
318
- stepped = False
319
- if eq_x:
320
- t_max_x += t_delta_x
321
- i += step_x
322
- stepped = True
323
- if eq_y:
324
- t_max_y += t_delta_y
325
- j += step_y
326
- stepped = True
327
- if eq_z:
328
- t_max_z += t_delta_z
329
- k += step_z
330
- stepped = True
331
-
332
- if not stepped:
333
- # Fallback: should not happen, but keep classic ordering
334
- if t_max_x < t_max_y:
335
- if t_max_x < t_max_z:
336
- t_max_x += t_delta_x; i += step_x
337
- else:
338
- t_max_z += t_delta_z; k += step_z
339
- else:
340
- if t_max_y < t_max_z:
341
- t_max_y += t_delta_y; j += step_y
342
- else:
343
- t_max_z += t_delta_z; k += step_z
344
-
345
- # Ray exited the grid without hitting a target
346
- return False, cumulative_transmittance
347
-
348
- @njit
349
- def compute_vi_generic(observer_location, voxel_data, ray_directions, hit_values, meshsize, tree_k, tree_lad, inclusion_mode=True):
350
- """Compute view index accounting for tree transmittance.
351
-
352
- Casts rays in specified directions and computes visibility index based on hits and transmittance.
353
- The view index is the ratio of visible rays to total rays cast, where:
354
- - For inclusion mode: Counts hits with target values
355
- - For exclusion mode: Counts rays that don't hit obstacles
356
- Tree transmittance is handled specially:
357
- - In inclusion mode with trees as targets: Uses (1 - transmittance) as contribution
358
- - In exclusion mode: Uses transmittance value directly
359
-
360
- Args:
361
- observer_location (ndarray): Observer position (x,y,z) in voxel coordinates
362
- voxel_data (ndarray): 3D array of voxel values
363
- ray_directions (ndarray): Array of direction vectors for rays
364
- hit_values (tuple): Values to check for hits
365
- meshsize (float): Size of each voxel in meters
366
- tree_k (float): Tree extinction coefficient
367
- tree_lad (float): Leaf area density in m^-1
368
- inclusion_mode (bool): If True, hit_values are hits. If False, hit_values are allowed values.
369
-
370
- Returns:
371
- float: View index value between 0 and 1
372
- 0.0 = no visibility in any direction
373
- 1.0 = full visibility in all directions
374
- """
375
- total_rays = ray_directions.shape[0]
376
- visibility_sum = 0.0
377
-
378
- # Cast rays in all specified directions
379
- for idx in range(total_rays):
380
- direction = ray_directions[idx]
381
- hit, value = trace_ray_generic(voxel_data, observer_location, direction,
382
- hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
383
-
384
- # Accumulate visibility contributions based on mode
385
- if inclusion_mode:
386
- if hit:
387
- # For trees in hit_values, use partial visibility based on transmittance (Beer-Lambert)
388
- if -2 in hit_values:
389
- # value is cumulative transmittance (0..1).
390
- # Contribution should be 1 - transmittance.
391
- contrib = 1.0 - max(0.0, min(1.0, value))
392
- visibility_sum += contrib
393
- else:
394
- # Full visibility for non-tree targets
395
- visibility_sum += 1.0
396
- else:
397
- if not hit:
398
- # For exclusion mode, use transmittance value directly as visibility
399
- visibility_sum += value
400
-
401
- # Return average visibility across all rays
402
- return visibility_sum / total_rays
403
-
404
- @njit(parallel=True)
405
- def compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel, hit_values,
406
- meshsize, tree_k, tree_lad, inclusion_mode=True):
407
- """Compute view index map incorporating tree transmittance.
408
-
409
- Places observers at valid locations and computes view index for each position.
410
- Valid observer locations are:
411
- - Empty voxels (0) or tree voxels (-2)
412
- - Above non-empty, non-tree voxels
413
- - Not above water (7,8,9) or negative values
414
-
415
- The function processes each x,y position in parallel for efficiency.
416
-
417
- Args:
418
- voxel_data (ndarray): 3D array of voxel values
419
- ray_directions (ndarray): Array of direction vectors for rays
420
- view_height_voxel (int): Observer height in voxel units
421
- hit_values (tuple): Values to check for hits
422
- meshsize (float): Size of each voxel in meters
423
- tree_k (float): Tree extinction coefficient
424
- tree_lad (float): Leaf area density in m^-1
425
- inclusion_mode (bool): If True, hit_values are hits. If False, hit_values are allowed values.
426
-
427
- Returns:
428
- ndarray: 2D array of view index values
429
- NaN = invalid observer location
430
- 0.0-1.0 = view index value
431
- """
432
- nx, ny, nz = voxel_data.shape
433
- vi_map = np.full((nx, ny), np.nan)
434
-
435
- # Process each horizontal position in parallel for efficiency
436
- for x in prange(nx):
437
- for y in range(ny):
438
- found_observer = False
439
- # Search from bottom to top for valid observer placement
440
- for z in range(1, nz):
441
- # Check for valid observer location: empty space above solid ground
442
- if voxel_data[x, y, z] in (0, -2) and voxel_data[x, y, z - 1] not in (0, -2):
443
- # Skip invalid ground types (water or negative values)
444
- if (voxel_data[x, y, z - 1] in (7, 8, 9)) or (voxel_data[x, y, z - 1] < 0):
445
- vi_map[x, y] = np.nan
446
- found_observer = True
447
- break
448
- else:
449
- # Place observer at specified height above ground level
450
- observer_location = np.array([x, y, z + view_height_voxel], dtype=np.float64)
451
- # Compute view index for this location
452
- vi_value = compute_vi_generic(observer_location, voxel_data, ray_directions,
453
- hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
454
- vi_map[x, y] = vi_value
455
- found_observer = True
456
- break
457
- # Mark locations where no valid observer position was found
458
- if not found_observer:
459
- vi_map[x, y] = np.nan
460
-
461
- # Flip vertically to match display orientation
462
- return np.flipud(vi_map)
463
-
464
- # ==========================
465
- # Fast-path helpers (mask-based)
466
- # ==========================
467
-
468
- def _prepare_masks_for_vi(voxel_data: np.ndarray, hit_values, inclusion_mode: bool):
469
- """Precompute boolean masks to avoid expensive value checks inside Numba loops.
470
-
471
- Returns a tuple (is_tree, is_target, is_allowed, is_blocker_inc), where some entries
472
- may be None depending on mode.
473
- """
474
- is_tree = (voxel_data == -2)
475
- if inclusion_mode:
476
- is_target = np.isin(voxel_data, hit_values)
477
- is_blocker_inc = (voxel_data != 0) & (~is_tree) & (~is_target)
478
- return is_tree, is_target, None, is_blocker_inc
479
- else:
480
- is_allowed = np.isin(voxel_data, hit_values)
481
- return is_tree, None, is_allowed, None
482
-
483
-
484
- @njit(cache=True, fastmath=True)
485
- def _trace_ray_inclusion_masks(is_tree, is_target, is_blocker_inc,
486
- origin, direction,
487
- meshsize, tree_k, tree_lad):
488
- """DDA traversal using precomputed masks for inclusion mode.
489
-
490
- Returns (hit, cumulative_transmittance).
491
- Tree transmittance uses Beer-Lambert with LAD and segment length in meters.
492
- """
493
- nx, ny, nz = is_tree.shape
494
-
495
- x0, y0, z0 = origin
496
- dx, dy, dz = direction
497
-
498
- # Normalize
499
- length = (dx*dx + dy*dy + dz*dz) ** 0.5
500
- if length == 0.0:
501
- return False, 1.0
502
- dx /= length; dy /= length; dz /= length
503
-
504
- x, y, z = x0 + 0.5, y0 + 0.5, z0 + 0.5
505
- i, j, k = int(x0), int(y0), int(z0)
506
-
507
- step_x = 1 if dx >= 0 else -1
508
- step_y = 1 if dy >= 0 else -1
509
- step_z = 1 if dz >= 0 else -1
510
-
511
- EPS = 1e-10
512
- if abs(dx) > EPS:
513
- t_max_x = ((i + (step_x > 0)) - x) / dx
514
- t_delta_x = abs(1.0 / dx)
515
- else:
516
- t_max_x = np.inf; t_delta_x = np.inf
517
- if abs(dy) > EPS:
518
- t_max_y = ((j + (step_y > 0)) - y) / dy
519
- t_delta_y = abs(1.0 / dy)
520
- else:
521
- t_max_y = np.inf; t_delta_y = np.inf
522
- if abs(dz) > EPS:
523
- t_max_z = ((k + (step_z > 0)) - z) / dz
524
- t_delta_z = abs(1.0 / dz)
525
- else:
526
- t_max_z = np.inf; t_delta_z = np.inf
527
-
528
- cumulative_transmittance = 1.0
529
- last_t = 0.0
530
-
531
- while (0 <= i < nx) and (0 <= j < ny) and (0 <= k < nz):
532
- t_next = t_max_x
533
- axis = 0
534
- if t_max_y < t_next:
535
- t_next = t_max_y; axis = 1
536
- if t_max_z < t_next:
537
- t_next = t_max_z; axis = 2
538
-
539
- segment_length = (t_next - last_t) * meshsize
540
- if segment_length < 0.0:
541
- segment_length = 0.0
542
-
543
- # Tree attenuation
544
- if is_tree[i, j, k]:
545
- # Beer-Lambert law over segment length
546
- trans = np.exp(-tree_k * tree_lad * segment_length)
547
- cumulative_transmittance *= trans
548
- if cumulative_transmittance < 1e-2:
549
- # Trees do not count as target here; early exit as blocked but no hit for inclusion mode
550
- return False, cumulative_transmittance
551
-
552
- # Inclusion: hit if voxel is in target set
553
- if is_target[i, j, k]:
554
- return True, cumulative_transmittance
555
-
556
- # Opaque blockers stop visibility
557
- if is_blocker_inc[i, j, k]:
558
- return False, cumulative_transmittance
559
-
560
- # advance
561
- last_t = t_next
562
- if axis == 0:
563
- t_max_x += t_delta_x; i += step_x
564
- elif axis == 1:
565
- t_max_y += t_delta_y; j += step_y
566
- else:
567
- t_max_z += t_delta_z; k += step_z
568
-
569
- return False, cumulative_transmittance
570
-
571
-
572
- @njit(cache=True, fastmath=True)
573
- def _trace_ray_exclusion_masks(is_tree, is_allowed,
574
- origin, direction,
575
- meshsize, tree_k, tree_lad):
576
- """DDA traversal using precomputed masks for exclusion mode.
577
-
578
- Returns (hit_blocker, cumulative_transmittance).
579
- For exclusion, a hit means obstruction (voxel not in allowed set and not a tree).
580
- """
581
- nx, ny, nz = is_tree.shape
582
-
583
- x0, y0, z0 = origin
584
- dx, dy, dz = direction
585
-
586
- length = (dx*dx + dy*dy + dz*dz) ** 0.5
587
- if length == 0.0:
588
- return False, 1.0
589
- dx /= length; dy /= length; dz /= length
590
-
591
- x, y, z = x0 + 0.5, y0 + 0.5, z0 + 0.5
592
- i, j, k = int(x0), int(y0), int(z0)
593
-
594
- step_x = 1 if dx >= 0 else -1
595
- step_y = 1 if dy >= 0 else -1
596
- step_z = 1 if dz >= 0 else -1
597
-
598
- EPS = 1e-10
599
- if abs(dx) > EPS:
600
- t_max_x = ((i + (step_x > 0)) - x) / dx
601
- t_delta_x = abs(1.0 / dx)
602
- else:
603
- t_max_x = np.inf; t_delta_x = np.inf
604
- if abs(dy) > EPS:
605
- t_max_y = ((j + (step_y > 0)) - y) / dy
606
- t_delta_y = abs(1.0 / dy)
607
- else:
608
- t_max_y = np.inf; t_delta_y = np.inf
609
- if abs(dz) > EPS:
610
- t_max_z = ((k + (step_z > 0)) - z) / dz
611
- t_delta_z = abs(1.0 / dz)
612
- else:
613
- t_max_z = np.inf; t_delta_z = np.inf
614
-
615
- cumulative_transmittance = 1.0
616
- last_t = 0.0
617
-
618
- while (0 <= i < nx) and (0 <= j < ny) and (0 <= k < nz):
619
- t_next = t_max_x
620
- axis = 0
621
- if t_max_y < t_next:
622
- t_next = t_max_y; axis = 1
623
- if t_max_z < t_next:
624
- t_next = t_max_z; axis = 2
625
-
626
- segment_length = (t_next - last_t) * meshsize
627
- if segment_length < 0.0:
628
- segment_length = 0.0
629
-
630
- # Tree attenuation
631
- if is_tree[i, j, k]:
632
- trans = np.exp(-tree_k * tree_lad * segment_length)
633
- cumulative_transmittance *= trans
634
- # In exclusion, a tree alone never counts as obstruction; but we can early exit
635
- if cumulative_transmittance < 1e-2:
636
- return True, cumulative_transmittance
637
-
638
- # Obstruction if voxel is not allowed and not a tree
639
- if (not is_allowed[i, j, k]) and (not is_tree[i, j, k]):
640
- return True, cumulative_transmittance
641
-
642
- last_t = t_next
643
- if axis == 0:
644
- t_max_x += t_delta_x; i += step_x
645
- elif axis == 1:
646
- t_max_y += t_delta_y; j += step_y
647
- else:
648
- t_max_z += t_delta_z; k += step_z
649
-
650
- return False, cumulative_transmittance
651
-
652
-
653
- @njit(parallel=True, cache=True, fastmath=True)
654
- def _compute_vi_map_generic_fast(voxel_data, ray_directions, view_height_voxel,
655
- meshsize, tree_k, tree_lad,
656
- is_tree, is_target, is_allowed, is_blocker_inc,
657
- inclusion_mode, trees_in_targets):
658
- """Fast mask-based computation of VI map.
659
-
660
- trees_in_targets indicates whether to use partial contribution 1 - T for inclusion mode.
661
- """
662
- nx, ny, nz = voxel_data.shape
663
- vi_map = np.full((nx, ny), np.nan)
664
-
665
- # Precompute observer z for each (x,y): returns -1 if invalid, else z index base
666
- obs_base_z = _precompute_observer_base_z(voxel_data)
667
-
668
- for x in prange(nx):
669
- for y in range(ny):
670
- base_z = obs_base_z[x, y]
671
- if base_z < 0:
672
- vi_map[x, y] = np.nan
673
- continue
674
-
675
- # Skip invalid ground: water or negative
676
- below = voxel_data[x, y, base_z]
677
- if (below == 7) or (below == 8) or (below == 9) or (below < 0):
678
- vi_map[x, y] = np.nan
679
- continue
680
-
681
- oz = base_z + 1 + view_height_voxel
682
- obs = np.array([x, y, oz], dtype=np.float64)
683
-
684
- visibility_sum = 0.0
685
- n_rays = ray_directions.shape[0]
686
- for r in range(n_rays):
687
- direction = ray_directions[r]
688
- if inclusion_mode:
689
- hit, value = _trace_ray_inclusion_masks(is_tree, is_target, is_blocker_inc,
690
- obs, direction,
691
- meshsize, tree_k, tree_lad)
692
- if hit:
693
- if trees_in_targets:
694
- contrib = 1.0 - max(0.0, min(1.0, value))
695
- visibility_sum += contrib
696
- else:
697
- visibility_sum += 1.0
698
- else:
699
- hit, value = _trace_ray_exclusion_masks(is_tree, is_allowed,
700
- obs, direction,
701
- meshsize, tree_k, tree_lad)
702
- if not hit:
703
- visibility_sum += value
704
-
705
- vi_map[x, y] = visibility_sum / n_rays
706
-
707
- return np.flipud(vi_map)
708
-
709
-
710
- @njit(cache=True, fastmath=True)
711
- def _precompute_observer_base_z(voxel_data):
712
- """For each (x,y), find the highest z such that z+1 is empty/tree and z is solid (non-empty & non-tree).
713
- Returns int32 array of shape (nx,ny) with z or -1 if none.
714
- """
715
- nx, ny, nz = voxel_data.shape
716
- out = np.empty((nx, ny), dtype=np.int32)
717
- for x in range(nx):
718
- for y in range(ny):
719
- found = False
720
- for z in range(1, nz):
721
- v_above = voxel_data[x, y, z]
722
- v_base = voxel_data[x, y, z - 1]
723
- if (v_above == 0 or v_above == -2) and not (v_base == 0 or v_base == -2):
724
- out[x, y] = z - 1
725
- found = True
726
- break
727
- if not found:
728
- out[x, y] = -1
729
- return out
730
-
731
-
732
- def get_view_index(voxel_data, meshsize, mode=None, hit_values=None, inclusion_mode=True, fast_path=True, **kwargs):
733
- """Calculate and visualize a generic view index for a voxel city model.
734
-
735
- This is a high-level function that provides a flexible interface for computing
736
- various view indices. It handles:
737
- - Mode presets for common indices (green, sky)
738
- - Ray direction generation
739
- - Tree transmittance parameters
740
- - Visualization
741
- - Optional OBJ export
742
-
743
- Args:
744
- voxel_data (ndarray): 3D array of voxel values.
745
- meshsize (float): Size of each voxel in meters.
746
- mode (str): Predefined mode. Options: 'green', 'sky', or None.
747
- If 'green': GVI mode - measures visibility of vegetation
748
- If 'sky': SVI mode - measures visibility of open sky
749
- If None: Custom mode requiring hit_values parameter
750
- hit_values (tuple): Voxel values considered as hits (if inclusion_mode=True)
751
- or allowed values (if inclusion_mode=False), if mode is None.
752
- inclusion_mode (bool):
753
- True = voxel_value in hit_values is success.
754
- False = voxel_value not in hit_values is success.
755
- **kwargs: Additional arguments:
756
- - view_point_height (float): Observer height in meters (default: 1.5)
757
- - colormap (str): Matplotlib colormap name (default: 'viridis')
758
- - obj_export (bool): Export as OBJ (default: False)
759
- - output_directory (str): Directory for OBJ output
760
- - output_file_name (str): Base filename for OBJ output
761
- - num_colors (int): Number of discrete colors for OBJ export
762
- - alpha (float): Transparency value for OBJ export
763
- - vmin (float): Minimum value for color mapping
764
- - vmax (float): Maximum value for color mapping
765
- - N_azimuth (int): Number of azimuth angles for ray directions
766
- - N_elevation (int): Number of elevation angles for ray directions
767
- - elevation_min_degrees (float): Minimum elevation angle in degrees
768
- - elevation_max_degrees (float): Maximum elevation angle in degrees
769
- - tree_k (float): Tree extinction coefficient (default: 0.5)
770
- - tree_lad (float): Leaf area density in m^-1 (default: 1.0)
771
-
772
- Returns:
773
- ndarray: 2D array of computed view index values.
774
- """
775
- # Handle predefined mode presets for common view indices
776
- if mode == 'green':
777
- # GVI defaults - detect vegetation and trees
778
- hit_values = (-2, 2, 5, 6, 7, 8)
779
- inclusion_mode = True
780
- elif mode == 'sky':
781
- # SVI defaults - detect open sky
782
- hit_values = (0,)
783
- inclusion_mode = False
784
- else:
785
- # For custom mode, user must specify hit_values
786
- if hit_values is None:
787
- raise ValueError("For custom mode, you must provide hit_values.")
788
-
789
- # Extract parameters from kwargs with sensible defaults
790
- view_point_height = kwargs.get("view_point_height", 1.5)
791
- view_height_voxel = int(view_point_height / meshsize)
792
- colormap = kwargs.get("colormap", 'viridis')
793
- vmin = kwargs.get("vmin", 0.0)
794
- vmax = kwargs.get("vmax", 1.0)
795
-
796
- # Ray casting parameters for hemisphere sampling
797
- N_azimuth = kwargs.get("N_azimuth", 60)
798
- N_elevation = kwargs.get("N_elevation", 10)
799
- elevation_min_degrees = kwargs.get("elevation_min_degrees", -30)
800
- elevation_max_degrees = kwargs.get("elevation_max_degrees", 30)
801
- ray_sampling = kwargs.get("ray_sampling", "grid") # 'grid' or 'fibonacci'
802
- N_rays = kwargs.get("N_rays", N_azimuth * N_elevation)
803
-
804
- # Tree transmittance parameters for Beer-Lambert law
805
- tree_k = kwargs.get("tree_k", 0.5)
806
- tree_lad = kwargs.get("tree_lad", 1.0)
807
-
808
- # Generate ray directions
809
- if str(ray_sampling).lower() == "fibonacci":
810
- ray_directions = _generate_ray_directions_fibonacci(
811
- int(N_rays), elevation_min_degrees, elevation_max_degrees
812
- )
813
- else:
814
- ray_directions = _generate_ray_directions_grid(
815
- int(N_azimuth), int(N_elevation), elevation_min_degrees, elevation_max_degrees
816
- )
817
-
818
- # Optional: configure numba threads
819
- num_threads = kwargs.get("num_threads", None)
820
- if num_threads is not None:
821
- try:
822
- from numba import set_num_threads
823
- set_num_threads(int(num_threads))
824
- except Exception:
825
- pass
826
-
827
- # Compute the view index map with transmittance parameters
828
- if fast_path:
829
- try:
830
- is_tree, is_target, is_allowed, is_blocker_inc = _prepare_masks_for_vi(voxel_data, hit_values, inclusion_mode)
831
- trees_in_targets = bool(inclusion_mode and (-2 in hit_values))
832
- vi_map = _compute_vi_map_generic_fast(
833
- voxel_data, ray_directions, view_height_voxel,
834
- meshsize, tree_k, tree_lad,
835
- is_tree, is_target if is_target is not None else np.zeros(1, dtype=np.bool_),
836
- is_allowed if is_allowed is not None else np.zeros(1, dtype=np.bool_),
837
- is_blocker_inc if is_blocker_inc is not None else np.zeros(1, dtype=np.bool_),
838
- inclusion_mode, trees_in_targets
839
- )
840
- except Exception:
841
- vi_map = compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel,
842
- hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
843
- else:
844
- vi_map = compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel,
845
- hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
846
-
847
- # Create visualization with custom colormap handling
848
- import matplotlib.pyplot as plt
849
- cmap = plt.cm.get_cmap(colormap).copy()
850
- cmap.set_bad(color='lightgray') # Color for NaN values (invalid locations)
851
- plt.figure(figsize=(10, 8))
852
- plt.imshow(vi_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
853
- plt.colorbar(label='View Index')
854
- plt.axis('off')
855
- plt.show()
856
-
857
- # Optional OBJ export for 3D visualization
858
- obj_export = kwargs.get("obj_export", False)
859
- if obj_export:
860
- dem_grid = kwargs.get("dem_grid", np.zeros_like(vi_map))
861
- output_dir = kwargs.get("output_directory", "output")
862
- output_file_name = kwargs.get("output_file_name", "view_index")
863
- num_colors = kwargs.get("num_colors", 10)
864
- alpha = kwargs.get("alpha", 1.0)
865
- grid_to_obj(
866
- vi_map,
867
- dem_grid,
868
- output_dir,
869
- output_file_name,
870
- meshsize,
871
- view_point_height,
872
- colormap_name=colormap,
873
- num_colors=num_colors,
874
- alpha=alpha,
875
- vmin=vmin,
876
- vmax=vmax
877
- )
878
-
879
- return vi_map
880
-
881
- def mark_building_by_id(voxcity_grid_ori, building_id_grid_ori, ids, mark):
882
- """Mark specific buildings in the voxel grid with a given value.
883
-
884
- This function is used to identify landmark buildings for visibility analysis
885
- by replacing their voxel values with a special marker value. It handles
886
- coordinate system alignment between the building ID grid and voxel grid.
887
-
888
- Args:
889
- voxcity_grid_ori (ndarray): 3D array of voxel values (original, will be copied)
890
- building_id_grid_ori (ndarray): 2D array of building IDs (original, will be copied)
891
- ids (list): List of building IDs to mark as landmarks
892
- mark (int): Value to mark the landmark buildings with (typically negative)
893
-
894
- Returns:
895
- ndarray: Modified 3D voxel grid with landmark buildings marked
896
- """
897
- # Create working copies to avoid modifying original data
898
- voxcity_grid = voxcity_grid_ori.copy()
899
-
900
- # Flip building ID grid vertically to match voxel grid orientation
901
- # This accounts for different coordinate system conventions
902
- building_id_grid = np.flipud(building_id_grid_ori.copy())
903
-
904
- # Find x,y positions where target building IDs are located
905
- positions = np.where(np.isin(building_id_grid, ids))
906
-
907
- # Process each location containing a target building
908
- for i in range(len(positions[0])):
909
- x, y = positions[0][i], positions[1][i]
910
- # Find all building voxels (-3) at this x,y location and mark them
911
- z_mask = voxcity_grid[x, y, :] == -3
912
- voxcity_grid[x, y, z_mask] = mark
913
-
914
- return voxcity_grid
915
-
916
- @njit
917
- def trace_ray_to_target(voxel_data, origin, target, opaque_values):
918
- """Trace a ray from origin to target through voxel data.
919
-
920
- Uses DDA algorithm to efficiently traverse voxels along ray path.
921
- Checks for any opaque voxels blocking the line of sight.
922
-
923
- Args:
924
- voxel_data (ndarray): 3D array of voxel values
925
- origin (tuple): Starting point (x,y,z) in voxel coordinates
926
- target (tuple): End point (x,y,z) in voxel coordinates
927
- opaque_values (ndarray): Array of voxel values that block the ray
928
-
929
- Returns:
930
- bool: True if target is visible from origin, False otherwise
931
- """
932
- nx, ny, nz = voxel_data.shape
933
- x0, y0, z0 = origin
934
- x1, y1, z1 = target
935
- dx = x1 - x0
936
- dy = y1 - y0
937
- dz = z1 - z0
938
-
939
- # Normalize direction vector for consistent traversal
940
- length = np.sqrt(dx*dx + dy*dy + dz*dz)
941
- if length == 0.0:
942
- return True # Origin and target are at the same location
943
- dx /= length
944
- dy /= length
945
- dz /= length
946
-
947
- # Initialize ray position at center of starting voxel
948
- x, y, z = x0 + 0.5, y0 + 0.5, z0 + 0.5
949
- i, j, k = int(x0), int(y0), int(z0)
950
-
951
- # Determine step direction for each axis
952
- step_x = 1 if dx >= 0 else -1
953
- step_y = 1 if dy >= 0 else -1
954
- step_z = 1 if dz >= 0 else -1
955
-
956
- # Calculate distances to next voxel boundaries and step sizes
957
- # Handle cases where direction components are zero to avoid division by zero
958
- if dx != 0:
959
- t_max_x = ((i + (step_x > 0)) - x) / dx
960
- t_delta_x = abs(1 / dx)
961
- else:
962
- t_max_x = np.inf
963
- t_delta_x = np.inf
964
-
965
- if dy != 0:
966
- t_max_y = ((j + (step_y > 0)) - y) / dy
967
- t_delta_y = abs(1 / dy)
968
- else:
969
- t_max_y = np.inf
970
- t_delta_y = np.inf
971
-
972
- if dz != 0:
973
- t_max_z = ((k + (step_z > 0)) - z) / dz
974
- t_delta_z = abs(1 / dz)
975
- else:
976
- t_max_z = np.inf
977
- t_delta_z = np.inf
978
-
979
- # Main ray traversal loop using DDA algorithm
980
- while True:
981
- # Check if current voxel is within bounds and contains opaque material
982
- if (0 <= i < nx) and (0 <= j < ny) and (0 <= k < nz):
983
- voxel_value = voxel_data[i, j, k]
984
- if voxel_value in opaque_values:
985
- return False # Ray is blocked by opaque voxel
986
- else:
987
- return False # Ray went out of bounds before reaching target
988
-
989
- # Check if we've reached the target voxel
990
- if i == int(x1) and j == int(y1) and k == int(z1):
991
- return True # Ray successfully reached the target
992
-
993
- # Move to next voxel using DDA algorithm
994
- # Choose the axis with the smallest distance to next boundary
995
- if t_max_x < t_max_y:
996
- if t_max_x < t_max_z:
997
- t_max = t_max_x
998
- t_max_x += t_delta_x
999
- i += step_x
1000
- else:
1001
- t_max = t_max_z
1002
- t_max_z += t_delta_z
1003
- k += step_z
1004
- else:
1005
- if t_max_y < t_max_z:
1006
- t_max = t_max_y
1007
- t_max_y += t_delta_y
1008
- j += step_y
1009
- else:
1010
- t_max = t_max_z
1011
- t_max_z += t_delta_z
1012
- k += step_z
1013
-
1014
- @njit
1015
- def compute_visibility_to_all_landmarks(observer_location, landmark_positions, voxel_data, opaque_values):
1016
- """Check if any landmark is visible from the observer location.
1017
-
1018
- Traces rays to each landmark position until finding one that's visible.
1019
- Uses optimized ray tracing with early exit on first visible landmark.
1020
-
1021
- Args:
1022
- observer_location (ndarray): Observer position (x,y,z) in voxel coordinates
1023
- landmark_positions (ndarray): Array of landmark positions (n_landmarks, 3)
1024
- voxel_data (ndarray): 3D array of voxel values
1025
- opaque_values (ndarray): Array of voxel values that block visibility
1026
-
1027
- Returns:
1028
- int: 1 if any landmark is visible, 0 if none are visible
1029
- """
1030
- # Check visibility to each landmark sequentially
1031
- # Early exit strategy: return 1 as soon as any landmark is visible
1032
- for idx in range(landmark_positions.shape[0]):
1033
- target = landmark_positions[idx].astype(np.float64)
1034
- is_visible = trace_ray_to_target(voxel_data, observer_location, target, opaque_values)
1035
- if is_visible:
1036
- return 1 # Return immediately when first visible landmark is found
1037
- return 0 # No landmarks were visible from this location
1038
-
1039
- @njit(parallel=True)
1040
- def compute_visibility_map(voxel_data, landmark_positions, opaque_values, view_height_voxel):
1041
- """Compute visibility map for landmarks in the voxel grid.
1042
-
1043
- Places observers at valid locations (empty voxels above ground, excluding building
1044
- roofs and vegetation) and checks visibility to any landmark.
1045
-
1046
- The function processes each x,y position in parallel for efficiency.
1047
- Valid observer locations are:
1048
- - Empty voxels (0) or tree voxels (-2)
1049
- - Above non-empty, non-tree voxels
1050
- - Not above water (7,8,9) or negative values
1051
-
1052
- Args:
1053
- voxel_data (ndarray): 3D array of voxel values
1054
- landmark_positions (ndarray): Array of landmark positions (n_landmarks, 3)
1055
- opaque_values (ndarray): Array of voxel values that block visibility
1056
- view_height_voxel (int): Height offset for observer in voxels
1057
-
1058
- Returns:
1059
- ndarray: 2D array of visibility values
1060
- NaN = invalid observer location
1061
- 0 = no landmarks visible
1062
- 1 = at least one landmark visible
1063
- """
1064
- nx, ny, nz = voxel_data.shape
1065
- visibility_map = np.full((nx, ny), np.nan)
1066
-
1067
- # Process each x,y position in parallel for computational efficiency
1068
- for x in prange(nx):
1069
- for y in range(ny):
1070
- found_observer = False
1071
- # Find the lowest valid observer location by searching from bottom up
1072
- for z in range(1, nz):
1073
- # Valid observer location: empty voxel above non-empty ground
1074
- if voxel_data[x, y, z] == 0 and voxel_data[x, y, z - 1] != 0:
1075
- # Skip locations above building roofs or vegetation
1076
- if (voxel_data[x, y, z - 1] in (7, 8, 9)) or (voxel_data[x, y, z - 1] < 0):
1077
- visibility_map[x, y] = np.nan
1078
- found_observer = True
1079
- break
1080
- else:
1081
- # Place observer at specified height above ground level
1082
- observer_location = np.array([x, y, z+view_height_voxel], dtype=np.float64)
1083
- # Check visibility to any landmark from this location
1084
- visible = compute_visibility_to_all_landmarks(observer_location, landmark_positions, voxel_data, opaque_values)
1085
- visibility_map[x, y] = visible
1086
- found_observer = True
1087
- break
1088
- # Mark locations where no valid observer position exists
1089
- if not found_observer:
1090
- visibility_map[x, y] = np.nan
1091
-
1092
- return visibility_map
1093
-
1094
- def compute_landmark_visibility(voxel_data, target_value=-30, view_height_voxel=0, colormap='viridis'):
1095
- """Compute and visualize landmark visibility in a voxel grid.
1096
-
1097
- Places observers at valid locations and checks visibility to any landmark voxel.
1098
- Generates a binary visibility map and visualization.
1099
-
1100
- The function:
1101
- 1. Identifies all landmark voxels (target_value)
1102
- 2. Determines which voxel values block visibility
1103
- 3. Computes visibility from each valid observer location
1104
- 4. Generates visualization with legend
1105
-
1106
- Args:
1107
- voxel_data (ndarray): 3D array of voxel values
1108
- target_value (int, optional): Value used to identify landmark voxels. Defaults to -30.
1109
- view_height_voxel (int, optional): Height offset for observer in voxels. Defaults to 0.
1110
- colormap (str, optional): Matplotlib colormap name. Defaults to 'viridis'.
1111
-
1112
- Returns:
1113
- ndarray: 2D array of visibility values (0 or 1) with y-axis flipped
1114
- NaN = invalid observer location
1115
- 0 = no landmarks visible
1116
- 1 = at least one landmark visible
1117
-
1118
- Raises:
1119
- ValueError: If no landmark voxels are found with the specified target_value
1120
- """
1121
- # Find positions of all landmark voxels
1122
- landmark_positions = np.argwhere(voxel_data == target_value)
1123
-
1124
- if landmark_positions.shape[0] == 0:
1125
- raise ValueError(f"No landmark with value {target_value} found in the voxel data.")
1126
-
1127
- # Define which voxel values block visibility
1128
- unique_values = np.unique(voxel_data)
1129
- opaque_values = np.array([v for v in unique_values if v != 0 and v != target_value], dtype=np.int32)
1130
-
1131
- # Compute visibility map
1132
- visibility_map = compute_visibility_map(voxel_data, landmark_positions, opaque_values, view_height_voxel)
1133
-
1134
- # Set up visualization
1135
- cmap = plt.cm.get_cmap(colormap, 2).copy()
1136
- cmap.set_bad(color='lightgray')
1137
-
1138
- # Create main plot
1139
- plt.figure(figsize=(10, 8))
1140
- plt.imshow(np.flipud(visibility_map), origin='lower', cmap=cmap, vmin=0, vmax=1)
1141
-
1142
- # Create and add legend
1143
- visible_patch = mpatches.Patch(color=cmap(1.0), label='Visible (1)')
1144
- not_visible_patch = mpatches.Patch(color=cmap(0.0), label='Not Visible (0)')
1145
- plt.legend(handles=[visible_patch, not_visible_patch],
1146
- loc='center left',
1147
- bbox_to_anchor=(1.0, 0.5))
1148
- plt.axis('off')
1149
- plt.show()
1150
-
1151
- return np.flipud(visibility_map)
1152
-
1153
- def get_landmark_visibility_map(voxcity_grid_ori, building_id_grid, building_gdf, meshsize, **kwargs):
1154
- """Generate a visibility map for landmark buildings in a voxel city.
1155
-
1156
- Places observers at valid locations and checks visibility to any part of the
1157
- specified landmark buildings. Can identify landmarks either by ID or by finding
1158
- buildings within a specified rectangle.
1159
-
1160
- Args:
1161
- voxcity_grid (ndarray): 3D array representing the voxel city
1162
- building_id_grid (ndarray): 3D array mapping voxels to building IDs
1163
- building_gdf (GeoDataFrame): GeoDataFrame containing building features
1164
- meshsize (float): Size of each voxel in meters
1165
- **kwargs: Additional keyword arguments
1166
- view_point_height (float): Height of observer viewpoint in meters
1167
- colormap (str): Matplotlib colormap name
1168
- landmark_building_ids (list): List of building IDs to mark as landmarks
1169
- rectangle_vertices (list): List of (lat,lon) coordinates defining rectangle
1170
- obj_export (bool): Whether to export visibility map as OBJ file
1171
- dem_grid (ndarray): Digital elevation model grid for OBJ export
1172
- output_directory (str): Directory for OBJ file output
1173
- output_file_name (str): Base filename for OBJ output
1174
- alpha (float): Alpha transparency value for OBJ export
1175
- vmin (float): Minimum value for color mapping
1176
- vmax (float): Maximum value for color mapping
1177
-
1178
- Returns:
1179
- ndarray: 2D array of visibility values for landmark buildings
1180
- """
1181
- # Convert observer height from meters to voxel units
1182
- view_point_height = kwargs.get("view_point_height", 1.5)
1183
- view_height_voxel = int(view_point_height / meshsize)
1184
-
1185
- colormap = kwargs.get("colormap", 'viridis')
1186
-
1187
- # Get landmark building IDs either directly or by finding buildings in rectangle
1188
- landmark_ids = kwargs.get('landmark_building_ids', None)
1189
- landmark_polygon = kwargs.get('landmark_polygon', None)
1190
- if landmark_ids is None:
1191
- if landmark_polygon is not None:
1192
- landmark_ids = get_buildings_in_drawn_polygon(building_gdf, landmark_polygon, operation='within')
1193
- else:
1194
- rectangle_vertices = kwargs.get("rectangle_vertices", None)
1195
- if rectangle_vertices is None:
1196
- print("Cannot set landmark buildings. You need to input either of rectangle_vertices or landmark_ids.")
1197
- return None
1198
-
1199
- # Calculate center point of rectangle
1200
- lons = [coord[0] for coord in rectangle_vertices]
1201
- lats = [coord[1] for coord in rectangle_vertices]
1202
- center_lon = (min(lons) + max(lons)) / 2
1203
- center_lat = (min(lats) + max(lats)) / 2
1204
- target_point = (center_lon, center_lat)
1205
-
1206
- # Find buildings at center point
1207
- landmark_ids = find_building_containing_point(building_gdf, target_point)
1208
-
1209
- # Mark landmark buildings in voxel grid with special value
1210
- target_value = -30
1211
- voxcity_grid = mark_building_by_id(voxcity_grid_ori, building_id_grid, landmark_ids, target_value)
1212
-
1213
- # Compute visibility map
1214
- landmark_vis_map = compute_landmark_visibility(voxcity_grid, target_value=target_value, view_height_voxel=view_height_voxel, colormap=colormap)
1215
-
1216
- # Handle optional OBJ export
1217
- obj_export = kwargs.get("obj_export")
1218
- if obj_export == True:
1219
- dem_grid = kwargs.get("dem_grid", np.zeros_like(landmark_vis_map))
1220
- output_dir = kwargs.get("output_directory", "output")
1221
- output_file_name = kwargs.get("output_file_name", "landmark_visibility")
1222
- num_colors = 2
1223
- alpha = kwargs.get("alpha", 1.0)
1224
- vmin = kwargs.get("vmin", 0.0)
1225
- vmax = kwargs.get("vmax", 1.0)
1226
-
1227
- # Export visibility map and voxel city as OBJ files
1228
- grid_to_obj(
1229
- landmark_vis_map,
1230
- dem_grid,
1231
- output_dir,
1232
- output_file_name,
1233
- meshsize,
1234
- view_point_height,
1235
- colormap_name=colormap,
1236
- num_colors=num_colors,
1237
- alpha=alpha,
1238
- vmin=vmin,
1239
- vmax=vmax
1240
- )
1241
- output_file_name_vox = 'voxcity_' + output_file_name
1242
- export_obj(voxcity_grid, output_dir, output_file_name_vox, meshsize)
1243
-
1244
- return landmark_vis_map, voxcity_grid
1245
-
1246
- def get_sky_view_factor_map(voxel_data, meshsize, show_plot=False, **kwargs):
1247
- """
1248
- Compute and visualize the Sky View Factor (SVF) for each valid observer cell in the voxel grid.
1249
-
1250
- Sky View Factor measures the proportion of the sky hemisphere that is visible from a given point.
1251
- It ranges from 0 (completely obstructed) to 1 (completely open sky). This implementation:
1252
- - Uses hemisphere ray casting to sample sky visibility
1253
- - Accounts for tree transmittance using Beer-Lambert law
1254
- - Places observers at valid street-level locations
1255
- - Provides optional visualization and OBJ export
1256
-
1257
- Args:
1258
- voxel_data (ndarray): 3D array of voxel values.
1259
- meshsize (float): Size of each voxel in meters.
1260
- show_plot (bool): Whether to display the SVF visualization plot.
1261
- **kwargs: Additional parameters including:
1262
- view_point_height (float): Observer height in meters (default: 1.5)
1263
- colormap (str): Matplotlib colormap name (default: 'BuPu_r')
1264
- vmin, vmax (float): Color scale limits (default: 0.0, 1.0)
1265
- N_azimuth (int): Number of azimuth angles for ray sampling (default: 60)
1266
- N_elevation (int): Number of elevation angles for ray sampling (default: 10)
1267
- elevation_min_degrees (float): Minimum elevation angle (default: 0)
1268
- elevation_max_degrees (float): Maximum elevation angle (default: 90)
1269
- tree_k (float): Tree extinction coefficient (default: 0.6)
1270
- tree_lad (float): Leaf area density in m^-1 (default: 1.0)
1271
- obj_export (bool): Whether to export as OBJ file (default: False)
1272
-
1273
- Returns:
1274
- ndarray: 2D array of SVF values at each valid observer location (x, y).
1275
- NaN values indicate invalid observer positions.
1276
- """
1277
- # Extract default parameters with sky-specific settings
1278
- view_point_height = kwargs.get("view_point_height", 1.5)
1279
- view_height_voxel = int(view_point_height / meshsize)
1280
- colormap = kwargs.get("colormap", 'BuPu_r') # Blue-purple colormap suitable for sky
1281
- vmin = kwargs.get("vmin", 0.0)
1282
- vmax = kwargs.get("vmax", 1.0)
1283
-
1284
- # Ray sampling parameters optimized for sky view factor
1285
- N_azimuth = kwargs.get("N_azimuth", 60) # Full 360-degree azimuth sampling
1286
- N_elevation = kwargs.get("N_elevation", 10) # Hemisphere elevation sampling
1287
- elevation_min_degrees = kwargs.get("elevation_min_degrees", 0) # Horizon
1288
- elevation_max_degrees = kwargs.get("elevation_max_degrees", 90) # Zenith
1289
- ray_sampling = kwargs.get("ray_sampling", "grid") # 'grid' or 'fibonacci'
1290
- N_rays = kwargs.get("N_rays", N_azimuth * N_elevation)
1291
-
1292
- # Tree transmittance parameters for Beer-Lambert law
1293
- tree_k = kwargs.get("tree_k", 0.6) # Static extinction coefficient
1294
- tree_lad = kwargs.get("tree_lad", 1.0) # Leaf area density in m^-1
1295
-
1296
- # Sky view factor configuration: detect open sky (value 0)
1297
- hit_values = (0,) # Sky voxels have value 0
1298
- inclusion_mode = False # Count rays that DON'T hit obstacles (exclusion mode)
1299
-
1300
- # Generate ray directions over the sky hemisphere (0 to 90 degrees elevation)
1301
- if str(ray_sampling).lower() == "fibonacci":
1302
- ray_directions = _generate_ray_directions_fibonacci(
1303
- int(N_rays), elevation_min_degrees, elevation_max_degrees
1304
- )
1305
- else:
1306
- ray_directions = _generate_ray_directions_grid(
1307
- int(N_azimuth), int(N_elevation), elevation_min_degrees, elevation_max_degrees
1308
- )
1309
-
1310
- # Compute the SVF map using the generic view index computation
1311
- vi_map = compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel,
1312
- hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
1313
-
1314
- # Display visualization if requested
1315
- if show_plot:
1316
- import matplotlib.pyplot as plt
1317
- cmap = plt.cm.get_cmap(colormap).copy()
1318
- cmap.set_bad(color='lightgray') # Gray for invalid observer locations
1319
- plt.figure(figsize=(10, 8))
1320
- plt.imshow(vi_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
1321
- plt.colorbar(label='Sky View Factor')
1322
- plt.axis('off')
1323
- plt.show()
1324
-
1325
- # Optional OBJ export for 3D visualization
1326
- obj_export = kwargs.get("obj_export", False)
1327
- if obj_export:
1328
- dem_grid = kwargs.get("dem_grid", np.zeros_like(vi_map))
1329
- output_dir = kwargs.get("output_directory", "output")
1330
- output_file_name = kwargs.get("output_file_name", "sky_view_factor")
1331
- num_colors = kwargs.get("num_colors", 10)
1332
- alpha = kwargs.get("alpha", 1.0)
1333
- grid_to_obj(
1334
- vi_map,
1335
- dem_grid,
1336
- output_dir,
1337
- output_file_name,
1338
- meshsize,
1339
- view_point_height,
1340
- colormap_name=colormap,
1341
- num_colors=num_colors,
1342
- alpha=alpha,
1343
- vmin=vmin,
1344
- vmax=vmax
1345
- )
1346
-
1347
- return vi_map
1348
-
1349
- @njit
1350
- def rotate_vector_axis_angle(vec, axis, angle):
1351
- """
1352
- Rotate a 3D vector around an arbitrary axis using Rodrigues' rotation formula.
1353
-
1354
- This function implements the Rodrigues rotation formula:
1355
- v_rot = v*cos(θ) + (k × v)*sin(θ) + k*(k·v)*(1-cos(θ))
1356
- where k is the unit rotation axis, θ is the rotation angle, and v is the input vector.
1357
-
1358
- Args:
1359
- vec (ndarray): 3D vector to rotate [x, y, z]
1360
- axis (ndarray): 3D rotation axis vector [x, y, z] (will be normalized)
1361
- angle (float): Rotation angle in radians
1362
-
1363
- Returns:
1364
- ndarray: Rotated 3D vector [x, y, z]
1365
- """
1366
- # Normalize rotation axis to unit length
1367
- axis_len = np.sqrt(axis[0]**2 + axis[1]**2 + axis[2]**2)
1368
- if axis_len < 1e-12:
1369
- # Degenerate axis case: return original vector unchanged
1370
- return vec
1371
-
1372
- ux, uy, uz = axis / axis_len
1373
- c = np.cos(angle)
1374
- s = np.sin(angle)
1375
-
1376
- # Calculate dot product: k·v
1377
- dot = vec[0]*ux + vec[1]*uy + vec[2]*uz
1378
-
1379
- # Calculate cross product: k × v
1380
- cross_x = uy*vec[2] - uz*vec[1]
1381
- cross_y = uz*vec[0] - ux*vec[2]
1382
- cross_z = ux*vec[1] - uy*vec[0]
1383
-
1384
- # Apply Rodrigues formula: v_rot = v*c + (k × v)*s + k*(k·v)*(1-c)
1385
- v_rot = np.zeros(3, dtype=np.float64)
1386
-
1387
- # First term: v*cos(θ)
1388
- v_rot[0] = vec[0] * c
1389
- v_rot[1] = vec[1] * c
1390
- v_rot[2] = vec[2] * c
1391
-
1392
- # Second term: (k × v)*sin(θ)
1393
- v_rot[0] += cross_x * s
1394
- v_rot[1] += cross_y * s
1395
- v_rot[2] += cross_z * s
1396
-
1397
- # Third term: k*(k·v)*(1-cos(θ))
1398
- tmp = dot * (1.0 - c)
1399
- v_rot[0] += ux * tmp
1400
- v_rot[1] += uy * tmp
1401
- v_rot[2] += uz * tmp
1402
-
1403
- return v_rot
1404
-
1405
- @njit
1406
- def compute_view_factor_for_all_faces(
1407
- face_centers,
1408
- face_normals,
1409
- hemisphere_dirs,
1410
- voxel_data,
1411
- meshsize,
1412
- tree_k,
1413
- tree_lad,
1414
- target_values,
1415
- inclusion_mode,
1416
- grid_bounds_real,
1417
- boundary_epsilon,
1418
- offset_vox=0.51
1419
- ):
1420
- """
1421
- Compute a per-face "view factor" for a specified set of target voxel classes.
1422
-
1423
- This function computes view factors from building surface faces to target voxel types
1424
- (e.g., sky, trees, other buildings). It uses hemisphere ray casting with rotation
1425
- to align rays with each face's normal direction.
1426
-
1427
- Typical usage examples:
1428
- - Sky View Factor: target_values=(0,), inclusion_mode=False (sky voxels)
1429
- - Tree View Factor: target_values=(-2,), inclusion_mode=True (tree voxels)
1430
- - Building View Factor: target_values=(-3,), inclusion_mode=True (building voxels)
1431
-
1432
- Args:
1433
- face_centers (np.ndarray): (n_faces, 3) face centroid positions in real coordinates.
1434
- face_normals (np.ndarray): (n_faces, 3) face normal vectors (outward pointing).
1435
- hemisphere_dirs (np.ndarray): (N, 3) set of direction vectors in the upper hemisphere.
1436
- voxel_data (np.ndarray): 3D array of voxel values.
1437
- meshsize (float): Size of each voxel in meters.
1438
- tree_k (float): Tree extinction coefficient for Beer-Lambert law.
1439
- tree_lad (float): Leaf area density in m^-1 for tree transmittance.
1440
- target_values (tuple[int]): Voxel classes that define a 'hit' or target.
1441
- inclusion_mode (bool): If True, hitting target_values counts as visibility.
1442
- If False, hitting anything NOT in target_values blocks the ray.
1443
- grid_bounds_real (np.ndarray): [[x_min,y_min,z_min],[x_max,y_max,z_max]] in real coords.
1444
- boundary_epsilon (float): Tolerance for identifying boundary vertical faces.
1445
-
1446
- Returns:
1447
- np.ndarray of shape (n_faces,): Computed view factor for each face.
1448
- NaN values indicate boundary vertical faces that should be excluded.
1449
- """
1450
- n_faces = face_centers.shape[0]
1451
- face_vf_values = np.zeros(n_faces, dtype=np.float64)
1452
-
1453
- # Reference vector pointing upward (+Z direction)
1454
- z_axis = np.array([0.0, 0.0, 1.0])
1455
-
1456
- # Process each face individually
1457
- for fidx in range(n_faces):
1458
- center = face_centers[fidx]
1459
- normal = face_normals[fidx]
1460
-
1461
- # Check for boundary vertical faces and mark as NaN
1462
- # This excludes faces on domain edges that may have artificial visibility
1463
- is_vertical = (abs(normal[2]) < 0.01) # Face normal is nearly horizontal
1464
-
1465
- # Check if face is near domain boundaries
1466
- on_x_min = (abs(center[0] - grid_bounds_real[0,0]) < boundary_epsilon)
1467
- on_y_min = (abs(center[1] - grid_bounds_real[0,1]) < boundary_epsilon)
1468
- on_x_max = (abs(center[0] - grid_bounds_real[1,0]) < boundary_epsilon)
1469
- on_y_max = (abs(center[1] - grid_bounds_real[1,1]) < boundary_epsilon)
1470
-
1471
- is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
1472
- if is_boundary_vertical:
1473
- face_vf_values[fidx] = np.nan
1474
- continue
1475
-
1476
- # Compute rotation to align face normal with +Z axis
1477
- # This allows us to use the same hemisphere directions for all faces
1478
- norm_n = np.sqrt(normal[0]**2 + normal[1]**2 + normal[2]**2)
1479
- if norm_n < 1e-12:
1480
- # Degenerate normal vector
1481
- face_vf_values[fidx] = 0.0
1482
- continue
1483
-
1484
- # Calculate angle between face normal and +Z axis
1485
- dot_zn = z_axis[0]*normal[0] + z_axis[1]*normal[1] + z_axis[2]*normal[2]
1486
- cos_angle = dot_zn / (norm_n)
1487
- if cos_angle > 1.0: cos_angle = 1.0
1488
- if cos_angle < -1.0: cos_angle = -1.0
1489
- angle = np.arccos(cos_angle)
1490
-
1491
- # Handle special cases and general rotation
1492
- if abs(cos_angle - 1.0) < 1e-9:
1493
- # Face normal is already aligned with +Z => no rotation needed
1494
- local_dirs = hemisphere_dirs
1495
- elif abs(cos_angle + 1.0) < 1e-9:
1496
- # Face normal points in -Z direction => rotate 180 degrees around X axis
1497
- axis_180 = np.array([1.0, 0.0, 0.0])
1498
- local_dirs = np.empty_like(hemisphere_dirs)
1499
- for i in range(hemisphere_dirs.shape[0]):
1500
- local_dirs[i] = rotate_vector_axis_angle(hemisphere_dirs[i], axis_180, np.pi)
1501
- else:
1502
- # General case: rotate around axis perpendicular to both +Z and face normal
1503
- axis_x = z_axis[1]*normal[2] - z_axis[2]*normal[1]
1504
- axis_y = z_axis[2]*normal[0] - z_axis[0]*normal[2]
1505
- axis_z = z_axis[0]*normal[1] - z_axis[1]*normal[0]
1506
- rot_axis = np.array([axis_x, axis_y, axis_z], dtype=np.float64)
1507
-
1508
- local_dirs = np.empty_like(hemisphere_dirs)
1509
- for i in range(hemisphere_dirs.shape[0]):
1510
- local_dirs[i] = rotate_vector_axis_angle(
1511
- hemisphere_dirs[i],
1512
- rot_axis,
1513
- angle
1514
- )
1515
-
1516
- # Count valid ray directions based on face orientation (outward only)
1517
- total_outward = 0 # Rays pointing away from face surface
1518
- num_valid = 0 # Rays that meet all criteria (outward)
1519
-
1520
- for i in range(local_dirs.shape[0]):
1521
- dvec = local_dirs[i]
1522
- # Check if ray points outward from face surface (positive dot product with normal)
1523
- dp = dvec[0]*normal[0] + dvec[1]*normal[1] + dvec[2]*normal[2]
1524
- if dp > 0.0:
1525
- total_outward += 1
1526
- num_valid += 1
1527
-
1528
- # Handle cases with no valid directions
1529
- if total_outward == 0:
1530
- face_vf_values[fidx] = 0.0
1531
- continue
1532
-
1533
- if num_valid == 0:
1534
- face_vf_values[fidx] = 0.0
1535
- continue
1536
-
1537
- # Create array containing only the valid ray directions
1538
- valid_dirs_arr = np.empty((num_valid, 3), dtype=np.float64)
1539
- out_idx = 0
1540
- for i in range(local_dirs.shape[0]):
1541
- dvec = local_dirs[i]
1542
- dp = dvec[0]*normal[0] + dvec[1]*normal[1] + dvec[2]*normal[2]
1543
- if dp > 0.0:
1544
- valid_dirs_arr[out_idx, 0] = dvec[0]
1545
- valid_dirs_arr[out_idx, 1] = dvec[1]
1546
- valid_dirs_arr[out_idx, 2] = dvec[2]
1547
- out_idx += 1
1548
-
1549
- # Set ray origin slightly offset from face surface to avoid self-intersection
1550
- # Use configurable offset to reduce self-hit artifacts.
1551
- ray_origin = (center / meshsize) + (normal / norm_n) * offset_vox
1552
-
1553
- # Compute fraction of valid rays that "see" the target using generic ray tracing
1554
- vf = compute_vi_generic(
1555
- ray_origin,
1556
- voxel_data,
1557
- valid_dirs_arr,
1558
- target_values,
1559
- meshsize,
1560
- tree_k,
1561
- tree_lad,
1562
- inclusion_mode
1563
- )
1564
-
1565
- # Scale result by fraction of directions that were valid
1566
- # This normalizes for the hemisphere portion that the face can actually "see"
1567
- fraction_valid = num_valid / total_outward
1568
- face_vf_values[fidx] = vf * fraction_valid
1569
-
1570
- return face_vf_values
1571
-
1572
- def get_surface_view_factor(voxel_data, meshsize, **kwargs):
1573
- """
1574
- Compute and optionally visualize view factors for surface meshes with respect to target voxel classes.
1575
-
1576
- This function provides a flexible framework for computing various surface-based view factors:
1577
- - Sky View Factor: Fraction of sky hemisphere visible from building surfaces
1578
- - Tree View Factor: Fraction of directions that intersect vegetation
1579
- - Building View Factor: Fraction of directions that intersect other buildings
1580
- - Custom View Factors: User-defined target voxel classes
1581
-
1582
- The function extracts surface meshes from the voxel data, then computes view factors
1583
- for each face using hemisphere ray casting with proper geometric transformations.
1584
-
1585
- Args:
1586
- voxel_data (ndarray): 3D array of voxel values representing the urban environment.
1587
- meshsize (float): Size of each voxel in meters for coordinate scaling.
1588
- **kwargs: Extensive configuration options including:
1589
- # Target specification:
1590
- target_values (tuple[int]): Voxel classes to measure visibility to (default: (0,) for sky)
1591
- inclusion_mode (bool): Interpretation of target_values (default: False for sky)
1592
-
1593
- # Surface extraction:
1594
- building_class_id (int): Voxel class to extract surfaces from (default: -3 for buildings)
1595
- building_id_grid (ndarray): Optional grid mapping voxels to building IDs
1596
-
1597
- # Ray sampling:
1598
- N_azimuth (int): Number of azimuth angles for hemisphere sampling (default: 60)
1599
- N_elevation (int): Number of elevation angles for hemisphere sampling (default: 10)
1600
-
1601
- # Tree transmittance (Beer-Lambert law):
1602
- tree_k (float): Tree extinction coefficient (default: 0.6)
1603
- tree_lad (float): Leaf area density in m^-1 (default: 1.0)
1604
-
1605
- # Visualization and export:
1606
- colormap (str): Matplotlib colormap for visualization (default: 'BuPu_r')
1607
- vmin, vmax (float): Color scale limits (default: 0.0, 1.0)
1608
- obj_export (bool): Whether to export mesh as OBJ file (default: False)
1609
- output_directory (str): Directory for OBJ export (default: "output")
1610
- output_file_name (str): Base filename for OBJ export (default: "surface_view_factor")
1611
-
1612
- # Other options:
1613
- progress_report (bool): Whether to print computation progress (default: False)
1614
- debug (bool): Enable debug output (default: False)
1615
-
1616
- Returns:
1617
- trimesh.Trimesh: Surface mesh with per-face view factor values stored in metadata.
1618
- The view factor values can be accessed via mesh.metadata[value_name].
1619
- Returns None if no surfaces are found or extraction fails.
1620
-
1621
- Example Usage:
1622
- # Sky View Factor for building surfaces
1623
- mesh = get_surface_view_factor(voxel_data, meshsize,
1624
- target_values=(0,), inclusion_mode=False)
1625
-
1626
- # Tree View Factor for building surfaces
1627
- mesh = get_surface_view_factor(voxel_data, meshsize,
1628
- target_values=(-2,), inclusion_mode=True)
1629
-
1630
- # Custom view factor with OBJ export
1631
- mesh = get_surface_view_factor(voxel_data, meshsize,
1632
- target_values=(-3,), inclusion_mode=True,
1633
- obj_export=True, output_file_name="building_view_factor")
1634
- """
1635
- import matplotlib.pyplot as plt
1636
- import matplotlib.cm as cm
1637
- import matplotlib.colors as mcolors
1638
- import os
1639
-
1640
- # Extract configuration parameters with appropriate defaults
1641
- value_name = kwargs.get("value_name", 'view_factor_values')
1642
- colormap = kwargs.get("colormap", 'BuPu_r')
1643
- vmin = kwargs.get("vmin", 0.0)
1644
- vmax = kwargs.get("vmax", 1.0)
1645
- N_azimuth = kwargs.get("N_azimuth", 60)
1646
- N_elevation = kwargs.get("N_elevation", 10)
1647
- ray_sampling = kwargs.get("ray_sampling", "grid") # 'grid' or 'fibonacci'
1648
- N_rays = kwargs.get("N_rays", N_azimuth * N_elevation)
1649
- debug = kwargs.get("debug", False)
1650
- progress_report= kwargs.get("progress_report", False)
1651
- building_id_grid = kwargs.get("building_id_grid", None)
1652
-
1653
- # Tree transmittance parameters for Beer-Lambert law
1654
- tree_k = kwargs.get("tree_k", 0.6)
1655
- tree_lad = kwargs.get("tree_lad", 1.0)
1656
-
1657
- # Target specification - defaults to sky view factor configuration
1658
- target_values = kwargs.get("target_values", (0,)) # Sky voxels by default
1659
- inclusion_mode = kwargs.get("inclusion_mode", False) # Exclusion mode for sky
1660
-
1661
- # Surface extraction parameters
1662
- building_class_id = kwargs.get("building_class_id", -3) # Building voxel class
1663
-
1664
- # Extract surface mesh from the specified voxel class
1665
- try:
1666
- building_mesh = create_voxel_mesh(
1667
- voxel_data,
1668
- building_class_id,
1669
- meshsize,
1670
- building_id_grid=building_id_grid,
1671
- mesh_type='open_air' # Extract surfaces exposed to air
1672
- )
1673
- if building_mesh is None or len(building_mesh.faces) == 0:
1674
- print("No surfaces found in voxel data for the specified class.")
1675
- return None
1676
- except Exception as e:
1677
- print(f"Error during mesh extraction: {e}")
1678
- return None
1679
-
1680
- if progress_report:
1681
- print(f"Processing view factor for {len(building_mesh.faces)} faces...")
1682
-
1683
- # Extract geometric properties from the mesh
1684
- face_centers = building_mesh.triangles_center # Centroid of each face
1685
- face_normals = building_mesh.face_normals # Outward normal of each face
1686
-
1687
- # Generate hemisphere ray directions (local +Z hemisphere)
1688
- if str(ray_sampling).lower() == "fibonacci":
1689
- hemisphere_dirs = _generate_ray_directions_fibonacci(
1690
- int(N_rays), 0.0, 90.0
1691
- )
1692
- else:
1693
- hemisphere_dirs = _generate_ray_directions_grid(
1694
- int(N_azimuth), int(N_elevation), 0.0, 90.0
1695
- )
1696
-
1697
- # Calculate domain bounds for boundary face detection
1698
- nx, ny, nz = voxel_data.shape
1699
- grid_bounds_voxel = np.array([[0,0,0],[nx, ny, nz]], dtype=np.float64)
1700
- grid_bounds_real = grid_bounds_voxel * meshsize
1701
- boundary_epsilon = meshsize * 0.05 # Tolerance for boundary detection
1702
-
1703
- # Attempt fast path using boolean masks + orthonormal basis + parallel Numba
1704
- fast_path = kwargs.get("fast_path", True)
1705
- face_vf_values = None
1706
- if fast_path:
1707
- try:
1708
- vox_is_tree, vox_is_target, vox_is_allowed, vox_is_opaque = _prepare_masks_for_view(
1709
- voxel_data, target_values, inclusion_mode
1710
- )
1711
- att = float(np.exp(-tree_k * tree_lad * meshsize))
1712
- att_cutoff = 0.01
1713
- trees_are_targets = bool((-2 in target_values) and inclusion_mode)
1714
-
1715
- face_vf_values = _compute_view_factor_faces_progress(
1716
- face_centers.astype(np.float64),
1717
- face_normals.astype(np.float64),
1718
- hemisphere_dirs.astype(np.float64),
1719
- vox_is_tree, vox_is_target, vox_is_allowed, vox_is_opaque,
1720
- float(meshsize), float(att), float(att_cutoff),
1721
- grid_bounds_real.astype(np.float64), float(boundary_epsilon),
1722
- inclusion_mode, trees_are_targets,
1723
- progress_report=progress_report
1724
- )
1725
- except Exception as e:
1726
- if debug:
1727
- print(f"Fast view-factor path failed: {e}. Falling back to standard path.")
1728
- face_vf_values = None
1729
-
1730
- # Fallback to original implementation if fast path unavailable/failed
1731
- if face_vf_values is None:
1732
- face_vf_values = compute_view_factor_for_all_faces(
1733
- face_centers,
1734
- face_normals,
1735
- hemisphere_dirs,
1736
- voxel_data,
1737
- meshsize,
1738
- tree_k,
1739
- tree_lad,
1740
- target_values,
1741
- inclusion_mode,
1742
- grid_bounds_real,
1743
- boundary_epsilon
1744
- )
1745
-
1746
- # Store computed view factor values in mesh metadata for later access
1747
- if not hasattr(building_mesh, 'metadata'):
1748
- building_mesh.metadata = {}
1749
- building_mesh.metadata[value_name] = face_vf_values
1750
-
1751
- # Optional OBJ file export for external visualization/analysis
1752
- obj_export = kwargs.get("obj_export", False)
1753
- if obj_export:
1754
- output_dir = kwargs.get("output_directory", "output")
1755
- output_file_name= kwargs.get("output_file_name", "surface_view_factor")
1756
- os.makedirs(output_dir, exist_ok=True)
1757
- try:
1758
- building_mesh.export(f"{output_dir}/{output_file_name}.obj")
1759
- print(f"Exported surface mesh to {output_dir}/{output_file_name}.obj")
1760
- except Exception as e:
1761
- print(f"Error exporting mesh: {e}")
1762
-
1763
- return building_mesh
1764
-
1765
- # ==========================
1766
- # Fast per-face view factor (parallel)
1767
- # ==========================
1768
- def _prepare_masks_for_view(voxel_data, target_values, inclusion_mode):
1769
- is_tree = (voxel_data == -2)
1770
- # Targets mask (for inclusion mode)
1771
- target_mask = np.zeros(voxel_data.shape, dtype=np.bool_)
1772
- for tv in target_values:
1773
- target_mask |= (voxel_data == tv)
1774
- if inclusion_mode:
1775
- # Opaque: anything non-air, non-tree, and not target
1776
- is_opaque = (voxel_data != 0) & (~is_tree) & (~target_mask)
1777
- # Allowed mask is unused in inclusion mode but keep shape compatibility
1778
- is_allowed = target_mask.copy()
1779
- else:
1780
- # Exclusion mode: allowed voxels are target_values (e.g., sky=0)
1781
- is_allowed = target_mask
1782
- # Opaque: anything not tree and not allowed
1783
- is_opaque = (~is_tree) & (~is_allowed)
1784
- return is_tree, target_mask, is_allowed, is_opaque
1785
-
1786
- @njit(cache=True, fastmath=True, nogil=True)
1787
- def _build_face_basis(normal):
1788
- nx = normal[0]; ny = normal[1]; nz = normal[2]
1789
- nrm = (nx*nx + ny*ny + nz*nz) ** 0.5
1790
- if nrm < 1e-12:
1791
- # Default to +Z if degenerate
1792
- return (np.array((1.0, 0.0, 0.0)),
1793
- np.array((0.0, 1.0, 0.0)),
1794
- np.array((0.0, 0.0, 1.0)))
1795
- invn = 1.0 / nrm
1796
- nx *= invn; ny *= invn; nz *= invn
1797
- n = np.array((nx, ny, nz))
1798
- # Choose helper to avoid near-parallel cross
1799
- if abs(nz) < 0.999:
1800
- helper = np.array((0.0, 0.0, 1.0))
1801
- else:
1802
- helper = np.array((1.0, 0.0, 0.0))
1803
- # u = normalize(helper x n)
1804
- ux = helper[1]*n[2] - helper[2]*n[1]
1805
- uy = helper[2]*n[0] - helper[0]*n[2]
1806
- uz = helper[0]*n[1] - helper[1]*n[0]
1807
- ul = (ux*ux + uy*uy + uz*uz) ** 0.5
1808
- if ul < 1e-12:
1809
- u = np.array((1.0, 0.0, 0.0))
1810
- else:
1811
- invul = 1.0 / ul
1812
- u = np.array((ux*invul, uy*invul, uz*invul))
1813
- # v = n x u
1814
- vx = n[1]*u[2] - n[2]*u[1]
1815
- vy = n[2]*u[0] - n[0]*u[2]
1816
- vz = n[0]*u[1] - n[1]*u[0]
1817
- v = np.array((vx, vy, vz))
1818
- return u, v, n
1819
-
1820
- @njit(cache=True, fastmath=True, nogil=True)
1821
- def _ray_visibility_contrib(origin, direction,
1822
- vox_is_tree, vox_is_target, vox_is_allowed, vox_is_opaque,
1823
- att, att_cutoff,
1824
- inclusion_mode, trees_are_targets):
1825
- nx, ny, nz = vox_is_opaque.shape
1826
- x0 = origin[0]; y0 = origin[1]; z0 = origin[2]
1827
- dx = direction[0]; dy = direction[1]; dz = direction[2]
1828
-
1829
- # Normalize
1830
- L = (dx*dx + dy*dy + dz*dz) ** 0.5
1831
- if L == 0.0:
1832
- return 0.0
1833
- invL = 1.0 / L
1834
- dx *= invL; dy *= invL; dz *= invL
1835
-
1836
- # Starting point and indices
1837
- x = x0 + 0.5
1838
- y = y0 + 0.5
1839
- z = z0 + 0.5
1840
- i = int(x0); j = int(y0); k = int(z0)
1841
-
1842
- step_x = 1 if dx >= 0.0 else -1
1843
- step_y = 1 if dy >= 0.0 else -1
1844
- step_z = 1 if dz >= 0.0 else -1
1845
-
1846
- BIG = 1e30
1847
- if dx != 0.0:
1848
- t_max_x = (((i + (1 if step_x > 0 else 0)) - x) / dx)
1849
- t_delta_x = abs(1.0 / dx)
1850
- else:
1851
- t_max_x = BIG; t_delta_x = BIG
1852
- if dy != 0.0:
1853
- t_max_y = (((j + (1 if step_y > 0 else 0)) - y) / dy)
1854
- t_delta_y = abs(1.0 / dy)
1855
- else:
1856
- t_max_y = BIG; t_delta_y = BIG
1857
- if dz != 0.0:
1858
- t_max_z = (((k + (1 if step_z > 0 else 0)) - z) / dz)
1859
- t_delta_z = abs(1.0 / dz)
1860
- else:
1861
- t_max_z = BIG; t_delta_z = BIG
1862
-
1863
- T = 1.0
1864
-
1865
- while True:
1866
- if (i < 0) or (i >= nx) or (j < 0) or (j >= ny) or (k < 0) or (k >= nz):
1867
- # Out of bounds: for exclusion mode return transmittance, else no hit
1868
- if inclusion_mode:
1869
- return 0.0
1870
- else:
1871
- return T
1872
-
1873
- if vox_is_opaque[i, j, k]:
1874
- return 0.0
1875
-
1876
- if vox_is_tree[i, j, k]:
1877
- T *= att
1878
- if T < att_cutoff:
1879
- return 0.0
1880
- if inclusion_mode and trees_are_targets:
1881
- # First tree encountered; contribution is partial visibility
1882
- return 1.0 - (T if T < 1.0 else 1.0)
1883
-
1884
- if inclusion_mode:
1885
- if (not vox_is_tree[i, j, k]) and vox_is_target[i, j, k]:
1886
- return 1.0
1887
- else:
1888
- # Exclusion: allow only allowed or tree; any other value blocks
1889
- if (not vox_is_tree[i, j, k]) and (not vox_is_allowed[i, j, k]):
1890
- return 0.0
1891
-
1892
- # Step DDA
1893
- if t_max_x < t_max_y:
1894
- if t_max_x < t_max_z:
1895
- t_max_x += t_delta_x; i += step_x
1896
- else:
1897
- t_max_z += t_delta_z; k += step_z
1898
- else:
1899
- if t_max_y < t_max_z:
1900
- t_max_y += t_delta_y; j += step_y
1901
- else:
1902
- t_max_z += t_delta_z; k += step_z
1903
-
1904
- @njit(parallel=True, cache=True, fastmath=True, nogil=True)
1905
- def _compute_view_factor_faces_chunk(face_centers, face_normals, hemisphere_dirs,
1906
- vox_is_tree, vox_is_target, vox_is_allowed, vox_is_opaque,
1907
- meshsize, att, att_cutoff,
1908
- grid_bounds_real, boundary_epsilon,
1909
- inclusion_mode, trees_are_targets):
1910
- n_faces = face_centers.shape[0]
1911
- out = np.empty(n_faces, dtype=np.float64)
1912
- for f in prange(n_faces):
1913
- center = face_centers[f]
1914
- normal = face_normals[f]
1915
-
1916
- # Boundary vertical exclusion
1917
- is_vertical = (abs(normal[2]) < 0.01)
1918
- on_x_min = (abs(center[0] - grid_bounds_real[0,0]) < boundary_epsilon)
1919
- on_y_min = (abs(center[1] - grid_bounds_real[0,1]) < boundary_epsilon)
1920
- on_x_max = (abs(center[0] - grid_bounds_real[1,0]) < boundary_epsilon)
1921
- on_y_max = (abs(center[1] - grid_bounds_real[1,1]) < boundary_epsilon)
1922
- if is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max):
1923
- out[f] = np.nan
1924
- continue
1925
-
1926
- u, v, n = _build_face_basis(normal)
1927
-
1928
- # Origin slightly outside face
1929
- ox = center[0] / meshsize + n[0] * 0.51
1930
- oy = center[1] / meshsize + n[1] * 0.51
1931
- oz = center[2] / meshsize + n[2] * 0.51
1932
- origin = np.array((ox, oy, oz))
1933
-
1934
- vis_sum = 0.0
1935
- valid = 0
1936
- for i in range(hemisphere_dirs.shape[0]):
1937
- lx = hemisphere_dirs[i,0]; ly = hemisphere_dirs[i,1]; lz = hemisphere_dirs[i,2]
1938
- # Transform local hemisphere (+Z up) into world; outward is +n
1939
- dx = u[0]*lx + v[0]*ly + n[0]*lz
1940
- dy = u[1]*lx + v[1]*ly + n[1]*lz
1941
- dz = u[2]*lx + v[2]*ly + n[2]*lz
1942
- # Only outward directions
1943
- if (dx*n[0] + dy*n[1] + dz*n[2]) <= 0.0:
1944
- continue
1945
- contrib = _ray_visibility_contrib(origin, np.array((dx, dy, dz)),
1946
- vox_is_tree, vox_is_target, vox_is_allowed, vox_is_opaque,
1947
- att, att_cutoff,
1948
- inclusion_mode, trees_are_targets)
1949
- vis_sum += contrib
1950
- valid += 1
1951
- out[f] = 0.0 if valid == 0 else (vis_sum / valid)
1952
- return out
1953
-
1954
- def _compute_view_factor_faces_progress(face_centers, face_normals, hemisphere_dirs,
1955
- vox_is_tree, vox_is_target, vox_is_allowed, vox_is_opaque,
1956
- meshsize, att, att_cutoff,
1957
- grid_bounds_real, boundary_epsilon,
1958
- inclusion_mode, trees_are_targets,
1959
- progress_report=False, chunks=10):
1960
- n_faces = face_centers.shape[0]
1961
- results = np.empty(n_faces, dtype=np.float64)
1962
- step = math.ceil(n_faces / chunks) if n_faces > 0 else 1
1963
- for start in range(0, n_faces, step):
1964
- end = min(start + step, n_faces)
1965
- results[start:end] = _compute_view_factor_faces_chunk(
1966
- face_centers[start:end], face_normals[start:end], hemisphere_dirs,
1967
- vox_is_tree, vox_is_target, vox_is_allowed, vox_is_opaque,
1968
- float(meshsize), float(att), float(att_cutoff),
1969
- grid_bounds_real, float(boundary_epsilon),
1970
- inclusion_mode, trees_are_targets
1971
- )
1972
- if progress_report:
1973
- pct = (end / n_faces) * 100 if n_faces > 0 else 100.0
1974
- print(f" Processed {end}/{n_faces} faces ({pct:.1f}%)")
1975
- return results
1976
-
1977
- # ==========================
1978
- # DDA ray traversal (fast)
1979
- # ==========================
1980
- @njit(cache=True, fastmath=True, nogil=True)
1981
- def _trace_ray(vox_is_tree, vox_is_opaque, origin, target, att, att_cutoff):
1982
- nx, ny, nz = vox_is_opaque.shape
1983
- x0, y0, z0 = origin[0], origin[1], origin[2]
1984
- x1, y1, z1 = target[0], target[1], target[2]
1985
-
1986
- dx = x1 - x0
1987
- dy = y1 - y0
1988
- dz = z1 - z0
1989
-
1990
- length = (dx*dx + dy*dy + dz*dz) ** 0.5
1991
- if length == 0.0:
1992
- return True
1993
- inv_len = 1.0 / length
1994
- dx *= inv_len; dy *= inv_len; dz *= inv_len
1995
-
1996
- x = x0 + 0.5
1997
- y = y0 + 0.5
1998
- z = z0 + 0.5
1999
- i = int(x0); j = int(y0); k = int(z0)
2000
-
2001
- step_x = 1 if dx >= 0.0 else -1
2002
- step_y = 1 if dy >= 0.0 else -1
2003
- step_z = 1 if dz >= 0.0 else -1
2004
-
2005
- BIG = 1e30
2006
-
2007
- if dx != 0.0:
2008
- t_max_x = (((i + (1 if step_x > 0 else 0)) - x) / dx)
2009
- t_delta_x = abs(1.0 / dx)
2010
- else:
2011
- t_max_x = BIG; t_delta_x = BIG
2012
-
2013
- if dy != 0.0:
2014
- t_max_y = (((j + (1 if step_y > 0 else 0)) - y) / dy)
2015
- t_delta_y = abs(1.0 / dy)
2016
- else:
2017
- t_max_y = BIG; t_delta_y = BIG
2018
-
2019
- if dz != 0.0:
2020
- t_max_z = (((k + (1 if step_z > 0 else 0)) - z) / dz)
2021
- t_delta_z = abs(1.0 / dz)
2022
- else:
2023
- t_max_z = BIG; t_delta_z = BIG
2024
-
2025
- T = 1.0
2026
- ti = int(x1); tj = int(y1); tk = int(z1)
2027
-
2028
- while True:
2029
- if (i < 0) or (i >= nx) or (j < 0) or (j >= ny) or (k < 0) or (k >= nz):
2030
- return False
2031
-
2032
- if vox_is_opaque[i, j, k]:
2033
- return False
2034
- if vox_is_tree[i, j, k]:
2035
- T *= att
2036
- if T < att_cutoff:
2037
- return False
2038
-
2039
- if (i == ti) and (j == tj) and (k == tk):
2040
- return True
2041
-
2042
- if t_max_x < t_max_y:
2043
- if t_max_x < t_max_z:
2044
- t_max_x += t_delta_x; i += step_x
2045
- else:
2046
- t_max_z += t_delta_z; k += step_z
2047
- else:
2048
- if t_max_y < t_max_z:
2049
- t_max_y += t_delta_y; j += step_y
2050
- else:
2051
- t_max_z += t_delta_z; k += step_z
2052
-
2053
-
2054
- # ==========================
2055
- # Per-face landmark visibility
2056
- # ==========================
2057
- @njit(cache=True, fastmath=True, nogil=True)
2058
- def _compute_face_visibility(face_center, face_normal,
2059
- landmark_positions_vox,
2060
- vox_is_tree, vox_is_opaque,
2061
- meshsize, att, att_cutoff,
2062
- grid_bounds_real, boundary_epsilon):
2063
- is_vertical = (abs(face_normal[2]) < 0.01)
2064
-
2065
- on_x_min = (abs(face_center[0] - grid_bounds_real[0,0]) < boundary_epsilon)
2066
- on_y_min = (abs(face_center[1] - grid_bounds_real[0,1]) < boundary_epsilon)
2067
- on_x_max = (abs(face_center[0] - grid_bounds_real[1,0]) < boundary_epsilon)
2068
- on_y_max = (abs(face_center[1] - grid_bounds_real[1,1]) < boundary_epsilon)
2069
-
2070
- if is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max):
2071
- return np.nan
2072
-
2073
- nx = face_normal[0]; ny = face_normal[1]; nz = face_normal[2]
2074
- nrm = (nx*nx + ny*ny + nz*nz) ** 0.5
2075
- if nrm < 1e-12:
2076
- return 0.0
2077
- invn = 1.0 / nrm
2078
- nx *= invn; ny *= invn; nz *= invn
2079
-
2080
- offset_vox = 0.1
2081
- ox = face_center[0] / meshsize + nx * offset_vox
2082
- oy = face_center[1] / meshsize + ny * offset_vox
2083
- oz = face_center[2] / meshsize + nz * offset_vox
2084
-
2085
- for idx in range(landmark_positions_vox.shape[0]):
2086
- tx = landmark_positions_vox[idx, 0]
2087
- ty = landmark_positions_vox[idx, 1]
2088
- tz = landmark_positions_vox[idx, 2]
2089
-
2090
- rx = tx - ox; ry = ty - oy; rz = tz - oz
2091
- rlen2 = rx*rx + ry*ry + rz*rz
2092
- if rlen2 == 0.0:
2093
- return 1.0
2094
- invr = 1.0 / (rlen2 ** 0.5)
2095
- rdx = rx * invr; rdy = ry * invr; rdz = rz * invr
2096
-
2097
- if (rdx*nx + rdy*ny + rdz*nz) <= 0.0:
2098
- continue
2099
-
2100
- if _trace_ray(vox_is_tree, vox_is_opaque,
2101
- np.array((ox, oy, oz)), np.array((tx, ty, tz)),
2102
- att, att_cutoff):
2103
- return 1.0
2104
-
2105
- return 0.0
2106
-
2107
- # ==========================
2108
- # Precompute voxel class masks
2109
- # ==========================
2110
- def _prepare_voxel_classes(voxel_data, landmark_value=-30):
2111
- is_tree = (voxel_data == -2)
2112
- is_opaque = (voxel_data != 0) & (voxel_data != landmark_value) & (~is_tree)
2113
- return is_tree, is_opaque
2114
-
2115
- # ==========================
2116
- # Chunked parallel loop for progress
2117
- # ==========================
2118
- def _compute_all_faces_progress(face_centers, face_normals, landmark_positions_vox,
2119
- vox_is_tree, vox_is_opaque,
2120
- meshsize, att, att_cutoff,
2121
- grid_bounds_real, boundary_epsilon,
2122
- progress_report=False, chunks=10):
2123
- n_faces = face_centers.shape[0]
2124
- results = np.empty(n_faces, dtype=np.float64)
2125
-
2126
- # Determine chunk size
2127
- step = math.ceil(n_faces / chunks)
2128
- for start in range(0, n_faces, step):
2129
- end = min(start + step, n_faces)
2130
- # Run parallel compute on this chunk
2131
- results[start:end] = _compute_faces_chunk(
2132
- face_centers[start:end],
2133
- face_normals[start:end],
2134
- landmark_positions_vox,
2135
- vox_is_tree, vox_is_opaque,
2136
- meshsize, att, att_cutoff,
2137
- grid_bounds_real, boundary_epsilon
2138
- )
2139
- if progress_report:
2140
- pct = (end / n_faces) * 100
2141
- print(f" Processed {end}/{n_faces} faces ({pct:.1f}%)")
2142
-
2143
- return results
2144
-
2145
-
2146
- @njit(parallel=True, cache=True, fastmath=True, nogil=True)
2147
- def _compute_faces_chunk(face_centers, face_normals, landmark_positions_vox,
2148
- vox_is_tree, vox_is_opaque,
2149
- meshsize, att, att_cutoff,
2150
- grid_bounds_real, boundary_epsilon):
2151
- n_faces = face_centers.shape[0]
2152
- out = np.empty(n_faces, dtype=np.float64)
2153
- for f in prange(n_faces):
2154
- out[f] = _compute_face_visibility(
2155
- face_centers[f], face_normals[f],
2156
- landmark_positions_vox,
2157
- vox_is_tree, vox_is_opaque,
2158
- meshsize, att, att_cutoff,
2159
- grid_bounds_real, boundary_epsilon
2160
- )
2161
- return out
2162
-
2163
-
2164
- # ==========================
2165
- # Main function
2166
- # ==========================
2167
- def get_surface_landmark_visibility(voxel_data, building_id_grid, building_gdf, meshsize, **kwargs):
2168
- import matplotlib.pyplot as plt
2169
- import os
2170
-
2171
- progress_report = kwargs.get("progress_report", False)
2172
-
2173
- # --- Landmark selection logic (unchanged) ---
2174
- landmark_ids = kwargs.get('landmark_building_ids', None)
2175
- landmark_polygon = kwargs.get('landmark_polygon', None)
2176
- if landmark_ids is None:
2177
- if landmark_polygon is not None:
2178
- landmark_ids = get_buildings_in_drawn_polygon(building_gdf, landmark_polygon, operation='within')
2179
- else:
2180
- rectangle_vertices = kwargs.get("rectangle_vertices", None)
2181
- if rectangle_vertices is None:
2182
- print("Cannot set landmark buildings. You need to input either of rectangle_vertices or landmark_ids.")
2183
- return None, None
2184
- lons = [coord[0] for coord in rectangle_vertices]
2185
- lats = [coord[1] for coord in rectangle_vertices]
2186
- center_lon = (min(lons) + max(lons)) / 2
2187
- center_lat = (min(lats) + max(lats)) / 2
2188
- target_point = (center_lon, center_lat)
2189
- landmark_ids = find_building_containing_point(building_gdf, target_point)
2190
-
2191
- building_class_id = kwargs.get("building_class_id", -3)
2192
- landmark_value = -30
2193
- tree_k = kwargs.get("tree_k", 0.6)
2194
- tree_lad = kwargs.get("tree_lad", 1.0)
2195
- colormap = kwargs.get("colormap", 'RdYlGn')
2196
-
2197
- voxel_data_for_mesh = voxel_data.copy()
2198
- voxel_data_modified = voxel_data.copy()
2199
-
2200
- voxel_data_modified = mark_building_by_id(voxel_data_modified, building_id_grid, landmark_ids, landmark_value)
2201
- voxel_data_for_mesh = mark_building_by_id(voxel_data_for_mesh, building_id_grid, landmark_ids, 0)
2202
-
2203
- landmark_positions = np.argwhere(voxel_data_modified == landmark_value).astype(np.float64)
2204
- if landmark_positions.shape[0] == 0:
2205
- print(f"No landmarks found after marking buildings with IDs: {landmark_ids}")
2206
- return None, None
2207
-
2208
- if progress_report:
2209
- print(f"Found {landmark_positions.shape[0]} landmark voxels")
2210
- print(f"Landmark building IDs: {landmark_ids}")
2211
-
2212
- try:
2213
- building_mesh = create_voxel_mesh(
2214
- voxel_data_for_mesh,
2215
- building_class_id,
2216
- meshsize,
2217
- building_id_grid=building_id_grid,
2218
- mesh_type='open_air'
2219
- )
2220
- if building_mesh is None or len(building_mesh.faces) == 0:
2221
- print("No non-landmark building surfaces found in voxel data.")
2222
- return None, None
2223
- except Exception as e:
2224
- print(f"Error during mesh extraction: {e}")
2225
- return None, None
2226
-
2227
- if progress_report:
2228
- print(f"Processing landmark visibility for {len(building_mesh.faces)} faces...")
2229
-
2230
- face_centers = building_mesh.triangles_center.astype(np.float64)
2231
- face_normals = building_mesh.face_normals.astype(np.float64)
2232
-
2233
- nx, ny, nz = voxel_data_modified.shape
2234
- grid_bounds_voxel = np.array([[0,0,0],[nx, ny, nz]], dtype=np.float64)
2235
- grid_bounds_real = grid_bounds_voxel * meshsize
2236
- boundary_epsilon = meshsize * 0.05
2237
-
2238
- # Precompute masks + attenuation
2239
- vox_is_tree, vox_is_opaque = _prepare_voxel_classes(voxel_data_modified, landmark_value)
2240
- att = float(np.exp(-tree_k * tree_lad * meshsize))
2241
- att_cutoff = 0.01
2242
-
2243
- visibility_values = _compute_all_faces_progress(
2244
- face_centers,
2245
- face_normals,
2246
- landmark_positions,
2247
- vox_is_tree, vox_is_opaque,
2248
- float(meshsize), att, att_cutoff,
2249
- grid_bounds_real.astype(np.float64),
2250
- float(boundary_epsilon),
2251
- progress_report=progress_report
2252
- )
2253
-
2254
- building_mesh.metadata = getattr(building_mesh, 'metadata', {})
2255
- building_mesh.metadata['landmark_visibility'] = visibility_values
2256
-
2257
- valid_mask = ~np.isnan(visibility_values)
2258
- n_valid = np.sum(valid_mask)
2259
- n_visible = np.sum(visibility_values[valid_mask] > 0.5)
2260
-
2261
- if progress_report:
2262
- print(f"Landmark visibility statistics:")
2263
- print(f" Total faces: {len(visibility_values)}")
2264
- print(f" Valid faces: {n_valid}")
2265
- print(f" Faces with landmark visibility: {n_visible} ({n_visible/n_valid*100:.1f}%)")
2266
-
2267
- obj_export = kwargs.get("obj_export", False)
2268
- if obj_export:
2269
- output_dir = kwargs.get("output_directory", "output")
2270
- output_file_name = kwargs.get("output_file_name", "surface_landmark_visibility")
2271
- os.makedirs(output_dir, exist_ok=True)
2272
- try:
2273
- cmap = plt.cm.get_cmap(colormap)
2274
- face_colors = np.zeros((len(visibility_values), 4))
2275
- for i, val in enumerate(visibility_values):
2276
- if np.isnan(val):
2277
- face_colors[i] = [0.7, 0.7, 0.7, 1.0]
2278
- else:
2279
- face_colors[i] = cmap(val)
2280
- building_mesh.visual.face_colors = face_colors
2281
- building_mesh.export(f"{output_dir}/{output_file_name}.obj")
2282
- print(f"Exported surface mesh to {output_dir}/{output_file_name}.obj")
2283
- except Exception as e:
2284
- print(f"Error exporting mesh: {e}")
2285
-
2286
- return building_mesh, voxel_data_modified
1
+ """Compatibility wrapper for the legacy view module.
2
+
3
+ The implementation has been split into the `visibility` package:
4
+ - voxcity.simulator.visibility.raytracing
5
+ - voxcity.simulator.visibility.geometry
6
+ - voxcity.simulator.visibility.view
7
+ - voxcity.simulator.visibility.landmark
8
+
9
+ Import the new API from `voxcity.simulator.visibility`.
10
+ This module re-exports the main public functions for backward compatibility.
11
+ """
12
+
13
+ from .visibility.view import (
14
+ get_view_index,
15
+ get_sky_view_factor_map,
16
+ get_surface_view_factor,
17
+ )
18
+ from .visibility.landmark import (
19
+ mark_building_by_id,
20
+ compute_landmark_visibility,
21
+ get_landmark_visibility_map,
22
+ get_surface_landmark_visibility,
23
+ )
24
+ from .common.geometry import rotate_vector_axis_angle
25
+
26
+ __all__ = [
27
+ "get_view_index",
28
+ "get_sky_view_factor_map",
29
+ "get_surface_view_factor",
30
+ "mark_building_by_id",
31
+ "compute_landmark_visibility",
32
+ "get_landmark_visibility_map",
33
+ "get_surface_landmark_visibility",
34
+ "rotate_vector_axis_angle",
35
+ ]
36
+