voxcity 0.6.1__py3-none-any.whl → 0.6.3__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,2239 @@
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
+ @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
+
2239
2239
  return building_mesh, voxel_data_modified