A mask the model draws arrives approximately right — stopping inside a shoulder, leaking into the hair — and FR-DEV-3's edge controls move the *whole* boundary, so no value of feather or dilation fixes two errors that go opposite ways. What fixes them is a second selection joined to the first, and a layer that held exactly one source had nowhere to put one. The brush the core has had all along was reachable from no control in the application. A layer is now an ordered list of parts. Each names a source and how it joins the mask before it — added to it, or taken out of it — and carries its own edge treatment, because a model's soft coverage and a stroke painted where it stopped short do not want the same feather. Invert and opacity stay on the layer, where the composed shader already reads them. The sidecar grows `[part]` blocks and nothing else. A layer of one part writes exactly the bytes it always did; a mask block with no part blocks after it reads back as one part; and a stroke, a join or a source this build cannot read costs that part rather than the layer. So every sidecar in every library still parses to the edit it always was. On the device the parts fold into the layer's one slice, so eight layers still cost eight channels: union is a `max` blend and subtraction is the erase blend the brush already used. A part is drawn into a scratch texture before it is joined, and that is not incidental — an erase stroke means a hole in *that part*, not a hole in the mask, and drawn straight onto the accumulator it would punch through the subject underneath. A layer of one part skips all of it and takes the path it always took. In the interface: a part list under the selected layer with a chip saying which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip with the brush's size, hardness and flow, and a drag on the photograph that paints. Pressing Paint on a mask that cannot hold a stroke joins a part that can, rather than explaining that a subject is not a brush. A whole stroke is one step in the history. The edge controls now shape the part that is selected rather than the layer, which is the one behaviour change to an existing control: with a correction selected, the feather slider softens the correction and leaves the model's mask alone.
350 lines
11 KiB
Rust
350 lines
11 KiB
Rust
//! Every path that produces pixels must apply the masks.
|
|
//!
|
|
//! The display, the export and the thumbnail run the same generated shader,
|
|
//! and that shader always emits a block per active mask layer. Bind the empty
|
|
//! placeholder instead of the real array and every one of those blocks
|
|
//! multiplies by zero: the local adjustments are simply absent, with no error
|
|
//! and no warning.
|
|
//!
|
|
//! That is what happened — exports and thumbnails both took the unmasked
|
|
//! path — and it is invisible from inside either one. The only way to see it
|
|
//! is to render the same edit twice and compare, which is what this does.
|
|
|
|
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass, SubjectMasks};
|
|
use dr_pipeline::descriptor::ParamId;
|
|
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
|
use dr_pipeline::operation::compose_full;
|
|
use dr_pipeline::spot::SpotSet;
|
|
use dr_pipeline::{ops, Framing};
|
|
use dr_segment::Shaped;
|
|
use dr_types::ColourSpace;
|
|
|
|
const PROXY: u32 = 24;
|
|
|
|
fn ctx() -> Option<GpuContext> {
|
|
pollster::block_on(GpuContext::new_headless()).ok()
|
|
}
|
|
|
|
fn grey(ctx: &GpuContext, size: u32) -> DemosaicedImage {
|
|
let data: Vec<u8> = (0..size * size)
|
|
.flat_map(|_| [128, 128, 128, 255])
|
|
.collect();
|
|
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
|
}
|
|
|
|
/// Coverage over the left half, as a detection would produce.
|
|
fn left_half() -> Vec<u8> {
|
|
(0..PROXY * PROXY)
|
|
.map(|i| if i % PROXY < PROXY / 2 { 255 } else { 0 })
|
|
.collect()
|
|
}
|
|
|
|
fn brightening_stack() -> MaskStack {
|
|
let mut stack = MaskStack::new();
|
|
let mut layer = MaskLayer::new(
|
|
"m1",
|
|
MaskSource::Subject {
|
|
signature: 1,
|
|
index: 0,
|
|
class: "person".into(),
|
|
score: 0.9,
|
|
},
|
|
);
|
|
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
|
layer.base_mut().feather = 0.0;
|
|
stack.push(layer);
|
|
stack
|
|
}
|
|
|
|
/// Render `stack` at `out` pixels, exactly as the session's shared helper
|
|
/// does: fields, array, then a masked render.
|
|
fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
|
let source = grey(ctx, 64);
|
|
|
|
let coverage = left_half();
|
|
let fields: Vec<Vec<f32>> = stack
|
|
.active()
|
|
.map(|_| {
|
|
Shaped::build(
|
|
&coverage,
|
|
PROXY as usize,
|
|
PROXY as usize,
|
|
128,
|
|
dr_segment::Morphology::None,
|
|
0.0,
|
|
)
|
|
.distance
|
|
})
|
|
.collect();
|
|
let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect();
|
|
let subjects = SubjectMasks::upload(ctx, &refs, PROXY, PROXY).expect("upload");
|
|
|
|
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
|
let array = masks
|
|
.render(stack, None, Some(&subjects), None, PROXY, PROXY)
|
|
.expect("rasterise");
|
|
|
|
let shader = compose_full(
|
|
&ops::chain(),
|
|
&Framing::new(),
|
|
ColourSpace::Srgb,
|
|
stack,
|
|
&SpotSet::new(),
|
|
&[],
|
|
);
|
|
let mut adjust = AdjustPass::new(ctx);
|
|
adjust
|
|
.render_masked(&source, &shader, out, out, Some(array))
|
|
.expect("render");
|
|
adjust.export_pixels().expect("readback").0
|
|
}
|
|
|
|
/// The same edit rendered *without* the array bound — what export and
|
|
/// thumbnail were doing.
|
|
fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
|
let source = grey(ctx, 64);
|
|
let shader = compose_full(
|
|
&ops::chain(),
|
|
&Framing::new(),
|
|
ColourSpace::Srgb,
|
|
stack,
|
|
&SpotSet::new(),
|
|
&[],
|
|
);
|
|
let mut adjust = AdjustPass::new(ctx);
|
|
adjust.render(&source, &shader, out, out).expect("render");
|
|
adjust.export_pixels().expect("readback").0
|
|
}
|
|
|
|
fn luma(pixels: &[u8], size: u32, x: u32, y: u32) -> u8 {
|
|
pixels[((y * size + x) * 4) as usize]
|
|
}
|
|
|
|
/// The fault itself, stated as a test: the two paths must not agree.
|
|
///
|
|
/// If binding the array made no difference, the masks would not be reaching
|
|
/// the shader at all — which is precisely the bug this file exists for.
|
|
#[test]
|
|
fn binding_the_masks_changes_the_result() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
let stack = brightening_stack();
|
|
const OUT: u32 = 64;
|
|
|
|
let masked = render_at(&ctx, &stack, OUT);
|
|
let unmasked = render_unmasked(&ctx, &stack, OUT);
|
|
|
|
let left_masked = luma(&masked, OUT, 8, 32);
|
|
let left_unmasked = luma(&unmasked, OUT, 8, 32);
|
|
|
|
assert!(
|
|
left_masked > left_unmasked + 40,
|
|
"with the array bound the masked half must brighten: {left_masked} \
|
|
against {left_unmasked}"
|
|
);
|
|
assert!(
|
|
(120..=136).contains(&left_unmasked),
|
|
"and without it the layer contributes nothing at all, silently: \
|
|
{left_unmasked}"
|
|
);
|
|
}
|
|
|
|
/// A thumbnail is the same edit at a small size, so it must carry the same
|
|
/// local adjustments. This is the reported bug.
|
|
#[test]
|
|
fn a_thumbnail_sized_render_still_carries_its_masks() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
let stack = brightening_stack();
|
|
|
|
for out in [16u32, 32, 64, 128] {
|
|
let pixels = render_at(&ctx, &stack, out);
|
|
let inside = luma(&pixels, out, out / 8, out / 2);
|
|
let outside = luma(&pixels, out, out - out / 8 - 1, out / 2);
|
|
|
|
assert!(
|
|
inside > outside + 40,
|
|
"at {out}px the masked half should be brighter: {inside} against \
|
|
{outside}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One mask array, every output size. The array is rasterised in source space
|
|
/// and sampled through the framing map, so a thumbnail and a full-size export
|
|
/// must reach the same *proportion* of the frame — not merely both be
|
|
/// non-empty.
|
|
#[test]
|
|
fn the_mask_covers_the_same_fraction_at_every_size() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
let stack = brightening_stack();
|
|
|
|
let fraction = |out: u32| {
|
|
let pixels = render_at(&ctx, &stack, out);
|
|
let lit = (0..out * out)
|
|
.filter(|i| pixels[(*i as usize) * 4] > 150)
|
|
.count();
|
|
lit as f32 / (out * out) as f32
|
|
};
|
|
|
|
let small = fraction(32);
|
|
let large = fraction(128);
|
|
assert!(
|
|
(small - large).abs() < 0.05,
|
|
"the same edit should mask the same share of the frame at any size: \
|
|
{small:.3} at 32px against {large:.3} at 128px"
|
|
);
|
|
assert!(
|
|
(0.4..0.6).contains(&small),
|
|
"and that share is the left half: {small:.3}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gradient geometry
|
|
//
|
|
// The mask is rasterised over the source frame, whose two axes are not the
|
|
// same length. A gradient measured in raw 0..1 fractions therefore means a
|
|
// different distance horizontally than vertically — so a circle comes out an
|
|
// ellipse and an angle is not the angle asked for. Neither shows in the stored
|
|
// numbers, and both are what a photographer is looking straight at while
|
|
// dragging a handle.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A flat grey frame at an arbitrary shape, brightened wherever `stack` covers.
|
|
fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u8> {
|
|
let data: Vec<u8> = (0..w * h).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
|
let source = DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload");
|
|
|
|
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
|
let array = masks
|
|
.render(stack, None, None, None, w, h)
|
|
.expect("rasterise");
|
|
|
|
let shader = compose_full(
|
|
&ops::chain(),
|
|
&Framing::new(),
|
|
ColourSpace::Srgb,
|
|
stack,
|
|
&SpotSet::new(),
|
|
&[],
|
|
);
|
|
let mut adjust = AdjustPass::new(ctx);
|
|
adjust
|
|
.render_masked(&source, &shader, w, h, Some(array))
|
|
.expect("render");
|
|
adjust.export_pixels().expect("readback").0
|
|
}
|
|
|
|
fn brightened(pixels: &[u8], w: u32, x: u32, y: u32) -> bool {
|
|
pixels[((y * w + x) * 4) as usize] > 160
|
|
}
|
|
|
|
fn gradient(source: MaskSource) -> MaskStack {
|
|
let mut stack = MaskStack::new();
|
|
let mut layer = MaskLayer::new("g1", source);
|
|
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
|
stack.push(layer);
|
|
stack
|
|
}
|
|
|
|
/// A radial with equal radii must be round on the screen, not on the numbers.
|
|
#[test]
|
|
fn a_radial_with_equal_radii_is_a_circle_on_a_wide_frame() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
// 3:2. On a square frame this test cannot fail, which is why it is not one.
|
|
const W: u32 = 96;
|
|
const H: u32 = 64;
|
|
|
|
// 0.3 of the frame's height. In pixels that is 19 either way — the x axis
|
|
// spans 0..1.5 in the same units, so the fraction is smaller and the
|
|
// distance is the same.
|
|
let pixels = render_gradient(
|
|
&ctx,
|
|
&gradient(MaskSource::Radial {
|
|
centre: (0.5, 0.5),
|
|
radii: (0.3, 0.3),
|
|
angle: 0.0,
|
|
// Hard, so "covered" is a question with an answer rather than a
|
|
// ramp to pick a threshold out of.
|
|
feather: 0.0,
|
|
}),
|
|
W,
|
|
H,
|
|
);
|
|
|
|
let (cx, cy) = (W / 2, H / 2);
|
|
assert!(brightened(&pixels, W, cx, cy), "the centre must be covered");
|
|
|
|
for (dx, dy, what) in [(15u32, 0u32, "right"), (0, 15, "down")] {
|
|
assert!(
|
|
brightened(&pixels, W, cx + dx, cy + dy),
|
|
"15px {what} of centre is inside a 19px radius"
|
|
);
|
|
}
|
|
for (dx, dy, what) in [(24u32, 0u32, "right"), (0, 24, "down")] {
|
|
assert!(
|
|
!brightened(&pixels, W, cx + dx, cy + dy),
|
|
"24px {what} of centre is outside it — before the aspect \
|
|
correction the horizontal reach was 28px and this passed only \
|
|
downwards"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// And a ramp at 45° must be at 45° where the photographer sees it.
|
|
#[test]
|
|
fn a_diagonal_ramp_runs_at_the_angle_it_was_given() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no adapter; skipping");
|
|
return;
|
|
};
|
|
const W: u32 = 96;
|
|
const H: u32 = 64;
|
|
|
|
let pixels = render_gradient(
|
|
&ctx,
|
|
&gradient(MaskSource::Linear {
|
|
centre: (0.5, 0.5),
|
|
angle: std::f32::consts::FRAC_PI_4,
|
|
width: 0.0,
|
|
}),
|
|
W,
|
|
H,
|
|
);
|
|
|
|
// Coverage increases along (cos 45°, sin 45°), so the half-way line runs
|
|
// from lower-left to upper-right through the centre: equal steps right and
|
|
// down stay on the covered side, and the two sides of it disagree.
|
|
let (cx, cy) = (W / 2, H / 2);
|
|
assert!(
|
|
brightened(&pixels, W, cx + 16, cy + 16),
|
|
"down and to the right of the line is inside"
|
|
);
|
|
assert!(
|
|
!brightened(&pixels, W, cx - 16, cy - 16),
|
|
"up and to the left of it is outside"
|
|
);
|
|
// The diagonal itself, sampled a few pixels either side. Without the
|
|
// aspect correction the line comes out at 34 degrees and both of these
|
|
// land on the same side of it.
|
|
assert!(
|
|
brightened(&pixels, W, cx + 18, cy - 14),
|
|
"just below the 45 degree line is inside"
|
|
);
|
|
assert!(
|
|
!brightened(&pixels, W, cx + 14, cy - 18),
|
|
"just above it is not"
|
|
);
|
|
}
|