1use crate::filtering::voxel_grid_filter;
4use crate::nearest_neighbor::KdTree;
5use nalgebra::{Matrix3, Matrix6, Translation3, UnitQuaternion, Vector3, Vector6};
6use rayon::prelude::*;
7use threecrate_core::{Error, Isometry3, Point3f, PointCloud, Result, Vector3f};
8
9#[derive(Debug, Clone)]
11pub struct ICPResult {
12 pub transformation: Isometry3<f32>,
14 pub mse: f32,
16 pub iterations: usize,
18 pub converged: bool,
20 pub correspondences: Vec<(usize, usize)>,
22}
23
24#[derive(Debug, Clone)]
26pub struct IcpScaleLevel {
27 pub voxel_size: f32,
29 pub max_iterations: usize,
31 pub max_correspondence_distance: Option<f32>,
33}
34
35#[derive(Debug, Clone)]
37pub struct MultiScaleIcpConfig {
38 pub levels: Vec<IcpScaleLevel>,
39 pub final_refinement_iterations: usize,
40 pub final_max_correspondence_distance: Option<f32>,
41 pub convergence_threshold: f32,
43}
44
45impl Default for MultiScaleIcpConfig {
46 fn default() -> Self {
47 Self {
48 levels: vec![
49 IcpScaleLevel {
50 voxel_size: 0.20,
51 max_iterations: 10,
52 max_correspondence_distance: Some(0.50),
53 },
54 IcpScaleLevel {
55 voxel_size: 0.10,
56 max_iterations: 10,
57 max_correspondence_distance: Some(0.25),
58 },
59 IcpScaleLevel {
60 voxel_size: 0.05,
61 max_iterations: 15,
62 max_correspondence_distance: Some(0.15),
63 },
64 ],
65 final_refinement_iterations: 10,
66 final_max_correspondence_distance: Some(0.10),
67 convergence_threshold: 1e-5,
68 }
69 }
70}
71
72#[cfg(test)]
74fn find_correspondences(
75 source: &[Point3f],
76 target: &[Point3f],
77 max_distance: Option<f32>,
78) -> Vec<Option<(usize, f32)>> {
79 match KdTree::new(target) {
80 Ok(tree) => {
81 let no_previous = vec![None; source.len()];
82 find_correspondences_seeded(
83 source,
84 &Isometry3::identity(),
85 target,
86 &tree,
87 &no_previous,
88 max_distance_sq(max_distance),
89 )
90 .into_iter()
91 .map(|c| c.map(|(idx, dist_sq)| (idx, dist_sq.sqrt())))
92 .collect()
93 }
94 Err(_) => find_correspondences_brute_force(source, target, max_distance),
95 }
96}
97
98fn max_distance_sq(max_distance: Option<f32>) -> f32 {
104 match max_distance {
105 Some(d) if d < 0.0 => 0.0,
106 Some(d) if d >= 0.0 => (d * d).next_up(),
107 _ => f32::INFINITY,
108 }
109}
110
111const MIN_POINTS_PER_TASK: usize = 512;
127
128fn find_correspondences_seeded(
129 source: &[Point3f],
130 transform: &Isometry3<f32>,
131 target: &[Point3f],
132 target_tree: &KdTree,
133 previous: &[Option<(usize, f32)>],
134 max_dist_sq: f32,
135) -> Vec<Option<(usize, f32)>> {
136 source
137 .par_iter()
138 .zip(previous.par_iter())
139 .with_min_len(MIN_POINTS_PER_TASK)
140 .map(|(point, prev)| {
141 let moved = transform * point;
142 let seed = prev.map(|(idx, _)| (idx, (moved - target[idx]).magnitude_squared()));
143 target_tree.find_nearest_bounded(&moved, max_dist_sq, seed)
144 })
145 .collect()
146}
147
148fn match_and_collect(
153 source: &[Point3f],
154 transform: &Isometry3<f32>,
155 target: &[Point3f],
156 target_tree: &KdTree,
157 matches: &mut [Option<(usize, f32)>],
158 max_dist_sq: f32,
159) -> CorrespondenceStats {
160 source
161 .par_chunks(MIN_POINTS_PER_TASK)
162 .zip(matches.par_chunks_mut(MIN_POINTS_PER_TASK))
163 .map(|(points, slots)| {
164 let mut stats = CorrespondenceStats::zero();
165 for (point, slot) in points.iter().zip(slots) {
166 let moved = transform * point;
167 let seed = slot.map(|(idx, _)| (idx, (moved - target[idx]).magnitude_squared()));
168 *slot = target_tree.find_nearest_bounded(&moved, max_dist_sq, seed);
169 if let Some((idx, _)) = *slot {
170 stats = stats.add(&moved, &target[idx]);
171 }
172 }
173 stats
174 })
175 .reduce(CorrespondenceStats::zero, CorrespondenceStats::merge)
176}
177
178#[cfg(test)]
180fn find_correspondences_brute_force(
181 source: &[Point3f],
182 target: &[Point3f],
183 max_distance: Option<f32>,
184) -> Vec<Option<(usize, f32)>> {
185 source
186 .par_iter()
187 .map(|source_point| {
188 let mut best_distance = f32::INFINITY;
189 let mut best_idx = None;
190
191 for (target_idx, target_point) in target.iter().enumerate() {
192 let distance = (source_point - target_point).magnitude();
193
194 if distance < best_distance {
195 best_distance = distance;
196 best_idx = Some(target_idx);
197 }
198 }
199
200 if let Some(max_dist) = max_distance {
202 if best_distance > max_dist {
203 return None;
204 }
205 }
206
207 best_idx.map(|idx| (idx, best_distance))
208 })
209 .collect()
210}
211
212#[derive(Clone, Copy)]
219struct CorrespondenceStats {
220 count: usize,
221 sum_source: Vector3<f64>,
222 sum_target: Vector3<f64>,
223 sum_outer: Matrix3<f64>,
224 sum_sq_error: f64,
225}
226
227impl CorrespondenceStats {
228 fn zero() -> Self {
229 Self {
230 count: 0,
231 sum_source: Vector3::zeros(),
232 sum_target: Vector3::zeros(),
233 sum_outer: Matrix3::zeros(),
234 sum_sq_error: 0.0,
235 }
236 }
237
238 fn add(mut self, source: &Point3f, target: &Point3f) -> Self {
239 let p = source.coords.cast::<f64>();
240 let q = target.coords.cast::<f64>();
241 self.count += 1;
242 self.sum_source += p;
243 self.sum_target += q;
244 self.sum_outer += p * q.transpose();
245 self.sum_sq_error += (p - q).norm_squared();
246 self
247 }
248
249 fn merge(self, other: Self) -> Self {
250 Self {
251 count: self.count + other.count,
252 sum_source: self.sum_source + other.sum_source,
253 sum_target: self.sum_target + other.sum_target,
254 sum_outer: self.sum_outer + other.sum_outer,
255 sum_sq_error: self.sum_sq_error + other.sum_sq_error,
256 }
257 }
258
259 fn collect(
261 source: &[Point3f],
262 transform: &Isometry3<f32>,
263 target: &[Point3f],
264 matches: &[Option<(usize, f32)>],
265 ) -> Self {
266 source
267 .par_iter()
268 .zip(matches.par_iter())
269 .with_min_len(MIN_POINTS_PER_TASK)
270 .fold(Self::zero, |stats, (point, m)| match m {
271 Some((idx, _)) => stats.add(&(transform * point), &target[*idx]),
272 None => stats,
273 })
274 .reduce(Self::zero, Self::merge)
275 }
276
277 fn mse(&self) -> f32 {
279 if self.count == 0 {
280 0.0
281 } else {
282 (self.sum_sq_error / self.count as f64) as f32
283 }
284 }
285
286 fn transformation(&self) -> Result<Isometry3<f32>> {
288 rigid_transform_from_sums(
289 self.count as f64,
290 &self.sum_source,
291 &self.sum_target,
292 &self.sum_outer,
293 )
294 }
295}
296
297pub fn rigid_transform_from_sums(
303 count: f64,
304 sum_source: &Vector3<f64>,
305 sum_target: &Vector3<f64>,
306 sum_outer: &Matrix3<f64>,
307) -> Result<Isometry3<f32>> {
308 if count <= 0.0 {
309 return Err(Error::InvalidData(
310 "Point correspondence mismatch".to_string(),
311 ));
312 }
313
314 let source_centroid = sum_source / count;
315 let target_centroid = sum_target / count;
316
317 let h: Matrix3<f32> =
321 (sum_outer - source_centroid * target_centroid.transpose() * count).cast::<f32>();
322
323 const SVD_MAX_ITERATIONS: usize = 1000;
325 let svd = h
326 .try_svd(true, true, f32::EPSILON * 5.0, SVD_MAX_ITERATIONS)
327 .ok_or_else(|| Error::Algorithm("SVD did not converge".to_string()))?;
328 let u = svd
329 .u
330 .ok_or_else(|| Error::Algorithm("SVD U matrix not available".to_string()))?;
331 let v_t = svd
332 .v_t
333 .ok_or_else(|| Error::Algorithm("SVD V^T matrix not available".to_string()))?;
334
335 let mut r = v_t.transpose() * u.transpose();
337
338 if r.determinant() < 0.0 {
340 let mut v_t_corrected = v_t;
341 v_t_corrected.set_row(2, &(-v_t.row(2)));
342 r = v_t_corrected.transpose() * u.transpose();
343 }
344
345 let rotation = UnitQuaternion::from_matrix(&r);
347
348 let translation = target_centroid - rotation.cast::<f64>() * source_centroid;
350
351 Ok(Isometry3::from_parts(
352 Translation3::from(translation.cast::<f32>()),
353 rotation,
354 ))
355}
356
357fn is_finite_point(p: &Point3f) -> bool {
359 p.coords.iter().all(|c| c.is_finite())
360}
361
362#[derive(Debug, Clone, Copy)]
366pub struct CloudScale {
367 pub centroid: Point3f,
368 pub radius: f32,
369}
370
371impl CloudScale {
372 pub fn of(points: &[Point3f]) -> Self {
373 let (sum, sum_sq, count) = points
374 .par_iter()
375 .filter(|p| is_finite_point(p))
376 .map(|p| {
377 let v = p.coords.cast::<f64>();
378 (v, v.norm_squared(), 1usize)
379 })
380 .reduce(
381 || (Vector3::zeros(), 0.0, 0),
382 |(a, a_sq, a_n), (b, b_sq, b_n)| (a + b, a_sq + b_sq, a_n + b_n),
383 );
384 let n = count.max(1) as f64;
385 let centroid = sum / n;
386 let radius_sq = (sum_sq / n - centroid.norm_squared()).max(0.0);
387 Self {
388 centroid: Point3f::from(centroid.cast::<f32>()),
389 radius: radius_sq.sqrt() as f32,
390 }
391 }
392}
393
394#[derive(Debug, Clone, Copy)]
401pub struct LocalFrame {
402 pub origin: Vector3<f64>,
404}
405
406impl LocalFrame {
407 pub fn centred_on(points: &[Point3f]) -> Self {
410 let (sum, count) = points
411 .par_iter()
412 .filter(|p| is_finite_point(p))
413 .map(|p| (p.coords.cast::<f64>(), 1usize))
414 .reduce(
415 || (Vector3::zeros(), 0),
416 |(a, a_n), (b, b_n)| (a + b, a_n + b_n),
417 );
418 Self {
419 origin: sum / count.max(1) as f64,
420 }
421 }
422
423 pub fn if_far_from_origin(points: &[Point3f]) -> Option<Self> {
427 let scale = CloudScale::of(points);
428 if scale.centroid.coords.norm() <= 100.0 * scale.radius {
429 return None;
430 }
431 Some(Self::centred_on(points))
432 }
433
434 pub fn point_to_local(&self, p: &Point3f) -> Point3f {
436 Point3f::from((p.coords.cast::<f64>() - self.origin).cast::<f32>())
437 }
438
439 pub fn points_to_local(&self, points: &[Point3f]) -> Vec<Point3f> {
441 points.par_iter().map(|p| self.point_to_local(p)).collect()
442 }
443
444 pub fn transform_to_local(&self, transform: &Isometry3<f32>) -> Isometry3<f32> {
446 let shift = Translation3::from(self.origin);
447 (shift.inverse() * transform.cast::<f64>() * shift).cast::<f32>()
448 }
449
450 pub fn transform_to_world(&self, transform: &Isometry3<f32>) -> Isometry3<f32> {
452 let shift = Translation3::from(self.origin);
453 (shift * transform.cast::<f64>() * shift.inverse()).cast::<f32>()
454 }
455}
456
457pub fn icp_converged(
474 previous_mse: f32,
475 current_mse: f32,
476 delta: &Isometry3<f32>,
477 centroid: &Point3f,
478 radius: f32,
479 threshold: f32,
480) -> bool {
481 let centroid_shift = (delta * centroid - centroid).norm();
483 let rotation_shift = delta.rotation.angle() * radius;
484 let motion = centroid_shift.hypot(rotation_shift);
485 if motion <= threshold * radius {
486 return true;
487 }
488
489 if !previous_mse.is_finite() {
490 return false;
491 }
492 let previous_rmse = previous_mse.sqrt();
493 if previous_rmse == 0.0
494 || (previous_rmse - current_mse.sqrt()).abs() / previous_rmse < threshold
495 {
496 return true;
497 }
498
499 let rounding_step = 2.0 * f32::EPSILON * (centroid.coords.norm() + radius);
500 current_mse >= previous_mse && motion <= rounding_step
501}
502
503fn correspondence_pairs(matches: &[Option<(usize, f32)>]) -> Vec<(usize, usize)> {
505 matches
506 .iter()
507 .enumerate()
508 .filter_map(|(src_idx, m)| m.map(|(tgt_idx, _)| (src_idx, tgt_idx)))
509 .collect()
510}
511
512pub fn icp(
525 source: &PointCloud<Point3f>,
526 target: &PointCloud<Point3f>,
527 init: Isometry3<f32>,
528 max_iters: usize,
529) -> Isometry3<f32> {
530 match icp_detailed(source, target, init, max_iters, None, 1e-6) {
531 Ok(result) => result.transformation,
532 Err(_) => init, }
534}
535
536pub fn icp_detailed(
551 source: &PointCloud<Point3f>,
552 target: &PointCloud<Point3f>,
553 init: Isometry3<f32>,
554 max_iters: usize,
555 max_correspondence_distance: Option<f32>,
556 convergence_threshold: f32,
557) -> Result<ICPResult> {
558 if source.is_empty() || target.is_empty() {
559 return Err(Error::InvalidData(
560 "Source or target point cloud is empty".to_string(),
561 ));
562 }
563
564 if max_iters == 0 {
565 return Err(Error::InvalidData(
566 "Max iterations must be positive".to_string(),
567 ));
568 }
569
570 let Some(frame) = LocalFrame::if_far_from_origin(&target.points) else {
571 return icp_point_to_point_local(
572 &source.points,
573 &target.points,
574 init,
575 max_iters,
576 max_correspondence_distance,
577 convergence_threshold,
578 );
579 };
580 let mut result = icp_point_to_point_local(
581 &frame.points_to_local(&source.points),
582 &frame.points_to_local(&target.points),
583 frame.transform_to_local(&init),
584 max_iters,
585 max_correspondence_distance,
586 convergence_threshold,
587 )?;
588 result.transformation = frame.transform_to_world(&result.transformation);
589 Ok(result)
590}
591
592fn icp_point_to_point_local(
594 source: &[Point3f],
595 target: &[Point3f],
596 init: Isometry3<f32>,
597 max_iters: usize,
598 max_correspondence_distance: Option<f32>,
599 convergence_threshold: f32,
600) -> Result<ICPResult> {
601 let mut current_transform = init;
602 let mut previous_mse = f32::INFINITY;
603 let target_tree = KdTree::new(target)?;
604 let max_dist_sq = max_distance_sq(max_correspondence_distance);
605 let source_scale = CloudScale::of(source);
606 let mut matches: Vec<Option<(usize, f32)>> = vec![None; source.len()];
607
608 for iteration in 0..max_iters {
609 let stats = match_and_collect(
612 source,
613 ¤t_transform,
614 target,
615 &target_tree,
616 &mut matches,
617 max_dist_sq,
618 );
619
620 if stats.count < 3 {
621 return Err(Error::Algorithm(
622 "Insufficient correspondences found".to_string(),
623 ));
624 }
625
626 let delta_transform = stats.transformation()?;
628
629 let moved_centroid = current_transform * source_scale.centroid;
631 current_transform = delta_transform * current_transform;
632
633 let current_mse = stats.mse();
635
636 if icp_converged(
638 previous_mse,
639 current_mse,
640 &delta_transform,
641 &moved_centroid,
642 source_scale.radius,
643 convergence_threshold,
644 ) {
645 return Ok(ICPResult {
646 transformation: current_transform,
647 mse: current_mse,
648 iterations: iteration + 1,
649 converged: true,
650 correspondences: correspondence_pairs(&matches),
651 });
652 }
653
654 previous_mse = current_mse;
655 }
656
657 let final_stats = CorrespondenceStats::collect(source, ¤t_transform, target, &matches);
659 let final_mse = if final_stats.count > 0 {
660 final_stats.mse()
661 } else {
662 previous_mse
663 };
664
665 Ok(ICPResult {
666 transformation: current_transform,
667 mse: final_mse,
668 iterations: max_iters,
669 converged: false,
670 correspondences: correspondence_pairs(&matches),
671 })
672}
673
674#[deprecated(note = "Use icp instead which matches the standard API")]
676pub fn icp_legacy(
677 source: &PointCloud<Point3f>,
678 target: &PointCloud<Point3f>,
679 max_iterations: usize,
680 threshold: f32,
681) -> Result<(threecrate_core::Transform3D, f32)> {
682 let init = Isometry3::identity();
683 let result = icp_detailed(source, target, init, max_iterations, Some(threshold), 1e-6)?;
684
685 let transform = threecrate_core::Transform3D::from(result.transformation);
687
688 Ok((transform, result.mse))
689}
690
691fn compute_transformation_point_to_plane(
698 source_points: &[Point3f],
699 target_points: &[Point3f],
700 target_normals: &[Vector3f],
701) -> Result<Isometry3<f32>> {
702 if source_points.len() != target_points.len()
703 || source_points.len() != target_normals.len()
704 || source_points.is_empty()
705 {
706 return Err(Error::InvalidData(
707 "Point/normal count mismatch in point-to-plane optimization".to_string(),
708 ));
709 }
710
711 let mut ata = Matrix6::<f32>::zeros();
712 let mut atb = Vector6::<f32>::zeros();
713
714 for ((src, tgt), normal) in source_points
715 .iter()
716 .zip(target_points.iter())
717 .zip(target_normals.iter())
718 {
719 let c = src.coords.cross(normal);
721
722 let a_row = Vector6::new(c.x, c.y, c.z, normal.x, normal.y, normal.z);
724
725 let b_i = normal.dot(&(tgt.coords - src.coords));
727
728 ata += a_row * a_row.transpose();
729 atb += a_row * b_i;
730 }
731
732 let x = if let Some(chol) = ata.cholesky() {
735 chol.solve(&atb)
736 } else {
737 ata.lu().solve(&atb).ok_or_else(|| {
738 Error::Algorithm("Point-to-plane system is ill-conditioned".to_string())
739 })?
740 };
741
742 let rot_x = UnitQuaternion::from_axis_angle(&Vector3f::x_axis(), x[0]);
744 let rot_y = UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), x[1]);
745 let rot_z = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), x[2]);
746 let rotation = rot_z * rot_y * rot_x;
747
748 Ok(Isometry3::from_parts(
749 Translation3::new(x[3], x[4], x[5]),
750 rotation,
751 ))
752}
753
754fn compute_point_to_plane_mse(
756 source_points: &[Point3f],
757 target_points: &[Point3f],
758 normals: &[Vector3f],
759) -> f32 {
760 if source_points.is_empty() {
761 return 0.0;
762 }
763 let sum: f32 = source_points
764 .iter()
765 .zip(target_points.iter())
766 .zip(normals.iter())
767 .map(|((src, tgt), n)| {
768 let d = n.dot(&(tgt.coords - src.coords));
769 d * d
770 })
771 .sum();
772 sum / source_points.len() as f32
773}
774
775pub fn icp_point_to_plane(
791 source: &PointCloud<Point3f>,
792 target: &PointCloud<Point3f>,
793 target_normals: &[Vector3f],
794 init: Isometry3<f32>,
795 max_iters: usize,
796) -> Result<ICPResult> {
797 icp_point_to_plane_detailed(source, target, target_normals, init, max_iters, None, 1e-6)
798}
799
800pub fn icp_point_to_plane_detailed(
811 source: &PointCloud<Point3f>,
812 target: &PointCloud<Point3f>,
813 target_normals: &[Vector3f],
814 init: Isometry3<f32>,
815 max_iters: usize,
816 max_correspondence_distance: Option<f32>,
817 convergence_threshold: f32,
818) -> Result<ICPResult> {
819 if source.is_empty() || target.is_empty() {
820 return Err(Error::InvalidData(
821 "Source or target point cloud is empty".to_string(),
822 ));
823 }
824 if target_normals.len() != target.points.len() {
825 return Err(Error::InvalidData(
826 "target_normals length must equal the number of target points".to_string(),
827 ));
828 }
829 if max_iters == 0 {
830 return Err(Error::InvalidData(
831 "Max iterations must be positive".to_string(),
832 ));
833 }
834
835 let Some(frame) = LocalFrame::if_far_from_origin(&target.points) else {
836 return icp_point_to_plane_local(
837 &source.points,
838 &target.points,
839 target_normals,
840 init,
841 max_iters,
842 max_correspondence_distance,
843 convergence_threshold,
844 );
845 };
846 let mut result = icp_point_to_plane_local(
847 &frame.points_to_local(&source.points),
848 &frame.points_to_local(&target.points),
849 target_normals,
850 frame.transform_to_local(&init),
851 max_iters,
852 max_correspondence_distance,
853 convergence_threshold,
854 )?;
855 result.transformation = frame.transform_to_world(&result.transformation);
856 Ok(result)
857}
858
859fn icp_point_to_plane_local(
861 source: &[Point3f],
862 target: &[Point3f],
863 target_normals: &[Vector3f],
864 init: Isometry3<f32>,
865 max_iters: usize,
866 max_correspondence_distance: Option<f32>,
867 convergence_threshold: f32,
868) -> Result<ICPResult> {
869 let mut current_transform = init;
870 let mut previous_mse = f32::INFINITY;
871 let mut final_correspondences: Vec<(usize, usize)> = Vec::new();
872 let target_tree = KdTree::new(target)?;
873 let max_dist_sq = max_distance_sq(max_correspondence_distance);
874 let source_scale = CloudScale::of(source);
875 let mut correspondences: Vec<Option<(usize, f32)>> = vec![None; source.len()];
876
877 for iteration in 0..max_iters {
878 let transformed_source: Vec<Point3f> =
880 source.par_iter().map(|p| current_transform * p).collect();
881
882 correspondences = find_correspondences_seeded(
884 source,
885 ¤t_transform,
886 target,
887 &target_tree,
888 &correspondences,
889 max_dist_sq,
890 );
891
892 let mut valid_source: Vec<Point3f> = Vec::new();
893 let mut valid_target: Vec<Point3f> = Vec::new();
894 let mut valid_normals: Vec<Vector3f> = Vec::new();
895 let mut corr_pairs: Vec<(usize, usize)> = Vec::new();
896
897 for (src_idx, corr) in correspondences.iter().enumerate() {
898 if let Some((tgt_idx, _)) = corr {
899 valid_source.push(transformed_source[src_idx]);
900 valid_target.push(target[*tgt_idx]);
901 valid_normals.push(target_normals[*tgt_idx]);
902 corr_pairs.push((src_idx, *tgt_idx));
903 }
904 }
905
906 if valid_source.len() < 6 {
908 return Err(Error::Algorithm(
909 "Insufficient correspondences for point-to-plane ICP (need ≥ 6)".to_string(),
910 ));
911 }
912
913 let delta =
914 compute_transformation_point_to_plane(&valid_source, &valid_target, &valid_normals)?;
915 let moved_centroid = current_transform * source_scale.centroid;
916 current_transform = delta * current_transform;
917
918 let current_mse = compute_point_to_plane_mse(&valid_source, &valid_target, &valid_normals);
919 if icp_converged(
920 previous_mse,
921 current_mse,
922 &delta,
923 &moved_centroid,
924 source_scale.radius,
925 convergence_threshold,
926 ) {
927 return Ok(ICPResult {
928 transformation: current_transform,
929 mse: current_mse,
930 iterations: iteration + 1,
931 converged: true,
932 correspondences: corr_pairs,
933 });
934 }
935
936 previous_mse = current_mse;
937 final_correspondences = corr_pairs;
938 }
939
940 Ok(ICPResult {
941 transformation: current_transform,
942 mse: previous_mse,
943 iterations: max_iters,
944 converged: false,
945 correspondences: final_correspondences,
946 })
947}
948
949pub fn icp_point_to_point(
990 source: &PointCloud<Point3f>,
991 target: &PointCloud<Point3f>,
992 init: Isometry3<f32>,
993 max_iterations: usize,
994 convergence_threshold: f32,
995 max_correspondence_distance: Option<f32>,
996) -> Result<ICPResult> {
997 if source.is_empty() || target.is_empty() {
999 return Err(Error::InvalidData(
1000 "Source or target point cloud is empty".to_string(),
1001 ));
1002 }
1003
1004 if max_iterations == 0 {
1005 return Err(Error::InvalidData(
1006 "Max iterations must be positive".to_string(),
1007 ));
1008 }
1009
1010 if convergence_threshold <= 0.0 {
1011 return Err(Error::InvalidData(
1012 "Convergence threshold must be positive".to_string(),
1013 ));
1014 }
1015
1016 icp_detailed(
1018 source,
1019 target,
1020 init,
1021 max_iterations,
1022 max_correspondence_distance,
1023 convergence_threshold,
1024 )
1025}
1026
1027pub fn icp_point_to_point_default(
1040 source: &PointCloud<Point3f>,
1041 target: &PointCloud<Point3f>,
1042 init: Isometry3<f32>,
1043 max_iterations: usize,
1044) -> Result<ICPResult> {
1045 icp_point_to_point(source, target, init, max_iterations, 1e-6, None)
1046}
1047
1048pub fn multiscale_icp_point_to_point(
1050 source: &PointCloud<Point3f>,
1051 target: &PointCloud<Point3f>,
1052 init: Isometry3<f32>,
1053 config: &MultiScaleIcpConfig,
1054) -> Result<ICPResult> {
1055 if source.is_empty() || target.is_empty() {
1056 return Err(Error::InvalidData(
1057 "Source or target point cloud is empty".to_string(),
1058 ));
1059 }
1060 if config.levels.is_empty() {
1061 return Err(Error::InvalidData(
1062 "At least one ICP scale level is required".to_string(),
1063 ));
1064 }
1065 if config.convergence_threshold <= 0.0 {
1066 return Err(Error::InvalidData(
1067 "Convergence threshold must be positive".to_string(),
1068 ));
1069 }
1070 if config.final_refinement_iterations == 0 {
1071 return Err(Error::InvalidData(
1072 "Final refinement iterations must be positive".to_string(),
1073 ));
1074 }
1075
1076 let mut current_transform = init;
1077 let mut total_iterations = 0usize;
1078 let mut last_result: Option<ICPResult> = None;
1079
1080 for level in &config.levels {
1081 if level.voxel_size <= 0.0 {
1082 return Err(Error::InvalidData(
1083 "Scale voxel_size must be positive".to_string(),
1084 ));
1085 }
1086 if level.max_iterations == 0 {
1087 return Err(Error::InvalidData(
1088 "Scale max_iterations must be positive".to_string(),
1089 ));
1090 }
1091
1092 let source_down = voxel_grid_filter(source, level.voxel_size)?;
1093 let target_down = voxel_grid_filter(target, level.voxel_size)?;
1094 if source_down.len() < 3 || target_down.len() < 3 {
1095 continue;
1096 }
1097
1098 let result = icp_point_to_point(
1099 &source_down,
1100 &target_down,
1101 current_transform,
1102 level.max_iterations,
1103 config.convergence_threshold,
1104 level.max_correspondence_distance,
1105 )?;
1106
1107 current_transform = result.transformation;
1108 total_iterations += result.iterations;
1109 last_result = Some(result);
1110 }
1111
1112 if last_result.is_none() {
1113 return Err(Error::Algorithm(
1114 "No multiscale ICP level had enough downsampled points".to_string(),
1115 ));
1116 }
1117
1118 let final_result = icp_point_to_point(
1119 source,
1120 target,
1121 current_transform,
1122 config.final_refinement_iterations,
1123 config.convergence_threshold,
1124 config.final_max_correspondence_distance,
1125 )?;
1126
1127 Ok(ICPResult {
1128 transformation: final_result.transformation,
1129 mse: final_result.mse,
1130 iterations: total_iterations + final_result.iterations,
1131 converged: final_result.converged,
1132 correspondences: final_result.correspondences,
1133 })
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139
1140 use nalgebra::UnitQuaternion;
1141
1142 #[test]
1143 fn test_icp_identity_transformation() {
1144 let mut source = PointCloud::new();
1146 let mut target = PointCloud::new();
1147
1148 for i in 0..10 {
1149 let point = Point3f::new(i as f32, (i * 2) as f32, (i * 3) as f32);
1150 source.push(point);
1151 target.push(point);
1152 }
1153
1154 let init = Isometry3::identity();
1155 let result = icp_detailed(&source, &target, init, 10, None, 1e-6).unwrap();
1156
1157 assert!(result.converged);
1159 assert!(result.mse < 1e-6);
1160 assert!(result.iterations <= 3);
1161 }
1162
1163 #[test]
1164 fn test_icp_translation() {
1165 let mut source = PointCloud::new();
1167 let mut target = PointCloud::new();
1168
1169 let translation = Vector3f::new(1.0, 2.0, 3.0);
1170
1171 for i in 0..10 {
1172 let source_point = Point3f::new(i as f32, (i * 2) as f32, (i * 3) as f32);
1173 let target_point = source_point + translation;
1174 source.push(source_point);
1175 target.push(target_point);
1176 }
1177
1178 let init = Isometry3::identity();
1179 let result = icp_detailed(&source, &target, init, 50, None, 1e-6).unwrap();
1180
1181 let computed_translation = result.transformation.translation.vector;
1183 assert!(
1186 computed_translation.magnitude() > 0.05,
1187 "Translation magnitude too small: {}",
1188 computed_translation.magnitude()
1189 );
1190
1191 assert!(result.mse < 2.0); }
1193
1194 #[test]
1195 fn test_icp_rotation() {
1196 let mut source = PointCloud::new();
1198 let mut target = PointCloud::new();
1199
1200 let rotation =
1201 UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), std::f32::consts::FRAC_PI_4);
1202
1203 for i in 0..20 {
1204 let source_point = Point3f::new(i as f32, (i % 5) as f32, 0.0);
1205 let target_point = rotation * source_point;
1206 source.push(source_point);
1207 target.push(target_point);
1208 }
1209
1210 let init = Isometry3::identity();
1211 let result = icp_detailed(&source, &target, init, 100, None, 1e-6).unwrap();
1212
1213 assert!(result.mse < 1.0, "MSE too high: {}", result.mse);
1215 }
1216
1217 #[test]
1218 fn test_icp_insufficient_points() {
1219 let mut source = PointCloud::new();
1220 let mut target = PointCloud::new();
1221
1222 source.push(Point3f::new(0.0, 0.0, 0.0));
1223 target.push(Point3f::new(1.0, 1.0, 1.0));
1224
1225 let init = Isometry3::identity();
1226 let result = icp_detailed(&source, &target, init, 10, None, 1e-6);
1227
1228 assert!(result.is_err());
1229 }
1230
1231 #[test]
1232 fn test_icp_api_compatibility() {
1233 let mut source = PointCloud::new();
1235 let mut target = PointCloud::new();
1236
1237 for i in 0..5 {
1238 let point = Point3f::new(i as f32, i as f32, 0.0);
1239 source.push(point);
1240 target.push(point + Vector3f::new(1.0, 0.0, 0.0));
1241 }
1242
1243 let init = Isometry3::identity();
1244 let transform = icp(&source, &target, init, 20);
1245
1246 assert!(transform.translation.vector.magnitude() > 0.5);
1248 }
1249
1250 #[test]
1251 fn test_icp_far_from_origin_does_not_stop_early() {
1252 let origin = nalgebra::Vector3::<f64>::new(500_000.0, 4_200_000.0, 0.0);
1256 let local: Vec<nalgebra::Vector3<f64>> = (0..40)
1257 .flat_map(|i| (0..40).map(move |j| (i as f64 * 10.0, j as f64 * 10.0)))
1258 .map(|(x, y)| nalgebra::Vector3::new(x, y, 30.0 * (x / 70.0).sin() * (y / 110.0).cos()))
1259 .collect();
1260 let centre = local.iter().sum::<nalgebra::Vector3<f64>>() / local.len() as f64 + origin;
1261
1262 let truth = nalgebra::Translation3::from(centre + nalgebra::Vector3::new(3.0, -2.0, 0.5))
1264 * nalgebra::UnitQuaternion::from_euler_angles(0.0, 0.0, 0.01)
1265 * nalgebra::Translation3::from(-centre);
1266
1267 let world: Vec<nalgebra::Point3<f64>> = local
1268 .iter()
1269 .map(|v| nalgebra::Point3::from(v + origin))
1270 .collect();
1271 let source = PointCloud::from_points(world.iter().map(|p| p.cast::<f32>()).collect());
1272 let target =
1273 PointCloud::from_points(world.iter().map(|p| (truth * p).cast::<f32>()).collect());
1274
1275 let result =
1276 icp_point_to_point(&source, &target, Isometry3::identity(), 100, 1e-6, None).unwrap();
1277
1278 let estimate = result.transformation.cast::<f64>();
1282 let error = world
1283 .iter()
1284 .map(|p| (estimate * p - truth * p).norm())
1285 .fold(0.0, f64::max);
1286 assert!(
1287 result.converged && error < 0.05,
1288 "points {error} m off after {} iterations (converged: {})",
1289 result.iterations,
1290 result.converged
1291 );
1292 }
1293
1294 #[test]
1295 fn test_icp_convergence_is_scale_invariant() {
1296 let solve = |scale: f32| {
1299 let mut source = PointCloud::new();
1300 for i in 0..40 {
1301 for j in 0..40 {
1302 let (x, y) = (i as f32 * 0.1, j as f32 * 0.1);
1303 let z = 0.3 * (1.3 * x).sin() * (0.7 * y).cos();
1304 source.push(Point3f::new(x, y, z) * scale);
1305 }
1306 }
1307 let truth = Isometry3::from_parts(
1308 Translation3::new(0.05 * scale, -0.03 * scale, 0.02 * scale),
1309 UnitQuaternion::from_euler_angles(0.0, 0.0, 0.03),
1310 );
1311 let target = PointCloud::from_points(source.points.iter().map(|p| truth * p).collect());
1312 let result =
1313 icp_point_to_point(&source, &target, Isometry3::identity(), 100, 1e-6, None)
1314 .unwrap();
1315 let rotation_error =
1316 (result.transformation.rotation.inverse() * truth.rotation).angle();
1317 let translation_error =
1318 (result.transformation.translation.vector - truth.translation.vector).norm()
1319 / scale;
1320 (result.iterations, rotation_error, translation_error)
1321 };
1322
1323 let (iters_big, rot_big, trans_big) = solve(1.0);
1324 let (iters_small, rot_small, trans_small) = solve(0.01);
1325
1326 assert!(rot_big < 1e-3 && trans_big < 1e-3);
1327 assert!(
1328 rot_small < 1e-3 && trans_small < 1e-3,
1329 "small-scale ICP stopped early: rot {rot_small}, trans {trans_small}, {iters_small} iterations"
1330 );
1331 assert!(
1332 iters_small.abs_diff(iters_big) <= 2,
1333 "iterations: big {iters_big}, small {iters_small}"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_max_distance_cutoff_semantics() {
1339 let source = vec![Point3f::new(0.0, 0.0, 0.0)];
1340 let target = vec![Point3f::new(0.5, 0.0, 0.0)];
1341
1342 assert!(find_correspondences(&source, &target, Some(0.5))[0].is_some());
1344 assert!(find_correspondences(&source, &target, Some(0.49))[0].is_none());
1346 assert!(find_correspondences(&source, &target, Some(-0.5))[0].is_none());
1348 assert!(find_correspondences(&source, &target, None)[0].is_some());
1350
1351 assert!(find_correspondences(&source, &source, Some(0.0))[0].is_some());
1353 }
1354
1355 #[test]
1356 fn test_correspondence_finding() {
1357 let source = vec![
1358 Point3f::new(0.0, 0.0, 0.0),
1359 Point3f::new(1.0, 0.0, 0.0),
1360 Point3f::new(0.0, 1.0, 0.0),
1361 ];
1362
1363 let target = vec![
1364 Point3f::new(0.1, 0.1, 0.0),
1365 Point3f::new(1.1, 0.1, 0.0),
1366 Point3f::new(0.1, 1.1, 0.0),
1367 ];
1368
1369 let correspondences = find_correspondences(&source, &target, None);
1370
1371 assert_eq!(correspondences.len(), 3);
1372 assert!(correspondences[0].is_some());
1373 assert!(correspondences[1].is_some());
1374 assert!(correspondences[2].is_some());
1375 }
1376
1377 #[test]
1378 fn test_icp_point_to_point_basic() {
1379 let mut source = PointCloud::new();
1381 let mut target = PointCloud::new();
1382
1383 for x in 0..3 {
1385 for y in 0..3 {
1386 for z in 0..3 {
1387 let point = Point3f::new(x as f32, y as f32, z as f32);
1388 source.push(point);
1389 target.push(point + Vector3f::new(1.0, 0.5, 0.25)); }
1391 }
1392 }
1393
1394 let init = Isometry3::identity();
1395 let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1396
1397 assert!(result.converged || result.iterations == 50);
1399 assert!(result.mse < 2.0); let translation_mag = result.transformation.translation.vector.magnitude();
1402 assert!(
1403 translation_mag > 0.1,
1404 "Translation magnitude too small: {}",
1405 translation_mag
1406 );
1407 }
1408
1409 #[test]
1410 fn test_icp_point_to_point_with_noise() {
1411 let mut source = PointCloud::new();
1413 let mut target = PointCloud::new();
1414
1415 let translation = Vector3f::new(2.0, 1.0, 0.5);
1416 let rotation = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), 0.3);
1417 let transform = Isometry3::from_parts(
1418 Translation3::new(translation.x, translation.y, translation.z),
1419 rotation,
1420 );
1421
1422 for i in 0..100 {
1424 let angle = (i as f32) * 0.1;
1425 let radius = 2.0 + (i % 10) as f32 * 0.1;
1426 let source_point = Point3f::new(
1427 radius * angle.cos(),
1428 radius * angle.sin(),
1429 (i % 5) as f32 * 0.5,
1430 );
1431 source.push(source_point);
1432 }
1433
1434 for point in &source.points {
1436 let transformed = transform * point;
1437 let noise = Vector3f::new(
1439 (rand::random::<f32>() - 0.5) * 0.1,
1440 (rand::random::<f32>() - 0.5) * 0.1,
1441 (rand::random::<f32>() - 0.5) * 0.1,
1442 );
1443 target.push(transformed + noise);
1444 }
1445
1446 let init = Isometry3::identity();
1447 let result = icp_point_to_point(&source, &target, init, 100, 1e-5, None).unwrap();
1448
1449 assert!(result.mse < 0.5); assert!(result.transformation.translation.vector.magnitude() > 1.0);
1452 }
1453
1454 #[test]
1455 fn test_icp_point_to_point_known_transform() {
1456 let mut source = PointCloud::new();
1458 let mut target = PointCloud::new();
1459
1460 let known_translation = Vector3f::new(1.0, -0.5, 0.25);
1462 let known_rotation = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), 0.2);
1463 let known_transform = Isometry3::from_parts(
1464 Translation3::new(
1465 known_translation.x,
1466 known_translation.y,
1467 known_translation.z,
1468 ),
1469 known_rotation,
1470 );
1471
1472 for x in -2..=2 {
1474 for y in -2..=2 {
1475 for z in -1..=1 {
1476 let point = Point3f::new(x as f32, y as f32, z as f32);
1477 source.push(point);
1478 target.push(known_transform * point);
1479 }
1480 }
1481 }
1482
1483 let init = Isometry3::identity();
1484 let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1485
1486 let computed_translation = result.transformation.translation.vector;
1488 let translation_error = (computed_translation - known_translation).magnitude();
1489 assert!(
1490 translation_error < 1.0,
1491 "Translation error too large: {}",
1492 translation_error
1493 );
1494
1495 assert!(result.mse < 0.5);
1496 }
1497
1498 #[test]
1499 fn test_icp_point_to_point_convergence() {
1500 let mut source = PointCloud::new();
1502 let mut target = PointCloud::new();
1503
1504 for i in 0..50 {
1506 let point = Point3f::new(i as f32 * 0.1, (i * 2) as f32 * 0.1, 0.0);
1507 source.push(point);
1508 target.push(point + Vector3f::new(0.5, 0.0, 0.0));
1509 }
1510
1511 let init = Isometry3::identity();
1512 let result = icp_point_to_point(&source, &target, init, 20, 1e-6, None).unwrap();
1513
1514 assert!(result.converged);
1516 assert!(result.iterations < 20);
1517 assert!(result.mse < 0.1);
1518 }
1519
1520 #[test]
1521 fn test_icp_point_to_point_max_distance() {
1522 let mut source = PointCloud::new();
1524 let mut target = PointCloud::new();
1525
1526 for i in 0..10 {
1528 source.push(Point3f::new(i as f32, 0.0, 0.0));
1529 }
1530
1531 for i in 0..10 {
1533 if i < 5 {
1534 target.push(Point3f::new(i as f32 + 0.1, 0.0, 0.0)); } else {
1536 target.push(Point3f::new(i as f32 + 10.0, 0.0, 0.0)); }
1538 }
1539
1540 let init = Isometry3::identity();
1541 let result = icp_point_to_point(&source, &target, init, 20, 1e-6, Some(1.0)).unwrap();
1542
1543 assert!(result.correspondences.len() <= 10);
1547 assert!(result.mse < 5.0); }
1549
1550 #[test]
1551 fn test_icp_point_to_point_default() {
1552 let mut source = PointCloud::new();
1554 let mut target = PointCloud::new();
1555
1556 for i in 0..10 {
1557 let point = Point3f::new(i as f32, i as f32, 0.0);
1558 source.push(point);
1559 target.push(point + Vector3f::new(1.0, 0.0, 0.0));
1560 }
1561
1562 let init = Isometry3::identity();
1563 let result = icp_point_to_point_default(&source, &target, init, 30).unwrap();
1564
1565 assert!(result.mse < 1.0);
1567 assert!(result.transformation.translation.vector.magnitude() > 0.5);
1568 }
1569
1570 #[test]
1571 fn test_icp_point_to_point_validation() {
1572 let empty_source = PointCloud::new();
1574 let mut target = PointCloud::new();
1575 target.push(Point3f::new(0.0, 0.0, 0.0));
1576
1577 let init = Isometry3::identity();
1578
1579 let result = icp_point_to_point(&empty_source, &target, init, 10, 1e-6, None);
1581 assert!(result.is_err());
1582
1583 let result = icp_point_to_point(&target, &target, init, 0, 1e-6, None);
1585 assert!(result.is_err());
1586
1587 let result = icp_point_to_point(&target, &target, init, 10, -1e-6, None);
1589 assert!(result.is_err());
1590 }
1591
1592 fn make_sphere_cloud(n: usize) -> (PointCloud<Point3f>, Vec<Vector3f>) {
1599 let mut cloud = PointCloud::new();
1600 let mut normals = Vec::new();
1601 let radius = 3.0_f32;
1602 let golden_angle = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
1603 for i in 0..n {
1604 let y = 1.0 - (i as f32 / (n as f32 - 1.0).max(1.0)) * 2.0;
1605 let r = (1.0 - y * y).max(0.0_f32).sqrt();
1606 let theta = golden_angle * i as f32;
1607 let x = theta.cos() * r;
1608 let z = theta.sin() * r;
1609 let normal = Vector3f::new(x, y, z);
1611 cloud.push(Point3f::new(x * radius, y * radius, z * radius));
1612 normals.push(normal);
1613 }
1614 (cloud, normals)
1615 }
1616
1617 #[test]
1618 fn test_icp_point_to_plane_identity() {
1619 let (source, normals) = make_sphere_cloud(50);
1620 let target = source.clone();
1621 let init = Isometry3::identity();
1622
1623 let result = icp_point_to_plane(&source, &target, &normals, init, 20).unwrap();
1624
1625 assert!(result.converged);
1626 assert!(result.mse < 1e-6, "mse={}", result.mse);
1627 }
1628
1629 #[test]
1630 fn test_icp_point_to_plane_translation() {
1631 let (source, normals) = make_sphere_cloud(100);
1633 let shift = Vector3f::new(0.15, 0.0, 0.0);
1634
1635 let mut target = PointCloud::new();
1636 for p in &source.points {
1637 target.push(p + shift);
1638 }
1639 let result =
1641 icp_point_to_plane(&source, &target, &normals, Isometry3::identity(), 50).unwrap();
1642
1643 let t_err = (result.transformation.translation.vector - shift).magnitude();
1644 assert!(t_err < 0.3, "translation error={}", t_err);
1645 assert!(result.mse < 0.1, "mse={}", result.mse);
1646 }
1647
1648 #[test]
1649 fn test_icp_point_to_plane_validation() {
1650 let (source, normals) = make_sphere_cloud(20);
1651 let init = Isometry3::identity();
1652
1653 let bad_normals = vec![Vector3f::new(0.0, 0.0, 1.0)];
1655 let result = icp_point_to_plane(&source, &source, &bad_normals, init, 10);
1656 assert!(result.is_err());
1657
1658 let empty: PointCloud<Point3f> = PointCloud::new();
1660 let result = icp_point_to_plane(&empty, &source, &normals, init, 10);
1661 assert!(result.is_err());
1662
1663 let result = icp_point_to_plane_detailed(&source, &source, &normals, init, 0, None, 1e-6);
1665 assert!(result.is_err());
1666 }
1667
1668 #[test]
1669 fn test_icp_point_to_plane_vs_point_to_point_convergence() {
1670 let (source, normals) = make_sphere_cloud(80);
1672 let shift = Vector3f::new(0.1, 0.05, 0.0);
1673 let mut target = PointCloud::new();
1674 for p in &source.points {
1675 target.push(p + shift);
1676 }
1677
1678 let init = Isometry3::identity();
1679
1680 let p2pl_result = icp_point_to_plane(&source, &target, &normals, init, 50).unwrap();
1681 let p2pt_result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1682
1683 assert!(
1685 p2pl_result.transformation.translation.vector.magnitude() > 0.05,
1686 "p2pl did not translate: t={}",
1687 p2pl_result.transformation.translation.vector.magnitude()
1688 );
1689 assert!(
1690 p2pt_result.transformation.translation.vector.magnitude() > 0.05,
1691 "p2pt did not translate: t={}",
1692 p2pt_result.transformation.translation.vector.magnitude()
1693 );
1694 assert!(
1696 p2pl_result.converged || p2pl_result.mse < 0.1,
1697 "p2pl failed to converge: mse={}, iters={}",
1698 p2pl_result.mse,
1699 p2pl_result.iterations
1700 );
1701 }
1702
1703 #[test]
1704 fn test_icp_point_to_plane_detailed_max_distance() {
1705 let (source, normals) = make_sphere_cloud(50);
1706 let mut target = PointCloud::new();
1707 for p in &source.points {
1708 target.push(p + Vector3f::new(0.1, 0.0, 0.0));
1709 }
1710
1711 let init = Isometry3::identity();
1712 let result =
1713 icp_point_to_plane_detailed(&source, &target, &normals, init, 30, Some(5.0), 1e-6);
1714 assert!(result.is_ok(), "unexpected error: {:?}", result.err());
1715 let result = result.unwrap();
1716 assert!(result.mse < 0.5, "mse={}", result.mse);
1717 }
1718}