The frame bugs that eat a week — and the twenty minutes of reasoning that find them.
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 checked | Result | Conclusion drawn |
|---|---|---|
| Gripper mechanical backlash | 0.4 mm measured with a dial gauge | Not mechanical |
| Arm joint encoders vs. commanded | tracking error < 0.05° on every joint | Not the controller |
| Camera intrinsic calibration | reprojection RMS 0.21 px on a fresh board | Not the lens |
| Object detector 3D output | bottle centroid within 6 mm of a laser-measured ground truth | Not perception |
| Re-ran the whole thing in simulation | reproduces exactly | Not 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.
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:
There is nothing to invent here. Substitute the first into the second and expand, and the composition rule falls out with no choices made:
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:
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:
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:
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°.
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, R⊤R = I:
Expand the product in the middle: (I − R)⊤(I − R) = I − R − R⊤ + R⊤R, and the last term collapses to I. So the whole thing is a quadratic form in one very simple matrix:
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:
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):
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:
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.
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.
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.
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:
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:
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:
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:
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.
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.
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.
camera_opticalhead_cameratorso, then base_linkbase_linkA 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 = 6°, 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.
"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.
| Quantity | Number | Where it comes from |
|---|---|---|
| Bytes per transform on the wire | ~100 B | 7 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 bandwidth | 29 × 100 B × 200 Hz = 0.58 MB/s (4.6 Mbit/s) | Every dynamic joint, every tick. |
| The RGB stream beside it | 720 × 1280 × 3 × 30 = 82.9 MB/s raw | tf 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 default | 29 × 200 × 10 ≈ 58,000 entries ≈ 6 MB | Also 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 latency | 28 ms detector + ~0.1 ms lookup/compose + <2 ms IK ≈ 31 ms | The 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:
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 version | The twenty-minute version | |
|---|---|---|
| First move | List every subsystem and start measuring them | Ask what the error is a function of |
| Search space | All of them — each measurement clears one | Rotation-dependent faults only — one observation clears the rest |
| Evidence used | Absolute magnitudes ("4 cm is a lot") | The shape of the dependence (zero at 0°, curved, sign-flipping) |
| Prediction made | None — measurement is the only tool | 4.14 cm at 30°, before touching the code |
| Confirming test | Re-run and hope | Turn the other way; the miss must flip sides |
| Cost | 3 engineers × 2 days | 1 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":
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.
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:
Every numbered chapter here carries five layers, because those are the five questions a working robotics engineer ends up answering about every subsystem:
| Layer | The question it answers | What 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:
| Bug | Symptom | Signature that identifies it |
|---|---|---|
| Unrotated child offset, (I−R)t | Position error that vanishes when the parent is un-rotated | Flips sign when the parent rotates the other way; grows as 2 sin(ψ/2)|t⊥| |
| Reversed composition order | Large, structured error even at small angles | Error is zero iff the two rotations commute (same axis) |
| Wrong inverse (−t instead of −R⊤t) | Round trip A→B→A does not return to start | T · 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 boundary | Sudden 360° whip in an interpolated pose | dot(qk, qk+1) < 0 between consecutive samples |
| Gimbal lock in an Euler pipeline | Yaw goes wild near vertical pitch; rates saturate | |pitch| → 90°, and the Euler-rate Jacobian determinant cos(pitch) → 0 |
| Optical vs body frame confusion | Axes permuted: forward reads as down, left as forward | Errors are exact axis swaps, not small numbers — a 90° multiple |
| Left-handed frame from a CAD export | Mirror-image scene; det(R) = −1 | det(R) = −1 and R⊤R = I still holds — orthogonal but not a rotation |
| map vs odom confusion (REP-105) | Controller jerks every time the localizer corrects | Discontinuity in the consumed pose coincides with loop-closure events |
| Extrinsic rotation error | Reach error proportional to distance | Residual field is a constant pixel shift, and 3D error scales with range |
| Intrinsic focal error | Reach error proportional to distance from image centre | Residual grows radially from the principal point, zero at the centre |
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.
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:
base_link → torso at the planning timestamp, tf extrapolated 180 ms past the last message, and the torso had moved 12° in that window. You fix it by requesting the transform at the sensor timestamp and failing loudly rather than extrapolating.map instead of odom, so every localisation correction becomes a step input. You move the controller to odom and write the REP-105 explanation into the team wiki so it does not happen again.None of those days involved a new algorithm. All five were frames. This is the job, and it is why the series starts here.
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.
"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.
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:
Square both sides and write the norm as a dot product: (Rv)⊤(Rv) = v⊤v, so v⊤(R⊤R)v = v⊤v for every v. Call M = R⊤R. 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)⊤ = B⊤A⊤ and (A⊤)⊤ = A:
Symmetry is not decoration here; it is what makes the whole argument legal. A quadratic form v⊤Mv only ever sees the symmetric part of M, because any antisymmetric part contributes v⊤Av = 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 v⊤Mv = v⊤v 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:
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 R⊤R = 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):
How many numbers is a rotation really? Count: R has 9 entries. The constraint R⊤R = I is a 3×3 matrix equation, but R⊤R 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 R⊤R 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 R⊤R = column pair | Scalar equation in the entries of R | What it constrains |
|---|---|---|
| (1,1) = c1·c1 | r11² + r21² + r31² = 1 | image of x̂ has unit length — no stretch along x |
| (2,2) = c2·c2 | r12² + r22² + r32² = 1 | image of ŷ has unit length |
| (3,3) = c3·c3 | r13² + r23² + r33² = 1 | image of ẑ has unit length |
| (1,2) = (2,1) = c1·c2 | r11r12 + r21r22 + r31r32 = 0 | x̂ and ŷ stay perpendicular — no shear in xy |
| (1,3) = (3,1) = c1·c3 | r11r13 + r21r23 + r31r33 = 0 | x̂ and ẑ stay perpendicular |
| (2,3) = (3,2) = c2·c3 | r12r13 + 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:
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.
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.
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.base_link. This is the transform /tf_static publishes once and latches.head_mount: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.
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:
| Channel | What goes on it | Rate | Why |
|---|---|---|---|
/tf_static | Bolted-down geometry: base_link→head_mount, head_mount→camera_optical, IMU mount | Published once, latched | It never changes; re-sending it 200 times a second is pure waste, and latching means a late subscriber still gets it |
/tf | Anything that moves: every joint, odom→base_link, map→odom | Joints 200 Hz, odom 100 Hz, map 5–20 Hz | Consumers 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:
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:
tf2_ros::BufferTen 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.
Data flow, with shapes, for one 200 Hz control cycle on a 7-DOF arm:
base_linkWrite 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:
| Library | Language | Use it when |
|---|---|---|
scipy.spatial.transform.Rotation | Python | Offline analysis, notebooks, converting representations. No SE(3) type — you carry t yourself. |
tf2 / tf2_ros | C++ / Python | Anything inside ROS. It is not just a math library — it is the time-indexed buffer and the tree. |
Sophus | C++ header-only | You need exp/log, Jacobians and the adjoint in an estimator. The de-facto choice next to Ceres. |
manif | C++ header-only | Same job as Sophus with a cleaner Lie-group API and analytic Jacobians as first-class returns. |
GTSAM / Pose3 | C++ / Python | Factor-graph estimation; the SE(3) type comes with the optimiser attached. |
pytransform3d | Python | Teaching, plotting frames, sanity-checking a convention before you write the C++. |
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:
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:
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 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.
| Matrix | world point (x, y, z) | det | R⊤R = I ? | distance from base |
|---|---|---|---|---|
| Rz(30°) — rotation | (2.33301, 1.42321, 0.00000) | +1.00000 | yes | 0.53852 m |
| Rbad — reflection | (2.53301, 1.07679, 0.00000) | −1.00000 | yes | 0.53852 m |
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:
Three references worth knowing by title:
manif library.LieTorch library. This is the frontier answer: it makes SE(3) a differentiable type inside PyTorch, computing gradients in the tangent space rather than through a matrix parameterisation. It is what lets DROID-SLAM backpropagate through a bundle-adjustment layer, and it is the reason "learned SLAM" stopped meaning "regress a pose vector".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 R⊤R = 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.
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.
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:
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.
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:
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 = R1⊤R2 — 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:
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:
and solving for θ gives the identity used throughout this chapter and the rest of the lesson:
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.
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.A @ B where you meant B @ A.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:
Multiply the two series and keep every term of total degree 2 or lower:
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:
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:
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]]:
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):
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:
Subtract entry by entry:
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.
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.
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 ÷ pred | growth vs row above |
|---|---|---|---|---|
| 1° | 0.0175° | 0.0175° | 1.0000 | — |
| 5° | 0.4363° | 0.4361° | 0.9994 | 24.9× (5× angle) |
| 15° | 3.9270° | 3.9048° | 0.9943 | 8.95× (3× angle) |
| 45° | 35.3429° | 33.6842° | 0.9531 | 8.63× (3× angle) |
| 90° | 141.3717° | 120.0000° | 0.8488 | 3.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.
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:
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 = −R⊤t. Done:
T.T: transposing the whole 4×4 dumps the translation into the bottom row, producing something that is not even affine.The general residual. If you use −t instead of −R⊤t, 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.
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
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.
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.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 moves | The point moves; the frame is fixed | The 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 them | Every 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
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.
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.
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.
| Thing | What it actually holds / returns | The trap |
|---|---|---|
TransformStamped with header.frame_id = "base", child_frame_id = "cam" | Tbase←cam — the pose of the camera in the base frame | People 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 target | Argument 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 datum | Silently 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-id | ROS 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 |
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.
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
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:
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.
ros2 run tf2_tools view_frames — is the tree even connected, and is anything parented twice?ros2 run tf2_ros tf2_echo <target> <source> — does the translation magnitude match a tape measure, and does the RPY match the CAD drawing?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.
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]]:
| Object | Rule from B to A | Why it is different |
|---|---|---|
| A point p | pA = R pB + t | Points have a position, so the offset t applies |
| A free vector v (e.g. a normal) | vA = R vB | No position, so no offset — this is the "transform a normal, not a point" trap |
| A twist ξ = (ω, v) | ξA = AdT ξB | The 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−⊤ FB | Wrenches are covectors — they pair with twists to give power, and power is frame-independent, which forces the inverse-transpose |
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?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?
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:
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:
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:
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:
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:
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:
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:
| Entry | from 2vv⊤ | [v]× entry | w-term = 2w × that | Total |
|---|---|---|---|---|
| R12 | 2xy | −z | −2wz | 2(xy − wz) |
| R13 | 2xz | +y | +2wy | 2(xz + wy) ← plus, above the diagonal |
| R21 | 2xy | +z | +2wz | 2(xy + wz) |
| R23 | 2yz | −x | −2wx | 2(yz − wx) |
| R31 | 2xz | −y | −2wy | 2(xz − wy) ← minus, below the diagonal |
| R32 | 2yz | +x | +2wx | 2(yz + wx) |
Two structural facts fall out of the decomposition that you will use later in this chapter:
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:
Step 1 — recover the six basis products. From k² = −1 we get k−1 = −k. Right-multiply ijk = −1 by 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.
Step 2 — expand the sixteen terms. Multiply out, keeping the order of every factor because this algebra is not commutative:
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:
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:
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:
| Matrix | Quaternion | Axis–angle / rot-vector | Euler | |
|---|---|---|---|---|
| Numbers stored | 9 | 4 | 3 (or 4) | 3 |
| Constraints | 6 | 1 (unit norm) | 0 (or 1) | 0 |
| Bytes, float64 | 72 | 32 | 24 | 24 |
| Singularity | none | none (but double cover) | θ = 0 and θ = 2π | pitch = ±90° |
| Compose two | 45 flops (27×, 18+) | 28 flops (16×, 12+) | convert first | convert first |
| Rotate one vector | 15 flops | ~30 flops | convert first | convert first |
| Rotate 100k points | 1.5 Mflop — and it vectorises | 3 Mflop | convert once, then matrix | convert once, then matrix |
| Renormalise | SVD or Gram–Schmidt | one division | n/a | n/a |
| Interpolate | badly (entrywise lerp leaves SO(3)) | slerp, exact | ok for small angles | never — it lies near lock |
| Human-readable | no | no | somewhat | yes |
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.
"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:
| Boundary | Payload · bandwidth | Rate → budget |
|---|---|---|
| IMU → filter | q (4,) float64, 32 B66 kB/s | 200 Hz → 5 ms |
Filter → /tf | q (4,) + t (3,) f64, 90 B18 kB/s | 200 Hz → 5 ms |
| LiDAR → de-warp | (120000,3) float32, 1.44 MB14.4 MB/s | 10 Hz → 100 ms |
| De-warp → registration | (120000,3) float32, 1.44 MB14.4 MB/s | 10 Hz → 100 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:
(120000,3) @ (3,3) BLAS call it reads 1.44 MB and writes 1.44 MB, so it moves 2.88 MB for 1.8 Mflop — 0.63 flop per byte, which is not compute-bound by any margin, it is bandwidth-bound. At the ~12 GB/s a single core can stream, that is ≈0.24 ms. Against a 100 ms budget: 0.24%. (The 100k-point row in the table is the same story: 1.5 Mflop, 2.4 MB, ~0.2 ms.)v + w·t + u×t with t = 2(u×v) is five separate numpy kernels, each allocating and writing a full (120000,3) temporary. That is ~5 × 2.88 MB ≈ 14 MB of traffic instead of 2.88 MB, plus a silent float32 → float64 promotion the moment your (4,) float64 quaternion meets the float32 cloud, which doubles every one of those temporaries again. Measured cost lands around 1.6–2.0 ms — roughly 8× the matrix path for 2× the arithmetic.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:
| Approach | Work per sweep | Cost |
|---|---|---|
| Slerp every point | 120,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.
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:
| Thing | Order | Note |
|---|---|---|
geometry_msgs/Quaternion (ROS) | x, y, z, w | Named 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-first | but q.coeffs() returns (x, y, z, w) — the same object, two orders, in one API |
scipy Rotation.from_quat | x, y, z, w by default | a scalar_first flag exists in recent versions; if you rely on the default, pin the version |
| Most IMU / AHRS datasheets | w, x, y, z | the aerospace tradition |
| Isaac Sim / USD, MuJoCo | w, x, y, z | simulator boundaries are a classic crossing point |
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.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:
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:
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.
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.
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.
R[row, col], so they line up with the code above rather than with the 1-based Rij used in the prose earlier.)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.R[0,0] − R[1,1] − R[2,2] = 1 + 1 + 0.9998477 + 0.9998477 = 3.9996954 (= 4x² = 4 × 0.9999619² ✓)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 cancelsR[0,1] + R[1,0])/s = 0, z = (R[0,2] + R[2,0])/s = 0 ✓| θ | 1 + tr R | digits left |
|---|---|---|
| 179° | 3.05e−4 | 12.1 |
| 179.9° | 3.05e−6 | 10.1 |
| 179.99° | 3.05e−8 | 8.1 — half gone |
| 180° | 0 (or a few ulps negative) | 0 — nan |
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.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
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.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:
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.
assert abs(norm(q) - 1) < 1e-6 — catches un-normalised output from an optimiser or a lerp, but not the order swap.assert abs(q.w) > 0.9 on any transform you know to be a small mounting rotation — catches the order swap.assert dot(q_prev, q_now) > 0 between consecutive samples of a smoothly moving body — catches the sign flip that Chapter 4 is about.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.
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:
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.
| Question | Answer | Where it came from |
|---|---|---|
| |w| for a 5° rotation | 0.999 | cos(2.5°) |
| |w| for a 90° rotation | 0.7 | cos(45°) |
| |w| for a 180° rotation | 0 | cos(90°) — and this is why Shepperd exists |
| Bytes for a 120k-point float32 xyz cloud | 1.4 MB | 120000 × 3 × 4 |
| … at 10 Hz, per subscriber | 14 MB/s | Copy it three times and you have saturated a gigabit link |
| Flops to rotate that cloud by a matrix | 2 Mflop | 120000 × 15 |
| … and the wall time on one core | 0.2 ms | Bandwidth-bound at 2.9 MB moved, not flop-bound |
| Quaternion vs matrix, float64 | 32 B vs 72 B | 4×8 vs 9×8 — a 2.25× saving on every stored pose |
| Attitude samples spanning a 10 Hz LiDAR sweep at 200 Hz | 20 | 0.1 s ÷ 0.005 s — the de-warp bracket count |
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.
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:
Now take its determinant. Expand along the first column (only the top entry is non-zero):
inf, and one line later it is nan)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):
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:
The θ̇ coefficient cancels identically — sinφcosφ minus itself — and the ψ̇ coefficient collapses to a bare cosθ. Divide:
Combination B — multiply row 2 by cosφ, row 3 by −sinφ, and add:
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θ:
Stack the three results as rows and every entry of the inverse is now visible:
Nine entries, and the pitch angle is distributed across them in a very specific pattern. Read the rows:
| Row | Entries | How θ enters | As θ → 90° |
|---|---|---|---|
| φ̇ — roll rate | 1, tanθ sinφ, tanθ cosφ | tanθ in two of three entries | diverges like 1/cosθ |
| θ̇ — pitch rate | 0, cosφ, −sinφ | none at all | bounded, unit-norm, always exact |
| ψ̇ — yaw rate | 0, sinφ/cosθ, cosφ/cosθ | secθ in two of three entries | diverges 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.
| pitch | φ̇ (rad/s) | ψ̇ (rad/s) | φ̇ − ψ̇ |
|---|---|---|---|
| 0° | 0.1000 | 0.1000 | 0.00000 |
| 60° | 0.2732 | 0.2000 | 0.07321 |
| 85° | 1.2430 | 1.1474 | 0.09563 |
| 89° | 5.8290 | 5.7299 | 0.09913 |
| 89.9° | 57.3957 | 57.2958 | 0.09991 → p |
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:
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:
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:
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 = E⊤E, 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
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:
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:
Reproduce one row of the table by hand. Take pitch θ = 60°, where sin 60° = 0.86603:
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.
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.
| pitch θ | unlucky direction, off body z | σmin | 1/σmin |
|---|---|---|---|
| 0° | 45.0° | 1.00000 | 1.00 |
| 30° | 30.0° | 0.70711 | 1.41 |
| 60° | 15.0° | 0.36603 | 2.73 |
| 85° | 2.5° | 0.06169 | 16.21 |
| 89° | 0.5° | 0.01234 | 81.03 |
| 89.9° | 0.05° | 0.00123 | 810.28 |
| ω | ‖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 |
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.
| pitch | det E = cosθ | σmin(E) | cond(E) | What you see |
|---|---|---|---|---|
| 0° | 1.00000 | 1.00000 | 1.0 | nothing wrong |
| 60° | 0.50000 | 0.36603 | 3.7 | rates a bit lively; still fine |
| 85° | 0.08716 | 0.06169 | 22.9 | rate commands 11× larger than the motion |
| 89° | 0.01745 | 0.01234 | 114.6 | motors saturate, tracking visibly lags |
| 89.9° | 0.00175 | 0.00123 | 1145.9 | numerically dead; yaw is meaningless noise |
| 90° | 0 | 0 | ∞ | rank 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:
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.
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.
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.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:
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.
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.if np.dot(q1, q2) < 0.0: q2 = -q2 # same rotation, near hemisphereAfter the flip, q1·q2 = +0.999848, Ω = 1.0°, physical 2.0°. Correct.
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.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.
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:
| Boundary | Representation | Rate / size | Why that one |
|---|---|---|---|
| URDF / robot description | Euler RPY, fixed axes | parsed once | A 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 Hz | Compact, no singularity, and interpolation between samples is well defined |
| Estimator state | nominal quaternion + 3-vector error rotation | 4 + 3 numbers | The covariance must be 3×3. See the box below — this is the point most implementations miss |
| Controller error term | rotation vector of Rdes⊤Ract | 3 numbers, 1 kHz | The 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 transform | matrix, materialised once | 72 B, reused 300k times | 15 flops per point and it vectorises; converting from the quaternion once costs nothing amortised |
| Operator UI / RViz readout | Euler, degrees | 10 Hz | Humans cannot read quaternions. Display only — never round-trip through it |
| Neural network output head | 6D or 9D (Chapter 3) | 24–36 B | Continuity. Sub-5D representations are provably discontinuous, and networks cannot fit a jump |
| Logs and plots | quaternion, plus unwrapped Euler for humans | — | Store 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 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.
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.
Those 12 split cleanly in half, and the split has names you should use:
| Family | Shape | The six | Where you meet them |
|---|---|---|---|
| Tait–Bryan (third ≠ first) | all three axes distinct | XYZ, XZY, YXZ, YZX, ZXY, ZYX | aerospace, robotics, ROS RPY. "Roll, pitch, yaw" is Tait–Bryan ZYX |
| Proper Euler (third = first) | first axis reused | XYX, XZX, YXY, YZY, ZXZ, ZYZ | orbital 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.
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.
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.[−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.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.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.
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:
| Order | Who uses it | The trap |
|---|---|---|
| (x, y, z, w) — scalar last | ROS 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 first | Eigen's constructor Quaterniond(w,x,y,z), MuJoCo, Blender, most textbooks, most Hamiltonian notation | Eigen stores xyzw but constructs from wxyz. The same library disagrees with itself, on purpose, and that is where this bug is born |
Quaterniond(w,x,y,z). Each slot shifts one place: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.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.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):
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 trace | Cause | The scalar that proves it | Value that separates it | Fix |
|---|---|---|---|---|
| Ramp — rate command grows smoothly, saturates, recovers | gimbal lock | abs(cos(pitch)), or better √(1−sin|pitch|) | σmin < 0.06 (|pitch| > 85°) at the moment of saturation | change representation: drive a rotation vector, not Euler rates |
| Step — isolated huge excursion, no build-up, any pitch | quaternion sign flip (double cover) | dot(q_prev, q_now) | a single sample < 0, surrounded by samples > 0.999 | one line: negate q2 when the dot product is negative |
| Constant — wrong from the first frame, never changes character | component-order swap (or convention mismatch) | reported angle at a known home pose | exactly 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.
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.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:
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.
| Thing | The convention | Why |
|---|---|---|
| Units | Strict SI: metres, seconds, radians, kilograms | No implicit conversions anywhere. Degrees appear only in a UI or a URDF's human-facing fields |
| Handedness | Right-handed, always | So cross products, torques and the right-hand rule all agree without a per-frame footnote |
| Body frames | x forward, y left, z up | Forward is the direction of travel; z up makes gravity −z; y left completes the right-handed set |
| Rotation about z | positive = counter-clockwise from above = turning left | Right-hand rule about +z |
| Outdoor world frame | ENU: x east, y north, z up | Right-handed and z-up, unlike aviation's NED. Robotics chose ENU so ground robots keep z up everywhere |
| Camera optical frames | z forward, x right, y down | Inherited from computer vision, where the image u-axis is right, v is down, and depth is +z. The whole pinhole model assumes it |
| Naming | optical frames get a _optical suffix | The suffix is the warning label. A frame without it is x-forward; with it, z-forward |
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.
| System | Forward | Left | Up | Note |
|---|---|---|---|---|
| ROS / REP-103 body | +x | +y | +z | the reference for everything on the robot |
ROS optical (_optical) | +z | −x | −y | z forward, x right, y down — the vision convention |
| OpenCV / COLMAP camera | +z | −x | −y | identical to ROS optical; this one at least agrees |
| OpenGL / Three.js camera | −z | −x | +y | looks down negative z; y up. Flipping z is the usual bridge |
| Gazebo / MuJoCo / Drake world | +x | +y | +z | z-up, agrees with ROS — a deliberate choice by all three |
| Isaac Sim / USD (default) | +x | +y | +y or +z | USD carries an explicit up-axis token; read it, never assume |
| Unity | +z | −x | +y | left-handed — det = −1 across this boundary if you build the matrix naively |
| Unreal Engine | +x | −y | +z | left-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 |
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.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.REP-105 defines the chain on any mobile robot:
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.
| Frame | Meaning | Continuous? | Drifts? | Published by |
|---|---|---|---|---|
earth | ECEF — origin at the Earth's centre of mass, rotating with the planet | yes | no | a latched static transform, set once per site from a survey. See below: you register in it, you never compute in it |
map | A globally consistent world frame fixed to the environment | NO — it jumps | no | the localizer / SLAM, 5–20 Hz |
odom | An arbitrary origin the robot started from | YES — always smooth | yes, without bound | wheel odometry / VIO, 50–200 Hz |
base_link | Rigidly attached to the chassis, at a defined reference point | — | — | it is the thing being located |
| sensor frames | Rigid or joint-driven offsets from base_link | — | — | robot_state_publisher, from the URDF |
earth — the frame you register in and never compute inMost 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:
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 choice | What it costs you | Use when |
|---|---|---|
| First fix — wherever the robot happened to boot | Zero 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 robot | Prototypes and single-run experiments only |
| Site-surveyed — one fixed lat/lon/alt per facility, in a version-controlled config file | Somebody must survey it and nobody may edit it casually — changing it invalidates every saved map and every recorded goal | The default for any deployed fleet. This is what production systems do |
| Tiled — a grid of local origins with known transforms between tiles | Tile-boundary handling: a robot straddling two tiles needs both, and every algorithm must know which tile a point is in | Sites 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:
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".
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.
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:
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.
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.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.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.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.odom" is a stronger answer than "clamp the velocity".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.
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.
| Edge | Publisher | Rate | Latency budget | Consumers |
|---|---|---|---|---|
map → odom | AMCL, SLAM, or a fusion EKF | 5–20 Hz | up to 200 ms — nobody in the fast loop reads it | global planner, global costmap, goal handling |
odom → base_link | exactly one of: wheel odometry, VIO, or the fused EKF — never two | 50–200 Hz | < 10 ms; this is in the control path | local planner, controller, local costmap, point-cloud deskewing |
base_link → joints | robot_state_publisher, from joint_states | 100–200 Hz | < 5 ms | kinematics, collision checking, sensor placement |
base_link → fixed sensors | static_transform_publisher or the URDF | latched, once | n/a | everything |
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:
| Consumer | Frame | Because |
|---|---|---|
| Velocity / trajectory controller | odom | Needs continuity above all. A discontinuity becomes an impulse through any derivative term |
| Local costmap and local planner | odom | Obstacle memory over a few seconds; a map jump would smear obstacles across the grid |
| Global planner, global costmap | map | Needs to agree with a building-scale map; a 0.35 m step is irrelevant at that scale |
| Goal poses from an operator | map | "Go to the loading dock" is a statement about the world, not about where the robot booted |
| Point-cloud motion compensation (deskew) | odom | Needs the pose at 100 sub-sweep timestamps; only a smooth, high-rate frame can be interpolated |
| Recorded bag for offline replay | store both, plus /tf_static | You 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".
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.
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.
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.
REP-105 assumes one robot and one map. The frontier is what happens when neither is true.
odom origin and its own private map frame. The system has to estimate the inter-robot transforms online from shared observations, decide which candidate alignments to trust, and reject the bad ones — a single false inter-robot loop closure corrupts every robot's map at once. The frame problem stops being bookkeeping and becomes an estimation problem with an outlier-rejection layer (they use graduated non-convexity on the pose graph).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.
rpy.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.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./tf interpolates by default. If you only know the impulse story, you miss the failure that actually ships.np.linalg.det returns −1×10−6. Name both faults and the source system.det is a two-channel diagnostic and not a yes/no check. Say the cube root out loud; it is the part people miss.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.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?odom for exactly this reason.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.
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.
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.
Everything in this chapter comes out of one line. Ignore distortion for a moment and write the horizontal half of the projection:
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:
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:
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):
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:
dropping the θ² term. Multiply by fx:
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.
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:
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:
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:
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.
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:
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.
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
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:
| Fault | Image residual pattern | 3D error vs range | The five-minute test |
|---|---|---|---|
| Focal length fx, fy | Δu = δf·xn — radial ramp, exactly zero at the principal point, sign flips across it | proportional to range, with a factor xn — so zero for a centred target | Put 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 corner | proportional to range, and not killed by centring the target | Nearly 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 corners | the angular error is range-independent, so the metric error still ramps with range — but it vanishes on-axis, like focal | Image 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 point | proportional to range, through the origin — and it survives centring the target | Measure 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 is | constant at all ranges | Same two-range test. Flat line ⇒ translation |
| Stereo baseline B | none — reprojection stays healthy | proportional to range, as a scale factor | Drive a measured distance. Everything short by the same percentage ⇒ baseline |
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:
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:
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.
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.)
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:
| Sensor | Its intrinsics | Its extrinsics | The failure if intrinsics are wrong |
|---|---|---|---|
| Camera | fx, fy, cx, cy, distortion | Tbase←cam_optical | rays leave at the wrong angle — radial, off-axis error |
| Spinning LiDAR | per-beam elevation angle, per-beam range offset and scale, azimuth offset | Tbase←lidar | a flat floor comes back as a set of concentric rings at slightly different heights — "ring artefacts" |
| IMU | 3 gyro biases, 3 accel biases, scale factors, a 3×3 axis-misalignment matrix | Tbase←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 odometry | wheel radius, track width, encoder counts per revolution | Tbase←wheel_centre | a wrong radius scales every distance; a wrong track width scales every rotation — drive a square and it does not close |
| Radar | range and Doppler bin calibration, antenna pattern | Tbase←radar | range bias; Doppler velocity that disagrees with wheel speed by a constant factor |
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.
| Parameter | Changes when | Re-measure | Storage |
|---|---|---|---|
| Intrinsics (K, distortion) | the lens is changed or refocused; slow thermal drift | at manufacture; on any lens service | per-unit YAML, keyed by camera serial, checksummed |
| Thermal focal drift | continuously, ~0.1–1 px per 10°C of focal length | never re-measured; either modelled or budgeted for | a temperature coefficient, if you model it |
| Extrinsics (Tbase←sensor) | a mount is bumped, a screw loosens, the robot is shipped | at manufacture, after any collision, on a monthly field check | same file; and versioned, so you can diff against the factory values |
| Time offset (sensor vs host clock) | on any driver or firmware change | with the extrinsics — and Lesson 2 covers why | same file |
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.
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.
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.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.
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.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.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.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.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.
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.
| Range | Measured 3D error | error ÷ range |
|---|---|---|
| 0.5 m | 1.8 mm | 1.8 / 0.5 = 3.6 mm/m |
| 1.0 m | 3.5 mm | 3.5 / 1.0 = 3.5 mm/m |
| 2.0 m | 7.0 mm | 7.0 / 2.0 = 3.5 mm/m |
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 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.
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".
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.
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.
Five frames, four edges. When a bug is active the offending edge turns red and names itself.
Click or drag anywhere in the scene to move base_link. Then pick a bug and watch the ghost separate.
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.
| # | Edge | The literal, exactly as composed | What it is physically | Published by |
|---|---|---|---|---|
| 1 | odom ← base_link | Tb = 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 |
| 2 | base_link ← torso | Tt = 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 |
| 3 | torso ← shoulder | Ts = 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 |
| 4 | shoulder ← gripper | Ta = 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 |
| 5 | torso ← head_camera | Tc = 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 detection | pcam = (0.35, 0.10) m | The 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:
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.
| Step | Arithmetic | Result (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 later | u = (0.466943, −0.283910) |
| R(−35°)·u | x: 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°)·that | cos 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 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:
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:
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:
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:
Law 3 — wrong inverse. A round trip base → odom → base through an inverse that negated t instead of applying −R⊤t leaves Tb·Tbbad −1, whose rotation cancels to identity and whose translation is the residual:
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:
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:
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 ‖torso. The gripper origin expressed in torso is the u the worked example already computed,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 simplytorso: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.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.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.
| Bug | Error law | Grows with | Blind to | Exactly zero when | At the default pose |
|---|---|---|---|---|---|
| unrotated offset | 2‖ts‖ sin(|ψ|/2) | torso yaw ψ | base pose, base yaw, σ | ψ = 0 | 2(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 robot | 11.31 cm |
| wrong inverse | 2‖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 quaternion | 2‖u‖ | nothing | everything | never (unless the sub-chain has zero offset) | 2(0.54648) = 109.30 cm |
| optical skipped | ‖(0.45, 0.10)‖ | nothing | everything | never (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.
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.
| # | Experiment | What you should see | What it proves |
|---|---|---|---|
| 1 | Select 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. |
| 2 | Same 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. |
| 3 | Select 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. |
| 4 | Select 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. |
| 5 | Select 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. |
| 6 | Select 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. |
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.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.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 at | flipped quaternion (bug 4) | optical skipped (bug 5) | Does it discriminate? |
|---|---|---|---|
error row | 109.30 cm, at every setting | 56.37 cm, at every setting | No. Both constant. This is the row that traps you. |
target offset row | not printed at all | 46.10 cm | Yes, decisively. A row that exists for exactly one fault is a perfect discriminator, and it costs zero robot time. |
| teal chain (the truth) | unchanged | unchanged | No — and it never can. The truth is the truth under either fault; only the software's beliefs move. |
| orange ghost gripper | jumps 1.0930 m, to the reflection of the arm through the waist pivot | sits exactly on the red believed-target square, because the commanded pose is the mis-decoded detection | Yes. 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 square | never moves | never moves | No — a useful negative. Ground truth is not a diagnostic; it is the ruler you measure against. |
| red believed-target square | absent | present, 46.10 cm away, joined by a red dashed line | Yes. Only one fault on this panel produces a second target at all. |
| the red tf-tree edge | base_link → torso | torso → head_camera | Yes, and it names the publisher. Different node, different parameter file, different on-call owner. |
| zero every joint, base to the odom origin | still 109.30 cm | still 56.37 cm | No — 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 repo | the quaternion blend in the orientation publisher | the detector's frame decode / the frame_id it stamps | — This is the whole reason to care. Two constants, two different teams. |
frame_id naming a frame that is not the one the data is in.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.
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.odom (1.300, 0.600) and two poses of this robot: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.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.odom (0.4002, 0.6289) — 90.03 cm from truthodom (1.0370, 0.4099) — 32.45 cm from truthtarget 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.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.
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.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 button | Where it was derived | What was abstract there | What is concrete here |
|---|---|---|---|
| unrotated offset | Chapter 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 compose | Chapter 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 inverse | Chapter 2 (deriving the inverse instead of memorising it) | "the inverse is (R⊤, −R⊤t), 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 sign | Chapter 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 skipped | Chapter 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 canvas | Chapter 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 |
Experiment 2 is the important one, and it generalises into the method for finding a frame bug in a chain of any length.
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:
fk_believed(q) and measure(q) both take a float array of shape (7,) — joint angles in radians, base to tip — and both return a 4×4 SE(3) matrix. The first is what the software thinks; the second is ground truth from whatever external instrument you have (mocap, a laser tracker, a fiducial the arm touches, or a person with a tape measure and patience).norm(T_true[:3,3] − T_believed[:3,3]), in metres. Position only, on purpose: it is the quantity a mis-grasp is measured in, and it is the one an external instrument can actually give you.tol is 2 mm, which is roughly twice the 1 mm-per-axis standard error you get from averaging 25 measurements of a ±1 cm instrument. Pick it from your noise, not from taste.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.
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.
| Signal | True size | Single measurement | Survives? |
|---|---|---|---|
| Experiment 2 — the 14.52 cm bias, invariant across base poses | 14.52 cm | 14.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 residual | 1.00 cm | You 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. |
_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
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.
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.
| Clock | What you do | What you learn |
|---|---|---|
| 0–2 min | Before 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 min | Jog 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 min | Hold 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 min | Predict 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 min | Now 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 min | Fix, 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. |
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.
| Instrument | Where it runs & at what rate | The check, on what data | Cost | Catches / trips at |
|---|---|---|---|---|
| Transform validator | Inside 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: ‖R⊤R − 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 ‖R⊤R − I‖F > 1e−6 — float32 round-trip noise is ~1e−7, so this is 10× margin. |
| Tape-measure assertion on static extrinsics | Startup 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 monitor | In 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 frames | In 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 telemetry | One 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 canary | On 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. |
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.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.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.
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.
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.
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 →
| Concept | The 30-second answer | Key equation | Tool | Classic | Recent (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. | R⊤R = 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⊤, −R⊤t], [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) |
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.map would read 0.35 m / 0.002 s = 175 m/s of apparent velocity, which no robot can do.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.odom; global planner, goal poses and shelf locations in map; point-cloud deskewing in odom because it interpolates./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._optical child with the −90°/0/−90° rotation. Prevents the axis-permutation class, whose errors are 90° multiples rather than small numbers.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.R_BODY_FROM_OPTICAL, ENU/NED conversions and the frame-name string constants. Nobody retypes a rotation matrix.| |ψ| | 2 sin(|ψ|/2) | predicted |e| = 0.080 m × that | linear approximation |ψ|·|t⊥| |
|---|---|---|---|
| 15° | 0.26105 | 2.088 cm | 2.094 cm (0.3% high) |
| 30° | 0.51764 | 4.141 cm | 4.189 cm (1.2% high) |
| 45° | 0.76537 | 6.123 cm | 6.283 cm (2.6% high) |
| 60° | 1.00000 | 8.000 cm | 8.378 cm (4.7% high) |
What to say while writing: "The rotation block inverts by transpose because it is orthogonal — that is what R⊤R = 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: −R⊤t, 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.
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.
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:
| Input | det R | ‖R⊤R − I‖F | singular values | what a 2 m reach becomes |
|---|---|---|---|---|
| 5°, ‖q‖ = 1 (healthy) | 1.00000000 | 3.2 × 10−18 | 1, 1, 1 | 2.0000 m |
| 5°, ‖q‖ = 1.02 | 1.00031989 | 4.52 × 10−4 | 1.00016, 1.00016, 1 | 2.0003 m (+0.3 mm) |
| 90°, ‖q‖ = 1.02 | 1.08406432 | 1.19 × 10−1 | 1.04118, 1.04118, 1 | 2.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.
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.
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:
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.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) | ‖R⊤R − 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.” |
| Question | Classical | Modern | When to use which |
|---|---|---|---|
| Representing a pose | 4×4 matrix, hand-rolled helpers | Lie-group type (Sophus, manif, GTSAM Pose3) with exp/log and the adjoint | Hand-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 estimator | Euler angles in the state vector | Error-state: nominal quaternion on the manifold, 3-vector error in the tangent space | Never Euler in a state vector. The covariance is meaningless near vertical and the Jacobian is singular there |
| Filter formulation | Standard EKF, error defined by subtraction | Invariant 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 network | Regress a quaternion or Euler angles | 6D 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 |
| Calibration | Offline, target-based, done once at manufacture | Online 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 robots | One map, one odom, per REP-105 | Estimated 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 images | Calibrate, then triangulate | Feed-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 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:
| Paper | Why 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:
| Repo | What 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/Sophus | sophus/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/manif | The 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_vins | ov_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/kalibr | The 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. |
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.
"What I cannot create, I do not understand."
— Richard Feynman