Skip to content

Commit 95c7909

Browse files
committed
Fix three accuracy bugs in reproject resampling kernels (#1086)
1. Nearest-neighbor: use floor(r + 0.5) instead of int(r + 0.5) to round correctly for negative fractional pixel coordinates. int() truncates toward zero, which maps [-1.0, -0.5) to pixel 0 instead of out-of-bounds. 2. Cubic: check real neighbor indices against source bounds instead of clamping OOB indices to the edge pixel. Border replication masked the OOB condition, preventing the bilinear fallback that GDAL uses when the 4x4 stencil extends outside the source. 3. CuPy map_coordinates fallback: tighten the NaN contamination threshold from 0.1 to 1e-6 so that small NaN weights from distant cubic neighbors are caught, matching the native CUDA kernel behavior.
1 parent 268618d commit 95c7909

File tree

2 files changed

+78
-32
lines changed

2 files changed

+78
-32
lines changed

xrspatial/reproject/_interpolate.py

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,11 @@ def _resample_nearest_jit(src, row_coords, col_coords, nodata):
4646
if r < -1.0 or r > sh or c < -1.0 or c > sw:
4747
out[i, j] = nodata
4848
continue
49-
ri = int(r + 0.5)
50-
ci = int(c + 0.5)
51-
if ri < 0:
52-
ri = 0
53-
if ri >= sh:
54-
ri = sh - 1
55-
if ci < 0:
56-
ci = 0
57-
if ci >= sw:
58-
ci = sw - 1
49+
ri = int(np.floor(r + 0.5))
50+
ci = int(np.floor(c + 0.5))
51+
if ri < 0 or ri >= sh or ci < 0 or ci >= sw:
52+
out[i, j] = nodata
53+
continue
5954
v = src[ri, ci]
6055
# NaN check: works for float64
6156
if v != v:
@@ -109,21 +104,17 @@ def _resample_cubic_jit(src, row_coords, col_coords, nodata):
109104
has_nan = False
110105
for di in range(4):
111106
ri = r0 - 1 + di
112-
ric = ri
113-
if ric < 0:
114-
ric = 0
115-
elif ric >= sh:
116-
ric = sh - 1
107+
if ri < 0 or ri >= sh:
108+
has_nan = True
109+
break
117110
# Interpolate along columns for this row
118111
rv = 0.0
119112
for dj in range(4):
120113
cj = c0 - 1 + dj
121-
cjc = cj
122-
if cjc < 0:
123-
cjc = 0
124-
elif cjc >= sw:
125-
cjc = sw - 1
126-
sv = src[ric, cjc]
114+
if cj < 0 or cj >= sw:
115+
has_nan = True
116+
break
117+
sv = src[ri, cj]
127118
if sv != sv: # NaN check
128119
has_nan = True
129120
break
@@ -339,16 +330,11 @@ def _resample_nearest_cuda(src, row_coords, col_coords, out, nodata):
339330
if r < -1.0 or r > sh or c < -1.0 or c > sw:
340331
out[i, j] = nodata
341332
return
342-
ri = int(r + 0.5)
343-
ci = int(c + 0.5)
344-
if ri < 0:
345-
ri = 0
346-
if ri >= sh:
347-
ri = sh - 1
348-
if ci < 0:
349-
ci = 0
350-
if ci >= sw:
351-
ci = sw - 1
333+
ri = int(math.floor(r + 0.5))
334+
ci = int(math.floor(c + 0.5))
335+
if ri < 0 or ri >= sh or ci < 0 or ci >= sw:
336+
out[i, j] = nodata
337+
return
352338
v = src[ri, ci]
353339
# NaN check
354340
if v != v:
@@ -453,6 +439,11 @@ def _resample_cubic_cuda(src, row_coords, col_coords, out, nodata):
453439
val = 0.0
454440
has_nan = False
455441

442+
# If any of the 4x4 stencil neighbors is outside source, fall
443+
# back to bilinear (matches GDAL behavior for boundary pixels).
444+
if r0 - 1 < 0 or r0 + 2 >= sh or c0 - 1 < 0 or c0 + 2 >= sw:
445+
has_nan = True
446+
456447
# Row 0
457448
ric = r0 - 1
458449
if ric < 0:
@@ -844,7 +835,7 @@ def _resample_cupy(source_window, src_row_coords, src_col_coords,
844835
nan_mask.astype(cp.float64), coords,
845836
order=order, mode='constant', cval=1.0
846837
).reshape(src_row_coords.shape)
847-
oob = oob | (nan_weight > 0.1)
838+
oob = oob | (nan_weight > 1e-6)
848839

849840
result[oob] = nodata
850841

xrspatial/tests/test_reproject.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,61 @@ def test_resample_oob_fills_nodata(self):
195195
result = _resample_numpy(src, rows, cols, nodata=-999)
196196
assert result[0, 0] == -999
197197

198+
def test_nearest_negative_rounding(self):
199+
"""int(r + 0.5) must round toward -inf, not toward zero (#1086)."""
200+
from xrspatial.reproject._interpolate import _resample_numpy
201+
src = np.arange(1, 17, dtype=np.float64).reshape(4, 4)
202+
# r = -0.6 is beyond the half-pixel boundary of pixel 0 -> nodata
203+
rows = np.array([[-0.6]])
204+
cols = np.array([[1.0]])
205+
result = _resample_numpy(src, rows, cols, resampling='nearest', nodata=-999)
206+
assert result[0, 0] == -999, (
207+
f"r=-0.6 should be nodata, got {result[0, 0]}"
208+
)
209+
# r = -0.4 is within pixel 0's domain -> pixel 0
210+
rows2 = np.array([[-0.4]])
211+
result2 = _resample_numpy(src, rows2, cols, resampling='nearest', nodata=-999)
212+
assert result2[0, 0] == src[0, 1], (
213+
f"r=-0.4 should map to pixel 0, got {result2[0, 0]}"
214+
)
215+
216+
def test_cubic_oob_fallback(self):
217+
"""Cubic must fall back to bilinear when stencil extends outside source (#1086)."""
218+
from xrspatial.reproject._interpolate import _resample_numpy
219+
# 6x6 source with a gradient
220+
src = np.arange(36, dtype=np.float64).reshape(6, 6)
221+
# Query at r=0.5, c=0.5: cubic stencil needs row -1, which is OOB.
222+
# Should fall back to bilinear using pixels (0,0),(0,1),(1,0),(1,1).
223+
rows = np.array([[0.5]])
224+
cols = np.array([[0.5]])
225+
cubic_result = _resample_numpy(src, rows, cols, resampling='cubic', nodata=-999)
226+
bilinear_result = _resample_numpy(src, rows, cols, resampling='bilinear', nodata=-999)
227+
# At the boundary, cubic should produce the same result as bilinear
228+
np.testing.assert_allclose(
229+
cubic_result, bilinear_result, atol=1e-10,
230+
err_msg="Cubic near boundary should fall back to bilinear"
231+
)
232+
# Interior query at r=2.5, c=2.5: full stencil fits, cubic should differ from bilinear
233+
rows_int = np.array([[2.5]])
234+
cols_int = np.array([[2.5]])
235+
cubic_int = _resample_numpy(src, rows_int, cols_int, resampling='cubic', nodata=-999)
236+
bilinear_int = _resample_numpy(src, rows_int, cols_int, resampling='bilinear', nodata=-999)
237+
# For a linear gradient, cubic and bilinear should agree closely
238+
# but the point is the code path exercises the non-fallback branch
239+
assert cubic_int[0, 0] != -999
240+
241+
def test_cubic_oob_bilinear_fallback_renormalizes(self):
242+
"""Cubic at (-0.8,-0.8): stencil OOB triggers bilinear, which
243+
finds pixel (0,0) as the only valid neighbor and returns it (#1086)."""
244+
from xrspatial.reproject._interpolate import _resample_numpy
245+
src = np.arange(1, 17, dtype=np.float64).reshape(4, 4)
246+
rows = np.array([[-0.8]])
247+
cols = np.array([[-0.8]])
248+
result = _resample_numpy(src, rows, cols, resampling='cubic', nodata=-999)
249+
# bilinear fallback: r0=-1 (OOB), r1=0, c0=-1 (OOB), c1=0
250+
# only (r1,c1)=(0,0) is valid -> returns src[0,0]=1.0
251+
assert result[0, 0] == 1.0
252+
198253
def test_invalid_resampling(self):
199254
from xrspatial.reproject._interpolate import _validate_resampling
200255
with pytest.raises(ValueError, match="resampling"):

0 commit comments

Comments
 (0)