use ndarray::parallel::prelude::*;
use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayViewMut2, Axis};
use serde::{Deserialize, Serialize};
#[derive(thiserror::Error, Debug)]
pub enum HullError {
#[error(
"Input data must have at least d+1 points to define a hull. Got {0} points for dimension {1}."
)]
InsufficientPoints(usize, usize),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeeledHull {
pub facets: Vec<(Array1<f64>, f64)>,
pub dim: usize,
}
impl PeeledHull {
pub(crate) fn project_in_place(&self, mut points: ArrayViewMut2<'_, f64>) -> usize {
if points.nrows() == 0 {
return 0;
}
points
.axis_iter_mut(Axis(0))
.into_par_iter()
.map(|mut row| {
let view = row.view();
if self.is_inside(view) {
0usize
} else {
let proj = self.project_point(view);
row.assign(&proj);
1usize
}
})
.sum()
}
pub fn project_if_needed(&self, points: ArrayView2<f64>) -> (Array2<f64>, usize) {
let d = points.ncols();
assert_eq!(
d, self.dim,
"Dimension mismatch in PeeledHull::project_if_needed"
);
let mut out = points.to_owned();
let projected = self.project_in_place(out.view_mut());
(out, projected)
}
pub fn is_inside(&self, x: ArrayView1<f64>) -> bool {
for (a, b) in &self.facets {
let s = a.dot(&x);
if s > *b + 1e-12 {
return false;
}
}
true
}
pub fn signed_distance(&self, x: ArrayView1<f64>) -> f64 {
if self.is_inside(x) {
let mut min_slack = f64::INFINITY;
for (a, b) in &self.facets {
let slack = *b - a.dot(&x);
if slack < min_slack {
min_slack = slack;
}
}
-min_slack.max(0.0)
} else {
let z = self.project_point(x);
let diff = &x.to_owned() - &z;
diff.mapv(|v| v * v).sum().sqrt()
}
}
fn project_point(&self, y: ArrayView1<f64>) -> Array1<f64> {
let d = self.dim;
let m = self.facets.len();
let max_cycles = 200; let tol = 1e-8;
let mut x = y.to_owned();
let mut p_corr: Vec<Array1<f64>> = (0..m).map(|_| Array1::zeros(d)).collect();
for _ in 0..max_cycles {
let x_prev = x.clone();
for (i, (a, b)) in self.facets.iter().enumerate() {
let mut y_i = x.clone();
y_i += &p_corr[i];
let a_tb = a.dot(&y_i) - *b;
let a_norm2 = a.dot(a).max(1e-16);
if a_tb > 0.0 {
let alpha = a_tb / a_norm2;
let z = &y_i - &(a * alpha);
p_corr[i] = &y_i - &z;
x = z;
} else {
p_corr[i].fill(0.0);
x = y_i;
}
}
let diff = (&x - &x_prev).mapv(|v| v.abs()).sum();
if diff < tol {
return x;
}
}
x
}
}
pub fn build_peeled_hull(data: &Array2<f64>, peels: usize) -> Result<PeeledHull, HullError> {
let n = data.nrows();
let d = data.ncols();
if n < d + 1 {
return Err(HullError::InsufficientPoints(n, d));
}
let mut current = data.clone();
let directions = generate_directions(d, 8 * d);
for _ in 0..peels {
if current.nrows() < d + 1 {
break;
}
let verts = extreme_point_indices(¤t, &directions);
if verts.is_empty() {
break;
}
if verts.len() as f64 > 0.25 * current.nrows() as f64
|| current.nrows() - verts.len() < d + 1
{
break;
}
let mut drop_mask = vec![false; current.nrows()];
for &idx in &verts {
if idx < drop_mask.len() {
drop_mask[idx] = true;
}
}
let keep_count = drop_mask.iter().filter(|&&drop| !drop).count();
let mut next = Array2::zeros((keep_count, d));
let mut r = 0;
for (i, &drop) in drop_mask.iter().enumerate() {
if !drop {
next.row_mut(r).assign(¤t.row(i));
r += 1;
}
}
current = next;
}
let mut facets: Vec<(Array1<f64>, f64)> = Vec::with_capacity(directions.len());
for a in directions {
let norm = a.mapv(|v| v * v).sum().sqrt().max(1e-12);
let a_unit = a.mapv(|v| v / norm);
let mut maxval = f64::NEG_INFINITY;
for i in 0..current.nrows() {
let s = a_unit.dot(¤t.row(i));
if s > maxval {
maxval = s;
}
}
facets.push((a_unit, maxval));
}
Ok(PeeledHull { facets, dim: d })
}
fn generate_directions(d: usize, extra: usize) -> Vec<Array1<f64>> {
let mut dirs: Vec<Array1<f64>> = Vec::new();
for i in 0..d {
let mut e = Array1::zeros(d);
e[i] = 1.0;
dirs.push(e.clone());
let mut en = Array1::zeros(d);
en[i] = -1.0;
dirs.push(en);
}
let mut seed: u64 = 0xDEADBEEFCAFEBABE;
let a: u64 = 6364136223846793005;
let c: u64 = 1;
let m: u64 = 1u64 << 63;
for k in 0..extra {
seed = (a.wrapping_mul(seed).wrapping_add(c)) % m;
let mut v = Array1::zeros(d);
for j in 0..d {
seed = (a.wrapping_mul(seed).wrapping_add(c)) % m;
let u = (seed as f64) / (m as f64); v[j] = u - 0.5;
}
let norm = v.mapv(|x| x * x).sum().sqrt();
if norm > 1e-12 {
dirs.push(v.mapv(|x| x / norm));
} else {
let mut e = Array1::zeros(d);
e[k % d] = 1.0;
dirs.push(e);
}
}
dirs
}
fn extreme_point_indices(points: &Array2<f64>, directions: &[Array1<f64>]) -> Vec<usize> {
let n = points.nrows();
let mut mark = vec![false; n];
let mut unique = 0usize;
for a in directions {
let mut argmax = 0usize;
let mut best = f64::NEG_INFINITY;
for i in 0..n {
let s = a.dot(&points.row(i));
if s > best {
best = s;
argmax = i;
}
}
if !mark[argmax] {
mark[argmax] = true;
unique += 1;
}
}
let mut out = Vec::with_capacity(unique);
for (i, &is_marked) in mark.iter().enumerate() {
if is_marked {
out.push(i);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
fn unit_square_hull() -> PeeledHull {
PeeledHull {
facets: vec![
(array![1.0, 0.0], 1.0), (array![-1.0, 0.0], 0.0), (array![0.0, 1.0], 1.0), (array![0.0, -1.0], 0.0), ],
dim: 2,
}
}
#[test]
fn test_is_inside_unit_square() {
let h = unit_square_hull();
assert!(h.is_inside(array![0.5, 0.5].view())); assert!(h.is_inside(array![1.0, 0.5].view())); assert!(h.is_inside(array![0.0, 0.0].view())); assert!(!h.is_inside(array![1.1, 0.5].view())); assert!(!h.is_inside(array![-0.1, 0.5].view())); assert!(!h.is_inside(array![0.5, 1.1].view())); assert!(!h.is_inside(array![0.5, -0.1].view())); }
#[test]
fn test_project_point_unit_square() {
let h = unit_square_hull();
let p_in = array![0.5, 0.5];
let proj_in = h.project_point(p_in.view());
assert!((&proj_in - &p_in).mapv(|v| v.abs()).sum() < 1e-12);
let p_face = array![1.5, 0.5];
let proj_face = h.project_point(p_face.view());
assert!((&proj_face - &array![1.0, 0.5]).mapv(|v| v.abs()).sum() < 1e-8);
let p_corner = array![1.5, -0.5];
let proj_corner = h.project_point(p_corner.view());
assert!((&proj_corner - &array![1.0, 0.0]).mapv(|v| v.abs()).sum() < 1e-6);
}
#[test]
fn test_project_if_neededwith_outliers() {
let mut pts = Vec::new();
for x in [-1.0, 0.0, 1.0] {
for y in [-1.0, 0.0, 1.0] {
pts.push([x, y]);
}
}
let data = ndarray::Array2::from(pts);
let hull = build_peeled_hull(&data, 2).expect("hull build failed");
let test = ndarray::arr2(&[[0.2, 0.2], [2.0, 2.0], [-2.0, -2.0]]);
let (corrected, num_proj) = hull.project_if_needed(test.view());
assert_eq!(num_proj, 2);
assert!(
(corrected.row(0).to_owned() - test.row(0).to_owned())
.mapv(|v| v.abs())
.sum()
< 1e-12
);
for i in 0..corrected.nrows() {
assert!(hull.is_inside(corrected.row(i)));
}
}
#[test]
fn test_zero_peels_preserves_outer_support() {
let mut pts = Vec::new();
for k in 0..80 {
let theta = 2.0 * std::f64::consts::PI * (k as f64) / 80.0;
pts.push([theta.cos(), theta.sin()]);
}
pts.extend([[5.0, 0.0], [-5.0, 0.0], [0.0, 5.0], [0.0, -5.0]]);
let data = ndarray::Array2::from(pts);
let zero_peel = build_peeled_hull(&data, 0).expect("zero-peel hull");
let one_peel = build_peeled_hull(&data, 1).expect("one-peel hull");
let outer_probe = array![4.0, 0.0];
assert!(zero_peel.is_inside(outer_probe.view()));
assert!(!one_peel.is_inside(outer_probe.view()));
}
#[test]
fn test_signed_distance_unit_square() {
let h = unit_square_hull();
let d_center = h.signed_distance(array![0.5, 0.5].view());
assert!((d_center + 0.5).abs() < 1e-12);
let d_inside = h.signed_distance(array![0.2, 0.8].view());
assert!((d_inside + 0.2).abs() < 1e-12);
let d_edge = h.signed_distance(array![1.0, 0.3].view());
assert!(d_edge.abs() < 1e-12);
let d_out_x = h.signed_distance(array![1.5, 0.5].view());
assert!((d_out_x - 0.5).abs() < 1e-8);
let d_out_corner = h.signed_distance(array![1.5, -0.5].view());
assert!((d_out_corner - (0.5f64.hypot(0.5))).abs() < 1e-6);
}
}