waterui_cli/workflows/
diff.rs1use std::path::Path;
7
8use eyre::eyre;
9use image::{ImageBuffer, Rgba, RgbaImage};
10
11#[derive(Debug, Clone, Copy)]
13pub struct BoundingBox {
14 pub min_x: u32,
16 pub min_y: u32,
18 pub max_x: u32,
20 pub max_y: u32,
22}
23
24impl BoundingBox {
25 #[must_use]
27 pub const fn width(&self) -> u32 {
28 self.max_x - self.min_x + 1
29 }
30
31 #[must_use]
33 pub const fn height(&self) -> u32 {
34 self.max_y - self.min_y + 1
35 }
36}
37
38#[derive(Debug)]
40pub struct DiffResult {
41 pub changed: bool,
43 pub bbox: Option<BoundingBox>,
45 pub percentage: f32,
47 pub changed_pixels: u32,
49 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
73const PIXEL_TOLERANCE: u8 = 10;
75
76fn 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
83pub 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
161pub 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 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 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}