Skip to main content

oximedia_transcode/
ab_compare.rs

1//! A/B quality comparison for evaluating transcode settings.
2//!
3//! Provides types for representing encode candidates and comparing their
4//! quality (PSNR, SSIM) vs. bitrate trade-offs.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9/// A candidate encode configuration to be compared.
10#[derive(Debug, Clone, PartialEq)]
11pub struct EncodeCandidate {
12    /// Human-readable name for this candidate.
13    pub name: String,
14    /// Constant Rate Factor (0 = lossless, higher = lower quality).
15    pub crf: u8,
16    /// Encoder speed preset name (e.g. "slow", "medium", "fast").
17    pub preset: String,
18    /// Estimated output bitrate in kbps.
19    pub estimated_kbps: u32,
20}
21
22impl EncodeCandidate {
23    /// Creates a new `EncodeCandidate`.
24    #[must_use]
25    pub fn new(
26        name: impl Into<String>,
27        crf: u8,
28        preset: impl Into<String>,
29        estimated_kbps: u32,
30    ) -> Self {
31        Self {
32            name: name.into(),
33            crf,
34            preset: preset.into(),
35            estimated_kbps,
36        }
37    }
38
39    /// Returns `true` if this candidate uses lossless encoding (CRF == 0).
40    #[must_use]
41    pub fn is_lossless(&self) -> bool {
42        self.crf == 0
43    }
44}
45
46/// Quality metrics comparison between two encode candidates.
47#[derive(Debug, Clone)]
48pub struct QualityComparison {
49    /// First candidate.
50    pub candidate_a: EncodeCandidate,
51    /// Second candidate.
52    pub candidate_b: EncodeCandidate,
53    /// PSNR difference (A - B) in dB. Positive means A is better.
54    pub psnr_diff: f32,
55    /// SSIM difference (A - B). Positive means A is better.
56    pub ssim_diff: f32,
57    /// Bitrate difference as a percentage: `(bitrate_a - bitrate_b) / bitrate_b * 100`.
58    pub bitrate_diff_pct: f32,
59}
60
61impl QualityComparison {
62    /// Creates a new `QualityComparison`.
63    #[must_use]
64    pub fn new(
65        candidate_a: EncodeCandidate,
66        candidate_b: EncodeCandidate,
67        psnr_diff: f32,
68        ssim_diff: f32,
69        bitrate_diff_pct: f32,
70    ) -> Self {
71        Self {
72            candidate_a,
73            candidate_b,
74            psnr_diff,
75            ssim_diff,
76            bitrate_diff_pct,
77        }
78    }
79
80    /// Returns the name of the candidate with higher PSNR.
81    ///
82    /// Returns `"tie"` if the difference is within 0.01 dB.
83    #[must_use]
84    pub fn winner_by_psnr(&self) -> &str {
85        if self.psnr_diff.abs() < 0.01 {
86            "tie"
87        } else if self.psnr_diff > 0.0 {
88            &self.candidate_a.name
89        } else {
90            &self.candidate_b.name
91        }
92    }
93
94    /// Returns the name of the candidate with the better quality/bitrate trade-off.
95    ///
96    /// "Efficiency" is defined as PSNR gain relative to bitrate increase.
97    /// If A has higher PSNR but also higher bitrate, efficiency is
98    /// `psnr_diff / bitrate_diff_pct`. If the score is ≥ 0 A wins; otherwise B wins.
99    /// Returns `"tie"` if both PSNR and bitrate differences are negligible.
100    #[must_use]
101    pub fn winner_by_efficiency(&self) -> &str {
102        // If bitrate is essentially equal, fall back to pure PSNR
103        if self.bitrate_diff_pct.abs() < 0.1 {
104            return self.winner_by_psnr();
105        }
106        // A is better if it delivers more quality per kbps
107        // Efficiency score: positive → A is more efficient
108        let score = if self.bitrate_diff_pct > 0.0 {
109            // A costs more bitrate; only wins if PSNR gain is worth it
110            self.psnr_diff / self.bitrate_diff_pct
111        } else {
112            // A costs less bitrate; wins unless its PSNR is notably worse
113            -self.psnr_diff / self.bitrate_diff_pct
114        };
115
116        if score >= 0.0 {
117            &self.candidate_a.name
118        } else {
119            &self.candidate_b.name
120        }
121    }
122}
123
124/// A suite of A/B comparisons that can identify the overall best candidate.
125#[derive(Debug, Clone, Default)]
126pub struct AbTestSuite {
127    /// All comparisons in this suite.
128    pub comparisons: Vec<QualityComparison>,
129}
130
131impl AbTestSuite {
132    /// Creates an empty suite.
133    #[must_use]
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Adds a comparison to the suite.
139    pub fn add(&mut self, comparison: QualityComparison) {
140        self.comparisons.push(comparison);
141    }
142
143    /// Returns the number of comparisons in the suite.
144    #[must_use]
145    pub fn comparison_count(&self) -> usize {
146        self.comparisons.len()
147    }
148
149    /// Returns the name of the candidate that wins the most comparisons by PSNR.
150    ///
151    /// Returns `None` if the suite is empty.
152    #[must_use]
153    pub fn best_candidate_by_psnr(&self) -> Option<String> {
154        if self.comparisons.is_empty() {
155            return None;
156        }
157        let mut scores: std::collections::HashMap<&str, i32> = std::collections::HashMap::new();
158        for cmp in &self.comparisons {
159            let winner = cmp.winner_by_psnr();
160            if winner != "tie" {
161                *scores.entry(winner).or_insert(0) += 1;
162            }
163        }
164        scores
165            .into_iter()
166            .max_by_key(|&(_, count)| count)
167            .map(|(name, _)| name.to_string())
168    }
169
170    /// Returns the name of the candidate that wins the most comparisons by efficiency.
171    ///
172    /// Returns `None` if the suite is empty.
173    #[must_use]
174    pub fn best_candidate_by_efficiency(&self) -> Option<String> {
175        if self.comparisons.is_empty() {
176            return None;
177        }
178        let mut scores: std::collections::HashMap<&str, i32> = std::collections::HashMap::new();
179        for cmp in &self.comparisons {
180            let winner = cmp.winner_by_efficiency();
181            if winner != "tie" {
182                *scores.entry(winner).or_insert(0) += 1;
183            }
184        }
185        scores
186            .into_iter()
187            .max_by_key(|&(_, count)| count)
188            .map(|(name, _)| name.to_string())
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn make_candidate(name: &str, crf: u8, kbps: u32) -> EncodeCandidate {
197        EncodeCandidate::new(name, crf, "medium", kbps)
198    }
199
200    // --- EncodeCandidate ---
201
202    #[test]
203    fn test_is_lossless_true() {
204        let c = make_candidate("lossless", 0, 50000);
205        assert!(c.is_lossless());
206    }
207
208    #[test]
209    fn test_is_lossless_false() {
210        let c = make_candidate("lossy", 23, 3000);
211        assert!(!c.is_lossless());
212    }
213
214    #[test]
215    fn test_candidate_name() {
216        let c = make_candidate("my-encoder", 18, 5000);
217        assert_eq!(c.name, "my-encoder");
218    }
219
220    // --- QualityComparison ---
221
222    #[test]
223    fn test_winner_by_psnr_a_wins() {
224        let cmp = QualityComparison::new(
225            make_candidate("A", 18, 4000),
226            make_candidate("B", 23, 3000),
227            2.0,
228            0.01,
229            33.0,
230        );
231        assert_eq!(cmp.winner_by_psnr(), "A");
232    }
233
234    #[test]
235    fn test_winner_by_psnr_b_wins() {
236        let cmp = QualityComparison::new(
237            make_candidate("A", 23, 3000),
238            make_candidate("B", 18, 4000),
239            -2.0,
240            -0.01,
241            -25.0,
242        );
243        assert_eq!(cmp.winner_by_psnr(), "B");
244    }
245
246    #[test]
247    fn test_winner_by_psnr_tie() {
248        let cmp = QualityComparison::new(
249            make_candidate("A", 18, 4000),
250            make_candidate("B", 18, 4000),
251            0.005,
252            0.0,
253            0.0,
254        );
255        assert_eq!(cmp.winner_by_psnr(), "tie");
256    }
257
258    #[test]
259    fn test_winner_by_efficiency_same_bitrate_falls_back_to_psnr() {
260        let cmp = QualityComparison::new(
261            make_candidate("A", 18, 4000),
262            make_candidate("B", 23, 4000),
263            3.0,
264            0.02,
265            0.05, // essentially same bitrate
266        );
267        assert_eq!(cmp.winner_by_efficiency(), "A");
268    }
269
270    #[test]
271    fn test_winner_by_efficiency_a_cheaper_and_better() {
272        // A has lower bitrate (negative bitrate_diff_pct) and better PSNR
273        let cmp = QualityComparison::new(
274            make_candidate("A", 20, 3000),
275            make_candidate("B", 18, 4000),
276            1.0, // A is 1 dB better
277            0.01,
278            -25.0, // A costs 25% less
279        );
280        assert_eq!(cmp.winner_by_efficiency(), "A");
281    }
282
283    // --- AbTestSuite ---
284
285    #[test]
286    fn test_suite_empty() {
287        let suite = AbTestSuite::new();
288        assert_eq!(suite.comparison_count(), 0);
289        assert!(suite.best_candidate_by_psnr().is_none());
290        assert!(suite.best_candidate_by_efficiency().is_none());
291    }
292
293    #[test]
294    fn test_suite_add_increments_count() {
295        let mut suite = AbTestSuite::new();
296        let cmp = QualityComparison::new(
297            make_candidate("A", 18, 4000),
298            make_candidate("B", 23, 3000),
299            2.0,
300            0.01,
301            33.0,
302        );
303        suite.add(cmp);
304        assert_eq!(suite.comparison_count(), 1);
305    }
306
307    #[test]
308    fn test_suite_best_by_psnr() {
309        let mut suite = AbTestSuite::new();
310        // A wins twice
311        for _ in 0..2 {
312            suite.add(QualityComparison::new(
313                make_candidate("A", 18, 4000),
314                make_candidate("B", 23, 3000),
315                2.0,
316                0.01,
317                33.0,
318            ));
319        }
320        assert_eq!(suite.best_candidate_by_psnr(), Some("A".to_string()));
321    }
322
323    #[test]
324    fn test_suite_best_by_efficiency() {
325        let mut suite = AbTestSuite::new();
326        // B wins by efficiency: lower bitrate, comparable quality
327        for _ in 0..2 {
328            suite.add(QualityComparison::new(
329                make_candidate("A", 18, 5000),
330                make_candidate("B", 20, 3500),
331                -0.5, // B is 0.5 dB better
332                -0.002,
333                43.0, // A costs 43% more
334            ));
335        }
336        assert_eq!(suite.best_candidate_by_efficiency(), Some("B".to_string()));
337    }
338}