From e13d3a54fc379685cef6522dd641d0c13ed55f87 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 6 Sep 2026 20:00:56 +0200 Subject: [PATCH] Watch the mask tools work, rather than reading that they do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A still frame cannot show what makes these tools right or wrong. What matters is how the mask *moves*: whether a stroke lands where the finger went, whether a subtraction takes away only what it covers, whether an erase inside a correction punches through the selection underneath. Every one of those is a sequence, and the test suite asserts single pixels. So this renders the sequences. A synthetic photograph, one frame per step of each mode — painting, erasing, joining a part and taking it out again, inverting, and sweeping the edge controls — as PPM, which ffmpeg turns into a GIF in one line. It runs headless, needs no RAW and no model, and takes a few seconds. It is also the honest answer to "show me it working" while the tools are still being wired to a finger: this is the pipeline itself, not a mock-up of it, and a fault in the fold shows here as a frame that looks wrong. --- core/dr-gpu/examples/mask_modes.rs | 279 +++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 core/dr-gpu/examples/mask_modes.rs diff --git a/core/dr-gpu/examples/mask_modes.rs b/core/dr-gpu/examples/mask_modes.rs new file mode 100644 index 0000000..6dbedd0 --- /dev/null +++ b/core/dr-gpu/examples/mask_modes.rs @@ -0,0 +1,279 @@ +//! Every way of editing a mask, as frames you can watch. +//! +//! The mask tools are hard to review from a still: what makes them right is +//! how the mask *moves* as a stroke is painted, as a correction is subtracted, +//! as an edge is shaped. This renders that — a synthetic photograph and one +//! frame per step of each mode — so the pipeline's behaviour can be watched +//! before any of it is wired to a finger. +//! +//! ```sh +//! cargo run -p dr-gpu --example mask_modes --release -- out +//! ffmpeg -y -framerate 12 -i out/paint-%03d.ppm out/paint.gif +//! ``` +//! +//! PPM for the reason every other example here writes it: no encoder +//! dependency, and ffmpeg, ImageMagick and every viewer read it. +//! +//! # What it is really showing +//! +//! The fold that builds a layer's mask (`MaskPass::render`), through the +//! composed shader that samples it. A part drawn in the wrong order, a +//! subtraction that took the base with it, an erase stroke that punched +//! through the selection underneath — each of those is a frame here that looks +//! wrong, and none of them is visible in a single rendered still. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass}; +use dr_pipeline::descriptor::ParamId; +use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Morphology}; +use dr_pipeline::operation::compose_full; +use dr_pipeline::spot::SpotSet; +use dr_pipeline::{ops, Framing}; +use dr_types::ColourSpace; + +const W: u32 = 480; +const H: u32 = 320; + +fn main() { + env_logger::init(); + let dir = std::env::args().nth(1).unwrap_or_else(|| "out".into()); + std::fs::create_dir_all(&dir).expect("output directory"); + + let Some(ctx) = pollster::block_on(GpuContext::new_headless()).ok() else { + eprintln!("no adapter; nothing to render"); + return; + }; + + let source = DemosaicedImage::from_rgba8(&ctx, &scene(), W, H).expect("upload"); + let mut masks = MaskPass::new(&ctx).expect("mask pass"); + let mut adjust = AdjustPass::new(&ctx); + let mut shot = Shot { + source: &source, + masks: &mut masks, + adjust: &mut adjust, + dir: &dir, + }; + + write_ppm(&format!("{dir}/original.ppm"), &to_rgb(&scene()), W, H); + + paint(&mut shot); + erase(&mut shot); + subtract(&mut shot); + invert(&mut shot); + shape(&mut shot); + + println!("frames in {dir}/"); +} + +/// One layer, brightened hard, so the mask is legible rather than tasteful. +fn lifted(source: MaskSource) -> MaskLayer { + let mut layer = MaskLayer::new("m1", source); + layer.set_param("exposure", ParamId("exposure"), 1.6); + layer.set_param("saturation", ParamId("vibrance"), 0.6); + layer +} + +/// A stroke painted from left to right across the subject, one frame per dab. +/// +/// Each frame is the whole mask rasterised again, which is what the +/// application does today on every shape change — so the frames are also a +/// crude answer to "is a stroke's cost growing as it is painted". +fn paint(shot: &mut Shot) { + let mut layer = lifted(MaskSource::brush()); + layer.begin_stroke(0, false, 0.13, 0.5, 1.0); + for (i, x) in steps(0.18, 0.82, 28).enumerate() { + layer.extend_stroke(0, x, 0.52 + 0.06 * (x * 9.0).sin()); + shot.frame("paint", i, &layer); + } + layer.end_stroke(0); +} + +/// The same layer, with an erase stroke taken back through the middle of it. +fn erase(shot: &mut Shot) { + let mut layer = lifted(MaskSource::brush()); + layer.begin_stroke(0, false, 0.16, 0.5, 1.0); + for x in steps(0.18, 0.82, 20) { + layer.extend_stroke(0, x, 0.5); + } + layer.end_stroke(0); + + for i in 0..8 { + shot.frame("erase", i, &layer); + } + layer.begin_stroke(0, true, 0.09, 0.7, 1.0); + for (i, x) in steps(0.25, 0.75, 20).enumerate() { + layer.extend_stroke(0, x, 0.5); + shot.frame("erase", 8 + i, &layer); + } + layer.end_stroke(0); +} + +/// A correction joined to a radial selection and then taken out of it: the +/// part appears, is painted, and the mask loses exactly what it covers. +fn subtract(shot: &mut Shot) { + let mut layer = lifted(MaskSource::Radial { + centre: (0.5, 0.5), + radii: (0.42, 0.34), + angle: 0.0, + feather: 0.35, + }); + + for i in 0..8 { + shot.frame("subtract", i, &layer); + } + + layer.push_part(MaskPart::painted("p2", Join::Subtract)); + for (i, x) in steps(0.3, 0.72, 22).enumerate() { + if i == 0 { + layer.begin_stroke(1, false, 0.1, 0.6, 1.0); + } + layer.extend_stroke(1, x, 0.46); + shot.frame("subtract", 8 + i, &layer); + } + layer.end_stroke(1); + + // And off again, which is the half a stroke cannot do: a part is a thing + // that can be switched off after the fact. + for i in 0..8 { + let mut without = layer.clone(); + without.remove_part(1); + shot.frame("subtract", 30 + i, &without); + } +} + +/// The layer turned over, and back, holding each state long enough to read. +fn invert(shot: &mut Shot) { + let mut layer = lifted(MaskSource::Radial { + centre: (0.42, 0.52), + radii: (0.3, 0.32), + angle: 0.0, + feather: 0.3, + }); + + for i in 0..24 { + layer.invert = (i / 8) % 2 == 1; + shot.frame("invert", i, &layer); + } +} + +/// The edge controls, swept: a feather opening up, then a dilation pushing the +/// boundary out and an erosion pulling it back. +fn shape(shot: &mut Shot) { + let mut layer = lifted(MaskSource::Radial { + centre: (0.5, 0.5), + radii: (0.3, 0.3), + angle: 0.0, + feather: 0.02, + }); + layer.base_mut().falloff = dr_pipeline::mask::Falloff::Smooth; + + for (i, f) in steps(0.0, 0.09, 18).enumerate() { + layer.base_mut().feather = f; + shot.frame("shape", i, &layer); + } + layer.base_mut().morphology = Morphology::Dilate; + for (i, r) in steps(0.0, 0.06, 12).enumerate() { + layer.base_mut().morph_radius = r; + shot.frame("shape", 18 + i, &layer); + } + layer.base_mut().morphology = Morphology::Erode; + for (i, r) in steps(0.0, 0.06, 12).enumerate() { + layer.base_mut().morph_radius = r; + shot.frame("shape", 30 + i, &layer); + } +} + +/// Everything one frame needs, so the mode functions read as what they do. +struct Shot<'a> { + source: &'a DemosaicedImage, + masks: &'a mut MaskPass, + adjust: &'a mut AdjustPass, + dir: &'a str, +} + +impl Shot<'_> { + fn frame(&mut self, mode: &str, index: usize, layer: &MaskLayer) { + let mut stack = MaskStack::new(); + stack.push(layer.clone()); + + let shader = compose_full( + &ops::chain(), + &Framing::new(), + ColourSpace::Srgb, + &stack, + &SpotSet::new(), + &[], + ); + + let array = self + .masks + .render(&stack, None, None, Some(self.source), W, H) + .expect("rasterise"); + self.adjust + .render_masked(self.source, &shader, W, H, Some(array)) + .expect("render"); + let rgba = self.adjust.export_pixels().expect("readback").0; + + write_ppm( + &format!("{}/{mode}-{index:03}.ppm", self.dir), + &to_rgb(&rgba), + W, + H, + ); + } +} + +/// `count` values from `from` to `to`, inclusive. +fn steps(from: f32, to: f32, count: usize) -> impl Iterator { + (0..count).map(move |i| from + (to - from) * i as f32 / (count.max(2) - 1) as f32) +} + +/// A picture with somewhere obvious to put a mask: a graded sky, a ground +/// band, and a warm subject sitting on the join. +fn scene() -> Vec { + let mut px = vec![0u8; (W * H * 4) as usize]; + for y in 0..H { + for x in 0..W { + let (fx, fy) = (x as f32 / W as f32, y as f32 / H as f32); + let sky = [ + (60.0 + 90.0 * fy) as u8, + (110.0 + 90.0 * fy) as u8, + (190.0 + 50.0 * fy) as u8, + ]; + let ground = [ + (70.0 + 40.0 * fx) as u8, + (85.0 + 30.0 * fx) as u8, + (60.0 + 20.0 * fx) as u8, + ]; + let mut c = if fy > 0.62 { ground } else { sky }; + + // The subject: an ellipse, warm, with a little internal structure + // so a feathered edge has something to be soft against. + let (dx, dy) = ((fx - 0.5) / 0.22, (fy - 0.52) / 0.3); + if dx * dx + dy * dy < 1.0 { + let shade = 0.75 + 0.25 * (fx * 40.0).sin() * (fy * 30.0).cos(); + c = [ + (205.0 * shade) as u8, + (170.0 * shade) as u8, + (140.0 * shade) as u8, + ]; + } + + let i = ((y * W + x) * 4) as usize; + px[i..i + 4].copy_from_slice(&[c[0], c[1], c[2], 255]); + } + } + px +} + +fn to_rgb(rgba: &[u8]) -> Vec { + rgba.chunks_exact(4) + .flat_map(|p| [p[0], p[1], p[2]]) + .collect() +} + +fn write_ppm(path: &str, rgb: &[u8], w: u32, h: u32) { + use std::io::Write as _; + let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create")); + write!(f, "P6\n{w} {h}\n255\n").expect("header"); + f.write_all(rgb).expect("body"); +}