From 6b7315cd78567cab34cf19bea12b856d4e508e63 Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Fri, 2 Oct 2026 07:44:11 +0200 Subject: [PATCH 01/12] fine rotation adjustments --- negpy/desktop/controller.py | 1 + negpy/desktop/view/canvas/overlay.py | 15 +++++++++++++-- negpy/desktop/view/main_window.py | 1 + negpy/desktop/view/sidebar/geometry.py | 2 ++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 20337bbf2..5668fc075 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -380,6 +380,7 @@ class AppController(QObject): analysis_buffer_preview_requested = pyqtSignal(float) analysis_buffer_drag_changed = pyqtSignal(bool) rotation_guide_requested = pyqtSignal() + rotation_grid_drag_changed = pyqtSignal(bool) keystone_lines_cleared = pyqtSignal() crop_guide_changed = pyqtSignal() dust_overlay_changed = pyqtSignal() diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index fa77f46ca..63ba9d995 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -70,7 +70,7 @@ _ROT_HANDLE_RADIUS_PX = 11.0 # hit + draw radius of the edge rotation handles _ROT_HANDLE_OFFSET_PX = 24.0 # gap between crop edge and handle center (outside the box) _ROT_FINE_SENSITIVITY = 0.2 # Shift-drag sensitivity, like the crop-move fine drag -_ROTATION_GRID_DIVISIONS = 10 +_ROTATION_GRID_DIVISIONS = 20 _GRID_ALPHA = 70 _MASK_RASTER_MAX = 384 # px cap for feathered overlay rasters @@ -386,6 +386,7 @@ def __init__(self, state: AppState, parent=None): self._buffer_hide_timer.timeout.connect(self._hide_buffer_overlay) self._rotation_grid_visible: bool = False + self._rotation_grid_dragging: bool = False self._rotation_grid_timer = QTimer(self) self._rotation_grid_timer.setSingleShot(True) self._rotation_grid_timer.timeout.connect(self._hide_rotation_grid) @@ -461,10 +462,20 @@ def _hide_buffer_overlay(self) -> None: self._buffer_overlay_visible = False self.update() + def set_rotation_grid_dragging(self, dragging: bool) -> None: + """Hold the grid while the slider is pressed; a stationary press fires no + valueChanged to keep restarting the hide timer.""" + self._rotation_grid_dragging = dragging + if dragging: + self._rotation_grid_timer.stop() + elif self._rotation_grid_visible: + self._rotation_grid_timer.start(1000) + def show_rotation_grid(self) -> None: """Show the rule-of-thirds alignment grid while Fine Rot is adjusted; lingers 1s.""" self._rotation_grid_visible = True - self._rotation_grid_timer.start(1000) + if not self._rotation_grid_dragging: + self._rotation_grid_timer.start(1000) self.update() def _hide_rotation_grid(self) -> None: diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py index 298355f0a..7cc21aeda 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -598,6 +598,7 @@ def _connect_signals(self) -> None: self.controller.analysis_buffer_preview_requested.connect(self.canvas.overlay.show_analysis_buffer) self.controller.analysis_buffer_drag_changed.connect(self.canvas.overlay.set_analysis_buffer_dragging) self.controller.rotation_guide_requested.connect(self.canvas.overlay.show_rotation_grid) + self.controller.rotation_grid_drag_changed.connect(self.canvas.overlay.set_rotation_grid_dragging) self.controller.rotation_guide_requested.connect(self.canvas.overlay.show_crop_preview) self.controller.crop_guide_changed.connect(self.canvas.overlay.update) self.controller.dust_overlay_changed.connect(self.canvas.overlay.update) diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index e442f1918..f3d33f025 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -163,6 +163,8 @@ def _connect_signals(self) -> None: lambda v: self.update_config_section("geometry", render=True, persist=False, readback_metrics=False, fine_rotation=-v) ) self.fine_rot_slider.valueChanged.connect(lambda _v: self.controller.show_rotation_guide()) + self.fine_rot_slider.dragStarted.connect(lambda: self.controller.rotation_grid_drag_changed.emit(True)) + self.fine_rot_slider.dragEnded.connect(lambda: self.controller.rotation_grid_drag_changed.emit(False)) self.fine_rot_slider.valueCommitted.connect( lambda v: self.update_config_section("geometry", render=True, persist=True, readback_metrics=True, fine_rotation=-v) ) From a075e35077b6eaccc1c28e5563c83f98436e3144 Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 11:42:22 +0200 Subject: [PATCH 02/12] Levels implemented by Muse Spark 1.3 --- docs/PIPELINE.md | 11 + docs/USER_GUIDE.md | 4 + negpy/desktop/settings_catalog.py | 48 ++++ negpy/desktop/view/sidebar/tone.py | 154 +++++++++++- negpy/desktop/view/widgets/charts.py | 248 ++++++++++++++++++++ negpy/features/exposure/levels.py | 142 +++++++++++ negpy/features/exposure/models.py | 24 ++ negpy/features/exposure/shaders/levels.wgsl | 51 ++++ negpy/services/rendering/engine.py | 17 +- negpy/services/rendering/gpu_engine.py | 43 +++- tests/test_levels.py | 202 ++++++++++++++++ tests/test_slide_separation.py | 23 +- tests/thumbnail_fingerprint_defaults.json | 20 ++ 13 files changed, 980 insertions(+), 7 deletions(-) create mode 100644 negpy/features/exposure/levels.py create mode 100644 negpy/features/exposure/shaders/levels.wgsl create mode 100644 tests/test_levels.py diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index f617c891b..f8b31f121 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -547,3 +547,14 @@ Post-crop print finishing in scene-linear, before the output transform. Order: e * **Preview paper size**: an interactive render sizes the paper from the preview long edge (`preview_render_size`) instead of the export DPI, and resamples the content to fit, never above its own resolution, because the canvas quotes zoom against the pipeline's buffer and an upscale would make 1:1 read closer than one scan pixel per device pixel. The display shader magnifies instead. +--- + +## 10. Levels +**Code**: `negpy.features.exposure.levels` (CPU) / `negpy.features.exposure.shaders.levels.wgsl` (GPU) + +GIMP-style fine-tuning on the display-encoded output, after the output transform and every creative stage. Four channels — the Value master, then Red, Green, Blue — each mapping an input window onto an output window through a gamma: + +$$t = \mathrm{clamp}\!\left(\frac{x - l}{h - l},\ 0,\ 1\right), \qquad t \leftarrow t^{1/\gamma}, \qquad I_{out} = o_l + (o_h - o_l)\,t$$ + +Bounds are 0-255 code values, $\gamma$ is 0.1 to 10.0 at 1.0. The master applies equally to all three channels first, then each per-channel curve trims on top. A degenerate window ($h \le l$) thresholds at the low marker. Identity by default, so an untouched edit renders exactly as before. Skipped under the Flat intent, which never encodes. + diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 09d75d16d..51e973601 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -450,6 +450,10 @@ Color timing, like enlarger dichroic filters. The **Global / Shadows / Highlight In R/G/B mode these become per-layer trims: **Grade** (±30 ISO-R), **Toe** / **Shoulder** (±1), **Toe Width** / **Shoulder Width** (±2), **Snap** (±0.5), **Dye Separation** (±0.4). +**Levels**, last on the panel and last in the pipeline: + +* **Value / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Value is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. + ### 5.3 Dodge & Burn: local exposure diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index 22658c14d..8ff8123b2 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -176,6 +176,34 @@ def _fmt_gear(values: tuple) -> str: _row("Contrast Mask", "exposure", "contrast_mask"), _row("Mask Spacer", "exposure", "mask_spacer"), _row("Preflash", "exposure", "preflash"), + _row("Levels Value", "exposure", "levels_in_low", "levels_gamma", "levels_in_high", "levels_out_low", "levels_out_high"), + _row( + "Levels Red", + "exposure", + "levels_in_low_red", + "levels_gamma_red", + "levels_in_high_red", + "levels_out_low_red", + "levels_out_high_red", + ), + _row( + "Levels Green", + "exposure", + "levels_in_low_green", + "levels_gamma_green", + "levels_in_high_green", + "levels_out_low_green", + "levels_out_high_green", + ), + _row( + "Levels Blue", + "exposure", + "levels_in_low_blue", + "levels_gamma_blue", + "levels_in_high_blue", + "levels_out_low_blue", + "levels_out_high_blue", + ), _row("Auto Density", "exposure", "auto_exposure", sticky=True), _row("Auto Grade", "exposure", "auto_normalize_contrast", sticky=True), _row("Paper Profile", "exposure", "paper_profile", sticky=True), @@ -432,6 +460,26 @@ def rows_for_section(section: str) -> list[SettingRow]: "contrast_mask", "mask_spacer", "preflash", + "levels_in_low", + "levels_gamma", + "levels_in_high", + "levels_out_low", + "levels_out_high", + "levels_in_low_red", + "levels_gamma_red", + "levels_in_high_red", + "levels_out_low_red", + "levels_out_high_red", + "levels_in_low_green", + "levels_gamma_green", + "levels_in_high_green", + "levels_out_low_green", + "levels_out_high_green", + "levels_in_low_blue", + "levels_gamma_blue", + "levels_in_high_blue", + "levels_out_low_blue", + "levels_out_high_blue", ) # Frame cards whose settings can be pushed to other frames, and the fields each owns. A diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 23f681b42..2159c3c18 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -1,19 +1,24 @@ from dataclasses import replace -from PyQt6.QtWidgets import QComboBox, QDialog, QHBoxLayout, QVBoxLayout +from PyQt6.QtWidgets import QComboBox, QDialog, QDoubleSpinBox, QHBoxLayout, QSpinBox, QVBoxLayout from negpy.desktop.auto_sliders import shown_values, stored_value from negpy.desktop.view.shortcut_registry import tooltip_with_shortcut from negpy.desktop.view.sidebar.base import BaseSidebar -from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, hint_label, header_row, section_subheader, wrap_tooltip +from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, field_label, header_row, hint_label, section_subheader, wrap_tooltip from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.widgets.charts import LevelsHistogramWidget from negpy.desktop.view.widgets.choice_button import ChoiceButton, ToggleMenuButton from negpy.desktop.view.widgets.sliders import CompactSlider, SliderGroup +from negpy.features.exposure.levels import LEVELS_CHANNELS from negpy.features.exposure.logic import per_channel_dye_separation from negpy.features.hdr.models import hdr_active from negpy.features.exposure.auto_sliders import NEUTRAL from negpy.features.exposure.models import EXPOSURE_CONSTANTS, TUNABLE_TARGETS, apply_targets +_LEVELS_SUFFIX = ("", "_red", "_green", "_blue") +_LEVELS_LABELS = ("Value", "Red", "Green", "Blue") + _ISO_R_MIN = float(EXPOSURE_CONSTANTS["iso_r_min"]) _ISO_R_MAX = float(EXPOSURE_CONSTANTS["iso_r_max"]) @@ -236,6 +241,66 @@ def _init_ui(self) -> None: self.layout.addWidget(self.sh_slider) self.layout.addWidget(SliderGroup(self.sh_w_slider, self.sh_w_trim_slider)) + levels_header = section_subheader("LEVELS") + levels_header.setToolTip( + wrap_tooltip( + "Display-referred levels, GIMP-style, applied last after everything else for " + "fine-tuning the output. Each channel maps its input range onto the output " + "range through a gamma; higher gamma holds more high-level intensities." + ) + ) + self.layout.addWidget(levels_header) + self.levels_combo = QComboBox() + for label in _LEVELS_LABELS: + self.levels_combo.addItem(label) + self.levels_combo.setToolTip( + wrap_tooltip( + "Levels channel: Value edits the master curve on all channels, Red, Green " + "and Blue trim one channel on top of it" + ) + ) + self.layout.addWidget(self.levels_combo) + self.levels_hist = LevelsHistogramWidget() + self.levels_hist.setToolTip( + wrap_tooltip( + "The channel's input histogram. Drag the black, gray and white markers for " + "input low, mid and high; drag the output bar's markers for output low and " + "high. Double-click resets the channel." + ) + ) + self.layout.addWidget(self.levels_hist) + self.levels_in_low_spin = QSpinBox() + self.levels_in_low_spin.setRange(0, 255) + self.levels_in_high_spin = QSpinBox() + self.levels_in_high_spin.setRange(0, 255) + self.levels_out_low_spin = QSpinBox() + self.levels_out_low_spin.setRange(0, 255) + self.levels_out_high_spin = QSpinBox() + self.levels_out_high_spin.setRange(0, 255) + self.levels_gamma_spin = QDoubleSpinBox() + self.levels_gamma_spin.setRange(0.10, 10.0) + self.levels_gamma_spin.setDecimals(2) + self.levels_gamma_spin.setSingleStep(0.05) + levels_in_row = QHBoxLayout() + for label, spin, tip in ( + ("Low", self.levels_in_low_spin, "Input shadows below this print at output low (0-255)"), + ("Mid", self.levels_gamma_spin, "Input midtone gamma (0.10-10.00): 1.00 is linear, higher holds more highlights"), + ("High", self.levels_in_high_spin, "Input highlights above this print at output high (0-255)"), + ): + spin.setToolTip(wrap_tooltip(f"{tip}, this channel")) + levels_in_row.addWidget(field_label(label)) + levels_in_row.addWidget(spin, 1) + self.layout.addLayout(levels_in_row) + levels_out_row = QHBoxLayout() + for label, spin, tip in ( + ("Out low", self.levels_out_low_spin, "Lowest output level (0-255): lifts blacks above this, this channel"), + ("Out high", self.levels_out_high_spin, "Highest output level (0-255): drops whites below this, this channel"), + ): + spin.setToolTip(wrap_tooltip(f"{tip}")) + levels_out_row.addWidget(field_label(label)) + levels_out_row.addWidget(spin, 1) + self.layout.addLayout(levels_out_row) + self.layout.addStretch() # Global-only controls, greyed while a channel page is active. @@ -279,6 +344,61 @@ def _on_targets_finished(self, dlg, result: int) -> None: def _channel_index(self) -> int: return self.ch_btn.currentIndex() + def _levels_channel(self) -> int: + return max(0, min(3, self.levels_combo.currentIndex())) + + def _levels_values(self, conf) -> tuple: + from negpy.features.exposure.levels import channel_levels + + lo, gamma, hi, olo, ohi = channel_levels(conf, LEVELS_CHANNELS[self._levels_channel()]) + return int(lo), float(gamma), int(hi), int(olo), int(ohi) + + def _write_levels(self, persist: bool, **values) -> None: + fields = {f"levels_{name}{_LEVELS_SUFFIX[self._levels_channel()]}": v for name, v in values.items()} + self.update_config_section("exposure", render=True, persist=persist, readback_metrics=True, **fields) + + def _show_levels(self, lo: int, gamma: float, hi: int, olo: int, ohi: int) -> None: + for spin, v in ( + (self.levels_in_low_spin, lo), + (self.levels_gamma_spin, gamma), + (self.levels_in_high_spin, hi), + (self.levels_out_low_spin, olo), + (self.levels_out_high_spin, ohi), + ): + spin.blockSignals(True) + spin.setValue(v) + spin.blockSignals(False) + self.levels_hist.set_levels(lo, gamma, hi, olo, ohi) + + def _on_levels_input(self, lo: int, gamma: float, hi: int, persist: bool) -> None: + olo, ohi = self.levels_out_low_spin.value(), self.levels_out_high_spin.value() + self._show_levels(lo, gamma, hi, olo, ohi) + self._write_levels(persist, in_low=lo, gamma=gamma, in_high=hi) + + def _on_levels_output(self, olo: int, ohi: int, persist: bool) -> None: + lo, gamma, hi = self.levels_in_low_spin.value(), self.levels_gamma_spin.value(), self.levels_in_high_spin.value() + self._show_levels(lo, gamma, hi, olo, ohi) + self._write_levels(persist, out_low=olo, out_high=ohi) + + def _on_levels_spins(self, persist: bool) -> None: + lo = self.levels_in_low_spin.value() + gamma = self.levels_gamma_spin.value() + hi = self.levels_in_high_spin.value() + olo = self.levels_out_low_spin.value() + ohi = self.levels_out_high_spin.value() + self.levels_hist.set_levels(lo, gamma, hi, olo, ohi) + self._write_levels(persist, in_low=lo, gamma=gamma, in_high=hi, out_low=olo, out_high=ohi) + + def _reset_levels_channel(self) -> None: + self._write_levels(True, in_low=0, gamma=1.0, in_high=255, out_low=0, out_high=255) + + def _sync_levels_histogram(self) -> None: + metrics = self.controller.state.last_metrics + buf = metrics.get("levels_input_histogram") + if buf is None: + buf = metrics.get("histogram_raw") + self.levels_hist.set_data(buf, self._levels_channel()) + def _curve_field(self, base: str) -> str: idx = self._channel_index() return base if idx == 0 else f"{base}_trim_{_CH_SUFFIX[idx - 1]}" @@ -330,6 +450,21 @@ def _connect_signals(self) -> None: # follow the controller rather than sync_ui. self.controller.test_strip_changed.connect(self._sync_test_strip_btn) self.ch_btn.currentChanged.connect(lambda _i: self.sync_ui()) + self.levels_combo.currentIndexChanged.connect(lambda _i: self.sync_ui()) + self.levels_hist.inputChanged.connect(lambda lo, g, hi: self._on_levels_input(lo, g, hi, False)) + self.levels_hist.inputCommitted.connect(lambda lo, g, hi: self._on_levels_input(lo, g, hi, True)) + self.levels_hist.outputChanged.connect(lambda olo, ohi: self._on_levels_output(olo, ohi, False)) + self.levels_hist.outputCommitted.connect(lambda olo, ohi: self._on_levels_output(olo, ohi, True)) + self.levels_hist.resetRequested.connect(self._reset_levels_channel) + for spin in ( + self.levels_in_low_spin, + self.levels_gamma_spin, + self.levels_in_high_spin, + self.levels_out_low_spin, + self.levels_out_high_spin, + ): + spin.valueChanged.connect(lambda _v: self._on_levels_spins(False)) + spin.editingFinished.connect(lambda: self._on_levels_spins(True)) # White Point/Black Point live on ProcessConfig, not ExposureConfig like the rest of # this panel, so they write to a different config section than the loop below. @@ -340,6 +475,7 @@ def _connect_signals(self) -> None: slider.dragStarted.connect(lambda f=field: self.controller.tone_drag_changed.emit(f)) slider.dragEnded.connect(lambda: self.controller.tone_drag_changed.emit("")) self.controller.image_updated.connect(self._sync_driven) + self.controller.image_updated.connect(self._sync_levels_histogram) for slider, field in ( (self.toe_w_slider, "toe_width"), @@ -493,6 +629,13 @@ def sync_ui(self) -> None: if is_bw: self.ch_btn.setCurrentIndex(0) self.ch_btn.setVisible(not is_bw) + # Levels keeps its own channel selector; B&W has only the Value master. + if is_bw: + self.levels_combo.setCurrentIndex(0) + self.levels_combo.setVisible(not is_bw) + lo, gamma, hi, olo, ohi = self._levels_values(conf) + self._show_levels(lo, gamma, hi, olo, ohi) + self._sync_levels_histogram() idx = self._channel_index() global_mode = idx == 0 @@ -580,6 +723,13 @@ def block_signals(self, blocked: bool) -> None: for w in ( self.paper_combo, self.ch_btn, + self.levels_combo, + self.levels_hist, + self.levels_in_low_spin, + self.levels_gamma_spin, + self.levels_in_high_spin, + self.levels_out_low_spin, + self.levels_out_high_spin, self.density_slider, self.grade_slider, self.grade_trim_slider, diff --git a/negpy/desktop/view/widgets/charts.py b/negpy/desktop/view/widgets/charts.py index 0018e7d07..dc78910fe 100644 --- a/negpy/desktop/view/widgets/charts.py +++ b/negpy/desktop/view/widgets/charts.py @@ -1048,3 +1048,251 @@ def paintEvent(self, event) -> None: c.setAlpha(120) painter.setBrush(QBrush(c)) painter.drawPath(path) + + +class LevelsHistogramWidget(QWidget): + """GIMP-style levels editor: the selected channel's input histogram with + draggable low/mid/high markers, the gamma mapping strip, and the output + bar with its own low/high markers. Double-click resets the channel.""" + + inputChanged = pyqtSignal(int, float, int) + inputCommitted = pyqtSignal(int, float, int) + outputChanged = pyqtSignal(int, int) + outputCommitted = pyqtSignal(int, int) + resetRequested = pyqtSignal() + + _HIT_PX = 8 + _MARK_H = 9 + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(118) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setMouseTracking(True) + self._counts: list = [] + self._channel = 0 + self._lo = 0 + self._gamma = 1.0 + self._hi = 255 + self._olo = 0 + self._ohi = 255 + self._drag: str | None = None + + def set_data(self, buffer: Any, channel: int = 0) -> None: + """Feed a (4, 256) [R, G, B, L] histogram; Value reads the L row.""" + self._channel = int(channel) + row = None + if isinstance(buffer, np.ndarray) and buffer.shape == (4, 256): + idx = 3 if self._channel == 0 else self._channel - 1 + vals = np.asarray(buffer[idx], dtype=float) + peak = float(vals.max()) + row = (vals / peak).tolist() if peak > 0 else [] + if row != self._counts: + self._counts = row or [] + self.update() + + def set_levels(self, in_low: int, gamma: float, in_high: int, out_low: int, out_high: int) -> None: + """Show these marker positions without emitting (parent drives).""" + changed = ( + (in_low, gamma, in_high, out_low, out_high) + != (self._lo, self._gamma, self._hi, self._olo, self._ohi) + ) + self._lo, self._gamma, self._hi, self._olo, self._ohi = int(in_low), float(gamma), int(in_high), int(out_low), int(out_high) + if changed: + self.update() + + def _geom(self) -> tuple: + """(hist_h, out_y, out_h): input histogram height, output bar top/height.""" + h = self.height() + out_h = 14 + out_y = h - self._MARK_H - out_h + return out_y - self._MARK_H - 14, out_y, out_h + + def _x(self, v: float, w: int) -> float: + return float(v) / 255.0 * max(1, w - 1) + + def _mid_x(self, w: int) -> float | None: + """Mid marker position, GIMP's log placement between the end markers.""" + if self._hi <= self._lo: + return None + lo_px, hi_px = self._x(self._lo, w), self._x(self._hi, w) + mid = (lo_px + hi_px) / 2.0 + width = (hi_px - lo_px) / 2.0 + return mid + width * float(np.log10(1.0 / max(self._gamma, 1e-3))) + + def _marker_at(self, x: float, y: float, w: int) -> str | None: + hist_h, out_y, out_h = self._geom() + if y < hist_h + self._MARK_H: + cands = [("lo", self._x(self._lo, w)), ("hi", self._x(self._hi, w))] + mid = self._mid_x(w) + if mid is not None: + cands.append(("mid", mid)) + near = [(m, abs(x - px)) for m, px in cands if abs(x - px) <= self._HIT_PX] + return min(near, key=lambda t: t[1])[0] if near else None + if y >= out_y: + for m, v in (("olo", self._olo), ("ohi", self._ohi)): + if abs(x - self._x(v, w)) <= self._HIT_PX: + return m + return None + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + hit = self._marker_at(event.position().x(), event.position().y(), self.width()) + if hit is not None: + self._drag = hit + self.setCursor(Qt.CursorShape.ClosedHandCursor) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: + x = event.position().x() + if self._drag is not None: + v = int(round(x / max(1, self.width() - 1) * 255)) + if self._drag == "lo": + self._lo = min(max(v, 0), self._hi) + self.inputChanged.emit(self._lo, self._gamma, self._hi) + elif self._drag == "hi": + self._hi = min(max(v, self._lo), 255) + self.inputChanged.emit(self._lo, self._gamma, self._hi) + elif self._drag == "mid": + lo_px, hi_px = self._x(self._lo, self.width()), self._x(self._hi, self.width()) + width = max((hi_px - lo_px) / 2.0, 1e-6) + mid = (lo_px + hi_px) / 2.0 + self._gamma = float(min(max(1.0 / (10.0 ** ((x - mid) / width)), 0.1), 10.0)) + self.inputChanged.emit(self._lo, self._gamma, self._hi) + elif self._drag == "olo": + self._olo = min(max(v, 0), 255) + self.outputChanged.emit(self._olo, self._ohi) + elif self._drag == "ohi": + self._ohi = min(max(v, 0), 255) + self.outputChanged.emit(self._olo, self._ohi) + self.update() + event.accept() + return + over = self._marker_at(x, event.position().y(), self.width()) is not None + self.setCursor(Qt.CursorShape.PointingHandCursor if over else Qt.CursorShape.ArrowCursor) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: + if self._drag is not None and event.button() == Qt.MouseButton.LeftButton: + if self._drag in ("lo", "mid", "hi"): + self.inputCommitted.emit(self._lo, self._gamma, self._hi) + else: + self.outputCommitted.emit(self._olo, self._ohi) + self._drag = None + self.unsetCursor() + event.accept() + return + super().mouseReleaseEvent(event) + + def mouseDoubleClickEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.resetRequested.emit() + event.accept() + return + super().mouseDoubleClickEvent(event) + + def _mapping(self) -> list: + """Current channel's 0-255 output values, for the mapping strip.""" + lo, hi, olo, ohi = self._lo / 255.0, self._hi / 255.0, self._olo, self._ohi + xs = np.arange(256, dtype=np.float64) / 255.0 + t = (xs - lo) / (hi - lo) if hi > lo else xs - lo + t = np.clip(t, 0.0, 1.0) + if self._gamma != 1.0: + t = np.power(t, 1.0 / self._gamma) + return (np.clip(olo + (ohi - olo) * t, 0.0, 255.0) / 255.0).tolist() + + def _triangle(self, painter: QPainter, x: float, y_top: float, size: float, fill: QColor, up: bool) -> None: + path = QPainterPath() + if up: + path.moveTo(x - size / 2, y_top + size) + path.lineTo(x + size / 2, y_top + size) + path.lineTo(x, y_top) + else: + path.moveTo(x - size / 2, y_top) + path.lineTo(x + size / 2, y_top) + path.lineTo(x, y_top + size) + path.closeSubpath() + painter.fillPath(path, QBrush(fill)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(QColor(240, 240, 240, 200), 1)) + painter.drawPath(path) + + def paintEvent(self, event) -> None: + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = self.width(), self.height() + painter.fillRect(self.rect(), QColor(THEME.canvas_bg_black)) + painter.setPen(QPen(QColor(THEME.border_primary), 1)) + painter.drawRect(self.rect().adjusted(0, 0, -1, -1)) + if w < 10: + return + hist_h, out_y, out_h = self._geom() + colors = (THEME.text_secondary, THEME.channel_red, THEME.channel_green, THEME.channel_blue) + base = QColor(colors[self._channel] if 0 <= self._channel < 4 else colors[0]) + + if self._counts: + step = w / (len(self._counts) - 1) + path = QPainterPath() + path.moveTo(0, hist_h) + for i, v in enumerate(self._counts): + path.lineTo(i * step, hist_h - v * hist_h) + path.lineTo(w, hist_h) + path.closeSubpath() + fill = QColor(base) + fill.setAlpha(90) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(fill)) + painter.drawPath(path) + line = QColor(base) + line.setAlpha(190) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(line, 1.0)) + edge = QPainterPath() + edge.moveTo(0, hist_h - self._counts[0] * hist_h) + for i, v in enumerate(self._counts): + edge.lineTo(i * step, hist_h - v * hist_h) + painter.drawPath(edge) + + # Gamma mapping strip under the histogram. + strip_y = hist_h + self._MARK_H + for i, v in enumerate(self._mapping()): + tone = int(v * 255) + if self._channel == 0: + c = QColor(tone, tone, tone) + elif self._channel == 1: + c = QColor(tone, 0, 0) + elif self._channel == 2: + c = QColor(0, tone, 0) + else: + c = QColor(0, 0, tone) + painter.fillRect(int(i / 255 * w), int(strip_y), max(1, int(w / 255) + 1), 14, c) + + painter.setPen(Qt.PenStyle.NoPen) + self._triangle(painter, self._x(self._lo, w), hist_h, self._MARK_H - 2, QColor(20, 20, 20), True) + self._triangle(painter, self._x(self._hi, w), hist_h, self._MARK_H - 2, QColor(245, 245, 245), True) + mid = self._mid_x(w) + if mid is not None: + grey = QColor(150, 150, 150) + self._triangle(painter, float(np.clip(mid, self._x(self._lo, w), self._x(self._hi, w))), hist_h, self._MARK_H - 3, grey, True) + + # Output bar in the channel color with its low/high markers. + for i in range(w): + f = i / max(1, w - 1) + tone = int(f * 255) + if self._channel == 0: + c = QColor(tone, tone, tone) + elif self._channel == 1: + c = QColor(tone, 0, 0) + elif self._channel == 2: + c = QColor(0, tone, 0) + else: + c = QColor(0, 0, tone) + painter.fillRect(i, int(out_y), 1, int(out_h), c) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(QColor(THEME.border_primary), 1)) + painter.drawRect(0, int(out_y), w - 1, int(out_h) - 1) + painter.setPen(Qt.PenStyle.NoPen) + self._triangle(painter, self._x(self._olo, w), out_y + out_h, self._MARK_H - 2, QColor(20, 20, 20), True) + self._triangle(painter, self._x(self._ohi, w), out_y + out_h, self._MARK_H - 2, QColor(245, 245, 245), True) diff --git a/negpy/features/exposure/levels.py b/negpy/features/exposure/levels.py new file mode 100644 index 000000000..a25c67d46 --- /dev/null +++ b/negpy/features/exposure/levels.py @@ -0,0 +1,142 @@ +"""Display-referred levels, GIMP-style, as the last pipeline step. + +Each of the four channels (the Value master, then Red, Green, Blue) carries its +own input low/gamma/high and output low/high. The master applies equally to all +three channels first, then each per-channel curve trims on top. GIMP applies +``pow(t, 1/gamma)``; higher gamma holds more high-level intensities. +""" + +from typing import Any + +import numpy as np + +from negpy.domain.types import ImageBuffer +from negpy.kernel.image.validation import ensure_image + +LEVELS_CHANNELS = ("value", "red", "green", "blue") + +LEVELS_IN_MIN = 0 +LEVELS_IN_MAX = 255 +LEVELS_GAMMA_MIN = 0.1 +LEVELS_GAMMA_MAX = 10.0 + + +def _channel_fields(channel: str) -> tuple[str, str, str, str, str]: + """(in_low, gamma, in_high, out_low, out_high) field names for `channel`.""" + suffix = "" if channel == "value" else f"_{channel}" + return ( + f"levels_in_low{suffix}", + f"levels_gamma{suffix}", + f"levels_in_high{suffix}", + f"levels_out_low{suffix}", + f"levels_out_high{suffix}", + ) + + +def levels_fields() -> tuple[str, ...]: + """Every ExposureConfig field levels owns, master first, then R/G/B.""" + return tuple(f for ch in LEVELS_CHANNELS for f in _channel_fields(ch)) + + +def channel_levels(config: Any, channel: str) -> tuple[float, float, float, float, float]: + """Clamped (in_low, gamma, in_high, out_low, out_high) for `channel`.""" + in_low_f, gamma_f, in_high_f, out_low_f, out_high_f = _channel_fields(channel) + in_low = float(min(max(round(float(getattr(config, in_low_f))), LEVELS_IN_MIN), LEVELS_IN_MAX)) + gamma = float(min(max(float(getattr(config, gamma_f)), LEVELS_GAMMA_MIN), LEVELS_GAMMA_MAX)) + in_high = float(min(max(round(float(getattr(config, in_high_f))), LEVELS_IN_MIN), LEVELS_IN_MAX)) + out_low = float(min(max(round(float(getattr(config, out_low_f))), LEVELS_IN_MIN), LEVELS_IN_MAX)) + out_high = float(min(max(round(float(getattr(config, out_high_f))), LEVELS_IN_MIN), LEVELS_IN_MAX)) + return in_low, gamma, in_high, out_low, out_high + + +def levels_active(config: Any) -> bool: + """True when any channel deviates from the identity mapping.""" + for channel in LEVELS_CHANNELS: + in_low, gamma, in_high, out_low, out_high = channel_levels(config, channel) + if in_low != 0.0 or in_high != 255.0 or out_low != 0.0 or out_high != 255.0 or gamma != 1.0: + return True + return False + + +def without_levels(config: Any) -> Any: + """`config` with every levels field at its default, so a levels-only edit + re-runs just the final stage instead of the exposure cache behind it.""" + from dataclasses import replace + + defaults = {} + for f in levels_fields(): + if "gamma" in f: + defaults[f] = 1.0 + elif "high" in f: + defaults[f] = 255 + else: + defaults[f] = 0 + try: + return replace(config, **defaults) + except TypeError: + return config + + +def uniform_rows(config: Any) -> tuple[tuple[float, float, float, float], ...]: + """Five vec4s over (value, red, green, blue): input low/high, inverse gamma, + output low and output span, all normalized. Single source for the GPU pack.""" + lo, hi, inv, olo, orange = [], [], [], [], [] + for channel in LEVELS_CHANNELS: + in_low, gamma, in_high, out_low, out_high = channel_levels(config, channel) + lo.append(in_low / 255.0) + hi.append(in_high / 255.0) + inv.append(1.0 / gamma) + olo.append(out_low / 255.0) + orange.append((out_high - out_low) / 255.0) + return (tuple(lo), tuple(hi), tuple(inv), tuple(olo), tuple(orange)) + + +def _remap_plane( + plane: np.ndarray, + in_low: float, + gamma: float, + in_high: float, + out_low: float, + out_high: float, +) -> np.ndarray: + """One channel's input range through gamma onto the output range, all in 0-255.""" + lo, hi = in_low / 255.0, in_high / 255.0 + if hi > lo: + t = (plane - lo) / (hi - lo) + else: + # Degenerate window: the low marker alone thresholds, as in GIMP, where + # the unnormalized distance is clamped straight onto [0, 1]. + t = (plane - lo) * 255.0 + t = np.clip(t, 0.0, 1.0) + if gamma != 1.0: + t = np.power(t, 1.0 / gamma, dtype=np.float32) + return (out_low + (out_high - out_low) * t) / 255.0 + + +def apply_levels(image: ImageBuffer, config: Any) -> ImageBuffer: + """GIMP levels on a display-encoded buffer, master first, then per-channel. + + Runs on float32 directly rather than through an OpenCV LUT: the pipeline is + float throughout and a 256-entry uint8 table would quantize the mapping. + Identity (every channel at defaults) returns the input untouched. + """ + if not levels_active(config): + return image + arr = np.asarray(image, dtype=np.float32) + mono = arr.ndim == 2 + img = arr[..., None] if mono else arr + res = img.astype(np.float32, copy=True) + master = channel_levels(config, "value") + if master != (0.0, 1.0, 255.0, 0.0, 255.0): + for c in range(res.shape[-1]): + res[..., c] = _remap_plane(res[..., c], *master) + for c, channel in enumerate(("red", "green", "blue")): + if c >= res.shape[-1]: + break + params = channel_levels(config, channel) + if params != (0.0, 1.0, 255.0, 0.0, 255.0): + res[..., c] = _remap_plane(res[..., c], *params) + res = np.clip(res, 0.0, 1.0) + if mono: + res = res[..., 0] + return ensure_image(res.astype(np.float32)) diff --git a/negpy/features/exposure/models.py b/negpy/features/exposure/models.py index a33dec20c..fe5fb477b 100644 --- a/negpy/features/exposure/models.py +++ b/negpy/features/exposure/models.py @@ -104,6 +104,30 @@ class ExposureConfig: # logic.separation_damping_gain). Inert at dye_separation 1.0: it only # redistributes that slider's push. separation_damping: float = 0.0 + # Display-referred levels (see levels.py), the last pipeline step: the Value + # master first, then one curve per channel. In/out bounds are 0-255 ints, + # gamma 0.1-10.0 at 1.0. Identity by default, so an untouched edit renders + # exactly as before. + levels_in_low: int = 0 + levels_gamma: float = 1.0 + levels_in_high: int = 255 + levels_out_low: int = 0 + levels_out_high: int = 255 + levels_in_low_red: int = 0 + levels_gamma_red: float = 1.0 + levels_in_high_red: int = 255 + levels_out_low_red: int = 0 + levels_out_high_red: int = 255 + levels_in_low_green: int = 0 + levels_gamma_green: float = 1.0 + levels_in_high_green: int = 255 + levels_out_low_green: int = 0 + levels_out_high_green: int = 255 + levels_in_low_blue: int = 0 + levels_gamma_blue: float = 1.0 + levels_in_high_blue: int = 255 + levels_out_low_blue: int = 0 + levels_out_high_blue: int = 255 def __post_init__(self) -> None: """ diff --git a/negpy/features/exposure/shaders/levels.wgsl b/negpy/features/exposure/shaders/levels.wgsl new file mode 100644 index 000000000..40e579444 --- /dev/null +++ b/negpy/features/exposure/shaders/levels.wgsl @@ -0,0 +1,51 @@ +// Display-referred levels, the last pipeline step; mirrors levels.py::apply_levels. +// +// The buffer is already display-encoded (output_encode ran). The Value master +// applies equally to all three channels first, then each per-channel curve. +// Uniforms carry normalized bounds over (value, red, green, blue); gamma rides +// inverted, as the pow exponent. + +struct LevelsUniforms { + in_lo: vec4, + in_hi: vec4, + gamma_inv: vec4, + out_lo: vec4, + out_span: 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: LevelsUniforms; + +fn level_channel(x: f32, lo: f32, hi: f32, ginv: f32, olo: f32, ospan: f32) -> f32 { + var t: f32; + if (hi > lo) { + t = (x - lo) / (hi - lo); + } else { + // Degenerate window: the low marker alone thresholds, as in GIMP, where + // the unnormalized distance is clamped straight onto [0, 1]. + t = (x - lo) * 255.0; + } + t = clamp(t, 0.0, 1.0); + if (ginv != 1.0) { + t = pow(t, ginv); + } + return olo + ospan * t; +} + +@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)); + var color = textureLoad(input_tex, coords, 0).rgb; + color = vec3( + level_channel(color.r, params.in_lo[0], params.in_hi[0], params.gamma_inv[0], params.out_lo[0], params.out_span[0]), + level_channel(color.g, params.in_lo[0], params.in_hi[0], params.gamma_inv[0], params.out_lo[0], params.out_span[0]), + level_channel(color.b, params.in_lo[0], params.in_hi[0], params.gamma_inv[0], params.out_lo[0], params.out_span[0]) + ); + color.r = level_channel(color.r, params.in_lo[1], params.in_hi[1], params.gamma_inv[1], params.out_lo[1], params.out_span[1]); + color.g = level_channel(color.g, params.in_lo[2], params.in_hi[2], params.gamma_inv[2], params.out_lo[2], params.out_span[2]); + color.b = level_channel(color.b, params.in_lo[3], params.in_hi[3], params.gamma_inv[3], params.out_lo[3], params.out_span[3]); + textureStore(output_tex, coords, vec4(clamp(color, vec3(0.0), vec3(1.0)), 1.0)); +} diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index b8a92eb7e..6602365ab 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -12,6 +12,7 @@ from negpy.kernel.system.logging import get_logger from negpy.features.geometry.processor import GeometryProcessor, CropProcessor from negpy.features.exposure import models as exposure_models +from negpy.features.exposure.levels import apply_levels, levels_active, without_levels from negpy.features.exposure.models import RenderIntent from negpy.features.exposure.processor import ( NormalizationProcessor, @@ -223,10 +224,12 @@ def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: # it on a drag. Stays inside the flat intent below, being a capture fix, not a look. return apply_hue_trim(img_out, settings.process.hue_trim) - # Dodge/burn masks are print-exposure inputs, so they key this stage. + # Dodge/burn masks are print-exposure inputs, so they key this stage. Levels + # runs on the encoded output, so it stays out of the key and a levels-only + # edit re-runs just the final stage. current_img, pipeline_changed = self._run_stage( current_img, - (settings.exposure, settings.local, settings.process.hue_trim), + (without_levels(settings.exposure), settings.local, settings.process.hue_trim), "exposure", run_exposure, context, @@ -277,6 +280,16 @@ def run_lab(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: current_img = FinishProcessor(settings.finish, settings.export.export_print_size, paper, tone).process(current_img, context) # Output transform: scene-linear -> display-encoded (flat master skips this). current_img = ensure_image(working_oetf_encode(current_img)) + # Levels, last: display-referred fine-tuning on the encoded output. The + # input histogram is published first, so the panel draws the source the + # markers act on rather than the leveled result. + if levels_active(settings.exposure): + from negpy.features.exposure.analysis import output_histogram + + pre_levels = output_histogram(current_img) + if pre_levels is not None: + context.metrics["levels_input_histogram"] = pre_levels + current_img = apply_levels(current_img, settings.exposure) # No paper layout runs here, so the whole buffer is the picture. Reported rather # than left out: the controller merges each render's metrics into last_metrics, so diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 48de6101c..dcfc68ac0 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -279,6 +279,7 @@ def __init__(self) -> None: "exposure": get_resource_path(os.path.join("negpy", "features", "exposure", "shaders", "exposure.wgsl")), "transfer": get_resource_path(os.path.join("negpy", "features", "transparency", "shaders", "transfer.wgsl")), "output_encode": get_resource_path(os.path.join("negpy", "features", "exposure", "shaders", "output_encode.wgsl")), + "levels": get_resource_path(os.path.join("negpy", "features", "exposure", "shaders", "levels.wgsl")), "autocrop": get_resource_path(os.path.join("negpy", "features", "geometry", "shaders", "autocrop.wgsl")), "clahe_hist": get_resource_path(os.path.join("negpy", "features", "lab", "shaders", "clahe_hist.wgsl")), "clahe_cdf": get_resource_path(os.path.join("negpy", "features", "lab", "shaders", "clahe_cdf.wgsl")), @@ -319,6 +320,7 @@ def __init__(self) -> None: "finish", "layout", "density_hist", + "levels", ] # Packed byte size per stage. A stage that exceeds the 256B dynamic-offset # alignment (exposure, 416B) occupies multiple aligned slots. @@ -335,6 +337,7 @@ def __init__(self) -> None: "finish": 60, "layout": 48, "density_hist": 16, + "levels": 80, } self._alignment = UNIFORM_ALIGNMENT_DEFAULT self._current_source_hash: Optional[str] = None @@ -407,7 +410,11 @@ def _detect_invalidated_stage( return 0 if last.flatfield.apply != settings.flatfield.apply: return 0 - if last.process != settings.process or last.exposure != settings.exposure: + # Levels runs on the encoded output in its own pass, so a levels-only edit + # re-runs nothing behind it. + from negpy.features.exposure.levels import without_levels + + if last.process != settings.process or without_levels(last.exposure) != without_levels(settings.exposure): return 1 # Retuned Auto Density/Grade targets live in EXPOSURE_CONSTANTS, invisible to the # config diff, but they reshape the print curve in the exposure pass. @@ -1409,6 +1416,25 @@ def _analyze_bounds() -> LogNegativeBounds: self._dispatch_pass(enc, "output_encode", [(0, tex_final.view), (1, tex_output.view)], tex_final.width, tex_final.height) tex_final = tex_output + # Levels, last: display-referred fine-tuning on the encoded output. Always + # dispatched, like output_encode, so a levels-only edit re-runs just this. + # The metrics pass stays on the pre-levels content texture, so the panel + # draws the histogram the markers act on. + tex_levels = self._get_intermediate_texture( + tex_output.width, + tex_output.height, + wgpu.TextureUsage.STORAGE_BINDING | wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_SRC, + "levels", + ) + self._dispatch_pass( + enc, + "levels", + [(0, tex_output.view), (1, tex_levels.view), (2, self._get_uniform_binding("levels"))], + tex_output.width, + tex_output.height, + ) + tex_final = tex_levels + device.queue.submit([enc.finish()]) # The exact stretch the shader normalized with (mirrors the CPU "final_bounds"). # The transfer path renders through the fixed window, not the measured one. @@ -2074,9 +2100,22 @@ def _upload_unified_uniforms( # ROI offset + crop dims for the density-histogram pass (tex_norm is uncropped). dh_data = struct.pack("IIII", crop_offset[0], crop_offset[1], crop_w, crop_h) + # Display-referred levels, over (value, red, green, blue); mirrors levels.py. + from negpy.features.exposure.levels import uniform_rows + + _lv_lo, _lv_hi, _lv_inv, _lv_olo, _lv_ospan = uniform_rows(settings.exposure) + lv_data = ( + struct.pack("ffff", *_lv_lo) + + struct.pack("ffff", *_lv_hi) + + struct.pack("ffff", *_lv_inv) + + struct.pack("ffff", *_lv_olo) + + struct.pack("ffff", *_lv_ospan) + ) + full_buffer = bytearray() for name, d in zip( - self._uniform_names, [g_data, n_data, e_data, tr_data, c_data, l_data, li_data, cy_data, t_data, f_data, y_data, dh_data] + self._uniform_names, + [g_data, n_data, e_data, tr_data, c_data, l_data, li_data, cy_data, t_data, f_data, y_data, dh_data, lv_data], ): full_buffer += d + b"\x00" * (self._slot_bytes(name) - len(d)) diff --git a/tests/test_levels.py b/tests/test_levels.py new file mode 100644 index 000000000..da73f828c --- /dev/null +++ b/tests/test_levels.py @@ -0,0 +1,202 @@ +"""Display-referred levels (GIMP-style), applied last in the pipeline. + +The math lives in two places — ``levels.py`` (CPU) and ``shaders/levels.wgsl`` +(GPU). They must agree, or GPU previews drift from CPU exports. +""" + +import unittest +from dataclasses import replace + +import numpy as np + +from negpy.domain.models import WorkspaceConfig, flat_master_config +from negpy.features.exposure.levels import apply_levels, channel_levels, levels_active, levels_fields, uniform_rows +from negpy.features.exposure.models import ExposureConfig +from negpy.infrastructure.gpu.device import GPUDevice + + +def _gray(*values: float) -> np.ndarray: + return np.repeat(np.asarray(values, dtype=np.float32)[None, :, None], 3, axis=2) + + +class TestLevelsMath(unittest.TestCase): + def test_identity_by_default(self) -> None: + conf = ExposureConfig() + self.assertFalse(levels_active(conf)) + img = np.linspace(0.0, 1.0, 300, dtype=np.float32).reshape(10, 10, 3) + np.testing.assert_array_equal(np.asarray(apply_levels(img, conf)), img) + + def test_every_channel_defaults_to_identity(self) -> None: + for field in levels_fields(): + default = getattr(ExposureConfig(), field) + if "gamma" in field: + self.assertEqual(default, 1.0) + elif "high" in field: + self.assertEqual(default, 255) + else: + self.assertEqual(default, 0) + + def test_input_window_stretches(self) -> None: + conf = replace(ExposureConfig(), levels_in_low=51, levels_in_high=204) + out = np.asarray(apply_levels(_gray(0.2, 0.5, 0.8), conf))[0, :, 0] + np.testing.assert_allclose(out, [0.0, 0.5, 1.0], atol=1e-6) + + def test_gamma_above_one_holds_highlights(self) -> None: + conf = replace(ExposureConfig(), levels_gamma=2.0) + out = float(np.asarray(apply_levels(_gray(0.25), conf))[0, 0, 0]) + self.assertAlmostEqual(out, 0.5, places=5) + + def test_gamma_below_one_deepens_midtones(self) -> None: + conf = replace(ExposureConfig(), levels_gamma=0.5) + out = float(np.asarray(apply_levels(_gray(0.25), conf))[0, 0, 0]) + self.assertAlmostEqual(out, 0.0625, places=5) + + def test_output_window_compresses(self) -> None: + conf = replace(ExposureConfig(), levels_out_low=51, levels_out_high=204) + out = np.asarray(apply_levels(_gray(0.0, 0.5, 1.0), conf))[0, :, 0] + np.testing.assert_allclose(out, [0.2, 0.5, 0.8], atol=1e-6) + + def test_degenerate_window_thresholds(self) -> None: + conf = replace(ExposureConfig(), levels_in_low=128, levels_in_high=128) + out = np.asarray(apply_levels(_gray(0.2, 0.9), conf))[0, :, 0] + np.testing.assert_allclose(out, [0.0, 1.0], atol=1e-6) + + def test_per_channel_leaves_other_channels(self) -> None: + conf = replace(ExposureConfig(), levels_in_low_red=128) + img = np.full((2, 2, 3), 0.6, dtype=np.float32) + out = np.asarray(apply_levels(img, conf)) + self.assertLess(float(out[0, 0, 0]), 0.6) + np.testing.assert_allclose(out[..., 1:], 0.6, atol=1e-6) + + def test_master_applies_before_the_channel(self) -> None: + conf = replace(ExposureConfig(), levels_in_low=51, levels_in_low_red=51) + img = np.full((2, 2, 3), 0.6, dtype=np.float32) + master_only = np.asarray(apply_levels(img, replace(ExposureConfig(), levels_in_low=51))) + both = np.asarray(apply_levels(img, conf)) + # The red channel passes two windows, green/blue only the master. + np.testing.assert_allclose(both[..., 1:], master_only[..., 1:], atol=1e-6) + self.assertLess(float(both[0, 0, 0]), float(master_only[0, 0, 0])) + + def test_config_values_are_clamped(self) -> None: + conf = replace( + ExposureConfig(), + levels_in_low=999, + levels_gamma=0.0, + levels_in_high=-99, + levels_out_low=-5, + levels_out_high=9999, + ) + lo, gamma, hi, olo, ohi = channel_levels(conf, "value") + self.assertEqual((lo, hi, olo, ohi), (255.0, 0.0, 0.0, 255.0)) + self.assertEqual(gamma, 0.1) + out = np.asarray(apply_levels(_gray(0.0, 0.5, 1.0), conf)) + self.assertTrue(np.all(out >= 0.0) and np.all(out <= 1.0)) + + def test_uniform_rows_are_normalized(self) -> None: + conf = replace(ExposureConfig(), levels_in_low=51, levels_gamma=2.0, levels_in_high_red=128) + lo, hi, inv, olo, ospan = uniform_rows(conf) + self.assertEqual(len((lo, hi, inv, olo, ospan)), 5) + self.assertAlmostEqual(lo[0], 51 / 255) + self.assertAlmostEqual(inv[0], 0.5) + self.assertAlmostEqual(hi[1], 128 / 255) + self.assertAlmostEqual(inv[1], 1.0) + + +class TestLevelsPipeline(unittest.TestCase): + def _processor(self): # local import: building it pulls the desktop stack + from negpy.services.rendering.image_processor import ImageProcessor + + return ImageProcessor() + + def _negative(self) -> np.ndarray: + rng = np.random.default_rng(7) + h, w = 48, 48 + grad = np.linspace(0.05, 0.9, w, dtype=np.float32) + img = np.repeat(grad[None, :], h, axis=0) + img = np.stack([img, img * 0.9, img * 0.8], axis=-1) + return np.ascontiguousarray(img + rng.uniform(0, 0.005, img.shape).astype(np.float32)) + + def _render(self, settings, **kw): + result, metrics = self._processor().run_pipeline( + self._negative(), settings, "levels-src", render_size_ref=48.0, prefer_gpu=False, readback_metrics=True, **kw + ) + return np.asarray(result)[:, :, :3].astype(np.float64), metrics + + def test_levels_move_the_render_and_publish_the_input_histogram(self) -> None: + base = WorkspaceConfig() + plain, _ = self._render(base) + leveled_cfg = replace( + base, + exposure=replace(base.exposure, levels_in_low=20, levels_gamma=1.2, levels_in_high=235), + ) + leveled, metrics = self._render(leveled_cfg) + self.assertGreater(float(np.abs(leveled - plain).max()), 1e-4) + hist = metrics.get("levels_input_histogram") + self.assertIsNotNone(hist) + assert hist is not None + self.assertEqual(np.asarray(hist).shape, (4, 256)) + + def test_no_histogram_key_without_levels(self) -> None: + _, metrics = self._render(WorkspaceConfig()) + self.assertNotIn("levels_input_histogram", metrics) + + def test_flat_master_bypasses_levels(self) -> None: + flat = flat_master_config(WorkspaceConfig()) + plain, _ = self._render(flat) + with_levels, _ = self._render(replace(flat, exposure=replace(flat.exposure, levels_in_low=40, levels_gamma=2.0))) + np.testing.assert_array_equal(with_levels, plain) + + +@unittest.skipUnless(GPUDevice.get().is_available, "GPU not available") +class TestGpuLevelsParity(unittest.TestCase): + def _render(self, processor, settings, img, prefer_gpu): + result, _ = processor.run_pipeline( + img, settings, "levels-parity-src", render_size_ref=float(max(img.shape[:2])), prefer_gpu=prefer_gpu, readback_metrics=False + ) + if hasattr(result, "readback"): + arr = np.asarray(result.readback())[:, :, :3] + else: + arr = np.asarray(result)[:, :, :3] + return arr.astype(np.float64) + + def test_cpu_gpu_match_levels(self) -> None: + from negpy.services.rendering.image_processor import ImageProcessor + + processor = ImageProcessor() + if processor.engine_gpu is None: + self.skipTest("GPU engine not initialised") + + rng = np.random.default_rng(3) + h, w = 64, 64 + grad = np.linspace(0.05, 0.9, w, dtype=np.float32) + img = np.repeat(grad[None, :], h, axis=0) + img = np.stack([img, img * 0.9, img * 0.8], axis=-1) + img = np.ascontiguousarray(img + rng.uniform(0, 0.01, img.shape).astype(np.float32)) + + base = WorkspaceConfig() + settings = replace( + base, + exposure=replace( + base.exposure, + levels_in_low=15, + levels_gamma=1.4, + levels_in_high=240, + levels_out_low=5, + levels_out_high=250, + levels_in_low_red=30, + levels_gamma_blue=0.7, + levels_out_high_green=230, + ), + ) + cpu = self._render(processor, settings, img, prefer_gpu=False) + gpu = self._render(processor, settings, img, prefer_gpu=True) + + self.assertEqual(cpu.shape, gpu.shape) + mad = float(np.mean(np.abs(cpu - gpu))) + mx = float(np.max(np.abs(cpu - gpu))) + self.assertLess(mad, 0.01, f"mean abs diff {mad:.4f}") + self.assertLess(mx, 0.04, f"max abs diff {mx:.4f}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_slide_separation.py b/tests/test_slide_separation.py index 04c9cfce7..6659e8b2d 100644 --- a/tests/test_slide_separation.py +++ b/tests/test_slide_separation.py @@ -29,6 +29,13 @@ def _with_trims(*names: str) -> set: | {f"{zone}_{dye}" for zone in ("shadow", "highlight") for dye in ("cyan", "magenta", "yellow")} | _with_trims("toe", "shoulder", "toe_width", "shoulder_width", "dye_separation") | {"separation_damping"} + # Display-referred levels run after the output transform on every path, so they + # move a slide exactly like a print. + | { + f"levels_{name}{suffix}" + for suffix in ("", "_red", "_green", "_blue") + for name in ("in_low", "gamma", "in_high", "out_low", "out_high") + } ) # Meter the frame on a raw slide and on a Positive frame alike; a slide starts with both off. METERS = {"auto_exposure", "auto_normalize_contrast"} @@ -64,6 +71,11 @@ def _moved(field: str, value): return not value if field == "paper_profile": return "kodak_endura" + if field.startswith("levels_"): + # Integer 0-255 bounds: ±25 stays in range and off the clamp for both ends. + if "gamma" in field: + return value + 2.0 + return value - 25.0 if "high" in field else value + 25.0 if "grade" in field: return value + 15.0 if field in ("toe_width", "shoulder_width", "mask_spacer"): @@ -96,10 +108,19 @@ def _config(positive: bool): def _render(cfg) -> np.ndarray: + """Base + exposure stages, then the engine tail (finish, output transform, + levels), so end-of-pipeline controls like levels read a delta too.""" + from negpy.features.exposure.levels import apply_levels + from negpy.features.finish.processor import FinishProcessor + from negpy.kernel.image.logic import working_oetf_encode + img = _image() ctx = PipelineContext(original_size=img.shape[:2], scale_factor=1.0, process_mode=ProcessMode.E6, wants_uv_grid=False) norm = base_processor(cfg).process(img, ctx) - return np.asarray(exposure_processor(cfg).process(norm, ctx), dtype=np.float64) + lin = np.asarray(exposure_processor(cfg).process(norm, ctx), dtype=np.float32) + finished = np.asarray(FinishProcessor(cfg.finish, cfg.export.export_print_size, (1.0, 1.0, 1.0), None).process(lin, ctx)) + encoded = np.asarray(working_oetf_encode(finished)) + return np.asarray(apply_levels(encoded, cfg.exposure), dtype=np.float64) def _delta(field: str, positive: bool) -> float: diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index 7434a035a..9d37612ea 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -33,6 +33,26 @@ "highlight_grade_trim_red": "0.0", "highlight_magenta": "0.0", "highlight_yellow": "0.0", + "levels_gamma": "1.0", + "levels_gamma_blue": "1.0", + "levels_gamma_green": "1.0", + "levels_gamma_red": "1.0", + "levels_in_high": "255", + "levels_in_high_blue": "255", + "levels_in_high_green": "255", + "levels_in_high_red": "255", + "levels_in_low": "0", + "levels_in_low_blue": "0", + "levels_in_low_green": "0", + "levels_in_low_red": "0", + "levels_out_high": "255", + "levels_out_high_blue": "255", + "levels_out_high_green": "255", + "levels_out_high_red": "255", + "levels_out_low": "0", + "levels_out_low_blue": "0", + "levels_out_low_green": "0", + "levels_out_low_red": "0", "mask_spacer": "4.0", "midtone_gamma": "0.0", "midtone_gamma_trim_blue": "0.0", From 38d33126811e9db4d695d2e21d618a8909f43784 Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 11:46:11 +0200 Subject: [PATCH 03/12] Levels layout changes --- negpy/desktop/view/sidebar/tone.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 2159c3c18..ddb3ce098 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -1,6 +1,6 @@ from dataclasses import replace -from PyQt6.QtWidgets import QComboBox, QDialog, QDoubleSpinBox, QHBoxLayout, QSpinBox, QVBoxLayout +from PyQt6.QtWidgets import QAbstractSpinBox, QComboBox, QDialog, QDoubleSpinBox, QHBoxLayout, QSpinBox, QStyle, QVBoxLayout from negpy.desktop.auto_sliders import shown_values, stored_value from negpy.desktop.view.shortcut_registry import tooltip_with_shortcut @@ -281,7 +281,22 @@ def _init_ui(self) -> None: self.levels_gamma_spin.setRange(0.10, 10.0) self.levels_gamma_spin.setDecimals(2) self.levels_gamma_spin.setSingleStep(0.05) + # Buttonless, content-width entries like the slider values: the markers are + # the mouse path, these are for exact entry (typing, arrow keys when focused). + for spin, sample in ( + (self.levels_in_low_spin, "255"), + (self.levels_in_high_spin, "255"), + (self.levels_out_low_spin, "255"), + (self.levels_out_high_spin, "255"), + (self.levels_gamma_spin, "10.00"), + ): + spin.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) + text = spin.fontMetrics().horizontalAdvance(sample) + frame = 2 * spin.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth) + spin.setFixedWidth(text + frame + 4) levels_in_row = QHBoxLayout() + levels_in_row.setContentsMargins(0, 0, 0, 0) + levels_in_row.setSpacing(THEME.space_sm) for label, spin, tip in ( ("Low", self.levels_in_low_spin, "Input shadows below this print at output low (0-255)"), ("Mid", self.levels_gamma_spin, "Input midtone gamma (0.10-10.00): 1.00 is linear, higher holds more highlights"), @@ -289,16 +304,18 @@ def _init_ui(self) -> None: ): spin.setToolTip(wrap_tooltip(f"{tip}, this channel")) levels_in_row.addWidget(field_label(label)) - levels_in_row.addWidget(spin, 1) + levels_in_row.addWidget(spin) self.layout.addLayout(levels_in_row) levels_out_row = QHBoxLayout() + levels_out_row.setContentsMargins(0, 0, 0, 0) + levels_out_row.setSpacing(THEME.space_sm) for label, spin, tip in ( ("Out low", self.levels_out_low_spin, "Lowest output level (0-255): lifts blacks above this, this channel"), ("Out high", self.levels_out_high_spin, "Highest output level (0-255): drops whites below this, this channel"), ): spin.setToolTip(wrap_tooltip(f"{tip}")) levels_out_row.addWidget(field_label(label)) - levels_out_row.addWidget(spin, 1) + levels_out_row.addWidget(spin) self.layout.addLayout(levels_out_row) self.layout.addStretch() From 9749b7e3eecdb031b382862af0a05a9a5c4e1d76 Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 11:52:04 +0200 Subject: [PATCH 04/12] Levels layout changes --- negpy/desktop/view/sidebar/tone.py | 71 +++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index ddb3ce098..7eab8fca3 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -1,6 +1,7 @@ from dataclasses import replace -from PyQt6.QtWidgets import QAbstractSpinBox, QComboBox, QDialog, QDoubleSpinBox, QHBoxLayout, QSpinBox, QStyle, QVBoxLayout +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QAbstractSpinBox, QComboBox, QDialog, QDoubleSpinBox, QGridLayout, QHBoxLayout, QSpinBox, QStyle, QVBoxLayout, QWidget from negpy.desktop.auto_sliders import shown_values, stored_value from negpy.desktop.view.shortcut_registry import tooltip_with_shortcut @@ -19,6 +20,17 @@ _LEVELS_SUFFIX = ("", "_red", "_green", "_blue") _LEVELS_LABELS = ("Value", "Red", "Green", "Blue") + +def _levels_pair(label: str, spin: QWidget) -> QWidget: + """One label + entry packed tight, for the left/center/right levels rows.""" + box = QWidget() + row = QHBoxLayout(box) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(THEME.space_sm) + row.addWidget(field_label(label)) + row.addWidget(spin) + return box + _ISO_R_MIN = float(EXPOSURE_CONSTANTS["iso_r_min"]) _ISO_R_MAX = float(EXPOSURE_CONSTANTS["iso_r_max"]) @@ -294,29 +306,48 @@ def _init_ui(self) -> None: text = spin.fontMetrics().horizontalAdvance(sample) frame = 2 * spin.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth) spin.setFixedWidth(text + frame + 4) - levels_in_row = QHBoxLayout() - levels_in_row.setContentsMargins(0, 0, 0, 0) - levels_in_row.setSpacing(THEME.space_sm) - for label, spin, tip in ( - ("Low", self.levels_in_low_spin, "Input shadows below this print at output low (0-255)"), - ("Mid", self.levels_gamma_spin, "Input midtone gamma (0.10-10.00): 1.00 is linear, higher holds more highlights"), - ("High", self.levels_in_high_spin, "Input highlights above this print at output high (0-255)"), + # Left/center/right under the histogram: equal columns pin Low left, Mid + # center and High right whatever the label widths are. + levels_in_grid = QGridLayout() + levels_in_grid.setContentsMargins(0, 0, 0, 0) + levels_in_grid.setHorizontalSpacing(THEME.space_sm) + levels_in_grid.setVerticalSpacing(0) + for col, (label, spin, tip, align) in enumerate( + ( + ("Low", self.levels_in_low_spin, "Input shadows below this print at output low (0-255)", Qt.AlignmentFlag.AlignLeft), + ( + "Mid", + self.levels_gamma_spin, + "Input midtone gamma (0.10-10.00): 1.00 is linear, higher holds more highlights", + Qt.AlignmentFlag.AlignHCenter, + ), + ("High", self.levels_in_high_spin, "Input highlights above this print at output high (0-255)", Qt.AlignmentFlag.AlignRight), + ) ): spin.setToolTip(wrap_tooltip(f"{tip}, this channel")) - levels_in_row.addWidget(field_label(label)) - levels_in_row.addWidget(spin) - self.layout.addLayout(levels_in_row) - levels_out_row = QHBoxLayout() - levels_out_row.setContentsMargins(0, 0, 0, 0) - levels_out_row.setSpacing(THEME.space_sm) - for label, spin, tip in ( - ("Out low", self.levels_out_low_spin, "Lowest output level (0-255): lifts blacks above this, this channel"), - ("Out high", self.levels_out_high_spin, "Highest output level (0-255): drops whites below this, this channel"), + levels_in_grid.setColumnStretch(col, 1) + levels_in_grid.addWidget(_levels_pair(label, spin), 0, col, align) + self.layout.addLayout(levels_in_grid) + # Output pair under the output bar's own ends. + levels_out_grid = QGridLayout() + levels_out_grid.setContentsMargins(0, 0, 0, 0) + levels_out_grid.setHorizontalSpacing(THEME.space_sm) + levels_out_grid.setVerticalSpacing(0) + for col, (label, spin, tip, align) in enumerate( + ( + ("Out low", self.levels_out_low_spin, "Lowest output level (0-255): lifts blacks above this, this channel", Qt.AlignmentFlag.AlignLeft), + ( + "Out high", + self.levels_out_high_spin, + "Highest output level (0-255): drops whites below this, this channel", + Qt.AlignmentFlag.AlignRight, + ), + ) ): spin.setToolTip(wrap_tooltip(f"{tip}")) - levels_out_row.addWidget(field_label(label)) - levels_out_row.addWidget(spin) - self.layout.addLayout(levels_out_row) + levels_out_grid.setColumnStretch(col, 1) + levels_out_grid.addWidget(_levels_pair(label, spin), 0, col, align) + self.layout.addLayout(levels_out_grid) self.layout.addStretch() From a0921c487850e6301da3706bd0dcb1f186f017f0 Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 11:56:18 +0200 Subject: [PATCH 05/12] Levels layout changes --- negpy/desktop/view/sidebar/tone.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 7eab8fca3..583f8951a 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -1,12 +1,12 @@ from dataclasses import replace from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QAbstractSpinBox, QComboBox, QDialog, QDoubleSpinBox, QGridLayout, QHBoxLayout, QSpinBox, QStyle, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QAbstractSpinBox, QComboBox, QDialog, QDoubleSpinBox, QGridLayout, QHBoxLayout, QLabel, QSpinBox, QStyle, QVBoxLayout, QWidget from negpy.desktop.auto_sliders import shown_values, stored_value from negpy.desktop.view.shortcut_registry import tooltip_with_shortcut from negpy.desktop.view.sidebar.base import BaseSidebar -from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, field_label, header_row, hint_label, section_subheader, wrap_tooltip +from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, field_label_qss, header_row, hint_label, section_subheader, wrap_tooltip from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.charts import LevelsHistogramWidget from negpy.desktop.view.widgets.choice_button import ChoiceButton, ToggleMenuButton @@ -22,12 +22,19 @@ def _levels_pair(label: str, spin: QWidget) -> QWidget: - """One label + entry packed tight, for the left/center/right levels rows.""" + """One label + entry packed tight, for the left/center/right levels rows. + + A plain QLabel, not a field_label: align_slider_columns widens every + FieldLabel to the slider label column and reserves the value column on its + row, which blew these inline pairs wide and squeezed the entries. + """ box = QWidget() row = QHBoxLayout(box) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(THEME.space_sm) - row.addWidget(field_label(label)) + caption = QLabel(label) + caption.setStyleSheet(field_label_qss()) + row.addWidget(caption) row.addWidget(spin) return box @@ -305,7 +312,8 @@ def _init_ui(self) -> None: spin.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) text = spin.fontMetrics().horizontalAdvance(sample) frame = 2 * spin.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth) - spin.setFixedWidth(text + frame + 4) + # QSS padding (4px) and border (1px) each side on top of the frame. + spin.setFixedWidth(text + frame + 12) # Left/center/right under the histogram: equal columns pin Low left, Mid # center and High right whatever the label widths are. levels_in_grid = QGridLayout() From 19444033a846d60a02835a66fb027011f726db63 Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 11:59:37 +0200 Subject: [PATCH 06/12] Renamed channel from value to global --- docs/PIPELINE.md | 2 +- docs/USER_GUIDE.md | 2 +- negpy/desktop/settings_catalog.py | 2 +- negpy/desktop/view/sidebar/tone.py | 6 +++--- negpy/desktop/view/widgets/charts.py | 2 +- negpy/features/exposure/levels.py | 10 +++++----- negpy/features/exposure/models.py | 2 +- negpy/features/exposure/shaders/levels.wgsl | 2 +- negpy/services/rendering/gpu_engine.py | 2 +- tests/test_levels.py | 2 +- 10 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index f8b31f121..483466f6e 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -552,7 +552,7 @@ Post-crop print finishing in scene-linear, before the output transform. Order: e ## 10. Levels **Code**: `negpy.features.exposure.levels` (CPU) / `negpy.features.exposure.shaders.levels.wgsl` (GPU) -GIMP-style fine-tuning on the display-encoded output, after the output transform and every creative stage. Four channels — the Value master, then Red, Green, Blue — each mapping an input window onto an output window through a gamma: +GIMP-style fine-tuning on the display-encoded output, after the output transform and every creative stage. Four channels — the Global master, then Red, Green, Blue — each mapping an input window onto an output window through a gamma: $$t = \mathrm{clamp}\!\left(\frac{x - l}{h - l},\ 0,\ 1\right), \qquad t \leftarrow t^{1/\gamma}, \qquad I_{out} = o_l + (o_h - o_l)\,t$$ diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 51e973601..71f434de2 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -452,7 +452,7 @@ In R/G/B mode these become per-layer trims: **Grade** (±30 ISO-R), **Toe** / ** **Levels**, last on the panel and last in the pipeline: -* **Value / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Value is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. +* **Global / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Global is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. ### 5.3 Dodge & Burn: local exposure diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index 8ff8123b2..5fa3938c4 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -176,7 +176,7 @@ def _fmt_gear(values: tuple) -> str: _row("Contrast Mask", "exposure", "contrast_mask"), _row("Mask Spacer", "exposure", "mask_spacer"), _row("Preflash", "exposure", "preflash"), - _row("Levels Value", "exposure", "levels_in_low", "levels_gamma", "levels_in_high", "levels_out_low", "levels_out_high"), + _row("Levels Global", "exposure", "levels_in_low", "levels_gamma", "levels_in_high", "levels_out_low", "levels_out_high"), _row( "Levels Red", "exposure", diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 583f8951a..9fc5d41db 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -18,7 +18,7 @@ from negpy.features.exposure.models import EXPOSURE_CONSTANTS, TUNABLE_TARGETS, apply_targets _LEVELS_SUFFIX = ("", "_red", "_green", "_blue") -_LEVELS_LABELS = ("Value", "Red", "Green", "Blue") +_LEVELS_LABELS = ("Global", "Red", "Green", "Blue") def _levels_pair(label: str, spin: QWidget) -> QWidget: @@ -274,7 +274,7 @@ def _init_ui(self) -> None: self.levels_combo.addItem(label) self.levels_combo.setToolTip( wrap_tooltip( - "Levels channel: Value edits the master curve on all channels, Red, Green " + "Levels channel: Global edits the master curve on all channels, Red, Green " "and Blue trim one channel on top of it" ) ) @@ -685,7 +685,7 @@ def sync_ui(self) -> None: if is_bw: self.ch_btn.setCurrentIndex(0) self.ch_btn.setVisible(not is_bw) - # Levels keeps its own channel selector; B&W has only the Value master. + # Levels keeps its own channel selector; B&W has only the Global master. if is_bw: self.levels_combo.setCurrentIndex(0) self.levels_combo.setVisible(not is_bw) diff --git a/negpy/desktop/view/widgets/charts.py b/negpy/desktop/view/widgets/charts.py index dc78910fe..ef77b4659 100644 --- a/negpy/desktop/view/widgets/charts.py +++ b/negpy/desktop/view/widgets/charts.py @@ -1079,7 +1079,7 @@ def __init__(self, parent=None): self._drag: str | None = None def set_data(self, buffer: Any, channel: int = 0) -> None: - """Feed a (4, 256) [R, G, B, L] histogram; Value reads the L row.""" + """Feed a (4, 256) [R, G, B, L] histogram; Global reads the L row.""" self._channel = int(channel) row = None if isinstance(buffer, np.ndarray) and buffer.shape == (4, 256): diff --git a/negpy/features/exposure/levels.py b/negpy/features/exposure/levels.py index a25c67d46..c1405ec11 100644 --- a/negpy/features/exposure/levels.py +++ b/negpy/features/exposure/levels.py @@ -1,6 +1,6 @@ """Display-referred levels, GIMP-style, as the last pipeline step. -Each of the four channels (the Value master, then Red, Green, Blue) carries its +Each of the four channels (the Global master, then Red, Green, Blue) carries its own input low/gamma/high and output low/high. The master applies equally to all three channels first, then each per-channel curve trims on top. GIMP applies ``pow(t, 1/gamma)``; higher gamma holds more high-level intensities. @@ -13,7 +13,7 @@ from negpy.domain.types import ImageBuffer from negpy.kernel.image.validation import ensure_image -LEVELS_CHANNELS = ("value", "red", "green", "blue") +LEVELS_CHANNELS = ("global", "red", "green", "blue") LEVELS_IN_MIN = 0 LEVELS_IN_MAX = 255 @@ -23,7 +23,7 @@ def _channel_fields(channel: str) -> tuple[str, str, str, str, str]: """(in_low, gamma, in_high, out_low, out_high) field names for `channel`.""" - suffix = "" if channel == "value" else f"_{channel}" + suffix = "" if channel == "global" else f"_{channel}" return ( f"levels_in_low{suffix}", f"levels_gamma{suffix}", @@ -78,7 +78,7 @@ def without_levels(config: Any) -> Any: def uniform_rows(config: Any) -> tuple[tuple[float, float, float, float], ...]: - """Five vec4s over (value, red, green, blue): input low/high, inverse gamma, + """Five vec4s over (global, red, green, blue): input low/high, inverse gamma, output low and output span, all normalized. Single source for the GPU pack.""" lo, hi, inv, olo, orange = [], [], [], [], [] for channel in LEVELS_CHANNELS: @@ -126,7 +126,7 @@ def apply_levels(image: ImageBuffer, config: Any) -> ImageBuffer: mono = arr.ndim == 2 img = arr[..., None] if mono else arr res = img.astype(np.float32, copy=True) - master = channel_levels(config, "value") + master = channel_levels(config, "global") if master != (0.0, 1.0, 255.0, 0.0, 255.0): for c in range(res.shape[-1]): res[..., c] = _remap_plane(res[..., c], *master) diff --git a/negpy/features/exposure/models.py b/negpy/features/exposure/models.py index fe5fb477b..a1a53738f 100644 --- a/negpy/features/exposure/models.py +++ b/negpy/features/exposure/models.py @@ -104,7 +104,7 @@ class ExposureConfig: # logic.separation_damping_gain). Inert at dye_separation 1.0: it only # redistributes that slider's push. separation_damping: float = 0.0 - # Display-referred levels (see levels.py), the last pipeline step: the Value + # Display-referred levels (see levels.py), the last pipeline step: the Global # master first, then one curve per channel. In/out bounds are 0-255 ints, # gamma 0.1-10.0 at 1.0. Identity by default, so an untouched edit renders # exactly as before. diff --git a/negpy/features/exposure/shaders/levels.wgsl b/negpy/features/exposure/shaders/levels.wgsl index 40e579444..220aba675 100644 --- a/negpy/features/exposure/shaders/levels.wgsl +++ b/negpy/features/exposure/shaders/levels.wgsl @@ -1,6 +1,6 @@ // Display-referred levels, the last pipeline step; mirrors levels.py::apply_levels. // -// The buffer is already display-encoded (output_encode ran). The Value master +// The buffer is already display-encoded (output_encode ran). The Global master // applies equally to all three channels first, then each per-channel curve. // Uniforms carry normalized bounds over (value, red, green, blue); gamma rides // inverted, as the pow exponent. diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index dcfc68ac0..5c440ae47 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -2100,7 +2100,7 @@ def _upload_unified_uniforms( # ROI offset + crop dims for the density-histogram pass (tex_norm is uncropped). dh_data = struct.pack("IIII", crop_offset[0], crop_offset[1], crop_w, crop_h) - # Display-referred levels, over (value, red, green, blue); mirrors levels.py. + # Display-referred levels, over (global, red, green, blue); mirrors levels.py. from negpy.features.exposure.levels import uniform_rows _lv_lo, _lv_hi, _lv_inv, _lv_olo, _lv_ospan = uniform_rows(settings.exposure) diff --git a/tests/test_levels.py b/tests/test_levels.py index da73f828c..7d4bd7104 100644 --- a/tests/test_levels.py +++ b/tests/test_levels.py @@ -86,7 +86,7 @@ def test_config_values_are_clamped(self) -> None: levels_out_low=-5, levels_out_high=9999, ) - lo, gamma, hi, olo, ohi = channel_levels(conf, "value") + lo, gamma, hi, olo, ohi = channel_levels(conf, "global") self.assertEqual((lo, hi, olo, ohi), (255.0, 0.0, 0.0, 255.0)) self.assertEqual(gamma, 0.1) out = np.asarray(apply_levels(_gray(0.0, 0.5, 1.0), conf)) From d56f2e15fbb8fb20a8ba311b29cbcaf00c55682b Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 16:08:42 +0200 Subject: [PATCH 07/12] auto levels button --- docs/PIPELINE.md | 2 + docs/USER_GUIDE.md | 2 +- negpy/desktop/view/keyboard_shortcuts.py | 1 + negpy/desktop/view/shortcut_registry.py | 1 + negpy/desktop/view/sidebar/controls_panel.py | 7 +++ negpy/desktop/view/sidebar/tone.py | 25 ++++++++- negpy/features/exposure/levels.py | 35 +++++++++++++ tests/test_levels.py | 54 +++++++++++++++++++- 8 files changed, 124 insertions(+), 3 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 483466f6e..4498e1892 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -558,3 +558,5 @@ $$t = \mathrm{clamp}\!\left(\frac{x - l}{h - l},\ 0,\ 1\right), \qquad t \leftar Bounds are 0-255 code values, $\gamma$ is 0.1 to 10.0 at 1.0. The master applies equally to all three channels first, then each per-channel curve trims on top. A degenerate window ($h \le l$) thresholds at the low marker. Identity by default, so an untouched edit renders exactly as before. Skipped under the Flat intent, which never encodes. +**Auto** (`auto_input_window`) stretches one channel like GIMP's Auto Input Levels: gamma 1, full output range, input bounds at the first bin past a 0.6% tail each end, identity on a degenerate frame. + diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 71f434de2..7cd729fdc 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -452,7 +452,7 @@ In R/G/B mode these become per-layer trims: **Grade** (±30 ISO-R), **Toe** / ** **Levels**, last on the panel and last in the pipeline: -* **Global / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Global is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. +* **Global / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Global is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. **Auto** (wand, in the LEVELS header) stretches the shown channel like GIMP's Auto Input Levels: gamma back to 1.00, full output range, input window at the 0.6% clip points — on Global it reads luminance, so hues don't move. ### 5.3 Dodge & Burn: local exposure diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index bf6b02063..d1d5a2540 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -448,6 +448,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "toggle_auto_density": controls.tone_sidebar.auto_density_action.trigger, "toggle_auto_grade": controls.tone_sidebar.auto_grade_action.trigger, "toggle_auto_both": controls.tone_sidebar.auto_both_action.trigger, + "auto_levels": lambda: controls.tone_sidebar.levels_auto_btn.click(), "preset_apply": controls.presets_sidebar.apply_btn.click, "preset_save": controls.presets_sidebar.save_btn.click, } diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index b7b46c88f..531f86d6e 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -83,6 +83,7 @@ class ShortcutEntry: "toggle_auto_density": ShortcutEntry("", "Toggle Auto Density", "Exposure"), "toggle_auto_grade": ShortcutEntry("", "Toggle Auto Grade", "Exposure"), "toggle_auto_both": ShortcutEntry("", "Toggle Auto Density and Auto Grade together", "Exposure"), + "auto_levels": ShortcutEntry("", "Auto levels on the selected channel", "Exposure"), "preset_apply": ShortcutEntry("", "Apply the selected preset", "Actions"), "preset_save": ShortcutEntry("", "Save a preset from the current settings", "Actions"), "toggle_test_strip": ShortcutEntry("Shift+T", "Density × grade test strip", "Tools"), diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index 64f5e5bac..d30a66557 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -770,6 +770,13 @@ def apply_shortcut_tooltips(self) -> None: ["preflash_inc", "preflash_dec"], ) ) + exp.levels_auto_btn.setToolTip( + tooltip_with_shortcut( + "Auto Levels: stretch this channel's input range onto the output range at the 0.6% clip points, " + "like GIMP's Auto Input Levels. With Global selected the stretch reads luminance, so hues don't move", + "auto_levels", + ) + ) lab.clahe_slider.setToolTip( tooltip_with_shortcut( "Local contrast (CLAHE) without blowing global highlights or crushing shadows. Use sparingly — near 1.0 can look cartoonish", diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 9fc5d41db..ff9326a93 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -1,5 +1,6 @@ from dataclasses import replace +import numpy as np from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QAbstractSpinBox, QComboBox, QDialog, QDoubleSpinBox, QGridLayout, QHBoxLayout, QLabel, QSpinBox, QStyle, QVBoxLayout, QWidget @@ -268,7 +269,12 @@ def _init_ui(self) -> None: "range through a gamma; higher gamma holds more high-level intensities." ) ) - self.layout.addWidget(levels_header) + self.levels_auto_btn = self._icon_action( + "fa5s.magic", + "Auto Levels: stretch this channel's input range onto the output range at the 0.6% clip points, " + "like GIMP's Auto Input Levels. With Global selected the stretch reads luminance, so hues don't move", + ) + self.layout.addLayout(header_row(levels_header, self.levels_auto_btn)) self.levels_combo = QComboBox() for label in _LEVELS_LABELS: self.levels_combo.addItem(label) @@ -448,6 +454,21 @@ def _on_levels_spins(self, persist: bool) -> None: def _reset_levels_channel(self) -> None: self._write_levels(True, in_low=0, gamma=1.0, in_high=255, out_low=0, out_high=255) + def _auto_levels(self) -> None: + """GIMP Auto Input Levels on the shown channel: gamma 1, full output range, + input window at the 0.6% clip points of the input histogram the panel draws.""" + from negpy.features.exposure.levels import auto_channel_levels + + metrics = self.controller.state.last_metrics + buf = metrics.get("levels_input_histogram") + if buf is None: + buf = metrics.get("histogram_raw") + if buf is None: + return + ch = self._levels_channel() + lo, gamma, hi, olo, ohi = auto_channel_levels(np.asarray(buf)[3 if ch == 0 else ch - 1]) + self._write_levels(True, in_low=lo, gamma=gamma, in_high=hi, out_low=olo, out_high=ohi) + def _sync_levels_histogram(self) -> None: metrics = self.controller.state.last_metrics buf = metrics.get("levels_input_histogram") @@ -512,6 +533,7 @@ def _connect_signals(self) -> None: self.levels_hist.outputChanged.connect(lambda olo, ohi: self._on_levels_output(olo, ohi, False)) self.levels_hist.outputCommitted.connect(lambda olo, ohi: self._on_levels_output(olo, ohi, True)) self.levels_hist.resetRequested.connect(self._reset_levels_channel) + self.levels_auto_btn.clicked.connect(self._auto_levels) for spin in ( self.levels_in_low_spin, self.levels_gamma_spin, @@ -781,6 +803,7 @@ def block_signals(self, blocked: bool) -> None: self.ch_btn, self.levels_combo, self.levels_hist, + self.levels_auto_btn, self.levels_in_low_spin, self.levels_gamma_spin, self.levels_in_high_spin, diff --git a/negpy/features/exposure/levels.py b/negpy/features/exposure/levels.py index c1405ec11..ab7b6326a 100644 --- a/negpy/features/exposure/levels.py +++ b/negpy/features/exposure/levels.py @@ -19,6 +19,8 @@ LEVELS_IN_MAX = 255 LEVELS_GAMMA_MIN = 0.1 LEVELS_GAMMA_MAX = 10.0 +# Share of pixels clipped at each end by Auto Input Levels, as in GIMP. +LEVELS_AUTO_CLIP = 0.006 def _channel_fields(channel: str) -> tuple[str, str, str, str, str]: @@ -58,6 +60,39 @@ def levels_active(config: Any) -> bool: return False +def auto_input_window(counts: Any) -> tuple[int, int]: + """GIMP Auto Input Levels for one 256-bin input histogram: the input bounds + at the first bin past a 0.6% tail each end. A degenerate frame falls back to + identity rather than posterizing on a threshold.""" + hist = np.asarray(counts, dtype=np.float64).ravel() + total = float(hist.sum()) + if total <= 0.0 or hist.shape[0] < 256: + return 0, 255 + lo, hi = 0, 255 + acc = 0.0 + for i in range(255): + acc += float(hist[i]) + if abs(acc / total - LEVELS_AUTO_CLIP) < abs((acc + float(hist[i + 1])) / total - LEVELS_AUTO_CLIP): + lo = i + 1 + break + acc = 0.0 + for i in range(255, 0, -1): + acc += float(hist[i]) + if abs(acc / total - LEVELS_AUTO_CLIP) < abs((acc + float(hist[i - 1])) / total - LEVELS_AUTO_CLIP): + hi = i - 1 + break + if hi <= lo: + return 0, 255 + return lo, hi + + +def auto_channel_levels(counts: Any) -> tuple[int, float, int, int, int]: + """One channel's Auto Input Levels: gamma 1 and the full output range over + the auto input window, as GIMP resets the other terms on Auto.""" + lo, hi = auto_input_window(counts) + return (lo, 1.0, hi, 0, 255) + + def without_levels(config: Any) -> Any: """`config` with every levels field at its default, so a levels-only edit re-runs just the final stage instead of the exposure cache behind it.""" diff --git a/tests/test_levels.py b/tests/test_levels.py index 7d4bd7104..250f249ac 100644 --- a/tests/test_levels.py +++ b/tests/test_levels.py @@ -10,7 +10,15 @@ import numpy as np from negpy.domain.models import WorkspaceConfig, flat_master_config -from negpy.features.exposure.levels import apply_levels, channel_levels, levels_active, levels_fields, uniform_rows +from negpy.features.exposure.levels import ( + apply_levels, + auto_channel_levels, + auto_input_window, + channel_levels, + levels_active, + levels_fields, + uniform_rows, +) from negpy.features.exposure.models import ExposureConfig from negpy.infrastructure.gpu.device import GPUDevice @@ -136,6 +144,27 @@ def test_levels_move_the_render_and_publish_the_input_histogram(self) -> None: assert hist is not None self.assertEqual(np.asarray(hist).shape, (4, 256)) + def test_auto_levels_on_render_histogram(self) -> None: + base = WorkspaceConfig() + # levels_input_histogram is the pre-levels input, published whenever + # levels are active; seed them so the key exists. + seeded = replace(base, exposure=replace(base.exposure, levels_gamma=1.5)) + _, metrics = self._render(seeded) + hist = metrics.get("levels_input_histogram") + self.assertIsNotNone(hist) + assert hist is not None + lo, gamma, hi, olo, ohi = auto_channel_levels(np.asarray(hist)[3]) + self.assertLessEqual(lo, hi) + auto_cfg = replace( + base, + exposure=replace( + base.exposure, levels_in_low=lo, levels_gamma=gamma, levels_in_high=hi, levels_out_low=olo, levels_out_high=ohi + ), + ) + auto, _ = self._render(auto_cfg) + plain, _ = self._render(base) + self.assertGreater(float(np.abs(auto - plain).max()), 1e-4) + def test_no_histogram_key_without_levels(self) -> None: _, metrics = self._render(WorkspaceConfig()) self.assertNotIn("levels_input_histogram", metrics) @@ -147,6 +176,29 @@ def test_flat_master_bypasses_levels(self) -> None: np.testing.assert_array_equal(with_levels, plain) +class TestAutoLevels(unittest.TestCase): + def test_uniform_histogram_stretches_one_bin_each_end(self) -> None: + # 0.6% of a flat 256-bin histogram lands just inside each edge. + self.assertEqual(auto_input_window(np.ones(256)), (2, 253)) + + def test_tails_found_at_the_cliff(self) -> None: + counts = np.zeros(256) + counts[:10] = 100.0 + self.assertEqual(auto_input_window(counts), (1, 9)) + + def test_single_bin_falls_back_to_identity(self) -> None: + counts = np.zeros(256) + counts[128] = 1000.0 + self.assertEqual(auto_input_window(counts), (0, 255)) + + def test_empty_histogram_falls_back_to_identity(self) -> None: + self.assertEqual(auto_input_window(np.zeros(256)), (0, 255)) + + def test_auto_resets_gamma_and_output_range(self) -> None: + lo, gamma, hi, olo, ohi = auto_channel_levels(np.ones(256)) + self.assertEqual((lo, gamma, hi, olo, ohi), (2, 1.0, 253, 0, 255)) + + @unittest.skipUnless(GPUDevice.get().is_available, "GPU not available") class TestGpuLevelsParity(unittest.TestCase): def _render(self, processor, settings, img, prefer_gpu): From 400633b7dd9be94df984d97fa4587dcb8f65a61a Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 16:29:21 +0200 Subject: [PATCH 08/12] curves control --- CLAUDE.md | 2 +- docs/PIPELINE.md | 5 + docs/USER_GUIDE.md | 2 + negpy/desktop/settings_catalog.py | 5 + negpy/desktop/view/sidebar/tone.py | 81 ++++++++- negpy/desktop/view/widgets/charts.py | 166 ++++++++++++++++++ negpy/features/exposure/curves.py | 158 +++++++++++++++++ negpy/features/exposure/models.py | 36 ++++ negpy/features/exposure/shaders/curves.wgsl | 40 +++++ negpy/services/rendering/engine.py | 17 +- negpy/services/rendering/gpu_engine.py | 51 +++++- tests/test_curves.py | 181 ++++++++++++++++++++ tests/test_slide_separation.py | 7 +- tests/thumbnail_fingerprint_defaults.json | 32 ++++ 14 files changed, 770 insertions(+), 13 deletions(-) create mode 100644 negpy/features/exposure/curves.py create mode 100644 negpy/features/exposure/shaders/curves.wgsl create mode 100644 tests/test_curves.py diff --git a/CLAUDE.md b/CLAUDE.md index 43917dd21..297733ec0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ Migrations that rewrite *rows* rather than a config payload need a repository, s ### Pipeline -- **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`) — base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish. The first four stages are cached per config-hash via `_run_stage()`; the rest run unconditionally. The base and exposure stages route on `process.path.render_path` (`base_processor` / `exposure_processor` in `engine.py`): `PRINT` runs the negative's processors in `features/exposure/`, `TRANSFER` and `POSITIVE` (a slide as captured, a Positive frame) run `features/transparency/`. The negative's processors hold no slide branch, and `features/exposure/` never imports `features/transparency/` (`tests/test_slide_separation.py`). The alt-process stage (lith or cyanotype, never both) is B&W-only and off by default; when off, both engines skip it rather than run an identity pass. +- **CPU**: `DarkroomEngine.process()` (`negpy/services/rendering/engine.py`) — base (geometry + normalization) → exposure (incl. dodge/burn) → clahe → lab → alt process → toning → crop → finish → output transform → levels → curves. The first four stages are cached per config-hash via `_run_stage()`; the rest run unconditionally. The base and exposure stages route on `process.path.render_path` (`base_processor` / `exposure_processor` in `engine.py`): `PRINT` runs the negative's processors in `features/exposure/`, `TRANSFER` and `POSITIVE` (a slide as captured, a Positive frame) run `features/transparency/`. The negative's processors hold no slide branch, and `features/exposure/` never imports `features/transparency/` (`tests/test_slide_separation.py`). The alt-process stage (lith or cyanotype, never both) is B&W-only and off by default; when off, both engines skip it rather than run an identity pass. - **GPU**: `GPUEngine` (`negpy/services/rendering/gpu_engine.py`) — the same logical stages as WGSL compute shaders from `negpy/features//shaders/`, with its own config-diff change detection. - **Orchestration**: `ImageProcessor` (`image_processor.py`) tries GPU first and falls back to CPU. Export always runs full-res, with CPU stage caching off (`PipelineContext.cache_stages`). Linear DNG decode, CPU saturation and unsharp masking use row blocks to bound temporary storage. `PipelineContext` carries `scale_factor`, `process_mode`, `active_roi` and a `metrics` dict between stages. - **Embedded lens correction** (`features/lens`) is a single-file decode step shared by preview and export: flat-field, lens warp, then sensor unmix and user geometry. Its independent distortion and CA settings and flat-field token belong to the source identity. It is disabled for composite setup, composite assembly and RGB+IR sources. diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 4498e1892..5f4ac8df1 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -560,3 +560,8 @@ Bounds are 0-255 code values, $\gamma$ is 0.1 to 10.0 at 1.0. The master applies **Auto** (`auto_input_window`) stretches one channel like GIMP's Auto Input Levels: gamma 1, full output range, input bounds at the first bin past a 0.6% tail each end, identity on a degenerate frame. +### Curves +**Code**: `negpy.features.exposure.curves` (CPU bake + apply) / `negpy.features.exposure.shaders.curves.wgsl` (GPU) + +Per-channel tone maps on the display-encoded output, after Levels. Four channels — the Global master, then Red, Green, Blue — each holding eight control points as offsets from the identity line. The offsets bake through a monotone cubic (Fritsch-Carlson PCHIP, hand-rolled: no scipy) into a 256-entry table, held monotone so the map never folds; both engines apply the table by integer indexing (`u32(clamp(x*255+0.5))`), so parity holds by construction. Identity lanes are skipped through an active mask: even an identity table would quantize to 8 bits. Skipped under the Flat intent with everything else past the output transform. + diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 7cd729fdc..3acb50c02 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -454,6 +454,8 @@ In R/G/B mode these become per-layer trims: **Grade** (±30 ISO-R), **Toe** / ** * **Global / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Global is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. **Auto** (wand, in the LEVELS header) stretches the shown channel like GIMP's Auto Input Levels: gamma back to 1.00, full output range, input window at the 0.6% clip points — on Global it reads luminance, so hues don't move. +**Curves**, after Levels and last of all: per-channel tone maps over the finished output. **Global / Red / Green / Blue** picks the channel; Global is the master curve, the others trim one channel on top. Each channel shows its input histogram with its curve: eight nodes, dragged vertically (they clamp against their neighbours, so the curve never folds); double-click resets the channel. + ### 5.3 Dodge & Burn: local exposure diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index 5fa3938c4..bcf8cee1a 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -204,6 +204,10 @@ def _fmt_gear(values: tuple) -> str: "levels_out_low_blue", "levels_out_high_blue", ), + _row("Curves Global", "exposure", *[f"curve_{i}" for i in range(8)]), + _row("Curves Red", "exposure", *[f"curve_{i}_red" for i in range(8)]), + _row("Curves Green", "exposure", *[f"curve_{i}_green" for i in range(8)]), + _row("Curves Blue", "exposure", *[f"curve_{i}_blue" for i in range(8)]), _row("Auto Density", "exposure", "auto_exposure", sticky=True), _row("Auto Grade", "exposure", "auto_normalize_contrast", sticky=True), _row("Paper Profile", "exposure", "paper_profile", sticky=True), @@ -480,6 +484,7 @@ def rows_for_section(section: str) -> list[SettingRow]: "levels_in_high_blue", "levels_out_low_blue", "levels_out_high_blue", + *[f"curve_{i}{sfx}" for sfx in ("", "_red", "_green", "_blue") for i in range(8)], ) # Frame cards whose settings can be pushed to other frames, and the fields each owns. A diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index ff9326a93..eb4287ad6 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -9,7 +9,7 @@ from negpy.desktop.view.sidebar.base import BaseSidebar from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, field_label_qss, header_row, hint_label, section_subheader, wrap_tooltip from negpy.desktop.view.styles.theme import THEME -from negpy.desktop.view.widgets.charts import LevelsHistogramWidget +from negpy.desktop.view.widgets.charts import LevelsHistogramWidget, CurvesWidget from negpy.desktop.view.widgets.choice_button import ChoiceButton, ToggleMenuButton from negpy.desktop.view.widgets.sliders import CompactSlider, SliderGroup from negpy.features.exposure.levels import LEVELS_CHANNELS @@ -18,7 +18,7 @@ from negpy.features.exposure.auto_sliders import NEUTRAL from negpy.features.exposure.models import EXPOSURE_CONSTANTS, TUNABLE_TARGETS, apply_targets -_LEVELS_SUFFIX = ("", "_red", "_green", "_blue") +_CHANNEL_SUFFIX = ("", "_red", "_green", "_blue") _LEVELS_LABELS = ("Global", "Red", "Green", "Blue") @@ -363,6 +363,34 @@ def _init_ui(self) -> None: levels_out_grid.addWidget(_levels_pair(label, spin), 0, col, align) self.layout.addLayout(levels_out_grid) + curves_header = section_subheader("CURVES") + curves_header.setToolTip( + wrap_tooltip( + "Display-referred curves, GIMP-style, applied after Levels as the last step. " + "Each channel holds eight nodes; drag one vertically to reshape the tone map. " + "Nodes clamp against their neighbours, so the curve can never fold." + ) + ) + self.layout.addWidget(curves_header) + self.curves_combo = QComboBox() + for label in _LEVELS_LABELS: + self.curves_combo.addItem(label) + self.curves_combo.setToolTip( + wrap_tooltip( + "Curves channel: Global edits the master curve on all channels, Red, Green " + "and Blue trim one channel on top of it" + ) + ) + self.layout.addWidget(self.curves_combo) + self.curves_widget = CurvesWidget() + self.curves_widget.setToolTip( + wrap_tooltip( + "The channel's input histogram with its tone curve. Drag a node vertically; " + "double-click resets the channel." + ) + ) + self.layout.addWidget(self.curves_widget) + self.layout.addStretch() # Global-only controls, greyed while a channel page is active. @@ -416,7 +444,7 @@ def _levels_values(self, conf) -> tuple: return int(lo), float(gamma), int(hi), int(olo), int(ohi) def _write_levels(self, persist: bool, **values) -> None: - fields = {f"levels_{name}{_LEVELS_SUFFIX[self._levels_channel()]}": v for name, v in values.items()} + fields = {f"levels_{name}{_CHANNEL_SUFFIX[self._levels_channel()]}": v for name, v in values.items()} self.update_config_section("exposure", render=True, persist=persist, readback_metrics=True, **fields) def _show_levels(self, lo: int, gamma: float, hi: int, olo: int, ohi: int) -> None: @@ -476,6 +504,40 @@ def _sync_levels_histogram(self) -> None: buf = metrics.get("histogram_raw") self.levels_hist.set_data(buf, self._levels_channel()) + def _curves_channel(self) -> int: + return max(0, min(3, self.curves_combo.currentIndex())) + + def _curves_outputs(self, conf) -> tuple: + from negpy.features.exposure.curves import grid_outputs + + return grid_outputs(conf, LEVELS_CHANNELS[self._curves_channel()]) + + def _write_curves(self, persist: bool, outputs) -> None: + from negpy.features.exposure.curves import CURVE_NODES + + sfx = _CHANNEL_SUFFIX[self._curves_channel()] + fields = {f"curve_{i}{sfx}": float(outputs[i]) - i * 255.0 / (CURVE_NODES - 1) for i in range(CURVE_NODES)} + self.update_config_section("exposure", render=True, persist=persist, readback_metrics=True, **fields) + + def _show_curves(self, outputs) -> None: + self.curves_widget.set_nodes(outputs) + + def _on_curves(self, outputs, persist: bool) -> None: + self._show_curves(outputs) + self._write_curves(persist, outputs) + + def _reset_curves_channel(self) -> None: + from negpy.features.exposure.curves import CURVE_NODES + + self._write_curves(True, [i * 255.0 / (CURVE_NODES - 1) for i in range(CURVE_NODES)]) + + def _sync_curves_histogram(self) -> None: + metrics = self.controller.state.last_metrics + buf = metrics.get("curves_input_histogram") + if buf is None: + buf = metrics.get("histogram_raw") + self.curves_widget.set_data(buf, self._curves_channel()) + def _curve_field(self, base: str) -> str: idx = self._channel_index() return base if idx == 0 else f"{base}_trim_{_CH_SUFFIX[idx - 1]}" @@ -534,6 +596,10 @@ def _connect_signals(self) -> None: self.levels_hist.outputCommitted.connect(lambda olo, ohi: self._on_levels_output(olo, ohi, True)) self.levels_hist.resetRequested.connect(self._reset_levels_channel) self.levels_auto_btn.clicked.connect(self._auto_levels) + self.curves_combo.currentIndexChanged.connect(lambda _i: self.sync_ui()) + self.curves_widget.nodesChanged.connect(lambda nodes: self._on_curves(nodes, False)) + self.curves_widget.nodesCommitted.connect(lambda nodes: self._on_curves(nodes, True)) + self.curves_widget.resetRequested.connect(self._reset_curves_channel) for spin in ( self.levels_in_low_spin, self.levels_gamma_spin, @@ -554,6 +620,7 @@ def _connect_signals(self) -> None: slider.dragEnded.connect(lambda: self.controller.tone_drag_changed.emit("")) self.controller.image_updated.connect(self._sync_driven) self.controller.image_updated.connect(self._sync_levels_histogram) + self.controller.image_updated.connect(self._sync_curves_histogram) for slider, field in ( (self.toe_w_slider, "toe_width"), @@ -714,6 +781,12 @@ def sync_ui(self) -> None: lo, gamma, hi, olo, ohi = self._levels_values(conf) self._show_levels(lo, gamma, hi, olo, ohi) self._sync_levels_histogram() + # Curves keeps its own channel selector; B&W has only the Global master. + if is_bw: + self.curves_combo.setCurrentIndex(0) + self.curves_combo.setVisible(not is_bw) + self._show_curves(self._curves_outputs(conf)) + self._sync_curves_histogram() idx = self._channel_index() global_mode = idx == 0 @@ -804,6 +877,8 @@ def block_signals(self, blocked: bool) -> None: self.levels_combo, self.levels_hist, self.levels_auto_btn, + self.curves_combo, + self.curves_widget, self.levels_in_low_spin, self.levels_gamma_spin, self.levels_in_high_spin, diff --git a/negpy/desktop/view/widgets/charts.py b/negpy/desktop/view/widgets/charts.py index ef77b4659..e8821db2b 100644 --- a/negpy/desktop/view/widgets/charts.py +++ b/negpy/desktop/view/widgets/charts.py @@ -1296,3 +1296,169 @@ def paintEvent(self, event) -> None: painter.setPen(Qt.PenStyle.NoPen) self._triangle(painter, self._x(self._olo, w), out_y + out_h, self._MARK_H - 2, QColor(20, 20, 20), True) self._triangle(painter, self._x(self._ohi, w), out_y + out_h, self._MARK_H - 2, QColor(245, 245, 245), True) + + +class CurvesWidget(QWidget): + """GIMP-style curve editor over the selected channel's input histogram: the + channel's baked curve with one draggable node per fixed grid position. + Nodes move vertically only, clamped between their neighbours so the curve + can never fold. Double-click resets the channel.""" + + nodesChanged = pyqtSignal(list) + nodesCommitted = pyqtSignal(list) + resetRequested = pyqtSignal() + + _HIT_PX = 9 + _NODE_R = 4.5 + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(132) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setMouseTracking(True) + self._counts: list = [] + self._channel = 0 + self._nodes: list = [0, 36, 73, 109, 146, 182, 219, 255] + self._curve: list = [i / 255 for i in range(256)] + self._drag: int | None = None + + def set_data(self, buffer: Any, channel: int = 0) -> None: + """Feed a (4, 256) [R, G, B, L] histogram; Global reads the L row.""" + self._channel = int(channel) + row = None + if isinstance(buffer, np.ndarray) and buffer.shape == (4, 256): + idx = 3 if self._channel == 0 else self._channel - 1 + vals = np.asarray(buffer[idx], dtype=float) + peak = float(vals.max()) + row = (vals / peak).tolist() if peak > 0 else [] + if row != self._counts: + self._counts = row or [] + self.update() + + def set_nodes(self, outputs: Any) -> None: + """Show these node outputs (0-255 per grid position) without emitting.""" + nodes = [min(max(int(round(v)), 0), 255) for v in list(outputs)] + if nodes != self._nodes: + self._nodes = nodes + self._update_curve() + self.update() + + def _update_curve(self) -> None: + from negpy.features.exposure.curves import CURVE_INPUTS, bake_channel_lut + + offsets = [o - x for o, x in zip(self._nodes, CURVE_INPUTS)] + self._curve = (np.asarray(bake_channel_lut(offsets), dtype=float) * 255.0).tolist() + + def _x(self, i: int, w: int) -> float: + return float(i) / 7.0 * max(1, w - 1) + + def _y(self, v: float, hist_h: int) -> float: + return hist_h - float(v) / 255.0 * hist_h + + def _node_at(self, x: float, y: float, w: int, hist_h: int) -> int | None: + near = [ + (i, abs(x - self._x(i, w)) + abs(y - self._y(v, hist_h))) + for i, v in enumerate(self._nodes) + if abs(x - self._x(i, w)) <= self._HIT_PX and abs(y - self._y(v, hist_h)) <= self._HIT_PX + 4 + ] + return min(near, key=lambda t: t[1])[0] if near else None + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + hist_h = self.height() - 12 + hit = self._node_at(event.position().x(), event.position().y(), self.width(), hist_h) + if hit is not None: + self._drag = hit + self.setCursor(Qt.CursorShape.ClosedHandCursor) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: + x, y = event.position().x(), event.position().y() + hist_h = self.height() - 12 + if self._drag is not None: + v = int(round((hist_h - y) / max(1, hist_h) * 255)) + lo = self._nodes[self._drag - 1] if self._drag > 0 else 0 + hi = self._nodes[self._drag + 1] if self._drag < 7 else 255 + self._nodes[self._drag] = min(max(v, lo), hi) + self._update_curve() + self.nodesChanged.emit(list(self._nodes)) + self.update() + event.accept() + return + over = self._node_at(x, y, self.width(), hist_h) is not None + self.setCursor(Qt.CursorShape.PointingHandCursor if over else Qt.CursorShape.ArrowCursor) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: + if self._drag is not None and event.button() == Qt.MouseButton.LeftButton: + self.nodesCommitted.emit(list(self._nodes)) + self._drag = None + self.unsetCursor() + event.accept() + return + super().mouseReleaseEvent(event) + + def mouseDoubleClickEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton: + self.resetRequested.emit() + event.accept() + return + super().mouseDoubleClickEvent(event) + + def paintEvent(self, event) -> None: + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = self.width(), self.height() + painter.fillRect(self.rect(), QColor(THEME.canvas_bg_black)) + painter.setPen(QPen(QColor(THEME.border_primary), 1)) + painter.drawRect(self.rect().adjusted(0, 0, -1, -1)) + if w < 10: + return + hist_h = h - 12 + colors = (THEME.text_secondary, THEME.channel_red, THEME.channel_green, THEME.channel_blue) + base = QColor(colors[self._channel] if 0 <= self._channel < 4 else colors[0]) + + painter.setPen(QPen(QColor(THEME.border_input), 1)) + for i in range(1, 4): + gy = int(i / 4 * hist_h) + painter.drawLine(0, gy, w, gy) + for i in range(8): + gx = int(self._x(i, w)) + painter.drawLine(gx, 0, gx, hist_h) + + if self._counts: + step = w / (len(self._counts) - 1) + path = QPainterPath() + path.moveTo(0, hist_h) + for i, v in enumerate(self._counts): + path.lineTo(i * step, hist_h - v * hist_h) + path.lineTo(w, hist_h) + path.closeSubpath() + fill = QColor(base) + fill.setAlpha(70) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(fill)) + painter.drawPath(path) + + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(QColor(THEME.bg_menu_selected), 1, Qt.PenStyle.DashLine)) + painter.drawLine(0, hist_h, w, 0) + + line = QColor(base) + line.setAlpha(230) + painter.setPen(QPen(QColor(240, 240, 240, 200) if self._channel == 0 else line, 1.5)) + curve_path = QPainterPath() + curve_path.moveTo(0, self._y(self._curve[0], hist_h)) + for i, v in enumerate(self._curve): + curve_path.lineTo(i / 255 * w, self._y(v, hist_h)) + painter.drawPath(curve_path) + + painter.setPen(QPen(QColor(240, 240, 240, 220), 1)) + for i, v in enumerate(self._nodes): + c = QColor(base) + c.setAlpha(255) + painter.setBrush(QBrush(c)) + r = self._NODE_R + (1.5 if i == self._drag else 0.0) + painter.drawEllipse(QPointF(self._x(i, w), self._y(v, hist_h)), r, r) diff --git a/negpy/features/exposure/curves.py b/negpy/features/exposure/curves.py new file mode 100644 index 000000000..e2197aa2c --- /dev/null +++ b/negpy/features/exposure/curves.py @@ -0,0 +1,158 @@ +"""Display-referred curves, GIMP-style, applied after levels as the last step. + +Each of the four channels (the Global master, then Red, Green, Blue) holds a +fixed grid of control points as offsets from the identity line, in code values. +The offsets bake through a monotone cubic (Fritsch-Carlson PCHIP) into a +256-entry table, and both engines apply the table by integer indexing, so CPU +and GPU agree by construction rather than by mirrored math. A fixed grid keeps +the config bounded and every node draggable vertically only, with its +neighbours clamping the drag so the curve can never fold. +""" + +from typing import Any + +import numpy as np + +from negpy.domain.types import ImageBuffer +from negpy.kernel.image.validation import ensure_image + +CURVE_NODES = 8 +CURVE_OFFSET_MIN = -255.0 +CURVE_OFFSET_MAX = 255.0 + +# Fixed node inputs, evenly spaced over the code-value range. +CURVE_INPUTS = tuple(i * 255.0 / (CURVE_NODES - 1) for i in range(CURVE_NODES)) + + +def _node_fields(channel: str) -> tuple[str, ...]: + """Offset field names for `channel`, node 0 (shadows) to node N (highlights).""" + suffix = "" if channel == "global" else f"_{channel}" + return tuple(f"curve_{i}{suffix}" for i in range(CURVE_NODES)) + + +def curves_fields() -> tuple[str, ...]: + """Every ExposureConfig field curves owns, Global first, then R/G/B.""" + from negpy.features.exposure.levels import LEVELS_CHANNELS + + return tuple(f for ch in LEVELS_CHANNELS for f in _node_fields(ch)) + + +def channel_offsets(config: Any, channel: str) -> tuple[float, ...]: + """Clamped offsets for `channel`, in code values.""" + return tuple(float(min(max(float(getattr(config, f)), CURVE_OFFSET_MIN), CURVE_OFFSET_MAX)) for f in _node_fields(channel)) + + +def curves_active(config: Any) -> bool: + """True when any node leaves the identity line.""" + return any(o != 0.0 for ch in ("global", "red", "green", "blue") for o in channel_offsets(config, ch)) + + +def active_mask(config: Any) -> tuple[int, int, int, int]: + """Per-lane apply flags over (global, red, green, blue). An identity table + still quantizes to 8 bits, so untouched lanes are skipped on both engines + and stay bit-exact. Single source for the CPU application and the GPU pack.""" + return tuple(1 if any(channel_offsets(config, ch)) else 0 for ch in ("global", "red", "green", "blue")) + + +def without_curves(config: Any) -> Any: + """`config` with every curves field at default, so a curves-only edit skips + the exposure cache like a levels-only one does (compose with without_levels).""" + from dataclasses import replace + + try: + return replace(config, **{f: 0.0 for f in curves_fields()}) + except TypeError: + return config + + +def _pchip_slopes(x: np.ndarray, y: np.ndarray) -> np.ndarray: + """Fritsch-Carlson monotone cubic slopes: the interpolant through + non-decreasing data never overshoots. No scipy dependency by design.""" + n = len(x) + h = np.diff(x) + delta = np.diff(y) / h + m = np.empty(n, dtype=np.float64) + m[0], m[-1] = delta[0], delta[-1] + for k in range(1, n - 1): + if delta[k - 1] == 0.0 or delta[k] == 0.0 or (delta[k - 1] < 0.0) != (delta[k] < 0.0): + m[k] = 0.0 + else: + w1 = 2.0 * h[k] + h[k - 1] + w2 = h[k] + 2.0 * h[k - 1] + m[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]) + for end in (0, -1): + d0, d1 = (delta[0], delta[1]) if end == 0 else (delta[-1], delta[-2]) + h0, h1 = (h[0], h[1]) if end == 0 else (h[-1], h[-2]) + edge = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1) + if (edge < 0.0) != (d0 < 0.0): + edge = 0.0 + elif abs(edge) > 3.0 * abs(d0): + edge = 3.0 * d0 + m[end] = edge + return m + + +def bake_channel_lut(offsets: Any) -> np.ndarray: + """(256,) float32 output code values for node `offsets`: identity plus the + offset, held monotone, through PCHIP, sampled per code value.""" + grid = np.clip(np.asarray(CURVE_INPUTS, dtype=np.float64) + np.asarray(list(offsets), dtype=np.float64), 0.0, 255.0) + grid = np.maximum.accumulate(grid) + x = np.asarray(CURVE_INPUTS, dtype=np.float64) + slopes = _pchip_slopes(x, grid) + xe = np.arange(256, dtype=np.float64) + k = np.clip(np.searchsorted(x, xe, side="right") - 1, 0, CURVE_NODES - 2) + h = x[k + 1] - x[k] + t = (xe - x[k]) / h + t2, t3 = t * t, t * t * t + y = ( + (2.0 * t3 - 3.0 * t2 + 1.0) * grid[k] + + (t3 - 2.0 * t2 + t) * h * slopes[k] + + (-2.0 * t3 + 3.0 * t2) * grid[k + 1] + + (t3 - t2) * h * slopes[k + 1] + ) + return np.clip(y / 255.0, 0.0, 1.0).astype(np.float32) + + +def bake_config_luts(config: Any) -> np.ndarray: + """(4, 256) float32 tables over (global, red, green, blue). Single source for + the CPU application and the GPU storage-buffer upload.""" + from negpy.features.exposure.levels import LEVELS_CHANNELS + + return np.stack([bake_channel_lut(channel_offsets(config, ch)) for ch in LEVELS_CHANNELS]) + + +def grid_outputs(config: Any, channel: str) -> tuple[int, ...]: + """The 8 node outputs the panel draws, in code values: what the bake holds, + so the markers never disagree with the render.""" + grid = np.clip(np.asarray(CURVE_INPUTS) + np.asarray(channel_offsets(config, channel)), 0.0, 255.0) + return tuple(int(v) for v in np.maximum.accumulate(grid).round()) + + +def _apply_lut_plane(plane: np.ndarray, lut: np.ndarray) -> np.ndarray: + """Integer-index table application in float32, mirroring the WGSL lookup: + `u32(clamp(x * 255 + 0.5, 0, 255))`.""" + idx = np.clip((plane * np.float32(255.0) + np.float32(0.5)).astype(np.int32), 0, 255) + return lut[idx] + + +def apply_curves(image: ImageBuffer, config: Any) -> ImageBuffer: + """Per-channel curves on a display-encoded buffer, Global master first, then + each channel's own table. Identity (every offset 0) returns the input + untouched, since even an identity table would quantize to 8 bits.""" + if not curves_active(config): + return image + mask = active_mask(config) + tables = bake_config_luts(config) + arr = np.asarray(image, dtype=np.float32) + mono = arr.ndim == 2 + res = arr[..., None].copy() if mono else arr.copy() + if mask[0]: + for c in range(res.shape[-1]): + res[..., c] = _apply_lut_plane(res[..., c], tables[0]) + for c in range(min(3, res.shape[-1])): + if mask[c + 1]: + res[..., c] = _apply_lut_plane(res[..., c], tables[c + 1]) + res = np.clip(res, 0.0, 1.0) + if mono: + res = res[..., 0] + return ensure_image(res.astype(np.float32)) diff --git a/negpy/features/exposure/models.py b/negpy/features/exposure/models.py index a1a53738f..767a6f301 100644 --- a/negpy/features/exposure/models.py +++ b/negpy/features/exposure/models.py @@ -128,6 +128,42 @@ class ExposureConfig: levels_in_high_blue: int = 255 levels_out_low_blue: int = 0 levels_out_high_blue: int = 255 + # Display-referred curves (see curves.py), after levels: per-channel control + # points as offsets from the identity line in code values, shadows to + # highlights. The Global master first, then one curve per channel. All zero + # is identity, so an untouched edit renders exactly as before. + curve_0: float = 0.0 + curve_1: float = 0.0 + curve_2: float = 0.0 + curve_3: float = 0.0 + curve_4: float = 0.0 + curve_5: float = 0.0 + curve_6: float = 0.0 + curve_7: float = 0.0 + curve_0_red: float = 0.0 + curve_1_red: float = 0.0 + curve_2_red: float = 0.0 + curve_3_red: float = 0.0 + curve_4_red: float = 0.0 + curve_5_red: float = 0.0 + curve_6_red: float = 0.0 + curve_7_red: float = 0.0 + curve_0_green: float = 0.0 + curve_1_green: float = 0.0 + curve_2_green: float = 0.0 + curve_3_green: float = 0.0 + curve_4_green: float = 0.0 + curve_5_green: float = 0.0 + curve_6_green: float = 0.0 + curve_7_green: float = 0.0 + curve_0_blue: float = 0.0 + curve_1_blue: float = 0.0 + curve_2_blue: float = 0.0 + curve_3_blue: float = 0.0 + curve_4_blue: float = 0.0 + curve_5_blue: float = 0.0 + curve_6_blue: float = 0.0 + curve_7_blue: float = 0.0 def __post_init__(self) -> None: """ diff --git a/negpy/features/exposure/shaders/curves.wgsl b/negpy/features/exposure/shaders/curves.wgsl new file mode 100644 index 000000000..81a28a733 --- /dev/null +++ b/negpy/features/exposure/shaders/curves.wgsl @@ -0,0 +1,40 @@ +// Display-referred curves after levels; mirrors curves.py::apply_curves. +// +// Lanes are (global, red, green, blue). The CPU bakes the same tables and both +// sides index them identically, so parity holds by construction. Identity lanes +// are skipped through the active mask: even an identity table would quantize +// to 8 bits. + +struct CurvesUniforms { + apply_mask: 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: CurvesUniforms; +@group(0) @binding(3) var lut: array, 256>; + +fn lut_at(x: f32, lane: u32) -> f32 { + return lut[u32(clamp(x * 255.0 + 0.5, 0.0, 255.0))][lane]; +} + +@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)); + var color = textureLoad(input_tex, coords, 0).rgb; + if (params.apply_mask.x != 0u) { + color = vec3(lut_at(color.r, 0u), lut_at(color.g, 0u), lut_at(color.b, 0u)); + } + if (params.apply_mask.y != 0u) { + color.r = lut_at(color.r, 1u); + } + if (params.apply_mask.z != 0u) { + color.g = lut_at(color.g, 2u); + } + if (params.apply_mask.w != 0u) { + color.b = lut_at(color.b, 3u); + } + textureStore(output_tex, coords, vec4(clamp(color, vec3(0.0), vec3(1.0)), 1.0)); +} diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index 6602365ab..7b46d1dd7 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -12,6 +12,7 @@ from negpy.kernel.system.logging import get_logger from negpy.features.geometry.processor import GeometryProcessor, CropProcessor from negpy.features.exposure import models as exposure_models +from negpy.features.exposure.curves import apply_curves, curves_active, without_curves from negpy.features.exposure.levels import apply_levels, levels_active, without_levels from negpy.features.exposure.models import RenderIntent from negpy.features.exposure.processor import ( @@ -225,11 +226,11 @@ def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: return apply_hue_trim(img_out, settings.process.hue_trim) # Dodge/burn masks are print-exposure inputs, so they key this stage. Levels - # runs on the encoded output, so it stays out of the key and a levels-only - # edit re-runs just the final stage. + # and curves run on the encoded output, so they stay out of the key and an + # edit to either re-runs just the final stages. current_img, pipeline_changed = self._run_stage( current_img, - (without_levels(settings.exposure), settings.local, settings.process.hue_trim), + (without_curves(without_levels(settings.exposure)), settings.local, settings.process.hue_trim), "exposure", run_exposure, context, @@ -290,6 +291,16 @@ def run_lab(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: if pre_levels is not None: context.metrics["levels_input_histogram"] = pre_levels current_img = apply_levels(current_img, settings.exposure) + # Curves, after levels: the panel draws the post-levels input, so a + # histogram is published whenever either stage runs. + if curves_active(settings.exposure) or levels_active(settings.exposure): + from negpy.features.exposure.analysis import output_histogram + + pre_curves = output_histogram(current_img) + if pre_curves is not None: + context.metrics["curves_input_histogram"] = pre_curves + if curves_active(settings.exposure): + current_img = apply_curves(current_img, settings.exposure) # No paper layout runs here, so the whole buffer is the picture. Reported rather # than left out: the controller merges each render's metrics into last_metrics, so diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 5c440ae47..1eeef9a7c 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -280,6 +280,7 @@ def __init__(self) -> None: "transfer": get_resource_path(os.path.join("negpy", "features", "transparency", "shaders", "transfer.wgsl")), "output_encode": get_resource_path(os.path.join("negpy", "features", "exposure", "shaders", "output_encode.wgsl")), "levels": get_resource_path(os.path.join("negpy", "features", "exposure", "shaders", "levels.wgsl")), + "curves": get_resource_path(os.path.join("negpy", "features", "exposure", "shaders", "curves.wgsl")), "autocrop": get_resource_path(os.path.join("negpy", "features", "geometry", "shaders", "autocrop.wgsl")), "clahe_hist": get_resource_path(os.path.join("negpy", "features", "lab", "shaders", "clahe_hist.wgsl")), "clahe_cdf": get_resource_path(os.path.join("negpy", "features", "lab", "shaders", "clahe_cdf.wgsl")), @@ -321,6 +322,7 @@ def __init__(self) -> None: "layout", "density_hist", "levels", + "curves", ] # Packed byte size per stage. A stage that exceeds the 256B dynamic-offset # alignment (exposure, 416B) occupies multiple aligned slots. @@ -338,6 +340,7 @@ def __init__(self) -> None: "layout": 48, "density_hist": 16, "levels": 80, + "curves": 16, } self._alignment = UNIFORM_ALIGNMENT_DEFAULT self._current_source_hash: Optional[str] = None @@ -410,11 +413,15 @@ def _detect_invalidated_stage( return 0 if last.flatfield.apply != settings.flatfield.apply: return 0 - # Levels runs on the encoded output in its own pass, so a levels-only edit - # re-runs nothing behind it. + # Levels and curves run on the encoded output in their own passes, so an + # edit to either re-runs nothing behind them. + from negpy.features.exposure.curves import without_curves from negpy.features.exposure.levels import without_levels - if last.process != settings.process or without_levels(last.exposure) != without_levels(settings.exposure): + def _print_key(exposure: Any) -> Any: + return without_curves(without_levels(exposure)) + + if last.process != settings.process or _print_key(last.exposure) != _print_key(settings.exposure): return 1 # Retuned Auto Density/Grade targets live in EXPOSURE_CONSTANTS, invisible to the # config diff, but they reshape the print curve in the exposure pass. @@ -525,6 +532,9 @@ def _init_resources(self) -> None: ) # Sharpen blur taps (gaussian_kernel_1d): 1024 f32 covers radius <= 511. self._buffers["sharpen_k"] = GPUBuffer(4096, wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_DST) + # Curves tables: 256 vec4 over (global, red, green, blue), re-baked + # per frame while a node is off the line. + self._buffers["curves_lut"] = GPUBuffer(4096, wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_DST) # Filed-carrier jitter profiles are a fixed table, so upload once. self._buffers["carrier_s"] = GPUBuffer(carrier_profiles().nbytes, wgpu.BufferUsage.STORAGE | wgpu.BufferUsage.COPY_DST) self._buffers["carrier_s"].upload(np.ascontiguousarray(carrier_profiles().ravel(), dtype=np.float32)) @@ -1435,6 +1445,34 @@ def _analyze_bounds() -> LogNegativeBounds: ) tex_final = tex_levels + # Curves, after levels: its own pass only while a node is off the line, like + # the alternative-process stage. The metrics pass stays on the pre-levels + # content texture, and the panel reads its post-levels input histogram from + # the CPU engine's curves_input_histogram instead. + from negpy.features.exposure.curves import bake_config_luts, curves_active + + if curves_active(settings.exposure): + self._buffers["curves_lut"].upload(np.ascontiguousarray(bake_config_luts(settings.exposure).T, dtype=np.float32)) + tex_curves = self._get_intermediate_texture( + tex_levels.width, + tex_levels.height, + wgpu.TextureUsage.STORAGE_BINDING | wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_SRC, + "curves", + ) + self._dispatch_pass( + enc, + "curves", + [ + (0, tex_levels.view), + (1, tex_curves.view), + (2, self._get_uniform_binding("curves")), + (3, self._buffers["curves_lut"]), + ], + tex_levels.width, + tex_levels.height, + ) + tex_final = tex_curves + device.queue.submit([enc.finish()]) # The exact stretch the shader normalized with (mirrors the CPU "final_bounds"). # The transfer path renders through the fixed window, not the measured one. @@ -2112,10 +2150,15 @@ def _upload_unified_uniforms( + struct.pack("ffff", *_lv_ospan) ) + # Curves apply flags over the same lanes; the tables ride a storage buffer. + from negpy.features.exposure.curves import active_mask + + cv_data = struct.pack("IIII", *active_mask(settings.exposure)) + full_buffer = bytearray() for name, d in zip( self._uniform_names, - [g_data, n_data, e_data, tr_data, c_data, l_data, li_data, cy_data, t_data, f_data, y_data, dh_data, lv_data], + [g_data, n_data, e_data, tr_data, c_data, l_data, li_data, cy_data, t_data, f_data, y_data, dh_data, lv_data, cv_data], ): full_buffer += d + b"\x00" * (self._slot_bytes(name) - len(d)) diff --git a/tests/test_curves.py b/tests/test_curves.py new file mode 100644 index 000000000..b93ede6be --- /dev/null +++ b/tests/test_curves.py @@ -0,0 +1,181 @@ +"""Display-referred curves, applied after levels as the last step. + +The math lives in two places — ``curves.py`` (CPU bake + apply) and +``shaders/curves.wgsl`` (integer-index table application). Both sides index the +same baked tables identically, so parity holds by construction. +""" + +import unittest +from dataclasses import replace + +import numpy as np + +from negpy.domain.models import WorkspaceConfig, flat_master_config +from negpy.features.exposure.curves import ( + CURVE_NODES, + active_mask, + apply_curves, + bake_channel_lut, + channel_offsets, + curves_active, + curves_fields, + grid_outputs, +) +from negpy.features.exposure.models import ExposureConfig +from negpy.infrastructure.gpu.device import GPUDevice + + +class TestCurvesMath(unittest.TestCase): + def test_identity_by_default(self) -> None: + conf = ExposureConfig() + self.assertFalse(curves_active(conf)) + self.assertEqual(len(curves_fields()), 4 * CURVE_NODES) + img = np.linspace(0.0, 1.0, 300, dtype=np.float32).reshape(10, 10, 3) + np.testing.assert_array_equal(np.asarray(apply_curves(img, conf)), img) + + def test_identity_bake_is_the_exact_ramp(self) -> None: + np.testing.assert_array_equal(bake_channel_lut((0.0,) * CURVE_NODES), np.arange(256, dtype=np.float32) / 255.0) + + def test_bake_passes_through_every_node(self) -> None: + # Nodes sit between integer code values, so the bracketing LUT entries + # straddle each node value within one steep pixel's slope. + rng = np.random.default_rng(0) + for _ in range(50): + off = tuple(rng.uniform(-100, 100, CURVE_NODES)) + lut = bake_channel_lut(off) + grid = np.maximum.accumulate(np.clip(np.array([i * 255 / 7 for i in range(8)]) + np.array(off), 0, 255)) + for i, gx in enumerate((0, 36, 73, 109, 146, 182, 219, 255)): + self.assertAlmostEqual(float(lut[gx] * 255), float(grid[i]), delta=2.0) + + def test_bake_stays_monotone_and_bounded(self) -> None: + rng = np.random.default_rng(1) + for _ in range(500): + lut = bake_channel_lut(tuple(rng.uniform(-200, 200, CURVE_NODES))) + self.assertTrue(bool(np.all(np.diff(lut) >= 0))) + self.assertGreaterEqual(float(lut.min()), 0.0) + self.assertLessEqual(float(lut.max()), 1.0) + + def test_midtone_lift(self) -> None: + conf = replace(ExposureConfig(), curve_3=40.0) + out = np.asarray(apply_curves(np.full((4, 4, 3), 109.3 / 255, dtype=np.float32), conf)) + self.assertAlmostEqual(float(out[0, 0, 0] * 255), 149.3, delta=1.5) + + def test_per_channel_leaves_siblings_bit_exact(self) -> None: + conf = replace(ExposureConfig(), curve_5_red=-50.0) + self.assertEqual(active_mask(conf), (0, 1, 0, 0)) + out = np.asarray(apply_curves(np.full((2, 2, 3), 0.7, dtype=np.float32), conf)) + self.assertLess(float(out[0, 0, 0]), 0.7) + # Untouched lanes never pass a table: bit-exact float32 passthrough. + self.assertEqual(float(out[0, 0, 1]), float(np.float32(0.7))) + self.assertEqual(float(out[0, 0, 2]), float(np.float32(0.7))) + + def test_config_values_are_clamped(self) -> None: + conf = replace(ExposureConfig(), curve_0_red=9999.0, curve_1_red=-9999.0) + lo = channel_offsets(conf, "red") + self.assertEqual((lo[0], lo[1]), (255.0, -255.0)) + out = np.asarray(apply_curves(np.full((2, 2, 3), 0.5, dtype=np.float32), conf)) + self.assertTrue(bool(np.all(out >= 0.0)) and bool(np.all(out <= 1.0))) + + def test_grid_outputs_match_the_bake(self) -> None: + self.assertEqual(grid_outputs(ExposureConfig(), "global"), (0, 36, 73, 109, 146, 182, 219, 255)) + conf = replace(ExposureConfig(), curve_7_blue=-300.0) + # Held at the monotone bound: the bake never folds, the marker shows it. + self.assertEqual(grid_outputs(conf, "blue")[-1], 219) + + +class TestCurvesPipeline(unittest.TestCase): + def _processor(self): # local import: building it pulls the desktop stack + from negpy.services.rendering.image_processor import ImageProcessor + + return ImageProcessor() + + def _negative(self) -> np.ndarray: + rng = np.random.default_rng(7) + h, w = 48, 48 + grad = np.linspace(0.05, 0.9, w, dtype=np.float32) + img = np.repeat(grad[None, :], h, axis=0) + img = np.stack([img, img * 0.9, img * 0.8], axis=-1) + return np.ascontiguousarray(img + rng.uniform(0, 0.005, img.shape).astype(np.float32)) + + def _render(self, settings, **kw): + result, metrics = self._processor().run_pipeline( + self._negative(), settings, "curves-src", render_size_ref=48.0, prefer_gpu=False, readback_metrics=True, **kw + ) + return np.asarray(result)[:, :, :3].astype(np.float64), metrics + + def test_curves_move_the_render_and_publish_the_input_histogram(self) -> None: + base = WorkspaceConfig() + plain, _ = self._render(base) + curved_cfg = replace(base, exposure=replace(base.exposure, curve_2=30.0, curve_5=-30.0)) + curved, metrics = self._render(curved_cfg) + self.assertGreater(float(np.abs(curved - plain).max()), 1e-4) + hist = metrics.get("curves_input_histogram") + self.assertIsNotNone(hist) + assert hist is not None + self.assertEqual(np.asarray(hist).shape, (4, 256)) + # Curves alone publish no levels histogram. + self.assertNotIn("levels_input_histogram", metrics) + + def test_no_histogram_keys_without_either_stage(self) -> None: + _, metrics = self._render(WorkspaceConfig()) + self.assertNotIn("curves_input_histogram", metrics) + self.assertNotIn("levels_input_histogram", metrics) + + def test_flat_master_bypasses_curves(self) -> None: + flat = flat_master_config(WorkspaceConfig()) + plain, _ = self._render(flat) + with_curves, _ = self._render(replace(flat, exposure=replace(flat.exposure, curve_2=60.0))) + np.testing.assert_array_equal(with_curves, plain) + + +@unittest.skipUnless(GPUDevice.get().is_available, "GPU not available") +class TestGpuCurvesParity(unittest.TestCase): + def _render(self, processor, settings, img, prefer_gpu): + result, _ = processor.run_pipeline( + img, settings, "curves-parity-src", render_size_ref=float(max(img.shape[:2])), prefer_gpu=prefer_gpu, readback_metrics=False + ) + if hasattr(result, "readback"): + arr = np.asarray(result.readback())[:, :, :3] + else: + arr = np.asarray(result)[:, :, :3] + return arr.astype(np.float64) + + def test_cpu_gpu_match_curves(self) -> None: + from negpy.services.rendering.image_processor import ImageProcessor + + processor = ImageProcessor() + if processor.engine_gpu is None: + self.skipTest("GPU engine not initialised") + + rng = np.random.default_rng(3) + h, w = 64, 64 + grad = np.linspace(0.05, 0.9, w, dtype=np.float32) + img = np.repeat(grad[None, :], h, axis=0) + img = np.stack([img, img * 0.9, img * 0.8], axis=-1) + img = np.ascontiguousarray(img + rng.uniform(0, 0.01, img.shape).astype(np.float32)) + + base = WorkspaceConfig() + settings = replace( + base, + exposure=replace( + base.exposure, + curve_1=25.0, + curve_5=-30.0, + curve_2_red=40.0, + curve_6_blue=-45.0, + levels_in_low=10, + levels_gamma=1.2, + ), + ) + cpu = self._render(processor, settings, img, prefer_gpu=False) + gpu = self._render(processor, settings, img, prefer_gpu=True) + + self.assertEqual(cpu.shape, gpu.shape) + mad = float(np.mean(np.abs(cpu - gpu))) + mx = float(np.max(np.abs(cpu - gpu))) + self.assertLess(mad, 0.01, f"mean abs diff {mad:.4f}") + self.assertLess(mx, 0.04, f"max abs diff {mx:.4f}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_slide_separation.py b/tests/test_slide_separation.py index 6659e8b2d..f4581cb6e 100644 --- a/tests/test_slide_separation.py +++ b/tests/test_slide_separation.py @@ -36,6 +36,8 @@ def _with_trims(*names: str) -> set: for suffix in ("", "_red", "_green", "_blue") for name in ("in_low", "gamma", "in_high", "out_low", "out_high") } + # Display-referred curves run after levels on every path, for the same reason. + | {f"curve_{i}{suffix}" for suffix in ("", "_red", "_green", "_blue") for i in range(8)} ) # Meter the frame on a raw slide and on a Positive frame alike; a slide starts with both off. METERS = {"auto_exposure", "auto_normalize_contrast"} @@ -109,7 +111,8 @@ def _config(positive: bool): def _render(cfg) -> np.ndarray: """Base + exposure stages, then the engine tail (finish, output transform, - levels), so end-of-pipeline controls like levels read a delta too.""" + levels, curves), so end-of-pipeline controls read a delta too.""" + from negpy.features.exposure.curves import apply_curves from negpy.features.exposure.levels import apply_levels from negpy.features.finish.processor import FinishProcessor from negpy.kernel.image.logic import working_oetf_encode @@ -120,7 +123,7 @@ def _render(cfg) -> np.ndarray: lin = np.asarray(exposure_processor(cfg).process(norm, ctx), dtype=np.float32) finished = np.asarray(FinishProcessor(cfg.finish, cfg.export.export_print_size, (1.0, 1.0, 1.0), None).process(lin, ctx)) encoded = np.asarray(working_oetf_encode(finished)) - return np.asarray(apply_levels(encoded, cfg.exposure), dtype=np.float64) + return np.asarray(apply_curves(apply_levels(encoded, cfg.exposure), cfg.exposure), dtype=np.float64) def _delta(field: str, positive: bool) -> float: diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index 9d37612ea..bb0189346 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -16,6 +16,38 @@ "auto_normalize_contrast": "true", "cast_removal_strength": "1.0", "contrast_mask": "0.0", + "curve_0": "0.0", + "curve_0_blue": "0.0", + "curve_0_green": "0.0", + "curve_0_red": "0.0", + "curve_1": "0.0", + "curve_1_blue": "0.0", + "curve_1_green": "0.0", + "curve_1_red": "0.0", + "curve_2": "0.0", + "curve_2_blue": "0.0", + "curve_2_green": "0.0", + "curve_2_red": "0.0", + "curve_3": "0.0", + "curve_3_blue": "0.0", + "curve_3_green": "0.0", + "curve_3_red": "0.0", + "curve_4": "0.0", + "curve_4_blue": "0.0", + "curve_4_green": "0.0", + "curve_4_red": "0.0", + "curve_5": "0.0", + "curve_5_blue": "0.0", + "curve_5_green": "0.0", + "curve_5_red": "0.0", + "curve_6": "0.0", + "curve_6_blue": "0.0", + "curve_6_green": "0.0", + "curve_6_red": "0.0", + "curve_7": "0.0", + "curve_7_blue": "0.0", + "curve_7_green": "0.0", + "curve_7_red": "0.0", "density": "1.0", "dye_separation": "1.0", "dye_separation_trim_blue": "0.0", From 84cb260194fe34d007942b8dcc292450d25e66e8 Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 16:56:10 +0200 Subject: [PATCH 09/12] reset values --- docs/USER_GUIDE.md | 4 ++-- negpy/desktop/view/sidebar/tone.py | 26 ++++++++++++++++++++++++-- negpy/features/exposure/levels.py | 23 ++++++++++++++--------- tests/test_levels.py | 5 +++++ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3acb50c02..286cb3ead 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -452,9 +452,9 @@ In R/G/B mode these become per-layer trims: **Grade** (±30 ISO-R), **Toe** / ** **Levels**, last on the panel and last in the pipeline: -* **Global / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Global is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. **Auto** (wand, in the LEVELS header) stretches the shown channel like GIMP's Auto Input Levels: gamma back to 1.00, full output range, input window at the 0.6% clip points — on Global it reads luminance, so hues don't move. +* **Global / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Global is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. **Auto** (wand, in the LEVELS header) stretches the shown channel like GIMP's Auto Input Levels: gamma back to 1.00, full output range, input window at the 0.6% clip points — on Global it reads luminance, so hues don't move. The header's undo arrow resets every channel. -**Curves**, after Levels and last of all: per-channel tone maps over the finished output. **Global / Red / Green / Blue** picks the channel; Global is the master curve, the others trim one channel on top. Each channel shows its input histogram with its curve: eight nodes, dragged vertically (they clamp against their neighbours, so the curve never folds); double-click resets the channel. +**Curves**, after Levels and last of all: per-channel tone maps over the finished output. **Global / Red / Green / Blue** picks the channel; Global is the master curve, the others trim one channel on top. Each channel shows its input histogram with its curve: eight nodes, dragged vertically (they clamp against their neighbours, so the curve never folds); double-click resets the channel. The header's undo arrow resets every channel. ### 5.3 Dodge & Burn: local exposure diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index eb4287ad6..0d28738c1 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -274,7 +274,11 @@ def _init_ui(self) -> None: "Auto Levels: stretch this channel's input range onto the output range at the 0.6% clip points, " "like GIMP's Auto Input Levels. With Global selected the stretch reads luminance, so hues don't move", ) - self.layout.addLayout(header_row(levels_header, self.levels_auto_btn)) + self.levels_reset_btn = self._icon_action( + "fa5s.undo", + "Reset Levels: every channel back to identity", + ) + self.layout.addLayout(header_row(levels_header, self.levels_auto_btn, self.levels_reset_btn)) self.levels_combo = QComboBox() for label in _LEVELS_LABELS: self.levels_combo.addItem(label) @@ -371,7 +375,11 @@ def _init_ui(self) -> None: "Nodes clamp against their neighbours, so the curve can never fold." ) ) - self.layout.addWidget(curves_header) + self.curves_reset_btn = self._icon_action( + "fa5s.undo", + "Reset Curves: every channel back to the identity line", + ) + self.layout.addLayout(header_row(curves_header, self.curves_reset_btn)) self.curves_combo = QComboBox() for label in _LEVELS_LABELS: self.curves_combo.addItem(label) @@ -482,6 +490,11 @@ def _on_levels_spins(self, persist: bool) -> None: def _reset_levels_channel(self) -> None: self._write_levels(True, in_low=0, gamma=1.0, in_high=255, out_low=0, out_high=255) + def _reset_levels_all(self) -> None: + from negpy.features.exposure.levels import levels_defaults + + self.update_config_section("exposure", render=True, persist=True, readback_metrics=True, **levels_defaults()) + def _auto_levels(self) -> None: """GIMP Auto Input Levels on the shown channel: gamma 1, full output range, input window at the 0.6% clip points of the input histogram the panel draws.""" @@ -531,6 +544,11 @@ def _reset_curves_channel(self) -> None: self._write_curves(True, [i * 255.0 / (CURVE_NODES - 1) for i in range(CURVE_NODES)]) + def _reset_curves_all(self) -> None: + from negpy.features.exposure.curves import curves_fields + + self.update_config_section("exposure", render=True, persist=True, readback_metrics=True, **{f: 0.0 for f in curves_fields()}) + def _sync_curves_histogram(self) -> None: metrics = self.controller.state.last_metrics buf = metrics.get("curves_input_histogram") @@ -596,10 +614,12 @@ def _connect_signals(self) -> None: self.levels_hist.outputCommitted.connect(lambda olo, ohi: self._on_levels_output(olo, ohi, True)) self.levels_hist.resetRequested.connect(self._reset_levels_channel) self.levels_auto_btn.clicked.connect(self._auto_levels) + self.levels_reset_btn.clicked.connect(self._reset_levels_all) self.curves_combo.currentIndexChanged.connect(lambda _i: self.sync_ui()) self.curves_widget.nodesChanged.connect(lambda nodes: self._on_curves(nodes, False)) self.curves_widget.nodesCommitted.connect(lambda nodes: self._on_curves(nodes, True)) self.curves_widget.resetRequested.connect(self._reset_curves_channel) + self.curves_reset_btn.clicked.connect(self._reset_curves_all) for spin in ( self.levels_in_low_spin, self.levels_gamma_spin, @@ -877,8 +897,10 @@ def block_signals(self, blocked: bool) -> None: self.levels_combo, self.levels_hist, self.levels_auto_btn, + self.levels_reset_btn, self.curves_combo, self.curves_widget, + self.curves_reset_btn, self.levels_in_low_spin, self.levels_gamma_spin, self.levels_in_high_spin, diff --git a/negpy/features/exposure/levels.py b/negpy/features/exposure/levels.py index ab7b6326a..7ee26d4b7 100644 --- a/negpy/features/exposure/levels.py +++ b/negpy/features/exposure/levels.py @@ -40,6 +40,19 @@ def levels_fields() -> tuple[str, ...]: return tuple(f for ch in LEVELS_CHANNELS for f in _channel_fields(ch)) +def levels_defaults() -> dict[str, Any]: + """Identity value per levels field. Single source for resets and cache keys.""" + defaults: dict[str, Any] = {} + for f in levels_fields(): + if "gamma" in f: + defaults[f] = 1.0 + elif "high" in f: + defaults[f] = 255 + else: + defaults[f] = 0 + return defaults + + def channel_levels(config: Any, channel: str) -> tuple[float, float, float, float, float]: """Clamped (in_low, gamma, in_high, out_low, out_high) for `channel`.""" in_low_f, gamma_f, in_high_f, out_low_f, out_high_f = _channel_fields(channel) @@ -98,16 +111,8 @@ def without_levels(config: Any) -> Any: re-runs just the final stage instead of the exposure cache behind it.""" from dataclasses import replace - defaults = {} - for f in levels_fields(): - if "gamma" in f: - defaults[f] = 1.0 - elif "high" in f: - defaults[f] = 255 - else: - defaults[f] = 0 try: - return replace(config, **defaults) + return replace(config, **levels_defaults()) except TypeError: return config diff --git a/tests/test_levels.py b/tests/test_levels.py index 250f249ac..873aa9755 100644 --- a/tests/test_levels.py +++ b/tests/test_levels.py @@ -44,6 +44,11 @@ def test_every_channel_defaults_to_identity(self) -> None: else: self.assertEqual(default, 0) + def test_defaults_match_the_dataclass(self) -> None: + from negpy.features.exposure.levels import levels_defaults + + self.assertEqual(levels_defaults(), {f: getattr(ExposureConfig(), f) for f in levels_fields()}) + def test_input_window_stretches(self) -> None: conf = replace(ExposureConfig(), levels_in_low=51, levels_in_high=204) out = np.asarray(apply_levels(_gray(0.2, 0.5, 0.8), conf))[0, :, 0] From aa7858ad0c4e1a1cb9d9577b52772e8556877aba Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 17:13:34 +0200 Subject: [PATCH 10/12] curves control more flexible --- docs/PIPELINE.md | 2 +- docs/USER_GUIDE.md | 2 +- negpy/desktop/settings_catalog.py | 9 +-- negpy/desktop/view/sidebar/tone.py | 35 +++++----- negpy/desktop/view/widgets/charts.py | 58 +++++++++------- negpy/features/exposure/curves.py | 85 +++++++++++++++++------ negpy/features/exposure/models.py | 35 ++++++++++ tests/test_curves.py | 43 ++++++++++-- tests/test_slide_separation.py | 14 +++- tests/thumbnail_fingerprint_defaults.json | 32 +++++++++ 10 files changed, 237 insertions(+), 78 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 5f4ac8df1..d1d4a8f1a 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -563,5 +563,5 @@ Bounds are 0-255 code values, $\gamma$ is 0.1 to 10.0 at 1.0. The master applies ### Curves **Code**: `negpy.features.exposure.curves` (CPU bake + apply) / `negpy.features.exposure.shaders.curves.wgsl` (GPU) -Per-channel tone maps on the display-encoded output, after Levels. Four channels — the Global master, then Red, Green, Blue — each holding eight control points as offsets from the identity line. The offsets bake through a monotone cubic (Fritsch-Carlson PCHIP, hand-rolled: no scipy) into a 256-entry table, held monotone so the map never folds; both engines apply the table by integer indexing (`u32(clamp(x*255+0.5))`), so parity holds by construction. Identity lanes are skipped through an active mask: even an identity table would quantize to 8 bits. Skipped under the Flat intent with everything else past the output transform. +Per-channel tone maps on the display-encoded output, after Levels. Four channels — the Global master, then Red, Green, Blue — each holding eight control points: offsets from the identity line at movable input positions, ordered and held monotone so the map never folds. The offsets bake through a monotone cubic (Fritsch-Carlson PCHIP, hand-rolled: no scipy) into a 256-entry table; both engines apply the table by integer indexing (`u32(clamp(x*255+0.5))`), so parity holds by construction. Identity lanes are skipped through an active mask: even an identity table would quantize to 8 bits. Skipped under the Flat intent with everything else past the output transform. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 286cb3ead..fabe2b407 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -454,7 +454,7 @@ In R/G/B mode these become per-layer trims: **Grade** (±30 ISO-R), **Toe** / ** * **Global / Red / Green / Blue** picks the channel GIMP-style fine-tuning acts on: Global is the master curve on all channels, the others trim one channel on top. Each channel shows its input histogram with draggable markers for **Input low / mid / high** (0-255, mid is a gamma at 1.00) and **Output low / high** fields (0-255) for the darkest and brightest output; double-click the histogram resets the channel. **Auto** (wand, in the LEVELS header) stretches the shown channel like GIMP's Auto Input Levels: gamma back to 1.00, full output range, input window at the 0.6% clip points — on Global it reads luminance, so hues don't move. The header's undo arrow resets every channel. -**Curves**, after Levels and last of all: per-channel tone maps over the finished output. **Global / Red / Green / Blue** picks the channel; Global is the master curve, the others trim one channel on top. Each channel shows its input histogram with its curve: eight nodes, dragged vertically (they clamp against their neighbours, so the curve never folds); double-click resets the channel. The header's undo arrow resets every channel. +**Curves**, after Levels and last of all: per-channel tone maps over the finished output. **Global / Red / Green / Blue** picks the channel; Global is the master curve, the others trim one channel on top. Each channel shows its input histogram with its curve: eight nodes, dragged in both directions to move input and output (they clamp against their neighbours, so the curve never folds); double-click resets the channel. The header's undo arrow resets every channel. ### 5.3 Dodge & Burn: local exposure diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index bcf8cee1a..6f9093bc0 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -204,10 +204,10 @@ def _fmt_gear(values: tuple) -> str: "levels_out_low_blue", "levels_out_high_blue", ), - _row("Curves Global", "exposure", *[f"curve_{i}" for i in range(8)]), - _row("Curves Red", "exposure", *[f"curve_{i}_red" for i in range(8)]), - _row("Curves Green", "exposure", *[f"curve_{i}_green" for i in range(8)]), - _row("Curves Blue", "exposure", *[f"curve_{i}_blue" for i in range(8)]), + _row("Curves Global", "exposure", *[f for i in range(8) for f in (f"curve_{i}", f"curve_x_{i}")]), + _row("Curves Red", "exposure", *[f for i in range(8) for f in (f"curve_{i}_red", f"curve_x_{i}_red")]), + _row("Curves Green", "exposure", *[f for i in range(8) for f in (f"curve_{i}_green", f"curve_x_{i}_green")]), + _row("Curves Blue", "exposure", *[f for i in range(8) for f in (f"curve_{i}_blue", f"curve_x_{i}_blue")]), _row("Auto Density", "exposure", "auto_exposure", sticky=True), _row("Auto Grade", "exposure", "auto_normalize_contrast", sticky=True), _row("Paper Profile", "exposure", "paper_profile", sticky=True), @@ -485,6 +485,7 @@ def rows_for_section(section: str) -> list[SettingRow]: "levels_out_low_blue", "levels_out_high_blue", *[f"curve_{i}{sfx}" for sfx in ("", "_red", "_green", "_blue") for i in range(8)], + *[f"curve_x_{i}{sfx}" for sfx in ("", "_red", "_green", "_blue") for i in range(8)], ) # Frame cards whose settings can be pushed to other frames, and the fields each owns. A diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 0d28738c1..9c7d5b201 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -371,7 +371,7 @@ def _init_ui(self) -> None: curves_header.setToolTip( wrap_tooltip( "Display-referred curves, GIMP-style, applied after Levels as the last step. " - "Each channel holds eight nodes; drag one vertically to reshape the tone map. " + "Each channel holds eight nodes; drag one to reshape the tone map. " "Nodes clamp against their neighbours, so the curve can never fold." ) ) @@ -393,7 +393,7 @@ def _init_ui(self) -> None: self.curves_widget = CurvesWidget() self.curves_widget.setToolTip( wrap_tooltip( - "The channel's input histogram with its tone curve. Drag a node vertically; " + "The channel's input histogram with its tone curve. Drag a node; " "double-click resets the channel." ) ) @@ -520,29 +520,30 @@ def _sync_levels_histogram(self) -> None: def _curves_channel(self) -> int: return max(0, min(3, self.curves_combo.currentIndex())) - def _curves_outputs(self, conf) -> tuple: - from negpy.features.exposure.curves import grid_outputs + def _curves_points(self, conf) -> tuple: + from negpy.features.exposure.curves import node_points - return grid_outputs(conf, LEVELS_CHANNELS[self._curves_channel()]) - - def _write_curves(self, persist: bool, outputs) -> None: - from negpy.features.exposure.curves import CURVE_NODES + return node_points(conf, LEVELS_CHANNELS[self._curves_channel()]) + def _write_curves(self, persist: bool, points) -> None: sfx = _CHANNEL_SUFFIX[self._curves_channel()] - fields = {f"curve_{i}{sfx}": float(outputs[i]) - i * 255.0 / (CURVE_NODES - 1) for i in range(CURVE_NODES)} + fields: dict = {} + for i, p in enumerate(points): + fields[f"curve_x_{i}{sfx}"] = p[0] + fields[f"curve_{i}{sfx}"] = float(p[1]) - float(p[0]) self.update_config_section("exposure", render=True, persist=persist, readback_metrics=True, **fields) - def _show_curves(self, outputs) -> None: - self.curves_widget.set_nodes(outputs) + def _show_curves(self, points) -> None: + self.curves_widget.set_nodes(points) - def _on_curves(self, outputs, persist: bool) -> None: - self._show_curves(outputs) - self._write_curves(persist, outputs) + def _on_curves(self, points, persist: bool) -> None: + self._show_curves(points) + self._write_curves(persist, points) def _reset_curves_channel(self) -> None: - from negpy.features.exposure.curves import CURVE_NODES + from negpy.features.exposure.curves import CURVE_DEFAULT_X - self._write_curves(True, [i * 255.0 / (CURVE_NODES - 1) for i in range(CURVE_NODES)]) + self._write_curves(True, [(x, x) for x in CURVE_DEFAULT_X]) def _reset_curves_all(self) -> None: from negpy.features.exposure.curves import curves_fields @@ -805,7 +806,7 @@ def sync_ui(self) -> None: if is_bw: self.curves_combo.setCurrentIndex(0) self.curves_combo.setVisible(not is_bw) - self._show_curves(self._curves_outputs(conf)) + self._show_curves(self._curves_points(conf)) self._sync_curves_histogram() idx = self._channel_index() diff --git a/negpy/desktop/view/widgets/charts.py b/negpy/desktop/view/widgets/charts.py index e8821db2b..2fe0aa8df 100644 --- a/negpy/desktop/view/widgets/charts.py +++ b/negpy/desktop/view/widgets/charts.py @@ -1300,9 +1300,9 @@ def paintEvent(self, event) -> None: class CurvesWidget(QWidget): """GIMP-style curve editor over the selected channel's input histogram: the - channel's baked curve with one draggable node per fixed grid position. - Nodes move vertically only, clamped between their neighbours so the curve - can never fold. Double-click resets the channel.""" + channel's baked curve with one draggable node per position. Nodes move in + both directions, clamped against their neighbours so the curve can never + fold or reorder. Double-click resets the channel.""" nodesChanged = pyqtSignal(list) nodesCommitted = pyqtSignal(list) @@ -1318,7 +1318,7 @@ def __init__(self, parent=None): self.setMouseTracking(True) self._counts: list = [] self._channel = 0 - self._nodes: list = [0, 36, 73, 109, 146, 182, 219, 255] + self._nodes: list = [[0, 0], [36, 36], [73, 73], [109, 109], [146, 146], [182, 182], [219, 219], [255, 255]] self._curve: list = [i / 255 for i in range(256)] self._drag: int | None = None @@ -1335,31 +1335,31 @@ def set_data(self, buffer: Any, channel: int = 0) -> None: self._counts = row or [] self.update() - def set_nodes(self, outputs: Any) -> None: - """Show these node outputs (0-255 per grid position) without emitting.""" - nodes = [min(max(int(round(v)), 0), 255) for v in list(outputs)] + def set_nodes(self, points: Any) -> None: + """Show these (x, y) node positions without emitting.""" + nodes = [[min(max(int(round(p[0])), 0), 255), min(max(int(round(p[1])), 0), 255)] for p in list(points)] if nodes != self._nodes: self._nodes = nodes self._update_curve() self.update() def _update_curve(self) -> None: - from negpy.features.exposure.curves import CURVE_INPUTS, bake_channel_lut + from negpy.features.exposure.curves import bake_channel_lut - offsets = [o - x for o, x in zip(self._nodes, CURVE_INPUTS)] - self._curve = (np.asarray(bake_channel_lut(offsets), dtype=float) * 255.0).tolist() + xs = [p[0] for p in self._nodes] + self._curve = (np.asarray(bake_channel_lut([p[1] - p[0] for p in self._nodes], xs), dtype=float) * 255.0).tolist() - def _x(self, i: int, w: int) -> float: - return float(i) / 7.0 * max(1, w - 1) + def _px(self, x: float, w: int) -> float: + return float(x) / 255.0 * max(1, w - 1) def _y(self, v: float, hist_h: int) -> float: return hist_h - float(v) / 255.0 * hist_h def _node_at(self, x: float, y: float, w: int, hist_h: int) -> int | None: near = [ - (i, abs(x - self._x(i, w)) + abs(y - self._y(v, hist_h))) - for i, v in enumerate(self._nodes) - if abs(x - self._x(i, w)) <= self._HIT_PX and abs(y - self._y(v, hist_h)) <= self._HIT_PX + 4 + (i, abs(x - self._px(p[0], w)) + abs(y - self._y(p[1], hist_h))) + for i, p in enumerate(self._nodes) + if abs(x - self._px(p[0], w)) <= self._HIT_PX and abs(y - self._y(p[1], hist_h)) <= self._HIT_PX + 4 ] return min(near, key=lambda t: t[1])[0] if near else None @@ -1376,24 +1376,28 @@ def mousePressEvent(self, event) -> None: def mouseMoveEvent(self, event) -> None: x, y = event.position().x(), event.position().y() - hist_h = self.height() - 12 + w, hist_h = self.width(), self.height() - 12 if self._drag is not None: - v = int(round((hist_h - y) / max(1, hist_h) * 255)) - lo = self._nodes[self._drag - 1] if self._drag > 0 else 0 - hi = self._nodes[self._drag + 1] if self._drag < 7 else 255 - self._nodes[self._drag] = min(max(v, lo), hi) + i = self._drag + nx = int(round(x / max(1, w - 1) * 255)) + ny = int(round((hist_h - y) / max(1, hist_h) * 255)) + x_lo = self._nodes[i - 1][0] if i > 0 else 0 + x_hi = self._nodes[i + 1][0] if i < 7 else 255 + y_lo = self._nodes[i - 1][1] if i > 0 else 0 + y_hi = self._nodes[i + 1][1] if i < 7 else 255 + self._nodes[i] = [min(max(nx, x_lo), x_hi), min(max(ny, y_lo), y_hi)] self._update_curve() - self.nodesChanged.emit(list(self._nodes)) + self.nodesChanged.emit([list(p) for p in self._nodes]) self.update() event.accept() return - over = self._node_at(x, y, self.width(), hist_h) is not None + over = self._node_at(x, y, w, hist_h) is not None self.setCursor(Qt.CursorShape.PointingHandCursor if over else Qt.CursorShape.ArrowCursor) super().mouseMoveEvent(event) def mouseReleaseEvent(self, event) -> None: if self._drag is not None and event.button() == Qt.MouseButton.LeftButton: - self.nodesCommitted.emit(list(self._nodes)) + self.nodesCommitted.emit([list(p) for p in self._nodes]) self._drag = None self.unsetCursor() event.accept() @@ -1424,8 +1428,8 @@ def paintEvent(self, event) -> None: for i in range(1, 4): gy = int(i / 4 * hist_h) painter.drawLine(0, gy, w, gy) - for i in range(8): - gx = int(self._x(i, w)) + for p in self._nodes: + gx = int(self._px(p[0], w)) painter.drawLine(gx, 0, gx, hist_h) if self._counts: @@ -1456,9 +1460,9 @@ def paintEvent(self, event) -> None: painter.drawPath(curve_path) painter.setPen(QPen(QColor(240, 240, 240, 220), 1)) - for i, v in enumerate(self._nodes): + for i, p in enumerate(self._nodes): c = QColor(base) c.setAlpha(255) painter.setBrush(QBrush(c)) r = self._NODE_R + (1.5 if i == self._drag else 0.0) - painter.drawEllipse(QPointF(self._x(i, w), self._y(v, hist_h)), r, r) + painter.drawEllipse(QPointF(self._px(p[0], w), self._y(p[1], hist_h)), r, r) diff --git a/negpy/features/exposure/curves.py b/negpy/features/exposure/curves.py index e2197aa2c..1cc04bccf 100644 --- a/negpy/features/exposure/curves.py +++ b/negpy/features/exposure/curves.py @@ -23,6 +23,9 @@ # Fixed node inputs, evenly spaced over the code-value range. CURVE_INPUTS = tuple(i * 255.0 / (CURVE_NODES - 1) for i in range(CURVE_NODES)) +# Default node x, the grid rounded to code values. +CURVE_DEFAULT_X = tuple(int(round(v)) for v in CURVE_INPUTS) + def _node_fields(channel: str) -> tuple[str, ...]: """Offset field names for `channel`, node 0 (shadows) to node N (highlights).""" @@ -30,11 +33,28 @@ def _node_fields(channel: str) -> tuple[str, ...]: return tuple(f"curve_{i}{suffix}" for i in range(CURVE_NODES)) +def _node_x_fields(channel: str) -> tuple[str, ...]: + """Position field names for `channel`, in code values, same node order.""" + suffix = "" if channel == "global" else f"_{channel}" + return tuple(f"curve_x_{i}{suffix}" for i in range(CURVE_NODES)) + + def curves_fields() -> tuple[str, ...]: - """Every ExposureConfig field curves owns, Global first, then R/G/B.""" + """Every ExposureConfig field curves owns: offsets then positions per channel.""" + from negpy.features.exposure.levels import LEVELS_CHANNELS + + return tuple(f for ch in LEVELS_CHANNELS for f in (*_node_fields(ch), *_node_x_fields(ch))) + + +def curves_defaults() -> dict[str, Any]: + """Identity value per curves field: zero offsets on the default grid.""" + defaults: dict[str, Any] = {} from negpy.features.exposure.levels import LEVELS_CHANNELS - return tuple(f for ch in LEVELS_CHANNELS for f in _node_fields(ch)) + for ch in LEVELS_CHANNELS: + defaults.update({f: 0.0 for f in _node_fields(ch)}) + defaults.update({f: x for f, x in zip(_node_x_fields(ch), CURVE_DEFAULT_X)}) + return defaults def channel_offsets(config: Any, channel: str) -> tuple[float, ...]: @@ -42,6 +62,11 @@ def channel_offsets(config: Any, channel: str) -> tuple[float, ...]: return tuple(float(min(max(float(getattr(config, f)), CURVE_OFFSET_MIN), CURVE_OFFSET_MAX)) for f in _node_fields(channel)) +def node_positions(config: Any, channel: str) -> tuple[int, ...]: + """Clamped node x for `channel`, in code values.""" + return tuple(int(min(max(round(float(getattr(config, f))), 0), 255)) for f in _node_x_fields(channel)) + + def curves_active(config: Any) -> bool: """True when any node leaves the identity line.""" return any(o != 0.0 for ch in ("global", "red", "green", "blue") for o in channel_offsets(config, ch)) @@ -55,12 +80,12 @@ def active_mask(config: Any) -> tuple[int, int, int, int]: def without_curves(config: Any) -> Any: - """`config` with every curves field at default, so a curves-only edit skips + """`config` with every curves field at its default, so a curves-only edit skips the exposure cache like a levels-only one does (compose with without_levels).""" from dataclasses import replace try: - return replace(config, **{f: 0.0 for f in curves_fields()}) + return replace(config, **curves_defaults()) except TypeError: return config @@ -69,8 +94,12 @@ def _pchip_slopes(x: np.ndarray, y: np.ndarray) -> np.ndarray: """Fritsch-Carlson monotone cubic slopes: the interpolant through non-decreasing data never overshoots. No scipy dependency by design.""" n = len(x) + if n < 2: + return np.zeros(max(n, 1), dtype=np.float64) h = np.diff(x) delta = np.diff(y) / h + if n == 2: + return np.array([delta[0], delta[0]], dtype=np.float64) m = np.empty(n, dtype=np.float64) m[0], m[-1] = delta[0], delta[-1] for k in range(1, n - 1): @@ -92,22 +121,31 @@ def _pchip_slopes(x: np.ndarray, y: np.ndarray) -> np.ndarray: return m -def bake_channel_lut(offsets: Any) -> np.ndarray: - """(256,) float32 output code values for node `offsets`: identity plus the - offset, held monotone, through PCHIP, sampled per code value.""" - grid = np.clip(np.asarray(CURVE_INPUTS, dtype=np.float64) + np.asarray(list(offsets), dtype=np.float64), 0.0, 255.0) - grid = np.maximum.accumulate(grid) - x = np.asarray(CURVE_INPUTS, dtype=np.float64) - slopes = _pchip_slopes(x, grid) +def bake_channel_lut(offsets: Any, x_positions: Any = None) -> np.ndarray: + """(256,) float32 output code values for node `offsets` at `x_positions` + (default the fixed grid): identity plus the offset, ordered and held + monotone, through PCHIP, sampled per code value. A degenerate grid falls + back to identity rather than dividing by zero.""" + if x_positions is None: + x_positions = CURVE_INPUTS + xs = np.asarray(list(x_positions), dtype=np.float64) + ys = np.clip(xs + np.asarray(list(offsets), dtype=np.float64), 0.0, 255.0) + order = np.argsort(xs, kind="stable") + xs, ys = xs[order], np.maximum.accumulate(ys[order]) + _, first = np.unique(xs, return_index=True) + xs, ys = xs[np.sort(first)], ys[np.sort(first)] + if len(xs) < 2: + return (np.arange(256, dtype=np.float32) / 255.0) + slopes = _pchip_slopes(xs, ys) xe = np.arange(256, dtype=np.float64) - k = np.clip(np.searchsorted(x, xe, side="right") - 1, 0, CURVE_NODES - 2) - h = x[k + 1] - x[k] - t = (xe - x[k]) / h + k = np.clip(np.searchsorted(xs, xe, side="right") - 1, 0, len(xs) - 2) + h = xs[k + 1] - xs[k] + t = (xe - xs[k]) / h t2, t3 = t * t, t * t * t y = ( - (2.0 * t3 - 3.0 * t2 + 1.0) * grid[k] + (2.0 * t3 - 3.0 * t2 + 1.0) * ys[k] + (t3 - 2.0 * t2 + t) * h * slopes[k] - + (-2.0 * t3 + 3.0 * t2) * grid[k + 1] + + (-2.0 * t3 + 3.0 * t2) * ys[k + 1] + (t3 - t2) * h * slopes[k + 1] ) return np.clip(y / 255.0, 0.0, 1.0).astype(np.float32) @@ -118,14 +156,17 @@ def bake_config_luts(config: Any) -> np.ndarray: the CPU application and the GPU storage-buffer upload.""" from negpy.features.exposure.levels import LEVELS_CHANNELS - return np.stack([bake_channel_lut(channel_offsets(config, ch)) for ch in LEVELS_CHANNELS]) + return np.stack([bake_channel_lut(channel_offsets(config, ch), node_positions(config, ch)) for ch in LEVELS_CHANNELS]) -def grid_outputs(config: Any, channel: str) -> tuple[int, ...]: - """The 8 node outputs the panel draws, in code values: what the bake holds, - so the markers never disagree with the render.""" - grid = np.clip(np.asarray(CURVE_INPUTS) + np.asarray(channel_offsets(config, channel)), 0.0, 255.0) - return tuple(int(v) for v in np.maximum.accumulate(grid).round()) +def node_points(config: Any, channel: str) -> tuple[tuple[int, int], ...]: + """The 8 (x, y) node positions the panel draws, in code values: what the + bake holds, so the markers never disagree with the render.""" + xs = np.asarray(node_positions(config, channel), dtype=np.float64) + ys = np.clip(xs + np.asarray(channel_offsets(config, channel)), 0.0, 255.0) + order = np.argsort(xs, kind="stable") + xs, ys = xs[order], np.maximum.accumulate(ys[order]) + return tuple((int(x), int(round(y))) for x, y in zip(xs, ys)) def _apply_lut_plane(plane: np.ndarray, lut: np.ndarray) -> np.ndarray: diff --git a/negpy/features/exposure/models.py b/negpy/features/exposure/models.py index 767a6f301..ec1e32837 100644 --- a/negpy/features/exposure/models.py +++ b/negpy/features/exposure/models.py @@ -164,6 +164,41 @@ class ExposureConfig: curve_5_blue: float = 0.0 curve_6_blue: float = 0.0 curve_7_blue: float = 0.0 + # Curves node positions matching the offsets above, in code values. Dragging + # a node horizontally moves its input; order is held at the panel and the + # bake, so the curve can never fold. + curve_x_0: int = 0 + curve_x_1: int = 36 + curve_x_2: int = 73 + curve_x_3: int = 109 + curve_x_4: int = 146 + curve_x_5: int = 182 + curve_x_6: int = 219 + curve_x_7: int = 255 + curve_x_0_red: int = 0 + curve_x_1_red: int = 36 + curve_x_2_red: int = 73 + curve_x_3_red: int = 109 + curve_x_4_red: int = 146 + curve_x_5_red: int = 182 + curve_x_6_red: int = 219 + curve_x_7_red: int = 255 + curve_x_0_green: int = 0 + curve_x_1_green: int = 36 + curve_x_2_green: int = 73 + curve_x_3_green: int = 109 + curve_x_4_green: int = 146 + curve_x_5_green: int = 182 + curve_x_6_green: int = 219 + curve_x_7_green: int = 255 + curve_x_0_blue: int = 0 + curve_x_1_blue: int = 36 + curve_x_2_blue: int = 73 + curve_x_3_blue: int = 109 + curve_x_4_blue: int = 146 + curve_x_5_blue: int = 182 + curve_x_6_blue: int = 219 + curve_x_7_blue: int = 255 def __post_init__(self) -> None: """ diff --git a/tests/test_curves.py b/tests/test_curves.py index b93ede6be..417ba724b 100644 --- a/tests/test_curves.py +++ b/tests/test_curves.py @@ -18,8 +18,11 @@ bake_channel_lut, channel_offsets, curves_active, + curves_defaults, curves_fields, - grid_outputs, + node_points, + node_positions, + without_curves, ) from negpy.features.exposure.models import ExposureConfig from negpy.infrastructure.gpu.device import GPUDevice @@ -29,7 +32,9 @@ class TestCurvesMath(unittest.TestCase): def test_identity_by_default(self) -> None: conf = ExposureConfig() self.assertFalse(curves_active(conf)) - self.assertEqual(len(curves_fields()), 4 * CURVE_NODES) + self.assertEqual(len(curves_fields()), 8 * CURVE_NODES) + self.assertEqual(curves_defaults(), {f: getattr(conf, f) for f in curves_fields()}) + self.assertEqual(without_curves(replace(conf, curve_3=9.0, curve_x_3=60)), conf) img = np.linspace(0.0, 1.0, 300, dtype=np.float32).reshape(10, 10, 3) np.testing.assert_array_equal(np.asarray(apply_curves(img, conf)), img) @@ -76,11 +81,37 @@ def test_config_values_are_clamped(self) -> None: out = np.asarray(apply_curves(np.full((2, 2, 3), 0.5, dtype=np.float32), conf)) self.assertTrue(bool(np.all(out >= 0.0)) and bool(np.all(out <= 1.0))) - def test_grid_outputs_match_the_bake(self) -> None: - self.assertEqual(grid_outputs(ExposureConfig(), "global"), (0, 36, 73, 109, 146, 182, 219, 255)) + def test_node_points_match_the_bake(self) -> None: + self.assertEqual( + node_points(ExposureConfig(), "global"), + ((0, 0), (36, 36), (73, 73), (109, 109), (146, 146), (182, 182), (219, 219), (255, 255)), + ) conf = replace(ExposureConfig(), curve_7_blue=-300.0) # Held at the monotone bound: the bake never folds, the marker shows it. - self.assertEqual(grid_outputs(conf, "blue")[-1], 219) + self.assertEqual(node_points(conf, "blue")[-1], (255, 219)) + moved = replace(ExposureConfig(), curve_x_3=60, curve_3=49.0) + self.assertEqual(node_points(moved, "global")[2], (60, 109)) + + def test_horizontal_drag_preserving_y_reshapes(self) -> None: + conf = replace(ExposureConfig(), curve_x_3=60, curve_3=49.0) + self.assertTrue(curves_active(conf)) + out = np.asarray(apply_curves(np.full((2, 2, 3), 80 / 255, dtype=np.float32), conf)) + self.assertAlmostEqual(float(out[0, 0, 0] * 255), 109, delta=2.0) + + def test_diagonal_x_move_stays_inactive(self) -> None: + # A node slid along the diagonal maps identity: correctly a no-op. + conf = replace(ExposureConfig(), curve_x_5_red=150) + self.assertFalse(curves_active(conf)) + self.assertEqual(node_positions(conf, "red")[5], 150) + + def test_bake_orders_and_dedupes(self) -> None: + # Unordered and stacked nodes bake without dividing by zero. + lut = bake_channel_lut((0.0,) * CURVE_NODES, (200, 0, 73, 109, 146, 182, 219, 255)) + self.assertTrue(bool(np.all(np.diff(lut) >= 0))) + # Zero offsets stay identity on any grid. + np.testing.assert_array_equal(lut, np.arange(256, dtype=np.float32) / 255.0) + degenerate = bake_channel_lut((5.0,) * CURVE_NODES, (100,) * CURVE_NODES) + np.testing.assert_array_equal(degenerate, np.arange(256, dtype=np.float32) / 255.0) class TestCurvesPipeline(unittest.TestCase): @@ -163,6 +194,8 @@ def test_cpu_gpu_match_curves(self) -> None: curve_5=-30.0, curve_2_red=40.0, curve_6_blue=-45.0, + curve_x_2=50, + curve_2=23.0, levels_in_low=10, levels_gamma=1.2, ), diff --git a/tests/test_slide_separation.py b/tests/test_slide_separation.py index f4581cb6e..bb8b54ef1 100644 --- a/tests/test_slide_separation.py +++ b/tests/test_slide_separation.py @@ -38,6 +38,7 @@ def _with_trims(*names: str) -> set: } # Display-referred curves run after levels on every path, for the same reason. | {f"curve_{i}{suffix}" for suffix in ("", "_red", "_green", "_blue") for i in range(8)} + | {f"curve_x_{i}{suffix}" for suffix in ("", "_red", "_green", "_blue") for i in range(8)} ) # Meter the frame on a raw slide and on a Positive frame alike; a slide starts with both off. METERS = {"auto_exposure", "auto_normalize_contrast"} @@ -73,6 +74,13 @@ def _moved(field: str, value): return not value if field == "paper_profile": return "kodak_endura" + if field.startswith("curve_x_"): + # Integer 0-255 positions: ±5 stays in range and off the end clamps. + return value - 5.0 if value > 128 else value + 5.0 + if field.startswith("curve_"): + # Offsets read through holds, clips and downstream lanes, so the move is + # sized to survive all three: up, except off the top end. + return value - 20.0 if field.split("_")[1] == "7" else value + 20.0 if field.startswith("levels_"): # Integer 0-255 bounds: ±25 stays in range and off the clamp for both ends. if "gamma" in field: @@ -95,8 +103,11 @@ def _image() -> np.ndarray: def _config(positive: bool): - """Toe, Shoulder and Dye Separation off neutral, so their widths, trims and damping bite.""" + """Toe, Shoulder and Dye Separation off neutral, so their widths, trims and damping bite. + A pinned-ends S-curve on every curves channel, so each offset and position move reshapes + live, nonlinear mapping rather than an identity the holds and clips swallow.""" cfg = DEFAULT_WORKSPACE_CONFIG + s_curve = (0.0, 12.0, -14.0, 22.0, -18.0, 16.0, -12.0, 0.0) exposure = replace( cfg.exposure, cast_removal_strength=cast_removal_for_mode(ProcessMode.E6, cfg.exposure.cast_removal_strength), @@ -105,6 +116,7 @@ def _config(positive: bool): dye_separation=1.3, auto_exposure=False, auto_normalize_contrast=False, + **{f"curve_{i}{sfx}": v for sfx in ("", "_red", "_green", "_blue") for i, v in enumerate(s_curve)}, ) return replace(cfg, process=replace(cfg.process, process_mode=ProcessMode.E6, positive_source=positive), exposure=exposure) diff --git a/tests/thumbnail_fingerprint_defaults.json b/tests/thumbnail_fingerprint_defaults.json index bb0189346..da5ae4476 100644 --- a/tests/thumbnail_fingerprint_defaults.json +++ b/tests/thumbnail_fingerprint_defaults.json @@ -48,6 +48,38 @@ "curve_7_blue": "0.0", "curve_7_green": "0.0", "curve_7_red": "0.0", + "curve_x_0": "0", + "curve_x_0_blue": "0", + "curve_x_0_green": "0", + "curve_x_0_red": "0", + "curve_x_1": "36", + "curve_x_1_blue": "36", + "curve_x_1_green": "36", + "curve_x_1_red": "36", + "curve_x_2": "73", + "curve_x_2_blue": "73", + "curve_x_2_green": "73", + "curve_x_2_red": "73", + "curve_x_3": "109", + "curve_x_3_blue": "109", + "curve_x_3_green": "109", + "curve_x_3_red": "109", + "curve_x_4": "146", + "curve_x_4_blue": "146", + "curve_x_4_green": "146", + "curve_x_4_red": "146", + "curve_x_5": "182", + "curve_x_5_blue": "182", + "curve_x_5_green": "182", + "curve_x_5_red": "182", + "curve_x_6": "219", + "curve_x_6_blue": "219", + "curve_x_6_green": "219", + "curve_x_6_red": "219", + "curve_x_7": "255", + "curve_x_7_blue": "255", + "curve_x_7_green": "255", + "curve_x_7_red": "255", "density": "1.0", "dye_separation": "1.0", "dye_separation_trim_blue": "0.0", From 6b0c54755b1716781dca55d3c890ba6bc561d362 Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Thu, 8 Oct 2026 22:37:02 +0200 Subject: [PATCH 11/12] fix curve reset --- negpy/desktop/view/sidebar/tone.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 9c7d5b201..27870122f 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -546,9 +546,9 @@ def _reset_curves_channel(self) -> None: self._write_curves(True, [(x, x) for x in CURVE_DEFAULT_X]) def _reset_curves_all(self) -> None: - from negpy.features.exposure.curves import curves_fields + from negpy.features.exposure.curves import curves_defaults - self.update_config_section("exposure", render=True, persist=True, readback_metrics=True, **{f: 0.0 for f in curves_fields()}) + self.update_config_section("exposure", render=True, persist=True, readback_metrics=True, **curves_defaults()) def _sync_curves_histogram(self) -> None: metrics = self.controller.state.last_metrics From 2d50ae6e83b476c5f6f25f29c9f3c4515efde0c1 Mon Sep 17 00:00:00 2001 From: Peter Barth Date: Fri, 9 Oct 2026 08:26:43 +0200 Subject: [PATCH 12/12] Revert "fine rotation adjustments" This reverts commit 6b7315cd78567cab34cf19bea12b856d4e508e63. --- negpy/desktop/controller.py | 1 - negpy/desktop/view/canvas/overlay.py | 15 ++------------- negpy/desktop/view/main_window.py | 1 - negpy/desktop/view/sidebar/geometry.py | 2 -- 4 files changed, 2 insertions(+), 17 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index a68ba732f..f965cb9c6 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -440,7 +440,6 @@ class AppController(QObject): analysis_buffer_preview_requested = pyqtSignal(float) analysis_buffer_drag_changed = pyqtSignal(bool) rotation_guide_requested = pyqtSignal() - rotation_grid_drag_changed = pyqtSignal(bool) keystone_lines_cleared = pyqtSignal() crop_guide_changed = pyqtSignal() dust_overlay_changed = pyqtSignal() diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 43d12e940..1d1163c52 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -72,7 +72,7 @@ _ROT_HANDLE_RADIUS_PX = 11.0 # hit + draw radius of the edge rotation handles _ROT_HANDLE_OFFSET_PX = 24.0 # gap between crop edge and handle center (outside the box) _ROT_FINE_SENSITIVITY = 0.2 # Shift-drag sensitivity, like the crop-move fine drag -_ROTATION_GRID_DIVISIONS = 20 +_ROTATION_GRID_DIVISIONS = 10 _GRID_ALPHA = 70 _MASK_RASTER_MAX = 384 # px cap for feathered overlay rasters @@ -440,7 +440,6 @@ def __init__(self, state: AppState, parent=None): self._buffer_hide_timer.timeout.connect(self._hide_buffer_overlay) self._rotation_grid_visible: bool = False - self._rotation_grid_dragging: bool = False self._rotation_grid_timer = QTimer(self) self._rotation_grid_timer.setSingleShot(True) self._rotation_grid_timer.timeout.connect(self._hide_rotation_grid) @@ -515,20 +514,10 @@ def _hide_buffer_overlay(self) -> None: self._buffer_overlay_visible = False self.update() - def set_rotation_grid_dragging(self, dragging: bool) -> None: - """Hold the grid while the slider is pressed; a stationary press fires no - valueChanged to keep restarting the hide timer.""" - self._rotation_grid_dragging = dragging - if dragging: - self._rotation_grid_timer.stop() - elif self._rotation_grid_visible: - self._rotation_grid_timer.start(1000) - def show_rotation_grid(self) -> None: """Show the rule-of-thirds alignment grid while Fine Rot is adjusted; lingers 1s.""" self._rotation_grid_visible = True - if not self._rotation_grid_dragging: - self._rotation_grid_timer.start(1000) + self._rotation_grid_timer.start(1000) self.update() def _hide_rotation_grid(self) -> None: diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py index ef0493895..579183c4f 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -606,7 +606,6 @@ def _connect_signals(self) -> None: self.controller.analysis_buffer_preview_requested.connect(self.canvas.overlay.show_analysis_buffer) self.controller.analysis_buffer_drag_changed.connect(self.canvas.overlay.set_analysis_buffer_dragging) self.controller.rotation_guide_requested.connect(self.canvas.overlay.show_rotation_grid) - self.controller.rotation_grid_drag_changed.connect(self.canvas.overlay.set_rotation_grid_dragging) self.controller.rotation_guide_requested.connect(self.canvas.overlay.show_crop_preview) self.controller.crop_guide_changed.connect(self.canvas.overlay.update) self.controller.dust_overlay_changed.connect(self.canvas.overlay.update) diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index 8b1071e33..728937b8a 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -166,8 +166,6 @@ def _connect_signals(self) -> None: lambda v: self.update_config_section("geometry", render=True, persist=False, readback_metrics=False, fine_rotation=-v) ) self.fine_rot_slider.valueChanged.connect(lambda _v: self.controller.show_rotation_guide()) - self.fine_rot_slider.dragStarted.connect(lambda: self.controller.rotation_grid_drag_changed.emit(True)) - self.fine_rot_slider.dragEnded.connect(lambda: self.controller.rotation_grid_drag_changed.emit(False)) self.fine_rot_slider.valueCommitted.connect( lambda v: self.update_config_section("geometry", render=True, persist=True, readback_metrics=True, fine_rotation=-v) )