Skip to main content

codec_eval/metrics/
dssim.rs

1//! DSSIM (Structural Dissimilarity) metric calculation.
2//!
3//! Wraps the `dssim-core` crate for perceptual image comparison.
4//!
5//! # ICC Profile Support
6//!
7//! Use [`calculate_dssim_icc`] for images with non-sRGB color profiles.
8
9use dssim_core::Dssim;
10use imgref::ImgVec;
11use rgb::RGBA;
12
13use super::icc::ColorProfile;
14use crate::error::{Error, Result};
15use crate::viewing::ViewingCondition;
16
17/// Calculate DSSIM between two images.
18///
19/// # Arguments
20///
21/// * `reference` - Reference image as RGBA f32 values (0.0-1.0).
22/// * `test` - Test image as RGBA f32 values (0.0-1.0).
23/// * `viewing` - Viewing condition for PPD-based adjustment (currently unused
24///   by dssim-core, but reserved for future use).
25///
26/// # Returns
27///
28/// DSSIM value where 0 = identical, higher = more different.
29/// Typical thresholds:
30/// - < 0.0003: Imperceptible
31/// - < 0.0007: Marginal
32/// - < 0.0015: Subtle
33/// - < 0.003: Noticeable
34/// - >= 0.003: Degraded
35///
36/// # Errors
37///
38/// Returns an error if the images have different dimensions or if DSSIM
39/// calculation fails.
40pub fn calculate_dssim(
41    reference: &ImgVec<RGBA<f32>>,
42    test: &ImgVec<RGBA<f32>>,
43    _viewing: &ViewingCondition,
44) -> Result<f64> {
45    if reference.width() != test.width() || reference.height() != test.height() {
46        return Err(Error::DimensionMismatch {
47            expected: (reference.width(), reference.height()),
48            actual: (test.width(), test.height()),
49        });
50    }
51
52    let dssim = Dssim::new();
53
54    let ref_image = dssim
55        .create_image(reference)
56        .ok_or_else(|| Error::MetricCalculation {
57            metric: "DSSIM".to_string(),
58            reason: "Failed to create reference image".to_string(),
59        })?;
60
61    let test_image = dssim
62        .create_image(test)
63        .ok_or_else(|| Error::MetricCalculation {
64            metric: "DSSIM".to_string(),
65            reason: "Failed to create test image".to_string(),
66        })?;
67
68    let (dssim_val, _ssim_maps) = dssim.compare(&ref_image, test_image);
69
70    Ok(f64::from(dssim_val))
71}
72
73/// Convert a single sRGB u8 component to linear f32.
74///
75/// Applies the sRGB transfer function (inverse gamma) to convert from
76/// gamma-encoded sRGB to linear light values.
77#[inline]
78fn srgb_to_linear(srgb: u8) -> f32 {
79    let s = f32::from(srgb) / 255.0;
80    if s <= 0.04045 {
81        s / 12.92
82    } else {
83        ((s + 0.055) / 1.055).powf(2.4)
84    }
85}
86
87/// Convert RGB8 image data to the format needed for DSSIM calculation.
88///
89/// Applies proper sRGB-to-linear conversion (inverse gamma) as required
90/// by dssim-core. This matches dssim-core's `ToRGBAPLU::to_rgblu()` behavior.
91///
92/// # Arguments
93///
94/// * `data` - RGB8 pixel data in row-major order (sRGB gamma-encoded).
95/// * `width` - Image width in pixels.
96/// * `height` - Image height in pixels.
97///
98/// # Returns
99///
100/// An `ImgVec<RGBA<f32>>` with linear light values suitable for DSSIM calculation.
101#[must_use]
102pub fn rgb8_to_dssim_image(data: &[u8], width: usize, height: usize) -> ImgVec<RGBA<f32>> {
103    let pixels: Vec<RGBA<f32>> = data
104        .chunks_exact(3)
105        .map(|rgb| RGBA {
106            r: srgb_to_linear(rgb[0]),
107            g: srgb_to_linear(rgb[1]),
108            b: srgb_to_linear(rgb[2]),
109            a: 1.0,
110        })
111        .collect();
112
113    ImgVec::new(pixels, width, height)
114}
115
116/// Convert RGBA8 image data to the format needed for DSSIM calculation.
117///
118/// Applies proper sRGB-to-linear conversion (inverse gamma) for RGB channels.
119/// Alpha channel is normalized linearly (0-255 → 0.0-1.0).
120///
121/// # Arguments
122///
123/// * `data` - RGBA8 pixel data in row-major order (sRGB gamma-encoded RGB + linear alpha).
124/// * `width` - Image width in pixels.
125/// * `height` - Image height in pixels.
126///
127/// # Returns
128///
129/// An `ImgVec<RGBA<f32>>` with linear light RGB values suitable for DSSIM calculation.
130#[must_use]
131pub fn rgba8_to_dssim_image(data: &[u8], width: usize, height: usize) -> ImgVec<RGBA<f32>> {
132    let pixels: Vec<RGBA<f32>> = data
133        .chunks_exact(4)
134        .map(|rgba| RGBA {
135            r: srgb_to_linear(rgba[0]),
136            g: srgb_to_linear(rgba[1]),
137            b: srgb_to_linear(rgba[2]),
138            a: f32::from(rgba[3]) / 255.0, // Alpha is linear, not gamma-encoded
139        })
140        .collect();
141
142    ImgVec::new(pixels, width, height)
143}
144
145/// Calculate DSSIM with ICC profile support.
146///
147/// This function transforms both images to sRGB before comparison.
148///
149/// # Arguments
150///
151/// * `reference` - Reference image as RGB8 pixel data.
152/// * `reference_profile` - Color profile of the reference image.
153/// * `test` - Test image as RGB8 pixel data.
154/// * `test_profile` - Color profile of the test image.
155/// * `width` - Image width in pixels.
156/// * `height` - Image height in pixels.
157/// * `viewing` - Viewing condition for PPD-based adjustment.
158pub fn calculate_dssim_icc(
159    reference: &[u8],
160    reference_profile: &ColorProfile,
161    test: &[u8],
162    test_profile: &ColorProfile,
163    width: usize,
164    height: usize,
165    viewing: &ViewingCondition,
166) -> Result<f64> {
167    let (ref_srgb, test_srgb) =
168        super::icc::prepare_for_comparison(reference, reference_profile, test, test_profile)?;
169
170    let ref_img = rgb8_to_dssim_image(&ref_srgb, width, height);
171    let test_img = rgb8_to_dssim_image(&test_srgb, width, height);
172
173    calculate_dssim(&ref_img, &test_img, viewing)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_identical_images() {
182        let pixels: Vec<RGBA<f32>> = (0..100 * 100)
183            .map(|_| RGBA {
184                r: 0.5,
185                g: 0.5,
186                b: 0.5,
187                a: 1.0,
188            })
189            .collect();
190        let img = ImgVec::new(pixels, 100, 100);
191
192        let dssim = calculate_dssim(&img, &img, &ViewingCondition::desktop()).unwrap();
193        assert!(
194            dssim < 0.0001,
195            "Identical images should have near-zero DSSIM"
196        );
197    }
198
199    #[test]
200    fn test_different_images() {
201        let ref_pixels: Vec<RGBA<f32>> = (0..100 * 100)
202            .map(|_| RGBA {
203                r: 0.3,
204                g: 0.3,
205                b: 0.3,
206                a: 1.0,
207            })
208            .collect();
209        let test_pixels: Vec<RGBA<f32>> = (0..100 * 100)
210            .map(|_| RGBA {
211                r: 0.7,
212                g: 0.7,
213                b: 0.7,
214                a: 1.0,
215            })
216            .collect();
217
218        let ref_img = ImgVec::new(ref_pixels, 100, 100);
219        let test_img = ImgVec::new(test_pixels, 100, 100);
220
221        let dssim = calculate_dssim(&ref_img, &test_img, &ViewingCondition::desktop()).unwrap();
222        assert!(dssim > 0.0, "Different images should have non-zero DSSIM");
223    }
224
225    #[test]
226    fn test_dimension_mismatch() {
227        let small: Vec<RGBA<f32>> = (0..50 * 50)
228            .map(|_| RGBA {
229                r: 0.5,
230                g: 0.5,
231                b: 0.5,
232                a: 1.0,
233            })
234            .collect();
235        let large: Vec<RGBA<f32>> = (0..100 * 100)
236            .map(|_| RGBA {
237                r: 0.5,
238                g: 0.5,
239                b: 0.5,
240                a: 1.0,
241            })
242            .collect();
243
244        let small_img = ImgVec::new(small, 50, 50);
245        let large_img = ImgVec::new(large, 100, 100);
246
247        let result = calculate_dssim(&small_img, &large_img, &ViewingCondition::desktop());
248        assert!(matches!(result, Err(Error::DimensionMismatch { .. })));
249    }
250
251    #[test]
252    fn test_rgb8_conversion() {
253        let rgb_data = vec![255u8, 0, 0, 0, 255, 0]; // Red, Green pixels
254        let img = rgb8_to_dssim_image(&rgb_data, 2, 1);
255
256        assert_eq!(img.width(), 2);
257        assert_eq!(img.height(), 1);
258        let pixels: Vec<_> = img.pixels().collect();
259        assert!((pixels[0].r - 1.0).abs() < 0.001);
260        assert!((pixels[1].g - 1.0).abs() < 0.001);
261    }
262
263    #[test]
264    fn test_rgba8_conversion() {
265        let rgba_data = vec![255u8, 0, 0, 128, 0, 255, 0, 255]; // Semi-transparent red, opaque green
266        let img = rgba8_to_dssim_image(&rgba_data, 2, 1);
267
268        assert_eq!(img.width(), 2);
269        assert_eq!(img.height(), 1);
270        let pixels: Vec<_> = img.pixels().collect();
271        assert!((pixels[0].a - 0.502).abs() < 0.01);
272        assert!((pixels[1].a - 1.0).abs() < 0.001);
273    }
274}