diff --git a/xrspatial/resample.py b/xrspatial/resample.py index 9426dad08..4fd17f6d7 100644 --- a/xrspatial/resample.py +++ b/xrspatial/resample.py @@ -1305,7 +1305,21 @@ def _resolve_nodata(agg, nodata): f"nodata={nodata!r} is not representable in integer dtype " f"{agg.dtype}; pass an integer sentinel instead." ) - return np.asarray(nodata).astype(agg.dtype).item() + # Integer inputs: an out-of-range sentinel wraps on cast (e.g. 999 + # becomes 231 for uint8), masking the wrong cells. Require the value + # to round-trip exactly into agg.dtype before trusting the cast. + info = np.iinfo(agg.dtype) + nd_int = int(nodata) + # A sentinel beyond the dtype range either wraps (numpy fixed-width + # cast) or overflows the C-long conversion for very large Python + # ints. Range-check up front so both surface the same ValueError + # instead of a raw OverflowError. + if nd_int < info.min or nd_int > info.max: + raise ValueError( + f"nodata={nodata!r} is out of range for integer dtype " + f"{agg.dtype} (valid range [{info.min}, {info.max}])." + ) + return np.asarray(nd_int).astype(agg.dtype).item() def _apply_nodata_mask(agg, nodata): @@ -1415,9 +1429,11 @@ def resample( of ``scale_factor`` and ``target_resolution`` are given; if either is a sequence whose length is not 2; if any component is zero, negative, NaN, or infinite; if ``method`` is not in - :data:`ALL_METHODS`; or if the spatial coordinates of ``agg`` are + :data:`ALL_METHODS`; if the spatial coordinates of ``agg`` are not strictly monotonic and evenly spaced (``resample`` only - supports regular monotonic rasters). + supports regular monotonic rasters); or if ``nodata`` does not + round-trip exactly into an integer ``agg.dtype`` (a fractional + or out-of-range sentinel that would wrap on the cast). """ _validate_raster(agg, func_name='resample', name='agg', ndim=(2, 3)) _validate_monotonic_regular_coords(agg) diff --git a/xrspatial/tests/test_resample.py b/xrspatial/tests/test_resample.py index 333f95d8b..53139e068 100644 --- a/xrspatial/tests/test_resample.py +++ b/xrspatial/tests/test_resample.py @@ -11,6 +11,7 @@ dask_array_available, cuda_and_cupy_available, ) +from xrspatial.utils import has_dask_array # --------------------------------------------------------------------------- @@ -1722,3 +1723,85 @@ def test_fractional_float_sentinel_on_int_input_raises(self): agg = create_test_raster(data, attrs={'res': (1.0, 1.0)}) with pytest.raises(ValueError, match="not representable"): resample(agg, scale_factor=0.5, method='nearest', nodata=-9999.5) + + +# --------------------------------------------------------------------------- +# Out-of-range integer nodata sentinels (issue #2660) +# --------------------------------------------------------------------------- + +backends = ['numpy'] +if has_dask_array(): + backends.append('dask+numpy') + + +class TestNodataOutOfRange: + """Regression coverage for #2660 -- an out-of-range integer sentinel + used to wrap silently on the dtype cast (e.g. 999 -> 231 for uint8), + masking the wrong cells. It must raise instead.""" + + @pytest.mark.parametrize('backend', backends) + def test_uint8_sentinel_above_max_raises(self, backend): + # 999 wraps to 231 on a uint8 cast; a real 231 pixel would then + # be masked. Reject the sentinel up front. + data = np.full((4, 4), 231, dtype=np.uint8) + agg = create_test_raster(data, attrs={'res': (1.0, 1.0)}, + backend=backend) + with pytest.raises(ValueError, match="out of range"): + resample(agg, scale_factor=0.5, method='nearest', nodata=999) + + @pytest.mark.parametrize('backend', backends) + def test_uint8_negative_sentinel_raises(self, backend): + # -1 is not representable in an unsigned dtype; it wraps to 255. + data = np.zeros((4, 4), dtype=np.uint8) + agg = create_test_raster(data, attrs={'res': (1.0, 1.0)}, + backend=backend) + with pytest.raises(ValueError, match="out of range"): + resample(agg, scale_factor=0.5, method='nearest', nodata=-1) + + def test_int8_sentinel_above_max_raises(self): + # 200 wraps to -56 on an int8 cast. + data = np.zeros((4, 4), dtype=np.int8) + agg = create_test_raster(data, attrs={'res': (1.0, 1.0)}) + with pytest.raises(ValueError, match="out of range"): + resample(agg, scale_factor=0.5, method='nearest', nodata=200) + + def test_sentinel_beyond_int64_raises_valueerror(self): + # A Python int past the C-long range would raise OverflowError on + # the numpy cast; the range check must turn it into the same + # ValueError as any other out-of-range sentinel. + data = np.zeros((4, 4), dtype=np.int64) + agg = create_test_raster(data, attrs={'res': (1.0, 1.0)}) + with pytest.raises(ValueError, match="out of range"): + resample(agg, scale_factor=0.5, method='nearest', nodata=2 ** 70) + + def test_out_of_range_sentinel_via_fillvalue_attr_raises(self): + # Same defect when the sentinel arrives through _FillValue rather + # than the explicit kwarg. + data = np.zeros((4, 4), dtype=np.uint8) + agg = create_test_raster( + data, attrs={'res': (1.0, 1.0), '_FillValue': 999} + ) + with pytest.raises(ValueError, match="out of range"): + resample(agg, scale_factor=0.5, method='nearest') + + @pytest.mark.parametrize('backend', backends) + def test_in_range_sentinel_at_dtype_limit_still_masks(self, backend): + # The boundary value (uint8 max) is representable and must still + # work -- the new check rejects only values that do not round-trip. + sentinel = 255 + data = np.array([ + [sentinel, sentinel, 10, 10], + [sentinel, sentinel, 10, 10], + [20, 20, 30, 30], + [20, 20, 30, 30], + ], dtype=np.uint8) + agg = create_test_raster(data, attrs={'res': (1.0, 1.0)}, + backend=backend) + out = resample(agg, scale_factor=0.5, method='nearest', + nodata=sentinel) + # Output type matches input backend (numpy stays numpy, dask + # stays dask) and the resampled corners mask as expected. + assert isinstance(out.data, type(agg.data)) + result = out.data.compute() if backend.startswith('dask') else out.data + assert np.isnan(result[0, 0]) + assert np.isfinite(result[1, 1])