use super::super::kdtree::{KdTree, NanoKdTree};
use super::features::{radius_search, set_column};
use super::{find_dim, par_map, xyz};
use crate::pointcloud::{PointCloud, Values};
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct DbscanOptions {
pub min_points: u64,
pub eps: f64,
pub dimensions: Vec<String>,
}
impl Default for DbscanOptions {
fn default() -> Self {
Self { min_points: 6, eps: 1.0, dimensions: vec!["X".into(), "Y".into(), "Z".into()] }
}
}
fn points_of<const D: usize>(cols: &[Vec<f64>]) -> Vec<[f64; D]> {
(0..cols[0].len()).map(|i| std::array::from_fn(|k| cols[k][i])).collect()
}
fn dbscan_in<const D: usize>(cols: &[Vec<f64>], eps: f64, min_points: u64) -> Vec<i64> {
let pts = points_of::<D>(cols);
let n = pts.len();
let tree = KdTree::new(pts);
let all: Vec<usize> = (0..n).collect();
let core: Vec<bool> = par_map(&all, || (), |_, &i| tree.count_within(tree.point(i), eps) as u64 >= min_points);
let mut ids = vec![-2i64; n];
let mut next = 0i64;
let mut queue: Vec<usize> = Vec::new();
for i in 0..n {
if ids[i] != -2 {
continue;
}
if !core[i] {
ids[i] = -1;
continue;
}
ids[i] = next;
queue.clear();
queue.push(i);
while let Some(p) = queue.pop() {
tree.within(tree.point(p), eps, &mut |q| {
let q = q as usize;
match ids[q] {
-1 => ids[q] = next,
-2 => {
ids[q] = next;
if core[q] {
queue.push(q);
}
}
_ => {}
}
});
}
next += 1;
}
ids
}
pub fn dbscan(cloud: &PointCloud, o: &DbscanOptions) -> Result<PointCloud> {
if o.eps.is_nan() || o.eps <= 0.0 {
return Err(Error::InvalidArgument("eps must be positive".into()));
}
let cols: Vec<Vec<f64>> = o.dimensions.iter().map(|d| Ok(find_dim(cloud, d)?.to_f64())).collect::<Result<_>>()?;
let ids = match cols.len() {
1 => dbscan_in::<1>(&cols, o.eps, o.min_points),
2 => dbscan_in::<2>(&cols, o.eps, o.min_points),
3 => dbscan_in::<3>(&cols, o.eps, o.min_points),
4 => dbscan_in::<4>(&cols, o.eps, o.min_points),
5 => dbscan_in::<5>(&cols, o.eps, o.min_points),
6 => dbscan_in::<6>(&cols, o.eps, o.min_points),
_ => return Err(Error::InvalidArgument("dbscan: give 1 to 6 dimensions".into())),
};
let mut out = cloud.clone();
set_column(&mut out, "cluster_id", Values::I64(ids));
Ok(out)
}
fn knn_all(cloud: &PointCloud, k: usize) -> Result<Vec<Vec<(f64, usize)>>> {
let (xs, ys, zs) = xyz(cloud)?;
let n = cloud.len();
let tree = NanoKdTree::new((0..n).map(|i| [xs[i], ys[i], zs[i]]).collect(), 100);
let all: Vec<usize> = (0..n).collect();
Ok(par_map(
&all,
|| (),
|_, &i| {
let mut found = Vec::new();
tree.knn(&[xs[i], ys[i], zs[i]], k, &mut found);
found.resize(k, (0.0, 0));
found
},
))
}
pub fn lof(cloud: &PointCloud, minpts: usize) -> Result<PointCloud> {
if minpts == 0 {
return Err(Error::InvalidArgument("minpts must be positive".into()));
}
let k = minpts + 1;
let near = knn_all(cloud, k)?;
let kdist: Vec<f64> = near.iter().map(|nb| nb[k - 1].0.sqrt()).collect();
let lrd: Vec<f64> = near
.iter()
.map(|nb| {
let mut m = 0.0;
for (j, (d2, p)) in nb.iter().enumerate() {
let reach = kdist[*p].max(d2.sqrt());
m += (reach - m) / (j + 1) as f64;
}
1.0 / m
})
.collect();
let factor: Vec<f64> = near
.iter()
.enumerate()
.map(|(i, nb)| {
let mut m = 0.0;
for (j, (_, p)) in nb.iter().enumerate() {
m += (lrd[*p] / lrd[i] - m) / (j + 1) as f64;
}
m
})
.collect();
let mut out = cloud.clone();
set_column(&mut out, "nndistance", Values::F64(kdist));
set_column(&mut out, "local_reachability_distance", Values::F64(lrd));
set_column(&mut out, "local_outlier_factor", Values::F64(factor));
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NnDistanceMode {
#[default]
Kth,
Avg,
}
impl NnDistanceMode {
pub fn parse(s: &str) -> Result<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"kth" => Ok(Self::Kth),
"avg" => Ok(Self::Avg),
_ => Err(Error::InvalidArgument(format!("invalid mode '{s}': use kth or avg"))),
}
}
}
pub fn nndistance(cloud: &PointCloud, k: usize, mode: NnDistanceMode) -> Result<PointCloud> {
if k == 0 {
return Err(Error::InvalidArgument("k must be positive".into()));
}
let near = knn_all(cloud, k + 1)?;
let d: Vec<f64> = near
.iter()
.map(|nb| match mode {
NnDistanceMode::Kth => nb[k].0.sqrt(),
NnDistanceMode::Avg => nb[1..].iter().map(|e| e.0.sqrt()).sum::<f64>() / k as f64,
})
.collect();
let mut out = cloud.clone();
set_column(&mut out, "nndistance", Values::F64(d));
Ok(out)
}
#[allow(clippy::approx_constant)]
const PDAL_RADIAL_PI: f64 = 3.14159;
pub fn radial_density(cloud: &PointCloud, radius: f64) -> Result<PointCloud> {
if radius.is_nan() || radius <= 0.0 {
return Err(Error::InvalidArgument("the radius must be positive".into()));
}
let (xs, ys, zs) = xyz(cloud)?;
let n = cloud.len();
let tree = KdTree::new((0..n).map(|i| [xs[i], ys[i], zs[i]]).collect());
let factor = 1.0 / ((4.0 / 3.0) * PDAL_RADIAL_PI * (radius * radius * radius));
let all: Vec<usize> = (0..n).collect();
let d = par_map(&all, || (), |_, &i| tree.count_within(tree.point(i), radius) as f64 * factor);
let mut out = cloud.clone();
set_column(&mut out, "radial_density", Values::F64(d));
Ok(out)
}
pub fn reciprocity(cloud: &PointCloud, knn: usize) -> Result<PointCloud> {
if knn == 0 {
return Err(Error::InvalidArgument("knn must be positive".into()));
}
let near = knn_all(cloud, knn + 1)?;
let n = cloud.len();
let lists: Vec<&[(f64, usize)]> = near.iter().map(|nb| &nb[..(knn + 1).min(n)]).collect();
let all: Vec<usize> = (0..n).collect();
let r = par_map(
&all,
|| (),
|_, &i| {
let uni = lists[i].iter().filter(|(_, j)| *j != i && !lists[*j].iter().any(|(_, p)| *p == i)).count();
100.0 * uni as f64 / knn as f64
},
);
let mut out = cloud.clone();
set_column(&mut out, "reciprocity", Values::F64(r));
Ok(out)
}
fn miniball_of(pts: &[[f64; 3]]) -> ([f64; 3], f64) {
fn ball(support: &[[f64; 3]]) -> ([f64; 3], f64) {
let sub = |a: &[f64; 3], b: &[f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let dot = |a: &[f64; 3], b: &[f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
match support.len() {
0 => ([0.0; 3], -1.0),
1 => (support[0], 0.0),
_ => {
let p0 = support[0];
let v: Vec<[f64; 3]> = support[1..].iter().map(|p| sub(p, &p0)).collect();
let m = v.len();
let mut a = vec![vec![0.0; m + 1]; m];
for r in 0..m {
for c in 0..m {
a[r][c] = 2.0 * dot(&v[r], &v[c]);
}
a[r][m] = dot(&v[r], &v[r]);
}
for c in 0..m {
let piv = (c..m).max_by(|&x, &y| a[x][c].abs().total_cmp(&a[y][c].abs())).unwrap_or(c);
a.swap(c, piv);
if a[c][c].abs() < 1e-300 {
continue;
}
let pivot = a[c].clone();
for (r, row) in a.iter_mut().enumerate() {
if r != c {
let f = row[c] / pivot[c];
for (x, p) in row.iter_mut().zip(&pivot).skip(c) {
*x -= f * p;
}
}
}
}
let mut centre = p0;
for (r, vr) in v.iter().enumerate() {
let l = if a[r][r].abs() < 1e-300 { 0.0 } else { a[r][m] / a[r][r] };
for k in 0..3 {
centre[k] += l * vr[k];
}
}
let d = sub(¢re, &p0);
(centre, dot(&d, &d).sqrt())
}
}
}
fn inside(b: &([f64; 3], f64), p: &[f64; 3]) -> bool {
let d = ((p[0] - b.0[0]).powi(2) + (p[1] - b.0[1]).powi(2) + (p[2] - b.0[2]).powi(2)).sqrt();
b.1 >= 0.0 && d <= b.1 * (1.0 + 1e-12) + 1e-12
}
fn welzl(pts: &[[f64; 3]], support: &mut Vec<[f64; 3]>) -> ([f64; 3], f64) {
if pts.is_empty() || support.len() == 4 {
return ball(support);
}
let (p, rest) = (pts[pts.len() - 1], &pts[..pts.len() - 1]);
let b = welzl(rest, support);
if inside(&b, &p) {
return b;
}
support.push(p);
let b = welzl(rest, support);
support.pop();
b
}
welzl(pts, &mut Vec::new())
}
pub fn miniball(cloud: &PointCloud, knn: usize) -> Result<PointCloud> {
if knn == 0 {
return Err(Error::InvalidArgument("knn must be positive".into()));
}
let (xs, ys, zs) = xyz(cloud)?;
let near = knn_all(cloud, knn + 1)?;
let n = cloud.len();
let all: Vec<usize> = (0..n).collect();
let m = par_map(
&all,
|| (),
|_, &i| {
let pts: Vec<[f64; 3]> =
near[i][..(knn + 1).min(n)].iter().filter(|(_, j)| *j != i).map(|(_, j)| [xs[*j], ys[*j], zs[*j]]).collect();
let (c, r) = miniball_of(&pts);
let d = ((xs[i] - c[0]).powi(2) + (ys[i] - c[1]).powi(2) + (zs[i] - c[2]).powi(2)).sqrt();
d / (d + 2.0 * r.max(0.0) / 3f64.sqrt())
},
);
let mut out = cloud.clone();
set_column(&mut out, "miniball", Values::F64(m));
Ok(out)
}
pub fn zsmooth(cloud: &PointCloud, dim: &str, radius: f64, percent: f64) -> Result<PointCloud> {
if !(0.0..=100.0).contains(&percent) {
return Err(Error::InvalidArgument("the percent must be in [0, 100]".into()));
}
if radius.is_nan() || radius <= 0.0 {
return Err(Error::InvalidArgument("the radius must be positive".into()));
}
let name = super::output_name(cloud, dim);
if name == "z" {
return Err(Error::InvalidArgument("zsmooth cannot write its statistic to Z".into()));
}
let (xs, ys, zs) = xyz(cloud)?;
let n = cloud.len();
let tree = NanoKdTree::new((0..n).map(|i| [xs[i], ys[i]]).collect(), 100);
let pos = percent / 100.0;
let all: Vec<usize> = (0..n).collect();
let v = par_map(
&all,
|| (Vec::new(), Vec::new()),
|(found, near), &i| {
radius_search(&tree, &[xs[i], ys[i]], radius, found, near);
let mut vals: Vec<f64> = near.iter().skip(1).map(|j| zs[*j]).collect();
vals.sort_by(f64::total_cmp);
match vals.len() {
0 => zs[i],
1 => vals[0],
_ if pos == 0.0 => vals[0],
m if pos == 1.0 => vals[m - 1],
m => {
let p = pos * (m - 1) as f64;
let low = p.floor() as usize;
let high_frac = p - low as f64;
vals[low] * (1.0 - high_frac) + vals[low + 1] * high_frac
}
}
},
);
let mut out = cloud.clone();
set_column(&mut out, &name, Values::F64(v));
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_the_smallest_enclosing_ball() {
let (c, r) = miniball_of(&[[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 0.1, 0.0]]);
assert!((r - 1.0).abs() < 1e-12 && (c[0] - 1.0).abs() < 1e-12);
let tetra = [[1.0, 1.0, 1.0], [1.0, -1.0, -1.0], [-1.0, 1.0, -1.0], [-1.0, -1.0, 1.0], [0.1, 0.2, 0.3]];
let (c, r) = miniball_of(&tetra);
assert!((r - 3f64.sqrt()).abs() < 1e-12 && c.iter().all(|v| v.abs() < 1e-12));
}
}