Skip to main content

long_screen/kernels/track/
reacquire.rs

1//! Anchor re-acquisition and drift correction — split out of the former monolithic `track.rs`.
2//! `track.ts::reacquire`/`driftCorrection`, each split into a
3//! hypothesis/audit half with no native luma and a native-patch refinement half, so the ABI layer can decide
4//! whether the resident native luma plane needs filling at all before calling the refinement half.
5
6use crate::kernels::features::{match_features, Feature};
7use crate::kernels::geometry::{js_hypot, Rect};
8use crate::kernels::motion::{
9    refine_patches, refine_spatial_patches, translation_hypotheses, Gray, MatchPoints, Motion,
10    Patch, Point,
11};
12
13/// `track.ts::reacquire`'s hypothesis-generation half: `matchFeatures` + `translationHypotheses`
14/// filtered by `support >= 6`, with NO native luma involved. Split out from the native-refinement half
15/// (`reacquire_refine`) so the ABI layer can decide whether the resident native luma plane needs filling at
16/// all this call — the original TS `native()` thunk was only ever evaluated once `models.slice(0, 4)` actually
17/// had a candidate (`reacquire`'s own doc comment); an empty result here means the caller must return "no
18/// reacquisition" without touching native luma, exactly like the original's `models.length === 0` path.
19pub fn reacquire_hypotheses(anchor_features: &[Feature], own_features: &[Feature]) -> Vec<Motion> {
20    let raw_matches = match_features(anchor_features, own_features, true);
21    let match_points: Vec<MatchPoints> = raw_matches
22        .iter()
23        .map(|m| {
24            let a = &anchor_features[m.a as usize];
25            let b = &own_features[m.b as usize];
26            MatchPoints {
27                ax: a.x as f64,
28                ay: a.y as f64,
29                bx: b.x as f64,
30                by: b.y as f64,
31                unique: m.unique,
32            }
33        })
34        .collect();
35    translation_hypotheses(&match_points, 8)
36        .into_iter()
37        .filter(|m| m.support >= 6)
38        .collect()
39}
40
41/// `track.ts::reacquire`'s output; `confidence` is the fully computed
42/// `top.m.confidence.max(0.05) * exp(-error / 20) * (ambiguous ? 0.6 : 1)`, using the unweighted native residual
43/// and `libm::exp` (the same code on every engine — see `odometry.rs`'s `OdometryEstimate.confidence` doc comment for why this
44/// is no longer finished by the TS caller with `Math.exp`).
45pub struct ReacquireEstimate {
46    pub x: i32,
47    pub y: i32,
48    pub ambiguous: bool,
49    pub confidence: f64,
50    /// Ordinary native residual at the selected offset; spatial weighting only selects the motion.
51    pub error: f64,
52}
53
54/// `track.ts::reacquire`'s native-refinement half, given the (non-empty) `models` `reacquire_hypotheses`
55/// already found and an ALREADY-FILLED native luma plane (the ABI layer's job, once it knows `models` is
56/// non-empty — see this module's doc comment). `None` covers both the original's "no candidate refined below
57/// the error threshold" and "a rival candidate exists" branches (the original returns `undefined` for both,
58/// not just marks the result ambiguous — unlike `odometry`'s rival check).
59pub fn reacquire_refine(
60    models: &[Motion],
61    patches: &[Patch<'_>],
62    native: Gray<'_>,
63    rect: Rect,
64    f: f64,
65    radius: i32,
66) -> Option<ReacquireEstimate> {
67    let mut options: Vec<_> = models
68        .iter()
69        .take(4)
70        .map(|m| {
71            let n = refine_spatial_patches(
72                patches,
73                native,
74                rect,
75                Point {
76                    x: m.x * f,
77                    y: m.y * f,
78                },
79                radius,
80            );
81            (m, n)
82        })
83        .filter(|(_, n)| n.error < 12.0)
84        .collect();
85    options.sort_by(|a, b| {
86        a.1.error
87            .partial_cmp(&b.1.error)
88            .unwrap_or(std::cmp::Ordering::Equal)
89    });
90    let (top_m, top_n) = options.first()?;
91    let has_rival = options.iter().skip(1).any(|(_, n)| {
92        js_hypot(n.x as f64 - top_n.x as f64, n.y as f64 - top_n.y as f64) > 2.0
93            && n.error < top_n.error + 2.0
94    });
95    if has_rival {
96        return None;
97    }
98    let ambiguous = top_m.ambiguous;
99    // Area weights choose the motion, not the source quality. Keep the ordinary texture residual for
100    // confidence, or adding unsampled space would change which frame the compositor prefers.
101    let error = refine_patches(
102        patches,
103        native,
104        rect,
105        Point {
106            x: top_n.x as f64,
107            y: top_n.y as f64,
108        },
109        0,
110    )
111    .error;
112    // `f64::max(0.05)` returns `0.05` for a NaN `confidence` where `Math.max(.05, NaN)` returns `NaN` (see
113    // odometry.rs's `OdometryEstimate.confidence` doc comment for the same difference and why it is kept) —
114    // never observed in practice, since `top_m.confidence` comes from `translation_hypotheses`, which never
115    // produces NaN.
116    let confidence =
117        top_m.confidence.max(0.05) * libm::exp(-error / 20.0) * if ambiguous { 0.6 } else { 1.0 };
118    Some(ReacquireEstimate {
119        x: top_n.x,
120        y: top_n.y,
121        ambiguous,
122        confidence,
123        error,
124    })
125}
126
127/// `track.ts::driftCorrection`'s output. `confidence_floor` is `0.96 * exp(-error / 20)` (`libm::exp`, identical
128/// on every engine); the TS caller takes `Math.max(carriedInConfidence, confidenceFloor)`, a plain comparison
129/// with no transcendental function left to vary between engines.
130pub struct DriftEstimate {
131    pub x: f64,
132    pub y: f64,
133    pub error: f64,
134    pub confidence_floor: f64,
135}
136
137/// `track.ts::driftCorrection`. Unlike `reacquire`, there is no gate: the original always evaluates `native()`
138/// here, so the ABI layer always fills the native luma plane (if not already filled this frame) before calling
139/// this — there is no "skip the fill" branch to preserve.
140pub fn drift_correction(
141    patches: &[Patch<'_>],
142    native: Gray<'_>,
143    rect: Rect,
144    anchor: (f64, f64),
145    pose: (f64, f64),
146    radius: i32,
147) -> Option<DriftEstimate> {
148    let expected = Point {
149        x: pose.0 - anchor.0,
150        y: pose.1 - anchor.1,
151    };
152    let n = refine_patches(patches, native, rect, expected, radius);
153    if n.error < 12.0 && n.runner_up > n.error + 1.5 {
154        Some(DriftEstimate {
155            x: anchor.0 + n.x as f64,
156            y: anchor.1 + n.y as f64,
157            error: n.error,
158            confidence_floor: 0.96 * libm::exp(-n.error / 20.0),
159        })
160    } else {
161        None
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    // Dense fixed controls must not outvote sparse page texture merely by supplying more patches.
170    // The same observations with no page movement must still re-acquire the stationary anchor.
171    #[test]
172    fn reacquisition_balances_clustered_controls_against_the_page() {
173        let (w, h, step, size) = (256, 960, 96, 16);
174        let page = |x: usize, y: usize| 180 + ((x * 37 + y * 71 + x * y * 13) % 64) as u8;
175        let frame = |dy: usize| {
176            let mut data = vec![0; w * h];
177            for y in 0..h {
178                for x in 0..w {
179                    data[y * w + x] = if !(64..h - 64).contains(&y) {
180                        64 + ((x * 43 + y * 29 + x * y * 17) % 128) as u8
181                    } else {
182                        page(x, y + dy)
183                    };
184                }
185            }
186            data
187        };
188        let anchor = frame(0);
189        let current = frame(step);
190        let mut origins: Vec<_> = [16, h - 32]
191            .into_iter()
192            .flat_map(|y| (0..8).map(move |i| (8 + i * 30, y)))
193            .collect();
194        origins.extend([200, 400, 600, 800].map(|y| (120, y)));
195        let data: Vec<Vec<u8>> = origins
196            .iter()
197            .map(|&(x, y)| {
198                (0..size)
199                    .flat_map(|py| {
200                        anchor[(y + py) * w + x..(y + py) * w + x + size]
201                            .iter()
202                            .copied()
203                    })
204                    .collect()
205            })
206            .collect();
207        let patches: Vec<_> = origins
208            .iter()
209            .zip(&data)
210            .map(|(&(x, y), data)| Patch {
211                x: x as i32,
212                y: y as i32,
213                size,
214                data,
215            })
216            .collect();
217        let rect = Rect {
218            x: 0.0,
219            y: 0.0,
220            width: w as f64,
221            height: h as f64,
222        };
223        let models: Vec<_> = [step, 0]
224            .map(|dy| Motion {
225                x: 0.0,
226                y: dy as f64,
227                support: 30,
228                unique: 30,
229                confidence: 1.0,
230                error: 0.0,
231                ambiguous: false,
232            })
233            .into();
234        for (pixels, expected) in [(&current, step), (&anchor, 0)] {
235            let found = reacquire_refine(
236                &models,
237                &patches,
238                Gray {
239                    width: w,
240                    height: h,
241                    data: pixels,
242                },
243                rect,
244                1.0,
245                3,
246            )
247            .expect("one globally supported anchor");
248            assert_eq!((found.x, found.y), (0, expected as i32));
249        }
250    }
251    // Extending the pane beyond the sampled patches must not change their source quality.
252    // Area weights establish motion support; the same native residual still means the same quality.
253    #[test]
254    fn anchor_quality_does_not_depend_on_unsampled_pane_extent() {
255        let (w, h, size) = (160, 320, 32);
256        let reference: Vec<u8> = (0..w * h)
257            .map(|i| {
258                let (x, y) = (i % w, i / w);
259                32 + ((x * 13 + y * 31 + x * y * 17) % 128) as u8
260            })
261            .collect();
262        let origins = [(16, 16), (96, 112)];
263        let data: Vec<Vec<u8>> = origins
264            .iter()
265            .map(|&(x, y)| {
266                (0..size)
267                    .flat_map(|dy| {
268                        reference[(y + dy) * w + x..(y + dy) * w + x + size]
269                            .iter()
270                            .copied()
271                    })
272                    .collect()
273            })
274            .collect();
275        let patches: Vec<_> = origins
276            .iter()
277            .zip(&data)
278            .map(|(&(x, y), data)| Patch {
279                x: x as i32,
280                y: y as i32,
281                size,
282                data,
283            })
284            .collect();
285        let mut current = reference.clone();
286        for y in 112..112 + size {
287            for x in 96..96 + size {
288                current[y * w + x] += 8;
289            }
290        }
291        let models = [Motion {
292            x: 0.0,
293            y: 0.0,
294            support: 30,
295            unique: 30,
296            confidence: 1.0,
297            error: 0.0,
298            ambiguous: false,
299        }];
300        let estimates: Vec<_> = [160, 320]
301            .into_iter()
302            .map(|height| {
303                reacquire_refine(
304                    &models,
305                    &patches,
306                    Gray {
307                        width: w,
308                        height: h,
309                        data: &current,
310                    },
311                    Rect {
312                        x: 0.0,
313                        y: 0.0,
314                        width: w as f64,
315                        height: height as f64,
316                    },
317                    1.0,
318                    3,
319                )
320                .unwrap()
321            })
322            .collect();
323        for estimate in &estimates {
324            assert_eq!((estimate.x, estimate.y), (0, 0));
325        }
326        assert_eq!(estimates[0].confidence, estimates[1].confidence);
327        assert_eq!(estimates[0].error, 4.0); // One equally sized patch differs by eight, the other is exact.
328    }
329}