1use 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
22struct 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Connectivity {
67 Conn4,
69 Conn8,
71}
72
73impl Default for Connectivity {
74 fn default() -> Self {
75 Connectivity::Conn4
76 }
77}
78
79pub 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 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 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#[derive(Debug, Clone)]
183pub struct RegionProps<T: Float> {
184 pub label: usize,
186 pub area: usize,
188 pub centroid: (T, T),
190 pub weighted_centroid: (T, T),
192 pub bounding_box: (usize, usize, usize, usize),
194 pub perimeter: T,
196 pub eccentricity: T,
198 pub orientation: T,
200 pub equivalent_diameter: T,
202 pub major_axis_length: T,
204 pub minor_axis_length: T,
206 pub euler_number: i32,
208 pub solidity: T,
210 pub extent: T,
212 pub mean_intensity: T,
214 pub min_intensity: T,
216 pub max_intensity: T,
218 pub hu_moments: [T; 7],
220 pub central_moments: [T; 8],
222 pub normalized_moments: [T; 7],
224}
225
226struct 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
294fn compute_euler_number(coords: &[(usize, usize)], rows: usize, cols: usize) -> i32 {
299 let mut img = Array2::<bool>::from_elem((rows, cols), false);
301 for &(r, c) in coords {
302 img[[r, c]] = true;
303 }
304
305 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 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 if pattern == 0b1001 || pattern == 0b0110 {
365 qd += 1;
366 }
367 }
368 }
369 }
370
371 (q1 - q3 + 2 * qd) / 4
372}
373
374fn 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 let h1 = nu_20 + nu_02;
389
390 let h2 = (nu_20 - nu_02).powi(2) + 4.0 * nu_11.powi(2);
392
393 let h3 = (nu_30 - 3.0 * nu_12).powi(2) + (3.0 * nu_21 - nu_03).powi(2);
395
396 let h4 = (nu_30 + nu_12).powi(2) + (nu_21 + nu_03).powi(2);
398
399 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 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 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
422fn 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 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 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 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
493pub 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 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 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 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 let cr = acc.sum_r / area_f;
599 let cc = acc.sum_c / area_f;
600
601 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 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 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 let hu = compute_hu_moments(nu_20, nu_11, nu_02, nu_30, nu_21, nu_12, nu_03);
654
655 let orientation = 0.5 * (2.0 * mu_11).atan2(mu_20 - mu_02);
657
658 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 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 let eq_diam = (4.0 * area_f / std::f64::consts::PI).sqrt();
683
684 let perim = *perimeter_counts.get(&lbl).unwrap_or(&0) as f64;
686
687 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 let extent = if bbox_area > 0 {
694 area_f / bbox_area as f64
695 } else {
696 0.0
697 };
698
699 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 let euler = compute_euler_number(&acc.coords, rows, cols);
709
710 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
766pub enum RegionFilter<T: Float> {
772 AreaRange { min: usize, max: usize },
774 PerimeterRange { min: T, max: T },
776 EccentricityRange { min: T, max: T },
778 SolidityRange { min: T, max: T },
780 IntensityRange { min: T, max: T },
782 ExtentRange { min: T, max: T },
784 Custom(Box<dyn Fn(&RegionProps<T>) -> bool>),
786}
787
788pub 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 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 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#[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 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 let mut labels = Array2::<usize>::zeros((11, 11));
977 let image = Array2::<f64>::from_elem((11, 11), 1.0);
978
979 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 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 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 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 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 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 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 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 for r in 2..8 {
1169 for c in 2..8 {
1170 labels[[r, c]] = 1;
1171 }
1172 }
1173
1174 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 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}