simple_editor\engine/
tracking.rs

1//! Point / area tracking (the Tracking pane): zero-mean normalised cross-correlation of a template
2//! patch over a search window, frame by frame, producing exactly the `(x, y, t)` point list a
3//! `PathAsset` holds — so a track can be saved as a project path and replayed onto a clip's X/Y
4//! keyframes (`Project::apply_path`).
5//!
6//! The job owns a thread and its own `DecoderPool` (same shape as every other worker here) and streams
7//! one point plus a progress fraction per frame; dropping the job hangs up the channel and the worker
8//! stops on its next send. The UI never blocks.
9//!
10//! ponytail: the clip is tracked in its source frame decoded at project resolution, so the clip's own
11//! transform is ignored — right for the usual full-frame clip. Render through `engine::compose` per
12//! frame if a scaled / rotated / nested clip ever has to track.
13
14use crate::media::{Backend, DecoderPool, Frame};
15use crate::model::{Id, Project};
16use std::sync::mpsc::{channel, Receiver, TryRecvError};
17
18/// Luma at (x, y), edge-clamped (the search window is allowed to hang off the frame).
19fn luma(f: &Frame, x: i32, y: i32) -> f32 {
20    let x = x.clamp(0, f.width as i32 - 1) as usize;
21    let y = y.clamp(0, f.height as i32 - 1) as usize;
22    let p = &f.rgba[y * f.stride() + x * 4..];
23    p[0] as f32 * 0.299 + p[1] as f32 * 0.587 + p[2] as f32 * 0.114
24}
25
26/// The `(2*hw+1) x (2*hh+1)` luma patch centred on (cx, cy).
27pub fn patch(f: &Frame, cx: i32, cy: i32, hw: i32, hh: i32) -> Vec<f32> {
28    let (tw, th) = (2 * hw + 1, 2 * hh + 1);
29    let mut out = Vec::with_capacity((tw * th).max(0) as usize);
30    if f.is_empty() {
31        return out;
32    }
33    for j in 0..th {
34        for i in 0..tw {
35            out.push(luma(f, cx - hw + i, cy - hh + j));
36        }
37    }
38    out
39}
40
41/// Best zero-mean NCC match for `tpl` (a `patch` of the same half-sizes) within `search` px of
42/// (cx, cy): the matched centre and its score in -1..=1. None when the template or every candidate
43/// window is flat — there is nothing to lock onto, and the caller keeps the previous position.
44///
45/// ponytail: brute-force scan, O(search² · patch) per frame — fine at the sizes the pane offers.
46/// Go to a coarse-to-fine image pyramid if a 4K search radius ever needs to be interactive.
47pub fn best_match(f: &Frame, tpl: &[f32], hw: i32, hh: i32, cx: i32, cy: i32, search: i32) -> Option<(i32, i32, f32)> {
48    let (tw, th) = (2 * hw + 1, 2 * hh + 1);
49    let n = (tw * th) as f32;
50    if f.is_empty() || tpl.len() != (tw * th) as usize {
51        return None;
52    }
53    let tmean = tpl.iter().sum::<f32>() / n;
54    let tvar: f32 = tpl.iter().map(|v| (v - tmean) * (v - tmean)).sum();
55    if tvar <= 1e-3 {
56        return None;
57    }
58    let mut best: Option<(i32, i32, f32)> = None;
59    for dy in -search..=search {
60        for dx in -search..=search {
61            let (ox, oy) = (cx + dx, cy + dy);
62            let (mut s, mut ss, mut dot) = (0.0f32, 0.0f32, 0.0f32);
63            for j in 0..th {
64                for i in 0..tw {
65                    let v = luma(f, ox - hw + i, oy - hh + j);
66                    s += v;
67                    ss += v * v;
68                    dot += v * tpl[(j * tw + i) as usize];
69                }
70            }
71            let wmean = s / n;
72            let wvar = ss - s * wmean;
73            if wvar <= 1e-3 {
74                continue;
75            }
76            let score = (dot - n * tmean * wmean) / (tvar * wvar).sqrt();
77            if best.is_none_or(|(_, _, b)| score > b) {
78                best = Some((ox, oy, score));
79            }
80        }
81    }
82    best
83}
84
85/// One tracked frame: its point (canvas px relative to the centre, clip-local seconds) and how far
86/// through the clip the worker is.
87type Msg = ((f32, f32, f32), f32);
88
89pub struct TrackJob {
90    rx: Receiver<Msg>,
91    /// Points so far, in `PathAsset::points` form and always in time order once `poll` reports done.
92    pub points: Vec<(f32, f32, f32)>,
93    pub progress: f32,
94}
95
96impl TrackJob {
97    /// Track `clip` from its head (or its tail, `backward`) with the box centred on (cx, cy) and
98    /// half-sized (hw, hh), all in canvas px relative to the centre. `refresh` re-grabs the template
99    /// every N frames (0 = never: rigid features drift less without it). Err = nothing to track.
100    #[allow(clippy::too_many_arguments)]
101    pub fn start(
102        project: &Project,
103        clip: Id,
104        (cx, cy, hw, hh): (f32, f32, f32, f32),
105        search: f32,
106        refresh: u32,
107        backward: bool,
108        backend: Backend,
109    ) -> Result<Self, String> {
110        let c = project.clip(clip).ok_or("pick a clip to track")?;
111        let path = project.asset(c.asset).map(|a| a.path.clone()).ok_or("that clip has no footage to track")?;
112        let (w, h) = (project.width.max(2), project.height.max(2));
113        let fps = project.fps.max(1.0);
114        let n = ((c.duration * fps).round() as usize).clamp(1, 200_000);
115        // clip-local time -> source time up front, so the worker never touches the Project
116        let mut times: Vec<(f32, f64)> =
117            (0..n).map(|i| (i as f64 / fps)).map(|lt| (lt as f32, c.src_time(c.start + lt))).collect();
118        if backward {
119            times.reverse();
120        }
121        let (hw, hh) = (hw.max(2.0) as i32, hh.max(2.0) as i32);
122        let search = search.clamp(1.0, 512.0) as i32;
123        let (x0, y0) = ((cx + w as f32 / 2.0) as i32, (cy + h as f32 / 2.0) as i32);
124        let (tx, rx) = channel();
125        std::thread::spawn(move || {
126            let mut pool = DecoderPool::new(backend);
127            let mut frame = Frame::default();
128            let mut tpl: Vec<f32> = Vec::new();
129            let (mut px, mut py) = (x0, y0);
130            let total = times.len() as f32;
131            for (i, (lt, st)) in times.iter().enumerate() {
132                let Some(dec) = pool.video(&path) else { break };
133                if !dec.frame_at(*st, w, h, &mut frame) {
134                    break;
135                }
136                if tpl.is_empty() {
137                    tpl = patch(&frame, px, py, hw, hh);
138                } else {
139                    if let Some((nx, ny, _)) = best_match(&frame, &tpl, hw, hh, px, py, search) {
140                        (px, py) = (nx, ny);
141                    }
142                    if refresh > 0 && i % refresh as usize == 0 {
143                        tpl = patch(&frame, px, py, hw, hh);
144                    }
145                }
146                let pt = (px as f32 - w as f32 / 2.0, py as f32 - h as f32 / 2.0, *lt);
147                if tx.send((pt, (i + 1) as f32 / total)).is_err() {
148                    break; // the pane went away (cancel / project closed)
149                }
150            }
151        });
152        Ok(Self { rx, points: Vec::new(), progress: 0.0 })
153    }
154
155    /// Drain what the worker produced. True while it is still running (the caller keeps repainting).
156    pub fn poll(&mut self) -> bool {
157        loop {
158            match self.rx.try_recv() {
159                Ok((pt, p)) => {
160                    self.points.push(pt);
161                    self.progress = p;
162                }
163                Err(TryRecvError::Empty) => return true,
164                Err(TryRecvError::Disconnected) => {
165                    // backward tracking walks the clip in reverse; a path is always in time order
166                    self.points.sort_by(|a, b| a.2.total_cmp(&b.2));
167                    return false;
168                }
169            }
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    /// Mid-grey frame with one bright square, so the patch is a real feature and not the only light.
179    fn square(w: u32, h: u32, cx: i32, cy: i32, r: i32) -> Frame {
180        let mut f = Frame::new(w, h);
181        f.fill([40, 40, 40, 255]);
182        for y in (cy - r).max(0)..=(cy + r).min(h as i32 - 1) {
183            for x in (cx - r).max(0)..=(cx + r).min(w as i32 - 1) {
184                let i = y as usize * f.stride() + x as usize * 4;
185                f.rgba[i..i + 4].copy_from_slice(&[230, 220, 200, 255]);
186            }
187        }
188        f
189    }
190
191    #[test]
192    fn ncc_reports_the_offset_the_square_moved_by() {
193        let a = square(96, 72, 40, 30, 6);
194        let b = square(96, 72, 49, 25, 6);
195        let tpl = patch(&a, 40, 30, 10, 10);
196        let (x, y, score) = best_match(&b, &tpl, 10, 10, 40, 30, 16).expect("a bright square is not flat");
197        assert_eq!((x - 40, y - 30), (9, -5), "score {score}");
198        assert!(score > 0.9, "an exact patch should correlate ~1, got {score}");
199        // a flat frame has nothing to lock onto: the caller keeps the previous position
200        let flat = Frame::new(96, 72);
201        assert!(best_match(&flat, &patch(&flat, 40, 30, 10, 10), 10, 10, 40, 30, 4).is_none());
202    }
203}