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}