Skip to main content

cranpose_render_common/
image_compare.rs

1use cranpose_ui_graphics::Rect;
2
3/// One pixel that differs between two RGBA images.
4#[derive(Clone, Debug, PartialEq, Eq)]
5pub struct PixelDifference {
6    /// Column of the pixel, counted from the left.
7    pub x: u32,
8    /// Row of the pixel, counted from the top.
9    pub y: u32,
10    /// RGBA bytes of the pixel in the left image.
11    pub lhs: [u8; 4],
12    /// RGBA bytes of the pixel in the right image.
13    pub rhs: [u8; 4],
14    /// Sum of the absolute per-channel differences, from 0 to 1020.
15    pub difference: u32,
16}
17
18/// Summary of how two RGBA images differ.
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct ImageDifferenceStats {
21    /// Number of pixels whose difference exceeds the tolerance.
22    pub differing_pixels: u32,
23    /// Largest difference among the differing pixels.
24    pub max_difference: u32,
25    /// The first differing pixel in row-major order, or `None` when the images match.
26    pub first_difference: Option<PixelDifference>,
27}
28
29/// Reads the RGBA bytes of pixel (`x`, `y`) from a tightly packed RGBA8 buffer that is `width`
30/// pixels wide. Panics when the pixel lies outside the buffer.
31pub fn sample_pixel(pixels: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] {
32    let idx = ((y * width + x) * 4) as usize;
33    [
34        pixels[idx],
35        pixels[idx + 1],
36        pixels[idx + 2],
37        pixels[idx + 3],
38    ]
39}
40
41/// Sums the absolute per-channel differences between two RGBA pixels, from 0 to 1020.
42pub fn pixel_difference(lhs: [u8; 4], rhs: [u8; 4]) -> u32 {
43    lhs.into_iter()
44        .zip(rhs)
45        .map(|(left, right)| left.abs_diff(right) as u32)
46        .sum()
47}
48
49/// Resamples the `rect` region, in source pixels, of an RGBA8 image of `width` by `height`
50/// pixels to `output_width` by `output_height` pixels with bilinear sampling. Panics when an
51/// output dimension is zero.
52pub fn normalize_rgba_region(
53    pixels: &[u8],
54    width: u32,
55    height: u32,
56    rect: Rect,
57    output_width: u32,
58    output_height: u32,
59) -> Vec<u8> {
60    assert!(output_width > 0, "normalized output_width must be positive");
61    assert!(
62        output_height > 0,
63        "normalized output_height must be positive"
64    );
65
66    let mut out = Vec::with_capacity((output_width * output_height * 4) as usize);
67    for y in 0..output_height {
68        for x in 0..output_width {
69            let sample_x = rect.x + ((x as f32 + 0.5) * rect.width / output_width as f32);
70            let sample_y = rect.y + ((y as f32 + 0.5) * rect.height / output_height as f32);
71            out.extend_from_slice(&sample_pixel_bilinear(
72                pixels, width, height, sample_x, sample_y,
73            ));
74        }
75    }
76    out
77}
78
79/// Compares two RGBA8 buffers of `width` by `height` pixels and counts the pixels whose
80/// difference exceeds `tolerance`. Panics when the buffers differ in length.
81pub fn image_difference_stats(
82    lhs: &[u8],
83    rhs: &[u8],
84    width: u32,
85    height: u32,
86    tolerance: u32,
87) -> ImageDifferenceStats {
88    assert_eq!(
89        lhs.len(),
90        rhs.len(),
91        "image_difference_stats requires equal buffer lengths"
92    );
93
94    let mut differing_pixels = 0;
95    let mut max_difference = 0;
96    let mut first_difference = None;
97    for y in 0..height {
98        for x in 0..width {
99            let index = ((y * width + x) * 4) as usize;
100            let lhs_pixel = [lhs[index], lhs[index + 1], lhs[index + 2], lhs[index + 3]];
101            let rhs_pixel = [rhs[index], rhs[index + 1], rhs[index + 2], rhs[index + 3]];
102            let difference = pixel_difference(lhs_pixel, rhs_pixel);
103            if difference > tolerance {
104                differing_pixels += 1;
105                max_difference = max_difference.max(difference);
106                if first_difference.is_none() {
107                    first_difference = Some(PixelDifference {
108                        x,
109                        y,
110                        lhs: lhs_pixel,
111                        rhs: rhs_pixel,
112                        difference,
113                    });
114                }
115            }
116        }
117    }
118
119    ImageDifferenceStats {
120        differing_pixels,
121        max_difference,
122        first_difference,
123    }
124}
125
126fn sample_pixel_bilinear(pixels: &[u8], width: u32, height: u32, x: f32, y: f32) -> [u8; 4] {
127    let max_x = width.saturating_sub(1) as f32;
128    let max_y = height.saturating_sub(1) as f32;
129    let source_x = (x - 0.5).clamp(0.0, max_x);
130    let source_y = (y - 0.5).clamp(0.0, max_y);
131    let x0 = source_x.floor() as u32;
132    let y0 = source_y.floor() as u32;
133    let x1 = (x0 + 1).min(width.saturating_sub(1));
134    let y1 = (y0 + 1).min(height.saturating_sub(1));
135    let tx = source_x - x0 as f32;
136    let ty = source_y - y0 as f32;
137    let top_left = sample_pixel(pixels, width, x0, y0);
138    let top_right = sample_pixel(pixels, width, x1, y0);
139    let bottom_left = sample_pixel(pixels, width, x0, y1);
140    let bottom_right = sample_pixel(pixels, width, x1, y1);
141
142    let lerp_channel = |index: usize| {
143        let top = top_left[index] as f32 * (1.0 - tx) + top_right[index] as f32 * tx;
144        let bottom = bottom_left[index] as f32 * (1.0 - tx) + bottom_right[index] as f32 * tx;
145        (top * (1.0 - ty) + bottom * ty).round() as u8
146    };
147
148    [
149        lerp_channel(0),
150        lerp_channel(1),
151        lerp_channel(2),
152        lerp_channel(3),
153    ]
154}
155
156#[cfg(test)]
157#[path = "tests/image_compare_tests.rs"]
158mod tests;