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, StaticTransformBroadcasterfrom geometry_msgs.msg import TransformStampedclass 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 zerosself.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, ExtrapolationExceptionimport rclpyfrom rclpy.duration import Durationclass Consumer(Node):def__init__(self):super().__init__("consumer")self.buffer= Buffer()self.listener = TransformListener(self.buffer, self) # keep a referencedef where_is_laser(self):try:returnself.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 npfrom scipy.spatial.transform import Rotation as Rdef 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] = xyzreturn Tdef 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 @ Treturn outdef 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 odomT_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_laserprint("point in laser frame:", p_laser[:3])print("point in map frame :", p_map[:3].round(4))# the inverse undoes it exactlyassert 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 axisyaw = np.rad2deg(R.from_matrix(T_map_laser[:3, :3]).as_euler("xyz")[2])assertabs(yaw -45.0) <1e-9# and order is not negotiable: swapping two links gives a different poseassertnot 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))assertabs(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")
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 Treefrom pyecharts import options as optstree_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 nowt =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 treeros2 run tf2_ros tf2_echo map base_link # stream one transformros2 topic hz /tf # is anything being published at allros2 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”.