Skip to main content

codec_eval/metrics/
butteraugli.rs

1//! Butteraugli metric calculation.
2//!
3//! Butteraugli is a perceptual image quality metric developed by Google.
4//! Lower scores indicate better quality (more similar to reference).
5//!
6//! Score interpretation:
7//! - < 1.0: Imperceptible difference
8//! - < 2.0: Marginal difference
9//! - < 3.0: Subtle difference
10//! - < 5.0: Noticeable difference
11//! - >= 5.0: Degraded
12//!
13//! # ICC Profile Support
14//!
15//! Use [`calculate_butteraugli_icc`] for images with non-sRGB color profiles.
16
17use butteraugli::{ButteraugliParams, Img, RGB8, butteraugli as butteraugli_compare};
18
19use super::icc::ColorProfile;
20use crate::error::{Error, Result};
21
22/// Convert raw RGB bytes to Vec<RGB8>.
23fn rgb_bytes_to_pixels(rgb: &[u8]) -> Vec<RGB8> {
24    rgb.chunks_exact(3)
25        .map(|c| RGB8::new(c[0], c[1], c[2]))
26        .collect()
27}
28
29/// Calculate Butteraugli distance between two images.
30///
31/// # Arguments
32///
33/// * `reference` - Reference image as RGB8 pixel data.
34/// * `test` - Test image as RGB8 pixel data.
35/// * `width` - Image width in pixels.
36/// * `height` - Image height in pixels.
37///
38/// # Returns
39///
40/// Butteraugli score where lower is better (<1.0 = imperceptible).
41///
42/// # Errors
43///
44/// Returns an error if the images have different sizes or if calculation fails.
45pub fn calculate_butteraugli(
46    reference: &[u8],
47    test: &[u8],
48    width: usize,
49    height: usize,
50) -> Result<f64> {
51    if reference.len() != test.len() {
52        return Err(Error::DimensionMismatch {
53            expected: (width, height),
54            actual: (test.len() / 3 / height, height),
55        });
56    }
57
58    let expected_len = width * height * 3;
59    if reference.len() != expected_len {
60        return Err(Error::MetricCalculation {
61            metric: "Butteraugli".to_string(),
62            reason: format!(
63                "Invalid image size: expected {} bytes, got {}",
64                expected_len,
65                reference.len()
66            ),
67        });
68    }
69
70    let img1 = Img::new(rgb_bytes_to_pixels(reference), width, height);
71    let img2 = Img::new(rgb_bytes_to_pixels(test), width, height);
72    let params = ButteraugliParams::default();
73    let result = butteraugli_compare(img1.as_ref(), img2.as_ref(), &params).map_err(|e| {
74        Error::MetricCalculation {
75            metric: "Butteraugli".to_string(),
76            reason: e.to_string(),
77        }
78    })?;
79
80    Ok(result.score)
81}
82
83/// Calculate Butteraugli with custom intensity target.
84///
85/// The intensity target affects how the metric perceives differences
86/// at different brightness levels.
87///
88/// # Arguments
89///
90/// * `reference` - Reference image as RGB8 pixel data.
91/// * `test` - Test image as RGB8 pixel data.
92/// * `width` - Image width in pixels.
93/// * `height` - Image height in pixels.
94/// * `intensity_target` - Target display intensity in nits (default: 80.0).
95///
96/// # Returns
97///
98/// Butteraugli score where lower is better.
99pub fn calculate_butteraugli_with_intensity(
100    reference: &[u8],
101    test: &[u8],
102    width: usize,
103    height: usize,
104    intensity_target: f32,
105) -> Result<f64> {
106    if reference.len() != test.len() {
107        return Err(Error::DimensionMismatch {
108            expected: (width, height),
109            actual: (test.len() / 3 / height, height),
110        });
111    }
112
113    let expected_len = width * height * 3;
114    if reference.len() != expected_len {
115        return Err(Error::MetricCalculation {
116            metric: "Butteraugli".to_string(),
117            reason: format!(
118                "Invalid image size: expected {} bytes, got {}",
119                expected_len,
120                reference.len()
121            ),
122        });
123    }
124
125    let img1 = Img::new(rgb_bytes_to_pixels(reference), width, height);
126    let img2 = Img::new(rgb_bytes_to_pixels(test), width, height);
127    let params = ButteraugliParams::default().with_intensity_target(intensity_target);
128    let result = butteraugli_compare(img1.as_ref(), img2.as_ref(), &params).map_err(|e| {
129        Error::MetricCalculation {
130            metric: "Butteraugli".to_string(),
131            reason: e.to_string(),
132        }
133    })?;
134
135    Ok(result.score)
136}
137
138/// Calculate Butteraugli with ICC profile support.
139///
140/// This function transforms both images to sRGB before comparison.
141///
142/// # Arguments
143///
144/// * `reference` - Reference image as RGB8 pixel data.
145/// * `reference_profile` - Color profile of the reference image.
146/// * `test` - Test image as RGB8 pixel data.
147/// * `test_profile` - Color profile of the test image.
148/// * `width` - Image width in pixels.
149/// * `height` - Image height in pixels.
150pub fn calculate_butteraugli_icc(
151    reference: &[u8],
152    reference_profile: &ColorProfile,
153    test: &[u8],
154    test_profile: &ColorProfile,
155    width: usize,
156    height: usize,
157) -> Result<f64> {
158    let (ref_srgb, test_srgb) =
159        super::icc::prepare_for_comparison(reference, reference_profile, test, test_profile)?;
160
161    calculate_butteraugli(&ref_srgb, &test_srgb, width, height)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_identical_images() {
170        let data: Vec<u8> = (0..100 * 100 * 3).map(|i| (i % 256) as u8).collect();
171        let score = calculate_butteraugli(&data, &data, 100, 100).unwrap();
172        // Identical images should have score close to 0
173        assert!(
174            score < 0.01,
175            "Identical images should have score ~0, got {score}"
176        );
177    }
178
179    #[test]
180    fn test_different_images() {
181        let ref_data: Vec<u8> = vec![100u8; 100 * 100 * 3];
182        let test_data: Vec<u8> = vec![200u8; 100 * 100 * 3];
183        let score = calculate_butteraugli(&ref_data, &test_data, 100, 100).unwrap();
184        // Very different images should have high score
185        assert!(
186            score > 1.0,
187            "Very different images should have high score, got {score}"
188        );
189    }
190
191    #[test]
192    fn test_size_mismatch() {
193        let small: Vec<u8> = vec![128u8; 50 * 50 * 3];
194        let large: Vec<u8> = vec![128u8; 100 * 100 * 3];
195        let result = calculate_butteraugli(&small, &large, 100, 100);
196        assert!(result.is_err());
197    }
198
199    #[test]
200    fn test_custom_intensity() {
201        let data: Vec<u8> = (0..100 * 100 * 3).map(|i| (i % 256) as u8).collect();
202        let score = calculate_butteraugli_with_intensity(&data, &data, 100, 100, 250.0).unwrap();
203        assert!(
204            score < 0.01,
205            "Identical images should have score ~0 at any intensity"
206        );
207    }
208}