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, screen_fixed_split_error,
10    select_native_points, translation_hypotheses, Gray, MatchPoints, NativeRefinement, Point,
11    TranslationAudit,
12};
13use crate::kernels::region::Region;
14
15/// `track.ts::odometry`'s decision: `Tracked` carries a real displacement, `Static`/`Lost` share the
16/// difference-sampling fallback (the TS original's `decision === 'lost' ? 'lost' : 'static'` split).
17#[derive(PartialEq, Eq, Debug)]
18pub enum OdometryDecision {
19    Tracked,
20    Static,
21    Lost,
22}
23
24/// `track.ts::OdometryEstimate`'s `contentChange` (`undefined` when the audit's blocks all agreed).
25pub struct ContentChange {
26    pub agreement: f64,
27    pub blocks: u32,
28}
29
30/// A measured step kept behind anchor re-acquisition and relocalization. It may recover a
31/// phase-sensitive native rejection or an overlap that the interior registration window missed.
32/// The caller uses it only where the frame would otherwise start a new fragment.
33pub struct Rescue {
34    /// This rescue has additional phase and spatial evidence; registration may use a separate support window.
35    pub phase_support: bool,
36    pub delta: (f64, f64),
37    pub confidence: f64,
38    pub ambiguous: bool,
39    pub weak_step: bool,
40    pub step_error: f64,
41}
42
43pub struct OdometryEstimate {
44    pub decision: OdometryDecision,
45    pub delta: (f64, f64),
46    /// On `Tracked`: the fully computed step confidence (`best.m.confidence.max(0.05) * exp(-stepError / 20) *
47    /// ambiguous/weakStep factors`) — see the comment at this field's one call site below for why the `exp`
48    /// factor is finished here rather than by the TS caller. On `Static`/`Lost`: the input `confidence`, unchanged.
49    pub confidence: f64,
50    pub ambiguous: bool,
51    pub weak_step: bool,
52    /// `f64::INFINITY` on the `Static`/`Lost` branches, exactly like the TS original's `stepError`.
53    pub step_error: f64,
54    pub content_change: Option<ContentChange>,
55    /// On `Static`/`Lost`: the rescued step, when there is one.
56    pub rescue: Option<Rescue>,
57}
58
59/// One region's odometry inputs, fused into a single call: `matchFeatures` +
60/// `translationHypotheses` + the audit filter/sort + up to 6 native refinements with the velocity prior +
61/// rival detection + confidence, and — only on the fallback path — the static/lost difference sampling.
62/// `previous`/`current` are full native RGBA frames (`width × height × 4`); `region` is only read on the
63/// fallback path (`track.ts::odometry`'s `regionContains` loop) and may be omitted when the caller knows
64/// the fast path always succeeds in practice — omitting it on a frame that actually needs it just treats
65/// every sample as "contained", matching `regionContains`'s own `!region.mask` shortcut.
66#[derive(Clone, Copy)]
67pub struct OdometryInputs<'a> {
68    pub f: f64,
69    pub radius: i32,
70    pub mask: Option<(&'a [u8], u8)>,
71    pub roi: Rect,
72    pub rect: Rect,
73    pub region: Option<&'a Region>,
74    pub image_width: f64,
75    pub image_height: f64,
76    pub previous: &'a [u8],
77    pub current: &'a [u8],
78    pub previous_gray: Gray<'a>,
79    pub g: Gray<'a>,
80    pub velocity: (f64, f64),
81    pub previous_features: &'a [Feature],
82    pub own_features: &'a [Feature],
83    /// Carried in from before this call; returned unchanged on the `Static`/`Lost` branches, exactly as the
84    /// TS original left the outer `confidence` local untouched there.
85    pub confidence: f64,
86}
87
88/// Native mean error below which a step is tracked.
89const TRACKED_ERROR: f64 = 14.0;
90/// Matches a hypothesis needs to be refined natively.
91const MIN_SUPPORT: u32 = 4;
92/// When no hypothesis has [`MIN_SUPPORT`] matches, the best this many of the rest (three matches each, the least
93/// `translation_hypotheses` proposes) are audited and refined instead, and the native check decides. Without them a
94/// dark, sparse frame that moved fell through to the difference sample, whose blank pixels call it static: on an HDR
95/// recording of a painting (test_4, frame 1080) the frame had four matches, the true 112-px step was the one
96/// hypothesis and had three of them, the mean difference was 0.6, and every later frame was placed 111 px off,
97/// leaving seams through the mouth and eyebrows and 36 rejected loop closures.
98const FEW_MATCH_MODELS: usize = 2;
99/// A step over [`TRACKED_ERROR`] can still be rescued when the offsets around it cost clearly more (runner-up above
100/// [`RESCUE_SEPARATION`] × the error) and its trimmed error ([`NativeRefinement::trimmed`]) is a small share of what
101/// a misplaced offset costs (below [`RESCUE_SHARE`] × the runner-up). Both are ratios because the errors scale with
102/// the content's contrast: the same steps of an HDR recording cost about twice as much once its colours are right as
103/// when its coded values were shown dark. Measured on 8 real steps the plain mean rejected — a sticky sidebar (error
104/// 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
105/// (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
106/// hypothesis the matches proposed had a runner-up within 1.1 × its error and a trimmed error at 0.66 or more of its
107/// runner-up.
108const RESCUE_SEPARATION: f64 = 1.5;
109const RESCUE_SHARE: f64 = 0.35;
110
111/// Whether a step the plain mean rejected is still a measurement: the points of a part that does not scroll (and,
112/// on a fast step, the encoder's loss) alone pushed the mean up — the rest match closely and no nearby offset does.
113fn rescuable(n: &NativeRefinement) -> bool {
114    n.error >= TRACKED_ERROR
115        && n.error.is_finite()
116        && n.runner_up > RESCUE_SEPARATION * n.error
117        && n.trimmed < RESCUE_SHARE * n.runner_up
118}
119
120/// Whether a step the plain mean rejected is still a measurement because its content moved by a fraction of a pixel:
121/// under the sampling-insensitive cost ([`sampling_insensitive_error`]) the chosen offset clears the tracked bar, and
122/// the offsets more than two pixels away miss it, by the same separation [`rescuable`] asks for. A recording
123/// downscaled by a non-integer factor moves by a fraction of a pixel on almost every step, and the native points sit
124/// on the strongest edges, so the plain mean at the correct offset grows with the phase: on a recording downscaled
125/// 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
126/// 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
127/// pixels away; on the same recording at full size the plain mean tracks 392 of the 393 steps. The insensitive
128/// cost's bottom is flat within a pixel, so the plain mean still places the step.
129fn measured_between_pixels(error: f64, runner_up: f64) -> bool {
130    error < TRACKED_ERROR && runner_up >= TRACKED_ERROR && runner_up > RESCUE_SEPARATION * error
131}
132
133/// Whether a step is measured once the region may hold a screen-fixed part ([`screen_fixed_split_error`]): the offset
134/// explains more of the points than zero does, the split cost clears the tracked bar, and the offsets more than two
135/// pixels away cost [`RESCUE_SEPARATION`] times as much and at least 4 more. A part that stays on screen inside a
136/// moving region is the smaller part; when most of the region stayed put, the region did not move and the part that
137/// did is something inside it (a nested pane, an animation), as on a Mac recording (own_a, frames 1024–1040) where 58 %
138/// of the features stayed and the rest moved by 54 px a frame. The bar on the runner-up is relative, not
139/// [`TRACKED_ERROR`], because the screen-fixed points lower every offset's split cost by their share: on new_ip_7
140/// (status bar, search bar and chips over a map, about a third of the points) the 122 true steps that reached this
141/// check split to 0.3–4.1 with runner-ups of 6.2–10.8, and none of the 83 wrong candidates had a runner-up above 1.07
142/// times its own cost.
143fn split_measured(error: f64, runner_up: f64, moved: f64) -> bool {
144    moved > 0.5
145        && error < TRACKED_ERROR
146        && runner_up > RESCUE_SEPARATION * error
147        && runner_up >= error + 4.0
148}
149
150/// `track.ts::odometry`.
151pub fn odometry(inputs: OdometryInputs) -> OdometryEstimate {
152    let OdometryInputs {
153        f,
154        radius,
155        mask,
156        roi,
157        rect,
158        region,
159        image_width,
160        image_height,
161        previous,
162        current,
163        previous_gray,
164        g,
165        velocity,
166        previous_features,
167        own_features,
168        confidence,
169    } = inputs;
170
171    let raw_matches = match_features(previous_features, own_features, true);
172    let match_points: Vec<MatchPoints> = raw_matches
173        .iter()
174        .map(|m| {
175            let a = &previous_features[m.a as usize];
176            let b = &own_features[m.b as usize];
177            MatchPoints {
178                ax: a.x as f64,
179                ay: a.y as f64,
180                bx: b.x as f64,
181                by: b.y as f64,
182                unique: m.unique,
183            }
184        })
185        .collect();
186
187    let hypotheses = translation_hypotheses(&match_points, 16);
188    let models: Vec<_> = if hypotheses.iter().any(|m| m.support >= MIN_SUPPORT) {
189        hypotheses
190            .into_iter()
191            .filter(|m| m.support >= MIN_SUPPORT)
192            .collect()
193    } else {
194        hypotheses.into_iter().take(FEW_MATCH_MODELS).collect()
195    };
196    // Period-aliased hypotheses on repeated content audit equally well; the constant-velocity prior orders
197    // them before the native decision so the true small step is never dropped in favour of a one-row-off
198    // alias with more (arbitrary) matches.
199    let prior = |mx: f64, my: f64| 0.02 * js_hypot(mx * f - velocity.0, my * f - velocity.1);
200    // Multiple tolerant candidates share the same filtered image pair. Zero or one
201    // candidate keeps the standalone path; release the buffers before native refinement.
202    let prepared =
203        (f > 1.0 && models.len() > 1).then(|| TranslationAudit::new(previous_gray, g, true));
204    let mut scored: Vec<_> = models
205        .into_iter()
206        .map(|m| {
207            let audit = match &prepared {
208                Some(pair) => pair.measure(m.x, m.y, Some(roi)),
209                None => audit_translation(previous_gray, g, m.x, m.y, Some(roi), f > 1.0),
210            };
211            (m, audit)
212        })
213        .filter(|(_, audit)| {
214            audit.overlap > 0.10
215                && audit.error.is_finite()
216                && ((audit.error < 14.0 && audit.mismatch < 0.2)
217                    || (audit.agreement >= 0.4
218                        && audit.agreeing >= 3
219                        && audit.agreeing_error < 8.0))
220        })
221        .collect();
222    drop(prepared);
223    // Matches the TS original's exact left-to-right evaluation of
224    // `Math.min(a.error, a.agreeingError) + prior(a.m) - Math.min(b.error, b.agreeingError) - prior(b.m)`:
225    // two chained subtractions, not `(ka) - (kb)` — floating point subtraction is not associative, so grouping
226    // them differently can flip the sign in a near-tie and reorder the cut this comparator feeds into
227    // `refined`'s `.take(6)`.
228    scored.sort_by(|(am, aa), (bm, ba)| {
229        let d = aa.error.min(aa.agreeing_error) + prior(am.x, am.y)
230            - ba.error.min(ba.agreeing_error)
231            - prior(bm.x, bm.y);
232        d.partial_cmp(&0.0).unwrap_or(std::cmp::Ordering::Equal)
233    });
234    // Points depend only on `current`/`rect`/`mask`/`g`/`f`, not on any hypothesis, so they are selected once —
235    // lazily, only when at least one hypothesis survived the audit — and reused for all (up to 6) native
236    // refinements below instead of rescanning the region per candidate. `g` (this frame's own analysis gray)
237    // and `f` guide the selection coarse-to-fine instead of a full-resolution native scan — see
238    // `select_native_points`'s doc comment.
239    let points = if scored.is_empty() {
240        Vec::new()
241    } else {
242        select_native_points(
243            current,
244            image_width as usize,
245            image_height as usize,
246            rect,
247            mask,
248            Some((g, (f as usize).max(1))),
249        )
250    };
251    let mut refined: Vec<_> = scored
252        .into_iter()
253        .take(6)
254        .map(|(m, audit)| {
255            let n = refine_native_at(
256                previous,
257                current,
258                image_width as usize,
259                image_height as usize,
260                Point {
261                    x: m.x * f,
262                    y: m.y * f,
263                },
264                mask,
265                radius,
266                &points,
267            );
268            let key = n.error + 0.02 * js_hypot(n.x as f64 - velocity.0, n.y as f64 - velocity.1);
269            (m, audit, n, key)
270        })
271        .filter(|(_, _, n, _)| n.error.is_finite())
272        .collect();
273    refined.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal));
274
275    let mut rescue = None;
276    let mut phase_support = false;
277    if let Some(best_index) = (!refined.is_empty()).then_some(0usize) {
278        let (best_m, best_audit, best_n, _) = &refined[best_index];
279        let measured = best_n.error < TRACKED_ERROR
280            || rescuable(best_n)
281            || best_n.error.is_finite() && {
282                let (error, runner_up) = sampling_insensitive_error(
283                    previous,
284                    current,
285                    image_width as usize,
286                    image_height as usize,
287                    (best_n.x, best_n.y),
288                    mask,
289                    radius,
290                    &points,
291                );
292                measured_between_pixels(error, runner_up) || {
293                    phase_support = !best_m.ambiguous
294                        && !refined.iter().enumerate().any(|(i, (_, _, n, _))| {
295                            i != best_index
296                                && js_hypot(
297                                    n.x as f64 - best_n.x as f64,
298                                    n.y as f64 - best_n.y as f64,
299                                ) > 2.0
300                                && n.error < best_n.error + 4.0
301                        })
302                        && super::phase::supports_translation(
303                            error,
304                            runner_up,
305                            best_m,
306                            best_audit,
307                            &match_points,
308                            roi,
309                        );
310                    phase_support
311                }
312            };
313        if measured {
314            let delta = (best_n.x as f64, best_n.y as f64);
315            let rival = refined.iter().enumerate().find(|(i, (_, _, n, _))| {
316                *i != best_index
317                    && js_hypot(n.x as f64 - best_n.x as f64, n.y as f64 - best_n.y as f64) > 2.0
318                    && n.error < best_n.error + 2.0
319            });
320            let ambiguous = rival.is_some() || (best_m.ambiguous && refined.len() > 1);
321            // A fast jump leaves a thin strip of shared content. Periodic layouts align just as well one
322            // period away, so such a step is a best guess to be re-examined by revisit evidence, not a
323            // settled fact.
324            let weak_step = best_audit.overlap < 0.25;
325            let step_error = best_n.error;
326            let content_change = if best_audit.agreement < 0.85 && best_audit.blocks >= 4 {
327                Some(ContentChange {
328                    agreement: best_audit.agreement,
329                    blocks: best_audit.blocks,
330                })
331            } else {
332                None
333            };
334            // Finished here, in Rust, with `libm::exp` (the same code on every engine and native host) rather
335            // than by the TS caller with `Math.exp`: JS engines' `Math.exp` implementations differ in the last
336            // bit on some inputs, and `stepError` is a continuous, effectively-arbitrary float, so finishing this
337            // multiply in TS would let the same recording reach a different confidence — and, at a threshold, a
338            // different decision — in Chrome vs. Safari (see docs/ARCHITECTURE.md §十二).
339            // `f64::max` also differs from `Math.max` on a NaN `confidence` (never observed in practice —
340            // `best_m.confidence` comes from `translation_hypotheses`, which never produces NaN): Rust's
341            // `max(0.05)` returns `0.05` for a NaN input, where `Math.max(.05, NaN)` returns `NaN`. Rust's
342            // behaviour is the more robust of the two (a floor, not a NaN that then poisons every factor
343            // multiplied against it), so this is kept rather than reproduced.
344            let confidence = best_m.confidence.max(0.05)
345                * libm::exp(-step_error / 20.0)
346                * if ambiguous { 0.6 } else { 1.0 }
347                * if weak_step { 0.5 } else { 1.0 };
348            if best_n.error < TRACKED_ERROR {
349                return OdometryEstimate {
350                    decision: OdometryDecision::Tracked,
351                    delta,
352                    confidence,
353                    ambiguous,
354                    weak_step,
355                    step_error,
356                    content_change,
357                    rescue: None,
358                };
359            }
360            rescue = Some(Rescue {
361                phase_support,
362                delta,
363                confidence,
364                ambiguous,
365                weak_step,
366                step_error,
367            });
368        }
369    }
370
371    // A region that holds a screen-fixed part keeps a zero-offset hypothesis alive on every frame, and the plain mean
372    // then charges the true step for every point of that part. When nothing else measured the step, or only a zero
373    // offset did, a non-zero candidate is still measured if splitting the points between it and zero clears the
374    // tracked bar while offsets more than two pixels from it do not (see `split_measured`).
375    if rescue.as_ref().is_none_or(|r| r.delta == (0.0, 0.0)) {
376        let split: Vec<(usize, f64, f64, f64)> = refined
377            .iter()
378            .enumerate()
379            .filter(|(_, (_, _, n, _))| (n.x, n.y) != (0, 0))
380            .map(|(i, (_, _, n, _))| {
381                let (error, runner_up, moved) = screen_fixed_split_error(
382                    previous,
383                    current,
384                    image_width as usize,
385                    image_height as usize,
386                    (n.x, n.y),
387                    mask,
388                    radius,
389                    &points,
390                );
391                (i, error, runner_up, moved)
392            })
393            .collect();
394        let chosen = split
395            .iter()
396            .filter(|(_, error, runner_up, moved)| split_measured(*error, *runner_up, *moved))
397            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
398        if let Some(&(i, error, _, _)) = chosen {
399            let (m, audit, n, _) = &refined[i];
400            let ambiguous = split.iter().any(|&(j, other, _, _)| {
401                let o = &refined[j].2;
402                j != i
403                    && js_hypot(o.x as f64 - n.x as f64, o.y as f64 - n.y as f64) > 2.0
404                    && other < error + 2.0
405            }) || (m.ambiguous && refined.len() > 1);
406            let weak_step = audit.overlap < 0.25;
407            let confidence = m.confidence.max(0.05)
408                * libm::exp(-n.error / 20.0)
409                * if ambiguous { 0.6 } else { 1.0 }
410                * if weak_step { 0.5 } else { 1.0 };
411            rescue = Some(Rescue {
412                phase_support: false,
413                delta: (n.x as f64, n.y as f64),
414                confidence,
415                ambiguous,
416                weak_step,
417                step_error: n.error,
418            });
419        }
420    }
421
422    let mut difference = 0.0;
423    let mut samples = 0u32;
424    let mut y = js_ceil(roi.y) as f64;
425    while y < roi.y + roi.height {
426        let mut x = js_ceil(roi.x) as f64;
427        while x < roi.x + roi.width {
428            let contained =
429                region.is_none_or(|r| r.contains(x * f, y * f, image_width, image_height));
430            if contained {
431                let (yi, xi) = (y as usize, x as usize);
432                difference += (previous_gray.data[yi * g.width + xi] as f64
433                    - g.data[yi * g.width + xi] as f64)
434                    .abs();
435                samples += 1;
436            }
437            x += 7.0;
438        }
439        y += 7.0;
440    }
441    // Blank space can dominate the mean difference of a sparse page. It cannot overrule a measured,
442    // nonzero step: keep that step on the lost/reacquisition path where the caller can use its rescue.
443    let moved = rescue.as_ref().is_some_and(|r| r.delta != (0.0, 0.0));
444    let decision = if moved || difference / (samples.max(1) as f64) > 5.0 {
445        OdometryDecision::Lost
446    } else {
447        OdometryDecision::Static
448    };
449    OdometryEstimate {
450        decision,
451        delta: (0.0, 0.0),
452        confidence,
453        ambiguous: false,
454        weak_step: false,
455        step_error: f64::INFINITY,
456        content_change: None,
457        rescue,
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::{measured_between_pixels, rescuable};
464    use crate::kernels::geometry::Rect;
465    use crate::kernels::motion::{
466        sampling_insensitive_error, select_native_points, NativeRefinement,
467    };
468
469    fn n(error: f64, trimmed: f64, runner_up: f64) -> NativeRefinement {
470        NativeRefinement {
471            x: 0,
472            y: 0,
473            error,
474            samples: 600,
475            runner_up,
476            trimmed,
477        }
478    }
479
480    // Failure cases for the rescue gate: a step the plain mean accepts counted as a rescue (it never needs one); the
481    // measured rescues (a sticky sidebar, a translucent tab bar, the slowest and the fastest flick) refused; a rescue
482    // granted when the trimmed error is not low next to what a misplaced offset costs, when another offset close by
483    // nearly matches (an aliased step on repeated content), when there were too few points to measure anything, or
484    // for a hypothesis that is simply wrong.
485    #[test]
486    fn only_an_unambiguous_step_the_plain_mean_rejected_is_rescuable() {
487        assert!(!rescuable(&n(13.9, 2.0, 50.0)));
488        for (error, trimmed, runner_up) in [
489            (14.33, 2.8, 50.48),
490            (14.90, 5.0, 55.71),
491            (20.2, 8.7, 52.0),
492            (25.0, 12.7, 51.8),
493        ] {
494            assert!(
495                rescuable(&n(error, trimmed, runner_up)),
496                "{error} {trimmed} {runner_up}"
497            );
498        }
499        assert!(!rescuable(&n(14.33, 20.0, 50.48)));
500        assert!(!rescuable(&n(14.33, 2.8, 20.0)));
501        assert!(!rescuable(&n(f64::INFINITY, 2.8, f64::INFINITY)));
502        assert!(!rescuable(&n(51.8, 39.1, 54.0)));
503    }
504    const PAGE: (usize, usize) = (400, 900);
505
506    /// A sparse text page scrolled by `offset` native pixels under a fixed `toolbar`, as RGBA. A fractional offset
507    /// is the average of the two whole-pixel renderings around it: the half-pixel phase a non-integer downscale of
508    /// a recording gives every step.
509    fn page(offset: f64, toolbar: usize) -> Vec<u8> {
510        render(offset, |x, y, offset| {
511            let world_y = y + offset;
512            let (line, yy, word, xx) = (world_y / 140, world_y % 140, x / 60, x % 60);
513            if y < toolbar && x > 40 && x < 360 {
514                return if (x * 13 + y * 73 + x * y % 79) % 47 < 23 {
515                    20
516                } else {
517                    240
518                };
519            }
520            if (40..58).contains(&yy) && xx > 8 && xx < 38 {
521                let ink = ((line * 193 + word * 73 + xx / 3 * 19) >> ((yy - 40) / 3)) & 1;
522                if ink != 0 {
523                    return 45;
524                }
525            }
526            248
527        })
528    }
529
530    /// [`PAGE`]-sized RGBA of `value(x, y, offset)`, a fractional `offset` averaged from the whole pixels around it.
531    fn render(offset: f64, value: impl Fn(usize, usize, usize) -> u8) -> Vec<u8> {
532        let (width, height) = PAGE;
533        let (low, high) = (offset.floor() as usize, offset.ceil() as usize);
534        let mut data = vec![0; width * height * 4];
535        for y in 0..height {
536            for x in 0..width {
537                let v = (value(x, y, low) as u32 + value(x, y, high) as u32).div_ceil(2) as u8;
538                data[(y * width + x) * 4..(y * width + x) * 4 + 4].copy_from_slice(&[v, v, v, 255]);
539            }
540        }
541        data
542    }
543
544    /// Odometry from `previous` to `current` (both [`PAGE`]-sized RGBA) at analysis factor `f`, as the engine runs it.
545    fn measure(previous: &[u8], current: &[u8], f: usize) -> super::OdometryEstimate {
546        use super::{odometry, OdometryInputs};
547        use crate::kernels::features::extract_features;
548        use crate::kernels::motion::Gray;
549        use crate::kernels::raster::{downscale_gray, downscaled_size};
550        let (width, height) = PAGE;
551        let (w, h) = downscaled_size(width, height, f);
552        let mut a = vec![0; w * h];
553        let mut b = vec![0; w * h];
554        downscale_gray(previous, width, height, f, &mut a);
555        downscale_gray(current, width, height, f, &mut b);
556        let previous_features = extract_features(&a, w, h, 480, None);
557        let own_features = extract_features(&b, w, h, 480, None);
558        odometry(OdometryInputs {
559            f: f as f64,
560            radius: 3.max(f.div_ceil(2) as i32 + 1),
561            mask: None,
562            roi: Rect {
563                x: 0.0,
564                y: 0.0,
565                width: w as f64,
566                height: h as f64,
567            },
568            rect: Rect {
569                x: 0.0,
570                y: 0.0,
571                width: width as f64,
572                height: height as f64,
573            },
574            region: None,
575            image_width: width as f64,
576            image_height: height as f64,
577            previous,
578            current,
579            previous_gray: Gray {
580                width: w,
581                height: h,
582                data: &a,
583            },
584            g: Gray {
585                width: w,
586                height: h,
587                data: &b,
588            },
589            velocity: (0.0, 0.0),
590            previous_features: &previous_features,
591            own_features: &own_features,
592            confidence: 1.0,
593        })
594    }
595
596    /// The step odometry measured: tracked, or kept as a rescue for the caller.
597    fn measured_step(result: super::OdometryEstimate) -> Option<(f64, f64)> {
598        match result.decision {
599            super::OdometryDecision::Tracked => Some(result.delta),
600            _ => result.rescue.map(|r| r.delta),
601        }
602    }
603
604    // The phase/fixed-toolbar regression lives here because the engine consumes Rescue in Rust;
605    // the legacy standalone Wasm odometry call no longer exposes it.
606    #[test]
607    fn sparse_text_motion_survives_analysis_phase_and_fixed_chrome() {
608        for f in [2usize, 3, 4, 5] {
609            for toolbar in [32, 96] {
610                let result = measure(&page(0.0, toolbar), &page(67.0, toolbar), f);
611                assert_ne!(
612                    result.decision,
613                    super::OdometryDecision::Static,
614                    "factor {f}, toolbar {toolbar}"
615                );
616                assert_eq!(
617                    measured_step(result),
618                    Some((0.0, 67.0)),
619                    "factor {f}, toolbar {toolbar}"
620                );
621            }
622        }
623    }
624
625    // A recording downscaled by a non-integer factor moves its content by fractional native pixels, so every edge the
626    // native refinement samples lands between two pixels and the correct whole-pixel offset costs as much as a wrong
627    // one under the plain mean. Such a step is still a measurement and must stay available to the caller, at either
628    // whole pixel next to it, or the region starts a new fragment wherever re-acquisition also misses.
629    #[test]
630    fn a_step_between_two_pixels_is_still_measured() {
631        for f in [2usize, 3, 4, 5] {
632            for toolbar in [0, 32] {
633                let result = measure(&page(0.0, toolbar), &page(67.5, toolbar), f);
634                assert_ne!(
635                    result.decision,
636                    super::OdometryDecision::Static,
637                    "factor {f}, toolbar {toolbar}"
638                );
639                let step = measured_step(result);
640                assert!(
641                    step == Some((0.0, 67.0)) || step == Some((0.0, 68.0)),
642                    "factor {f}, toolbar {toolbar}: {step:?}"
643                );
644            }
645        }
646    }
647
648    // Failure cases for the between-pixels rule: a half-pixel step left unmeasured although only its phase costs, a
649    // displacement of a few pixels forgiven as phase, and rows that repeat within the search radius accepted although
650    // no offset stands out from the others.
651    #[test]
652    fn only_a_fraction_of_a_pixel_is_forgiven() {
653        let (width, height) = PAGE;
654        let rect = Rect {
655            x: 0.0,
656            y: 0.0,
657            width: width as f64,
658            height: height as f64,
659        };
660        let measured = |previous: &[u8], current: &[u8], dy: i32| {
661            let points = select_native_points(current, width, height, rect, None, None);
662            let (error, runner_up) = sampling_insensitive_error(
663                previous,
664                current,
665                width,
666                height,
667                (0, dy),
668                None,
669                3,
670                &points,
671            );
672            measured_between_pixels(error, runner_up)
673        };
674        let (previous, current) = (page(0.0, 0), page(67.5, 0));
675        for dy in [67, 68] {
676            assert!(measured(&previous, &current, dy), "{dy}");
677        }
678        for dy in [64, 65, 70, 71] {
679            assert!(!measured(&previous, &current, dy), "{dy}");
680        }
681        let rows = |offset| {
682            render(offset, |x, y, offset| {
683                if (x % 7 < 3) != ((y + offset) % 3 == 0) {
684                    45
685                } else {
686                    248
687                }
688            })
689        };
690        let (previous, current) = (rows(0.0), rows(67.5));
691        for dy in [67, 68] {
692            assert!(!measured(&previous, &current, dy), "repeated rows, {dy}");
693        }
694    }
695
696    /// A textured map under a screen-fixed bar of `toolbar` rows (dense, high contrast, like a search bar and status bar
697    /// over a map), panned by `offset` native pixels; the map is two thirds of the frame or more.
698    fn map(offset: f64, toolbar: usize) -> Vec<u8> {
699        render(offset, |x, y, offset| {
700            if y < toolbar && x > 40 && x < 360 {
701                return if (x * 13 + y * 73 + x * y % 79) % 47 < 23 {
702                    20
703                } else {
704                    240
705                };
706            }
707            let (bx, by) = (x / 3, (y + offset) / 3);
708            (150 + (bx.wrapping_mul(73_856_093) ^ by.wrapping_mul(19_349_663)) % 97) as u8
709        })
710    }
711
712    // Failure cases for a moving region that holds a screen-fixed part (a search bar, status bar and chips over a map,
713    // new_ip_7): the fixed part's points match at zero on every frame, so the plain mean puts the zero offset ahead of
714    // the true step and keeps it as the rescue, also when the map moved by a fraction of a pixel; a frame that did not
715    // move measured as a step; and, when the fixed part is the larger one, the part that moved taken for the region.
716    #[test]
717    fn a_step_under_a_screen_fixed_part_is_still_measured() {
718        for f in [2usize, 3, 4] {
719            for (offset, steps) in [(67.0, [67.0, 67.0]), (67.5, [67.0, 68.0])] {
720                let result = measure(&map(0.0, 300), &map(offset, 300), f);
721                let step = measured_step(result);
722                assert!(
723                    step == Some((0.0, steps[0])) || step == Some((0.0, steps[1])),
724                    "factor {f}, offset {offset}: {step:?}"
725                );
726            }
727            let still = measure(&map(67.0, 300), &map(67.0, 300), f);
728            assert!(
729                matches!(measured_step(still), None | Some((0.0, 0.0))),
730                "factor {f}"
731            );
732            // The bar is now most of the frame: what moved is something inside a region that stayed put.
733            let inside = measured_step(measure(&map(0.0, 700), &map(67.0, 700), f));
734            assert!(
735                matches!(inside, None | Some((0.0, 0.0))),
736                "factor {f}: {inside:?}"
737            );
738        }
739    }
740
741    /// A dark page with five dim 28 × 16 marks, scrolled by `offset`: the frame gives the matcher only a handful of
742    /// features, so the true step can be backed by three matches.
743    fn dark(offset: f64) -> Vec<u8> {
744        const MARKS: [(usize, usize); 5] =
745            [(60, 300), (250, 420), (130, 610), (300, 760), (80, 900)];
746        render(offset, |x, y, offset| {
747            let world_y = y + offset;
748            for (mx, my) in MARKS {
749                if (mx..mx + 28).contains(&x) && (my..my + 16).contains(&world_y) {
750                    let (u, v) = (x - mx, world_y - my);
751                    return if (u * 7 + v * 3 + u * v) % 11 < 5 {
752                        96
753                    } else {
754                        30
755                    };
756                }
757            }
758            18
759        })
760    }
761
762    // Failure cases for a dark, sparse frame: a real step declared static because the one hypothesis that has it is
763    // backed by three matches (fewer than the four a hypothesis needs), so every later frame inherits the missed step;
764    // a spurious hypothesis with the same support taken instead of it; and a frame that did not move measured as a
765    // step. At factor 3 the matches propose the true step and a spurious one, three matches each.
766    #[test]
767    fn a_step_backed_by_three_matches_is_still_measured() {
768        for f in [2usize, 3, 4] {
769            let result = measure(&dark(0.0), &dark(112.0), f);
770            assert_ne!(
771                result.decision,
772                super::OdometryDecision::Static,
773                "factor {f}"
774            );
775            assert_eq!(measured_step(result), Some((0.0, 112.0)), "factor {f}");
776            let still = measure(&dark(112.0), &dark(112.0), f);
777            assert!(
778                matches!(measured_step(still), None | Some((0.0, 0.0))),
779                "factor {f}"
780            );
781        }
782    }
783}