diff --git a/xrspatial/rasterize.py b/xrspatial/rasterize.py index 9abdbbc61..33626d2b2 100644 --- a/xrspatial/rasterize.py +++ b/xrspatial/rasterize.py @@ -1517,6 +1517,12 @@ def _normalize_chunks(chunks, height, width): else: rchunk, cchunk = chunks + # Both axes must have positive chunk sizes. A zero would loop forever + # below; a negative would diverge. + if rchunk <= 0 or cchunk <= 0: + raise ValueError( + f"chunks must be positive, got ({rchunk}, {cchunk})") + row_chunks = [] remaining = height while remaining > 0: @@ -2099,9 +2105,9 @@ def rasterize( name : str, default 'rasterize' Name for the output DataArray. resolution : float or (x_res, y_res), optional - Pixel size. When given with ``bounds``, computes ``width`` and - ``height`` automatically. A single float uses the same - resolution for both axes. + Pixel size. Must be finite and ``> 0``. When given with + ``bounds``, computes ``width`` and ``height`` automatically. + A single float uses the same resolution for both axes. like : xr.DataArray, optional Template raster. Width, height, bounds, and dtype are copied from this array (any can still be overridden explicitly). @@ -2130,9 +2136,9 @@ def rasterize( chunks : int or (int, int), optional If given, use the dask backend and split the output raster into - tiles of this size ``(row_chunk, col_chunk)``. A single int - uses the same chunk size for both axes. Combined with - ``use_cuda`` to select dask+numpy vs dask+cupy. + tiles of this size ``(row_chunk, col_chunk)``. Both axes must be + ``> 0``. A single int uses the same chunk size for both axes. + Combined with ``use_cuda`` to select dask+numpy vs dask+cupy. max_pixels : int, default 1_000_000_000 Safety cap on the resolved output size (``width * height``). The function raises ``ValueError`` before any host or device @@ -2232,6 +2238,13 @@ def rasterize( x_res = y_res = float(resolution) else: x_res, y_res = float(resolution[0]), float(resolution[1]) + # Reject non-finite or non-positive resolution before dimension math. + # Without this, inf/-1 quietly produce a 1x1 raster, 0 raises an + # opaque ZeroDivisionError, and nan raises an int-conversion error. + for r in (x_res, y_res): + if not np.isfinite(r) or r <= 0: + raise ValueError( + f"resolution must be finite and > 0, got {resolution!r}") final_width = max(int(np.ceil((xmax - xmin) / x_res)), 1) final_height = max(int(np.ceil((ymax - ymin) / y_res)), 1) elif like_width is not None: diff --git a/xrspatial/tests/test_rasterize.py b/xrspatial/tests/test_rasterize.py index df89328a8..125e1961a 100644 --- a/xrspatial/tests/test_rasterize.py +++ b/xrspatial/tests/test_rasterize.py @@ -342,6 +342,28 @@ def test_max_pixels_one_over_rejected(self): width=101, height=100, bounds=(0, 0, 1, 1), max_pixels=10_000) + @pytest.mark.parametrize("bad", [0, -1, -0.5, float('inf'), + -float('inf'), float('nan')]) + def test_invalid_resolution_scalar(self, bad): + with pytest.raises(ValueError, match="resolution must be finite"): + rasterize([(box(0, 0, 1, 1), 1.0)], + resolution=bad, bounds=(0, 0, 1, 1)) + + @pytest.mark.parametrize("bad", [(0, 1), (1, 0), (-1, 1), (1, float('nan')), + (float('inf'), 1)]) + def test_invalid_resolution_tuple(self, bad): + with pytest.raises(ValueError, match="resolution must be finite"): + rasterize([(box(0, 0, 1, 1), 1.0)], + resolution=bad, bounds=(0, 0, 1, 1)) + + @pytest.mark.parametrize("bad", [0, -1, (0, 1), (1, -1)]) + def test_invalid_chunks(self, bad): + # chunks=0 used to hang (issue #2066); negative diverged. + with pytest.raises(ValueError, match="chunks must be positive"): + rasterize([(box(0, 0, 1, 1), 1.0)], + width=10, height=10, bounds=(0, 0, 1, 1), + chunks=bad) + # --------------------------------------------------------------------------- # all_touched mode