Skip to content
Closed
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
2 changes: 2 additions & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,8 @@ When the render intent is **Linear**, the entire darkroom pipeline is bypassed.

**Optional corrections** (camera RAW only): three toggles let you bake corrections into the linear output before writing. All default to off, following the raw dump philosophy, so 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, because without them vignetting and crosstalk differences create visible seams at part boundaries.

`wb_bake_block_reason()` makes *Apply white balance* inert for a Trichrome triplet or a Single-Shot Narrowband capture ("trichrome"/"narrowband", "" otherwise): the as-shot gains correct a broadband scene, and a narrowband capture has none, so the multiplier is an artifact of the camera's own preset rather than a correction the light calls for. Single-Shot Narrowband is detected from either the Narrowband toggle or a calibrated sensor matrix, since the two are independent sticky settings and a rig can have one set without the other. The sidebar checkbox greys on the same reason, so the two cannot disagree.

**Output is always clean, and for TIFF, richly self-documenting.** The file is written from scratch: only raw pixels plus Make, Model and DateTime from the source. ICC profiles, EXIF color space tags and XMP color metadata from scanner software or editors are never copied through. For the **TIFF** path, the description field records the source format, expansion, white balance and any applied corrections, including whether ICE ran, and ends with "no color management"; as-shot WB also goes into XMP. For Flextight FFF files, the Make field includes film stock and type from the embedded plist, and the Model field includes the scanner serial.

**The JPEG XL path carries the same record.** `imagecodecs.jpegxl_encode()` has no metadata parameter, so `_write_jxl()` writes the description, Make, Model, DateTime and XMP into the container's Exif and `xml ` boxes after the encode. The IR sidecar stays untagged in both formats.
Expand Down
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ A scrollable list of every edit step, the last 100 kept, newest on top. The curr
* **TIFF**: generic scanner TIFFs. If the file has a 4th channel tagged as IR (ExtraSamples = UNSPECIFIED or missing), it is written as a separate `_ir` file in the same Format as the main output. Sidecar IR files (`_ir.tif` next to the source) and IR stored in secondary TIFF pages are also detected. **Input gamma** lets you select the gamma encoding of the source (linear, 1.8, 2.2 or sRGB) so the data can be linearized before export. Expansion is available, off by default.
* **Expansion**: scales the linear data before writing. The combo box shows source-appropriate options: Pakon F135/F235 default to 4×, Noritsu to 16×, F335 and LinearRaw DNG to off. Camera RAW, Coolscan NEF and Flextight FFF have no expansion option. Leave it at the default unless you know why you need to change it.
* **Apply ICE dust removal** (visible when an IR channel is available): applies IR-based dust and scratch correction to the linear output before writing. Off by default.
* **Corrections** (camera RAW only): three optional toggles that bake corrections into the linear output before writing. All default to off, following the 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, because clean seams require it.
* **Corrections** (camera RAW only): three optional toggles that bake corrections into the linear output before writing. All default to off, following the raw-dump philosophy. **Apply white balance** multiplies by the as-shot WB gains; it greys out for a Trichrome triplet or a Single-Shot Narrowband capture, since as-shot gains have no practical use against a narrowband exposure. **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, because clean seams require it.

Linear Output writes where the **Destination** section says, the same as a print or flat export: folder mode, subfolder, export path and Filename Pattern all apply. `_linear` is always appended to the rendered filename, so a dump written next to its source cannot overwrite that source. Without **Overwrite**, an existing file makes the next one `_linear_2`, `_linear_3` and so on.

Expand Down
16 changes: 14 additions & 2 deletions negpy/desktop/view/sidebar/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@ def _on_linear_output_changed(self, enabled: bool) -> None:
}

def _refresh_linear_expansion_combo(self) -> None:
from negpy.services.export.linear_output import linear_output_source_type
from negpy.services.export.linear_output import linear_output_source_type, wb_bake_block_reason

path = self.state.current_file_path or ""
source_type = linear_output_source_type(path) if path else "unsupported"
Expand Down Expand Up @@ -849,6 +849,18 @@ def _refresh_linear_expansion_combo(self) -> None:
else:
self.linear_ice_checkbox.setToolTip("Apply IR-based dust and scratch correction")

