1use 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
13pub const DEFAULT_WEIGHTS: [f64; 5] = [0.0448, 0.2856, 0.3001, 0.2363, 0.1333];
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
19pub enum MsSsimMethod {
20 #[default]
22 Product,
23 WeightedSum,
25}
26
27#[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
44pub 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 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 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 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}