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