Robotics Engineering · Lesson 1 of 26

Coordinate Frames &
Rigid-Body Math

The frame bugs that eat a week — and the twenty minutes of reasoning that find them.

Prerequisites: matrix multiplication + sine and cosine. That's it.
9
Chapters
10
Simulations
3
Code Labs

Chapter 0: The Miss — Four Centimetres to the Left

It is Wednesday. A humanoid on the pick cell has been missing its grasps for two days.

Not randomly. The gripper closes about four centimetres to the left of the bottle every single time — and only when the torso is turned to reach the bin on the robot's right. Command the same grasp with the torso square to the world and it works perfectly, a hundred times in a row.

Three engineers are in the room. Between them they have spent two days on it. Here is the whiteboard:

What they checkedResultConclusion drawn
Gripper mechanical backlash0.4 mm measured with a dial gaugeNot mechanical
Arm joint encoders vs. commandedtracking error < 0.05° on every jointNot the controller
Camera intrinsic calibrationreprojection RMS 0.21 px on a fresh boardNot the lens
Object detector 3D outputbottle centroid within 6 mm of a laser-measured ground truthNot perception
Re-ran the whole thing in simulationreproduces exactlyNot the hardware, then

Everything measures clean. The perception is right, the control is right, the mechanics are right, and the robot still misses. Two days gone.

The bug is a coordinate frame. It is always a coordinate frame. The reason it survived two days of measurement is that every individual component was measuring correctly in its own frame — the error lives in the arithmetic that stitches those frames together, and no component test can see it.

CONCEPT — the twenty-minute version, and the one rule you have to derive

Here is how you find it, starting from cold, in the time it takes to drink a coffee.

Step 1 — read the symptom as a function, not as a number. "Four centimetres" is not the datum. The datum is "zero when the torso is square, four centimetres when the torso is turned thirty degrees." The error is a function of a rotation. That single observation eliminates every additive, rotation-independent fault in one stroke: a constant mounting offset, a bias in the detector, a fixed tool-centre-point error. All of those would show at zero degrees too.

Step 2 — ask which quantities in the stack are multiplied by that rotation. Not many. The torso yaw appears in exactly one place in the kinematic chain: the transform from base_link to torso. Anything downstream of the torso — the head camera, the shoulder, the whole arm — has its offset from the torso origin rotated by that yaw before it lands in the world.

Step 3 — write the one line that could be wrong. A pose is a rotation and a translation together. Do not recall the composition rule — derive it, because the derivation is three lines and it tells you exactly which term is missing when the number comes out wrong.

Take one physical point — the bottle. It has a position in each frame, and each frame's transform is a promise about how to convert. The two promises we are given are:

pB = RBC pC + tBC     (frame C's answer, restated in B)
pA = RAB pB + tAB     (frame B's answer, restated in A)

There is nothing to invent here. Substitute the first into the second and expand, and the composition rule falls out with no choices made:

pA = RAB (RBC pC + tBC) + tAB
     = (RAB RBC) pC + (RAB tBC + tAB)

Now match that against the definition of what a single A←C transform must look like, pA = RAC pC + tAC. Two expressions agree for every pC, so the coefficient of pC and the constant term must match separately — term by term, with no room to argue:

TA←C = TA←B · TB←C  ⇒  RAC = RABRBC,   tAC = RAB tBC + tAB

Why the rotation lands on tBC and not on tAB. Because of what the two symbols are. tBC is C's origin measured with B's rulers — it is a set of numbers in frame B, so before it can be added to anything living in A it has to be carried into A, and carrying a vector between frames is exactly what RAB does. tAB is already measured with A's rulers; it needs no carrying. That asymmetry is the whole rule. If you can say that sentence out loud you will never write the composition backwards again.

The 4×4 homogeneous form is not a different fact — it is this same substitution, bookkept by a matrix product. Stack the rotation and the translation into one block matrix and multiply:

[[RAB, tAB], [0, 1]] · [[RBC, tBC], [0, 1]] = [[RABRBC,  RABtBC + tAB], [0, 1]]

Two degenerate checks, worth ten seconds at a whiteboard. If you are ever unsure you wrote the rule the right way round, substitute the cases where you already know the answer. Set RAB = I: the rule collapses to tAC = tBC + tAB, plain vector addition, which is exactly right when the parent is not rotated — and note that this is also the buggy line, which is the mathematical statement of why the bench test passed. Now set tBC = 0, meaning frames B and C share an origin: the rule gives tAC = tAB, so a pure re-orientation moves no origins. Both come out right, and a version with the rotation on the wrong translation fails the second check instantly (it would give tAC = RABtAB, which rotates a vector already living in A — visibly nonsense).

Look at where the top-right block came from: row one of the left matrix, (RAB | tAB), dotted with column four of the right matrix, (tBC | 1). That is literally RABtBC + tAB·1. The bottom row of ones is not decoration — it is the mechanism that turns "add the parent translation" into an ordinary matrix multiply. That is the entire reason the homogeneous representation exists, and it is a better answer to "why 4×4?" than "so you can compose them", which is a restatement, not a reason.

Read the translation part again: tAC = RAB tBC + tAB. The child's offset gets rotated by the parent's rotation before it is added. The single most common frame bug in robotics is code that writes t_AC = t_BC + t_AB — adding the offsets without rotating — because when you test it with the torso square, R is the identity and the bug is invisible.

Step 4 — predict the number before you look at the code. If that is the bug, the error is exactly the difference between the two candidate translations:

e = tBC − R tBC = (I − R) tBC

Now put real numbers in it. The head camera on this robot sits 8.0 cm forward and 42 cm above the torso pivot, so t = (0.080, 0.000, 0.420) metres in the torso frame, and the torso is turned 30° to the robot's right, which in the ROS convention (z up, positive yaw turns left) is ψ = −30°.

Worked example 1 — predicting the 4 cm, by hand.

The rotation about z by −30°, with cos(−30°) = 0.8660 and sin(−30°) = −0.5000:
R = [[0.8660,  0.5000, 0], [−0.5000, 0.8660, 0], [0, 0, 1]]
The correct offset in the world, R t, one row at a time:
  x: 0.8660 × 0.080 + 0.5000 × 0.000 + 0 × 0.420 = 0.06928 + 0 + 0 = 0.06928
  y: −0.5000 × 0.080 + 0.8660 × 0.000 + 0 × 0.420 = −0.04000 + 0 + 0 = −0.04000
  z: 0 × 0.080 + 0 × 0.000 + 1 × 0.420 = 0.42000

The buggy offset is just t itself: (0.08000, 0.00000, 0.42000).

The error = buggy − correct, component by component:
  ex = 0.08000 − 0.06928 = +0.01072 m
  ey = 0.00000 − (−0.04000) = +0.04000 m
  ez = 0.42000 − 0.42000 = 0.00000 m

  |e| = √(0.01072² + 0.04000²) = √(0.00011487 + 0.00160000) = √0.00171487 = 0.04141 m = 4.14 cm

And it points along +y, which in the ROS body convention is the robot's left. Four centimetres to the left. That is the reported symptom, reproduced from the mounting offset and one angle, before anyone opened an editor.

CODE — the same twelve multiplications, with nowhere to hide

The hand calculation above is the thing to do at a whiteboard. The next thing to do is write the compose in code and watch it reproduce the number. Write it with the matrix–vector product spelled out. No np.dot, no scipy, no @. Every one of those hides the exact line the bug lives on, and the point of the exercise is to put that line on screen.

python — from scratch, stdlib only
import math

def matvec(R, v):
    """3x3 times 3-vector, written out. Twelve flops, zero abstraction."""
    return [R[i][0]*v[0] + R[i][1]*v[1] + R[i][2]*v[2] for i in range(3)]

def matmul(A, B):
    return [[sum(A[i][k]*B[k][j] for k in range(3)) for j in range(3)]
            for i in range(3)]

def compose(RA, tA, RB, tB):
    """T_A<-C = T_A<-B . T_B<-C.   (RA, tA) is A<-B ; (RB, tB) is B<-C."""
    R = matmul(RA, RB)
    Rt = matvec(RA, tB)                       # carry the child offset INTO A first
    t = [Rt[i] + tA[i] for i in range(3)]     # ...then add the parent offset
    return R, t

def compose_broken(RA, tA, RB, tB):
    """The 11pm version. Rotations composed correctly; translations just added."""
    R = matmul(RA, RB)
    t = [tB[i] + tA[i] for i in range(3)]     # the missing matvec IS the bug
    return R, t

def rot_z(psi):
    c, s = math.cos(psi), math.sin(psi)
    return [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]]

# base_link <- torso : yaw 30 deg to the robot's right, pivot at the origin.
R_bt, t_bt = rot_z(math.radians(-30.0)), [0.0, 0.0, 0.0]
# torso <- head_camera : the static mount, 8 cm forward and 42 cm up.
R_tc = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
t_tc = [0.080, 0.000, 0.420]

_, t_ok  = compose(R_bt, t_bt, R_tc, t_tc)
_, t_bad = compose_broken(R_bt, t_bt, R_tc, t_tc)
err = math.sqrt(sum((a - b)**2 for a, b in zip(t_ok, t_bad)))

fmt = lambda v: "[" + ", ".join(f"{x:.5f}" for x in v) + "]"
print("correct:", fmt(t_ok))
print("broken :", fmt(t_bad))
print("err    :", f"{err:.5f}", "m")

Run it and the stdout is the whiteboard, character for character:

stdout
correct: [0.06928, -0.04000, 0.42000]
broken : [0.08000, 0.00000, 0.42000]
err    : 0.04141 m

Three things worth saying out loud while this is on the screen. First, compose and compose_broken differ by one function call — the rotations are composed identically in both, which is why an orientation check on the gripper passes and tells you nothing. Second, the z component is 0.42000 in both. A yaw cannot move anything along its own axis, so the tallest number in the mount is completely innocent; if you had eyeballed the 42 cm and expected a large error you would have been chasing the wrong term. Third, feed it rot_z(0.0) and the two functions return byte-identical translations. That is the unit test the original author wrote, and it passed.

Now the library one-liner, so the ladder is complete — hand arithmetic, from-scratch code, production call. In practice you write this one and reach for the version above only when a number disagrees:

python — the production form
from scipy.spatial.transform import Rotation

t_ok = Rotation.from_euler('z', -30, degrees=True).apply([0.080, 0.000, 0.420])
# array([ 0.06928203, -0.04      ,  0.42      ])

# In a live ROS 2 stack you would not do any of this by hand -- tf2 owns the
# tree and the timestamps, and the composition happens inside the buffer:
#   T = buf.lookup_transform('base_link', 'head_camera', stamp)

Notice that Rotation.apply silently does the right thing — it is a rotation object, so there is no way to forget to rotate. That is the real argument for the library, and we come back to it in the FRONTIER note at the end of this chapter: libraries do not exist because the multiply is hard, they exist because they delete whole categories of the mistake we just made.

You do not even need the matrix multiply. There is a closed form for the magnitude of (I − R)t, it is four lines to derive, and it is the version you can rebuild at a whiteboard whenever you need it. Start from the squared length, because squaring lets us use the one property a rotation is defined by, RR = I:

|e|² = |(I − R)t|² = t(I − R)(I − R) t

Expand the product in the middle: (I − R)(I − R) = I − R − R + RR, and the last term collapses to I. So the whole thing is a quadratic form in one very simple matrix:

|e|² = t(2I − R − R) t

Now specialise to a yaw. For R = Rz(ψ), the off-diagonal ±sinψ terms cancel when you add R to its own transpose, leaving R + R = diag(2cosψ, 2cosψ, 2). Subtract that from 2I:

2I − R − R = diag(2 − 2cosψ,  2 − 2cosψ,  0)

Look at that third entry. It is exactly zero, so the z component of t is multiplied by nothing and drops out of the answer entirely. That is why the 42 cm of camera height never appears in the 4.14 cm — not because it is small, but because a rotation about z cannot move a point along z, so the height contributes structurally nothing. The quadratic form therefore keeps only the perpendicular part, t = (tx, ty):

|e|² = (2 − 2cosψ)(tx² + ty²) = 2(1 − cosψ) · |t

One identity left. The half-angle formula says 1 − cosψ = 2sin²(ψ/2), so 2(1 − cosψ) = 4sin²(ψ/2). Take the square root, and because a length is non-negative the ψ comes out under an absolute value:

|e| = |(I − R)t| = 2 sin(|ψ| / 2) · |t|

Where the 2 and the half-angle come from, geometrically. Rotating t by ψ sweeps its perpendicular part around a circle of radius |t| through an arc of angle ψ. The error vector is the straight line joining start to finish — the chord subtending that arc. Drop a perpendicular from the circle's centre to the chord and it bisects both the chord and the angle, giving two right triangles with angle ψ/2 and opposite side |e|/2. Hence |e|/2 = |t| sin(ψ/2), which is the formula. The 2 is "two half-chords"; the half-angle is "the bisected arc". Say it that way and it stops being something you can misremember.

Check it against the hand calculation: 2 × sin(15°) × 0.080 = 2 × 0.25882 × 0.080 = 0.041411 m. Same answer as the full matrix product, one line, no matrix.

Worked example 2 — the whole error curve, from the chord formula. The same one-liner gives every angle at once, so you can predict what the technician will report before they measure it:

  ψ = 10°: 2 sin(5°) × 0.080 = 2 × 0.087156 × 0.080 = 0.013945 m = 1.39 cm
  ψ = 30°: 2 sin(15°) × 0.080 = 2 × 0.258819 × 0.080 = 0.041411 m = 4.14 cm
  ψ = 60°: 2 sin(30°) × 0.080 = 2 × 0.500000 × 0.080 = 0.080000 m = 8.00 cm
  ψ = 90°: 2 sin(45°) × 0.080 = 2 × 0.707107 × 0.080 = 0.113137 m = 11.31 cm

Note that the growth is not linear — it is a sine of the half-angle, so it curves. If the field data plots as a straight line through the origin instead, you have a different bug (a scale error on the yaw itself). The shape of the curve is evidence.
The 4 cm, live

Top-down view of the pick cell. The teal gripper is where the arm actually goes; the orange ghost is where the buggy code thinks it is going. Drag the torso yaw and watch the gap open. At zero degrees the two are identical — which is exactly why this shipped.

torso yaw −30°
mount offset 0.080 m
 

Five things to actually do with that widget, because a slider you only wiggle teaches nothing. Every one of them is an observation you could also get from the chord formula on paper — which is the point. The widget is not showing you a fact you do not have; it is letting you check yourself against a picture, and that is the only route by which a formula becomes reflex fast enough to use while someone is watching you. (The view zooms out slightly as you push the mount offset, so that the full ±90° sweep stays on the canvas at every setting. Compare the red number in centimetres, not the gap in pixels.)

(1) Set the yaw to 0°. The two chains collapse onto each other exactly: the dashed orange line disappears underneath the solid teal one, the two gripper markers become concentric rings, the red leader line and its number vanish entirely, and the readout under the canvas reads 0.00 cm and volunteers the sentence about the bench test. That is the passing bench test, rendered. It is worth twenty seconds of staring at, because this is the picture the original author had in their head when they wrote the unit test, ran it, watched it go green, and shipped.

(2) Drag from −30° to +30° and watch the red number. It does not change — 4.14 cm at both — while the entire picture mirrors about the torso's forward axis: at −30° the chain swings below the axis and the orange gripper sits above the teal one; at +30° the chain swings above and the orange gripper sits below it. Look closer and there is a second thing to see. In both cases the orange gripper also sits a hair further out along base_link's forward axis — straight to the right on screen, and note that this is the world's forward direction, not the turned torso's — and that forward part is the same +1.07 cm at both angles, refusing to flip while the lateral 4.00 cm flips cleanly. That is the odd part and the even part of the error, side by side, exactly as Worked Example 3 below separates them algebraically. The magnitude is even in ψ, the lateral direction is odd, and the widget is showing you both facts in one frame. It is the trap in the sign-flip test made visible, and it is why the instruction you hand the technician has to ask for a side and not for a number.

(3) Push the mount offset from 0.080 m to 0.30 m at a fixed −30°. The readout tracks 4.14 cm → 15.53 cm, and 15.53 / 4.14 = 3.75 once you allow for the two-decimal rounding on screen — which is exactly 0.30 / 0.080. The error scales linearly in the offset to the last digit, because |t| is a plain multiplier in the chord formula and nothing else in the expression moved. Doubling how far the camera sits from the pivot doubles the miss — the sentence that kills the "mount it further out for a better view" proposal, and the same arithmetic run backwards in PRACTICE question 2 below.

(4) Take the yaw to ±90° and read the shape rather than the number. The teal chain now points straight up the screen while the dashed chain runs out along the stale un-rotated offset and only then turns up, so the red segment joining the two grippers is the diagonal of a square whose sides are both |t|. That means |e| = √2 |t| with no trigonometry at all: 1.4142 × 0.080 = 11.31 cm at the default offset, and 1.4142 × 0.30 = 42.43 cm at the far end of the slider. The chord formula has to agree, and it does, because 2 sin(45°) is √2. A right angle is the one state where the widget can be checked against a square root you already know cold, which makes it the state to visit whenever you suspect you are misreading the picture.

(5) Drag the mount offset down to 0.000 m, then sweep the yaw through its whole range. The readout holds at 0.00 cm at every single angle, and the dashed chain stays welded to the solid one all the way round. A camera sitting exactly on the axis of rotation gives the missing R nothing to act on: (I − R)t vanishes when t is zero, and — the part that matters in three dimensions — it vanishes just as completely for any t lying along the rotation axis, which is the same structural fact that deleted the 42 cm of camera height from Worked Example 1. This is the widget's degenerate check, and it is a sobering one: here is a bug that is real, present in the source, executed on every single call, and utterly invisible because of where somebody put a bolt.

What the picture is, geometrically. Hold the offset fixed and sweep the yaw slowly, watching the two small dots near the pivot rather than the grippers. The teal one — the camera origin at R t — traces a circular arc about the pivot. The orange one never moves at all: it is pinned at the raw un-rotated offset t, because a translation that was never multiplied by R cannot know the torso turned. The red segment is therefore the straight line from the start of that arc to its current end — the chord — which is exactly the object we bisected four paragraphs ago to derive |e| = 2 sin(|ψ|/2)·|t|. You are not looking at an illustration that resembles the formula. You are looking at the formula's construction, drawn to scale.

And one thing in the picture that is easy to walk straight past. The gap between the two gripper markers is exactly the same length as the gap between the two small camera dots beside the pivot — hold a thumbnail against the screen at any slider setting and they match. That is not a coincidence and it is not an artefact of the drawing: the arm segment is common to both chains, its rotation composes correctly in both (the bug is in a translation, and the rotations were never in doubt), so the arm carries the error downstream rigidly without amplifying it by a single millimetre. An un-rotated child translation produces a constant offset everywhere below it in the tree; it does not grow with reach. Which hands you a second discriminator for free, and one you can use before anybody touches a keyboard: if the report is that the miss gets worse the further the arm extends, this is not (I − R)t at all. It is an extrinsic rotation error, whose 3D miss is roughly δθ × range and therefore grows with distance — the "reach error proportional to distance" row of the taxonomy table below. Same 4 cm at one range, entirely different bug, and one question tells them apart.

DEBUG — the tell

Step 5 is the part that turns a hypothesis into a diagnosis in one command. Every frame bug has a signature — a way the error behaves that no other bug reproduces. For this one:

Turn the torso the other way. If the miss flips to the other side by the same amount, it is (I − R)t. The error vector is linear in t and antisymmetric in ψ to first order: e ≈ ψ(ty, −tx, 0). Flip the sign of ψ and the whole vector flips. A mechanical offset would not flip. A detector bias would not flip. A tool-centre-point error would not flip. Ninety seconds of experiment, and you are down to one candidate.

That claim has to be earned, not asserted — it is the sentence the entire diagnosis rests on, so here is where e ≈ ψ(ty, −tx, 0) comes from. For a small yaw, expand the rotation to first order. With cosψ → 1 and sinψ → ψ, Rz(ψ) becomes the identity plus ψ times a fixed matrix:

Rz(ψ) ≈ I + ψK,    K = [[0, −1, 0], [1, 0, 0], [0, 0, 0]]

K is the generator of rotation about z — the skew-symmetric matrix that says "an infinitesimal yaw pushes x toward y and y away from x". Substituting into the error definition, the identities cancel and one clean term survives:

e = (I − R)t ≈ (I − I − ψK)t = −ψKt

And Kt is immediate from the matrix: row one gives −ty, row two gives tx, row three gives 0. So Kt = (−ty, tx, 0), and negating it:

e ≈ −ψ(−ty, tx, 0) = ψ(ty, −tx, 0)

Every symbol on the right is either ψ or a constant of the mount. ψ appears exactly once, to the first power. That is what "antisymmetric" means here and that is why the sign flip is a real discriminator: negate ψ and every component of e negates with it. Nothing else on the fault list has that property, because nothing else on the fault list has ψ in it at all.

Sanity-check the approximation against the exact number we already have. At ψ = −30° = −0.5236 rad with t = (0.080, 0.000, 0.420), the linear prediction is −0.5236 × (0.000, −0.080, 0) = (0, +0.04189, 0), i.e. 4.19 cm, against the exact 4.14 cm. That is a 1.2% overestimate — close enough to be the number you say out loud at the robot, and a useful marker of where the linearisation begins to fray. At 60° the same expansion predicts 8.38 cm against an exact 8.00 cm, a 4.7% error; by 90° it is 12.57 vs 11.31 cm, off by 11%. The first-order picture is a diagnostic tool, not a calibration.

Worked example 3 — running the sign-flip test properly, and the trap in it.

The test is "turn the torso the other way". So compute the exact error at ψ = +30° and compare it to ψ = −30°, component by component. At +30°, cos = 0.8660 and sin = +0.5000, so R = [[0.8660, −0.5000, 0], [0.5000, 0.8660, 0], [0, 0, 1]] and:

  R t = (0.8660 × 0.080,  0.5000 × 0.080,  0.420) = (0.06928, +0.04000, 0.42000)
  e(+30°) = t − R t = (+0.01072, −0.04000, 0.00000) m

Set that beside the −30° result from Worked Example 1:

  e(−30°) = (+0.01072, +0.04000, 0.00000) m

The lateral component flipped exactly: −4.00 cm becomes +4.00 cm. That is the tell, and it is clean.

The forward component did not flip at all: +1.07 cm at both angles. And that is not a mistake — it is the second-order term. From the exact form, ex = (1 − cosψ)tx, which is an even function of ψ, so it cannot flip; numerically (1 − 0.86603) × 0.080 = 0.01072 m either way, and its small-angle size is ½ψ²tx = 0.5 × 0.5236² × 0.080 = 0.01097 m, which is the same 1.1 cm. The exact ey = −sinψ tx is odd, which is the flip.

The trap: |e| = 0.04141 m at both angles. It has to be — the chord formula depends on |ψ|, so the magnitude is even in ψ no matter what the cause is. If the technician reports only "still about 4 cm", you have learned nothing. The experiment is only discriminating if the miss is recorded as a vector, or at minimum as "left of the bottle" versus "right of the bottle". Ask for the direction, or the ninety-second test costs you an afternoon and answers nothing.

So the refined instruction to hand the technician is: "turn the torso 30° the other way, and tell me which side of the bottle the gripper closes on." Left-then-right confirms (I − R)t. Left-then-left says the error does not care about the yaw direction, which points instead at something even in ψ — a mis-modelled link length, or a yaw magnitude scale error. And the residual 1 cm of forward miss that survives the flip is not noise; it is the second-order term you just predicted, and being able to say so before it is measured is the difference between explaining the data and being surprised by it.

Then you go find the line. It will be in a helper someone wrote at 11pm called world_pose_of(), and it will add two translations and multiply two rotations, and it will be four characters short of correct.

The chain the number actually travelled

To make the diagnosis concrete, here is the path a single bottle detection takes on this robot, with the shapes and the rates. Every arrow is a transform, and every transform is a place the bug could have lived.

head camera, 30 Hz
RGB (720, 1280, 3) uint8 + depth (720, 1280) uint16 mm, stamped at exposure mid-point
↓ detector, ~28 ms on-device
bottle centroid in camera_optical
(3,) float64 metres, z forward — measured 6 mm from laser ground truth
↓ static transform, optical → body convention (a fixed 90°–90° rotation)
centroid in head_camera
(3,) float64, x forward, y left, z up — still exact
↓ static transform, t = (0.080, 0.000, 0.420) — the bug is on this arrow
centroid in torso, then base_link
(3,) float64 — now 4.14 cm wrong, and nothing downstream can tell
↓ torso yaw from the joint encoder, 200 Hz
grasp pose in base_link
SE(3), a 4×4 float64 — handed to the IK solver
↓ IK, 7 joints, < 2 ms
joint targets
(7,) float64 radians — tracked to within 0.05°, which is why the controller looked innocent
Read that chain the way a seasoned debugger does. Perception was accurate to 6 mm. Control tracked to 0.05°. The total system error was 41 mm. When the end-to-end error is an order of magnitude larger than every component error, the fault is not in a component — it is in the composition between them. That sentence alone tells you where to start.

Three decisions inside that diagram, and why each was made

A boxes-and-arrows diagram is decoration until someone can defend the choices inside it. There are three in the chain above that a careful reviewer will pick at, and each one is worth millimetres.

Why the RGB is stamped at exposure mid-point, not at arrival. The timestamp is not metadata — it is the argument to the transform lookup, so it decides which pose of the robot the detection is attached to. The photons that formed this image landed during the exposure window, so the pose you want is the pose at the middle of that window. Stamping at arrival instead lumps in the driver, USB or GMSL transfer, and any queueing — call it roughly one frame period on a 30 Hz camera, so 33 ms. With the torso slewing at 60°/s that is 33 ms × 60°/s ≈ 2.0° of yaw error, and 2° on the 80 mm camera offset is another chord: 2 sin(1°) × 0.080 = 2.8 mm. Small next to the 41 mm we are chasing — and that is exactly the danger, because it is big enough to keep a grasp marginal after you fix the real bug, and it will be blamed on the gripper. The rule that follows: a timestamp is a claim about when the world was sampled, so stamp as close to the physics as the hardware allows, which is why cameras with a strobe output get hardware-triggered and stamped by the trigger rather than by ros::Time::now() in the driver callback. Lesson 2 makes this its whole subject.

Why the head-camera extrinsic is static and the torso yaw is not. A transform belongs on /tf_static when its value can only change by someone picking up a screwdriver — the camera is bolted to the torso, so torso → head_camera is one latched message published at start-up and never again, and a node that subscribes an hour later still receives it. base_link → torso is a revolute joint driven by a motor, so it is genuinely time-varying and must be republished on /tf at the encoder rate, 200 Hz, so consumers can interpolate to an arbitrary stamp. The cost difference is real: static costs one message for the life of the process, dynamic costs 200 msg/s per joint and a buffer deep enough to cover the worst detector latency (28 ms here, so a 10-second buffer is enormous headroom and still only ~2,000 samples). The failure mode to watch for is the inverse: publishing a genuinely dynamic transform on /tf_static. Nothing errors. Every consumer silently uses the boot-time value forever, and the symptom is an error that grows as the joint moves away from wherever it happened to be at start-up — which looks uncannily like the bug we just diagnosed.

Why the grasp pose is a 4×4 SE(3) at the IK boundary rather than a (quaternion, vector) pair. Not because 4×4 is better — it is 16 floats against 7, and its rotation block has 6 redundant degrees of freedom to drift instead of a quaternion's 1. The choice is about what the consumer does. The IK solver wants dense linear algebra: composing is one small GEMM, applying to a point is one matvec with no branch and no normalisation, and the Jacobian assembly reads columns straight out of the matrix. Handing it a quaternion just means it converts, so you have paid the conversion and lost the audit trail. Conversely, anything that is stored, sent on the wire, or integrated over thousands of steps — a pose in a message, a state in an estimator, a keyframe on disk — wants (quaternion, vector), because renormalising a quaternion is one division while re-orthonormalising a drifted 3×3 needs an SVD or Gram–Schmidt, and the wire cost is less than half. The defensible rule, and the one to say out loud: store and interpolate as (quaternion, vector); compute and hand off as 4×4; convert exactly once at each boundary and assert det(R) = 1 when you do. Chapter 3 turns this into the full comparison.

And when the inputs degrade. Suppose the 200 Hz encoder stream drops out for 100 ms — a CAN burst, a starved callback, a dropped DDS sample — and tf is configured to extrapolate rather than throw. Nothing logs. The lookup returns a pose confidently built from the last sample it has, and with the torso still slewing at 60°/s that pose is stale by 100 ms × 60°/s = , worth another 2 sin(3°) × 0.080 = 8.4 mm of position error on the same 80 mm offset. Three things now break at once. The 4.14 cm stops being a constant and becomes configuration- and time-dependent, so the technician's numbers scatter and the neat curve in Worked Example 2 turns into a cloud. The prediction you made before opening the editor no longer matches, and the natural — wrong — conclusion is that the hypothesis was bad. Worst of all, the sign-flip test stops being clean: two error sources are superposing, and the staleness term depends on the direction and speed of the slew rather than on the final yaw, so turning the other way changes both terms and the flip comes back partial. The discriminator: hold the torso still for one second, then command the grasp. The staleness term is proportional to joint velocity, so at rest it vanishes; the algebra term does not care about velocity at all and stays exactly where it was. If the miss shrinks when you pause, you have two bugs, and the timing one must be fixed first or it will corrupt every measurement you take of the other.

DESIGN — what the tf tree actually costs

"Where does the tf tree live and what does it cost?" is the DESIGN form of this topic, and the answer has to be in numbers or it is not an answer. Take this humanoid: 29 actuated joints (2 × 6 leg, 2 × 7 arm, 1 torso, 2 head), each publishing a TransformStamped at the 200 Hz encoder rate, plus a handful of static sensor mounts published once.

QuantityNumberWhere it comes from
Bytes per transform on the wire~100 B7 float64 (3 translation + 4 quaternion) = 56 B, plus stamp and two frame-id strings. The strings are a third of it — which is the real reason teams keep frame names short.
Dynamic tf bandwidth29 × 100 B × 200 Hz = 0.58 MB/s (4.6 Mbit/s)Every dynamic joint, every tick.
The RGB stream beside it720 × 1280 × 3 × 30 = 82.9 MB/s rawtf is 0.7% of one uncompressed camera. Budget accordingly: tf is never your bandwidth problem, and throttling it to "save bandwidth" is a false economy that buys 0.6 MB/s and costs you accuracy.
Buffer memory at the 10 s default29 × 200 × 10 ≈ 58,000 entries ≈ 6 MBAlso nothing. So the reason to shorten a buffer is never memory — it is that a 10-second window lets a bug silently look up a pose from 9 seconds ago and return it with a straight face.
Sensor-to-command latency28 ms detector + ~0.1 ms lookup/compose + <2 ms IK ≈ 31 msThe transform lookup is three orders of magnitude cheaper than the detector. Optimising it is wasted effort; getting its timestamp right is everything.

Now defend the 200 Hz. It is not a round number someone liked — it sets how badly the buffer can be wrong when it interpolates. Worst case is half a sample period, and at full slew (60°/s) that is:

200 Hz → 2.5 ms → 0.15° → 0.21 mm   |   20 Hz → 25 ms → 1.5° → 2.1 mm   |   5 Hz → 100 ms → 6° → 8.4 mm

Read that against the 6 mm perception error we measured. At 200 Hz the interpolation residual is 0.2 mm — a thirtieth of the perception budget, i.e. free. At 20 Hz it is 2.1 mm, now a visible third of it. At 5 Hz it is 8.4 mm and the transform tree has quietly become the largest error source in the system, larger than the sensor it exists to serve. That is how you justify a publish rate: convert the rate into millimetres at the tool, and compare it to the error budget of the thing it feeds. Every number in that chain is one sine and one multiply.

The two-day versionThe twenty-minute version
First moveList every subsystem and start measuring themAsk what the error is a function of
Search spaceAll of them — each measurement clears oneRotation-dependent faults only — one observation clears the rest
Evidence usedAbsolute magnitudes ("4 cm is a lot")The shape of the dependence (zero at 0°, curved, sign-flipping)
Prediction madeNone — measurement is the only tool4.14 cm at 30°, before touching the code
Confirming testRe-run and hopeTurn the other way; the miss must flip sides
Cost3 engineers × 2 days1 engineer × 1 coffee

Strip the bottle and the torso out of that and what is left is a method, and the method is the transferable part. It is five moves, and none of them is "guess":

  1. Turn the symptom into a function. Not "4 cm" but "zero here, 4 cm there". A single number is one data point; a dependence is a shape, and shapes eliminate whole classes of cause at once.
  2. Find every quantity that the offending variable multiplies. Here the yaw touched exactly one arrow in the chain, which is why the search collapsed from "the whole stack" to "one line".
  3. Write the candidate line and its plausible corruption side by side. tAC = R tBC + tAB against tAC = tBC + tAB. If you cannot write the correct line from memory you cannot write its corruption either, which is the real reason the derivation earlier in this chapter matters.
  4. Predict the magnitude before opening an editor. 4.14 cm. A prediction that matches converts a hypothesis into a near-certainty; a prediction that misses is worth even more, because it tells you the model is wrong while it is still cheap to say so.
  5. Name the one experiment that discriminates, and state what each outcome would mean. Flip the yaw: lateral component flips ⇒ (I − R)t; does not flip ⇒ something even in ψ; magnitude changes with range instead ⇒ an extrinsic rotation error. An experiment whose outcomes you have not pre-assigned meanings to is not an experiment, it is a hope.

That sequence works on drift, on latency, on a controller that oscillates, on a detector that degrades in the rain. It is the reason this chapter spent its length on one 4 cm miss instead of surveying ten bugs: the survey is the taxonomy table below, and it is only useful to someone who already owns the method.

Why this chapter is chapter one of twenty-six

Every robot is a tree of coordinate frames. The world, the odometry origin, the chassis, the torso, each sensor, each joint, each tool. Every number that flows through the system — a LiDAR return, a camera detection, a wheel tick, an IMU sample, a planned waypoint, a joint command — is meaningless until you say which frame it is expressed in, and dangerous the moment two numbers in different frames get added together.

Frames come first in this series for three reasons:

What separates the levels. A junior engineer can write down a homogeneous transform. A senior engineer can compose and invert them without hesitating about the order. A staff engineer does what we just did: reads the symptom as a function of a rotation, predicts the magnitude before looking at the code, and names the one experiment that discriminates. That is the skill this lesson builds.

How each chapter is built

Every numbered chapter here carries five layers, because those are the five questions a working robotics engineer ends up answering about every subsystem:

LayerThe question it answersWhat it really tests
CONCEPT"Derive the composition rule."Can you rebuild it, or only recall it?
DESIGN"Where does the tf tree live and what does it cost?"Do you know the rates, sizes and latencies?
CODE"Implement SE(3) inverse. No library."Can your hands do what your mouth claims?
DEBUG"The arm misses by 4 cm. Go."Do you know the failure modes or just the happy path?
FRONTIER"Why would anyone use a Lie-group library?"Are you current, and can you defend a tradeoff?

Here is the whole taxonomy of frame bugs we will build over the next six chapters. Each one has a distinct signature, and by the Field Guide chapter you should be able to name the discriminating observation for every row without looking:

BugSymptomSignature that identifies it
Unrotated child offset, (I−R)tPosition error that vanishes when the parent is un-rotatedFlips sign when the parent rotates the other way; grows as 2 sin(ψ/2)|t|
Reversed composition orderLarge, structured error even at small anglesError is zero iff the two rotations commute (same axis)
Wrong inverse (−t instead of −Rt)Round trip A→B→A does not return to startT · T−1 ≠ I; residual is exactly (I − R)t
Quaternion order (w,x,y,z) vs (x,y,z,w)Wildly wrong orientation, often near 180°Norm is still 1, so no sanity check fires; the scalar part is tiny when it should be near 1
Flipped quaternion sign across a message boundarySudden 360° whip in an interpolated posedot(qk, qk+1) < 0 between consecutive samples
Gimbal lock in an Euler pipelineYaw goes wild near vertical pitch; rates saturate|pitch| → 90°, and the Euler-rate Jacobian determinant cos(pitch) → 0
Optical vs body frame confusionAxes permuted: forward reads as down, left as forwardErrors are exact axis swaps, not small numbers — a 90° multiple
Left-handed frame from a CAD exportMirror-image scene; det(R) = −1det(R) = −1 and RR = I still holds — orthogonal but not a rotation
map vs odom confusion (REP-105)Controller jerks every time the localizer correctsDiscontinuity in the consumed pose coincides with loop-closure events
Extrinsic rotation errorReach error proportional to distanceResidual field is a constant pixel shift, and 3D error scales with range
Intrinsic focal errorReach error proportional to distance from image centreResidual grows radially from the principal point, zero at the centre

FRONTIER — so why would anyone use a Lie-group library?

The dimensions table above promises this question, so here is the answer in the form it should be given: not as a library recommendation, but as a statement about which mistakes become impossible.

The reference everyone in this corner of robotics cites is Solà, Deray & Atchuthan, "A micro Lie theory for state estimation in robotics" (arXiv:1812.01537, 2018) — a deliberately short paper that gives you the four operations an estimator actually needs (exp, log, and the retraction pair ⊞/⊟) plus the Jacobian conventions, without the differential-geometry apparatus. It is the paper the library READMEs point at, and naming it signals you have read the thing rather than the wrapper. The two libraries themselves: Sophus (Strasdat, 2011–), the header-only C++ that grew up alongside Ceres and is still the default in most SLAM codebases, and manif (Deray & Solà, JOSS, 2020), which is the micro-Lie-theory paper turned into an API where the analytic Jacobians are first-class return values rather than something you derive in a notebook.

The canonical case where naive bookkeeping breaks is Forster, Carlone, Dellaert & Scaramuzza, "On-Manifold Preintegration for Real-Time Visual-Inertial Odometry" (IEEE Transactions on Robotics, 2017). Hundreds of IMU increments have to be folded into one relative motion constraint, and the optimiser keeps changing its estimate of the state those increments were composed from. Do it with Euler angles or raw matrices and you either recompose the entire chain on every iteration, or you accumulate a rotation that has quietly stopped being orthonormal. Do it on the manifold and the increments compose once, in a frame independent of the state being estimated, with a first-order correction Jacobian carried alongside. That is a composition-order insight worth a 10–50× speedup, and it is in essentially every VIO system shipped since.

Which sets up the sentence to end on: you use a Lie-group library because it makes the ⊞/⊟ retraction and the Jacobians the optimiser needs impossible to get wrong — not because matrix multiply is hard. Everything in this chapter was arithmetic a first-year could do; the bug still cost three engineers two days. Scale that to a factor graph where every residual needs a 6×6 Jacobian with a sign convention attached, and "it is only algebra" stops being a defence.

Bridge — where the theory lives
This lesson does not re-teach rigid-body geometry. It puts it to work.
If you have never derived a rotation matrix, or you want the group theory behind SE(3), the site already has that and it is better than a compressed retelling here:
Foundations
AA274A — Rigid-Body Transforms SE(2)/SE(3) builds the rotation matrix and the homogeneous transform from scratch, with the unicycle and differential-drive models on top.
3D geometry
VNAV 01 — Intro to 3D Geometry walks the four rotation representations and their conversions in full, with a transform composer.
Lie theory
VNAV 02 — Lie Groups derives the hat map, Rodrigues' formula, the logarithm, twists, and the adjoint — the machinery behind on-manifold optimisation.
This lesson
Spends its words on: deriving under time pressure, the tf tree as a system with rates and budgets, the failure taxonomy above, and defending a representation choice out loud.
If a chapter here feels like it is restating one of those, read the DESIGN and DEBUG sections — that is where this lesson lives.

The role, concretely

You are a robotics software engineer on a humanoid manipulation team. The frame layer is yours whether or not it is in your job description, because it is the layer everyone else's bugs land in. A representative week:

None of those days involved a new algorithm. All five were frames. This is the job, and it is why the series starts here.

What good looks like in practice. When someone hands you a broken robot, do not start guessing causes. Start by asking what the error is a function of: does it depend on the configuration, the range, the speed, the time since start, the direction of travel? Each dependency eliminates a whole class. That question — "what is the error a function of?" — is worth more than any formula in this lesson.

PRACTICE — sixty seconds, no calculator

The chord formula |e| = 2 sin(|ψ|/2)·|t| is the one piece of arithmetic from this chapter you should be able to run in your head, to one significant figure, while someone is talking. Cover the answers and try all three before reading on.

  1. The camera sits 12 cm off the pivot and the torso is at 45°. How far does the arm miss?
    Half-angle 22.5°, sin ≈ 0.38, so 2 × 0.38 × 0.12 = 0.092 m — call it 9 cm. The shortcut for the room: below about 60°, 2 sin(ψ/2) ≈ ψ in radians, and 45° is 0.785 rad, so 0.785 × 0.12 = 0.094. Within 2%, and it is one multiply.
  2. Someone proposes fixing this in hardware: "just mount the camera closer to the pivot." How close would it have to be to hold the miss under 5 mm at 30° of yaw?
    Invert the formula: |t| = 0.005 / (2 sin 15°) = 0.005 / 0.518 = 0.0097 m, i.e. within 1 cm of the pivot axis. Which is a way of saying you cannot fix this mechanically — no head camera lives 1 cm from the torso axis. The answer is a good one to have ready, because "can we design around it?" is a real question a hardware lead will ask, and being able to kill it in ten seconds with a number is worth more than an opinion.
  3. The torso yaws at 90°/s, the transform you looked up is 50 ms stale, and the offset is the usual 8 cm. How much error does the staleness alone contribute?
    90 × 0.05 = 4.5° of missed yaw; 2 sin(2.25°) × 0.080 = 2 × 0.0393 × 0.080 = 6.3 mm. Note that this lands right on top of the 6 mm perception error, which is precisely why timing bugs get misattributed to perception. Same formula, different cause — and the discriminator is still velocity, as above.
The pattern all three share. Every one is "angle in radians × lever arm", because 2 sin(ψ/2) ≈ ψ for anything under about a radian. Carry that single approximation and you can convert any angular error anywhere in a kinematic chain into millimetres at the tool, in your head, on the spot: Δ(mm) ≈ Δθ(rad) × lever(mm). One degree is 17 milliradians, so one degree of error at a one-metre lever is 17 mm — commit that one number and you can do the rest by scaling.
An arm's grasp error is zero when the torso is square and grows to 4 cm at 30° of torso yaw. Before you look at any code, which single follow-up measurement best narrows the cause?

Chapter 1: SE(3) and SO(3) — Rebuilding the 4×4 From Two Requirements

"Where does the homogeneous transform come from?" is the right first question for this whole subject, and it is not a memory question. The goal is to produce the 4×4 as a consequence of two requirements — not to draw it from memory and then be unable to say why the bottom row is what it is.

Here is the derivation, built so that every step can defend itself.

CONCEPT — two requirements, one matrix

Requirement one: rigid means distances do not change. A rigid body is a set of points whose mutual distances are fixed. So whatever matrix R represents a rotation, it must preserve the length of every vector:

‖Rv‖ = ‖v‖   for every v

Square both sides and write the norm as a dot product: (Rv)(Rv) = vv, so v(RR)v = vv for every v. Call M = RR. The claim is that M must be the identity. This is the step most write-ups hand-wave — "same quadratic form, therefore same matrix" — and it is exactly the step worth doing properly. Do it in two moves.

Move 1 — M is symmetric. Transpose it, using (AB) = BA and (A) = A:

M = (RR) = R(R) = RR = M

Symmetry is not decoration here; it is what makes the whole argument legal. A quadratic form vMv only ever sees the symmetric part of M, because any antisymmetric part contributes vAv = 0 for every v. So if M could carry an antisymmetric component, knowing the form everywhere would not pin the matrix down and the argument would prove nothing. M is symmetric, so the form determines it completely.

Move 2 — read the entries straight off the form. We know vMv = vv for every v, so choose vs that expose one entry at a time.

Every diagonal entry is 1 and every off-diagonal entry is 0. That is the identity matrix, recovered entry by entry from the constraint — not asserted, and not borrowed from a theorem you would have to name under pressure:

RR = I  ⇒  R−1 = R

That is the definition of an orthogonal matrix, and the set of them is the orthogonal group O(3). You just got the inverse for free — a transpose, no division, no solve. That is the practical payoff of the constraint.

Requirement two: no mirrors. Take determinants of RR = I: det(R)det(R) = 1, and det(R) = det(R), so det(R)² = 1 and det(R) = ±1. The −1 branch is a reflection — it preserves lengths but turns a right hand into a left hand. A physical rigid body cannot do that. Throwing that branch away leaves the special orthogonal group SO(3):

SO(3) = { R ∈ ℝ3×3 : RR = I, det R = +1 }
This is a real bug, not a footnote. A CAD tool exporting a left-handed frame, or a calibration solver run without the det = +1 constraint, hands you an orthogonal matrix with det = −1. It passes every "is it a rotation?" check that only tests RR = I. The scene comes out mirrored: the robot reaches left when told right, and every quaternion conversion downstream produces garbage. Always check det(R), not just orthogonality.

How many numbers is a rotation really? Count: R has 9 entries. The constraint RR = I is a 3×3 matrix equation, but RR is symmetric, so it only carries 6 independent scalar equations — three saying each column is a unit vector, three saying each pair of columns is perpendicular. So 9 − 6 = 3 degrees of freedom. Every rotation representation is an attempt to package those 3 numbers, and Chapter 3 is about the fact that none of them does it perfectly.

Do not say "six" without being able to list them. The (i, j) entry of RR is exactly ci·cj, the dot product of columns i and j of R. Nine entries, but the matrix is symmetric, so the lower triangle repeats the upper: only the six on-or-above the diagonal are independent. Here they are, each mapped to the column pair it constrains:

Entry of RR = column pairScalar equation in the entries of RWhat it constrains
(1,1) = c1·c1r11² + r21² + r31² = 1image of x̂ has unit length — no stretch along x
(2,2) = c2·c2r12² + r22² + r32² = 1image of ŷ has unit length
(3,3) = c3·c3r13² + r23² + r33² = 1image of ẑ has unit length
(1,2) = (2,1) = c1·c2r11r12 + r21r22 + r31r32 = 0x̂ and ŷ stay perpendicular — no shear in xy
(1,3) = (3,1) = c1·c3r11r13 + r21r23 + r31r33 = 0x̂ and ẑ stay perpendicular
(2,3) = (3,2) = c2·c3r12r13 + r22r23 + r32r33 = 0ŷ and ẑ stay perpendicular

Three "no stretch" equations, three "no shear" equations. Together they say the transform carries an orthonormal triad to another orthonormal triad, which is what "rigid" means in coordinates. The three redundant equations — (2,1), (3,1), (3,2) — are not extra information; they are the same three perpendicularity statements written a second time, which is precisely why the count is 6 and not 9. Get that count wrong and your DOF answer comes out as 0 (if you say 9 constraints) or 6 (if you forget the constraints entirely), and both are visibly absurd for a rotation.

Now translation. Rotation is linear, so a matrix handles it. Translation is not: p → p + t sends 0 to t, and every linear map sends 0 to 0. You cannot write a translation as a 3×3 matrix. Two options: carry the pair (R, t) around and remember the rule, or find a bigger space where translation is linear.

The trick is homogeneous coordinates: embed the 3D point p = (x, y, z) as the 4-vector (x, y, z, 1). Now look at what this block matrix does:

[[R, t], [0, 1]] · [p; 1] = [Rp + t; 1]

The rotation and the translation both happen, in one matrix multiply, and the fourth component comes out as 1 again — so the result is a valid homogeneous point and you can multiply again. That last fact is the whole reason for the bottom row.

Why the bottom row is [0 0 0 1] — the full answer. Two reasons, and the complete explanation gives both. (1) Closure: the fourth output component must be 1 so the result can be fed into the next transform; a bottom row of [0 0 0 1] guarantees it for every input. (2) Affine, not projective: a general bottom row [v, w] with v ≠ 0 makes the fourth component depend on p, and dividing through by it is a perspective divide. That is exactly what a camera projection matrix does. So SE(3) is the affine subgroup of the projective transforms — the ones with no perspective. Robots move; cameras project. Same 4×4 machinery, different bottom row.

Put it together. The special Euclidean group SE(3) is the set of those block matrices, and it is a group: closed under multiplication, has an identity, every element has an inverse. Six degrees of freedom — three of rotation, three of translation.

TA←B = [[RAB, tAB], [0 0 0, 1]]  —  "the pose of frame B, expressed in frame A"
Notation discipline is a survival skill. Write TA←B, never bare T. Read it two ways, both correct and both useful: it is the pose of B as seen from A, and it is the operator that takes coordinates in B and returns coordinates in A. When you compose, adjacent subscripts must cancel: TA←BTB←C = TA←C. If they do not cancel, you have written a bug. Engineers who use this notation do not make composition-order errors; engineers who write T1 T2 do.
Worked example 1 — a point through a transform, entirely by hand.

The robot base sits at (2, 1, 0) in the world, yawed 30°. A shelf corner is at p = (0.5, 0.2, 0) in the base frame. Where is it in the world?

Build R = Rz(30°) with cos 30° = 0.86603, sin 30° = 0.50000:
R = [[0.86603, −0.50000, 0], [0.50000, 0.86603, 0], [0, 0, 1]]
Rp, one row at a time:
  x: 0.86603 × 0.5 + (−0.50000) × 0.2 + 0 × 0 = 0.43301 − 0.10000 = 0.33301
  y: 0.50000 × 0.5 + 0.86603 × 0.2 + 0 × 0 = 0.25000 + 0.17321 = 0.42321
  z: 0 × 0.5 + 0 × 0.2 + 1 × 0 = 0.00000

Add t: (0.33301 + 2, 0.42321 + 1, 0 + 0) = (2.33301, 1.42321, 0.00000) in world metres.

And the homogeneous version does the same thing: the fourth row of T is [0, 0, 0, 1], so the fourth output is 0×0.5 + 0×0.2 + 0×0 + 1×1 = 1. Still a point, still composable.

Sanity check without redoing the arithmetic: the shelf corner is √(0.5² + 0.2²) = 0.53852 m from the base origin. In the world it should still be 0.53852 m from (2, 1, 0): √(0.33301² + 0.42321²) = √(0.11090 + 0.17911) = √0.29001 = 0.53852 ✓. Rigid means distances are preserved — that is a two-second check you can do out loud.
Worked example 2 — verify R is really in SO(3), and compose two transforms.

(a) Orthogonality, by hand — all six constraints, not two. The columns of Rz(30°) are c1 = (0.86603, 0.50000, 0), c2 = (−0.50000, 0.86603, 0), c3 = (0, 0, 1). The table above says there are exactly six independent scalar equations, so compute exactly six — checking two and waving at the rest is the thing that lets a broken matrix through.
Three unit-length conditions:
  1. c1·c1 = 0.86603² + 0.50000² + 0² = 0.75000 + 0.25000 + 0.00000 = 1.00000
  2. c2·c2 = (−0.50000)² + 0.86603² + 0² = 0.25000 + 0.75000 + 0.00000 = 1.00000
  3. c3·c3 = 0² + 0² + 1² = 0.00000 + 0.00000 + 1.00000 = 1.00000
Three perpendicularity conditions:
  4. c1·c2 = 0.86603×(−0.50000) + 0.50000×0.86603 + 0×0 = −0.43301 + 0.43301 + 0.00000 = 0.00000
  5. c1·c3 = 0.86603×0 + 0.50000×0 + 0×1 = 0.00000 + 0.00000 + 0.00000 = 0.00000
  6. c2·c3 = (−0.50000)×0 + 0.86603×0 + 0×1 = 0.00000 + 0.00000 + 0.00000 = 0.00000
6 of 6 constraints satisfied — and 9 entries minus 6 constraints is the 3 DOF from above, now confirmed on a concrete matrix rather than asserted.

The determinant, with the expansion written out. Do not quote a 2×2 shortcut for a 3×3 object; say where it comes from. Expand along the third row, because it has two zeros and each zero kills a term:
det R = r31C31 + r32C32 + r33C33 = 0×C31 + 0×C32 + 1×C33 = C33
C33 is the signed cofactor for position (3,3): delete row 3 and column 3, take the determinant of what is left, and multiply by (−1)3+3 = +1. What is left is the top-left 2×2 block:
  C33 = +det [[0.86603, −0.50000], [0.50000, 0.86603]] = 0.86603×0.86603 − (−0.50000)×0.50000 = 0.75000 − (−0.25000) = 0.75000 + 0.25000 = +1.00000
So det R = +1.00000 ✓ — a rotation, not a reflection. Notice this is the sin² + cos² = 1 identity wearing a different hat, which is why every Rz(θ) has determinant exactly +1 and you never have to redo this for a different angle.

(b) Compose. Put a gripper on that base: Tbase←grip has Rz(45°) and t = (0.5, 0, 0). Then Tworld←grip = Tworld←base Tbase←grip:
  Rotation — all nine entries, by hand. This is the arithmetic the whole lesson turns on, so do not skip to the rule. With cos 45° = sin 45° = 0.70711:
Rz(30°) = [[0.86603, −0.50000, 0], [0.50000, 0.86603, 0], [0, 0, 1]]    Rz(45°) = [[0.70711, −0.70711, 0], [0.70711, 0.70711, 0], [0, 0, 1]]
Entry (i, j) of the product is row i of the left matrix dotted with column j of the right matrix. Nine dot products:
  (1,1) = 0.86603×0.70711 + (−0.50000)×0.70711 + 0×0 = 0.61237 − 0.35355 = 0.25882
  (1,2) = 0.86603×(−0.70711) + (−0.50000)×0.70711 + 0×0 = −0.61237 − 0.35355 = −0.96593
  (1,3) = 0.86603×0 + (−0.50000)×0 + 0×1 = 0.00000
  (2,1) = 0.50000×0.70711 + 0.86603×0.70711 + 0×0 = 0.35355 + 0.61237 = 0.96593
  (2,2) = 0.50000×(−0.70711) + 0.86603×0.70711 + 0×0 = −0.35355 + 0.61237 = 0.25882
  (2,3) = 0.50000×0 + 0.86603×0 + 0×1 = 0.00000
  (3,1) = 0×0.70711 + 0×0.70711 + 1×0 = 0.00000
  (3,2) = 0×(−0.70711) + 0×0.70711 + 1×0 = 0.00000
  (3,3) = 0×0 + 0×0 + 1×1 = 1.00000
Rz(30°)Rz(45°) = [[0.25882, −0.96593, 0], [0.96593, 0.25882, 0], [0, 0, 1]]
Now read the answer. That matrix has the shape [[c, −s, 0], [s, c, 0], [0, 0, 1]] with c = 0.25882 and s = 0.96593 — and cos 75° = 0.25882, sin 75° = 0.96593. So the product is Rz(75°), and 30 + 45 = 75. The angle-addition rule is the observed consequence of the arithmetic, not the justification that replaced it: entry (1,1) computed out to cos30 cos45 − sin30 sin45, which is the cosine addition formula, and entry (2,1) computed out to sin30 cos45 + cos30 sin45, which is the sine addition formula. The trigonometric identities fell out of the matrix product. (This shortcut is available only because both rotations share the z axis, so their column-3 and row-3 structure never mixes. Compose two rotations about different axes and there is no angle to add — you do the nine dot products or you get it wrong.)
  Translation: t = Rworld,base tbase,grip + tworld,base
    Rz(30°)·(0.5, 0, 0) = (0.86603×0.5, 0.50000×0.5, 0) = (0.43301, 0.25000, 0)
    + (2, 1, 0) = (2.43301, 1.25000, 0.00000)

Notice the gripper's 0.5 m offset moved the base by 0.43301 m in world-x and 0.25000 m in world-y, not 0.5 m in world-x. The child's offset is rotated by the parent's rotation. That one line is the whole of Chapter 0's bug.
Worked example 3 — the real chain: build Tworld←camera out of the static tree and push a pixel ray through it.

Examples 1 and 2 were toy z-rotations. The transform you actually type on Monday is the one that gets a camera ray into the world, and it is where the optical-frame convention bites. Two static links, straight out of the URDF:

  base_link → head_mount: R = Rz(0°) = I (the mount is square to the torso), tbh = (0.05, 0, 0.62) m — 5 cm forward, 62 cm up.
  head_mount → camera_optical: thc = (0.02, 0, 0.04) m, and the REP-103-to-optical rotation
Rhc = [[0, 0, 1], [−1, 0, 0], [0, −1, 0]]
Step 0 — where that matrix comes from, so you can rebuild it rather than memorise it. A column of a rotation matrix is the image of a basis vector. REP-103 body axes are x forward, y left, z up; optical axes are x right, y down, z forward. So: optical x̂ (right) is body −ŷ ⇒ column 1 = (0, −1, 0). Optical ŷ (down) is body −ẑ ⇒ column 2 = (0, 0, −1). Optical ẑ (forward) is body +x̂ ⇒ column 3 = (1, 0, 0). Stack those three columns and you get Rhc above, every time, in about eight seconds at the whiteboard.

Step 1 — is it in SO(3)? Columns c1 = (0, −1, 0), c2 = (0, 0, −1), c3 = (1, 0, 0). Each has exactly one non-zero entry of magnitude 1, so all three unit-length conditions give 1.00000. Each pair has its non-zero entries in different slots, so all three dot products give 0.00000. Six of six. Determinant, expanding along row 1: det = 0×C11 + 0×C12 + 1×C13, and C13 = (−1)1+3 det [[−1, 0], [0, −1]] = (−1)×(−1) − 0×0 = +1.00000. A rotation. A sign slip anywhere in that matrix would leave it orthogonal with det = −1, and nothing downstream would complain.

Step 2 — collapse the two static links into Tbase←cam. Rotation: Rbc = RbhRhc = I·Rhc, and every entry of that product is 1×(the Rhc entry) + 0 + 0, so Rbc = Rhc unchanged. Translation, using t = Rbhthc + tbh:
  x: (1×0.02 + 0×0 + 0×0.04) + 0.05 = 0.02000 + 0.05000 = 0.07000
  y: (0×0.02 + 1×0 + 0×0.04) + 0.00 = 0.00000 + 0.00000 = 0.00000
  z: (0×0.02 + 0×0 + 1×0.04) + 0.62 = 0.04000 + 0.62000 = 0.66000
So the lens sits 7 cm forward of and 66 cm above base_link. This is the transform /tf_static publishes once and latches.

Step 3 — put the robot in the world. Reuse Tworld←base from example 1: Rz(30°), t = (2, 1, 0). Then Rwc = Rz(30°)Rhc, nine dot products of Rz(30°) rows against Rhc columns:
  (1,1) = 0.86603×0 + (−0.50000)×(−1) + 0×0 = 0 + 0.50000 + 0 = 0.50000
  (1,2) = 0.86603×0 + (−0.50000)×0 + 0×(−1) = 0.00000
  (1,3) = 0.86603×1 + (−0.50000)×0 + 0×0 = 0.86603
  (2,1) = 0.50000×0 + 0.86603×(−1) + 0×0 = −0.86603
  (2,2) = 0.50000×0 + 0.86603×0 + 0×(−1) = 0.00000
  (2,3) = 0.50000×1 + 0.86603×0 + 0×0 = 0.50000
  (3,1) = 0×0 + 0×(−1) + 1×0 = 0.00000
  (3,2) = 0×0 + 0×0 + 1×(−1) = −1.00000
  (3,3) = 0×1 + 0×0 + 1×0 = 0.00000
Translation, twc = Rz(30°)·(0.07, 0, 0.66) + (2, 1, 0):
  x: 0.86603×0.07 + (−0.50000)×0 + 0×0.66 = 0.06062; + 2 = 2.06062
  y: 0.50000×0.07 + 0.86603×0 + 0×0.66 = 0.03500; + 1 = 1.03500
  z: 0×0.07 + 0×0 + 1×0.66 = 0.66000; + 0 = 0.66000
Tworld←cam = [[0.50000, 0, 0.86603, 2.06062], [−0.86603, 0, 0.50000, 1.03500], [0, −1.00000, 0, 0.66000], [0, 0, 0, 1]]
Step 4 — push a pixel ray through it. A pixel at the principal point back-projects to the direction v = (0, 0, 1) in optical coordinates: straight out of the lens. A direction is a free vector, so it embeds as (0, 0, 1, 0) — w = 0, not 1. That zero is what makes the translation column drop out, and forgetting it is the second most common homogeneous-coordinates bug after composition order.
  Through Rhc alone, into head_mount:
    x = 0×0 + 0×0 + 1×1 = 1.00000   y = (−1)×0 + 0×0 + 0×1 = 0.00000   z = 0×0 + (−1)×0 + 0×1 = 0.00000
  That is (1, 0, 0) — body-frame forward. Exactly right: optical z is body x. The convention rotation earns its place in one line of arithmetic.
  Through Rwc, into the world:
    x = 0.50000×0 + 0.00000×0 + 0.86603×1 = 0.86603
    y = (−0.86603)×0 + 0.00000×0 + 0.50000×1 = 0.50000
    z = 0.00000×0 + (−1.00000)×0 + 0.00000×1 = 0.00000
The optical axis points along (0.86603, 0.50000, 0) = (cos 30°, sin 30°, 0) — horizontal, yawed 30°, which is precisely what a level head on a base yawed 30° should give. Its norm is √(0.75 + 0.25 + 0) = 1.00000, so the rotation preserved length, as it must.

Step 5 — a real detection. The depth image says a target is 3 m down that ray: pcam = (0, 0, 3), now a point, so w = 1 and the translation does come in.
  Rwc·(0, 0, 3) = 3×(0.86603, 0.50000, 0.00000) = (2.59809, 1.50000, 0.00000)
  + twc = (2.59809 + 2.06062, 1.50000 + 1.03500, 0.00000 + 0.66000) = (4.65871, 2.53500, 0.66000) m in the world.
Sanity check without redoing anything: the world z came out at 0.66000, identical to the camera height, because the optical axis is horizontal. If your answer had put the target above or below the lens, the head pitch would have to be non-zero — and it is not.

What it costs to omit Rhc. Drop the convention rotation (use identity) and the same detection lands at Rz(30°)·(0, 0, 3) + twc = (0, 0, 3) + (2.06062, 1.03500, 0.66000) = (2.06062, 1.03500, 3.66000): the robot believes the target is 3.66 m in the air, directly above its own head. The error vector is (2.59809, 1.50000, −3.00000), whose length is √(6.75 + 2.25 + 9) = √18 = 4.24264 m. One missing static rotation, no exception, no NaN, no failed check — a 4.2 metre lie delivered at 30 Hz.
SE(3) builder — watch the 4×4 fill in

Isometric view. The grey triad is the world frame; the coloured triad is the body. Drive the sliders and read the live 4×4 — the top-left 3×3 block is the rotation, the right column is the translation, and the bottom row never moves.

yaw (z)35°
pitch (y)−20°
tx0.60
tz0.35
 

DESIGN — the transform tree is a system, and it has a bill

Deriving the 4×4 gets you to the second question, which is where the engineering actually starts: where do these live on a real robot, and what do they cost?

On a ROS 2 humanoid, poses live in a tf tree: a directed tree of frames where every non-root frame has exactly one parent and one transform to it. Two channels feed it:

ChannelWhat goes on itRateWhy
/tf_staticBolted-down geometry: base_link→head_mount, head_mount→camera_optical, IMU mountPublished once, latchedIt never changes; re-sending it 200 times a second is pure waste, and latching means a late subscriber still gets it
/tfAnything that moves: every joint, odom→base_link, map→odomJoints 200 Hz, odom 100 Hz, map 5–20 HzConsumers interpolate between samples, so the rate sets your interpolation error

Size the buffer, out loud — and split the frame count first. The table above just said static frames are published once and latched, so they store one sample each, not 2,000. Multiplying all 40 frames by 200 Hz is the exact sloppiness this section is warning you about, and anyone who has actually sized a buffer will catch it. Take a humanoid with 40 frames, of which say 12 are static (both camera links, the IMU mount, the LiDAR mount, the bumper and tool frames) and 28 are dynamic (the joints, odom→base_link, map→odom), with the tf2 default 10-second buffer:

28 dynamic × 200 Hz × 10 s = 56,000  +  12 static × 1 = 12  ≈  56,000 stored transforms

Now the bytes, counted honestly. A stored entry is a translation (3 doubles) plus a quaternion (4 doubles) = 7 doubles = 56 bytes of pose, plus the timestamp, which is another 8 bytes — so 64 bytes of payload, not 56. Around that sits the deque node and the frame-id bookkeeping, which takes the real per-entry cost to roughly 100 bytes:

56,000 × 100 B = 5.6 MB per tf2_ros::Buffer

Ten nodes each running their own buffer is 56 MB, which is how a Jetson runs out of memory in a way nobody expected — and why the fix is a shared buffer or a shorter horizon (drop to 2 s and you are at 1.1 MB per node), not more RAM. Note also what the split bought you: the naive 40-frame figure overstates the buffer by 43%, which in a design review is the difference between "fits" and "does not".

Size the wire, out loud. Only the dynamic frames ride /tf, so one message carrying 28 TransformStamped entries is about 28 × 100 B = 2.8 kB. At 200 Hz that is 560 kB/s ≈ 4.5 Mbit/s of pure transform traffic, multiplied by every subscriber if the transport is not shared. On a 100 Mbit link that is 4.5% of the pipe doing nothing but bookkeeping — and it is why teams split the fast joint broadcaster from the slow odometry broadcaster and drop the rate of anything that does not need 200 Hz. The 12 static frames cost 12 × 100 B = 1.2 kB once, which is the entire argument for latching them.

Size the lookup, out loud. Asking for camera_optical → base_link at time t does three things: a binary search in each link's sorted sample deque (2,000 samples at 200 Hz over 10 s, so about 11 comparisons), a slerp/lerp interpolation between the two bracketing samples, and a compose up and back down the tree. For an 8-link chain that is roughly 8 interpolations and 8 quaternion products — on the order of 2–5 µs. Cheap per call, and murderous if a 200 Hz control loop calls it 40 times per cycle without caching.

The design rule that falls out of all three numbers: "Store poses as (quaternion, translation), not as 4×4 matrices." A 4×4 is 16 doubles = 128 bytes and drifts off the manifold when you compose it repeatedly; (q, t) is 7 doubles = 56 bytes, renormalises with one division, and is what every message format on the wire actually uses. Convert to a matrix at the point of use, in a tight loop, and throw it away.

Data flow, with shapes, for one 200 Hz control cycle on a 7-DOF arm:

joint encoders
(7,) float64 radians @ 200 Hz, hardware-timestamped
↓ forward kinematics: 7 SE(3) products
link poses in base_link
7 × (q, t) = 7 × 7 float64 — published on /tf, ~700 B/message
↓ tf2 buffer insert, O(1) amortised at the deque tail
tf2 buffer
28 dynamic frames × 200 Hz × 10 s = 56,000 entries @ ~100 B = ~5.6 MB (+12 latched static entries)
↓ lookupTransform(target, source, stamp), 2–5 µs
one 4×4 for the caller
materialised on demand, used, discarded — never accumulated

CODE — from scratch, then the library

Write this yourself at least once, because it is 15 lines and it exposes everything: whether you know the inverse, whether you get the composition order right, whether you handle the translation block.

python — from scratch
import numpy as np

def se3(R, t):
    """Pack a rotation and a translation into a 4x4."""
    T = np.eye(4)
    T[:3, :3] = R
    T[:3, 3] = t
    return T

def se3_inv(T):
    """Inverse WITHOUT a linear solve: exploit R^-1 = R^T."""
    R, t = T[:3, :3], T[:3, 3]
    Ti = np.eye(4)
    Ti[:3, :3] = R.T
    Ti[:3, 3]  = -R.T @ t      # NOT -t. Chapter 2 explains why.
    return Ti

def apply_to_points(T, P):
    """P is (N, 3). Returns (N, 3). No 4-vector allocation."""
    return P @ T[:3, :3].T + T[:3, 3]

def is_valid(T, tol=1e-6):
    """The four checks worth running on any transform you did not build."""
    R = T[:3, :3]
    return (np.abs(R @ R.T - np.eye(3)).max() < tol      # orthogonal
            and abs(np.linalg.det(R) - 1.0) < tol           # right-handed
            and np.allclose(T[3], [0, 0, 0, 1], atol=tol)     # affine, not projective
            and np.isfinite(T).all())                       # no NaN from a bad solve

Two details that separate a clean implementation from a slow one. First, apply_to_points uses P @ R.T rather than building 4-vectors — for a 300,000-point LiDAR sweep that avoids allocating a 300,000×4 array and roughly halves the memory traffic. Second, se3_inv never calls np.linalg.inv. Inverting a general 4×4 is an LU factorisation, about 25× slower than a transpose and two products, and it silently produces a matrix that is not exactly in SE(3) when the input has drifted.

The production form. Nobody ships hand-rolled SE(3) in a control loop, so know the ecosystem:

LibraryLanguageUse it when
scipy.spatial.transform.RotationPythonOffline analysis, notebooks, converting representations. No SE(3) type — you carry t yourself.
tf2 / tf2_rosC++ / PythonAnything inside ROS. It is not just a math library — it is the time-indexed buffer and the tree.
SophusC++ header-onlyYou need exp/log, Jacobians and the adjoint in an estimator. The de-facto choice next to Ceres.
manifC++ header-onlySame job as Sophus with a cleaner Lie-group API and analytic Jacobians as first-class returns.
GTSAM / Pose3C++ / PythonFactor-graph estimation; the SE(3) type comes with the optimiser attached.
pytransform3dPythonTeaching, plotting frames, sanity-checking a convention before you write the C++.

DEBUG — the rotation that stops being a rotation

Failure mode: accumulated rotation drift. A node integrates gyro increments by repeatedly multiplying its stored rotation matrix: R = R @ dR, 200 times a second, for a whole shift. Nothing crashes. Nothing looks wrong for the first hour.

Every product carries floating-point rounding, and there is nothing in the multiply that pushes the result back onto SO(3). The matrix slowly stops being orthogonal — its columns stop being unit length and stop being perpendicular. Geometrically, your "rotation" acquires a scale and a shear.

Observable symptom: a rigid object in the map slowly grows or skews. Two LiDAR sweeps of the same wall, taken an hour apart, no longer have the same length. Grasps get worse over a shift and are fixed by restarting the node — which is the tell everyone misreads as "a memory leak".

The metric that reveals it, and you should be publishing it as a diagnostic:

εorth = ‖RR − I‖F   and   εdet = |det R − 1|

Put numbers on it. Single precision has a machine epsilon of about 1.2×10−7. Rounding errors accumulate like a random walk, so after N products the orthogonality error grows roughly as √N · εmach. Over an 8-hour shift at 200 Hz, N = 200 × 3600 × 8 = 5.76×106, and √N = 2400:

εorth ≈ 2400 × 1.2×10−7 ≈ 2.9×10−4

That is a 0.029% scale-and-skew error. At a 1.5 m arm reach it is 1.5 × 2.9×10−4 = 0.43 mm — small, but it is a monotonically growing bias, and it stacks on top of everything else. Redo the same sum in double precision (εmach = 2.2×10−16): 2400 × 2.2×10−16 = 5.3×10−13, which is 0.8 nanometres at the same reach. Precision choice is the fix, not a detail.

The three fixes, in the order you should offer them. (1) Do not accumulate. Recompute the pose from the source of truth (joint angles, the estimator's state) every cycle instead of integrating a delta into a stored matrix. This removes the failure entirely. (2) Store a quaternion and renormalise — a quaternion has one constraint (unit norm) instead of six, and enforcing it is one division. (3) If you must keep a matrix, re-orthonormalise periodically: take the SVD R = UΣV and replace R with UV, which is the nearest true rotation in the Frobenius sense. Gram–Schmidt is cheaper but biased toward the first column.

The second failure mode, same family: the silent det = −1. A calibration solver returns a matrix from an unconstrained least-squares fit. It is orthogonal to 10−9, so the usual check passes. Its determinant is −1. Symptom: the scene is mirrored — the robot consistently reaches to the wrong side, and the error is not small, it is a reflection. Metric: np.linalg.det(R), one line, and it should be in every transform validator you write. Fix: enforce det = +1 inside the solver by flipping the sign of the last column of V in the SVD when the determinant comes out negative — the standard Kabsch/Umeyama correction.

Worked example 4 — build the det = −1 matrix yourself and watch it pass every check you were going to run.

Take Rz(30°) from worked example 1 and negate its second column. That is a one-character sign error in an exporter, or one flipped basis vector in a CAD file — the most ordinary way this bug enters a codebase.
Rbad = [[0.86603, 0.50000, 0], [0.50000, −0.86603, 0], [0, 0, 1]]
Check 1 — orthogonality, all six conditions, entry by entry. Columns are c1 = (0.86603, 0.50000, 0), c2 = (0.50000, −0.86603, 0), c3 = (0, 0, 1).
  1. c1·c1 = 0.75000 + 0.25000 + 0.00000 = 1.00000
  2. c2·c2 = 0.25000 + 0.75000 + 0.00000 = 1.00000 ✓ — squaring killed the sign, which is the heart of the problem
  3. c3·c3 = 0.00000 + 0.00000 + 1.00000 = 1.00000
  4. c1·c2 = 0.86603×0.50000 + 0.50000×(−0.86603) + 0×0 = 0.43301 − 0.43301 = 0.00000
  5. c1·c3 = 0.00000 + 0.00000 + 0.00000 = 0.00000
  6. c2·c3 = 0.00000 + 0.00000 + 0.00000 = 0.00000
6 of 6. RbadRbad = I to machine precision. Every "is it a rotation?" assert built on orthogonality alone is green.

Check 2 — the determinant, expanded along the third row. det Rbad = 0×C31 + 0×C32 + 1×C33, and
  C33 = +det [[0.86603, 0.50000], [0.50000, −0.86603]] = 0.86603×(−0.86603) − 0.50000×0.50000 = −0.75000 − 0.25000 = −1.00000
So det Rbad = −1.00000. Negating one column negated the determinant — that is the whole mechanism, and it is why an unconstrained least-squares fit lands on the wrong branch half the time.

Now push the same point through both. Shelf corner p = (0.5, 0.2, 0) in the base frame, base at t = (2, 1, 0).
  Good (Rz(30°)): x = 0.86603×0.5 + (−0.50000)×0.2 = 0.43301 − 0.10000 = 0.33301  |  y = 0.50000×0.5 + 0.86603×0.2 = 0.25000 + 0.17321 = 0.42321
  Bad (Rbad): x = 0.86603×0.5 + 0.50000×0.2 = 0.43301 + 0.10000 = 0.53301  |  y = 0.50000×0.5 + (−0.86603)×0.2 = 0.25000 0.17321 = 0.07679
Add t = (2, 1, 0) to each and put them side by side (five columns — scroll the table sideways →):
Matrixworld point (x, y, z)detRR = I ?distance from base
Rz(30°) — rotation(2.33301, 1.42321, 0.00000)+1.00000yes0.53852 m
Rbad — reflection(2.53301, 1.07679, 0.00000)−1.00000yes0.53852 m
The y coordinate moved from 1.42321 to 1.07679 and x from 2.33301 to 2.53301: the shelf corner is on the other side of the base x axis than it should be. And look at the last column — the rigid-body distance check from worked example 1 also passes, identically, 0.53852 m in both rows. That is not a coincidence and it is the trap: reflections are isometries, so no distance-preservation test can ever catch one. Only the determinant can.

What is the error a function of? The good matrix sends a base-frame point at polar angle θ to θ + 30°; Rbad sends it to 30° − θ, because it is the reflection about the line at 15°. The two answers are a chord apart on a circle of radius r, so
‖error‖ = 2r|sin θ|
Here r = 0.53852 m and θ = atan2(0.2, 0.5) = 21.801°, giving 2 × 0.53852 × 0.37139 = 0.40001 m — and indeed √((2.33301−2.53301)² + (1.42321−1.07679)²) = √(0.04000 + 0.12001) = √0.16001 = 0.40001 m. ✓
That formula is the diagnostic. The error is zero for any target lying on the base x axis (θ = 0) and maximal, 2r, for targets off to the side (θ = 90°). So the field symptom is a miss that vanishes in one direction and grows with lateral offset — never a constant bias, which is exactly why it does not look like a calibration offset and why teams chase it for days. Ask Chapter 0's question, "what is the error a function of?", and the answer 2r|sin θ| names the bug before you open the code.

FRONTIER — from matrices to manifolds

The 4×4 is not going anywhere — it is the interchange format. What has changed in the last decade is how estimators optimise over it.

The problem: SE(3) is a curved 6-dimensional manifold sitting inside a 16-dimensional matrix space. Gradient descent on the 16 entries wanders off the manifold immediately. The old workaround was to parameterise with Euler angles or a constrained quaternion and accept the singularities and the Lagrange multipliers. The modern answer is to keep the state on the manifold and take the increment in the tangent space, using the exponential map to get back:

Rk+1 = Rk · exp(δ),   δ ∈ ℝ³

Three references worth knowing by title:

Is this corner of the field still moving? The representation is stable — SE(3) is SE(3). What changed is that the tangent space became a first-class citizen. Ten years ago you hand-derived Jacobians and re-orthonormalised; now Sophus or manif gives you exp, log and the adjoint with analytic Jacobians, and LieTorch puts the same thing inside an autodiff graph so a learned front-end can train through a geometric back-end. The practical consequence: you no longer write your own retraction, and you no longer accept a pose parameterisation that has a singularity.
A vendor ships the mounting rotation R = [[0, −1, 0], [−1, 0, 0], [0, 0, 1]]. You verify RR = I to 10−12. A calibration target sits at p = (0.4, 0.1, 0) in sensor coordinates. Work out what the robot computes for p in the mount frame — and say what, if anything, is wrong.
Work it through (open after you have committed to an answer)

The arithmetic. Row by row: x = 0×0.4 + (−1)×0.1 + 0×0 = −0.1; y = (−1)×0.4 + 0×0.1 + 0×0 = −0.4; z = 0×0.4 + 0×0.1 + 1×0 = 0. So three of the four options quote the right numbers — the numbers were never the question.

The determinant. Expand along the third row: det R = 0×C31 + 0×C32 + 1×C33, and C33 = +det [[0, −1], [−1, 0]] = 0×0 − (−1)×(−1) = −1. So det R = −1.00000 while RR = I exactly. The matrix is in O(3) but not SO(3).

What it actually does. (x, y) → (−y, −x) is the reflection about the line y = −x, not any yaw. A rotation would have taken (0.4, 0.1) to a point at polar angle 14.036° + φ; this one lands it at −104.036°, which no single value of φ explains consistently across targets — the discrepancy depends on where the target is, so it will not look like a fixed calibration offset. Note the length check passes: √(0.1² + 0.4²) = 0.41231 = √(0.4² + 0.1²), because reflections are isometries. Distance-preservation cannot catch this; only det can.

Why the other options fail. (a) treats a reflection as a yaw — a 90° yaw is [[0, −1, 0], [1, 0, 0], [0, 0, 1]], and the sign of the (2,1) entry is the entire difference. (c) mis-multiplies: R = R here, since the matrix is symmetric, so "the transpose was applied by mistake" cannot change any sign. (d) confuses det with a scale factor; for an orthogonal matrix |det| is always 1, and the sign — the thing that carries handedness — is exactly the information it is discarding.

What to do next: reject the matrix and ask the vendor for the export convention, because a det of −1 usually means one basis vector was flipped in CAD. If you have to salvage it, run an SVD and negate the last column of V to project onto SO(3) — the Kabsch correction — but find out which axis was flipped before trusting the mount at all.

Chapter 2: Composition Order and Inverses — Where Most Frame Bugs Live

It is Friday afternoon and you have just shipped a calibration node. All week the arm grasped correctly on the bench: you jogged the torso a few degrees each way, the gripper closed on the part, the reprojection residual sat at 0.4 px. On Monday the cell runs its real cycle — a 90° torso turn to reach the far bin — and the gripper closes on empty air, 0.7 m from the part.

Nothing in the log is red. Every node is publishing at its rated rate. The tf tree is connected end to end, the calibration residual is still 0.4 px, and the joint encoders agree with the commanded angles to a hundredth of a degree. There is no error to grep for, because from the software's point of view nothing went wrong.

The cause is one of exactly two one-character mistakes, and this chapter is about both: multiplying two transforms in the wrong order, and inverting one incorrectly. Between them they account for the majority of frame bugs that reach production robots.

Both survive code review, because both lines look entirely reasonable on the page. Both are invisible in the unit test, because unit tests use small angles and short offsets — exactly the regime where the wrong answer is nearly the right answer. By the end of this chapter you will derive that 0.7 m from first principles, you will know why the bench test could never have caught it, and you will know the ten-second command that would have.

CONCEPT — the subscript rule, and why order is physical

Composition is matrix multiplication, and matrix multiplication is not commutative. That is not a mathematical inconvenience; it is a statement about the world. Rotating a book 90° about the vertical axis and then 90° about the axis pointing at you does not land in the same place as doing it the other way round. Try it with an actual book — a physical object settles the argument faster than any diagram.

The bookkeeping device that makes order errors impossible is the subscript-cancellation rule:

TA←B · TB←C = TA←C   —   the inner B's cancel

Write every transform with both frames on it, always. Then check that adjacent subscripts touch. T_world_base @ T_base_grip has base next to base: valid, and the result is T_world_grip. T_base_grip @ T_world_base has grip next to world: invalid, and the compiler will not tell you, but the rule will.

Make the rule a spoken habit. "I name transforms T_parent_child and I check that the inner subscripts cancel." Say it to yourself every time you write a compose. It is the habit engineers develop after losing a week — adopt it before, and it costs you nothing.

Reading a chain out loud. A three-link chain composes right to left in the algebra and, confusingly, that is also the physical order of application to a point:

Tworld←tool = Tworld←base Tbase←torso Ttorso←tool

Apply that to a point p expressed in the tool frame. The rightmost matrix hits p first, lifting it into the torso frame; then into the base; then into the world. So the matrix nearest the point is the transform nearest the point in the physical chain. That is the sanity check: the innermost matrix is the innermost frame.

Before the arithmetic: how to score a rotation error with one number. Two rotations differ by a third rotation. If the right answer is R1 and you shipped R2, the residual is Rrel = R1R2 — the rotation you would have to apply to undo your mistake. The honest scalar for "how wrong is that" is the rotation angle of Rrel: the single angle that Euler's theorem says every rotation is. You do not need to extract an axis to get it. The trace hands it to you — and "where does that come from?" deserves an answer, so derive it rather than reciting it.

Rodrigues' formula writes any rotation by angle θ about a unit axis n as a three-term sum:

R = I + sinθ · K + (1 − cosθ) · K²,     K = n, the skew matrix of n

Take the trace of both sides. Trace is linear, so it distributes over the three terms, and three small facts finish it:

Substituting all three:

tr(R) = 3 + sinθ · 0 + (1 − cosθ)(−2) = 3 − 2 + 2cosθ = 1 + 2cosθ

and solving for θ gives the identity used throughout this chapter and the rest of the lesson:

θ = arccos( (tr(R) − 1) / 2 )

Check it on two rotations you already know. R = I has trace 3, so θ = arccos((3−1)/2) = arccos(1) = 0° ✓. A 180° rotation about z is diag(−1, −1, +1), trace −1, so θ = arccos(−1) = 180° ✓. And the maximum possible trace is 3 and the minimum is −1, which is the algebraic statement that no rotation is ever more than 180° from another one.

Ship the clamp. Write it as theta = acos(clip((trace(R) - 1) / 2, -1.0, 1.0)), always. A matrix that has drifted by 10−9 — which every matrix in a long chain has — hands you an argument of 1.0000000004, and acos returns NaN. That NaN then propagates silently into a health metric, and the monitoring thread you wrote to catch frame bugs becomes the thing that hides them. One clamp, on every trace-to-angle conversion you ever write.
Worked example 1 — the order matters, quantified.

Let A = Rz(90°) and B = Rx(90°). Write them out:
A = [[0, −1, 0], [1, 0, 0], [0, 0, 1]]    B = [[1, 0, 0], [0, 0, −1], [0, 1, 0]]
Multiply AB. Row 1 of A is (0, −1, 0). The columns of B are (1,0,0), (0,0,1), (0,−1,0):
  (0,−1,0)·(1,0,0) = 0    (0,−1,0)·(0,0,1) = 0    (0,−1,0)·(0,−1,0) = +1
Row 2 of A is (1,0,0): gives (1, 0, 0). Row 3 is (0,0,1): gives (0, 1, 0). So
AB = [[0, 0, 1], [1, 0, 0], [0, 1, 0]]
Now BA. Row 1 of B is (1,0,0); the columns of A are (0,1,0), (−1,0,0), (0,0,1):
  (1,0,0)·(0,1,0) = 0    (1,0,0)·(−1,0,0) = −1    (1,0,0)·(0,0,1) = 0
Row 2 of B is (0,0,−1): gives (0, 0, −1). Row 3 is (0,1,0): gives (1, 0, 0). So
BA = [[0, −1, 0], [0, 0, −1], [1, 0, 0]]
What happened to the x-axis? The image of (1,0,0) is just the first column.
  AB · (1,0,0) = (0, 1, 0) — forward became left.
  BA · (1,0,0) = (0, 0, 1) — forward became up.

How wrong is that, as one number? Build the residual Rrel = (AB)(BA) explicitly, using the trace identity we just derived. First transpose AB — rows become columns:
  (AB) = [[0, 1, 0], [0, 0, 1], [1, 0, 0]]
Now multiply it by BA = [[0, −1, 0], [0, 0, −1], [1, 0, 0]], row by row:
  Row 1 of (AB) is (0,1,0). Columns of BA are (0,0,1), (−1,0,0), (0,−1,0):
    (0,1,0)·(0,0,1) = 0    (0,1,0)·(−1,0,0) = 0    (0,1,0)·(0,−1,0) = −1
  Row 2 is (0,0,1): gives (1, 0, 0).   Row 3 is (1,0,0): gives (0, −1, 0). So
Rrel = [[0, 0, −1], [1, 0, 0], [0, −1, 0]]
Read the diagonal. It is 0 + 0 + 0, so tr(Rrel) = 0, and the identity gives θ = arccos((0 − 1)/2) = arccos(−0.5) = 120°. Swapping two 90° rotations is a 120° error — a third of a full turn, from writing A @ B where you meant B @ A.

Turn the angle into metres. A tool held 0.5 m out along the body x-axis sits at (0.5, 0, 0) in the body frame. Under AB it lands at 0.5·(0,1,0) = (0, 0.5, 0); under BA at 0.5·(0,0,1) = (0, 0, 0.5). The gap is √(0.5² + 0.5²) = 0.5√2 = 0.71 m — and that is the Monday-morning number from the opening scene, produced by nothing but a transposed pair of matrix names.

So order errors at large angles are catastrophic and obvious. The dangerous regime is the opposite one — and to say anything quantitative about it we need to know how badly two small rotations fail to commute. That is a derivation, not a lookup, and it is the load-bearing step of this chapter.

Step 1 — multiply two exponentials by hand. Write each small rotation as a matrix exponential, R = exp(A) where A is the skew matrix of the rotation vector. Expand each exponential to second order — we only care about terms up to quadratic, because that is where the difference will live:

exp(A) = I + A + ½A² + O(A³),    exp(B) = I + B + ½B² + O(B³)

Multiply the two series and keep every term of total degree 2 or lower:

exp(A)exp(B) = I + A + B + AB + ½A² + ½B² + O(3)

Step 2 — ask what single exponential this equals. Guess that the product is exp(C) for some C = A + B + D, where D is itself second order (so D² is fourth order and can be dropped). Expanding that guess:

exp(A + B + D) = I + (A + B) + D + ½(A + B)² + O(3) = I + A + B + D + ½(A² + AB + BA + B²) + O(3)

Step 3 — subtract. The I, A, B, ½A² and ½B² terms are identical on both sides and cancel. What is left is AB on one side against D + ½AB + ½BA on the other:

D = AB − ½AB − ½BA = ½(AB − BA) = ½[A, B]

That is the second-order Baker–Campbell–Hausdorff formula, derived rather than quoted. Read what it says physically: the only thing distinguishing the two orders is the commutator. Swap A and B and every term is unchanged except [A, B], which flips sign. So the discrepancy between the two orders is the full commutator [A, B], and its half-size correction is what each order carries.

Step 4 — the commutator of two skew matrices is a cross product. This is the step that turns algebra into geometry, and it is the one most people cannot reproduce. Claim: for any two 3-vectors u and v, [u, v] = (u × v). Prove it by hand on the cleanest possible case, a = ẑ and b = x̂.

Write both skew matrices out. Using K = [[0, −n3, n2], [n3, 0, −n1], [−n2, n1, 0]]:

A = ẑ = [[0, −1, 0], [1, 0, 0], [0, 0, 0]]     B = x̂ = [[0, 0, 0], [0, 0, −1], [0, 1, 0]]

Compute AB, all nine entries. Rows of A dotted against columns of B, where the columns of B are (0,0,0), (0,0,1) and (0,−1,0):

AB = [[0, 0, 1], [0, 0, 0], [0, 0, 0]]

Now BA. The columns of A are (0,1,0), (−1,0,0) and (0,0,0) — note that third column is all zeros, which is where most hand-calculations of this go wrong:

BA = [[0, 0, 0], [0, 0, 0], [1, 0, 0]]

Subtract entry by entry:

[A, B] = AB − BA = [[0, 0, 1], [0, 0, 0], [−1, 0, 0]]

Is that the skew matrix of something? Match it against the template: the (1,3) entry is n2, so n2 = 1; the (3,1) entry is −n2 = −1 ✓; the (1,2) entry is −n3 = 0 and the (2,3) entry is −n1 = 0, so n1 = n3 = 0. It is exactly (0, 1, 0) = ŷ. And the cross product? ẑ × x̂ = ŷ. The commutator of the two skew matrices is the skew matrix of the cross product of their axes — verified, by hand, entry by entry.

Why this one identity is worth memorising the derivation of. It is the bridge between the matrix world and the vector world. It says the Lie bracket on so(3) is the cross product, which is why rotation vectors add like vectors when they are small and stop doing so exactly in proportion to how non-parallel they are. Every "small-angle" claim about rotations in this lesson, and every gyro-integration error bound in Lesson 3, ultimately rests on it. Estimation engineers reach for it by name.

Put the pieces together. Two rotations by α about axis a and β about axis b, taken in the two orders, differ by the full commutator [αa, βb] = (αa × βb). The rotation angle of a small rotation is the magnitude of its rotation vector, so the order error is |αa × βb| = αβ sinφ, with φ the angle between the axes. Now the formula in the next box is earned.

Worked example 2 — why the bug hides on the bench.

Redo the same experiment with 5° instead of 90°. From the derivation above, exp(αa)exp(βb) ≈ exp(αa + βb + ½[αa, βb]) — the Baker–Campbell–Hausdorff expansion. Only the commutator term depends on the order, so the order error is:
θorder ≈ |αa × βb| = αβ sinφ
where φ is the angle between the two rotation axes. For α = β = 5° = 0.087266 rad about perpendicular axes:
  θorder ≈ 0.087266 × 0.087266 × 1 = 0.0076154 rad = 0.4363°
The exact numerical answer is 0.4361° — the approximation is good to four digits.

Read what that means. On a bench test with 5° motions, reversing the composition order produces a 0.44° error — well inside the noise of a hand-held calibration target. On the robot, doing a 90° torso turn, the same line of code produces a 120° error. The bug's magnitude is quadratic in the motion, so the test that would catch it is the one nobody runs. That sentence is the whole story of why this bug ships.

Two points do not make a curve. Worked example 2 asserts a quadratic law from a 5° sample and a 90° sample. That is a claim you should be able to defend across the whole range, so here is the exact order error — angle of (AB)(BA), computed from the trace identity — for Rz(α) against Rx(α), tabulated against the small-angle prediction α²:

α = βpredicted α²exact θexact ÷ predgrowth vs row above
0.0175°0.0175°1.0000
0.4363°0.4361°0.999424.9× (5× angle)
15°3.9270°3.9048°0.99438.95× (3× angle)
45°35.3429°33.6842°0.95318.63× (3× angle)
90°141.3717°120.0000°0.84883.56× (2× angle)

Read the table in three passes. First the growth column: the quadratic law is not decoration. Going 1° → 5° multiplies the angle by 5 and the error by 24.9 — call it 25, which is 5². Going 5° → 15° multiplies by 3 and the error by 8.95, which is 3² = 9 to within half a percent. Halving both angles quarters the error. That is the whole argument for why a gentle bench test is worthless as a detector: cut your test motion in half to be "safe" and you cut your detection signal by four. The widget below prints this ratio live — drag both sliders to 5° and it reads 4.00×.

Second, the "exact ÷ pred" column: the small-angle prediction is exact to four digits below 5°, still good to half a percent at 15°, and starts breaking down past 45° where the neglected third-order BCH terms bite. It under-predicts nothing — it over-predicts, saturating at the geometric ceiling of 120°, because a rotation error can never exceed 180° and the residual axis itself rotates as the angles grow.

Third, the practical reading. Suppose your acceptance test is "grasp error under 5 mm at a 0.5 m tool offset", which is a 0.57° rotation tolerance. From the table that trips at roughly α = 5.7° of test motion — so a rig that jogs ±5° sits just under the threshold and passes. The test does not merely fail to catch the bug; it is calibrated, by accident, to sit exactly on the boundary where it cannot. The fix is not a tighter tolerance, it is a bigger motion: add one 90° sweep to the acceptance script and the error becomes 120°, which nothing can hide.

CONCEPT — deriving the inverse instead of memorising it

The inverse is the other half of the chapter, and it earns its place precisely because the wrong answer is so tempting. Do not recall it — derive it in three lines, every time.

We want the T′ with T T′ = I. Write both in block form and multiply:

[[R, t], [0, 1]] · [[R′, t′], [0, 1]] = [[R R′,   R t′ + t], [0, 1]]

For that to be the identity, two things must hold. The rotation block gives R R′ = I, so R′ = R−1 = R. The translation block gives R t′ + t = 0, so t′ = −R−1t = −Rt. Done:

T−1 = [[R,   −Rt], [0, 1]]
The trap is −t. Writing the inverse translation as −t is the single most common transform bug after composition order. It looks right — undoing a move means going back — but the translation t is expressed in the parent's axes, and the inverse transform's translation must be expressed in the child's axes. You have to rotate it into the new frame first. The other trap is T.T: transposing the whole 4×4 dumps the translation into the bottom row, producing something that is not even affine.
Worked example 3 — the inverse, by hand, and the cost of getting it wrong.

Take Tworld←base with R = Rz(30°) and t = (2, 1, 0).

Step 1 — transpose the rotation. R = Rz(−30°) = [[0.86603, 0.50000, 0], [−0.50000, 0.86603, 0], [0, 0, 1]].

Step 2 — rotate the translation. Rt, row by row:
  x: 0.86603 × 2 + 0.50000 × 1 + 0 = 1.73205 + 0.50000 = 2.23205
  y: −0.50000 × 2 + 0.86603 × 1 + 0 = −1.00000 + 0.86603 = −0.13397
  z: 0

Step 3 — negate. t′ = −Rt = (−2.23205, +0.13397, 0).

Read the physical meaning. Tbase←world answers "where is the world origin, in base coordinates?" It is 2.232 m behind the robot and 0.134 m to its left. That is a statement you can picture, and picturing it is the check.

Two-second sanity check: the distance from base to world origin must be the same both ways. |t| = √(2² + 1²) = √5 = 2.23607. |−Rt| = √(2.23205² + 0.13397²) = √(4.98205 + 0.01795) = √5.00000 = 2.23607 ✓. A rotation cannot change a length, so if these differ you made an arithmetic slip.

What the naive answer costs. The −t version gives (−2, −1, 0). The gap:
  Δ = (−2 − (−2.23205), −1 − 0.13397, 0) = (0.23205, −1.13397, 0)
  |Δ| = √(0.05385 + 1.28589) = √1.33974 = 1.157 m
More than a metre, from one missing transpose, on a robot whose workspace is two metres wide.

The general residual. If you use −t instead of −Rt, then T · Twrong−1 is not the identity — its translation block is exactly (I − R)t. That is the same expression as Chapter 0's bug, which is not a coincidence: both are "a translation that was never rotated into the frame it is being used in". It is one disease with two presentations.

Confirm that on the numbers you just computed. Rz(30°)t = (0.86603×2 − 0.5×1,  0.5×2 + 0.86603×1,  0) = (1.23205, 1.86603, 0), so (I − R)t = (2 − 1.23205,  1 − 1.86603,  0) = (0.76795, −0.86603, 0), whose norm is √(0.58975 + 0.75001) = 1.15747 m. Same 1.157 m as the gap measured in the child frame — which it must be, because the two vectors differ only by a rotation, and rotations preserve length. Two independent routes to the same number is the check.

CONCEPT — composing the whole transform, translations included

Worked examples 1 and 2 kept the translations at zero so the rotation story stayed clean. Real chains never do. Composing two full SE(3) elements in block form gives

[[R1, t1], [0, 1]] · [[R2, t2], [0, 1]] = [[R1R2,   R1t2 + t1], [0, 1]]

and the whole lesson of this chapter lives in that top-right block. The translations do not simply add. The child's offset t2 is written in the child's axes, so it has to be rotated into the parent's axes by R1 before the parent's own offset is added to it. Get that wrong — write t1 + t2 — and you have made Chapter 0's mistake again, in a third costume.

Worked example 4 — a full SE(3) compose, all sixteen entries, by hand.

A mobile manipulator. Tworld←base is a 30° yaw with the base standing at (2, 1, 0). Tbase←tool is a 45° roll about x with the tool mounted 0.4 m above the base plate.
  Rwb = Rz(30°) = [[0.86603, −0.50000, 0], [0.50000, 0.86603, 0], [0, 0, 1]],   twb = (2, 1, 0)
  Rbt = Rx(45°) = [[1, 0, 0], [0, 0.70711, −0.70711], [0, 0.70711, 0.70711]],   tbt = (0, 0, 0.4)

Step 1 — the rotation block, Rwt = RwbRbt, all nine entries. The columns of Rbt are c1 = (1, 0, 0), c2 = (0, 0.70711, 0.70711), c3 = (0, −0.70711, 0.70711).
Row 1 of Rwb is (0.86603, −0.50000, 0):
  (1,1) = 0.86603×1 − 0.5×0 + 0×0 = 0.86603
  (1,2) = 0.86603×0 − 0.5×0.70711 + 0×0.70711 = −0.35355
  (1,3) = 0.86603×0 − 0.5×(−0.70711) + 0×0.70711 = +0.35355
Row 2 of Rwb is (0.50000, 0.86603, 0):
  (2,1) = 0.5×1 + 0.86603×0 + 0×0 = 0.50000
  (2,2) = 0.5×0 + 0.86603×0.70711 + 0×0.70711 = 0.61237
  (2,3) = 0.5×0 + 0.86603×(−0.70711) + 0×0.70711 = −0.61237
Row 3 of Rwb is (0, 0, 1):
  (3,1) = 0    (3,2) = 1×0.70711 = 0.70711    (3,3) = 1×0.70711 = 0.70711
Rwt = [[0.86603, −0.35355, 0.35355], [0.50000, 0.61237, −0.61237], [0, 0.70711, 0.70711]]
Sanity check before going on: column 1 has norm √(0.75 + 0.25 + 0) = 1 ✓, column 2 has √(0.125 + 0.375 + 0.5) = 1 ✓, and c1·c2 = 0.86603×(−0.35355) + 0.5×0.61237 + 0 = −0.30619 + 0.30619 = 0 ✓. Two seconds, and it catches every arithmetic slip above.

Step 2 — the translation block, twt = Rwbtbt + twb, all three components. First rotate the child offset (0, 0, 0.4) into world axes:
  x: 0.86603×0 + (−0.50000)×0 + 0×0.4 = 0
  y: 0.50000×0 + 0.86603×0 + 0×0.4 = 0
  z: 0×0 + 0×0 + 1×0.4 = 0.4
Then add the parent offset: twt = (0, 0, 0.4) + (2, 1, 0) = (2, 1, 0.4).

Why that came out so clean, and why that is a warning. The rotation left the offset untouched because the offset lies along z, which is precisely the axis Rz spins about. A rig whose tool offset is purely vertical and whose base motion is purely yaw will produce the same translation whether or not you rotate before adding — so it cannot detect the "just add the translations" bug at all. Move the tool 0.25 m forward as well, tbt = (0.25, 0, 0.4), and the same arithmetic gives Rwbtbt = (0.86603×0.25, 0.50000×0.25, 0.4) = (0.21651, 0.125, 0.4), so twt = (2.21651, 1.125, 0.4) — whereas naive addition gives (2.25, 1, 0.4). That is a gap of √(0.03349² + 0.125²) = 0.129 m, from a 25 cm offset and a 30° yaw. Note also |Rwbtbt| = √(0.04688 + 0.01563 + 0.16) = 0.47170 = |tbt| ✓.
Worked example 4b — the same two transforms, composed backwards.

Now write the bug: T_base_tool @ T_world_base. The subscript rule rejects it instantly — tool next to world does not cancel — but numpy will happily multiply it, so let us see what the robot actually does.

Rotation block, RbtRwb. Columns of Rwb are (0.86603, 0.5, 0), (−0.5, 0.86603, 0), (0, 0, 1).
  Row 1 of Rbt = (1, 0, 0) → (0.86603, −0.50000, 0)
  Row 2 of Rbt = (0, 0.70711, −0.70711) → (0.70711×0.5,  0.70711×0.86603,  −0.70711×1) = (0.35355, 0.61237, −0.70711)
  Row 3 of Rbt = (0, 0.70711, 0.70711) → (0.35355, 0.61237, 0.70711)

Translation block, Rbttwb + tbt. Rbt(2, 1, 0) = (2,  0.70711×1 − 0.70711×0,  0.70711×1 + 0.70711×0) = (2, 0.70711, 0.70711). Add (0, 0, 0.4): (2, 0.70711, 1.10711).

Score the damage. Translation gap Δt = (2, 0.70711, 1.10711) − (2, 1, 0.4) = (0, −0.29289, 0.70711), so |Δt| = √(0.08579 + 0.50000) = 0.765 m. And the rotation gap, via the trace identity on Rwt(RbtRwb): 22.74°, against a small-angle prediction of αβ = 30°×45° in radians = 0.41123 rad = 23.56° — the same 0.965 ratio the table above predicts for that angle range. The tool ends up three quarters of a metre away and tilted 23°, and the only visible difference in the source is which name came first.

CONCEPT — active vs passive, the other order confusion

There is a second reason people get order wrong, and it is not about matrices at all. The same matrix R can mean two different things:

Active (alibi)Passive (alias)
What movesThe point moves; the frame is fixedThe frame moves; the point is fixed
Typical use"Rotate this gripper 30°""Express this LiDAR point in the base frame"
Matrix for the same 30°Rz(+30°)Rz(−30°) = Rz(+30°)
Failure if you mix themEvery angle in the chain gets the wrong sign — the error is a reflection about the identity, so it is exactly double the true rotation

The 2θ claim, proved in one line. Suppose the correct passive matrix is R and you shipped R. Score it the way we score everything in this chapter: the residual is (correct)−1(shipped). The inverse of R is R, so

Rrel = (R)−1 R = R · R = R(θ)R(θ)

and because both factors are rotations about the same fixed axis n, they commute and their angles simply add: R(θ)R(θ) = R(2θ). (Check it with the trace identity if you like: for R = Rz(30°), R² = Rz(60°) has trace 1 + 2cos60° = 2, giving arccos(0.5) = 60° = 2×30° ✓.) So the residual is not "some rotation" — it is exactly double the true angle, about exactly the true axis.

That precision is the diagnostic. A random sign error, a wrong axis convention, or a bad calibration gives you an error with no particular relationship to the mounting angle. An active/passive mix-up gives you an error that is 2θ to within your measurement noise, on the same axis. So when a technician reports "the camera thinks the pallet is at 60° and it is really at 30°", you do not go looking for a calibration problem — you go looking for a missing transpose, and you find it in one grep. If instead the error were θ itself, or 90° − θ, or an axis swap, it would be a different bug entirely.

They are inverses of each other, which is why mixing them produces an error of exactly 2θ rather than something random. If your calibration is off by precisely twice the mounting angle, this is your bug. In the TA←B notation the ambiguity disappears — TA←B is always the passive reading, and the active reading is "TA←B is where the body ends up if it starts at A's origin and moves by that pose" — but you have to pick one convention and put it in the header comment.

Order playground — AB vs BA in 3D, and the inverse

A = Rz(α) and B = Rx(β), the two rotations from Worked example 1. The arrows are the body's forward axis after AB (teal) and after BA (orange); the dashed red arc between their tips is the rotation you would have to apply to fix the bug, and the readout is its angle from the trace identity. It opens at 90°/90°, where the two arrows point along different world axes and the readout says 120° — Worked example 1, live.

Now drag both angle sliders down to 5°. The arrows collapse onto each other and the readout falls to 0.44° — that is the bench test, and it is why the bug ships. Then halve both again and watch the second readout line: the error does not halve, it quarters. That is the quadratic law, observed rather than asserted.

α — A about z90°
β — B about x90°
|t| — inverse mode0.70
 
 

DESIGN — the API that gets the direction wrong for you

Here is where the chapter turns from algebra to systems. In ROS, the transform library already handles composition and inversion — and the number one source of production frame bugs is not doing the math wrong, it is calling the API with the arguments in the wrong order.

ThingWhat it actually holds / returnsThe trap
TransformStamped with header.frame_id = "base", child_frame_id = "cam"Tbase←cam — the pose of the camera in the base framePeople read frame_id as "the frame this data is in" (true for other messages) and publish the inverse
lookupTransform(target, source, stamp)Ttarget←source — use it to move points from source into targetArgument order is (target, source), which is the reverse of how you say it in English ("transform from lidar to base")
tf2::doTransform(in, out, tf)Applies the stored transform to a stamped datumSilently wrong if you hand it the transform for the reverse direction — there is no type-level check
static_transform_publisher (ROS 2)Args are --x --y --z --yaw --pitch --roll --frame-id --child-frame-idROS 1's positional form was x y z yaw pitch roll frame child — yaw first, not roll. Half the internet's copy-pasted commands have roll and yaw swapped
The habit that kills this class of bug. Never let a raw 4×4 or a raw Transform exist in your code without both frame names attached — either in the variable name (T_base_cam) or in a small typed wrapper that carries the frame ids and refuses to compose mismatched pairs. In C++ a 30-line Pose<From, To> template turns every order bug into a compile error. In Python, the naming convention plus one assertion in a helper gets you most of the way. This is the deeper habit — preventing the class of bug, not fixing the instance.

The cost model for a chain. Composing two SE(3) matrices is a 3×3 product (27 multiply–adds) plus a matrix–vector product (9) plus 3 adds — about 39 flops. Composing two (q, t) pairs is a quaternion product (16 multiplies, 12 adds) plus a quaternion rotation of a vector (about 30 flops) — roughly 60 flops but 56 bytes instead of 128. On an 8-link chain either is under a microsecond, so the choice is driven by memory and drift, not speed.

The lookup you should not do 40 times per cycle. A 200 Hz controller that calls lookupTransform once per link per cycle spends 40 × 3 µs = 120 µs per cycle, which is 2.4% of a 5 ms budget on lookups alone — and each call takes the buffer mutex, so it also serialises against the tf listener thread. The fix is to look up the one transform you need at the top of the cycle and pass it down. Being able to say "and it takes the buffer lock" is what makes this a design answer rather than a micro-optimisation.

CODE — a chain, a fold, and a guard

python — from scratch
import numpy as np
from functools import reduce

def se3_inv(T):
    """Inverse of a 4x4 rigid transform, derived not recalled.
    [[R, t], [0, 1]]^-1 == [[R.T, -R.T @ t], [0, 1]]
    NOT T.T  (that dumps t into the bottom row -- not even affine)
    NOT -t   (t lives in the PARENT axes; the inverse needs it in the CHILD's)"""
    R, t = T[:3, :3], T[:3, 3]
    out = np.eye(4)
    out[:3, :3] = R.T          # orthogonal => transpose IS the inverse
    out[:3, 3]  = -R.T @ t     # <-- the one line people get wrong
    return out

def chain(*transforms):
    """chain(T_w_b, T_b_t, T_t_g) -> T_w_g.  Left to right, subscripts cancel."""
    return reduce(lambda a, b: a @ b, transforms, np.eye(4))

def relative(T_w_a, T_w_b):
    """Pose of b as seen from a, when both are known in the world.
    T_a_b = inv(T_w_a) @ T_w_b   -- the TARGET side is the one inverted."""
    return se3_inv(T_w_a) @ T_w_b

def chain_checked(links):
    """links: [(('world','base'), T), (('base','tool'), T), ...]
    Refuses to compose a chain whose subscripts do not cancel."""
    (a, b), T = links[0]
    for (c, d), U in links[1:]:
        if c != b:
            raise ValueError(f"cannot compose {a}<-{b} with {c}<-{d}")
        T, b = T @ U, d
    return (a, b), T

chain_checked is 8 lines and it is the difference between a bug that costs two days and an exception on the first run. In C++ the same idea with template parameters costs zero runtime and fails at compile time; in Rust the newtype pattern does it too. When a postmortem asks "how do we prevent this from happening again?", this is a much better answer than "more careful code review".

The test that would have caught the Friday deploy. A test that only asserts the right answer is half a test — it passes just as happily against an implementation that is right for the wrong reason. Assert both directions: the correct inverse must round-trip to the identity, and the tempting wrong one must not. Six lines, and it pins the exact number from Worked example 3 so a future refactor cannot quietly regress it.

python — the six-line guard
def Rz(th):
    c, s = np.cos(th), np.sin(th)
    return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])

T = np.eye(4)
T[:3, :3] = Rz(np.deg2rad(30.0))
T[:3, 3]  = [2.0, 1.0, 0.0]        # Worked example 3, exactly

def se3_inv_wrong(T):                # the -t version, kept for contrast
    out = np.eye(4); out[:3, :3] = T[:3, :3].T; out[:3, 3] = -T[:3, 3]
    return out

# leg 1 -- the right one round-trips to machine precision
assert np.allclose(T @ se3_inv(T), np.eye(4), atol=1e-12)
# leg 2 -- the tempting one MUST fail, or the test proves nothing
resid = T @ se3_inv_wrong(T)
assert not np.allclose(resid, np.eye(4), atol=1e-12)
# leg 3 -- pin the magnitude so a refactor cannot silently shrink it
err = np.linalg.norm(resid[:3, 3])          # this is |(I - R) t|
assert abs(err - 1.157) < 1e-3, err
print(f"-t inverse leaves a {err:.3f} m residual")   # -> 1.157 m

Three things worth understanding about that test. One: the second assert is the one that matters — a test suite full of positive assertions cannot distinguish "my code is correct" from "my code and my reference are wrong the same way". Two: the tolerance is 1e-12, not 1e-6, because an exact algebraic identity in float64 should close to about 10−16; a loose tolerance here would let a genuinely broken inverse through. Three: the residual translation is (I − R)t, so the test degenerates when R is near identity — which is exactly why you pin a 30° rotation and not the 1° one a lazy fixture would use. That last sentence is the same lesson as the order-error table, arriving through the code instead of the algebra.

python — the production form
# ROS 2. Note the argument order: TARGET first, SOURCE second.
T = buf.lookup_transform('base_link', 'lidar', stamp, timeout=Duration(seconds=0.05))
p_base = tf2_geometry_msgs.do_transform_point(p_lidar_stamped, T)

# Sophus / manif (C++): composition and inverse are operators, and the
# library keeps the result on the manifold for you.
#   Sophus::SE3d T_w_g = T_w_b * T_b_g;
#   Sophus::SE3d T_g_w = T_w_g.inverse();          // R^T and -R^T t
#   Eigen::Vector3d p_w = T_w_g * p_g;             // no 4-vector needed

DEBUG — the reversed chain, and how to catch it in one command

Failure mode: a reversed composition inside a calibration publisher. A team writes a node that fuses two extrinsic calibrations to produce lidar → camera from base → lidar and base → camera. Someone writes T_lidar_cam = T_base_lidar @ T_base_cam instead of se3_inv(T_base_lidar) @ T_base_cam.

Observable symptom: the projected LiDAR points land on the image, but rotated and offset — the wall is there, it is just leaning. Detection recall drops from 94% to 71% and everyone blames the detector. Nobody blames the transform, because the point cloud looks plausible in RViz.

The metric that reveals it, and it takes ten seconds. A valid relative transform between two rigidly mounted sensors has a translation whose magnitude equals the physically measured baseline. Print it:

‖tlidar,cam‖  vs  the tape-measure distance between the two mounts

With the reversed composition the translation becomes Rbase,lidartbase,cam + tbase,lidar, whose magnitude is generally close to the sum of the two mount distances rather than their difference. If the LiDAR is 0.35 m from base and the camera is 0.42 m from base and they are 0.15 m apart on the rig, a reported 0.71 m baseline is not a subtle discrepancy — it is a shout. Every extrinsic your system computes should be compared against a tape measure on first publish. One assertion, one time.

The three-command field triage for any suspected frame bug.
1. ros2 run tf2_tools view_frames — is the tree even connected, and is anything parented twice?
2. ros2 run tf2_ros tf2_echo <target> <source> — does the translation magnitude match a tape measure, and does the RPY match the CAD drawing?
3. Move one joint by hand and watch the echo. Does the number move in the direction and by the amount you expect? A sign error shows up instantly; a composition-order error shows up as the wrong axis moving.
Being able to name these three, in order, is worth more at a broken robot than any amount of theory.

Second failure mode: extrapolation, which looks exactly like a composition bug. lookupTransform with a timestamp newer than the last published sample either throws or, with some settings, extrapolates. A torso moving at 60°/s with a 180 ms stale transform gives 60 × 0.18 = 10.8° of error — and because it depends on how fast the joint is moving, it produces an error that looks configuration-dependent, which is the same signature as a rotation bug. Discriminator: a composition bug is deterministic and repeatable at a given configuration; an extrapolation bug varies with joint velocity. Hold the configuration still and command the same grasp: if the error vanishes, it is timing, not algebra. That is Lesson 2's territory.

FRONTIER — composing increments instead of poses

Composition order is settled mathematics, but where the composition happens has moved. Two developments worth naming:

Three objects, three rules — write them down and keep them. Given T = TA←B = [[R, t], [0, 1]]:

ObjectRule from B to AWhy it is different
A point ppA = R pB + tPoints have a position, so the offset t applies
A free vector v (e.g. a normal)vA = R vBNo position, so no offset — this is the "transform a normal, not a point" trap
A twist ξ = (ω, v)ξA = AdT ξBThe linear part picks up a moment arm term t × ω, because a pure rotation about one origin is a rotation-plus-translation about another
A wrench F = (τ, f)FA = AdT−⊤ FBWrenches are covectors — they pair with twists to give power, and power is frame-independent, which forces the inverse-transpose
AdT = [[R,  0], [tR,  R]]   (with the twist ordered angular-first, ξ = (ω, v))
Worked example 5 — the adjoint, built and applied by hand.

A wrist camera sits 0.3 m along the forearm's x-axis and is rotated 90° about z relative to it. So Tarm←cam has R = Rz(90°) and t = (0.3, 0, 0). The camera reports that the body it is attached to is spinning at 1 rad/s about the camera's own z-axis and translating not at all: ξcam = (ω, v) = (0, 0, 1 ; 0, 0, 0).

Step 1 — write the skew of t, entry by entry. With t = (0.3, 0, 0), the template t = [[0, −t3, t2], [t3, 0, −t1], [−t2, t1, 0]] gives
t = [[0, 0, 0], [0, 0, −0.3], [0, 0.3, 0]]
Only two entries are non-zero, and both carry the 0.3 — that is the moment arm, sitting in the matrix in plain sight.

Step 2 — the lower-left block, tR. With R = Rz(90°) = [[0, −1, 0], [1, 0, 0], [0, 0, 1]], multiply row by row:
  Row 1 of t is (0,0,0) → (0, 0, 0)
  Row 2 is (0, 0, −0.3): against columns (0,1,0), (−1,0,0), (0,0,1) → (0, 0, −0.3)
  Row 3 is (0, 0.3, 0): → (0.3×1, 0.3×0, 0) = (0.3, 0, 0)
tR = [[0, 0, 0], [0, 0, −0.3], [0.3, 0, 0]]
Step 3 — assemble the 6×6. Top-left R, top-right 0, bottom-left tR, bottom-right R:
AdT = [ [0, −1, 0 ‖ 0, 0, 0], [1, 0, 0 ‖ 0, 0, 0], [0, 0, 1 ‖ 0, 0, 0], [0, 0, 0 ‖ 0, −1, 0], [0, 0, −0.3 ‖ 1, 0, 0], [0.3, 0, 0 ‖ 0, 0, 1] ]
Step 4 — apply it. The input is (ω; v) = (0, 0, 1; 0, 0, 0).
  Angular part: ωarm = Rω = (0×0 − 1×0 + 0×1,  1×0 + 0×0 + 0×1,  0 + 0 + 1×1) = (0, 0, 1) rad/s — unchanged, because R spins about z and ω is along z.
  Linear part: varm = tRω + Rv. The second term is zero. The first: row 4 gives 0, row 5 gives −0.3×1 = −0.3, row 6 gives 0.3×0 = 0.
varm = (0, −0.3, 0) m/s,  ‖varm‖ = 0.3 m/s
Check it against physics, no matrices. A twist with v = 0 means the instantaneous axis of rotation passes through the camera's origin. The arm's origin sits 0.3 m away from that axis, so it must be sweeping a circle at speed ωr = 1 rad/s × 0.3 m = 0.3 m/s ✓. Direction: r = −t = (−0.3, 0, 0) and ω × r = (0,0,1) × (−0.3,0,0) = (0, −0.3, 0) ✓. The matrix and the picture agree.

What the naive answer gives. Anyone who reaches for "just rotate it like a vector" — varm = Rvcam, or worse, blockdiag(R, R)ξ — gets varm = (0, 0, 0). They report the arm as stationary while it is genuinely moving at 0.3 m/s. At a 100 Hz servo rate that is 3 mm of un-modelled motion every tick, accumulating until the tracker blames its own noise model. The signature is unmistakable once you know it: the velocity error is zero whenever the arm is not rotating, and grows exactly linearly with the rotation rate, scaled by the mount offset. Hold the arm still and translate — the error vanishes; spin in place — it is maximal. That one experiment separates a missing adjoint from a bad calibration in about twenty seconds.
The ordering landmine. There is no universal convention for whether a twist is (ω, v) or (v, ω). GTSAM and most of the robotics-textbook literature (Murray–Li–Sastry, Lynch–Park) put angular first; Sophus and much of the SLAM code built on it put translational first, which swaps the two off-diagonal blocks of AdT. Mixing a GTSAM covariance with a Sophus Jacobian gives you a 6×6 that is silently permuted — the result is not garbage, it is plausible, which is worse. Before you trust any cross-library covariance, check the twist ordering convention of both libraries.
The tradeoff to defend. Should you use a Lie-group library or hand-rolled SE(3)? For a fixed pipeline of composes and inverses, hand-rolled is fine, auditable, and dependency-free. The moment you are optimising over poses — calibration, bundle adjustment, any factor graph — take the library, because the value is not the multiply, it is the analytic Jacobian and the retraction that keeps you on the manifold. Hand-derived SE(3) Jacobians are where teams lose weeks, and they are the thing a library gets exactly right.
Your node computes T_lidar_cam from two known extrinsics and the projected point cloud lands on the image but visibly rotated. In sixty seconds, what do you check first, and why that?

Chapter 3: Four Ways to Say the Same Rotation

A rotation is three numbers' worth of information. There are four common ways to package it, they all appear in the same codebase, and the conversions between them are where the sign errors live.

The question that matters in practice is never "what is a quaternion". It is which representation would you store, which would you compute in, and why are those different answers?

CONCEPT — why there are four, and why there cannot be a perfect one

Start with the fact that makes this chapter necessary. The set of rotations SO(3) is a curved, closed 3-dimensional space — it is not flat ℝ³. Specifically it is topologically the 3-sphere with antipodal points identified. This has a hard consequence:

No three-parameter representation of SO(3) can be both global and singularity-free. This is topology, not engineering sloppiness — you cannot cover a closed curved space with a single flat chart. Every 3-number scheme (Euler angles, rotation vectors, Rodrigues parameters) must either have a point where it breaks down or fail to reach some rotations. Your options are: accept a singularity, or use more than three numbers plus a constraint. Every representation below is one of those two bargains.

Here is each one, what it costs, and what it breaks.

1. The rotation matrix R. Nine numbers, six constraints, three free. No singularities anywhere — it is the only representation that is globally clean. Rotating a vector is one matrix–vector product. The cost is memory (72 bytes in double precision) and the fact that six constraints are six things to drift away from, as Chapter 1's failure mode showed.

2. Axis–angle, and its cousin the rotation vector. Euler's rotation theorem says any rotation is a single rotation by θ about a single unit axis n. Store them separately as (n, θ) — four numbers, one constraint — or multiply them into one 3-vector ω = θn, the rotation vector, which is exactly three numbers. Rodrigues' formula turns it into a matrix:

R = I + sinθ · n + (1 − cosθ) · (n

where n is the skew-symmetric matrix of n. The derivation is in VNAV 02 — Lie Groups; here what matters is the failure: the axis is undefined at θ = 0 (you cannot normalise a zero vector), and the map wraps at θ = 2π. The rotation vector is exactly the Lie algebra element, which is why estimators use it for the increment — increments are small, so you are always far from the singularity.

3. Euler angles. Three sequential rotations about coordinate axes. Three numbers, no constraints, and human-readable — which is the entire reason they refuse to die. There are 24 conventions: 12 axis sequences (zyx, zyz, xyz, …) times two interpretations (intrinsic, where each rotation is about the already-rotated body axis, vs extrinsic, about the fixed world axes). The robotics default is intrinsic z–y–x, spoken as yaw–pitch–roll. The failure is gimbal lock at pitch = ±90°, and Chapter 4 is entirely about it.

4. The unit quaternion. Four numbers, one constraint (‖q‖ = 1), three free. Built directly from axis–angle:

q = (w, x, y, z) = (cos(θ/2),   nx sin(θ/2),   ny sin(θ/2),   nz sin(θ/2))

The half-angle is not decoration — it is the whole design. Because everything is a half-angle, rotating a vector is a sandwich product q v q−1 where the two halves each contribute θ/2. And because cos and sin of θ/2 are 2π-periodic while the rotation is not, q and −q are the same rotation. That is the double cover, and Chapter 4 will show you what it costs.

The matrix built from a quaternion — the conversion you will be asked to write:

R(q) = [[1−2(y²+z²),  2(xy−wz),  2(xz+wy)], [2(xy+wz),  1−2(x²+z²),  2(yz−wx)], [2(xz−wy),  2(yz+wx),  1−2(x²+y²)]]

Do not memorise those nine entries — derive them. There is a one-line closed form that produces every one of them, signs included, and it is the thing to write down first:

R(q) = (w² − ‖v‖²) I  +  2 v v  +  2w [v]×

where v = (x, y, z) is the vector part and [v]× is the skew-symmetric matrix of v — the matrix that satisfies [v]×u = v × u for every u. Write it down explicitly, because this is the object that carries all six off-diagonal signs:

[v]× = [[0, −z, y], [z, 0, −x], [−y, x, 0]]

Three terms, three distinct jobs, and each has a name worth knowing:

So the quaternion→matrix formula and Rodrigues' formula are the same three terms wearing different clothes, and the half-angle identities are the translation. Substituting the unit-norm constraint w² + x² + y² + z² = 1 collapses the diagonal to the familiar form — entry (1,1) is (w² − x² − y² − z²) + 2x² = w² + x² − y² − z² = (1 − y² − z²) − y² − z² = 1 − 2(y² + z²) ✓.

Now the rule for the off-diagonals, stated correctly. The w-term of entry (i, j) is 2w times entry (i, j) of [v]× — nothing more. Because [v]× is not a checkerboard (its entries cycle x → y → z → x), the signs do not simply alternate across the diagonal. All six, written out:

Entryfrom 2vv[v]× entryw-term = 2w × thatTotal
R122xy−z−2wz2(xy − wz)
R132xz+y+2wy2(xz + wy)  ← plus, above the diagonal
R212xy+z+2wz2(xy + wz)
R232yz−x−2wx2(yz − wx)
R312xz−y−2wy2(xz − wy)  ← minus, below the diagonal
R322yz+x+2wx2(yz + wx)
The mnemonic that will betray you. A very common shortcut says "the w-term is minus above the diagonal, plus below." It is correct for the (1,2)/(2,1) pair and correct for the (2,3)/(3,2) pair — and inverted for (1,3)/(3,1), where the plus sits above: R13 = 2(xz + wy) while R31 = 2(xz wy). The reason is structural, not arbitrary: [v]× cycles x→y→z→x, and (1,3) is the slot the cycle reaches backwards, so its sign is the odd one out.

Two of the three pairs obey the false rule, which is precisely why it survives contact with reality: it passes every hand-check whose wy term happens to be zero — a pure yaw (v along z), a pure roll (v along x), and the identity. Worked example 1 below is a pure yaw, so it would not catch the error either. Write [v]× down first and read the six signs straight off it. It takes four seconds and it is never wrong.

Two structural facts fall out of the decomposition that you will use later in this chapter:

Worked example 1 — quaternion to matrix, by hand, for a 90° yaw.

θ = 90°, n = (0, 0, 1). Half angle is 45°, so cos 45° = sin 45° = 0.70711:
  q = (w, x, y, z) = (0.70711, 0, 0, 0.70711)

Now every entry, with the squares first: x² = 0, y² = 0, z² = 0.5, w² = 0.5, and wz = 0.70711 × 0.70711 = 0.5.
  R11 = 1 − 2(y² + z²) = 1 − 2(0 + 0.5) = 0
  R12 = 2(xy − wz) = 2(0 − 0.5) = −1
  R13 = 2(xz + wy) = 2(0 + 0) = 0
  R21 = 2(xy + wz) = 2(0 + 0.5) = +1
  R22 = 1 − 2(x² + z²) = 1 − 2(0 + 0.5) = 0
  R23 = 2(yz − wx) = 0    R31 = 2(xz − wy) = 0    R32 = 2(yz + wx) = 0
  R33 = 1 − 2(x² + y²) = 1 − 0 = 1

R = [[0, −1, 0], [1, 0, 0], [0, 0, 1]]
which is exactly Rz(90°) ✓. Check it moves the right thing: R·(1,0,0) = (0,1,0) — forward becomes left, which is what a positive yaw does in a right-handed z-up frame.

Sub-case 1b — the entry the false mnemonic gets wrong. Notice what the yaw above did not test. Its vector part is (0, 0, 0.70711), so y = 0, so wy = 0, so R13 and R31 both came out 0 and their signs were never exercised. Redo the whole thing for θ = 90° about y — a positive pitch — where wy is the only surviving cross term:
  n = (0, 1, 0), half angle 45°, so q = (w, x, y, z) = (0.70711, 0, 0.70711, 0)
Squares and products, all of them: x² = 0, y² = 0.5, z² = 0, w² = 0.5, wy = 0.70711 × 0.70711 = 0.5, and wx = wz = xy = xz = yz = 0.
First read the skew matrix, since it is where every off-diagonal sign lives: [v]× = [[0, −0, 0.70711], [0, 0, −0], [−0.70711, 0, 0]] — the + is in slot (1,3) and the is in slot (3,1). Now the nine entries:
  R11 = 1 − 2(y² + z²) = 1 − 2(0.5 + 0) = 0
  R12 = 2(xy − wz) = 2(0 − 0) = 0
  R13 = 2(xz + wy) = 2(0 + 0.5) = +1   ← a plus, and it sits above the diagonal
  R21 = 2(xy + wz) = 0
  R22 = 1 − 2(x² + z²) = 1 − 2(0 + 0) = 1
  R23 = 2(yz − wx) = 0
  R31 = 2(xz − wy) = 2(0 − 0.5) = −1   ← a minus, and it sits below
  R32 = 2(yz + wx) = 0
  R33 = 1 − 2(x² + y²) = 1 − 2(0 + 0.5) = 0
R = [[0, 0, 1], [0, 1, 0], [−1, 0, 0]]
Check against the textbook Ry(θ) = [[cosθ, 0, sinθ], [0, 1, 0], [−sinθ, 0, cosθ]] at θ = 90°, which is [[0,0,1],[0,1,0],[−1,0,0]] ✓. Physical check: R·(0,0,1) = (1,0,0) — up becomes forward, which is what a positive pitch about +y does in a right-handed frame. And R·(1,0,0) = (0,0,−1) — forward goes down. Both correct.

What the false mnemonic would have produced here. "Minus above, plus below" gives R13 = −1 and R31 = +1, i.e. R = [[0,0,−1],[0,1,0],[1,0,0]] = Ry(−90°). That is a 180° pitch error — and here is the part that makes it dangerous rather than merely wrong: that matrix is still perfectly orthogonal (RR = I) and still has det R = +1. It is a valid rotation. Every guard you wrote in Chapter 1 passes it. The only thing that catches it is comparing against an independently built reference — which is exactly what the Code Lab below does with its Rodrigues assert.

Now the double cover, for free. Go back to the yaw quaternion and take −q = (−0.70711, 0, 0, −0.70711). Then wz = (−0.70711)(−0.70711) = +0.5, unchanged. Every entry of R is a product of two components, so both signs flip and cancel. R(−q) = R(q), exactly, to the last bit.

CONCEPT — deriving the quaternion product instead of memorising it

Composition is the operation you will be asked to perform live, and the formula for it is usually handed to students as a fact. Do not accept it that way — the derivation is four lines, it explains where the dot and cross products come from, and it is the only way the Hamilton-vs-JPL split later in this chapter will make sense rather than being a thing you assert.

Everything comes from the single line Hamilton carved into Broom Bridge in Dublin in 1843:

i² = j² = k² = ijk = −1

Step 1 — recover the six basis products. From k² = −1 we get k−1 = −k. Right-multiply ijk = −1 by k:

(ij)k·k = −k  ⇒  (ij)(−1) = −k  ⇒  ij = k

Left-multiply ijk = −1 by i−1 = −i and you get jk = i; the same move on the other side gives ki = j. For the reversed products, start from jk = i and substitute k = ij: j(ij) = i, so (ji)j = i; right-multiply by j−1 = −j and (ji)(−j²) = −ij, i.e. ji = −k. Identically, kj = −i and ik = −j.

The load-bearing observation: the basis units anticommute — ij = −ji, jk = −kj, ki = −ik. The dot product is about to come from the four squares (which are all −1, hence a minus sign in front) and the cross product is about to come from the six mixed terms, which cancel pairwise except for their antisymmetric remainder. There is nothing else in the formula.

Step 2 — expand the sixteen terms. Multiply out, keeping the order of every factor because this algebra is not commutative:

(w1 + x1i + y1j + z1k)(w2 + x2i + y2j + z2k)

Step 3 — sort the sixteen terms by which basis element they land on.

Real part — four terms. The only products that land on the reals are w1w2 and the three squares:

w1w2 + x1x2i² + y1y2j² + z1z2k² = w1w2 − (x1x2 + y1y2 + z1z2) = w1w2 − v1·v2

The minus sign in front of the dot product is not a convention someone chose. It is i² = −1, three times.

i part — four terms. Two come from multiplying by a scalar, two from the mixed pair jk = i and kj = −i:

w1x2 + x1w2 + y1z2(jk) + z1y2(kj) = w1x2 + w2x1 + (y1z2 − z1y2)

and y1z2 − z1y2 is exactly (v1 × v2)x.

j part. w1y2 + w2y1 + z1x2(ki) + x1z2(ik) = w1y2 + w2y1 + (z1x2 − x1z2) — and that bracket is (v1 × v2)y.

k part. w1z2 + w2z1 + x1y2(ij) + y1x2(ji) = w1z2 + w2z1 + (x1y2 − y1x2) — and that bracket is (v1 × v2)z.

Step 4 — stack the three vector components back up. The three brackets are the three components of one cross product, so:

q1 ⊗ q2 = (w1w2 − v1·v2,   w1v2 + w2v1 + v1×v2)
Why the cross product is there at all. Split each mixed pair into a symmetric and an antisymmetric half. The symmetric halves — things like ½(ij + ji) — are exactly zero, because ij = −ji. Every mixed term that survives is antisymmetric, and the antisymmetric bilinear map on ℝ³ is the cross product; there is only one, up to scale. So the cross-product term in the quaternion product is nothing but the antisymmetry of ij = −ji written in vector notation.

This is also why quaternion multiplication does not commute, and therefore why rotations do not commute: swap the two arguments and only the cross term changes, flipping sign. Concretely, q2 ⊗ q1 = (w1w2 − v1·v2,  w1v2 + w2v1 v1×v2). Hold on to that expression — it is the entire Hamilton-vs-JPL story, and it is about to reappear verbatim.

A historical footnote worth 10 seconds: when w1 = w2 = 0 (two pure vectors), the product is (−v1·v2, v1×v2). The dot and cross products of ordinary vector algebra were carved out of this single quaternion product by Gibbs and Heaviside in the 1880s. You are not learning two operations that happen to appear together; you are looking at the thing they were both taken from.
Worked example 2 — composing two quaternions by hand.

The Hamilton product we just derived, ready to use:
q1 ⊗ q2 = (w1w2 − v1·v2,   w1v2 + w2v1 + v1×v2)
Take q1 = 90° about z = (0.70711, 0, 0, 0.70711) and q2 = 90° about x = (0.70711, 0.70711, 0, 0). So v1 = (0, 0, 0.70711) and v2 = (0.70711, 0, 0).

Scalar part: w1w2 − v1·v2 = 0.70711×0.70711 − (0×0.70711 + 0 + 0) = 0.5 − 0 = 0.5
Vector part, three pieces:
  w1v2 = 0.70711 × (0.70711, 0, 0) = (0.5, 0, 0)
  w2v1 = 0.70711 × (0, 0, 0.70711) = (0, 0, 0.5)
  v1×v2 = (0,0,0.70711) × (0.70711,0,0) = (0·0 − 0.70711·0,  0.70711·0.70711 − 0·0,  0·0 − 0·0.70711) = (0, 0.5, 0)
  sum = (0.5, 0.5, 0.5)

Result: q = (0.5, 0.5, 0.5, 0.5). Norm = √(4 × 0.25) = 1.0 ✓ — unit quaternions are closed under the product, no renormalisation needed in exact arithmetic.

Read it back as axis–angle. θ = 2 arccos(w) = 2 arccos(0.5) = 2 × 60° = 120°. Axis = (0.5, 0.5, 0.5) normalised = (0.5774, 0.5774, 0.5774) = (1,1,1)/√3 — the body diagonal of the cube.

That is the same 120° we computed matrix-side in Chapter 2, arrived at with 16 multiplications instead of 27, and it is the classic result: a quarter turn about z followed by a quarter turn about x is a third of a turn about the cube's diagonal.

CONCEPT — the comparison table you should be able to redraw

MatrixQuaternionAxis–angle / rot-vectorEuler
Numbers stored943 (or 4)3
Constraints61 (unit norm)0 (or 1)0
Bytes, float6472322424
Singularitynonenone (but double cover)θ = 0 and θ = 2πpitch = ±90°
Compose two45 flops (27×, 18+)28 flops (16×, 12+)convert firstconvert first
Rotate one vector15 flops~30 flopsconvert firstconvert first
Rotate 100k points1.5 Mflop — and it vectorises3 Mflopconvert once, then matrixconvert once, then matrix
RenormaliseSVD or Gram–Schmidtone divisionn/an/a
Interpolatebadly (entrywise lerp leaves SO(3))slerp, exactok for small anglesnever — it lies near lock
Human-readablenonosomewhatyes
The one-sentence answer to "which do I use?" Store and compose quaternions; rotate point clouds with a matrix; expose Euler angles only at the human interface; use rotation vectors for increments inside an estimator. Every serious stack does exactly this, and being able to justify each clause is the whole question. The reasons in order: quaternions are compact, drift-resistant and interpolate correctly; matrices vectorise for bulk point work; Euler angles are for the operator's screen and the URDF file, never for storage; rotation vectors are the tangent space, so increments live there naturally and are always near zero, far from the singularity.
Representation inspector

One rotation, four spellings. Pick an axis, sweep the angle, and read all four representations update together. Watch the quaternion's scalar part: it is cos(θ/2), so it only reaches zero at a half turn — and the purple row is −q, the other name for the identical rotation.

angle θ120°
 

DESIGN — the numbers that decide the representation

"Store quaternions, rotate with matrices" sounds like taste. It is not. It is forced by the rates, shapes and byte counts on a real robot — "which representation?" is really a question about whether you can produce those numbers. So produce them.

Take a concrete stack: a ground robot with an IMU-driven attitude filter and a 10 Hz spinning LiDAR, on ROS 2. Every boundary, annotated:

BoundaryPayload · bandwidthRate → budget
IMU → filterq (4,) float64, 32 B
66 kB/s
200 Hz5 ms
Filter → /tfq (4,) + t (3,) f64, 90 B
18 kB/s
200 Hz5 ms
LiDAR → de-warp(120000,3) float32, 1.44 MB
14.4 MB/s
10 Hz100 ms
De-warp → registration(120000,3) float32, 1.44 MB
14.4 MB/s
10 Hz100 ms

Two details from that table worth saying out loud. First, the message types are sensor_msgs/Imu, geometry_msgs/TransformStamped and sensor_msgs/PointCloud2; the IMU message is ≈330 B on the wire even though the orientation is only 32 B of it, because two 9×float64 covariance blocks dominate it — the quaternion is not what costs you. Second, 14.4 MB/s is per subscriber: three nodes that each take their own copy of the cloud will saturate a gigabit link on the point cloud alone.

Now cash in the flop counts from the comparison table. De-warping means rotating the cloud, and the table above says a matrix rotate is 15 flop per point while the quaternion sandwich is ~30. Convert both into time:

That 8× is the entire answer to "why convert to a matrix first?" You pay one quaternion→matrix conversion — about 30 flops, once — and buy a contiguous, single-kernel, BLAS-dispatched bulk rotate. The conversion is 0.002% of the work it enables. That one sentence settles the representation question.

But the sweep is not one rotation — and that is why you still store quaternions. A 10 Hz spinning LiDAR paints its 120,000 points across the full 100 ms. The robot moves during that time, so each point was captured in a slightly different frame; de-warping means applying the attitude at that point's capture time. The attitude arrives at 200 Hz, so a 100 ms sweep is bracketed by 20 attitude samples, one every 5 ms. Two ways to spend that:

ApproachWork per sweepCost
Slerp every point120,000 slerps
≈ 360k transcendentals
≈8–11 ms
8–11%
Slerp 20 brackets,
then rotate by matrix
20 slerps + 20 conversions
+ 20 BLAS calls
≈0.25 ms
ship this

Unpacking the two rows. A slerp costs three transcendental evaluations per call — one acos for the angle between the endpoints and two sin for the weights — so 120,000 of them is ~360k transcendentals, and even vectorised those run at roughly 20–30 ns each. The bracketed version instead does 20 slerps, 20 quaternion→matrix conversions (20 × ~30 flop = 600 flop total, which is free), and 20 BLAS calls of (6000,3) @ (3,3) — 6000 because 120,000 points spread over 20 brackets is 6000 points each.

Is 20 brackets accurate enough? Do the error bound rather than guessing. Within a 5 ms bracket, slerp is exact for constant angular rate, so the residual is second-order in the angular acceleration α: roughly ⅛αΔt². For an aggressive α = 10 rad/s² and Δt = 5 ms, that is ⅛ × 10 × (0.005)² = 3.1 × 10−5 rad = 31 µrad. At 30 m range: 0.9 mm of lateral error — comfortably below the LiDAR's own ~2 cm range noise. The 8–11 ms per-point slerp buys nothing measurable.

Read the design conclusion straight off the numbers. Quaternions live on the wire and in the interpolator, because 32 B beats 72 B at 200 Hz, because slerp is only defined on them, and because a unit-norm repair is one division rather than an SVD. Matrices live in the inner loop, because 2.88 MB of streamed traffic beats 14 MB of temporaries. Both are true at the same time, in the same node, five milliseconds apart — and that is exactly why the "which representation?" question has two answers.

DESIGN — the component order that will bite you

This is the highest-value paragraph in the chapter, because it is a real bug that ships constantly and it takes ninety seconds to understand.

A quaternion has four components. Two orderings are in wide use, (w, x, y, z) scalar-first and (x, y, z, w) scalar-last, and the libraries on your robot disagree:

ThingOrderNote
geometry_msgs/Quaternion (ROS)x, y, z, wNamed fields, so the wire format is safe — the danger is at the boundary where you flatten it to an array
Eigen::Quaterniond(w, x, y, z)constructor is w-firstbut q.coeffs() returns (x, y, z, w) — the same object, two orders, in one API
scipy Rotation.from_quatx, y, z, w by defaulta scalar_first flag exists in recent versions; if you rely on the default, pin the version
Most IMU / AHRS datasheetsw, x, y, zthe aerospace tradition
Isaac Sim / USD, MuJoCow, x, y, zsimulator boundaries are a classic crossing point
Worked example 3 — what a swapped order actually costs.

A modest 5° yaw mounting rotation, written scalar-first:
  q = (w, x, y, z) = (cos 2.5°, 0, 0, sin 2.5°) = (0.99905, 0, 0, 0.04362)

Now a downstream library reads that same array as scalar-last, so it believes:
  x = 0.99905, y = 0, z = 0, w = 0.04362

Is the norm still 1? √(0.99905² + 0.04362²) = √(0.99810 + 0.00190) = 1.00000. Yes. Every "is this a valid quaternion" assertion in your codebase passes.

What rotation did it just build? θ = 2 arccos(w) = 2 arccos(0.04362) = 2 × 87.50° = 175.0°, about the x-axis. A 5° yaw became a 175° roll.

The metric that catches it in one line: for any small rotation the scalar part must be near ±1 and the vector part near 0. So assert abs(q[0]) > 0.9 on a mounting quaternion you expect to be a few degrees. If the first element is near zero and the last is near one, you are reading the wrong order. Put that assertion at every library boundary.

And here is what it costs, because someone will push back on adding a check to a 200 Hz path. The assertion is one field read, one abs, one float compare: ≈0.1 µs in Python (a few nanoseconds in C++). Against the 5 ms per-message budget that is 2 parts in 100,000 — 0.002%. Aggregated over a second: 200 × 0.1 µs = 20 µs/s, i.e. 0.002% of one core. You are spending two-thousandths of a percent of a core to make a class of bug loud that otherwise costs a day of URDF archaeology. There is no version of that trade you lose. Note also where it goes: at the boundary where the named-field message is flattened into an array ([q.w, q.x, q.y, q.z] or q.coeffs()), not inside the filter — the wire format with named fields was never the vulnerable part.

The second, deeper convention split: Hamilton vs JPL. Beyond component order, there are two algebraic conventions. Hamilton — used by Eigen, ROS, scipy and essentially all robotics — has ij = k. The JPL convention, common in aerospace and in a lot of filter literature, has ij = −k. That looks like a footnote. It is not, and the derivation above tells you exactly why in one line.

Trace it through the four-line derivation. Go back to Step 3. The real part w1w2 − v1·v2 came only from the four squares i² = j² = k² = −1, which JPL keeps — so the scalar part is unchanged. The terms w1v2 + w2v1 came from multiplying by a scalar, so they are unchanged too. The only thing that came from ij = k was the cross-product term. Flip ij = k to ij = −k and every one of those six mixed products flips with it:

q1JPL q2 = (w1w2 − v1·v2,   w1v2 + w2v1 v1×v2)

Compare that with the expression flagged in the callout above — the Hamilton product with its arguments swapped. They are character for character the same thing, because swapping the arguments flips only the cross term. So:

qaJPL qb  =  qbHamilton qa  ⇒  R(qaJPL qb) = R(qb) R(qa)

That is the whole thing. It is not "JPL is weird"; it is that the antisymmetric term reversed, and a reversed antisymmetric term is a reversed composition order. Feed JPL bits to a Hamilton matrix builder and your kinematic chain multiplies backwards — which, as Chapter 2 showed, is a large, stable, silent error, not a crash.

Worked example 4 — the same two rotations, both conventions, side by side.

Reuse Worked example 2 exactly: q1 = 90° about z = (0.70711, 0, 0, 0.70711), q2 = 90° about x = (0.70711, 0.70711, 0, 0), so v1 = (0, 0, 0.70711), v2 = (0.70711, 0, 0), and from before v1 × v2 = (0, 0.5, 0).

Scalar part — identical in both conventions: 0.5 − 0 = 0.5.
Shared vector terms: w1v2 + w2v1 = (0.5, 0, 0) + (0, 0, 0.5) = (0.5, 0, 0.5).

  Hamilton adds the cross term: (0.5, 0, 0.5) + (0, 0.5, 0) = (0.5, 0.5, 0.5) ⇒ q = (0.5, 0.5, 0.5, 0.5)
  JPL subtracts it: (0.5, 0, 0.5) − (0, 0.5, 0) = (0.5, −0.5, 0.5) ⇒ q = (0.5, 0.5, −0.5, 0.5)

Both are unit norm (√(4 × 0.25) = 1), both read back as θ = 2 arccos(0.5) = 120°. Nothing you can assert on a single quaternion tells them apart. Only the axis differs: (1, 1, 1)/√3 versus (1, −1, 1)/√3 — two different body diagonals of the cube.

Build both matrices with the Hamilton R(q) from earlier (the entries and their signs are already tabulated above):
  R(0.5, 0.5, 0.5, 0.5) = [[0, 0, 1], [1, 0, 0], [0, 1, 0]]
  R(0.5, 0.5, −0.5, 0.5) = [[0, −1, 0], [0, 0, −1], [1, 0, 0]]

Independently multiply the two 90° matrices in both orders. With Rz(90°) = [[0,−1,0],[1,0,0],[0,0,1]] and Rx(90°) = [[1,0,0],[0,0,−1],[0,1,0]]:
  RzRx = [[0, 0, 1], [1, 0, 0], [0, 1, 0]]  ← matches the Hamilton product ✓
  RxRz = [[0, −1, 0], [0, 0, −1], [1, 0, 0]]  ← matches the JPL product ✓

The chain literally composed backwards, and now the symptom is a number: apply each to the forward axis (1, 0, 0). Hamilton gives (0, 1, 0) — forward becomes left. The mixed-convention result gives (0, 0, 1) — forward becomes up. Ninety degrees apart, perfectly stable, on a quaternion whose norm is exactly 1 and whose recovered angle is exactly the right 120°.

How to detect it in a running system. Both conventions produce unit quaternions and both produce valid rotation matrices, so norm and orthogonality checks are blind — as Worked example 4 just showed. The one cheap discriminator: compose two known, non-commuting rotations at startup (a 90° yaw and a 90° roll will do) and check the result against a hard-coded reference. If your library returns the transpose of what you expect, you are on the other convention. Three lines in a unit test, run once, and it fires at build time instead of in the field.

The reference every practitioner cites is Solà, "Quaternion kinematics for the error-state Kalman filter" (2017), whose appendix tabulates both conventions side by side — including the fact that JPL is usually paired with scalar-last storage, so the two convention splits often arrive together and partially mask each other. Know that document by name; it is the thing people actually keep open on a second monitor.

CODE — the conversion nobody gets right first try

Quaternion to matrix is easy — nine quadratic entries, no branching, no failure case. Matrix to quaternion is the one that catches people, because the naive formula w = ½√(1 + tr R) is a subtraction of nearly equal numbers exactly where robots spend real time: near a half turn.

Why the trace shows up at all. Sum the three diagonal entries of R(q): tr R = 3 − 4(x² + y² + z²), and with the unit constraint x² + y² + z² = 1 − w² that is tr R = 4w² − 1, i.e. 1 + tr R = 4w². So ½√(1 + tr R) = |w|, and the trace formula is not a trick — it is that identity solved for w. It also tells you precisely when it dies: at θ = 180°, w = 0, so 1 + tr R = 0, and you are extracting a square root of zero and then dividing by it to get x, y, z.

Shepperd's method is the fix: the same identity exists for each of the four components (1 + R00 − R11 − R22 = 4x², and cyclically for y and z), and at least one of w², x², y², z² is always ≥ ¼ because they sum to 1. Branch on the largest and the square root is never taken of a small number. Here is the whole thing — all four branches, because the two people usually elide are exactly the ones where the index permutation goes wrong:

python — from scratch, numerically safe (Shepperd 1978)
def R_to_quat(R):
    """Shepperd's method: branch on whichever of w, x, y, z is
    largest, so the square root is never taken of a small,
    cancellation-prone number. Six identities do all the work
    (read each one straight off R(q), entry by entry):

        1 + tr R            = 4w^2    R[2,1] - R[1,2] = 4wx
        1 + R00 - R11 - R22 = 4x^2    R[0,2] - R[2,0] = 4wy
        1 + R11 - R00 - R22 = 4y^2    R[1,0] - R[0,1] = 4wz
        1 + R22 - R00 - R11 = 4z^2    R[0,1] + R[1,0] = 4xy
                                      R[0,2] + R[2,0] = 4xz
                                      R[1,2] + R[2,1] = 4yz

    Every line below is one of those over 4*(branch component)."""
    tr = R[0,0] + R[1,1] + R[2,2]
    if tr > 0:                                  # w is largest
        # CAREFUL: here s = 1/(4w) -- a RECIPROCAL -- so this
        # branch MULTIPLIES by s; the other three DIVIDE.
        s = 0.5 / np.sqrt(tr + 1.0)
        w = 0.25 / s
        x = (R[2,1] - R[1,2]) * s           # 4wx * 1/(4w)
        y = (R[0,2] - R[2,0]) * s           # 4wy * 1/(4w)
        z = (R[1,0] - R[0,1]) * s           # 4wz * 1/(4w)
    elif R[0,0] > R[1,1] and R[0,0] > R[2,2]:   # x is largest
        s = 2.0 * np.sqrt(1.0 + R[0,0] - R[1,1] - R[2,2])
        w = (R[2,1] - R[1,2]) / s           # 4wx / 4x  ANTIsym
        x = 0.25 * s                        # s = 4|x|
        y = (R[0,1] + R[1,0]) / s           # 4xy / 4x  sym
        z = (R[0,2] + R[2,0]) / s           # 4xz / 4x  sym
    elif R[1,1] > R[2,2]:                       # y is largest
        # The ANTIsym pair moves to (0,2), and the two sym sums
        # are the ones containing a y. This branch is NOT the
        # x-branch with the letters swapped -- indices permute.
        s = 2.0 * np.sqrt(1.0 + R[1,1] - R[0,0] - R[2,2])
        w = (R[0,2] - R[2,0]) / s           # 4wy / 4y  NOT (2,1)
        x = (R[0,1] + R[1,0]) / s           # 4xy / 4y
        y = 0.25 * s                        # s = 4|y|
        z = (R[1,2] + R[2,1]) / s           # 4yz / 4y
    else:                                       # z is largest
        s = 2.0 * np.sqrt(1.0 + R[2,2] - R[0,0] - R[1,1])
        w = (R[1,0] - R[0,1]) / s           # 4wz / 4z  antisym
        x = (R[0,2] + R[2,0]) / s           # 4xz / 4z
        y = (R[1,2] + R[2,1]) / s           # 4yz / 4z
        z = 0.25 * s                        # s = 4|z|
    q = np.array([w, x, y, z])
    return q / np.linalg.norm(q)            # renormalise on exit

def quat_mul(a, b):
    """Hamilton product. 16 multiplies, 12 adds."""
    w1, v1 = a[0], a[1:]
    w2, v2 = b[0], b[1:]
    return np.concatenate(([w1*w2 - v1 @ v2],
                           w1*v2 + w2*v1 + np.cross(v1, v2)))

def rotate(q, v):
    """q v q^-1 without building the matrix. ~30 flops."""
    u = q[1:]
    t = 2.0 * np.cross(u, v)
    return v + q[0] * t + np.cross(u, t)

The y and z branches are where hand-written versions break, so look at what actually changes. It is tempting to describe them as "the x-branch with the letters rotated," and that is exactly the mistake. Two independent things permute at once. First, the antisymmetric pair — the one that yields w — moves: it is R[2,1]−R[1,2] in the w- and x-branches, R[0,2]−R[2,0] in the y-branch, and R[1,0]−R[0,1] in the z-branch, because each branch needs the difference that contains its own component (4wx, 4wy, 4wz respectively). Second, the two symmetric sums change identity: the x-branch uses the two sums containing an x (4xy and 4xz), the y-branch uses the two containing a y (4xy and 4yz). So R[0,1]+R[1,0] appears in both the x-branch and the y-branch — same numerator, different quotient: divided by 4x it yields y, divided by 4y it yields x. Copy the line without changing which variable it assigns to and you get a silently transposed quaternion that still has norm 1.

Worked example 5 — trace one matrix through the branches, and count the digits you would have lost.

Take a real case: an arm wrist that has rolled almost all the way over, θ = 179° about x. Build the matrix first:
  cos 179° = −0.9998477, sin 179° = 0.0174524
  R = Rx(179°) = [[1, 0, 0], [0, −0.9998477, −0.0174524], [0, 0.0174524, −0.9998477]]

(Indices below are 0-based bracket notation, R[row, col], so they line up with the code above rather than with the 1-based Rij used in the prose earlier.)

Which branch fires? tr R = 1 + (−0.9998477) + (−0.9998477) = −0.9996954. Not positive, so the w-branch is skipped. Compare diagonals: R[0,0] = 1 is greater than both R[1,1] = R[2,2] = −0.9998477, so the x-branch fires. Good — the true quaternion is (cos 89.5°, sin 89.5°, 0, 0) = (0.0087265, 0.9999619, 0, 0), and x really is the largest component.

What the x-branch computes:
  1 + R[0,0]R[1,1]R[2,2] = 1 + 1 + 0.9998477 + 0.9998477 = 3.9996954  (= 4x² = 4 × 0.9999619² ✓)
  s = 2√3.9996954 = 3.9998477
  x = 0.25s = 0.9999619
  w = (R[2,1]R[1,2])/s = (0.0174524 − (−0.0174524))/3.9998477 = 0.0349048/3.9998477 = 0.0087265 ✓  — that numerator is the 4wx identity, and note it is a difference of opposite-signed numbers, so it adds rather than cancels
  y = (R[0,1] + R[1,0])/s = 0,   z = (R[0,2] + R[2,0])/s = 0 ✓
Every quantity under a root or in a denominator is O(1). 3.9997 and 3.9998. Nothing small, nothing cancelling.

Now the naive path, on the same matrix. w = ½√(1 + tr R) = ½√(1 − 0.9996954) = ½√(3.046 × 10−4) = ½ × 0.0174524 = 0.0087262. The value is right. The precision is not, and this is the number that makes Shepperd feel necessary instead of decorative:

  • Each diagonal entry of R carries float64 rounding of about 1.1 × 10−16 absolute. Their sum carries roughly 2 × 10−16.
  • Adding 1 does not reduce that absolute error, but it collapses the magnitude from ~1 to 3.046 × 10−4. Relative error: 2×10−16 / 3.046×10−4 = 7.2 × 10−13.
  • float64 carries 1.1 × 10−16 relative, i.e. ~15.9 decimal digits. You now have 12.1. Roughly four digits gone, at only 179°.

And it degrades quadratically as you close on the half turn — the cancelling quantity is 1 + tr R = 4w² = 4cos²(θ/2), so halving the gap to 180° quarters it:
θ1 + tr Rdigits left
179°3.05e−412.1
179.9°3.05e−610.1
179.99°3.05e−88.1 — half gone
180°0 (or a few ulps negative)0 — nan
(The matching relative errors are 7.2e−13, 7.2e−11 and 7.2e−9 — each row is the 2e−16 absolute rounding error divided by the shrinking value in the middle column.)

That last row is the one that actually reaches production, so read it carefully. A rotation matrix that has drifted a few ulps off SO(3) — which, per Chapter 1, is every matrix that has been composed a few thousand times — can make 1 + tr R come out at −3 × 10−16 rather than 0. np.sqrt(-3e-16) returns nan; the nan flows into w, then into x = (R[2,1]R[1,2])/(4w), then into the published pose. Downstream the symptom is not a crash — it is a planner that silently stops emitting goals, because every comparison against a nan is false. The x-branch on that same matrix computes 3.9997 and does not care.

The summary, in one breath: "The trace formula is 4w² solved for w, so it cancels exactly when w is small — near a half turn. Shepperd branches to whichever component is largest, and since the four squares sum to 1 the largest is always at least a quarter, so the radicand is always at least 1. At 179° the naive form has lost four digits; at 179.99° it has lost half of them; at 180° on a slightly drifted matrix it returns nan."
python — the production form
from scipy.spatial.transform import Rotation as Rot

r = Rot.from_quat([x, y, z, w])       # scipy default is SCALAR-LAST
r.as_matrix()                          # (3,3)
r.as_rotvec()                          # (3,) -- axis * angle
r.as_euler('zyx', degrees=True)        # yaw, pitch, roll -- lowercase = EXTRINSIC
r.as_euler('ZYX', degrees=True)        # uppercase = INTRINSIC. Different answer!
(r1 * r2).as_quat()                    # composition, r1 applied AFTER r2
The scipy detail that costs an afternoon: in as_euler, lowercase axis letters mean extrinsic (rotations about the fixed world axes) and uppercase mean intrinsic (about the moving body axes). 'zyx' and 'ZYX' give different numbers for the same rotation. Robotics yaw–pitch–roll is the intrinsic one: 'ZYX'. If your Euler angles disagree with RViz by something that looks almost right, this is usually why.

DEBUG — the bug whose norm is still exactly 1

Failure mode: quaternion component order swapped across a boundary. A driver publishes the IMU orientation scalar-first into an array; a fusion node reads it scalar-last. Nothing in the system objects, because the norm is unchanged.

Observable symptom: the orientation estimate is wrong by a large, oddly stable angle — usually somewhere between 120° and 180° — and it moves in the wrong axis when the robot rotates. Because the error is large and consistent, people assume a mounting-orientation mistake and start editing the URDF, which produces a second, compensating bug.

The metric that reveals it: log the scalar component of every quaternion crossing a boundary, and compare it against the physically expected angle:

|w| = |cos(θ/2)|  ⇒  a 5° rotation has |w| = 0.999, a 90° rotation has |w| = 0.707, a 180° rotation has |w| = 0

If a transform you know to be a few degrees reports |w| ≈ 0.04 while one of the vector components is 0.999, the order is swapped. This is a one-line assertion and it belongs in your driver.

Three assertions that make quaternion bugs loud instead of silent.
1. assert abs(norm(q) - 1) < 1e-6 — catches un-normalised output from an optimiser or a lerp, but not the order swap.
2. assert abs(q.w) > 0.9 on any transform you know to be a small mounting rotation — catches the order swap.
3. assert dot(q_prev, q_now) > 0 between consecutive samples of a smoothly moving body — catches the sign flip that Chapter 4 is about.
Three lines, and they cover the three ways a quaternion goes wrong in production.

Second failure mode: the un-normalised quaternion from a naive average. Someone averages N orientation estimates by taking the componentwise mean and normalising. If the estimates are all in the same hemisphere it is a decent approximation (it is the chordal L2 mean). If any of them arrived sign-flipped, the mean is pulled toward zero and the normalisation amplifies noise catastrophically. Symptom: the averaged orientation is wildly wrong, and only sometimes — it depends on which sign the upstream happened to publish. Metric: the norm of the sum before normalisation. For N nearby unit quaternions it should be close to N; if it is close to 0, you have a hemisphere problem. That is exactly the assertion in the Code Lab above.

FRONTIER — rotations that a neural network can actually regress

Something genuinely new happened here recently, and it is a great answer to "what's changing?"

When a network predicts an orientation, the natural instinct is to output a quaternion (4 numbers) or an Euler triple (3 numbers) and train with an L2 loss. Both train badly, and for a long time people blamed the optimiser. The real reason is topological:

How to use this in a real design. For anything a human or a message bus touches, quaternions — compact, no singularity, correct interpolation. For anything a network outputs, the 6D or 9D representation from Zhou 2019 / Levinson 2020, because sub-5D representations are provably discontinuous and the network cannot fit a discontinuity. Then project to SO(3) once at the boundary and go back to quaternions for storage. That rule connects topology, training dynamics and system design in three sentences.

PRACTICE — the three drills this chapter is actually preparing you for

Reading this chapter is not the practice. Performing it is. Each drill below has a time box, the answer, and the habit the drill is really training — which is rarely the answer itself.

Drill 1 — "Write the quaternion-to-matrix conversion on the board." (90 seconds.)

Do it in this order, out loud. (a) "The vector part is v = (x, y, z); let me write its skew matrix first, because that is where all the off-diagonal signs live" — then write [v]× = [[0, −z, y], [z, 0, −x], [−y, x, 0]]. (b) "R is three terms: (w² − ‖v‖²)I + 2vv + 2w[v]× — isotropic, along-axis, and the turn direction." (c) Expand into the nine entries, reading each w-sign off the skew matrix rather than reciting it. (d) Sanity-check one entry by plugging in a 90° yaw.

What the drill trains: writing the skew matrix first instead of guessing signs. Guessing is the tell that you memorised the table; two-thirds of the guesses will even come out right, which is worse, because then you defend the wrong one. The other habit: checking your own answer unprompted at step (d) — the mark of an engineer who tests their code.

Drill 2 — "Implement quaternion multiply, then rotate a vector with it." (5 minutes, no libraries.)

The whole thing is ten lines and you have already seen them: quat_mul is (w1*w2 - v1@v2, w1*v2 + w2*v1 + cross(v1,v2)) and rotate is the sandwich in its cheap algebraic form, t = 2*cross(u,v); return v + w*t + cross(u,t). Narrate three things while typing: (1) "scalar-first ordering, I will assert that at the boundary"; (2) "the cross term is why this does not commute, which is why rotation order matters"; (3) "the sandwich form avoids building the matrix — about 30 flops versus 15 for a matrix rotate, so this is the right call for one vector and the wrong call for a hundred thousand."

What the drill trains: keeping the flop comparison attached to the code. Anyone can write the sandwich. Knowing when not to use it is the harder skill, and it costs you one sentence.

Drill 3 — "Orientation is wrong by a stable 175°. Ten minutes to a hypothesis." (Say the metric, not the guess.)

Large, stable, wrong-axis says convention, not calibration — calibration errors are small and drift. Log |w| on every quaternion crossing a node boundary and compare it to the physically expected angle, since |w| = |cos(θ/2)|. A mount you know to be a few degrees must have |w| > 0.99. If |w| is near zero and a vector component is near one, the component order was swapped at a flatten site. If instead everything looks individually sane but a composed chain is wrong, compose two known non-commuting rotations and check against a hard-coded reference — that separates Hamilton from JPL.

What the drill trains: naming a measurement before a fix. Engineers who lead with "try swapping the order and see" are describing guess-and-check; engineers who lead with a logged quantity and its expected value are describing debugging. The distinction is the whole craft.

Numbers drill — answer each to one significant figure, from memory, in under ten seconds. Order-of-magnitude fluency is what lets you push back on a bad design in real time instead of a week later.
QuestionAnswerWhere it came from
|w| for a 5° rotation0.999cos(2.5°)
|w| for a 90° rotation0.7cos(45°)
|w| for a 180° rotation0cos(90°) — and this is why Shepperd exists
Bytes for a 120k-point float32 xyz cloud1.4 MB120000 × 3 × 4
… at 10 Hz, per subscriber14 MB/sCopy it three times and you have saturated a gigabit link
Flops to rotate that cloud by a matrix2 Mflop120000 × 15
… and the wall time on one core0.2 msBandwidth-bound at 2.9 MB moved, not flop-bound
Quaternion vs matrix, float6432 B vs 72 B4×8 vs 9×8 — a 2.25× saving on every stored pose
Attitude samples spanning a 10 Hz LiDAR sweep at 200 Hz200.1 s ÷ 0.005 s — the de-warp bracket count
Your team's 6-DOF pose network outputs a unit quaternion and the rotation error plateaus at about 12° no matter how much data you add. Architecture and losses look fine. What is the most likely structural cause?

Chapter 4: Gimbal Lock, Double Cover, and Choosing a Representation

A pan–tilt camera mount is tracking a drone that is climbing steeply, almost straight up over the operator. At roughly 80° of tilt the picture starts to trail the target. At 87° the head visibly sticks — the drone slides out of the top of the frame and stays out — and then the whole mount whips around and reacquires from the other side. The operator attaches the video to a ticket titled "gimbal goes crazy overhead."

You pull the log. The tilt channel is smooth the whole way through. The pan channel is not: in the two seconds around the stick, commanded pan rate is pinned at the motor limit, and the recorded yaw angle is jumping between values several hundred degrees apart.

Two completely different bugs produce that video, and they produce it on the same hardware. One is a genuine rank deficiency in the map from Euler rates to body rates — a mathematical singularity that no gain, no filter and no faster loop can remove. The other is a sign flip in a quaternion crossing a library boundary, and the entire fix is one dot product. The first costs a week of estimator surgery. The second costs one line. Reaching for the wrong one costs you the week and leaves the bug in the field.

So this chapter does three things. It derives both failures from scratch, so you can rebuild either from a blank page. It hands you the scalars that separate them in a single glance at a plot. And — picking up Chapter 3's claim that every representation of SO(3) pays a price — it turns those two prices into a defensible, per-boundary choice you can argue for out loud.

CONCEPT — gimbal lock is a rank collapse, not a mysterious glitch

Most explanations of gimbal lock show three nested rings and say "look, two of them line up". True, and unsatisfying, because it does not tell you what breaks in your code or how to detect it. Here is the version that does.

Euler angles say the orientation is R = Rz(ψ)Ry(θ)Rx(φ) — yaw, then pitch, then roll, each about the already-rotated body axis. Now differentiate. The body's angular velocity ω is not (φ̇, θ̇, ψ̇), because those three rates are about three different, mutually rotated axes. Collect them:

Stack those three as columns and you have the Euler-rate Jacobian E, the matrix that turns Euler rates into body rates:

ωbody = E(φ, θ) [φ̇; θ̇; ψ̇],    E = [[1, 0, −sinθ], [0, cosφ, cosθ sinφ], [0, −sinφ, cosθ cosφ]]

Now take its determinant. Expand along the first column (only the top entry is non-zero):

det E = 1 · (cosφ · cosθcosφ − cosθsinφ · (−sinφ)) = cosθ(cos²φ + sin²φ) = cosθ
That is gimbal lock, in one symbol. The determinant of the Euler-rate Jacobian is exactly cos(pitch). It has nothing to do with roll or yaw. At pitch = ±90°, det E = 0, the matrix loses a rank, and the map from Euler rates to body rates is no longer invertible: there exist body motions you cannot produce with any finite Euler rates. Not "hard to produce" — cannot. That is the whole phenomenon, and being able to derive it in four lines is a distinctly better answer than the three-rings story.
Worked example 1 — the blow-up, with real numbers.

Take φ = 0 for clarity. Then E = [[1, 0, −sinθ], [0, 1, 0], [0, 0, cosθ]], and the inverse is easy to write by inspection: from the third row, ψ̇ = r / cosθ; from the second, θ̇ = q; from the first, φ̇ = p + ψ̇ sinθ = p + r tanθ.

Suppose you want a modest body yaw rate r = 0.1 rad/s (5.7°/s). How much Euler yaw rate does that require?

  pitch = 0°:  ψ̇ = 0.1 / cos 0° = 0.1 / 1.00000 = 0.100 rad/s   (5.7°/s — sensible)
  pitch = 60°: ψ̇ = 0.1 / cos 60° = 0.1 / 0.50000 = 0.200 rad/s   (11.5°/s)
  pitch = 85°: ψ̇ = 0.1 / cos 85° = 0.1 / 0.08716 = 1.147 rad/s   (65.7°/s)
  pitch = 89°: ψ̇ = 0.1 / cos 89° = 0.1 / 0.01745 = 5.730 rad/s   (328°/s)
  pitch = 90°: ψ̇ = 0.1 / 0 =   (in float64, this is inf, and one line later it is nan)

And roll picks up the same blow-up through tanθ. Row 1 of that inverse is φ̇ = p + r tanθ, so give the gyro a body roll rate as well — p = 0.1 rad/s alongside the same r = 0.1 rad/s — and read both channels at 89°, where tan 89° = 57.2900 and 1/cos 89° = 57.2987:

  φ̇ = 0.1 + 0.1 × 57.2900 = 0.1 + 5.7290 = 5.829 rad/s   (334°/s)
  ψ̇ = 0.1 × 57.2987 = 5.730 rad/s   (328°/s)

Both roll and yaw rates run away together, in the SAME direction and at nearly the same magnitude. But their difference, 5.829 − 5.730 = 0.099, is still just p — the small, bounded gyro number you started from. Hold on to that; it is the whole point of the next two sections, where the same difference is tabulated at five pitches and walks calmly down to p while both columns sprint past 57 rad/s.

One bookkeeping note, because two numbers above look like they disagree. The list of ψ̇ values was computed with p left at zero, so at 89° it also reported 5.730 for φ̇ if you carried it through; here φ̇ reads 5.829. Both are right. ψ̇ = r/cosθ never contains p at all, while φ̇ = p + r tanθ carries it as a bare additive term, so switching p from 0 to 0.1 moves φ̇ by exactly 0.1 and moves ψ̇ by exactly nothing. That 0.1 offset is the difference that survives, which is why it is worth being fussy about here rather than discovering the discrepancy in the table.

A pan–tilt gimbal motor rated for 120°/s hits its limit somewhere around 87° of tilt and simply stops tracking. The operator sees the camera "stick" as the target passes overhead. That is the observable symptom, and now you can predict the exact tilt angle at which it starts.

CONCEPT — the inverse in general, all nine entries

Worked example 1 set φ = 0 to keep the arithmetic readable, and that is the right first move. But it hides where the damage actually lands, because at φ = 0 the second and third rows of E happen to decouple in a way they do not in general. If you are going to claim gimbal lock is a rank collapse, you should be able to write down the inverse for arbitrary roll and point at the singular entries. It is four lines of algebra.

Write out the three scalar equations that ω = E [φ̇; θ̇; ψ̇] actually says, with ω = (p, q, r):

p = φ̇ − sinθ · ψ̇   ‖   q = cosφ · θ̇ + cosθ sinφ · ψ̇   ‖   r = −sinφ · θ̇ + cosθ cosφ · ψ̇

Rows 2 and 3 contain only θ̇ and ψ̇, so that 2×2 block solves on its own. Do not reach for Cramer's rule — the block is a scaled planar rotation by φ, so the clean move is to take the two combinations that annihilate one unknown at a time. Each one collapses through sin² + cos² = 1.

Combination A — multiply row 2 by sinφ, row 3 by cosφ, and add:

sinφ · q + cosφ · r = (sinφcosφ − cosφsinφ)θ̇ + cosθ(sin²φ + cos²φ)ψ̇ = 0 · θ̇ + cosθ · ψ̇

The θ̇ coefficient cancels identically — sinφcosφ minus itself — and the ψ̇ coefficient collapses to a bare cosθ. Divide:

ψ̇ = (sinφ · q + cosφ · r) / cosθ

Combination B — multiply row 2 by cosφ, row 3 by −sinφ, and add:

cosφ · q − sinφ · r = (cos²φ + sin²φ)θ̇ + cosθ(sinφcosφ − cosφsinφ)ψ̇ = θ̇ + 0 · ψ̇

This time it is the ψ̇ terms that cancel, and the answer falls out with no θ in it anywhere: θ̇ = cosφ · q − sinφ · r. Hold on to that; it is not a coincidence and we will meet it again from a completely different direction in two sections' time.

Back-substitute into row 1. Rearranged, row 1 says φ̇ = p + sinθ · ψ̇. Substitute the ψ̇ we just found, and sinθ/cosθ becomes tanθ:

φ̇ = p + tanθ (sinφ · q + cosφ · r)

Stack the three results as rows and every entry of the inverse is now visible:

E−1 = [[1,  tanθ sinφ,  tanθ cosφ], [0,  cosφ,  −sinφ], [0,  sinφ/cosθ,  cosφ/cosθ]]

Nine entries, and the pitch angle is distributed across them in a very specific pattern. Read the rows:

RowEntriesHow θ entersAs θ → 90°
φ̇ — roll rate1,  tanθ sinφ,  tanθ cosφtanθ in two of three entriesdiverges like 1/cosθ
θ̇ — pitch rate0,  cosφ,  −sinφnone at allbounded, unit-norm, always exact
ψ̇ — yaw rate0,  sinφ/cosθ,  cosφ/cosθsecθ in two of three entriesdiverges like 1/cosθ

That middle row is the sentence most people cannot produce under pressure. Pitch rate is never lost. The pitch-rate row of E−1 is a plain unit vector rotated by roll — it has no cosθ in the denominator, so it is perfectly conditioned at every pitch including exactly 90°. Gimbal lock does not destroy your ability to read pitch rate; it destroys your ability to separate roll rate from yaw rate. Saying "you lose a degree of freedom" is vague. Saying "you lose the roll/yaw split, and pitch rate is untouched" is the answer that shows you did the algebra.

Worked example 2 — the general inverse at a real attitude, entry by entry.

Take a mount at roll φ = 30°, pitch θ = 85°, reading a gyro that reports body rates (p, q, r) = (0.100, 0.200, 0.100) rad/s. Build E−1 numerically first.

  tan 85° = 11.43005  ·  1/cos 85° = 1/0.0871557 = 11.47371
  sin 30° = 0.50000  ·  cos 30° = 0.86603

Row 1  [1,  11.43005×0.50000,  11.43005×0.86603] = [1,  5.71503,  9.89872]
Row 2  [0,  0.86603,  −0.50000]   ← no pitch anywhere
Row 3  [0,  0.50000×11.47371,  0.86603×11.47371] = [0,  5.73686,  9.93653]

Now apply it to the gyro reading:

  φ̇ = 1×0.100 + 5.71503×0.200 + 9.89872×0.100 = 0.100 + 1.14301 + 0.98987 = 2.23288 rad/s (127.9°/s)
  θ̇ = 0×0.100 + 0.86603×0.200 − 0.50000×0.100 = 0.17321 − 0.05000 = 0.12321 rad/s (7.1°/s)
  ψ̇ = 0×0.100 + 5.73686×0.200 + 9.93653×0.100 = 1.14737 + 0.99365 = 2.14102 rad/s (122.7°/s)

A gyro reading a gentle 0.2 rad/s of body pitch and 0.1 rad/s each of body roll and yaw — nothing a human would call fast — demands 128°/s of Euler roll and 123°/s of Euler yaw from a mount whose motors top out near 120°/s. The pitch channel, meanwhile, is asking for a placid 7.1°/s. Two of your three actuators saturate and the third is idle. That is exactly what the operator sees as "sticking."
The two divergences are locked together, and that is the whole insight.

Look again at row 1 in its unsubstituted form: φ̇ = p + sinθ · ψ̇. Rearrange it and you get an identity that holds at every pitch, singular or not:

φ̇ − sinθ · ψ̇ = p
Check it on the numbers above: 2.23288 − 0.996195×2.14102 = 2.23288 − 2.13288 = 0.10000 = p. Exact.

So the two blowing-up quantities are not blowing up independently — they are chained by a relation whose right-hand side is a bounded gyro reading. As θ → 90°, sinθ → 1, and the combination that stays finite becomes the plain difference φ̇ − ψ̇. Watch it converge (roll = 0, ω = (0.1, 0, 0.1)):

pitchφ̇ (rad/s)ψ̇ (rad/s)φ̇ − ψ̇
0.10000.10000.00000
60°0.27320.20000.07321
85°1.24301.14740.09563
89°5.82905.72990.09913
89.9°57.395757.29580.09991 → p
Both columns run away to 57 rad/s; their difference walks calmly to 0.1. The singularity is not in the motion, it is in the coordinates. The body is doing something perfectly ordinary; you chose a chart in which two of the three coordinates must sprint in near-lockstep to describe it.

The conditioning view, which is the one you should actually monitor. The determinant is a blunt instrument. The honest measure is the smallest singular value of E, because that is what controls how much the inverse amplifies:

max over all unit body rates ω of  ‖E−1ω‖  =  1 / σmin(E)

That quantity has a name — the operator norm of E−1, the worst stretch it can apply to any input — and it drops out of the SVD in two lines. Write the singular value decomposition E = UΣV, where U and V are orthogonal and Σ = diag(σmax, σmid, σmin). Inverting a product reverses it and inverts each factor, and the inverse of an orthogonal matrix is its transpose:

E−1 = (UΣV)−1 = (V)−1Σ−1U−1 = VΣ−1U,    Σ−1 = diag(1/σmax, 1/σmid, 1/σmin)

Now read the lengths off. U is orthogonal, so it rotates ω without changing ‖ω‖. V is orthogonal, so it rotates the result without changing its length either. Every bit of stretching lives in the diagonal Σ−1 in the middle, and the largest stretch a diagonal matrix can apply is its largest entry. Since σmin is the smallest singular value, 1/σmin is the largest entry of Σ−1. Hence the boxed identity, and it is attained, not merely bounded: feed in the particular unit ω that U maps onto the third coordinate axis and you get exactly 1/σmin out.

Put units on it and it stops being abstract. One radian per second of body rate goes in; up to 1/σmin radians per second of Euler rate is demanded out. At pitch = 85°, where we are about to derive σmin = √(1 − sin 85°) = √0.0038053 = 0.061687:

1 / σmin = 1 / 0.061687 = 16.21  —  one unit of body rate can demand 16.21 units of Euler rate

That 16.21 is the number the motors have to survive, and it is the number quoted a few paragraphs below when we decide what to log. Keep it; we will exhibit the exact body-rate direction that attains it once the closed form is in hand.

But a number you are told to monitor and cannot compute is a number you will not monitor. So derive the closed form — it is shorter than the determinant was.

Singular values of E are the square roots of the eigenvalues of the Gram matrix G = EE, and the Gram matrix is just the table of dot products between E's columns. We already know those columns: they are the three rotation axes written in body coordinates. Write s = sinθ, c = cosθ, and take c1 = (1, 0, 0), c2 = (0, cosφ, −sinφ), c3 = (−s, c sinφ, c cosφ). Six dot products and you are done:

So the Gram matrix, for any roll whatsoever, is

G = EE = [[1, 0, −sinθ], [0, 1, 0], [−sinθ, 0, 1]]

Read the structure off it. The middle basis vector e2 = (0, 1, 0) has zeros above and below it in both directions, so G e2 = e2: it is already an eigenvector, with eigenvalue exactly 1. That accounts for one singular value and it never moves. What is left is the 2×2 block sitting in the (1, 3) corners:

[[1, −s], [−s, 1]]  →  eigenvectors (1, −1)/√2 and (1, 1)/√2,   eigenvalues 1 + s and 1 − s

A symmetric matrix of the form aI + b(off-diagonal swap) always has that pair — (1, 1) and (1, −1) are its eigenvectors and a ± b are its eigenvalues, which you can verify in one line by multiplying. Take square roots and you have the whole spectrum:

σmin(E) = √(1 − sinθ),    σmid(E) = 1,    σmax(E) = √(1 + sinθ),    cond(E) = √((1 + sinθ)/(1 − sinθ))
Two consistency checks, both free.

(1) The determinant must be the product of the singular values. √(1+s) · 1 · √(1−s) = √(1 − s²) = √(cos²θ) = |cosθ|. That is exactly the det E = cosθ we derived two sections ago, by a completely different route. The two derivations agree, so neither is a typo.

(2) The unit singular value is the pitch channel. We found earlier that the θ̇ row of E−1 is [0, cosφ, −sinφ] — unit norm, no θ anywhere. Here the same fact reappears as σmid = 1 for all θ. The direction that survives gimbal lock intact is the pitch direction, and you can now show that from either the inverse or the spectrum.

Reproduce one row of the table by hand. Take pitch θ = 60°, where sin 60° = 0.86603:

σmin = √(1 − 0.86603) = √0.13397 = 0.36603,    σmax = √(1 + 0.86603) = √1.86603 = 1.36603
cond(E) = 1.36603 / 0.36603 = 3.7321

Those are the 60° entries of the table below, produced in two square roots and a division. Run the same two lines at 85° (sin = 0.99619): √0.00381 = 0.06169 and √1.99619 / 0.06169 = 22.90. At 89.9° (sin = 0.9999985): √0.0000015 = 0.00123 and cond = 1145.9. Every number in the table is now yours to regenerate from scratch.

And now the payoff of that c2·c3 = 0 line. Roll dropped out of the Gram matrix completely — not approximately, identically. So σmin, σmax and cond(E) are functions of pitch alone, exactly like det E. That is why the table below has no roll column and does not need one. (Verify it numerically if you distrust the algebra: at roll = 0°, pitch = 85°, the singular values are (1.412868, 1.000000, 0.061687); at roll = 30°, same pitch, they are (1.412868, 1.000000, 0.061687) to every digit numpy will print.) This is a stronger statement than the determinant version and it is worth making explicitly, because the natural assumption — "surely conditioning depends on the whole attitude" — is wrong here, and being able to say why it is wrong is one dot product: the pitch axis and the yaw axis stay orthogonal at every roll.

One practical note before the table. Because σmax = √(1 + sinθ) is trapped between 1 and √2, the condition number is never more than √2 times 1/σmin. The two right-hand columns therefore carry the same information, and you should log σmin rather than cond(E), because σmin is the number with a physical reading: 1/σmin is the worst-case amplification over all directions of body motion, so σmin = 0.0617 at 85° means "some unit of body rate will demand 16.2 units of Euler rate." That is the bound the motors have to survive. The per-channel figure 1/cosθ = 11.5, which is what the yaw row of E−1 costs, is the typical case — smaller, and the reason a system can look healthy in one axis right up until the unlucky direction shows up.

"Some unit of body rate" is doing a lot of unpaid work in that sentence. A direction you cannot name is a direction you cannot check, so name it.

The unlucky direction, exhibited — where 16.21 actually comes from.

Step 0 — which vector are we even looking for? This is the step people skip, and it is the one that decides whether the arithmetic means anything. The Gram matrix G = EE acts on the Euler-rate side of E, so its eigenvectors are the right singular vectors v — the Euler-rate combinations that E squashes. The direction we want is a body rate, and body rates live on the output side, so we want the matching left singular vector u. They are related by the defining equation of the SVD, E v = σ u, so one matrix–vector product converts one into the other. Take a body rate that is not a left singular vector and you will get an amplification, but not the worst one — we will do exactly that at the end, deliberately, so you can see the gap.

Work at φ = 0, θ = 85°, so E = [[1, 0, −0.996195], [0, 1, 0], [0, 0, 0.087156]], with s = sin 85° = 0.996195, c = cos 85° = 0.087156, tan 85° = 11.43005, and σmin = √(1 − s) = √0.003805 = 0.061687.

Step 1 — the Euler-rate direction that gets squashed. From the 2×2 block [[1, −s], [−s, 1]] two sections up, the eigenvector for the small eigenvalue 1 − s is (1, 1)/√2 in the (1, 3) coordinates, so in full

  vmin = (1, 0, 1)/√2 = (0.707107, 0, 0.707107)   ← equal roll rate and yaw rate, same sign

Step 2 — push it through E to get the body rate. Three dot products with E's rows:

  row 1: 1(0.707107) + 0(0) − 0.996195(0.707107) = 0.707107 − 0.704416 = 0.002691
  row 2: 0 + 1(0) + 0 = 0
  row 3: 0 + 0 + 0.087156(0.707107) = 0.061629

Its length is √(0.002691² + 0.061629²) = √(0.00000724 + 0.00379813) = √0.00380537 = 0.061687. That is σmin to six figures, which is the check that we picked the right v: E really does shrink this direction by 0.061687 and nothing shrinks more. Divide by it to get the unit body rate:

ωworst = (0.002691, 0, 0.061629) / 0.061687 = (0.043619, 0, 0.999048)
Almost pure body-z rate, tipped 2.5° toward body x. Not an exotic manoeuvre — a gimbal yawing while nearly vertical does this by accident.

Step 3 — run it back through E−1 by hand. With (p, q, r) = (0.043619, 0, 0.999048) and φ = 0, the three rows of the inverse we derived earlier give

  ψ̇ = r / cosθ = 0.999048 / 0.087156 = 11.46279 rad/s
  θ̇ = q = 0
  φ̇ = p + r tanθ = 0.043619 + 0.999048 × 11.43005 = 0.043619 + 11.41918 = 11.46279 rad/s

  ‖(φ̇, θ̇, ψ̇)‖ = √(131.396 + 0 + 131.396) = √262.792 = 16.2108

Which is 1/σmin = 1/0.061687 = 16.2108. The claim is now a computation, not an assertion, and you can regenerate it with two divisions and a square root.

Step 4 — the part worth saying out loud. Look at what came out: φ̇ and ψ̇ are identical to five decimals. The most expensive body motion is precisely the one that asks roll and yaw to run in lockstep — which is exactly the combination the identity φ̇ − sinθ·ψ̇ = p told us the body barely notices. The body is doing 1 rad/s. The chart is doing 16.2. The worst-case direction is not some pathological input; it is the direction in which the coordinates are lying hardest.

Step 5 — the closed form, so you never have to run an SVD to find it. Redo Steps 2–3 symbolically: E vmin = (1 − s, 0, c)/√2, whose norm is √((1−s)² + c²)/√2 = √(1 − 2s + s² + c²)/√2 = √(2 − 2s)/√2 = √(1−s). Dividing, and using c² = (1−s)(1+s):

ωworst = ( √((1 − sinθ)/2),  0,  √((1 + sinθ)/2) ) = ( sin(45° − θ/2),  0,  cos(45° − θ/2) )
The unlucky direction sits (45° − θ/2) away from the body z-axis, in the body x–z plane. Check it at θ = 85°: 45 − 42.5 = 2.5°, and sin 2.5° = 0.043619, cos 2.5° = 0.999048 — the vector from Step 2, exactly. One angle, no linear algebra:

pitch θunlucky direction, off body zσmin1/σmin
45.0°1.000001.00
30°30.0°0.707111.41
60°15.0°0.366032.73
85°2.5°0.0616916.21
89°0.5°0.0123481.03
89.9°0.05°0.00123810.28
As pitch climbs, the unlucky direction slides onto the body z-axis. That is the operational reading: near lock, a plain body yaw rate is already essentially the worst case, so you do not get to hope the robot avoids it.

(Roll does not change any of this.) E(φ, θ) = Rx(−φ) E(0, θ) — multiply it out and compare columns — and left-multiplying by an orthogonal matrix rotates the left singular vectors while leaving every singular value alone. So at roll φ the unlucky direction is the vector above rotated by −φ about body x, and 1/σmin is unchanged. Same one-dot-product reason as before.
Three reference numbers, side by side, so 16.21 is not taken on faith. All at φ = 0, θ = 85°, all unit body rates, all computed with the same two-line inverse:

ω‖E−1ω‖(φ̇, θ̇, ψ̇)note
(0, 1, 0)1.000(0, 1, 0)pure body pitch
(1, 0, 0)1.000(1, 0, 0)pure body roll
(0, 0, 1)16.195(11.43, 0, 11.47)pure body yaw
(0.0436, 0, 0.9990)16.211(11.46, 0, 11.46)ωworst
(0.7071, 0, 0.7071)11.961(8.79, 0, 8.11)Gram vector
(0.7071, 0, −0.7071)10.964(−7.38, 0, −8.11)Gram vector
Read it row by row. The first two are the lucky directions and they cost exactly nothing: σmid = 1 handles body pitch, and column 1 of E−1 is the bare (1, 0, 0), so body roll passes through untouched — at every pitch, including exactly 90°. Row 3 is the obvious bad case, a plain body yaw, and it already sits within 0.1% of the worst. Row 4 is ωworst from Step 2, and its 16.211 is 1/σmin.

Rows 5 and 6 are why Step 0 matters. Both are eigenvectors of G, both are perfectly reasonable-looking unit vectors, and both under-report the amplification — by 26% and 32% respectively. A worst-case bound you compute on the wrong side of the SVD is not a bound.

Notice too what the whole table says about the shape of the problem. E has three singular directions, and the amplification along them is 1/σmax = 1/1.4129 = 0.708, 1/σmid = 1.000, and 1/σmin = 16.211. Two are benign; one is not. Gimbal lock is not a general degradation of the chart — it is one bad direction getting worse and worse while the other two stay pristine. Average the three and you get √((0.708² + 1² + 16.211²)/3) = √88.10 = 9.39, a number that is wrong about every direction and hides the structure completely. The maximum is the only summary that tells you what the motors will actually be asked for, and that is precisely why the metric to log is σmin and not a mean.

And the exact relation between the two figures in the paragraph above. Divide: (1/σmin) / (1/cosθ) = cosθ/√(1−s) = √((1−s)(1+s))/√(1−s) = √(1 + sinθ). So

1/σmin = √(1 + sinθ) · (1/cosθ)  ≤  √2 · (1/cosθ)
Check it: 11.47371 × √1.996195 = 11.47371 × 1.412868 = 16.2108. So the worst case is never more than 41% above the per-channel 1/cosθ you were already watching — a comforting bound, and the reason logging either one catches the event. Log σmin anyway: it is the one that stays meaningful at exactly 90°, where 1/cosθ is not a number at all.
python — reproduce every number above in five lines
import numpy as np
th = np.deg2rad(85.0)
E  = np.array([[1, 0, -np.sin(th)], [0, 1, 0], [0, 0, np.cos(th)]])
U, S, Vt = np.linalg.svd(E)
w_worst = U[:, -1] * np.sign(U[2, -1])     # LEFT singular vector = a BODY rate
print(S[-1], np.sqrt(1 - np.sin(th)))       # 0.061687 0.061687  -- the closed form
print(w_worst)                              # [0.043619 0. 0.999048]
print(np.linalg.norm(np.linalg.inv(E) @ w_worst), 1 / S[-1])  # 16.2108 16.2108

Note the U[:, -1] in line 5 and not Vt[-1]. That one-character-class difference is the whole of Step 0, and it is exactly the kind of slip you catch by plugging the vector back in — which is what the last line does. Always close the loop: compute the direction, then apply the inverse to it, then confirm the norm matches 1/σmin. If it does not, you took the wrong singular vector.

pitchdet E = cosθσmin(E)cond(E)What you see
1.000001.000001.0nothing wrong
60°0.500000.366033.7rates a bit lively; still fine
85°0.087160.0616922.9rate commands 11× larger than the motion
89°0.017450.01234114.6motors saturate, tracking visibly lags
89.9°0.001750.001231145.9numerically dead; yaw is meaningless noise
90°00rank 2. Only one combination of yaw and roll is defined

What "rank 2" actually means at the singularity. At pitch = +90°, plug three different (yaw, roll) pairs with the same difference into R = Rz(ψ)Ry(90°)Rx(φ) — say (ψ, φ) = (0.3, 0.0), (0.5, 0.2) and (0.9, 0.6). All three produce the identical matrix:

R = [[0, −0.2955, 0.9553], [0, 0.9553, 0.2955], [−1, 0, 0]]

That claim is worth more than your trust in it, so here is the algebra that produces it. It is the single most convincing thirty seconds you can spend on this topic, because it turns "you lose a degree of freedom" from a slogan into a matrix identity.

Worked example 3 — watching yaw and roll fuse, symbolically then numerically.

Step 1 — the pitch block at exactly 90°. With cos 90° = 0 and sin 90° = 1, the general Ry(θ) = [[cosθ, 0, sinθ], [0, 1, 0], [−sinθ, 0, cosθ]] collapses to

  Ry(90°) = [[0, 0, 1], [0, 1, 0], [−1, 0, 0]]

Every entry is 0 or ±1. The body x-axis has been laid onto −world z, and world x onto body z. That is the geometric reason the yaw ring and the roll ring have become the same ring.

Step 2 — left-multiply by the yaw. Write c = cosψ, s = sinψ, so Rz(ψ) = [[c, −s, 0], [s, c, 0], [0, 0, 1]]. The columns of Ry(90°) are (0, 0, −1), (0, 1, 0), (1, 0, 0). Take the nine dot products:

  row 1 = (c, −s, 0):  ·(0,0,−1) = 0;  ·(0,1,0) = −s;  ·(1,0,0) = c
  row 2 = (s, c, 0):  ·(0,0,−1) = 0;  ·(0,1,0) = c;  ·(1,0,0) = s
  row 3 = (0, 0, 1):  ·(0,0,−1) = −1;  ·(0,1,0) = 0;  ·(1,0,0) = 0

  Rz(ψ)Ry(90°) = [[0, −sinψ, cosψ], [0, cosψ, sinψ], [−1, 0, 0]]

Notice the first column is already frozen at (0, 0, −1) — yaw cannot touch it, because yaw spins about the axis that column now points along. That frozen column is the rank deficiency, visible before roll even enters.

Step 3 — right-multiply by the roll. Rx(φ) = [[1, 0, 0], [0, cosφ, −sinφ], [0, sinφ, cosφ]], whose columns are (1, 0, 0), (0, cosφ, sinφ), (0, −sinφ, cosφ). Multiply the Step-2 matrix by each:

New column 1 — against (1, 0, 0), each row keeps only its first entry: (0, 0, −1). Unchanged. Roll cannot touch it either.

New column 2 — against (0, cosφ, sinφ):
  row 1: (−sinψ)(cosφ) + (cosψ)(sinφ) = −(sinψcosφ − cosψsinφ) = −sin(ψ − φ)
  row 2: (cosψ)(cosφ) + (sinψ)(sinφ) = cos(ψ − φ)
  row 3: 0·cosφ + 0·sinφ = 0

New column 3 — against (0, −sinφ, cosφ):
  row 1: (−sinψ)(−sinφ) + (cosψ)(cosφ) = cos(ψ − φ)
  row 2: (cosψ)(−sinφ) + (sinψ)(cosφ) = sin(ψ − φ)
  row 3: 0

Every surviving entry went through the angle-difference identities — sinψcosφ − cosψsinφ = sin(ψ−φ) and cosψcosφ + sinψsinφ = cos(ψ−φ). That is the whole mechanism. Assemble:

R = [[0, −sin(ψ−φ), cos(ψ−φ)], [0, cos(ψ−φ), sin(ψ−φ)], [−1, 0, 0]]
Two free parameters went in. One came out. Nowhere in those nine entries do ψ and φ appear except inside the single combination ψ − φ.

Step 4 — the numbers, twice. Both requested pairs have ψ − φ = 0.3, and sin(0.3) = 0.29552, cos(0.3) = 0.95534.

  (ψ, φ) = (0.3, 0.0): ψ − φ = 0.3 − 0.0 = 0.3 → [[0, −0.29552, 0.95534], [0, 0.95534, 0.29552], [−1, 0, 0]]
  (ψ, φ) = (0.9, 0.6): ψ − φ = 0.9 − 0.6 = 0.3 → [[0, −0.29552, 0.95534], [0, 0.95534, 0.29552], [−1, 0, 0]]

Entry for entry, to every decimal place. And (0.5, 0.2) gives 0.3 as well, so it lands on the same matrix. An estimator reporting ψ = 0.3, φ = 0.0 and one reporting ψ = 51.6°, φ = 34.4° are describing the identical physical orientation. There is no measurement, no sensor and no amount of data that could tell them apart, because they are not different states.

Step 5 — the other pole, where it is the sum. At θ = −90°, cos = 0 and sin = −1, so Ry(−90°) = [[0, 0, −1], [0, 1, 0], [1, 0, 0]] — the two off-diagonal signs have flipped. Running the identical two multiplications, the sign flip turns both subtractions into additions:

R = [[0, −sin(ψ+φ), −cos(ψ+φ)], [0, cos(ψ+φ), −sin(ψ+φ)], [1, 0, 0]]
Check it: (ψ, φ) = (0.3, 0.0), (0.1, 0.2) and (0.5, −0.2) all have ψ + φ = 0.3, and all three produce [[0, −0.29552, −0.95534], [0, 0.95534, −0.29552], [1, 0, 0]]. Top pole: the difference survives. Bottom pole: the sum survives. One free parameter either way — the rank is 2 at both, which is exactly what det E = cosθ predicted, since cos(±90°) = 0.

The rate picture and the angle picture are the same statement. Two sections ago we found that φ̇ and ψ̇ both diverge but φ̇ − ψ̇ walks calmly to p. Here we find that φ and ψ are individually unobservable but ψ − φ is well defined. Those are not two facts. Differentiate the second and you get the first: the one surviving coordinate is ψ − φ, so the one surviving rate is its derivative, and everything orthogonal to it is coordinate noise. If you can state that link out loud, you have understood gimbal lock rather than memorised it.

Only ψ − φ is observable; yaw and roll have collapsed into one degree of freedom. (At pitch = −90° it is ψ + φ instead.) So an estimator that reports Euler angles near vertical is not just noisy — it is reporting two numbers that individually mean nothing, and any controller differencing them is differencing noise.

The discriminator that stops the wrong fix. A yaw trace that jumps by 360° looks like a catastrophe and is usually not gimbal lock — it is the atan2 branch cut at ±180°, which is a plotting artefact, not a physical event. Tell them apart in one glance: gimbal lock has |pitch| near 90°, and yaw and roll swing wildly together while their difference stays smooth. A wrap has pitch nowhere near 90°, and yaw jumps by exactly 360.000°. The fix for the first is to stop using Euler angles; the fix for the second is one unwrapping call in the plotting script. Applying the first fix to the second problem is a week of rewriting an estimator that was never broken.

CONCEPT — the double cover, and the 358° whip

Quaternions have no gimbal lock. Their price is that the map to SO(3) is two-to-one: q and −q are the same rotation. Chapter 3 showed why algebraically (every entry of R is quadratic). The consequence is that the quaternion can be discontinuous while the rotation is perfectly smooth.

This bites hardest in interpolation. Spherical linear interpolation walks the great-circle arc from q1 to q2 on the unit 3-sphere:

slerp(q1, q2, t) = [sin((1−t)Ω) q1 + sin(tΩ) q2] / sinΩ,    cosΩ = q1·q2

The arc length Ω in quaternion space is half the physical rotation angle. Two nearby orientations should have Ω near 0. But if the second quaternion arrived sign-flipped — because a driver renormalised, or an optimiser converged to the other branch, or a message crossed a library boundary — then Ω is near 180° and slerp takes the long way round.

Worked example 4 — the 358° whip, quantified.

A gimbal is rotating slowly about z. Two consecutive samples, 4° and 6° of yaw — a real motion of just .

  q1 = (w, x, y, z) = (cos 2°, 0, 0, sin 2°) = (0.99939, 0, 0, 0.034899)
  q2 = (w, x, y, z) = (cos 3°, 0, 0, sin 3°) = (−0.99863, 0, 0, −0.052336)   ← arrived sign-flipped

The dot product:
  q1·q2 = 0.99939×(−0.99863) + 0 + 0 + 0.034899×(−0.052336)
    = −0.998021 − 0.001827 = −0.999848

The arc: Ω = arccos(−0.999848) = 179.0° in quaternion space, which is 358.0° of physical rotation.

So slerp obediently spins the gimbal 358° the wrong way round to get somewhere 2° away. On a camera mount at a 90°/s rate limit that is a four-second whip in the wrong direction, every time the sign happens to flip.

A note on the four numbers above, since this chapter is about to spend a whole section on exactly this. This example is written scalar-first, (w, x, y, z) — the textbook and Eigen-constructor order. The ROS wire order in the DEBUG section below is scalar-last, (x, y, z, w). The dot product is unchanged either way, because reordering the four terms of a sum of products does not change the sum — as long as BOTH quaternions use the same order. That assumption is exactly what Worked example 6 breaks, and it is why np.dot(q1, q2) below cannot tell you which convention you are in: the very symmetry that makes the sign fix order-agnostic is the symmetry that makes the order bug invisible to it. Two different failures, one function, opposite implications — so label the order at every boundary, including in your own worked examples.

The fix is one line, and it goes before every slerp, every quaternion average, and every quaternion difference:
if np.dot(q1, q2) < 0.0:
    q2 = -q2        # same rotation, near hemisphere
After the flip, q1·q2 = +0.999848, Ω = 1.0°, physical 2.0°. Correct.
The monitor that catches it before a customer does. Publish dot(qk−1, qk) for every consecutive pair of orientation samples in any stream you care about. For a physically continuous body sampled at any sane rate, that number lives just below 1. A negative value is never physical — it would mean the body rotated more than 180° between samples. One scalar, one threshold, and this entire failure class becomes a warning line instead of a field incident.
Gimbal lock, and the rank that disappears

Left: three gimbal rings. Right: det E = cos(pitch) and the smallest singular value, with the current pitch marked. Push the pitch toward 90° and watch the outer and inner rings become coplanar while both curves fall to zero — the picture and the number are the same fact.

pitch θ35°
roll φ20°
 

DESIGN — one representation per boundary, and why

Now the decision all of this machinery exists to serve. Here is a real humanoid stack, boundary by boundary, with the representation and the reason:

BoundaryRepresentationRate / sizeWhy that one
URDF / robot descriptionEuler RPY, fixed axesparsed onceA human writes and reviews it. Mount angles are small and axis-aligned, so lock is unreachable
Wire messages (/tf, odometry, IMU)quaternion (x,y,z,w)32 B, 200 HzCompact, no singularity, and interpolation between samples is well defined
Estimator statenominal quaternion + 3-vector error rotation4 + 3 numbersThe covariance must be 3×3. See the box below — this is the point most implementations miss
Controller error termrotation vector of RdesRact3 numbers, 1 kHzThe error is small by construction, so the rotation-vector singularity at θ=0 never bites — and its magnitude is directly the angle you want to drive to zero
Bulk point-cloud transformmatrix, materialised once72 B, reused 300k times15 flops per point and it vectorises; converting from the quaternion once costs nothing amortised
Operator UI / RViz readoutEuler, degrees10 HzHumans cannot read quaternions. Display only — never round-trip through it
Neural network output head6D or 9D (Chapter 3)24–36 BContinuity. Sub-5D representations are provably discontinuous, and networks cannot fit a jump
Logs and plotsquaternion, plus unwrapped Euler for humansStore the thing that cannot lie; display the thing a human can read, and unwrap it so the branch cut does not look like a fault
The estimator subtlety that separates the serious implementations. Why does the covariance have to be 3×3 when the state carries a 4-number quaternion? Because a unit quaternion lives on a 3-dimensional sphere embedded in 4-space: the unit-norm constraint means the true uncertainty has zero variance in the radial direction. A 4×4 covariance over quaternion components is therefore singular by construction — rank 3 in a 4×4 box — and its inverse, which the filter needs, does not exist. Naive implementations paper over this by adding εI, which silently injects fake uncertainty along a direction that has no physical meaning and slowly corrupts the estimate. The correct construction is the error-state form: keep a nominal quaternion on the manifold, keep a small 3-vector rotation error in the tangent space, put the 3×3 covariance on the error, and fold the error back into the nominal after each update. Every serious VIO and INS does exactly this.

The cost of getting the boundary wrong, in one line each. Euler in a message: your consumer has to know the convention and there are 24 of them. Quaternion in a URDF: nobody can review the file. Matrix on the wire: 72 bytes and it arrives off the manifold after a float32 round trip. Euler in an estimator: the covariance is meaningless near vertical. Quaternion out of a network: the loss has a discontinuity in it. Each of those is a real system somebody shipped.

DESIGN — "there are 24 of them" is a claim you should be able to defend

That line about 24 Euler conventions gets repeated a lot and derived almost never. "Why 24?" deserves a real answer — and the count is a thirty-second construction that also tells you exactly which mistakes are possible.

Build the axis sequence first. You pick three axes in order, with one rule: no axis may immediately repeat, because rotating about z and then about z again is just one rotation about z and you have wasted a slot.

3 × 2 × 2 = 12 axis sequences

Those 12 split cleanly in half, and the split has names you should use:

FamilyShapeThe sixWhere you meet them
Tait–Bryan (third ≠ first)all three axes distinctXYZ, XZY, YXZ, YZX, ZXY, ZYXaerospace, robotics, ROS RPY. "Roll, pitch, yaw" is Tait–Bryan ZYX
Proper Euler (third = first)first axis reusedXYX, XZX, YXY, YZY, ZXZ, ZYZorbital mechanics, crystallography, rigid-body dynamics texts

Then double it. Each sequence can be interpreted two ways: intrinsic (each rotation is about the already-rotated body axis) or extrinsic (every rotation is about the fixed world axis). Same three numbers, same three axis letters, different orientation.

12 × 2 = 24 conventions
The identity that halves your confusion. Intrinsic and extrinsic are not independent worlds — they are reversals of each other:

Rintrinsic XYZ(a, b, c) = Rextrinsic ZYX(c, b, a)
Reverse the axis letters and the angle order and you swap families. This is why SciPy encodes the choice as capitalisation: from_euler('ZYX', ...) is intrinsic, from_euler('zyx', ...) is extrinsic, and 'ZYX' with angles (ψ, θ, φ) equals 'xyz' with angles (φ, θ, ψ). Standard aerospace RPY is both: intrinsic Z–Y–X read yaw-first, or extrinsic x–y–z read roll-first. They are the same matrix Rz(ψ)Ry(θ)Rx(φ), and half the arguments on robotics teams are two people describing it with different words.

Three more doublings that are not in the 24 but bite exactly as hard. Active versus passive (does R rotate the vector, or rotate the frame the vector is expressed in? — they differ by a transpose, which is Chapter 2's whole subject). Degrees versus radians (a 57× error, usually obvious, occasionally not). And quaternion component order, which gets its own section in the DEBUG block below. Counting those, the real space of ways to misread an orientation triple is nearer 200 than 24.

Worked example 5 — what one convention mismatch actually costs, in degrees.

A perception node publishes the pan–tilt mount's orientation as rpy = [30, 40, 50] degrees, meaning intrinsic Z–Y–X: yaw 50°, then pitch 40°, then roll 30°. The consumer's library defaults to intrinsic X–Y–Z and builds the rotation in the order it was given. Nobody's code throws. Here are the two matrices.

What the publisher meant — R = Rz(50°)Ry(40°)Rx(30°):
[[0.4924, −0.4568, 0.7408], [0.5868, 0.8029, 0.1050], [−0.6428, 0.3830, 0.6634]]
What the consumer built — R = Rx(30°)Ry(40°)Rz(50°):
[[0.4924, −0.5868, 0.6428], [0.8700, 0.3105, −0.3830], [0.0252, 0.7478, 0.6634]]
Two entries agree (the corners 0.4924 and 0.6634 are R11 = cosθcosψ and R33 = cosθcosφ, which happen to be order-independent here). Everything else has moved, and one entry has changed sign.

The size of the error. Form Rerr = RZYXRXYZ and read its angle from the trace:
  Rerr = [[0.7368, −0.5875, −0.3347], [0.4832, 0.8038, −0.3471], [0.4729, 0.0940, 0.8761]]
  tr Rerr = 0.7368 + 0.8038 + 0.8761 = 2.4167
  Θ = arccos((2.4167 − 1)/2) = arccos(0.70835) = 44.90°

A 44.9° orientation error from three correct numbers. As a boresight error — where the camera's own x-axis actually points — the two frames are 42.5° apart: (0.4924, 0.5868, −0.6428) versus (0.4924, 0.8700, 0.0252). On a 60°-field-of-view camera the target is not merely mis-registered, it is out of frame.

And the reverse direction is worse, because it looks plausible. Decode that same, correct RZYX with an intrinsic-XYZ decoder and it hands back [−9.00, 47.80, 42.85] degrees instead of [30, 40, 50]. Nothing is NaN, nothing is out of range, the pitch is within 8° of right — and the roll has crossed zero and changed sign. A reviewer scanning the log sees three unremarkable angles. That is why this bug survives code review and dies only on a test range.
The design rules that follow, and they are short. (1) Never put an Euler triple on a wire. The wire carries a quaternion; Euler is a display format, generated at the last possible moment for a human and never read back. (2) If you are forced to — a config file, a URDF, a vendor protocol — the convention goes in the field name, not the documentation. rpy_intrinsic_zyx_deg is ugly and has never once caused an incident; orientation has caused thousands. (3) Ship a round-trip assertion at the boundary: quaternion → Euler → quaternion, and assert abs(dot(q_in, q_out)) > 0.9999. It costs microseconds, it runs on real traffic rather than on a test fixture, and it fires the first time somebody's library default changes under you.

CODE — slerp with the fix, and the Euler-rate inverse with a guard

python — from scratch
def slerp(q1, q2, t):
    """Interpolate two unit quaternions. The first three lines are the ones
    that matter -- everything after is textbook."""
    d = float(np.dot(q1, q2))
    if d < 0.0:                 # THE line. Same rotation, near hemisphere.
        q2, d = -q2, -d
    if d > 0.9995:              # nearly identical: slerp is numerically unstable
        return (q1 + t * (q2 - q1)) / np.linalg.norm(q1 + t * (q2 - q1))
    om = np.arccos(d)
    so = np.sin(om)
    return (np.sin((1 - t) * om) * q1 + np.sin(t * om) * q2) / so

def euler_rates_from_body(omega, roll, pitch, lock_tol=0.05):
    """Inverse of E. Refuses rather than returning nonsense near lock."""
    cp = np.cos(pitch)
    if abs(cp) < lock_tol:      # |pitch| > ~87.1 deg
        raise ValueError(f"gimbal lock: cos(pitch)={cp:.4f}, "
                         "yaw and roll are not separately observable here")
    sr, cr = np.sin(roll), np.cos(roll)
    tp = np.tan(pitch)
    p, q, r = omega
    return np.array([p + tp * (sr * q + cr * r),      # roll rate
                     cr * q - sr * r,                 # pitch rate
                     (sr * q + cr * r) / cp])         # yaw rate

The lock_tol guard is the engineering content. cos(pitch) < 0.05 means |pitch| > 87.1°, where the inverse already amplifies by 20×. Raising there is far better than returning a number that is technically finite and physically meaningless — a controller downstream will act on that number. Fail loudly at the edge of the chart; do not hand your caller a plausible-looking infinity.

python — the production form
from scipy.spatial.transform import Rotation as Rot, Slerp

key_times = [0.0, 1.0]
key_rots = Rot.from_quat([q1_xyzw, q2_xyzw])
interp = Slerp(key_times, key_rots)     # scipy handles the sign internally
q_mid = interp([0.5]).as_quat()

# The controller error, the way you should write it:
R_err = R_des.T @ R_act                  # the rotation still to be undone
e = Rot.from_matrix(R_err).as_rotvec()   # (3,) -- direction AND magnitude
# |e| is the angle in radians; e/|e| is the axis. Feed e straight to the gain.

DEBUG — three failures that look alike and are not

All three arrive as the same ticket — "the gimbal is doing something insane" — and all three are separated by a single scalar you can plot. Ramp, step, or constant: that is the entire triage, and the rest of this section is what each one means.

Failure mode: a camera gimbal that stops tracking overhead. A pan–tilt mount follows a target. As the target passes near vertical, the camera visibly lags, then snaps.

Observable symptom: commanded pan rate saturates at the motor limit for a second or two, then the mount whips around. The video shows the target sliding out of frame and back in.

The metric: log cos(tilt) alongside the commanded pan rate. If the rate command is proportional to 1/cos(tilt) and the saturation events line up with |tilt| > 85°, it is gimbal lock, not a tuning problem — and no amount of gain scheduling will fix it, because the required rate is genuinely unbounded. The fix is representational: compute the pointing error as a rotation vector between the desired and actual optical axes and drive that, which has no singularity, then convert to motor commands only at the last step (and accept that mechanically the mount still cannot point straight up while yawing — that part is hardware, and saying so is the honest answer).

Failure mode: the 360° whip. Same mount, different fault: once every few minutes, the camera spins almost a full turn and comes back.

Observable symptom: an isolated, large, fast excursion with no build-up, at a tilt angle nowhere near vertical.

The metric: dot(q_prev, q_now) between consecutive samples. Gimbal lock gives a smooth 1/cos ramp in the commanded rate; a sign flip gives an instantaneous negative dot product and a single huge step. Ramp versus step is the discriminator. Both look like "the gimbal went crazy" in the video; they are different bugs with different fixes, and naming which one from the trace is the whole debugging answer.

Failure mode: the third one, which neither of the first two monitors can see. Same mount again. This time the camera's reported orientation is simply wrong — wildly, constantly wrong — and it was wrong from the first frame after somebody swapped a driver. The tilt does not matter. The rate does not matter. Nothing in the log ramps or steps, because nothing ever changes: the error was there at boot.

The root cause: quaternion component order. Four numbers, and the industry never agreed which one is the scalar. Both orders are in production, in libraries you will use in the same process:

OrderWho uses itThe trap
(x, y, z, w) — scalar lastROS geometry_msgs/Quaternion, SciPy Rotation.from_quat, Unity, Eigen's memory layout (q.coeffs())Reading the raw buffer feels safe. It is not — see the next row
(w, x, y, z) — scalar firstEigen's constructor Quaterniond(w,x,y,z), MuJoCo, Blender, most textbooks, most Hamiltonian notationEigen stores xyzw but constructs from wxyz. The same library disagrees with itself, on purpose, and that is where this bug is born
Worked example 6 — a 30° yaw, misread, to the exact wrong angle.

The mount is yawed 30° about z and nothing else. Half-angle 15°, so cos 15° = 0.965926 and sin 15° = 0.258819, and the correct quaternion is

  ROS wire, (x, y, z, w) = (0, 0, 0.258819, 0.965926)

Direction A — that buffer fed positionally into Quaterniond(w,x,y,z). Each slot shifts one place:

  w ← 0 (was x)  ·  x ← 0 (was y)  ·  y ← 0.258819 (was z)  ·  z ← 0.965926 (was w)

The rotation angle is Θ = 2 arccos(w), and w is now exactly zero:

  Θ = 2 arccos(0) = 2 × 90° = 180.000°

about the axis (0, 0.258819, 0.965926) — a unit vector tilted 15° off z toward y. Expand it with the R(q) formula from Chapter 3 (with w = 0 it reduces to R = 2vv − I), using 0.258819 × 0.965926 = 0.250000 and 0.258819² = 0.066987:

R = [[−1, 0, 0], [0, −0.866025, 0.5], [0, 0.5, 0.866025]]
Trace = −1 − 0.866025 + 0.866025 = −1, and arccos((−1 − 1)/2) = arccos(−1) = 180°, confirming it. A gentle 30° yaw is reported as the camera being upside down.

Direction B — the mirror bug, and it is subtler. An Eigen-side buffer (w, x, y, z) = (0.965926, 0, 0, 0.258819) handed to something expecting (x, y, z, w):

  x ← 0.965926  ·  y ← 0  ·  z ← 0  ·  w ← 0.258819
  Θ = 2 arccos(0.258819) = 2 × 75° = 150.000°, about axis (0.965926, 0, 0)/0.965926 = (1, 0, 0)

A 30° yaw becomes a 150° roll. Wrong magnitude, wrong axis, wrong plane — and still a perfectly valid rotation that every downstream consumer will happily use.

The general rule, worth memorising. Direction A makes the new scalar part equal to the old x-component. So for a rotation of α purely about x, w′ = sin(α/2) and

  Θ′ = 2 arccos(sin(α/2)) = 2(90° − α/2) = 180° − α

A near-zero roll reads as a near-half-turn. Small motions map to enormous ones, which is why this failure looks nothing like drift and everything like the sensor being bolted on backwards.
Why the two monitors you already have will not catch it.

The norm check passes. norm(q) is a sum of four squares, and a permutation reorders the terms without changing the sum. abs(norm(q) − 1) is 0.000000 in every example above. Exactly zero, not approximately.

The sign-flip check passes too. dot(q_prev, q_now) compares two consecutive samples that were both misread the same way, so the misreading is a fixed permutation applied to both, and a permutation is orthogonal — it preserves dot products exactly. The stream looks beautifully continuous. It is continuously wrong.

This is the general lesson about monitors. A monitor detects a departure from an invariant. Unit norm and sample-to-sample continuity are both invariant under coordinate relabelling, so neither can possibly see a relabelling. You need a monitor that is anchored to a known physical state.

The metric that does catch it: park the robot and assert identity. Put the mount in its mechanical home, where the true orientation is the identity rotation. The correct quaternion is (x, y, z, w) = (0, 0, 0, 1). Misread through Direction A, w becomes 0 and the axis becomes (0, 0, 1):

Θ = 2 arccos(0) = 180.000° about z — reported by a mount that is bolted down and not moving

That is the whole test, and it is worth wiring into the startup sequence permanently. A stationary body at a known home pose must report an orientation angle of 0.000°. If it reports exactly 180.000°, you have a component-order swap, not a calibration error — calibration errors are small and untidy, whereas this one is enormous and suspiciously round. Two supporting tells for when you cannot park the hardware: (1) for a mount that only yaws, x ≡ 0, so the misread scalar is pinned at zero and the reported angle is stuck at exactly 180.000° while the mount demonstrably turns — a channel that will not move is as diagnostic as one that moves too much; (2) the reported rotation axis will be a unit vector that includes the true w-component, so it drifts as the body rotates, whereas a genuine mechanical misalignment has a fixed axis.

The whole triage, on one page. Say this table out loud at a broken robot and you have answered the question before you have touched a keyboard:

Shape in the traceCauseThe scalar that proves itValue that separates itFix
Ramp — rate command grows smoothly, saturates, recoversgimbal lockabs(cos(pitch)), or better √(1−sin|pitch|)σmin < 0.06 (|pitch| > 85°) at the moment of saturationchange representation: drive a rotation vector, not Euler rates
Step — isolated huge excursion, no build-up, any pitchquaternion sign flip (double cover)dot(q_prev, q_now)a single sample < 0, surrounded by samples > 0.999one line: negate q2 when the dot product is negative
Constant — wrong from the first frame, never changes charactercomponent-order swap (or convention mismatch)reported angle at a known home poseexactly 180.000° (order swap) or ~45° and untidy (Euler convention)fix the adapter at the boundary; add the round-trip assertion

Notice that the three fixes live at three different layers — the control law, one line inside an interpolation routine, and a type conversion at a process boundary. That is the real reason naming the failure matters more than knowing all three cures: the cures do not overlap at all, so guessing wrong does not get you partway there. It gets you nowhere, one layer away from the bug.

The four-line health monitor for any orientation stream. abs(norm(q) - 1) catches un-normalised output. abs(q.w) against the expected angle catches component-order swaps. dot(q_prev, q_now) catches sign flips. abs(cos(pitch)) catches proximity to lock in anything still using Euler. Four scalars, published as diagnostics, and the entire orientation failure class becomes observable instead of anecdotal. Shipping this unprompted is the mark of an engineer who builds for the second occurrence, not just the first.

FRONTIER — the error state grew up into invariant filtering

The error-state trick in the DESIGN table — nominal on the manifold, small error in the tangent space — was for a long time a practical hack with good empirical behaviour. It now has theory, and the theory changed what people build:

The position to hold. Gimbal lock and the double cover are the two prices for compressing SO(3). Pay the double cover, because it is one dot-product check at every boundary; refuse to pay gimbal lock, because there is no check that fixes a rank deficiency. Where you are estimating orientation, go one step further and keep the state on the group with the error in the tangent space — that is the error-state EKF, and the invariant EKF (Barrau & Bonnabel 2017, Hartley et al. 2020 for legged robots) is the version with actual convergence guarantees.
Your quadruped's estimator publishes yaw that occasionally jumps by 360° in the plot. A colleague says "gimbal lock, we need to rewrite the estimator in quaternions." What do you check before agreeing, and what would each answer mean?

Chapter 5: Frame Conventions — world, odom, base_link, and the Optical Trap

It is day three of the bug. A pick-and-place cell keeps missing, and every subsystem insists it is innocent. The detector is clearly fine: in RViz the bounding box sits on the box on the conveyor, frame after frame, confidence 0.94, no flicker. The IK is fine too — the solver returns a valid joint configuration in under a millisecond, no limit hit, no singularity warning, no exception. The arm moves smoothly and lands exactly on the pose it was asked for. And then the gripper closes on air, roughly a metre above the box, every single time.

The number that crossed the boundary between the perception node and the motion node was (0.3, 0.2, 1.0). Three floats. All small, all plausible, none of them NaN, none of them out of range. There is no log line to grep for, because nothing failed: each side is internally consistent and each side is doing exactly what it was written to do. What is missing is an agreement about what those three floats mean — and the agreement was never written anywhere a compiler, a linter or a unit test could check it.

That is the shape of this chapter. The previous four were mathematics; this one is agreements, and agreements are where teams lose weeks precisely because there is nothing to derive. Two documents cover most of them: REP-103 fixes units and axis directions, and REP-105 fixes what the frames on a mobile robot mean and how they nest. Knowing them cold is table stakes for robotics software work. Knowing why they are that way — and being able to rebuild the matrices from scratch instead of pasting them — is the differentiator, and it shows within ninety seconds of touching a real tree.

By the end you will have derived the transform that would have caught the grasp bug on day zero, pushed a covariance through it (the step almost everyone forgets), taken the determinant apart at the game-engine boundary where it comes out negative, and written the twenty lines of code that produce the launch-file numbers rather than asserting them.

CONCEPT — REP-103, the axes and units everyone agrees on

ThingThe conventionWhy
UnitsStrict SI: metres, seconds, radians, kilogramsNo implicit conversions anywhere. Degrees appear only in a UI or a URDF's human-facing fields
HandednessRight-handed, alwaysSo cross products, torques and the right-hand rule all agree without a per-frame footnote
Body framesx forward, y left, z upForward is the direction of travel; z up makes gravity −z; y left completes the right-handed set
Rotation about zpositive = counter-clockwise from above = turning leftRight-hand rule about +z
Outdoor world frameENU: x east, y north, z upRight-handed and z-up, unlike aviation's NED. Robotics chose ENU so ground robots keep z up everywhere
Camera optical framesz forward, x right, y downInherited from computer vision, where the image u-axis is right, v is down, and depth is +z. The whole pinhole model assumes it
Namingoptical frames get a _optical suffixThe suffix is the warning label. A frame without it is x-forward; with it, z-forward
The single most common convention bug in all of robotics is treating a point from a camera driver as if it were already in a body frame. The camera says (0, 0, 2): "two metres straight ahead". Feed that into an x-forward frame without the rotation and it means "two metres straight up". Nothing about the number looks wrong. The robot reaches for the ceiling.
Worked example 1 — the optical-to-body rotation, derived rather than copied.

Do not memorise this matrix. Build it from the axis correspondence, which you can always reconstruct:
  optical z (forward)  →  body +x (forward)
  optical x (right)    →  body −y (right is negative-y, since +y is left)
  optical y (down)    →  body −z (down is negative-z, since +z is up)

The columns of a rotation matrix are the images of the basis vectors, so write each image as a column:
  column 1 = image of optical x = (0, −1, 0)
  column 2 = image of optical y = (0, 0, −1)
  column 3 = image of optical z = (1, 0, 0)
Rbody←optical = [[0, 0, 1], [−1, 0, 0], [0, −1, 0]]
Verify it is a rotation, not a reflection. Expand the determinant along the first row:
  det = 0·(0·0 − 0·(−1)) − 0·((−1)·0 − 0·0) + 1·((−1)·(−1) − 0·0) = 0 − 0 + 1 = +1
(If you had written "right is +y" by accident, the determinant would come out −1 — the reflection check from Chapter 1 catches the mistake immediately.)

Its RPY form, which is what you will actually type into a launch file, is roll = −90°, pitch = 0, yaw = −90°. Check it: Rz(−90°)Rx(−90°) = [[0,1,0],[−1,0,0],[0,0,1]] · [[1,0,0],[0,0,1],[0,−1,0]] = [[0,0,1],[−1,0,0],[0,−1,0]] ✓ — the same matrix.

Now use it. The detector reports an object at (0.3, 0.2, 1.0) in the optical frame — 1 m ahead, 0.3 m to the image right, 0.2 m below centre. In the body frame:
  x = 0×0.3 + 0×0.2 + 1×1.0 = +1.0 (1 m forward ✓)
  y = −1×0.3 + 0×0.2 + 0×1.0 = −0.3 (0.3 m to the right, since +y is left ✓)
  z = 0×0.3 − 1×0.2 + 0×1.0 = −0.2 (0.2 m down ✓)

And what skipping it costs. Pass (0.3, 0.2, 1.0) straight through and the robot believes the object is 0.3 m forward, 0.2 m left and 1 m above its head. The error is not small, and it is not random — it is an exact axis permutation. That is the signature: errors that are 90° multiples and axis swaps, never small offsets.
Worked example 2 — the point is the easy half. Rotate the covariance too.

Every depth sensor on the market shares one anisotropy: depth is far noisier than lateral position. Lateral error comes from a fraction of a pixel of ray direction; depth error comes from that same pixel divided by the disparity, so it grows roughly as range squared. A short-range stereo head with 10 cm of lateral standard deviation therefore has more like 30 cm of depth standard deviation. Variances are standard deviations squared, so:
Σoptical = diag(0.01, 0.01, 0.09)  [m2] — x lateral, y vertical, z depth (3× the sigma, 9× the variance)
A point transforms as p′ = R·p. A covariance transforms as a sandwich, one R for each of the two vectors in the outer product that defines it:
Σbody = Rbody←optical · Σoptical · Rbody←opticalT
(That identity, and the harder case where the transform itself is uncertain and you need a Jacobian, belong to rip-03 — Uncertainty, Least Squares & Robust Costs. Here we only apply it, by hand, with the R we just built.)

Step 1 — the inner product A = RΣ. Right-multiplying by a diagonal matrix scales column j of R by σj: column 1 by 0.01, column 2 by 0.01, column 3 by 0.09. All nine entries:
  A11 = 0×0.01 = 0   A12 = 0×0.01 = 0   A13 = 1×0.09 = 0.09
  A21 = −1×0.01 = −0.01   A22 = 0×0.01 = 0   A23 = 0×0.09 = 0
  A31 = 0×0.01 = 0   A32 = −1×0.01 = −0.01   A33 = 0×0.09 = 0
  A = [[0, 0, 0.09], [−0.01, 0, 0], [0, −0.01, 0]]

Step 2 — the outer product Σbody = A·RT. Transposing R turns its rows into columns:
  RT = [[0, −1, 0], [0, 0, −1], [1, 0, 0]], so its columns are (0,0,1), (−1,0,0) and (0,−1,0).
Now every entry is a dot product of a row of A with a column of RT. All nine:
  (1,1) = (0, 0, 0.09)·(0, 0, 1) = 0.09  (1,2) = (0, 0, 0.09)·(−1, 0, 0) = 0  (1,3) = (0, 0, 0.09)·(0, −1, 0) = 0
  (2,1) = (−0.01, 0, 0)·(0, 0, 1) = 0  (2,2) = (−0.01, 0, 0)·(−1, 0, 0) = 0.01  (2,3) = (−0.01, 0, 0)·(0, −1, 0) = 0
  (3,1) = (0, −0.01, 0)·(0, 0, 1) = 0  (3,2) = (0, −0.01, 0)·(−1, 0, 0) = 0  (3,3) = (0, −0.01, 0)·(0, −1, 0) = 0.01
Σbody = diag(0.09, 0.01, 0.01)  [m2]
Two free checks. A rotation preserves both the trace and the determinant of a covariance, because it only re-labels axes: trace was 0.01+0.01+0.09 = 0.11 and still is; determinant was 0.01×0.01×0.09 = 9×10−6 and still is. If your code changes either one, you have a bug in the sandwich, not a modelling choice.

And now the payoff, which is the whole reason to do this by hand. The large-variance axis moved. It was z in the optical frame — depth. It is x in the body frame — the direction the robot drives. A node that rotates the point and forwards the covariance untouched is telling the rest of the stack that it is 0.30 m uncertain in height, where nothing is, and only 0.10 m uncertain in range, where the wall is. It is three times overconfident along its own direction of travel, and the covariance is still a perfectly valid positive-definite matrix, so nothing complains.

The two ways that bites, with numbers. First, gating: an EKF rejects a measurement whose Mahalanobis distance exceeds ~3. A genuine 0.4 m depth residual is 0.4/0.30 = 1.33σ — comfortably inside the gate, accept it. Under the un-rotated covariance it is 0.4/0.10 = 4.0σ — rejected as an outlier. The filter silently throws away every good depth measurement and coasts on odometry, and the log says "outlier rejected", which reads like the filter working. Second, planning: a costmap inflates obstacles by roughly 2σ along each axis. With the correct covariance the wall gets 0.60 m of inflation along the driving direction; with the un-rotated one it gets 0.20 m, and 0.60 m of pointless inflation upward. The robot drives into the wall it was uncertain about, with a clearance margin that was computed in the wrong axis.

CONCEPT — the conventions your neighbours use

REP-103 governs your robot. It does not govern the simulator, the game engine the visualisation team uses, the CAD package the mechanical team exports from, or the flight-controller firmware. Every one of those boundaries is a rotation you must apply, and every one of them has been the subject of a multi-day bug somewhere.

SystemForwardLeftUpNote
ROS / REP-103 body+x+y+zthe reference for everything on the robot
ROS optical (_optical)+z−x−yz forward, x right, y down — the vision convention
OpenCV / COLMAP camera+z−x−yidentical to ROS optical; this one at least agrees
OpenGL / Three.js camera−z−x+ylooks down negative z; y up. Flipping z is the usual bridge
Gazebo / MuJoCo / Drake world+x+y+zz-up, agrees with ROS — a deliberate choice by all three
Isaac Sim / USD (default)+x+y+y or +zUSD carries an explicit up-axis token; read it, never assume
Unity+z−x+yleft-handed — det = −1 across this boundary if you build the matrix naively
Unreal Engine+x−y+zleft-handed, and units are centimetres
Aviation / PX4 NED+x (north)−y (y is east)−z (z is down)z down, so gravity is +z. Every sign in your IMU handling flips
Worked example 3 — the game-engine boundary, where the determinant comes out negative.

The Unity row above claims det = −1. Claims are cheap; do it. Unity's axes are +z forward, +x right, +y up, so the axis correspondence into a REP-103 body frame is:
  Unity z (forward)  →  body +x = (1, 0, 0)
  Unity x (right)    →  body −y = (0, −1, 0)
  Unity y (up)      →  body +z = (0, 0, 1)

Same rule as Worked example 1 — each image is a column, in source-axis order x, y, z:
  column 1 = image of Unity x = (0, −1, 0)
  column 2 = image of Unity y = (0, 0, 1)
  column 3 = image of Unity z = (1, 0, 0)
Mbody←unity = [[0, 0, 1], [−1, 0, 0], [0, 1, 0]]
Expand the determinant along the first row, term by term. With a11=0, a12=0, a13=1 and the lower rows (−1, 0, 0) and (0, 1, 0):
  term 1 = +a11·(a22a33 − a23a32) = 0·(0·0 − 0·1) = 0
  term 2 = −a12·(a21a33 − a23a31) = −0·((−1)·0 − 0·0) = 0
  term 3 = +a13·(a21a32 − a22a31) = 1·((−1)·1 − 0·0) = −1
  det = 0 + 0 − 1 = −1

Compare that with Worked example 1, where the identical construction gave +1. The single sign difference is Unity's up-axis landing on +z while its right-axis still lands on −y — and that is what left-handedness is.

What −1 means, and why no rotation can fix it. Every rotation matrix has determinant exactly +1; det is multiplicative, so composing M with any number of rotations leaves the sign alone. There is no R with det(RM) = +1 unless det(M) = +1 already. M is a reflection — a mirror — and SO(3) does not contain mirrors. Concretely: tf2 stores every rotation as a unit quaternion, quaternions parametrise SO(3) and nothing else, so there is no quaternion that encodes M. Hand M to a quaternion converter and it will not error; it will run its formula on an improper matrix and hand you back a plausible-looking unit quaternion for some rotation that is not M. That is the trap: the failure is silent and the output is well-formed.

The repair: split the mirror out. Write M as a proper rotation times a single-axis flip, M = R·S, and choose S to negate the handedness-breaking axis — take S = diag(1, 1, −1), which flips Unity's z. Then R = M·S−1 = M·S (S is its own inverse), and right-multiplying by S negates column 3:
R = [[0, 0, −1], [−1, 0, 0], [0, 1, 0]]
Re-expand, same three terms: term 1 = 0·(0·0 − 0·1) = 0; term 2 = −0·((−1)·0 − 0·0) = 0; term 3 = (−1)·((−1)·1 − 0·0) = (−1)·(−1) = +1. det(R) = +1 ✓ — a genuine rotation, and it is safe to put in the tf tree.

Where each half lives, which is the actual engineering decision. The mirror S is a data operation and belongs in the bridge node that reads Unity: flip the sign of one component the instant the message arrives, and everything downstream is right-handed. The rotation R is a frame operation and belongs in the URDF or a static transform, where a reviewer can read it. Never let the mirror into tf.

Run a point through both paths and check they agree. Take a Unity point pu = (2, 1, 5): 2 m to the camera's right, 1 m up, 5 m forward.
  Direct: x = zu = 5, y = −xu = −2, z = yu = 1 → (5, −2, 1) — 5 m ahead, 2 m right, 1 m up ✓
  Split: S·pu = (2, 1, −5), then R·(2, 1, −5): row 1 = 0·2 + 0·1 + (−1)(−5) = 5; row 2 = (−1)(2) + 0 + 0 = −2; row 3 = 0 + 1·1 + 0 = 1 → (5, −2, 1)
Identical, as they must be — but only the second path ever handed tf a proper rotation.

The orientation version, one line. A Unity rotation maps as Rros = M Ru M−1, and conjugation preserves the determinant even when M is improper, so Rros is a legitimate rotation. In quaternion terms the vector part gets the axis map and the scalar part flips sign: qros = (w, −Mv) ≡ (−w, Mv). That w sign is the handedness flip — the same physical turn measured with the opposite sense — and forgetting it gives you a robot that turns the correct number of degrees the wrong way.

Now Unreal, where a unit bug stacks on top of the handedness bug. Unreal is +x forward, +y right, +z up, in centimetres. A perception node reports an obstacle at (250, −400, 90) in Unreal coordinates. Do the two corrections as two visible steps, because they fail differently:
  Step 1, handedness — +y is right in Unreal and left in ROS, so negate y: (250, −400, 90) → (250, +400, 90) cm, now right-handed but still centimetres.
  Step 2, units — REP-103 is strict SI, so divide every component by 100: (250, 400, 90)/100 → (2.50, 4.00, 0.90) m.
Final answer: 2.50 m ahead, 4.00 m to the left, 0.90 m up.

What each omission costs, separately. Skip the unit step and you get (250, 400, 90) m — an obstacle a quarter of a kilometre away, which is absurd enough that somebody notices within the hour. Skip the handedness step and you get (2.50, −4.00, 0.90) m — an obstacle 4 m to the right instead of 4 m to the left, an 8 m lateral error, with every component a perfectly plausible magnitude. The unit bug is loud; the handedness bug is quiet, and the quiet one is the one that survives into the field. Skip both and you get (250, −400, 90) m, which is loud again — so a team that fixes only the units can make a working system worse.

One diagnostic that separates them. Build the full Unreal-to-ROS matrix including the scale — it is 0.01·diag(1, −1, 1) — and take its determinant: 0.013 × (−1) = −1×10−6. Two independent facts fall straight out. The sign is negative, so there is a handedness error. The magnitude is not 1, so there is a scale error, and the cube root of 10−6 is 0.01 — which names the scale factor as centimetres-to-metres without your having to guess. Run det on any transform you did not build yourself: its sign catches handedness and its magnitude catches units, and between them that is most of the boundary bugs in robotics.
Worked example 4 — ENU and NED, and the heading that is not a yaw.

Robotics uses ENU (x east, y north, z up); aviation uses NED (x north, y east, z down). The conversion swaps x with y and flips z:
RENU←NED = [[0, 1, 0], [1, 0, 0], [0, 0, −1]]
Is it a rotation? Expand along the first row: det = 0·(0·(−1) − 0·0) − 1·(1·(−1) − 0·0) + 0 = −1×(−1) = +1 ✓. It looks like a mirror because two axes swap and one flips, but two flips and a swap compose to a genuine rotation. Its trace is 0 + 0 + (−1) = −1, so the angle is arccos((−1−1)/2) = arccos(−1) = 180°, about the axis (1,1,0)/√2. Satisfying, and a good two-line sanity check to run whenever this matrix appears.

The trap that is not in the matrix. A magnetometer or GPS reports heading: degrees clockwise from north. ENU yaw is radians counter-clockwise from east. The conversion is not a matrix, it is:
yawENU = 90° − heading
A robot with a compass heading of 30° (north-north-east) has an ENU yaw of 90° − 30° = +60°. Skip this and your robot drives east when told north, and the error is not a constant offset — it is a reflection about the 45° line, so it looks correct at exactly one heading and is wrong everywhere else. That "correct at one point, wrong elsewhere" pattern is the fingerprint of a sign or handedness error rather than a bias.

CONCEPT — REP-105, and the one idea that makes it work

REP-105 defines the chain on any mobile robot:

earth → map → odom → base_link → (every sensor and joint)

Each frame has a job, and the jobs are chosen so that two incompatible requirements — be globally accurate and be smooth — can both be satisfied at once, by different frames.

FrameMeaningContinuous?Drifts?Published by
earthECEF — origin at the Earth's centre of mass, rotating with the planetyesnoa latched static transform, set once per site from a survey. See below: you register in it, you never compute in it
mapA globally consistent world frame fixed to the environmentNO — it jumpsnothe localizer / SLAM, 5–20 Hz
odomAn arbitrary origin the robot started fromYES — always smoothyes, without boundwheel odometry / VIO, 50–200 Hz
base_linkRigidly attached to the chassis, at a defined reference pointit is the thing being located
sensor framesRigid or joint-driven offsets from base_linkrobot_state_publisher, from the URDF

earth — the frame you register in and never compute in

Most REP-105 explanations dismiss earth in four words and move on, which is a mistake, because why it exists and why you must immediately leave it is a complete design argument worth five minutes of your attention.

What ECEF actually is. Earth-Centred, Earth-Fixed is a right-handed Cartesian frame whose origin is the Earth's centre of mass. +z points along the conventional rotation axis, out through the reference pole. +x pierces the surface where the equator meets the IERS reference meridian (about 102 m east of the old Greenwich line, which is itself a fine trivia answer). +y completes the right-handed set, coming out at the equator at 90° east. "Fixed" means it rotates with the planet, so a surveyed benchmark has constant coordinates in it — that is the whole point, and it is what makes ECEF the natural common frame for two robots that have never met.

And here is why you leave it immediately: floating point. The Earth's radius is about 6.37×106 m, so every ECEF coordinate on the surface has a magnitude near 6.4 million. A float32 carries a 24-bit significand, and for a value in the binade [222, 223) = [4,194,304 , 8,388,608) — which is exactly where 6.4×106 lands — the spacing between adjacent representable numbers is 222−23 = 2−1:

float32 resolution at ECEF magnitudes = 0.5 m.  float32 resolution 1 km from a local origin = 29−23 = 0.061 mm.

Those two numbers are the same data type, 8,192× apart, and the only difference is where you put the origin. A sensor_msgs/PointCloud2 stores XYZ as float32. So does every GPU vertex buffer, every OpenGL model-view matrix, most mesh formats and a good fraction of the mapping libraries. Express a LiDAR sweep in ECEF and it quantises onto a half-metre lattice before any algorithm touches it — the scan simply stops containing the wall. Symptom: point clouds that look "blocky" or "terraced" at range, ICP that will not converge below half a metre of residual, and a mesh with visible stair-stepping. Metric: take the set of unique coordinate values in one axis and difference the sorted list; if the smallest non-zero gap is a power of two rather than a sensor-noise-like continuum, you are looking at ULP quantisation, not sensor noise.

float64 survives the magnitude — its ULP at 6.4×106 m is 222−52 ≈ 10−9 m, a nanometre — but it does not survive the arithmetic. Every geometric quantity you care about is a difference of two nearby huge numbers: subtract two 6.4×106 m coordinates to get a 1 cm feature and you have burned about nine of your sixteen significant digits to catastrophic cancellation before the first cosine. Doing it in float32 burns all of them.

So REP-105 introduces a local tangent plane, and the decision it forces is where to put the origin. You pick a datum point (latitude, longitude, altitude), convert it to ECEF once, and build a local ENU frame tangent to the ellipsoid there. That is map. Everything downstream is now metres from a nearby origin, and float32 is fine again. Three ways to choose the origin, with real costs:

Origin choiceWhat it costs youUse when
First fix — wherever the robot happened to bootZero configuration, but every run has a different map. You cannot reuse a saved map, cannot compare two logs, cannot hand a goal pose to a second robotPrototypes and single-run experiments only
Site-surveyed — one fixed lat/lon/alt per facility, in a version-controlled config fileSomebody must survey it and nobody may edit it casually — changing it invalidates every saved map and every recorded goalThe default for any deployed fleet. This is what production systems do
Tiled — a grid of local origins with known transforms between tilesTile-boundary handling: a robot straddling two tiles needs both, and every algorithm must know which tile a point is inSites larger than a few kilometres, where one tangent plane no longer fits the ellipsoid

Size the tangent plane, because "a few kilometres" should be a number. A plane tangent to a sphere of radius R departs from the surface by about d2/(2R) at horizontal distance d. With R = 6.371×106 m:

d = 1 km → 106/(1.274×107) = 7.8 cm ·  d = 5 km → 1.96 m ·  d = 10 km → 7.85 m

So a single local origin is excellent out to about a kilometre, marginal at five, and indefensible at ten — and because the error goes as d2, it is invisible during testing near the origin and then grows fast at the edge of the site. That quadratic is the answer to "when do you need more than one map frame", and it is a much better answer than "when it feels too big".

The failure mode nobody calibrates away: a datum mismatch. "Latitude and longitude" is meaningless without a datum. Your RTK base station broadcasts WGS-84; your site survey came from the civil engineers in NAD83 or GDA94; tectonic plate motion means those frames have been drifting apart at roughly 2–7 cm per year for decades. Symptom: a constant, direction-independent position offset of one to two metres between the robot's global pose and the site drawings — constant in magnitude and bearing, unaffected by speed, unaffected by which sensors are running, and completely immune to re-running extrinsic calibration. The metric: park the robot on a surveyed benchmark and difference the reported ECEF position against the benchmark's published ECEF; if the residual is a fixed vector of order 1 m rather than a noise cloud of order 2 cm, it is a datum, not a calibration. The fix is a config field, not a filter: record the datum name alongside every origin, and refuse to load a map whose datum tag does not match the base station's. Australia's GDA94-to-GDA2020 shift was about 1.8 m in one jump, which is enough to put a robot in the wrong aisle.

When earth earns its place. Three cases, and they are the ones worth naming out loud: a fleet spanning several buildings, each with its own surveyed map origin, needing one frame in which to express "go to site B"; two robots that booted independently and must relate their maps without ever having seen each other; and any system fusing RTK GNSS, where the measurement genuinely arrives in a global datum and has to be brought into map by a transform somebody chose deliberately. In all three, earth → map is a latched static transform computed once from the survey — it does not stream, it does not drift, and no fast loop ever reads it. The one-sentence version: earth exists so that maps can be registered to each other; the local tangent plane exists so that nobody has to do geometry in numbers with six digits in front of the decimal point.

The idea that makes REP-105 work, in one sentence. The localizer does not publish map → base_link. It publishes map → odom, and it chooses that transform so the corrected global pose comes out right. That single indirection is the whole design: it means the entire correction lands on the map→odom edge, and odom→base_link is never touched. Consumers that need smoothness (the local planner, the controller, the local costmap) live in odom and never see a jump. Consumers that need global accuracy (the global planner, the global costmap, a goal in a building map) live in map and accept the jumps. One tree, two contracts.

And the formula the localizer uses is a direct application of Chapter 2:

Tmap←odom = Tmap←base(measured) · (Todom←base)−1

Read it with the subscript rule: map←base composed with base←odom gives map←odom, and base←odom is the inverse of what odometry publishes. Four symbols, and it is the entire body of a localizer's publish function.

Worked example 5 — a loop closure, arithmetic in one dimension.

A robot has driven down a corridor. Odometry says it has travelled 12.40 m; the wheels have slipped, and the true distance is 12.05 m. For clarity everything is along x with no rotation.

Before the correction:
  Todom←base = +12.40   (what wheel odometry integrated)
  Tmap←odom = 0.00   (no correction yet)
  Tmap←base = 0.00 + 12.40 = 12.40 m — 0.35 m too far along the corridor

The localizer matches a landmark and measures the true pose as 12.05 m. It computes:
  Tmap←odom = 12.05 − 12.40 = −0.35 m

After the correction:
  Todom←base = +12.40   — unchanged. Not one number moved.
  Tmap←odom = −0.35   (jumped by 0.35 m, in one message)
  Tmap←base = −0.35 + 12.40 = 12.05 m ✓ globally correct

Now read the two consumers. The velocity controller runs at 200 Hz in odom — a 5 ms cycle, which is the rate the DESIGN table below budgets for this edge. Between the two samples straddling the correction it sees the pose change by whatever the robot actually moved — a few millimetres. No step, no impulse, no jerk.

The global planner runs at 1 Hz in map. It sees the robot teleport 0.35 m backwards. That is fine: it replans, and 0.35 m is nothing to a path over a building.

Now swap them, which is the bug. Put the velocity controller in map. At the correction it sees a 0.35 m position step in one 5 ms cycle. A derivative term differentiates that step: 0.35 m / 0.005 s = 70 m/s of apparent velocity. The controller commands a violent correction. On a 60 kg robot that is a lurch you can hear across the room, and it happens every time the localizer converges — which is exactly when things were going well.

There is a second, quieter version of the same bug, and it is hiding in the rates. Look at them: the controller ticks at 200 Hz, and odom → base_link is published at 50–200 Hz. On a bad day the controller asks for a pose at a timestamp for which no transform has been published yet. It cannot invent one, so it does one of exactly two things — zero-order-hold the most recent sample, or interpolate between the two samples that bracket the request time. tf2's lookup_transform does the second by default: linear on the translation, slerp on the rotation, silently, with no warning. That is the deeper reason the frame has to be continuous. You cannot safely interpolate across a discontinuity, and the library will not stop you trying.

Put numbers on both. Zero-order-hold turns the 0.35 m jump into the single 70 m/s impulse above: violent, but so far outside physics that any velocity sanity check, watchdog or saturation limit catches it on the first sample. Interpolation instead spreads the same jump smoothly across the gap between two localizer samples — at 10 Hz that is 100 ms, so 0.35 m / 0.100 s = 3.5 m/s of fictitious velocity, sustained for a tenth of a second. That is a completely ordinary speed for a warehouse AMR. It passes every sanity check, every limit and every plausibility filter, and the robot simply accelerates hard in a direction nothing commanded. The interpolated failure is the more dangerous of the two precisely because the number looks reasonable — and it is why "put the controller in odom" is a stronger answer than "clamp the velocity".
REP-105 live — who jumps and who does not

Drive the robot down the corridor and fire a loop closure. The teal trace is the pose in odom (smooth, drifting); the orange trace is the pose in map (accurate, discontinuous). Watch which one steps.

distance driven12.4 m
slip rate2.8 %
 

DESIGN — who publishes what, and the one-parent rule

A tf tree is a tree, and that word is load-bearing. Every frame has exactly one parent, and exactly one node may publish a given parent→child edge. Both halves are enforced by convention, not by the library, which is why both get violated.

EdgePublisherRateLatency budgetConsumers
map → odomAMCL, SLAM, or a fusion EKF5–20 Hzup to 200 ms — nobody in the fast loop reads itglobal planner, global costmap, goal handling
odom → base_linkexactly one of: wheel odometry, VIO, or the fused EKF — never two50–200 Hz< 10 ms; this is in the control pathlocal planner, controller, local costmap, point-cloud deskewing
base_link → jointsrobot_state_publisher, from joint_states100–200 Hz< 5 mskinematics, collision checking, sensor placement
base_link → fixed sensorsstatic_transform_publisher or the URDFlatched, oncen/aeverything
The two-publisher failure, and how it presents. A team runs wheel odometry and an EKF, and both publish odom → base_link. tf2 does not error — it stores whichever message arrived most recently for that timestamp. Symptom: the robot's pose appears to vibrate at the beat frequency between the two publish rates, and the amplitude equals the disagreement between the two estimators. It looks exactly like sensor noise, so people spend a week tuning covariances. The metric: ros2 run tf2_ros tf2_monitor odom base_link reports the list of authorities publishing that edge. More than one name in the list is the bug. Ten seconds, and it is the first thing to run whenever a pose looks noisy in a way that does not match the sensor.

The frame-choice cheat sheet for consumers, because "which frame does this node live in?" comes up on every new node:

ConsumerFrameBecause
Velocity / trajectory controllerodomNeeds continuity above all. A discontinuity becomes an impulse through any derivative term
Local costmap and local plannerodomObstacle memory over a few seconds; a map jump would smear obstacles across the grid
Global planner, global costmapmapNeeds to agree with a building-scale map; a 0.35 m step is irrelevant at that scale
Goal poses from an operatormap"Go to the loading dock" is a statement about the world, not about where the robot booted
Point-cloud motion compensation (deskew)odomNeeds the pose at 100 sub-sweep timestamps; only a smooth, high-rate frame can be interpolated
Recorded bag for offline replaystore both, plus /tf_staticYou cannot recover map→odom later; and a bag without tf_static is a bag you cannot replay

Sizing the correction latency. A localizer at 10 Hz with 60 ms of internal processing means the map→odom edge can be up to 160 ms old. If a 200 Hz controller read map, that is 32 control cycles operating on a stale global pose — and the moment it updates, all 32 cycles' worth of correction arrives at once. In odom, the same controller reads a transform that is at most 5 ms old and never steps. That is the latency argument for REP-105, and it is a better answer than "because the REP says so".

CODE — a localizer's publish function, and the optical bridge

Three functions, and the rule that binds them: nothing in this section contains a pasted matrix, and nothing calls a helper you have not seen the body of. A matrix you cannot regenerate is a matrix you cannot review, and a matrix you cannot regenerate is a matrix you cannot defend.

python — build the matrix from the axis map, never paste it
import numpy as np

def rotation_from_axis_map(mapping):
    """Turn an axis correspondence into a rotation matrix.

    IN    mapping : dict 'x','y','z' -> length-3 sequence.
                    The value is WHERE THAT SOURCE BASIS VECTOR LANDS
                    in the target frame. That is the entire input.
    OUT   (3,3) float64, guaranteed orthonormal with det = +1
    RAISE ValueError naming the fault: not orthonormal, or left-handed

    The columns of a rotation matrix ARE the images of the basis
    vectors, so the whole construction is one column_stack. This is
    the bold rule from Worked example 1, executed instead of asserted."""
    R = np.column_stack([np.asarray(mapping[k], dtype=float)
                         for k in ('x', 'y', 'z')])
    err = float(np.abs(R.T @ R - np.eye(3)).max())
    if err > 1e-12:                   # orthogonality half of Chapter 1's is_valid
        raise ValueError(
            f"axis images are not orthonormal: max|R^T R - I| = {err:.2e}. "
            "Two source axes probably land on the same target axis.")
    d = float(np.linalg.det(R))
    if d < 0:                             # the handedness half -- and the Unity case
        raise ValueError(
            f"det = {d:+.1f}: this is a REFLECTION, not a rotation. The source "
            "frame is LEFT-HANDED. Factor a single-axis mirror out in the "
            "importer and pass the proper part here; tf2 stores rotations as "
            "unit quaternions and cannot represent a mirror at all.")
    return R

def matrix_to_rpy(R, eps=1e-9):
    """Extract (roll, pitch, yaw) for the ZYX convention: R = Rz(y) Ry(p) Rx(r).
    That is what URDF <origin rpy="..."> and static_transform_publisher
    --roll/--pitch/--yaw both mean.

    Read the closed form of that product and four entries do all the work:
        R[2,0] = -sin(pitch)
        R[2,1] =  cos(pitch)*sin(roll)   R[2,2] = cos(pitch)*cos(roll)
        R[1,0] =  cos(pitch)*sin(yaw)    R[0,0] = cos(pitch)*cos(yaw)
    so roll and yaw are one atan2 each -- UNLESS cos(pitch) = 0, when all
    four vanish together, atan2(0,0) is meaningless, and only the SUM or
    DIFFERENCE of roll and yaw is observable. That is gimbal lock, and the
    honest response is to pin one angle rather than return noise."""
    s_pitch = -float(R[2, 0])                # = sin(pitch)
    if s_pitch > 1.0 - eps:                  # pitch = +90 deg: LOCKED
        # here R[0,1] = sin(roll - yaw), R[0,2] = cos(roll - yaw)
        return (float(np.arctan2(R[0, 1], R[0, 2])), np.pi / 2, 0.0)
    if s_pitch < -1.0 + eps:                 # pitch = -90 deg: LOCKED
        # here R[0,1] = -sin(roll + yaw), R[0,2] = -cos(roll + yaw)
        return (float(np.arctan2(-R[0, 1], -R[0, 2])), -np.pi / 2, 0.0)
    roll  = np.arctan2(R[2, 1], R[2, 2])
    pitch = np.arctan2(s_pitch, np.hypot(R[0, 0], R[1, 0]))
    yaw   = np.arctan2(R[1, 0], R[0, 0])   # atan2, never asin: all four quadrants
    return (float(roll), float(pitch), float(yaw))
python — run it, and get the launch-file numbers out
R_body_optical = rotation_from_axis_map({
    'x': (0, -1, 0),      # optical x (image right) lands on body -y
    'y': (0, 0, -1),      # optical y (image down)  lands on body -z
    'z': (1, 0, 0)})     # optical z (forward)     lands on body +x
print(R_body_optical)
# [[ 0.  0.  1.]      <- Worked example 1's matrix, ASSEMBLED, not typed in
#  [-1.  0.  0.]
#  [ 0. -1.  0.]]

roll, pitch, yaw = matrix_to_rpy(R_body_optical)
# the "+ 0.0" turns numpy's signed -0.0 into 0.0 so the printout matches
# the URDF a human will type; -0.0 in a diff wastes a reviewer's minute
print(f"rpy = {roll + 0.0:.4f} {pitch + 0.0:.4f} {yaw + 0.0:.4f}")
# rpy = -1.5708 0.0000 -1.5708
#       ^^^^^^^          ^^^^^^^  these two numbers are the -1.5708 pasted
#       into the launch file and the URDF below. Produced, not asserted.

# And the Unity boundary from Worked example 3, through the SAME function:
rotation_from_axis_map({'x': (0, -1, 0), 'y': (0, 0, 1), 'z': (1, 0, 0)})
# ValueError: det = -1.0: this is a REFLECTION, not a rotation. The source
# frame is LEFT-HANDED. Factor a single-axis mirror out in the importer ...

That failure is the point of the function. The Unity axis map is a reasonable-looking dictionary — three unit vectors, all orthogonal, nothing obviously wrong — and it is rejected with a message that names the actual fault and the actual fix. Compare that to what a pasted matrix does: nothing. It just quietly produces the wrong quaternion, and you find out three days later when a gripper closes on air.

python — the four lines that ARE REP-105, with nothing left undefined
def to_matrix(ts):
    """geometry_msgs/TransformStamped -> (4,4) float64 homogeneous matrix.

    ts.transform.translation : (x, y, z) in METRES        (REP-103, always SI)
    ts.transform.rotation    : (x, y, z, w), SCALAR LAST  (ROS order; Eigen
                               and MuJoCo put w first -- this swap is its own
                               multi-day bug, and it looks like a 180 deg error)"""
    q = ts.transform.rotation
    x, y, z, w = q.x, q.y, q.z, q.w
    n = float(np.sqrt(x*x + y*y + z*z + w*w))
    if abs(n - 1.0) > 1e-6:        # hand-edited YAML and float32 hops both do this
        x, y, z, w = x/n, y/n, z/n, w/n   # an unnormalised q SCALES R by n^2
    T = np.eye(4)
    T[:3, :3] = np.array([
        [1 - 2*(y*y + z*z), 2*(x*y - z*w),     2*(x*z + y*w)],
        [2*(x*y + z*w),     1 - 2*(x*x + z*z), 2*(y*z - x*w)],
        [2*(x*z - y*w),     2*(y*z + x*w),     1 - 2*(x*x + y*y)]])
    t = ts.transform.translation
    T[:3, 3] = [t.x, t.y, t.z]
    return T
# to_msg is the exact inverse: same translation copy, plus matrix -> quaternion,
# which is Chapter 3's Shepperd branch (pick the largest of the four candidate
# denominators). se3_inv is Chapter 1's: R.T and -R.T @ t, never np.linalg.inv.

def publish_correction(T_map_base_measured, buf, stamp, br):
    """Called every time the localizer produces a global pose fix.

    IN   T_map_base_measured : (4,4) float64 ndarray, metres, map <- base_link
         buf                 : tf2_ros.Buffer
         stamp               : builtin_interfaces/Time -- the SENSOR CAPTURE
                               time, never now(). Using now() here dates the
                               correction to the wrong pose.
         br                  : tf2_ros.TransformBroadcaster
    OUT  one geometry_msgs/TransformStamped on /tf: parent 'map', child 'odom'

    We never publish map -> base_link. We publish map -> odom, chosen so
    that the existing odom -> base_link composes to the corrected pose."""
    try:
        ts = buf.lookup_transform('odom', 'base_link', stamp)  # TransformStamped
    except tf2_ros.ExtrapolationException:
        return          # stamp is newer than the newest odom sample. Grabbing
                        # the latest sample instead injects an error equal to the
                        # distance travelled in the gap: 1.5 m/s x 50 ms = 7.5 cm,
                        # which then looks exactly like a bad landmark match.
    T_odom_base = to_matrix(ts)                              # (4,4) float64
    T_map_odom  = T_map_base_measured @ se3_inv(T_odom_base)   # map←base · base←odom
    br.sendTransform(to_msg(T_map_odom, parent='map', child='odom', stamp=stamp))

# The correction magnitude is a free health metric -- publish it.
# A growing ||t|| of map->odom means odometry is drifting faster than
# it used to: a tyre is worn, a wheel is slipping, or VIO is degrading.

That last comment hides a genuinely deep insight. The map→odom transform is a free, always-available measurement of accumulated odometry drift. Publish its translation norm as a diagnostic, alarm when it grows faster than usual, and you have a wheel-slip detector and a VIO health monitor that cost four lines and no new sensors.

the optical bridge, three ways
# 1. In a launch file (ROS 2), as a static transform:
#   ros2 run tf2_ros static_transform_publisher \
#     --x 0 --y 0 --z 0 --roll -1.5708 --pitch 0 --yaw -1.5708 \
#     --frame-id head_camera --child-frame-id head_camera_optical

# 2. In the URDF, which is where it belongs (reviewable, versioned):
#   <joint name="cam_optical_joint" type="fixed">
#     <origin xyz="0 0 0" rpy="-1.5708 0 -1.5708"/>
#     <parent link="head_camera"/><child link="head_camera_optical"/>
#   </joint>

# 3. In code, when you must -- built from the axis map, never pasted. Same
#    function as above, so the orthonormality and det checks come for free:
R_BODY_FROM_OPTICAL = rotation_from_axis_map({'x': (0, -1, 0),
                                             'y': (0, 0, -1),
                                             'z': (1, 0, 0)})
# A reviewer can read that dict out loud -- "optical z lands on body +x" --
# and agree or disagree. Nobody can review [[0,0,1],[-1,0,0],[0,-1,0]].
# The rpy above came from matrix_to_rpy(R_BODY_FROM_OPTICAL), so forms 1, 2
# and 3 are provably the same transform and cannot drift apart in review.

The reason there are three forms and not one is that they fail differently, and which one to ship is a real decision. The launch-file form is the worst: the numbers live in a string, nothing validates them, and a typo in one digit of -1.5708 is a silent few-degree error that survives code review. The URDF form is the right default — it sits with the rest of the geometry, it is diffable, and robot_state_publisher latches it once on /tf_static so it costs nothing at runtime. The code form is for the boundary a URDF cannot express: an external SDK, a game engine, a device driver that hands you raw arrays. The rule that ties them together is that all three should be generated from one axis map, so they cannot disagree. A URDF that says rpy="-1.5708 0 -1.5708" and a Python constant that says something else is a bug that no test catches, because each is self-consistent.

DEBUG — the jerk that arrives on good news

Failure mode: a controller subscribed to the wrong frame. A warehouse AMR drives smoothly, then lurches. Not randomly — a few times a minute, and much more often near the ends of aisles.

Observable symptom: a brief violent acceleration command with no corresponding obstacle, no corresponding operator input, and no corresponding sensor event. Operators describe it as "it flinches". It gets worse when localisation is working well, because a well-localised robot corrects more often.

The metric that reveals it in one plot: overlay the timestamps of map→odom updates on the commanded acceleration trace. If every acceleration spike coincides with a correction, you have found it. Quantitatively, differentiate the pose the controller is consuming: a genuine motion gives a bounded velocity; a frame jump gives an impulse whose height is (jump size)/(control period), which for a 0.35 m jump at 200 Hz is 0.35/0.005 = 70 m/s — a number no physical robot can produce, and therefore an unambiguous fingerprint.

The variant that hides from that plot. If the consumer is interpolating rather than holding — which is what tf2 does by default — the fingerprint is not a 70 m/s spike but a 3.5 m/s plateau lasting one localizer period, because the 0.35 m jump is spread over the 100 ms between two 10 Hz samples. Nothing in the trace is physically impossible any more, so the "no robot can do that" argument evaporates and the acceleration looks like an aggressive but legal command. The tell that survives: the plateau still starts on a map→odom timestamp and still lasts exactly one localizer period, and its height still equals the correction magnitude divided by that period. Plot the correction size against the plateau height across a hundred events; if the points fall on a straight line through the origin whose slope is the localizer rate, the pose is jumping, not the robot moving. An impulse is easy to spot and a ramp is not, so always check the ramp before concluding the frames are fine.

The general rule this is an instance of. Any consumer that differentiates a pose must live in a continuous frame. Controllers differentiate. Velocity estimators differentiate. Kalman filter innovations differentiate implicitly. Point-cloud deskewing interpolates, which is differentiation's cousin. All of them belong in odom. Only things that plan or display belong in map. Stating that rule, rather than reciting REP-105, is what makes the answer sound like experience.

Second failure mode: the missing /tf_static in a replayed bag. An engineer records a bag to debug a field failure, replays it, and every transform lookup fails with "frame does not exist". Because /tf_static is latched and published once at startup, a bag started afterwards contains none of it. Symptom: the tree in view_frames is a set of disconnected islands — sensors floating with no path to base_link. Metric: ros2 bag info and check the /tf_static message count; if it is zero, the bag is not replayable. Fix: record /tf_static explicitly in every recording profile, and add a CI check on the recording config. This is a five-minute fix that saves a field trip.

FRONTIER — when there is more than one map frame

REP-105 assumes one robot and one map. The frontier is what happens when neither is true.

The forward-looking view. REP-105 solved the single-robot version by putting the discontinuity on one designated edge. Multi-robot systems break that, because there is no single privileged map — every pair of robots has an estimated, uncertain, occasionally-wrong transform between their frames. So the interesting work has moved from defining frames to estimating and validating them, with outlier rejection on the inter-robot links. Kimera-Multi and Swarm-SLAM are the reference points, and the design lesson that carries over is: keep a continuous local frame per robot, and treat every cross-robot transform as a measurement with a covariance and a rejection test, never as ground truth.

PRACTICE — four drills to do on paper

None of these are lookups. Each is the kind of question that arrives mid-conversation at a robot and deserves an answer within a minute. Cover the answer, do the arithmetic, then check.

Drill 1 — a rear-facing camera. The same camera model is mounted on the back of the robot, upright, looking backwards. Build Rbody←optical from the axis correspondence and give the URDF rpy.

The answer. Optical z (forward, for the camera) now lands on body −x = (−1, 0, 0). The camera is still upright, so optical y (down) still lands on body −z = (0, 0, −1). Do not guess the third: for a right-handed optical frame x = y × z, so optical x lands on (0,0,−1) × (−1,0,0) = (0, 1, 0) — body +y, which is left, and that is correct because a backwards-facing camera's "right" is the robot's left. Columns in x, y, z order:
R = [[0, 0, −1], [1, 0, 0], [0, −1, 0]]
det, first row: 0 − 0 + (−1)·(1·(−1) − 0·0) = (−1)(−1) = +1 ✓. Then matrix_to_rpy: R[2,0] = 0 so pitch = 0; roll = atan2(R[2,1], R[2,2]) = atan2(−1, 0) = −1.5708; yaw = atan2(R[1,0], R[0,0]) = atan2(+1, 0) = +1.5708. So rpy="-1.5708 0 1.5708"identical to the forward camera except the sign of yaw. What it tests: whether you derived the third axis with a cross product or guessed it. Guessing gives det = −1 about half the time.
Drill 2 — the two fingerprints of a frame jump. A 0.35 m map→odom correction reaches a 200 Hz controller. Give the apparent velocity if the controller zero-order-holds, and if it interpolates between 10 Hz localizer samples. Say which is more dangerous.

The answer. Zero-order-hold: the whole step lands in one control period, 0.35 m / 0.005 s = 70 m/s. Interpolated: the step is spread over one localizer period, 0.35 m / 0.100 s = 3.5 m/s. The 70 m/s is more violent and less dangerous, because it is impossible and every limiter catches it. The 3.5 m/s is an ordinary AMR speed, so it passes every check and the robot executes it. What it tests: whether you know that a consumer of /tf interpolates by default. If you only know the impulse story, you miss the failure that actually ships.
Drill 3 — read a determinant. You are handed a 3×3 matrix from a third-party SDK and np.linalg.det returns −1×10−6. Name both faults and the source system.

The answer. Two facts, read independently. The sign is negative → it is improper, so the source frame is left-handed. The magnitude is not 1 → there is a uniform scale, and since det scales as the cube, the factor is (10−6)1/3 = 0.01 — centimetres to metres. Left-handed plus centimetres is Unreal Engine. What it tests: that det is a two-channel diagnostic and not a yes/no check. Say the cube root out loud; it is the part people miss.
Drill 4 — catch an un-rotated covariance from a log. A fusion node consumes 3D detections from a forward-facing stereo head. You suspect it rotates the point but forwards the covariance untouched. You have the logged body-frame covariances and nothing else. What single scalar proves it?

The answer. argmax(diag(Σbody)). Depth dominates the covariance of any stereo or RGB-D detection, and after Rbody←optical the depth axis is body x. So the argmax must be index 0. If it is index 2, the largest uncertainty is being reported as vertical, which is exactly where the optical frame put it — the sandwich RΣRT was never applied. One integer per message, and it does not need ground truth, a second sensor, or a test rig. What it tests: whether "propagate the covariance" is a slogan you repeat or a thing you can instrument.
Your AMR drives smoothly and you have already confirmed the velocity controller is correctly subscribed to odom. But its LiDAR obstacle avoidance intermittently paints a phantom wall smeared diagonally across an empty aisle — always within a second of the localizer converging after a long straight run. The deskew node interpolates the sensor pose across each 100 ms sweep. Where is the bug, and what is the fix?
Why the three wrong answers are wrong, which is the follow-up question. All three are real bugs with real symptoms, and that is the point — you eliminate them with the timing, not with plausibility. A slow-clearing costmap smears obstacles in the direction of travel and does it constantly, not in bursts correlated with localisation. A stale extrinsic produces a constant geometric offset in every sweep, so the wall would be there always, not intermittently. A clock-domain error produces smear proportional to the robot's speed and clock offset, which is worst during fast motion and unrelated to when the localizer converges. Only one candidate explains "right after the localizer converges": the deskew node is interpolating a pose across a 100 ms sweep in a frame that just stepped, so the points from the first half of the sweep and the points from the second half are expressed about origins 0.35 m apart — and a wall split across a 0.35 m offset is a diagonal smear. The general rule from earlier in the chapter did the work: any consumer that differentiates a pose must live in a continuous frame, and interpolation is differentiation's cousin. The consumer table lists deskew in odom for exactly this reason.

Chapter 6: Extrinsics vs Intrinsics — the Distinction That Locates the Error

A manipulator that was grasping at 96% is now at 78%. Nothing shipped. No software changed. The failures are all on the far bin — the near picks still work. You have five minutes and a tape measure before the line restarts.

Which of the camera's two calibrations is broken?

That question — not "define intrinsics" — is the one production will actually ask you, and the whole chapter is the machinery for answering it. Notice what the scenario already handed you: near works, far fails. That is not a complaint, it is a measurement. Errors that grow with distance and errors that do not come from different families, and by the end of this chapter you will be able to name the family from that one sentence, then propose the five-minute experiment that confirms it.

So here are the two families, introduced as the answer to the scenario rather than as a preamble to it.

Intrinsics answer: given a ray arriving in the sensor's own frame, which measurement does the sensor report? For a camera that is focal length, principal point and lens distortion. It is a property of the device, and it does not change when you move the device. Ship the camera to another continent, bolt it to another robot: same intrinsics.

Extrinsics answer: where is that sensor frame, relative to the rest of the robot? A rigid transform — the SE(3) from Chapter 1, six numbers. It changes the moment someone bumps the mount, and it says nothing whatever about the sensor's internals.

Why the split is the whole diagnostic. The two families enter the measurement at different points in the pipeline, on opposite sides of the perspective divide. That single structural fact is what makes their errors look different, and it is why "near works, far fails" is already half an answer. Everything below is that sentence, made quantitative.

Nobody at a broken robot asks for definitions. They describe the symptom and need to know which one is broken from the shape of the error. That is what this chapter trains, and it hinges on one idea: the two families produce completely different error signatures, and the signature is measurable in five minutes.

CONCEPT — the pipeline, and where each parameter sits

world point
Xw = (X, Y, Z) in metres
EXTRINSICS: Xc = R Xw + t — 6 numbers
point in the camera frame
Xc = (Xc, Yc, Zc), optical convention, z forward
↓ perspective divide — no parameters, pure geometry
normalised image coordinates
xn = (Xc/Zc, Yc/Zc) — dimensionless, this is the ray
INTRINSICS, part 1: distortion (k1, k2, k3, p1, p2)
distorted normalised coordinates
xd — the lens has bent the ray
INTRINSICS, part 2: K = [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]
pixel
(u, v) — what the image actually contains

Read the pipeline and the discriminator falls out of it. The extrinsics act before the perspective divide, so their effect is modulated by depth. The intrinsics act after, so their effect depends only on where you are in the image. That is the whole chapter.

CONCEPT — one equation, five perturbations, five different shapes

Everything in this chapter comes out of one line. Ignore distortion for a moment and write the horizontal half of the projection:

u = fx · (Xc / Zc) + cx  =  fx xn + cx

Nothing else is needed. Every calibration fault is a perturbation of one symbol in that line, and the shape of the resulting residual Δu is just the derivative of u with respect to that symbol. Do the five derivatives once, by hand, and you never have to memorise a signature table again — you can regenerate it at a whiteboard whenever a robot is broken, which is the point.

Perturbation 1 — the focal length is wrong by δf. Differentiate u with respect to fx:

∂u/∂fx = xn  ⇒  Δu = δf · xn

Linear in xn, exactly zero at xn = 0, and Z does not appear anywhere. The depth cancelled in the perspective divide before fx ever touched the number, which is the formal version of "intrinsics act after the divide".

Perturbation 2 — the principal point is wrong by δcx. Even easier:

∂u/∂cx = 1  ⇒  Δu = δcx

A constant. The same number at every pixel, at every depth. It is the only fault in the chapter whose image residual is genuinely constant — remember that, because the next one is often mis-stated as constant and is not.

Perturbation 3 — the mount is yawed by θ. This one people get wrong, so do it slowly. A rotation acts on the ray, not on the pixel. Write the ray in the camera frame as the direction d = (xn, yn, 1) — any point along it projects to the same pixel, so the direction is all that matters. A small rotation of θ about the optical y-axis (the yaw axis for a forward-looking camera) is, to first order, d → d + θ (ŷ × d). Compute the cross product with ŷ = (0, 1, 0):

ŷ × (xn, yn, 1) = (1, 0, −xn)

So the rotated ray is d′ = (xn + θ, yn, 1 − θxn). Now re-do the perspective divide, because the third component moved too — this is the step everyone skips:

xn′ = (xn + θ) / (1 − θxn) ≈ (xn + θ)(1 + θxn) ≈ xn + θ(1 + xn²)

dropping the θ² term. Multiply by fx:

Δu = fx · θ · (1 + xn²)

The same result falls out of the tangent form: a yaw sends xn → tan(arctan xn + θ), and d/dθ of that at θ = 0 is sec²(arctan xn) = 1 + xn². Two derivations, one answer — a good sign you have it right.

The (1 + xn²) factor is not a footnote, it is the discriminator. A yaw is not a constant image shift. It is a constant fxθ plus a quadratic term fxθxn² that grows toward the edges. Read the two terms as a decomposition and the classic calibration degeneracy appears for free: a small yaw ≈ a principal-point shift of fxθ, plus a small radial-distortion change. That is exactly why a checkerboard calibration with poor viewpoint diversity trades cx against extrinsic yaw against k1 and reports a beautiful reprojection error for all of them.

Perturbation 4 — the mount is translated by δt laterally. Here the rays stay parallel and the origin moves. Start from the projection of a point at depth Z, perturb Xc by δt at fixed Z, and subtract:

Δu = fx(Xc + δt)/Z − fxXc/Z = fx δt / Z

Three lines, and now the 1/depth falloff is obvious rather than memorised: the numerator is a fixed number of metres and the denominator is how far away the thing is. Same physical shift, fewer pixels, the further you look. This is the only perturbation in which Z survives to the end, and it survives because a translation happens before the divide and does not cancel with it.

Perturbation 5 needs its model built first, because it is the only equation in this chapter that has not been earned yet. The other four came out of u = fxxn + cx by differentiation. Distortion is a new term, and anyone who writes the polynomial down from memory and cannot say where it comes from is one "why even powers?" away from trouble. It takes forty seconds to derive, so do it slowly the way we did the yaw.

Why the correction can only point along the radius. A lens element is ground on a spherical surface, so the whole assembly is rotationally symmetric about the optical axis: spin the barrel about that axis by any angle and it is physically the same lens. Whatever displacement the glass induces at an image point must therefore be equivariant under that spin — rotate the incoming ray by φ and the displacement has to rotate by the same φ, because the lens cannot tell you did it. The only vector field on the plane with that property is one that points along the radius with a magnitude depending on the radius alone. Any term that singles out a direction — a bare yn², an xnyn cross term, anything carrying the polar angle explicitly — would require the lens to have a preferred azimuth, and a ground sphere does not have one. So before any physics enters, symmetry alone has forced the shape:

xd = xn · g(r)   with   r² = xn² + yn²

Why g contains only even powers of r. Two independent arguments, and they agree, which is the same comfort the two yaw derivations gave. (1) Parity. A 180° spin is a special case of the symmetry above, and it sends (xn, yn) → (−xn, −yn) while leaving r unchanged. For xd = xng(r) to negate along with its input — which it must, since the displaced point has to follow the ray — g may not change when xn flips sign, so g must be an even function of r. Every odd power is forbidden. (2) Smoothness. The wavefront leaving a polished lens has no kink on the optical axis, so g must be differentiable through r = 0. But r = √(xn² + yn²) has a corner at the origin exactly as |x| does, while r² is a plain polynomial in the coordinates and is perfectly smooth. A smooth g can therefore only be a power series in r². Same conclusion, different route.

One constant is still free, and pinning it is worth a sentence because it is the chapter's degeneracy theme showing up again: we define g(0) = 1. A g(0) of anything else would be a uniform magnification applied to every ray equally, and a uniform magnification is indistinguishable from a change in fx — the calibration would simply absorb it into the focal length and you would have two parameters fighting over one number. Normalising g(0) to 1 is what makes k1 and fx separately identifiable. What is left is the lowest-order series that can exist:

xd = xn(1 + k1r² + k2r4 + k3r6 + …)

And now the physics, which decides the sign of k1 rather than the form. A pinhole is exact only in the paraxial limit, where sin u is replaced by u. Refraction at a spherical surface obeys Snell's law with the true sine, and the first term the paraxial model threw away is −u³/6 — cubic in the field angle. That single fact is why the displacement f·k1·r²·xn scales as r³ and not as r² or r4: the leading distortion term is the leading term of the sine expansion, the third-order Seidel aberration. Physically, a spherical surface bends marginal rays — the ones arriving far off-axis — more strongly than the paraxial rule predicts, so those rays land closer to the axis than an ideal pinhole says. The corners are pulled inward, straight lines bow outward like the staves of a barrel, and that is k1 < 0, barrel distortion, the normal case for a short focal length. Move the aperture stop so magnification instead rises with field angle and the corners are pushed outward: k1 > 0, pincushion, which is what long lenses and some machine-vision optics do.

The magnitude, on the same camera, so this stops feeling like a correction term.

Keep the chapter's rig: f = 800 px on a 1280×960 sensor, so xn runs to ±0.80 and yn to ±0.60. Give it an ordinary wide-angle lens, k1 = −0.28. At the corner, r² = 0.8² + 0.6² = 0.64 + 0.36 = 1.00 exactly, so r = 1.00 and the arithmetic is clean:
  horizontal: f·k1·r²·xn = 800 × (−0.28) × 1.00 × 0.80 = −179.2 px
  vertical: f·k1·r²·yn = 800 × (−0.28) × 1.00 × 0.60 = −134.4 px
  magnitude: √(179.2² + 134.4²) = √(32112.6 + 18063.4) = √50176 = 224.0 px, inward.

Check it the fast way: the magnitude must be f·|k1|·r³ = 800 × 0.28 × 1.00 = 224.0 px ✓. And as a fraction of the half-diagonal √(640² + 480²) = 800 px, that is 224/800 = 28% — which is just |k1|r² back again, as it has to be.

Two hundred and twenty-four pixels. The focal error in worked example 1 was 3.2 px; the 0.2° yaw is 2.79 px; the 5 mm mount shift is 4.0 px. Distortion on a wide lens is fifty to seventy times larger than every other term in this chapter. It is not a refinement bolted onto the pinhole model — on anything wider than a mild telephoto it is the largest single term in the projection, and the reason nobody does geometry on raw pixels. Run the same lens at r = 1/3 and it moves the point by 800 × 0.28 × (1/27) = 8.3 px instead: same lens, 27× less signal, which is the quantitative version of "calibrating on centred targets gives you k1 from noise."

Perturbation 5 — the radial distortion coefficient k1 is wrong by δk1. With the model now derived rather than asserted — xd = xn(1 + k1r² + …), r² = xn² + yn² — differentiate with respect to k1 and multiply by fx:

Δu = fx · δk1 · r² · xn  —  magnitude fx · δk1 · r³

Cubic in the radius. At r = 1/3 of the way out, r³ = 1/27 of the corner value — which is precisely why distortion is invisible in the middle third of the frame and enormous at the corners, and why calibrating on targets that never leave the centre gives you distortion coefficients that are essentially noise.

The five shapes, as a basis. Written as functions of image position, the residuals are
  focal → xn  ·  principal point → 1  ·  yaw → 1 + xn²  ·  distortion → r²xn  ·  translation → 1/Z

Four of the five are polynomials in image position and one is not a function of image position at all. Only the translation term involves depth, and only the yaw and distortion terms are non-linear in position. Say that when asked "how would you tell them apart?" and you have given a complete answer in one sentence, before touching a tool.

Now flip the pipeline around, because the robot cares about metres, not pixels. The manipulator does not consume u; it consumes a 3D point. Back-project: given a measured pixel u and a depth Z (from stereo, a depth sensor, or a known target size), the reconstructed lateral coordinate is

c = Z · (u − ĉx) / f̂x

with hats marking the stored calibration. Every 3D error in this chapter is the difference between that and the truth, and because Z multiplies the whole expression, any error in the assumed ray direction becomes a metric error proportional to range, while an error that is already metric stays put. That is the second sentence of the answer, and the two together are the entire diagnostic:

Two sentences that classify every calibration fault. (1) Faults that corrupt the ray direction — yaw, focal length, principal point, distortion — produce a 3D error proportional to range, because a wrong angle times a longer lever arm is a bigger miss. (2) Faults that corrupt the ray origin — extrinsic translation — produce a 3D error that is constant with range, because the whole reconstruction is rigidly shifted. Everything else in this chapter is bookkeeping on top of those two sentences.
Worked example 1 — an intrinsic focal error, pixel by pixel.

A 1280×960 camera with true fx = 800 px, cx = 640. The stored calibration is 1% high: fx = 808.

Project three rays. The pixel is u = fx xn + cx:
  xn = 0.0 (dead centre): true u = 800×0 + 640 = 640.0; wrong u = 808×0 + 640 = 640.0; error 0.0 px
  xn = 0.4: true u = 800×0.4 + 640 = 960.0; wrong u = 808×0.4 + 640 = 963.2; error +3.2 px
  xn = −0.6: true u = 800×(−0.6) + 640 = 160.0; wrong u = 808×(−0.6) + 640 = 155.2; error −4.8 px

Read the pattern. Zero at the principal point. Linear in the distance from it. Sign flips across the centre — the residual arrows all point outward (or all inward). And it does not depend on the depth of the point at all: a target at 0.5 m and one at 8 m at the same image position get the identical residual.

That is the intrinsic fingerprint: a radial residual field, zero at the centre, independent of range.
Worked example 2 — the extrinsic rotation as an actual 3×3, entirely by hand.

Before any signature talk, do the matrix arithmetic once so the millimetres are yours and not the formula's. The mount has yawed 0.2° about the optical y-axis — the kind of shift a vibrating M3 screw produces over a month. Build Ry(0.2°) from the definition in Chapter 1, with cos 0.2° = 0.99999391 and sin 0.2° = 0.00349065:
Ry(0.2°) = [[0.99999391, 0, 0.00349065], [0, 1, 0], [−0.00349065, 0, 0.99999391]]
Sanity-check it is in SO(3) before using it (Chapter 1's habit, and it takes four seconds): column 1 dotted with itself is 0.99999391² + 0 + 0.00349065² = 0.99998782 + 0.00001218 = 1.00000000 ✓, and columns 1 and 3 dot to 0.99999391×0.00349065 + 0 + (−0.00349065)×0.99999391 = 0.00000000 ✓.

Set up the error. The software believes the camera sits at R̂ (take R̂ = I so the arithmetic stays readable — base axes aligned with optical axes; a real robot has the Chapter 4 permutation here and it changes nothing about the shape). The truth is R = R̂ · Ry(0.2°). So the position error for a point measured at Xc in the camera frame is
ΔX = (R − R̂) Xc = (Ry(0.2°) − I) Xc
and that difference matrix is worth writing out, because it is where the smallness lives:
Ry(0.2°) − I = [[−0.00000609, 0, 0.00349065], [0, 0, 0], [−0.00349065, 0, −0.00000609]]
The off-diagonal entries are ±sinθ ≈ θ; the diagonal entries are cosθ − 1 ≈ −θ²/2, which is 6.1 micro-units — three orders of magnitude smaller. A small rotation is essentially its skew-symmetric part. That is the whole of the small-angle approximation, visible in the numbers.

Apply it to a real target. Put the calibration board at Xc = (0.4, 0, 1.0) m — one metre in front, 0.4 m to the right, i.e. exactly the xn = 0.4 ray from worked example 1. Row by row:
  x: (−0.00000609)(0.4) + 0(0) + (0.00349065)(1.0) = −0.00000244 + 0.00349065 = +0.00348821 m = +3.4882 mm
  y: 0(0.4) + 0(0) + 0(1.0) = 0.0000 mm  (a yaw moves nothing vertically — the y-row of Ry is untouched)
  z: (−0.00349065)(0.4) + 0(0) + (−0.00000609)(1.0) = −0.00139626 − 0.00000609 = −0.00140235 m = −1.4024 mm

Magnitude: √(3.4882² + 1.4024²) = √(12.1675 + 1.9667) = √14.1342 = 3.7596 mm.

Sanity check without redoing any of it. A rotation about the camera origin moves a point on a circular arc of radius |Xc|, so the displacement must be |Xc|·θ for small θ. Here |Xc| = √(0.4² + 1.0²) = 1.07703 m and θ = 0.0034907 rad, giving 1.07703 × 0.0034907 × 1000 = 3.7596 mm ✓. The two agree to five figures, and the check took one multiplication. Make that check a habit — it proves you know what the matrix does, not just how to multiply it.

The on-axis special case, which is the one to quote. Redo it for Xc = (0, 0, 1.0): x-component = 0.00349065 m, z-component = −0.0000061 m, magnitude 3.4907 mm — essentially the pure lateral term. So at 1 m, a 0.2° yaw is 3.49 mm, and the next example scales that.
Worked example 3 — that same 0.2° yaw, seen in the image and at three ranges.

In 3D, first, because it is the simplest. The ray direction is wrong by 0.2°, so the lateral position error is range × tanθ. With tan(0.2°) = 0.0034907:
  at 1.0 m: 1000 × 1.0 × 0.0034907 = 3.49 mm  (matches the 3×3 above, as it must)
  at 1.5 m: 1000 × 1.5 × 0.0034907 = 5.24 mm
  at 3.0 m: 1000 × 3.0 × 0.0034907 = 10.47 mm
Divide each by its range: 3.49, 3.49, 3.49 mm/m. Constant ratio, so it is a straight line through the origin — no regression required to see it.

In the image, second, and this is where the careless answer lives. It is tempting to say a yaw tips every ray by the same angle so every pixel shifts by fxtanθ = 800 × 0.0034907 = 2.79 px, uniformly. That is wrong, and it is wrong in the direction that matters. The derivation above gave Δu = fxθ(1 + xn²), because rotating the ray also changes its z-component and the perspective divide re-scales the result. Put the numbers in:
  xn = 0.0 (principal point): 800 × 0.0034907 × (1 + 0.00) = 2.79 px
  xn = 0.4: 800 × 0.0034907 × (1 + 0.16) = 2.7925 × 1.16 = 3.24 px
  xn = 0.6: 800 × 0.0034907 × (1 + 0.36) = 2.7925 × 1.36 = 3.80 px
  xn = 0.8 (frame edge on a 1280-wide sensor): 2.7925 × 1.64 = 4.58 px
The exact values, computed with the full tangent instead of the first-order expansion, are 2.79 / 3.24 / 3.81 / 4.59 px — the difference is the θ² term, which at a fifth of a degree is a hundredth of a pixel. The residual grows 64% from centre to edge. It is not constant.

Why this correction is load-bearing. Worked example 1 gave a focal residual of 3.2 px at xn = 0.4. The growth of this rotation residual across the frame — 4.58 − 2.79 = 1.79 px — is the same order as that whole focal signature. If you had claimed "rotation is flat", you would have called a 1.79 px systematic pattern noise, and then failed to explain why your bundle adjustment kept trading yaw against k1.

The extrinsic-rotation fingerprint, stated correctly: 3D error strictly proportional to range, through the origin. Image residual near-constant in the middle third, growing as (1 + xn²) toward the edges, and — the part that separates it from focal — non-zero at the principal point.
Worked example 4 — an extrinsic translation error, which behaves the opposite way.

The mount position is off by δt = 5 mm laterally. Now the ray directions are right and the ray origin is wrong.

In 3D: every reconstructed point is shifted rigidly by the same 5 mm. The error is 5 mm at 0.3 m, 5 mm at 1 m, 5 mm at 10 m — flat, no range dependence at all, because nothing about the geometry stretches it.

In the image, derived rather than asserted. A point sits at Xc at depth Z, projecting to u = fxXc/Z + cx. Move the camera origin laterally by δt, which is the same as moving the point by −δt at unchanged depth. Project the perturbed point and subtract the original, and the cx terms cancel:
  Δu = fx(Xc + δt)/Z − fxXc/Z = fx·δt/Z
  at Z = 1 m: 800 × 0.005 / 1 = 4.0 px
  at Z = 2 m: 800 × 0.005 / 2 = 2.0 px
  at Z = 5 m: 800 × 0.005 / 5 = 0.8 px
Notice Xc dropped out entirely, so this does not depend on where in the frame the point is — only on how deep it is. That is a genuinely different shape from all four other faults, every one of which depends on image position and none of which depends on depth.

The extrinsic-translation fingerprint: constant 3D error at all ranges; image residuals that fall off as 1/depth — big on close targets, nearly invisible on far ones. Which is why a calibration captured only against a far wall never sees it: at 8 m a 5 mm mount error is 0.5 px, comfortably inside the residual noise of a good checkerboard fit.
Worked example 5 — the focal error in 3D, and the case the two-range test does not crack.

The previous three examples make the range test look omnipotent. It is not, and knowing exactly where it fails is worth more than knowing where it works. Take worked example 1's fault — true fx = 800, stored f̂x = 808 — and ask what it does to metres.

Step 1: what does the camera actually report? A target on the true xn = 0.4 ray lands at the real pixel u = 800 × 0.4 + 640 = 960.0. That number is physics; the lens does not know what is in the YAML file.

Step 2: what does the software reconstruct? Back-project with the stored focal length: x̂n = (960 − 640) / 808 = 320 / 808 = 0.3960396. The believed ray is 0.0039604 too shallow, and note the compact form of that gap: 0.4 × (1 − 800/808) = 0.4 × 8/808 = 3.2/808 = 0.0039604 ✓. A focal-scale error is a fractional error on the ray angle, so it inherits the off-axis distance as a factor.

Step 3: turn it into millimetres at two ranges.c = Z × 0.3960396 against the truth Xc = Z × 0.4:
  at Z = 0.5 m: 0.5 × 0.0039604 × 1000 = 1.98 mm
  at Z = 1.0 m: 1.0 × 0.0039604 × 1000 = 3.96 mm
  at Z = 2.0 m: 2.0 × 0.0039604 × 1000 = 7.92 mm
Divide by range: 3.96, 3.96, 3.96 mm/m. Constant ratio. A straight line through the origin.

Now look at what just happened. That is the same shape as worked example 3's extrinsic rotation — a line through the origin, and even a similar slope (3.96 vs 3.49 mm/m). The two-range test, run exactly as the discriminator table describes it, returns "rotation" for a pure focal-length fault. The test is not wrong; it is incomplete, and presenting it as sufficient is one follow-up question from collapsing.

The resolution, and it costs one extra measurement. Re-run the two-range sweep with the target centred, at xn = 0. The focal error is proportional to xn, so it collapses to 0.00 mm at every range. The yaw error is not, so it still reads 3.49 mm/m. Range separates ray-direction faults from origin faults; image position separates focal from rotation. You need both axes, and neither alone is enough. Four measurements total — centred and off-axis, near and far — and the space is fully partitioned.

CONCEPT — the discriminator table, and how to earn it in five minutes

FaultImage residual pattern3D error vs rangeThe five-minute test
Focal length fx, fyΔu = δf·xn — radial ramp, exactly zero at the principal point, sign flips across itproportional to range, with a factor xn — so zero for a centred targetPut the target dead centre. If the error vanishes, it is focal (or distortion)
Principal point cx, cyΔu = δcx — the one genuinely constant field: identical at the centre and at the cornerproportional to range, and not killed by centring the targetNearly degenerate with an extrinsic yaw from images alone (see below) — you need a second sensor, or the yaw's (1 + xn²) curvature at the edges
Distortion k1, k2Δu = f·δk1·r²xn — radial and cubic in r, so invisible in the middle third and large in the cornersthe angular error is range-independent, so the metric error still ramps with range — but it vanishes on-axis, like focalImage a straight line across the corners. Curvature means distortion
Extrinsic rotation (yaw θ)Δu = f·θ·(1 + xn²) — near-constant in the middle third, growing as (1 + xn²) toward the corners; crucially NON-ZERO at the principal pointproportional to range, through the origin — and it survives centring the targetMeasure the error at 1 m and 3 m. Straight line through zero ⇒ rotation or focal; then centre the target to split them
Extrinsic translationΔu = f·δt/Z — falls off as 1/depth, and is the only pattern that does not care where in the frame the point isconstant at all rangesSame two-range test. Flat line ⇒ translation
Stereo baseline Bnone — reprojection stays healthyproportional to range, as a scale factorDrive a measured distance. Everything short by the same percentage ⇒ baseline
Read the table as three questions, asked in this order.
Q1 — does the 3D error grow with range? No ⇒ extrinsic translation, and you are done. Yes ⇒ it is a ray-direction fault; continue.
Q2 — does it survive centring the target? No ⇒ focal length or distortion (both carry a factor of xn). Yes ⇒ extrinsic rotation or principal point.
Q3 — only if you reached "rotation or principal point": look at the residual at the frame edges. A yaw adds a (1 + xn²) bulge that a principal-point shift cannot produce, and the bulge is big enough to measure — on this camera a 0.20° yaw reads 2.79 px at the centre and 4.58 px at xn = 0.8, a 1.79 px gap against roughly 0.2 px of feature noise. So Q3 is decidable whenever the distortion model is trustworthy, and the DEBUG section below runs exactly this comparison to close its verdict. What makes the branch genuinely degenerate is an unmodelled k1, because a distortion error also manufactures edge growth and a single image cannot say which polynomial produced it. So the honest answer has two halves: "I compare the edge residual against the centre residual, and if the distortion coefficients are themselves in question I bring in a second sensor or an external metric reference." Knowing which branch is undecidable — and under exactly what condition it stops being undecidable — is a stronger answer than pretending none is.
The five-minute experiment, stated as a 2×2 rather than a single sweep. Two ranges (near, far) × two image positions (centred, off-axis) = four measurements with a tape measure and a checkerboard. Read the four numbers as a table:

  flat with range, both positions ⇒ extrinsic translation — the magnitude is the mount error directly.
  ramps with range, and dies when centredfocal length (or distortion, if the off-axis point was near a corner).
  ramps with range, and survives centring ⇒ extrinsic rotation or a principal-point error — and now say out loud that those two are nearly degenerate from a single camera, because volunteering the ambiguity is what a staff engineer does.
  everything short by the same percentage, reprojection healthy ⇒ a metric input: baseline, target square size, wheel radius.

Worked example 5 is the reason this is a 2×2 and not a line fit: range alone cannot separate focal from rotation, because both are ray-direction faults and both give a line through the origin. Offering the 2×2 rather than "I'd recalibrate" is the difference between a senior and a staff answer — recalibrating fixes the instance and teaches you nothing about which subsystem is unreliable.

The stereo case is worth its own subsection, because it is the one where every internal metric stays green. And since the formula gets quoted more often than it gets derived, derive it — it is two lines of similar triangles.

Two identical pinhole cameras, optical axes parallel, separated by a baseline B along x. A world point at depth Z and lateral offset X from the left camera projects to xL = f·X/Z. Seen from the right camera the same point sits at lateral offset X − B, so xR = f·(X − B)/Z. The disparity is the difference of those two image coordinates, and X cancels:

d = xL − xR = f·X/Z − f·(X − B)/Z = f·B/Z  ⇒  Z = f·B/d

X vanishing is the whole reason stereo works: disparity depends on depth and on nothing else about where the point is. Now perturb B, which is the failure mode. Suppose the true baseline is B but the stored value is B̂ = B(1 + ε). The cameras still see the same photons, so d does not change — it is a measurement, not a parameter. Only the conversion changes:

Ẑ = f·B̂/d = f·B(1 + ε)/d = Z(1 + ε)

Every depth is scaled by exactly (1 + ε), and the disparity — the only thing reprojection error is computed from — is untouched. That is the formal reason the metric stays green: reprojection error asks "do the pixels agree?", and they do, perfectly. A uniformly scaled scene viewed by a uniformly scaled rig lands on identical pixels. Concretely, at ε = 8% a target truly at 2.000 m is reported at 2.160 m, at 4 m it is reported at 4.32 m, and the reprojection RMS reads whatever it read yesterday.

Note the shape, because it is the sixth signature and it is different from the other five: the error is 160 mm at 2 m and 320 mm at 4 m — proportional to range, so it looks like a rotation on the range plot — but expressed as a percentage it is flat, and unlike a rotation it does not depend on the target's angular position at all. Healthy reprojection error plus a constant percentage error in every distance means suspect a metric input — baseline, calibration-target square size, wheel radius. Those three are the only places a number in metres enters the pipeline, so that is the whole search space. Multi-view geometry works through the full version.

Residual signature explorer — in real units

Pick a fault and read its handwriting. Left: the residual field on the image plane of a real camera — f = 800 px on a 1280×960 sensor, so xn runs ±0.80 and yn runs ±0.60. Right: 3D error against range, drawn twice — solid for a target at xn = 0.4 and dashed for a centred target, because the difference between those two curves is exactly what separates a focal fault from a yaw.

Every arrow and every curve is evaluated from the formulas derived above — nothing here is a stylised shape. The slider carries the fault's own physical unit and starts at the worked-example value, so set δf to +8 px and the readout prints 3.20 px at xn = 0.4 and 1.98 mm at 0.5 m; set the yaw to 0.20° and it prints 2.79 px at the centre, 3.24 px at xn = 0.4 and 4.77 px at the frame corner. Those are the numbers you just computed by hand — worked examples 1, 3 and 5, on screen. (The corner reads 4.77 rather than worked example 3's 4.58 because the corner is at (xn, yn) = (0.8, 0.6), so the printed magnitude also carries the small vertical term f·θ·xnyn = 1.34 px that a yaw induces off the horizontal axis.)

δf+8.0 px
 
Three things to do with this widget before moving on.
1. Set the yaw to 0.20° and watch the centre. The arrow at the principal point does not vanish — it reads 2.79 px. Now switch to focal and set δf = +8: the centre arrow disappears entirely and becomes a dot. That single pixel of difference is the focal-vs-rotation discriminator, and it is why worked example 5 tells you to centre the target.
2. Flip between principal point and extrinsic rotation. Set cx to 2.79 px and the yaw to 0.20° and the two are the same picture in the middle of the frame — and the right panel, the 3D-error-versus-range plot, is near-identical too, which is precisely why the range sweep in the DEBUG section cannot choose between them. The whole difference lives at the edges: only the yaw bulges as (1 + xn²), from 2.79 px to 4.58 px, while the principal-point arrows stay 2.79 px long from centre to corner. Flip back and forth and watch only the corner arrows — that 1.79 px of length is the entire discriminator, and it is measurable. This pair is degenerate on range data and separable on image position, which is the chapter's thesis in one widget. It becomes genuinely undecidable only when k1 is also in doubt, because distortion can fake the same edge growth — that is when you reach for a second sensor.
3. Set translation to 5 mm and look at the vertical structure. The arrows shrink as you go up the frame, because up is far. Every other fault in this widget is flat in depth; this one is the only one that reads the depth ramp at all.

CONCEPT — every sensor has both, not just cameras

Engineers who have only done vision think "intrinsics" means K. The split is general, and knowing what each sensor's intrinsics are is what lets you debug any of them:

SensorIts intrinsicsIts extrinsicsThe failure if intrinsics are wrong
Camerafx, fy, cx, cy, distortionTbase←cam_opticalrays leave at the wrong angle — radial, off-axis error
Spinning LiDARper-beam elevation angle, per-beam range offset and scale, azimuth offsetTbase←lidara flat floor comes back as a set of concentric rings at slightly different heights — "ring artefacts"
IMU3 gyro biases, 3 accel biases, scale factors, a 3×3 axis-misalignment matrixTbase←imu (and it is the rotation that matters most)a stationary robot reports a slow rotation; gravity does not read 9.81 m/s² on the right axis
Wheel odometrywheel radius, track width, encoder counts per revolutionTbase←wheel_centrea wrong radius scales every distance; a wrong track width scales every rotation — drive a square and it does not close
Radarrange and Doppler bin calibration, antenna patternTbase←radarrange bias; Doppler velocity that disagrees with wheel speed by a constant factor
The wheel-odometry version of the discriminator, because it is the cheapest experiment in robotics. Drive a 4 m square, four times, and come back to the start. If the robot ends up short or long along the path but the heading closes, the wheel radius is wrong — a pure distance scale. If it ends up rotated but the distances are right, the track width is wrong — a pure rotation scale. If it ends up displaced sideways with the heading correct, the two wheels have different radii. Three distinguishable outcomes, one experiment, no equipment. This is the UMBmark test, and it is the concrete answer to "how do you check a differential drive?"

DESIGN — calibration is data, and data has a lifecycle

The single most useful framing for this topic: calibration is not code, it is per-unit data, and it needs the same lifecycle discipline as any other data.

Count what defines the geometry of a six-camera humanoid: per camera, K is 4 numbers (fx, fy, cx, cy), distortion is 5, and the extrinsic to base_link is 6. That is 15 numbers per camera, 90 numbers for the rig, plus one time offset each. Ninety floating-point values, unique to that physical robot, on which every millimetre of manipulation accuracy depends. Lose the file and you have a robot that cannot see.

ParameterChanges whenRe-measureStorage
Intrinsics (K, distortion)the lens is changed or refocused; slow thermal driftat manufacture; on any lens serviceper-unit YAML, keyed by camera serial, checksummed
Thermal focal driftcontinuously, ~0.1–1 px per 10°C of focal lengthnever re-measured; either modelled or budgeted fora temperature coefficient, if you model it
Extrinsics (Tbase←sensor)a mount is bumped, a screw loosens, the robot is shippedat manufacture, after any collision, on a monthly field checksame file; and versioned, so you can diff against the factory values
Time offset (sensor vs host clock)on any driver or firmware changewith the extrinsics — and Lesson 2 covers whysame file
The four practices that manage calibration at fleet scale.
1. Per-unit, versioned, checksummed. Calibration is keyed by robot serial and stored with a date, an operator and the residual statistics of the fit. You must be able to diff today's values against the factory values.
2. Never silently overwritten. A recalibration that moves an extrinsic by more than a threshold (say 0.5° or 3 mm) is a hardware event and should file a ticket, not just update a file. That threshold is how you discover the mount is loose instead of quietly compensating for it forever.
3. Continuously monitored. Publish a health metric derived from cross-sensor consistency — project LiDAR points into the camera and track the median edge-alignment residual, or track the reprojection error on any incidental planar surface. It costs almost nothing and it turns "the grasps got worse this month" into an alarm on the day it happened.
4. Shipped with the robot, and reproducible. The bag of the calibration capture is archived alongside the result, so a disputed calibration can be re-solved with a better algorithm two years later.

The runtime data flow, with shapes. Every frame: an image (720, 1280, 3) plus K (3,3) and dist (5,) go into undistortion — done once into a cached remap table, roughly 2–4 ms on CPU for 720p, or free on the GPU. The undistorted rays plus depth give points in the optical frame (N, 3) float32. Then one 4×4 extrinsic lifts them to base_link: for a 300,000-point cloud that is 300k × 15 flops = 4.5 Mflop, well under a millisecond vectorised. The extrinsic is applied once per cloud; the intrinsics are baked into a lookup table once at startup. Knowing which one is per-frame work and which is one-time is a real design distinction.

CODE — the projection, and a residual classifier

One term in the code below has not appeared in the prose yet, so name it before you read past it. The dist vector unpacks as k1, k2, p1, p2, k3 — three radial coefficients and two others. Those two are tangential distortion, and they exist because the symmetry argument that forced the radial form has a loophole. Radial distortion came from the lens being rotationally symmetric about the optical axis. p1 and p2 model what happens when it is not: an element whose optical axis is tilted or laterally offset relative to the sensor, which is decentering — an assembly tolerance, not a grinding property. Somebody's pick-and-place machine placed the barrel a few tens of microns off, or the glue cured with the element a fraction of a degree cocked. Because that breaks the rotational symmetry, the displacement is no longer forced to point along the radius, and the two components are free to be asymmetric. That asymmetry is exactly what you see in the code: each component mixes an xnyn cross term with an r² + 2·(its own coordinate)² term, the pair being the lowest-order polynomial that a single tilt direction can produce. This is Brown's 1966 decentering model, the "Conrady" half of Brown–Conrady.

How big is it, and should you fit it? Do the arithmetic and the engineering decision falls out.

On a decently assembled module p1 and p2 land around 1×10−4. Take the chapter's corner again — xn = 0.80, yn = 0.60, r² = 1.00 — with p1 = p2 = 1×10−4, and push it through the same f = 800 px:
  x-component: f·(2p1xnyn + p2(r² + 2xn²)) = 800 × (2×10−4 × 0.48 + 10−4 × 2.28) = 800 × (9.6×10−5 + 2.28×10−4) = 800 × 3.24×10−4 = 0.26 px
  y-component: f·(p1(r² + 2yn²) + 2p2xnyn) = 800 × (10−4 × 1.72 + 9.6×10−5) = 800 × 2.68×10−4 = 0.21 px

Compare that against the 224 px of radial distortion at the same corner: tangential is a factor of ~860 smaller. Compare it against the number that actually decides the question — corner-feature localisation noise on a real checkerboard, about 0.3–0.5 px — and it is under the noise floor at the worst point in the frame.

So we ship p1 = p2 = 0, and the reason is not laziness. A parameter whose true signal is 0.26 px cannot be estimated from data with 0.5 px of noise; the optimiser will happily fit it, but it will be fitting noise. Worse, p1/p2 are partially collinear with a small principal-point shift and with k1, so a free parameter that only absorbs noise steals variance from the coefficients you care about — you get a marginally lower reprojection RMS and a measurably worse k1. That is the textbook overfitting trade, appearing in a place people do not expect it. The condition that flips the decision: a cheap glued module, a lens that has been dropped, or any assembly where the barrel is not precision-threaded, where p1, p2 can reach 1×10−3. Rerun the same corner at that value and the x-component is 800 × 3.24×10−3 = 2.59 px — five times the noise floor, and now it must be estimated or it will contaminate everything else in the fit.

Recognising it in a residual plot, which is the part that makes this operational. Radial distortion is an odd field: flip to the opposite side of the principal point and the arrows flip with you, so they all point outward (or all inward). The tangential field is even — substitute (−xn, −yn) into either component and every sign cancels, leaving the same value. So an unmodelled p1/p2 shows up as residual arrows that do not reverse when you cross the centre of the frame: a one-sided, lopsided bias, strongest along one axis, that no radial polynomial of any order can absorb. If your residual field has a consistent lean to it rather than a clean in-out pattern, stop adding k3 and add p1, p2.
python — from scratch
import numpy as np

def project(X_w, K, dist, R_cw, t_cw):
    """World point -> pixel. EXTRINSICS first, INTRINSICS last."""
    Xc = R_cw @ X_w + t_cw               # extrinsics: 6 numbers
    if Xc[2] <= 0:
        return None                     # behind the camera; a real check
    xn = Xc[:2] / Xc[2]                  # perspective divide: no parameters
    k1, k2, p1, p2, k3 = dist
    r2 = float(xn @ xn)
    radial = 1 + k1*r2 + k2*r2**2 + k3*r2**3
    tang = np.array([2*p1*xn[0]*xn[1] + p2*(r2 + 2*xn[0]**2),
                     p1*(r2 + 2*xn[1]**2) + 2*p2*xn[0]*xn[1]])
    xd = radial * xn + tang              # intrinsics, part 1
    return np.array([K[0,0]*xd[0] + K[0,2],   # intrinsics, part 2
                     K[1,1]*xd[1] + K[1,2]])
python — the diagnostic worth more than the projection
import numpy as np

def classify_fault(ranges, errors_3d, radii_px, residuals_px,
                   centred=False, noise_px=0.2):
    """Two fits, and BOTH of them fire.

    Fit 1 (3D error vs range)  splits ray-ORIGIN faults from ray-DIRECTION ones.
    Fit 2 (residual vs radius) splits the ray-direction faults from each other,
                               so it is consulted on every path that reaches a
                               ray-direction verdict - never as a fall-through.

    ranges       (N,) metres  - target distance for each 3D measurement
    errors_3d    (N,) mm      - measured 3D position error at that distance
    radii_px     (M,) px      - radius from the principal point, one image
    residuals_px (M,) px      - reprojection residual at that radius
    centred      bool         - True if the 3D sweep used a CENTRED target,
                                which is what kills the focal term
    noise_px     float        - per-feature residual noise; growth across the
                                frame is only believable above it
    """
    ranges, errors_3d = np.asarray(ranges, float), np.asarray(errors_3d, float)
    radii_px, residuals_px = np.asarray(radii_px, float), np.asarray(residuals_px, float)

    # GUARD FIRST. A single-range sweep makes A rank-deficient, so this has to
    # sit ABOVE the solves, not after them.
    span = float(ranges.max() - ranges.min())
    if span <= 0:
        return "inconclusive - every sample is at the same range"

    # 1. 3D error vs range: a ramp through the origin vs a flat offset
    A = np.stack([ranges, np.ones_like(ranges)], axis=1)
    slope, offset = np.linalg.lstsq(A, errors_3d, rcond=None)[0]

    # 2. image residual vs radius. r_offset = the residual extrapolated to the
    #    PRINCIPAL POINT; growth = how much it gains out to the frame edge.
    B = np.stack([radii_px, np.ones_like(radii_px)], axis=1)
    r_slope, r_offset = np.linalg.lstsq(B, residuals_px, rcond=None)[0]
    growth = abs(r_slope) * float(np.ptp(radii_px))

    # UNITS. slope is mm per metre; offset is mm. Comparing them directly is a
    # dimensional error: the same physical data logged in cm changes slope by
    # 100x and leaves offset alone, flipping the verdict. Multiply the slope by
    # the range span so both sides are mm and the test is unit-free.
    ramp = abs(slope) * span          # mm of growth across the sweep

    if ramp > 3 * abs(offset):
        # RAY-DIRECTION fault. Fit 2 now NAMES which one, here on the main path.
        if not centred and growth > 3 * abs(r_offset):
            return "INTRINSIC focal / distortion (residual -> 0 at the principal point)"
        if growth > 3 * noise_px:
            return "extrinsic ROTATION (non-zero at centre AND grows as 1+xn^2)"
        if abs(r_offset) > 3 * noise_px:
            return "PRINCIPAL POINT (non-zero at centre and flat across the frame)"
        return "ray-direction fault - residuals under noise; widen the radius spread"
    if abs(offset) > 3 * ramp:
        return "extrinsic TRANSLATION (3D error is constant with range)"
    return "mixed / inconclusive - collect a wider range spread"

# On THIS chapter's DEBUG data - ranges 0.5/1.0/2.0 m, errors 1.8/3.5/7.0 mm,
# centred=True, and the five image radii 0/160/320/480/640 px whose residuals
# a 0.20 deg yaw predicts as 2.79/2.90/3.24/3.80/4.58 px:
#   fit 1 -> ramp  = 3.471 mm/m * 1.5 m = 5.21 mm  vs offset  0.05 mm -> direction
#   fit 2 -> growth = 0.0027925 * 640   = 1.79 px  vs r_offset 2.57 px
#   1.79 px of growth is 9x the 0.2 px noise  -> "extrinsic ROTATION"
# Feed it a flat 2.79 px residual instead (growth 0, r_offset 2.79) and the
# SAME 3D numbers now return "PRINCIPAL POINT". That is the whole Q3 branch,
# decided by fit 2, on data fit 1 cannot tell apart.
The unit bug in that function is the point of showing it. Run the chapter's own DEBUG data through the naive version — ranges 0.5, 1.0, 2.0 m against errors 1.8, 3.5, 7.0 mm — and the least-squares fit gives slope = 3.471 and offset = 0.050, so abs(slope) > 3*abs(offset) is true and it correctly says rotation. Now log exactly the same physical experiment with ranges in centimetres: 50, 100, 200. The errors have not changed, the robot has not changed, but slope becomes 0.0347 while offset stays 0.050 — and the naive test now returns "inconclusive". A dimensional error that silently changes the diagnosis, in the artefact the chapter is selling as the load-bearing diagnostic.

The fix is one multiplication: ramp = abs(slope) * span converts mm-per-metre into millimetres of growth across the sweep, which is the same unit as the offset. In metres: 3.471 × 1.5 = 5.21 mm of ramp against 0.05 mm of offset. In centimetres: 0.0347 × 150 = 5.21 mm — identical, as it must be. Comparing two quantities of different dimension is the bug class that separates a senior engineer from a staff one, because it survives every unit test written with the same units the author had in mind.
Which fit fires when, because a function with a limb that never executes is not a diagnostic, it is decoration.

Fit 1 — 3D error vs range — always runs, and it answers exactly one question: ray origin or ray direction? Flat with range ⇒ extrinsic translation, and the function returns there and stops, because nothing in an image residual would add anything. That is the only verdict fit 1 is competent to reach alone.

Fit 2 — image residual vs radius — runs on every ray-direction verdict, which is the branch fit 1 explicitly cannot finish. It reports two numbers with direct physical readings: r_offset is the residual extrapolated back to the principal point, and growth is how much it gains out to the frame edge. Map them onto the five shapes from the top of the chapter and the branch order writes itself — focal (which carries a factor xn) and distortion (which carries r²xn) both vanish at the centre, so large growth with near-zero offset means intrinsic; a yaw is fθ(1 + xn²), so offset and growth means rotation; a cx shift is flat, so offset with no growth means principal point.

Why growth is compared against noise and not against r_offset. On the chapter's own yaw the two are 1.79 px and 2.57 px — a ratio of 0.64, nowhere near 3×, so an offset-relative test would have thrown the rotation away. The bulge from a small yaw is genuinely smaller than its own constant term; that is what (1 + xn²) means. The question is not "is the growth big compared to the offset" but "is the growth big compared to what I can measure", which is why noise_px is a parameter and not a magic number. At 0.2 px per corner the 1.79 px of growth is a 9σ call, and it is bigger still once you average over a few hundred corners.

And the centred flag still earns its place, but now as an economy rather than an excuse: when the 3D sweep was centred, the focal term was already multiplied by xn = 0 and cannot be the cause, so the focal branch is skipped and fit 2 spends its resolving power on the rotation-versus-principal-point split. When the sweep was off-axis, focal is still live and fit 2 excludes it first. Either way the function returns a named subsystem, not a punt — and where it genuinely cannot decide, the last return says so in the specific terms of what more data would fix it.

Fifty-odd lines that turn "the robot is inaccurate" into a subsystem name. Writing a diagnostic like this is worth far more than reciting the projection equations, which everyone already knows. A diagnostic that reports "I cannot distinguish these two from this data" is worth more than one that guesses — but a diagnostic that computes a second fit and then ignores it is worse than either, and the earlier version of this function did exactly that. Making the second fit load-bearing is the difference between code that looks like a decision procedure and code that is one.

the production tools
# Intrinsics + stereo extrinsics, checkerboard or ChArUco:
cv2.calibrateCamera(...)        # returns K, dist, per-view rvecs/tvecs
cv2.stereoCalibrate(...)        # returns R, T between the two cameras

# Camera-IMU extrinsics AND the time offset, jointly (Kalibr):
#   kalibr_calibrate_imu_camera --bag rig.bag --cam cams.yaml --imu imu.yaml

# LiDAR-camera and multi-sensor rigs: OpenCalib, direct_visual_lidar_calibration
# Online, inside the estimator: OpenVINS estimates K, extrinsics and the
# time offset as part of the filter state.

DEBUG — the scenario we opened with, resolved

Back to the top of the chapter. The grasp success rate fell from 96% to 78% over three weeks, nothing shipped, and the failures are all on the far bin. Now you have the machinery, so run it.

Say the symptom first, because the symptom is already a measurement. The failures are concentrated on far objects. Picks at the near edge of the workspace still succeed; picks at the far edge miss. Nobody noticed at first because the far bin is used less. From the two-sentence classifier: an error that grows with range is a ray-direction fault, not a ray-origin one, which rules out extrinsic translation before you have touched a tool.

The metric that reveals the rest: place a calibration target at 0.5 m, 1.0 m and 2.0 m, centred in the frame each time — that detail is doing real work, and worked example 5 is why — measure the 3D position error at each, and fit a line.

RangeMeasured 3D errorerror ÷ range
0.5 m1.8 mm1.8 / 0.5 = 3.6 mm/m
1.0 m3.5 mm3.5 / 1.0 = 3.5 mm/m
2.0 m7.0 mm7.0 / 2.0 = 3.5 mm/m
The fit, shown rather than asserted — because with three points it is two divisions and that is exactly the point.

Step 1: divide each error by its range. 1.8/0.5 = 3.6, 3.5/1.0 = 3.5, 7.0/2.0 = 3.5 mm/m. The ratio is constant. A constant error-to-range ratio is the definition of a line through the origin — y/x = k means y = kx — so no least-squares call is needed and you can do it in your head while the numbers are still being read out.

Step 2: convert the slope to an angle. 3.5 mm/m = 0.0035 m/m = 0.0035 rad (for small angles the tangent is the angle), and 0.0035 × 180/π = 0.2005°. Call it 0.20° of extrinsic yaw.

Step 3: check it against the alternative, because a fit you have not falsified is not a diagnosis. Had the fault been an extrinsic translation of 1.8 mm, the three errors would all have read 1.8 mm, so the ratios would have been 1.8/0.5 = 3.6, 1.8/1.0 = 1.8, 1.8/2.0 = 0.9halving every time the range doubles. Two divisions distinguish the two hypotheses, and the near-range point alone (3.6 both times) does not. That is why you need the far measurement, and it is the sentence to say when someone asks why one range is not enough.

Step 4: what the fit does NOT settle. A line through the origin is also what a focal-length error produces — worked example 5 gave 1.98 / 3.96 / 7.92 mm, ratios 3.96 / 3.96 / 3.96, the same shape. The centred target is what excludes it: a focal residual is proportional to xn, and xn was zero. Say "these were all centred, which is what rules out focal" out loud — it is one clause, and it is the difference between an answer that survives a follow-up and one that does not.

Step 5: the other thing the fit does not settle, and this one you have to close, not wave at. Centring the target killed focal — but Q2 of the decision procedure says centring leaves two hypotheses standing, and the second one is a principal-point error. So put a number on it before claiming a verdict. A cx that is wrong by δcx back-projects to a lateral error of Z·δcx/f, so ask what δcx would fake this data: set Z·δcx/f = Z·θ, and δcx = fθ = 800 × 0.0034907 = 2.79 px. Push that back through the sweep: 3.49 mm/m again, so 0.5 m → 1.75 mm, 1.0 m → 3.49 mm, 2.0 m → 6.98 mm. Those are the measured 1.8 / 3.5 / 7.0 to within the measurement. A 2.79 px principal-point error and a 0.20° yaw are, on this table, the same table. The range sweep cannot choose, and if you stop here your verdict is a coin flip you did not know you were making.

The measurement that does choose costs one image. Compare the reprojection residual at the centre of the frame against the residual at the edge, xn = 0.8. A yaw is fθ(1 + xn²): 2.79 px → 2.79 × 1.64 = 4.58 px, a ratio of 1.64 — and note that 1.64 is just 1 + 0.8², so you can quote it without computing anything. A pure cx shift is f-independent and position-independent: 2.79 px → 2.79 px, ratio 1.00. The two hypotheses predict edge residuals that differ by 4.58 − 2.79 = 1.79 px, against roughly 0.2 px of per-corner feature-localisation noise. That is a ~9σ separation on a single corner, and you have hundreds of corners near the frame edge to average, so in practice it is not close. This instance IS decidable — the undecidable branch in Q3 is the one where an unmodelled k1 can also manufacture edge growth, and here the lens is a fixed, previously-validated calibration that did not change when the grasp rate did.

Verdict: extrinsic rotation, 0.20° of yaw — and the edge-versus-centre residual ratio is what excludes a principal-point shift. Say the second clause. The cross-check in Step 5 is not colour, it is the step that earns the verdict: without it you have named one of two indistinguishable causes and prescribed a mechanical fix for what might be a YAML typo. Run it, confirm the residuals really do climb from 2.79 px at the centre to about 4.6 px at the edge, and if they come back flat at 2.79 the story is wrong — the answer is cx, nobody touched the mount, and you go and look at what wrote the calibration file instead.

0.20° is about a fifth of a degree — you cannot see it, you cannot feel it by hand, and it is exactly what two loose M3 screws and three weeks of vibration produce. Note also how small the cause is relative to the effect: 0.20° at the far bin is 7 mm of miss, which for a 20 mm gripper aperture on a 15 mm part is precisely the margin. The fix is Loctite and a torque spec, not a recalibration, because a recalibration will hold for another three weeks and then you will be back. Saying that out loud is what separates fixing the symptom from fixing the system.

The follow-up question that always comes: "how do you know it drifted rather than being wrong from the start?" Two answers, and give both. (1) The success rate was 96% for months — a calibration that was wrong at manufacture would never have produced 96%, so the parameter moved. (2) Diff the file. This is the payoff of the versioning discipline in the DESIGN section: if extrinsics are stored per-unit with a date, you subtract today's values from the factory values and read the drift directly, in degrees, with a timestamp. Without that discipline you can only re-measure, which tells you the current value and nothing about the trajectory. The design decision and the debugging story are the same decision, which is the connection worth internalising.
Second failure mode, and the one that fools everyone: reprojection error stays green. A team validates calibration by reprojection RMS and it reads 0.21 px — excellent — while the robot is 8% short on every distance. Reprojection error is blind to a uniform metric scale error, because a scaled scene viewed by a scaled rig lands on the same pixels. The metric that does catch it is external: drive or reach a tape-measured distance and compare. Any pipeline whose only accuracy check is an internal, self-consistent residual will eventually be confidently wrong in metres. Every geometric system needs at least one measurement that comes from outside itself.

FRONTIER — calibration that stops being an event

The direction of travel is clear: from calibration as a one-off procedure with a target, toward calibration as a continuously estimated part of the state.

The tradeoff to defend. Keep an offline, target-based calibration as the reference — it is accurate, repeatable and auditable — and run online estimation on top as a monitor, not as the source of truth. The online estimate tells you when the mount has moved, which is a maintenance signal you otherwise do not have. What you should not do is let an online estimator freely correct extrinsics in production, because observability depends on the motion the robot happens to be doing, and a robot on a repetitive straight-line task will have parameters that are unobservable for hours at a time. That is how you get a slow drift into a calibration nobody chose.

PRACTICE — the sixty-second answer, and three to classify cold

Everything above is knowledge. This section is the performance, which is a separate skill. Rehearse it out loud — the failure mode at a broken robot is never "I did not know the (1 + xn²) factor", it is "I knew it and produced it in the wrong order, after ninety seconds of silence".

Drill 1 — the sixty-second spoken answer to "the grasps got worse; go". Time yourself. Four beats, in this order, and do not let yourself skip beat 2.

(0–10 s) Restate the symptom as a measurement. "Near picks work, far picks fail. That is already telling me the error grows with range, which points at the ray direction rather than the ray origin."
(10–25 s) Name the experiment before naming the cause. "I would put a target at 0.5 and 2 metres, centred, and measure the 3D error at each. Five minutes with a tape measure."
(25–45 s) Give the decision rule with its arithmetic. "Divide error by range. Constant ratio means a line through the origin, which is extrinsic rotation — and the slope in mm per metre is the angle in milliradians. Halving ratio means a flat error, which is extrinsic translation."
(45–60 s) Volunteer both limitations — there are two, and naming only one is the trap. "A range sweep leaves me with two ambiguities, not one. First, focal versus rotation: both ramp through the origin, so I centre the target — that kills the focal term because it carries a factor of xn, and leaves the rotation term alone. Second, and this is the one people forget: centring does not separate rotation from a principal-point error. A cx off by fθ — 2.79 px on this camera — reproduces my three range numbers exactly. What splits those two is the image, not the range: I compare the residual at the centre against the residual at the edge. A yaw grows as 1 + xn², so 2.79 px becomes 4.58 at xn = 0.8; a principal-point shift stays at 2.79. That is 1.79 px of difference against about 0.2 px of feature noise, so it is a clean call. Range separates origin from direction, image position separates the direction faults from each other, and I need both axes."

The last beat is worth more than the other three combined. Anyone can produce a decision rule; volunteering the case where your own rule fails is the signal that you have run the experiment rather than read about it.
Drill 2 — classify three cold, with the reasoning out loud. Cover the answers.

(a) A LiDAR–camera fusion node has projected points landing 6 px left of their true edges near the image centre and 6 px left near the corners, identically, on every frame. Depth is irrelevant — near and far objects show the same 6 px. What is it?
Answer: constant in position and constant in depth. Constant-in-depth rules out translation (that falls off as 1/Z); constant-in-position rules out focal and distortion (both carry xn). That leaves principal point, or an extrinsic yaw with the (1 + xn²) growth too small to see — at 6 px the edge bulge would be about 4 px, so if the corners really read 6.0 and not 10, it is cx. State both and say which measurement separates them.

(b) A mobile robot's wheel odometry closes a 4 m square perfectly in heading but ends 30 cm short of the start, consistently, on every lap. What is it?
Answer: a pure distance scale with no rotation error ⇒ wheel radius. 30 cm over 16 m of path is 1.9% — and note this is the wheel-odometry instance of the stereo-baseline signature: a metric input is wrong, every distance is short by the same percentage, and no internal consistency check will ever see it.

(c) A stereo depth camera reads 2.16 m for a target a tape measure says is 2.00 m, and 4.32 m for one at 4.00 m. Reprojection RMS is 0.2 px. What is it?
Answer: 8% long at both ranges — a constant percentage, not a constant offset and not a growing angle. Baseline, or the calibration target's stated square size, which is the same bug one level up. The healthy reprojection number is the confirmation, not the contradiction: disparity is unchanged, so the pixels still agree.
Drill 3 — the whiteboard reconstruction, from nothing. Give yourself a blank page and three minutes. Write u = f·xn + cx, then derive all five residual shapes by perturbing one symbol at a time: δf → xn, δcx → 1, yaw → 1 + xn² (rotate the ray, then re-divide), δt → 1/Z, δk1 → r²xn. If you can produce those five in three minutes you never need the discriminator table again, because you can regenerate it — and regenerating beats reciting whenever there is a follow-up question.

The one sentence to carry out of this chapter. If you remember nothing else about calibration: "Extrinsics and intrinsics enter the measurement on opposite sides of the perspective divide, so extrinsic errors are modulated by depth and intrinsic errors are modulated by image position — which means a target at two ranges and two image positions partitions the entire fault space in five minutes." That sentence contains the mechanism, the consequence and the experiment, in that order, which is the shape every good technical answer has.

A stereo rig measures a target's 3D position with 4.1 mm of error at 0.6 m, 4.0 mm at 1.5 m and 4.2 mm at 3.0 m, target centred in the frame each time. Reprojection RMS is 0.19 px. What is broken?

Chapter 7: Showcase — A Live tf-Tree Inspector You Can Break

02:40, Tuesday night shift, cell 4. The humanoid has been lifting totes off a conveyor for six weeks without a complaint. Tonight it is missing the bin by roughly 15 cm — but only on left-hand picks. Right-hand picks land clean. The arm was recalibrated on Friday and its report is green. The gripper closes on empty air at exactly the moment it should, so the timing is not the problem. The detector's boxes sit perfectly on the totes in the debug image. The line supervisor gives you twenty minutes before cell 4 goes to manual and the shift's numbers are gone.

Nothing in that paragraph tells you which subsystem is wrong, and that is the whole difficulty: a frame bug is invisible to every component that does not span the broken edge. The arm's calibration only ever measures the arm against itself. The detector's confidence only ever scores pixels. Each subsystem certifies itself and the robot still misses. The only thing that can see the fault is the end-to-end geometry, and the only tool that localises it is the one this chapter builds: make the error a function of something you can turn, then find out what it is a function of.

The data flow the widget stands for, with the numbers. The localizer publishes odom → base_link as a nav_msgs/Odometry at 50 Hz, mirrored into tf. The motor controller publishes sensor_msgs/JointState on /joint_states at 200 Hz — parallel name[], position[], velocity[] arrays — and robot_state_publisher turns each revolute joint into a tf edge at that same 200 Hz. torso → shoulder and torso → head_camera are static: latched once on /tf_static at boot and never republished. The detector publishes a geometry_msgs/PoseStamped in camera_color_optical_frame at 30 Hz. The IK consumes exactly one thing: a single 4×4 in odom. Four frames, five edges, three message types, and one number at the end that is either right or 15 cm wrong.

Where the bytes and the milliseconds go. A TransformStamped is 7 doubles of payload — 56 bytes, roughly 100 with the header and bookkeeping — so two dynamic edges at 200 Hz is 400 messages/s, about 40 kB/s. Bandwidth is not the constraint here and saying so is part of the answer. Latency is. The detection is stamped at time t, and if the consumer looks up "the latest tf" instead of tf at t, it uses joint angles that are up to a full detector period stale. At 30 Hz that is 33 ms; with the torso slewing at 60 °/s that is 2.0° of waist rotation, and 2.0° = 0.035 rad across the 0.55 m lever arm from the waist pivot to the gripper is 1.9 cm of pure timing error with every transform in the tree perfectly correct. Hold that number: it is the noise floor against which every fault below has to be distinguished, and it is why Lesson 2 exists.

Below is that humanoid on the pick cell, viewed from above. Drag the base anywhere in the scene. Turn the torso. Turn the gripper. The teal chain is the true kinematic chain, drawn frame by frame from odom down to gripper. The orange ghost is what the software believes, once you inject a bug.

The readout below the scene gives you the true gripper position, the commanded position, the error in centimetres, and the exact algebraic reason for the gap. The point of this widget is not the picture — it is the relationship between the control you move and the number that changes. Each bug has a distinct signature, and by the end of this chapter you should be able to predict which control makes which error grow, and to how many centimetres, before you touch it. Every number quoted in this chapter is one the widget will print back at you; if any of them disagrees, one of us is wrong and it is worth ten minutes to find out which.

The tf tree

Five frames, four edges. When a bug is active the offending edge turns red and names itself.

Pick cell — drag the base, break the chain

Click or drag anywhere in the scene to move base_link. Then pick a bug and watch the ghost separate.

base yaw15°
torso yaw−35°
shoulder25°
 

CONCEPT — the five constants this robot is actually made of

A showcase widget you cannot open is a black box with a nice colour scheme, and a black box teaches nothing. So here is the entire robot. It is five planar rigid transforms, each written P2(θ, x, y) — rotate by θ about z, then translate by (x, y) in the parent's axes. Every number the readout prints falls out of these five and nothing else.

#EdgeThe literal, exactly as composedWhat it is physicallyPublished by
1odom ← base_linkTb = P2(β, bx, by)The localizer's answer. β is the base yaw slider; (bx, by) is wherever you dragged the base. The only edge that knows anything about the world.localizer, 50 Hz
2base_link ← torsoTt = P2(ψ, 0.04, 0)The waist pivot, sitting 4 cm forward of base_link's origin, rotating by the waist encoder ψ — the torso yaw slider.joint stream, 200 Hz
3torso ← shoulderTs = P2(−0.25, 0.06, −0.18)A static mount: 6 cm forward, 18 cm to the robot's right (negative y under REP-103), and tilted −0.25 rad = −14.32° about z. It carries an offset and a rotation, which is precisely the shape that makes Chapter 0's bug bite./tf_static, once
4shoulder ← gripperTa = P2(σ, 0.42, 0)A 42 cm upper arm: the gripper origin sits 42 cm out along the shoulder's x-axis. σ is the gripper's own yaw about that point — the shoulder slider.joint stream, 200 Hz
5torso ← head_cameraTc = P2(0, 0.08, 0)The head camera, 8 cm forward of the waist pivot and axis-aligned with the torso (no relative rotation, which is what makes the optical-convention bug isolable)./tf_static, once
the detectionpcam = (0.35, 0.10) mThe tote, as the camera reports it: 35 cm ahead of the lens, 10 cm to the camera's left. A PoseStamped at 30 Hz, and the only measurement in the whole system.detector, 30 Hz

Compose them in order and the gripper's position in odom is one nested expression:

pgrip = tb + R(β) [ tt + R(ψ) [ ts + R(−0.25)·(0.42, 0) ] ]

Worked example — the true gripper at the default sliders, by hand. Load the chapter and touch nothing: base yaw β = 15°, torso yaw ψ = −35°, shoulder σ = 25°, base at (0.55, 0.55) m. Work outward from the arm.

StepArithmeticResult (m)
R(−0.25)·(0.42, 0)(0.42·cos 0.25, −0.42·sin 0.25) = (0.42·0.968912, −0.42·0.247404)(0.406943, −0.103910)
  + ts = (0.06, −0.18)(0.406943 + 0.06, −0.103910 − 0.18)  — call this u, it returns lateru = (0.466943, −0.283910)
R(−35°)·ux: 0.819152·0.466943 − (−0.573576)·(−0.283910) = 0.382489 − 0.162836
y: (−0.573576)·0.466943 + 0.819152·(−0.283910) = −0.267824 − 0.232565
(0.219653, −0.500389)
  + tt = (0.04, 0)(0.219653 + 0.04, −0.500389 + 0)(0.259653, −0.500389)
R(15°)·thatcos 15° = 0.965926, sin 15° = 0.258819
x: 0.965926·0.259653 − 0.258819·(−0.500389) = 0.250806 + 0.129510
y: 0.258819·0.259653 + 0.965926·(−0.500389) = 0.067203 − 0.483339
(0.380316, −0.416136)
  + tb = (0.55, 0.55)(0.550 + 0.380316, 0.550 − 0.416136)(0.930, 0.134)

Now read the widget's first line. It says true gripper = (0.930, 0.134) m. That agreement is the licence to trust every other number in this chapter — you have verified the instrument against your own arithmetic once, so from here on a disagreement means a real fault rather than a broken toy.

The null lever arm — look at what is missing from that expression.

σ, the shoulder slider, does not appear anywhere in pgrip. Sweep it from −90° to +90° with no bug selected and the gripper's approach arrow turns while its origin does not move by a micrometre. That is not a defect in the widget; it is the structure of the chain. Tshoulder←gripper has a constant translation (0.42, 0), and a rotation applied at the leaf of a chain rotates the leaf's axes about its own origin — it can never move that origin.

The consequence that matters, and it is the caveat step 1 of the bisection below badly needs: a joint sweep that does not move the error does not exonerate that joint. It either means the joint is above/below the fault, or that the joint has no lever arm to the quantity you are measuring — in which case it is not innocent, it is unobservable. Before you conclude "joint 7 is fine", check that joint 7 could have moved your metric at all. On a real 7-DOF arm the wrist roll is exactly this: it cannot move the tool-centre point when the tool is on the roll axis, so a wrist-roll extrinsic error is silent in position and screams in orientation. Measure the thing the joint can actually move.

CONCEPT — deriving all five error laws before you touch a slider

The experiments below are worth nothing if you run them and then rationalise what you saw. Derive first, predict a number, then look. Four of the five laws need one small identity, so derive that once.

The (I − R) identity. For any planar rotation R(θ) and any vector v:

‖(I − R(θ))v‖2 = ‖v‖2 − 2 v·R(θ)v + ‖R(θ)v‖2

A rotation preserves length, so the last term is ‖v‖2. And v·R(θ)v = ‖v‖2 cos θ, because rotating v by θ puts it at angle θ from itself. So the whole thing is 2‖v‖2(1 − cos θ), and the half-angle identity 1 − cos θ = 2 sin2(θ/2) collapses it:

‖(I − R(θ))v‖ = 2 ‖v‖ sin(|θ|/2)

Linear in the offset, and in the angle it starts at exactly zero, rises almost linearly for small θ, and reaches 2‖v‖ at a half turn. "Zero at identity, growing like 2 sin(half the angle)" is the fingerprint of a missing rotation, and you now own it for the rest of your career.

Law 1 — unrotated offset. The code builds base_link ← shoulder by composing the rotations correctly but merely adding the translations: t = tt + ts instead of tt + R(ψ)ts. Because the rotation part is untouched, everything downstream (Ta) contributes identically to both chains and cancels. What survives is the translation gap, carried into odom by the base rotation:

e1 = R(β)·(I − R(ψ)) ts  ⇒  ‖e1‖ = 2 ‖ts‖ sin(|ψ|/2)

with ‖ts‖ = √(0.062 + 0.182) = √0.036 = 0.18974 m. R(β) is a rotation, so it cannot change the magnitude — which is the entire content of experiment 2.

Law 2 — reversed compose. The chain multiplies TsTt where it should multiply TtTs. Both products have the same total rotation, ψ − 0.25, so again Ta cancels and only the translations differ:

e2 = R(β)·[ (R(−0.25)tt + ts) − (R(ψ)ts + tt) ]

Law 3 — wrong inverse. A round trip base → odom → base through an inverse that negated t instead of applying −Rt leaves Tb·Tbbad −1, whose rotation cancels to identity and whose translation is the residual:

e3 = (I − R(β)) tb  ⇒  ‖e3‖ = 2 ‖tb‖ sin(|β|/2)

Law 4 — flipped quaternion sign. The torso executes ψ + 180°. Since R(ψ + π) = −R(ψ), everything hanging below the waist pivot is reflected through it, and the gap is twice that sub-chain's offset. The sub-chain's offset is exactly the u you already computed:

e4 = 2 R(β)R(ψ) u,   u = ts + R(−0.25)(0.42, 0)  ⇒  ‖e4‖ = 2‖u

Law 5 — optical frame skipped. Here the kinematics are untouched and the measurement is corrupted: (right, down, forward) read straight into (x, y, z), so (0.35, 0.10) is consumed as (−0.10, 0.00). The fault is the target's displacement, a fixed vector in the camera frame rotated into odom:

e5 = Rcam·[(0.35, 0.10) − (−0.10, 0.00)] = Rcam·(0.45, 0.10)  ⇒  ‖e5‖ = √(0.2025 + 0.01) = 0.46098 m
Read the bug-5 readout carefully — the instrument is measuring something else.

For bugs 1–4 the readout's error row is the fault size, because both chains are asked for the same thing and disagree. For bug 5 nothing in the chain is wrong, so there is no second gripper to difference against. What the widget prints instead is the distance from where the arm currently stands to the pose the mis-decoded detection commands:

  error = ‖ pgripper − ptarget, mis-decoded

That number is 56.37 cm — and here is the part that catches people out: it does not move. Not when you turn the torso, not when you turn the base, not when you drag the base across the cell, not when you sweep the shoulder. Check it against the widget rather than taking it on trust: (β, ψ, σ) = (15°, −35°, 25°) with the base at (0.55, 0.55) prints 56.37 cm, and (90°, 60°, −80°) with the base at (1.10, 0.20) prints 56.37 cm. It is constant by construction, and the construction is worth two minutes because it is the same argument that makes experiment 2 work.

Why it is invariant — the arithmetic, in the torso frame. Both endpoints are rigid in torso. The gripper origin expressed in torso is the u the worked example already computed,

  u = ts + R(−0.25)·(0.42, 0) = (0.06, −0.18) + (0.406943, −0.103910) = (0.466943, −0.283910)

and note that σ is absent from it — the null lever arm again, a leaf rotation cannot move its own origin. The mis-decoded target expressed in torso is Tc applied to (−0.10, 0.00), and Tc = P2(0, 0.08, 0) is a pure 8 cm translation with no rotation, so it is simply

  (0.08 − 0.10,  0 + 0.00) = (−0.020000, 0.000000)

Subtract them, still in torso:

  d = (0.466943 − (−0.020000),  −0.283910 − 0.000000) = (0.486943, −0.283910)
  ‖d‖ = √(0.4869432 + 0.2839102) = √(0.237113 + 0.080605) = √0.317718 = 0.563665 m = 56.37 cm

Everything above the torso — R(ψ) at the waist, R(β) at the base, and tb wherever you dragged it — acts on d as one rigid motion, and rigid motions preserve length. The readout has no choice but to print 56.37. So it is not a fault measurement at all: it is "how far the arm is parked from a target it was told wrongly", and it mixes the fault with a parking choice you made with the sliders.

The fault is a different constant. The fault is the displacement between the true and the mis-decoded target, on the target offset row: ‖(0.45, 0.10)‖ = √(0.2025 + 0.01) = 0.460977 m = 46.10 cm. Two constants on one screen, 56.37 and 46.10, and they are not the same number — which is itself the tell. 56.37 answers "where is my arm relative to a lie". 46.10 answers "how big is the lie". Only the second one is the bug.

So what actually names bug 5? Not a sweep — you can move every control on the panel and both numbers sit still. Two things name it, and both are structural rather than numerical:
  1. Bug 5 is the only bug that populates the target offset row at all. For bugs 1–4 that row is not printed, because the measurement path is untouched, and a row that only appears for one fault is a perfect discriminator.
  2. Bug 5 is the only bug that changes what the target looks like. Press it and a red believed-target square opens up 46.10 cm from the green true one, joined by a red dashed line, while the teal kinematic chain stays exactly where you put it — to the pixel. Every other bug moves the orange ghost gripper and leaves the target alone.

Knowing which of your dashboard's numbers is the fault and which is an artefact of the dashboard is not pedantry; it is the difference between chasing a 56 cm ghost and fixing a 46 cm bug. And on a real robot the artefact is more dangerous than it is here, because there the 56 cm does move: the arm is servoing toward the bad target, so the "error" your dashboard reports is a function of the controller's transient and will look, convincingly and for days, like a control problem.

Now the payoff table. Every one of these is computable before you click anything, and all six are readable off the widget at the default sliders — press the six buttons in turn and change nothing else.

BugError lawGrows withBlind toExactly zero whenAt the default pose
unrotated offset2‖ts‖ sin(|ψ|/2)torso yaw ψbase pose, base yaw, σψ = 02(0.18974)sin(17.5°) = 11.41 cm
reversed compose‖(R(−0.25)tt + ts) − (R(ψ)ts + tt)‖torso yaw ψbase pose, base yaw, σnever at any ψ on this robot11.31 cm
wrong inverse2‖tb‖ sin(|β|/2)base distance and base yawψ, σ, the whole armβ = 0, or the base at the odom origin‖tb‖ = √(2·0.552) = 0.77782, so 2(0.77782)sin(7.5°) = 20.31 cm
flipped quaternion2‖unothingeverythingnever (unless the sub-chain has zero offset)2(0.54648) = 109.30 cm
optical skipped‖(0.45, 0.10)‖nothingeverythingnever (unless the detection is at the optical origin)46.10 cm target offset

Two columns there do more work than the rest: "blind to" is what bisects the chain, and "exactly zero when" is what separates a missing rotation from a structural fault in a single control move. Every diagnosis below comes from those two.

What is being simplified here, and what survives the promotion to 3D. This robot is planar — SE(2), one rotation axis, three degrees of freedom per frame instead of six. That is a real simplification and you should name it yourself before anyone else has to. What changes and what does not:

The identity generalises with one modification. In SE(3), a rotation of angle θ about a unit axis n leaves the component of v along n untouched and rotates only the perpendicular part, so
  ‖(I − R)v‖ = 2 ‖v‖ sin(|θ|/2),   v = v − (v·n)n
In the plane every offset is perpendicular to z, so v = v and the formula above is the special case. The practical consequence is a genuine trap: a missing rotation about the waist's z-axis is completely invisible in the height of a mount that sits directly above the pivot, because that offset is parallel to the axis. Bisect with a joint whose axis is not parallel to the offset you are hunting, or you will exonerate the guilty edge.

What does not change: zero-at-identity, invariance-above-the-fault, constancy for representation faults, and the whole bisection. Those are properties of the group structure — that R is orthogonal and that composition is associative but not commutative — and SE(2) is a subgroup of SE(3), so nothing you learn here has to be unlearned. What does change is that SO(3) rotations do not commute, so a swapped order in 3D corrupts the orientation as well as the position, and you get a second, independent signal for free: check the relative rotation, not just the position, and the order swap announces itself.

What to do with it — five experiments, five signatures

Run these in order. Each one is a diagnostic you can carry into a real robot, and the whole point is that the shape of the dependence names the bug without reading a single line of code. Predict the number from the laws above before you move the slider — a prediction you have written down is a test; a prediction you make after seeing the answer is a memory.

#ExperimentWhat you should seeWhat it proves
1Select unrotated offset. Set torso yaw to 0. Then step it through 45° and on to 90°.0.00 cm at ψ = 0. 14.52 cm at 45°. 26.83 cm at 90° — the ceiling, 2‖ts‖. At −45° the magnitude is 14.52 cm again but the orange ghost sits on the opposite side of the true gripper.Chapter 0's bug. Rotation-dependent, exactly zero at identity, sign-flipping — the signature that survives a bench test. Growth is 2 sin(|ψ|/2)·‖ts‖, so it is sub-linear: doubling 45° to 90° multiplies the error by 1.85, not 2.
2Same bug. Leave torso yaw at 45°. Now drag the base to every corner of the cell and sweep base yaw across its full ±180°.The error stays at 14.52 cm. Not "roughly" — the readout does not change in the second decimal place, at any base pose, at any base yaw, at any shoulder angle.The fault is on the torso→shoulder edge, not upstream. R(β) is orthogonal, so it moves the error vector around and cannot touch its magnitude. Errors invariant to everything above an edge localise the fault below it — that is how you bisect a chain.
3Select reversed compose. Set torso yaw to 0, the setting that killed bug 1 stone dead.The error is 1.00 cm — small, but not zero, and it does not move when you change the base or the shoulder. Take torso yaw to 30° and it is 10.50 cm; to −90° and it is 27.18 cm; to +90° and it is 27.78 cm. Note the asymmetry: unlike bug 1 this one is not symmetric in ±ψ.Order errors and unrotated-offset errors are different faults, and one control move separates them: at ψ = 0 a missing rotation gives exactly 0.00 and an order swap gives 1.00. The asymmetry in ±ψ is a second, independent tell — (I − R)t is even in θ's magnitude, a swapped product is not.
4Select wrong inverse. Hold base yaw at 15° and drag the base from ~0.2 m out to ~1.0 m out. Then set base yaw to 0 and drag anywhere.With base yaw at 15° the error grows linearly in distance: 5.22 cm at 0.2 m, 26.11 cm at 1.0 m. With base yaw at 0 the error is 0.00 cm no matter how far out you drag, and no matter what the arm is doing.The residual is (I − Rbase)tbase — it needs both a nonzero base rotation and a nonzero base translation. That is the half of the signature that matters: a bad inverse hides completely on a robot that never leaves its start pose facing forward, which is exactly the pose it gets unit-tested in.
5Select flipped quaternion sign. Then move everything — base, base yaw, torso, shoulder.109.30 cm. At every single setting, to the second decimal place, forever.A naive blend of q and −q normalises to the antipodal quaternion, so the torso executes ψ + 180° and the whole 0.55 m sub-chain below the waist reflects through the pivot: 2‖u‖ = 2(0.54648) m. Constant, huge, configuration-independent errors mean a representation fault, not a geometry fault — geometry faults are functions of configuration by construction. But finish the rule, because on this panel it leaves two suspects standing: bug 4 prints a constant 109.30 cm and bug 5 prints a constant 56.37 cm. Separate them by what moves, not by the number. Bug 4 displaces the orange ghost gripper — the kinematics are wrong, and the green target square never budges. Bug 5 leaves the teal chain exactly where you put it and splits the target instead — the kinematics are right and the measurement is wrong. The one-move test: zero every joint and drive the base back to the odom origin. Bugs 1 and 3 go to 0.00 cm and bug 2 collapses to 1.00 cm, but both constants survive that pose untouched — so the discriminator is not the sweep at all, it is which object on the canvas jumped.
6Select optical frame skipped. Move the torso, then the base.A red believed-target square opens up 46.10 cm from the green true one, joined by a red dashed line; the green square itself never moves. The teal arm never moves except where you moved it. The target offset line reads 46.10 cm at every setting — and so does the error row, at a rock-steady 56.37 cm. Do not go hunting for variation in either number: there is none, at any slider setting, at any base pose. Both are lengths of torso-frame vectors (the callout above derives the 56.37 from u and Tc), and a rigid motion above the torso cannot change a length.Convention faults corrupt the measurement, not the kinematics. The arm is innocent, and blaming it costs a week. Note the size: 46 cm is far larger than any arithmetic slip, because axis permutations move things by whole coordinates rather than by small residues. And this is the one experiment whose signal is not a dependence but an appearance: the target offset row exists, and there are suddenly two target squares. Check the tf-tree canvas too — exactly one arrow is red, and it is torso → head_camera. The shoulder → gripper arrow stays grey, which is the picture saying what the algebra says: the fault is on the camera edge, and the arm is not a suspect.
Worked example 1 — predict experiment 1 at torso yaw 45°, then go read it off the widget.

The shoulder mount is ts = (0.06, −0.18) m. At ψ = 45°, cos 45° = sin 45° = 0.70711, so

  R(45°) ts = (0.70711·0.06 − 0.70711·(−0.18),  0.70711·0.06 + 0.70711·(−0.18))
             = (0.042426 + 0.127279,  0.042426 − 0.127279) = (0.169706, −0.084853)

The buggy chain used ts raw where it should have used R(45°)ts, so the error vector is the difference:

  e = ts − R(45°)ts = (0.06 − 0.169706,  −0.18 + 0.084853) = (−0.109706, −0.095147)

  ‖e‖ = √(0.1097062 + 0.0951472) = √(0.012035 + 0.009053) = √0.021088 = 0.14522 m = 14.52 cm

Now the same number from the closed form, as a check on both: ‖ts‖ = √(0.062 + 0.182) = √(0.0036 + 0.0324) = √0.036 = 0.18974 m, and

  2 sin(22.5°) · 0.18974 = 2(0.382683)(0.18974) = 0.14522 m

Identical, to five digits, by two independent routes. Now set the torso slider to 45° and read the widget: it says 14.52 cm. That is the entire reason to ship a simulation with a numeric readout instead of a pretty picture — a chapter that never quotes a number from its own instrument has not actually verified anything, and neither has its reader.
Worked example 2 — how big is "not zero"? Experiment 3 at torso yaw 0.

"The error is not zero" proves nothing on its own: the reader cannot tell whether that means 1 mm or 1 m, and a red readout at 1.00 cm looks exactly like noise to somebody who does not know what to expect. So compute it. At ψ = 0, Tt = P2(0, 0.04, 0) is a pure translation, so its rotation block is the identity.

Correct order, TtTs. The translation is R(0)ts + tt:
  (1·0.06 − 0·(−0.18) + 0.04,  0·0.06 + 1·(−0.18) + 0) = (0.100000, −0.180000)

Reversed order, TsTt. Now Ts's rotation acts on tt, with cos(−0.25) = 0.968912 and sin(−0.25) = −0.247404:
  x: 0.968912·0.04 − (−0.247404)·0 + 0.06 = 0.038756 + 0.06 = 0.098756
  y: (−0.247404)·0.04 + 0.968912·0 − 0.18 = −0.009896 − 0.18 = −0.189896
  ⇒ (0.098756, −0.189896)

Difference = (0.098756 − 0.100000,  −0.189896 + 0.180000) = (−0.001244, −0.009896)
  ‖·‖ = √(1.547×10−6 + 9.793×10−5) = √(9.948×10−5) = 0.00997 m = 1.00 cm, and the widget agrees.

And now the part worth saying out loud: why does the 42 cm arm not amplify this? Intuition says a 1 cm error at the shoulder should be levered out by a long arm. It is not, because both orders produce the same total rotation — β + ψ − 0.25 + σ — so Ta rotates by the same amount in the true and the believed chain and contributes an identical displacement to each. It cancels exactly in the difference. The residual is purely the translation gap, rigidly carried into odom by R(β): 1.00 cm no matter where the arm points and no matter where the robot stands. An order swap between two frames whose rotations commute in sum is a pure offset fault, and offset faults do not lever.
Why experiment 5 lands on exactly 180° — the derivation, because it is a lovely result.

Two sources report the torso orientation and they agree: the encoder gives q1 = q(θ) and the IMU gives q2 = −q(θ′) with θ′ ≈ θ, sign-flipped somewhere upstream. Somebody averages them componentwise and normalises.

For a rotation about z, q(θ) = (cos(θ/2), 0, 0, sin(θ/2)). Take the first-order Taylor expansion about θ:
  q1 + q2 = q(θ) − q(θ′) ≈ (θ − θ′) · d q/dθ

and the derivative already carries its own factor of ½ from the half-angle:
  d q/dθ = ½(−sin(θ/2), 0, 0, cos(θ/2))

so the sum is
  q1 + q2(θ − θ′)/2 · (−sin(θ/2), 0, 0, cos(θ/2))

(Apply the ½ once, not twice — it lives inside dq/dθ. The scalar out front is (θ − θ′)/2, and since (−sin(θ/2), 0, 0, cos(θ/2)) is already a unit 4-vector, that scalar is the norm of the sum.)

The sum is therefore a tiny vector pointing in a perfectly definite direction, so normalising it blows it back up to unit length and what you get is exactly
  (−sin(θ/2), 0, 0, cos(θ/2))

Now compare that against the general form of a z-rotation by θ + 180°:
  q(θ + 180°) = (cos(θ/2 + 90°), 0, 0, sin(θ/2 + 90°)) = (−sin(θ/2), 0, 0, cos(θ/2))

Identical. The naive average of a quaternion and a sign-flipped near-copy is precisely the rotation 180° away. Not "noisy", not "unstable" — deterministically, exactly, half a turn wrong.

The concrete degraded case, and the threshold it gives you. Take two sensors that agree to a tenth of a degree: θ = 30° and θ′ = 29.9°. Then θ − θ′ = 0.1° = 0.0017453 rad, and the pre-normalisation norm is
  |θ − θ′|/2 = 0.00087266

That is 0.087% of unit length — the summed quaternion is nine ten-thousandths long, and normalisation multiplies it by 1146 to force it back onto the unit sphere. This is the number the Chapter 3 assertion exists to trip on: assert norm(q_sum) > 0.1 before normalising. A genuine average of two agreeing quaternions has norm just under 2 (two nearly-parallel unit vectors added); a sign-flipped pair has norm under 0.001. There are four orders of magnitude between the healthy and the broken case, so 0.1 is not a tuned threshold, it is a chasm — and a check with four decades of margin is a check nobody will ever have to re-tune.

DEBUG — two constants on one screen, and the move that separates them

Experiment 5 hands you a rule that is genuinely useful and very slightly too clean: constant, huge, configuration-independent error means a representation fault. Run the widget honestly and the rule does not partition the panel, because two of the six bugs print a constant. Bug 4 sits at 109.30 cm forever. Bug 5 sits at 56.37 cm forever. A rule that leaves two suspects standing is a rule you have not finished, and finishing it is the most practically important paragraph in this chapter, because "and what would you check next?" is the question that always follows a correct first answer. Getting the first answer right and then going quiet is how a strong engineer ends up sounding lucky.

The move that does not work, first, so you do not waste a minute on it. The instinct is to sweep harder — wider angles, further base poses, both joints at once. It buys nothing. Both faults are lengths of vectors that are rigid in a frame at or above the fault, and everything you can turn with the sliders acts on those vectors as a rigid motion. Rigid motions preserve length. You can drive the panel to its stops in every direction and read 109.30 and 56.37 all afternoon. When a fault is invariant under every control you own, more control authority is not the answer — a different observable is.

What you look atflipped quaternion (bug 4)optical skipped (bug 5)Does it discriminate?
error row109.30 cm, at every setting56.37 cm, at every settingNo. Both constant. This is the row that traps you.
target offset rownot printed at all46.10 cmYes, decisively. A row that exists for exactly one fault is a perfect discriminator, and it costs zero robot time.
teal chain (the truth)unchangedunchangedNo — and it never can. The truth is the truth under either fault; only the software's beliefs move.
orange ghost gripperjumps 1.0930 m, to the reflection of the arm through the waist pivotsits exactly on the red believed-target square, because the commanded pose is the mis-decoded detectionYes. Bug 4 moves the pose the arm computes; bug 5 moves the pose the arm is asked for. Same ghost, opposite meaning.
green true-target squarenever movesnever movesNo — a useful negative. Ground truth is not a diagnostic; it is the ruler you measure against.
red believed-target squareabsentpresent, 46.10 cm away, joined by a red dashed lineYes. Only one fault on this panel produces a second target at all.
the red tf-tree edgebase_link → torsotorso → head_cameraYes, and it names the publisher. Different node, different parameter file, different on-call owner.
zero every joint, base to the odom originstill 109.30 cmstill 56.37 cmNo — but run it anyway, because it kills the other four. Bugs 1 and 3 fall to 0.00 cm and bug 2 collapses to 1.00 cm, so one pose reduces six candidates to two.
where the fix lands in the repothe quaternion blend in the orientation publisherthe detector's frame decode / the frame_id it stamps— This is the whole reason to care. Two constants, two different teams.
The partition that actually generalises — and it is two questions, not one.

"Constant vs configuration-dependent" is one axis. It is not the only one, and by itself it cannot finish a diagnosis. The second axis is which half of the stack the fault lives in: the kinematic path (what the robot believes about itself) or the measurement path (what the robot believes about the world). Cross them and the whole panel falls into four boxes:

  configuration-dependent × kinematic — bugs 1, 2, 3. The classic frame bugs. The sweep localises them, and the bisection in the flow diagram below is exactly the tool.
  constant × kinematic — bug 4. Representation faults: the antipodal quaternion, a left-handed export with det(R) = −1, a hard-coded constant that is simply wrong.
  constant × measurement — bug 5. Convention faults: an axis permutation, a frame_id naming a frame that is not the one the data is in.
  configuration-dependent × measurementthe box this widget does not have a button for, and the one a real robot fills constantly. A camera-mount rotation error of 2° puts the target 2.0 m × sin 2° = 7.0 cm off at two metres and 0.64 m × sin 2° = 2.2 cm off at 64 cm — linear in range, with no intercept. A time offset of 33 ms with the waist slewing at 60 °/s puts it 0.035 rad × 0.441 m = 1.5 cm off — linear in angular rate, and zero when the robot holds still.

That empty box is why the sweep has to have two axes. Sweep configuration and you separate the top row from the bottom. Sweep range and rate and you separate the two measurement boxes from each other. An engineer who only ever varies joint angles will confidently mis-file every fault in the right-hand column.

But you do not get this canvas on a real robot. "Which object jumped" is a perfectly good discriminator when someone has drawn the true chain in teal for you, and on cell 4 at 02:40 nobody has. So here is the field version of the same question, and it is one of the two or three most useful tests in applied robotics.

Worked example 3 — the two-viewpoint landmark test, with the numbers.

The idea. A static landmark has exactly one position in odom. Observe it from two robot poses, push each detection through the camera chain into odom, and compare the two answers to each other. A healthy measurement path returns the same point from every viewpoint; a broken one does not. This test needs no ground truth, no mocap, no survey — only that the landmark did not move — which is why you can run it in a warehouse aisle in four minutes.

Take a tote surveyed at odom (1.300, 0.600) and two poses of this robot:

Viewpoint A: base at (0.55, 0.55), β = 15°, ψ = −35°. The camera lands at odom (0.6638, 0.5330) with yaw −20.00°, and the tote is detected at (0.5749, 0.2806) m in body axes (forward, left) — a range of 0.6397 m.
Viewpoint B: base at (0.90, 0.20), β = 40°, ψ = +20°. The camera lands at odom (0.9706, 0.2950) with yaw +60.00°, and the tote is detected at (0.4288, −0.1327) m — a range of 0.4489 m.

With the decode correct, both back-projections return (1.3000, 0.6000). Spread = 0.00 cm, and it is 0.00 for any pair of viewpoints, because that is what "the landmark is static" means.

With the optical axes read straight into body axes, the planar decode collapses a detection (bx, by) to (−by, 0), so:
  A: body (−0.2806, 0.0000) → odom (0.4002, 0.6289) — 90.03 cm from truth
  B: body (+0.1327, 0.0000) → odom (1.0370, 0.4099) — 32.45 cm from truth
  spread between the two estimates = 67.34 cm

Two observations of one bolted-down tote, 67 cm apart. No ground truth was used to detect that, and no kinematic model beyond the camera chain. That single number says "the measurement path is broken" and it says nothing at all about the arm — which is precisely the sentence you could not get out of the 56.37 cm readout.

And the honest caveat, which is worth more than the test. Notice that 90.03 and 32.45 are not equal, and neither is 46.10. The fault size under this bug is ‖(bx + by, by)‖, which is a function of where the tote sits in the image — check it against the widget's own case: (0.35, 0.10) gives ‖(0.45, 0.10)‖ = 46.10 cm, exactly the target offset row. The widget holds the detection fixed in camera axes by construction, which is what makes its 46.10 constant. On a real robot that constancy is a lie: a convention fault is constant only for a fixed relative bearing, and the tell is not "the error is constant" but "the error is a rigid function of the detection with no dependence on the arm at all". Say the caveat before someone else finds it for you.

CODE — the finisher, as twenty lines you can paste under the bisector

The bisector earlier in this chapter returns 'no fault above tol' when the error does not vary, and admits that is its honest failure mode. Here is the branch that catches what it drops. The contract: err_nom is the mean error vector norm at the pose the robot is failing in, err_zero the same at the zeroed pose, and spread the two-viewpoint landmark spread above — all in metres, all averaged as vectors, never as magnitudes.

python — the constant-fault finisher
import numpy as np

def landmark_spread(views, decode):
    """Do all viewpoints agree on ONE static landmark?

    views:  [(T_odom_cam 4x4, raw_detection), ...]  -- >= 2 poses
    decode: raw_detection -> (3,) point in the camera BODY axes
    returns: max pairwise separation of the odom estimates, metres.
    Needs no ground truth -- only that the landmark did not move."""
    pts = [T[:3, :3] @ decode(raw) + T[:3, 3] for T, raw in views]
    return max(np.linalg.norm(a - b) for a in pts for b in pts)

def classify_constant_fault(err_nom, err_zero, spread, tol=0.002):
    """Run ONLY when the joint sweep found no configuration dependence."""
    if err_nom < tol:
        return 'no fault above tol'
    if abs(err_nom - err_zero) > tol:
        return 'configuration-dependent -- go back and bisect'
    return ('measurement path (camera edge: extrinsic or convention)'
            if spread > tol else
            'kinematic path (representation or a wrong constant)')

def split_kinematic_constant(err_m, subchain_offset_m, R, tol=0.02):
    """Three constants live in the kinematic box. Separate them."""
    if abs(np.linalg.det(R) + 1.0) < 1e-6:
        return 'handedness: det(R) = -1, a left-handed export'
    if abs(err_m - 2.0 * subchain_offset_m) < tol:
        return 'antipodal: the error is EXACTLY twice the sub-chain offset'
    return 'a wrong constant in the static extrinsics -- go read the YAML'

Check the last one against a number you already have. On bug 4 the error is 1.09296 m and the sub-chain offset is ‖u‖ = 0.546480 m. The ratio is 2.00000 — not "about two", exactly two, because a half-turn about the waist maps u to −u and the gap between them is 2u by definition. That exactness is the point of the test: a wrong constant in the YAML has no reason whatsoever to land on a clean factor of two, so a ratio of 2.000 ± 0.02 is a signature, not a coincidence. On a real 3D chain the same test reads err / ‖t, with t the component of the sub-chain offset perpendicular to the flipped axis — the parallel part is invariant under the half-turn and must be projected out or the ratio comes back short and you dismiss a real antipodal fault.

Numbers drill — one significant figure, out loud, no calculator. "It would be some error" is not an answer; sizing the fault before you measure it is.

  • Missing parent rotation on a 19 cm mount, waist at 45°: 2 × 0.19 × sin 22.5° ≈ 15 cm
  • Antipodal quaternion above a 0.55 m sub-chain: 2 × 0.55 ≈ 1.1 m
  • 2° camera-mount yaw error at 2 m range: 2 × 0.035 ≈ 7 cm (and 2 cm at 0.6 m — linear in range)
  • 33 ms of stale tf at 60 °/s across a 0.44 m lever: 0.035 × 0.44 ≈ 1.5 cm (linear in rate, zero at rest)
  • An axis permutation on a detection 0.35 m out: a whole coordinate, so tens of cm — never millimetres
  • A centimetres-for-metres slip on the 0.08 m camera mount: ×100, so 8 m — the robot reaches through a wall

Read the spread of that list. Convention and representation faults are decimetres to metres; geometry and timing faults are centimetres; unit slips are orders of magnitude. The size of the miss narrows the search before you have touched anything, and "15 cm on left-hand picks" was already telling you it was not a unit slip and not a permutation.
What to check after "it is constant". Constant rules out the geometry faults, but it leaves two: a representation fault in the kinematics and a convention fault in the measurement. Split them without any ground truth — observe one static landmark from two robot poses and push both detections into odom. If the two estimates disagree, the fault is on the camera edge. If they agree and the arm still misses, it is in the kinematic chain, and then check det(R) for handedness and the ratio of the error to the sub-chain offset for an antipodal flip — a clean factor of two is the antipode's signature. Two experiments and no code reading. It is short, it terminates, and every branch names an observable. That is what a senior diagnosis looks like.

Every earlier chapter, now with a number attached to it

This is the showcase's actual job: not to be the biggest canvas in the lesson, but to make the previous seven chapters measurable. Each bug button is one chapter's abstraction turned into centimetres you can read off a screen.

Bug buttonWhere it was derivedWhat was abstract thereWhat is concrete here
unrotated offsetChapter 0 (the room) and Chapter 1 (SE(3) as one matrix)"the translation must be rotated by the parent's orientation"Skip the rotation and it costs 14.52 cm at 45° and 0.00 cm at 0° — and the zero is what makes it survive every bench test
reversed composeChapter 2 (composition order and inverses)"matrix multiplication does not commute"Non-commutativity is worth exactly 1.00 cm at ψ = 0 on this geometry, and it is asymmetric in ±ψ
wrong inverseChapter 2 (deriving the inverse instead of memorising it)"the inverse is (R, −Rt), not (R, −t)"The forgotten R is worth 26.11 cm at a metre out and exactly nothing at base yaw 0 — which is where it will be unit-tested
flipped quaternion signChapter 3 (rotation representations) and Chapter 4 (the double cover)"q and −q are the same rotation"They are — until you average them, at which point you get a deterministic 109.30 cm and a pre-normalisation norm of 0.00087
optical frame skippedChapter 5 (REP-103/105 conventions)"optical is (right, down, forward); body is (forward, left, up)"One skipped permutation moves the target 46.10 cm and leaves the arm provably innocent, which is where the week goes
the tf tree canvasChapter 5 (the one-parent rule)"every frame has exactly one parent"Which is why a single red edge can name the fault at all — in a graph with two parents there is no unique edge to blame, and the bisection has nowhere to converge. Press the six buttons and count: never more than one red arrow, and for the optical bug it is the camera edge, not the arm edge. Two red arrows would not be a prettier picture, it would be a claim the tree structure does not license

The bisection procedure this widget is teaching

Experiment 2 is the important one, and it generalises into the method for finding a frame bug in a chain of any length.

step 1
Establish what the error is a function of. Sweep each joint one at a time and note which ones move the error.
step 2
The broken edge is at or below the highest joint that changes the error, and at or above the lowest one. Everything outside that window is exonerated.
step 3
Zero every joint in the window. If the error goes to zero, the fault is multiplicative in a rotation — an unrotated offset or a bad inverse. If it does not, it is structural — an order swap, a convention swap, or a wrong constant.
step 4
Check the scale. Is the error a small number of centimetres (arithmetic), or a 90°-multiple axis swap (convention)? Those go to different parts of the codebase.
step 5
Predict the magnitude from the geometry, then read the code to confirm. If your prediction and the observation agree to two digits, you are done — and you know it, rather than hoping.
The procedure, said out loud. "Before I read any code I would find out what the error is a function of. Each joint I can sweep either changes the error or does not, and that alone bisects the chain. Then I zero the joints inside the window: if the error vanishes at identity, it is a missing rotation; if it does not, it is structural. Then I predict the magnitude from the mount geometry and check it against the measurement." That is a procedure, it works on a chain you have never seen, and it is far more convincing than naming the right bug by luck.

CODE — the bisection as a function you could actually run tonight

The procedure above is five boxes in a flow diagram, and five boxes in a flow diagram is a thing you can nod at without being able to do. Here it is as code. The contract is small and worth stating out loud before you write a line of it, because getting it wrong is what makes the whole exercise unfalsifiable:

python — from scratch
import numpy as np
from math import radians

def _err_vec(fk_believed, measure, q, reps):
    """Mean error VECTOR, metres, shape (3,).

    Average the vector, never the norm. For zero-mean noise n,
    E[||b + n||] > ||b||, so a mean of norms is biased upward and the
    bias does NOT shrink with reps -- it converges to a wrong number."""
    acc = np.zeros(3)
    for _ in range(reps):
        acc += measure(q)[:3, 3] - fk_believed(q)[:3, 3]
    return acc / reps

def localise_frame_bug(fk_believed, measure, q_now, joint_names,
                       sweep=radians(30), tol=0.002, reps=25):
    """Bisect a kinematic chain by sweeping one joint at a time.

    fk_believed, measure: (7,) float array -> 4x4 SE(3)
    q_now:                (7,) the pose the robot is failing in
    returns: (lo, hi, fault_class, diagnostics)"""
    n = q_now.shape[0]
    sensitive, unobservable = [], []

    # --- step 1: what is the error a FUNCTION of? -------------------
    for i in range(n):
        errs, tips = [], []
        for a in (-sweep, 0.0, sweep):
            q = q_now.copy(); q[i] = a
            errs.append(np.linalg.norm(_err_vec(fk_believed, measure, q, reps)))
            tips.append(measure(q)[:3, 3])   # where the TCP truly went
        spread = max(errs) - min(errs)

        # the null-lever-arm guard: can this joint move the metric AT ALL?
        lever = max(np.linalg.norm(tips[k] - tips[0]) for k in (1, 2))
        if lever < tol:
            unobservable.append(joint_names[i])  # NOT exonerated -- blind
        elif spread > tol:
            sensitive.append(i)

    if not sensitive:
        return None, None, 'no fault above tol', {'blind': unobservable}

    # --- step 2: the suspect window --------------------------------
    lo, hi = min(sensitive), max(sensitive)

    # --- step 3: zero the window; does the fault survive identity? --
    q = q_now.copy()
    q[lo:hi + 1] = 0.0
    residual = np.linalg.norm(_err_vec(fk_believed, measure, q, reps))
    cls = 'multiplicative' if residual < tol else 'structural'

    return lo, hi, cls, {'residual_m': residual,
                          'suspect_edges': joint_names[lo:hi + 1],
                          'blind': unobservable}

What it returns on this widget's bugs, so you can check it against something you have already measured. On unrotated offset: the window closes on the waist joint alone, and zeroing it drops the residual to 0.000 m, so 'multiplicative' — a rotation is missing. On reversed compose: the same window, but zeroing leaves 0.00997 m, five times tol, so 'structural' — an order swap or a wrong constant, and no amount of straightening the robot will hide it. On flipped quaternion: spread is zero on every joint because 1.0930 m is constant, so sensitive comes back empty and the function reports "no fault above tol" while the robot is a metre out. That is the honest failure mode of the whole method and you should name it yourself: a bisection that keys on changes in the error is blind to a fault that does not vary. Guard it by checking the error at the nominal pose against zero before you start sweeping — one line, and it catches the biggest bug in the list.

the library version — tf2 walks the tree for you
from tf2_ros import Buffer, TransformListener
from rclpy.duration import Duration

buf = Buffer(cache_time=Duration(seconds=10))   # 10 s horizon = tf2 default
TransformListener(buf, node)

# the composed answer -- one call, the whole chain, interpolated to `stamp`
T = buf.lookup_transform('odom', 'gripper', stamp, timeout=Duration(seconds=0.05))

# but while bisecting you want it EDGE BY EDGE, which is the same call
# with the composition switched off:
for parent, child in [('odom', 'base_link'), ('base_link', 'torso'),
                      ('torso', 'shoulder'), ('shoulder', 'gripper')]:
    e = buf.lookup_transform(parent, child, stamp).transform.translation
    print(parent, child, (e.x**2 + e.y**2 + e.z**2)**0.5)   # vs the tape measure

# and the thirty-second version, no code at all:
#   ros2 run tf2_ros tf2_echo torso shoulder
#   ros2 run tf2_ros tf2_monitor odom base_link    # who is publishing it

Note what the library gives you and what it does not. lookup_transform composes the chain and interpolates each edge to your timestamp, which is the part nobody writes correctly by hand. It does not tell you whether any edge is right. The bisection is still yours.

DEBUG — run the same experiments with a real instrument, and watch one of them die

Everything above assumed a perfect measurement, which the widget has and your robot does not. So degrade it. Suppose the ground-truth gripper position comes from a fiducial the arm touches, read by the same camera: call it σ = 1 cm of zero-mean Gaussian noise per axis, which is honest for a 30 Hz depth camera at a metre. Rerun experiments 2 and 3.

SignalTrue sizeSingle measurementSurvives?
Experiment 2 — the 14.52 cm bias, invariant across base poses14.52 cm14.52 ± ~1.4 cm (the 2D noise magnitude). Across six base poses you would read 13.4, 15.6, 14.0, 15.1, 13.9, 14.8.Yes. A 10% scatter around a level line is unmistakably "does not change". The bias dwarfs the noise by 14:1.
Experiment 3 — the 1.00 cm reversed-compose residual1.00 cmYou would read anything from 0.2 to 2.8 cm, and you would read a nonzero number even on a perfectly healthy robot.No. One measurement cannot tell 1.00 cm of bias from 1.25 cm of noise. The whole "structural vs multiplicative" call collapses.
The trap that eats the naive fix, and it is worth more than the fix itself.

"Just average it" is right, and averaging the wrong thing is the classic way to make it worse. If you average the error magnitudes, you are averaging ‖b + n‖, and for a 2D Gaussian with per-axis σ the magnitude of pure noise is Rayleigh-distributed with mean

  E[‖n‖] = σ√(π/2) = 1.2533 σ = 1.25 cm for σ = 1 cm

and that number does not shrink with more samples — it is a bias, not a variance. Average a thousand magnitudes on a perfectly calibrated robot and the dashboard will confidently report a steady 1.25 cm error, which is the same size as the reversed-compose bug you are hunting. Teams have chased that ghost for weeks.

Average the error VECTOR, then take its norm. The noise is zero-mean in each component, so the per-axis standard error falls as σ/√n, and the bias survives untouched. That is the one line in _err_vec above that carries the whole method.

So how many repeats? Set the requirement first: detect the 1.00 cm residual at 5 standard errors, so that "not zero" is a claim rather than a hope. With σ = 1 cm per axis and n repeats, the standard error per axis is 1/√n cm, and we need

n ≥ (5σ / b)2 = (5 · 1.00 / 1.00)2 = 25 repeats

Twenty-five touches gives a standard error of 0.20 cm per axis, so the mean error vector (−0.124, −0.990) cm sits five standard errors from the origin and the y-component alone settles it. At roughly 3 s per touch that is 75 seconds of robot time — cheap against the twenty minutes you were given, and it is the difference between "there is a 1 cm structural fault on the torso→shoulder edge" and "I think I saw something". Meanwhile experiment 2's 14.52 cm bias needs n = 1: (5·1.00/14.52)2 = 0.12, so a single touch already clears 5σ by a factor of three. Different faults need different amounts of evidence, and knowing which of your experiments is cheap is how you spend the twenty minutes.

One more degradation worth naming, because it produces the identical symptom and no amount of averaging touches it: a stale transform. If the consumer calls lookup_transform(..., rclpy.time.Time()) — the "give me the latest" idiom — instead of passing the detection's stamp, it composes a 30 Hz measurement against 200 Hz joint angles that have moved on. We priced that at the top of the chapter: 33 ms of skew at 60 °/s of waist rate is 1.9 cm at the gripper. That is larger than the reversed-compose bug and the same order as your noise. Its tell is that it correlates with joint velocity rather than joint position: hold every joint still and it vanishes; move fast through the same poses and it grows. Add angular rate to your sweep and you have separated it in one more experiment.

PRACTICE — back to cell 4, and how the twenty minutes actually go

Everything above is machinery. Here is the machinery spent, against the clock, on the failure this chapter opened with. Run it out loud; what a shift lead needs from you is a plan with times on it, not a list of things that could conceivably be wrong.

ClockWhat you doWhat you learn
0–2 minBefore touching the robot: ask what "left-hand pick" means geometrically. Answer: the left bin sits at 45° off the aisle, so left picks are executed with the waist at roughly +45°; right picks are executed with the waist near 0°.The failing and passing cases differ by one joint. The bug is already a function of torso yaw and you have not moved anything yet. Two minutes of asking beats twenty minutes of logs.
2–6 minJog the waist to 0° and command a pick at a surveyed fiducial. Then jog to 45° and command the same pick. One touch each — the expected signal is 15 cm and the instrument noise is 1 cm, so n = 1 clears 5σ three times over.0.3 cm at 0°, 14.6 cm at 45°. Zero at identity, large under rotation. That is the (I − R)t fingerprint and it has just eliminated the order swap, the antipodal quaternion and the optical-convention fault, all of which are nonzero at ψ = 0.
6–10 minHold the waist at 45°. Drive the base to three different spots in the cell and rotate it. Repeat the pick.14.5, 14.6, 14.5 cm. Invariant to everything above the waist, so odom→base_link is exonerated and the bad inverse is out. The window has closed on base_link→torso→shoulder.
10–13 minPredict before you read. The suspect edge's offset is 6 cm forward and 18 cm right, so ‖ts‖ = 0.190 m, and a missing rotation at 45° should give 2(0.190)sin(22.5°) = 14.52 cm.Prediction and measurement agree to two digits. You now know the bug rather than suspecting it, and you know its size, which means you will recognise the fix when it works and not be fooled by a change that merely moves it.
13–17 minNow open the diff. git log -p on the extrinsics for the last week — Friday's arm recalibration touched the shoulder mount block.The new code adds the mount offset in the parent's axes and never rotates it by the waist angle. Four minutes of reading, aimed at four lines, because the geometry told you which four.
17–20 minFix, then verify against the prediction, not against a successful pick. Re-run the 0°/45° pair.0.3 cm and 0.4 cm. A single successful pick would have proved nothing — the tote is wider than the error. The pair proves the dependence is gone, which is what was actually broken.
The move that saved the shift is the first one, and it costs nothing. Every minute after 02:40 was spent making the error a function of something and then reading the function. Nobody read a stack trace. Nobody re-derived forward kinematics. The subsystem-level instinct — "the arm is missing, so look at the arm" — would have burned the twenty minutes inside the one subsystem that was provably innocent, because the arm's own calibration is blind to an edge above the arm. Frame bugs live on edges, and edges belong to no subsystem, which is exactly why they survive every component test you own.

DESIGN — the instrumentation this suggests you should ship

A widget you can break is nice. A robot you can break-and-see is better, and the real question after any postmortem is what you would build so that next time it takes twenty minutes. A checklist of boxes is not an answer to that question — every row below carries where it runs, at what rate, on what message, what it costs in microseconds, and what number trips it. If you cannot state those five things, you have not designed the instrument, you have named it.

InstrumentWhere it runs & at what rateThe check, on what dataCostCatches / trips at
Transform validatorInside the tf broadcaster, on the publish path. 200 Hz for base_link→torso and shoulder→gripper off the joint stream; 50 Hz for odom→base_link; once at latch for the two static edges.On the 3×3 R: ‖RR − I‖F, det(R), and isfinite on all 12 numbers.One 3×3 matmul (27 mul + 18 add) plus a 3×3 determinant — ~30 flops, under 2 µs. At 200 Hz that is 0.04% of a core, and it fits inside a 5 ms control period with four decimal places to spare.Drifted matrices, reflections (det = −1), NaN out of a failed solve. Trip at ‖RR − I‖F > 1e−6 — float32 round-trip noise is ~1e−7, so this is 10× margin.
Tape-measure assertion on static extrinsicsStartup only, in the node that loads the URDF/YAML. Once per boot, before the first publish.‖t‖ of each static edge against the CAD value in the same YAML. Here: 0.18974 m for torso→shoulder, 0.08 m for torso→head_camera.Zero at runtime. One float per edge in the config, one comparison at boot.Reversed composition, cm-vs-m, a decimal typo. Trip at >5 mm from CAD — wide enough to survive real machining tolerance, narrow enough that no unit error passes.
Quaternion continuity monitorIn each orientation publisher, at that stream's own rate. Here N = 3 streams: IMU 400 Hz, waist encoder 200 Hz, localizer 50 Hz.One 4-vector dot: dot(q_prev, q_now), on the raw quaternion before any blending.4 multiplies and 3 adds per sample. Across all three streams that is 650 samples/s × 7 flops ≈ 4.6 kflop/s — unmeasurable.Sign flips and the antipodal blend. Trip at dot < 0 (flip) and, before any average, at norm(q_sum) < 0.1 — healthy is ~2.0, broken is ~0.0009, four decades of margin.
Pose-jump detector on consumed framesIn each consumer, on the frame it actually uses, at the consumer's rate (the IK runs at 100 Hz).‖Δp‖/Δt between successive lookups, against the platform's physical limit. This base tops out at 1.5 m/s, so flag at 2.0 m/s.Two subtractions and a norm per lookup. Needs one previous pose kept in the node — 7 doubles of state.Consumers reading map where they should read odom (loop closures teleport it). Also names the tf-buffer horizon as a suspect: tf2's default cache is 10 s, so a consumer that looks up a 9-second-old stamp gets a legally-served stale transform and produces the same jump. Log the stamp age with the jump or you will misdiagnose it.
Drift telemetryOne node, 1 Hz, straight to the fleet metrics pipeline. ~100 bytes/s/robot.‖t‖ and yaw of map→odom, trended over the shift.Free — the transform already exists and is already in the buffer.Wheel slip, tyre wear, VIO degradation. There is no fixed threshold; the signal is the slope. A step change of >0.5 m in one sample is a false loop closure.
End-to-end geometric canaryOn the robot, once per shift (3×/day) plus after every calibration change. Publishes one float per run.Reach to a fiducial bolted at a surveyed pose, from two torso angles (0° and 45°) and two base poses, 25 touches each. Log the mean error vector, not the magnitude.~5 minutes of robot time per shift at 3 s/touch (4 configurations × 25 repeats). One float per configuration into the time series.Everything above, and everything you did not think of. Pass at <1.0 cm mean vector error; page at >2.0 cm. The two torso angles are the point: 0° and 45° is exactly experiment 1, and Chapter 0's bug shows up as 0.00 vs 14.52 cm.
The one design decision to explain rather than assert: validate on publish, not on lookup.

Both are defensible and the reason to prefer one is arithmetic, not taste. This robot has 5 publishers. It has roughly 8 consumers — IK, planner, costmap, two visualisers, the safety monitor, the logger, the detector's back-projection — and each does its own lookup_transform at its own rate. Validating on publish costs 5 checks per cycle. Validating on lookup costs 8 × 5 = 40, at whatever rate each consumer runs, for exactly the same information: the same edges, checked repeatedly, by people who cannot fix them.

And the deeper reason: the publisher is the only party that can name the fault. A consumer that finds a bad torso→shoulder can log "someone upstream is broken"; the broadcaster that produced it knows the node, the parameter file, and the line. Validation belongs where the blame is actionable.

What the tradeoff costs you, stated honestly: publish-side validation cannot catch a fault that is created between publish and use — a stale lookup, a wrong target_frame string, a consumer composing two edges in the wrong order in its own code. Those are real and common. So the consumer side keeps exactly one cheap check, the pose-jump detector, which is the minimum that catches "the transform was fine and you used it wrong". Five publish-side checks plus one consumer-side check, not forty.

That canary row is the one to emphasise. Component-level checks catch the failures you anticipated; a single end-to-end geometric measurement against an external truth catches the ones you did not. Chapter 0's bug would have been caught on day one by a robot that reaches for a known fiducial at the start of every shift, from two different torso angles, and logs the error — because "0.00 cm at torso 0°, 14.52 cm at torso 45°" is the diagnosis, printed daily, whether or not anyone was looking for it.

FRONTIER — the bisection wants to run continuously, not once

Everything in this chapter is a manual procedure performed after a robot has already failed. The research direction is to stop treating the transform tree as a set of constants that a human occasionally audits, and start treating the suspect edges as state to be estimated while the robot works. Two papers define the shape of that, and each one also hands you the caveat that stops the manual method from converging.

The honest summary of where the field is: online estimation moves the frame fault from "silent until it costs a shift" to "observable with a covariance", and it does not remove the need for the manual procedure — it changes what you do with the twenty minutes. You spend them deciding whether the parameter was excited, rather than whether it was ever right.

On a 7-link chain, sweeping joints 3, 4 and 5 changes the end-effector error and sweeping joints 1, 2, 6 and 7 does not. You then zero joints 3, 4 and 5 together and 1.2 cm of error remains. What class of fault is this, and which of the widget's six bugs does that second observation rule out?
Why the second observation is the whole question. The first observation alone — "joints 3, 4, 5 move it" — only draws the window; four of the six bugs are still live inside it. The residual at identity is what partitions them. (I − R)t faults are multiplicative in a rotation and are exactly zero when that rotation is the identity, which kills the unrotated offset and the bad inverse in one measurement. A 1.2 cm survivor is a constant that is simply wrong, or two transforms in the wrong order — and you can tell those two apart with one more move, because an order swap is asymmetric in ±ψ (27.18 cm one way, 27.78 cm the other on this robot) while a wrong constant is not. Three measurements, six candidates down to one, and not a line of code read.

Chapter 8: Field Guide — Frames & Rigid-Body Math

The message arrives mid-morning: the gripper is 4 cm off, but only when the torso is turned. You have ninety seconds before someone asks what you think. What you say in those ninety seconds is decided now, not then — by which numbers you can produce without a calculator, which formula you can rebuild rather than recall, and which test you would run first.

Six sections, in the order you will need them: the cheat sheet (and the three derivations underneath it that you must be able to rebuild from scratch), the system-design patterns, the coding drills, the debugging triage, the classical-versus-modern call, and what to read.

1 · The cheat sheet

One row per concept. The middle column is what you say when someone asks "what is X?" and you have thirty seconds.

six columns — scroll sideways →

ConceptThe 30-second answerKey equationToolClassicRecent (year)
SO(3) The rotations. Orthogonal matrices with determinant +1 — orthogonal because rigid motion preserves length, determinant +1 because a body cannot mirror itself. Nine numbers, six constraints, three degrees of freedom. RR = I, det R = +1 Eigen, scipy Rotation Murray, Li & Sastry, A Mathematical Introduction to Robotic Manipulation (1994) Solà, Deray & Atchuthan, micro Lie theory (2018, rev. 2021)
SE(3) Rotation plus translation as one 4×4, using homogeneous coordinates so translation becomes linear. Bottom row [0 0 0 1] guarantees closure and makes it affine rather than projective. T = [[R, t], [0, 1]], 6 DOF Sophus, manif, GTSAM Pose3 Murray, Li & Sastry (1994) Teed & Deng, LieTorch tangent-space backprop (CVPR 2021)
Composition Matrix product, right to left. Name every transform with both frames and check that the inner subscripts cancel. The child's offset is rotated by the parent's rotation before it is added. TAC = TABTBC; tAC = RABtBC + tAB tf2 Craig, Introduction to Robotics (1986) Forster et al., on-manifold preintegration (T-RO 2017)
Inverse Transpose the rotation, then rotate the negated translation into the new frame. Never the transpose of the whole 4×4, never plain −t. T−1 = [[R, −Rt], [0, 1]] Sophus .inverse() Craig (1986) Deray & Solà, manif (JOSS 2020)
Quaternion Four numbers, unit norm, built from the half-angle. No singularity, cheap to compose, correct interpolation. Price: q and −q are the same rotation. q = (cos(θ/2), n sin(θ/2)) Eigen::Quaterniond Shoemake, Animating Rotation with Quaternion Curves (SIGGRAPH 1985) Solà, quaternion kinematics for the error-state KF (2017)
Gimbal lock The Euler-rate Jacobian's determinant is cos(pitch). At ±90° it hits zero, rank drops to 2, and some body motions need infinite Euler rates. Topology, not sloppiness — no 3-parameter chart of SO(3) is global. det E = cosθ any quaternion library (the fix is to not use Euler) Stuelpnagel, On the Parametrization of the Three-Dimensional Rotation Group (SIAM Review, 1964) Zhou et al., continuity of rotation representations (CVPR 2019)
Representation for learning Anything below 5 dimensions is provably discontinuous, so a network cannot fit it. Output 6D (two columns, Gram–Schmidt) or 9D (full matrix, SVD projection). R = SVD-project(M), M ∈ ℝ3×3 PyTorch3D, RoMa Zhou et al. (CVPR 2019) Levinson et al., SVD for deep rotation estimation (NeurIPS 2020); Brégier, deep regression on manifolds (3DV 2021)
REP-103 / REP-105 SI units, right-handed, x forward / y left / z up; optical frames are z forward. The chain is map→odom→base_link, with the localizer publishing map→odom so odom→base_link stays continuous. Tmap,odom = Tmap,baseTodom,base−1 tf2, robot_state_publisher, Nav2 REP-103 (2010), REP-105 (2010) Lajoie & Beltrame, Swarm-SLAM (T-RO 2024) — frames when there is no single map
Extrinsics vs intrinsics Intrinsics are the sensor's internal geometry, extrinsics are where the sensor is. Extrinsics act before the perspective divide, so their effect scales with depth; intrinsics act after, so theirs depends on image position. p = K · π(R Xw + t) OpenCV, Kalibr, OpenCalib Zhang, A Flexible New Technique for Camera Calibration (PAMI 2000) Wang et al., DUSt3R (CVPR 2024) — geometry without known calibration
Error-state filtering Keep the nominal rotation on the manifold and a small 3-vector error in the tangent space. The covariance must be 3×3, because a 4×4 over a unit quaternion is rank-deficient by construction. R = R̂ exp(δ), P ∈ ℝ3×3 OpenVINS, GTSAM, manif Solà, error-state Kalman filter (2017) Hartley et al., contact-aided invariant EKF (IJRR 2020)
Three you must be able to derive from scratch in sixty seconds. Three rows of that table drop a result with no argument attached — det E = cosθ, 2 sin(|ψ|/2)|t|, and the map→odom formula. An engineer who can only quote them is indistinguishable from someone who read a blog post this morning. Here is each one, at the speed you would actually say it.

(1) det E = cos(pitch), so gimbal lock is a rank collapse. Build the ZYX Euler-rate Jacobian E by asking, for each Euler rate, which axis does it act about, written in body coordinates?
• Roll φ̇ acts about the body x-axis already: column (1, 0, 0).
• Pitch θ̇ acts about the y-axis of the frame before roll, so pull it back through roll: (0, cosφ, −sinφ).
• Yaw ψ̇ acts about world z, so pull it back through pitch and roll: (−sinθ, cosθ sinφ, cosθ cosφ).
Stack them as columns:
E = [[1, 0, −sinθ], [0, cosφ, cosθ sinφ], [0, −sinφ, cosθ cosφ]]
Expand the determinant along the first column — only one non-zero entry, so it is a single 2×2:
det E = 1 · [ cosφ · (cosθ cosφ) − (cosθ sinφ) · (−sinφ) ] = cosθ cos2φ + cosθ sin2φ = cosθ.
Say the consequence out loud, because that is the part that matters: at θ = ±90° the determinant is zero, E drops to rank 2, and the map from Euler rates to body rates is no longer onto — there exist body motions no finite Euler rate can produce. Concretely, with φ = 0 the inverse gives ψ̇ = r / cosθ, so a modest body yaw rate of r = 0.1 rad/s needs 0.100, 0.200, 1.147 and 5.730 rad/s of Euler yaw rate at pitch 0°, 60°, 85° and 89° — that is 5.7, 11.5, 65.7 and 328 deg/s, so a 120 deg/s actuator saturates at about 87° of pitch. The failure is announced by the rate command, not by the angle.

(2) |(I − R)t| = 2 sin(|ψ|/2) |t| — a chord, not a mystery. Split the offset into the part along the rotation axis and the part across it: t = t + t. A rotation about n leaves t exactly where it was, so (I − R)t = 0 and the whole error lives in the perpendicular part.
Now look at the plane perpendicular to n. Both t and R t are radii of the same circle — a rotation cannot change a length — and they are separated by the rotation angle ψ. The distance between the tips of two radii of length r separated by angle ψ is the chord, and the chord of a circle is 2r sin(ψ/2). Substitute r = |t|:
|e| = |(I − R) t| = 2 sin(|ψ| / 2) · |t|
Two things fall out for free. For small ψ, 2 sin(ψ/2) → ψ, so the error is linear in the joint angle with slope |t| — which is why a joint sweep produces a straight line through the origin and why the miss flips side when the joint reverses. And the error is capped: at ψ = 180° it is 2|t|, never more. If someone reports an error larger than twice the offset, this is not the bug.

(3) Tmap,odom = Tmap,base Todom,base−1, in one line of algebra. Start from the only thing REP-105 actually asserts — that the chain composes:
Tmap,base = Tmap,odom · Todom,base
Right-multiply both sides by Todom,base−1. On the right the inner pair cancels (odom,base)(base,odom) = identity, leaving Tmap,odom alone:
Tmap,odom = Tmap,base · Todom,base−1
The left-hand side is what the localizer measured; the right-hand factor is what odometry has been integrating. Then answer the deeper question — why publish this edge and not map→base? Because tf is a tree: base_link gets exactly one parent. If the localizer owned map→base, then every correction would have to land either on odom→base (destroying the continuity the velocity controller differentiates) or on a second parent (which is not a tree). Absorbing the correction one level up is the only placement that leaves odom→base smooth. One number closes the argument: 1D corridor, odometry has integrated 12.40 m, the localizer measures 12.05 m, so Tmap,odom = 12.05 − 12.40 = −0.35 m — and a 500 Hz controller subscribed to map would read 0.35 m / 0.002 s = 175 m/s of apparent velocity, which no robot can do.

2 · System-design patterns, with answer frameworks

Prompt A: "Design the coordinate-frame architecture for a mobile manipulator that has to pick from shelves in a warehouse it has a prior map of."

Frame the answer around the two conflicting requirements first. The controller needs continuity; the planner needs global accuracy. Say that out loud before drawing anything — it is the reason the architecture has the shape it does.

Then lay the tree out: map → odom → base_link → {torso, arm links, sensors}. Localizer publishes map→odom at 10 Hz; VIO or wheel odometry publishes odom→base_link at 100–200 Hz; robot_state_publisher drives the joint chain from joint_states at 200 Hz; static extrinsics come from the URDF on latched /tf_static.

Assign consumers explicitly: velocity controller and local costmap in odom; global planner, goal poses and shelf locations in map; point-cloud deskewing in odom because it interpolates.

Put numbers on it — and show the bytes-per-entry, because that is the step most people skip. Start from one buffered transform. It stores 4 quaternion doubles + 3 translation doubles = 7 × 8 B = 56 B; a stamp of int32 seconds + uint32 nanoseconds = 8 B, taking it to 64 B; then the parent and child frame identifiers plus the deque-node bookkeeping the buffer needs to keep the samples time-sorted, call it another 36 B. So ≈ 100 B per buffered transform, and now the storage is arithmetic rather than a guess:
40 frames × 200 Hz × 10 s = 80,000 transforms; 80,000 × 100 B = 8,000,000 B = 8.0 MB (7.6 MiB) per process
The same 100 B drives the wire budget: one /tf message carrying all 40 edges is 40 × 100 B = 4 kB, and 4 kB × 200 Hz = 800,000 B/s = 800 kB/s — which is 23 GB across an 8-hour shift, so say out loud that /tf alone will dominate a bag profile unless you throttle or split it. Finish with the access cost: lookupTransform is 2–5 µs and takes the buffer lock, so a 200 Hz loop caches one lookup per cycle instead of forty. Every one of those numbers should arrive with its inputs attached; a bare "about 8 megabytes" invites exactly the follow-up you do not want.

Close with the failure modes you have designed against: exactly one publisher per edge (checked with tf2_monitor); extrapolation disabled, so a stale transform raises rather than lies; /tf_static recorded in every bag profile; and a transform validator on publish (orthogonality, determinant, bottom row, finiteness).
Prompt B: "A new depth camera is being added to an existing robot. Walk me through everything that has to happen before a single detection is trustworthy."

Five stages, and name the failure each one prevents.
(1) Mechanical + URDF. Add the link and joint with the CAD offset. Prevents "the frame does not exist" and gives you a nominal to diff against later.
(2) Convention. Establish whether the driver publishes body or optical axes, and add the _optical child with the −90°/0/−90° rotation. Prevents the axis-permutation class, whose errors are 90° multiples rather than small numbers.
(3) Intrinsics. Factory or bench calibration; store per-serial with the residual statistics and the capture bag. Prevents radial, off-axis error.
(4) Extrinsics. Refine the CAD value against a shared target with an existing sensor; assert ‖t‖ against a tape measure before accepting. Prevents reversed compositions and unit errors.
(5) Time offset. Estimate it, and put the number on the whiteboard while you say so: an offset of 5 ms = 0.005 s, at a relative speed of 1 m/s, displaces every point by 0.005 s × 1 m/s = 0.005 m = 5 mm — and at the 2 m/s a mobile base actually drives, 10 mm. That is above most acceptance thresholds, it is the same size as a real extrinsic fault, and it looks exactly like one. Prevents the entire class covered in Lesson 2.

Then the validation: measure the 3D error at two ranges. A line through the origin means extrinsic rotation, a flat line means extrinsic translation. And define the acceptance threshold before you measure, or you will accept whatever you get.
Prompt C: "How would you prevent frame bugs from happening at all, on a team of thirty engineers?"

This is a culture-and-tooling question, and the answer should be tooling first.

Make the bug unrepresentable. A Pose<From, To> template in C++ (or a newtype in Rust, or a naming convention plus one assertion in Python) turns a composition-order error into a compile error. Thirty lines, once.
Make the conventions single-sourced. One header defining R_BODY_FROM_OPTICAL, ENU/NED conversions and the frame-name string constants. Nobody retypes a rotation matrix.
Make the invariants checked at runtime. Transform validator on publish; tape-measure assertion on static extrinsics; quaternion continuity monitor; pose-jump detector.
Make it observable. Publish the four scalars (orthogonality residual, |w|, consecutive dot product, |cos pitch|) as diagnostics, and an end-to-end geometric canary once per shift.
Make it reviewable. Extrinsics live in the URDF, in version control, with the CAD reference in the comment — not in a launch file argument that nobody diffs.

The closing sentence: "Every one of those is cheaper than the week we lost, and they compound — the canary catches the bugs I did not think of."
Worked example: the 4 cm report from the top of this chapter, all the way to a number. “The gripper is 4 cm off, but only when the torso is turned.” Do not reach for a cause. Reach for the arithmetic — it takes ninety seconds and it is the answer.

Inputs, stated first. Frame A is the torso pivot at the neutral pose, frame B is the torso after it turns, frame C is the head camera. The camera sits 8.0 cm forward and 42 cm above the pivot, so in torso coordinates tBC = (0.080, 0, 0.420) m. The torso is turned 30° to the robot's right, and with REP-103 axes (x forward, y left, z up) a turn to the right is a negative yaw: ψ = −30° about z.

Step 1 — write all nine entries of RAB. With cos(−30°) = 0.8660 and sin(−30°) = −0.5000, and Rz(ψ) = [[cosψ, −sinψ, 0], [sinψ, cosψ, 0], [0, 0, 1]]:
RAB = [[ 0.8660,  0.5000,  0 ],  [−0.5000,  0.8660,  0 ],  [ 0,  0,  1 ]]
Sanity-check it before using it. Column 1 is (0.8660, −0.5000, 0) and column 2 is (0.5000, 0.8660, 0). Unit length: 0.86602 + 0.50002 = 0.7500 + 0.2500 = 1.0000, for both. Orthogonal: 0.8660 × 0.5000 + (−0.5000) × 0.8660 + 0 = 0.4330 − 0.4330 = 0. Two seconds of arithmetic, and it stops you building five steps on a sign typo.

Step 2 — three dot products for RAB tBC. One row at a time, every term shown:
row x: 0.8660 × 0.080 + 0.5000 × 0 + 0 × 0.420 = 0.069282 + 0 + 0 = 0.06928
row y: −0.5000 × 0.080 + 0.8660 × 0 + 0 × 0.420 = −0.040000 + 0 + 0 = −0.04000
row z: 0 × 0.080 + 0 × 0 + 1 × 0.420 = 0.42000
So the camera's true offset in world axes is RAB tBC = (0.06928, −0.04000, 0.42000) m. The buggy code added the offset without rotating it, so it believed tBC = (0.08000, 0, 0.42000) m. The parent's own translation tAB appears in both and cancels in the difference, which is why the bug is invisible at the neutral pose and grows with the angle.

Step 3 — subtract componentwise.
ex = 0.08000 − 0.06928 = +0.01072    ey = 0 − (−0.04000) = +0.04000    ez = 0.42000 − 0.42000 = 0
The z component is exactly zero and that is not a coincidence: 42 cm of the offset lies along the rotation axis, and a rotation about z cannot move anything along z. Only the 8.0 cm perpendicular part contributes.

Step 4 — magnitude.
|e| = √(0.010722 + 0.040002 + 02) = √(0.00011487 + 0.00160000) = √0.00171487 = 0.04141 m = 4.14 cm, pointing at +x/+y — forward and to the robot's left. That is the 4 cm from the report, produced from geometry alone, before anyone opened a file.

Step 5 — check it against the closed form. The perpendicular part of the offset is t = (0.080, 0), so |t| = 0.080 m, and the chord formula derived above gives
2 sin(|ψ| / 2) · |t| = 2 sin(15°) × 0.080 = 2 × 0.25882 × 0.080 = 0.5176 × 0.080 = 0.041411 m.
The two routes agree to five decimal places (0.041411 versus 0.041411). Saying “let me verify that against the closed form” and having the two land on the same number is worth more than either number alone — it shows the formula is a tool you use, not a fact you stored.

Step 6 — predict the sweep, so the experiment is falsifiable. Evaluate the closed form across the joint range and hand the technician a table they can check on the robot:
|ψ|2 sin(|ψ|/2)predicted |e| = 0.080 m × thatlinear approximation |ψ|·|t|
15°0.261052.088 cm2.094 cm (0.3% high)
30°0.517644.141 cm4.189 cm (1.2% high)
45°0.765376.123 cm6.283 cm (2.6% high)
60°1.000008.000 cm8.378 cm (4.7% high)
Near-linear at 1.40 mm per degree of torso yaw (0.080 m/rad × π/180), bending gently below the line as the angle opens. And the ceiling matters for triage: at 180° the error is 2|t| = 16 cm and it cannot exceed that, so a reported 30 cm miss rules this cause out without any further work.

3 · Coding drills

drill 1 Implement SE(3) inverse and composition. No libraries.

What to say while writing: "The rotation block inverts by transpose because it is orthogonal — that is what RR = I gives us, and it means no linear solve. The translation is expressed in the parent's axes, so I have to rotate it into the child's axes before negating it: −Rt, not −t."

# CONTRACT  T: ndarray (4,4) float64. Top-left 3x3 orthonormal to 1e-9,
#           det R = +1 (a rotation, never a reflection), bottom row
#           exactly [0, 0, 0, 1], translation in METRES. Units are the
#           caller's job; this function never rescales.
# RETURNS   the same shape, dtype and invariants. Exact, not iterative.
def se3_inv(T):
    R, t = T[:3, :3], T[:3, 3]
    Ti = np.eye(4)
    Ti[:3, :3] = R.T
    Ti[:3, 3]  = -R.T @ t
    return Ti

def relative(T_w_a, T_w_b):
    """Pose of b seen from a. The TARGET side is the one inverted."""
    return se3_inv(T_w_a) @ T_w_b

The follow-up they will ask: "Why not np.linalg.inv?" — because it is an LU factorisation, roughly 25× slower, and its output is not exactly in SE(3) when the input has drifted. The second follow-up: "What do you assert in a unit test?" — T @ inv(T) == I, inv(T) @ T == I, and explicitly that inv(T) != T.T, because that is the mistake being tested for.

Worked example: do it by hand once, so you can do it anywhere. Sooner or later you will have to run your own function on a number by hand. Have this one ready.

Inputs. A base_link that has yawed a quarter turn and stands 2.0 m along world x and 0.5 m up: R = Rz(90°), t = (2.0, 0, 0.5) m. So T = Tworld,base, and we want Tbase,world.

Step 1 — write R, entry by entry. With cos 90° = 0 and sin 90° = 1, Rz(90°) = [[0, −1, 0], [1, 0, 0], [0, 0, 1]]. Transposing means reflecting across the diagonal — entry (i, j) becomes entry (j, i):
R = [[ 0,  1,  0 ],  [−1,  0,  0 ],  [ 0,  0,  1 ]]
Check it is still a rotation before you use it: columns unit length, mutually orthogonal, det = +1. (Transposing an orthogonal matrix can never break that — but saying you checked is free.)

Step 2 — three dot products for Rt.
row x: 0 × 2.0 + 1 × 0 + 0 × 0.5 = 0
row y: (−1) × 2.0 + 0 × 0 + 0 × 0.5 = −2.0
row z: 0 × 2.0 + 0 × 0 + 1 × 0.5 = +0.5
So Rt = (0, −2.0, 0.5), and negating gives −Rt = (0, +2.0, −0.5) m. Note that the transpose reordered the components as well as changing signs — that is precisely what the naive −t = (−2.0, 0, −0.5) fails to do. The two answers differ by (2.0, 2.0, 0), a distance of 2.83 m: not a subtle error, which is why it survives code review and dies on the robot.

Step 3 — say what the number means. "In the robot's own axes, the world origin is 2.0 m off its left side and 0.5 m below it." That sentence is checkable against the room, and a length check confirms it: |t| = √(2.02 + 0.52) = √4.25 = 2.0616 m and |−Rt| = √(2.02 + 0.52) = 2.0616 m. A rotation cannot change a length, so if those two disagree you have made an arithmetic slip, not a modelling one.

Step 4 — verify by multiplying out the top-right block of T T−1. Block multiplication gives that block as R(−Rt) + t, which is −(R R)t + t = −t + t = 0 in symbols. Now with the numbers, applying R = [[0, −1, 0], [1, 0, 0], [0, 0, 1]] to (0, 2.0, −0.5):
row x: 0 × 0 + (−1) × 2.0 + 0 × (−0.5) = −2.0
row y: 1 × 0 + 0 × 2.0 + 0 × (−0.5) = 0
row z: 0 × 0 + 0 × 2.0 + 1 × (−0.5) = −0.5
giving R(−Rt) = (−2.0, 0, −0.5), and adding t = (2.0, 0, 0.5) lands on (0, 0, 0). The rotation block is R R = I by orthogonality and the bottom row is [0 0 0 1] by construction, so T T−1 = I exactly — that is the unit test, run on paper.
drill 2 Walk a tf tree: lookup_transform(target, source) from scratch.

What to say while writing: "I walk each frame up to the root accumulating on the left, because Troot,frame = Troot,p1Tp1,p2…Tpk,frame. Then the answer is inv(Troot,target) · Troot,source — the target side is the one that gets inverted, so the subscripts cancel."

# CONTRACT  tree: dict[str, tuple[str, ndarray (4,4) float64]]
#           keyed by CHILD frame name, value = (parent name, T_parent_child).
#           Must be a forest with one root reachable from both frames:
#           acyclic, exactly one parent per child. Both names must exist.
# RETURNS   T_root_frame, (4,4) float64. Raises on a disconnected tree —
#           NEVER returns identity, which would be a silent wrong answer.
def to_root(tree, frame):
    """tree: child -> (parent, T_parent_child). Returns T_root_frame."""
    T = np.eye(4)
    while frame in tree:
        parent, T_pc = tree[frame]
        T = T_pc @ T                  # accumulate on the LEFT
        frame = parent
    return T

def lookup(tree, target, source):
    return se3_inv(to_root(tree, target)) @ to_root(tree, source)

Follow-ups to be ready for: "Make it O(depth) instead of walking to the root twice" — find the lowest common ancestor and stop there. "Now add timestamps" — each edge holds a time-sorted deque; binary-search the two bracketing samples and slerp the rotation, lerp the translation. "What if the tree is disconnected?" — raise, loudly, with both frame names in the message; never return the identity.

drill 3 Quaternion to rotation matrix, and slerp that does not take the long way.

What to say while writing: "The diagonal is 1 minus twice the sum of the squares of the other two vector components; the off-diagonals are 2(product) with the w-term antisymmetric — minus above the diagonal, plus below. Every entry is quadratic, which is exactly why R(q) = R(−q)."

# CONTRACT  q: ndarray shape (4,) float64, HAMILTON convention, w FIRST
#           (w, x, y, z) — NOT the scipy/ROS (x, y, z, w) order — and
#           ||q|| = 1 to 1e-8. Passive/frame convention: R maps a vector
#           from the child frame into the parent frame.
# RETURNS   (3,3) float64 with R^T R = I and det R = +1, to ~1e-15.
#           Every formula below ASSUMES the unit norm — see the degraded
#           case underneath for what a norm of 1.02 silently produces.
def quat_to_R(q):
    assert abs(np.linalg.norm(q) - 1.0) < 1e-8, "quaternion not unit"
    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)]])

# CONTRACT  q1, q2: ndarray shape (4,) float64, unit Hamilton quaternions,
#           same component order as above. t: float in [0, 1].
# RETURNS   (4,) unit quaternion on the SHORTER arc between them. Not
#           commutative in the sign of q2 unless the flip line below runs.
def slerp(q1, q2, t):
    d = float(np.dot(q1, q2))
    if d < 0.0: q2, d = -q2, -d       # THE line
    if d > 0.9995:
        r = q1 + t*(q2-q1); return r/np.linalg.norm(r)
    om = np.arccos(d)
    return (np.sin((1-t)*om)*q1 + np.sin(t*om)*q2) / np.sin(om)

The follow-up: "Why the d > 0.9995 branch?" — because sin(Ω) is in the denominator and goes to zero for nearly identical quaternions; lerp-and-normalise is both stable and accurate there. And: "What does the sign line prevent?" — a 358° rotation instead of a 2° one.

The degraded case, because "assumes unit norm" is a claim you should be able to price. The chapter tells you the (w,x,y,z)-versus-(x,y,z,w) bug is real and shipped. The quieter cousin is a quaternion that is almost unit — drifted by a couple of percent after a few thousand float32 compositions, or emitted by a solver that never re-normalised. Feed it in and see exactly what comes out.

# 5 degrees about z, then the same thing 2% off the unit sphere
q_good = np.array([0.99904822, 0.0, 0.0, 0.04361939])   # ||q|| = 1.000000
q_bad  = q_good * 1.02                                   # ||q|| = 1.020000

R = quat_to_R_no_assert(q_bad)          # the same body, minus the guard
print(np.linalg.det(R))                 # 1.00031989   (must be 1.0)
print(np.linalg.norm(R.T @ R - np.eye(3)))   # 4.5239e-04   (must be ~1e-16)

Two numbers, and they are the two numbers that detect this in production. But the important part is how they scale, because that is what makes the bug survive. For a rotation of θ about z with a norm scaled by k, the matrix has the form [[a, −b], [b, a]] with a = 1 − k2(1 − cosθ) and b = k2 sinθ, so det R = a2 + b2:

Inputdet R‖RR − I‖Fsingular valueswhat a 2 m reach becomes
5°, ‖q‖ = 1 (healthy)1.000000003.2 × 10−181, 1, 12.0000 m
5°, ‖q‖ = 1.021.000319894.52 × 10−41.00016, 1.00016, 12.0003 m (+0.3 mm)
90°, ‖q‖ = 1.021.084064321.19 × 10−11.04118, 1.04118, 12.0824 m (+8.2 cm)

Read the middle row first: at a small mounting angle the corruption is 4.5 × 10−4 — invisible to the eye, invisible to a plot, and 0.3 mm at a 2 m reach, which is under anybody's acceptance threshold. It passes. Then the same drifted quaternion is used for a 90° sensor mount and the very same 2% becomes an 8.2 cm reach error, because the matrix is no longer a rotation at all: its singular values are 1.041, so it stretches every vector in the xy-plane by 4.1% on top of rotating it.

Why assert instead of silently normalising, in one sentence: the norm is a health signal about the caller, and normalising throws it away — a quaternion arriving at 1.02 means something upstream is accumulating without renormalising, and that thing will keep drifting to 1.05 and 1.20 whether or not this function papers over it. Fail loudly at 1e-8, name the caller in the message, and you convert a slow silent skew into a stack trace on the first bad message. The one exception worth stating out loud: at a hard boundary where you ingest third-party data you cannot fix, normalise and log the pre-normalisation residual as a metric, so the drift is still observable.

drill 4 Re-orthonormalise a drifted rotation matrix.

What to say while writing: "The nearest true rotation to a drifted matrix in the Frobenius sense is UV from its SVD. The determinant guard matters: if the input has drifted through a reflection, or came from an unconstrained solver, UV can come out with determinant −1, and flipping the sign of the last column of V is the standard correction — the same one inside Kabsch and Umeyama."

# CONTRACT  M: ndarray (3,3) float64, finite, full rank. NO orthogonality
#           assumed — that is the whole point — but a rank-deficient or
#           NaN input makes the SVD meaningless, so check first.
# RETURNS   (3,3) float64 with R^T R = I to ~1e-15 and det R = +1 exactly
#           by the guard: the nearest rotation to M in Frobenius norm.
def project_to_SO3(M):
    U, _, Vt = np.linalg.svd(M)
    R = U @ Vt
    if np.linalg.det(R) < 0:
        Vt[-1] *= -1            # flip the smallest-singular-value direction
        R = U @ Vt
    return R

The follow-up: "When would you ever need this?" — after accumulating rotations in float32, after a learned network outputs 9 numbers, or after any least-squares fit that did not enforce the constraint. And: "What would you rather do?" — not accumulate at all: recompute from the source of truth, or store a quaternion and renormalise with one division.

4 · Debugging scenarios

Symptom → commit to one test → cause

Pick a symptom, then click the ONE test row you would run first — before anything is revealed. That commitment is the whole exercise: at a real broken robot, what matters is the test you choose, not the cause you eventually name. Your pick is scored on whether it actually separates the candidate causes or merely confirms something you already believed. Reveal all is there for afterwards.

 

Read this table in triage order, not in row order. The question at a broken robot is rarely “name the cause”; it is “what do we do first?” The ordering principle is cheapest discriminating test first — cost meaning setup time, hardware risk and how many people you have to interrupt, divided by how many candidate causes the test eliminates. State the order out loud before you start diagnosing, because it is the part of your answer that transfers to bugs you have never seen:

  1. Software-only, zero risk, ten seconds. tf2_monitor on the suspect edge, plus the four published scalars (orthogonality residual, |w|, consecutive quaternion dot, |cos pitch|). Nobody has to clear a cell, nothing moves, and it eliminates the duplicate-publisher, the sign-flip, the off-manifold-drift and the convention classes in one pass. There is no excuse for not having run this before you speak.
  2. Robot stationary, target required, about twenty minutes. The two-range fit against an external length. Costs a target setup and a tape measure, but the robot never moves, so it needs no safety observer. It separates rotation from translation from scale — three causes with one experiment.
  3. Robot moving, cell cleared, safety observer. The joint sweep. It is the most specific test on the board and it is still last, because it is the only one that costs motion. Note the inversion: the highest-information test is the lowest-priority one, and an engineer who reaches for it first has never had to book time on a shared robot.

five columns — scroll sideways →

Symptom Root cause The metric that reveals it Fix What you say first
Grasp misses by a few cm, only when a parent joint is rotated; zero at the neutral pose A child offset added without the parent's rotation: (I − R)t Sweep the parent joint. Error follows 2 sin(|ψ|/2)·|t| and flips side when the joint reverses Compose properly: tAC = RABtBC + tAB “Before I open the kinematics I want to predict the number. An 8 cm perpendicular offset at 30° gives 2 sin(15°) × 0.080 = 4.1 cm. If the measured miss is not near that, I am on the wrong cause — and I would rather find that out in the next two minutes than after an afternoon in the file.”
3D error proportional to distance, through the origin Extrinsic rotation error — a bumped mount Measure at two ranges and fit. Slope, no intercept ⇒ rotation; intercept, no slope ⇒ translation Thread-lock and a torque spec, then recalibrate “I would resist recalibrating first. The fit tells me whether I am looking at geometry or at a mount, and if it is a mount then recalibration is a fix with a three-week half-life. I would want the maintenance ticket raised in the same hour as the calibration job.”
Every distance short by the same percentage, reprojection error healthy A wrong metric input: stereo baseline, calibration-target square size, or wheel radius Compare against an external measurement — a tape measure, a survey point, an IMU-integrated distance. Internal residuals are blind to a uniform scale Fix the constant. Then add an external check to CI “Reprojection error is healthy, which is the tell — internal residuals cannot see a uniform scale, so I need an external length. Everything the optimiser can check is already consistent; the error is in a number nobody optimised.”
Controller jerks a few times a minute, worse when localisation is good A consumer reading its pose in map instead of odom Overlay map→odom update timestamps on the commanded acceleration. Apparent velocity = jump / control period, e.g. 0.35 m / 2 ms = 175 m/s — physically impossible Move anything that differentiates a pose into odom “Before I look at a single gain, I would ask whether the jerks correlate with localisation corrections — if they do, this is a frame-choice bug and no amount of tuning will touch it. And the symptom getting worse as localisation improves is the part that should stop everyone: that is backwards for every tuning hypothesis and forwards for this one.”
Axes permuted — forward reads as up, left as forward Optical vs body convention, or a left-handed export The errors are exact 90° multiples, never small numbers. Check det(R) too: −1 means handedness Insert the _optical static transform; assert det = +1 at every import boundary “The size of the error is the diagnosis. Small errors are calibration; exact 90° errors are conventions. So I would stop treating this numerically and go read what the driver documents about its frame, because no amount of fitting will recover a permutation.”
Pose appears to vibrate; tuning covariances does not help Two nodes publishing the same tf edge ros2 run tf2_ros tf2_monitor odom base_link lists the authorities. More than one name is the bug One publisher per edge. Disable the redundant one “This is the cheapest test on the board — ten seconds, no motion, no risk — so I would run it first even though it is not my leading hypothesis. And ‘tuning does not help’ is itself evidence: it says the signal is not being estimated badly, it is being overwritten.”
An isolated 360° whip in an interpolated orientation Quaternion sign flip across a boundary; slerp took the long way dot(q_prev, q_now) goes negative. A negative value is never physical Canonicalise the sign before every slerp, average or difference “An isolated whip with a perfectly smooth neighbourhood is not dynamics. I would plot the consecutive dot product before touching the filter — a negative dot between consecutive poses at 200 Hz is never physical, and it is one line to check.”
Yaw goes wild near vertical; rate commands saturate Gimbal lock in an Euler pipeline Plot cos(pitch) against the commanded rate. A 1/cos ramp, with saturation past |pitch| > 85° Compute the error as a rotation vector; convert to Euler only for display “First question: what was the pitch at the moment of the jump? If it was nowhere near vertical, this is an atan2 branch cut in the plot and we are one day away from fixing a display bug. If it was near vertical, I expect the rate command to show a 1/cos ramp, and that ramp is what I would show the team rather than the angle.”
Orientation wrong by a large, stable angle; norm is exactly 1 Quaternion component order swapped, (w,x,y,z) vs (x,y,z,w) |w| against the expected angle. A 5° rotation must have |w| = cos(2.5°) ≈ 0.999; if the last element is 0.999 instead, the order is swapped Assert |w| > 0.9 on every small mounting quaternion at the boundary “‘Exactly 1’ is doing a lot of work in that sentence. Drift does not preserve the norm and a bad calibration does not either, so the four numbers are a legal quaternion in the wrong slots. I would print the vector and look at which element is large before I look at anything else.”
Reconstruction slowly grows or skews over a shift; restarting fixes it Accumulated rotation drift off SO(3) ‖RR − I‖F and |det R − 1| growing monotonically Stop accumulating; or store a quaternion; or re-orthonormalise with SVD “That restarting fixes it is the entire clue — a restart only repairs state that accumulates and is never reset. So I would look for an accumulator before I look at a sensor, and I would plot the residual against uptime rather than against the scene.”
Where the |w| number in row nine comes from. Do not memorise “0.999”. A quaternion is built from the half-angle, q = (cos(θ/2), n sin(θ/2)), so for a 5° mounting rotation the scalar part is w = cos(2.5°) = 0.99905 and the vector part has magnitude sin(2.5°) = 0.04362. That is the whole test: a small rotation puts almost all the magnitude in one component, and if the component carrying 0.999 is the last one, you are reading (x, y, z, w) as (w, x, y, z). The 0.9 threshold in the fix column is derived the same way rather than picked: |w| > 0.9 means cos(θ/2) > 0.9, i.e. θ < 2 arccos(0.9) = 51.7° — loose enough that no genuine small mounting rotation ever trips it, tight enough that any swapped order does.

5 · Classical vs modern, and when to use which

QuestionClassicalModernWhen to use which
Representing a pose4×4 matrix, hand-rolled helpersLie-group type (Sophus, manif, GTSAM Pose3) with exp/log and the adjointHand-rolled is fine for a fixed pipeline of composes. The moment you optimise over poses, take the library — the value is the analytic Jacobians and the retraction, not the multiply
Rotation in an estimatorEuler angles in the state vectorError-state: nominal quaternion on the manifold, 3-vector error in the tangent spaceNever Euler in a state vector. The covariance is meaningless near vertical and the Jacobian is singular there
Filter formulationStandard EKF, error defined by subtractionInvariant EKF, error defined by the group operation (Barrau & Bonnabel 2017)InEKF when the system fits the group structure — legged robots, VIO — and especially when initial yaw is poor. Standard EKF is simpler and fine when you initialise well
Rotation out of a networkRegress a quaternion or Euler angles6D or 9D representation, projected onto SO(3)Always the 6D/9D form for a learned head. Sub-5D is provably discontinuous — the plateau you see is structural, not a tuning problem
CalibrationOffline, target-based, done once at manufactureOnline estimation inside the filter (OpenVINS); targetless from scene structure (OpenCalib)Offline as the reference and the audit trail; online as a monitor that tells you a mount has moved. Do not let online estimation freely rewrite production extrinsics — observability depends on the motion the robot happens to be doing
Frames across robotsOne map, one odom, per REP-105Estimated inter-robot transforms with outlier rejection (Kimera-Multi, Swarm-SLAM)REP-105 for a single robot, always. For fleets, keep a continuous local frame per robot and treat every cross-robot transform as a measurement with a covariance and a rejection test
Getting geometry from imagesCalibrate, then triangulateFeed-forward pointmap regression (DUSt3R, MASt3R, 2024)Learned pointmaps are excellent for coarse reconstruction from uncalibrated images and terrible as a metric source — no guaranteed scale, no trustworthy uncertainty. Keep calibrated geometry anywhere a millimetre matters
The meta-answer for any "classical or modern?" question. Refuse the binary and split the system: put the learned component where its failure is recoverable and keep an interpretable residual anywhere the product has to be debugged in the field. Then name your switching condition out loud — "move more of this to learning when you can bound its worst case on-device". The question is never which side you pick; it is whether you know the cost of the side you picked.

6 · Recommended reading

The one book: Timothy Barfoot, State Estimation for Robotics (Cambridge University Press; 1st ed. 2017, 2nd ed. 2024). Chapters 6–8 are the best single treatment of rotations, poses and Lie-group estimation in print — rigorous about SO(3)/SE(3), explicit about conventions, and written by someone who has shipped. Free PDF from the author's page. If you read one thing on this topic, read those three chapters.

Runner-up, for kinematics rather than estimation: Lynch & Park, Modern Robotics: Mechanics, Planning, and Control (2017) — chapter 3 builds SE(3), screws and twists from scratch, with the accompanying video course.

Five papers, and why each one:

PaperWhy it is on the list
Solà, Deray & Atchuthan, "A micro Lie theory for state estimation in robotics" (2018, rev. 2021)The document that made this vocabulary standard on robotics teams. Read it for the definitions of the exp/log maps, the adjoint and the right/left Jacobians, all in the notation your colleagues will use. It is also the theory behind manif.
Solà, "Quaternion kinematics for the error-state Kalman filter" (2017)The definitive practical reference on quaternion conventions. Its appendix tabulates Hamilton against JPL side by side, which is the thing that settles arguments. Read it for the error-state derivation and keep it open when you implement.
Zhou, Barnes, Lu, Yang & Li, "On the Continuity of Rotation Representations in Neural Networks" (CVPR 2019)Short, surprising, and it changes what you build. The proof that any representation below 5 dimensions is discontinuous explains a whole category of "the network just will not converge" failures, and the 6D fix is four lines.
Barrau & Bonnabel, "The Invariant Extended Kalman Filter as a Stable Observer" (IEEE TAC 2017)The theory that turned the error-state hack into a filter with convergence guarantees. Read it for why defining the error with the group operation makes the error dynamics estimate-independent — that single idea is the whole contribution.
Furgale, Rehder & Siegwart, "Unified Temporal and Spatial Calibration for Multi-Sensor Systems" (IROS 2013)The Kalibr paper. Read it for the insight that the time offset belongs in the same optimisation as the extrinsics, because otherwise the two errors trade off against each other invisibly — and that idea is the bridge into Lesson 2.

Five repositories, and exactly what to look at in each:

RepoWhat to read
ros2/geometry2 (tf2)tf2/src/buffer_core.cpp — the tree walk, the time-interpolation logic, and how it decides whether to interpolate, extrapolate or throw. This is the reference implementation of everything in Chapter 2, and reading it once removes all mystery from lookupTransform.
strasdat/Sophussophus/se3.hpp — exp, log, the adjoint, and the Dx_this_mul_exp_x_at_0 Jacobians. Compare its inverse() against the naive version and note that it never calls a general matrix inverse.
artivis/manifThe examples directory, then SE3.h. Read it right after Solà's micro Lie theory paper — the API is a direct transcription of the notation, so the two together teach faster than either alone.
rpng/open_vinsov_msckf/src/state/State.cpp and Propagator.cpp — a production error-state formulation with online calibration of intrinsics, extrinsics and the time offset. This is what "the covariance is 3×3, not 4×4" looks like in shipped code.
ethz-asl/kalibrThe wiki pages on camera–IMU calibration, then the B-spline trajectory representation in the source. Read it for how spatial and temporal parameters end up in one optimisation.
The last thing to fix in your head. Frames are not a topic you recall — they are a habit you practise. Name transforms with both frames. Check that inner subscripts cancel. Ask what the error is a function of before you guess a cause. Predict a magnitude before you read the code. Every one of those shows within the first two minutes of real work, and none of them requires you to remember a formula.

When you want to pressure-test these habits under a clock, the Studio button on this lesson runs a timed practice session on exactly this material.

You have told your tech lead the gripper miss is caused by a missing parent rotation. She leans back and says: prove it without touching the code. What single experiment do you run, and what exactly must you observe?
Why (d) is the tempting wrong answer, and what each wrong option actually tests. Option (d) — measure at the neutral pose — feels like the disciplined move, and it is the one careful engineers pick. It fails for a structural reason worth naming out loud: at the neutral pose R = I, so e = (I − R)t = 0 by construction. The experiment is guaranteed to return zero whether or not the bug exists, so it cannot distinguish this cause from a perfectly healthy robot. A test whose outcome does not depend on the hypothesis has no diagnostic value, however careful it looks.

Option (a) measures repeatability, which is a statement about noise. This error is a deterministic bias — it will repeat beautifully, and so would a bug in the tool-centre-point offset, so the test separates nothing.

Option (c) attacks the wrong layer. Intrinsics are the camera's internal geometry and act after the perspective divide; this fault is extrinsic and kinematic, and the reprojection residual is already healthy. You would spend an afternoon confirming that a healthy thing is healthy.

Option (b) is the only one whose two possible outcomes both carry information: the miss flips at equal magnitude (the hypothesis survives) or it does not (the hypothesis is dead, in one measurement). That is the property to say out loud — “I want a test that can come back and tell me I am wrong” — because it is the sentence that separates a debugger from a guesser.
Next in the track
Lesson 02 — Time, Clocks & Sensor Alignment
Every transform in this lesson carried a hidden argument: at what time? A perfectly calibrated extrinsic with a 5 ms clock offset produces 5 mm of error at 1 m/s, and it looks exactly like a calibration fault. Lesson 2 covers clock domains, PTP and hardware triggering, capture-versus-receive timestamps, and interpolating streams that were never sampled together.
The question that connects them: if the geometry is right and the timing is wrong, which of the two does the error signature look like — and how would you tell?

"What I cannot create, I do not understand."
— Richard Feynman