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
11 changes: 11 additions & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ $$I_{out} = \text{clip}\big(\text{lift} + \text{gain} \cdot (1 - \text{val}),\ 0

Both are fixed (no per-frame metering) so an evenly-exposed roll renders identically; manual white balance still rides as an additive per-channel shift in log space. For a flat intent the engine also bypasses the creative stages (Local Contrast, Retouch, Lab, Toning, Finish, and dodge/burn masks are skipped too); only Geometry → Normalization → this log map → Crop run. Export is full-resolution; the colour space follows the export selection (color-managed at encode like the print path), as 16-bit TIFF. CPU engine is forced (no GPU flat shader) for numerical exactness.

### Linear Output
**Code**: `negpy.services.export.linear_output`

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.
* **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).

Source device metadata (Make, Model, DateTime) is carried through to the output TIFF when available from the source file.

---

## 4. Local Contrast (CLAHE)
Expand Down
5 changes: 5 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,11 @@ A scrollable list of every edit step (last 100 kept), newest on top; the current
* **Flat**: a flat, neutral, low-contrast master that keeps maximum tonal/colour information for editing elsewhere (Lightroom, Darktable, Photoshop). Skips the print look, effects, toning, and vignette, and writes a wide-gamut 16-bit TIFF. Your in-app preview is unaffected.
* **Preview Flat**: temporarily show the flat master on the canvas without changing your edit.
* **Roll Baseline**: measure every visible frame and share one exposure baseline, so flat masters are consistent across a roll (recommended before a flat batch).
* **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.
* **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

Expand Down
63 changes: 63 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ class AppController(QObject):
monitor_profile_changed = pyqtSignal()
compare_changed = pyqtSignal(bool)
flat_output_changed = pyqtSignal(bool)
linear_output_changed = pyqtSignal(bool)
flat_peek_changed = pyqtSignal(bool)
zoom_requested = pyqtSignal(float)
zoom_changed = pyqtSignal(float)
Expand Down Expand Up @@ -3004,6 +3005,8 @@ def set_flat_output(self, enabled: bool) -> None:
if self.state.flat_output == enabled:
return
self.state.flat_output = enabled
if enabled:
self.state.linear_output = False
self.session.save_flat_output_prefs()
# Flat masters default to full resolution; only honour Print/Pixels when the
# user explicitly selects those modes in the export panel.
Expand All @@ -3019,10 +3022,25 @@ def set_flat_output(self, enabled: bool) -> None:
persist=True,
)
self.flat_output_changed.emit(enabled)
if enabled:
self.linear_output_changed.emit(False)
# If a peek is active and flat output was turned off, drop back to the edit.
if not enabled and self.state.flat_peek:
self.toggle_flat_peek(force=False)

def set_linear_output(self, enabled: bool) -> None:
"""Toggle the linear output intent (raw loader dump, no pipeline)."""
if self.state.linear_output == enabled:
return
self.state.linear_output = enabled
if enabled:
self.state.flat_output = False
self.flat_output_changed.emit(False)
if self.state.flat_peek:
self.toggle_flat_peek(force=False)
self.session.save_flat_output_prefs()
self.linear_output_changed.emit(enabled)

def toggle_flat_peek(self, force: Optional[bool] = None) -> None:
"""Preview the flat master render in the canvas without changing the saved edit.

Expand Down Expand Up @@ -3149,6 +3167,51 @@ def export_history_step(self, index: int) -> None:
self.session.jump_to_step(index)
self.request_export()

def request_linear_output_export(self, files: list[dict] | None = None) -> None:
"""Export decoded linear buffers as untagged 16-bit TIFFs to the export folder."""
from negpy.services.export.linear_output import export_linear_output, is_linear_output_supported

export_path = self._ensure_valid_export_path()
if not export_path:
return

if files is None:
file_path = self.state.current_file_path
if not file_path:
return
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)}]

supported = [f for f in files if is_linear_output_supported(f["path"])]
if not supported:
self.set_status("No files support Linear Output", 4000)
return

if len(supported) > 1 and not self._confirm_bulk_export(f"Linear-export {len(supported)} frames?"):
return

exported = 0
geometry = self.state.config.geometry
expansion = self.state.linear_expansion
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")
counter = 2
while os.path.exists(out_path):
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)
exported += 1
except Exception as e:
logger.warning("Linear output failed for %s: %s", f.get("name"), e)
self.set_status(f"Linear Output failed: {os.path.basename(f['path'])}: {e}", 4000)

if exported:
self.set_status(f"Linear Output: exported {exported} file(s)", 4000)

def request_export(self) -> None:
"""Exports the current file using the settings currently shown in the Export panel."""
if self._batch_busy("export"):
Expand Down
12 changes: 11 additions & 1 deletion negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ class AppState:
# Transient: preview is currently peeking the flat render (not persisted).
flat_peek: bool = False

# Linear Output: export the loader's raw decoded buffer as an untagged 16-bit TIFF.
linear_output: bool = False
# Linear Output expansion factor override. None = source-type default (4× Pakon, off DNG).
linear_expansion: float | None = None

@property
def local_hidden_masks(self) -> set:
"""The current file's hidden-mask indices (empty = all shown). Returns a fresh,
Expand Down Expand Up @@ -463,6 +468,10 @@ def __init__(self, repo: StorageRepository):
if saved_flat_output is not None:
self.state.flat_output = bool(saved_flat_output)

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)

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

def set_gpu_enabled(self, enabled: bool) -> None:
Expand Down Expand Up @@ -516,8 +525,9 @@ def save_export_presets(self) -> None:
self.repo.save_export_presets(self.state.export_presets)

def save_flat_output_prefs(self) -> None:
"""Persists the flat ('for editing elsewhere') output preferences."""
"""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)

def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = False) -> WorkspaceConfig:
"""
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/keyboard_shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]:
"zoom_100": self.window.canvas.zoom_to_original,
"zoom_200": lambda: self.window.canvas.zoom_to_percent(200.0),
"export": controller.request_export,
"export_linear_output": controller.request_linear_output_export,
"copy": controller.session.copy_settings,
"copy_with_bounds": controller.session.copy_settings_with_bounds,
"paste": lambda: open_paste_dialog(self.window, controller),
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/shortcut_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ class ShortcutEntry:
"zoom_100": ShortcutEntry("1", "Zoom 100%", "View"),
"zoom_200": ShortcutEntry("2", "Zoom 200%", "View"),
"export": ShortcutEntry("Ctrl+E", "Export", "Actions"),
"export_linear_output": ShortcutEntry("", "Export Linear Output", "Actions"),
"copy": ShortcutEntry("Ctrl+C", "Copy settings", "Actions"),
"copy_with_bounds": ShortcutEntry("Ctrl+Shift+C", "Copy settings (with bounds)", "Actions"),
"paste": ShortcutEntry("Ctrl+V", "Paste settings", "Actions"),
Expand Down
Loading
Loading