Let a mask start from a tone or a colour, not only a shape

Every local adjustment began from a shape: painted, drawn with a handle, or
found by a model. So the only way to hold back a sky was to draw a line near
where it ended, and the only way to warm skin was to paint round it — both of
which put the edit's edge where the photographer put a gesture rather than
where the picture changes. A gradient across a treeline halos, and an
adjustment traced round a face stops on the outline of a hand.

MaskSource grows two variants that select by what a pixel *is*. Luminance
carries two bounds on the perceptual tone scale plus a softness; Colour carries
an arc of hue, a range of chroma, and one softness for every edge of both. Five
floats and three, so they diff, sync and merge per field under FR-NC-9 exactly
as a gradient's geometry does — the property a stored raster has none of, and
the reason the model's coverage had to sit beside its source rather than inside
it.

The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the
demosaiced source as a sixth binding and two new modes read it: decode, balance,
pull a clipped photosite back to neutral, apply the camera matrix, then weigh
the band. Nothing crosses to the CPU but the numbers and the matrix, and each
mask texel averages its own footprint in the source, so a band lands on the tone
an area is rather than on whichever texel a proxy grid happened to land on.

The photograph it measures is the one the camera recorded, before this edit. A
band over the edited result would slide out from under the edit as the edit was
made — raising the highlights would change which pixels counted as highlights,
and the slider would chase its own mask.

