This tutorial covers the Iterative Closest Point (ICP) algorithm for point cloud registration using the Point Cloud Library (PCL). ICP is fundamental to LiDAR odometry and 3D mapping.
Iterative Closest Point (ICP) is an algorithm for aligning two point clouds by iteratively minimizing the distance between corresponding points.
- LiDAR Odometry: Estimate sensor motion between scans
- 3D Reconstruction: Merge multiple scans into a map
- Object Recognition: Align model to observed points
- Loop Closure: Refine pose after detecting revisited area
Input: Source cloud P, Target cloud Q, Initial transform T0
1. Initialize T = T0
2. Repeat until convergence:
a. For each point p in P:
- Find closest point q in Q (correspondence)
b. Compute optimal T that minimizes:
E(T) = sum ||T(p) - q||^2
c. Apply T to P: P' = T(P)
3. Return final transformation T
Objective Function (Point-to-Point):
E(R, t) = sum_i ||R * p_i + t - q_i||^2
Closed-form Solution:
- Compute centroids:
p_bar = mean(p_i),q_bar = mean(q_i) - Build cross-covariance:
H = sum (p_i - p_bar)(q_i - q_bar)^T - SVD:
H = U * S * V^T - Rotation:
R = V * U^T - Translation:
t = q_bar - R * p_bar
2_29/
├── README.md
├── CMakeLists.txt
├── Dockerfile
├── data/
│ └── sample_clouds/
└── examples/
├── icp_basic.cpp # Point-to-point ICP
├── icp_point_to_plane.cpp # Point-to-plane ICP
├── icp_visualization.cpp # Visualize alignment
└── lidar_odometry.cpp # Sequential scan registration
- PCL 1.12+
- Eigen3
mkdir build && cd build
cmake ..
make -j4docker build . -t slam_zero_to_hero:2_29# Basic ICP
./build/icp_basic source.pcd target.pcd
# Point-to-plane ICP
./build/icp_point_to_plane source.pcd target.pcd
# Visualization
./build/icp_visualization source.pcd target.pcd
# LiDAR odometry on sequence
./build/lidar_odometry /path/to/kitti/velodyne/Standard formulation minimizing point distances:
E = sum ||R*p_i + t - q_i||^2
Pros: Simple, fast Cons: Slow convergence, local minima
Minimizes distance to tangent plane:
E = sum ((R*p_i + t - q_i) · n_i)^2
Where n_i is the normal at q_i.
Pros: Faster convergence, better for planar surfaces Cons: Requires normals, more computation per iteration
| Aspect | Point-to-Point | Point-to-Plane |
|---|---|---|
| Convergence | Slower | 10x faster |
| Per iteration | Faster | Slower |
| Requirements | Points only | Points + normals |
| Accuracy | Good | Better |
#include <pcl/registration/icp.h>
#include <pcl/io/pcd_io.h>
// Load point clouds
pcl::PointCloud<pcl::PointXYZ>::Ptr source(
new pcl::PointCloud<pcl::PointXYZ>);
pcl::PointCloud<pcl::PointXYZ>::Ptr target(
new pcl::PointCloud<pcl::PointXYZ>);
pcl::io::loadPCDFile("source.pcd", *source);
pcl::io::loadPCDFile("target.pcd", *target);
// Setup ICP
pcl::IterativeClosestPoint<pcl::PointXYZ, pcl::PointXYZ> icp;
icp.setInputSource(source);
icp.setInputTarget(target);
// Set parameters
icp.setMaximumIterations(50);
icp.setTransformationEpsilon(1e-8);
icp.setEuclideanFitnessEpsilon(1e-6);
icp.setMaxCorrespondenceDistance(0.5);
// Align
pcl::PointCloud<pcl::PointXYZ>::Ptr aligned(
new pcl::PointCloud<pcl::PointXYZ>);
icp.align(*aligned);
// Get results
if (icp.hasConverged()) {
std::cout << "ICP converged!" << std::endl;
std::cout << "Fitness score: " << icp.getFitnessScore() << std::endl;
std::cout << "Transformation:\n" << icp.getFinalTransformation() << std::endl;
} else {
std::cout << "ICP did not converge!" << std::endl;
}#include <pcl/registration/icp.h>
#include <pcl/features/normal_3d_omp.h>
// Compute normals for target cloud
pcl::PointCloud<pcl::PointNormal>::Ptr target_with_normals(
new pcl::PointCloud<pcl::PointNormal>);
pcl::NormalEstimationOMP<pcl::PointXYZ, pcl::Normal> ne;
ne.setInputCloud(target);
ne.setRadiusSearch(0.1);
pcl::PointCloud<pcl::Normal>::Ptr normals(
new pcl::PointCloud<pcl::Normal>);
ne.compute(*normals);
// Concatenate points and normals
pcl::concatenateFields(*target, *normals, *target_with_normals);
// Also for source
pcl::PointCloud<pcl::PointNormal>::Ptr source_with_normals(
new pcl::PointCloud<pcl::PointNormal>);
ne.setInputCloud(source);
ne.compute(*normals);
pcl::concatenateFields(*source, *normals, *source_with_normals);
// Point-to-plane ICP
pcl::IterativeClosestPointWithNormals<pcl::PointNormal, pcl::PointNormal> icp;
icp.setInputSource(source_with_normals);
icp.setInputTarget(target_with_normals);
icp.setMaximumIterations(30);
icp.setTransformationEpsilon(1e-8);
pcl::PointCloud<pcl::PointNormal>::Ptr aligned(
new pcl::PointCloud<pcl::PointNormal>);
icp.align(*aligned);// Provide initial transformation estimate
Eigen::Matrix4f initial_guess = Eigen::Matrix4f::Identity();
// From IMU or previous odometry
initial_guess(0, 3) = 0.1; // x translation
initial_guess(1, 3) = 0.0; // y translation
initial_guess(2, 3) = 0.0; // z translation
// Align with initial guess
icp.align(*aligned, initial_guess);class LidarOdometry {
public:
LidarOdometry() {
icp_.setMaximumIterations(30);
icp_.setTransformationEpsilon(1e-6);
icp_.setMaxCorrespondenceDistance(1.0);
global_pose_ = Eigen::Matrix4f::Identity();
}
void processCloud(pcl::PointCloud<pcl::PointXYZ>::Ptr cloud) {
// Downsample
pcl::PointCloud<pcl::PointXYZ>::Ptr filtered(
new pcl::PointCloud<pcl::PointXYZ>);
voxel_.setInputCloud(cloud);
voxel_.setLeafSize(0.1f, 0.1f, 0.1f);
voxel_.filter(*filtered);
if (prev_cloud_ == nullptr) {
prev_cloud_ = filtered;
return;
}
// ICP alignment
icp_.setInputSource(filtered);
icp_.setInputTarget(prev_cloud_);
pcl::PointCloud<pcl::PointXYZ>::Ptr aligned(
new pcl::PointCloud<pcl::PointXYZ>);
icp_.align(*aligned);
if (icp_.hasConverged() && icp_.getFitnessScore() < 0.5) {
// Get relative transformation
Eigen::Matrix4f delta = icp_.getFinalTransformation();
// Update global pose
global_pose_ = global_pose_ * delta;
std::cout << "Position: "
<< global_pose_(0, 3) << ", "
<< global_pose_(1, 3) << ", "
<< global_pose_(2, 3) << std::endl;
}
prev_cloud_ = filtered;
}
Eigen::Matrix4f getPose() const { return global_pose_; }
private:
pcl::IterativeClosestPoint<pcl::PointXYZ, pcl::PointXYZ> icp_;
pcl::VoxelGrid<pcl::PointXYZ> voxel_;
pcl::PointCloud<pcl::PointXYZ>::Ptr prev_cloud_;
Eigen::Matrix4f global_pose_;
};#include <pcl/visualization/pcl_visualizer.h>
void visualizeICP(
pcl::PointCloud<pcl::PointXYZ>::Ptr source,
pcl::PointCloud<pcl::PointXYZ>::Ptr target,
pcl::PointCloud<pcl::PointXYZ>::Ptr aligned) {
pcl::visualization::PCLVisualizer viewer("ICP Visualization");
viewer.setBackgroundColor(0, 0, 0);
// Source (green)
pcl::visualization::PointCloudColorHandlerCustom<pcl::PointXYZ>
source_color(source, 0, 255, 0);
viewer.addPointCloud<pcl::PointXYZ>(source, source_color, "source");
// Target (blue)
pcl::visualization::PointCloudColorHandlerCustom<pcl::PointXYZ>
target_color(target, 0, 0, 255);
viewer.addPointCloud<pcl::PointXYZ>(target, target_color, "target");
// Aligned (red)
pcl::visualization::PointCloudColorHandlerCustom<pcl::PointXYZ>
aligned_color(aligned, 255, 0, 0);
viewer.addPointCloud<pcl::PointXYZ>(aligned, aligned_color, "aligned");
viewer.addCoordinateSystem(1.0);
viewer.spin();
}| Parameter | Description | Typical Value |
|---|---|---|
MaximumIterations |
Max optimization iterations | 30-100 |
TransformationEpsilon |
Convergence threshold (transform change) | 1e-6 to 1e-8 |
EuclideanFitnessEpsilon |
Convergence threshold (MSE change) | 1e-6 |
MaxCorrespondenceDistance |
Max distance for correspondences | 0.5-2.0m |
-
MaxCorrespondenceDistance:
- Too small: Misses correct correspondences
- Too large: Includes wrong correspondences
- Start large, decrease during iterations
-
Initial Guess:
- Critical for convergence
- Use IMU, wheel odometry, or constant velocity model
-
Preprocessing:
- Downsample to reduce computation
- Remove outliers for robustness
// Check convergence
if (icp.hasConverged()) {
double fitness = icp.getFitnessScore();
if (fitness < 0.1) {
// Good alignment
} else if (fitness < 0.5) {
// Acceptable
} else {
// Poor alignment - may need different initial guess
}
}| Issue | Description | Solution |
|---|---|---|
| Local minima | Converges to wrong solution | Good initial guess |
| Outliers | Wrong correspondences | Robust variants (GICP) |
| Partial overlap | Only part of clouds match | Robust cost functions |
| Degenerate geometry | Featureless environments | Multi-sensor fusion |
| Computational cost | O(n*m) per iteration | KD-tree, downsampling |
- Good Initial Guess: Use IMU or motion model
- Multi-resolution: Coarse-to-fine approach
- Outlier Rejection: Use robust estimators
- Correspondence Rejection: Remove poor matches
- Preprocessing: Downsample, remove noise
// Multi-resolution ICP
void multiResolutionICP(
pcl::PointCloud<pcl::PointXYZ>::Ptr source,
pcl::PointCloud<pcl::PointXYZ>::Ptr target,
Eigen::Matrix4f& transform) {
std::vector<float> resolutions = {1.0f, 0.5f, 0.1f};
transform = Eigen::Matrix4f::Identity();
for (float res : resolutions) {
// Downsample
pcl::PointCloud<pcl::PointXYZ>::Ptr src_down(
new pcl::PointCloud<pcl::PointXYZ>);
pcl::PointCloud<pcl::PointXYZ>::Ptr tgt_down(
new pcl::PointCloud<pcl::PointXYZ>);
pcl::VoxelGrid<pcl::PointXYZ> voxel;
voxel.setLeafSize(res, res, res);
voxel.setInputCloud(source);
voxel.filter(*src_down);
voxel.setInputCloud(target);
voxel.filter(*tgt_down);
// ICP at this resolution
pcl::IterativeClosestPoint<pcl::PointXYZ, pcl::PointXYZ> icp;
icp.setInputSource(src_down);
icp.setInputTarget(tgt_down);
icp.setMaximumIterations(20);
icp.setMaxCorrespondenceDistance(res * 5);
pcl::PointCloud<pcl::PointXYZ>::Ptr aligned(
new pcl::PointCloud<pcl::PointXYZ>);
icp.align(*aligned, transform);
if (icp.hasConverged()) {
transform = icp.getFinalTransformation();
}
}
}- Besl & McKay, "A Method for Registration of 3-D Shapes", 1992
- Chen & Medioni, "Object Modelling by Registration of Multiple Range Images", 1992
- PCL ICP Tutorial
- Pomerleau et al., "A Review of Point Cloud Registration Algorithms for Mobile Robotics"