Skip to main content

cranpose_render_common/
image_compare.rs

1use cranpose_ui_graphics::Rect;
2
3#[derive(Clone, Debug, PartialEq, Eq)]
4pub struct PixelDifference {
5    pub x: u32,
6    pub y: u32,
7    pub lhs: [u8; 4],
8    pub rhs: [u8; 4],
9    pub difference: u32,
10}
11
12#[derive(Clone, Debug, Default, PartialEq, Eq)]
13pub struct ImageDifferenceStats {
14    pub differing_pixels: u32,
15    pub max_difference: u32,
16    pub first_difference: Option<PixelDifference>,
17}
18
19pub fn sample_pixel(pixels: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] {
20    let idx = ((y * width + x) * 4) as usize;
21    [
22        pixels[idx],
23        pixels[idx + 1],
24        pixels[idx + 2],
25        pixels[idx + 3],
26    ]
27}
28
29pub fn pixel_difference(lhs: [u8; 4], rhs: [u8; 4]) -> u32 {
30    lhs.into_iter()
31        .zip(rhs)
32        .map(|(left, right)| left.abs_diff(right) as u32)
33        .sum()
34}
35
36pub fn normalize_rgba_region(
37    pixels: &[u8],
38    width: u32,
39    height: u32,
40    rect: Rect,
41    output_width: u32,
42    output_height: u32,
43) -> Vec<u8> {
44    assert!(output_width > 0, "normalized output_width must be positive");
45    assert!(
46        output_height > 0,
47        "normalized output_height must be positive"
48    );
49
50    let mut out = Vec::with_capacity((output_width * output_height * 4) as usize);
51    for y in 0..output_height {
52        for x in 0..output_width {
53            let sample_x = rect.x + ((x as f32 + 0.5) * rect.width / output_width as f32);
54            let sample_y = rect.y + ((y as f32 + 0.5) * rect.height / output_height as f32);
55            out.extend_from_slice(&sample_pixel_bilinear(
56                pixels, width, height, sample_x, sample_y,
57            ));
58        }
59    }
60    out
61}
62
63pub fn image_difference_stats(
64    lhs: &[u8],
65    rhs: &[u8],
66    width: u32,
67    height: u32,
68    tolerance: u32,
69) -> ImageDifferenceStats {
70    assert_eq!(
71        lhs.len(),
72        rhs.len(),
73        "image_difference_stats requires equal buffer lengths"
74    );
75
76    let mut differing_pixels = 0;
77    let mut max_difference = 0;
78    let mut first_difference = None;
79    for y in 0..height {
80        for x in 0..width {
81            let index = ((y * width + x) * 4) as usize;
82            let lhs_pixel = [lhs[index], lhs[index + 1], lhs[index + 2], lhs[index + 3]];
83            let rhs_pixel = [rhs[index], rhs[index + 1], rhs[index + 2], rhs[index + 3]];
84            let difference = pixel_difference(lhs_pixel, rhs_pixel);
85            if difference > tolerance {
86                differing_pixels += 1;
87                max_difference = max_difference.max(difference);
88                if first_difference.is_none() {
89                    first_difference = Some(PixelDifference {
90                        x,
91                        y,
92                        lhs: lhs_pixel,
93                        rhs: rhs_pixel,
94                        difference,
95                    });
96                }
97            }
98        }
99    }
100
101    ImageDifferenceStats {
102        differing_pixels,
103        max_difference,
104        first_difference,
105    }
106}
107
108fn sample_pixel_bilinear(pixels: &[u8], width: u32, height: u32, x: f32, y: f32) -> [u8; 4] {
109    let max_x = width.saturating_sub(1) as f32;
110    let max_y = height.saturating_sub(1) as f32;
111    let source_x = (x - 0.5).clamp(0.0, max_x);
112    let source_y = (y - 0.5).clamp(0.0, max_y);
113    let x0 = source_x.floor() as u32;
114    let y0 = source_y.floor() as u32;
115    let x1 = (x0 + 1).min(width.saturating_sub(1));
116    let y1 = (y0 + 1).min(height.saturating_sub(1));
117    let tx = source_x - x0 as f32;
118    let ty = source_y - y0 as f32;
119    let top_left = sample_pixel(pixels, width, x0, y0);
120    let top_right = sample_pixel(pixels, width, x1, y0);
121    let bottom_left = sample_pixel(pixels, width, x0, y1);
122    let bottom_right = sample_pixel(pixels, width, x1, y1);
123
124    let lerp_channel = |index: usize| {
125        let top = top_left[index] as f32 * (1.0 - tx) + top_right[index] as f32 * tx;
126        let bottom = bottom_left[index] as f32 * (1.0 - tx) + bottom_right[index] as f32 * tx;
127        (top * (1.0 - ty) + bottom * ty).round() as u8
128    };
129
130    [
131        lerp_channel(0),
132        lerp_channel(1),
133        lerp_channel(2),
134        lerp_channel(3),
135    ]
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn normalize_rgba_region_preserves_pixel_grid_at_native_size() {
144        let pixels = vec![
145            10, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 100, 110, 120, 255,
146        ];
147        let normalized = normalize_rgba_region(
148            &pixels,
149            2,
150            2,
151            Rect {
152                x: 0.0,
153                y: 0.0,
154                width: 2.0,
155                height: 2.0,
156            },
157            2,
158            2,
159        );
160        assert_eq!(normalized, pixels);
161    }
162
163    #[test]
164    fn image_difference_stats_reports_first_difference() {
165        let lhs = vec![0, 0, 0, 255, 8, 8, 8, 255];
166        let rhs = vec![0, 0, 0, 255, 18, 12, 10, 255];
167        let stats = image_difference_stats(&lhs, &rhs, 2, 1, 3);
168
169        assert_eq!(stats.differing_pixels, 1);
170        assert_eq!(stats.max_difference, 16);
171        assert_eq!(
172            stats.first_difference,
173            Some(PixelDifference {
174                x: 1,
175                y: 0,
176                lhs: [8, 8, 8, 255],
177                rhs: [18, 12, 10, 255],
178                difference: 16,
179            })
180        );
181    }
182}