FixTheRobot Explore Robotics / Systems Engineering / Engineering Guides / Module 06

Engineering Guides · Module 06

Frame conventions and transform direction

Most calibration and TF bugs are direction bugs. This guide fixes one convention, shows how tf2 maps onto it, and gives a worked, tested example.

  • Intermediate
  • Module 06: Camera–LiDAR Calibration & Coordinate Transformations
  • Written for ROS 2 Jazzy where ROS 2 is used
  • Sources checked 2026-11-04

Assumptions: Right-handed frames; ROS conventions (x forward, y left, z up for body and LiDAR frames; x right, y down, z forward for camera optical frames). Python examples use NumPy and are executed by the site’s tests; ROS 2 commands are written for Jazzy and are not.

1. Engineering problem

A LiDAR point must be compared with a camera pixel, an IMU orientation applied to a point cloud, or a detection moved into the map. Each step multiplies by a transform, and the same numbers describe two opposite operations: the pose of frame B in frame A, and the map that takes a point from A to B. Get the direction wrong and the result is still a plausible-looking point cloud, only rotated or shifted by the wrong amount.

2. When is this needed?

  • Any robot with more than one sensor: LiDAR + camera, IMU + LiDAR, camera + arm.
  • Whenever you copy extrinsics from a calibration tool into a URDF or static_transform_publisher.
  • When point clouds look mirrored, rotated by 90°, or offset by the mounting distance.

3. Selection and design decisions

Pick one notation for the whole project and write it in the interface control document.

Transform notations compared
NotationMeaningUse it when
T_AB (this site)Maps coordinates in frame B into frame A: P_A = T_AB · P_B. Its translation is the position of B’s origin in A; its rotation is the orientation of B in A.Always. Subscripts cancel on the inside: T_AC = T_AB · T_BC.
tf2 lookup_transform(target, source)Returns T_target,source: converts points from source into target.Reading TF. “target” is the frame you want the answer in.
URDF <joint> parent/child + <origin>The origin is the pose of the child in the parent: T_parent,child.Writing mounting offsets.
“extrinsics from A to B”Ambiguous: it can mean T_BA or T_AB. Read the tool’s documentation and test with a known point.Never trust the name; verify with a point.

Choosing a rotation representation

Rotation representations
RepresentationGood forWatch out for
Rotation matrix (3×3)Applying and composing rotations; unambiguousDrift from orthonormality after many multiplications; 9 numbers
Quaternion (x, y, z, w)Storage, interpolation, TF messagesq and −q are the same rotation; must be normalised; ROS uses x, y, z, w order
Roll-pitch-yawHuman-readable configurationOrder and axes conventions; gimbal lock at ±90° pitch; never interpolate

4. Advantages and disadvantages

  • Matrix form everywhere: no ambiguity, easy to verify, slightly more storage. Best inside your own code.
  • RPY in configuration files: easy to read and review, but you must state the order. Convert to a matrix immediately on load.
  • Quaternions in messages: compact and standard in ROS; never edit them by hand.

5. Requirements and constraints

  • All frames right-handed; units metres and radians (ROS convention).
  • Camera optical frames differ from the camera body frame: optical is x right, y down, z forward.
  • Every transform in TF has exactly one parent. A frame cannot have two publishers.
  • Time matters: a transform is valid at a time. Static mounting transforms do not change; odom → base_link does.

6. Architecture

A typical mobile robot frame tree (the arrows are parent → child, and each edge stores T_parent,child):

map
 └─ odom                      (published by the localisation estimator)
     └─ base_link             (published by the odometry/EKF)
         ├─ imu_link          (static, from the URDF)
         ├─ lidar             (static, x forward, y left, z up)
         └─ camera_link       (static)
             └─ camera_optical (static, x right, y down, z forward)

The fixed rotation between camera_link and camera_optical is the same optical-to-body matrix used in the example below.

7. Implementation

Rotation and translation in code

import numpy as np

def rpy_to_R(roll, pitch, yaw):            # radians; R = Rz(yaw) @ Ry(pitch) @ Rx(roll)
    cr, sr, cp, sp, cy, sy = np.cos(roll), np.sin(roll), np.cos(pitch), np.sin(pitch), np.cos(yaw), np.sin(yaw)
    Rz = np.array([[cy, -sy, 0], [sy, cy, 0], [0, 0, 1]])
    Ry = np.array([[cp, 0, sp], [0, 1, 0], [-sp, 0, cp]])
    Rx = np.array([[1, 0, 0], [0, cr, -sr], [0, sr, cr]])
    return Rz @ Ry @ Rx

def make_T(R, t):                           # T_AB: maps coordinates in frame B into frame A
    T = np.eye(4)
    T[:3, :3] = R
    T[:3, 3] = t
    return T

