Skip to main content

threecrate_algorithms/
gicp.rs

1//! Generalized ICP (GICP) registration algorithm.
2//!
3//! GICP (Segal et al., 2009) extends standard ICP by modelling each point as a local
4//! Gaussian distribution whose covariance is estimated from its k nearest neighbours.
5//! Each correspondence is weighted by the combined source + target covariance, giving
6//! much stronger robustness to noise than point-to-point or point-to-plane ICP.
7//!
8//! Used in: LOAM, LIO-SAM, and most modern LiDAR odometry pipelines.
9
10use 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// ---------------------------------------------------------------------------
20// Config
21// ---------------------------------------------------------------------------
22
23/// Parameters for [`gicp`].
24#[derive(Debug, Clone)]
25pub struct GicpConfig {
26    /// Maximum number of ICP iterations.
27    pub max_iterations: usize,
28    /// Maximum Euclidean distance for accepting a source–target correspondence.
29    pub max_correspondence_distance: f32,
30    /// Stop when the RMSE improves by less than this fraction of its previous
31    /// value, or an update barely moves the points (same rule as ICP).
32    pub convergence_threshold: f32,
33    /// Number of nearest neighbours used to estimate per-point covariance matrices.
34    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// ---------------------------------------------------------------------------
49// Internal helpers
50// ---------------------------------------------------------------------------
51
52/// Skew-symmetric ("cross-product") matrix of a 3-D vector.
53#[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
58/// Replace a neighbourhood covariance with the "plane" covariance from the GICP
59/// paper: same principal directions, but spread (1, 1, ε) with ε for the
60/// surface normal. This keeps every local surface a thin plate whatever the
61/// scene size; a fixed regularisation term would swamp small scenes and vanish
62/// in large ones.
63///
64/// A neighbourhood with no clear plane (all points in one spot, e.g. stacked
65/// duplicate returns, or on a line) has no meaningful normal, so it gets an
66/// isotropic covariance instead of a plane in a random orientation.
67fn 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
82/// Estimate per-point 3×3 covariance matrices from k nearest neighbours.
83///
84/// Returns a `Vec` of length `points.len()`, aligned index-for-index.
85fn compute_covariances(points: &[Point3f], k: usize) -> Result<Vec<Matrix3<f32>>> {
86    let k = k.max(4); // need ≥ 4 neighbours for a non-degenerate 3-D covariance
87    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                // Sparse neighbourhood: isotropic fallback (same scale as the
96                // normalised covariances below).
97                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
120// ---------------------------------------------------------------------------
121// Public API
122// ---------------------------------------------------------------------------
123
124/// Align `source` to `target` using Generalized ICP.
125///
126/// # Algorithm
127///
128/// 1. Estimate per-point covariances for both clouds using k-NN.
129/// 2. Iteratively:
130///    a. Transform source with the current estimate.
131///    b. Find nearest-neighbour correspondences within `max_correspondence_distance`.
132///    c. Build the weighted 6×6 Gauss-Newton system using combined covariances
133///       `M_i = C_i^T + R C_i^S R^T`.
134///    d. Solve for the incremental 6-DOF update and compose with the current transform.
135///    e. Declare convergence when the RMSE improves by less than
136///       `convergence_threshold` as a fraction, or the update barely moves the
137///       points. Both tests are relative, so they work at any scene size.
138///
139/// # References
140/// Segal, A., Haehnel, D., & Thrun, S. (2009). *Generalized-ICP.*
141/// Robotics: Science and Systems.
142pub 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    // Covariance estimation needs at least k neighbours per point; fewer points
159    // than k_correspondences produces a degenerate (global) covariance.
160    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    // Reject degenerate clouds where all points lie on a plane or line — GICP
172    // requires 3-D structure to build meaningful per-point covariance matrices.
173    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    // Step 1: per-point covariances.
195    let source_covs = compute_covariances(&source.points, config.k_correspondences)?;
196    let target_covs = compute_covariances(&target.points, config.k_correspondences)?;
197
198    // Step 2: KD-tree over target.
199    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        // Transform source points with the current estimate.
208        let transformed_source: Vec<Point3f> = source
209            .points
210            .iter()
211            .map(|p| current_transform * p)
212            .collect();
213
214        // Rotation matrix for covariance transformation.
215        let r_mat = *current_transform.rotation.to_rotation_matrix().matrix();
216
217        // Accumulate the 6×6 Gauss-Newton Hessian H and gradient g.
218        // Pose parameterisation: x = [δω_x, δω_y, δω_z, δt_x, δt_y, δt_z]
219        // (rotation first, matching the existing point-to-plane convention).
220        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            // Combined covariance M = C_t + R C_s R^T.
243            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, // numerically degenerate — skip
247            };
248
249            // Residual r = t_i – T s_i.
250            let residual = target.points[tgt_idx].coords - ts.coords;
251
252            // 3×6 Jacobian J = [A | I] where A = −skew(T s_i).
253            // Contribution to H and g:
254            //   H_rr (0..3, 0..3) = A^T M⁻¹ A
255            //   H_rt (0..3, 3..6) = A^T M⁻¹
256            //   H_tr (3..6, 0..3) = M⁻¹ A = H_rt^T
257            //   H_tt (3..6, 3..6) = M⁻¹
258            //   g_r  (0..3)       = A^T M⁻¹ r
259            //   g_t  (3..6)       = M⁻¹ r
260
261            let a = -skew_sym(&ts.coords); // rotation block
262            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_tr = H_rt^T
272                    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        // Solve H δ = g (Cholesky preferred; LU fallback).
292        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        // Compose incremental transform δT from x = [δω_x, δω_y, δω_z, δt_x, δt_y, δt_z].
301        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        // Convergence check.
310        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// ---------------------------------------------------------------------------
341// Tests
342// ---------------------------------------------------------------------------
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use approx::assert_relative_eq;
348    #[allow(unused_imports)]
349    use nalgebra::Translation3;
350
351    /// Fibonacci-sphere point cloud with outward unit normals (not used by GICP
352    /// directly, but a good well-conditioned test surface).
353    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        // No spread at all (duplicate points): isotropic, not a random plane
372        assert_eq!(plane_covariance(Matrix3::zeros()), Matrix3::identity());
373        // Points on a line along x: no normal either
374        let line = Matrix3::from_diagonal(&nalgebra::Vector3::new(4.0, 0.0, 0.0));
375        assert_eq!(plane_covariance(line), Matrix3::identity());
376        // A plane in x/y: thin along z
377        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        // The same problem at 1x and at 1/100 scale. Before, an absolute MSE
386        // stopping rule and a fixed covariance term both broke the small copy.
387        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    // -------------------------------------------------------------------------
487    // Rotation recovery
488    //
489    // ICP is a LOCAL optimizer: it refines an initial guess rather than solving
490    // the global alignment problem.  Tests use either:
491    //   (a) identity init + tiny rotation (< half the average point spacing), or
492    //   (b) a near-correct initial guess for larger rotations, matching the real
493    //       SLAM use-case where IMU/odometry supplies a rough estimate.
494    // -------------------------------------------------------------------------
495
496    #[test]
497    fn gicp_recovers_tiny_rotation_from_identity() {
498        // 300-point sphere → average angular spacing ≈ 11°.
499        // 2° rotation chord ≈ 0.21 m — well within the half-spacing convergence
500        // basin so every nearest-neighbour correspondence is the correct one.
501        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        // Simulate the SLAM use-case: odometry gives an 80%-correct initial guess
525        // (6° out of 8°).  GICP refines the 2° residual to < 0.5°.
526        let source = make_sphere(200, 3.0);
527        let true_angle = 8_f32.to_radians();
528        let init_angle = 6_f32.to_radians(); // ← provided by odometry / IMU
529        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        // Near-correct init for combined 6° rotation + 0.3 m translation.
554        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        // Initial guess: 80% of the rotation, 80% of the translation.
565        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    // -------------------------------------------------------------------------
588    // Noise robustness
589    // -------------------------------------------------------------------------
590
591    #[test]
592    fn gicp_robust_to_gaussian_noise() {
593        // Target = source + uniform ±0.05 m noise per axis.
594        let source = make_sphere(200, 3.0);
595        let noise_half = 0.05_f32;
596        // Deterministic noise via a simple LCG so tests are reproducible.
597        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        // MSE should be in the noise-variance ballpark (3 × 0.05² ≈ 0.0075),
619        // well below the correspondence-distance threshold.
620        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        // Target = source union 20 far outliers. Tight correspondence distance
628        // should exclude the outliers so the result stays close to identity.
629        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    // -------------------------------------------------------------------------
650    // Degenerate inputs
651    // -------------------------------------------------------------------------
652
653    #[test]
654    fn gicp_too_few_points_errors() {
655        // Fewer points than k_correspondences (default 20).
656        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        // All points on Z = 0 plane — bounding box extent in Z is 0.
666        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}