Skip to main content

scirs2_ndimage/
shape_analysis.rs

1//! Shape analysis: convex hull, contour extraction, shape descriptors, ellipse fitting,
2//! and minimum bounding rectangle.
3//!
4//! # Overview
5//!
6//! This module provides geometric shape analysis tools operating on 2-D binary images
7//! and point clouds:
8//!
9//! - **Convex hull** (Graham scan) for arbitrary 2-D point sets.
10//! - **Contour extraction** using Moore-neighbourhood (8-connectivity) tracing.
11//! - **Shape descriptors**: area, perimeter, circularity, eccentricity, aspect ratio,
12//!   extent, solidity, convexity.
13//! - **Ellipse fitting** via the algebraic least-squares method (Fitzgibbon et al.).
14//! - **Minimum bounding rectangle** using the rotating calipers technique.
15
16use crate::error::{NdimageError, NdimageResult};
17use scirs2_core::ndarray::{Array2, ArrayView2};
18
19// ─────────────────────────────────────────────────────────────────────────────
20// Convex Hull – Graham Scan
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// Compute the convex hull of a 2-D point set using the Graham scan algorithm.
24///
25/// Returns the hull vertices in counter-clockwise order.  If fewer than 3 distinct
26/// points are supplied the function returns the (deduplicated) input sorted
27/// lexicographically so callers always receive a valid polygon-like result.
28///
29/// # Arguments
30///
31/// * `points` – Slice of (x, y) coordinates.
32///
33/// # Errors
34///
35/// Returns [`NdimageError::InvalidInput`] when the input is empty.
36///
37/// # Example
38///
39/// ```
40/// use scirs2_ndimage::shape_analysis::convex_hull_2d;
41///
42/// let pts = vec![(0.0_f64, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.5, 0.5)];
43/// let hull = convex_hull_2d(&pts).unwrap();
44/// assert_eq!(hull.len(), 4);
45/// ```
46pub 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    // Deduplicate
54    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    // Cross product of vectors OA and OB
67    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    // Lower hull
75    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    // Upper hull
83    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(); // Remove last point (same as first)
93    Ok(hull)
94}
95
96// ─────────────────────────────────────────────────────────────────────────────
97// Contour Extraction – Moore Neighbourhood
98// ─────────────────────────────────────────────────────────────────────────────
99
100/// Extract outer contours from a binary image.
101///
102/// Returns one contour per 4-connected foreground component.  Each contour is
103/// the ordered sequence of boundary pixels (those with at least one 8-connected
104/// background neighbour), collected by BFS through the 8-connected boundary
105/// ring of the component.
106///
107/// # Arguments
108///
109/// * `binary` – 2-D boolean array where `true` denotes the foreground.
110///
111/// # Example
112///
113/// ```
114/// use scirs2_ndimage::shape_analysis::contour_extraction;
115/// use scirs2_core::ndarray::Array2;
116///
117/// let mut img = Array2::<bool>::default((6, 6));
118/// for r in 1..5 { for c in 1..5 { img[[r, c]] = true; } }
119/// let contours = contour_extraction(&img.view()).unwrap();
120/// assert_eq!(contours.len(), 1);
121/// assert!(!contours[0].is_empty());
122/// ```
123pub 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    // 8-connected neighbourhood offsets
134    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    // 4-connected neighbourhood offsets
145    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    // is_boundary: true if foreground pixel has at least one background 8-neighbour
152    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    // Mark which foreground pixels have been fully processed
161    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            // BFS to collect all 4-connected foreground pixels of this component
171            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            // From the component, collect boundary pixels and order them by
193            // BFS traversal through the 8-connected boundary ring.
194            let mut boundary_visited = Array2::<bool>::default((rows, cols));
195            let mut contour: Vec<(usize, usize)> = Vec::new();
196
197            // Find the first boundary pixel (top-left scanning order)
198            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// ─────────────────────────────────────────────────────────────────────────────
232// Shape Descriptors
233// ─────────────────────────────────────────────────────────────────────────────
234
235/// Collection of scalar shape descriptors computed from a contour.
236#[derive(Debug, Clone)]
237pub struct ShapeDescriptors {
238    /// Area enclosed by the contour (shoelace formula, pixels²).
239    pub area: f64,
240    /// Perimeter (sum of Euclidean distances between consecutive contour points).
241    pub perimeter: f64,
242    /// Circularity: 4π·area / perimeter².  Perfect circle → 1.
243    pub circularity: f64,
244    /// Eccentricity of the best-fit ellipse (0 = circle, 1 = line).
245    pub eccentricity: f64,
246    /// Aspect ratio: major_axis / minor_axis of the bounding box.
247    pub aspect_ratio: f64,
248    /// Extent: area / bounding_box_area.
249    pub extent: f64,
250    /// Solidity: area / convex_hull_area.
251    pub solidity: f64,
252    /// Convexity: convex_hull_perimeter / perimeter.
253    pub convexity: f64,
254}
255
256/// Compute shape descriptors for a contour given as (row, col) pixel coordinates.
257///
258/// # Arguments
259///
260/// * `contour` – Ordered list of boundary pixels (row, col).
261///
262/// # Errors
263///
264/// Returns [`NdimageError::InvalidInput`] when the contour has fewer than 3 points.
265///
266/// # Example
267///
268/// ```
269/// use scirs2_ndimage::shape_analysis::shape_descriptors;
270///
271/// let contour: Vec<(usize, usize)> = vec![(0,0),(0,1),(0,2),(1,2),(2,2),(2,1),(2,0),(1,0)];
272/// let d = shape_descriptors(&contour).unwrap();
273/// assert!(d.area > 0.0);
274/// assert!(d.circularity > 0.0 && d.circularity <= 1.0 + 1e-6);
275/// ```
276pub 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)) // (x, y)
286        .collect();
287
288    // --- Area (shoelace) ---
289    let area = shoelace_area(&pts_f).abs();
290
291    // --- Perimeter ---
292    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    // --- Circularity ---
301    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    // --- Bounding box ---
308    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    // --- Aspect ratio ---
322    let aspect_ratio = if bbox_h > 1e-12 { bbox_w / bbox_h } else { 1.0 };
323
324    // --- Extent ---
325    let extent = area / bbox_area;
326
327    // --- Convex hull ---
328    let hull = convex_hull_2d(&pts_f).unwrap_or_default();
329
330    // Convex hull area
331    let hull_area = if hull.len() >= 3 {
332        shoelace_area(&hull).abs()
333    } else {
334        area
335    };
336
337    // Convex hull perimeter
338    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    // --- Solidity ---
350    let solidity = if hull_area > 1e-12 {
351        (area / hull_area).min(1.0)
352    } else {
353        1.0
354    };
355
356    // --- Convexity ---
357    let convexity = if perimeter > 1e-12 {
358        (hull_perim / perimeter).min(1.0)
359    } else {
360        1.0
361    };
362
363    // --- Eccentricity via second-order central moments ---
364    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
405// ─────────────────────────────────────────────────────────────────────────────
406// Ellipse Fitting (Fitzgibbon algebraic least-squares)
407// ─────────────────────────────────────────────────────────────────────────────
408
409/// Fit an ellipse to a set of 2-D points using the Fitzgibbon–Pilu–Fisher
410/// algebraic least-squares method.
411///
412/// Returns `(cx, cy, a, b, angle_rad)` where:
413/// - `(cx, cy)` is the ellipse centre,
414/// - `a` ≥ `b` are the semi-axes,
415/// - `angle_rad` is the tilt of the major axis (radians, measured from the
416///    positive x-axis counter-clockwise).
417///
418/// # Arguments
419///
420/// * `points` – At least 5 (x, y) sample points.
421///
422/// # Errors
423///
424/// - [`NdimageError::InvalidInput`] if fewer than 5 points are given.
425/// - [`NdimageError::ComputationError`] if the SVD / eigenvalue solver fails.
426///
427/// # Example
428///
429/// ```
430/// use scirs2_ndimage::shape_analysis::ellipse_fit;
431/// use std::f64::consts::PI;
432///
433/// // Generate points on a 3×2 axis-aligned ellipse
434/// let pts: Vec<(f64, f64)> = (0..36)
435///     .map(|i| {
436///         let t = i as f64 * PI / 18.0;
437///         (3.0 * t.cos(), 2.0 * t.sin())
438///     })
439///     .collect();
440/// let (cx, cy, a, b, _angle) = ellipse_fit(&pts).unwrap();
441/// assert!((cx).abs() < 0.1);
442/// assert!((cy).abs() < 0.1);
443/// assert!((a - 3.0).abs() < 0.1);
444/// assert!((b - 2.0).abs() < 0.1);
445/// ```
446pub 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    // Build the design matrix D (n × 6), columns: x², xy, y², x, y, 1
456    // Scatter matrix S = D'D
457    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    // Constraint matrix C (only C[0][2], C[1][1], C[2][0] are non-zero for the
468    // "bookstein" ellipse-specific constraint 4ac - b² = 1)
469    // We use the eigenvector approach: solve (S⁻¹ C) v = λ v, pick the eigenvector
470    // with the smallest positive eigenvalue.
471    //
472    // For numerical stability we use the reduced 3×3 system (Bookstein constraint).
473    // The Fitzgibbon direct method:
474    //   C = [[0,0,2],[0,-1,0],[2,0,0]]  (top-left 3×3) and zeros elsewhere.
475
476    // Partition S into blocks
477    // S = [S11 S12; S21 S22]  where S11, S12, S21, S22 are 3×3
478    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    // Reduced system M = C11^{-1} (S11 - S12 S22^{-1} S21)
488    let tmp = mat3_mul(&s12, &mat3_mul(&s22_inv, &s21));
489    let m_raw = mat3_sub(&s11, &tmp);
490
491    // C11 = [[0,0,2],[0,-1,0],[2,0,0]], C11^{-1} = [[0,0,0.5],[0,-1,0],[0.5,0,0]]
492    // M = C11^{-1} * m_raw
493    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    // Find eigenvalues / eigenvectors of M (3×3 via characteristic polynomial)
500    let (eigenvalues, eigenvectors) = mat3_eigen(&m);
501
502    // Pick eigenvector with smallest positive eigenvalue where 4ac - b² > 0
503    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    // Recover full 6-vector: [a, b, c, d, e, f]
524    // a2 = -S22^{-1} S21 a1
525    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    // Convert general conic Ax²+Bxy+Cy²+Dx+Ey+F=0 to geometric parameters
536    conic_to_ellipse(coeffs)
537}
538
539// ─────────────────────────────────────────────────────────────────────────────
540// Minimum Bounding Rectangle – Rotating Calipers
541// ─────────────────────────────────────────────────────────────────────────────
542
543/// Compute the minimum-area bounding rectangle of a 2-D point set.
544///
545/// Returns the four corners of the rectangle in counter-clockwise order.
546///
547/// Uses the rotating calipers algorithm on the convex hull.
548///
549/// # Arguments
550///
551/// * `points` – At least one (x, y) point.
552///
553/// # Errors
554///
555/// Returns [`NdimageError::InvalidInput`] when the input is empty.
556///
557/// # Example
558///
559/// ```
560/// use scirs2_ndimage::shape_analysis::minimum_bounding_rectangle;
561///
562/// let pts = vec![(0.0_f64, 0.0), (4.0, 0.0), (4.0, 3.0), (0.0, 3.0)];
563/// let rect = minimum_bounding_rectangle(&pts).unwrap();
564/// // Area should be close to 12
565/// let area = {
566///     let (dx1, dy1) = (rect[1].0 - rect[0].0, rect[1].1 - rect[0].1);
567///     let (dx2, dy2) = (rect[3].0 - rect[0].0, rect[3].1 - rect[0].1);
568///     let l1 = (dx1*dx1 + dy1*dy1).sqrt();
569///     let l2 = (dx2*dx2 + dy2*dy2).sqrt();
570///     l1 * l2
571/// };
572/// assert!((area - 12.0).abs() < 0.5);
573/// ```
574pub 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    // Rotating calipers: iterate over each edge of the hull
597    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; // unit edge vector
605        let uy = ey / len_e;
606
607        // Project all hull points onto (ux, uy) and its perpendicular (-uy, ux)
608        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            // Reconstruct four corners from (u,v) back to (x,y)
628            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
641// ─────────────────────────────────────────────────────────────────────────────
642// Internal helpers
643// ─────────────────────────────────────────────────────────────────────────────
644
645/// Shoelace formula for signed polygon area.
646fn 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
660// 3×3 matrix helpers (row-major arrays)
661
662fn 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
729/// Compute eigenvalues and eigenvectors of a real 3×3 matrix using the
730/// characteristic polynomial (Cardano) + inverse iteration refinement.
731/// Returns `(eigenvalues, eigenvectors)` both of length 3.
732fn mat3_eigen(m: &[[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
733    // Characteristic polynomial: λ³ - p·λ² + q·λ - r = 0
734    let p = m[0][0] + m[1][1] + m[2][2]; // trace
735    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    // Depress the cubic λ³ − p·λ² + q·λ − r = 0 via λ = t + p/3.
742    // The resulting depressed cubic is t³ + a·t + b = 0, with:
743    //   a = q − p²/3
744    //   b = −2p³/27 + p·q/3 − r
745    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        // One or two repeated real roots (or complex pair – degenerate case)
752        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        // Three distinct real roots
760        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    // Compute eigenvectors via (M - λI) null-space (cross-product method)
770    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        // Take cross products of pairs of rows and pick the largest
778        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
816/// Convert general conic coefficients [A, B, C, D, E, F] to ellipse geometry.
817fn 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    // Centre
821    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    // Axis-aligned form
831    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    // Angle of major axis
856    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// ─────────────────────────────────────────────────────────────────────────────
868// Tests
869// ─────────────────────────────────────────────────────────────────────────────
870
871#[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        // Collinear – degenerate hull
890        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        // 4×4 filled square: use the 8-pixel contour
923        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}