mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-12 00:42:44 -06:00
refactor(lightcraft-pipeline): move masks.rs tests to crates/pipeline/src/masks/tests.rs
This commit is contained in:
@@ -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<f32> = (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<f32> = (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<f32> = 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<f32> = (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<f32>| {
|
||||
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<Point>| {
|
||||
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<u8> {
|
||||
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<u8> = 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;
|
||||
|
||||
@@ -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<f32> = (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<f32> = (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<f32> = 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<f32> = (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<f32>| {
|
||||
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<Point>| {
|
||||
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<u8> {
|
||||
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<u8> = 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));
|
||||
}
|
||||
Reference in New Issue
Block a user