Unit quaternions: compose rotations and understand the sign

Rotate vectors with Hamilton quaternions, check composition order, and see why q and minus q describe the same orientation. Includes an interactive experiment and Python.

By 12 min read

A robot orientation may arrive as four numbers labeled w, x, y, z. They can encode a three-dimensional rotation, but their meaning depends on component order, multiplication rules, and the frames involved.

A unit quaternion gives a compact way to compose rotations and recover their inverses. This lesson follows two quarter-turns, checks the resulting vector, and explains why reversing every quaternion component leaves the orientation unchanged.

State the quaternion convention

We use the Hamilton, scalar-first convention:

q = w + xi + yj + zk
q = (w, x, y, z)
i² = j² = k² = ijk = −1

The scalar part is w. The vector part is (x, y, z). All four components are dimensionless; the vector part is not a point's spatial position.

Rotations here are active and right-handed. We turn a vector while keeping one coordinate frame fixed. Vectors are columns, and positive rotation follows the right-hand rule.

Positive z rotation turns +x toward +y; positive x rotation turns +y toward +z. Rotation matrices explains how this differs from changing the frame used to describe an unchanged vector.

Component order alone does not specify every convention. Check multiplication, frame direction, and active versus passive interpretation when moving data between systems. For example, SciPy's Rotation.from_quat accepts active rotations but defaults to (x, y, z, w). Its scalar_first=True option accepts this lesson's order. SciPy documents both orders and its normalization behavior.

Encode an axis and half an angle

For a unit axis u = (u_x, u_y, u_z) and rotation angle θ, construct:

w = cos(θ/2)
x = u_x sin(θ/2)
y = u_y sin(θ/2)
z = u_z sin(θ/2)

The axis-angle lesson supplies the physical interpretation: u is the direction that stays fixed, and θ is the turn around it. The quaternion uses half that angle in its components.

For a 90° turn about +z, θ/2 is 45°. Write c = √2/2. Then q_z = (c, 0, 0, c). Its norm is √(c² + c²) = 1.

In general, the squared norm is w² + x² + y² + z² = 1. Four components with this constraint describe three rotational degrees of freedom. The quaternion (1, 0, 0, 0) represents identity, and (0, 0, 1, 0) represents a 180° turn about +y.

Rotate a vector with two products

Quaternion multiplication is not componentwise multiplication. If a = (a₀, a) and b = (b₀, b), with bold letters denoting their three-component vector parts, the Hamilton product is:

scalar(ab) = a₀b₀ − a · b
vector(ab) = a₀b + b₀a + a × b

The cross product term makes order matter. For example, ij = k, while ji = −k. Multiplication is associative, so parentheses can move without changing the factor order.

Define the conjugate by negating only the vector part: q̄ = (w, −x, −y, −z). Embed a spatial vector v as the pure quaternion (0, v), then calculate:

(0, v′) = q (0, v) q̄

Take q_z = (c, 0, 0, c) and v = (1, 0, 0). The first product is q_z(0, 1, 0, 0) = (0, c, c, 0). Multiplying by q̄_z = (c, 0, 0, −c) gives (0, 0, 1, 0).

Drop the leading scalar zero: v′ = (0, 1, 0), exactly the expected quarter-turn. Solà derives this rotation action from the Hamilton product.

Work through two quarter-turns

Let q₁ turn 90° about fixed +z, and let q₂ then turn 90° about fixed +x. Starting with (1, 0, 0), the first turn gives (0, 1, 0); the second gives (0, 0, 1).

The combined quaternion puts the first action on the right:

q₁ = (c, 0, 0, c)
q₂ = (c, c, 0, 0)
q = q₂q₁ = (½, ½, −½, ½)

You can check the negative y component directly: the product's cross term contains +x × +z = −y. All four squared components sum to 1.

In matrix form the same rotation is:

R(q) = R_x R_z
R(q) =
[0, −1, 0;
0, 0, −1;
1, 0, 0]

Its first column is the output for (1, 0, 0): (0, 0, 1). Reverse the order and q₁q₂ = (½, ½, ½, ½). Now the x-axis vector stays unchanged during the x turn and then moves to (0, 1, 0) during the z turn. SciPy's composition rule follows the same rightmost-first order.

Compare order and quaternion sign

Start with the quarter-turn example. The component tables use (w, x, y, z), while the vector readouts use (x, y, z). Compare Composed output with Reverse-order output, then check Negate combined quaternion.

Interactive experiment

Compose two turns and change the quaternion sign

Apply q₁ about fixed +z, then q₂ about fixed +x. The combined quaternion is q₂q₁. All vector coordinates use the same right-handed frame.

90°
90°

Hamilton scalar-first order: (w, x, y, z). Positive z turns +x toward +y; positive x turns +y toward +z. Negating changes the displayed representative to −q₂q₁ and preserves its rotation. Presets change the two angles only.

