diff --git a/crates/track/src/lib.rs b/crates/track/src/lib.rs index 898019726..8867bfcb8 100644 --- a/crates/track/src/lib.rs +++ b/crates/track/src/lib.rs @@ -399,6 +399,12 @@ impl Crop { } } +/// Largest distance (in top-level pixels, per axis) between the best coarse match and the +/// motion prediction for which the fine stage searches only ±1 pixel per level. +const SMALL_MOTION: f64 = 1.0; +/// Lowest coarse NCC score of the best match for which the fine stage may search only ±1. +const RELIABLE_SCORE: f64 = 0.8; + /// Per-point tracking state. #[derive(Clone, Debug)] struct PointState { @@ -499,7 +505,10 @@ impl PointState { break; } } - // Fine: refine each seed down the pyramid in a 5 × 5 neighbourhood. + // Fine: refine each seed down the pyramid. A good motion prediction plus a reliable coarse + // match only needs a 3 × 3 neighbourhood per level; otherwise keep 5 × 5. + let near = cands[0].0 >= RELIABLE_SCORE && seeds.first().is_some_and(|c| (c[0] - cp[0]).abs() <= SMALL_MOTION && (c[1] - cp[1]).abs() <= SMALL_MOTION); + let rr: i32 = if near { 1 } else { 2 }; let mut best = (f64::MIN, sc); for seed in seeds { let mut c = seed; @@ -510,8 +519,8 @@ impl PointState { // Start from the projected match; neighbours must beat it (no drift on ties). let scorer = Scorer::new(pl, tp, c, shape); let mut lb = (scorer.at(0, 0), c); - for j in -2..=2 { - for i in -2..=2 { + for j in -rr..=rr { + for i in -rr..=rr { if i == 0 && j == 0 { continue; } diff --git a/crates/track/src/tests.rs b/crates/track/src/tests.rs index e97452ddc..c019996f5 100644 --- a/crates/track/src/tests.rs +++ b/crates/track/src/tests.rs @@ -163,6 +163,27 @@ fn recovers_translation_path_subpixel() { assert!(worst < 0.25, "worst error {worst} px"); } +#[test] +fn recovers_after_sudden_jumps_the_prediction_misses() { + // Alternating large steps defeat the velocity prediction, so the fine stage must keep its + // wider neighbourhood; smooth frames in between use the narrow one. + let path = |f: usize| { + let t = f as f64; + let jump = [0.0, 7.0, -5.0, 9.0, -8.0, 6.0, -7.0, 8.0]; + let j = jump[f % jump.len()]; + Pose { c: [120.0 + 1.5 * t + j, 100.0 + 1.1 * t - 0.6 * j], rot: 0.0, scale: 1.0 } + }; + let f0 = frame(360, 260, path(0), 40.0, 0.02, 0, None); + let mut tr = Tracker::new(TrackOptions::default(), &[spec(path(0).c, 31.0, 91.0)], &Frame::new(&f0)); + for f in 1..24 { + let img = frame(360, 260, path(f), 40.0, 0.02, f as u32, None); + let r = tr.step(&Frame::new(&img))[0]; + let e = dist(r.center, path(f).c); + assert!(e < 0.3, "frame {f}: error {e} px"); + assert_eq!(r.status, Status::Tracked); + } +} + #[test] fn layer_offset_and_channels() { let pose = |f: usize| Pose { c: [120.0 + 3.3 * f as f64, 100.0 - 1.7 * f as f64], rot: 0.0, scale: 1.0 };