Cheat sheet

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.

ROS 2MuJoCoLast verified 14 Nov 2026· SciPy 1.18, NumPy 2.5, MuJoCo 3.16, spatialmath-python 1.1, PyBullet 3.2, ROS 2 Jazzy

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), and np.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_euler returns 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_link and camera_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.