1#![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#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct NdtConfig {
25 pub max_iterations: usize,
27 pub resolution: f32,
29 pub epsilon: f64,
31 pub min_points_per_voxel: usize,
33 pub transformation_epsilon: f64,
35 pub fitness_epsilon: f64,
37 pub min_correspondences: usize,
39 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 #[must_use]
61 pub fn with_resolution(resolution: f32) -> Self {
62 Self { resolution, ..Self::default() }
63 }
64}
65
66#[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 #[must_use]
80 pub const fn new(config: NdtConfig) -> Self {
81 Self { config }
82 }
83
84 #[must_use]
86 pub const fn config(&self) -> NdtConfig {
87 self.config
88 }
89
90 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 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
238fn 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
272fn 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 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}