tf2

Coordinate frames and the transform tree: what tf2 guarantees, how transforms are broadcast and looked up, and the composition maths worked through numerically.
Author

Benedict Thekkel

Frames and the Transform Tree

Every spatial quantity on a robot is expressed in some frame, and tf2 is the service that converts between them. A PoseStamped without a frame_id is not a pose, it is three numbers.

tf2 maintains a tree, not a graph. Each frame has exactly one parent, which is what makes a lookup between any two frames a unique walk up to the common ancestor and back down. The conventional tree, fixed by REP-105:

map                 world-fixed, may jump when localisation corrects
 +-- odom           continuous and smooth, drifts without bound
      +-- base_link the robot body, at the rotation centre
           +-- laser
           +-- camera_link
           +-- imu_link

The division of labour between map and odom is the part worth internalising. odom is guaranteed continuous: it never jumps, so a controller can differentiate it safely, but it drifts. map is guaranteed accurate on average: it does not drift, but it jumps when AMCL or a SLAM backend corrects the estimate. A velocity controller reads odom; a path planner reads map. Reading map in a control loop gives a jerk every time localisation corrects.

Two hard rules follow from “tree, not graph”:

  • One parent per frame. Two nodes publishing a transform for the same child frame is the single most common tf2 failure, and the symptom is a frame that visibly flickers between two poses in RViz.
  • No cycles, and no disconnected islands. A lookup between frames in two separate trees fails with ConnectivityException, which reads as a missing transform rather than as two trees.

Broadcasters and Listeners

A transform is published on /tf (dynamic, continuously updated) or /tf_static (fixed forever, latched). Nothing enforces which you use, but the choice matters: /tf_static is published once with a transient-local profile, so late joiners still receive it, while /tf must be republished continuously and is only visible from the moment you subscribe.

from tf2_ros import TransformBroadcaster, StaticTransformBroadcaster
from geometry_msgs.msg import TransformStamped

class Publisher(Node):
    def __init__(self):
        super().__init__("frames")
        self.static_br = StaticTransformBroadcaster(self)
        self.br = TransformBroadcaster(self)

        t = TransformStamped()
        t.header.stamp = self.get_clock().now().to_msg()
        t.header.frame_id = "base_link"        # parent
        t.child_frame_id = "laser"            # child
        t.transform.translation.x = 0.2
        t.transform.translation.z = 0.3
        t.transform.rotation.w = 1.0          # identity: w=1, not all zeros
        self.static_br.sendTransform(t)       # once is enough

t.transform.rotation.w = 1.0 is not optional. A default-constructed quaternion is (0,0,0,0), which is not a rotation; tf2 rejects it with “Quaternion not normalized” and the frame never appears.

Looking one up:

from tf2_ros import Buffer, TransformListener, LookupException, ExtrapolationException
import rclpy
from rclpy.duration import Duration

class Consumer(Node):
    def __init__(self):
        super().__init__("consumer")
        self.buffer = Buffer()
        self.listener = TransformListener(self.buffer, self)   # keep a reference

    def where_is_laser(self):
        try:
            return self.buffer.lookup_transform(
                "map", "laser", rclpy.time.Time(),         # Time() = latest available
                timeout=Duration(seconds=0.1))
        except (LookupException, ExtrapolationException) as e:
            self.get_logger().warn(f"no transform: {e}", throttle_duration_sec=2.0)

Three things that bite:

  • Keep a reference to the TransformListener. Assigning it to a local that goes out of scope silently stops the buffer filling, and every lookup then fails with LookupException.
  • The buffer needs time to fill. A lookup in __init__, or in the first callback after startup, usually fails. The timeout argument is what makes it wait.
  • rclpy.time.Time() means “the latest I have”, which is not the same as “now”. Asking for self.get_clock().now() frequently raises ExtrapolationException, because the transform for this instant has not been published yet.

Composing Transforms

