tutorial
ROS 2 tf2: transform a laser point at the right time
Connect an odometry message to a transform tree, convert a timestamped laser point, and reproduce missing-frame and extrapolation errors in ROS 2 Jazzy.
What you will learn
- Distinguish an Odometry message from a broadcast transform.
- Read a map, odom, base_link, and laser frame chain.
- Convert a stamped point with the correct target, source, and time.
- Check a numeric transformation and its inverse.
- Reproduce disconnected-tree, unknown-frame, and extrapolation errors.
Before you start
- Coordinates in robot, sensor, and world frames
- ROS 2 topics and subscribers
- A terminal and an installed, running Docker engine
To transform a sensor point in ROS 2, supply its source frame and acquisition time, then request the destination frame. tf2 combines the available transforms at that time. Publishing an odometry message does not automatically supply those transforms.
This example sends real ROS messages between three nodes inside one container. A laser point becomes (12, 2.2, 0) meters in map at t = 11 s. Replacing its timestamp with a latest-transform request changes the result by one meter.
A point needs a frame and a time
A laser reports a point one meter along its own x-axis. The coordinates (1, 0, 0) describe the point relative to the laser. To place it in a map, software also needs the laser's mounting transform and the robot's pose when the observation occurred.
This tutorial supplies those poses explicitly. It tests ROS transport, transform composition, and timestamp handling. It does not run an encoder estimator, SLAM, or a physical robot.
Run the checked Jazzy example
Save odometry_tf2.py and verify.sh in the same local directory. Open a host terminal in that directory and run:
docker run --rm --network none \
--env ROS_DOMAIN_ID=72 \
--env RMW_IMPLEMENTATION=rmw_fastrtps_cpp \
--mount "type=bind,source=$(pwd),target=/lesson,readonly" \
ros:jazzy-ros-base@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
bash /lesson/verify.sh
Docker pulls the image if it is absent. The running container has no external network, and it reads the downloaded files without modifying them. It exits after the checks, so no background tutorial process remains.
Validation used ROS 2 Jazzy, Ubuntu 24.04.5, Python 3.12.3, and Fast DDS on ARM64 in Docker Desktop for macOS. The pinned image already supplies the required ROS packages. The environment record lists their versions; this run does not establish results on other architectures.
The script prints each check and raises an error if a required result differs. Compare your run with the captured output. The README explains the files, coordinate conventions, and bounded waits.
Read the frame tree
The tree uses four named frames:
map: the destination frame for the point.odom: the frame containing the supplied robot poses.base_link: the robot body frame.laser: a sensor mounted 0.2 m along the body's positive x-axis.
REP 105 separates continuous, drifting odometry from a global map frame that can receive localization corrections. Here map to odom is a supplied constant offset of (10, −1, 0) m. That choice makes the arithmetic inspectable; a real localization system can update this relationship.
The arrows name parent → child relationships. A transform with parent odom and child base_link gives the child's pose in the parent. It converts a point expressed in base_link into odom coordinates.
To convert our laser point into map, follow the coordinate conversions laser → base_link → odom → map. tf2 composes the chain and inverts edges when needed. Review homogeneous transformations for the matrix form of this composition.
Assign each child one parent and each relationship one intended publishing authority. Competing nodes broadcasting different poses for the same child can corrupt the intended tree; naming an authority does not make tf2 arbitrate between estimates.
Bridge the odometry pose into TF
The source publishes two nav_msgs/msg/Odometry samples on /odom. Their timestamps are 10 s and 12 s, with positions (1, 2, 0) m and (3, 2, 0) m. Both use yaw +90°.
The Odometry definition assigns different meanings to its frame fields:
header.frame_id = "odom": the pose uses odom coordinates.child_frame_id = "base_link": the pose describes the body; the twist uses body coordinates.header.stamp: the pose's measurement time, which the bridge preserves.
The motion moves along odom's +x direction while the body faces odom's +y direction. Its linear velocity is therefore (0, −1, 0) m/s in base_link, with zero angular velocity. These supplied poses describe sideways body motion; they are not a differential-drive rollout.
The first two messages reach the subscriber while its TF bridge is disabled. Both static transforms already exist, leaving two disconnected branches: map → odom and base_link → laser. The lookup still fails:
ODOMETRY ONLY: two /odom samples received; map <- laser unavailable
DISCONNECTED TREE: ConnectivityException
Enabling the bridge makes its callback copy each pose into a TransformStamped and send it through TransformBroadcaster. This excerpt shows the copy; the download contains the full node and checks:
transform = TransformStamped()
transform.header = message.header
transform.child_frame_id = message.child_frame_id
transform.transform.translation.x = message.pose.pose.position.x
transform.transform.translation.y = message.pose.pose.position.y
transform.transform.translation.z = message.pose.pose.position.z
transform.transform.rotation = message.pose.pose.orientation
self.broadcaster.sendTransform(transform)
The listener receives dynamic transforms on /tf and fixed relationships on /tf_static. It does not subscribe to /odom. The script checks the received buffer's parent relationships after the missing edge arrives.
Preserve the pose timestamp when broadcasting. Replacing it with callback arrival time would give old pose data a new time label. TF also carries no pose covariance; this fixture uses exact supplied poses and does not estimate or fuse uncertainty.
Ask for the measurement's transform
The point carries its own source frame and timestamp:
import tf2_geometry_msgs # Registers PointStamped support with tf2.
from geometry_msgs.msg import PointStamped
from rclpy.time import Time
point = PointStamped()
point.header.frame_id = "laser"
point.header.stamp = Time(seconds=11).to_msg()
point.point.x = 1.0
map_point = buffer.transform(point, "map")
buffer here is the Buffer already filled by the running listener in the download. The Python buffer interface reads the point's source frame and stamp. "map" names the destination.
To retrieve the transform itself, the argument order is target, source, time:
map_from_laser = buffer.lookup_transform(
"map", "laser", Time.from_msg(point.header.stamp)
)
The returned transform at t = 11 has translation (12, 1.2, 0) m and yaw +90°. Its translation locates the laser origin in map; transforming the point also applies the rotation to the point's nonzero coordinates.
The excerpt assumes the needed data has arrived. The runnable example spins callbacks while it waits for both dynamic samples. Blocking the only callback-processing thread while waiting for TF can prevent the listener from receiving the transform it needs.
Check the coordinates by hand
REP 103 uses right-handed body axes: x forward, y left, z up. Positive yaw turns around +z; ROS quaternion fields appear in x, y, z, w order. Our +90° quaternion is (0, 0, sin(π/4), cos(π/4)).
At t = 11, the supplied linear motion lies halfway between the two poses. The body position is (2, 2, 0) m and its yaw remains +90°. All z coordinates stay zero, so calculate in the plane:
p_laser = (1, 0)
p_base = (1, 0) + (0.2, 0) = (1.2, 0)
R(90°) p_base = (0, 1.2)
p_odom = (0, 1.2) + (2, 2) = (2, 3.2)
p_map = (2, 3.2) + (10, −1) = (12, 2.2)
For the inverse, subtract the map offset, subtract the body translation, rotate by −90°, and subtract the sensor mount. The result returns to (1, 0) in laser.
The script checks this round trip at the same timestamp. Negating translation alone would miss the inverse rotation. A round trip checks conversion consistency; it cannot establish that a real sensor's mounting calibration is correct.
Compare the measurement time with latest
tf2 interpolates between stored samples when the requested time lies inside their coverage. Its cache implementation linearly interpolates translation and uses quaternion interpolation for orientation. Our fixed orientation and linear translation make the t = 11 result exact for the supplied motion.
A zero time has a special meaning: use the latest common available time along the chain. It does not mean the computer's current time or the point's acquisition time. In this fixture, that common time is 12 s.
If you replace the point's 11 s stamp with zero, the same numeric sensor coordinates convert to (13, 2.2, 0) m. The returned stamp is 12 s. The one-meter difference comes entirely from substituting a different body pose.
STAMPED t=11: laser (1.000, 0.000, 0.000) -> map (12.000, 2.200, 0.000)
INVERSE t=11: map (12.000, 2.200, 0.000) -> laser (1.000, 0.000, 0.000)
LATEST t=0: resolved t=12 -> map (13.000, 2.200, 0.000); difference = 1.000 m
Latest can answer a latest-pose question. It gives the wrong result when substituted for this observation's known acquisition time. The buffer's latest-time handling finds a common time across the path; it does not independently combine each edge's newest pose at unrelated times.
The timestamps 10, 11, and 12 are synthetic dataset times. They are not seconds since this script started, Unix timestamps for this run, or a /clock simulation. Do not compare them with the host's current time to infer communication delay.
Reproduce three different lookup failures
The download checks distinct failure conditions using the same buffer:
- Disconnected branches: before the bridge runs,
mapandlaserexist but no chain connects them. The lookup raisesConnectivityException. Broadcasting the suppliedodom→base_linkedge repairs it. - Unknown frame: a lookup from
missing_sensorraisesLookupException. Check the actual source name and its publisher. Increasing the wait cannot fix a misspelled frame. - Time outside coverage: t = 9 precedes both dynamic samples; t = 13 follows them. Both raise
ExtrapolationException. The valid t = 11 request succeeds between them.
UNKNOWN FRAME: LookupException
PAST t=9: ExtrapolationException
FUTURE t=13: ExtrapolationException
The ten-second buffer setting controls retained history. It does not create samples before the first supplied pose or after the last one. tf2 also does not integrate the odometry twist to predict a future transform.
On a running robot, a short wait can help when the required transform is still arriving and callbacks continue processing. A timestamp from the wrong clock, a missing edge, or a request outside retained history needs a different repair. Use the QoS tutorial when the broadcaster and listener are present but their messages do not arrive.
To preserve a stream for repeated inspection, continue with the ROS 2 bag tutorial. Its separate point fixture compares stored header stamps, bag timestamps, and a replay consumer’s ROS clock; it does not supply the transforms used here.
Apply the checks to your own robot
Start with one observation. Record its frame ID, acquisition stamp, requested destination, and the available transform chain. Check both direction and time before changing the point's coordinates.
This example deliberately supplies the map → odom offset and two exact body poses. It has no localization corrections, wheel slip, calibration error, or sensor uncertainty. Its zero covariance arrays are fixture values, not a convention for reporting unknown uncertainty.
The script tests same-time historical transforms. Advanced requests that convert between different source and target times through a fixed frame need the fuller tf2 API. RViz, view_frames, and tf2_echo diagnostics also remain outside this checked workflow.
For the pose's origin, continue with wheel odometry. For uncertainty and sensor corrections, use the extended Kalman filter lesson. Those are separate teaching experiments with their own models and data.
To produce a filtered pose from motion measurements, use the robot_localization tutorial. Its real ROS EKF owns the local odom-to-body transform and compares wheel-only estimation with a biased IMU input; it uses a separate fixture.
Try two frame and time decisions
Exercise 1. The same t = 11 observation changes to (2, 0, 0) in laser. What should the map point become? Which lookup arguments keep the conversion direction correct?
Check the longer laser point
The body point is (2.2, 0, 0). A +90° rotation gives (0, 2.2, 0); adding body position (2, 2, 0) and map offset (10, −1, 0) gives (12, 3.2, 0) m. Request target map, source laser, and time 11 s. The direction is unchanged by the point's distance.
Exercise 2. A point has stamp 13 s, but the newest dynamic transform has stamp 12 s. Would increasing the buffer duration or replacing 13 with zero preserve the requested calculation?
Check the future-time diagnosis
A longer buffer retains more past data; it cannot supply the missing future pose. Zero requests the latest common time, which changes the question to time 12. Wait only if data covering time 13 is expected to arrive, with listener callbacks still running. Otherwise investigate the timestamps and missing data before accepting a different-time result.
Sources and further study
- REP 103: units and coordinate conventions: body axes, handedness, and rotation conventions.
- REP 105: coordinate frames for mobile platforms: map, odometry, body frames, and frame authorities.
- ROS 2 Jazzy Odometry message: the pose and twist frame fields.
- ROS 2 Jazzy Python buffer interface: stamped object transformations and target/source/time arguments.
- ROS 2 Jazzy BufferCore: composing a chain and finding a common latest time.
- ROS 2 Jazzy transform cache: interpolation and out-of-range requests.
Take one point from your own sensor stream and write down the expected frame and time of the result before calling tf2. Keep those labels beside the numeric answer when you check it.
To compare complete pose streams, use the timestamped trajectory-evaluation exercise. Its separate synthetic CSVs show how a low aligned error can hide incorrect time associations.