Skip to main content

blazediff_interpret/region/
mod.rs

1mod label_extract;
2mod morphology;
3
4use super::types::BoundingBox;
5use label_extract::extract_labeled_regions;
6use morphology::morph_close;
7
8struct UnionFind {
9    parent: Vec<u32>,
10    rank: Vec<u8>,
11}
12
13impl UnionFind {
14    fn new(size: usize) -> Self {
15        Self {
16            parent: (0..size as u32).collect(),
17            rank: vec![0; size],
18        }
19    }
20
21    fn find(&mut self, mut x: u32) -> u32 {
22        while self.parent[x as usize] != x {
23            self.parent[x as usize] = self.parent[self.parent[x as usize] as usize];
24            x = self.parent[x as usize];
25        }
26        x
27    }
28
29    fn union(&mut self, a: u32, b: u32) {
30        let ra = self.find(a);
31        let rb = self.find(b);
32        if ra == rb {
33            return;
34        }
35        match self.rank[ra as usize].cmp(&self.rank[rb as usize]) {
36            std::cmp::Ordering::Less => self.parent[ra as usize] = rb,
37            std::cmp::Ordering::Greater => self.parent[rb as usize] = ra,
38            std::cmp::Ordering::Equal => {
39                self.parent[rb as usize] = ra;
40                self.rank[ra as usize] += 1;
41            }
42        }
43    }
44}
45
46pub struct ComponentInfo {
47    pub bbox: BoundingBox,
48    pub pixel_count: u32,
49}
50
51/// Extract change mask from output image: a pixel is changed if it's not grayscale (R != G or R != B).
52pub fn extract_change_mask(output_data: &[u8], width: u32, height: u32) -> Vec<bool> {
53    let total = (width * height) as usize;
54    let mut mask = vec![false; total];
55
56    #[cfg(target_arch = "aarch64")]
57    {
58        // SAFETY: aarch64 always has NEON
59        unsafe { extract_mask_neon(output_data.as_ptr(), &mut mask, total) };
60        return mask;
61    }
62
63    #[cfg(not(target_arch = "aarch64"))]
64    {
65        for i in 0..total {
66            let pos = i * 4;
67            let r = output_data[pos];
68            let g = output_data[pos + 1];
69            let b = output_data[pos + 2];
70            mask[i] = r != g || r != b;
71        }
72        mask
73    }
74}
75
76/// NEON-accelerated change mask extraction. Processes 16 pixels at a time.
77#[cfg(target_arch = "aarch64")]
78unsafe fn extract_mask_neon(data: *const u8, mask: &mut [bool], count: usize) {
79    use std::arch::aarch64::*;
80
81    let chunks = count / 16;
82    let mask_ptr = mask.as_mut_ptr() as *mut u8;
83
84    for i in 0..chunks {
85        let base = i * 16 * 4;
86        // Load 64 bytes (16 RGBA pixels) as 4 interleaved channels
87        let rgba = vld4q_u8(data.add(base));
88        let r = rgba.0;
89        let g = rgba.1;
90        let b = rgba.2;
91
92        // R != G
93        let ne_rg = vmvnq_u8(vceqq_u8(r, g));
94        // R != B
95        let ne_rb = vmvnq_u8(vceqq_u8(r, b));
96        // changed = (R != G) | (R != B)
97        let changed = vorrq_u8(ne_rg, ne_rb);
98
99        // Convert 0xFF to 0x01 for bool representation
100        let ones = vdupq_n_u8(1);
101        let result = vandq_u8(changed, ones);
102
103        vst1q_u8(mask_ptr.add(i * 16), result);
104    }
105
106    // Scalar tail
107    let processed = chunks * 16;
108    for i in processed..count {
109        let pos = i * 4;
110        let r = *data.add(pos);
111        let g = *data.add(pos + 1);
112        let b = *data.add(pos + 2);
113        mask[i] = r != g || r != b;
114    }
115}
116
117/// Test-only: CC labeling without morph close, for unit testing the CC algorithm directly.
118#[cfg(test)]
119fn find_connected_components(mask: &[bool], width: u32, height: u32) -> Vec<ComponentInfo> {
120    let labels = label_connected_components(mask, width, height);
121    extract_labeled_regions(&labels, mask, width)
122}
123
124/// Label each foreground pixel with its connected component ID (4-connectivity).
125/// Returns a label map: 0 = background, >0 = component label.
126fn label_connected_components(mask: &[bool], width: u32, height: u32) -> Vec<i32> {
127    let w = width as usize;
128    let h = height as usize;
129    let total = w * h;
130    let mut labels = vec![0i32; total];
131
132    if total == 0 {
133        return labels;
134    }
135
136    let mut uf = UnionFind::new(total);
137
138    for y in 0..h {
139        for x in 0..w {
140            let idx = y * w + x;
141            if !mask[idx] {
142                continue;
143            }
144            if x > 0 && mask[idx - 1] {
145                uf.union(idx as u32, (idx - 1) as u32);
146            }
147            if y > 0 && mask[idx - w] {
148                uf.union(idx as u32, (idx - w) as u32);
149            }
150        }
151    }
152
153    // Map roots to sequential labels
154    let mut root_to_label: std::collections::HashMap<u32, i32> = std::collections::HashMap::new();
155    let mut next_label = 1i32;
156
157    for i in 0..total {
158        if !mask[i] {
159            continue;
160        }
161        let root = uf.find(i as u32);
162        let label = *root_to_label.entry(root).or_insert_with(|| {
163            let l = next_label;
164            next_label += 1;
165            l
166        });
167        labels[i] = label;
168    }
169
170    labels
171}
172
173/// Full region detection pipeline:
174/// binary mask → morph close → connected components → extract with original mask
175pub fn detect_regions(mask: &[bool], width: u32, height: u32) -> Vec<ComponentInfo> {
176    let total = (width * height) as usize;
177    if total == 0 || !mask.iter().any(|&m| m) {
178        return Vec::new();
179    }
180
181    // 1. Morphological close to bridge small gaps
182    let closed = morph_close(mask, width, height);
183
184    // 2. Connected components on closed mask
185    let labels = label_connected_components(&closed, width, height);
186
187    // 3. Extract regions using original mask for pixel counts
188    extract_labeled_regions(&labels, mask, width)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_extract_change_mask_gray_pixels() {
197        let data = vec![128, 128, 128, 255, 128, 128, 128, 255];
198        let mask = extract_change_mask(&data, 2, 1);
199        assert_eq!(mask, vec![false, false]);
200    }
201
202    #[test]
203    fn test_extract_change_mask_colored_pixels() {
204        let data = vec![255, 0, 0, 255, 128, 128, 128, 255];
205        let mask = extract_change_mask(&data, 2, 1);
206        assert_eq!(mask, vec![true, false]);
207    }
208
209    #[test]
210    fn test_find_connected_components_empty() {
211        let mask = vec![false; 9];
212        let components = find_connected_components(&mask, 3, 3);
213        assert!(components.is_empty());
214    }
215
216    #[test]
217    fn test_find_connected_components_single_pixel() {
218        let mut mask = vec![false; 9];
219        mask[4] = true;
220        let components = find_connected_components(&mask, 3, 3);
221        assert_eq!(components.len(), 1);
222        assert_eq!(components[0].pixel_count, 1);
223        assert_eq!(
224            components[0].bbox,
225            BoundingBox {
226                x: 1,
227                y: 1,
228                width: 1,
229                height: 1,
230            }
231        );
232    }
233
234    #[test]
235    fn test_find_connected_components_two_separate() {
236        let mask = vec![true, false, false, false, true];
237        let components = find_connected_components(&mask, 5, 1);
238        assert_eq!(components.len(), 2);
239    }
240
241    #[test]
242    fn test_find_connected_components_l_shape() {
243        let mask = vec![true, false, false, true, false, false, true, true, true];
244        let components = find_connected_components(&mask, 3, 3);
245        assert_eq!(components.len(), 1);
246        assert_eq!(components[0].pixel_count, 5);
247        assert_eq!(
248            components[0].bbox,
249            BoundingBox {
250                x: 0,
251                y: 0,
252                width: 3,
253                height: 3,
254            }
255        );
256    }
257
258    #[test]
259    fn test_detect_regions_empty() {
260        let mask = vec![false; 100];
261        let regions = detect_regions(&mask, 10, 10);
262        assert!(regions.is_empty());
263    }
264
265    #[test]
266    fn test_detect_regions_single_blob() {
267        let mut mask = vec![false; 10000]; // 100x100
268        for y in 40..60 {
269            for x in 40..60 {
270                mask[y * 100 + x] = true;
271            }
272        }
273        let regions = detect_regions(&mask, 100, 100);
274        assert_eq!(regions.len(), 1);
275        assert_eq!(regions[0].pixel_count, 400);
276    }
277
278    #[test]
279    fn test_detect_regions_two_separate_blobs() {
280        // Two blobs far apart - should stay separate
281        let mut mask = vec![false; 10000]; // 100x100
282        for y in 5..15 {
283            for x in 5..15 {
284                mask[y * 100 + x] = true;
285            }
286        }
287        for y in 80..90 {
288            for x in 80..90 {
289                mask[y * 100 + x] = true;
290            }
291        }
292        let regions = detect_regions(&mask, 100, 100);
293        assert_eq!(regions.len(), 2);
294    }
295
296    #[test]
297    fn test_detect_regions_nearby_blobs_bridged() {
298        // Two blobs with small gap - morph close should bridge
299        let mut mask = vec![false; 10000]; // 100x100
300                                           // Blob 1
301        for y in 40..50 {
302            for x in 40..48 {
303                mask[y * 100 + x] = true;
304            }
305        }
306        // Blob 2 - gap of 3px
307        for y in 40..50 {
308            for x in 51..60 {
309                mask[y * 100 + x] = true;
310            }
311        }
312        let regions = detect_regions(&mask, 100, 100);
313        // With adaptive radius=2 for 100x100, a 3px gap should be bridged
314        assert_eq!(
315            regions.len(),
316            1,
317            "nearby blobs should be merged by morph close"
318        );
319    }
320
321    #[test]
322    fn test_detect_regions_connected_bridge() {
323        // Two blobs connected by a 1px bridge - single connected component
324        let mut mask = vec![false; 50 * 20]; // 50x20
325        for y in 6..14 {
326            for x in 2..10 {
327                mask[y * 50 + x] = true;
328            }
329        }
330        for y in 6..14 {
331            for x in 30..38 {
332                mask[y * 50 + x] = true;
333            }
334        }
335        for x in 10..30 {
336            mask[10 * 50 + x] = true;
337        }
338        let regions = detect_regions(&mask, 50, 20);
339        assert_eq!(regions.len(), 1, "connected blobs should be one region");
340    }
341
342    #[test]
343    fn test_extract_mask_simd_matches_scalar() {
344        // Test with various sizes to exercise SIMD + scalar tail
345        for size in [1, 15, 16, 17, 31, 32, 33, 64, 100] {
346            let mut data = vec![128u8; size * 4]; // all gray
347                                                  // Sprinkle some colored pixels
348            for i in (0..size).step_by(3) {
349                data[i * 4] = 255; // R=255, G=128, B=128 → changed
350            }
351
352            let mask = extract_change_mask(&data, size as u32, 1);
353
354            for i in 0..size {
355                let r = data[i * 4];
356                let g = data[i * 4 + 1];
357                let b = data[i * 4 + 2];
358                let expected = r != g || r != b;
359                assert_eq!(
360                    mask[i], expected,
361                    "mismatch at pixel {i} (size={size}): r={r} g={g} b={b}"
362                );
363            }
364        }
365    }
366}