1use nalgebra::{Matrix3, Translation3, UnitQuaternion, Vector3};
10use std::collections::HashMap;
11use threecrate_core::{Error, Isometry3, Point3f, PointCloud, Result};
12
13#[derive(Debug, Clone)]
15pub struct NdtConfig {
16 pub resolution: f32,
18 pub step_size: f32,
20 pub max_iterations: usize,
22 pub epsilon: f32,
24 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#[derive(Debug, Clone)]
42pub struct NdtResult {
43 pub transformation: Isometry3<f32>,
45 pub score: f32,
47 pub iterations: usize,
49 pub converged: bool,
51}
52
53#[derive(Debug, Clone)]
55struct VoxelDistribution {
56 mean: Vector3<f32>,
57 inv_cov: Matrix3<f32>,
58}
59
60fn 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
69fn build_voxel_grid(
71 target: &PointCloud<Point3f>,
72 resolution: f32,
73 min_points: usize,
74) -> HashMap<(i32, i32, i32), VoxelDistribution> {
75 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 let mean = pts.iter().fold(Vector3::zeros(), |acc, p| acc + p) / n;
94
95 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 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
113fn 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 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 let src_vec = Vector3::new(src_pt.x, src_pt.y, src_pt.z);
151 let dp_dtrans = Matrix3::identity();
152 let rs = rot_mat * src_vec;
155 let dp_drx = Vector3::new(0.0, -rs[2], rs[1]); let dp_dry = Vector3::new(rs[2], 0.0, -rs[0]); let dp_drz = Vector3::new(-rs[1], rs[0], 0.0); 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 let g_vec = jac.transpose() * cov_diff;
168 gradient += e * g_vec;
169
170 let h_contrib = jac.transpose() * dist.inv_cov * jac;
172 hessian += e * h_contrib;
173 }
174
175 (score, gradient, hessian)
176}
177
178pub 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 let reg = nalgebra::Matrix6::<f32>::identity() * 1e-6;
225 let hess_reg = hess + reg;
226
227 let delta = match hess_reg.lu().solve(&(-grad)) {
229 Some(d) => d,
230 None => break,
231 };
232
233 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 if delta.norm() < config.epsilon {
243 converged = true;
244 break;
245 }
246
247 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
262pub 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 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 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 assert!(result.iterations > 0);
373 assert!(result.score >= 0.0);
374 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 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}