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, butteraugli, dssim, ssimulacra2, MetricConfig, MetricResult, PerceptionLevel,
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
125            .pixels()
126            .flat_map(|p| [p.r, p.g, p.b])
127            .collect();
128        let roundtripped = metrics::xyb_roundtrip(&ref_bytes, width, height);
129        let pixels: Vec<RGB8> = roundtripped
130            .chunks_exact(3)
131            .map(|chunk| RGB8::new(chunk[0], chunk[1], chunk[2]))
132            .collect();
133        reference_img = ImgVec::new(pixels, width, height);
134        &reference_img
135    } else {
136        reference
137    };
138
139    let mut result = MetricResult::default();
140
141    // Calculate requested metrics
142    // DSSIM requires RGBA<f32> format
143    if config.dssim {
144        let ref_rgba = rgb8_to_rgba_f32(reference_final);
145        let enc_rgba = rgb8_to_rgba_f32(encoded);
146        let viewing = ViewingCondition::desktop();
147        result.dssim = Some(dssim::calculate_dssim(&ref_rgba, &enc_rgba, &viewing)?);
148    }
149
150    // SSIMULACRA2 and Butteraugli use raw u8 buffers
151    if config.ssimulacra2 || config.butteraugli || config.psnr {
152        let ref_buf: Vec<u8> = reference_final
153            .pixels()
154            .flat_map(|p| [p.r, p.g, p.b])
155            .collect();
156        let enc_buf: Vec<u8> = encoded.pixels().flat_map(|p| [p.r, p.g, p.b]).collect();
157
158        if config.ssimulacra2 {
159            result.ssimulacra2 =
160                Some(ssimulacra2::calculate_ssimulacra2(&ref_buf, &enc_buf, width, height)?);
161        }
162
163        if config.butteraugli {
164            result.butteraugli =
165                Some(butteraugli::calculate_butteraugli(&ref_buf, &enc_buf, width, height)?);
166        }
167
168        if config.psnr {
169            result.psnr = Some(metrics::calculate_psnr(&ref_buf, &enc_buf, width, height));
170        }
171    }
172
173    Ok(result)
174}
175
176/// Assert that quality meets specified thresholds.
177///
178/// This is designed for use in CI tests and benchmarks. It calculates quality
179/// metrics and fails if they don't meet the specified thresholds.
180///
181/// # Arguments
182///
183/// * `reference` - Reference image (original)
184/// * `encoded` - Encoded/decoded image to compare
185/// * `min_ssimulacra2` - Minimum acceptable SSIMULACRA2 score (optional)
186/// * `max_dssim` - Maximum acceptable DSSIM value (optional)
187///
188/// # Returns
189///
190/// `Ok(())` if quality meets all specified thresholds.
191///
192/// # Errors
193///
194/// Returns an error if:
195/// - Images have different dimensions
196/// - Quality is below the specified thresholds
197/// - Metric calculation fails
198///
199/// # Example
200///
201/// ```rust,ignore
202/// use codec_eval::eval::helpers::assert_quality;
203///
204/// // Assert SSIMULACRA2 >= 80.0 and DSSIM <= 0.002
205/// assert_quality(&reference, &encoded, Some(80.0), Some(0.002))?;
206///
207/// // Assert only SSIMULACRA2 >= 90.0
208/// assert_quality(&reference, &encoded, Some(90.0), None)?;
209///
210/// // Assert only DSSIM <= 0.001
211/// assert_quality(&reference, &encoded, None, Some(0.001))?;
212/// ```
213pub fn assert_quality(
214    reference: &ImgVec<RGB8>,
215    encoded: &ImgVec<RGB8>,
216    min_ssimulacra2: Option<f64>,
217    max_dssim: Option<f64>,
218) -> Result<()> {
219    // Build config based on what thresholds are specified
220    let config = MetricConfig {
221        dssim: max_dssim.is_some(),
222        ssimulacra2: min_ssimulacra2.is_some(),
223        butteraugli: false,
224        psnr: false,
225        xyb_roundtrip: false,
226    };
227
228    let result = evaluate_single(reference, encoded, &config)?;
229
230    // Check thresholds
231    if let Some(threshold) = min_ssimulacra2 {
232        if let Some(score) = result.ssimulacra2 {
233            if score < threshold {
234                return Err(Error::QualityBelowThreshold {
235                    metric: "SSIMULACRA2".to_string(),
236                    value: score,
237                    threshold,
238                });
239            }
240        }
241    }
242
243    if let Some(threshold) = max_dssim {
244        if let Some(score) = result.dssim {
245            if score > threshold {
246                return Err(Error::QualityBelowThreshold {
247                    metric: "DSSIM".to_string(),
248                    value: score,
249                    threshold,
250                });
251            }
252        }
253    }
254
255    Ok(())
256}
257
258/// Assert that quality is at the specified perception level or better.
259///
260/// This is a more semantic way to assert quality thresholds based on
261/// perceptual categories rather than raw metric values.
262///
263/// # Arguments
264///
265/// * `reference` - Reference image (original)
266/// * `encoded` - Encoded/decoded image to compare
267/// * `min_level` - Minimum acceptable perception level
268///
269/// # Returns
270///
271/// `Ok(())` if quality is at the specified level or better.
272///
273/// # Errors
274///
275/// Returns an error if:
276/// - Images have different dimensions
277/// - Quality is below the specified perception level
278/// - Metric calculation fails
279///
280/// # Example
281///
282/// ```rust,ignore
283/// use codec_eval::eval::helpers::assert_perception_level;
284/// use codec_eval::metrics::PerceptionLevel;
285///
286/// // Assert quality is at least "Subtle" (DSSIM < 0.0015)
287/// assert_perception_level(&reference, &encoded, PerceptionLevel::Subtle)?;
288///
289/// // Assert quality is "Imperceptible" (DSSIM < 0.0003)
290/// assert_perception_level(&reference, &encoded, PerceptionLevel::Imperceptible)?;
291/// ```
292pub fn assert_perception_level(
293    reference: &ImgVec<RGB8>,
294    encoded: &ImgVec<RGB8>,
295    min_level: PerceptionLevel,
296) -> Result<()> {
297    let config = MetricConfig {
298        dssim: true,
299        ssimulacra2: false,
300        butteraugli: false,
301        psnr: false,
302        xyb_roundtrip: false,
303    };
304
305    let result = evaluate_single(reference, encoded, &config)?;
306
307    if let Some(dssim) = result.dssim {
308        let actual_level = PerceptionLevel::from_dssim(dssim);
309        let min_level_value = min_level as u8;
310        let actual_level_value = actual_level as u8;
311
312        if actual_level_value > min_level_value {
313            return Err(Error::QualityBelowThreshold {
314                metric: format!("PerceptionLevel (DSSIM {dssim:.6})"),
315                value: actual_level_value.into(),
316                threshold: min_level_value.into(),
317            });
318        }
319    }
320
321    Ok(())
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn create_test_image(width: usize, height: usize, pattern: u8) -> ImgVec<RGB8> {
329        let pixels: Vec<RGB8> = (0..width * height)
330            .map(|i| {
331                let base = (i + usize::from(pattern)) % 256;
332                RGB8::new(base as u8, (base + 50) as u8, (base + 100) as u8)
333            })
334            .collect();
335        ImgVec::new(pixels, width, height)
336    }
337
338    #[test]
339    fn test_evaluate_single_identical() {
340        let img = create_test_image(64, 64, 0);
341        let config = MetricConfig::perceptual();
342
343        let result = evaluate_single(&img, &img, &config).unwrap();
344
345        // Identical images should have perfect scores
346        assert!(result.dssim.unwrap() < 0.0001);
347        assert!(result.ssimulacra2.unwrap() > 99.0);
348        assert!(result.butteraugli.unwrap() < 0.1);
349    }
350
351    #[test]
352    fn test_evaluate_single_dimension_mismatch() {
353        let img1 = create_test_image(64, 64, 0);
354        let img2 = create_test_image(32, 32, 0);
355        let config = MetricConfig::perceptual();
356
357        let result = evaluate_single(&img1, &img2, &config);
358        assert!(result.is_err());
359    }
360
361    #[test]
362    fn test_assert_quality_pass() {
363        let img = create_test_image(64, 64, 0);
364
365        // Identical images should easily pass these thresholds
366        assert!(assert_quality(&img, &img, Some(90.0), Some(0.001)).is_ok());
367    }
368
369    #[test]
370    fn test_assert_quality_fail_ssimulacra2() {
371        let img1 = create_test_image(64, 64, 0);
372        let img2 = create_test_image(64, 64, 50);
373
374        // Different images won't meet high SSIMULACRA2 threshold
375        assert!(assert_quality(&img1, &img2, Some(99.0), None).is_err());
376    }
377
378    #[test]
379    fn test_assert_perception_level() {
380        let img = create_test_image(64, 64, 0);
381
382        // Identical images should be imperceptible
383        assert!(assert_perception_level(&img, &img, PerceptionLevel::Imperceptible).is_ok());
384    }
385}