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
| 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:
odomtobase_linkis dead reckoning.vesc_to_odom_nodeintegrates 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.maptoodomis the correction.slam_toolboxmatches 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 → laserodom_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_framesThe 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.
odomdrifts without limit. Anything that needs position over more than a few metres usesmap.
Checked against
car 1 commit 0265263
and
car 2 commit b81b291, before the two workspaces merged.