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
4 changes: 2 additions & 2 deletions negpy/infrastructure/loaders/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 10 additions & 8 deletions negpy/infrastructure/loaders/tiff_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion negpy/services/rendering/image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions negpy/services/rendering/preview_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_preview_manager_open_count.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions tests/test_tiff_loader_encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
Loading