1use crate::error::{NdimageError, NdimageResult};
17use scirs2_core::ndarray::{Array2, ArrayView2};
18
19pub fn convex_hull_2d(points: &[(f64, f64)]) -> NdimageResult<Vec<(f64, f64)>> {
47 if points.is_empty() {
48 return Err(NdimageError::InvalidInput(
49 "convex_hull_2d: point set is empty".to_string(),
50 ));
51 }
52
53 let mut pts: Vec<(f64, f64)> = points.to_vec();
55 pts.sort_by(|a, b| {
56 a.0.partial_cmp(&b.0)
57 .unwrap_or(std::cmp::Ordering::Equal)
58 .then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
59 });
60 pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-12 && (a.1 - b.1).abs() < 1e-12);
61
62 if pts.len() < 3 {
63 return Ok(pts);
64 }
65
66 let cross = |o: (f64, f64), a: (f64, f64), b: (f64, f64)| -> f64 {
68 (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0)
69 };
70
71 let n = pts.len();
72 let mut hull: Vec<(f64, f64)> = Vec::with_capacity(2 * n);
73
74 for &p in &pts {
76 while hull.len() >= 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0.0 {
77 hull.pop();
78 }
79 hull.push(p);
80 }
81
82 let lower_len = hull.len() + 1;
84 for &p in pts.iter().rev() {
85 while hull.len() >= lower_len && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0.0
86 {
87 hull.pop();
88 }
89 hull.push(p);
90 }
91
92 hull.pop(); Ok(hull)
94}
95
96pub fn contour_extraction(binary: &ArrayView2<bool>) -> NdimageResult<Vec<Vec<(usize, usize)>>> {
124 use std::collections::VecDeque;
125
126 let rows = binary.nrows();
127 let cols = binary.ncols();
128
129 if rows == 0 || cols == 0 {
130 return Ok(Vec::new());
131 }
132
133 const N8: [(i32, i32); 8] = [
135 (0, 1),
136 (1, 1),
137 (1, 0),
138 (1, -1),
139 (0, -1),
140 (-1, -1),
141 (-1, 0),
142 (-1, 1),
143 ];
144 const N4: [(i32, i32); 4] = [(0, 1), (1, 0), (0, -1), (-1, 0)];
146
147 let in_bounds = |r: i32, c: i32| r >= 0 && r < rows as i32 && c >= 0 && c < cols as i32;
148
149 let get = |r: usize, c: usize| -> bool { *binary.get((r, c)).unwrap_or(&false) };
150
151 let is_boundary = |r: usize, c: usize| -> bool {
153 N8.iter().any(|(dr, dc)| {
154 let nr = r as i32 + dr;
155 let nc = c as i32 + dc;
156 !in_bounds(nr, nc) || !get(nr as usize, nc as usize)
157 })
158 };
159
160 let mut fg_visited = Array2::<bool>::default((rows, cols));
162 let mut contours: Vec<Vec<(usize, usize)>> = Vec::new();
163
164 for start_r in 0..rows {
165 for start_c in 0..cols {
166 if !get(start_r, start_c) || fg_visited[[start_r, start_c]] {
167 continue;
168 }
169
170 let mut component: Vec<(usize, usize)> = Vec::new();
172 let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
173 queue.push_back((start_r, start_c));
174 fg_visited[[start_r, start_c]] = true;
175
176 while let Some((r, c)) = queue.pop_front() {
177 component.push((r, c));
178 for (dr, dc) in &N4 {
179 let nr = r as i32 + dr;
180 let nc = c as i32 + dc;
181 if in_bounds(nr, nc) {
182 let nr = nr as usize;
183 let nc = nc as usize;
184 if get(nr, nc) && !fg_visited[[nr, nc]] {
185 fg_visited[[nr, nc]] = true;
186 queue.push_back((nr, nc));
187 }
188 }
189 }
190 }
191
192 let mut boundary_visited = Array2::<bool>::default((rows, cols));
195 let mut contour: Vec<(usize, usize)> = Vec::new();
196
197 let first_boundary = component.iter().find(|&&(r, c)| is_boundary(r, c));
199
200 if let Some(&(br, bc)) = first_boundary {
201 let mut bq: VecDeque<(usize, usize)> = VecDeque::new();
202 bq.push_back((br, bc));
203 boundary_visited[[br, bc]] = true;
204
205 while let Some((r, c)) = bq.pop_front() {
206 contour.push((r, c));
207 for (dr, dc) in &N8 {
208 let nr = r as i32 + dr;
209 let nc = c as i32 + dc;
210 if in_bounds(nr, nc) {
211 let nr = nr as usize;
212 let nc = nc as usize;
213 if get(nr, nc) && is_boundary(nr, nc) && !boundary_visited[[nr, nc]] {
214 boundary_visited[[nr, nc]] = true;
215 bq.push_back((nr, nc));
216 }
217 }
218 }
219 }
220 }
221
222 if !contour.is_empty() {
223 contours.push(contour);
224 }
225 }
226 }
227
228 Ok(contours)
229}
230
231#[derive(Debug, Clone)]
237pub struct ShapeDescriptors {
238 pub area: f64,
240 pub perimeter: f64,
242 pub circularity: f64,
244 pub eccentricity: f64,
246 pub aspect_ratio: f64,
248 pub extent: f64,
250 pub solidity: f64,
252 pub convexity: f64,
254}
255
256pub fn shape_descriptors(contour: &[(usize, usize)]) -> NdimageResult<ShapeDescriptors> {
277 if contour.len() < 3 {
278 return Err(NdimageError::InvalidInput(
279 "shape_descriptors: contour must have at least 3 points".to_string(),
280 ));
281 }
282
283 let pts_f: Vec<(f64, f64)> = contour
284 .iter()
285 .map(|&(r, c)| (c as f64, r as f64)) .collect();
287
288 let area = shoelace_area(&pts_f).abs();
290
291 let n = pts_f.len();
293 let perimeter: f64 = pts_f
294 .iter()
295 .zip(pts_f.iter().cycle().skip(1))
296 .take(n)
297 .map(|(&(x0, y0), &(x1, y1))| ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt())
298 .sum();
299
300 let circularity = if perimeter > 1e-12 {
302 4.0 * std::f64::consts::PI * area / perimeter.powi(2)
303 } else {
304 0.0
305 };
306
307 let (min_x, max_x, min_y, max_y) = pts_f.iter().fold(
309 (
310 f64::INFINITY,
311 f64::NEG_INFINITY,
312 f64::INFINITY,
313 f64::NEG_INFINITY,
314 ),
315 |(mnx, mxx, mny, mxy), &(x, y)| (mnx.min(x), mxx.max(x), mny.min(y), mxy.max(y)),
316 );
317 let bbox_w = (max_x - min_x).max(1.0);
318 let bbox_h = (max_y - min_y).max(1.0);
319 let bbox_area = bbox_w * bbox_h;
320
321 let aspect_ratio = if bbox_h > 1e-12 { bbox_w / bbox_h } else { 1.0 };
323
324 let extent = area / bbox_area;
326
327 let hull = convex_hull_2d(&pts_f).unwrap_or_default();
329
330 let hull_area = if hull.len() >= 3 {
332 shoelace_area(&hull).abs()
333 } else {
334 area
335 };
336
337 let hull_perim: f64 = if hull.len() >= 2 {
339 let hn = hull.len();
340 hull.iter()
341 .zip(hull.iter().cycle().skip(1))
342 .take(hn)
343 .map(|(&(x0, y0), &(x1, y1))| ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt())
344 .sum()
345 } else {
346 perimeter
347 };
348
349 let solidity = if hull_area > 1e-12 {
351 (area / hull_area).min(1.0)
352 } else {
353 1.0
354 };
355
356 let convexity = if perimeter > 1e-12 {
358 (hull_perim / perimeter).min(1.0)
359 } else {
360 1.0
361 };
362
363 let (cx, cy) = pts_f
365 .iter()
366 .fold((0.0f64, 0.0f64), |(ax, ay), &(x, y)| (ax + x, ay + y));
367 let cx = cx / n as f64;
368 let cy = cy / n as f64;
369
370 let (mu20, mu02, mu11) =
371 pts_f
372 .iter()
373 .fold((0.0f64, 0.0f64, 0.0f64), |(m20, m02, m11), &(x, y)| {
374 let dx = x - cx;
375 let dy = y - cy;
376 (m20 + dx * dx, m02 + dy * dy, m11 + dx * dy)
377 });
378 let mu20 = mu20 / n as f64;
379 let mu02 = mu02 / n as f64;
380 let mu11 = mu11 / n as f64;
381
382 let diff = mu20 - mu02;
383 let discriminant = (diff * diff + 4.0 * mu11 * mu11).sqrt();
384 let lambda1 = (mu20 + mu02 + discriminant) / 2.0;
385 let lambda2 = ((mu20 + mu02 - discriminant) / 2.0).max(0.0);
386
387 let eccentricity = if lambda1 > 1e-12 {
388 (1.0 - lambda2 / lambda1).sqrt()
389 } else {
390 0.0
391 };
392
393 Ok(ShapeDescriptors {
394 area,
395 perimeter,
396 circularity: circularity.min(1.0 + 1e-9),
397 eccentricity,
398 aspect_ratio,
399 extent: extent.min(1.0),
400 solidity,
401 convexity,
402 })
403}
404
405pub fn ellipse_fit(points: &[(f64, f64)]) -> NdimageResult<(f64, f64, f64, f64, f64)> {
447 if points.len() < 5 {
448 return Err(NdimageError::InvalidInput(
449 "ellipse_fit: at least 5 points are required".to_string(),
450 ));
451 }
452
453 let n = points.len();
454
455 let mut s = [[0.0f64; 6]; 6];
458 for &(x, y) in points {
459 let row = [x * x, x * y, y * y, x, y, 1.0];
460 for i in 0..6 {
461 for j in 0..6 {
462 s[i][j] += row[i] * row[j];
463 }
464 }
465 }
466
467 let s11 = sub_matrix_3x3(&s, 0, 0);
479 let s12 = sub_matrix_3x3(&s, 0, 3);
480 let s21 = sub_matrix_3x3(&s, 3, 0);
481 let s22 = sub_matrix_3x3(&s, 3, 3);
482
483 let s22_inv = mat3_inv(&s22).ok_or_else(|| {
484 NdimageError::ComputationError("ellipse_fit: singular scatter sub-matrix".to_string())
485 })?;
486
487 let tmp = mat3_mul(&s12, &mat3_mul(&s22_inv, &s21));
489 let m_raw = mat3_sub(&s11, &tmp);
490
491 let m = [
494 [0.5 * m_raw[2][0], 0.5 * m_raw[2][1], 0.5 * m_raw[2][2]],
495 [-m_raw[1][0], -m_raw[1][1], -m_raw[1][2]],
496 [0.5 * m_raw[0][0], 0.5 * m_raw[0][1], 0.5 * m_raw[0][2]],
497 ];
498
499 let (eigenvalues, eigenvectors) = mat3_eigen(&m);
501
502 let mut best: Option<[f64; 3]> = None;
504 let mut best_eval = f64::INFINITY;
505 for i in 0..3 {
506 let ev = eigenvalues[i];
507 if ev.is_finite() && ev > -1e-10 {
508 let v = eigenvectors[i];
509 let cond = 4.0 * v[0] * v[2] - v[1] * v[1];
510 if cond > 0.0 && ev < best_eval {
511 best_eval = ev;
512 best = Some(v);
513 }
514 }
515 }
516
517 let a1 = best.ok_or_else(|| {
518 NdimageError::ComputationError(
519 "ellipse_fit: no valid ellipse eigenvector found".to_string(),
520 )
521 })?;
522
523 let neg_s22inv_s21_a1 = mat3_mul_vec(&mat3_mul(&s22_inv, &s21), &a1);
526 let coeffs = [
527 a1[0],
528 a1[1],
529 a1[2],
530 -neg_s22inv_s21_a1[0],
531 -neg_s22inv_s21_a1[1],
532 -neg_s22inv_s21_a1[2],
533 ];
534
535 conic_to_ellipse(coeffs)
537}
538
539pub fn minimum_bounding_rectangle(points: &[(f64, f64)]) -> NdimageResult<[(f64, f64); 4]> {
575 if points.is_empty() {
576 return Err(NdimageError::InvalidInput(
577 "minimum_bounding_rectangle: point set is empty".to_string(),
578 ));
579 }
580
581 let hull = convex_hull_2d(points)?;
582 if hull.len() == 1 {
583 let p = hull[0];
584 return Ok([p, p, p, p]);
585 }
586 if hull.len() == 2 {
587 let (x0, y0) = hull[0];
588 let (x1, y1) = hull[1];
589 return Ok([(x0, y0), (x1, y1), (x1, y1), (x0, y0)]);
590 }
591
592 let n = hull.len();
593 let mut min_area = f64::INFINITY;
594 let mut best_rect = [(0.0f64, 0.0f64); 4];
595
596 for i in 0..n {
598 let j = (i + 1) % n;
599 let (ex, ey) = (hull[j].0 - hull[i].0, hull[j].1 - hull[i].1);
600 let len_e = (ex * ex + ey * ey).sqrt();
601 if len_e < 1e-12 {
602 continue;
603 }
604 let ux = ex / len_e; let uy = ey / len_e;
606
607 let (mut min_u, mut max_u, mut min_v, mut max_v) = (
609 f64::INFINITY,
610 f64::NEG_INFINITY,
611 f64::INFINITY,
612 f64::NEG_INFINITY,
613 );
614
615 for &(px, py) in &hull {
616 let u = px * ux + py * uy;
617 let v = -px * uy + py * ux;
618 min_u = min_u.min(u);
619 max_u = max_u.max(u);
620 min_v = min_v.min(v);
621 max_v = max_v.max(v);
622 }
623
624 let area = (max_u - min_u) * (max_v - min_v);
625 if area < min_area {
626 min_area = area;
627 let corner = |u: f64, v: f64| -> (f64, f64) { (u * ux - v * uy, u * uy + v * ux) };
629 best_rect = [
630 corner(min_u, min_v),
631 corner(max_u, min_v),
632 corner(max_u, max_v),
633 corner(min_u, max_v),
634 ];
635 }
636 }
637
638 Ok(best_rect)
639}
640
641fn shoelace_area(pts: &[(f64, f64)]) -> f64 {
647 let n = pts.len();
648 if n < 3 {
649 return 0.0;
650 }
651 let mut area = 0.0f64;
652 for i in 0..n {
653 let j = (i + 1) % n;
654 area += pts[i].0 * pts[j].1;
655 area -= pts[j].0 * pts[i].1;
656 }
657 area / 2.0
658}
659
660fn sub_matrix_3x3(s: &[[f64; 6]; 6], row_off: usize, col_off: usize) -> [[f64; 3]; 3] {
663 let mut m = [[0.0f64; 3]; 3];
664 for i in 0..3 {
665 for j in 0..3 {
666 m[i][j] = s[row_off + i][col_off + j];
667 }
668 }
669 m
670}
671
672fn mat3_det(m: &[[f64; 3]; 3]) -> f64 {
673 m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
674 - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
675 + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
676}
677
678fn mat3_inv(m: &[[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
679 let det = mat3_det(m);
680 if det.abs() < 1e-14 {
681 return None;
682 }
683 let inv_det = 1.0 / det;
684 let mut inv = [[0.0f64; 3]; 3];
685 inv[0][0] = (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det;
686 inv[0][1] = (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det;
687 inv[0][2] = (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det;
688 inv[1][0] = (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det;
689 inv[1][1] = (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det;
690 inv[1][2] = (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det;
691 inv[2][0] = (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det;
692 inv[2][1] = (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det;
693 inv[2][2] = (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det;
694 Some(inv)
695}
696
697fn mat3_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
698 let mut c = [[0.0f64; 3]; 3];
699 for i in 0..3 {
700 for j in 0..3 {
701 for k in 0..3 {
702 c[i][j] += a[i][k] * b[k][j];
703 }
704 }
705 }
706 c
707}
708
709fn mat3_sub(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
710 let mut c = [[0.0f64; 3]; 3];
711 for i in 0..3 {
712 for j in 0..3 {
713 c[i][j] = a[i][j] - b[i][j];
714 }
715 }
716 c
717}
718
719fn mat3_mul_vec(m: &[[f64; 3]; 3], v: &[f64; 3]) -> [f64; 3] {
720 let mut out = [0.0f64; 3];
721 for i in 0..3 {
722 for j in 0..3 {
723 out[i] += m[i][j] * v[j];
724 }
725 }
726 out
727}
728
729fn mat3_eigen(m: &[[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
733 let p = m[0][0] + m[1][1] + m[2][2]; let q = m[0][0] * m[1][1] + m[1][1] * m[2][2] + m[0][0] * m[2][2]
736 - m[0][1] * m[1][0]
737 - m[1][2] * m[2][1]
738 - m[0][2] * m[2][0];
739 let r = mat3_det(m);
740
741 let a = q - p * p / 3.0;
746 let b = -2.0 * p * p * p / 27.0 + p * q / 3.0 - r;
747
748 let disc = (b / 2.0).powi(2) + (a / 3.0).powi(3);
749
750 let eigenvalues: [f64; 3] = if disc >= 0.0 {
751 let sqrt_disc = disc.sqrt();
753 let u = cbrt(-b / 2.0 + sqrt_disc);
754 let v = cbrt(-b / 2.0 - sqrt_disc);
755 let t0 = u + v;
756 let t1 = -(u + v) / 2.0;
757 [t0 + p / 3.0, t1 + p / 3.0, t1 + p / 3.0]
758 } else {
759 let rho = (-a / 3.0).sqrt().max(1e-30);
761 let theta = ((-b / 2.0) / (rho * rho * rho)).clamp(-1.0, 1.0).acos();
762 [
763 2.0 * rho * (theta / 3.0).cos() + p / 3.0,
764 2.0 * rho * ((theta + 2.0 * std::f64::consts::PI) / 3.0).cos() + p / 3.0,
765 2.0 * rho * ((theta + 4.0 * std::f64::consts::PI) / 3.0).cos() + p / 3.0,
766 ]
767 };
768
769 let mut evecs = [[0.0f64; 3]; 3];
771 for (i, &lam) in eigenvalues.iter().enumerate() {
772 let a_mat = [
773 [m[0][0] - lam, m[0][1], m[0][2]],
774 [m[1][0], m[1][1] - lam, m[1][2]],
775 [m[2][0], m[2][1], m[2][2] - lam],
776 ];
777 let r0 = a_mat[0];
779 let r1 = a_mat[1];
780 let r2 = a_mat[2];
781 let candidates = [cross3(r0, r1), cross3(r1, r2), cross3(r0, r2)];
782 let best = candidates
783 .iter()
784 .copied()
785 .max_by(|x, y| {
786 let nx = x[0] * x[0] + x[1] * x[1] + x[2] * x[2];
787 let ny = y[0] * y[0] + y[1] * y[1] + y[2] * y[2];
788 nx.partial_cmp(&ny).unwrap_or(std::cmp::Ordering::Equal)
789 })
790 .unwrap_or([1.0, 0.0, 0.0]);
791 let norm = (best[0] * best[0] + best[1] * best[1] + best[2] * best[2])
792 .sqrt()
793 .max(1e-30);
794 evecs[i] = [best[0] / norm, best[1] / norm, best[2] / norm];
795 }
796
797 (eigenvalues, evecs)
798}
799
800fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
801 [
802 a[1] * b[2] - a[2] * b[1],
803 a[2] * b[0] - a[0] * b[2],
804 a[0] * b[1] - a[1] * b[0],
805 ]
806}
807
808fn cbrt(x: f64) -> f64 {
809 if x >= 0.0 {
810 x.powf(1.0 / 3.0)
811 } else {
812 -(-x).powf(1.0 / 3.0)
813 }
814}
815
816fn conic_to_ellipse(c: [f64; 6]) -> NdimageResult<(f64, f64, f64, f64, f64)> {
818 let (a, b, cc, d, e, f) = (c[0], c[1], c[2], c[3], c[4], c[5]);
819
820 let denom = 4.0 * a * cc - b * b;
822 if denom.abs() < 1e-14 {
823 return Err(NdimageError::ComputationError(
824 "ellipse_fit: degenerate conic (not an ellipse)".to_string(),
825 ));
826 }
827 let cx = (b * e - 2.0 * cc * d) / denom;
828 let cy = (b * d - 2.0 * a * e) / denom;
829
830 let f_prime = a * cx * cx + b * cx * cy + cc * cy * cy + d * cx + e * cy + f;
832
833 let m_11 = a;
834 let m_12 = b / 2.0;
835 let m_22 = cc;
836
837 let eig_diff = ((m_11 - m_22).powi(2) + m_12 * m_12 * 4.0).sqrt();
838 let lam1 = (m_11 + m_22 + eig_diff) / 2.0;
839 let lam2 = (m_11 + m_22 - eig_diff) / 2.0;
840
841 if -f_prime / lam1 <= 0.0 || -f_prime / lam2 <= 0.0 {
842 return Err(NdimageError::ComputationError(
843 "ellipse_fit: conic is not a real ellipse".to_string(),
844 ));
845 }
846
847 let axis1 = (-f_prime / lam1).sqrt();
848 let axis2 = (-f_prime / lam2).sqrt();
849 let (semi_major, semi_minor) = if axis1 >= axis2 {
850 (axis1, axis2)
851 } else {
852 (axis2, axis1)
853 };
854
855 let angle = if (m_12).abs() < 1e-14 && m_11 <= m_22 {
857 0.0
858 } else if (m_12).abs() < 1e-14 {
859 std::f64::consts::PI / 2.0
860 } else {
861 ((lam1 - m_11) / m_12).atan()
862 };
863
864 Ok((cx, cy, semi_major, semi_minor, angle))
865}
866
867#[cfg(test)]
872mod tests {
873 use super::*;
874 use scirs2_core::ndarray::Array2;
875
876 #[test]
877 fn test_convex_hull_square() {
878 let pts = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.5, 0.5)];
879 let hull =
880 convex_hull_2d(&pts).expect("convex_hull_2d should succeed for square point set");
881 assert_eq!(hull.len(), 4);
882 }
883
884 #[test]
885 fn test_convex_hull_collinear() {
886 let pts = vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)];
887 let hull =
888 convex_hull_2d(&pts).expect("convex_hull_2d should succeed for collinear points");
889 assert!(hull.len() >= 2);
891 }
892
893 #[test]
894 fn test_convex_hull_empty() {
895 assert!(convex_hull_2d(&[]).is_err());
896 }
897
898 #[test]
899 fn test_contour_extraction_filled_square() {
900 let mut img = Array2::<bool>::default((8, 8));
901 for r in 2..6 {
902 for c in 2..6 {
903 img[[r, c]] = true;
904 }
905 }
906 let contours = contour_extraction(&img.view())
907 .expect("contour_extraction should succeed on valid image");
908 assert_eq!(contours.len(), 1);
909 assert!(!contours[0].is_empty());
910 }
911
912 #[test]
913 fn test_contour_extraction_empty_image() {
914 let img = Array2::<bool>::default((5, 5));
915 let contours = contour_extraction(&img.view())
916 .expect("contour_extraction should succeed on empty image");
917 assert!(contours.is_empty());
918 }
919
920 #[test]
921 fn test_shape_descriptors_square() {
922 let contour: Vec<(usize, usize)> = vec![
924 (0, 0),
925 (0, 1),
926 (0, 2),
927 (0, 3),
928 (1, 3),
929 (2, 3),
930 (3, 3),
931 (3, 2),
932 (3, 1),
933 (3, 0),
934 (2, 0),
935 (1, 0),
936 ];
937 let d = shape_descriptors(&contour)
938 .expect("shape_descriptors should succeed for valid square contour");
939 assert!(d.area > 0.0);
940 assert!(d.perimeter > 0.0);
941 assert!(d.circularity > 0.0);
942 assert!(d.circularity <= 1.0 + 1e-9);
943 assert!(d.solidity > 0.0 && d.solidity <= 1.0);
944 }
945
946 #[test]
947 fn test_shape_descriptors_too_few() {
948 assert!(shape_descriptors(&[(0, 0), (1, 1)]).is_err());
949 }
950
951 #[test]
952 fn test_minimum_bounding_rectangle_axis_aligned() {
953 let pts = vec![(0.0, 0.0), (4.0, 0.0), (4.0, 3.0), (0.0, 3.0)];
954 let rect = minimum_bounding_rectangle(&pts)
955 .expect("minimum_bounding_rectangle should succeed for axis-aligned rectangle");
956 let area = {
957 let (dx1, dy1) = (rect[1].0 - rect[0].0, rect[1].1 - rect[0].1);
958 let (dx2, dy2) = (rect[3].0 - rect[0].0, rect[3].1 - rect[0].1);
959 let l1 = (dx1 * dx1 + dy1 * dy1).sqrt();
960 let l2 = (dx2 * dx2 + dy2 * dy2).sqrt();
961 l1 * l2
962 };
963 assert!((area - 12.0).abs() < 0.5, "area = {area}");
964 }
965
966 #[test]
967 fn test_ellipse_fit_circle() {
968 let pts: Vec<(f64, f64)> = (0..36)
969 .map(|i| {
970 let t = i as f64 * std::f64::consts::PI / 18.0;
971 (t.cos(), t.sin())
972 })
973 .collect();
974 let (cx, cy, a, b, _angle) =
975 ellipse_fit(&pts).expect("ellipse_fit should succeed for circular point set");
976 assert!((cx).abs() < 0.05, "cx={cx}");
977 assert!((cy).abs() < 0.05, "cy={cy}");
978 assert!((a - 1.0).abs() < 0.05, "a={a}");
979 assert!((b - 1.0).abs() < 0.05, "b={b}");
980 }
981}