Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
80b3dc4
Add ICE dust removal toggle to Linear Output
Aug 5, 2026
72dda41
Add TIFF linear output with manual gamma linearization
Aug 5, 2026
fe024bf
Add TIFF expansion and strip color profiles options
Aug 5, 2026
6a5eac4
Remove strip-profiles checkbox — TIFF output is always clean
Aug 5, 2026
f0210cf
Fix linear output crash on non-standard TIFF DateTime tags
Aug 5, 2026
d479baf
Add Coolscan NEF loader as a first-class scanner format
Aug 5, 2026
1229f67
Add Flextight FFF loader as a first-class scanner format
Aug 5, 2026
58209f5
Add Noritsu RAW loader as a first-class scanner format
Aug 5, 2026
6f9b3f8
Harden scanner loaders: CFA rejection, NEF gamma, Noritsu tier-3, ove…
Aug 5, 2026
aad23b3
Fix Linear Output ICE for extra-page and sidecar IR sources
Aug 5, 2026
6d9937f
Parse FFF plist metadata, fix Linear Output IR bugs
Aug 5, 2026
c9ca599
Consolidate Linear Output decode functions to use main loaders
Aug 5, 2026
b763a20
Strip sRGB linearization from NEF and FFF loaders
Aug 5, 2026
4bfe2f9
Revert _decode_tiff to independent implementation with IR fixes
Aug 5, 2026
43574b6
Remove dead IR logic from NEF and FFF loaders
Aug 6, 2026
007778c
Document new Linear Output sources: NEF, FFF, Noritsu, TIFF, ICE
Aug 6, 2026
6719040
Detect SGI LogLuv FFF files and error clearly
Aug 6, 2026
42c8182
Add SGI LogLuv decoder for Flextight FFF raw files
Aug 6, 2026
32b1ad7
Document that Linear Output TIFF is always written clean
Aug 6, 2026
738f5e8
Add per-channel percentile normalization to LogLuv decoder
Aug 6, 2026
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
12 changes: 9 additions & 3 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,21 @@ Both are fixed (no per-frame metering) so an evenly-exposed roll renders identic

When the render intent is **Linear**, the entire darkroom pipeline is bypassed. The source file is decoded to its native linear buffer, lossless geometry (EXIF orientation + user rotation/flip) is applied, and the result is written as an untagged 16-bit TIFF with zlib compression. No normalization, exposure, colour management, flatfield, or sensor correction runs.

