Skip to main content

spatialrust_registration/
ndt.rs

1// Explicit row/column indexing reads more clearly than iterators for fixed 3x3
2// and 3x6 linear-algebra kernels.
3#![allow(clippy::needless_range_loop)]
4
5use std::collections::HashMap;
6
7use spatialrust_core::{HasPositions3, PointCloud, SpatialError, SpatialResult};
8use spatialrust_math::{
9    solve_linear_system, symmetric_eigen3, CovarianceAccumulator3, Isometry3, LeastSquaresResult,
10    Mat3, Quat, TransformPoint, Vec3,
11};
12
13use crate::registration::{PointCloudRegistration, RegistrationResult};
14
15type M3 = [[f64; 3]; 3];
16
17/// Configuration for NDT (Normal Distributions Transform) registration.
18///
19/// The target cloud is discretized into a voxel grid; each cell with enough
20/// points becomes a Gaussian (mean + covariance). The source is aligned by
21/// minimizing the Mahalanobis distance of each transformed point to its target
22/// cell distribution via Gauss-Newton (point-to-distribution NDT).
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct NdtConfig {
25    /// Maximum number of optimization iterations.
26    pub max_iterations: usize,
27    /// Voxel size used to build the target distributions.
28    pub resolution: f32,
29    /// Planar regularization: smallest eigenvalue floor as a fraction of the largest.
30    pub epsilon: f64,
31    /// Minimum points required for a target voxel to form a distribution.
32    pub min_points_per_voxel: usize,
33    /// Stop when the transform update is smaller than this threshold.
34    pub transformation_epsilon: f64,
35    /// Stop when the fitness is smaller than this threshold.
36    pub fitness_epsilon: f64,
37    /// Minimum number of matched points required per iteration.
38    pub min_correspondences: usize,
39    /// Initial transform guess mapping source into target frame.
40    pub initial_guess: Isometry3<f32>,
41}
42
43impl Default for NdtConfig {
44    fn default() -> Self {
45        Self {
46            max_iterations: 35,
47            resolution: 1.0,
48            epsilon: 1e-3,
49            min_points_per_voxel: 5,
50            transformation_epsilon: 1e-8,
51            fitness_epsilon: 1e-6,
52            min_correspondences: 6,
53            initial_guess: Isometry3::identity(),
54        }
55    }
56}
57
58impl NdtConfig {
59    /// Creates a config with the given voxel resolution.
60    #[must_use]
61    pub fn with_resolution(resolution: f32) -> Self {
62        Self { resolution, ..Self::default() }
63    }
64}
65
66/// NDT registration (point-to-distribution).
67#[derive(Clone, Copy, Debug, PartialEq)]
68pub struct NdtRegistration {
69    config: NdtConfig,
70}
71
72struct VoxelDistribution {
73    mean: [f64; 3],
74    information: M3,
75}
76
77impl NdtRegistration {
78    /// Creates an NDT algorithm from config.
79    #[must_use]
80    pub const fn new(config: NdtConfig) -> Self {
81        Self { config }
82    }
83
84    /// Returns the config.
85    #[must_use]
86    pub const fn config(&self) -> NdtConfig {
87        self.config
88    }
89
90    /// Aligns `source` to `target` using NDT.
91    pub fn align_with_diagnostics(
92        &self,
93        source: &PointCloud,
94        target: &PointCloud,
95    ) -> SpatialResult<RegistrationResult> {
96        if source.is_empty() || target.is_empty() {
97            return Err(SpatialError::InvalidArgument(
98                "NDT requires non-empty source and target point clouds".to_owned(),
99            ));
100        }
101        if self.config.resolution <= 0.0 {
102            return Err(SpatialError::InvalidArgument(
103                "NDT resolution must be positive".to_owned(),
104            ));
105        }
106
107        let (sx, sy, sz) = source.positions3()?;
108        let (tx, ty, tz) = target.positions3()?;
109        let inv_res = 1.0 / self.config.resolution;
110        let distributions = build_distributions(
111            tx,
112            ty,
113            tz,
114            inv_res,
115            self.config.min_points_per_voxel,
116            self.config.epsilon,
117        );
118        if distributions.is_empty() {
119            return Err(SpatialError::InvalidArgument(
120                "NDT found no target voxels with enough points; increase resolution".to_owned(),
121            ));
122        }
123
124        let mut transform = self.config.initial_guess;
125        let mut transformed = vec![Vec3::new(0.0, 0.0, 0.0); source.len()];
126        apply_transform(&mut transformed, sx, sy, sz, transform);
127
128        let mut iterations = 0usize;
129        let mut converged = false;
130        let mut lambda = 1e-3_f64;
131        let mut cost = mahalanobis_fitness(&transformed, &distributions, inv_res);
132
133        for _ in 0..self.config.max_iterations {
134            iterations += 1;
135            let mut hessian = [[0.0_f64; 6]; 6];
136            let mut gradient = [0.0_f64; 6];
137            let mut count = 0usize;
138
139            for point in &transformed {
140                let cell = cell_of(point, inv_res);
141                let Some(dist) = distributions.get(&cell) else {
142                    continue;
143                };
144                let d = [
145                    f64::from(point.x) - dist.mean[0],
146                    f64::from(point.y) - dist.mean[1],
147                    f64::from(point.z) - dist.mean[2],
148                ];
149                let jac = jacobian([f64::from(point.x), f64::from(point.y), f64::from(point.z)]);
150                let mj = mat3x6_premul(dist.information, &jac);
151                let md = mat_vec(dist.information, d);
152                for a in 0..6 {
153                    for k in 0..3 {
154                        gradient[a] += jac[k][a] * md[k];
155                        for b in 0..6 {
156                            hessian[a][b] += jac[k][a] * mj[k][b];
157                        }
158                    }
159                }
160                count += 1;
161            }
162
163            if count < self.config.min_correspondences {
164                return Err(SpatialError::InvalidArgument(format!(
165                    "NDT matched only {} points to target voxels, minimum is {}",
166                    count, self.config.min_correspondences
167                )));
168            }
169
170            // Levenberg-Marquardt: damp the Hessian and only accept steps that
171            // lower the cost, otherwise increase damping and retry. Hard voxel
172            // assignment makes the objective non-smooth, so a plain Gauss-Newton
173            // step can overshoot and diverge.
174            let mut step_accepted = false;
175            for _ in 0..8 {
176                let mut a_rows: Vec<Vec<f64>> = hessian.iter().map(|row| row.to_vec()).collect();
177                for d in 0..6 {
178                    a_rows[d][d] += lambda * hessian[d][d].max(1e-9);
179                }
180                let neg_g: Vec<f64> = gradient.iter().map(|value| -value).collect();
181                let LeastSquaresResult::Solved(solution) = solve_linear_system(a_rows, neg_g)
182                else {
183                    lambda *= 4.0;
184                    continue;
185                };
186
187                let candidate = delta_from_solution(&solution).compose(transform);
188                let mut candidate_points = transformed.clone();
189                apply_transform(&mut candidate_points, sx, sy, sz, candidate);
190                let candidate_cost =
191                    mahalanobis_fitness(&candidate_points, &distributions, inv_res);
192
193                if candidate_cost < cost {
194                    transform = candidate;
195                    transformed = candidate_points;
196                    let step = update_magnitude(&solution);
197                    cost = candidate_cost;
198                    lambda = (lambda * 0.5).max(1e-9);
199                    step_accepted = true;
200                    if step < self.config.transformation_epsilon
201                        || cost < self.config.fitness_epsilon
202                    {
203                        converged = true;
204                    }
205                    break;
206                }
207                lambda *= 4.0;
208            }
209
210            if converged || !step_accepted {
211                converged = converged || step_accepted;
212                break;
213            }
214        }
215
216        Ok(RegistrationResult { transform, fitness: cost, iterations, converged })
217    }
218}
219
220impl PointCloudRegistration for NdtRegistration {
221    fn name(&self) -> &'static str {
222        "NdtRegistration"
223    }
224
225    fn align(&self, source: &PointCloud, target: &PointCloud) -> SpatialResult<RegistrationResult> {
226        self.align_with_diagnostics(source, target)
227    }
228}
229
230fn cell_of(point: &Vec3<f32>, inv_res: f32) -> (i64, i64, i64) {
231    (
232        (point.x * inv_res).floor() as i64,
233        (point.y * inv_res).floor() as i64,
234        (point.z * inv_res).floor() as i64,
235    )
236}
237
238/// Builds per-voxel Gaussian distributions (mean + inverse covariance) for the target.
239fn build_distributions(
240    x: &[f32],
241    y: &[f32],
242    z: &[f32],
243    inv_res: f32,
244    min_points: usize,
245    epsilon: f64,
246) -> HashMap<(i64, i64, i64), VoxelDistribution> {
247    let mut accumulators: HashMap<(i64, i64, i64), CovarianceAccumulator3> = HashMap::new();
248    for index in 0..x.len() {
249        let point = Vec3::new(x[index], y[index], z[index]);
250        let cell = cell_of(&point, inv_res);
251        accumulators.entry(cell).or_default().push(point);
252    }
253
254    let mut distributions = HashMap::with_capacity(accumulators.len());
255    for (cell, acc) in accumulators {
256        if (acc.count() as usize) < min_points.max(3) {
257            continue;
258        }
259        let (Some(mean), Some(cov)) = (acc.mean(), acc.covariance()) else {
260            continue;
261        };
262        let regularized = regularize(cov, epsilon);
263        let Some(information) = inverse3(regularized) else {
264            continue;
265        };
266        distributions
267            .insert(cell, VoxelDistribution { mean: [mean.x, mean.y, mean.z], information });
268    }
269    distributions
270}
271
272/// Floors small eigenvalues so a near-planar cell still yields an invertible covariance.
273fn regularize(cov: Mat3<f64>, epsilon: f64) -> M3 {
274    let eigen = symmetric_eigen3(cov);
275    let max_eig = eigen.eigenvalues[2].max(1e-12);
276    let floor = epsilon * max_eig;
277    let v = eigen.eigenvectors.m;
278    let mut result = [[0.0_f64; 3]; 3];
279    for col in 0..3 {
280        let lambda = eigen.eigenvalues[col].max(floor);
281        let axis = [v[0][col], v[1][col], v[2][col]];
282        for r in 0..3 {
283            for c in 0..3 {
284                result[r][c] += lambda * axis[r] * axis[c];
285            }
286        }
287    }
288    result
289}
290
291fn mahalanobis_fitness(
292    transformed: &[Vec3<f32>],
293    distributions: &HashMap<(i64, i64, i64), VoxelDistribution>,
294    inv_res: f32,
295) -> f64 {
296    let mut sum = 0.0_f64;
297    let mut count = 0usize;
298    for point in transformed {
299        let cell = cell_of(point, inv_res);
300        let Some(dist) = distributions.get(&cell) else {
301            continue;
302        };
303        let d = [
304            f64::from(point.x) - dist.mean[0],
305            f64::from(point.y) - dist.mean[1],
306            f64::from(point.z) - dist.mean[2],
307        ];
308        let md = mat_vec(dist.information, d);
309        sum += d[0] * md[0] + d[1] * md[1] + d[2] * md[2];
310        count += 1;
311    }
312    if count == 0 {
313        return f64::MAX;
314    }
315    sum / count as f64
316}
317
318fn apply_transform(
319    transformed: &mut [Vec3<f32>],
320    x: &[f32],
321    y: &[f32],
322    z: &[f32],
323    transform: Isometry3<f32>,
324) {
325    for (index, point) in transformed.iter_mut().enumerate() {
326        *point = transform.transform_point(Vec3::new(x[index], y[index], z[index]));
327    }
328}
329
330fn jacobian(p: [f64; 3]) -> [[f64; 6]; 3] {
331    [
332        [0.0, p[2], -p[1], 1.0, 0.0, 0.0],
333        [-p[2], 0.0, p[0], 0.0, 1.0, 0.0],
334        [p[1], -p[0], 0.0, 0.0, 0.0, 1.0],
335    ]
336}
337
338fn delta_from_solution(solution: &[f64]) -> Isometry3<f32> {
339    let (rx, ry, rz) = (solution[0], solution[1], solution[2]);
340    let angle = (rx * rx + ry * ry + rz * rz).sqrt();
341    let rotation = if angle > 1e-12 {
342        let axis = Vec3::new((rx / angle) as f32, (ry / angle) as f32, (rz / angle) as f32);
343        Quat::from_axis_angle(axis, angle as f32)
344    } else {
345        Quat::<f32>::identity()
346    };
347    Isometry3::new(rotation, Vec3::new(solution[3] as f32, solution[4] as f32, solution[5] as f32))
348}
349
350fn update_magnitude(solution: &[f64]) -> f64 {
351    solution.iter().map(|value| value * value).sum::<f64>().sqrt()
352}
353
354fn mat_vec(a: M3, v: [f64; 3]) -> [f64; 3] {
355    [
356        a[0][0] * v[0] + a[0][1] * v[1] + a[0][2] * v[2],
357        a[1][0] * v[0] + a[1][1] * v[1] + a[1][2] * v[2],
358        a[2][0] * v[0] + a[2][1] * v[1] + a[2][2] * v[2],
359    ]
360}
361
362fn mat3x6_premul(m: M3, j: &[[f64; 6]; 3]) -> [[f64; 6]; 3] {
363    let mut out = [[0.0_f64; 6]; 3];
364    for r in 0..3 {
365        for c in 0..6 {
366            for k in 0..3 {
367                out[r][c] += m[r][k] * j[k][c];
368            }
369        }
370    }
371    out
372}
373
374fn inverse3(a: M3) -> Option<M3> {
375    let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
376        - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
377        + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
378    if det.abs() < 1e-18 {
379        return None;
380    }
381    let inv_det = 1.0 / det;
382    let mut out = [[0.0_f64; 3]; 3];
383    out[0][0] = (a[1][1] * a[2][2] - a[1][2] * a[2][1]) * inv_det;
384    out[0][1] = (a[0][2] * a[2][1] - a[0][1] * a[2][2]) * inv_det;
385    out[0][2] = (a[0][1] * a[1][2] - a[0][2] * a[1][1]) * inv_det;
386    out[1][0] = (a[1][2] * a[2][0] - a[1][0] * a[2][2]) * inv_det;
387    out[1][1] = (a[0][0] * a[2][2] - a[0][2] * a[2][0]) * inv_det;
388    out[1][2] = (a[0][2] * a[1][0] - a[0][0] * a[1][2]) * inv_det;
389    out[2][0] = (a[1][0] * a[2][1] - a[1][1] * a[2][0]) * inv_det;
390    out[2][1] = (a[0][1] * a[2][0] - a[0][0] * a[2][1]) * inv_det;
391    out[2][2] = (a[0][0] * a[1][1] - a[0][1] * a[1][0]) * inv_det;
392    Some(out)
393}
394
395#[cfg(test)]
396mod tests {
397    use super::{NdtConfig, NdtRegistration};
398    use crate::registration::PointCloudRegistration;
399    use crate::transform::transform_point_cloud;
400    use spatialrust_core::{PointCloudBuilder, StandardSchemas};
401    use spatialrust_math::{Isometry3, Quat, TransformPoint, Vec3};
402
403    /// Dense box corner so each voxel holds enough points for a distribution.
404    fn box_corner() -> spatialrust_core::PointCloud {
405        let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
406        for i in 0..30 {
407            for j in 0..30 {
408                let (a, b) = (i as f32 * 0.05, j as f32 * 0.05);
409                builder.push_point([a, b, 0.0]).unwrap();
410                builder.push_point([a, 0.0, b + 0.02]).unwrap();
411                builder.push_point([0.0, a + 0.02, b + 0.02]).unwrap();
412            }
413        }
414        builder.build().unwrap()
415    }
416
417    #[test]
418    fn aligns_rotated_and_translated_source() {
419        let target = box_corner();
420        let misalignment = Isometry3::new(
421            Quat::from_axis_angle(Vec3::new(0.0, 0.0, 1.0), 0.03),
422            Vec3::new(0.02, -0.015, 0.01),
423        );
424        let source = transform_point_cloud(&target, misalignment).unwrap();
425
426        let ndt = NdtRegistration::new(NdtConfig {
427            resolution: 0.2,
428            max_iterations: 60,
429            min_points_per_voxel: 4,
430            ..NdtConfig::default()
431        });
432        let result = ndt.align(&source, &target).unwrap();
433
434        let composed = result.transform.compose(misalignment);
435        let probe = Vec3::new(0.4, 0.5, 0.3);
436        let restored = composed.transform_point(probe);
437        assert!((restored.x - probe.x).abs() < 2e-2, "x off: {}", restored.x);
438        assert!((restored.y - probe.y).abs() < 2e-2, "y off: {}", restored.y);
439        assert!((restored.z - probe.z).abs() < 2e-2, "z off: {}", restored.z);
440    }
441
442    #[test]
443    fn rejects_nonpositive_resolution() {
444        let cloud = box_corner();
445        let ndt = NdtRegistration::new(NdtConfig { resolution: 0.0, ..NdtConfig::default() });
446        assert!(ndt.align(&cloud, &cloud).is_err());
447    }
448}