Skip to content

Contrast Mask, and perspective correction as easel Tilt/Swing - #877

Open
marcinz606 wants to merge 8 commits into
mainfrom
feat/darkroom
Open

Contrast Mask, and perspective correction as easel Tilt/Swing#877
marcinz606 wants to merge 8 commits into
mainfrom
feat/darkroom

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

Two darkroom controls that sit outside the tone domain the print stage already covers.

They came out of a survey of what the July–August instrument push left unfilled: 12 candidates, each put through a code audit and an adversarial review. Most died, several with numbers — Pt/Pd is already Toe at +1.0 (d_max_eff = 2.3 − 0.90 = 1.40, the published Dmax), a per-mask Farmer's reducer is matched to 0.085 stops by the mask's existing stops+grade pair, a five-paper proof ladder spans 0.73 8-bit levels. The two here survived because neither is a uniform density-scale change (which is Grade) nor a black-level change (which is Toe).

Contrast Mask

The darkroom's unsharp contrast-reduction mask: a blurred, low-gamma positive contact-printed off the negative and sandwiched with it. Densities add, so D' = D − g·blur(D) + const — the global range compresses to fit the paper while the blur keeps fine detail out of the compression. Sources: Ctein Post Exposure, Bond Unsharp Masking, Adams The Print.

It is the pipeline's only pre-curve spatial operator. Everything upstream of the print curve is pointwise, so no curve control can raise local contrast and shorten the range at once; Grade only trades one for the other. Measured on a synthetic wide-range negative at R60, RMS-Laplacian micro-contrast per density third goes 2.55/0.24/0.03 unmasked to 5.85/0.27/0.08 at gamma 0.6.

Not a stage: contrast_mask_plane builds the plane, contrast_mask_ev turns it into stops, and it joins the dodge/burn map the print stage already reads. Equal stops is an equal absolute density change in all three channels — what a neutral panchromatic masking film records, and why there is no per-layer trim. No shader.

  • Both engines call the same helper on the same pre-geometry array, so CPU and GPU renders are bit-identical (0.00000 max abs diff).
  • σ is a fraction of the analysis grid, never of the render, so preview and export mask alike.
  • The plane covers the printed frame only. The enlarger projects the crop, and a rebate or scanner surround blurred into the mask prints as a vignette the negative does not have: on a uniform picture inside a bright border it swings the plane −0.10 to +0.23, over a stop of edge-to-centre exposure.
  • Zero-mean, since a real sandwich is denser and the printer opens up for it — the slider leaves print density alone.
  • Hidden on the transparency transfer path, which takes no dodge/burn map.

Tilt and Swing

Perspective correction, absent until now: rot90, flips, fine rotation and the radial k1 are all rigid or radially symmetric, so none of them moved a converging vertical by a pixel. A 28mm lens tilted 8° up on 35mm gives a top:bottom width ratio of 1.128 — about 770 px across a 6000 px scan — and the only route was exporting a positive TIFF and losing the print stage.

Named for the enlarger movements: tilt about a horizontal axis straightens converging verticals, swing about a vertical axis converging horizontals. A plane-to-plane projectivity (Hartley & Zisserman §2.3), the transform a tilted easel realises; why the whole sheet cannot then be sharp is Scheimpflug.

  • Runs last in the forward chain, after k1: a projectivity cannot be fitted to a barrel-distorted frame.
  • Unit is per-cent of the frame, not tilt degrees. Convergence is (H/2)sin(τ)/D, so the same tilt keystones differently at every enlargement, and no magnification or focal length is modelled — degrees would bake in a reference enlarger.
  • Output keeps the canvas size and replicates the wedge, as fine rotation does. No scale-to-fill, which would change magnification and move an existing crop.
  • keystone_matrix_normalized is the single definition of the quad; the pixel matrix and the GPU's inverse both derive from it. Built twice they disagree by half a pixel and a w/(w−1) scale — a real bug hit and measured on the way.
  • Every reader of geometry carries it: the uv grid, map_coords_to_geometry (dodge/burn vertices), autocrop's replay and detection key, and the GPU's own analysis replay. That last one is the subtle one — the meters must read the frame the print stage gets, and skipping it had the engines normalizing different pixels, worth 0.031 mean parity error. With it, keystone parity is 0.00007 mean / 0.002 max, better than fine rotation's.
  • CoordinateMapping now fits the grid projectively instead of affinely, for the off-frame points card-edge masks depend on; its own docstring already conceded the affine model was exact only for the affine part.

Verification

make all green — 3944 passed. New: tests/test_contrast_mask.py (11), tests/test_keystone.py (10), both with CPU/GPU parity cases. Beyond pytest, both were driven headlessly through the real GUI on a real scan, and the mask was inspected at 1:1 across the hardest sun/shade edge in the frame for halo — it reads as a masked print, not an HDR tonemap.

