use threecrate_core::{PointCloud, Result, Point3f, Vector3f, Error};
use nalgebra::{Vector4};
use rayon::prelude::*;
use rand::prelude::*;
use rand::thread_rng;
use std::collections::{HashSet, VecDeque};
use crate::KdTree;
use threecrate_core::NearestNeighborSearch;
#[derive(Debug, Clone, PartialEq)]
pub struct PlaneModel {
pub coefficients: Vector4<f32>,
}
impl PlaneModel {
pub fn new(a: f32, b: f32, c: f32, d: f32) -> Self {
Self {
coefficients: Vector4::new(a, b, c, d),
}
}
pub fn from_points(p1: &Point3f, p2: &Point3f, p3: &Point3f) -> Option<Self> {
let v1 = p2 - p1;
let v2 = p3 - p1;
let normal = v1.cross(&v2);
if normal.magnitude() < 1e-8 {
return None;
}
let normal = normal.normalize();
let d = -normal.dot(&p1.coords);
Some(PlaneModel::new(normal.x, normal.y, normal.z, d))
}
pub fn normal(&self) -> Vector3f {
Vector3f::new(
self.coefficients.x,
self.coefficients.y,
self.coefficients.z,
)
}
pub fn distance_to_point(&self, point: &Point3f) -> f32 {
let normal = self.normal();
let normal_magnitude = normal.magnitude();
if normal_magnitude < 1e-8 {
return f32::INFINITY;
}
(self.coefficients.x * point.x +
self.coefficients.y * point.y +
self.coefficients.z * point.z +
self.coefficients.w).abs() / normal_magnitude
}
pub fn count_inliers(&self, points: &[Point3f], threshold: f32) -> usize {
points.iter()
.filter(|point| self.distance_to_point(point) <= threshold)
.count()
}
pub fn get_inliers(&self, points: &[Point3f], threshold: f32) -> Vec<usize> {
points.iter()
.enumerate()
.filter(|(_, point)| self.distance_to_point(point) <= threshold)
.map(|(i, _)| i)
.collect()
}
}
#[derive(Debug, Clone)]
pub struct PlaneSegmentationResult {
pub model: PlaneModel,
pub inliers: Vec<usize>,
pub iterations: usize,
}
pub fn segment_plane(
cloud: &PointCloud<Point3f>,
threshold: f32,
max_iters: usize
) -> Result<PlaneSegmentationResult> {
if cloud.len() < 3 {
return Err(Error::InvalidData("Need at least 3 points for plane segmentation".to_string()));
}
if threshold <= 0.0 {
return Err(Error::InvalidData("Threshold must be positive".to_string()));
}
if max_iters == 0 {
return Err(Error::InvalidData("Max iterations must be positive".to_string()));
}
let points = &cloud.points;
let mut rng = thread_rng();
let mut best_model: Option<PlaneModel> = None;
let mut best_inliers = Vec::new();
let mut best_score = 0;
for _iteration in 0..max_iters {
let mut indices = HashSet::new();
while indices.len() < 3 {
indices.insert(rng.gen_range(0..points.len()));
}
let indices: Vec<usize> = indices.into_iter().collect();
let p1 = &points[indices[0]];
let p2 = &points[indices[1]];
let p3 = &points[indices[2]];
if let Some(model) = PlaneModel::from_points(p1, p2, p3) {
let inlier_count = model.count_inliers(points, threshold);
if inlier_count > best_score {
best_score = inlier_count;
best_inliers = model.get_inliers(points, threshold);
best_model = Some(model);
}
}
}
match best_model {
Some(model) => Ok(PlaneSegmentationResult {
model,
inliers: best_inliers,
iterations: max_iters,
}),
None => Err(Error::Algorithm("Failed to find valid plane model".to_string())),
}
}
pub fn segment_plane_parallel(
cloud: &PointCloud<Point3f>,
threshold: f32,
max_iters: usize
) -> Result<PlaneSegmentationResult> {
if cloud.len() < 3 {
return Err(Error::InvalidData("Need at least 3 points for plane segmentation".to_string()));
}
if threshold <= 0.0 {
return Err(Error::InvalidData("Threshold must be positive".to_string()));
}
if max_iters == 0 {
return Err(Error::InvalidData("Max iterations must be positive".to_string()));
}
let points = &cloud.points;
let results: Vec<_> = (0..max_iters)
.into_par_iter()
.filter_map(|_| {
let mut rng = thread_rng();
let mut indices = HashSet::new();
while indices.len() < 3 {
indices.insert(rng.gen_range(0..points.len()));
}
let indices: Vec<usize> = indices.into_iter().collect();
let p1 = &points[indices[0]];
let p2 = &points[indices[1]];
let p3 = &points[indices[2]];
PlaneModel::from_points(p1, p2, p3).map(|model| {
let inliers = model.get_inliers(points, threshold);
let score = inliers.len();
(model, inliers, score)
})
})
.collect();
let best = results.into_iter()
.max_by_key(|(_, _, score)| *score);
match best {
Some((model, inliers, _)) => Ok(PlaneSegmentationResult {
model,
inliers,
iterations: max_iters,
}),
None => Err(Error::Algorithm("Failed to find valid plane model".to_string())),
}
}
#[deprecated(note = "Use segment_plane instead which returns a complete result")]
pub fn segment_plane_legacy(cloud: &PointCloud<Point3f>, threshold: f32) -> Result<Vec<usize>> {
let result = segment_plane(cloud, threshold, 1000)?;
Ok(result.inliers)
}
pub fn segment_plane_ransac(
cloud: &PointCloud<Point3f>,
max_iters: usize,
threshold: f32,
) -> Result<(Vector4<f32>, Vec<usize>)> {
let result = segment_plane(cloud, threshold, max_iters)?;
Ok((result.model.coefficients, result.inliers))
}
pub fn plane_segmentation_ransac(
cloud: &PointCloud<Point3f>,
max_iters: usize,
threshold: f32,
) -> Result<(Vector4<f32>, Vec<usize>)> {
segment_plane_ransac(cloud, max_iters, threshold)
}
#[derive(Debug, Clone)]
pub struct EuclideanClusterConfig {
pub tolerance: f32,
pub min_cluster_size: usize,
pub max_cluster_size: usize,
}
impl Default for EuclideanClusterConfig {
fn default() -> Self {
Self {
tolerance: 0.02,
min_cluster_size: 100,
max_cluster_size: 25000,
}
}
}
impl EuclideanClusterConfig {
pub fn new(tolerance: f32, min_cluster_size: usize, max_cluster_size: usize) -> Self {
Self { tolerance, min_cluster_size, max_cluster_size }
}
}
#[derive(Debug, Clone)]
pub struct ClusterExtractionResult {
pub clusters: Vec<Vec<usize>>,
}
impl ClusterExtractionResult {
pub fn num_clusters(&self) -> usize {
self.clusters.len()
}
pub fn get_cluster_cloud(&self, cloud: &PointCloud<Point3f>, index: usize) -> Option<PointCloud<Point3f>> {
self.clusters.get(index).map(|indices| {
PointCloud::from_points(indices.iter().map(|&i| cloud.points[i]).collect())
})
}
}
pub fn extract_euclidean_clusters(
cloud: &PointCloud<Point3f>,
config: &EuclideanClusterConfig,
) -> Result<ClusterExtractionResult> {
if cloud.is_empty() {
return Err(Error::InvalidData("Point cloud is empty".to_string()));
}
if config.tolerance <= 0.0 {
return Err(Error::InvalidData("Tolerance must be positive".to_string()));
}
if config.min_cluster_size == 0 {
return Err(Error::InvalidData("min_cluster_size must be at least 1".to_string()));
}
if config.min_cluster_size > config.max_cluster_size {
return Err(Error::InvalidData(
"min_cluster_size must not exceed max_cluster_size".to_string(),
));
}
let points = &cloud.points;
let kdtree = KdTree::new(points)?;
let mut visited = vec![false; points.len()];
let mut clusters: Vec<Vec<usize>> = Vec::new();
for seed_idx in 0..points.len() {
if visited[seed_idx] {
continue;
}
let mut cluster = Vec::new();
let mut queue = VecDeque::new();
visited[seed_idx] = true;
queue.push_back(seed_idx);
while let Some(current) = queue.pop_front() {
cluster.push(current);
let neighbors = kdtree.find_radius_neighbors(&points[current], config.tolerance);
for (neighbor_idx, _dist) in neighbors {
if !visited[neighbor_idx] {
visited[neighbor_idx] = true;
queue.push_back(neighbor_idx);
}
}
}
if cluster.len() >= config.min_cluster_size && cluster.len() <= config.max_cluster_size {
clusters.push(cluster);
}
}
clusters.sort_by(|a, b| b.len().cmp(&a.len()));
Ok(ClusterExtractionResult { clusters })
}
pub fn extract_euclidean_clusters_parallel(
cloud: &PointCloud<Point3f>,
config: &EuclideanClusterConfig,
) -> Result<ClusterExtractionResult> {
if cloud.is_empty() {
return Err(Error::InvalidData("Point cloud is empty".to_string()));
}
if config.tolerance <= 0.0 {
return Err(Error::InvalidData("Tolerance must be positive".to_string()));
}
if config.min_cluster_size == 0 {
return Err(Error::InvalidData("min_cluster_size must be at least 1".to_string()));
}
if config.min_cluster_size > config.max_cluster_size {
return Err(Error::InvalidData(
"min_cluster_size must not exceed max_cluster_size".to_string(),
));
}
let points = &cloud.points;
let kdtree = KdTree::new(points)?;
let tolerance = config.tolerance;
let neighbor_lists: Vec<Vec<usize>> = (0..points.len())
.into_par_iter()
.map(|i| {
kdtree
.find_radius_neighbors(&points[i], tolerance)
.into_iter()
.map(|(idx, _)| idx)
.collect()
})
.collect();
let mut visited = vec![false; points.len()];
let mut clusters: Vec<Vec<usize>> = Vec::new();
for seed_idx in 0..points.len() {
if visited[seed_idx] {
continue;
}
let mut cluster = Vec::new();
let mut queue = VecDeque::new();
visited[seed_idx] = true;
queue.push_back(seed_idx);
while let Some(current) = queue.pop_front() {
cluster.push(current);
for &neighbor_idx in &neighbor_lists[current] {
if !visited[neighbor_idx] {
visited[neighbor_idx] = true;
queue.push_back(neighbor_idx);
}
}
}
if cluster.len() >= config.min_cluster_size && cluster.len() <= config.max_cluster_size {
clusters.push(cluster);
}
}
clusters.sort_by(|a, b| b.len().cmp(&a.len()));
Ok(ClusterExtractionResult { clusters })
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_plane_model_from_points() {
let p1 = Point3f::new(0.0, 0.0, 0.0);
let p2 = Point3f::new(1.0, 0.0, 0.0);
let p3 = Point3f::new(0.0, 1.0, 0.0);
let model = PlaneModel::from_points(&p1, &p2, &p3).unwrap();
let normal = model.normal();
assert!(normal.z.abs() > 0.9, "Normal should be primarily in Z direction: {:?}", normal);
assert!(model.distance_to_point(&p1) < 1e-6);
assert!(model.distance_to_point(&p2) < 1e-6);
assert!(model.distance_to_point(&p3) < 1e-6);
}
#[test]
fn test_plane_model_collinear_points() {
let p1 = Point3f::new(0.0, 0.0, 0.0);
let p2 = Point3f::new(1.0, 0.0, 0.0);
let p3 = Point3f::new(2.0, 0.0, 0.0);
let model = PlaneModel::from_points(&p1, &p2, &p3);
assert!(model.is_none(), "Should return None for collinear points");
}
#[test]
fn test_plane_distance_calculation() {
let model = PlaneModel::new(0.0, 0.0, 1.0, -1.0);
let point_on_plane = Point3f::new(0.0, 0.0, 1.0);
let point_above_plane = Point3f::new(0.0, 0.0, 2.0);
let point_below_plane = Point3f::new(0.0, 0.0, 0.0);
assert_relative_eq!(model.distance_to_point(&point_on_plane), 0.0, epsilon = 1e-6);
assert_relative_eq!(model.distance_to_point(&point_above_plane), 1.0, epsilon = 1e-6);
assert_relative_eq!(model.distance_to_point(&point_below_plane), 1.0, epsilon = 1e-6);
}
#[test]
fn test_segment_plane_simple() {
let mut cloud = PointCloud::new();
for i in 0..10 {
for j in 0..10 {
cloud.push(Point3f::new(i as f32, j as f32, 0.0));
}
}
cloud.push(Point3f::new(5.0, 5.0, 10.0));
cloud.push(Point3f::new(5.0, 5.0, -10.0));
let result = segment_plane(&cloud, 0.1, 100).unwrap();
assert!(result.inliers.len() >= 95, "Should find most points as inliers");
let normal = result.model.normal();
assert!(normal.z.abs() > 0.9, "Normal should be primarily in Z direction");
}
#[test]
fn test_segment_plane_insufficient_points() {
let mut cloud = PointCloud::new();
cloud.push(Point3f::new(0.0, 0.0, 0.0));
cloud.push(Point3f::new(1.0, 0.0, 0.0));
let result = segment_plane(&cloud, 0.1, 100);
assert!(result.is_err(), "Should fail with insufficient points");
}
#[test]
fn test_segment_plane_invalid_threshold() {
let mut cloud = PointCloud::new();
cloud.push(Point3f::new(0.0, 0.0, 0.0));
cloud.push(Point3f::new(1.0, 0.0, 0.0));
cloud.push(Point3f::new(0.0, 1.0, 0.0));
let result = segment_plane(&cloud, -0.1, 100);
assert!(result.is_err(), "Should fail with negative threshold");
}
#[test]
fn test_segment_plane_parallel() {
let mut cloud = PointCloud::new();
for i in 0..10 {
for j in 0..10 {
cloud.push(Point3f::new(i as f32, j as f32, 0.0));
}
}
let result = segment_plane_parallel(&cloud, 0.1, 100).unwrap();
assert!(result.inliers.len() >= 95, "Should find most points as inliers");
}
#[test]
fn test_segment_plane_ransac_simple() {
let mut cloud = PointCloud::new();
for i in 0..10 {
for j in 0..10 {
cloud.push(Point3f::new(i as f32, j as f32, 0.0));
}
}
cloud.push(Point3f::new(5.0, 5.0, 10.0));
cloud.push(Point3f::new(5.0, 5.0, -10.0));
let (coefficients, inliers) = segment_plane_ransac(&cloud, 100, 0.1).unwrap();
assert!(inliers.len() >= 95, "Should find most points as inliers");
let normal = Vector3f::new(coefficients.x, coefficients.y, coefficients.z);
assert!(normal.z.abs() > 0.9, "Normal should be primarily in Z direction: {:?}", normal);
}
#[test]
fn test_segment_plane_ransac_noisy() {
let mut cloud = PointCloud::new();
let mut rng = thread_rng();
for i in 0..20 {
for j in 0..20 {
let x = i as f32;
let y = j as f32;
let z = rng.gen_range(-0.05..0.05); cloud.push(Point3f::new(x, y, z));
}
}
for _ in 0..20 {
let x = rng.gen_range(0.0..20.0);
let y = rng.gen_range(0.0..20.0);
let z = rng.gen_range(1.0..5.0); cloud.push(Point3f::new(x, y, z));
}
let (coefficients, inliers) = segment_plane_ransac(&cloud, 1000, 0.1).unwrap();
assert!(inliers.len() >= 350, "Should find most planar points as inliers");
let normal = Vector3f::new(coefficients.x, coefficients.y, coefficients.z);
assert!(normal.z.abs() > 0.8, "Normal should be primarily in Z direction: {:?}", normal);
let outlier_indices: Vec<usize> = (400..420).collect();
let outlier_inliers: Vec<usize> = inliers.iter()
.filter(|&&idx| outlier_indices.contains(&idx))
.cloned()
.collect();
assert!(outlier_inliers.len() <= 2, "Should not include many outliers in inliers");
}
#[test]
fn test_segment_plane_ransac_tilted_plane() {
let mut cloud = PointCloud::new();
let mut rng = thread_rng();
for i in 0..15 {
for j in 0..15 {
let x = i as f32;
let y = j as f32;
let z = -(x + y);
let noise_x = rng.gen_range(-0.02..0.02);
let noise_y = rng.gen_range(-0.02..0.02);
let noise_z = rng.gen_range(-0.02..0.02);
cloud.push(Point3f::new(x + noise_x, y + noise_y, z + noise_z));
}
}
for _ in 0..30 {
let x = rng.gen_range(0.0..15.0);
let y = rng.gen_range(0.0..15.0);
let z = rng.gen_range(5.0..10.0); cloud.push(Point3f::new(x, y, z));
}
let (coefficients, inliers) = segment_plane_ransac(&cloud, 1000, 0.1).unwrap();
assert!(inliers.len() >= 200, "Should find most planar points as inliers");
let normal = Vector3f::new(coefficients.x, coefficients.y, coefficients.z);
let expected_normal = Vector3f::new(1.0, 1.0, 1.0).normalize();
let dot_product = normal.dot(&expected_normal).abs();
assert!(dot_product > 0.8, "Normal should be close to expected direction: {:?}", normal);
}
#[test]
fn test_plane_segmentation_ransac_alias() {
let mut cloud = PointCloud::new();
for i in 0..5 {
for j in 0..5 {
cloud.push(Point3f::new(i as f32, j as f32, 0.0));
}
}
let result1 = segment_plane_ransac(&cloud, 100, 0.1).unwrap();
let result2 = plane_segmentation_ransac(&cloud, 100, 0.1).unwrap();
assert!(result1.1.len() >= 20, "Should find most points as inliers");
assert!(result2.1.len() >= 20, "Should find most points as inliers");
let diff = (result1.1.len() as i32 - result2.1.len() as i32).abs();
assert!(diff <= 5, "Inlier counts should be similar: {} vs {}", result1.1.len(), result2.1.len());
}
#[test]
fn test_segment_plane_ransac_insufficient_points() {
let mut cloud = PointCloud::new();
cloud.push(Point3f::new(0.0, 0.0, 0.0));
cloud.push(Point3f::new(1.0, 0.0, 0.0));
let result = segment_plane_ransac(&cloud, 100, 0.1);
assert!(result.is_err(), "Should fail with insufficient points");
}
#[test]
fn test_segment_plane_ransac_invalid_threshold() {
let mut cloud = PointCloud::new();
cloud.push(Point3f::new(0.0, 0.0, 0.0));
cloud.push(Point3f::new(1.0, 0.0, 0.0));
cloud.push(Point3f::new(0.0, 1.0, 0.0));
let result = segment_plane_ransac(&cloud, 100, -0.1);
assert!(result.is_err(), "Should fail with negative threshold");
}
#[test]
fn test_segment_plane_ransac_zero_iterations() {
let mut cloud = PointCloud::new();
cloud.push(Point3f::new(0.0, 0.0, 0.0));
cloud.push(Point3f::new(1.0, 0.0, 0.0));
cloud.push(Point3f::new(0.0, 1.0, 0.0));
let result = segment_plane_ransac(&cloud, 0, 0.1);
assert!(result.is_err(), "Should fail with zero iterations");
}
fn make_sphere_cloud(center: Point3f, radius: f32, count: usize) -> Vec<Point3f> {
let mut rng = thread_rng();
let mut pts = Vec::with_capacity(count);
while pts.len() < count {
let x: f32 = rng.gen_range(-radius..radius);
let y: f32 = rng.gen_range(-radius..radius);
let z: f32 = rng.gen_range(-radius..radius);
if x * x + y * y + z * z <= radius * radius {
pts.push(Point3f::new(center.x + x, center.y + y, center.z + z));
}
}
pts
}
#[test]
fn test_euclidean_cluster_two_blobs() {
let mut cloud = PointCloud::new();
for p in make_sphere_cloud(Point3f::new(0.0, 0.0, 0.0), 0.3, 200) {
cloud.push(p);
}
for p in make_sphere_cloud(Point3f::new(10.0, 0.0, 0.0), 0.3, 150) {
cloud.push(p);
}
let config = EuclideanClusterConfig::new(0.5, 50, 10000);
let result = extract_euclidean_clusters(&cloud, &config).unwrap();
assert_eq!(result.num_clusters(), 2, "Should detect exactly 2 clusters");
assert!(result.clusters[0].len() >= result.clusters[1].len());
}
#[test]
fn test_euclidean_cluster_three_blobs() {
let mut cloud = PointCloud::new();
for p in make_sphere_cloud(Point3f::new(0.0, 0.0, 0.0), 0.4, 300) { cloud.push(p); }
for p in make_sphere_cloud(Point3f::new(5.0, 0.0, 0.0), 0.4, 200) { cloud.push(p); }
for p in make_sphere_cloud(Point3f::new(0.0, 5.0, 0.0), 0.4, 100) { cloud.push(p); }
let config = EuclideanClusterConfig::new(0.6, 50, 10000);
let result = extract_euclidean_clusters(&cloud, &config).unwrap();
assert_eq!(result.num_clusters(), 3);
}
#[test]
fn test_euclidean_cluster_min_size_filter() {
let mut cloud = PointCloud::new();
for p in make_sphere_cloud(Point3f::new(0.0, 0.0, 0.0), 0.4, 300) { cloud.push(p); }
for p in make_sphere_cloud(Point3f::new(10.0, 0.0, 0.0), 0.2, 5) { cloud.push(p); }
let config = EuclideanClusterConfig::new(0.5, 50, 10000);
let result = extract_euclidean_clusters(&cloud, &config).unwrap();
assert_eq!(result.num_clusters(), 1, "Small cluster should be filtered");
}
#[test]
fn test_euclidean_cluster_max_size_filter() {
let mut cloud = PointCloud::new();
for p in make_sphere_cloud(Point3f::new(0.0, 0.0, 0.0), 0.5, 500) { cloud.push(p); }
let config = EuclideanClusterConfig::new(0.6, 1, 100);
let result = extract_euclidean_clusters(&cloud, &config).unwrap();
assert_eq!(result.num_clusters(), 0, "Oversized cluster should be filtered");
}
#[test]
fn test_euclidean_cluster_get_cloud() {
let mut cloud = PointCloud::new();
for p in make_sphere_cloud(Point3f::new(0.0, 0.0, 0.0), 0.3, 200) { cloud.push(p); }
for p in make_sphere_cloud(Point3f::new(10.0, 0.0, 0.0), 0.3, 100) { cloud.push(p); }
let config = EuclideanClusterConfig::new(0.5, 50, 10000);
let result = extract_euclidean_clusters(&cloud, &config).unwrap();
let sub = result.get_cluster_cloud(&cloud, 0).unwrap();
assert_eq!(sub.len(), result.clusters[0].len());
}
#[test]
fn test_euclidean_cluster_parallel_matches_sequential() {
let mut cloud = PointCloud::new();
for p in make_sphere_cloud(Point3f::new(0.0, 0.0, 0.0), 0.3, 200) { cloud.push(p); }
for p in make_sphere_cloud(Point3f::new(5.0, 0.0, 0.0), 0.3, 150) { cloud.push(p); }
let config = EuclideanClusterConfig::new(0.5, 50, 10000);
let seq = extract_euclidean_clusters(&cloud, &config).unwrap();
let par = extract_euclidean_clusters_parallel(&cloud, &config).unwrap();
assert_eq!(seq.num_clusters(), par.num_clusters());
for (s, p) in seq.clusters.iter().zip(par.clusters.iter()) {
assert_eq!(s.len(), p.len());
}
}
#[test]
fn test_euclidean_cluster_invalid_config() {
let mut cloud = PointCloud::new();
cloud.push(Point3f::new(0.0, 0.0, 0.0));
assert!(extract_euclidean_clusters(&cloud, &EuclideanClusterConfig::new(-1.0, 1, 100)).is_err());
assert!(extract_euclidean_clusters(&cloud, &EuclideanClusterConfig::new(0.1, 0, 100)).is_err());
assert!(extract_euclidean_clusters(&cloud, &EuclideanClusterConfig::new(0.1, 10, 5)).is_err());
}
#[test]
fn test_euclidean_cluster_empty_cloud() {
let cloud: PointCloud<Point3f> = PointCloud::new();
let config = EuclideanClusterConfig::default();
assert!(extract_euclidean_clusters(&cloud, &config).is_err());
}
}