use crate::error::{NdimageError, NdimageResult};
use scirs2_core::ndarray::{Array2, ArrayView2};
pub fn convex_hull_2d(points: &[(f64, f64)]) -> NdimageResult<Vec<(f64, f64)>> {
if points.is_empty() {
return Err(NdimageError::InvalidInput(
"convex_hull_2d: point set is empty".to_string(),
));
}
let mut pts: Vec<(f64, f64)> = points.to_vec();
pts.sort_by(|a, b| {
a.0.partial_cmp(&b.0)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
});
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-12 && (a.1 - b.1).abs() < 1e-12);
if pts.len() < 3 {
return Ok(pts);
}
let cross = |o: (f64, f64), a: (f64, f64), b: (f64, f64)| -> f64 {
(a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0)
};
let n = pts.len();
let mut hull: Vec<(f64, f64)> = Vec::with_capacity(2 * n);
for &p in &pts {
while hull.len() >= 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0.0 {
hull.pop();
}
hull.push(p);
}
let lower_len = hull.len() + 1;
for &p in pts.iter().rev() {
while hull.len() >= lower_len && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0.0 {
hull.pop();
}
hull.push(p);
}
hull.pop(); Ok(hull)
}
pub fn contour_extraction(
binary: &ArrayView2<bool>,
) -> NdimageResult<Vec<Vec<(usize, usize)>>> {
let rows = binary.nrows();
let cols = binary.ncols();
if rows == 0 || cols == 0 {
return Ok(Vec::new());
}
const MOORE: [(i32, i32); 8] = [
(0, 1),
(1, 1),
(1, 0),
(1, -1),
(0, -1),
(-1, -1),
(-1, 0),
(-1, 1),
];
let in_bounds = |r: i32, c: i32| -> bool {
r >= 0 && r < rows as i32 && c >= 0 && c < cols as i32
};
let get = |r: usize, c: usize| -> bool {
*binary.get((r, c)).unwrap_or(&false)
};
let mut visited = Array2::<bool>::default((rows, cols));
let mut contours: Vec<Vec<(usize, usize)>> = Vec::new();
for start_r in 0..rows {
for start_c in 0..cols {
if !get(start_r, start_c) || visited[[start_r, start_c]] {
continue;
}
let is_boundary = MOORE.iter().any(|(dr, dc)| {
let nr = start_r as i32 + dr;
let nc = start_c as i32 + dc;
!in_bounds(nr, nc) || !get(nr as usize, nc as usize)
});
if !is_boundary {
visited[[start_r, start_c]] = true;
continue;
}
let mut contour: Vec<(usize, usize)> = Vec::new();
let start = (start_r, start_c);
let mut current = start;
let mut entry_dir: usize = 4;
loop {
contour.push(current);
visited[[current.0, current.1]] = true;
let start_search = (entry_dir + 5) % 8;
let mut found = false;
let mut next = current;
let mut next_entry = 0usize;
for k in 0..8usize {
let dir = (start_search + k) % 8;
let (dr, dc) = MOORE[dir];
let nr = current.0 as i32 + dr;
let nc = current.1 as i32 + dc;
if in_bounds(nr, nc) && get(nr as usize, nc as usize) {
next = (nr as usize, nc as usize);
next_entry = (dir + 4) % 8;
found = true;
break;
}
}
if !found {
break;
}
entry_dir = next_entry;
current = next;
if current == start && entry_dir == (4 + 4) % 8 {
break;
}
if current == start {
break;
}
if contour.len() > rows * cols {
break;
}
}
if !contour.is_empty() {
contours.push(contour);
}
}
}
Ok(contours)
}
#[derive(Debug, Clone)]
pub struct ShapeDescriptors {
pub area: f64,
pub perimeter: f64,
pub circularity: f64,
pub eccentricity: f64,
pub aspect_ratio: f64,
pub extent: f64,
pub solidity: f64,
pub convexity: f64,
}
pub fn shape_descriptors(contour: &[(usize, usize)]) -> NdimageResult<ShapeDescriptors> {
if contour.len() < 3 {
return Err(NdimageError::InvalidInput(
"shape_descriptors: contour must have at least 3 points".to_string(),
));
}
let pts_f: Vec<(f64, f64)> = contour
.iter()
.map(|&(r, c)| (c as f64, r as f64)) .collect();
let area = shoelace_area(&pts_f).abs();
let n = pts_f.len();
let perimeter: f64 = pts_f
.iter()
.zip(pts_f.iter().cycle().skip(1))
.take(n)
.map(|(&(x0, y0), &(x1, y1))| ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt())
.sum();
let circularity = if perimeter > 1e-12 {
4.0 * std::f64::consts::PI * area / perimeter.powi(2)
} else {
0.0
};
let (min_x, max_x, min_y, max_y) = pts_f.iter().fold(
(f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY),
|(mnx, mxx, mny, mxy), &(x, y)| {
(mnx.min(x), mxx.max(x), mny.min(y), mxy.max(y))
},
);
let bbox_w = (max_x - min_x).max(1.0);
let bbox_h = (max_y - min_y).max(1.0);
let bbox_area = bbox_w * bbox_h;
let aspect_ratio = if bbox_h > 1e-12 {
bbox_w / bbox_h
} else {
1.0
};
let extent = area / bbox_area;
let hull = convex_hull_2d(&pts_f).unwrap_or_default();
let hull_area = if hull.len() >= 3 {
shoelace_area(&hull).abs()
} else {
area
};
let hull_perim: f64 = if hull.len() >= 2 {
let hn = hull.len();
hull.iter()
.zip(hull.iter().cycle().skip(1))
.take(hn)
.map(|(&(x0, y0), &(x1, y1))| ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt())
.sum()
} else {
perimeter
};
let solidity = if hull_area > 1e-12 {
(area / hull_area).min(1.0)
} else {
1.0
};
let convexity = if perimeter > 1e-12 {
(hull_perim / perimeter).min(1.0)
} else {
1.0
};
let (cx, cy) = pts_f.iter().fold((0.0f64, 0.0f64), |(ax, ay), &(x, y)| {
(ax + x, ay + y)
});
let cx = cx / n as f64;
let cy = cy / n as f64;
let (mu20, mu02, mu11) = pts_f.iter().fold((0.0f64, 0.0f64, 0.0f64), |(m20, m02, m11), &(x, y)| {
let dx = x - cx;
let dy = y - cy;
(m20 + dx * dx, m02 + dy * dy, m11 + dx * dy)
});
let mu20 = mu20 / n as f64;
let mu02 = mu02 / n as f64;
let mu11 = mu11 / n as f64;
let diff = mu20 - mu02;
let discriminant = (diff * diff + 4.0 * mu11 * mu11).sqrt();
let lambda1 = (mu20 + mu02 + discriminant) / 2.0;
let lambda2 = ((mu20 + mu02 - discriminant) / 2.0).max(0.0);
let eccentricity = if lambda1 > 1e-12 {
(1.0 - lambda2 / lambda1).sqrt()
} else {
0.0
};
Ok(ShapeDescriptors {
area,
perimeter,
circularity: circularity.min(1.0 + 1e-9),
eccentricity,
aspect_ratio,
extent: extent.min(1.0),
solidity,
convexity,
})
}
pub fn ellipse_fit(points: &[(f64, f64)]) -> NdimageResult<(f64, f64, f64, f64, f64)> {
if points.len() < 5 {
return Err(NdimageError::InvalidInput(
"ellipse_fit: at least 5 points are required".to_string(),
));
}
let n = points.len();
let mut s = [[0.0f64; 6]; 6];
for &(x, y) in points {
let row = [x * x, x * y, y * y, x, y, 1.0];
for i in 0..6 {
for j in 0..6 {
s[i][j] += row[i] * row[j];
}
}
}
let s11 = sub_matrix_3x3(&s, 0, 0);
let s12 = sub_matrix_3x3(&s, 0, 3);
let s21 = sub_matrix_3x3(&s, 3, 0);
let s22 = sub_matrix_3x3(&s, 3, 3);
let s22_inv = mat3_inv(&s22).ok_or_else(|| {
NdimageError::ComputationError("ellipse_fit: singular scatter sub-matrix".to_string())
})?;
let tmp = mat3_mul(&s12, &mat3_mul(&s22_inv, &s21));
let m_raw = mat3_sub(&s11, &tmp);
let m = [
[
0.5 * m_raw[2][0],
0.5 * m_raw[2][1],
0.5 * m_raw[2][2],
],
[
-m_raw[1][0],
-m_raw[1][1],
-m_raw[1][2],
],
[
0.5 * m_raw[0][0],
0.5 * m_raw[0][1],
0.5 * m_raw[0][2],
],
];
let (eigenvalues, eigenvectors) = mat3_eigen(&m);
let mut best: Option<[f64; 3]> = None;
let mut best_eval = f64::INFINITY;
for i in 0..3 {
let ev = eigenvalues[i];
if ev.is_finite() && ev > 1e-15 {
let v = eigenvectors[i];
let cond = 4.0 * v[0] * v[2] - v[1] * v[1];
if cond > 0.0 && ev < best_eval {
best_eval = ev;
best = Some(v);
}
}
}
let a1 = best.ok_or_else(|| {
NdimageError::ComputationError(
"ellipse_fit: no valid ellipse eigenvector found".to_string(),
)
})?;
let neg_s22inv_s21_a1 = mat3_mul_vec(&mat3_mul(&s22_inv, &s21), &a1);
let coeffs = [
a1[0],
a1[1],
a1[2],
-neg_s22inv_s21_a1[0],
-neg_s22inv_s21_a1[1],
-neg_s22inv_s21_a1[2],
];
conic_to_ellipse(coeffs)
}
pub fn minimum_bounding_rectangle(points: &[(f64, f64)]) -> NdimageResult<[(f64, f64); 4]> {
if points.is_empty() {
return Err(NdimageError::InvalidInput(
"minimum_bounding_rectangle: point set is empty".to_string(),
));
}
let hull = convex_hull_2d(points)?;
if hull.len() == 1 {
let p = hull[0];
return Ok([p, p, p, p]);
}
if hull.len() == 2 {
let (x0, y0) = hull[0];
let (x1, y1) = hull[1];
return Ok([(x0, y0), (x1, y1), (x1, y1), (x0, y0)]);
}
let n = hull.len();
let mut min_area = f64::INFINITY;
let mut best_rect = [(0.0f64, 0.0f64); 4];
for i in 0..n {
let j = (i + 1) % n;
let (ex, ey) = (hull[j].0 - hull[i].0, hull[j].1 - hull[i].1);
let len_e = (ex * ex + ey * ey).sqrt();
if len_e < 1e-12 {
continue;
}
let ux = ex / len_e; let uy = ey / len_e;
let (mut min_u, mut max_u, mut min_v, mut max_v) =
(f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
for &(px, py) in &hull {
let u = px * ux + py * uy;
let v = -px * uy + py * ux;
min_u = min_u.min(u);
max_u = max_u.max(u);
min_v = min_v.min(v);
max_v = max_v.max(v);
}
let area = (max_u - min_u) * (max_v - min_v);
if area < min_area {
min_area = area;
let corner = |u: f64, v: f64| -> (f64, f64) {
(u * ux - v * uy, u * uy + v * ux)
};
best_rect = [
corner(min_u, min_v),
corner(max_u, min_v),
corner(max_u, max_v),
corner(min_u, max_v),
];
}
}
Ok(best_rect)
}
fn shoelace_area(pts: &[(f64, f64)]) -> f64 {
let n = pts.len();
if n < 3 {
return 0.0;
}
let mut area = 0.0f64;
for i in 0..n {
let j = (i + 1) % n;
area += pts[i].0 * pts[j].1;
area -= pts[j].0 * pts[i].1;
}
area / 2.0
}
fn sub_matrix_3x3(s: &[[f64; 6]; 6], row_off: usize, col_off: usize) -> [[f64; 3]; 3] {
let mut m = [[0.0f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
m[i][j] = s[row_off + i][col_off + j];
}
}
m
}
fn mat3_det(m: &[[f64; 3]; 3]) -> f64 {
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
fn mat3_inv(m: &[[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
let det = mat3_det(m);
if det.abs() < 1e-14 {
return None;
}
let inv_det = 1.0 / det;
let mut inv = [[0.0f64; 3]; 3];
inv[0][0] = (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det;
inv[0][1] = (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det;
inv[0][2] = (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det;
inv[1][0] = (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det;
inv[1][1] = (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det;
inv[1][2] = (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det;
inv[2][0] = (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det;
inv[2][1] = (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det;
inv[2][2] = (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det;
Some(inv)
}
fn mat3_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut c = [[0.0f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
for k in 0..3 {
c[i][j] += a[i][k] * b[k][j];
}
}
}
c
}
fn mat3_sub(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut c = [[0.0f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
c[i][j] = a[i][j] - b[i][j];
}
}
c
}
fn mat3_mul_vec(m: &[[f64; 3]; 3], v: &[f64; 3]) -> [f64; 3] {
let mut out = [0.0f64; 3];
for i in 0..3 {
for j in 0..3 {
out[i] += m[i][j] * v[j];
}
}
out
}
fn mat3_eigen(m: &[[f64; 3]; 3]) -> ([f64; 3], [[f64; 3]; 3]) {
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]
- m[0][1] * m[1][0]
- m[1][2] * m[2][1]
- m[0][2] * m[2][0];
let r = mat3_det(m);
let a = q - p * p / 3.0;
let b = 2.0 * p * p * p / 27.0 - p * q / 3.0 + r;
let disc = (b / 2.0).powi(2) + (a / 3.0).powi(3);
let eigenvalues: [f64; 3] = if disc >= 0.0 {
let sqrt_disc = disc.sqrt();
let u = cbrt(-b / 2.0 + sqrt_disc);
let v = cbrt(-b / 2.0 - sqrt_disc);
let t0 = u + v;
let t1 = -(u + v) / 2.0;
[t0 + p / 3.0, t1 + p / 3.0, t1 + p / 3.0]
} else {
let rho = (-a / 3.0).sqrt().max(1e-30);
let theta = ((-b / 2.0) / (rho * rho * rho)).clamp(-1.0, 1.0).acos();
[
2.0 * rho * (theta / 3.0).cos() + p / 3.0,
2.0 * rho * ((theta + 2.0 * std::f64::consts::PI) / 3.0).cos() + p / 3.0,
2.0 * rho * ((theta + 4.0 * std::f64::consts::PI) / 3.0).cos() + p / 3.0,
]
};
let mut evecs = [[0.0f64; 3]; 3];
for (i, &lam) in eigenvalues.iter().enumerate() {
let a_mat = [
[m[0][0] - lam, m[0][1], m[0][2]],
[m[1][0], m[1][1] - lam, m[1][2]],
[m[2][0], m[2][1], m[2][2] - lam],
];
let r0 = a_mat[0];
let r1 = a_mat[1];
let r2 = a_mat[2];
let candidates = [
cross3(r0, r1),
cross3(r1, r2),
cross3(r0, r2),
];
let best = candidates
.iter()
.copied()
.max_by(|x, y| {
let nx = x[0] * x[0] + x[1] * x[1] + x[2] * x[2];
let ny = y[0] * y[0] + y[1] * y[1] + y[2] * y[2];
nx.partial_cmp(&ny).unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or([1.0, 0.0, 0.0]);
let norm = (best[0] * best[0] + best[1] * best[1] + best[2] * best[2])
.sqrt()
.max(1e-30);
evecs[i] = [best[0] / norm, best[1] / norm, best[2] / norm];
}
(eigenvalues, evecs)
}
fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn cbrt(x: f64) -> f64 {
if x >= 0.0 {
x.powf(1.0 / 3.0)
} else {
-(-x).powf(1.0 / 3.0)
}
}
fn conic_to_ellipse(c: [f64; 6]) -> NdimageResult<(f64, f64, f64, f64, f64)> {
let (a, b, cc, d, e, f) = (c[0], c[1], c[2], c[3], c[4], c[5]);
let denom = 4.0 * a * cc - b * b;
if denom.abs() < 1e-14 {
return Err(NdimageError::ComputationError(
"ellipse_fit: degenerate conic (not an ellipse)".to_string(),
));
}
let cx = (b * e - 2.0 * cc * d) / denom;
let cy = (b * d - 2.0 * a * e) / denom;
let f_prime = a * cx * cx + b * cx * cy + cc * cy * cy + d * cx + e * cy + f;
let m_11 = a;
let m_12 = b / 2.0;
let m_22 = cc;
let eig_diff = ((m_11 - m_22).powi(2) + m_12 * m_12 * 4.0).sqrt();
let lam1 = (m_11 + m_22 + eig_diff) / 2.0;
let lam2 = (m_11 + m_22 - eig_diff) / 2.0;
if -f_prime / lam1 <= 0.0 || -f_prime / lam2 <= 0.0 {
return Err(NdimageError::ComputationError(
"ellipse_fit: conic is not a real ellipse".to_string(),
));
}
let axis1 = (-f_prime / lam1).sqrt();
let axis2 = (-f_prime / lam2).sqrt();
let (semi_major, semi_minor) = if axis1 >= axis2 {
(axis1, axis2)
} else {
(axis2, axis1)
};
let angle = if (m_12).abs() < 1e-14 && m_11 <= m_22 {
0.0
} else if (m_12).abs() < 1e-14 {
std::f64::consts::PI / 2.0
} else {
((lam1 - m_11) / m_12).atan()
};
Ok((cx, cy, semi_major, semi_minor, angle))
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::Array2;
#[test]
fn test_convex_hull_square() {
let pts = vec![
(0.0, 0.0),
(1.0, 0.0),
(1.0, 1.0),
(0.0, 1.0),
(0.5, 0.5),
];
let hull = convex_hull_2d(&pts).expect("convex_hull_2d should succeed for square point set");
assert_eq!(hull.len(), 4);
}
#[test]
fn test_convex_hull_collinear() {
let pts = vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)];
let hull = convex_hull_2d(&pts).expect("convex_hull_2d should succeed for collinear points");
assert!(hull.len() >= 2);
}
#[test]
fn test_convex_hull_empty() {
assert!(convex_hull_2d(&[]).is_err());
}
#[test]
fn test_contour_extraction_filled_square() {
let mut img = Array2::<bool>::default((8, 8));
for r in 2..6 {
for c in 2..6 {
img[[r, c]] = true;
}
}
let contours = contour_extraction(&img.view()).expect("contour_extraction should succeed on valid image");
assert_eq!(contours.len(), 1);
assert!(!contours[0].is_empty());
}
#[test]
fn test_contour_extraction_empty_image() {
let img = Array2::<bool>::default((5, 5));
let contours = contour_extraction(&img.view()).expect("contour_extraction should succeed on empty image");
assert!(contours.is_empty());
}
#[test]
fn test_shape_descriptors_square() {
let contour: Vec<(usize, usize)> = vec![
(0, 0), (0, 1), (0, 2), (0, 3),
(1, 3), (2, 3), (3, 3),
(3, 2), (3, 1), (3, 0),
(2, 0), (1, 0),
];
let d = shape_descriptors(&contour).expect("shape_descriptors should succeed for valid square contour");
assert!(d.area > 0.0);
assert!(d.perimeter > 0.0);
assert!(d.circularity > 0.0);
assert!(d.circularity <= 1.0 + 1e-9);
assert!(d.solidity > 0.0 && d.solidity <= 1.0);
}
#[test]
fn test_shape_descriptors_too_few() {
assert!(shape_descriptors(&[(0, 0), (1, 1)]).is_err());
}
#[test]
fn test_minimum_bounding_rectangle_axis_aligned() {
let pts = vec![
(0.0, 0.0), (4.0, 0.0), (4.0, 3.0), (0.0, 3.0),
];
let rect = minimum_bounding_rectangle(&pts).expect("minimum_bounding_rectangle should succeed for axis-aligned rectangle");
let area = {
let (dx1, dy1) = (rect[1].0 - rect[0].0, rect[1].1 - rect[0].1);
let (dx2, dy2) = (rect[3].0 - rect[0].0, rect[3].1 - rect[0].1);
let l1 = (dx1 * dx1 + dy1 * dy1).sqrt();
let l2 = (dx2 * dx2 + dy2 * dy2).sqrt();
l1 * l2
};
assert!((area - 12.0).abs() < 0.5, "area = {area}");
}
#[test]
fn test_ellipse_fit_circle() {
let pts: Vec<(f64, f64)> = (0..36)
.map(|i| {
let t = i as f64 * std::f64::consts::PI / 18.0;
(t.cos(), t.sin())
})
.collect();
let (cx, cy, a, b, _angle) = ellipse_fit(&pts).expect("ellipse_fit should succeed for circular point set");
assert!((cx).abs() < 0.05, "cx={cx}");
assert!((cy).abs() < 0.05, "cy={cy}");
assert!((a - 1.0).abs() < 0.05, "a={a}");
assert!((b - 1.0).abs() < 0.05, "b={b}");
}
}