Why TF2 Exists
Imagine you have a robot with:
- A base link (the chassis)
- A lidar sensor (mounted 0.1m forward)
- A camera (mounted 0.05m up and rotated 45 degrees)
- An arm with 3 joints
Every node publishes data in its own coordinate frame. Lidar publishes in the “lidar_link” frame. Camera in “camera_frame”. The arm’s endpoints in whatever the engineer named them.
How do you convert between all of these? TF2.
TF2 maintains a tree of coordinate frames and the transformations between them. It’s the backbone of any functioning ROS 2 robot.
But it’s also where robots mysteriously stop working because someone published a transform with the wrong parent frame.
Part 1: The TF2 Tree (What You Must Understand)
1.1 The Hierarchy
world (global reference)
|
+-- odom (odometry frame, changes over time)
| |
| +-- base_footprint (projection on ground)
| |
| +-- base_link (center of robot)
| |
| +-- lidar_link
| | |
| | +-- lidar_optical
| |
| +-- camera_link
| |
| +-- camera_optical
|
+-- map (global static map)
This tree is a DAG (Directed Acyclic Graph). Each frame has:
- A parent frame
- A position (x, y, z)
- A rotation (quaternion or RPY)
- A timestamp
Golden rule: You cannot have cycles. If base_link has parent odom and odom has parent base_link, TF2 will cry.
1.2 The Static vs. Dynamic Distinction
Static transforms don’t change:
- Lidar mounted 0.1m forward on base_link
- Camera 0.05m up on base_link
- These are published once and never change
Dynamic transforms change constantly:
- Robot’s position relative to odom (odometry)
- Joint angles in an arm
- These are published every 10-50ms
Part 2: Broadcasting Transforms (Publishing)
2.1 Static Transforms (The Easy Way)
Use static_transform_publisher in your launch file:
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
# Static transform: lidar is 0.1m forward, 0.05m up on base_link
lidar_tf = Node(
package='tf2_ros',
executable='static_transform_publisher',
arguments=[
'--x', '0.1', # X offset (forward)
'--y', '0.0', # Y offset (left)
'--z', '0.05', # Z offset (up)
'--frame-id', 'base_link',
'--child-frame-id', 'lidar_link'
]
)
return LaunchDescription([lidar_tf])Or in command line:
ros2 run tf2_ros static_transform_publisher \
--x 0.1 --y 0 --z 0.05 \
--frame-id base_link \
--child-frame-id lidar_link2.2 Static Transforms with Rotation
# Rotate 45 degrees around Z axis
camera_tf = Node(
package='tf2_ros',
executable='static_transform_publisher',
arguments=[
'--x', '0.05',
'--y', '0.0',
'--z', '0.1',
'--roll', '0.0', # Rotation around X
'--pitch', '0.0', # Rotation around Y
'--yaw', '0.7854', # Rotation around Z (45 degrees in radians)
'--frame-id', 'base_link',
'--child-frame-id', 'camera_link'
]
)2.3 Broadcasting Dynamic Transforms (from Python)
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import TransformStamped
from tf2_ros import TransformBroadcaster
import math
class OdometryPublisher(Node):
def __init__(self):
super().__init__('odom_publisher')
# Create a transform broadcaster
self.broadcaster = TransformBroadcaster(self)
# Timer to publish transforms periodically
self.create_timer(0.05, self.publish_transform) # 20 Hz
self.x = 0.0
self.y = 0.0
self.theta = 0.0
def publish_transform(self):
"""Publish the robot's position relative to odom frame"""
# Create a transform message
t = TransformStamped()
t.header.stamp = self.get_clock().now().to_msg()
t.header.frame_id = 'odom' # Parent frame
t.child_frame_id = 'base_link' # Child frame
# Position
t.transform.translation.x = self.x
t.transform.translation.y = self.y
t.transform.translation.z = 0.0
# Rotation (convert angle to quaternion)
q = self.euler_to_quaternion(0, 0, self.theta)
t.transform.rotation.x = q[0]
t.transform.rotation.y = q[1]
t.transform.rotation.z = q[2]
t.transform.rotation.w = q[3]
# Publish the transform
self.broadcaster.sendTransform(t)
# Simulate robot motion (you'd get this from actual odometry)
self.x += 0.01
self.theta += 0.01
@staticmethod
def euler_to_quaternion(roll, pitch, yaw):
"""Convert Euler angles to quaternion"""
cy = math.cos(yaw * 0.5)
sy = math.sin(yaw * 0.5)
cp = math.cos(pitch * 0.5)
sp = math.sin(pitch * 0.5)
cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5)
w = cr * cp * cy + sr * sp * sy
x = sr * cp * cy - cr * sp * sy
y = cr * sp * cy + sr * cp * sy
z = cr * cp * sy - sr * sp * cy
return [x, y, z, w]Part 3: Listening to Transforms (Reading)
3.1 Basic Transform Lookup
import rclpy
from rclpy.node import Node
from tf2_ros.buffer import Buffer
from tf2_ros.transform_listener import TransformListener
from geometry_msgs.msg import PointStamped
class TransformConsumer(Node):
def __init__(self):
super().__init__('transform_consumer')
# Create buffer to store transforms
self.buffer = Buffer()
# Listen for transforms
self.listener = TransformListener(self.buffer, self)
# Timer to query transforms
self.create_timer(0.1, self.query_transform)
def query_transform(self):
"""Look up a transform"""
try:
# Get transform from 'odom' to 'base_link'
transform = self.buffer.lookup_transform(
target_frame='base_link',
source_frame='odom',
time=rclpy.time.Time() # Latest transform
)
x = transform.transform.translation.x
y = transform.transform.translation.y
z = transform.transform.translation.z
self.get_logger().info(f'Robot position: ({x:.2f}, {y:.2f}, {z:.2f})')
except Exception as e:
self.get_logger().error(f'Transform lookup failed: {e}')Important: time=rclpy.time.Time() means “latest available transform”. This is usually what you want.
3.2 The Deadly Gotcha: Waiting for Transforms
def query_transform(self):
try:
# ❌ WRONG: If transform doesn't exist yet, this hangs
transform = self.buffer.lookup_transform(
target_frame='base_link',
source_frame='odom',
time=rclpy.time.Time()
)
except Exception:
passWhy it hangs: If the transform broadcaster hasn’t published yet, lookup_transform will block.
Solution: Use a timeout.
from tf2_ros import LookupException, ConnectivityException, ExtrapolationException
def query_transform(self):
try:
transform = self.buffer.lookup_transform(
target_frame='base_link',
source_frame='odom',
time=rclpy.time.Time(),
timeout=rclpy.duration.Duration(seconds=1.0) # Wait up to 1 second
)
except (LookupException, ConnectivityException, ExtrapolationException):
self.get_logger().warn('Transform not available yet')3.3 Transform Point Data (Real-World Use Case)
from geometry_msgs.msg import PointStamped
import tf2_geometry_msgs
class PointTransformer(Node):
def __init__(self):
super().__init__('point_transformer')
self.buffer = Buffer()
self.listener = TransformListener(self.buffer, self)
def transform_point(self):
"""Transform a point from one frame to another"""
# Create a point in the lidar frame
point_in_lidar = PointStamped()
point_in_lidar.header.frame_id = 'lidar_link'
point_in_lidar.header.stamp = self.get_clock().now()
point_in_lidar.point.x = 1.0 # 1 meter away in lidar frame
point_in_lidar.point.y = 0.5
point_in_lidar.point.z = 0.0
try:
# Transform to base_link frame
point_in_base = self.buffer.transform(
point_in_lidar,
target_frame='base_link',
timeout=rclpy.duration.Duration(seconds=1.0)
)
x = point_in_base.point.x
y = point_in_base.point.y
z = point_in_base.point.z
self.get_logger().info(f'Point in base_link: ({x:.2f}, {y:.2f}, {z:.2f})')
except Exception as e:
self.get_logger().error(f'Transform failed: {e}')Part 4: The Gotchas That Will Destroy Your Robot
Gotcha 1: Frame ID Typos
# ❌ Published with:
t.header.frame_id = 'base_link'
t.child_frame_id = 'camera_link'
# ❌ But listening for:
self.buffer.lookup_transform('base_link', 'camera_frame') # Typo!Result: “Frame camera_frame does not exist”
Prevention: Use constants.
FRAMES = {
'ODOM': 'odom',
'BASE_LINK': 'base_link',
'LIDAR': 'lidar_link',
'CAMERA': 'camera_link',
}
# Then use FRAMES['CAMERA'] everywhereGotcha 2: Static Transform In the Wrong Direction
# ❌ Wrong: Publishing camera -> base_link when it should be base_link -> camera
t.header.frame_id = 'camera_link'
t.child_frame_id = 'base_link'
# The tree gets confused because it's backwardsGolden rule: Always publish from parent to child (base_link to lidar, not lidar to base_link).
Gotcha 3: Forgetting Timestamps
t.header.stamp = self.get_clock().now().to_msg() # ✅ Include this!
# Without timestamp, TF2 won't use the transformGotcha 4: Publishing Transforms Too Slowly
# ❌ Publishing odometry transforms once per second
self.create_timer(1.0, self.publish_transform)
# ❌ But your navigation stack expects 20 Hz updates
# Result: Navigation thinks robot is not movingRule of thumb: Publish at 20-50 Hz for dynamic transforms.
Gotcha 5: The Time Barrier
# You publish a transform at t=1.0
# You try to lookup transform at t=2.0, but...
# The transform doesn't exist because buffer only stores recent history
# Time moves forward, old transforms get garbage collectedSolution: Always use time=rclpy.time.Time() to get the latest.
Part 5: Real-World Pattern - Robot with Multiple Sensors
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
"""
Robot structure:
odom -> base_link -> {lidar_link, camera_link, imu_link}
"""
# Odometry publisher (dynamic)
odom_node = Node(
package='robot_pkg',
executable='odometry_node',
name='odometry'
)
# Static transforms (sensor mounts)
lidar_tf = Node(
package='tf2_ros',
executable='static_transform_publisher',
arguments=[
'--x', '0.1', '--y', '0.0', '--z', '0.05',
'--frame-id', 'base_link',
'--child-frame-id', 'lidar_link'
]
)
camera_tf = Node(
package='tf2_ros',
executable='static_transform_publisher',
arguments=[
'--x', '0.05', '--y', '0.0', '--z', '0.10',
'--roll', '0.0', '--pitch', '0.0', '--yaw', '0.0',
'--frame-id', 'base_link',
'--child-frame-id', 'camera_link'
]
)
imu_tf = Node(
package='tf2_ros',
executable='static_transform_publisher',
arguments=[
'--x', '0.0', '--y', '0.0', '--z', '0.08',
'--frame-id', 'base_link',
'--child-frame-id', 'imu_link'
]
)
return LaunchDescription([
odom_node,
lidar_tf,
camera_tf,
imu_tf,
])Debugging Transforms
# View the entire transform tree
ros2 run rqt_tf_tree rqt_tf_tree
# Print a specific transform
ros2 topic echo /tf
# Check if a transform exists
ros2 run tf2_tools tf2_echo odom base_link
# Broadcast a test transform (for debugging)
ros2 run tf2_ros static_transform_publisher 0 0 0 0 0 0 odom base_link
# Dump all transforms to a PDF
ros2 run tf2_tools view_frames.py
# Then: evince frames.pdfQuick Checklist for TF2
- All frame names are unique and consistent
- Parent-child relationships are correct (no cycles)
- Static transforms published before dynamic ones
- Dynamic transforms published frequently enough (20+ Hz)
- All published transforms have correct timestamps
- Using timeout when querying transforms
- No frame ID typos
- Transform tree checked with
ros2 run tf2_tools tf2_echo
Key Takeaways
- TF2 is a coordinate frame tree - understand the hierarchy
- Static transforms use
static_transform_publisher - Dynamic transforms use
TransformBroadcasterin Python - Always include timestamps in transform messages
- Always use timeouts when listening
- Publish frequently (20+ Hz for dynamic transforms)
- Test frame connections with
tf2_echo
The golden rule of TF2: If you don’t understand the tree structure, your robot will be confused.