Skip to main content

agent_image_diff/
classify.rs

1use image::RgbaImage;
2
3use crate::region::Region;
4
5/// Labels for region classification.
6pub const LABEL_ADDED: &str = "added";
7pub const LABEL_REMOVED: &str = "removed";
8pub const LABEL_COLOR_CHANGE: &str = "color-change";
9pub const LABEL_CONTENT_CHANGE: &str = "content-change";
10
11/// Classify each region by analyzing pixels in the baseline and candidate images.
12///
13/// Labels:
14/// - "added": region area is mostly uniform/empty in baseline
15/// - "removed": region area is mostly uniform/empty in candidate
16/// - "color-change": structure (edges) matches but colors differ
17/// - "content-change": structure differs (fallback)
18pub fn classify_regions(
19    regions: &mut [Region],
20    baseline: &RgbaImage,
21    candidate: &RgbaImage,
22    diff_mask: &[bool],
23    width: u32,
24    height: u32,
25) {
26    for region in regions.iter_mut() {
27        region.label = classify_one(region, baseline, candidate, diff_mask, width, height);
28    }
29}
30
31fn classify_one(
32    region: &Region,
33    baseline: &RgbaImage,
34    candidate: &RgbaImage,
35    diff_mask: &[bool],
36    width: u32,
37    height: u32,
38) -> String {
39    let bb = &region.bounding_box;
40
41    // Collect changed pixels within the bounding box
42    let mut baseline_variances = Vec::new();
43    let mut candidate_variances = Vec::new();
44    let mut gradient_matches = 0u32;
45    let mut gradient_total = 0u32;
46
47    let x_end = (bb.x + bb.width).min(width);
48    let y_end = (bb.y + bb.height).min(height);
49
50    for y in bb.y..y_end {
51        for x in bb.x..x_end {
52            let idx = (y * width + x) as usize;
53            if idx >= diff_mask.len() || !diff_mask[idx] {
54                continue;
55            }
56
57            // Compute local luminance for variance check
58            if x < baseline.width() && y < baseline.height() {
59                baseline_variances.push(pixel_luminance(baseline, x, y));
60            }
61            if x < candidate.width() && y < candidate.height() {
62                candidate_variances.push(pixel_luminance(candidate, x, y));
63            }
64
65            // Gradient direction check (needs neighbors)
66            if x > 0 && x + 1 < width && y > 0 && y + 1 < height
67                && x < baseline.width().saturating_sub(1)
68                && y < baseline.height().saturating_sub(1)
69                && x < candidate.width().saturating_sub(1)
70                && y < candidate.height().saturating_sub(1)
71            {
72                gradient_total += 1;
73                if gradients_match(baseline, candidate, x, y) {
74                    gradient_matches += 1;
75                }
76            }
77        }
78    }
79
80    // Check for added/removed: one image has very low variance (uniform background)
81    let baseline_var = variance(&baseline_variances);
82    let candidate_var = variance(&candidate_variances);
83    let low_var_threshold = 100.0; // luminance variance threshold for "uniform"
84
85    if baseline_var < low_var_threshold && candidate_var >= low_var_threshold {
86        return LABEL_ADDED.to_string();
87    }
88    if candidate_var < low_var_threshold && baseline_var >= low_var_threshold {
89        return LABEL_REMOVED.to_string();
90    }
91
92    // Check for color-change vs content-change via gradient matching
93    if gradient_total > 0 {
94        let match_ratio = gradient_matches as f64 / gradient_total as f64;
95        if match_ratio > 0.7 {
96            return LABEL_COLOR_CHANGE.to_string();
97        }
98    }
99
100    LABEL_CONTENT_CHANGE.to_string()
101}
102
103/// Get luminance of a pixel (simple weighted average).
104fn pixel_luminance(img: &RgbaImage, x: u32, y: u32) -> f64 {
105    let px = img.get_pixel(x, y).0;
106    0.299 * px[0] as f64 + 0.587 * px[1] as f64 + 0.114 * px[2] as f64
107}
108
109/// Check if the gradient direction (horizontal and vertical) at (x,y)
110/// is similar in both images. Returns true if signs match.
111fn gradients_match(baseline: &RgbaImage, candidate: &RgbaImage, x: u32, y: u32) -> bool {
112    let b_gx = pixel_luminance(baseline, x + 1, y) - pixel_luminance(baseline, x - 1, y);
113    let b_gy = pixel_luminance(baseline, x, y + 1) - pixel_luminance(baseline, x, y - 1);
114    let c_gx = pixel_luminance(candidate, x + 1, y) - pixel_luminance(candidate, x - 1, y);
115    let c_gy = pixel_luminance(candidate, x, y + 1) - pixel_luminance(candidate, x, y - 1);
116
117    // Signs match if both have same direction (or both near zero)
118    let threshold = 5.0; // ignore tiny gradients
119    let gx_match = (b_gx.abs() < threshold && c_gx.abs() < threshold)
120        || (b_gx.signum() == c_gx.signum());
121    let gy_match = (b_gy.abs() < threshold && c_gy.abs() < threshold)
122        || (b_gy.signum() == c_gy.signum());
123
124    gx_match && gy_match
125}
126
127/// Compute variance of a slice of f64 values.
128fn variance(values: &[f64]) -> f64 {
129    if values.is_empty() {
130        return 0.0;
131    }
132    let mean = values.iter().sum::<f64>() / values.len() as f64;
133    values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn variance_of_uniform_is_zero() {
142        let vals = vec![100.0, 100.0, 100.0];
143        assert_eq!(variance(&vals), 0.0);
144    }
145
146    #[test]
147    fn variance_of_varied_is_high() {
148        let vals = vec![0.0, 255.0, 0.0, 255.0];
149        assert!(variance(&vals) > 1000.0);
150    }
151
152    #[test]
153    fn variance_empty_is_zero() {
154        assert_eq!(variance(&[]), 0.0);
155    }
156}