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