Docs Architecture Workspace repo
Docs menu

Coordinate frames

The map, odom, base_link and laser frames, which node publishes each transform, and which point on the car a pose refers to.

A frame is a named coordinate system: an answer to “position relative to what”. The LiDAR measures distances from itself, odometry measures movement from where the car powered up, and a racing line is stored relative to the building. ROS 2 keeps the relationships between frames in the transform tree (TF), and converts between them on request. Lecture 3 (notes) and Tutorial 3 (notes) cover rigid-body transforms and TF2, and the site’s rigid body transforms guide works through the mathematics.

The tree

The transform tree Four frames in a chain: map, odom, base_link and laser. slam_toolbox publishes map to odom. vesc_to_odom_node publishes odom to base_link. A static transform publishes base_link to laser. map odom base_link laser slam_toolbox vesc_to_odom static CORRECTION SLOW, JUMPS DEAD RECKONING FAST, DRIFTS FIXED 0.26 M AHEAD THE BUILDING WHERE IT POWERED UP REAR AXLE THE LIDAR
How to read it: each box is a frame, and each arrow is one transform, from parent to child. The text above an arrow names the node that publishes it. The text below says how that transform behaves, and the text under each box says where that frame’s origin is.
Frame Origin Published by
map Fixed to the building, where mapping started slam_toolbox publishes map to odom
odom Where the car powered up. Moves smoothly, never jumps, drifts. vesc_to_odom_node publishes odom to base_link
base_link The centre of the rear axle The car itself; no transform ends here
laser The LiDAR, 0.26 m ahead of and 0.11 m above base_link A static transform in bringup_launch.pyGitHub

The car’s position on the map is the two transforms composed: map to odom to base_link. The two halves behave differently:

  • odom to base_link is dead reckoning. vesc_to_odom_node integrates the wheel speed and the steering command through the bicycle model. It updates at 50 Hz and grows a little more wrong every second, most of all in heading.
  • map to odom is the correction. slam_toolbox matches each LiDAR scan against the map and publishes how far the dead reckoning has drifted. It updates less often, and each update arrives as a jump.

slam_toolbox is configured with base_frame: laser, not base_link, in f1tenth_online_async.yaml. That is the upstream F1TENTH setting, not an error.

Composing transforms

A 2D transform from frame to frame is three numbers : where ‘s origin sits in , and how far is rotated from . A point at in ‘s coordinates is at

in ‘s. rotates a vector by , then the translation moves it to ‘s origin.

Chaining two transforms. If goes from map to odom and from odom to base_link, then map to base_link is followed by , written . ’s translation is rotated into ‘s frame before it is added, and the angles add:

Undoing one. The inverse goes back the other way, so that is no motion at all:

imu_bridge uses exactly these two operations, written out for the 2D case:

def compose(a, b):
    ax, ay, at = a
    bx, by, bt = b
    c, s = math.cos(at), math.sin(at)
    return (ax + c * bx - s * by, ay + s * bx + c * by, at + bt)

def inverse(p):
    x, y, t = p
    c, s = math.cos(t), math.sin(t)
    return (-c * x - s * y, s * x - c * y, -t)

Why map to odom is the correction. SLAM estimates where the car is on the map, map to base_link. Odometry already says odom to base_link. The tree needs the piece in between, and composing the estimate with the inverse of the odometry gives it:

When the odometry is perfect, this stays constant. Every change in it is drift that SLAM has removed, which is why it arrives as jumps. imu_bridge’s gyro odometry builds its odom_imu to odom transform the same way, from the gyro pose and the wheel pose.

Gyro odometry

With gyro odometry, which map_course uses by default on both cars (gyro_odom follows imu), imu_bridge’s vehicle_state_node (vehicle_state_node.pyGitHub) adds a frame between map and odom:

default:     map → odom ─────────────→ base_link → laser
gyro_odom:   map → odom_imu → odom ──→ base_link → laser

odom_imu to odom is chosen so that odom_imu to base_link equals the gyro-based pose on /odom/imu: the wheel distance with the BNO085’s heading. slam_toolbox then maps on odom_imu. A frame can have only one parent, so the node stops publishing odom_imu to odom for good if anything else becomes odom’s parent. See IMU fusion.

Which point on the car a pose means

Every pose in the workspace is the position of base_link, the rear axle. The racing line, the pure pursuit geometry, the collision rectangle and the dashboard’s car drawing all assume it. The particle filter is the exception: it scores each guess by how well a scan taken from that point matches, so its estimate is the LiDAR’s position, 0.26 m further forward.

Pose topic Which point Converted by
/pf/viz/inferred_pose The LiDAR Each consumer, with pose_frame: laser and laser_offset_x: 0.26
/slam_pose The rear axle Nothing to convert

For a pose of the LiDAR, the rear axle is

A node told the wrong pose_frame raises no error. The car believes it is 0.26 m from where it is.

Inspect the tree

With bringup running:

ros2 run tf2_ros tf2_echo base_link laser
ros2 run tf2_tools view_frames

The first prints the LiDAR’s transform, which should read a translation of [0.260, 0.000, 0.110]. The second writes frames.pdf, a drawing of the whole tree with each transform’s publisher and rate.

Limitations

  • The 0.26 m LiDAR offset was measured on car 2. Car 1 uses the same value. If car 1’s LiDAR sits elsewhere, every pose on car 1 is off by the difference.
  • odom drifts without limit. Anything that needs position over more than a few metres uses map.

Checked against car 1 commit 0265263 and car 2 commit b81b291, before the two workspaces merged.