Skip to main content

semdiff_differ_image/
lib.rs

1use color::{AlphaColor, Oklab, Srgb};
2use image::{ImageError, ImageFormat, Rgba, RgbaImage};
3use mime::Mime;
4use semdiff_core::fs::FileLeaf;
5use semdiff_core::{Diff, DiffCalculator, MayUnsupported};
6use thiserror::Error;
7
8pub mod report_html;
9pub mod report_json;
10pub mod report_summary;
11
12#[cfg(test)]
13mod tests;
14
15pub struct ImageDiffReporter;
16
17#[derive(Debug)]
18pub struct ImageDiff {
19    equal: bool,
20    expected: ImageData,
21    actual: ImageData,
22    diff_stat: ImageDiffStat,
23    diff_image: RgbaImage,
24}
25
26#[derive(Debug, Clone)]
27pub struct ImageData {
28    pub mime: Mime,
29    pub width: u32,
30    pub height: u32,
31    pub data: RgbaImage,
32}
33
34#[derive(Debug)]
35pub struct ImageDiffStat {
36    pub diff_pixels: u64,
37    pub total_pixels: u64,
38    pub diff_ratio: f32,
39}
40
41impl Diff for ImageDiff {
42    fn equal(&self) -> bool {
43        self.equal
44    }
45}
46
47impl ImageDiff {
48    pub fn expected(&self) -> &ImageData {
49        &self.expected
50    }
51
52    pub fn actual(&self) -> &ImageData {
53        &self.actual
54    }
55
56    pub fn diff_stat(&self) -> &ImageDiffStat {
57        &self.diff_stat
58    }
59
60    pub fn diff_image(&self) -> &RgbaImage {
61        &self.diff_image
62    }
63}
64
65#[derive(Debug, Error)]
66pub enum ImageDiffError {
67    #[error("image error: {0}")]
68    Image(#[from] ImageError),
69}
70
71#[derive(Debug, Clone, Copy, Default)]
72pub struct ImageDiffCalculator {
73    max_distance: f32,
74    max_diff_ratio: f32,
75}
76
77impl ImageDiffCalculator {
78    pub fn new(max_distance: f32, max_diff_ratio: f32) -> Self {
79        Self {
80            max_distance,
81            max_diff_ratio,
82        }
83    }
84
85    #[inline(always)]
86    fn pixel_diff(&self, expected: Rgba<u8>, actual: Rgba<u8>) -> bool {
87        let (expected_oklab, expected_alpha) = Self::to_oklab_alpha(expected);
88        let (actual_oklab, actual_alpha) = Self::to_oklab_alpha(actual);
89        let delta_l = expected_oklab[0] - actual_oklab[0];
90        let delta_a = expected_oklab[1] - actual_oklab[1];
91        let delta_b = expected_oklab[2] - actual_oklab[2];
92        let delta_alpha = expected_alpha - actual_alpha;
93        let distance = (delta_l * delta_l + delta_a * delta_a + delta_b * delta_b + delta_alpha * delta_alpha).sqrt();
94        distance > self.max_distance
95    }
96
97    #[inline(always)]
98    fn to_oklab_alpha(pixel: Rgba<u8>) -> ([f32; 3], f32) {
99        let [r, g, b, a] = pixel.0;
100        let oklab = AlphaColor::<Srgb>::from_rgba8(r, g, b, a).convert::<Oklab>();
101        let [l, a, b, alpha] = oklab.components;
102        ([l, a, b], alpha)
103    }
104
105    fn compare(&self, expected: &RgbaImage, actual: &RgbaImage) -> (ImageDiffStat, RgbaImage) {
106        let (expected_width, expected_height) = expected.dimensions();
107        let (actual_width, actual_height) = actual.dimensions();
108        let max_width = expected_width.max(actual_width);
109        let max_height = expected_height.max(actual_height);
110        let min_width = expected_width.min(actual_width);
111        let min_height = expected_height.min(actual_height);
112        let total_pixels = u64::from(max_width) * u64::from(max_height);
113        let mut diff_pixels = 0u64;
114        let mut diff_image = RgbaImage::new(max_width, max_height);
115        const DIFF_PIXEL_COLOR: Rgba<u8> = Rgba([255, 255, 255, 180]);
116        const SAME_PIXEL_COLOR: Rgba<u8> = Rgba([255, 255, 255, 0]);
117        for y in 0..min_height {
118            for x in 0..min_width {
119                let expected_pixel = *expected.get_pixel(x, y);
120                let actual_pixel = *actual.get_pixel(x, y);
121                let diff_pixel = if self.pixel_diff(expected_pixel, actual_pixel) {
122                    diff_pixels += 1;
123                    DIFF_PIXEL_COLOR
124                } else {
125                    SAME_PIXEL_COLOR
126                };
127                diff_image.put_pixel(x, y, diff_pixel);
128            }
129            for x in min_width..max_width {
130                diff_pixels += 1;
131                diff_image.put_pixel(x, y, DIFF_PIXEL_COLOR);
132            }
133        }
134        for y in min_height..max_height {
135            for x in 0..max_width {
136                diff_pixels += 1;
137                diff_image.put_pixel(x, y, DIFF_PIXEL_COLOR);
138            }
139        }
140        let diff_ratio = if total_pixels == 0 {
141            0.0
142        } else {
143            diff_pixels as f32 / total_pixels as f32
144        };
145        (
146            ImageDiffStat {
147                diff_pixels,
148                total_pixels,
149                diff_ratio,
150            },
151            diff_image,
152        )
153    }
154}
155
156impl DiffCalculator<FileLeaf> for ImageDiffCalculator {
157    type Error = ImageDiffError;
158    type Diff = ImageDiff;
159
160    fn diff(
161        &self,
162        _name: &str,
163        expected: FileLeaf,
164        actual: FileLeaf,
165    ) -> Result<MayUnsupported<Self::Diff>, Self::Error> {
166        let (Some(expected_format), Some(actual_format)) = (image_format(&expected.kind), image_format(&actual.kind))
167        else {
168            return Ok(MayUnsupported::Unsupported);
169        };
170        let expected_image = match image::load_from_memory_with_format(&expected.content, expected_format) {
171            Ok(image) => image,
172            Err(_) => return Ok(MayUnsupported::Unsupported),
173        };
174        let actual_image = match image::load_from_memory_with_format(&actual.content, actual_format) {
175            Ok(image) => image,
176            Err(_) => return Ok(MayUnsupported::Unsupported),
177        };
178        let expected_image = expected_image.into_rgba8();
179        let actual_image = actual_image.into_rgba8();
180        let (diff_stat, diff_image) = self.compare(&expected_image, &actual_image);
181        let expected_data = ImageData {
182            mime: expected.kind,
183            width: expected_image.width(),
184            height: expected_image.height(),
185            data: expected_image,
186        };
187        let actual_data = ImageData {
188            mime: actual.kind,
189            width: actual_image.width(),
190            height: actual_image.height(),
191            data: actual_image,
192        };
193        let equal = diff_stat.diff_ratio <= self.max_diff_ratio;
194        Ok(MayUnsupported::Ok(ImageDiff {
195            equal,
196            expected: expected_data,
197            actual: actual_data,
198            diff_stat,
199            diff_image,
200        }))
201    }
202}
203
204fn image_format(mime: &Mime) -> Option<ImageFormat> {
205    if mime.type_() != mime::IMAGE {
206        return None;
207    }
208    let format = match mime.subtype().as_str() {
209        "png" => ImageFormat::Png,
210        "bmp" => ImageFormat::Bmp,
211        "gif" => ImageFormat::Gif,
212        "jpeg" => ImageFormat::Jpeg,
213        "webp" => ImageFormat::WebP,
214        "avif" => ImageFormat::Avif,
215        _ => return None,
216    };
217    Some(format)
218}