1use super::color::{linear_rgb_to_lab_into, to_linear_rgb, LinearRgb};
14use super::convolve::halve_into;
15use super::gaussian::{window_1d, SIGMA};
16use super::multiscale::{MsSsimMethod, DEFAULT_WEIGHTS};
17use super::simd::sum_absolute_deviation;
18use super::stats::{scale_statistics_into, Workspace};
19use super::{SsimOptions, SsimOutcome};
20use crate::{Rgba8, SsimError};
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
24pub enum ColorSpace {
25 #[default]
27 GammaLuma,
28 Lab,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
34pub enum Pooling {
35 #[default]
37 Mean,
38 MeanAbsoluteDeviation,
41}
42
43#[derive(Clone, Debug)]
44pub struct PerceptualOptions {
45 pub weights: Vec<f64>,
46 pub method: MsSsimMethod,
47 pub color: ColorSpace,
48 pub chroma_weight: f64,
51 pub chroma_subsample: u32,
55 pub pooling: Pooling,
56 pub deviation_weight: f64,
58}
59
60impl Default for PerceptualOptions {
61 fn default() -> Self {
62 Self {
63 weights: DEFAULT_WEIGHTS.to_vec(),
64 method: MsSsimMethod::default(),
65 color: ColorSpace::default(),
66 chroma_weight: 0.0,
67 chroma_subsample: 0,
68 pooling: Pooling::default(),
69 deviation_weight: 1.0,
70 }
71 }
72}
73
74pub fn perceptual_ssim(
79 image1: Rgba8<'_>,
80 image2: Rgba8<'_>,
81 options: &SsimOptions,
82 perceptual: &PerceptualOptions,
83) -> Result<SsimOutcome, SsimError> {
84 let scales = perceptual.weights.len();
85 if scales == 0 {
86 return Err(SsimError::Options(
87 "perceptual ssim needs at least one scale weight".to_string(),
88 ));
89 }
90
91 if image1.width != image2.width || image1.height != image2.height {
92 return Err(SsimError::SizeMismatch {
93 img1_width: image1.width as u32,
94 img1_height: image1.height as u32,
95 img2_width: image2.width as u32,
96 img2_height: image2.height as u32,
97 });
98 }
99 image1.validate()?;
100 image2.validate()?;
101
102 let (width, height) = (image1.width, image1.height);
103 let window_size = options.window_size;
104 if width.min(height) >> (scales - 1) < window_size {
105 return Err(SsimError::InputTooSmall {
106 width: width as u32,
107 height: height as u32,
108 minimum: (window_size << (scales - 1)) as u32,
109 });
110 }
111
112 let window = window_1d(window_size, SIGMA);
113 let dynamic_range = options.dynamic_range();
114 let c1 = (options.k1 * dynamic_range).powi(2) as f32;
115 let c2 = (options.k2 * dynamic_range).powi(2) as f32;
116
117 let full = width * height;
122 let mut pyramid = Pyramid::new(image1, image2, perceptual.color);
123 let mut workspace = Workspace::new(width, window_size);
124 let mut map = vec![0f32; full];
125 let mut cs_map = vec![0f32; full];
126 let mut chroma1 = Halver::new(perceptual.chroma_subsample > 0, full);
127 let mut chroma2 = Halver::new(perceptual.chroma_subsample > 0, full);
128
129 let mut ssim_per_scale = Vec::with_capacity(scales);
130 let mut cs_per_scale = Vec::with_capacity(scales);
131 let mut coarsest = (Vec::new(), 0usize, 0usize);
132
133 for scale in 0..scales {
134 let (width, height) = pyramid.size();
135
136 let mut ssim_total = 0.0;
139 let mut cs_total = 0.0;
140 let mut weight_total = 0.0;
141 let mut lightness_map = Vec::new();
142
143 for index in 0..pyramid.channel_count() {
144 let is_chroma = index > 0;
145 let weight = if is_chroma {
146 perceptual.chroma_weight
147 } else {
148 1.0
149 };
150 if weight == 0.0 {
151 continue;
152 }
153
154 let (source1, source2) = pyramid.channel(index);
159 let (plane1, plane2, plane_width, plane_height) =
160 if is_chroma && perceptual.chroma_subsample > 0 {
161 let octaves = perceptual.chroma_subsample;
162 let (w, h) = chroma1.halve(source1, width, height, octaves);
163 chroma2.halve(source2, width, height, octaves);
164 (chroma1.plane(source1), chroma2.plane(source2), w, h)
165 } else {
166 (source1, source2, width, height)
167 };
168 if plane_width < window_size || plane_height < window_size {
171 continue;
172 }
173
174 let len = plane_width * plane_height;
175 let (ssim_sum, cs_sum) = scale_statistics_into(
176 plane1,
177 plane2,
178 plane_width,
179 plane_height,
180 &window,
181 c1,
182 c2,
183 &mut workspace,
184 &mut map,
185 &mut cs_map,
186 );
187 ssim_total += weight * pool(&map[..len], ssim_sum, perceptual);
188 cs_total += weight * pool(&cs_map[..len], cs_sum, perceptual);
189 weight_total += weight;
190
191 if !is_chroma && scale == scales - 1 {
194 lightness_map = map[..len].to_vec();
195 }
196 }
197
198 if weight_total == 0.0 {
199 return Err(SsimError::Options(
200 "every channel was skipped; check chroma_weight and chroma_subsample".to_string(),
201 ));
202 }
203 ssim_per_scale.push(ssim_total / weight_total);
204 cs_per_scale.push(cs_total / weight_total);
205
206 if scale == scales - 1 {
207 coarsest = (lightness_map, width, height);
208 break;
209 }
210 pyramid.descend();
211 }
212
213 let last = scales - 1;
214 let coarse_to_fine = cs_per_scale[..last].iter().zip(&perceptual.weights[..last]);
215 let score = match perceptual.method {
216 MsSsimMethod::Product => {
217 let product = coarse_to_fine.fold(1.0, |acc, (cs, weight)| acc * cs.powf(*weight));
218 product * ssim_per_scale[last].powf(perceptual.weights[last])
219 }
220 MsSsimMethod::WeightedSum => {
221 let total: f64 = perceptual.weights.iter().sum();
222 let sum = coarse_to_fine
223 .map(|(cs, weight)| cs * (weight / total))
224 .sum::<f64>();
225 sum + ssim_per_scale[last] * (perceptual.weights[last] / total)
226 }
227 };
228
229 Ok(SsimOutcome {
230 score,
231 map: coarsest.0,
232 map_width: coarsest.1,
233 map_height: coarsest.2,
234 })
235}
236
237fn pool(map: &[f32], sum: f64, options: &PerceptualOptions) -> f64 {
239 let count = map.len() as f64;
240 let mean = sum / count;
241 match options.pooling {
242 Pooling::Mean => mean,
243 Pooling::MeanAbsoluteDeviation => {
244 let deviation = sum_absolute_deviation(map, mean) / count;
245 mean - options.deviation_weight * deviation
246 }
247 }
248}
249
250struct Halver {
256 buffers: [Vec<f32>; 2],
257 halved: bool,
258}
259
260impl Halver {
261 fn new(needed: bool, full: usize) -> Self {
262 let capacity = if needed { full / 4 + 1 } else { 0 };
263 Self {
264 buffers: [vec![0f32; capacity], vec![0f32; capacity]],
265 halved: false,
266 }
267 }
268
269 fn halve(
272 &mut self,
273 plane: &[f32],
274 width: usize,
275 height: usize,
276 octaves: u32,
277 ) -> (usize, usize) {
278 let (front, back) = self.buffers.split_at_mut(1);
279 let (front, back) = (&mut front[0], &mut back[0]);
280 let (mut width, mut height) = (width, height);
281 self.halved = false;
282 for _ in 0..octaves {
283 if width < 2 || height < 2 {
284 break;
285 }
286 let (next_width, next_height) = if self.halved {
287 let size = halve_into(front, width, height, back);
288 std::mem::swap(front, back);
289 size
290 } else {
291 halve_into(plane, width, height, front)
292 };
293 self.halved = true;
294 width = next_width;
295 height = next_height;
296 }
297 (width, height)
298 }
299
300 fn plane<'a>(&'a self, original: &'a [f32]) -> &'a [f32] {
302 if self.halved {
303 &self.buffers[0]
304 } else {
305 original
306 }
307 }
308}
309
310struct Pyramid {
320 source: Source,
321 first: Vec<Vec<f32>>,
324 second: Vec<Vec<f32>>,
325 width: usize,
326 height: usize,
327}
328
329enum Source {
330 GammaLuma {
331 spare: (Vec<f32>, Vec<f32>),
332 },
333 Lab(Box<LinearPyramid>),
337}
338
339struct LinearPyramid {
342 first: LinearRgb,
343 second: LinearRgb,
344 spare: (LinearRgb, LinearRgb),
345}
346
347impl Pyramid {
348 fn new(image1: Rgba8<'_>, image2: Rgba8<'_>, color: ColorSpace) -> Self {
351 let (width, height) = (image1.width, image1.height);
352 let full = width * height;
353 let quarter = full / 4 + 1;
354 let (source, first, second) = match color {
355 ColorSpace::GammaLuma => (
356 Source::GammaLuma {
357 spare: (vec![0f32; quarter], vec![0f32; quarter]),
358 },
359 vec![crate::grayscale::to_luma(&image1.data[..full * 4])],
360 vec![crate::grayscale::to_luma(&image2.data[..full * 4])],
361 ),
362 ColorSpace::Lab => {
363 let linear1 = to_linear_rgb(&image1.data[..full * 4]);
364 let linear2 = to_linear_rgb(&image2.data[..full * 4]);
365 let mut first = vec![vec![0f32; full], vec![0f32; full], vec![0f32; full]];
366 let mut second = vec![vec![0f32; full], vec![0f32; full], vec![0f32; full]];
367 write_lab(&linear1, full, &mut first);
368 write_lab(&linear2, full, &mut second);
369 (
370 Source::Lab(Box::new(LinearPyramid {
371 first: linear1,
372 second: linear2,
373 spare: (LinearRgb::zeroed(quarter), LinearRgb::zeroed(quarter)),
374 })),
375 first,
376 second,
377 )
378 }
379 };
380 Self {
381 source,
382 first,
383 second,
384 width,
385 height,
386 }
387 }
388
389 fn size(&self) -> (usize, usize) {
390 (self.width, self.height)
391 }
392
393 fn channel_count(&self) -> usize {
394 self.first.len()
395 }
396
397 fn channel(&self, index: usize) -> (&[f32], &[f32]) {
399 let len = self.width * self.height;
400 (&self.first[index][..len], &self.second[index][..len])
401 }
402
403 fn descend(&mut self) {
404 let (width, height) = (self.width, self.height);
405 match &mut self.source {
406 Source::GammaLuma { spare } => {
407 let (next_width, next_height) =
408 halve_into(&self.first[0], width, height, &mut spare.0);
409 halve_into(&self.second[0], width, height, &mut spare.1);
410 std::mem::swap(&mut self.first[0], &mut spare.0);
411 std::mem::swap(&mut self.second[0], &mut spare.1);
412 self.width = next_width;
413 self.height = next_height;
414 }
415 Source::Lab(linear) => {
416 let LinearPyramid {
417 first,
418 second,
419 spare,
420 } = &mut **linear;
421 let (next_width, next_height) = first.halve_into(width, height, &mut spare.0);
422 second.halve_into(width, height, &mut spare.1);
423 std::mem::swap(first, &mut spare.0);
424 std::mem::swap(second, &mut spare.1);
425 self.width = next_width;
426 self.height = next_height;
427
428 let len = next_width * next_height;
429 write_lab(first, len, &mut self.first);
430 write_lab(second, len, &mut self.second);
431 }
432 }
433 }
434}
435
436fn write_lab(linear: &LinearRgb, len: usize, channels: &mut [Vec<f32>]) {
438 let (lightness, rest) = channels.split_at_mut(1);
439 let (green_red, blue_yellow) = rest.split_at_mut(1);
440 linear_rgb_to_lab_into(
441 linear,
442 len,
443 &mut lightness[0],
444 &mut green_red[0],
445 &mut blue_yellow[0],
446 );
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::{ms_ssim, MsSsimOptions, Plane};
453
454 const SIDE: usize = 256;
455
456 fn image(width: usize, height: usize, f: impl Fn(usize, usize) -> [u8; 3]) -> Vec<u8> {
457 let mut data = vec![0u8; width * height * 4];
458 for (i, pixel) in data.chunks_exact_mut(4).enumerate() {
459 let rgb = f(i % width, i / width);
460 pixel.copy_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
461 }
462 data
463 }
464
465 fn view(data: &[u8]) -> Rgba8<'_> {
467 Rgba8::new(data, SIDE, SIDE)
468 }
469
470 fn textured(shift: u8) -> Vec<u8> {
471 image(SIDE, SIDE, |x, y| {
472 let v = ((x * 7 + y * 13) % 251) as u8;
473 [
474 v.wrapping_add(shift),
475 (v / 2).wrapping_add(shift),
476 (v / 3).wrapping_add(shift),
477 ]
478 })
479 }
480
481 #[test]
485 fn reduces_to_ms_ssim_with_every_knob_off() {
486 let image1 = textured(0);
487 let image2 = textured(9);
488 let options = SsimOptions::default();
489
490 let baseline = ms_ssim(
491 &Plane::from_rgba8(view(&image1)).unwrap(),
492 &Plane::from_rgba8(view(&image2)).unwrap(),
493 &options,
494 &MsSsimOptions::default(),
495 )
496 .unwrap()
497 .score;
498 let ours = perceptual_ssim(
499 view(&image1),
500 view(&image2),
501 &options,
502 &PerceptualOptions::default(),
503 )
504 .unwrap()
505 .score;
506
507 assert_eq!(ours, baseline, "default must be bit-identical to ms-ssim");
508 }
509
510 #[test]
511 fn identical_images_score_one_in_every_configuration() {
512 let image1 = textured(0);
513 for color in [ColorSpace::GammaLuma, ColorSpace::Lab] {
514 for pooling in [Pooling::Mean, Pooling::MeanAbsoluteDeviation] {
515 let perceptual = PerceptualOptions {
516 color,
517 pooling,
518 chroma_weight: if color == ColorSpace::Lab { 0.5 } else { 0.0 },
519 ..Default::default()
520 };
521 let score = perceptual_ssim(
522 view(&image1),
523 view(&image1),
524 &SsimOptions::default(),
525 &perceptual,
526 )
527 .unwrap()
528 .score;
529 assert!(
530 (score - 1.0).abs() < 1e-9,
531 "{color:?}/{pooling:?} scored {score}"
532 );
533 }
534 }
535 }
536
537 #[test]
545 fn chroma_weight_opens_the_equal_luma_blind_spot() {
546 const EQUAL_LUMA: [[u8; 3]; 2] = [[200, 30, 60], [111, 39, 247]];
547 let board = |swapped: bool| {
548 image(SIDE, SIDE, |x, y| {
549 let square = (x / 8 + y / 8) % 2;
550 EQUAL_LUMA[if swapped { 1 - square } else { square }]
551 })
552 };
553 let (image1, image2) = (board(false), board(true));
554 let options = SsimOptions::default();
555 let score = |perceptual: PerceptualOptions| {
556 perceptual_ssim(view(&image1), view(&image2), &options, &perceptual)
557 .unwrap()
558 .score
559 };
560
561 let gamma_luma = score(PerceptualOptions::default());
562 let lab_lightness = score(PerceptualOptions {
563 color: ColorSpace::Lab,
564 chroma_weight: 0.0,
565 ..Default::default()
566 });
567 let lab_chroma = score(PerceptualOptions {
568 color: ColorSpace::Lab,
569 chroma_weight: 1.0,
570 ..Default::default()
571 });
572
573 assert!(
574 gamma_luma > 0.99,
575 "gamma luma is blind by construction, got {gamma_luma}"
576 );
577 assert!(
578 lab_lightness < 0.8,
579 "linear-light L* alone should already see most of it, got {lab_lightness}"
580 );
581 assert!(
582 lab_chroma < lab_lightness,
583 "chroma should sharpen it further: {lab_chroma} vs {lab_lightness}"
584 );
585 }
586
587 #[test]
588 fn deviation_pooling_never_scores_above_the_mean() {
589 let image1 = textured(0);
590 let image2 = textured(20);
591 let options = SsimOptions::default();
592 let with = |pooling| {
593 perceptual_ssim(
594 view(&image1),
595 view(&image2),
596 &options,
597 &PerceptualOptions {
598 pooling,
599 ..Default::default()
600 },
601 )
602 .unwrap()
603 .score
604 };
605 assert!(with(Pooling::MeanAbsoluteDeviation) <= with(Pooling::Mean));
606 }
607
608 #[test]
609 fn chroma_subsampling_shrinks_the_colour_planes_without_failing() {
610 let image1 = textured(0);
611 let image2 = textured(12);
612 let score = perceptual_ssim(
613 view(&image1),
614 view(&image2),
615 &SsimOptions::default(),
616 &PerceptualOptions {
617 color: ColorSpace::Lab,
618 chroma_weight: 0.5,
619 chroma_subsample: 2,
620 ..Default::default()
621 },
622 )
623 .unwrap()
624 .score;
625 assert!(score.is_finite() && score < 1.0, "got {score}");
626 }
627}