All posts

3D Rotations and Coordinate Frames for Robotics: Quaternions, Euler Angles and Every Convention

Rotation matrices, Euler angles, quaternions and 4x4 transforms, plus the axis and quaternion conventions of ROS, PX4, Unity, Unreal, Blender and glTF.

Illustration for 3D Rotations and Coordinate Frames for Robotics: Quaternions, Euler Angles and Every Convention

3D rotations and coordinate frames are how a robot says where everything is and which way it points: a camera on a mast, a gripper at the end of an arm, a drone over a field. The same orientation can be written as a 3x3 rotation matrix, three Euler angles, an axis and an angle, or a four-number quaternion, and each robotics tool picks its own axis directions, handedness, angle order and quaternion component order. ROS stores quaternions as x, y, z, w; MuJoCo, PX4 and MAVLink use w, x, y, z. ROS puts z up and x forward; Unity puts y up and is left-handed. Most orientation bugs come from two pieces of correct code that assumed different conventions.

This guide covers the mathematics once, plainly, and then the conventions of the tools robotics teams actually connect: ROS 2 and tf2, URDF, Eigen, SciPy, MuJoCo, PX4 and MAVLink, OpenCV, Unity, Unreal Engine, Blender, three.js and glTF. Every convention below was checked against the project's own documentation or source code, and every worked number was computed. To check a conversion of your own as you read, the Frame and Rotation Converter shows one rotation in every representation at once and re-expresses it between ROS, NED, Unity, Unreal, OpenCV, OpenGL and Blender frames.

The take: store orientations as quaternions or rotation matrices, and use Euler angles only at the human edge (config files, logs, UI). Write the frame names into every variable (T_map_base, q_world_imu), label quaternion order wherever you serialise one, and keep radians internally. When you cross a tool boundary, convert with an explicit basis-change matrix and check it with one known vector before trusting it.

Companion reading: motion planning and kinematics, robot calibration, IMUs and inertial navigation, and ROS 2.

Table of contents

  1. Key takeaways
  2. Why orientation bugs are so common
  3. Frames, and what a transform actually means
  4. Rotation matrices
  5. Euler angles: 12 sequences, intrinsic and extrinsic, gimbal lock
  6. Axis-angle and rotation vectors
  7. Quaternions: Hamilton and JPL, storage order, double cover, slerp
  8. Homogeneous transforms and composition order
  9. Which tool uses which convention
  10. Converting between conventions, with worked examples
  11. tf2 frame names: parent, child, target, source
  12. The common bugs
  13. Debugging checklist
  14. Frequently asked questions
  15. Sources

Why orientation bugs are so common

A position is easy to write down: three numbers and a unit. An orientation is one physical thing with many encodings, and the encodings carry conventions that never appear in the numbers themselves. The quaternion [0, 0, 0.7071, 0.7071] is a 90 degree turn about z if the scalar is last, and a 180 degree turn about the axis (0, 0.7071, 0.7071) if the scalar is first. Nothing in the four numbers tells you which.

ROS REP 103 states the problem directly in its rationale: "Inconsistency in units and conventions is a common source of integration issues for developers and can also lead to software bugs." It ranks rotation representations by preference: quaternion first ("No singularities"), then rotation matrix, then fixed-axis roll, pitch and yaw about X, Y and Z, and last Euler angles, which it says "are generally discouraged due to having 24 'valid' conventions with different domains using different conventions by default."

Robots make this worse because they sit at the meeting point of several traditions. The drivetrain and navigation stack follow ROS. The flight controller follows aerospace NED. The perception stack follows OpenCV's camera frame. The simulator or digital twin follows a game engine or a physics engine. Every interface between them is a place where two correct conventions meet.

Frames, and what a transform actually means

A coordinate frame is an origin plus three orthogonal unit axes. A transform relates two frames. The clearest notation names both: R_a_b (or T_a_b with translation) is the pose of frame b expressed in frame a. It answers two questions at once:

  1. Where is b? The columns of R_a_b are b's x, y and z axes written in a's coordinates, and the translation is b's origin in a's coordinates.
  2. How do I move a point's coordinates from b to a? p_a = R_a_b * p_b (plus the translation for points).

With this naming, chains read correctly when the inner names cancel: R_a_c = R_a_b * R_b_c. The inverse swaps the names: R_b_a = R_a_b^T.

Active and passive. The same matrix can be read two ways. Read actively, it rotates a vector inside one fixed frame: the vector moves, the axes stay. Read passively, it re-expresses a fixed vector in a different frame: the vector stays, the axes move. A rotation of the body by +30 degrees about z (active) produces exactly the same matrix as the coordinates change from the body frame to the world frame (passive). Trouble starts when one piece of code builds a matrix as a passive "world to body" change and another treats it as the active "body to world" rotation: the two are transposes of each other, and the result is the inverse rotation. Joan Solà's quaternion reference lists "rotating frames or rotating vectors" as one of the four binary choices that separate quaternion conventions.

