Skip to main content

long_screen/kernels/track/
verdicts.rs

1//! Per-region, per-frame stateless tracking verdicts — split out of the former monolithic
2//! `track.rs`. Byte-for-byte
3//! ports of `src/pipeline/solve/track.ts`'s frozen oracle in `tests/support/reference/track.ts`. Every
4//! function here is pure: plain scalars/tuples in, a plain result out, no I/O, no state carried between calls.
5//!
6//! `js_hypot` (not `f64::hypot`) is used everywhere the TS original used `Math.hypot`: V8's hypot and
7//! libm's hypot round differently in the last bit for some inputs (see `crate::kernels::geometry::js_hypot`).
8
9use crate::kernels::geometry::js_hypot;
10use crate::kernels::motion::{detect_scale, MatchPoints};
11
12/// `track.ts::uncertainty`.
13pub fn uncertainty(confidence: f64, ambiguous: bool, weak_step: bool) -> bool {
14    confidence < 0.60 || ambiguous || weak_step
15}
16
17/// The historical-match half of `track.ts::relocalizeVerdict`'s input (`match` there); `None` mirrors
18/// the original's `match === undefined`.
19pub struct MatchInfo {
20    pub ambiguous: bool,
21    pub confidence: f64,
22}
23
24/// `track.ts::relocalizeVerdict`.
25pub fn relocalize_verdict(m: Option<MatchInfo>, zoom_change: bool) -> bool {
26    match m {
27        Some(m) => !m.ambiguous && m.confidence > 0.6 && !zoom_change,
28        None => false,
29    }
30}
31
32/// The three branches `track.ts::fragmentCause` chooses between: the trivial "gate" ("pure
33/// part"). `ZoomChange` means the caller returns `{ scale: fieldZoom, error: 0 }` directly;
34/// `ProbeScale` means the caller runs the existing `probeScale` kernel; `None` means "no cause"
35/// (`undefined`).
36#[derive(PartialEq, Eq, Debug)]
37pub enum FragmentCauseGate {
38    ZoomChange,
39    ProbeScale,
40    None,
41}
42
43/// `track.ts::fragmentCause`'s branch selection (checked in the same order: zoom change first).
44pub fn fragment_cause_gate(
45    zoom_change: bool,
46    has_previous_gray: bool,
47    blind: bool,
48) -> FragmentCauseGate {
49    if zoom_change {
50        FragmentCauseGate::ZoomChange
51    } else if has_previous_gray && !blind {
52        FragmentCauseGate::ProbeScale
53    } else {
54        FragmentCauseGate::None
55    }
56}
57
58/// `track.ts::occlusionEligible`. `decision` is the odometry/gate outcome tag shared with the ABI
59/// layer: 0 = tracked, 1 = static, 2 = blind, 3 = lost (only tracked/static are eligible).
60pub fn occlusion_eligible(has_previous: bool, kind_moving: bool, decision: u8) -> bool {
61    has_previous && kind_moving && (decision == 0 || decision == 1)
62}
63
64/// `track.ts::targetPose`.
65pub fn target_pose(keyframe: (f64, f64), offset: (f64, f64), shift: (f64, f64)) -> (f64, f64) {
66    (
67        keyframe.0 + offset.0 + shift.0,
68        keyframe.1 + offset.1 + shift.1,
69    )
70}
71
72/// The `global` revisit match `track.ts::attachVerdict` takes.
73pub struct GlobalMatch {
74    pub keyframe: (f64, f64),
75    pub offset: (f64, f64),
76    pub ambiguous: bool,
77    pub confidence: f64,
78}
79
80/// `track.ts::attachVerdict`. `resolved_target_eq_canvas` is the caller's `resolvedTarget ===
81/// canvasId` (string comparison stays in TS); the returned pose still needs pairing with
82/// `resolvedTarget` by the caller, which is why this returns only the pose, not the target string.
83pub fn attach_verdict(
84    global: Option<GlobalMatch>,
85    resolved_target_eq_canvas: bool,
86    shift: (f64, f64),
87) -> Option<(f64, f64)> {
88    let g = global?;
89    // `!(g.confidence > 0.72)`, spelled via `partial_cmp` to satisfy `clippy::neg_cmp_op_on_partial_ord` WITHOUT
90    // changing the truth table: `Some(Greater)` is the only value the negation excludes, so a NaN confidence
91    // (`partial_cmp` gives `None`) still takes this branch, exactly as `!(NaN > 0.72)` (`NaN > x` is always
92    // `false` in IEEE 754) evaluates to `true` in the TS original.
93    if resolved_target_eq_canvas
94        || g.ambiguous
95        || g.confidence.partial_cmp(&0.72) != Some(std::cmp::Ordering::Greater)
96    {
97        return None;
98    }
99    Some(target_pose(g.keyframe, g.offset, shift))
100}
101
102/// `track.ts::odometryWeight`.
103pub fn odometry_weight(weak_step: bool) -> f64 {
104    if weak_step {
105        0.05
106    } else {
107        1.0
108    }
109}
110
111/// `track.ts::thinOverlapEligible`.
112pub fn thin_overlap_eligible(
113    weak_step: bool,
114    weak: bool,
115    ambiguous: bool,
116    confidence: f64,
117    error: f64,
118) -> bool {
119    (weak_step || weak) && !ambiguous && confidence > 0.72 && error < 8.0
120}
121
122/// `track.ts::thinOverlapCorrection`. Returns `(target, discrepancy)`.
123pub fn thin_overlap_correction(
124    canonical_keyframe: (f64, f64),
125    offset: (f64, f64),
126    pose: (f64, f64),
127) -> Option<((f64, f64), f64)> {
128    let target = (
129        canonical_keyframe.0 + offset.0,
130        canonical_keyframe.1 + offset.1,
131    );
132    let discrepancy = js_hypot(target.0 - pose.0, target.1 - pose.1);
133    if discrepancy >= 16.0 {
134        Some((target, discrepancy))
135    } else {
136        None
137    }
138}
139
140/// `track.ts::LoopVerdict`, in the ABI tag order (0..3) `rust/wasm/src/track.rs` returns.
141#[derive(PartialEq, Eq, Debug)]
142pub enum LoopVerdict {
143    Closure,
144    Inconsistent,
145    Ambiguous,
146    None,
147}
148
149/// `track.ts::loopClosureVerdict`. Computes the discrepancy itself, like the TS original.
150pub fn loop_closure_verdict(
151    global_keyframe: (f64, f64),
152    global_offset: (f64, f64),
153    global_ambiguous: bool,
154    global_confidence: f64,
155    shift: (f64, f64),
156    pose: (f64, f64),
157) -> (LoopVerdict, f64) {
158    let discrepancy = js_hypot(
159        global_keyframe.0 + shift.0 + global_offset.0 - pose.0,
160        global_keyframe.1 + shift.1 + global_offset.1 - pose.1,
161    );
162    if !global_ambiguous && global_confidence > 0.72 && discrepancy < 16.0 {
163        (LoopVerdict::Closure, discrepancy)
164    } else if discrepancy >= 16.0 {
165        (LoopVerdict::Inconsistent, discrepancy)
166    } else if global_ambiguous {
167        (LoopVerdict::Ambiguous, discrepancy)
168    } else {
169        (LoopVerdict::None, discrepancy)
170    }
171}
172
173/// `track.ts::needsKeyframe`. `last_node_frame: None` mirrors the original's `lastNodeFrame ===
174/// undefined` ("no node exists yet"); `anchor: None` mirrors `anchor` undefined (→ `Infinity`
175/// distance) exactly like the TS ternary did on `anchor ? ... : Infinity`.
176/// `f64::max`/`f64::min` (not `Math.max`/`Math.min`) are used for the rect-dimension floor: Rust's
177/// versions return the non-NaN operand where JS's propagate NaN, but `rect.width`/`rect.height` are
178/// always finite region dimensions here, so the two never observably diverge for this call.
179#[allow(clippy::too_many_arguments)]
180pub fn needs_keyframe(
181    kind_moving: bool,
182    anchor: Option<(f64, f64)>,
183    pose: (f64, f64),
184    last_node_frame: Option<i32>,
185    rect: (f64, f64),
186    frame_index: i32,
187    field_difference: f64,
188) -> bool {
189    let Some(last_node_frame) = last_node_frame else {
190        return true;
191    };
192    if !kind_moving {
193        return false;
194    }
195    let anchor_distance = match anchor {
196        Some(a) => js_hypot(pose.0 - a.0, pose.1 - a.1),
197        None => f64::INFINITY,
198    };
199    let frames_since_last_node = (frame_index - last_node_frame) as f64;
200    anchor_distance > f64::max(48.0, f64::min(rect.0, rect.1) * 0.30)
201        || (frames_since_last_node > 90.0 && field_difference > 0.2)
202}
203
204/// `track.ts::zoomChanged`.
205pub fn zoom_changed(region_zoom: Option<f64>, field_zoom: f64) -> bool {
206    match region_zoom {
207        Some(rz) => (rz - 1.0).abs() > 0.04,
208        None => (field_zoom - 1.0).abs() > 0.04,
209    }
210}
211
212/// `track.ts::regionZoom`: the eligibility gate (`kind === 'moving' && ... >= 8`) fused with the
213/// existing `detect_scale` kernel it conditionally calls, so the ABI layer makes one call instead of
214/// counting uniques in TS first. `detect_scale` is unchanged ("detectScale is already Rust").
215pub fn region_zoom(kind_moving: bool, matches: &[MatchPoints]) -> Option<f64> {
216    if !kind_moving {
217        return None;
218    }
219    let unique_count = matches.iter().filter(|m| m.unique).count();
220    if unique_count >= 8 {
221        Some(detect_scale(matches))
222    } else {
223        None
224    }
225}