How to use Normal Distributions Transform

In this tutorial we will describe how to use the Normal Distributions Transform (NDT) algorithm to determine a rigid transformation between two large point clouds, both over 100,000 points. The NDT algorithm is a registration algorithm that uses standard optimization techniques applied to statistical models of 3D points to determine the most probable registration between two point clouds. For more information on the inner workings of the NDT algorithm, see Dr. Martin Magnusson’s doctoral thesis, “The Three-Dimensional Normal Distributions Transform – an Efficient Representation for Registration, Surface Analysis, and Loop Detection.”

The code

First, download the datasets room_scan1.pcd and room_scan2.pcd and save them to your disk. These point clouds contain 360 degree scans of the same room from different perspectives.

Then, create a file in your favorite editor and place the following inside. I used normal_distributions_transform.cpp for this tutorial.

  1#include <iostream>
  2#include <thread>
  3
  4#include <pcl/io/pcd_io.h>
  5#include <pcl/point_types.h>
  6
  7#include <pcl/registration/ndt.h>
  8#include <pcl/filters/approximate_voxel_grid.h>
  9
 10#include <pcl/visualization/pcl_visualizer.h>
 11
 12using namespace std::chrono_literals;
 13
 14int
 15main ()
 16{
 17  // Loading first scan of room.
 18  pcl::PointCloud<pcl::PointXYZ>::Ptr target_cloud (new pcl::PointCloud<pcl::PointXYZ>);
 19  if (pcl::io::loadPCDFile<pcl::PointXYZ> ("room_scan1.pcd", *target_cloud) == -1)
 20  {
 21    PCL_ERROR ("Couldn't read file room_scan1.pcd \n");
 22    return (-1);
 23  }
 24  std::cout << "Loaded " << target_cloud->size () << " data points from room_scan1.pcd" << std::endl;
 25
 26  // Loading second scan of room from new perspective.
 27  pcl::PointCloud<pcl::PointXYZ>::Ptr input_cloud (new pcl::PointCloud<pcl::PointXYZ>);
 28  if (pcl::io::loadPCDFile<pcl::PointXYZ> ("room_scan2.pcd", *input_cloud) == -1)
 29  {
 30    PCL_ERROR ("Couldn't read file room_scan2.pcd \n");
 31    return (-1);
 32  }
 33  std::cout << "Loaded " << input_cloud->size () << " data points from room_scan2.pcd" << std::endl;
 34
 35  // Filtering input scan to roughly 10% of original size to increase speed of registration.
 36  pcl::PointCloud<pcl::PointXYZ>::Ptr filtered_cloud (new pcl::PointCloud<pcl::PointXYZ>);
 37  pcl::ApproximateVoxelGrid<pcl::PointXYZ> approximate_voxel_filter;
 38  approximate_voxel_filter.setLeafSize (0.2, 0.2, 0.2);
 39  approximate_voxel_filter.setInputCloud (input_cloud);
 40  approximate_voxel_filter.filter (*filtered_cloud);
 41  std::cout << "Filtered cloud contains " << filtered_cloud->size ()
 42            << " data points from room_scan2.pcd" << std::endl;
 43
 44  // Initializing Normal Distributions Transform (NDT).
 45  pcl::NormalDistributionsTransform<pcl::PointXYZ, pcl::PointXYZ> ndt;
 46
 47  // Setting scale dependent NDT parameters
 48  // Setting minimum transformation difference for termination condition.
 49  ndt.setTransformationEpsilon (0.01);
 50  // Setting maximum step size for More-Thuente line search.
 51  ndt.setStepSize (0.1);
 52  //Setting Resolution of NDT grid structure (VoxelGridCovariance).
 53  ndt.setResolution (1.0);
 54
 55  // Setting max number of registration iterations.
 56  ndt.setMaximumIterations (35);
 57
 58  // Setting point cloud to be aligned.
 59  ndt.setInputSource (filtered_cloud);
 60  // Setting point cloud to be aligned to.
 61  ndt.setInputTarget (target_cloud);
 62
 63  // Set initial alignment estimate found using robot odometry.
 64  Eigen::AngleAxisf init_rotation (0.6931, Eigen::Vector3f::UnitZ ());
 65  Eigen::Translation3f init_translation (1.79387, 0.720047, 0);
 66  Eigen::Matrix4f init_guess = (init_translation * init_rotation).matrix ();
 67
 68  // Calculating required rigid transform to align the input cloud to the target cloud.
 69  pcl::PointCloud<pcl::PointXYZ>::Ptr output_cloud (new pcl::PointCloud<pcl::PointXYZ>);
 70  ndt.align (*output_cloud, init_guess);
 71
 72  std::cout << "Normal Distributions Transform has " << (ndt.hasConverged ()?"converged":"not converged")
 73            << ", score: " << ndt.getFitnessScore () << std::endl;
 74
 75  // Transforming unfiltered, input cloud using found transform.
 76  pcl::transformPointCloud (*input_cloud, *output_cloud, ndt.getFinalTransformation ());
 77
 78  // Saving transformed input cloud.
 79  pcl::io::savePCDFileASCII ("room_scan2_transformed.pcd", *output_cloud);
 80
 81  // Initializing point cloud visualizer
 82  pcl::visualization::PCLVisualizer::Ptr
 83  viewer_final (new pcl::visualization::PCLVisualizer ("3D Viewer"));
 84  viewer_final->setBackgroundColor (0, 0, 0);
 85
 86  // Coloring and visualizing target cloud (red).
 87  pcl::visualization::PointCloudColorHandlerCustom<pcl::PointXYZ>
 88  target_color (target_cloud, 255, 0, 0);
 89  viewer_final->addPointCloud<pcl::PointXYZ> (target_cloud, target_color, "target cloud");
 90  viewer_final->setPointCloudRenderingProperties (pcl::visualization::PCL_VISUALIZER_POINT_SIZE,
 91                                                  1, "target cloud");
 92
 93  // Coloring and visualizing transformed input cloud (green).
 94  pcl::visualization::PointCloudColorHandlerCustom<pcl::PointXYZ>
 95  output_color (output_cloud, 0, 255, 0);
 96  viewer_final->addPointCloud<pcl::PointXYZ> (output_cloud, output_color, "output cloud");
 97  viewer_final->setPointCloudRenderingProperties (pcl::visualization::PCL_VISUALIZER_POINT_SIZE,
 98                                                  1, "output cloud");
 99
100  // Starting visualizer
101  viewer_final->addCoordinateSystem (1.0, "global");
102  viewer_final->initCameraParameters ();
103
104  // Wait until visualizer window is closed.
105  while (!viewer_final->wasStopped ())
106  {
107    viewer_final->spinOnce (100);
108    std::this_thread::sleep_for(100ms);
109  }
110
111  return (0);
112}

