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, ¶ms).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}