ROS 2 message_filters Guide 2026
Synchronize multiple sensor topics in ROS 2 with message_filters: exact-match TimeSynchronizer for RGBD cameras, fuzzy-match ApproximateTimeSynchronizer for camera+IMU and lidar+camera fusion, C++ sync policies, and QoS debugging.
The Core Problem
A camera at 30Hz and an IMU at 200Hz publish on separate topics with different timestamps. Without synchronization, your callback receives mismatched pairs — a camera frame from 50ms ago with the latest IMU reading. message_filters buffers messages and calls your callback only when temporally-matching messages from all topics are available simultaneously.
message_filters — Synchronized Multi-Sensor Input
Robots have multiple sensors producing data at different rates with different timestamps. message_filters provides synchronization policies to call your callback only when matching messages from all topics are available.
message_filters synchronization policies
TimeSynchronizer (exact match):
/camera/image_raw t=1.000 ─────────────┐
/camera/depth/image t=1.000 ─────────────┤→ callback(image, depth)
Requirement: EXACT same timestamp
ApproximateTimeSynchronizer (fuzzy match):
/camera/image_raw t=1.000 ─────────────┐
/imu/data t=1.003 ─────────────┤→ callback if |Δt| < slop
Requirement: timestamps within slop seconds
Cache:
/scan → Cache(10) → stores last 10 messages, query by time
TimeSequencer:
/scan → TimeSequencer(0.1s) → delivers messages in timestamp order
SimpleFilter:
Wraps any rclpy subscriber → uniform filter interface
Common use cases:
RGBD fusion: image + depth_image (TimeSynchronizer)
Visual-IMU: image + imu (ApproximateTimeSynchronizer)
Lidar+camera: pointcloud + image (ApproximateTimeSynchronizer)
Stereo: left_image + right_image (TimeSynchronizer)
Install:
sudo apt install ros-jazzy-message-filters
Package: message_filters (C++) / message_filters (Python rclpy)TimeSynchronizer — Exact Timestamp Matching (Python)
Use TimeSynchronizer when your sensors publish with exactly matching timestamps (e.g., RGBD cameras that hardware-synchronize color and depth).
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image, CameraInfo
import message_filters
class RGBDFusionNode(Node):
def __init__(self):
super().__init__("rgbd_fusion")
# Create individual Subscribers via message_filters
# These are NOT rclpy subscribers — they wrap into the filter system
self.rgb_sub = message_filters.Subscriber(
self, Image, "/camera/color/image_raw"
)
self.depth_sub = message_filters.Subscriber(
self, Image, "/camera/depth/image_raw"
)
# TimeSynchronizer: require EXACT timestamp match
# queue_size=10 means buffer up to 10 unmatched messages per topic
self.ts = message_filters.TimeSynchronizer(
[self.rgb_sub, self.depth_sub],
queue_size=10
)
self.ts.registerCallback(self.synchronized_callback)
self.get_logger().info("RGBD synchronizer ready")
def synchronized_callback(self, rgb_msg: Image, depth_msg: Image):
"""Called only when rgb and depth have EXACTLY the same timestamp."""
self.get_logger().info(
f"Synchronized: rgb stamp={rgb_msg.header.stamp.sec}, "
f"depth stamp={depth_msg.header.stamp.sec}"
)
# Both messages have the same timestamp here
# Process: 3D point cloud reconstruction, depth fusion, etc.
assert rgb_msg.header.stamp == depth_msg.header.stamp
def main():
rclpy.init()
node = RGBDFusionNode()
rclpy.spin(node)
rclpy.shutdown()
if __name__ == "__main__":
main()TimeSynchronizer drops message pairs if timestamps don't match exactly. For sensors without hardware sync, use ApproximateTimeSynchronizer instead.
ApproximateTimeSynchronizer — Camera + IMU Fusion (Python)
ApproximateTimeSynchronizer uses the POLICIES_ADAPTIVETIME algorithm to match messages whose timestamps are within a slop window. Essential for camera+IMU or lidar+camera fusion.
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Image, Imu, LaserScan
import message_filters
class VisualIMUNode(Node):
def __init__(self):
super().__init__("visual_imu_sync")
# Camera at 30 Hz
self.img_sub = message_filters.Subscriber(
self, Image, "/camera/image_raw"
)
# IMU at 200 Hz — timestamps will not match camera exactly
self.imu_sub = message_filters.Subscriber(
self, Imu, "/imu/data"
)
# ApproximateTimeSynchronizer
# slop=0.1 → accept pairs within 100ms of each other
# queue_size=30 → buffer more messages (IMU publishes faster)
self.ats = message_filters.ApproximateTimeSynchronizer(
[self.img_sub, self.imu_sub],
queue_size=30,
slop=0.1, # seconds — adjust based on your rates
allow_headerless=False # require header.stamp on all messages
)
self.ats.registerCallback(self.vio_callback)
def vio_callback(self, img_msg: Image, imu_msg: Imu):
"""Called when image and IMU are within 100ms of each other."""
dt = abs(
img_msg.header.stamp.sec - imu_msg.header.stamp.sec
)
self.get_logger().info(
f"Visual-IMU sync: dt={dt:.3f}s "
f"accel=({imu_msg.linear_acceleration.x:.2f}, ...)"
)
# Feed into VIO algorithm (ORB-SLAM3, Kimera, etc.)
class LidarCameraNode(Node):
def __init__(self):
super().__init__("lidar_camera_sync")
# LiDAR at 10 Hz
self.lidar_sub = message_filters.Subscriber(
self, LaserScan, "/scan"
)
# Camera at 30 Hz
self.img_sub = message_filters.Subscriber(
self, Image, "/camera/image_raw"
)
# 3 topics: lidar + image
self.ats = message_filters.ApproximateTimeSynchronizer(
[self.lidar_sub, self.img_sub],
queue_size=10,
slop=0.05 # 50ms — tight for obstacle detection
)
self.ats.registerCallback(self.fusion_callback)
def fusion_callback(self, scan: LaserScan, img: Image):
self.get_logger().info("Lidar+camera synchronized")
def main():
rclpy.init()
node = VisualIMUNode()
rclpy.spin(node)
rclpy.shutdown()Set slop to slightly larger than the time difference between your sensor rates. For 30Hz camera + 100Hz IMU, slop = 1/30 - 1/100 ≈ 0.023s. Start with 0.05s and tighten if needed.
message_filters in C++ — Stereo Camera
The C++ message_filters API uses policy-based synchronization. TimeSynchronizer and ApproximateTimeSynchronizer have the same interface but use templates.
#include "rclcpp/rclcpp.hpp"
#include "message_filters/subscriber.h"
#include "message_filters/synchronizer.h"
#include "message_filters/sync_policies/approximate_time.h"
#include "message_filters/sync_policies/exact_time.h"
#include "sensor_msgs/msg/image.hpp"
using sensor_msgs::msg::Image;
// Policy types
using ApproxPolicy = message_filters::sync_policies::ApproximateTime<Image, Image>;
using ExactPolicy = message_filters::sync_policies::ExactTime<Image, Image>;
class StereoFusionNode : public rclcpp::Node {
public:
StereoFusionNode()
: Node("stereo_fusion"),
left_sub_(this, "/stereo/left/image_raw"),
right_sub_(this, "/stereo/right/image_raw"),
sync_(ApproxPolicy(30), left_sub_, right_sub_)
{
// Register callback on the synchronizer
sync_.registerCallback(
std::bind(&StereoFusionNode::stereo_callback, this,
std::placeholders::_1, std::placeholders::_2)
);
RCLCPP_INFO(get_logger(), "Stereo synchronizer ready");
}
private:
void stereo_callback(
const Image::ConstSharedPtr & left,
const Image::ConstSharedPtr & right)
{
RCLCPP_INFO(get_logger(),
"Stereo sync: left=%d.%d right=%d.%d",
left->header.stamp.sec, left->header.stamp.nanosec,
right->header.stamp.sec, right->header.stamp.nanosec);
// Feed into stereo matcher (OpenCV StereoBM, StereoSGBM, etc.)
}
message_filters::Subscriber<Image> left_sub_;
message_filters::Subscriber<Image> right_sub_;
message_filters::Synchronizer<ApproxPolicy> sync_;
};
int main(int argc, char * argv[]) {
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<StereoFusionNode>());
rclcpp::shutdown();
}
// CMakeLists.txt:
// find_package(message_filters REQUIRED)
// ament_target_dependencies(stereo_fusion
// rclcpp message_filters sensor_msgs)The queue_size parameter to ApproximateTime is the maximum number of unmatched messages to buffer per topic. Higher values handle bigger timestamp jitter but use more memory.
Debug Synchronization Problems
When the callback never fires, use these techniques to diagnose timestamp mismatches, topic remapping errors, and QoS incompatibilities.
# ── Check that topics are actually publishing ─────────────────
ros2 topic hz /camera/image_raw
ros2 topic hz /imu/data
# ── Inspect timestamps ────────────────────────────────────────
# Python one-liner to print timestamps:
ros2 topic echo /camera/image_raw --field header.stamp
ros2 topic echo /imu/data --field header.stamp
# ── Check QoS compatibility ───────────────────────────────────
# message_filters.Subscriber defaults to sensor_data QoS
# If your topic uses default QoS, this mismatch will cause 0 messages
# Fix: specify matching QoS in the Subscriber constructor
ros2 topic info /camera/image_raw --verbose
# In Python, specify QoS explicitly:
# from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
# qos = QoSProfile(reliability=ReliabilityPolicy.BEST_EFFORT, depth=10)
# self.img_sub = message_filters.Subscriber(self, Image, "/cam", qos_profile=qos)
# ── Verify timestamps are close enough ────────────────────────
# Print time delta between two topics:
python3 - <<EOF
import rclpy, message_filters
from rclpy.node import Node
from sensor_msgs.msg import Image, Imu
class DeltaChecker(Node):
def __init__(self):
super().__init__("delta_checker")
self.last_img_t = None
self.create_subscription(Image, "/camera/image_raw",
lambda m: setattr(self, "last_img_t", m.header.stamp), 10)
self.create_subscription(Imu, "/imu/data", self._imu_cb, 10)
def _imu_cb(self, m):
if self.last_img_t:
dt = abs(m.header.stamp.sec - self.last_img_t.sec)
self.get_logger().info(f"delta={dt:.3f}s")
rclpy.init(); rclpy.spin(DeltaChecker())
EOF
# Common fixes:
# 1. slop too small → increase to 0.1–0.5s and confirm callback fires
# 2. QoS mismatch → match reliability between publisher and subscriber
# 3. Callback never fires → check with ros2 topic echo that both topics publish
# 4. queue_size too small → increase for high-rate topics (IMU at 200Hz)Quick Reference
| API | Details |
|---|---|
| Exact sync | message_filters.TimeSynchronizer([sub1, sub2], queue_size=10) |
| Approx sync | message_filters.ApproximateTimeSynchronizer([s1, s2], 30, slop=0.1) |
| Subscribe | message_filters.Subscriber(self, MsgType, '/topic') |
| Register cb | .registerCallback(self.callback) |
| C++ policy | message_filters::sync_policies::ApproximateTime<Img, Img> |
| Sync callback | def cb(self, msg1, msg2): — one arg per subscribed topic |
| slop guideline | Set slop ≥ max(1/rate1, 1/rate2) — e.g. 30Hz + 100Hz → 0.033s |
| QoS mismatch fix | Pass qos_profile= to Subscriber to match publisher QoS |
| Debug: no callback | Check ros2 topic hz for both topics; increase slop; check QoS |
| queue_size | Buffer unmatched messages per topic. Increase for high-rate topics. |