use rayon::prelude::*;
use nalgebra::{Matrix3, Matrix6, Translation3, UnitQuaternion, Vector6};
use threecrate_core::{Error, Isometry3, NearestNeighborSearch, Point3f, PointCloud, Result, Vector3f};
use crate::nearest_neighbor::KdTree;
use crate::registration::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 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() * 1e-3_f32;
}
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 mut cov = cov / (nf - 1.0).max(1.0);
cov += Matrix3::identity() * 1e-4_f32;
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 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,
);
current_transform = delta_iso * current_transform;
if (prev_mse - mse).abs() < 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 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"
);
}
}