Skip to main content

codec_eval/metrics/
mod.rs

1//! Quality metrics for image comparison.
2//!
3//! This module provides perceptual quality metrics for comparing reference
4//! and test images. Supported metrics:
5//!
6//! - **DSSIM**: Structural dissimilarity metric (lower is better, 0 = identical)
7//! - **SSIMULACRA2**: Perceptual similarity metric (higher is better, 100 = identical)
8//! - **Butteraugli**: Perceptual difference metric (lower is better, <1.0 = imperceptible)
9//! - **PSNR**: Peak Signal-to-Noise Ratio (higher is better) - NOT RECOMMENDED
10//!
11//! ## Recommended Metrics
12//!
13//! **Prefer SSIMULACRA2 or Butteraugli over PSNR.** PSNR does not correlate well
14//! with human perception. SSIMULACRA2 and Butteraugli are designed to match
15//! human visual perception of image quality.
16//!
17//! ## Perception Thresholds
18//!
19//! Based on empirical data from imageflow:
20//!
21//! | Level | DSSIM | SSIMULACRA2 | Butteraugli | Description |
22//! |-------|-------|-------------|-------------|-------------|
23//! | Imperceptible | < 0.0003 | > 90 | < 1.0 | Visually identical |
24//! | Marginal | < 0.0007 | > 80 | < 2.0 | Only A/B comparison reveals |
25//! | Subtle | < 0.0015 | > 70 | < 3.0 | Barely noticeable |
26//! | Noticeable | < 0.003 | > 50 | < 5.0 | Visible on inspection |
27//! | Degraded | >= 0.003 | <= 50 | >= 5.0 | Clearly visible artifacts |
28
29pub mod butteraugli;
30pub mod dssim;
31pub mod icc;
32pub mod prelude;
33pub mod ssimulacra2;
34pub mod xyb;
35
36// Re-export ICC types for convenience
37pub use icc::{ColorProfile, prepare_for_comparison, transform_to_srgb};
38
39use serde::{Deserialize, Serialize};
40
41// Re-export XYB roundtrip for convenience
42pub use xyb::xyb_roundtrip;
43
44/// Configuration for which metrics to calculate.
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct MetricConfig {
47    /// Calculate DSSIM (structural dissimilarity).
48    pub dssim: bool,
49    /// Calculate SSIMULACRA2 (perceptual similarity, higher is better).
50    pub ssimulacra2: bool,
51    /// Calculate Butteraugli (perceptual difference, lower is better).
52    pub butteraugli: bool,
53    /// Calculate PSNR (peak signal-to-noise ratio). NOT RECOMMENDED.
54    pub psnr: bool,
55    /// Roundtrip reference through XYB color space before comparing.
56    ///
57    /// When enabled, the reference image is converted RGB → XYB → u8 → XYB → RGB
58    /// before computing metrics. This isolates true compression error from
59    /// color space conversion error.
60    ///
61    /// Recommended for codecs that operate in XYB color space (e.g., jpegli).
62    pub xyb_roundtrip: bool,
63}
64
65impl MetricConfig {
66    /// Calculate all available metrics.
67    #[must_use]
68    pub fn all() -> Self {
69        Self {
70            dssim: true,
71            ssimulacra2: true,
72            butteraugli: true,
73            psnr: true,
74            xyb_roundtrip: false,
75        }
76    }
77
78    /// Fast metric set (PSNR only). NOT RECOMMENDED for quality comparison.
79    #[must_use]
80    pub fn fast() -> Self {
81        Self {
82            dssim: false,
83            ssimulacra2: false,
84            butteraugli: false,
85            psnr: true,
86            xyb_roundtrip: false,
87        }
88    }
89
90    /// Perceptual metrics only (DSSIM, SSIMULACRA2, Butteraugli). RECOMMENDED.
91    #[must_use]
92    pub fn perceptual() -> Self {
93        Self {
94            dssim: true,
95            ssimulacra2: true,
96            butteraugli: true,
97            psnr: false,
98            xyb_roundtrip: false,
99        }
100    }
101
102    /// Perceptual metrics with XYB roundtrip. RECOMMENDED for XYB codecs.
103    ///
104    /// Same as `perceptual()` but with XYB roundtrip enabled.
105    /// This gives fairer comparisons for codecs that operate in XYB color space
106    /// (like jpegli) by isolating compression error from color space conversion error.
107    #[must_use]
108    pub fn perceptual_xyb() -> Self {
109        Self {
110            dssim: true,
111            ssimulacra2: true,
112            butteraugli: true,
113            psnr: false,
114            xyb_roundtrip: true,
115        }
116    }
117
118    /// SSIMULACRA2 only - good balance of speed and accuracy.
119    #[must_use]
120    pub fn ssimulacra2_only() -> Self {
121        Self {
122            dssim: false,
123            ssimulacra2: true,
124            butteraugli: false,
125            psnr: false,
126            xyb_roundtrip: false,
127        }
128    }
129
130    /// Enable XYB roundtrip on this config.
131    #[must_use]
132    pub fn with_xyb_roundtrip(mut self) -> Self {
133        self.xyb_roundtrip = true;
134        self
135    }
136}
137
138/// Results from metric calculations.
139#[derive(Debug, Clone, Default, Serialize, Deserialize)]
140pub struct MetricResult {
141    /// DSSIM value (lower is better, 0 = identical).
142    pub dssim: Option<f64>,
143    /// SSIMULACRA2 score (higher is better, 100 = identical).
144    pub ssimulacra2: Option<f64>,
145    /// Butteraugli score (lower is better, <1.0 = imperceptible).
146    pub butteraugli: Option<f64>,
147    /// PSNR value in dB (higher is better). NOT RECOMMENDED.
148    pub psnr: Option<f64>,
149}
150
151impl MetricResult {
152    /// Get the perception level based on DSSIM value.
153    #[must_use]
154    pub fn perception_level(&self) -> Option<PerceptionLevel> {
155        self.dssim.map(PerceptionLevel::from_dssim)
156    }
157
158    /// Get the perception level based on SSIMULACRA2 value.
159    #[must_use]
160    pub fn perception_level_ssimulacra2(&self) -> Option<PerceptionLevel> {
161        self.ssimulacra2.map(PerceptionLevel::from_ssimulacra2)
162    }
163
164    /// Get the perception level based on Butteraugli value.
165    #[must_use]
166    pub fn perception_level_butteraugli(&self) -> Option<PerceptionLevel> {
167        self.butteraugli.map(PerceptionLevel::from_butteraugli)
168    }
169}
170
171/// Perceptual quality level based on metric thresholds.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173pub enum PerceptionLevel {
174    /// DSSIM < 0.0003 - Visually identical.
175    Imperceptible,
176    /// DSSIM < 0.0007 - Only A/B comparison reveals difference.
177    Marginal,
178    /// DSSIM < 0.0015 - Barely noticeable.
179    Subtle,
180    /// DSSIM < 0.003 - Visible on inspection.
181    Noticeable,
182    /// DSSIM >= 0.003 - Clearly visible artifacts.
183    Degraded,
184}
185
186impl PerceptionLevel {
187    /// Determine perception level from DSSIM value.
188    #[must_use]
189    pub fn from_dssim(dssim: f64) -> Self {
190        if dssim < 0.0003 {
191            Self::Imperceptible
192        } else if dssim < 0.0007 {
193            Self::Marginal
194        } else if dssim < 0.0015 {
195            Self::Subtle
196        } else if dssim < 0.003 {
197            Self::Noticeable
198        } else {
199            Self::Degraded
200        }
201    }
202
203    /// Determine perception level from SSIMULACRA2 value.
204    /// SSIMULACRA2 is higher-is-better (100 = identical).
205    #[must_use]
206    pub fn from_ssimulacra2(score: f64) -> Self {
207        if score > 90.0 {
208            Self::Imperceptible
209        } else if score > 80.0 {
210            Self::Marginal
211        } else if score > 70.0 {
212            Self::Subtle
213        } else if score > 50.0 {
214            Self::Noticeable
215        } else {
216            Self::Degraded
217        }
218    }
219
220    /// Determine perception level from Butteraugli value.
221    /// Butteraugli is lower-is-better (<1.0 = imperceptible).
222    #[must_use]
223    pub fn from_butteraugli(score: f64) -> Self {
224        if score < 1.0 {
225            Self::Imperceptible
226        } else if score < 2.0 {
227            Self::Marginal
228        } else if score < 3.0 {
229            Self::Subtle
230        } else if score < 5.0 {
231            Self::Noticeable
232        } else {
233            Self::Degraded
234        }
235    }
236
237    /// Get the maximum DSSIM value for this perception level.
238    #[must_use]
239    pub fn max_dssim(self) -> f64 {
240        match self {
241            Self::Imperceptible => 0.0003,
242            Self::Marginal => 0.0007,
243            Self::Subtle => 0.0015,
244            Self::Noticeable => 0.003,
245            Self::Degraded => f64::INFINITY,
246        }
247    }
248
249    /// Get the minimum SSIMULACRA2 value for this perception level.
250    #[must_use]
251    pub fn min_ssimulacra2(self) -> f64 {
252        match self {
253            Self::Imperceptible => 90.0,
254            Self::Marginal => 80.0,
255            Self::Subtle => 70.0,
256            Self::Noticeable => 50.0,
257            Self::Degraded => f64::NEG_INFINITY,
258        }
259    }
260
261    /// Get the maximum Butteraugli value for this perception level.
262    #[must_use]
263    pub fn max_butteraugli(self) -> f64 {
264        match self {
265            Self::Imperceptible => 1.0,
266            Self::Marginal => 2.0,
267            Self::Subtle => 3.0,
268            Self::Noticeable => 5.0,
269            Self::Degraded => f64::INFINITY,
270        }
271    }
272
273    /// Get a short code for this level.
274    #[must_use]
275    pub fn code(self) -> &'static str {
276        match self {
277            Self::Imperceptible => "IMP",
278            Self::Marginal => "MAR",
279            Self::Subtle => "SUB",
280            Self::Noticeable => "NOT",
281            Self::Degraded => "DEG",
282        }
283    }
284}
285
286impl std::fmt::Display for PerceptionLevel {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        match self {
289            Self::Imperceptible => write!(f, "Imperceptible"),
290            Self::Marginal => write!(f, "Marginal"),
291            Self::Subtle => write!(f, "Subtle"),
292            Self::Noticeable => write!(f, "Noticeable"),
293            Self::Degraded => write!(f, "Degraded"),
294        }
295    }
296}
297
298/// Calculate PSNR between two images.
299///
300/// # Arguments
301///
302/// * `reference` - Reference image pixel data (RGB8, row-major).
303/// * `test` - Test image pixel data (RGB8, row-major).
304/// * `width` - Image width in pixels.
305/// * `height` - Image height in pixels.
306///
307/// # Returns
308///
309/// PSNR value in decibels. Higher is better. Returns `f64::INFINITY` if
310/// images are identical.
311#[must_use]
312pub fn calculate_psnr(reference: &[u8], test: &[u8], width: usize, height: usize) -> f64 {
313    assert_eq!(reference.len(), test.len());
314    assert_eq!(reference.len(), width * height * 3);
315
316    let mut mse_sum: f64 = 0.0;
317    let pixel_count = (width * height * 3) as f64;
318
319    for (r, t) in reference.iter().zip(test.iter()) {
320        let diff = f64::from(*r) - f64::from(*t);
321        mse_sum += diff * diff;
322    }
323
324    let mse = mse_sum / pixel_count;
325
326    if mse == 0.0 {
327        f64::INFINITY
328    } else {
329        10.0 * (255.0_f64 * 255.0 / mse).log10()
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_perception_level_thresholds() {
339        assert_eq!(
340            PerceptionLevel::from_dssim(0.0001),
341            PerceptionLevel::Imperceptible
342        );
343        assert_eq!(
344            PerceptionLevel::from_dssim(0.0003),
345            PerceptionLevel::Marginal
346        );
347        assert_eq!(
348            PerceptionLevel::from_dssim(0.0005),
349            PerceptionLevel::Marginal
350        );
351        assert_eq!(PerceptionLevel::from_dssim(0.0007), PerceptionLevel::Subtle);
352        assert_eq!(PerceptionLevel::from_dssim(0.001), PerceptionLevel::Subtle);
353        assert_eq!(
354            PerceptionLevel::from_dssim(0.0015),
355            PerceptionLevel::Noticeable
356        );
357        assert_eq!(
358            PerceptionLevel::from_dssim(0.002),
359            PerceptionLevel::Noticeable
360        );
361        assert_eq!(
362            PerceptionLevel::from_dssim(0.003),
363            PerceptionLevel::Degraded
364        );
365        assert_eq!(PerceptionLevel::from_dssim(0.01), PerceptionLevel::Degraded);
366    }
367
368    #[test]
369    fn test_psnr_identical() {
370        let data = vec![128u8; 100 * 100 * 3];
371        let psnr = calculate_psnr(&data, &data, 100, 100);
372        assert!(psnr.is_infinite());
373    }
374
375    #[test]
376    fn test_psnr_different() {
377        let reference = vec![100u8; 100 * 100 * 3];
378        let test = vec![110u8; 100 * 100 * 3];
379        let psnr = calculate_psnr(&reference, &test, 100, 100);
380        // PSNR for constant difference of 10: 10 * log10(255^2 / 100) ≈ 28.13
381        assert!(psnr > 28.0);
382        assert!(psnr < 29.0);
383    }
384
385    #[test]
386    fn test_metric_config_all() {
387        let config = MetricConfig::all();
388        assert!(config.dssim);
389        assert!(config.psnr);
390    }
391
392    #[test]
393    fn test_metric_config_fast() {
394        let config = MetricConfig::fast();
395        assert!(!config.dssim);
396        assert!(config.psnr);
397    }
398}