Skip to main content

locus_core/
segmentation.rs

1//! Connected components labeling (CCL) using Union-Find.
2//!
3//! This module implements the second stage of the pipeline, identifying and grouping
4//! dark pixels that could potentially form fiducial markers.
5//!
6//! It provides:
7//! - **Standard CCL**: Efficient binary component labeling.
8//! - **Threshold-Model CCL**: Advanced connectivity based on local adaptive thresholds.
9
10#![allow(unsafe_code)]
11
12use bumpalo::Bump;
13use bumpalo::collections::Vec as BumpVec;
14use rayon::prelude::*;
15
16/// A disjoint-set forest (Union-Find) with path compression and rank optimization.
17pub struct UnionFind<'a> {
18    parent: &'a mut [u32],
19    rank: &'a mut [u8],
20}
21
22impl<'a> UnionFind<'a> {
23    /// Create a new UnionFind structure backed by the provided arena.
24    pub fn new_in(arena: &'a Bump, size: usize) -> Self {
25        let parent = arena.alloc_slice_fill_with(size, |i| i as u32);
26        let rank = arena.alloc_slice_fill_copy(size, 0u8);
27        Self { parent, rank }
28    }
29
30    /// Find the representative (root) of the set containing `i`.
31    #[inline]
32    pub fn find(&mut self, i: u32) -> u32 {
33        let mut root = i;
34        while self.parent[root as usize] != root {
35            self.parent[root as usize] = self.parent[self.parent[root as usize] as usize];
36            root = self.parent[root as usize];
37        }
38        root
39    }
40
41    /// Unite the sets containing `i` and `j`.
42    #[inline]
43    pub fn union(&mut self, i: u32, j: u32) {
44        let root_i = self.find(i);
45        let root_j = self.find(j);
46        if root_i != root_j {
47            match self.rank[root_i as usize].cmp(&self.rank[root_j as usize]) {
48                std::cmp::Ordering::Less => self.parent[root_i as usize] = root_j,
49                std::cmp::Ordering::Greater => self.parent[root_j as usize] = root_i,
50                std::cmp::Ordering::Equal => {
51                    self.parent[root_i as usize] = root_j;
52                    self.rank[root_j as usize] += 1;
53                },
54            }
55        }
56    }
57}
58
59/// Bounding box and statistics for a connected component.
60#[derive(Clone, Copy, Debug)]
61pub struct ComponentStats {
62    /// Minimum x coordinate.
63    pub min_x: u16,
64    /// Maximum x coordinate.
65    pub max_x: u16,
66    /// Minimum y coordinate.
67    pub min_y: u16,
68    /// Maximum y coordinate.
69    pub max_y: u16,
70    /// Total number of pixels in the component.
71    pub pixel_count: u32,
72    /// First encountered pixel X (for boundary start).
73    pub first_pixel_x: u16,
74    /// First encountered pixel Y (for boundary start).
75    pub first_pixel_y: u16,
76    /// Spatial moment: Σ x (raw, integer).
77    pub m10: u64,
78    /// Spatial moment: Σ y (raw, integer).
79    pub m01: u64,
80    /// Spatial moment: Σ x² (raw, integer).
81    pub m20: u64,
82    /// Spatial moment: Σ y² (raw, integer).
83    pub m02: u64,
84    /// Spatial moment: Σ xy (raw, integer).
85    pub m11: u64,
86}
87
88impl Default for ComponentStats {
89    fn default() -> Self {
90        Self {
91            min_x: u16::MAX,
92            max_x: 0,
93            min_y: u16::MAX,
94            max_y: 0,
95            pixel_count: 0,
96            first_pixel_x: 0,
97            first_pixel_y: 0,
98            m10: 0,
99            m01: 0,
100            m20: 0,
101            m02: 0,
102            m11: 0,
103        }
104    }
105}
106
107/// Compute shape descriptors from a component's spatial moments.
108///
109/// Returns `Some((elongation, density))` where:
110/// - `elongation` ε = λ_max / λ_min (ratio of principal-axis variances; ≥ 1.0).
111///   A perfect square has ε ≈ 1.0; a thin line or needle has ε >> 1.0.
112/// - `density` D = pixel_count / bbox_area (fraction of bounding box filled).
113///
114/// Returns `None` for degenerate components (λ_min < 1e-9, e.g. single-pixel rows).
115#[must_use]
116pub fn compute_moment_shape(stats: &ComponentStats) -> Option<(f64, f64)> {
117    let m00 = f64::from(stats.pixel_count);
118    if m00 < 1.0 {
119        return None;
120    }
121
122    let x_bar = stats.m10 as f64 / m00;
123    let y_bar = stats.m01 as f64 / m00;
124
125    // Central moments (unnormalized: sum of squared deviations, not divided by m00)
126    let mu20 = stats.m20 as f64 - x_bar * stats.m10 as f64;
127    let mu02 = stats.m02 as f64 - y_bar * stats.m01 as f64;
128    let mu11 = stats.m11 as f64 - x_bar * stats.m01 as f64;
129
130    // Eigenvalues of the normalized 2x2 covariance matrix.
131    // λ = ((μ20 + μ02) ± sqrt((μ20 - μ02)² + 4·μ11²)) / (2·m00)
132    let sum = mu20 + mu02;
133    let diff = mu20 - mu02;
134    let disc = (diff * diff + 4.0 * mu11 * mu11).sqrt();
135    let lambda_max = (sum + disc) / (2.0 * m00);
136    let lambda_min = (sum - disc) / (2.0 * m00);
137
138    if lambda_min < 1e-9 {
139        return None;
140    }
141
142    let elongation = lambda_max / lambda_min;
143
144    let bbox_w = u32::from(stats.max_x - stats.min_x) + 1;
145    let bbox_h = u32::from(stats.max_y - stats.min_y) + 1;
146    let bbox_area = f64::from(bbox_w * bbox_h);
147    let density = m00 / bbox_area;
148
149    Some((elongation, density))
150}
151
152/// Result of connected component labeling.
153pub struct LabelResult<'a> {
154    /// Flat array of pixel labels (row-major).
155    pub labels: &'a [u32],
156    /// Statistics for each component (indexed by label - 1).
157    pub component_stats: Vec<ComponentStats>,
158}
159
160/// A detected run of background pixels in a row.
161#[derive(Clone, Copy, Debug)]
162struct Run {
163    y: u32,
164    x_start: u32,
165    x_end: u32,
166    id: u32,
167}
168
169/// Label connected components in a binary image.
170pub fn label_components<'a>(
171    arena: &'a Bump,
172    binary: &[u8],
173    width: usize,
174    height: usize,
175    use_8_connectivity: bool,
176) -> &'a [u32] {
177    label_components_with_stats(arena, binary, width, height, use_8_connectivity).labels
178}
179
180/// Label components and compute bounding box stats for each.
181#[allow(clippy::too_many_lines)]
182pub fn label_components_with_stats<'a>(
183    arena: &'a Bump,
184    binary: &[u8],
185    width: usize,
186    height: usize,
187    use_8_connectivity: bool,
188) -> LabelResult<'a> {
189    // Pass 1: Extract runs - Optimized with Rayon parallel processing
190    let all_runs: Vec<Vec<Run>> = binary
191        .par_chunks(width)
192        .enumerate()
193        .map(|(y, row)| {
194            let mut row_runs = Vec::with_capacity(width / 4 + 1);
195            let mut x = 0;
196            while x < width {
197                // Find start of background run (0)
198                if let Some(pos) = row[x..].iter().position(|&p| p == 0) {
199                    let start = x + pos;
200                    // Find end of run
201                    let len = row[start..].iter().take_while(|&&p| p == 0).count();
202                    row_runs.push(Run {
203                        y: y as u32,
204                        x_start: start as u32,
205                        x_end: (start + len - 1) as u32,
206                        id: 0,
207                    });
208                    x = start + len;
209                } else {
210                    break;
211                }
212            }
213            row_runs
214        })
215        .collect();
216
217    let total_runs: usize = all_runs.iter().map(std::vec::Vec::len).sum();
218    let mut runs: BumpVec<Run> = BumpVec::with_capacity_in(total_runs, arena);
219    for (id, mut run) in all_runs.into_iter().flatten().enumerate() {
220        run.id = id as u32;
221        runs.push(run);
222    }
223
224    if runs.is_empty() {
225        return LabelResult {
226            labels: arena.alloc_slice_fill_copy(width * height, 0u32),
227            component_stats: Vec::new(),
228        };
229    }
230
231    let mut uf = UnionFind::new_in(arena, runs.len());
232    let mut curr_row_range = 0..0; // Initialize curr_row_range
233    let mut i = 0;
234
235    // Pass 2: Link runs between adjacent rows using two-pointer linear scan
236    while i < runs.len() {
237        let y = runs[i].y;
238
239        // Identify the range of runs in the current row
240        let start = i;
241        while i < runs.len() && runs[i].y == y {
242            i += 1;
243        }
244        let prev_row_range = curr_row_range; // Now correctly uses the previously assigned curr_row_range
245        curr_row_range = start..i;
246
247        if y > 0 && !prev_row_range.is_empty() && runs[prev_row_range.start].y == y - 1 {
248            let mut p_idx = prev_row_range.start;
249            for c_idx in curr_row_range.clone() {
250                let curr = &runs[c_idx];
251
252                if use_8_connectivity {
253                    // 8-connectivity check: overlap if [xs1, xe1] and [xs2-1, xe2+1] intersect
254                    while p_idx < prev_row_range.end && runs[p_idx].x_end + 1 < curr.x_start {
255                        p_idx += 1;
256                    }
257                    let mut temp_p = p_idx;
258                    while temp_p < prev_row_range.end && runs[temp_p].x_start <= curr.x_end + 1 {
259                        uf.union(curr.id, runs[temp_p].id);
260                        temp_p += 1;
261                    }
262                } else {
263                    // 4-connectivity check: overlap if [xs1, xe1] and [xs2, xe2] intersect
264                    while p_idx < prev_row_range.end && runs[p_idx].x_end < curr.x_start {
265                        p_idx += 1;
266                    }
267                    let mut temp_p = p_idx;
268                    while temp_p < prev_row_range.end && runs[temp_p].x_start <= curr.x_end {
269                        uf.union(curr.id, runs[temp_p].id);
270                        temp_p += 1;
271                    }
272                }
273            }
274        }
275    }
276
277    // Pass 3: Collect stats per root and assign labels
278    let mut root_to_label: Vec<u32> = vec![0; runs.len()];
279    let mut component_stats: Vec<ComponentStats> = Vec::new();
280    let mut next_label = 1u32;
281
282    // Pre-resolve roots to avoid repeated find() in Pass 4
283    let mut run_roots = Vec::with_capacity(runs.len());
284
285    for run in &runs {
286        let root = uf.find(run.id) as usize;
287        run_roots.push(root);
288        if root_to_label[root] == 0 {
289            root_to_label[root] = next_label;
290            next_label += 1;
291            let new_stat = ComponentStats {
292                first_pixel_x: run.x_start as u16,
293                first_pixel_y: run.y as u16,
294                ..Default::default()
295            };
296            component_stats.push(new_stat);
297        }
298        let label_idx = (root_to_label[root] - 1) as usize;
299        let stats = &mut component_stats[label_idx];
300        stats.min_x = stats.min_x.min(run.x_start as u16);
301        stats.max_x = stats.max_x.max(run.x_end as u16);
302        stats.min_y = stats.min_y.min(run.y as u16);
303        stats.max_y = stats.max_y.max(run.y as u16);
304        stats.pixel_count += run.x_end - run.x_start + 1;
305        // Accumulate spatial moments using closed-form per-run sums.
306        // Run covers x in [a, b) exclusive, i.e. x = a, a+1, ..., b-1.
307        let a = u64::from(run.x_start);
308        let b = u64::from(run.x_end) + 1; // convert inclusive end to exclusive
309        let yu = u64::from(run.y);
310        // SAFETY: `a - 1` and `2*a - 1` are always multiplied by `a`, so their
311        // value is irrelevant when a = 0. saturating_sub avoids u64 underflow in
312        // debug builds (release wraps, but the `* a` factor zeros the term anyway).
313        stats.m10 += b * (b - 1) / 2 - a * a.saturating_sub(1) / 2;
314        stats.m01 += yu * (b - a);
315        stats.m20 +=
316            (b - 1) * b * (2 * b - 1) / 6 - a.saturating_sub(1) * a * (2 * a).saturating_sub(1) / 6;
317        stats.m02 += yu * yu * (b - a);
318        stats.m11 += yu * (b * (b - 1) / 2 - a * a.saturating_sub(1) / 2);
319    }
320
321    // Pass 4: Assign labels to pixels - Optimized with slice fill
322    let labels = arena.alloc_slice_fill_copy(width * height, 0u32);
323    for (run, root) in runs.iter().zip(run_roots) {
324        let label = root_to_label[root];
325        let row_off = run.y as usize * width;
326        labels[row_off + run.x_start as usize..=row_off + run.x_end as usize].fill(label);
327    }
328
329    LabelResult {
330        labels,
331        component_stats,
332    }
333}
334#[cfg(test)]
335#[allow(clippy::unwrap_used, clippy::cast_sign_loss)]
336mod tests {
337    use super::*;
338    use bumpalo::Bump;
339    use proptest::prelude::prop;
340    use proptest::proptest;
341
342    #[test]
343    fn test_union_find() {
344        let arena = Bump::new();
345        let mut uf = UnionFind::new_in(&arena, 10);
346
347        uf.union(1, 2);
348        uf.union(2, 3);
349        uf.union(5, 6);
350
351        assert_eq!(uf.find(1), uf.find(3));
352        assert_eq!(uf.find(1), uf.find(2));
353        assert_ne!(uf.find(1), uf.find(5));
354
355        uf.union(3, 5);
356        assert_eq!(uf.find(1), uf.find(6));
357    }
358
359    #[test]
360    fn test_label_components_simple() {
361        let arena = Bump::new();
362        // 6x6 image with two separate 2x2 squares that are NOT 8-connected.
363        // 0 = background (black), 255 = foreground (white)
364        // Tag detector looks for black components (0)
365        let binary = [
366            0, 0, 255, 255, 255, 255, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
367            255, 255, 0, 0, 255, 255, 255, 255, 0, 0, 255, 255, 255, 255, 255, 255, 255,
368        ];
369        let width = 6;
370        let height = 6;
371
372        let result = label_components_with_stats(&arena, &binary, width, height, false);
373
374        assert_eq!(result.component_stats.len(), 2);
375
376        // Component 1 (top-left)
377        let s1 = result.component_stats[0];
378        assert_eq!(s1.pixel_count, 4);
379        assert_eq!(s1.min_x, 0);
380        assert_eq!(s1.max_x, 1);
381        assert_eq!(s1.min_y, 0);
382        assert_eq!(s1.max_y, 1);
383
384        // Component 2 (middle-rightish)
385        let s2 = result.component_stats[1];
386        assert_eq!(s2.pixel_count, 4);
387        assert_eq!(s2.min_x, 3);
388        assert_eq!(s2.max_x, 4);
389        assert_eq!(s2.min_y, 3);
390        assert_eq!(s2.max_y, 4);
391    }
392
393    #[test]
394    fn test_segmentation_with_decimation() {
395        let arena = Bump::new();
396        let width = 32;
397        let height = 32;
398        let mut binary = vec![255u8; width * height];
399        // Draw a 10x10 black square at (10,10)
400        for y in 10..20 {
401            for x in 10..20 {
402                binary[y * width + x] = 0;
403            }
404        }
405
406        // use statement moved to top of module or block
407        let img =
408            crate::image::ImageView::new(&binary, width, height, width).expect("valid creation");
409
410        // Decimate by 2 -> 16x16
411        let mut decimated_data = vec![0u8; 16 * 16];
412        let decimated_img = img
413            .decimate_to(2, &mut decimated_data)
414            .expect("decimation failed");
415        // In decimated image, square should be roughly at (5,5) with size 5x5
416        let result = label_components_with_stats(&arena, decimated_img.data, 16, 16, true);
417
418        assert_eq!(result.component_stats.len(), 1);
419        let s = result.component_stats[0];
420        assert_eq!(s.pixel_count, 25);
421        assert_eq!(s.min_x, 5);
422        assert_eq!(s.max_x, 9);
423        assert_eq!(s.min_y, 5);
424        assert_eq!(s.max_y, 9);
425    }
426
427    proptest! {
428        #[test]
429        fn prop_union_find_reflexivity(size in 1..1000usize) {
430            let arena = Bump::new();
431            let mut uf = UnionFind::new_in(&arena, size);
432            for i in 0..size as u32 {
433                assert_eq!(uf.find(i), i);
434            }
435        }
436
437        #[test]
438        fn prop_union_find_transitivity(size in 1..1000usize, pairs in prop::collection::vec((0..1000u32, 0..1000u32), 0..100)) {
439            let arena = Bump::new();
440            let real_size = size.max(1001); // Ensure indices are in range
441            let mut uf = UnionFind::new_in(&arena, real_size);
442
443            for (a, b) in pairs {
444                let a = a % real_size as u32;
445                let b = b % real_size as u32;
446                uf.union(a, b);
447                assert_eq!(uf.find(a), uf.find(b));
448            }
449        }
450
451        #[test]
452        fn prop_label_components_no_panic(
453            width in 1..64usize,
454            height in 1..64usize,
455            data in prop::collection::vec(0..=1u8, 64 * 64)
456        ) {
457            let arena = Bump::new();
458            let binary: Vec<u8> = data.iter().map(|&b| if b == 0 { 0 } else { 255 }).collect();
459            let real_width = width.min(64);
460            let real_height = height.min(64);
461            let slice = &binary[..real_width * real_height];
462
463            let result = label_components_with_stats(&arena, slice, real_width, real_height, true);
464
465            for stat in result.component_stats {
466                assert!(stat.pixel_count > 0);
467                assert!(stat.max_x < real_width as u16);
468                assert!(stat.max_y < real_height as u16);
469                assert!(stat.min_x <= stat.max_x);
470                assert!(stat.min_y <= stat.max_y);
471            }
472        }
473    }
474
475    // ========================================================================
476    // SEGMENTATION ROBUSTNESS TESTS
477    // ========================================================================
478
479    use crate::config::TagFamily;
480    use crate::image::ImageView;
481    use crate::test_utils::{TestImageParams, generate_test_image_with_params};
482    use crate::threshold::ThresholdEngine;
483
484    /// Helper: Generate a binarized tag image at the given size.
485    fn generate_binarized_tag(tag_size: usize, canvas_size: usize) -> (Vec<u8>, [[f64; 2]; 4]) {
486        let params = TestImageParams {
487            family: TagFamily::AprilTag36h11,
488            id: 0,
489            tag_size,
490            canvas_size,
491            ..Default::default()
492        };
493
494        let (data, corners) = generate_test_image_with_params(&params);
495        let img = ImageView::new(&data, canvas_size, canvas_size, canvas_size).unwrap();
496
497        let arena = Bump::new();
498        let engine = ThresholdEngine::new();
499        let stats = engine.compute_tile_stats(&arena, &img);
500        let mut binary = vec![0u8; canvas_size * canvas_size];
501        engine.apply_threshold(&arena, &img, &stats, &mut binary);
502
503        (binary, corners)
504    }
505
506    /// Test segmentation at varying tag sizes.
507    #[test]
508    fn test_segmentation_at_varying_tag_sizes() {
509        let canvas_size = 640;
510        let tag_sizes = [32, 64, 100, 200, 300];
511
512        for tag_size in tag_sizes {
513            let arena = Bump::new();
514            let (binary, corners) = generate_binarized_tag(tag_size, canvas_size);
515
516            let result =
517                label_components_with_stats(&arena, &binary, canvas_size, canvas_size, true);
518
519            assert!(
520                !result.component_stats.is_empty(),
521                "Tag size {tag_size}: No components found"
522            );
523
524            let largest = result
525                .component_stats
526                .iter()
527                .max_by_key(|s| s.pixel_count)
528                .unwrap();
529
530            let expected_min_x = corners[0][0] as u16;
531            let expected_max_x = corners[1][0] as u16;
532            let tolerance = 5;
533
534            assert!(
535                (i32::from(largest.min_x) - i32::from(expected_min_x)).abs() <= tolerance,
536                "Tag size {tag_size}: min_x mismatch"
537            );
538            assert!(
539                (i32::from(largest.max_x) - i32::from(expected_max_x)).abs() <= tolerance,
540                "Tag size {tag_size}: max_x mismatch"
541            );
542
543            println!(
544                "Tag size {:>3}px: {} components, largest has {} px",
545                tag_size,
546                result.component_stats.len(),
547                largest.pixel_count
548            );
549        }
550    }
551
552    /// Test component pixel counts are reasonable for clean binarization.
553    #[test]
554    fn test_segmentation_component_accuracy() {
555        let canvas_size = 320;
556        let tag_size = 120;
557
558        let arena = Bump::new();
559        let (binary, corners) = generate_binarized_tag(tag_size, canvas_size);
560
561        let result = label_components_with_stats(&arena, &binary, canvas_size, canvas_size, true);
562
563        let largest = result
564            .component_stats
565            .iter()
566            .max_by_key(|s| s.pixel_count)
567            .unwrap();
568
569        let expected_min = (tag_size * tag_size / 3) as u32;
570        let expected_max = (tag_size * tag_size) as u32;
571
572        assert!(largest.pixel_count >= expected_min);
573        assert!(largest.pixel_count <= expected_max);
574
575        let gt_width = (corners[1][0] - corners[0][0]).abs() as i32;
576        let gt_height = (corners[2][1] - corners[0][1]).abs() as i32;
577        let bbox_width = i32::from(largest.max_x - largest.min_x);
578        let bbox_height = i32::from(largest.max_y - largest.min_y);
579
580        assert!((bbox_width - gt_width).abs() <= 2);
581        assert!((bbox_height - gt_height).abs() <= 2);
582
583        println!(
584            "Component accuracy: {} pixels, bbox={}x{} (GT: {}x{})",
585            largest.pixel_count, bbox_width, bbox_height, gt_width, gt_height
586        );
587    }
588
589    /// Test segmentation with noisy binary boundaries.
590    #[test]
591    fn test_segmentation_noisy_boundaries() {
592        let canvas_size = 320;
593        let tag_size = 120;
594
595        let arena = Bump::new();
596        let (mut binary, _corners) = generate_binarized_tag(tag_size, canvas_size);
597
598        let noise_rate = 0.05;
599
600        for y in 1..(canvas_size - 1) {
601            for x in 1..(canvas_size - 1) {
602                let idx = y * canvas_size + x;
603                let current = binary[idx];
604                let left = binary[idx - 1];
605                let right = binary[idx + 1];
606                let up = binary[idx - canvas_size];
607                let down = binary[idx + canvas_size];
608
609                let is_edge =
610                    current != left || current != right || current != up || current != down;
611                if is_edge && rand::random_range(0.0..1.0_f32) < noise_rate {
612                    binary[idx] = if current == 0 { 255 } else { 0 };
613                }
614            }
615        }
616
617        let result = label_components_with_stats(&arena, &binary, canvas_size, canvas_size, true);
618
619        assert!(!result.component_stats.is_empty());
620
621        let largest = result
622            .component_stats
623            .iter()
624            .max_by_key(|s| s.pixel_count)
625            .unwrap();
626
627        let min_expected = (tag_size * tag_size / 4) as u32;
628        assert!(largest.pixel_count >= min_expected);
629
630        println!(
631            "Noisy segmentation: {} components, largest has {} px",
632            result.component_stats.len(),
633            largest.pixel_count
634        );
635    }
636
637    #[test]
638    fn test_segmentation_correctness_large_image() {
639        let arena = Bump::new();
640        let width = 3840; // 4K width
641        let height = 2160; // 4K height
642        let mut binary = vec![255u8; width * height];
643
644        // Create multiple separate components
645        // 1. A square at the top left
646        for y in 100..200 {
647            for x in 100..200 {
648                binary[y * width + x] = 0;
649            }
650        }
651
652        // 2. A long horizontal strip in the middle
653        for x in 500..3000 {
654            binary[1000 * width + x] = 0;
655            binary[1001 * width + x] = 0;
656        }
657
658        // 3. Horizontal stripes at the bottom
659        for y in 1800..2000 {
660            if y % 4 == 0 {
661                for x in 1800..2000 {
662                    binary[y * width + x] = 0;
663                }
664            }
665        }
666
667        // 4. Noise (avoiding the square area)
668        for y in 0..height {
669            if y % 10 == 0 {
670                for x in 0..width {
671                    if (!(100..200).contains(&x) || !(100..200).contains(&y)) && (x + y) % 31 == 0 {
672                        binary[y * width + x] = 0;
673                    }
674                }
675            }
676        }
677
678        // Run segmentation
679        let start = std::time::Instant::now();
680        let result = label_components_with_stats(&arena, &binary, width, height, true);
681        let duration = start.elapsed();
682
683        // Basic verification of component counts
684        assert!(result.component_stats.len() > 1000);
685
686        println!(
687            "Found {} components on 4K image in {:?}",
688            result.component_stats.len(),
689            duration
690        );
691    }
692}