ROS 2 LOAM Scan Matching Node¶
Overview¶
This script implements a ROS 2 node, loam_scan_matcher, that subscribes to edge and planar feature point clouds and performs incremental pose estimation using point-to-plane Iterative Closest Point (ICP) registration. The node integrates relative transformations into a global pose, continuously publishing odometry, trajectory, and frame transforms.
The algorithm follows the LOAM (LiDAR Odometry and Mapping) framework by Zhang & Singh (2014) and is designed for real-time incremental LiDAR odometry. It has been adapted from ENPM818Z L2C lecture content and is suitable for research and teaching applications within the NIST SLAM Front-End testbed.
Core Functionality¶
The node performs the following sequence of operations for each incoming frame:
Feature Fusion Combines edge features (high curvature) and planar features (low curvature) from the current scan into a single point cloud for registration.
Voxel Downsampling Reduces point density using voxel-grid hashing to improve ICP convergence speed and robustness.
Normal Estimation Computes per-point surface normals on the reference frame using k-NN PCA. Normals are oriented consistently (toward +Z by heuristic) for reliable point-to-plane residuals.
Point-to-Plane ICP Registration Performs iterative closest point matching against the previous frame using point-to-plane constraints:
\[\mathbf{n}_i^T \left( R\mathbf{q}_i + \mathbf{t} - \mathbf{p}_i \right) \approx 0\]where \(\mathbf{p}_i\) are reference points, \(\mathbf{q}_i\) are source points, \(\mathbf{n}_i\) are surface normals, and the goal is to estimate \(R\) and \(\mathbf{t}\).
SE(3) Optimization Solves the registration problem directly in Lie algebra (se(3)) using the exponential map:
\[\begin{split}\exp(\boldsymbol{\xi}) = \begin{pmatrix} R & V\mathbf{t} \\ 0 & 1 \end{pmatrix}\end{split}\]where \(\boldsymbol{\xi} = [\boldsymbol{\omega}, \mathbf{t}]^T\) is a 6D twist vector. This allows direct least-squares optimization without singularities.
Pose Integration Integrates the estimated relative transformation into the global world-to-base transformation:
\[T_{wb} \gets T_{wb} \cdot T_{curr}\]Publishing The node publishes:
Odometry (
scan_match/odometry): Current pose in the map frame as anav_msgs/msg/Odometrymessage.Trajectory (
scan_match/trajectory): Accumulated path as anav_msgs/msg/Pathmessage.TF Transform (
map → base_link): Broadcast pose as a geometry frame transform.
Coordinate Frame Hierarchy¶
The scan matcher uses a single global frame hierarchy rooted in the map frame:
map (global reference)
└── base_link (robot body frame)
└── velodyne (LiDAR sensor frame)
Frame Semantics:
map: Global reference frame provided by the KITTI ground truth publisher. All poses and point clouds are ultimately expressed in this frame.
base_link: Robot body frame. Updated by the scan matcher with the estimated pose
T_wb(world-to-body transformation).velodyne: LiDAR sensor frame. Point clouds are published in this frame by the KITTI publisher and feature extractor.
Why Clouds Appear in Both Frames (Foxglove Display):
When visualizing point clouds in Foxglove Studio, the same cloud data can be displayed in multiple frames because:
Point clouds carry a
frame_id(e.g.,velodyne) indicating their native frame.Foxglove uses the TF tree to transform clouds into any requested frame.
Selecting different panels with different
frameIdsettings will show the same cloud data transformed into those frames.
For example:
- A cloud native to velodyne displayed in the map frame is transformed as: velodyne → base_link → map
- The same cloud displayed in the base_link frame is transformed as: velodyne → base_link
If the map → base_link transform is static (pose not changing), the clouds will appear identical in both frames. If the transform updates with new pose estimates, the clouds will appear to move relative to the map frame grid.
Note: To verify that pose estimation is working, check:
ros2 topic echo /scan_match/odometry --field pose.pose.position
Position (x, y, z) should change with each new scan if the sensor is moving.
ROS 2 Interface¶
Parameters:
Parameter |
Value (from params.yaml) |
Meaning |
|---|---|---|
|
|
Input topic for edge feature clouds from feature extractor. |
|
|
Input topic for planar feature clouds from feature extractor. |
|
|
Voxel grid downsampling cell size (meters). Smaller values preserve detail; larger values speed up ICP. |
|
|
Maximum number of ICP iterations per frame. Early stopping occurs if update norm < 1e-6. |
|
|
Distance threshold (meters) for considering point-to-point correspondences in ICP. Correspondences beyond this distance are rejected. |
|
|
Number of neighbors used for k-NN surface normal estimation via PCA. |
|
|
Global reference frame ID (from KITTI ground truth). |
|
|
Robot body frame ID. Updated by scan matcher pose estimates. |
|
|
Output topic for publishing odometry messages. |
|
|
Output topic for publishing trajectory paths. |
Subscriptions:
<edge_topic>(sensor_msgs/msg/PointCloud2): Edge feature clouds (high curvature points) from the feature extractor.<planar_topic>(sensor_msgs/msg/PointCloud2): Planar feature clouds (low curvature points) from the feature extractor.
Publications:
<odom_topic>(nav_msgs/msg/Odometry): Odometry message with pose and frame information in the map frame.<trajectory_topic>(nav_msgs/msg/Path): Accumulated trajectory of all poses since launch.TF Transform (
geometry_msgs/msg/TransformStamped): Broadcast transform from<frame_map>to<frame_base>.
Launch Files:
File: src/loam_scan_matcher/launch/scan_matching.launch.py
This launch file starts the complete LOAM scan matching pipeline, including all dependent stages required for full functionality.
It launches the following sequence:
Feature Extraction Pipeline (included) - KITTI data loader - Voxel downsampler - LOAM feature extractor
LOAM Scan Matcher (this file) - Subscribes to edge and planar features - Publishes odometry, trajectory, and TF transforms
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""
4Launch File: scan_matching.launch.py
5====================================
6
7Description
8-----------
9This launch file starts the **LOAM scan matching** stage and, by inclusion,
10its upstream dependencies. It first launches the **feature extraction pipeline**
11(which itself brings up KITTI playback and LiDAR preprocessing), then launches
12the scan matcher node configured via a package-local YAML file.
13
14Pipeline (launch order)
15-----------------------
161) **Feature Extraction Pipeline** (included from
17 ``loam_feature_extraction/launch/feature_extraction.launch.py``)
18 - KITTI Data Loader (raw LiDAR + ground truth)
19 - LiDAR Preprocessing (e.g., voxel downsampling)
20 - LOAM Feature Extraction (publishes edge/planar feature clouds)
21
222) **LOAM Scan Matcher** (this file)
23 - Subscribes to: ``features/edge_cloud`` and ``features/planar_cloud``
24 - Publishes: odometry (``/odom``), TF (``odom -> base_link``), and path (``/trajectory``)
25
26Configuration
27-------------
28All scan matcher parameters are loaded from:
29``loam_scan_matcher/config/params.yaml``
30
31**No inline parameter overrides** are provided in this launch file, ensuring that
32edits to the YAML fully control behavior (topics, frames, ICP settings, etc.).
33
34Execution
35---------
36Launch the full chain (KITTI → Preprocessing → Features → Scan Matching):
37
38.. code-block:: bash
39
40 ros2 launch loam_scan_matcher scan_matching.launch.py
41
42Returns
43-------
44launch.LaunchDescription
45 A ROS 2 launch description that includes the feature extraction pipeline
46 and the LOAM scan matcher node, with parameters sourced from YAML only.
47"""
48
49from launch import LaunchDescription
50from launch.actions import IncludeLaunchDescription
51from launch.launch_description_sources import PythonLaunchDescriptionSource
52from launch.substitutions import PathJoinSubstitution
53from launch_ros.substitutions import FindPackageShare
54from launch_ros.actions import Node
55
56
57def generate_launch_description() -> LaunchDescription:
58 """
59 Compose the launch description for LOAM scan matching.
60
61 Steps:
62 1) Include the **feature extraction** launch file from the
63 ``loam_feature_extraction`` package so that KITTI playback and LiDAR
64 preprocessing are available before scan matching starts.
65 2) Launch the **scan matcher** node from the ``loam_scan_matcher`` package,
66 loading its configuration solely from ``config/params.yaml``.
67
68 Returns:
69 launch.LaunchDescription: The composed launch description.
70 """
71 # ------------------------------------------------------------------
72 # Include LOAM Feature Extraction Pipeline
73 # (KITTI loader + preprocessing + feature extractor)
74 # ------------------------------------------------------------------
75 feature_extraction_launch = IncludeLaunchDescription(
76 PythonLaunchDescriptionSource(
77 PathJoinSubstitution([
78 FindPackageShare("loam_feature_extraction"),
79 "launch",
80 "feature_extraction.launch.py",
81 ])
82 )
83 )
84
85 # ------------------------------------------------------------------
86 # Scan Matcher parameters from YAML (no inline overrides)
87 # ------------------------------------------------------------------
88 scan_matcher_params = PathJoinSubstitution([
89 FindPackageShare("loam_scan_matcher"),
90 "config",
91 "params.yaml",
92 ])
93
94 # ------------------------------------------------------------------
95 # LOAM Scan Matcher Node
96 # ------------------------------------------------------------------
97 scan_matcher_node = Node(
98 package="loam_scan_matcher",
99 executable="loam_scan_matcher",
100 name="loam_scan_matcher",
101 output="screen",
102 parameters=[scan_matcher_params], # <-- YAML-driven configuration only
103 )
104
105 # ------------------------------------------------------------------
106 # Launch: feature extraction first, then scan matcher
107 # ------------------------------------------------------------------
108 return LaunchDescription([
109 feature_extraction_launch,
110 scan_matcher_node,
111 ])
Compile and Run¶
1. Build the Workspace
Build the workspace (if not already built):
colcon build --symlink-install
source install/setup.bash
2. Run and Visualize
You will need two sourced ROS 2 terminals and the Foxglove Studio application.
Terminal 1 - Start Foxglove Bridge
Launch the websocket bridge that Foxglove Studio uses to communicate with ROS 2:
ros2 launch foxglove_bridge foxglove_bridge_launch.xml port:=8765
Foxglove Studio GUI
Open Foxglove Studio.
Go to Open Connection → Foxglove WebSocket.
Enter
ws://localhost:8765and click Open.Load the pre-configured layout from
loam_scan_matcher/config/scan_matching_layout.jsonto visualize the following topics:/features/edge_cloud(point cloud, colorized by intensity)/features/planar_cloud(point cloud, colorized by intensity)/scan_match/trajectory(path visualization)/scan_match/odometry(pose and position plots)
The layout includes:
3D View (main): Point clouds, trajectory path, grid in
mapframe, and robot axes atbase_link.Transform Tree: Visualization of the TF hierarchy (
map → base_link → velodyne).Topic Graph: Node and topic connectivity.
Feature Widths Plot: Edge and planar cloud sizes over time.
Position Plot: X, Y, Z trajectory coordinates.
Terminal 2 - Launch the Scan Matching Pipeline
Once Foxglove Studio is connected, launch all nodes:
ros2 launch loam_scan_matcher scan_matching.launch.py
Monitor Pose Estimation
To verify that the scan matcher is successfully estimating pose (not stalled):
ros2 topic echo /scan_match/odometry --field pose.pose.position
Position values should change as the KITTI dataset plays through the sequence.
Troubleshooting¶
Pose Not Updating (Position Constant)
If /scan_match/odometry shows the same position repeatedly:
Check that edge and planar clouds have sufficient points:
ros2 topic echo /features/edge_cloud --field width ros2 topic echo /features/planar_cloud --field width
Verify ICP convergence by reducing
icp_dist_threshor increasingmax_icp_iterin params.yaml.Check the TF tree:
ros2 run tf2_tools view_frames
The tree should show
map → base_link.
TF Transform Errors in Foxglove
If Foxglove reports “Missing transform”, ensure:
The
frame_mapandframe_baseparameters match your layout’s frame IDs.The TF broadcaster is running (check
/tftopic):ros2 topic list | grep tf
Noisy or Jumping Trajectories
If the estimated path jitters or jumps:
Increase
voxel_sizeto reduce noise sensitivity.Increase
icp_dist_threshto include more correspondences.Check that the feature extractor is producing balanced edge/planar distributions (see feature width plots).