agent_image_diff/dilate.rs
1/// Morphological dilation of a boolean mask.
2///
3/// Expands true pixels by `radius` in all directions using separable
4/// box dilation (horizontal pass then vertical pass). O(n) per pass
5/// regardless of radius.
6///
7/// The dilated mask is used for clustering only — original mask is
8/// preserved for accurate pixel statistics.
9pub fn dilate(mask: &[bool], width: u32, height: u32, radius: u32) -> Vec<bool> {
10 if radius == 0 {
11 return mask.to_vec();
12 }
13
14 let w = width as usize;
15 let h = height as usize;
16 let r = radius as usize;
17
18 // Horizontal pass: for each row, dilate along x
19 let mut horiz = vec![false; w * h];
20 for y in 0..h {
21 let row_start = y * w;
22 // Count of true pixels in the sliding window
23 // Window covers [x - r, x + r]
24 let mut count = 0usize;
25
26 // Initialize window for x=0: covers [0, min(r, w-1)]
27 for wx in 0..=(r.min(w - 1)) {
28 if mask[row_start + wx] {
29 count += 1;
30 }
31 }
32
33 for x in 0..w {
34 if count > 0 {
35 horiz[row_start + x] = true;
36 }
37 // Slide window: add pixel entering on the right, remove pixel leaving on the left
38 let add_x = x + r + 1;
39 if add_x < w && mask[row_start + add_x] {
40 count += 1;
41 }
42 if x >= r {
43 let remove_x = x - r;
44 if mask[row_start + remove_x] {
45 count -= 1;
46 }
47 }
48 }
49 }
50
51 // Vertical pass: for each column, dilate along y using horiz as input
52 let mut result = vec![false; w * h];
53 for x in 0..w {
54 let mut count = 0usize;
55
56 // Initialize window for y=0
57 for wy in 0..=(r.min(h - 1)) {
58 if horiz[wy * w + x] {
59 count += 1;
60 }
61 }
62
63 for y in 0..h {
64 if count > 0 {
65 result[y * w + x] = true;
66 }
67 let add_y = y + r + 1;
68 if add_y < h && horiz[add_y * w + x] {
69 count += 1;
70 }
71 if y >= r {
72 let remove_y = y - r;
73 if horiz[remove_y * w + x] {
74 count -= 1;
75 }
76 }
77 }
78 }
79
80 result
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 #[test]
88 fn radius_zero_is_noop() {
89 let mask = vec![false, true, false, false];
90 let result = dilate(&mask, 2, 2, 0);
91 assert_eq!(result, mask);
92 }
93
94 #[test]
95 fn single_pixel_expands() {
96 // 5x5 grid with one true pixel at center (2,2)
97 let mut mask = vec![false; 25];
98 mask[2 * 5 + 2] = true;
99
100 let result = dilate(&mask, 5, 5, 1);
101
102 // Should expand to a cross/diamond shape with radius 1
103 // Center and all 4-neighbors should be true
104 assert!(result[2 * 5 + 2]); // center
105 assert!(result[1 * 5 + 2]); // up
106 assert!(result[3 * 5 + 2]); // down
107 assert!(result[2 * 5 + 1]); // left
108 assert!(result[2 * 5 + 3]); // right
109 // Corners at distance 2 should also be true (box dilation, not diamond)
110 assert!(result[1 * 5 + 1]); // up-left
111 assert!(result[1 * 5 + 3]); // up-right
112 assert!(result[3 * 5 + 1]); // down-left
113 assert!(result[3 * 5 + 3]); // down-right
114 }
115
116 #[test]
117 fn nearby_pixels_merge() {
118 // Two pixels 3 apart: (0,0) and (0,4) in a 5x1 grid
119 let mask = vec![true, false, false, false, true];
120 let result = dilate(&mask, 5, 1, 2);
121 // With radius 2, they should merge into one contiguous block
122 assert!(result.iter().all(|&v| v));
123 }
124
125 #[test]
126 fn distant_pixels_stay_separate() {
127 // Two pixels far apart in a 10x1 grid
128 let mut mask = vec![false; 10];
129 mask[0] = true;
130 mask[9] = true;
131 let result = dilate(&mask, 10, 1, 2);
132 // Pixel 0 expands to [0,2], pixel 9 expands to [7,9]
133 // Middle pixels [3,6] should remain false
134 assert!(!result[4]);
135 assert!(!result[5]);
136 }
137}