#![warn(clippy::all)]
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)]
#![allow(clippy::similar_names)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::unreadable_literal)]
#![allow(clippy::inconsistent_digit_grouping)]
#![allow(clippy::excessive_precision)]
#![allow(clippy::suboptimal_flops)]
#![allow(clippy::many_single_char_names)]
#![allow(clippy::needless_range_loop)]
#![allow(clippy::doc_markdown)]
#![allow(clippy::items_after_statements)]
#![allow(clippy::manual_saturating_arithmetic)]
#![allow(clippy::cast_lossless)]
#![allow(clippy::cast_possible_wrap)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::missing_const_for_fn)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::too_many_lines)]
#![allow(clippy::collapsible_else_if)]
#![allow(clippy::if_not_else)]
#![allow(clippy::imprecise_flops)]
#![allow(clippy::implicit_saturating_sub)]
#![allow(clippy::useless_let_if_seq)]
#![allow(clippy::used_underscore_binding)]
#![allow(deprecated)]
#[cfg(feature = "internals")]
pub mod blur;
#[cfg(not(feature = "internals"))]
#[allow(dead_code)]
pub(crate) mod blur;
#[cfg(feature = "iir-blur")]
#[allow(dead_code)]
pub(crate) mod blur_iir;
#[cfg(feature = "internals")]
pub mod consts;
#[cfg(not(feature = "internals"))]
#[allow(dead_code)]
pub(crate) mod consts;
mod diff;
#[cfg(feature = "internals")]
pub mod image;
#[cfg(not(feature = "internals"))]
#[allow(dead_code)]
pub(crate) mod image;
#[cfg(feature = "internals")]
pub mod malta;
#[cfg(not(feature = "internals"))]
pub(crate) mod malta;
#[cfg(feature = "internals")]
pub mod mask;
#[cfg(not(feature = "internals"))]
#[allow(dead_code)]
pub(crate) mod mask;
#[cfg(feature = "internals")]
pub mod opsin;
#[cfg(not(feature = "internals"))]
#[allow(dead_code)]
pub(crate) mod opsin;
pub mod precompute;
pub use precompute::ButteraugliReference;
#[cfg(feature = "internals")]
pub mod psycho;
#[cfg(not(feature = "internals"))]
pub(crate) mod psycho;
#[allow(dead_code)]
pub(crate) mod xyb;
#[doc(hidden)]
pub mod reference_data;
pub use imgref::{Img, ImgRef, ImgVec};
pub use rgb::{RGB, RGB8};
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ButteraugliError {
#[non_exhaustive]
ImageTooSmall {
width: usize,
height: usize,
},
#[non_exhaustive]
DimensionMismatch {
w1: usize,
h1: usize,
w2: usize,
h2: usize,
},
#[non_exhaustive]
#[doc(hidden)]
InvalidDimensions {
width: usize,
height: usize,
},
#[non_exhaustive]
#[doc(hidden)]
InvalidBufferSize {
expected: usize,
actual: usize,
},
#[non_exhaustive]
InvalidParameter {
name: &'static str,
value: f64,
reason: &'static str,
},
#[non_exhaustive]
DimensionOverflow {
width: usize,
height: usize,
},
NonFiniteResult,
}
impl std::fmt::Display for ButteraugliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ImageTooSmall { width, height } => {
write!(f, "image too small: {width}x{height} (minimum 8x8)")
}
Self::DimensionMismatch { w1, h1, w2, h2 } => {
write!(f, "image dimensions don't match: {w1}x{h1} vs {w2}x{h2}")
}
Self::InvalidDimensions { width, height } => {
write!(f, "invalid dimensions: {width}x{height} (minimum 8x8)")
}
Self::InvalidBufferSize { expected, actual } => {
write!(
f,
"buffer size {actual} doesn't match expected size {expected}"
)
}
Self::InvalidParameter {
name,
value,
reason,
} => {
write!(f, "invalid parameter {name}={value}: {reason}")
}
Self::DimensionOverflow { width, height } => {
write!(
f,
"image dimensions {width}x{height} overflow buffer size calculation"
)
}
Self::NonFiniteResult => {
write!(
f,
"score computation produced NaN or infinity (check input pixels)"
)
}
}
}
}
impl std::error::Error for ButteraugliError {}
#[derive(Debug, Clone)]
pub struct ButteraugliParams {
hf_asymmetry: f32,
xmul: f32,
intensity_target: f32,
compute_diffmap: bool,
single_resolution: bool,
}
impl Default for ButteraugliParams {
fn default() -> Self {
Self {
hf_asymmetry: 1.0,
xmul: 1.0,
intensity_target: 80.0,
compute_diffmap: false,
single_resolution: false,
}
}
}
impl ButteraugliParams {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_intensity_target(mut self, intensity_target: f32) -> Self {
self.intensity_target = intensity_target;
self
}
#[must_use]
pub fn with_hf_asymmetry(mut self, hf_asymmetry: f32) -> Self {
self.hf_asymmetry = hf_asymmetry;
self
}
#[must_use]
pub fn with_xmul(mut self, xmul: f32) -> Self {
self.xmul = xmul;
self
}
#[must_use]
pub fn with_compute_diffmap(mut self, compute_diffmap: bool) -> Self {
self.compute_diffmap = compute_diffmap;
self
}
#[must_use]
pub fn hf_asymmetry(&self) -> f32 {
self.hf_asymmetry
}
#[must_use]
pub fn xmul(&self) -> f32 {
self.xmul
}
#[must_use]
pub fn intensity_target(&self) -> f32 {
self.intensity_target
}
#[must_use]
pub fn compute_diffmap(&self) -> bool {
self.compute_diffmap
}
#[must_use]
pub fn with_single_resolution(mut self, single_resolution: bool) -> Self {
self.single_resolution = single_resolution;
self
}
#[must_use]
pub fn single_resolution(&self) -> bool {
self.single_resolution
}
pub fn validate(&self) -> Result<(), ButteraugliError> {
if !self.hf_asymmetry.is_finite() || self.hf_asymmetry <= 0.0 {
return Err(ButteraugliError::InvalidParameter {
name: "hf_asymmetry",
value: self.hf_asymmetry as f64,
reason: "must be finite and positive",
});
}
if !self.intensity_target.is_finite() || self.intensity_target <= 0.0 {
return Err(ButteraugliError::InvalidParameter {
name: "intensity_target",
value: self.intensity_target as f64,
reason: "must be finite and positive",
});
}
if !self.xmul.is_finite() || self.xmul < 0.0 {
return Err(ButteraugliError::InvalidParameter {
name: "xmul",
value: self.xmul as f64,
reason: "must be finite and non-negative",
});
}
Ok(())
}
}
pub(crate) fn check_finite_f32(
data: &[f32],
context: &'static str,
) -> Result<(), ButteraugliError> {
for &v in data {
if !v.is_finite() {
return Err(ButteraugliError::NonFiniteResult);
}
}
let _ = context;
Ok(())
}
fn check_finite_rgb_imgref(img: ImgRef<RGB<f32>>) -> Result<(), ButteraugliError> {
for row in img.rows() {
for px in row {
if !px.r.is_finite() || !px.g.is_finite() || !px.b.is_finite() {
return Err(ButteraugliError::NonFiniteResult);
}
}
}
Ok(())
}
pub const BUTTERAUGLI_GOOD: f64 = 1.0;
pub const BUTTERAUGLI_BAD: f64 = 2.0;
fn pnorm_slice(diffmap: &[f32], p: f64) -> f64 {
if diffmap.is_empty() {
return f64::NAN;
}
let mut sum = [0.0_f64; 3];
for &v in diffmap {
let d = v as f64;
let mut acc = d.powf(p);
sum[0] += acc;
acc *= acc;
sum[1] += acc;
acc *= acc;
sum[2] += acc;
}
let one_per_pixels = 1.0_f64 / diffmap.len() as f64;
let mut v = 0.0_f64;
for (i, &s) in sum.iter().enumerate() {
let exponent = 1.0_f64 / (p * f64::from(1u32 << i));
v += (one_per_pixels * s).powf(exponent);
}
v / 3.0
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ButteraugliResult {
pub score: f64,
pub pnorm_3: f64,
pub diffmap: Option<ImgVec<f32>>,
}
impl ButteraugliResult {
#[must_use]
pub fn pnorm(&self, p: f64) -> Option<f64> {
if (p - 3.0).abs() < 1e-6 {
return Some(self.pnorm_3);
}
let dm = self.diffmap.as_ref()?;
debug_assert_eq!(dm.buf().len(), dm.width() * dm.height());
Some(pnorm_slice(dm.buf(), p))
}
#[must_use]
pub fn max_norm(&self) -> f64 {
self.score
}
}
pub fn butteraugli(
img1: ImgRef<RGB8>,
img2: ImgRef<RGB8>,
params: &ButteraugliParams,
) -> Result<ButteraugliResult, ButteraugliError> {
params.validate()?;
let (w1, h1) = (img1.width(), img1.height());
let (w2, h2) = (img2.width(), img2.height());
if w1 < 8 || h1 < 8 {
return Err(ButteraugliError::ImageTooSmall {
width: w1,
height: h1,
});
}
if w1 != w2 || h1 != h2 {
return Err(ButteraugliError::DimensionMismatch { w1, h1, w2, h2 });
}
let result = diff::compute_butteraugli_imgref(img1, img2, params, params.compute_diffmap);
if !result.score.is_finite() {
return Err(ButteraugliError::NonFiniteResult);
}
Ok(ButteraugliResult {
score: result.score,
pnorm_3: result.pnorm_3,
diffmap: result.diffmap.map(image::ImageF::into_imgvec),
})
}
pub fn butteraugli_linear(
img1: ImgRef<RGB<f32>>,
img2: ImgRef<RGB<f32>>,
params: &ButteraugliParams,
) -> Result<ButteraugliResult, ButteraugliError> {
params.validate()?;
let (w1, h1) = (img1.width(), img1.height());
let (w2, h2) = (img2.width(), img2.height());
if w1 < 8 || h1 < 8 {
return Err(ButteraugliError::ImageTooSmall {
width: w1,
height: h1,
});
}
if w1 != w2 || h1 != h2 {
return Err(ButteraugliError::DimensionMismatch { w1, h1, w2, h2 });
}
check_finite_rgb_imgref(img1)?;
check_finite_rgb_imgref(img2)?;
let result =
diff::compute_butteraugli_linear_imgref(img1, img2, params, params.compute_diffmap);
if !result.score.is_finite() {
return Err(ButteraugliError::NonFiniteResult);
}
Ok(ButteraugliResult {
score: result.score,
pnorm_3: result.pnorm_3,
diffmap: result.diffmap.map(image::ImageF::into_imgvec),
})
}
#[must_use]
pub fn srgb_to_linear(v: u8) -> f32 {
opsin::srgb_to_linear(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identical_images() {
let width = 16;
let height = 16;
let pixels: Vec<RGB8> = (0..width * height)
.map(|i| {
RGB8::new(
(i % 256) as u8,
((i * 2) % 256) as u8,
((i * 3) % 256) as u8,
)
})
.collect();
let img = Img::new(pixels, width, height);
let result = butteraugli(img.as_ref(), img.as_ref(), &ButteraugliParams::default())
.expect("valid input");
assert!(
result.score < 0.001,
"Identical images should have score ~0, got {}",
result.score
);
}
#[test]
fn test_different_images() {
let width = 16;
let height = 16;
let pixels1: Vec<RGB8> = vec![RGB8::new(0, 0, 0); width * height];
let pixels2: Vec<RGB8> = vec![RGB8::new(255, 255, 255); width * height];
let img1 = Img::new(pixels1, width, height);
let img2 = Img::new(pixels2, width, height);
let result = butteraugli(img1.as_ref(), img2.as_ref(), &ButteraugliParams::default())
.expect("valid input");
assert!(
result.score > 0.01,
"Different images should have non-zero score, got {}",
result.score
);
}
#[test]
fn test_dimension_mismatch() {
let pixels1: Vec<RGB8> = vec![RGB8::new(0, 0, 0); 16 * 16];
let pixels2: Vec<RGB8> = vec![RGB8::new(0, 0, 0); 8 * 8];
let img1 = Img::new(pixels1, 16, 16);
let img2 = Img::new(pixels2, 8, 8);
let result = butteraugli(img1.as_ref(), img2.as_ref(), &ButteraugliParams::default());
assert!(matches!(
result,
Err(ButteraugliError::DimensionMismatch { .. })
));
}
#[test]
fn test_too_small_dimensions() {
let pixels: Vec<RGB8> = vec![RGB8::new(0, 0, 0); 4 * 4];
let img = Img::new(pixels, 4, 4);
let result = butteraugli(img.as_ref(), img.as_ref(), &ButteraugliParams::default());
assert!(matches!(
result,
Err(ButteraugliError::ImageTooSmall { .. })
));
}
#[test]
fn test_compute_diffmap_flag() {
let width = 16;
let height = 16;
let pixels: Vec<RGB8> = vec![RGB8::new(128, 128, 128); width * height];
let img = Img::new(pixels, width, height);
let params = ButteraugliParams::default();
let result = butteraugli(img.as_ref(), img.as_ref(), ¶ms).unwrap();
assert!(result.diffmap.is_none());
let params = ButteraugliParams::default().with_compute_diffmap(true);
let result = butteraugli(img.as_ref(), img.as_ref(), ¶ms).unwrap();
assert!(result.diffmap.is_some());
let diffmap = result.diffmap.unwrap();
assert_eq!(diffmap.width(), width);
assert_eq!(diffmap.height(), height);
}
fn make_test_images() -> (ImgVec<RGB8>, ImgVec<RGB8>) {
let width = 16;
let height = 16;
let pixels: Vec<RGB8> = vec![RGB8::new(128, 128, 128); width * height];
let img = Img::new(pixels, width, height);
(img.clone(), img)
}
#[test]
fn test_zero_hf_asymmetry_returns_error() {
let (img1, img2) = make_test_images();
let params = ButteraugliParams::new().with_hf_asymmetry(0.0);
let result = butteraugli(img1.as_ref(), img2.as_ref(), ¶ms);
assert!(
matches!(
result,
Err(ButteraugliError::InvalidParameter {
name: "hf_asymmetry",
..
})
),
"expected InvalidParameter for hf_asymmetry=0.0, got {result:?}"
);
}
#[test]
fn test_negative_hf_asymmetry_returns_error() {
let (img1, img2) = make_test_images();
let params = ButteraugliParams::new().with_hf_asymmetry(-1.0);
let result = butteraugli(img1.as_ref(), img2.as_ref(), ¶ms);
assert!(
matches!(
result,
Err(ButteraugliError::InvalidParameter {
name: "hf_asymmetry",
..
})
),
"expected InvalidParameter for hf_asymmetry=-1.0, got {result:?}"
);
}
#[test]
fn test_zero_intensity_target_returns_error() {
let (img1, img2) = make_test_images();
let params = ButteraugliParams::new().with_intensity_target(0.0);
let result = butteraugli(img1.as_ref(), img2.as_ref(), ¶ms);
assert!(
matches!(
result,
Err(ButteraugliError::InvalidParameter {
name: "intensity_target",
..
})
),
"expected InvalidParameter for intensity_target=0.0, got {result:?}"
);
}
#[test]
fn test_nan_xmul_returns_error() {
let (img1, img2) = make_test_images();
let params = ButteraugliParams::new().with_xmul(f32::NAN);
let result = butteraugli(img1.as_ref(), img2.as_ref(), ¶ms);
assert!(
matches!(
result,
Err(ButteraugliError::InvalidParameter { name: "xmul", .. })
),
"expected InvalidParameter for xmul=NaN, got {result:?}"
);
}
#[test]
fn test_inf_hf_asymmetry_returns_error() {
let (img1, img2) = make_test_images();
let params = ButteraugliParams::new().with_hf_asymmetry(f32::INFINITY);
let result = butteraugli(img1.as_ref(), img2.as_ref(), ¶ms);
assert!(
matches!(
result,
Err(ButteraugliError::InvalidParameter {
name: "hf_asymmetry",
..
})
),
"expected InvalidParameter for hf_asymmetry=Inf, got {result:?}"
);
}
#[test]
fn test_negative_xmul_returns_error() {
let (img1, img2) = make_test_images();
let params = ButteraugliParams::new().with_xmul(-0.5);
let result = butteraugli(img1.as_ref(), img2.as_ref(), ¶ms);
assert!(
matches!(
result,
Err(ButteraugliError::InvalidParameter { name: "xmul", .. })
),
"expected InvalidParameter for xmul=-0.5, got {result:?}"
);
}
#[test]
fn test_zero_xmul_is_valid() {
let (img1, img2) = make_test_images();
let params = ButteraugliParams::new().with_xmul(0.0);
let result = butteraugli(img1.as_ref(), img2.as_ref(), ¶ms);
assert!(result.is_ok(), "xmul=0.0 should be valid, got {result:?}");
}
#[test]
fn test_nan_pixels_returns_non_finite_result() {
let width = 16;
let height = 16;
let pixels1: Vec<RGB<f32>> = vec![RGB::new(f32::NAN, f32::NAN, f32::NAN); width * height];
let pixels2: Vec<RGB<f32>> = vec![RGB::new(0.5, 0.5, 0.5); width * height];
let img1 = Img::new(pixels1, width, height);
let img2 = Img::new(pixels2, width, height);
let result =
butteraugli_linear(img1.as_ref(), img2.as_ref(), &ButteraugliParams::default());
assert!(
matches!(result, Err(ButteraugliError::NonFiniteResult)),
"expected NonFiniteResult for NaN input pixels, got {result:?}"
);
}
#[test]
fn test_inf_pixels_returns_non_finite_result() {
let width = 16;
let height = 16;
let pixels1: Vec<RGB<f32>> =
vec![RGB::new(f32::INFINITY, f32::INFINITY, f32::INFINITY); width * height];
let pixels2: Vec<RGB<f32>> = vec![RGB::new(0.5, 0.5, 0.5); width * height];
let img1 = Img::new(pixels1, width, height);
let img2 = Img::new(pixels2, width, height);
let result =
butteraugli_linear(img1.as_ref(), img2.as_ref(), &ButteraugliParams::default());
assert!(
matches!(result, Err(ButteraugliError::NonFiniteResult)),
"expected NonFiniteResult for Inf input pixels, got {result:?}"
);
}
#[test]
fn test_default_params_still_valid() {
assert!(ButteraugliParams::default().validate().is_ok());
}
#[test]
fn test_validate_method_directly() {
assert!(
ButteraugliParams::new()
.with_hf_asymmetry(1.5)
.with_intensity_target(250.0)
.with_xmul(0.5)
.validate()
.is_ok()
);
assert!(
ButteraugliParams::new()
.with_hf_asymmetry(0.0)
.validate()
.is_err()
);
assert!(
ButteraugliParams::new()
.with_hf_asymmetry(-1.0)
.validate()
.is_err()
);
assert!(
ButteraugliParams::new()
.with_hf_asymmetry(f32::NAN)
.validate()
.is_err()
);
assert!(
ButteraugliParams::new()
.with_hf_asymmetry(f32::INFINITY)
.validate()
.is_err()
);
assert!(
ButteraugliParams::new()
.with_intensity_target(0.0)
.validate()
.is_err()
);
assert!(
ButteraugliParams::new()
.with_intensity_target(-10.0)
.validate()
.is_err()
);
assert!(ButteraugliParams::new().with_xmul(-0.1).validate().is_err());
assert!(
ButteraugliParams::new()
.with_xmul(f32::NAN)
.validate()
.is_err()
);
assert!(ButteraugliParams::new().with_xmul(0.0).validate().is_ok());
}
#[test]
fn test_validation_on_linear_api() {
let width = 16;
let height = 16;
let pixels: Vec<RGB<f32>> = vec![RGB::new(0.5, 0.5, 0.5); width * height];
let img = Img::new(pixels, width, height);
let params = ButteraugliParams::new().with_hf_asymmetry(0.0);
let result = butteraugli_linear(img.as_ref(), img.as_ref(), ¶ms);
assert!(matches!(
result,
Err(ButteraugliError::InvalidParameter {
name: "hf_asymmetry",
..
})
));
}
#[test]
fn test_validation_on_precompute_api() {
let width = 32;
let height = 32;
let rgb: Vec<u8> = vec![128; width * height * 3];
let params = ButteraugliParams::new().with_intensity_target(0.0);
let result = ButteraugliReference::new(&rgb, width, height, params);
assert!(matches!(
result,
Err(ButteraugliError::InvalidParameter {
name: "intensity_target",
..
})
));
}
#[test]
fn test_validation_on_precompute_linear_api() {
let width = 32;
let height = 32;
let rgb: Vec<f32> = vec![0.5; width * height * 3];
let params = ButteraugliParams::new().with_hf_asymmetry(-1.0);
let result = ButteraugliReference::new_linear(&rgb, width, height, params);
assert!(matches!(
result,
Err(ButteraugliError::InvalidParameter {
name: "hf_asymmetry",
..
})
));
}
#[test]
fn test_validation_on_precompute_planar_api() {
let width = 32;
let height = 32;
let channel: Vec<f32> = vec![0.5; width * height];
let params = ButteraugliParams::new().with_xmul(f32::NAN);
let result = ButteraugliReference::new_linear_planar(
&channel, &channel, &channel, width, height, width, params,
);
assert!(matches!(
result,
Err(ButteraugliError::InvalidParameter { name: "xmul", .. })
));
}
#[test]
fn test_pnorm_slice_empty_returns_nan() {
assert!(pnorm_slice(&[], 3.0).is_nan());
}
#[test]
fn test_pnorm_slice_uniform_returns_value() {
for &v in &[0.5_f32, 1.0, 2.5, 7.3] {
let dm = vec![v; 64];
for &p in &[1.5_f64, 2.0, 3.0, 4.0] {
let got = pnorm_slice(&dm, p);
assert!(
(got - v as f64).abs() < 1e-6,
"uniform pnorm_slice(v={v}, p={p}) = {got}, want {v}"
);
}
}
}
#[test]
fn test_pnorm_slice_matches_libjxl_reference_formula() {
let dm: Vec<f32> = (1..=20).map(|i| (i as f32) * 0.13).collect();
let p = 3.0_f64;
let mut sum1 = [0.0_f64; 3];
for &v in &dm {
let mut d2 = (v as f64).powf(p);
sum1[0] += d2;
d2 *= d2;
sum1[1] += d2;
d2 *= d2;
sum1[2] += d2;
}
let one_per_pixels = 1.0 / dm.len() as f64;
let mut want = 0.0_f64;
for i in 0..3 {
want += (one_per_pixels * sum1[i]).powf(1.0 / (p * f64::from(1u32 << i)));
}
want /= 3.0;
let got = pnorm_slice(&dm, p);
let rel_err = (got - want).abs() / want;
assert!(
rel_err < 1e-12,
"pnorm_slice {got} vs reference formula {want}, rel_err {rel_err}"
);
}
#[test]
fn test_pnorm_method_returns_precomputed_for_p3_without_diffmap() {
let res = ButteraugliResult {
score: 0.5,
pnorm_3: 0.42,
diffmap: None,
};
assert_eq!(res.pnorm(3.0), Some(0.42));
assert!(res.pnorm(2.5).is_none());
assert!(res.pnorm(4.0).is_none());
}
#[test]
fn test_pnorm_method_uses_diffmap_for_arbitrary_p() {
let dm = ImgVec::new(vec![2.0_f32; 16 * 16], 16, 16);
let res = ButteraugliResult {
score: 2.0,
pnorm_3: 99.0, diffmap: Some(dm),
};
let got = res.pnorm(4.0).expect("diffmap is present");
assert!((got - 2.0).abs() < 1e-9, "got {got}");
assert_eq!(res.pnorm(3.0), Some(99.0));
}
#[test]
fn test_max_norm_alias() {
let res = ButteraugliResult {
score: 1.234,
pnorm_3: 0.0,
diffmap: None,
};
assert_eq!(res.max_norm().to_bits(), 1.234_f64.to_bits());
}
#[test]
fn test_pnorm_3_field_matches_slice_helper_on_diffmap() {
let width = 32;
let height = 32;
let pixels1: Vec<RGB8> = (0..width * height)
.map(|i| RGB8::new((i % 256) as u8, ((i * 3) % 256) as u8, 0))
.collect();
let pixels2: Vec<RGB8> = (0..width * height)
.map(|i| RGB8::new(((i + 50) % 256) as u8, ((i * 3 + 30) % 256) as u8, 0))
.collect();
let img1 = Img::new(pixels1, width, height);
let img2 = Img::new(pixels2, width, height);
let params = ButteraugliParams::default().with_compute_diffmap(true);
let result = butteraugli(img1.as_ref(), img2.as_ref(), ¶ms).expect("valid");
let dm = result.diffmap.as_ref().expect("diffmap requested");
let recomputed = pnorm_slice(dm.buf(), 3.0);
let rel_err = (result.pnorm_3 - recomputed).abs() / recomputed.max(1e-12);
assert!(
rel_err < 1e-9,
"fused pnorm_3 = {} vs slice helper pnorm(diffmap, 3) = {}, rel_err {rel_err}",
result.pnorm_3,
recomputed
);
}
#[test]
fn test_pnorm_3_available_without_diffmap() {
let width = 32;
let height = 32;
let pixels1: Vec<RGB8> = vec![RGB8::new(40, 40, 40); width * height];
let pixels2: Vec<RGB8> = vec![RGB8::new(80, 80, 80); width * height];
let img1 = Img::new(pixels1, width, height);
let img2 = Img::new(pixels2, width, height);
let params = ButteraugliParams::default(); let result = butteraugli(img1.as_ref(), img2.as_ref(), ¶ms).expect("valid");
assert!(result.diffmap.is_none(), "diffmap should not be returned");
assert!(result.pnorm_3.is_finite(), "pnorm_3 must be populated");
assert!(result.pnorm_3 >= 0.0);
assert_eq!(result.pnorm(3.0), Some(result.pnorm_3));
}
#[test]
fn test_pnorm_3_zero_for_identical_images() {
let width = 16;
let height = 16;
let pixels: Vec<RGB8> = vec![RGB8::new(128, 128, 128); width * height];
let img = Img::new(pixels, width, height);
let params = ButteraugliParams::default();
let result = butteraugli(img.as_ref(), img.as_ref(), ¶ms).expect("valid");
assert!(result.pnorm_3.abs() < 1e-9);
assert!(result.score < 0.001);
}
#[test]
fn test_error_display() {
let err = ButteraugliError::InvalidParameter {
name: "hf_asymmetry",
value: 0.0,
reason: "must be finite and positive",
};
assert_eq!(
err.to_string(),
"invalid parameter hf_asymmetry=0: must be finite and positive"
);
let err = ButteraugliError::DimensionOverflow {
width: 1000000,
height: 1000000,
};
assert!(err.to_string().contains("overflow"));
let err = ButteraugliError::NonFiniteResult;
assert!(err.to_string().contains("NaN"));
}
}