Skip to content

Commit 4b8462f

Browse files
authored
Fixes #845: add configurable boundary mode to kernel-based spatial ops (#874)
Add a `boundary` parameter ('nan', 'nearest', 'reflect', 'wrap') to slope, aspect, curvature, hillshade, convolve_2d, mean, apply, focal_stats, and hotspots. Default is 'nan' (backward compatible). Uses pad-compute-trim for numpy/cupy backends and passes boundary mode through to dask map_overlap. Adds shared utilities (_validate_boundary, _boundary_to_dask, _pad_array) in utils.py. Includes parametrized cross-backend tests covering multiple array sizes, chunk sizes, and boundary modes to verify numpy-vs-dask consistency.
1 parent 568498e commit 4b8462f

13 files changed

Lines changed: 840 additions & 101 deletions

xrspatial/aspect.py

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616

1717
from xrspatial.utils import ArrayTypeFunctionMapping
1818
from xrspatial.utils import Z_UNITS
19+
from xrspatial.utils import _boundary_to_dask
1920
from xrspatial.utils import _extract_latlon_coords
21+
from xrspatial.utils import _pad_array
22+
from xrspatial.utils import _validate_boundary
2023
from xrspatial.utils import cuda_args
2124
from xrspatial.utils import ngjit
2225
from xrspatial.dataset_support import supports_dataset
@@ -54,7 +57,7 @@ class cupy(object):
5457
# =====================================================================
5558

5659
@ngjit
57-
def _run_numpy(data: np.ndarray):
60+
def _cpu(data: np.ndarray):
5861
data = data.astype(np.float32)
5962
out = np.zeros_like(data, dtype=np.float32)
6063
out[:] = np.nan
@@ -90,6 +93,14 @@ def _run_numpy(data: np.ndarray):
9093
return out
9194

9295

96+
def _run_numpy(data: np.ndarray, boundary: str = 'nan') -> np.ndarray:
97+
if boundary == 'nan':
98+
return _cpu(data)
99+
padded = _pad_array(data, 1, boundary)
100+
result = _cpu(padded)
101+
return result[1:-1, 1:-1]
102+
103+
93104
@cuda.jit(device=True)
94105
def _gpu(arr):
95106

@@ -136,7 +147,12 @@ def _run_gpu(arr, out):
136147
out[i, j] = _gpu(arr[i-di:i+di+1, j-dj:j+dj+1])
137148

138149

139-
def _run_cupy(data: cupy.ndarray) -> cupy.ndarray:
150+
def _run_cupy(data: cupy.ndarray, boundary: str = 'nan') -> cupy.ndarray:
151+
if boundary != 'nan':
152+
padded = _pad_array(data, 1, boundary)
153+
result = _run_cupy(padded)
154+
return result[1:-1, 1:-1]
155+
140156
data = data.astype(cupy.float32)
141157
griddim, blockdim = cuda_args(data.shape)
142158
out = cupy.empty(data.shape, dtype='f4')
@@ -145,22 +161,22 @@ def _run_cupy(data: cupy.ndarray) -> cupy.ndarray:
145161
return out
146162

147163

148-
def _run_dask_numpy(data: da.Array) -> da.Array:
164+
def _run_dask_numpy(data: da.Array, boundary: str = 'nan') -> da.Array:
149165
data = data.astype(np.float32)
150-
_func = partial(_run_numpy)
166+
_func = partial(_cpu)
151167
out = data.map_overlap(_func,
152168
depth=(1, 1),
153-
boundary=np.nan,
169+
boundary=_boundary_to_dask(boundary),
154170
meta=np.array(()))
155171
return out
156172

157173

158-
def _run_dask_cupy(data: da.Array) -> da.Array:
174+
def _run_dask_cupy(data: da.Array, boundary: str = 'nan') -> da.Array:
159175
data = data.astype(cupy.float32)
160176
_func = partial(_run_cupy)
161177
out = data.map_overlap(_func,
162178
depth=(1, 1),
163-
boundary=cupy.nan,
179+
boundary=_boundary_to_dask(boundary, is_cupy=True),
164180
meta=cupy.array(()))
165181
return out
166182

