From bf2a4d508bd4b8e762ecc4e0c399156f35e6f134 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 21 Jul 2026 18:55:00 +0200 Subject: [PATCH] fix: gate TIFF sRGB decode behind Linear RAW toggle A TIFF tagged (or assumed) sRGB got degamma'd to scene-linear unconditionally, lifting shadows when the tag lied about the actual encoding (e.g. stitcher output). Linear RAW now controls this per the same neutral-decode semantics it already has for camera RAW. --- negpy/infrastructure/loaders/factory.py | 4 ++-- negpy/infrastructure/loaders/tiff_loader.py | 18 ++++++++------- negpy/services/rendering/image_processor.py | 2 +- negpy/services/rendering/preview_manager.py | 6 ++--- tests/test_preview_manager_open_count.py | 2 +- tests/test_tiff_loader_encoding.py | 25 +++++++++++++++++++-- 6 files changed, 40 insertions(+), 17 deletions(-) diff --git a/negpy/infrastructure/loaders/factory.py b/negpy/infrastructure/loaders/factory.py index fdf6fe9d..c2ed87d9 100644 --- a/negpy/infrastructure/loaders/factory.py +++ b/negpy/infrastructure/loaders/factory.py @@ -21,11 +21,11 @@ def __init__(self) -> None: self._jpeg = JpegLoader() self._rawpy = RawpyLoader() - def get_loader(self, file_path: str) -> Tuple[ContextManager[Any], dict]: + def get_loader(self, file_path: str, linear_raw: bool = False) -> Tuple[ContextManager[Any], dict]: ext = os.path.splitext(file_path)[1].lower() if ext in SUPPORTED_TIFF_EXTENSIONS: - return self._tiff.load(file_path) + return self._tiff.load(file_path, linear_raw=linear_raw) if ext in SUPPORTED_JPEG_EXTENSIONS: return self._jpeg.load(file_path) diff --git a/negpy/infrastructure/loaders/tiff_loader.py b/negpy/infrastructure/loaders/tiff_loader.py index c9e74392..d6902eb1 100644 --- a/negpy/infrastructure/loaders/tiff_loader.py +++ b/negpy/infrastructure/loaders/tiff_loader.py @@ -125,7 +125,7 @@ class TiffLoader(IImageLoader): (either as a 4th sample with ExtraSamples=UNSPECIFIED, or via a `_IR.tif` sidecar). """ - def load(self, file_path: str) -> Tuple[ContextManager[Any], dict]: + def load(self, file_path: str, linear_raw: bool = False) -> Tuple[ContextManager[Any], dict]: img = iio.imread(file_path) ir: Optional[np.ndarray] = None ir_valid_mask: Optional[np.ndarray] = None @@ -161,13 +161,15 @@ def load(self, file_path: str) -> Tuple[ContextManager[Any], dict]: except Exception: icc_bytes = None - color_space = identify_color_space_from_icc(icc_bytes) - if color_space is None and img.dtype == np.uint8: - # Untagged 8-bit is display-encoded in practice. Untagged 16-bit is - # scanner-raw linear, which no ColorSpace names, so it stays None. - color_space = ColorSpace.SRGB.value - if color_space == ColorSpace.SRGB.value: - f32 = srgb_to_linear(f32) + color_space = None + if not linear_raw: + color_space = identify_color_space_from_icc(icc_bytes) + if color_space is None and img.dtype == np.uint8: + # Untagged 8-bit is display-encoded in practice. Untagged 16-bit is + # scanner-raw linear, which no ColorSpace names, so it stays None. + color_space = ColorSpace.SRGB.value + if color_space == ColorSpace.SRGB.value: + f32 = srgb_to_linear(f32) metadata = { "orientation": read_orientation(file_path), "color_space": color_space, diff --git a/negpy/services/rendering/image_processor.py b/negpy/services/rendering/image_processor.py index bf763548..51b64f12 100644 --- a/negpy/services/rendering/image_processor.py +++ b/negpy/services/rendering/image_processor.py @@ -410,7 +410,7 @@ def _decode_sensor_rgb(self, file_path: str, linear_raw: bool, fast: bool = Fals half_size would distort colors (see _use_half_size_decode). Returns (rgb_uint16, loader_metadata). """ - ctx_mgr, metadata = loader_factory.get_loader(file_path) + ctx_mgr, metadata = loader_factory.get_loader(file_path, linear_raw=linear_raw) with ctx_mgr as raw: algo = get_best_demosaic_algorithm(raw) user_wb = [1, 1, 1, 1] if linear_raw else None diff --git a/negpy/services/rendering/preview_manager.py b/negpy/services/rendering/preview_manager.py index 4b0b25a5..6a9f64e6 100644 --- a/negpy/services/rendering/preview_manager.py +++ b/negpy/services/rendering/preview_manager.py @@ -274,7 +274,7 @@ def load_linear_preview( logger.debug("preview cache hit %.3fs for %s", time.perf_counter() - t_all, file_path) return hit # cache hit — caller must not mutate this buffer - ctx_mgr, metadata = loader_factory.get_loader(file_path) + ctx_mgr, metadata = loader_factory.get_loader(file_path, linear_raw=not use_camera_wb) if color_space is None: color_space = metadata.get("color_space") or WORKING_COLOR_SPACE @@ -314,7 +314,7 @@ def decode_for_detection(self, file_path: str) -> Optional[ImageBuffer]: """No-WB linear decode for autodetect only — skips the preview resize/orient/cache (detect_process_mode downsamples), so it costs just the demosaic. Mirrors the fast path.""" try: - ctx_mgr, _meta = loader_factory.get_loader(file_path) + ctx_mgr, _meta = loader_factory.get_loader(file_path, linear_raw=True) with ctx_mgr as raw: if isinstance(raw, NonStandardFileWrapper): demosaic = get_best_demosaic_algorithm(raw) @@ -471,7 +471,7 @@ def load_splash_and_linear( return None, hit # no splash on cache hit — linear is already fast try: - ctx_mgr, metadata = loader_factory.get_loader(file_path) + ctx_mgr, metadata = loader_factory.get_loader(file_path, linear_raw=not use_camera_wb) except Exception as e: logger.debug("preview load_splash_and_linear open failed: %s", e) raise diff --git a/tests/test_preview_manager_open_count.py b/tests/test_preview_manager_open_count.py index a5012eb9..6808d131 100644 --- a/tests/test_preview_manager_open_count.py +++ b/tests/test_preview_manager_open_count.py @@ -37,7 +37,7 @@ def _make_fake_raw(w: int = 100, h: int = 100) -> MagicMock: def _make_loader_factory_patch(fake_raw: MagicMock, open_count: dict): """Return a side_effect callable for loader_factory.get_loader.""" - def fake_get_loader(path): + def fake_get_loader(path, linear_raw=False): open_count["n"] += 1 metadata = {"color_space": "Adobe RGB", "orientation": 0, "raw_flip": 0} return fake_raw, metadata diff --git a/tests/test_tiff_loader_encoding.py b/tests/test_tiff_loader_encoding.py index ff5d2971..cc4fd899 100644 --- a/tests/test_tiff_loader_encoding.py +++ b/tests/test_tiff_loader_encoding.py @@ -22,8 +22,8 @@ def _rgb16(seed: int = 0) -> np.ndarray: return rng.integers(0, 30000, (32, 48, 3), dtype=np.uint16) -def _load(path: str) -> tuple[np.ndarray, dict]: - ctx, metadata = TiffLoader().load(path) +def _load(path: str, linear_raw: bool = False) -> tuple[np.ndarray, dict]: + ctx, metadata = TiffLoader().load(path, linear_raw=linear_raw) with ctx as raw: return raw.data, metadata @@ -58,6 +58,27 @@ def test_srgb_icc_uint16_gets_srgb_decode(self) -> None: np.testing.assert_allclose(f32, srgb_to_linear(data.astype(np.float32) / 65535.0), atol=1e-6) assert metadata["color_space"] == ColorSpace.SRGB.value + def test_linear_raw_ignores_srgb_icc_tag(self) -> None: + """Linear RAW couples to the loader: a stitched/scanner TIFF that lies about + being sRGB (see #588) must decode as literal linear data when it's on.""" + icc = ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes() + data = _rgb16() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "tagged.tif") + tifffile.imwrite(path, data, photometric="rgb", extratags=[(34675, 7, len(icc), icc, True)]) + f32, metadata = _load(path, linear_raw=True) + np.testing.assert_allclose(f32, data.astype(np.float32) / 65535.0, atol=1e-7) + assert metadata["color_space"] is None + + def test_linear_raw_ignores_untagged_uint8_srgb_assumption(self) -> None: + data = np.linspace(0, 255, 32 * 48 * 3).reshape(32, 48, 3).astype(np.uint8) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "photo.tif") + tifffile.imwrite(path, data, photometric="rgb") + f32, metadata = _load(path, linear_raw=True) + np.testing.assert_allclose(f32, data.astype(np.float32) / 255.0, atol=1e-7) + assert metadata["color_space"] is None + class TestScanRoundTripParity: def test_tiff_and_dng_decode_identically(self) -> None: