Skip to main content

long_screen/kernels/track/
keyframes.rs

1//! Keyframe candidate scoring — split out of the former monolithic `track.rs`.
2//! `keyframes.ts::evaluateCandidates` fused end to end: hypothesis matching + analysis-scale audit over every
3//! keyframe, native-patch refinement for the audited candidates, the confidence formula, and the
4//! score/sort/strong-best/rival-ambiguity selection (`select_candidate`) that picks the one candidate `find()`
5//! returns.
6
7use crate::kernels::features::{match_features, Feature};
8use crate::kernels::geometry::{js_hypot, Rect};
9use crate::kernels::motion::{
10    refine_patches, translation_hypotheses, Gray, MatchPoints, Patch, Point, TranslationAudit,
11};
12
13/// One keyframe `evaluate_candidates_audit`/`_refine`/`select_candidate` score a query against — the parts of
14/// `keyframes.ts`'s `Keyframe` (features/gray/patches/position) the Rust port needs. `x`/`y` is the keyframe's
15/// own native-pixel pose; `canonical_idx`/`dx`/`dy` is the caller's `canonical` map already resolved and interned
16/// per keyframe (a keyframe with no attachment resolves to its own canvas, `dx = dy = 0`) — `select_candidate`'s
17/// rival check compares candidates by this resolved (canvas index, x, y), never by raw `canvasId`, exactly like
18/// `keyframes.ts`'s `position()` closure did.
19pub struct CandidateKeyframe<'a> {
20    pub features: &'a [Feature],
21    pub gray: Gray<'a>,
22    pub patches: &'a [Patch<'a>],
23    pub x: f64,
24    pub y: f64,
25    pub canonical_idx: u32,
26    pub dx: f64,
27    pub dy: f64,
28}
29
30/// One (keyframe, hypothesis) pair that passed `keyframes.ts::evaluateCandidates`'s analysis-scale audit —
31/// native-pixel work not run yet (see `evaluate_candidates_refine`'s doc comment for why the phases are split).
32#[derive(Clone, Copy)]
33pub struct AuditedCandidate {
34    pub keyframe_index: usize,
35    pub guess_x: f64,
36    pub guess_y: f64,
37    pub support: u32,
38    pub unique: u32,
39    pub hyp_confidence: f64,
40    pub ambiguous: bool,
41    pub analysis_error: f64,
42}
43
44/// `keyframes.ts::evaluateCandidates`'s match + hypothesis + analysis-scale-audit phase, over every keyframe —
45/// no native luma touched here, so the ABI layer can decide whether to fill the (possibly resident) native
46/// plane at all based on whether this returns anything ("native refinement only for candidates that
47/// pass the audit").
48pub fn evaluate_candidates_audit(
49    keyframes: &[CandidateKeyframe<'_>],
50    features: &[Feature],
51    gray: Gray<'_>,
52    roi: Rect,
53    factor: f64,
54) -> Vec<AuditedCandidate> {
55    let mut out = Vec::new();
56    for (ki, k) in keyframes.iter().enumerate() {
57        let raw_matches = match_features(k.features, features, true);
58        let match_points: Vec<MatchPoints> = raw_matches
59            .iter()
60            .map(|m| {
61                let a = &k.features[m.a as usize];
62                let b = &features[m.b as usize];
63                MatchPoints {
64                    ax: a.x as f64,
65                    ay: a.y as f64,
66                    bx: b.x as f64,
67                    by: b.y as f64,
68                    unique: m.unique,
69                }
70            })
71            .collect();
72        let models = translation_hypotheses(&match_points, 16);
73        // The pair is fixed across hypotheses. Prepare it only when a supported
74        // candidate needs an audit, then reuse its filtered images for the rest.
75        let mut prepared = None;
76        for m in models.iter().take(8) {
77            if m.support < 6 {
78                continue;
79            }
80            // Analysis-scale audit tolerates sub-factor misalignment; the decision is made on native pixels.
81            let pair =
82                prepared.get_or_insert_with(|| TranslationAudit::new(k.gray, gray, factor > 1.0));
83            let audit = pair.measure(m.x, m.y, Some(roi));
84            if audit.overlap < 0.22
85                || !audit.error.is_finite()
86                || (audit.mismatch > 0.12 && audit.agreement < 0.5)
87            {
88                continue;
89            }
90            out.push(AuditedCandidate {
91                keyframe_index: ki,
92                guess_x: m.x * factor,
93                guess_y: m.y * factor,
94                support: m.support,
95                unique: m.unique,
96                hyp_confidence: m.confidence,
97                ambiguous: m.ambiguous,
98                analysis_error: audit.error,
99            });
100        }
101    }
102    out
103}
104
105/// One audited candidate after native-patch refinement; `strong` is already decided here (`keyframes.ts`'s
106/// `m.support >= 10 && m.unique >= 6 && m.confidence >= .45`, no `exp` involved).
107#[derive(Clone, Copy)]
108pub struct RefinedCandidate {
109    pub keyframe_index: usize,
110    pub x: i32,
111    pub y: i32,
112    pub support: u32,
113    pub unique: u32,
114    pub ambiguous: bool,
115    pub error: f64,
116    pub analysis_error: f64,
117    pub strong: bool,
118}
119
120/// `keyframes.ts::evaluateCandidates`'s native-refinement phase: called by the ABI layer only once it knows
121/// `audited` is non-empty, so the (possibly resident) native luma plane is filled lazily, at most once —
122/// exactly like `reacquire`/`driftCorrection` (see `abi::track::resolve_native`).
123pub fn evaluate_candidates_refine(
124    audited: &[AuditedCandidate],
125    keyframes: &[CandidateKeyframe<'_>],
126    native: Gray<'_>,
127    region: Rect,
128    radius: i32,
129) -> Vec<RefinedCandidate> {
130    let mut out = Vec::new();
131    for c in audited {
132        let refined = refine_patches(
133            keyframes[c.keyframe_index].patches,
134            native,
135            region,
136            Point {
137                x: c.guess_x,
138                y: c.guess_y,
139            },
140            radius,
141        );
142        if !refined.error.is_finite() || refined.error > 12.0 {
143            continue;
144        }
145        out.push(RefinedCandidate {
146            keyframe_index: c.keyframe_index,
147            x: refined.x,
148            y: refined.y,
149            support: c.support,
150            unique: c.unique,
151            ambiguous: c.ambiguous,
152            error: refined.error,
153            analysis_error: c.analysis_error,
154            strong: c.support >= 10 && c.unique >= 6 && c.hyp_confidence >= 0.45,
155        });
156    }
157    out
158}
159
160/// `keyframes.ts::evaluateCandidates`'s final pick, after `evaluate_candidates_refine`: the confidence formula, a
161/// stable descending sort by score over the finite-scored candidates, the first `strong` candidate in that
162/// order, and the rival/ambiguity check against every other candidate's resolved position.
163pub struct SelectedCandidate {
164    pub keyframe_index: usize,
165    pub x: i32,
166    pub y: i32,
167    pub support: u32,
168    pub unique: u32,
169    pub ambiguous: bool,
170    pub error: f64,
171    pub analysis_error: f64,
172    /// `min(0.95, 0.45 + 0.5 * (1 - exp(-unique / 7))) * exp(-error / 20)`, using `libm::exp` — see
173    /// `odometry.rs`'s `OdometryEstimate.confidence` doc comment for why this no longer needs the host's
174    /// `Math.exp` for bit-identity.
175    pub confidence: f64,
176}
177
178fn candidate_confidence(unique: u32, error: f64) -> f64 {
179    (0.45 + 0.5 * (1.0 - libm::exp(-(unique as f64) / 7.0))).min(0.95) * libm::exp(-error / 20.0)
180}
181
182fn candidate_score(support: u32, unique: u32, confidence: f64) -> f64 {
183    (support as f64 * 0.25 + unique as f64) * confidence
184}
185
186/// A refined candidate's position in the canvas space rivals are compared in: the keyframe's own pose plus this
187/// candidate's offset, translated through the keyframe's already-resolved canonical attachment (identity when
188/// the keyframe has none) — `keyframes.ts`'s `position()` closure.
189fn candidate_position(
190    r: &RefinedCandidate,
191    keyframes: &[CandidateKeyframe<'_>],
192) -> (u32, f64, f64) {
193    let k = &keyframes[r.keyframe_index];
194    (
195        k.canonical_idx,
196        k.x + r.x as f64 + k.dx,
197        k.y + r.y as f64 + k.dy,
198    )
199}
200
201pub fn select_candidate(
202    refined: &[RefinedCandidate],
203    keyframes: &[CandidateKeyframe<'_>],
204) -> Option<SelectedCandidate> {
205    struct Scored {
206        index: usize,
207        score: f64,
208        confidence: f64,
209    }
210    // A non-finite score (confidence or error reaching NaN/±Infinity from a degenerate refinement — unreachable
211    // today only because `evaluate_candidates_refine` already drops any non-finite `error`, but this function
212    // has no other way to enforce that on its own input) is excluded before sorting rather than folded into the
213    // comparator via `partial_cmp().unwrap_or(Equal)`: that fallback is NOT a total order (it can make two
214    // otherwise-unequal scores compare "equal" through a NaN third element), and `slice::sort_by`'s total-order
215    // check panics on exactly that inconsistency once the slice is long enough to take its merge path — a wasm
216    // trap, against this crate's never-trap FFI rule. `Array.prototype.sort` has no such check (a NaN
217    // comparator result is simply "no preference" for that one pairwise comparison), which is why the original
218    // TS never had this failure mode. A non-finite score can never be a sensible choice anyway, so it is simply
219    // never a candidate, never a rival.
220    let mut scored: Vec<Scored> = refined
221        .iter()
222        .enumerate()
223        .filter_map(|(index, r)| {
224            let confidence = candidate_confidence(r.unique, r.error);
225            let score = candidate_score(r.support, r.unique, confidence);
226            score.is_finite().then_some(Scored {
227                index,
228                score,
229                confidence,
230            })
231        })
232        .collect();
233    // Total order over the remaining (all-finite) scores, so `partial_cmp` never returns `None` — stable, so
234    // ties keep the audit's original order, matching `Array.prototype.sort`.
235    scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
236    let best_at = scored.iter().position(|s| refined[s.index].strong)?;
237    let best = &scored[best_at];
238    let (best_canvas, best_x, best_y) = candidate_position(&refined[best.index], keyframes);
239    // Any other plausible place, weak or strong, that lands somewhere else makes the revisit ambiguous —
240    // repeated content looks alike. `.8`/`6` are the measured thresholds this selection has always used: a
241    // rival must score within 80% of the best AND either resolve to a different canvas or land more than 6
242    // native pixels away to count as a competing explanation.
243    let rival = scored.iter().enumerate().any(|(i, s)| {
244        i != best_at && {
245            let (canvas, x, y) = candidate_position(&refined[s.index], keyframes);
246            (canvas != best_canvas || js_hypot(x - best_x, y - best_y) > 6.0)
247                && s.score > best.score * 0.8
248        }
249    });
250    let r = &refined[best.index];
251    Some(SelectedCandidate {
252        keyframe_index: r.keyframe_index,
253        x: r.x,
254        y: r.y,
255        support: r.support,
256        unique: r.unique,
257        ambiguous: r.ambiguous || rival,
258        error: r.error,
259        analysis_error: r.analysis_error,
260        confidence: best.confidence,
261    })
262}
263
264#[cfg(test)]
265mod select_candidate_tests {
266    use super::*;
267
268    /// A keyframe with no features/gray/patches — `select_candidate` never reads them, only
269    /// `x`/`y`/`canonical_idx`/`dx`/`dy`.
270    fn kf(x: f64, y: f64, canonical_idx: u32) -> CandidateKeyframe<'static> {
271        CandidateKeyframe {
272            features: &[],
273            gray: Gray {
274                width: 0,
275                height: 0,
276                data: &[],
277            },
278            patches: &[],
279            x,
280            y,
281            canonical_idx,
282            dx: 0.0,
283            dy: 0.0,
284        }
285    }
286    fn rc(
287        keyframe_index: usize,
288        support: u32,
289        unique: u32,
290        error: f64,
291        strong: bool,
292    ) -> RefinedCandidate {
293        RefinedCandidate {
294            keyframe_index,
295            x: 0,
296            y: 0,
297            support,
298            unique,
299            ambiguous: false,
300            error,
301            analysis_error: 0.0,
302            strong,
303        }
304    }
305
306    #[test]
307    fn the_only_strong_candidate_is_chosen_and_its_confidence_is_in_range() {
308        let keyframes = [kf(0.0, 0.0, 0)];
309        let refined = [rc(0, 10, 6, 2.0, true)];
310        let chosen = select_candidate(&refined, &keyframes).unwrap();
311        assert_eq!(chosen.keyframe_index, 0);
312        assert!(!chosen.ambiguous);
313        assert!(chosen.confidence > 0.0 && chosen.confidence <= 0.95);
314    }
315
316    #[test]
317    fn a_higher_scoring_but_non_strong_candidate_is_skipped_for_the_first_strong_one() {
318        // Candidate 0 scores far higher (support=50, unique=50) but is not strong; candidate 1 is the only
319        // strong candidate and must be the one chosen, exactly like `results.find((r) => r.strong)` on a
320        // descending-score sort — "highest score" and "chosen" are not the same thing.
321        let keyframes = [kf(0.0, 0.0, 0), kf(0.0, 0.0, 0)];
322        let refined = [rc(0, 50, 50, 0.0, false), rc(1, 10, 6, 2.0, true)];
323        let chosen = select_candidate(&refined, &keyframes).unwrap();
324        assert_eq!(chosen.keyframe_index, 1);
325    }
326
327    #[test]
328    fn no_strong_candidate_returns_none() {
329        let keyframes = [kf(0.0, 0.0, 0)];
330        let refined = [rc(0, 3, 2, 1.0, false)];
331        assert!(select_candidate(&refined, &keyframes).is_none());
332    }
333
334    #[test]
335    fn a_rival_over_the_08_score_boundary_on_a_different_canonical_canvas_marks_ambiguous() {
336        // Both candidates share unique=0, error=0 (confidence = 0.45 for both), isolating the boundary to a
337        // plain support ratio: rival/best = 9/10 = .9 > .8.
338        let keyframes = [kf(0.0, 0.0, 0), kf(1000.0, 1000.0, 1)];
339        let refined = [rc(0, 10, 0, 0.0, true), rc(1, 9, 0, 0.0, false)];
340        let chosen = select_candidate(&refined, &keyframes).unwrap();
341        assert!(chosen.ambiguous, "9/10 = .9 is over the .8 rival boundary");
342    }
343
344    #[test]
345    fn a_rival_under_the_08_score_boundary_does_not_mark_ambiguous() {
346        // rival/best = 7/10 = .7 < .8, on a different canonical canvas — the geometry condition alone is not
347        // enough; the score gate must also clear .8.
348        let keyframes = [kf(0.0, 0.0, 0), kf(1000.0, 1000.0, 1)];
349        let refined = [rc(0, 10, 0, 0.0, true), rc(1, 7, 0, 0.0, false)];
350        let chosen = select_candidate(&refined, &keyframes).unwrap();
351        assert!(
352            !chosen.ambiguous,
353            "7/10 = .7 is under the .8 rival boundary"
354        );
355    }
356
357    #[test]
358    fn a_same_canvas_close_candidate_is_not_a_rival_even_with_a_high_score() {
359        // rival/best = 9/10 = .9 clears the score gate, but both keyframes resolve to the SAME canonical
360        // canvas and land within 6 native px of each other — not a competing explanation, just noisy agreement.
361        let keyframes = [kf(0.0, 0.0, 0), kf(3.0, 0.0, 0)];
362        let refined = [rc(0, 10, 0, 0.0, true), rc(1, 9, 0, 0.0, false)];
363        let chosen = select_candidate(&refined, &keyframes).unwrap();
364        assert!(
365            !chosen.ambiguous,
366            "same canvas, 3px apart: agreement, not a rival"
367        );
368    }
369
370    #[test]
371    fn a_different_canonical_canvas_at_the_same_point_is_still_a_rival() {
372        // Same (x, y) as the best candidate but a DIFFERENT resolved canvas — the rival test's canvas check must
373        // fire on its own, independent of the >6px distance check.
374        let keyframes = [kf(0.0, 0.0, 0), kf(0.0, 0.0, 1)];
375        let refined = [rc(0, 10, 0, 0.0, true), rc(1, 9, 0, 0.0, false)];
376        let chosen = select_candidate(&refined, &keyframes).unwrap();
377        assert!(
378            chosen.ambiguous,
379            "same point, different canonical canvas: still a rival"
380        );
381    }
382
383    #[test]
384    fn non_finite_scores_are_excluded_without_panicking_in_a_large_candidate_set() {
385        // `slice::sort_by`'s total-order check only walks the merge path (where it can detect and panic on an
386        // inconsistent comparator) once the slice is long enough — the reviewer's probe needed length >= 21.
387        // Candidates 0..23 all have a non-finite `error` (NaN, or an error so extreme its `exp()` term
388        // overflows to +Infinity), so `partial_cmp().unwrap_or(Equal)` on their scores against each other would
389        // have been the exact inconsistent-comparator shape that panics; here they are excluded before the sort
390        // ever runs. Candidate 24 is the only one with an ordinary finite score and must still be found.
391        let mut keyframes = Vec::new();
392        let mut refined = Vec::new();
393        for i in 0..24 {
394            keyframes.push(kf(0.0, 0.0, 0));
395            let error = if i % 2 == 0 {
396                f64::NAN
397            } else {
398                f64::NEG_INFINITY
399            };
400            refined.push(rc(i, 10, 6, error, true));
401        }
402        keyframes.push(kf(0.0, 0.0, 0));
403        refined.push(rc(24, 10, 6, 2.0, true));
404        let chosen = select_candidate(&refined, &keyframes).unwrap();
405        assert_eq!(chosen.keyframe_index, 24);
406        assert!(chosen.confidence.is_finite());
407    }
408
409    #[test]
410    fn an_exact_score_tie_keeps_the_first_candidate_in_input_order() {
411        // Two candidates with identical support/unique/error score identically (a genuine finite tie, not a
412        // NaN one) — the stable sort must not reorder them, matching `Array.prototype.sort`'s stability.
413        let keyframes = [kf(0.0, 0.0, 0), kf(0.0, 0.0, 0)];
414        let refined = [rc(0, 10, 6, 2.0, true), rc(1, 10, 6, 2.0, true)];
415        let chosen = select_candidate(&refined, &keyframes).unwrap();
416        assert_eq!(
417            chosen.keyframe_index, 0,
418            "a tied score keeps the first candidate in input order"
419        );
420    }
421}