Point tracker: refine in a 3x3 neighbourhood when the motion was small

The fine stage searched a 5x5 neighbourhood per pyramid level and seed.
When the best coarse match is reliable (NCC score >= 0.8) and lies within
one top-level pixel of the motion prediction, a 3x3 neighbourhood is
enough; otherwise the 5x5 search stays.

Fixes #2349

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-25 00:51:00 +02:00
co-authored by Claude Sonnet 5.5
parent 9e68fc47f7
commit 4b31e21a1d
2 changed files with 33 additions and 3 deletions
+12 -3
View File
@@ -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;
}
+21
View File
@@ -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 };