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}