Vaibhav Shende Vaibhav Shende

ROS 2 TF2: Coordinate Frames & Transforms (The Right Way)

Master ROS 2 TF2: coordinate frame hierarchy, broadcasting transforms, listening to frames, and the frame hell that will cost you 8 hours of debugging.

ROS 2

ROS 2 TF2: Coordinate Frames & Transforms (The Right Way)

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_link

2.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:
        pass

Why 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'] everywhere

Gotcha 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 backwards

Golden 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 transform

Gotcha 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 moving

Rule 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 collected

Solution: 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.pdf

Quick 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

  1. TF2 is a coordinate frame tree - understand the hierarchy
  2. Static transforms use static_transform_publisher
  3. Dynamic transforms use TransformBroadcaster in Python
  4. Always include timestamps in transform messages
  5. Always use timeouts when listening
  6. Publish frequently (20+ Hz for dynamic transforms)
  7. Test frame connections with tf2_echo

The golden rule of TF2: If you don’t understand the tree structure, your robot will be confused.