Docs updated in the same change: docs/USER_GUIDE.md and docs/PIPELINE.md.

… mask

Sandwich the negative with a blurred, low-gamma positive, as a masking film
and a spacer do in the darkroom. Densities add, so the sandwich is
D' = D - g*blur(D) + const: the global range compresses to fit the paper
while the blur keeps fine detail out of the compression.

This is the pipeline's only pre-curve spatial operator. Everything upstream
of the print curve is pointwise, so no curve control can raise local contrast
and shorten the range at once — Grade only trades one for the other. Measured
on a synthetic wide-range negative at R60, RMS-Laplacian micro-contrast per
density third goes 2.55/0.24/0.03 unmasked to 5.85/0.27/0.08 at gamma 0.6.

Not a stage: normalization.contrast_mask_plane builds the plane and
logic.contrast_mask_ev turns it into stops for the dodge/burn map the print
stage already reads. Equal stops is an equal absolute density change in all
three channels, which is what a neutral panchromatic masking film records —
so the control is global, with no per-layer trim.

The plane is built on the crop only. The enlarger projects the frame you
print, and a bright rebate or a black scanner surround blurred into the mask
pushes a low-frequency ramp inward that prints as a vignette the negative
does not have: on a uniform picture inside a bright border it swings the
plane -0.10 to +0.23, over a stop of edge-to-centre exposure. Placed back at
the crop and edge-replicated outside, so the crop tool's full-frame preview
has no seam.

Both engines call the same helper on the same pre-geometry array, so CPU and
GPU renders are bit-identical (measured 0.00000 max abs diff). Sigma is a
fraction of the analysis grid, never of the render, so preview and export
mask alike. The plane is zero-mean: a real sandwich is denser and the printer
opens up for it, so the slider leaves print density alone.

Hidden on the transparency transfer path, which takes no dodge/burn map.

Sources: Ctein, Post Exposure; Bond, Unsharp Masking; Adams, The Print.
Perspective correction, absent until now: rot90, flips, fine rotation and the
radial k1 are all rigid or radially symmetric, so none of them moved a
converging vertical by a pixel. A 28mm lens tilted 8 degrees up on 35mm gives a
top:bottom width ratio of 1.128 — about 770 px across a 6000 px scan — and the
only route was exporting a positive TIFF and losing the print stage.

A plane-to-plane projectivity, which is what a tilted easel realises
(Scheimpflug BP 1196; Adams, The Print). Runs last in the forward chain, after
k1: a projectivity cannot be fitted to a frame that still carries barrel
distortion. Output keeps the canvas size and replicates the wedge, as fine
rotation does — scaling to fill would change magnification and drag a crop the
user had already drawn.

The unit is per-cent of the frame, not tilt degrees. Convergence is
(H/2)sin(tau)/D, so the same tilt keystones differently at every enlargement,
and nothing here models a magnification or a focal length; degrees would be a
reference enlarger silently baked in.

keystone_matrix_normalized is the single definition of the quad. The pixel
matrix and the GPU's inverse both derive from it — built twice they disagree by
half a pixel and a w/(w-1) scale, which is a real bug I hit and measured. The
shader consumes that inverse from the geometry uniform (the two free
distortion lanes were not enough, so the block grows to 64B) rather than
rebuilding the quad.

Everything that reads geometry carries it: the uv grid, map_coords_to_geometry
for dodge/burn vertices, autocrop's replay and detection key, and the GPU's own
analysis replay. That last one is the subtle one — the meters must read the
frame the print stage gets, and skipping it there had the engines normalizing
different pixels, worth 0.031 mean parity error. With it, keystone parity is
0.00007 mean / 0.002 max, better than fine rotation's.

CoordinateMapping now fits the grid projectively instead of affinely, for the
off-frame points card-edge masks depend on; its own docstring already conceded
the affine model was exact only for the affine part.
The enlarger's own movement names: tilt about a horizontal axis straightens
converging verticals, swing about a vertical axis converging horizontals.

Also fixes a layout defect. CompactSlider's header is label | dot | stretch |
spin, and the spin sits at width 0 until hover, so a label wide enough to eat
the stretch's slack gets pushed sideways when the value appears. At a
two-per-row slider's 170px share, "Converging Verticals" overran by 30px and
"Converging Horizontals" by 48px; Tilt and Swing clear it by 77px and 59px,
more headroom than Fine Rotation has.

Fields stay converge_v / converge_h, as Snap stays midtone_gamma.
…dget

