Skip to content

Point Cloud Registration with Open3D: A Complete Guide

Published: at 10:00 AMSuggest Changes

Point cloud registration is the process of aligning two or more point clouds into a common coordinate system. This is a fundamental task in 3D computer vision with applications ranging from 3D reconstruction to autonomous navigation. In this guide, we explore how to perform point cloud registration using the Open3D library in Python.

Table of Contents

Open Table of Contents

What is Point Cloud Registration?

Point cloud registration involves finding the optimal transformation (rotation and translation) that aligns a source point cloud to a target point cloud. This process is essential in many applications:

The registration problem can be formulated as finding a transformation matrix T that minimizes the distance between corresponding points in the source and target point clouds.

Types of Registration

Registration methods are typically divided into two categories:

Local Registration

Local registration methods require a good initial alignment between the point clouds. They refine this initial guess to achieve precise alignment. The most common algorithm is ICP (Iterative Closest Point).

Characteristics:

Global Registration

Global registration methods do not require an initial alignment. They can align point clouds from arbitrary initial positions but typically produce less precise results.

Characteristics:

Typical Pipeline: Global Registration → Local Refinement (ICP)

Prerequisites and Installation

Installing Open3D

pip install open3d

Required Imports

import open3d as o3d
import numpy as np
import copy

Loading and Preparing Point Clouds

Before registration, we need to load and preprocess our point clouds:

import open3d as o3d
import numpy as np
import copy

# Load point clouds
source = o3d.io.read_point_cloud("source.ply")
target = o3d.io.read_point_cloud("target.ply")

print(f"Source points: {len(source.points)}")
print(f"Target points: {len(target.points)}")

# Visualize the initial state
def draw_registration_result(source, target, transformation):
    """Visualize registration result with colored point clouds."""
    source_temp = copy.deepcopy(source)
    target_temp = copy.deepcopy(target)
    source_temp.paint_uniform_color([1, 0.706, 0])      # Orange
    target_temp.paint_uniform_color([0, 0.651, 0.929])  # Blue
    source_temp.transform(transformation)
    o3d.visualization.draw_geometries([source_temp, target_temp])

# Initial visualization (identity transform)
draw_registration_result(source, target, np.identity(4))

Local Registration with ICP

The Iterative Closest Point (ICP) algorithm is the most widely used method for local registration. Open3D provides several ICP variants.

Evaluation Metrics

Before running ICP, let’s understand how to evaluate registration quality:

# Define the maximum correspondence distance
threshold = 0.02

# Initial transformation (rough alignment)
trans_init = np.asarray([[0.862, 0.011, -0.507, 0.5],
                         [-0.139, 0.967, -0.215, 0.7],
                         [0.487, 0.255, 0.835, -1.4],
                         [0.0, 0.0, 0.0, 1.0]])

# Evaluate the initial alignment
evaluation = o3d.pipelines.registration.evaluate_registration(
    source, target, threshold, trans_init)

print(f"Fitness: {evaluation.fitness:.4f}")
print(f"Inlier RMSE: {evaluation.inlier_rmse:.4f}")

Metrics explained:

Point-to-Point ICP

The simplest ICP variant minimizes the distance between corresponding points:

# Point-to-Point ICP
reg_p2p = o3d.pipelines.registration.registration_icp(
    source, target, threshold, trans_init,
    o3d.pipelines.registration.TransformationEstimationPointToPoint())

print("Point-to-Point ICP Result:")
print(f"Fitness: {reg_p2p.fitness:.4f}")
print(f"Inlier RMSE: {reg_p2p.inlier_rmse:.4f}")
print("Transformation matrix:")
print(reg_p2p.transformation)

# Visualize result
draw_registration_result(source, target, reg_p2p.transformation)

Point-to-Plane ICP

Point-to-Plane ICP minimizes the distance from points to the tangent planes at corresponding points. It converges faster than Point-to-Point ICP but requires normal vectors:

# Estimate normals if not present
source.estimate_normals(
    o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
target.estimate_normals(
    o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))

# Point-to-Plane ICP
reg_p2l = o3d.pipelines.registration.registration_icp(
    source, target, threshold, trans_init,
    o3d.pipelines.registration.TransformationEstimationPointToPlane())

print("Point-to-Plane ICP Result:")
print(f"Fitness: {reg_p2l.fitness:.4f}")
print(f"Inlier RMSE: {reg_p2l.inlier_rmse:.4f}")

