use super::convolve::halve_into;
use super::gaussian::{window_1d, SIGMA};
use super::stats::{scale_statistics_into, Workspace};
use super::{Plane, SsimOptions, SsimOutcome};
use crate::SsimError;
pub const DEFAULT_WEIGHTS: [f64; 5] = [0.0448, 0.2856, 0.3001, 0.2363, 0.1333];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum MsSsimMethod {
#[default]
Product,
WeightedSum,
}
#[derive(Clone, Debug)]
pub struct MsSsimOptions {
pub weights: Vec<f64>,
pub method: MsSsimMethod,
}
impl Default for MsSsimOptions {
fn default() -> Self {
Self {
weights: DEFAULT_WEIGHTS.to_vec(),
method: MsSsimMethod::default(),
}
}
}
pub fn ms_ssim(
image1: &Plane,
image2: &Plane,
options: &SsimOptions,
ms_options: &MsSsimOptions,
) -> Result<SsimOutcome, SsimError> {
Plane::validate_pair(image1, image2)?;
let scales = ms_options.weights.len();
if scales == 0 {
return Err(SsimError::Options(
"ms-ssim needs at least one scale weight".to_string(),
));
}
let window_size = options.window_size;
let smallest = image1.width.min(image1.height) >> (scales - 1);
if smallest < window_size {
return Err(SsimError::InputTooSmall {
width: image1.width as u32,
height: image1.height as u32,
minimum: (window_size << (scales - 1)) as u32,
});
}
let window = window_1d(window_size, SIGMA);
let dynamic_range = options.dynamic_range();
let c1 = (options.k1 * dynamic_range).powi(2) as f32;
let c2 = (options.k2 * dynamic_range).powi(2) as f32;
let mut luma1 = image1.samples.clone();
let mut luma2 = image2.samples.clone();
let mut width = image1.width;
let mut height = image1.height;
let full = width * height;
let mut workspace = Workspace::new(width, window_size);
let mut map = vec![0f32; full];
let mut cs_map = vec![0f32; full];
let mut spare1 = vec![0f32; full / 4 + 1];
let mut spare2 = vec![0f32; full / 4 + 1];
let mut ssim_per_scale = Vec::with_capacity(scales);
let mut cs_per_scale = Vec::with_capacity(scales);
let mut coarsest_map = Vec::new();
for scale in 0..scales {
let (ssim_sum, cs_sum) = scale_statistics_into(
&luma1,
&luma2,
width,
height,
&window,
c1,
c2,
&mut workspace,
&mut map,
&mut cs_map,
);
let len = (width * height) as f64;
ssim_per_scale.push(ssim_sum / len);
cs_per_scale.push(cs_sum / len);
if scale == scales - 1 {
coarsest_map = map[..width * height].to_vec();
break;
}
let (next_width, next_height) = halve_into(&luma1, width, height, &mut spare1);
halve_into(&luma2, width, height, &mut spare2);
std::mem::swap(&mut luma1, &mut spare1);
std::mem::swap(&mut luma2, &mut spare2);
width = next_width;
height = next_height;
}
let last = scales - 1;
let coarse_to_fine = cs_per_scale[..last].iter().zip(&ms_options.weights[..last]);
let score = match ms_options.method {
MsSsimMethod::Product => {
let product = coarse_to_fine.fold(1.0, |acc, (cs, weight)| acc * cs.powf(*weight));
product * ssim_per_scale[last].powf(ms_options.weights[last])
}
MsSsimMethod::WeightedSum => {
let total: f64 = ms_options.weights.iter().sum();
let sum = coarse_to_fine
.map(|(cs, weight)| cs * (weight / total))
.sum::<f64>();
sum + ssim_per_scale[last] * (ms_options.weights[last] / total)
}
};
Ok(SsimOutcome {
score,
map: coarsest_map,
map_width: width,
map_height: height,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn plane(width: usize, height: usize, f: impl Fn(usize, usize) -> f32) -> Plane {
Plane {
samples: (0..width * height)
.map(|i| f(i % width, i / width))
.collect(),
width,
height,
}
}
#[test]
fn identical_images_score_one() {
let image = plane(256, 256, |x, y| ((x * 7 + y * 13) % 256) as f32);
let outcome = ms_ssim(
&image,
&image,
&SsimOptions::default(),
&MsSsimOptions::default(),
)
.unwrap();
assert!((outcome.score - 1.0).abs() < 1e-12, "{}", outcome.score);
}
#[test]
fn map_comes_from_the_coarsest_scale() {
let image = plane(256, 256, |x, y| ((x ^ y) % 256) as f32);
let outcome = ms_ssim(
&image,
&image,
&SsimOptions::default(),
&MsSsimOptions::default(),
)
.unwrap();
assert_eq!(outcome.map_width, 16);
assert_eq!(outcome.map_height, 16);
assert_eq!(outcome.map.len(), 256);
}
#[test]
fn images_too_small_for_the_pyramid_are_rejected() {
let small = plane(128, 128, |_, _| 100.0);
assert!(matches!(
ms_ssim(
&small,
&small,
&SsimOptions::default(),
&MsSsimOptions::default()
),
Err(SsimError::InputTooSmall { .. })
));
}
#[test]
fn weighted_sum_and_product_agree_on_identical_images() {
let image = plane(256, 256, |x, y| ((x * 3 + y) % 251) as f32);
let product = ms_ssim(
&image,
&image,
&SsimOptions::default(),
&MsSsimOptions::default(),
)
.unwrap();
let weighted = ms_ssim(
&image,
&image,
&SsimOptions::default(),
&MsSsimOptions {
method: MsSsimMethod::WeightedSum,
..Default::default()
},
)
.unwrap();
assert!((product.score - weighted.score).abs() < 1e-9);
}
#[test]
fn degraded_images_score_below_intact_ones() {
let base = plane(256, 256, |x, y| ((x * 5 + y * 3) % 240) as f32);
let nudged = plane(256, 256, |x, y| ((x * 5 + y * 3) % 240) as f32 + 2.0);
let scrambled = plane(256, 256, |x, y| (((x * 91 + y * 173) % 256) ^ 0x5a) as f32);
let options = SsimOptions::default();
let close = ms_ssim(&base, &nudged, &options, &MsSsimOptions::default())
.unwrap()
.score;
let far = ms_ssim(&base, &scrambled, &options, &MsSsimOptions::default())
.unwrap()
.score;
assert!(close > far, "{close} should beat {far}");
}
}