M13.3: Subspace Warp content-preserving mesh warp, face tracker library

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 04:08:23 -04:00
parent 4210f00302
commit 0bce3f2e49
7 changed files with 1971 additions and 37 deletions

View File

@ -17,8 +17,11 @@
//! Edge Cropping ignores bad edge pixels of the input frames;
//! - **Show Track Points** draws the analysed background features.
//!
//! *Subspace Warp* is approximated by a per-frame perspective warp (no content-preserving
//! mesh warp yet), and *Rolling Shutter Ripple* is stored but has no effect.
//! *Subspace Warp* warps each frame with a content-preserving mesh fitted to the analysis'
//! subspace-smoothed feature trajectories ([`effectcraft_track::subspace`]); analyses made
//! before trajectories were stored fall back to a perspective warp. *Rolling Shutter Ripple*
//! picks the mesh: Automatic Reduction (12 × 8, stiffer) or Enhanced Reduction (twice the rows,
//! softer), which follows the row-wise wobble of rolling-shutter shake more closely.
//!
//! Without an analysis (or with one made for another layer size) the input passes through.
@ -30,7 +33,7 @@ use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use effectcraft_track::Homography;
use effectcraft_track::stabilize::{Framing, Method, Plan, StabResult, StabSettings, WarpAnalysis, plan};
use effectcraft_track::stabilize::{Framing, Method, Plan, Ripple, StabResult, StabSettings, WarpAnalysis, plan};
use rayon::prelude::*;
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
@ -145,6 +148,7 @@ pub fn settings(params: &Params, fps: f64) -> StabSettings {
// AE's 0…100 slider (50 = balanced) onto the planner's −100…100.
crop_less_smooth_more: (f(params, "advanced/cropLessSmoothMore") - 50.0) * 2.0,
fps,
ripple: if e(params, "advanced/rollingShutterRipple") == 1 { Ripple::Enhanced } else { Ripple::Automatic },
}
}
@ -175,7 +179,7 @@ fn settings_key(s: &StabSettings) -> u64 {
for v in [s.smoothness, s.max_scale, s.action_safe, s.additional_scale, s.crop_less_smooth_more, s.fps] {
h.write_u64(v.to_bits());
}
h.write(format!("{:?}{:?}{:?}{:?}", s.result, s.method, s.preserve_scale, s.framing).as_bytes());
h.write(format!("{:?}{:?}{:?}{:?}{:?}", s.result, s.method, s.preserve_scale, s.framing, s.ripple).as_bytes());
h.finish()
}
@ -275,6 +279,13 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
}
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
let Some(inv) = pl.warps[k].inverse() else { return buf };
// Output point → source point of this frame (a mesh lookup for Subspace Warp).
let mesh = pl.meshes.get(k);
let finv = pl.framing.inverse().unwrap_or(Homography::IDENTITY);
let src_of = |p: [f64; 2]| match mesh {
Some(m) => m.sample(finv.apply(p)),
None => inv.apply(p),
};
let (ow, oh) = (((size[0] * sc).ceil() as u32).max(1), ((size[1] * sc).ceil() as u32).max(1));
let synth = matches!(settings(ctx.params, 30.0).framing, Framing::SynthesizeEdges);
let crop = [
@ -306,7 +317,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
{
continue;
}
let q = inv.apply(p);
let q = src_of(p);
if !synth {
*px = sample(src, q);
continue;
@ -337,12 +348,12 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
});
let need = need_m.into_inner().unwrap_or_else(|e| e.into_inner());
if synth && need.iter().any(|w| *w > 0.0) {
synthesize(ctx, &a, k, &inv, &crop, sc, &mut out, &need);
synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need);
}
if b(ctx.params, "advanced/showTrackPoints") {
let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5);
for q in &a.frames[k].points {
let o = pl.warps[k].apply([q[0] as f64, q[1] as f64]);
let Some(o) = pl.output_of(k, [q[0] as f64, q[1] as f64]) else { continue };
dot(&mut out, [o[0] * sc, o[1] * sc], r, [0.25, 0.75, 1.0, 1.0]);
}
}
@ -352,7 +363,16 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from
/// neighbouring frames, nearest first.
#[allow(clippy::too_many_arguments)]
fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, inv: &Homography, crop: &[f64; 4], sc: f64, out: &mut Image, need: &[f32]) {
fn synthesize(
ctx: &EffectCtx,
a: &WarpAnalysis,
k: usize,
src_of: &(dyn Fn([f64; 2]) -> [f64; 2] + Sync),
crop: &[f64; 4],
sc: f64,
out: &mut Image,
need: &[f32],
) {
let Some(host) = ctx.env.host else { return };
let n = a.frames.len();
let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60);
@ -376,7 +396,7 @@ fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, inv: &Homography, cro
.filter(|i| !filled[**i])
.filter_map(|i| {
let (x, y) = (i % ow, i / ow);
let q = inv.apply([(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc]);
let q = src_of([(x as f64 + 0.5) / sc, (y as f64 + 0.5) / sc]);
let qj = m.apply(q);
inside(crop, qj).then(|| (*i, nb.img.sample_bilinear_clamped(qj[0] * nb.scale + nb.offset[0], qj[1] * nb.scale + nb.offset[1])))
})
@ -412,7 +432,7 @@ mod tests {
use effectcraft_track::stabilize::FrameMotion;
fn analysis_json(n: usize, w: f64, h: f64, shift: impl Fn(usize) -> [f64; 2]) -> String {
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 25.0, size: [w, h], detailed: false, frames: vec![] };
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 25.0, size: [w, h], detailed: false, frames: vec![], tracks: vec![] };
for k in 0..n {
let mut fm = FrameMotion::default();
if k > 0 {

View File

@ -28,11 +28,19 @@ pub struct TrackAnalyzer {
frame: u32,
size: [f64; 2],
factor: f64,
/// (analysis long side, wanted features) overriding the options' defaults.
budget: Option<(u32, usize)>,
}
impl TrackAnalyzer {
pub fn new(size: [f64; 2], opts: AnalyzeOpts) -> TrackAnalyzer {
TrackAnalyzer { opts, prev: None, active: vec![], tracks: vec![], frame: 0, size, factor: 1.0 }
TrackAnalyzer { opts, prev: None, active: vec![], tracks: vec![], frame: 0, size, factor: 1.0, budget: None }
}
/// An analyzer working at most at `max_side` pixels with about `want` live features (the
/// Warp Stabilizer's trajectories for Subspace Warp).
pub fn with_budget(size: [f64; 2], max_side: u32, want: usize) -> TrackAnalyzer {
TrackAnalyzer { budget: Some((max_side, want.max(8))), ..TrackAnalyzer::new(size, AnalyzeOpts::default()) }
}
pub fn frames(&self) -> u32 {
@ -45,6 +53,9 @@ impl TrackAnalyzer {
}
fn params(&self) -> (u32, usize) {
if let Some(b) = self.budget {
return b;
}
if self.opts.detailed { (1280, 900) } else { (720, 500) }
}

1142
crates/track/src/face.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@
//! after subtracting the frame's `offset` (see [`Frame`]).
pub mod camtrack;
pub mod face;
pub mod fit;
pub mod klt;
pub mod mask;
@ -26,6 +27,7 @@ pub mod plane;
pub mod roto;
pub mod solve;
pub mod stabilize;
pub mod subspace;
use effectcraft_raster::Image;
use rayon::prelude::*;

View File

@ -37,6 +37,12 @@ pub fn solve_linear<const N: usize>(mut a: [[f64; N]; N], mut b: [f64; N]) -> Op
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Homography(pub [[f64; 3]; 3]);
impl Default for Homography {
fn default() -> Self {
Homography::IDENTITY
}
}
impl Homography {
pub const IDENTITY: Homography = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);

View File

@ -18,14 +18,23 @@
//! Video Stabilization with Robust L1 Optimal Camera Paths" (CVPR 2011), applied here
//! per frame rather than through a linear program.
//!
//!
//! *Subspace Warp* additionally follows long feature trajectories through the clip
//! ([`crate::camtrack::TrackAnalyzer`]) and stabilises each frame with a content-preserving mesh
//! warp fitted to the subspace-smoothed trajectories ([`crate::subspace`]): scenes with depth
//! (parallax) that one homography per frame cannot hold still are stabilised locally. The
//! framing (crop, auto-scale, Maximum Scale relaxation) works the same on meshes.
//!
//! Coordinates are layer pixels; transforms map *source* frame pixels to *stabilized* pixels.
use serde::{Deserialize, Serialize};
use crate::Frame;
use crate::fit::{Model, ransac};
use crate::camtrack::{Track2D, TrackAnalyzer};
use crate::fit::{Model, least_squares, ransac};
use crate::klt::{CornerOpts, GrayPyramid, LkOpts, analysis_factor, good_features, track};
use crate::solve::Homography;
use crate::subspace::{Mesh, MeshOpts, smooth_trajectories, solve_mesh};
/// Inter-frame motion of one frame (from the previous frame to this one) for each model.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -82,6 +91,10 @@ pub struct WarpAnalysis {
pub detailed: bool,
/// Per frame; frame 0's motion is the identity.
pub frames: Vec<FrameMotion>,
/// Long feature trajectories (layer pixels, 0.1 px) for Subspace Warp; empty in analyses
/// made before it (which then stabilise like Perspective).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tracks: Vec<Track2D>,
}
impl WarpAnalysis {
@ -120,6 +133,7 @@ pub struct Analyzer {
prev: Option<GrayPyramid>,
frames: Vec<FrameMotion>,
size: [f64; 2],
trajectories: TrackAnalyzer,
}
fn round_pts(p: &[[f64; 2]], n: usize) -> Vec<[f32; 2]> {
@ -129,7 +143,8 @@ fn round_pts(p: &[[f64; 2]], n: usize) -> Vec<[f32; 2]> {
impl Analyzer {
pub fn new(size: [f64; 2], opts: AnalyzeOpts) -> Analyzer {
Analyzer { opts, prev: None, frames: vec![], size }
let (side, want) = if opts.detailed { (640, 260) } else { (480, 160) };
Analyzer { opts, prev: None, frames: vec![], size, trajectories: TrackAnalyzer::with_budget(size, side, want) }
}
pub fn len(&self) -> usize {
@ -144,6 +159,7 @@ impl Analyzer {
let (max_side, max_feat) = if self.opts.detailed { (960, 700) } else { (480, 350) };
let factor = analysis_factor(frame.img.width, frame.img.height, max_side);
let pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, 3);
self.trajectories.push(frame);
let mut fm = FrameMotion::default();
if let Some(prev) = &self.prev
&& prev.width() == pyr.width()
@ -184,7 +200,17 @@ impl Analyzer {
}
pub fn finish(self, start: f64, frame_duration: f64) -> WarpAnalysis {
WarpAnalysis { version: 1, start, frame_duration, size: self.size, detailed: self.opts.detailed, frames: self.frames }
let mut tracks = self.trajectories.finish(start, frame_duration).tracks;
// Short trajectories hardly constrain the subspace: keep those of at least 6 frames, at
// 0.1 px (smaller JSON).
tracks.retain(|t| t.pts.len() >= 6);
for (i, t) in tracks.iter_mut().enumerate() {
t.id = i as u32;
for p in t.pts.iter_mut() {
*p = [(p[0] * 10.0).round() / 10.0, (p[1] * 10.0).round() / 10.0];
}
}
WarpAnalysis { version: 2, start, frame_duration, size: self.size, detailed: self.opts.detailed, frames: self.frames, tracks }
}
}
@ -205,11 +231,31 @@ pub enum Method {
/// Position, Scale, Rotation.
Similarity,
Perspective,
/// Approximated by Perspective (see the module docs of the effect).
/// Content-preserving mesh warps fitted to subspace-smoothed trajectories.
#[default]
SubspaceWarp,
}
/// Advanced ▸ Rolling Shutter Ripple: how freely Subspace Warp's mesh may bend row by row (a
/// rolling-shutter camera exposes rows at different times, so shake wobbles the frame).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Ripple {
/// A 12 × 8 mesh.
#[default]
Automatic,
/// Twice the rows and a softer shape term: follows row-wise wobble more closely.
Enhanced,
}
impl Ripple {
pub fn mesh(self) -> MeshOpts {
match self {
Ripple::Automatic => MeshOpts { cols: 12, rows: 8, alpha: 1.0, prior: 0.02 },
Ripple::Enhanced => MeshOpts { cols: 12, rows: 16, alpha: 0.5, prior: 0.02 },
}
}
}
/// Borders ▸ Framing.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Framing {
@ -239,6 +285,7 @@ pub struct StabSettings {
pub crop_less_smooth_more: f64,
/// Frames per second (for Smoothness).
pub fps: f64,
pub ripple: Ripple,
}
impl Default for StabSettings {
@ -254,6 +301,7 @@ impl Default for StabSettings {
additional_scale: 100.0,
crop_less_smooth_more: 0.0,
fps: 30.0,
ripple: Ripple::Automatic,
}
}
}
@ -269,8 +317,15 @@ impl StabSettings {
/// The per-frame stabilization of a clip.
#[derive(Clone, Debug, Default)]
pub struct Plan {
/// Final source → output transform per frame (layer pixels), framing scale included.
/// Final source → output transform per frame (layer pixels), framing scale included. For
/// mesh warps, the closest homography (for overlays and edge synthesis).
pub warps: Vec<Homography>,
/// Subspace Warp: per frame, the mesh (stabilised pixels before framing → source pixels);
/// empty for the other methods.
pub meshes: Vec<Mesh>,
/// The framing transform (auto-scale and Additional Scale about the centre) after the
/// stabilisation.
pub framing: Homography,
/// The visible region in output pixels `[x0, y0, x1, y1]` (Stabilize, Crop / Auto-scale);
/// `None` = everything the warp produces.
pub crop: Option<[f64; 4]>,
@ -280,6 +335,24 @@ pub struct Plan {
pub valid_fraction: f64,
}
impl Plan {
/// The source position (frame `k`'s layer pixels) shown at output point `p`.
pub fn source_of(&self, k: usize, p: [f64; 2]) -> Option<[f64; 2]> {
if let Some(m) = self.meshes.get(k) {
let inv = self.framing.inverse()?;
return Some(m.sample(inv.apply(p)));
}
Some(self.warps.get(k)?.inverse()?.apply(p))
}
/// Where source point `q` of frame `k` lands in the output.
pub fn output_of(&self, k: usize, q: [f64; 2]) -> Option<[f64; 2]> {
if let Some(m) = self.meshes.get(k) {
return m.forward(q).map(|p| self.framing.apply(p));
}
Some(self.warps.get(k)?.apply(q))
}
}
fn convex_contains(q: &[[f64; 2]; 4], p: [f64; 2]) -> bool {
let mut sign = 0.0;
for i in 0..4 {
@ -418,14 +491,100 @@ pub fn corrections(a: &WarpAnalysis, s: &StabSettings) -> Vec<Homography> {
out
}
/// One frame's stabilisation before framing.
#[derive(Clone, Debug)]
enum Warp {
H(Homography),
M(Mesh),
}
impl Warp {
fn valid(&self, size: [f64; 2]) -> f64 {
match self {
Warp::H(h) => valid_fraction(h, size),
Warp::M(m) => m.valid_fraction(),
}
}
/// Blend towards the identity (`l` = 1 keeps the warp).
fn relax(&self, l: f64) -> Warp {
match self {
Warp::H(h) => Warp::H(Homography::IDENTITY.lerp(h, l)),
Warp::M(m) => Warp::M(m.lerp_identity(l)),
}
}
fn homography(&self) -> Homography {
match self {
Warp::H(h) => *h,
Warp::M(m) => m.homography(),
}
}
}
/// Remove the uniform scale of the correspondences' best similarity (Preserve Scale): the
/// wanted positions are scaled back about their centroid.
fn unscale(pairs: &mut [([f64; 2], [f64; 2])]) {
let (src, dst): (Vec<[f64; 2]>, Vec<[f64; 2]>) = pairs.iter().copied().unzip();
let Some(h) = least_squares(Model::Similarity, &src, &dst) else { return };
let m = h.0;
let k = (m[0][0] * m[1][1] - m[0][1] * m[1][0]).abs().sqrt();
if k < 1e-6 {
return;
}
let n = dst.len() as f64;
let c = [dst.iter().map(|q| q[0]).sum::<f64>() / n, dst.iter().map(|q| q[1]).sum::<f64>() / n];
for (_, d) in pairs.iter_mut() {
*d = [c[0] + (d[0] - c[0]) / k, c[1] + (d[1] - c[1]) / k];
}
}
/// Subspace Warp's meshes (stabilised → source, before framing) for every frame; `None` for the
/// other methods and for analyses without trajectories. `base`: the perspective corrections
/// ([`corrections`]), the meshes' prior.
pub fn meshes(a: &WarpAnalysis, s: &StabSettings, base: &[Homography]) -> Option<Vec<Mesh>> {
if s.method != Method::SubspaceWarp || a.tracks.is_empty() || base.len() != a.frames.len() {
return None;
}
let n = a.frames.len();
let o = s.ripple.mesh();
let size = a.size;
let pairs: Vec<Vec<([f64; 2], [f64; 2])>> = match s.result {
StabResult::NoMotion => (0..n).map(|t| a.tracks.iter().filter_map(|tr| tr.at(t as u32)).map(|p| (p, base[t].apply(p))).collect()).collect(),
StabResult::SmoothMotion => {
let sigma = s.sigma();
if sigma < 0.3 {
return Some((0..n).map(|_| Mesh::identity(o.cols, o.rows, size)).collect());
}
smooth_trajectories(&a.tracks, n, sigma, 9)
}
};
use rayon::prelude::*;
Some(
pairs
.into_par_iter()
.enumerate()
.map(|(t, mut pr)| {
if s.preserve_scale && pr.len() >= 2 {
unscale(&mut pr);
}
if pr.len() < 6 { Mesh::from_homography(o.cols, o.rows, size, &base[t]) } else { solve_mesh(size, &o, &pr, &base[t]) }
})
.collect(),
)
}
/// Stabilization plan: corrections plus framing.
pub fn plan(a: &WarpAnalysis, s: &StabSettings) -> Plan {
let mut b = corrections(a, s);
use rayon::prelude::*;
let base = corrections(a, s);
let mut b: Vec<Warp> = match meshes(a, s, &base) {
Some(m) => m.into_iter().map(Warp::M).collect(),
None => base.into_iter().map(Warp::H).collect(),
};
let size = a.size;
let c = [size[0] / 2.0, size[1] / 2.0];
let add = (s.additional_scale / 100.0).max(0.01);
let z_add = Homography::scale_about(add, c);
let fracs = |b: &[Homography]| b.iter().map(|h| valid_fraction(h, size)).collect::<Vec<f64>>();
let fracs = |b: &[Warp]| b.par_iter().map(|w| w.valid(size)).collect::<Vec<f64>>();
let mut fr = fracs(&b);
let mut vmin = fr.iter().copied().fold(1.0, f64::min);
let margin = (s.action_safe / 100.0).clamp(0.0, 0.45);
@ -435,22 +594,24 @@ pub fn plan(a: &WarpAnalysis, s: &StabSettings) -> Plan {
// Relax the correction on frames that need more than Maximum Scale.
let target = need_cover / max_scale;
let n = b.len();
let mut lam = vec![1.0f64; n];
for t in 0..n {
if fr[t] >= target {
continue;
}
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
if valid_fraction(&Homography::IDENTITY.lerp(&b[t], mid), size) >= target {
lo = mid;
} else {
hi = mid;
let lam: Vec<f64> = (0..n)
.into_par_iter()
.map(|t| {
if fr[t] >= target {
return 1.0;
}
}
lam[t] = lo;
}
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
if b[t].relax(mid).valid(size) >= target {
lo = mid;
} else {
hi = mid;
}
}
lo
})
.collect();
// Spread the relaxation smoothly (min filter, then a Gaussian of the same radius) so
// the camera doesn't jump.
let r = ((s.sigma() * 0.5).ceil() as usize).max(1);
@ -468,7 +629,7 @@ pub fn plan(a: &WarpAnalysis, s: &StabSettings) -> Plan {
(acc / ws).min(lam[t])
})
.collect();
b = b.iter().zip(&smooth).map(|(h, l)| if *l >= 1.0 { *h } else { Homography::IDENTITY.lerp(h, *l) }).collect();
b = b.iter().zip(&smooth).map(|(w, l)| if *l >= 1.0 { w.clone() } else { w.relax(*l) }).collect();
fr = fracs(&b);
vmin = fr.iter().copied().fold(1.0, f64::min);
}
@ -483,13 +644,23 @@ pub fn plan(a: &WarpAnalysis, s: &StabSettings) -> Plan {
}
};
let z = Homography::scale_about(auto, c);
let warps = b.iter().map(|h| z_add.then_after(&z.then_after(h))).collect();
Plan { warps, crop, auto_scale: auto, valid_fraction: vmin }
let framing = z_add.then_after(&z);
let warps = b.par_iter().map(|w| framing.then_after(&w.homography())).collect();
let meshes = b
.into_iter()
.filter_map(|w| match w {
Warp::M(m) => Some(m),
Warp::H(_) => None,
})
.collect();
Plan { warps, meshes, framing, crop, auto_scale: auto, valid_fraction: vmin }
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_raster::Image;
use rayon::prelude::*;
/// A synthetic analysis: a slow pan plus per-frame jitter.
fn shaky(n: usize) -> (WarpAnalysis, Vec<[f64; 2]>) {
@ -497,7 +668,7 @@ mod tests {
let j = [((k * 7919) % 13) as f64 - 6.0, ((k * 104729) % 11) as f64 - 5.0];
[2.0 * k as f64 + j[0], 0.5 * k as f64 + j[1]]
};
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 30.0, size: [640.0, 360.0], detailed: false, frames: vec![] };
let mut a = WarpAnalysis { version: 1, start: 0.0, frame_duration: 1.0 / 30.0, size: [640.0, 360.0], detailed: false, frames: vec![], tracks: vec![] };
let path: Vec<[f64; 2]> = (0..n).map(pos).collect();
for k in 0..n {
let mut f = FrameMotion::default();
@ -573,4 +744,139 @@ mod tests {
let j = WarpAnalysis::from_json(&a.to_json()).unwrap();
assert_eq!(j, a);
}
// ------------------------------------------------------------ synthetic shaky footage
fn hash(i: i64, j: i64, k: i64) -> f64 {
let mut v = (i.wrapping_mul(73_856_093) ^ j.wrapping_mul(19_349_663) ^ k.wrapping_mul(83_492_791)) as u64;
v ^= v >> 13;
v = v.wrapping_mul(0x5bd1_e995);
v ^= v >> 15;
(v % 10_000) as f64 / 10_000.0
}
fn value_noise(x: f64, y: f64, cell: f64, seed: i64) -> f64 {
let (fx, fy) = (x / cell, y / cell);
let (i, j) = (fx.floor() as i64, fy.floor() as i64);
let (u, v) = (fx - i as f64, fy - j as f64);
let (u, v) = (u * u * (3.0 - 2.0 * u), v * v * (3.0 - 2.0 * v));
let a = hash(i, j, seed) * (1.0 - u) + hash(i + 1, j, seed) * u;
let b = hash(i, j + 1, seed) * (1.0 - u) + hash(i + 1, j + 1, seed) * u;
a * (1.0 - v) + b * v
}
fn texture(x: f64, y: f64) -> f32 {
(0.55 * value_noise(x, y, 5.0, 1) + 0.45 * value_noise(x, y, 13.0, 2)) as f32
}
/// A scene with depth: a near bump in the middle moves `1 + depth` times as much as the
/// background when the camera shakes (parallax a homography cannot model).
struct Scene {
size: [f64; 2],
cams: Vec<[f64; 2]>,
}
impl Scene {
fn new(n: usize) -> Scene {
let cams = (0..n)
.map(|t| {
let j = [5.0 * (hash(t as i64, 7, 3) - 0.5), 5.0 * (hash(t as i64, 11, 5) - 0.5)];
[0.4 * t as f64 + j[0], 0.15 * t as f64 + j[1]]
})
.collect();
Scene { size: [320.0, 180.0], cams }
}
fn depth(&self, s: [f64; 2]) -> f64 {
1.0 + 1.2 * (-((s[0] - 160.0).powi(2) + (s[1] - 90.0).powi(2)) / (2.0 * 38.0f64.powi(2))).exp()
}
/// Where scene point `s` appears on frame `t`.
fn image_of(&self, t: usize, s: [f64; 2]) -> [f64; 2] {
let (c, d) = (self.cams[t], self.depth(s));
[s[0] + c[0] * d, s[1] + c[1] * d]
}
fn frame(&self, t: usize) -> Image {
let (w, h) = (self.size[0] as u32, self.size[1] as u32);
let mut im = Image::new(w, h);
let c = self.cams[t];
im.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let p = [x as f64 + 0.5, y as f64 + 0.5];
let mut s = p;
for _ in 0..10 {
let d = self.depth(s);
s = [p[0] - c[0] * d, p[1] - c[1] * d];
}
let v = texture(s[0], s[1]);
*px = [v, v, v, 1.0];
}
});
im
}
}
fn residual_jitter(scene: &Scene, out: impl Fn(usize, [f64; 2]) -> Option<[f64; 2]>) -> f64 {
let n = scene.cams.len();
let mut tot = 0.0;
let mut cnt = 0;
for gy in 0..5 {
for gx in 0..7 {
let s = [70.0 + gx as f64 * 30.0, 45.0 + gy as f64 * 22.0];
let ys: Vec<Option<[f64; 2]>> = (0..n).map(|t| out(t, scene.image_of(t, s))).collect();
for t in 6..n - 6 {
if let (Some(a), Some(b), Some(c)) = (ys[t - 1], ys[t], ys[t + 1]) {
tot += (c[0] - 2.0 * b[0] + a[0]).hypot(c[1] - 2.0 * b[1] + a[1]);
cnt += 1;
}
}
}
}
tot / cnt.max(1) as f64
}
#[test]
fn subspace_warp_beats_perspective_on_parallax() {
let n = 48;
let scene = Scene::new(n);
let mut an = Analyzer::new(scene.size, AnalyzeOpts::default());
for t in 0..n {
let img = scene.frame(t);
an.push(&Frame { img: &img, offset: [0.0; 2] });
}
let a = an.finish(0.0, 1.0 / 24.0);
assert!(a.tracks.len() >= 40, "trajectories: {}", a.tracks.len());
// The analysis round-trips with its trajectories.
assert_eq!(WarpAnalysis::from_json(&a.to_json()).unwrap(), a);
let base = StabSettings { framing: Framing::StabilizeOnly, fps: 24.0, ..Default::default() };
let before = residual_jitter(&scene, |_, x| Some(x));
let ppl = plan(&a, &StabSettings { method: Method::Perspective, ..base });
assert!(ppl.meshes.is_empty());
let persp = residual_jitter(&scene, |t, x| ppl.output_of(t, x));
let spl = plan(&a, &StabSettings { method: Method::SubspaceWarp, ..base });
assert_eq!(spl.meshes.len(), n);
let sub = residual_jitter(&scene, |t, x| spl.output_of(t, x));
let epl = plan(&a, &StabSettings { method: Method::SubspaceWarp, ripple: Ripple::Enhanced, ..base });
let enh = residual_jitter(&scene, |t, x| epl.output_of(t, x));
eprintln!("jitter: before {before:.3}, perspective {persp:.3}, subspace {sub:.3}, enhanced {enh:.3}");
assert!(persp < 0.8 * before, "perspective {persp} vs {before}");
assert!(sub < 0.6 * persp, "subspace {sub} vs perspective {persp}");
assert!(enh < 0.6 * persp, "enhanced {enh} vs perspective {persp}");
// source_of and output_of agree.
let q = [150.0, 80.0];
let o = spl.output_of(10, q).unwrap();
let back = spl.source_of(10, o).unwrap();
assert!((back[0] - q[0]).abs() < 1e-3 && (back[1] - q[1]).abs() < 1e-3);
// Auto-scale framing covers the frame with mesh warps too.
let fpl = plan(&a, &StabSettings { method: Method::SubspaceWarp, fps: 24.0, ..Default::default() });
assert!(fpl.auto_scale >= 1.0);
for k in [0, n / 2, n - 1] {
for p in [[1.0, 1.0], [319.0, 1.0], [319.0, 179.0], [1.0, 179.0]] {
let q = fpl.source_of(k, p).unwrap();
let inside = q[0] >= -0.5 && q[1] >= -0.5 && q[0] <= 320.5 && q[1] <= 180.5;
assert!(inside || fpl.crop.is_some(), "frame {k}: {p:?} → {q:?}");
}
}
// Preserve Scale keeps working with meshes.
let psp = plan(&a, &StabSettings { method: Method::SubspaceWarp, preserve_scale: true, ..base });
assert_eq!(psp.meshes.len(), n);
}
}

View File

@ -0,0 +1,447 @@
//! Warp Stabilizer ▸ Method ▸ **Subspace Warp**: content-preserving warps fitted to smoothed
//! feature trajectories, clean-room from the published method:
//!
//! - Liu, Gleicher, Wang, Jin & Agarwala, "Subspace Video Stabilization", ACM TOG 30(1), 2011:
//! the feature trajectories of a short window form a matrix `M` (two rows per trajectory, one
//! column per frame) that is close to low rank (rank 9 for a moving camera over a static
//! scene); its factorisation `M ≈ W C` gives *eigen-trajectories* `C`, which are smoothed (here
//! with a Gaussian) instead of the trajectories themselves. Every trajectory's smoothed
//! position is `W_i Ĉ_t`; trajectories that do not span the whole window get their
//! coefficients `W_i` by least squares over the frames they cover (the paper's handling of
//! incomplete trajectories in a moving factorisation). Smoothing in the subspace keeps the
//! smoothed trajectories consistent with one another, so the warp does not tear the scene.
//! - Liu, Gleicher, Jin & Agarwala, "Content-Preserving Warps for 3D Video Stabilization", ACM
//! TOG 28(3), 2009: each frame is warped by a grid mesh fitted to the feature correspondences
//! (original → smoothed position) in least squares, with a **similarity term** asking every
//! grid triangle to keep its shape (`V1 = V2 + u (V3 − V2) + v R90 (V3 − V2)` with `u, v` from
//! the undeformed grid) so the warp bends where the scene's depth demands it but stays locally
//! rigid elsewhere.
//!
//! The mesh is stored *inversely*: a regular grid over the stabilised frame whose vertices hold
//! the source position they show ([`Mesh`]), so rendering is a bilinear lookup per output pixel.
//! The data term is the same linear constraint read the other way (the smoothed position's grid
//! cell, bilinearly, must land on the original position). A weak prior ties every vertex to the
//! frame's perspective correction, which fills feature-less regions and keeps the system well
//! posed.
use rayon::prelude::*;
use crate::camtrack::Track2D;
use crate::camtrack::linalg::{cholesky_solve, sym_eigen};
use crate::fit::{Model, least_squares};
use crate::solve::Homography;
/// A warp mesh: a regular `cols × rows` grid over the stabilised frame (`size`), each vertex
/// holding the source-frame position it shows.
#[derive(Clone, Debug, PartialEq)]
pub struct Mesh {
pub cols: usize,
pub rows: usize,
pub size: [f64; 2],
/// `(rows + 1) × (cols + 1)` source positions, row-major.
pub verts: Vec<[f64; 2]>,
}
impl Mesh {
/// The identity mesh.
pub fn identity(cols: usize, rows: usize, size: [f64; 2]) -> Mesh {
let mut m = Mesh { cols: cols.max(1), rows: rows.max(1), size, verts: vec![] };
m.verts = (0..=m.rows).flat_map(|j| (0..=m.cols).map(move |i| (i, j))).map(|(i, j)| m.grid(i, j)).collect();
m
}
/// The mesh of a homography (`h`: source → stabilised).
pub fn from_homography(cols: usize, rows: usize, size: [f64; 2], h: &Homography) -> Mesh {
let inv = h.inverse().unwrap_or(Homography::IDENTITY);
let mut m = Mesh::identity(cols, rows, size);
for v in m.verts.iter_mut() {
*v = inv.apply(*v);
}
m
}
fn cell(&self) -> [f64; 2] {
[self.size[0] / self.cols as f64, self.size[1] / self.rows as f64]
}
/// The undeformed position of vertex `(i, j)` (column, row).
pub fn grid(&self, i: usize, j: usize) -> [f64; 2] {
let c = self.cell();
[i as f64 * c[0], j as f64 * c[1]]
}
fn idx(&self, i: usize, j: usize) -> usize {
j * (self.cols + 1) + i
}
/// Cell and bilinear coordinates of a stabilised point (the outer cells extend beyond the
/// frame, so points outside it extrapolate).
fn locate(&self, p: [f64; 2]) -> (usize, usize, f64, f64) {
let c = self.cell();
let fx = p[0] / c[0];
let fy = p[1] / c[1];
let i = (fx.floor().max(0.0) as usize).min(self.cols - 1);
let j = (fy.floor().max(0.0) as usize).min(self.rows - 1);
(i, j, fx - i as f64, fy - j as f64)
}
/// The source position shown at stabilised point `p`.
pub fn sample(&self, p: [f64; 2]) -> [f64; 2] {
let (i, j, u, v) = self.locate(p);
let a = self.verts[self.idx(i, j)];
let b = self.verts[self.idx(i + 1, j)];
let c = self.verts[self.idx(i, j + 1)];
let d = self.verts[self.idx(i + 1, j + 1)];
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
[0, 1].map(|k| w[0] * a[k] + w[1] * b[k] + w[2] * c[k] + w[3] * d[k])
}
/// Where source point `q` lands in the stabilised frame (Newton iterations on
/// [`Mesh::sample`]); `None` when it does not converge (a folded mesh).
pub fn forward(&self, q: [f64; 2]) -> Option<[f64; 2]> {
// Start from the mesh's own offset at q.
let s = self.sample(q);
let mut p = [2.0 * q[0] - s[0], 2.0 * q[1] - s[1]];
for _ in 0..30 {
let f = self.sample(p);
let e = [f[0] - q[0], f[1] - q[1]];
if e[0].hypot(e[1]) < 1e-6 {
return Some(p);
}
let h = 0.5;
let fx = self.sample([p[0] + h, p[1]]);
let fy = self.sample([p[0], p[1] + h]);
let j = [[(fx[0] - f[0]) / h, (fy[0] - f[0]) / h], [(fx[1] - f[1]) / h, (fy[1] - f[1]) / h]];
let det = j[0][0] * j[1][1] - j[0][1] * j[1][0];
if det.abs() < 1e-12 {
return None;
}
p[0] -= (j[1][1] * e[0] - j[0][1] * e[1]) / det;
p[1] -= (-j[1][0] * e[0] + j[0][0] * e[1]) / det;
}
let f = self.sample(p);
((f[0] - q[0]).hypot(f[1] - q[1]) < 1e-3).then_some(p)
}
/// `(1 − t) · identity + t · self` per vertex.
pub fn lerp_identity(&self, t: f64) -> Mesh {
let mut m = self.clone();
for j in 0..=self.rows {
for i in 0..=self.cols {
let g = self.grid(i, j);
let k = self.idx(i, j);
m.verts[k] = [g[0] + (self.verts[k][0] - g[0]) * t, g[1] + (self.verts[k][1] - g[1]) * t];
}
}
m
}
/// Largest `s ∈ [0, 1]` such that the centred `s·W × s·H` rectangle of the stabilised frame
/// shows only source pixels (its outline, sampled densely, maps inside the source frame).
pub fn valid_fraction(&self) -> f64 {
let [w, h] = self.size;
let c = [w / 2.0, h / 2.0];
let tol = 1e-6;
let inside = |q: [f64; 2]| q[0] >= -tol && q[1] >= -tol && q[0] <= w + tol && q[1] <= h + tol;
let fits = |s: f64| {
let (hw, hh) = (s * w / 2.0, s * h / 2.0);
let n = 48;
(0..=n).all(|k| {
let t = k as f64 / n as f64;
let x = c[0] - hw + 2.0 * hw * t;
let y = c[1] - hh + 2.0 * hh * t;
inside(self.sample([x, c[1] - hh]))
&& inside(self.sample([x, c[1] + hh]))
&& inside(self.sample([c[0] - hw, y]))
&& inside(self.sample([c[0] + hw, y]))
})
};
if fits(1.0) {
return 1.0;
}
if !fits(0.0) {
return 0.0;
}
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
if fits(mid) {
lo = mid;
} else {
hi = mid;
}
}
lo
}
/// The homography (source → stabilised) closest to the mesh in least squares.
pub fn homography(&self) -> Homography {
let mut src = vec![];
let mut dst = vec![];
for j in 0..=self.rows {
for i in 0..=self.cols {
src.push(self.verts[self.idx(i, j)]);
dst.push(self.grid(i, j));
}
}
least_squares(Model::Homography, &src, &dst).unwrap_or(Homography::IDENTITY)
}
}
/// Mesh resolution and rigidity.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeshOpts {
pub cols: usize,
pub rows: usize,
/// Weight of the similarity (shape-preserving) term relative to one feature.
pub alpha: f64,
/// Weight of the prior towards the frame's perspective correction, per vertex.
pub prior: f64,
}
impl Default for MeshOpts {
fn default() -> Self {
MeshOpts { cols: 12, rows: 8, alpha: 1.0, prior: 0.02 }
}
}
/// Normal equations `AᵀA x = Aᵀb`, accumulated row by row.
struct Normal {
n: usize,
a: Vec<f64>,
b: Vec<f64>,
}
impl Normal {
fn row(&mut self, coefs: &[(usize, f64)], rhs: f64, w: f64) {
for (ia, ca) in coefs {
self.b[*ia] += w * ca * rhs;
for (ib, cb) in coefs {
self.a[ia * self.n + ib] += w * ca * cb;
}
}
}
}
/// Fit the content-preserving warp of one frame: `pairs` are (source position, wanted
/// stabilised position) of its features; `base` is the frame's perspective correction (source →
/// stabilised), the prior.
pub fn solve_mesh(size: [f64; 2], o: &MeshOpts, pairs: &[([f64; 2], [f64; 2])], base: &Homography) -> Mesh {
let mut m = Mesh::from_homography(o.cols, o.rows, size, base);
let nv = (m.cols + 1) * (m.rows + 1);
let n = 2 * nv;
let mut ne = Normal { n, a: vec![0.0; n * n], b: vec![0.0; n] };
// Prior.
for k in 0..nv {
let v = m.verts[k];
ne.row(&[(2 * k, 1.0)], v[0], o.prior);
ne.row(&[(2 * k + 1, 1.0)], v[1], o.prior);
}
// Data: the bilinear combination of the wanted position's cell lands on the source position.
let mut used = 0;
for (src, dst) in pairs {
if !(dst[0] >= 0.0 && dst[1] >= 0.0 && dst[0] <= size[0] && dst[1] <= size[1] && src[0].is_finite() && src[1].is_finite()) {
continue;
}
let (i, j, u, v) = m.locate(*dst);
let ks = [m.idx(i, j), m.idx(i + 1, j), m.idx(i, j + 1), m.idx(i + 1, j + 1)];
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
for d in 0..2 {
let coefs: Vec<(usize, f64)> = ks.iter().zip(w).map(|(k, w)| (2 * k + d, w)).collect();
ne.row(&coefs, src[d], 1.0);
}
used += 1;
}
if used == 0 {
return m;
}
// Similarity: four triangles per cell, each corner against its two cell neighbours.
let a2 = o.alpha * o.alpha;
for j in 0..m.rows {
for i in 0..m.cols {
let (a, b, c, d) = ((i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1));
for (v1, v2, v3) in [(a, b, d), (b, c, a), (c, d, b), (d, a, c)] {
let (g1, g2, g3) = (m.grid(v1.0, v1.1), m.grid(v2.0, v2.1), m.grid(v3.0, v3.1));
let dv = [g3[0] - g2[0], g3[1] - g2[1]];
let e = [g1[0] - g2[0], g1[1] - g2[1]];
let l2 = dv[0] * dv[0] + dv[1] * dv[1];
// R90(d) = (−d.y, d.x).
let u = (e[0] * dv[0] + e[1] * dv[1]) / l2;
let v = (e[0] * -dv[1] + e[1] * dv[0]) / l2;
let (k1, k2, k3) = (m.idx(v1.0, v1.1), m.idx(v2.0, v2.1), m.idx(v3.0, v3.1));
// x: V1x − V2x − u (V3x − V2x) + v (V3y − V2y) = 0
ne.row(&[(2 * k1, 1.0), (2 * k2, u - 1.0), (2 * k3, -u), (2 * k3 + 1, v), (2 * k2 + 1, -v)], 0.0, a2);
// y: V1y − V2y − u (V3y − V2y) − v (V3x − V2x) = 0
ne.row(&[(2 * k1 + 1, 1.0), (2 * k2 + 1, u - 1.0), (2 * k3 + 1, -u), (2 * k3, -v), (2 * k2, v)], 0.0, a2);
}
}
}
let mut x = ne.b;
if cholesky_solve(&mut ne.a, n, &mut x) {
for k in 0..nv {
m.verts[k] = [x[2 * k], x[2 * k + 1]];
}
}
m
}
/// Gaussian weights of the frames `lo..=hi` around `t`.
fn gauss(lo: usize, hi: usize, t: usize, sigma: f64) -> Vec<f64> {
let g: Vec<f64> = (lo..=hi).map(|j| (-((j as f64 - t as f64).powi(2)) / (2.0 * sigma * sigma)).exp()).collect();
let s: f64 = g.iter().sum();
g.into_iter().map(|v| v / s.max(1e-300)).collect()
}
/// Subspace-smoothed feature positions: for every frame `t < frames`, the (original, smoothed)
/// positions of the trajectories seen on it. `sigma`: Gaussian smoothing in frames; `rank`: the
/// subspace dimension (9 in the paper). Frames without enough trajectories get no pairs.
pub fn smooth_trajectories(tracks: &[Track2D], frames: usize, sigma: f64, rank: usize) -> Vec<Vec<([f64; 2], [f64; 2])>> {
let max_r = ((3.0 * sigma).ceil() as usize).clamp(1, 30);
(0..frames)
.into_par_iter()
.map(|t| {
let t32 = t as u32;
let mut r = max_r;
// The window around t: shrink it until enough trajectories span it.
let (lo, hi, complete) = loop {
let lo = t.saturating_sub(r);
let hi = (t + r).min(frames.saturating_sub(1));
let complete: Vec<&Track2D> = tracks.iter().filter(|tr| tr.start as usize <= lo && tr.end() as usize > hi).collect();
if complete.len() >= 8 || r <= 2 {
break (lo, hi, complete);
}
r = (r * 2 / 3).max(2);
};
if complete.len() < 4 {
return vec![];
}
let m = hi - lo + 1;
let rows = 2 * complete.len();
let k = rank.min(m).min(rows).max(1);
// M: rows × m (row-major).
let mut mat = vec![0.0; rows * m];
for (i, tr) in complete.iter().enumerate() {
for c in 0..m {
let p = tr.at((lo + c) as u32).unwrap_or([0.0; 2]);
mat[(2 * i) * m + c] = p[0];
mat[(2 * i + 1) * m + c] = p[1];
}
}
// Gram MᵀM and its top eigenvectors: the eigen-trajectories (orthonormal rows of C).
let mut gram = vec![0.0; m * m];
for a in 0..m {
for b in a..m {
let s: f64 = (0..rows).map(|r| mat[r * m + a] * mat[r * m + b]).sum();
gram[a * m + b] = s;
gram[b * m + a] = s;
}
}
let (_, vecs) = sym_eigen(&gram, m);
// C[q][c] = vecs[c][m − 1 − q] (largest first).
let cmat: Vec<Vec<f64>> = (0..k).map(|q| (0..m).map(|c| vecs[c * m + (m - 1 - q)]).collect()).collect();
let g = gauss(lo, hi, t, sigma.max(1e-6));
let chat: Vec<f64> = cmat.iter().map(|row| row.iter().zip(&g).map(|(a, b)| a * b).sum()).collect();
let tc = t - lo;
let mut out = Vec::new();
// Complete trajectories: W = M Cᵀ.
for (i, _) in complete.iter().enumerate() {
let src = [mat[(2 * i) * m + tc], mat[(2 * i + 1) * m + tc]];
let mut dst = [0.0; 2];
for (d, slot) in dst.iter_mut().enumerate() {
let row = &mat[(2 * i + d) * m..(2 * i + d + 1) * m];
for q in 0..k {
let w: f64 = row.iter().zip(&cmat[q]).map(|(a, b)| a * b).sum();
*slot += w * chat[q];
}
}
out.push((src, dst));
}
// Incomplete trajectories seen on t: coefficients by least squares over the frames
// they cover in the window.
let need = (k + 3).max(m / 2);
for tr in tracks.iter().filter(|tr| tr.at(t32).is_some() && !(tr.start as usize <= lo && tr.end() as usize > hi)) {
let cols: Vec<usize> = (0..m).filter(|c| tr.at((lo + c) as u32).is_some()).collect();
if cols.len() < need {
continue;
}
let mut ata = vec![0.0; k * k];
let mut atb = [vec![0.0; k], vec![0.0; k]];
for &c in &cols {
let p = tr.at((lo + c) as u32).expect("covered");
for a in 0..k {
for d in 0..2 {
atb[d][a] += cmat[a][c] * p[d];
}
for b in 0..k {
ata[a * k + b] += cmat[a][c] * cmat[b][c];
}
}
}
for a in 0..k {
ata[a * k + a] += 1e-9;
}
let mut dst = [0.0; 2];
let mut ok = true;
for d in 0..2 {
let mut x = atb[d].clone();
let mut aa = ata.clone();
if !cholesky_solve(&mut aa, k, &mut x) {
ok = false;
break;
}
dst[d] = x.iter().zip(&chat).map(|(a, b)| a * b).sum();
}
if ok {
out.push((tr.at(t32).expect("seen"), dst));
}
}
out
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mesh_identity_sample_and_forward() {
let m = Mesh::identity(4, 3, [100.0, 60.0]);
assert_eq!(m.sample([37.0, 21.0]), [37.0, 21.0]);
assert_eq!(m.valid_fraction(), 1.0);
let h = Homography([[1.0, 0.02, 5.0], [-0.01, 1.0, -3.0], [0.0, 0.0, 1.0]]);
let mh = Mesh::from_homography(4, 3, [100.0, 60.0], &h);
// Affine maps are exact on a bilinear mesh.
let q = [40.0, 30.0];
let p = mh.forward(q).unwrap();
let e = h.apply(q);
assert!((p[0] - e[0]).abs() < 1e-6 && (p[1] - e[1]).abs() < 1e-6, "{p:?} {e:?}");
assert!(mh.homography().max_diff(&h) < 1e-6);
assert!(mh.valid_fraction() < 1.0);
}
#[test]
fn mesh_fits_a_translation_and_bends_for_local_motion() {
let size = [200.0, 120.0];
let mut pairs = vec![];
for y in 0..12 {
for x in 0..20 {
let p = [5.0 + x as f64 * 10.0, 5.0 + y as f64 * 10.0];
// Everything moves by (3, −2); a blob near the centre by (8, −2).
let bump = (-((p[0] - 100.0).powi(2) + (p[1] - 60.0).powi(2)) / (2.0 * 20.0f64.powi(2))).exp();
pairs.push((p, [p[0] + 3.0 + 5.0 * bump, p[1] - 2.0]));
}
}
let base = Homography::translation([3.0, -2.0]);
let m = solve_mesh(size, &MeshOpts { cols: 10, rows: 6, alpha: 0.5, prior: 0.01 }, &pairs, &base);
let mut err_mesh = 0.0;
let mut err_h = 0.0;
for (s, d) in &pairs {
let q = m.sample(*d);
err_mesh += (q[0] - s[0]).hypot(q[1] - s[1]);
let qh = base.inverse().unwrap().apply(*d);
err_h += (qh[0] - s[0]).hypot(qh[1] - s[1]);
}
assert!(err_mesh < 0.35 * err_h, "{err_mesh} vs {err_h}");
}
}