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 ssimulacra2;
32
33use serde::{Deserialize, Serialize};
34
35/// Configuration for which metrics to calculate.
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct MetricConfig {
38    /// Calculate DSSIM (structural dissimilarity).
39    pub dssim: bool,
40    /// Calculate SSIMULACRA2 (perceptual similarity, higher is better).
41    pub ssimulacra2: bool,
42    /// Calculate Butteraugli (perceptual difference, lower is better).
43    pub butteraugli: bool,
44    /// Calculate PSNR (peak signal-to-noise ratio). NOT RECOMMENDED.
45    pub psnr: bool,
46}
47
48impl MetricConfig {
49    /// Calculate all available metrics.
50    #[must_use]
51    pub fn all() -> Self {
52        Self {
53            dssim: true,
54            ssimulacra2: true,
55            butteraugli: true,
56            psnr: true,
57        }
58    }
59
60    /// Fast metric set (PSNR only). NOT RECOMMENDED for quality comparison.
61    #[must_use]
62    pub fn fast() -> Self {
63        Self {
64            dssim: false,
65            ssimulacra2: false,
66            butteraugli: false,
67            psnr: true,
68        }
69    }
70
71    /// Perceptual metrics only (DSSIM, SSIMULACRA2, Butteraugli). RECOMMENDED.
72    #[must_use]
73    pub fn perceptual() -> Self {
74        Self {
75            dssim: true,
76            ssimulacra2: true,
77            butteraugli: true,
78            psnr: false,
79        }
80    }
81
82    /// SSIMULACRA2 only - good balance of speed and accuracy.
83    #[must_use]
84    pub fn ssimulacra2_only() -> Self {
85        Self {
86            dssim: false,
87            ssimulacra2: true,
88            butteraugli: false,
89            psnr: false,
90        }
91    }
92}
93
94/// Results from metric calculations.
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96pub struct MetricResult {
97    /// DSSIM value (lower is better, 0 = identical).
98    pub dssim: Option<f64>,
99    /// SSIMULACRA2 score (higher is better, 100 = identical).
100    pub ssimulacra2: Option<f64>,
101    /// Butteraugli score (lower is better, <1.0 = imperceptible).
102    pub butteraugli: Option<f64>,
103    /// PSNR value in dB (higher is better). NOT RECOMMENDED.
104    pub psnr: Option<f64>,
105}
106
107impl MetricResult {
108    /// Get the perception level based on DSSIM value.
109    #[must_use]
110    pub fn perception_level(&self) -> Option<PerceptionLevel> {
111        self.dssim.map(PerceptionLevel::from_dssim)
112    }
113
114    /// Get the perception level based on SSIMULACRA2 value.
115    #[must_use]
116    pub fn perception_level_ssimulacra2(&self) -> Option<PerceptionLevel> {
117        self.ssimulacra2.map(PerceptionLevel::from_ssimulacra2)
118    }
119
120    /// Get the perception level based on Butteraugli value.
121    #[must_use]
122    pub fn perception_level_butteraugli(&self) -> Option<PerceptionLevel> {
123        self.butteraugli.map(PerceptionLevel::from_butteraugli)
124    }
125}
126
127/// Perceptual quality level based on metric thresholds.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129pub enum PerceptionLevel {
130    /// DSSIM < 0.0003 - Visually identical.
131    Imperceptible,
132    /// DSSIM < 0.0007 - Only A/B comparison reveals difference.
133    Marginal,
134    /// DSSIM < 0.0015 - Barely noticeable.
135    Subtle,
136    /// DSSIM < 0.003 - Visible on inspection.
137    Noticeable,
138    /// DSSIM >= 0.003 - Clearly visible artifacts.
139    Degraded,
140}
141
142impl PerceptionLevel {
143    /// Determine perception level from DSSIM value.
144    #[must_use]
145    pub fn from_dssim(dssim: f64) -> Self {
146        if dssim < 0.0003 {
147            Self::Imperceptible
148        } else if dssim < 0.0007 {
149            Self::Marginal
150        } else if dssim < 0.0015 {
151            Self::Subtle
152        } else if dssim < 0.003 {
153            Self::Noticeable
154        } else {
155            Self::Degraded
156        }
157    }
158
159    /// Determine perception level from SSIMULACRA2 value.
160    /// SSIMULACRA2 is higher-is-better (100 = identical).
161    #[must_use]
162    pub fn from_ssimulacra2(score: f64) -> Self {
163        if score > 90.0 {
164            Self::Imperceptible
165        } else if score > 80.0 {
166            Self::Marginal
167        } else if score > 70.0 {
168            Self::Subtle
169        } else if score > 50.0 {
170            Self::Noticeable
171        } else {
172            Self::Degraded
173        }
174    }
175
176    /// Determine perception level from Butteraugli value.
177    /// Butteraugli is lower-is-better (<1.0 = imperceptible).
178    #[must_use]
179    pub fn from_butteraugli(score: f64) -> Self {
180        if score < 1.0 {
181            Self::Imperceptible
182        } else if score < 2.0 {
183            Self::Marginal
184        } else if score < 3.0 {
185            Self::Subtle
186        } else if score < 5.0 {
187            Self::Noticeable
188        } else {
189            Self::Degraded
190        }
191    }
192
193    /// Get the maximum DSSIM value for this perception level.
194    #[must_use]
195    pub fn max_dssim(self) -> f64 {
196        match self {
197            Self::Imperceptible => 0.0003,
198            Self::Marginal => 0.0007,
199            Self::Subtle => 0.0015,
200            Self::Noticeable => 0.003,
201            Self::Degraded => f64::INFINITY,
202        }
203    }
204
205    /// Get the minimum SSIMULACRA2 value for this perception level.
206    #[must_use]
207    pub fn min_ssimulacra2(self) -> f64 {
208        match self {
209            Self::Imperceptible => 90.0,
210            Self::Marginal => 80.0,
211            Self::Subtle => 70.0,
212            Self::Noticeable => 50.0,
213            Self::Degraded => f64::NEG_INFINITY,
214        }
215    }
216
217    /// Get the maximum Butteraugli value for this perception level.
218    #[must_use]
219    pub fn max_butteraugli(self) -> f64 {
220        match self {
221            Self::Imperceptible => 1.0,
222            Self::Marginal => 2.0,
223            Self::Subtle => 3.0,
224            Self::Noticeable => 5.0,
225            Self::Degraded => f64::INFINITY,
226        }
227    }
228
229    /// Get a short code for this level.
230    #[must_use]
231    pub fn code(self) -> &'static str {
232        match self {
233            Self::Imperceptible => "IMP",
234            Self::Marginal => "MAR",
235            Self::Subtle => "SUB",
236            Self::Noticeable => "NOT",
237            Self::Degraded => "DEG",
238        }
239    }
240}
241
242impl std::fmt::Display for PerceptionLevel {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Self::Imperceptible => write!(f, "Imperceptible"),
246            Self::Marginal => write!(f, "Marginal"),
247            Self::Subtle => write!(f, "Subtle"),
248            Self::Noticeable => write!(f, "Noticeable"),
249            Self::Degraded => write!(f, "Degraded"),
250        }
251    }
252}
253
254/// Calculate PSNR between two images.
255///
256/// # Arguments
257///
258/// * `reference` - Reference image pixel data (RGB8, row-major).
259/// * `test` - Test image pixel data (RGB8, row-major).
260/// * `width` - Image width in pixels.
261/// * `height` - Image height in pixels.
262///
263/// # Returns
264///
265/// PSNR value in decibels. Higher is better. Returns `f64::INFINITY` if
266/// images are identical.
267#[must_use]
268pub fn calculate_psnr(reference: &[u8], test: &[u8], width: usize, height: usize) -> f64 {
269    assert_eq!(reference.len(), test.len());
270    assert_eq!(reference.len(), width * height * 3);
271
272    let mut mse_sum: f64 = 0.0;
273    let pixel_count = (width * height * 3) as f64;
274
275    for (r, t) in reference.iter().zip(test.iter()) {
276        let diff = f64::from(*r) - f64::from(*t);
277        mse_sum += diff * diff;
278    }
279
280    let mse = mse_sum / pixel_count;
281
282    if mse == 0.0 {
283        f64::INFINITY
284    } else {
285        10.0 * (255.0_f64 * 255.0 / mse).log10()
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_perception_level_thresholds() {
295        assert_eq!(
296            PerceptionLevel::from_dssim(0.0001),
297            PerceptionLevel::Imperceptible
298        );
299        assert_eq!(
300            PerceptionLevel::from_dssim(0.0003),
301            PerceptionLevel::Marginal
302        );
303        assert_eq!(
304            PerceptionLevel::from_dssim(0.0005),
305            PerceptionLevel::Marginal
306        );
307        assert_eq!(PerceptionLevel::from_dssim(0.0007), PerceptionLevel::Subtle);
308        assert_eq!(PerceptionLevel::from_dssim(0.001), PerceptionLevel::Subtle);
309        assert_eq!(
310            PerceptionLevel::from_dssim(0.0015),
311            PerceptionLevel::Noticeable
312        );
313        assert_eq!(
314            PerceptionLevel::from_dssim(0.002),
315            PerceptionLevel::Noticeable
316        );
317        assert_eq!(
318            PerceptionLevel::from_dssim(0.003),
319            PerceptionLevel::Degraded
320        );
321        assert_eq!(PerceptionLevel::from_dssim(0.01), PerceptionLevel::Degraded);
322    }
323
324    #[test]
325    fn test_psnr_identical() {
326        let data = vec![128u8; 100 * 100 * 3];
327        let psnr = calculate_psnr(&data, &data, 100, 100);
328        assert!(psnr.is_infinite());
329    }
330
331    #[test]
332    fn test_psnr_different() {
333        let reference = vec![100u8; 100 * 100 * 3];
334        let test = vec![110u8; 100 * 100 * 3];
335        let psnr = calculate_psnr(&reference, &test, 100, 100);
336        // PSNR for constant difference of 10: 10 * log10(255^2 / 100) ≈ 28.13
337        assert!(psnr > 28.0);
338        assert!(psnr < 29.0);
339    }
340
341    #[test]
342    fn test_metric_config_all() {
343        let config = MetricConfig::all();
344        assert!(config.dssim);
345        assert!(config.psnr);
346    }
347
348    #[test]
349    fn test_metric_config_fast() {
350        let config = MetricConfig::fast();
351        assert!(!config.dssim);
352        assert!(config.psnr);
353    }
354}