Skip to main content

blazediff_ssim/
perceptual.rs

1//! An MS-SSIM variant with the departures from textbook SSIM turned into knobs.
2//!
3//! Phase-1 measurement (`crates/blazediff-ssim-benchmark`, KADID-10k) said
4//! dssim predicts human opinion better than `ms-ssim` by 0.037 SRCC, and that
5//! going single- to multi-scale was worth 0.069 on its own. This module exists
6//! to find out which of dssim's *remaining* departures are worth what — so each
7//! is independently switchable and the harness can ablate them rather than
8//! anyone guessing.
9//!
10//! With [`PerceptualOptions::default`] every knob is off and this reduces to
11//! `super::ms_ssim`, which `reduces_to_ms_ssim_with_every_knob_off` pins.
12
13use 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/// Which space the pyramid scales in and the statistics are computed in.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
24pub enum ColorSpace {
25    /// MATLAB's `rgb2gray` over gamma-encoded sRGB. What `ms-ssim` does.
26    #[default]
27    GammaLuma,
28    /// Downscale in linear light, compare in CIE L\*a\*b\*.
29    Lab,
30}
31
32/// How a scale's local map collapses to one number.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
34pub enum Pooling {
35    /// Plain mean. What every blazediff metric except Hitchhiker's does.
36    #[default]
37    Mean,
38    /// `mean − λ·MAD`: penalise a map whose damage is concentrated, which a
39    /// mean smears away. dssim pools by mean absolute deviation.
40    MeanAbsoluteDeviation,
41}
42
43#[derive(Clone, Debug)]
44pub struct PerceptualOptions {
45    pub weights: Vec<f64>,
46    pub method: MsSsimMethod,
47    pub color: ColorSpace,
48    /// Weight on each chroma channel relative to lightness. `0.0` ignores
49    /// chroma entirely; only meaningful under [`ColorSpace::Lab`].
50    pub chroma_weight: f64,
51    /// Extra octaves of downscaling applied to a\*/b\* before their statistics,
52    /// modelling the eye's lower chroma acuity. `0` compares them at full
53    /// resolution.
54    pub chroma_subsample: u32,
55    pub pooling: Pooling,
56    /// λ in the MAD pooling above.
57    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
74/// Pooled perceptual SSIM over an RGBA pair.
75///
76/// Takes [`Rgba8`] rather than [`crate::Plane`] because, unlike the rest of the
77/// family, this one can look at colour.
78pub 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    // Everything the pyramid writes to is allocated once, at the finest level's
118    // size, and re-sliced on the way down. Five scales times three channels is
119    // fifteen sets of full-size intermediates otherwise, and faulting those in
120    // costs more than the arithmetic they carry.
121    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        // Lightness always carries weight 1; each chroma channel carries
137        // `chroma_weight`, so the combination stays a weighted average.
138        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            // Read the pyramid's own planes unless the chroma pair actually has
155            // to shrink — the statistics only read them, so handing the level's
156            // plane straight through costs nothing, and only the shrunk pair
157            // needs somewhere to live.
158            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            // A chroma plane can shrink below the window; skip it rather than
169            // fail the whole comparison.
170            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            // The chroma channels overwrite the shared map, so the coarsest
192            // lightness map — the one the caller renders — is taken here.
193            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
237/// Collapse one scale's map to a number.
238fn 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
250/// Ping-pong scratch for dropping one chroma plane by whole octaves.
251///
252/// Both buffers are sized for a quarter of the finest level, which covers every
253/// octave of every level, so a subsampled configuration allocates twice for the
254/// whole comparison rather than twice per channel per scale.
255struct 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    /// Halve `plane` up to `octaves` times, stopping early if it would run out
270    /// of samples. Returns the size that came out.
271    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    /// The halved plane, or `original` where nothing was halved.
301    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
310/// The scale pyramid, holding whatever representation `ColorSpace` calls for.
311///
312/// Under [`ColorSpace::Lab`] the *linear-light RGB* planes are what descend;
313/// L\*a\*b\* is derived fresh at each level. Downscaling the L\*a\*b\* planes
314/// directly would average in a perceptual space, which is precisely the gamma
315/// error the linear-light step exists to avoid.
316///
317/// Every plane is allocated once at the finest level's size and only the first
318/// `width × height` samples are live at deeper levels.
319struct Pyramid {
320    source: Source,
321    /// Channel planes for the current level, lightness first. Derived once per
322    /// level rather than per channel read.
323    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    /// Boxed only because it is six times the other variant's size, and this
334    /// enum is one local per comparison — one indirection next to buffers
335    /// measured in megabytes.
336    Lab(Box<LinearPyramid>),
337}
338
339/// The linear-light planes a [`ColorSpace::Lab`] pyramid descends, and the
340/// spares the halved level lands in before the two swap.
341struct LinearPyramid {
342    first: LinearRgb,
343    second: LinearRgb,
344    spare: (LinearRgb, LinearRgb),
345}
346
347impl Pyramid {
348    /// Both views are already validated by [`perceptual_ssim`], so the slicing
349    /// below cannot go out of bounds and this stays infallible.
350    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    /// `(plane1, plane2)` for one channel, cropped to the current level.
398    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
436/// L\*a\*b\* planes for one side of a level, lightness first.
437fn 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    /// A square view over a buffer `image`/`textured` produced.
466    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    /// The contract that makes the ablation meaningful: with no knob turned on,
482    /// this is exactly MS-SSIM, so any measured delta is attributable to a knob
483    /// rather than to a reimplementation drifting.
484    #[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    /// The equal-luma blind spot, taken apart knob by knob.
538    ///
539    /// The two colours match under `rgb2gray`'s weights applied to
540    /// *gamma-encoded* channels. They do not match in linear-light Y, and
541    /// therefore not in L\* either — so moving to Lab recovers most of the blind
542    /// spot before a single chroma channel is switched on. Chroma then closes
543    /// the rest. Both effects are real and this pins their order.
544    #[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}