Skip to main content

ffcine_stabilize/
path.rs

1//! Camera path smoothing (vidstab's Gaussian `cameraPathGaussian`) and the static zoom that keeps
2//! every corrected frame inside the source.
3
4use super::motion::Motion;
5use ffcine_core::geom::{self, Mat3};
6
7/// Per-component camera path, about the frame centre: where reference-frame (frame 0) content
8/// lands in frame `i`. Angles are unwrapped, zoom is `ln(scale)` so it smooths multiplicatively.
9#[derive(Debug, Clone, Copy, PartialEq, Default)]
10pub struct Pose {
11    pub tx: f64,
12    pub ty: f64,
13    pub angle: f64,
14    pub log_scale: f64,
15}
16
17impl Pose {
18    fn to_mat3(self, centre: [f64; 2]) -> Mat3 {
19        geom::similarity(centre, self.tx, self.ty, self.angle, self.log_scale.exp())
20    }
21
22    fn components(self) -> [f64; 4] {
23        [self.tx, self.ty, self.angle, self.log_scale]
24    }
25
26    fn from_components([tx, ty, angle, log_scale]: [f64; 4]) -> Self {
27        Self {
28            tx,
29            ty,
30            angle,
31            log_scale,
32        }
33    }
34}
35
36/// Accumulates frame-to-frame motions (`motions[k]` takes frame `k` to `k - 1`; `motions[0]` is
37/// ignored) into absolute poses `A_i` (frame 0 -> frame `i`) by composition:
38/// `A_i = M_i^-1 * A_{i-1}`. vidstab adds the components instead, which is the same to first
39/// order.
40pub fn camera_path(motions: &[Motion], centre: [f64; 2]) -> Vec<Pose> {
41    let mut abs = geom::IDENTITY;
42    let mut out = Vec::with_capacity(motions.len());
43    let mut prev_angle = 0.0;
44    for (k, m) in motions.iter().enumerate() {
45        if k > 0 {
46            // A similarity's inverse is a similarity; fall back to "no motion" if degenerate.
47            let inv = geom::inverse(&m.to_mat3(centre)).unwrap_or(geom::IDENTITY);
48            abs = geom::mul(&inv, &abs);
49        }
50        let (tx, ty, angle, scale) = geom::decompose_similarity(&abs, centre);
51        // Unwrap: keep the angle continuous with the previous frame's.
52        let tau = std::f64::consts::TAU;
53        let angle = angle + tau * ((prev_angle - angle) / tau).round();
54        prev_angle = angle;
55        out.push(Pose {
56            tx,
57            ty,
58            angle,
59            log_scale: scale.ln(),
60        });
61    }
62    out
63}
64
65/// vidstab's Gaussian path filter: window `2 * smoothing + 1`, `mu = smoothing`,
66/// `w(i) = exp(-(i - mu)^2 / (mu / 2)^2)`, truncated and renormalised at the clip ends, per
67/// component. `smoothing == 0` returns the path unchanged.
68pub fn gaussian(path: &[Pose], smoothing: u32) -> Vec<Pose> {
69    if smoothing == 0 {
70        return path.to_vec();
71    }
72    let mu = f64::from(smoothing);
73    let sigma2 = (mu / 2.0).powi(2);
74    let s = 2 * smoothing as usize + 1;
75    let kernel: Vec<f64> = (0..s)
76        .map(|i| (-(i as f64 - mu).powi(2) / sigma2).exp())
77        .collect();
78    let mu = smoothing as usize;
79    (0..path.len())
80        .map(|i| {
81            let (mut sum, mut wsum) = ([0.0; 4], 0.0);
82            for (k, &w) in kernel.iter().enumerate() {
83                let Some(idx) = (i + k).checked_sub(mu).filter(|&j| j < path.len()) else {
84                    continue;
85                };
86                wsum += w;
87                for (acc, v) in sum.iter_mut().zip(path[idx].components()) {
88                    *acc += w * v;
89                }
90            }
91            Pose::from_components(sum.map(|v| v / wsum))
92        })
93        .collect()
94}
95
96/// How the static zoom (about the frame centre, applied to every frame) is chosen.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub enum Zoom {
99    /// The smallest zoom with no black border on any frame (vidstab's `optzoom=1`, but exact:
100    /// it accounts for rotation and scale), capped at `max` if set.
101    Auto { max: Option<f64> },
102    /// Fixed magnification (1.0 = none); borders may show.
103    Fixed(f64),
104}
105
106impl Default for Zoom {
107    fn default() -> Self {
108        Self::Auto { max: None }
109    }
110}
111
112/// Per frame `i`, the warp `W_i = A_i * S_i^-1` (output pixel -> source pixel) that shows content
113/// on the smoothed path `S_i` instead of the actual `A_i`.
114pub fn corrections(actual: &[Pose], smoothed: &[Pose], centre: [f64; 2]) -> Vec<Mat3> {
115    actual
116        .iter()
117        .zip(smoothed)
118        .map(|(a, s)| {
119            let s_inv = geom::inverse(&s.to_mat3(centre)).unwrap_or(geom::IDENTITY);
120            geom::mul(&a.to_mat3(centre), &s_inv)
121        })
122        .collect()
123}
124
125/// The smallest zoom `z >= 1` such that, for every affine warp, the output frame's corners scaled
126/// by `1 / z` about the centre and then warped land inside the `w x h` source. Exact for affine
127/// warps: the warped output rectangle is convex, so its corners decide. `f64::INFINITY` if some
128/// warp moves the frame centre outside the source (no zoom helps).
129pub fn min_zoom(warps: &[Mat3], width: f64, height: f64) -> f64 {
130    let c = [width / 2.0, height / 2.0];
131    let corners = [[0.0, 0.0], [width, 0.0], [0.0, height], [width, height]];
132    let mut z: f64 = 1.0;
133    for m in warps {
134        let p0 = geom::apply(m, c);
135        if !(0.0..=width).contains(&p0[0]) || !(0.0..=height).contains(&p0[1]) {
136            return f64::INFINITY;
137        }
138        // Corner k at zoom z lands at p0 + L (corner - c) / z, L = linear part; each bound gives
139        // an upper limit on u = 1 / z.
140        let mut u_max: f64 = 1.0;
141        for k in corners {
142            let d = [k[0] - c[0], k[1] - c[1]];
143            let v = [
144                m[0][0] * d[0] + m[0][1] * d[1],
145                m[1][0] * d[0] + m[1][1] * d[1],
146            ];
147            for (p, v, hi) in [(p0[0], v[0], width), (p0[1], v[1], height)] {
148                if v > 0.0 {
149                    u_max = u_max.min((hi - p) / v);
150                } else if v < 0.0 {
151                    u_max = u_max.min(p / -v);
152                }
153            }
154        }
155        z = z.max(1.0 / u_max);
156    }
157    z
158}
159
160/// The zoom to apply, given what [`min_zoom`] found.
161pub fn choose_zoom(zoom: Zoom, needed: f64) -> f64 {
162    match zoom {
163        Zoom::Auto { max } => max.map_or(needed, |m| needed.min(m)),
164        Zoom::Fixed(z) => z,
165    }
166}
167
168/// Folds a static zoom into each warp: `W_i * scale_about(centre, 1 / z)`.
169pub fn apply_zoom(warps: &mut [Mat3], centre: [f64; 2], zoom: f64) {
170    let s = geom::scale_about(centre, 1.0 / zoom);
171    for m in warps {
172        *m = geom::mul(m, &s);
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    const C: [f64; 2] = [960.0, 540.0];
181
182    fn motion(tx: f64, ty: f64, angle: f64, scale: f64) -> Motion {
183        Motion {
184            tx,
185            ty,
186            angle,
187            scale,
188            confidence: 1.0,
189        }
190    }
191
192    #[test]
193    fn path_composes_motions() {
194        // Content moves +2 px in x per frame: frame k -> k-1 is -2 px.
195        let m = vec![motion(-2.0, 0.0, 0.0, 1.0); 5];
196        let p = camera_path(&m, C);
197        assert_eq!(p.len(), 5);
198        assert!((p[4].tx - 8.0).abs() < 1e-9 && p[4].ty.abs() < 1e-9);
199        // Rotations add up about the centre.
200        let m = vec![motion(0.0, 0.0, -0.01, 1.0); 4];
201        let p = camera_path(&m, C);
202        assert!((p[3].angle - 0.03).abs() < 1e-12 && p[3].tx.abs() < 1e-9);
203    }
204
205    #[test]
206    fn gaussian_matches_vidstab_kernel() {
207        // A constant path is unchanged, including at the truncated ends.
208        let flat = vec![
209            Pose {
210                tx: 3.0,
211                ty: -1.0,
212                angle: 0.1,
213                log_scale: 0.01
214            };
215            30
216        ];
217        for (a, b) in gaussian(&flat, 10).iter().zip(&flat) {
218            assert!((a.tx - b.tx).abs() < 1e-12 && (a.angle - b.angle).abs() < 1e-12);
219        }
220        // An impulse spreads as the kernel exp(-(k - mu)^2 / (mu/2)^2) / sum.
221        let mut imp = vec![Pose::default(); 41];
222        imp[20].tx = 1.0;
223        let out = gaussian(&imp, 4);
224        let k: Vec<f64> = (0..9)
225            .map(|i| (-(f64::from(i) - 4.0).powi(2) / 4.0).exp())
226            .collect();
227        let sum: f64 = k.iter().sum();
228        for d in 0..=4 {
229            assert!((out[20 + d].tx - k[4 + d] / sum).abs() < 1e-12);
230        }
231        assert!(out[25].tx.abs() < 1e-15);
232        // A linear pan is kept away from the ends (symmetric kernel).
233        let pan: Vec<Pose> = (0..60)
234            .map(|i| Pose {
235                tx: f64::from(i) * 2.0,
236                ..Pose::default()
237            })
238            .collect();
239        let out = gaussian(&pan, 20);
240        assert!((out[30].tx - 60.0).abs() < 1e-9);
241    }
242
243    #[test]
244    fn corrections_cancel_shake_and_zoom_hides_borders() {
245        // Shake of +-4 px in x and +-0.2 deg on a static shot.
246        let m: Vec<Motion> = (0..50)
247            .map(|i| {
248                let s = if i % 2 == 0 { 1.0 } else { -1.0 };
249                motion(8.0 * s, 0.0, 0.0035 * 2.0 * s, 1.0)
250            })
251            .collect();
252        let actual = camera_path(&m, C);
253        let smooth = gaussian(&actual, 15);
254        let w = corrections(&actual, &smooth, C);
255        // The correction is actual - smoothed per component (vidstab), to second order.
256        for (i, wi) in w.iter().enumerate() {
257            let (tx, ty, a, _) = geom::decompose_similarity(wi, C);
258            let d = actual[i].tx - smooth[i].tx;
259            assert!(
260                d.abs() > 3.0 && (tx - d).abs() < 0.1,
261                "frame {i}: {tx} vs {d}"
262            );
263            assert!((ty - (actual[i].ty - smooth[i].ty)).abs() < 0.1);
264            assert!((a - (actual[i].angle - smooth[i].angle)).abs() < 1e-6);
265        }
266        let z = min_zoom(&w, 1920.0, 1080.0);
267        assert!(z > 1.0 && z < 1.03, "{z}");
268        let mut zw = w.clone();
269        apply_zoom(&mut zw, C, z);
270        let tol = 1e-6;
271        for m in &zw {
272            for k in [[0.0, 0.0], [1920.0, 0.0], [0.0, 1080.0], [1920.0, 1080.0]] {
273                let [x, y] = geom::apply(m, k);
274                assert!((-tol..=1920.0 + tol).contains(&x) && (-tol..=1080.0 + tol).contains(&y));
275            }
276        }
277        // Just below the minimum, some corner leaves the source.
278        let mut zw = w;
279        apply_zoom(&mut zw, C, z * 0.999);
280        let out = zw.iter().any(|m| {
281            [[0.0, 0.0], [1920.0, 0.0], [0.0, 1080.0], [1920.0, 1080.0]]
282                .iter()
283                .any(|&k| {
284                    let [x, y] = geom::apply(m, k);
285                    !(0.0..=1920.0).contains(&x) || !(0.0..=1080.0).contains(&y)
286                })
287        });
288        assert!(out);
289        assert!((choose_zoom(Zoom::Auto { max: Some(1.001) }, z) - 1.001).abs() < 1e-12);
290        assert!((choose_zoom(Zoom::Fixed(1.1), z) - 1.1).abs() < 1e-12);
291        assert!((min_zoom(&[geom::IDENTITY], 1920.0, 1080.0) - 1.0).abs() < 1e-12);
292    }
293}