Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Migrations that rewrite *rows* rather than a config payload need a repository, s

### Pipeline

- **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`) — base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish. The first four stages are cached per config-hash via `_run_stage()`; the rest run unconditionally. The base and exposure stages route on `process.path.render_path` (`base_processor` / `exposure_processor` in `engine.py`): `PRINT` runs the negative's processors in `features/exposure/`, `TRANSFER` and `POSITIVE` (a slide as captured, a Positive frame) run `features/transparency/`. The negative's processors hold no slide branch, and `features/exposure/` never imports `features/transparency/` (`tests/test_slide_separation.py`). The alt-process stage (lith or cyanotype, never both) is B&W-only and off by default; when off, both engines skip it rather than run an identity pass.
- **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`) — base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish → output transform → levels → curves. The first four stages are cached per config-hash via `_run_stage()`; the rest run unconditionally. The base and exposure stages route on `process.path.render_path` (`base_processor` / `exposure_processor` in `engine.py`): `PRINT` runs the negative's processors in `features/exposure/`, `TRANSFER` and `POSITIVE` (a slide as captured, a Positive frame) run `features/transparency/`. The negative's processors hold no slide branch, and `features/exposure/` never imports `features/transparency/` (`tests/test_slide_separation.py`). The alt-process stage (lith or cyanotype, never both) is B&W-only and off by default; when off, both engines skip it rather than run an identity pass.
- **GPU**: `GPUEngine` (`negpy/services/rendering/gpu_engine.py`) — the same logical stages as WGSL compute shaders from `negpy/features/<name>/shaders/`, with its own config-diff change detection.
- **Orchestration**: `ImageProcessor` (`image_processor.py`) tries GPU first and falls back to CPU. Export always runs full-res, with CPU stage caching off (`PipelineContext.cache_stages`). Linear DNG decode, CPU saturation and unsharp masking use row blocks to bound temporary storage. `PipelineContext` carries `scale_factor`, `process_mode`, `active_roi` and a `metrics` dict between stages.
- **Embedded lens correction** (`features/lens`) is a single-file decode step shared by preview and export: flat-field, lens warp, then sensor unmix and user geometry. Its independent distortion and CA settings and flat-field token belong to the source identity. It is disabled for composite setup, composite assembly and RGB+IR sources.
Expand Down
18 changes: 18 additions & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,3 +547,21 @@ Post-crop print finishing in scene-linear, before the output transform. Order: e

* **Preview paper size**: an interactive render sizes the paper from the preview long edge (`preview_render_size`) instead of the export DPI, and resamples the content to fit, never above its own resolution, because the canvas quotes zoom against the pipeline's buffer and an upscale would make 1:1 read closer than one scan pixel per device pixel. The display shader magnifies instead.

---

## 10. Levels
**Code**: `negpy.features.exposure.levels` (CPU) / `negpy.features.exposure.shaders.levels.wgsl` (GPU)

GIMP-style fine-tuning on the display-encoded output, after the output transform and every creative stage. Four channels — the Global master, then Red, Green, Blue — each mapping an input window onto an output window through a gamma:

$$t = \mathrm{clamp}\!\left(\frac{x - l}{h - l},\ 0,\ 1\right), \qquad t \leftarrow t^{1/\gamma}, \qquad I_{out} = o_l + (o_h - o_l)\,t$$

Bounds are 0-255 code values, $\gamma$ is 0.1 to 10.0 at 1.0. The master applies equally to all three channels first, then each per-channel curve trims on top. A degenerate window ($h \le l$) thresholds at the low marker. Identity by default, so an untouched edit renders exactly as before. Skipped under the Flat intent, which never encodes.

**Auto** (`auto_input_window`) stretches one channel like GIMP's Auto Input Levels: gamma 1, full output range, input bounds at the first bin past a 0.6% tail each end, identity on a degenerate frame.

### Curves
**Code**: `negpy.features.exposure.curves` (CPU bake + apply) / `negpy.features.exposure.shaders.curves.wgsl` (GPU)

Per-channel tone maps on the display-encoded output, after Levels. Four channels — the Global master, then Red, Green, Blue — each holding eight control points: offsets from the identity line at movable input positions, ordered and held monotone so the map never folds. The offsets bake through a monotone cubic (Fritsch-Carlson PCHIP, hand-rolled: no scipy) into a 256-entry table; both engines apply the table by integer indexing (`u32(clamp(x*255+0.5))`), so parity holds by construction. Identity lanes are skipped through an active mask: even an identity table would quantize to 8 bits. Skipped under the Flat intent with everything else past the output transform.

6 changes: 6 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,12 @@ Color timing, like enlarger dichroic filters. The **Global / Shadows / Highlight

In R/G/B mode these become per-layer trims: **Grade** (±30 ISO-R), **Toe** / **Shoulder** (±1), **Toe Width** / **Shoulder Width** (±2), **Snap** (±0.5), **Dye Separation** (±0.4).

**Levels**, last on the panel and last in the pipeline:

* **Global / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Global is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. **Auto** (wand, in the LEVELS header) stretches the shown channel like GIMP's Auto Input Levels: gamma back to 1.00, full output range, input window at the 0.6% clip points — on Global it reads luminance, so hues don't move. The header's undo arrow resets every channel.

