Coordinate frames and rigid transforms
Every 3D number in your pipeline is expressed in some coordinate frame, and the frame is stored nowhere in the array. This lesson builds the notation, the SO(3)/SE(3) math, and the numerical checks that make frame bugs visible before they reach the motors.
- Name every frame in a WidowX AI + RealSense manipulation cell and draw its transform tree with tf2-style parent/child names.
- Choose among rotation matrices, quaternions, axis-angle, and Euler angles for a given job, and state each representation's failure mode.
- Derive the closed-form inverse of a homogeneous transform and compose multi-frame chains without inversion mistakes.
- Verify any transform chain numerically with orthonormality, round-trip, and physical-plausibility checks before trusting it in a pipeline.
You have spent a career making sure tensors have the right shape, dtype, and device before a kernel touches them. Robotics has an equivalent invariant, and nothing enforces it: every 3D quantity in your stack — a grasp point, a joint axis, a velocity command — is a handful of numbers expressed in some coordinate frame, and the frame is recorded nowhere in the array. The array [0.42, -0.03, 0.11] in the camera frame and the identical array in the arm base frame are different physical points roughly 60 cm apart, and numpy will happily add them together. This lesson gives you the discipline — a naming convention, the underlying group theory, and cheap numerical checks — that turns silent frame corruption into loud, early failure.
Foundations: Frames, Poses, and the Geometry of Rotation
Before manipulating matrices, we must rigorously define the objects they represent. A Pose is the complete state of a rigid body in space, consisting of a position vector and an orientation . A Transform is the mathematical operator that maps coordinates from frame to frame . While often used interchangeably, the distinction is critical: is the pose of frame expressed in frame . The translation column of is the position of 's origin in , and the rotation block is the orientation of 's axes in .
All frames in this course are right-handed. This means the cross product of the first two axes yields the third: . This convention ensures that rotation matrices have a determinant of . The Robot Convention (used for arm links) typically aligns forward, left, and up. The Optical Convention (used for cameras) aligns forward along the optical axis, right, and down. Note that the optical frame is often left-handed in image pixel space, but the 3D camera body frame remains right-handed. Confusing these conventions is the primary source of sign errors in extrinsic calibration.
To understand how rotation matrices are constructed, let frame be oriented by a positive right-hand-rule rotation about the axis relative to frame . We derive by expressing 's basis axes in coordinates. Positive rotation about moves toward , so its coordinates are ; it moves toward , giving . The axis is unchanged. Placing those three axes as columns yields the standard form. Calling this matrix 'active' or 'passive' without naming the source and destination frames invites a sign mistake, so this course always states the mapping instead: .
The set of all such rotation matrices forms the Lie Group . Its associated Lie Algebra is the tangent space at the identity, consisting of all skew-symmetric matrices where . For any vector , the skew-symmetric matrix is defined such that for any vector . The exponential map connects the algebra to the group: . This is the rigorous bridge between axis-angle vectors (which live in the algebra) and rotation matrices (which live in the group).
A critical numerical issue is drift. After many matrix multiplications, floating-point errors cause the rotation block to deviate from orthogonality (). This drift is not just a theoretical concern; it accumulates linearly with the number of operations. To restore validity, we project back onto using the Singular Value Decomposition (SVD). If , the closest orthogonal matrix is . This step is mandatory in any long-running kinematics chain.
Worked Example: Deriving and Inverting a Rigid Transform
Let us compute the transform for a camera mounted with a pitch and a translation meters. First, we construct . Since , the rotation matrix is:
The full homogeneous transform is . To find the inverse , we use the derived formula . The key step is computing . We perform the matrix-vector multiplication :
Thus, . We verify this by checking that . The top-left block is . The top-right block is . The bottom row is trivially . This explicit block multiplication confirms that the inverse correctly undoes both the rotation and the translation, a step often skipped in favor of generic np.linalg.inv which is slower and less numerically stable for rigid bodies.
Why is the inverse of a rotation matrix equal to its transpose , but the inverse of a homogeneous transform is not simply ?
The frame zoo: why every robotics bug is secretly a frames bug
Walk around the smallest useful manipulation cell — a WidowX AI arm bolted to a table, a RealSense-class RGB-D camera on a rigid mount, one object to pick up — and count coordinate frames:
- world / table — the fixed reference everything hangs from; often coincident with the arm base until a second robot or moving cart earns it a name of its own.
- base_link — the arm's root frame beneath the base yaw joint (J0). Cartesian goals, workspace limits, and most dataset action conventions live here.
- one link frame per joint — the URDF of the 6-DOF WidowX AI defines a chain of link frames (shoulder, upper arm, forearm, wrist segments, gripper carriage), whose mutual transforms are functions of the joint angles from the previous lesson and change every 2 ms controller cycle.
- end-effector / gripper frame — between the fingertips; grasp poses, tool offsets, and delta-action commands are expressed here or relative to here.
- camera mount and camera body — where the bracket holds the camera, and the camera's own body frame millimetres away.
- camera optical frames — one per sensor stream: color, depth, each infrared imager. On a D435-class device the color sensor sits roughly 15 mm from the depth origin; ignore that and every fused point is biased by 15 mm.
- object / task frame — attached to the thing being manipulated, estimated by perception, moving whenever the object does.
That is roughly fifteen frames for a one-camera cell; the WidowX AI's wrist-mounted D405 pushes it near twenty. Every arrow between frames is a rigid transform, and nearly every classic manipulation failure is one arrow being wrong: grasps that consistently miss 3 cm left (a stale camera extrinsic), a fine-tuned policy that mirrors its motions (actions logged in the wrong frame), a depth cloud where the table stands up like a wall (a missing optical-frame rotation). The folk theorem — every robotics bug is a frames bug until proven otherwise — survives because frame errors produce plausible geometry, not crashes. This lesson's job is to take away the plausibility.
Rotations: four ways to write SO(3), and what each one costs
Orientation is where the representation trouble concentrates, so start there. The rigid rotations of 3-space form the group — special orthogonal: linear maps preserving lengths and angles (orthogonal) and handedness (special: determinant , not the of a mirror).
Rotation matrices are the workhorse: composition is matmul, applying to a point is a matvec, no singularities anywhere, and orthogonality hands you the inverse for free: . Two costs: 9 floats for 3 degrees of freedom, and after thousands of composed multiplications, floating-point drift walks the product off the manifold — columns stop being unit-length and orthogonal — so you must project back (SVD or Gram–Schmidt). And never forget that composition does not commute: rotate a book 90° about its spine then 90° about the vertical axis, then swap the order, and you get visibly different results. Order is physical, not notational.
Axis-angle exploits Euler's rotation theorem: every rotation is a single turn by angle about some unit axis , packed as the rotation vector — minimal, 3 floats, no constraint to maintain. It is the natural language for small rotations — orientation errors, angular velocities, the residuals a controller or learned policy outputs — because near the identity it behaves almost linearly. (Formally it is the tangent space of , connected to the matrix form by the exponential map; Phase 02's kinematics meets that machinery properly.) Its weaknesses: no closed-form composition — convert, multiply, convert back — and the axis becomes ambiguous as .
Unit quaternions are why your middleware messages have four fields. A quaternion packs the same axis-angle data as : four floats, one constraint (). Composition is a 16-multiply product, drift repair is a trivial renormalization (compare re-orthonormalizing a matrix), and interpolation has a clean constant-speed answer (slerp) — which is why ROS and every robot logging format standardize on quaternions for storage and transport. But the representation has one deep quirk you must internalize:
The half-angle inside the formula is the culprit: the quaternion completes only half a cycle while the physical rotation completes a full one, so the 3-sphere of unit quaternions maps 2-to-1 onto . Consequences are everywhere. Orientation distance must be sign-aware — use the angle . Slerp must flip one endpoint's sign when or it takes the long way around. Componentwise averaging across a log silently mixes hemispheres. And training a network to regress quaternions from a dataset containing both signs gives it two contradictory labels for one orientation — one reason modern pose estimators prefer rotation-matrix or 6D outputs. One shallower but expensive trap: SciPy and ROS store quaternions scalar-last while Eigen's constructor takes the scalar first. A swapped is a large, valid-looking rotation.
You log gripper quaternions at 30 Hz and smooth them by averaging each component over a 5-frame window, then renormalizing. Occasionally the smoothed orientation snaps by nearly 180° for a frame or two. Most likely cause?
| Representation | Storage / DOF | Composition | Interpolation | Failure mode |
|---|---|---|---|---|
| Rotation matrix | 9 floats / 3 | matmul; cheapest to apply to points | no direct path; detour via axis-angle | drifts off the manifold under repeated composition; needs re-orthonormalization |
| Unit quaternion | 4 floats / 3 | quaternion product (16 mults) | slerp, clean and constant-speed | double cover: q and −q identical; sign bugs in distances, averages, learned targets |
| Axis-angle / rotation vector | 3 floats / 3 | no closed form; convert first | scale the vector (valid near identity) | axis ambiguous at θ = π; nonlinear far from identity |
| Euler angles | 3 floats / 3 | never compose directly | do not — paths can sweep through gimbal lock | 24 axis conventions, intrinsic/extrinsic confusion, degree/radian confusion, gimbal lock |
SE(3): the 4×4 matrix that carries rotation and translation together
A general rigid motion rotates and then translates: . That affine form composes awkwardly — chain two and the translations tangle with the rotations. The homogeneous-coordinates trick appends a 1 to every point and packs the motion into one 4×4 matrix, making composition pure matmul. The set of all such matrices is , the special Euclidean group — the configuration space of every rigid body in your cell.
Composition is now matrix multiplication, applied right to left like function composition: applies first, and non-commutativity carries over with translations joining the tangle. The inverse is the one formula worth deriving rather than memorizing, because the naive guess is wrong in exactly the plausible-garbage way this lesson is about. Set up the unknown inverse in block form and demand that the product be the identity:
Read off the two block equations. The rotation block gives , so — orthogonality hands you the inverse rotation as a transpose, no linear solve required. The translation block gives , so . Note what is not: it is not ; the old translation must be rotated into the new frame before negation.
One more distinction the 4×4 form encodes elegantly: points versus vectors. A point (a grasp location) has a definite place, so translation applies — homogeneous coordinate 1. A vector (a ray direction, an angular velocity, a surface normal) has magnitude and direction but no place, so translation must not apply — homogeneous coordinate 0:
The transform tree: names that make inversion bugs impossible
Now the bookkeeping convention everything else in this course rests on. Write every transform as T_parent_child, defined to map coordinates from the child frame into the parent frame: if is a point expressed in frame , then . Equivalently, is the pose of frame B as seen from A — its translation column is B's origin in A-coordinates, its rotation columns are B's axes in A-coordinates. This is exactly the semantics of ROS tf2, where frames are strings, every frame has exactly one parent, and the cell forms a tree: no cycles, so exactly one chain of transforms connects any two frames.
The tree has two kinds of edges. Static transforms — camera mount to body, body to each optical frame — are fixed by machining, CAD, or calibration; publish them once. Dynamic transforms — the arm's link-to-link chain — are recomputed from the joint encoders at the controller's update rate (500 Hz on the WidowX AI); composing base-to-gripper through six joint-dependent links is forward kinematics, which Phase 02 treats properly. The consequence for your logging: a transform is only valid together with a timestamp, because on a moving arm is a function of time. tf2 buffers transforms with timestamps and interpolates on lookup for exactly this reason — the next lesson is about what those timestamps actually mean.
Calibration gives you T_base_cam (the camera optical frame posed in the arm base frame). Perception reports a grasp point p_cam in the optical frame. Which expression yields the grasp point in the base frame?
Trust nothing: verify every chain numerically
Frames discipline is a habit, and habits need enforcement. Three checks cost microseconds and catch most transform bugs before they touch hardware: (1) rotation validity — to tight tolerance and , catching transposition-instead-of-inversion, drift, and accidental mirrors; (2) round trip — a chain composed with its inverse must reproduce the input to near machine precision; (3) physical plausibility — transform a point whose answer you know from a tape measure. Here is the full pattern on a realistic three-frame chain: arm base, a camera mounted 15 cm behind the base and 40 cm up, pitched 40° down over the workspace, and an object seen 55 cm away along the optical axis.
import numpy as np
def rot_y(theta_rad):
c, s = np.cos(theta_rad), np.sin(theta_rad)
return np.array([[c, 0.0, s],
[0.0, 1.0, 0.0],
[-s, 0.0, c]])
def make_T(R, t):
T = np.eye(4)
T[:3, :3] = R
T[:3, 3] = t
return T
def inv_T(T):
# The closed form we derived: [R^T, -R^T t], NOT [R^T, -t].
R, t = T[:3, :3], T[:3, 3]
return make_T(R.T, -R.T @ t)
def check_rotation(R, name, atol=1e-9):
ortho = np.allclose(R.T @ R, np.eye(3), atol=atol)
proper = abs(np.linalg.det(R) - 1.0) < atol
if not (ortho and proper):
raise ValueError(name + " is not a valid rotation")
# Frame 1: camera mount posed in the arm base frame.
# 150 mm behind the base origin, 400 mm up, pitched 40 deg down.
T_base_mount = make_T(rot_y(np.deg2rad(40.0)),
np.array([-0.150, 0.0, 0.400]))
# Frame 2: optical frame posed in the mount (body) frame.
# Body convention (x fwd, y left, z up) -> optical convention
# (z fwd, x right, y down), plus the color sensor's 15 mm offset.
R_mount_opt = np.array([[0.0, 0.0, 1.0],
[-1.0, 0.0, 0.0],
[0.0, -1.0, 0.0]])
T_mount_opt = make_T(R_mount_opt, np.array([0.0, -0.015, 0.0]))
# Frame 3: perception reports the object in the optical frame:
# 20 mm right of image center, 50 mm below, 550 mm along the axis.
p_opt = np.array([0.020, 0.050, 0.550, 1.0]) # homogeneous point
# Compose. Read it out loud: base-from-mount, mount-from-optical.
T_base_opt = T_base_mount @ T_mount_opt
# Check 1: still a proper rotation after composition.
check_rotation(T_base_opt[:3, :3], "R_base_opt")
# Check 2: round trip to machine precision, and the closed-form
# inverse must agree with the generic solver.
assert np.allclose(inv_T(T_base_opt) @ T_base_opt, np.eye(4), atol=1e-12)
assert np.allclose(inv_T(T_base_opt), np.linalg.inv(T_base_opt), atol=1e-12)
# Check 3: physical plausibility.
p_base = T_base_opt @ p_opt
print("object in base frame [m]:", np.round(p_base[:3], 4))
# -> [ 0.2392 -0.035 0.0082]
# 24 cm ahead of the base, 3.5 cm right, 8 mm above the table
# plane -- reachable (WidowX AI reach ~0.77 m) and ON the table.
# A direction transforms with homogeneous coordinate 0:
table_normal_opt = inv_T(T_base_opt) @ np.array([0.0, 0.0, 1.0, 0.0])
assert abs(np.linalg.norm(table_normal_opt[:3]) - 1.0) < 1e-12The plausibility check is doing real work in that printout: the object lands at mm, essentially on the table plane, where objects live. Now make the classic mistake and use inv_T(T_base_opt) @ p_opt instead: the result, about , is 36 cm from the truth yet also near table height and also inside the workspace. That is the treachery of inversion bugs: they do not always look absurd. So plausibility alone is not enough — set an object at a taped mark and demand the chain reproduce that ground-truth point to within a centimetre.
Also know how sensitive the chain is — that number sets Phase 02's calibration requirements. Rotation errors are amplified by lever arm: a small angular error in the camera's orientation displaces a point at distance by approximately
This is where the lesson becomes the course's load-bearing wall. Phase 02's camera calibration is nothing but estimating T_base_cam from data — this lesson tells you what that matrix means and how to check the estimate. And in Phases 03–04, every dataset and policy defines an action space in some frame, typically delta end-effector poses in the arm base frame. Fine-tuning a π₀-class model from the OpenPI stack (opens in a new tab) on your own WidowX AI means writing, by hand, the transform chain from the policy's output convention to your joint commands, and from your logged observations back into the dataset's convention. Get one link wrong and you train on a lie, with losses that converge beautifully on data that does not describe your robot.
Model your cell, then break it on purpose
With a tape measure and your actual (or planned) camera mount, build your own cell's three-frame chain in numpy: base to mount (measured offsets and tilt), mount to optical (the fixed convention rotation), and one object point you can verify against a taped mark on the table. Then: (1) verify the chain with orthonormality, round-trip, and tape-measure checks; (2) inject a 2° pitch error into the mount rotation and report the object's displacement in millimetres against the small-angle prediction; (3) deliberately replace T_base_opt with its inverse and write two sentences on whether the corrupted output would survive a plausibility-only review.
Need a hint?
Work in metres and radians; convert at the boundaries with np.deg2rad. For experiment 2, compose the error as T_base_mount @ make_T(rot_y(np.deg2rad(2.0)), zero) so the perturbation happens in the mount frame, then compare against epsilon = theta times r with r = camera-to-object distance. If your unperturbed z is not within a couple of centimetres of the table plane, debug that first — the usual cause is pitching the camera up instead of down (sign of the angle).
Where this goes next: the previous lesson, Inside the arm: actuators, encoders, and control modes, gave you joint angles as trustworthy numbers; this lesson turned those numbers into geometry — a tree of frames where every point, pose, and action has a well-defined home. But a transform is only correct at the instant it was measured: on a moving arm, is sampled by encoders, shipped over the network, and consumed milliseconds later. The next lesson, Time is a sensor: clocks, timestamps, and latency, gives every transform its fourth coordinate — together they define the episode logger you will build in Lab 0.