Where you are. You can build rotation matrices, compose them in the right order, and you have seen Euler angles tear themselves apart at gimbal lock. This lesson hands you the representation that does not tear, plus the two ways it will still bite you.
Two photographs of a clock
Here are two photographs of the same wall clock, taken an hour apart. In the first, the hour hand points at 11. In the second, it points at 1.
Where was the hand in between? Average the readings: (11 + 1) / 2 = 6. Six o’clock. The bottom of the dial, pointing straight down, about as far from both photographs as it is possible to get.
Nothing went wrong. The readings were right and the arithmetic was right, and the answer is nonsense. The mistake happened earlier, when you agreed to treat a position on a circle as though it were a number. Positions on a circle do not add. They only behave as though they do, most of the time, which is exactly what makes this the kind of bug you ship rather than the kind you catch.
Your robot’s wrist has the same problem in a worse form. Its orientation does not live on a dial; it lives on a curved surface with no edge and no natural numbering, and you have to choose coordinates on that surface before you can compute anything. The rest of this lesson is about the choice that makes the obvious arithmetic - blending, comparing, averaging - stop lying to you.
The idea in one paragraph
A quaternion is four numbers that name a 3D orientation. Where those four numbers come from is algebra William Rowan Hamilton worked out in 1843, and you can treat it the way you treat UTF-8: an encoding, correct, someone else’s problem. What is your problem is the contract around it. The four numbers must keep unit length, or they are not a rotation at all. Every orientation has exactly two valid quaternions, and , which are the same rotation and will quietly poison any loss function or distance check that does not know it. They compose by multiplication, right to left, the same order as matrices. And because they cover the space of orientations smoothly, with no seams, you can interpolate between two of them and get exactly the motion you meant: the shortest turn, at constant speed. That last property is the one you will use every single day.
Every rotation is one turn about one axis
Start with a fact that is not obvious and is worth trusting. However tangled a 3D rotation is, however many separate turns you composed to build it, the net result is always reachable as a single rotation by a single angle about a single fixed axis. Euler proved this in 1775. Compose fifty rotations and the total is one turn about one line through the origin.
That collapses the problem nicely. To name any orientation you need a direction - three numbers, themselves constrained to unit length - and an angle. Four numbers, one axis, one turn.
A quaternion is that packaging with one twist: it stores the half angle.
In plain words: the first three slots hold which way the axis points and how much turn there is; the fourth slot holds how much turn there is not. A quarter turn about the z-axis is and , so the four numbers come out .
Wider than the screen; scroll it sideways.
Halving the angle looks arbitrary, and it is the one piece of the encoding worth remembering, because it is the direct cause of the strangest behaviour in this lesson. It arrives two sections from here.
Unit length is the whole contract
Floating-point error accumulates any time you multiply quaternions in a loop, which a controller does forever. I composed one small rotation a million times to see how bad it gets. In float64 the drift is glacial: the norm finished 3.4e-11 away from 1, which is nothing. In float32, which is what a policy network hands you, the same million products finished 9.0e-3 out. Run the identical experiment with rotation matrices in float32 and the determinant finishes at 1.0465 - a matrix that no longer merely rotates, because it now scales whatever it touches as well.
Both representations drift. The difference is what repair costs. Snapping a quaternion back is : one square root and four divides. Snapping a 3x3 matrix back means finding the nearest orthonormal matrix, which is a Gram-Schmidt pass or an SVD. That is a linear-algebra routine you now run every few hundred frames, forever, and it is a large part of why nobody stores orientation as a matrix.
Two names for every rotation
Negate all four numbers. Every one of them. You would expect the opposite rotation, or at least a different one.
You get the same rotation. Not approximately: and produce byte-identical rotation matrices and rotate every vector to the same floating-point bits.
Read back through the encoding and you can see why. If is a 90° turn about , then decodes as a 270° turn about . Same destination, opposite way round the dial. This is the half-angle collecting its debt: turning by and by land in exactly the same place, but half of those two angles differ by 180°, and 180° flips the sign of every sine and cosine.
Wider than the screen; scroll it sideways.
Harmless, until the moment you subtract two quaternions and call the result an error.
That first line of the gotcha is not hypothetical. It is the most common silent bug in orientation-predicting policies. You will not meet it in this course, and the reason is worth knowing: every policy you train from Module 3 onward predicts joint angles, because that is the interface the SO-101’s servos accept, so no quaternion is ever a network output. The moment you work on an arm whose policy predicts an end-effector pose, this is the first thing to check in the loss.
Chaining them
Quaternions compose by quaternion multiplication, and the convention is the one you already know: means apply first, then . Right to left, exactly like matrix products and exactly like . With scipy’s Rotation objects you write r2 * r1 and it agrees with r2.as_matrix() @ r1.as_matrix() to machine precision.
The payoff: interpolation that behaves
Now the reason all of this is worth learning.
You have two wrist poses - where the gripper starts and where it needs to end up - and you need the poses in between. The obvious move is to interpolate the three Euler angles component by component, and it is the clock-face mistake wearing a different hat.
The honest tool is slerp: spherical linear interpolation. Because unit quaternions live on a sphere, “interpolate” has an unambiguous meaning there - walk the great-circle arc between the two points at constant speed - and slerp is exactly that walk.
Both paths below run between the same two orientations over the same 600 steps. Slerp turns through 161.49°, which is the true angle between the endpoints, and it turns through 0.2696° on every single one of the 599 steps: constant angular speed, all the way. Component-wise Euler interpolation turns through 172.13°, 6.6% further, at a speed that wanders from 0.2744° to 0.3028° per step. Halfway along, the two wrists are pointing 28.82° apart.
Wider than the screen; scroll it sideways.
A 6.6% detour is a bad move, not a disaster. The disaster is available too, and it needs no exotic setup.
The bug that is not conceptual
Everything above is about understanding. This last one is pure clerical damage, and it costs people whole afternoons.
When to use what
| Numbers | Compose | Interpolate | Drift repair | Good for | |
|---|---|---|---|---|---|
| Euler angles | 3 | awkward | never do it | none needed | showing a human, hand-authoring a pose |
| Rotation matrix | 9 | matrix product | no | SVD or Gram-Schmidt | applying to vectors, reading off axes |
| Quaternion | 4 | quaternion product | slerp | divide by the norm | storing, sending, comparing, blending |
The permanent rule: store and send quaternions, compute with matrices when you need to hit a vector, display Euler angles to humans, and never interpolate anything but quaternions.
Review
Four numbers, and the contract on them
Every rotation is a single turn about a single axis, so naming one needs a direction and an angle: four numbers. A quaternion is that packaging with one twist, in that it stores the half angle. The first three slots hold which way the axis points and how much turn there is, shrunk by the sine of half the turn; the fourth holds the cosine of half the turn, which is how much turn there is not. The four numbers must keep unit length or they are not a rotation at all, but something that also scales whatever it touches. That contract is cheap to keep: divide by the norm, one square root and four divides. Repairing a drifted rotation matrix instead means finding the nearest orthonormal matrix, which is a real linear algebra routine, and that is a large part of why nobody stores orientation as a matrix.
Two names for every rotation
Negate all four numbers and you get the same rotation, byte for byte. Read the negated quaternion back through the encoding and you can see why: a ninety-degree turn about plus z becomes a two hundred and seventy degree turn about minus z. Same destination, opposite way round the dial. This is the half angle collecting its debt, because turning by an angle and by that angle plus a full revolution land in the same place, but half of those two differ by one hundred and eighty degrees, which flips the sign of every sine and cosine. Every orientation has exactly two quaternions and there is no third. Harmless, until you subtract two quaternions and call the result an error: a perfect prediction of the negated quaternion scores the worst loss that mean squared error can produce, fifty nine times worse than an answer which is genuinely thirty degrees wrong.
Interpolation that behaves
Average two clock readings of eleven and one and you get six, the bottom of the dial. Positions on a circle do not add; they only behave as though they do, most of the time. Orientations have the same problem in a worse form, and blending three Euler angles component by component is that mistake wearing a different hat. Because unit quaternions live on a sphere, the word interpolate has an unambiguous meaning there: walk the great circle arc between the two points at constant speed. That is slerp. On one measured pair of wrist poses five degrees apart, component-wise blending swept a full three hundred and sixty five degrees and passed halfway round the wrong side, while slerp turned five degrees and stopped. Store and send quaternions, compute with matrices when you need to hit a vector, show angles to people, and never interpolate anything but quaternions.
Check yourself
1. Why four numbers? Orientation has three degrees of freedom, so where does the fourth come from and what stops it adding a fourth degree of freedom?
Euler’s rotation theorem says every rotation is one turn about one axis: three numbers for the axis direction, one for the angle. Four slots. The unit-length constraint removes exactly one degree of freedom, taking four back down to three. The redundancy is the point: it buys a coordinate system with no seams, which three numbers provably cannot have.
2. A policy predicts orientation as a quaternion and trains with MSE against the label. Loss falls nicely, then plateaus, and inspecting the worst-scoring samples shows the predictions look correct. What is happening, and give two fixes?
Double cover. Those samples are predicting where the label is : physically identical, 0° of error, and MSE exactly 1.0 because the two points are antipodal on the unit 4-sphere. The loss is teaching the network to avoid a set of right answers. Fixes: canonicalise the labels so ; take the minimum of the loss against and ; or switch to a rotation distance such as or .
3. After an hour of composing updates your quaternion’s norm reads 0.9994. Is it still a rotation? What do you do, and what would the same fix have cost on a rotation matrix?
No. Only unit quaternions are rotations; this one now scales slightly as well as rotating, and the error compounds. The fix is q = q / np.linalg.norm(q): one square root and four divides, cheap enough to do unconditionally every cycle. The matrix equivalent is re-orthonormalising, which needs Gram-Schmidt or an SVD - a real linear-algebra routine rather than a division.
4. Decode -q = (0, 0, -0.7071, -0.7071) as an axis and an angle, and explain why it is the same rotation as q = (0, 0, +0.7071, +0.7071).
gives , so . The vector part divided by gives the axis . So is a 270° turn about , which lands in exactly the same place as a 90° turn about . The half-angle is what creates the pair: and are the same rotation, but their halves differ by 180°, which negates every sine and cosine.
5. An interpolator blends roll-pitch-yaw component-wise between two poses. Most moves look fine; on one particular move the wrist makes a full revolution to reach a pose a few degrees away. What is special about that move?
Its pitch sits near ±90°, where roll and yaw stop being independent. Near there, two very different (roll, pitch, yaw) triples can name nearly the same orientation, so a small physical move is written as a huge change in the numbers - and component-wise interpolation, which only sees numbers, drives the whole way. Measured on a real pair 5.10° apart, the interpolated wrist swept 365.00° and passed 180.00° from where it should have been. Slerp on the same pair sweeps 5.10°.
6. Your perception node sends orientations to a controller written by someone else. Poses come out plausible but rotated about the wrong axis. What do you check first, and why is “plausible” the tell?
Quaternion element order: w-first versus w-last. A reordered unit quaternion is still a unit quaternion, so nothing validates false and nothing throws - you get a legitimate rotation of the correct magnitude about the wrong axis. That is exactly what “plausible but wrong” looks like. A 90° turn about , written w-first and read w-last, becomes a 90° turn about , which is 120.00° from what you meant.
Do this
Work parts 3 and 4 of code/rotations_lab.py. Parts 1 and 2 belong to the earlier rotation lessons; these two are the quaternion payoff.
Part 3, double cover. It already asserts that and rotate a vector identically and prints . Before you run it, predict the MSE. Then extend it: pick a third quaternion that is a genuine 30° error, print its MSE too, and see which of the two the loss prefers. Add the two-line canonicaliser (q if q[3] >= 0 else -q) and confirm the number it was punishing goes to zero.
Part 4, slerp against Euler lerp. Run it and look at the plot it writes. Then set e0, e1 = np.array([0., -89.5, 0.]), np.array([180., -89.5, 185.]) - the near-lock pair from this lesson - and run it again. To get the numbers rather than the picture, keep the two rotation sequences before they are applied to a vector:
eul = R.from_euler("xyz", e0 + ts[:, None] * (e1 - e0), degrees=True)
sl = Slerp([0, 1], R.concatenate([r0, r1]))(ts)
swept = lambda seq: np.degrees((seq[:-1].inv() * seq[1:]).magnitude()).sum()
print(f"slerp {swept(sl):.2f}° euler lerp {swept(eul):.2f}°")
Watch 5.10° of required motion turn into 365.00° of actual motion. Then put the original endpoints back and confirm the gentler 161.49° against 172.1°. Both totals are independent of how finely you sample; the per-step angles in the figure above are not, because that run used 600 steps and the lab uses 60.
Then one thing away from the keyboard. Take any object with a clear front - a phone, a book, a mug with a handle. Hold it in a start pose, hold it in an end pose, and move between them the short way. That path is what slerp computes. Now do it again taking the long way round. Both are valid interpolations between the same two orientations, and only one of them is the one you meant; that is the whole reason the shortest-arc property is worth paying for.
What you can now do
You can store an orientation as four numbers and say what each one holds. You can state the unit-length contract, spot a quaternion that has drifted off it, and repair it for the cost of one division. You can explain why every rotation has two quaternions, recognise the loss-function bug that follows, and name three fixes. You can compose quaternions in the right order, interpolate two poses along the shortest arc at constant speed, and diagnose the element-order bug that makes orientation look plausible and be wrong.