The explanation

Now, let’s breakdown this code piece by piece.

#include <pcl/registration/ndt.h>
#include <pcl/filters/approximate_voxel_grid.h>

These are the required header files to use Normal Distributions Transform algorithm and a filter used to down sample the data. The filter can be exchanged for other filters but I have found the approximate voxel filter to produce the best results.

  // Loading first scan of room.
  pcl::PointCloud<pcl::PointXYZ>::Ptr target_cloud (new pcl::PointCloud<pcl::PointXYZ>);
  if (pcl::io::loadPCDFile<pcl::PointXYZ> ("room_scan1.pcd", *target_cloud) == -1)
  {
    PCL_ERROR ("Couldn't read file room_scan1.pcd \n");
    return (-1);
  }
  std::cout << "Loaded " << target_cloud->size () << " data points from room_scan1.pcd" << std::endl;

  // Loading second scan of room from new perspective.
  pcl::PointCloud<pcl::PointXYZ>::Ptr input_cloud (new pcl::PointCloud<pcl::PointXYZ>);
  if (pcl::io::loadPCDFile<pcl::PointXYZ> ("room_scan2.pcd", *input_cloud) == -1)
  {
    PCL_ERROR ("Couldn't read file room_scan2.pcd \n");
    return (-1);
  }
  std::cout << "Loaded " << input_cloud->size () << " data points from room_scan2.pcd" << std::endl;

