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:
@@ -1125,7 +1125,10 @@ export component AppWindow inherits Window {
|
||||
callback mask-falloff-picked(string, int);
|
||||
callback mask-morphology-picked(string, int);
|
||||
callback mask-morph-radius-changed(string, float);
|
||||
callback mask-band-changed(string, float, float, float);
|
||||
callback mask-hue-changed(string, float, float);
|
||||
callback add-gradient-mask(bool);
|
||||
callback add-range-mask(bool);
|
||||
callback add-subject-mask(int);
|
||||
callback add-category-mask(string);
|
||||
|
||||
@@ -2662,7 +2665,14 @@ in property <bool> panel-visible: true;
|
||||
mask-morph-radius-changed(id, v) => {
|
||||
root.mask-morph-radius-changed(id, v);
|
||||
}
|
||||
mask-band-changed(id, lo, hi, soft) => {
|
||||
root.mask-band-changed(id, lo, hi, soft);
|
||||
}
|
||||
mask-hue-changed(id, h, w) => {
|
||||
root.mask-hue-changed(id, h, w);
|
||||
}
|
||||
add-gradient(radial) => { root.add-gradient-mask(radial); }
|
||||
add-range(chromatic) => { root.add-range-mask(chromatic); }
|
||||
add-subject(i) => { root.add-subject-mask(i); }
|
||||
add-category(n) => { root.add-category-mask(n); }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,26 @@ export struct MaskRow {
|
||||
/// does not go through the distance field, so showing them would offer
|
||||
/// controls that do nothing.
|
||||
shapeable: bool,
|
||||
/// Whether the band controls apply — a mask made from a range of the
|
||||
/// photograph's own values rather than from a shape (FR-DEV-10).
|
||||
///
|
||||
/// The complement of `shapeable` in practice but not by definition: a
|
||||
/// range has no boundary in the picture, so it has no distance from one
|
||||
/// for a feather or a dilation to act on, and its edge is the softness of
|
||||
/// its own band instead.
|
||||
ranged: bool,
|
||||
/// Whether that range is over colour rather than over brightness, so the
|
||||
/// hue arc applies as well as the band.
|
||||
chromatic: bool,
|
||||
/// The band's lower and upper bounds, and the fade at each edge. Tone
|
||||
/// positions for a brightness range, colourfulness for a colour one —
|
||||
/// two readings of one control, which is why they share three fields.
|
||||
band-lo: float,
|
||||
band-hi: float,
|
||||
band-softness: float,
|
||||
/// A colour range's arc: centre and half-width, in turns.
|
||||
hue: float,
|
||||
hue-width: float,
|
||||
/// Its region ids came from a different segmentation than the one loaded,
|
||||
/// so applying it would produce a confidently wrong shape.
|
||||
stale: bool,
|
||||
@@ -107,6 +127,12 @@ component MaskEntry inherits Rectangle {
|
||||
callback falloff-picked(int);
|
||||
callback morphology-picked(int);
|
||||
callback morph-radius-changed(float);
|
||||
/// Lower bound, upper bound, softness — all three, because they are one
|
||||
/// control: dragging the lower past the upper swaps them, and a callback
|
||||
/// per field would have to answer that with a third of the answer.
|
||||
callback band-changed(float, float, float);
|
||||
/// Centre hue and half-width, in turns.
|
||||
callback hue-changed(float, float);
|
||||
callback refined();
|
||||
|
||||
height: layout.preferred-height;
|
||||
@@ -266,6 +292,90 @@ component MaskEntry inherits Rectangle {
|
||||
reset => { root.refine-changed(4); }
|
||||
}
|
||||
|
||||
// The band (FR-DEV-10). Above the edge controls because for a
|
||||
// range there are none: a band has no boundary in the picture and
|
||||
// therefore no distance from one, so `shapeable` is false and the
|
||||
// softness below is the whole of this mask's edge.
|
||||
//
|
||||
// Degrees and percentages rather than the turns and fractions
|
||||
// stored. Both are the units the quantity is discussed in, and a
|
||||
// hue slider reading 0.06 is a control the photographer has to
|
||||
// learn before it means anything.
|
||||
if root.data.chromatic: SliderRow {
|
||||
label: "Hue";
|
||||
hint: "Which colour the mask follows. Skin sits just past red.";
|
||||
value: root.data.hue * 360;
|
||||
default-value: 22;
|
||||
minimum: 0;
|
||||
maximum: 360;
|
||||
changed(v) => { root.hue-changed(v / 360, root.data.hue-width); }
|
||||
reset => { root.hue-changed(0.06, root.data.hue-width); }
|
||||
}
|
||||
|
||||
if root.data.chromatic: SliderRow {
|
||||
label: "Spread";
|
||||
hint: "How far either side of that colour the mask reaches.";
|
||||
value: root.data.hue-width * 360;
|
||||
default-value: 18;
|
||||
minimum: 0;
|
||||
// Half the circle each way is the whole of it, so this is the
|
||||
// widest arc that is still a selection.
|
||||
maximum: 180;
|
||||
changed(v) => { root.hue-changed(root.data.hue, v / 360); }
|
||||
reset => { root.hue-changed(root.data.hue, 0.05); }
|
||||
}
|
||||
|
||||
if root.data.ranged: SliderRow {
|
||||
// Named by what the end of the band *is* rather than by
|
||||
// "from", which says nothing on its own and says the wrong
|
||||
// thing on the colour range, where the axis is not brightness.
|
||||
label: root.data.chromatic ? "Least colourful" : "Darkest";
|
||||
value: root.data.band-lo * 100;
|
||||
default-value: root.data.chromatic ? 15 : 50;
|
||||
minimum: 0;
|
||||
maximum: 100;
|
||||
changed(v) => {
|
||||
root.band-changed(v / 100, root.data.band-hi, root.data.band-softness);
|
||||
}
|
||||
reset => {
|
||||
root.band-changed(root.data.chromatic ? 0.15 : 0.5,
|
||||
root.data.band-hi,
|
||||
root.data.band-softness);
|
||||
}
|
||||
}
|
||||
|
||||
if root.data.ranged: SliderRow {
|
||||
label: root.data.chromatic ? "Most colourful" : "Brightest";
|
||||
value: root.data.band-hi * 100;
|
||||
default-value: 100;
|
||||
minimum: 0;
|
||||
maximum: 100;
|
||||
changed(v) => {
|
||||
root.band-changed(root.data.band-lo, v / 100, root.data.band-softness);
|
||||
}
|
||||
reset => {
|
||||
root.band-changed(root.data.band-lo, 1.0, root.data.band-softness);
|
||||
}
|
||||
}
|
||||
|
||||
if root.data.ranged: SliderRow {
|
||||
label: "Softness";
|
||||
hint: "How gradually the mask fades in at each end of the "
|
||||
+ "range. At zero it is a threshold, and a threshold "
|
||||
+ "over a photograph's own tones draws contour lines "
|
||||
+ "across a smooth sky.";
|
||||
value: root.data.band-softness * 100;
|
||||
default-value: 15;
|
||||
minimum: 0;
|
||||
maximum: 100;
|
||||
changed(v) => {
|
||||
root.band-changed(root.data.band-lo, root.data.band-hi, v / 100);
|
||||
}
|
||||
reset => {
|
||||
root.band-changed(root.data.band-lo, root.data.band-hi, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// The edge. All of it reads one signed distance field, so these
|
||||
// are free to move — none of them recomputes anything except a
|
||||
// compound morphology.
|
||||
@@ -344,8 +454,12 @@ export component MaskPanel inherits Rectangle {
|
||||
callback mask-falloff-picked(string, int);
|
||||
callback mask-morphology-picked(string, int);
|
||||
callback mask-morph-radius-changed(string, float);
|
||||
callback mask-band-changed(string, float, float, float);
|
||||
callback mask-hue-changed(string, float, float);
|
||||
|
||||
callback add-gradient(bool);
|
||||
/// `true` asks for the colour range rather than the brightness one.
|
||||
callback add-range(bool);
|
||||
callback add-subject(int);
|
||||
callback add-category(string);
|
||||
|
||||
@@ -577,6 +691,35 @@ export component MaskPanel inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Ranges, on their own row rather than beside the gradients. A
|
||||
// gradient answers "where" and these answer "what", and putting four
|
||||
// buttons in one row would suggest four flavours of the same thing —
|
||||
// where the difference between them is the only thing a photographer
|
||||
// needs to understand to reach for the right one.
|
||||
//
|
||||
// "Tone" and "Colour", not "luminance range" and "colour range". What
|
||||
// the photographer is choosing is which property the mask follows.
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Button {
|
||||
text: "Tone";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.add-range(false); }
|
||||
}
|
||||
Button {
|
||||
text: "Colour";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.add-range(true); }
|
||||
}
|
||||
}
|
||||
|
||||
if root.enabled: Caption {
|
||||
text: "A tone or colour mask follows the photograph's own values, "
|
||||
+ "so the edit blends where the picture does.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if root.enabled && root.masks.length == 0: Caption {
|
||||
text: "No local adjustments. Select a region, or add a gradient.";
|
||||
wrap: word-wrap;
|
||||
@@ -597,6 +740,8 @@ export component MaskPanel inherits Rectangle {
|
||||
falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); }
|
||||
morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); }
|
||||
morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); }
|
||||
band-changed(lo, hi, soft) => { root.mask-band-changed(mask.id, lo, hi, soft); }
|
||||
hue-changed(h, w) => { root.mask-hue-changed(mask.id, h, w); }
|
||||
}
|
||||
|
||||
// No caption saying which chain the sliders below are pointed at.
|
||||
|
||||
Reference in New Issue
Block a user