The Pipeline
June 13, 2026 · View on GitHub
Here is what actually happens to your image. We apply these steps in order, passing the buffer from one stage to the next.
1. Geometry (Straighten & Crop)
Code: negpy.features.geometry
- Rotation: We spin the image array (90° steps) and fine-tune with affine transformations. We use bilinear interpolation so it stays sharp.
- Autocrop: I try to detect where the film ends and the scanner bed begins by looking for the density jump. It's not perfect (light leaks or weird scanning holders can fool it), so there's a manual override.
Note: Cropping happens early because the normalization step needs to know what is "image" and what is "border" to calculate the black/white points correctly. Instead of cropping you can also use the "Analysis buffer" option to exclude outer X% of the image from the analysis. This is useful when you have a border around the film.
2. Scan Normalization
Code: negpy.features.exposure.normalization
-
Physical Model: We treat the input as a radiometric measurement. Pixel values represent linear transmittance captured by the sensor.
-
Log Conversion: Film density is logarithmic (). We convert the raw signal to log-space to align with the physics of the film layers:
-
Bounding & Polarity: The engine uses statistical percentiles to detect the usable signal range. To maintain a unified pipeline, we always map the target White Point to the Floor ($0.0) and the **Black Point** to the **Ceiling** (\1.0$).
- Negative (C-41/B&W): Raw low-signal (Film Base) maps to Floor ($0.0). Raw high-signal (Highlights) maps to Ceiling (\1.0$). Range: 0.5% to 99.5%.
- Positive (E-6): Raw high-signal (Highlights) maps to Floor ($0.0). Raw low-signal (Shadows) maps to Ceiling (\1.0$). Range: 99.9% to 0.01%.
The bounds are customizable via two controls:
- D-Range Clip: Tunes how aggressively the percentile window is set. Positive values symmetrically tighten the window before bounds detection — useful for very dense or fogged negatives where a few outlier pixels would otherwise pull the white or black point to an extreme. Zero uses robust extremes (a block-median prefilter rejects dust and speculars, and a small base clip excludes tiny outlier populations). Negative values push the bounds outward beyond the extremes, leaving lifted blacks and unclipped highlights as headroom.
- White & Black Point Offsets: Fine-tunes the detected bounds after statistical analysis. Shifting the White Point floor or Black Point ceiling enables precise highlight recovery or shadow crushing without re-running the analysis.
-
Stretch: All modes use independent channel bounding. This neutralizes the orange mask in negatives and base tints/fading in reversal film by stretching each channel to the full range. The result is not clamped: tones outside the detected bounds are kept and rolled off later by the soft toe/shoulder of the print curve, rather than being truncated here.
-
Per-frame metering: Normalization also measures a few statistics used later by the Print stage's automatic helpers — per-channel shadow references (, for Cast Removal) and a per-frame exposure anchor ( luminance) and textural range (, for Auto Density / Auto Grade). See §3.
3. The Print (Exposure)
Code: negpy.features.exposure
- Virtual Darkroom: Simulates shining light through the normalized log-signal onto paper.
- Color Timing: Applies subtractive filtration (CMY) in log-space. This mimics a dichroic head on an enlarger. Adjustments can be targeted to Global, Shadows, or Highlights regions; the shadow/highlight offsets are weighted by the toe/shoulder tonal masks (below).
- The H&D Curve: Models paper response using a Richards curve (generalized logistic). The plain sigmoid is raised to a power , which gives an asymmetric, paper-like toe while keeping a smooth shoulder. The curve lives in density space:
- : Paper white (the base isn't pure black). Toggle with Paper White Base (
paper_dmin); off uses . - : The curve's virtual asymptote. The actual deepest black is the physical D-max , enforced by a soft-saturation shoulder so density rolls into the floor instead of clipping.
- : Paper-toe sharpness.
- : Per-channel slope (contrast), derived from Grade.
- : Adjusted input log-exposure (after toe, shoulder, and CMY offsets).
- : Paper white (the base isn't pure black). Toggle with Paper White Base (
- Grade (ISO-R): Contrast is set as an ISO range (R) value, default 115, range 50–180 (R110 ≈ classic paper grade 2; higher R = softer). Edits saved under the old 0–5 paper-grade scale are auto-migrated via .
- Toe & Shoulder: Two independent levers, both anchored so the rest of the tone scale stays put (slider values are scaled by $0.85$ internally):
- Shoulder — an integrated-sigmoid term on the input axis that modulates highlight local contrast, leaving the pivot tone invariant.
- Toe — a density-domain shadow lever that begins at an onset density of $1.2$, with its tangent removed so highlights remain invariant at any width. Lifts blacks or deepens shadows without touching the upper scale.
- Output: Converts print density back to light (Transmittance), then encodes:
- Note: The sRGB transfer (display gamma) is applied as the final encode.
Automatic helpers
The defaults are tuned to look right straight out of the box; these helpers do per-frame work so you don't have to. All correct partially — they nudge toward a good result while preserving the photograph's intent. Turn them off to let the conversion follow the negative honestly (a dense negative prints dense, a flat one prints flat).
- Auto Density (
auto_exposure, on): Meters each frame's median tone and sets a sensible brightness. The exposure anchor is a partial move from an assumed key toward the measured median: The $0.25\pm 0.12$ band) means a deliberately low-key or high-key shot keeps its mood instead of being flattened to neutral grey. - Auto Grade (
auto_normalize_contrast, on): Chooses contrast to suit each scene from the textural density range (). Letting be the ratio of the full bounded range to the textural range, the effective contrast target is: \0.6 \cdot \big(2.0 + 0.4 \cdot (r - 2.0)\big)$$ The $0.4$ adaptation strength dampens contrast swings gently — a flat scene gets a small lift, a punchy scene stays punchy, nothing is pushed to a harsh extreme. - Cast Removal (
cast_removal, on): Neutralizes the colour cast a negative leaves in the print, balancing each layer so greys read neutral from deep shadows through highlights — not just at the midtone (the usual cause of shadows/highlights drifting off-colour after a C-41 midtone white balance). Using the per-channel shadow refs (), each non-green channel gets its own slope so its shadow ref lines up with green's, while the pivot (midtone) stays neutral: The per-channel cast is bounded () so the tilt can't run away. - Contrast Lift / Surround (
surround, off): A gentle Bartleson–Breneman dim-surround correction. Prints viewed in a normal room want slightly more midtone contrast than a 1:1 reproduction, so this expands contrast about paper white: - Flare (
flare, off): A darkroom-style veiling-glare floor that lifts the deepest blacks and softens the toe for a more film-like look, while leaving paper white fixed. Applied in linear reflectance:
With the helpers off, the conversion shows you your photography — exactly how the frame was exposed and developed. The defaults should be neutral, but you can (and should) use the sliders to match the curve shape (your "print") to your liking.
4. Retouching
Code: negpy.features.retouch
This stage removes physical artifacts like dust, hairs, and scratches from the negative. We use two complementary approaches:
-
Automatic Dust Removal: A resolution-invariant impulse detector and patching engine.
- Statistical Gating: Uses dual-radius analysis. A local window ($3\times scaled) identifies luminance spikes, while a wide window (\4\timesw_std^3$) aggressively raises detection thresholds in high-frequency regions (foliage, rocks) to minimize false positives.
- Peak Integrity: Validates candidates via a strict 3x3 Local Maximum check and a sigma outlier gate. A strong-signal bypass ensures saturation-limited artifacts (hairs/scratches) are captured even if they form plateaus.
- Annular Sampling (SPS): Background data is reconstructed via Stochastic Perimeter Sampling. Samples are fetched from a ring strictly exterior to the artifact footprint, ensuring zero contamination from the dust luminance itself.
- Soft Patching: Healed regions are integrated using distance-weighted alpha blending with cubic falloff and procedural grain injection to match local noise characteristics.
-
Manual Healing (Stochastic Boundary Sampling - SBS): When you use the Heal tool, we fill the brush area using information from its own perimeter.
- Perimeter Characterization: The tool identifies the cleanest background luminance at the edge of the brush circle. This sets a "Perimeter-Safe" floor to prevent dark artifacts in bright areas like skies.
- Stochastic Sampling: For every pixel inside the brush, we sample the immediate boundary with small angular jitter:
- : Perimeter point at pixel's angle with random jitter .
- This reconstructs the natural grain and texture of the surrounding area without using "synthetic" noise.
- Luminance Keying: To preserve original details and grain within the brush, we only apply the patch to pixels that are significantly brighter than the reconstructed background:
- Cumulative Patching: Patches can be overlaid and stacked. The tool intelligently heals long hairs or scratches by basing each new patch on the current accumulated state.
-
Resolution Independence: Retouching coordinates and sizes are scaled relative to the full-resolution RAW data, ensuring that edits made on the preview translate perfectly to the high-resolution export.
5. Lab Scanner Mode
Code: negpy.features.lab
This mimics what lab scanners like Frontier or Noritsu do automatically. For maximum signal quality, the steps are applied in the following sequence:
-
Chroma Denoise: Applies a Gaussian filter to the A and B channels in LAB space. This reduces color noise and digital "chroma speckle" while leaving the L-channel (and its film grain) completely untouched.
-
Crosstalk: We use a mixing matrix (in density space) to push colors apart. It blends between a neutral identity matrix and a "calibration" matrix based on how much pop you want.
- : Identity matrix (neutral).
- : Calibration matrix.
- : Crosstalk strength.
A profile dropdown selects the calibration matrix . The built-in matrix is always available as Default; you can also drop your own
.tomlcalibration matrices into theNegPy/crosstalkfolder (seeded with a starter example on first run) and pick one per film stock or scanner. SeeCROSSTALK.mdfor the file format and how to contribute matrices to the bundled gallery. -
Vibrance: Selectively boosts the saturation of muted colors using a chroma mask. The mask is strongest at zero chroma and fades to zero for already vibrant colors, preventing over-saturation of sensitive areas like skin tones.
-
Global Saturation: A linear boost applied to all colors via the HSV saturation channel.
-
CLAHE: Adaptive histogram equalization. It boosts local contrast in the luminance channel.
- : Luminance channel.
- : Blending strength.
-
Sharpening: We sharpen just the Lightness channel () in LAB space using Unsharp Masking (USM). We apply a threshold to avoid amplifying noise.
- : Blur radius (scale factor).
- $2.5$: Hardcoded USM boosting factor.
- $2.0$: Noise threshold.
-
Glow: Simulates lens bloom by blurring highlights and compositing them back using screen blending.
- : Luminance-based highlight mask, quadratically ramped from 50% to 100%.
- Applied equally to all three channels.
-
Halation: Simulates the red scatter caused by light reflecting back through the film base. Uses a larger-radius Gaussian than Glow and a strongly red-biased highlight source.
- : Red channel used as the scatter source.
- : Per-channel tint weights for red-dominant scatter.
6. Toning
Code: negpy.features.toning
-
Chemical Toning (B&W mode only): We simulate toner by blending the original pixel with a tinted version based on luminance (, Rec. 709) masks.
-
Selenium: Targets the shadows (inverse squared luminance).
- : Pixel Luminance.
- : Selenium strength.
- : Selenium target color (cool purple).
-
Sepia: Targets the midtones using a Gaussian bell curve centered at $0.6$ luminance.
- : Sepia strength.
- : Sepia target color (warm gold).
-
-
Chromaticity-Preserving Black Point (B&W mode only): After chemical toning, the black point is re-seated about the $0.05$ luminance percentile, preserving the toner's hue:
- : $0.05$-percentile luminance.
-
Split Toning (all modes): Additive tint in LAB () space, so luminance — and therefore grain and detail — is preserved. Shadows and highlights are pushed toward independent hue angles. With the CIELAB lightness ($0–\100): $$m_{shadow} = \text{clip}(1 - L/50,\ 0,\ 1), \qquad m_{highlight} = \text{clip}((L - 50)/50,\ 0,\ 1)$$ For each region (using its hue \thetaSm$):