3D-Rotation by Quaternions

This notebook uses the quaternion algebra to rotate vectors and to represent the orientation of a rigid body.

Introduction

A unit quaternion is the tool we use to represent the orientation of a rigid body or coordinate frame in 3D. Compared with Euler angles it has no gimbal lock (no orientation where a degree of freedom is lost), and compared with a $3\times3$ rotation matrix it is compact (4 numbers instead of 9) and easy to keep normalised and to interpolate. We will see that a unit quaternion both rotates vectors and, read the other way, transforms vector coordinates between frames.

The vector (pure) quaternion

To rotate a 3D vector $\vec v=(v_x,v_y,v_z)$ with the quaternion product, it must first be "promoted" to a pure quaternion — a quaternion with zero scalar part:

$$\mathbf v = \begin{bmatrix} 0 \\ \vec v\end{bmatrix} = 0 + v_x i + v_y j + v_z k .$$

The rotation quaternion $\mathbf q$

A rotation of angle $\phi$ about a unit axis $\vec u$ is encoded by the unit quaternion

$$\mathbf q = \begin{bmatrix}\cos(\phi/2)\\[2pt] \vec u\,\sin(\phi/2)\end{bmatrix} = \cos\tfrac{\phi}{2} + \big(u_x i + u_y j + u_z k\big)\sin\tfrac{\phi}{2}.$$

Its norm is $\cos^2\tfrac{\phi}{2}+\sin^2\tfrac{\phi}{2}=1$, so it is a unit quaternion, and every unit quaternion can be written in this axis–angle form.

Why the half-angle $\phi/2$? The rotation is applied as a sandwich $\mathbf q\otimes\mathbf v\otimes\mathbf q^*$ (below), so $\mathbf q$ effectively acts twice on the vector. Encoding $\phi/2$ in $\mathbf q$ makes the two half-turns combine into the desired full angle $\phi$.

Because $\mathbf q$ is a unit quaternion, its inverse equals its conjugate: $$\mathbf q^{-1}=\mathbf q^* = \begin{bmatrix}\cos(\phi/2)\\[2pt] -\vec u\,\sin(\phi/2)\end{bmatrix}.$$

Rotating a vector — the sandwich product

The vector $\vec v$ (as the pure quaternion $\mathbf v$) is rotated by

$$\boxed{\;\mathbf v' = \mathbf q \otimes \mathbf v \otimes \mathbf q^*\;}$$

where

  • $\mathbf v=[0,\ \vec v]^\top$ is the (pure) quaternion of the original vector,
  • $\mathbf q$ is the unit rotation quaternion, $\mathbf q^*$ its conjugate,
  • $\mathbf v'=[0,\ \vec v\,']^\top$ is again pure, and its vector part $\vec v\,'$ is the rotated vector.

