Files
effectcraft/crates/track/src/fit.rs
Brandon ThomasandClaude Opus 5.5 65f03366f1 M6.6: mask tracking + mask interpolation core (KLT, RANSAC fits, stabilization math)
Library groundwork for mask tracking (MSK-3) and the Warp Stabilizer (TRK-2),
clean-room from published methods:
- track::klt: Shi-Tomasi corners, pyramidal Lucas-Kanade with forward-backward check
- track::fit: RANSAC + least-squares translation/scale/similarity/affine/homography
- track:😷 MaskTracker following the pixels inside a mask outline per frame
- track::stabilize: incremental clip analysis (serde WarpAnalysis), Gaussian path
  smoothing (Matsushita 2006), No Motion, preserve scale, crop / auto-scale with
  max-scale relaxation
- path::interp: Mask Interpolation correspondence (arc-length vertex insertion,
  shape-context matching) and rigid/linear in-betweens
Engine commands, UI and the effect itself are not wired yet (stubs unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:10:53 -04:00

309 lines
12 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.
//! Robust motion-model fits between point correspondences: **RANSAC** (Fischler & Bolles,
//! "Random Sample Consensus", 1981) with minimal samples, followed by least-squares refits on the
//! consensus set (Hartley & Zisserman, *Multiple View Geometry*, §4.7–4.8).
//!
//! Models, in increasing freedom: translation, translation + uniform scale, similarity (rotation,
//! uniform scale, translation), affine (adds skew / non-uniform scale) and homography
//! (perspective). Every fit is returned as a [`Homography`] matrix mapping `src` to `dst`.
use serde::{Deserialize, Serialize};
use crate::solve::{Homography, solve_linear};
/// A 2D motion model.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Model {
Translation,
TranslationScale,
#[default]
Similarity,
Affine,
Homography,
}
impl Model {
/// Points in a minimal sample.
pub fn min_points(self) -> usize {
match self {
Model::Translation => 1,
Model::TranslationScale | Model::Similarity => 2,
Model::Affine => 3,
Model::Homography => 4,
}
}
}
fn centroid(p: &[[f64; 2]]) -> [f64; 2] {
let n = p.len().max(1) as f64;
[p.iter().map(|q| q[0]).sum::<f64>() / n, p.iter().map(|q| q[1]).sum::<f64>() / n]
}
/// Least-squares fit of `model` taking `src` to `dst` (`None` when degenerate).
pub fn least_squares(model: Model, src: &[[f64; 2]], dst: &[[f64; 2]]) -> Option<Homography> {
let n = src.len().min(dst.len());
if n < model.min_points() {
return None;
}
let (src, dst) = (&src[..n], &dst[..n]);
let (cs, cd) = (centroid(src), centroid(dst));
let h = match model {
Model::Translation => Homography::translation([cd[0] - cs[0], cd[1] - cs[1]]),
Model::TranslationScale | Model::Similarity => {
let (mut ss, mut dot, mut cross) = (0.0, 0.0, 0.0);
for (p, q) in src.iter().zip(dst) {
let (a, b) = ([p[0] - cs[0], p[1] - cs[1]], [q[0] - cd[0], q[1] - cd[1]]);
ss += a[0] * a[0] + a[1] * a[1];
dot += a[0] * b[0] + a[1] * b[1];
cross += a[0] * b[1] - a[1] * b[0];
}
if ss < 1e-12 {
return None;
}
let (a, b) = if model == Model::Similarity { (dot / ss, cross / ss) } else { (dot / ss, 0.0) };
if a.hypot(b) < 1e-9 {
return None;
}
// q = R p + t with R = [[a, -b], [b, a]].
let t = [cd[0] - (a * cs[0] - b * cs[1]), cd[1] - (b * cs[0] + a * cs[1])];
Homography([[a, -b, t[0]], [b, a, t[1]], [0.0, 0.0, 1.0]])
}
Model::Affine => {
let mut m = [[0.0; 3]; 3];
let (mut rx, mut ry) = ([0.0; 3], [0.0; 3]);
for (p, q) in src.iter().zip(dst) {
let v = [p[0] - cs[0], p[1] - cs[1], 1.0];
for i in 0..3 {
for j in 0..3 {
m[i][j] += v[i] * v[j];
}
rx[i] += v[i] * (q[0] - cd[0]);
ry[i] += v[i] * (q[1] - cd[1]);
}
}
let x = solve_linear(m, rx)?;
let y = solve_linear(m, ry)?;
// q - cd = A (p - cs) + t'
let t = [cd[0] + x[2] - x[0] * cs[0] - x[1] * cs[1], cd[1] + y[2] - y[0] * cs[0] - y[1] * cs[1]];
Homography([[x[0], x[1], t[0]], [y[0], y[1], t[1]], [0.0, 0.0, 1.0]])
}
Model::Homography => {
// Normalised DLT with h33 = 1 (normal equations).
let norm = |p: &[[f64; 2]], c: [f64; 2]| {
let d = p.iter().map(|q| (q[0] - c[0]).hypot(q[1] - c[1])).sum::<f64>() / p.len() as f64;
if d > 1e-12 { std::f64::consts::SQRT_2 / d } else { 1.0 }
};
let (ks, kd) = (norm(src, cs), norm(dst, cd));
let mut a = [[0.0; 8]; 8];
let mut b = [0.0; 8];
for (p, q) in src.iter().zip(dst) {
let (x, y) = ((p[0] - cs[0]) * ks, (p[1] - cs[1]) * ks);
let (u, v) = ((q[0] - cd[0]) * kd, (q[1] - cd[1]) * kd);
let rows = [([x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y], u), ([0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y], v)];
for (r, t) in rows {
for i in 0..8 {
b[i] += r[i] * t;
for j in 0..8 {
a[i][j] += r[i] * r[j];
}
}
}
}
let h = solve_linear(a, b)?;
let hn = Homography([[h[0], h[1], h[2]], [h[3], h[4], h[5]], [h[6], h[7], 1.0]]);
let ts = Homography([[ks, 0.0, -ks * cs[0]], [0.0, ks, -ks * cs[1]], [0.0, 0.0, 1.0]]);
let td_inv = Homography([[1.0 / kd, 0.0, cd[0]], [0.0, 1.0 / kd, cd[1]], [0.0, 0.0, 1.0]]);
let m = td_inv.then_after(&hn.then_after(&ts));
if m.0[2][2].abs() < 1e-12 {
return None;
}
m.normalized()
}
};
h.0.iter().flatten().all(|v| v.is_finite()).then_some(h)
}
fn residual2(h: &Homography, p: [f64; 2], q: [f64; 2]) -> f64 {
let m = h.apply(p);
(m[0] - q[0]).powi(2) + (m[1] - q[1]).powi(2)
}
/// Deterministic xorshift generator (reproducible fits).
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
/// A robust fit: the model, which correspondences are inliers, and the RMS inlier error.
#[derive(Clone, Debug)]
pub struct Fit {
pub h: Homography,
pub inliers: Vec<bool>,
pub rms: f64,
}
impl Fit {
pub fn inlier_count(&self) -> usize {
self.inliers.iter().filter(|b| **b).count()
}
}
/// RANSAC fit of `model` with inlier `threshold` (pixels) and at most `iterations` samples.
pub fn ransac(model: Model, src: &[[f64; 2]], dst: &[[f64; 2]], threshold: f64, iterations: usize, seed: u64) -> Option<Fit> {
let n = src.len().min(dst.len());
let k = model.min_points();
if n < k {
return None;
}
let t2 = threshold * threshold;
let count = |h: &Homography| {
let mut c = 0;
let mut e = 0.0;
for i in 0..n {
let r = residual2(h, src[i], dst[i]);
if r < t2 {
c += 1;
e += r;
}
}
(c, e)
};
let mut best: Option<(usize, f64, Homography)> = None;
if n == k {
let h = least_squares(model, src, dst)?;
let (c, e) = count(&h);
best = Some((c, e, h));
} else {
let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
let mut max_iter = iterations.max(1);
let mut it = 0;
let mut idx = vec![0usize; k];
let (mut ss, mut sd) = (vec![[0.0; 2]; k], vec![[0.0; 2]; k]);
while it < max_iter {
it += 1;
// Distinct random sample.
let mut j = 0;
let mut guard = 0;
while j < k && guard < 100 {
guard += 1;
let c = rng.below(n);
if idx[..j].contains(&c) {
continue;
}
idx[j] = c;
j += 1;
}
if j < k {
continue;
}
for (m, &i) in idx.iter().enumerate() {
ss[m] = src[i];
sd[m] = dst[i];
}
let Some(h) = least_squares(model, &ss, &sd) else { continue };
let (c, e) = count(&h);
let better = match &best {
None => true,
Some((bc, be, _)) => c > *bc || (c == *bc && e < *be),
};
if better {
best = Some((c, e, h));
// Adaptive stopping (99 % confidence of an outlier-free sample).
let w = c as f64 / n as f64;
let p_good = w.powi(k as i32);
if p_good > 1e-9 && p_good < 1.0 {
let need = ((1.0 - 0.99f64).ln() / (1.0 - p_good).ln()).ceil();
if need.is_finite() {
max_iter = max_iter.min((need as usize).max(it + 1));
}
} else if p_good >= 1.0 {
break;
}
}
}
}
let (_, _, mut h) = best?;
// Refine on the consensus set (twice: the set can grow after the first refit).
let mut inl: Vec<bool> = (0..n).map(|i| residual2(&h, src[i], dst[i]) < t2).collect();
for _ in 0..2 {
let (s2, d2): (Vec<[f64; 2]>, Vec<[f64; 2]>) = (0..n).filter(|i| inl[*i]).map(|i| (src[i], dst[i])).unzip();
if s2.len() < k {
break;
}
if let Some(r) = least_squares(model, &s2, &d2) {
h = r;
}
inl = (0..n).map(|i| residual2(&h, src[i], dst[i]) < t2).collect();
}
let c = inl.iter().filter(|b| **b).count();
let e: f64 = (0..n).filter(|i| inl[*i]).map(|i| residual2(&h, src[i], dst[i])).sum();
Some(Fit { h, inliers: inl, rms: if c > 0 { (e / c as f64).sqrt() } else { f64::INFINITY } })
}
#[cfg(test)]
mod tests {
use super::*;
fn pts() -> Vec<[f64; 2]> {
let mut v = vec![];
let mut s = 12345u64;
for _ in 0..80 {
s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let x = (s >> 33) as f64 / (1u64 << 31) as f64 * 300.0;
s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let y = (s >> 33) as f64 / (1u64 << 31) as f64 * 200.0;
v.push([x, y]);
}
v
}
#[test]
fn ransac_rejects_outliers_for_every_model() {
let truth = [
(Model::Translation, Homography::translation([5.0, -3.0])),
(Model::TranslationScale, Homography([[1.1, 0.0, -4.0], [0.0, 1.1, 2.0], [0.0, 0.0, 1.0]])),
(Model::Similarity, Homography([[0.98, -0.17, 12.0], [0.17, 0.98, -6.0], [0.0, 0.0, 1.0]])),
(Model::Affine, Homography([[1.05, 0.12, 3.0], [-0.04, 0.93, 1.0], [0.0, 0.0, 1.0]])),
(Model::Homography, Homography([[1.02, 0.05, 4.0], [-0.03, 0.97, 2.0], [0.0004, -0.0003, 1.0]])),
];
for (m, h) in truth {
let src = pts();
let mut dst: Vec<[f64; 2]> = src.iter().map(|p| h.apply(*p)).collect();
// 30 % gross outliers.
for (i, d) in dst.iter_mut().enumerate() {
if i % 10 < 3 {
d[0] += 40.0 + i as f64;
d[1] -= 25.0;
}
}
let f = ransac(m, &src, &dst, 1.0, 500, 7).unwrap();
assert_eq!(f.inlier_count(), 56, "{m:?}");
for p in [[0.0, 0.0], [150.0, 100.0], [300.0, 200.0]] {
let (a, b) = (f.h.apply(p), h.apply(p));
assert!((a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6, "{m:?} {a:?} {b:?}");
}
}
}
#[test]
fn inverse_and_compose() {
let h = Homography([[1.02, 0.05, 4.0], [-0.03, 0.97, 2.0], [0.0004, -0.0003, 1.0]]);
let i = h.inverse().unwrap();
let p = [123.0, 45.0];
let q = i.apply(h.apply(p));
assert!((q[0] - p[0]).abs() < 1e-9 && (q[1] - p[1]).abs() < 1e-9);
let c = Homography::compose(&h, &i);
assert!(c.max_diff(&Homography::IDENTITY) < 1e-9);
}
}