use crate::FrameRate;
use crate::media::MediaScan;
use crate::pattern::Blend;
use crate::report::median;
const MIN_MIXED: usize = 5;
const MIN_PHASE_SPREAD: f64 = 0.1;
const JUDDER_FRAMES: f64 = 0.3;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DisplayTiming {
pub offset: f64,
pub uncertainty: f64,
pub method: TimingMethod,
pub exposure: Option<f64>,
pub max_exposure: Option<f64>,
pub frame_offsets: Vec<(usize, f64)>,
pub judder: usize,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TimingMethod {
Blend,
#[default]
Bounds,
}
impl TimingMethod {
pub fn name(self) -> &'static str {
match self {
TimingMethod::Blend => "blend",
TimingMethod::Bounds => "bounds",
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct Observation {
pub index: usize,
pub time: f64,
pub frame: u32,
pub qr: bool,
pub blend: Option<Blend>,
pub code_rows: Option<(f64, f64)>,
pub corners: Option<[(f32, f32); 4]>,
}
impl Observation {
pub fn from_scan(scan: &MediaScan) -> Vec<Observation> {
scan.video
.iter()
.enumerate()
.filter_map(|(index, v)| {
let s = v.stamp?;
let code_rows = s.corners.map(|c| {
let ys = c.map(|p| f64::from(p.1));
let top = ys.iter().copied().fold(f64::INFINITY, f64::min);
let bottom = ys.iter().copied().fold(f64::NEG_INFINITY, f64::max);
((top + bottom) / 2.0, (bottom - top).max(1.0))
});
Some(Observation {
index,
time: v.time,
frame: s.frame_id,
qr: s.source.is_qr(),
blend: s.blend,
code_rows,
corners: s.corners,
})
})
.collect()
}
}
pub(crate) fn display_timing(
observations: &[Observation],
fps: FrameRate,
camera_interval: Option<f64>,
) -> Option<DisplayTiming> {
blend_timing(observations, fps, camera_interval).or_else(|| {
let exposure = typical_exposure(observations, fps, camera_interval);
bound_timing(observations, fps, exposure)
})
}
fn typical_exposure(
observations: &[Observation],
fps: FrameRate,
camera_interval: Option<f64>,
) -> f64 {
let checked: Vec<&Blend> = observations
.iter()
.filter_map(|o| o.blend.as_ref())
.collect();
if checked.is_empty() {
return 0.0;
}
let mixed = checked.iter().filter(|b| b.is_mixed()).count() as f64;
let e = mixed / checked.len() as f64 * fps.frame_duration();
e.min(camera_interval.unwrap_or(f64::INFINITY))
}
fn blend_timing(
observations: &[Observation],
fps: FrameRate,
camera_interval: Option<f64>,
) -> Option<DisplayTiming> {
let d = fps.frame_duration();
let mixed: Vec<(&Observation, Blend)> = observations
.iter()
.filter_map(|o| o.blend.filter(Blend::is_mixed).map(|b| (o, b)))
.collect();
if mixed.len() < MIN_MIXED || mixed.len() * 10 < observations.len() {
return None;
}
let slope = median(mixed.iter().map(|(_, b)| f64::from(b.slope)).collect());
let points: Vec<(usize, f64, f64)> = mixed
.iter()
.map(|(o, b)| {
let top_weight = match o.code_rows {
Some((centre, height)) => f64::from(b.weight_next) - slope * centre / height,
None => f64::from(b.weight_next),
};
let r = o.time - (f64::from(b.first) + 1.0) * d;
(o.index, r, 1.0 - top_weight)
})
.collect();
let u: Vec<f64> = points.iter().map(|p| p.2).collect();
let spread = quantile(&u, 0.9) - quantile(&u, 0.1);
let max_exposure = camera_interval.unwrap_or(d);
let fitted = (spread >= MIN_PHASE_SPREAD)
.then(|| theil_sen(&points.iter().map(|p| (p.2, p.1)).collect::<Vec<_>>()))
.flatten()
.map(|(_, slope)| -slope)
.filter(|&e| e > 0.0 && e <= 1.5 * max_exposure);
let exposure = fitted.unwrap_or(max_exposure / 2.0);
let offsets: Vec<(usize, f64)> = points
.iter()
.map(|&(i, r, u)| (i, r + exposure * u))
.collect();
let values: Vec<f64> = offsets.iter().map(|p| p.1).collect();
let offset = median(values.clone());
let mad = median(values.iter().map(|v| (v - offset).abs()).collect());
let sigma = 1.4826 * mad;
let n = values.len() as f64;
let statistical = 2.0 * 1.25 * sigma / n.sqrt();
let typical_u = median(u.clone());
let uncertainty = match fitted {
Some(_) => statistical + typical_u * exposure * 0.1,
None => statistical + typical_u * max_exposure / 2.0,
};
let judder = match fitted {
Some(_) => count_judder(&values, d),
None => count_judder(
&points
.iter()
.map(|&(_, r, u)| r + max_exposure * u)
.collect::<Vec<_>>(),
d,
),
};
Some(DisplayTiming {
offset,
uncertainty,
method: TimingMethod::Blend,
exposure: fitted,
max_exposure: fitted.is_none().then_some(max_exposure),
frame_offsets: offsets,
judder,
})
}
fn count_judder(offsets: &[f64], d: f64) -> usize {
(0..offsets.len())
.filter(|&i| {
let lo = i.saturating_sub(15);
let hi = (i + 16).min(offsets.len());
let local = median(offsets[lo..hi].to_vec());
(offsets[i] - local).abs() > JUDDER_FRAMES * d
})
.count()
}
fn bound_timing(
observations: &[Observation],
fps: FrameRate,
exposure: f64,
) -> Option<DisplayTiming> {
if observations.is_empty() {
return None;
}
let d = fps.frame_duration();
let mid = exposure / 2.0;
let mut lower: Vec<f64> = observations
.iter()
.map(|o| o.time + mid - (f64::from(o.frame) + 1.0) * d)
.collect();
let mut upper: Vec<f64> = observations
.iter()
.map(|o| o.time + mid - f64::from(o.frame) * d)
.collect();
lower.sort_by(f64::total_cmp);
upper.sort_by(f64::total_cmp);
let trim = observations.len() / 50;
let (lo, hi) = (lower[lower.len() - 1 - trim], upper[trim]);
let slack = mid / 2.0;
if lo <= hi {
return Some(DisplayTiming {
offset: (lo + hi) / 2.0,
uncertainty: (hi - lo) / 2.0 + slack,
..Default::default()
});
}
let mids: Vec<f64> = observations
.iter()
.map(|o| o.time + mid - (f64::from(o.frame) + 0.5) * d)
.collect();
Some(DisplayTiming {
offset: median(mids),
uncertainty: d / 2.0 + slack,
..Default::default()
})
}
pub(crate) fn phase_consistency(observations: &[Observation], fps: FrameRate) -> f64 {
if observations.is_empty() {
return 1.0;
}
let d = fps.frame_duration();
let (mut c, mut s) = (0.0, 0.0);
for o in observations {
let phase = (o.time - f64::from(o.frame) * d) / d;
let angle = std::f64::consts::TAU * phase;
c += angle.cos();
s += angle.sin();
}
let n = observations.len() as f64;
(c * c + s * s).sqrt() / n
}
fn quantile(v: &[f64], q: f64) -> f64 {
let mut v = v.to_vec();
v.sort_by(f64::total_cmp);
v[((v.len() - 1) as f64 * q).round() as usize]
}
fn theil_sen(points: &[(f64, f64)]) -> Option<(f64, f64)> {
let step = points.len().div_ceil(400).max(1);
let p: Vec<(f64, f64)> = points.iter().step_by(step).copied().collect();
let mut slopes = Vec::with_capacity(p.len() * p.len() / 2);
for i in 0..p.len() {
for j in i + 1..p.len() {
let dx = p[j].0 - p[i].0;
if dx.abs() > 1e-6 {
slopes.push((p[j].1 - p[i].1) / dx);
}
}
}
if slopes.is_empty() {
return None;
}
let slope = median(slopes);
let intercept = median(p.iter().map(|(x, y)| y - slope * x).collect());
Some((intercept, slope))
}
#[cfg(test)]
mod tests {
use super::*;
fn blend(first: u32, w: f64) -> Blend {
Blend {
first,
weight_next: w as f32,
slope: 0.0,
rms: 0.05,
margin: 3.0,
}
}
fn observe(fps: f64, camera_fps: f64, e: f64, start: f64, seconds: f64) -> Vec<Observation> {
(0..(seconds * camera_fps) as usize)
.filter_map(|k| {
let t = 0.0071 + k as f64 / camera_fps;
let at = |t: f64| ((t - start) * fps).floor();
let (a, b) = (at(t), at(t + e));
if a < 0.0 {
return None;
}
let w = if b > a {
let switch = start + (a + 1.0) / fps;
(t + e - switch) / e
} else {
0.0
};
let frame = if w >= 0.5 { a as u32 + 1 } else { a as u32 };
Some(Observation {
index: k,
time: t,
frame,
qr: true,
blend: Some(blend(a as u32, w)),
code_rows: None,
corners: None,
})
})
.collect()
}
#[test]
fn locked_camera_is_timed_by_blends() {
let fps: FrameRate = "30".parse().unwrap();
let start = 0.0071 - 0.9 / 30.0 + 0.2;
let obs = observe(30.0, 30.0, 0.025, start, 6.0);
let t = display_timing(&obs, fps, Some(1.0 / 30.0)).unwrap();
assert_eq!(t.method, TimingMethod::Blend);
assert_eq!(t.exposure, None);
assert!(
(t.offset - start).abs() <= t.uncertainty + 1e-4,
"offset {} ± {}",
t.offset,
t.uncertainty
);
assert!(t.uncertainty < 0.01, "± {}", t.uncertainty);
let bounds = bound_timing(&obs, fps, 0.0).unwrap();
assert!(bounds.uncertainty > 0.012);
}
#[test]
fn drifting_camera_measures_the_exposure() {
let fps: FrameRate = "30".parse().unwrap();
let obs = observe(30.0, 29.5, 0.02, 0.137, 6.0);
let t = display_timing(&obs, fps, Some(1.0 / 29.5)).unwrap();
assert_eq!(t.method, TimingMethod::Blend);
let e = t.exposure.expect("exposure measured");
assert!((e - 0.02).abs() < 0.001, "exposure {e}");
assert!((t.offset - 0.137).abs() < 0.0005, "offset {}", t.offset);
assert!(t.uncertainty < 0.002, "± {}", t.uncertainty);
assert_eq!(t.judder, 0);
}
#[test]
fn phase_consistency_separates_copies_from_cameras() {
let fps: FrameRate = "30".parse().unwrap();
let copy: Vec<Observation> = (0..60)
.map(|n| Observation {
index: n,
time: 0.5 + n as f64 / 30.0,
frame: n as u32,
qr: false,
blend: None,
code_rows: None,
corners: None,
})
.collect();
assert!(phase_consistency(©, fps) > 0.99);
let camera = observe(30.0, 25.0, 0.01, 0.0, 4.0);
assert!(phase_consistency(&camera, fps) < 0.3);
}
}