First quaternion q₁ (w, x, y, z)
wxyz
0.7070.0000.0000.707
Second quaternion q₂ (w, x, y, z)
wxyz
0.7070.7070.0000.000
Displayed quaternion q (w, x, y, z)
wxyz
0.5000.500-0.5000.500
  • Open circle: input
  • Filled dot: composed output
  • Open square: reverse order

XY projection

XY projectionThis view drops z. Composed output: z = 1.000; reverse-order output: z = 0.000. A projected origin can represent a nonzero three-dimensional vector.-1-1011xy
This view drops z. Composed output: z = 1.000; reverse-order output: z = 0.000. A projected origin can represent a nonzero three-dimensional vector.

XZ projection

XZ projectionThis view drops y. Composed output: y = 0.000; reverse-order output: y = 1.000. A projected origin can represent a nonzero three-dimensional vector.-1-1011xz
This view drops y. Composed output: y = 0.000; reverse-order output: y = 1.000. A projected origin can represent a nonzero three-dimensional vector.
Input vector
(1.000, 0.000, 0.000)
After first turn
(0.000, 1.000, 0.000)
Composed output
(0.000, 0.000, 1.000)
Opposite-sign output
(0.000, 0.000, 1.000)
Reverse-order output
(0.000, 1.000, 0.000)
Recovered input
(1.000, 0.000, 0.000)
Quaternion norm
1.000
Output length
1.000
Opposite-sign matrix difference
0.000000
Reverse-order matrix difference
2.449490
Rotation matrix R(q)
0.000-1.0000.000
0.0000.000-1.000
1.0000.0000.000
Rotation matrix R(−q)
0.000-1.0000.000
0.0000.000-1.000
1.0000.0000.000

The complete rotations differ when the turn order reverses. The opposite-sign quaternion still gives the same rotation as the selected order.

Each matrix column is the image of a unit basis vector, so the two matching matrices establish agreement for every input. Matrix differences are Frobenius norms: square each entry difference, add, and take the square root. The equality tolerance is 10⁻¹². Both plots use equal axis scales. All probes have length 1; recovered input applies the conjugate of the displayed unit quaternion. Displayed entries are rounded; calculations keep additional precision.

The checkbox changes every displayed quaternion component. The output and complete rotation matrix stay unchanged. Switch the probe to each unit basis vector to inspect the matrix columns individually.

Choose Only the z turn: the X probe becomes (0, 1, 0). Identity leaves it at (1, 0, 0).

Two half-turns sends it to (−1, 0, 0), and the two orders now agree as rotations. Their quaternion products are (0, 0, −1, 0) and (0, 0, 1, 0), an opposite-sign pair.

The two diagrams are projections with equal horizontal and vertical scales. An output can project onto the origin while its omitted coordinate remains nonzero. Use the full numeric readouts to distinguish that case from a zero vector.

The controls construct unit quaternions directly and normalize their product after composition. This bounded experiment compares fixed-axis rotations; it does not simulate a robot's joint motion or estimate orientation from sensor measurements.

Explain why two signs give one rotation

For a unit q, q and −q represent the same rotation. Both factors in the rotation action change sign, so the signs cancel:

(−q)(0, v)(−q̄) = q(0, v)q̄

This holds for every vector v. Accordingly, R(−q) = R(q). The correspondence from unit quaternions to three-dimensional rotations is two-to-one, often called a double cover. SciPy's quaternion output documentation describes this sign equivalence.

Agreement on one probe alone would be weaker evidence. Identity and a turn about +x both leave (1, 0, 0) unchanged. Comparing the complete matrices checks all three basis directions and therefore every vector, by linearity.

This also explains the two-half-turn preset. The quaternion products differ by sign, yet the corresponding rotations commute. General three-dimensional rotations still depend on order, as the quarter-turn example demonstrates.

Normalize before using the conjugate as an inverse

Normalize a nonzero quaternion by dividing each component by its Euclidean norm. The zero quaternion has no unit normalization and no multiplicative inverse.

For a unit quaternion, q⁻¹ = q̄. For a general nonzero quaternion, q⁻¹ = q̄/‖q‖². The lab requires unit inputs for its rotation calculation; it does not silently treat every four-number tuple as a unit quaternion.

If you insert an unnormalized q into q(0, v)q̄, the result includes a scale factor of ‖q‖². For q = (2, 0, 0, 0), the sandwich product multiplies a vector by 4. Normalizing q gives (1, 0, 0, 0), which leaves that vector unchanged.

Repeated floating-point operations can introduce norm drift. Renormalizing a valid rotation quaternion corrects that constraint error. It does not repair a wrong frame convention, swapped components, or an inaccurate orientation estimate.

Keep representation and physical motion separate

