#![forbid(unsafe_code)]
mod blur;
mod input;
mod precompute;
#[cfg(feature = "hdr-pu")]
mod pu_xyb;
#[doc(hidden)]
pub mod reference_data;
#[allow(clippy::too_many_arguments)] mod simd_ops;
mod strip;
mod weights;
mod xyb_simd;
pub use blur::Blur;
pub use input::{LinearRgbImage, LinearRgbImageError, ToLinearRgb};
pub use precompute::{CompareContext, ScalePlanesView, Ssimulacra2Reference};
pub use strip::{
HALO_ROWS_DEFAULT, MIN_STRIP_HEIGHT, Ssimulacra2StripConfig, compute_ssimulacra2_strip,
compute_ssimulacra2_strip_with_config,
};
pub use input::{srgb_to_linear, srgb_u8_to_linear, srgb_u16_to_linear};
use yuvxyb::LinearRgb;
use yuvxyb::Xyb;
pub(crate) use weights::NUM_SCALES;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SimdImpl {
Scalar,
#[default]
Simd,
}
impl SimdImpl {
pub fn name(&self) -> &'static str {
match self {
SimdImpl::Scalar => "scalar",
SimdImpl::Simd => "simd (archmage)",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Ssimulacra2Config {
pub impl_type: SimdImpl,
}
impl Ssimulacra2Config {
pub fn new(impl_type: SimdImpl) -> Self {
Self { impl_type }
}
pub fn simd() -> Self {
Self::new(SimdImpl::Simd)
}
pub fn scalar() -> Self {
Self::new(SimdImpl::Scalar)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum Ssimulacra2Error {
#[error("Failed to convert input image to linear RGB")]
LinearRgbConversionFailed,
#[error("Source and distorted image width and height must be equal")]
NonMatchingImageDimensions,
#[error("Images must be at least 8x8 pixels")]
InvalidImageSize,
#[error(
"Image is too large: {actual} pixels exceeds limit of {} pixels",
MAX_IMAGE_PIXELS
)]
ImageTooLarge {
actual: usize,
},
#[error("Gaussian blur operation failed")]
GaussianBlurError,
}
pub const MAX_IMAGE_PIXELS: usize = 16_384 * 16_384;
#[deprecated(
since = "0.8.0",
note = "use compute_ssimulacra2 with ToLinearRgb types instead"
)]
pub fn compute_frame_ssimulacra2<T, U>(source: T, distorted: U) -> Result<f64, Ssimulacra2Error>
where
LinearRgb: TryFrom<T> + TryFrom<U>,
{
compute_frame_ssimulacra2_impl(source, distorted, Ssimulacra2Config::default())
}
#[deprecated(
since = "0.8.0",
note = "use compute_ssimulacra2_with_config with ToLinearRgb types instead"
)]
pub fn compute_frame_ssimulacra2_with_config<T, U>(
source: T,
distorted: U,
config: Ssimulacra2Config,
) -> Result<f64, Ssimulacra2Error>
where
LinearRgb: TryFrom<T> + TryFrom<U>,
{
compute_frame_ssimulacra2_impl(source, distorted, config)
}
pub fn compute_ssimulacra2<S, D>(source: S, distorted: D) -> Result<f64, Ssimulacra2Error>
where
S: ToLinearRgb,
D: ToLinearRgb,
{
compute_ssimulacra2_with_config(source, distorted, Ssimulacra2Config::default())
}
pub fn compute_ssimulacra2_with_config<S, D>(
source: S,
distorted: D,
config: Ssimulacra2Config,
) -> Result<f64, Ssimulacra2Error>
where
S: ToLinearRgb,
D: ToLinearRgb,
{
let img1: LinearRgb = reflect_pad_linear(source.into_linear_rgb(), 8).into();
let img2: LinearRgb = reflect_pad_linear(distorted.into_linear_rgb(), 8).into();
compute_frame_ssimulacra2_impl(img1, img2, config)
}
#[inline]
fn reflect_index(i: usize, n: usize) -> usize {
if n <= 1 {
return 0;
}
let period = 2 * (n - 1);
let mut k = i % period;
if k >= n {
k = period - k;
}
k
}
pub(crate) fn reflect_pad_linear(img: LinearRgbImage, min: usize) -> LinearRgbImage {
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 {
return img;
}
let (pw, ph) = (w.max(min), h.max(min));
if pw == w && ph == h {
return img;
}
let src = img.data();
let mut out = Vec::with_capacity(pw * ph);
for y in 0..ph {
let row = reflect_index(y, h) * w;
for x in 0..pw {
out.push(src[row + reflect_index(x, w)]);
}
}
LinearRgbImage::new(out, pw, ph)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum XybFlavor {
CubeRoot,
#[cfg(feature = "hdr-pu")]
Pu21,
}
fn compute_frame_ssimulacra2_impl<T, U>(
source: T,
distorted: U,
config: Ssimulacra2Config,
) -> Result<f64, Ssimulacra2Error>
where
LinearRgb: TryFrom<T> + TryFrom<U>,
{
let Ok(img1) = LinearRgb::try_from(source) else {
return Err(Ssimulacra2Error::LinearRgbConversionFailed);
};
let Ok(img2) = LinearRgb::try_from(distorted) else {
return Err(Ssimulacra2Error::LinearRgbConversionFailed);
};
compute_frame_flavored(img1, img2, config, XybFlavor::CubeRoot)
}
fn compute_frame_flavored(
mut img1: LinearRgb,
mut img2: LinearRgb,
config: Ssimulacra2Config,
flavor: XybFlavor,
) -> Result<f64, Ssimulacra2Error> {
if img1.width() != img2.width() || img1.height() != img2.height() {
return Err(Ssimulacra2Error::NonMatchingImageDimensions);
}
if img1.width().get() < 8 || img1.height().get() < 8 {
return Err(Ssimulacra2Error::InvalidImageSize);
}
let pixels = img1
.width()
.get()
.checked_mul(img1.height().get())
.ok_or(Ssimulacra2Error::ImageTooLarge { actual: usize::MAX })?;
if pixels > MAX_IMAGE_PIXELS {
return Err(Ssimulacra2Error::ImageTooLarge { actual: pixels });
}
let mut width = img1.width().get();
let mut height = img1.height().get();
let impl_type = config.impl_type;
let scales_n = weights::count_scales(width, height);
let alloc_plane = || vec![0.0f32; width * height];
let alloc_3planes = || [alloc_plane(), alloc_plane(), alloc_plane()];
let mut mul = alloc_3planes();
let mut sigma1_sq = alloc_3planes();
let mut sigma2_sq = alloc_3planes();
let mut sigma12 = alloc_3planes();
let mut mu1 = alloc_3planes();
let mut mu2 = alloc_3planes();
let mut img1_planar = alloc_3planes();
let mut img2_planar = alloc_3planes();
let mut blur = Blur::with_simd_impl(width, height, impl_type);
let mut msssim = Msssim::default();
for scale in 0..NUM_SCALES {
if width < 8 || height < 8 {
break;
}
if scale > 0 {
img1 = downscale_by_2(&img1);
img2 = downscale_by_2(&img2);
width = img1.width().get();
height = img2.height().get();
}
let size = width * height;
for buf in [
&mut mul,
&mut sigma1_sq,
&mut sigma2_sq,
&mut sigma12,
&mut mu1,
&mut mu2,
&mut img1_planar,
&mut img2_planar,
] {
for c in buf.iter_mut() {
c.truncate(size);
}
}
blur.shrink_to(width, height);
let (img1_xyb, img2_xyb) = match flavor {
XybFlavor::CubeRoot => {
let mut a = linear_rgb_to_xyb(img1.clone(), impl_type);
let mut b = linear_rgb_to_xyb(img2.clone(), impl_type);
make_positive_xyb(&mut a);
make_positive_xyb(&mut b);
(a, b)
}
#[cfg(feature = "hdr-pu")]
XybFlavor::Pu21 => (
linear_nits_to_pu_xyb(img1.clone()),
linear_nits_to_pu_xyb(img2.clone()),
),
};
xyb_to_planar_into(&img1_xyb, &mut img1_planar);
xyb_to_planar_into(&img2_xyb, &mut img2_planar);
image_multiply(&img1_planar, &img1_planar, &mut mul, impl_type);
blur.blur_into(&mul, &mut sigma1_sq);
image_multiply(&img2_planar, &img2_planar, &mut mul, impl_type);
blur.blur_into(&mul, &mut sigma2_sq);
image_multiply(&img1_planar, &img2_planar, &mut mul, impl_type);
blur.blur_into(&mul, &mut sigma12);
blur.blur_into(&img1_planar, &mut mu1);
blur.blur_into(&img2_planar, &mut mu2);
let avg_ssim = ssim_map(
scales_n, scale, width, height, &mu1, &mu2, &sigma1_sq, &sigma2_sq, &sigma12, impl_type,
);
let avg_edgediff = edge_diff_map(
scales_n,
scale,
width,
height,
&img1_planar,
&mu1,
&img2_planar,
&mu2,
impl_type,
);
msssim.scales.push(MsssimScale {
avg_ssim,
avg_edgediff,
});
}
Ok(msssim.score())
}
#[cfg(feature = "hdr-pu")]
fn linear_nits_to_pu_xyb(linear_nits: LinearRgb) -> Xyb {
let width = linear_nits.width();
let height = linear_nits.height();
let mut data = linear_nits.into_data();
pu_xyb::linear_nits_to_pu_xyb(&mut data);
Xyb::new(data, width, height).expect("XYB construction should not fail")
}
#[cfg(feature = "hdr-pu")]
pub fn compute_ssimulacra2_pu_nits(
source_nits: LinearRgbImage,
distorted_nits: LinearRgbImage,
) -> Result<f64, Ssimulacra2Error> {
let img1: LinearRgb = reflect_pad_linear(source_nits, 8).into();
let img2: LinearRgb = reflect_pad_linear(distorted_nits, 8).into();
compute_frame_flavored(img1, img2, Ssimulacra2Config::default(), XybFlavor::Pu21)
}
fn linear_rgb_to_xyb(linear_rgb: LinearRgb, impl_type: SimdImpl) -> Xyb {
match impl_type {
SimdImpl::Scalar => Xyb::from(linear_rgb),
SimdImpl::Simd => {
let width = linear_rgb.width(); let height = linear_rgb.height(); let mut data = linear_rgb.into_data();
xyb_simd::linear_rgb_to_xyb_simd(&mut data);
Xyb::new(data, width, height).expect("XYB construction should not fail")
}
}
}
pub(crate) fn linear_rgb_to_xyb_simd(linear_rgb: LinearRgb) -> Xyb {
linear_rgb_to_xyb(linear_rgb, SimdImpl::Simd)
}
pub(crate) fn make_positive_xyb(xyb: &mut Xyb) {
for pix in xyb.data_mut().iter_mut() {
pix[2] = (pix[2] - pix[1]) + 0.55;
pix[0] = (pix[0]).mul_add(14.0, 0.42);
pix[1] += 0.01;
}
}
pub(crate) fn xyb_to_planar(xyb: &Xyb) -> [Vec<f32>; 3] {
let size = xyb.width().get() * xyb.height().get();
let mut out = [vec![0.0f32; size], vec![0.0f32; size], vec![0.0f32; size]];
xyb_to_planar_into(xyb, &mut out);
out
}
pub(crate) fn xyb_to_planar_into(xyb: &Xyb, out: &mut [Vec<f32>; 3]) {
let [out0, out1, out2] = out;
for (((i, o0), o1), o2) in xyb
.data()
.iter()
.copied()
.zip(out0.iter_mut())
.zip(out1.iter_mut())
.zip(out2.iter_mut())
{
*o0 = i[0];
*o1 = i[1];
*o2 = i[2];
}
}
pub(crate) fn image_multiply(
img1: &[Vec<f32>; 3],
img2: &[Vec<f32>; 3],
out: &mut [Vec<f32>; 3],
impl_type: SimdImpl,
) {
match impl_type {
SimdImpl::Scalar => image_multiply_scalar(img1, img2, out),
SimdImpl::Simd => simd_ops::image_multiply_simd(img1, img2, out),
}
}
fn image_multiply_scalar(img1: &[Vec<f32>; 3], img2: &[Vec<f32>; 3], out: &mut [Vec<f32>; 3]) {
for ((plane1, plane2), out_plane) in img1.iter().zip(img2.iter()).zip(out.iter_mut()) {
for ((&p1, &p2), o) in plane1.iter().zip(plane2.iter()).zip(out_plane.iter_mut()) {
*o = p1 * p2;
}
}
}
pub(crate) fn downscale_by_2(in_data: &LinearRgb) -> LinearRgb {
use std::num::NonZeroUsize;
const SCALE: usize = 2;
let in_w = in_data.width().get();
let in_h = in_data.height().get();
let out_w = in_w.div_ceil(SCALE);
let out_h = in_h.div_ceil(SCALE);
let mut out_data = vec![[0.0f32; 3]; out_w * out_h];
let normalize = 1.0f32 / (SCALE * SCALE) as f32;
let in_data = &in_data.data();
for oy in 0..out_h {
for ox in 0..out_w {
for c in 0..3 {
let mut sum = 0f32;
for iy in 0..SCALE {
for ix in 0..SCALE {
let x = (ox * SCALE + ix).min(in_w - 1);
let y = (oy * SCALE + iy).min(in_h - 1);
sum += in_data[y * in_w + x][c];
}
}
out_data[oy * out_w + ox][c] = sum * normalize;
}
}
}
LinearRgb::new(
out_data,
NonZeroUsize::new(out_w).expect("out_w must be nonzero"),
NonZeroUsize::new(out_h).expect("out_h must be nonzero"),
)
.expect("Resolution and data size match")
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn ssim_map(
scales_n: usize,
scale_idx: usize,
width: usize,
height: usize,
m1: &[Vec<f32>; 3],
m2: &[Vec<f32>; 3],
s11: &[Vec<f32>; 3],
s22: &[Vec<f32>; 3],
s12: &[Vec<f32>; 3],
impl_type: SimdImpl,
) -> [f64; 3 * 2] {
match impl_type {
SimdImpl::Scalar => {
ssim_map_scalar(scales_n, scale_idx, width, height, m1, m2, s11, s22, s12)
}
SimdImpl::Simd => {
simd_ops::ssim_map_simd(scales_n, scale_idx, width, height, m1, m2, s11, s22, s12)
}
}
}
#[allow(clippy::too_many_arguments)]
fn ssim_map_scalar(
scales_n: usize,
scale_idx: usize,
width: usize,
height: usize,
m1: &[Vec<f32>; 3],
m2: &[Vec<f32>; 3],
s11: &[Vec<f32>; 3],
s22: &[Vec<f32>; 3],
s12: &[Vec<f32>; 3],
) -> [f64; 3 * 2] {
const C2: f32 = 0.0009f32;
let one_per_pixels = 1.0f64 / (width * height) as f64;
let mut plane_averages = [0f64; 3 * 2];
let skip_table = weights::SSIM_HAS_WEIGHT[scales_n.min(NUM_SCALES)];
for c in 0..3 {
if scale_idx < NUM_SCALES && !skip_table[c][scale_idx] {
continue;
}
let mut sum_d = 0.0f64;
let mut sum_d4 = 0.0f64;
for (row_m1, (row_m2, (row_s11, (row_s22, row_s12)))) in m1[c].chunks_exact(width).zip(
m2[c].chunks_exact(width).zip(
s11[c]
.chunks_exact(width)
.zip(s22[c].chunks_exact(width).zip(s12[c].chunks_exact(width))),
),
) {
for x in 0..width {
let mu1 = row_m1[x];
let mu2 = row_m2[x];
let mu11 = mu1 * mu1;
let mu22 = mu2 * mu2;
let mu12 = mu1 * mu2;
let mu_diff = mu1 - mu2;
let num_m = mu_diff.mul_add(-mu_diff, 1.0f32);
let num_s = 2.0f32.mul_add(row_s12[x] - mu12, C2);
let denom_s = (row_s11[x] - mu11) + (row_s22[x] - mu22) + C2;
let d = (1.0f32 - (num_m * num_s) / denom_s).max(0.0f32);
let d2 = d * d;
let d4 = d2 * d2;
sum_d += f64::from(d);
sum_d4 += f64::from(d4);
}
}
plane_averages[c * 2] = one_per_pixels * sum_d;
plane_averages[c * 2 + 1] = (one_per_pixels * sum_d4).sqrt().sqrt();
}
plane_averages
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn edge_diff_map(
scales_n: usize,
scale_idx: usize,
width: usize,
height: usize,
img1: &[Vec<f32>; 3],
mu1: &[Vec<f32>; 3],
img2: &[Vec<f32>; 3],
mu2: &[Vec<f32>; 3],
impl_type: SimdImpl,
) -> [f64; 3 * 4] {
match impl_type {
SimdImpl::Scalar => {
edge_diff_map_scalar(scales_n, scale_idx, width, height, img1, mu1, img2, mu2)
}
SimdImpl::Simd => {
simd_ops::edge_diff_map_simd(scales_n, scale_idx, width, height, img1, mu1, img2, mu2)
}
}
}
#[allow(clippy::too_many_arguments)]
fn edge_diff_map_scalar(
scales_n: usize,
scale_idx: usize,
width: usize,
height: usize,
img1: &[Vec<f32>; 3],
mu1: &[Vec<f32>; 3],
img2: &[Vec<f32>; 3],
mu2: &[Vec<f32>; 3],
) -> [f64; 3 * 4] {
let one_per_pixels = 1.0f64 / (width * height) as f64;
let mut plane_averages = [0f64; 3 * 4];
let skip_table = weights::EDGE_HAS_WEIGHT[scales_n.min(NUM_SCALES)];
for c in 0..3 {
if scale_idx < NUM_SCALES && !skip_table[c][scale_idx] {
continue;
}
let mut sum1 = [0.0f64; 4];
for (row1, (row2, (rowm1, rowm2))) in img1[c].chunks_exact(width).zip(
img2[c]
.chunks_exact(width)
.zip(mu1[c].chunks_exact(width).zip(mu2[c].chunks_exact(width))),
) {
for x in 0..width {
let d1: f64 = (1.0 + f64::from((row2[x] - rowm2[x]).abs()))
/ (1.0 + f64::from((row1[x] - rowm1[x]).abs()))
- 1.0;
let artifact = d1.max(0.0);
sum1[0] += artifact;
sum1[1] += artifact.powi(4);
let detail_lost = (-d1).max(0.0);
sum1[2] += detail_lost;
sum1[3] += detail_lost.powi(4);
}
}
plane_averages[c * 4] = one_per_pixels * sum1[0];
plane_averages[c * 4 + 1] = (one_per_pixels * sum1[1]).sqrt().sqrt();
plane_averages[c * 4 + 2] = one_per_pixels * sum1[2];
plane_averages[c * 4 + 3] = (one_per_pixels * sum1[3]).sqrt().sqrt();
}
plane_averages
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Msssim {
pub scales: Vec<MsssimScale>,
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct MsssimScale {
pub avg_ssim: [f64; 3 * 2],
pub avg_edgediff: [f64; 3 * 4],
}
impl Msssim {
pub fn score(&self) -> f64 {
use weights::WEIGHT;
let mut ssim = 0.0f64;
let mut i = 0usize;
for c in 0..3 {
for scale in &self.scales {
for n in 0..2 {
ssim = WEIGHT[i].mul_add(scale.avg_ssim[c * 2 + n].abs(), ssim);
i += 1;
ssim = WEIGHT[i].mul_add(scale.avg_edgediff[c * 4 + n].abs(), ssim);
i += 1;
ssim = WEIGHT[i].mul_add(scale.avg_edgediff[c * 4 + n + 2].abs(), ssim);
i += 1;
}
}
}
ssim *= 0.956_238_261_683_484_4_f64;
ssim = (6.248_496_625_763_138e-5 * ssim * ssim).mul_add(
ssim,
2.326_765_642_916_932f64.mul_add(ssim, -0.020_884_521_182_843_837 * ssim * ssim),
);
if ssim > 0.0f64 {
ssim = ssim
.powf(0.627_633_646_783_138_7)
.mul_add(-10.0f64, 100.0f64);
} else {
ssim = 100.0f64;
}
ssim
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use std::path::PathBuf;
use super::*;
use yuvxyb::{ColorPrimaries, Rgb, TransferCharacteristic};
#[test]
fn test_ssimulacra2() {
let source = image::open(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("test_data")
.join("tank_source.png"),
)
.unwrap();
let distorted = image::open(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("test_data")
.join("tank_distorted.png"),
)
.unwrap();
let source_data = source
.to_rgb32f()
.chunks_exact(3)
.map(|chunk| [chunk[0], chunk[1], chunk[2]])
.collect::<Vec<_>>();
let source_data = Xyb::try_from(
Rgb::new(
source_data,
std::num::NonZeroUsize::new(source.width() as usize).unwrap(),
std::num::NonZeroUsize::new(source.height() as usize).unwrap(),
TransferCharacteristic::SRGB,
ColorPrimaries::BT709,
)
.unwrap(),
)
.unwrap();
let distorted_data = distorted
.to_rgb32f()
.chunks_exact(3)
.map(|chunk| [chunk[0], chunk[1], chunk[2]])
.collect::<Vec<_>>();
let distorted_data = Xyb::try_from(
Rgb::new(
distorted_data,
std::num::NonZeroUsize::new(distorted.width() as usize).unwrap(),
std::num::NonZeroUsize::new(distorted.height() as usize).unwrap(),
TransferCharacteristic::SRGB,
ColorPrimaries::BT709,
)
.unwrap(),
)
.unwrap();
let result = compute_frame_ssimulacra2(source_data, distorted_data).unwrap();
let expected = 17.398_505_f64;
assert!(
(result - expected).abs() < 0.25f64,
"Result {result:.6} not equal to expected {expected:.6}",
);
}
#[test]
fn test_xyb_simd_vs_yuvxyb() {
use yuvxyb::{ColorPrimaries, TransferCharacteristic};
let source = image::open(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("test_data")
.join("tank_source.png"),
)
.unwrap();
let source_data: Vec<[f32; 3]> = source
.to_rgb32f()
.chunks_exact(3)
.map(|chunk| [chunk[0], chunk[1], chunk[2]])
.collect();
let width = source.width() as usize;
let height = source.height() as usize;
let nz_width = std::num::NonZeroUsize::new(width).unwrap();
let nz_height = std::num::NonZeroUsize::new(height).unwrap();
let rgb_for_yuvxyb = Rgb::new(
source_data.clone(),
nz_width,
nz_height,
TransferCharacteristic::SRGB,
ColorPrimaries::BT709,
)
.unwrap();
let lrgb_for_yuvxyb = yuvxyb::LinearRgb::try_from(rgb_for_yuvxyb).unwrap();
let xyb_yuvxyb = yuvxyb::Xyb::from(lrgb_for_yuvxyb);
let rgb_for_simd = Rgb::new(
source_data,
nz_width,
nz_height,
TransferCharacteristic::SRGB,
ColorPrimaries::BT709,
)
.unwrap();
let lrgb_for_simd = LinearRgb::try_from(rgb_for_simd).unwrap();
let xyb_simd = linear_rgb_to_xyb_simd(lrgb_for_simd);
let mut max_diff = [0.0f32; 3];
for (yuvxyb_pix, simd_pix) in xyb_yuvxyb.data().iter().zip(xyb_simd.data().iter()) {
for c in 0..3 {
let diff = (yuvxyb_pix[c] - simd_pix[c]).abs();
max_diff[c] = max_diff[c].max(diff);
}
}
assert!(
max_diff[0] < 1e-5 && max_diff[1] < 1e-5 && max_diff[2] < 1e-5,
"SIMD XYB differs from yuvxyb: max_diff={:?}",
max_diff
);
}
fn make_linear_rgb(width: usize, height: usize) -> LinearRgb {
use std::num::NonZeroUsize;
let data = vec![[0.5f32, 0.5, 0.5]; width * height];
LinearRgb::new(
data,
NonZeroUsize::new(width).unwrap(),
NonZeroUsize::new(height).unwrap(),
)
.unwrap()
}
#[test]
fn test_compute_rejects_too_large_input() {
const { assert!(MAX_IMAGE_PIXELS >= 8 * 8) };
let err = Ssimulacra2Error::ImageTooLarge {
actual: MAX_IMAGE_PIXELS + 1,
};
let msg = format!("{err}");
assert!(msg.contains("too large"), "unexpected message: {msg}");
assert!(
msg.contains(&MAX_IMAGE_PIXELS.to_string()),
"message should reference the limit: {msg}"
);
}
#[test]
fn test_compute_accepts_small_input() {
let img = make_linear_rgb(16, 16);
let score = compute_ssimulacra2_with_config(img.clone(), img, Ssimulacra2Config::default())
.expect("16x16 grey image must be accepted");
assert!(
(score - 100.0).abs() < 0.01,
"identical images should score 100, got {score}"
);
}
#[test]
fn test_sub_8_reflect_pads_instead_of_rejecting() {
use std::num::NonZeroUsize;
for (w, h) in [(4usize, 4usize), (1, 1), (3, 7), (7, 3)] {
let img = make_linear_rgb(w, h);
let score =
compute_ssimulacra2_with_config(img.clone(), img, Ssimulacra2Config::default())
.unwrap_or_else(|e| panic!("{w}x{h} must score, got {e:?}"));
assert!(
(score - 100.0).abs() < 0.01,
"identical {w}x{h} should score ~100, got {score}"
);
}
let a = make_linear_rgb(5, 5);
let b = LinearRgb::new(
vec![[0.9f32, 0.1, 0.2]; 25],
NonZeroUsize::new(5).unwrap(),
NonZeroUsize::new(5).unwrap(),
)
.unwrap();
let s = compute_ssimulacra2_with_config(a, b, Ssimulacra2Config::default())
.expect("5x5 differing pair must score");
assert!(s.is_finite() && s < 100.0, "5x5 differing score {s}");
}
}