Skip to main content

agent_image_diff/
merge.rs

1use crate::region::{BoundingBox, Region};
2
3/// Merge regions whose bounding boxes are within `max_gap` pixels of each other.
4///
5/// Uses greedy iterative merging. O(n²) per pass on region count,
6/// repeated until stable. Region count is typically small (<100) so this is fast.
7pub fn merge_regions(regions: &mut Vec<Region>, max_gap: u32) {
8    if max_gap == 0 || regions.len() < 2 {
9        return;
10    }
11
12    let mut merged = true;
13    while merged {
14        merged = false;
15        let mut i = 0;
16        while i < regions.len() {
17            let mut j = i + 1;
18            while j < regions.len() {
19                if bbox_gap(&regions[i].bounding_box, &regions[j].bounding_box) <= max_gap {
20                    // Merge j into i
21                    let removed = regions.remove(j);
22                    let target = &mut regions[i];
23                    let new_bbox = bbox_union(&target.bounding_box, &removed.bounding_box);
24                    let total_px = target.pixel_count + removed.pixel_count;
25                    target.avg_delta = (target.avg_delta * target.pixel_count as f64
26                        + removed.avg_delta * removed.pixel_count as f64)
27                        / total_px as f64;
28                    target.max_delta = target.max_delta.max(removed.max_delta);
29                    target.pixel_count = total_px;
30                    target.bounding_box = new_bbox;
31                    target.component_ids.extend(removed.component_ids);
32                    merged = true;
33                    // Don't increment j — next element shifted into position
34                } else {
35                    j += 1;
36                }
37            }
38            i += 1;
39        }
40    }
41
42    // Re-sort by pixel count descending and re-assign IDs
43    regions.sort_by(|a, b| b.pixel_count.cmp(&a.pixel_count));
44    for (i, region) in regions.iter_mut().enumerate() {
45        region.id = (i + 1) as u32;
46    }
47}
48
49/// Compute the minimum gap (in pixels) between two bounding boxes.
50/// Returns 0 if they overlap or touch.
51fn bbox_gap(a: &BoundingBox, b: &BoundingBox) -> u32 {
52    let a_right = a.x + a.width;
53    let a_bottom = a.y + a.height;
54    let b_right = b.x + b.width;
55    let b_bottom = b.y + b.height;
56
57    let gap_x = if a_right <= b.x {
58        b.x - a_right
59    } else if b_right <= a.x {
60        a.x - b_right
61    } else {
62        0
63    };
64
65    let gap_y = if a_bottom <= b.y {
66        b.y - a_bottom
67    } else if b_bottom <= a.y {
68        a.y - b_bottom
69    } else {
70        0
71    };
72
73    // Chebyshev distance between closest edges
74    gap_x.max(gap_y)
75}
76
77/// Compute the union (smallest enclosing) bounding box of two bounding boxes.
78fn bbox_union(a: &BoundingBox, b: &BoundingBox) -> BoundingBox {
79    let x = a.x.min(b.x);
80    let y = a.y.min(b.y);
81    let right = (a.x + a.width).max(b.x + b.width);
82    let bottom = (a.y + a.height).max(b.y + b.height);
83    BoundingBox {
84        x,
85        y,
86        width: right - x,
87        height: bottom - y,
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn make_region(id: u32, x: u32, y: u32, w: u32, h: u32, px: u32) -> Region {
96        Region {
97            id,
98            bounding_box: BoundingBox {
99                x,
100                y,
101                width: w,
102                height: h,
103            },
104            pixel_count: px,
105            avg_delta: 0.5,
106            max_delta: 0.8,
107            label: String::new(),
108            component_ids: vec![id],
109        }
110    }
111
112    #[test]
113    fn no_merge_when_disabled() {
114        let mut regions = vec![
115            make_region(1, 0, 0, 10, 10, 100),
116            make_region(2, 15, 0, 10, 10, 50),
117        ];
118        merge_regions(&mut regions, 0);
119        assert_eq!(regions.len(), 2);
120    }
121
122    #[test]
123    fn nearby_regions_merge() {
124        let mut regions = vec![
125            make_region(1, 0, 0, 10, 10, 100),
126            make_region(2, 15, 0, 10, 10, 50),
127        ];
128        merge_regions(&mut regions, 10);
129        assert_eq!(regions.len(), 1);
130        assert_eq!(regions[0].bounding_box.x, 0);
131        assert_eq!(regions[0].bounding_box.width, 25);
132        assert_eq!(regions[0].pixel_count, 150);
133    }
134
135    #[test]
136    fn distant_regions_stay_separate() {
137        let mut regions = vec![
138            make_region(1, 0, 0, 10, 10, 100),
139            make_region(2, 100, 100, 10, 10, 50),
140        ];
141        merge_regions(&mut regions, 20);
142        assert_eq!(regions.len(), 2);
143    }
144
145    #[test]
146    fn overlapping_regions_merge() {
147        let mut regions = vec![
148            make_region(1, 0, 0, 20, 20, 200),
149            make_region(2, 10, 10, 20, 20, 100),
150        ];
151        merge_regions(&mut regions, 1);
152        assert_eq!(regions.len(), 1);
153        assert_eq!(regions[0].bounding_box.x, 0);
154        assert_eq!(regions[0].bounding_box.y, 0);
155        assert_eq!(regions[0].bounding_box.width, 30);
156        assert_eq!(regions[0].bounding_box.height, 30);
157    }
158
159    #[test]
160    fn chain_merge() {
161        // Three regions in a line, each within distance of the next
162        // A -- B -- C should all merge into one
163        let mut regions = vec![
164            make_region(1, 0, 0, 10, 10, 50),
165            make_region(2, 15, 0, 10, 10, 50),
166            make_region(3, 30, 0, 10, 10, 50),
167        ];
168        merge_regions(&mut regions, 10);
169        assert_eq!(regions.len(), 1);
170        assert_eq!(regions[0].pixel_count, 150);
171    }
172}