Skip to main content

agentic_vision/
diff.rs

1//! Change detection between visual captures.
2
3use image::{DynamicImage, GenericImageView, GrayImage, Luma};
4
5use crate::types::{Rect, VisionResult, VisualDiff};
6
7/// Pixel difference threshold (0-255) for considering a pixel "changed".
8const DIFF_THRESHOLD: u8 = 30;
9
10/// Minimum region size (pixels) to report as a changed region.
11const MIN_REGION_SIZE: u32 = 10;
12
13/// Compute a visual diff between two images (provided as JPEG thumbnail bytes).
14pub fn compute_diff(
15    before_id: u64,
16    after_id: u64,
17    img_a: &DynamicImage,
18    img_b: &DynamicImage,
19) -> VisionResult<VisualDiff> {
20    let (w_a, h_a) = img_a.dimensions();
21    let (w_b, h_b) = img_b.dimensions();
22
23    // Resize to common dimensions for comparison
24    let target_w = w_a.min(w_b);
25    let target_h = h_a.min(h_b);
26
27    let gray_a = img_a
28        .resize_exact(target_w, target_h, image::imageops::FilterType::Nearest)
29        .to_luma8();
30    let gray_b = img_b
31        .resize_exact(target_w, target_h, image::imageops::FilterType::Nearest)
32        .to_luma8();
33
34    // Compute per-pixel absolute difference
35    let mut diff_img = GrayImage::new(target_w, target_h);
36    let mut changed_pixels = 0u64;
37    let total_pixels = (target_w as u64) * (target_h as u64);
38
39    for y in 0..target_h {
40        for x in 0..target_w {
41            let a = gray_a.get_pixel(x, y).0[0];
42            let b = gray_b.get_pixel(x, y).0[0];
43            let d = (a as i16 - b as i16).unsigned_abs() as u8;
44            diff_img.put_pixel(x, y, Luma([d]));
45            if d > DIFF_THRESHOLD {
46                changed_pixels += 1;
47            }
48        }
49    }
50
51    let pixel_diff_ratio = if total_pixels > 0 {
52        changed_pixels as f32 / total_pixels as f32
53    } else {
54        0.0
55    };
56
57    let similarity = 1.0 - pixel_diff_ratio;
58    let changed_regions = find_changed_regions(&diff_img, DIFF_THRESHOLD);
59
60    Ok(VisualDiff {
61        before_id,
62        after_id,
63        similarity,
64        changed_regions,
65        pixel_diff_ratio,
66    })
67}
68
69/// Find bounding boxes of changed regions using simple grid-based detection.
70fn find_changed_regions(diff_img: &GrayImage, threshold: u8) -> Vec<Rect> {
71    let (w, h) = diff_img.dimensions();
72    if w == 0 || h == 0 {
73        return Vec::new();
74    }
75
76    // Divide image into a grid and find cells with significant changes
77    let cell_w = (w / 8).max(1);
78    let cell_h = (h / 8).max(1);
79    let mut regions = Vec::new();
80
81    for gy in 0..(h / cell_h).max(1) {
82        for gx in 0..(w / cell_w).max(1) {
83            let x0 = gx * cell_w;
84            let y0 = gy * cell_h;
85            let x1 = ((gx + 1) * cell_w).min(w);
86            let y1 = ((gy + 1) * cell_h).min(h);
87
88            let mut changed = 0u32;
89            let total = (x1 - x0) * (y1 - y0);
90
91            for y in y0..y1 {
92                for x in x0..x1 {
93                    if diff_img.get_pixel(x, y).0[0] > threshold {
94                        changed += 1;
95                    }
96                }
97            }
98
99            // If more than 10% of cell pixels changed, mark this region
100            if total > 0 && changed > total / 10 && (x1 - x0) >= MIN_REGION_SIZE {
101                regions.push(Rect {
102                    x: x0,
103                    y: y0,
104                    w: x1 - x0,
105                    h: y1 - y0,
106                });
107            }
108        }
109    }
110
111    // Merge adjacent regions
112    merge_adjacent_regions(&mut regions);
113    regions
114}
115
116/// Merge adjacent or overlapping rectangles.
117fn merge_adjacent_regions(regions: &mut Vec<Rect>) {
118    if regions.len() < 2 {
119        return;
120    }
121
122    let mut merged = true;
123    while merged {
124        merged = false;
125        let mut i = 0;
126        while i < regions.len() {
127            let mut j = i + 1;
128            while j < regions.len() {
129                if rects_adjacent(&regions[i], &regions[j]) {
130                    let a = regions[i];
131                    let b = regions.remove(j);
132                    regions[i] = merge_rects(&a, &b);
133                    merged = true;
134                } else {
135                    j += 1;
136                }
137            }
138            i += 1;
139        }
140    }
141}
142
143fn rects_adjacent(a: &Rect, b: &Rect) -> bool {
144    let a_right = a.x + a.w;
145    let a_bottom = a.y + a.h;
146    let b_right = b.x + b.w;
147    let b_bottom = b.y + b.h;
148
149    // Check if they overlap or touch
150    !(a_right < b.x || b_right < a.x || a_bottom < b.y || b_bottom < a.y)
151}
152
153fn merge_rects(a: &Rect, b: &Rect) -> Rect {
154    let x = a.x.min(b.x);
155    let y = a.y.min(b.y);
156    let right = (a.x + a.w).max(b.x + b.w);
157    let bottom = (a.y + a.h).max(b.y + b.h);
158    Rect {
159        x,
160        y,
161        w: right - x,
162        h: bottom - y,
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_identical_images() {
172        let img = DynamicImage::new_rgb8(100, 100);
173        let diff = compute_diff(1, 2, &img, &img).unwrap();
174        assert!((diff.similarity - 1.0).abs() < 0.01);
175        assert!(diff.pixel_diff_ratio < 0.01);
176    }
177
178    #[test]
179    fn test_different_images() {
180        let mut img_a = DynamicImage::new_rgb8(100, 100);
181        let img_b = DynamicImage::new_rgba8(100, 100);
182        // Fill img_a with white
183        if let Some(rgb) = img_a.as_mut_rgb8() {
184            for pixel in rgb.pixels_mut() {
185                *pixel = image::Rgb([255, 255, 255]);
186            }
187        }
188        let diff = compute_diff(1, 2, &img_a, &img_b).unwrap();
189        assert!(diff.similarity < 1.0);
190    }
191}