diff --git a/.claude/sweep-reference-validation-state.csv b/.claude/sweep-reference-validation-state.csv new file mode 100644 index 000000000..0539615a9 --- /dev/null +++ b/.claude/sweep-reference-validation-state.csv @@ -0,0 +1,2 @@ +module,last_inspected,issue,severity_max,verdict,tolerance,notes +convolution,2026-07-02,3619,MEDIUM,CONVENTION-DIFF,0.0 exact (float64),scipy 1.16.1; gdal-unavailable astropy-unavailable; cuda True. convolve_2d interior MATCHES scipy.ndimage.correlate exactly (0.0) across nan/nearest/reflect/wrap; cupy parity 0.0. circle_kernel/annulus_kernel match analytic int-truncated disc exactly; calc_cellsize correct. CONVENTION-DIFF: named 'convolution' but computes cross-correlation (kernel not flipped) -> diverges from scipy.ndimage.convolve on asymmetric kernels (built-in kernels are symmetric so common path unaffected). Convention was undocumented -> MEDIUM doc fix + golden test pinning correlation. diff --git a/xrspatial/convolution.py b/xrspatial/convolution.py index b1b08bfb7..672809128 100644 --- a/xrspatial/convolution.py +++ b/xrspatial/convolution.py @@ -501,6 +501,14 @@ def convolution_2d(agg, kernel, name='convolution_2d', boundary='nan'): images by eliminating spurious data or enhancing features in the data. Note that edges of output data array are filled with NaNs. + The kernel is applied by cross-correlation: it is overlaid on each + neighbourhood as given, without being flipped, which matches + ``scipy.ndimage.correlate``. This equals a true convolution only when + the kernel is symmetric, as the built-in ``circle_kernel`` and + ``annulus_kernel`` are. For an asymmetric kernel where you need + ``scipy.ndimage.convolve`` semantics, pass the kernel pre-flipped + (``kernel[::-1, ::-1]``). + Parameters ---------- agg : xarray.DataArray diff --git a/xrspatial/tests/test_convolution.py b/xrspatial/tests/test_convolution.py index dff4af4a7..cdebc0d05 100644 --- a/xrspatial/tests/test_convolution.py +++ b/xrspatial/tests/test_convolution.py @@ -48,3 +48,25 @@ def test_convolve_2d_accepts_float64(): # Centre cell is finite; edges are NaN by default boundary mode. assert np.isfinite(out[2, 2]) assert np.isnan(out[0, 0]) + + +def test_convolve_2d_uses_correlation_convention(): + # Golden values verified against scipy.ndimage 1.16.1: convolve_2d + # applies the kernel by cross-correlation (kernel NOT flipped), so it + # matches scipy.ndimage.correlate, not scipy.ndimage.convolve. An + # asymmetric kernel distinguishes the two. This pins the documented + # convention so a rename/refactor cannot silently flip the kernel. + data = np.arange(24, dtype=np.float64).reshape(4, 6) + kernel = custom_kernel(np.array([[1., 0., 0.], + [1., 1., 0.], + [1., 0., 0.]])) + out = convolve_2d(data, kernel) + # scipy.ndimage.correlate(data, kernel)[1:-1, 1:-1] + expected_correlate = np.array([[25., 29., 33., 37.], + [49., 53., 57., 61.]]) + np.testing.assert_array_equal(out[1:-1, 1:-1], expected_correlate) + # A true convolution (scipy.ndimage.convolve, kernel flipped) would + # instead give these values; convolve_2d must NOT match them. + expected_convolve = np.array([[31., 35., 39., 43.], + [55., 59., 63., 67.]]) + assert not np.array_equal(out[1:-1, 1:-1], expected_convolve)