Handedness. In a right-handed frame, x cross y equals z, and a positive rotation about an axis turns counter-clockwise when you look back down that axis toward the origin (the right-hand rule). In a left-handed frame, the axes are arranged the other way round and positive rotations follow the left hand. A change of basis between a right-handed and a left-handed frame has determinant -1: it is a mirror.

Rotation matrices

A 3x3 rotation matrix R is orthonormal with determinant +1: R^T R = I and det(R) = 1. Its inverse is its transpose. It has nine numbers for three degrees of freedom, so it is redundant, and that redundancy is useful. It has no singularities, composition is a plain matrix product, and you can check validity by computing R^T R and det(R).

The elementary rotations about the axes of a right-handed frame are:

Rx(a) = [1    0       0   ]   Ry(a) = [ cos a  0  sin a]   Rz(a) = [cos a  -sin a  0]
        [0  cos a  -sin a ]           [   0    1    0  ]           [sin a   cos a  0]
        [0  sin a   cos a ]           [-sin a  0  cos a]           [  0       0    1]

Two practical points:

  • Drift. Multiplying many matrices in floating point slowly breaks orthonormality. Re-orthonormalise periodically, for example by taking the SVD R = U S V^T and replacing R with U V^T (flip a sign if the determinant comes out -1).
  • Determinant -1 means a mirror. If a matrix pasted from a log has determinant -1, it reflects as well as rotates. That nearly always means a handedness mismatch or a single flipped axis sign somewhere upstream.

Storage order is its own convention. OpenCV Mat and default NumPy arrays are row-major. glTF stores matrix elements in column-major order, Blender's C code indexes matrices as mat[column][row], and three.js keeps Matrix4.elements column-major. Reading a column-major buffer as row-major silently transposes it, which inverts the rotation.

Euler angles: 12 sequences, intrinsic and extrinsic, gimbal lock

Euler angles describe an orientation as three successive rotations about coordinate axes. They are the representation people can read, and the one with the most ways to go wrong.

The 12 sequences. Rotations about the same axis twice in a row collapse into one, so valid sequences use either three different axes or the same first and last axis:

Family Sequences Common names
Tait-Bryan (three different axes) XYZ, XZY, YXZ, YZX, ZXY, ZYX roll, pitch, yaw; heading, attitude, bank
Proper Euler (first and last axis equal) XYX, XZX, YXY, YZY, ZXZ, ZYZ classical mechanics, some robot wrists

Intrinsic or extrinsic. Each sequence can be applied about axes that move with the body after each step (intrinsic) or about axes fixed in the parent frame (extrinsic). That doubles 12 to 24, which is the count REP 103 gives. The two families are linked by a simple identity: an intrinsic rotation sequence A, B, C with angles (a, b, c) produces the same matrix as the extrinsic sequence C, B, A with angles (c, b, a). In matrix form, intrinsic Z-Y-X is Rz(yaw) Ry(pitch) Rx(roll), and extrinsic x-y-z is the same product.

That identity is why URDF and ROS can describe "roll, pitch, yaw" in two ways that agree. REP 103 calls its third option "fixed axis roll, pitch, yaw about X, Y, Z axes respectively", which is extrinsic. urdfdom's setFromRPY builds exactly that quaternion, and MuJoCo's documentation states that "the 'rpy' convention used in URDF corresponds to 'XYZ' in MJCF", where upper case means fixed axes. The result equals intrinsic yaw, pitch, roll about Z, Y, X.

Library notation disagrees. Even the letter case means opposite things:

Library How it names intrinsic and extrinsic Default
SciPy Rotation.from_euler Upper case XYZ intrinsic, lower case xyz extrinsic No default; you must pass a sequence
MuJoCo compiler/eulerseq Lower case intrinsic ("axes that rotate with the frame"), upper case extrinsic "xyz", so intrinsic XYZ
tf_transformations (ROS Python) Prefix s static (extrinsic), r rotating (intrinsic) sxyz
Eigen eulerAngles(a0, a1, a2) Always intrinsic: R = AngleAxis(e0, a0) * AngleAxis(e1, a1) * AngleAxis(e2, a2) None

A worked example shows how large the difference is. Take the rotation built from intrinsic ZYX angles (yaw 30, pitch 20, roll 10 degrees). In SciPy, as_euler("ZYX", degrees=True) returns (30, 20, 10). as_euler("zyx", degrees=True) on the same rotation returns (28.45, 22.24, -1.12), and as_euler("xyz", degrees=True) returns (10, 20, 30), which is the same rotation written extrinsically in the reversed order. One rotation, three sets of numbers, all correct for their convention.

Angle ranges. Extraction functions choose a range for each angle, and those choices differ. Eigen's documentation notes that eulerAngles returns its first angle in [0, pi] and the other two in [-pi, pi], a "non-canonical" range, so a yaw of -30 degrees can come back as 150 degrees with the other two angles adjusted. Eigen's current development documentation adds canonicalEulerAngles, which returns Tait-Bryan angles in [-pi, pi] x [-pi/2, pi/2] x [-pi, pi]. Two valid triples for the same rotation are common, so tests should compare matrices or quaternions.

