From 247a0f14eb999468ff5d47e7d4ce251fbb43712e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 25 Oct 2026 00:29:15 +0200 Subject: [PATCH] Share the Warp Stabilizer luma pyramid with feature tracking Reuse consecutive caller-owned pyramids for regular Warp motion and trajectory tracking. Preserve the original three-level motion search by setting aside the trajectory-only extra level, without cloning planes. Keep detailed analysis at its existing distinct resolutions and test bitwise motion and complete JSON equality against separate pyramids. Fixes #2322 --- crates/track/src/camtrack/features.rs | 20 ++++++--- crates/track/src/stabilize.rs | 58 ++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/crates/track/src/camtrack/features.rs b/crates/track/src/camtrack/features.rs index 1775a304f..246febda4 100644 --- a/crates/track/src/camtrack/features.rs +++ b/crates/track/src/camtrack/features.rs @@ -61,15 +61,24 @@ impl TrackAnalyzer { /// Analyse the next frame. pub fn push(&mut self, frame: &Frame) { - let (max_side, want) = self.params(); + let (max_side, _) = self.params(); let factor = analysis_factor(frame.img.width, frame.img.height, max_side); - self.factor = factor as f64; let pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, 4); + let prev = self.prev.take(); + self.push_pyramid(prev.as_ref(), &pyr); + self.prev = Some(pyr); + } + + /// Track using caller-owned consecutive pyramids. The caller retains the full pyramid + /// for the next frame; standalone camera analysis uses the same path through `push`. + pub(crate) fn push_pyramid(&mut self, prev: Option<&GrayPyramid>, pyr: &GrayPyramid) { + let (_, want) = self.params(); + self.factor = pyr.factor; let k = self.frame; - match &self.prev { + match prev { Some(prev) if prev.width() == pyr.width() && prev.height() == pyr.height() && prev.offset == pyr.offset => { let pts: Vec<[f64; 2]> = self.active.iter().map(|a| a.1).collect(); - let q = track(prev, &pyr, &pts, None, &LkOpts { radius: 6, iterations: 30, epsilon: 0.005, fb_max: 0.5, ..Default::default() }); + let q = track(prev, pyr, &pts, None, &LkOpts { radius: 6, iterations: 30, epsilon: 0.005, fb_max: 0.5, ..Default::default() }); let mut next = Vec::with_capacity(self.active.len()); for ((ti, _), q) in self.active.iter().zip(q) { if let Some(q) = q { @@ -101,14 +110,13 @@ impl TrackAnalyzer { !occ.get(cy * gw + cx).copied().unwrap_or(true) }; let opts = CornerOpts { max_features: want - self.active.len().min(want), min_distance: md, quality: 0.001, window: 2, border: 8 }; - for p in good_features(&pyr, &opts, Some(&free)) { + for p in good_features(pyr, &opts, Some(&free)) { let l = pyr.to_layer(p); let id = self.tracks.len() as u32; self.tracks.push(Track2D { id, start: k, pts: vec![[l[0] as f32, l[1] as f32]] }); self.active.push((id as usize, p)); } } - self.prev = Some(pyr); self.frame += 1; } diff --git a/crates/track/src/stabilize.rs b/crates/track/src/stabilize.rs index 9769fcbd0..90cab6aab 100644 --- a/crates/track/src/stabilize.rs +++ b/crates/track/src/stabilize.rs @@ -156,10 +156,27 @@ impl Analyzer { /// Analyse the next frame. pub fn push(&mut self, frame: &Frame) { + self.push_impl(frame, !self.opts.detailed); + } + + fn push_impl(&mut self, frame: &Frame, share_pyramid: bool) { + let share_pyramid = share_pyramid && !self.opts.detailed; 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 pyr = GrayPyramid::from_image(frame.img, frame.offset, factor, if share_pyramid { 4 } else { 3 }); + if share_pyramid { + self.trajectories.push_pyramid(self.prev.as_ref(), &pyr); + } else { + // Detailed analysis uses different downsampling factors for motion and trajectories. + self.trajectories.push(frame); + } + // Motion tracking historically used only three extra levels. Temporarily remove + // the trajectory tracker's fourth level without cloning any planes or gradients. + let extra = if share_pyramid && pyr.levels.len() > 4 { pyr.levels.pop().zip(pyr.grads.pop()) } else { None }; + if share_pyramid && let Some(prev) = &mut self.prev { + prev.levels.truncate(4); + prev.grads.truncate(4); + } let mut fm = FrameMotion::default(); if let Some(prev) = &self.prev && prev.width() == pyr.width() @@ -195,6 +212,10 @@ impl Analyzer { fm.points = round_pts(&inl, 48); } } + if let Some((level, grad)) = extra { + pyr.levels.push(level); + pyr.grads.push(grad); + } self.frames.push(fm); self.prev = Some(pyr); } @@ -833,6 +854,39 @@ mod tests { tot / cnt.max(1) as f64 } + #[test] + fn shared_pyramid_preserves_motion_and_trajectory_bits() { + rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap().install(|| { + for (size, detailed) in [([321.0, 181.0], false), ([997.0, 565.0], false), ([97.0, 63.0], false), ([1281.0, 721.0], true)] { + let mut scene = Scene::new(12); + scene.size = size; + let mut shared = Analyzer::new(size, AnalyzeOpts { detailed }); + let mut separate = Analyzer::new(size, AnalyzeOpts { detailed }); + for k in 0..12 { + // Also cover partial downsample blocks, an offset/size change and the + // resulting reset of active features halfway through the clip. + if k == 6 { + scene.size = [size[0] + 7.0, size[1] + 5.0]; + } + let img = scene.frame(k); + let frame = Frame { img: &img, offset: if k < 6 { [2.25, -3.5] } else { [-1.75, 4.5] } }; + shared.push(&frame); + separate.push_impl(&frame, false); + } + let a = shared.finish(0.123456789012345, 1.0 / 24.0); + let b = separate.finish(0.123456789012345, 1.0 / 24.0); + assert!(!a.tracks.is_empty(), "exercise live trajectories: {size:?}, detailed={detailed}"); + assert!(a.frames.iter().any(|f| f.features > 0)); + assert_eq!(a.to_json(), b.to_json(), "{size:?}, detailed={detailed}"); + for (a, b) in a.frames.iter().zip(&b.frames) { + for (a, b) in a.t.iter().chain(&a.s).chain(&a.h).zip(b.t.iter().chain(&b.s).chain(&b.h)) { + assert_eq!(a.to_bits(), b.to_bits()); + } + } + } + }); + } + #[test] fn subspace_warp_beats_perspective_on_parallax() { let n = 48;