diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 36cc291925..73383d1d4a 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -738,457 +738,4 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i } #[cfg(test)] -mod tests { - use super::*; - use lightcraft_develop::{DevelopSettings, MaskComponent}; - - fn frame(w: usize, h: usize) -> Frame { - Frame::new(w, h, &DevelopSettings::default(), true) - } - - #[test] - fn linear_gradient_ramps() { - let f = frame(100, 50); - let img = Rgb32f::new(100, 50); - let l = Plane::new(100, 50); - let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l, 0.0, None); - assert!(a.get(1, 25) > 0.99 && a.get(98, 25) < 0.01); - assert!((a.get(50, 25) - 0.5).abs() < 0.05); - } - - #[test] - fn stored_segmentations_render_at_any_size_and_follow_the_crop() { - // the left half selected, on a 16² logit grid - let side = 16; - let logits: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); - let seg = SegMask::from_logits(side, &logits); - for shape in [ - MaskShape::Object { hint: vec![Point::new(0.25, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, - MaskShape::Prompt { text: "left".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, - ] { - for (w, h) in [(40, 20), (400, 200)] { - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(1, h / 2) > 0.99 && a.get(w - 2, h / 2) < 0.01, "{w}×{h}"); - if w >= 400 { - let soft = (0..w).filter(|x| (0.05..0.95).contains(&a.get(*x, h / 2))).count(); - assert!((2..30).contains(&soft), "an anti-aliased edge, not a ramp: {soft} px"); - } - } - } - // cropped to the right half: nothing left selected - let mut d = DevelopSettings::default(); - d.crop.geometry.rect = lightcraft_geom::Rect::from_center(Point::new(0.75, 0.5), 0.5, 1.0); - let f = Frame::new(40, 20, &d, true); - let shape = MaskShape::Prompt { text: "left".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; - let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0, None); - assert!(a.data.iter().all(|v| *v < 0.05)); - } - - #[test] - fn detail_patches_replace_the_coarse_mask_inside_their_rectangle() { - // coarse: nothing; a patch over the right half selects its own left half (x 0.5..0.75) - let coarse = SegMask::from_logits(4, &[-12.0; 16]); - let side = 16; - let l: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); - let patch = SegMask::from_logits_in(side, &l, [0.5, 0.0, 1.0, 1.0]); - let shape = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(coarse), detail: vec![patch], edge: 0.0 }; - let (w, h) = (200, 100); - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(20, 50) < 0.01, "outside the patch: the coarse mask"); - assert!(a.get(120, 50) > 0.99, "inside the patch, its selected half"); - assert!(a.get(190, 50) < 0.01, "inside the patch, its unselected half"); - } - - #[test] - fn a_hostile_number_of_detail_patches_is_capped() { - // built in memory (a deserialized document is capped already): only the first - // MAX_DETAIL are decoded and sampled, so this needs 4 × 4 MB, not 20 000 × 4 MB - let side = 1024; - let l: Vec = vec![12.0; side * side]; - let patch = SegMask::from_logits_in(side, &l, [0.0, 0.0, 0.5, 1.0]); - let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: None, detail: vec![patch; 20_000], edge: 0.0 }; - let (w, h) = (64, 32); - let t = std::time::Instant::now(); - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(5, 16) > 0.99 && a.get(60, 16) < 0.01); - assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); - } - - #[test] - fn the_edge_setting_hardens_or_feathers_the_transition() { - let side = 16; - let l: Vec = (0..side * side).map(|i| ((i % side) as f32 - 7.5) * -0.6).collect(); // a gentle ramp - let seg = SegMask::from_logits(side, &l); - let (w, h) = (400, 40); - let soft_px = |edge: f64| { - let shape = MaskShape::Prompt { text: "x".into(), seg: Some(seg.clone()), detail: vec![], edge }; - let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); - assert!(a.get(2, h / 2) > 0.9 && a.get(w - 3, h / 2) < 0.1, "still the same selection at {edge}"); - (0..w).filter(|x| (0.1..0.9).contains(&a.get(*x, h / 2))).count() - }; - let (hard, plain, soft) = (soft_px(-100.0), soft_px(0.0), soft_px(100.0)); - assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}"); - } - - #[test] - fn ai_shapes_without_a_segmentation_select_nothing() { - let f = frame(30, 30); - let (img, l) = (Rgb32f::new(30, 30), Plane::new(30, 30)); - for shape in [ - MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }, - MaskShape::Prompt { text: "sky".into(), seg: None, detail: vec![], edge: 0.0 }, - MaskShape::Prompt { text: "sky".into(), seg: Some(SegMask { side: 4, data: "damaged!".into(), rect: None }), detail: vec![], edge: 0.0 }, - ] { - assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0, None).data.iter().all(|v| *v == 0.0)); - } - } - - #[test] - fn radial_inside_and_invert() { - let f = frame(100, 100); - let img = Rgb32f::new(100, 100); - let l = Plane::new(100, 100); - let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false }; - let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0, None); - assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01); - let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() }; - let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0, None); - assert!(e[0].alpha.get(50, 50) < 0.01); - } - - /// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at - /// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once - /// (the committed one). - #[test] - fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { - let r = 12.0; - let pts = [Point::new(10.0, 20.0), Point::new(610.0, 260.0), Point::new(611.0, 260.0), Point::new(40.0, 400.0)]; - let mut live = Vec::new(); - let mut prev = None; - for q in pts { - if push_dabs(&mut live, prev, q, r) { - prev = Some(q); - } - } - assert!(live.len() > 3 * 600 / 12, "{} dabs", live.len()); - assert!(live.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(r) + 1e-9)); - assert_eq!(live.first(), pts.first()); - assert_eq!(live.last(), pts.last()); - // the committed stroke places the same dabs (in output pixels; this frame maps 1:1 scaled) - let (w, h) = (1000, 500); - let f = frame(w, h); - let stroke = BrushStroke { points: pts.map(|p| Point::new(p.x / w as f64, p.y / h as f64)).to_vec(), ..Default::default() }; - let committed = brush_dabs(&stroke, &f, w, h); - let mut again = Vec::new(); - let mut prev = None; - for p in &stroke.points { - let q = f.norm_to_out(w, h).apply(*p); - if push_dabs(&mut again, prev, q, committed.r) { - prev = Some(q); - } - } - assert_eq!(committed.dabs, again); - assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); - // many huge segments stop at the stroke budget instead of adding up without bound - let mut big = Vec::new(); - let mut at = Point::new(0.0, 0.0); - for i in 0..5 { - let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0); - if push_dabs(&mut big, Some(at), q, 1.0) { - at = q; - } - } - assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); - assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); - // a hostile segment places no in-between dabs - let mut v = Vec::new(); - assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); - assert!(!push_dabs(&mut v, None, Point::new(f64::INFINITY, 1.0), r)); - push_dabs(&mut v, Some(Point::new(0.0, 0.0)), Point::new(1e300, 1.0), 0.0); - assert!(v.len() <= 1_000_002, "{}", v.len()); - } - - /// A temporary output buffer must spend the same stroke budget as a retained one. - #[test] - fn incremental_dab_buffers_share_the_stroke_budget() { - let points = [Point::new(20.0, 20.0), Point::new(80.0, 20.0), Point::new(200.0, 80.0), Point::new(21.0, 20.0)]; - let mut retained = Vec::new(); - let mut streamed = Vec::new(); - let mut retained_count = MAX_STROKE_DABS - 3; - let mut streamed_count = retained_count; - let mut prev = None; - for q in points { - let mut chunk = Vec::new(); - let accepted = push_dabs_counted(&mut retained, &mut retained_count, prev, q, 10.0); - assert_eq!(accepted, push_dabs_counted(&mut chunk, &mut streamed_count, prev, q, 10.0)); - streamed.extend(chunk); - if accepted { - prev = Some(q); - } - } - assert_eq!(retained, streamed); - assert_eq!(retained.len(), 3); - assert_eq!(retained_count, MAX_STROKE_DABS); - assert_eq!(streamed_count, retained_count); - assert_eq!(prev, Some(points[1])); - assert!(!retained.contains(&points[2])); - assert!(!retained.contains(&points[3])); - } - - /// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile - /// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. - #[test] - fn pen_pressure_scales_dab_size_and_flow() { - let (w, h) = (400, 100); - let f = frame(w, h); - let img = Rgb32f::new(w, h); - let l = Plane::new(w, h); - let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)]; - let alpha = |pressure: Vec| { - let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() }; - shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) - }; - let full = alpha(vec![]); - assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure"); - // light at the start, full at the end: thin and faint on the left, as wide as the brush on the right - let ramp = alpha(vec![0.1, 1.0]); - let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count(); - assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60)); - assert!(width(&ramp, 350) + 2 >= width(&full, 350)); - assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6); - let faint = alpha(vec![0.2]); - assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure"); - assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50)); - for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] { - let a = alpha(hostile); - assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); - } - assert_eq!(pressure_radius(10.0, f32::NAN), 10.0); - assert_eq!(pressure_radius(10.0, 0.0), 1.0); - assert_eq!(pressure_flow(0.8, 2.0), 0.8); - } - - #[test] - fn brush_and_subtract() { - let f = frame(200, 100); - let img = Rgb32f::new(200, 100); - let l = Plane::new(200, 100); - let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() }; - let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() }; - let m = Mask { - components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], - ..Default::default() - }; - let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0, None); - let a = &e[0].alpha; - assert!(a.get(40, 50) > 0.9, "{}", a.get(40, 50)); - assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50)); - assert!(a.get(40, 5) < 0.01); - } - - /// #518: holding the brush still adds no coverage. A press plus any number of stationary - /// pointer samples paints exactly the single dab of the press, whatever the frame rate. - #[test] - fn holding_the_brush_still_adds_no_coverage() { - let (w, h) = (200, 100); - let f = frame(w, h); - let img = Rgb32f::new(w, h); - let l = Plane::new(w, h); - let p = Point::new(0.5, 0.5); - let spot = |samples: usize, flow: f64| { - let s = BrushStroke { points: vec![p; samples], size: 0.1, feather: 80.0, flow, ..Default::default() }; - shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) - }; - for flow in [100.0, 40.0] { - let press = spot(1, flow); - // the feathered edge is soft to begin with - let edge = press.get(100 + 14, 50); - assert!(edge > 0.05 && edge < 0.6, "flow {flow}: edge {edge}"); - for n in [2, 5, 60, 600] { - let held = spot(n, flow); - assert!(held.data == press.data, "flow {flow}: {n} stationary samples hardened the edge {edge} → {}", held.get(100 + 14, 50)); - } - } - let s = BrushStroke { points: vec![p; 60], size: 0.1, ..Default::default() }; - assert_eq!(brush_dabs(&s, &f, w, h).dabs.len(), 1, "one dab for the press"); - // a stroke that pauses partway lays the same dabs as one that doesn't - let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); - let paused = BrushStroke { points: vec![a, p, p, p, p, b], flow: 30.0, ..Default::default() }; - let smooth = BrushStroke { points: vec![a, p, b], flow: 30.0, ..Default::default() }; - assert_eq!(brush_dabs(&paused, &f, w, h).dabs, brush_dabs(&smooth, &f, w, h).dabs); - } - - /// Flow builds up by passes: going back over an area in the same stroke adds coverage. - #[test] - fn a_second_pass_builds_up_with_low_flow() { - let (w, h) = (200, 100); - let f = frame(w, h); - let img = Rgb32f::new(w, h); - let l = Plane::new(w, h); - let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); - let paint = |points: Vec| { - let s = BrushStroke { points, size: 0.05, feather: 50.0, flow: 20.0, ..Default::default() }; - shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) - }; - let once = paint(vec![a, b]); - let twice = paint(vec![a, b, a]); - let thrice = paint(vec![a, b, a, b]); - let (o, t, th) = (once.get(100, 50), twice.get(100, 50), thrice.get(100, 50)); - assert!(o > 0.05 && o < 0.99, "one pass {o}"); - assert!(t > o + 0.02 && th > t + 0.01, "passes build up: {o} → {t} → {th}"); - } - - #[test] - fn auto_mask_stops_at_edges() { - // dark left half, bright right half; a stroke along the boundary, centred on the dark side - let (w, h) = (200, 100); - let f = frame(w, h); - let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] }); - let l = img.map(crate::local::log_lum); - let stroke = |auto_mask| BrushStroke { - points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)], - size: 0.08, - feather: 0.0, - auto_mask, - ..Default::default() - }; - let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] }; - let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0, None); - let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0, None); - // without Auto Mask the brush spills over the edge; with it, it stays on the dark side - assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50)); - assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50)); - assert!(auto.get(110, 50) < 0.02); - assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50)); - assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50)); - // on a flat area Auto Mask paints like the plain brush - let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]); - let fl = flat.map(crate::local::log_lum); - let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0, None); - let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0, None); - assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50)); - } - - /// Refine Edges: a soft mask over a hard edge in the photo snaps to that edge. - #[test] - fn refine_edges_follows_the_photo() { - let (w, h) = (120usize, 80usize); - // left half dark, right half bright - let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { [0.05; 3] } else { [0.6; 3] }); - let log_l = Plane::from_fn(w, h, |x, _| if x < 60 { (0.05f32).log2() } else { (0.6f32).log2() }); - let f = Frame::new(w, h, &Default::default(), true); - let soft = Mask { - components: vec![MaskComponent { - name: None, - op: MaskOp::Add, - invert: false, - shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) }, - }], - ..Default::default() - }; - let refined = Mask { refine: 100.0, ..soft.clone() }; - let step = |m: &Mask| { - let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0, None); - a.data[40 * w + 63] - a.data[40 * w + 56] - }; - let (plain, sharp) = (step(&soft), step(&refined)); - assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}"); - } - - /// Issue #157: a colour-range sample of one flat patch must not select a clearly different - /// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195), - /// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab). - #[test] - fn color_range_keeps_to_the_sampled_colour() { - use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8}; - let (w, h) = (120usize, 40usize); - let m = SRGB.to_space(&REC2020); - let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8)); - let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195])); - let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right }); - let log_l = img.map(crate::local::log_lum); - let f = Frame::new(w, h, &Default::default(), true); - let mut sample = [0f64; 3]; - for y in 19..=21 { - for x in 29..=31 { - let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x])); - for k in 0..3 { - sample[k] += lab[k] as f64 / 9.0; - } - } - } - let alpha = |refine| { - let shape = MaskShape::ColorRange { samples: vec![sample], refine }; - let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0, None); - (a.get(30, 20), a.get(90, 20)) - }; - for refine in [0.0, 50.0, 85.0] { - let (l, r) = alpha(refine); - assert!(l > 0.95, "refine {refine}: sampled patch {l}"); - assert!(r < 0.05, "refine {refine}: other patch {r}"); - } - // Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the - // other patch stays mostly out - let (l, r) = alpha(100.0); - assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); - } - - /// A half-resolution matte selecting the left `frac` of a `w × h` source. - fn left_matte(w: usize, h: usize, frac: f64) -> Image { - Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) - } - - /// The Sky mask follows the source's sky matte where it has one (here: the bottom-left of a - /// uniformly bright blue photo, where the heuristic sees no sky, and not its top right, where - /// it does), through user orientation, at any output size; without one it keeps the heuristic. - #[test] - fn sky_uses_the_sources_matte() { - let (w, h) = (200, 100); - let img = Rgb32f::filled(w, h, [0.5, 0.7, 1.4]); - let l = img.map(crate::local::log_lum); - let mut mattes = Mattes::default(); - mattes.push(MatteKind::Sky, left_matte(w, h, 0.5)); - let f = frame(w, h); - let heuristic = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, None); - let matte = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&mattes)); - assert!(heuristic.get(150, 5) > 0.9 && heuristic.get(25, 90) < 0.1, "{} {}", heuristic.get(150, 5), heuristic.get(25, 90)); - assert!(matte.get(150, 5) < 0.01 && matte.get(25, 90) > 0.99, "{} {}", matte.get(150, 5), matte.get(25, 90)); - // mattes of other kinds leave the heuristic alone - let mut hair = Mattes::default(); - hair.push(MatteKind::Hair, left_matte(w, h, 0.5)); - assert_eq!(shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&hair)), heuristic); - // rotated 90° clockwise, the source's left half is the top half; a preview and a larger - // render agree - let s = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() }; - let f = Frame::new(w, h, &s, true); - for (ow, oh) in [(100, 200), (25, 50)] { - let small = Rgb32f::filled(ow, oh, [0.5, 0.7, 1.4]); - let a = shape_alpha(&MaskShape::Sky, &f, ow, oh, &small, &small.map(crate::local::log_lum), 0.0, Some(&mattes)); - let at = |x: f64, y: f64| a.get((x * ow as f64) as usize, (y * oh as f64) as usize); - assert!(at(0.5, 0.2) > 0.99 && at(0.5, 0.8) < 0.01, "{ow}×{oh}: {} {}", at(0.5, 0.2), at(0.5, 0.8)); - } - } - - /// Subject, Background and People use person mattes: the whole person is the union of the - /// parts; a People part without a matte keeps the heuristic. - #[test] - fn people_and_subject_use_person_mattes() { - let (w, h) = (200, 100); - let img = Rgb32f::filled(w, h, [0.2; 3]); - let l = img.map(crate::local::log_lum); - let mut mattes = Mattes::default(); - mattes.push(MatteKind::Skin, left_matte(w, h, 0.25)); - let right: Image = Image::from_fn(w / 2, h / 2, |x, _| if x >= 75 { 255 } else { 0 }); - mattes.push(MatteKind::Hair, right); - let f = frame(w, h); - let eval = |shape: &MaskShape| shape_alpha(shape, &f, w, h, &img, &l, 0.0, Some(&mattes)); - let subject = eval(&MaskShape::Subject); - assert!(subject.get(10, 50) > 0.99 && subject.get(190, 50) > 0.99 && subject.get(100, 50) < 0.01); - let background = eval(&MaskShape::Background); - assert!(background.get(100, 50) > 0.99 && background.get(10, 50) < 0.01); - let hair = eval(&MaskShape::People { person: 0, parts: vec!["Hair".into()] }); - assert!(hair.get(190, 50) > 0.99 && hair.get(10, 50) < 0.01); - let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; - assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); - } -} +mod tests; diff --git a/crates/pipeline/src/masks/tests.rs b/crates/pipeline/src/masks/tests.rs new file mode 100644 index 0000000000..a3f776d0d7 --- /dev/null +++ b/crates/pipeline/src/masks/tests.rs @@ -0,0 +1,452 @@ +use super::*; +use lightcraft_develop::{DevelopSettings, MaskComponent}; + +fn frame(w: usize, h: usize) -> Frame { + Frame::new(w, h, &DevelopSettings::default(), true) +} + +#[test] +fn linear_gradient_ramps() { + let f = frame(100, 50); + let img = Rgb32f::new(100, 50); + let l = Plane::new(100, 50); + let a = shape_alpha(&MaskShape::Linear { start: Point::new(0.0, 0.0), end: Point::new(1.0, 0.0) }, &f, 100, 50, &img, &l, 0.0, None); + assert!(a.get(1, 25) > 0.99 && a.get(98, 25) < 0.01); + assert!((a.get(50, 25) - 0.5).abs() < 0.05); +} + +#[test] +fn stored_segmentations_render_at_any_size_and_follow_the_crop() { + // the left half selected, on a 16² logit grid + let side = 16; + let logits: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); + let seg = SegMask::from_logits(side, &logits); + for shape in [ + MaskShape::Object { hint: vec![Point::new(0.25, 0.5)], exclude: vec![], seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, + MaskShape::Prompt { text: "left".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, + ] { + for (w, h) in [(40, 20), (400, 200)] { + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(1, h / 2) > 0.99 && a.get(w - 2, h / 2) < 0.01, "{w}×{h}"); + if w >= 400 { + let soft = (0..w).filter(|x| (0.05..0.95).contains(&a.get(*x, h / 2))).count(); + assert!((2..30).contains(&soft), "an anti-aliased edge, not a ramp: {soft} px"); + } + } + } + // cropped to the right half: nothing left selected + let mut d = DevelopSettings::default(); + d.crop.geometry.rect = lightcraft_geom::Rect::from_center(Point::new(0.75, 0.5), 0.5, 1.0); + let f = Frame::new(40, 20, &d, true); + let shape = MaskShape::Prompt { text: "left".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; + let a = shape_alpha(&shape, &f, 20, 20, &Rgb32f::new(20, 20), &Plane::new(20, 20), 0.0, None); + assert!(a.data.iter().all(|v| *v < 0.05)); +} + +#[test] +fn detail_patches_replace_the_coarse_mask_inside_their_rectangle() { + // coarse: nothing; a patch over the right half selects its own left half (x 0.5..0.75) + let coarse = SegMask::from_logits(4, &[-12.0; 16]); + let side = 16; + let l: Vec = (0..side * side).map(|i| if i % side < side / 2 { 12.0 } else { -12.0 }).collect(); + let patch = SegMask::from_logits_in(side, &l, [0.5, 0.0, 1.0, 1.0]); + let shape = MaskShape::Object { hint: vec![Point::new(0.6, 0.5)], exclude: vec![], seg: Some(coarse), detail: vec![patch], edge: 0.0 }; + let (w, h) = (200, 100); + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(20, 50) < 0.01, "outside the patch: the coarse mask"); + assert!(a.get(120, 50) > 0.99, "inside the patch, its selected half"); + assert!(a.get(190, 50) < 0.01, "inside the patch, its unselected half"); +} + +#[test] +fn a_hostile_number_of_detail_patches_is_capped() { + // built in memory (a deserialized document is capped already): only the first + // MAX_DETAIL are decoded and sampled, so this needs 4 × 4 MB, not 20 000 × 4 MB + let side = 1024; + let l: Vec = vec![12.0; side * side]; + let patch = SegMask::from_logits_in(side, &l, [0.0, 0.0, 0.5, 1.0]); + let shape = MaskShape::Object { hint: vec![Point::new(0.2, 0.5)], exclude: vec![], seg: None, detail: vec![patch; 20_000], edge: 0.0 }; + let (w, h) = (64, 32); + let t = std::time::Instant::now(); + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(5, 16) > 0.99 && a.get(60, 16) < 0.01); + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); +} + +#[test] +fn the_edge_setting_hardens_or_feathers_the_transition() { + let side = 16; + let l: Vec = (0..side * side).map(|i| ((i % side) as f32 - 7.5) * -0.6).collect(); // a gentle ramp + let seg = SegMask::from_logits(side, &l); + let (w, h) = (400, 40); + let soft_px = |edge: f64| { + let shape = MaskShape::Prompt { text: "x".into(), seg: Some(seg.clone()), detail: vec![], edge }; + let a = shape_alpha(&shape, &frame(w, h), w, h, &Rgb32f::new(w, h), &Plane::new(w, h), 0.0, None); + assert!(a.get(2, h / 2) > 0.9 && a.get(w - 3, h / 2) < 0.1, "still the same selection at {edge}"); + (0..w).filter(|x| (0.1..0.9).contains(&a.get(*x, h / 2))).count() + }; + let (hard, plain, soft) = (soft_px(-100.0), soft_px(0.0), soft_px(100.0)); + assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}"); +} + +#[test] +fn ai_shapes_without_a_segmentation_select_nothing() { + let f = frame(30, 30); + let (img, l) = (Rgb32f::new(30, 30), Plane::new(30, 30)); + for shape in [ + MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }, + MaskShape::Prompt { text: "sky".into(), seg: None, detail: vec![], edge: 0.0 }, + MaskShape::Prompt { text: "sky".into(), seg: Some(SegMask { side: 4, data: "damaged!".into(), rect: None }), detail: vec![], edge: 0.0 }, + ] { + assert!(shape_alpha(&shape, &f, 30, 30, &img, &l, 0.0, None).data.iter().all(|v| *v == 0.0)); + } +} + +#[test] +fn radial_inside_and_invert() { + let f = frame(100, 100); + let img = Rgb32f::new(100, 100); + let l = Plane::new(100, 100); + let shape = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.2, angle: 0.0, feather: 20.0, invert: false }; + let a = shape_alpha(&shape, &f, 100, 100, &img, &l, 0.0, None); + assert!(a.get(50, 50) > 0.99 && a.get(5, 5) < 0.01); + let m = Mask { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: true, shape }], ..Default::default() }; + let e = evaluate(&[m], &f, 100, 100, &img, &l, 0.0, None); + assert!(e[0].alpha.get(50, 50) < 0.01); +} + +/// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at +/// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once +/// (the committed one). +#[test] +fn a_long_segment_gets_dabs_at_most_the_spacing_apart() { + let r = 12.0; + let pts = [Point::new(10.0, 20.0), Point::new(610.0, 260.0), Point::new(611.0, 260.0), Point::new(40.0, 400.0)]; + let mut live = Vec::new(); + let mut prev = None; + for q in pts { + if push_dabs(&mut live, prev, q, r) { + prev = Some(q); + } + } + assert!(live.len() > 3 * 600 / 12, "{} dabs", live.len()); + assert!(live.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(r) + 1e-9)); + assert_eq!(live.first(), pts.first()); + assert_eq!(live.last(), pts.last()); + // the committed stroke places the same dabs (in output pixels; this frame maps 1:1 scaled) + let (w, h) = (1000, 500); + let f = frame(w, h); + let stroke = BrushStroke { points: pts.map(|p| Point::new(p.x / w as f64, p.y / h as f64)).to_vec(), ..Default::default() }; + let committed = brush_dabs(&stroke, &f, w, h); + let mut again = Vec::new(); + let mut prev = None; + for p in &stroke.points { + let q = f.norm_to_out(w, h).apply(*p); + if push_dabs(&mut again, prev, q, committed.r) { + prev = Some(q); + } + } + assert_eq!(committed.dabs, again); + assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); + // many huge segments stop at the stroke budget instead of adding up without bound + let mut big = Vec::new(); + let mut at = Point::new(0.0, 0.0); + for i in 0..5 { + let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0); + if push_dabs(&mut big, Some(at), q, 1.0) { + at = q; + } + } + assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); + assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); + // a hostile segment places no in-between dabs + let mut v = Vec::new(); + assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); + assert!(!push_dabs(&mut v, None, Point::new(f64::INFINITY, 1.0), r)); + push_dabs(&mut v, Some(Point::new(0.0, 0.0)), Point::new(1e300, 1.0), 0.0); + assert!(v.len() <= 1_000_002, "{}", v.len()); +} + +/// A temporary output buffer must spend the same stroke budget as a retained one. +#[test] +fn incremental_dab_buffers_share_the_stroke_budget() { + let points = [Point::new(20.0, 20.0), Point::new(80.0, 20.0), Point::new(200.0, 80.0), Point::new(21.0, 20.0)]; + let mut retained = Vec::new(); + let mut streamed = Vec::new(); + let mut retained_count = MAX_STROKE_DABS - 3; + let mut streamed_count = retained_count; + let mut prev = None; + for q in points { + let mut chunk = Vec::new(); + let accepted = push_dabs_counted(&mut retained, &mut retained_count, prev, q, 10.0); + assert_eq!(accepted, push_dabs_counted(&mut chunk, &mut streamed_count, prev, q, 10.0)); + streamed.extend(chunk); + if accepted { + prev = Some(q); + } + } + assert_eq!(retained, streamed); + assert_eq!(retained.len(), 3); + assert_eq!(retained_count, MAX_STROKE_DABS); + assert_eq!(streamed_count, retained_count); + assert_eq!(prev, Some(points[1])); + assert!(!retained.contains(&points[2])); + assert!(!retained.contains(&points[3])); +} + +/// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile +/// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. +#[test] +fn pen_pressure_scales_dab_size_and_flow() { + let (w, h) = (400, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)]; + let alpha = |pressure: Vec| { + let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let full = alpha(vec![]); + assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure"); + // light at the start, full at the end: thin and faint on the left, as wide as the brush on the right + let ramp = alpha(vec![0.1, 1.0]); + let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count(); + assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60)); + assert!(width(&ramp, 350) + 2 >= width(&full, 350)); + assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6); + let faint = alpha(vec![0.2]); + assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure"); + assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50)); + for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] { + let a = alpha(hostile); + assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + } + assert_eq!(pressure_radius(10.0, f32::NAN), 10.0); + assert_eq!(pressure_radius(10.0, 0.0), 1.0); + assert_eq!(pressure_flow(0.8, 2.0), 0.8); +} + +#[test] +fn brush_and_subtract() { + let f = frame(200, 100); + let img = Rgb32f::new(200, 100); + let l = Plane::new(200, 100); + let stroke = BrushStroke { points: vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)], size: 0.05, ..Default::default() }; + let erase = BrushStroke { points: vec![Point::new(0.5, 0.5)], size: 0.05, erase: true, feather: 0.0, ..Default::default() }; + let m = Mask { + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke, erase] } }], + ..Default::default() + }; + let e = evaluate(&[m], &f, 200, 100, &img, &l, 0.0, None); + let a = &e[0].alpha; + assert!(a.get(40, 50) > 0.9, "{}", a.get(40, 50)); + assert!(a.get(100, 50) < 0.05, "erased centre {}", a.get(100, 50)); + assert!(a.get(40, 5) < 0.01); +} + +/// #518: holding the brush still adds no coverage. A press plus any number of stationary +/// pointer samples paints exactly the single dab of the press, whatever the frame rate. +#[test] +fn holding_the_brush_still_adds_no_coverage() { + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let p = Point::new(0.5, 0.5); + let spot = |samples: usize, flow: f64| { + let s = BrushStroke { points: vec![p; samples], size: 0.1, feather: 80.0, flow, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + for flow in [100.0, 40.0] { + let press = spot(1, flow); + // the feathered edge is soft to begin with + let edge = press.get(100 + 14, 50); + assert!(edge > 0.05 && edge < 0.6, "flow {flow}: edge {edge}"); + for n in [2, 5, 60, 600] { + let held = spot(n, flow); + assert!(held.data == press.data, "flow {flow}: {n} stationary samples hardened the edge {edge} → {}", held.get(100 + 14, 50)); + } + } + let s = BrushStroke { points: vec![p; 60], size: 0.1, ..Default::default() }; + assert_eq!(brush_dabs(&s, &f, w, h).dabs.len(), 1, "one dab for the press"); + // a stroke that pauses partway lays the same dabs as one that doesn't + let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); + let paused = BrushStroke { points: vec![a, p, p, p, p, b], flow: 30.0, ..Default::default() }; + let smooth = BrushStroke { points: vec![a, p, b], flow: 30.0, ..Default::default() }; + assert_eq!(brush_dabs(&paused, &f, w, h).dabs, brush_dabs(&smooth, &f, w, h).dabs); +} + +/// Flow builds up by passes: going back over an area in the same stroke adds coverage. +#[test] +fn a_second_pass_builds_up_with_low_flow() { + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let (a, b) = (Point::new(0.2, 0.5), Point::new(0.8, 0.5)); + let paint = |points: Vec| { + let s = BrushStroke { points, size: 0.05, feather: 50.0, flow: 20.0, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let once = paint(vec![a, b]); + let twice = paint(vec![a, b, a]); + let thrice = paint(vec![a, b, a, b]); + let (o, t, th) = (once.get(100, 50), twice.get(100, 50), thrice.get(100, 50)); + assert!(o > 0.05 && o < 0.99, "one pass {o}"); + assert!(t > o + 0.02 && th > t + 0.01, "passes build up: {o} → {t} → {th}"); +} + +#[test] +fn auto_mask_stops_at_edges() { + // dark left half, bright right half; a stroke along the boundary, centred on the dark side + let (w, h) = (200, 100); + let f = frame(w, h); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 100 { [0.03; 3] } else { [0.6, 0.5, 0.4] }); + let l = img.map(crate::local::log_lum); + let stroke = |auto_mask| BrushStroke { + points: vec![Point::new(0.47, 0.2), Point::new(0.47, 0.8)], + size: 0.08, + feather: 0.0, + auto_mask, + ..Default::default() + }; + let comp = |auto| MaskShape::Brush { strokes: vec![stroke(auto)] }; + let plain = shape_alpha(&comp(false), &f, w, h, &img, &l, 0.0, None); + let auto = shape_alpha(&comp(true), &f, w, h, &img, &l, 0.0, None); + // without Auto Mask the brush spills over the edge; with it, it stays on the dark side + assert!(plain.get(105, 50) > 0.9, "{}", plain.get(105, 50)); + assert!(auto.get(105, 50) < 0.05, "spill {}", auto.get(105, 50)); + assert!(auto.get(110, 50) < 0.02); + assert!(auto.get(90, 50) > 0.9, "painted side {}", auto.get(90, 50)); + assert!(auto.get(99, 50) > 0.8, "up to the edge {}", auto.get(99, 50)); + // on a flat area Auto Mask paints like the plain brush + let flat = Rgb32f::from_fn(w, h, |_, _| [0.2; 3]); + let fl = flat.map(crate::local::log_lum); + let a = shape_alpha(&comp(true), &f, w, h, &flat, &fl, 0.0, None); + let b = shape_alpha(&comp(false), &f, w, h, &flat, &fl, 0.0, None); + assert!((a.get(94, 50) - b.get(94, 50)).abs() < 0.02, "{} vs {}", a.get(94, 50), b.get(94, 50)); +} + +/// Refine Edges: a soft mask over a hard edge in the photo snaps to that edge. +#[test] +fn refine_edges_follows_the_photo() { + let (w, h) = (120usize, 80usize); + // left half dark, right half bright + let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { [0.05; 3] } else { [0.6; 3] }); + let log_l = Plane::from_fn(w, h, |x, _| if x < 60 { (0.05f32).log2() } else { (0.6f32).log2() }); + let f = Frame::new(w, h, &Default::default(), true); + let soft = Mask { + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.75, 0.5), end: Point::new(0.25, 0.5) }, + }], + ..Default::default() + }; + let refined = Mask { refine: 100.0, ..soft.clone() }; + let step = |m: &Mask| { + let a = evaluate_one(m, &f, w, h, &img, &log_l, 0.0, None); + a.data[40 * w + 63] - a.data[40 * w + 56] + }; + let (plain, sharp) = (step(&soft), step(&refined)); + assert!(sharp > plain * 1.5 && sharp > 0.1, "the edge in the mask follows the photo's: {plain} → {sharp}"); +} + +/// Issue #157: a colour-range sample of one flat patch must not select a clearly different +/// patch. The two patches are the ones from the report (sRGB 120,150,155 and 200,198,195), +/// sampled the way `mask.sampleColor` does (3×3 mean of the selection-space OkLab). +#[test] +fn color_range_keeps_to_the_sampled_colour() { + use lightcraft_color::{REC2020, SRGB, transfer::decode_srgb8}; + let (w, h) = (120usize, 40usize); + let m = SRGB.to_space(&REC2020); + let patch = |rgb: [u8; 3]| m.apply_f32(rgb.map(decode_srgb8)); + let (left, right) = (patch([120, 150, 155]), patch([200, 198, 195])); + let img = Rgb32f::from_fn(w, h, |x, _| if x < 60 { left } else { right }); + let log_l = img.map(crate::local::log_lum); + let f = Frame::new(w, h, &Default::default(), true); + let mut sample = [0f64; 3]; + for y in 19..=21 { + for x in 29..=31 { + let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(img.data[y * w + x])); + for k in 0..3 { + sample[k] += lab[k] as f64 / 9.0; + } + } + } + let alpha = |refine| { + let shape = MaskShape::ColorRange { samples: vec![sample], refine }; + let a = shape_alpha(&shape, &f, w, h, &img, &log_l, 0.0, None); + (a.get(30, 20), a.get(90, 20)) + }; + for refine in [0.0, 50.0, 85.0] { + let (l, r) = alpha(refine); + assert!(l > 0.95, "refine {refine}: sampled patch {l}"); + assert!(r < 0.05, "refine {refine}: other patch {r}"); + } + // Refine 100 reaches furthest (the grey is ~0.063 away, the fade ends at 0.075), but the + // other patch stays mostly out + let (l, r) = alpha(100.0); + assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); +} + +/// A half-resolution matte selecting the left `frac` of a `w × h` source. +fn left_matte(w: usize, h: usize, frac: f64) -> Image { + Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) +} + +/// The Sky mask follows the source's sky matte where it has one (here: the bottom-left of a +/// uniformly bright blue photo, where the heuristic sees no sky, and not its top right, where +/// it does), through user orientation, at any output size; without one it keeps the heuristic. +#[test] +fn sky_uses_the_sources_matte() { + let (w, h) = (200, 100); + let img = Rgb32f::filled(w, h, [0.5, 0.7, 1.4]); + let l = img.map(crate::local::log_lum); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Sky, left_matte(w, h, 0.5)); + let f = frame(w, h); + let heuristic = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, None); + let matte = shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&mattes)); + assert!(heuristic.get(150, 5) > 0.9 && heuristic.get(25, 90) < 0.1, "{} {}", heuristic.get(150, 5), heuristic.get(25, 90)); + assert!(matte.get(150, 5) < 0.01 && matte.get(25, 90) > 0.99, "{} {}", matte.get(150, 5), matte.get(25, 90)); + // mattes of other kinds leave the heuristic alone + let mut hair = Mattes::default(); + hair.push(MatteKind::Hair, left_matte(w, h, 0.5)); + assert_eq!(shape_alpha(&MaskShape::Sky, &f, w, h, &img, &l, 0.0, Some(&hair)), heuristic); + // rotated 90° clockwise, the source's left half is the top half; a preview and a larger + // render agree + let s = lightcraft_develop::DevelopSettings { orientation: Orientation::Rotate90, ..Default::default() }; + let f = Frame::new(w, h, &s, true); + for (ow, oh) in [(100, 200), (25, 50)] { + let small = Rgb32f::filled(ow, oh, [0.5, 0.7, 1.4]); + let a = shape_alpha(&MaskShape::Sky, &f, ow, oh, &small, &small.map(crate::local::log_lum), 0.0, Some(&mattes)); + let at = |x: f64, y: f64| a.get((x * ow as f64) as usize, (y * oh as f64) as usize); + assert!(at(0.5, 0.2) > 0.99 && at(0.5, 0.8) < 0.01, "{ow}×{oh}: {} {}", at(0.5, 0.2), at(0.5, 0.8)); + } +} + +/// Subject, Background and People use person mattes: the whole person is the union of the +/// parts; a People part without a matte keeps the heuristic. +#[test] +fn people_and_subject_use_person_mattes() { + let (w, h) = (200, 100); + let img = Rgb32f::filled(w, h, [0.2; 3]); + let l = img.map(crate::local::log_lum); + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Skin, left_matte(w, h, 0.25)); + let right: Image = Image::from_fn(w / 2, h / 2, |x, _| if x >= 75 { 255 } else { 0 }); + mattes.push(MatteKind::Hair, right); + let f = frame(w, h); + let eval = |shape: &MaskShape| shape_alpha(shape, &f, w, h, &img, &l, 0.0, Some(&mattes)); + let subject = eval(&MaskShape::Subject); + assert!(subject.get(10, 50) > 0.99 && subject.get(190, 50) > 0.99 && subject.get(100, 50) < 0.01); + let background = eval(&MaskShape::Background); + assert!(background.get(100, 50) > 0.99 && background.get(10, 50) < 0.01); + let hair = eval(&MaskShape::People { person: 0, parts: vec!["Hair".into()] }); + assert!(hair.get(190, 50) > 0.99 && hair.get(10, 50) < 0.01); + let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; + assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); +}