Measure the distance to the edge, and get four controls for one transform
Feathering, growing, shrinking, closing and opening are the same number read differently. With the signed distance from the boundary in hand, dilation is the set where d >= -r, erosion where d >= +r, and a feather of any shape is a function of d. So the field is computed once and the controls are arithmetic on it. The **field** is what reaches the GPU, not a finished alpha, and that is the point: growing a mask or changing its falloff then costs a uniform upload and no recomputation, which is what makes them live controls rather than ones that stall on every drag. Only closing and opening rebuild, because after the first threshold the shape has changed and the old distances describe the old one. Exact Euclidean, via Felzenszwalb's separable transform — not a chamfer approximation, which leaves a mask visibly octagonal once grown more than a few pixels. A test asserts the diagonal is √2 rather than 1 or 2. It runs on the CPU, which ARCH §5.4 forbids for masks. The rule is about brush lag — a stroke rasterised per frame — and this is a different operation: once per mask edit, on input the model already produced here, producing a field the GPU then samples for free. What it buys is exact determinism, which matters because masks reach the sidecar as indices and a field that varied by vendor would mean a mask meaning one thing on the desktop and another on the phone. The half-pixel in `signed_distance` is not a detail, and a test caught it. Measuring to the nearest opposite pixel *centre* puts the smallest magnitude at 1 either side, so the boundary is nowhere and **eroding by less than a pixel removes nothing**. A control whose first notch does nothing is a broken control. Half a pixel off each side puts the boundary where it physically is, and eroding by 1 takes exactly the outermost ring. Every falloff curve is 0.5 at the boundary by construction, asserted for all five: changing the curve should change how the transition looks and never where it sits.
This commit is contained in:
@@ -30,10 +30,10 @@ use dr_gpu::{
|
||||
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, SubjectMasks,
|
||||
};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::{ops, EditGraph, Framing};
|
||||
use dr_segment::{SemanticModel, SemanticOptions};
|
||||
use dr_segment::{SemanticModel, SemanticOptions, Shaped};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// Longest edge the mask and the model work at.
|
||||
@@ -127,7 +127,6 @@ fn main() {
|
||||
.iter()
|
||||
.map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||||
.collect();
|
||||
let subjects = SubjectMasks::upload(&ctx, &[&alpha], pw, ph).expect("upload subject");
|
||||
|
||||
let (ow, oh) = fit(sw, sh, OUT);
|
||||
let mut masks = MaskPass::new(&ctx).expect("mask pass");
|
||||
@@ -153,7 +152,7 @@ fn main() {
|
||||
drain.feather = 0.02;
|
||||
pop.push(drain);
|
||||
|
||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &pop, &subjects, pw, ph, ow, oh);
|
||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &pop, &alpha, pw, ph, ow, oh);
|
||||
write(&format!("{prefix}-colour-pop.ppm"), &adjust);
|
||||
|
||||
// ---- 2. lift the subject out of its background ------------------------
|
||||
@@ -171,15 +170,39 @@ fn main() {
|
||||
darker.feather = 0.03;
|
||||
lift.push(darker);
|
||||
|
||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &lift, &subjects, pw, ph, ow, oh);
|
||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &lift, &alpha, pw, ph, ow, oh);
|
||||
write(&format!("{prefix}-subject-lift.ppm"), &adjust);
|
||||
|
||||
// ---- 3. the same edit, grown and shrunk -------------------------------
|
||||
//
|
||||
// The model's outline is approximately right and slightly soft, so the
|
||||
// everyday correction is to move it: grow to catch a halo the detector
|
||||
// stopped short of, shrink to pull off one it caught. Both are a threshold
|
||||
// of the distance field, which is why they cost a uniform.
|
||||
for (name, morphology, radius) in [
|
||||
("grown", Morphology::Dilate, 0.012),
|
||||
("shrunk", Morphology::Erode, 0.012),
|
||||
] {
|
||||
let mut stack = MaskStack::new();
|
||||
let mut layer = subject_layer("m1", index, subject);
|
||||
layer.invert = true;
|
||||
layer.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
layer.feather = 0.004;
|
||||
layer.morphology = morphology;
|
||||
layer.morph_radius = radius;
|
||||
stack.push(layer);
|
||||
|
||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &stack, &alpha, pw, ph, ow, oh);
|
||||
write(&format!("{prefix}-{name}.ppm"), &adjust);
|
||||
}
|
||||
|
||||
// ---- the mask itself, to check the outline ----------------------------
|
||||
write_mask(&format!("{prefix}-mask.ppm"), &alpha, pw, ph);
|
||||
|
||||
println!("\nwrote {prefix}-original.ppm");
|
||||
println!(" {prefix}-colour-pop.ppm");
|
||||
println!(" {prefix}-subject-lift.ppm");
|
||||
println!(" {prefix}-grown.ppm, {prefix}-shrunk.ppm");
|
||||
println!(" {prefix}-mask.ppm");
|
||||
}
|
||||
|
||||
@@ -221,18 +244,44 @@ fn render_stack(
|
||||
masks: &mut MaskPass,
|
||||
adjust: &mut AdjustPass,
|
||||
stack: &MaskStack,
|
||||
subjects: &SubjectMasks,
|
||||
coverage: &[u8],
|
||||
pw: u32,
|
||||
ph: u32,
|
||||
ow: u32,
|
||||
oh: u32,
|
||||
) {
|
||||
let _ = ctx;
|
||||
// One signed distance field per active layer, in that order — the order
|
||||
// the rasteriser indexes them by. Built here rather than once up front
|
||||
// because a compound morphology rebuilds the field, so it belongs to the
|
||||
// layer that shaped it rather than to the object.
|
||||
let fields: Vec<Vec<f32>> = stack
|
||||
.active()
|
||||
.map(|layer| {
|
||||
Shaped::build(
|
||||
coverage,
|
||||
pw as usize,
|
||||
ph as usize,
|
||||
128,
|
||||
match layer.morphology {
|
||||
Morphology::None => dr_segment::Morphology::None,
|
||||
Morphology::Dilate => dr_segment::Morphology::Dilate,
|
||||
Morphology::Erode => dr_segment::Morphology::Erode,
|
||||
Morphology::Close => dr_segment::Morphology::Close,
|
||||
Morphology::Open => dr_segment::Morphology::Open,
|
||||
},
|
||||
layer.morph_radius * pw.min(ph) as f32,
|
||||
)
|
||||
.distance
|
||||
})
|
||||
.collect();
|
||||
let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect();
|
||||
let subjects = SubjectMasks::upload(ctx, &refs, pw, ph).expect("upload fields");
|
||||
|
||||
// Rasterised at *proxy* size in source space, then sampled by the shader
|
||||
// after the framing map — which is what makes one mask correct at every
|
||||
// output size, zoom and crop.
|
||||
let array = masks
|
||||
.render(stack, None, Some(subjects), pw, ph)
|
||||
.render(stack, None, Some(&subjects), pw, ph)
|
||||
.expect("rasterise masks");
|
||||
|
||||
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
|
||||
|
||||
Reference in New Issue
Block a user