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: 4 additions & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ 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.
* **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.

Expand Down
3 changes: 2 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,8 +533,9 @@ 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. 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

Expand Down
19 changes: 17 additions & 2 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3362,8 +3362,11 @@ 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 = cfg.rgbscan
stitch = cfg.stitch if cfg.stitch.stitch_enabled 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")
Expand All @@ -3372,7 +3375,19 @@ 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,
stitch=stitch,
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:
logger.warning("Linear output failed for %s: %s", f.get("name"), e)
Expand Down
11 changes: 11 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
"""
Expand Down
78 changes: 78 additions & 0 deletions negpy/desktop/view/sidebar/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

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