1use crate::registration::LocalFrame;
10use nalgebra::{Matrix3, Translation3, UnitQuaternion, Vector3};
11use std::collections::HashMap;
12use threecrate_core::{Error, Isometry3, Point3f, PointCloud, Result};
13
14#[derive(Debug, Clone)]
16pub struct NdtConfig {
17 pub resolution: f32,
19 pub step_size: f32,
21 pub max_iterations: usize,
23 pub epsilon: f32,
25 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#[derive(Debug, Clone)]
43pub struct NdtResult {
44 pub transformation: Isometry3<f32>,
46 pub score: f32,
48 pub iterations: usize,
50 pub converged: bool,
52}
53
54#[derive(Debug, Clone)]
56struct VoxelDistribution {
57 mean: Vector3<f32>,
58 inv_cov: Matrix3<f32>,
59}
60
61fn 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
70fn build_voxel_grid(
72 target: &PointCloud<Point3f>,
73 resolution: f32,
74 min_points: usize,
75) -> HashMap<(i32, i32, i32), VoxelDistribution> {
76 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 let mean = pts.iter().fold(Vector3::zeros(), |acc, p| acc + p) / n;
95
96 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 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
114fn 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 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 let dp_dtrans = Matrix3::identity();
149 let dp_drx = Vector3::new(0.0, -p.z, p.y); let dp_dry = Vector3::new(p.z, 0.0, -p.x); let dp_drz = Vector3::new(-p.y, p.x, 0.0); 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 let g_vec = jac.transpose() * cov_diff;
165 gradient += e * g_vec;
166
167 let h_contrib = jac.transpose() * dist.inv_cov * jac;
169 hessian += e * h_contrib;
170 }
171
172 (score, gradient, hessian)
173}
174
175pub 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 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 let reg = nalgebra::Matrix6::<f32>::identity() * 1e-6;
230 let hess_reg = hess + reg;
231
232 let delta = match hess_reg.lu().solve(&(-grad)) {
234 Some(d) => d,
235 None => break,
236 };
237
238 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 if delta.norm() < config.epsilon {
248 converged = true;
249 break;
250 }
251
252 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
267pub 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 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 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 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 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 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 assert!(result.iterations > 0);
430 assert!(result.score >= 0.0);
431 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 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}