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, ¤t, dy), "{dy}");
677 }
678 for dy in [64, 65, 70, 71] {
679 assert!(!measured(&previous, ¤t, 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, ¤t, 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}