draw_registration_result(source, target, reg_p2l.transformation)

ICP with Custom Convergence Criteria

You can control the number of iterations and convergence thresholds:

# Custom convergence criteria
reg_p2l = o3d.pipelines.registration.registration_icp(
    source, target, threshold, trans_init,
    o3d.pipelines.registration.TransformationEstimationPointToPlane(),
    o3d.pipelines.registration.ICPConvergenceCriteria(
        max_iteration=2000,
        relative_fitness=1e-6,
        relative_rmse=1e-6))

print(f"Final Fitness: {reg_p2l.fitness:.4f}")
print(f"Final RMSE: {reg_p2l.inlier_rmse:.6f}")

Colored ICP

When point clouds have color information, Colored ICP can achieve more accurate alignment by using both geometry and photometric information:

# Colored ICP (requires point clouds with color)
voxel_radius = [0.04, 0.02, 0.01]
max_iter = [50, 30, 14]
current_transformation = trans_init

# Multi-scale colored ICP
for scale in range(3):
    iter_count = max_iter[scale]
    radius = voxel_radius[scale]

    # Downsample point clouds
    source_down = source.voxel_down_sample(radius)
    target_down = target.voxel_down_sample(radius)

    # Estimate normals
    source_down.estimate_normals(
        o3d.geometry.KDTreeSearchParamHybrid(radius=radius * 2, max_nn=30))
    target_down.estimate_normals(
        o3d.geometry.KDTreeSearchParamHybrid(radius=radius * 2, max_nn=30))

    # Apply colored ICP
    result = o3d.pipelines.registration.registration_colored_icp(
        source_down, target_down, radius, current_transformation,
        o3d.pipelines.registration.TransformationEstimationForColoredICP(),
        o3d.pipelines.registration.ICPConvergenceCriteria(
            relative_fitness=1e-6,
            relative_rmse=1e-6,
            max_iteration=iter_count))

    current_transformation = result.transformation
    print(f"Scale {scale}: Fitness={result.fitness:.4f}, RMSE={result.inlier_rmse:.6f}")

print("\nFinal Colored ICP Transformation:")
print(current_transformation)

Global Registration

Global registration methods find alignment without requiring an initial transformation. They are essential when the initial pose is unknown.

FPFH Feature Extraction

Fast Point Feature Histograms (FPFH) are 33-dimensional descriptors that capture local geometric properties. They enable correspondence matching between point clouds:

def preprocess_point_cloud(pcd, voxel_size):
    """Downsample, estimate normals, and compute FPFH features."""

    # Downsample
    print(f":: Downsample with voxel size {voxel_size:.3f}")
    pcd_down = pcd.voxel_down_sample(voxel_size)

    # Estimate normals
    radius_normal = voxel_size * 2
    print(f":: Estimate normals with search radius {radius_normal:.3f}")
    pcd_down.estimate_normals(
        o3d.geometry.KDTreeSearchParamHybrid(radius=radius_normal, max_nn=30))

    # Compute FPFH features
    radius_feature = voxel_size * 5
    print(f":: Compute FPFH features with search radius {radius_feature:.3f}")
    pcd_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
        pcd_down,
        o3d.geometry.KDTreeSearchParamHybrid(radius=radius_feature, max_nn=100))

    return pcd_down, pcd_fpfh

Preparing the Dataset

def prepare_dataset(source, target, voxel_size):
    """Prepare source and target point clouds for global registration."""

    source_down, source_fpfh = preprocess_point_cloud(source, voxel_size)
    target_down, target_fpfh = preprocess_point_cloud(target, voxel_size)

    return source_down, target_down, source_fpfh, target_fpfh

# Prepare dataset
voxel_size = 0.05  # Adjust based on your data scale
source_down, target_down, source_fpfh, target_fpfh = prepare_dataset(
    source, target, voxel_size)

print(f"Source downsampled to {len(source_down.points)} points")
print(f"Target downsampled to {len(target_down.points)} points")
print(f"FPFH feature dimension: {source_fpfh.dimension()}")

RANSAC-Based Global Registration

RANSAC iteratively samples point correspondences based on FPFH feature matching and validates transformations:

