|
| 1 | +"""Sky-view factor (SVF) for digital elevation models. |
| 2 | +
|
| 3 | +SVF measures the fraction of the sky hemisphere visible from each cell, |
| 4 | +ranging from 0 (fully obstructed) to 1 (flat open terrain). For each |
| 5 | +cell, rays are cast at *n_directions* evenly spaced azimuths out to |
| 6 | +*max_radius* cells. Along each ray the maximum elevation angle to the |
| 7 | +horizon is recorded and SVF is computed as: |
| 8 | +
|
| 9 | + SVF(i,j) = 1 - mean(sin(max_horizon_angle(d)) for d in directions) |
| 10 | +
|
| 11 | +References |
| 12 | +---------- |
| 13 | +Zakek, K., Ostir, K., Kokalj, Z. (2011). Sky-View Factor as a Relief |
| 14 | +Visualization Technique. Remote Sensing, 3(2), 398-415. |
| 15 | +
|
| 16 | +Yokoyama, R., Sidle, R.C., Noguchi, S. (2002). Application of |
| 17 | +Topographic Shading to Terrain Visualization. International Journal |
| 18 | +of Geographical Information Science, 16(5), 489-502. |
| 19 | +""" |
| 20 | +from __future__ import annotations |
| 21 | + |
| 22 | +from functools import partial |
| 23 | +from math import atan2 as _atan2, cos as _cos, pi as _pi, sin as _sin, sqrt as _sqrt |
| 24 | +from typing import Optional |
| 25 | + |
| 26 | +import numpy as np |
| 27 | +import xarray as xr |
| 28 | +from numba import cuda |
| 29 | + |
| 30 | +try: |
| 31 | + import cupy |
| 32 | +except ImportError: |
| 33 | + class cupy: |
| 34 | + ndarray = False |
| 35 | + |
| 36 | +try: |
| 37 | + import dask.array as da |
| 38 | +except ImportError: |
| 39 | + da = None |
| 40 | + |
| 41 | +from xrspatial.dataset_support import supports_dataset |
| 42 | +from xrspatial.utils import ( |
| 43 | + ArrayTypeFunctionMapping, |
| 44 | + _boundary_to_dask, |
| 45 | + _validate_raster, |
| 46 | + _validate_scalar, |
| 47 | + cuda_args, |
| 48 | + ngjit, |
| 49 | +) |
| 50 | + |
| 51 | + |
| 52 | +# --------------------------------------------------------------------------- |
| 53 | +# CPU kernel |
| 54 | +# --------------------------------------------------------------------------- |
| 55 | + |
| 56 | +@ngjit |
| 57 | +def _svf_cpu(data, max_radius, n_directions): |
| 58 | + """Compute SVF over an entire 2-D array on the CPU.""" |
| 59 | + rows, cols = data.shape |
| 60 | + out = np.empty((rows, cols), dtype=np.float64) |
| 61 | + out[:] = np.nan |
| 62 | + |
| 63 | + for y in range(rows): |
| 64 | + for x in range(cols): |
| 65 | + center = data[y, x] |
| 66 | + if center != center: # NaN check |
| 67 | + continue |
| 68 | + |
| 69 | + svf_sum = 0.0 |
| 70 | + for d in range(n_directions): |
| 71 | + angle = 2.0 * _pi * d / n_directions |
| 72 | + dx = _cos(angle) |
| 73 | + dy = _sin(angle) |
| 74 | + |
| 75 | + max_elev_angle = 0.0 |
| 76 | + for r in range(1, max_radius + 1): |
| 77 | + sx = x + int(round(r * dx)) |
| 78 | + sy = y + int(round(r * dy)) |
| 79 | + if sx < 0 or sx >= cols or sy < 0 or sy >= rows: |
| 80 | + break |
| 81 | + elev = data[sy, sx] |
| 82 | + if elev != elev: # NaN |
| 83 | + break |
| 84 | + dz = elev - center |
| 85 | + dist = float(r) |
| 86 | + elev_angle = _atan2(dz, dist) |
| 87 | + if elev_angle > max_elev_angle: |
| 88 | + max_elev_angle = elev_angle |
| 89 | + |
| 90 | + svf_sum += _sin(max_elev_angle) |
| 91 | + |
| 92 | + out[y, x] = 1.0 - svf_sum / n_directions |
| 93 | + return out |
| 94 | + |
| 95 | + |
| 96 | +# --------------------------------------------------------------------------- |
| 97 | +# GPU kernels |
| 98 | +# --------------------------------------------------------------------------- |
| 99 | + |
| 100 | +@cuda.jit |
| 101 | +def _svf_gpu(data, out, max_radius, n_directions): |
| 102 | + """CUDA global kernel: one thread per cell.""" |
| 103 | + y, x = cuda.grid(2) |
| 104 | + rows, cols = data.shape |
| 105 | + |
| 106 | + if y >= rows or x >= cols: |
| 107 | + return |
| 108 | + |
| 109 | + center = data[y, x] |
| 110 | + if center != center: # NaN |
| 111 | + return |
| 112 | + |
| 113 | + svf_sum = 0.0 |
| 114 | + pi2 = 2.0 * 3.141592653589793 |
| 115 | + |
| 116 | + for d in range(n_directions): |
| 117 | + angle = pi2 * d / n_directions |
| 118 | + dx = _cos(angle) |
| 119 | + dy = _sin(angle) |
| 120 | + |
| 121 | + max_elev_angle = 0.0 |
| 122 | + for r in range(1, max_radius + 1): |
| 123 | + sx = x + int(round(r * dx)) |
| 124 | + sy = y + int(round(r * dy)) |
| 125 | + if sx < 0 or sx >= cols or sy < 0 or sy >= rows: |
| 126 | + break |
| 127 | + elev = data[sy, sx] |
| 128 | + if elev != elev: # NaN |
| 129 | + break |
| 130 | + dz = elev - center |
| 131 | + dist = float(r) |
| 132 | + elev_angle = _atan2(dz, dist) |
| 133 | + if elev_angle > max_elev_angle: |
| 134 | + max_elev_angle = elev_angle |
| 135 | + |
| 136 | + svf_sum += _sin(max_elev_angle) |
| 137 | + |
| 138 | + out[y, x] = 1.0 - svf_sum / n_directions |
| 139 | + |
| 140 | + |
| 141 | +# --------------------------------------------------------------------------- |
| 142 | +# Backend wrappers |
| 143 | +# --------------------------------------------------------------------------- |
| 144 | + |
| 145 | +def _run_numpy(data, max_radius, n_directions): |
| 146 | + data = data.astype(np.float64) |
| 147 | + return _svf_cpu(data, max_radius, n_directions) |
| 148 | + |
| 149 | + |
| 150 | +def _run_cupy(data, max_radius, n_directions): |
| 151 | + data = data.astype(cupy.float64) |
| 152 | + out = cupy.full(data.shape, cupy.nan, dtype=cupy.float64) |
| 153 | + griddim, blockdim = cuda_args(data.shape) |
| 154 | + _svf_gpu[griddim, blockdim](data, out, max_radius, n_directions) |
| 155 | + return out |
| 156 | + |
| 157 | + |
| 158 | +def _run_dask_numpy(data, max_radius, n_directions): |
| 159 | + data = data.astype(np.float64) |
| 160 | + _func = partial(_svf_cpu, max_radius=max_radius, n_directions=n_directions) |
| 161 | + out = data.map_overlap( |
| 162 | + _func, |
| 163 | + depth=(max_radius, max_radius), |
| 164 | + boundary=np.nan, |
| 165 | + meta=np.array(()), |
| 166 | + ) |
| 167 | + return out |
| 168 | + |
| 169 | + |
| 170 | +def _run_dask_cupy(data, max_radius, n_directions): |
| 171 | + data = data.astype(cupy.float64) |
| 172 | + _func = partial(_run_cupy, max_radius=max_radius, n_directions=n_directions) |
| 173 | + out = data.map_overlap( |
| 174 | + _func, |
| 175 | + depth=(max_radius, max_radius), |
| 176 | + boundary=cupy.nan, |
| 177 | + meta=cupy.array(()), |
| 178 | + ) |
| 179 | + return out |
| 180 | + |
| 181 | + |
| 182 | +# --------------------------------------------------------------------------- |
| 183 | +# Public API |
| 184 | +# --------------------------------------------------------------------------- |
| 185 | + |
| 186 | +@supports_dataset |
| 187 | +def sky_view_factor( |
| 188 | + agg: xr.DataArray, |
| 189 | + max_radius: int = 10, |
| 190 | + n_directions: int = 16, |
| 191 | + name: Optional[str] = 'sky_view_factor', |
| 192 | +) -> xr.DataArray: |
| 193 | + """Compute the sky-view factor for each cell of a DEM. |
| 194 | +
|
| 195 | + SVF measures the fraction of the sky hemisphere visible from each |
| 196 | + cell on a scale from 0 (fully obstructed) to 1 (flat open terrain). |
| 197 | + Rays are cast at *n_directions* evenly spaced azimuths out to |
| 198 | + *max_radius* cells, and the maximum elevation angle along each |
| 199 | + ray determines the horizon obstruction. |
| 200 | +
|
| 201 | + Parameters |
| 202 | + ---------- |
| 203 | + agg : xarray.DataArray or xr.Dataset |
| 204 | + 2D NumPy, CuPy, NumPy-backed Dask, or CuPy-backed Dask |
| 205 | + xarray DataArray of elevation values. |
| 206 | + If a Dataset is passed, the operation is applied to each |
| 207 | + data variable independently. |
| 208 | + max_radius : int, default=10 |
| 209 | + Maximum search distance in cells along each ray direction. |
| 210 | + Cells within *max_radius* of the raster edge will be NaN. |
| 211 | + n_directions : int, default=16 |
| 212 | + Number of azimuth directions to sample, evenly spaced |
| 213 | + around 360 degrees. |
| 214 | + name : str, default='sky_view_factor' |
| 215 | + Name of the output DataArray. |
| 216 | +
|
| 217 | + Returns |
| 218 | + ------- |
| 219 | + xarray.DataArray or xr.Dataset |
| 220 | + 2D array of SVF values in [0, 1] with the same shape, coords, |
| 221 | + dims, and attrs as the input. Edge cells use truncated rays |
| 222 | + that stop at the raster boundary. |
| 223 | +
|
| 224 | + References |
| 225 | + ---------- |
| 226 | + Zakek, K., Ostir, K., Kokalj, Z. (2011). Sky-View Factor as a |
| 227 | + Relief Visualization Technique. Remote Sensing, 3(2), 398-415. |
| 228 | +
|
| 229 | + Examples |
| 230 | + -------- |
| 231 | + >>> from xrspatial import sky_view_factor |
| 232 | + >>> svf = sky_view_factor(dem, max_radius=100, n_directions=16) |
| 233 | + """ |
| 234 | + _validate_raster(agg, func_name='sky_view_factor', name='agg') |
| 235 | + _validate_scalar(max_radius, func_name='sky_view_factor', |
| 236 | + name='max_radius', dtype=int, min_val=1) |
| 237 | + _validate_scalar(n_directions, func_name='sky_view_factor', |
| 238 | + name='n_directions', dtype=int, min_val=1) |
| 239 | + |
| 240 | + mapper = ArrayTypeFunctionMapping( |
| 241 | + numpy_func=_run_numpy, |
| 242 | + cupy_func=_run_cupy, |
| 243 | + dask_func=_run_dask_numpy, |
| 244 | + dask_cupy_func=_run_dask_cupy, |
| 245 | + ) |
| 246 | + out = mapper(agg)(agg.data, max_radius, n_directions) |
| 247 | + return xr.DataArray( |
| 248 | + out, |
| 249 | + name=name, |
| 250 | + coords=agg.coords, |
| 251 | + dims=agg.dims, |
| 252 | + attrs=agg.attrs, |
| 253 | + ) |
0 commit comments