Unit quaternions avoid the gimbal-lock coordinate degeneracy of a three-angle Euler description. They retain a unit-norm constraint and a sign ambiguity. Converting them back to Euler angles still encounters that angle representation's singular cases.

Changing coordinates cannot remove a robot's mechanical constraints or kinematic singularities.

A unit quaternion encodes rotation about the origin. A spatial pose also needs translation, as shown in homogeneous transformations. An orientation by itself does not determine a tool's world position.

Sign handling matters when interpolating orientations. The componentwise average of q and −q is zero, despite the endpoints representing identical orientations. For interpolation, choose compatible signs before blending, and use an algorithm designed for rotations. SciPy's Slerp follows a shortest rotational path with constant angular velocity between each pair of keyframes. That geometric property does not guarantee collision avoidance, joint-limit feasibility, or safe motion. See the official Slerp description.

The manifolds and tangent spaces lesson starts with a circle to explain why ordinary vector operations can leave a curved set. It separates a valid local direction from a finite update that stays on the set, then connects that distinction to rotations.

Geodesics uses circular headings to separate the shortest route from a longer path with the same endpoints. Its antipodal example shows why choosing an interpolation direction can require an explicit convention.

Check the calculation in Python

This Python 3 example uses only the standard library. It normalizes the composed quaternion, applies the rotation action, and checks opposite signs on all three basis vectors. The formatter removes tiny display noise without changing the calculations.

from math import sqrt

def multiply(a, b):
    w, x, y, z = a
    s, u, v, t = b
    return (
        w*s - x*u - y*v - z*t,
        w*u + x*s + y*t - z*v,
        w*v - x*t + y*s + z*u,
        w*t + x*v - y*u + z*s,
    )

def unit(q):
    length = sqrt(sum(value * value for value in q))
    if length == 0:
        raise ValueError("A zero quaternion cannot be normalized")
    return tuple(value / length for value in q)

def rotate(q, vector):
    if abs(sum(value * value for value in q) - 1) > 1e-12:
        raise ValueError("The rotation quaternion must have unit norm")
    conjugate = (q[0], -q[1], -q[2], -q[3])
    return multiply(multiply(q, (0, *vector)), conjugate)[1:]

def show(values):
    return "(" + ", ".join(
        f"{0.0 if abs(value) < 0.0005 else value:.3f}"
        for value in values
    ) + ")"

c = sqrt(0.5)
q1, q2 = (c, 0, 0, c), (c, c, 0, 0)
q = unit(multiply(q2, q1))
reverse = unit(multiply(q1, q2))
opposite = tuple(-value for value in q)
probe = (1, 0, 0)
basis = ((1, 0, 0), (0, 1, 0), (0, 0, 1))
for vector in basis:
    assert all(abs(a - b) < 1e-12 for a, b in zip(
        rotate(q, vector), rotate(opposite, vector)
    ))

print("q2 q1:", show(q))
print("Composed:", show(rotate(q, probe)))
print("Reverse:", show(rotate(reverse, probe)))
print("Opposite sign:", show(rotate(opposite, probe)))
print("Basis columns:")
for vector in basis:
    print(show(rotate(q, vector)))

Expected output:

q2 q1: (0.500, 0.500, -0.500, 0.500)
Composed: (0.000, 0.000, 1.000)
Reverse: (0.000, 1.000, 0.000)
Opposite sign: (0.000, 0.000, 1.000)
Basis columns:
(0.000, 0.000, 1.000)
(-1.000, 0.000, 0.000)
(0.000, -1.000, 0.000)

The last three lines are matrix columns, listed one per line. Place them side by side to recover the worked R(q). This example uses small finite inputs; a production numerical routine also needs explicit handling of invalid values and extreme magnitudes.

Try it yourself

Exercise 1. Let q = (0, 0, 1, 0). Find the image of v = (1, 2, 3). What rotation does −q produce, and how would you undo the result?

Show the half-turn solution

q represents a 180° rotation about +y. The y component stays fixed, while x and z change sign, giving (−1, 2, −3). The quaternion −q produces exactly the same rotation.

Apply the conjugate q̄ = (0, 0, −1, 0) to undo it. This is a half-turn about −y, which has the same action as the forward half-turn, and it returns (1, 2, 3).

Exercise 2. An unnormalized quaternion is r = (1, 1, 0, 0). Normalize it, identify its rotation, and apply it to v = (0, 1, 0). What goes wrong if you use r(0, v)r̄ directly?

Show the normalization solution

The norm is √2, so the unit quaternion is (1/√2, 1/√2, 0, 0). Its scalar part is cos 45°, and its vector part points along +x, giving a 90° rotation about +x. The unit-quaternion action sends v to (0, 0, 1).

The unnormalized sandwich product also multiplies length by ‖r‖² = 2, giving (0, 0, 2). That scaled result is not a pure rotation of the original vector.

Sources and further study