1use nalgebra::{Matrix3, Matrix6, Translation3, UnitQuaternion, Vector6};
11use rayon::prelude::*;
12use threecrate_core::{
13 Error, Isometry3, NearestNeighborSearch, Point3f, PointCloud, Result, Vector3f,
14};
15
16use crate::nearest_neighbor::KdTree;
17use crate::registration::{icp_converged, CloudScale, ICPResult};
18
19#[derive(Debug, Clone)]
25pub struct GicpConfig {
26 pub max_iterations: usize,
28 pub max_correspondence_distance: f32,
30 pub convergence_threshold: f32,
33 pub k_correspondences: usize,
35}
36
37impl Default for GicpConfig {
38 fn default() -> Self {
39 Self {
40 max_iterations: 50,
41 max_correspondence_distance: 1.0,
42 convergence_threshold: 1e-6,
43 k_correspondences: 20,
44 }
45 }
46}
47
48#[inline]
54fn skew_sym(v: &nalgebra::Vector3<f32>) -> Matrix3<f32> {
55 Matrix3::new(0.0, -v.z, v.y, v.z, 0.0, -v.x, -v.y, v.x, 0.0)
56}
57
58fn plane_covariance(cov: Matrix3<f32>) -> Matrix3<f32> {
68 const EPSILON: f32 = 1e-3;
69 let eigen = cov.symmetric_eigen();
70 let largest = eigen.eigenvalues.max();
71 let middle = eigen.eigenvalues.sum() - largest - eigen.eigenvalues.min();
72 if !(largest > 0.0) || middle <= 1e-6 * largest {
73 return Matrix3::identity();
74 }
75 let normal_axis = eigen.eigenvalues.imin();
76 let mut spread = nalgebra::Vector3::new(1.0, 1.0, 1.0);
77 spread[normal_axis] = EPSILON;
78 let v = eigen.eigenvectors;
79 v * Matrix3::from_diagonal(&spread) * v.transpose()
80}
81
82fn compute_covariances(points: &[Point3f], k: usize) -> Result<Vec<Matrix3<f32>>> {
86 let k = k.max(4); let tree = KdTree::new(points)?;
88
89 let covs: Vec<Matrix3<f32>> = points
90 .par_iter()
91 .map(|p| {
92 let neighbours = tree.find_k_nearest(p, k);
93 let n = neighbours.len();
94 if n < 3 {
95 return Matrix3::identity();
98 }
99
100 let nf = n as f32;
101 let mean = neighbours
102 .iter()
103 .map(|(idx, _)| points[*idx].coords)
104 .fold(nalgebra::Vector3::zeros(), |acc, v| acc + v)
105 / nf;
106
107 let mut cov = Matrix3::zeros();
108 for (idx, _) in &neighbours {
109 let d = points[*idx].coords - mean;
110 cov += d * d.transpose();
111 }
112 let cov = cov / (nf - 1.0).max(1.0);
113 plane_covariance(cov)
114 })
115 .collect();
116
117 Ok(covs)
118}
119
120pub fn gicp(
143 source: &PointCloud<Point3f>,
144 target: &PointCloud<Point3f>,
145 init: Isometry3<f32>,
146 config: GicpConfig,
147) -> Result<ICPResult> {
148 if source.is_empty() || target.is_empty() {
149 return Err(Error::InvalidData(
150 "GICP: source or target point cloud is empty".into(),
151 ));
152 }
153 if config.max_iterations == 0 {
154 return Err(Error::InvalidData(
155 "GICP: max_iterations must be > 0".into(),
156 ));
157 }
158 let min_k = config.k_correspondences.max(4);
161 if source.len() < min_k || target.len() < min_k {
162 return Err(Error::InvalidData(format!(
163 "GICP: clouds must have at least {} points for reliable covariance estimation \
164 (k_correspondences={}); got source={}, target={}",
165 min_k,
166 config.k_correspondences,
167 source.len(),
168 target.len()
169 )));
170 }
171 for (label, cloud) in [("source", source), ("target", target)] {
174 let mut min = [f32::INFINITY; 3];
175 let mut max = [f32::NEG_INFINITY; 3];
176 for p in &cloud.points {
177 for ax in 0..3 {
178 min[ax] = min[ax].min(p.coords[ax]);
179 max[ax] = max[ax].max(p.coords[ax]);
180 }
181 }
182 let min_extent = (0..3)
183 .map(|ax| max[ax] - min[ax])
184 .fold(f32::INFINITY, f32::min);
185 if min_extent < 1e-4 {
186 return Err(Error::InvalidData(format!(
187 "GICP: {label} point cloud appears to be coplanar or collinear \
188 (smallest bounding-box dimension = {min_extent:.2e}); \
189 GICP requires 3-D structure"
190 )));
191 }
192 }
193
194 let source_covs = compute_covariances(&source.points, config.k_correspondences)?;
196 let target_covs = compute_covariances(&target.points, config.k_correspondences)?;
197
198 let target_tree = KdTree::new(&target.points)?;
200
201 let mut current_transform = init;
202 let mut prev_mse = f32::INFINITY;
203 let source_scale = CloudScale::of(&source.points);
204 let mut final_corr: Vec<(usize, usize)> = Vec::new();
205
206 for iteration in 0..config.max_iterations {
207 let transformed_source: Vec<Point3f> = source
209 .points
210 .iter()
211 .map(|p| current_transform * p)
212 .collect();
213
214 let r_mat = *current_transform.rotation.to_rotation_matrix().matrix();
216
217 let mut h = Matrix6::<f32>::zeros();
221 let mut gvec = Vector6::<f32>::zeros();
222 let mut n_corr = 0usize;
223 let mut mse_sum = 0.0f32;
224 let mut corr_pairs: Vec<(usize, usize)> = Vec::new();
225
226 for (src_idx, (ts, c_s)) in transformed_source
227 .iter()
228 .zip(source_covs.iter())
229 .enumerate()
230 {
231 let neighbours = target_tree.find_k_nearest(ts, 1);
232 if neighbours.is_empty() {
233 continue;
234 }
235 let (tgt_idx, dist) = neighbours[0];
236 if dist > config.max_correspondence_distance {
237 continue;
238 }
239
240 let c_t = &target_covs[tgt_idx];
241
242 let m = c_t + r_mat * c_s * r_mat.transpose();
244 let m_inv = match m.try_inverse() {
245 Some(inv) => inv,
246 None => continue, };
248
249 let residual = target.points[tgt_idx].coords - ts.coords;
251
252 let a = -skew_sym(&ts.coords); let h_rr = a.transpose() * (m_inv * a);
263 let h_rt = a.transpose() * m_inv;
264 let wr = m_inv * residual;
265 let g_r = a.transpose() * wr;
266
267 for i in 0..3 {
268 for j in 0..3 {
269 h[(i, j)] += h_rr[(i, j)];
270 h[(i, j + 3)] += h_rt[(i, j)];
271 h[(i + 3, j)] += h_rt[(j, i)]; h[(i + 3, j + 3)] += m_inv[(i, j)];
273 }
274 gvec[i] += g_r[i];
275 gvec[i + 3] += wr[i];
276 }
277
278 n_corr += 1;
279 mse_sum += dist * dist;
280 corr_pairs.push((src_idx, tgt_idx));
281 }
282
283 if n_corr < 6 {
284 return Err(Error::Algorithm(
285 "GICP: insufficient correspondences (need ≥ 6)".into(),
286 ));
287 }
288
289 let mse = mse_sum / n_corr as f32;
290
291 let delta = if let Some(chol) = h.cholesky() {
293 chol.solve(&gvec)
294 } else {
295 h.lu().solve(&gvec).ok_or_else(|| {
296 Error::Algorithm("GICP: Gauss-Newton system is ill-conditioned".into())
297 })?
298 };
299
300 let delta_rot = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), delta[2])
302 * UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), delta[1])
303 * UnitQuaternion::from_axis_angle(&Vector3f::x_axis(), delta[0]);
304 let delta_iso =
305 Isometry3::from_parts(Translation3::new(delta[3], delta[4], delta[5]), delta_rot);
306 let moved_centroid = current_transform * source_scale.centroid;
307 current_transform = delta_iso * current_transform;
308
309 if icp_converged(
311 prev_mse,
312 mse,
313 &delta_iso,
314 &moved_centroid,
315 source_scale.radius,
316 config.convergence_threshold,
317 ) {
318 return Ok(ICPResult {
319 transformation: current_transform,
320 mse,
321 iterations: iteration + 1,
322 converged: true,
323 correspondences: corr_pairs,
324 });
325 }
326
327 prev_mse = mse;
328 final_corr = corr_pairs;
329 }
330
331 Ok(ICPResult {
332 transformation: current_transform,
333 mse: prev_mse,
334 iterations: config.max_iterations,
335 converged: false,
336 correspondences: final_corr,
337 })
338}
339
340#[cfg(test)]
345mod tests {
346 use super::*;
347 use approx::assert_relative_eq;
348 #[allow(unused_imports)]
349 use nalgebra::Translation3;
350
351 fn make_sphere(n: usize, radius: f32) -> PointCloud<Point3f> {
354 let mut cloud = PointCloud::new();
355 let golden = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
356 for i in 0..n {
357 let y = 1.0 - (i as f32 / (n as f32 - 1.0).max(1.0)) * 2.0;
358 let r = (1.0 - y * y).max(0.0_f32).sqrt();
359 let theta = golden * i as f32;
360 cloud.push(Point3f::new(
361 theta.cos() * r * radius,
362 y * radius,
363 theta.sin() * r * radius,
364 ));
365 }
366 cloud
367 }
368
369 #[test]
370 fn test_plane_covariance_degenerate_neighbourhoods() {
371 assert_eq!(plane_covariance(Matrix3::zeros()), Matrix3::identity());
373 let line = Matrix3::from_diagonal(&nalgebra::Vector3::new(4.0, 0.0, 0.0));
375 assert_eq!(plane_covariance(line), Matrix3::identity());
376 let plane = Matrix3::from_diagonal(&nalgebra::Vector3::new(4.0, 2.0, 0.0));
378 let c = plane_covariance(plane);
379 assert_relative_eq!(c[(2, 2)], 1e-3, epsilon = 1e-6);
380 assert_relative_eq!(c[(0, 0)], 1.0, epsilon = 1e-6);
381 }
382
383 #[test]
384 fn test_gicp_convergence_is_scale_invariant() {
385 let solve = |scale: f32| {
388 let mut source = PointCloud::new();
389 for i in 0..30 {
390 for j in 0..30 {
391 let (x, y) = (i as f32 * 0.1, j as f32 * 0.1);
392 let z = 0.3 * (1.3 * x).sin() * (0.7 * y).cos();
393 source.push(Point3f::new(x, y, z) * scale);
394 }
395 }
396 let truth = Isometry3::from_parts(
397 Translation3::new(0.2 * scale, -0.12 * scale, 0.06 * scale),
398 UnitQuaternion::from_euler_angles(0.0, 0.0, 0.1),
399 );
400 let target = PointCloud::from_points(source.points.iter().map(|p| truth * p).collect());
401 let config = GicpConfig {
402 max_iterations: 100,
403 max_correspondence_distance: scale,
404 ..Default::default()
405 };
406 let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
407 let rotation_error =
408 (result.transformation.rotation.inverse() * truth.rotation).angle();
409 let translation_error =
410 (result.transformation.translation.vector - truth.translation.vector).norm()
411 / scale;
412 (rotation_error, translation_error)
413 };
414
415 let (rot_big, trans_big) = solve(1.0);
416 let (rot_small, trans_small) = solve(0.01);
417 assert!(
418 rot_big < 1e-6 && trans_big < 1e-5,
419 "1x: rot {rot_big}, trans {trans_big}"
420 );
421 assert!(
422 rot_small < 1e-6 && trans_small < 1e-5,
423 "1/100 scale stopped early: rot {rot_small}, trans {trans_small}"
424 );
425 }
426
427 #[test]
428 fn gicp_identity_converges() {
429 let cloud = make_sphere(100, 3.0);
430 let config = GicpConfig {
431 max_iterations: 30,
432 ..Default::default()
433 };
434 let result = gicp(&cloud, &cloud, Isometry3::identity(), config).unwrap();
435 assert!(result.converged, "should converge for identical clouds");
436 assert!(result.mse < 1e-4, "mse={}", result.mse);
437 }
438
439 #[test]
440 fn gicp_recovers_small_translation() {
441 let source = make_sphere(150, 3.0);
442 let shift = Vector3f::new(0.1, 0.0, 0.0);
443 let target = PointCloud::from_points(source.points.iter().map(|p| p + shift).collect());
444
445 let config = GicpConfig {
446 max_iterations: 60,
447 max_correspondence_distance: 2.0,
448 ..Default::default()
449 };
450 let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
451
452 let t_err = (result.transformation.translation.vector - shift).magnitude();
453 assert!(t_err < 0.05, "translation error={}", t_err);
454 assert!(result.mse < 0.1, "mse={}", result.mse);
455 }
456
457 #[test]
458 fn gicp_empty_source_errors() {
459 let empty: PointCloud<Point3f> = PointCloud::new();
460 let cloud = make_sphere(30, 1.0);
461 assert!(gicp(&empty, &cloud, Isometry3::identity(), GicpConfig::default()).is_err());
462 }
463
464 #[test]
465 fn gicp_zero_iterations_errors() {
466 let cloud = make_sphere(30, 1.0);
467 let config = GicpConfig {
468 max_iterations: 0,
469 ..Default::default()
470 };
471 assert!(gicp(&cloud, &cloud, Isometry3::identity(), config).is_err());
472 }
473
474 #[test]
475 fn gicp_result_fields_populated() {
476 let cloud = make_sphere(60, 2.0);
477 let config = GicpConfig {
478 max_iterations: 10,
479 ..Default::default()
480 };
481 let result = gicp(&cloud, &cloud, Isometry3::identity(), config).unwrap();
482 assert!(result.iterations > 0);
483 assert!(!result.correspondences.is_empty());
484 }
485
486 #[test]
497 fn gicp_recovers_tiny_rotation_from_identity() {
498 let source = make_sphere(300, 3.0);
502 let angle = 2_f32.to_radians();
503 let rot = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), angle);
504 let target = PointCloud::from_points(source.points.iter().map(|p| rot * p).collect());
505
506 let config = GicpConfig {
507 max_iterations: 60,
508 max_correspondence_distance: 0.8,
509 ..Default::default()
510 };
511 let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
512
513 let rot_err = result.transformation.rotation.angle_to(&rot);
514 assert!(
515 rot_err < 0.5_f32.to_radians(),
516 "rotation error = {:.2}°",
517 rot_err.to_degrees()
518 );
519 assert!(result.mse < 0.01, "mse={}", result.mse);
520 }
521
522 #[test]
523 fn gicp_refines_rotation_from_near_correct_init() {
524 let source = make_sphere(200, 3.0);
527 let true_angle = 8_f32.to_radians();
528 let init_angle = 6_f32.to_radians(); let true_rot = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), true_angle);
530 let target = PointCloud::from_points(source.points.iter().map(|p| true_rot * p).collect());
531 let init = Isometry3::from_parts(
532 Translation3::identity(),
533 UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), init_angle),
534 );
535
536 let config = GicpConfig {
537 max_iterations: 60,
538 max_correspondence_distance: 0.8,
539 ..Default::default()
540 };
541 let result = gicp(&source, &target, init, config).unwrap();
542
543 let rot_err = result.transformation.rotation.angle_to(&true_rot);
544 assert!(
545 rot_err < 0.5_f32.to_radians(),
546 "rotation error = {:.2}°",
547 rot_err.to_degrees()
548 );
549 }
550
551 #[test]
552 fn gicp_refines_combined_rotation_and_translation() {
553 let source = make_sphere(200, 3.0);
555 let true_angle = 6_f32.to_radians();
556 let true_shift = Vector3f::new(0.3, 0.0, 0.0);
557 let true_rot = UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), true_angle);
558 let true_iso = Isometry3::from_parts(
559 Translation3::new(true_shift.x, true_shift.y, true_shift.z),
560 true_rot,
561 );
562 let target = PointCloud::from_points(source.points.iter().map(|p| true_iso * p).collect());
563
564 let init = Isometry3::from_parts(
566 Translation3::new(0.24, 0.0, 0.0),
567 UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), 4.8_f32.to_radians()),
568 );
569
570 let config = GicpConfig {
571 max_iterations: 80,
572 max_correspondence_distance: 0.8,
573 ..Default::default()
574 };
575 let result = gicp(&source, &target, init, config).unwrap();
576
577 let t_err = (result.transformation.translation.vector - true_shift).magnitude();
578 let rot_err = result.transformation.rotation.angle_to(&true_rot);
579 assert!(t_err < 0.05, "translation error={}", t_err);
580 assert!(
581 rot_err < 0.5_f32.to_radians(),
582 "rotation error = {:.2}°",
583 rot_err.to_degrees()
584 );
585 }
586
587 #[test]
592 fn gicp_robust_to_gaussian_noise() {
593 let source = make_sphere(200, 3.0);
595 let noise_half = 0.05_f32;
596 let target = PointCloud::from_points(
598 source
599 .points
600 .iter()
601 .enumerate()
602 .map(|(i, p)| {
603 let t = (i as f32 * 1.6180339887_f32).sin() * noise_half;
604 let u = (i as f32 * 2.7182818284_f32).cos() * noise_half;
605 let v = (i as f32 * 3.1415926535_f32).sin() * noise_half;
606 Point3f::new(p.x + t, p.y + u, p.z + v)
607 })
608 .collect(),
609 );
610
611 let config = GicpConfig {
612 max_iterations: 50,
613 max_correspondence_distance: 1.0,
614 ..Default::default()
615 };
616 let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
617
618 assert!(result.mse < 0.05, "mse={}", result.mse);
621 let t_err = result.transformation.translation.vector.magnitude();
622 assert!(t_err < 0.1, "spurious translation drift={}", t_err);
623 }
624
625 #[test]
626 fn gicp_robust_to_outlier_points() {
627 let source = make_sphere(200, 3.0);
630 let mut target_pts = source.points.clone();
631 for i in 0..20 {
632 let t = i as f32;
633 target_pts.push(Point3f::new(t * 7.3 - 50.0, t * 3.1 - 30.0, t * 5.7 - 40.0));
634 }
635 let target = PointCloud::from_points(target_pts);
636
637 let config = GicpConfig {
638 max_iterations: 40,
639 max_correspondence_distance: 0.5,
640 ..Default::default()
641 };
642 let result = gicp(&source, &target, Isometry3::identity(), config).unwrap();
643
644 let t_err = result.transformation.translation.vector.magnitude();
645 assert!(t_err < 0.05, "spurious drift from outliers={}", t_err);
646 assert!(result.mse < 0.01);
647 }
648
649 #[test]
654 fn gicp_too_few_points_errors() {
655 let cloud = make_sphere(10, 1.0);
657 assert!(
658 gicp(&cloud, &cloud, Isometry3::identity(), GicpConfig::default()).is_err(),
659 "expected error for cloud with too few points"
660 );
661 }
662
663 #[test]
664 fn gicp_coplanar_points_errors() {
665 let pts: Vec<Point3f> = (0..50)
667 .flat_map(|i| (0..50).map(move |j| Point3f::new(i as f32 * 0.1, j as f32 * 0.1, 0.0)))
668 .collect();
669 let cloud = PointCloud::from_points(pts);
670 assert!(
671 gicp(&cloud, &cloud, Isometry3::identity(), GicpConfig::default()).is_err(),
672 "expected error for coplanar point cloud"
673 );
674 }
675}