rendiff/
diff.rs

1use imgref::{ImgRef, ImgVec};
2
3use crate::{Histogram, RgbaPixel};
4
5/// Output of [`diff()`]; a comparison between two images.
6#[derive(Clone, Debug, Eq, Hash, PartialEq)]
7#[non_exhaustive]
8pub struct Difference {
9    histogram: Histogram,
10
11    diff_image: Option<imgref::ImgVec<RgbaPixel>>,
12}
13
14impl Difference {
15    /// A histogram of magnitudes of the detected differences.
16    #[must_use]
17    pub fn histogram(&self) -> Histogram {
18        self.histogram
19    }
20
21    /// An sRGB RGBA image intended for human viewing of which pixels are different,
22    /// or [`None`] if the images had different sizes.
23    ///
24    /// The precise content of this image is not specified. It will be 1:1 scale with the
25    /// images being compared, but it may be larger or smaller due to treatment of the edges.
26    ///
27    /// Currently, the red channel contains data from the input `expected` image,
28    /// and the blue and green channels contain differences, scaled up for high visibility.
29    #[must_use]
30    pub fn diff_image(&self) -> Option<ImgRef<'_, RgbaPixel>> {
31        self.diff_image.as_ref().map(imgref::ImgExt::as_ref)
32    }
33}
34
35/// Compares two RGBA images with a neighborhood-sensitive comparison which counts one pixel worth
36/// of displacement as not a difference.
37///
38/// See the [crate documentation](crate) for more details on the algorithm used.
39///
40/// This function does not have any options for ignoring small color differences; rather, the
41/// result can be checked against a [`Threshold`](crate::Threshold).
42///
43/// Details:
44///
45/// * If the images have different sizes, then the result will always be the maximum difference.
46/// * Differences in the alpha channel are counted the same as differences in luma; the maximum
47///   of luma and alpha is used as the result.
48#[must_use]
49pub fn diff(actual: ImgRef<'_, RgbaPixel>, expected: ImgRef<'_, RgbaPixel>) -> Difference {
50    if dimensions(expected) != dimensions(actual) {
51        return Difference {
52            // Count it as every pixel different.
53            histogram: {
54                let mut h = [0; 256];
55                h[usize::from(u8::MAX)] = expected.pixels().len().max(actual.pixels().len());
56                Histogram(h)
57            },
58            diff_image: None,
59        };
60    }
61
62    let hd1 = half_diff(expected, actual);
63    let hd2 = half_diff(actual, expected);
64
65    // Combine the two half_diff results: _both_ must be small for the output to be small.
66    let raw_diff_image: ImgVec<u8> = ImgVec::new(
67        (0..hd1.height())
68            .flat_map(|y| {
69                (0..hd1.width()).map({
70                    let hd1 = &hd1;
71                    let hd2 = &hd2;
72                    move |x| core::cmp::max(hd1[(x, y)], hd2[(x, y)])
73                })
74            })
75            .collect(),
76        hd1.width(),
77        hd1.height(),
78    );
79
80    // Compute a histogram of difference sizes.
81    let mut histogram: [usize; 256] = [0; 256];
82    for diff_value in raw_diff_image.pixels() {
83        histogram[usize::from(diff_value)] += 1;
84    }
85    let histogram = Histogram(histogram);
86
87    Difference {
88        histogram,
89        diff_image: Some(crate::visualize::visualize(
90            expected,
91            raw_diff_image.as_ref(),
92            &histogram,
93        )),
94    }
95}
96
97fn dimensions<T>(image: imgref::ImgRef<'_, T>) -> [usize; 2] {
98    [image.width(), image.height()]
99}
100
101/// Compare each pixel of `have` against a neighborhood of `want` (ignoring the edge).
102/// Each pixel's color must be approximately equal to some pixel in the neighborhood.
103///
104/// This is "half" of the complete diffing process because the neighborhood comparison
105/// could allow a 1-pixel line in `want` to completely vanish. By performing the same
106/// comparison in both directions, we ensure that each color in each image must also
107/// appear in the other image.
108fn half_diff(have: ImgRef<'_, RgbaPixel>, want: ImgRef<'_, RgbaPixel>) -> ImgVec<u8> {
109    let have_elems = have.sub_image(1, 1, have.width() - 2, have.height() - 2);
110
111    let mut buffer: Vec<u8> = vec_for_same_size_image(have);
112    for (y, have_row) in have_elems.rows().enumerate() {
113        // Precalculate the rows in `want` that we're going to be fetching neighborhoods from.
114        let want_rows: [&[RgbaPixel]; 3] = {
115            // The row iterator overrides nth() which makes skip() O(1).
116            let mut iter = want.rows().skip(y);
117            std::array::from_fn(|_| iter.next().unwrap_or(/* unreachable */ &[]))
118        };
119
120        buffer.extend(have_row.iter().enumerate().map(move |(x, &have_pixel)| {
121            // Note on coordinates:
122            // The x and y we get from the enumerate()s start at (0, 0) ignoring our offset,
123            // so when we use those same x,y as top-left corner of the neighborhood,
124            // we get a centered neighborhood.
125            //
126            // Note on performance: this mess of explicit indexing proved faster than
127            // `want.sub_image().pixels()`, and also faster than iterating over slices.
128            let neighborhood = [
129                want_rows[0][x],
130                want_rows[0][x + 1],
131                want_rows[0][x + 2],
132                want_rows[1][x],
133                want_rows[1][x + 1],
134                want_rows[1][x + 2],
135                want_rows[2][x],
136                want_rows[2][x + 1],
137                want_rows[2][x + 2],
138            ];
139            let minimum_diff_in_neighborhood: u8 = neighborhood
140                .into_iter()
141                .map(|want_pixel| pixel_diff(have_pixel, want_pixel))
142                .min()
143                .expect("neighborhood is never empty");
144            minimum_diff_in_neighborhood
145        }));
146    }
147
148    ImgVec::new(buffer, have_elems.width(), have_elems.height())
149}
150
151/// Compare two pixel values and produce a difference magnitude.
152///
153/// TODO: This function should be replaceable by the caller of `diff()` instead,
154/// allowing the caller to choose a perceptual or encoded difference function,
155/// and choose how they wish to treat alpha.
156fn pixel_diff(a: RgbaPixel, b: RgbaPixel) -> u8 {
157    // Diff each channel independently, then convert the difference to luma.
158    // Note: this is a very naive comparison, but
159    let r_diff = a[0].abs_diff(b[0]);
160    let g_diff = a[1].abs_diff(b[1]);
161    let b_diff = a[2].abs_diff(b[2]);
162    let a_diff = a[3].abs_diff(b[3]);
163
164    let color_diff = crate::image::rgba_to_luma([r_diff, g_diff, b_diff, 255]);
165
166    color_diff.max(a_diff)
167}
168
169#[cfg_attr(test, mutants::skip)] // Not really testable.
170fn vec_for_same_size_image<T>(image: ImgRef<'_, impl Sized>) -> Vec<T> {
171    // We should not use `image.buf().len()` because that includes stride.
172    Vec::with_capacity(image.width() * image.height())
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::Threshold;
179    use crate::image::luma_to_rgba;
180    use imgref::{Img, ImgExt as _};
181
182    /// Run [`diff()`] against two images defined as vectors,
183    /// with an added border.
184    fn diff_vecs(
185        (width, height): (usize, usize),
186        actual_data: Vec<RgbaPixel>,
187        expected_data: Vec<RgbaPixel>,
188        border_value: RgbaPixel,
189    ) -> Difference {
190        let expected = add_border(border_value, ImgVec::new(expected_data, width, height));
191        let actual = add_border(border_value, ImgVec::new(actual_data, width, height));
192        diff(actual.as_ref(), expected.as_ref())
193    }
194
195    #[allow(clippy::needless_pass_by_value)]
196    fn add_border<P: Copy>(border_value: P, image: ImgVec<P>) -> ImgVec<P> {
197        let width = image.width();
198        let height = image.height();
199        crate::image::from_fn(width + 2, height + 2, |x, y| {
200            if (1..=width).contains(&x) && (1..=height).contains(&y) {
201                image[(x - 1, y - 1)]
202            } else {
203                border_value
204            }
205        })
206    }
207
208    #[test]
209    fn simple_equality() {
210        let image = Img::new(
211            [0, 255, 128, 0, 255, 12, 255, 13, 99].map(luma_to_rgba),
212            3,
213            3,
214        );
215        let image = image.as_ref();
216        let diff_result = dbg!(diff(image, image));
217
218        let mut expected_histogram = [0; 256];
219        expected_histogram[0] = 1;
220        assert_eq!(diff_result.histogram, Histogram(expected_histogram));
221
222        assert!(Threshold::no_bigger_than(0).allows(diff_result.histogram));
223        assert!(Threshold::no_bigger_than(5).allows(diff_result.histogram));
224        assert!(Threshold::no_bigger_than(254).allows(diff_result.histogram));
225    }
226
227    #[test]
228    fn simple_inequality_thoroughly_examined() {
229        let base_pixel_value = 100u8;
230        let delta = 55u8;
231        let dred = 11; // delta scaled down by being on red channel of the test image only
232        let display_scale = 3; // input image is divided by this when put in diff image
233
234        let mut expected_histogram = [0; 256];
235        expected_histogram[usize::from(dred)] = 1;
236
237        // Try both orders; result should be symmetric except for the diff image
238        let result_of_negative_difference = dbg!(diff_vecs(
239            (1, 1),
240            vec![[base_pixel_value, base_pixel_value, base_pixel_value, 255]],
241            vec![[
242                base_pixel_value + delta,
243                base_pixel_value,
244                base_pixel_value,
245                255
246            ]],
247            [base_pixel_value, base_pixel_value, base_pixel_value, 255],
248        ));
249        let result_of_positive_difference = dbg!(diff_vecs(
250            (1, 1),
251            vec![[
252                base_pixel_value + delta,
253                base_pixel_value,
254                base_pixel_value,
255                255
256            ]],
257            vec![[base_pixel_value, base_pixel_value, base_pixel_value, 255]],
258            [base_pixel_value, base_pixel_value, base_pixel_value, 255],
259        ));
260
261        // Note that the diff image is constructed using the expected image, not actual.
262        assert_eq!(
263            result_of_positive_difference,
264            Difference {
265                histogram: Histogram(expected_histogram),
266                diff_image: Some(ImgVec::new(
267                    vec![[(base_pixel_value) / display_scale, 255, 255, 255]],
268                    1,
269                    1,
270                ))
271            }
272        );
273        assert_eq!(
274            result_of_negative_difference,
275            Difference {
276                histogram: Histogram(expected_histogram),
277                diff_image: Some(ImgVec::new(
278                    vec![[(base_pixel_value + dred) / display_scale, 255, 255, 255]],
279                    1,
280                    1,
281                ))
282            }
283        );
284
285        assert_eq!(
286            (
287                Threshold::no_bigger_than(dred - 1).allows(result_of_positive_difference.histogram),
288                Threshold::no_bigger_than(dred).allows(result_of_positive_difference.histogram),
289            ),
290            (false, true)
291        );
292    }
293
294    /// Test that the diff image is 2 pixels smaller, as expected.
295    ///
296    /// TODO: We should have image-comparison tests applying to the diff image.
297    /// Once we do, this test is moot.
298    #[test]
299    fn diff_image_size() {
300        let image1 = crate::image::from_fn(10, 10, |_, _| [1, 2, 3, 255]);
301        let image2 = crate::image::from_fn(10, 10, |_, _| [100, 200, 255, 255]);
302        let diff_result = diff(image1.as_ref(), image2.as_ref());
303
304        let diff_image = diff_result.diff_image.unwrap();
305        assert_eq!((diff_image.width(), diff_image.height()), (8, 8));
306    }
307
308    #[test]
309    fn mismatched_sizes() {
310        let expected = ImgRef::new(&[[0, 0, 0, 255u8]], 1, 1);
311        let actual = ImgRef::new(&[[0, 0, 0, 255], [0, 0, 0, 255u8]], 1, 2);
312        assert_eq!(
313            diff(actual, expected),
314            Difference {
315                histogram: {
316                    let mut h = [0; 256];
317                    h[255] = 2;
318                    Histogram(h)
319                },
320                diff_image: None
321            }
322        );
323    }
324
325    /// Verify that the neighborhood comparison covers the expected neighborhood
326    /// (currently a 3×3 square).
327    #[test]
328    fn shape_of_neighborhood() {
329        let test_image_with_center_pixel = crate::image::from_fn(7, 7, |x, y| {
330            if (x, y) == (3, 3) {
331                [255, 255, 255, 255]
332            } else {
333                [0, 0, 0, 255]
334            }
335        });
336
337        let passes_if_pixel_is_here = crate::image::from_fn(7, 7, |place_x, place_y| {
338            let test_image_with_displaced_pixel = crate::image::from_fn(7, 7, |x, y| {
339                if (x, y) == (place_x, place_y) {
340                    [255, 255, 255, 255]
341                } else {
342                    [0, 0, 0, 255]
343                }
344            });
345
346            diff(
347                test_image_with_displaced_pixel.as_ref(),
348                test_image_with_center_pixel.as_ref(),
349            )
350            .histogram()
351            .max_difference()
352                == 0
353        });
354        assert_eq!(
355            passes_if_pixel_is_here.into_buf(),
356            vec![
357                false, false, false, false, false, false, false, //
358                false, false, false, false, false, false, false, //
359                false, false, true, true, true, false, false, //
360                false, false, true, true, true, false, false, //
361                false, false, true, true, true, false, false, //
362                false, false, false, false, false, false, false, //
363                false, false, false, false, false, false, false, //
364            ]
365        );
366    }
367}