Skip to main content

long_screen/kernels/track/
odometry.rs

1//! Frame-to-frame odometry — split out of the former monolithic `track.rs`.
2//! `track.ts::odometry` fused into one call: analysis-scale hypotheses,
3//! block-aware audit, up to 6 native refinements + rival detection + confidence, falling back to the
4//! difference sample that decides `static` vs `lost`.
5
6use crate::kernels::features::{match_features, Feature};
7use crate::kernels::geometry::{js_ceil, js_hypot, Rect};
8use crate::kernels::motion::{
9    audit_translation, refine_native_at, sampling_insensitive_error, select_native_points,
10    translation_hypotheses, Gray, MatchPoints, NativeRefinement, Point,
11};
12use crate::kernels::region::Region;
13
14/// `track.ts::odometry`'s decision: `Tracked` carries a real displacement, `Static`/`Lost` share the
15/// difference-sampling fallback (the TS original's `decision === 'lost' ? 'lost' : 'static'` split).
16#[derive(PartialEq, Eq, Debug)]
17pub enum OdometryDecision {
18    Tracked,
19    Static,
20    Lost,
21}
22
23/// `track.ts::OdometryEstimate`'s `contentChange` (`undefined` when the audit's blocks all agreed).
24pub struct ContentChange {
25    pub agreement: f64,
26    pub blocks: u32,
27}
28
29/// A step the plain native error rejected but `rescuable` or `measured_between_pixels` measured as unambiguous: what
30/// `Tracked` would have carried. The caller takes it only where the frame would otherwise start a new fragment (after
31/// re-acquiring the anchor and relocalising have both failed), so every frame those already place stays as it was.
32pub struct Rescue {
33    pub delta: (f64, f64),
34    pub confidence: f64,
35    pub ambiguous: bool,
36    pub weak_step: bool,
37    pub step_error: f64,
38}
39
40pub struct OdometryEstimate {
41    pub decision: OdometryDecision,
42    pub delta: (f64, f64),
43    /// On `Tracked`: the fully computed step confidence (`best.m.confidence.max(0.05) * exp(-stepError / 20) *
44    /// ambiguous/weakStep factors`) — see the comment at this field's one call site below for why the `exp`
45    /// factor is finished here rather than by the TS caller. On `Static`/`Lost`: the input `confidence`, unchanged.
46    pub confidence: f64,
47    pub ambiguous: bool,
48    pub weak_step: bool,
49    /// `f64::INFINITY` on the `Static`/`Lost` branches, exactly like the TS original's `stepError`.
50    pub step_error: f64,
51    pub content_change: Option<ContentChange>,
52    /// On `Static`/`Lost`: the rescued step, when there is one.
53    pub rescue: Option<Rescue>,
54}
55
56/// One region's odometry inputs, fused into a single call: `matchFeatures` +
57/// `translationHypotheses` + the audit filter/sort + up to 6 native refinements with the velocity prior +
58/// rival detection + confidence, and — only on the fallback path — the static/lost difference sampling.
59/// `previous`/`current` are full native RGBA frames (`width × height × 4`); `region` is only read on the
60/// fallback path (`track.ts::odometry`'s `regionContains` loop) and may be omitted when the caller knows
61/// the fast path always succeeds in practice — omitting it on a frame that actually needs it just treats
62/// every sample as "contained", matching `regionContains`'s own `!region.mask` shortcut.
63pub struct OdometryInputs<'a> {
64    pub f: f64,
65    pub radius: i32,
66    pub mask: Option<(&'a [u8], u8)>,
67    pub roi: Rect,
68    pub rect: Rect,
69    pub region: Option<&'a Region>,
70    pub image_width: f64,
71    pub image_height: f64,
72    pub previous: &'a [u8],
73    pub current: &'a [u8],
74    pub previous_gray: Gray<'a>,
75    pub g: Gray<'a>,
76    pub velocity: (f64, f64),
77    pub previous_features: &'a [Feature],
78    pub own_features: &'a [Feature],
79    /// Carried in from before this call; returned unchanged on the `Static`/`Lost` branches, exactly as the
80    /// TS original left the outer `confidence` local untouched there.
81    pub confidence: f64,
82}
83
84/// Native mean error below which a step is tracked.
85const TRACKED_ERROR: f64 = 14.0;
86/// A step over [`TRACKED_ERROR`] can still be rescued when the offsets around it cost clearly more (runner-up above
87/// [`RESCUE_SEPARATION`] × the error) and its trimmed error ([`NativeRefinement::trimmed`]) is a small share of what
88/// a misplaced offset costs (below [`RESCUE_SHARE`] × the runner-up). Both are ratios because the errors scale with
89/// the content's contrast: the same steps of an HDR recording cost about twice as much once its colours are right as
90/// when its coded values were shown dark. Measured on 8 real steps the plain mean rejected — a sticky sidebar (error
91/// 14.3, trimmed 2.8, runner-up 50.5), a translucent tab bar (14.9, 5.0, 55.7) and fast flicks of 101–198 px
92/// (18.0–25.7, 8.4–12.7, 51.3–54.2): runner-up / error 2.07–3.73, trimmed / runner-up 0.06–0.25. Every other
93/// hypothesis the matches proposed had a runner-up within 1.1 × its error and a trimmed error at 0.66 or more of its
94/// runner-up.
95const RESCUE_SEPARATION: f64 = 1.5;
96const RESCUE_SHARE: f64 = 0.35;
97
98/// Whether a step the plain mean rejected is still a measurement: the points of a part that does not scroll (and,
99/// on a fast step, the encoder's loss) alone pushed the mean up — the rest match closely and no nearby offset does.
100fn rescuable(n: &NativeRefinement) -> bool {
101    n.error >= TRACKED_ERROR
102        && n.error.is_finite()
103        && n.runner_up > RESCUE_SEPARATION * n.error
104        && n.trimmed < RESCUE_SHARE * n.runner_up
105}
106
107/// Whether a step the plain mean rejected is still a measurement because its content moved by a fraction of a pixel:
108/// under the sampling-insensitive cost ([`sampling_insensitive_error`]) the chosen offset clears the tracked bar, and
109/// the offsets more than two pixels away miss it, by the same separation [`rescuable`] asks for. A recording
110/// downscaled by a non-integer factor moves by a fraction of a pixel on almost every step, and the native points sit
111/// on the strongest edges, so the plain mean at the correct offset grows with the phase: on a recording downscaled
112/// by 0.804, 303 of 393 steps cost 14.2–47.7, and 185 of them (27.1–47.7, runner-up 45–62) were not rescuable
113/// either. Their insensitive cost is 0–12.5 (median 0.17) at the plain mean's offset and 22.8–34.7 more than two
114/// pixels away; on the same recording at full size the plain mean tracks 392 of the 393 steps. The insensitive
115/// cost's bottom is flat within a pixel, so the plain mean still places the step.
116fn measured_between_pixels(error: f64, runner_up: f64) -> bool {
117    error < TRACKED_ERROR && runner_up >= TRACKED_ERROR && runner_up > RESCUE_SEPARATION * error
118}
119
120/// `track.ts::odometry`.
121pub fn odometry(inputs: OdometryInputs) -> OdometryEstimate {
122    let OdometryInputs {
123        f,
124        radius,
125        mask,
126        roi,
127        rect,
128        region,
129        image_width,
130        image_height,
131        previous,
132        current,
133        previous_gray,
134        g,
135        velocity,
136        previous_features,
137        own_features,
138        confidence,
139    } = inputs;
140
141    let raw_matches = match_features(previous_features, own_features, true);
142    let match_points: Vec<MatchPoints> = raw_matches
143        .iter()
144        .map(|m| {
145            let a = &previous_features[m.a as usize];
146            let b = &own_features[m.b as usize];
147            MatchPoints {
148                ax: a.x as f64,
149                ay: a.y as f64,
150                bx: b.x as f64,
151                by: b.y as f64,
152                unique: m.unique,
153            }
154        })
155        .collect();
156
157    let models: Vec<_> = translation_hypotheses(&match_points, 16)
158        .into_iter()
159        .filter(|m| m.support >= 4)
160        .collect();
161    // Period-aliased hypotheses on repeated content audit equally well; the constant-velocity prior orders
162    // them before the native decision so the true small step is never dropped in favour of a one-row-off
163    // alias with more (arbitrary) matches.
164    let prior = |mx: f64, my: f64| 0.02 * js_hypot(mx * f - velocity.0, my * f - velocity.1);
165    let mut scored: Vec<_> = models
166        .into_iter()
167        .map(|m| {
168            let audit = audit_translation(previous_gray, g, m.x, m.y, Some(roi), f > 1.0);
169            (m, audit)
170        })
171        .filter(|(_, audit)| {
172            audit.overlap > 0.10
173                && audit.error.is_finite()
174                && ((audit.error < 14.0 && audit.mismatch < 0.2)
175                    || (audit.agreement >= 0.4
176                        && audit.agreeing >= 3
177                        && audit.agreeing_error < 8.0))
178        })
179        .collect();
180    // Matches the TS original's exact left-to-right evaluation of
181    // `Math.min(a.error, a.agreeingError) + prior(a.m) - Math.min(b.error, b.agreeingError) - prior(b.m)`:
182    // two chained subtractions, not `(ka) - (kb)` — floating point subtraction is not associative, so grouping
183    // them differently can flip the sign in a near-tie and reorder the cut this comparator feeds into
184    // `refined`'s `.take(6)`.
185    scored.sort_by(|(am, aa), (bm, ba)| {
186        let d = aa.error.min(aa.agreeing_error) + prior(am.x, am.y)
187            - ba.error.min(ba.agreeing_error)
188            - prior(bm.x, bm.y);
189        d.partial_cmp(&0.0).unwrap_or(std::cmp::Ordering::Equal)
190    });
191    // Points depend only on `current`/`rect`/`mask`/`g`/`f`, not on any hypothesis, so they are selected once —
192    // lazily, only when at least one hypothesis survived the audit — and reused for all (up to 6) native
193    // refinements below instead of rescanning the region per candidate. `g` (this frame's own analysis gray)
194    // and `f` guide the selection coarse-to-fine instead of a full-resolution native scan — see
195    // `select_native_points`'s doc comment.
196    let points = if scored.is_empty() {
197        Vec::new()
198    } else {
199        select_native_points(
200            current,
201            image_width as usize,
202            image_height as usize,
203            rect,
204            mask,
205            Some((g, (f as usize).max(1))),
206        )
207    };
208    let mut refined: Vec<_> = scored
209        .into_iter()
210        .take(6)
211        .map(|(m, audit)| {
212            let n = refine_native_at(
213                previous,
214                current,
215                image_width as usize,
216                image_height as usize,
217                Point {
218                    x: m.x * f,
219                    y: m.y * f,
220                },
221                mask,
222                radius,
223                &points,
224            );
225            let key = n.error + 0.02 * js_hypot(n.x as f64 - velocity.0, n.y as f64 - velocity.1);
226            (m, audit, n, key)
227        })
228        .filter(|(_, _, n, _)| n.error.is_finite())
229        .collect();
230    refined.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal));
231
232    let mut rescue = None;
233    if let Some(best_index) = (!refined.is_empty()).then_some(0usize) {
234        let (best_m, best_audit, best_n, _) = &refined[best_index];
235        let measured = best_n.error < TRACKED_ERROR
236            || rescuable(best_n)
237            || best_n.error.is_finite() && {
238                let (error, runner_up) = sampling_insensitive_error(
239                    previous,
240                    current,
241                    image_width as usize,
242                    image_height as usize,
243                    (best_n.x, best_n.y),
244                    mask,
245                    radius,
246                    &points,
247                );
248                measured_between_pixels(error, runner_up)
249            };
250        if measured {
251            let delta = (best_n.x as f64, best_n.y as f64);
252            let rival = refined.iter().enumerate().find(|(i, (_, _, n, _))| {
253                *i != best_index
254                    && js_hypot(n.x as f64 - best_n.x as f64, n.y as f64 - best_n.y as f64) > 2.0
255                    && n.error < best_n.error + 2.0
256            });
257            let ambiguous = rival.is_some() || (best_m.ambiguous && refined.len() > 1);
258            // A fast jump leaves a thin strip of shared content. Periodic layouts align just as well one
259            // period away, so such a step is a best guess to be re-examined by revisit evidence, not a
260            // settled fact.
261            let weak_step = best_audit.overlap < 0.25;
262            let step_error = best_n.error;
263            let content_change = if best_audit.agreement < 0.85 && best_audit.blocks >= 4 {
264                Some(ContentChange {
265                    agreement: best_audit.agreement,
266                    blocks: best_audit.blocks,
267                })
268            } else {
269                None
270            };
271            // Finished here, in Rust, with `libm::exp` (the same code on every engine and native host) rather
272            // than by the TS caller with `Math.exp`: JS engines' `Math.exp` implementations differ in the last
273            // bit on some inputs, and `stepError` is a continuous, effectively-arbitrary float, so finishing this
274            // multiply in TS would let the same recording reach a different confidence — and, at a threshold, a
275            // different decision — in Chrome vs. Safari (see docs/ARCHITECTURE.md §十二).
276            // `f64::max` also differs from `Math.max` on a NaN `confidence` (never observed in practice —
277            // `best_m.confidence` comes from `translation_hypotheses`, which never produces NaN): Rust's
278            // `max(0.05)` returns `0.05` for a NaN input, where `Math.max(.05, NaN)` returns `NaN`. Rust's
279            // behaviour is the more robust of the two (a floor, not a NaN that then poisons every factor
280            // multiplied against it), so this is kept rather than reproduced.
281            let confidence = best_m.confidence.max(0.05)
282                * libm::exp(-step_error / 20.0)
283                * if ambiguous { 0.6 } else { 1.0 }
284                * if weak_step { 0.5 } else { 1.0 };
285            if best_n.error < TRACKED_ERROR {
286                return OdometryEstimate {
287                    decision: OdometryDecision::Tracked,
288                    delta,
289                    confidence,
290                    ambiguous,
291                    weak_step,
292                    step_error,
293                    content_change,
294                    rescue: None,
295                };
296            }
297            rescue = Some(Rescue {
298                delta,
299                confidence,
300                ambiguous,
301                weak_step,
302                step_error,
303            });
304        }
305    }
306
307    let mut difference = 0.0;
308    let mut samples = 0u32;
309    let mut y = js_ceil(roi.y) as f64;
310    while y < roi.y + roi.height {
311        let mut x = js_ceil(roi.x) as f64;
312        while x < roi.x + roi.width {
313            let contained =
314                region.is_none_or(|r| r.contains(x * f, y * f, image_width, image_height));
315            if contained {
316                let (yi, xi) = (y as usize, x as usize);
317                difference += (previous_gray.data[yi * g.width + xi] as f64
318                    - g.data[yi * g.width + xi] as f64)
319                    .abs();
320                samples += 1;
321            }
322            x += 7.0;
323        }
324        y += 7.0;
325    }
326    // Blank space can dominate the mean difference of a sparse page. It cannot overrule a measured,
327    // nonzero step: keep that step on the lost/reacquisition path where the caller can use its rescue.
328    let moved = rescue.as_ref().is_some_and(|r| r.delta != (0.0, 0.0));
329    let decision = if moved || difference / (samples.max(1) as f64) > 5.0 {
330        OdometryDecision::Lost
331    } else {
332        OdometryDecision::Static
333    };
334    OdometryEstimate {
335        decision,
336        delta: (0.0, 0.0),
337        confidence,
338        ambiguous: false,
339        weak_step: false,
340        step_error: f64::INFINITY,
341        content_change: None,
342        rescue,
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::{measured_between_pixels, rescuable};
349    use crate::kernels::geometry::Rect;
350    use crate::kernels::motion::{
351        sampling_insensitive_error, select_native_points, NativeRefinement,
352    };
353
354    fn n(error: f64, trimmed: f64, runner_up: f64) -> NativeRefinement {
355        NativeRefinement {
356            x: 0,
357            y: 0,
358            error,
359            samples: 600,
360            runner_up,
361            trimmed,
362        }
363    }
364
365    // Failure cases for the rescue gate: a step the plain mean accepts counted as a rescue (it never needs one); the
366    // measured rescues (a sticky sidebar, a translucent tab bar, the slowest and the fastest flick) refused; a rescue
367    // granted when the trimmed error is not low next to what a misplaced offset costs, when another offset close by
368    // nearly matches (an aliased step on repeated content), when there were too few points to measure anything, or
369    // for a hypothesis that is simply wrong.
370    #[test]
371    fn only_an_unambiguous_step_the_plain_mean_rejected_is_rescuable() {
372        assert!(!rescuable(&n(13.9, 2.0, 50.0)));
373        for (error, trimmed, runner_up) in [
374            (14.33, 2.8, 50.48),
375            (14.90, 5.0, 55.71),
376            (20.2, 8.7, 52.0),
377            (25.0, 12.7, 51.8),
378        ] {
379            assert!(
380                rescuable(&n(error, trimmed, runner_up)),
381                "{error} {trimmed} {runner_up}"
382            );
383        }
384        assert!(!rescuable(&n(14.33, 20.0, 50.48)));
385        assert!(!rescuable(&n(14.33, 2.8, 20.0)));
386        assert!(!rescuable(&n(f64::INFINITY, 2.8, f64::INFINITY)));
387        assert!(!rescuable(&n(51.8, 39.1, 54.0)));
388    }
389    const PAGE: (usize, usize) = (400, 900);
390
391    /// A sparse text page scrolled by `offset` native pixels under a fixed `toolbar`, as RGBA. A fractional offset
392    /// is the average of the two whole-pixel renderings around it: the half-pixel phase a non-integer downscale of
393    /// a recording gives every step.
394    fn page(offset: f64, toolbar: usize) -> Vec<u8> {
395        render(offset, |x, y, offset| {
396            let world_y = y + offset;
397            let (line, yy, word, xx) = (world_y / 140, world_y % 140, x / 60, x % 60);
398            if y < toolbar && x > 40 && x < 360 {
399                return if (x * 13 + y * 73 + x * y % 79) % 47 < 23 {
400                    20
401                } else {
402                    240
403                };
404            }
405            if (40..58).contains(&yy) && xx > 8 && xx < 38 {
406                let ink = ((line * 193 + word * 73 + xx / 3 * 19) >> ((yy - 40) / 3)) & 1;
407                if ink != 0 {
408                    return 45;
409                }
410            }
411            248
412        })
413    }
414
415    /// [`PAGE`]-sized RGBA of `value(x, y, offset)`, a fractional `offset` averaged from the whole pixels around it.
416    fn render(offset: f64, value: impl Fn(usize, usize, usize) -> u8) -> Vec<u8> {
417        let (width, height) = PAGE;
418        let (low, high) = (offset.floor() as usize, offset.ceil() as usize);
419        let mut data = vec![0; width * height * 4];
420        for y in 0..height {
421            for x in 0..width {
422                let v = (value(x, y, low) as u32 + value(x, y, high) as u32).div_ceil(2) as u8;
423                data[(y * width + x) * 4..(y * width + x) * 4 + 4].copy_from_slice(&[v, v, v, 255]);
424            }
425        }
426        data
427    }
428
429    /// Odometry from `previous` to `current` (both [`PAGE`]-sized RGBA) at analysis factor `f`, as the engine runs it.
430    fn measure(previous: &[u8], current: &[u8], f: usize) -> super::OdometryEstimate {
431        use super::{odometry, OdometryInputs};
432        use crate::kernels::features::extract_features;
433        use crate::kernels::motion::Gray;
434        use crate::kernels::raster::{downscale_gray, downscaled_size};
435        let (width, height) = PAGE;
436        let (w, h) = downscaled_size(width, height, f);
437        let mut a = vec![0; w * h];
438        let mut b = vec![0; w * h];
439        downscale_gray(previous, width, height, f, &mut a);
440        downscale_gray(current, width, height, f, &mut b);
441        let previous_features = extract_features(&a, w, h, 480, None);
442        let own_features = extract_features(&b, w, h, 480, None);
443        odometry(OdometryInputs {
444            f: f as f64,
445            radius: 3.max(f.div_ceil(2) as i32 + 1),
446            mask: None,
447            roi: Rect {
448                x: 0.0,
449                y: 0.0,
450                width: w as f64,
451                height: h as f64,
452            },
453            rect: Rect {
454                x: 0.0,
455                y: 0.0,
456                width: width as f64,
457                height: height as f64,
458            },
459            region: None,
460            image_width: width as f64,
461            image_height: height as f64,
462            previous,
463            current,
464            previous_gray: Gray {
465                width: w,
466                height: h,
467                data: &a,
468            },
469            g: Gray {
470                width: w,
471                height: h,
472                data: &b,
473            },
474            velocity: (0.0, 0.0),
475            previous_features: &previous_features,
476            own_features: &own_features,
477            confidence: 1.0,
478        })
479    }
480
481    /// The step odometry measured: tracked, or kept as a rescue for the caller.
482    fn measured_step(result: super::OdometryEstimate) -> Option<(f64, f64)> {
483        match result.decision {
484            super::OdometryDecision::Tracked => Some(result.delta),
485            _ => result.rescue.map(|r| r.delta),
486        }
487    }
488
489    // The phase/fixed-toolbar regression lives here because the engine consumes Rescue in Rust;
490    // the legacy standalone Wasm odometry call no longer exposes it.
491    #[test]
492    fn sparse_text_motion_survives_analysis_phase_and_fixed_chrome() {
493        for f in [2usize, 3, 4, 5] {
494            for toolbar in [32, 96] {
495                let result = measure(&page(0.0, toolbar), &page(67.0, toolbar), f);
496                assert_ne!(
497                    result.decision,
498                    super::OdometryDecision::Static,
499                    "factor {f}, toolbar {toolbar}"
500                );
501                assert_eq!(
502                    measured_step(result),
503                    Some((0.0, 67.0)),
504                    "factor {f}, toolbar {toolbar}"
505                );
506            }
507        }
508    }
509
510    // A recording downscaled by a non-integer factor moves its content by fractional native pixels, so every edge the
511    // native refinement samples lands between two pixels and the correct whole-pixel offset costs as much as a wrong
512    // one under the plain mean. Such a step is still a measurement and must stay available to the caller, at either
513    // whole pixel next to it, or the region starts a new fragment wherever re-acquisition also misses.
514    #[test]
515    fn a_step_between_two_pixels_is_still_measured() {
516        for f in [2usize, 3, 4, 5] {
517            for toolbar in [0, 32] {
518                let result = measure(&page(0.0, toolbar), &page(67.5, toolbar), f);
519                assert_ne!(
520                    result.decision,
521                    super::OdometryDecision::Static,
522                    "factor {f}, toolbar {toolbar}"
523                );
524                let step = measured_step(result);
525                assert!(
526                    step == Some((0.0, 67.0)) || step == Some((0.0, 68.0)),
527                    "factor {f}, toolbar {toolbar}: {step:?}"
528                );
529            }
530        }
531    }
532
533    // Failure cases for the between-pixels rule: a half-pixel step left unmeasured although only its phase costs, a
534    // displacement of a few pixels forgiven as phase, and rows that repeat within the search radius accepted although
535    // no offset stands out from the others.
536    #[test]
537    fn only_a_fraction_of_a_pixel_is_forgiven() {
538        let (width, height) = PAGE;
539        let rect = Rect {
540            x: 0.0,
541            y: 0.0,
542            width: width as f64,
543            height: height as f64,
544        };
545        let measured = |previous: &[u8], current: &[u8], dy: i32| {
546            let points = select_native_points(current, width, height, rect, None, None);
547            let (error, runner_up) = sampling_insensitive_error(
548                previous,
549                current,
550                width,
551                height,
552                (0, dy),
553                None,
554                3,
555                &points,
556            );
557            measured_between_pixels(error, runner_up)
558        };
559        let (previous, current) = (page(0.0, 0), page(67.5, 0));
560        for dy in [67, 68] {
561            assert!(measured(&previous, &current, dy), "{dy}");
562        }
563        for dy in [64, 65, 70, 71] {
564            assert!(!measured(&previous, &current, dy), "{dy}");
565        }
566        let rows = |offset| {
567            render(offset, |x, y, offset| {
568                if (x % 7 < 3) != ((y + offset) % 3 == 0) {
569                    45
570                } else {
571                    248
572                }
573            })
574        };
575        let (previous, current) = (rows(0.0), rows(67.5));
576        for dy in [67, 68] {
577            assert!(!measured(&previous, &current, dy), "repeated rows, {dy}");
578        }
579    }
580}