Skip to content
Merged
12 changes: 12 additions & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

<!-- panel:flatfield -->
### 5.2 Flat Field: even out the light
Expand Down Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions negpy/desktop/settings_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)),
Expand Down Expand Up @@ -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"),
Expand Down
6 changes: 6 additions & 0 deletions negpy/desktop/view/shortcut_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
30 changes: 30 additions & 0 deletions negpy/desktop/view/sidebar/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion negpy/desktop/view/sidebar/right_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading