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