diff --git a/.claude/sweep-reference-validation-state.csv b/.claude/sweep-reference-validation-state.csv index baa7e1fb2..359f6f59f 100644 --- a/.claude/sweep-reference-validation-state.csv +++ b/.claude/sweep-reference-validation-state.csv @@ -1,4 +1,5 @@ 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. +edge_detection,2026-07-18,3677,,MATCHES,"1e-6 rel (observed ~1e-13 interior, bit-exact vs ndi.correlate)",scipy 1.16.1; sobel_x/sobel_y/prewitt_x/prewitt_y/laplacian interior match ndi.sobel/prewitt/laplace and ndi.correlate with same stencils; boundary nan/nearest/reflect/wrap == scipy modes nearest/reflect/wrap incl. edges; tilted plane analytic exact (sobel=8g prewitt=6g laplacian=0); coords/cellsize independent as documented; cupy+dask+numpy+dask+cupy bit-identical to numpy (CUDA run); golden-value test added via #3677 geotiff,2026-07-02,,,MATCHES,rtol=1e-6 float64; float32 codecs exact,"rasterio 1.4.4 (GDAL 3.10.3); gdal-cli 3.11.4; tifffile 2026.3.3; richdem-unavailable; osgeo-python-unavailable. Reader/writer round-trip vs rasterio/GDAL: codecs zstd/deflate/lzw/packbits/none + predictor 2&3 dmax=0; CRS EPSG:32611 & 4326 round-trip exact; GeoTransform north-up, non-square px, tiepoint & ModelTransformation all correct; nodata float-NaN->sentinel + int16/uint8/int32/uint16 sentinels exact; BigTIFF magic+data ok; windowed reads match rasterio.Window incl coord alignment; multiband round-trips (band-last is xrspatial's documented convention); COG valid per 'rio cogeo validate'; overview mean matches numpy block-mean to float32 ulp; tifffile tag check: Compression/Predictor/SampleFormat/GeoKeyDir all correct. Existing repo parity suite parity/test_reference.py 31 passed (incl dask+cupy). No divergence; golden_corpus already pins parity." pathfinding,2026-07-08,3655,,MATCHES,1e-12 rel (observed <=2e-15),"a_star_search vs scipy 1.16.1 sparse.csgraph.dijkstra + skimage 0.26.0 MCP_Geometric + analytic open-grid truth; 64x64 open/maze/friction/NaN-friction/anisotropic inputs, 4+8 conn; path chains adjacency+increment consistent; backend parity numpy/dask+numpy/cupy/dask+cupy confirmed (CUDA host); no prior golden test -> added golden-value tests (#3655); gdal/richdem/wbt not applicable to pathfinding" diff --git a/xrspatial/tests/test_edge_detection.py b/xrspatial/tests/test_edge_detection.py index a73e1285f..f4b33ca8d 100644 --- a/xrspatial/tests/test_edge_detection.py +++ b/xrspatial/tests/test_edge_detection.py @@ -115,6 +115,83 @@ def test_prewitt_y_on_ramp(self, numpy_agg): np.testing.assert_allclose(interior, 0, atol=1e-6) +# --------------------------------------------------------------------------- +# Golden values pinned against scipy.ndimage +# --------------------------------------------------------------------------- + +class TestGoldenScipyParity: + """Pin parity with scipy.ndimage using hardcoded reference outputs. + + Expected arrays were generated with scipy.ndimage 1.16.1: + ``ndi.sobel(data, axis=1)``, ``ndi.sobel(data, axis=0)``, + ``ndi.prewitt(data, axis=1)``, ``ndi.prewitt(data, axis=0)`` and + ``ndi.laplace(data)``, all with scipy's default ``mode='reflect'``, + which matches xrspatial's ``boundary='reflect'``. The values are + integer-exact for this input, so comparisons use equality. + """ + + DATA = np.array([[3, 1, 4, 1, 5, 9], + [2, 6, 5, 3, 5, 8], + [9, 7, 9, 3, 2, 3], + [8, 4, 6, 2, 6, 4], + [3, 3, 8, 3, 2, 7]], dtype=np.float64) + + EXPECTED = { + 'sobel_x': np.array([[-2, 6, -3, 3, 29, 15], + [4, 7, -10, -6, 18, 11], + [-4, 1, -13, -14, 7, 3], + [-10, 1, -8, -13, 8, 2], + [-4, 13, -2, -18, 14, 13]], dtype=np.float64), + 'sobel_y': np.array([[2, 10, 9, 5, 1, -3], + [24, 23, 18, 6, -10, -21], + [16, 3, -1, 0, -3, -11], + [-22, -15, -6, -1, 4, 12], + [-16, -5, 4, 0, -4, 5]], dtype=np.float64), + 'prewitt_x': np.array([[0, 5, -3, 2, 21, 11], + [0, 4, -7, -6, 13, 8], + [-2, 1, -9, -7, 7, 2], + [-6, 3, -6, -13, 6, 4], + [-4, 8, -2, -12, 10, 8]], dtype=np.float64), + 'prewitt_y': np.array([[3, 5, 8, 3, 1, -2], + [18, 17, 13, 4, -7, -15], + [10, 5, -2, 1, -4, -7], + [-16, -11, -5, -1, 4, 8], + [-11, -4, 2, -1, 0, 2]], dtype=np.float64), + 'laplacian': np.array([[-3, 10, -5, 9, 0, -5], + [12, -9, 2, 2, -2, -7], + [-10, 0, -15, 4, 9, 5], + [-8, 8, -1, 10, -14, 4], + [5, 6, -12, 3, 10, -8]], dtype=np.float64), + } + + @pytest.mark.parametrize('func,key', [ + (sobel_x, 'sobel_x'), + (sobel_y, 'sobel_y'), + (prewitt_x, 'prewitt_x'), + (prewitt_y, 'prewitt_y'), + (laplacian, 'laplacian'), + ]) + def test_golden_reflect_full_array(self, func, key): + agg = create_test_raster(self.DATA, backend='numpy') + result = func(agg, boundary='reflect') + np.testing.assert_array_equal(result.data, self.EXPECTED[key]) + + @pytest.mark.parametrize('func,key', [ + (sobel_x, 'sobel_x'), + (sobel_y, 'sobel_y'), + (prewitt_x, 'prewitt_x'), + (prewitt_y, 'prewitt_y'), + (laplacian, 'laplacian'), + ]) + def test_golden_nan_interior(self, func, key): + # default boundary='nan': interior pixels are unaffected by the + # boundary mode, so they must equal the reference interior + agg = create_test_raster(self.DATA, backend='numpy') + result = func(agg) + np.testing.assert_array_equal( + result.data[1:-1, 1:-1], self.EXPECTED[key][1:-1, 1:-1]) + + # --------------------------------------------------------------------------- # Output metadata / backend checks # ---------------------------------------------------------------------------