use nalgebra::{Matrix3, Matrix6, Translation3, UnitQuaternion, Vector6};
use rayon::prelude::*;
use threecrate_core::{
Error, Isometry3, NearestNeighborSearch, Point3f, PointCloud, Result, Vector3f,
};
use crate::nearest_neighbor::KdTree;
use crate::registration::{icp_converged, CloudScale, ICPResult};
#[derive(Debug, Clone)]
pub struct GicpConfig {
pub max_iterations: usize,
pub max_correspondence_distance: f32,
pub convergence_threshold: f32,
pub k_correspondences: usize,
}
impl Default for GicpConfig {
fn default() -> Self {
Self {
max_iterations: 50,
max_correspondence_distance: 1.0,
convergence_threshold: 1e-6,
k_correspondences: 20,
}
}
}
#[inline]
fn skew_sym(v: &nalgebra::Vector3<f32>) -> Matrix3<f32> {
Matrix3::new(0.0, -v.z, v.y, v.z, 0.0, -v.x, -v.y, v.x, 0.0)
}
fn plane_covariance(cov: Matrix3<f32>) -> Matrix3<f32> {
const EPSILON: f32 = 1e-3;
let eigen = cov.symmetric_eigen();
let largest = eigen.eigenvalues.max();
let middle = eigen.eigenvalues.sum() - largest - eigen.eigenvalues.min();
if !(largest > 0.0) || middle <= 1e-6 * largest {
return Matrix3::identity();
}
let normal_axis = eigen.eigenvalues.imin();
let mut spread = nalgebra::Vector3::new(1.0, 1.0, 1.0);
spread[normal_axis] = EPSILON;
let v = eigen.eigenvectors;
v * Matrix3::from_diagonal(&spread) * v.transpose()
}
fn compute_covariances(points: &[Point3f], k: usize) -> Result<Vec<Matrix3<f32>>> {
let k = k.max(4); let tree = KdTree::new(points)?;
let covs: Vec<Matrix3<f32>> = points
.par_iter()
.map(|p| {
let neighbours = tree.find_k_nearest(p, k);
let n = neighbours.len();
if n < 3 {
return Matrix3::identity();
}
let nf = n as f32;
let mean = neighbours
.iter()
.map(|(idx, _)| points[*idx].coords)
.fold(nalgebra::Vector3::zeros(), |acc, v| acc + v)
/ nf;
let mut cov = Matrix3::zeros();
for (idx, _) in &neighbours {
let d = points[*idx].coords - mean;
cov += d * d.transpose();
}
let cov = cov / (nf - 1.0).max(1.0);
plane_covariance(cov)
})
.collect();
Ok(covs)
}
pub fn gicp(
source: &PointCloud<Point3f>,
target: &PointCloud<Point3f>,
init: Isometry3<f32>,
config: GicpConfig,
) -> Result<ICPResult> {
if source.is_empty() || target.is_empty() {
return Err(Error::InvalidData(
"GICP: source or target point cloud is empty".into(),
));
}
if config.max_iterations == 0 {
return Err(Error::InvalidData(
"GICP: max_iterations must be > 0".into(),
));
}
let min_k = config.k_correspondences.max(4);
if source.len() < min_k || target.len() < min_k {
return Err(Error::InvalidData(format!(
"GICP: clouds must have at least {} points for reliable covariance estimation \
(k_correspondences={}); got source={}, target={}",
min_k,
config.k_correspondences,
source.len(),
target.len()
)));
}
for (label, cloud) in [("source", source), ("target", target)] {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for p in &cloud.points {
for ax in 0..3 {
min[ax] = min[ax].min(p.coords[ax]);
max[ax] = max[ax].max(p.coords[ax]);
}
}
let min_extent = (0..3)
.map(|ax| max[ax] - min[ax])
.fold(f32::INFINITY, f32::min);
if min_extent < 1e-4 {
return Err(Error::InvalidData(format!(
"GICP: {label} point cloud appears to be coplanar or collinear \
(smallest bounding-box dimension = {min_extent:.2e}); \
GICP requires 3-D structure"
)));
}
}
let source_covs = compute_covariances(&source.points, config.k_correspondences)?;
let target_covs = compute_covariances(&target.points, config.k_correspondences)?;
let target_tree = KdTree::new(&target.points)?;
let mut current_transform = init;
let mut prev_mse = f32::INFINITY;
let source_scale = CloudScale::of(&source.points);
let mut final_corr: Vec<(usize, usize)> = Vec::new();
for iteration in 0..config.max_iterations {
let transformed_source: Vec<Point3f> = source
.points
.iter()
.map(|p| current_transform * p)
.collect();
let r_mat = *current_transform.rotation.to_rotation_matrix().matrix();
let mut h = Matrix6::<f32>::zeros();
let mut gvec = Vector6::<f32>::zeros();
let mut n_corr = 0usize;
let mut mse_sum = 0.0f32;
let mut corr_pairs: Vec<(usize, usize)> = Vec::new();
for (src_idx, (ts, c_s)) in transformed_source
.iter()
.zip(source_covs.iter())
.enumerate()
{
let neighbours = target_tree.find_k_nearest(ts, 1);
if neighbours.is_empty() {
continue;
}
let (tgt_idx, dist) = neighbours[0];
if dist > config.max_correspondence_distance {
continue;
}
let c_t = &target_covs[tgt_idx];
let m = c_t + r_mat * c_s * r_mat.transpose();
let m_inv = match m.try_inverse() {
Some(inv) => inv,
None => continue, };
let residual = target.points[tgt_idx].coords - ts.coords;
let a = -skew_sym(&ts.coords); let h_rr = a.transpose() * (m_inv * a);
let h_rt = a.transpose() * m_inv;
let wr = m_inv * residual;
let g_r = a.transpose() * wr;
for i in 0..3 {
for j in 0..3 {
h[(i, j)] += h_rr[(i, j)];
h[(i, j + 3)] += h_rt[(i, j)];
h[(i + 3, j)] += h_rt[(j, i)]; h[(i + 3, j + 3)] += m_inv[(i, j)];
}
gvec[i] += g_r[i];
gvec[i + 3] += wr[i];
}
n_corr += 1;
mse_sum += dist * dist;
corr_pairs.push((src_idx, tgt_idx));
}
if n_corr < 6 {
return Err(Error::Algorithm(
"GICP: insufficient correspondences (need ≥ 6)".into(),
));
}
let mse = mse_sum / n_corr as f32;
let delta = if let Some(chol) = h.cholesky() {
chol.solve(&gvec)
} else {
h.lu().solve(&gvec).ok_or_else(|| {
Error::Algorithm("GICP: Gauss-Newton system is ill-conditioned".into())
})?
};
let delta_rot = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), delta[2])
* UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), delta[1])
* UnitQuaternion::from_axis_angle(&Vector3f::x_axis(), delta[0]);
let delta_iso =
Isometry3::from_parts(Translation3::new(delta[3], delta[4], delta[5]), delta_rot);
let moved_centroid = current_transform * source_scale.centroid;
current_transform = delta_iso * current_transform;
if icp_converged(
prev_mse,
mse,
&delta_iso,
&moved_centroid,
source_scale.radius,
config.convergence_threshold,
) {
return Ok(ICPResult {
transformation: current_transform,
mse,
iterations: iteration + 1,
converged: true,
correspondences: corr_pairs,
});
}
prev_mse = mse;
final_corr = corr_pairs;
}
Ok(ICPResult {
transformation: current_transform,
mse: prev_mse,
iterations: config.max_iterations,
converged: false,
correspondences: final_corr,
})
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[allow(unused_imports)]
use nalgebra::Translation3;
fn make_sphere(n: usize, radius: f32) -> PointCloud<Point3f> {
let mut cloud = PointCloud::new();
let golden = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
for i in 0..n {
let y = 1.0 - (i as f32 / (n as f32 - 1.0).max(1.0)) * 2.0;
let r = (1.0 - y * y).max(0.0_f32).sqrt();
let theta = golden * i as f32;
cloud.push(Point3f::new(
theta.cos() * r * radius,
y * radius,
theta.sin() * r * radius,
));
}
cloud
}
#[test]
fn test_plane_covariance_degenerate_neighbourhoods() {
assert_eq!(plane_covariance(Matrix3::zeros()), Matrix3::identity());
let line = Matrix3::from_diagonal(&nalgebra::Vector3::new(4.0, 0.0, 0.0));
assert_eq!(plane_covariance(line), Matrix3::identity());
let plane = Matrix3::from_diagonal(&nalgebra::Vector3::new(4.0, 2.0, 0.0));
let c = plane_covariance(plane);
assert_relative_eq!(c[(2, 2)], 1e-3, epsilon = 1e-6);
assert_relative_eq!(c[(0, 0)], 1.0, epsilon = 1e-6);
}
#[test]
fn test_gicp_convergence_is_scale_invariant() {
let solve = |scale: f32| {
let mut source = PointCloud::new();
for i in 0..30 {
for j in 0..30 {
let (x, y) = (i as f32 * 0.1, j as f32 * 0.1);
let z = 0.3 * (1.3 * x).sin() * (0.7 * y).cos();
source.push(Point3f::new(x, y, z) * scale);
}
}
let truth = Isometry3::from_parts(
Translation3::new(0.2 * scale, -0.12 * scale, 0.06 * scale),
UnitQuaternion::from_euler_angles(0.0, 0.0, 0.1),
);
let target = PointCloud::from_points(source.points.iter().map(|p| truth * p).collect());
let config = GicpConfig {
max_iterations: 100,
max_correspondence_distance: scale,
..Default::default()
};
let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
let rotation_error =
(result.transformation.rotation.inverse() * truth.rotation).angle();
let translation_error =
(result.transformation.translation.vector - truth.translation.vector).norm()
/ scale;
(rotation_error, translation_error)
};
let (rot_big, trans_big) = solve(1.0);
let (rot_small, trans_small) = solve(0.01);
assert!(
rot_big < 1e-6 && trans_big < 1e-5,
"1x: rot {rot_big}, trans {trans_big}"
);
assert!(
rot_small < 1e-6 && trans_small < 1e-5,
"1/100 scale stopped early: rot {rot_small}, trans {trans_small}"
);
}
#[test]
fn gicp_identity_converges() {
let cloud = make_sphere(100, 3.0);
let config = GicpConfig {
max_iterations: 30,
..Default::default()
};
let result = gicp(&cloud, &cloud, Isometry3::identity(), config).unwrap();
assert!(result.converged, "should converge for identical clouds");
assert!(result.mse < 1e-4, "mse={}", result.mse);
}
#[test]
fn gicp_recovers_small_translation() {
let source = make_sphere(150, 3.0);
let shift = Vector3f::new(0.1, 0.0, 0.0);
let target = PointCloud::from_points(source.points.iter().map(|p| p + shift).collect());
let config = GicpConfig {
max_iterations: 60,
max_correspondence_distance: 2.0,
..Default::default()
};
let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
let t_err = (result.transformation.translation.vector - shift).magnitude();
assert!(t_err < 0.05, "translation error={}", t_err);
assert!(result.mse < 0.1, "mse={}", result.mse);
}
#[test]
fn gicp_empty_source_errors() {
let empty: PointCloud<Point3f> = PointCloud::new();
let cloud = make_sphere(30, 1.0);
assert!(gicp(&empty, &cloud, Isometry3::identity(), GicpConfig::default()).is_err());
}
#[test]
fn gicp_zero_iterations_errors() {
let cloud = make_sphere(30, 1.0);
let config = GicpConfig {
max_iterations: 0,
..Default::default()
};
assert!(gicp(&cloud, &cloud, Isometry3::identity(), config).is_err());
}
#[test]
fn gicp_result_fields_populated() {
let cloud = make_sphere(60, 2.0);
let config = GicpConfig {
max_iterations: 10,
..Default::default()
};
let result = gicp(&cloud, &cloud, Isometry3::identity(), config).unwrap();
assert!(result.iterations > 0);
assert!(!result.correspondences.is_empty());
}
#[test]
fn gicp_recovers_tiny_rotation_from_identity() {
let source = make_sphere(300, 3.0);
let angle = 2_f32.to_radians();
let rot = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), angle);
let target = PointCloud::from_points(source.points.iter().map(|p| rot * p).collect());
let config = GicpConfig {
max_iterations: 60,
max_correspondence_distance: 0.8,
..Default::default()
};
let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
let rot_err = result.transformation.rotation.angle_to(&rot);
assert!(
rot_err < 0.5_f32.to_radians(),
"rotation error = {:.2}°",
rot_err.to_degrees()
);
assert!(result.mse < 0.01, "mse={}", result.mse);
}
#[test]
fn gicp_refines_rotation_from_near_correct_init() {
let source = make_sphere(200, 3.0);
let true_angle = 8_f32.to_radians();
let init_angle = 6_f32.to_radians(); let true_rot = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), true_angle);
let target = PointCloud::from_points(source.points.iter().map(|p| true_rot * p).collect());
let init = Isometry3::from_parts(
Translation3::identity(),
UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), init_angle),
);
let config = GicpConfig {
max_iterations: 60,
max_correspondence_distance: 0.8,
..Default::default()
};
let result = gicp(&source, &target, init, config).unwrap();
let rot_err = result.transformation.rotation.angle_to(&true_rot);
assert!(
rot_err < 0.5_f32.to_radians(),
"rotation error = {:.2}°",
rot_err.to_degrees()
);
}
#[test]
fn gicp_refines_combined_rotation_and_translation() {
let source = make_sphere(200, 3.0);
let true_angle = 6_f32.to_radians();
let true_shift = Vector3f::new(0.3, 0.0, 0.0);
let true_rot = UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), true_angle);
let true_iso = Isometry3::from_parts(
Translation3::new(true_shift.x, true_shift.y, true_shift.z),
true_rot,
);
let target = PointCloud::from_points(source.points.iter().map(|p| true_iso * p).collect());
let init = Isometry3::from_parts(
Translation3::new(0.24, 0.0, 0.0),
UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), 4.8_f32.to_radians()),
);
let config = GicpConfig {
max_iterations: 80,
max_correspondence_distance: 0.8,
..Default::default()
};
let result = gicp(&source, &target, init, config).unwrap();
let t_err = (result.transformation.translation.vector - true_shift).magnitude();
let rot_err = result.transformation.rotation.angle_to(&true_rot);
assert!(t_err < 0.05, "translation error={}", t_err);
assert!(
rot_err < 0.5_f32.to_radians(),
"rotation error = {:.2}°",
rot_err.to_degrees()
);
}
#[test]
fn gicp_robust_to_gaussian_noise() {
let source = make_sphere(200, 3.0);
let noise_half = 0.05_f32;
let target = PointCloud::from_points(
source
.points
.iter()
.enumerate()
.map(|(i, p)| {
let t = (i as f32 * 1.6180339887_f32).sin() * noise_half;
let u = (i as f32 * 2.7182818284_f32).cos() * noise_half;
let v = (i as f32 * 3.1415926535_f32).sin() * noise_half;
Point3f::new(p.x + t, p.y + u, p.z + v)
})
.collect(),
);
let config = GicpConfig {
max_iterations: 50,
max_correspondence_distance: 1.0,
..Default::default()
};
let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
assert!(result.mse < 0.05, "mse={}", result.mse);
let t_err = result.transformation.translation.vector.magnitude();
assert!(t_err < 0.1, "spurious translation drift={}", t_err);
}
#[test]
fn gicp_robust_to_outlier_points() {
let source = make_sphere(200, 3.0);
let mut target_pts = source.points.clone();
for i in 0..20 {
let t = i as f32;
target_pts.push(Point3f::new(t * 7.3 - 50.0, t * 3.1 - 30.0, t * 5.7 - 40.0));
}
let target = PointCloud::from_points(target_pts);
let config = GicpConfig {
max_iterations: 40,
max_correspondence_distance: 0.5,
..Default::default()
};
let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
let t_err = result.transformation.translation.vector.magnitude();
assert!(t_err < 0.05, "spurious drift from outliers={}", t_err);
assert!(result.mse < 0.01);
}
#[test]
fn gicp_too_few_points_errors() {
let cloud = make_sphere(10, 1.0);
assert!(
gicp(&cloud, &cloud, Isometry3::identity(), GicpConfig::default()).is_err(),
"expected error for cloud with too few points"
);
}
#[test]
fn gicp_coplanar_points_errors() {
let pts: Vec<Point3f> = (0..50)
.flat_map(|i| (0..50).map(move |j| Point3f::new(i as f32 * 0.1, j as f32 * 0.1, 0.0)))
.collect();
let cloud = PointCloud::from_points(pts);
assert!(
gicp(&cloud, &cloud, Isometry3::identity(), GicpConfig::default()).is_err(),
"expected error for coplanar point cloud"
);
}
}