wb_reason = wb_bake_block_reason(self.state.config.rgbscan, self.state.config.process)
wb_available = not wb_reason
self.linear_wb_checkbox.setEnabled(wb_available)
if wb_reason == "trichrome":
self.linear_wb_checkbox.setToolTip(
"No practical use: each Trichrome channel is its own narrowband exposure, not a broadband as-shot gain"
)
elif wb_reason == "narrowband":
self.linear_wb_checkbox.setToolTip("No practical use: as-shot WB gains do not correct a narrowband capture")
else:
self.linear_wb_checkbox.setToolTip("Multiply by the as-shot WB gains before writing")

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:
Expand All @@ -864,7 +876,7 @@ def _refresh_linear_expansion_combo(self) -> None:
self.linear_sensor_checkbox.setToolTip("Apply the sensor crosstalk unmixing matrix")

any_on = (
self.state.linear_apply_wb
(self.state.linear_apply_wb and wb_available)
or (self.state.linear_apply_flatfield and has_flatfield)
or (self.state.linear_apply_sensor and has_matrix)
or (self.state.linear_apply_ice and has_ir)
Expand Down
34 changes: 28 additions & 6 deletions negpy/services/export/linear_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,26 @@ def _apply_white_balance(f32: np.ndarray, wb: _CameraWB) -> np.ndarray:
return f32


def wb_bake_block_reason(rgbscan: Optional[RgbScanConfig], process: Optional[ProcessConfig]) -> str:
"""Why baking the as-shot WB gains cannot apply — "trichrome", "narrowband", or "" when it can.

The as-shot gains are the camera's correction for a broadband scene under some assumed
illuminant. A narrowband capture (Trichrome's three isolated exposures, or a Single-Shot
Narrowband profile) has no such scene: the gains are an artifact of whatever preset was
dialed on the camera, not a correction the light or the film calls for. Channel separation
for these captures comes from the RGBScan/sensor-unmix profile instead.

Single-Shot Narrowband is flagged by either of two independent sticky settings: the
Narrowband toggle (the bundled RGBScan input profile) or a calibrated sensor matrix
(Single-Shot Narrowband Calibration) — a rig can have one set without the other.
"""
if rgbscan is not None and is_rgb_triplet(rgbscan):
return "trichrome"
if process is not None and (process.narrowband_scan or process.sensor_matrix is not None):
return "narrowband"
return ""


def _apply_ice(rgb: np.ndarray, ir: np.ndarray, retouch: RetouchConfig) -> np.ndarray:
"""Apply IR dust correction to a linear RGB buffer using the IR channel."""
if retouch.ir_method == IR_METHOD_OPENICE:
Expand Down Expand Up @@ -324,9 +344,10 @@ def _decode_linear(
gamma_key: str = "linear",
) -> 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)."""
wb_blocked = bool(wb_bake_block_reason(rgbscan, process))
if stitch is not None and stitch.stitch_enabled and stitch.stitch_paths:
rgb, ir, wb, meta = _decode_stitch(file_path, stitch, geometry, flatfield, process)
if apply_wb and wb is not None:
if apply_wb and not wb_blocked and wb is not None:
rgb = _apply_white_balance(rgb, wb)
return rgb, ir, wb, meta
# Ahead of every per-format branch below, because each of those returns. Otherwise a
Expand All @@ -338,7 +359,7 @@ def _decode_linear(
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:
if apply_wb and not wb_blocked and wb is not None:
rgb = _apply_white_balance(rgb, wb)
return rgb, None, wb, meta
if PakonLoader.can_handle(file_path):
Expand Down Expand Up @@ -370,7 +391,7 @@ def _decode_linear(
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:
if apply_wb and not wb_blocked and wb is not None:
rgb = _apply_white_balance(rgb, wb)
return rgb, ir, wb, meta
meta = _read_source_meta_tiff(file_path)
Expand All @@ -384,7 +405,7 @@ def _decode_linear(
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:
if apply_wb and not wb_blocked and wb is not None:
rgb = _apply_white_balance(rgb, wb)
return rgb, ir, wb, merged
if _is_tiff(file_path):
Expand Down Expand Up @@ -1154,6 +1175,7 @@ def export_linear_output(
"""
eff = _effective_expansion(file_path, expansion)
fmt = _source_format_label(file_path, rgbscan, stitch)
wb_applied = apply_wb and not wb_bake_block_reason(rgbscan, process)
f32, ir, camera_wb, meta = _decode_linear(
file_path,
geometry,
Expand Down Expand Up @@ -1187,7 +1209,7 @@ def export_linear_output(
source_meta=meta,
expansion=eff,
source_format=fmt,
wb_applied=apply_wb,
wb_applied=wb_applied,
flatfield_applied=apply_flatfield or is_stitch,
sensor_applied=apply_sensor or is_stitch,
ice_applied=ice_applied,
Expand All @@ -1203,7 +1225,7 @@ def export_linear_output(
source_meta=meta,
expansion=eff,
source_format=fmt,
wb_applied=apply_wb,
wb_applied=wb_applied,
flatfield_applied=apply_flatfield or is_stitch,
sensor_applied=apply_sensor or is_stitch,
ice_applied=ice_applied,
Expand Down
47 changes: 47 additions & 0 deletions tests/test_linear_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import tifffile

from negpy.features.geometry.models import GeometryConfig
from negpy.features.process.models import ProcessConfig
from negpy.features.rgbscan.models import RgbScanConfig
from negpy.features.stitch.models import StitchConfig
from negpy.kernel.image.logic import apply_exif_orientation
Expand All @@ -38,6 +39,7 @@
export_linear_output_bytes,
is_linear_output_supported,
linear_output_source_type,
wb_bake_block_reason,
)


Expand Down Expand Up @@ -960,6 +962,20 @@ def test_triplet_preserves_wb_metadata(self, tmp_path: str) -> None:
assert "no WB applied" in desc
assert "as-shot:" in desc

def test_triplet_apply_wb_flag_is_inert(self, tmp_path: str) -> None:
"""A Trichrome triplet has no single as-shot WB to bake: apply_wb=True stays a no-op."""
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, apply_wb=True)

with tifffile.TiffFile(out) as tf:
desc = tf.pages[0].description
assert "no WB applied" in desc

def test_triplet_preserves_make_model(self, tmp_path: str) -> None:
paths = _make_fake_camera_raws(str(tmp_path))
bufs = _triplet_buffers()
Expand Down Expand Up @@ -1250,6 +1266,37 @@ def test_apply_white_balance_clamps(self) -> None:
result = _apply_white_balance(f32, wb)
assert result.max() <= 1.0

def test_wb_bake_block_reason_trichrome(self) -> None:
rgbscan = RgbScanConfig(enabled=True, green_path="g.nef", blue_path="b.nef")
assert wb_bake_block_reason(rgbscan, ProcessConfig()) == "trichrome"

def test_wb_bake_block_reason_narrowband(self) -> None:
assert wb_bake_block_reason(RgbScanConfig(), ProcessConfig(narrowband_scan=True)) == "narrowband"

def test_wb_bake_block_reason_sensor_profile_alone(self) -> None:
"""A calibrated sensor matrix flags Single-Shot Narrowband even with the toggle off."""
conf = ProcessConfig(narrowband_scan=False, sensor_matrix=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0))
assert wb_bake_block_reason(RgbScanConfig(), conf) == "narrowband"

def test_wb_bake_block_reason_clear(self) -> None:
assert wb_bake_block_reason(RgbScanConfig(), ProcessConfig()) == ""
assert wb_bake_block_reason(None, None) == ""

def test_apply_wb_flag_blocked_for_single_shot_narrowband(self, tmp_path: str) -> None:
"""As-shot WB has no practical use for a narrowband capture: the checkbox stays inert."""
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, process=ProcessConfig(narrowband_scan=True))

with tifffile.TiffFile(out) as tf:
desc = tf.pages[0].description
assert "no WB applied" in desc

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()
Expand Down
Loading