Skip to main content

scirs2_ndimage/
regionprops.rs

1//! Region properties analysis for labeled images
2//!
3//! This module provides comprehensive geometric, statistical, and shape
4//! measurements for labeled regions in 2D images. It is modeled after
5//! scikit-image's `regionprops` and scipy.ndimage's `measurements`.
6//!
7//! # Features
8//!
9//! - **Connected component labeling**: 4-connectivity and 8-connectivity
10//! - **Geometric properties**: area, perimeter, centroid, bounding box
11//! - **Shape descriptors**: eccentricity, orientation, solidity, extent,
12//!   major/minor axis lengths, Euler number
13//! - **Hu moments**: 7 rotation/translation/scale-invariant moment descriptors
14//! - **Region filtering**: filter regions by any computed property
15
16use crate::error::{NdimageError, NdimageResult};
17use scirs2_core::ndarray::Array2;
18use scirs2_core::numeric::{Float, FromPrimitive, NumAssign};
19use std::collections::HashMap;
20use std::fmt::Debug;
21
22// ---------------------------------------------------------------------------
23// Connected component labeling
24// ---------------------------------------------------------------------------
25
26/// Union-Find data structure for efficient connected component labeling
27struct UnionFind {
28    parent: Vec<usize>,
29    rank: Vec<usize>,
30}
31
32impl UnionFind {
33    fn new(n: usize) -> Self {
34        UnionFind {
35            parent: (0..n).collect(),
36            rank: vec![0; n],
37        }
38    }
39
40    fn find(&mut self, x: usize) -> usize {
41        if self.parent[x] != x {
42            self.parent[x] = self.find(self.parent[x]);
43        }
44        self.parent[x]
45    }
46
47    fn union(&mut self, a: usize, b: usize) {
48        let ra = self.find(a);
49        let rb = self.find(b);
50        if ra == rb {
51            return;
52        }
53        if self.rank[ra] < self.rank[rb] {
54            self.parent[ra] = rb;
55        } else if self.rank[ra] > self.rank[rb] {
56            self.parent[rb] = ra;
57        } else {
58            self.parent[rb] = ra;
59            self.rank[ra] += 1;
60        }
61    }
62}
63
64/// Connectivity mode for connected component labeling
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Connectivity {
67    /// 4-connectivity: only face-adjacent (up, down, left, right)
68    Conn4,
69    /// 8-connectivity: face-adjacent plus diagonals
70    Conn8,
71}
72
73impl Default for Connectivity {
74    fn default() -> Self {
75        Connectivity::Conn4
76    }
77}
78
79/// Label connected components in a binary image
80///
81/// Uses a two-pass union-find algorithm optimized for 2D images.
82///
83/// # Arguments
84///
85/// * `binary` - Input binary image (`true` = foreground)
86/// * `connectivity` - `Conn4` (4-connected) or `Conn8` (8-connected)
87///
88/// # Returns
89///
90/// * Labeled image (0 = background, 1..n = component labels)
91/// * Number of components
92///
93/// # Example
94///
95/// ```
96/// use scirs2_core::ndarray::array;
97/// use scirs2_ndimage::regionprops::{label_components, Connectivity};
98///
99/// let binary = array![
100///     [true,  true,  false, false],
101///     [true,  true,  false, false],
102///     [false, false, true,  true],
103///     [false, false, true,  true],
104/// ];
105///
106/// let (labeled, n) = label_components(&binary, Connectivity::Conn4)
107///     .expect("should succeed");
108/// assert_eq!(n, 2);
109/// ```
110pub fn label_components(
111    binary: &Array2<bool>,
112    connectivity: Connectivity,
113) -> NdimageResult<(Array2<usize>, usize)> {
114    let rows = binary.nrows();
115    let cols = binary.ncols();
116
117    if rows == 0 || cols == 0 {
118        return Ok((Array2::zeros((rows, cols)), 0));
119    }
120
121    let total = rows * cols;
122    let mut uf = UnionFind::new(total);
123    let use_diag = connectivity == Connectivity::Conn8;
124
125    // First pass
126    for r in 0..rows {
127        for c in 0..cols {
128            if !binary[[r, c]] {
129                continue;
130            }
131            let idx = r * cols + c;
132
133            if r > 0 && binary[[r - 1, c]] {
134                uf.union(idx, (r - 1) * cols + c);
135            }
136            if c > 0 && binary[[r, c - 1]] {
137                uf.union(idx, r * cols + (c - 1));
138            }
139            if use_diag {
140                if r > 0 && c > 0 && binary[[r - 1, c - 1]] {
141                    uf.union(idx, (r - 1) * cols + (c - 1));
142                }
143                if r > 0 && c + 1 < cols && binary[[r - 1, c + 1]] {
144                    uf.union(idx, (r - 1) * cols + (c + 1));
145                }
146            }
147        }
148    }
149
150    // Second pass: assign sequential labels
151    let mut root_to_label: HashMap<usize, usize> = HashMap::new();
152    let mut next_label = 1usize;
153    let mut output = Array2::zeros((rows, cols));
154
155    for r in 0..rows {
156        for c in 0..cols {
157            if !binary[[r, c]] {
158                continue;
159            }
160            let root = uf.find(r * cols + c);
161            let lbl = match root_to_label.get(&root) {
162                Some(&l) => l,
163                None => {
164                    let l = next_label;
165                    root_to_label.insert(root, l);
166                    next_label += 1;
167                    l
168                }
169            };
170            output[[r, c]] = lbl;
171        }
172    }
173
174    Ok((output, next_label - 1))
175}
176
177// ---------------------------------------------------------------------------
178// Region properties
179// ---------------------------------------------------------------------------
180
181/// Comprehensive properties of a single labeled region
182#[derive(Debug, Clone)]
183pub struct RegionProps<T: Float> {
184    /// Label value
185    pub label: usize,
186    /// Number of pixels
187    pub area: usize,
188    /// Geometric centroid (row, col)
189    pub centroid: (T, T),
190    /// Intensity-weighted centroid (row, col)
191    pub weighted_centroid: (T, T),
192    /// Bounding box (min_row, min_col, max_row_exclusive, max_col_exclusive)
193    pub bounding_box: (usize, usize, usize, usize),
194    /// Perimeter length (boundary pixel count, 4-connected)
195    pub perimeter: T,
196    /// Eccentricity of equivalent ellipse (0 = circle, approaching 1 = line)
197    pub eccentricity: T,
198    /// Orientation angle in radians of major axis
199    pub orientation: T,
200    /// Diameter of a circle with the same area
201    pub equivalent_diameter: T,
202    /// Length of the major axis of the equivalent ellipse
203    pub major_axis_length: T,
204    /// Length of the minor axis of the equivalent ellipse
205    pub minor_axis_length: T,
206    /// Euler number: #objects - #holes (4-connected topology)
207    pub euler_number: i32,
208    /// Solidity: area / convex_hull_area
209    pub solidity: T,
210    /// Extent: area / bounding_box_area
211    pub extent: T,
212    /// Mean intensity
213    pub mean_intensity: T,
214    /// Minimum intensity
215    pub min_intensity: T,
216    /// Maximum intensity
217    pub max_intensity: T,
218    /// 7 Hu invariant moments (translation, scale, rotation invariant)
219    pub hu_moments: [T; 7],
220    /// Raw central moments (mu_00, mu_20, mu_11, mu_02, mu_30, mu_21, mu_12, mu_03)
221    pub central_moments: [T; 8],
222    /// Normalized central moments (nu_20, nu_11, nu_02, nu_30, nu_21, nu_12, nu_03)
223    pub normalized_moments: [T; 7],
224}
225
226/// Internal accumulator for single-pass pixel gathering
227struct PixelAccumulator {
228    area: usize,
229    sum_r: f64,
230    sum_c: f64,
231    sum_r_weighted: f64,
232    sum_c_weighted: f64,
233    sum_intensity: f64,
234    min_intensity: f64,
235    max_intensity: f64,
236    min_row: usize,
237    max_row: usize,
238    min_col: usize,
239    max_col: usize,
240    coords: Vec<(usize, usize)>,
241    intensities: Vec<f64>,
242}
243
244impl PixelAccumulator {
245    fn new() -> Self {
246        Self {
247            area: 0,
248            sum_r: 0.0,
249            sum_c: 0.0,
250            sum_r_weighted: 0.0,
251            sum_c_weighted: 0.0,
252            sum_intensity: 0.0,
253            min_intensity: f64::INFINITY,
254            max_intensity: f64::NEG_INFINITY,
255            min_row: usize::MAX,
256            max_row: 0,
257            min_col: usize::MAX,
258            max_col: 0,
259            coords: Vec::new(),
260            intensities: Vec::new(),
261        }
262    }
263
264    fn add(&mut self, r: usize, c: usize, intensity: f64) {
265        self.area += 1;
266        self.sum_r += r as f64;
267        self.sum_c += c as f64;
268        self.sum_r_weighted += r as f64 * intensity;
269        self.sum_c_weighted += c as f64 * intensity;
270        self.sum_intensity += intensity;
271        if intensity < self.min_intensity {
272            self.min_intensity = intensity;
273        }
274        if intensity > self.max_intensity {
275            self.max_intensity = intensity;
276        }
277        if r < self.min_row {
278            self.min_row = r;
279        }
280        if r > self.max_row {
281            self.max_row = r;
282        }
283        if c < self.min_col {
284            self.min_col = c;
285        }
286        if c > self.max_col {
287            self.max_col = c;
288        }
289        self.coords.push((r, c));
290        self.intensities.push(intensity);
291    }
292}
293
294/// Compute the Euler number of a binary region using the bit-quad method
295///
296/// For a 4-connected foreground, the Euler number is calculated by counting
297/// specific 2x2 bit patterns (quads) in the binary image.
298fn compute_euler_number(coords: &[(usize, usize)], rows: usize, cols: usize) -> i32 {
299    // Build a binary image for this region
300    let mut img = Array2::<bool>::from_elem((rows, cols), false);
301    for &(r, c) in coords {
302        img[[r, c]] = true;
303    }
304
305    // Count quad patterns for 4-connected Euler number
306    // Q1: exactly one pixel set in 2x2 block
307    // Q3: exactly three pixels set in 2x2 block
308    // Qd: diagonal pair set (two diagonally opposite pixels)
309    // Euler = (Q1 - Q3 + 2*Qd) / 4
310    let mut q1 = 0i32;
311    let mut q3 = 0i32;
312    let mut qd = 0i32;
313
314    for r in 0..rows.saturating_sub(1) + 1 {
315        for c in 0..cols.saturating_sub(1) + 1 {
316            // Count set pixels in the 2x2 block at (r, c)
317            let mut count = 0u32;
318            let mut pattern = 0u8;
319
320            let p00 = if r < rows && c < cols {
321                img[[r, c]]
322            } else {
323                false
324            };
325            let p01 = if r < rows && c + 1 < cols {
326                img[[r, c + 1]]
327            } else {
328                false
329            };
330            let p10 = if r + 1 < rows && c < cols {
331                img[[r + 1, c]]
332            } else {
333                false
334            };
335            let p11 = if r + 1 < rows && c + 1 < cols {
336                img[[r + 1, c + 1]]
337            } else {
338                false
339            };
340
341            if p00 {
342                count += 1;
343                pattern |= 1;
344            }
345            if p01 {
346                count += 1;
347                pattern |= 2;
348            }
349            if p10 {
350                count += 1;
351                pattern |= 4;
352            }
353            if p11 {
354                count += 1;
355                pattern |= 8;
356            }
357
358            if count == 1 {
359                q1 += 1;
360            } else if count == 3 {
361                q3 += 1;
362            } else if count == 2 {
363                // Check diagonal patterns: 0101 (bits 0,3) or 1010 (bits 1,2)
364                if pattern == 0b1001 || pattern == 0b0110 {
365                    qd += 1;
366                }
367            }
368        }
369    }
370
371    (q1 - q3 + 2 * qd) / 4
372}
373
374/// Compute Hu's 7 invariant moments from normalized central moments
375///
376/// These moments are invariant to translation, scale, and rotation.
377/// Reference: Hu, M.K. (1962). "Visual pattern recognition by moment invariants"
378fn compute_hu_moments(
379    nu_20: f64,
380    nu_11: f64,
381    nu_02: f64,
382    nu_30: f64,
383    nu_21: f64,
384    nu_12: f64,
385    nu_03: f64,
386) -> [f64; 7] {
387    // H1
388    let h1 = nu_20 + nu_02;
389
390    // H2
391    let h2 = (nu_20 - nu_02).powi(2) + 4.0 * nu_11.powi(2);
392
393    // H3
394    let h3 = (nu_30 - 3.0 * nu_12).powi(2) + (3.0 * nu_21 - nu_03).powi(2);
395
396    // H4
397    let h4 = (nu_30 + nu_12).powi(2) + (nu_21 + nu_03).powi(2);
398
399    // H5
400    let h5 = (nu_30 - 3.0 * nu_12)
401        * (nu_30 + nu_12)
402        * ((nu_30 + nu_12).powi(2) - 3.0 * (nu_21 + nu_03).powi(2))
403        + (3.0 * nu_21 - nu_03)
404            * (nu_21 + nu_03)
405            * (3.0 * (nu_30 + nu_12).powi(2) - (nu_21 + nu_03).powi(2));
406
407    // H6
408    let h6 = (nu_20 - nu_02) * ((nu_30 + nu_12).powi(2) - (nu_21 + nu_03).powi(2))
409        + 4.0 * nu_11 * (nu_30 + nu_12) * (nu_21 + nu_03);
410
411    // H7 (skew invariant, sign changes for mirrored images)
412    let h7 = (3.0 * nu_21 - nu_03)
413        * (nu_30 + nu_12)
414        * ((nu_30 + nu_12).powi(2) - 3.0 * (nu_21 + nu_03).powi(2))
415        - (nu_30 - 3.0 * nu_12)
416            * (nu_21 + nu_03)
417            * (3.0 * (nu_30 + nu_12).powi(2) - (nu_21 + nu_03).powi(2));
418
419    [h1, h2, h3, h4, h5, h6, h7]
420}
421
422/// Convex hull area via Andrew's monotone chain algorithm + shoelace formula
423fn convex_hull_area(coords: &[(usize, usize)]) -> f64 {
424    if coords.len() < 3 {
425        return coords.len() as f64;
426    }
427
428    let mut pts: Vec<(f64, f64)> = coords.iter().map(|&(r, c)| (c as f64, r as f64)).collect();
429    pts.sort_by(|a, b| {
430        a.0.partial_cmp(&b.0)
431            .unwrap_or(std::cmp::Ordering::Equal)
432            .then_with(|| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
433    });
434    pts.dedup();
435
436    if pts.len() < 3 {
437        return pts.len() as f64;
438    }
439
440    // Lower hull
441    let mut lower: Vec<(f64, f64)> = Vec::new();
442    for &p in &pts {
443        while lower.len() >= 2 {
444            let n = lower.len();
445            if cross_2d(lower[n - 2], lower[n - 1], p) <= 0.0 {
446                lower.pop();
447            } else {
448                break;
449            }
450        }
451        lower.push(p);
452    }
453
454    // Upper hull
455    let mut upper: Vec<(f64, f64)> = Vec::new();
456    for &p in pts.iter().rev() {
457        while upper.len() >= 2 {
458            let n = upper.len();
459            if cross_2d(upper[n - 2], upper[n - 1], p) <= 0.0 {
460                upper.pop();
461            } else {
462                break;
463            }
464        }
465        upper.push(p);
466    }
467
468    lower.pop();
469    upper.pop();
470
471    let hull: Vec<(f64, f64)> = lower.into_iter().chain(upper).collect();
472
473    if hull.len() < 3 {
474        return hull.len() as f64;
475    }
476
477    // Shoelace formula
478    let n = hull.len();
479    let mut area = 0.0;
480    for i in 0..n {
481        let j = (i + 1) % n;
482        area += hull[i].0 * hull[j].1;
483        area -= hull[j].0 * hull[i].1;
484    }
485    area.abs() / 2.0
486}
487
488#[inline]
489fn cross_2d(o: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
490    (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0)
491}
492
493/// Compute region properties for all labeled regions in a 2D image
494///
495/// Analyzes each non-zero label and computes a comprehensive set of geometric,
496/// statistical, and moment-based properties.
497///
498/// # Arguments
499///
500/// * `image`  - Intensity image (grayscale)
501/// * `labels` - Label image (0 = background, positive integers = regions)
502///
503/// # Returns
504///
505/// Vector of `RegionProps` sorted by label value.
506///
507/// # Example
508///
509/// ```
510/// use scirs2_core::ndarray::array;
511/// use scirs2_ndimage::regionprops::region_properties;
512///
513/// let image: scirs2_core::ndarray::Array2<f64> = array![
514///     [100.0, 100.0, 0.0, 200.0, 200.0],
515///     [100.0, 100.0, 0.0, 200.0, 200.0],
516///     [0.0,   0.0,   0.0, 0.0,   0.0],
517///     [150.0, 150.0, 150.0, 150.0, 150.0],
518///     [150.0, 150.0, 150.0, 150.0, 150.0],
519/// ];
520///
521/// let labels = array![
522///     [1, 1, 0, 2, 2],
523///     [1, 1, 0, 2, 2],
524///     [0, 0, 0, 0, 0],
525///     [3, 3, 3, 3, 3],
526///     [3, 3, 3, 3, 3],
527/// ];
528///
529/// let props = region_properties(&image, &labels).expect("should succeed");
530/// assert_eq!(props.len(), 3);
531/// assert_eq!(props[0].area, 4);
532/// ```
533pub fn region_properties<T>(
534    image: &Array2<T>,
535    labels: &Array2<usize>,
536) -> NdimageResult<Vec<RegionProps<T>>>
537where
538    T: Float + FromPrimitive + NumAssign + Debug + Copy + 'static,
539{
540    if image.shape() != labels.shape() {
541        return Err(NdimageError::DimensionError(
542            "Image and labels must have the same shape".to_string(),
543        ));
544    }
545
546    let rows = image.nrows();
547    let cols = image.ncols();
548
549    if rows == 0 || cols == 0 {
550        return Ok(Vec::new());
551    }
552
553    // Accumulate pixel data
554    let mut accumulators: HashMap<usize, PixelAccumulator> = HashMap::new();
555
556    for r in 0..rows {
557        for c in 0..cols {
558            let lbl = labels[[r, c]];
559            if lbl == 0 {
560                continue;
561            }
562            let intensity = image[[r, c]].to_f64().unwrap_or(0.0);
563            accumulators
564                .entry(lbl)
565                .or_insert_with(PixelAccumulator::new)
566                .add(r, c, intensity);
567        }
568    }
569
570    // Perimeter computation (4-connected boundary count)
571    let mut perimeter_counts: HashMap<usize, usize> = HashMap::new();
572    for r in 0..rows {
573        for c in 0..cols {
574            let lbl = labels[[r, c]];
575            if lbl == 0 {
576                continue;
577            }
578            let is_boundary = (r == 0 || labels[[r - 1, c]] != lbl)
579                || (r + 1 >= rows || labels[[r + 1, c]] != lbl)
580                || (c == 0 || labels[[r, c - 1]] != lbl)
581                || (c + 1 >= cols || labels[[r, c + 1]] != lbl);
582            if is_boundary {
583                *perimeter_counts.entry(lbl).or_insert(0) += 1;
584            }
585        }
586    }
587
588    // Build properties for each region
589    let to_t = |v: f64| -> T { T::from_f64(v).unwrap_or(T::zero()) };
590
591    let mut result = Vec::with_capacity(accumulators.len());
592
593    for (&lbl, acc) in &accumulators {
594        let area = acc.area;
595        let area_f = area as f64;
596
597        // Centroid
598        let cr = acc.sum_r / area_f;
599        let cc = acc.sum_c / area_f;
600
601        // Weighted centroid
602        let (wcr, wcc) = if acc.sum_intensity.abs() > 1e-15 {
603            (
604                acc.sum_r_weighted / acc.sum_intensity,
605                acc.sum_c_weighted / acc.sum_intensity,
606            )
607        } else {
608            (cr, cc)
609        };
610
611        // Central moments up to order 3
612        let mut mu_00 = 0.0;
613        let mut mu_20 = 0.0;
614        let mut mu_11 = 0.0;
615        let mut mu_02 = 0.0;
616        let mut mu_30 = 0.0;
617        let mut mu_21 = 0.0;
618        let mut mu_12 = 0.0;
619        let mut mu_03 = 0.0;
620
621        for &(r, c) in &acc.coords {
622            let dr = r as f64 - cr;
623            let dc = c as f64 - cc;
624            mu_00 += 1.0;
625            mu_20 += dr * dr;
626            mu_11 += dr * dc;
627            mu_02 += dc * dc;
628            mu_30 += dr * dr * dr;
629            mu_21 += dr * dr * dc;
630            mu_12 += dr * dc * dc;
631            mu_03 += dc * dc * dc;
632        }
633
634        // Normalized central moments: nu_pq = mu_pq / mu_00^((p+q)/2 + 1)
635        let gamma = |p: i32, q: i32| -> f64 {
636            let exp = (p + q) as f64 / 2.0 + 1.0;
637            if mu_00.abs() > 1e-15 {
638                mu_00.powf(exp)
639            } else {
640                1.0
641            }
642        };
643
644        let nu_20 = mu_20 / gamma(2, 0);
645        let nu_11 = mu_11 / gamma(1, 1);
646        let nu_02 = mu_02 / gamma(0, 2);
647        let nu_30 = mu_30 / gamma(3, 0);
648        let nu_21 = mu_21 / gamma(2, 1);
649        let nu_12 = mu_12 / gamma(1, 2);
650        let nu_03 = mu_03 / gamma(0, 3);
651
652        // Hu moments
653        let hu = compute_hu_moments(nu_20, nu_11, nu_02, nu_30, nu_21, nu_12, nu_03);
654
655        // Orientation (angle of major axis)
656        let orientation = 0.5 * (2.0 * mu_11).atan2(mu_20 - mu_02);
657
658        // Eigenvalues of the 2x2 inertia tensor for ellipse fitting
659        let mu_20_n = mu_20 / area_f;
660        let mu_11_n = mu_11 / area_f;
661        let mu_02_n = mu_02 / area_f;
662
663        let trace = mu_20_n + mu_02_n;
664        let det = mu_20_n * mu_02_n - mu_11_n * mu_11_n;
665        let discriminant = (trace * trace - 4.0 * det).max(0.0);
666        let sqrt_disc = discriminant.sqrt();
667        let lambda1 = (trace + sqrt_disc) / 2.0;
668        let lambda2 = (trace - sqrt_disc) / 2.0;
669
670        let major_axis = 4.0 * lambda1.max(0.0).sqrt();
671        let minor_axis = 4.0 * lambda2.max(0.0).sqrt();
672
673        // Eccentricity
674        let eccentricity = if major_axis > 1e-15 {
675            let ratio = minor_axis / major_axis;
676            (1.0 - ratio * ratio).max(0.0).sqrt()
677        } else {
678            0.0
679        };
680
681        // Equivalent diameter
682        let eq_diam = (4.0 * area_f / std::f64::consts::PI).sqrt();
683
684        // Perimeter
685        let perim = *perimeter_counts.get(&lbl).unwrap_or(&0) as f64;
686
687        // Bounding box area
688        let bbox_h = acc.max_row - acc.min_row + 1;
689        let bbox_w = acc.max_col - acc.min_col + 1;
690        let bbox_area = bbox_h * bbox_w;
691
692        // Extent
693        let extent = if bbox_area > 0 {
694            area_f / bbox_area as f64
695        } else {
696            0.0
697        };
698
699        // Solidity
700        let ch_area = convex_hull_area(&acc.coords);
701        let solidity = if ch_area > 1e-15 {
702            (area_f / ch_area).min(1.0)
703        } else {
704            1.0
705        };
706
707        // Euler number
708        let euler = compute_euler_number(&acc.coords, rows, cols);
709
710        // Mean intensity
711        let mean_intensity = acc.sum_intensity / area_f;
712
713        result.push(RegionProps {
714            label: lbl,
715            area,
716            centroid: (to_t(cr), to_t(cc)),
717            weighted_centroid: (to_t(wcr), to_t(wcc)),
718            bounding_box: (acc.min_row, acc.min_col, acc.max_row + 1, acc.max_col + 1),
719            perimeter: to_t(perim),
720            eccentricity: to_t(eccentricity),
721            orientation: to_t(orientation),
722            equivalent_diameter: to_t(eq_diam),
723            major_axis_length: to_t(major_axis),
724            minor_axis_length: to_t(minor_axis),
725            euler_number: euler,
726            solidity: to_t(solidity),
727            extent: to_t(extent),
728            mean_intensity: to_t(mean_intensity),
729            min_intensity: to_t(acc.min_intensity),
730            max_intensity: to_t(acc.max_intensity),
731            hu_moments: [
732                to_t(hu[0]),
733                to_t(hu[1]),
734                to_t(hu[2]),
735                to_t(hu[3]),
736                to_t(hu[4]),
737                to_t(hu[5]),
738                to_t(hu[6]),
739            ],
740            central_moments: [
741                to_t(mu_00),
742                to_t(mu_20),
743                to_t(mu_11),
744                to_t(mu_02),
745                to_t(mu_30),
746                to_t(mu_21),
747                to_t(mu_12),
748                to_t(mu_03),
749            ],
750            normalized_moments: [
751                to_t(nu_20),
752                to_t(nu_11),
753                to_t(nu_02),
754                to_t(nu_30),
755                to_t(nu_21),
756                to_t(nu_12),
757                to_t(nu_03),
758            ],
759        });
760    }
761
762    result.sort_by_key(|p| p.label);
763    Ok(result)
764}
765
766// ---------------------------------------------------------------------------
767// Region filtering
768// ---------------------------------------------------------------------------
769
770/// Predicate for filtering regions by property
771pub enum RegionFilter<T: Float> {
772    /// Keep regions whose area is in [min, max]
773    AreaRange { min: usize, max: usize },
774    /// Keep regions whose perimeter is in [min, max]
775    PerimeterRange { min: T, max: T },
776    /// Keep regions whose eccentricity is in [min, max]
777    EccentricityRange { min: T, max: T },
778    /// Keep regions whose solidity is in [min, max]
779    SolidityRange { min: T, max: T },
780    /// Keep regions whose mean intensity is in [min, max]
781    IntensityRange { min: T, max: T },
782    /// Keep regions whose extent is in [min, max]
783    ExtentRange { min: T, max: T },
784    /// Custom predicate
785    Custom(Box<dyn Fn(&RegionProps<T>) -> bool>),
786}
787
788/// Filter regions by one or more property criteria
789///
790/// Returns a new labeled image with only the regions that satisfy all filters.
791///
792/// # Arguments
793///
794/// * `labels`  - Labeled image
795/// * `props`   - Pre-computed region properties
796/// * `filters` - List of filter predicates (all must be satisfied)
797///
798/// # Returns
799///
800/// New labeled image with only accepted regions (relabeled sequentially).
801///
802/// # Example
803///
804/// ```
805/// use scirs2_core::ndarray::array;
806/// use scirs2_ndimage::regionprops::{region_properties, filter_regions, RegionFilter};
807///
808/// let image: scirs2_core::ndarray::Array2<f64> = scirs2_core::ndarray::Array2::from_elem((6, 6), 1.0);
809/// let labels = array![
810///     [1, 1, 0, 2, 2, 2],
811///     [1, 1, 0, 2, 2, 2],
812///     [0, 0, 0, 0, 0, 0],
813///     [0, 0, 3, 0, 0, 0],
814///     [0, 0, 0, 0, 0, 0],
815///     [0, 0, 0, 0, 0, 0],
816/// ];
817///
818/// let props = region_properties(&image, &labels).expect("should succeed");
819/// let filters = vec![RegionFilter::AreaRange { min: 3, max: 100 }];
820/// let filtered = filter_regions(&labels, &props, &filters).expect("should succeed");
821///
822/// // Region 3 (area 1) is filtered out
823/// let mut unique = std::collections::HashSet::new();
824/// for &v in filtered.iter() { if v > 0 { unique.insert(v); } }
825/// assert_eq!(unique.len(), 2);
826/// ```
827pub fn filter_regions<T>(
828    labels: &Array2<usize>,
829    props: &[RegionProps<T>],
830    filters: &[RegionFilter<T>],
831) -> NdimageResult<Array2<usize>>
832where
833    T: Float + FromPrimitive + NumAssign + Debug + Copy + 'static,
834{
835    let rows = labels.nrows();
836    let cols = labels.ncols();
837
838    // Determine which labels pass all filters
839    let mut accepted: HashMap<usize, usize> = HashMap::new();
840    let mut next_label = 1usize;
841
842    for rp in props {
843        let pass = filters.iter().all(|f| match f {
844            RegionFilter::AreaRange { min, max } => rp.area >= *min && rp.area <= *max,
845            RegionFilter::PerimeterRange { min, max } => {
846                rp.perimeter >= *min && rp.perimeter <= *max
847            }
848            RegionFilter::EccentricityRange { min, max } => {
849                rp.eccentricity >= *min && rp.eccentricity <= *max
850            }
851            RegionFilter::SolidityRange { min, max } => rp.solidity >= *min && rp.solidity <= *max,
852            RegionFilter::IntensityRange { min, max } => {
853                rp.mean_intensity >= *min && rp.mean_intensity <= *max
854            }
855            RegionFilter::ExtentRange { min, max } => rp.extent >= *min && rp.extent <= *max,
856            RegionFilter::Custom(pred) => pred(rp),
857        });
858
859        if pass {
860            accepted.insert(rp.label, next_label);
861            next_label += 1;
862        }
863    }
864
865    // Relabel
866    let mut output = Array2::zeros((rows, cols));
867    for r in 0..rows {
868        for c in 0..cols {
869            let lbl = labels[[r, c]];
870            if let Some(&new_lbl) = accepted.get(&lbl) {
871                output[[r, c]] = new_lbl;
872            }
873        }
874    }
875
876    Ok(output)
877}
878
879// ---------------------------------------------------------------------------
880// Tests
881// ---------------------------------------------------------------------------
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use approx::assert_abs_diff_eq;
887    use scirs2_core::ndarray::array;
888
889    #[test]
890    fn test_label_components_4conn() {
891        let binary = array![
892            [true, true, false, false],
893            [true, true, false, false],
894            [false, false, true, true],
895            [false, false, true, true],
896        ];
897
898        let (labeled, n) = label_components(&binary, Connectivity::Conn4).expect("should succeed");
899        assert_eq!(n, 2);
900        let l1 = labeled[[0, 0]];
901        let l2 = labeled[[2, 2]];
902        assert_ne!(l1, 0);
903        assert_ne!(l2, 0);
904        assert_ne!(l1, l2);
905    }
906
907    #[test]
908    fn test_label_components_8conn() {
909        let binary = array![
910            [true, false, false],
911            [false, true, false],
912            [false, false, true],
913        ];
914
915        let (labeled, n) = label_components(&binary, Connectivity::Conn8).expect("should succeed");
916        assert_eq!(n, 1);
917        assert_eq!(labeled[[0, 0]], labeled[[1, 1]]);
918        assert_eq!(labeled[[1, 1]], labeled[[2, 2]]);
919    }
920
921    #[test]
922    fn test_label_components_4conn_diagonal() {
923        let binary = array![
924            [true, false, false],
925            [false, true, false],
926            [false, false, true],
927        ];
928
929        let (_, n) = label_components(&binary, Connectivity::Conn4).expect("should succeed");
930        assert_eq!(n, 3);
931    }
932
933    #[test]
934    fn test_label_components_empty() {
935        let binary = Array2::from_elem((3, 3), false);
936        let (labeled, n) = label_components(&binary, Connectivity::Conn4).expect("should succeed");
937        assert_eq!(n, 0);
938        for &v in labeled.iter() {
939            assert_eq!(v, 0);
940        }
941    }
942
943    #[test]
944    fn test_region_properties_basic() {
945        let image: Array2<f64> = array![
946            [100.0, 100.0, 0.0, 200.0, 200.0],
947            [100.0, 100.0, 0.0, 200.0, 200.0],
948            [0.0, 0.0, 0.0, 0.0, 0.0],
949            [150.0, 150.0, 150.0, 150.0, 150.0],
950            [150.0, 150.0, 150.0, 150.0, 150.0],
951        ];
952
953        let labels = array![
954            [1, 1, 0, 2, 2],
955            [1, 1, 0, 2, 2],
956            [0, 0, 0, 0, 0],
957            [3, 3, 3, 3, 3],
958            [3, 3, 3, 3, 3],
959        ];
960
961        let props = region_properties(&image, &labels).expect("should succeed");
962        assert_eq!(props.len(), 3);
963
964        // Region 1
965        assert_eq!(props[0].label, 1);
966        assert_eq!(props[0].area, 4);
967        assert_abs_diff_eq!(props[0].centroid.0, 0.5, epsilon = 1e-10);
968        assert_abs_diff_eq!(props[0].centroid.1, 0.5, epsilon = 1e-10);
969        assert_abs_diff_eq!(props[0].mean_intensity, 100.0, epsilon = 1e-10);
970        assert_eq!(props[0].bounding_box, (0, 0, 2, 2));
971    }
972
973    #[test]
974    fn test_region_properties_hu_moments() {
975        // A symmetric region should have specific moment properties
976        let mut labels = Array2::<usize>::zeros((11, 11));
977        let image = Array2::<f64>::from_elem((11, 11), 1.0);
978
979        // Create a symmetric cross pattern
980        for i in 4..7 {
981            for j in 0..11 {
982                labels[[i, j]] = 1;
983                labels[[j, i]] = 1;
984            }
985        }
986
987        let props = region_properties(&image, &labels).expect("should succeed");
988        assert_eq!(props.len(), 1);
989
990        // For a symmetric shape, odd-order Hu moments should be near zero
991        // H3 and H5 involve odd-order normalized moments
992        assert!(
993            props[0].hu_moments[2].abs() < 0.1,
994            "H3 should be near zero for symmetric shape"
995        );
996    }
997
998    #[test]
999    fn test_region_properties_eccentricity_circle() {
1000        let mut labels = Array2::<usize>::zeros((21, 21));
1001        let image = Array2::<f64>::from_elem((21, 21), 1.0);
1002
1003        for r in 0..21 {
1004            for c in 0..21 {
1005                let dr = r as f64 - 10.0;
1006                let dc = c as f64 - 10.0;
1007                if dr * dr + dc * dc <= 64.0 {
1008                    labels[[r, c]] = 1;
1009                }
1010            }
1011        }
1012
1013        let props = region_properties(&image, &labels).expect("should succeed");
1014        assert_eq!(props.len(), 1);
1015        let ecc: f64 = props[0].eccentricity;
1016        assert!(ecc < 0.2, "Circle eccentricity {} should be < 0.2", ecc);
1017    }
1018
1019    #[test]
1020    fn test_region_properties_eccentricity_line() {
1021        let mut labels = Array2::<usize>::zeros((3, 21));
1022        let image = Array2::<f64>::from_elem((3, 21), 1.0);
1023
1024        for c in 0..21 {
1025            labels[[1, c]] = 1;
1026        }
1027
1028        let props = region_properties(&image, &labels).expect("should succeed");
1029        let ecc = props[0].eccentricity;
1030        assert!(ecc > 0.9, "Line eccentricity {} should be > 0.9", ecc);
1031    }
1032
1033    #[test]
1034    fn test_region_properties_euler_number() {
1035        // Simple filled rectangle: Euler number = 1
1036        let mut labels = Array2::<usize>::zeros((10, 10));
1037        let image = Array2::<f64>::from_elem((10, 10), 1.0);
1038
1039        for r in 2..8 {
1040            for c in 2..8 {
1041                labels[[r, c]] = 1;
1042            }
1043        }
1044
1045        let props = region_properties(&image, &labels).expect("should succeed");
1046        assert_eq!(
1047            props[0].euler_number, 1,
1048            "Filled rectangle Euler should be 1"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_region_properties_euler_number_with_hole() {
1054        // Rectangle with a hole: Euler number = 0 (1 object - 1 hole)
1055        let mut labels = Array2::<usize>::zeros((12, 12));
1056        let image = Array2::<f64>::from_elem((12, 12), 1.0);
1057
1058        for r in 1..11 {
1059            for c in 1..11 {
1060                labels[[r, c]] = 1;
1061            }
1062        }
1063        // Remove interior to create hole
1064        for r in 4..8 {
1065            for c in 4..8 {
1066                labels[[r, c]] = 0;
1067            }
1068        }
1069
1070        let props = region_properties(&image, &labels).expect("should succeed");
1071        assert_eq!(
1072            props[0].euler_number, 0,
1073            "Rectangle with hole Euler should be 0"
1074        );
1075    }
1076
1077    #[test]
1078    fn test_region_properties_solidity() {
1079        // A filled square should have solidity near 1.0
1080        let mut labels = Array2::<usize>::zeros((10, 10));
1081        let image = Array2::<f64>::from_elem((10, 10), 1.0);
1082
1083        for r in 2..8 {
1084            for c in 2..8 {
1085                labels[[r, c]] = 1;
1086            }
1087        }
1088
1089        let props = region_properties(&image, &labels).expect("should succeed");
1090        let sol = props[0].solidity;
1091        assert_abs_diff_eq!(sol, 1.0, epsilon = 0.02);
1092    }
1093
1094    #[test]
1095    fn test_region_properties_extent() {
1096        // A filled rectangle: extent = 1.0
1097        let mut labels = Array2::<usize>::zeros((10, 10));
1098        let image = Array2::<f64>::from_elem((10, 10), 1.0);
1099
1100        for r in 2..5 {
1101            for c in 3..8 {
1102                labels[[r, c]] = 1;
1103            }
1104        }
1105
1106        let props = region_properties(&image, &labels).expect("should succeed");
1107        let ext = props[0].extent;
1108        assert_abs_diff_eq!(ext, 1.0, epsilon = 1e-10);
1109    }
1110
1111    #[test]
1112    fn test_region_properties_shape_mismatch() {
1113        let image: Array2<f64> = Array2::zeros((3, 3));
1114        let labels: Array2<usize> = Array2::zeros((4, 4));
1115        let result = region_properties(&image, &labels);
1116        assert!(result.is_err());
1117    }
1118
1119    #[test]
1120    fn test_region_properties_equivalent_diameter() {
1121        let mut labels = Array2::<usize>::zeros((12, 12));
1122        let image = Array2::<f64>::from_elem((12, 12), 1.0);
1123
1124        for r in 1..11 {
1125            for c in 1..11 {
1126                labels[[r, c]] = 1;
1127            }
1128        }
1129
1130        let props = region_properties(&image, &labels).expect("should succeed");
1131        let expected_diam = (4.0 * 100.0 / std::f64::consts::PI).sqrt();
1132        let actual_diam = props[0].equivalent_diameter;
1133        assert_abs_diff_eq!(actual_diam, expected_diam, epsilon = 0.01);
1134    }
1135
1136    #[test]
1137    fn test_filter_regions_by_area() {
1138        let image = Array2::<f64>::from_elem((6, 6), 1.0);
1139        let labels = array![
1140            [1, 1, 0, 2, 2, 2],
1141            [1, 1, 0, 2, 2, 2],
1142            [0, 0, 0, 0, 0, 0],
1143            [0, 0, 3, 0, 0, 0],
1144            [0, 0, 0, 0, 0, 0],
1145            [0, 0, 0, 0, 0, 0],
1146        ];
1147
1148        let props = region_properties(&image, &labels).expect("should succeed");
1149        let filters = vec![RegionFilter::AreaRange { min: 3, max: 100 }];
1150        let filtered = filter_regions(&labels, &props, &filters).expect("should succeed");
1151
1152        let mut unique = std::collections::HashSet::new();
1153        for &v in filtered.iter() {
1154            if v > 0 {
1155                unique.insert(v);
1156            }
1157        }
1158        // Region 3 (area 1) should be filtered out
1159        assert_eq!(unique.len(), 2);
1160    }
1161
1162    #[test]
1163    fn test_filter_regions_by_eccentricity() {
1164        let image = Array2::<f64>::from_elem((20, 20), 1.0);
1165        let mut labels = Array2::<usize>::zeros((20, 20));
1166
1167        // Region 1: roughly circular
1168        for r in 2..8 {
1169            for c in 2..8 {
1170                labels[[r, c]] = 1;
1171            }
1172        }
1173
1174        // Region 2: elongated horizontal line
1175        for c in 0..20 {
1176            labels[[15, c]] = 2;
1177        }
1178
1179        let props = region_properties(&image, &labels).expect("should succeed");
1180        let filters = vec![RegionFilter::EccentricityRange {
1181            min: 0.0f64,
1182            max: 0.5,
1183        }];
1184        let filtered = filter_regions(&labels, &props, &filters).expect("should succeed");
1185
1186        // Only the compact region should remain
1187        let mut unique = std::collections::HashSet::new();
1188        for &v in filtered.iter() {
1189            if v > 0 {
1190                unique.insert(v);
1191            }
1192        }
1193        assert_eq!(unique.len(), 1, "Only the compact region should remain");
1194    }
1195
1196    #[test]
1197    fn test_region_properties_intensity_stats() {
1198        let image: Array2<f64> = array![[10.0, 20.0, 30.0], [40.0, 50.0, 60.0], [70.0, 80.0, 90.0]];
1199        let labels = Array2::from_elem((3, 3), 1usize);
1200
1201        let props = region_properties(&image, &labels).expect("should succeed");
1202        assert_abs_diff_eq!(props[0].mean_intensity, 50.0, epsilon = 1e-10);
1203        assert_abs_diff_eq!(props[0].min_intensity, 10.0, epsilon = 1e-10);
1204        assert_abs_diff_eq!(props[0].max_intensity, 90.0, epsilon = 1e-10);
1205    }
1206
1207    #[test]
1208    fn test_label_components_zero_size() {
1209        let binary: Array2<bool> = Array2::from_elem((0, 0), false);
1210        let (_, n) = label_components(&binary, Connectivity::Conn4).expect("should succeed");
1211        assert_eq!(n, 0);
1212    }
1213}