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(
529 source: &PointCloud<Point3f>,
530 target: &PointCloud<Point3f>,
531 init: Isometry3<f32>,
532 max_iters: usize,
533) -> Result<Isometry3<f32>> {
534 Ok(icp_detailed(source, target, init, max_iters, None, 1e-6)?.transformation)
535}
536
537pub fn icp_detailed(
552 source: &PointCloud<Point3f>,
553 target: &PointCloud<Point3f>,
554 init: Isometry3<f32>,
555 max_iters: usize,
556 max_correspondence_distance: Option<f32>,
557 convergence_threshold: f32,
558) -> Result<ICPResult> {
559 if source.is_empty() || target.is_empty() {
560 return Err(Error::InvalidData(
561 "Source or target point cloud is empty".to_string(),
562 ));
563 }
564
565 if max_iters == 0 {
566 return Err(Error::InvalidData(
567 "Max iterations must be positive".to_string(),
568 ));
569 }
570
571 let Some(frame) = LocalFrame::if_far_from_origin(&target.points) else {
572 return icp_point_to_point_local(
573 &source.points,
574 &target.points,
575 init,
576 max_iters,
577 max_correspondence_distance,
578 convergence_threshold,
579 );
580 };
581 let mut result = icp_point_to_point_local(
582 &frame.points_to_local(&source.points),
583 &frame.points_to_local(&target.points),
584 frame.transform_to_local(&init),
585 max_iters,
586 max_correspondence_distance,
587 convergence_threshold,
588 )?;
589 result.transformation = frame.transform_to_world(&result.transformation);
590 Ok(result)
591}
592
593fn icp_point_to_point_local(
595 source: &[Point3f],
596 target: &[Point3f],
597 init: Isometry3<f32>,
598 max_iters: usize,
599 max_correspondence_distance: Option<f32>,
600 convergence_threshold: f32,
601) -> Result<ICPResult> {
602 let mut current_transform = init;
603 let mut previous_mse = f32::INFINITY;
604 let target_tree = KdTree::new(target)?;
605 let max_dist_sq = max_distance_sq(max_correspondence_distance);
606 let source_scale = CloudScale::of(source);
607 let mut matches: Vec<Option<(usize, f32)>> = vec![None; source.len()];
608
609 for iteration in 0..max_iters {
610 let stats = match_and_collect(
613 source,
614 ¤t_transform,
615 target,
616 &target_tree,
617 &mut matches,
618 max_dist_sq,
619 );
620
621 if stats.count < 3 {
622 return Err(Error::Algorithm(
623 "Insufficient correspondences found".to_string(),
624 ));
625 }
626
627 let delta_transform = stats.transformation()?;
629
630 let moved_centroid = current_transform * source_scale.centroid;
632 current_transform = delta_transform * current_transform;
633
634 let current_mse = stats.mse();
636
637 if icp_converged(
639 previous_mse,
640 current_mse,
641 &delta_transform,
642 &moved_centroid,
643 source_scale.radius,
644 convergence_threshold,
645 ) {
646 return Ok(ICPResult {
647 transformation: current_transform,
648 mse: current_mse,
649 iterations: iteration + 1,
650 converged: true,
651 correspondences: correspondence_pairs(&matches),
652 });
653 }
654
655 previous_mse = current_mse;
656 }
657
658 let final_stats = CorrespondenceStats::collect(source, ¤t_transform, target, &matches);
660 let final_mse = if final_stats.count > 0 {
661 final_stats.mse()
662 } else {
663 previous_mse
664 };
665
666 Ok(ICPResult {
667 transformation: current_transform,
668 mse: final_mse,
669 iterations: max_iters,
670 converged: false,
671 correspondences: correspondence_pairs(&matches),
672 })
673}
674
675#[deprecated(note = "Use icp instead which matches the standard API")]
677pub fn icp_legacy(
678 source: &PointCloud<Point3f>,
679 target: &PointCloud<Point3f>,
680 max_iterations: usize,
681 threshold: f32,
682) -> Result<(threecrate_core::Transform3D, f32)> {
683 let init = Isometry3::identity();
684 let result = icp_detailed(source, target, init, max_iterations, Some(threshold), 1e-6)?;
685
686 let transform = threecrate_core::Transform3D::from(result.transformation);
688
689 Ok((transform, result.mse))
690}
691
692fn compute_transformation_point_to_plane(
699 source_points: &[Point3f],
700 target_points: &[Point3f],
701 target_normals: &[Vector3f],
702) -> Result<Isometry3<f32>> {
703 if source_points.len() != target_points.len()
704 || source_points.len() != target_normals.len()
705 || source_points.is_empty()
706 {
707 return Err(Error::InvalidData(
708 "Point/normal count mismatch in point-to-plane optimization".to_string(),
709 ));
710 }
711
712 let mut ata = Matrix6::<f32>::zeros();
713 let mut atb = Vector6::<f32>::zeros();
714
715 for ((src, tgt), normal) in source_points
716 .iter()
717 .zip(target_points.iter())
718 .zip(target_normals.iter())
719 {
720 let c = src.coords.cross(normal);
722
723 let a_row = Vector6::new(c.x, c.y, c.z, normal.x, normal.y, normal.z);
725
726 let b_i = normal.dot(&(tgt.coords - src.coords));
728
729 ata += a_row * a_row.transpose();
730 atb += a_row * b_i;
731 }
732
733 let x = if let Some(chol) = ata.cholesky() {
736 chol.solve(&atb)
737 } else {
738 ata.lu().solve(&atb).ok_or_else(|| {
739 Error::Algorithm("Point-to-plane system is ill-conditioned".to_string())
740 })?
741 };
742
743 let rot_x = UnitQuaternion::from_axis_angle(&Vector3f::x_axis(), x[0]);
745 let rot_y = UnitQuaternion::from_axis_angle(&Vector3f::y_axis(), x[1]);
746 let rot_z = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), x[2]);
747 let rotation = rot_z * rot_y * rot_x;
748
749 Ok(Isometry3::from_parts(
750 Translation3::new(x[3], x[4], x[5]),
751 rotation,
752 ))
753}
754
755fn compute_point_to_plane_mse(
757 source_points: &[Point3f],
758 target_points: &[Point3f],
759 normals: &[Vector3f],
760) -> f32 {
761 if source_points.is_empty() {
762 return 0.0;
763 }
764 let sum: f32 = source_points
765 .iter()
766 .zip(target_points.iter())
767 .zip(normals.iter())
768 .map(|((src, tgt), n)| {
769 let d = n.dot(&(tgt.coords - src.coords));
770 d * d
771 })
772 .sum();
773 sum / source_points.len() as f32
774}
775
776pub fn icp_point_to_plane(
792 source: &PointCloud<Point3f>,
793 target: &PointCloud<Point3f>,
794 target_normals: &[Vector3f],
795 init: Isometry3<f32>,
796 max_iters: usize,
797) -> Result<ICPResult> {
798 icp_point_to_plane_detailed(source, target, target_normals, init, max_iters, None, 1e-6)
799}
800
801pub fn icp_point_to_plane_detailed(
812 source: &PointCloud<Point3f>,
813 target: &PointCloud<Point3f>,
814 target_normals: &[Vector3f],
815 init: Isometry3<f32>,
816 max_iters: usize,
817 max_correspondence_distance: Option<f32>,
818 convergence_threshold: f32,
819) -> Result<ICPResult> {
820 if source.is_empty() || target.is_empty() {
821 return Err(Error::InvalidData(
822 "Source or target point cloud is empty".to_string(),
823 ));
824 }
825 if target_normals.len() != target.points.len() {
826 return Err(Error::InvalidData(
827 "target_normals length must equal the number of target points".to_string(),
828 ));
829 }
830 if max_iters == 0 {
831 return Err(Error::InvalidData(
832 "Max iterations must be positive".to_string(),
833 ));
834 }
835
836 let Some(frame) = LocalFrame::if_far_from_origin(&target.points) else {
837 return icp_point_to_plane_local(
838 &source.points,
839 &target.points,
840 target_normals,
841 init,
842 max_iters,
843 max_correspondence_distance,
844 convergence_threshold,
845 );
846 };
847 let mut result = icp_point_to_plane_local(
848 &frame.points_to_local(&source.points),
849 &frame.points_to_local(&target.points),
850 target_normals,
851 frame.transform_to_local(&init),
852 max_iters,
853 max_correspondence_distance,
854 convergence_threshold,
855 )?;
856 result.transformation = frame.transform_to_world(&result.transformation);
857 Ok(result)
858}
859
860fn icp_point_to_plane_local(
862 source: &[Point3f],
863 target: &[Point3f],
864 target_normals: &[Vector3f],
865 init: Isometry3<f32>,
866 max_iters: usize,
867 max_correspondence_distance: Option<f32>,
868 convergence_threshold: f32,
869) -> Result<ICPResult> {
870 let mut current_transform = init;
871 let mut previous_mse = f32::INFINITY;
872 let mut final_correspondences: Vec<(usize, usize)> = Vec::new();
873 let target_tree = KdTree::new(target)?;
874 let max_dist_sq = max_distance_sq(max_correspondence_distance);
875 let source_scale = CloudScale::of(source);
876 let mut correspondences: Vec<Option<(usize, f32)>> = vec![None; source.len()];
877
878 for iteration in 0..max_iters {
879 let transformed_source: Vec<Point3f> =
881 source.par_iter().map(|p| current_transform * p).collect();
882
883 correspondences = find_correspondences_seeded(
885 source,
886 ¤t_transform,
887 target,
888 &target_tree,
889 &correspondences,
890 max_dist_sq,
891 );
892
893 let mut valid_source: Vec<Point3f> = Vec::new();
894 let mut valid_target: Vec<Point3f> = Vec::new();
895 let mut valid_normals: Vec<Vector3f> = Vec::new();
896 let mut corr_pairs: Vec<(usize, usize)> = Vec::new();
897
898 for (src_idx, corr) in correspondences.iter().enumerate() {
899 if let Some((tgt_idx, _)) = corr {
900 valid_source.push(transformed_source[src_idx]);
901 valid_target.push(target[*tgt_idx]);
902 valid_normals.push(target_normals[*tgt_idx]);
903 corr_pairs.push((src_idx, *tgt_idx));
904 }
905 }
906
907 if valid_source.len() < 6 {
909 return Err(Error::Algorithm(
910 "Insufficient correspondences for point-to-plane ICP (need ≥ 6)".to_string(),
911 ));
912 }
913
914 let delta =
915 compute_transformation_point_to_plane(&valid_source, &valid_target, &valid_normals)?;
916 let moved_centroid = current_transform * source_scale.centroid;
917 current_transform = delta * current_transform;
918
919 let current_mse = compute_point_to_plane_mse(&valid_source, &valid_target, &valid_normals);
920 if icp_converged(
921 previous_mse,
922 current_mse,
923 &delta,
924 &moved_centroid,
925 source_scale.radius,
926 convergence_threshold,
927 ) {
928 return Ok(ICPResult {
929 transformation: current_transform,
930 mse: current_mse,
931 iterations: iteration + 1,
932 converged: true,
933 correspondences: corr_pairs,
934 });
935 }
936
937 previous_mse = current_mse;
938 final_correspondences = corr_pairs;
939 }
940
941 Ok(ICPResult {
942 transformation: current_transform,
943 mse: previous_mse,
944 iterations: max_iters,
945 converged: false,
946 correspondences: final_correspondences,
947 })
948}
949
950pub fn icp_point_to_point(
991 source: &PointCloud<Point3f>,
992 target: &PointCloud<Point3f>,
993 init: Isometry3<f32>,
994 max_iterations: usize,
995 convergence_threshold: f32,
996 max_correspondence_distance: Option<f32>,
997) -> Result<ICPResult> {
998 if source.is_empty() || target.is_empty() {
1000 return Err(Error::InvalidData(
1001 "Source or target point cloud is empty".to_string(),
1002 ));
1003 }
1004
1005 if max_iterations == 0 {
1006 return Err(Error::InvalidData(
1007 "Max iterations must be positive".to_string(),
1008 ));
1009 }
1010
1011 if convergence_threshold <= 0.0 {
1012 return Err(Error::InvalidData(
1013 "Convergence threshold must be positive".to_string(),
1014 ));
1015 }
1016
1017 icp_detailed(
1019 source,
1020 target,
1021 init,
1022 max_iterations,
1023 max_correspondence_distance,
1024 convergence_threshold,
1025 )
1026}
1027
1028pub fn icp_point_to_point_default(
1041 source: &PointCloud<Point3f>,
1042 target: &PointCloud<Point3f>,
1043 init: Isometry3<f32>,
1044 max_iterations: usize,
1045) -> Result<ICPResult> {
1046 icp_point_to_point(source, target, init, max_iterations, 1e-6, None)
1047}
1048
1049pub fn multiscale_icp_point_to_point(
1051 source: &PointCloud<Point3f>,
1052 target: &PointCloud<Point3f>,
1053 init: Isometry3<f32>,
1054 config: &MultiScaleIcpConfig,
1055) -> Result<ICPResult> {
1056 if source.is_empty() || target.is_empty() {
1057 return Err(Error::InvalidData(
1058 "Source or target point cloud is empty".to_string(),
1059 ));
1060 }
1061 if config.levels.is_empty() {
1062 return Err(Error::InvalidData(
1063 "At least one ICP scale level is required".to_string(),
1064 ));
1065 }
1066 if config.convergence_threshold <= 0.0 {
1067 return Err(Error::InvalidData(
1068 "Convergence threshold must be positive".to_string(),
1069 ));
1070 }
1071 if config.final_refinement_iterations == 0 {
1072 return Err(Error::InvalidData(
1073 "Final refinement iterations must be positive".to_string(),
1074 ));
1075 }
1076
1077 let mut current_transform = init;
1078 let mut total_iterations = 0usize;
1079 let mut last_result: Option<ICPResult> = None;
1080
1081 for level in &config.levels {
1082 if level.voxel_size <= 0.0 {
1083 return Err(Error::InvalidData(
1084 "Scale voxel_size must be positive".to_string(),
1085 ));
1086 }
1087 if level.max_iterations == 0 {
1088 return Err(Error::InvalidData(
1089 "Scale max_iterations must be positive".to_string(),
1090 ));
1091 }
1092
1093 let source_down = voxel_grid_filter(source, level.voxel_size)?;
1094 let target_down = voxel_grid_filter(target, level.voxel_size)?;
1095 if source_down.len() < 3 || target_down.len() < 3 {
1096 continue;
1097 }
1098
1099 let result = icp_point_to_point(
1100 &source_down,
1101 &target_down,
1102 current_transform,
1103 level.max_iterations,
1104 config.convergence_threshold,
1105 level.max_correspondence_distance,
1106 )?;
1107
1108 current_transform = result.transformation;
1109 total_iterations += result.iterations;
1110 last_result = Some(result);
1111 }
1112
1113 if last_result.is_none() {
1114 return Err(Error::Algorithm(
1115 "No multiscale ICP level had enough downsampled points".to_string(),
1116 ));
1117 }
1118
1119 let final_result = icp_point_to_point(
1120 source,
1121 target,
1122 current_transform,
1123 config.final_refinement_iterations,
1124 config.convergence_threshold,
1125 config.final_max_correspondence_distance,
1126 )?;
1127
1128 Ok(ICPResult {
1129 transformation: final_result.transformation,
1130 mse: final_result.mse,
1131 iterations: total_iterations + final_result.iterations,
1132 converged: final_result.converged,
1133 correspondences: final_result.correspondences,
1134 })
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140
1141 use nalgebra::UnitQuaternion;
1142
1143 #[test]
1144 fn test_icp_identity_transformation() {
1145 let mut source = PointCloud::new();
1147 let mut target = PointCloud::new();
1148
1149 for i in 0..10 {
1150 let point = Point3f::new(i as f32, (i * 2) as f32, (i * 3) as f32);
1151 source.push(point);
1152 target.push(point);
1153 }
1154
1155 let init = Isometry3::identity();
1156 let result = icp_detailed(&source, &target, init, 10, None, 1e-6).unwrap();
1157
1158 assert!(result.converged);
1160 assert!(result.mse < 1e-6);
1161 assert!(result.iterations <= 3);
1162 }
1163
1164 #[test]
1165 fn test_icp_translation() {
1166 let mut source = PointCloud::new();
1168 let mut target = PointCloud::new();
1169
1170 let translation = Vector3f::new(1.0, 2.0, 3.0);
1171
1172 for i in 0..10 {
1173 let source_point = Point3f::new(i as f32, (i * 2) as f32, (i * 3) as f32);
1174 let target_point = source_point + translation;
1175 source.push(source_point);
1176 target.push(target_point);
1177 }
1178
1179 let init = Isometry3::identity();
1180 let result = icp_detailed(&source, &target, init, 50, None, 1e-6).unwrap();
1181
1182 let computed_translation = result.transformation.translation.vector;
1184 assert!(
1187 computed_translation.magnitude() > 0.05,
1188 "Translation magnitude too small: {}",
1189 computed_translation.magnitude()
1190 );
1191
1192 assert!(result.mse < 2.0); }
1194
1195 #[test]
1196 fn test_icp_rotation() {
1197 let mut source = PointCloud::new();
1199 let mut target = PointCloud::new();
1200
1201 let rotation =
1202 UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), std::f32::consts::FRAC_PI_4);
1203
1204 for i in 0..20 {
1205 let source_point = Point3f::new(i as f32, (i % 5) as f32, 0.0);
1206 let target_point = rotation * source_point;
1207 source.push(source_point);
1208 target.push(target_point);
1209 }
1210
1211 let init = Isometry3::identity();
1212 let result = icp_detailed(&source, &target, init, 100, None, 1e-6).unwrap();
1213
1214 assert!(result.mse < 1.0, "MSE too high: {}", result.mse);
1216 }
1217
1218 #[test]
1219 fn test_icp_insufficient_points() {
1220 let mut source = PointCloud::new();
1221 let mut target = PointCloud::new();
1222
1223 source.push(Point3f::new(0.0, 0.0, 0.0));
1224 target.push(Point3f::new(1.0, 1.0, 1.0));
1225
1226 let init = Isometry3::identity();
1227 let result = icp_detailed(&source, &target, init, 10, None, 1e-6);
1228
1229 assert!(result.is_err());
1230 }
1231
1232 #[test]
1233 fn test_icp_api_compatibility() {
1234 let mut source = PointCloud::new();
1236 let mut target = PointCloud::new();
1237
1238 for i in 0..5 {
1239 let point = Point3f::new(i as f32, i as f32, 0.0);
1240 source.push(point);
1241 target.push(point + Vector3f::new(1.0, 0.0, 0.0));
1242 }
1243
1244 let init = Isometry3::identity();
1245 let transform = icp(&source, &target, init, 20).unwrap();
1246
1247 assert!(transform.translation.vector.magnitude() > 0.5);
1249
1250 let empty = PointCloud::new();
1251 assert!(icp(&empty, &target, init, 20).is_err());
1252 }
1253
1254 #[test]
1255 fn test_icp_far_from_origin_does_not_stop_early() {
1256 let origin = nalgebra::Vector3::<f64>::new(500_000.0, 4_200_000.0, 0.0);
1260 let local: Vec<nalgebra::Vector3<f64>> = (0..40)
1261 .flat_map(|i| (0..40).map(move |j| (i as f64 * 10.0, j as f64 * 10.0)))
1262 .map(|(x, y)| nalgebra::Vector3::new(x, y, 30.0 * (x / 70.0).sin() * (y / 110.0).cos()))
1263 .collect();
1264 let centre = local.iter().sum::<nalgebra::Vector3<f64>>() / local.len() as f64 + origin;
1265
1266 let truth = nalgebra::Translation3::from(centre + nalgebra::Vector3::new(3.0, -2.0, 0.5))
1268 * nalgebra::UnitQuaternion::from_euler_angles(0.0, 0.0, 0.01)
1269 * nalgebra::Translation3::from(-centre);
1270
1271 let world: Vec<nalgebra::Point3<f64>> = local
1272 .iter()
1273 .map(|v| nalgebra::Point3::from(v + origin))
1274 .collect();
1275 let source = PointCloud::from_points(world.iter().map(|p| p.cast::<f32>()).collect());
1276 let target =
1277 PointCloud::from_points(world.iter().map(|p| (truth * p).cast::<f32>()).collect());
1278
1279 let result =
1280 icp_point_to_point(&source, &target, Isometry3::identity(), 100, 1e-6, None).unwrap();
1281
1282 let estimate = result.transformation.cast::<f64>();
1286 let error = world
1287 .iter()
1288 .map(|p| (estimate * p - truth * p).norm())
1289 .fold(0.0, f64::max);
1290 assert!(
1291 result.converged && error < 0.05,
1292 "points {error} m off after {} iterations (converged: {})",
1293 result.iterations,
1294 result.converged
1295 );
1296 }
1297
1298 #[test]
1299 fn test_icp_convergence_is_scale_invariant() {
1300 let solve = |scale: f32| {
1303 let mut source = PointCloud::new();
1304 for i in 0..40 {
1305 for j in 0..40 {
1306 let (x, y) = (i as f32 * 0.1, j as f32 * 0.1);
1307 let z = 0.3 * (1.3 * x).sin() * (0.7 * y).cos();
1308 source.push(Point3f::new(x, y, z) * scale);
1309 }
1310 }
1311 let truth = Isometry3::from_parts(
1312 Translation3::new(0.05 * scale, -0.03 * scale, 0.02 * scale),
1313 UnitQuaternion::from_euler_angles(0.0, 0.0, 0.03),
1314 );
1315 let target = PointCloud::from_points(source.points.iter().map(|p| truth * p).collect());
1316 let result =
1317 icp_point_to_point(&source, &target, Isometry3::identity(), 100, 1e-6, None)
1318 .unwrap();
1319 let rotation_error =
1320 (result.transformation.rotation.inverse() * truth.rotation).angle();
1321 let translation_error =
1322 (result.transformation.translation.vector - truth.translation.vector).norm()
1323 / scale;
1324 (result.iterations, rotation_error, translation_error)
1325 };
1326
1327 let (iters_big, rot_big, trans_big) = solve(1.0);
1328 let (iters_small, rot_small, trans_small) = solve(0.01);
1329
1330 assert!(rot_big < 1e-3 && trans_big < 1e-3);
1331 assert!(
1332 rot_small < 1e-3 && trans_small < 1e-3,
1333 "small-scale ICP stopped early: rot {rot_small}, trans {trans_small}, {iters_small} iterations"
1334 );
1335 assert!(
1336 iters_small.abs_diff(iters_big) <= 2,
1337 "iterations: big {iters_big}, small {iters_small}"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_max_distance_cutoff_semantics() {
1343 let source = vec![Point3f::new(0.0, 0.0, 0.0)];
1344 let target = vec![Point3f::new(0.5, 0.0, 0.0)];
1345
1346 assert!(find_correspondences(&source, &target, Some(0.5))[0].is_some());
1348 assert!(find_correspondences(&source, &target, Some(0.49))[0].is_none());
1350 assert!(find_correspondences(&source, &target, Some(-0.5))[0].is_none());
1352 assert!(find_correspondences(&source, &target, None)[0].is_some());
1354
1355 assert!(find_correspondences(&source, &source, Some(0.0))[0].is_some());
1357 }
1358
1359 #[test]
1360 fn test_correspondence_finding() {
1361 let source = vec![
1362 Point3f::new(0.0, 0.0, 0.0),
1363 Point3f::new(1.0, 0.0, 0.0),
1364 Point3f::new(0.0, 1.0, 0.0),
1365 ];
1366
1367 let target = vec![
1368 Point3f::new(0.1, 0.1, 0.0),
1369 Point3f::new(1.1, 0.1, 0.0),
1370 Point3f::new(0.1, 1.1, 0.0),
1371 ];
1372
1373 let correspondences = find_correspondences(&source, &target, None);
1374
1375 assert_eq!(correspondences.len(), 3);
1376 assert!(correspondences[0].is_some());
1377 assert!(correspondences[1].is_some());
1378 assert!(correspondences[2].is_some());
1379 }
1380
1381 #[test]
1382 fn test_icp_point_to_point_basic() {
1383 let mut source = PointCloud::new();
1385 let mut target = PointCloud::new();
1386
1387 for x in 0..3 {
1389 for y in 0..3 {
1390 for z in 0..3 {
1391 let point = Point3f::new(x as f32, y as f32, z as f32);
1392 source.push(point);
1393 target.push(point + Vector3f::new(1.0, 0.5, 0.25)); }
1395 }
1396 }
1397
1398 let init = Isometry3::identity();
1399 let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1400
1401 assert!(result.converged || result.iterations == 50);
1403 assert!(result.mse < 2.0); let translation_mag = result.transformation.translation.vector.magnitude();
1406 assert!(
1407 translation_mag > 0.1,
1408 "Translation magnitude too small: {}",
1409 translation_mag
1410 );
1411 }
1412
1413 #[test]
1414 fn test_icp_point_to_point_with_noise() {
1415 let mut source = PointCloud::new();
1417 let mut target = PointCloud::new();
1418
1419 let translation = Vector3f::new(2.0, 1.0, 0.5);
1420 let rotation = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), 0.3);
1421 let transform = Isometry3::from_parts(
1422 Translation3::new(translation.x, translation.y, translation.z),
1423 rotation,
1424 );
1425
1426 for i in 0..100 {
1428 let angle = (i as f32) * 0.1;
1429 let radius = 2.0 + (i % 10) as f32 * 0.1;
1430 let source_point = Point3f::new(
1431 radius * angle.cos(),
1432 radius * angle.sin(),
1433 (i % 5) as f32 * 0.5,
1434 );
1435 source.push(source_point);
1436 }
1437
1438 for point in &source.points {
1440 let transformed = transform * point;
1441 let noise = Vector3f::new(
1443 (rand::random::<f32>() - 0.5) * 0.1,
1444 (rand::random::<f32>() - 0.5) * 0.1,
1445 (rand::random::<f32>() - 0.5) * 0.1,
1446 );
1447 target.push(transformed + noise);
1448 }
1449
1450 let init = Isometry3::identity();
1451 let result = icp_point_to_point(&source, &target, init, 100, 1e-5, None).unwrap();
1452
1453 assert!(result.mse < 0.5); assert!(result.transformation.translation.vector.magnitude() > 1.0);
1456 }
1457
1458 #[test]
1459 fn test_icp_point_to_point_known_transform() {
1460 let mut source = PointCloud::new();
1462 let mut target = PointCloud::new();
1463
1464 let known_translation = Vector3f::new(1.0, -0.5, 0.25);
1466 let known_rotation = UnitQuaternion::from_axis_angle(&Vector3f::z_axis(), 0.2);
1467 let known_transform = Isometry3::from_parts(
1468 Translation3::new(
1469 known_translation.x,
1470 known_translation.y,
1471 known_translation.z,
1472 ),
1473 known_rotation,
1474 );
1475
1476 for x in -2..=2 {
1478 for y in -2..=2 {
1479 for z in -1..=1 {
1480 let point = Point3f::new(x as f32, y as f32, z as f32);
1481 source.push(point);
1482 target.push(known_transform * point);
1483 }
1484 }
1485 }
1486
1487 let init = Isometry3::identity();
1488 let result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1489
1490 let computed_translation = result.transformation.translation.vector;
1492 let translation_error = (computed_translation - known_translation).magnitude();
1493 assert!(
1494 translation_error < 1.0,
1495 "Translation error too large: {}",
1496 translation_error
1497 );
1498
1499 assert!(result.mse < 0.5);
1500 }
1501
1502 #[test]
1503 fn test_icp_point_to_point_convergence() {
1504 let mut source = PointCloud::new();
1506 let mut target = PointCloud::new();
1507
1508 for i in 0..50 {
1510 let point = Point3f::new(i as f32 * 0.1, (i * 2) as f32 * 0.1, 0.0);
1511 source.push(point);
1512 target.push(point + Vector3f::new(0.5, 0.0, 0.0));
1513 }
1514
1515 let init = Isometry3::identity();
1516 let result = icp_point_to_point(&source, &target, init, 20, 1e-6, None).unwrap();
1517
1518 assert!(result.converged);
1520 assert!(result.iterations < 20);
1521 assert!(result.mse < 0.1);
1522 }
1523
1524 #[test]
1525 fn test_icp_point_to_point_max_distance() {
1526 let mut source = PointCloud::new();
1528 let mut target = PointCloud::new();
1529
1530 for i in 0..10 {
1532 source.push(Point3f::new(i as f32, 0.0, 0.0));
1533 }
1534
1535 for i in 0..10 {
1537 if i < 5 {
1538 target.push(Point3f::new(i as f32 + 0.1, 0.0, 0.0)); } else {
1540 target.push(Point3f::new(i as f32 + 10.0, 0.0, 0.0)); }
1542 }
1543
1544 let init = Isometry3::identity();
1545 let result = icp_point_to_point(&source, &target, init, 20, 1e-6, Some(1.0)).unwrap();
1546
1547 assert!(result.correspondences.len() <= 10);
1551 assert!(result.mse < 5.0); }
1553
1554 #[test]
1555 fn test_icp_point_to_point_default() {
1556 let mut source = PointCloud::new();
1558 let mut target = PointCloud::new();
1559
1560 for i in 0..10 {
1561 let point = Point3f::new(i as f32, i as f32, 0.0);
1562 source.push(point);
1563 target.push(point + Vector3f::new(1.0, 0.0, 0.0));
1564 }
1565
1566 let init = Isometry3::identity();
1567 let result = icp_point_to_point_default(&source, &target, init, 30).unwrap();
1568
1569 assert!(result.mse < 1.0);
1571 assert!(result.transformation.translation.vector.magnitude() > 0.5);
1572 }
1573
1574 #[test]
1575 fn test_icp_point_to_point_validation() {
1576 let empty_source = PointCloud::new();
1578 let mut target = PointCloud::new();
1579 target.push(Point3f::new(0.0, 0.0, 0.0));
1580
1581 let init = Isometry3::identity();
1582
1583 let result = icp_point_to_point(&empty_source, &target, init, 10, 1e-6, None);
1585 assert!(result.is_err());
1586
1587 let result = icp_point_to_point(&target, &target, init, 0, 1e-6, None);
1589 assert!(result.is_err());
1590
1591 let result = icp_point_to_point(&target, &target, init, 10, -1e-6, None);
1593 assert!(result.is_err());
1594 }
1595
1596 fn make_sphere_cloud(n: usize) -> (PointCloud<Point3f>, Vec<Vector3f>) {
1603 let mut cloud = PointCloud::new();
1604 let mut normals = Vec::new();
1605 let radius = 3.0_f32;
1606 let golden_angle = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
1607 for i in 0..n {
1608 let y = 1.0 - (i as f32 / (n as f32 - 1.0).max(1.0)) * 2.0;
1609 let r = (1.0 - y * y).max(0.0_f32).sqrt();
1610 let theta = golden_angle * i as f32;
1611 let x = theta.cos() * r;
1612 let z = theta.sin() * r;
1613 let normal = Vector3f::new(x, y, z);
1615 cloud.push(Point3f::new(x * radius, y * radius, z * radius));
1616 normals.push(normal);
1617 }
1618 (cloud, normals)
1619 }
1620
1621 #[test]
1622 fn test_icp_point_to_plane_identity() {
1623 let (source, normals) = make_sphere_cloud(50);
1624 let target = source.clone();
1625 let init = Isometry3::identity();
1626
1627 let result = icp_point_to_plane(&source, &target, &normals, init, 20).unwrap();
1628
1629 assert!(result.converged);
1630 assert!(result.mse < 1e-6, "mse={}", result.mse);
1631 }
1632
1633 #[test]
1634 fn test_icp_point_to_plane_translation() {
1635 let (source, normals) = make_sphere_cloud(100);
1637 let shift = Vector3f::new(0.15, 0.0, 0.0);
1638
1639 let mut target = PointCloud::new();
1640 for p in &source.points {
1641 target.push(p + shift);
1642 }
1643 let result =
1645 icp_point_to_plane(&source, &target, &normals, Isometry3::identity(), 50).unwrap();
1646
1647 let t_err = (result.transformation.translation.vector - shift).magnitude();
1648 assert!(t_err < 0.3, "translation error={}", t_err);
1649 assert!(result.mse < 0.1, "mse={}", result.mse);
1650 }
1651
1652 #[test]
1653 fn test_icp_point_to_plane_validation() {
1654 let (source, normals) = make_sphere_cloud(20);
1655 let init = Isometry3::identity();
1656
1657 let bad_normals = vec![Vector3f::new(0.0, 0.0, 1.0)];
1659 let result = icp_point_to_plane(&source, &source, &bad_normals, init, 10);
1660 assert!(result.is_err());
1661
1662 let empty: PointCloud<Point3f> = PointCloud::new();
1664 let result = icp_point_to_plane(&empty, &source, &normals, init, 10);
1665 assert!(result.is_err());
1666
1667 let result = icp_point_to_plane_detailed(&source, &source, &normals, init, 0, None, 1e-6);
1669 assert!(result.is_err());
1670 }
1671
1672 #[test]
1673 fn test_icp_point_to_plane_vs_point_to_point_convergence() {
1674 let (source, normals) = make_sphere_cloud(80);
1676 let shift = Vector3f::new(0.1, 0.05, 0.0);
1677 let mut target = PointCloud::new();
1678 for p in &source.points {
1679 target.push(p + shift);
1680 }
1681
1682 let init = Isometry3::identity();
1683
1684 let p2pl_result = icp_point_to_plane(&source, &target, &normals, init, 50).unwrap();
1685 let p2pt_result = icp_point_to_point(&source, &target, init, 50, 1e-6, None).unwrap();
1686
1687 assert!(
1689 p2pl_result.transformation.translation.vector.magnitude() > 0.05,
1690 "p2pl did not translate: t={}",
1691 p2pl_result.transformation.translation.vector.magnitude()
1692 );
1693 assert!(
1694 p2pt_result.transformation.translation.vector.magnitude() > 0.05,
1695 "p2pt did not translate: t={}",
1696 p2pt_result.transformation.translation.vector.magnitude()
1697 );
1698 assert!(
1700 p2pl_result.converged || p2pl_result.mse < 0.1,
1701 "p2pl failed to converge: mse={}, iters={}",
1702 p2pl_result.mse,
1703 p2pl_result.iterations
1704 );
1705 }
1706
1707 #[test]
1708 fn test_icp_point_to_plane_detailed_max_distance() {
1709 let (source, normals) = make_sphere_cloud(50);
1710 let mut target = PointCloud::new();
1711 for p in &source.points {
1712 target.push(p + Vector3f::new(0.1, 0.0, 0.0));
1713 }
1714
1715 let init = Isometry3::identity();
1716 let result =
1717 icp_point_to_plane_detailed(&source, &target, &normals, init, 30, Some(5.0), 1e-6);
1718 assert!(result.is_ok(), "unexpected error: {:?}", result.err());
1719 let result = result.unwrap();
1720 assert!(result.mse < 0.5, "mse={}", result.mse);
1721 }
1722}