Skip to main content

blazediff_ssim/
multiscale.rs

1//! MS-SSIM: SSIM pooled across a dyadic scale pyramid.
2//!
3//! Z. Wang, E. P. Simoncelli and A. C. Bovik, "Multi-scale structural
4//! similarity for image quality assessment," IEEE Asilomar Conference on
5//! Signals, Systems and Computers, 2003.
6
7use super::convolve::halve_into;
8use super::gaussian::{window_1d, SIGMA};
9use super::stats::{scale_statistics_into, Workspace};
10use super::{Plane, SsimOptions, SsimOutcome};
11use crate::SsimError;
12
13/// Per-scale weights from the MS-SSIM paper. The number of entries is the
14/// number of scales, so `weights.len()` replaces MATLAB's separate `level`.
15pub const DEFAULT_WEIGHTS: [f64; 5] = [0.0448, 0.2856, 0.3001, 0.2363, 0.1333];
16
17/// How the per-scale scores are pooled into one number.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
19pub enum MsSsimMethod {
20    /// `∏ mcs_l^w_l · mssim_L^w_L` — MATLAB's default.
21    #[default]
22    Product,
23    /// `Σ mcs_l·w_l + mssim_L·w_L` with the weights renormalised to sum 1.
24    WeightedSum,
25}
26
27/// Knobs specific to MS-SSIM; the shared window and stability constants live
28/// in [`SsimOptions`].
29#[derive(Clone, Debug)]
30pub struct MsSsimOptions {
31    pub weights: Vec<f64>,
32    pub method: MsSsimMethod,
33}
34
35impl Default for MsSsimOptions {
36    fn default() -> Self {
37        Self {
38            weights: DEFAULT_WEIGHTS.to_vec(),
39            method: MsSsimMethod::default(),
40        }
41    }
42}
43
44/// Pooled multi-scale SSIM, plus the local map from the coarsest scale.
45///
46/// Each scale contributes its contrast-structure mean; only the coarsest one
47/// contributes a full SSIM mean, matching `msssim.m`. Unlike single-scale
48/// SSIM, the per-scale statistics use symmetric `'same'` filtering, so the map
49/// is the size of the coarsest plane rather than a `'valid'` crop of it.
50///
51/// [`MsSsimMethod::Product`] returns `NaN` when a scale's mean
52/// contrast-structure term goes negative, which takes globally anticorrelated
53/// content (an inverted image, say) rather than ordinary degradation: raising a
54/// negative base to a fractional power has no real value. Both reference
55/// implementations degenerate the same way — the JS gives `NaN`, MATLAB gives a
56/// complex number — so the behaviour is kept rather than papered over.
57/// [`MsSsimMethod::WeightedSum`] stays finite throughout.
58pub fn ms_ssim(
59    image1: &Plane,
60    image2: &Plane,
61    options: &SsimOptions,
62    ms_options: &MsSsimOptions,
63) -> Result<SsimOutcome, SsimError> {
64    Plane::validate_pair(image1, image2)?;
65
66    let scales = ms_options.weights.len();
67    if scales == 0 {
68        return Err(SsimError::Options(
69            "ms-ssim needs at least one scale weight".to_string(),
70        ));
71    }
72
73    // MATLAB refuses the whole computation when the coarsest plane would be
74    // narrower than the window, rather than silently pooling garbage.
75    let window_size = options.window_size;
76    let smallest = image1.width.min(image1.height) >> (scales - 1);
77    if smallest < window_size {
78        return Err(SsimError::InputTooSmall {
79            width: image1.width as u32,
80            height: image1.height as u32,
81            minimum: (window_size << (scales - 1)) as u32,
82        });
83    }
84
85    let window = window_1d(window_size, SIGMA);
86    let dynamic_range = options.dynamic_range();
87    let c1 = (options.k1 * dynamic_range).powi(2) as f32;
88    let c2 = (options.k2 * dynamic_range).powi(2) as f32;
89
90    let mut luma1 = image1.samples.clone();
91    let mut luma2 = image2.samples.clone();
92    let mut width = image1.width;
93    let mut height = image1.height;
94
95    // Every buffer the pyramid needs, sized for the finest level and re-sliced
96    // on the way down; the halved planes ping-pong through the spares.
97    let full = width * height;
98    let mut workspace = Workspace::new(width, window_size);
99    let mut map = vec![0f32; full];
100    let mut cs_map = vec![0f32; full];
101    let mut spare1 = vec![0f32; full / 4 + 1];
102    let mut spare2 = vec![0f32; full / 4 + 1];
103
104    let mut ssim_per_scale = Vec::with_capacity(scales);
105    let mut cs_per_scale = Vec::with_capacity(scales);
106    let mut coarsest_map = Vec::new();
107
108    for scale in 0..scales {
109        let (ssim_sum, cs_sum) = scale_statistics_into(
110            &luma1,
111            &luma2,
112            width,
113            height,
114            &window,
115            c1,
116            c2,
117            &mut workspace,
118            &mut map,
119            &mut cs_map,
120        );
121        let len = (width * height) as f64;
122        ssim_per_scale.push(ssim_sum / len);
123        cs_per_scale.push(cs_sum / len);
124
125        if scale == scales - 1 {
126            coarsest_map = map[..width * height].to_vec();
127            break;
128        }
129
130        let (next_width, next_height) = halve_into(&luma1, width, height, &mut spare1);
131        halve_into(&luma2, width, height, &mut spare2);
132        std::mem::swap(&mut luma1, &mut spare1);
133        std::mem::swap(&mut luma2, &mut spare2);
134        width = next_width;
135        height = next_height;
136    }
137
138    // Every scale but the coarsest contributes contrast-structure only; the
139    // coarsest contributes full SSIM.
140    let last = scales - 1;
141    let coarse_to_fine = cs_per_scale[..last].iter().zip(&ms_options.weights[..last]);
142    let score = match ms_options.method {
143        MsSsimMethod::Product => {
144            let product = coarse_to_fine.fold(1.0, |acc, (cs, weight)| acc * cs.powf(*weight));
145            product * ssim_per_scale[last].powf(ms_options.weights[last])
146        }
147        MsSsimMethod::WeightedSum => {
148            let total: f64 = ms_options.weights.iter().sum();
149            let sum = coarse_to_fine
150                .map(|(cs, weight)| cs * (weight / total))
151                .sum::<f64>();
152            sum + ssim_per_scale[last] * (ms_options.weights[last] / total)
153        }
154    };
155
156    Ok(SsimOutcome {
157        score,
158        map: coarsest_map,
159        map_width: width,
160        map_height: height,
161    })
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn plane(width: usize, height: usize, f: impl Fn(usize, usize) -> f32) -> Plane {
169        Plane {
170            samples: (0..width * height)
171                .map(|i| f(i % width, i / width))
172                .collect(),
173            width,
174            height,
175        }
176    }
177
178    #[test]
179    fn identical_images_score_one() {
180        let image = plane(256, 256, |x, y| ((x * 7 + y * 13) % 256) as f32);
181        let outcome = ms_ssim(
182            &image,
183            &image,
184            &SsimOptions::default(),
185            &MsSsimOptions::default(),
186        )
187        .unwrap();
188        assert!((outcome.score - 1.0).abs() < 1e-12, "{}", outcome.score);
189    }
190
191    #[test]
192    fn map_comes_from_the_coarsest_scale() {
193        let image = plane(256, 256, |x, y| ((x ^ y) % 256) as f32);
194        let outcome = ms_ssim(
195            &image,
196            &image,
197            &SsimOptions::default(),
198            &MsSsimOptions::default(),
199        )
200        .unwrap();
201        assert_eq!(outcome.map_width, 16);
202        assert_eq!(outcome.map_height, 16);
203        assert_eq!(outcome.map.len(), 256);
204    }
205
206    #[test]
207    fn images_too_small_for_the_pyramid_are_rejected() {
208        let small = plane(128, 128, |_, _| 100.0);
209        assert!(matches!(
210            ms_ssim(
211                &small,
212                &small,
213                &SsimOptions::default(),
214                &MsSsimOptions::default()
215            ),
216            Err(SsimError::InputTooSmall { .. })
217        ));
218    }
219
220    #[test]
221    fn weighted_sum_and_product_agree_on_identical_images() {
222        let image = plane(256, 256, |x, y| ((x * 3 + y) % 251) as f32);
223        let product = ms_ssim(
224            &image,
225            &image,
226            &SsimOptions::default(),
227            &MsSsimOptions::default(),
228        )
229        .unwrap();
230        let weighted = ms_ssim(
231            &image,
232            &image,
233            &SsimOptions::default(),
234            &MsSsimOptions {
235                method: MsSsimMethod::WeightedSum,
236                ..Default::default()
237            },
238        )
239        .unwrap();
240        assert!((product.score - weighted.score).abs() < 1e-9);
241    }
242
243    #[test]
244    fn degraded_images_score_below_intact_ones() {
245        let base = plane(256, 256, |x, y| ((x * 5 + y * 3) % 240) as f32);
246        let nudged = plane(256, 256, |x, y| ((x * 5 + y * 3) % 240) as f32 + 2.0);
247        let scrambled = plane(256, 256, |x, y| (((x * 91 + y * 173) % 256) ^ 0x5a) as f32);
248
249        let options = SsimOptions::default();
250        let close = ms_ssim(&base, &nudged, &options, &MsSsimOptions::default())
251            .unwrap()
252            .score;
253        let far = ms_ssim(&base, &scrambled, &options, &MsSsimOptions::default())
254            .unwrap()
255            .score;
256        assert!(close > far, "{close} should beat {far}");
257    }
258}