The above code loads the two pcd file into pcl::PointCloud<pcl::PointXYZ> boost shared pointers. The input cloud will be transformed into the reference frame of the target cloud.

  // Filtering input scan to roughly 10% of original size to increase speed of registration.
  pcl::PointCloud<pcl::PointXYZ>::Ptr filtered_cloud (new pcl::PointCloud<pcl::PointXYZ>);
  pcl::ApproximateVoxelGrid<pcl::PointXYZ> approximate_voxel_filter;
  approximate_voxel_filter.setLeafSize (0.2, 0.2, 0.2);
  approximate_voxel_filter.setInputCloud (input_cloud);
  approximate_voxel_filter.filter (*filtered_cloud);
  std::cout << "Filtered cloud contains " << filtered_cloud->size ()
            << " data points from room_scan2.pcd" << std::endl;

This section filters the input cloud to improve registration time. Any filter that downsamples the data uniformly can work for this section. The target cloud does not need be filtered because voxel grid data structure used by the NDT algorithm does not use individual points, but instead uses the statistical data of the points contained in each of its data structures voxel cells.

  // Initializing Normal Distributions Transform (NDT).
  pcl::NormalDistributionsTransform<pcl::PointXYZ, pcl::PointXYZ> ndt;

Here we create the NDT algorithm with the default values. The internal data structures are not initialized until later.

  // Setting scale dependent NDT parameters
  // Setting minimum transformation difference for termination condition.
  ndt.setTransformationEpsilon (0.01);
  // Setting maximum step size for More-Thuente line search.
  ndt.setStepSize (0.1);
  //Setting Resolution of NDT grid structure (VoxelGridCovariance).
  ndt.setResolution (1.0);

Next we need to modify some of the scale dependent parameters. Because the NDT algorithm uses a voxelized data structure and More-Thuente line search, some parameters need to be scaled to fit the data set. The above parameters seem to work well on the scale we are working with, size of a room, but they would need to be significantly decreased to handle smaller objects, such as scans of a coffee mug.

The Transformation Epsilon parameter defines minimum, allowable, incremental change of the transformation vector, [x, y, z, roll, pitch, yaw] in meters and radians respectively. Once the incremental change dips below this threshold, the alignment terminates. The Step Size parameter defines the maximum step length allowed by the More-Thuente line search. This line search algorithm determines the best step length below this maximum value, shrinking the step length as you near the optimal solution. Larger maximum step lengths will be able to clear greater distances in fewer iterations but run the risk of overshooting and ending up in an undesirable local minimum. Finally, the Resolution parameter defines the voxel resolution of the internal NDT grid structure. This structure is easily searchable and each voxel contain the statistical data, mean, covariance, etc., associated with the points it contains. The statistical data is used to model the cloud as a set of multivariate Gaussian distributions and allows us to calculate and optimize the probability of the existence of points at any position within the voxel. This parameter is the most scale dependent. It needs to be large enough for each voxel to contain at least 6 points but small enough to uniquely describe the environment.

  // Setting max number of registration iterations.
  ndt.setMaximumIterations (35);

This parameter controls the maximum number of iterations the optimizer can run. For the most part, the optimizer will terminate on the Transformation Epsilon before hitting this limit but this helps prevent it from running for too long in the wrong direction.

  // Setting point cloud to be aligned.
  ndt.setInputSource (filtered_cloud);
  // Setting point cloud to be aligned to.
  ndt.setInputTarget (target_cloud);

Here, we pass the point clouds to the NDT registration program. The input cloud is the cloud that will be transformed and the target cloud is the reference frame to which the input cloud will be aligned. When the target cloud is added, the NDT algorithm’s internal data structure is initialized using the target cloud data.

  // Set initial alignment estimate found using robot odometry.
  Eigen::AngleAxisf init_rotation (0.6931, Eigen::Vector3f::UnitZ ());
  Eigen::Translation3f init_translation (1.79387, 0.720047, 0);
  Eigen::Matrix4f init_guess = (init_translation * init_rotation).matrix ();

