Skip to content

Commit 4e22efa

Browse files
authored
polygonize: cupy backend honours float tolerance from numba path (#2151) (#2153)
* polygonize: cupy backend honours numba _is_close tolerance for floats (#2151) The cupy backend binned float pixels into regions by exact value while the numba CPU path (used by the numpy, dask and dask+cupy backends) compares neighbouring pixels with `_is_close` (atol=1e-8, rtol=1e-5). On float rasters with near-equal values this produced different polygon sets across backends. For float dtypes, group sorted unique values in the cupy backend using the same tolerance and label each group as a single region. Integer dtypes keep the existing exact-equality path. Adds a regression test asserting that the cupy backend yields the same polygon count and per-value areas as the numpy backend on a float raster with values 1.0 and 1.000001. Also records this finding in the accuracy sweep state CSV. * polygonize: route cupy float path through CPU CCL for true _is_close parity The first pass at #2151 added value-only greedy chaining of unique float values in _calculate_regions_cupy. That fixed the reported pattern (adjacent near-equal pixels) but still diverged from the CPU path when three or more transitively-close values were present in the raster without intermediate adjacent pixels. Example: [1.0, 1.000018] [1.000009, 3.0 ] CPU spatial CCL yields three regions: 1.0 + 1.000009 (close, adjacent), 1.000018 alone (not close to either neighbour), and 3.0 alone. The value-grouping heuristic chained 1.0 -> 1.000009 -> 1.000018 and labeled the connected mask as a single region, returning two regions on cupy. Route float dtypes through _polygonize_numpy in _polygonize_cupy so the numba _is_close predicate is applied to spatially adjacent pixels, the only place CPU and GPU semantics can agree without re-implementing CCL. The data is already transferred to host for boundary tracing, so the fall-back cost is one extra _calculate_regions call. Drop _group_float_values_by_tolerance and the float branch of _calculate_regions_cupy as dead code. Extend the regression test to parametrize over the original adjacent pattern and the transitivity edge case.
1 parent 6549a48 commit 4e22efa

3 files changed

Lines changed: 76 additions & 8 deletions

File tree

.claude/sweep-accuracy-state.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ multispectral,2026-03-30T14:00:00Z,1094,,,
2323
normalize,2026-05-01,,,,rescale and standardize across all 4 backends. NaN/inf filtered via isfinite mask before min/max/mean/std. Constant input handled (range=0 -> new_min; std=0 -> 0.0). Output dtype float64 consistently. Backend parity covered by test_matches_numpy. No accuracy issues found.
2424
perlin,2026-04-10T12:00:00Z,,,,Improved Perlin noise implementation correct. Fade/gradient functions verified. Backend-consistent. Continuous at cell boundaries.
2525
polygon_clip,2026-04-13T12:00:00Z,1197,,,crop=True + all_touched=True drops boundary pixels. Fix in PR #1200.
26-
polygonize,2026-04-13T12:00:00Z,1190,,,NaN pixels not masked in numpy/dask backends. Fix in PR #1194.
26+
polygonize,2026-05-19,2151,HIGH,5,cupy backend used exact-equality vs numba _is_close tolerance for float dtypes; fixed via tolerance-aware value grouping
2727
proximity,2026-03-30T15:00:00Z,,,,Direction >= boundary fragile but works due to truncated constant. Float32 truncation is design choice. No wrong-results bugs found.
2828
reproject,2026-05-17,2025,HIGH,5,"HIGH: _apply_vertical_shift crashed on dask (no fancy-index setitem), cupy (Numba JIT can't take cupy), and 3D multi-band (broadcast mismatch). Fixed by routing dask through map_blocks, round-tripping cupy via host, and looping per band. Previously documented MEDIUM (GPU dispatcher missing NAD27/Helmert wrapper) and MEDIUM (legacy _resample_cupy cubic NaN threshold 1e-6 diverges from CPU bilinear-fallback) still stand. Identity-CRS cupy reproject returns zeros - separate pre-existing bug, not in scope."
2929
resample,2026-04-14T12:00:00Z,1202,,,Interpolation used edge-aligned coords instead of block-centered. Fix in PR #1204.

xrspatial/polygonize.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -594,10 +594,16 @@ def _polygonize_numpy(
594594

595595

596596
def _calculate_regions_cupy(data, mask_data, connectivity_8):
597-
"""CuPy GPU backend for connected-component labeling.
597+
"""CuPy GPU backend for connected-component labeling on integer data.
598598
599599
Uses cupyx.scipy.ndimage.label per unique value to produce a regions
600600
array compatible with _scan. Returns a cupy uint32 2D array.
601+
602+
Only used for integer dtypes; float dtypes route through the CPU
603+
``_calculate_regions`` so that connected components honour the numba
604+
``_is_close`` predicate (``atol=1e-8``, ``rtol=1e-5``) exactly.
605+
A purely value-based grouping on the GPU cannot reproduce CPU spatial
606+
CCL when transitively-close values are not spatially adjacent.
601607
"""
602608
import cupy as cp
603609
from cupyx.scipy.ndimage import label as cp_label
@@ -609,14 +615,10 @@ def _calculate_regions_cupy(data, mask_data, connectivity_8):
609615

610616
regions = cp.zeros(data.shape, dtype=cp.uint32)
611617

612-
# Build valid mask (unmask + handle float NaN).
613-
is_float = cp.issubdtype(data.dtype, cp.floating)
614618
if mask_data is not None:
615619
valid = cp.asarray(mask_data, dtype=bool)
616-
if is_float:
617-
valid &= ~cp.isnan(data)
618620
else:
619-
valid = ~cp.isnan(data) if is_float else None
621+
valid = None
620622

621623
unique_vals = data[valid] if valid is not None else data.ravel()
622624
unique_vals = cp.unique(unique_vals)
@@ -669,9 +671,19 @@ def _renumber_regions(regions, nx, ny):
669671

670672

671673
def _polygonize_cupy(data, mask_data, connectivity_8, transform):
672-
"""Hybrid GPU/CPU: GPU CCL, CPU boundary tracing."""
674+
"""Hybrid GPU/CPU: GPU CCL for integer data, CPU CCL for float data,
675+
CPU boundary tracing in either case.
676+
677+
Float dtypes route through ``_polygonize_numpy`` so that connected
678+
components honour the numba ``_is_close`` tolerance (``atol=1e-8``,
679+
``rtol=1e-5``) for spatially adjacent pixels (#2151). GPU CCL is a
680+
per-value labeling, which cannot reproduce spatial tolerance-aware
681+
CCL when transitively-close values are not spatially adjacent.
682+
"""
673683
np_data = cupy.asnumpy(data)
674684
np_mask = cupy.asnumpy(mask_data) if mask_data is not None else None
685+
if np.issubdtype(np_data.dtype, np.floating):
686+
return _polygonize_numpy(np_data, np_mask, connectivity_8, transform)
675687
ny, nx = np_data.shape
676688
if nx == 1:
677689
# Edge case: fall back to full numpy path (pads array).

xrspatial/tests/test_polygonize.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,62 @@ def test_polygonize_dask_cupy_matches_numpy(chunks):
459459
err_msg=f"Area mismatch for value {val}")
460460

461461

462+
@cuda_and_cupy_available
463+
@pytest.mark.parametrize(
464+
"data",
465+
[
466+
# Adjacent near-equal values, the originally reported pattern.
467+
np.array([
468+
[1.0, 1.000001, 2.0],
469+
[1.000001, 1.0, 2.0],
470+
[3.0, 3.0, 2.0],
471+
], dtype=np.float64),
472+
# Transitivity edge case: three values 1.0, 1.000009, 1.000018 where
473+
# 1.0 and 1.000018 are NOT pairwise close by ``_is_close`` but a
474+
# value-only grouping heuristic would chain them transitively. The
475+
# CPU spatial CCL keeps them in separate regions when they are not
476+
# bridged by an adjacent intermediate pixel; the cupy backend must
477+
# match.
478+
np.array([
479+
[1.0, 1.000018],
480+
[1.000009, 3.0],
481+
], dtype=np.float64),
482+
],
483+
ids=["adjacent_near_equal", "transitive_non_adjacent"],
484+
)
485+
def test_polygonize_cupy_float_tolerance_matches_numpy_2151(data):
486+
"""CuPy backend must use the same float tolerance as the numpy/numba
487+
path for grouping pixels into regions (#2151).
488+
489+
Before the fix, the cupy backend binned pixels by exact value while
490+
the numpy/numba backend used ``_is_close`` (atol=1e-8, rtol=1e-5),
491+
yielding different polygon counts on float rasters with near-equal
492+
values.
493+
"""
494+
import cupy
495+
496+
raster_np = xr.DataArray(data)
497+
vals_np, polys_np = polygonize(raster_np, connectivity=4)
498+
499+
raster_cp = xr.DataArray(cupy.asarray(data))
500+
vals_cp, polys_cp = polygonize(raster_cp, connectivity=4)
501+
502+
# Both backends should return the same number of polygons.
503+
assert len(polys_np) == len(polys_cp), (
504+
f"polygon count differs: numpy={len(polys_np)} cupy={len(polys_cp)}; "
505+
f"numpy values={vals_np}, cupy values={vals_cp}"
506+
)
507+
# Per-value total area must agree across backends.
508+
areas_np = _area_by_value(vals_np, polys_np)
509+
areas_cp = _area_by_value(vals_cp, polys_cp)
510+
assert set(areas_np.keys()) == set(areas_cp.keys()), (
511+
f"value sets differ: numpy={set(areas_np.keys())} "
512+
f"cupy={set(areas_cp.keys())}"
513+
)
514+
for v, a in areas_np.items():
515+
assert_allclose(areas_cp[v], a, atol=1e-10)
516+
517+
462518
# --- Performance-related regression tests (#1008) ---
463519

464520
def test_polygonize_1008_jit_merge_helpers():

0 commit comments

Comments
 (0)