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.
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.
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:
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.
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 Type | Example in ARCHO | How it's published |
|---|---|---|
| Static | base_link โ laser_link | Once, at startup; never changes |
| Dynamic | base_link โ left_wheel_link | Republished every moment the wheel turns |
| Dynamic (navigation) | odom โ base_link | Continuously updated as the robot moves |
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.
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.
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.
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}],
),
])
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 link | Who is responsible for publishing it |
|---|---|
map โ odom | SLAM or AMCL (Chapters 10 and 11) |
odom โ base_link | Odometry or robot_localization (Chapter 9) |
base_link โ laser_link | robot_state_publisher (this chapter) |
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.
To see the relationship between two specific Frames:
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.
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?
| Error | Common cause | Solution |
|---|---|---|
Lookup would require extrapolation | Timestamps are out of sync, or TF is published late | Check the TF publish rate and the system clock |
| TF doesn't work in simulation but works correctly with real time | The use_sim_time Parameter isn't set | Set use_sim_time: true on all Nodes related to Gazebo |
| Two identical Transforms from two sources | Two Nodes are simultaneously publishing the same chain link | Keep only one publisher per chain link |
| Wrong Frame in RViz | The wrong Fixed Frame is selected | Set the Fixed Frame to odom or map, not base_link |
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.
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.
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.
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.