Skip to main content
🔗 message_filtersROS 2 · June 2026

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.

1

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.

text
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)
2

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).

python
#!/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.

3

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.

python
#!/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.

4

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.

cpp
#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.

5

Debug Synchronization Problems

When the callback never fires, use these techniques to diagnose timestamp mismatches, topic remapping errors, and QoS incompatibilities.

bash
# ── 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

APIDetails
Exact syncmessage_filters.TimeSynchronizer([sub1, sub2], queue_size=10)
Approx syncmessage_filters.ApproximateTimeSynchronizer([s1, s2], 30, slop=0.1)
Subscribemessage_filters.Subscriber(self, MsgType, '/topic')
Register cb.registerCallback(self.callback)
C++ policymessage_filters::sync_policies::ApproximateTime<Img, Img>
Sync callbackdef cb(self, msg1, msg2): — one arg per subscribed topic
slop guidelineSet slop ≥ max(1/rate1, 1/rate2) — e.g. 30Hz + 100Hz → 0.033s
QoS mismatch fixPass qos_profile= to Subscriber to match publisher QoS
Debug: no callbackCheck ros2 topic hz for both topics; increase slop; check QoS
queue_sizeBuffer unmatched messages per topic. Increase for high-rate topics.