Skip to main content

oximedia_codec/
multipass_quality.rs

1//! Multipass encoding quality comparison.
2//!
3//! This module provides tools to verify that multipass encoding (two-pass,
4//! lookahead-based) produces measurably better quality than single-pass
5//! encoding at the same average bitrate. The comparison uses:
6//!
7//! - Per-frame quality scores (QP-derived or PSNR-measured)
8//! - Bitrate distribution analysis (variance, min/max ratio)
9//! - Quality consistency metrics (standard deviation of per-frame quality)
10//!
11//! # Verification Strategy
12//!
13//! For a fair comparison between single-pass and multipass:
14//! 1. Encode the same source with both methods at the same target bitrate
15//! 2. Record per-frame (size, QP) tuples
16//! 3. Compare:
17//!    - Average quality should be equal or better for multipass
18//!    - Quality variance should be lower for multipass
19//!    - Bitrate distribution should be smoother for multipass
20//!
21//! # Usage
22//!
23//! ```rust
24//! use oximedia_codec::multipass_quality::{
25//!     PassRecorder, MultipassComparison, FrameMetric,
26//! };
27//!
28//! let mut single = PassRecorder::new("single-pass");
29//! let mut multi  = PassRecorder::new("two-pass");
30//!
31//! // Record frames from each encoding session
32//! single.record(FrameMetric { size_bytes: 5000, qp: 28, is_keyframe: false });
33//! multi.record(FrameMetric { size_bytes: 4800, qp: 27, is_keyframe: false });
34//! // ... more frames ...
35//!
36//! let cmp = MultipassComparison::compare(&single, &multi);
37//! // multipass should have equal or better average QP
38//! ```
39
40/// Per-frame metric recorded during encoding.
41#[derive(Debug, Clone, Copy)]
42pub struct FrameMetric {
43    /// Encoded frame size in bytes.
44    pub size_bytes: u32,
45    /// Quantisation parameter used for this frame.
46    pub qp: u8,
47    /// Whether this frame was encoded as a keyframe.
48    pub is_keyframe: bool,
49}
50
51/// Records per-frame metrics for one encoding pass.
52#[derive(Debug, Clone)]
53pub struct PassRecorder {
54    /// Label for this pass (e.g. "single-pass", "two-pass").
55    label: String,
56    /// Recorded frame metrics in order.
57    frames: Vec<FrameMetric>,
58}
59
60impl PassRecorder {
61    /// Create a new pass recorder with the given label.
62    #[must_use]
63    pub fn new(label: &str) -> Self {
64        Self {
65            label: label.to_string(),
66            frames: Vec::new(),
67        }
68    }
69
70    /// Record a single frame's metrics.
71    pub fn record(&mut self, metric: FrameMetric) {
72        self.frames.push(metric);
73    }
74
75    /// Get the number of recorded frames.
76    #[must_use]
77    pub fn frame_count(&self) -> usize {
78        self.frames.len()
79    }
80
81    /// Get the label.
82    #[must_use]
83    pub fn label(&self) -> &str {
84        &self.label
85    }
86
87    /// Compute the average QP across all frames.
88    #[must_use]
89    pub fn average_qp(&self) -> f64 {
90        if self.frames.is_empty() {
91            return 0.0;
92        }
93        let sum: u64 = self.frames.iter().map(|f| u64::from(f.qp)).sum();
94        sum as f64 / self.frames.len() as f64
95    }
96
97    /// Compute the QP standard deviation.
98    #[must_use]
99    pub fn qp_std_dev(&self) -> f64 {
100        if self.frames.len() < 2 {
101            return 0.0;
102        }
103        let mean = self.average_qp();
104        let variance: f64 = self
105            .frames
106            .iter()
107            .map(|f| {
108                let d = f64::from(f.qp) - mean;
109                d * d
110            })
111            .sum::<f64>()
112            / (self.frames.len() - 1) as f64;
113        variance.sqrt()
114    }
115
116    /// Compute the total size in bytes.
117    #[must_use]
118    pub fn total_bytes(&self) -> u64 {
119        self.frames.iter().map(|f| u64::from(f.size_bytes)).sum()
120    }
121
122    /// Compute the average frame size in bytes.
123    #[must_use]
124    pub fn average_frame_size(&self) -> f64 {
125        if self.frames.is_empty() {
126            return 0.0;
127        }
128        self.total_bytes() as f64 / self.frames.len() as f64
129    }
130
131    /// Compute the frame size standard deviation.
132    #[must_use]
133    pub fn size_std_dev(&self) -> f64 {
134        if self.frames.len() < 2 {
135            return 0.0;
136        }
137        let mean = self.average_frame_size();
138        let variance: f64 = self
139            .frames
140            .iter()
141            .map(|f| {
142                let d = f64::from(f.size_bytes) - mean;
143                d * d
144            })
145            .sum::<f64>()
146            / (self.frames.len() - 1) as f64;
147        variance.sqrt()
148    }
149
150    /// Compute min/max frame size ratio.
151    #[must_use]
152    pub fn size_min_max_ratio(&self) -> f64 {
153        if self.frames.is_empty() {
154            return 1.0;
155        }
156        let min = self.frames.iter().map(|f| f.size_bytes).min().unwrap_or(1);
157        let max = self.frames.iter().map(|f| f.size_bytes).max().unwrap_or(1);
158        if max == 0 {
159            return 1.0;
160        }
161        f64::from(min) / f64::from(max)
162    }
163
164    /// Return a reference to all recorded frames.
165    #[must_use]
166    pub fn frames(&self) -> &[FrameMetric] {
167        &self.frames
168    }
169
170    /// Clear all recorded data.
171    pub fn reset(&mut self) {
172        self.frames.clear();
173    }
174}
175
176/// Result of comparing two encoding passes.
177#[derive(Debug, Clone)]
178pub struct MultipassComparison {
179    /// Label of the reference (typically single-pass) pass.
180    pub reference_label: String,
181    /// Label of the candidate (typically multipass) pass.
182    pub candidate_label: String,
183    /// Average QP of reference pass.
184    pub ref_avg_qp: f64,
185    /// Average QP of candidate pass.
186    pub cand_avg_qp: f64,
187    /// QP std dev of reference pass.
188    pub ref_qp_std_dev: f64,
189    /// QP std dev of candidate pass.
190    pub cand_qp_std_dev: f64,
191    /// Total bytes of reference pass.
192    pub ref_total_bytes: u64,
193    /// Total bytes of candidate pass.
194    pub cand_total_bytes: u64,
195    /// Frame size std dev of reference.
196    pub ref_size_std_dev: f64,
197    /// Frame size std dev of candidate.
198    pub cand_size_std_dev: f64,
199    /// Whether the candidate has equal or better average QP (lower is better).
200    pub candidate_qp_equal_or_better: bool,
201    /// Whether the candidate has lower or equal QP variance.
202    pub candidate_qp_more_consistent: bool,
203    /// Whether the candidate has a smoother bitrate distribution.
204    pub candidate_smoother_bitrate: bool,
205}
206
207impl MultipassComparison {
208    /// Compare two passes: `reference` is the baseline (e.g. single-pass),
209    /// `candidate` is the multipass result.
210    #[must_use]
211    pub fn compare(reference: &PassRecorder, candidate: &PassRecorder) -> Self {
212        let ref_avg_qp = reference.average_qp();
213        let cand_avg_qp = candidate.average_qp();
214        let ref_qp_std = reference.qp_std_dev();
215        let cand_qp_std = candidate.qp_std_dev();
216        let ref_size_std = reference.size_std_dev();
217        let cand_size_std = candidate.size_std_dev();
218
219        Self {
220            reference_label: reference.label().to_string(),
221            candidate_label: candidate.label().to_string(),
222            ref_avg_qp,
223            cand_avg_qp,
224            ref_qp_std_dev: ref_qp_std,
225            cand_qp_std_dev: cand_qp_std,
226            ref_total_bytes: reference.total_bytes(),
227            cand_total_bytes: candidate.total_bytes(),
228            ref_size_std_dev: ref_size_std,
229            cand_size_std_dev: cand_size_std,
230            candidate_qp_equal_or_better: cand_avg_qp <= ref_avg_qp + 0.5,
231            candidate_qp_more_consistent: cand_qp_std <= ref_qp_std + 0.5,
232            candidate_smoother_bitrate: cand_size_std <= ref_size_std * 1.1,
233        }
234    }
235
236    /// Summary string for logging / assertion messages.
237    #[must_use]
238    pub fn summary(&self) -> String {
239        format!(
240            "{} vs {}: avg_qp {:.1} vs {:.1}, qp_std {:.2} vs {:.2}, \
241             size_std {:.0} vs {:.0}, bytes {} vs {}",
242            self.reference_label,
243            self.candidate_label,
244            self.ref_avg_qp,
245            self.cand_avg_qp,
246            self.ref_qp_std_dev,
247            self.cand_qp_std_dev,
248            self.ref_size_std_dev,
249            self.cand_size_std_dev,
250            self.ref_total_bytes,
251            self.cand_total_bytes,
252        )
253    }
254}
255
256// =============================================================================
257// Tests — Multipass encoding quality comparison
258// =============================================================================
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    /// Helper: create a pass recorder with uniform frames.
265    fn uniform_pass(label: &str, n: usize, size: u32, qp: u8) -> PassRecorder {
266        let mut rec = PassRecorder::new(label);
267        for i in 0..n {
268            rec.record(FrameMetric {
269                size_bytes: size,
270                qp,
271                is_keyframe: i == 0,
272            });
273        }
274        rec
275    }
276
277    /// Helper: create a pass with varying complexity.
278    fn varying_pass(label: &str, n: usize, base_size: u32, qp_range: (u8, u8)) -> PassRecorder {
279        let mut rec = PassRecorder::new(label);
280        for i in 0..n {
281            let t = i as f64 / n as f64;
282            // Simulate varying complexity with a sine wave
283            let variation = (t * std::f64::consts::PI * 4.0).sin();
284            let qp = (qp_range.0 as f64
285                + (qp_range.1 as f64 - qp_range.0 as f64) * (variation + 1.0) / 2.0)
286                as u8;
287            let size = (base_size as f64 * (1.0 + variation * 0.3)) as u32;
288            rec.record(FrameMetric {
289                size_bytes: size,
290                qp,
291                is_keyframe: i % 30 == 0,
292            });
293        }
294        rec
295    }
296
297    #[test]
298    fn test_pass_recorder_basic() {
299        let mut rec = PassRecorder::new("test");
300        rec.record(FrameMetric {
301            size_bytes: 1000,
302            qp: 28,
303            is_keyframe: true,
304        });
305        rec.record(FrameMetric {
306            size_bytes: 500,
307            qp: 30,
308            is_keyframe: false,
309        });
310        assert_eq!(rec.frame_count(), 2);
311        assert_eq!(rec.label(), "test");
312        assert_eq!(rec.total_bytes(), 1500);
313    }
314
315    #[test]
316    fn test_average_qp() {
317        let rec = uniform_pass("test", 10, 1000, 28);
318        assert!((rec.average_qp() - 28.0).abs() < f64::EPSILON);
319    }
320
321    #[test]
322    fn test_qp_std_dev_uniform() {
323        let rec = uniform_pass("test", 10, 1000, 28);
324        assert!(
325            rec.qp_std_dev() < f64::EPSILON,
326            "uniform QP should have zero std dev"
327        );
328    }
329
330    #[test]
331    fn test_qp_std_dev_varying() {
332        let mut rec = PassRecorder::new("test");
333        rec.record(FrameMetric {
334            size_bytes: 1000,
335            qp: 20,
336            is_keyframe: false,
337        });
338        rec.record(FrameMetric {
339            size_bytes: 1000,
340            qp: 40,
341            is_keyframe: false,
342        });
343        let std_dev = rec.qp_std_dev();
344        assert!(
345            std_dev > 10.0,
346            "QP 20 vs 40 should have large std dev, got {std_dev}"
347        );
348    }
349
350    #[test]
351    fn test_size_std_dev_uniform() {
352        let rec = uniform_pass("test", 20, 5000, 28);
353        assert!(
354            rec.size_std_dev() < f64::EPSILON,
355            "uniform size should have zero std dev"
356        );
357    }
358
359    #[test]
360    fn test_size_min_max_ratio() {
361        let mut rec = PassRecorder::new("test");
362        rec.record(FrameMetric {
363            size_bytes: 1000,
364            qp: 28,
365            is_keyframe: false,
366        });
367        rec.record(FrameMetric {
368            size_bytes: 2000,
369            qp: 28,
370            is_keyframe: false,
371        });
372        let ratio = rec.size_min_max_ratio();
373        assert!(
374            (ratio - 0.5).abs() < f64::EPSILON,
375            "min/max ratio should be 0.5, got {ratio}"
376        );
377    }
378
379    #[test]
380    fn test_comparison_identical_passes() {
381        let single = uniform_pass("single", 90, 5000, 28);
382        let multi = uniform_pass("multi", 90, 5000, 28);
383        let cmp = MultipassComparison::compare(&single, &multi);
384
385        assert!(cmp.candidate_qp_equal_or_better);
386        assert!(cmp.candidate_qp_more_consistent);
387        assert!(cmp.candidate_smoother_bitrate);
388    }
389
390    #[test]
391    fn test_comparison_multipass_better_qp() {
392        let single = uniform_pass("single", 90, 5000, 30);
393        let multi = uniform_pass("multi", 90, 5000, 26); // lower QP = better
394        let cmp = MultipassComparison::compare(&single, &multi);
395
396        assert!(
397            cmp.candidate_qp_equal_or_better,
398            "multipass with lower QP should be detected as better"
399        );
400    }
401
402    #[test]
403    fn test_comparison_multipass_more_consistent() {
404        let single = varying_pass("single", 120, 5000, (20, 40));
405        let multi = varying_pass("multi", 120, 5000, (26, 30)); // tighter QP range
406        let cmp = MultipassComparison::compare(&single, &multi);
407
408        assert!(
409            cmp.cand_qp_std_dev < cmp.ref_qp_std_dev,
410            "multipass should have lower QP variance: {} vs {}",
411            cmp.cand_qp_std_dev,
412            cmp.ref_qp_std_dev
413        );
414    }
415
416    #[test]
417    fn test_comparison_smoother_bitrate() {
418        // Single-pass: large size variance
419        let mut single = PassRecorder::new("single");
420        for i in 0..60 {
421            let size = if i % 10 == 0 { 15000 } else { 3000 };
422            single.record(FrameMetric {
423                size_bytes: size,
424                qp: 28,
425                is_keyframe: i % 10 == 0,
426            });
427        }
428
429        // Multipass: smooth size distribution
430        let multi = uniform_pass("multi", 60, 5000, 28);
431        let cmp = MultipassComparison::compare(&single, &multi);
432
433        assert!(
434            cmp.candidate_smoother_bitrate,
435            "uniform multipass should have smoother bitrate: {} vs {}",
436            cmp.cand_size_std_dev, cmp.ref_size_std_dev
437        );
438    }
439
440    #[test]
441    fn test_comparison_summary_format() {
442        let single = uniform_pass("single", 10, 5000, 28);
443        let multi = uniform_pass("multi", 10, 5000, 26);
444        let cmp = MultipassComparison::compare(&single, &multi);
445        let summary = cmp.summary();
446
447        assert!(summary.contains("single"));
448        assert!(summary.contains("multi"));
449        assert!(summary.contains("avg_qp"));
450    }
451
452    #[test]
453    fn test_pass_recorder_reset() {
454        let mut rec = uniform_pass("test", 10, 5000, 28);
455        assert_eq!(rec.frame_count(), 10);
456        rec.reset();
457        assert_eq!(rec.frame_count(), 0);
458        assert!(rec.total_bytes() == 0);
459    }
460
461    #[test]
462    fn test_empty_recorder_defaults() {
463        let rec = PassRecorder::new("empty");
464        assert!(rec.average_qp() < f64::EPSILON);
465        assert!(rec.qp_std_dev() < f64::EPSILON);
466        assert!(rec.average_frame_size() < f64::EPSILON);
467        assert!((rec.size_min_max_ratio() - 1.0).abs() < f64::EPSILON);
468    }
469}