Show the photographer the mask they are shaping
Nobody can refine an edge they are not being shown. The only thing drawn on the canvas was the region overlay — a false-coloured picture of what the model *detected* — which knows nothing of a layer's feather, its falloff, its morphology, its invert or its opacity, and nothing at all about a gradient, a range or a stroke. Every control added for mask editing therefore acted on something invisible, which is why the whole feature reads as absent rather than as unfinished. A layer's finished mask now draws over the photograph in one of three styles: a tint for whether the right thing is selected, an alpha for where the edge is, an outline for whether that edge is registered against the detail the other two hide. The hard part is not the shader. A selection with no adjustment on it changes no pixel, so it is not active, so it holds no slice of the mask array and is never rasterised — and that is exactly the layer somebody wants to look at, for the whole of the time between choosing a subject and deciding what to do to it. So `MaskStack::rendered` is `active()` plus the layer being looked at, and the rasteriser, the composer and the distance-field builder all index by position in it. Which is also why the design's "two uniforms, no recompile" is not available: a uniform can select a slot, it cannot conjure one. The reveal is never on the graph. It reaches the pipeline as an argument to `compose_revealing`, and `compose_for` — which the exporter, the thumbnail and the neutral probe all call — has no way to ask for one. A flag on the graph would have been shorter, would have type-checked, and would have been one forgotten reset away from a red tint baked into an exported file. And the tools that shape a mask now arm. `Masking.tool` is an `in` property only Rust may write, and the handler wrote nothing back, so the strip reported "Select" however many times Paint was pressed and the paint area was never enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no control in the application. The region overlay stands down while a mask is being shown, and its button now says what it hides: two overlays that look alike and mean different things is worse than either.
This commit is contained in:
+28
-2
@@ -708,10 +708,36 @@ impl MaskPass {
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<&MaskArray, GpuError> {
|
||||
self.render_revealing(stack, labels, subjects, source, width, height, None)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// [`Self::render`], also drawing the layer being looked at.
|
||||
///
|
||||
/// A selection with no adjustment on it changes no pixel, so it is not
|
||||
/// active and has no slice — which is right until somebody asks to *see*
|
||||
/// it, and that is the state a photographer is in from choosing a subject
|
||||
/// until deciding what to do to it.
|
||||
///
|
||||
/// `reveal` has to be the same one the shader was composed with and the
|
||||
/// same one the distance fields were built for: all three index this array
|
||||
/// by position in [`MaskStack::rendered`], and two of them disagreeing
|
||||
/// shows as an adjustment applied through another layer's mask.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_revealing(
|
||||
&mut self,
|
||||
stack: &MaskStack,
|
||||
labels: Option<&LabelField>,
|
||||
subjects: Option<&SubjectMasks>,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
reveal: Option<&dr_pipeline::mask::Reveal>,
|
||||
) -> Result<&MaskArray, GpuError> {
|
||||
// At least one layer, because a zero-layer texture array is invalid
|
||||
// and the shader binds this slot unconditionally.
|
||||
let active = stack.active_count().clamp(1, MAX_LAYERS) as u32;
|
||||
let active = stack.rendered_count(reveal).clamp(1, MAX_LAYERS) as u32;
|
||||
self.ensure_array(width, height, active)?;
|
||||
|
||||
let mut encoder = self
|
||||
@@ -721,7 +747,7 @@ impl MaskPass {
|
||||
label: Some("mask-encoder"),
|
||||
});
|
||||
|
||||
for (slot, layer) in stack.active().enumerate().take(MAX_LAYERS) {
|
||||
for (slot, layer) in stack.rendered(reveal).enumerate().take(MAX_LAYERS) {
|
||||
// **The path a mask with one part takes is the path every mask
|
||||
// took before parts existed**: drawn straight into the layer's
|
||||
// slice, cleared by the draw itself. Nothing about an unedited
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Reveal, RevealStyle};
|
||||
use dr_pipeline::operation::{compose_full, compose_full_revealing};
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, EditGraph, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
@@ -789,3 +789,198 @@ fn the_order_parts_are_joined_in_is_the_mask() {
|
||||
"and subtracting after an addition takes it away again"
|
||||
);
|
||||
}
|
||||
|
||||
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
|
||||
|
||||
/// A radial that covers the middle of the frame and nothing near the corners.
|
||||
fn middle() -> MaskSource {
|
||||
MaskSource::Radial {
|
||||
centre: (0.5, 0.5),
|
||||
radii: (0.3, 0.3),
|
||||
angle: 0.0,
|
||||
feather: 0.05,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`render`], with one layer's mask drawn over the result.
|
||||
fn render_revealing(ctx: &GpuContext, stack: &MaskStack, reveal: &Reveal) -> Vec<u8> {
|
||||
let source = grey(ctx);
|
||||
let shader = compose_full_revealing(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
Some(reveal),
|
||||
);
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render_revealing(stack, None, None, None, SIZE, SIZE, Some(reveal))
|
||||
.expect("rasterise");
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// The state every mask is in for its first few seconds: chosen, and not yet
|
||||
/// used for anything.
|
||||
///
|
||||
/// Such a layer changes no pixel, so it is not active, so it occupied no mask
|
||||
/// slot and was never rasterised — and the reveal drew nothing. That is the
|
||||
/// whole of "I clicked the category and nothing happened": there was a mask,
|
||||
/// and no way to see that there was.
|
||||
#[test]
|
||||
fn a_selection_with_no_adjustment_can_still_be_seen() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
assert!(
|
||||
stack.is_neutral(),
|
||||
"the fixture must be a selection with nothing done to it"
|
||||
);
|
||||
|
||||
let pixels = render_revealing(
|
||||
&ctx,
|
||||
&stack,
|
||||
&Reveal {
|
||||
layer: "m1".into(),
|
||||
style: RevealStyle::Alpha,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2) > 200,
|
||||
"the middle is inside the mask and should read white"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 1, 1) < 40,
|
||||
"the corner is outside it and should read black"
|
||||
);
|
||||
}
|
||||
|
||||
/// And with nobody looking, the same stack changes nothing at all.
|
||||
///
|
||||
/// The other half of the property above: a layer renders *because* it is being
|
||||
/// revealed, so it must stop when the reveal does — otherwise a selection with
|
||||
/// no adjustment would leave a slice in the array for ever.
|
||||
#[test]
|
||||
fn a_mask_nobody_is_looking_at_draws_nothing() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render(&ctx, &stack, None);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2),
|
||||
128,
|
||||
"flat grey, exactly as it went in"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// A tint has to leave the photograph visible, or it cannot be judged against
|
||||
/// it — which is the one thing an overlay exists for.
|
||||
#[test]
|
||||
fn a_tint_colours_the_mask_and_leaves_the_rest_alone() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render_revealing(
|
||||
&ctx,
|
||||
&stack,
|
||||
&Reveal {
|
||||
layer: "m1".into(),
|
||||
style: RevealStyle::Tint,
|
||||
},
|
||||
);
|
||||
|
||||
let at = |x: u32, y: u32| {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
(pixels[i], pixels[i + 1], pixels[i + 2])
|
||||
};
|
||||
|
||||
let (r, g, _) = at(SIZE / 2, SIZE / 2);
|
||||
assert!(r > g + 40, "the mask should read red, got r={r} g={g}");
|
||||
assert!(
|
||||
g > 20,
|
||||
"and not opaque — the photograph under it is what the tint is judged \
|
||||
against, got g={g}"
|
||||
);
|
||||
|
||||
let (r, g, b) = at(1, 1);
|
||||
assert!(
|
||||
(120..=136).contains(&r) && r == g && g == b,
|
||||
"outside the mask the photograph is untouched, got ({r}, {g}, {b})"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// An outline draws where the mask stops and nowhere else — which is the
|
||||
/// point of it, since the other two styles cover the detail the boundary has
|
||||
/// to be judged against.
|
||||
#[test]
|
||||
fn an_outline_draws_the_boundary_and_not_the_interior() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(MaskLayer::new("m1", middle()));
|
||||
|
||||
let pixels = render_revealing(
|
||||
&ctx,
|
||||
&stack,
|
||||
&Reveal {
|
||||
layer: "m1".into(),
|
||||
style: RevealStyle::Edge,
|
||||
},
|
||||
);
|
||||
|
||||
// Where the line landed, along the row through the centre. Searched
|
||||
// rather than sampled at one place: the radial's edge crosses this row
|
||||
// about 9.6 pixels out from the middle on a 32px frame, and asserting a
|
||||
// particular pixel would be asserting the rounding.
|
||||
let (at, brightest) = (SIZE / 2..SIZE)
|
||||
.map(|x| (x, luma_at(&pixels, x, SIZE / 2)))
|
||||
.max_by_key(|&(_, v)| v)
|
||||
.expect("the row is not empty");
|
||||
|
||||
assert!(
|
||||
brightest > 160,
|
||||
"there should be a line somewhere on this row, brightest was {brightest}"
|
||||
);
|
||||
assert!(
|
||||
(SIZE / 2 + 7..=SIZE / 2 + 12).contains(&at),
|
||||
"and it should be on the mask's boundary, not somewhere else: x={at}"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, SIZE / 2, SIZE / 2),
|
||||
128,
|
||||
"the picture inside the mask is untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 1, 1),
|
||||
128,
|
||||
"and so is the picture outside it"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user