def execute_global_registration(source_down, target_down,
                                source_fpfh, target_fpfh, voxel_size):
    """Execute RANSAC-based global registration."""

    distance_threshold = voxel_size * 1.5
    print(f":: RANSAC registration with distance threshold {distance_threshold:.3f}")

    result = o3d.pipelines.registration.registration_ransac_based_on_feature_matching(
        source_down, target_down, source_fpfh, target_fpfh,
        mutual_filter=True,
        max_correspondence_distance=distance_threshold,
        estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPoint(False),
        ransac_n=3,
        checkers=[
            o3d.pipelines.registration.CorrespondenceCheckerBasedOnEdgeLength(0.9),
            o3d.pipelines.registration.CorrespondenceCheckerBasedOnDistance(distance_threshold)
        ],
        criteria=o3d.pipelines.registration.RANSACConvergenceCriteria(100000, 0.999))

    return result

# Execute global registration
result_ransac = execute_global_registration(
    source_down, target_down, source_fpfh, target_fpfh, voxel_size)

print("Global Registration Result:")
print(f"Fitness: {result_ransac.fitness:.4f}")
print(f"Inlier RMSE: {result_ransac.inlier_rmse:.4f}")
print(f"Correspondences: {len(result_ransac.correspondence_set)}")

draw_registration_result(source, target, result_ransac.transformation)

Fast Global Registration (FGR)

FGR is a faster alternative to RANSAC that uses optimization instead of exhaustive sampling:

def execute_fast_global_registration(source_down, target_down,
                                     source_fpfh, target_fpfh, voxel_size):
    """Execute Fast Global Registration (FGR)."""

    distance_threshold = voxel_size * 0.5
    print(f":: Fast global registration with distance threshold {distance_threshold:.3f}")

    result = o3d.pipelines.registration.registration_fgr_based_on_feature_matching(
        source_down, target_down, source_fpfh, target_fpfh,
        o3d.pipelines.registration.FastGlobalRegistrationOption(
            maximum_correspondence_distance=distance_threshold))

    return result

# Execute FGR
result_fgr = execute_fast_global_registration(
    source_down, target_down, source_fpfh, target_fpfh, voxel_size)

print("Fast Global Registration Result:")
print(f"Fitness: {result_fgr.fitness:.4f}")
print(f"Inlier RMSE: {result_fgr.inlier_rmse:.4f}")

draw_registration_result(source, target, result_fgr.transformation)

Complete Registration Pipeline

Here’s a complete pipeline that combines global and local registration for robust alignment:

import open3d as o3d
import numpy as np
import copy

def draw_registration_result(source, target, transformation):
    """Visualize registration result."""
    source_temp = copy.deepcopy(source)
    target_temp = copy.deepcopy(target)
    source_temp.paint_uniform_color([1, 0.706, 0])
    target_temp.paint_uniform_color([0, 0.651, 0.929])
    source_temp.transform(transformation)
    o3d.visualization.draw_geometries([source_temp, target_temp])

def preprocess_point_cloud(pcd, voxel_size):
    """Downsample and compute FPFH features."""
    pcd_down = pcd.voxel_down_sample(voxel_size)

    radius_normal = voxel_size * 2
    pcd_down.estimate_normals(
        o3d.geometry.KDTreeSearchParamHybrid(radius=radius_normal, max_nn=30))

    radius_feature = voxel_size * 5
    pcd_fpfh = o3d.pipelines.registration.compute_fpfh_feature(
        pcd_down,
        o3d.geometry.KDTreeSearchParamHybrid(radius=radius_feature, max_nn=100))

    return pcd_down, pcd_fpfh

def global_registration(source_down, target_down, source_fpfh, target_fpfh, voxel_size):
    """Perform global registration using RANSAC."""
    distance_threshold = voxel_size * 1.5

    result = o3d.pipelines.registration.registration_ransac_based_on_feature_matching(
        source_down, target_down, source_fpfh, target_fpfh,
        mutual_filter=True,
        max_correspondence_distance=distance_threshold,
        estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPoint(False),
        ransac_n=3,
        checkers=[
            o3d.pipelines.registration.CorrespondenceCheckerBasedOnEdgeLength(0.9),
            o3d.pipelines.registration.CorrespondenceCheckerBasedOnDistance(distance_threshold)
        ],
        criteria=o3d.pipelines.registration.RANSACConvergenceCriteria(100000, 0.999))

    return result