def transform(T, p):
    return (T @ np.append(p, 1.0))[:3]

# camera optical frame (x right, y down, z forward) -> lidar frame (x forward, y left, z up)
R_LC = np.array([[0, 0, 1], [-1, 0, 0], [0, -1, 0]], dtype=float)
T_LC = make_T(R_LC, [0.1, 0.0, 0.3])        # camera origin: 0.1 m ahead of and 0.3 m above the lidar origin
P_C = np.array([0.0, 0.0, 5.0])             # 5 m straight ahead of the camera
P_L = transform(T_LC, P_C)                  # P_L = T_LC * P_C
print(np.round(P_L, 3) + 0.0)
T_CL = np.linalg.inv(T_LC)                  # the opposite direction: lidar -> camera
print(np.round(transform(T_CL, P_L), 3) + 0.0)
print(np.round(T_CL[:3, 3], 3) + 0.0)

Output of this block (checked by the site’s tests):

[5.1 0.  0.3]
[0. 0. 5.]
[ 0.   0.3 -0.1]

Read it as: a point 5 m straight ahead of the camera is 5.1 m ahead of the LiDAR and 0.3 m above it; the inverse transform returns it; the inverse translation is −Rᵀt, not −t.

The same in ROS 2 (Jazzy)

Not executed by the site’s automated tests (they need a ROS 2 installation).

# publish T_base_lidar: the pose of the "lidar" frame in "base_link"
ros2 run tf2_ros static_transform_publisher \
    --x 0.1 --y 0.0 --z 0.3 --roll 0.0 --pitch 0.0 --yaw 0.0 \
    --frame-id base_link --child-frame-id lidar

# read it back: prints the transform that converts points from "lidar" into "base_link"
ros2 run tf2_ros tf2_echo base_link lidar

# draw the whole tree to frames.pdf
ros2 run tf2_tools view_frames

--frame-id is the parent and --child-frame-id the child. The numbers are T_parent,child: they convert child coordinates into the parent frame.

Try your own numbers in the Coordinate Transformation Calculator, which shows every intermediate step, and see how projection differs from transformation in the Camera–LiDAR Projection Visualizer.

8. Real examples

  1. Mirrored cloud: a handedness error (a reflection matrix with determinant −1 pasted in from a spreadsheet). Check det(R) = +1.
  2. Everything rotated by 90°: optical and body frames mixed up. A camera’s +z (forward) must become the LiDAR’s +x.
  3. Detections offset by exactly the mounting distance: the translation was applied in the wrong direction, i.e. T_AB was used where T_BA was needed.

9. Failure scenarios

  • Using −t instead of −Rᵀt when inverting by hand.
  • Composing in the wrong order: T_AB · T_BC applies C→B first. T_BC · T_AB is nonsense unless the frames happen to line up.
  • Mixing RPY conventions between tools (degrees vs radians, fixed vs rotating axes).
  • Non-unit quaternions or matrices that are no longer rotations after rounding.
  • Two nodes publishing the same child frame, so the transform flickers.

10. Troubleshooting

  1. Draw the tree: ros2 run tf2_tools view_frames. Look for a frame with two parents or a broken chain.
  2. Read the transform with ros2 run tf2_ros tf2_echo <parent> <child> and compare it with the CAD or tape-measure mounting: the translation must be where the child frame’s origin really is, expressed in the parent.
  3. Choose a physical test point (an object at a measured distance straight ahead of the sensor). Transform it by hand with the calculator. If the answer is wrong, the direction or the axes are wrong.
  4. Check the determinant of every rotation you pasted in (+1) and that columns are unit length.
  5. Check which tool produced the numbers: does it output T_AB or T_BA? When in doubt, apply the inverse and see whether the test point lands correctly.

A dedicated “Incorrect TF transforms” diagnostic is listed in the troubleshooting section and is not written yet.

11. Validation

  • Round trip: transforming a point and then applying the inverse returns the original within 1e-9.
  • A known physical target (a box at 5.0 m) appears at the right coordinates in both sensor frames.
  • While the robot rotates in place, static objects do not slide in the map.
  • Checks that the site’s tests run on this page’s example: the printed values above.

12. Engineering checklist

13. Further references

  • ROS REP 103 (units and coordinate conventions) and REP 105 (map, odom, base_link): REP 103, REP 105. Facts used here (x forward, y left, z up; the _optical suffix for camera frames; odom continuous but drifting; map non-drifting but able to jump) were verified against the REP sources on GitHub (rep-0103.rst, rep-0105.rst) on 2026-11-05.
  • tf2 command-line tools (tf2_echo, view_frames, static_transform_publisher): ROS 2 documentation for your distribution (Jazzy). Command names are well established but the documentation could not be fetched in the build environment.

Related on FixTheRobot