voxcity 0.6.3__py3-none-any.whl → 0.6.5__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.

Potentially problematic release.


This version of voxcity might be problematic. Click here for more details.

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