Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions xrspatial/geotiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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

Comment on lines +3304 to +3310
# 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
Expand Down
19 changes: 13 additions & 6 deletions xrspatial/geotiff/_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down
120 changes: 120 additions & 0 deletions xrspatial/geotiff/tests/test_miniswhite_backend_parity_1797.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +35 to +74


@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)
Loading