Skip to content

Expose the Contrast Mask's spacer as Mask Blur - #884

Merged
marcinz606 merged 1 commit into
mainfrom
feat/mask-blur
Aug 17, 2026
Merged

Expose the Contrast Mask's spacer as Mask Blur#884
marcinz606 merged 1 commit into
mainfrom
feat/mask-blur

Conversation

@marcinz606

Copy link
Copy Markdown
Owner

The Contrast Mask's blur was pinned at 4% of the analysis grid. This exposes it as Mask Blur, beside Contrast Mask in the Tone panel.

Why it earns a control now

When the radius was cut from the first version, the only evidence I had was smooth synthetic content, where sigma 40 against 150 moved micro-contrast by 0.05. That did not justify a slider. Measured per octave on a real scan it clearly does, because the blur is not a strength but a frequency cut-off: it sets the scale above which tones are masked.

Band energy against unmasked, at gamma 0.5:

blur 2px 4px 8px 16px 32px 64px
1% 0.92 0.89 0.75 0.60 0.52 0.51
2% 0.93 0.93 0.89 0.77 0.61 0.54
4% (default) 0.94 0.94 0.94 0.91 0.79 0.64
8% 0.97 0.96 0.96 0.96 0.93 0.82

Narrower compresses harder (global range 0.50× at 1% against 0.62× at 4%, on a theoretical floor of 0.50) and lifts shadows sitting next to something bright, which is the mask line a printer sees on the sheet. At the narrow limit blur(val) → val and the operator collapses into a plain (1−g) reduction, which is Grade's job. 4% stays the default as the conservative end.

Note the first metric I reached for was blind to this: an RMS Laplacian only sees 1–2px, and even 1% blur is ~10px on a 1000px render, so micro-contrast barely moved (0.94 to 0.98) across the whole range. The per-octave decomposition is what showed the axis.

Naming

Mask Blur rather than Spacer, Unsharpness or Mask Sharpness.

  • Spacer is the better darkroom name (it is literally the sheet holding mask off negative) but invites a thickness in mm, and mm needs FilmFormat from user-editable EXIF, already rejected as a fake unit on this feature.
  • Mask Sharpness measures 100px against a 96px budget at a two-per-row slider's share, so it would push the label on hover, the defect just fixed on Tilt/Swing. It also inverts against Lab's Sharpening, which moves apparent detail the opposite way, and it would force the stored value from a measurable per-cent into an abstract knob.
  • Mask Radius collides with Lab → Sharpening → Radius, also a blur radius.

Mask Blur is 62px, keeps the number a real per-cent, and needs no inversion.

Cache correctness

The gamma slider is a pure scalar on a cached plane, so the blur is the one part that genuinely rebuilds it. It now keys the plane cache in both engines and the GPU's mask texture. Verified by moving the blur under an unchanged source hash: CPU and GPU both re-render (8%→4% mean |Δ| 0.00242, 4%→1% 0.00618) and agree exactly, and parity at a non-default blur is 0.00000 max abs diff.

Dead without a mask, so it greys out at gamma 0, and it is global-only like the mask itself. It is deliberately not in _global_only, since that tuple's contract is "enabled exactly when global" and this has a second gate.

Verification

make all green, 3964 passed, 3 new tests (the cut-off behaviour, the cache rebuild under one source hash, and that it is inert with no mask). Driven headlessly through the real GUI on a real scan alongside the existing Contrast Mask checks. Both docs updated.

The blur was pinned at 4% of the analysis grid. It is not a strength but a
frequency cut-off, setting the scale above which tones are masked, and measured
per octave at gamma 0.5 the axis is wide: 1% attenuates the 8px band to 0.75 and
the 64px band to 0.51, where 8% leaves everything under 64px above 0.93.

Narrower compresses harder (global range 0.50x at 1% against 0.62x at 4%, on a
floor of 0.50) and lifts shadows that sit beside something bright, which is the
mask line on a real sheet. At the limit blur(val) -> val and the operator
collapses into a plain (1-g) reduction, which is Grade. 4% stays the default as
the conservative end.

This is the evidence the radius was missing when it was cut from the first
version: on smooth synthetic content sigma 40 against 150 moved micro-contrast
by 0.05, which did not justify a control. Per-octave on a real scan it does.

Named for the quantity rather than the darkroom object: Spacer invites a
thickness in mm, and mm needs FilmFormat from user-editable EXIF, which was
already rejected as a fake unit here.

Unlike the gamma scalar the blur rebuilds the plane, so it keys the plane cache
on both engines, and it reaches both plane builds: the live one and the single
plane a tiled export shares across its tiles, which would otherwise have stayed
at the default. Verified that moving it re-renders under an unchanged source
hash on CPU and GPU, with parity still exact at a non-default value.
@marcinz606
marcinz606 merged commit d3a39fb into main Aug 17, 2026
1 check passed
@marcinz606
marcinz606 deleted the feat/mask-blur branch August 17, 2026 20:02
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