Coordinate Conventions, or: Why Your Transform Is Inverted

8 minute read

Almost every multi-sensor bug I have chased came down to one of three things: a transform applied in the wrong direction, a quaternion whose components were in the wrong order, or two libraries that disagreed about which way $+Y$ points. None of these are hard problems. They are bookkeeping problems, and they survive because the notation most people use is ambiguous.

This is the convention I use and the table I keep open.

Say what you mean: “pose of” versus “transform from”

The sentence “the camera transform” has two readings, and they are inverses of each other:

  • the pose of the camera, expressed in the world frame
  • the transform from world coordinates into camera coordinates

Both are described by the same six numbers, and they are not the same six numbers.

So don’t say “the camera transform.” Write $T_{W \leftarrow C}$ — read as “world from camera” — for the rigid transform that takes a point expressed in camera coordinates and re-expresses it in world coordinates:

\[p^W = T_{W \leftarrow C} \; p^C, \qquad T_{W \leftarrow C} = \begin{bmatrix} R_{W \leftarrow C} & t_{W \leftarrow C} \\ 0 & 1 \end{bmatrix}\]

The payoff is that chaining becomes mechanical — adjacent frames cancel:

\[T_{W \leftarrow C} = T_{W \leftarrow B} \; T_{B \leftarrow C}\]

In code I name the variable after the subscripts, T_world_cam, and then a chain is correct exactly when the inner names match:

T_world_cam = T_world_base @ T_base_cam    # base cancels -> world_cam. Correct.
T_world_cam = T_world_base @ T_cam_base    # base vs cam. Wrong, and it is now obvious.

That single naming rule has caught more bugs for me than any amount of careful thinking. Note also that $T_{W \leftarrow C}$ is the pose of the camera in the world — the two readings above are $T_{W \leftarrow C}$ and $T_{C \leftarrow W}$ respectively.

Written out, with the two operations you actually need:

import numpy as np
from scipy.spatial.transform import Rotation

def make_T(R, t):
    T = np.eye(4)
    T[:3, :3], T[:3, 3] = R, t
    return T

def invert_T(T):
    R, t = T[:3, :3], T[:3, 3]
    return make_T(R.T, -R.T @ t)          # NOT just R.T -- the -R^T t matters

# scipy's from_quat is scalar-LAST: (x, y, z, w)
R_world_base = Rotation.from_quat([0.0, 0.0, 0.3827, 0.9239]).as_matrix()   # 45 deg yaw
T_world_base = make_T(R_world_base, np.array([10.0, 4.0, 0.0]))
T_base_cam   = make_T(np.eye(3), np.array([1.5, 0.0, 1.2]))

T_world_cam = T_world_base @ T_base_cam   # base cancels
cam_position_in_world = T_world_cam[:3, 3]

And the inverse is not the transpose:

\[T_{W \leftarrow C}^{-1} = T_{C \leftarrow W} = \begin{bmatrix} R^{\top} & -R^{\top} t \\ 0 & 1 \end{bmatrix}\]

Only the rotation block transposes. Forgetting the $-R^{\top}t$ gives you a pose with the right orientation in a plausible-but-wrong location, which is the hardest kind of wrong to notice.

Axis conventions

All of these are right-handed. They still disagree.

Three right-handed axis conventionsThe OpenCV camera frame has X right, Y down and Z into the scene. The OpenGL camera frame has X right, Y up and Z out of the scene toward the viewer. The ROS body frame, viewed from above, has X forward, Y to the left and Z up out of the page. All three are right-handed; they disagree about which physical direction each axis points. OPENCV / COLMAP CAMERA X Y Z Z into the page (into the scene) OPENGL CAMERA X Y Z Z out of the page (toward viewer) ROS BODY — REP-103 forward X Y left Z viewed from above; Z up
The same right-handed rule, three different assignments. ⊗ is into the page, ⊙ is out of it.
Convention$+X$$+Y$$+Z$
OpenCV / COLMAP camerarightdownforward (into scene)
OpenGL camerarightupbackward (out of scene)
ROS body frame (REP‑103)forwardleftup
ROS camera optical framerightdownforward

Two traps here.

The OpenCV↔OpenGL flip. Going between a reconstruction pipeline and a renderer means flipping $Y$ and $Z$. If your point cloud renders upside down and inside out, this is why.

