Skip to main content

codec_eval/eval/
report.rs

1//! Report types for evaluation results.
2//!
3//! This module defines the data structures for evaluation reports that can be
4//! serialized to JSON or CSV.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use crate::metrics::{MetricResult, PerceptionLevel};
13
14/// Result from evaluating a single codec on a single image at a single quality.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct CodecResult {
17    /// Codec identifier.
18    pub codec_id: String,
19
20    /// Codec version string.
21    pub codec_version: String,
22
23    /// Quality setting used.
24    pub quality: f64,
25
26    /// Encoded file size in bytes.
27    pub file_size: usize,
28
29    /// Bits per pixel of the encoded image.
30    pub bits_per_pixel: f64,
31
32    /// Encoding time.
33    #[serde(with = "duration_millis")]
34    pub encode_time: Duration,
35
36    /// Decoding time (if decoder was provided).
37    #[serde(with = "duration_millis_option")]
38    pub decode_time: Option<Duration>,
39
40    /// Quality metrics comparing decoded to reference.
41    pub metrics: MetricResult,
42
43    /// Perception level based on metrics.
44    pub perception: Option<PerceptionLevel>,
45
46    /// Path to cached encoded file (if caching enabled).
47    pub cached_path: Option<PathBuf>,
48
49    /// Additional codec-specific parameters used.
50    #[serde(default)]
51    pub codec_params: HashMap<String, String>,
52}
53
54impl CodecResult {
55    /// Calculate compression ratio (original size / encoded size).
56    #[must_use]
57    pub fn compression_ratio(&self, original_size: usize) -> f64 {
58        if self.file_size == 0 {
59            0.0
60        } else {
61            original_size as f64 / self.file_size as f64
62        }
63    }
64}
65
66/// Report for a single image evaluated across multiple codecs and quality levels.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ImageReport {
69    /// Image name or identifier.
70    pub name: String,
71
72    /// Path to the source image.
73    pub source_path: Option<PathBuf>,
74
75    /// Image dimensions.
76    pub width: u32,
77    pub height: u32,
78
79    /// Uncompressed image size in bytes (estimated).
80    pub uncompressed_size: usize,
81
82    /// Results for each codec/quality combination.
83    pub results: Vec<CodecResult>,
84
85    /// When this report was generated.
86    #[serde(with = "chrono_serde")]
87    pub timestamp: chrono::DateTime<chrono::Utc>,
88}
89
90impl ImageReport {
91    /// Create a new image report.
92    #[must_use]
93    pub fn new(name: String, width: u32, height: u32) -> Self {
94        Self {
95            name,
96            source_path: None,
97            width,
98            height,
99            uncompressed_size: (width as usize) * (height as usize) * 3,
100            results: Vec::new(),
101            timestamp: chrono::Utc::now(),
102        }
103    }
104
105    /// Get results for a specific codec.
106    pub fn results_for_codec(&self, codec_id: &str) -> impl Iterator<Item = &CodecResult> {
107        self.results.iter().filter(move |r| r.codec_id == codec_id)
108    }
109
110    /// Get the best result (highest quality metric) at or below a target file size.
111    #[must_use]
112    pub fn best_at_size(&self, max_bytes: usize) -> Option<&CodecResult> {
113        self.results
114            .iter()
115            .filter(|r| r.file_size <= max_bytes)
116            .max_by(|a, b| {
117                // Compare by DSSIM (lower is better), so we invert
118                let a_quality = a.metrics.dssim.map(|d| -d).unwrap_or(f64::NEG_INFINITY);
119                let b_quality = b.metrics.dssim.map(|d| -d).unwrap_or(f64::NEG_INFINITY);
120                a_quality
121                    .partial_cmp(&b_quality)
122                    .unwrap_or(std::cmp::Ordering::Equal)
123            })
124    }
125
126    /// Get the smallest file that achieves at least the target quality.
127    #[must_use]
128    pub fn smallest_at_quality(&self, max_dssim: f64) -> Option<&CodecResult> {
129        self.results
130            .iter()
131            .filter(|r| r.metrics.dssim.map_or(false, |d| d <= max_dssim))
132            .min_by_key(|r| r.file_size)
133    }
134}
135
136/// Report for a corpus of images.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct CorpusReport {
139    /// Corpus name or identifier.
140    pub name: String,
141
142    /// Individual image reports.
143    pub images: Vec<ImageReport>,
144
145    /// When this report was generated.
146    #[serde(with = "chrono_serde")]
147    pub timestamp: chrono::DateTime<chrono::Utc>,
148
149    /// Configuration used for this evaluation.
150    pub config_summary: String,
151}
152
153impl CorpusReport {
154    /// Create a new corpus report.
155    #[must_use]
156    pub fn new(name: String) -> Self {
157        Self {
158            name,
159            images: Vec::new(),
160            timestamp: chrono::Utc::now(),
161            config_summary: String::new(),
162        }
163    }
164
165    /// Total number of codec results across all images.
166    #[must_use]
167    pub fn total_results(&self) -> usize {
168        self.images.iter().map(|img| img.results.len()).sum()
169    }
170
171    /// Get unique codec IDs in this report.
172    #[must_use]
173    pub fn codec_ids(&self) -> Vec<String> {
174        let mut ids: Vec<String> = self
175            .images
176            .iter()
177            .flat_map(|img| img.results.iter().map(|r| r.codec_id.clone()))
178            .collect();
179        ids.sort();
180        ids.dedup();
181        ids
182    }
183}
184
185// Custom serialization for Duration as milliseconds
186mod duration_millis {
187    use serde::{Deserialize, Deserializer, Serialize, Serializer};
188    use std::time::Duration;
189
190    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
191    where
192        S: Serializer,
193    {
194        duration.as_millis().serialize(serializer)
195    }
196
197    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
198    where
199        D: Deserializer<'de>,
200    {
201        let millis = u64::deserialize(deserializer)?;
202        Ok(Duration::from_millis(millis))
203    }
204}
205
206mod duration_millis_option {
207    use serde::{Deserialize, Deserializer, Serialize, Serializer};
208    use std::time::Duration;
209
210    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
211    where
212        S: Serializer,
213    {
214        duration.map(|d| d.as_millis()).serialize(serializer)
215    }
216
217    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
218    where
219        D: Deserializer<'de>,
220    {
221        let millis: Option<u64> = Option::deserialize(deserializer)?;
222        Ok(millis.map(Duration::from_millis))
223    }
224}
225
226mod chrono_serde {
227    use chrono::{DateTime, Utc};
228    use serde::{Deserialize, Deserializer, Serialize, Serializer};
229
230    pub fn serialize<S>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
231    where
232        S: Serializer,
233    {
234        dt.to_rfc3339().serialize(serializer)
235    }
236
237    pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
238    where
239        D: Deserializer<'de>,
240    {
241        let s = String::deserialize(deserializer)?;
242        DateTime::parse_from_rfc3339(&s)
243            .map(|dt| dt.with_timezone(&Utc))
244            .map_err(serde::de::Error::custom)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_image_report_new() {
254        let report = ImageReport::new("test.png".to_string(), 1920, 1080);
255        assert_eq!(report.name, "test.png");
256        assert_eq!(report.width, 1920);
257        assert_eq!(report.height, 1080);
258        assert_eq!(report.uncompressed_size, 1920 * 1080 * 3);
259    }
260
261    #[test]
262    fn test_codec_result_compression_ratio() {
263        let result = CodecResult {
264            codec_id: "test".to_string(),
265            codec_version: "1.0".to_string(),
266            quality: 80.0,
267            file_size: 1000,
268            bits_per_pixel: 0.5,
269            encode_time: Duration::from_millis(100),
270            decode_time: None,
271            metrics: MetricResult::default(),
272            perception: None,
273            cached_path: None,
274            codec_params: HashMap::new(),
275        };
276
277        assert!((result.compression_ratio(10000) - 10.0).abs() < 0.001);
278    }
279
280    #[test]
281    fn test_corpus_report_codec_ids() {
282        let mut report = CorpusReport::new("test".to_string());
283        let mut img = ImageReport::new("img1.png".to_string(), 100, 100);
284        img.results.push(CodecResult {
285            codec_id: "mozjpeg".to_string(),
286            codec_version: "4.0".to_string(),
287            quality: 80.0,
288            file_size: 1000,
289            bits_per_pixel: 0.8,
290            encode_time: Duration::from_millis(50),
291            decode_time: None,
292            metrics: MetricResult::default(),
293            perception: None,
294            cached_path: None,
295            codec_params: HashMap::new(),
296        });
297        img.results.push(CodecResult {
298            codec_id: "webp".to_string(),
299            codec_version: "1.0".to_string(),
300            quality: 80.0,
301            file_size: 900,
302            bits_per_pixel: 0.72,
303            encode_time: Duration::from_millis(60),
304            decode_time: None,
305            metrics: MetricResult::default(),
306            perception: None,
307            cached_path: None,
308            codec_params: HashMap::new(),
309        });
310        report.images.push(img);
311
312        let ids = report.codec_ids();
313        assert_eq!(ids.len(), 2);
314        assert!(ids.contains(&"mozjpeg".to_string()));
315        assert!(ids.contains(&"webp".to_string()));
316    }
317}