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
10use bumpalo::Bump;
11use bumpalo::collections::Vec as BumpVec;
12use rayon::prelude::*;
13
14/// A disjoint-set forest (Union-Find) with path compression and rank optimization.
15pub struct UnionFind<'a> {
16    parent: &'a mut [u32],
17    rank: &'a mut [u8],
18}
19
20impl<'a> UnionFind<'a> {
21    /// Create a new UnionFind structure backed by the provided arena.
22    pub fn new_in(arena: &'a Bump, size: usize) -> Self {
23        let parent = arena.alloc_slice_fill_with(size, |i| i as u32);
24        let rank = arena.alloc_slice_fill_copy(size, 0u8);
25        Self { parent, rank }
26    }
27
28    /// Find the representative (root) of the set containing `i`.
29    #[inline]
30    pub fn find(&mut self, i: u32) -> u32 {
31        let mut root = i;
32        while self.parent[root as usize] != root {
33            self.parent[root as usize] = self.parent[self.parent[root as usize] as usize];
34            root = self.parent[root as usize];
35        }
36        root
37    }
38
39    /// Unite the sets containing `i` and `j`.
40    #[inline]
41    pub fn union(&mut self, i: u32, j: u32) {
42        let root_i = self.find(i);
43        let root_j = self.find(j);
44        if root_i != root_j {
45            match self.rank[root_i as usize].cmp(&self.rank[root_j as usize]) {
46                std::cmp::Ordering::Less => self.parent[root_i as usize] = root_j,
47                std::cmp::Ordering::Greater => self.parent[root_j as usize] = root_i,
48                std::cmp::Ordering::Equal => {
49                    self.parent[root_i as usize] = root_j;
50                    self.rank[root_j as usize] += 1;
51                },
52            }
53        }
54    }
55}
56
57/// Bounding box and statistics for a connected component.
58#[derive(Clone, Copy, Debug)]
59pub struct ComponentStats {
60    /// Minimum x coordinate.
61    pub min_x: u16,
62    /// Maximum x coordinate.
63    pub max_x: u16,
64    /// Minimum y coordinate.
65    pub min_y: u16,
66    /// Maximum y coordinate.
67    pub max_y: u16,
68    /// Total number of pixels in the component.
69    pub pixel_count: u32,
70    /// First encountered pixel X (for boundary start).
71    pub first_pixel_x: u16,
72    /// First encountered pixel Y (for boundary start).
73    pub first_pixel_y: u16,
74}
75
76impl Default for ComponentStats {
77    fn default() -> Self {
78        Self {
79            min_x: u16::MAX,
80            max_x: 0,
81            min_y: u16::MAX,
82            max_y: 0,
83            pixel_count: 0,
84            first_pixel_x: 0,
85            first_pixel_y: 0,
86        }
87    }
88}
89
90/// Result of connected component labeling.
91pub struct LabelResult<'a> {
92    /// Flat array of pixel labels (row-major).
93    pub labels: &'a [u32],
94    /// Statistics for each component (indexed by label - 1).
95    pub component_stats: Vec<ComponentStats>,
96}
97
98/// A detected run of background pixels in a row.
99#[derive(Clone, Copy, Debug)]
100struct Run {
101    y: u32,
102    x_start: u32,
103    x_end: u32,
104    id: u32,
105}
106
107/// Label connected components in a binary image.
108pub fn label_components<'a>(
109    arena: &'a Bump,
110    binary: &[u8],
111    width: usize,
112    height: usize,
113    use_8_connectivity: bool,
114) -> &'a [u32] {
115    label_components_with_stats(arena, binary, width, height, use_8_connectivity).labels
116}
117
118/// Label components and compute bounding box stats for each.
119#[allow(clippy::too_many_lines)]
120pub fn label_components_with_stats<'a>(
121    arena: &'a Bump,
122    binary: &[u8],
123    width: usize,
124    height: usize,
125    use_8_connectivity: bool,
126) -> LabelResult<'a> {
127    // Pass 1: Extract runs - Optimized with Rayon parallel processing
128    let all_runs: Vec<Vec<Run>> = binary
129        .par_chunks(width)
130        .enumerate()
131        .map(|(y, row)| {
132            let mut row_runs = Vec::new();
133            let mut x = 0;
134            while x < width {
135                // Find start of background run (0)
136                if let Some(pos) = row[x..].iter().position(|&p| p == 0) {
137                    let start = x + pos;
138                    // Find end of run
139                    let len = row[start..].iter().take_while(|&&p| p == 0).count();
140                    row_runs.push(Run {
141                        y: y as u32,
142                        x_start: start as u32,
143                        x_end: (start + len - 1) as u32,
144                        id: 0,
145                    });
146                    x = start + len;
147                } else {
148                    break;
149                }
150            }
151            row_runs
152        })
153        .collect();
154
155    let total_runs: usize = all_runs.iter().map(std::vec::Vec::len).sum();
156    let mut runs: BumpVec<Run> = BumpVec::with_capacity_in(total_runs, arena);
157    for (id, mut run) in all_runs.into_iter().flatten().enumerate() {
158        run.id = id as u32;
159        runs.push(run);
160    }
161
162    if runs.is_empty() {
163        return LabelResult {
164            labels: arena.alloc_slice_fill_copy(width * height, 0u32),
165            component_stats: Vec::new(),
166        };
167    }
168
169    let mut uf = UnionFind::new_in(arena, runs.len());
170    let mut curr_row_range = 0..0; // Initialize curr_row_range
171    let mut i = 0;
172
173    // Pass 2: Link runs between adjacent rows using two-pointer linear scan
174    while i < runs.len() {
175        let y = runs[i].y;
176
177        // Identify the range of runs in the current row
178        let start = i;
179        while i < runs.len() && runs[i].y == y {
180            i += 1;
181        }
182        let prev_row_range = curr_row_range; // Now correctly uses the previously assigned curr_row_range
183        curr_row_range = start..i;
184
185        if y > 0 && !prev_row_range.is_empty() && runs[prev_row_range.start].y == y - 1 {
186            let mut p_idx = prev_row_range.start;
187            for c_idx in curr_row_range.clone() {
188                let curr = &runs[c_idx];
189
190                if use_8_connectivity {
191                    // 8-connectivity check: overlap if [xs1, xe1] and [xs2-1, xe2+1] intersect
192                    while p_idx < prev_row_range.end && runs[p_idx].x_end + 1 < curr.x_start {
193                        p_idx += 1;
194                    }
195                    let mut temp_p = p_idx;
196                    while temp_p < prev_row_range.end && runs[temp_p].x_start <= curr.x_end + 1 {
197                        uf.union(curr.id, runs[temp_p].id);
198                        temp_p += 1;
199                    }
200                } else {
201                    // 4-connectivity check: overlap if [xs1, xe1] and [xs2, xe2] intersect
202                    while p_idx < prev_row_range.end && runs[p_idx].x_end < curr.x_start {
203                        p_idx += 1;
204                    }
205                    let mut temp_p = p_idx;
206                    while temp_p < prev_row_range.end && runs[temp_p].x_start <= curr.x_end {
207                        uf.union(curr.id, runs[temp_p].id);
208                        temp_p += 1;
209                    }
210                }
211            }
212        }
213    }
214
215    // Pass 3: Collect stats per root and assign labels
216    let mut root_to_label: Vec<u32> = vec![0; runs.len()];
217    let mut component_stats: Vec<ComponentStats> = Vec::new();
218    let mut next_label = 1u32;
219
220    // Pre-resolve roots to avoid repeated find() in Pass 4
221    let mut run_roots = Vec::with_capacity(runs.len());
222
223    for run in &runs {
224        let root = uf.find(run.id) as usize;
225        run_roots.push(root);
226        if root_to_label[root] == 0 {
227            root_to_label[root] = next_label;
228            next_label += 1;
229            let new_stat = ComponentStats {
230                first_pixel_x: run.x_start as u16,
231                first_pixel_y: run.y as u16,
232                ..Default::default()
233            };
234            component_stats.push(new_stat);
235        }
236        let label_idx = (root_to_label[root] - 1) as usize;
237        let stats = &mut component_stats[label_idx];
238        stats.min_x = stats.min_x.min(run.x_start as u16);
239        stats.max_x = stats.max_x.max(run.x_end as u16);
240        stats.min_y = stats.min_y.min(run.y as u16);
241        stats.max_y = stats.max_y.max(run.y as u16);
242        stats.pixel_count += run.x_end - run.x_start + 1;
243    }
244
245    // Pass 4: Assign labels to pixels - Optimized with slice fill
246    let labels = arena.alloc_slice_fill_copy(width * height, 0u32);
247    for (run, root) in runs.iter().zip(run_roots) {
248        let label = root_to_label[root];
249        let row_off = run.y as usize * width;
250        labels[row_off + run.x_start as usize..=row_off + run.x_end as usize].fill(label);
251    }
252
253    LabelResult {
254        labels,
255        component_stats,
256    }
257}
258
259/// Threshold-model-aware connected component labeling.
260///
261/// Instead of connecting pixels based on binary value (0 = black), this function
262/// connects pixels based on their *signed deviation* from the local threshold.
263/// This preserves the shape of small tag corners where pure binary would see noise.
264///
265/// # Arguments
266/// * `arena` - Bump allocator for temporary storage
267/// * `grayscale` - Original grayscale image
268/// * `threshold_map` - Per-pixel threshold values (from adaptive thresholding)
269/// * `width` - Image width
270/// * `height` - Image height
271///
272/// # Returns
273/// Same `LabelResult` as `label_components_with_stats`, but with improved connectivity
274/// for small features.
275#[allow(clippy::too_many_lines)]
276#[allow(clippy::cast_sign_loss)]
277#[allow(clippy::cast_possible_wrap, clippy::too_many_arguments)]
278pub fn label_components_threshold_model<'a>(
279    arena: &'a Bump,
280    grayscale: &[u8],
281    grayscale_stride: usize,
282    threshold_map: &[u8],
283    width: usize,
284    height: usize,
285    use_8_connectivity: bool,
286    min_area: u32,
287    margin: i16,
288) -> LabelResult<'a> {
289    // Compute signed deviation: negative = dark (below threshold), positive = light
290    // We only care about dark regions (tag interior) for now
291    let mut runs = BumpVec::new_in(arena);
292
293    // Pass 1: Extract runs of "consistently dark" pixels
294    // A pixel is "dark" if (grayscale - threshold) < -margin
295    // This is more robust than binary == 0 because it considers the local model
296
297    let all_runs: Vec<Vec<Run>> = (0..height)
298        .into_par_iter()
299        .map(|y| {
300            let row_gs = &grayscale[y * grayscale_stride..y * grayscale_stride + width];
301            let row_th = &threshold_map[y * width..(y + 1) * width];
302            let mut row_runs = Vec::new();
303            let mut x = 0;
304            while x < width {
305                let gs = row_gs[x];
306                let th = row_th[x];
307                if i16::from(gs) - i16::from(th) < -margin {
308                    let start = x;
309                    x += 1;
310                    while x < width {
311                        if i16::from(row_gs[x]) - i16::from(row_th[x]) >= -margin {
312                            break;
313                        }
314                        x += 1;
315                    }
316                    row_runs.push(Run {
317                        y: y as u32,
318                        x_start: start as u32,
319                        x_end: (x - 1) as u32,
320                        id: 0,
321                    });
322                } else {
323                    x += 1;
324                }
325            }
326            row_runs
327        })
328        .collect();
329
330    for (id, mut run) in all_runs.into_iter().flatten().enumerate() {
331        run.id = id as u32;
332        runs.push(run);
333    }
334
335    if runs.is_empty() {
336        return LabelResult {
337            labels: arena.alloc_slice_fill_copy(width * height, 0u32),
338            component_stats: Vec::new(),
339        };
340    }
341
342    // Pass 2-4 are the same as label_components_with_stats
343    let mut uf = UnionFind::new_in(arena, runs.len());
344    let mut curr_row_range = 0..0;
345    let mut i = 0;
346
347    while i < runs.len() {
348        let y = runs[i].y;
349        let start = i;
350        while i < runs.len() && runs[i].y == y {
351            i += 1;
352        }
353        let prev_row_range = curr_row_range;
354        curr_row_range = start..i;
355
356        if y > 0 && !prev_row_range.is_empty() && runs[prev_row_range.start].y == y - 1 {
357            let mut p_idx = prev_row_range.start;
358            for c_idx in curr_row_range.clone() {
359                let curr = &runs[c_idx];
360                if use_8_connectivity {
361                    while p_idx < prev_row_range.end && runs[p_idx].x_end + 1 < curr.x_start {
362                        p_idx += 1;
363                    }
364                    let mut temp_p = p_idx;
365                    while temp_p < prev_row_range.end && runs[temp_p].x_start <= curr.x_end + 1 {
366                        uf.union(curr.id, runs[temp_p].id);
367                        temp_p += 1;
368                    }
369                } else {
370                    while p_idx < prev_row_range.end && runs[p_idx].x_end < curr.x_start {
371                        p_idx += 1;
372                    }
373                    let mut temp_p = p_idx;
374                    while temp_p < prev_row_range.end && runs[temp_p].x_start <= curr.x_end {
375                        uf.union(curr.id, runs[temp_p].id);
376                        temp_p += 1;
377                    }
378                }
379            }
380        }
381    }
382
383    // Pass 3: Aggregate stats for ALL potential components
384    let mut root_to_temp_idx = vec![usize::MAX; runs.len()];
385    let mut temp_stats = Vec::new();
386
387    for run in &runs {
388        let root = uf.find(run.id) as usize;
389        if root_to_temp_idx[root] == usize::MAX {
390            root_to_temp_idx[root] = temp_stats.len();
391            temp_stats.push(ComponentStats {
392                first_pixel_x: run.x_start as u16,
393                first_pixel_y: run.y as u16,
394                ..Default::default()
395            });
396        }
397        let s_idx = root_to_temp_idx[root];
398        let stats = &mut temp_stats[s_idx];
399        stats.min_x = stats.min_x.min(run.x_start as u16);
400        stats.max_x = stats.max_x.max(run.x_end as u16);
401        stats.min_y = stats.min_y.min(run.y as u16);
402        stats.max_y = stats.max_y.max(run.y as u16);
403        stats.pixel_count += run.x_end - run.x_start + 1;
404    }
405
406    // Pass 4: Filter by area and assign final labels
407    let mut component_stats = Vec::with_capacity(temp_stats.len());
408    let mut root_to_final_label = vec![0u32; runs.len()];
409    let mut next_label = 1u32;
410
411    for root in 0..runs.len() {
412        let s_idx = root_to_temp_idx[root];
413        if s_idx != usize::MAX {
414            let s = temp_stats[s_idx];
415            if s.pixel_count >= min_area {
416                component_stats.push(s);
417                root_to_final_label[root] = next_label;
418                next_label += 1;
419            }
420        }
421    }
422
423    // Pass 5: Parallel label writing for surviving components
424    let labels = arena.alloc_slice_fill_copy(width * height, 0u32);
425    let mut runs_by_y: Vec<Vec<(usize, usize, u32)>> = vec![Vec::new(); height];
426
427    for run in &runs {
428        let root = uf.find(run.id) as usize;
429        let label = root_to_final_label[root];
430        if label > 0 {
431            runs_by_y[run.y as usize].push((run.x_start as usize, run.x_end as usize, label));
432        }
433    }
434
435    labels
436        .par_chunks_exact_mut(width)
437        .enumerate()
438        .for_each(|(y, row)| {
439            for &(x_start, x_end, label) in &runs_by_y[y] {
440                row[x_start..=x_end].fill(label);
441            }
442        });
443
444    LabelResult {
445        labels,
446        component_stats,
447    }
448}
449
450#[cfg(test)]
451#[allow(clippy::unwrap_used, clippy::cast_sign_loss)]
452mod tests {
453    use super::*;
454    use bumpalo::Bump;
455    use proptest::prelude::*;
456
457    #[test]
458    fn test_union_find() {
459        let arena = Bump::new();
460        let mut uf = UnionFind::new_in(&arena, 10);
461
462        uf.union(1, 2);
463        uf.union(2, 3);
464        uf.union(5, 6);
465
466        assert_eq!(uf.find(1), uf.find(3));
467        assert_eq!(uf.find(1), uf.find(2));
468        assert_ne!(uf.find(1), uf.find(5));
469
470        uf.union(3, 5);
471        assert_eq!(uf.find(1), uf.find(6));
472    }
473
474    #[test]
475    fn test_label_components_simple() {
476        let arena = Bump::new();
477        // 6x6 image with two separate 2x2 squares that are NOT 8-connected.
478        // 0 = background (black), 255 = foreground (white)
479        // Tag detector looks for black components (0)
480        let binary = [
481            0, 0, 255, 255, 255, 255, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
482            255, 255, 0, 0, 255, 255, 255, 255, 0, 0, 255, 255, 255, 255, 255, 255, 255,
483        ];
484        let width = 6;
485        let height = 6;
486
487        let result = label_components_with_stats(&arena, &binary, width, height, false);
488
489        assert_eq!(result.component_stats.len(), 2);
490
491        // Component 1 (top-left)
492        let s1 = result.component_stats[0];
493        assert_eq!(s1.pixel_count, 4);
494        assert_eq!(s1.min_x, 0);
495        assert_eq!(s1.max_x, 1);
496        assert_eq!(s1.min_y, 0);
497        assert_eq!(s1.max_y, 1);
498
499        // Component 2 (middle-rightish)
500        let s2 = result.component_stats[1];
501        assert_eq!(s2.pixel_count, 4);
502        assert_eq!(s2.min_x, 3);
503        assert_eq!(s2.max_x, 4);
504        assert_eq!(s2.min_y, 3);
505        assert_eq!(s2.max_y, 4);
506    }
507
508    #[test]
509    fn test_segmentation_with_decimation() {
510        let arena = Bump::new();
511        let width = 32;
512        let height = 32;
513        let mut binary = vec![255u8; width * height];
514        // Draw a 10x10 black square at (10,10)
515        for y in 10..20 {
516            for x in 10..20 {
517                binary[y * width + x] = 0;
518            }
519        }
520
521        // use statement moved to top of module or block
522        let img =
523            crate::image::ImageView::new(&binary, width, height, width).expect("valid creation");
524
525        // Decimate by 2 -> 16x16
526        let mut decimated_data = vec![0u8; 16 * 16];
527        let decimated_img = img
528            .decimate_to(2, &mut decimated_data)
529            .expect("decimation failed");
530        // In decimated image, square should be roughly at (5,5) with size 5x5
531        let result = label_components_with_stats(&arena, decimated_img.data, 16, 16, true);
532
533        assert_eq!(result.component_stats.len(), 1);
534        let s = result.component_stats[0];
535        assert_eq!(s.pixel_count, 25);
536        assert_eq!(s.min_x, 5);
537        assert_eq!(s.max_x, 9);
538        assert_eq!(s.min_y, 5);
539        assert_eq!(s.max_y, 9);
540    }
541
542    proptest! {
543        #[test]
544        fn prop_union_find_reflexivity(size in 1..1000usize) {
545            let arena = Bump::new();
546            let mut uf = UnionFind::new_in(&arena, size);
547            for i in 0..size as u32 {
548                assert_eq!(uf.find(i), i);
549            }
550        }
551
552        #[test]
553        fn prop_union_find_transitivity(size in 1..1000usize, pairs in prop::collection::vec((0..1000u32, 0..1000u32), 0..100)) {
554            let arena = Bump::new();
555            let real_size = size.max(1001); // Ensure indices are in range
556            let mut uf = UnionFind::new_in(&arena, real_size);
557
558            for (a, b) in pairs {
559                let a = a % real_size as u32;
560                let b = b % real_size as u32;
561                uf.union(a, b);
562                assert_eq!(uf.find(a), uf.find(b));
563            }
564        }
565
566        #[test]
567        fn prop_label_components_no_panic(
568            width in 1..64usize,
569            height in 1..64usize,
570            data in prop::collection::vec(0..=1u8, 64 * 64)
571        ) {
572            let arena = Bump::new();
573            let binary: Vec<u8> = data.iter().map(|&b| if b == 0 { 0 } else { 255 }).collect();
574            let real_width = width.min(64);
575            let real_height = height.min(64);
576            let slice = &binary[..real_width * real_height];
577
578            let result = label_components_with_stats(&arena, slice, real_width, real_height, true);
579
580            for stat in result.component_stats {
581                assert!(stat.pixel_count > 0);
582                assert!(stat.max_x < real_width as u16);
583                assert!(stat.max_y < real_height as u16);
584                assert!(stat.min_x <= stat.max_x);
585                assert!(stat.min_y <= stat.max_y);
586            }
587        }
588    }
589
590    // ========================================================================
591    // SEGMENTATION ROBUSTNESS TESTS
592    // ========================================================================
593
594    use crate::config::TagFamily;
595    use crate::image::ImageView;
596    use crate::test_utils::{TestImageParams, generate_test_image_with_params};
597    use crate::threshold::ThresholdEngine;
598
599    /// Helper: Generate a binarized tag image at the given size.
600    fn generate_binarized_tag(tag_size: usize, canvas_size: usize) -> (Vec<u8>, [[f64; 2]; 4]) {
601        let params = TestImageParams {
602            family: TagFamily::AprilTag36h11,
603            id: 0,
604            tag_size,
605            canvas_size,
606            ..Default::default()
607        };
608
609        let (data, corners) = generate_test_image_with_params(&params);
610        let img = ImageView::new(&data, canvas_size, canvas_size, canvas_size).unwrap();
611
612        let arena = Bump::new();
613        let engine = ThresholdEngine::new();
614        let stats = engine.compute_tile_stats(&arena, &img);
615        let mut binary = vec![0u8; canvas_size * canvas_size];
616        engine.apply_threshold(&arena, &img, &stats, &mut binary);
617
618        (binary, corners)
619    }
620
621    /// Test segmentation at varying tag sizes.
622    #[test]
623    fn test_segmentation_at_varying_tag_sizes() {
624        let canvas_size = 640;
625        let tag_sizes = [32, 64, 100, 200, 300];
626
627        for tag_size in tag_sizes {
628            let arena = Bump::new();
629            let (binary, corners) = generate_binarized_tag(tag_size, canvas_size);
630
631            let result =
632                label_components_with_stats(&arena, &binary, canvas_size, canvas_size, true);
633
634            assert!(
635                !result.component_stats.is_empty(),
636                "Tag size {tag_size}: No components found"
637            );
638
639            let largest = result
640                .component_stats
641                .iter()
642                .max_by_key(|s| s.pixel_count)
643                .unwrap();
644
645            let expected_min_x = corners[0][0] as u16;
646            let expected_max_x = corners[1][0] as u16;
647            let tolerance = 5;
648
649            assert!(
650                (i32::from(largest.min_x) - i32::from(expected_min_x)).abs() <= tolerance,
651                "Tag size {tag_size}: min_x mismatch"
652            );
653            assert!(
654                (i32::from(largest.max_x) - i32::from(expected_max_x)).abs() <= tolerance,
655                "Tag size {tag_size}: max_x mismatch"
656            );
657
658            println!(
659                "Tag size {:>3}px: {} components, largest has {} px",
660                tag_size,
661                result.component_stats.len(),
662                largest.pixel_count
663            );
664        }
665    }
666
667    /// Test component pixel counts are reasonable for clean binarization.
668    #[test]
669    fn test_segmentation_component_accuracy() {
670        let canvas_size = 320;
671        let tag_size = 120;
672
673        let arena = Bump::new();
674        let (binary, corners) = generate_binarized_tag(tag_size, canvas_size);
675
676        let result = label_components_with_stats(&arena, &binary, canvas_size, canvas_size, true);
677
678        let largest = result
679            .component_stats
680            .iter()
681            .max_by_key(|s| s.pixel_count)
682            .unwrap();
683
684        let expected_min = (tag_size * tag_size / 3) as u32;
685        let expected_max = (tag_size * tag_size) as u32;
686
687        assert!(largest.pixel_count >= expected_min);
688        assert!(largest.pixel_count <= expected_max);
689
690        let gt_width = (corners[1][0] - corners[0][0]).abs() as i32;
691        let gt_height = (corners[2][1] - corners[0][1]).abs() as i32;
692        let bbox_width = i32::from(largest.max_x - largest.min_x);
693        let bbox_height = i32::from(largest.max_y - largest.min_y);
694
695        assert!((bbox_width - gt_width).abs() <= 2);
696        assert!((bbox_height - gt_height).abs() <= 2);
697
698        println!(
699            "Component accuracy: {} pixels, bbox={}x{} (GT: {}x{})",
700            largest.pixel_count, bbox_width, bbox_height, gt_width, gt_height
701        );
702    }
703
704    /// Test segmentation with noisy binary boundaries.
705    #[test]
706    fn test_segmentation_noisy_boundaries() {
707        use rand::prelude::*;
708
709        let canvas_size = 320;
710        let tag_size = 120;
711
712        let arena = Bump::new();
713        let (mut binary, _corners) = generate_binarized_tag(tag_size, canvas_size);
714
715        let mut rng = rand::thread_rng();
716        let noise_rate = 0.05;
717
718        for y in 1..(canvas_size - 1) {
719            for x in 1..(canvas_size - 1) {
720                let idx = y * canvas_size + x;
721                let current = binary[idx];
722                let left = binary[idx - 1];
723                let right = binary[idx + 1];
724                let up = binary[idx - canvas_size];
725                let down = binary[idx + canvas_size];
726
727                let is_edge =
728                    current != left || current != right || current != up || current != down;
729                if is_edge && rng.gen_range(0.0..1.0_f32) < noise_rate {
730                    binary[idx] = if current == 0 { 255 } else { 0 };
731                }
732            }
733        }
734
735        let result = label_components_with_stats(&arena, &binary, canvas_size, canvas_size, true);
736
737        assert!(!result.component_stats.is_empty());
738
739        let largest = result
740            .component_stats
741            .iter()
742            .max_by_key(|s| s.pixel_count)
743            .unwrap();
744
745        let min_expected = (tag_size * tag_size / 4) as u32;
746        assert!(largest.pixel_count >= min_expected);
747
748        println!(
749            "Noisy segmentation: {} components, largest has {} px",
750            result.component_stats.len(),
751            largest.pixel_count
752        );
753    }
754
755    #[test]
756    fn test_segmentation_correctness_large_image() {
757        let arena = Bump::new();
758        let width = 3840; // 4K width
759        let height = 2160; // 4K height
760        let mut binary = vec![255u8; width * height];
761
762        // Create multiple separate components
763        // 1. A square at the top left
764        for y in 100..200 {
765            for x in 100..200 {
766                binary[y * width + x] = 0;
767            }
768        }
769
770        // 2. A long horizontal strip in the middle
771        for x in 500..3000 {
772            binary[1000 * width + x] = 0;
773            binary[1001 * width + x] = 0;
774        }
775
776        // 3. Horizontal stripes at the bottom
777        for y in 1800..2000 {
778            if y % 4 == 0 {
779                for x in 1800..2000 {
780                    binary[y * width + x] = 0;
781                }
782            }
783        }
784
785        // 4. Noise (avoiding the square area)
786        for y in 0..height {
787            if y % 10 == 0 {
788                for x in 0..width {
789                    if (!(100..200).contains(&x) || !(100..200).contains(&y)) && (x + y) % 31 == 0 {
790                        binary[y * width + x] = 0;
791                    }
792                }
793            }
794        }
795
796        // Run segmentation
797        let start = std::time::Instant::now();
798        let result = label_components_with_stats(&arena, &binary, width, height, true);
799        let duration = start.elapsed();
800
801        // Basic verification of component counts
802        assert!(result.component_stats.len() > 1000);
803
804        println!(
805            "Found {} components on 4K image in {:?}",
806            result.component_stats.len(),
807            duration
808        );
809    }
810}