Skip to main content

ffcine_stabilize/
motion.rs

1//! Global motion between two frames from a block flow field: a robust similarity fit (RANSAC on
2//! two-point samples, then iteratively reweighted least squares).
3
4use ffcine_core::geom::{self, Mat3};
5
6/// Frame-to-frame camera motion: the similarity taking pixel positions in frame `k` to where the
7/// same content was in frame `k - 1`, about the frame centre ([`geom::similarity`] parameters).
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct Motion {
10    pub tx: f64,
11    pub ty: f64,
12    /// Radians.
13    pub angle: f64,
14    pub scale: f64,
15    /// Fraction of usable flow vectors that agreed with the fit (0 when the fit failed and this
16    /// is the identity).
17    pub confidence: f64,
18}
19
20impl Motion {
21    pub const IDENTITY: Self = Self {
22        tx: 0.0,
23        ty: 0.0,
24        angle: 0.0,
25        scale: 1.0,
26        confidence: 0.0,
27    };
28
29    pub fn to_mat3(&self, centre: [f64; 2]) -> Mat3 {
30        geom::similarity(centre, self.tx, self.ty, self.angle, self.scale)
31    }
32
33    /// The same motion in a frame `kx` x `ky` times larger (analysis -> source pixels).
34    #[must_use]
35    pub fn rescaled(self, kx: f64, ky: f64) -> Self {
36        Self {
37            tx: self.tx * kx,
38            ty: self.ty * ky,
39            ..self
40        }
41    }
42}
43
44/// A block flow field between two frames: per `grid x grid` block (row-major, `cols x rows`),
45/// the vector `f` from the block's centre `p` in frame `k` to `p + f` in frame `k - 1`, in pixels,
46/// and the engine's match cost (lower is better).
47#[derive(Debug, Clone, Copy)]
48pub struct FlowField<'a> {
49    pub width: u32,
50    pub height: u32,
51    pub grid: u32,
52    pub cols: usize,
53    pub rows: usize,
54    pub flow: &'a [[f32; 2]],
55    pub cost: &'a [u8],
56}
57
58/// Knobs for [`estimate`]; the defaults suit NVOF at grid 4 on a ~1080p analysis frame.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct EstimateParams {
61    /// Ignore blocks within this fraction of the frame size of each edge (borders have no
62    /// counterpart after motion, and lens vignetting/black edges confuse matching).
63    pub border: f64,
64    /// Ignore blocks with an NVOF cost above this (flat or occluded areas).
65    pub max_cost: u8,
66    /// Use every `step`-th block in each direction.
67    pub step: usize,
68    /// Inlier distance in analysis pixels. Generous: depth parallax and lens distortion bend a
69    /// handheld wide-angle shot's background flow away from a pure similarity by a pixel or two.
70    pub threshold: f64,
71    pub iterations: u32,
72    /// Below this inlier fraction the fit is rejected and the motion is the identity.
73    pub min_confidence: f64,
74}
75
76impl Default for EstimateParams {
77    fn default() -> Self {
78        Self {
79            border: 0.04,
80            max_cost: 48,
81            step: 2,
82            threshold: 2.0,
83            iterations: 200,
84            min_confidence: 0.1,
85        }
86    }
87}
88
89/// xorshift64*: deterministic sampling, so detect results (and the cache) are reproducible.
90struct Rng(u64);
91
92impl Rng {
93    fn below(&mut self, n: usize) -> usize {
94        self.0 ^= self.0 >> 12;
95        self.0 ^= self.0 << 25;
96        self.0 ^= self.0 >> 27;
97        ((self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 32) as usize) % n
98    }
99}
100
101/// `q = z * p + t` with `z = a + ib`, on centred coordinates.
102#[derive(Debug, Clone, Copy)]
103struct Sim {
104    a: f64,
105    b: f64,
106    tx: f64,
107    ty: f64,
108}
109
110impl Sim {
111    fn residual2(&self, p: [f64; 2], q: [f64; 2]) -> f64 {
112        let x = self.a * p[0] - self.b * p[1] + self.tx - q[0];
113        let y = self.b * p[0] + self.a * p[1] + self.ty - q[1];
114        x * x + y * y
115    }
116
117    /// Exact fit through two correspondences: `z = (q1 - q2) / (p1 - p2)`, `t = q1 - z p1`.
118    fn from_pairs(p1: [f64; 2], q1: [f64; 2], p2: [f64; 2], q2: [f64; 2]) -> Option<Self> {
119        let (dpx, dpy) = (p1[0] - p2[0], p1[1] - p2[1]);
120        let (dqx, dqy) = (q1[0] - q2[0], q1[1] - q2[1]);
121        let n = dpx * dpx + dpy * dpy;
122        if n < 1e-6 {
123            return None;
124        }
125        let a = (dqx * dpx + dqy * dpy) / n;
126        let b = (dqy * dpx - dqx * dpy) / n;
127        Some(Self {
128            a,
129            b,
130            tx: q1[0] - (a * p1[0] - b * p1[1]),
131            ty: q1[1] - (b * p1[0] + a * p1[1]),
132        })
133    }
134}
135
136/// `q = m * p + t`: the refinement model. A free affine absorbs rolling-shutter skew, which a
137/// similarity would partly read as rotation.
138#[derive(Debug, Clone, Copy)]
139struct Affine {
140    m: [[f64; 2]; 2],
141    t: [f64; 2],
142}
143
144impl Affine {
145    fn from_sim(s: &Sim) -> Self {
146        Self {
147            m: [[s.a, -s.b], [s.b, s.a]],
148            t: [s.tx, s.ty],
149        }
150    }
151
152    fn residual2(&self, p: [f64; 2], q: [f64; 2]) -> f64 {
153        let x = self.m[0][0] * p[0] + self.m[0][1] * p[1] + self.t[0] - q[0];
154        let y = self.m[1][0] * p[0] + self.m[1][1] * p[1] + self.t[1] - q[1];
155        x * x + y * y
156    }
157
158    /// Weighted least squares: with centroids removed, `m = C_qp * C_pp^-1`.
159    fn fit(pts: &[([f64; 2], [f64; 2])], w: &[f64]) -> Option<Self> {
160        let sw: f64 = w.iter().sum();
161        if sw <= 0.0 {
162            return None;
163        }
164        let (mut pc, mut qc) = ([0.0; 2], [0.0; 2]);
165        for ((p, q), &wi) in pts.iter().zip(w) {
166            for i in 0..2 {
167                pc[i] += wi * p[i] / sw;
168                qc[i] += wi * q[i] / sw;
169            }
170        }
171        let (mut cpp, mut cqp) = ([[0.0; 2]; 2], [[0.0; 2]; 2]);
172        for ((p, q), &wi) in pts.iter().zip(w) {
173            let d = [p[0] - pc[0], p[1] - pc[1]];
174            let e = [q[0] - qc[0], q[1] - qc[1]];
175            for i in 0..2 {
176                for j in 0..2 {
177                    cpp[i][j] += wi * d[i] * d[j];
178                    cqp[i][j] += wi * e[i] * d[j];
179                }
180            }
181        }
182        let det = cpp[0][0] * cpp[1][1] - cpp[0][1] * cpp[1][0];
183        if det.abs() <= 1e-9 * (cpp[0][0] * cpp[1][1]).max(1e-30) {
184            return None;
185        }
186        let inv = [
187            [cpp[1][1] / det, -cpp[0][1] / det],
188            [-cpp[1][0] / det, cpp[0][0] / det],
189        ];
190        let mut m = [[0.0; 2]; 2];
191        for i in 0..2 {
192            for j in 0..2 {
193                m[i][j] = cqp[i][0] * inv[0][j] + cqp[i][1] * inv[1][j];
194            }
195        }
196        let t = [
197            qc[0] - m[0][0] * pc[0] - m[0][1] * pc[1],
198            qc[1] - m[1][0] * pc[0] - m[1][1] * pc[1],
199        ];
200        Some(Self { m, t })
201    }
202}
203
204/// Robust global similarity from a flow field, in the field's pixels.
205pub fn estimate(field: &FlowField<'_>, params: &EstimateParams) -> Motion {
206    let (w, h) = (f64::from(field.width), f64::from(field.height));
207    let centre = [w / 2.0, h / 2.0];
208    let g = f64::from(field.grid);
209    let (mx, my) = (params.border * w, params.border * h);
210    let step = params.step.max(1);
211
212    let mut pts = Vec::with_capacity(field.cols * field.rows / (step * step) + 1);
213    for r in (0..field.rows).step_by(step) {
214        for c in (0..field.cols).step_by(step) {
215            let i = r * field.cols + c;
216            if field.cost[i] > params.max_cost {
217                continue;
218            }
219            let (x, y) = ((c as f64 + 0.5) * g, (r as f64 + 0.5) * g);
220            if x < mx || x > w - mx || y < my || y > h - my {
221                continue;
222            }
223            let [fx, fy] = field.flow[i];
224            let p = [x - centre[0], y - centre[1]];
225            pts.push((p, [p[0] + f64::from(fx), p[1] + f64::from(fy)]));
226        }
227    }
228    if pts.len() < 16 {
229        return Motion::IDENTITY;
230    }
231
232    // RANSAC on 2-point samples, scored on a fixed subset for speed. Pairs closer than a quarter
233    // of the frame are skipped: they pin rotation and scale poorly.
234    let thr2 = params.threshold * params.threshold;
235    let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
236    let score_set: Vec<usize> = (0..pts.len().min(1500))
237        .map(|_| rng.below(pts.len()))
238        .collect();
239    let min_d2 = (w.min(h) / 4.0).powi(2);
240    let mut best: Option<(usize, Sim)> = None;
241    for _ in 0..params.iterations {
242        let (i, j) = (rng.below(pts.len()), rng.below(pts.len()));
243        let (p1, q1) = pts[i];
244        let (p2, q2) = pts[j];
245        if (p1[0] - p2[0]).powi(2) + (p1[1] - p2[1]).powi(2) < min_d2 {
246            continue;
247        }
248        let Some(s) = Sim::from_pairs(p1, q1, p2, q2) else {
249            continue;
250        };
251        let n = score_set
252            .iter()
253            .filter(|&&k| s.residual2(pts[k].0, pts[k].1) < thr2)
254            .count();
255        if best.is_none_or(|(b, _)| n > b) {
256            best = Some((n, s));
257        }
258    }
259    let Some((_, sim)) = best else {
260        return Motion::IDENTITY;
261    };
262
263    // IRLS with Cauchy-like weights inside a shrinking hard gate (moving subjects drop out).
264    let mut fit = Affine::from_sim(&sim);
265    let mut weights = vec![0.0; pts.len()];
266    for gate in [4.0, 2.0, 1.0, 1.0, 1.0] {
267        let g2 = thr2 * gate * gate;
268        for (wi, (p, q)) in weights.iter_mut().zip(&pts) {
269            let r2 = fit.residual2(*p, *q);
270            *wi = if r2 < g2 {
271                1.0 / (1.0 + r2 / thr2)
272            } else {
273                0.0
274            };
275        }
276        match Affine::fit(&pts, &weights) {
277            Some(f) => fit = f,
278            None => return Motion::IDENTITY,
279        }
280    }
281    let inliers = pts
282        .iter()
283        .filter(|(p, q)| fit.residual2(*p, *q) < thr2)
284        .count();
285    let confidence = inliers as f64 / pts.len() as f64;
286    if confidence < params.min_confidence {
287        return Motion::IDENTITY;
288    }
289    let m = fit.m;
290    // Rotation and scale from the first column: horizontal pans skew rows (m01) under a rolling
291    // shutter, and the x axis stays clean of that.
292    Motion {
293        tx: fit.t[0],
294        ty: fit.t[1],
295        angle: m[1][0].atan2(m[0][0]),
296        scale: m[0][0].hypot(m[1][0]),
297        confidence,
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    /// A field generated by `truth` (frame k -> k-1), with deterministic noise, an outlier
306    /// "subject" moving on its own, and high-cost blocks carrying garbage.
307    /// `shear` adds rolling-shutter skew about the centre: x += shear * (y - h/2).
308    fn field(truth: &Motion, shear: f64, w: u32, h: u32) -> (Vec<[f32; 2]>, Vec<u8>, usize, usize) {
309        let grid = 4;
310        let (cols, rows) = ((w / grid) as usize, (h / grid) as usize);
311        let cy = f64::from(h) / 2.0;
312        let skew = [[1.0, shear, -shear * cy], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
313        let m = geom::mul(&skew, &truth.to_mat3([f64::from(w) / 2.0, cy]));
314        let mut rng = Rng(42);
315        let mut noise = || (rng.below(1000) as f64 / 1000.0 - 0.5) * 0.5;
316        let (mut flow, mut cost) = (Vec::new(), Vec::new());
317        for r in 0..rows {
318            for c in 0..cols {
319                let p = [(c as f64 + 0.5) * 4.0, (r as f64 + 0.5) * 4.0];
320                let q = geom::apply(&m, p);
321                let mut f = [q[0] - p[0] + noise(), q[1] - p[1] + noise()];
322                let mut k = 10;
323                // A subject in the middle third, moving 12 px right on its own.
324                if (cols / 3..cols / 2).contains(&c) && (rows / 3..2 * rows / 3).contains(&r) {
325                    f[0] += 12.0;
326                }
327                // Flat sky: garbage vectors, flagged by cost.
328                if r < rows / 6 {
329                    f = [noise() * 80.0, noise() * 80.0];
330                    k = 200;
331                }
332                flow.push([f[0] as f32, f[1] as f32]);
333                cost.push(k);
334            }
335        }
336        (flow, cost, cols, rows)
337    }
338
339    #[test]
340    fn recovers_similarity_with_outliers() {
341        let truth = Motion {
342            tx: 6.3,
343            ty: -2.1,
344            angle: 0.004,
345            scale: 1.003,
346            confidence: 1.0,
347        };
348        let (w, h) = (1920, 1080);
349        let (flow, cost, cols, rows) = field(&truth, 0.0, w, h);
350        let f = FlowField {
351            width: w,
352            height: h,
353            grid: 4,
354            cols,
355            rows,
356            flow: &flow,
357            cost: &cost,
358        };
359        let m = estimate(&f, &EstimateParams::default());
360        assert!((m.tx - truth.tx).abs() < 0.02, "{m:?}");
361        assert!((m.ty - truth.ty).abs() < 0.02, "{m:?}");
362        assert!((m.angle - truth.angle).abs() < 2e-5, "{m:?}");
363        assert!((m.scale - truth.scale).abs() < 2e-5, "{m:?}");
364        // The subject is about 1/18 of the usable blocks.
365        assert!(m.confidence > 0.85 && m.confidence < 0.97, "{m:?}");
366    }
367
368    #[test]
369    fn rolling_shutter_skew_is_not_rotation() {
370        let truth = Motion {
371            tx: -14.0,
372            ty: 1.2,
373            angle: 0.001,
374            scale: 1.0,
375            confidence: 1.0,
376        };
377        let (w, h) = (1920, 1080);
378        let (flow, cost, cols, rows) = field(&truth, 0.004, w, h);
379        let f = FlowField {
380            width: w,
381            height: h,
382            grid: 4,
383            cols,
384            rows,
385            flow: &flow,
386            cost: &cost,
387        };
388        let m = estimate(&f, &EstimateParams::default());
389        assert!((m.tx - truth.tx).abs() < 0.05, "{m:?}");
390        assert!((m.ty - truth.ty).abs() < 0.05, "{m:?}");
391        assert!((m.angle - truth.angle).abs() < 3e-5, "{m:?}");
392    }
393
394    #[test]
395    fn rejects_garbage() {
396        let flow = vec![[0.0_f32; 2]; 480 * 270];
397        let cost = vec![255_u8; 480 * 270];
398        let f = FlowField {
399            width: 1920,
400            height: 1080,
401            grid: 4,
402            cols: 480,
403            rows: 270,
404            flow: &flow,
405            cost: &cost,
406        };
407        assert_eq!(estimate(&f, &EstimateParams::default()), Motion::IDENTITY);
408    }
409}