A robot that cannot answer “where is this point relative to that body?” cannot grasp, navigate, or localize. Every sensor reading, joint angle, and map cell lives in some coordinate frame, and every useful computation moves data between frames using homogeneous transforms. This guide builds the full pipeline: rotation matrices, 4×4 transforms, quaternions, the ROS2 tf2 tree, and a working fusion example with a LiDAR, an IMU, and wheel odometry.
Quick Takeaways
- A homogeneous transform
Tpacks rotationR(3×3) and translationp(3×1) into one 4×4 matrix, so chaining frames becomes matrix multiplication. - Quaternions store orientation in 4 numbers, avoid gimbal lock, and normalize cheaply. Use them for storage and interpolation. Use matrices for bulk point transforms.
- In ROS2,
tf2maintains a time-buffered tree of transforms. Follow REP-103 (x forward, y left, z up) and REP-105 (map→odom→base_link). - Most real-world bugs come from three sources: wrong multiplication order, mismatched timestamps, and mixing intrinsic with extrinsic rotation conventions.
| Representation | Parameters | Singularities | Composition Cost | Best Use |
|---|---|---|---|---|
| Rotation matrix | 9 (3 DOF) | None | 27 mults | Transforming point clouds |
| Euler angles | 3 | Gimbal lock at ±90° pitch | Convert first | Human-readable I/O |
| Quaternion | 4 (3 DOF) | None | 16 mults | Storage, SLERP, tf2 messages |
| Axis-angle | 4 | At θ = 0 | Convert first | Angular velocity, Rodrigues |
| 4×4 Homogeneous | 16 (6 DOF) | None | 64 mults | Full pose chaining |
Kinematic Foundations: What a Frame Actually Is
A frame is an origin plus three orthonormal axes. Notation matters, so fix it now:
^A pis the pointpexpressed in frame{A}.^A_B Tis the pose of frame{B}expressed in{A}. It maps coordinates from B into A.
^A p = ^A_B T * ^B p
Read the subscript/superscript pair as cancelling neighbors: ^A_B T * ^B p leaves ^A p.
Rotation Matrices
A rotation matrix R satisfies two constraints:
R^T * R = I (orthonormal columns)
det(R) = +1 (proper rotation, no reflection)
Hence the inverse is the transpose: R^-1 = R^T. This is cheaper and more stable than a general matrix inverse.
Elementary rotations about the principal axes by angle θ:
Rx(θ) = | 1 0 0 |
| 0 cos θ -sin θ |
| 0 sin θ cos θ |
Ry(θ) = | cos θ 0 sin θ |
| 0 1 0 |
| -sin θ 0 cos θ |
Rz(θ) = | cos θ -sin θ 0 |
| sin θ cos θ 0 |
| 0 0 1 |
Homogeneous Transformation Matrices
Append a 1 to the point ([x y z 1]^T) and build:
T = | R p |
| 0 1 |
The inverse exploits structure and avoids a numerical 4×4 inversion:
T^-1 = | R^T -R^T * p |
| 0 1 |
Chaining frames is a product. A point in the camera frame mapped to the map frame:
^map p = ^map_odom T * ^odom_base T * ^base_cam T * ^cam p
Order rule: multiplication reads right-to-left for transforming a point, left-to-right for walking down the tree from the root.
Intrinsic vs. Extrinsic Rotation
| Convention | Axes Rotate About | Composition | Equivalent |
|---|---|---|---|
| Extrinsic (fixed axes) | Original fixed frame | Pre-multiply: R = Rz * Ry * Rx |
X then Y then Z fixed |
| Intrinsic (body axes) | Frame’s own moving axes | Post-multiply: R = Rz * Ry * Rx |
Z then Y’ then X” |
The ROS convention for roll-pitch-yaw is extrinsic XYZ, which equals intrinsic ZYX. This is the convention behind R = Rz(yaw) * Ry(pitch) * Rx(roll).
Quaternions: Orientation Without Singularities
A unit quaternion q = [x, y, z, w] (ROS ordering, w last) encodes a rotation of angle θ about unit axis n:
q = [ n_x*sin(θ/2), n_y*sin(θ/2), n_z*sin(θ/2), cos(θ/2) ]
|q| = 1
Key operations:
Composition: q_total = q2 ⊗ q1 (apply q1 first)
Inverse: q^-1 = [-x, -y, -z, w] (for unit q)
Rotate vector v: v' = q ⊗ [v, 0] ⊗ q^-1
Note that q and -q represent the same rotation (double cover). When interpolating with SLERP, flip the sign if dot(q1, q2) < 0 to take the short path.
slerp(q1, q2, t) = sin((1-t)Ω)/sin(Ω) * q1 + sin(tΩ)/sin(Ω) * q2
where Ω = acos(dot(q1, q2))
Gimbal lock happens in Euler form when pitch reaches ±90°: the roll and yaw axes align, and one DOF vanishes. Quaternions never hit this because they do not decompose into three sequential axis rotations.
Frame Conventions in ROS and ROS2
REP-103 and REP-105
REP-103 fixes axis directions: x forward, y left, z up for body frames. For optical (camera) frames the convention is z forward, x right, y down. That difference requires a fixed rotation between camera_link and camera_optical_frame.
REP-105 defines the mobile-base frame chain:
map → odom → base_link → sensor frames (lidar_link, imu_link, camera_link)
| Frame | Continuity | Drift | Published By | Use |
|---|---|---|---|---|
map |
Discontinuous jumps allowed | None (global) | SLAM / localization (e.g., AMCL) | Long-term goals |
odom |
Smooth, continuous | Accumulates | Wheel odometry / EKF | Local control loops |
base_link |
Rigidly attached to robot | N/A | Robot state | Body reference |
lidar_link |
Static relative to base_link |
None | URDF / static broadcaster | Sensor data |
The map → odom transform absorbs drift correction. This is why local planners use odom (smooth) and global planners use map (consistent).
Implementation: Static and Dynamic Transforms in ROS2
Defining Sensor Mounts in URDF
<!-- Mount a 2D LiDAR 0.15 m forward and 0.20 m above base_link -->
<joint name="lidar_joint" type="fixed">
<parent link="base_link"/>
<child link="lidar_link"/>
<!-- xyz in meters, rpy in radians (extrinsic XYZ per URDF spec) -->
<origin xyz="0.15 0.0 0.20" rpy="0 0 0"/>
</joint>
<!-- IMU mounted rotated 180 deg about z (cable facing rear) -->
<joint name="imu_joint" type="fixed">
<parent link="base_link"/>
<child link="imu_link"/>
<origin xyz="0.0 0.0 0.05" rpy="0 0 3.14159265"/>
</joint>
The robot_state_publisher node reads this URDF and broadcasts every fixed joint on /tf_static.
Publishing a Dynamic Transform (Python, ROS2)
import math
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import TransformStamped
from tf2_ros import TransformBroadcaster
from nav_msgs.msg import Odometry
def yaw_to_quaternion(yaw):
"""Pure-yaw rotation about z. Returns (x, y, z, w)."""
return (0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0))
class OdomTfPublisher(Node):
def __init__(self):
super().__init__('odom_tf_publisher')
self.br = TransformBroadcaster(self) # publishes to /tf
self.create_subscription(Odometry, '/odom', self.cb, 10)
def cb(self, msg: Odometry):
t = TransformStamped()
# CRITICAL: reuse the message stamp, never now(), to keep time-consistency
t.header.stamp = msg.header.stamp
t.header.frame_id = 'odom' # parent frame
t.child_frame_id = 'base_link' # child frame
t.transform.translation.x = msg.pose.pose.position.x
t.transform.translation.y = msg.pose.pose.position.y
t.transform.translation.z = 0.0 # planar robot
t.transform.rotation = msg.pose.pose.orientation # already a unit quaternion
self.br.sendTransform(t)
def main():
rclpy.init()
rclpy.spin(OdomTfPublisher())
if __name__ == '__main__':
main()
Looking Up a Transform and Converting a LiDAR Point
import rclpy
from rclpy.node import Node
from rclpy.duration import Duration
from rclpy.time import Time
from tf2_ros import Buffer, TransformListener
from tf2_geometry_msgs import do_transform_point
from geometry_msgs.msg import PointStamped
class PointMapper(Node):
def __init__(self):
super().__init__('point_mapper')
self.buffer = Buffer(cache_time=Duration(seconds=10.0)) # 10 s history
self.listener = TransformListener(self.buffer, self)
self.create_timer(0.1, self.tick) # 10 Hz
def tick(self):
p = PointStamped()
p.header.frame_id = 'lidar_link'
p.header.stamp = Time().to_msg() # zero stamp = "latest available"
p.point.x, p.point.y, p.point.z = 2.0, 0.5, 0.0 # obstacle 2 m ahead
try:
# Look up ^map_lidar T, waiting up to 50 ms for data
tf = self.buffer.lookup_transform(
'map', 'lidar_link', Time(), timeout=Duration(seconds=0.05))
p_map = do_transform_point(p, tf)
self.get_logger().info(
f'Obstacle in map: ({p_map.point.x:.2f}, {p_map.point.y:.2f})')
except Exception as e:
self.get_logger().warn(f'TF lookup failed: {e}')
Inspecting the Tree from the CLI
# Print the full frame tree to frames.pdf
ros2 run tf2_tools view_frames
# Stream one transform live (parent first, child second)
ros2 run tf2_ros tf2_echo map base_link
# Publish a static transform: x y z yaw pitch roll parent child
ros2 run tf2_ros static_transform_publisher 0.15 0 0.2 0 0 0 base_link lidar_link
Plain NumPy Implementation (No ROS Required)
import numpy as np
def make_T(R, p):
"""Build a 4x4 homogeneous transform from a 3x3 R and 3-vector p."""
T = np.eye(4)
T[:3, :3] = R
T[:3, 3] = p
return T
def invert_T(T):
"""Fast inverse using R^T. Avoids np.linalg.inv on 4x4."""
R, p = T[:3, :3], T[:3, 3]
Ti = np.eye(4)
Ti[:3, :3] = R.T
Ti[:3, 3] = -R.T @ p
return Ti
def Rz(theta):
c, s = np.cos(theta), np.sin(theta)
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
# Robot pose in the world: x=3 m, y=1 m, heading 90 deg
T_world_base = make_T(Rz(np.pi / 2), [3.0, 1.0, 0.0])
# LiDAR mounted 0.15 m ahead of base center
T_base_lidar = make_T(np.eye(3), [0.15, 0.0, 0.20])
# Obstacle detected 2 m ahead of the LiDAR (homogeneous coordinates)
p_lidar = np.array([2.0, 0.0, 0.0, 1.0])
# Chain right-to-left: lidar -> base -> world
p_world = T_world_base @ T_base_lidar @ p_lidar
print(p_world[:3]) # [3.0, 3.15, 0.2]
Check by hand: the LiDAR point sits at x = 2.15 in base_link. A 90° yaw rotates +x to +y, so the world point lands at (3.0, 1.0 + 2.15) = (3.0, 3.15). The code agrees.
Sensor Fusion Across Frames: LiDAR + IMU + Odometry
Each sensor reports in its own frame at its own rate. Fusion requires converting everything into one reference frame (typically base_link or odom) at a common timestamp.
| Sensor | Native Frame | Typical Rate | Latency | Frame Pitfall |
|---|---|---|---|---|
| 2D LiDAR | lidar_link |
10-40 Hz | 20-100 ms | Scan points are time-skewed during motion |
| IMU | imu_link |
100-400 Hz | 1-5 ms | Mounting rotation must be removed before use |
| Wheel odometry | odom → base_link |
20-50 Hz | 5-20 ms | Slip drift grows without bound |
| Depth camera | camera_optical_frame |
15-60 Hz | 30-120 ms | Optical vs. body axis convention |
Rotating IMU Data into the Body Frame
If the IMU is mounted with rotation ^base_R_imu, its angular velocity and acceleration must be rotated before fusion:
^base ω = ^base_R_imu * ^imu ω
^base a = ^base_R_imu * ^imu a
Skipping this step is a classic bug: the robot “turns left” in the filter while physically turning right, because the IMU was mounted rotated 180° (the URDF example above).
Compensating LiDAR Motion Distortion
A spinning LiDAR at 10 Hz takes 100 ms per revolution. At 1 m/s the robot moves 0.1 m during one scan. For beam i measured at time t_i, transform it with the pose interpolated at t_i:
^odom p_i = ^odom_base T(t_i) * ^base_lidar T * ^lidar p_i
Interpolate translation linearly and rotation with SLERP between the poses bracketing t_i. tf2 does this for you if you pass each point’s own timestamp to lookup_transform (or use laser_geometry with transformLaserScanToPointCloud).
Practical Workflow: Debugging a Broken Frame Tree
Scenario: A differential-drive robot maps a corridor, but walls appear rotated 180° and doubled.
- Check the tree shape. Run
ros2 run tf2_tools view_frames. You need exactly one parent per frame and no cycles. Two nodes publishingodom → base_linkproduce flickering poses. - Verify axis conventions. Drive forward with
ros2 topic pub /cmd_vel. In RViz, thebase_linkaxes should show red (x) pointing forward. If x points backward, the odometry sign or URDF is wrong. - Confirm sensor mounts. Place an object 1 m directly ahead. The LiDAR point must appear at
x = 1.0inbase_link(minus the mount offset). A 180° error points to a missingrpyyaw in the URDF. - Check timestamps. Run
ros2 topic delay /scan. Delays above 100 ms cause extrapolation errors like “Lookup would require extrapolation into the future.” - Test simulation time. In Gazebo, set
use_sim_time:=trueon every node. A single node on wall time breaks the buffer lookup. - Validate numerically. Use
tf2_echo base_link lidar_linkand compare to your physical tape-measure values within 5 mm.
Common Failure Modes
| Symptom | Root Cause | Fix |
|---|---|---|
| “Extrapolation into the future” | Lookup stamp newer than buffered data | Add timeout, or use Time() for latest |
| Mirror-image map | Left-handed frame or sign error | Verify det(R) = +1 |
| Robot drifts on spot | IMU yaw rate not rotated to body | Apply ^base_R_imu |
| Two disconnected trees | Missing parent link | Publish odom → base_link |
| Jittery pose | Duplicate publishers | Check ros2 topic info /tf -v |
| Gimbal flip in logs | Euler angle printing | Log quaternions, convert for display only |
Manipulator Kinematics: Frames Along a Chain
For a serial arm, assign a frame to each link using Denavit-Hartenberg (DH) parameters. Each joint transform is:
^(i-1)_i T = Rz(θ_i) * Tz(d_i) * Tx(a_i) * Rx(α_i)
Expanded:
| cos θ -sin θ cos α sin θ sin α a cos θ |
| sin θ cos θ cos α -cos θ sin α a sin θ |
| 0 sin α cos α d |
| 0 0 0 1 |
Forward kinematics is the product down the chain:
^0_n T = ^0_1 T(q1) * ^1_2 T(q2) * ... * ^(n-1)_n T(qn)
Example: a planar 2-link arm with link lengths L1 = 0.30 m, L2 = 0.25 m gives the end-effector position:
x = L1 cos(q1) + L2 cos(q1 + q2)
y = L1 sin(q1) + L2 sin(q1 + q2)
Inverse kinematics (elbow-down solution):
cos(q2) = (x² + y² - L1² - L2²) / (2 L1 L2)
q2 = atan2( +sqrt(1 - cos²(q2)), cos(q2) )
q1 = atan2(y, x) - atan2( L2 sin(q2), L1 + L2 cos(q2) )
If |cos(q2)| > 1, the target lies outside the reachable workspace. Always check this before calling acos or atan2.
Hand-Eye Calibration
To grasp what a camera sees, you need ^base_T_cam. For an eye-in-hand setup, solve AX = XB where A is the gripper motion between two poses, B is the camera-observed motion, and X is the unknown camera-to-gripper transform. OpenCV provides cv2.calibrateHandEye with Tsai, Park, and Daniilidis methods. Collect at least 10-15 diverse poses with at least 30° of rotation variation between them for a stable solution.
Numerical Hygiene: Keeping Transforms Valid
Repeated multiplication accumulates floating-point error, so R drifts away from orthonormality. Re-orthonormalize with SVD:
import numpy as np
def orthonormalize(R):
"""Project a drifting matrix back onto SO(3) via SVD."""
U, _, Vt = np.linalg.svd(R)
R_clean = U @ Vt
if np.linalg.det(R_clean) < 0: # guard against reflection
U[:, -1] *= -1
R_clean = U @ Vt
return R_clean
For quaternions, renormalize after each integration step: q = q / np.linalg.norm(q). At a 500 Hz gyro integration rate, renormalizing every step costs under 1 µs on a modern ARM core.
Frequently Asked Questions
What is the difference between a coordinate frame and a transform?
A coordinate frame is a reference system (an origin plus three orthogonal axes). A transform is the rigid-body relationship between two frames: a rotation and a translation. The transform converts coordinates of the same physical point from one frame’s description to another’s.
Why do roboticists use 4×4 matrices instead of separate rotation and translation?
A 4×4 homogeneous transformation matrix merges rotation and translation into a single linear operation. Chaining N frames then becomes N-1 matrix multiplications with no special cases, and the inverse has a closed-form structure.
Should I use quaternions or rotation matrices?
Use quaternions to store, transmit, and interpolate orientations (4 values, no gimbal lock, smooth SLERP). Use rotation matrices when transforming many points at once, since one matrix multiply per point is faster than a quaternion sandwich product. ROS2 messages use quaternions; most point-cloud libraries use matrices internally.
What does “Lookup would require extrapolation into the future” mean in tf2?
It means you requested a transform at a timestamp newer than the latest data in the tf2 buffer. Fix it by passing Time() (latest available), increasing the lookup timeout, reducing publisher latency, or confirming all nodes share the same use_sim_time setting.




