Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions xrspatial/convolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ class cupy(object):
ndarray = False


def _promote_float(dtype):
"""Return at least float32; preserve float64."""
if np.issubdtype(dtype, np.floating):
return dtype
return np.float32


DEFAULT_UNIT = 'meter'
METER = 1
FOOT = 0.3048
Expand Down Expand Up @@ -286,16 +293,15 @@ def custom_kernel(kernel):
@jit(nopython=True, nogil=True)
def _convolve_2d_numpy(data, kernel):
# apply kernel to data image.
# TODO: handle nan
data = data.astype(np.float32)
# Caller must ensure data is a float type (float32 or float64).
nx = data.shape[0]
ny = data.shape[1]
nkx = kernel.shape[0]
nky = kernel.shape[1]
wkx = nkx // 2
wky = nky // 2

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


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


def _convolve_2d_dask_numpy(data, kernel, boundary='nan'):
data = data.astype(np.float32)
data = data.astype(_promote_float(data.dtype))
pad_h = kernel.shape[0] // 2
pad_w = kernel.shape[1] // 2
_func = partial(_convolve_2d_numpy, kernel=kernel)
Expand Down Expand Up @@ -393,16 +400,17 @@ def _convolve_2d_cupy(data, kernel, boundary='nan'):
c1 = -pad_w if pad_w else None
return result[r0:r1, c0:c1]

data = data.astype(cupy.float32)
out = cupy.empty(data.shape, dtype='f4')
fdtype = _promote_float(data.dtype)
data = data.astype(fdtype)
out = cupy.empty(data.shape, dtype=fdtype)
out[:, :] = cupy.nan
griddim, blockdim = cuda_args(data.shape)
_convolve_2d_cuda[griddim, blockdim](data, kernel, cupy.asarray(out))
return out


def _convolve_2d_dask_cupy(data, kernel, boundary='nan'):
data = data.astype(cupy.float32)
data = data.astype(_promote_float(data.dtype))
pad_h = kernel.shape[0] // 2
pad_w = kernel.shape[1] // 2
_func = partial(_convolve_2d_cupy, kernel=kernel)
Expand Down
56 changes: 56 additions & 0 deletions xrspatial/tests/test_focal.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,62 @@ def test_convolution_2d_boundary_no_nan(boundary):
np_result.data, da_result.data.compute(), equal_nan=True, rtol=1e-5)


# --- convolve_2d float64 preservation (issue-1096) ---


@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy', 'dask+cupy'])
def test_convolve_2d_preserves_float64_1096(backend):
"""Float64 input should produce float64 output, not float32.

Regression test for #1096: convolve_2d hardcoded .astype(float32)
across all backends.
"""
from xrspatial.tests.general_checks import has_cuda_and_cupy
if 'cupy' in backend and not has_cuda_and_cupy():
pytest.skip("Requires CUDA and CuPy")
if 'dask' in backend and da is None:
pytest.skip("Requires Dask")

# Values near 1e7 where float32 loses the 0.0x differences
data = np.array([[1e7 + 0.01, 1e7 + 0.02, 1e7 + 0.03],
[1e7 + 0.04, 1e7 + 0.05, 1e7 + 0.06],
[1e7 + 0.07, 1e7 + 0.08, 1e7 + 0.09],
[1e7 + 0.10, 1e7 + 0.11, 1e7 + 0.12]], dtype=np.float64)
kernel = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=np.float64)

agg = create_test_raster(data, backend=backend, chunks=(4, 3))
result = convolve_2d(agg.data, kernel)

if hasattr(result, 'compute'):
result = result.compute()
if hasattr(result, 'get'):
result = result.get()
result = np.asarray(result)

# Output must be float64
assert result.dtype == np.float64, f"got {result.dtype}"

# Interior pixel (1,1): kernel cross hits [0.02, 0.04, 0.05, 0.06, 0.08]
# Expected: sum = 5e7 + 0.25
expected_center = 5e7 + 0.25
assert abs(float(result[1, 1]) - expected_center) < 1e-8, (
f"center={result[1, 1]}, expected={expected_center}"
)


def test_convolve_2d_int_promotes_to_float32_1096():
"""Integer input should be promoted to float32 (not stay int)."""
data = np.array([[1, 2, 3],
[4, 5, 6],
[7, 8, 9]], dtype=np.int32)
kernel = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=np.float64)

result = convolve_2d(data, kernel)
assert np.issubdtype(result.dtype, np.floating), f"got {result.dtype}"
# Interior pixel (1,1): cross sum = 2+4+5+6+8 = 25
assert float(result[1, 1]) == 25.0


# --- 3D (multi-band) focal tests ---


Expand Down
Loading