use crate::kernels::features::{match_features, Feature};
use crate::kernels::geometry::{js_ceil, js_hypot, Rect};
use crate::kernels::motion::{
audit_translation, refine_native_at, sampling_insensitive_error, select_native_points,
translation_hypotheses, Gray, MatchPoints, NativeRefinement, Point,
};
use crate::kernels::region::Region;
#[derive(PartialEq, Eq, Debug)]
pub enum OdometryDecision {
Tracked,
Static,
Lost,
}
pub struct ContentChange {
pub agreement: f64,
pub blocks: u32,
}
pub struct Rescue {
pub delta: (f64, f64),
pub confidence: f64,
pub ambiguous: bool,
pub weak_step: bool,
pub step_error: f64,
}
pub struct OdometryEstimate {
pub decision: OdometryDecision,
pub delta: (f64, f64),
pub confidence: f64,
pub ambiguous: bool,
pub weak_step: bool,
pub step_error: f64,
pub content_change: Option<ContentChange>,
pub rescue: Option<Rescue>,
}
pub struct OdometryInputs<'a> {
pub f: f64,
pub radius: i32,
pub mask: Option<(&'a [u8], u8)>,
pub roi: Rect,
pub rect: Rect,
pub region: Option<&'a Region>,
pub image_width: f64,
pub image_height: f64,
pub previous: &'a [u8],
pub current: &'a [u8],
pub previous_gray: Gray<'a>,
pub g: Gray<'a>,
pub velocity: (f64, f64),
pub previous_features: &'a [Feature],
pub own_features: &'a [Feature],
pub confidence: f64,
}
const TRACKED_ERROR: f64 = 14.0;
const RESCUE_SEPARATION: f64 = 1.5;
const RESCUE_SHARE: f64 = 0.35;
fn rescuable(n: &NativeRefinement) -> bool {
n.error >= TRACKED_ERROR
&& n.error.is_finite()
&& n.runner_up > RESCUE_SEPARATION * n.error
&& n.trimmed < RESCUE_SHARE * n.runner_up
}
fn measured_between_pixels(error: f64, runner_up: f64) -> bool {
error < TRACKED_ERROR && runner_up >= TRACKED_ERROR && runner_up > RESCUE_SEPARATION * error
}
pub fn odometry(inputs: OdometryInputs) -> OdometryEstimate {
let OdometryInputs {
f,
radius,
mask,
roi,
rect,
region,
image_width,
image_height,
previous,
current,
previous_gray,
g,
velocity,
previous_features,
own_features,
confidence,
} = inputs;
let raw_matches = match_features(previous_features, own_features, true);
let match_points: Vec<MatchPoints> = raw_matches
.iter()
.map(|m| {
let a = &previous_features[m.a as usize];
let b = &own_features[m.b as usize];
MatchPoints {
ax: a.x as f64,
ay: a.y as f64,
bx: b.x as f64,
by: b.y as f64,
unique: m.unique,
}
})
.collect();
let models: Vec<_> = translation_hypotheses(&match_points, 16)
.into_iter()
.filter(|m| m.support >= 4)
.collect();
let prior = |mx: f64, my: f64| 0.02 * js_hypot(mx * f - velocity.0, my * f - velocity.1);
let mut scored: Vec<_> = models
.into_iter()
.map(|m| {
let audit = audit_translation(previous_gray, g, m.x, m.y, Some(roi), f > 1.0);
(m, audit)
})
.filter(|(_, audit)| {
audit.overlap > 0.10
&& audit.error.is_finite()
&& ((audit.error < 14.0 && audit.mismatch < 0.2)
|| (audit.agreement >= 0.4
&& audit.agreeing >= 3
&& audit.agreeing_error < 8.0))
})
.collect();
scored.sort_by(|(am, aa), (bm, ba)| {
let d = aa.error.min(aa.agreeing_error) + prior(am.x, am.y)
- ba.error.min(ba.agreeing_error)
- prior(bm.x, bm.y);
d.partial_cmp(&0.0).unwrap_or(std::cmp::Ordering::Equal)
});
let points = if scored.is_empty() {
Vec::new()
} else {
select_native_points(
current,
image_width as usize,
image_height as usize,
rect,
mask,
Some((g, (f as usize).max(1))),
)
};
let mut refined: Vec<_> = scored
.into_iter()
.take(6)
.map(|(m, audit)| {
let n = refine_native_at(
previous,
current,
image_width as usize,
image_height as usize,
Point {
x: m.x * f,
y: m.y * f,
},
mask,
radius,
&points,
);
let key = n.error + 0.02 * js_hypot(n.x as f64 - velocity.0, n.y as f64 - velocity.1);
(m, audit, n, key)
})
.filter(|(_, _, n, _)| n.error.is_finite())
.collect();
refined.sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal));
let mut rescue = None;
if let Some(best_index) = (!refined.is_empty()).then_some(0usize) {
let (best_m, best_audit, best_n, _) = &refined[best_index];
let measured = best_n.error < TRACKED_ERROR
|| rescuable(best_n)
|| best_n.error.is_finite() && {
let (error, runner_up) = sampling_insensitive_error(
previous,
current,
image_width as usize,
image_height as usize,
(best_n.x, best_n.y),
mask,
radius,
&points,
);
measured_between_pixels(error, runner_up)
};
if measured {
let delta = (best_n.x as f64, best_n.y as f64);
let rival = refined.iter().enumerate().find(|(i, (_, _, n, _))| {
*i != best_index
&& js_hypot(n.x as f64 - best_n.x as f64, n.y as f64 - best_n.y as f64) > 2.0
&& n.error < best_n.error + 2.0
});
let ambiguous = rival.is_some() || (best_m.ambiguous && refined.len() > 1);
let weak_step = best_audit.overlap < 0.25;
let step_error = best_n.error;
let content_change = if best_audit.agreement < 0.85 && best_audit.blocks >= 4 {
Some(ContentChange {
agreement: best_audit.agreement,
blocks: best_audit.blocks,
})
} else {
None
};
let confidence = best_m.confidence.max(0.05)
* libm::exp(-step_error / 20.0)
* if ambiguous { 0.6 } else { 1.0 }
* if weak_step { 0.5 } else { 1.0 };
if best_n.error < TRACKED_ERROR {
return OdometryEstimate {
decision: OdometryDecision::Tracked,
delta,
confidence,
ambiguous,
weak_step,
step_error,
content_change,
rescue: None,
};
}
rescue = Some(Rescue {
delta,
confidence,
ambiguous,
weak_step,
step_error,
});
}
}
let mut difference = 0.0;
let mut samples = 0u32;
let mut y = js_ceil(roi.y) as f64;
while y < roi.y + roi.height {
let mut x = js_ceil(roi.x) as f64;
while x < roi.x + roi.width {
let contained =
region.is_none_or(|r| r.contains(x * f, y * f, image_width, image_height));
if contained {
let (yi, xi) = (y as usize, x as usize);
difference += (previous_gray.data[yi * g.width + xi] as f64
- g.data[yi * g.width + xi] as f64)
.abs();
samples += 1;
}
x += 7.0;
}
y += 7.0;
}
let moved = rescue.as_ref().is_some_and(|r| r.delta != (0.0, 0.0));
let decision = if moved || difference / (samples.max(1) as f64) > 5.0 {
OdometryDecision::Lost
} else {
OdometryDecision::Static
};
OdometryEstimate {
decision,
delta: (0.0, 0.0),
confidence,
ambiguous: false,
weak_step: false,
step_error: f64::INFINITY,
content_change: None,
rescue,
}
}
#[cfg(test)]
mod tests {
use super::{measured_between_pixels, rescuable};
use crate::kernels::geometry::Rect;
use crate::kernels::motion::{
sampling_insensitive_error, select_native_points, NativeRefinement,
};
fn n(error: f64, trimmed: f64, runner_up: f64) -> NativeRefinement {
NativeRefinement {
x: 0,
y: 0,
error,
samples: 600,
runner_up,
trimmed,
}
}
#[test]
fn only_an_unambiguous_step_the_plain_mean_rejected_is_rescuable() {
assert!(!rescuable(&n(13.9, 2.0, 50.0)));
for (error, trimmed, runner_up) in [
(14.33, 2.8, 50.48),
(14.90, 5.0, 55.71),
(20.2, 8.7, 52.0),
(25.0, 12.7, 51.8),
] {
assert!(
rescuable(&n(error, trimmed, runner_up)),
"{error} {trimmed} {runner_up}"
);
}
assert!(!rescuable(&n(14.33, 20.0, 50.48)));
assert!(!rescuable(&n(14.33, 2.8, 20.0)));
assert!(!rescuable(&n(f64::INFINITY, 2.8, f64::INFINITY)));
assert!(!rescuable(&n(51.8, 39.1, 54.0)));
}
const PAGE: (usize, usize) = (400, 900);
fn page(offset: f64, toolbar: usize) -> Vec<u8> {
render(offset, |x, y, offset| {
let world_y = y + offset;
let (line, yy, word, xx) = (world_y / 140, world_y % 140, x / 60, x % 60);
if y < toolbar && x > 40 && x < 360 {
return if (x * 13 + y * 73 + x * y % 79) % 47 < 23 {
20
} else {
240
};
}
if (40..58).contains(&yy) && xx > 8 && xx < 38 {
let ink = ((line * 193 + word * 73 + xx / 3 * 19) >> ((yy - 40) / 3)) & 1;
if ink != 0 {
return 45;
}
}
248
})
}
fn render(offset: f64, value: impl Fn(usize, usize, usize) -> u8) -> Vec<u8> {
let (width, height) = PAGE;
let (low, high) = (offset.floor() as usize, offset.ceil() as usize);
let mut data = vec![0; width * height * 4];
for y in 0..height {
for x in 0..width {
let v = (value(x, y, low) as u32 + value(x, y, high) as u32).div_ceil(2) as u8;
data[(y * width + x) * 4..(y * width + x) * 4 + 4].copy_from_slice(&[v, v, v, 255]);
}
}
data
}
fn measure(previous: &[u8], current: &[u8], f: usize) -> super::OdometryEstimate {
use super::{odometry, OdometryInputs};
use crate::kernels::features::extract_features;
use crate::kernels::motion::Gray;
use crate::kernels::raster::{downscale_gray, downscaled_size};
let (width, height) = PAGE;
let (w, h) = downscaled_size(width, height, f);
let mut a = vec![0; w * h];
let mut b = vec![0; w * h];
downscale_gray(previous, width, height, f, &mut a);
downscale_gray(current, width, height, f, &mut b);
let previous_features = extract_features(&a, w, h, 480, None);
let own_features = extract_features(&b, w, h, 480, None);
odometry(OdometryInputs {
f: f as f64,
radius: 3.max(f.div_ceil(2) as i32 + 1),
mask: None,
roi: Rect {
x: 0.0,
y: 0.0,
width: w as f64,
height: h as f64,
},
rect: Rect {
x: 0.0,
y: 0.0,
width: width as f64,
height: height as f64,
},
region: None,
image_width: width as f64,
image_height: height as f64,
previous,
current,
previous_gray: Gray {
width: w,
height: h,
data: &a,
},
g: Gray {
width: w,
height: h,
data: &b,
},
velocity: (0.0, 0.0),
previous_features: &previous_features,
own_features: &own_features,
confidence: 1.0,
})
}
fn measured_step(result: super::OdometryEstimate) -> Option<(f64, f64)> {
match result.decision {
super::OdometryDecision::Tracked => Some(result.delta),
_ => result.rescue.map(|r| r.delta),
}
}
#[test]
fn sparse_text_motion_survives_analysis_phase_and_fixed_chrome() {
for f in [2usize, 3, 4, 5] {
for toolbar in [32, 96] {
let result = measure(&page(0.0, toolbar), &page(67.0, toolbar), f);
assert_ne!(
result.decision,
super::OdometryDecision::Static,
"factor {f}, toolbar {toolbar}"
);
assert_eq!(
measured_step(result),
Some((0.0, 67.0)),
"factor {f}, toolbar {toolbar}"
);
}
}
}
#[test]
fn a_step_between_two_pixels_is_still_measured() {
for f in [2usize, 3, 4, 5] {
for toolbar in [0, 32] {
let result = measure(&page(0.0, toolbar), &page(67.5, toolbar), f);
assert_ne!(
result.decision,
super::OdometryDecision::Static,
"factor {f}, toolbar {toolbar}"
);
let step = measured_step(result);
assert!(
step == Some((0.0, 67.0)) || step == Some((0.0, 68.0)),
"factor {f}, toolbar {toolbar}: {step:?}"
);
}
}
}
#[test]
fn only_a_fraction_of_a_pixel_is_forgiven() {
let (width, height) = PAGE;
let rect = Rect {
x: 0.0,
y: 0.0,
width: width as f64,
height: height as f64,
};
let measured = |previous: &[u8], current: &[u8], dy: i32| {
let points = select_native_points(current, width, height, rect, None, None);
let (error, runner_up) = sampling_insensitive_error(
previous,
current,
width,
height,
(0, dy),
None,
3,
&points,
);
measured_between_pixels(error, runner_up)
};
let (previous, current) = (page(0.0, 0), page(67.5, 0));
for dy in [67, 68] {
assert!(measured(&previous, ¤t, dy), "{dy}");
}
for dy in [64, 65, 70, 71] {
assert!(!measured(&previous, ¤t, dy), "{dy}");
}
let rows = |offset| {
render(offset, |x, y, offset| {
if (x % 7 < 3) != ((y + offset) % 3 == 0) {
45
} else {
248
}
})
};
let (previous, current) = (rows(0.0), rows(67.5));
for dy in [67, 68] {
assert!(!measured(&previous, ¤t, dy), "repeated rows, {dy}");
}
}
}