**Curves**, after Levels and last of all: per-channel tone maps over the finished output. **Global / Red / Green / Blue** picks the channel; Global is the master curve, the others trim one channel on top. Each channel shows its input histogram with its curve: eight nodes, dragged in both directions to move input and output (they clamp against their neighbours, so the curve never folds); double-click resets the channel. The header's undo arrow resets every channel.

<!-- panel:local -->
### 5.3 Dodge & Burn: local exposure

Expand Down
54 changes: 54 additions & 0 deletions negpy/desktop/settings_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,38 @@ def _fmt_gear(values: tuple) -> str:
_row("Contrast Mask", "exposure", "contrast_mask"),
_row("Mask Spacer", "exposure", "mask_spacer"),
_row("Preflash", "exposure", "preflash"),
_row("Levels Global", "exposure", "levels_in_low", "levels_gamma", "levels_in_high", "levels_out_low", "levels_out_high"),
_row(
"Levels Red",
"exposure",
"levels_in_low_red",
"levels_gamma_red",
"levels_in_high_red",
"levels_out_low_red",
"levels_out_high_red",
),
_row(
"Levels Green",
"exposure",
"levels_in_low_green",
"levels_gamma_green",
"levels_in_high_green",
"levels_out_low_green",
"levels_out_high_green",
),
_row(
"Levels Blue",
"exposure",
"levels_in_low_blue",
"levels_gamma_blue",
"levels_in_high_blue",
"levels_out_low_blue",
"levels_out_high_blue",
),
_row("Curves Global", "exposure", *[f for i in range(8) for f in (f"curve_{i}", f"curve_x_{i}")]),
_row("Curves Red", "exposure", *[f for i in range(8) for f in (f"curve_{i}_red", f"curve_x_{i}_red")]),
_row("Curves Green", "exposure", *[f for i in range(8) for f in (f"curve_{i}_green", f"curve_x_{i}_green")]),
_row("Curves Blue", "exposure", *[f for i in range(8) for f in (f"curve_{i}_blue", f"curve_x_{i}_blue")]),
_row("Auto Density", "exposure", "auto_exposure", sticky=True),
_row("Auto Grade", "exposure", "auto_normalize_contrast", sticky=True),
_row("Paper Profile", "exposure", "paper_profile", sticky=True),
Expand Down Expand Up @@ -432,6 +464,28 @@ def rows_for_section(section: str) -> list[SettingRow]:
"contrast_mask",
"mask_spacer",
"preflash",
"levels_in_low",
"levels_gamma",
"levels_in_high",
"levels_out_low",
"levels_out_high",
"levels_in_low_red",
"levels_gamma_red",
"levels_in_high_red",
"levels_out_low_red",
"levels_out_high_red",
"levels_in_low_green",
"levels_gamma_green",
"levels_in_high_green",
"levels_out_low_green",
"levels_out_high_green",
"levels_in_low_blue",
"levels_gamma_blue",
"levels_in_high_blue",
"levels_out_low_blue",
"levels_out_high_blue",
*[f"curve_{i}{sfx}" for sfx in ("", "_red", "_green", "_blue") for i in range(8)],
*[f"curve_x_{i}{sfx}" for sfx in ("", "_red", "_green", "_blue") for i in range(8)],
)

# Frame cards whose settings can be pushed to other frames, and the fields each owns. A
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 @@ -448,6 +448,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]:
"toggle_auto_density": controls.tone_sidebar.auto_density_action.trigger,
"toggle_auto_grade": controls.tone_sidebar.auto_grade_action.trigger,
"toggle_auto_both": controls.tone_sidebar.auto_both_action.trigger,
"auto_levels": lambda: controls.tone_sidebar.levels_auto_btn.click(),
"preset_apply": controls.presets_sidebar.apply_btn.click,
"preset_save": controls.presets_sidebar.save_btn.click,
}
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 @@ -83,6 +83,7 @@ class ShortcutEntry:
"toggle_auto_density": ShortcutEntry("", "Toggle Auto Density", "Exposure"),
"toggle_auto_grade": ShortcutEntry("", "Toggle Auto Grade", "Exposure"),
"toggle_auto_both": ShortcutEntry("", "Toggle Auto Density and Auto Grade together", "Exposure"),
"auto_levels": ShortcutEntry("", "Auto levels on the selected channel", "Exposure"),
"preset_apply": ShortcutEntry("", "Apply the selected preset", "Actions"),
"preset_save": ShortcutEntry("", "Save a preset from the current settings", "Actions"),
"toggle_test_strip": ShortcutEntry("Shift+T", "Density × grade test strip", "Tools"),
Expand Down
7 changes: 7 additions & 0 deletions negpy/desktop/view/sidebar/controls_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,13 @@ def apply_shortcut_tooltips(self) -> None:
["preflash_inc", "preflash_dec"],
)
)
exp.levels_auto_btn.setToolTip(
tooltip_with_shortcut(
"Auto Levels: stretch this channel's input range onto the output range at the 0.6% clip points, "
"like GIMP's Auto Input Levels. With Global selected the stretch reads luminance, so hues don't move",
"auto_levels",
)
)
lab.clahe_slider.setToolTip(
tooltip_with_shortcut(
"Local contrast (CLAHE) without blowing global highlights or crushing shadows. Use sparingly — near 1.0 can look cartoonish",
Expand Down
Loading
Loading