Every pose in MuJoCo is a position and an orientation: of a body (xpos, xquat, xmat), a geom, a site, a camera. Positions are easy. Orientations are where robotics code goes wrong, because there are several ways to write one, they compose in an order that matters, and the most compact one, the quaternion, describes every rotation twice.
This lesson fixes the conventions MuJoCo uses and gives you one tool for each job:
| Representation | Numbers | Use it for | Watch out for |
|---|---|---|---|
| rotation matrix $R$ | 9 | composing, rotating vectors, reading axes | must stay orthonormal |
| unit quaternion $\mathbf q$ | 4 | storage, interpolation, MuJoCo’s state | $\mathbf q$ and $-\mathbf q$ are the same rotation |
| rotation vector $\boldsymbol\phi$ (axis times angle) | 3 | small differences, errors, angular velocity integration | unique only for angles below $\pi$ |
| Euler angles | 3 | humans typing orientations | order conventions, gimbal lock |
[!established] MuJoCo’s conventions Quaternions are $(w, x, y, z)$ with $w$ first. Rotation matrices act on column vectors, so $R\mathbf v$ rotates $\mathbf v$, and are stored as 9 numbers in row-major order; the columns of a body’s $R$ are its $x$, $y$, $z$ axes in world coordinates. Euler sequences are three characters from
xyzXYZ: lower case is intrinsic (each rotation about the frame’s current axes), upper case extrinsic (about the fixed world axes). Themju_euler2Quatdocstring in 3.14.0 says it: “lower/upper-case mean intrinsic/extrinsic rotations”.
The explorer computes the rotation from Euler angles or from an axis and an angle, draws the resulting frame, and computes the quaternion twice: with the formulas below in JavaScript, and with MuJoCo’s own functions. The “vs MuJoCo” readout is the angle between the two answers; anything above $10^{-15}$ would mean the formulas on this page are wrong.
```lab frames {“dock”: true, “mode”: “euler”, “seq”: “xyz”, “euler”: [30, 45, 0], “height”: 260}
Three experiments, in order:
1. Set the angles to (90, 90, 0) with sequence `xyz`, then switch to `XYZ`. The frames differ. The same three numbers mean different rotations under the two conventions.
2. With `xyz`, set the second angle to 90 and move the first and third sliders. They now turn the frame about the same axis: one degree of freedom has disappeared. That is **gimbal lock**, and the readout flags it.
3. Switch to axis-and-angle and sweep the angle from 0 to 360 degrees. The quaternion returns to its start only at 720 degrees; at 360 it reads $-\mathbf q$ of where it began, and the frame is back exactly where it started.
## Mathematics
### Rotation matrices
A rotation matrix is orthonormal with determinant $+1$: $R^\top R = \mathbb 1$, $\det R = 1$. Its inverse is its transpose. Elementary rotations about the coordinate axes by angle $\theta$ are
$$R_x(\theta) = \begin{bmatrix}1&0&0\\0&c&-s\\0&s&c\end{bmatrix},\quad R_y(\theta)=\begin{bmatrix}c&0&s\\0&1&0\\-s&0&c\end{bmatrix},\quad R_z(\theta)=\begin{bmatrix}c&-s&0\\s&c&0\\0&0&1\end{bmatrix}$$
with $c = \cos\theta$, $s = \sin\theta$.
> [!derivation] Intrinsic and extrinsic sequences
> Rotating about the frame's own (moving) axes, first by $\alpha$ about $x$, then by $\beta$ about the new $y$, then by $\gamma$ about the newest $z$, gives $R = R_x(\alpha)\,R_y(\beta)\,R_z(\gamma)$: each new rotation multiplies on the **right**, because it is expressed in the frame produced by the previous ones. Rotating about the fixed world axes in the same order gives $R = R_z(\gamma)\,R_y(\beta)\,R_x(\alpha)$: each new rotation multiplies on the **left**. MuJoCo's `xyz` is the first, `XYZ` the second; this course checked both against `mju_euler2Quat` for six sequences in `tests/test_spatial.py`.
### Quaternions
A rotation by angle $\theta$ about the unit axis $\hat{\mathbf n}$ is the unit quaternion
$$\mathbf q = \left(\cos\tfrac\theta2,\ \sin\tfrac\theta2\,\hat{\mathbf n}\right) = (w, x, y, z).$$
Composition is the Hamilton product: $\mathbf q_a \otimes \mathbf q_b$ means "apply $\mathbf q_b$, then $\mathbf q_a$" in the same sense as $R_a R_b$. A vector is rotated by the sandwich $\mathbf v' = \mathbf q \otimes (0, \mathbf v) \otimes \mathbf q^{*}$, where $\mathbf q^* = (w, -x, -y, -z)$ is the conjugate (the inverse, for a unit quaternion).
> [!derivation] The double cover
> Replacing $\theta$ by $\theta + 2\pi$ leaves the rotation unchanged but flips the sign of every component: $\cos(\tfrac\theta2 + \pi) = -\cos\tfrac\theta2$, and likewise for the sine. So $\mathbf q$ and $-\mathbf q$ are the same rotation. In the sandwich, the two signs cancel. The set of unit quaternions covers the set of rotations exactly twice.
### Rotation vectors, exp and log
The rotation vector $\boldsymbol\phi = \theta\hat{\mathbf n}$ packs a rotation into three numbers. The maps between it and quaternions are the exponential and logarithm:
$$\exp(\boldsymbol\phi) = \left(\cos\tfrac{|\boldsymbol\phi|}{2},\ \sin\tfrac{|\boldsymbol\phi|}{2}\,\tfrac{\boldsymbol\phi}{|\boldsymbol\phi|}\right),\qquad \log(\mathbf q) = 2\,\mathrm{atan2}\!\left(|\mathbf q_{xyz}|,\ |w|\right) \frac{\mathbf q_{xyz}}{|\mathbf q_{xyz}|}\ \text{(sign chosen so } w \ge 0).$$
They are how angular velocities are integrated (in this notation, Lesson 1.2's update is $\mathbf q_{t+h} = \mathbf q_t \otimes \exp(h\boldsymbol\omega)$ for a body-frame $\boldsymbol\omega$) and how orientation errors are measured: the error between $\mathbf q_1$ and $\mathbf q_2$ is $\log(\mathbf q_1^* \otimes \mathbf q_2)$, and its norm is the **geodesic angle** between them.
### Rigid transforms
A pose is a rotation and a translation, $(R, \mathbf p)$, or the $4 \times 4$ matrix $T = \begin{bmatrix} R & \mathbf p \\ \mathbf 0^\top & 1\end{bmatrix}$ acting on homogeneous points $(\mathbf x, 1)$. The set of all such transforms is the special Euclidean group $SE(3)$. Composition is matrix multiplication; the inverse needs no general matrix inverse:
$$T_{a c} = T_{a b}\, T_{b c}, \qquad T^{-1} = \begin{bmatrix} R^\top & -R^\top \mathbf p \\ \mathbf 0^\top & 1 \end{bmatrix}.$$
Read the subscripts as "pose of $c$ expressed in frame $a$", and the composition rule becomes a cancellation rule for adjacent indices. MuJoCo stores the world pose of every body in `data.xpos` and `data.xmat`, and the pose of each site relative to its body in `model.site_pos` and `model.site_quat`, so the site's world pose is one product, which the script below checks.
## Implementation
The formulas above are in `mjcourse/spatial.py`, about 150 lines of NumPy, each function tested against MuJoCo's `mju_*` counterpart. The lesson's script exercises them:
```io
INPUT: `mjcourse.spatial` and `tutorial_tree.xml`
PROCESS: compare conventions, rotate a vector three ways, probe the double cover and small-angle precision, compose a site pose by hand
OUTPUT: printed comparisons
```python file=examples/l4_1_rotations.py “"”Lesson 4.1: rotations and transforms, checked against MuJoCo.
INPUT mjcourse.spatial (the lesson’s formulas in NumPy) and tutorial_tree.xml PROCESS (1) the same three Euler angles read intrinsically and extrinsically; (2) rotating a vector with a matrix and with a quaternion sandwich; (3) the double cover and what it does to a naive loss; (4) why arccos is a poor way to measure small rotation errors; (5) a site’s world pose composed by hand from its parent body’s pose OUTPUT printed comparisons
Run: python examples/l4_1_rotations.py “””
import mujoco import numpy as np
from mjcourse import model_path, spatial
def intrinsic_vs_extrinsic() -> None: e = np.radians([90.0, 90.0, 0.0]) for seq in (“xyz”, “XYZ”): r = spatial.quat_to_mat(spatial.euler_to_quat(e, seq)) print(f” seq {seq}: body x axis in world = {np.round(r[:, 0], 3)}, z axis = {np.round(r[:, 2], 3)}”)
def sandwich() -> None: q = spatial.euler_to_quat(np.radians([20.0, -35.0, 60.0]), “xyz”) v = np.array([0.3, -0.1, 0.8]) by_matrix = spatial.quat_to_mat(q) @ v by_quat = spatial.quat_mul(spatial.quat_mul(q, np.r_[0.0, v]), spatial.quat_conj(q))[1:] ref = np.zeros(3) mujoco.mju_rotVecQuat(ref, v, q) print(f” R v = {np.round(by_matrix, 6)}; q v q* = {np.round(by_quat, 6)}; mju_rotVecQuat = {np.round(ref, 6)}”)
def double_cover() -> None: q = spatial.euler_to_quat(np.radians([10.0, 20.0, 30.0]), “xyz”) print(f” q and -q: same matrix = {np.allclose(spatial.quat_to_mat(q), spatial.quat_to_mat(-q))}; “ f”||q - (-q)|| = {np.linalg.norm(q - (-q)):.3f}; geodesic angle = {spatial.geodesic_angle(q, -q):.2e} rad”)
def small_angle_precision() -> None: q1 = spatial.euler_to_quat(np.radians([10.0, 20.0, 30.0]), “xyz”) for true_angle in (1e-3, 1e-6, 1e-9): q2 = spatial.quat_mul(q1, spatial.axis_angle_to_quat([1.0, 2.0, 3.0], true_angle)) naive = 2.0 * np.arccos(min(1.0, abs(float(np.dot(q1, q2))))) print(f” true {true_angle:.0e} rad: arccos formula {naive:.3e}, atan2 formula {spatial.geodesic_angle(q1, q2):.3e}”)
def compose_site_pose() -> None: model = mujoco.MjModel.from_xml_path(str(model_path(“tutorial_tree”))) data = mujoco.MjData(model) data.qpos[:] = [0.5, -0.8] mujoco.mj_forward(model, data) site = model.site(“tip”) parent = site.bodyid[0] t_world_body = spatial.transform(data.xmat[parent].reshape(3, 3), data.xpos[parent]) t_body_site = spatial.transform(spatial.quat_to_mat(model.site_quat[site.id]), model.site_pos[site.id]) t_world_site = t_world_body @ t_body_site print(f” by hand: {np.round(t_world_site[:3, 3], 6)}; MuJoCo site_xpos: {np.round(data.site_xpos[site.id], 6)}”) print(f” max matrix difference: {np.max(np.abs(t_world_site[:3, :3] - data.site_xmat[site.id].reshape(3, 3))):.1e}”)
if name == “main”: print(“one set of angles (90, 90, 0) deg, two conventions:”) intrinsic_vs_extrinsic() print(“rotating a vector:”) sandwich() print(“double cover:”) double_cover() print(“measuring small rotations:”) small_angle_precision() print(“composing transforms:”) compose_site_pose()
Output:
```text
one set of angles (90, 90, 0) deg, two conventions:
seq xyz: body x axis in world = [ 0. 1. -0.], z axis = [ 1. -0. 0.]
seq XYZ: body x axis in world = [ 0. 0. -1.], z axis = [ 0. -1. 0.]
rotating a vector:
R v = [-0.265048 -0.073394 0.815085]; q v q* = [-0.265048 -0.073394 0.815085]; mju_rotVecQuat = [-0.265048 -0.073394 0.815085]
double cover:
q and -q: same matrix = True; ||q - (-q)|| = 2.000; geodesic angle = 1.39e-17 rad
measuring small rotations:
true 1e-03 rad: arccos formula 1.000e-03, atan2 formula 1.000e-03
true 1e-06 rad: arccos formula 1.000e-06, atan2 formula 1.000e-06
true 1e-09 rad: arccos formula 0.000e+00, atan2 formula 1.000e-09
composing transforms:
by hand: [-0.069948 0. 0.297891]; MuJoCo site_xpos: [-0.069948 0. 0.297891]
max matrix difference: 0.0e+00
[!implementation] A bug this lesson’s tests found The first version of
geodesic_angleused the textbook formula $2\arccos|\mathbf q_1 \cdot \mathbf q_2|$. Its test against MuJoCo failed at the $10^{-9}$ tolerance: $\arccos$ near 1 loses about half the floating-point digits, so errors below roughly $10^{-8}$ rad read as exactly zero (the fourth block of output). Theatan2form on the relative quaternion keeps full precision. It matters when you check a controller’s orientation tracking or a solver’s convergence to tight tolerances, and it is the kind of bug that only a test with a tight tolerance finds.
A frame is rotated “the wrong way” by a fixed amount. Mixed conventions: an angle read as intrinsic that was written as extrinsic, or a quaternion read as $(x, y, z, w)$ (the order used by SciPy’s Rotation.as_quat by default and by three.js) instead of MuJoCo’s $(w, x, y, z)$. Check by converting a 90-degree rotation about $z$ both ways.
An orientation controller suddenly spins the long way round. Its error was computed from a quaternion difference without fixing the sign, so $\mathbf q$ and $-\mathbf q$ gave errors of $\theta$ and $2\pi - \theta$. Take the error from $\log(\mathbf q_1^* \otimes \mathbf q_2)$ with $w \ge 0$, or use mju_subQuat.
A learned model’s rotation loss stops improving at a value near 2. It is an L2 loss on quaternions, and the targets contain both signs of the same rotation; the network cannot fit both. Canonicalize ($w \ge 0$), use a geodesic loss, or a continuous representation (next section).
A rotation matrix drifts away from orthonormal after many multiplications in your own code. Re-orthonormalize (for example with a polar decomposition) or keep a quaternion and normalize it.
Write the matrix of the intrinsic zyx sequence with angles $(\psi, \theta, \phi)$ symbolically, then the extrinsic XYZ sequence with angles $(\phi, \theta, \psi)$, and show they are equal. (This is the “yaw, pitch, roll” convention of aerospace, written two ways.) Check with mjcourse.spatial.euler_to_quat.
Implement spherical linear interpolation (slerp) between two quaternions, making sure it takes the short path, and use it to command a smooth orientation trajectory for a free body by setting its quaternion directly. Measure the angular speed along the path and show it is constant. Then compare with naive linear interpolation of quaternion components followed by normalization.
How a network represents rotation changes what it can learn. Zhou, Barnes, Lu, Yang and Li showed that every representation in four or fewer real dimensions is discontinuous for 3-D rotations, and proposed a continuous 6-D representation (two columns of the rotation matrix, re-orthonormalized), which is now common in 6-D pose estimation and in policies that output end-effector orientations (arXiv:1812.07035, CVPR 2019). Whatever the representation, evaluate rotation errors as geodesic angles in degrees, and state whether the metric is symmetric under the object’s symmetries (a cube looks the same after 90 degrees).
{"id": "4.1-check", "title": "Knowledge check", "questions": [
{"kind": "mcq", "q": "In MuJoCo's convention, what is the quaternion of a 90-degree rotation about the world z axis?",
"options": ["(0, 0, 0.7071, 0.7071)", "(0.7071, 0, 0, 0.7071)", "(0.7071, 0.7071, 0, 0)", "(1, 0, 0, 90)"],
"answer": 1,
"explain": "<p>$(\\cos 45°, 0, 0, \\sin 45°)$ with $w$ first. The first option is the $(x, y, z, w)$ order used by SciPy and three.js.</p>"},
{"kind": "mcq", "q": "Body B's pose in frame A is $T_{AB}$ and C's pose in frame B is $T_{BC}$. What is C's pose in frame A?",
"options": ["$T_{BC} T_{AB}$", "$T_{AB} T_{BC}$", "$T_{AB}^{-1} T_{BC}$", "$T_{AB} + T_{BC}$"],
"answer": 1,
"explain": "<p>Adjacent indices cancel: $T_{AB} T_{BC} = T_{AC}$.</p>"},
{"kind": "numeric", "q": "Two quaternions differ by a rotation of 0.2 rad. What is $|w|$ of the relative quaternion $\\mathbf q_1^* \\otimes \\mathbf q_2$? (4 decimals)",
"answer": 0.995, "tol": 0.0001, "unit": "",
"explain": "<p>$|w| = \\cos(0.1)$ = 0.9950.</p>"},
{"kind": "open", "q": "A dataset stores object orientations as quaternions produced by a simulator that sometimes returns $-\\mathbf q$. Your regression network's loss plateaus. Propose two fixes and the cost of each.",
"reference": "<p>(1) Canonicalize targets to $w \\ge 0$: one line, but discontinuous near $w = 0$ (rotations near 180 degrees), where the network still sees jumps. (2) Change the loss to a geodesic angle or to $\\min(\\|\\mathbf q - \\hat{\\mathbf q}\\|, \\|\\mathbf q + \\hat{\\mathbf q}\\|)$: handles the sign, still uses a 4-D output. (3) Predict a continuous 6-D representation and convert: removes the discontinuity at the cost of a slightly larger output and a Gram-Schmidt step. Also check object symmetries, which create the same problem for symmetric objects.</p>"}
]}
Lesson 4.2 moves from where a body is to how hard it is to move: mass, centre of mass and the inertia tensor, read out of MuJoCo and checked against the formulas.