ROS TF Transform Tutorial: Build Turtle Following and Custom Frames with turtlesim

Technical Specification Snapshot

Parameter Details
Runtime Environment Ubuntu 18.04/20.04
ROS Version Melodic / Noetic
Primary Language C++
Communication / Mechanisms Topic, Service, TF
Core Dependencies roscpp, tf, geometry_msgs, turtlesim, std_msgs
Example Nodes turtle_broadcaster, turtle_listener, turtle_tf
Applicable Scenarios Robot frame management, target following, virtual reference frames
Star Count Not provided in the source content

TF is the core infrastructure for managing spatial relationships in robotics

TF does not solve the question, “How does the robot move?” It solves a more fundamental one: “How does the robot understand the spatial relationships between its parts?” In a real robot system, the chassis, LiDAR, camera, and robotic arm all have their own coordinate frames. Algorithms can only compute paths, obstacle locations, and control outputs after those frames are unified under a consistent reference model.

TF maintains these relationships as a tree of coordinate frames. It enforces a single-parent, multiple-children structure with no cycles, so every frame can have only one parent. This constraint may look strict, but it guarantees that every transform chain remains traceable, composable, and queryable in real time.

This experiment builds a minimal, runnable TF system

This example uses turtlesim as a low-barrier simulation environment: turtle1 is controlled from the keyboard, and turtle2 automatically tracks it by listening to TF. That reduces the core TF workflow to three steps: broadcast poses, query transforms, and compute control commands.

cd ~/workspace_hzq/src
catkin_create_pkg turtle_follow std_msgs roscpp rospy tf geometry_msgs
cd ..
catkin_make  # Build the workspace

These commands create the package and complete the initial build.

The TF broadcaster publishes pose messages as coordinate transforms

The broadcaster node subscribes to the turtle pose topic and converts turtlesim/Pose into a world -> turtle_name TF transform. Translation comes from x/y, and rotation is published after converting theta into a quaternion.

#include <ros/ros.h>
#include <tf/transform_broadcaster.h>
#include <turtlesim/Pose.h>

std::string turtle_name;

void poseCallback(const turtlesim::PoseConstPtr& msg) {
  static tf::TransformBroadcaster br;  // Static broadcaster to avoid repeated construction
  tf::Transform transform;

  transform.setOrigin(tf::Vector3(msg->x, msg->y, 0.0));  // Set 2D translation

  tf::Quaternion q;
  q.setRPY(0, 0, msg->theta);  // Rotate only around the Z axis to match turtle heading
  transform.setRotation(q);

  br.sendTransform(tf::StampedTransform(
    transform,
    ros::Time::now(),  // Use the current timestamp
    "world",          // Parent frame
    turtle_name        // Child frame
  ));
}

This code converts the turtle’s real-time pose into TF data that the entire system can consume globally.

The key broadcaster design principle is consistent frame semantics

Choosing world as the parent frame means all turtle poses are expressed relative to a global reference frame. As long as multiple objects are attached to the same tree, a listener can query the relative relationship between any two frames across nodes and subsystems.

If the broadcaster runs at a stable frequency, the TF buffer can maintain a time-continuous pose chain. That continuity is also the foundation for stable downstream control.

The TF listener generates following control directly from relative transforms

The listener first calls the spawn service to create turtle2, then continuously queries the transform from /turtle2 to /turtle1. After obtaining the relative coordinates, it uses angular error to control turning and Euclidean distance to control forward velocity.

#include <ros/ros.h>
#include <tf/transform_listener.h>
#include <geometry_msgs/Twist.h>
#include <turtlesim/Spawn.h>

int main(int argc, char** argv) {
  ros::init(argc, argv, "turtle_tf_listener");
  ros::NodeHandle node;

  ros::service::waitForService("spawn");  // Wait until the turtle spawn service is available
  ros::ServiceClient add_turtle = node.serviceClient<turtlesim::Spawn>("spawn");
  turtlesim::Spawn srv;
  add_turtle.call(srv);

  ros::Publisher turtle_vel = node.advertise<geometry_msgs::Twist>("turtle2/cmd_vel", 2);
  tf::TransformListener listener;
  ros::Rate rate(10.0);

  while (node.ok()) {
    tf::StampedTransform transform;
    try {
      listener.waitForTransform("/turtle2", "/turtle1", ros::Time(0), ros::Duration(3.0));
      listener.lookupTransform("/turtle2", "/turtle1", ros::Time(0), transform);  // Query the latest transform
    } catch (tf::TransformException &ex) {
      ROS_ERROR("%s", ex.what());
      ros::Duration(1.0).sleep();
      continue;
    }

    geometry_msgs::Twist vel_msg;
    vel_msg.angular.z = 4.0 * atan2(transform.getOrigin().y(), transform.getOrigin().x());  // Turn toward the target
    vel_msg.linear.x = 0.5 * sqrt(
      pow(transform.getOrigin().x(), 2) +
      pow(transform.getOrigin().y(), 2)  // Move forward based on distance
    );
    turtle_vel.publish(vel_msg);
    rate.sleep();
  }
}

