Files
effectcraft/crates/track/src/tests.rs
T
Brandon ThomasandClaude Opus 5.5 f2d481ce20 Tracker: the 1080p timing test takes the fastest step, so a loaded FreeBSD VM doesn't fail it
The nightly FreeBSD run measured 119 ms/frame against a 100 ms limit: the mean included
Tracker::new and any scheduling stalls on the two-core VM. The best single step with a
250 ms bound still catches an order-of-magnitude regression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 04:47:27 -04:00

261 lines
11 KiB
Rust
Raw 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.
//! Synthetic footage: a textured patch moving along a known path (with rotation and scale) over a
//! textured background, with noise. Ground truth is exact, so recovered positions are compared
//! against it directly.
use super::*;
/// Smooth, band-limited texture (periods of 9–40 px).
fn texture(x: f64, y: f64, seed: u32) -> [f32; 3] {
let mut v = [0.0f64; 3];
let mut s = seed.wrapping_mul(2654435761).wrapping_add(12345);
let mut rnd = || {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
(s % 10_000) as f64 / 10_000.0
};
for _ in 0..7 {
let ang = rnd() * std::f64::consts::TAU;
let period = 9.0 + rnd() * 31.0;
let phase = rnd() * std::f64::consts::TAU;
let k = std::f64::consts::TAU / period;
let w = (k * (x * ang.cos() + y * ang.sin()) + phase).sin();
let c = [rnd(), rnd(), rnd()];
for i in 0..3 {
v[i] += w * (0.3 + c[i]) * 0.12;
}
}
v.map(|q| (0.5 + q).clamp(0.0, 1.0) as f32)
}
fn noise(x: u32, y: u32, f: u32) -> f32 {
effectcraft_raster::hash_noise(x, y.wrapping_add(f.wrapping_mul(7919)), 99) - 0.5
}
/// Patch pose on a frame: centre, rotation (degrees), scale.
#[derive(Clone, Copy)]
struct Pose {
c: [f64; 2],
rot: f64,
scale: f64,
}
impl Pose {
/// Where the patch-local point `u` lands in the frame.
fn map(&self, u: [f64; 2]) -> [f64; 2] {
let (s, c) = self.rot.to_radians().sin_cos();
[self.c[0] + self.scale * (c * u[0] - s * u[1]), self.c[1] + self.scale * (s * u[0] + c * u[1])]
}
fn local(&self, p: [f64; 2]) -> [f64; 2] {
let (s, c) = self.rot.to_radians().sin_cos();
let d = [(p[0] - self.c[0]) / self.scale, (p[1] - self.c[1]) / self.scale];
[c * d[0] + s * d[1], -s * d[0] + c * d[1]]
}
}
/// Render a frame: background texture, the patch (half size `half`) on top, Gaussian-ish noise
/// of amplitude `amp`, and an optional flat occluder rectangle.
fn frame(w: u32, h: u32, pose: Pose, half: f64, amp: f32, f: u32, occluder: Option<[f64; 4]>) -> Image {
let mut img = Image::new(w, h);
let ss = [0.25, 0.75];
for y in 0..h {
for x in 0..w {
let mut acc = [0.0f32; 3];
for sy in ss {
for sx in ss {
let p = [x as f64 + sx, y as f64 + sy];
let l = pose.local(p);
let t = if l[0].abs() <= half && l[1].abs() <= half { texture(l[0], l[1], 7) } else { texture(p[0] * 0.7, p[1] * 0.7, 3).map(|v| v * 0.6) };
for i in 0..3 {
acc[i] += t[i] * 0.25;
}
}
}
let n = noise(x, y, f) * amp;
let mut px = [acc[0] + n, acc[1] + n, acc[2] + n, 1.0];
if let Some([ox, oy, ow, oh]) = occluder {
let (cx, cy) = (x as f64 + 0.5, y as f64 + 0.5);
if cx >= ox && cx < ox + ow && cy >= oy && cy < oy + oh {
px = [0.4, 0.4, 0.4, 1.0];
}
}
img.set(x, y, px);
}
}
img
}
fn spec(c: [f64; 2], feature: f64, search: f64) -> PointSpec {
PointSpec { center: c, feature_size: [feature, feature], search_offset: [0.0, 0.0], search_size: [search, search] }
}
fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt()
}
#[test]
fn recovers_translation_path_subpixel() {
let path = |f: usize| {
let t = f as f64;
Pose { c: [100.0 + 4.37 * t + 6.0 * (t * 0.3).sin(), 90.0 + 2.71 * t - 3.0 * (t * 0.21).cos()], rot: 0.0, scale: 1.0 }
};
let f0 = frame(320, 240, path(0), 40.0, 0.02, 0, None);
let mut tr = Tracker::new(TrackOptions::default(), &[spec(path(0).c, 31.0, 71.0)], &Frame::new(&f0));
let mut worst: f64 = 0.0;
for f in 1..24 {
let img = frame(320, 240, path(f), 40.0, 0.02, f as u32, None);
let r = tr.step(&Frame::new(&img))[0];
let e = dist(r.center, path(f).c);
worst = worst.max(e);
assert!(r.confidence > 90.0, "frame {f}: confidence {}", r.confidence);
assert_eq!(r.status, Status::Tracked);
}
eprintln!("worst error {worst:.4} px");
assert!(worst < 0.25, "worst error {worst} px");
}
#[test]
fn layer_offset_and_channels() {
let pose = |f: usize| Pose { c: [120.0 + 3.3 * f as f64, 100.0 - 1.7 * f as f64], rot: 0.0, scale: 1.0 };
for channel in Channel::ALL {
for (blur, enhance) in [(0.0, false), (3.0, false), (0.0, true)] {
let opts = TrackOptions { channel, blur, enhance, ..Default::default() };
let f0 = frame(240, 200, pose(0), 36.0, 0.01, 0, None);
// Layer coordinates are image pixels minus the offset.
let off = [10.0, -5.0];
let c0 = [pose(0).c[0] - off[0], pose(0).c[1] - off[1]];
let mut tr = Tracker::new(opts, &[spec(c0, 29.0, 61.0)], &Frame { img: &f0, offset: off });
for f in 1..6 {
let img = frame(240, 200, pose(f), 36.0, 0.01, f as u32, None);
let r = tr.step(&Frame { img: &img, offset: off })[0];
let want = [pose(f).c[0] - off[0], pose(f).c[1] - off[1]];
assert!(dist(r.center, want) < 0.3, "{channel:?} blur {blur} enhance {enhance} frame {f}: {:?} vs {want:?}", r.center);
}
}
}
}
#[test]
fn two_points_recover_rotation_and_scale() {
let pose = |f: usize| Pose { c: [160.0 + 1.5 * f as f64, 120.0 + 0.8 * f as f64], rot: 1.8 * f as f64, scale: 1.0 + 0.01 * f as f64 };
let u = [[-26.0, 0.0], [26.0, 0.0]];
let p0 = pose(0);
let f0 = frame(320, 240, p0, 52.0, 0.01, 0, None);
let mut tr = Tracker::new(TrackOptions::default(), &u.map(|q| spec(p0.map(q), 23.0, 51.0)), &Frame::new(&f0));
let (a0, l0) = angle_and_length(p0.map(u[0]), p0.map(u[1]));
let mut worst: f64 = 0.0;
for f in 1..20 {
let p = pose(f);
let img = frame(320, 240, p, 52.0, 0.01, f as u32, None);
let r = tr.step(&Frame::new(&img));
for k in 0..2 {
worst = worst.max(dist(r[k].center, p.map(u[k])));
}
let (a, l) = angle_and_length(r[0].center, r[1].center);
assert!((unwrap_degrees(0.0, a - a0) - p.rot).abs() < 0.5, "frame {f}: rotation {} vs {}", a - a0, p.rot);
assert!((l / l0 - p.scale).abs() < 0.01, "frame {f}: scale {} vs {}", l / l0, p.scale);
// The per-feature shape estimate follows too.
assert!((r[0].rotation - p.rot).abs() < 1.0, "frame {f}: feature rotation {}", r[0].rotation);
assert!((r[0].scale - p.scale).abs() < 0.02, "frame {f}: feature scale {}", r[0].scale);
}
eprintln!("worst error {worst:.4} px");
assert!(worst < 0.25, "worst error {worst} px");
}
#[test]
fn confidence_drops_on_occlusion_and_actions() {
let pose = |f: usize| Pose { c: [110.0 + 2.0 * f as f64, 100.0], rot: 0.0, scale: 1.0 };
let occ = |f: usize| (f == 5).then(|| [pose(f).c[0] - 12.0, pose(f).c[1] - 30.0, 40.0, 60.0]);
let run = |action: ConfidenceAction| {
let f0 = frame(240, 200, pose(0), 36.0, 0.01, 0, None);
let opts = TrackOptions { action, ..Default::default() };
let mut tr = Tracker::new(opts, &[spec(pose(0).c, 31.0, 61.0)], &Frame::new(&f0));
(1..9)
.map(|f| {
let img = frame(240, 200, pose(f), 36.0, 0.01, f as u32, occ(f));
tr.step(&Frame::new(&img))[0]
})
.collect::<Vec<_>>()
};
let r = run(ConfidenceAction::Continue);
assert!(r[3].confidence > 95.0, "{}", r[3].confidence);
assert!(r[4].confidence < 70.0, "occluded frame confidence {}", r[4].confidence);
let r = run(ConfidenceAction::Extrapolate);
assert_eq!(r[4].status, Status::Extrapolated);
assert!(dist(r[4].center, pose(5).c) < 0.5, "extrapolated {:?}", r[4].center);
// Back on track after the occlusion.
assert!(dist(r[6].center, pose(7).c) < 0.25 && r[6].status == Status::Tracked);
let r = run(ConfidenceAction::Stop);
assert_eq!(r[4].status, Status::Stopped);
let r = run(ConfidenceAction::Adapt);
assert_eq!(r[4].status, Status::Adapted);
}
#[test]
fn backward_and_whole_pixel() {
let pose = |f: usize| Pose { c: [100.0 + 3.25 * f as f64, 100.0 + 1.5 * f as f64], rot: 0.0, scale: 1.0 };
let last = 6;
let fl = frame(240, 200, pose(last), 36.0, 0.0, 0, None);
let opts = TrackOptions { subpixel: false, ..Default::default() };
let mut tr = Tracker::new(opts, &[spec(pose(last).c, 31.0, 61.0)], &Frame::new(&fl));
for f in (0..last).rev() {
let img = frame(240, 200, pose(f), 36.0, 0.0, 0, None);
let r = tr.step(&Frame::new(&img))[0];
let want = pose(f).c;
// Whole-pixel displacements of the start position.
let d = [r.center[0] - pose(last).c[0], r.center[1] - pose(last).c[1]];
assert!((d[0] - d[0].round()).abs() < 1e-9 && (d[1] - d[1].round()).abs() < 1e-9);
assert!(dist(r.center, want) < 0.75, "frame {f}: {:?} vs {want:?}", r.center);
}
}
/// 1080p, one point: tracking cost per frame (frames are generated outside the timing).
#[test]
fn performance_1080p_single_point() {
let w = 1920;
let h = 1080;
// A cheap textured frame (the full synthetic renderer is slow at 1080p in debug builds).
let make = |dx: f64, dy: f64| {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let (fx, fy) = (x as f64 + 0.5 - dx, y as f64 + 0.5 - dy);
let v = 0.5 + 0.2 * (fx * 0.31).sin() * (fy * 0.17).cos() + 0.15 * ((fx + fy) * 0.11).sin() + 0.1 * ((fx * 0.05).sin() * (fy * 0.23).sin());
img.set(x, y, [v as f32, v as f32, v as f32, 1.0]);
}
}
img
};
let frames: Vec<Image> = (0..6).map(|f| make(2.3 * f as f64, 1.1 * f as f64)).collect();
let c0 = [960.0, 540.0];
let s = PointSpec { center: c0, feature_size: [48.0, 48.0], search_offset: [0.0; 2], search_size: [128.0, 128.0] };
let mut tr = Tracker::new(TrackOptions::default(), &[s], &Frame::new(&frames[0]));
let mut last = PointResult { center: c0, confidence: 0.0, rotation: 0.0, scale: 1.0, status: Status::Tracked };
// The fastest step: a loaded CI machine (the FreeBSD VM runs the whole suite on two cores)
// stalls individual steps, which the best one doesn't see.
let mut per = f64::INFINITY;
for f in frames.iter().skip(1) {
let t0 = std::time::Instant::now();
last = tr.step(&Frame::new(f))[0];
per = per.min(t0.elapsed().as_secs_f64());
}
eprintln!("1080p single-point track: {:.2} ms/frame ({:.0} fps)", per * 1e3, 1.0 / per);
assert!(dist(last.center, [c0[0] + 2.3 * 5.0, c0[1] + 1.1 * 5.0]) < 0.25, "{:?}", last.center);
// Catches an order-of-magnitude regression, not a slow runner.
assert!(per < 0.25, "{per} s per frame");
}
#[test]
fn featureless_regions_stay_put() {
let flat = Image::filled(200, 160, [0.3, 0.3, 0.3, 1.0]);
for action in ConfidenceAction::ALL {
let opts = TrackOptions { action, ..Default::default() };
let mut tr = Tracker::new(opts, &[spec([100.0, 80.0], 21.0, 61.0)], &Frame::new(&flat));
for _ in 0..10 {
let r = tr.step(&Frame::new(&flat))[0];
assert!(dist(r.center, [100.0, 80.0]) < 1e-9, "{action:?}: drifted to {:?}", r.center);
assert!(r.confidence < 1.0);
}
}
}