Multi-LiDAR fusion node for ROS 2 β merges any mix of PointCloud2 and LaserScan sources into unified output, with optional CUDA GPU acceleration.
Replaces multi-node pipelines (relay β filter β transform β merge β downsample) with a single composable node.
- Heterogeneous source fusion: mix PointCloud2 and LaserScan sensors freely
- Dual output: publish merged PointCloud2, LaserScan, or both
- Per-source filtering: range, angular, box filters applied before merge
- Output filtering: range, angular, box, height, footprint (ego-body exclusion), voxel downsample β applied in fixed order after merge
- IMU deskewing: per-point SE(3) motion correction using IMU angular velocity and acceleration; auto-detects per-point timestamp fields (
time,t,timestamp, etc.) - CUDA acceleration: optional GPU merge engine with fused kernels and pre-allocated buffers
- TF2 fallback: automatic transform lookup; falls back to last known good on dropout
- Composable node: standalone or loaded into a component container
- Fully parameterized: every feature runtime-configurable via ROS 2 parameters
| Package | Purpose |
|---|---|
rclcpp / rclcpp_components |
ROS 2 node framework |
sensor_msgs |
PointCloud2, LaserScan, Imu |
tf2_ros / tf2_eigen |
Frame transforms |
pcl_conversions |
PCL β ROS message conversion |
laser_geometry |
LaserScan β PointCloud2 projection |
| CUDA toolkit | Optional β GPU merge engine only |
# CPU only
colcon build --packages-select polka
# With CUDA
colcon build --packages-select polka --cmake-args -DPOLKA_ENABLE_CUDA=ONcp config/example_params.yaml config/my_robot.yaml
# Edit: set output_frame_id, list sensors under source_names, configure per-source topics/filters
ros2 launch polka polka.launch.py params_file:=config/my_robot.yamlEnsure TF is published from each sensor's frame_id to your output_frame_id.
All parameters live under the polka namespace. See config/example_params.yaml for the full annotated reference.
| Parameter | Default | Description |
|---|---|---|
output_frame_id |
"base_link" |
Target frame for merged output |
output_rate |
20.0 |
Merge + publish rate (Hz) |
source_timeout |
0.5 |
Drop source if no data within this window (s) |
timestamp_strategy |
"earliest" |
Output stamp: earliest, latest, average, or local |
Per-point deskewing uses the SE(3) exponential map with constant-acceleration + constant-angular-velocity, applied per point based on its timestamp. Inter-source alignment corrects timing offsets between sensors.
Motion model inspired by rko_lio (Malladi et al., 2025).
motion_compensation:
enabled: true
imu_topic: "/imu/data"
max_imu_age: 0.2
imu_buffer_size: 200 # ring buffer (~1s at 200Hz)
per_point_deskew: true
deskew_timestamp_field: "auto" # auto-detects 'time', 't', 'timestamp', etc.Applied after merge in this order: output filters (range/angular/box) β footprint filter β height filter β voxel downsample.
outputs:
cloud:
height_filter:
enabled: true
z_min: -1.0
z_max: 3.0
voxel:
enabled: true
leaf_size: 0.05
footprint_filter:
enabled: true
box_names: ["chassis"]
chassis:
x_min: -0.30
x_max: 0.30
y_min: -0.25
y_max: 0.25
z_min: -0.10
z_max: 0.50graph LR
subgraph Drivers
D1[lidar driver Β· front]
D2[odom / cmd_vel]
D3[lidar driver Β· back]
end
P[<strong>polka</strong>]
subgraph Consumers
C1[mapping / reconstruction<br/>~/merged_cloud]
C2[localization / navigation<br/>~/merged_scan]
end
D1 --> P
D2 -.-> P
D3 --> P
P --> C1
P --> C2
Cloud path:
graph LR
subgraph Drivers
D1[lidar driver Β· front]
D2[lidar driver Β· back]
end
CAT[pcl_ros::<br/>ConcatenatePointCloud<br/>+ ApproxTimeSynchronizer]
CF[custom node<br/>cloud filters]
MAP[mapping node]
D1 --> CAT
D2 --> CAT
CAT --> CF -->|merged_cloud| MAP
Scan path:
graph LR
subgraph Drivers
D1[lidar driver Β· front]
D2[lidar driver Β· back]
end
P2L1[pointcloud_to_laserscan<br/>Β· front]
P2L2[pointcloud_to_laserscan<br/>Β· back]
IRA[ira_laser_tools::<br/>LaserscanMerger]
SF[custom node<br/>scan filters]
NAV[localization / navigation]
D1 --> P2L1
D2 --> P2L2
P2L1 --> IRA
P2L2 --> IRA
IRA --> SF -->|merged_scan| NAV
graph LR
subgraph Sources
PC[PointCloud2<br/>/front/points]
LS[LaserScan<br/>/rear/scan]
end
subgraph Per-Source Filters
PF1[Range / Angular /<br/>Box Filter]
PF2[Range / Angular /<br/>Box Filter]
end
subgraph Merge Engine
ME[CPU or CUDA<br/>Merge]
end
subgraph Output Pipeline
OF[Range / Angular /<br/>Box Filter]
FF[Footprint Filter]
HF[Height Filter]
VX[Voxel Downsample]
end
PC --> PF1 --> ME
LS --> PF2 --> ME
ME --> OF --> FF --> HF --> VX
VX --> OUT_PC[PointCloud2]
VX --> OUT_LS[LaserScan]
polka/
βββ config/example_params.yaml
βββ launch/polka.launch.py
βββ include/polka/
β βββ polka_node.hpp # Main composable node
β βββ types.hpp # Config structs and type definitions
β βββ config_loader.hpp # Parameter loading and hot-reload
β βββ source_adapter.hpp # Sensor subscription and conversion
β βββ filters/
β β βββ i_filter.hpp # Filter interface
β β βββ range_filter.hpp
β β βββ angular_filter.hpp
β β βββ box_filter.hpp # Also used inverted for footprint filter
β βββ merge_engine/
β βββ i_merge_engine.hpp # Merge engine interface
β βββ cpu_merge_engine.hpp
β βββ cuda_merge_engine.hpp
β βββ cuda_types.cuh
βββ src/
βββ main.cpp
βββ polka_node.cpp
βββ config_loader.cpp
βββ source_adapter.cpp
βββ filters/
βββ merge_engine/
Per-point deskewing motion model inspired by rko_lio:
@article{malladi2025arxiv,
author = {M.V.R. Malladi and T. Guadagnino and L. Lobefaro and C. Stachniss},
title = {A Robust Approach for LiDAR-Inertial Odometry Without Sensor-Specific Modeling},
journal = {arXiv preprint},
year = {2025},
volume = {arXiv:2509.06593},
url = {https://arxiv.org/pdf/2509.06593},
}Apache-2.0
