Skip to main content

threecrate_algorithms/
ndt_registration.rs

1//! NDT (Normal Distributions Transform) registration algorithm
2//!
3//! NDT is a point cloud registration method that represents the target point cloud
4//! as a set of normal distributions (one per voxel). It is more robust than ICP for
5//! large initial misalignments and sparse point clouds.
6//!
7//! Reference: Biber & Straßer (2003), "The Normal Distributions Transform: A New Approach to Laser Scan Matching"
8
9use crate::registration::LocalFrame;
10use nalgebra::{Matrix3, Translation3, UnitQuaternion, Vector3};
11use std::collections::HashMap;
12use threecrate_core::{Error, Isometry3, Point3f, PointCloud, Result};
13
14/// Configuration for NDT registration
15#[derive(Debug, Clone)]
16pub struct NdtConfig {
17    /// Voxel grid resolution (side length of each cell)
18    pub resolution: f32,
19    /// Maximum step size for gradient descent
20    pub step_size: f32,
21    /// Maximum number of iterations
22    pub max_iterations: usize,
23    /// Convergence threshold (minimum change in transformation norm)
24    pub epsilon: f32,
25    /// Minimum number of points required in a voxel to compute distribution
26    pub min_points_per_voxel: usize,
27}
28
29impl Default for NdtConfig {
30    fn default() -> Self {
31        Self {
32            resolution: 1.0,
33            step_size: 0.1,
34            max_iterations: 35,
35            epsilon: 1e-4,
36            min_points_per_voxel: 5,
37        }
38    }
39}
40
41/// Result of NDT registration
42#[derive(Debug, Clone)]
43pub struct NdtResult {
44    /// Final transformation (source → target)
45    pub transformation: Isometry3<f32>,
46    /// Final score (higher = better fit; NDT score)
47    pub score: f32,
48    /// Number of iterations performed
49    pub iterations: usize,
50    /// Whether convergence was achieved
51    pub converged: bool,
52}
53
54/// A single voxel's normal distribution: mean and inverse covariance
55#[derive(Debug, Clone)]
56struct VoxelDistribution {
57    mean: Vector3<f32>,
58    inv_cov: Matrix3<f32>,
59}
60
61/// Discretize a point to voxel key
62fn voxel_key(p: &Point3f, resolution: f32) -> (i32, i32, i32) {
63    (
64        (p.x / resolution).floor() as i32,
65        (p.y / resolution).floor() as i32,
66        (p.z / resolution).floor() as i32,
67    )
68}
69
70/// Build the NDT voxel grid from a target point cloud
71fn build_voxel_grid(
72    target: &PointCloud<Point3f>,
73    resolution: f32,
74    min_points: usize,
75) -> HashMap<(i32, i32, i32), VoxelDistribution> {
76    // Group points by voxel
77    let mut cells: HashMap<(i32, i32, i32), Vec<Vector3<f32>>> = HashMap::new();
78    for p in &target.points {
79        let key = voxel_key(p, resolution);
80        cells
81            .entry(key)
82            .or_default()
83            .push(Vector3::new(p.x, p.y, p.z));
84    }
85
86    let mut grid = HashMap::new();
87    for (key, pts) in cells {
88        if pts.len() < min_points {
89            continue;
90        }
91        let n = pts.len() as f32;
92
93        // Compute mean
94        let mean = pts.iter().fold(Vector3::zeros(), |acc, p| acc + p) / n;
95
96        // Compute covariance
97        let mut cov = Matrix3::zeros();
98        for p in &pts {
99            let d = p - mean;
100            cov += d * d.transpose();
101        }
102        cov /= n;
103
104        // Regularize to avoid singular matrices
105        cov += Matrix3::identity() * 1e-4;
106
107        if let Some(inv_cov) = cov.try_inverse() {
108            grid.insert(key, VoxelDistribution { mean, inv_cov });
109        }
110    }
111    grid
112}
113
114/// Compute the NDT score and gradient/hessian for a given pose
115///
116/// Returns (score, gradient_6dof, hessian_6x6)
117/// The 6-DOF parameterization is [tx, ty, tz, rx, ry, rz] (small angles).
118fn compute_score_and_derivatives(
119    source: &[Point3f],
120    grid: &HashMap<(i32, i32, i32), VoxelDistribution>,
121    transform: &Isometry3<f32>,
122    resolution: f32,
123) -> (f32, nalgebra::Vector6<f32>, nalgebra::Matrix6<f32>) {
124    let mut score = 0.0_f32;
125    let mut gradient = nalgebra::Vector6::<f32>::zeros();
126    let mut hessian = nalgebra::Matrix6::<f32>::zeros();
127
128    for src_pt in source {
129        // Transform source point
130        let p = transform * src_pt;
131        let key = voxel_key(&p, resolution);
132
133        let dist = match grid.get(&key) {
134            Some(d) => d,
135            None => continue,
136        };
137
138        let diff = Vector3::new(p.x, p.y, p.z) - dist.mean;
139        let cov_diff = dist.inv_cov * diff;
140        let exponent = -0.5 * diff.dot(&cov_diff);
141        let e = exponent.exp();
142
143        score += e;
144
145        // Jacobian of transformed point w.r.t. 6-DOF params
146        // [∂p/∂tx, ∂p/∂ty, ∂p/∂tz, ∂p/∂rx, ∂p/∂ry, ∂p/∂rz]
147        // Translation part is identity
148        let dp_dtrans = Matrix3::identity();
149        // Rotation part. The update is applied as `delta * transform`, which
150        // rotates the moved point p = R*s + t, so the columns are skew(axis) * p
151        // (using R*s alone breaks convergence once t is far from zero).
152        let dp_drx = Vector3::new(0.0, -p.z, p.y); // skew([1,0,0]) * p
153        let dp_dry = Vector3::new(p.z, 0.0, -p.x); // skew([0,1,0]) * p
154        let dp_drz = Vector3::new(-p.y, p.x, 0.0); // skew([0,0,1]) * p
155
156        // Full jacobian: 3x6 matrix (rows=xyz, cols=6dof)
157        let mut jac = nalgebra::Matrix3x6::<f32>::zeros();
158        jac.fixed_columns_mut::<3>(0).copy_from(&dp_dtrans);
159        jac.column_mut(3).copy_from(&dp_drx);
160        jac.column_mut(4).copy_from(&dp_dry);
161        jac.column_mut(5).copy_from(&dp_drz);
162
163        // g = J^T * Σ^-1 * diff  (NDT gradient contribution)
164        let g_vec = jac.transpose() * cov_diff;
165        gradient += e * g_vec;
166
167        // H ≈ J^T * Σ^-1 * J (Gauss-Newton approximation)
168        let h_contrib = jac.transpose() * dist.inv_cov * jac;
169        hessian += e * h_contrib;
170    }
171
172    (score, gradient, hessian)
173}
174
175/// Run NDT registration to align `source` onto `target`.
176///
177/// # Arguments
178/// * `source` - The point cloud to align
179/// * `target` - The reference point cloud
180/// * `initial_transform` - Initial guess for the transformation
181/// * `config` - NDT configuration parameters
182///
183/// # Returns
184/// [`NdtResult`] with the final transformation, score, iterations, and convergence flag.
185pub fn ndt_registration(
186    source: &PointCloud<Point3f>,
187    target: &PointCloud<Point3f>,
188    initial_transform: Isometry3<f32>,
189    config: &NdtConfig,
190) -> Result<NdtResult> {
191    if source.points.is_empty() {
192        return Err(Error::Algorithm("Source point cloud is empty".into()));
193    }
194    if target.points.len() < config.min_points_per_voxel {
195        return Err(Error::Algorithm(
196            "Target point cloud has too few points for NDT voxel grid".into(),
197        ));
198    }
199
200    // Rotation steps turn about the origin, so solve in a frame centred on the
201    // target: far from the origin a tiny rotation would swing the cloud by
202    // metres and the solver would need many more steps (or never converge).
203    let frame = LocalFrame::centred_on(&target.points);
204    let target = PointCloud::from_points(frame.points_to_local(&target.points));
205    let source = frame.points_to_local(&source.points);
206    let initial_transform = frame.transform_to_local(&initial_transform);
207
208    let grid = build_voxel_grid(&target, config.resolution, config.min_points_per_voxel);
209    if grid.is_empty() {
210        return Err(Error::Algorithm(
211            "NDT voxel grid is empty — try a larger resolution or lower min_points_per_voxel"
212                .into(),
213        ));
214    }
215
216    let mut transform = initial_transform;
217    let mut converged = false;
218    let mut iterations = 0;
219    let mut score = 0.0_f32;
220
221    for iter in 0..config.max_iterations {
222        iterations = iter + 1;
223
224        let (s, grad, hess) =
225            compute_score_and_derivatives(&source, &grid, &transform, config.resolution);
226        score = s;
227
228        // Regularize hessian
229        let reg = nalgebra::Matrix6::<f32>::identity() * 1e-6;
230        let hess_reg = hess + reg;
231
232        // Solve H * delta = -gradient  (Newton step)
233        let delta = match hess_reg.lu().solve(&(-grad)) {
234            Some(d) => d,
235            None => break,
236        };
237
238        // Clamp step size
239        let step_norm = delta.norm();
240        let delta = if step_norm > config.step_size {
241            delta * (config.step_size / step_norm)
242        } else {
243            delta
244        };
245
246        // Check convergence
247        if delta.norm() < config.epsilon {
248            converged = true;
249            break;
250        }
251
252        // Apply delta to transform
253        let dt = Translation3::new(delta[0], delta[1], delta[2]);
254        let dr = UnitQuaternion::from_euler_angles(delta[3], delta[4], delta[5]);
255        let delta_iso = Isometry3::from_parts(dt, dr);
256        transform = delta_iso * transform;
257    }
258
259    Ok(NdtResult {
260        transformation: frame.transform_to_world(&transform),
261        score,
262        iterations,
263        converged,
264    })
265}
266
267/// Convenience wrapper with default config
268pub fn ndt_registration_default(
269    source: &PointCloud<Point3f>,
270    target: &PointCloud<Point3f>,
271    initial_transform: Isometry3<f32>,
272) -> Result<NdtResult> {
273    ndt_registration(source, target, initial_transform, &NdtConfig::default())
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use nalgebra::{Translation3, UnitQuaternion};
280    use threecrate_core::PointCloud;
281
282    fn make_grid_cloud(nx: usize, ny: usize, nz: usize, scale: f32) -> PointCloud<Point3f> {
283        let mut points = Vec::new();
284        for ix in 0..nx {
285            for iy in 0..ny {
286                for iz in 0..nz {
287                    points.push(Point3f::new(
288                        ix as f32 * scale,
289                        iy as f32 * scale,
290                        iz as f32 * scale,
291                    ));
292                }
293            }
294        }
295        PointCloud { points }
296    }
297
298    fn apply_transform(cloud: &PointCloud<Point3f>, iso: &Isometry3<f32>) -> PointCloud<Point3f> {
299        PointCloud {
300            points: cloud.points.iter().map(|p| iso * p).collect(),
301        }
302    }
303
304    /// Max distance between the source moved by `estimate` and by `truth`.
305    fn alignment_error(
306        source: &PointCloud<Point3f>,
307        estimate: &Isometry3<f32>,
308        truth: &Isometry3<f32>,
309    ) -> f32 {
310        source
311            .points
312            .iter()
313            .map(|p| (estimate * p - truth * p).norm())
314            .fold(0.0, f32::max)
315    }
316
317    #[test]
318    fn test_ndt_works_with_a_large_initial_translation() {
319        // A scan near the origin aligned into a map 20 m away, starting from a
320        // rough guess (like GPS) that carries the 20 m. The same small motion
321        // must be solved as accurately as when the map is at the origin.
322        let surface: Vec<Point3f> = (0..1600)
323            .map(|i| {
324                let (x, y) = ((i % 40) as f32 * 0.1, (i / 40) as f32 * 0.1);
325                Point3f::new(x, y, 0.3 * (x * 1.5).sin() + 0.2 * (y * 2.0).cos())
326            })
327            .collect();
328        let motion = Isometry3::from_parts(
329            Translation3::new(0.1, 0.05, 0.0),
330            UnitQuaternion::from_euler_angles(0.0, 0.0, 0.08),
331        );
332        let source = PointCloud {
333            points: surface.iter().map(|p| motion.inverse() * p).collect(),
334        };
335        // Default iteration cap. Before the fix 50 m was 0.87 m off and 200 m
336        // was 9.6 m off.
337        let config = NdtConfig {
338            resolution: 0.5,
339            ..Default::default()
340        };
341        for offset in [0.0, 20.0, 200.0] {
342            let guess = Isometry3::translation(offset, 0.0, 0.0);
343            let target = apply_transform(
344                &PointCloud {
345                    points: surface.clone(),
346                },
347                &guess,
348            );
349            let truth = guess * motion;
350            let result = ndt_registration(&source, &target, guess, &config).unwrap();
351            let error = alignment_error(&source, &result.transformation, &truth);
352            assert!(error < 0.02, "offset {offset} m: error {error} m");
353        }
354    }
355
356    #[test]
357    fn test_ndt_identity() {
358        let target = make_grid_cloud(5, 5, 5, 1.0);
359        let config = NdtConfig {
360            resolution: 2.0,
361            min_points_per_voxel: 2,
362            ..Default::default()
363        };
364        let result = ndt_registration(&target, &target, Isometry3::identity(), &config).unwrap();
365        assert!(result.score > 0.0);
366        // With identical clouds, translation should be near zero
367        let t = result.transformation.translation.vector;
368        assert!(t.norm() < 1.0, "Translation should be small: {}", t.norm());
369    }
370
371    #[test]
372    fn test_ndt_small_translation() {
373        let target = make_grid_cloud(6, 6, 6, 1.0);
374        let translation =
375            Isometry3::from_parts(Translation3::new(0.3, 0.2, 0.1), UnitQuaternion::identity());
376        let source = apply_transform(&target, &translation);
377
378        let config = NdtConfig {
379            resolution: 2.0,
380            step_size: 0.5,
381            max_iterations: 50,
382            epsilon: 1e-5,
383            min_points_per_voxel: 3,
384        };
385        let result = ndt_registration(&source, &target, Isometry3::identity(), &config).unwrap();
386        assert!(result.score > 0.0);
387        assert!(result.iterations <= 50);
388    }
389
390    #[test]
391    fn test_ndt_empty_source() {
392        let empty: PointCloud<Point3f> = PointCloud { points: vec![] };
393        let target = make_grid_cloud(4, 4, 4, 1.0);
394        let config = NdtConfig::default();
395        assert!(ndt_registration(&empty, &target, Isometry3::identity(), &config).is_err());
396    }
397
398    #[test]
399    fn test_ndt_sparse_target() {
400        let source = make_grid_cloud(4, 4, 4, 1.0);
401        // Only 2 points — below default min_points_per_voxel
402        let sparse: PointCloud<Point3f> = PointCloud {
403            points: vec![Point3f::new(0.0, 0.0, 0.0), Point3f::new(1.0, 1.0, 1.0)],
404        };
405        let config = NdtConfig::default();
406        assert!(ndt_registration(&source, &sparse, Isometry3::identity(), &config).is_err());
407    }
408
409    #[test]
410    fn test_ndt_config_default() {
411        let cfg = NdtConfig::default();
412        assert_eq!(cfg.resolution, 1.0);
413        assert_eq!(cfg.max_iterations, 35);
414        assert!(cfg.epsilon > 0.0);
415        assert!(cfg.step_size > 0.0);
416        assert!(cfg.min_points_per_voxel >= 1);
417    }
418
419    #[test]
420    fn test_ndt_result_fields() {
421        let target = make_grid_cloud(5, 5, 5, 1.0);
422        let config = NdtConfig {
423            resolution: 2.0,
424            min_points_per_voxel: 2,
425            ..Default::default()
426        };
427        let result = ndt_registration(&target, &target, Isometry3::identity(), &config).unwrap();
428        // All result fields should be populated
429        assert!(result.iterations > 0);
430        assert!(result.score >= 0.0);
431        // transformation should be a valid isometry (rotation norm ≈ 1)
432        let qnorm = result.transformation.rotation.norm();
433        assert!((qnorm - 1.0).abs() < 1e-5);
434    }
435
436    #[test]
437    fn test_ndt_default_wrapper() {
438        let target = make_grid_cloud(5, 5, 5, 1.0);
439        // Should not panic/error with a dense enough cloud
440        let config_res_cloud = NdtConfig {
441            resolution: 2.0,
442            min_points_per_voxel: 2,
443            ..Default::default()
444        };
445        let r1 = ndt_registration(&target, &target, Isometry3::identity(), &config_res_cloud);
446        assert!(r1.is_ok());
447    }
448}