gwordal

Lesson 4 of 5 · 24 min

Coordinate frames and tf2

A lidar reports an obstacle 2 m ahead. Ahead of what? Of the lidar, which is bolted 20 cm in front of the robot's centre, which in turn is somewhere in a room. Every sensor reading and every command is only meaningful relative to a coordinate frame, and a robot has many of them. The tf2 library keeps track of how all frames relate, so that your code never has to do that bookkeeping by hand.

Frames and transforms

A frame is an origin plus three axes. ROS follows the right-handed convention: x forward, y left, z up. A transform says where one frame sits relative to another: a translation (x, y, z) and a rotation. Rotations are stored as quaternions (x, y, z, w) rather than roll, pitch and yaw, because quaternions have no gimbal lock and interpolate cleanly. For a robot turning only around the vertical axis, the quaternion is simply z = sin(yaw / 2) and w = cos(yaw / 2).

Transforms are directional and composable. If you know where the laser is relative to the base, and the base relative to the odometry origin, you can get the laser relative to the odometry origin by multiplying them. This is the whole job of tf2.

The tree

Frames form a tree: every frame has exactly one parent. This constraint is deliberate. With a single parent there is exactly one path between any two frames, so there is never an ambiguity, and lookups are fast. The standard layout (REP 105) is:

mapodombase_linklidar_linkcamera_link
Each arrow is a transform. Chain them to express a lidar point in the map frame.
FrameMeaningBehaviour
mapFixed world frameDoes not drift, but may jump when localization corrects itself
odomFixed frame from wheel odometrySmooth and continuous, but drifts over time
base_linkRigidly attached to the robot bodyMoves with the robot
laser, camera_link, ...Sensors on the robotFixed relative to base_link

Why two world frames? A navigation planner needs a smooth pose to control motors without sudden jumps, so it uses odom. A planner that follows a global route needs a pose that does not drift, so it uses map. A localization node reconciles them by publishing the map to odom transform, absorbing each correction there. Neither consumer sees the other's problem.

Static transforms

Fixed relationships, like a sensor bolted to the chassis, are published once as static transforms. From the command line, place a laser 10 cm forward and 20 cm up from the base:

ros2 run tf2_ros static_transform_publisher \
  --x 0.1 --y 0 --z 0.2 --roll 0 --pitch 0 --yaw 0 \
  --frame-id base_link --child-frame-id laser

The static broadcaster uses a latched, transient local topic (/tf_static), the same QoS idea from lesson 2, so nodes that start later still receive it. Dynamic transforms go on /tf and must be refreshed continuously.

Broadcasting from code

This node pretends to be a wheel odometry source, driving the robot in a circle of radius 1 m and publishing odom to base_link at 20 Hz. It also publishes the static laser transform.

import math
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import TransformStamped
from tf2_ros import TransformBroadcaster, StaticTransformBroadcaster


class FakeOdom(Node):
    def __init__(self):
        super().__init__('fake_odom')
        self.br = TransformBroadcaster(self)
        self.static_br = StaticTransformBroadcaster(self)
        self.publish_static()
        self.t = 0.0
        self.create_timer(0.05, self.tick)

    def publish_static(self):
        tf = TransformStamped()
        tf.header.stamp = self.get_clock().now().to_msg()
        tf.header.frame_id = 'base_link'
        tf.child_frame_id = 'laser'
        tf.transform.translation.x = 0.1
        tf.transform.translation.z = 0.2
        tf.transform.rotation.w = 1.0
        self.static_br.sendTransform(tf)

    def tick(self):
        self.t += 0.05
        yaw = 0.5 * self.t   # heading grows at 0.5 rad/s
        radius = 1.0
        tf = TransformStamped()
        tf.header.stamp = self.get_clock().now().to_msg()
        tf.header.frame_id = 'odom'
        tf.child_frame_id = 'base_link'
        tf.transform.translation.x = radius * math.sin(yaw)
        tf.transform.translation.y = radius * (1.0 - math.cos(yaw))
        tf.transform.rotation.z = math.sin(yaw / 2.0)
        tf.transform.rotation.w = math.cos(yaw / 2.0)
        self.br.sendTransform(tf)


def main():
    rclpy.init()
    node = FakeOdom()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.try_shutdown()


if __name__ == '__main__':
    main()

Every transform carries a timestamp, because tf2 stores a short history of each one (10 seconds by default). That lets you ask where the laser was when a scan was captured, which matters for a moving robot: using the current pose for an old scan smears the data.

Looking up a transform

Join the tree at the top with an identity transform, then ask for the laser's pose in the map frame:

ros2 run tf2_ros static_transform_publisher --frame-id map --child-frame-id odom
import rclpy
from rclpy.node import Node
from rclpy.time import Time
from tf2_ros import Buffer, TransformListener, TransformException


class Where(Node):
    def __init__(self):
        super().__init__('where')
        self.buffer = Buffer()
        self.listener = TransformListener(self.buffer, self)
        self.create_timer(1.0, self.tick)

    def tick(self):
        try:
            # Time() means "latest available"
            t = self.buffer.lookup_transform('map', 'laser', Time())
        except TransformException as e:
            self.get_logger().warn(f'no transform yet: {e}')
            return
        p = t.transform.translation
        self.get_logger().info(f'laser in map: x={p.x:.2f} y={p.y:.2f}')


def main():
    rclpy.init()
    node = Where()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.try_shutdown()


if __name__ == '__main__':
    main()

The listener asks for map to laser, a pair that nobody publishes directly. tf2 chains map to odom to base_link to laser and multiplies. Catch TransformException: at startup the buffer is empty, and lookups also fail if the tree is broken.

Debugging the tree

ros2 run tf2_tools view_frames          # writes frames.pdf of the whole tree
ros2 run tf2_ros tf2_echo map laser     # print one transform continuously

Open the PDF first whenever something is "in the wrong place". Two roots, a missing link, or a frame named base_footprint where you expected base_link shows up immediately.

Check yourself

Why do robots use both an odom frame and a map frame?

Check yourself

A node publishes base_link to laser at startup only, with a normal /tf broadcaster. Later listeners cannot find it. What is the best fix?