The sandwich product preserves the norm ($\lVert\vec v\,'\rVert=\lVert\vec v\rVert$) and leaves the scalar part zero — it is a genuine rotation. Read as "the vector is moved while the frame stays fixed," this is an active rotation.

The full process to rotate $\vec v$ by angle $\phi$ about unit axis $\vec u$:

  1. build $\mathbf q=[\cos\tfrac{\phi}{2},\ \vec u\sin\tfrac{\phi}{2}]$ and its conjugate $\mathbf q^*$;
  2. promote $\vec v$ to the pure quaternion $\mathbf v=[0,\vec v]$;
  3. compute $\mathbf v'=\mathbf q\otimes\mathbf v\otimes\mathbf q^*$ and read off its vector part.

Active vs. passive — one formula, two readings

The same algebra $\mathbf q\otimes(\cdot)\otimes\mathbf q^*$ is used in two ways that are easy to confuse:

  • Active rotation — the frame is fixed and the vector itself is rotated by $\phi$ about $\vec u$: $$\vec v\,' = \mathbf q\otimes \vec v\otimes\mathbf q^*.$$
  • Passive transformation — the vector is fixed and we re-express its coordinates in another frame. If $\mathbf q$ is the orientation of the body frame w.r.t. the world (see next cell), the same product maps body coordinates to world coordinates: $$\vec v_I = \mathbf q\otimes \vec v_B\otimes\mathbf q^*.$$

So "rotating a vector by $\mathbf q$" and "expressing a body vector in the world via orientation $\mathbf q$" are the same computation — only the interpretation differs.

Orientation: body frame ↔ inertial frame

Following the Hamilton, local→global convention, the orientation quaternion is defined as the transformation from the body frame $B$ to the inertial/world frame $I$:

$$\mathbf q = \mathbf q_{B\to I} = \mathbf q_{GL}.$$

Then a vector's coordinates transform as

$$\mathbf v_I = \mathbf q\otimes\mathbf v_B\otimes\mathbf q^*,\qquad \mathbf v_B = \mathbf q^*\otimes\mathbf v_I\otimes\mathbf q ,$$

where $\mathbf v_I$ and $\mathbf v_B$ are the same physical vector expressed in the two frames, and the inverse transform uses $\mathbf q_{I\to B}=\mathbf q_{B\to I}^*=\mathbf q^*$. (This is the convention used by the IMU/ESKF notebooks and by ROS/Eigen/Ceres.)

The rotation matrix $\mathbf R\{\mathbf q\}$

The sandwich product is linear in $\vec v$, so it can be written as a single $3\times3$ rotation matrix $\mathbf R\{\mathbf q\}$ acting on the vector:

$$\vec v\,' = \mathbf R\{\mathbf q\}\,\vec v ,\qquad \mathbf R\{\mathbf q\}=\begin{bmatrix} 1-2(q_y^2+q_z^2) & 2(q_xq_y-q_wq_z) & 2(q_xq_z+q_wq_y)\\ 2(q_xq_y+q_wq_z) & 1-2(q_x^2+q_z^2) & 2(q_yq_z-q_wq_x)\\ 2(q_xq_z-q_wq_y) & 2(q_yq_z+q_wq_x) & 1-2(q_x^2+q_y^2) \end{bmatrix}.$$

$\mathbf R\{\mathbf q\}$ is orthonormal with $\det=+1$ (a proper rotation), and $\mathbf R\{\mathbf q\}=\mathbf R\{-\mathbf q\}$ — the quaternion double-covers $SO(3)$, i.e. $\mathbf q$ and $-\mathbf q$ are the same rotation. This is the matrix that appears as $\mathbf R$ in the ESKF equations (digest).

Composing rotations

To apply rotation $\mathbf q_1$ first and then $\mathbf q_2$, multiply the quaternions in that order:

$$\mathbf q_c = \mathbf q_2 \otimes \mathbf q_1,\qquad\text{equivalently}\qquad \mathbf R\{\mathbf q_1\otimes\mathbf q_2\}=\mathbf R\{\mathbf q_1\}\,\mathbf R\{\mathbf q_2\}.$$

Since the quaternion product is non-commutative, the order matters — exactly mirroring the fact that $3\times3$ rotation matrices do not commute.

Numerical check

The cell below builds a rotation quaternion from an axis and angle and confirms: the sandwich product equals the matrix action $\mathbf R\{\mathbf q\}\vec v$, the rotation preserves norm, $\mathbf R\{\mathbf q\}$ is a proper rotation, composition matches matrix multiplication, and $\mathbf q,-\mathbf q$ give the same rotation.

In [1]:
import numpy as np

def qmul(a, b):                                  # Hamilton product  a ⊗ b
    aw, av = a[0], a[1:]; bw, bv = b[0], b[1:]
    return np.concatenate([[aw*bw - av @ bv], aw*bv + bw*av + np.cross(av, bv)])
def qconj(q): return np.concatenate([[q[0]], -q[1:]])

def quat_from_axis_angle(u, phi):
    u = np.asarray(u, float); u = u / np.linalg.norm(u)
    return np.concatenate([[np.cos(phi/2)], u * np.sin(phi/2)])

def rotate(q, v):                                # sandwich product  q ⊗ [0,v] ⊗ q*
    return qmul(qmul(q, np.concatenate([[0.0], v])), qconj(q))[1:]

def R_of_q(q):                                   # rotation matrix R{q}
    w, x, y, z = 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)]])

# concrete example: rotate (1,0,0) by 90° about +z  ->  (0,1,0)
q90 = quat_from_axis_angle([0, 0, 1], np.pi/2)
print("rotate (1,0,0) by 90° about z :", np.round(rotate(q90, [1, 0, 0]), 6))

q = quat_from_axis_angle([0.3, -0.7, 0.5], 1.1); v = np.array([1.0, 2.0, -0.5])
print("sandwich == R{q}·v            :", np.linalg.norm(rotate(q, v) - R_of_q(q) @ v))
print("norm preserved                :", abs(np.linalg.norm(rotate(q, v)) - np.linalg.norm(v)))
print("R{q} proper rotation (det,orth):", round(np.linalg.det(R_of_q(q)), 6),
      ",", round(np.linalg.norm(R_of_q(q).T @ R_of_q(q) - np.eye(3)), 12))

q1 = quat_from_axis_angle([0, 0, 1], 0.5); q2 = quat_from_axis_angle([1, 0, 0], 0.8)
print("compose R{q2⊗q1}=R{q2}R{q1}    :", np.linalg.norm(R_of_q(qmul(q2, q1)) - R_of_q(q2) @ R_of_q(q1)))
print("double cover ||R{q}-R{-q}||    :", np.linalg.norm(R_of_q(q) - R_of_q(-q)))
rotate (1,0,0) by 90° about z : [0. 1. 0.]
sandwich == R{q}·v            : 4.611102534756203e-16
norm preserved                : 4.440892098500626e-16
R{q} proper rotation (det,orth): 1.0 , 0.0
compose R{q2⊗q1}=R{q2}R{q1}    : 2.0014830212433605e-16
double cover ||R{q}-R{-q}||    : 0.0

Why quaternions (vs. Euler angles / matrices)

  • No gimbal lock — unlike Euler angles, quaternions have no orientation where a degree of freedom collapses.
  • Compact & stable — 4 numbers (vs. 9 for a matrix), trivially re-normalised to stay a valid rotation.
  • Efficient composition — chaining rotations is one quaternion product.
  • Smooth interpolationSlerp (spherical linear interpolation) blends two orientations along the shortest arc; useful for trajectories and animation.

The Hamilton convention (summary)

This notebook commits to Joan Solà's Hamilton convention:

  • scalar-first ordering $\mathbf q=[q_w,\mathbf q_v]^\top$ and $ij=+k$ (right-handed);
  • the orientation quaternion is local→global, $\mathbf q=\mathbf q_{B\to I}=\mathbf q_{GL}$;
  • a body vector goes to the world by $\mathbf v_I=\mathbf q\otimes\mathbf v_B\otimes\mathbf q^*=\mathbf R\{\mathbf q\}\,\mathbf v_B$.

Literature