1use std::fmt::Write as _;
4use std::str::FromStr;
5
6use serde::Serialize;
7
8use crate::pattern::StampSource;
9use crate::{Error, FrameRate};
10
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "lowercase")]
14pub enum CaptureMode {
15 #[default]
17 Auto,
18 File,
21 Camera,
24}
25
26impl FromStr for CaptureMode {
27 type Err = Error;
28
29 fn from_str(s: &str) -> Result<Self, Error> {
31 match s.to_ascii_lowercase().as_str() {
32 "auto" => Ok(CaptureMode::Auto),
33 "file" => Ok(CaptureMode::File),
34 "camera" => Ok(CaptureMode::Camera),
35 _ => Err(Error::InvalidOption(format!(
36 "capture mode must be auto, file or camera, not `{s}`"
37 ))),
38 }
39 }
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum FrameStatus {
46 Ok,
47 NoVideoStamp,
49 NoAudio,
51}
52
53#[derive(Clone, Debug, Serialize)]
55pub struct FrameDrift {
56 pub time: f64,
58 pub video_frame: Option<u32>,
60 pub video_position: Option<f64>,
63 pub stamp_source: Option<StampSource>,
64 pub video_blend: Option<f32>,
67 pub audio_frame: Option<f64>,
69 pub drift_ms: Option<f64>,
71 pub dropped_before: u32,
73 pub repeated: bool,
75 pub status: FrameStatus,
76}
77
78#[derive(Clone, Debug, Default, Serialize)]
80pub struct DriftSummary {
81 pub frames: usize,
83 pub measured: usize,
85 pub mean_ms: Option<f64>,
86 pub median_ms: Option<f64>,
87 pub min_ms: Option<f64>,
88 pub max_ms: Option<f64>,
89 pub stddev_ms: Option<f64>,
91 pub skew_ms_per_min: Option<f64>,
93 pub skew_ppm: Option<f64>,
95 pub dropped_frames: u64,
96 pub repeated_frames: u64,
97 pub backward_jumps: u64,
99 pub no_video_stamp: usize,
100 pub no_audio: usize,
101 pub audio_cells: usize,
103 pub audio_outliers: usize,
105 pub audio_level_dbfs: Option<f64>,
107 pub audio_snr_db: Option<f64>,
109 pub qr_located_unread: usize,
111 pub qr_blend_frames: usize,
115 pub mixed_frames: usize,
117 pub tone_gamma: Option<f64>,
122}
123
124#[derive(Clone, Debug, Serialize)]
127pub struct AlternativeReading {
128 pub capture: CaptureMode,
129 pub mean_ms: f64,
130 pub video_timing_uncertainty_ms: Option<f64>,
131}
132
133#[derive(Clone, Debug, Serialize)]
135pub struct DriftReport {
136 pub input: String,
137 pub fps: FrameRate,
138 pub fps_source: &'static str,
140 pub capture: CaptureMode,
142 pub video_timing_uncertainty_ms: Option<f64>,
145 pub video_timing: Option<&'static str>,
148 pub exposure_ms: Option<f64>,
151 pub exposure_source: Option<&'static str>,
154 pub max_exposure_ms: Option<f64>,
156 pub display_judder: usize,
158 pub alternative: Option<AlternativeReading>,
161 pub width: u32,
162 pub height: u32,
163 pub offset: f64,
164 pub duration: Option<f64>,
165 pub summary: DriftSummary,
166 pub frames: Vec<FrameDrift>,
167}
168
169impl DriftSummary {
170 pub fn from_frames(frames: &[FrameDrift], audio_cells: usize) -> Self {
171 let mut s = DriftSummary {
172 frames: frames.len(),
173 audio_cells,
174 ..Default::default()
175 };
176 let mut points: Vec<(f64, f64)> = Vec::new();
177 for f in frames {
178 match f.status {
179 FrameStatus::Ok => {}
180 FrameStatus::NoVideoStamp => s.no_video_stamp += 1,
181 FrameStatus::NoAudio => s.no_audio += 1,
182 }
183 s.dropped_frames += u64::from(f.dropped_before);
184 s.repeated_frames += u64::from(f.repeated);
185 if let Some(d) = f.drift_ms {
186 points.push((f.time, d));
187 }
188 }
189 s.backward_jumps = frames
190 .iter()
191 .filter_map(|f| f.video_frame)
192 .collect::<Vec<_>>()
193 .windows(2)
194 .filter(|w| w[1] < w[0])
195 .count() as u64;
196 s.measured = points.len();
197 if points.is_empty() {
198 return s;
199 }
200
201 let n = points.len() as f64;
202 let mean = points.iter().map(|p| p.1).sum::<f64>() / n;
203 let var = points.iter().map(|p| (p.1 - mean).powi(2)).sum::<f64>() / n;
204 let drifts: Vec<f64> = points.iter().map(|p| p.1).collect();
205 s.mean_ms = Some(mean);
206 s.median_ms = Some(median(drifts.clone()));
207 s.min_ms = drifts.iter().copied().reduce(f64::min);
208 s.max_ms = drifts.iter().copied().reduce(f64::max);
209 s.stddev_ms = Some(var.sqrt());
210
211 let mean_t = points.iter().map(|p| p.0).sum::<f64>() / n;
212 let sxx: f64 = points.iter().map(|p| (p.0 - mean_t).powi(2)).sum();
213 if points.len() >= 2 && sxx > 1e-9 {
214 let sxy: f64 = points.iter().map(|p| (p.0 - mean_t) * (p.1 - mean)).sum();
215 let slope = sxy / sxx; s.skew_ms_per_min = Some(slope * 60.0);
217 s.skew_ppm = Some(slope * 1000.0);
218 }
219 s
220 }
221}
222
223pub(crate) fn median(mut v: Vec<f64>) -> f64 {
225 v.sort_by(f64::total_cmp);
226 let mid = v.len() / 2;
227 if v.len().is_multiple_of(2) {
228 (v[mid - 1] + v[mid]) / 2.0
229 } else {
230 v[mid]
231 }
232}
233
234fn zeroed(v: f64, below: f64) -> f64 {
235 if v.abs() < below { 0.0 } else { v }
236}
237
238fn ms(v: Option<f64>) -> String {
239 v.map_or_else(
240 || "-".to_string(),
241 |v| format!("{:+.2} ms", zeroed(v, 0.005)),
242 )
243}
244
245impl DriftReport {
246 pub fn summary_text(&self) -> String {
248 let s = &self.summary;
249 let mut out = String::new();
250 let end = self.frames.last().map_or(self.offset, |f| f.time);
251 let _ = writeln!(out, "Input {}", self.input);
252 let _ = writeln!(
253 out,
254 "Pattern {} fps (from {}), {}x{}",
255 self.fps.label(),
256 self.fps_source,
257 self.width,
258 self.height
259 );
260 let _ = writeln!(
261 out,
262 "Window {:.3}s - {:.3}s, {} frames",
263 self.offset, end, s.frames
264 );
265 match (self.capture, self.video_timing_uncertainty_ms) {
266 (CaptureMode::Camera, Some(u)) if self.video_timing == Some("blend") => {
267 let _ = writeln!(
268 out,
269 "Capture camera; display timing from {} frames caught mid-switch, known to ±{u:.2} ms",
270 s.mixed_frames
271 );
272 match (self.exposure_ms, self.max_exposure_ms) {
273 (Some(e), _) if self.exposure_source == Some("reported") => {
274 let _ =
275 writeln!(out, " (exposure {e:.1} ms, as the camera reported)");
276 }
277 (Some(e), _) => {
278 let _ = writeln!(out, " (exposure measured at {e:.1} ms)");
279 }
280 (None, Some(max)) => {
281 let _ = writeln!(
282 out,
283 " (the camera's exposure couldn't be measured, so up to {max:.1} ms is allowed \
284 for. A longer recording, or a camera rate that isn't a multiple of {} fps, narrows this.)",
285 self.fps.label()
286 );
287 }
288 _ => {}
289 }
290 if let Some(g) = s.tone_gamma {
291 let _ = writeln!(out, " (tone curve measured at gamma {g:.1})");
292 }
293 }
294 (CaptureMode::Camera, Some(u)) => {
295 let _ = writeln!(out, "Capture camera; display timing known to ±{u:.2} ms");
296 if let (Some(e), Some("reported")) = (self.exposure_ms, self.exposure_source) {
297 let _ = writeln!(out, " (exposure {e:.1} ms, as the camera reported)");
298 }
299 if u > 0.25 * 1000.0 / self.fps.as_f64() {
300 let _ = writeln!(
301 out,
302 " (frames look locked to the pattern's rate. For sub-frame video timing, film at a \
303 rate that isn't a multiple of {} fps, e.g. 25 or 24 fps, or in slow motion. If this is \
304 a cropped or letterboxed digital capture rather than a camera, use --capture file.)",
305 self.fps.label()
306 );
307 }
308 }
309 (CaptureMode::Camera, None) => {
310 let _ = writeln!(out, "Capture camera");
311 }
312 _ => {}
313 }
314 if self.display_judder > 0 {
315 let _ = writeln!(
316 out,
317 " display judder at {} frames (shown noticeably early or late)",
318 self.display_judder
319 );
320 }
321 if let Some(alt) = &self.alternative {
322 let other = match alt.capture {
323 CaptureMode::Camera => "a camera locked to the pattern's rate",
324 _ => "a digital copy",
325 };
326 let flag = match alt.capture {
327 CaptureMode::Camera => "camera",
328 _ => "file",
329 };
330 let _ = writeln!(
331 out,
332 "Note this could also be {other}: then the drift is {}{} (--capture {flag})",
333 ms(Some(alt.mean_ms)),
334 alt.video_timing_uncertainty_ms
335 .map_or(String::new(), |u| format!(" ± {u:.2} ms"))
336 );
337 }
338 match s.mean_ms {
339 Some(mean) => {
340 let _ = writeln!(
341 out,
342 "Drift mean {} median {} min {} max {} stddev {:.2} ms",
343 ms(Some(mean)),
344 ms(s.median_ms),
345 ms(s.min_ms),
346 ms(s.max_ms),
347 s.stddev_ms.unwrap_or(0.0)
348 );
349 let _ = writeln!(
350 out,
351 " = {:+.2} frames at {} fps (positive = audio late)",
352 zeroed(mean / 1000.0 * self.fps.as_f64(), 0.005),
353 self.fps.label()
354 );
355 }
356 None => {
357 let _ = writeln!(out, "Drift no measurements");
358 }
359 }
360 if let (Some(per_min), Some(ppm)) = (s.skew_ms_per_min, s.skew_ppm) {
361 let (per_min, ppm) = (zeroed(per_min, 0.0005), zeroed(ppm, 0.05));
363 let _ = writeln!(out, "Skew {per_min:+.3} ms/min ({ppm:+.1} ppm)");
364 }
365 let _ = writeln!(
366 out,
367 "Frames {} measured, {} unreadable, {} without audio, {} dropped, {} repeated{}",
368 s.measured,
369 s.no_video_stamp,
370 s.no_audio,
371 s.dropped_frames,
372 s.repeated_frames,
373 if s.backward_jumps > 0 {
374 format!(", {} backward jumps", s.backward_jumps)
375 } else {
376 String::new()
377 }
378 );
379 let qr_read = self
380 .frames
381 .iter()
382 .filter(|f| f.stamp_source == Some(StampSource::Qr))
383 .count();
384 if qr_read + s.qr_blend_frames + s.qr_located_unread > 0 {
385 let _ = writeln!(
386 out,
387 "QR {qr_read} decoded, {} read by fitting, {} found but unreadable",
388 s.qr_blend_frames, s.qr_located_unread
389 );
390 }
391 let _ = write!(out, "Audio {} frame cells decoded", s.audio_cells);
392 if s.audio_outliers > 0 {
393 let _ = write!(out, ", {} outliers discarded", s.audio_outliers);
394 }
395 if let (Some(level), Some(snr)) = (s.audio_level_dbfs, s.audio_snr_db) {
396 let _ = write!(
397 out,
398 "; tones at {level:.0} dBFS, {snr:.0} dB above the noise"
399 );
400 }
401 if let Some(warning) = self.coverage_warning() {
402 let _ = write!(out, "\nWarning {warning}");
403 }
404 out
405 }
406
407 fn coverage_warning(&self) -> Option<String> {
409 let s = &self.summary;
410 if s.frames < 10 || s.measured * 5 >= s.frames {
411 return None;
412 }
413 let mut reasons = Vec::new();
414 if s.audio_cells == 0 {
415 reasons.push("no test tones were found in the audio".to_string());
416 } else if s.no_audio * 2 > s.frames {
417 reasons.push(format!(
418 "{} frames had no decodable audio near them",
419 s.no_audio
420 ));
421 }
422 if s.no_video_stamp * 2 > s.frames {
423 reasons.push(if s.qr_located_unread * 2 > s.no_video_stamp {
424 format!(
425 "{} frames had a QR code that couldn't be read (blur, glare, or too small)",
426 s.qr_located_unread
427 )
428 } else {
429 format!(
430 "{} frames had no readable stamp (is the whole QR code in shot?)",
431 s.no_video_stamp
432 )
433 });
434 }
435 Some(format!(
436 "only {} of {} frames could be measured{}",
437 s.measured,
438 s.frames,
439 if reasons.is_empty() {
440 String::new()
441 } else {
442 format!(": {}", reasons.join("; "))
443 }
444 ))
445 }
446
447 pub fn frames_table(&self) -> String {
449 let camera = self.capture == CaptureMode::Camera;
450 let mut out = String::from(if camera {
451 " time video video_pos audio drift_ms note\n"
452 } else {
453 " time video audio drift_ms note\n"
454 });
455 for f in &self.frames {
456 let video = f.video_frame.map_or_else(|| "-".into(), |v| v.to_string());
457 let position = f
458 .video_position
459 .map_or_else(|| "-".into(), |p| format!("{p:.3}"));
460 let audio = f
461 .audio_frame
462 .map_or_else(|| "-".into(), |a| format!("{a:.3}"));
463 let drift = f
464 .drift_ms
465 .map_or_else(|| "-".into(), |d| format!("{d:+.3}"));
466 let mut note = Vec::new();
467 match f.status {
468 FrameStatus::Ok => {}
469 FrameStatus::NoVideoStamp => note.push("unreadable".to_string()),
470 FrameStatus::NoAudio => note.push("no audio".to_string()),
471 }
472 if f.dropped_before > 0 {
473 note.push(format!("{} dropped", f.dropped_before));
474 }
475 if f.repeated {
476 note.push("repeat".into());
477 }
478 if let Some(source) = f.stamp_source.filter(|s| s.is_qr()) {
479 note.push(source.name().into());
480 }
481 if camera {
482 let _ = writeln!(
483 out,
484 "{:>10.4} {video:>9} {position:>12} {audio:>12} {drift:>11} {}",
485 f.time,
486 note.join(", ")
487 );
488 } else {
489 let _ = writeln!(
490 out,
491 "{:>10.4} {video:>9} {audio:>12} {drift:>11} {}",
492 f.time,
493 note.join(", ")
494 );
495 }
496 }
497 out
498 }
499
500 pub fn to_csv(&self) -> String {
502 let mut out = String::from(
503 "time,video_frame,video_position,stamp_source,video_blend,audio_frame,drift_ms,dropped_before,repeated,status\n",
504 );
505 for f in &self.frames {
506 let opt = |v: Option<String>| v.unwrap_or_default();
507 let source = f.stamp_source.map(|s| s.name().to_string());
508 let status = match f.status {
509 FrameStatus::Ok => "ok",
510 FrameStatus::NoVideoStamp => "no_video_stamp",
511 FrameStatus::NoAudio => "no_audio",
512 };
513 let _ = writeln!(
514 out,
515 "{:.6},{},{},{},{},{},{},{},{},{}",
516 f.time,
517 opt(f.video_frame.map(|v| v.to_string())),
518 opt(f.video_position.map(|p| format!("{p:.6}"))),
519 opt(source),
520 opt(f.video_blend.map(|b| format!("{b:.4}"))),
521 opt(f.audio_frame.map(|a| format!("{a:.6}"))),
522 opt(f.drift_ms.map(|d| format!("{d:.6}"))),
523 f.dropped_before,
524 f.repeated,
525 status
526 );
527 }
528 out
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn parses_capture_modes() {
538 assert_eq!(
539 "Camera".parse::<CaptureMode>().unwrap(),
540 CaptureMode::Camera
541 );
542 assert_eq!("file".parse::<CaptureMode>().unwrap(), CaptureMode::File);
543 assert!("phone".parse::<CaptureMode>().is_err());
544 }
545
546 fn frame(time: f64, drift: f64) -> FrameDrift {
547 FrameDrift {
548 time,
549 video_frame: Some((time * 30.0).round() as u32),
550 video_position: Some((time * 30.0).round()),
551 stamp_source: Some(StampSource::Barcode),
552 video_blend: None,
553 audio_frame: Some(0.0),
554 drift_ms: Some(drift),
555 dropped_before: 0,
556 repeated: false,
557 status: FrameStatus::Ok,
558 }
559 }
560
561 #[test]
562 fn summary_statistics() {
563 let frames: Vec<FrameDrift> = (0..31)
565 .map(|i| frame(i as f64 / 10.0, 10.0 + i as f64 / 10.0))
566 .collect();
567 let s = DriftSummary::from_frames(&frames, 5);
568 assert_eq!((s.frames, s.measured, s.audio_cells), (31, 31, 5));
569 assert!((s.mean_ms.unwrap() - 11.5).abs() < 1e-9);
570 assert!((s.median_ms.unwrap() - 11.5).abs() < 1e-9);
571 assert_eq!((s.min_ms, s.max_ms), (Some(10.0), Some(13.0)));
572 assert!((s.skew_ms_per_min.unwrap() - 60.0).abs() < 1e-6);
573 assert!((s.skew_ppm.unwrap() - 1000.0).abs() < 1e-6);
574 }
575
576 #[test]
577 fn empty_summary() {
578 let s = DriftSummary::from_frames(&[], 0);
579 assert_eq!(s.measured, 0);
580 assert!(s.mean_ms.is_none() && s.skew_ppm.is_none());
581 }
582}