Skip to main content

codec_eval/stats/
pareto.rs

1//! Pareto front calculation for rate-distortion analysis.
2//!
3//! A Pareto front identifies the set of non-dominated points where no other
4//! point is better on all objectives. For codec comparison, this helps find
5//! the best codec at each quality/size trade-off.
6
7use serde::{Deserialize, Serialize};
8
9/// A point on a rate-distortion curve.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct RDPoint {
12    /// Codec identifier.
13    pub codec: String,
14
15    /// Quality setting used.
16    pub quality_setting: f64,
17
18    /// Bits per pixel (rate).
19    pub bpp: f64,
20
21    /// Quality metric value (higher = better, e.g., SSIMULACRA2).
22    /// For DSSIM, negate before adding.
23    pub quality: f64,
24
25    /// Optional encode time in milliseconds.
26    pub encode_time_ms: Option<f64>,
27
28    /// Optional image name.
29    pub image: Option<String>,
30}
31
32impl RDPoint {
33    /// Create a new RD point.
34    #[must_use]
35    pub fn new(codec: impl Into<String>, quality_setting: f64, bpp: f64, quality: f64) -> Self {
36        Self {
37            codec: codec.into(),
38            quality_setting,
39            bpp,
40            quality,
41            encode_time_ms: None,
42            image: None,
43        }
44    }
45
46    /// Check if this point dominates another.
47    ///
48    /// A point dominates another if it's better on at least one objective
49    /// and not worse on any objective.
50    ///
51    /// Objectives:
52    /// - Lower bpp is better (smaller files)
53    /// - Higher quality is better
54    #[must_use]
55    pub fn dominates(&self, other: &Self) -> bool {
56        let better_or_equal_bpp = self.bpp <= other.bpp;
57        let better_or_equal_quality = self.quality >= other.quality;
58        let strictly_better = self.bpp < other.bpp || self.quality > other.quality;
59
60        better_or_equal_bpp && better_or_equal_quality && strictly_better
61    }
62}
63
64/// Pareto front of rate-distortion points.
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct ParetoFront {
67    /// Points on the Pareto front (non-dominated).
68    pub points: Vec<RDPoint>,
69}
70
71impl ParetoFront {
72    /// Compute the Pareto front from a set of points.
73    ///
74    /// Returns a new `ParetoFront` containing only the non-dominated points.
75    #[must_use]
76    pub fn compute(points: &[RDPoint]) -> Self {
77        let mut front = Vec::new();
78
79        for point in points {
80            // Check if any existing front point dominates this one
81            let is_dominated = front.iter().any(|p: &RDPoint| p.dominates(point));
82
83            if !is_dominated {
84                // Remove any front points that this point dominates
85                front.retain(|p| !point.dominates(p));
86                front.push(point.clone());
87            }
88        }
89
90        // Sort by bpp for easy plotting
91        front.sort_by(|a, b| {
92            a.bpp
93                .partial_cmp(&b.bpp)
94                .unwrap_or(std::cmp::Ordering::Equal)
95        });
96
97        Self { points: front }
98    }
99
100    /// Check if the front is empty.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.points.is_empty()
104    }
105
106    /// Get the number of points on the front.
107    #[must_use]
108    pub fn len(&self) -> usize {
109        self.points.len()
110    }
111
112    /// Get points that achieve at least the target quality.
113    #[must_use]
114    pub fn at_quality(&self, min_quality: f64) -> Vec<&RDPoint> {
115        self.points
116            .iter()
117            .filter(|p| p.quality >= min_quality)
118            .collect()
119    }
120
121    /// Get points that achieve at most the target bpp.
122    #[must_use]
123    pub fn at_bpp(&self, max_bpp: f64) -> Vec<&RDPoint> {
124        self.points.iter().filter(|p| p.bpp <= max_bpp).collect()
125    }
126
127    /// Get the best point (highest quality) at or below the target bpp.
128    #[must_use]
129    pub fn best_at_bpp(&self, max_bpp: f64) -> Option<&RDPoint> {
130        self.points
131            .iter()
132            .filter(|p| p.bpp <= max_bpp)
133            .max_by(|a, b| {
134                a.quality
135                    .partial_cmp(&b.quality)
136                    .unwrap_or(std::cmp::Ordering::Equal)
137            })
138    }
139
140    /// Get the most efficient point (lowest bpp) at or above the target quality.
141    #[must_use]
142    pub fn best_at_quality(&self, min_quality: f64) -> Option<&RDPoint> {
143        self.points
144            .iter()
145            .filter(|p| p.quality >= min_quality)
146            .min_by(|a, b| {
147                a.bpp
148                    .partial_cmp(&b.bpp)
149                    .unwrap_or(std::cmp::Ordering::Equal)
150            })
151    }
152
153    /// Get unique codecs on the Pareto front.
154    #[must_use]
155    pub fn codecs(&self) -> Vec<&str> {
156        let mut codecs: Vec<&str> = self.points.iter().map(|p| p.codec.as_str()).collect();
157        codecs.sort();
158        codecs.dedup();
159        codecs
160    }
161
162    /// Filter to points from a specific codec.
163    #[must_use]
164    pub fn filter_codec(&self, codec: &str) -> Vec<&RDPoint> {
165        self.points.iter().filter(|p| p.codec == codec).collect()
166    }
167
168    /// Compute per-codec Pareto fronts.
169    #[must_use]
170    pub fn per_codec(points: &[RDPoint]) -> std::collections::HashMap<String, ParetoFront> {
171        use std::collections::HashMap;
172
173        let mut by_codec: HashMap<String, Vec<RDPoint>> = HashMap::new();
174
175        for point in points {
176            by_codec
177                .entry(point.codec.clone())
178                .or_default()
179                .push(point.clone());
180        }
181
182        by_codec
183            .into_iter()
184            .map(|(codec, pts)| (codec, Self::compute(&pts)))
185            .collect()
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_dominates() {
195        let p1 = RDPoint::new("a", 80.0, 1.0, 90.0); // bpp=1, quality=90
196        let p2 = RDPoint::new("b", 80.0, 2.0, 85.0); // bpp=2, quality=85
197
198        // p1 dominates p2 (lower bpp AND higher quality)
199        assert!(p1.dominates(&p2));
200        assert!(!p2.dominates(&p1));
201    }
202
203    #[test]
204    fn test_no_dominance() {
205        let p1 = RDPoint::new("a", 80.0, 1.0, 85.0); // bpp=1, quality=85
206        let p2 = RDPoint::new("b", 80.0, 2.0, 90.0); // bpp=2, quality=90
207
208        // Neither dominates (trade-off: p1 is smaller, p2 is higher quality)
209        assert!(!p1.dominates(&p2));
210        assert!(!p2.dominates(&p1));
211    }
212
213    #[test]
214    fn test_pareto_front_basic() {
215        let points = vec![
216            RDPoint::new("a", 80.0, 1.0, 80.0),
217            RDPoint::new("b", 80.0, 2.0, 90.0),
218            RDPoint::new("c", 80.0, 3.0, 85.0), // Dominated by b
219            RDPoint::new("d", 80.0, 0.5, 70.0),
220        ];
221
222        let front = ParetoFront::compute(&points);
223
224        // Should have 3 points on front: d (smallest), a, b (highest quality)
225        // c is dominated by b (same or better on both)
226        assert_eq!(front.len(), 3);
227
228        let codecs = front.codecs();
229        assert!(codecs.contains(&"a"));
230        assert!(codecs.contains(&"b"));
231        assert!(codecs.contains(&"d"));
232        assert!(!codecs.contains(&"c"));
233    }
234
235    #[test]
236    fn test_pareto_best_at_bpp() {
237        let points = vec![
238            RDPoint::new("a", 80.0, 1.0, 80.0),
239            RDPoint::new("b", 80.0, 2.0, 90.0),
240            RDPoint::new("c", 80.0, 0.5, 70.0),
241        ];
242
243        let front = ParetoFront::compute(&points);
244
245        // Best at bpp <= 1.0 should be "a" (higher quality than "c")
246        let best = front.best_at_bpp(1.0).unwrap();
247        assert_eq!(best.codec, "a");
248
249        // Best at bpp <= 2.0 should be "b" (highest quality)
250        let best = front.best_at_bpp(2.0).unwrap();
251        assert_eq!(best.codec, "b");
252    }
253
254    #[test]
255    fn test_pareto_best_at_quality() {
256        let points = vec![
257            RDPoint::new("a", 80.0, 1.0, 80.0),
258            RDPoint::new("b", 80.0, 2.0, 90.0),
259            RDPoint::new("c", 80.0, 0.5, 70.0),
260        ];
261
262        let front = ParetoFront::compute(&points);
263
264        // Most efficient at quality >= 80 should be "a"
265        let best = front.best_at_quality(80.0).unwrap();
266        assert_eq!(best.codec, "a");
267
268        // Most efficient at quality >= 85 should be "b"
269        let best = front.best_at_quality(85.0).unwrap();
270        assert_eq!(best.codec, "b");
271    }
272}