From 2eba7e693e9738b8f010c783795507c0dab5469f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 17 Aug 2026 09:19:53 +0200 Subject: [PATCH 1/9] style: apply ruff format to test_updater --- tests/test_updater.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_updater.py b/tests/test_updater.py index a5ed958f..952e63fc 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -230,7 +230,9 @@ def test_the_windows_script_switches_cmd_to_utf8_before_the_paths(): """cmd parses a batch file in the OEM codepage, not the UTF-8 it is written in. The staging path carries the profile name, so a non-ASCII user name garbles every path unless the script switches the codepage first.""" - script = nsis_script(Path("C:\\Users\\José\\Temp\\Setup.exe"), Path(r"C:\Program Files\NegPy"), Path(r"C:\Program Files\NegPy\NegPy.exe"), 3) + script = nsis_script( + Path("C:\\Users\\José\\Temp\\Setup.exe"), Path(r"C:\Program Files\NegPy"), Path(r"C:\Program Files\NegPy\NegPy.exe"), 3 + ) assert "chcp 65001 >nul" in script assert script.index("chcp 65001") < script.index("José") From b22a1602b6ab589a6b5f5dff4f4ce11c1a40b392 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 17 Aug 2026 09:22:18 +0200 Subject: [PATCH 2/9] =?UTF-8?q?feat(tone):=20Contrast=20Mask=20=E2=80=94?= =?UTF-8?q?=20the=20darkroom's=20unsharp=20contrast-reduction=20mask?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sandwich the negative with a blurred, low-gamma positive, as a masking film and a spacer do in the darkroom. Densities add, so the sandwich is D' = D - g*blur(D) + const: the global range compresses to fit the paper while the blur keeps fine detail out of the compression. This is the pipeline's only pre-curve spatial operator. Everything upstream of the print curve is pointwise, so no curve control can raise local contrast and shorten the range at once — Grade only trades one for the other. Measured on a synthetic wide-range negative at R60, RMS-Laplacian micro-contrast per density third goes 2.55/0.24/0.03 unmasked to 5.85/0.27/0.08 at gamma 0.6. Not a stage: normalization.contrast_mask_plane builds the plane and logic.contrast_mask_ev turns it into stops for the dodge/burn map the print stage already reads. Equal stops is an equal absolute density change in all three channels, which is what a neutral panchromatic masking film records — so the control is global, with no per-layer trim. The plane is built on the crop only. The enlarger projects the frame you print, and a bright rebate or a black scanner surround blurred into the mask pushes a low-frequency ramp inward that prints as a vignette the negative does not have: on a uniform picture inside a bright border it swings the plane -0.10 to +0.23, over a stop of edge-to-centre exposure. Placed back at the crop and edge-replicated outside, so the crop tool's full-frame preview has no seam. Both engines call the same helper on the same pre-geometry array, so CPU and GPU renders are bit-identical (measured 0.00000 max abs diff). Sigma is a fraction of the analysis grid, never of the render, so preview and export mask alike. The plane is zero-mean: a real sandwich is denser and the printer opens up for it, so the slider leaves print density alone. Hidden on the transparency transfer path, which takes no dodge/burn map. Sources: Ctein, Post Exposure; Bond, Unsharp Masking; Adams, The Print. --- docs/PIPELINE.md | 5 + docs/USER_GUIDE.md | 1 + negpy/desktop/settings_catalog.py | 1 + negpy/desktop/view/shortcut_registry.py | 2 + negpy/desktop/view/sidebar/tone.py | 18 ++ negpy/desktop/view/slider_shortcut_groups.py | 1 + negpy/desktop/view/slider_targets.py | 1 + negpy/features/exposure/logic.py | 41 +++++ negpy/features/exposure/models.py | 3 + negpy/features/exposure/normalization.py | 86 +++++++++ negpy/features/exposure/processor.py | 12 ++ negpy/services/rendering/engine.py | 30 +++ negpy/services/rendering/gpu_engine.py | 61 ++++++- tests/test_contrast_mask.py | 181 +++++++++++++++++++ 14 files changed, 434 insertions(+), 9 deletions(-) create mode 100644 tests/test_contrast_mask.py diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 1b1adcee..7822c5c9 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -131,6 +131,11 @@ Here is what happens to your image. We apply these steps in order, passing the b One function (`local/logic.rasterise`) serves the render, the canvas tint and the printing-notes map, so none of the three can describe a different shape than the others. The GPU consumes the same CPU-rasterised map (`compute_local_maps` → the dodge/burn texture), so shapes need no shader work and have no parity surface. * **Local Grade** (`LocalMask.grade`, ISO-R points off the frame's Grade, negative = harder): burning or dodging *through a different filter*, which on variable-contrast paper is what a hard-filter burn is. The masks rasterize a second plane in the same pass (`compute_local_maps`: plane 0 EV, plane 1 summed $\Delta R$), and the $\Delta R$ becomes a per-pixel slope multiplier through the same ratio a per-layer Grade trim uses, $R/(R+\Delta R)$ clamped to the ISO-R ladder (`local_grade_factor_map`, single source for the CPU kernel and the GPU's uploaded map). In the curve it multiplies the straight-line slope only, $v = k \cdot g \cdot (x_{adj} - x_0) + c \cdot x_{adj}^2$, so the rotation is **about the channel pivot**: a grade-only mask changes its region's contrast without moving its density, and the cast-removal curvature $c$ stays global. All three channels take the same factor, matching global Grade. On the GPU the factor rides the dodge/burn texture's green channel, so it costs no extra bind slot; the metrics and the zone ruler still describe the frame-wide grade, not a masked region's. +* **Contrast Mask** (`ExposureConfig.contrast_mask`, 0 to 0.5, default 0): the darkroom's unsharp contrast-reduction mask — a blurred, low-gamma positive contact-printed off the negative and sandwiched with it. Densities add, so the sandwich is $D' = D - g\,\text{blur}(D) + \text{const}$: a linear high-boost in log density that compresses the global range while the blur keeps fine detail out of the compression. $g$ is the mask film's gamma, and the published range is the slider's range (Ctein, *Post Exposure*; Bond, *Unsharp Masking*; Adams, *The Print*). + + It is not a stage. The plane is built by `normalization.contrast_mask_plane` and converted to stops by `logic.contrast_mask_ev`, then added to the dodge/burn map the print stage already reads — equal stops is an equal absolute density change in all three channels, which is what a neutral panchromatic masking film records, and why the control has no per-layer trim. Both engines call the same helper on the same pre-geometry array and replay geometry on the downsampled copy, so the two mask identically. $\sigma$ is a fraction of the analysis grid, never of the render, so preview and export mask alike. The plane is returned zero-mean: a real sandwich is denser and the printer opens up for it, so the control leaves print density alone. + + **The plane covers the printed frame only.** The enlarger projects the crop, so the rebate and the scanner surround stay out of the mask; blurred over, they push a low-frequency ramp inward and print as a vignette the negative does not have. The plane is placed back at the crop and edge-replicated outside, so the crop tool's full-frame preview has no seam at the crop line. Degenerate bounds (a frame that never metered) return a zero plane rather than amplifying a broken stretch. Hidden on the transparency transfer path, which takes no dodge/burn map. Instruments (densitometer, zone pins, the placement solver) read the unmasked negative, as they already do under a dodge. * **Output**: converts print density back to **scene-linear** reflectance (transmittance): $$I_{out} = 10^{-D}$$ * **Paper Black** (`paper_black`, off): off applies black point compensation, the same idea as ICC relative-colorimetric soft-proofing. A reflection print's D-max ($2.3$) floors reflectance at $10^{-2.3} \approx 0.005$, but the adapted eye reads paper black as black, so the display should too; on preserves the paper's lifted D-max instead. With compensation, the default, each channel becomes $I_{out} = (I - t_b) / (1 - t_b)$, clamped at $0$, where $t_b = 10^{-D_b}$ and $D_b$ is the physical $D_{max}$, or $D_{max} + \text{toe}_{ch} \cdot 0.90$ when that layer's toe is negative. The curve reaches $D_{max}$ only asymptotically, so a **negative toe raises the clip point** into the shadows, which is what makes exact $0$ reachable and "negative toe deepens blacks" literal. A lifted toe and per-layer shadow casts survive because the reference is the *physical* $D_{max}$, not $D_{max,eff}$. A negative per-layer toe trim, with compensation on, tints the deepest black. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e32922bf..4ec992f1 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -489,6 +489,7 @@ The paper's response. A **Global / R / G / B** selector at the top scopes most c These two also work in Transparency with **Normalize off**, on the same tones (the centres are mapped by position on each curve's own scale, not by raw density), and there they are the only mid-sparing controls: Shadows Density opens the quarter-tone with the highlights unmoved, where Grade and Toe drag the whole scale with them and cost the highlights. * **Shadows Grade** / **Highlights Grade** (split grade, ±50 ISO-R): rotate contrast locally in the deep shadows or highlights, the digital equivalent of split-grade printing. +* **Contrast Mask** (0.0 to 0.5, hidden in Transparency): sandwich the negative with a blurred, low-contrast positive, as the darkroom does with a mask film and a spacer. The value is that mask's gamma. It squeezes the negative's overall range so a harder grade still fits the paper, while the blur keeps fine detail out of the squeeze, so local contrast and texture stay put. Use it when a scene is too contrasty to print at the grade you want: raise the mask, then take the Grade back down in R. Above about 0.4 the low-frequency lift starts to show as a soft halo along strong edges, which is what a real masked print looks like. It reads only what you have cropped, the way the enlarger only projects the frame you print, so the rebate and the scanner surround cannot leak in as a vignette. It is a global control: a masking film is neutral, so there is no per-layer trim and the slider greys out in R/G/B mode. * **Dye Separation** (0.5 to 1.5, hidden in B&W Negative): saturation in density space. It pushes the print's three dye densities apart *before* the positive is decoded, in the same matrix the paper's own dye crosstalk uses, so it responds to the paper profile you picked and eases off automatically where the curve is already compressed at toe and shoulder, instead of forcing color into tones that have none left to give. Below 1.0 it pulls the dyes together toward neutral. 1.0 is off. Contrast this with **Chroma** in the Color tab, which scales color evenly after decode. * **Separation Damping** (0 to 1, hidden in B&W Negative): decides *where* the Dye Separation push lands, rather than adding a push of its own. At 0 every color gets the same treatment. Turn it up and muted color keeps the full push while color that is already saturated gets the opposite, so a hard push puts color into the tones that had none instead of driving the strongest colors until they flatten into a slab. Below 1.0 separation it mirrors: pastels go grey while the vivid colors survive. It is **dead at Dye Separation 1.0**, where the slider greys out, because it has no look of its own. This is not the same as backing Dye Separation off: a lower value takes color from *everything*, including tones that had little to start with, where turning damping up takes it only from the colors that already have plenty. diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index 44eefe78..debb37b7 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -124,6 +124,7 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: _row("Dye Separation", "exposure", "dye_separation"), _row("Dye Separation Trim", "exposure", "dye_separation_trim_red", "dye_separation_trim_green", "dye_separation_trim_blue", channels="RGB"), _row("Separation Damping", "exposure", "separation_damping"), + _row("Contrast Mask", "exposure", "contrast_mask"), _row("Auto Exposure", "exposure", "auto_exposure"), _row("Auto Contrast", "exposure", "auto_normalize_contrast"), _row("Paper Profile", "exposure", "paper_profile"), diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 39eddaae..78efe3fd 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -88,6 +88,8 @@ class ShortcutEntry: "dye_separation_inc": ShortcutEntry("", "Dye Separation up", "Exposure"), "separation_damping_dec": ShortcutEntry("", "Separation Damping down", "Exposure"), "separation_damping_inc": ShortcutEntry("", "Separation Damping up", "Exposure"), + "contrast_mask_dec": ShortcutEntry("", "Contrast Mask down", "Exposure"), + "contrast_mask_inc": ShortcutEntry("", "Contrast Mask up", "Exposure"), "lock_bounds_toggle": ShortcutEntry("Alt+Q", "Toggle bounds lock", "Process"), "scan_setup": ShortcutEntry("", "Scanning setup wizard", "Process"), "scan_prescan": ShortcutEntry("", "Prescan and set crop (Plustek)", "Process"), diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 110e42aa..2ec29770 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -137,6 +137,16 @@ def _init_ui(self) -> None: split_grade_row.addWidget(self.highlight_grade_slider) self.layout.addLayout(split_grade_row) + self.contrast_mask_slider = CompactSlider("Contrast Mask", 0.0, 0.5, conf.contrast_mask) + self.contrast_mask_slider.setToolTip( + "Contrast Mask: sandwich the negative with a blurred low-contrast positive, as in the " + "darkroom. The value is the mask film's gamma — it compresses the negative's overall " + "range so a harder grade fits the paper, while local contrast and fine detail stay put." + ) + contrast_mask_row = QHBoxLayout() + contrast_mask_row.addWidget(self.contrast_mask_slider) + self.layout.addLayout(contrast_mask_row) + # Density-domain saturation, composed into the same dye_mix slot as the paper's real dye # crosstalk, rather than a post-hoc Lab-space a*/b* self.dye_separation_slider = CompactSlider("Dye Separation", 0.5, 1.5, conf.dye_separation, has_neutral=True) @@ -227,6 +237,9 @@ def _init_ui(self) -> None: self.paper_combo, self.shadow_density_slider, self.highlight_density_slider, + # A pan masking film is neutral: the mask is one plane subtracted as equal + # density from every layer, so it has no per-channel form to trim. + self.contrast_mask_slider, ) def _open_targets_dialog(self) -> None: @@ -316,6 +329,7 @@ def _connect_signals(self) -> None: (self.highlight_density_slider, "highlight_density"), (self.dye_separation_slider, "dye_separation"), (self.separation_damping_slider, "separation_damping"), + (self.contrast_mask_slider, "contrast_mask"), ): slider.valueChanged.connect( lambda v, f=field: self.update_config_section("exposure", render=True, persist=False, readback_metrics=False, **{f: v}) @@ -418,6 +432,8 @@ def sync_ui(self) -> None: self.dye_separation_slider, self.dye_separation_trim_slider, self.separation_damping_slider, + # The transfer curve takes no dodge/burn map, and the mask rides it. + self.contrast_mask_slider, ): w.setVisible(not transfer) @@ -480,6 +496,7 @@ def sync_ui(self) -> None: self.highlight_density_slider.setValue(conf.highlight_density) self.dye_separation_slider.setValue(conf.dye_separation) self.separation_damping_slider.setValue(conf.separation_damping) + self.contrast_mask_slider.setValue(conf.contrast_mask) # It redistributes Dye Separation's push and does nothing on its own, so at 1.0 # separation it is dead. Say so instead of letting it be dragged for no result. self.separation_damping_slider.setEnabled(conf.dye_separation != 1.0) @@ -515,6 +532,7 @@ def block_signals(self, blocked: bool) -> None: self.separation_damping_slider, self.shadow_grade_slider, self.highlight_grade_slider, + self.contrast_mask_slider, self.paper_dmin_btn, self.paper_black_btn, self.auto_density_btn, diff --git a/negpy/desktop/view/slider_shortcut_groups.py b/negpy/desktop/view/slider_shortcut_groups.py index bd1374a7..8aa715ec 100644 --- a/negpy/desktop/view/slider_shortcut_groups.py +++ b/negpy/desktop/view/slider_shortcut_groups.py @@ -86,6 +86,7 @@ def _g( _g("highlight_grade", "Highlights grade ↑/↓", "highlight_grade_inc", "highlight_grade_dec", 1.0, "Exposure", step_decimals=0), _g("dye_separation", "Dye Separation ↑/↓", "dye_separation_inc", "dye_separation_dec", 0.01, "Exposure"), _g("separation_damping", "Separation Damping ↑/↓", "separation_damping_inc", "separation_damping_dec", 0.01, "Exposure"), + _g("contrast_mask", "Contrast Mask ↑/↓", "contrast_mask_inc", "contrast_mask_dec", 0.01, "Exposure"), _g("offset", "Crop offset ↑/↓", "offset_inc", "offset_dec", 1.0, "Geometry", step_decimals=0, step_suffix=" px"), _g("fine_rot", "Fine rotation ↑/↓", "fine_rot_inc", "fine_rot_dec", 0.01, "Geometry", step_suffix="°"), _g("analysis_buffer", "Analysis buffer ↑/↓", "analysis_buffer_inc", "analysis_buffer_dec", 0.01, "Process"), diff --git a/negpy/desktop/view/slider_targets.py b/negpy/desktop/view/slider_targets.py index 1bdeac07..760fc3a1 100644 --- a/negpy/desktop/view/slider_targets.py +++ b/negpy/desktop/view/slider_targets.py @@ -26,6 +26,7 @@ "highlight_grade": "tone_sidebar.highlight_grade_slider", "dye_separation": "tone_sidebar.dye_separation_slider", "separation_damping": "tone_sidebar.separation_damping_slider", + "contrast_mask": "tone_sidebar.contrast_mask_slider", "offset": "geometry_sidebar.offset_slider", "fine_rot": "geometry_sidebar.fine_rot_slider", "analysis_buffer": "process_sidebar.analysis_buffer_slider", diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index 306e57c1..31380ea1 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -1,5 +1,6 @@ from typing import Any, Dict, Optional, Tuple +import cv2 import numpy as np from numba import njit, prange # type: ignore @@ -1107,6 +1108,46 @@ def local_ev_scale(bounds: Any) -> Tuple[float, float, float]: return (out[0], out[1], out[2]) +def contrast_mask_ev( + plane: Optional[np.ndarray], + gamma: float, + density_range: float, + out_shape: Tuple[int, int], + roi: Optional[Tuple[int, int, int, int]] = None, +) -> Optional[np.ndarray]: + """ + The contrast mask as print-exposure stops, ready to add to the dodge/burn map. + + Sandwiching a blurred gamma-g positive with the negative gives D' = D - g*blur(D), + which in normalized space is val - g*blur. One stop is log10(2) of density, so + dividing by that turns the density subtraction into the stops the map carries — and + equal stops is an equal absolute density change in every channel, which is what a + neutral panchromatic masking film records. + + The plane covers the printed frame, so with a crop it is placed back at `roi` and + the surround is edge-replicated: outside the crop nothing is printed, and replicating + keeps the crop tool's full-frame preview free of a seam at the crop line. + """ + if plane is None or gamma <= 0.0: + return None + ev = (-gamma * plane * float(density_range) / float(np.log10(2.0))).astype(np.float32) + h, w = out_shape + if roi is None: + if ev.shape[:2] != (h, w): + ev = cv2.resize(ev, (w, h), interpolation=cv2.INTER_LINEAR) + return np.ascontiguousarray(ev, dtype=np.float32) + + y1, y2, x1, x2 = roi + y1, x1 = max(0, y1), max(0, x1) + y2, x2 = min(h, y2), min(w, x2) + if y2 - y1 < 1 or x2 - x1 < 1: + return None + inner = cv2.resize(ev, (x2 - x1, y2 - y1), interpolation=cv2.INTER_LINEAR) + if (y1, x1, y2, x2) == (0, 0, h, w): + return np.ascontiguousarray(inner, dtype=np.float32) + return np.ascontiguousarray(np.pad(inner, ((y1, h - y2), (x1, w - x2)), mode="edge"), dtype=np.float32) + + def cmy_to_density(val: float, log_range: float = 1.0) -> float: """ Converts a CMY slider value (-1.0..1.0) to a physical density shift (D). diff --git a/negpy/features/exposure/models.py b/negpy/features/exposure/models.py index fb94d37d..606ef93b 100644 --- a/negpy/features/exposure/models.py +++ b/negpy/features/exposure/models.py @@ -43,6 +43,9 @@ class ExposureConfig: # Ranges are asymmetric: density is log10, so an equal ΔD reads smaller near d_max. shadow_density: float = 0.0 highlight_density: float = 0.0 + # Unsharp contrast-reduction mask: the gamma of the blurred low-contrast positive + # sandwiched with the negative. [0.0, 0.5]; 0 = no mask. + contrast_mask: float = 0.0 # Split grade: zone contrast in ISO-R points (negative = harder), global # value + per-layer trims like Grade. shadow_grade: float = 0.0 diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 3b4cbe06..025017a1 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -2,6 +2,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING, Optional, Tuple +import cv2 import numpy as np from numba import njit # type: ignore @@ -15,6 +16,11 @@ # Above this size the block-median is threaded over row strips (np.median frees the GIL). _BLOCK_MEDIAN_PARALLEL_MIN_PIXELS = 2_000_000 +# Unsharpness of the contrast mask, as a fraction of the analysis grid's short side. A +# fraction of the grid rather than of the render is what keeps the mask identical at +# preview and export resolution. +MASK_SIGMA_FRACTION = 0.04 + @njit(cache=True, fastmath=True) def _normalize_log_image_jit(img_log: np.ndarray, floors: np.ndarray, ceils: np.ndarray) -> np.ndarray: @@ -507,6 +513,86 @@ def measure_textural_range( return measure_textural_range_from_log(img_log, roi, analysis_buffer) +def normalized_roi(roi: Optional[Tuple[int, int, int, int]], shape: Tuple[int, int]) -> Optional[Tuple[float, float, float, float]]: + """An (y1, y2, x1, x2) pixel ROI as fractions of `shape`, so it can be replayed on + any downsampled copy. None (or a full-frame ROI) stays None.""" + if roi is None: + return None + h, w = shape + y1, y2, x1, x2 = roi + if (y1, x1, y2, x2) == (0, 0, h, w): + return None + return (y1 / float(h), y2 / float(h), x1 / float(w), x2 / float(w)) + + +def contrast_mask_plane( + image: ImageBuffer, + bounds: LogNegativeBounds, + unmix: Optional[np.ndarray], + rotation: int = 0, + fine_rotation: float = 0.0, + flip_horizontal: bool = False, + flip_vertical: bool = False, + distortion_k1: float = 0.0, + roi_norm: Optional[Tuple[float, float, float, float]] = None, +) -> np.ndarray: + """ + The blurred low-gamma positive of an unsharp contrast-reduction mask, as a + normalized luminance plane on the analysis grid. + + Takes the linear frame *before* geometry and replays it on the downsampled copy, + so both engines can call this on the same array and get the same plane. Sigma is a + fraction of the grid, never of the render, so preview and export mask alike. + + `roi_norm` is the printed frame as (y1, y2, x1, x2) fractions. The plane covers only + that: the enlarger projects the frame you print, so rebate and scanner surround must + stay out of the mask — blurred over, they would print as a vignette the negative + does not have. + + Returned zero-mean: a sandwiched mask is denser and the printer opens up for it, so + the plane carries only the redistribution and leaves print density alone. + """ + from negpy.features.exposure.models import EXPOSURE_CONSTANTS + from negpy.features.geometry.logic import apply_fine_rotation, apply_radial_distortion + + h, w = image.shape[:2] + grid = int(EXPOSURE_CONSTANTS["analysis_grid"]) + if max(h, w) > grid: + scale = grid / float(max(h, w)) + image = cv2.resize(image, (max(1, round(w * scale)), max(1, round(h * scale))), interpolation=cv2.INTER_AREA) + + if rotation: + image = np.rot90(image, k=rotation) + if flip_horizontal: + image = np.fliplr(image) + if flip_vertical: + image = np.flipud(image) + image = np.ascontiguousarray(image) + if fine_rotation != 0.0: + image = apply_fine_rotation(image, fine_rotation) + if distortion_k1 != 0.0: + image = apply_radial_distortion(image, distortion_k1) + + if roi_norm is not None: + gh, gw = image.shape[:2] + y1 = max(0, min(gh - 1, int(round(roi_norm[0] * gh)))) + y2 = max(y1 + 1, min(gh, int(round(roi_norm[1] * gh)))) + x1 = max(0, min(gw - 1, int(round(roi_norm[2] * gw)))) + x2 = max(x1 + 1, min(gw, int(round(roi_norm[3] * gw)))) + image = np.ascontiguousarray(image[y1:y2, x1:x2]) + + # Degenerate bounds (a frame that never metered) would normalize to huge values and + # the mask would amplify them into the print. No stretch, no mask. + if luminance_density_range(bounds) < 1e-6: + return np.zeros(image.shape[:2], dtype=np.float32) + + val = normalize_log_image(unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), bounds) + lum = LUMA_R * val[:, :, 0] + LUMA_G * val[:, :, 1] + LUMA_B * val[:, :, 2] + sigma = MASK_SIGMA_FRACTION * min(lum.shape[:2]) + blurred = cv2.GaussianBlur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), sigma, borderType=cv2.BORDER_REPLICATE) + return blurred - float(blurred.mean()) + + def normalize_log_image(img_log: ImageBuffer, bounds: LogNegativeBounds) -> ImageBuffer: """ Stretches log-data to fit [0, 1]. diff --git a/negpy/features/exposure/processor.py b/negpy/features/exposure/processor.py index ee2801e7..73cdc217 100644 --- a/negpy/features/exposure/processor.py +++ b/negpy/features/exposure/processor.py @@ -9,6 +9,7 @@ apply_characteristic_curve, apply_flat_curve, cast_solve_inputs, + contrast_mask_ev, effective_midtone_gamma, filtration_offsets, flat_curve_params, @@ -339,6 +340,17 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: local_maps = self._build_local_maps(image, context) ev_map = None if local_maps is None else np.ascontiguousarray(local_maps[:, :, 0]) + # The contrast mask reaches the print as print exposure, like a dodge, so it rides + # the same map instead of needing a stage of its own. + mask_ev = contrast_mask_ev( + context.metrics.get("contrast_mask_plane"), + self.config.contrast_mask, + lum_range if lum_range else 1.0, + image.shape[:2], + context.metrics.get("contrast_mask_roi"), + ) + if mask_ev is not None: + ev_map = mask_ev if ev_map is None else np.ascontiguousarray(ev_map + mask_ev) grade_map = None if local_maps is not None and local_maps[:, :, 1].any(): grade_map = local_grade_factor_map(np.ascontiguousarray(local_maps[:, :, 1]), self.config.grade) diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index afad4947..fcdca563 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -1,4 +1,7 @@ from typing import Optional, Any, Callable, Tuple + +import numpy as np + from negpy.domain.types import ImageBuffer from negpy.domain.interfaces import PipelineContext from negpy.domain.models import WorkspaceConfig @@ -14,6 +17,7 @@ NormalizationProcessor, PhotometricProcessor, ) +from negpy.features.exposure.normalization import contrast_mask_plane, effective_crosstalk_matrix, normalized_roi from negpy.features.process.hue import apply_hue_trim from negpy.features.exposure.papers import effective_paper_profile from negpy.features.cyanotype.processor import CyanotypeProcessor @@ -36,6 +40,7 @@ class DarkroomEngine: def __init__(self) -> None: self.config = APP_CONFIG self.cache = PipelineCache() + self._mask_plane: Optional[Tuple[Any, np.ndarray]] = None def _run_stage( self, @@ -156,6 +161,31 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: ) current_img, pipeline_changed = self._run_stage(current_img, base_key, "base", run_base, context, pipeline_changed) + # Built from the source, not from the base output, so the GPU engine can call the + # same helper on the same array. Keyed on base, so the Contrast Mask slider re-runs + # only the exposure stage. + mask_bounds = context.metrics.get("final_bounds") + if settings.exposure.contrast_mask > 0.0 and mask_bounds is not None: + mask_roi = context.active_roi + mask_key = (calculate_config_hash(base_key), mask_roi, current_img.shape[:2]) + if self._mask_plane is None or self._mask_plane[0] != mask_key: + self._mask_plane = ( + mask_key, + contrast_mask_plane( + img, + mask_bounds, + effective_crosstalk_matrix(settings.process, settings.process.process_mode), + rotation=settings.geometry.rotation, + fine_rotation=settings.geometry.fine_rotation, + flip_horizontal=settings.geometry.flip_horizontal, + flip_vertical=settings.geometry.flip_vertical, + distortion_k1=distortion_k1, + roi_norm=normalized_roi(mask_roi, current_img.shape[:2]), + ), + ) + context.metrics["contrast_mask_plane"] = self._mask_plane[1] + context.metrics["contrast_mask_roi"] = mask_roi + def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: img_out = PhotometricProcessor(settings.exposure, settings.local, settings.process).process(img_in, ctx) # Rides this stage: it needs the print, and its own stage would re-run everything behind diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 7721c0f5..2f8cd3c1 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -17,7 +17,9 @@ from negpy.features.exposure.normalization import ( LogNegativeBounds, analyze_log_exposure_bounds_from_log, + contrast_mask_plane, luma_source_bounds, + normalized_roi, luminance_density_range, measure_anchor_from_log, measure_clip_fractions, @@ -251,6 +253,7 @@ def __init__(self) -> None: self._uv_grid_cache: Optional[Tuple[Tuple, np.ndarray]] = None # Identity of the dodge/burn EV map currently sitting in the local_ev texture. self._local_ev_key: Optional[Tuple] = None + self._mask_plane: Optional[Tuple[Tuple, np.ndarray]] = None def _detect_invalidated_stage(self, settings: WorkspaceConfig, scale_factor: float, render_size_ref: Optional[float] = None) -> int: """ @@ -597,6 +600,29 @@ def _analyze_bounds() -> LogNegativeBounds: self._analysis_cache, analysis_key, bounds, shadow_refs, metered_anchor, textural_range, neutral_axis_refs ) + # Same helper, same pre-geometry array as the CPU engine, so the two mask alike. + # Keyed off the meter, so only the Contrast Mask slider's own value stays live. + mask_plane = None + mask_key = None + if settings.exposure.contrast_mask > 0.0 and not tiling_mode: + mask_key = (analysis_key, bounds, roi, (h_rot, w_rot)) + if self._mask_plane is None or self._mask_plane[0] != mask_key: + self._mask_plane = ( + mask_key, + contrast_mask_plane( + img, + bounds, + unmix_m, + rotation=settings.geometry.rotation, + fine_rotation=settings.geometry.fine_rotation, + flip_horizontal=settings.geometry.flip_horizontal, + flip_vertical=settings.geometry.flip_vertical, + distortion_k1=k1_eff, + roi_norm=normalized_roi(roi, (h_rot, w_rot)), + ), + ) + mask_plane = self._mask_plane[1] + # CPU meter cost, logged once per source (skips creative-slider re-renders). if analysis_source is not None and analysis_source_hash is not None and analysis_source_hash != self._analysis_timing_hash: self._analysis_timing_hash = analysis_source_hash @@ -667,7 +693,8 @@ def _analyze_bounds() -> LogNegativeBounds: ) # The dodge/burn EV map feeds the exposure pass. A zero-initialized 1x1 dummy keeps # the bind group valid when no masks are active (ev_scale.w gates it). - if settings.local.masks: + wants_ev_map = bool(settings.local.masks) or mask_plane is not None + if wants_ev_map: tex_local_ev = self._get_intermediate_texture( w_rot, h_rot, @@ -715,9 +742,9 @@ def _analyze_bounds() -> LogNegativeBounds: w_rot, h_rot, ) - if settings.local.masks: + if wants_ev_map: # This stage re-runs for any exposure change, but the map only moves - # with the masks, the geometry and the grade. + # with the masks, the geometry, the grade and the contrast mask. tiled_maps = local_maps is not None ev_key = ( settings.local, @@ -727,12 +754,14 @@ def _analyze_bounds() -> LogNegativeBounds: settings.geometry.flip_vertical, k1_eff, settings.exposure.grade, + settings.exposure.contrast_mask, + mask_key, orig_shape, w_rot, h_rot, ) if tiled_maps or self._local_ev_key != ev_key: - if local_maps is None: + if local_maps is None and settings.local.masks: local_maps = compute_local_maps( settings.local, h_rot, @@ -744,16 +773,25 @@ def _analyze_bounds() -> LogNegativeBounds: flip_vertical=settings.geometry.flip_vertical, distortion_k1=k1_eff, ) - from negpy.features.exposure.logic import local_grade_factor_map + from negpy.features.exposure.logic import contrast_mask_ev, local_grade_factor_map + + if local_maps is None: + local_maps = np.zeros((h_rot, w_rot, 2), dtype=np.float32) + ev_plane = local_maps[:, :, 0] + mask_ev = contrast_mask_ev( + mask_plane, settings.exposure.contrast_mask, luminance_density_range(bounds), (h_rot, w_rot), roi + ) + if mask_ev is not None: + ev_plane = ev_plane + mask_ev # r = dodge/burn EV, g = local grade slope factor, b unused. One texture, # so the local-grade map costs no bind slot. tex_local_ev.upload( np.dstack( [ - local_maps[:, :, 0], + ev_plane, local_grade_factor_map(local_maps[:, :, 1], settings.exposure.grade), - np.zeros_like(local_maps[:, :, 0]), + np.zeros_like(ev_plane), ] ) ) @@ -1378,8 +1416,13 @@ def _upload_unified_uniforms( + struct.pack("ffff", dye_rows[0, 0], dye_rows[0, 1], dye_rows[0, 2], _mg3[0]) + struct.pack("ffff", dye_rows[1, 0], dye_rows[1, 1], dye_rows[1, 2], _mg3[1]) + struct.pack("ffff", dye_rows[2, 0], dye_rows[2, 1], dye_rows[2, 2], _mg3[2]) - # Dodge/burn EV-stop size per channel (local_ev_scale); w = enable flag. - + struct.pack("ffff", *local_ev_scale(LogNegativeBounds(adj_floors, adj_ceils)), 1.0 if settings.local.masks else 0.0) + # Dodge/burn EV-stop size per channel (local_ev_scale); w = enable flag. The + # contrast mask rides the same map, so it opens the same gate. + + struct.pack( + "ffff", + *local_ev_scale(LogNegativeBounds(adj_floors, adj_ceils)), + 1.0 if (settings.local.masks or exp.contrast_mask > 0.0) else 0.0, + ) # Split Grade per-channel zone contrast gains (split_grade_deltas). The w-lanes # carry Separation Damping's green and blue k. + struct.pack("ffff", _sg3[0], _sg3[1], _sg3[2], sat_k3[1]) diff --git a/tests/test_contrast_mask.py b/tests/test_contrast_mask.py new file mode 100644 index 00000000..6fa77f9b --- /dev/null +++ b/tests/test_contrast_mask.py @@ -0,0 +1,181 @@ +"""The unsharp contrast-reduction mask. + +The mask reaches the print through the dodge/burn EV map, so both engines build the +plane with the same helper on the same pre-geometry array. These pin the three things +that would silently break it: the sign, the resolution invariance, and CPU/GPU parity. +""" + +import unittest +from dataclasses import replace + +import cv2 +import numpy as np + +from negpy.domain.models import WorkspaceConfig +from negpy.features.exposure.logic import contrast_mask_ev +from negpy.features.exposure.normalization import LogNegativeBounds, contrast_mask_plane +from negpy.infrastructure.gpu.device import GPUDevice +from negpy.services.rendering.engine import DarkroomEngine + + +def _wide_range_negative(h: int = 240, w: int = 360) -> np.ndarray: + """A negative whose subject range runs far past the paper scale, with fine texture + at every density so micro-contrast is measurable in each third.""" + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + key = np.exp(-3.2 * (yy / h)) + texture = 1.0 + 0.16 * np.sin(xx / 3.0) * np.sin(yy / 2.5) + scene = np.clip(key * texture, 1e-5, None) + scene /= scene.max() + neg = np.clip(0.03 + 0.85 * (1.0 - scene**0.35), 1e-4, 1.0) + return np.ascontiguousarray(np.dstack([neg, neg, neg]).astype(np.float32)) + + +def _bw_settings(**exposure) -> WorkspaceConfig: + s = WorkspaceConfig() + return replace( + s, + process=replace(s.process, process_mode="B&W"), + exposure=replace(s.exposure, auto_exposure=False, auto_normalize_contrast=False, **exposure), + ) + + +def _micro_contrast(plane: np.ndarray) -> list[float]: + """RMS Laplacian per horizontal third: thin, mid, dense.""" + lap = cv2.Laplacian(plane, cv2.CV_32F) + h = plane.shape[0] + return [float(np.sqrt((band**2).mean()) * 100) for band in (lap[: h // 3], lap[h // 3 : 2 * h // 3], lap[2 * h // 3 :])] + + +def _render(img: np.ndarray, settings: WorkspaceConfig, tag: str) -> np.ndarray: + return np.asarray(DarkroomEngine().process(img.copy(), settings, tag))[:, :, 1] + + +class TestContrastMaskPlane(unittest.TestCase): + def test_plane_is_zero_mean(self): + """A sandwiched mask is denser and the printer opens up for it; the plane must + carry only the redistribution, or the slider would double as Print Density.""" + img = _wide_range_negative() + plane = contrast_mask_plane(img, LogNegativeBounds(floors=(-1.4, -1.4, -1.4), ceils=(-0.05, -0.05, -0.05)), None) + self.assertAlmostEqual(float(plane.mean()), 0.0, places=5) + + def test_surround_outside_the_crop_stays_out_of_the_mask(self): + """The enlarger projects the frame you print. Blurred over, a bright rebate or a + black scanner surround prints as a vignette the negative does not have, so the + plane must be built on the crop, not on the whole scan.""" + h, w = 600, 900 + img = np.full((h, w, 3), 0.35, dtype=np.float32) + border = 60 + img[:border] = img[-border:] = img[:, :border] = img[:, -border:] = 0.98 + + bounds = LogNegativeBounds(floors=(-1.4, -1.4, -1.4), ceils=(-0.05, -0.05, -0.05)) + roi_norm = (border / h, (h - border) / h, border / w, (w - border) / w) + + cropped = contrast_mask_plane(img, bounds, None, roi_norm=roi_norm) + whole = contrast_mask_plane(img, bounds, None) + + # A uniform picture area has no low frequencies, so its mask must be flat. + self.assertLess(float(cropped.std()), 0.002, f"crop-respecting plane std {cropped.std():.5f}") + # Without the crop the border bleeds in and manufactures a gradient. + self.assertGreater(float(whole.std()), 10.0 * float(cropped.std()) + 0.01) + + def test_ev_places_the_plane_back_at_the_crop(self): + plane = np.full((4, 6), 0.25, dtype=np.float32) + ev = contrast_mask_ev(plane, 0.5, 1.4, (20, 30), roi=(5, 15, 6, 24)) + assert ev is not None + self.assertEqual(ev.shape, (20, 30)) + # Edge-replicated outside the crop, so the crop tool's full-frame view has no seam. + np.testing.assert_allclose(ev[0, 0], ev[5, 6], rtol=1e-5) + + def test_ev_is_off_when_gamma_is_zero(self): + plane = np.zeros((8, 8), dtype=np.float32) + self.assertIsNone(contrast_mask_ev(plane, 0.0, 1.4, (8, 8))) + self.assertIsNone(contrast_mask_ev(None, 0.5, 1.4, (8, 8))) + + def test_ev_opposes_the_plane(self): + """The sandwich subtracts the blurred positive, so a dense low-frequency area + must come back as negative exposure and a thin one as positive.""" + plane = np.array([[-0.2, 0.2]], dtype=np.float32) + ev = contrast_mask_ev(plane, 0.5, 1.4, (1, 2)) + assert ev is not None + self.assertGreater(ev[0, 0], 0.0) + self.assertLess(ev[0, 1], 0.0) + + def test_ev_scales_with_gamma(self): + plane = np.array([[0.25]], dtype=np.float32) + half = contrast_mask_ev(plane, 0.25, 1.4, (1, 1)) + full = contrast_mask_ev(plane, 0.5, 1.4, (1, 1)) + assert half is not None and full is not None + self.assertAlmostEqual(float(full[0, 0]), 2.0 * float(half[0, 0]), places=5) + + +class TestContrastMaskRender(unittest.TestCase): + def test_mask_raises_micro_contrast_at_every_density(self): + """The point of the mask: the global range is compressed, so a hard grade's + local contrast survives at both ends instead of being traded away.""" + img = _wide_range_negative() + plain = _render(img, _bw_settings(grade=60.0), "cm-plain") + masked = _render(img, _bw_settings(grade=60.0, contrast_mask=0.5), "cm-masked") + + for band, (before, after) in enumerate(zip(_micro_contrast(plain), _micro_contrast(masked))): + self.assertGreater(after, before, f"band {band}: {before:.2f} -> {after:.2f}") + + def test_mask_compresses_the_global_range(self): + img = _wide_range_negative() + plain = _render(img, _bw_settings(grade=60.0), "cm-plain") + masked = _render(img, _bw_settings(grade=60.0, contrast_mask=0.5), "cm-masked") + self.assertLess(masked.std(), plain.std()) + + def test_zero_gamma_changes_nothing(self): + img = _wide_range_negative() + off = _render(img, _bw_settings(grade=60.0), "cm-off") + explicit = _render(img, _bw_settings(grade=60.0, contrast_mask=0.0), "cm-off") + np.testing.assert_allclose(off, explicit, atol=1e-6) + + def test_mask_survives_a_resolution_change(self): + """Sigma is a fraction of the analysis grid, not of the render, so the mask + must not add a resolution dependence of its own on top of the pipeline's.""" + img = _wide_range_negative() + h, w = img.shape[:2] + big = cv2.resize(img, (w * 2, h * 2), interpolation=cv2.INTER_LINEAR) + + def drift(**kw): + small = _render(img, _bw_settings(grade=60.0, **kw), "cm-res-s" + str(kw)) + large = _render(big, _bw_settings(grade=60.0, **kw), "cm-res-l" + str(kw)) + return float(np.abs(small - cv2.resize(large, (w, h), interpolation=cv2.INTER_AREA)).mean()) + + self.assertLess(drift(contrast_mask=0.5), 2.0 * drift() + 0.002) + + +@unittest.skipUnless(GPUDevice.get().is_available, "GPU not available") +class TestContrastMaskParity(unittest.TestCase): + def test_cpu_gpu_match(self): + from negpy.services.rendering.image_processor import ImageProcessor + + processor = ImageProcessor() + if processor.engine_gpu is None: + self.skipTest("GPU engine not initialised") + + img = _wide_range_negative(96, 144) + settings = _bw_settings(grade=60.0, contrast_mask=0.5) + + def render(prefer_gpu: bool) -> np.ndarray: + result, _ = processor.run_pipeline( + img.copy(), + settings, + "contrast-mask-parity", + render_size_ref=float(max(img.shape[:2])), + prefer_gpu=prefer_gpu, + readback_metrics=False, + ) + arr = np.asarray(result.readback()) if hasattr(result, "readback") else np.asarray(result) + return arr[:, :, :3].astype(np.float64) + + cpu = render(False) + gpu = render(True) + self.assertEqual(cpu.shape, gpu.shape) + self.assertLess(float(np.mean(np.abs(cpu - gpu))), 0.01) + self.assertLess(float(np.max(np.abs(cpu - gpu))), 0.04) + + +if __name__ == "__main__": + unittest.main() From 809a8e54a978754da45942736621f99ece5deca6 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 17 Aug 2026 09:56:35 +0200 Subject: [PATCH 3/9] feat(geometry): converging verticals, the tilted easel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perspective correction, absent until now: rot90, flips, fine rotation and the radial k1 are all rigid or radially symmetric, so none of them moved a converging vertical by a pixel. A 28mm lens tilted 8 degrees up on 35mm gives a top:bottom width ratio of 1.128 — about 770 px across a 6000 px scan — and the only route was exporting a positive TIFF and losing the print stage. A plane-to-plane projectivity, which is what a tilted easel realises (Scheimpflug BP 1196; Adams, The Print). Runs last in the forward chain, after k1: a projectivity cannot be fitted to a frame that still carries barrel distortion. Output keeps the canvas size and replicates the wedge, as fine rotation does — scaling to fill would change magnification and drag a crop the user had already drawn. The unit is per-cent of the frame, not tilt degrees. Convergence is (H/2)sin(tau)/D, so the same tilt keystones differently at every enlargement, and nothing here models a magnification or a focal length; degrees would be a reference enlarger silently baked in. keystone_matrix_normalized is the single definition of the quad. The pixel matrix and the GPU's inverse both derive from it — built twice they disagree by half a pixel and a w/(w-1) scale, which is a real bug I hit and measured. The shader consumes that inverse from the geometry uniform (the two free distortion lanes were not enough, so the block grows to 64B) rather than rebuilding the quad. Everything that reads geometry carries it: the uv grid, map_coords_to_geometry for dodge/burn vertices, autocrop's replay and detection key, and the GPU's own analysis replay. That last one is the subtle one — the meters must read the frame the print stage gets, and skipping it there had the engines normalizing different pixels, worth 0.031 mean parity error. With it, keystone parity is 0.00007 mean / 0.002 max, better than fine rotation's. CoordinateMapping now fits the grid projectively instead of affinely, for the off-frame points card-edge masks depend on; its own docstring already conceded the affine model was exact only for the affine part. --- docs/PIPELINE.md | 3 + docs/USER_GUIDE.md | 4 + negpy/desktop/settings_catalog.py | 2 + negpy/desktop/view/shortcut_registry.py | 4 + negpy/desktop/view/sidebar/geometry.py | 28 ++++ negpy/desktop/view/slider_shortcut_groups.py | 2 + negpy/desktop/view/slider_targets.py | 2 + negpy/features/exposure/normalization.py | 5 +- negpy/features/exposure/processor.py | 2 + negpy/features/geometry/logic.py | 92 +++++++++++ negpy/features/geometry/models.py | 7 + negpy/features/geometry/processor.py | 7 + .../features/geometry/shaders/transform.wgsl | 21 ++- negpy/features/local/logic.py | 4 + negpy/services/rendering/engine.py | 4 + negpy/services/rendering/gpu_engine.py | 24 ++- negpy/services/view/coordinate_mapping.py | 63 ++++--- tests/test_keystone.py | 156 ++++++++++++++++++ 18 files changed, 403 insertions(+), 27 deletions(-) create mode 100644 tests/test_keystone.py diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 7822c5c9..08cce881 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -9,6 +9,9 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Rotation**: we spin the image array in 90° steps and fine-tune with affine transformations, using bilinear interpolation so it stays sharp. * **Lens distortion**: a radial $k_1$ coefficient, a rig property mirrored from the active flat-field profile (`flatfield.k1`), corrected in the same resample. +* **Converging verticals** (`geometry.converge_v` / `converge_h`, ±15%): perspective correction as easel tilt — a plane-to-plane projectivity (Hartley & Zisserman §2.3), the transform a tilted easel realises. It runs **last** in the forward chain, after $k_1$: a projectivity cannot be fitted to a frame that still carries barrel distortion. The unit is per-cent of the frame, not tilt degrees, because convergence is $(H/2)\sin\tau / D$ — the same tilt keystones differently at every enlargement, and no magnification or focal length is modelled. Output keeps the canvas size and replicates the wedge, as fine rotation does; scaling to fill would change magnification and move an existing crop. + + `keystone_matrix_normalized` is the single definition of the quad. The pixel-space matrix and the GPU's inverse are both derived from it — built twice they would disagree by half a pixel and a $w/(w-1)$ scale. The shader undoes it first, from that same inverse passed in the geometry uniform, so it never rebuilds the quad. Everything that reads geometry carries it too: the uv grid, `map_coords_to_geometry` (dodge/burn vertices), autocrop's detection replay and key, and the GPU's own analysis replay — the meters must read the frame the print stage gets, or the two engines normalize different pixels. `CoordinateMapping` fits the grid projectively rather than affinely for the same reason; off-frame points, which card-edge masks depend on, would otherwise be wrong. * **Autocrop**: we detect where the film ends and the scanner bed begins by looking for the density jump. It is not perfect, because light leaks and odd scanning holders can fool it, so there is a manual override. Detection runs once per edit, in `ImageProcessor` ahead of either engine, and the rect is stored on the edit (`geometry.crop_rect`, with `crop_from_auto` marking where it came from). Both engines only slice that rect. The border walk reads whatever buffer it is given, and a preview buffer and a full-resolution export can stop on different edges, so detecting per render would export a crop the user never saw. The rect keeps the detection key it was found under (`autocrop_detection_key`: orientation, ratio, mode, rebate trim); a change there re-detects on the next render. Crop Offset is not in the key, because it is re-applied to the rect every render. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4ec992f1..a1c7c9e7 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -435,6 +435,10 @@ Where the frame gets its final shape: what is inside the print, and whether it s * **Fine Rotation** (±45°): free rotation for tilted scans, in sub-degree steps (positive is clockwise). Applied after auto-crop so the frame stays axis-aligned. * **Straighten** tool (ruler): draw a line along a horizon or vertical edge and NegPy rotates to make it level or plumb. +* **Converging Verticals** (±15%): straighten a building that leans back because the camera was tilted up, the way a printer tilts the easel under the enlarger. Positive stretches the top edge. The unit is per-cent of the frame, the amount you would measure with a ruler on the easel, not a tilt angle — the same tilt keystones differently at every enlargement, and NegPy models no enlarger. +* **Converging Horizontals** (±15%): the same correction across the frame, for a wall shot from one side, or a copy stand that is not square to the film. Positive stretches the left edge. + + Both leave a wedge of replicated edge along the squeezed side, exactly as Fine Rotation does; crop it off. Nothing is scaled to hide it, because that would quietly change your magnification and drag a crop you had already drawn. Crop first if you can: the meters read the corrected frame, so on a still-uncropped scan a large correction pulls rebate and scanner surround further into the metered area and the print gets darker. Inside a crop the exposure barely moves. ### 5.2 Flat Field: even out the light diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index debb37b7..13e82815 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -96,6 +96,8 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: ("Rotation", ( _row("Rotation", "geometry", "rotation"), _row("Fine Rotation", "geometry", "fine_rotation"), + _row("Converging Verticals", "geometry", "converge_v"), + _row("Converging Horizontals", "geometry", "converge_h"), _row("Flip Horizontal", "geometry", "flip_horizontal"), _row("Flip Vertical", "geometry", "flip_vertical"), )), diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 78efe3fd..2713dc2e 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -34,6 +34,10 @@ class ShortcutEntry: "offset_inc": ShortcutEntry("X", "Crop offset up", "Geometry"), "fine_rot_dec": ShortcutEntry("Alt+Shift+R", "Fine rotation counter-clockwise", "Geometry"), "fine_rot_inc": ShortcutEntry("Alt+R", "Fine rotation clockwise", "Geometry"), + "converge_v_dec": ShortcutEntry("", "Converging verticals down", "Geometry"), + "converge_v_inc": ShortcutEntry("", "Converging verticals up", "Geometry"), + "converge_h_dec": ShortcutEntry("", "Converging horizontals down", "Geometry"), + "converge_h_inc": ShortcutEntry("", "Converging horizontals up", "Geometry"), "straighten": ShortcutEntry("L", "Toggle straighten line tool", "Geometry"), "pick_wb": ShortcutEntry("Shift+W", "Toggle WB picker", "Tools"), "manual_crop": ShortcutEntry("Shift+C", "Toggle manual crop", "Tools"), diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index 55913535..4ea0ec30 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -170,6 +170,22 @@ def _init_ui(self) -> None: align_row.addWidget(self.straighten_btn, 0) self.layout.addLayout(align_row) + self.converge_v_slider = CompactSlider("Converging Verticals", -15.0, 15.0, conf.converge_v, unit="%") + self.converge_v_slider.setToolTip( + "Converging Verticals: straighten a building that leans back, the way a printer tilts " + "the easel. Positive stretches the top edge. Measured as a per-cent of the frame, not " + "a tilt angle, because the same tilt keystones differently at every enlargement." + ) + self.converge_h_slider = CompactSlider("Converging Horizontals", -15.0, 15.0, conf.converge_h, unit="%") + self.converge_h_slider.setToolTip( + "Converging Horizontals: the same correction across the frame, for a wall shot from " + "one side or a copy stand that is not square. Positive stretches the left edge." + ) + converge_row = QHBoxLayout() + converge_row.addWidget(self.converge_v_slider) + converge_row.addWidget(self.converge_h_slider) + self.layout.addLayout(converge_row) + def cycle_guide(self) -> None: self.guide_combo.setCurrentIndex((self.guide_combo.currentIndex() + 1) % self.guide_combo.count()) @@ -212,6 +228,14 @@ def _connect_signals(self) -> None: lambda v: self.update_config_section("geometry", render=True, persist=True, readback_metrics=True, fine_rotation=-v) ) + for slider, field in ((self.converge_v_slider, "converge_v"), (self.converge_h_slider, "converge_h")): + slider.valueChanged.connect( + lambda v, f=field: self.update_config_section("geometry", render=True, persist=False, readback_metrics=False, **{f: v}) + ) + slider.valueCommitted.connect( + lambda v, f=field: self.update_config_section("geometry", render=True, persist=True, readback_metrics=True, **{f: v}) + ) + def _on_ratio_changed(self, ratio: str) -> None: self.controller.set_crop_ratio(ratio) @@ -269,6 +293,8 @@ def sync_ui(self) -> None: self.offset_slider.setValue(float(conf.autocrop_offset)) self.rebate_trim_slider.setValue(conf.autocrop_rebate_trim * 100.0) self.fine_rot_slider.setValue(-conf.fine_rotation) + self.converge_v_slider.setValue(conf.converge_v) + self.converge_h_slider.setValue(conf.converge_h) self.manual_crop_btn.setChecked(self.state.active_tool == ToolMode.CROP_MANUAL) self.straighten_btn.setChecked(self.state.active_tool == ToolMode.STRAIGHTEN) @@ -289,6 +315,8 @@ def block_signals(self, blocked: bool) -> None: self.offset_slider.blockSignals(blocked) self.rebate_trim_slider.blockSignals(blocked) self.fine_rot_slider.blockSignals(blocked) + self.converge_v_slider.blockSignals(blocked) + self.converge_h_slider.blockSignals(blocked) self.manual_crop_btn.blockSignals(blocked) self.straighten_btn.blockSignals(blocked) self.reset_crop_btn.blockSignals(blocked) diff --git a/negpy/desktop/view/slider_shortcut_groups.py b/negpy/desktop/view/slider_shortcut_groups.py index 8aa715ec..b84ef114 100644 --- a/negpy/desktop/view/slider_shortcut_groups.py +++ b/negpy/desktop/view/slider_shortcut_groups.py @@ -89,6 +89,8 @@ def _g( _g("contrast_mask", "Contrast Mask ↑/↓", "contrast_mask_inc", "contrast_mask_dec", 0.01, "Exposure"), _g("offset", "Crop offset ↑/↓", "offset_inc", "offset_dec", 1.0, "Geometry", step_decimals=0, step_suffix=" px"), _g("fine_rot", "Fine rotation ↑/↓", "fine_rot_inc", "fine_rot_dec", 0.01, "Geometry", step_suffix="°"), + _g("converge_v", "Converging verticals ↑/↓", "converge_v_inc", "converge_v_dec", 0.1, "Geometry", step_suffix="%"), + _g("converge_h", "Converging horizontals ↑/↓", "converge_h_inc", "converge_h_dec", 0.1, "Geometry", step_suffix="%"), _g("analysis_buffer", "Analysis buffer ↑/↓", "analysis_buffer_inc", "analysis_buffer_dec", 0.01, "Process"), _g("luma_range_clip", "Luma range clip ↑/↓", "luma_range_clip_inc", "luma_range_clip_dec", 1.0, "Process", step_decimals=0), _g("color_range_clip", "Color range clip ↑/↓", "color_range_clip_inc", "color_range_clip_dec", 1.0, "Process", step_decimals=0), diff --git a/negpy/desktop/view/slider_targets.py b/negpy/desktop/view/slider_targets.py index 760fc3a1..c9e8623b 100644 --- a/negpy/desktop/view/slider_targets.py +++ b/negpy/desktop/view/slider_targets.py @@ -29,6 +29,8 @@ "contrast_mask": "tone_sidebar.contrast_mask_slider", "offset": "geometry_sidebar.offset_slider", "fine_rot": "geometry_sidebar.fine_rot_slider", + "converge_v": "geometry_sidebar.converge_v_slider", + "converge_h": "geometry_sidebar.converge_h_slider", "analysis_buffer": "process_sidebar.analysis_buffer_slider", "luma_range_clip": "process_sidebar.luma_range_clip_slider", "color_range_clip": "process_sidebar.color_range_clip_slider", diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 025017a1..6b1e9aa8 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -534,6 +534,8 @@ def contrast_mask_plane( flip_horizontal: bool = False, flip_vertical: bool = False, distortion_k1: float = 0.0, + converge_v: float = 0.0, + converge_h: float = 0.0, roi_norm: Optional[Tuple[float, float, float, float]] = None, ) -> np.ndarray: """ @@ -553,7 +555,7 @@ def contrast_mask_plane( the plane carries only the redistribution and leaves print density alone. """ from negpy.features.exposure.models import EXPOSURE_CONSTANTS - from negpy.features.geometry.logic import apply_fine_rotation, apply_radial_distortion + from negpy.features.geometry.logic import apply_fine_rotation, apply_keystone, apply_radial_distortion h, w = image.shape[:2] grid = int(EXPOSURE_CONSTANTS["analysis_grid"]) @@ -572,6 +574,7 @@ def contrast_mask_plane( image = apply_fine_rotation(image, fine_rotation) if distortion_k1 != 0.0: image = apply_radial_distortion(image, distortion_k1) + image = apply_keystone(image, converge_v, converge_h) if roi_norm is not None: gh, gw = image.shape[:2] diff --git a/negpy/features/exposure/processor.py b/negpy/features/exposure/processor.py index 73cdc217..b1a89d83 100644 --- a/negpy/features/exposure/processor.py +++ b/negpy/features/exposure/processor.py @@ -263,6 +263,8 @@ def _build_local_maps(self, image: ImageBuffer, context: PipelineContext) -> Opt flip_horizontal=geo.get("flip_horizontal", False), flip_vertical=geo.get("flip_vertical", False), distortion_k1=context.metrics.get("distortion_k1", 0.0), + converge_v=geo.get("converge_v", 0.0), + converge_h=geo.get("converge_h", 0.0), ) def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 75bdec08..8cadaed1 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1738,6 +1738,88 @@ def map_point_radial(px: float, py: float, k1: float, w: int, h: int) -> Tuple[f return cx + ix * scale, cy + iy * scale +# Converging verticals, the tilted easel. A plane-to-plane projectivity: the output +# rectangle samples a trapezoid of the source, so one edge is stretched and the opposite +# one squeezed. The unit is per-cent of frame width (or height), the number a printer +# measures on the easel — not tilt degrees, which need an enlarger magnification and a +# focal length the pipeline does not model. Mirrored in transform.wgsl and applied to +# points in map_coords_to_geometry; change the quad in all three. + +_KEYSTONE_EPS = 1e-4 # per-cent + + +def keystone_matrix_normalized(converge_v: float, converge_h: float) -> np.ndarray: + """Forward map (source -> corrected) in normalized frame coords. + + The single definition of the quad. "Normalized" is the convention the GPU samples in, + u = (index + 0.5) / dims, so the frame spans [0, 1] and the pixel-space matrix below + is derived from this one rather than built a second time — the two would otherwise + disagree by half a pixel and a w/(w-1) scale. + + Both convergences are per-cent of the frame, the config's unit. Positive converge_v + stretches the top edge, which straightens a frame shot with the camera tilted up; + positive converge_h stretches the left edge. + """ + a, b = converge_v * 0.005, converge_h * 0.005 + src = np.float32([[a, b], [1.0 - a, -b], [1.0 + a, 1.0 + b], [-a, 1.0 - b]]) + dst = np.float32([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) + return cv2.getPerspectiveTransform(src, dst).astype(np.float64) + + +def _norm_to_index(w: int, h: int) -> np.ndarray: + """Normalized [0,1] -> OpenCV pixel-index coords, where pixel i sits at index i.""" + return np.array([[float(w), 0.0, -0.5], [0.0, float(h), -0.5], [0.0, 0.0, 1.0]], dtype=np.float64) + + +def keystone_matrix(converge_v: float, converge_h: float, w: int, h: int) -> np.ndarray: + """The same map in OpenCV pixel-index coords, for warpPerspective.""" + a = _norm_to_index(w, h) + return a @ keystone_matrix_normalized(converge_v, converge_h) @ np.linalg.inv(a) + + +def apply_keystone(img: ImageBuffer, converge_v: float, converge_h: float) -> ImageBuffer: + """Perspective correction. Same canvas size, replicated edges: the wedge stays and + the user crops it, exactly as fine rotation does. Scaling to fill would silently + change magnification and drag a crop the user already drew.""" + if abs(converge_v) < _KEYSTONE_EPS and abs(converge_h) < _KEYSTONE_EPS: + return img + h, w = img.shape[:2] + res = cv2.warpPerspective( + img, + keystone_matrix(converge_v, converge_h, w, h), + (w, h), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REPLICATE, + ) + return ensure_image(res) + + +def map_point_keystone(px: float, py: float, converge_v: float, converge_h: float, w: int, h: int) -> Tuple[float, float]: + """Where a pre-correction point lands in the corrected output.""" + if abs(converge_v) < _KEYSTONE_EPS and abs(converge_h) < _KEYSTONE_EPS: + return px, py + m = keystone_matrix(converge_v, converge_h, w, h) + den = m[2, 0] * px + m[2, 1] * py + m[2, 2] + if abs(den) < 1e-12: + return px, py + return ( + float((m[0, 0] * px + m[0, 1] * py + m[0, 2]) / den), + float((m[1, 0] * px + m[1, 1] * py + m[1, 2]) / den), + ) + + +def keystone_inverse_normalized(converge_v: float, converge_h: float) -> np.ndarray: + """The keystone's inverse (corrected -> source) in normalized coords. + + The GPU undoes the keystone first and consumes this directly, so the shader never + rebuilds the quad and cannot drift from the CPU. Identity when both are zero. + """ + if abs(converge_v) < _KEYSTONE_EPS and abs(converge_h) < _KEYSTONE_EPS: + return np.eye(3, dtype=np.float64) + m = np.linalg.inv(keystone_matrix_normalized(converge_v, converge_h)) + return m / m[2, 2] + + def apply_margin_to_roi( roi: ROI, h: int, @@ -2008,6 +2090,8 @@ def autocrop_detection_key(geometry: GeometryConfig) -> str: int(geometry.flip_horizontal), int(geometry.flip_vertical), round(geometry.fine_rotation, 4), + round(geometry.converge_v, 4), + round(geometry.converge_h, 4), geometry.autocrop_ratio, geometry.autocrop_mode, round(geometry.autocrop_rebate_trim, 4), @@ -2046,6 +2130,9 @@ def resolve_autocrop_rect( tmp = np.ascontiguousarray(tmp.astype(np.float32, copy=False)) if geometry.fine_rotation != 0.0: tmp = apply_fine_rotation(tmp, geometry.fine_rotation) + # Detection must see the frame the render produces, or the rect is found on a + # straight rebate and applied to a keystoned one. + tmp = apply_keystone(tmp, geometry.converge_v, geometry.converge_h) rh, rw = tmp.shape[:2] if rh < 2 or rw < 2: @@ -2075,6 +2162,8 @@ def map_coords_to_geometry( flip_vertical: bool = False, roi: Optional[ROI] = None, distortion_k1: float = 0.0, + converge_v: float = 0.0, + converge_h: float = 0.0, ) -> Tuple[float, float]: """ Maps raw coordinates to geometry-transformed space. @@ -2110,6 +2199,9 @@ def map_coords_to_geometry( if distortion_k1 != 0.0: px, py = map_point_radial(px, py, distortion_k1, w, h) + if converge_v != 0.0 or converge_h != 0.0: + px, py = map_point_keystone(px, py, converge_v, converge_h, w, h) + if roi: y1, y2, x1, x2 = roi px -= x1 diff --git a/negpy/features/geometry/models.py b/negpy/features/geometry/models.py index 78c8e0aa..b0e70cd9 100644 --- a/negpy/features/geometry/models.py +++ b/negpy/features/geometry/models.py @@ -106,6 +106,13 @@ class GeometryConfig: fine_rotation: float = 0.0 flip_horizontal: bool = False flip_vertical: bool = False + # Perspective correction, the tilted easel. Per-cent of the frame, the number a + # printer measures with a ruler: tilt degrees would need an enlarger magnification + # and focal length the pipeline does not model. Positive converge_v stretches the + # top edge, which straightens a frame shot with the camera tilted up; positive + # converge_h stretches the left edge. + converge_v: float = 0.0 # [-15.0, 15.0] % + converge_h: float = 0.0 # [-15.0, 15.0] % autocrop_offset: int = 0 # Free, not 3:2: autocrop reads the film format off the detected frame, so the # default fits 6x6, 645 and 6x7 as well as 35mm. A fixed 3:2 center-cropped every diff --git a/negpy/features/geometry/processor.py b/negpy/features/geometry/processor.py index ed99d58b..2105ca37 100644 --- a/negpy/features/geometry/processor.py +++ b/negpy/features/geometry/processor.py @@ -4,6 +4,7 @@ from negpy.features.geometry.models import GeometryConfig from negpy.features.geometry.logic import ( apply_fine_rotation, + apply_keystone, apply_margin_to_roi, apply_radial_distortion, get_manual_rect_coords, @@ -37,11 +38,17 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: if self.distortion_k1 != 0.0: img = apply_radial_distortion(img, self.distortion_k1) + # Last in the forward chain: a plane projectivity cannot be fitted to a frame that + # still carries barrel distortion. + img = apply_keystone(img, self.config.converge_v, self.config.converge_h) + context.metrics["geometry_params"] = { "rotation": self.config.rotation, "fine_rotation": self.config.fine_rotation, "flip_horizontal": self.config.flip_horizontal, "flip_vertical": self.config.flip_vertical, + "converge_v": self.config.converge_v, + "converge_h": self.config.converge_h, } # Downstream coordinate mappers (retouch/local) need the same correction. context.metrics["distortion_k1"] = self.distortion_k1 diff --git a/negpy/features/geometry/shaders/transform.wgsl b/negpy/features/geometry/shaders/transform.wgsl index eaeae7d4..82a06c50 100644 --- a/negpy/features/geometry/shaders/transform.wgsl +++ b/negpy/features/geometry/shaders/transform.wgsl @@ -4,6 +4,11 @@ struct GeometryUniforms { flip_h: i32, // 0 or 1 flip_v: i32, // 0 or 1 distortion: vec4, // (k1, scale_to_fill, _, _) — radial lens correction + // Keystone inverse in [0,1] coords, rows packed as (h00,h01,h02,h10) and + // (h11,h12,h20,h21); h22 is 1. Built by keystone_inverse_normalized on the CPU, so + // the quad is never derived twice. All zeros = no keystone. + keystone_a: vec4, + keystone_b: vec4, }; @group(0) @binding(0) var input_tex: texture_2d; @@ -52,7 +57,21 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } let coords = vec2(i32(gid.x), i32(gid.y)); - let out_uv = vec2(f32(coords.x) + 0.5, f32(coords.y) + 0.5) / vec2(f32(out_dims.x), f32(out_dims.y)); + var out_uv = vec2(f32(coords.x) + 0.5, f32(coords.y) + 0.5) / vec2(f32(out_dims.x), f32(out_dims.y)); + + // Inverse keystone. It is the last forward op, so it is undone first, before the + // frame is centred. Mirrors apply_keystone on the CPU via the matrix it built. + let ka = params.keystone_a; + let kb = params.keystone_b; + if (ka.x != 0.0 || ka.y != 0.0 || kb.z != 0.0 || kb.w != 0.0) { + let den = kb.z * out_uv.x + kb.w * out_uv.y + 1.0; + if (abs(den) > 1e-12) { + out_uv = vec2( + (ka.x * out_uv.x + ka.y * out_uv.y + ka.z) / den, + (ka.w * out_uv.x + kb.x * out_uv.y + kb.y) / den, + ); + } + } // 1. Center UV var uv = out_uv - 0.5; diff --git a/negpy/features/local/logic.py b/negpy/features/local/logic.py index f4692f18..80868ca8 100644 --- a/negpy/features/local/logic.py +++ b/negpy/features/local/logic.py @@ -85,6 +85,8 @@ def compute_local_maps( flip_horizontal: bool = False, flip_vertical: bool = False, distortion_k1: float = 0.0, + converge_v: float = 0.0, + converge_h: float = 0.0, ) -> np.ndarray: """ Build the per-pixel dodge/burn maps [h, w, 2] float32, each plane the sum over @@ -111,6 +113,8 @@ def compute_local_maps( flip_horizontal, flip_vertical, distortion_k1=distortion_k1, + converge_v=converge_v, + converge_h=converge_h, ) for rx, ry in mask.vertices ] diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index fcdca563..306f1396 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -179,6 +179,8 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: fine_rotation=settings.geometry.fine_rotation, flip_horizontal=settings.geometry.flip_horizontal, flip_vertical=settings.geometry.flip_vertical, + converge_v=settings.geometry.converge_v, + converge_h=settings.geometry.converge_h, distortion_k1=distortion_k1, roi_norm=normalized_roi(mask_roi, current_img.shape[:2]), ), @@ -257,6 +259,8 @@ def run_lab(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: autocrop=True, autocrop_params=({"roi": context.active_roi} if context.active_roi and not context.crop_preview_full else None), distortion_k1=distortion_k1, + converge_v=settings.geometry.converge_v, + converge_h=settings.geometry.converge_h, ) context.metrics["uv_grid"] = uv_grid except Exception as e: diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 2f8cd3c1..d8a83614 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -34,6 +34,8 @@ ) from negpy.features.geometry.logic import ( apply_fine_rotation, + apply_keystone, + keystone_inverse_normalized, apply_margin_to_roi, apply_radial_distortion, compute_distortion_scale, @@ -107,6 +109,13 @@ def _binding_identity(idx: int, res: Any) -> tuple: return (idx, id(res)) +def _keystone_inverse_bytes(converge_v: float, converge_h: float) -> bytes: + """The keystone inverse packed as two vec4s: (h00, h01, h02, h10), (h11, h12, h20, h21). + h22 is 1 by construction, so the shader supplies it.""" + m = keystone_inverse_normalized(converge_v, converge_h) + return struct.pack("ffff", m[0, 0], m[0, 1], m[0, 2], m[1, 0]) + struct.pack("ffff", m[1, 1], m[1, 2], m[2, 0], m[2, 1]) + + def _analysis_cache_key(settings: WorkspaceConfig, analysis_source_hash: str) -> tuple: """Identity of the auto-exposure analysis for a frame. Mirrors the CPU base-stage cache key (engine.py) plus the fields that gate refs/anchor/textural, so it survives @@ -208,7 +217,7 @@ def __init__(self) -> None: # Packed byte size per stage. A stage that exceeds the 256B dynamic-offset # alignment (exposure, 304B) occupies multiple aligned slots. self._uniform_sizes = { - "geometry": 32, + "geometry": 64, "normalization": 160, "exposure": 304, "transfer": 144, @@ -546,6 +555,10 @@ def process_to_texture( analysis_source = np.ascontiguousarray(analysis_source[ay1:ay2, ax1:ax2]) if settings.geometry.fine_rotation != 0.0: analysis_source = apply_fine_rotation(analysis_source, settings.geometry.fine_rotation) + # The meters must read the frame the print stage gets. The CPU engine + # normalizes the keystoned buffer, so this replay has to carry it too or the + # two engines measure different bounds. + analysis_source = apply_keystone(analysis_source, settings.geometry.converge_v, settings.geometry.converge_h) analysis_source = _downsample_for_analysis(analysis_source, APP_CONFIG.preview_render_size) # Shared prefilter, once for all five meters (ROI already applied). @@ -617,6 +630,8 @@ def _analyze_bounds() -> LogNegativeBounds: fine_rotation=settings.geometry.fine_rotation, flip_horizontal=settings.geometry.flip_horizontal, flip_vertical=settings.geometry.flip_vertical, + converge_v=settings.geometry.converge_v, + converge_h=settings.geometry.converge_h, distortion_k1=k1_eff, roi_norm=normalized_roi(roi, (h_rot, w_rot)), ), @@ -1105,6 +1120,8 @@ def _analyze_bounds() -> LogNegativeBounds: autocrop=True, autocrop_params={"roi": roi} if roi else None, distortion_k1=k1_eff, + converge_v=settings.geometry.converge_v, + converge_h=settings.geometry.converge_h, ) self._uv_grid_cache = (uv_key, uv_grid) metrics["uv_grid"] = uv_grid @@ -1153,8 +1170,11 @@ def _upload_unified_uniforms( (1 if settings.geometry.flip_horizontal else 0), (1 if settings.geometry.flip_vertical else 0), ) + struct.pack("ffff", float(k1_eff), float(scale_s), 0.0, 0.0) + # The shader undoes the keystone first, so it gets the CPU's own matrix inverted + # and normalized to [0,1] coords — deriving the quad twice would let the two drift. + g_data += _keystone_inverse_bytes(settings.geometry.converge_v, settings.geometry.converge_h) if tiling_mode: - g_data = b"\x00" * 32 + g_data = b"\x00" * 64 f, c = bounds.floors, bounds.ceils mode_val = 0 diff --git a/negpy/services/view/coordinate_mapping.py b/negpy/services/view/coordinate_mapping.py index a44fbb86..6f595386 100644 --- a/negpy/services/view/coordinate_mapping.py +++ b/negpy/services/view/coordinate_mapping.py @@ -19,6 +19,8 @@ def create_uv_grid( autocrop: bool = False, autocrop_params: Optional[dict] = None, distortion_k1: float = 0.0, + converge_v: float = 0.0, + converge_h: float = 0.0, ) -> np.ndarray: """ Generates UV map for geometric state (output pixel -> raw uv it samples), so it @@ -53,6 +55,11 @@ def create_uv_grid( uv_grid = np.ascontiguousarray(apply_radial_distortion(uv_grid, distortion_k1)) + if converge_v != 0.0 or converge_h != 0.0: + from negpy.features.geometry.logic import apply_keystone + + uv_grid = np.ascontiguousarray(apply_keystone(uv_grid, converge_v, converge_h)) + if autocrop and autocrop_params: y1, y2, x1, x2 = autocrop_params["roi"] # copy so the ROI slice doesn't pin the full-size parent @@ -61,42 +68,53 @@ def create_uv_grid( return uv_grid @staticmethod - def _grid_affine(uv_grid: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - """The grid as an affine map: (viewport reference, raw reference, 2x2 rate). + def _grid_homography(uv_grid: np.ndarray) -> np.ndarray: + """The grid as a projective map, viewport (0-1) -> raw (0-1). - Two central differences give the rate, so the model is exact for the affine part - of the geometry (rotation, flips, fine rotation, crop). Distortion is not affine, - but the model applies only off the frame, where the grid has no data anyway. - - All samples come from the middle of the grid. A fine rotation fills the grid + Fitted from four interior samples, so it is exact for every geometry op that is + itself projective — rotation, flips, fine rotation, crop and the keystone. Only + distortion stays approximate, and the model applies off the frame, where the grid + has no data anyway. Samples come from the middle: a fine rotation fills the grid border with zeros, and those are not coordinates. """ h_uv, w_uv = uv_grid.shape[:2] x0, x1 = w_uv // 4, w_uv - 1 - w_uv // 4 y0, y1 = h_uv // 4, h_uv - 1 - h_uv // 4 - cx, cy = w_uv // 2, h_uv // 2 - d_col = (uv_grid[cy, x1] - uv_grid[cy, x0]) * ((w_uv - 1) / max(x1 - x0, 1)) - d_row = (uv_grid[y1, cx] - uv_grid[y0, cx]) * ((h_uv - 1) / max(y1 - y0, 1)) - jacobian = np.stack([d_col, d_row], axis=-1).astype(np.float64) - viewport_ref = np.array([cx / (w_uv - 1), cy / (h_uv - 1)], dtype=np.float64) - return viewport_ref, np.asarray(uv_grid[cy, cx], dtype=np.float64), jacobian + src = np.float32( + [ + [x0 / (w_uv - 1), y0 / (h_uv - 1)], + [x1 / (w_uv - 1), y0 / (h_uv - 1)], + [x1 / (w_uv - 1), y1 / (h_uv - 1)], + [x0 / (w_uv - 1), y1 / (h_uv - 1)], + ] + ) + dst = np.float32([uv_grid[y0, x0], uv_grid[y0, x1], uv_grid[y1, x1], uv_grid[y1, x0]]) + return cv2.getPerspectiveTransform(src, dst).astype(np.float64) + + @staticmethod + def _apply_homography(m: np.ndarray, nx: float, ny: float) -> Tuple[float, float]: + den = m[2, 0] * nx + m[2, 1] * ny + m[2, 2] + if abs(den) < 1e-12: + return nx, ny + return ( + float((m[0, 0] * nx + m[0, 1] * ny + m[0, 2]) / den), + float((m[1, 0] * nx + m[1, 1] * ny + m[1, 2]) / den), + ) @staticmethod def map_click_to_raw(nx: float, ny: float, uv_grid: np.ndarray) -> Tuple[float, float]: """ Viewport (0-1) -> Raw (0-1). - A point off the frame has no grid sample, so the affine model of the grid gives - it. Dodge/burn masks use this, because a card edge must start outside the + A point off the frame has no grid sample, so the projective model of the grid + gives it. Dodge/burn masks use this, because a card edge must start outside the picture to cover a corner when you tilt it. """ h_uv, w_uv = uv_grid.shape[:2] if 0.0 <= nx <= 1.0 and 0.0 <= ny <= 1.0: raw_uv = uv_grid[int(ny * (h_uv - 1)), int(nx * (w_uv - 1))] return float(raw_uv[0]), float(raw_uv[1]) - viewport_ref, raw_ref, jacobian = CoordinateMapping._grid_affine(uv_grid) - out = raw_ref + jacobian @ (np.array([nx, ny], dtype=np.float64) - viewport_ref) - return float(out[0]), float(out[1]) + return CoordinateMapping._apply_homography(CoordinateMapping._grid_homography(uv_grid), nx, ny) @staticmethod def map_raw_to_viewport(rx: float, ry: float, uv_grid: np.ndarray, buckets: int = 100) -> Tuple[float, float]: @@ -129,10 +147,9 @@ def map_raw_to_viewport(rx: float, ry: float, uv_grid: np.ndarray, buckets: int nx, ny = min((x0 + wx + 0.5) / w_uv, 1.0), min((y0 + wy + 0.5) / h_uv, 1.0) # The nearest sample is more than one grid step away only if the raw point is off the - # frame. The affine model then gives the answer, the inverse of what map_click_to_raw - # does there. + # frame. The projective model then gives the answer, the inverse of what + # map_click_to_raw does there. if float(wdist.flat[widx]) > (2.0 / max(h_uv, w_uv)) ** 2: - viewport_ref, raw_ref, jacobian = CoordinateMapping._grid_affine(uv_grid) - out = viewport_ref + np.linalg.solve(jacobian, np.array([rx, ry], dtype=np.float64) - raw_ref) - return float(out[0]), float(out[1]) + inv = np.linalg.inv(CoordinateMapping._grid_homography(uv_grid)) + return CoordinateMapping._apply_homography(inv, rx, ry) return nx, ny diff --git a/tests/test_keystone.py b/tests/test_keystone.py new file mode 100644 index 00000000..fcbed728 --- /dev/null +++ b/tests/test_keystone.py @@ -0,0 +1,156 @@ +"""Converging verticals — the perspective correction, framed as easel tilt. + +The warp itself is one cv2 call. What can silently break is everything that has to +agree with it: the shader's inverse, the meters that read the warped frame, the point +mapper that puts dodge/burn masks where the canvas draws them, and autocrop's replay. +""" + +import unittest +from dataclasses import replace + +import numpy as np + +from negpy.domain.models import WorkspaceConfig +from negpy.features.geometry.logic import ( + apply_keystone, + autocrop_detection_key, + keystone_inverse_normalized, + keystone_matrix, + map_coords_to_geometry, + map_point_keystone, +) +from negpy.infrastructure.gpu.device import GPUDevice + + +def _test_field(h: int = 96, w: int = 144) -> np.ndarray: + rng = np.random.default_rng(0) + 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.95, img * 0.9], axis=-1) + return np.ascontiguousarray(img + rng.uniform(0, 0.01, img.shape).astype(np.float32)) + + +class TestKeystoneTransform(unittest.TestCase): + def test_zero_is_identity(self): + img = _test_field() + np.testing.assert_array_equal(apply_keystone(img, 0.0, 0.0), img) + np.testing.assert_allclose(keystone_inverse_normalized(0.0, 0.0), np.eye(3), atol=1e-12) + + def test_output_keeps_the_canvas_size(self): + """Same-size output is an invariant the GPU's texture allocation depends on: it + derives the intermediate dimensions from rotation alone.""" + img = _test_field() + self.assertEqual(apply_keystone(img, 12.0, -8.0).shape, img.shape) + + def test_positive_converge_v_stretches_the_top(self): + """A bar at the top must come out wider than the same bar at the bottom.""" + h, w = 120, 200 + img = np.zeros((h, w), np.float32) + img[10, 60:140] = 1.0 + img[h - 11, 60:140] = 1.0 + out = apply_keystone(img, 10.0, 0.0) + top = float((out[: h // 2] > 0.5).sum()) + bottom = float((out[h // 2 :] > 0.5).sum()) + self.assertGreater(top, bottom, f"top {top} px, bottom {bottom} px") + + def test_point_mapper_follows_the_resample(self): + """A feature point must land where the warp actually put its pixels, or masks + drift off what the canvas draws.""" + h, w = 96, 144 + for cv_, ch_ in ((8.0, 0.0), (0.0, -6.0), (12.0, 5.0)): + img = np.zeros((h, w), np.float32) + img[40, 30] = 1.0 + out = apply_keystone(img, cv_, ch_) + ys, xs = np.nonzero(out > 0.25) + got = (float(ys.mean()), float(xs.mean())) + want = map_point_keystone(30.0, 40.0, cv_, ch_, w, h) + self.assertAlmostEqual(got[0], want[1], delta=1.0, msg=f"y at cv={cv_} ch={ch_}") + self.assertAlmostEqual(got[1], want[0], delta=1.0, msg=f"x at cv={cv_} ch={ch_}") + + def test_shader_inverse_undoes_the_forward_matrix(self): + """The GPU consumes this matrix directly; if it stopped being the inverse of the + CPU's own quad the two engines would sample different pixels.""" + h, w = 96, 144 + fwd = keystone_matrix(9.0, -4.0, w, h) + inv_norm = keystone_inverse_normalized(9.0, -4.0) + to_index = np.array([[w, 0.0, -0.5], [0.0, h, -0.5], [0.0, 0.0, 1.0]], dtype=np.float64) + inv_index = to_index @ inv_norm @ np.linalg.inv(to_index) + np.testing.assert_allclose(inv_index @ fwd / (inv_index @ fwd)[2, 2], np.eye(3), atol=1e-8) + + def test_mask_vertices_follow_the_keystone(self): + mapped = map_coords_to_geometry(0.5, 0.1, (96, 144), converge_v=12.0) + plain = map_coords_to_geometry(0.5, 0.1, (96, 144)) + self.assertNotAlmostEqual(mapped[1], plain[1], places=3) + + def test_detection_key_tracks_the_correction(self): + """Autocrop replays the keystone, so a resolved rect must not survive a change + to it — detection would have found the rebate on a differently warped frame.""" + g = WorkspaceConfig().geometry + self.assertNotEqual(autocrop_detection_key(g), autocrop_detection_key(replace(g, converge_v=6.0))) + self.assertNotEqual(autocrop_detection_key(g), autocrop_detection_key(replace(g, converge_h=6.0))) + + +class TestKeystoneCoordinateMapping(unittest.TestCase): + def test_uv_grid_carries_the_correction(self): + from negpy.services.view.coordinate_mapping import CoordinateMapping + + plain = CoordinateMapping.create_uv_grid(96, 144, 0, 0.0) + warped = CoordinateMapping.create_uv_grid(96, 144, 0, 0.0, converge_v=10.0) + self.assertFalse(np.allclose(plain, warped)) + + def test_off_frame_points_round_trip(self): + """Card-edge masks put handles outside the picture, where there is no grid sample + and the projective model answers instead. It must invert cleanly.""" + from negpy.services.view.coordinate_mapping import CoordinateMapping + + grid = CoordinateMapping.create_uv_grid(96, 144, 0, 0.0, converge_v=10.0, converge_h=-6.0) + for nx, ny in ((-0.4, 0.3), (1.35, 0.8), (0.5, -0.25)): + rx, ry = CoordinateMapping.map_click_to_raw(nx, ny, grid) + back = CoordinateMapping.map_raw_to_viewport(rx, ry, grid) + self.assertAlmostEqual(back[0], nx, delta=0.02, msg=f"x for ({nx},{ny})") + self.assertAlmostEqual(back[1], ny, delta=0.02, msg=f"y for ({nx},{ny})") + + +@unittest.skipUnless(GPUDevice.get().is_available, "GPU not available") +class TestKeystoneParity(unittest.TestCase): + def test_cpu_gpu_match(self): + from negpy.services.rendering.image_processor import ImageProcessor + + processor = ImageProcessor() + if processor.engine_gpu is None: + self.skipTest("GPU engine not initialised") + + img = _test_field() + base = WorkspaceConfig() + + def render(prefer_gpu: bool, **geo) -> np.ndarray: + settings = replace(base, geometry=replace(base.geometry, **geo)) + result, _ = processor.run_pipeline( + img.copy(), + settings, + f"keystone-parity{geo}{prefer_gpu}", + render_size_ref=float(max(img.shape[:2])), + prefer_gpu=prefer_gpu, + readback_metrics=False, + ) + arr = np.asarray(result.readback()) if hasattr(result, "readback") else np.asarray(result) + return arr[:, :, :3].astype(np.float64) + + # Both axes, both signs, and composed with the ops that run before it. This is + # also the only guard on the GPU's own geometry replay for the meters: skipping + # the keystone there makes the engines normalize different pixels, and it shows + # up here and nowhere else. + for geo in ( + dict(converge_v=8.0), + dict(converge_v=-12.0), + dict(converge_h=10.0), + dict(converge_v=9.0, converge_h=-5.0, fine_rotation=1.5, rotation=1), + ): + cpu, gpu = render(False, **geo), render(True, **geo) + self.assertEqual(cpu.shape, gpu.shape, str(geo)) + self.assertLess(float(np.mean(np.abs(cpu - gpu))), 0.01, str(geo)) + self.assertLess(float(np.max(np.abs(cpu - gpu))), 0.04, str(geo)) + + +if __name__ == "__main__": + unittest.main() From 06d3c6479835d30ac28c16eef35e5266979bdc7f Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 17 Aug 2026 10:14:36 +0200 Subject: [PATCH 4/9] refactor(geometry): name the perspective controls Tilt and Swing The enlarger's own movement names: tilt about a horizontal axis straightens converging verticals, swing about a vertical axis converging horizontals. Also fixes a layout defect. CompactSlider's header is label | dot | stretch | spin, and the spin sits at width 0 until hover, so a label wide enough to eat the stretch's slack gets pushed sideways when the value appears. At a two-per-row slider's 170px share, "Converging Verticals" overran by 30px and "Converging Horizontals" by 48px; Tilt and Swing clear it by 77px and 59px, more headroom than Fine Rotation has. Fields stay converge_v / converge_h, as Snap stays midtone_gamma. --- docs/PIPELINE.md | 2 +- docs/USER_GUIDE.md | 4 ++-- negpy/desktop/settings_catalog.py | 4 ++-- negpy/desktop/view/shortcut_registry.py | 8 ++++---- negpy/desktop/view/sidebar/geometry.py | 16 +++++++++------- negpy/desktop/view/slider_shortcut_groups.py | 4 ++-- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 08cce881..34cf3216 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -9,7 +9,7 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Rotation**: we spin the image array in 90° steps and fine-tune with affine transformations, using bilinear interpolation so it stays sharp. * **Lens distortion**: a radial $k_1$ coefficient, a rig property mirrored from the active flat-field profile (`flatfield.k1`), corrected in the same resample. -* **Converging verticals** (`geometry.converge_v` / `converge_h`, ±15%): perspective correction as easel tilt — a plane-to-plane projectivity (Hartley & Zisserman §2.3), the transform a tilted easel realises. It runs **last** in the forward chain, after $k_1$: a projectivity cannot be fitted to a frame that still carries barrel distortion. The unit is per-cent of the frame, not tilt degrees, because convergence is $(H/2)\sin\tau / D$ — the same tilt keystones differently at every enlargement, and no magnification or focal length is modelled. Output keeps the canvas size and replicates the wedge, as fine rotation does; scaling to fill would change magnification and move an existing crop. +* **Tilt and Swing** (`geometry.converge_v` / `converge_h`, ±15%): perspective correction as the enlarger movements it is named for — tilt about a horizontal axis straightens converging verticals, swing about a vertical axis converging horizontals. A plane-to-plane projectivity (Hartley & Zisserman §2.3), the transform a tilted easel realises. It runs **last** in the forward chain, after $k_1$: a projectivity cannot be fitted to a frame that still carries barrel distortion. The unit is per-cent of the frame, not tilt degrees, because convergence is $(H/2)\sin\tau / D$ — the same tilt keystones differently at every enlargement, and no magnification or focal length is modelled. Output keeps the canvas size and replicates the wedge, as fine rotation does; scaling to fill would change magnification and move an existing crop. `keystone_matrix_normalized` is the single definition of the quad. The pixel-space matrix and the GPU's inverse are both derived from it — built twice they would disagree by half a pixel and a $w/(w-1)$ scale. The shader undoes it first, from that same inverse passed in the geometry uniform, so it never rebuilds the quad. Everything that reads geometry carries it too: the uv grid, `map_coords_to_geometry` (dodge/burn vertices), autocrop's detection replay and key, and the GPU's own analysis replay — the meters must read the frame the print stage gets, or the two engines normalize different pixels. `CoordinateMapping` fits the grid projectively rather than affinely for the same reason; off-frame points, which card-edge masks depend on, would otherwise be wrong. * **Autocrop**: we detect where the film ends and the scanner bed begins by looking for the density jump. It is not perfect, because light leaks and odd scanning holders can fool it, so there is a manual override. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a1c7c9e7..e4390640 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -435,8 +435,8 @@ Where the frame gets its final shape: what is inside the print, and whether it s * **Fine Rotation** (±45°): free rotation for tilted scans, in sub-degree steps (positive is clockwise). Applied after auto-crop so the frame stays axis-aligned. * **Straighten** tool (ruler): draw a line along a horizon or vertical edge and NegPy rotates to make it level or plumb. -* **Converging Verticals** (±15%): straighten a building that leans back because the camera was tilted up, the way a printer tilts the easel under the enlarger. Positive stretches the top edge. The unit is per-cent of the frame, the amount you would measure with a ruler on the easel, not a tilt angle — the same tilt keystones differently at every enlargement, and NegPy models no enlarger. -* **Converging Horizontals** (±15%): the same correction across the frame, for a wall shot from one side, or a copy stand that is not square to the film. Positive stretches the left edge. +* **Tilt** (±15%): tip the easel about a horizontal axis, straightening converging verticals — the building that leans back because the camera was pointed up. Positive stretches the top edge. The unit is per-cent of the frame, the amount you would measure with a ruler on the easel, not a tilt angle: the same tilt keystones differently at every enlargement, and NegPy models no enlarger. +* **Swing** (±15%): the same movement about a vertical axis, straightening converging horizontals — a wall shot from one side, or a copy stand that is not square to the film. Positive stretches the left edge. Both leave a wedge of replicated edge along the squeezed side, exactly as Fine Rotation does; crop it off. Nothing is scaled to hide it, because that would quietly change your magnification and drag a crop you had already drawn. Crop first if you can: the meters read the corrected frame, so on a still-uncropped scan a large correction pulls rebate and scanner surround further into the metered area and the print gets darker. Inside a crop the exposure barely moves. diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index 13e82815..c688b1eb 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -96,8 +96,8 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: ("Rotation", ( _row("Rotation", "geometry", "rotation"), _row("Fine Rotation", "geometry", "fine_rotation"), - _row("Converging Verticals", "geometry", "converge_v"), - _row("Converging Horizontals", "geometry", "converge_h"), + _row("Easel Tilt", "geometry", "converge_v"), + _row("Easel Swing", "geometry", "converge_h"), _row("Flip Horizontal", "geometry", "flip_horizontal"), _row("Flip Vertical", "geometry", "flip_vertical"), )), diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 2713dc2e..850c3cf2 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -34,10 +34,10 @@ class ShortcutEntry: "offset_inc": ShortcutEntry("X", "Crop offset up", "Geometry"), "fine_rot_dec": ShortcutEntry("Alt+Shift+R", "Fine rotation counter-clockwise", "Geometry"), "fine_rot_inc": ShortcutEntry("Alt+R", "Fine rotation clockwise", "Geometry"), - "converge_v_dec": ShortcutEntry("", "Converging verticals down", "Geometry"), - "converge_v_inc": ShortcutEntry("", "Converging verticals up", "Geometry"), - "converge_h_dec": ShortcutEntry("", "Converging horizontals down", "Geometry"), - "converge_h_inc": ShortcutEntry("", "Converging horizontals up", "Geometry"), + "converge_v_dec": ShortcutEntry("", "Easel tilt down", "Geometry"), + "converge_v_inc": ShortcutEntry("", "Easel tilt up", "Geometry"), + "converge_h_dec": ShortcutEntry("", "Easel swing down", "Geometry"), + "converge_h_inc": ShortcutEntry("", "Easel swing up", "Geometry"), "straighten": ShortcutEntry("L", "Toggle straighten line tool", "Geometry"), "pick_wb": ShortcutEntry("Shift+W", "Toggle WB picker", "Tools"), "manual_crop": ShortcutEntry("Shift+C", "Toggle manual crop", "Tools"), diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index 4ea0ec30..eaf91bbe 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -170,16 +170,18 @@ def _init_ui(self) -> None: align_row.addWidget(self.straighten_btn, 0) self.layout.addLayout(align_row) - self.converge_v_slider = CompactSlider("Converging Verticals", -15.0, 15.0, conf.converge_v, unit="%") + self.converge_v_slider = CompactSlider("Tilt", -15.0, 15.0, conf.converge_v, unit="%") self.converge_v_slider.setToolTip( - "Converging Verticals: straighten a building that leans back, the way a printer tilts " - "the easel. Positive stretches the top edge. Measured as a per-cent of the frame, not " - "a tilt angle, because the same tilt keystones differently at every enlargement." + "Easel Tilt: tip the easel about a horizontal axis to straighten converging verticals, " + "the building that leans back because the camera was pointed up. Positive stretches the " + "top edge. Per-cent of the frame, the amount you would measure on the easel, not a tilt " + "angle — the same tilt keystones differently at every enlargement." ) - self.converge_h_slider = CompactSlider("Converging Horizontals", -15.0, 15.0, conf.converge_h, unit="%") + self.converge_h_slider = CompactSlider("Swing", -15.0, 15.0, conf.converge_h, unit="%") self.converge_h_slider.setToolTip( - "Converging Horizontals: the same correction across the frame, for a wall shot from " - "one side or a copy stand that is not square. Positive stretches the left edge." + "Easel Swing: the same movement about a vertical axis, straightening converging " + "horizontals — a wall shot from one side, or a copy stand that is not square to the " + "film. Positive stretches the left edge." ) converge_row = QHBoxLayout() converge_row.addWidget(self.converge_v_slider) diff --git a/negpy/desktop/view/slider_shortcut_groups.py b/negpy/desktop/view/slider_shortcut_groups.py index b84ef114..8339bc90 100644 --- a/negpy/desktop/view/slider_shortcut_groups.py +++ b/negpy/desktop/view/slider_shortcut_groups.py @@ -89,8 +89,8 @@ def _g( _g("contrast_mask", "Contrast Mask ↑/↓", "contrast_mask_inc", "contrast_mask_dec", 0.01, "Exposure"), _g("offset", "Crop offset ↑/↓", "offset_inc", "offset_dec", 1.0, "Geometry", step_decimals=0, step_suffix=" px"), _g("fine_rot", "Fine rotation ↑/↓", "fine_rot_inc", "fine_rot_dec", 0.01, "Geometry", step_suffix="°"), - _g("converge_v", "Converging verticals ↑/↓", "converge_v_inc", "converge_v_dec", 0.1, "Geometry", step_suffix="%"), - _g("converge_h", "Converging horizontals ↑/↓", "converge_h_inc", "converge_h_dec", 0.1, "Geometry", step_suffix="%"), + _g("converge_v", "Easel tilt ↑/↓", "converge_v_inc", "converge_v_dec", 0.1, "Geometry", step_suffix="%"), + _g("converge_h", "Easel swing ↑/↓", "converge_h_inc", "converge_h_dec", 0.1, "Geometry", step_suffix="%"), _g("analysis_buffer", "Analysis buffer ↑/↓", "analysis_buffer_inc", "analysis_buffer_dec", 0.01, "Process"), _g("luma_range_clip", "Luma range clip ↑/↓", "luma_range_clip_inc", "luma_range_clip_dec", 1.0, "Process", step_decimals=0), _g("color_range_clip", "Color range clip ↑/↓", "color_range_clip_inc", "color_range_clip_dec", 1.0, "Process", step_decimals=0), From 92336708e786ccd5904b5bf84ebac995f077210e Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 17 Aug 2026 10:21:14 +0200 Subject: [PATCH 5/9] docs: cut the prose in the Contrast Mask and Tilt/Swing changes to budget Docstrings were running to 13 lines against a 1-4 line budget, PIPELINE entries to three paragraphs against one, and several stated the bug they came from rather than the rule that replaced it. Also drops bold emphasis and a defensive-guard note that only mattered to the code. --- docs/PIPELINE.md | 8 +++--- docs/USER_GUIDE.md | 8 +++--- negpy/features/exposure/logic.py | 13 +++------ negpy/features/exposure/normalization.py | 30 +++++++------------ negpy/features/exposure/processor.py | 3 +- negpy/features/geometry/logic.py | 35 +++++++++-------------- negpy/features/geometry/models.py | 7 ++--- negpy/services/view/coordinate_mapping.py | 9 +++--- tests/test_contrast_mask.py | 25 +++++++--------- tests/test_keystone.py | 24 +++++++--------- 10 files changed, 64 insertions(+), 98 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 34cf3216..75db5f67 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -9,9 +9,9 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Rotation**: we spin the image array in 90° steps and fine-tune with affine transformations, using bilinear interpolation so it stays sharp. * **Lens distortion**: a radial $k_1$ coefficient, a rig property mirrored from the active flat-field profile (`flatfield.k1`), corrected in the same resample. -* **Tilt and Swing** (`geometry.converge_v` / `converge_h`, ±15%): perspective correction as the enlarger movements it is named for — tilt about a horizontal axis straightens converging verticals, swing about a vertical axis converging horizontals. A plane-to-plane projectivity (Hartley & Zisserman §2.3), the transform a tilted easel realises. It runs **last** in the forward chain, after $k_1$: a projectivity cannot be fitted to a frame that still carries barrel distortion. The unit is per-cent of the frame, not tilt degrees, because convergence is $(H/2)\sin\tau / D$ — the same tilt keystones differently at every enlargement, and no magnification or focal length is modelled. Output keeps the canvas size and replicates the wedge, as fine rotation does; scaling to fill would change magnification and move an existing crop. +* **Tilt and Swing** (`geometry.converge_v` / `converge_h`, ±15%): perspective correction, named for the enlarger movements. Tilt about a horizontal axis straightens converging verticals, swing about a vertical axis converging horizontals: the plane-to-plane projectivity a tilted easel realises (Hartley & Zisserman §2.3). Runs last in the forward chain, after $k_1$, since a projectivity cannot be fitted to a barrel-distorted frame. The unit is per-cent of the frame rather than tilt degrees, because convergence is $(H/2)\sin\tau / D$ and no magnification or focal length is modelled. Output keeps the canvas size and replicates the wedge, as fine rotation does; the user crops it. - `keystone_matrix_normalized` is the single definition of the quad. The pixel-space matrix and the GPU's inverse are both derived from it — built twice they would disagree by half a pixel and a $w/(w-1)$ scale. The shader undoes it first, from that same inverse passed in the geometry uniform, so it never rebuilds the quad. Everything that reads geometry carries it too: the uv grid, `map_coords_to_geometry` (dodge/burn vertices), autocrop's detection replay and key, and the GPU's own analysis replay — the meters must read the frame the print stage gets, or the two engines normalize different pixels. `CoordinateMapping` fits the grid projectively rather than affinely for the same reason; off-frame points, which card-edge masks depend on, would otherwise be wrong. + `keystone_matrix_normalized` is the single definition of the quad, and the pixel-space matrix and the GPU's inverse both derive from it. Every reader of geometry carries the correction: the uv grid, `map_coords_to_geometry`, autocrop's replay and detection key, and the GPU's analysis replay, whose meters must read the frame the print stage gets. `CoordinateMapping` fits the grid projectively, which off-frame card-edge handles depend on. * **Autocrop**: we detect where the film ends and the scanner bed begins by looking for the density jump. It is not perfect, because light leaks and odd scanning holders can fool it, so there is a manual override. Detection runs once per edit, in `ImageProcessor` ahead of either engine, and the rect is stored on the edit (`geometry.crop_rect`, with `crop_from_auto` marking where it came from). Both engines only slice that rect. The border walk reads whatever buffer it is given, and a preview buffer and a full-resolution export can stop on different edges, so detecting per render would export a crop the user never saw. The rect keeps the detection key it was found under (`autocrop_detection_key`: orientation, ratio, mode, rebate trim); a change there re-detects on the next render. Crop Offset is not in the key, because it is re-applied to the rect every render. @@ -136,9 +136,9 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Local Grade** (`LocalMask.grade`, ISO-R points off the frame's Grade, negative = harder): burning or dodging *through a different filter*, which on variable-contrast paper is what a hard-filter burn is. The masks rasterize a second plane in the same pass (`compute_local_maps`: plane 0 EV, plane 1 summed $\Delta R$), and the $\Delta R$ becomes a per-pixel slope multiplier through the same ratio a per-layer Grade trim uses, $R/(R+\Delta R)$ clamped to the ISO-R ladder (`local_grade_factor_map`, single source for the CPU kernel and the GPU's uploaded map). In the curve it multiplies the straight-line slope only, $v = k \cdot g \cdot (x_{adj} - x_0) + c \cdot x_{adj}^2$, so the rotation is **about the channel pivot**: a grade-only mask changes its region's contrast without moving its density, and the cast-removal curvature $c$ stays global. All three channels take the same factor, matching global Grade. On the GPU the factor rides the dodge/burn texture's green channel, so it costs no extra bind slot; the metrics and the zone ruler still describe the frame-wide grade, not a masked region's. * **Contrast Mask** (`ExposureConfig.contrast_mask`, 0 to 0.5, default 0): the darkroom's unsharp contrast-reduction mask — a blurred, low-gamma positive contact-printed off the negative and sandwiched with it. Densities add, so the sandwich is $D' = D - g\,\text{blur}(D) + \text{const}$: a linear high-boost in log density that compresses the global range while the blur keeps fine detail out of the compression. $g$ is the mask film's gamma, and the published range is the slider's range (Ctein, *Post Exposure*; Bond, *Unsharp Masking*; Adams, *The Print*). - It is not a stage. The plane is built by `normalization.contrast_mask_plane` and converted to stops by `logic.contrast_mask_ev`, then added to the dodge/burn map the print stage already reads — equal stops is an equal absolute density change in all three channels, which is what a neutral panchromatic masking film records, and why the control has no per-layer trim. Both engines call the same helper on the same pre-geometry array and replay geometry on the downsampled copy, so the two mask identically. $\sigma$ is a fraction of the analysis grid, never of the render, so preview and export mask alike. The plane is returned zero-mean: a real sandwich is denser and the printer opens up for it, so the control leaves print density alone. + It is not a stage: `contrast_mask_plane` builds the plane, `contrast_mask_ev` converts it to stops, and it joins the dodge/burn map the print stage already reads. Equal stops is an equal absolute density change in all three channels, which is what a neutral panchromatic masking film records, and why there is no per-layer trim. Both engines call the same helper on the same pre-geometry array, and $\sigma$ is a fraction of the analysis grid rather than of the render, so preview, export and the two engines all mask alike. The plane is zero-mean, since a real sandwich is denser and the printer opens up for it. - **The plane covers the printed frame only.** The enlarger projects the crop, so the rebate and the scanner surround stay out of the mask; blurred over, they push a low-frequency ramp inward and print as a vignette the negative does not have. The plane is placed back at the crop and edge-replicated outside, so the crop tool's full-frame preview has no seam at the crop line. Degenerate bounds (a frame that never metered) return a zero plane rather than amplifying a broken stretch. Hidden on the transparency transfer path, which takes no dodge/burn map. Instruments (densitometer, zone pins, the placement solver) read the unmasked negative, as they already do under a dodge. + The plane covers the printed frame only, because the enlarger projects the crop: a rebate or scanner surround blurred into the mask prints as a vignette the negative does not have. It is placed back at the crop, edge-replicated outside so the crop tool's full-frame preview has no seam. Hidden on the transparency transfer path, which takes no dodge/burn map. Instruments read the unmasked negative, as they already do under a dodge. * **Output**: converts print density back to **scene-linear** reflectance (transmittance): $$I_{out} = 10^{-D}$$ * **Paper Black** (`paper_black`, off): off applies black point compensation, the same idea as ICC relative-colorimetric soft-proofing. A reflection print's D-max ($2.3$) floors reflectance at $10^{-2.3} \approx 0.005$, but the adapted eye reads paper black as black, so the display should too; on preserves the paper's lifted D-max instead. With compensation, the default, each channel becomes $I_{out} = (I - t_b) / (1 - t_b)$, clamped at $0$, where $t_b = 10^{-D_b}$ and $D_b$ is the physical $D_{max}$, or $D_{max} + \text{toe}_{ch} \cdot 0.90$ when that layer's toe is negative. The curve reaches $D_{max}$ only asymptotically, so a **negative toe raises the clip point** into the shadows, which is what makes exact $0$ reachable and "negative toe deepens blacks" literal. A lifted toe and per-layer shadow casts survive because the reference is the *physical* $D_{max}$, not $D_{max,eff}$. A negative per-layer toe trim, with compensation on, tints the deepest black. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e4390640..1da2dfa8 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -435,10 +435,10 @@ Where the frame gets its final shape: what is inside the print, and whether it s * **Fine Rotation** (±45°): free rotation for tilted scans, in sub-degree steps (positive is clockwise). Applied after auto-crop so the frame stays axis-aligned. * **Straighten** tool (ruler): draw a line along a horizon or vertical edge and NegPy rotates to make it level or plumb. -* **Tilt** (±15%): tip the easel about a horizontal axis, straightening converging verticals — the building that leans back because the camera was pointed up. Positive stretches the top edge. The unit is per-cent of the frame, the amount you would measure with a ruler on the easel, not a tilt angle: the same tilt keystones differently at every enlargement, and NegPy models no enlarger. -* **Swing** (±15%): the same movement about a vertical axis, straightening converging horizontals — a wall shot from one side, or a copy stand that is not square to the film. Positive stretches the left edge. +* **Tilt** (±15%): tip the easel about a horizontal axis to straighten converging verticals, the building that leans back because the camera was pointed up. Positive stretches the top edge. Measured as a per-cent of the frame, the amount you would read off the easel, rather than a tilt angle, since the same tilt keystones differently at every enlargement. +* **Swing** (±15%): the same movement about a vertical axis, for converging horizontals: a wall shot from one side, or a copy stand that is not square to the film. Positive stretches the left edge. - Both leave a wedge of replicated edge along the squeezed side, exactly as Fine Rotation does; crop it off. Nothing is scaled to hide it, because that would quietly change your magnification and drag a crop you had already drawn. Crop first if you can: the meters read the corrected frame, so on a still-uncropped scan a large correction pulls rebate and scanner surround further into the metered area and the print gets darker. Inside a crop the exposure barely moves. + Both leave a wedge of replicated edge along the squeezed side, as Fine Rotation does; crop it off. Crop before correcting where you can, because the meters read the corrected frame: on an uncropped scan a large correction pulls rebate and surround into the metered area and the print darkens. Inside a crop the exposure barely moves. ### 5.2 Flat Field: even out the light @@ -493,7 +493,7 @@ The paper's response. A **Global / R / G / B** selector at the top scopes most c These two also work in Transparency with **Normalize off**, on the same tones (the centres are mapped by position on each curve's own scale, not by raw density), and there they are the only mid-sparing controls: Shadows Density opens the quarter-tone with the highlights unmoved, where Grade and Toe drag the whole scale with them and cost the highlights. * **Shadows Grade** / **Highlights Grade** (split grade, ±50 ISO-R): rotate contrast locally in the deep shadows or highlights, the digital equivalent of split-grade printing. -* **Contrast Mask** (0.0 to 0.5, hidden in Transparency): sandwich the negative with a blurred, low-contrast positive, as the darkroom does with a mask film and a spacer. The value is that mask's gamma. It squeezes the negative's overall range so a harder grade still fits the paper, while the blur keeps fine detail out of the squeeze, so local contrast and texture stay put. Use it when a scene is too contrasty to print at the grade you want: raise the mask, then take the Grade back down in R. Above about 0.4 the low-frequency lift starts to show as a soft halo along strong edges, which is what a real masked print looks like. It reads only what you have cropped, the way the enlarger only projects the frame you print, so the rebate and the scanner surround cannot leak in as a vignette. It is a global control: a masking film is neutral, so there is no per-layer trim and the slider greys out in R/G/B mode. +* **Contrast Mask** (0.0 to 0.5, hidden in Transparency): sandwich the negative with a blurred, low-contrast positive, as the darkroom does with a mask film and a spacer. The value is that mask's gamma. It squeezes the negative's overall range so a harder grade still fits the paper, while the blur keeps fine detail out of the squeeze and local contrast stays put. Use it when a scene is too contrasty to print at the grade you want: raise the mask, then take the Grade back down in R. Past about 0.4 the lift shows as a soft halo along strong edges, which is what a masked print looks like. It reads only what you have cropped. A masking film is neutral, so there is no per-layer trim and the slider greys out in R/G/B mode. * **Dye Separation** (0.5 to 1.5, hidden in B&W Negative): saturation in density space. It pushes the print's three dye densities apart *before* the positive is decoded, in the same matrix the paper's own dye crosstalk uses, so it responds to the paper profile you picked and eases off automatically where the curve is already compressed at toe and shoulder, instead of forcing color into tones that have none left to give. Below 1.0 it pulls the dyes together toward neutral. 1.0 is off. Contrast this with **Chroma** in the Color tab, which scales color evenly after decode. * **Separation Damping** (0 to 1, hidden in B&W Negative): decides *where* the Dye Separation push lands, rather than adding a push of its own. At 0 every color gets the same treatment. Turn it up and muted color keeps the full push while color that is already saturated gets the opposite, so a hard push puts color into the tones that had none instead of driving the strongest colors until they flatten into a slab. Below 1.0 separation it mirrors: pastels go grey while the vivid colors survive. It is **dead at Dye Separation 1.0**, where the slider greys out, because it has no look of its own. This is not the same as backing Dye Separation off: a lower value takes color from *everything*, including tones that had little to start with, where turning damping up takes it only from the colors that already have plenty. diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index 31380ea1..df05793b 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -1118,15 +1118,10 @@ def contrast_mask_ev( """ The contrast mask as print-exposure stops, ready to add to the dodge/burn map. - Sandwiching a blurred gamma-g positive with the negative gives D' = D - g*blur(D), - which in normalized space is val - g*blur. One stop is log10(2) of density, so - dividing by that turns the density subtraction into the stops the map carries — and - equal stops is an equal absolute density change in every channel, which is what a - neutral panchromatic masking film records. - - The plane covers the printed frame, so with a crop it is placed back at `roi` and - the surround is edge-replicated: outside the crop nothing is printed, and replicating - keeps the crop tool's full-frame preview free of a seam at the crop line. + The sandwich D' = D - g*blur(D) is val - g*blur in normalized space; one stop is + log10(2) of density, and equal stops is an equal density change in every channel, + as a neutral panchromatic masking film gives. The plane covers the printed frame, + so it lands back at `roi`, edge-replicated so the uncropped preview has no seam. """ if plane is None or gamma <= 0.0: return None diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 6b1e9aa8..a4b97195 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -16,9 +16,8 @@ # Above this size the block-median is threaded over row strips (np.median frees the GIL). _BLOCK_MEDIAN_PARALLEL_MIN_PIXELS = 2_000_000 -# Unsharpness of the contrast mask, as a fraction of the analysis grid's short side. A -# fraction of the grid rather than of the render is what keeps the mask identical at -# preview and export resolution. +# Contrast-mask unsharpness, as a fraction of the analysis grid's short side. Of the +# grid, not of the render, so preview and export mask alike. MASK_SIGMA_FRACTION = 0.04 @@ -514,8 +513,8 @@ def measure_textural_range( def normalized_roi(roi: Optional[Tuple[int, int, int, int]], shape: Tuple[int, int]) -> Optional[Tuple[float, float, float, float]]: - """An (y1, y2, x1, x2) pixel ROI as fractions of `shape`, so it can be replayed on - any downsampled copy. None (or a full-frame ROI) stays None.""" + """A pixel ROI as fractions of `shape`, replayable on any downsampled copy. None + and a full-frame ROI both stay None.""" if roi is None: return None h, w = shape @@ -539,20 +538,12 @@ def contrast_mask_plane( roi_norm: Optional[Tuple[float, float, float, float]] = None, ) -> np.ndarray: """ - The blurred low-gamma positive of an unsharp contrast-reduction mask, as a - normalized luminance plane on the analysis grid. + The blurred low-gamma positive of an unsharp contrast-reduction mask, as a zero-mean + luminance plane on the analysis grid. - Takes the linear frame *before* geometry and replays it on the downsampled copy, - so both engines can call this on the same array and get the same plane. Sigma is a - fraction of the grid, never of the render, so preview and export mask alike. - - `roi_norm` is the printed frame as (y1, y2, x1, x2) fractions. The plane covers only - that: the enlarger projects the frame you print, so rebate and scanner surround must - stay out of the mask — blurred over, they would print as a vignette the negative - does not have. - - Returned zero-mean: a sandwiched mask is denser and the printer opens up for it, so - the plane carries only the redistribution and leaves print density alone. + Takes the linear frame *before* geometry and replays it on the downsampled copy, so + both engines call this on the same array. `roi_norm` is the printed frame as + (y1, y2, x1, x2) fractions: a rebate or surround blurred in prints as a vignette. """ from negpy.features.exposure.models import EXPOSURE_CONSTANTS from negpy.features.geometry.logic import apply_fine_rotation, apply_keystone, apply_radial_distortion @@ -584,8 +575,7 @@ def contrast_mask_plane( x2 = max(x1 + 1, min(gw, int(round(roi_norm[3] * gw)))) image = np.ascontiguousarray(image[y1:y2, x1:x2]) - # Degenerate bounds (a frame that never metered) would normalize to huge values and - # the mask would amplify them into the print. No stretch, no mask. + # A frame that never metered normalizes to huge values; no stretch, no mask. if luminance_density_range(bounds) < 1e-6: return np.zeros(image.shape[:2], dtype=np.float32) diff --git a/negpy/features/exposure/processor.py b/negpy/features/exposure/processor.py index b1a89d83..82e96122 100644 --- a/negpy/features/exposure/processor.py +++ b/negpy/features/exposure/processor.py @@ -342,8 +342,7 @@ def process(self, image: ImageBuffer, context: PipelineContext) -> ImageBuffer: local_maps = self._build_local_maps(image, context) ev_map = None if local_maps is None else np.ascontiguousarray(local_maps[:, :, 0]) - # The contrast mask reaches the print as print exposure, like a dodge, so it rides - # the same map instead of needing a stage of its own. + # The mask is a print-exposure input like a dodge, so it rides the same map. mask_ev = contrast_mask_ev( context.metrics.get("contrast_mask_plane"), self.config.contrast_mask, diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index 8cadaed1..49efe6ce 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1738,27 +1738,21 @@ def map_point_radial(px: float, py: float, k1: float, w: int, h: int) -> Tuple[f return cx + ix * scale, cy + iy * scale -# Converging verticals, the tilted easel. A plane-to-plane projectivity: the output -# rectangle samples a trapezoid of the source, so one edge is stretched and the opposite -# one squeezed. The unit is per-cent of frame width (or height), the number a printer -# measures on the easel — not tilt degrees, which need an enlarger magnification and a -# focal length the pipeline does not model. Mirrored in transform.wgsl and applied to -# points in map_coords_to_geometry; change the quad in all three. +# Easel tilt and swing: a plane-to-plane projectivity, the output rect sampling a +# trapezoid of the source. The unit is per-cent of the frame, not tilt degrees, which +# need an enlarger magnification and focal length the pipeline does not model. Mirrored +# in transform.wgsl and in map_coords_to_geometry; change the quad in all three. _KEYSTONE_EPS = 1e-4 # per-cent def keystone_matrix_normalized(converge_v: float, converge_h: float) -> np.ndarray: - """Forward map (source -> corrected) in normalized frame coords. + """Forward map (source -> corrected) in the GPU's sampling convention, + u = (index + 0.5) / dims. - The single definition of the quad. "Normalized" is the convention the GPU samples in, - u = (index + 0.5) / dims, so the frame spans [0, 1] and the pixel-space matrix below - is derived from this one rather than built a second time — the two would otherwise - disagree by half a pixel and a w/(w-1) scale. - - Both convergences are per-cent of the frame, the config's unit. Positive converge_v - stretches the top edge, which straightens a frame shot with the camera tilted up; - positive converge_h stretches the left edge. + The single definition of the quad: the pixel-space matrix and the shader's inverse + both derive from it. Convergences are per-cent; positive converge_v stretches the + top edge, positive converge_h the left. """ a, b = converge_v * 0.005, converge_h * 0.005 src = np.float32([[a, b], [1.0 - a, -b], [1.0 + a, 1.0 + b], [-a, 1.0 - b]]) @@ -1779,8 +1773,8 @@ def keystone_matrix(converge_v: float, converge_h: float, w: int, h: int) -> np. def apply_keystone(img: ImageBuffer, converge_v: float, converge_h: float) -> ImageBuffer: """Perspective correction. Same canvas size, replicated edges: the wedge stays and - the user crops it, exactly as fine rotation does. Scaling to fill would silently - change magnification and drag a crop the user already drew.""" + the user crops it, as fine rotation does. Scaling to fill would change magnification + and move an existing crop.""" if abs(converge_v) < _KEYSTONE_EPS and abs(converge_h) < _KEYSTONE_EPS: return img h, w = img.shape[:2] @@ -1809,11 +1803,8 @@ def map_point_keystone(px: float, py: float, converge_v: float, converge_h: floa def keystone_inverse_normalized(converge_v: float, converge_h: float) -> np.ndarray: - """The keystone's inverse (corrected -> source) in normalized coords. - - The GPU undoes the keystone first and consumes this directly, so the shader never - rebuilds the quad and cannot drift from the CPU. Identity when both are zero. - """ + """The keystone's inverse (corrected -> source), which the shader consumes directly + rather than rebuilding the quad. Identity when both are zero.""" if abs(converge_v) < _KEYSTONE_EPS and abs(converge_h) < _KEYSTONE_EPS: return np.eye(3, dtype=np.float64) m = np.linalg.inv(keystone_matrix_normalized(converge_v, converge_h)) diff --git a/negpy/features/geometry/models.py b/negpy/features/geometry/models.py index b0e70cd9..171a808c 100644 --- a/negpy/features/geometry/models.py +++ b/negpy/features/geometry/models.py @@ -106,11 +106,8 @@ class GeometryConfig: fine_rotation: float = 0.0 flip_horizontal: bool = False flip_vertical: bool = False - # Perspective correction, the tilted easel. Per-cent of the frame, the number a - # printer measures with a ruler: tilt degrees would need an enlarger magnification - # and focal length the pipeline does not model. Positive converge_v stretches the - # top edge, which straightens a frame shot with the camera tilted up; positive - # converge_h stretches the left edge. + # Easel tilt and swing, in per-cent of the frame rather than degrees (see + # geometry.logic). Positive converge_v stretches the top edge, converge_h the left. converge_v: float = 0.0 # [-15.0, 15.0] % converge_h: float = 0.0 # [-15.0, 15.0] % autocrop_offset: int = 0 diff --git a/negpy/services/view/coordinate_mapping.py b/negpy/services/view/coordinate_mapping.py index 6f595386..018f5f09 100644 --- a/negpy/services/view/coordinate_mapping.py +++ b/negpy/services/view/coordinate_mapping.py @@ -71,11 +71,10 @@ def create_uv_grid( def _grid_homography(uv_grid: np.ndarray) -> np.ndarray: """The grid as a projective map, viewport (0-1) -> raw (0-1). - Fitted from four interior samples, so it is exact for every geometry op that is - itself projective — rotation, flips, fine rotation, crop and the keystone. Only - distortion stays approximate, and the model applies off the frame, where the grid - has no data anyway. Samples come from the middle: a fine rotation fills the grid - border with zeros, and those are not coordinates. + Four interior samples, exact for every projective op (rotation, flips, fine + rotation, crop, keystone); only distortion stays approximate, and this is used + only off the frame. Samples come from the middle, since a fine rotation fills + the border with zeros and those are not coordinates. """ h_uv, w_uv = uv_grid.shape[:2] x0, x1 = w_uv // 4, w_uv - 1 - w_uv // 4 diff --git a/tests/test_contrast_mask.py b/tests/test_contrast_mask.py index 6fa77f9b..c37a9ec7 100644 --- a/tests/test_contrast_mask.py +++ b/tests/test_contrast_mask.py @@ -1,8 +1,7 @@ """The unsharp contrast-reduction mask. -The mask reaches the print through the dodge/burn EV map, so both engines build the -plane with the same helper on the same pre-geometry array. These pin the three things -that would silently break it: the sign, the resolution invariance, and CPU/GPU parity. +Pins what would break silently: the sign, resolution invariance, the crop boundary and +CPU/GPU parity. """ import unittest @@ -52,16 +51,15 @@ def _render(img: np.ndarray, settings: WorkspaceConfig, tag: str) -> np.ndarray: class TestContrastMaskPlane(unittest.TestCase): def test_plane_is_zero_mean(self): - """A sandwiched mask is denser and the printer opens up for it; the plane must - carry only the redistribution, or the slider would double as Print Density.""" + """A sandwich is denser and the printer opens up for it, so the plane carries + only the redistribution; otherwise the slider doubles as Print Density.""" img = _wide_range_negative() plane = contrast_mask_plane(img, LogNegativeBounds(floors=(-1.4, -1.4, -1.4), ceils=(-0.05, -0.05, -0.05)), None) self.assertAlmostEqual(float(plane.mean()), 0.0, places=5) def test_surround_outside_the_crop_stays_out_of_the_mask(self): - """The enlarger projects the frame you print. Blurred over, a bright rebate or a - black scanner surround prints as a vignette the negative does not have, so the - plane must be built on the crop, not on the whole scan.""" + """A rebate or surround blurred into the mask prints as a vignette the negative + does not have.""" h, w = 600, 900 img = np.full((h, w, 3), 0.35, dtype=np.float32) border = 60 @@ -92,8 +90,8 @@ def test_ev_is_off_when_gamma_is_zero(self): self.assertIsNone(contrast_mask_ev(None, 0.5, 1.4, (8, 8))) def test_ev_opposes_the_plane(self): - """The sandwich subtracts the blurred positive, so a dense low-frequency area - must come back as negative exposure and a thin one as positive.""" + """The sandwich subtracts the blurred positive: dense areas come back as + negative exposure, thin ones as positive.""" plane = np.array([[-0.2, 0.2]], dtype=np.float32) ev = contrast_mask_ev(plane, 0.5, 1.4, (1, 2)) assert ev is not None @@ -110,8 +108,8 @@ def test_ev_scales_with_gamma(self): class TestContrastMaskRender(unittest.TestCase): def test_mask_raises_micro_contrast_at_every_density(self): - """The point of the mask: the global range is compressed, so a hard grade's - local contrast survives at both ends instead of being traded away.""" + """The range compresses, so a hard grade's local contrast survives at both ends + instead of being traded away.""" img = _wide_range_negative() plain = _render(img, _bw_settings(grade=60.0), "cm-plain") masked = _render(img, _bw_settings(grade=60.0, contrast_mask=0.5), "cm-masked") @@ -132,8 +130,7 @@ def test_zero_gamma_changes_nothing(self): np.testing.assert_allclose(off, explicit, atol=1e-6) def test_mask_survives_a_resolution_change(self): - """Sigma is a fraction of the analysis grid, not of the render, so the mask - must not add a resolution dependence of its own on top of the pipeline's.""" + """Sigma is a fraction of the analysis grid, not of the render.""" img = _wide_range_negative() h, w = img.shape[:2] big = cv2.resize(img, (w * 2, h * 2), interpolation=cv2.INTER_LINEAR) diff --git a/tests/test_keystone.py b/tests/test_keystone.py index fcbed728..028c9eb4 100644 --- a/tests/test_keystone.py +++ b/tests/test_keystone.py @@ -1,8 +1,8 @@ -"""Converging verticals — the perspective correction, framed as easel tilt. +"""Easel tilt and swing, the perspective correction. -The warp itself is one cv2 call. What can silently break is everything that has to -agree with it: the shader's inverse, the meters that read the warped frame, the point -mapper that puts dodge/burn masks where the canvas draws them, and autocrop's replay. +The warp is one cv2 call; what breaks silently is everything that has to agree with it — +the shader's inverse, the meters, the point mapper behind dodge/burn masks, and +autocrop's replay. """ import unittest @@ -37,8 +37,7 @@ def test_zero_is_identity(self): np.testing.assert_allclose(keystone_inverse_normalized(0.0, 0.0), np.eye(3), atol=1e-12) def test_output_keeps_the_canvas_size(self): - """Same-size output is an invariant the GPU's texture allocation depends on: it - derives the intermediate dimensions from rotation alone.""" + """The GPU derives its intermediate dimensions from rotation alone.""" img = _test_field() self.assertEqual(apply_keystone(img, 12.0, -8.0).shape, img.shape) @@ -54,8 +53,8 @@ def test_positive_converge_v_stretches_the_top(self): self.assertGreater(top, bottom, f"top {top} px, bottom {bottom} px") def test_point_mapper_follows_the_resample(self): - """A feature point must land where the warp actually put its pixels, or masks - drift off what the canvas draws.""" + """A feature point lands where the warp put its pixels, or masks drift off what + the canvas draws.""" h, w = 96, 144 for cv_, ch_ in ((8.0, 0.0), (0.0, -6.0), (12.0, 5.0)): img = np.zeros((h, w), np.float32) @@ -68,8 +67,7 @@ def test_point_mapper_follows_the_resample(self): self.assertAlmostEqual(got[1], want[0], delta=1.0, msg=f"x at cv={cv_} ch={ch_}") def test_shader_inverse_undoes_the_forward_matrix(self): - """The GPU consumes this matrix directly; if it stopped being the inverse of the - CPU's own quad the two engines would sample different pixels.""" + """The GPU consumes this matrix directly, so it must invert the CPU's own quad.""" h, w = 96, 144 fwd = keystone_matrix(9.0, -4.0, w, h) inv_norm = keystone_inverse_normalized(9.0, -4.0) @@ -84,7 +82,7 @@ def test_mask_vertices_follow_the_keystone(self): def test_detection_key_tracks_the_correction(self): """Autocrop replays the keystone, so a resolved rect must not survive a change - to it — detection would have found the rebate on a differently warped frame.""" + to it.""" g = WorkspaceConfig().geometry self.assertNotEqual(autocrop_detection_key(g), autocrop_detection_key(replace(g, converge_v=6.0))) self.assertNotEqual(autocrop_detection_key(g), autocrop_detection_key(replace(g, converge_h=6.0))) @@ -99,8 +97,8 @@ def test_uv_grid_carries_the_correction(self): self.assertFalse(np.allclose(plain, warped)) def test_off_frame_points_round_trip(self): - """Card-edge masks put handles outside the picture, where there is no grid sample - and the projective model answers instead. It must invert cleanly.""" + """Card-edge handles sit outside the picture, where the projective model answers + instead of the grid. It must invert cleanly.""" from negpy.services.view.coordinate_mapping import CoordinateMapping grid = CoordinateMapping.create_uv_grid(96, 144, 0, 0.0, converge_v=10.0, converge_h=-6.0) From 93dab74b45766ea16e6f888260411ef0f51e8a02 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 17 Aug 2026 14:26:23 +0200 Subject: [PATCH 6/9] feat(tone): make the Contrast Mask work both ways Ctein's masking chapter covers contrast increase as well as reduction, and it is the same operation with the sign flipped: D' = D - g*blur(D) subtracts the low frequencies for a blurred positive and adds them for a blurred negative. The range opens to +/-0.5 and the gates stop requiring g > 0. The increasing half is not a Grade preset. Grade multiplies every frequency alike; the mask multiplies only the low ones, so at a matched gain in global contrast the two move micro-contrast in opposite directions. Measured on a wide-range negative at equal global spread: RMS-Laplacian micro-contrast goes 1.15 -> 0.87 under the mask and 1.15 -> 1.21 under a harder grade. It also has travel where Grade has none. The straight-line slope is k = 2.9 * density_range / (ISO_R/100) clamped to a floor of 2.0, so a negative flat enough sits on that floor at every ISO-R and the grade slider is inert; the mask still adds 22% of global spread there. No veiling artifact in the new direction: on a real scan at -0.3 the bright areas rise and the dark areas fall, while the dark side of strong edges stays neutral, which is the opposite of the lifted-shadow signature. Slider is inverted like ISO-R Grade, so dragging right hardens on both. --- docs/PIPELINE.md | 4 +- docs/USER_GUIDE.md | 8 +++- negpy/desktop/view/sidebar/tone.py | 12 ++++-- negpy/features/exposure/logic.py | 6 ++- negpy/features/exposure/models.py | 4 +- negpy/features/exposure/normalization.py | 4 +- negpy/services/rendering/engine.py | 2 +- negpy/services/rendering/gpu_engine.py | 4 +- tests/test_contrast_mask.py | 51 ++++++++++++++++++++++++ 9 files changed, 80 insertions(+), 15 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 75db5f67..5d8345ed 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -134,7 +134,9 @@ Here is what happens to your image. We apply these steps in order, passing the b One function (`local/logic.rasterise`) serves the render, the canvas tint and the printing-notes map, so none of the three can describe a different shape than the others. The GPU consumes the same CPU-rasterised map (`compute_local_maps` → the dodge/burn texture), so shapes need no shader work and have no parity surface. * **Local Grade** (`LocalMask.grade`, ISO-R points off the frame's Grade, negative = harder): burning or dodging *through a different filter*, which on variable-contrast paper is what a hard-filter burn is. The masks rasterize a second plane in the same pass (`compute_local_maps`: plane 0 EV, plane 1 summed $\Delta R$), and the $\Delta R$ becomes a per-pixel slope multiplier through the same ratio a per-layer Grade trim uses, $R/(R+\Delta R)$ clamped to the ISO-R ladder (`local_grade_factor_map`, single source for the CPU kernel and the GPU's uploaded map). In the curve it multiplies the straight-line slope only, $v = k \cdot g \cdot (x_{adj} - x_0) + c \cdot x_{adj}^2$, so the rotation is **about the channel pivot**: a grade-only mask changes its region's contrast without moving its density, and the cast-removal curvature $c$ stays global. All three channels take the same factor, matching global Grade. On the GPU the factor rides the dodge/burn texture's green channel, so it costs no extra bind slot; the metrics and the zone ruler still describe the frame-wide grade, not a masked region's. -* **Contrast Mask** (`ExposureConfig.contrast_mask`, 0 to 0.5, default 0): the darkroom's unsharp contrast-reduction mask — a blurred, low-gamma positive contact-printed off the negative and sandwiched with it. Densities add, so the sandwich is $D' = D - g\,\text{blur}(D) + \text{const}$: a linear high-boost in log density that compresses the global range while the blur keeps fine detail out of the compression. $g$ is the mask film's gamma, and the published range is the slider's range (Ctein, *Post Exposure*; Bond, *Unsharp Masking*; Adams, *The Print*). +* **Contrast Mask** (`ExposureConfig.contrast_mask`, ±0.5, default 0): the darkroom's unsharp mask — a blurred, low-gamma copy contact-printed off the negative and sandwiched with it. Densities add, so the sandwich is $D' = D - g\,\text{blur}(D) + \text{const}$: a linear high-boost in log density (Ctein, *Post Exposure*; Bond, *Unsharp Masking*; Adams, *The Print*). + + $g$ is the mask film's gamma and it is signed, because both masks are the same operation. **Positive** is a blurred positive: it compresses the global range while the blur keeps fine detail out of the compression. **Negative** is a blurred negative, adding the low frequencies instead, which expands the global range and leaves fine detail where it was. That is what separates the increasing mask from a harder Grade, which multiplies every frequency alike: at a matched global gain the two move micro-contrast in opposite directions. It also has travel where Grade does not, since the straight-line slope is clamped at a floor of 2.0 and a sufficiently flat negative sits on it at every ISO-R. It is not a stage: `contrast_mask_plane` builds the plane, `contrast_mask_ev` converts it to stops, and it joins the dodge/burn map the print stage already reads. Equal stops is an equal absolute density change in all three channels, which is what a neutral panchromatic masking film records, and why there is no per-layer trim. Both engines call the same helper on the same pre-geometry array, and $\sigma$ is a fraction of the analysis grid rather than of the render, so preview, export and the two engines all mask alike. The plane is zero-mean, since a real sandwich is denser and the printer opens up for it. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 1da2dfa8..0328c172 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -493,7 +493,13 @@ The paper's response. A **Global / R / G / B** selector at the top scopes most c These two also work in Transparency with **Normalize off**, on the same tones (the centres are mapped by position on each curve's own scale, not by raw density), and there they are the only mid-sparing controls: Shadows Density opens the quarter-tone with the highlights unmoved, where Grade and Toe drag the whole scale with them and cost the highlights. * **Shadows Grade** / **Highlights Grade** (split grade, ±50 ISO-R): rotate contrast locally in the deep shadows or highlights, the digital equivalent of split-grade printing. -* **Contrast Mask** (0.0 to 0.5, hidden in Transparency): sandwich the negative with a blurred, low-contrast positive, as the darkroom does with a mask film and a spacer. The value is that mask's gamma. It squeezes the negative's overall range so a harder grade still fits the paper, while the blur keeps fine detail out of the squeeze and local contrast stays put. Use it when a scene is too contrasty to print at the grade you want: raise the mask, then take the Grade back down in R. Past about 0.4 the lift shows as a soft halo along strong edges, which is what a masked print looks like. It reads only what you have cropped. A masking film is neutral, so there is no per-layer trim and the slider greys out in R/G/B mode. +* **Contrast Mask** (±0.5, hidden in Transparency): sandwich the negative with a blurred, low-contrast copy of itself, as the darkroom does with a mask film and a spacer. The value is that mask's gamma, and it runs both ways. + + **Positive** is the familiar contrast-reducing mask, a blurred positive. It squeezes the negative's overall range so a harder grade still fits the paper, while the blur keeps fine detail out of the squeeze and local contrast stays put. Use it when a scene is too contrasty to print at the grade you want: raise the mask, then take the Grade back down in R. Past about 0.4 the lift shows as a soft halo along strong edges, which is what a masked print looks like. + + **Negative** is the contrast-increasing mask, a blurred negative, which adds the low frequencies instead of removing them. It expands the broad tones without crunching grain and texture, where a harder Grade steepens everything at once — at a matched gain in overall contrast the two move fine detail in opposite directions. It also still has travel on a very flat negative, where the print curve's slope sits on its floor and Grade has run out entirely. Past about −0.4 the expanded range starts clipping highlights; the Analysis panel's Clipping row will tell you. + + Either way it reads only what you have cropped. A masking film is neutral, so there is no per-layer trim and the slider greys out in R/G/B mode. * **Dye Separation** (0.5 to 1.5, hidden in B&W Negative): saturation in density space. It pushes the print's three dye densities apart *before* the positive is decoded, in the same matrix the paper's own dye crosstalk uses, so it responds to the paper profile you picked and eases off automatically where the curve is already compressed at toe and shoulder, instead of forcing color into tones that have none left to give. Below 1.0 it pulls the dyes together toward neutral. 1.0 is off. Contrast this with **Chroma** in the Color tab, which scales color evenly after decode. * **Separation Damping** (0 to 1, hidden in B&W Negative): decides *where* the Dye Separation push lands, rather than adding a push of its own. At 0 every color gets the same treatment. Turn it up and muted color keeps the full push while color that is already saturated gets the opposite, so a hard push puts color into the tones that had none instead of driving the strongest colors until they flatten into a slab. Below 1.0 separation it mirrors: pastels go grey while the vivid colors survive. It is **dead at Dye Separation 1.0**, where the slider greys out, because it has no look of its own. This is not the same as backing Dye Separation off: a lower value takes color from *everything*, including tones that had little to start with, where turning damping up takes it only from the colors that already have plenty. diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 2ec29770..99d137c6 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -137,11 +137,15 @@ def _init_ui(self) -> None: split_grade_row.addWidget(self.highlight_grade_slider) self.layout.addLayout(split_grade_row) - self.contrast_mask_slider = CompactSlider("Contrast Mask", 0.0, 0.5, conf.contrast_mask) + # Inverted like ISO-R Grade, so dragging right hardens on both controls. + self.contrast_mask_slider = CompactSlider("Contrast Mask", -0.5, 0.5, conf.contrast_mask, has_neutral=True, inverted=True) self.contrast_mask_slider.setToolTip( - "Contrast Mask: sandwich the negative with a blurred low-contrast positive, as in the " - "darkroom. The value is the mask film's gamma — it compresses the negative's overall " - "range so a harder grade fits the paper, while local contrast and fine detail stay put." + "Contrast Mask: sandwich the negative with a blurred, low-contrast copy, as in the " + "darkroom. The value is the mask film's gamma, and it works both ways. Positive is a " + "blurred positive, which compresses the negative's range so a harder grade fits the " + "paper. Negative is a blurred negative, which expands the range instead, adding snap " + "to the broad tones without crunching grain and texture the way Grade does — and it " + "still has travel on a flat negative, where Grade has run out." ) contrast_mask_row = QHBoxLayout() contrast_mask_row.addWidget(self.contrast_mask_slider) diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index df05793b..cd04e039 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -1120,10 +1120,12 @@ def contrast_mask_ev( The sandwich D' = D - g*blur(D) is val - g*blur in normalized space; one stop is log10(2) of density, and equal stops is an equal density change in every channel, - as a neutral panchromatic masking film gives. The plane covers the printed frame, + as a neutral panchromatic masking film gives. Positive g is the reduction mask, a + blurred positive; negative g is the increasing mask, a blurred negative, which adds + the low frequencies instead of removing them. The plane covers the printed frame, so it lands back at `roi`, edge-replicated so the uncropped preview has no seam. """ - if plane is None or gamma <= 0.0: + if plane is None or abs(gamma) < 1e-6: return None ev = (-gamma * plane * float(density_range) / float(np.log10(2.0))).astype(np.float32) h, w = out_shape diff --git a/negpy/features/exposure/models.py b/negpy/features/exposure/models.py index 606ef93b..a29b8f06 100644 --- a/negpy/features/exposure/models.py +++ b/negpy/features/exposure/models.py @@ -43,8 +43,8 @@ class ExposureConfig: # Ranges are asymmetric: density is log10, so an equal ΔD reads smaller near d_max. shadow_density: float = 0.0 highlight_density: float = 0.0 - # Unsharp contrast-reduction mask: the gamma of the blurred low-contrast positive - # sandwiched with the negative. [0.0, 0.5]; 0 = no mask. + # Unsharp mask gamma: positive sandwiches a blurred positive and reduces contrast, + # negative a blurred negative and increases it. [-0.5, 0.5]; 0 = no mask. contrast_mask: float = 0.0 # Split grade: zone contrast in ISO-R points (negative = harder), global # value + per-layer trims like Grade. diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index a4b97195..f7ab2331 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -538,8 +538,8 @@ def contrast_mask_plane( roi_norm: Optional[Tuple[float, float, float, float]] = None, ) -> np.ndarray: """ - The blurred low-gamma positive of an unsharp contrast-reduction mask, as a zero-mean - luminance plane on the analysis grid. + The blurred low-gamma copy an unsharp mask is made from, as a zero-mean luminance + plane on the analysis grid. The sign of the gamma decides positive or negative. Takes the linear frame *before* geometry and replays it on the downsampled copy, so both engines call this on the same array. `roi_norm` is the printed frame as diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index 306f1396..a07e964b 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -165,7 +165,7 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: # same helper on the same array. Keyed on base, so the Contrast Mask slider re-runs # only the exposure stage. mask_bounds = context.metrics.get("final_bounds") - if settings.exposure.contrast_mask > 0.0 and mask_bounds is not None: + if settings.exposure.contrast_mask != 0.0 and mask_bounds is not None: mask_roi = context.active_roi mask_key = (calculate_config_hash(base_key), mask_roi, current_img.shape[:2]) if self._mask_plane is None or self._mask_plane[0] != mask_key: diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index d8a83614..b652f7f1 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -617,7 +617,7 @@ def _analyze_bounds() -> LogNegativeBounds: # Keyed off the meter, so only the Contrast Mask slider's own value stays live. mask_plane = None mask_key = None - if settings.exposure.contrast_mask > 0.0 and not tiling_mode: + if settings.exposure.contrast_mask != 0.0 and not tiling_mode: mask_key = (analysis_key, bounds, roi, (h_rot, w_rot)) if self._mask_plane is None or self._mask_plane[0] != mask_key: self._mask_plane = ( @@ -1441,7 +1441,7 @@ def _upload_unified_uniforms( + struct.pack( "ffff", *local_ev_scale(LogNegativeBounds(adj_floors, adj_ceils)), - 1.0 if (settings.local.masks or exp.contrast_mask > 0.0) else 0.0, + 1.0 if (settings.local.masks or exp.contrast_mask != 0.0) else 0.0, ) # Split Grade per-channel zone contrast gains (split_grade_deltas). The w-lanes # carry Separation Damping's green and blue k. diff --git a/tests/test_contrast_mask.py b/tests/test_contrast_mask.py index c37a9ec7..88c228bb 100644 --- a/tests/test_contrast_mask.py +++ b/tests/test_contrast_mask.py @@ -29,6 +29,23 @@ def _wide_range_negative(h: int = 240, w: int = 360) -> np.ndarray: return np.ascontiguousarray(np.dstack([neg, neg, neg]).astype(np.float32)) +def _flat_negative(h: int = 300, w: int = 450) -> np.ndarray: + """A negative too flat for Grade: its log range puts the straight-line slope on the + k floor at every ISO-R, so the grade slider has no travel left.""" + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + key = np.exp(-0.9 * (yy / h)) + texture = 1.0 + 0.14 * np.sin(xx / 3.0) * np.sin(yy / 2.5) + scene = np.clip(key * texture, 1e-5, None) + scene /= scene.max() + neg = np.clip(0.25 + 0.30 * (1.0 - scene**0.35), 1e-4, 1.0) + return np.ascontiguousarray(np.dstack([neg, neg, neg]).astype(np.float32)) + + +def _global_spread(plane: np.ndarray) -> float: + """Spread of the broad tonal masses, with fine detail blurred away.""" + return float(cv2.GaussianBlur(plane, (0, 0), 12.0).std()) + + def _bw_settings(**exposure) -> WorkspaceConfig: s = WorkspaceConfig() return replace( @@ -98,6 +115,15 @@ def test_ev_opposes_the_plane(self): self.assertGreater(ev[0, 0], 0.0) self.assertLess(ev[0, 1], 0.0) + def test_negative_gamma_reverses_the_ev(self): + """A blurred negative adds the low frequencies where a blurred positive removes + them, so the two directions are one axis through zero.""" + plane = np.array([[0.2]], dtype=np.float32) + reduce_ = contrast_mask_ev(plane, 0.3, 1.4, (1, 1)) + increase = contrast_mask_ev(plane, -0.3, 1.4, (1, 1)) + assert reduce_ is not None and increase is not None + self.assertAlmostEqual(float(reduce_[0, 0]), -float(increase[0, 0]), places=6) + def test_ev_scales_with_gamma(self): plane = np.array([[0.25]], dtype=np.float32) half = contrast_mask_ev(plane, 0.25, 1.4, (1, 1)) @@ -123,6 +149,31 @@ def test_mask_compresses_the_global_range(self): masked = _render(img, _bw_settings(grade=60.0, contrast_mask=0.5), "cm-masked") self.assertLess(masked.std(), plain.std()) + def test_negative_gamma_expands_the_range_where_grade_cannot(self): + """Contrast increase, the other half of Ctein's masking chapter. On a flat + negative the straight-line slope sits on its clamp, so Grade is inert there; + the mask still has travel because it works on the low frequencies.""" + img = _flat_negative() + flat = _render(img, _bw_settings(grade=115.0), "cm-flat") + harder = _render(img, _bw_settings(grade=60.0), "cm-flat-hard") + masked = _render(img, _bw_settings(grade=115.0, contrast_mask=-0.3), "cm-flat-mask") + + np.testing.assert_allclose(flat, harder, atol=1e-6, err_msg="grade should be clamped inert here") + self.assertGreater(_global_spread(masked), _global_spread(flat) * 1.1) + + def test_the_two_directions_move_micro_contrast_oppositely(self): + """At a matched global gain Grade raises micro-contrast and the mask lowers it, + which is what makes the increasing mask more than a Grade preset.""" + img = _wide_range_negative() + base = _render(img, _bw_settings(grade=115.0), "cm-dir-base") + masked = _render(img, _bw_settings(grade=115.0, contrast_mask=-0.3), "cm-dir-mask") + graded = _render(img, _bw_settings(grade=90.0), "cm-dir-grade") + + for other in (masked, graded): + self.assertGreater(_global_spread(other), _global_spread(base)) + self.assertLess(_micro_contrast(masked)[1], _micro_contrast(base)[1]) + self.assertGreater(_micro_contrast(graded)[1], _micro_contrast(base)[1]) + def test_zero_gamma_changes_nothing(self): img = _wide_range_negative() off = _render(img, _bw_settings(grade=60.0), "cm-off") From 509fdf29e0d3ac7a0da8bd1e8c50c05c9f5bba34 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 17 Aug 2026 14:40:23 +0200 Subject: [PATCH 7/9] docs: correct the mask description and tighten the prose The shared opening claimed the mask was "a blurred, low-contrast copy of itself, contact-printed off the negative", which describes only the reduction case: a contact print of a negative is a positive, so it cannot also be the same-polarity mask the increasing direction needs. Polarity now lives in each direction's own sentence, with the range scaling each one gives. Verified against the implementation rather than the derivation: on a ramp plus fine texture, the low-frequency range scales 0.505 / 0.752 / 1.248 / 1.495 at g = +0.5 / +0.25 / -0.25 / -0.5 against a predicted (1 - g), while detail above the blur scale passes through at 1.000 throughout. Also drops the em dashes, the bold run-ins and several trailing relative clauses from the guide bullets, tooltips and pipeline entries, and shortens all three. --- docs/PIPELINE.md | 10 +++++----- docs/USER_GUIDE.md | 14 +++++++------- negpy/desktop/view/sidebar/geometry.py | 12 ++++++------ negpy/desktop/view/sidebar/tone.py | 12 ++++++------ negpy/features/exposure/logic.py | 8 ++++---- negpy/features/exposure/normalization.py | 4 ++-- negpy/services/rendering/gpu_engine.py | 2 +- tests/test_keystone.py | 2 +- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 5d8345ed..7ba54f33 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -9,9 +9,9 @@ Here is what happens to your image. We apply these steps in order, passing the b * **Rotation**: we spin the image array in 90° steps and fine-tune with affine transformations, using bilinear interpolation so it stays sharp. * **Lens distortion**: a radial $k_1$ coefficient, a rig property mirrored from the active flat-field profile (`flatfield.k1`), corrected in the same resample. -* **Tilt and Swing** (`geometry.converge_v` / `converge_h`, ±15%): perspective correction, named for the enlarger movements. Tilt about a horizontal axis straightens converging verticals, swing about a vertical axis converging horizontals: the plane-to-plane projectivity a tilted easel realises (Hartley & Zisserman §2.3). Runs last in the forward chain, after $k_1$, since a projectivity cannot be fitted to a barrel-distorted frame. The unit is per-cent of the frame rather than tilt degrees, because convergence is $(H/2)\sin\tau / D$ and no magnification or focal length is modelled. Output keeps the canvas size and replicates the wedge, as fine rotation does; the user crops it. +* **Tilt and Swing** (`geometry.converge_v` / `converge_h`, ±15%): perspective correction, named for the enlarger movements. Tilt about a horizontal axis straightens converging verticals, swing about a vertical axis converging horizontals: the plane-to-plane projectivity a tilted easel realises (Hartley & Zisserman §2.3). Runs last in the forward chain, after $k_1$, since a projectivity cannot be fitted to a barrel-distorted frame. The unit is per-cent of the frame, not tilt degrees, because convergence is $(H/2)\sin\tau / D$ and no magnification or focal length is modelled. Output keeps the canvas size and replicates the wedge, as fine rotation does. - `keystone_matrix_normalized` is the single definition of the quad, and the pixel-space matrix and the GPU's inverse both derive from it. Every reader of geometry carries the correction: the uv grid, `map_coords_to_geometry`, autocrop's replay and detection key, and the GPU's analysis replay, whose meters must read the frame the print stage gets. `CoordinateMapping` fits the grid projectively, which off-frame card-edge handles depend on. + `keystone_matrix_normalized` is the single definition of the quad; the pixel-space matrix and the GPU's inverse both derive from it. Every reader of geometry carries the correction: the uv grid, `map_coords_to_geometry`, autocrop's replay and detection key, and the GPU's analysis replay, whose meters must read the frame the print stage gets. Off-frame card-edge handles need `CoordinateMapping` to fit the grid projectively. * **Autocrop**: we detect where the film ends and the scanner bed begins by looking for the density jump. It is not perfect, because light leaks and odd scanning holders can fool it, so there is a manual override. Detection runs once per edit, in `ImageProcessor` ahead of either engine, and the rect is stored on the edit (`geometry.crop_rect`, with `crop_from_auto` marking where it came from). Both engines only slice that rect. The border walk reads whatever buffer it is given, and a preview buffer and a full-resolution export can stop on different edges, so detecting per render would export a crop the user never saw. The rect keeps the detection key it was found under (`autocrop_detection_key`: orientation, ratio, mode, rebate trim); a change there re-detects on the next render. Crop Offset is not in the key, because it is re-applied to the rect every render. @@ -134,11 +134,11 @@ Here is what happens to your image. We apply these steps in order, passing the b One function (`local/logic.rasterise`) serves the render, the canvas tint and the printing-notes map, so none of the three can describe a different shape than the others. The GPU consumes the same CPU-rasterised map (`compute_local_maps` → the dodge/burn texture), so shapes need no shader work and have no parity surface. * **Local Grade** (`LocalMask.grade`, ISO-R points off the frame's Grade, negative = harder): burning or dodging *through a different filter*, which on variable-contrast paper is what a hard-filter burn is. The masks rasterize a second plane in the same pass (`compute_local_maps`: plane 0 EV, plane 1 summed $\Delta R$), and the $\Delta R$ becomes a per-pixel slope multiplier through the same ratio a per-layer Grade trim uses, $R/(R+\Delta R)$ clamped to the ISO-R ladder (`local_grade_factor_map`, single source for the CPU kernel and the GPU's uploaded map). In the curve it multiplies the straight-line slope only, $v = k \cdot g \cdot (x_{adj} - x_0) + c \cdot x_{adj}^2$, so the rotation is **about the channel pivot**: a grade-only mask changes its region's contrast without moving its density, and the cast-removal curvature $c$ stays global. All three channels take the same factor, matching global Grade. On the GPU the factor rides the dodge/burn texture's green channel, so it costs no extra bind slot; the metrics and the zone ruler still describe the frame-wide grade, not a masked region's. -* **Contrast Mask** (`ExposureConfig.contrast_mask`, ±0.5, default 0): the darkroom's unsharp mask — a blurred, low-gamma copy contact-printed off the negative and sandwiched with it. Densities add, so the sandwich is $D' = D - g\,\text{blur}(D) + \text{const}$: a linear high-boost in log density (Ctein, *Post Exposure*; Bond, *Unsharp Masking*; Adams, *The Print*). +* **Contrast Mask** (`ExposureConfig.contrast_mask`, ±0.5, default 0): the darkroom's unsharp mask, a blurred low-gamma film mask sandwiched with the negative. Densities add, so the sandwich is $D' = D - g\,\text{blur}(D) + \text{const}$, a linear high-boost in log density (Ctein, *Post Exposure*; Bond, *Unsharp Masking*; Adams, *The Print*). - $g$ is the mask film's gamma and it is signed, because both masks are the same operation. **Positive** is a blurred positive: it compresses the global range while the blur keeps fine detail out of the compression. **Negative** is a blurred negative, adding the low frequencies instead, which expands the global range and leaves fine detail where it was. That is what separates the increasing mask from a harder Grade, which multiplies every frequency alike: at a matched global gain the two move micro-contrast in opposite directions. It also has travel where Grade does not, since the straight-line slope is clamped at a floor of 2.0 and a sufficiently flat negative sits on it at every ISO-R. + $g$ is the mask's gamma and it is signed, because the mask's polarity is what sets the direction. Positive is a blurred positive, dense where the negative is thin, and scales the range by $(1-g)$; the blur holds fine detail out of the compression. Negative is a mask of the negative's own polarity, dense where it is dense, and scales the range by $(1+g)$ while fine detail stays put. Both ratios hold on the low frequencies only, which is what makes it an *unsharp* mask: detail above the blur scale passes through at unity either way. That expansion is what separates the increasing mask from a harder Grade, which multiplies every frequency alike: at a matched global gain the two move micro-contrast in opposite directions. It also has travel where Grade has none, since the straight-line slope is clamped at a floor of 2.0 and a flat enough negative sits on that floor at every ISO-R. - It is not a stage: `contrast_mask_plane` builds the plane, `contrast_mask_ev` converts it to stops, and it joins the dodge/burn map the print stage already reads. Equal stops is an equal absolute density change in all three channels, which is what a neutral panchromatic masking film records, and why there is no per-layer trim. Both engines call the same helper on the same pre-geometry array, and $\sigma$ is a fraction of the analysis grid rather than of the render, so preview, export and the two engines all mask alike. The plane is zero-mean, since a real sandwich is denser and the printer opens up for it. + Not a stage: `contrast_mask_plane` builds the plane, `contrast_mask_ev` converts it to stops, and it joins the dodge/burn map the print stage already reads. Equal stops is an equal absolute density change in every channel, as a neutral panchromatic masking film gives, so there is no per-layer trim. Both engines call the same helper on the same pre-geometry array, and $\sigma$ is a fraction of the analysis grid rather than of the render, so preview, export and the two engines mask alike. The plane is zero-mean, since a real sandwich is denser and the printer opens up for it. The plane covers the printed frame only, because the enlarger projects the crop: a rebate or scanner surround blurred into the mask prints as a vignette the negative does not have. It is placed back at the crop, edge-replicated outside so the crop tool's full-frame preview has no seam. Hidden on the transparency transfer path, which takes no dodge/burn map. Instruments read the unmasked negative, as they already do under a dodge. * **Output**: converts print density back to **scene-linear** reflectance (transmittance): diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 0328c172..ac94b5ac 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -435,10 +435,10 @@ Where the frame gets its final shape: what is inside the print, and whether it s * **Fine Rotation** (±45°): free rotation for tilted scans, in sub-degree steps (positive is clockwise). Applied after auto-crop so the frame stays axis-aligned. * **Straighten** tool (ruler): draw a line along a horizon or vertical edge and NegPy rotates to make it level or plumb. -* **Tilt** (±15%): tip the easel about a horizontal axis to straighten converging verticals, the building that leans back because the camera was pointed up. Positive stretches the top edge. Measured as a per-cent of the frame, the amount you would read off the easel, rather than a tilt angle, since the same tilt keystones differently at every enlargement. -* **Swing** (±15%): the same movement about a vertical axis, for converging horizontals: a wall shot from one side, or a copy stand that is not square to the film. Positive stretches the left edge. +* **Tilt** (±15%): tip the easel about a horizontal axis to straighten converging verticals, the building that leans back because the camera pointed up. Positive stretches the top edge. The unit is per-cent of the frame, what you would measure on the easel, not a tilt angle: the same tilt keystones differently at every enlargement. +* **Swing** (±15%): the same movement about a vertical axis, for converging horizontals. A wall shot from one side, or a copy stand not square to the film. Positive stretches the left edge. - Both leave a wedge of replicated edge along the squeezed side, as Fine Rotation does; crop it off. Crop before correcting where you can, because the meters read the corrected frame: on an uncropped scan a large correction pulls rebate and surround into the metered area and the print darkens. Inside a crop the exposure barely moves. + Both replicate a wedge along the squeezed edge, as Fine Rotation does; crop it off. Crop before correcting if you can, because the meters read the corrected frame: on an uncropped scan a big correction pulls rebate and surround into the metered area and the print darkens. ### 5.2 Flat Field: even out the light @@ -493,13 +493,13 @@ The paper's response. A **Global / R / G / B** selector at the top scopes most c These two also work in Transparency with **Normalize off**, on the same tones (the centres are mapped by position on each curve's own scale, not by raw density), and there they are the only mid-sparing controls: Shadows Density opens the quarter-tone with the highlights unmoved, where Grade and Toe drag the whole scale with them and cost the highlights. * **Shadows Grade** / **Highlights Grade** (split grade, ±50 ISO-R): rotate contrast locally in the deep shadows or highlights, the digital equivalent of split-grade printing. -* **Contrast Mask** (±0.5, hidden in Transparency): sandwich the negative with a blurred, low-contrast copy of itself, as the darkroom does with a mask film and a spacer. The value is that mask's gamma, and it runs both ways. +* **Contrast Mask** (±0.5, hidden in Transparency): sandwich the negative with a blurred, low-contrast mask, as the darkroom does with a mask film and a spacer. Densities add, so the mask's own polarity decides which way the range goes, and its gamma decides how far. The value is that gamma, signed. - **Positive** is the familiar contrast-reducing mask, a blurred positive. It squeezes the negative's overall range so a harder grade still fits the paper, while the blur keeps fine detail out of the squeeze and local contrast stays put. Use it when a scene is too contrasty to print at the grade you want: raise the mask, then take the Grade back down in R. Past about 0.4 the lift shows as a soft halo along strong edges, which is what a masked print looks like. + Positive is the ordinary masking case: a blurred positive, contact-printed straight off the negative, so it is dense where the negative is thin. That squeezes the range by (1 − gamma) and a harder grade then fits the paper, while the blur keeps fine detail out of the squeeze. Use it on a scene too contrasty for the grade you want, then bring Grade back down in R. Past about 0.4 a soft halo appears along strong edges, as it does on a masked print. - **Negative** is the contrast-increasing mask, a blurred negative, which adds the low frequencies instead of removing them. It expands the broad tones without crunching grain and texture, where a harder Grade steepens everything at once — at a matched gain in overall contrast the two move fine detail in opposite directions. It also still has travel on a very flat negative, where the print curve's slope sits on its floor and Grade has run out entirely. Past about −0.4 the expanded range starts clipping highlights; the Analysis panel's Clipping row will tell you. + Negative is a mask of the same polarity as the negative, dense where the negative is dense, which stretches the range by (1 + gamma) instead. The broad tones expand while grain and texture stay put, where a harder Grade steepens both together. It also works on a negative too flat for Grade, whose slope has bottomed out. Past about −0.4 highlights start to clip, and the Analysis panel's Clipping row says so. - Either way it reads only what you have cropped. A masking film is neutral, so there is no per-layer trim and the slider greys out in R/G/B mode. + It reads only your crop. A masking film is neutral, so there is no per-layer trim and the slider greys out in R/G/B mode. * **Dye Separation** (0.5 to 1.5, hidden in B&W Negative): saturation in density space. It pushes the print's three dye densities apart *before* the positive is decoded, in the same matrix the paper's own dye crosstalk uses, so it responds to the paper profile you picked and eases off automatically where the curve is already compressed at toe and shoulder, instead of forcing color into tones that have none left to give. Below 1.0 it pulls the dyes together toward neutral. 1.0 is off. Contrast this with **Chroma** in the Color tab, which scales color evenly after decode. * **Separation Damping** (0 to 1, hidden in B&W Negative): decides *where* the Dye Separation push lands, rather than adding a push of its own. At 0 every color gets the same treatment. Turn it up and muted color keeps the full push while color that is already saturated gets the opposite, so a hard push puts color into the tones that had none instead of driving the strongest colors until they flatten into a slab. Below 1.0 separation it mirrors: pastels go grey while the vivid colors survive. It is **dead at Dye Separation 1.0**, where the slider greys out, because it has no look of its own. This is not the same as backing Dye Separation off: a lower value takes color from *everything*, including tones that had little to start with, where turning damping up takes it only from the colors that already have plenty. diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index eaf91bbe..ccd51c57 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -173,15 +173,15 @@ def _init_ui(self) -> None: self.converge_v_slider = CompactSlider("Tilt", -15.0, 15.0, conf.converge_v, unit="%") self.converge_v_slider.setToolTip( "Easel Tilt: tip the easel about a horizontal axis to straighten converging verticals, " - "the building that leans back because the camera was pointed up. Positive stretches the " - "top edge. Per-cent of the frame, the amount you would measure on the easel, not a tilt " - "angle — the same tilt keystones differently at every enlargement." + "the building that leans back because the camera pointed up. Positive stretches the top " + "edge. Per-cent of the frame, what you would measure on the easel, not a tilt angle: " + "the same tilt keystones differently at every enlargement." ) self.converge_h_slider = CompactSlider("Swing", -15.0, 15.0, conf.converge_h, unit="%") self.converge_h_slider.setToolTip( - "Easel Swing: the same movement about a vertical axis, straightening converging " - "horizontals — a wall shot from one side, or a copy stand that is not square to the " - "film. Positive stretches the left edge." + "Easel Swing: the same movement about a vertical axis, for converging horizontals. A " + "wall shot from one side, or a copy stand not square to the film. Positive stretches " + "the left edge." ) converge_row = QHBoxLayout() converge_row.addWidget(self.converge_v_slider) diff --git a/negpy/desktop/view/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 99d137c6..8a198bc1 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -140,12 +140,12 @@ def _init_ui(self) -> None: # Inverted like ISO-R Grade, so dragging right hardens on both controls. self.contrast_mask_slider = CompactSlider("Contrast Mask", -0.5, 0.5, conf.contrast_mask, has_neutral=True, inverted=True) self.contrast_mask_slider.setToolTip( - "Contrast Mask: sandwich the negative with a blurred, low-contrast copy, as in the " - "darkroom. The value is the mask film's gamma, and it works both ways. Positive is a " - "blurred positive, which compresses the negative's range so a harder grade fits the " - "paper. Negative is a blurred negative, which expands the range instead, adding snap " - "to the broad tones without crunching grain and texture the way Grade does — and it " - "still has travel on a flat negative, where Grade has run out." + "Contrast Mask: sandwich the negative with a blurred, low-contrast film mask, as in " + "the darkroom. Densities add, so the mask's polarity sets the direction and its gamma " + "sets the amount. Positive is a blurred positive and squeezes the negative's range, so " + "a harder grade then fits the paper. Negative matches the negative's own polarity and " + "stretches the range instead, adding snap to the broad tones while grain and texture " + "stay put. It still works on a flat negative where Grade has run out." ) contrast_mask_row = QHBoxLayout() contrast_mask_row.addWidget(self.contrast_mask_slider) diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index cd04e039..70be75cf 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -1120,10 +1120,10 @@ def contrast_mask_ev( The sandwich D' = D - g*blur(D) is val - g*blur in normalized space; one stop is log10(2) of density, and equal stops is an equal density change in every channel, - as a neutral panchromatic masking film gives. Positive g is the reduction mask, a - blurred positive; negative g is the increasing mask, a blurred negative, which adds - the low frequencies instead of removing them. The plane covers the printed frame, - so it lands back at `roi`, edge-replicated so the uncropped preview has no seam. + as a neutral panchromatic masking film gives. Positive g is a blurred positive and + subtracts the low frequencies; negative g matches the negative's polarity and adds + them. The plane covers the printed frame, so it lands back at `roi`, edge-replicated + so the uncropped preview has no seam. """ if plane is None or abs(gamma) < 1e-6: return None diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index f7ab2331..c1178ab1 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -538,8 +538,8 @@ def contrast_mask_plane( roi_norm: Optional[Tuple[float, float, float, float]] = None, ) -> np.ndarray: """ - The blurred low-gamma copy an unsharp mask is made from, as a zero-mean luminance - plane on the analysis grid. The sign of the gamma decides positive or negative. + The blurred low-gamma plane an unsharp mask is built from, zero-mean, on the analysis + grid. The gamma's sign picks the mask's polarity and so the direction. Takes the linear frame *before* geometry and replays it on the downsampled copy, so both engines call this on the same array. `roi_norm` is the printed frame as diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index b652f7f1..cc7c921d 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -1171,7 +1171,7 @@ def _upload_unified_uniforms( (1 if settings.geometry.flip_vertical else 0), ) + struct.pack("ffff", float(k1_eff), float(scale_s), 0.0, 0.0) # The shader undoes the keystone first, so it gets the CPU's own matrix inverted - # and normalized to [0,1] coords — deriving the quad twice would let the two drift. + # and normalized to [0,1] coords. Deriving the quad twice would let the two drift. g_data += _keystone_inverse_bytes(settings.geometry.converge_v, settings.geometry.converge_h) if tiling_mode: g_data = b"\x00" * 64 diff --git a/tests/test_keystone.py b/tests/test_keystone.py index 028c9eb4..666dbbdf 100644 --- a/tests/test_keystone.py +++ b/tests/test_keystone.py @@ -1,6 +1,6 @@ """Easel tilt and swing, the perspective correction. -The warp is one cv2 call; what breaks silently is everything that has to agree with it — +The warp is one cv2 call. What breaks silently is everything that has to agree with it: the shader's inverse, the meters, the point mapper behind dodge/burn masks, and autocrop's replay. """ From 8bdac2b5fce69a45c42ba585113b478956be7fc6 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 17 Aug 2026 15:26:28 +0200 Subject: [PATCH 8/9] feat(analysis): draw the Contrast Mask's reach as a band on the H&D chart The chart plots the global pointwise transfer function, so a spatial control has no line on it: the mask shifts each pixel by how far its own value sits from its blurred surroundings, and two pixels at one negative density print differently depending on their neighbours. That left the histogram moving while the curve sat still, which reads as a bug. There is an honest representation, though, and it is a band rather than a line. A large flat area, where the blur equals its own value, prints at val' = (1-g)*val + g*centre; fine detail riding on a neutral surround prints unmoved. Those are the two extremes and every pixel lands between them, which is what the earlier measurement showed: low-frequency range scaled by (1-g) to three decimals while detail amplitude held at 1.000. The far edge is dashed, since no single tone has to land there. contrast_mask_plane now returns the val it centred on alongside the plane, and both engines put it in metrics for the chart. Violet, because amber is the toe wash and blue the shoulder. Dodge/burn, local grade and CLAHE stay absent from the chart for the same reason the mask could not be a line; the guide now says so rather than leaving it to be discovered. --- docs/USER_GUIDE.md | 2 + negpy/desktop/view/sidebar/right_panel.py | 9 ++- negpy/desktop/view/widgets/charts.py | 47 +++++++++++++++- negpy/features/exposure/normalization.py | 11 ++-- negpy/services/rendering/engine.py | 5 +- negpy/services/rendering/gpu_engine.py | 7 ++- tests/test_chart_curve_parity.py | 68 +++++++++++++++++++++++ tests/test_contrast_mask.py | 6 +- 8 files changed, 141 insertions(+), 14 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index ac94b5ac..3964e8f0 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -221,6 +221,8 @@ Pinned above the tabs, this is your feedback while printing. Drag the divider to The chart is the paper characteristic (H&D) curve NegPy is printing through right now. It models how a sheet of photographic paper responds, and it is not a curves editor. Left to right is **negative density**, the exposure the paper receives, so dense parts of the negative (the scene's highlights) sit to the right. Bottom to top is the **print tone** that comes out. A steeper curve means more contrast, which is what Grade moves. The flattening at each end is the toe (shadows) and shoulder (highlights), where the paper runs out of range. +With a **Contrast Mask** dialled in, a violet band opens between the curve and a dashed edge. The mask shifts each pixel by how far its own value sits from its blurred surroundings, so there is no single curve for it: a large flat area prints on the dashed edge, fine detail prints on the solid curve, and everything else falls between. The band is the mask's reach. Dodge/burn, local grade and CLAHE are spatial in the same way and are deliberately absent from the chart, which plots the global curve. + The crosshair marks the **pivot**, the density the curve rotates around when you change contrast, so the midtone stays put. While you drag a slider, a faint **ghost** of the previous curve stays behind for comparison. If cast removal pulls the channels apart you get three separate R/G/B traces instead of one grey curve, and that spread *is* the color correction. #### The two histograms diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index 6be7a227..4b7b6db6 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -406,7 +406,14 @@ def _update_analysis(self) -> None: # Green channel is the base curve (white reference + stats slope). slope, pivot = slopes[1], pivots[1] self.curve_widget.update_curve( - config, slope=slope, pivot=pivot, slopes=slopes, pivots=pivots, curvatures=curvatures, process_mode=process_mode + config, + slope=slope, + pivot=pivot, + slopes=slopes, + pivots=pivots, + curvatures=curvatures, + process_mode=process_mode, + mask_centre=metrics.get("contrast_mask_centre"), ) self._update_step_wedge(config, process_mode, slope, pivot, metrics) diff --git a/negpy/desktop/view/widgets/charts.py b/negpy/desktop/view/widgets/charts.py index f827f662..957da0ee 100644 --- a/negpy/desktop/view/widgets/charts.py +++ b/negpy/desktop/view/widgets/charts.py @@ -58,6 +58,9 @@ def __init__(self, parent=None): self._shoulder_mask: list[float] = [] self._toe_strength: float = 0.0 self._shoulder_strength: float = 0.0 + # Contrast Mask band: the curve on the mask's remapped input. Every pixel prints + # between this and the base curve, by how much of its own value survives the blur. + self._mask_pts: list[tuple[float, float]] = [] # Drag feedback: pre-drag curve snapshot + the exposure field being dragged. self._active_param: str | None = None self._ghost_pts: list[tuple[float, float]] = [] @@ -165,6 +168,7 @@ def update_curve( curvatures: tuple[float, float, float] | None = None, process_mode: str | None = None, flat: bool = False, + mask_centre: float | None = None, ) -> None: from negpy.features.exposure.logic import ( _expit, @@ -213,11 +217,13 @@ def _curve_points( sg_ch: float | None = None, hg_ch: float | None = None, curv_ch: float = 0.0, + x_in: np.ndarray | None = None, ) -> list[tuple[float, float]]: + xv = x_log_exp if x_in is None else x_in if flat: # True log master: the code value is linear in the log signal (1 - val) and emitted # directly, with no 10^-D and no sRGB. s = gain, p = lift. - yv = np.clip(p + s * (1.0 - x_log_exp), 0.0, 1.0) + yv = np.clip(p + s * (1.0 - xv), 0.0, 1.0) return list(zip(plt_x.tolist(), yv.tolist())) curve = print_curve( params, @@ -233,11 +239,20 @@ def _curve_points( highlight_grade_delta=hg_ch, curvature=curv_ch, ) - return list(zip(plt_x.tolist(), print_curve_output(curve, x_log_exp).tolist())) + return list(zip(plt_x.tolist(), print_curve_output(curve, xv).tolist())) # Base (white) reference curve, and also the fill, pivot and zone geometry. self._curve_pts = _curve_points(slope, pivot) + # Contrast Mask: the mask shifts each pixel's print exposure by how far its own + # value sits from its blurred surroundings, so it has no single curve. A flat area, + # where the blur equals its own value, prints at val' = (1-g)*val + g*centre; fine + # detail riding on a neutral surround prints unmoved. The band spans the two. + self._mask_pts = [] + gamma = float(getattr(params, "contrast_mask", 0.0) or 0.0) + if gamma and mask_centre is not None and not flat: + self._mask_pts = _curve_points(slope, pivot, x_in=(1.0 - gamma) * x_log_exp + gamma * float(mask_centre)) + # Per-channel traces when Cast Removal or grade trims diverge the channels, or the knee # trims split toe and shoulder. Otherwise one white curve. self._channel_curves = [] @@ -376,6 +391,8 @@ def paintEvent(self, event) -> None: zx = int(self._wx(i * 0.1, w)) painter.drawLine(zx, h - 5, zx, h - 1) + self._draw_mask_band(painter, w, h) + # Pre-drag ghost curve if self._ghost_pts: ghost_path = QPainterPath() @@ -441,6 +458,32 @@ def paintEvent(self, event) -> None: self._draw_clip_indicators(painter, w, h) self._draw_scale_toggle(painter, w, h) + def _draw_mask_band(self, painter: QPainter, w: int, h: int) -> None: + """The Contrast Mask's reach: filled between the base curve and the curve a flat + area prints on, with that far edge dashed because no single tone has to land there. + """ + if not self._mask_pts or not self._curve_pts: + return + band = QPainterPath() + band.moveTo(self._wx(self._curve_pts[0][0], w), self._wy(self._curve_pts[0][1], h)) + for px, py in self._curve_pts[1:]: + band.lineTo(self._wx(px, w), self._wy(py, h)) + for px, py in reversed(self._mask_pts): + band.lineTo(self._wx(px, w), self._wy(py, h)) + band.closeSubpath() + + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(QColor(175, 140, 255, 38))) + painter.drawPath(band) + + edge = QPainterPath() + edge.moveTo(self._wx(self._mask_pts[0][0], w), self._wy(self._mask_pts[0][1], h)) + for px, py in self._mask_pts[1:]: + edge.lineTo(self._wx(px, w), self._wy(py, h)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(QColor(185, 155, 255, 165), 1, Qt.PenStyle.DashLine)) + painter.drawPath(edge) + def _draw_output_histogram(self, painter: QPainter, w: int, h: int) -> None: """Output tones, black left → white right — same direction the curve rises.""" if self._output_counts is None: diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index c1178ab1..60c5974d 100644 --- a/negpy/features/exposure/normalization.py +++ b/negpy/features/exposure/normalization.py @@ -536,10 +536,12 @@ def contrast_mask_plane( converge_v: float = 0.0, converge_h: float = 0.0, roi_norm: Optional[Tuple[float, float, float, float]] = None, -) -> np.ndarray: +) -> Tuple[np.ndarray, float]: """ The blurred low-gamma plane an unsharp mask is built from, zero-mean, on the analysis - grid. The gamma's sign picks the mask's polarity and so the direction. + grid, plus the val it was centred on. The gamma's sign picks the mask's polarity and + so the direction; the centre is the val a flat area rotates about, which the Analysis + chart needs to draw the mask's band. Takes the linear frame *before* geometry and replays it on the downsampled copy, so both engines call this on the same array. `roi_norm` is the printed frame as @@ -577,13 +579,14 @@ def contrast_mask_plane( # A frame that never metered normalizes to huge values; no stretch, no mask. if luminance_density_range(bounds) < 1e-6: - return np.zeros(image.shape[:2], dtype=np.float32) + return np.zeros(image.shape[:2], dtype=np.float32), 0.5 val = normalize_log_image(unmix_log_image(prefilter_log_grid(image, None, 0.0), unmix), bounds) lum = LUMA_R * val[:, :, 0] + LUMA_G * val[:, :, 1] + LUMA_B * val[:, :, 2] sigma = MASK_SIGMA_FRACTION * min(lum.shape[:2]) blurred = cv2.GaussianBlur(np.ascontiguousarray(lum, dtype=np.float32), (0, 0), sigma, borderType=cv2.BORDER_REPLICATE) - return blurred - float(blurred.mean()) + centre = float(blurred.mean()) + return blurred - centre, centre def normalize_log_image(img_log: ImageBuffer, bounds: LogNegativeBounds) -> ImageBuffer: diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index a07e964b..e8b9c829 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -40,7 +40,7 @@ class DarkroomEngine: def __init__(self) -> None: self.config = APP_CONFIG self.cache = PipelineCache() - self._mask_plane: Optional[Tuple[Any, np.ndarray]] = None + self._mask_plane: Optional[Tuple[Any, np.ndarray, float]] = None def _run_stage( self, @@ -171,7 +171,7 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: if self._mask_plane is None or self._mask_plane[0] != mask_key: self._mask_plane = ( mask_key, - contrast_mask_plane( + *contrast_mask_plane( img, mask_bounds, effective_crosstalk_matrix(settings.process, settings.process.process_mode), @@ -186,6 +186,7 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: ), ) context.metrics["contrast_mask_plane"] = self._mask_plane[1] + context.metrics["contrast_mask_centre"] = self._mask_plane[2] context.metrics["contrast_mask_roi"] = mask_roi def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index cc7c921d..7112a8bd 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -262,7 +262,7 @@ def __init__(self) -> None: self._uv_grid_cache: Optional[Tuple[Tuple, np.ndarray]] = None # Identity of the dodge/burn EV map currently sitting in the local_ev texture. self._local_ev_key: Optional[Tuple] = None - self._mask_plane: Optional[Tuple[Tuple, np.ndarray]] = None + self._mask_plane: Optional[Tuple[Tuple, np.ndarray, float]] = None def _detect_invalidated_stage(self, settings: WorkspaceConfig, scale_factor: float, render_size_ref: Optional[float] = None) -> int: """ @@ -616,13 +616,14 @@ def _analyze_bounds() -> LogNegativeBounds: # Same helper, same pre-geometry array as the CPU engine, so the two mask alike. # Keyed off the meter, so only the Contrast Mask slider's own value stays live. mask_plane = None + mask_centre = 0.5 mask_key = None if settings.exposure.contrast_mask != 0.0 and not tiling_mode: mask_key = (analysis_key, bounds, roi, (h_rot, w_rot)) if self._mask_plane is None or self._mask_plane[0] != mask_key: self._mask_plane = ( mask_key, - contrast_mask_plane( + *contrast_mask_plane( img, bounds, unmix_m, @@ -637,6 +638,7 @@ def _analyze_bounds() -> LogNegativeBounds: ), ) mask_plane = self._mask_plane[1] + mask_centre = self._mask_plane[2] # CPU meter cost, logged once per source (skips creative-slider re-renders). if analysis_source is not None and analysis_source_hash is not None and analysis_source_hash != self._analysis_timing_hash: @@ -1085,6 +1087,7 @@ def _analyze_bounds() -> LogNegativeBounds: "log_bounds_base": base_bounds, "norm_density_range": luminance_density_range(bounds), "metered_anchor": metered_anchor, + "contrast_mask_centre": mask_centre, "textural_range": textural_range, "scan_clip_fractions": scan_clip_fractions, # Raw cast refs so the chart can re-solve the exact render curves. diff --git a/tests/test_chart_curve_parity.py b/tests/test_chart_curve_parity.py index dfbfa116..4595c0a6 100644 --- a/tests/test_chart_curve_parity.py +++ b/tests/test_chart_curve_parity.py @@ -146,3 +146,71 @@ def test_curvature_spread_alone_triggers_divergence(qapp): assert w._channel_curves w.update_curve(config, slope=4.0, pivot=0.3, slopes=(4.0, 4.0, 4.0), pivots=(0.3, 0.3, 0.3), curvatures=(0.0, 0.0, 0.0)) assert not w._channel_curves + + +def _band_gap(widget) -> float: + """Largest vertical gap between the base curve and the mask band's far edge.""" + base = dict(widget._curve_pts) + return max(abs(base[x] - y) for x, y in widget._mask_pts) + + +def test_mask_band_is_absent_without_a_mask(qapp): + from negpy.desktop.view.widgets.charts import PhotometricCurveWidget + + w = PhotometricCurveWidget() + w.update_curve(ExposureConfig(), mask_centre=0.5) + assert w._mask_pts == [] + + +def test_mask_band_needs_the_metered_centre(qapp): + """Without the centre from the render there is nothing to rotate about, so the band + stays hidden rather than guessing one.""" + from negpy.desktop.view.widgets.charts import PhotometricCurveWidget + + w = PhotometricCurveWidget() + w.update_curve(ExposureConfig(contrast_mask=0.4), mask_centre=None) + assert w._mask_pts == [] + + +def test_mask_band_holds_the_centre_and_opens_at_the_ends(qapp): + """A flat area at the centre val prints where it always did; the band widens away + from it, which is what the (1-g) remap does.""" + from negpy.desktop.view.widgets.charts import PhotometricCurveWidget + + centre = 0.5 + w = PhotometricCurveWidget() + w.update_curve(ExposureConfig(contrast_mask=0.4), mask_centre=centre) + assert w._mask_pts + + base = dict(w._curve_pts) + # plt_x = 1 - val, so the centre val sits at plt_x = 1 - centre. + at_centre = min(w._mask_pts, key=lambda p: abs(p[0] - (1.0 - centre))) + assert abs(base[at_centre[0]] - at_centre[1]) < 5e-3 + + ends = [p for p in w._mask_pts if p[0] < 0.15 or p[0] > 0.85] + assert max(abs(base[x] - y) for x, y in ends) > 2e-2 + + +def test_mask_band_reverses_with_the_gamma_sign(qapp): + """Reduction pulls the ends toward the centre tone, increase pushes them away.""" + from negpy.desktop.view.widgets.charts import PhotometricCurveWidget + + reduce_w, increase_w = PhotometricCurveWidget(), PhotometricCurveWidget() + reduce_w.update_curve(ExposureConfig(contrast_mask=0.4), mask_centre=0.5) + increase_w.update_curve(ExposureConfig(contrast_mask=-0.4), mask_centre=0.5) + + base = dict(reduce_w._curve_pts) + # The thin end of the negative (plt_x high) prints light; reduction darkens it back + # toward the centre, increase drives it further. + x = max(p[0] for p in reduce_w._mask_pts if p[0] < 0.95) + red = next(y for px, y in reduce_w._mask_pts if px == x) + inc = next(y for px, y in increase_w._mask_pts if px == x) + assert red < base[x] < inc or inc < base[x] < red + + +def test_mask_band_is_off_for_the_flat_master(qapp): + from negpy.desktop.view.widgets.charts import PhotometricCurveWidget + + w = PhotometricCurveWidget() + w.update_curve(ExposureConfig(contrast_mask=0.4), slope=0.65, pivot=0.10, flat=True, mask_centre=0.5) + assert w._mask_pts == [] diff --git a/tests/test_contrast_mask.py b/tests/test_contrast_mask.py index 88c228bb..007c71c3 100644 --- a/tests/test_contrast_mask.py +++ b/tests/test_contrast_mask.py @@ -71,7 +71,7 @@ def test_plane_is_zero_mean(self): """A sandwich is denser and the printer opens up for it, so the plane carries only the redistribution; otherwise the slider doubles as Print Density.""" img = _wide_range_negative() - plane = contrast_mask_plane(img, LogNegativeBounds(floors=(-1.4, -1.4, -1.4), ceils=(-0.05, -0.05, -0.05)), None) + plane, _ = contrast_mask_plane(img, LogNegativeBounds(floors=(-1.4, -1.4, -1.4), ceils=(-0.05, -0.05, -0.05)), None) self.assertAlmostEqual(float(plane.mean()), 0.0, places=5) def test_surround_outside_the_crop_stays_out_of_the_mask(self): @@ -85,8 +85,8 @@ def test_surround_outside_the_crop_stays_out_of_the_mask(self): bounds = LogNegativeBounds(floors=(-1.4, -1.4, -1.4), ceils=(-0.05, -0.05, -0.05)) roi_norm = (border / h, (h - border) / h, border / w, (w - border) / w) - cropped = contrast_mask_plane(img, bounds, None, roi_norm=roi_norm) - whole = contrast_mask_plane(img, bounds, None) + cropped, _ = contrast_mask_plane(img, bounds, None, roi_norm=roi_norm) + whole, _ = contrast_mask_plane(img, bounds, None) # A uniform picture area has no low frequencies, so its mask must be flat. self.assertLess(float(cropped.std()), 0.002, f"crop-respecting plane std {cropped.std():.5f}") From 70e8d66f98c029e7d1eeae8c4458572fd4216c7a Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Mon, 17 Aug 2026 19:01:39 +0200 Subject: [PATCH 9/9] perf(tone): make the Contrast Mask slider cost a uniform write The slider sat in the GPU's EV-map key, so every tick of a drag upscaled the mask plane to render size, summed it into the dodge/burn map and re-uploaded that texture: ~20 MB per frame at 30 Hz against Print Density's single float. Give the plane its own analysis-grid texture, uploaded only when the plane moves, and let exposure.wgsl do the upscale and the scale. The CPU engine caches the expanded plane beside it, leaving its exposure stage one multiply. The shader mirrors expand_mask_plane's half-pixel bilinear with clamped taps, which is the edge replication outside the printed frame. --- docs/PIPELINE.md | 4 +- negpy/features/exposure/logic.py | 55 ++++++++++---- negpy/features/exposure/shaders/exposure.wgsl | 27 +++++++ negpy/services/rendering/engine.py | 39 +++++----- negpy/services/rendering/gpu_engine.py | 75 ++++++++++++++----- tests/test_contrast_mask.py | 58 ++++++++------ 6 files changed, 185 insertions(+), 73 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 7ba54f33..386a0b14 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -138,7 +138,9 @@ Here is what happens to your image. We apply these steps in order, passing the b $g$ is the mask's gamma and it is signed, because the mask's polarity is what sets the direction. Positive is a blurred positive, dense where the negative is thin, and scales the range by $(1-g)$; the blur holds fine detail out of the compression. Negative is a mask of the negative's own polarity, dense where it is dense, and scales the range by $(1+g)$ while fine detail stays put. Both ratios hold on the low frequencies only, which is what makes it an *unsharp* mask: detail above the blur scale passes through at unity either way. That expansion is what separates the increasing mask from a harder Grade, which multiplies every frequency alike: at a matched global gain the two move micro-contrast in opposite directions. It also has travel where Grade has none, since the straight-line slope is clamped at a floor of 2.0 and a flat enough negative sits on that floor at every ISO-R. - Not a stage: `contrast_mask_plane` builds the plane, `contrast_mask_ev` converts it to stops, and it joins the dodge/burn map the print stage already reads. Equal stops is an equal absolute density change in every channel, as a neutral panchromatic masking film gives, so there is no per-layer trim. Both engines call the same helper on the same pre-geometry array, and $\sigma$ is a fraction of the analysis grid rather than of the render, so preview, export and the two engines mask alike. The plane is zero-mean, since a real sandwich is denser and the printer opens up for it. + Not a stage: `contrast_mask_plane` builds the plane and it becomes stops of print exposure the curve reads alongside the dodge/burn map. Equal stops is an equal absolute density change in every channel, as a neutral panchromatic masking film gives, so there is no per-layer trim. Both engines call the same helper on the same pre-geometry array, and $\sigma$ is a fraction of the analysis grid rather than of the render, so preview, export and the two engines mask alike. The plane is zero-mean, since a real sandwich is denser and the printer opens up for it. + + The slider is a pure scalar on the plane (`contrast_mask_scale`), so neither engine rebuilds anything as it moves: the CPU caches the plane at render size (`expand_mask_plane`) and the GPU keeps it on the analysis grid in its own texture and upscales in the shader, leaving the slider a uniform write. The plane covers the printed frame only, because the enlarger projects the crop: a rebate or scanner surround blurred into the mask prints as a vignette the negative does not have. It is placed back at the crop, edge-replicated outside so the crop tool's full-frame preview has no seam. Hidden on the transparency transfer path, which takes no dodge/burn map. Instruments read the unmasked negative, as they already do under a dodge. * **Output**: converts print density back to **scene-linear** reflectance (transmittance): diff --git a/negpy/features/exposure/logic.py b/negpy/features/exposure/logic.py index 70be75cf..d3fb87f7 100644 --- a/negpy/features/exposure/logic.py +++ b/negpy/features/exposure/logic.py @@ -1108,43 +1108,66 @@ def local_ev_scale(bounds: Any) -> Tuple[float, float, float]: return (out[0], out[1], out[2]) -def contrast_mask_ev( +def expand_mask_plane( plane: Optional[np.ndarray], - gamma: float, - density_range: float, out_shape: Tuple[int, int], roi: Optional[Tuple[int, int, int, int]] = None, ) -> Optional[np.ndarray]: """ - The contrast mask as print-exposure stops, ready to add to the dodge/burn map. + The analysis-grid mask plane at render size. The plane covers the printed frame, so + it lands back at `roi`, edge-replicated so the uncropped preview has no seam. - The sandwich D' = D - g*blur(D) is val - g*blur in normalized space; one stop is - log10(2) of density, and equal stops is an equal density change in every channel, - as a neutral panchromatic masking film gives. Positive g is a blurred positive and - subtracts the low frequencies; negative g matches the negative's polarity and adds - them. The plane covers the printed frame, so it lands back at `roi`, edge-replicated - so the uncropped preview has no seam. + Split from `contrast_mask_ev` so a caller can cache this across slider values: only + the scalar depends on the gamma. The GPU shader mirrors the bilinear mapping. """ - if plane is None or abs(gamma) < 1e-6: + if plane is None: return None - ev = (-gamma * plane * float(density_range) / float(np.log10(2.0))).astype(np.float32) h, w = out_shape if roi is None: - if ev.shape[:2] != (h, w): - ev = cv2.resize(ev, (w, h), interpolation=cv2.INTER_LINEAR) - return np.ascontiguousarray(ev, dtype=np.float32) + if plane.shape[:2] != (h, w): + plane = cv2.resize(plane, (w, h), interpolation=cv2.INTER_LINEAR) + return np.ascontiguousarray(plane, dtype=np.float32) y1, y2, x1, x2 = roi y1, x1 = max(0, y1), max(0, x1) y2, x2 = min(h, y2), min(w, x2) if y2 - y1 < 1 or x2 - x1 < 1: return None - inner = cv2.resize(ev, (x2 - x1, y2 - y1), interpolation=cv2.INTER_LINEAR) + inner = cv2.resize(plane, (x2 - x1, y2 - y1), interpolation=cv2.INTER_LINEAR) if (y1, x1, y2, x2) == (0, 0, h, w): return np.ascontiguousarray(inner, dtype=np.float32) return np.ascontiguousarray(np.pad(inner, ((y1, h - y2), (x1, w - x2)), mode="edge"), dtype=np.float32) +def contrast_mask_scale(gamma: float, density_range: float) -> float: + """ + Stops of print exposure per unit of mask plane. + + The sandwich D' = D - g*blur(D) is val - g*blur in normalized space; one stop is + log10(2) of density, and equal stops is an equal density change in every channel, + as a neutral panchromatic masking film gives. Positive g is a blurred positive and + subtracts the low frequencies; negative g matches the negative's polarity and adds + them. + """ + return -float(gamma) * float(density_range) / float(np.log10(2.0)) + + +def contrast_mask_ev( + plane: Optional[np.ndarray], + gamma: float, + density_range: float, + out_shape: Tuple[int, int], + roi: Optional[Tuple[int, int, int, int]] = None, +) -> Optional[np.ndarray]: + """The contrast mask as print-exposure stops, ready to add to the dodge/burn map.""" + if plane is None or abs(gamma) < 1e-6: + return None + expanded = expand_mask_plane(plane, out_shape, roi) + if expanded is None: + return None + return (expanded * contrast_mask_scale(gamma, density_range)).astype(np.float32) + + def cmy_to_density(val: float, log_range: float = 1.0) -> float: """ Converts a CMY slider value (-1.0..1.0) to a physical density shift (D). diff --git a/negpy/features/exposure/shaders/exposure.wgsl b/negpy/features/exposure/shaders/exposure.wgsl index c1536f37..907995e1 100644 --- a/negpy/features/exposure/shaders/exposure.wgsl +++ b/negpy/features/exposure/shaders/exposure.wgsl @@ -51,6 +51,11 @@ struct ExposureUniforms { // Hue Trim: x = rotation in radians, yzw pad. Costs no slot; 288B already // spanned two. hue: vec4, + // Contrast Mask: x = stops per unit of plane (contrast_mask_scale; 0 gates + // mask_tex off), yz = the printed frame's origin in rotated pixels, w pad. + mask: vec4, + // Contrast Mask: xy = the printed frame's span in rotated pixels, zw pad. + mask_span: vec4, }; @group(0) @binding(0) var input_tex: texture_2d; @@ -58,6 +63,25 @@ struct ExposureUniforms { @group(0) @binding(2) var params: ExposureUniforms; // Per-pixel dodge/burn EV map, rasterised on the CPU (shared with the CPU path). @group(0) @binding(3) var ev_tex: texture_2d; +// Contrast Mask plane on the analysis grid. Upscaled here rather than uploaded at +// render size, so the slider costs a uniform write and no transfer. +@group(0) @binding(4) var mask_tex: texture_2d; + +// The mask plane at this pixel, in stops. Mirrors expand_mask_plane in +// exposure/logic.py: OpenCV's half-pixel bilinear, taps clamped, which is the +// edge replication outside the printed frame. +fn contrast_mask_stops(coords: vec2) -> f32 { + let dims = vec2(textureDimensions(mask_tex)); + let p = (vec2(coords) + vec2(0.5) - params.mask.yz) * dims / params.mask_span.xy - vec2(0.5); + let lo = clamp(floor(p), vec2(0.0), dims - vec2(1.0)); + let hi = clamp(lo + vec2(1.0), vec2(0.0), dims - vec2(1.0)); + let f = clamp(p - lo, vec2(0.0), vec2(1.0)); + let i0 = vec2(lo); + let i1 = vec2(hi); + let top = mix(textureLoad(mask_tex, vec2(i0.x, i0.y), 0).r, textureLoad(mask_tex, vec2(i1.x, i0.y), 0).r, f.x); + let bot = mix(textureLoad(mask_tex, vec2(i0.x, i1.y), 0).r, textureLoad(mask_tex, vec2(i1.x, i1.y), 0).r, f.x); + return params.mask.x * mix(top, bot, f.y); +} fn fast_sigmoid(x: f32) -> f32 { if (x >= 0.0) { @@ -185,6 +209,9 @@ fn main(@builtin(global_invocation_id) gid: vec3) { ev = local_maps.r; gfac = local_maps.g; } + if (params.mask.x != 0.0) { + ev = ev + contrast_mask_stops(coords); + } var dens: vec3; diff --git a/negpy/services/rendering/engine.py b/negpy/services/rendering/engine.py index e8b9c829..a0d12d7b 100644 --- a/negpy/services/rendering/engine.py +++ b/negpy/services/rendering/engine.py @@ -17,6 +17,7 @@ NormalizationProcessor, PhotometricProcessor, ) +from negpy.features.exposure.logic import expand_mask_plane from negpy.features.exposure.normalization import contrast_mask_plane, effective_crosstalk_matrix, normalized_roi from negpy.features.process.hue import apply_hue_trim from negpy.features.exposure.papers import effective_paper_profile @@ -169,25 +170,27 @@ def run_base(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: mask_roi = context.active_roi mask_key = (calculate_config_hash(base_key), mask_roi, current_img.shape[:2]) if self._mask_plane is None or self._mask_plane[0] != mask_key: - self._mask_plane = ( - mask_key, - *contrast_mask_plane( - img, - mask_bounds, - effective_crosstalk_matrix(settings.process, settings.process.process_mode), - rotation=settings.geometry.rotation, - fine_rotation=settings.geometry.fine_rotation, - flip_horizontal=settings.geometry.flip_horizontal, - flip_vertical=settings.geometry.flip_vertical, - converge_v=settings.geometry.converge_v, - converge_h=settings.geometry.converge_h, - distortion_k1=distortion_k1, - roi_norm=normalized_roi(mask_roi, current_img.shape[:2]), - ), + plane, centre = contrast_mask_plane( + img, + mask_bounds, + effective_crosstalk_matrix(settings.process, settings.process.process_mode), + rotation=settings.geometry.rotation, + fine_rotation=settings.geometry.fine_rotation, + flip_horizontal=settings.geometry.flip_horizontal, + flip_vertical=settings.geometry.flip_vertical, + converge_v=settings.geometry.converge_v, + converge_h=settings.geometry.converge_h, + distortion_k1=distortion_k1, + roi_norm=normalized_roi(mask_roi, current_img.shape[:2]), ) - context.metrics["contrast_mask_plane"] = self._mask_plane[1] - context.metrics["contrast_mask_centre"] = self._mask_plane[2] - context.metrics["contrast_mask_roi"] = mask_roi + # Expanded here, not in the exposure stage: the slider re-runs that stage, + # and only the scalar moves with it. + expanded = expand_mask_plane(plane, current_img.shape[:2], mask_roi) + self._mask_plane = None if expanded is None else (mask_key, expanded, centre) + if self._mask_plane is not None: + context.metrics["contrast_mask_plane"] = self._mask_plane[1] + context.metrics["contrast_mask_centre"] = self._mask_plane[2] + context.metrics["contrast_mask_roi"] = None def run_exposure(img_in: ImageBuffer, ctx: PipelineContext) -> ImageBuffer: img_out = PhotometricProcessor(settings.exposure, settings.local, settings.process).process(img_in, ctx) diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 7112a8bd..f83cedaa 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -215,11 +215,11 @@ def __init__(self) -> None: "density_hist", ] # Packed byte size per stage. A stage that exceeds the 256B dynamic-offset - # alignment (exposure, 304B) occupies multiple aligned slots. + # alignment (exposure, 336B) occupies multiple aligned slots. self._uniform_sizes = { "geometry": 64, "normalization": 160, - "exposure": 304, + "exposure": 336, "transfer": 144, "clahe_u": 32, "lab": 96, @@ -263,6 +263,8 @@ def __init__(self) -> None: # Identity of the dodge/burn EV map currently sitting in the local_ev texture. self._local_ev_key: Optional[Tuple] = None self._mask_plane: Optional[Tuple[Tuple, np.ndarray, float]] = None + # Identity of the plane currently sitting in the contrast_mask texture. + self._mask_tex_key: Optional[Tuple] = None def _detect_invalidated_stage(self, settings: WorkspaceConfig, scale_factor: float, render_size_ref: Optional[float] = None) -> int: """ @@ -640,6 +642,26 @@ def _analyze_bounds() -> LogNegativeBounds: mask_plane = self._mask_plane[1] mask_centre = self._mask_plane[2] + # The printed frame in rotated pixels; the shader maps the plane onto it and + # clamps outside, which is expand_mask_plane's edge padding. + mask_uniform = None + if mask_plane is not None: + y1_m, y2_m, x1_m, x2_m = roi if roi is not None else (0, h_rot, 0, w_rot) + y1_m, x1_m = max(0, y1_m), max(0, x1_m) + y2_m, x2_m = min(h_rot, y2_m), min(w_rot, x2_m) + if y2_m - y1_m < 1 or x2_m - x1_m < 1: + mask_plane = None + else: + from negpy.features.exposure.logic import contrast_mask_scale + + mask_uniform = ( + contrast_mask_scale(settings.exposure.contrast_mask, luminance_density_range(bounds)), + float(x1_m), + float(y1_m), + float(x2_m - x1_m), + float(y2_m - y1_m), + ) + # CPU meter cost, logged once per source (skips creative-slider re-renders). if analysis_source is not None and analysis_source_hash is not None and analysis_source_hash != self._analysis_timing_hash: self._analysis_timing_hash = analysis_source_hash @@ -673,6 +695,7 @@ def _analyze_bounds() -> LogNegativeBounds: unmix=unmix_m, cam_xyz=cam_xyz, camera_wb=camera_wb, + contrast_mask=mask_uniform, ) if clahe_cdf_override is not None: self._buffers["clahe_c"].upload(clahe_cdf_override) @@ -708,9 +731,29 @@ def _analyze_bounds() -> LogNegativeBounds: wgpu.TextureUsage.STORAGE_BINDING | wgpu.TextureUsage.TEXTURE_BINDING, "lab", ) + # The Contrast Mask plane rides its own analysis-grid texture, uploaded only when the + # plane itself moves. A 1x1 dummy keeps the bind group valid when it is off (mask.x + # gates it). + if mask_plane is not None: + tex_mask = self._get_intermediate_texture( + mask_plane.shape[1], + mask_plane.shape[0], + wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST, + "contrast_mask", + ) + if self._mask_tex_key != mask_key: + tex_mask.upload(np.dstack([mask_plane] * 3)) + self._mask_tex_key = mask_key + else: + tex_mask = self._get_intermediate_texture( + 1, + 1, + wgpu.TextureUsage.TEXTURE_BINDING | wgpu.TextureUsage.COPY_DST, + "contrast_mask", + ) # The dodge/burn EV map feeds the exposure pass. A zero-initialized 1x1 dummy keeps # the bind group valid when no masks are active (ev_scale.w gates it). - wants_ev_map = bool(settings.local.masks) or mask_plane is not None + wants_ev_map = bool(settings.local.masks) if wants_ev_map: tex_local_ev = self._get_intermediate_texture( w_rot, @@ -761,7 +804,7 @@ def _analyze_bounds() -> LogNegativeBounds: ) if wants_ev_map: # This stage re-runs for any exposure change, but the map only moves - # with the masks, the geometry, the grade and the contrast mask. + # with the masks, the geometry and the grade. tiled_maps = local_maps is not None ev_key = ( settings.local, @@ -771,14 +814,12 @@ def _analyze_bounds() -> LogNegativeBounds: settings.geometry.flip_vertical, k1_eff, settings.exposure.grade, - settings.exposure.contrast_mask, - mask_key, orig_shape, w_rot, h_rot, ) if tiled_maps or self._local_ev_key != ev_key: - if local_maps is None and settings.local.masks: + if local_maps is None: local_maps = compute_local_maps( settings.local, h_rot, @@ -790,17 +831,11 @@ def _analyze_bounds() -> LogNegativeBounds: flip_vertical=settings.geometry.flip_vertical, distortion_k1=k1_eff, ) - from negpy.features.exposure.logic import contrast_mask_ev, local_grade_factor_map + from negpy.features.exposure.logic import local_grade_factor_map if local_maps is None: local_maps = np.zeros((h_rot, w_rot, 2), dtype=np.float32) ev_plane = local_maps[:, :, 0] - mask_ev = contrast_mask_ev( - mask_plane, settings.exposure.contrast_mask, luminance_density_range(bounds), (h_rot, w_rot), roi - ) - if mask_ev is not None: - ev_plane = ev_plane + mask_ev - # r = dodge/burn EV, g = local grade slope factor, b unused. One texture, # so the local-grade map costs no bind slot. tex_local_ev.upload( @@ -837,6 +872,7 @@ def _analyze_bounds() -> LogNegativeBounds: (1, tex_expo.view), (2, self._get_uniform_binding("exposure")), (3, tex_local_ev.view), + (4, tex_mask.view), ], w_rot, h_rot, @@ -1159,6 +1195,7 @@ def _upload_unified_uniforms( unmix: Optional[np.ndarray] = None, cam_xyz: Optional[list] = None, camera_wb: Optional[list] = None, + contrast_mask: Optional[Tuple[float, float, float, float, float]] = None, ) -> None: """Packs and uploads all pipeline parameters to the unified UBO.""" # scale_s uses the post-rotation dims the geometry pass emits. Zeroed for tiled @@ -1439,12 +1476,11 @@ def _upload_unified_uniforms( + struct.pack("ffff", dye_rows[0, 0], dye_rows[0, 1], dye_rows[0, 2], _mg3[0]) + struct.pack("ffff", dye_rows[1, 0], dye_rows[1, 1], dye_rows[1, 2], _mg3[1]) + struct.pack("ffff", dye_rows[2, 0], dye_rows[2, 1], dye_rows[2, 2], _mg3[2]) - # Dodge/burn EV-stop size per channel (local_ev_scale); w = enable flag. The - # contrast mask rides the same map, so it opens the same gate. + # Dodge/burn EV-stop size per channel (local_ev_scale); w = enable flag. + struct.pack( "ffff", *local_ev_scale(LogNegativeBounds(adj_floors, adj_ceils)), - 1.0 if (settings.local.masks or exp.contrast_mask != 0.0) else 0.0, + 1.0 if settings.local.masks else 0.0, ) # Split Grade per-channel zone contrast gains (split_grade_deltas). The w-lanes # carry Separation Damping's green and blue k. @@ -1452,6 +1488,10 @@ def _upload_unified_uniforms( + struct.pack("ffff", _hg3[0], _hg3[1], _hg3[2], sat_k3[2]) # Hue Trim in radians (x; yzw pad). The shader rotates before its encode. + struct.pack("ffff", math.radians(float(settings.process.hue_trim)), 0.0, 0.0, 0.0) + # Contrast Mask: stops per unit of plane (0 = off), then the printed frame's + # origin and span in rotated pixels. The shader does the upscale. + + struct.pack("ffff", *(contrast_mask[:3] if contrast_mask else (0.0, 0.0, 0.0)), 0.0) + + struct.pack("ffff", *(contrast_mask[3:] if contrast_mask else (1.0, 1.0)), 0.0, 0.0) ) cls = float(settings.lab.clahe_strength) @@ -2108,6 +2148,7 @@ def cleanup(self, collect: bool = True, retain: Optional[GPUTexture] = None) -> self._current_source_hash = None self._last_settings = None self._local_ev_key = None + self._mask_tex_key = None if collect: gc.collect() logger.info("GPUEngine: VRAM resources released") diff --git a/tests/test_contrast_mask.py b/tests/test_contrast_mask.py index 007c71c3..733e3d8c 100644 --- a/tests/test_contrast_mask.py +++ b/tests/test_contrast_mask.py @@ -196,34 +196,50 @@ def drift(**kw): @unittest.skipUnless(GPUDevice.get().is_available, "GPU not available") class TestContrastMaskParity(unittest.TestCase): - def test_cpu_gpu_match(self): + def setUp(self): from negpy.services.rendering.image_processor import ImageProcessor - processor = ImageProcessor() - if processor.engine_gpu is None: + self.processor = ImageProcessor() + if self.processor.engine_gpu is None: self.skipTest("GPU engine not initialised") - - img = _wide_range_negative(96, 144) - settings = _bw_settings(grade=60.0, contrast_mask=0.5) - - def render(prefer_gpu: bool) -> np.ndarray: - result, _ = processor.run_pipeline( - img.copy(), - settings, - "contrast-mask-parity", - render_size_ref=float(max(img.shape[:2])), - prefer_gpu=prefer_gpu, - readback_metrics=False, - ) - arr = np.asarray(result.readback()) if hasattr(result, "readback") else np.asarray(result) - return arr[:, :, :3].astype(np.float64) - - cpu = render(False) - gpu = render(True) + self.img = _wide_range_negative(96, 144) + + def _render(self, settings: WorkspaceConfig, tag: str, prefer_gpu: bool) -> np.ndarray: + result, _ = self.processor.run_pipeline( + self.img.copy(), + settings, + tag, + render_size_ref=float(max(self.img.shape[:2])), + prefer_gpu=prefer_gpu, + readback_metrics=False, + ) + arr = np.asarray(result.readback()) if hasattr(result, "readback") else np.asarray(result) + return arr[:, :, :3].astype(np.float64) + + def _assert_match(self, settings: WorkspaceConfig, tag: str): + cpu = self._render(settings, tag, prefer_gpu=False) + gpu = self._render(settings, tag, prefer_gpu=True) self.assertEqual(cpu.shape, gpu.shape) self.assertLess(float(np.mean(np.abs(cpu - gpu))), 0.01) self.assertLess(float(np.max(np.abs(cpu - gpu))), 0.04) + def test_cpu_gpu_match(self): + self._assert_match(_bw_settings(grade=60.0, contrast_mask=0.5), "contrast-mask-parity") + + def test_cpu_gpu_match_negative_gamma(self): + self._assert_match(_bw_settings(grade=60.0, contrast_mask=-0.35), "contrast-mask-parity-neg") + + def test_slider_uploads_no_texture(self): + base = _bw_settings(grade=60.0, contrast_mask=0.5) + self._render(base, "contrast-mask-drag", prefer_gpu=True) + engine = self.processor.engine_gpu + mask_key, ev_key = engine._mask_tex_key, engine._local_ev_key + self.assertIsNotNone(mask_key) + + self._render(replace(base, exposure=replace(base.exposure, contrast_mask=0.2)), "contrast-mask-drag", prefer_gpu=True) + self.assertEqual(engine._mask_tex_key, mask_key) + self.assertEqual(engine._local_ev_key, ev_key) + if __name__ == "__main__": unittest.main()