This code shows how TF can directly serve a control loop: relative position becomes the control input.

The real value here is eliminating manual coordinate transform derivation

Without TF, developers would need to manage pose subscriptions, time synchronization, rotation matrix composition, and coordinate conversion by hand. With lookupTransform(), the control node only needs to answer one question: “Where am I relative to the target?” That greatly reduces system coupling.

Build and launch configuration determine whether the project can be reproduced reliably

CMakeLists.txt must declare the executables and link them against catkin_LIBRARIES. package.xml must also include the full build-time and runtime dependencies. Otherwise, the nodes may be written correctly but still fail to build as a complete package.

find_package(catkin REQUIRED COMPONENTS
  geometry_msgs
  roscpp
  rospy
  std_msgs
  tf
)

add_executable(turtle_broadcaster src/turtle_broadcaster.cpp)
add_executable(turtle_listener src/turtle_listener.cpp)

target_link_libraries(turtle_broadcaster ${catkin_LIBRARIES})
target_link_libraries(turtle_listener ${catkin_LIBRARIES})

This configuration ensures that catkin can compile and link the broadcaster and listener correctly.


<launch>
  <node pkg="turtlesim" type="turtlesim_node" name="sim"/>
  <node pkg="turtlesim" type="turtle_teleop_key" name="teleop" output="screen"/>
  <node pkg="turtle_follow" type="turtle_broadcaster" args="/turtle1" name="turtle1_tf_broadcaster" />
  <node pkg="turtle_follow" type="turtle_broadcaster" args="/turtle2" name="turtle2_tf_broadcaster" />
  <node pkg="turtle_follow" type="turtle_listener" name="listener" />
</launch>

This launch file starts the simulation, broadcasters, and following control chain with a single command.

Adding the carrot1 frame upgrades TF from following an entity to following a target point

The advanced section is not about adding one more node. It is about understanding that a TF tree can describe virtual reference frames. By making carrot1 a child frame of turtle1 with a fixed offset of (2, 2, 0), turtle2 can follow a target point instead of the turtle body itself.

#include <ros/ros.h>
#include <tf/transform_broadcaster.h>

int main(int argc, char** argv) {
  ros::init(argc, argv, "turtle_tf_broadcaster");
  ros::NodeHandle node;
  tf::TransformBroadcaster br;
  tf::Transform transform;
  ros::Rate rate(10.0);

  while (node.ok()) {
    transform.setOrigin(tf::Vector3(2.0, 2.0, 0.0));  // Create an offset target in front of turtle1
    transform.setRotation(tf::Quaternion(0, 0, 0, 1));  // Do not add extra rotation
    br.sendTransform(tf::StampedTransform(transform, ros::Time::now(), "turtle1", "carrot1"));
    rate.sleep();
  }
}

This code attaches a virtual target point to the existing TF tree.

Changing the listener target to carrot1 completes the control semantics switch

listener.waitForTransform("/turtle2", "/carrot1", ros::Time(0), ros::Duration(3.0));
listener.lookupTransform("/turtle2", "/carrot1", ros::Time(0), transform);  // Switch from following turtle1 to following carrot1

These two lines switch the following target from a physical frame to a virtual frame.

TF debugging tools quickly verify whether the coordinate tree is correct

When the system behaves unexpectedly, check TF connectivity before tuning the control algorithm. Use rqt_tf_tree to inspect the tree structure, tf_echo to inspect real-time values between two frames, and rostopic echo /tf to confirm that broadcasts are still being published.

rosrun rqt_tf_tree rqt_tf_tree
rosrun tf tf_echo /turtle1 /carrot1
rostopic echo /tf  # Check whether TF messages are being published continuously

These commands help verify the TF tree structure, transform values, and message flow.

This example transfers directly to real robot systems

Turtle following is only the simplest demonstration. The underlying idea transfers directly to mobile robots, robotic grasping, and multi-sensor fusion. Replace turtle1 with a LiDAR, camera, or end effector, and replace carrot1 with a local goal point, and TF will continue to serve as the central hub for spatial relationships.

For further optimization, add PID control, velocity limiting, low-pass filtering, and time synchronization strategies to make the following behavior smoother and more resistant to jitter.

FAQ

Q1: Why does the TF tree not allow cycles?

A: Cycles would make coordinate derivation ambiguous because the system could no longer determine a unique parent path. TF guarantees a unique transform chain for every frame through its single-parent, multiple-children rule, which keeps query results stable and interpretable.

Q2: What does ros::Time(0) mean in a TF query?

A: It requests the latest available transform in the current buffer. This is common in real-time control because developers usually want the most recently synchronized pose relationship.

Q3: Why can following control work using only relative coordinates?

A: Because the listener receives the target position in its own coordinate frame. atan2(y, x) directly gives the steering error, and the distance norm directly gives the forward motion intensity, so no additional global coordinate conversion is required.

Core summary: This article uses ROS1 and turtlesim to explain TF coordinate trees, the collaboration between broadcasters and listeners, and a complete C++ implementation of turtle following plus a custom carrot1 frame, covering package creation, coding, compilation, launch, and debugging end to end.