Feather, falloff and morphology stay off a range layer, which is what
`shapeable` already meant. All three are functions of the signed distance from
a boundary, and a range has no boundary to be at a distance from; its edge is
the softness of its own band, in the band's units. Offering them would be four
controls that move and change nothing.
This commit is contained in:
2026-09-06 19:01:48 +02:00
parent 81b1ae8c42
commit 68ebf5d78b
16 changed files with 1555 additions and 86 deletions
+296 -3
View File
@@ -50,6 +50,24 @@
//! still merges per field. The raster sits beside it as a cache, takes no part
//! in equality, and is thrown away rather than trusted when it does not fit.
//! See [`crate::coverage`].
//!
//! # The masks that are not shapes
//!
//! Everything above selects by *where*: a region, an instance, a gradient's
//! geometry, the path a finger took. [`MaskSource::Luminance`] and
//! [`MaskSource::Colour`] select by *what a pixel is* — a band over the
//! photograph's own brightness or its own colour, with the edges of the band
//! soft (FR-DEV-10). That is what a luminosity mask has always been, and it is
//! why a local edit made through one blends without a halo: the boundary
//! follows the picture's values exactly, at whatever detail the picture has,
//! rather than an outline drawn near them.
//!
//! They store the band and nothing else, for precisely the reason the region
//! ids above are ids: five numbers diff, sync and merge per field under
//! FR-NC-9, where the pixels they select would be a blob for a merge to
//! arbitrate. And the pixels never need storing, because unlike a model's
//! coverage they are reproducible by anything that can read the photograph —
//! which is every path that renders one.
use std::fmt::Write as _;
use std::sync::Arc;
@@ -104,6 +122,27 @@ pub const MAX_STROKE_POINTS: usize = 256;
/// work the user can see on screen must not vanish without being told.
pub const MAX_LAYER_POINTS: usize = 4096;
/// TRACES: FR-DEV-10
/// How far a range mask fades in and out at each edge of its band.
///
/// In the band's own units — tone position for a luminance range, chroma or
/// turns of hue for a colour one — because that is the only place a range
/// mask has an edge. It has no boundary in the picture and therefore no
/// distance from one, which is why [`MaskLayer::feather`] cannot reach it.
///
/// The default is generous on purpose. A hard band over a photograph's values
/// finds the contour lines of the picture and draws them, and a tone contour
/// crossing a smooth sky is the one artefact a luminosity mask exists to
/// avoid. Softness is what turns the band from a threshold into a weighting.
pub const DEFAULT_RANGE_SOFTNESS: f32 = 0.15;
/// The widest a hue arc may be, in turns.
///
/// Half a turn each way is the whole circle, so anything past this is not a
/// selection — it is every colour, arrived at by a control the user thought
/// was narrowing something.
pub const MAX_HUE_WIDTH: f32 = 0.5;
/// Brush radius a new stroke starts at, as a fraction of the shorter edge.
pub const DEFAULT_BRUSH_RADIUS: f32 = 0.05;
/// Fraction of the radius that is fully covered before the edge falls away.
@@ -148,6 +187,21 @@ fn snap(v: f32) -> f32 {
(v * STROKE_GRID).round() / STROKE_GRID
}
/// Two band edges, clamped to `0..=1` and put the right way round.
///
/// Swapped rather than collapsed to a point: dragging one handle past the
/// other is how every range control in every editor is used to reverse a
/// selection's ends, and the alternative — clamping the lower bound against
/// the upper — makes the band stick at zero width and the drag stop dead.
fn ordered(lo: f32, hi: f32) -> (f32, f32) {
let (lo, hi) = (lo.clamp(0.0, 1.0), hi.clamp(0.0, 1.0));
if lo <= hi {
(lo, hi)
} else {
(hi, lo)
}
}
/// TRACES: FR-DEV-3
/// One painted stroke: a disc of radius `radius` swept along a polyline.
///
@@ -651,6 +705,82 @@ pub enum MaskSource {
/// thing it was painted on through a crop, a straighten and an export at
/// any size.
Brush { strokes: Vec<Stroke> },
/// TRACES: FR-DEV-10
/// Every pixel whose brightness falls in a band — the luminosity mask.
///
/// The first mask in this enum that is not a shape. A gradient, a stroke
/// and a region all answer "where"; this answers "how bright", and the
/// selection that comes out has the edge the *photograph* has rather than
/// an edge somebody drew near it. That is the whole reason a local edit
/// made through one blends: there is no boundary to halo along.
///
/// # Why the band is in perceptual position, not in linear light
///
/// The bounds are positions on the same 0..1 lightness scale
/// `ops/_helpers.yaml`'s `tone_position` establishes, which is the cube
/// root of luminance. Linear light puts middle grey at 0.18, so a band
/// stated in it would call four fifths of the range "shadow" and the
/// slider's useful travel would be its first inch. The stored numbers are
/// where the eye puts them, which is also where the control puts them.
///
/// # Which image it measures
///
/// The photograph as captured, before this edit — not the edited result.
/// The mask is rasterised in its own pass, ahead of the adjustment that
/// samples it, so there is no arrangement in which it could read its own
/// output. That is a property worth having rather than a limitation
/// worked around: a band over the edited image would slide out from under
/// the edit as the edit was made, so raising the highlights would change
/// which pixels counted as highlights, and the slider would fight itself.
Luminance {
/// Lower edge of the band, as a tone position in `0.0..=1.0`.
lo: f32,
/// Upper edge, in the same units. Never below `lo` — see
/// [`MaskSource::luminance_range`], which is where that is kept true.
hi: f32,
/// How far the band fades in below `lo` and out above `hi`, in the
/// same units. Zero is a threshold, and a threshold over a
/// photograph's own tones draws contour lines.
softness: f32,
},
/// TRACES: FR-DEV-10
/// Every pixel whose colour falls in a band — the skin-tone mask.
///
/// The counterpart to [`Self::Luminance`], and the pair is not symmetric:
/// brightness is one number and colour is two, so this carries an arc of
/// hue *and* a range of chroma. Both are needed, and the chroma half is
/// the one that is easy to leave out. Hue is meaningless as a colour
/// approaches grey — a nearly-neutral wall has a hue, arrived at by
/// rounding — so an arc alone selects a haze of noise everywhere the
/// picture is desaturated. The lower chroma bound is what keeps that out,
/// and it is why the default sits well above zero.
///
/// Measured in linear sRGB, after the camera matrix, because that is the
/// only space in which a stated hue means the colour the photographer
/// named. Camera RGB is the body's own primaries: the same arc would
/// select a different set of colours on every make of sensor, and a skin
/// tone stored on one body would land on foliage on another.
Colour {
/// Centre of the accepted arc, in turns of the hue circle,
/// `0.0..1.0`. Wraps, so 0.98 and 0.02 are close together.
hue: f32,
/// Half-width of the arc, in turns. Bounded by [`MAX_HUE_WIDTH`].
hue_width: f32,
/// Lower edge of the accepted chroma, `0.0..=1.0`, in the
/// max-minus-min sense `colour_saturation` uses.
chroma_lo: f32,
/// Upper edge of the accepted chroma. Kept above `chroma_lo` by
/// [`MaskSource::colour_range`].
chroma_hi: f32,
/// The fade at every edge of the band — both ends of the arc and both
/// ends of the chroma range. One number rather than three: they are
/// the same control to a photographer, who is choosing how strictly
/// the selection is drawn, not tuning three of them against each
/// other.
softness: f32,
},
}
impl MaskSource {
@@ -663,6 +793,8 @@ impl MaskSource {
Self::Linear { .. } => "linear",
Self::Radial { .. } => "radial",
Self::Brush { .. } => "brush",
Self::Luminance { .. } => "luminance",
Self::Colour { .. } => "colour",
}
}
@@ -673,6 +805,82 @@ impl MaskSource {
}
}
/// TRACES: FR-DEV-10
/// A luminance band, with the bounds put in order and inside the scale.
///
/// **The only way one is built**, including on the way in from a sidecar.
/// The invariants a band has — bounds inside `0..1`, `lo` at or below
/// `hi`, softness not negative — are cheap to state once and impossible
/// to remember at four call sites. A crossed pair is the interesting one:
/// it comes from a user dragging the lower handle past the upper, and a
/// shader given it would select nothing at all rather than the thin band
/// the gesture plainly meant.
pub fn luminance_range(lo: f32, hi: f32, softness: f32) -> Self {
let (lo, hi) = ordered(lo, hi);
Self::Luminance {
lo,
hi,
softness: softness.clamp(0.0, 1.0),
}
}
/// TRACES: FR-DEV-10
/// A colour band, clamped and ordered like [`Self::luminance_range`].
///
/// The hue is wrapped rather than clamped, because the hue circle has no
/// ends: a control dragged past red comes back round to red, where
/// clamping would stick it there and make the far side of the circle
/// unreachable from one direction.
pub fn colour_range(
hue: f32,
hue_width: f32,
chroma_lo: f32,
chroma_hi: f32,
softness: f32,
) -> Self {
let (chroma_lo, chroma_hi) = ordered(chroma_lo, chroma_hi);
Self::Colour {
hue: hue.rem_euclid(1.0),
hue_width: hue_width.clamp(0.0, MAX_HUE_WIDTH),
chroma_lo,
chroma_hi,
softness: softness.clamp(0.0, 1.0),
}
}
/// TRACES: FR-DEV-10
/// The band a new luminance layer starts on: the brighter half.
///
/// Not the whole scale, which would select everything and read as a mask
/// that had failed to do anything. The upper half is what a photographer
/// reaches for first — holding back a sky, shaping a highlight — and it
/// is immediately visible on almost any photograph, so the control the
/// user then drags starts from something they can see.
pub fn highlights() -> Self {
Self::luminance_range(0.5, 1.0, DEFAULT_RANGE_SOFTNESS)
}
/// TRACES: FR-DEV-10
/// The band a new colour layer starts on: the orange–red arc skin sits in.
///
/// Opinionated, and deliberately. Selecting by colour is a general tool
/// and skin is the reason most people reach for it, so starting there
/// means the first thing the user sees is the answer to the question they
/// asked; starting at red, or at whatever hue zero happens to be, would
/// mean the control had to be understood before it did anything.
pub fn skin_tones() -> Self {
Self::colour_range(0.06, 0.05, 0.15, 1.0, DEFAULT_RANGE_SOFTNESS)
}
/// TRACES: FR-DEV-10
/// Whether this mask selects by a property rather than by a shape.
///
/// Asked by the panel, which offers a band's controls for one and a
/// shape's for the other, and by [`MaskLayer::is_stale`].
pub fn is_range(&self) -> bool {
matches!(self, Self::Luminance { .. } | Self::Colour { .. })
}
/// The strokes, or nothing for a source that is not painted.
pub fn strokes(&self) -> &[Stroke] {
match self {
@@ -947,6 +1155,14 @@ impl MaskLayer {
/// adjustment to the whole photograph before a single stroke was made —
/// the loud, wrong-looking failure this codebase avoids everywhere else a
/// mask can go missing.
///
/// A range answers yes, and it is worth saying why rather than leaving it
/// to the catch-all. A band *can* be empty on a particular photograph — a
/// highlight band over a picture with no highlights in it — but nothing
/// on this side could know that without rasterising the mask, which is the
/// one thing this module promises never to do. So the honest answer is
/// that the layer selects whatever the picture has, and an empty result
/// is the picture's answer rather than the layer's.
fn covers(&self) -> bool {
match &self.source {
MaskSource::Brush { strokes } => strokes.iter().any(|s| !s.erase && !s.is_empty()),
@@ -985,9 +1201,16 @@ impl MaskLayer {
// same thing whatever was or was not detected, so nothing about a
// new run can invalidate it. Painted strokes are the same: they are
// where the user put them, not where a model thought something was.
MaskSource::Linear { .. } | MaskSource::Radial { .. } | MaskSource::Brush { .. } => {
false
}
//
// A range is the strongest case of all: it is a band over the
// photograph's own values, so it does not merely survive a new
// segmentation — there is nothing a model could ever say that
// would change which pixels it names.
MaskSource::Linear { .. }
| MaskSource::Radial { .. }
| MaskSource::Brush { .. }
| MaskSource::Luminance { .. }
| MaskSource::Colour { .. } => false,
}
}
@@ -1935,4 +2158,74 @@ mod tests {
);
}
}
/// TRACES: FR-DEV-10
/// A band the user dragged inside out is the band they described, not an
/// empty one. Dropping it to zero width would make the drag stop dead
/// under the finger at the moment the handles crossed.
#[test]
fn a_crossed_band_is_the_band_between_the_two_numbers() {
let MaskSource::Luminance { lo, hi, .. } = MaskSource::luminance_range(0.8, 0.3, 0.1)
else {
panic!("not a luminance range");
};
assert_eq!((lo, hi), (0.3, 0.8));
}
/// TRACES: FR-DEV-10
/// The hue circle has no ends. Clamping a hue past red would stick it
/// there, and the far side of the circle would be unreachable from one
/// direction — a control that stops turning is a control that is broken.
#[test]
fn a_hue_past_the_end_of_the_circle_wraps() {
let MaskSource::Colour { hue, .. } = MaskSource::colour_range(1.25, 0.05, 0.1, 1.0, 0.1)
else {
panic!("not a colour range");
};
assert!((hue - 0.25).abs() < 1e-6, "hue wrapped to {hue}");
let MaskSource::Colour { hue, .. } = MaskSource::colour_range(-0.1, 0.05, 0.1, 1.0, 0.1)
else {
panic!("not a colour range");
};
assert!((hue - 0.9).abs() < 1e-6, "hue wrapped to {hue}");
}
/// TRACES: FR-DEV-10
/// An arc may not quietly become the whole circle. Half a turn each way is
/// every colour there is, arrived at through a control the photographer
/// believed was narrowing something.
#[test]
fn a_hue_arc_cannot_swallow_the_circle() {
let MaskSource::Colour { hue_width, .. } =
MaskSource::colour_range(0.0, 9.0, 0.1, 1.0, 0.1)
else {
panic!("not a colour range");
};
assert_eq!(hue_width, MAX_HUE_WIDTH);
}
/// TRACES: FR-DEV-10
/// Nothing a model finds can change which pixels a band names, so a range
/// layer is never stale — where a subject layer read against a new
/// segmentation is exactly that.
#[test]
fn a_range_never_goes_stale() {
assert!(!MaskLayer::new("m1", MaskSource::highlights()).is_stale(7));
assert!(!MaskLayer::new("m2", MaskSource::skin_tones()).is_stale(7));
}
/// TRACES: FR-DEV-10
/// A range is a rule, and the rule is the whole of what it stores.
#[test]
fn a_range_is_a_rule_and_not_a_shape() {
let source = MaskSource::skin_tones();
assert!(source.is_range());
assert!(
source.strokes().is_empty(),
"a range has no geometry to hand out"
);
assert_eq!(source.kind(), "colour");
assert_eq!(MaskSource::highlights().kind(), "luminance");
}
}
+90 -3
View File
@@ -1129,6 +1129,46 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
let _ = writeln!(out, "feather = {}", format_value(*feather));
}
MaskSource::Brush { strokes } => write_strokes(out, strokes),
// TRACES: FR-DEV-10
// `band` rather than a key per edge, and the same key for both range
// sources. The two bounds and their softness are one quantity to a
// reader — "the band this mask selects" — and splitting them over
// three lines makes a hand edit that moves one edge and forgets the
// other look like a file that was written that way.
MaskSource::Luminance { lo, hi, softness } => {
let _ = writeln!(
out,
"band = {} {} {}",
format_value(*lo),
format_value(*hi),
format_value(*softness)
);
}
MaskSource::Colour {
hue,
hue_width,
chroma_lo,
chroma_hi,
softness,
} => {
// The chroma half goes out as `band` so that the two range
// sources share a key for the same idea, and the arc gets its
// own: a hue is a position on a circle and its neighbours are not
// bounds in the sense the chroma pair are.
let _ = writeln!(
out,
"hue = {} {}",
format_value(*hue),
format_value(*hue_width)
);
let _ = writeln!(
out,
"band = {} {} {}",
format_value(*chroma_lo),
format_value(*chroma_hi),
format_value(*softness)
);
}
}
if layer.invert {
@@ -1277,6 +1317,16 @@ struct PartialMask {
angle: f32,
width: f32,
feather: f32,
/// TRACES: FR-DEV-10
/// A range source's band: its two bounds and the fade at each edge.
///
/// One triple for both range kinds — tone positions for a luminance
/// layer, chroma for a colour one — because they are the same line in the
/// file and reading them into two sets of fields would be two ways to
/// spell one thing.
band: (f32, f32, f32),
/// A colour range's arc: centre and half-width, in turns.
hue: (f32, f32),
invert: bool,
opacity: f32,
enabled: bool,
@@ -1311,6 +1361,11 @@ impl PartialMask {
angle: 0.0,
width: 0.0,
feather: 0.0,
// The defaults a new layer gets, so a block that names a range
// source and nothing else reads back as the mask the panel would
// have made rather than as an empty band selecting nothing.
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
hue: (0.06, 0.05),
invert: false,
opacity: 1.0,
enabled: true,
@@ -1356,6 +1411,12 @@ impl PartialMask {
"angle" => self.angle = value.parse().unwrap_or(0.0),
"width" => self.width = value.parse().unwrap_or(0.0),
"feather" => self.feather = value.parse().unwrap_or(0.0),
// TRACES: FR-DEV-10
// Kept as written and ordered later, in `MaskSource::*_range`.
// Clamping here as well would be a second place the band's
// invariants live, and the two would drift.
"band" => self.band = triple(value).unwrap_or(self.band),
"hue" => self.hue = pair(value).unwrap_or(self.hue),
// A cache, so an unreadable one is dropped rather than refused:
// the layer still says what it selects, and the worst a `None`
// here costs is a model run. Refusing the layer over it would
@@ -1454,6 +1515,18 @@ impl PartialMask {
"brush" => MaskSource::Brush {
strokes: self.strokes,
},
// TRACES: FR-DEV-10
// Through the constructors, not built by hand: they are where the
// band's invariants are kept, and a file is exactly the input
// that has not been through them.
"luminance" => MaskSource::luminance_range(self.band.0, self.band.1, self.band.2),
"colour" => MaskSource::colour_range(
self.hue.0,
self.hue.1,
self.band.0,
self.band.1,
self.band.2,
),
other => {
log::warn!(
"sidecar: unknown mask source '{other}'; skipping layer {}",
@@ -1517,9 +1590,23 @@ impl PartialMask {
/// Two whitespace-separated floats.
fn pair(value: &str) -> Option<(f32, f32)> {
let mut it = value.split_whitespace();
let a = it.next()?.parse().ok()?;
let b = it.next()?.parse().ok()?;
Some((a, b))
let a: f32 = it.next()?.parse().ok()?;
let b: f32 = it.next()?.parse().ok()?;
// A NaN parses — `"nan"` is a valid float — and then survives every clamp
// downstream, because every comparison against it is false. It reaches a
// uniform, and a mask whose geometry or band is NaN selects nothing while
// looking, in the file and in the panel, exactly like one that should.
(a.is_finite() && b.is_finite()).then_some((a, b))
}
/// TRACES: FR-DEV-10
/// Three whitespace-separated floats — a range mask's band.
fn triple(value: &str) -> Option<(f32, f32, f32)> {
let mut it = value.split_whitespace();
let a: f32 = it.next()?.parse().ok()?;
let b: f32 = it.next()?.parse().ok()?;
let c: f32 = it.next()?.parse().ok()?;
(a.is_finite() && b.is_finite() && c.is_finite()).then_some((a, b, c))
}
/// Format a value without a trailing `.0` on whole numbers, and without