A transform is a rotation plus a translation, which composes cleanly as a 4x4 homogeneous matrix. Walking a tf2 chain is matrix multiplication in the right order, and doing it by hand once makes the failure modes obvious.


import numpy as np
from scipy.spatial.transform import Rotation as R

def make_tf(xyz, rpy):
    "4x4 homogeneous transform from a translation and fixed-axis roll-pitch-yaw (the URDF convention)."
    T = np.eye(4)
    T[:3, :3] = R.from_euler("xyz", rpy).as_matrix()
    T[:3, 3] = xyz
    return T

def chain(*tfs):
    "Compose parent->child transforms left to right: chain(A_B, B_C) is A_C."
    out = np.eye(4)
    for T in tfs:
        out = out @ T
    return out

def inv_tf(T):
    "Inverse of a rigid transform, without a general matrix inverse."
    Ti = np.eye(4)
    Ti[:3, :3] = T[:3, :3].T
    Ti[:3, 3] = -T[:3, :3].T @ T[:3, 3]
    return Ti

# the conventional chain, with the robot 2 m east and 1 m north of the map origin,
# rotated 30 deg, and its base rotated another 15 deg within odom
T_map_odom   = make_tf([2.0, 1.0, 0.0], [0, 0, np.deg2rad(30)])
T_odom_base  = make_tf([0.5, 0.0, 0.0], [0, 0, np.deg2rad(15)])
T_base_laser = make_tf([0.2, 0.0, 0.3], [0, 0, 0])

T_map_laser = chain(T_map_odom, T_odom_base, T_base_laser)
print("laser origin in map:", T_map_laser[:3, 3].round(4))
print("laser yaw in map   :", round(np.rad2deg(R.from_matrix(T_map_laser[:3, :3]).as_euler("xyz")[2]), 4), "deg")
laser origin in map: [2.5744 1.3914 0.3   ]
laser yaw in map   : 45.0 deg
# A lidar return 3 m straight ahead of the laser, expressed in map coordinates.
# This is exactly what tf2_geometry_msgs.do_transform_point does for you.
p_laser = np.array([3.0, 0.0, 0.0, 1.0])
p_map = T_map_laser @ p_laser
print("point in laser frame:", p_laser[:3])
print("point in map frame  :", p_map[:3].round(4))

# the inverse undoes it exactly
assert np.allclose(inv_tf(T_map_laser) @ p_map, p_laser, atol=1e-12)
assert np.allclose(inv_tf(T_map_laser) @ T_map_laser, np.eye(4), atol=1e-12)

# yaw adds along the chain for rotations that share an axis
yaw = np.rad2deg(R.from_matrix(T_map_laser[:3, :3]).as_euler("xyz")[2])
assert abs(yaw - 45.0) < 1e-9

# and order is not negotiable: swapping two links gives a different pose
assert not np.allclose(chain(T_odom_base, T_map_odom, T_base_laser), T_map_laser)
print("composition, inversion and ordering all check out")
point in laser frame: [3. 0. 0.]
point in map frame  : [4.6958 3.5127 0.3   ]
composition, inversion and ordering all check out
# The wire format is a quaternion, not Euler angles. scipy's as_quat() is (x, y, z, w),
# the same ordering as geometry_msgs/Quaternion, so the fields map across directly.
q = R.from_matrix(T_map_laser[:3, :3]).as_quat()
print("quaternion xyzw:", q.round(6), " norm:", round(float(np.linalg.norm(q)), 12))
assert abs(np.linalg.norm(q) - 1.0) < 1e-12

# Euler angles are for humans. Round-tripping through them is lossy near gimbal lock:
for pitch_deg in [0.0, 45.0, 89.9, 90.0]:
    T = make_tf([0, 0, 0], [0.3, np.deg2rad(pitch_deg), 0.7])
    rpy = R.from_matrix(T[:3, :3]).as_euler("xyz")
    err = np.abs(rpy - np.array([0.3, np.deg2rad(pitch_deg), 0.7])).max()
    print(f"  pitch {pitch_deg:5.1f} deg -> round-trip error {err:.2e} rad")
