Skip to main content

threecrate_algorithms/
global_registration.rs

1//! Global point cloud registration via FPFH feature matching and RANSAC.
2//!
3//! Implements a coarse-to-fine pipeline:
4//! 1. Estimate surface normals (if not pre-computed)
5//! 2. Extract FPFH descriptors from source and target
6//! 3. Match descriptors to build putative correspondences
7//! 4. RANSAC to find the best rigid transformation consistent with those correspondences
8//! 5. Optional ICP refinement to polish the coarse alignment
9//!
10//! Expected use: call `global_registration()` to get a good initial pose, then hand off to ICP
11//! or NDT for sub-millimetre refinement.
12
13use crate::features::{extract_fpfh_features_with_normals, FpfhConfig, FPFH_DIM};
14use crate::normals::estimate_normals;
15use crate::registration::{icp_point_to_point, ICPResult};
16use nalgebra::{Matrix3, Rotation3, Translation3, UnitQuaternion, Vector3};
17use rand::Rng;
18use rayon::prelude::*;
19use threecrate_core::{Error, Isometry3, Point3f, PointCloud, Result};
20
21// ---------------------------------------------------------------------------
22// Config
23// ---------------------------------------------------------------------------
24
25/// Configuration for the global registration pipeline.
26#[derive(Debug, Clone)]
27pub struct GlobalRegistrationConfig {
28    /// Maximum RANSAC iterations.  Higher = more robust, slower.
29    pub ransac_iterations: usize,
30    /// Maximum Euclidean distance (in model units) for a correspondence to count as an inlier.
31    pub distance_threshold: f32,
32    /// Early-exit RANSAC when the fraction of inlier correspondences exceeds this value.
33    pub inlier_ratio: f32,
34    /// Radius used for FPFH feature extraction.
35    pub fpfh_radius: f32,
36    /// Minimum neighbours required by radius search; falls back to k-NN when fewer found.
37    pub fpfh_k_neighbors: usize,
38    /// Number of nearest neighbours used for surface normal estimation.
39    pub normal_k_neighbors: usize,
40    /// Run ICP after RANSAC to refine the coarse alignment.
41    pub refine_with_icp: bool,
42    /// Maximum ICP iterations (only used when `refine_with_icp` is true).
43    pub icp_max_iterations: usize,
44    /// Maximum point-to-point correspondence distance for ICP (None = unlimited).
45    pub icp_distance_threshold: Option<f32>,
46}
47
48impl Default for GlobalRegistrationConfig {
49    fn default() -> Self {
50        Self {
51            ransac_iterations: 50_000,
52            distance_threshold: 0.05,
53            inlier_ratio: 0.25,
54            fpfh_radius: 0.25,
55            fpfh_k_neighbors: 10,
56            normal_k_neighbors: 10,
57            refine_with_icp: true,
58            icp_max_iterations: 50,
59            icp_distance_threshold: None,
60        }
61    }
62}
63
64// ---------------------------------------------------------------------------
65// Result
66// ---------------------------------------------------------------------------
67
68/// Result of the global registration pipeline.
69#[derive(Debug, Clone)]
70pub struct GlobalRegistrationResult {
71    /// Best-found rigid transformation that maps `source` onto `target`.
72    pub transformation: Isometry3<f32>,
73    /// Number of correspondences classified as inliers under `transformation`.
74    pub inlier_count: usize,
75    /// Fraction of total correspondences that are inliers (0.0 – 1.0).
76    pub inlier_ratio: f32,
77    /// ICP refinement result, present when `config.refine_with_icp` is `true`.
78    pub icp_result: Option<ICPResult>,
79}
80
81// ---------------------------------------------------------------------------
82// Internal helpers
83// ---------------------------------------------------------------------------
84
85/// Squared L2 distance between two FPFH descriptors.
86#[inline]
87fn fpfh_dist_sq(a: &[f32; FPFH_DIM], b: &[f32; FPFH_DIM]) -> f32 {
88    a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum()
89}
90
91/// Match source descriptors to target descriptors (nearest-neighbour in descriptor space).
92///
93/// Returns a list of `(source_idx, target_idx)` pairs.
94fn find_feature_correspondences(
95    src_descs: &[[f32; FPFH_DIM]],
96    tgt_descs: &[[f32; FPFH_DIM]],
97) -> Vec<(usize, usize)> {
98    src_descs
99        .par_iter()
100        .enumerate()
101        .filter_map(|(i, sd)| {
102            let best = tgt_descs.iter().enumerate().min_by(|(_, ta), (_, tb)| {
103                fpfh_dist_sq(sd, ta)
104                    .partial_cmp(&fpfh_dist_sq(sd, tb))
105                    .unwrap_or(std::cmp::Ordering::Equal)
106            })?;
107            Some((i, best.0))
108        })
109        .collect()
110}
111
112/// Estimate a rigid transformation from ≥ 3 point pairs using SVD (same method as ICP).
113fn estimate_transform_svd(src_pts: &[Point3f], tgt_pts: &[Point3f]) -> Option<Isometry3<f32>> {
114    let n = src_pts.len();
115    if n < 3 {
116        return None;
117    }
118    let scale = 1.0 / n as f32;
119
120    let src_centroid = src_pts.iter().fold(Vector3::zeros(), |a, p| a + p.coords) * scale;
121    let tgt_centroid = tgt_pts.iter().fold(Vector3::zeros(), |a, p| a + p.coords) * scale;
122
123    let mut h = Matrix3::<f32>::zeros();
124    for (s, t) in src_pts.iter().zip(tgt_pts.iter()) {
125        let ds = s.coords - src_centroid;
126        let dt = t.coords - tgt_centroid;
127        h += ds * dt.transpose();
128    }
129
130    let svd = h.svd(true, true);
131    let u = svd.u?;
132    let vt = svd.v_t?;
133    let mut r = vt.transpose() * u.transpose();
134
135    // Fix reflection
136    if r.determinant() < 0.0 {
137        let mut vt_fix = vt;
138        vt_fix.row_mut(2).neg_mut();
139        r = vt_fix.transpose() * u.transpose();
140    }
141
142    let rotation = UnitQuaternion::from_rotation_matrix(&Rotation3::from_matrix_unchecked(r));
143    let t = tgt_centroid - rotation * src_centroid;
144    Some(Isometry3::from_parts(Translation3::from(t), rotation))
145}
146
147/// Count correspondences that are inliers under `transform`.
148fn count_inliers(
149    corrs: &[(usize, usize)],
150    src_pts: &[Point3f],
151    tgt_pts: &[Point3f],
152    transform: &Isometry3<f32>,
153    threshold: f32,
154) -> usize {
155    let thr_sq = threshold * threshold;
156    corrs
157        .iter()
158        .filter(|&&(si, ti)| {
159            let tp = transform * src_pts[si];
160            (tp - tgt_pts[ti]).magnitude_squared() <= thr_sq
161        })
162        .count()
163}
164
165// ---------------------------------------------------------------------------
166// Public API
167// ---------------------------------------------------------------------------
168
169/// Run the full global registration pipeline on raw (un-normalised) point clouds.
170///
171/// This function estimates normals internally. For more control (e.g. when normals are
172/// already computed or cloud density is irregular) call `global_registration_with_normals`.
173///
174/// # Workflow
175/// 1. Normal estimation → FPFH extraction → feature matching → RANSAC → optional ICP
176///
177/// # Arguments
178/// * `source` – Point cloud to align (the "model")
179/// * `target` – Reference point cloud (the "scene")
180/// * `config` – Pipeline parameters
181///
182/// # Returns
183/// [`GlobalRegistrationResult`] containing the best transformation found, inlier statistics,
184/// and (optionally) the ICP refinement result.
185pub fn global_registration(
186    source: &PointCloud<Point3f>,
187    target: &PointCloud<Point3f>,
188    config: &GlobalRegistrationConfig,
189) -> Result<GlobalRegistrationResult> {
190    if source.is_empty() {
191        return Err(Error::Algorithm("Source point cloud is empty".into()));
192    }
193    if target.is_empty() {
194        return Err(Error::Algorithm("Target point cloud is empty".into()));
195    }
196
197    let src_normals = estimate_normals(source, config.normal_k_neighbors)?;
198    let tgt_normals = estimate_normals(target, config.normal_k_neighbors)?;
199
200    global_registration_with_normals(&src_normals, &tgt_normals, source, target, config)
201}
202
203/// Global registration when surface normals are already available.
204///
205/// Skips normal estimation; otherwise identical to [`global_registration`].
206///
207/// # Arguments
208/// * `source_n` – Source cloud with normals (for FPFH)
209/// * `target_n` – Target cloud with normals (for FPFH)
210/// * `source`   – Raw source positions (for ICP refinement)
211/// * `target`   – Raw target positions (for ICP refinement)
212/// * `config`   – Pipeline parameters
213pub fn global_registration_with_normals(
214    source_n: &PointCloud<threecrate_core::NormalPoint3f>,
215    target_n: &PointCloud<threecrate_core::NormalPoint3f>,
216    source: &PointCloud<Point3f>,
217    target: &PointCloud<Point3f>,
218    config: &GlobalRegistrationConfig,
219) -> Result<GlobalRegistrationResult> {
220    if source_n.is_empty() || target_n.is_empty() {
221        return Err(Error::Algorithm("Source or target cloud is empty".into()));
222    }
223
224    // --- FPFH extraction ---
225    let fpfh_cfg = FpfhConfig {
226        search_radius: config.fpfh_radius,
227        k_neighbors: config.fpfh_k_neighbors,
228    };
229    let src_descs = extract_fpfh_features_with_normals(source_n, &fpfh_cfg)?;
230    let tgt_descs = extract_fpfh_features_with_normals(target_n, &fpfh_cfg)?;
231
232    // --- Feature correspondences ---
233    let corrs = find_feature_correspondences(&src_descs, &tgt_descs);
234
235    if corrs.len() < 3 {
236        return Err(Error::Algorithm(
237            "Too few feature correspondences for RANSAC (need ≥ 3)".into(),
238        ));
239    }
240
241    let src_pts = &source_n
242        .points
243        .iter()
244        .map(|p| p.position)
245        .collect::<Vec<_>>();
246    let tgt_pts = &target_n
247        .points
248        .iter()
249        .map(|p| p.position)
250        .collect::<Vec<_>>();
251
252    // --- RANSAC ---
253    let mut best_transform = Isometry3::identity();
254    let mut best_inliers = 0usize;
255    let early_exit_count = ((config.inlier_ratio * corrs.len() as f32).ceil() as usize).max(3);
256    let mut rng = rand::rng();
257    let n_corrs = corrs.len();
258
259    for _ in 0..config.ransac_iterations {
260        // Pick 3 unique random correspondence indices
261        let i0 = rng.random_range(0..n_corrs);
262        let mut i1 = rng.random_range(0..n_corrs - 1);
263        if i1 >= i0 {
264            i1 += 1;
265        }
266        let mut i2 = rng.random_range(0..n_corrs - 2);
267        if i2 >= i0.min(i1) {
268            i2 += 1;
269        }
270        if i2 >= i0.max(i1) {
271            i2 += 1;
272        }
273        let sample = [i0, i1, i2];
274
275        let s_pts: Vec<Point3f> = sample.iter().map(|&i| src_pts[corrs[i].0]).collect();
276        let t_pts: Vec<Point3f> = sample.iter().map(|&i| tgt_pts[corrs[i].1]).collect();
277
278        let transform = match estimate_transform_svd(&s_pts, &t_pts) {
279            Some(t) => t,
280            None => continue,
281        };
282
283        let inliers = count_inliers(
284            &corrs,
285            src_pts,
286            tgt_pts,
287            &transform,
288            config.distance_threshold,
289        );
290
291        if inliers > best_inliers {
292            best_inliers = inliers;
293            best_transform = transform;
294
295            if inliers >= early_exit_count {
296                break;
297            }
298        }
299    }
300
301    let total_corrs = corrs.len();
302    let inlier_ratio = if total_corrs > 0 {
303        best_inliers as f32 / total_corrs as f32
304    } else {
305        0.0
306    };
307
308    // --- ICP refinement ---
309    let icp_result = if config.refine_with_icp {
310        Some(icp_point_to_point(
311            source,
312            target,
313            best_transform,
314            config.icp_max_iterations,
315            1e-6,
316            config.icp_distance_threshold,
317        )?)
318    } else {
319        None
320    };
321
322    let final_transform = icp_result
323        .as_ref()
324        .map(|r| r.transformation)
325        .unwrap_or(best_transform);
326
327    Ok(GlobalRegistrationResult {
328        transformation: final_transform,
329        inlier_count: best_inliers,
330        inlier_ratio,
331        icp_result,
332    })
333}
334
335// ---------------------------------------------------------------------------
336// Tests
337// ---------------------------------------------------------------------------
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use nalgebra::{Isometry3, Translation3, UnitQuaternion};
343    use threecrate_core::{Point3f, PointCloud};
344
345    fn grid_cloud(nx: usize, ny: usize, nz: usize, scale: f32) -> PointCloud<Point3f> {
346        let mut pts = Vec::new();
347        for ix in 0..nx {
348            for iy in 0..ny {
349                for iz in 0..nz {
350                    pts.push(Point3f::new(
351                        ix as f32 * scale,
352                        iy as f32 * scale,
353                        iz as f32 * scale,
354                    ));
355                }
356            }
357        }
358        PointCloud { points: pts }
359    }
360
361    fn apply(cloud: &PointCloud<Point3f>, iso: &Isometry3<f32>) -> PointCloud<Point3f> {
362        PointCloud {
363            points: cloud.points.iter().map(|p| iso * p).collect(),
364        }
365    }
366
367    #[test]
368    fn test_global_reg_empty_source() {
369        let empty: PointCloud<Point3f> = PointCloud { points: vec![] };
370        let target = grid_cloud(4, 4, 4, 1.0);
371        let cfg = GlobalRegistrationConfig::default();
372        assert!(global_registration(&empty, &target, &cfg).is_err());
373    }
374
375    #[test]
376    fn test_global_reg_empty_target() {
377        let source = grid_cloud(4, 4, 4, 1.0);
378        let empty: PointCloud<Point3f> = PointCloud { points: vec![] };
379        let cfg = GlobalRegistrationConfig::default();
380        assert!(global_registration(&source, &empty, &cfg).is_err());
381    }
382
383    #[test]
384    fn test_global_reg_identity() {
385        let cloud = grid_cloud(4, 4, 4, 1.0);
386        let cfg = GlobalRegistrationConfig {
387            ransac_iterations: 200,
388            distance_threshold: 0.5,
389            fpfh_radius: 3.0,
390            refine_with_icp: false,
391            ..Default::default()
392        };
393        let result = global_registration(&cloud, &cloud, &cfg).unwrap();
394        // With identical clouds, should find many inliers
395        assert!(result.inlier_count > 0);
396        assert!(result.inlier_ratio > 0.0);
397    }
398
399    #[test]
400    fn test_global_reg_returns_valid_isometry() {
401        let cloud = grid_cloud(4, 4, 4, 1.0);
402        let cfg = GlobalRegistrationConfig {
403            ransac_iterations: 100,
404            distance_threshold: 0.5,
405            fpfh_radius: 3.0,
406            refine_with_icp: false,
407            ..Default::default()
408        };
409        let result = global_registration(&cloud, &cloud, &cfg).unwrap();
410        // Rotation must have unit quaternion norm
411        let qnorm = result.transformation.rotation.norm();
412        assert!((qnorm - 1.0).abs() < 1e-5);
413    }
414
415    #[test]
416    fn test_global_reg_with_icp() {
417        let target = grid_cloud(4, 4, 4, 1.0);
418        let t = Isometry3::from_parts(Translation3::new(0.3, 0.2, 0.1), UnitQuaternion::identity());
419        let source = apply(&target, &t);
420        let cfg = GlobalRegistrationConfig {
421            ransac_iterations: 500,
422            distance_threshold: 1.0,
423            fpfh_radius: 3.0,
424            refine_with_icp: true,
425            icp_max_iterations: 30,
426            icp_distance_threshold: Some(2.0),
427            ..Default::default()
428        };
429        let result = global_registration(&source, &target, &cfg).unwrap();
430        assert!(result.icp_result.is_some());
431    }
432
433    #[test]
434    fn test_estimate_transform_svd_three_points() {
435        let src = vec![
436            Point3f::new(0.0, 0.0, 0.0),
437            Point3f::new(1.0, 0.0, 0.0),
438            Point3f::new(0.0, 1.0, 0.0),
439        ];
440        let shift = Vector3::new(1.0f32, 2.0, 3.0);
441        let tgt: Vec<Point3f> = src
442            .iter()
443            .map(|p| Point3f::from(p.coords + shift))
444            .collect();
445        let iso = estimate_transform_svd(&src, &tgt).unwrap();
446        let t = iso.translation.vector;
447        assert!((t.x - 1.0).abs() < 1e-4);
448        assert!((t.y - 2.0).abs() < 1e-4);
449        assert!((t.z - 3.0).abs() < 1e-4);
450    }
451
452    #[test]
453    fn test_config_defaults() {
454        let cfg = GlobalRegistrationConfig::default();
455        assert!(cfg.ransac_iterations > 0);
456        assert!(cfg.distance_threshold > 0.0);
457        assert!(cfg.inlier_ratio > 0.0 && cfg.inlier_ratio < 1.0);
458        assert!(cfg.fpfh_radius > 0.0);
459    }
460}