Rotation representations cheat sheet
Rotation matrices, axis-angle, Euler angles and quaternions side by side, with the quaternion order each library uses, tested conversions in NumPy and SciPy, and a gimbal lock demonstration.
This sheet is for anyone moving an orientation between two libraries and wanting it to arrive unchanged. It lists the four common representations, the conventions that differ between tools, and conversions you can run. For the derivations, start with frames and rotations. Angles are in radians throughout. Frames follow REP 103: right-handed, x forward, y left, z up.
The four representations
| Representation | Numbers stored | Constraint | Compose | Weak spot | Typical use |
|---|---|---|---|---|---|
| Rotation matrix R | 9 | Columns orthonormal, det R = +1 | Matrix product, R_ac = R_ab R_bc | Rounding drift makes R non-orthonormal after many products. Re-orthonormalise with an SVD or by converting to a quaternion and back | Transforming vectors, homogeneous transforms, Jacobians |
| Axis-angle, rotation vector | 3 (unit axis times angle) or 4 (axis plus angle) | Axis has unit length | Convert first. There is no simple direct rule | Axis undefined at angle 0. Axis sign ambiguous at angle pi | Small corrections, angular velocity integration, errors in controllers |
| Euler angles | 3 | None | Convert first | Gimbal lock. 24 conventions (12 axis orders, each intrinsic or extrinsic) | Human input and display, URDF rpy |
| Quaternion q | 4 | Unit norm | Quaternion product | q and -q are the same rotation. The order of w differs between libraries | Storage, interpolation (slerp), filters, messages |
REP 103 prefers quaternions and rotation matrices because neither has a singularity. It discourages Euler angles because of the many conventions.
Formulas
Rodrigues (unit axis k, angle t, K = skew(k)):
R = I + sin(t) K + (1 - cos(t)) K^2
Quaternion from axis-angle:
q = (w, x, y, z) = (cos(t/2), sin(t/2) kx, sin(t/2) ky, sin(t/2) kz)
Angle from a rotation matrix:
t = arccos((trace(R) - 1) / 2)
k = (R32 - R23, R13 - R31, R21 - R12) / (2 sin t) # undefined near t = 0 and t = pi
URDF rpy (fixed axes x, then y, then z):
R = Rz(yaw) Ry(pitch) Rx(roll)
Intrinsic and extrinsic Euler angles
Extrinsic rotations turn about the fixed axes of the parent frame. Intrinsic rotations turn about the axes of the frame as it moves. A sequence applied extrinsically gives the same rotation as the reversed sequence applied intrinsically. Roll-pitch-yaw about fixed x, y, z is therefore the same as yaw-pitch-roll about moving z, y’, x’’.
| Tool | Intrinsic | Extrinsic | Default |
|---|---|---|---|
SciPy Rotation.from_euler |
Upper case, "ZYX" |
Lower case, "xyz" |
Radians unless degrees=True |
MuJoCo MJCF compiler/eulerseq |
Lower case, "xyz" |
Upper case, "XYZ" |
eulerseq="xyz". The compiler angle defaults to degrees |
URDF origin rpy |
Not used | Roll about x, pitch about y, yaw about z, fixed axes | Radians |
The case rule in MuJoCo is the opposite of SciPy. The MuJoCo XML reference states that URDF rpy corresponds to eulerseq="XYZ" in MJCF. MuJoCo also reads euler and axisangle in degrees unless you set <compiler angle="radian"/>.
Quaternion order per library
| Library or format | Order | Where it is stated |
|---|---|---|
SciPy Rotation.as_quat, from_quat |
(x, y, z, w) by default. Pass scalar_first=True for (w, x, y, z) |
SciPy from_quat |
ROS 2 geometry_msgs/msg/Quaternion |
Fields x, y, z, w, defaults 0, 0, 0, 1 |
ros2 interface show geometry_msgs/msg/Quaternion on Jazzy |
MuJoCo (quat attribute, qpos of free and ball joints, mju_* functions) |
(w, x, y, z), default "1 0 0 0" |
MuJoCo modelling guide |
spatialmath-python UnitQuaternion |
.vec is (s, vx, vy, vz), scalar first. .vec_xyzs is scalar last |
spatialmath quaternion docs |
PyBullet getQuaternionFromEuler and poses |
(x, y, z, w) | Checked in the code below |
The ROS message has named fields, so order only bites when you pack those fields into an array. Write the order in a comment every time you do.
ros2 interface show geometry_msgs/msg/Quaternion
Conversions with SciPy
One rotation, five views. The first line checks the intrinsic and extrinsic equivalence from the section above.
import numpy as np
from scipy.spatial.transform import Rotation as R
# Intrinsic Z-Y-X (yaw, pitch, roll) equals extrinsic x-y-z with the angles reversed.
roll, pitch, yaw = 0.1, -0.4, 1.2 # rad
r_intr = R.from_euler("ZYX", [yaw, pitch, roll])
r_extr = R.from_euler("xyz", [roll, pitch, yaw])
print("same rotation:", np.allclose(r_intr.as_matrix(), r_extr.as_matrix()))
m = r_intr.as_matrix()
print("orthonormal:", np.allclose(m.T @ m, np.eye(3)), "det =", round(np.linalg.det(m), 6))
rotvec = r_intr.as_rotvec() # axis * angle, rad
angle = np.linalg.norm(rotvec)
print("axis:", np.round(rotvec / angle, 4), "angle (rad):", round(angle, 4))
q_xyzw = r_intr.as_quat() # SciPy default: scalar last
q_wxyz = r_intr.as_quat(scalar_first=True)
print("q (x, y, z, w):", np.round(q_xyzw, 4))
print("q (w, x, y, z):", np.round(q_wxyz, 4))
print("round trip rpy:", np.round(R.from_quat(q_xyzw).as_euler("xyz"), 4))
The same conversions in plain NumPy
These are the formulas from the table, checked against SciPy. The last line confirms that q and -q give the same matrix.
import numpy as np
from scipy.spatial.transform import Rotation as R
def skew(v: np.ndarray) -> np.ndarray:
return np.array([[0.0, -v[2], v[1]], [v[2], 0.0, -v[0]], [-v[1], v[0], 0.0]])
def axis_angle_to_matrix(axis: np.ndarray, angle: float) -> np.ndarray:
k = skew(axis / np.linalg.norm(axis))
return np.eye(3) + np.sin(angle) * k + (1.0 - np.cos(angle)) * k @ k # Rodrigues
def quat_wxyz_to_matrix(q: np.ndarray) -> np.ndarray:
w, x, y, z = q / np.linalg.norm(q)
return np.array([
[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
[2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
[2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
])
def matrix_to_axis_angle(m: np.ndarray) -> tuple[np.ndarray, float]:
angle = np.arccos(np.clip((np.trace(m) - 1.0) / 2.0, -1.0, 1.0))
axis = np.array([m[2, 1] - m[1, 2], m[0, 2] - m[2, 0], m[1, 0] - m[0, 1]]) / (2.0 * np.sin(angle))
return axis, angle # fails near 0 and pi, where sin(angle) -> 0
axis, angle = np.array([1.0, 2.0, 2.0]) / 3.0, 0.9
m1 = axis_angle_to_matrix(axis, angle)
q = np.concatenate([[np.cos(angle / 2)], np.sin(angle / 2) * axis]) # (w, x, y, z)
m2 = quat_wxyz_to_matrix(q)
m_ref = R.from_rotvec(axis * angle).as_matrix()
a_back, ang_back = matrix_to_axis_angle(m1)
print("Rodrigues matches SciPy:", np.allclose(m1, m_ref))
print("quaternion matches SciPy:", np.allclose(m2, m_ref))
print("recovered axis:", np.round(a_back, 4), "angle:", round(ang_back, 4))
print("q and -q same rotation:", np.allclose(quat_wxyz_to_matrix(-q), m2))
One rotation across four libraries
This block hands one orientation to MuJoCo, spatialmath and PyBullet, and checks that each gives back the same matrix. Only the quaternion order changes.
import numpy as np
import mujoco
import pybullet as p
from scipy.spatial.transform import Rotation as R
from spatialmath import UnitQuaternion
rpy = [0.3, -0.2, 0.8] # URDF-style roll, pitch, yaw (rad)
r = R.from_euler("xyz", rpy) # extrinsic x, then y, then z
q_scipy = r.as_quat() # (x, y, z, w)
q_wfirst = np.roll(q_scipy, 1) # (w, x, y, z)
m_mj = np.zeros(9)
mujoco.mju_quat2Mat(m_mj, q_wfirst) # MuJoCo expects w first
uq = UnitQuaternion(q_wfirst) # spatialmath: scalar part first
q_pb = np.array(p.getQuaternionFromEuler(rpy)) # PyBullet: (x, y, z, w), no server needed
print("MuJoCo agrees:", np.allclose(m_mj.reshape(3, 3), r.as_matrix()))
print("spatialmath agrees:", np.allclose(uq.R, r.as_matrix()))
print("PyBullet agrees:", np.allclose(q_pb, q_scipy) or np.allclose(q_pb, -q_scipy))
print("w first (MuJoCo, spatialmath .vec):", np.round(uq.vec, 4))
print("w last (SciPy, ROS msg, PyBullet):", np.round(q_scipy, 4))
Gimbal lock
With intrinsic yaw-pitch-roll at a pitch of plus or minus 90 degrees, the first and third rotations turn about the same physical axis. Only their difference matters, so one degree of freedom is lost. Different angle triples then give the same matrix, and the inverse conversion has to pick one. SciPy sets the third angle to zero and warns.
import warnings
import numpy as np
from scipy.spatial.transform import Rotation as R
# At pitch = +90 degrees, roll and yaw act about the same axis: one degree of freedom is lost.
a = R.from_euler("ZYX", [0.7, np.pi / 2, 0.2]) # yaw, pitch, roll
b = R.from_euler("ZYX", [0.9, np.pi / 2, 0.4]) # yaw and roll both +0.2 rad
print("different angles, same rotation:", np.allclose(a.as_matrix(), b.as_matrix()))
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
back = a.as_euler("ZYX")
print("recovered (yaw, pitch, roll):", np.round(back, 4))
print("SciPy warned about gimbal lock:", any("Gimbal lock" in str(x.message) for x in w))
Near the singularity, small changes in orientation produce large jumps in yaw and roll. A controller or filter that works in Euler angles sees those jumps as huge errors. Keep the state as a quaternion or a matrix. Convert to Euler angles only for display.
Pitfalls
- A quaternion pasted from SciPy into MJCF without reordering is a different rotation. Use
np.roll(q, 1)to go from (x, y, z, w) to (w, x, y, z), andnp.roll(q, -1)to go back. - Two quaternion streams can flip sign between samples (q, then -q). Before averaging or interpolating, flip one so that their dot product is positive.
- Normalise quaternions after integration or after reading them from a file. Most libraries normalise on input, but your own maths may not.
as_eulerreturns angles in a fixed range. A yaw signal will wrap at plus or minus pi. Unwrap it (np.unwrap) before plotting or differentiating.- Camera optical frames in ROS use z forward, x right, y down. That differs from the body convention above, so a fixed rotation sits between
camera_linkandcamera_optical_frame. - Angular velocity is not the derivative of Euler angles. The mapping between them depends on the current angles and is singular at gimbal lock.
Related pages
- Frames and rotations and forward kinematics
- Spatial maths hub
- DH and product of exponentials for chaining rotations into arm kinematics
- URDF and MJCF for where
rpyandquatappear in robot descriptions - REP 103, the ROS units and frame conventions