diff --git a/xrspatial/geotiff/__init__.py b/xrspatial/geotiff/__init__.py index 67a5fade..6e1cbfe3 100644 --- a/xrspatial/geotiff/__init__.py +++ b/xrspatial/geotiff/__init__.py @@ -2435,6 +2435,7 @@ def _read(http_meta): from ._reader import ( _fetch_decode_cog_http_tiles, MAX_PIXELS_DEFAULT, + _apply_photometric_miniswhite, ) header, ifd = http_meta if _is_http_src: @@ -2454,6 +2455,7 @@ def _read(http_meta): if (arr.ndim == 3 and ifd.samples_per_pixel > 1 and band is not None): arr = arr[:, :, band] + arr = _apply_photometric_miniswhite(arr, ifd) else: _r2a_kwargs = {} if max_pixels is not None: @@ -3299,6 +3301,13 @@ def _read_once(): geo_info = _apply_orientation_geo_info( geo_info, orientation, file_h=height, file_w=width) + if (ifd.photometric == 0 and samples == 1 and not arr_was_cpu_decoded): + gpu_dtype = np.dtype(str(arr_gpu.dtype)) + if gpu_dtype.kind == 'u': + arr_gpu = np.iinfo(gpu_dtype).max - arr_gpu + elif gpu_dtype.kind == 'f': + arr_gpu = -arr_gpu + # Apply nodata mask + record sentinel so the GPU read agrees with the # CPU eager path (issue #1542). Without this, integer rasters keep the # literal sentinel value and float rasters keep the sentinel rather diff --git a/xrspatial/geotiff/_reader.py b/xrspatial/geotiff/_reader.py index 19c2105f..0d4a5d70 100644 --- a/xrspatial/geotiff/_reader.py +++ b/xrspatial/geotiff/_reader.py @@ -1941,6 +1941,8 @@ def _read_cog_http(url: str, overview_level: int | None = None, arr, geo_info = _apply_orientation_with_geo( arr, geo_info, ifd.orientation) + arr = _apply_photometric_miniswhite(arr, ifd) + return arr, geo_info @@ -2290,6 +2292,16 @@ def _apply_orientation_with_geo( return arr, geo_info +def _apply_photometric_miniswhite(arr: np.ndarray, ifd: IFD) -> np.ndarray: + """Apply TIFF MinIsWhite inversion for single-band grayscale images.""" + if ifd.photometric == 0 and ifd.samples_per_pixel == 1: + if arr.dtype.kind == 'u': + return np.iinfo(arr.dtype).max - arr + if arr.dtype.kind == 'f': + return -arr + return arr + + def read_to_array(source, *, window=None, overview_level: int | None = None, band: int | None = None, max_pixels: int = MAX_PIXELS_DEFAULT, @@ -2421,12 +2433,7 @@ def read_to_array(source, *, window=None, overview_level: int | None = None, arr, geo_info = _apply_orientation_with_geo( arr, geo_info, orientation) - # MinIsWhite (photometric=0): invert single-band grayscale values - if ifd.photometric == 0 and ifd.samples_per_pixel == 1: - if arr.dtype.kind == 'u': - arr = np.iinfo(arr.dtype).max - arr - elif arr.dtype.kind == 'f': - arr = -arr + arr = _apply_photometric_miniswhite(arr, ifd) finally: src.close() diff --git a/xrspatial/geotiff/tests/test_miniswhite_backend_parity_1797.py b/xrspatial/geotiff/tests/test_miniswhite_backend_parity_1797.py new file mode 100644 index 00000000..6520ada6 --- /dev/null +++ b/xrspatial/geotiff/tests/test_miniswhite_backend_parity_1797.py @@ -0,0 +1,120 @@ +"""MinIsWhite photometric handling must be backend-consistent (#1797).""" +from __future__ import annotations + +import http.server +import importlib.util +import socketserver +import threading + +import numpy as np +import pytest + +from xrspatial.geotiff import open_geotiff + +tifffile = pytest.importorskip("tifffile") + + +def _gpu_available() -> 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() +_gpu_only = pytest.mark.skipif( + not _HAS_GPU, + reason="cupy + CUDA required", +) + + +class _RangeHandler(http.server.BaseHTTPRequestHandler): + payload: bytes = b'' + + def do_GET(self): # noqa: N802 + rng = self.headers.get('Range') + if rng and rng.startswith('bytes='): + spec = rng[len('bytes='):] + start_s, _, end_s = spec.partition('-') + start = int(start_s) + end = int(end_s) if end_s else len(self.payload) - 1 + chunk = self.payload[start:end + 1] + self.send_response(206) + self.send_header('Content-Type', 'application/octet-stream') + self.send_header( + 'Content-Range', + f'bytes {start}-{start + len(chunk) - 1}/{len(self.payload)}', + ) + self.send_header('Content-Length', str(len(chunk))) + self.end_headers() + self.wfile.write(chunk) + return + self.send_response(200) + self.send_header('Content-Type', 'application/octet-stream') + self.send_header('Content-Length', str(len(self.payload))) + self.end_headers() + self.wfile.write(self.payload) + + def log_message(self, *_args, **_kwargs): + pass + + +def _serve(payload: bytes): + handler_cls = type( + 'RangeHandler1797', (_RangeHandler,), {'payload': payload} + ) + httpd = socketserver.TCPServer(('127.0.0.1', 0), handler_cls) + port = httpd.server_address[1] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + return httpd, port + + +@pytest.fixture +def miniswhite_http_url(tmp_path, monkeypatch): + monkeypatch.setenv('XRSPATIAL_GEOTIFF_ALLOW_PRIVATE_HOSTS', '1') + stored = np.array([[0, 1, 2], [10, 128, 255]], dtype=np.uint8) + path = tmp_path / "tmp_1797_miniswhite.tif" + tifffile.imwrite(str(path), stored, photometric='miniswhite') + httpd, port = _serve(path.read_bytes()) + try: + yield f'http://127.0.0.1:{port}/tmp_1797_miniswhite.tif', stored + finally: + httpd.shutdown() + httpd.server_close() + + +def test_http_miniswhite_matches_local_reader(miniswhite_http_url): + url, stored = miniswhite_http_url + + got = open_geotiff(url) + + np.testing.assert_array_equal(got.values, np.iinfo(stored.dtype).max - stored) + + +def test_http_dask_miniswhite_matches_local_reader(miniswhite_http_url): + url, stored = miniswhite_http_url + + got = open_geotiff(url, chunks=2).compute() + + np.testing.assert_array_equal(got.values, np.iinfo(stored.dtype).max - stored) + + +@_gpu_only +def test_gpu_miniswhite_matches_cpu_reader(tmp_path): + from xrspatial.geotiff._writer import write + + stored = np.array([[0, 1, 2], [10, 128, 255]], dtype=np.uint8) + path = str(tmp_path / "tmp_1797_miniswhite_gpu.tif") + write(stored, path, compression='deflate', tiled=True, tile_size=16, + photometric='miniswhite') + + cpu = open_geotiff(path) + gpu = open_geotiff(path, gpu=True) + + np.testing.assert_array_equal(cpu.values, np.iinfo(stored.dtype).max - stored) + np.testing.assert_array_equal(gpu.data.get(), cpu.values)