agent_image_diff/
classify.rs1use image::RgbaImage;
2
3use crate::region::Region;
4
5pub 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
11pub 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 = ®ion.bounding_box;
40
41 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 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 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 let baseline_var = variance(&baseline_variances);
82 let candidate_var = variance(&candidate_variances);
83 let low_var_threshold = 100.0; 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 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
103fn 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
109fn 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 let threshold = 5.0; 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
127fn 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}