Understanding TF2: Coordinate Frames in ROS2
TF2 is the ROS2 library for tracking coordinate frames. Understanding it is essential for any robot that needs to know where things are in 3D space.
Your LiDAR reports an obstacle “0.5 meters away at 30 degrees.” But 30 degrees from what? Measured where — at the sensor, or at the robot’s center? Without knowing which coordinate frame that reading lives in, the number is meaningless. TF2 is the ROS2 library that keeps track of all these frames and the relationships between them, so any node can ask “where is this point in some other frame?” and get a correct answer.
Why Coordinate Frames Matter
Every measurement on a robot is taken relative to something. A wheel encoder measures relative to the chassis; a camera measures relative to its lens; a navigation goal is given relative to the world. A coordinate frame is just a labeled reference point and orientation (an origin and a set of X/Y/Z axes). A piece of sensor data is only useful once you know which frame it was measured in — and how that frame relates to the others.
A transform is the recipe to convert a point from one frame into another: a translation (shift) plus a rotation. TF2 stores all the transforms on your robot and lets any node convert between any two connected frames.
The Transform Tree
TF2 organizes every frame into a single connected tree. The rule that makes a tree (rather than a tangle) is strict: each frame has exactly one parent, though a frame may have many children. A typical mobile robot tree looks like this:
map
└── odom
└── base_link
└── laser
Reading it: laser is mounted on base_link (the robot body), base_link moves within odom (a smooth, drift-prone odometry frame), and odom is positioned within map (the fixed world frame). Because the structure is a tree, there’s always exactly one path between any two frames — so TF2 can chain the transforms together to compute, say, where the laser is in the map frame.
REP-105: The Standard Frames
To keep robots interoperable, ROS defines a convention called REP-105 for the names and meaning of the common frames:
map: a fixed world frame. Accurate over the long term but can “jump” when localization corrects itself.odom: continuous and smooth (great for short-term control) but drifts over time.base_link: rigidly attached to the robot’s body.
Sticking to these names means tools like Nav2 and RViz understand your robot without custom configuration.
Static vs Dynamic Transforms
- Static transforms never change: the laser is bolted to the chassis, so
base_linktolaseris fixed forever. Publish it once. - Dynamic transforms change over time:
odomtobase_linkupdates constantly as the robot drives.
Broadcasting a Static Transform from the Command Line
For a fixed sensor mount, you don’t even need to write code. The argument order is translation (x y z), then rotation (yaw pitch roll in radians), then parent and child frame:
ros2 run tf2_ros static_transform_publisher 0.1 0 0.2 0 0 0 base_link laser
This says the laser frame sits 10 cm forward and 20 cm above base_link, with no rotation.
Inspecting the Transform Tree
Two tools answer “is my tree correct?”:
# Generate a PDF diagram of the entire transform tree
ros2 run tf2_tools view_frames
# Print the live transform from one frame to another
ros2 run tf2_ros tf2_echo base_link laser
view_frames is the first thing to run whenever transforms misbehave — it shows the whole tree, which frames are connected, and who’s publishing them.
A Minimal Python Example
Here’s a node that broadcasts a static transform in code, and a second node that listens for a transform and looks it up.
Broadcasting (using StaticTransformBroadcaster):
import rclpy
from rclpy.node import Node
from tf2_ros import StaticTransformBroadcaster
from geometry_msgs.msg import TransformStamped
class StaticFramePublisher(Node):
def __init__(self):
super().__init__('static_frame_publisher')
self.broadcaster = StaticTransformBroadcaster(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.1
t.transform.translation.z = 0.2
t.transform.rotation.w = 1.0 # identity rotation
self.broadcaster.sendTransform(t)
def main():
rclpy.init()
node = StaticFramePublisher()
rclpy.spin(node)
rclpy.shutdown()
if __name__ == '__main__':
main()
Listening with a Buffer and TransformListener:
import rclpy
from rclpy.node import Node
from tf2_ros import Buffer, TransformListener
from rclpy.time import Time
class FrameListener(Node):
def __init__(self):
super().__init__('frame_listener')
self.buffer = Buffer()
self.listener = TransformListener(self.buffer, self)
self.timer = self.create_timer(1.0, self.on_timer)
def on_timer(self):
try:
# Time() (time zero) means "the latest available transform"
tf = self.buffer.lookup_transform('base_link', 'laser', Time())
self.get_logger().info(f'laser x in base_link: {tf.transform.translation.x}')
except Exception as e:
self.get_logger().warn(f'Could not transform: {e}')
def main():
rclpy.init()
node = FrameListener()
rclpy.spin(node)
rclpy.shutdown()
if __name__ == '__main__':
main()
Common Pitfalls
- “Extrapolation into the future” errors: you asked for a transform at a timestamp newer than anything TF2 has received yet. Looking up at
Time()(time zero, meaning “latest available”) avoids this; otherwise make sure transforms are arriving fast enough. - “Could not find a connection between frames”: the two frames aren’t in the same tree, usually because a transform isn’t being published. Run
view_framesto spot the gap. - Multiple parents break the tree: if two different nodes both publish a transform for the same child frame, you’ve created a conflict — the frame now has two parents and the tree becomes inconsistent. Each frame must have exactly one publisher.
- Time synchronization: on multi-machine robots, clocks that disagree make transforms look stale or future-dated. Keep clocks in sync (e.g. with NTP or
use_sim_timein simulation).
With coordinate frames under control, you have the foundation to position every part of a robot precisely — next week we start designing your first robot chassis.