@@ -169,7 +185,14 @@ def _run_dask_cupy(data: da.Array) -> da.Array:
169185
# Geodesic backend functions
170186
# =====================================================================
171187

172-
def _run_numpy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
188+
def _run_numpy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor, boundary='nan'):
189+
if boundary != 'nan':
190+
data_p = _pad_array(data.astype(np.float64), 1, boundary)
191+
lat_p = _pad_array(lat_2d, 1, boundary)
192+
lon_p = _pad_array(lon_2d, 1, boundary)
193+
stacked = np.stack([data_p, lat_p, lon_p], axis=0)
194+
result = _cpu_geodesic_aspect(stacked, a2, b2, z_factor)
195+
return result[1:-1, 1:-1]
173196
stacked = np.stack([
174197
data.astype(np.float64),
175198
lat_2d,
@@ -178,7 +201,22 @@ def _run_numpy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
178201
return _cpu_geodesic_aspect(stacked, a2, b2, z_factor)
179202

180203

181-
def _run_cupy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
204+
def _run_cupy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor, boundary='nan'):
205+
if boundary != 'nan':
206+
data_p = _pad_array(data.astype(cupy.float64), 1, boundary)
207+
lat_p = _pad_array(cupy.asarray(lat_2d, dtype=cupy.float64), 1, boundary)
208+
lon_p = _pad_array(cupy.asarray(lon_2d, dtype=cupy.float64), 1, boundary)
209+
stacked = cupy.stack([data_p, lat_p, lon_p], axis=0)
210+
H, W = data_p.shape
211+
out = cupy.full((H, W), cupy.nan, dtype=cupy.float32)
212+
a2_arr = cupy.array([a2], dtype=cupy.float64)
213+
b2_arr = cupy.array([b2], dtype=cupy.float64)
214+
zf_arr = cupy.array([z_factor], dtype=cupy.float64)
215+
inv_2r_arr = cupy.array([INV_2R], dtype=cupy.float64)
216+
griddim, blockdim = _geodesic_cuda_dims((H, W))
217+
_run_gpu_geodesic_aspect[griddim, blockdim](stacked, a2_arr, b2_arr, zf_arr, inv_2r_arr, out)
218+
return out[1:-1, 1:-1]
219+
182220
lat_2d_gpu = cupy.asarray(lat_2d, dtype=cupy.float64)
183221
lon_2d_gpu = cupy.asarray(lon_2d, dtype=cupy.float64)
184222
stacked = cupy.stack([
@@ -227,7 +265,7 @@ def _dask_geodesic_aspect_chunk_cupy(stacked_chunk, a2, b2, z_factor):
227265
return out
228266

229267

230-
def _run_dask_numpy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
268+
def _run_dask_numpy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor, boundary='nan'):
231269
lat_dask = da.from_array(lat_2d, chunks=data.chunksize)
232270
lon_dask = da.from_array(lon_2d, chunks=data.chunksize)
233271
stacked = da.stack([
@@ -237,16 +275,17 @@ def _run_dask_numpy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
237275
], axis=0).rechunk({0: 3})
238276

239277
_func = partial(_dask_geodesic_aspect_chunk, a2=a2, b2=b2, z_factor=z_factor)
278+
dask_bnd = _boundary_to_dask(boundary)
240279
out = stacked.map_overlap(
241280
_func,
242281
depth=(0, 1, 1),
243-
boundary=np.nan,
282+
boundary={0: np.nan, 1: dask_bnd, 2: dask_bnd},
244283
meta=np.array((), dtype=np.float32),
245284
)
246285
return out[0]
247286

248287

249-
def _run_dask_cupy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
288+
def _run_dask_cupy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor, boundary='nan'):
250289
lat_dask = da.from_array(cupy.asarray(lat_2d, dtype=cupy.float64),
251290
chunks=data.chunksize)
252291
lon_dask = da.from_array(cupy.asarray(lon_2d, dtype=cupy.float64),
@@ -258,10 +297,11 @@ def _run_dask_cupy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
258297
], axis=0).rechunk({0: 3})
259298

