diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 1b1adcee4..386a0b142 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. +* **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; 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. @@ -131,6 +134,15 @@ 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 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'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 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): $$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 e32922bf4..3964e8f0f 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 @@ -435,6 +437,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 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 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 @@ -489,6 +495,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 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 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 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. + + 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/settings_catalog.py b/negpy/desktop/settings_catalog.py index 44eefe787..c688b1eb8 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("Easel Tilt", "geometry", "converge_v"), + _row("Easel Swing", "geometry", "converge_h"), _row("Flip Horizontal", "geometry", "flip_horizontal"), _row("Flip Vertical", "geometry", "flip_vertical"), )), @@ -124,6 +126,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 39eddaae5..850c3cf2e 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("", "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"), @@ -88,6 +92,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/geometry.py b/negpy/desktop/view/sidebar/geometry.py index 55913535f..ccd51c57d 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -170,6 +170,24 @@ def _init_ui(self) -> None: align_row.addWidget(self.straighten_btn, 0) self.layout.addLayout(align_row) + 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 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, 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) + 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 +230,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 +295,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 +317,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/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index 6be7a2272..4b7b6db6f 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/sidebar/tone.py b/negpy/desktop/view/sidebar/tone.py index 110e42aad..8a198bc10 100644 --- a/negpy/desktop/view/sidebar/tone.py +++ b/negpy/desktop/view/sidebar/tone.py @@ -137,6 +137,20 @@ def _init_ui(self) -> None: split_grade_row.addWidget(self.highlight_grade_slider) self.layout.addLayout(split_grade_row) + # 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 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) + 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 +241,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 +333,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 +436,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 +500,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 +536,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 bd1374a78..8339bc90a 100644 --- a/negpy/desktop/view/slider_shortcut_groups.py +++ b/negpy/desktop/view/slider_shortcut_groups.py @@ -86,8 +86,11 @@ 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("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), diff --git a/negpy/desktop/view/slider_targets.py b/negpy/desktop/view/slider_targets.py index 1bdeac07e..c9e8623ba 100644 --- a/negpy/desktop/view/slider_targets.py +++ b/negpy/desktop/view/slider_targets.py @@ -26,8 +26,11 @@ "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", + "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/desktop/view/widgets/charts.py b/negpy/desktop/view/widgets/charts.py index f827f662a..957da0ee4 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/logic.py b/negpy/features/exposure/logic.py index 306e57c18..d3fb87f79 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,66 @@ def local_ev_scale(bounds: Any) -> Tuple[float, float, float]: return (out[0], out[1], out[2]) +def expand_mask_plane( + plane: Optional[np.ndarray], + out_shape: Tuple[int, int], + roi: Optional[Tuple[int, int, int, int]] = None, +) -> Optional[np.ndarray]: + """ + 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. + + 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: + return None + h, w = out_shape + if roi is None: + 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(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/models.py b/negpy/features/exposure/models.py index fb94d37dc..a29b8f06e 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 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. shadow_grade: float = 0.0 diff --git a/negpy/features/exposure/normalization.py b/negpy/features/exposure/normalization.py index 3b4cbe062..60c5974d4 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,10 @@ # Above this size the block-median is threaded over row strips (np.median frees the GIL). _BLOCK_MEDIAN_PARALLEL_MIN_PIXELS = 2_000_000 +# 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 + @njit(cache=True, fastmath=True) def _normalize_log_image_jit(img_log: np.ndarray, floors: np.ndarray, ceils: np.ndarray) -> np.ndarray: @@ -507,6 +512,83 @@ 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]]: + """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 + 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, + converge_v: float = 0.0, + converge_h: float = 0.0, + roi_norm: Optional[Tuple[float, float, float, float]] = None, +) -> Tuple[np.ndarray, float]: + """ + The blurred low-gamma plane an unsharp mask is built from, zero-mean, on the analysis + 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 + (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 + + 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) + image = apply_keystone(image, converge_v, converge_h) + + 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]) + + # 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), 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) + centre = float(blurred.mean()) + return blurred - centre, centre + + 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 ee2801e7d..82e961228 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, @@ -262,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: @@ -339,6 +342,16 @@ 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 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, + 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/features/exposure/shaders/exposure.wgsl b/negpy/features/exposure/shaders/exposure.wgsl index c1536f37a..907995e1d 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/features/geometry/logic.py b/negpy/features/geometry/logic.py index 75bdec089..49efe6cef 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1738,6 +1738,79 @@ def map_point_radial(px: float, py: float, k1: float, w: int, h: int) -> Tuple[f return cx + ix * scale, cy + iy * scale +# 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 the GPU's sampling convention, + u = (index + 0.5) / dims. + + 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]]) + 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, 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] + 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), 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)) + return m / m[2, 2] + + def apply_margin_to_roi( roi: ROI, h: int, @@ -2008,6 +2081,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 +2121,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 +2153,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 +2190,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 78c8e0aa0..171a808c0 100644 --- a/negpy/features/geometry/models.py +++ b/negpy/features/geometry/models.py @@ -106,6 +106,10 @@ class GeometryConfig: fine_rotation: float = 0.0 flip_horizontal: bool = False flip_vertical: bool = False + # 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 # 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 ed99d58b1..2105ca37d 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 eaeae7d47..82a06c503 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 f4692f189..80868ca8a 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 afad4947a..a0d12d7b7 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,8 @@ 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 from negpy.features.cyanotype.processor import CyanotypeProcessor @@ -36,6 +41,7 @@ class DarkroomEngine: def __init__(self) -> None: self.config = APP_CONFIG self.cache = PipelineCache() + self._mask_plane: Optional[Tuple[Any, np.ndarray, float]] = None def _run_stage( self, @@ -156,6 +162,36 @@ 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: + 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]), + ) + # 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) # Rides this stage: it needs the print, and its own stage would re-run everything behind @@ -227,6 +263,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 7721c0f52..f83cedaa4 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, @@ -32,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, @@ -105,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 @@ -204,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": 32, + "geometry": 64, "normalization": 160, - "exposure": 304, + "exposure": 336, "transfer": 144, "clahe_u": 32, "lab": 96, @@ -251,6 +262,9 @@ 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, 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: """ @@ -543,6 +557,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). @@ -597,6 +615,53 @@ 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_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( + 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, + 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)), + ), + ) + 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 @@ -630,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) @@ -665,9 +731,30 @@ 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). - if settings.local.masks: + wants_ev_map = bool(settings.local.masks) + if wants_ev_map: tex_local_ev = self._get_intermediate_texture( w_rot, h_rot, @@ -715,7 +802,7 @@ 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. tiled_maps = local_maps is not None @@ -746,14 +833,17 @@ def _analyze_bounds() -> LogNegativeBounds: ) 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] # 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), ] ) ) @@ -782,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, @@ -1032,6 +1123,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. @@ -1067,6 +1159,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 @@ -1101,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 @@ -1115,8 +1210,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 @@ -1379,13 +1477,21 @@ def _upload_unified_uniforms( + 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) + + struct.pack( + "ffff", + *local_ev_scale(LogNegativeBounds(adj_floors, adj_ceils)), + 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. + struct.pack("ffff", _sg3[0], _sg3[1], _sg3[2], sat_k3[1]) + 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) @@ -2042,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/negpy/services/view/coordinate_mapping.py b/negpy/services/view/coordinate_mapping.py index a44fbb86a..018f5f091 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,52 @@ 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 - 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 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 +146,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_chart_curve_parity.py b/tests/test_chart_curve_parity.py index dfbfa1162..4595c0a63 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 new file mode 100644 index 000000000..733e3d8c5 --- /dev/null +++ b/tests/test_contrast_mask.py @@ -0,0 +1,245 @@ +"""The unsharp contrast-reduction mask. + +Pins what would break silently: the sign, resolution invariance, the crop boundary 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 _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( + 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 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): + """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 + 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: 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 + 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)) + 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 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") + + 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_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") + 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.""" + 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 setUp(self): + from negpy.services.rendering.image_processor import ImageProcessor + + self.processor = ImageProcessor() + if self.processor.engine_gpu is None: + self.skipTest("GPU engine not initialised") + 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() diff --git a/tests/test_keystone.py b/tests/test_keystone.py new file mode 100644 index 000000000..666dbbdfd --- /dev/null +++ b/tests/test_keystone.py @@ -0,0 +1,154 @@ +"""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 shader's inverse, the meters, the point mapper behind dodge/burn masks, 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): + """The GPU derives its 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 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) + 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, 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) + 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.""" + 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 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) + 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() diff --git a/tests/test_updater.py b/tests/test_updater.py index a5ed958f7..952e63fcc 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é")