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, ¤t, dy), "{dy}");
562 }
563 for dy in [64, 65, 70, 71] {
564 assert!(!measured(&previous, ¤t, 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, ¤t, dy), "repeated rows, {dy}");
578 }
579 }
580}