Docstrings were running to 13 lines against a 1-4 line budget, PIPELINE entries
to three paragraphs against one, and several stated the bug they came from
rather than the rule that replaced it. Also drops bold emphasis and a
defensive-guard note that only mattered to the code.
Ctein's masking chapter covers contrast increase as well as reduction, and it is
the same operation with the sign flipped: D' = D - g*blur(D) subtracts the low
frequencies for a blurred positive and adds them for a blurred negative. The
range opens to +/-0.5 and the gates stop requiring g > 0.

The increasing half is not a Grade preset. Grade multiplies every frequency
alike; the mask multiplies only the low ones, so at a matched gain in global
contrast the two move micro-contrast in opposite directions. Measured on a
wide-range negative at equal global spread: RMS-Laplacian micro-contrast goes
1.15 -> 0.87 under the mask and 1.15 -> 1.21 under a harder grade.

It also has travel where Grade has none. The straight-line slope is
k = 2.9 * density_range / (ISO_R/100) clamped to a floor of 2.0, so a negative
flat enough sits on that floor at every ISO-R and the grade slider is inert; the
mask still adds 22% of global spread there.

No veiling artifact in the new direction: on a real scan at -0.3 the bright
areas rise and the dark areas fall, while the dark side of strong edges stays
neutral, which is the opposite of the lifted-shadow signature.

Slider is inverted like ISO-R Grade, so dragging right hardens on both.
@marcinz606

Copy link
Copy Markdown
Owner Author

Update: the Contrast Mask now works both ways

Ctein's masking chapter covers contrast increase as well as reduction, and it is the same operation with the sign flipped. D' = D - g·blur(D) subtracts the low frequencies for a blurred positive and adds them for a blurred negative, so the two masks are one axis through zero. Range is now ±0.5.

The increasing half is not a Grade preset. Grade multiplies every frequency alike; the mask multiplies only the low ones. At a matched gain in global contrast the two move micro-contrast in opposite directions:

route global spread micro-contrast micro/global
R115 baseline 0.1986 1.15 5.78
mask −0.30 0.2468 0.87 3.54
R90 (grade-matched) 0.2298 1.21 5.27

That is the point of the increasing mask: snap in the broad tones without crunching grain and texture.

It also has travel where Grade has none. The straight-line slope is k = 2.9·density_range/(ISO_R/100) clamped to a floor of 2.0, so a sufficiently flat negative sits on that floor at every ISO-R and the grade slider is completely inert — R60 and R180 render identically. The mask still adds 22% of global spread there, because it works on the low frequencies rather than the slope.

Checked for the artifact the new direction risks (adding a blurred copy lifting the dark side of an edge, i.e. veiling): on a real scan at −0.3 the bright areas rise (+6.5 levels) and the dark areas fall (−3.9), while the dark side of strong edges stays neutral (+0.1). That is the opposite of the veiling signature.

Clipping at the negative end grows smoothly and crosses the app's own 1% warning threshold at about −0.5, where the Analysis panel's Clipping row turns red and says so.

The slider is inverted like ISO-R Grade, so dragging right hardens on both controls — verified against the real position-to-value mapping, not the property name.

The shared opening claimed the mask was "a blurred, low-contrast copy of itself,
contact-printed off the negative", which describes only the reduction case: a
contact print of a negative is a positive, so it cannot also be the
same-polarity mask the increasing direction needs. Polarity now lives in each
direction's own sentence, with the range scaling each one gives.

Verified against the implementation rather than the derivation: on a ramp plus
fine texture, the low-frequency range scales 0.505 / 0.752 / 1.248 / 1.495 at
g = +0.5 / +0.25 / -0.25 / -0.5 against a predicted (1 - g), while detail above
the blur scale passes through at 1.000 throughout.

Also drops the em dashes, the bold run-ins and several trailing relative
clauses from the guide bullets, tooltips and pipeline entries, and shortens all
three.
…hart

The chart plots the global pointwise transfer function, so a spatial control has
no line on it: the mask shifts each pixel by how far its own value sits from its
blurred surroundings, and two pixels at one negative density print differently
depending on their neighbours. That left the histogram moving while the curve sat
still, which reads as a bug.

There is an honest representation, though, and it is a band rather than a line.
A large flat area, where the blur equals its own value, prints at
val' = (1-g)*val + g*centre; fine detail riding on a neutral surround prints
unmoved. Those are the two extremes and every pixel lands between them, which is
what the earlier measurement showed: low-frequency range scaled by (1-g) to three
decimals while detail amplitude held at 1.000. The far edge is dashed, since no
single tone has to land there.

contrast_mask_plane now returns the val it centred on alongside the plane, and
both engines put it in metrics for the chart. Violet, because amber is the toe
wash and blue the shoulder.

Dodge/burn, local grade and CLAHE stay absent from the chart for the same reason
the mask could not be a line; the guide now says so rather than leaving it to be
discovered.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant