Part XI: Tactile, Embodied, and Robotic Sensing
Chapter 57: Proprioception, Exteroception, and Robot Perception

ROS 2 perception integration

"The algorithm was correct. The transform was ten milliseconds stale, the QoS was mismatched, and the depth cloud arrived on a topic nobody subscribed to. My beautiful fusion node saw nothing at all."

A Freshly Humbled Integration AI Agent

The big picture

Every idea in this chapter, body-state sensing, exteroceptive fusion, detection and tracking, failure recovery, has so far lived as isolated mathematics. On a real robot none of it runs in a single script. It runs as a graph of concurrent processes exchanging typed messages over a middleware, each with its own clock, its own frame of reference, and its own tolerance for late or dropped data. ROS 2 (the Robot Operating System, version 2) is the framework that most of the field uses to wire that graph together. It is not an algorithm; it is the plumbing that lets a lidar driver, a depth camera, an IMU filter, a neural detector, and a planner cooperate without any one of them knowing the others exist. This section is about that plumbing: the message types perception speaks, the coordinate-frame bookkeeping that keeps sensors geometrically consistent, the time-synchronization machinery that pairs measurements taken at the same instant, and the quality-of-service knobs that decide whether a stream is reliable-but-buffered or fast-but-lossy. Get the plumbing right and your estimator from Section 57.4 just works on hardware. Get it wrong and correct math produces silence.

This section assumes the timing and synchronization foundations of Chapter 3 and the real-time budgeting of Section 57.6. Here we make those concrete inside one concrete middleware rather than in the abstract.

The graph: nodes, topics, and DDS

A ROS 2 system is a set of nodes (independent units of computation) that communicate over named topics using a publish-subscribe model. A camera driver node publishes on /camera/image_raw; a detector node subscribes to it and publishes bounding boxes on /detections; a tracker subscribes to those. No node holds a reference to another. This decoupling is what lets you swap a classical detector for a neural one, or run the detector on a separate compute board, by changing a topic name rather than rewriting the caller. Underneath, ROS 2 delegates transport to DDS (the Data Distribution Service, an industrial pub-sub standard), which handles discovery, serialization, and delivery with no central broker. That decentralization matters for robots: there is no ROS master to become a single point of failure, and two nodes on the same machine can bypass the network stack entirely.

Perception messages are standardized so that any node can consume any sensor. The sensor_msgs package defines Image, PointCloud2, LaserScan, Imu, and CameraInfo among others; every one carries a std_msgs/Header with a timestamp and a frame_id. Those two fields, when the measurement was taken and in which coordinate frame, are the entire basis for fusing heterogeneous sensors, and forgetting to populate them correctly is the single most common integration bug.

tf2: the coordinate-frame spine

A robot has many frames: base_link at its center, lidar_link where the lidar sits, camera_optical_frame, an odom frame that drifts smoothly, a map frame that is globally consistent. A lidar point is meaningless until you know it is expressed in lidar_link and can transform it into base_link to reason about obstacles near the body. tf2 is the ROS 2 subsystem that maintains this tree of time-stamped rigid transforms and answers queries of the form "give me the transform from frame A to frame B at time \(t\)." Because transforms are time-stamped, tf2 interpolates: if a wheel moved between two published odom updates, tf2 returns the pose at the exact instant your camera frame was captured. This is what makes proprioception-exteroception fusion geometrically honest. The composed transform from a sensor frame to the map frame is just a chain of homogeneous matrices,

$$T^{\text{map}}_{\text{sensor}} = T^{\text{map}}_{\text{odom}}\, T^{\text{odom}}_{\text{base}}\, T^{\text{base}}_{\text{sensor}},$$

and tf2 walks that chain for you, raising an exception if any link is missing or too old. The static links (sensor mounts) are published once; the dynamic links (odom to base) stream from the estimator of Section 57.4.

Key insight

In ROS 2 perception, the timestamp is not metadata, it is part of the measurement. A point cloud stamped with the arrival time instead of the capture time will be transformed by the wrong pose, projecting obstacles centimeters to meters off their true location during motion. The two most damaging integration errors are almost never in the perception algorithm: they are a wrong or missing frame_id, and a timestamp that reflects when the CPU noticed the data rather than when the sensor sampled it. Fix the header first; debug the math second.

Time synchronization and quality of service

Fusion nodes usually need several streams that belong to the same instant: an RGB image, its depth map, and the IMU state when both were captured. The streams arrive on separate topics at different rates with jitter, so you cannot simply grab the latest of each. ROS 2 provides message_filters with an approximate-time synchronizer that buffers recent messages per topic and emits a tuple only when it finds one message from each whose stamps fall inside a tolerance window. This is the practical realization of the alignment problem from Chapter 3, packaged as a callback.

Equally important is QoS (quality of service), the per-topic contract that governs delivery. A Reliable publisher retransmits until every subscriber acknowledges; BestEffort fires and forgets. High-rate sensor data (lidar, images) is almost always BestEffort with a shallow KEEP_LAST history, because a robot moving at speed wants the freshest scan, not a guaranteed-delivered stale one. A mismatched QoS, a Reliable subscriber on a BestEffort topic, causes the connection to silently never form: the subscriber simply receives nothing, with no error. This is the classic "my node sees no data" failure, and it is a delivery contract problem, not a code problem.

import rclpy
from rclpy.node import Node
from message_filters import Subscriber, ApproximateTimeSynchronizer
from sensor_msgs.msg import Image, PointCloud2
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy

SENSOR_QOS = QoSProfile(reliability=ReliabilityPolicy.BEST_EFFORT,
                        history=HistoryPolicy.KEEP_LAST, depth=5)

class FusionNode(Node):
    def __init__(self):
        super().__init__("rgbd_fusion")
        img = Subscriber(self, Image, "/camera/image_raw", qos_profile=SENSOR_QOS)
        cloud = Subscriber(self, PointCloud2, "/lidar/points", qos_profile=SENSOR_QOS)
        # pair messages whose stamps agree within ~20 ms
        self.sync = ApproximateTimeSynchronizer([img, cloud], queue_size=10, slop=0.02)
        self.sync.registerCallback(self.on_pair)

    def on_pair(self, image, cloud):
        dt = abs((image.header.stamp.sec + image.header.stamp.nanosec * 1e-9) -
                 (cloud.header.stamp.sec + cloud.header.stamp.nanosec * 1e-9))
        self.get_logger().info(f"aligned pair, |dt|={dt*1e3:.1f} ms")
        # ... transform cloud into camera frame via tf2, run fusion ...

def main():
    rclpy.init(); rclpy.spin(FusionNode()); rclpy.shutdown()
A minimal ROS 2 fusion node. The ApproximateTimeSynchronizer pairs an image with a lidar cloud only when their header stamps agree within the 20 ms slop, and the shared BEST_EFFORT QoS ensures the subscriptions actually connect to the high-rate sensor publishers. The synchronizer and QoS logic are the two lines that turn correct math into a node that receives data on hardware.

The code above is referenced in the lab below. Note how little of it is perception: the substance is delivery contracts and stamp alignment, exactly the concerns that never appear in a paper but decide whether the paper's method survives contact with a robot.

Right tool: message_filters and tf2 do the hard bookkeeping

Hand-rolling time synchronization means a per-topic ring buffer, a windowed matching search across streams, and drop policies for stragglers: comfortably 150 to 200 lines, and it is where subtle off-by-one-frame bugs breed. The ApproximateTimeSynchronizer reduces it to the three lines above. Likewise, computing an interpolated sensor-to-map transform by hand means storing a time-series of poses, doing spherical-linear quaternion interpolation, and chaining matrices with staleness checks; tf2's lookup_transform(target, source, stamp) is one call. Together they remove roughly 300 lines of exactly the code you least want to write yourself and most want battle-tested.

Composition, lifecycle, and Nav2

Passing a multi-megabyte point cloud between processes means serializing and copying it, which at 20 Hz wastes real bandwidth. ROS 2 answers with components: nodes compiled as shared libraries and loaded into one process, where intra-process communication passes a pointer instead of a copy. A perception pipeline (driver, filter, detector) composed into a single container can cut end-to-end latency dramatically, which connects directly to the budgets of Section 57.6. ROS 2 also offers managed lifecycle nodes with explicit unconfigured, inactive, and active states, so a sensor driver can be brought up, checked, and only then allowed to publish, which is the clean hook for the failure detection and recovery of Section 57.5. Above all this sits Nav2, the navigation stack that consumes the fused, transformed perception to build costmaps and plan paths, tying this chapter's perception to the spatial reasoning of Chapter 52.

Practical example: a warehouse AMR that went blind at the dock

A team deploying an autonomous mobile robot (AMR) in a logistics warehouse hit an intermittent fault: near the loading dock the robot occasionally froze, reporting no obstacles despite a wall in front of it. The perception code was unchanged from a working simulation. The cause was pure integration. The depth-camera driver published its point cloud with Reliable QoS, while the costmap's subscriber requested BestEffort; in simulation the timing masked the mismatch, but on the loaded robot the connection intermittently failed to form and the costmap received nothing, defaulting to "clear." A second, compounding bug: the driver stamped clouds with wall-clock arrival time, so during the robot's turn into the dock, tf2 transformed points with a pose up to 40 ms stale, smearing the wall out of the safety zone. Both fixes were one line each: align the QoS profiles, and stamp clouds with the sensor's hardware capture time. No perception algorithm changed. The robot's "blindness" was entirely in the plumbing, which is precisely where field-deployment bugs concentrate.

When ROS 2 is the right choice

ROS 2 earns its complexity when you have many heterogeneous sensors, multiple compute units, and a need to reuse community drivers and stacks like Nav2. For a single-sensor embedded classifier on a microcontroller (Chapter 59), it is overkill; a bare inference loop is simpler and lighter. The decision hinges on graph complexity, not on prestige. And a running caution on evaluation: a perception node that passes in a simulator can still fail on hardware purely from QoS, stamping, or frame errors, so the leakage-safe discipline of validating on real recorded rosbags, not just simulation, applies to integration exactly as it applies to models.

Exercise

Take the FusionNode above and record a rosbag containing /camera/image_raw at 30 Hz and /lidar/points at 10 Hz with realistic jitter. (a) Sweep the slop parameter from 5 ms to 100 ms and plot the fraction of lidar messages that get paired versus the mean absolute stamp difference of the pairs; identify the knee. (b) Deliberately set the subscriber QoS to Reliable while leaving the publisher BestEffort and confirm from the node logs that zero pairs arrive. (c) Republish the cloud with arrival-time stamps instead of capture-time stamps, transform a static wall through tf2 during a simulated turn, and measure the induced position error in centimeters as a function of angular velocity.

Self-check

1. A subscriber receives no messages but the topic clearly has a publisher and correct name. Name the two most likely non-code causes and how you would confirm each.
2. Why must a PointCloud2 carry the sensor's capture timestamp rather than its arrival timestamp, and what geometric error results from getting this wrong during motion?
3. When does composing several perception nodes into one process meaningfully reduce latency, and what specifically is being avoided?

Lab 57

Build a small robot perception pipeline in simulation/ROS 2 fusing IMU, depth, and lidar. Stand up driver nodes (or bag playback) for an IMU, a depth camera, and a lidar; publish correct static transforms into tf2; write a fusion node that uses an approximate-time synchronizer to pair depth and lidar and looks up the IMU-anchored odom to base transform at each pair's stamp; feed the transformed obstacles into a Nav2 costmap and confirm the robot plans around them. Validate on a recorded rosbag, not only live simulation, and log per-pair stamp differences to verify your synchronizer's slop is tight.

Bibliography

Framework and middleware

Macenski, Foote, Gerkey, Lalancette, Woodall (2022). Robot Operating System 2: Design, architecture, and uses in the wild. Science Robotics 7(66).

The authoritative overview of ROS 2's DDS-based architecture, QoS model, and real-world deployments; the reference to cite for why ROS 2 differs from ROS 1.

Object Management Group (2015). Data Distribution Service (DDS) Specification, v1.4.

The formal pub-sub standard underneath ROS 2, defining discovery, reliability, and the QoS policies that govern every topic connection.

Transforms and time

Foote (2013). tf: The transform library. IEEE Conference on Technologies for Practical Robot Applications (TePRA).

The design of the time-stamped transform tree that became tf2; explains interpolation and the frame-consistency guarantees perception relies on.

Navigation and perception stacks

Macenski, Martin, White, Ginés Clavero (2020). The Marathon 2: A navigation system. IEEE/RSJ IROS.

The Nav2 architecture paper, showing how costmaps consume fused, transformed perception and how lifecycle-managed nodes structure a deployable stack.

Meeussen (2010). REP 105: Coordinate Frames for Mobile Platforms.

The convention that fixes the map / odom / base_link frame hierarchy; the shared contract that lets independent perception and estimation nodes interoperate.

Documentation and tooling

Open Robotics (2024). ROS 2 Documentation: Quality of Service settings.

The practical reference for reliability, history, and durability policies and the incompatibility rules behind silent "no data" failures.

Open Robotics (2024). message_filters package: ApproximateTime and ExactTime synchronizers.

Source and documentation for the multi-topic synchronizers used to pair measurements from different sensors at matched timestamps.

What's Next

With perception wired into a running ROS 2 graph, the robot can see, transform, and fuse the world in real time. In Chapter 58, we close the loop from perception to action: vision-language-action models that map raw sensor streams and natural-language goals directly to robot commands, turning the perceptual substrate of this chapter into embodied behavior.