Contrast Mask, and perspective correction as easel Tilt/Swing - #877
Contrast Mask, and perspective correction as easel Tilt/Swing#877marcinz606 wants to merge 8 commits into
Conversation
… 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.
Update: the Contrast Mask now works both waysCtein's masking chapter covers contrast increase as well as reduction, and it is the same operation with the sign flipped. 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:
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 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.
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_planebuilds the plane,contrast_mask_evturns 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.Tilt and Swing
Perspective correction, absent until now:
rot90, flips, fine rotation and the radialk1are 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.
k1: a projectivity cannot be fitted to a barrel-distorted frame.(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.keystone_matrix_normalizedis 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 aw/(w−1)scale — a real bug hit and measured on the way.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.CoordinateMappingnow 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 allgreen — 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.mdanddocs/PIPELINE.md.