In this section of code, we create an initial guess about the transformation needed to align the point clouds. Though the algorithm can be run without such an initial transformation, you tend to get better results with one, particularly if there is a large discrepancy between reference frames. In robotic applications, such as the ones used to generate this data set, the initial transformation is usually generated using odometry data.

  // Calculating required rigid transform to align the input cloud to the target cloud.
  pcl::PointCloud<pcl::PointXYZ>::Ptr output_cloud (new pcl::PointCloud<pcl::PointXYZ>);
  ndt.align (*output_cloud, init_guess);

  std::cout << "Normal Distributions Transform has " << (ndt.hasConverged ()?"converged":"not converged")
            << ", score: " << ndt.getFitnessScore () << std::endl;

Finally, we are ready to align the point clouds. The resulting transformed input cloud is stored in the output cloud. We then display the results of the alignment as well as the Euclidean fitness score, calculated as the sum of squared distances from the output cloud to the closest point in the target cloud.

  // Transforming unfiltered, input cloud using found transform.
  pcl::transformPointCloud (*input_cloud, *output_cloud, ndt.getFinalTransformation ());

  // Saving transformed input cloud.
  pcl::io::savePCDFileASCII ("room_scan2_transformed.pcd", *output_cloud);

Immediately after the alignment process, the output cloud will contain a transformed version of the filtered input cloud because we passed the algorithm a filtered point cloud, as opposed to the original input cloud. To obtain the aligned version of the original cloud, we extract the final transformation from the NDT algorithm and transform our original input cloud. We can now save this cloud to file room_scan2_transformed.pcd for future use.

  // Initializing point cloud visualizer
  pcl::visualization::PCLVisualizer::Ptr
  viewer_final (new pcl::visualization::PCLVisualizer ("3D Viewer"));
  viewer_final->setBackgroundColor (0, 0, 0);

  // Coloring and visualizing target cloud (red).
  pcl::visualization::PointCloudColorHandlerCustom<pcl::PointXYZ>
  target_color (target_cloud, 255, 0, 0);
  viewer_final->addPointCloud<pcl::PointXYZ> (target_cloud, target_color, "target cloud");
  viewer_final->setPointCloudRenderingProperties (pcl::visualization::PCL_VISUALIZER_POINT_SIZE,
                                                  1, "target cloud");

  // Coloring and visualizing transformed input cloud (green).
  pcl::visualization::PointCloudColorHandlerCustom<pcl::PointXYZ>
  output_color (output_cloud, 0, 255, 0);
  viewer_final->addPointCloud<pcl::PointXYZ> (output_cloud, output_color, "output cloud");
  viewer_final->setPointCloudRenderingProperties (pcl::visualization::PCL_VISUALIZER_POINT_SIZE,
                                                  1, "output cloud");

  // Starting visualizer
  viewer_final->addCoordinateSystem (1.0, "global");
  viewer_final->initCameraParameters ();

  // Wait until visualizer window is closed.
  while (!viewer_final->wasStopped ())
  {
    viewer_final->spinOnce (100);
    std::this_thread::sleep_for(100ms);
  }

This next part is unnecessary but I like to visually see the results of my labors. With PCL’s visualizer classes, this can be easily accomplished. We first generate a visualizer with a black background. Then we colorize our target and output cloud, red and green respectively, and load them into the visualizer. Finally we start the visualizer and wait for the window to be closed.

Compiling and running the program

Add the following lines to your CMakeLists.txt file:

 1cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
 2
 3project(normal_distributions_transform)
 4
 5find_package(PCL 1.5 REQUIRED)
 6
 7include_directories(${PCL_INCLUDE_DIRS})
 8link_directories(${PCL_LIBRARY_DIRS})
 9add_definitions(${PCL_DEFINITIONS})
10
11
12add_executable(normal_distributions_transform normal_distributions_transform.cpp)
13target_link_libraries (normal_distributions_transform ${PCL_LIBRARIES})

After you have made the executable, you can run it. Simply do:

$ ./normal_distributions_transform

You should see results similar those below as well as a visualization of the aligned point clouds. Happy Coding:

Loaded 112586 data points from room_scan1.pcd
Loaded 112624 data points from room_scan2.pcd
Filtered cloud contains 12433 data points from room_scan2.pcd
Normal Distributions Transform has converged, score: 0.638694