Where you are. You can build a rotation in 3D and store it as a quaternion without walking into gimbal lock. This lesson adds the other half of a pose, where a thing sits rather than which way it faces, and packs both halves into one object that composes and inverts by ordinary matrix arithmetic.
Put your phone on the desk at an angle
Lay your phone flat on the desk, turned maybe thirty degrees off square. Drop a coin on the desk a hand’s width from it.
Now say where the coin is, twice.
First along the desk: so far to the right of the desk’s front-left corner, so far back. Then along the phone: so far up the phone’s long edge, so far across its short edge. Two pairs of numbers, one coin, and nothing moved.
Turning the phone’s pair into the desk’s pair is a two-step move. Turn the pair by thirty degrees so it lines up with the desk’s directions, then add on where the phone’s corner sits. Rotate, then translate. That part feels obvious.
Now go the other way. Desk numbers in, phone numbers out. The move your hand reaches for is to run the same two steps backwards: undo the turn, then subtract the corner offset. Both halves are correct operations. That order is not. And the answer it gives is wrong by the same fixed amount for every coin on the desk, which means it never looks like a bug. It looks like somebody mismeasured the desk.
This lesson builds the object that gets both directions right without asking you to remember which step comes first.
The idea in one paragraph
A pose is a rotation together with a translation: which way something faces and where it sits. Carried as two loose variables, poses compose by a formula you have to remember, and re-derive, every time you use it. Carried as one 4 by 4 matrix, with the rotation in the top-left 3 by 3 block, the translation down the last column and the row 0 0 0 1 along the bottom, the same operations collapse into matrix multiplication. Applying a pose to a point is one multiply. Chaining two poses is one multiply. Undoing a pose is a matrix inverse with a two-line closed form. That object is the homogeneous transform, and every robotics library, every simulator, every robot description file and every GPU already speaks it.
The formula nobody remembers correctly
Start with the two-variable form, because you need to feel the problem before the fix means anything.
Frame sits somewhere inside frame . Its axes are turned by a rotation , and its origin sits at offset . A point whose coordinates in are has coordinates in :
In words: turn the point’s numbers until they line up with A’s directions, then shift by where B’s origin sits. Exactly what you did with the coin.
Now chain two of them. A point known in , expressed in , expressed in :
So the composed pair is . Rotations multiply, which nobody gets wrong. Translations, however, do not simply add.
Pad the point with a 1
Here is the fix, and it is one of those small ideas that quietly runs an entire field.
Stack and into a 4 by 4, and stick a 1 onto the end of the point.
Multiply them and read the rows. Each of the first three rows dots one row of with , then adds one entry of multiplied by that appended 1. Together they are exactly . The fourth row computes , which is 1, so the answer comes out padded the same way the input went in.
Wider than the screen; scroll it sideways.
The appended 1 is the whole mechanism. A matrix multiply can only add a quantity it is multiplying by something, so a translation has nowhere to live inside a 3 by 3. Give every point a fourth coordinate that is always 1, and the translation column rides into the sum for free.
Why the bottom row is 0 0 0 1
That row looks like padding. It is doing four jobs.
It keeps the shape. Multiply two of these matrices and the product’s bottom row comes out 0 0 0 1 again, with a valid rotation still in the top-left block. Checked on a random pair: bottom row exactly [0 0 0 1], and the composed rotation block satisfied . That closure is what lets you chain as many poses as you like and still hold a pose at the end.
It keeps the point legal. The fourth row computes the output’s fourth coordinate. With 0 0 0 1 it copies the incoming 1 straight through, so the result is again a padded point you can feed into the next multiply. Any other bottom row would put something else there, and the next multiply would silently scale your point by it.
It is a switch. The fourth coordinate decides whether the translation applies at all. Pad with 1 and you have a place, which gets turned and then shifted. Pad with 0 and you have a direction, which gets turned and nothing else, because every entry of is multiplied by that 0.
Wider than the screen; scroll it sideways.
That distinction matters more than it looks. Run the 90-degree-about-z transform with offset (1, 0, 0) on the x direction and on the point at the same coordinates:
| Padded input | Output | Reading |
|---|---|---|
[1, 0, 0, 0] | [0, 1, 0, 0] | a direction: turned, not moved |
[1, 0, 0, 1] | [1, 1, 0, 1] | a place: turned, then shifted |
Velocities, joint axes, surface normals and camera view directions are all directions. Translate one of them by accident and the direction picks up the transform’s offset, so a unit axis comes back with a length that depends on where the frames happen to be. The smaller the offsets in your test scene, the longer it hides.
A different bottom row is a camera. Nothing forces the bottom row to be 0 0 0 1. Put depth there instead and dividing by the fourth coordinate shrinks distant things: that is perspective projection, and it is what a camera matrix does in Module 4. The 4x4 you are learning is the rigid, non-projective corner of a larger family. The family is what “homogeneous” names, and it is why the word attaches to the coordinates rather than to the transform.
Reading
The subscripts are a contract, and it is worth fixing now because the next lesson leans on it entirely.
means the pose of frame B, expressed in frame A. Equivalently, and more usefully, it is the function that takes B-coordinates and returns A-coordinates. Outer frame first, inner frame second. In code, put it in the name:
cup_C = np.array([0.05, -0.02, 0.55]) # cup, in CAMERA coordinates
cup_B = apply_T(T_BC, cup_C) # cup, in BASE coordinates
cup with no suffix is a bug incubator. The suffix is not decoration, it is the only type system you get.
Undoing it
maps B-coordinates to A-coordinates. You constantly need the other direction, and you do not need a general 4x4 inverse to get it. Solve the forward equation for :
Read the last line as a transform and you can see its two parts: the rotation is and the translation is .
The sitting in front of is not an algebraic flourish. It is the answer to a physical question: where is A’s origin, measured along B’s axes?
Wider than the screen; scroll it sideways.
What it costs
Sixteen numbers to store six numbers of information. That redundancy has to be paid for somewhere, and it is worth knowing the size of the bill.
The closed form above is not more accurate than a general inverse, it is cheaper and it is guaranteed to stay rigid. Over 10,000 random poses, and numpy.linalg.inv agreed to within 2.7e-15, so the reason to write the closed form is not precision. It is that a general solver knows nothing about rotations and can hand you back a matrix that is no longer one, whereas transposing a rotation cannot.
Drift is real and slower than the folklore suggests. Composing a million small transforms in sequence, the rotation block ended up off orthonormal by 2.2e-10, with a determinant of 0.999999999867. Renormalize in a long-running estimator; do not lose sleep over a 6-joint arm.
Review
The appended one is the whole mechanism
A pose is a rotation together with a translation: which way something faces and where it sits. Carried as two loose variables they compose by a formula you have to re-derive every time, and the part everyone drops is that the inner translation must be turned into the outer frame’s directions before it is added. Pack them instead into one four by four, with the rotation in the top-left block, the translation down the last column and zero zero zero one along the bottom, then stick a one on the end of every point. A matrix multiply can only add a quantity it is multiplying by something, so a translation has nowhere to live inside a three by three. Give every point a fourth coordinate that is always one, and the translation column rides into the sum for free. The algorithm now lives in the data structure.
One for a place, zero for a direction
The fourth coordinate is a switch. Pad with one and you have a place, which gets turned and then shifted. Pad with zero and you have a direction, which gets turned and nothing else, because every entry of the translation is multiplied by that zero. Velocities, joint axes, surface normals and camera viewing directions are all directions. Translate one by accident and it picks up the transform’s offset, so a unit axis comes back with a length that depends on where the frames happen to be, and the smaller the offsets in your test scene, the longer it hides. The bottom row does the rest of the work: it keeps the product’s shape closed, so chaining any number of poses still leaves a pose, and it copies the incoming one straight through, so the answer is again a legal point.
Why the inverse translation is minus R transpose t
Solve the forward equation for the point in the inner frame and the inverse falls out: its rotation is R transpose, and its translation is minus R transpose t. Forwards you turn the point and then shift it; backwards you un-shift and then un-turn, and squashed into one matrix the un-shift ends up expressed in the frame you are arriving in rather than the one you left. The near miss is to write minus t, and it is wrong in the most survivable way possible. The error is a constant offset of the entire world, the same vector for every point, which reads as bad calibration rather than as broken code. Worse, it vanishes under the two tests you are most likely to write: with an identity rotation, and with the translation lying along the rotation axis, the wrong inverse is exactly right. Test it with a translation off the axis, or you will ship it.
Check yourself
1. Why can a 3x3 matrix not represent a translation, and what does the appended 1 change?
A matrix multiply produces sums of products of the input’s entries. With a 3-vector in, every term in the output is proportional to some input coordinate, so the origin always maps to the origin and there is no constant to add. Appending a fourth coordinate that is always 1 gives the matrix a term to multiply by 1, and that term is the translation column. The translation is not smuggled in beside the multiply, it is a genuine part of it.
2. You compose two poses by multiplying their 4x4s. Where in the product does the “rotate the inner translation before adding it” step happen, and why do you never have to remember it?
It happens in the top-right column of the product. Multiplying by gives a translation column of , because the last column of the right-hand matrix is dotted with the rows of the left-hand one and then picks up from the appended 1. You never have to remember it because the layout performs it. This is the point of the packaging: the algorithm lives in the data structure.
3. A pose has a 30-degree rotation about z and a translation of (0.4, 0, 0) m. A colleague inverts it as [R.T, -t]. How far off is the result, and how would you have caught it?
Off by the constant vector , which here is (-0.054, -0.200, 0), a distance of 0.207 m. The same offset for every point, so the whole scene is shifted rather than distorted, and 20 cm is easily large enough to miss every grasp and small enough to look like a mounting-bracket measurement error. You catch it with a round-trip property test: send randomised points forward and back and assert you get the original point, using a translation that is not parallel to the rotation axis. With identity rotation, or with the translation lying along the rotation axis, the wrong inverse is exactly right and the test passes while the bug survives.
4. When would you pad a 3-vector with 0 instead of 1, and what breaks if you get it backwards?
Pad with 0 when the vector is a direction rather than a place: a velocity, a joint axis, a surface normal, a camera’s viewing direction. A direction has no location, so translating it is meaningless. Pad a direction with 1 by mistake and it picks up the transform’s translation, so your “unit z axis” comes out with a length that depends on where the robot is standing. Pad a place with 0 and it loses the translation, so everything behaves as though the frames all shared an origin, which looks correct near the origin and wrong everywhere else.
5. The bottom row is always 0 0 0 1 here. What is it in a camera matrix, and what does that change?
In a perspective camera the bottom row is not constant: it copies (a multiple of) the point’s depth into the fourth coordinate. The result then has other than 1, and you divide the first three coordinates by it. That division is what makes distant objects small. Rigid transforms are the special case where stays 1, so no division is needed and lengths and angles survive. Same 4x4 machinery, one row of difference.
6. Why prefer the closed-form inverse over a general 4x4 inverse if they agree numerically?
Cost and closure. The closed form is a transpose and one matrix-vector product, with no factorization. More importantly a general solver treats the matrix as sixteen unrelated numbers, so nothing in it guarantees the answer is still a rotation plus a translation; the transpose of a rotation is a rotation by construction. Over 10,000 random poses the two agreed to 2.7e-15, so accuracy is not the argument. Staying inside the set of valid poses is.
Do this
Open code/transforms_starter.py. This file becomes your transform library and every later lesson in the module imports it, so it is worth doing properly.
Fill in the three functions this lesson earned: make_T(Rm, t), apply_T(T, p) and inv_T(T). Leave compose and the quaternion helpers alone for now, they land in the next lesson. Write inv_T from the closed form and resist the urge to call np.linalg.inv.
Then run this from the code/ directory. It is the worked example from the gotcha, and the comments are the answers:
import numpy as np
from scipy.spatial.transform import Rotation
from transforms_starter import make_T, apply_T, inv_T
T = make_T(Rotation.from_euler("z", 90, degrees=True).as_matrix(), [1, 0, 0])
p_B = np.array([0.2, 0.3, 0.0])
p_A = apply_T(T, p_B)
print("forward ", p_A) # [0.7 0.2 0. ]
print("back again ", apply_T(inv_T(T), p_A)) # [0.2 0.3 0. ]
wrong = make_T(T[:3, :3].T, -T[:3, 3]) # the -t version
print("wrong inverse", apply_T(wrong, p_A)) # [-0.8 -0.7 0. ]
for q in np.random.default_rng(0).uniform(-3, 3, (4, 3)):
print("error", apply_T(wrong, q) - apply_T(inv_T(T), q)) # [-1. -1. 0.] four times
Two things to notice before moving on. The last loop prints the same error four times, because the wrong inverse shifts the whole world by a constant. And if you swap the transform for one with no rotation, or with its translation along z, the wrong inverse stops being wrong. That is the shape of a bug that reaches production.
What you can now do
You can pack a rotation and a translation into a single 4x4, apply it to a point by padding the point with a 1, and say precisely what the bottom row is for: closure under multiplication, keeping the output a legal point, and switching translation on for places and off for directions. You can write the closed-form inverse from memory, explain why its translation is rather than in terms of which frame the offset is measured in, and describe the two test setups in which that bug hides.