ROS 2 Learning Path  ยท  Illustrated Educational Book

Chapter 5: TF2 and Coordinate Frames

How ARCHO knows where each of its parts is
Prerequisite: Chapter 4 (URDF/Xacro)
Ongoing project: ARCHO robot
Tools: tf2_echo, view_frames
Reading time: 80โ€“100 minutes
What's in this chapter 5.1Why a robot needs a coordinate system 5.2Transform, Frame, and the TF Tree 5.3Static vs. Dynamic Transforms 5.4robot_state_publisher: from URDF to live TF 5.5The navigation chain: map โ†’ odom โ†’ base_link 5.6Inspecting TF from the command line 5.7Common TF errors 5.8Summary, glossary, and exercises

5.1Why a robot needs a coordinate system

In the previous chapter, ARCHO got a body: wheels, a caster wheel, mounting points for the LiDAR and IMU. But one simple question is still unanswered: when the LiDAR says "there's an obstacle 2 meters from me," where exactly is "me"? And is that 2 meters measured from the center of the robot, the edge of the body, or the LiDAR itself, mounted a few centimeters ahead of center?

This is exactly the problem TF2 (the second version of ROS's Transform library) solves: a standard system for tracking where every part of the robot is, at every moment, relative to the other parts and relative to the surrounding world.

๐Ÿง  Simple Analogy

Imagine you're in an office building and want to give someone directions. You could say "third floor, room 12" (relative to the building), or "two steps past the elevator" (relative to a local point). Both addresses are correct, just relative to different references. TF2 does exactly this for a robot: it tells each part to define its position relative to its own "parent," and then automatically computes where any point is relative to any other point.

5.2Transform, Frame, and the TF Tree

๐Ÿ“– Definition

A Frame (coordinate frame) is a reference point in space with a defined orientation โ€” for example base_link or laser_link. A Transform is the geometric relationship (translation + rotation) between two Frames. The set of all Transforms for a robot forms a tree: each Frame has exactly one parent, but can have multiple children.

The current TF tree for ARCHO โ€” exactly what we built with URDF in the previous chapter โ€” looks like this:

graph TD F["base_footprint
on the ground"] --> B["base_link
body center"] B --> LW["left_wheel_link"] B --> RW["right_wheel_link"] B --> C["caster_link"] B --> L["laser_link"] B --> I["imu_link"] style F fill:#eafaf3,stroke:#0e9e6e style B fill:#eef0ff,stroke:#3d4bf5

When we ask "where is the LiDAR relative to the ground?", TF2 walks the path laser_link โ†’ base_link โ†’ base_footprint and chains the Transforms together to produce the final answer. You never have to do this calculation by hand yourself โ€” that's exactly what TF2 does behind the scenes for you.

5.3Static vs. Dynamic Transforms

In Chapter 4 we saw two types of joints: fixed for static connections (like the LiDAR mounted on the body), and continuous for moving connections (like the wheels). TF2 preserves exactly this distinction:

Transform TypeExample in ARCHOHow it's published
Staticbase_link โ†’ laser_linkOnce, at startup; never changes
Dynamicbase_link โ†’ left_wheel_linkRepublished every moment the wheel turns
Dynamic (navigation)odom โ†’ base_linkContinuously updated as the robot moves
๐Ÿ”ง Engineering View

Static Transforms are usually published with the static_transform_publisher tool or directly from the URDF, and cost almost no processing overhead, since they're sent only once and cached on the receiving end. Dynamic Transforms are published at a high rate (typically several times per second), because their state is constantly changing.

5.4robot_state_publisher: from URDF to live TF

Now the practical question: who actually builds and publishes these Transforms from the Xacro file we wrote in the previous chapter? The answer is a standard Node called robot_state_publisher.

flowchart LR A["Xacro/URDF
fixed robot structure"] --> B["robot_description
string parameter"] B --> C["robot_state_publisher"] D["/joint_states
current angle of each Joint"] --> C C --> E["Live TF tree"] style C fill:#eef0ff,stroke:#3d4bf5,color:#211f1a,font-weight:bold style E fill:#eafaf3,stroke:#0e9e6e,color:#211f1a

This Node combines two things: the robot's fixed structure (from the URDF โ€” which Link is attached to which Joint) and the current angle of each Joint (from the /joint_states Topic โ€” for example, "the left wheel has now turned 45 degrees"). The result of this combination is a live TF tree that updates every time the wheel angles change.

๐ŸŒ ARCHO's complete motion loop

When we get to Gazebo in the next chapter, this loop becomes complete: a /cmd_vel command goes to ros2_control, Gazebo's wheels turn, the new Joint positions are published on /joint_states, robot_state_publisher picks it up and updates the TF tree, and finally RViz displays that same rotation on the model.

A simple launch file to see this Node in action (with nothing else):

from launch import LaunchDescription
from launch.substitutions import Command
from launch_ros.actions import Node
from launch_ros.parameter_descriptions import ParameterValue
from ament_index_python.packages import get_package_share_directory
import os


def generate_launch_description():
    pkg_path = get_package_share_directory('archo_description')
    xacro_file = os.path.join(pkg_path, 'urdf', 'archo.urdf.xacro')
    robot_description = ParameterValue(
        Command(['xacro ', xacro_file]), value_type=str)

    return LaunchDescription([
        Node(
            package='robot_state_publisher',
            executable='robot_state_publisher',
            parameters=[{'robot_description': robot_description}],
        ),
    ])

5.5The navigation chain: map โ†’ odom โ†’ base_link

The TF tree we've seen so far only covers the robot's fixed body. But for Nav2 (which we'll get to in later chapters) to work, we need a larger chain:

map โ†’ odom โ†’ base_link โ†’ laser_link
Chain linkWho is responsible for publishing it
map โ†’ odomSLAM or AMCL (Chapters 10 and 11)
odom โ†’ base_linkOdometry or robot_localization (Chapter 9)
base_link โ†’ laser_linkrobot_state_publisher (this chapter)
โš ๏ธ A strict TF2 rule

No two Nodes should ever publish the same Transform at the same time. If, for example, both SLAM and another Node try to publish map โ†’ odom, TF2 runs into a conflict and unpredictable behavior follows. Each link in the chain has exactly one designated publisher.

Why is this chain designed in three pieces instead of one direct Transform from map to base_link? Because each piece has a different rate and reliability: Odometry is fast and smooth but accumulates error (drift) over time; SLAM/AMCL is slower but periodically corrects that error by comparing against the map. Keeping these two layers separate makes both accuracy and stability possible at the same time.

5.6Inspecting TF from the command line

To see the relationship between two specific Frames:

dev@archo:~$ ros2 run tf2_ros tf2_echo odom base_link At time 1732000012.4 - Translation: [0.842, 0.113, 0.000] - Rotation: in Quaternion [0.000, 0.000, 0.071, 0.997]

And to see the entire TF tree as a visual diagram:

ros2 run tf2_tools view_frames

This command generates a PDF file showing the entire tree โ€” from map down to the smallest sensor Frame โ€” along with the rate at which each Transform is published and when it was last updated.

Easy Exercise

After running robot_state_publisher with the URDF from the previous chapter, run tf2_echo base_link laser_link. The Translation value should match the origin you wrote in the laser_joint Joint โ€” why?

5.7Common TF errors

ErrorCommon causeSolution
Lookup would require extrapolationTimestamps are out of sync, or TF is published lateCheck the TF publish rate and the system clock
TF doesn't work in simulation but works correctly with real timeThe use_sim_time Parameter isn't setSet use_sim_time: true on all Nodes related to Gazebo
Two identical Transforms from two sourcesTwo Nodes are simultaneously publishing the same chain linkKeep only one publisher per chain link
Wrong Frame in RVizThe wrong Fixed Frame is selectedSet the Fixed Frame to odom or map, not base_link
๐Ÿง  Why use_sim_time matters

When Gazebo is running, simulation time may progress slower or faster than real time (remember the Real-Time Factor from the next chapter with Gazebo?). If a Node relies on the system's real clock while TF is published based on simulation time, TF2's calculations will constantly run into extrapolation errors. Setting use_sim_time: true tells all Nodes to use the same simulation clock.

5.8Chapter 5 Summary

Now ARCHO is no longer just a static body โ€” it has a live coordinate system. We know how robot_state_publisher combines the URDF and /joint_states to build the TF tree, why the navigation chain is split into three separate links, and how to inspect every part of this system with tf2_echo and view_frames.

โœ… Learning Checkpoint
  • I can explain the difference between a Frame and a Transform.
  • I know why Static and Dynamic Transforms are published at different rates.
  • I can say exactly which two inputs robot_state_publisher uses to build TF.
  • I know which Nodes fill in the chain map โ†’ odom โ†’ base_link โ†’ laser_link.
  • I can inspect a TF tree using tf2_echo and view_frames.
  • I know what use_sim_time is and why it's critical in simulation.

Connection to the main project

ARCHO Project can now publish its body's TF tree live with robot_state_publisher โ€” a prerequisite we'll need immediately in the next chapter, when we bring ARCHO into the physical world of Gazebo.

What the next chapter adds

In Chapter 6 we'll first "see" this same TF tree and robot model for the first time with RViz โ€” the ROS 2 visual dashboard; then in Chapter 7 we'll enter Gazebo, where gravity, friction, and collisions truly act on ARCHO.

Chapter 5 Glossary

TF2
The ROS 2 library for tracking the relative position of a robot's different Frames over time.
Frame
A reference point in space with a defined orientation, such as base_link or laser_link.
Transform
The geometric relationship (translation and rotation) between two Frames.
robot_state_publisher
The Node that combines the URDF and /joint_states to build a live TF tree.
/joint_states
The standard Topic that publishes the current angle or position of every moving Joint.
use_sim_time
A Parameter that tells a Node to use the simulation clock instead of the system's real clock.

Chapter 5 Common Errors โ€” Recap