ROS has two camera frames on purpose. REP‑103 specifies that camera_link follows the body convention (x forward, y left, z up), while camera_link_optical — anything with the _optical_frame suffix — uses the vision convention (z forward, y down). They describe the same physical camera. Publishing your OpenCV-derived extrinsics onto the non-optical frame is a very common and very confusing mistake, because everything is almost right.

For world frames, REP‑103 specifies ENU (east-north-up), not the NED that aviation and many IMU datasheets use. That is another $Y$/$Z$ flip waiting to happen at the driver boundary.

Quaternion component order

Same four numbers, three orderings in wide use:

APIOrder
Eigen::Quaterniond(w, x, y, z) constructorscalar first
Eigen .coeffs() / internal storagescalar last
ROS 2 geometry_msgs/msg/Quaternionx, y, z, w — scalar last
scipy Rotation.from_quatscalar last by default
gtsam::Rot3::Quaternion(w, x, y, z)scalar first

Eigen is its own worst enemy here: the constructor takes $w$ first, but .coeffs() returns it last. Passing q.coeffs().data() to something expecting scalar-first gives you a rotation that is wrong in a way that still looks like a valid rotation.

The cheap defence is a unit test with a known non-symmetric rotation — 90° about $Z$, say — carried through every serialization boundary in your stack. Symmetric test cases like 180° rotations will happily pass with the components scrambled.

Library-specific notes

OpenCV. solvePnP returns rvec, tvec satisfying $p^{C} = R\,p^{W} + t$. That is $T_{C \leftarrow W}$ — world from camera is the inverse of what you got back. If you want the camera’s position in world coordinates it is $-R^{\top}t$, not tvec. cv2.Rodrigues converts between the 3-vector rvec and the matrix; the vector’s direction is the axis and its norm is the angle in radians.

ROS 2 / tf2.

A tf2 transform treeFrames form a strict tree: map is the parent of odom, odom of base_link, and base_link of the sensor frames. The map to odom edge jumps when localization corrects; the odom to base_link edge is continuous but drifts. Each camera has both a body-convention frame and an optical-convention child. map T_map_odom — jumps on correction odom T_odom_base — continuous, drifts base_link lidar camera_link camera_link_optical imu_link one parent per frame, no cycles
A minimal tf2 tree. The map → odom edge absorbs localization corrections so that odom → base_link can stay smooth.

The tree is a strict tree: every frame has exactly one parent, no cycles. REP‑105 fixes the chain as earth → map → odom → base_link → sensor. The split between map and odom is the useful part of that design — odom is continuous but drifts, map is drift-free but jumps when localization corrects. Controllers consume odom because a discontinuity is worse than slow drift; planners consume map.

lookupTransform(target, source, time) returns $T_{\text{target} \leftarrow \text{source}}$, which composes in the order you would hope. Note the argument order is the opposite of the reading order in the name.

GTSAM. Pose3 is the pose of a body in the world, i.e. $T_{W \leftarrow B}$. pose.transformFrom(p) takes a body-frame point to world; pose.transformTo(p) goes the other way. A BetweenFactor<Pose3> between poses $i$ and $j$ measures $T_i^{-1} T_j$, the relative pose expressed in frame $i$ — so odometry goes in directly, but only if your odometry is also expressed in the previous body frame rather than the world frame. GTSAM’s PinholeCamera uses the OpenCV optical convention, which makes it easy to mix with cv2 output as long as you keep the pose direction straight.

A checklist

When a transform looks wrong, in order of how often it turns out to be the cause:

  1. Direction. Try the inverse. It is right about half the time, and it costs one line.
  2. Component order at a serialization boundary — quaternion, or Euler angle sequence.
  3. Axis convention — optical versus body, ENU versus NED.
  4. Point versus vector. A direction rotates but must not translate. Homogeneous $w=0$ for directions, $w=1$ for points; using $w=1$ on a velocity or a surface normal adds the translation to it.
  5. Timestamps. On a moving platform, the right transform at the wrong time is the wrong transform. At 20 m/s, 50 ms is a metre.

The last one is the one that keeps working in the parking lot and fails on the highway, which makes it worth more scrutiny than its position on this list suggests.