quaternion xyzw: [0.       0.       0.382683 0.92388 ]  norm: 1.0
  pitch   0.0 deg -> round-trip error 5.55e-17 rad
  pitch  45.0 deg -> round-trip error 4.44e-16 rad
  pitch  89.9 deg -> round-trip error 7.44e-14 rad
  pitch  90.0 deg -> round-trip error 7.00e-01 rad
UserWarning: Gimbal lock detected. Setting third angle to zero since it is not possible to uniquely determine all angles.
  rpy = R.from_matrix(T[:3, :3]).as_euler("xyz")

The Frame Tree as a Graph

ros2 run tf2_tools view_frames writes a PDF of the live tree, which is the tool to reach for on a real robot. The same structure drawn here, to make the shape of a conventional tree concrete:


from pyecharts.charts import Tree
from pyecharts import options as opts

tree_data = [{
    "name": "map",
    "children": [{
        "name": "odom",
        "children": [{
            "name": "base_link",
            "children": [
                {"name": "laser"},
                {"name": "camera_link", "children": [{"name": "camera_optical_frame"}]},
                {"name": "imu_link"},
                {"name": "wheel_left"},
                {"name": "wheel_right"},
            ]}]}]}]

(Tree(init_opts=opts.InitOpts(width="820px", height="380px"))
 .add("", tree_data, orient="LR", symbol_size=9,
      label_opts=opts.LabelOpts(position="right", font_size=12))
 .set_global_opts(title_opts=opts.TitleOpts(
     title="A conventional tf2 tree",
     subtitle="map and odom are world-fixed; everything under base_link is usually static"))
 ).render_notebook()

Looking Up a Transform at a Time

tf2 is not a dictionary of current poses, it is a time-indexed buffer, and this is the part that causes the most confusion. Every lookup names an instant, and tf2 interpolates between the two surrounding samples.

# the transform as it was when this scan was taken, not as it is now
t = buffer.lookup_transform("map", scan.header.frame_id, scan.header.stamp,
                            timeout=Duration(seconds=0.1))

Using scan.header.stamp rather than “now” is the whole point: by the time a 20 Hz perception callback runs, the robot has moved, and transforming the scan with the current pose smears it.

The failures all come from the time argument:

Exception Means
LookupException the frame has never been seen; usually a typo or a dead publisher
ConnectivityException both frames exist but in disconnected trees
ExtrapolationException the frame exists, the requested time does not: too old (past the buffer) or too new (not published yet)

The buffer holds 10 seconds by default (Buffer(cache_time=Duration(seconds=30)) to extend it). Processing a bag slower than real time, or asking about a stamp from a minute ago, walks off the back of it.

The clock has to agree. If publishers stamp with wall time and a consumer runs with use_sim_time: true, every stamp looks like it is from 1970 and every lookup fails with extrapolation errors, which reads as a tf problem and is a time problem. See 02_Conventions_and_Time.ipynb.


Debugging a Broken Tree

In rough order of how often each is the answer:

ros2 run tf2_tools view_frames                    # writes frames.pdf: the whole tree
ros2 run tf2_ros tf2_echo map base_link           # stream one transform
ros2 topic hz /tf                                 # is anything being published at all
ros2 topic echo /tf_static --once                 # the latched ones
Symptom Cause
frame missing entirely publisher not running, or a name typo; check view_frames
frame flickers between two poses two publishers for one child frame
“Quaternion not normalized” a default-constructed quaternion, w left at 0
works for a second then fails dynamic transform published once instead of continuously
extrapolation errors only clock mismatch, or use_sim_time set on some nodes and not others
RViz shows nothing Fixed Frame is not in the tree
lookups fail in one node only the TransformListener was not kept alive

view_frames is worth running before anything else, because it prints the publisher and the average rate for every edge, which immediately distinguishes “never published” from “published too slowly”.


Back to top