def local_refinement(source, target, initial_transformation, voxel_size):
    """Refine registration using Point-to-Plane ICP."""
    distance_threshold = voxel_size * 0.4

    # Ensure normals are computed on original point clouds
    source.estimate_normals(
        o3d.geometry.KDTreeSearchParamHybrid(radius=voxel_size * 2, max_nn=30))
    target.estimate_normals(
        o3d.geometry.KDTreeSearchParamHybrid(radius=voxel_size * 2, max_nn=30))

    result = o3d.pipelines.registration.registration_icp(
        source, target, distance_threshold, initial_transformation,
        o3d.pipelines.registration.TransformationEstimationPointToPlane(),
        o3d.pipelines.registration.ICPConvergenceCriteria(max_iteration=200))

    return result

def register_point_clouds(source_path, target_path, voxel_size=0.05):
    """
    Complete registration pipeline.

    Args:
        source_path: Path to source point cloud file
        target_path: Path to target point cloud file
        voxel_size: Voxel size for downsampling (adjust based on data scale)

    Returns:
        transformation: 4x4 transformation matrix
        metrics: Dictionary with fitness and RMSE values
    """
    # Load point clouds
    print("Loading point clouds...")
    source = o3d.io.read_point_cloud(source_path)
    target = o3d.io.read_point_cloud(target_path)

    print(f"Source: {len(source.points)} points")
    print(f"Target: {len(target.points)} points")

    # Step 1: Preprocess
    print("\n--- Preprocessing ---")
    source_down, source_fpfh = preprocess_point_cloud(source, voxel_size)
    target_down, target_fpfh = preprocess_point_cloud(target, voxel_size)

    # Step 2: Global Registration
    print("\n--- Global Registration (RANSAC) ---")
    result_global = global_registration(
        source_down, target_down, source_fpfh, target_fpfh, voxel_size)
    print(f"Global registration fitness: {result_global.fitness:.4f}")
    print(f"Global registration RMSE: {result_global.inlier_rmse:.4f}")

    # Step 3: Local Refinement
    print("\n--- Local Refinement (ICP) ---")
    result_icp = local_refinement(
        source, target, result_global.transformation, voxel_size)
    print(f"ICP refinement fitness: {result_icp.fitness:.4f}")
    print(f"ICP refinement RMSE: {result_icp.inlier_rmse:.6f}")

    # Final result
    print("\n--- Final Transformation Matrix ---")
    print(result_icp.transformation)

    # Visualize
    draw_registration_result(source, target, result_icp.transformation)

    return result_icp.transformation, {
        "fitness": result_icp.fitness,
        "rmse": result_icp.inlier_rmse
    }

# Example usage
if __name__ == "__main__":
    transformation, metrics = register_point_clouds(
        "source.ply",
        "target.ply",
        voxel_size=0.05
    )

    print(f"\nFinal metrics: Fitness={metrics['fitness']:.4f}, RMSE={metrics['rmse']:.6f}")

Choosing the Right Parameters

Voxel Size

The voxel size is crucial and should be chosen based on your data:

Point Cloud ScaleVoxel SizeExample Use Case
Millimeters0.001 - 0.01Small object scanning
Centimeters0.01 - 0.05Indoor scenes
Meters0.05 - 0.5Outdoor LiDAR
Large scale0.5 - 2.0City-scale mapping

Distance Threshold

RANSAC Parameters

Common Issues and Solutions

Poor Registration Results

  1. Check normal estimation: Ensure normals are correctly oriented
  2. Adjust voxel size: Too large may lose detail, too small may be noisy
  3. Increase RANSAC iterations: For challenging cases

Slow Performance

  1. Increase voxel size: Fewer points = faster processing
  2. Use FGR instead of RANSAC: Generally faster
  3. Reduce max_iteration: Trade accuracy for speed

Local Minima

  1. Use global registration first: Provides better initialization
  2. Try multiple initializations: Run ICP from different starting points
  3. Use Colored ICP: Additional photometric constraints help

Conclusion

Point cloud registration is a fundamental operation in 3D computer vision. Open3D provides a comprehensive set of tools for both global and local registration:

The typical workflow combines global registration for initial alignment followed by ICP refinement for precise results. By understanding the strengths and parameters of each method, you can build robust registration pipelines for various applications from 3D reconstruction to autonomous navigation.

For more information, refer to the Open3D Registration Documentation.


Next Post
Introduction to Plotly: Interactive Data Visualization in Python