Skip to content

Commit a7c2399

Browse files
authored
Fix three accuracy bugs in reproject resampling kernels (#1087)
* Add design spec for geotiff performance and memory controls Covers dtype on read, compression_level on write, and VRT tiled output from to_geotiff for streaming dask writes. * 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. * Address code review: edge-case tests and threshold comment (#1086) - Test nearest-neighbor at exact boundary r=-0.5 - Test cubic fallback at far (bottom-right) edge - Comment the 1e-6 NaN threshold rationale
1 parent 443ed78 commit a7c2399

File tree

2 files changed

+98
-32
lines changed

2 files changed

+98
-32
lines changed

xrspatial/reproject/_interpolate.py

Lines changed: 26 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,10 @@ 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+
# Any nonzero weight means NaN pixels contributed to the output.
839+
# Use 1e-6 instead of 0.0 to absorb floating-point interpolation
840+
# noise from map_coordinates on the binary mask.
841+
oob = oob | (nan_weight > 1e-6)
848842

849843
result[oob] = nodata
850844

xrspatial/tests/test_reproject.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,78 @@ 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+
# r = -0.5 is exactly on the half-pixel boundary: floor(-0.5+0.5)=0 -> pixel 0
216+
rows3 = np.array([[-0.5]])
217+
result3 = _resample_numpy(src, rows3, cols, resampling='nearest', nodata=-999)
218+
assert result3[0, 0] == src[0, 1], (
219+
f"r=-0.5 should map to pixel 0, got {result3[0, 0]}"
220+
)
221+
222+
def test_cubic_oob_fallback(self):
223+
"""Cubic must fall back to bilinear when stencil extends outside source (#1086)."""
224+
from xrspatial.reproject._interpolate import _resample_numpy
225+
# 6x6 source with a gradient
226+
src = np.arange(36, dtype=np.float64).reshape(6, 6)
227+
# Query at r=0.5, c=0.5: cubic stencil needs row -1, which is OOB.
228+
# Should fall back to bilinear using pixels (0,0),(0,1),(1,0),(1,1).
229+
rows = np.array([[0.5]])
230+
cols = np.array([[0.5]])
231+
cubic_result = _resample_numpy(src, rows, cols, resampling='cubic', nodata=-999)
232+
bilinear_result = _resample_numpy(src, rows, cols, resampling='bilinear', nodata=-999)
233+
# At the boundary, cubic should produce the same result as bilinear
234+
np.testing.assert_allclose(
235+
cubic_result, bilinear_result, atol=1e-10,
236+
err_msg="Cubic near boundary should fall back to bilinear"
237+
)
238+
# Interior query at r=2.5, c=2.5: full stencil fits, cubic should differ from bilinear
239+
rows_int = np.array([[2.5]])
240+
cols_int = np.array([[2.5]])
241+
cubic_int = _resample_numpy(src, rows_int, cols_int, resampling='cubic', nodata=-999)
242+
bilinear_int = _resample_numpy(src, rows_int, cols_int, resampling='bilinear', nodata=-999)
243+
# For a linear gradient, cubic and bilinear should agree closely
244+
# but the point is the code path exercises the non-fallback branch
245+
assert cubic_int[0, 0] != -999
246+
247+
def test_cubic_oob_fallback_far_edge(self):
248+
"""Cubic at bottom-right boundary: stencil needs row sh, same fallback (#1086)."""
249+
from xrspatial.reproject._interpolate import _resample_numpy
250+
src = np.arange(36, dtype=np.float64).reshape(6, 6)
251+
# r=4.5: cubic stencil needs row 6 (= sh), which is OOB
252+
rows = np.array([[4.5]])
253+
cols = np.array([[4.5]])
254+
cubic = _resample_numpy(src, rows, cols, resampling='cubic', nodata=-999)
255+
bilinear = _resample_numpy(src, rows, cols, resampling='bilinear', nodata=-999)
256+
np.testing.assert_allclose(cubic, bilinear, atol=1e-10)
257+
258+
def test_cubic_oob_bilinear_fallback_renormalizes(self):
259+
"""Cubic at (-0.8,-0.8): stencil OOB triggers bilinear, which
260+
finds pixel (0,0) as the only valid neighbor and returns it (#1086)."""
261+
from xrspatial.reproject._interpolate import _resample_numpy
262+
src = np.arange(1, 17, dtype=np.float64).reshape(4, 4)
263+
rows = np.array([[-0.8]])
264+
cols = np.array([[-0.8]])
265+
result = _resample_numpy(src, rows, cols, resampling='cubic', nodata=-999)
266+
# bilinear fallback: r0=-1 (OOB), r1=0, c0=-1 (OOB), c1=0
267+
# only (r1,c1)=(0,0) is valid -> returns src[0,0]=1.0
268+
assert result[0, 0] == 1.0
269+
198270
def test_invalid_resampling(self):
199271
from xrspatial.reproject._interpolate import _validate_resampling
200272
with pytest.raises(ValueError, match="resampling"):

0 commit comments

Comments
 (0)