Skip to main content

av_drift_detector/
report.rs

1//! Drift reports and their text, JSON and CSV renderings.
2
3use std::fmt::Write as _;
4use std::str::FromStr;
5
6use serde::Serialize;
7
8use crate::pattern::StampSource;
9use crate::{Error, FrameRate};
10
11/// How the capture was made, which decides how video frames are timed.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "lowercase")]
14pub enum CaptureMode {
15    /// Decide from the stamps: camera if most had to be read from QR codes.
16    #[default]
17    Auto,
18    /// A digital copy (re-encode, stream recording, capture card): a frame's
19    /// timestamp is when it starts.
20    File,
21    /// A camera filming a screen: frames sample the display at unknown points
22    /// within each displayed frame.
23    Camera,
24}
25
26impl FromStr for CaptureMode {
27    type Err = Error;
28
29    /// `auto`, `file` or `camera`.
30    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/// What could be measured for one captured video frame.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "snake_case")]
45pub enum FrameStatus {
46    Ok,
47    /// Neither the barcode nor the QR code could be read.
48    NoVideoStamp,
49    /// No decodable audio near this frame.
50    NoAudio,
51}
52
53/// Drift measured at one captured video frame.
54#[derive(Clone, Debug, Serialize)]
55pub struct FrameDrift {
56    /// Presentation time of the captured frame, in seconds.
57    pub time: f64,
58    /// Source frame number read from the picture.
59    pub video_frame: Option<u32>,
60    /// Source video position at `time`, in (fractional) frames. Equal to
61    /// `video_frame` for file captures; estimated from the display timing for camera captures.
62    pub video_position: Option<f64>,
63    pub stamp_source: Option<StampSource>,
64    /// How far the picture had switched to the next frame (0 to 1), where the
65    /// QR code showed a blend of two frames.
66    pub video_blend: Option<f32>,
67    /// Source position of the audio playing at `time`, in (fractional) frames.
68    pub audio_frame: Option<f64>,
69    /// `(video − audio)` in milliseconds: positive means the audio is late.
70    pub drift_ms: Option<f64>,
71    /// Source frames missing between the previous readable frame and this one.
72    pub dropped_before: u32,
73    /// Whether this frame repeats the previous readable frame's number.
74    pub repeated: bool,
75    pub status: FrameStatus,
76}
77
78/// Aggregate statistics over a report's frames.
79#[derive(Clone, Debug, Default, Serialize)]
80pub struct DriftSummary {
81    /// Captured frames in the analysis window.
82    pub frames: usize,
83    /// Frames with a drift measurement.
84    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    /// Standard deviation of the drift (jitter).
90    pub stddev_ms: Option<f64>,
91    /// Slope of a least-squares line through drift against time.
92    pub skew_ms_per_min: Option<f64>,
93    /// The same slope as a clock-rate difference, in parts per million.
94    pub skew_ppm: Option<f64>,
95    pub dropped_frames: u64,
96    pub repeated_frames: u64,
97    /// Places where the frame number went backwards.
98    pub backward_jumps: u64,
99    pub no_video_stamp: usize,
100    pub no_audio: usize,
101    /// Audio frame cells decoded in the window (including the margins).
102    pub audio_cells: usize,
103    /// Decoded audio cells discarded because their timing disagreed with their neighbours.
104    pub audio_outliers: usize,
105    /// Median level of each decoded cell's weakest readable tone, in dBFS.
106    pub audio_level_dbfs: Option<f64>,
107    /// Median of each decoded cell's weakest tone's height above the noise, in dB.
108    pub audio_snr_db: Option<f64>,
109    /// Frames where a QR code was found but couldn't be read.
110    pub qr_located_unread: usize,
111    /// Frames read by fitting the QR code against rendered frames' codes
112    /// (codes blended between two frames, too blurred to decode, or tracked
113    /// from the frame before).
114    pub qr_blend_frames: usize,
115    /// Frames that caught the display switching between two frames.
116    pub mixed_frames: usize,
117    /// The tone curve those frames showed, as a gamma (their median):
118    /// brightness was light to the power `1/tone_gamma`. About 2.2 for a
119    /// phone's camera; 1 where brightness is proportional to light, as in a
120    /// digital copy. The blends were read through it.
121    pub tone_gamma: Option<f64>,
122}
123
124/// What a capture would measure if it were the other kind (see
125/// [`DriftReport::alternative`]).
126#[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/// The result of analysing a capture.
134#[derive(Clone, Debug, Serialize)]
135pub struct DriftReport {
136    pub input: String,
137    pub fps: FrameRate,
138    /// Where the frame rate came from: `barcode`, `qr` or `option`.
139    pub fps_source: &'static str,
140    /// The capture mode used (never `auto`).
141    pub capture: CaptureMode,
142    /// Camera captures: ± half-width of the display timings consistent with the
143    /// frames seen, which bounds the video side's contribution to the error.
144    pub video_timing_uncertainty_ms: Option<f64>,
145    /// Camera captures: how the display's timeline was found: `blend`, from
146    /// frames that caught it mid-switch, or `bounds`, from frame numbers alone.
147    pub video_timing: Option<&'static str>,
148    /// The camera's exposure time, where the camera reported it (the median)
149    /// or the capture let it be measured.
150    pub exposure_ms: Option<f64>,
151    /// Where [`exposure_ms`](Self::exposure_ms) came from: `reported`, by the
152    /// camera with each frame, or `measured`, from the blends.
153    pub exposure_source: Option<&'static str>,
154    /// The longest exposure allowed for, where it couldn't be measured.
155    pub max_exposure_ms: Option<f64>,
156    /// Frames where the display switched noticeably early or late (judder).
157    pub display_judder: usize,
158    /// When the stamps fit both a digital copy and a camera locked to the
159    /// pattern's rate (a camera on a stand, say): the other reading.
160    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; // ms of drift per second of capture
216            s.skew_ms_per_min = Some(slope * 60.0);
217            s.skew_ppm = Some(slope * 1000.0);
218        }
219        s
220    }
221}
222
223/// The median of a non-empty list.
224pub(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    /// A short human-readable summary.
247    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            // Avoid printing "-0.000" for slopes that round to zero.
362            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    /// Why few frames could be measured, when that's so.
408    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    /// A per-frame table.
448    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    /// One CSV row per captured frame.
501    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        // Drift rising 1 ms per second: 60 ms/min, 1000 ppm.
564        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}