Skip to main content

oximedia_align/
align_report.rs

1#![allow(dead_code)]
2//! Alignment quality reporting and diagnostics.
3//!
4//! This module generates comprehensive reports about alignment quality,
5//! including per-frame accuracy metrics, drift analysis, and confidence
6//! scoring for multi-camera synchronization workflows.
7
8use std::collections::BTreeMap;
9
10/// Overall quality grade for an alignment result.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum AlignGrade {
13    /// Perfect alignment (sub-sample accuracy)
14    Excellent,
15    /// Good alignment (within 1 frame)
16    Good,
17    /// Acceptable alignment (within 2-3 frames)
18    Acceptable,
19    /// Poor alignment (more than 3 frames off)
20    Poor,
21    /// Alignment failed entirely
22    Failed,
23}
24
25impl AlignGrade {
26    /// Convert a numeric error (in frames) to a quality grade.
27    #[allow(clippy::cast_precision_loss)]
28    #[must_use]
29    pub fn from_frame_error(error_frames: f64) -> Self {
30        if error_frames < 0.5 {
31            Self::Excellent
32        } else if error_frames < 1.5 {
33            Self::Good
34        } else if error_frames < 3.5 {
35            Self::Acceptable
36        } else if error_frames < f64::INFINITY {
37            Self::Poor
38        } else {
39            Self::Failed
40        }
41    }
42
43    /// Return a human-readable label for this grade.
44    #[must_use]
45    pub fn label(&self) -> &'static str {
46        match self {
47            Self::Excellent => "Excellent",
48            Self::Good => "Good",
49            Self::Acceptable => "Acceptable",
50            Self::Poor => "Poor",
51            Self::Failed => "Failed",
52        }
53    }
54
55    /// Return a numeric score from 0.0 (Failed) to 1.0 (Excellent).
56    #[must_use]
57    pub fn score(&self) -> f64 {
58        match self {
59            Self::Excellent => 1.0,
60            Self::Good => 0.8,
61            Self::Acceptable => 0.6,
62            Self::Poor => 0.3,
63            Self::Failed => 0.0,
64        }
65    }
66}
67
68/// A single per-frame alignment measurement.
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub struct FrameMeasurement {
71    /// Frame index in the source timeline.
72    pub frame_index: u64,
73    /// Measured offset in seconds relative to reference.
74    pub offset_secs: f64,
75    /// Confidence of this measurement (0.0..1.0).
76    pub confidence: f64,
77    /// Spatial displacement in pixels (if applicable).
78    pub spatial_error_px: f64,
79}
80
81impl FrameMeasurement {
82    /// Create a new frame measurement.
83    #[must_use]
84    pub fn new(frame_index: u64, offset_secs: f64, confidence: f64, spatial_error_px: f64) -> Self {
85        Self {
86            frame_index,
87            offset_secs,
88            confidence: confidence.clamp(0.0, 1.0),
89            spatial_error_px,
90        }
91    }
92}
93
94/// Drift statistics computed from a series of frame measurements.
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct DriftStats {
97    /// Mean drift in seconds.
98    pub mean_drift: f64,
99    /// Maximum absolute drift in seconds.
100    pub max_drift: f64,
101    /// Standard deviation of drift in seconds.
102    pub std_dev: f64,
103    /// Linear drift rate (seconds per frame).
104    pub drift_rate: f64,
105}
106
107impl DriftStats {
108    /// Compute drift statistics from a slice of offset values.
109    #[allow(clippy::cast_precision_loss)]
110    #[must_use]
111    pub fn compute(offsets: &[f64]) -> Self {
112        if offsets.is_empty() {
113            return Self {
114                mean_drift: 0.0,
115                max_drift: 0.0,
116                std_dev: 0.0,
117                drift_rate: 0.0,
118            };
119        }
120
121        let n = offsets.len() as f64;
122        let mean = offsets.iter().sum::<f64>() / n;
123        let max_abs = offsets.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
124        let variance = offsets.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
125        let std_dev = variance.sqrt();
126
127        // Simple linear regression for drift rate
128        let drift_rate = if offsets.len() >= 2 {
129            let n_minus_1 = (offsets.len() - 1) as f64;
130            let first = offsets[0];
131            let last = offsets[offsets.len() - 1];
132            (last - first) / n_minus_1
133        } else {
134            0.0
135        };
136
137        Self {
138            mean_drift: mean,
139            max_drift: max_abs,
140            std_dev,
141            drift_rate,
142        }
143    }
144
145    /// Check whether drift exceeds a threshold (in seconds).
146    #[must_use]
147    pub fn exceeds_threshold(&self, threshold_secs: f64) -> bool {
148        self.max_drift > threshold_secs
149    }
150}
151
152/// A comprehensive alignment quality report.
153#[derive(Debug, Clone)]
154pub struct AlignReport {
155    /// Human-readable title for this report.
156    pub title: String,
157    /// Per-frame measurements indexed by frame number.
158    pub measurements: BTreeMap<u64, FrameMeasurement>,
159    /// Computed drift statistics.
160    pub drift_stats: Option<DriftStats>,
161    /// Overall quality grade.
162    pub grade: AlignGrade,
163    /// Textual notes and warnings.
164    pub notes: Vec<String>,
165}
166
167impl AlignReport {
168    /// Create a new empty alignment report.
169    #[must_use]
170    pub fn new(title: &str) -> Self {
171        Self {
172            title: title.to_string(),
173            measurements: BTreeMap::new(),
174            drift_stats: None,
175            grade: AlignGrade::Failed,
176            notes: Vec::new(),
177        }
178    }
179
180    /// Add a frame measurement to the report.
181    pub fn add_measurement(&mut self, m: FrameMeasurement) {
182        self.measurements.insert(m.frame_index, m);
183    }
184
185    /// Add a textual note or warning.
186    pub fn add_note(&mut self, note: &str) {
187        self.notes.push(note.to_string());
188    }
189
190    /// Finalize the report: compute drift stats and assign a grade.
191    #[allow(clippy::cast_precision_loss)]
192    pub fn finalize(&mut self) {
193        let offsets: Vec<f64> = self.measurements.values().map(|m| m.offset_secs).collect();
194        let drift = DriftStats::compute(&offsets);
195        self.drift_stats = Some(drift);
196
197        // Grade based on max drift (assuming 30fps -> 1 frame = ~0.0333s)
198        let frame_error = drift.max_drift / 0.0333;
199        self.grade = AlignGrade::from_frame_error(frame_error);
200
201        if drift.drift_rate.abs() > 1e-6 {
202            self.add_note(&format!(
203                "Linear drift detected: {:.6} s/frame",
204                drift.drift_rate
205            ));
206        }
207    }
208
209    /// Return the number of measurements in this report.
210    #[must_use]
211    pub fn measurement_count(&self) -> usize {
212        self.measurements.len()
213    }
214
215    /// Return the average confidence across all measurements.
216    #[allow(clippy::cast_precision_loss)]
217    #[must_use]
218    pub fn average_confidence(&self) -> f64 {
219        if self.measurements.is_empty() {
220            return 0.0;
221        }
222        let total: f64 = self.measurements.values().map(|m| m.confidence).sum();
223        total / self.measurements.len() as f64
224    }
225
226    /// Generate a plain-text summary of this report.
227    #[must_use]
228    pub fn summary_text(&self) -> String {
229        let mut lines = Vec::new();
230        lines.push(format!("=== {} ===", self.title));
231        lines.push(format!("Grade: {}", self.grade.label()));
232        lines.push(format!("Measurements: {}", self.measurement_count()));
233        lines.push(format!("Avg confidence: {:.3}", self.average_confidence()));
234        if let Some(ref drift) = self.drift_stats {
235            lines.push(format!("Mean drift: {:.6} s", drift.mean_drift));
236            lines.push(format!("Max drift:  {:.6} s", drift.max_drift));
237            lines.push(format!("Drift rate: {:.9} s/frame", drift.drift_rate));
238        }
239        for note in &self.notes {
240            lines.push(format!("NOTE: {note}"));
241        }
242        lines.join("\n")
243    }
244}
245
246/// Builder for creating alignment reports incrementally.
247#[derive(Debug)]
248pub struct AlignReportBuilder {
249    /// Title for the report being built.
250    title: String,
251    /// Accumulated measurements.
252    measurements: Vec<FrameMeasurement>,
253    /// Accumulated notes.
254    notes: Vec<String>,
255}
256
257impl AlignReportBuilder {
258    /// Create a new report builder with the given title.
259    #[must_use]
260    pub fn new(title: &str) -> Self {
261        Self {
262            title: title.to_string(),
263            measurements: Vec::new(),
264            notes: Vec::new(),
265        }
266    }
267
268    /// Add a measurement.
269    #[must_use]
270    pub fn measurement(mut self, m: FrameMeasurement) -> Self {
271        self.measurements.push(m);
272        self
273    }
274
275    /// Add a note.
276    #[must_use]
277    pub fn note(mut self, note: &str) -> Self {
278        self.notes.push(note.to_string());
279        self
280    }
281
282    /// Build and finalize the report.
283    #[must_use]
284    pub fn build(self) -> AlignReport {
285        let mut report = AlignReport::new(&self.title);
286        for m in self.measurements {
287            report.add_measurement(m);
288        }
289        for note in &self.notes {
290            report.add_note(note);
291        }
292        report.finalize();
293        report
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_grade_from_frame_error_excellent() {
303        assert_eq!(AlignGrade::from_frame_error(0.1), AlignGrade::Excellent);
304        assert_eq!(AlignGrade::from_frame_error(0.0), AlignGrade::Excellent);
305    }
306
307    #[test]
308    fn test_grade_from_frame_error_good() {
309        assert_eq!(AlignGrade::from_frame_error(1.0), AlignGrade::Good);
310    }
311
312    #[test]
313    fn test_grade_from_frame_error_acceptable() {
314        assert_eq!(AlignGrade::from_frame_error(2.0), AlignGrade::Acceptable);
315        assert_eq!(AlignGrade::from_frame_error(3.0), AlignGrade::Acceptable);
316    }
317
318    #[test]
319    fn test_grade_from_frame_error_poor() {
320        assert_eq!(AlignGrade::from_frame_error(5.0), AlignGrade::Poor);
321        assert_eq!(AlignGrade::from_frame_error(100.0), AlignGrade::Poor);
322    }
323
324    #[test]
325    fn test_grade_labels() {
326        assert_eq!(AlignGrade::Excellent.label(), "Excellent");
327        assert_eq!(AlignGrade::Failed.label(), "Failed");
328    }
329
330    #[test]
331    fn test_grade_scores() {
332        assert!((AlignGrade::Excellent.score() - 1.0).abs() < f64::EPSILON);
333        assert!((AlignGrade::Failed.score()).abs() < f64::EPSILON);
334    }
335
336    #[test]
337    fn test_frame_measurement_confidence_clamped() {
338        let m = FrameMeasurement::new(0, 0.0, 1.5, 0.0);
339        assert!((m.confidence - 1.0).abs() < f64::EPSILON);
340
341        let m2 = FrameMeasurement::new(0, 0.0, -0.5, 0.0);
342        assert!((m2.confidence).abs() < f64::EPSILON);
343    }
344
345    #[test]
346    fn test_drift_stats_empty() {
347        let stats = DriftStats::compute(&[]);
348        assert!((stats.mean_drift).abs() < f64::EPSILON);
349        assert!((stats.max_drift).abs() < f64::EPSILON);
350    }
351
352    #[test]
353    fn test_drift_stats_constant_offset() {
354        let offsets = vec![0.01, 0.01, 0.01, 0.01];
355        let stats = DriftStats::compute(&offsets);
356        assert!((stats.mean_drift - 0.01).abs() < 1e-10);
357        assert!((stats.max_drift - 0.01).abs() < 1e-10);
358        assert!(stats.std_dev < 1e-10);
359        assert!(stats.drift_rate.abs() < 1e-10);
360    }
361
362    #[test]
363    fn test_drift_stats_linear_drift() {
364        let offsets = vec![0.0, 0.001, 0.002, 0.003];
365        let stats = DriftStats::compute(&offsets);
366        assert!((stats.drift_rate - 0.001).abs() < 1e-10);
367    }
368
369    #[test]
370    fn test_drift_exceeds_threshold() {
371        let stats = DriftStats {
372            mean_drift: 0.05,
373            max_drift: 0.1,
374            std_dev: 0.02,
375            drift_rate: 0.0001,
376        };
377        assert!(stats.exceeds_threshold(0.05));
378        assert!(!stats.exceeds_threshold(0.2));
379    }
380
381    #[test]
382    fn test_report_finalize_and_grade() {
383        let mut report = AlignReport::new("Test Report");
384        for i in 0..10 {
385            report.add_measurement(FrameMeasurement::new(i, 0.001, 0.9, 0.5));
386        }
387        report.finalize();
388        assert_eq!(report.grade, AlignGrade::Excellent);
389        assert!(report.drift_stats.is_some());
390    }
391
392    #[test]
393    fn test_report_average_confidence() {
394        let mut report = AlignReport::new("Conf test");
395        report.add_measurement(FrameMeasurement::new(0, 0.0, 0.8, 0.0));
396        report.add_measurement(FrameMeasurement::new(1, 0.0, 0.6, 0.0));
397        assert!((report.average_confidence() - 0.7).abs() < 1e-10);
398    }
399
400    #[test]
401    fn test_report_empty_confidence() {
402        let report = AlignReport::new("Empty");
403        assert!((report.average_confidence()).abs() < f64::EPSILON);
404    }
405
406    #[test]
407    fn test_report_summary_text_contains_title() {
408        let mut report = AlignReport::new("My Alignment");
409        report.finalize();
410        let text = report.summary_text();
411        assert!(text.contains("My Alignment"));
412        assert!(text.contains("Grade:"));
413    }
414
415    #[test]
416    fn test_builder_builds_finalized_report() {
417        let report = AlignReportBuilder::new("Builder Test")
418            .measurement(FrameMeasurement::new(0, 0.0, 0.95, 0.1))
419            .measurement(FrameMeasurement::new(1, 0.001, 0.92, 0.2))
420            .note("Test note")
421            .build();
422        assert_eq!(report.measurement_count(), 2);
423        // 1 user note + 1 auto-generated drift note from finalize()
424        assert_eq!(report.notes.len(), 2);
425        assert!(report.drift_stats.is_some());
426    }
427
428    #[test]
429    fn test_grade_ordering() {
430        assert!(AlignGrade::Excellent < AlignGrade::Good);
431        assert!(AlignGrade::Good < AlignGrade::Acceptable);
432        assert!(AlignGrade::Poor < AlignGrade::Failed);
433    }
434}