From 49841970929794df5f21e6445e60b9fa2cdc9fc5 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 5 Jul 2026 08:59:22 +0200 Subject: [PATCH 1/7] style: apply ruff format to stragglers --- negpy/desktop/view/sidebar/export.py | 7 ++----- tests/test_controller.py | 4 +--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/negpy/desktop/view/sidebar/export.py b/negpy/desktop/view/sidebar/export.py index 752c9ba3..37fcc212 100644 --- a/negpy/desktop/view/sidebar/export.py +++ b/negpy/desktop/view/sidebar/export.py @@ -126,8 +126,7 @@ def _add_presets_section(self) -> None: self.manage_presets_btn.setIcon(qta.icon("fa5s.sliders-h", color=THEME.text_primary)) self.manage_presets_btn.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) preset_scope_tooltip = ( - "Export the selected filmstrip frames with every enabled preset. " - "Use the menu arrow for current frame or all visible frames." + "Export the selected filmstrip frames with every enabled preset. Use the menu arrow for current frame or all visible frames." ) self.export_presets_group = QWidget() export_presets_row = QHBoxLayout(self.export_presets_group) @@ -145,9 +144,7 @@ def _add_presets_section(self) -> None: export_current_action.setToolTip("Export the current file with every enabled preset") export_current_action.triggered.connect(self.controller.request_preset_export) export_all_presets_action = preset_menu.addAction("Export all visible frames…") - export_all_presets_action.setToolTip( - "Export every visible frame in the filmstrip with every enabled preset" - ) + export_all_presets_action.setToolTip("Export every visible frame in the filmstrip with every enabled preset") export_all_presets_action.triggered.connect(self.controller.request_preset_batch_export) self.export_presets_menu_btn = QToolButton() diff --git a/tests/test_controller.py b/tests/test_controller.py index 56251fdb..059da7b9 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -526,9 +526,7 @@ def setUp(self): self.visible_indices = [0, 1, 2] self.mock_session_manager.asset_model = MagicMock() - self.mock_session_manager.asset_model.visible_actual_indices_ordered.side_effect = ( - lambda: list(self.visible_indices) - ) + self.mock_session_manager.asset_model.visible_actual_indices_ordered.side_effect = lambda: list(self.visible_indices) with ( patch("negpy.desktop.controller.RenderWorker") as mock_rw_class, From b6f4e0683a6712e1e12ae244f894e7c331692483 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 5 Jul 2026 08:59:32 +0200 Subject: [PATCH 2/7] feat: dodge/burn as print exposure ahead of paper curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Masks now offset per-pixel print exposure before the H&D curve (same domain as WB filtration) instead of multiplying the finished positive: burns roll into paper black through the toe, dodges lift through the shoulder — no clipped flat patches. Local stage removed; the EV map feeds the exposure pass on CPU and GPU (bit-exact parity), tiled export slices it per tile. Dodge & Burn UI moves to the Exposure tab. Existing dodge/burn edits render slightly differently. --- CLAUDE.md | 8 +- build.py | 1 - docs/CHANGELOG.md | 5 + negpy/desktop/view/sidebar/controls_panel.py | 14 ++- negpy/desktop/view/widgets/tutorial_steps.py | 27 ++--- negpy/features/exposure/logic.py | 37 +++++- negpy/features/exposure/processor.py | 32 +++++- negpy/features/exposure/shaders/exposure.wgsl | 14 ++- negpy/features/local/logic.py | 47 ++------ negpy/features/local/processor.py | 25 ---- negpy/features/local/shaders/local.wgsl | 19 ---- negpy/kernel/caching/manager.py | 2 - negpy/services/rendering/engine.py | 12 +- negpy/services/rendering/gpu_engine.py | 107 ++++++++---------- tests/test_exposure_logic.py | 102 +++++++++++++++++ tests/test_local_logic.py | 87 +++++--------- 16 files changed, 307 insertions(+), 232 deletions(-) delete mode 100644 negpy/features/local/processor.py delete mode 100644 negpy/features/local/shaders/local.wgsl diff --git a/CLAUDE.md b/CLAUDE.md index 1b7a8cad..6cd0dd14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,13 +41,15 @@ Every feature under `negpy/features//` follows this structure: ### CPU pipeline (`negpy/services/rendering/engine.py`) -`DarkroomEngine.process()` runs stages in order: base (geometry + normalization) → exposure → retouch → lab → local (dodge/burn) → toning → crop → finish. The cached stages (base, exposure, retouch, lab, local) go through `_run_stage()`, which hashes the stage config and skips re-execution if the hash matches the cached `CacheEntry`; toning/crop/finish run unconditionally. Source image change clears the whole cache; a process-mode change invalidates only base/exposure/retouch/lab. Note `settings.geometry` drives both the early geometry transform and the late crop stage. +`DarkroomEngine.process()` runs stages in order: base (geometry + normalization) → exposure → retouch → lab → toning → crop → finish. The cached stages (base, exposure, retouch, lab) go through `_run_stage()`, which hashes the stage config and skips re-execution if the hash matches the cached `CacheEntry`; toning/crop/finish run unconditionally. Source image change clears the whole cache; a process-mode change invalidates only base/exposure/retouch/lab. Note `settings.geometry` drives both the early geometry transform and the late crop stage. + +**Dodge/burn lives in the exposure stage.** Polygon masks (`negpy/features/local/`, `LocalAdjustmentsConfig`) rasterise to a per-pixel **EV map** (`compute_local_ev_map`, Σ strength·alpha) consumed by the print curve as a print-exposure offset next to the WB `cmy_offsets` (`local_ev_scale` = −log10(2)/stretch range per channel) — burns roll into paper black through the toe, dodges lift through the shoulder, like enlarger exposure. There is no separate local stage or shader: the exposure `_run_stage` is keyed on `(settings.exposure, settings.local)`, the GPU binds the EV map as a texture in the exposure pass (`ev_scale.w` gates a 1×1 dummy when no masks), and tiled export slices the full-res EV map per tile. The UI section sits on the Exposure ("tone") page. **Flat-field profile (rig-level) + lens distortion.** Flat-field is a per-rig **profile** (DB `flatfield_profiles`: name, path, **k1**), not a per-image edit. `FlatFieldConfig` (`apply`, `reference_path`, `k1`) mirrors the active profile into the recipe but the profile is canonical — `reference_path` and `k1` are **re-seeded from the active profile on every file load** (`session.py`), so a roll shares them. The *photometric* correction (illumination falloff) is baked at **source-load** (`apply_flatfield`, before the pipeline → before any warp, so the flat stays registered to the raw sensor grid). The *geometric* correction is a single radial coefficient **k1** (poly3 / Brown-Conrady, half-diagonal–normalized so it's rotation/aspect invariant) folded into the **geometry transform** (`apply_radial_distortion` / `transform.wgsl`), gated by the same `apply` toggle (`k1_eff = k1 if apply else 0`). Being a geometry op, k1 invalidates the **base stage** (CPU `base_key`; GPU `_detect_invalidated_stage` checks `flatfield.apply`/`k1`) — **not** `source_hash`/`flatfield_token` (that would force a RAW re-decode per slider step). Scale-to-fill is baked into the same remap (`compute_distortion_scale`, no empty borders). The correction must stay consistent across the image warp **and** the coordinate mappers (`create_uv_grid` forward, `map_coords_to_geometry` inverse via `map_point_radial`) so crop/autocrop/retouch/dodge-burn placements stay aligned; CPU stages read the effective k1 from `context.metrics["distortion_k1"]`, GPU passes `k1_eff` directly. Tiled export applies it on the CPU `img_rot`/IR path (the shader geometry uniform is zeroed there). -**Scene-linear working space.** The pipeline is **scene-linear internally**: the exposure stage (`exposure/logic.py`) emits linear reflectance (`10^-D`, no OETF), and the creative stages **Lab, Local, Toning, Finish operate on linear light**. The working-space OETF — the **ProPhoto RGB (ROMM) TRC** (gamma `1.8` with a linear toe below `1/512`), `working_oetf_encode`/`working_oetf_decode` in `kernel/image/logic.py`, mirrored in WGSL `oetf_encode`/`oetf_decode` — is applied **only as the final engine step** (CPU: end of `process()`; GPU: the `output_encode` pass in `gpu_engine.process_to_texture`), so the encoded buffer composes correctly with the ProPhoto ICC at the display/export boundary (`WORKING_COLOR_SPACE`). **Retouch runs in the linear island** like the other creative stages: dust *detection* is perceptual, so on the CPU it computes the detection luma on a `working_oetf_encode`d copy while *healing* in linear (`retouch/logic.py`) — the engine no longer brackets it. The GPU keeps a single encoded perceptual region (exposure encodes → clahe/retouch operate encoded → lab's `load_lin` decodes back to linear), equivalent within parity tolerance. Lab/Toning compute CIELAB directly from linear (`rgb_to_lab_working`/`lab_to_rgb_working` use the **ProPhoto ROMM primaries at D50**, no transfer decode); CIELAB ops are encoding-invariant, while the light ops (glow/halation highlight mask, chemical-toning luma masks) compute their masks on a `working_oetf_encode`d copy so display-domain thresholds stay valid. The flat master keeps its own log encoding and is never OETF-encoded. When changing the working **TRC**, update all seven sites together: the CPU `working_oetf_*` helpers, the WGSL `oetf_*` in `exposure/output_encode/lab/toning/metrics` shaders, and the chart (`charts.py`); changing the **primaries/white point** additionally means the CPU `_PROPHOTO_TO_XYZ`/`_XYZ_TO_PROPHOTO`/`_D50_WHITE` and the inline matrices + white in `lab.wgsl`/`toning.wgsl`. The greyscale export path re-encodes luma from the working TRC to gamma 2.2 to match its `GrayGamma2.2` tag (`_apply_color_management_u16_greyscale`). +**Scene-linear working space.** The pipeline is **scene-linear internally**: the exposure stage (`exposure/logic.py`) emits linear reflectance (`10^-D`, no OETF), and the creative stages **Lab, Toning, Finish operate on linear light** (dodge/burn happens inside the exposure stage, pre-curve). The working-space OETF — the **ProPhoto RGB (ROMM) TRC** (gamma `1.8` with a linear toe below `1/512`), `working_oetf_encode`/`working_oetf_decode` in `kernel/image/logic.py`, mirrored in WGSL `oetf_encode`/`oetf_decode` — is applied **only as the final engine step** (CPU: end of `process()`; GPU: the `output_encode` pass in `gpu_engine.process_to_texture`), so the encoded buffer composes correctly with the ProPhoto ICC at the display/export boundary (`WORKING_COLOR_SPACE`). **Retouch runs in the linear island** like the other creative stages: dust *detection* is perceptual, so on the CPU it computes the detection luma on a `working_oetf_encode`d copy while *healing* in linear (`retouch/logic.py`) — the engine no longer brackets it. The GPU keeps a single encoded perceptual region (exposure encodes → clahe/retouch operate encoded → lab's `load_lin` decodes back to linear), equivalent within parity tolerance. Lab/Toning compute CIELAB directly from linear (`rgb_to_lab_working`/`lab_to_rgb_working` use the **ProPhoto ROMM primaries at D50**, no transfer decode); CIELAB ops are encoding-invariant, while the light ops (glow/halation highlight mask, chemical-toning luma masks) compute their masks on a `working_oetf_encode`d copy so display-domain thresholds stay valid. The flat master keeps its own log encoding and is never OETF-encoded. When changing the working **TRC**, update all seven sites together: the CPU `working_oetf_*` helpers, the WGSL `oetf_*` in `exposure/output_encode/lab/toning/metrics` shaders, and the chart (`charts.py`); changing the **primaries/white point** additionally means the CPU `_PROPHOTO_TO_XYZ`/`_XYZ_TO_PROPHOTO`/`_D50_WHITE` and the inline matrices + white in `lab.wgsl`/`toning.wgsl`. The greyscale export path re-encodes luma from the working TRC to gamma 2.2 to match its `GrayGamma2.2` tag (`_apply_color_management_u16_greyscale`). -**Flat ("for editing elsewhere") render intent.** When `settings.exposure.render_intent == RenderIntent.FLAT` (`negpy/features/exposure/models.py`), the Print stage uses `PhotometricProcessor._process_flat`, which calls `apply_flat_curve` (`negpy/features/exposure/logic.py`) — a true **log-video master**: the normalized log signal (`val`) is emitted **directly** as the code value, positive-oriented, `code = clip(lift + gain·(1 − val), 0, 1)` (Cineon-like `flat_log_gain`/`flat_log_lift`; `flat_curve_params` returns gain+lift). Crucially it does **not** apply `10^-D` or the working-space OETF — `10^-D` is the log→linear *decode* that would turn the signal back into a normal-contrast positive (that decode is why earlier "flat" attempts looked like ordinary photos, not log). The result is flat/milky and fully invertible for editing. It bypasses the asymmetric print kernel (`_apply_print_curve_kernel`) entirely. Ignores auto density/grade, cast removal, toe/shoulder, surround/flare (WB rides as a per-channel log shift). The engine **bypasses retouch/lab/local/toning/finish** (crop still runs). This is the digital-intermediate master path. `ImageProcessor` forces the **CPU engine** for flat renders (no WGSL flat shader — guarantees numerical exactness). The desktop exposes it as a hybrid output intent: `AppState.flat_output`/`flat_format` (persisted) drive export, `flat_peek` drives a preview-only peek; `flat_master_config()` / `flat_export_config()` in `negpy/domain/models.py` derive the flat config (full-res by default when Flat is enabled; Print/Pixels sizing honoured when explicitly selected; 16-bit TIFF or linear DNG; export colour space follows the user's selection). DNG export writes an uncompressed 16-bit LinearRaw DNG with `tifffile` via `ImageProcessor._encode_dng_bytes` → `write_dng_linear` (`negpy/services/scanning/writer.py`); `NewSubfileType=0` + the DNG version tags are required for LibRaw/rawpy to accept it. +**Flat ("for editing elsewhere") render intent.** When `settings.exposure.render_intent == RenderIntent.FLAT` (`negpy/features/exposure/models.py`), the Print stage uses `PhotometricProcessor._process_flat`, which calls `apply_flat_curve` (`negpy/features/exposure/logic.py`) — a true **log-video master**: the normalized log signal (`val`) is emitted **directly** as the code value, positive-oriented, `code = clip(lift + gain·(1 − val), 0, 1)` (Cineon-like `flat_log_gain`/`flat_log_lift`; `flat_curve_params` returns gain+lift). Crucially it does **not** apply `10^-D` or the working-space OETF — `10^-D` is the log→linear *decode* that would turn the signal back into a normal-contrast positive (that decode is why earlier "flat" attempts looked like ordinary photos, not log). The result is flat/milky and fully invertible for editing. It bypasses the asymmetric print kernel (`_apply_print_curve_kernel`) entirely. Ignores auto density/grade, cast removal, toe/shoulder, surround/flare, dodge/burn masks (WB rides as a per-channel log shift). The engine **bypasses retouch/lab/toning/finish** (crop still runs). This is the digital-intermediate master path. `ImageProcessor` forces the **CPU engine** for flat renders (no WGSL flat shader — guarantees numerical exactness). The desktop exposes it as a hybrid output intent: `AppState.flat_output`/`flat_format` (persisted) drive export, `flat_peek` drives a preview-only peek; `flat_master_config()` / `flat_export_config()` in `negpy/domain/models.py` derive the flat config (full-res by default when Flat is enabled; Print/Pixels sizing honoured when explicitly selected; 16-bit TIFF or linear DNG; export colour space follows the user's selection). DNG export writes an uncompressed 16-bit LinearRaw DNG with `tifffile` via `ImageProcessor._encode_dng_bytes` → `write_dng_linear` (`negpy/services/scanning/writer.py`); `NewSubfileType=0` + the DNG version tags are required for LibRaw/rawpy to accept it. For **roll consistency**, flat masters only match across frames when the roll shares one normalization baseline; the Export panel shows a "Bake roll baseline" nudge (reusing `request_batch_normalization`) when flat output is on but bounds aren't locked. Flat masters use the **selected export colour space** like the print path — the pipeline's ProPhoto RGB working buffer is color-managed to that space at encode time (`_apply_color_management_u16_rgb`). diff --git a/build.py b/build.py index ab1e750c..2c1781d8 100644 --- a/build.py +++ b/build.py @@ -66,7 +66,6 @@ "--add-data=negpy/features/toning/shaders:negpy/features/toning/shaders", "--add-data=negpy/features/retouch/shaders:negpy/features/retouch/shaders", "--add-data=negpy/features/lab/shaders:negpy/features/lab/shaders", - "--add-data=negpy/features/local/shaders:negpy/features/local/shaders", "--add-data=negpy/features/finish/shaders:negpy/features/finish/shaders", "--add-data=negpy/desktop/view/styles:negpy/desktop/view/styles", "--add-data=icc:icc", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 69f3af4b..7bfc160c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log +## 0.34.0 + +- Change: **Dodge & Burn is real print exposure** — masks now change the exposure ahead of the paper curve instead of brightening/darkening the finished image. Burns roll into paper black through the toe and dodges lift toward paper white through the shoulder, like holding back light under the enlarger — no more flat clipped patches under strong masks. Existing dodge/burn edits will render slightly differently. The Dodge & Burn section moved to the Exposure tab to match. + + ## 0.33.1 - Fix: **exporting all RGB-scan triplets no longer fails** with "Input/output error" on most frames. Batch export was reusing stale saved paths for each frame's green/blue exposures instead of the ones the triplet was actually built from, so it tried to read files that weren't there; it now uses each frame's own exposures, the same as exporting one at a time. diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index c8f32bab..0e7616bc 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -204,14 +204,20 @@ def _init_ui(self) -> None: [self.geometry_section, self.flatfield_section], ["geometry_section", "flatfield_section"], ), - ("tone", "fa5s.sun", "Exposure — Colour, Tone", [self.colour_section, self.tone_section], ["colour_section", "tone_section"]), + ( + "tone", + "fa5s.sun", + "Exposure — Colour, Tone, Dodge & Burn", + [self.colour_section, self.tone_section, self.local_section], + ["colour_section", "tone_section", "local_section"], + ), ("color", "fa5s.palette", "Color — Lab, Toning", [self.lab_section, self.toning_section], ["lab_section", "toning_section"]), ( "finish", "fa5s.brush", - "Finish — Retouch, Dodge & Burn, Finishing", - [self.retouch_section, self.local_section, self.finish_section], - ["retouch_section", "local_section", "finish_section"], + "Finish — Retouch, Finishing", + [self.retouch_section, self.finish_section], + ["retouch_section", "finish_section"], ), ] diff --git a/negpy/desktop/view/widgets/tutorial_steps.py b/negpy/desktop/view/widgets/tutorial_steps.py index c7ce3bd5..f485502e 100644 --- a/negpy/desktop/view/widgets/tutorial_steps.py +++ b/negpy/desktop/view/widgets/tutorial_steps.py @@ -273,6 +273,20 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]: target=_paper, section_attr="tone_section", ), + TutorialStep( + title="Dodge & Burn", + body=( + "Darkroom-style local lighten/darken with freehand polygon masks. " + "Draw Mask, click to drop vertices, double-click to close; each mask has " + "its own EV Strength and Feather.

" + "Masks change the print exposure ahead of the paper curve — burns roll " + "into paper black through the toe and dodges lift toward paper white, like " + "holding back light under the enlarger. Masks are stored in raw-image space, so " + "they survive rotation, flip and crop. Show Masks toggles their overlay." + ), + target=_local, + section_attr="local_section", + ), TutorialStep( title="Lab Panel — Film Aesthetics", body=( @@ -315,19 +329,6 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]: target=_retouch, section_attr="retouch_section", ), - TutorialStep( - title="Dodge & Burn", - body=( - "Darkroom-style local lighten/darken with freehand polygon masks. " - "Draw Mask, click to drop vertices, double-click to close; each mask has " - "its own EV Strength and Feather.

" - "Masks are stored in raw-image space, so they survive rotation, flip and crop. " - "Show Masks toggles their overlay. Runs on the GPU with bit-for-bit CPU " - "parity." - ), - target=_local, - section_attr="local_section", - ), TutorialStep( title="Finish — Vignette & Border", body=( diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index ee704480..25b7eb4d 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -71,6 +71,9 @@ def _apply_print_curve_kernel( gamma_width: float, dye_mix: np.ndarray, use_dye_mix: bool, + ev_map: np.ndarray, + ev_scale: np.ndarray, + use_ev: bool, flare: float = 0.0, surround_gamma: float = 1.0, ) -> np.ndarray: @@ -84,6 +87,9 @@ def _apply_print_curve_kernel( d_min_rgb: per-channel paper-white floor (base+fog incl. tint). dye_mix: dye coupling above that floor (D_rgb = M · D_dye) when use_dye_mix is set. + ev_map/ev_scale: per-pixel dodge/burn print-exposure offset (EV stops × + normalized-space stop size) when use_ev is set — same domain as cmy_offsets, + so burns/dodges roll through the toe/shoulder like real enlarger exposure. Output is linear reflectance (transmittance = 10^-D); the working-space OETF is applied at the engine output, not here. @@ -124,6 +130,8 @@ def _apply_print_curve_kernel( for x in range(w): for ch in range(3): val = img[y, x, ch] + cmy_offsets[ch] + if use_ev: + val = val + ev_map[y, x] * ev_scale[ch] # Quadratic per-channel core (curvature 0 -> the original straight line). v = slopes[ch] * (val - pivots[ch]) + curvatures[ch] * val * val @@ -266,8 +274,13 @@ def apply_characteristic_curve( midtone_gamma: Optional[float] = None, curvatures: Tuple[float, float, float] = (0.0, 0.0, 0.0), paper: Optional[PaperProfile] = None, + ev_map: Optional[np.ndarray] = None, + ev_scale: Tuple[float, float, float] = (0.0, 0.0, 0.0), ) -> ImageBuffer: - """Applies the asymmetric H&D print curve per channel in log-density space.""" + """Applies the asymmetric H&D print curve per channel in log-density space. + + ev_map (H×W, EV stops; positive = dodge) with ev_scale (see local_ev_scale) + applies per-pixel dodge/burn as print-exposure offsets ahead of the curve.""" c = effective_constants(paper) ts = c["toe_shoulder_strength"] if midtone_gamma is None: @@ -281,6 +294,8 @@ def apply_characteristic_curve( h_cmy = np.ascontiguousarray(np.array(highlight_cmy, dtype=np.float32)) dye = resolve_dye_matrix(paper) dye_mix = np.ascontiguousarray(np.eye(3) if dye is None else dye) + use_ev = ev_map is not None + ev_arr = np.ascontiguousarray(ev_map.astype(np.float32)) if ev_map is not None else np.zeros((1, 1), dtype=np.float32) res = _apply_print_curve_kernel( np.ascontiguousarray(img.astype(np.float32)), @@ -307,6 +322,9 @@ def apply_characteristic_curve( gamma_width=float(c["paper_gamma_width"]), dye_mix=dye_mix, use_dye_mix=dye is not None, + ev_map=ev_arr, + ev_scale=np.ascontiguousarray(np.array(ev_scale, dtype=np.float32)), + use_ev=use_ev, flare=float(flare), surround_gamma=float(surround_gamma), ) @@ -643,6 +661,23 @@ def filtration_offsets(wb_cmy: Tuple[float, float, float], bounds: Any) -> Tuple return (out[0], out[1], out[2]) +def local_ev_scale(bounds: Any) -> Tuple[float, float, float]: + """ + Normalized-space size of one dodge/burn stop, per channel: -log10(2) divided + by the channel's stretch range (like filtration_offsets), negative so a + positive EV (dodge) lowers print exposure -> lighter print. Equal EV in all + channels is a neutral light change; colour shifts emerge only through the + per-channel slopes, as on real paper. Range 1 when bounds are None. + """ + step = -float(np.log10(2.0)) + if bounds is None: + return (step, step, step) + out = [] + for ch in range(3): + out.append(step / max(abs(bounds.ceils[ch] - bounds.floors[ch]), 1e-6)) + return (out[0], out[1], out[2]) + + def cmy_to_density(val: float, log_range: float = 1.0) -> float: """ Converts a CMY slider value (-1.0..1.0) to a physical density shift (D). diff --git a/negpy/features/exposure/processor.py b/negpy/features/exposure/processor.py index 06c47f03..8becf54f 100644 --- a/negpy/features/exposure/processor.py +++ b/negpy/features/exposure/processor.py @@ -1,3 +1,5 @@ +from typing import Optional + import numpy as np from negpy.domain.interfaces import PipelineContext @@ -9,6 +11,7 @@ filtration_offsets, flat_curve_params, grade_coupled_shape, + local_ev_scale, normalized_neutral_axis, normalized_shadow_refs, per_channel_curve_params, @@ -31,6 +34,8 @@ resolve_crosstalk_matrix, unmix_log_image, ) +from negpy.features.local.logic import compute_local_ev_map +from negpy.features.local.models import LocalAdjustmentsConfig from negpy.features.process.models import ProcessConfig, ProcessMode from negpy.kernel.image.logic import get_luminance @@ -162,11 +167,30 @@ def analyze_base() -> LogNegativeBounds: class PhotometricProcessor: """ - Applies H&D curve simulation. + Applies H&D curve simulation. Dodge/burn masks are folded in as per-pixel + print-exposure offsets ahead of the curve (real enlarger exposure changes). """ - def __init__(self, config: ExposureConfig): + def __init__(self, config: ExposureConfig, local_config: Optional[LocalAdjustmentsConfig] = None): self.config = config + self.local_config = local_config + + def _build_ev_map(self, image: ImageBuffer, context: PipelineContext) -> Optional[np.ndarray]: + if self.local_config is None or not self.local_config.masks: + return None + h, w = image.shape[:2] + geo = context.metrics.get("geometry_params", {}) + return compute_local_ev_map( + self.local_config, + h, + w, + orig_shape=context.original_size, + rotation=geo.get("rotation", 0), + fine_rotation=geo.get("fine_rotation", 0.0), + flip_horizontal=geo.get("flip_horizontal", False), + flip_vertical=geo.get("flip_vertical", False), + distortion_k1=context.metrics.get("distortion_k1", 0.0), + ) def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: if self.config.render_intent == RenderIntent.FLAT: @@ -222,6 +246,8 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: lum = get_luminance(image) image = np.stack([lum, lum, lum], axis=-1) + ev_map = self._build_ev_map(image, context) + img_pos = apply_characteristic_curve( image, params_r=(pivots[0], slopes[0]), @@ -239,6 +265,8 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: surround_gamma=EXPOSURE_CONSTANTS["target_system_gamma"] if self.config.surround else 1.0, curvatures=curvatures, paper=paper, + ev_map=ev_map, + ev_scale=local_ev_scale(final_bounds), ) if context.process_mode == ProcessMode.BW: diff --git a/negpy/features/exposure/shaders/exposure.wgsl b/negpy/features/exposure/shaders/exposure.wgsl index d0bec060..0b18d0e7 100644 --- a/negpy/features/exposure/shaders/exposure.wgsl +++ b/negpy/features/exposure/shaders/exposure.wgsl @@ -30,11 +30,16 @@ struct ExposureUniforms { dye_r: vec4, dye_g: vec4, dye_b: vec4, + // Dodge/burn: xyz = per-channel normalized-space size of one EV stop + // (local_ev_scale), w = enable flag (0 -> ev_tex is a dummy, skip it). + ev_scale: vec4, }; @group(0) @binding(0) var input_tex: texture_2d; @group(0) @binding(1) var output_tex: texture_storage_2d; @group(0) @binding(2) var params: ExposureUniforms; +// Per-pixel dodge/burn EV map, rasterised on the CPU (shared with the CPU path). +@group(0) @binding(3) var ev_tex: texture_2d; fn fast_sigmoid(x: f32) -> f32 { if (x >= 0.0) { @@ -89,10 +94,17 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let d_max_eff = max(vec3(d_max_base), d_min_eff + vec3(0.1)); let flare_white = pow(vec3(10.0), -d_min_rgb); + // Dodge/burn print-exposure offset (EV stops), same domain as cmy_offsets so + // burns/dodges roll through the toe/shoulder like real enlarger exposure. + var ev = 0.0; + if (params.ev_scale.w != 0.0) { + ev = textureLoad(ev_tex, coords, 0).r; + } + var dens: vec3; for (var ch = 0; ch < 3; ch++) { - let val = color[ch] + params.cmy_offsets[ch]; + let val = color[ch] + params.cmy_offsets[ch] + ev * params.ev_scale[ch]; // Quadratic per-channel core (curvature 0 -> the original straight line). var v = params.slopes[ch] * (val - params.pivots[ch]) + params.curvatures[ch] * val * val; diff --git a/negpy/features/local/logic.py b/negpy/features/local/logic.py index 8ad7aa84..d6eca8e1 100644 --- a/negpy/features/local/logic.py +++ b/negpy/features/local/logic.py @@ -27,7 +27,7 @@ def _rasterise_mask( return mask_f -def compute_local_factor_map( +def compute_local_ev_map( config: LocalAdjustmentsConfig, h: int, w: int, @@ -39,15 +39,15 @@ def compute_local_factor_map( distortion_k1: float = 0.0, ) -> np.ndarray: """ - Build the per-pixel multiplicative dodge/burn factor map [h, w] float32. - - factor = prod over masks of 2^(strength * alpha), where alpha is the - feathered polygon mask. All-ones when there are no masks. The image is - adjusted by multiplying each channel by this map. + Build the per-pixel dodge/burn print-exposure map [h, w] float32, in EV + stops: ev = sum over masks of strength * alpha, where alpha is the feathered + polygon mask. Positive = dodge (hold back light), negative = burn. All-zeros + when there are no masks. Consumed by the exposure stage as a per-pixel + exposure offset ahead of the H&D print curve. """ - factor = np.ones((h, w), dtype=np.float32) + ev = np.zeros((h, w), dtype=np.float32) if not config.masks: - return factor + return ev short_side = float(min(h, w)) for mask in config.masks: @@ -70,33 +70,6 @@ def compute_local_factor_map( sigma_px = mask.feather * short_side alpha = _rasterise_mask(transformed, h, w, sigma_px) - factor *= np.power(2.0, mask.strength * alpha, dtype=np.float32) - - return factor - - -def apply_local_adjustments( - img: np.ndarray, - config: LocalAdjustmentsConfig, - orig_shape: Tuple[int, int], - rotation: int = 0, - fine_rotation: float = 0.0, - flip_horizontal: bool = False, - flip_vertical: bool = False, - distortion_k1: float = 0.0, -) -> np.ndarray: - """ - Apply polygon dodge/burn masks to a linear float32 RGB image [H, W, 3]. - - Vertices are in raw-image normalised space; they are mapped into the - current image's geometry-transformed space before rasterisation. - Positive strength dodges (brightens), negative burns (darkens). - Returns the adjusted image clipped to [0, 1]. - """ - if not config.masks: - return img + ev += mask.strength * alpha - h, w = img.shape[:2] - factor = compute_local_factor_map(config, h, w, orig_shape, rotation, fine_rotation, flip_horizontal, flip_vertical, distortion_k1) - result = img.astype(np.float32, copy=True) * factor[..., np.newaxis] - return np.clip(result, 0.0, 1.0) + return ev diff --git a/negpy/features/local/processor.py b/negpy/features/local/processor.py deleted file mode 100644 index 2007d09d..00000000 --- a/negpy/features/local/processor.py +++ /dev/null @@ -1,25 +0,0 @@ -from negpy.domain.interfaces import PipelineContext -from negpy.domain.types import ImageBuffer -from negpy.features.local.logic import apply_local_adjustments -from negpy.features.local.models import LocalAdjustmentsConfig - - -class LocalProcessor: - def __init__(self, config: LocalAdjustmentsConfig): - self.config = config - - def process(self, img: ImageBuffer, context: PipelineContext) -> ImageBuffer: - if not self.config.masks: - return img - - geo = context.metrics.get("geometry_params", {}) - return apply_local_adjustments( - img, - self.config, - orig_shape=context.original_size, - rotation=geo.get("rotation", 0), - fine_rotation=geo.get("fine_rotation", 0.0), - flip_horizontal=geo.get("flip_horizontal", False), - flip_vertical=geo.get("flip_vertical", False), - distortion_k1=context.metrics.get("distortion_k1", 0.0), - ) diff --git a/negpy/features/local/shaders/local.wgsl b/negpy/features/local/shaders/local.wgsl deleted file mode 100644 index 9c19900d..00000000 --- a/negpy/features/local/shaders/local.wgsl +++ /dev/null @@ -1,19 +0,0 @@ -// Dodge/burn local adjustments. The per-pixel multiplicative factor map is -// rasterised on the CPU (cv2 fillPoly + Gaussian feather) and uploaded as a -// texture, so the shader only multiplies and clamps — guaranteeing CPU parity. - -@group(0) @binding(0) var input_tex: texture_2d; -@group(0) @binding(1) var output_tex: texture_storage_2d; -@group(0) @binding(2) var factor_tex: texture_2d; - -@compute @workgroup_size(8, 8) -fn main(@builtin(global_invocation_id) gid: vec3) { - let dims = textureDimensions(output_tex); - if (gid.x >= dims.x || gid.y >= dims.y) { return; } - let coords = vec2(i32(gid.x), i32(gid.y)); - - let color = textureLoad(input_tex, coords, 0).rgb; - let factor = textureLoad(factor_tex, coords, 0).r; - let adjusted = clamp(color * factor, vec3(0.0), vec3(1.0)); - textureStore(output_tex, coords, vec4(adjusted, 1.0)); -} diff --git a/negpy/kernel/caching/manager.py b/negpy/kernel/caching/manager.py index 72d38737..27287a8e 100644 --- a/negpy/kernel/caching/manager.py +++ b/negpy/kernel/caching/manager.py @@ -15,12 +15,10 @@ class PipelineCache: exposure: Optional[CacheEntry] = None retouch: Optional[CacheEntry] = None lab: Optional[CacheEntry] = None - local: Optional[CacheEntry] = None def clear(self) -> None: self.base = None self.exposure = None self.retouch = None self.lab = None - self.local = None self.source_hash = "" diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index 3b9629b2..df18f363 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -15,7 +15,6 @@ ) from negpy.features.toning.processor import ToningProcessor from negpy.features.lab.processor import PhotoLabProcessor -from negpy.features.local.processor import LocalProcessor from negpy.features.retouch.processor import RetouchProcessor from negpy.features.finish.processor import FinishProcessor from negpy.kernel.system.config import APP_CONFIG @@ -141,12 +140,14 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: current_img, pipeline_changed = self._run_stage(current_img, base_key, "base", run_base, context, pipeline_changed) def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: - img_out = PhotometricProcessor(settings.exposure).process(img_in, ctx) + img_out = PhotometricProcessor(settings.exposure, settings.local).process(img_in, ctx) return img_out + # Dodge/burn is part of the print exposure, so the local config keys + # this stage alongside the exposure params. current_img, pipeline_changed = self._run_stage( current_img, - settings.exposure, + (settings.exposure, settings.local), "exposure", run_exposure, context, @@ -177,11 +178,6 @@ def run_lab(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: current_img, pipeline_changed = self._run_stage(current_img, settings.lab, "lab", run_lab, context, pipeline_changed) - def run_local(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: - return LocalProcessor(settings.local).process(img_in, ctx) - - current_img, pipeline_changed = self._run_stage(current_img, settings.local, "local", run_local, context, pipeline_changed) - current_img = ToningProcessor(settings.toning).process(current_img, context) if not context.crop_preview_full: diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index f67950e9..a6003cd0 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -39,7 +39,7 @@ get_manual_rect_coords, map_coords_to_geometry, ) -from negpy.features.local.logic import compute_local_factor_map +from negpy.features.local.logic import compute_local_ev_map from negpy.features.process.models import ProcessMode from negpy.infrastructure.gpu.device import GPUDevice from negpy.infrastructure.gpu.resources import GPUBuffer, GPUTexture @@ -186,7 +186,6 @@ def __init__(self) -> None: "clahe_apply": get_resource_path(os.path.join("negpy", "features", "lab", "shaders", "clahe_apply.wgsl")), "retouch": get_resource_path(os.path.join("negpy", "features", "retouch", "shaders", "retouch.wgsl")), "lab": get_resource_path(os.path.join("negpy", "features", "lab", "shaders", "lab.wgsl")), - "local": get_resource_path(os.path.join("negpy", "features", "local", "shaders", "local.wgsl")), "toning": get_resource_path(os.path.join("negpy", "features", "toning", "shaders", "toning.wgsl")), "finish": get_resource_path(os.path.join("negpy", "features", "finish", "shaders", "finish.wgsl")), "metrics": get_resource_path(os.path.join("negpy", "features", "lab", "shaders", "metrics.wgsl")), @@ -238,13 +237,13 @@ def __init__(self) -> None: def _detect_invalidated_stage(self, settings: WorkspaceConfig, scale_factor: float) -> int: """ Determines the earliest pipeline stage that needs re-running. - Returns stage index: + Returns stage index (5 is unused — dodge/burn folded into the + exposure pass, keeping the downstream indices stable): 0: Geometry (Source/Transform) - 1: Exposure (Normalization/Grading) + 1: Exposure (Normalization/Grading/Dodge & Burn) 2: CLAHE (Adaptive Hist) 3: Retouch (Healing) 4: Lab (Color/Sharpen) - 5: Local (Dodge/Burn) 6: Toning (Paper/Split) 7: Finish (Vignette) 8: Layout (Final compositing) @@ -264,14 +263,15 @@ def _detect_invalidated_stage(self, settings: WorkspaceConfig, scale_factor: flo return 0 if last.process != settings.process or last.exposure != settings.exposure: return 1 + # Dodge/burn is part of the print exposure now. + if last.local != settings.local: + return 1 if last.lab.clahe_strength != settings.lab.clahe_strength: return 2 if last.retouch != settings.retouch: return 3 if last.lab != settings.lab: return 4 - if last.local != settings.local: - return 5 if last.toning != settings.toning: return 6 if last.finish != settings.finish: @@ -350,7 +350,7 @@ def _get_uniform_binding(self, name: str) -> Dict[str, Any]: sizes = { "geometry": 32, "normalization": 112, - "exposure": 240, + "exposure": 256, "clahe_u": 32, "retouch_u": 64, "lab": 96, @@ -384,13 +384,13 @@ def process_to_texture( readback_metrics: bool = True, ir_buffer: Optional[np.ndarray] = None, vignette_full_crop: Optional[Tuple[int, int, int, int]] = None, - local_factor: Optional[np.ndarray] = None, + local_ev: Optional[np.ndarray] = None, analysis_source_hash: Optional[str] = None, ) -> Tuple[Any, Dict[str, Any]]: """ Executes the full pipeline, returning a GPU texture and associated metrics. - ``local_factor`` is a pre-rasterised dodge/burn factor map already in the + ``local_ev`` is a pre-rasterised dodge/burn EV map already in the post-geometry frame; tiled export passes a per-tile slice. When None and masks are present, it is computed here from ``settings.local``. """ @@ -656,18 +656,22 @@ def _analyze_bounds() -> LogNegativeBounds: wgpu.TextureUsage.STORAGE_BINDING | wgpu.TextureUsage.TEXTURE_BINDING, "lab", ) - tex_local = self._get_intermediate_texture( - w_rot, - h_rot, - wgpu.TextureUsage.STORAGE_BINDING | wgpu.TextureUsage.TEXTURE_BINDING, - "local", - ) - tex_local_factor = self._get_intermediate_texture( - w_rot, - h_rot, - wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST, - "local_factor", - ) + # Dodge/burn EV map feeds the exposure pass; a zero-initialized 1x1 dummy + # keeps the bind group valid when no masks are active (ev_scale.w gates it). + if settings.local.masks: + tex_local_ev = self._get_intermediate_texture( + w_rot, + h_rot, + wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST, + "local_ev", + ) + else: + tex_local_ev = self._get_intermediate_texture( + 1, + 1, + wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST, + "local_ev", + ) tex_toning = self._get_intermediate_texture( crop_w, crop_h, @@ -702,6 +706,20 @@ def _analyze_bounds() -> LogNegativeBounds: w_rot, h_rot, ) + if settings.local.masks: + if local_ev is None: + local_ev = compute_local_ev_map( + settings.local, + h_rot, + w_rot, + orig_shape, + rotation=settings.geometry.rotation, + fine_rotation=settings.geometry.fine_rotation, + flip_horizontal=settings.geometry.flip_horizontal, + flip_vertical=settings.geometry.flip_vertical, + distortion_k1=k1_eff, + ) + tex_local_ev.upload(np.stack([local_ev] * 3, axis=-1)) self._dispatch_pass( enc, "exposure", @@ -709,6 +727,7 @@ def _analyze_bounds() -> LogNegativeBounds: (0, tex_norm.view), (1, tex_expo.view), (2, self._get_uniform_binding("exposure")), + (3, tex_local_ev.view), ], w_rot, h_rot, @@ -792,36 +811,7 @@ def _analyze_bounds() -> LogNegativeBounds: h_rot, ) - # --- Local (Dodge/Burn) --- runs at full pre-crop resolution, before toning. - if settings.local.masks: - if start_stage <= 5: - if local_factor is None: - local_factor = compute_local_factor_map( - settings.local, - h_rot, - w_rot, - orig_shape, - rotation=settings.geometry.rotation, - fine_rotation=settings.geometry.fine_rotation, - flip_horizontal=settings.geometry.flip_horizontal, - flip_vertical=settings.geometry.flip_vertical, - distortion_k1=k1_eff, - ) - tex_local_factor.upload(np.stack([local_factor] * 3, axis=-1)) - self._dispatch_pass( - enc, - "local", - [ - (0, tex_lab.view), - (1, tex_local.view), - (2, tex_local_factor.view), - ], - w_rot, - h_rot, - ) - tex_pre_toning = tex_local - else: - tex_pre_toning = tex_lab + tex_pre_toning = tex_lab if start_stage <= 6: self._dispatch_pass( @@ -1028,6 +1018,7 @@ def _upload_unified_uniforms( effective_cast_strength, filtration_offsets, grade_coupled_shape, + local_ev_scale, normalize_refs, paper_dmin_rgb, per_channel_curve_params, @@ -1132,6 +1123,8 @@ def _upload_unified_uniforms( + struct.pack("ffff", dye_rows[0, 0], dye_rows[0, 1], dye_rows[0, 2], 0.0) + struct.pack("ffff", dye_rows[1, 0], dye_rows[1, 1], dye_rows[1, 2], 0.0) + struct.pack("ffff", dye_rows[2, 0], dye_rows[2, 1], dye_rows[2, 2], 0.0) + # Dodge/burn: per-channel EV-stop size (mirrors the CPU ev_scale), w = enable. + + struct.pack("ffff", *local_ev_scale(LogNegativeBounds(adj_floors, adj_ceils)), 1.0 if settings.local.masks else 0.0) ) cls = float(settings.lab.clahe_strength) @@ -1571,15 +1564,15 @@ def _process_tiled( logger.warning(f"IR pre-transform failed for tiled export; skipping IR dust removal: {e}") ir_rot = None - # Rasterise the dodge/burn factor map once at full post-geometry resolution; + # Rasterise the dodge/burn EV map once at full post-geometry resolution; # tiles slice it directly (same pattern as IR above). # ponytail: mask vertices are distortion-mapped (centres land right), but the # feathered falloff isn't re-warped — negligible unless a mask sits at the frame # edge under strong k1. Rasterise on a warped grid if that combo ever matters. - local_factor_rot: Optional[np.ndarray] = None + local_ev_rot: Optional[np.ndarray] = None if settings.local.masks: h_rot_full, w_rot_full = img_rot.shape[:2] - local_factor_rot = compute_local_factor_map( + local_ev_rot = compute_local_ev_map( settings.local, h_rot_full, w_rot_full, @@ -1710,7 +1703,7 @@ def _analyze_global_bounds() -> LogNegativeBounds: min(h_rot, y1 + ty + th + TILE_HALO), ) ir_tile = np.ascontiguousarray(ir_rot[iy1:iy2, ix1:ix2]) if ir_rot is not None else None - factor_tile = np.ascontiguousarray(local_factor_rot[iy1:iy2, ix1:ix2]) if local_factor_rot is not None else None + ev_tile = np.ascontiguousarray(local_ev_rot[iy1:iy2, ix1:ix2]) if local_ev_rot is not None else None ox, oy = x1 + tx - ix1, y1 + ty - iy1 tile_res, _ = self.process_to_texture( img_rot[iy1:iy2, ix1:ix2], @@ -1728,7 +1721,7 @@ def _analyze_global_bounds() -> LogNegativeBounds: apply_layout=False, ir_buffer=ir_tile, vignette_full_crop=(crop_w, crop_h, tx - ox, ty - oy), - local_factor=factor_tile, + local_ev=ev_tile, ) full_source_res[ty : ty + th, tx : tx + tw] = self._readback_downsampled(tile_res)[oy : oy + th, ox : ox + tw] diff --git a/tests/test_exposure_logic.py b/tests/test_exposure_logic.py index d0351357..18d771d6 100644 --- a/tests/test_exposure_logic.py +++ b/tests/test_exposure_logic.py @@ -183,5 +183,107 @@ def test_token_differs_by_mode(self): self.assertNotEqual(on, off) +class TestExposureDomainDodgeBurn(unittest.TestCase): + """Dodge/burn as per-pixel print-exposure offsets fed through the H&D curve: + burns roll into paper black via the toe, dodges lift toward paper white via + the shoulder — never a hard clip.""" + + PARAMS = (0.35, 4.0) # (pivot, slope) + + def _render(self, ev: float) -> np.ndarray: + from negpy.features.exposure.logic import local_ev_scale + + img = np.full((8, 8, 3), 0.5, dtype=np.float32) + ev_map = np.full((8, 8), ev, dtype=np.float32) + return apply_characteristic_curve( + img, + self.PARAMS, + self.PARAMS, + self.PARAMS, + ev_map=ev_map, + ev_scale=local_ev_scale(None), + ) + + def test_local_ev_scale(self): + """One stop = -log10(2) in normalized space, divided by the channel's stretch range.""" + from negpy.features.exposure.logic import local_ev_scale + from negpy.features.exposure.normalization import LogNegativeBounds + + s = local_ev_scale(None) + self.assertAlmostEqual(s[0], -np.log10(2.0), places=6) + self.assertEqual(s[0], s[1]) + self.assertEqual(s[1], s[2]) + + b = LogNegativeBounds(floors=(-2.0, -1.5, -1.0), ceils=(-0.1, -0.1, -0.1)) + s = local_ev_scale(b) + self.assertAlmostEqual(s[0], -np.log10(2.0) / 1.9, places=6) + self.assertAlmostEqual(s[1], -np.log10(2.0) / 1.4, places=6) + self.assertAlmostEqual(s[2], -np.log10(2.0) / 0.9, places=6) + + def test_no_map_is_baseline(self): + """ev_map=None must be byte-exact with the plain call.""" + img = np.linspace(0.0, 1.0, 8 * 8 * 3, dtype=np.float32).reshape(8, 8, 3) + base = apply_characteristic_curve(img, self.PARAMS, self.PARAMS, self.PARAMS) + with_none = apply_characteristic_curve(img, self.PARAMS, self.PARAMS, self.PARAMS, ev_map=None) + np.testing.assert_array_equal(base, with_none) + + def test_zero_map_is_baseline(self): + from negpy.features.exposure.logic import local_ev_scale + + img = np.full((8, 8, 3), 0.5, dtype=np.float32) + base = apply_characteristic_curve(img, self.PARAMS, self.PARAMS, self.PARAMS) + zero = apply_characteristic_curve( + img, + self.PARAMS, + self.PARAMS, + self.PARAMS, + ev_map=np.zeros((8, 8), dtype=np.float32), + ev_scale=local_ev_scale(None), + ) + np.testing.assert_allclose(zero, base, atol=1e-6) + + def test_positive_ev_dodges_brighter(self): + self.assertGreater(float(np.mean(self._render(1.0))), float(np.mean(self._render(0.0)))) + + def test_burn_rolls_into_toe_no_clip(self): + """Deeper burns approach paper black asymptotically: density stays below + d_max, transmittance stays above zero, and increments shrink.""" + d = {ev: float(-np.log10(np.mean(self._render(ev)))) for ev in (-2.0, -4.0, -6.0)} + d_max = EXPOSURE_CONSTANTS["d_max"] + self.assertLess(d[-2.0], d[-4.0]) + self.assertLess(d[-4.0], d[-6.0]) + self.assertLess(d[-6.0], d_max + 1e-3) + self.assertGreater(float(self._render(-6.0).min()), 0.0) + self.assertGreater(d[-4.0] - d[-2.0], d[-6.0] - d[-4.0]) + + def test_dodge_rolls_into_shoulder_no_clip(self): + """Deeper dodges approach paper white (10^-d_min) asymptotically: + transmittance never overshoots it and increments shrink. Uses the real + paper d_min so the asymptote is measurable in float32.""" + from negpy.features.exposure.logic import local_ev_scale + + d_min = float(EXPOSURE_CONSTANTS["d_min"]) + img = np.full((8, 8, 3), 0.5, dtype=np.float32) + + def render(ev: float) -> float: + ev_map = np.full((8, 8), ev, dtype=np.float32) + res = apply_characteristic_curve( + img, + self.PARAMS, + self.PARAMS, + self.PARAMS, + d_min=d_min, + ev_map=ev_map, + ev_scale=local_ev_scale(None), + ) + return float(np.mean(res)) + + t = {ev: render(ev) for ev in (1.0, 2.0, 3.0)} + self.assertLess(t[1.0], t[2.0]) + self.assertLess(t[2.0], t[3.0]) + self.assertLessEqual(t[3.0], 10.0**-d_min + 1e-4) + self.assertGreater(t[2.0] - t[1.0], t[3.0] - t[2.0]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_local_logic.py b/tests/test_local_logic.py index acc4f89e..8b447705 100644 --- a/tests/test_local_logic.py +++ b/tests/test_local_logic.py @@ -3,7 +3,7 @@ import numpy as np from negpy.domain.models import WorkspaceConfig -from negpy.features.local.logic import apply_local_adjustments, compute_local_factor_map +from negpy.features.local.logic import compute_local_ev_map from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask @@ -16,73 +16,42 @@ def _center_square_mask(strength: float, feather: float = 0.0) -> PolygonMask: ) -class TestApplyLocalAdjustments(unittest.TestCase): - def setUp(self) -> None: - self.img = np.full((100, 100, 3), 0.5, dtype=np.float32) - self.shape = (100, 100) +class TestComputeEvMap(unittest.TestCase): + """The EV map is the shared CPU/GPU primitive — per-pixel print-exposure stops.""" - def test_noop_when_no_masks(self) -> None: - """Empty config returns the input image.""" - res = apply_local_adjustments(self.img, LocalAdjustmentsConfig(), self.shape) - np.testing.assert_array_equal(res, self.img) + def test_all_zeros_when_empty(self) -> None: + ev = compute_local_ev_map(LocalAdjustmentsConfig(), 100, 100, (100, 100)) + np.testing.assert_array_equal(ev, np.zeros((100, 100), dtype=np.float32)) - def test_dodge_brightens_inside_corners_unchanged(self) -> None: - """Positive strength brightens the masked region; outside stays put.""" + def test_interior_equals_strength(self) -> None: cfg = LocalAdjustmentsConfig(masks=(_center_square_mask(1.0),)) - res = apply_local_adjustments(self.img, cfg, self.shape) - self.assertGreater(float(res[50, 50, 0]), 0.5) - self.assertAlmostEqual(float(res[5, 5, 0]), 0.5, places=5) + ev = compute_local_ev_map(cfg, 100, 100, (100, 100)) + self.assertAlmostEqual(float(ev[50, 50]), 1.0, places=5) + self.assertAlmostEqual(float(ev[5, 5]), 0.0, places=5) - def test_burn_darkens_inside(self) -> None: - """Negative strength darkens the masked region (2^-1 -> 0.25).""" - cfg = LocalAdjustmentsConfig(masks=(_center_square_mask(-1.0),)) - res = apply_local_adjustments(self.img, cfg, self.shape) - self.assertAlmostEqual(float(res[50, 50, 0]), 0.25, places=5) - self.assertAlmostEqual(float(res[5, 5, 0]), 0.5, places=5) + def test_burn_is_negative(self) -> None: + cfg = LocalAdjustmentsConfig(masks=(_center_square_mask(-1.5),)) + ev = compute_local_ev_map(cfg, 100, 100, (100, 100)) + self.assertAlmostEqual(float(ev[50, 50]), -1.5, places=5) + + def test_overlapping_masks_are_additive(self) -> None: + cfg = LocalAdjustmentsConfig(masks=(_center_square_mask(0.5), _center_square_mask(0.75))) + ev = compute_local_ev_map(cfg, 100, 100, (100, 100)) + self.assertAlmostEqual(float(ev[50, 50]), 1.25, places=5) def test_degenerate_mask_skipped(self) -> None: """A mask with fewer than 3 vertices is ignored.""" cfg = LocalAdjustmentsConfig(masks=(PolygonMask(vertices=((0.4, 0.4), (0.6, 0.6)), strength=1.0),)) - res = apply_local_adjustments(self.img, cfg, self.shape) - np.testing.assert_array_equal(res, self.img) - - def test_output_clipped_to_unit_range(self) -> None: - """Large strength is clipped into [0, 1].""" - cfg = LocalAdjustmentsConfig(masks=(_center_square_mask(5.0),)) - res = apply_local_adjustments(self.img, cfg, self.shape) - self.assertGreaterEqual(float(res.min()), 0.0) - self.assertLessEqual(float(res.max()), 1.0) - - def test_feathered_mask_runs(self) -> None: - """Default feather path produces a valid in-range result.""" - cfg = LocalAdjustmentsConfig(masks=(_center_square_mask(1.0, feather=0.05),)) - res = apply_local_adjustments(self.img, cfg, self.shape) - self.assertEqual(res.shape, self.img.shape) - self.assertGreaterEqual(float(res.min()), 0.0) - self.assertLessEqual(float(res.max()), 1.0) - - -class TestComputeFactorMap(unittest.TestCase): - """The factor map is the shared CPU/GPU primitive — multiply the image by it.""" + ev = compute_local_ev_map(cfg, 100, 100, (100, 100)) + np.testing.assert_array_equal(ev, np.zeros((100, 100), dtype=np.float32)) - def test_all_ones_when_empty(self) -> None: - factor = compute_local_factor_map(LocalAdjustmentsConfig(), 100, 100, (100, 100)) - np.testing.assert_array_equal(factor, np.ones((100, 100), dtype=np.float32)) - - def test_interior_equals_two_pow_strength(self) -> None: - cfg = LocalAdjustmentsConfig(masks=(_center_square_mask(1.0),)) - factor = compute_local_factor_map(cfg, 100, 100, (100, 100)) - self.assertAlmostEqual(float(factor[50, 50]), 2.0, places=5) - self.assertAlmostEqual(float(factor[5, 5]), 1.0, places=5) - - def test_matches_apply_local_adjustments(self) -> None: - """apply_local_adjustments must equal clip(img * factor_map).""" - img = np.full((100, 100, 3), 0.5, dtype=np.float32) - cfg = LocalAdjustmentsConfig(masks=(_center_square_mask(-1.0, feather=0.04),)) - factor = compute_local_factor_map(cfg, 100, 100, (100, 100)) - expected = np.clip(img * factor[..., np.newaxis], 0.0, 1.0) - got = apply_local_adjustments(img, cfg, (100, 100)) - np.testing.assert_allclose(got, expected, rtol=0, atol=0) + def test_feathered_mask_stays_within_strength(self) -> None: + cfg = LocalAdjustmentsConfig(masks=(_center_square_mask(1.0, feather=0.05),)) + ev = compute_local_ev_map(cfg, 100, 100, (100, 100)) + self.assertEqual(ev.shape, (100, 100)) + self.assertGreaterEqual(float(ev.min()), 0.0) + self.assertLessEqual(float(ev.max()), 1.0 + 1e-5) + self.assertGreater(float(ev[50, 50]), 0.9) class TestLocalSerialization(unittest.TestCase): From 02bc0744e154773e66d51765bf0377acf6915347 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 5 Jul 2026 09:07:46 +0200 Subject: [PATCH 3/7] feat: density-driven selenium and sepia toning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chemical toners act on the print's silver density instead of luma-masked RGB tints: selenium converts the densest silver first (Dmax boost, cool eggplant shadows), sepia bleach-redevelops the thinnest first (warm highlights, shadows hold, split-sepia at partial strength). Paper white stays white. Runs on the linear buffer — no OETF bracket; CPU and WGSL share TONING_CONSTANTS. GPU chemical toning now gated to B&W like the CPU. Existing toned edits render differently. --- CLAUDE.md | 2 +- docs/CHANGELOG.md | 1 + negpy/desktop/view/sidebar/controls_panel.py | 4 +- negpy/desktop/view/sidebar/toning.py | 8 +- negpy/desktop/view/widgets/tutorial_steps.py | 7 +- negpy/features/toning/logic.py | 104 ++++++++++++++----- negpy/features/toning/processor.py | 9 +- negpy/features/toning/shaders/toning.wgsl | 36 +++---- tests/test_pipeline_parity.py | 22 +++- tests/test_toning_logic.py | 104 ++++++++++++++----- 10 files changed, 212 insertions(+), 85 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6cd0dd14..cc5aeb57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Every feature under `negpy/features//` follows this structure: **Flat-field profile (rig-level) + lens distortion.** Flat-field is a per-rig **profile** (DB `flatfield_profiles`: name, path, **k1**), not a per-image edit. `FlatFieldConfig` (`apply`, `reference_path`, `k1`) mirrors the active profile into the recipe but the profile is canonical — `reference_path` and `k1` are **re-seeded from the active profile on every file load** (`session.py`), so a roll shares them. The *photometric* correction (illumination falloff) is baked at **source-load** (`apply_flatfield`, before the pipeline → before any warp, so the flat stays registered to the raw sensor grid). The *geometric* correction is a single radial coefficient **k1** (poly3 / Brown-Conrady, half-diagonal–normalized so it's rotation/aspect invariant) folded into the **geometry transform** (`apply_radial_distortion` / `transform.wgsl`), gated by the same `apply` toggle (`k1_eff = k1 if apply else 0`). Being a geometry op, k1 invalidates the **base stage** (CPU `base_key`; GPU `_detect_invalidated_stage` checks `flatfield.apply`/`k1`) — **not** `source_hash`/`flatfield_token` (that would force a RAW re-decode per slider step). Scale-to-fill is baked into the same remap (`compute_distortion_scale`, no empty borders). The correction must stay consistent across the image warp **and** the coordinate mappers (`create_uv_grid` forward, `map_coords_to_geometry` inverse via `map_point_radial`) so crop/autocrop/retouch/dodge-burn placements stay aligned; CPU stages read the effective k1 from `context.metrics["distortion_k1"]`, GPU passes `k1_eff` directly. Tiled export applies it on the CPU `img_rot`/IR path (the shader geometry uniform is zeroed there). -**Scene-linear working space.** The pipeline is **scene-linear internally**: the exposure stage (`exposure/logic.py`) emits linear reflectance (`10^-D`, no OETF), and the creative stages **Lab, Toning, Finish operate on linear light** (dodge/burn happens inside the exposure stage, pre-curve). The working-space OETF — the **ProPhoto RGB (ROMM) TRC** (gamma `1.8` with a linear toe below `1/512`), `working_oetf_encode`/`working_oetf_decode` in `kernel/image/logic.py`, mirrored in WGSL `oetf_encode`/`oetf_decode` — is applied **only as the final engine step** (CPU: end of `process()`; GPU: the `output_encode` pass in `gpu_engine.process_to_texture`), so the encoded buffer composes correctly with the ProPhoto ICC at the display/export boundary (`WORKING_COLOR_SPACE`). **Retouch runs in the linear island** like the other creative stages: dust *detection* is perceptual, so on the CPU it computes the detection luma on a `working_oetf_encode`d copy while *healing* in linear (`retouch/logic.py`) — the engine no longer brackets it. The GPU keeps a single encoded perceptual region (exposure encodes → clahe/retouch operate encoded → lab's `load_lin` decodes back to linear), equivalent within parity tolerance. Lab/Toning compute CIELAB directly from linear (`rgb_to_lab_working`/`lab_to_rgb_working` use the **ProPhoto ROMM primaries at D50**, no transfer decode); CIELAB ops are encoding-invariant, while the light ops (glow/halation highlight mask, chemical-toning luma masks) compute their masks on a `working_oetf_encode`d copy so display-domain thresholds stay valid. The flat master keeps its own log encoding and is never OETF-encoded. When changing the working **TRC**, update all seven sites together: the CPU `working_oetf_*` helpers, the WGSL `oetf_*` in `exposure/output_encode/lab/toning/metrics` shaders, and the chart (`charts.py`); changing the **primaries/white point** additionally means the CPU `_PROPHOTO_TO_XYZ`/`_XYZ_TO_PROPHOTO`/`_D50_WHITE` and the inline matrices + white in `lab.wgsl`/`toning.wgsl`. The greyscale export path re-encodes luma from the working TRC to gamma 2.2 to match its `GrayGamma2.2` tag (`_apply_color_management_u16_greyscale`). +**Scene-linear working space.** The pipeline is **scene-linear internally**: the exposure stage (`exposure/logic.py`) emits linear reflectance (`10^-D`, no OETF), and the creative stages **Lab, Toning, Finish operate on linear light** (dodge/burn happens inside the exposure stage, pre-curve). The working-space OETF — the **ProPhoto RGB (ROMM) TRC** (gamma `1.8` with a linear toe below `1/512`), `working_oetf_encode`/`working_oetf_decode` in `kernel/image/logic.py`, mirrored in WGSL `oetf_encode`/`oetf_decode` — is applied **only as the final engine step** (CPU: end of `process()`; GPU: the `output_encode` pass in `gpu_engine.process_to_texture`), so the encoded buffer composes correctly with the ProPhoto ICC at the display/export boundary (`WORKING_COLOR_SPACE`). **Retouch runs in the linear island** like the other creative stages: dust *detection* is perceptual, so on the CPU it computes the detection luma on a `working_oetf_encode`d copy while *healing* in linear (`retouch/logic.py`) — the engine no longer brackets it. The GPU keeps a single encoded perceptual region (exposure encodes → clahe/retouch operate encoded → lab's `load_lin` decodes back to linear), equivalent within parity tolerance. Lab/Toning compute CIELAB directly from linear (`rgb_to_lab_working`/`lab_to_rgb_working` use the **ProPhoto ROMM primaries at D50**, no transfer decode); CIELAB ops are encoding-invariant; the glow highlight mask computes on a `working_oetf_encode`d copy so its display-domain threshold stays valid, and chemical toning (selenium/sepia) is **density-driven** — it reads print density `D = −log10(t)` straight off the linear buffer (`toning/logic.py` `TONING_CONSTANTS`), no OETF bracket (only the B&W black point keeps one). The flat master keeps its own log encoding and is never OETF-encoded. When changing the working **TRC**, update all six sites together: the CPU `working_oetf_*` helpers, the WGSL `oetf_*` in `exposure/output_encode/lab/metrics` shaders, and the chart (`charts.py`); changing the **primaries/white point** additionally means the CPU `_PROPHOTO_TO_XYZ`/`_XYZ_TO_PROPHOTO`/`_D50_WHITE` and the inline matrices + white in `lab.wgsl`/`toning.wgsl`. The greyscale export path re-encodes luma from the working TRC to gamma 2.2 to match its `GrayGamma2.2` tag (`_apply_color_management_u16_greyscale`). **Flat ("for editing elsewhere") render intent.** When `settings.exposure.render_intent == RenderIntent.FLAT` (`negpy/features/exposure/models.py`), the Print stage uses `PhotometricProcessor._process_flat`, which calls `apply_flat_curve` (`negpy/features/exposure/logic.py`) — a true **log-video master**: the normalized log signal (`val`) is emitted **directly** as the code value, positive-oriented, `code = clip(lift + gain·(1 − val), 0, 1)` (Cineon-like `flat_log_gain`/`flat_log_lift`; `flat_curve_params` returns gain+lift). Crucially it does **not** apply `10^-D` or the working-space OETF — `10^-D` is the log→linear *decode* that would turn the signal back into a normal-contrast positive (that decode is why earlier "flat" attempts looked like ordinary photos, not log). The result is flat/milky and fully invertible for editing. It bypasses the asymmetric print kernel (`_apply_print_curve_kernel`) entirely. Ignores auto density/grade, cast removal, toe/shoulder, surround/flare, dodge/burn masks (WB rides as a per-channel log shift). The engine **bypasses retouch/lab/toning/finish** (crop still runs). This is the digital-intermediate master path. `ImageProcessor` forces the **CPU engine** for flat renders (no WGSL flat shader — guarantees numerical exactness). The desktop exposes it as a hybrid output intent: `AppState.flat_output`/`flat_format` (persisted) drive export, `flat_peek` drives a preview-only peek; `flat_master_config()` / `flat_export_config()` in `negpy/domain/models.py` derive the flat config (full-res by default when Flat is enabled; Print/Pixels sizing honoured when explicitly selected; 16-bit TIFF or linear DNG; export colour space follows the user's selection). DNG export writes an uncompressed 16-bit LinearRaw DNG with `tifffile` via `ImageProcessor._encode_dng_bytes` → `write_dng_linear` (`negpy/services/scanning/writer.py`); `NewSubfileType=0` + the DNG version tags are required for LibRaw/rawpy to accept it. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7bfc160c..54739499 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ ## 0.34.0 - Change: **Dodge & Burn is real print exposure** — masks now change the exposure ahead of the paper curve instead of brightening/darkening the finished image. Burns roll into paper black through the toe and dodges lift toward paper white through the shoulder, like holding back light under the enlarger — no more flat clipped patches under strong masks. Existing dodge/burn edits will render slightly differently. The Dodge & Burn section moved to the Exposure tab to match. +- Change: **Selenium and Sepia tone the print's silver, not its brightness** — both toners now work on density like the real baths. Selenium converts the densest silver first: visibly deeper blacks (the reason printers selenium-tone) and a cool eggplant cast that stays in the shadows. Sepia bleach-redevelops the thinnest silver first: highlights warm up while shadows hold, and partial strength gives the classic split-sepia look. Paper white stays white. Existing toned edits will render differently. ## 0.33.1 diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index 0e7616bc..01d1ade4 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -495,13 +495,13 @@ def apply_shortcut_tooltips(self) -> None: ton.selenium_slider.setToolTip( tooltip_with_shortcut( - "Simulates selenium toning — adds a cool blue-purple cast to shadows. B&W mode only", + "Simulates selenium toning — converts the densest silver first: deeper blacks, cool eggplant shadows. B&W mode only", ["selenium_inc", "selenium_dec"], ) ) ton.sepia_slider.setToolTip( tooltip_with_shortcut( - "Simulates sepia toning — adds a warm brown cast across the full tonal range. B&W mode only", + "Simulates sepia bleach-redevelop toning — warms the highlights first while shadows hold. B&W mode only", ["sepia_inc", "sepia_dec"], ) ) diff --git a/negpy/desktop/view/sidebar/toning.py b/negpy/desktop/view/sidebar/toning.py index 08936d1d..b56a9ec4 100644 --- a/negpy/desktop/view/sidebar/toning.py +++ b/negpy/desktop/view/sidebar/toning.py @@ -18,9 +18,13 @@ def _init_ui(self) -> None: self.layout.addWidget(section_subheader("TONERS")) self.selenium_slider = CompactSlider("Selenium", 0.0, 2.0, conf.selenium_strength, color="#444466") - self.selenium_slider.setToolTip("Simulates selenium toning — adds cool blue-purple cast to shadows (B&W only)") + self.selenium_slider.setToolTip( + "Simulates selenium toning — converts the densest silver first: deeper blacks, cool eggplant shadows (B&W only)" + ) self.sepia_slider = CompactSlider("Sepia", 0.0, 2.0, conf.sepia_strength, color="#664422") - self.sepia_slider.setToolTip("Simulates sepia toning — adds warm brown cast across the tonal range (B&W only)") + self.sepia_slider.setToolTip( + "Simulates sepia bleach-redevelop toning — warms the highlights first, shadows hold; partial strength gives the classic split-sepia look (B&W only)" + ) self.layout.addWidget(self.selenium_slider) self.layout.addWidget(self.sepia_slider) diff --git a/negpy/desktop/view/widgets/tutorial_steps.py b/negpy/desktop/view/widgets/tutorial_steps.py index f485502e..685870ef 100644 --- a/negpy/desktop/view/widgets/tutorial_steps.py +++ b/negpy/desktop/view/widgets/tutorial_steps.py @@ -312,8 +312,11 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]: "Split Toning (all modes) pushes shadows and highlights toward independent " "hue angles with their own strength. It works in Lab space, so luminance — and " "therefore grain and detail — is preserved exactly.

" - "Selenium and Sepia simulate classic chemical toners (B&W mode only): " - "selenium cools the shadows, sepia warms the midtones." + "Selenium and Sepia simulate classic chemical toners on the print's " + "silver density (B&W mode only): selenium converts the densest silver first — " + "deeper blacks and cool eggplant shadows; sepia bleach-redevelops the thinnest " + "silver first — warm highlights that hold the shadows (partial strength gives the " + "classic split-sepia look)." ), target=_toning, section_attr="toning_section", diff --git a/negpy/features/toning/logic.py b/negpy/features/toning/logic.py index 5428109c..a84c4c21 100644 --- a/negpy/features/toning/logic.py +++ b/negpy/features/toning/logic.py @@ -1,45 +1,88 @@ +from typing import Any, Dict + import numpy as np from numba import njit # type: ignore -from negpy.domain.types import LUMA_B, LUMA_G, LUMA_R, ImageBuffer +from negpy.domain.types import ImageBuffer from negpy.kernel.image.logic import lab_to_rgb_working, rgb_to_lab_working from negpy.kernel.image.validation import ensure_image +TONING_CONSTANTS: Dict[str, Any] = { + # ── Selenium (silver -> silver selenide, densest silver first) ─────────── + # Density at which selenium conversion saturates (c = strength·(D/this)^power). + # ↑ conversion spreads to lighter tones more slowly; ↓ shadows convert sooner. + "sel_d_ref": 2.0, + # Exponent shaping the density-proportional conversion. + # ↑ conversion concentrates deeper in the shadows; ↓ creeps into midtones. + "sel_power": 1.5, + # Per-channel density multipliers of converted silver: all ≥1 deepens blacks + # (the Dmax boost selenium is used for); green highest -> eggplant shadow hue. + "sel_gain": (1.04, 1.10, 1.02), + # ── Sepia (bleach–redevelop to sulfide, thinnest silver first) ──────────── + # Density above which bleach no longer reaches (c = strength·(1 − D/this)^power). + # ↑ toning creeps into deeper shadows; ↓ holds toning to the highlights. + "sep_d_bleach": 1.8, + # Exponent shaping the highlight-first conversion falloff. + # ↑ tighter split-sepia (highlights only); ↓ more even toning. + "sep_power": 2.0, + # Per-channel density multipliers of converted silver: red < 1 (sulfide's + # lower covering power lifts/warms), blue > 1 -> yellow-brown hue. + "sep_gain": (0.82, 0.94, 1.12), +} + @njit(cache=True, fastmath=True) -def _apply_chemical_toning_jit(img: np.ndarray, sel_strength: float, sep_strength: float) -> np.ndarray: +def _apply_chemical_toning_jit( + img: np.ndarray, + sel_strength: float, + sep_strength: float, + sel_d_ref: float, + sel_power: float, + sel_gain: np.ndarray, + sep_d_bleach: float, + sep_power: float, + sep_gain: np.ndarray, +) -> np.ndarray: """ - Selenium (Shadows) & Sepia (Mids) toning. + Density-driven chemical toning on linear reflectance. Silver density + D = -log10(t); a density-dependent fraction c of it converts to the toner's + dye, whose per-channel covering power reshapes D: D_ch = D·(1−c) + c·D·gain. + Selenium converts the densest silver first, sepia the thinnest. """ h, w, c = img.shape res = np.empty_like(img) - sel_color = np.array([0.85, 0.75, 0.85], dtype=np.float32) - sep_color = np.array([1.1, 0.99, 0.825], dtype=np.float32) + eps = 1e-6 for y in range(h): for x in range(w): - # Fused Luminance (Rec. 709) - lum_val = LUMA_R * img[y, x, 0] + LUMA_G * img[y, x, 1] + LUMA_B * img[y, x, 2] - - sel_m = 0.0 - if sel_strength > 0: - m = 1.0 - lum_val - if m < 0.0: - m = 0.0 - sel_m = m * m * sel_strength - - sep_m = 0.0 - if sep_strength > 0: - dist = lum_val - 0.6 - sep_m = np.exp(-(dist * dist) / 0.08) * sep_strength - for ch in range(3): - pixel = img[y, x, ch] - if sel_m > 0: - pixel = pixel * (1.0 - sel_m) + (pixel * sel_color[ch]) * sel_m - if sep_m > 0: - pixel = pixel * (1.0 - sep_m) + (pixel * sep_color[ch]) * sep_m - + t = img[y, x, ch] + if t < eps: + t = eps + elif t > 1.0: + t = 1.0 + d = -np.log10(t) + + if sel_strength > 0.0: + frac = d / sel_d_ref + if frac > 1.0: + frac = 1.0 + # Conversion caps at 1: all the silver is toned (slider > 1 = longer bath). + c_sel = sel_strength * frac**sel_power + if c_sel > 1.0: + c_sel = 1.0 + d = d * (1.0 - c_sel) + c_sel * d * sel_gain[ch] + + if sep_strength > 0.0: + frac = d / sep_d_bleach + if frac > 1.0: + frac = 1.0 + c_sep = sep_strength * (1.0 - frac) ** sep_power + if c_sep > 1.0: + c_sep = 1.0 + d = d * (1.0 - c_sep) + c_sep * d * sep_gain[ch] + + pixel = 10.0**-d if pixel < 0.0: pixel = 0.0 elif pixel > 1.0: @@ -86,15 +129,22 @@ def apply_chemical_toning( sepia_strength: float = 0.0, ) -> ImageBuffer: """ - Applies split-toning based on luminance. + Selenium / sepia toning of a linear-reflectance print (density domain). """ if selenium_strength == 0 and sepia_strength == 0: return img + c = TONING_CONSTANTS return ensure_image( _apply_chemical_toning_jit( np.ascontiguousarray(img.astype(np.float32)), float(selenium_strength), float(sepia_strength), + float(c["sel_d_ref"]), + float(c["sel_power"]), + np.array(c["sel_gain"], dtype=np.float32), + float(c["sep_d_bleach"]), + float(c["sep_power"]), + np.array(c["sep_gain"], dtype=np.float32), ) ) diff --git a/negpy/features/toning/processor.py b/negpy/features/toning/processor.py index c85ede12..8caf0610 100644 --- a/negpy/features/toning/processor.py +++ b/negpy/features/toning/processor.py @@ -22,13 +22,14 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: img = image if context.process_mode == ProcessMode.BW: - # Chemical toning + black point run in the display domain (luma masks). - p = working_oetf_encode(img) - p = apply_chemical_toning( - p, + # Chemical toning is density-driven, so it works on the linear print + # directly; the black point keeps its display-domain bracket. + img = apply_chemical_toning( + img, selenium_strength=self.config.selenium_strength, sepia_strength=self.config.sepia_strength, ) + p = working_oetf_encode(img) p = apply_chromaticity_preserving_black_point(p, 0.05) img = working_oetf_decode(p) diff --git a/negpy/features/toning/shaders/toning.wgsl b/negpy/features/toning/shaders/toning.wgsl index 183cd395..5308870b 100644 --- a/negpy/features/toning/shaders/toning.wgsl +++ b/negpy/features/toning/shaders/toning.wgsl @@ -16,17 +16,6 @@ struct ToningUniforms { @group(0) @binding(1) var output_tex: texture_storage_2d; @group(0) @binding(2) var params: ToningUniforms; -// Working-space TRC (ProPhoto ROMM: gamma 1.8 + linear toe); chemical toning is bracketed in it. -fn oetf_encode(c: vec3) -> vec3 { - let x = clamp(c, vec3(0.0), vec3(1.0)); - return select(pow(x, vec3(0.55555556)), x * 16.0, x < vec3(0.001953125)); -} - -fn oetf_decode(c: vec3) -> vec3 { - let e = max(c, vec3(0.0)); - return select(pow(e, vec3(1.8)), e / 16.0, e < vec3(0.03125)); -} - fn rgb_to_lab(rgb: vec3) -> vec3 { // Linear Adobe RGB -> CIELAB (D65). Input is scene-linear (no sRGB decode). let r = max(rgb.r, 0.0); @@ -98,19 +87,26 @@ fn main(@builtin(global_invocation_id) gid: vec3) { color = vec3(luma); } - // 2. Chemical Toning (Selenium/Sepia) — B&W only, bracketed in the display domain. - if (params.selenium_strength > 0.0 || params.sepia_strength > 0.0) { - var p = oetf_encode(color); - let luma_toning = dot(p, vec3(0.2126, 0.7152, 0.0722)); + // 2. Chemical Toning (Selenium/Sepia) — B&W only, density-driven on the + // linear print (mirrors _apply_chemical_toning_jit / TONING_CONSTANTS). + // Silver density D = -log10(t); a density-dependent fraction c converts to + // the toner's dye: D' = D*(1-c) + c*D*gain. Selenium converts the densest + // silver first (Dmax boost, eggplant shadows); sepia the thinnest (warm + // highlights, shadows hold). + if (params.is_bw == 1u && (params.selenium_strength > 0.0 || params.sepia_strength > 0.0)) { + let sel_gain = vec3(1.04, 1.10, 1.02); + let sep_gain = vec3(0.82, 0.94, 1.12); + var d = -log(clamp(color, vec3(1e-6), vec3(1.0))) / log(10.0); + // Conversion caps at 1: all the silver is toned (slider > 1 = longer bath). if (params.selenium_strength > 0.0) { - let sel_m = clamp((1.0 - luma_toning) * (1.0 - luma_toning) * params.selenium_strength, 0.0, 1.0); - p = mix(p, p * vec3(0.85, 0.75, 0.85), sel_m); + let c_sel = min(params.selenium_strength * pow(min(d / 2.0, vec3(1.0)), vec3(1.5)), vec3(1.0)); + d = d * (1.0 - c_sel) + c_sel * d * sel_gain; } if (params.sepia_strength > 0.0) { - let sep_m = exp(-pow(luma_toning - 0.6, 2.0) / 0.08) * params.sepia_strength; - p = mix(p, p * vec3(1.1, 0.99, 0.825), sep_m); + let c_sep = min(params.sepia_strength * pow(1.0 - min(d / 1.8, vec3(1.0)), vec3(2.0)), vec3(1.0)); + d = d * (1.0 - c_sep) + c_sep * d * sep_gain; } - color = oetf_decode(p); + color = clamp(pow(vec3(10.0), -d), vec3(0.0), vec3(1.0)); } // 3. Split Toning — all modes (color and B&W) diff --git a/tests/test_pipeline_parity.py b/tests/test_pipeline_parity.py index 6ceff819..a89f76e6 100644 --- a/tests/test_pipeline_parity.py +++ b/tests/test_pipeline_parity.py @@ -24,7 +24,7 @@ from negpy.features.retouch.models import RetouchConfig from negpy.features.toning.models import ToningConfig from negpy.features.geometry.models import GeometryConfig -from negpy.features.process.models import ProcessConfig +from negpy.features.process.models import ProcessConfig, ProcessMode from negpy.infrastructure.gpu.device import GPUDevice from negpy.services.rendering.engine import DarkroomEngine from negpy.services.rendering.gpu_engine import GPUEngine @@ -424,6 +424,26 @@ def test_split_toning_both(self): ) self._run_and_compare(s) + def _bw_settings(self, **toning_kwargs) -> WorkspaceConfig: + base = _make_base_settings() + return replace( + base, + process=replace(base.process, process_mode=ProcessMode.BW), + toning=ToningConfig(**toning_kwargs), + ) + + # B&W parity carries the CPU-only chromaticity-preserving black point on top + # of the toning math; the shared tolerance absorbs it on this synthetic image. + + def test_chemical_selenium(self): + self._run_and_compare(self._bw_settings(selenium_strength=0.8)) + + def test_chemical_sepia(self): + self._run_and_compare(self._bw_settings(sepia_strength=0.8)) + + def test_chemical_both(self): + self._run_and_compare(self._bw_settings(selenium_strength=0.5, sepia_strength=0.5)) + class TestRetouchParity: """CPU vs GPU parity for the dust-removal shader (detect-encoded, heal-linear).""" diff --git a/tests/test_toning_logic.py b/tests/test_toning_logic.py index 3347c02a..50f8f2b4 100644 --- a/tests/test_toning_logic.py +++ b/tests/test_toning_logic.py @@ -7,32 +7,84 @@ ) -class TestToningLogic(unittest.TestCase): - def test_apply_chemical_toning_selenium(self): - """Selenium targets shadows (low luma).""" - # Create a gradient from 0 to 1 - img = np.linspace(0, 1, 100).reshape((10, 10, 1)).repeat(3, axis=2).astype(np.float32) - - res = apply_chemical_toning(img, selenium_strength=1.0, sepia_strength=0.0) - - # Selenium color is [0.85, 0.75, 0.85] (cool/dark) - # It affects low lum (1 - lum_val) - # Shadow (img=0.1) should be changed more than highlight (img=0.9) - diff_shadow = np.abs(res[1, 0, 0] - img[1, 0, 0]) - diff_highlight = np.abs(res[9, 0, 0] - img[9, 0, 0]) - - self.assertGreater(diff_shadow, diff_highlight) - - def test_apply_chemical_toning_sepia(self): - """Sepia targets midtones (warm shift).""" - img = np.full((10, 10, 3), 0.6, dtype=np.float32) - res = apply_chemical_toning(img, selenium_strength=0.0, sepia_strength=1.0) - - # Sepia color is [1.1, 0.99, 0.825] - # Midtones around 0.6 are affected by exp(-((lum-0.6)**2)/0.08) - # Check that red increased and blue decreased - self.assertGreater(res[0, 0, 0], img[0, 0, 0]) - self.assertLess(res[0, 0, 2], img[0, 0, 2]) +class TestChemicalToning(unittest.TestCase): + """Density-driven chemical toners on the linear print: selenium converts the + densest silver first (Dmax boost, cool shadows); sepia bleach-redevelop + converts the thinnest silver first (warm highlights, shadows hold).""" + + @staticmethod + def _gray(t: float) -> np.ndarray: + return np.full((4, 4, 3), t, dtype=np.float32) + + @staticmethod + def _density(res: np.ndarray, ch: int) -> float: + return float(-np.log10(max(float(res[0, 0, ch]), 1e-6))) + + def test_zero_strength_is_identity(self): + img = np.random.rand(10, 10, 3).astype(np.float32) + res = apply_chemical_toning(img, selenium_strength=0.0, sepia_strength=0.0) + np.testing.assert_array_equal(res, img) + + def test_selenium_deepens_shadows(self): + """Selenium adds density where silver is dense — blacks get deeper.""" + dark = self._gray(0.05) # D ~ 1.3 + res = apply_chemical_toning(dark, selenium_strength=1.0, sepia_strength=0.0) + self.assertLess(float(res.mean()), 0.05) + + def test_selenium_converts_densest_first(self): + """Density gain grows with input density; highlights barely move.""" + d_dark_in, d_light_in = -np.log10(0.05), -np.log10(0.9) + res_dark = apply_chemical_toning(self._gray(0.05), selenium_strength=1.0, sepia_strength=0.0) + res_light = apply_chemical_toning(self._gray(0.9), selenium_strength=1.0, sepia_strength=0.0) + gain_dark = self._density(res_dark, 1) - d_dark_in + gain_light = self._density(res_light, 1) - d_light_in + self.assertGreater(gain_dark, gain_light * 10) + self.assertAlmostEqual(gain_light, 0.0, places=3) + + def test_selenium_cools_shadows(self): + """Green gains the most density -> magenta/eggplant cast in the shadows.""" + res = apply_chemical_toning(self._gray(0.05), selenium_strength=1.0, sepia_strength=0.0) + self.assertLess(float(res[0, 0, 1]), float(res[0, 0, 0])) # G darker than R + self.assertLess(float(res[0, 0, 1]), float(res[0, 0, 2])) # G darker than B + + def test_sepia_warms_highlights(self): + """Converted silver -> warm sulfide dye: red lifts, blue drops.""" + light = self._gray(0.6) + res = apply_chemical_toning(light, selenium_strength=0.0, sepia_strength=1.0) + self.assertGreater(float(res[0, 0, 0]), 0.6) # R lighter (warm) + self.assertLess(float(res[0, 0, 2]), 0.6) # B denser + + def test_sepia_converts_thinnest_first(self): + """Bleach eats the thinnest silver first — highlights tone, shadows hold + (the classic split-sepia look at partial strength).""" + res_light = apply_chemical_toning(self._gray(0.6), selenium_strength=0.0, sepia_strength=1.0) + res_dark = apply_chemical_toning(self._gray(0.01), selenium_strength=0.0, sepia_strength=1.0) + warmth_light = float(res_light[0, 0, 0] - res_light[0, 0, 2]) + warmth_dark = float(res_dark[0, 0, 0] - res_dark[0, 0, 2]) + self.assertGreater(warmth_light, 0.01) + self.assertAlmostEqual(warmth_dark, 0.0, places=3) + + def test_paper_white_stays_white(self): + """No silver at paper white — nothing to tone.""" + white = self._gray(1.0) + res = apply_chemical_toning(white, selenium_strength=1.0, sepia_strength=1.0) + np.testing.assert_allclose(res, white, atol=1e-3) + + def test_output_range_combined(self): + img = np.random.rand(10, 10, 3).astype(np.float32) + res = apply_chemical_toning(img, selenium_strength=1.0, sepia_strength=1.0) + self.assertGreaterEqual(float(res.min()), 0.0) + self.assertLessEqual(float(res.max()), 1.0) + + def test_slider_max_saturates_conversion(self): + """Sliders go to 2.0 — conversion caps at all-silver-toned, output stays + sane and monotone with strength.""" + dark = self._gray(0.05) + res_1 = apply_chemical_toning(dark, selenium_strength=1.0, sepia_strength=0.0) + res_2 = apply_chemical_toning(dark, selenium_strength=2.0, sepia_strength=0.0) + self.assertGreaterEqual(float(res_2.min()), 0.0) + self.assertLessEqual(float(res_2.max()), 1.0) + self.assertLessEqual(float(res_2.mean()), float(res_1.mean())) # longer bath, deeper class TestSplitToning(unittest.TestCase): From 247375b9f9c5037d980591b4a19d30e9d1cc905f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 5 Jul 2026 09:26:21 +0200 Subject: [PATCH 4/7] style: tighten dodge/burn and toning comments --- negpy/features/exposure/logic.py | 11 ++++------- negpy/features/exposure/processor.py | 4 ++-- negpy/features/exposure/shaders/exposure.wgsl | 3 +-- negpy/features/local/logic.py | 8 +++----- negpy/features/toning/processor.py | 4 ++-- negpy/features/toning/shaders/toning.wgsl | 9 +++------ negpy/services/rendering/engine.py | 3 +-- negpy/services/rendering/gpu_engine.py | 6 ++---- 8 files changed, 18 insertions(+), 30 deletions(-) diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index 25b7eb4d..f27d4241 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -88,8 +88,7 @@ def _apply_print_curve_kernel( d_min_rgb: per-channel paper-white floor (base+fog incl. tint). dye_mix: dye coupling above that floor (D_rgb = M · D_dye) when use_dye_mix is set. ev_map/ev_scale: per-pixel dodge/burn print-exposure offset (EV stops × - normalized-space stop size) when use_ev is set — same domain as cmy_offsets, - so burns/dodges roll through the toe/shoulder like real enlarger exposure. + normalized-space stop size) when use_ev is set; same domain as cmy_offsets. Output is linear reflectance (transmittance = 10^-D); the working-space OETF is applied at the engine output, not here. @@ -663,11 +662,9 @@ def filtration_offsets(wb_cmy: Tuple[float, float, float], bounds: Any) -> Tuple def local_ev_scale(bounds: Any) -> Tuple[float, float, float]: """ - Normalized-space size of one dodge/burn stop, per channel: -log10(2) divided - by the channel's stretch range (like filtration_offsets), negative so a - positive EV (dodge) lowers print exposure -> lighter print. Equal EV in all - channels is a neutral light change; colour shifts emerge only through the - per-channel slopes, as on real paper. Range 1 when bounds are None. + Normalized-space size of one dodge/burn EV stop per channel: -log10(2) over + the channel's stretch range (like filtration_offsets); negative so positive + EV (dodge) lowers print exposure. Range 1 when bounds are None. """ step = -float(np.log10(2.0)) if bounds is None: diff --git a/negpy/features/exposure/processor.py b/negpy/features/exposure/processor.py index 8becf54f..edb77bc7 100644 --- a/negpy/features/exposure/processor.py +++ b/negpy/features/exposure/processor.py @@ -167,8 +167,8 @@ def analyze_base() -> LogNegativeBounds: class PhotometricProcessor: """ - Applies H&D curve simulation. Dodge/burn masks are folded in as per-pixel - print-exposure offsets ahead of the curve (real enlarger exposure changes). + Applies H&D curve simulation; dodge/burn masks enter as per-pixel + print-exposure offsets. """ def __init__(self, config: ExposureConfig, local_config: Optional[LocalAdjustmentsConfig] = None): diff --git a/negpy/features/exposure/shaders/exposure.wgsl b/negpy/features/exposure/shaders/exposure.wgsl index 0b18d0e7..ea538417 100644 --- a/negpy/features/exposure/shaders/exposure.wgsl +++ b/negpy/features/exposure/shaders/exposure.wgsl @@ -94,8 +94,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let d_max_eff = max(vec3(d_max_base), d_min_eff + vec3(0.1)); let flare_white = pow(vec3(10.0), -d_min_rgb); - // Dodge/burn print-exposure offset (EV stops), same domain as cmy_offsets so - // burns/dodges roll through the toe/shoulder like real enlarger exposure. + // Dodge/burn print-exposure offset (EV stops), same domain as cmy_offsets. var ev = 0.0; if (params.ev_scale.w != 0.0) { ev = textureLoad(ev_tex, coords, 0).r; diff --git a/negpy/features/local/logic.py b/negpy/features/local/logic.py index d6eca8e1..93b998da 100644 --- a/negpy/features/local/logic.py +++ b/negpy/features/local/logic.py @@ -39,11 +39,9 @@ def compute_local_ev_map( distortion_k1: float = 0.0, ) -> np.ndarray: """ - Build the per-pixel dodge/burn print-exposure map [h, w] float32, in EV - stops: ev = sum over masks of strength * alpha, where alpha is the feathered - polygon mask. Positive = dodge (hold back light), negative = burn. All-zeros - when there are no masks. Consumed by the exposure stage as a per-pixel - exposure offset ahead of the H&D print curve. + Build the per-pixel dodge/burn EV map [h, w] float32: ev = sum over masks + of strength * alpha, where alpha is the feathered polygon mask. Positive = + dodge, negative = burn. All-zeros when there are no masks. """ ev = np.zeros((h, w), dtype=np.float32) if not config.masks: diff --git a/negpy/features/toning/processor.py b/negpy/features/toning/processor.py index 8caf0610..db859732 100644 --- a/negpy/features/toning/processor.py +++ b/negpy/features/toning/processor.py @@ -22,8 +22,8 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: img = image if context.process_mode == ProcessMode.BW: - # Chemical toning is density-driven, so it works on the linear print - # directly; the black point keeps its display-domain bracket. + # Density-driven toning reads the linear print; the black point + # keeps its display-domain bracket. img = apply_chemical_toning( img, selenium_strength=self.config.selenium_strength, diff --git a/negpy/features/toning/shaders/toning.wgsl b/negpy/features/toning/shaders/toning.wgsl index 5308870b..e37ba270 100644 --- a/negpy/features/toning/shaders/toning.wgsl +++ b/negpy/features/toning/shaders/toning.wgsl @@ -87,12 +87,9 @@ fn main(@builtin(global_invocation_id) gid: vec3) { color = vec3(luma); } - // 2. Chemical Toning (Selenium/Sepia) — B&W only, density-driven on the - // linear print (mirrors _apply_chemical_toning_jit / TONING_CONSTANTS). - // Silver density D = -log10(t); a density-dependent fraction c converts to - // the toner's dye: D' = D*(1-c) + c*D*gain. Selenium converts the densest - // silver first (Dmax boost, eggplant shadows); sepia the thinnest (warm - // highlights, shadows hold). + // 2. Chemical Toning (Selenium/Sepia) — B&W only, density-driven on the linear + // print; mirrors _apply_chemical_toning_jit / TONING_CONSTANTS: a density- + // dependent fraction c of the silver converts, D' = D*(1-c) + c*D*gain. if (params.is_bw == 1u && (params.selenium_strength > 0.0 || params.sepia_strength > 0.0)) { let sel_gain = vec3(1.04, 1.10, 1.02); let sep_gain = vec3(0.82, 0.94, 1.12); diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index df18f363..2fd33a54 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -143,8 +143,7 @@ def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: img_out = PhotometricProcessor(settings.exposure, settings.local).process(img_in, ctx) return img_out - # Dodge/burn is part of the print exposure, so the local config keys - # this stage alongside the exposure params. + # Dodge/burn masks are print-exposure inputs, so they key this stage. current_img, pipeline_changed = self._run_stage( current_img, (settings.exposure, settings.local), diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index a6003cd0..58a41e62 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -237,8 +237,7 @@ def __init__(self) -> None: def _detect_invalidated_stage(self, settings: WorkspaceConfig, scale_factor: float) -> int: """ Determines the earliest pipeline stage that needs re-running. - Returns stage index (5 is unused — dodge/burn folded into the - exposure pass, keeping the downstream indices stable): + Returns stage index (5 unused — dodge/burn lives in the exposure pass): 0: Geometry (Source/Transform) 1: Exposure (Normalization/Grading/Dodge & Burn) 2: CLAHE (Adaptive Hist) @@ -263,7 +262,6 @@ def _detect_invalidated_stage(self, settings: WorkspaceConfig, scale_factor: flo return 0 if last.process != settings.process or last.exposure != settings.exposure: return 1 - # Dodge/burn is part of the print exposure now. if last.local != settings.local: return 1 if last.lab.clahe_strength != settings.lab.clahe_strength: @@ -1123,7 +1121,7 @@ def _upload_unified_uniforms( + struct.pack("ffff", dye_rows[0, 0], dye_rows[0, 1], dye_rows[0, 2], 0.0) + struct.pack("ffff", dye_rows[1, 0], dye_rows[1, 1], dye_rows[1, 2], 0.0) + struct.pack("ffff", dye_rows[2, 0], dye_rows[2, 1], dye_rows[2, 2], 0.0) - # Dodge/burn: per-channel EV-stop size (mirrors the CPU ev_scale), w = enable. + # Dodge/burn EV-stop size per channel (local_ev_scale); w = enable flag. + struct.pack("ffff", *local_ev_scale(LogNegativeBounds(adj_floors, adj_ceils)), 1.0 if settings.local.masks else 0.0) ) From 47bde7226649f1da38096e7015ca01ea3b7896b4 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 5 Jul 2026 09:29:39 +0200 Subject: [PATCH 5/7] chore: add verify skill for headless app-drive verification --- .claude/skills/verify/SKILL.md | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .claude/skills/verify/SKILL.md diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 00000000..ca26560b --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,45 @@ +--- +name: verify +description: Drive the real NegPy desktop app headlessly and capture rendered evidence — use when verifying pipeline/UI changes end-to-end (not via pytest). +--- + +# Verifying NegPy changes end-to-end + +The surface is the PyQt6 GUI; the render pipeline's output is the canvas. + +## Recipe that works + +1. **Sandbox app state** so the run never touches `~/Documents/NegPy`: + `NEGPY_USER_DIR=/negpy_home` (checked in `get_default_user_dir`). + Create the subdirs from `_bootstrap_environment` (or call it) before + `StorageRepository.initialize()`. +2. **Suppress the first-run tutorial** or every window grab is dimmed by the + overlay: `repo.save_global_setting("tutorial_seen", True)` before + constructing `MainWindow`. +3. **Headless display**: `xvfb-run -a -s "-screen 0 1920x1080x24" uv run python driver.py`. + wgpu/Vulkan works fine under Xvfb on this machine (real GPU). +4. **Driver skeleton**: build the app exactly like `negpy/desktop/main.py` + (`StorageRepository` → `DesktopSessionManager` → `AppController` → + `MainWindow`), then drive a QTimer state machine: + - `controller.load_file("samples/06.raw")` — same method the Files sidebar calls. + - advance steps on `controller.image_updated` (skip `state.last_metrics["splash"]`, + guard against multiple emissions per render, ~600 ms settle). + - change edits like the sidebars do: `session.update_config(replace(state.config, ...))` + + `controller.request_render()`. + - **pixel evidence**: `window.canvas.grab()` (canvas only, overlay-free) → + numpy stats; `window.grab().save(...)` for reviewer screenshots. + - exit with a nonzero code on failed assertions; 120 s timeout guard. + +A working driver from the dodge/burn + toning verification lives in the +session scratchpad as `drive_verify.py` — copy its `grab`/`set_cfg`/state-machine +parts rather than rewriting. + +## Gotchas + +- `samples/06.raw` is a dark graveyard scene — burn/dodge probes need the sky + band (top of frame in raw coords) to see roll-off; mid-frame is near black. +- Mask vertices are raw-image normalized coords, not canvas coords; analyze + screenshots diff-based (`before - after > threshold`) instead of guessing + where a mask lands on the canvas. +- Status bar process label may lag when configs are set programmatically — + don't treat it as ground truth for the active mode. From 5c4d8339b25742f8cbaff86723b1a3a6aaf89d53 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 5 Jul 2026 09:35:58 +0200 Subject: [PATCH 6/7] Update CHANGELOG --- VERSION | 2 +- docs/CHANGELOG.md | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/VERSION b/VERSION index be386c9e..85e60ed1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.33.0 +0.34.0 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 54739499..3254c1ec 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,10 +5,6 @@ - Change: **Dodge & Burn is real print exposure** — masks now change the exposure ahead of the paper curve instead of brightening/darkening the finished image. Burns roll into paper black through the toe and dodges lift toward paper white through the shoulder, like holding back light under the enlarger — no more flat clipped patches under strong masks. Existing dodge/burn edits will render slightly differently. The Dodge & Burn section moved to the Exposure tab to match. - Change: **Selenium and Sepia tone the print's silver, not its brightness** — both toners now work on density like the real baths. Selenium converts the densest silver first: visibly deeper blacks (the reason printers selenium-tone) and a cool eggplant cast that stays in the shadows. Sepia bleach-redevelops the thinnest silver first: highlights warm up while shadows hold, and partial strength gives the classic split-sepia look. Paper white stays white. Existing toned edits will render differently. - - -## 0.33.1 - - Fix: **exporting all RGB-scan triplets no longer fails** with "Input/output error" on most frames. Batch export was reusing stale saved paths for each frame's green/blue exposures instead of the ones the triplet was actually built from, so it tried to read files that weren't there; it now uses each frame's own exposures, the same as exporting one at a time. From 2e6624c0e9fb69aa3cdd95e1a307a78d5708a78d Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Sun, 5 Jul 2026 09:42:36 +0200 Subject: [PATCH 7/7] docs: tone down 0.34.0 changelog entries --- docs/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3254c1ec..aee515f3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,8 +3,8 @@ ## 0.34.0 -- Change: **Dodge & Burn is real print exposure** — masks now change the exposure ahead of the paper curve instead of brightening/darkening the finished image. Burns roll into paper black through the toe and dodges lift toward paper white through the shoulder, like holding back light under the enlarger — no more flat clipped patches under strong masks. Existing dodge/burn edits will render slightly differently. The Dodge & Burn section moved to the Exposure tab to match. -- Change: **Selenium and Sepia tone the print's silver, not its brightness** — both toners now work on density like the real baths. Selenium converts the densest silver first: visibly deeper blacks (the reason printers selenium-tone) and a cool eggplant cast that stays in the shadows. Sepia bleach-redevelops the thinnest silver first: highlights warm up while shadows hold, and partial strength gives the classic split-sepia look. Paper white stays white. Existing toned edits will render differently. +- Change: **Dodge & Burn moved into the print exposure** — masks now adjust exposure before the paper curve instead of scaling the finished image, which is how dodging and burning physically work. Strong burns and dodges roll off through the paper's toe and shoulder instead of clipping flat. The section moved to the Exposure tab; existing dodge/burn edits will render slightly differently. +- Change: **Selenium and Sepia work on print density** — both toners now convert silver density instead of tinting by brightness, matching the real baths. Selenium acts on the densest areas (deeper blacks, cooler shadows); sepia acts on the thinnest (warmer highlights, shadows hold). Existing toned edits will render differently. - Fix: **exporting all RGB-scan triplets no longer fails** with "Input/output error" on most frames. Batch export was reusing stale saved paths for each frame's green/blue exposures instead of the ones the triplet was actually built from, so it tried to read files that weren't there; it now uses each frame's own exposures, the same as exporting one at a time.