* **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.
* **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. Some external Pakon tools use 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).
* **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`, following the `RAW-WB` XMP convention used by external linear-workflow tools. The source filename is stored in `crs:RawFileName`. No expansion option (camera sensors use the full bit depth).
* **Coolscan NEF** (`negpy.infrastructure.loaders.nef_loader`): Nikon Coolscan scanner files are TIFF-structured with the full-res RGB image in a SubIFD chain (tag 0x014A). The loader picks the largest RGB SubIFD by pixel count and rejects files containing CFA/Bayer SubIFDs (camera NEFs). Despite the name, scanner NEFs are not raw sensor data — they are processed images whose content depends on the Nikon Scan settings used at scan time (curves, gain, DigitalICE, etc.). Getting linear, unprocessed output requires the right Nikon Scan settings before scanning. The loader makes no assumptions about linearity or colour space. No separate IR channel exists; any extra channels beyond RGB are dropped. No expansion.
* **Flextight FFF** (`negpy.infrastructure.loaders.fff_loader`): Imacon/Hasselblad Flextight scanner files in two variants: uncompressed 16-bit RGB (standard FlexColor export), and SGI LogLuv compressed (raw `.3fr`/`.fff` from the scanner hardware). Uncompressed files are big-endian TIFFs with the full-res image in a top-level IFD (selected by pixel count, not SubfileType — the tag is unreliable). LogLuv files (compression tags 34676/34677) are decoded via `negpy.infrastructure.loaders.logluv` — a LogLuv32/24 → CIE XYZ → linear sRGB pipeline ported from [flexcolor-tool](https://github.com/rohanpandula/flexcolor-tool) (MIT). Because LogLuv is an HDR encoding whose raw linear values routinely exceed 1.0, the decoder applies per-channel percentile normalization (0.2th/99.8th) after the XYZ → linear RGB conversion — this is part of the decode recipe (matching the flexcolor-tool reference), not a creative edit; without it the data would be silently truncated, not "raw." The data is gain-normalized linear transmittance with no gamma applied. Embedded FlexColor metadata is parsed from two proprietary tags: tag 50457 (Apple plist with film stock, film type, gamma, DPI, scan date) and tag 46279 (firmware version, scanner serial). No IR hardware exists on Flextight scanners; extra channels beyond RGB are dropped. No expansion.
* **Noritsu RAW** (`negpy.infrastructure.loaders.noritsu_loader`): headerless BGR 16-bit little-endian scanner dumps. Frame dimensions are auto-detected from file size against a table of known Noritsu scan dimensions (three tiers: exact match, known-width with novel height, and novel-width fallback). The channel order is BGR, swapped to RGB on load. 12-bit sensor data in 16-bit range; default expansion is 16× (`NORITSU_EXPANSION`).
* **TIFF** (standalone `_decode_tiff`, not routed through `TiffLoader` — kept independent to avoid inheriting TiffLoader's sRGB linearization assumptions): the 4th channel is treated as IR only when the ExtraSamples tag is 0 (UNSPECIFIED) or missing; values 1/2 (associated/unassociated alpha) are dropped. Sidecar IR files (`_ir.tif` next to the source, with optional `_ir_valid` mask) and IR stored in secondary TIFF pages (SilverFast iSRD convention) are also detected. An **Input gamma** selector lets the user declare the source encoding (linear, 1.8, 2.2, or sRGB) so the data can be linearized before export. Expansion is available (off by default).
* **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).

**ICE dust removal** (visible when an IR channel is available): when enabled, IR-based dust and scratch correction is applied to the linear buffer before writing. Off by default — the raw dump philosophy applies.

**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.
**Output is always clean.** The TIFF is written from scratch — only raw pixels plus Make/Model/DateTime from the source. ICC profiles, EXIF color space tags, and XMP color metadata from scanner software or editors are never copied through. The description field records the source format, expansion, white balance, and any applied corrections, and ends with "no color management". For Flextight FFF files, the Make field includes film stock and type from the embedded plist, and the Model field includes the scanner serial.

---

Expand Down
11 changes: 9 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -545,10 +545,17 @@ 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. 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.
* **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`) so it can be applied by downstream tools. 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.
* **Coolscan NEF** — Nikon Coolscan scanner files. Despite the name, these are not raw sensor data — the content depends on the Nikon Scan settings used at scan time. Getting linear, unprocessed output requires the right settings before scanning. The full-res RGB SubIFD is read directly; any extra channels beyond RGB are dropped (Coolscan has no separate IR channel). No expansion.
* **Flextight FFF** — Imacon/Hasselblad Flextight scanner files, including both standard uncompressed 16-bit RGB exports and SGI LogLuv compressed raw files (`.3fr`/`.fff`). LogLuv files are decoded through a LogLuv → XYZ → linear sRGB pipeline with per-channel percentile normalization (LogLuv is HDR, so normalization is part of the decode — without it the data would be truncated, not raw). The largest image IFD is selected by pixel count. Data is linear scanner transmittance. Embedded FlexColor metadata (film stock, film type, scan date, scanner serial) from the proprietary plist (tag 50457) and firmware blob (tag 46279) is carried through to the output TIFF headers. No expansion.
* **Noritsu RAW** — headerless BGR 16-bit scanner dumps. Frame dimensions are auto-detected from file size against known Noritsu scan dimensions. 16× expansion by default (12-bit sensor data in 16-bit range).
* **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` TIFF. 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 defaults to 16×, F335 and LinearRaw DNG default to off. Camera RAW, Coolscan NEF, and Flextight FFF files have no expansion option. Leave 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 (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).

The output TIFF is always written clean — no ICC profiles, no EXIF color space tags, and no XMP color metadata from the source are carried through. Only raw pixels plus device metadata (Make, Model, DateTime) from the source file.

### Export button

The primary **Export** action. Its chevron menu picks the scope: current frame (Ctrl+E), selected frames, all visible with current settings, or all visible with each frame's saved settings.
Expand Down
5 changes: 4 additions & 1 deletion negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3471,7 +3471,7 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None:
if not is_linear_output_supported(file_path):
self.set_status("Linear Output is not supported for this file type", 4000)
return
files = [{"path": file_path, "name": os.path.basename(file_path)}]
files = [{"path": file_path, "name": os.path.basename(file_path), "hash": self.state.current_file_hash}]

supported = [f for f in files if is_linear_output_supported(f["path"])]
if not supported:
Expand Down Expand Up @@ -3505,6 +3505,9 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None:
apply_wb=self.state.linear_apply_wb,
apply_flatfield=self.state.linear_apply_flatfield,
apply_sensor=self.state.linear_apply_sensor,
apply_ice=self.state.linear_apply_ice,
retouch=params.retouch,
gamma_key=self.state.linear_gamma_key,
)
exported += 1
except Exception as e:
Expand Down
9 changes: 8 additions & 1 deletion negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ class AppState:
linear_apply_wb: bool = False
linear_apply_flatfield: bool = False
linear_apply_sensor: bool = False
linear_apply_ice: bool = False
linear_gamma_key: str = "linear"

@property
def local_hidden_masks(self) -> set:
Expand Down Expand Up @@ -500,10 +502,13 @@ 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"):
for key in ("linear_apply_wb", "linear_apply_flatfield", "linear_apply_sensor", "linear_apply_ice"):
val = self.repo.get_global_setting(key)
if val is not None:
setattr(self.state, key, bool(val))
saved_gamma = self.repo.get_global_setting("linear_gamma_key")
if saved_gamma is not None:
self.state.linear_gamma_key = str(saved_gamma)

self.state.export_presets = self.repo.load_export_presets()

Expand Down Expand Up @@ -585,6 +590,8 @@ def save_flat_output_prefs(self) -> None:
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)
self.repo.save_global_setting("linear_apply_ice", self.state.linear_apply_ice)
self.repo.save_global_setting("linear_gamma_key", self.state.linear_gamma_key)

def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = False) -> WorkspaceConfig:
"""
Expand Down
Loading
Loading