|
| 1 | +"""Parameter coverage for the ``overview_resampling`` modes |
| 2 | +``'min'``, ``'max'``, and ``'median'``. |
| 3 | +
|
| 4 | +The CPU writer (``xrspatial.geotiff._writer._block_reduce_2d``) and the |
| 5 | +GPU writer (``xrspatial.geotiff._gpu_decode._block_reduce_2d_gpu``) both |
| 6 | +implement seven resampling reductions for COG overview generation: |
| 7 | +
|
| 8 | +* ``mean`` -- covered by ``test_cog_overview_nodata_1613`` and |
| 9 | + ``test_features``. |
| 10 | +* ``nearest`` -- covered by ``test_features`` and the same suite. |
| 11 | +* ``mode`` -- covered by ``test_mode_overview_perf``. |
| 12 | +* ``cubic`` -- covered by ``test_cog_cubic_overview_nodata_1623``. |
| 13 | +* ``min`` / ``max`` / ``median`` -- CPU end-to-end paths covered by |
| 14 | + ``test_cog_overview_nodata_1613::test_cpu_cog_overview_aggregations_ignore_sentinel``, |
| 15 | + but the GPU end-to-end paths and the direct CPU/GPU block-reducer |
| 16 | + branches had no targeted tests prior to this file. |
| 17 | +
|
| 18 | +Test coverage gap sweep 2026-05-11 (pass 6) closes a Cat 4 (parameter |
| 19 | +coverage) HIGH gap: the GPU end-to-end paths and the direct CPU+GPU |
| 20 | +block-reducer branches for ``overview_resampling='min'/'max'/'median'`` |
| 21 | +had no targeted tests, so a regression on those code paths would ship |
| 22 | +undetected. |
| 23 | +
|
| 24 | +The tests cover: |
| 25 | +
|
| 26 | +* CPU writer (``to_geotiff(cog=True, overview_resampling='min'/'max'/'median')``) |
| 27 | + with finite data, and with a nodata sentinel so the nan-aware |
| 28 | + reductions (``nanmin`` / ``nanmax`` / ``nanmedian``) get exercised. |
| 29 | +* GPU writer (``write_geotiff_gpu`` and ``to_geotiff(gpu=True, ...)``) |
| 30 | + for the three modes, verifying the cupy implementation matches the |
| 31 | + CPU implementation byte-for-byte. |
| 32 | +* The ``cog=True`` overview-level read path round-trips the resampled |
| 33 | + data so the full write/read pipeline is exercised. |
| 34 | +""" |
| 35 | +from __future__ import annotations |
| 36 | + |
| 37 | +import importlib.util |
| 38 | + |
| 39 | +import numpy as np |
| 40 | +import pytest |
| 41 | +import xarray as xr |
| 42 | + |
| 43 | +from xrspatial.geotiff import open_geotiff, to_geotiff |
| 44 | +from xrspatial.geotiff._writer import _block_reduce_2d |
| 45 | + |
| 46 | + |
| 47 | +def _gpu_available() -> bool: |
| 48 | + """True if cupy is importable and CUDA is initialised.""" |
| 49 | + if importlib.util.find_spec("cupy") is None: |
| 50 | + return False |
| 51 | + try: |
| 52 | + import cupy |
| 53 | + return bool(cupy.cuda.is_available()) |
| 54 | + except Exception: |
| 55 | + return False |
| 56 | + |
| 57 | + |
| 58 | +_HAS_GPU = _gpu_available() |
| 59 | +_gpu_only = pytest.mark.skipif( |
| 60 | + not _HAS_GPU, |
| 61 | + reason="cupy + CUDA required", |
| 62 | +) |
| 63 | + |
| 64 | + |
| 65 | +# --------------------------------------------------------------------------- |
| 66 | +# Fixtures: 4x4 rasters with deterministic values so the 2x decimated |
| 67 | +# overview has a closed-form min / max / median for every 2x2 block. |
| 68 | +# --------------------------------------------------------------------------- |
| 69 | + |
| 70 | +def _arr_4x4_ramp() -> np.ndarray: |
| 71 | + """4x4 float32 ramp. |
| 72 | +
|
| 73 | + Block layout (top-left 2x2, top-right 2x2, ...): |
| 74 | +
|
| 75 | + [ 1 2 | 3 4 ] |
| 76 | + [ 5 6 | 7 8 ] |
| 77 | + -------------- |
| 78 | + [ 9 10 |11 12 ] |
| 79 | + [13 14 |15 16 ] |
| 80 | +
|
| 81 | + Per-block reductions: |
| 82 | + * min: [[1, 3], [9, 11]] |
| 83 | + * max: [[6, 8], [14, 16]] |
| 84 | + * median: [[3.5, 5.5], [11.5, 13.5]] (mean of the two middle values) |
| 85 | + """ |
| 86 | + return np.arange(1, 17, dtype=np.float32).reshape(4, 4) |
| 87 | + |
| 88 | + |
| 89 | +def _arr_4x4_with_nan() -> np.ndarray: |
| 90 | + """4x4 float32 ramp with one NaN per top-row 2x2 block. |
| 91 | +
|
| 92 | + Block layout: |
| 93 | +
|
| 94 | + [NaN 2 | 3 NaN] |
| 95 | + [ 5 6 | 7 8 ] |
| 96 | + -------------- |
| 97 | + [ 9 10 |11 12 ] |
| 98 | + [ 13 14 |15 16 ] |
| 99 | +
|
| 100 | + Per-block reductions (NaN ignored): |
| 101 | + * min: [[2, 3], [9, 11]] |
| 102 | + * max: [[6, 8], [14, 16]] |
| 103 | + * median: the four-cell median ignoring NaN. For top-left, finite |
| 104 | + cells are {2, 5, 6} -> median 5. Top-right finite {3, 7, 8} -> 7. |
| 105 | + Bottom rows are unchanged ramp medians. |
| 106 | + """ |
| 107 | + arr = _arr_4x4_ramp() |
| 108 | + arr[0, 0] = np.nan |
| 109 | + arr[0, 3] = np.nan |
| 110 | + return arr |
| 111 | + |
| 112 | + |
| 113 | +# Expected outputs are computed via numpy.nan* once at module import so |
| 114 | +# they double as a CPU-impl correctness check. |
| 115 | + |
| 116 | +_RAMP_EXPECTED_MIN = np.array([[1.0, 3.0], [9.0, 11.0]], dtype=np.float32) |
| 117 | +_RAMP_EXPECTED_MAX = np.array([[6.0, 8.0], [14.0, 16.0]], dtype=np.float32) |
| 118 | +_RAMP_EXPECTED_MEDIAN = np.array([[3.5, 5.5], [11.5, 13.5]], dtype=np.float32) |
| 119 | + |
| 120 | + |
| 121 | +# --------------------------------------------------------------------------- |
| 122 | +# Cat 4 HIGH: CPU writer overview_resampling=min/max/median (block reducer). |
| 123 | +# --------------------------------------------------------------------------- |
| 124 | + |
| 125 | +@pytest.mark.parametrize("method, expected", [ |
| 126 | + ('min', _RAMP_EXPECTED_MIN), |
| 127 | + ('max', _RAMP_EXPECTED_MAX), |
| 128 | + ('median', _RAMP_EXPECTED_MEDIAN), |
| 129 | +]) |
| 130 | +def test_block_reduce_2d_cpu(method, expected): |
| 131 | + """``_block_reduce_2d`` returns the documented reduction per 2x2 block.""" |
| 132 | + arr = _arr_4x4_ramp() |
| 133 | + out = _block_reduce_2d(arr, method) |
| 134 | + np.testing.assert_allclose(out, expected) |
| 135 | + |
| 136 | + |
| 137 | +@pytest.mark.parametrize("method", ['min', 'max', 'median']) |
| 138 | +def test_block_reduce_2d_cpu_skips_nan(method): |
| 139 | + """``_block_reduce_2d`` uses nan-aware reductions so partial-NaN |
| 140 | + blocks aggregate over the finite cells only.""" |
| 141 | + arr = _arr_4x4_with_nan() |
| 142 | + out = _block_reduce_2d(arr, method) |
| 143 | + assert np.all(np.isfinite(out)), ( |
| 144 | + f"method={method!r} returned NaN for a partial-NaN block") |
| 145 | + |
| 146 | + # Recompute expected via numpy nan-aware ops on the same 2x2 reshape. |
| 147 | + blocks = arr.reshape(2, 2, 2, 2) |
| 148 | + flat = blocks.transpose(0, 2, 1, 3).reshape(2, 2, 4) |
| 149 | + if method == 'min': |
| 150 | + expected = np.nanmin(flat, axis=2) |
| 151 | + elif method == 'max': |
| 152 | + expected = np.nanmax(flat, axis=2) |
| 153 | + else: |
| 154 | + expected = np.nanmedian(flat, axis=2) |
| 155 | + np.testing.assert_allclose(out, expected.astype(np.float32)) |
| 156 | + |
| 157 | + |
| 158 | +@pytest.mark.parametrize("method, expected", [ |
| 159 | + ('min', _RAMP_EXPECTED_MIN), |
| 160 | + ('max', _RAMP_EXPECTED_MAX), |
| 161 | + ('median', _RAMP_EXPECTED_MEDIAN), |
| 162 | +]) |
| 163 | +def test_to_geotiff_cog_overview_resampling_cpu(tmp_path, method, expected): |
| 164 | + """End-to-end: ``to_geotiff(cog=True, overview_resampling=method)`` |
| 165 | + writes a COG whose overview level 1 matches the closed-form 2x2 |
| 166 | + reduction.""" |
| 167 | + arr = _arr_4x4_ramp() |
| 168 | + da = xr.DataArray(arr, dims=['y', 'x']) |
| 169 | + p = str(tmp_path / f'cog_{method}.tif') |
| 170 | + to_geotiff(da, p, cog=True, compression='deflate', tiled=True, |
| 171 | + tile_size=2, overview_levels=[1], |
| 172 | + overview_resampling=method) |
| 173 | + |
| 174 | + ov = open_geotiff(p, overview_level=1) |
| 175 | + np.testing.assert_allclose(np.asarray(ov.data), expected) |
| 176 | + |
| 177 | + |
| 178 | +@pytest.mark.parametrize("method", ['min', 'max', 'median']) |
| 179 | +def test_to_geotiff_cog_overview_resampling_cpu_nodata(tmp_path, method): |
| 180 | + """CPU writer: nan-aware reductions skip the sentinel when ``nodata`` |
| 181 | + is set (the regression that motivated issue #1613, here covering the |
| 182 | + min/max/median branches that #1613 did not test).""" |
| 183 | + arr = _arr_4x4_with_nan() |
| 184 | + da = xr.DataArray(arr, dims=['y', 'x']) |
| 185 | + p = str(tmp_path / f'cog_{method}_nodata.tif') |
| 186 | + to_geotiff(da, p, nodata=-9999.0, cog=True, compression='deflate', |
| 187 | + tiled=True, tile_size=2, overview_levels=[1], |
| 188 | + overview_resampling=method) |
| 189 | + |
| 190 | + ov = open_geotiff(p, overview_level=1) |
| 191 | + out = np.asarray(ov.data) |
| 192 | + |
| 193 | + # Recompute expected from the same nan-aware reduction on the source. |
| 194 | + blocks = arr.reshape(2, 2, 2, 2) |
| 195 | + flat = blocks.transpose(0, 2, 1, 3).reshape(2, 2, 4) |
| 196 | + if method == 'min': |
| 197 | + expected = np.nanmin(flat, axis=2) |
| 198 | + elif method == 'max': |
| 199 | + expected = np.nanmax(flat, axis=2) |
| 200 | + else: |
| 201 | + expected = np.nanmedian(flat, axis=2) |
| 202 | + np.testing.assert_allclose(out, expected.astype(np.float32)) |
| 203 | + |
| 204 | + |
| 205 | +# --------------------------------------------------------------------------- |
| 206 | +# Cat 4 HIGH: GPU writer overview_resampling=min/max/median. |
| 207 | +# --------------------------------------------------------------------------- |
| 208 | + |
| 209 | +@_gpu_only |
| 210 | +@pytest.mark.parametrize("method, expected", [ |
| 211 | + ('min', _RAMP_EXPECTED_MIN), |
| 212 | + ('max', _RAMP_EXPECTED_MAX), |
| 213 | + ('median', _RAMP_EXPECTED_MEDIAN), |
| 214 | +]) |
| 215 | +def test_block_reduce_2d_gpu(method, expected): |
| 216 | + """``_block_reduce_2d_gpu`` returns the same reduction as the CPU |
| 217 | + block reducer for finite input.""" |
| 218 | + import cupy |
| 219 | + |
| 220 | + from xrspatial.geotiff._gpu_decode import _block_reduce_2d_gpu |
| 221 | + |
| 222 | + arr_cpu = _arr_4x4_ramp() |
| 223 | + arr_gpu = cupy.asarray(arr_cpu) |
| 224 | + out = _block_reduce_2d_gpu(arr_gpu, method) |
| 225 | + np.testing.assert_allclose(cupy.asnumpy(out), expected) |
| 226 | + |
| 227 | + |
| 228 | +@_gpu_only |
| 229 | +@pytest.mark.parametrize("method", ['min', 'max', 'median']) |
| 230 | +def test_block_reduce_2d_gpu_matches_cpu_with_nan(method): |
| 231 | + """GPU nan-aware reductions match CPU nan-aware reductions for a |
| 232 | + partial-NaN block.""" |
| 233 | + import cupy |
| 234 | + |
| 235 | + from xrspatial.geotiff._gpu_decode import _block_reduce_2d_gpu |
| 236 | + |
| 237 | + arr_cpu = _arr_4x4_with_nan() |
| 238 | + cpu_out = _block_reduce_2d(arr_cpu, method) |
| 239 | + gpu_out = _block_reduce_2d_gpu(cupy.asarray(arr_cpu), method) |
| 240 | + np.testing.assert_allclose(cupy.asnumpy(gpu_out), cpu_out) |
| 241 | + |
| 242 | + |
| 243 | +@_gpu_only |
| 244 | +@pytest.mark.parametrize("method, expected", [ |
| 245 | + ('min', _RAMP_EXPECTED_MIN), |
| 246 | + ('max', _RAMP_EXPECTED_MAX), |
| 247 | + ('median', _RAMP_EXPECTED_MEDIAN), |
| 248 | +]) |
| 249 | +def test_write_geotiff_gpu_cog_overview_resampling(tmp_path, method, expected): |
| 250 | + """End-to-end: ``write_geotiff_gpu(cog=True, overview_resampling=method)`` |
| 251 | + writes a COG whose overview level 1 matches the closed-form 2x2 |
| 252 | + reduction. Exercises the GPU make-overview path including the dispatch |
| 253 | + on ``method``.""" |
| 254 | + import cupy |
| 255 | + |
| 256 | + from xrspatial.geotiff import write_geotiff_gpu |
| 257 | + |
| 258 | + arr = _arr_4x4_ramp() |
| 259 | + arr_gpu = cupy.asarray(arr) |
| 260 | + da = xr.DataArray(arr_gpu, dims=['y', 'x']) |
| 261 | + p = str(tmp_path / f'cog_{method}_gpu.tif') |
| 262 | + write_geotiff_gpu(da, p, cog=True, compression='deflate', tiled=True, |
| 263 | + tile_size=2, overview_levels=[1], |
| 264 | + overview_resampling=method) |
| 265 | + |
| 266 | + ov = open_geotiff(p, overview_level=1) |
| 267 | + np.testing.assert_allclose(np.asarray(ov.data), expected) |
| 268 | + |
| 269 | + |
| 270 | +@_gpu_only |
| 271 | +@pytest.mark.parametrize("method", ['min', 'max', 'median']) |
| 272 | +def test_to_geotiff_gpu_cog_overview_matches_cpu(tmp_path, method): |
| 273 | + """``to_geotiff(gpu=True, ..., overview_resampling=method)`` produces |
| 274 | + overview bytes that round-trip to the same values as the CPU writer.""" |
| 275 | + import cupy |
| 276 | + |
| 277 | + arr = _arr_4x4_ramp() |
| 278 | + da_cpu = xr.DataArray(arr, dims=['y', 'x']) |
| 279 | + p_cpu = str(tmp_path / f'cog_{method}_cpu.tif') |
| 280 | + to_geotiff(da_cpu, p_cpu, cog=True, compression='deflate', tiled=True, |
| 281 | + tile_size=2, overview_levels=[1], |
| 282 | + overview_resampling=method) |
| 283 | + |
| 284 | + da_gpu = xr.DataArray(cupy.asarray(arr), dims=['y', 'x']) |
| 285 | + p_gpu = str(tmp_path / f'cog_{method}_gpu_via_to_geotiff.tif') |
| 286 | + to_geotiff(da_gpu, p_gpu, gpu=True, cog=True, compression='deflate', |
| 287 | + tiled=True, tile_size=2, overview_levels=[1], |
| 288 | + overview_resampling=method) |
| 289 | + |
| 290 | + ov_cpu = np.asarray(open_geotiff(p_cpu, overview_level=1).data) |
| 291 | + ov_gpu = np.asarray(open_geotiff(p_gpu, overview_level=1).data) |
| 292 | + np.testing.assert_allclose(ov_gpu, ov_cpu) |
| 293 | + |
| 294 | + |
| 295 | +# --------------------------------------------------------------------------- |
| 296 | +# Error path: unknown method names raise ValueError on both backends. |
| 297 | +# --------------------------------------------------------------------------- |
| 298 | + |
| 299 | +def test_block_reduce_2d_cpu_unknown_method_raises(): |
| 300 | + """The CPU block reducer raises ``ValueError`` on an unknown method |
| 301 | + name. Exercises the else-branch that lists the valid methods.""" |
| 302 | + arr = _arr_4x4_ramp() |
| 303 | + with pytest.raises(ValueError, match="Unknown overview resampling"): |
| 304 | + _block_reduce_2d(arr, 'bogus') |
| 305 | + |
| 306 | + |
| 307 | +@_gpu_only |
| 308 | +def test_block_reduce_2d_gpu_unknown_method_raises(): |
| 309 | + """The GPU block reducer raises ``ValueError`` on an unknown method |
| 310 | + name. The CPU equivalent already raises for parity.""" |
| 311 | + import cupy |
| 312 | + |
| 313 | + from xrspatial.geotiff._gpu_decode import _block_reduce_2d_gpu |
| 314 | + |
| 315 | + arr_gpu = cupy.asarray(_arr_4x4_ramp()) |
| 316 | + with pytest.raises(ValueError, match="Unknown GPU overview resampling"): |
| 317 | + _block_reduce_2d_gpu(arr_gpu, 'bogus') |
0 commit comments