From 0185978b4e4ab7baa553b09aa7eab3507230e255 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 7 May 2026 19:23:05 -0700 Subject: [PATCH 1/2] Fix read_geotiff_gpu byteswap on big-endian multi-byte TIFFs cupy.ndarray (13.x) does not expose .byteswap(), so any BE multi-byte TIFF hit AttributeError inside the GPU decode pipeline. The dispatcher in read_geotiff_gpu caught it and silently fell back to CPU, so output stayed correct but the GPU path was effectively dead for BE data. Replace both arr.byteswap() calls with a small helper that views the array as the swapped-order dtype and copies, which works on numpy and cupy arrays alike. Closes #1508 --- xrspatial/geotiff/_gpu_decode.py | 17 ++++- .../geotiff/tests/test_gpu_byteswap_1508.py | 74 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 xrspatial/geotiff/tests/test_gpu_byteswap_1508.py diff --git a/xrspatial/geotiff/_gpu_decode.py b/xrspatial/geotiff/_gpu_decode.py index 4cb5fe75..a5d74f46 100644 --- a/xrspatial/geotiff/_gpu_decode.py +++ b/xrspatial/geotiff/_gpu_decode.py @@ -56,6 +56,17 @@ def _check_gpu_memory(required_bytes: int, what: str = "tile buffer") -> None: "with cupy.get_default_memory_pool().free_all_blocks()." ) +def _xp_byteswap(arr): + """Return *arr* with each element's bytes reversed. + + ``numpy.ndarray`` exposes ``byteswap()`` directly, but ``cupy.ndarray`` + (as of cupy 13.x) does not. The view-then-copy trick works on both: + re-interpret the buffer as the swapped-order dtype, then copy to + materialise the swapped bytes as a real array in that dtype. + """ + return arr.view(arr.dtype.newbyteorder()).copy() + + # LZW constants (same as _compression.py) LZW_CLEAR_CODE = 256 LZW_EOI_CODE = 257 @@ -1555,7 +1566,8 @@ def _apply_predictor_and_assemble(d_decomp, d_decomp_offsets, n_tiles, image_height, image_width) if big_endian and dtype.itemsize > 1: # See gpu_decode_tiles for why BE samples need a final byteswap. - out = out.byteswap() + # cupy.ndarray has no .byteswap(), so use the dtype-view helper. + out = _xp_byteswap(out) return out @@ -1814,7 +1826,8 @@ def gpu_decode_tiles( # so big-endian samples that are wider than a byte must be swapped # back to native before the values mean anything. if byte_order == '>' and dtype.itemsize > 1: - out = out.byteswap() + # cupy.ndarray has no .byteswap(), so use the dtype-view helper. + out = _xp_byteswap(out) return out diff --git a/xrspatial/geotiff/tests/test_gpu_byteswap_1508.py b/xrspatial/geotiff/tests/test_gpu_byteswap_1508.py new file mode 100644 index 00000000..3a25d762 --- /dev/null +++ b/xrspatial/geotiff/tests/test_gpu_byteswap_1508.py @@ -0,0 +1,74 @@ +"""Regression test for issue #1508. + +Big-endian multi-byte TIFFs read via ``read_geotiff_gpu`` used to crash +inside the GPU decode pipeline with:: + + AttributeError: 'ndarray' object has no attribute 'byteswap' + +because ``cupy.ndarray`` (as of cupy 13.x) does not expose ``byteswap()``. +The dispatcher in ``read_geotiff_gpu`` caught the error and silently fell +back to CPU, so results stayed correct but the GPU fast path was lost. + +These tests confirm the GPU path now decodes BE multi-byte data directly +(result is a CuPy array, not a NumPy fallback) and matches the CPU read. +""" +from __future__ import annotations + +import numpy as np +import pytest + +tifffile = pytest.importorskip("tifffile") +cupy = pytest.importorskip("cupy") +if not cupy.cuda.is_available(): + pytest.skip("CUDA not available", allow_module_level=True) + +from xrspatial.geotiff import read_geotiff_gpu # noqa: E402 +from xrspatial.geotiff._reader import read_to_array # noqa: E402 + + +@pytest.mark.parametrize("dtype", [np.uint16, np.int16, np.uint32, np.int32]) +def test_read_geotiff_gpu_big_endian_multibyte(tmp_path, dtype): + """GPU path decodes BE multi-byte tiles and stays on GPU.""" + rng = np.random.RandomState(20260507) + info = np.iinfo(dtype) + arr = rng.randint( + info.min, info.max, size=(32, 48), dtype=np.int64 + ).astype(dtype) + + path = tmp_path / f"be_{np.dtype(dtype).name}.tif" + tifffile.imwrite( + str(path), arr, byteorder=">", compression="deflate", + tile=(16, 16), + ) + + cpu, _ = read_to_array(str(path)) + np.testing.assert_array_equal(cpu, arr) + + gpu_da = read_geotiff_gpu(str(path)) + + # The GPU path was actually exercised (no silent CPU fallback masking + # a crash inside gpu_decode_tiles_from_file). + assert isinstance(gpu_da.data, cupy.ndarray), ( + "expected cupy-backed DataArray, got " + f"{type(gpu_da.data).__name__} -- the GPU path likely fell back " + "to CPU again" + ) + + np.testing.assert_array_equal(gpu_da.data.get(), arr) + + +def test_read_geotiff_gpu_big_endian_uncompressed(tmp_path): + """Uncompressed BE multi-byte tiles also stay on the GPU.""" + rng = np.random.RandomState(20260507) + arr = rng.randint(0, 60000, size=(32, 48), dtype=np.uint16) + + path = tmp_path / "be_uint16_raw.tif" + tifffile.imwrite( + str(path), arr, byteorder=">", compression=None, tile=(16, 16), + ) + + gpu_da = read_geotiff_gpu(str(path)) + assert isinstance(gpu_da.data, cupy.ndarray), ( + "expected cupy-backed DataArray; GPU path may have fallen back" + ) + np.testing.assert_array_equal(gpu_da.data.get(), arr) From a7ac3038ec1338ac56a829821e627a12fd6b33d7 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Fri, 8 May 2026 07:30:11 -0700 Subject: [PATCH 2/2] Address PR #1515 review: preserve native dtype in _xp_byteswap The earlier implementation, ``arr.view(arr.dtype.newbyteorder()).copy()``, left the result tagged with non-native byteorder (``>u2`` instead of `` None: ) def _xp_byteswap(arr): - """Return *arr* with each element's bytes reversed. - - ``numpy.ndarray`` exposes ``byteswap()`` directly, but ``cupy.ndarray`` - (as of cupy 13.x) does not. The view-then-copy trick works on both: - re-interpret the buffer as the swapped-order dtype, then copy to - materialise the swapped bytes as a real array in that dtype. + """Return *arr* with each element's bytes physically reversed. + + Equivalent to ``numpy.ndarray.byteswap()``: the dtype is preserved + (still native-endian on output), and the bytes that make up each + element are flipped end-for-end. Works on both numpy and cupy. + + The earlier ``arr.view(arr.dtype.newbyteorder()).copy()`` shortcut + looked equivalent but produced an array whose dtype was tagged with + the opposite byte order (e.g. ``>u2`` instead of `` bool: + """True if cupy is importable and CUDA is initialised.""" + if importlib.util.find_spec("cupy") is None: + return False + try: + import cupy + return bool(cupy.cuda.is_available()) + except Exception: + return False + + +_HAS_GPU = _gpu_available() +_HAS_TIFFFILE = importlib.util.find_spec("tifffile") is not None +_gpu_only = pytest.mark.skipif( + not (_HAS_GPU and _HAS_TIFFFILE), + reason="cupy + CUDA + tifffile required", +) +@_gpu_only @pytest.mark.parametrize("dtype", [np.uint16, np.int16, np.uint32, np.int32]) def test_read_geotiff_gpu_big_endian_multibyte(tmp_path, dtype): """GPU path decodes BE multi-byte tiles and stays on GPU.""" + import cupy + import tifffile + + from xrspatial.geotiff import read_geotiff_gpu + from xrspatial.geotiff._reader import read_to_array + rng = np.random.RandomState(20260507) info = np.iinfo(dtype) arr = rng.randint( @@ -43,6 +63,9 @@ def test_read_geotiff_gpu_big_endian_multibyte(tmp_path, dtype): cpu, _ = read_to_array(str(path)) np.testing.assert_array_equal(cpu, arr) + assert cpu.dtype == np.dtype(dtype), ( + f"CPU baseline drifted from native dtype: got {cpu.dtype}" + ) gpu_da = read_geotiff_gpu(str(path)) @@ -54,11 +77,31 @@ def test_read_geotiff_gpu_big_endian_multibyte(tmp_path, dtype): "to CPU again" ) + # The fix must preserve the native dtype contract. An earlier version + # used ``arr.view(arr.dtype.newbyteorder()).copy()`` which produced an + # array tagged with non-native byteorder (``>u2`` instead of ``