Skip to main content

blazediff_ssim/
lib.rs

1//! Structural-similarity metrics: SSIM, MS-SSIM and Hitchhiker's SSIM.
2//!
3//! Where a pixel diff answers "which pixels changed", these answer "how alike
4//! do these look": a single score in `0..=1` that tolerates imperceptible noise
5//! and punishes structural damage. They are ports of `@blazediff/ssim`, which
6//! is validated against the reference MATLAB scripts in `packages/ssim/matlab`;
7//! `tests/matlab_parity.rs` holds the Rust side of that proof to the same
8//! tolerances.
9//!
10//! The hot loops are vectorised through `simd`, which picks a lane backend at
11//! compile time. Tap-by-tap accumulation order is kept identical to the JS
12//! reference so the ports inherit its measured MATLAB agreement rather than
13//! drifting by an unmeasured amount.
14//!
15//! ```
16//! use blazediff_ssim::{ms_ssim, MsSsimOptions, Plane, Rgba8, SsimOptions};
17//!
18//! # fn main() -> Result<(), blazediff_ssim::SsimError> {
19//! # let (width, height) = (256, 256);
20//! # let (rgba1, rgba2) = (vec![0u8; width * height * 4], vec![0u8; width * height * 4]);
21//! let plane1 = Plane::from_rgba8(Rgba8::new(&rgba1, width, height))?;
22//! let plane2 = Plane::from_rgba8(Rgba8::new(&rgba2, width, height))?;
23//!
24//! let outcome = ms_ssim(
25//!     &plane1,
26//!     &plane2,
27//!     &SsimOptions::default(),
28//!     &MsSsimOptions::default(),
29//! )?;
30//! println!("{:.6}", outcome.score);
31//! # Ok(())
32//! # }
33//! ```
34
35mod color;
36mod convolve;
37mod error;
38mod gaussian;
39mod grayscale;
40mod hitchhikers;
41mod map;
42mod multiscale;
43#[cfg(feature = "napi")]
44mod napi;
45mod perceptual;
46#[cfg(feature = "python")]
47mod python;
48mod simd;
49mod standard;
50mod stats;
51
52pub use error::SsimError;
53pub use hitchhikers::{hitchhikers_ssim, HitchhikersOptions};
54pub use map::render_map;
55pub use multiscale::{ms_ssim, MsSsimMethod, MsSsimOptions, DEFAULT_WEIGHTS};
56pub use perceptual::{perceptual_ssim, ColorSpace, PerceptualOptions, Pooling};
57pub use standard::ssim;
58
59/// A borrowed RGBA8 image, 4 bytes per pixel, row-major.
60///
61/// The metrics never need to own their input, so this is what they take:
62/// `Copy`, four words wide, and cheap to build over whatever buffer the caller
63/// already has.
64#[derive(Clone, Copy, Debug)]
65pub struct Rgba8<'a> {
66    pub data: &'a [u8],
67    pub width: usize,
68    pub height: usize,
69}
70
71impl<'a> Rgba8<'a> {
72    pub fn new(data: &'a [u8], width: usize, height: usize) -> Self {
73        Self {
74            data,
75            width,
76            height,
77        }
78    }
79
80    /// Reject a buffer that cannot hold `width * height` RGBA pixels.
81    fn validate(&self) -> Result<usize, SsimError> {
82        let pixels = self.width * self.height;
83        if self.data.len() < pixels * 4 {
84            return Err(SsimError::Options(format!(
85                "image data is {} bytes, need {} for {}x{} RGBA",
86                self.data.len(),
87                pixels * 4,
88                self.width,
89                self.height
90            )));
91        }
92        Ok(pixels)
93    }
94}
95
96/// A single-channel luma plane, the form every metric works in.
97#[derive(Clone, Debug)]
98pub struct Plane {
99    pub samples: Vec<f32>,
100    pub width: usize,
101    pub height: usize,
102}
103
104impl Plane {
105    /// Reduce an RGBA image to luma using MATLAB's `rgb2gray` weights.
106    pub fn from_rgba8(image: Rgba8<'_>) -> Result<Self, SsimError> {
107        let pixels = image.validate()?;
108        Ok(Self {
109            samples: grayscale::to_luma(&image.data[..pixels * 4]),
110            width: image.width,
111            height: image.height,
112        })
113    }
114
115    /// Reject a pair the metrics would otherwise index out of bounds.
116    ///
117    /// Every metric reads `image2` with `image1`'s dimensions, so a mismatch is
118    /// a panic rather than a wrong answer. This is the guard that turns it into
119    /// an error at the crate boundary.
120    fn validate_pair(image1: &Plane, image2: &Plane) -> Result<(), SsimError> {
121        if image1.width != image2.width || image1.height != image2.height {
122            return Err(SsimError::SizeMismatch {
123                img1_width: image1.width as u32,
124                img1_height: image1.height as u32,
125                img2_width: image2.width as u32,
126                img2_height: image2.height as u32,
127            });
128        }
129        let expected = image1.width * image1.height;
130        if image1.samples.len() < expected || image2.samples.len() < expected {
131            return Err(SsimError::Options(format!(
132                "plane samples are shorter than {}x{}",
133                image1.width, image1.height
134            )));
135        }
136        Ok(())
137    }
138}
139
140/// Knobs shared by every SSIM variant.
141#[derive(Clone, Copy, Debug, PartialEq)]
142pub struct SsimOptions {
143    /// Side of the local window. Default 11, as in every reference.
144    pub window_size: usize,
145    /// Luminance stability constant: `c1 = (k1·L)²`. Default 0.01.
146    pub k1: f64,
147    /// Contrast stability constant: `c2 = (k2·L)²`. Default 0.03.
148    pub k2: f64,
149    /// Sample bit depth, which sets the dynamic range `L = 2^bit_depth - 1`.
150    /// Default 8.
151    pub bit_depth: u32,
152}
153
154impl Default for SsimOptions {
155    fn default() -> Self {
156        Self {
157            window_size: 11,
158            k1: 0.01,
159            k2: 0.03,
160            bit_depth: 8,
161        }
162    }
163}
164
165impl SsimOptions {
166    /// `L`, the dynamic range the stability constants are scaled by.
167    #[inline]
168    pub fn dynamic_range(&self) -> f64 {
169        2f64.powi(self.bit_depth as i32) - 1.0
170    }
171}
172
173/// A metric score and the local map it was pooled from.
174#[derive(Clone, Debug)]
175pub struct SsimOutcome {
176    /// Pooled score. 1.0 means identical for every variant.
177    pub score: f64,
178    /// Per-window scores, row-major.
179    pub map: Vec<f32>,
180    pub map_width: usize,
181    pub map_height: usize,
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn rgba(width: usize, height: usize, value: u8) -> Vec<u8> {
189        let mut data = vec![0u8; width * height * 4];
190        for pixel in data.chunks_exact_mut(4) {
191            pixel.copy_from_slice(&[value, value, value, 255]);
192        }
193        data
194    }
195
196    #[test]
197    fn a_plane_takes_its_dimensions_from_the_view() {
198        let data = rgba(4, 3, 128);
199        let plane = Plane::from_rgba8(Rgba8::new(&data, 4, 3)).unwrap();
200        assert_eq!((plane.width, plane.height), (4, 3));
201        assert_eq!(plane.samples.len(), 12);
202    }
203
204    #[test]
205    fn a_short_buffer_is_rejected() {
206        let data = rgba(4, 3, 128);
207        assert!(matches!(
208            Plane::from_rgba8(Rgba8::new(&data, 8, 3)),
209            Err(SsimError::Options(_))
210        ));
211    }
212
213    #[test]
214    fn mismatched_planes_are_rejected() {
215        let plane = |width, height| Plane {
216            samples: vec![0.0; width * height],
217            width,
218            height,
219        };
220        assert!(matches!(
221            Plane::validate_pair(&plane(4, 4), &plane(2, 4)),
222            Err(SsimError::SizeMismatch { .. })
223        ));
224    }
225
226    #[test]
227    fn a_truncated_plane_is_rejected() {
228        let full = Plane {
229            samples: vec![0.0; 16],
230            width: 4,
231            height: 4,
232        };
233        let short = Plane {
234            samples: vec![0.0; 4],
235            width: 4,
236            height: 4,
237        };
238        assert!(matches!(
239            Plane::validate_pair(&full, &short),
240            Err(SsimError::Options(_))
241        ));
242    }
243
244    #[test]
245    fn the_dynamic_range_follows_the_bit_depth() {
246        assert_eq!(SsimOptions::default().dynamic_range(), 255.0);
247        assert_eq!(
248            SsimOptions {
249                bit_depth: 16,
250                ..Default::default()
251            }
252            .dynamic_range(),
253            65535.0
254        );
255    }
256}