Skip to main content

codec_eval/eval/
helpers.rs

1//! Lightweight evaluation helpers for codec testing.
2//!
3//! These helpers provide simple APIs for common use cases:
4//! - Evaluate a single encoded image against a reference
5//! - Assert quality thresholds in CI tests
6//! - Quick quality checks during development
7//!
8//! # Example
9//!
10//! ```rust,ignore
11//! use codec_eval::eval::helpers::{evaluate_single, assert_quality};
12//! use codec_eval::metrics::MetricConfig;
13//! use imgref::ImgVec;
14//! use rgb::RGB8;
15//!
16//! # fn encode_image(img: &ImgVec<RGB8>) -> Vec<u8> { vec![] }
17//! # fn decode_image(data: &[u8]) -> ImgVec<RGB8> { ImgVec::new(vec![], 8, 8) }
18//! // Evaluate quality
19//! let reference: ImgVec<RGB8> = // ...
20//! # ImgVec::new(vec![], 8, 8);
21//! let encoded_data = encode_image(&reference);
22//! let decoded = decode_image(&encoded_data);
23//!
24//! let config = MetricConfig::perceptual();
25//! let result = evaluate_single(&reference, &decoded, &config).unwrap();
26//!
27//! println!("DSSIM: {:?}", result.dssim);
28//! println!("SSIMULACRA2: {:?}", result.ssimulacra2);
29//!
30//! // Assert quality in tests
31//! assert_quality(&reference, &decoded, Some(80.0), Some(0.002)).unwrap();
32//! ```
33
34use crate::error::{Error, Result};
35use crate::metrics::{
36    self, MetricConfig, MetricResult, PerceptionLevel, butteraugli, dssim, ssimulacra2,
37};
38use crate::viewing::ViewingCondition;
39use imgref::ImgVec;
40use rgb::{RGB8, RGBA};
41
42/// Convert RGB8 image to RGBA<f32> with linear RGB values.
43///
44/// This applies sRGB gamma decoding (sRGB → linear RGB) and adds alpha = 1.0.
45fn rgb8_to_rgba_f32(img: &ImgVec<RGB8>) -> ImgVec<RGBA<f32>> {
46    let pixels: Vec<RGBA<f32>> = img
47        .pixels()
48        .map(|p| {
49            let r = srgb_to_linear(p.r);
50            let g = srgb_to_linear(p.g);
51            let b = srgb_to_linear(p.b);
52            RGBA::new(r, g, b, 1.0)
53        })
54        .collect();
55    ImgVec::new(pixels, img.width(), img.height())
56}
57
58/// Apply sRGB gamma decoding (sRGB u8 → linear f32).
59#[inline]
60fn srgb_to_linear(srgb: u8) -> f32 {
61    let s = f32::from(srgb) / 255.0;
62    if s <= 0.04045 {
63        s / 12.92
64    } else {
65        ((s + 0.055) / 1.055).powf(2.4)
66    }
67}
68
69/// Evaluate a single encoded image against a reference.
70///
71/// This is a convenience wrapper around the individual metric calculation functions.
72/// It's designed for simple use cases where you want to quickly evaluate the quality
73/// of an encoded image without setting up a full `EvalSession`.
74///
75/// # Arguments
76///
77/// * `reference` - Reference image (original)
78/// * `encoded` - Encoded/decoded image to compare
79/// * `config` - Which metrics to calculate
80///
81/// # Returns
82///
83/// `MetricResult` containing the calculated metric values.
84///
85/// # Errors
86///
87/// Returns an error if:
88/// - Images have different dimensions
89/// - Images are too small for the metric (minimum 8x8 for butteraugli)
90/// - Metric calculation fails
91///
92/// # Example
93///
94/// ```rust,ignore
95/// use codec_eval::eval::helpers::evaluate_single;
96/// use codec_eval::metrics::MetricConfig;
97///
98/// let config = MetricConfig::perceptual();
99/// let result = evaluate_single(&reference, &encoded, &config)?;
100///
101/// if let Some(dssim) = result.dssim {
102///     println!("DSSIM: {:.6}", dssim);
103/// }
104/// ```
105pub fn evaluate_single(
106    reference: &ImgVec<RGB8>,
107    encoded: &ImgVec<RGB8>,
108    config: &MetricConfig,
109) -> Result<MetricResult> {
110    // Validate dimensions match
111    if reference.width() != encoded.width() || reference.height() != encoded.height() {
112        return Err(Error::DimensionMismatch {
113            expected: (reference.width(), reference.height()),
114            actual: (encoded.width(), encoded.height()),
115        });
116    }
117
118    let width = reference.width();
119    let height = reference.height();
120
121    // Apply XYB roundtrip to reference if requested
122    let reference_img: ImgVec<RGB8>;
123    let reference_final = if config.xyb_roundtrip {
124        let ref_bytes: Vec<u8> = reference.pixels().flat_map(|p| [p.r, p.g, p.b]).collect();
125        let roundtripped = metrics::xyb_roundtrip(&ref_bytes, width, height);
126        let pixels: Vec<RGB8> = roundtripped
127            .chunks_exact(3)
128            .map(|chunk| RGB8::new(chunk[0], chunk[1], chunk[2]))
129            .collect();
130        reference_img = ImgVec::new(pixels, width, height);
131        &reference_img
132    } else {
133        reference
134    };
135
136    let mut result = MetricResult::default();
137
138    // Calculate requested metrics
139    // DSSIM requires RGBA<f32> format
140    if config.dssim {
141        let ref_rgba = rgb8_to_rgba_f32(reference_final);
142        let enc_rgba = rgb8_to_rgba_f32(encoded);
143        let viewing = ViewingCondition::desktop();
144        result.dssim = Some(dssim::calculate_dssim(&ref_rgba, &enc_rgba, &viewing)?);
145    }
146
147    // SSIMULACRA2 and Butteraugli use raw u8 buffers
148    if config.ssimulacra2 || config.butteraugli || config.psnr {
149        let ref_buf: Vec<u8> = reference_final
150            .pixels()
151            .flat_map(|p| [p.r, p.g, p.b])
152            .collect();
153        let enc_buf: Vec<u8> = encoded.pixels().flat_map(|p| [p.r, p.g, p.b]).collect();
154
155        if config.ssimulacra2 {
156            result.ssimulacra2 = Some(ssimulacra2::calculate_ssimulacra2(
157                &ref_buf, &enc_buf, width, height,
158            )?);
159        }
160
161        if config.butteraugli {
162            result.butteraugli = Some(butteraugli::calculate_butteraugli(
163                &ref_buf, &enc_buf, width, height,
164            )?);
165        }
166
167        if config.psnr {
168            result.psnr = Some(metrics::calculate_psnr(&ref_buf, &enc_buf, width, height));
169        }
170    }
171
172    Ok(result)
173}
174
175/// Assert that quality meets specified thresholds.
176///
177/// This is designed for use in CI tests and benchmarks. It calculates quality
178/// metrics and fails if they don't meet the specified thresholds.
179///
180/// # Arguments
181///
182/// * `reference` - Reference image (original)
183/// * `encoded` - Encoded/decoded image to compare
184/// * `min_ssimulacra2` - Minimum acceptable SSIMULACRA2 score (optional)
185/// * `max_dssim` - Maximum acceptable DSSIM value (optional)
186///
187/// # Returns
188///
189/// `Ok(())` if quality meets all specified thresholds.
190///
191/// # Errors
192///
193/// Returns an error if:
194/// - Images have different dimensions
195/// - Quality is below the specified thresholds
196/// - Metric calculation fails
197///
198/// # Example
199///
200/// ```rust,ignore
201/// use codec_eval::eval::helpers::assert_quality;
202///
203/// // Assert SSIMULACRA2 >= 80.0 and DSSIM <= 0.002
204/// assert_quality(&reference, &encoded, Some(80.0), Some(0.002))?;
205///
206/// // Assert only SSIMULACRA2 >= 90.0
207/// assert_quality(&reference, &encoded, Some(90.0), None)?;
208///
209/// // Assert only DSSIM <= 0.001
210/// assert_quality(&reference, &encoded, None, Some(0.001))?;
211/// ```
212pub fn assert_quality(
213    reference: &ImgVec<RGB8>,
214    encoded: &ImgVec<RGB8>,
215    min_ssimulacra2: Option<f64>,
216    max_dssim: Option<f64>,
217) -> Result<()> {
218    // Build config based on what thresholds are specified
219    let config = MetricConfig {
220        dssim: max_dssim.is_some(),
221        ssimulacra2: min_ssimulacra2.is_some(),
222        butteraugli: false,
223        psnr: false,
224        xyb_roundtrip: false,
225    };
226
227    let result = evaluate_single(reference, encoded, &config)?;
228
229    // Check thresholds
230    if let Some(threshold) = min_ssimulacra2 {
231        if let Some(score) = result.ssimulacra2 {
232            if score < threshold {
233                return Err(Error::QualityBelowThreshold {
234                    metric: "SSIMULACRA2".to_string(),
235                    value: score,
236                    threshold,
237                });
238            }
239        }
240    }
241
242    if let Some(threshold) = max_dssim {
243        if let Some(score) = result.dssim {
244            if score > threshold {
245                return Err(Error::QualityBelowThreshold {
246                    metric: "DSSIM".to_string(),
247                    value: score,
248                    threshold,
249                });
250            }
251        }
252    }
253
254    Ok(())
255}
256
257/// Assert that quality is at the specified perception level or better.
258///
259/// This is a more semantic way to assert quality thresholds based on
260/// perceptual categories rather than raw metric values.
261///
262/// # Arguments
263///
264/// * `reference` - Reference image (original)
265/// * `encoded` - Encoded/decoded image to compare
266/// * `min_level` - Minimum acceptable perception level
267///
268/// # Returns
269///
270/// `Ok(())` if quality is at the specified level or better.
271///
272/// # Errors
273///
274/// Returns an error if:
275/// - Images have different dimensions
276/// - Quality is below the specified perception level
277/// - Metric calculation fails
278///
279/// # Example
280///
281/// ```rust,ignore
282/// use codec_eval::eval::helpers::assert_perception_level;
283/// use codec_eval::metrics::PerceptionLevel;
284///
285/// // Assert quality is at least "Subtle" (DSSIM < 0.0015)
286/// assert_perception_level(&reference, &encoded, PerceptionLevel::Subtle)?;
287///
288/// // Assert quality is "Imperceptible" (DSSIM < 0.0003)
289/// assert_perception_level(&reference, &encoded, PerceptionLevel::Imperceptible)?;
290/// ```
291pub fn assert_perception_level(
292    reference: &ImgVec<RGB8>,
293    encoded: &ImgVec<RGB8>,
294    min_level: PerceptionLevel,
295) -> Result<()> {
296    let config = MetricConfig {
297        dssim: true,
298        ssimulacra2: false,
299        butteraugli: false,
300        psnr: false,
301        xyb_roundtrip: false,
302    };
303
304    let result = evaluate_single(reference, encoded, &config)?;
305
306    if let Some(dssim) = result.dssim {
307        let actual_level = PerceptionLevel::from_dssim(dssim);
308        let min_level_value = min_level as u8;
309        let actual_level_value = actual_level as u8;
310
311        if actual_level_value > min_level_value {
312            return Err(Error::QualityBelowThreshold {
313                metric: format!("PerceptionLevel (DSSIM {dssim:.6})"),
314                value: actual_level_value.into(),
315                threshold: min_level_value.into(),
316            });
317        }
318    }
319
320    Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    fn create_test_image(width: usize, height: usize, pattern: u8) -> ImgVec<RGB8> {
328        let pixels: Vec<RGB8> = (0..width * height)
329            .map(|i| {
330                let base = (i + usize::from(pattern)) % 256;
331                RGB8::new(base as u8, (base + 50) as u8, (base + 100) as u8)
332            })
333            .collect();
334        ImgVec::new(pixels, width, height)
335    }
336
337    #[test]
338    fn test_evaluate_single_identical() {
339        let img = create_test_image(64, 64, 0);
340        let config = MetricConfig::perceptual();
341
342        let result = evaluate_single(&img, &img, &config).unwrap();
343
344        // Identical images should have perfect scores
345        assert!(result.dssim.unwrap() < 0.0001);
346        assert!(result.ssimulacra2.unwrap() > 99.0);
347        assert!(result.butteraugli.unwrap() < 0.1);
348    }
349
350    #[test]
351    fn test_evaluate_single_dimension_mismatch() {
352        let img1 = create_test_image(64, 64, 0);
353        let img2 = create_test_image(32, 32, 0);
354        let config = MetricConfig::perceptual();
355
356        let result = evaluate_single(&img1, &img2, &config);
357        assert!(result.is_err());
358    }
359
360    #[test]
361    fn test_assert_quality_pass() {
362        let img = create_test_image(64, 64, 0);
363
364        // Identical images should easily pass these thresholds
365        assert!(assert_quality(&img, &img, Some(90.0), Some(0.001)).is_ok());
366    }
367
368    #[test]
369    fn test_assert_quality_fail_ssimulacra2() {
370        let img1 = create_test_image(64, 64, 0);
371        let img2 = create_test_image(64, 64, 50);
372
373        // Different images won't meet high SSIMULACRA2 threshold
374        assert!(assert_quality(&img1, &img2, Some(99.0), None).is_err());
375    }
376
377    #[test]
378    fn test_assert_perception_level() {
379        let img = create_test_image(64, 64, 0);
380
381        // Identical images should be imperceptible
382        assert!(assert_perception_level(&img, &img, PerceptionLevel::Imperceptible).is_ok());
383    }
384}