Skip to content

Commit 4c0cce3

Browse files
authored
Preserve float64 precision in convolve_2d (#1096) (#1097)
* Preserve float64 precision in convolve_2d (#1096) All four backends hardcoded .astype(float32), silently truncating float64 input. Now integer inputs are promoted to float32 (avoiding overflow) while float64 inputs keep their precision. Output dtype matches the promoted input. * Add float64 preservation tests for convolve_2d (#1096) - test_convolve_2d_preserves_float64_1096: float64 input at 1e7 magnitude retains precision across all 4 backends - test_convolve_2d_int_promotes_to_float32_1096: int32 input gets promoted to float, not left as integer
1 parent aa74fbb commit 4c0cce3

File tree

2 files changed

+71
-7
lines changed

2 files changed

+71
-7
lines changed

xrspatial/convolution.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ class cupy(object):
1717
ndarray = False
1818

1919

20+
def _promote_float(dtype):
21+
"""Return at least float32; preserve float64."""
22+
if np.issubdtype(dtype, np.floating):
23+
return dtype
24+
return np.float32
25+
26+
2027
DEFAULT_UNIT = 'meter'
2128
METER = 1
2229
FOOT = 0.3048
@@ -286,16 +293,15 @@ def custom_kernel(kernel):
286293
@jit(nopython=True, nogil=True)
287294
def _convolve_2d_numpy(data, kernel):
288295
# apply kernel to data image.
289-
# TODO: handle nan
290-
data = data.astype(np.float32)
296+
# Caller must ensure data is a float type (float32 or float64).
291297
nx = data.shape[0]
292298
ny = data.shape[1]
293299
nkx = kernel.shape[0]
294300
nky = kernel.shape[1]
295301
wkx = nkx // 2
296302
wky = nky // 2
297303

298-
out = np.zeros(data.shape, dtype=np.float32)
304+
out = np.empty_like(data)
299305
out[:] = np.nan
300306
for i in prange(wkx, nx-wkx):
301307
iimin = max(i - wkx, 0)
@@ -315,6 +321,7 @@ def _convolve_2d_numpy(data, kernel):
315321

316322

317323
def _convolve_2d_numpy_boundary(data, kernel, boundary='nan'):
324+
data = data.astype(_promote_float(data.dtype))
318325
if boundary == 'nan':
319326
return _convolve_2d_numpy(data, kernel)
320327
pad_h = kernel.shape[0] // 2
@@ -329,7 +336,7 @@ def _convolve_2d_numpy_boundary(data, kernel, boundary='nan'):
329336

330337

331338
def _convolve_2d_dask_numpy(data, kernel, boundary='nan'):
332-
data = data.astype(np.float32)
339+
data = data.astype(_promote_float(data.dtype))
333340
pad_h = kernel.shape[0] // 2
334341
pad_w = kernel.shape[1] // 2
335342
_func = partial(_convolve_2d_numpy, kernel=kernel)
@@ -393,16 +400,17 @@ def _convolve_2d_cupy(data, kernel, boundary='nan'):
393400
c1 = -pad_w if pad_w else None
394401
return result[r0:r1, c0:c1]
395402

396-
data = data.astype(cupy.float32)
397-
out = cupy.empty(data.shape, dtype='f4')
403+
fdtype = _promote_float(data.dtype)
404+
data = data.astype(fdtype)
405+
out = cupy.empty(data.shape, dtype=fdtype)
398406
out[:, :] = cupy.nan
399407
griddim, blockdim = cuda_args(data.shape)
400408
_convolve_2d_cuda[griddim, blockdim](data, kernel, cupy.asarray(out))
401409
return out
402410

403411

404412
def _convolve_2d_dask_cupy(data, kernel, boundary='nan'):
405-
data = data.astype(cupy.float32)
413+
data = data.astype(_promote_float(data.dtype))
406414
pad_h = kernel.shape[0] // 2
407415
pad_w = kernel.shape[1] // 2
408416
_func = partial(_convolve_2d_cupy, kernel=kernel)

xrspatial/tests/test_focal.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,62 @@ def test_convolution_2d_boundary_no_nan(boundary):
942942
np_result.data, da_result.data.compute(), equal_nan=True, rtol=1e-5)
943943

944944

945+
# --- convolve_2d float64 preservation (issue-1096) ---
946+
947+
948+
@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy', 'dask+cupy'])
949+
def test_convolve_2d_preserves_float64_1096(backend):
950+
"""Float64 input should produce float64 output, not float32.
951+
952+
Regression test for #1096: convolve_2d hardcoded .astype(float32)
953+
across all backends.
954+
"""
955+
from xrspatial.tests.general_checks import has_cuda_and_cupy
956+
if 'cupy' in backend and not has_cuda_and_cupy():
957+
pytest.skip("Requires CUDA and CuPy")
958+
if 'dask' in backend and da is None:
959+
pytest.skip("Requires Dask")
960+
961+
# Values near 1e7 where float32 loses the 0.0x differences
962+
data = np.array([[1e7 + 0.01, 1e7 + 0.02, 1e7 + 0.03],
963+
[1e7 + 0.04, 1e7 + 0.05, 1e7 + 0.06],
964+
[1e7 + 0.07, 1e7 + 0.08, 1e7 + 0.09],
965+
[1e7 + 0.10, 1e7 + 0.11, 1e7 + 0.12]], dtype=np.float64)
966+
kernel = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=np.float64)
967+
968+
agg = create_test_raster(data, backend=backend, chunks=(4, 3))
969+
result = convolve_2d(agg.data, kernel)
970+
971+
if hasattr(result, 'compute'):
972+
result = result.compute()
973+
if hasattr(result, 'get'):
974+
result = result.get()
975+
result = np.asarray(result)
976+
977+
# Output must be float64
978+
assert result.dtype == np.float64, f"got {result.dtype}"
979+
980+
# Interior pixel (1,1): kernel cross hits [0.02, 0.04, 0.05, 0.06, 0.08]
981+
# Expected: sum = 5e7 + 0.25
982+
expected_center = 5e7 + 0.25
983+
assert abs(float(result[1, 1]) - expected_center) < 1e-8, (
984+
f"center={result[1, 1]}, expected={expected_center}"
985+
)
986+
987+
988+
def test_convolve_2d_int_promotes_to_float32_1096():
989+
"""Integer input should be promoted to float32 (not stay int)."""
990+
data = np.array([[1, 2, 3],
991+
[4, 5, 6],
992+
[7, 8, 9]], dtype=np.int32)
993+
kernel = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=np.float64)
994+
995+
result = convolve_2d(data, kernel)
996+
assert np.issubdtype(result.dtype, np.floating), f"got {result.dtype}"
997+
# Interior pixel (1,1): cross sum = 2+4+5+6+8 = 25
998+
assert float(result[1, 1]) == 25.0
999+
1000+
9451001
# --- 3D (multi-band) focal tests ---
9461002

9471003

0 commit comments

Comments
 (0)