effectcraft/crates/automation/tests/qa/tracking.rs

157 lines
7.7 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Scenario 4 — tracking and roto on synthetic footage: a point track applied to a null, Warp
//! Stabilizer on a shaking plate, Roto Brush propagation and Content-Aware Fill, each checked
//! numerically against the known motion.
use serde_json::{Value, json};
use crate::harness::{Qa, luma};
const N: u32 = 12;
/// Deterministic texture (value noise) so trackers have features to lock onto.
fn texture(x: i64, y: i64) -> u8 {
let h = |x: i64, y: i64| {
let mut v = (x.wrapping_mul(374_761_393) ^ y.wrapping_mul(668_265_263)) as u64;
v = (v ^ (v >> 13)).wrapping_mul(1_274_126_177);
((v ^ (v >> 16)) & 0xff) as u8
};
let (cx, cy) = (x.div_euclid(6), y.div_euclid(6));
40 + h(cx, cy) / 3
}
/// Where the square is on frame `f`.
fn square_center(f: u32) -> (f64, f64) {
(40.0 + f as f64 * 6.0, 45.0 + f as f64 * 1.0)
}
/// A textured plate with a white, black-dotted square moving right; or (`shake`) the plate
/// itself jittering by a known offset per frame.
fn write_plate(qa: &Qa, dir: &str, shake: bool) -> String {
std::fs::create_dir_all(qa.dir.join(dir)).unwrap();
for f in 0..N {
let (sx, sy) = if shake { shake_offset(f) } else { (0, 0) };
let (cx, cy) = square_center(f);
let img = image::RgbaImage::from_fn(160, 90, |x, y| {
let (px, py) = (x as i64 - sx, y as i64 - sy);
if !shake && (x as f64 - cx).abs() < 10.0 && (y as f64 - cy).abs() < 10.0 {
// The object: white with a dark centre dot (a trackable feature).
let dot = (x as f64 - cx).abs() < 3.0 && (y as f64 - cy).abs() < 3.0;
return if dot { image::Rgba([20, 20, 20, 255]) } else { image::Rgba([250, 250, 250, 255]) };
}
let v = texture(px, py);
image::Rgba([v, v / 2 + 30, 255 - v, 255])
});
img.save(qa.dir.join(format!("{dir}/f_{f:03}.png"))).unwrap();
}
qa.path(&format!("{dir}/f_000.png"))
}
fn shake_offset(f: u32) -> (i64, i64) {
[(0, 0), (3, -2), (-2, 3), (4, 1), (-3, -3), (2, 2), (-4, 0), (1, -4), (3, 3), (-2, -1), (0, 4), (-3, 2)][f as usize]
}
/// Import a sequence at 12 fps and make a comp of it; returns (comp, layer).
fn plate_comp(qa: &mut Qa, first: &str, name: &str) -> (Value, Value) {
let item = qa.exec("file.import", json!({"paths": [first]}))["items"][0].clone();
qa.exec("file.interpretFootage", json!({"items": [item.clone()], "frameRate": 12}));
let comp = qa.exec("comp.new", json!({"name": name, "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
let layer = qa.exec("layer.addItem", json!({"item": item}))["layer"].clone();
(comp, layer)
}
#[test]
fn point_track_to_null() {
let mut qa = Qa::new("track");
let first = write_plate(&qa, "plate", false);
let (_, plate) = plate_comp(&mut qa, &first, "Track");
let follow = qa.exec("layer.newNull", json!({"name": "Follow"}))["layer"].clone();
// Track the plate; the Motion Target is the null.
qa.exec("track.new", json!({"layer": plate.clone(), "kind": "transform", "target": follow.clone()}));
let (cx, cy) = square_center(0);
qa.exec("track.setPoint", json!({"layer": plate.clone(), "point": 1, "center": [cx, cy], "featureSize": [24, 24], "searchSize": [48, 48], "time": 0}));
qa.exec("time.set", json!({"time": 0}));
qa.exec("track.analyze", json!({"layer": plate.clone(), "direction": "forward", "wait": true}));
let st = qa.exec("track.status", json!({"layer": plate.clone()}));
assert!(st.to_string().contains("featureCenter") || st.is_object(), "{st}");
qa.exec("track.apply", json!({"layer": plate.clone(), "dimensions": "xy"}));
// The null now follows the square, frame by frame, to within a pixel.
for f in [0u32, 5, 11] {
let t = f as f64 / 12.0;
let p = qa.tool("get_property", json!({"layer": follow.clone(), "path": "transform/position", "time": t}));
let v = p["value"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect::<Vec<_>>();
let (ex, ey) = square_center(f);
assert!((v[0] - ex).abs() < 1.0 && (v[1] - ey).abs() < 1.0, "frame {f}: null at {v:?}, square at ({ex}, {ey})");
}
}
#[test]
fn warp_stabilizer_steadies_a_shaking_plate() {
let mut qa = Qa::new("warp");
let first = write_plate(&qa, "shake", true);
let (_, plate) = plate_comp(&mut qa, &first, "Shaky");
let jitter = |qa: &mut Qa| {
// Mean frame-to-frame difference over the middle of the frame (borders may be cropped
// or scaled).
let frames: Vec<_> = (0..N).map(|f| qa.frame(None, f as f64 / 12.0)).collect();
let mut s = 0.0;
for w in frames.windows(2) {
let mut d = 0.0;
for y in 25..65 {
for x in 40..120 {
d += (luma(w[0].get_pixel(x, y)) - luma(w[1].get_pixel(x, y))).abs();
}
}
s += d / (40.0 * 80.0);
}
s / (N - 1) as f64
};
let before = jitter(&mut qa);
qa.exec("track.warpStabilizer", json!({"layer": plate.clone(), "wait": true}));
qa.tool("set_property", json!({"layer": plate.clone(), "path": "effects/#1/stabilization/result", "value": 1}));
let st = qa.exec("warp.status", json!({"layer": plate.clone()}));
assert_eq!(st["analyzed"], true, "{st}");
let after = jitter(&mut qa);
assert!(after < before * 0.5, "No Motion stabilisation removes most of the shake: {before} → {after}");
}
#[test]
fn roto_brush_propagates_and_content_aware_fill_removes_the_object() {
let mut qa = Qa::new("roto");
let first = write_plate(&qa, "plate", false);
let (_, plate) = plate_comp(&mut qa, &first, "Roto");
// Paint the square as foreground and the plate around it as background on frame 0.
let (cx, cy) = square_center(0);
qa.exec(
"roto.stroke",
json!({"layer": plate.clone(), "kind": "fg", "frame": 0, "radius": 3, "points": [[cx - 5.0, cy - 5.0], [cx + 5.0, cy + 5.0], [cx - 5.0, cy + 5.0]]}),
);
qa.exec(
"roto.stroke",
json!({"layer": plate.clone(), "kind": "bg", "frame": 0, "radius": 4, "points": [[5, 5], [150, 5], [150, 85], [5, 85], [5, 5], [cx + 25.0, cy]]}),
);
qa.exec("roto.propagate", json!({"layer": plate.clone(), "direction": "forward", "to": N - 1, "wait": true}));
for f in [0u32, 6, 11] {
let st = qa.exec("roto.status", json!({"layer": plate.clone(), "frame": f, "compute": true}));
let (ex, ey) = square_center(f);
let c = st["centroid"].as_array().unwrap_or_else(|| panic!("frame {f}: {st}"));
let (mx, my) = (c[0].as_f64().unwrap(), c[1].as_f64().unwrap());
assert!((mx - ex).abs() < 3.0 && (my - ey).abs() < 3.0, "frame {f}: matte centroid ({mx}, {my}) vs square ({ex}, {ey})");
let area = st["area"].as_f64().unwrap();
assert!((area - 400.0).abs() < 160.0, "frame {f}: matte area {area} ≈ 20×20");
}
// Content-Aware Fill: a mask around the object's path, Object fill, then the square is gone.
let roto_fx = qa.exec("effect.remove", json!({"layer": plate.clone(), "effect": 1}));
let _ = roto_fx;
qa.exec("layer.addMask", json!({"layer": plate.clone(), "shape": "rect", "rect": [20, 25, 120, 40], "mode": "Subtract"}));
let caf =
qa.exec("layer.newContentAwareFill", json!({"layer": plate.clone(), "method": "object", "range": "entire", "outputDir": qa.path("caf"), "wait": true}));
assert!(caf["layer"].is_u64() || caf.is_object(), "{caf}");
for f in [0u32, 6] {
let img = qa.frame(None, f as f64 / 12.0);
let (ex, ey) = square_center(f);
let p = img.get_pixel(ex as u32 + 6, ey as u32 + 6);
assert!(luma(p) < 200.0, "frame {f}: the white square is filled with plate texture: {p:?}");
}
}