Skip to main content

waterui_cli/workflows/
diff.rs

1//! Image comparison and diff generation for visual testing.
2//!
3//! Provides pixel-level comparison of before/after screenshots to detect
4//! UI changes after gestures.
5
6use std::path::Path;
7
8use eyre::eyre;
9use image::{ImageBuffer, Rgba, RgbaImage};
10
11/// Bounding box of changed region.
12#[derive(Debug, Clone, Copy)]
13pub struct BoundingBox {
14    /// Minimum X coordinate.
15    pub min_x: u32,
16    /// Minimum Y coordinate.
17    pub min_y: u32,
18    /// Maximum X coordinate.
19    pub max_x: u32,
20    /// Maximum Y coordinate.
21    pub max_y: u32,
22}
23
24impl BoundingBox {
25    /// Width of the bounding box.
26    #[must_use]
27    pub const fn width(&self) -> u32 {
28        self.max_x - self.min_x + 1
29    }
30
31    /// Height of the bounding box.
32    #[must_use]
33    pub const fn height(&self) -> u32 {
34        self.max_y - self.min_y + 1
35    }
36}
37
38/// Result of comparing two images.
39#[derive(Debug)]
40pub struct DiffResult {
41    /// Whether any pixels changed.
42    pub changed: bool,
43    /// Bounding box of the changed region, if any.
44    pub bbox: Option<BoundingBox>,
45    /// Percentage of pixels that changed (0.0 to 100.0).
46    pub percentage: f32,
47    /// Total number of changed pixels.
48    pub changed_pixels: u32,
49    /// Image dimensions (width, height).
50    pub dimensions: (u32, u32),
51}
52
53impl std::fmt::Display for DiffResult {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        if self.changed {
56            writeln!(f, "Changed: yes")?;
57            if let Some(bbox) = &self.bbox {
58                writeln!(
59                    f,
60                    "Bounding box: ({}, {}) to ({}, {})",
61                    bbox.min_x, bbox.min_y, bbox.max_x, bbox.max_y
62                )?;
63                writeln!(f, "Dimensions: {}x{} pixels", bbox.width(), bbox.height())?;
64            }
65            writeln!(f, "Screen percentage: {:.1}%", self.percentage)?;
66        } else {
67            writeln!(f, "Changed: no")?;
68        }
69        Ok(())
70    }
71}
72
73/// Tolerance for pixel comparison to handle compression artifacts.
74const PIXEL_TOLERANCE: u8 = 10;
75
76/// Check if two pixels are different (with tolerance).
77fn pixels_differ(a: Rgba<u8>, b: Rgba<u8>) -> bool {
78    a.0.iter()
79        .zip(b.0.iter())
80        .any(|(a_val, b_val)| a_val.abs_diff(*b_val) > PIXEL_TOLERANCE)
81}
82
83/// Compare two images and return the diff result.
84///
85/// # Arguments
86///
87/// * `before` - PNG bytes of the before image
88/// * `after` - PNG bytes of the after image
89///
90/// # Errors
91///
92/// Returns an error if the images cannot be loaded or have different dimensions.
93pub fn compare_images(before: &[u8], after: &[u8]) -> eyre::Result<DiffResult> {
94    let before_img = image::load_from_memory(before)
95        .map_err(|e| eyre!("Failed to load before image: {e}"))?
96        .to_rgba8();
97
98    let after_img = image::load_from_memory(after)
99        .map_err(|e| eyre!("Failed to load after image: {e}"))?
100        .to_rgba8();
101
102    let (width, height) = before_img.dimensions();
103    let (after_width, after_height) = after_img.dimensions();
104
105    if width != after_width || height != after_height {
106        eyre::bail!(
107            "Image dimensions don't match: before={}x{}, after={}x{}",
108            width,
109            height,
110            after_width,
111            after_height
112        );
113    }
114
115    let total_pixels = width * height;
116    let mut changed_pixels: u32 = 0;
117    let mut min_x = width;
118    let mut min_y = height;
119    let mut max_x: u32 = 0;
120    let mut max_y: u32 = 0;
121
122    for y in 0..height {
123        for x in 0..width {
124            let before_pixel = before_img.get_pixel(x, y);
125            let after_pixel = after_img.get_pixel(x, y);
126
127            if pixels_differ(*before_pixel, *after_pixel) {
128                changed_pixels += 1;
129                min_x = min_x.min(x);
130                min_y = min_y.min(y);
131                max_x = max_x.max(x);
132                max_y = max_y.max(y);
133            }
134        }
135    }
136
137    let changed = changed_pixels > 0;
138    #[allow(clippy::cast_possible_truncation)]
139    let percentage = (f64::from(changed_pixels) / f64::from(total_pixels) * 100.0) as f32;
140
141    let bbox = if changed {
142        Some(BoundingBox {
143            min_x,
144            min_y,
145            max_x,
146            max_y,
147        })
148    } else {
149        None
150    };
151
152    Ok(DiffResult {
153        changed,
154        bbox,
155        percentage,
156        changed_pixels,
157        dimensions: (width, height),
158    })
159}
160
161/// Save a diff image highlighting changed pixels.
162///
163/// Changed pixels are overlaid with semi-transparent red.
164///
165/// # Arguments
166///
167/// * `before` - PNG bytes of the before image
168/// * `after` - PNG bytes of the after image
169/// * `output` - Path to save the diff image
170///
171/// # Errors
172///
173/// Returns an error if the images cannot be loaded or the output cannot be written.
174pub fn save_diff_image(before: &[u8], after: &[u8], output: &Path) -> eyre::Result<()> {
175    let before_img = image::load_from_memory(before)
176        .map_err(|e| eyre!("Failed to load before image: {e}"))?
177        .to_rgba8();
178
179    let after_img = image::load_from_memory(after)
180        .map_err(|e| eyre!("Failed to load after image: {e}"))?
181        .to_rgba8();
182
183    let (width, height) = after_img.dimensions();
184    let (before_width, before_height) = before_img.dimensions();
185
186    if width != before_width || height != before_height {
187        eyre::bail!(
188            "Image dimensions don't match: before={}x{}, after={}x{}",
189            before_width,
190            before_height,
191            width,
192            height
193        );
194    }
195
196    let mut diff_img: RgbaImage = ImageBuffer::new(width, height);
197
198    // Semi-transparent red overlay for changed pixels
199    let overlay_r: u8 = 255;
200    let overlay_g: u8 = 0;
201    let overlay_b: u8 = 0;
202
203    for y in 0..height {
204        for x in 0..width {
205            let before_pixel = before_img.get_pixel(x, y);
206            let after_pixel = after_img.get_pixel(x, y);
207
208            if pixels_differ(*before_pixel, *after_pixel) {
209                // Blend the after pixel with red overlay
210                let after = after_pixel.0;
211                #[allow(clippy::cast_possible_truncation)]
212                let blended = Rgba([
213                    u16::midpoint(u16::from(after[0]), u16::from(overlay_r)) as u8,
214                    u16::midpoint(u16::from(after[1]), u16::from(overlay_g)) as u8,
215                    u16::midpoint(u16::from(after[2]), u16::from(overlay_b)) as u8,
216                    255,
217                ]);
218                diff_img.put_pixel(x, y, blended);
219            } else {
220                diff_img.put_pixel(x, y, *after_pixel);
221            }
222        }
223    }
224
225    diff_img
226        .save(output)
227        .map_err(|e| eyre!("Failed to save diff image: {e}"))?;
228
229    Ok(())
230}