Gimbal lock. When the middle angle of a Tait-Bryan sequence reaches plus or minus 90 degrees (or the middle angle of a proper Euler sequence reaches 0 or 180 degrees), the first and third rotation axes line up and one degree of freedom disappears from the parameterisation. With intrinsic ZYX at pitch +90 degrees, only yaw minus roll matters: (yaw 30, pitch 90, roll 10), (yaw 20, pitch 90, roll 0) and (yaw 40, pitch 90, roll 20) are the same rotation. An extraction routine at that point has to choose one, small changes in orientation produce large jumps in the angles, and filters or controllers that work in Euler angles become badly conditioned nearby. The orientation itself is fine. The problem is in the three-number description, which is why REP 103 ranks quaternions and matrices first and why estimators such as the error-state Kalman filter keep their state in quaternions.

Axis-angle and rotation vectors

Every rotation is a single turn by angle theta about some unit axis n (Euler's rotation theorem). Axis-angle stores the pair (n, theta). The rotation vector packs it into three numbers, r = theta * n, so the angle is the vector's length. Rotation vectors are what OpenCV calls rvec, what SciPy calls rotvec, and what Lie-group texts call the exponential coordinates of SO(3).

The conversion to a matrix is Rodrigues' formula, as written in OpenCV's Rodrigues documentation: with theta the norm of r and n = r / theta, R = cos(theta) I + (1 - cos(theta)) n n^T + sin(theta) [n]x, where [n]x is the skew-symmetric cross-product matrix. For example, rvec = (0, 0, pi/2) gives the matrix of a 90 degree turn about z.

Things to know about rotation vectors:

  • They are compact and have no gimbal lock, which is why optimisers use them. OpenCV's documentation calls the rotation vector "a convenient and most compact representation of a rotation matrix (since any rotation matrix has just 3 degrees of freedom)" and uses it in calibrateCamera, stereoCalibrate and solvePnP.
  • At theta = 180 degrees, r and -r describe the same rotation. Near theta = 0, the axis is numerically undefined, so implementations use series expansions.
  • In OpenCV, rvec and tvec from solvePnP bring points "from the model coordinate system to the camera coordinate system". That is the pose of the object in the camera frame. The camera's pose in the object frame is the inverse.
  • Rotation vectors do not add like vectors. r1 + r2 is only a good approximation when the rotations are small.

Quaternions: Hamilton and JPL, storage order, double cover, slerp

A unit quaternion q = (w, x, y, z) with w = cos(theta/2) and (x, y, z) = sin(theta/2) * n encodes the rotation by theta about n. SciPy's documentation gives exactly these formulas, and MuJoCo's modelling guide gives the same quaternion (cos(a/2), sin(a/2) * (x, y, z)). Quaternions have four numbers for three degrees of freedom, no singularities, cheap composition and clean interpolation. That is why REP 103 lists them first.

Hamilton and JPL. Solà's "Quaternion kinematics for the error-state Kalman filter" separates quaternion conventions into four binary choices: component order (real part first or last), the algebra (ij = k or ij = -k, which corresponds to right-handed or left-handed rotations), whether the operator rotates frames or vectors, and the direction of the passive operation (local to global or global to local). His Table 2 summarises the two best-documented conventions:

Choice Hamilton JPL
Component order in the definition (qw, qv) (qv, qw)
Algebra ij = k ij = -k
Handedness Right-handed Left-handed
Default operation x_G = q ⊗ x_L ⊗ q* (local to global) x_L = q ⊗ x_G ⊗ q* (global to local)

Solà notes that "JPL is mostly used in the aerospace domain, while Hamilton is more common to other engineering areas such as robotics", and that the Hamilton convention "coincides with many software libraries of widespread use in robotics, such as Eigen, ROS, Google Ceres". The JPL quaternion appears in the visual-inertial odometry literature by Li, Mourikis, Roumeliotis and colleagues, which draws on Trawny and Roumeliotis' technical report. PX4 states in its VehicleAttitude message that "the quaternion uses the Hamilton convention".

One subtlety catches people: the Hamilton definition lists w first, yet ROS and Eigen store w last and are still Hamilton. The algebra (ij = k) defines the convention, and storage order is a separate serialisation choice. Porting equations from a JPL paper into a Hamilton library needs the algebra changed, which a reorder alone does not do.

Storage order. This is the most common quaternion bug in robotics code, so it gets its own column in the conventions table. Feeding the ROS quaternion for a 90 degree yaw, [0, 0, 0.7071, 0.7071] in x, y, z, w order, into an API that expects w, x, y, z produces a 180 degree rotation about the axis (0, 0.7071, 0.7071). The robot flips over where it should have turned left.

Double cover. q and -q represent the same rotation. Negating all four components is the same as adding a full turn (360 degrees) to the rotation angle: both cos(theta/2) and sin(theta/2) change sign, and the physical rotation is unchanged. Consequences:

  • Two quaternions from different sources can differ in sign and still agree. Compare them by checking that |q1 · q2| is close to 1; a component-by-component check fails on the sign.
  • The angular distance between orientations is 2 * acos(|q1 · q2|). Yaw 10 degrees and yaw 350 degrees in SciPy come back as (0, 0, 0.0872, 0.9962) and (0, 0, 0.0872, -0.9962). Their dot product is -0.9848; without the absolute value the formula gives 340 degrees, and with it, the correct 20 degrees.
  • Some libraries offer a canonical form with w >= 0; SciPy's as_quat has a canonical option. Use it before logging or hashing quaternions.

Normalisation. Only unit quaternions are rotations. A quaternion that has drifted to norm 0.99 and is pushed through the matrix formula produces a matrix that is no longer orthonormal. For example, (0, 0, 0.7, 0.7) (norm 0.99) gives a matrix with determinant 0.9608 and diagonal entries of 0.02 where 0 was intended. Some libraries normalise on input (SciPy says each quaternion "will be normalized to unit norm"; MuJoCo normalises quat at compile time; glTF says the rotation quaternion "MUST be unit"), and others trust you. Normalise after every integration step and before every conversion.

Composition. With Hamilton quaternions, q_a_c = q_a_b ⊗ q_b_c, the same order as matrices. In SciPy, p * q means apply q first, then p: composing a 90 degree turn about z (A) and then a 90 degree turn about x (B) is B * A, which sends the x axis to +z, while A * B sends it to +y. Unity's Unity.Mathematics source composes its default Euler rotation as mul(rotateY, mul(rotateX, rotateZ)), the same right-to-left rule.

Interpolation. Spherical linear interpolation (slerp) moves at constant angular speed along the shortest great-circle arc between two unit quaternions: slerp(q0, q1, t) = q0 ⊗ (q0^-1 ⊗ q1)^t. Halfway between identity and a 90 degree yaw is a 45 degree yaw. Before slerping, flip q1 to -q1 if q0 · q1 < 0, or the interpolation takes the long way round. Normalised linear interpolation (lerp then normalise) is a cheaper approximation that is close for small angles and does not move at constant speed.

Homogeneous transforms and composition order

A rigid transform combines a rotation R and a translation t into a 4x4 matrix that acts on points written as (x, y, z, 1):

T_a_b = [ R_a_b   t_a_b ]      p_a = T_a_b * p_b
        [ 0 0 0     1   ]

OpenCV's calibration documentation uses exactly this form for the change of basis from world to camera coordinates, and gives the inverse used to go the other way: T^-1 = [R^T, -R^T t; 0, 1].

Composition order. Transforms chain right to left, and the inner frame names should cancel. A worked example: a robot sits at (2, 1, 0) in the map, yawed 90 degrees. Its camera is mounted 0.3 m forward and 0.5 m up from base_link, aligned with the base. A point 1 m in front of the camera is at:

p_map = T_map_base * T_base_cam * p_cam = (2.0, 2.3, 0.5)

Swapping the order to T_base_cam * T_map_base * p_cam gives (2.3, 2.0, 0.5), a plausible-looking answer that is wrong by about 0.4 m. Nothing crashes. That is the danger.

The inverse of T_map_base in this example is the rotation part transposed and the translation -R^T t = (-1, 2, 0). Do not invert transforms with a general 4x4 inverse in tight loops; the closed form is cheaper and keeps R orthonormal.

Points and directions. Points take the translation; direction vectors (velocities, normals, axes) take only the rotation. In homogeneous form a direction has a fourth component of 0. Applying a full transform to a velocity adds the frame offset to it, an easy bug to write in odometry code.

Which tool uses which convention

The table lists what each project documents in its own reference pages or source code. Where a tool has no single world convention (Eigen, SciPy), the axes are whatever your application defines. The Frame and Rotation Converter puts these conventions side by side, generates the code snippet for each target, and shows the basis-change matrix and whether a conversion flips handedness.

Tool or standard Quaternion order Euler convention Axes and handedness Units
ROS REP 103 and geometry_msgs/Quaternion x, y, z, w (default 0, 0, 0, 1) Fixed-axis roll, pitch, yaw about X, Y, Z (extrinsic xyz) ranked above other Euler forms Body x forward, y left, z up; world ENU; right-handed Metres, radians
ROS REP 105 frames n/a n/a map and odom world-fixed, map z up; earth-referenced maps x east, y north, z up Metres
tf2 C++ tf2::Quaternion Constructor (x, y, z, w); a three-argument constructor takes roll, pitch, yaw setRPY is fixed-axis RPY Follows REP 103 Radians
tf_transformations (Python) [x, y, z, w] 24 axis strings; default sxyz (static, extrinsic) Follows REP 103 Radians
URDF <origin> quat_xyzw attribute in URDF 1.1 parsers; otherwise none rpy: fixed-axis, extrinsic xyz Follows REP 103 Metres, radians
Eigen Quaternion Constructor w, x, y, z; stored x, y, z, w (coeffs()) eulerAngles(a0, a1, a2) intrinsic, first angle in [0, pi] Application-defined Radians
SciPy Rotation x, y, z, w by default; scalar_first=True for w first Upper case intrinsic, lower case extrinsic Application-defined Radians unless degrees=True
MuJoCo MJCF w, x, y, z (default "1 0 0 0") eulerseq default "xyz"; lower case intrinsic Z up by default Degrees by default (compiler/angle)
PX4 uORB VehicleAttitude w, x, y, z, Hamilton Body FRD to NED NED world, FRD body; right-handed SI; attitude as a quaternion
MAVLink ATTITUDE and ATTITUDE_QUATERNION w, x, y, z, identity (1, 0, 0, 0) Roll, pitch, yaw: ZYX intrinsic Aeronautical: X front, Y right, Z down; right-handed Radians
OpenCV No quaternion; rvec rotation vector None; use Rodrigues Camera: x right, y down, z forward (REP 103 optical frame) Same as your inputs
Unity Quaternion(x, y, z, w) Applied about z, then x, then y (extrinsic zxy, equal to intrinsic YXZ) x right, y up, z forward; left-handed Degrees for Euler
Unreal Engine TQuat(X, Y, Z, W) FRotator: intrinsic yaw (Z), then pitch (Y), then roll (X) x forward, y right, z up; left-handed Centimetres, degrees
Blender (Python mathutils) Quaternion((w, x, y, z)) Euler default order "XYZ", which builds Rz Ry Rx (extrinsic xyz) Z up Radians in the Python API
three.js Quaternion(x, y, z, w) Euler default "XYZ", which builds Rx Ry Rz (intrinsic XYZ) Y up by default; camera looks down local -Z Radians
glTF 2.0 x, y, z, w, w scalar, must be unit None Right-handed, +Y up, asset front faces +Z; camera looks down -Z Metres, radians

Notes on the rows that most often cause trouble:

  • MuJoCo and SciPy use opposite letter cases. MuJoCo's eulerseq="xyz" is intrinsic; SciPy's "xyz" is extrinsic. Copying an Euler triple from a MuJoCo model into SciPy with the same string gives a different rotation unless the angles are all about one axis. MuJoCo also reads euler and axisangle in degrees unless <compiler angle="radian"/> is set; URDF files it imports are always read in radians.
  • Blender's "XYZ" and three.js's "XYZ" are different rotations. Blender's eul_to_mat3 produces Rz Ry Rx, so its XYZ mode applies X first about fixed axes. three.js's makeRotationFromEuler produces Rx Ry Rz, so its XYZ order applies X first about moving axes. Same letters, opposite multiplication order.
  • Unity applies z, then x, then y. Unity's scripting reference says rotations "are performed around the Z axis, the X axis, and the Y axis, in that order", and the Unity.Mathematics package documents its default RotationOrder.ZXY as "Extrinsic Rotation around the z axis, then around the x axis and finally around the y axis". The matrix is Ry Rx Rz.
  • Unreal mixes sign conventions inside one type. Epic's TRotator reference says yaw, pitch and roll are applied as intrinsic rotations in that order, and that "Rotator treats yaw as left-handed but pitch and roll as right-handed", while "UE Quat always considers a positive angle to be a left-handed rotation". Test with known vectors when you build an Unreal bridge.
  • Eigen's constructor and storage disagree on purpose. The documentation warns: "the real w coefficient first, while internally the coefficients are stored in the following order: [x, y, z, w]". A memcpy from a w-first buffer into coeffs() is a bug.
  • URDF now has an optional quaternion. Current urdfdom parses quat_xyzw on <origin> when the file declares URDF version 1.1, rejects files that set both rpy and quat_xyzw, and ignores quat_xyzw with a warning in version 1.0 files.

Converting between conventions, with worked examples

Every frame convention can be written as a basis-change matrix P whose rows say how the target axes read in source coordinates. The rules are:

  • Positions: p_target = P * p_source (and scale units if needed).
  • Rotation matrices: R_target = P * R_source * P^T. This works whether or not handedness changes, and the result is always a proper rotation.
  • Quaternions: keep w; the vector part becomes det(P) * P * v. When det(P) = -1 (a mirror), the extra sign is what reverses the rotation sense.
  • Body and world both change: for an orientation that maps body to world, R_new = P_world * R_old * P_body^T.

Check every conversion with one known vector: rotate (1, 0, 0) in the source, convert both, and confirm the target agrees.

ENU and NED, FLU and FRD (ROS and PX4)

ROS uses ENU for the world and FLU (forward, left, up) for the body. PX4 uses NED and FRD. PX4's ROS 2 guide states that "the FRD (NED) conventions are adopted on all PX4 topics unless explicitly specified", that ENU to NED is a pi/2 rotation about Z followed by a pi rotation about X, and that FLU to FRD is a pi rotation about X. Both conventions are right-handed, so these are rotations:

P_enu_to_ned = [0 1  0]      P_flu_to_frd = diag(1, -1, -1)
               [1 0  0]
               [0 0 -1]

R_ned_frd = P_enu_to_ned * R_enu_flu * P_flu_to_frd^T   (P_flu_to_frd is its own transpose)

Worked results, all computed:

ROS (ENU, FLU), intrinsic ZYX degrees PX4 (NED, FRD), intrinsic ZYX degrees Why
Yaw 90 (facing north), level Yaw 0, level ROS yaw is zero facing east; NED yaw is zero facing north
Yaw 0 (facing east) Yaw 90 Heading NED = 90 - yaw ENU
Yaw 30 Yaw 60 Same rule; NED yaw is clockwise from north
Yaw 90, pitch -10 (nose up in FLU) Pitch +10 FLU pitch is about the left axis, FRD pitch about the right axis
Yaw 90, roll 15 Roll 15 Roll is about the forward axis in both

REP 103 calls out the heading difference: yaw "is zero when pointing east", which "differs from a traditional compass bearing, which is zero when pointing north and increments clockwise", and it asks hardware drivers to convert before publishing. PX4 provides the frame_transforms library in px4_ros_com for these conversions. Remember the quaternion order too: PX4 and MAVLink are w first, ROS is w last.

Camera optical frames (ROS and OpenCV)

REP 103 defines the _optical suffix frame as z forward, x right, y down, which matches OpenCV's pinhole model: projection divides by Z_c, the depth along the optical axis, and image coordinates "assume a (u,v) top-left coordinates frame". The rotation from a ROS camera body frame (x forward, y left, z up) to its optical frame has extrinsic roll, pitch, yaw of (-pi/2, 0, -pi/2), or quaternion (0.5, -0.5, 0.5, -0.5) in x, y, z, w. In ROS 2 that is a static transform:

ros2 run tf2_ros static_transform_publisher \
  --roll -1.5708 --yaw -1.5708 \
  --frame-id camera_link --child-frame-id camera_optical_frame

An OpenGL-style camera (glTF and three.js both document it: +X right, +Y up, looking down -Z) differs from the OpenCV camera by a 180 degree rotation about x, diag(1, -1, -1). Missing that rotation flips the image vertically and puts the scene behind the camera.

Unity (left-handed, y up)

From ROS (x forward, y left, z up) to Unity (x right, y up, z forward), Unity's x is ROS -y, Unity's y is ROS z, and Unity's z is ROS x:

P_ros_to_unity = [0 -1 0]    det = -1 (a mirror)
                 [0  0 1]
                 [1  0 0]

position:   (x_u, y_u, z_u) = (-y, z, x)
quaternion: (qx_u, qy_u, qz_u, qw_u) = (qy, -qz, -qx, qw)

Worked example: a ROS yaw of +30 degrees (turning left) is the quaternion (0, 0, 0.2588, 0.9659). In Unity it becomes (0, -0.2588, 0, 0.9659), a rotation of -30 degrees about Unity's y axis. A point 1 m ahead of the robot, rotated by that yaw, is (0.866, 0.5, 0) in ROS and (-0.5, 0, 0.866) in Unity, and the converted rotation applied to the converted point gives the same answer. Copying the yaw angle across unchanged would turn the robot right instead of left.

Unreal Engine (left-handed, z up, centimetres)

Unreal keeps x forward and z up but points y right, so the conversion from ROS flips only y and scales by 100: (x_ue, y_ue, z_ue) = 100 * (x, -y, z). A ROS position of (1.0, 0.5, 0.2) m is (100, -50, 20) in Unreal units. This is also a mirror (det = -1), so rotation senses reverse. Given Unreal's mixed sign conventions between FRotator and FQuat, build the rotation from P * R * P^T and check it against a known vector inside the engine.

glTF and Blender

glTF is right-handed with +Y up, an asset's front facing +Z and its left side facing +X. From a ROS body frame, that gives (x_g, y_g, z_g) = (y, z, x): a cyclic permutation with determinant +1, so a pure rotation. Blender's own world is Z up (its manual: "a right-angled 'Cartesian' coordinate system with the Z axis pointing upwards"), and its glTF exporter has a "+Y Up" option to "export using glTF convention".

tf2 frame names: parent, child, target, source

tf2 keeps the transforms between frames as a tree. REP 105 defines the standard mobile-robot chain earth -> map -> odom -> base_link, with each frame having one parent: "The map frame is the parent of odom, and odom is the parent of base_link." odom is continuous but drifts; map does not drift but can jump when localisation corrects.

The geometry_msgs/TransformStamped message defines the direction: it "expresses a transform from coordinate frame header.frame_id to the coordinate frame child_frame_id", and the translation and rotation are "of child_frame_id from header.frame_id". In the naming used above, a message with frame_id: odom and child_frame_id: base_link carries T_odom_base_link: the robot's pose in the odometry frame.

Lookups use different words. In tf2's BufferCore::lookupTransform(target_frame, source_frame, time), the target is "the frame to which data should be transformed" and the source is "the frame where the data originated". So lookupTransform("map", "base_link", t) returns what you need to move data from base_link into map. Swapping the two arguments returns the inverse transform, and code that does so moves data the wrong way.

Three tf2 habits prevent most frame bugs:

  • Name the frames in the variable. T_map_base or tf_map_from_base makes a reversed multiplication visible in code review.
  • Publish one parent per frame. A localisation node that publishes map -> base_link while odometry publishes odom -> base_link breaks the tree. Publish map -> odom instead, as REP 105 describes.
  • Use ros2 run tf2_ros tf2_echo map base_link and ros2 run tf2_tools view_frames to see what is actually in the tree before debugging the maths.

The common bugs

Bug Symptom Fix
Quaternion order mixed (x, y, z, w read as w, x, y, z) Small turns become large flips; identity becomes a 180 degree rotation Label order at every serialisation boundary; prefer named fields to arrays
Degrees passed as radians A "90 degree" turn of 90 radians wraps to 2.035 rad, about 116.6 degrees Radians everywhere internally; convert only at the UI and config edge; MuJoCo and Unity default to degrees
Wrong composition order Plausible but wrong poses, with errors that change as the robot moves Name frames in variables so inner names cancel
Intrinsic read as extrinsic Correct at small angles, wrong when two axes rotate together State the convention with the angles; compare matrices in tests
Transform direction reversed (T_a_b used as T_b_a) Motion rotated the wrong way; sensor data swings the wrong way as the robot turns Follow the parent and child or target and source definitions; test with a pure yaw
Handedness not converted (ROS to Unity or Unreal) Robot turns the wrong way in simulation; mirrored meshes Apply the basis change with det = -1 handling
Non-unit quaternion Shearing, scaling, slow drift in estimators Normalise after integration and before conversion
q and -q treated as different Angular error spikes near 360 degrees; jittery interpolation Use `
tf2 constructor confusion tf2::Quaternion(a, b, c) reads roll, pitch, yaw; four arguments read x, y, z, w Use setRPY explicitly
Row-major and column-major mixed Rotation inverted (transposed) Know each library's storage order; glTF buffers, three.js elements and Blender's C code are column-major
Points and directions treated alike Velocities offset by the frame translation Directions use rotation only (fourth component 0)

Debugging checklist

Work through this in order when an orientation looks wrong. Most bugs are found in the first five steps.

  1. Write down both conventions. For the source and the target: axis directions, handedness, quaternion order, Euler sequence and intrinsic or extrinsic, angle units, length units. Use the conventions table.
  2. Check the identity. Send the identity rotation through the whole pipeline. If it does not come out as identity (or as the known fixed offset between conventions), the order or the basis change is wrong.
  3. Test one axis at a time. Send a pure +90 degree rotation about each axis separately. A wrong sign shows up as a turn the wrong way; a wrong order or swapped axes show up as a turn about the wrong axis.
  4. Check the quaternion norm and the matrix determinant. Norm far from 1 means a scaling bug or bad serialisation. A determinant of -1 means a handedness mismatch.
  5. Print the tf tree. view_frames and tf2_echo show the parent and child of every transform and the actual values being published.
  6. Compare in matrices or quaternions. Euler angles from different libraries can differ while describing the same rotation. Compare |q1 · q2| or R1^T R2.
  7. Check units at boundaries. Degrees in MuJoCo MJCF and Unity Euler angles, centimetres in Unreal, radians in ROS, SciPy and glTF.
  8. Check composition order with a known chain. Place a point 1 m in front of a sensor with a known mount and compute where it lands in the world by hand.
  9. Re-orthonormalise matrices pasted from logs. Truncated decimals produce matrices that are slightly invalid; fix them before decomposing.
  10. Write the convention into the code. A comment such as // q_world_body, Hamilton, stored x y z w, ROS ENU/FLU at every boundary saves the next person the same afternoon.

The Frame and Rotation Converter covers steps 1 to 4 and 9 in one page: paste a quaternion, matrix or 4x4 transform from a log and it shows every other representation, the orthonormality check and the basis change to a target convention. For calibrating the mount offsets themselves, see the robot calibration guide; for how orientation errors feed into an estimator, see sensor fusion and Kalman filtering.

Frequently asked questions

What coordinate frame convention does ROS use? ROS REP 103 specifies right-handed frames with x forward, y left and z up for bodies, ENU (x east, y north, z up) for local world frames, and SI units: metres and radians. Camera frames with the _optical suffix use z forward, x right and y down, and frames with the _ned suffix use north, east, down.

Is a ROS quaternion x, y, z, w or w, x, y, z? ROS stores quaternions as x, y, z, w: the geometry_msgs/Quaternion message has fields x, y, z and w, with w defaulting to 1. MuJoCo, PX4, MAVLink, Blender's Python API and the Eigen constructor take w first, so convert the order at every boundary with those tools.

What is the difference between intrinsic and extrinsic Euler angles? Intrinsic rotations are about the body's own axes, which move after each step; extrinsic rotations are about axes fixed in the parent frame. An intrinsic sequence A, B, C equals the extrinsic sequence C, B, A with the angles reversed, so intrinsic yaw, pitch, roll about Z, Y, X equals URDF's fixed-axis roll, pitch, yaw about x, y, z.

What is gimbal lock? Gimbal lock is a singularity of a three-angle description. When the middle angle of a Tait-Bryan sequence reaches plus or minus 90 degrees, the first and third axes align and only their difference (or sum) is defined. The orientation itself is valid; the angles stop being unique and become unstable nearby. Quaternions and rotation matrices have no such singularity.

What is the difference between Hamilton and JPL quaternions? They differ in the quaternion algebra (Hamilton uses ij = k, JPL uses ij = -k), which makes Hamilton rotations right-handed and JPL rotations left-handed, and in the default component order and direction of the rotation operator. Robotics libraries such as Eigen, ROS, Ceres and PX4 use Hamilton. JPL appears mostly in aerospace and in some visual-inertial odometry papers.

Why do q and -q give the same rotation? A unit quaternion encodes half the rotation angle. Negating every component is the same as adding a full 360 degree turn to the angle, which leaves the rotation unchanged. Compare quaternions with the absolute value of their dot product, and flip one before interpolating if the dot product is negative.

How do I convert a pose from ROS to Unity? Map the axes with Unity x = -ROS y, Unity y = ROS z and Unity z = ROS x, so a position (x, y, z) becomes (-y, z, x). Because Unity is left-handed, the quaternion's vector part takes an extra sign: (qx, qy, qz, qw) becomes (qy, -qz, -qx, qw). A left turn in ROS becomes a negative rotation about Unity's y axis.

What does the order of tf2 lookupTransform arguments mean? lookupTransform(target_frame, source_frame, time) returns the transform that moves data from the source frame into the target frame. In a TransformStamped, header.frame_id is the parent and child_frame_id is the child, and the values are the child's pose in the parent frame.

Sources

Primary sources first: the standards, specifications, official documentation and project source code. Secondary reading follows.

Primary

  1. REP 103, Standard Units of Measure and Coordinate Conventions - right-handed frames, x forward y left z up, ENU, optical and NED suffix frames, rotation representation preferences, yaw zero at east (source).
  2. REP 105, Coordinate Frames for Mobile Platforms - base_link, odom, map and earth, the parent and child chain and map conventions (source).
  3. geometry_msgs/Quaternion and TransformStamped - field order and the frame_id and child_frame_id definitions.
  4. tf2 BufferCore - lookupTransform target and source definitions.
  5. tf2 Quaternion - constructors and fixed-axis setRPY.
  6. tf2_ros static_transform_publisher - command-line arguments.
  7. tf.transformations and tf_transformations - [x, y, z, w] order, 24 axis strings, default sxyz.
  8. urdfdom_headers pose.h and urdfdom pose.cpp - setFromRPY and the URDF 1.1 quat_xyzw attribute.
  9. Eigen Quaternion and Eigen Geometry module - constructor and storage order, eulerAngles and canonicalEulerAngles ranges.
  10. SciPy Rotation, from_euler and from_quat - intrinsic and extrinsic letter case, scalar-last default, normalisation.
  11. MuJoCo XML reference and modelling guide - eulerseq, angle units, quat default, gravity and Z up.
  12. Solà, Quaternion kinematics for the error-state Kalman filter - the four quaternion convention choices and Table 2, Hamilton against JPL.
  13. PX4 VehicleAttitude message - Hamilton, w, x, y, z, FRD body to NED.
  14. PX4 ROS 2 user guide - ENU and NED, FLU and FRD conversions.
  15. PX4 notation - FRD and NED definitions.
  16. MAVLink common messages - ATTITUDE and ATTITUDE_QUATERNION frame, order and units.
  17. OpenCV calib3d - pinhole model, homogeneous transforms, Rodrigues and solvePnP rvec (header source).
  18. Unity Quaternion, Quaternion.eulerAngles and Quaternion.Euler - x, y, z, w constructor and ZXY rotation order.
  19. Unity manual, rotation and orientation - left-handed coordinate system and axis directions.
  20. Unity.Mathematics - RotationOrder definitions, default extrinsic ZXY.
  21. Unreal Engine coordinate system and spaces - left-handed, Z up, X forward, Y right.
  22. Unreal Engine units of measurement - centimetres and degrees.
  23. Unreal TRotator and TQuat - rotation order, sign conventions and component order.
  24. Blender mathutils - Quaternion (w, x, y, z) and Euler default order.
  25. Blender math_rotation_c.cc and math_matrix_c.cc - eul_to_mat3 and column-major indexing.
  26. Blender manual, viewpoint and glTF 2.0 add-on - Z up and the +Y Up export option.
  27. three.js Euler, Matrix4 and Camera - default XYZ order and its matrix, camera looking down -Z.
  28. glTF 2.0 specification - right-handed, +Y up, front +Z, metres, radians, XYZW unit quaternions, column-major matrices, camera conventions.

Further reading

  1. Frame and Rotation Converter - every representation of one rotation, code output for ROS 2, tf2, Eigen, SciPy, MuJoCo and URDF, and frame conversion between conventions.
  2. Trawny and Roumeliotis, Indirect Kalman Filter for 3D Attitude Estimation - the reference treatment of the JPL quaternion convention.
  3. Rotation formalisms in three dimensions and Euler angles - overviews of the representations and the 12 sequences.

Related guides