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}