Skip to main content

oxicuda_vision/imgproc/
connected_components.rs

1//! Connected-component labelling of binary images via union-find.
2//!
3//! Given a **single-channel** `[h × w]` row-major `f32` image, every pixel with
4//! a strictly positive value (`> 0`) is treated as *foreground*; everything else
5//! is *background*. [`connected_components`] groups foreground pixels into
6//! maximal connected regions under either 4- or 8-connectivity and returns a
7//! label image plus per-component statistics.
8//!
9//! The algorithm is the classical two-pass scan (Rosenfeld–Pfaltz) backed by a
10//! union-find / disjoint-set forest with path halving and union by rank:
11//!
12//! 1. **First pass** — scan in raster order; each foreground pixel inherits the
13//!    smallest label among its already-visited neighbours (or starts a new
14//!    provisional label), unioning every neighbouring label together.
15//! 2. **Resolve** — collapse equivalence classes to their representatives.
16//! 3. **Second pass** — relabel every pixel to a compact `1..=num_components`
17//!    identifier and accumulate component sizes.
18
19use crate::error::{VisionError, VisionResult};
20
21/// Pixel adjacency used when grouping foreground pixels.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Connectivity {
24    /// 4-connectivity: only the N/S/E/W neighbours are adjacent.
25    Four,
26    /// 8-connectivity: the diagonal neighbours are adjacent as well.
27    Eight,
28}
29
30/// Result of [`connected_components`].
31#[derive(Debug, Clone)]
32pub struct ComponentLabels {
33    /// Row-major `[height × width]` label image; `0` is background and
34    /// `1..=num_components` identifies each connected region.
35    pub labels: Vec<u32>,
36    /// Number of distinct foreground components.
37    pub num_components: usize,
38    /// Image height.
39    pub height: usize,
40    /// Image width.
41    pub width: usize,
42    /// Pixel count of each component; `sizes[label - 1]` is the size of `label`.
43    sizes: Vec<usize>,
44}
45
46impl ComponentLabels {
47    /// Per-component pixel counts, indexed by `label - 1`.
48    #[must_use]
49    pub fn sizes(&self) -> &[usize] {
50        &self.sizes
51    }
52
53    /// Pixel count of a specific `label` (`0` for background or an out-of-range
54    /// label).
55    #[must_use]
56    pub fn size_of(&self, label: u32) -> usize {
57        if label == 0 || (label as usize) > self.num_components {
58            0
59        } else {
60            self.sizes[label as usize - 1]
61        }
62    }
63
64    /// Axis-aligned bounding boxes of every component as inclusive
65    /// `[x_min, y_min, x_max, y_max]`, indexed by `label - 1`.
66    #[must_use]
67    pub fn bounding_boxes(&self) -> Vec<[usize; 4]> {
68        let mut boxes = vec![[usize::MAX, usize::MAX, 0usize, 0usize]; self.num_components];
69        for y in 0..self.height {
70            for x in 0..self.width {
71                let label = self.labels[y * self.width + x];
72                if label == 0 {
73                    continue;
74                }
75                let bb = &mut boxes[label as usize - 1];
76                bb[0] = bb[0].min(x);
77                bb[1] = bb[1].min(y);
78                bb[2] = bb[2].max(x);
79                bb[3] = bb[3].max(y);
80            }
81        }
82        boxes
83    }
84}
85
86/// Disjoint-set forest over provisional labels (index `0` reserved for
87/// background and never allocated as a set).
88struct UnionFind {
89    parent: Vec<u32>,
90    rank: Vec<u32>,
91}
92
93impl UnionFind {
94    fn new() -> Self {
95        // Index 0 is the background sentinel.
96        Self {
97            parent: vec![0],
98            rank: vec![0],
99        }
100    }
101
102    /// Allocate a fresh singleton set and return its label.
103    fn make_set(&mut self) -> u32 {
104        let id = self.parent.len() as u32;
105        self.parent.push(id);
106        self.rank.push(0);
107        id
108    }
109
110    /// Find the representative of `x` with path halving.
111    fn find(&mut self, mut x: u32) -> u32 {
112        while self.parent[x as usize] != x {
113            let grandparent = self.parent[self.parent[x as usize] as usize];
114            self.parent[x as usize] = grandparent;
115            x = grandparent;
116        }
117        x
118    }
119
120    /// Merge the sets containing `a` and `b` (union by rank).
121    fn union(&mut self, a: u32, b: u32) {
122        let ra = self.find(a);
123        let rb = self.find(b);
124        if ra == rb {
125            return;
126        }
127        let (high, low) = if self.rank[ra as usize] < self.rank[rb as usize] {
128            (rb, ra)
129        } else {
130            (ra, rb)
131        };
132        self.parent[low as usize] = high;
133        if self.rank[high as usize] == self.rank[low as usize] {
134            self.rank[high as usize] += 1;
135        }
136    }
137}
138
139/// Validate a single-channel image buffer.
140#[inline]
141fn validate_gray(img: &[f32], h: usize, w: usize) -> VisionResult<()> {
142    if h == 0 || w == 0 {
143        return Err(VisionError::InvalidImageSize {
144            height: h,
145            width: w,
146            channels: 1,
147        });
148    }
149    if img.len() != h * w {
150        return Err(VisionError::DimensionMismatch {
151            expected: h * w,
152            got: img.len(),
153        });
154    }
155    Ok(())
156}
157
158/// Label the connected foreground components of a binary image.
159///
160/// Foreground pixels are those with value `> 0`. Returns a [`ComponentLabels`]
161/// holding the compact label image (`0` = background, `1..=num_components`),
162/// component count, and per-component sizes.
163///
164/// # Errors
165/// Returns [`VisionError::InvalidImageSize`] if `h == 0` or `w == 0`, and
166/// [`VisionError::DimensionMismatch`] if `img.len() != h * w`.
167pub fn connected_components(
168    img: &[f32],
169    h: usize,
170    w: usize,
171    connectivity: Connectivity,
172) -> VisionResult<ComponentLabels> {
173    validate_gray(img, h, w)?;
174
175    let mut uf = UnionFind::new();
176    let mut provisional = vec![0u32; h * w];
177
178    // ── First pass: provisional labelling + equivalence recording ───────────
179    let mut neighbours: Vec<u32> = Vec::with_capacity(4);
180    for y in 0..h {
181        for x in 0..w {
182            let idx = y * w + x;
183            if img[idx] <= 0.0 {
184                continue;
185            }
186            neighbours.clear();
187            // West.
188            if x > 0 && provisional[idx - 1] != 0 {
189                neighbours.push(provisional[idx - 1]);
190            }
191            // North.
192            if y > 0 && provisional[idx - w] != 0 {
193                neighbours.push(provisional[idx - w]);
194            }
195            if connectivity == Connectivity::Eight {
196                // North-west.
197                if x > 0 && y > 0 && provisional[idx - w - 1] != 0 {
198                    neighbours.push(provisional[idx - w - 1]);
199                }
200                // North-east.
201                if x + 1 < w && y > 0 && provisional[idx - w + 1] != 0 {
202                    neighbours.push(provisional[idx - w + 1]);
203                }
204            }
205
206            match neighbours.split_first() {
207                None => {
208                    provisional[idx] = uf.make_set();
209                }
210                Some((&head, tail)) => {
211                    let mut root = uf.find(head);
212                    for &other in tail {
213                        let r = uf.find(other);
214                        if r < root {
215                            root = r;
216                        }
217                    }
218                    uf.union(root, head);
219                    for &other in tail {
220                        uf.union(root, other);
221                    }
222                    provisional[idx] = root;
223                }
224            }
225        }
226    }
227
228    // ── Second pass: compact relabel + size accumulation ────────────────────
229    let mut remap = vec![0u32; uf.parent.len()];
230    let mut labels = vec![0u32; h * w];
231    let mut sizes: Vec<usize> = Vec::new();
232    let mut num_components = 0u32;
233    for (idx, &prov) in provisional.iter().enumerate() {
234        if prov == 0 {
235            continue;
236        }
237        let root = uf.find(prov);
238        let mut compact = remap[root as usize];
239        if compact == 0 {
240            num_components += 1;
241            compact = num_components;
242            remap[root as usize] = compact;
243            sizes.push(0);
244        }
245        labels[idx] = compact;
246        sizes[compact as usize - 1] += 1;
247    }
248
249    Ok(ComponentLabels {
250        labels,
251        num_components: num_components as usize,
252        height: h,
253        width: w,
254        sizes,
255    })
256}
257
258// ─── Tests ───────────────────────────────────────────────────────────────────
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn set_block(img: &mut [f32], w: usize, r0: usize, r1: usize, c0: usize, c1: usize) {
265        for y in r0..r1 {
266            for x in c0..c1 {
267                img[y * w + x] = 1.0;
268            }
269        }
270    }
271
272    #[test]
273    fn single_blob_one_component() {
274        let mut img = vec![0.0_f32; 8 * 8];
275        set_block(&mut img, 8, 2, 5, 2, 5); // 3×3 block
276        let cc = connected_components(&img, 8, 8, Connectivity::Eight).expect("cc");
277        assert_eq!(cc.num_components, 1);
278        assert_eq!(cc.size_of(1), 9);
279    }
280
281    #[test]
282    fn two_disjoint_blobs_two_components() {
283        let mut img = vec![0.0_f32; 8 * 8];
284        set_block(&mut img, 8, 0, 2, 0, 2);
285        set_block(&mut img, 8, 5, 7, 5, 7);
286        let cc = connected_components(&img, 8, 8, Connectivity::Eight).expect("cc");
287        assert_eq!(cc.num_components, 2);
288        assert_eq!(cc.size_of(1), 4);
289        assert_eq!(cc.size_of(2), 4);
290    }
291
292    #[test]
293    fn diagonal_pair_four_vs_eight_connectivity() {
294        // Two pixels touching only at a corner.
295        let mut img = vec![0.0_f32; 4 * 4];
296        let at = |y: usize, x: usize| y * 4 + x;
297        img[at(1, 1)] = 1.0;
298        img[at(2, 2)] = 1.0;
299        let cc4 = connected_components(&img, 4, 4, Connectivity::Four).expect("cc4");
300        assert_eq!(cc4.num_components, 2, "4-connectivity splits the diagonal");
301        let cc8 = connected_components(&img, 4, 4, Connectivity::Eight).expect("cc8");
302        assert_eq!(cc8.num_components, 1, "8-connectivity joins the diagonal");
303    }
304
305    #[test]
306    fn all_background_zero_components() {
307        let img = vec![0.0_f32; 5 * 5];
308        let cc = connected_components(&img, 5, 5, Connectivity::Eight).expect("cc");
309        assert_eq!(cc.num_components, 0);
310        assert!(cc.labels.iter().all(|&l| l == 0));
311        assert!(cc.sizes().is_empty());
312    }
313
314    #[test]
315    fn all_foreground_single_component() {
316        let img = vec![1.0_f32; 5 * 5];
317        let cc = connected_components(&img, 5, 5, Connectivity::Four).expect("cc");
318        assert_eq!(cc.num_components, 1);
319        assert_eq!(cc.size_of(1), 25);
320    }
321
322    #[test]
323    fn u_shape_is_single_component() {
324        // A U shape that requires equivalence merging across the two arms.
325        let mut img = vec![0.0_f32; 5 * 5];
326        // Left arm, right arm, and the bottom bar.
327        for y in 0..5 {
328            img[y * 5] = 1.0;
329            img[y * 5 + 4] = 1.0;
330        }
331        for x in 0..5 {
332            img[4 * 5 + x] = 1.0;
333        }
334        let cc = connected_components(&img, 5, 5, Connectivity::Four).expect("cc");
335        assert_eq!(cc.num_components, 1, "the U is one connected region");
336        assert_eq!(cc.size_of(1), 5 + 5 + 3); // two arms + bottom (corners shared)
337    }
338
339    #[test]
340    fn sizes_sum_equals_foreground_count() {
341        let mut img = vec![0.0_f32; 6 * 6];
342        set_block(&mut img, 6, 0, 2, 0, 2);
343        set_block(&mut img, 6, 3, 5, 3, 6);
344        let foreground: usize = img.iter().filter(|&&v| v > 0.0).count();
345        let cc = connected_components(&img, 6, 6, Connectivity::Eight).expect("cc");
346        let total: usize = cc.sizes().iter().sum();
347        assert_eq!(total, foreground);
348    }
349
350    #[test]
351    fn bounding_boxes_match_blob_extent() {
352        let mut img = vec![0.0_f32; 8 * 8];
353        set_block(&mut img, 8, 2, 5, 3, 7); // rows 2..5, cols 3..7
354        let cc = connected_components(&img, 8, 8, Connectivity::Eight).expect("cc");
355        let boxes = cc.bounding_boxes();
356        assert_eq!(boxes.len(), 1);
357        // [x_min, y_min, x_max, y_max] inclusive.
358        assert_eq!(boxes[0], [3, 2, 6, 4]);
359    }
360
361    #[test]
362    fn size_of_out_of_range_is_zero() {
363        let img = vec![1.0_f32; 4 * 4];
364        let cc = connected_components(&img, 4, 4, Connectivity::Eight).expect("cc");
365        assert_eq!(cc.size_of(0), 0);
366        assert_eq!(cc.size_of(99), 0);
367    }
368
369    #[test]
370    fn wrong_size_errors() {
371        let img = vec![0.0_f32; 10];
372        assert!(matches!(
373            connected_components(&img, 8, 8, Connectivity::Four),
374            Err(VisionError::DimensionMismatch { .. })
375        ));
376    }
377
378    #[test]
379    fn zero_dim_errors() {
380        let img: Vec<f32> = vec![];
381        assert!(matches!(
382            connected_components(&img, 0, 4, Connectivity::Four),
383            Err(VisionError::InvalidImageSize { .. })
384        ));
385    }
386
387    #[test]
388    fn checkerboard_four_connectivity_many_components() {
389        // Each foreground pixel is isolated under 4-connectivity.
390        let mut img = vec![0.0_f32; 4 * 4];
391        let mut expected = 0;
392        for y in 0..4 {
393            for x in 0..4 {
394                if (y + x) % 2 == 0 {
395                    img[y * 4 + x] = 1.0;
396                    expected += 1;
397                }
398            }
399        }
400        let cc = connected_components(&img, 4, 4, Connectivity::Four).expect("cc");
401        assert_eq!(cc.num_components, expected);
402        // Under 8-connectivity every foreground pixel touches diagonally.
403        let cc8 = connected_components(&img, 4, 4, Connectivity::Eight).expect("cc8");
404        assert_eq!(cc8.num_components, 1);
405    }
406}