From dcce967f36ef4de08b1f4ead10a34ea214edf707 Mon Sep 17 00:00:00 2001 From: Mats Date: Tue, 4 Aug 2026 12:26:23 +0200 Subject: [PATCH 1/4] Add RGB-scan triplet merge to Linear Output Three narrowband exposures are decoded and merged via merge_rgb_triplet() into a single combined 16-bit TIFF. No sensor correction (not applicable to narrowband captures). WB and device metadata from the primary exposure. Co-Authored-By: Claude Opus 4.6 --- docs/PIPELINE.md | 1 + docs/USER_GUIDE.md | 2 +- negpy/desktop/controller.py | 3 +- negpy/services/export/linear_output.py | 86 +++++++++++--- tests/test_linear_output.py | 156 +++++++++++++++++++++++++ 5 files changed, 233 insertions(+), 15 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index b5e5a47f..e2b8e4dd 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -147,6 +147,7 @@ When the render intent is **Linear**, the entire darkroom pipeline is bypassed. * **Pakon RAW**: the uint16 scanner data is scaled by an expansion factor to use more of the 16-bit output range. F135 (14-bit sensor, confirmed) defaults to 4× (`PAKON_EXPANSION`); F335 (16-bit sensor, detected by file size) defaults to 1× (off). The 2k Square and Panoram specs are assumed 14-bit (same default as F135) but this has not been verified with real samples — override manually if needed. MakeTiff uses 2×; 4× places the typical F135 negative peak around 50–55 % of the range. The user can override via the Expansion combo. The applied expansion factor is recorded in the output TIFF's ImageDescription tag. * **LinearRaw DNG**: 4-channel VueScan (RGB+IR) and 3-channel SilverFast HDRi files are read directly via tifffile, bypassing rawpy. The IR channel, when present, is written as a separate grayscale TIFF with an `_ir` suffix. Expansion defaults to off; 2× and 4× are available. * **Camera RAW**: demosaiced by rawpy with `user_wb=[1,1,1,1]` (unity), `output_color=raw` (sensor-native), `gamma=(1,1)` (linear), `no_auto_bright=True`. The camera's as-shot white balance multipliers (green-normalized to three RGB values) are embedded in XMP as `RAW-WB: R G B` inside `dc:description`, matching the MakeTiff/ColorPerfect convention. The source filename is stored in `crs:RawFileName`. No expansion option (camera sensors use the full bit depth). +* **RGB-scan triplets**: when the current frame is an RGB-scan composite (three narrowband exposures), all three are decoded and merged via `merge_rgb_triplet()` into a single combined TIFF. The red exposure is the primary file; green and blue paths come from the frame's `RgbScanConfig`. No sensor correction is applied (narrowband exposures have no cross-channel leakage). WB and device metadata are taken from the primary (red) exposure. Source device metadata (Make, Model, DateTime) is carried through to the output TIFF when available from the source file. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e829d84c..36e0b8aa 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -533,7 +533,7 @@ A scrollable list of every edit step (last 100 kept), newest on top; the current * **Linear**: bypass the entire darkroom pipeline and dump the scanner's or camera's decoded buffer as an untagged linear 16-bit TIFF. No normalization, exposure, colour management, flatfield, or sensor correction — just the raw data with lossless geometry (rotation/flip) applied. Supported sources: * **Pakon RAW** — 4× expansion by default (14-bit sensor range scaled into 16-bit). F335 files (16-bit sensor) default to no expansion. * **LinearRaw DNG** — SilverFast HDRi (3-channel) and VueScan (4-channel RGB+IR). IR is written as a separate grayscale TIFF with an `_ir` suffix. - * **Camera RAW** — demosaiced with unity white balance (1,1,1,1). The camera's as-shot WB is written into XMP (`RAW-WB: R G B`, MakeTiff-compatible) so it can be applied downstream. Source device and timestamp are preserved. + * **Camera RAW** — demosaiced with unity white balance (1,1,1,1). The camera's as-shot WB is written into XMP (`RAW-WB: R G B`, MakeTiff-compatible) so it can be applied downstream. Source device and timestamp are preserved. RGB-scan triplets (narrowband R/G/B exposures) are merged into a single combined TIFF. * **Expansion**: scales the linear data before writing. The combo box shows source-appropriate options: Pakon F135/F235 default to 4×, F335 and LinearRaw DNG default to off. Camera RAW files have no expansion option. Leave at the default unless you know why you need to change it. ### Export button diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index c88edea8..716227ad 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -3364,6 +3364,7 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None: exported = 0 geometry = self.state.config.geometry expansion = self.state.linear_expansion + rgbscan = self.state.config.rgbscan for f in supported: stem = os.path.splitext(os.path.basename(f["path"]))[0] out_path = os.path.join(export_path, f"{stem}_linear.tiff") @@ -3372,7 +3373,7 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None: out_path = os.path.join(export_path, f"{stem}_linear_{counter}.tiff") counter += 1 try: - export_linear_output(f["path"], out_path, geometry=geometry, expansion=expansion) + export_linear_output(f["path"], out_path, geometry=geometry, expansion=expansion, rgbscan=rgbscan) exported += 1 except Exception as e: logger.warning("Linear output failed for %s: %s", f.get("name"), e) diff --git a/negpy/services/export/linear_output.py b/negpy/services/export/linear_output.py index a4150ef3..abd055c4 100644 --- a/negpy/services/export/linear_output.py +++ b/negpy/services/export/linear_output.py @@ -17,6 +17,8 @@ import tifffile as _tifffile from negpy.features.geometry.models import GeometryConfig +from negpy.features.rgbscan.logic import merge_rgb_triplet +from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet from negpy.infrastructure.loaders.constants import SUPPORTED_JPEG_EXTENSIONS, SUPPORTED_RAW_EXTENSIONS, SUPPORTED_TIFF_EXTENSIONS from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper, get_best_demosaic_algorithm, read_orientation from negpy.infrastructure.loaders.pakon_loader import PakonLoader @@ -134,20 +136,28 @@ def _is_linearraw_dng(file_path: str) -> bool: return False +def _apply_user_geometry(f32: np.ndarray, geometry: GeometryConfig) -> np.ndarray: + if geometry.rotation != 0: + f32 = np.rot90(f32, k=geometry.rotation) + if geometry.flip_horizontal: + f32 = np.ascontiguousarray(np.fliplr(f32)) + if geometry.flip_vertical: + f32 = np.ascontiguousarray(np.flipud(f32)) + return f32 + + def _apply_geometry(f32: np.ndarray, orientation: int, geometry: Optional[GeometryConfig]) -> np.ndarray: f32 = apply_exif_orientation(f32, orientation) if geometry is not None: - if geometry.rotation != 0: - f32 = np.rot90(f32, k=geometry.rotation) - if geometry.flip_horizontal: - f32 = np.ascontiguousarray(np.fliplr(f32)) - if geometry.flip_vertical: - f32 = np.ascontiguousarray(np.flipud(f32)) + f32 = _apply_user_geometry(f32, geometry) return f32 def _decode_linear( - file_path: str, geometry: Optional[GeometryConfig] = None, expansion: Optional[float] = None + file_path: str, + geometry: Optional[GeometryConfig] = None, + expansion: Optional[float] = None, + rgbscan: Optional[RgbScanConfig] = None, ) -> tuple[np.ndarray, Optional[np.ndarray], Optional[_CameraWB], _SourceMeta]: """Decode to an oriented float32 buffer. Returns (rgb, ir_or_none, camera_wb_or_none, source_meta).""" if PakonLoader.can_handle(file_path): @@ -163,6 +173,8 @@ def _decode_linear( rgb, ir, wb = _decode_camera_raw(file_path, geometry) return rgb, ir, wb, meta if _is_camera_raw(file_path): + if rgbscan is not None and is_rgb_triplet(rgbscan): + return _decode_camera_raw_triplet(file_path, rgbscan, geometry) meta = _read_source_meta_tiff(file_path) rgb, ir, wb, decode_meta = _decode_camera_raw(file_path, geometry) merged = _SourceMeta( @@ -256,7 +268,12 @@ def _decode_dng( return rgb, ir -def _decode_camera_raw(file_path: str, geometry: Optional[GeometryConfig] = None) -> tuple[np.ndarray, None, _CameraWB, _SourceMeta]: +def _decode_camera_raw_buffer(file_path: str) -> tuple[np.ndarray, _CameraWB, _SourceMeta]: + """Decode a camera RAW to an oriented float32 buffer without applying user geometry. + + Returns (f32, camera_wb, source_meta). EXIF orientation *is* applied (lossless, + baked into the file) but user rotation/flip is not — the caller decides that. + """ raw = rawpy.imread(file_path) wb = _CameraWB( as_shot=tuple(raw.camera_whitebalance), # type: ignore[arg-type] @@ -279,11 +296,45 @@ def _decode_camera_raw(file_path: str, geometry: Optional[GeometryConfig] = None rgb = ensure_rgb(rgb) f32 = uint16_to_float32(rgb) orientation = read_orientation(file_path) - f32 = _apply_geometry(f32, orientation, geometry) + f32 = apply_exif_orientation(f32, orientation) meta = _SourceMeta(datetime=dt_str) + return f32, wb, meta + + +def _decode_camera_raw(file_path: str, geometry: Optional[GeometryConfig] = None) -> tuple[np.ndarray, None, _CameraWB, _SourceMeta]: + f32, wb, meta = _decode_camera_raw_buffer(file_path) + if geometry is not None: + f32 = _apply_user_geometry(f32, geometry) return f32, None, wb, meta +def _decode_camera_raw_triplet( + file_path: str, rgbscan: RgbScanConfig, geometry: Optional[GeometryConfig] = None +) -> tuple[np.ndarray, None, Optional[_CameraWB], _SourceMeta]: + """Decode three narrowband exposures and merge into one RGB buffer.""" + primary_f32, wb, meta = _decode_camera_raw_buffer(file_path) + file_meta = _read_source_meta_tiff(file_path) + merged_meta = _SourceMeta( + make=file_meta.make or meta.make, + model=file_meta.model or meta.model, + datetime=file_meta.datetime or meta.datetime, + ) + + cache: dict[str, np.ndarray] = {file_path: primary_f32} + + def _decode(path: str) -> np.ndarray: + if path in cache: + return cache[path] + buf, _, _ = _decode_camera_raw_buffer(path) + cache[path] = buf + return buf + + f32 = merge_rgb_triplet(_decode, file_path, rgbscan.green_path, rgbscan.blue_path, align=rgbscan.align) + if geometry is not None: + f32 = _apply_user_geometry(f32, geometry) + return f32, None, wb, merged_meta + + def _normalize_wb_rgb(wb: tuple[float, float, float, float]) -> tuple[float, float, float]: """Normalize RGGB multipliers to green=1, return (R, G, B).""" g = wb[1] if wb[1] > 0 else 1.0 @@ -325,12 +376,14 @@ def _effective_expansion(file_path: str, expansion: Optional[float]) -> float: return 1.0 -def _source_format_label(file_path: str) -> str: +def _source_format_label(file_path: str, rgbscan: Optional[RgbScanConfig] = None) -> str: if PakonLoader.can_handle(file_path): return f"Pakon {_pakon_spec_desc(file_path)}" if _is_dng(file_path) and _is_linearraw_dng(file_path): return "DNG LinearRaw" if _is_camera_raw(file_path): + if rgbscan is not None and is_rgb_triplet(rgbscan): + return "camera RAW (RGB triplet)" return "camera RAW" return "unknown" @@ -405,7 +458,11 @@ def _write_ir_tiff(ir: np.ndarray, dest, source_name: str) -> None: def export_linear_output( - file_path: str, output_path: str, geometry: Optional[GeometryConfig] = None, expansion: Optional[float] = None + file_path: str, + output_path: str, + geometry: Optional[GeometryConfig] = None, + expansion: Optional[float] = None, + rgbscan: Optional[RgbScanConfig] = None, ) -> None: """Decode *file_path* and write an untagged linear 16-bit TIFF to *output_path*. @@ -415,12 +472,15 @@ def export_linear_output( *expansion* scales the linear data before writing (e.g. 4.0 for Pakon's 14-bit sensor → 16-bit range). ``None`` uses the source-type default; values <= 1.0 disable. + *rgbscan*, when a valid triplet config, merges three narrowband exposures into + one combined RGB buffer before writing. + If the source has an IR channel, it is written as a separate grayscale TIFF with an ``_ir`` suffix next to the RGB output. """ eff = _effective_expansion(file_path, expansion) - fmt = _source_format_label(file_path) - f32, ir, camera_wb, meta = _decode_linear(file_path, geometry, expansion=expansion) + fmt = _source_format_label(file_path, rgbscan) + f32, ir, camera_wb, meta = _decode_linear(file_path, geometry, expansion=expansion, rgbscan=rgbscan) os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) _write_tiff( f32, output_path, os.path.basename(file_path), camera_wb, source_path=file_path, source_meta=meta, expansion=eff, source_format=fmt diff --git a/tests/test_linear_output.py b/tests/test_linear_output.py index 058c7a9a..de80d9fd 100644 --- a/tests/test_linear_output.py +++ b/tests/test_linear_output.py @@ -2,12 +2,14 @@ import io import os +from unittest import mock import numpy as np import pytest import tifffile from negpy.features.geometry.models import GeometryConfig +from negpy.features.rgbscan.models import RgbScanConfig from negpy.kernel.image.logic import apply_exif_orientation from negpy.services.export.linear_output import ( _CameraWB, @@ -17,6 +19,7 @@ _effective_expansion, _is_camera_raw, _normalize_wb_rgb, + _source_format_label, _write_tiff, export_linear_output, export_linear_output_bytes, @@ -540,3 +543,156 @@ def test_f335_make_model_tags(self, tmp_path: str) -> None: tags = tf.pages[0].tags assert tags["Make"].value == "Pakon" assert "F335" in tags["Model"].value + + +def _make_fake_camera_raws(tmp_dir: str, h: int = 40, w: int = 60) -> tuple[str, str, str]: + """Create three empty .nef files to act as triplet paths.""" + paths = [] + for name in ("red.nef", "green.nef", "blue.nef"): + p = os.path.join(tmp_dir, name) + open(p, "wb").close() + paths.append(p) + return tuple(paths) # type: ignore[return-value] + + +def _triplet_buffers(h: int = 40, w: int = 60) -> dict[str, np.ndarray]: + """Synthetic RGB buffers where each exposure is bright in its own channel.""" + r = np.full((h, w, 3), 0.1, dtype=np.float32) + r[..., 0] = 0.8 + g = np.full((h, w, 3), 0.1, dtype=np.float32) + g[..., 1] = 0.7 + b = np.full((h, w, 3), 0.1, dtype=np.float32) + b[..., 2] = 0.9 + return {"r": r, "g": g, "b": b} + + +_MOCK_WB = _CameraWB(as_shot=(1.5, 1.0, 2.0, 1.0), daylight=(2.0, 1.0, 1.5, 1.0)) +_MOCK_META = _SourceMeta(make="Nikon", model="D850", datetime="2026:01:01 12:00:00") + + +class TestTripletExport: + """Linear Output with RGB-scan triplet merge.""" + + def _patch_decode(self, paths: tuple[str, str, str], bufs: dict[str, np.ndarray]): + mapping = {paths[0]: bufs["r"], paths[1]: bufs["g"], paths[2]: bufs["b"]} + + def fake_decode(path: str): + return mapping[path], _MOCK_WB, _MOCK_META + + return mock.patch( + "negpy.services.export.linear_output._decode_camera_raw_buffer", + side_effect=fake_decode, + ) + + def test_triplet_produces_merged_tiff(self, tmp_path: str) -> None: + paths = _make_fake_camera_raws(str(tmp_path)) + bufs = _triplet_buffers() + rgbscan = RgbScanConfig(enabled=True, green_path=paths[1], blue_path=paths[2], align=False) + out = os.path.join(str(tmp_path), "triplet_linear.tiff") + + with self._patch_decode(paths, bufs): + export_linear_output(paths[0], out, rgbscan=rgbscan) + + assert os.path.exists(out) + with tifffile.TiffFile(out) as tf: + arr = tf.pages[0].asarray() + assert arr.dtype == np.uint16 + assert arr.shape == (40, 60, 3) + f32 = arr.astype(np.float32) / 65535.0 + assert f32[0, 0, 0] == pytest.approx(0.8, abs=0.01) + assert f32[0, 0, 1] == pytest.approx(0.7, abs=0.01) + assert f32[0, 0, 2] == pytest.approx(0.9, abs=0.01) + + def test_triplet_channels_from_correct_exposures(self, tmp_path: str) -> None: + """Red channel from red exposure, green from green, blue from blue.""" + paths = _make_fake_camera_raws(str(tmp_path)) + bufs = _triplet_buffers() + rgbscan = RgbScanConfig(enabled=True, green_path=paths[1], blue_path=paths[2], align=False) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode(paths, bufs): + export_linear_output(paths[0], out, rgbscan=rgbscan) + + with tifffile.TiffFile(out) as tf: + f32 = tf.pages[0].asarray().astype(np.float32) / 65535.0 + assert f32[..., 0].mean() == pytest.approx(0.8, abs=0.01) + assert f32[..., 1].mean() == pytest.approx(0.7, abs=0.01) + assert f32[..., 2].mean() == pytest.approx(0.9, abs=0.01) + + def test_triplet_description_mentions_triplet(self, tmp_path: str) -> None: + paths = _make_fake_camera_raws(str(tmp_path)) + bufs = _triplet_buffers() + rgbscan = RgbScanConfig(enabled=True, green_path=paths[1], blue_path=paths[2], align=False) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode(paths, bufs): + export_linear_output(paths[0], out, rgbscan=rgbscan) + + with tifffile.TiffFile(out) as tf: + desc = tf.pages[0].description + assert "RGB triplet" in desc + + def test_triplet_preserves_wb_metadata(self, tmp_path: str) -> None: + paths = _make_fake_camera_raws(str(tmp_path)) + bufs = _triplet_buffers() + rgbscan = RgbScanConfig(enabled=True, green_path=paths[1], blue_path=paths[2], align=False) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode(paths, bufs): + export_linear_output(paths[0], out, rgbscan=rgbscan) + + with tifffile.TiffFile(out) as tf: + desc = tf.pages[0].description + assert "no WB applied" in desc + assert "as-shot:" in desc + + def test_triplet_preserves_make_model(self, tmp_path: str) -> None: + paths = _make_fake_camera_raws(str(tmp_path)) + bufs = _triplet_buffers() + rgbscan = RgbScanConfig(enabled=True, green_path=paths[1], blue_path=paths[2], align=False) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode(paths, bufs): + export_linear_output(paths[0], out, rgbscan=rgbscan) + + with tifffile.TiffFile(out) as tf: + tags = tf.pages[0].tags + assert tags["Make"].value == "Nikon" + assert tags["Model"].value == "D850" + + def test_triplet_with_geometry(self, tmp_path: str) -> None: + paths = _make_fake_camera_raws(str(tmp_path)) + bufs = _triplet_buffers() + rgbscan = RgbScanConfig(enabled=True, green_path=paths[1], blue_path=paths[2], align=False) + geo = GeometryConfig(rotation=1) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode(paths, bufs): + export_linear_output(paths[0], out, geometry=geo, rgbscan=rgbscan) + + with tifffile.TiffFile(out) as tf: + arr = tf.pages[0].asarray() + assert arr.shape == (60, 40, 3) + + def test_no_triplet_without_rgbscan(self, tmp_path: str) -> None: + """Without rgbscan config, camera RAW goes through the normal single-file path.""" + paths = _make_fake_camera_raws(str(tmp_path)) + bufs = _triplet_buffers() + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode(paths, bufs): + export_linear_output(paths[0], out) + + with tifffile.TiffFile(out) as tf: + arr = tf.pages[0].asarray() + assert arr.shape == (40, 60, 3) + f32 = arr.astype(np.float32) / 65535.0 + assert f32[0, 0, 0] == pytest.approx(0.8, abs=0.01) + assert f32[0, 0, 1] == pytest.approx(0.1, abs=0.01) + + def test_source_format_label_triplet(self, tmp_path: str) -> None: + path = os.path.join(str(tmp_path), "test.nef") + open(path, "wb").close() + rgbscan = RgbScanConfig(enabled=True, green_path="g.nef", blue_path="b.nef") + assert _source_format_label(path, rgbscan) == "camera RAW (RGB triplet)" + assert _source_format_label(path) == "camera RAW" From 36d4b535379ceca295ac66461fb5aef07b600ff6 Mon Sep 17 00:00:00 2001 From: Mats Date: Tue, 4 Aug 2026 13:01:02 +0200 Subject: [PATCH 2/4] Add stitch composite support to Linear Output Stitch composites are decoded per-part with flatfield and sensor correction applied before assembly (clean seams, no channel crosstalk). Stitch + RGB-scan triplet combinations supported: each part can be a triplet, producing one TIFF from all source files. Triplet parts skip sensor correction (no leakage with narrowband exposures). Co-Authored-By: Claude Opus 4.6 --- docs/PIPELINE.md | 1 + docs/USER_GUIDE.md | 2 +- negpy/desktop/controller.py | 19 ++- negpy/services/export/linear_output.py | 119 ++++++++++++++-- tests/test_linear_output.py | 189 +++++++++++++++++++++++++ 5 files changed, 318 insertions(+), 12 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index e2b8e4dd..be5ee889 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -148,6 +148,7 @@ When the render intent is **Linear**, the entire darkroom pipeline is bypassed. * **LinearRaw DNG**: 4-channel VueScan (RGB+IR) and 3-channel SilverFast HDRi files are read directly via tifffile, bypassing rawpy. The IR channel, when present, is written as a separate grayscale TIFF with an `_ir` suffix. Expansion defaults to off; 2× and 4× are available. * **Camera RAW**: demosaiced by rawpy with `user_wb=[1,1,1,1]` (unity), `output_color=raw` (sensor-native), `gamma=(1,1)` (linear), `no_auto_bright=True`. The camera's as-shot white balance multipliers (green-normalized to three RGB values) are embedded in XMP as `RAW-WB: R G B` inside `dc:description`, matching the MakeTiff/ColorPerfect convention. The source filename is stored in `crs:RawFileName`. No expansion option (camera sensors use the full bit depth). * **RGB-scan triplets**: when the current frame is an RGB-scan composite (three narrowband exposures), all three are decoded and merged via `merge_rgb_triplet()` into a single combined TIFF. The red exposure is the primary file; green and blue paths come from the frame's `RgbScanConfig`. No sensor correction is applied (narrowband exposures have no cross-channel leakage). WB and device metadata are taken from the primary (red) exposure. +* **Stitch composites**: when the frame is a multi-part stitch, each part is decoded and corrected (flatfield + sensor correction for single-shot parts, flatfield only for triplet parts — triplets have no cross-channel leakage), then assembled via `stitch_composite()` with gain compensation and feather blending. Stitch + triplet combinations are supported: each stitch part can be an RGB-scan triplet, producing one combined TIFF from all source files (e.g. a 4-part stitch of triplets = 12 RAW files → 1 TIFF). Source device metadata (Make, Model, DateTime) is carried through to the output TIFF when available from the source file. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 36e0b8aa..eaade067 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -533,7 +533,7 @@ A scrollable list of every edit step (last 100 kept), newest on top; the current * **Linear**: bypass the entire darkroom pipeline and dump the scanner's or camera's decoded buffer as an untagged linear 16-bit TIFF. No normalization, exposure, colour management, flatfield, or sensor correction — just the raw data with lossless geometry (rotation/flip) applied. Supported sources: * **Pakon RAW** — 4× expansion by default (14-bit sensor range scaled into 16-bit). F335 files (16-bit sensor) default to no expansion. * **LinearRaw DNG** — SilverFast HDRi (3-channel) and VueScan (4-channel RGB+IR). IR is written as a separate grayscale TIFF with an `_ir` suffix. - * **Camera RAW** — demosaiced with unity white balance (1,1,1,1). The camera's as-shot WB is written into XMP (`RAW-WB: R G B`, MakeTiff-compatible) so it can be applied downstream. Source device and timestamp are preserved. RGB-scan triplets (narrowband R/G/B exposures) are merged into a single combined TIFF. + * **Camera RAW** — demosaiced with unity white balance (1,1,1,1). The camera's as-shot WB is written into XMP (`RAW-WB: R G B`, MakeTiff-compatible) so it can be applied downstream. Source device and timestamp are preserved. RGB-scan triplets (narrowband R/G/B exposures) are merged into a single combined TIFF. Stitch composites are assembled with flatfield and sensor correction applied per-part for clean seams; stitch + triplet combinations are also supported. * **Expansion**: scales the linear data before writing. The combo box shows source-appropriate options: Pakon F135/F235 default to 4×, F335 and LinearRaw DNG default to off. Camera RAW files have no expansion option. Leave at the default unless you know why you need to change it. ### Export button diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 716227ad..38bc7690 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -3362,9 +3362,13 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None: return exported = 0 - geometry = self.state.config.geometry + cfg = self.state.config + geometry = cfg.geometry expansion = self.state.linear_expansion - rgbscan = self.state.config.rgbscan + rgbscan = cfg.rgbscan + stitch = cfg.stitch if cfg.stitch.stitch_enabled else None + flatfield = cfg.flatfield if stitch else None + process = cfg.process if stitch else None for f in supported: stem = os.path.splitext(os.path.basename(f["path"]))[0] out_path = os.path.join(export_path, f"{stem}_linear.tiff") @@ -3373,7 +3377,16 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None: out_path = os.path.join(export_path, f"{stem}_linear_{counter}.tiff") counter += 1 try: - export_linear_output(f["path"], out_path, geometry=geometry, expansion=expansion, rgbscan=rgbscan) + export_linear_output( + f["path"], + out_path, + geometry=geometry, + expansion=expansion, + rgbscan=rgbscan, + stitch=stitch, + flatfield=flatfield, + process=process, + ) exported += 1 except Exception as e: logger.warning("Linear output failed for %s: %s", f.get("name"), e) diff --git a/negpy/services/export/linear_output.py b/negpy/services/export/linear_output.py index abd055c4..7d45b8d6 100644 --- a/negpy/services/export/linear_output.py +++ b/negpy/services/export/linear_output.py @@ -1,10 +1,11 @@ """Linear Output: export a loader's decoded buffer as an untagged 16-bit TIFF. -Bypasses the entire darkroom pipeline — no normalization, exposure, lab, -toning, finish, flatfield, or sensor-crosstalk correction. The output is -the closest thing to "what the scanner/camera actually captured" that NegPy -can produce, with only lossless geometry (EXIF orientation + user rotation/flip) -baked in. +For single files, bypasses the entire darkroom pipeline — no normalization, +exposure, lab, toning, finish, flatfield, or sensor-crosstalk correction. + +For composites (stitch / RGB-scan triplets), flatfield and sensor correction +are applied per-part before assembly so the output is physically correct +(no vignetting seams or channel crosstalk). """ import io @@ -16,9 +17,15 @@ import rawpy import tifffile as _tifffile +from negpy.features.flatfield.logic import apply_flatfield +from negpy.features.flatfield.models import FlatFieldConfig from negpy.features.geometry.models import GeometryConfig +from negpy.features.process.models import ProcessConfig +from negpy.features.process.sensor import apply_sensor_correction, effective_sensor_matrix from negpy.features.rgbscan.logic import merge_rgb_triplet from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet +from negpy.features.stitch.logic import stitch_composite +from negpy.features.stitch.models import StitchConfig, stitch_has_triplets from negpy.infrastructure.loaders.constants import SUPPORTED_JPEG_EXTENSIONS, SUPPORTED_RAW_EXTENSIONS, SUPPORTED_TIFF_EXTENSIONS from negpy.infrastructure.loaders.helpers import NonStandardFileWrapper, get_best_demosaic_algorithm, read_orientation from negpy.infrastructure.loaders.pakon_loader import PakonLoader @@ -158,8 +165,13 @@ def _decode_linear( geometry: Optional[GeometryConfig] = None, expansion: Optional[float] = None, rgbscan: Optional[RgbScanConfig] = None, + stitch: Optional[StitchConfig] = None, + flatfield: Optional[FlatFieldConfig] = None, + process: Optional[ProcessConfig] = None, ) -> tuple[np.ndarray, Optional[np.ndarray], Optional[_CameraWB], _SourceMeta]: """Decode to an oriented float32 buffer. Returns (rgb, ir_or_none, camera_wb_or_none, source_meta).""" + if stitch is not None and stitch.stitch_enabled and stitch.stitch_paths: + return _decode_stitch(file_path, stitch, geometry, flatfield, process) if PakonLoader.can_handle(file_path): rgb, ir = _decode_pakon(file_path, geometry, expansion=expansion) meta = _SourceMeta(make="Pakon", model=_pakon_spec_desc(file_path)) @@ -335,6 +347,77 @@ def _decode(path: str) -> np.ndarray: return f32, None, wb, merged_meta +def _decode_stitch_part( + file_path: str, + rgbscan: Optional[RgbScanConfig], + flatfield: Optional[FlatFieldConfig], + process: Optional[ProcessConfig], +) -> np.ndarray: + """Decode one stitch part with flatfield and sensor correction applied. + + Triplet merge is performed when *rgbscan* is a valid triplet config. + Sensor correction is skipped for triplets (no cross-channel leakage + with narrowband exposures). + """ + is_triplet = rgbscan is not None and is_rgb_triplet(rgbscan) + + if is_triplet: + primary_f32, _, _ = _decode_camera_raw_buffer(file_path) + cache: dict[str, np.ndarray] = {file_path: primary_f32} + + def _decode(path: str) -> np.ndarray: + if path in cache: + return cache[path] + buf, _, _ = _decode_camera_raw_buffer(path) + cache[path] = buf + return buf + + f32 = merge_rgb_triplet(_decode, file_path, rgbscan.green_path, rgbscan.blue_path, align=rgbscan.align) + else: + f32, _, _ = _decode_camera_raw_buffer(file_path) + + if flatfield is not None: + f32 = apply_flatfield(f32, flatfield) + if not is_triplet and process is not None: + f32 = apply_sensor_correction(f32, effective_sensor_matrix(process)) + return f32 + + +def _decode_stitch( + file_path: str, + stitch: StitchConfig, + geometry: Optional[GeometryConfig], + flatfield: Optional[FlatFieldConfig], + process: Optional[ProcessConfig], +) -> tuple[np.ndarray, None, Optional[_CameraWB], _SourceMeta]: + """Decode all stitch parts, apply per-part corrections, and assemble.""" + all_paths = [file_path, *stitch.stitch_paths] + has_triplets = stitch_has_triplets(stitch) + + primary_meta = _read_source_meta_tiff(file_path) + _, wb, decode_meta = _decode_camera_raw_buffer(file_path) + merged_meta = _SourceMeta( + make=primary_meta.make or decode_meta.make, + model=primary_meta.model or decode_meta.model, + datetime=primary_meta.datetime or decode_meta.datetime, + ) + + parts: list[np.ndarray] = [] + for i, path in enumerate(all_paths): + part_rgbscan: Optional[RgbScanConfig] = None + if i < len(stitch.stitch_triplets): + green, blue = stitch.stitch_triplets[i] + if green and blue: + part_rgbscan = RgbScanConfig(enabled=True, green_path=green, blue_path=blue, align=stitch.stitch_align) + parts.append(_decode_stitch_part(path, part_rgbscan, flatfield, process)) + + irs: list[None] = [None] * len(parts) + f32, _ = stitch_composite(parts, irs, stitch) + if geometry is not None: + f32 = _apply_user_geometry(f32, geometry) + return f32, None, wb if not has_triplets else None, merged_meta + + def _normalize_wb_rgb(wb: tuple[float, float, float, float]) -> tuple[float, float, float]: """Normalize RGGB multipliers to green=1, return (R, G, B).""" g = wb[1] if wb[1] > 0 else 1.0 @@ -376,12 +459,23 @@ def _effective_expansion(file_path: str, expansion: Optional[float]) -> float: return 1.0 -def _source_format_label(file_path: str, rgbscan: Optional[RgbScanConfig] = None) -> str: +def _source_format_label( + file_path: str, + rgbscan: Optional[RgbScanConfig] = None, + stitch: Optional[StitchConfig] = None, +) -> str: + is_stitch = stitch is not None and stitch.stitch_enabled and stitch.stitch_paths if PakonLoader.can_handle(file_path): return f"Pakon {_pakon_spec_desc(file_path)}" if _is_dng(file_path) and _is_linearraw_dng(file_path): return "DNG LinearRaw" if _is_camera_raw(file_path): + if is_stitch and stitch_has_triplets(stitch): + n = 1 + len(stitch.stitch_paths) + return f"camera RAW (stitch {n}-part, RGB triplet)" + if is_stitch: + n = 1 + len(stitch.stitch_paths) + return f"camera RAW (stitch {n}-part)" if rgbscan is not None and is_rgb_triplet(rgbscan): return "camera RAW (RGB triplet)" return "camera RAW" @@ -463,6 +557,9 @@ def export_linear_output( geometry: Optional[GeometryConfig] = None, expansion: Optional[float] = None, rgbscan: Optional[RgbScanConfig] = None, + stitch: Optional[StitchConfig] = None, + flatfield: Optional[FlatFieldConfig] = None, + process: Optional[ProcessConfig] = None, ) -> None: """Decode *file_path* and write an untagged linear 16-bit TIFF to *output_path*. @@ -475,12 +572,18 @@ def export_linear_output( *rgbscan*, when a valid triplet config, merges three narrowband exposures into one combined RGB buffer before writing. + *stitch*, when active, decodes all parts (with per-part triplet merge if + applicable), applies flatfield and sensor correction per-part, then assembles + via stitch_composite. + If the source has an IR channel, it is written as a separate grayscale TIFF with an ``_ir`` suffix next to the RGB output. """ eff = _effective_expansion(file_path, expansion) - fmt = _source_format_label(file_path, rgbscan) - f32, ir, camera_wb, meta = _decode_linear(file_path, geometry, expansion=expansion, rgbscan=rgbscan) + fmt = _source_format_label(file_path, rgbscan, stitch) + f32, ir, camera_wb, meta = _decode_linear( + file_path, geometry, expansion=expansion, rgbscan=rgbscan, stitch=stitch, flatfield=flatfield, process=process + ) os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) _write_tiff( f32, output_path, os.path.basename(file_path), camera_wb, source_path=file_path, source_meta=meta, expansion=eff, source_format=fmt diff --git a/tests/test_linear_output.py b/tests/test_linear_output.py index de80d9fd..76611108 100644 --- a/tests/test_linear_output.py +++ b/tests/test_linear_output.py @@ -10,6 +10,7 @@ from negpy.features.geometry.models import GeometryConfig from negpy.features.rgbscan.models import RgbScanConfig +from negpy.features.stitch.models import StitchConfig from negpy.kernel.image.logic import apply_exif_orientation from negpy.services.export.linear_output import ( _CameraWB, @@ -696,3 +697,191 @@ def test_source_format_label_triplet(self, tmp_path: str) -> None: rgbscan = RgbScanConfig(enabled=True, green_path="g.nef", blue_path="b.nef") assert _source_format_label(path, rgbscan) == "camera RAW (RGB triplet)" assert _source_format_label(path) == "camera RAW" + + +def _make_stitch_config( + part1_path: str, + w: int = 60, + h: int = 40, + triplets: tuple[tuple[str, str], ...] = (), +) -> StitchConfig: + """Two side-by-side parts with 10px overlap, identity + offset transforms.""" + offset = w - 10 + return StitchConfig( + stitch_enabled=True, + stitch_paths=(part1_path,), + stitch_transforms=( + (1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + (1.0, 0.0, float(offset), 0.0, 1.0, 0.0), + ), + stitch_canvas=(w + offset, h), + stitch_sizes=((w, h), (w, h)), + stitch_triplets=triplets, + ) + + +class TestStitchExport: + """Linear Output with stitch composites.""" + + def _patch_decode(self, path_to_buf: dict[str, np.ndarray]): + def fake_decode(path: str): + return path_to_buf[path], _MOCK_WB, _MOCK_META + + return mock.patch( + "negpy.services.export.linear_output._decode_camera_raw_buffer", + side_effect=fake_decode, + ) + + def test_stitch_produces_composite_tiff(self, tmp_path: str) -> None: + p0 = os.path.join(str(tmp_path), "part0.nef") + p1 = os.path.join(str(tmp_path), "part1.nef") + for p in (p0, p1): + open(p, "wb").close() + + h, w = 40, 60 + buf0 = np.full((h, w, 3), 0.4, dtype=np.float32) + buf1 = np.full((h, w, 3), 0.6, dtype=np.float32) + stitch = _make_stitch_config(p1, w=w, h=h) + out = os.path.join(str(tmp_path), "stitch_linear.tiff") + + with self._patch_decode({p0: buf0, p1: buf1}): + export_linear_output(p0, out, stitch=stitch) + + assert os.path.exists(out) + with tifffile.TiffFile(out) as tf: + arr = tf.pages[0].asarray() + assert arr.dtype == np.uint16 + expected_w = w + (w - 10) + assert arr.shape == (h, expected_w, 3) + + def test_stitch_description_mentions_stitch(self, tmp_path: str) -> None: + p0 = os.path.join(str(tmp_path), "part0.nef") + p1 = os.path.join(str(tmp_path), "part1.nef") + for p in (p0, p1): + open(p, "wb").close() + + h, w = 40, 60 + buf = np.full((h, w, 3), 0.5, dtype=np.float32) + stitch = _make_stitch_config(p1, w=w, h=h) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode({p0: buf, p1: buf}): + export_linear_output(p0, out, stitch=stitch) + + with tifffile.TiffFile(out) as tf: + desc = tf.pages[0].description + assert "stitch 2-part" in desc + + def test_stitch_preserves_make_model(self, tmp_path: str) -> None: + p0 = os.path.join(str(tmp_path), "part0.nef") + p1 = os.path.join(str(tmp_path), "part1.nef") + for p in (p0, p1): + open(p, "wb").close() + + h, w = 40, 60 + buf = np.full((h, w, 3), 0.5, dtype=np.float32) + stitch = _make_stitch_config(p1, w=w, h=h) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode({p0: buf, p1: buf}): + export_linear_output(p0, out, stitch=stitch) + + with tifffile.TiffFile(out) as tf: + tags = tf.pages[0].tags + assert tags["Make"].value == "Nikon" + assert tags["Model"].value == "D850" + + def test_stitch_with_geometry(self, tmp_path: str) -> None: + p0 = os.path.join(str(tmp_path), "part0.nef") + p1 = os.path.join(str(tmp_path), "part1.nef") + for p in (p0, p1): + open(p, "wb").close() + + h, w = 40, 60 + buf = np.full((h, w, 3), 0.5, dtype=np.float32) + stitch = _make_stitch_config(p1, w=w, h=h) + geo = GeometryConfig(rotation=1) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode({p0: buf, p1: buf}): + export_linear_output(p0, out, geometry=geo, stitch=stitch) + + with tifffile.TiffFile(out) as tf: + arr = tf.pages[0].asarray() + expected_w = w + (w - 10) + assert arr.shape == (expected_w, h, 3) + + def test_stitch_with_triplets(self, tmp_path: str) -> None: + """Stitch where each part is an RGB triplet.""" + p0r = os.path.join(str(tmp_path), "p0_r.nef") + p0g = os.path.join(str(tmp_path), "p0_g.nef") + p0b = os.path.join(str(tmp_path), "p0_b.nef") + p1r = os.path.join(str(tmp_path), "p1_r.nef") + p1g = os.path.join(str(tmp_path), "p1_g.nef") + p1b = os.path.join(str(tmp_path), "p1_b.nef") + for p in (p0r, p0g, p0b, p1r, p1g, p1b): + open(p, "wb").close() + + h, w = 40, 60 + bufs = {} + for path, ch in [(p0r, 0), (p0g, 1), (p0b, 2), (p1r, 0), (p1g, 1), (p1b, 2)]: + arr = np.full((h, w, 3), 0.1, dtype=np.float32) + arr[..., ch] = 0.7 + bufs[path] = arr + + triplets = ((p0g, p0b), (p1g, p1b)) + stitch = _make_stitch_config(p1r, w=w, h=h, triplets=triplets) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode(bufs): + export_linear_output(p0r, out, stitch=stitch) + + assert os.path.exists(out) + with tifffile.TiffFile(out) as tf: + desc = tf.pages[0].description + assert "stitch 2-part" in desc + assert "RGB triplet" in desc + + def test_stitch_triplet_no_wb_in_output(self, tmp_path: str) -> None: + """Triplet composites don't record WB (narrowband captures have no meaningful WB).""" + p0r = os.path.join(str(tmp_path), "p0_r.nef") + p0g = os.path.join(str(tmp_path), "p0_g.nef") + p0b = os.path.join(str(tmp_path), "p0_b.nef") + p1r = os.path.join(str(tmp_path), "p1_r.nef") + p1g = os.path.join(str(tmp_path), "p1_g.nef") + p1b = os.path.join(str(tmp_path), "p1_b.nef") + for p in (p0r, p0g, p0b, p1r, p1g, p1b): + open(p, "wb").close() + + h, w = 40, 60 + buf = np.full((h, w, 3), 0.5, dtype=np.float32) + bufs = {p: buf for p in (p0r, p0g, p0b, p1r, p1g, p1b)} + + triplets = ((p0g, p0b), (p1g, p1b)) + stitch = _make_stitch_config(p1r, w=w, h=h, triplets=triplets) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode(bufs): + export_linear_output(p0r, out, stitch=stitch) + + with tifffile.TiffFile(out) as tf: + desc = tf.pages[0].description + assert "as-shot:" not in desc + + def test_source_format_label_stitch(self, tmp_path: str) -> None: + path = os.path.join(str(tmp_path), "test.nef") + open(path, "wb").close() + stitch = StitchConfig(stitch_enabled=True, stitch_paths=("/p1.nef",)) + assert "stitch 2-part" in _source_format_label(path, stitch=stitch) + + def test_source_format_label_stitch_triplet(self, tmp_path: str) -> None: + path = os.path.join(str(tmp_path), "test.nef") + open(path, "wb").close() + stitch = StitchConfig( + stitch_enabled=True, + stitch_paths=("/p1.nef",), + stitch_triplets=(("g0.nef", "b0.nef"), ("g1.nef", "b1.nef")), + ) + label = _source_format_label(path, stitch=stitch) + assert "stitch 2-part" in label + assert "RGB triplet" in label From aafb39b50b92005f8c2c042a87bfe1efaaf34726 Mon Sep 17 00:00:00 2001 From: Mats Date: Tue, 4 Aug 2026 13:58:25 +0200 Subject: [PATCH 3/4] Add per-step correction toggles to Linear Output Optional checkboxes (WB, flatfield, sensor correction) let users bake corrections into camera RAW linear exports. All default to off (raw dump philosophy). Hidden for Pakon/DNG sources where they don't apply. Stitch composites always apply flatfield + sensor per-part regardless. Warning hint shown when any correction is active. Co-Authored-By: Claude Opus 4.6 --- docs/PIPELINE.md | 2 + docs/USER_GUIDE.md | 1 + negpy/desktop/controller.py | 9 +- negpy/desktop/session.py | 11 ++ negpy/desktop/view/sidebar/export.py | 78 ++++++++++++ negpy/services/export/linear_output.py | 83 ++++++++++-- tests/test_linear_output.py | 169 +++++++++++++++++++++++++ 7 files changed, 339 insertions(+), 14 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index be5ee889..5c7630ec 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -150,6 +150,8 @@ When the render intent is **Linear**, the entire darkroom pipeline is bypassed. * **RGB-scan triplets**: when the current frame is an RGB-scan composite (three narrowband exposures), all three are decoded and merged via `merge_rgb_triplet()` into a single combined TIFF. The red exposure is the primary file; green and blue paths come from the frame's `RgbScanConfig`. No sensor correction is applied (narrowband exposures have no cross-channel leakage). WB and device metadata are taken from the primary (red) exposure. * **Stitch composites**: when the frame is a multi-part stitch, each part is decoded and corrected (flatfield + sensor correction for single-shot parts, flatfield only for triplet parts — triplets have no cross-channel leakage), then assembled via `stitch_composite()` with gain compensation and feather blending. Stitch + triplet combinations are supported: each stitch part can be an RGB-scan triplet, producing one combined TIFF from all source files (e.g. a 4-part stitch of triplets = 12 RAW files → 1 TIFF). +**Optional corrections** (camera RAW only): three toggles let you bake corrections into the linear output before writing. All default to off (raw dump philosophy — the output is unchanged sensor data). *Apply white balance* multiplies the buffer by the as-shot WB gains (green-normalized). *Apply flatfield* applies the configured flatfield gain correction. *Apply sensor correction* applies the crosstalk unmixing matrix. For stitch composites, flatfield and sensor correction are always applied per-part regardless of these toggles — without them, vignetting and crosstalk differences create visible seams at part boundaries. + Source device metadata (Make, Model, DateTime) is carried through to the output TIFF when available from the source file. --- diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index eaade067..ec44746a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -535,6 +535,7 @@ A scrollable list of every edit step (last 100 kept), newest on top; the current * **LinearRaw DNG** — SilverFast HDRi (3-channel) and VueScan (4-channel RGB+IR). IR is written as a separate grayscale TIFF with an `_ir` suffix. * **Camera RAW** — demosaiced with unity white balance (1,1,1,1). The camera's as-shot WB is written into XMP (`RAW-WB: R G B`, MakeTiff-compatible) so it can be applied downstream. Source device and timestamp are preserved. RGB-scan triplets (narrowband R/G/B exposures) are merged into a single combined TIFF. Stitch composites are assembled with flatfield and sensor correction applied per-part for clean seams; stitch + triplet combinations are also supported. * **Expansion**: scales the linear data before writing. The combo box shows source-appropriate options: Pakon F135/F235 default to 4×, F335 and LinearRaw DNG default to off. Camera RAW files have no expansion option. Leave at the default unless you know why you need to change it. + * **Corrections** (camera RAW only): three optional toggles that bake corrections into the linear output before writing. All default to off (raw dump philosophy). **Apply white balance** multiplies by the as-shot WB gains. **Apply flatfield** applies the flatfield gain correction. **Apply sensor correction** applies the sensor crosstalk unmixing matrix. For stitch composites, flatfield and sensor correction are always applied per-part regardless of these toggles (required for clean seams). ### Export button diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 38bc7690..5005dcfd 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -3367,8 +3367,6 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None: expansion = self.state.linear_expansion rgbscan = cfg.rgbscan stitch = cfg.stitch if cfg.stitch.stitch_enabled else None - flatfield = cfg.flatfield if stitch else None - process = cfg.process if stitch else None for f in supported: stem = os.path.splitext(os.path.basename(f["path"]))[0] out_path = os.path.join(export_path, f"{stem}_linear.tiff") @@ -3384,8 +3382,11 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None: expansion=expansion, rgbscan=rgbscan, stitch=stitch, - flatfield=flatfield, - process=process, + flatfield=cfg.flatfield, + process=cfg.process, + apply_wb=self.state.linear_apply_wb, + apply_flatfield=self.state.linear_apply_flatfield, + apply_sensor=self.state.linear_apply_sensor, ) exported += 1 except Exception as e: diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 9168c88f..e13b4ba4 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -168,6 +168,10 @@ class AppState: linear_output: bool = False # Linear Output expansion factor override. None = source-type default (4× Pakon, off DNG). linear_expansion: float | None = None + # Linear Output optional corrections (off by default — raw dump philosophy). + linear_apply_wb: bool = False + linear_apply_flatfield: bool = False + linear_apply_sensor: bool = False @property def local_hidden_masks(self) -> set: @@ -496,6 +500,10 @@ def __init__(self, repo: StorageRepository): saved_linear_output = self.repo.get_global_setting("linear_output") if saved_linear_output is not None: self.state.linear_output = bool(saved_linear_output) + for key in ("linear_apply_wb", "linear_apply_flatfield", "linear_apply_sensor"): + val = self.repo.get_global_setting(key) + if val is not None: + setattr(self.state, key, bool(val)) self.state.export_presets = self.repo.load_export_presets() @@ -574,6 +582,9 @@ def save_flat_output_prefs(self) -> None: """Persists the flat / linear output preferences.""" self.repo.save_global_setting("flat_output", self.state.flat_output) self.repo.save_global_setting("linear_output", self.state.linear_output) + self.repo.save_global_setting("linear_apply_wb", self.state.linear_apply_wb) + self.repo.save_global_setting("linear_apply_flatfield", self.state.linear_apply_flatfield) + self.repo.save_global_setting("linear_apply_sensor", self.state.linear_apply_sensor) def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = False) -> WorkspaceConfig: """ diff --git a/negpy/desktop/view/sidebar/export.py b/negpy/desktop/view/sidebar/export.py index e1269cc3..e79393b6 100644 --- a/negpy/desktop/view/sidebar/export.py +++ b/negpy/desktop/view/sidebar/export.py @@ -569,6 +569,37 @@ def _add_flat_master_section(self) -> None: box.addWidget(self.linear_expansion_hint) self.linear_expansion_combo.currentIndexChanged.connect(self._on_linear_expansion_changed) + self.linear_corrections_label = field_label("Corrections") + self.linear_corrections_label.setVisible(False) + box.addWidget(self.linear_corrections_label) + + self.linear_wb_checkbox = QCheckBox("Apply white balance") + self.linear_wb_checkbox.setToolTip("Multiply by the as-shot WB gains before writing") + self.linear_wb_checkbox.setChecked(self.state.linear_apply_wb) + self.linear_wb_checkbox.setVisible(False) + self.linear_wb_checkbox.toggled.connect(self._on_linear_correction_changed) + box.addWidget(self.linear_wb_checkbox) + + self.linear_flatfield_checkbox = QCheckBox("Apply flatfield") + self.linear_flatfield_checkbox.setToolTip("Apply the flatfield gain correction") + self.linear_flatfield_checkbox.setChecked(self.state.linear_apply_flatfield) + self.linear_flatfield_checkbox.setVisible(False) + self.linear_flatfield_checkbox.toggled.connect(self._on_linear_correction_changed) + box.addWidget(self.linear_flatfield_checkbox) + + self.linear_sensor_checkbox = QCheckBox("Apply sensor correction") + self.linear_sensor_checkbox.setToolTip("Apply the sensor crosstalk unmixing matrix") + self.linear_sensor_checkbox.setChecked(self.state.linear_apply_sensor) + self.linear_sensor_checkbox.setVisible(False) + self.linear_sensor_checkbox.toggled.connect(self._on_linear_correction_changed) + box.addWidget(self.linear_sensor_checkbox) + + self.linear_corrections_hint = hint_label( + "Corrections are baked in and cannot be undone from the exported file. Re-export from the original RAW to get uncorrected data." + ) + self.linear_corrections_hint.setVisible(False) + box.addWidget(self.linear_corrections_hint) + self.layout.addWidget(container) def _sync_flat_enabled(self) -> None: @@ -585,6 +616,12 @@ def _sync_flat_enabled(self) -> None: self.linear_expansion_hint.setVisible(linear_on) if linear_on: self._refresh_linear_expansion_combo() + if hasattr(self, "linear_corrections_label") and not linear_on: + self.linear_corrections_label.setVisible(False) + self.linear_wb_checkbox.setVisible(False) + self.linear_flatfield_checkbox.setVisible(False) + self.linear_sensor_checkbox.setVisible(False) + self.linear_corrections_hint.setVisible(False) if hasattr(self, "_presets_section"): self._presets_section.setVisible(not linear_on) if hasattr(self, "_sidecars_section"): @@ -666,12 +703,47 @@ def _refresh_linear_expansion_combo(self) -> None: combo.blockSignals(False) self._current_expansion_source_type = source_type + is_camera = source_type == "camera" + self.linear_corrections_label.setVisible(is_camera) + self.linear_wb_checkbox.setVisible(is_camera) + self.linear_flatfield_checkbox.setVisible(is_camera) + self.linear_sensor_checkbox.setVisible(is_camera) + + has_flatfield = bool(self.state.config.flatfield.apply and self.state.config.flatfield.profile_id) + self.linear_flatfield_checkbox.setEnabled(has_flatfield) + if not has_flatfield: + self.linear_flatfield_checkbox.setToolTip("No flatfield profile configured") + else: + self.linear_flatfield_checkbox.setToolTip("Apply the flatfield gain correction") + + has_matrix = self.state.config.process.sensor_matrix is not None + self.linear_sensor_checkbox.setEnabled(has_matrix) + if not has_matrix: + self.linear_sensor_checkbox.setToolTip("No sensor correction matrix configured") + else: + self.linear_sensor_checkbox.setToolTip("Apply the sensor crosstalk unmixing matrix") + + any_on = ( + self.state.linear_apply_wb + or (self.state.linear_apply_flatfield and has_flatfield) + or (self.state.linear_apply_sensor and has_matrix) + ) + self.linear_corrections_hint.setVisible(is_camera and any_on) + def _on_linear_expansion_changed(self, index: int) -> None: source_type = getattr(self, "_current_expansion_source_type", "unsupported") options = self._EXPANSION_OPTIONS.get(source_type, []) if 0 <= index < len(options): self.state.linear_expansion = options[index][1] + def _on_linear_correction_changed(self, _checked: bool) -> None: + self.state.linear_apply_wb = self.linear_wb_checkbox.isChecked() + self.state.linear_apply_flatfield = self.linear_flatfield_checkbox.isChecked() + self.state.linear_apply_sensor = self.linear_sensor_checkbox.isChecked() + self.controller.session.save_flat_output_prefs() + any_on = self.state.linear_apply_wb or self.state.linear_apply_flatfield or self.state.linear_apply_sensor + self.linear_corrections_hint.setVisible(any_on) + def _on_flat_peek_changed(self, active: bool) -> None: self.flat_peek_btn.blockSignals(True) self.flat_peek_btn.setChecked(active) @@ -1104,6 +1176,9 @@ def sync_ui(self) -> None: else: self.intent_print_btn.setChecked(True) self.flat_peek_btn.setChecked(self.state.flat_peek) + self.linear_wb_checkbox.setChecked(self.state.linear_apply_wb) + self.linear_flatfield_checkbox.setChecked(self.state.linear_apply_flatfield) + self.linear_sensor_checkbox.setChecked(self.state.linear_apply_sensor) finally: self.block_signals(False) @@ -1127,6 +1202,9 @@ def block_signals(self, blocked: bool) -> None: self.cs_template_combo, self.sidecars_enabled_btn, self.flat_peek_btn, + self.linear_wb_checkbox, + self.linear_flatfield_checkbox, + self.linear_sensor_checkbox, ] for w in widgets: w.blockSignals(blocked) diff --git a/negpy/services/export/linear_output.py b/negpy/services/export/linear_output.py index 7d45b8d6..147e0b42 100644 --- a/negpy/services/export/linear_output.py +++ b/negpy/services/export/linear_output.py @@ -17,11 +17,11 @@ import rawpy import tifffile as _tifffile -from negpy.features.flatfield.logic import apply_flatfield +from negpy.features.flatfield.logic import apply_flatfield as _apply_flatfield_correction from negpy.features.flatfield.models import FlatFieldConfig from negpy.features.geometry.models import GeometryConfig from negpy.features.process.models import ProcessConfig -from negpy.features.process.sensor import apply_sensor_correction, effective_sensor_matrix +from negpy.features.process.sensor import apply_sensor_correction from negpy.features.rgbscan.logic import merge_rgb_triplet from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet from negpy.features.stitch.logic import stitch_composite @@ -160,6 +160,16 @@ def _apply_geometry(f32: np.ndarray, orientation: int, geometry: Optional[Geomet return f32 +def _apply_white_balance(f32: np.ndarray, wb: _CameraWB) -> np.ndarray: + """Multiply a linear RGB buffer by the as-shot white-balance gains.""" + r, _g, b = _normalize_wb_rgb(wb.as_shot) + f32 = f32.copy() + f32[:, :, 0] *= r + f32[:, :, 2] *= b + np.clip(f32, 0.0, 1.0, out=f32) + return f32 + + def _decode_linear( file_path: str, geometry: Optional[GeometryConfig] = None, @@ -168,10 +178,16 @@ def _decode_linear( stitch: Optional[StitchConfig] = None, flatfield: Optional[FlatFieldConfig] = None, process: Optional[ProcessConfig] = None, + apply_wb: bool = False, + apply_flatfield: bool = False, + apply_sensor: bool = False, ) -> tuple[np.ndarray, Optional[np.ndarray], Optional[_CameraWB], _SourceMeta]: """Decode to an oriented float32 buffer. Returns (rgb, ir_or_none, camera_wb_or_none, source_meta).""" if stitch is not None and stitch.stitch_enabled and stitch.stitch_paths: - return _decode_stitch(file_path, stitch, geometry, flatfield, process) + rgb, ir, wb, meta = _decode_stitch(file_path, stitch, geometry, flatfield, process) + if apply_wb and wb is not None: + rgb = _apply_white_balance(rgb, wb) + return rgb, ir, wb, meta if PakonLoader.can_handle(file_path): rgb, ir = _decode_pakon(file_path, geometry, expansion=expansion) meta = _SourceMeta(make="Pakon", model=_pakon_spec_desc(file_path)) @@ -186,7 +202,12 @@ def _decode_linear( return rgb, ir, wb, meta if _is_camera_raw(file_path): if rgbscan is not None and is_rgb_triplet(rgbscan): - return _decode_camera_raw_triplet(file_path, rgbscan, geometry) + rgb, ir, wb, meta = _decode_camera_raw_triplet(file_path, rgbscan, geometry) + if apply_flatfield and flatfield is not None: + rgb = _apply_flatfield_correction(rgb, flatfield) + if apply_wb and wb is not None: + rgb = _apply_white_balance(rgb, wb) + return rgb, ir, wb, meta meta = _read_source_meta_tiff(file_path) rgb, ir, wb, decode_meta = _decode_camera_raw(file_path, geometry) merged = _SourceMeta( @@ -194,6 +215,12 @@ def _decode_linear( model=meta.model or decode_meta.model, datetime=meta.datetime or decode_meta.datetime, ) + if apply_flatfield and flatfield is not None: + rgb = _apply_flatfield_correction(rgb, flatfield) + if apply_sensor and process is not None and process.sensor_matrix is not None: + rgb = apply_sensor_correction(rgb, process.sensor_matrix) + if apply_wb and wb is not None: + rgb = _apply_white_balance(rgb, wb) return rgb, ir, wb, merged raise ValueError(f"Linear Output is not supported for this file type: {file_path}") @@ -377,9 +404,9 @@ def _decode(path: str) -> np.ndarray: f32, _, _ = _decode_camera_raw_buffer(file_path) if flatfield is not None: - f32 = apply_flatfield(f32, flatfield) - if not is_triplet and process is not None: - f32 = apply_sensor_correction(f32, effective_sensor_matrix(process)) + f32 = _apply_flatfield_correction(f32, flatfield) + if not is_triplet and process is not None and process.sensor_matrix is not None: + f32 = apply_sensor_correction(f32, process.sensor_matrix) return f32 @@ -491,6 +518,9 @@ def _write_tiff( source_meta: Optional[_SourceMeta] = None, expansion: float = 1.0, source_format: str = "", + wb_applied: bool = False, + flatfield_applied: bool = False, + sensor_applied: bool = False, ) -> None: """Write a float32 buffer as an untagged 16-bit TIFF to *dest* (path or file-like).""" u16 = _to_uint16_jit(np.ascontiguousarray(f32, dtype=np.float32)) @@ -502,9 +532,15 @@ def _write_tiff( parts.append("no scaling") if camera_wb is not None: r, g, b = _normalize_wb_rgb(camera_wb.as_shot) - parts.append(f"no WB applied (as-shot: {r:.3f} {g:.3f} {b:.3f})") + if wb_applied: + parts.append(f"WB applied (as-shot: {r:.3f} {g:.3f} {b:.3f})") + else: + parts.append(f"no WB applied (as-shot: {r:.3f} {g:.3f} {b:.3f})") else: parts.append("no WB applied") + corrections = [s for s, on in (("flatfield", flatfield_applied), ("sensor", sensor_applied)) if on] + if corrections: + parts.append(f"corrections: {', '.join(corrections)}") parts.append("no color management") description = f"NegPy Linear Output -- {', '.join(parts)}." @@ -560,6 +596,9 @@ def export_linear_output( stitch: Optional[StitchConfig] = None, flatfield: Optional[FlatFieldConfig] = None, process: Optional[ProcessConfig] = None, + apply_wb: bool = False, + apply_flatfield: bool = False, + apply_sensor: bool = False, ) -> None: """Decode *file_path* and write an untagged linear 16-bit TIFF to *output_path*. @@ -576,17 +615,41 @@ def export_linear_output( applicable), applies flatfield and sensor correction per-part, then assembles via stitch_composite. + *apply_wb*, *apply_flatfield*, *apply_sensor*: optional per-step corrections. + When False (default), the raw dump is written unchanged. When True, the + corresponding correction is applied before writing. + If the source has an IR channel, it is written as a separate grayscale TIFF with an ``_ir`` suffix next to the RGB output. """ eff = _effective_expansion(file_path, expansion) fmt = _source_format_label(file_path, rgbscan, stitch) f32, ir, camera_wb, meta = _decode_linear( - file_path, geometry, expansion=expansion, rgbscan=rgbscan, stitch=stitch, flatfield=flatfield, process=process + file_path, + geometry, + expansion=expansion, + rgbscan=rgbscan, + stitch=stitch, + flatfield=flatfield, + process=process, + apply_wb=apply_wb, + apply_flatfield=apply_flatfield, + apply_sensor=apply_sensor, ) + is_stitch = stitch is not None and stitch.stitch_enabled and stitch.stitch_paths os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) _write_tiff( - f32, output_path, os.path.basename(file_path), camera_wb, source_path=file_path, source_meta=meta, expansion=eff, source_format=fmt + f32, + output_path, + os.path.basename(file_path), + camera_wb, + source_path=file_path, + source_meta=meta, + expansion=eff, + source_format=fmt, + wb_applied=apply_wb, + flatfield_applied=apply_flatfield or is_stitch, + sensor_applied=apply_sensor or is_stitch, ) if ir is not None: diff --git a/tests/test_linear_output.py b/tests/test_linear_output.py index 76611108..4445cf97 100644 --- a/tests/test_linear_output.py +++ b/tests/test_linear_output.py @@ -15,6 +15,7 @@ from negpy.services.export.linear_output import ( _CameraWB, _SourceMeta, + _apply_white_balance, _build_xmp, _default_pakon_expansion, _effective_expansion, @@ -885,3 +886,171 @@ def test_source_format_label_stitch_triplet(self, tmp_path: str) -> None: label = _source_format_label(path, stitch=stitch) assert "stitch 2-part" in label assert "RGB triplet" in label + + +class TestLinearCorrections: + """Tests for optional per-step corrections (WB, flatfield, sensor).""" + + def _patch_decode(self, path_to_buf: dict[str, np.ndarray]): + def fake_decode(path: str): + return path_to_buf[path], _MOCK_WB, _MOCK_META + + return mock.patch( + "negpy.services.export.linear_output._decode_camera_raw_buffer", + side_effect=fake_decode, + ) + + def test_apply_white_balance_scales_channels(self) -> None: + f32 = np.full((4, 4, 3), 0.5, dtype=np.float32) + wb = _CameraWB(as_shot=(2.0, 1.0, 3.0, 1.0), daylight=(1.0, 1.0, 1.0, 1.0)) + result = _apply_white_balance(f32, wb) + assert result.shape == f32.shape + np.testing.assert_allclose(result[:, :, 0], 1.0, atol=1e-6) + np.testing.assert_allclose(result[:, :, 1], 0.5, atol=1e-6) + np.testing.assert_allclose(result[:, :, 2], 1.0, atol=1e-6) + + def test_apply_white_balance_clamps(self) -> None: + f32 = np.full((4, 4, 3), 0.8, dtype=np.float32) + wb = _CameraWB(as_shot=(2.0, 1.0, 2.0, 1.0), daylight=(1.0, 1.0, 1.0, 1.0)) + result = _apply_white_balance(f32, wb) + assert result.max() <= 1.0 + + def test_apply_wb_flag_bakes_wb(self, tmp_path: str) -> None: + p = os.path.join(str(tmp_path), "photo.nef") + open(p, "wb").close() + + buf = np.full((10, 10, 3), 0.3, dtype=np.float32) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode({p: buf}): + export_linear_output(p, out, apply_wb=True) + + with tifffile.TiffFile(out) as tf: + desc = tf.pages[0].description + assert "WB applied" in desc + assert "no WB applied" not in desc + + def test_no_apply_wb_flag_records_raw(self, tmp_path: str) -> None: + p = os.path.join(str(tmp_path), "photo.nef") + open(p, "wb").close() + + buf = np.full((10, 10, 3), 0.3, dtype=np.float32) + out = os.path.join(str(tmp_path), "out.tiff") + + with self._patch_decode({p: buf}): + export_linear_output(p, out, apply_wb=False) + + with tifffile.TiffFile(out) as tf: + desc = tf.pages[0].description + assert "no WB applied" in desc + + def test_apply_flatfield_calls_correction(self, tmp_path: str) -> None: + from negpy.features.flatfield.models import FlatFieldConfig + + p = os.path.join(str(tmp_path), "photo.nef") + open(p, "wb").close() + + buf = np.full((10, 10, 3), 0.3, dtype=np.float32) + ff = FlatFieldConfig(apply=True, profile_id="test") + out = os.path.join(str(tmp_path), "out.tiff") + + with ( + self._patch_decode({p: buf}), + mock.patch("negpy.services.export.linear_output._apply_flatfield_correction", return_value=buf) as ff_mock, + ): + export_linear_output(p, out, flatfield=ff, apply_flatfield=True) + + ff_mock.assert_called_once() + with tifffile.TiffFile(out) as tf: + assert "flatfield" in tf.pages[0].description + + def test_no_apply_flatfield_skips(self, tmp_path: str) -> None: + from negpy.features.flatfield.models import FlatFieldConfig + + p = os.path.join(str(tmp_path), "photo.nef") + open(p, "wb").close() + + buf = np.full((10, 10, 3), 0.3, dtype=np.float32) + ff = FlatFieldConfig(apply=True, profile_id="test") + out = os.path.join(str(tmp_path), "out.tiff") + + with ( + self._patch_decode({p: buf}), + mock.patch("negpy.services.export.linear_output._apply_flatfield_correction", return_value=buf) as ff_mock, + ): + export_linear_output(p, out, flatfield=ff, apply_flatfield=False) + + ff_mock.assert_not_called() + + def test_apply_sensor_calls_correction(self, tmp_path: str) -> None: + from negpy.features.process.models import ProcessConfig + + p = os.path.join(str(tmp_path), "photo.nef") + open(p, "wb").close() + + buf = np.full((10, 10, 3), 0.3, dtype=np.float32) + matrix = (1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0) + proc = ProcessConfig(sensor_matrix=matrix) + out = os.path.join(str(tmp_path), "out.tiff") + + with ( + self._patch_decode({p: buf}), + mock.patch("negpy.services.export.linear_output.apply_sensor_correction", return_value=buf) as sc_mock, + ): + export_linear_output(p, out, process=proc, apply_sensor=True) + + sc_mock.assert_called_once() + with tifffile.TiffFile(out) as tf: + assert "sensor" in tf.pages[0].description + + def test_apply_sensor_noop_without_matrix(self, tmp_path: str) -> None: + from negpy.features.process.models import ProcessConfig + + p = os.path.join(str(tmp_path), "photo.nef") + open(p, "wb").close() + + buf = np.full((10, 10, 3), 0.3, dtype=np.float32) + proc = ProcessConfig(sensor_matrix=None) + out = os.path.join(str(tmp_path), "out.tiff") + + with ( + self._patch_decode({p: buf}), + mock.patch("negpy.services.export.linear_output.apply_sensor_correction", return_value=buf) as sc_mock, + ): + export_linear_output(p, out, process=proc, apply_sensor=True) + + sc_mock.assert_not_called() + + def test_no_apply_sensor_skips(self, tmp_path: str) -> None: + from negpy.features.process.models import ProcessConfig + + p = os.path.join(str(tmp_path), "photo.nef") + open(p, "wb").close() + + buf = np.full((10, 10, 3), 0.3, dtype=np.float32) + proc = ProcessConfig() + out = os.path.join(str(tmp_path), "out.tiff") + + with ( + self._patch_decode({p: buf}), + mock.patch("negpy.services.export.linear_output.apply_sensor_correction", return_value=buf) as sc_mock, + ): + export_linear_output(p, out, process=proc, apply_sensor=False) + + sc_mock.assert_not_called() + + def test_description_lists_corrections(self, tmp_path: str) -> None: + f32 = np.full((4, 4, 3), 0.5, dtype=np.float32) + out = os.path.join(str(tmp_path), "out.tiff") + _write_tiff(f32, out, "test.nef", flatfield_applied=True, sensor_applied=True) + with tifffile.TiffFile(out) as tf: + desc = tf.pages[0].description + assert "corrections: flatfield, sensor" in desc + + def test_description_no_corrections_by_default(self, tmp_path: str) -> None: + f32 = np.full((4, 4, 3), 0.5, dtype=np.float32) + out = os.path.join(str(tmp_path), "out.tiff") + _write_tiff(f32, out, "test.nef") + with tifffile.TiffFile(out) as tf: + desc = tf.pages[0].description + assert "corrections:" not in desc From dcac98e3c6c61e0ef4e5556edfda000a7c1fb27e Mon Sep 17 00:00:00 2001 From: Mats Date: Tue, 4 Aug 2026 14:38:38 +0200 Subject: [PATCH 4/4] fix: average G1/G2 green channels in WB normalization Cameras report RGGB quads where G1 and G2 can differ slightly. Averaging both greens for the normalization denominator matches what the demosaiced green channel represents (interpolated from both CFA positions). Co-Authored-By: Claude Opus 4.6 --- negpy/services/export/linear_output.py | 2 +- tests/test_linear_output.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/negpy/services/export/linear_output.py b/negpy/services/export/linear_output.py index 147e0b42..f0d11e5e 100644 --- a/negpy/services/export/linear_output.py +++ b/negpy/services/export/linear_output.py @@ -447,7 +447,7 @@ def _decode_stitch( def _normalize_wb_rgb(wb: tuple[float, float, float, float]) -> tuple[float, float, float]: """Normalize RGGB multipliers to green=1, return (R, G, B).""" - g = wb[1] if wb[1] > 0 else 1.0 + g = (wb[1] + wb[3]) / 2.0 if (wb[1] + wb[3]) > 0 else 1.0 return (wb[0] / g, 1.0, wb[2] / g) diff --git a/tests/test_linear_output.py b/tests/test_linear_output.py index 4445cf97..a1d4e6d8 100644 --- a/tests/test_linear_output.py +++ b/tests/test_linear_output.py @@ -439,15 +439,16 @@ def test_dng_is_camera_raw(self, tmp_path: str) -> None: assert _is_camera_raw(path) def test_normalize_wb_rgb(self) -> None: - r, g, b = _normalize_wb_rgb((398.0, 302.0, 873.0, 0.0)) + r, g, b = _normalize_wb_rgb((398.0, 302.0, 873.0, 304.0)) assert g == 1.0 - assert abs(r - 398.0 / 302.0) < 1e-6 - assert abs(b - 873.0 / 302.0) < 1e-6 + g_avg = (302.0 + 304.0) / 2.0 + assert abs(r - 398.0 / g_avg) < 1e-6 + assert abs(b - 873.0 / g_avg) < 1e-6 def test_build_xmp_maketiff_format(self) -> None: wb = _CameraWB( - as_shot=(398.0, 302.0, 873.0, 0.0), - daylight=(1.94, 0.94, 1.38, 0.0), + as_shot=(398.0, 302.0, 873.0, 304.0), + daylight=(1.94, 0.94, 1.38, 0.96), ) xmp = _build_xmp("/path/to/DSCF3404.RAF", wb) text = xmp.decode("utf-8")