260299
_func = partial(_dask_geodesic_aspect_chunk_cupy, a2=a2, b2=b2, z_factor=z_factor)
300+
dask_bnd = _boundary_to_dask(boundary, is_cupy=True)
261301
out = stacked.map_overlap(
262302
_func,
263303
depth=(0, 1, 1),
264-
boundary=cupy.nan,
304+
boundary={0: cupy.nan, 1: dask_bnd, 2: dask_bnd},
265305
meta=cupy.array((), dtype=cupy.float32),
266306
)
267307
return out[0]
@@ -275,7 +315,8 @@ def _run_dask_cupy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
275315
def aspect(agg: xr.DataArray,
276316
name: Optional[str] = 'aspect',
277317
method: str = 'planar',
278-
z_unit: str = 'meter') -> xr.DataArray:
318+
z_unit: str = 'meter',
319+
boundary: str = 'nan') -> xr.DataArray:
279320
"""
280321
Calculates the aspect value of an elevation aggregate.
281322
@@ -314,6 +355,12 @@ def aspect(agg: xr.DataArray,
314355
Unit of the elevation values. Only used when ``method='geodesic'``.
315356
Accepted values: ``'meter'``, ``'foot'``, ``'kilometer'``, ``'mile'``
316357
(and common aliases).
358+
boundary : str, default='nan'
359+
How to handle edges where the kernel extends beyond the raster.
360+
``'nan'`` — fill missing neighbours with NaN (default).
361+
``'nearest'`` — repeat edge values.
362+
``'reflect'`` — mirror at boundary.
363+
``'wrap'`` — periodic / toroidal.
317364
318365
Returns
319366
-------
@@ -353,6 +400,7 @@ def aspect(agg: xr.DataArray,
353400
raise ValueError(
354401
f"method must be 'planar' or 'geodesic', got {method!r}"
355402
)
403+
_validate_boundary(boundary)
356404

357405
if method == 'planar':
358406
mapper = ArrayTypeFunctionMapping(
@@ -361,7 +409,7 @@ def aspect(agg: xr.DataArray,
361409
cupy_func=_run_cupy,
362410
dask_cupy_func=_run_dask_cupy,
363411
)
364-
out = mapper(agg)(agg.data)
412+
out = mapper(agg)(agg.data, boundary=boundary)
365413

366414
else: # geodesic
367415
if z_unit not in Z_UNITS:
@@ -379,7 +427,7 @@ def aspect(agg: xr.DataArray,
379427
dask_func=_run_dask_numpy_geodesic,
380428
dask_cupy_func=_run_dask_cupy_geodesic,
381429
)
382-
out = mapper(agg)(agg.data, lat_2d, lon_2d, WGS84_A2, WGS84_B2, z_factor)
430+
out = mapper(agg)(agg.data, lat_2d, lon_2d, WGS84_A2, WGS84_B2, z_factor, boundary)
383431

384432
return xr.DataArray(out,
385433
name=name,

xrspatial/convolution.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import xarray as xr
66
from numba import cuda, jit, prange
77

8-
from xrspatial.utils import (ArrayTypeFunctionMapping, cuda_args, get_dataarray_resolution,
8+
from xrspatial.utils import (ArrayTypeFunctionMapping, _boundary_to_dask, _pad_array,
9+
_validate_boundary, cuda_args, get_dataarray_resolution,
910
not_implemented_func)
1011

1112
# 3rd-party
@@ -313,14 +314,28 @@ def _convolve_2d_numpy(data, kernel):
313314
return out
314315

315316

316-
def _convolve_2d_dask_numpy(data, kernel):
317+
def _convolve_2d_numpy_boundary(data, kernel, boundary='nan'):
318+
if boundary == 'nan':
319+
return _convolve_2d_numpy(data, kernel)
320+
pad_h = kernel.shape[0] // 2
321+
pad_w = kernel.shape[1] // 2
322+
padded = _pad_array(data, (pad_h, pad_w), boundary)
323+
result = _convolve_2d_numpy(padded, kernel)
324+
r0 = pad_h if pad_h else None
325+
r1 = -pad_h if pad_h else None
326+
c0 = pad_w if pad_w else None
327+
c1 = -pad_w if pad_w else None
328+
return result[r0:r1, c0:c1]
329+
330+
331+
def _convolve_2d_dask_numpy(data, kernel, boundary='nan'):
317332
data = data.astype(np.float32)
318333
pad_h = kernel.shape[0] // 2
319334
pad_w = kernel.shape[1] // 2
320335
_func = partial(_convolve_2d_numpy, kernel=kernel)
321336
out = data.map_overlap(_func,
322337
depth=(pad_h, pad_w),
323-
boundary=np.nan,
338+
boundary=_boundary_to_dask(boundary),
324339
meta=np.array(()))
325340
return out
326341

@@ -365,7 +380,18 @@ def _convolve_2d_cuda(data, kernel, out):
365380
out[i, j] = s
366381

367382

368-
def _convolve_2d_cupy(data, kernel):
383+
def _convolve_2d_cupy(data, kernel, boundary='nan'):
384+
if boundary != 'nan':
385+
pad_h = kernel.shape[0] // 2
386+
pad_w = kernel.shape[1] // 2
387+
padded = _pad_array(data, (pad_h, pad_w), boundary)
388+
result = _convolve_2d_cupy(padded, kernel)
389+
r0 = pad_h if pad_h else None
390+
r1 = -pad_h if pad_h else None
391+
c0 = pad_w if pad_w else None
392+
c1 = -pad_w if pad_w else None
393+
return result[r0:r1, c0:c1]
394+
369395
data = data.astype(cupy.float32)
370396
out = cupy.empty(data.shape, dtype='f4')
371397
out[:, :] = cupy.nan
@@ -374,30 +400,31 @@ def _convolve_2d_cupy(data, kernel):
374400
return out
375401

376402

377-
def _convolve_2d_dask_cupy(data, kernel):
403+
def _convolve_2d_dask_cupy(data, kernel, boundary='nan'):
378404
data = data.astype(cupy.float32)
379405
pad_h = kernel.shape[0] // 2
380406
pad_w = kernel.shape[1] // 2
381407
_func = partial(_convolve_2d_cupy, kernel=kernel)
382408
out = data.map_overlap(_func,
383409
depth=(pad_h, pad_w),
384-
boundary=cupy.nan,
410+
boundary=_boundary_to_dask(boundary, is_cupy=True),
385411
meta=cupy.array(()))
386412
return out
387413

388414

389-
def convolve_2d(data, kernel):
415+
def convolve_2d(data, kernel, boundary='nan'):
416+
_validate_boundary(boundary)
390417
mapper = ArrayTypeFunctionMapping(
391-
numpy_func=_convolve_2d_numpy,
418+
numpy_func=_convolve_2d_numpy_boundary,
392419
cupy_func=_convolve_2d_cupy,
393420
dask_func=_convolve_2d_dask_numpy,
394421
dask_cupy_func=_convolve_2d_dask_cupy
395422
)
396-
out = mapper(xr.DataArray(data))(data, kernel)
423+
out = mapper(xr.DataArray(data))(data, kernel, boundary)
397424
return out
398425

399426

400-
def convolution_2d(agg, kernel, name='convolution_2d'):
427+
def convolution_2d(agg, kernel, name='convolution_2d', boundary='nan'):
401428
"""
402429
Calculates, for all inner cells of an array, the 2D convolution of
403430
each cell. Convolution is frequently used for image
@@ -413,6 +440,12 @@ def convolution_2d(agg, kernel, name='convolution_2d'):
413440
kernel : array-like object
414441
Impulse kernel, determines area to apply impulse function for
415442
each cell.
443+
boundary : str, default='nan'
444+
How to handle edges where the kernel extends beyond the raster.
445+
``'nan'`` -- fill missing neighbours with NaN (default).
446+
``'nearest'`` -- repeat edge values.
447+
``'reflect'`` -- mirror at boundary.
448+
``'wrap'`` -- periodic / toroidal.
416449
417450
Returns
418451
-------
@@ -513,7 +546,7 @@ def convolution_2d(agg, kernel, name='convolution_2d'):
513546
"""
514547

515548
# wrapper of convolve_2d
516-
out = convolve_2d(agg.data, kernel)
549+
out = convolve_2d(agg.data, kernel, boundary)
517550
return xr.DataArray(out,
518551
name=name,
519552
coords=agg.coords,

0 commit comments

Comments
 (0)