butteraugli_oxide/
lib.rs

1//! # Butteraugli
2//!
3//! Butteraugli is a perceptual image quality metric developed by Google.
4//! This is a Rust port of the butteraugli algorithm from libjxl.
5//!
6//! The metric is based on:
7//! - Opsin: dynamics of photosensitive chemicals in the retina
8//! - XYB: hybrid opponent/trichromatic color space
9//! - Visual masking: how features hide other features
10//! - Multi-scale analysis: UHF, HF, MF, LF frequency components
11//!
12//! ## Quality Thresholds
13//!
14//! - Score < 1.0: Images are perceived as identical
15//! - Score 1.0-2.0: Subtle differences may be noticeable
16//! - Score > 2.0: Visible difference between images
17//!
18//! ## Example
19//!
20//! ```rust
21//! use butteraugli_oxide::{compute_butteraugli, ButteraugliParams};
22//!
23//! // Create two 8x8 RGB images (must be 8x8 minimum)
24//! let width = 8;
25//! let height = 8;
26//! let rgb1: Vec<u8> = (0..width * height * 3).map(|i| (i % 256) as u8).collect();
27//! let rgb2 = rgb1.clone(); // Identical images
28//!
29//! let params = ButteraugliParams::default();
30//! let result = compute_butteraugli(&rgb1, &rgb2, width, height, &params).unwrap();
31//!
32//! // Identical images should have score ~0
33//! assert!(result.score < 0.01);
34//! ```
35//!
36//! ## Comparing Different Images
37//!
38//! ```rust
39//! use butteraugli_oxide::{compute_butteraugli, ButteraugliParams, BUTTERAUGLI_GOOD, BUTTERAUGLI_BAD};
40//!
41//! let width = 16;
42//! let height = 16;
43//!
44//! // Original image - gradient
45//! let original: Vec<u8> = (0..width * height)
46//!     .flat_map(|i| {
47//!         let x = i % width;
48//!         [(x * 16) as u8, 128, 128]
49//!     })
50//!     .collect();
51//!
52//! // Distorted image - add noise
53//! let distorted: Vec<u8> = original.iter()
54//!     .map(|&v| v.saturating_add(10))
55//!     .collect();
56//!
57//! let result = compute_butteraugli(&original, &distorted, width, height, &ButteraugliParams::default())
58//!     .expect("valid image data");
59//!
60//! if result.score < BUTTERAUGLI_GOOD {
61//!     println!("Images appear identical to humans");
62//! } else if result.score > BUTTERAUGLI_BAD {
63//!     println!("Visible difference detected");
64//! }
65//! ```
66//!
67//! ## References
68//!
69//! - <https://github.com/google/butteraugli>
70//! - <https://github.com/libjxl/libjxl>
71
72#![warn(clippy::all)]
73#![warn(clippy::pedantic)]
74#![allow(clippy::module_name_repetitions)]
75#![allow(clippy::similar_names)]
76#![allow(clippy::cast_precision_loss)]
77#![allow(clippy::cast_possible_truncation)]
78#![allow(clippy::cast_sign_loss)]
79// Allow C++ constant formats (ported from libjxl with exact values)
80#![allow(clippy::unreadable_literal)]
81#![allow(clippy::inconsistent_digit_grouping)]
82#![allow(clippy::excessive_precision)]
83// Allow mul_add style from C++ (may affect numerical parity)
84#![allow(clippy::suboptimal_flops)]
85// Allow common patterns in numerical code ported from C++
86#![allow(clippy::many_single_char_names)]
87#![allow(clippy::needless_range_loop)]
88#![allow(clippy::doc_markdown)]
89#![allow(clippy::items_after_statements)]
90#![allow(clippy::manual_saturating_arithmetic)]
91#![allow(clippy::cast_lossless)]
92#![allow(clippy::cast_possible_wrap)]
93// These are nice-to-have but not critical for initial release
94#![allow(clippy::must_use_candidate)]
95#![allow(clippy::missing_const_for_fn)]
96#![allow(clippy::missing_panics_doc)]
97#![allow(clippy::too_many_lines)]
98#![allow(clippy::collapsible_else_if)]
99#![allow(clippy::if_not_else)]
100#![allow(clippy::imprecise_flops)]
101#![allow(clippy::implicit_saturating_sub)]
102#![allow(clippy::useless_let_if_seq)]
103
104// Module structure - expose all for testing parity with C++
105pub mod blur;
106pub mod consts;
107mod diff;
108pub mod image;
109pub mod malta;
110pub mod mask;
111pub mod opsin;
112pub mod psycho;
113pub mod xyb;
114
115// C++ reference data for regression testing (auto-generated)
116// Hidden from docs as this is internal test infrastructure
117#[doc(hidden)]
118pub mod reference_data;
119
120// Re-export main types and functions
121pub use crate::image::{Image3F, ImageF};
122
123/// Error type for butteraugli operations.
124#[derive(Debug, Clone, PartialEq, Eq)]
125#[non_exhaustive]
126pub enum ButteraugliError {
127    /// Image dimensions don't match.
128    DimensionMismatch {
129        /// First image dimensions (width, height).
130        first: (usize, usize),
131        /// Second image dimensions (width, height).
132        second: (usize, usize),
133    },
134    /// Buffer size doesn't match expected size for dimensions.
135    InvalidBufferSize {
136        /// Expected buffer size.
137        expected: usize,
138        /// Actual buffer size.
139        actual: usize,
140    },
141    /// Image dimensions are invalid (zero or too small).
142    InvalidDimensions {
143        /// Width provided.
144        width: usize,
145        /// Height provided.
146        height: usize,
147    },
148}
149
150impl std::fmt::Display for ButteraugliError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            Self::DimensionMismatch { first, second } => {
154                write!(
155                    f,
156                    "image dimensions don't match: {}x{} vs {}x{}",
157                    first.0, first.1, second.0, second.1
158                )
159            }
160            Self::InvalidBufferSize { expected, actual } => {
161                write!(
162                    f,
163                    "buffer size {} doesn't match expected size {}",
164                    actual, expected
165                )
166            }
167            Self::InvalidDimensions { width, height } => {
168                write!(f, "invalid dimensions: {}x{} (minimum 8x8)", width, height)
169            }
170        }
171    }
172}
173
174impl std::error::Error for ButteraugliError {}
175
176/// Butteraugli comparison parameters.
177///
178/// Use the builder pattern to construct:
179/// ```rust
180/// use butteraugli_oxide::ButteraugliParams;
181///
182/// let params = ButteraugliParams::new()
183///     .with_intensity_target(250.0)  // HDR display
184///     .with_hf_asymmetry(1.5);       // Penalize new artifacts more
185/// ```
186#[derive(Debug, Clone)]
187pub struct ButteraugliParams {
188    hf_asymmetry: f32,
189    xmul: f32,
190    intensity_target: f32,
191}
192
193impl Default for ButteraugliParams {
194    fn default() -> Self {
195        Self {
196            hf_asymmetry: 1.0,
197            xmul: 1.0,
198            intensity_target: 80.0,
199        }
200    }
201}
202
203impl ButteraugliParams {
204    /// Creates a new `ButteraugliParams` with default values.
205    #[must_use]
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// Sets the intensity target (display brightness in nits).
211    #[must_use]
212    pub fn with_intensity_target(mut self, intensity_target: f32) -> Self {
213        self.intensity_target = intensity_target;
214        self
215    }
216
217    /// Sets the HF asymmetry multiplier.
218    /// Values > 1.0 penalize new high-frequency artifacts more than blurring.
219    #[must_use]
220    pub fn with_hf_asymmetry(mut self, hf_asymmetry: f32) -> Self {
221        self.hf_asymmetry = hf_asymmetry;
222        self
223    }
224
225    /// Sets the X channel multiplier.
226    #[must_use]
227    pub fn with_xmul(mut self, xmul: f32) -> Self {
228        self.xmul = xmul;
229        self
230    }
231
232    /// Returns the HF asymmetry multiplier.
233    #[must_use]
234    pub fn hf_asymmetry(&self) -> f32 {
235        self.hf_asymmetry
236    }
237
238    /// Returns the X channel multiplier.
239    #[must_use]
240    pub fn xmul(&self) -> f32 {
241        self.xmul
242    }
243
244    /// Returns the intensity target in nits.
245    #[must_use]
246    pub fn intensity_target(&self) -> f32 {
247        self.intensity_target
248    }
249}
250
251/// Quality threshold for "good" (images look the same).
252pub const BUTTERAUGLI_GOOD: f64 = 1.0;
253
254/// Quality threshold for "bad" (visible difference).
255pub const BUTTERAUGLI_BAD: f64 = 2.0;
256
257/// Butteraugli image comparison result.
258#[derive(Debug, Clone)]
259pub struct ButteraugliResult {
260    /// Global difference score. < 1.0 is "good", > 2.0 is "bad".
261    pub score: f64,
262    /// Per-pixel difference map (optional).
263    pub diffmap: Option<ImageF>,
264}
265
266/// Computes butteraugli score between two sRGB images.
267///
268/// This function accepts 8-bit sRGB data and internally converts to linear RGB
269/// for perceptual comparison. For higher bit depths (16-bit, HDR), use
270/// [`compute_butteraugli_linear`] instead.
271///
272/// # Arguments
273/// * `rgb1` - First image (sRGB u8, 3 bytes per pixel, row-major RGB order)
274/// * `rgb2` - Second image (sRGB u8, 3 bytes per pixel, row-major RGB order)
275/// * `width` - Image width in pixels
276/// * `height` - Image height in pixels
277/// * `params` - Comparison parameters
278///
279/// # Returns
280/// Butteraugli score and optional per-pixel difference map.
281///
282/// # Color Space
283/// Input is assumed to be **sRGB** (gamma-encoded). The function applies the
284/// sRGB transfer function internally to convert to linear RGB before comparison.
285/// If your input is already linear RGB, use [`compute_butteraugli_linear`].
286///
287/// # Errors
288/// Returns an error if:
289/// - Buffer sizes don't match expected dimensions
290/// - Images are smaller than 8x8 pixels
291pub fn compute_butteraugli(
292    rgb1: &[u8],
293    rgb2: &[u8],
294    width: usize,
295    height: usize,
296    params: &ButteraugliParams,
297) -> Result<ButteraugliResult, ButteraugliError> {
298    let expected_size = width * height * 3;
299
300    if width < 8 || height < 8 {
301        return Err(ButteraugliError::InvalidDimensions { width, height });
302    }
303
304    if rgb1.len() != expected_size {
305        return Err(ButteraugliError::InvalidBufferSize {
306            expected: expected_size,
307            actual: rgb1.len(),
308        });
309    }
310
311    if rgb2.len() != expected_size {
312        return Err(ButteraugliError::InvalidBufferSize {
313            expected: expected_size,
314            actual: rgb2.len(),
315        });
316    }
317
318    Ok(diff::compute_butteraugli_impl(rgb1, rgb2, width, height, params))
319}
320
321/// Computes butteraugli score between two linear RGB images.
322///
323/// This function matches the C++ butteraugli API which expects linear RGB float
324/// input. Use this for higher bit depths (16-bit, HDR) or when you already have
325/// linear RGB data.
326///
327/// # Arguments
328/// * `rgb1` - First image (linear RGB f32, 3 floats per pixel, row-major, 0.0-1.0 range)
329/// * `rgb2` - Second image (linear RGB f32, 3 floats per pixel, row-major, 0.0-1.0 range)
330/// * `width` - Image width in pixels
331/// * `height` - Image height in pixels
332/// * `params` - Comparison parameters
333///
334/// # Returns
335/// Butteraugli score and optional per-pixel difference map.
336///
337/// # Color Space
338/// Input must be **linear RGB** (NOT gamma-encoded sRGB). Values should be in
339/// the range 0.0 to 1.0. If your input is sRGB, either:
340/// - Use [`compute_butteraugli`] which handles the conversion automatically
341/// - Apply gamma decoding yourself: `linear = ((srgb + 0.055) / 1.055).powf(2.4)`
342///
343/// # Errors
344/// Returns an error if:
345/// - Buffer sizes don't match expected dimensions
346/// - Images are smaller than 8x8 pixels
347pub fn compute_butteraugli_linear(
348    rgb1: &[f32],
349    rgb2: &[f32],
350    width: usize,
351    height: usize,
352    params: &ButteraugliParams,
353) -> Result<ButteraugliResult, ButteraugliError> {
354    let expected_size = width * height * 3;
355
356    if width < 8 || height < 8 {
357        return Err(ButteraugliError::InvalidDimensions { width, height });
358    }
359
360    if rgb1.len() != expected_size {
361        return Err(ButteraugliError::InvalidBufferSize {
362            expected: expected_size,
363            actual: rgb1.len(),
364        });
365    }
366
367    if rgb2.len() != expected_size {
368        return Err(ButteraugliError::InvalidBufferSize {
369            expected: expected_size,
370            actual: rgb2.len(),
371        });
372    }
373
374    Ok(diff::compute_butteraugli_linear_impl(rgb1, rgb2, width, height, params))
375}
376
377/// Converts sRGB u8 value to linear RGB f32.
378///
379/// Apply this to each channel when converting sRGB images to linear RGB
380/// for use with [`compute_butteraugli_linear`].
381#[must_use]
382pub fn srgb_to_linear(v: u8) -> f32 {
383    opsin::srgb_to_linear(v)
384}
385
386/// Converts butteraugli score to quality percentage (0-100).
387///
388/// This provides a rough human-friendly interpretation:
389/// - Score < 1.0 → ~100% (imperceptible difference)
390/// - Score 4.0+ → ~0% (major visible difference)
391#[must_use]
392pub fn score_to_quality(score: f64) -> f64 {
393    (100.0 - score * 25.0).clamp(0.0, 100.0)
394}
395
396/// Converts butteraugli score to fuzzy class value.
397///
398/// Returns 2.0 for a perfect match, 1.0 for 'ok', 0.0 for bad.
399/// The scoring is fuzzy - a butteraugli score of 0.96 would return
400/// a class of around 1.9.
401#[must_use]
402pub fn butteraugli_fuzzy_class(score: f64) -> f64 {
403    // Based on the C++ implementation
404    let val = 2.0 - score * 0.5;
405    val.clamp(0.0, 2.0)
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_identical_images() {
414        let width = 16;
415        let height = 16;
416        let rgb: Vec<u8> = (0..width * height * 3).map(|i| (i % 256) as u8).collect();
417
418        let result = compute_butteraugli(&rgb, &rgb, width, height, &ButteraugliParams::default())
419            .expect("valid input");
420
421        // Identical images should have score 0
422        assert!(
423            result.score < 0.001,
424            "Identical images should have score ~0, got {}",
425            result.score
426        );
427    }
428
429    #[test]
430    fn test_different_images() {
431        let width = 16;
432        let height = 16;
433        let rgb1: Vec<u8> = vec![0; width * height * 3];
434        let rgb2: Vec<u8> = vec![255; width * height * 3];
435
436        let result =
437            compute_butteraugli(&rgb1, &rgb2, width, height, &ButteraugliParams::default())
438                .expect("valid input");
439
440        // Different images should have non-zero score
441        // Note: uniform images (all black vs all white) don't have much frequency content
442        assert!(
443            result.score > 0.01,
444            "Different images should have non-zero score, got {}",
445            result.score
446        );
447    }
448
449    #[test]
450    fn test_invalid_buffer_size() {
451        let width = 16;
452        let height = 16;
453        let rgb1: Vec<u8> = vec![0; width * height * 3];
454        let rgb2: Vec<u8> = vec![0; 10]; // Wrong size
455
456        let result = compute_butteraugli(&rgb1, &rgb2, width, height, &ButteraugliParams::default());
457        assert!(matches!(result, Err(ButteraugliError::InvalidBufferSize { .. })));
458    }
459
460    #[test]
461    fn test_too_small_dimensions() {
462        let rgb: Vec<u8> = vec![0; 4 * 4 * 3];
463        let result = compute_butteraugli(&rgb, &rgb, 4, 4, &ButteraugliParams::default());
464        assert!(matches!(result, Err(ButteraugliError::InvalidDimensions { .. })));
465    }
466
467    #[test]
468    fn test_score_to_quality() {
469        assert!((score_to_quality(0.0) - 100.0).abs() < 0.001);
470        assert!((score_to_quality(4.0) - 0.0).abs() < 0.001);
471        assert!((score_to_quality(2.0) - 50.0).abs() < 0.001);
472    }
473
474    #[test]
475    fn test_fuzzy_class() {
476        assert!((butteraugli_fuzzy_class(0.0) - 2.0).abs() < 0.001);
477        assert!((butteraugli_fuzzy_class(4.0) - 0.0).abs() < 0.001);
478    }
479}