Robotics Engineering · Lesson 8 of 26

Drift, Loop Closure
& Relocalization

The map looks perfect for two aisles, then quietly shears — and only a loop closure can pull it back. Here is where the error comes from, which part is fixable, and how to close a loop without trusting a lie.

Prerequisites: a pose graph is nodes + edges + the idea of least squares. The rest is built here.
8
Chapters
9
Interactive Sims
3
Code Labs

Chapter 0: The Shear

A warehouse robot rolls out of its dock and starts mapping. The first two aisles are gorgeous. Every shelf edge lands where the previous pass put it, the pallet racks line up, the floor markings agree with themselves. You could ship this.

Then, somewhere past the third aisle, the map starts to shear. Not break — shear. The far end of the warehouse leans a few degrees off.

By the back wall the shelves sit a full metre from where they should be, and worse, they are doubled: the racks the robot saw on the way out and the racks it saw on the way back are drawn as two separate walls a metre apart.

Nothing crashed. No sensor dropped out. No exception was thrown. The pipeline is, by every log it keeps, perfectly healthy. That is the unsettling part — the failure is silent, and the code cannot feel it happening.

The estimator is quietly lying, and it does not know it. Every metre it drives, it commits a tiny, honest error — a fraction of a degree of heading, a couple of centimetres of range — and integrates it into the pose. Those errors do not cancel. They compound. That slow, invisible accumulation is drift, and it is the central disease of every odometry system ever built.

Put a number on it. Suppose the robot's yaw estimate carries a modest systematic bias of 0.2° per metre driven — the kind of thing a slightly miscalibrated wheel radius or a warm-drifting gyro produces. That is invisibly small. But heading error accumulates, and lateral position is the integral of heading:

y(L) = ∫0L sin(b·s) ds = (1 − cos(b·L)) / b,   b = 0.2°/m = 0.00349 rad/m

That closed form is not handed down — it falls out of two short integrations, and it is worth seeing both, because the structure of those two nested integrals is the whole reason drift behaves the way it does.

Step one: heading is the integral of turn-rate. The bias b is a constant yaw error per metre driven, so after arclength s the heading error is the running sum of b over every metre so far:

θ(s) = ∫0s b ds' = b·s

Step two: lateral position is the integral of heading. Moving forward at unit speed while pointed θ off the true axis, the sideways rate is sin θ (for small angles, nearly θ itself). Integrate that from 0 to L, substituting θ(s) = b·s:

y(L) = ∫0L sin(θ(s)) ds = ∫0L sin(b·s) ds

The antiderivative of sin(b·s) is −cos(b·s)/b. Evaluate at the two limits and subtract:

y(L) = [ −cos(b·s)/b ]0L = −cos(bL)/b − (−cos(0)/b) = (1 − cos(bL)) / b

Two integrations, no magic. Heading is the integral of the per-metre bias; position is the integral of heading.

Because the second integral has the first integral inside it, the error is doubly accumulated — that nesting is the whole reason drift outruns distance.

Keep that nesting in mind: it is the single structural fact that makes drift dangerous rather than merely annoying. A one-off measurement error stays one error; a biased measurement feeds a running integral, and the integral of a running integral grows faster than the thing you are driving through. Now put numbers through it.

Do not just quote the answers — grind two of them out so the arithmetic is on the table, because a number you have computed by hand is a number you will not misremember under pressure.

Take L = 40 m. First the heading error at the end is simply the bias times the distance, bL:

bL = 0.00349 × 40 = 0.1396 rad = 8.0°

Now the lateral drift. Feed bL through the cosine, subtract from one, divide by b:

cos(0.1396) = 0.99027  →  1 − 0.99027 = 0.00973  →  y = 0.00973 / 0.00349 = 2.79 m

One more, at L = 60 m, so you can see the super-linear jump with your own eyes — distance grew by half, drift more than doubled:

bL = 0.00349 × 60 = 0.2094 rad = 12.0°
cos(0.2094) = 0.97815  →  1 − 0.97815 = 0.02185  →  y = 0.02185 / 0.00349 = 6.26 m

Read the ratio directly off those two answers: driving 1.5× as far (40 m → 60 m) turned 2.79 m of drift into 6.26 m — a factor of 2.24, almost exactly 1.5² = 2.25. That is the quadratic law caught red-handed: y scales with the square of distance, so a 50% longer path costs you 125% more error.

The other two rows are the same three steps; here is the whole ladder:

Distance driven LHeading error bL1 − cos(bL)Lateral drift y(L)
10 m2.0° (0.0349 rad)0.000610.18 m
20 m4.0° (0.0698 rad)0.002440.70 m
40 m8.0° (0.1396 rad)0.009732.79 m
60 m12.0° (0.2094 rad)0.021856.26 m

Where do the shelves first cross the one-metre error line? Set y(L) = 1 and solve for L instead of guessing. Multiply both sides by b, so 1 − cos(bL) = b, and isolate the cosine:

cos(bL) = 1 − b = 1 − 0.00349 = 0.99651
bL = arccos(0.99651) = 0.0836 rad  →  L = 0.0836 / 0.00349 = 23.9 m

So the shelves cross the one-metre line at L ≈ 24 m — barely three aisles in.

The error is not linear in distance; because heading feeds position, it grows super-linearly. Doubling the distance from 20 m to 40 m did not double the drift — it took it from 0.70 m to 2.79 m, very nearly a factor of four, the signature of a quadratic.

Two aisles look perfect precisely because 0.18 m is below the shelf pitch, comfortably invisible to the eye. The far wall looks wrong because 2.79 m is not — and there was no moment where anything "broke". The map degraded smoothly, which is exactly what makes drift so easy to ship past QA.

The same disease, measured in time: an IMU gyro bias

The warehouse case ties drift to distance because a miscalibrated wheel radius errs per metre. But the identical mechanism appears in a sensor that does not care about distance at all — an inertial measurement unit.

A gyroscope's bias is a small constant offset it reports even when perfectly still: it insists the robot is turning at, say, 0.01° per second when it is not. Cheap MEMS gyros drift there after they warm up.

The consequence is that heading error now grows with the clock, not the odometer. A robot that sits still for a minute, wheels locked, still accumulates heading error — something wheel odometry, which reads zero when nothing moves, can never do. That difference is why the two sources fail in complementary ways, which Chapter 1 turns into the argument for fusing them.

Same two integrations, one variable swapped — time t for arclength, angular bias ω = 0.01°/s = 0.000175 rad/s for b. Let the robot roll forward at a steady v = 1 m/s.

Heading is θ(t) = ω·t, and lateral drift is speed times the integral of sin θ — the only new factor is the v out front, because now position advances at v metres per second rather than one metre per metre:

y(t) = v ∫0t sin(ω·t') dt' = v·(1 − cos(ω·t)) / ω

Grind the two-minute mark by hand, exactly as we did for the wheel case — the recipe is the same three steps, only the accumulating variable is seconds now.

Heading first: ωt = 0.000175 × 120 = 0.02094 rad = 1.2°. Then feed that through the cosine and scale by v for the lateral drift:

cos(0.02094) = 0.99978  →  1 − 0.99978 = 0.000219  →  y = 1 × 0.000219 / 0.000175 = 1.26 m

Two minutes of standing-still-level bias, and a slow-moving robot is already off by more than a metre — and remember, the gyro reports this bias whether the robot moves or not.

Push the clock out further and the numbers stop being cute:

Time standing / rollingHeading error ωtLateral drift y(t)
1 min (60 s)0.60°0.31 m
2 min (120 s)1.20°1.26 m
5 min (300 s)3.00°7.85 m
10 min (600 s)6.00°31.39 m

Two things to read off this table. First, at these small angles y ≈ ½ v ω t² — drift grows with the square of time, which is why the 10-minute figure (31 m) is roughly four times the 5-minute figure (7.9 m), not double.

Second, the crossing time for y = 1 m is t = arccos(1 − ω/v)/ω ≈ 107 s — under two minutes.

A pure-inertial system is untrustworthy in seconds, which is exactly why an IMU is never used alone; Chapter 1 makes precise which of its errors a loop closure can and cannot undo.

The same disease, measured per frame: a scan-matching rotational bias

One more costume for the same integral, because it is the source a perception engineer meets most often. A camera or LiDAR SLAM frontend does not integrate wheels or a clock — it estimates the motion between successive keyframes by aligning one scan (or image) to the next.

That alignment is never perfect. If the scan-matcher systematically under-rotates each frame — because the point cloud is slightly sparse on one side, or the feature matches are biased, or the motion model over-damps rotation — then every keyframe contributes a small constant rotational bias. Call it δθ = 0.05° per frame, and suppose the robot lays down a keyframe every d = 0.5 m of travel.

The mechanism is identical; only the accumulating variable changed again — now it is the frame index. Heading after N frames is the running sum of the per-frame bias, and distance is L = N·d, so the per-frame bias converts to an effective per-metre bias beff = δθ/d:

beff = δθ / d = 0.000873 rad / 0.5 m = 0.001745 rad/m = 0.1°/m

That is a clean sanity check: 0.05° every half-metre is 0.1° per metre — exactly half the wheel bias we started with, so the scan-matcher here drifts at half the wheel-odometry rate. From there the same y(L) = (1 − cos(beffL))/beff law applies untouched. Grind the L = 40 m row by hand so the arithmetic is on the table. First count frames, then heading:

N = L/d = 40/0.5 = 80 frames  →  θ = N·δθ = 80 × 0.000873 = 0.06981 rad = 4.0°

That 0.06981 rad is exactly beffL = 0.001745 × 40, as it must be. Now push it through the cosine:

cos(0.06981) = 0.99756  →  1 − 0.99756 = 0.00244  →  y = 0.00244 / 0.001745 = 1.40 m

Forty metres of a half-degree-per-half-metre alignment bias, and the far scan is 1.4 m off — the shelves double just as surely as with wheels or a gyro. The whole ladder:

Distance LFrames N = L/0.5Heading beffLLateral drift y(L)
10 m201.0° (0.0175 rad)0.09 m
20 m402.0° (0.0349 rad)0.35 m
40 m804.0° (0.0698 rad)1.40 m
60 m1206.0° (0.1047 rad)3.14 m

Solve for the one-metre crossing the same way — cos(beffL) = 1 − beff = 0.99826, beffL = 0.0591 rad, so L = 0.0591/0.001745 = 33.9 m. Because the scan-matcher drifts at half the wheel rate, it buys you 34 m before the shelves cross a metre instead of 24 m — and since y ≈ ½ beff, halving the bias stretches the safe distance by √2, not by two, which is exactly the L ∝ 1/√b scaling the wheel case predicted.

Distance-drift, time-drift, and per-frame-drift are the same integral wearing three sets of clothes. Wheel bias accumulates per metre; gyro bias accumulates per second; scan-matcher bias accumulates per keyframe. Each accumulating variable converts to an effective per-metre bias (ω/v for the gyro, δθ/d for the scan-matcher), and then the one law y = (1 − cos)/rate governs all three — heading is the first integral of a constant bias, position the second integral of heading, so lateral error is quadratic in whatever is accumulating. Recognise the (1 − cos)/rate signature and you can size the drift of any dead-reckoning sensor on the back of a napkin.
Watch the drift compound

The green ring is where the robot actually drove — a clean loop through the warehouse. The warm path is what odometry reports, with a per-metre heading bias you control. Small bias, long path: the map shears. Push the slider and watch the far side peel away, then double back onto the start as a gap.

heading bias 0.09 rad/edge
end-to-start gap: —

Before you touch the slider, predict the readout. The sim drives an eight-node ring where each edge over-turns by the slider's bias (rad/edge) and also over-shoots its length by 2%.

The "gap" it prints is how far the drifted end lands from the start, scaled so the ring radius is 2 m — a direct read of how badly the loop failed to close. Here is what three slider positions produce:

Slider (rad/edge)What the ring doesEnd-to-start gap
0.000No heading bias — but each edge is still 2% too long, so the ring spirals slightly open1.56 m
0.110The heading curl happens to bend the open end right back onto the start — a coincidental near-closure0.03 m
0.180Too much curl: the end swings past the start and the ring reopens on the far side0.79 m

Notice the gap is not monotonic in the bias — it falls to almost nothing near 0.11 and climbs again.

That is the sharp lesson hiding in this hook: a small end-to-start gap does not prove the map is good. Here a badly-drifted ring accidentally lines its seam back up, and a naive "the loop closed, so we're fine" check would be fooled into accepting a map that is metres wrong everywhere except at the seam.

Chapters 3 and 4 are about exactly this — a closed seam is necessary, never sufficient, and only a geometrically verified loop earns the correction. Hold onto the intuition that "it looks closed" and "it is correct" are different claims; half of this lesson exists to separate them.

DESIGN — sizing the drift budget against the shelf pitch

Turn the physics into a spec. The warehouse racking has a shelf pitch of 1.2 m — adjacent aisle centre-lines are 1.2 m apart.

The map is visibly wrong once a shelf drifts more than half a pitch (0.6 m), because at that point the drifted rack overlaps its true-position neighbour and a human doing QA sees doubling.

So the acceptance threshold is not "small error" — it is a hard number: y(L) ≤ 0.6 m everywhere the robot maps unaided. That single inequality is the whole design constraint, and it converts directly into a maximum unaided path length.

With the 0.2°/m bias, invert the drift law for that budget. Set y(L) = 0.6, so 1 − cos(bL) = 0.6b = 0.00209, hence cos(bL) = 0.99791, bL = 0.0647 rad, and L = 0.0647/0.00349 = 18.5 m.

Full doubling (a whole pitch, y = 1.2 m) arrives only a little later, at L = 26 m — the two thresholds are close because the curve is steep here. So the design consequence is concrete and quantitative:

The drift budget sets the loop-closure spacing. If the robot must stay under half a shelf pitch on wheel odometry alone, it may drive at most ~18 m before it needs a corrective constraint — a loop closure to a recognised place, or an anchor. That single number, derived from the pitch and the bias, is what dictates how densely you have to place recognisable landmarks and how aggressive the place-recognition thread (Chapter 2) has to be. Halve the bias (better calibration) and the budget roughly doubles the safe distance, because at these angles y ≈ ½ b L² so L ∝ 1/√b.

CODE — integrate the bias from scratch and reproduce the table

You do not have to trust the closed form — step the robot forward one small increment at a time, accumulate heading, accumulate position, and watch the same numbers fall out. This is the whole of dead reckoning in eight lines:

python
import numpy as np

b = np.deg2rad(0.2)          # heading bias: 0.2 deg per metre -> rad/m
def drift(L, ds=0.01):    # march forward in 1 cm steps, summing heading then position
    theta = 0.0; y = 0.0
    for _ in range(int(L/ds)):
        theta += b*ds            # heading = integral of the per-metre bias
        y     += np.sin(theta)*ds # lateral pos = integral of sin(heading)
    return y

for L in (10, 20, 40, 60):
    print(L, round(drift(L), 2))
# 10 0.17   20 0.7   40 2.79   60 6.26   -- matches (1-cos(bL))/b

# the closed form is just the exact limit of that loop:
print([round((1-np.cos(b*L))/b, 2) for L in (10,20,40,60)])
# [0.17, 0.7, 2.79, 6.26]

Before you take that loop on faith, hand-crank its first few steps — that is the whole point of a from-scratch integrator, that you can watch the state advance one increment at a time.

Use a coarse ds = 1 m for the walk-through so theta and y stay readable (the shipped code uses ds = 0.01 m; a coarser step over-shoots a touch, and we will see by how much).

Each step does exactly two additions: bump the heading by b·ds, then add sin(theta)·ds to the position. That is the entire loop body — two lines — so tracing three iterations by hand is genuinely tractable.

Step 1 (after the first metre): theta = 0 + b·1 = 0.003491 rad = 0.200°. Then sin(0.003491) = 0.003491, so y = 0 + 0.003491 × 1 = 0.003491 m. Barely 3.5 mm — the robot is still almost straight.

Step 2 (second metre): heading accumulates again, theta = 0.003491 + 0.003491 = 0.006981 rad = 0.400°. Now sin(0.006981) = 0.006981, so y = 0.003491 + 0.006981 = 0.010472 m. Notice the second increment to y is already double the first — because heading is bigger, the sideways step is bigger. That is the nesting made visible.

Step 3 (third metre): theta = 0.006981 + 0.003491 = 0.010472 rad = 0.600°, sin(0.010472) = 0.010472, so y = 0.010472 + 0.010472 = 0.020944 m. The y-increments run 0.0035, 0.0070, 0.0105 — a linear ramp in the increment means a quadratic ramp in the total, which is the ½ b L² law appearing before your eyes, three lines of arithmetic in.

Let that coarse loop run to the end and it gives y(40) = 2.86 m against the exact table's 2.79 m — the 1-metre step over-integrates by about 3%. Shrink ds to the shipped 1 cm and that gap closes to under a millimetre, which is why the code prints 2.79. The Euler sum and the analytic form agree to the penny (the 10 m step rounds to 0.17 by discretisation, 0.18 in the exact table — shrink ds and it converges). Swapping the loop variable from metres to seconds and b for the gyro bias ω reproduces the IMU table above with no other change. That is the payoff of seeing it as an integral: one routine, three sensors.

DEBUG — the shear that hides behind healthy local residuals

Symptom: the reconstructed map doubles at the far wall — two parallel copies of the same rack a metre apart, one from the outbound pass and one from the return — yet every local check is green: per-frame reprojection error is textbook, consecutive scan-matches align cleanly, no factor is flagged as an outlier. Why the happy path lies: drift lives in the sum of relative measurements, and every relative measurement is individually correct. No local residual can see an accumulated global error. The metric that reveals it: log the per-pose covariance trace (the estimator's own uncertainty about each pose, the sum of the diagonal of its covariance matrix) against path length — on a drifting dead-reckoning run it grows without bound, monotonically, even while residuals stay flat. A rising covariance trace with healthy residuals is the fingerprint of unobserved drift, and it stops rising the instant a loop closure or anchor injects an absolute constraint. If you only watch residuals you will ship the doubled map; watch the covariance trace and you will see the disease coming.

Make that metric concrete rather than described. Suppose the position part of the per-pose covariance trace reads 0.01 m² at 10 m of path, 0.04 m² at 20 m, and 0.09 m² at 30 m. Those are not random — they are (0.1·s)² in metres² with s the path length in units of 10 m, so the trace is growing as the square of distance, exactly the y ≈ ½ b L² signature of the drift itself. Read it as a standard deviation and it is cleaner still: √0.01 = 0.1 m, √0.04 = 0.2 m, √0.09 = 0.3 m — the estimator's own one-sigma position uncertainty is climbing 0.1 m for every 10 m driven, a straight line you can extrapolate. When that line predicts σ ≥ 0.6 m (half the shelf pitch) at the same ~18 m the DESIGN budget flagged, the estimator is telling you in advance that the map is about to double. The moment a verified loop closure lands, the trace drops and flattens; if it keeps climbing, your "closure" did nothing.

FRONTIER

The failure in this hook — a locally-perfect trajectory that shears globally — is the problem graph-based SLAM was built to solve. The modern lineage starts with Grisetti, Kümmerle, Stachniss & Burgard (2010), "A Tutorial on Graph-Based SLAM," which framed the whole map as a pose graph whose loop-closure edges pull accumulated drift back into consistency.

The efficient solver that made it real-time, g2o (Kümmerle et al., 2011), and the incremental smoother iSAM2 (Kaess et al., 2012) are why a warehouse robot can close a loop and re-optimise thousands of poses in milliseconds today.

The live thread that decides when to add those edges — place recognition — and the check that keeps a wrong edge out are the subject of Chapters 2 and 3; the backend that spreads the correction is the SLAM Backend lesson.

Here is the thing that ought to bother you: the robot cannot see its own drift. Every measurement it takes is locally consistent.

The wheel encoders agree with the gyro. The gyro agrees with the last frame's visual odometry. Each step is a small, defensible estimate that no cross-check flags.

The lie is not in any single step — it is in the sum, and no local sensor can observe a sum. This is the same fact the DEBUG box measured (residuals stay green while the covariance trace climbs), stated as an intuition instead of a metric.

The one thing that breaks the spell: the robot drives back to somewhere it has already been, recognises it, and measures the relative pose. That single measurement — "I am now where I was 200 poses ago" — is a loop closure. It is the only constraint in the whole system that connects the far-future estimate to the far-past estimate, and it is the only thing that can pull the shear back.

But a loop closure is dangerous. To use it, the robot must first decide "have I been here before?" from noisy images of shelves that all look identical.

Get that decision wrong — say yes when the answer is no — and it welds two unrelated places together, snapping the far-future estimate onto the far-past one across a chasm that should never have been bridged. The whole map folds in half, and unlike drift the damage is instant and catastrophic rather than slow and smooth.

So this lesson is really three problems stacked:

Where does drift come from?
And which part of it is even fixable? (Ch 1)
Have I been here before?
Place recognition — bag-of-words, learned descriptors (Ch 2)
Am I SURE?
Reject false loops before they corrupt the map (Ch 3)
What does closing it fix?
Global consistency — and what it does NOT fix (Ch 4)

What this lesson is, and is not

The underlying machinery — how a pose graph is built and solved, how factor graphs eliminate variables, how bag-of-words indexing works — is already taught elsewhere on this site.

If any of the mechanics below feels unfamiliar, read the matching lesson first; this one will not repeat them. It spends its words on the craft between those pieces, not on rederiving them.

If you want…Read
The pose-graph backend from first principles: the information matrix, the normal equations, marginalisation and fill-inSLAM Backend
Classical SLAM end to end: EKF-SLAM, the frontend, the data-association problemClassical SLAM
Modern feature-based SLAM: ORB-SLAM, keyframes, local mapping, the loop-closing thread in contextModern SLAM
Factor graphs, elimination, iSAM and the Bayes treeSLAM: Factor Graphs
Bag-of-visual-words place recognition in full: vocabulary trees, inverted indexes, DBoWPlace Recognition (BoW)

What this lesson spends its words on is the craft between those pieces — the four moves that turn the machinery above into a map you can trust:

Everything from Chapter 1 onward is one of those four moves, worked out in full. This chapter's job was only to make you feel the disease before we name the cures.

Two aisles of the warehouse map look perfect; the far wall is a metre off and doubled. What is the most accurate description of what went wrong?

Chapter 1: Sources of Drift, and Observability

Before you can fix drift you have to know which part of it is even fixable. That is not a philosophical question — it is a linear-algebra question with a definite answer, and the answer decides where every constraint in your system has to come from.

Where the error comes from

Drift is the accumulation of small errors in relative measurements. Every odometry source produces a relative motion estimate — "between this frame and the last I moved forward 0.5 m and turned 3°" — and the pose is the running sum of those. Three sources feed it:

SourceWhat leaksTypical magnitude
Wheel odometryWheel-radius miscalibration, slip on turns; a systematic scale + heading bias1–5% of distance; 0.1–1°/m yaw
Visual odometryFeature-track noise, poor triangulation baseline, and (monocular only) scale drift0.1–2% of distance; scale can wander freely
Inertial (IMU)Gyro bias integrates to heading drift; accel bias double-integrates to positionGyro bias 0.01°/s → heading error grows linearly in time

Notice a pattern: every one of these is a relative quantity. None of them measures where the robot is in the world — they measure how it changed. And that is exactly why the sum is unconstrained.

Observability: the gauge you can never see

Observability asks: given all your measurements, is the state uniquely determined, or are there directions you could move the entire estimate without changing a single measurement? Those invisible directions are the unobservable subspace — the null space of the information matrix Λ = ATWA — and error accumulates freely along exactly those directions, because nothing pushes back.

That definition is worth restating carefully, because it is the whole game. The measurements are the residual function r(x); the Jacobian A = ∂r/∂x is how the residuals respond to a nudge in the state. If there is a state direction d with Ad = 0, then to first order you can slide the estimate by any amount along d and every residual stays put. No cost function built from those residuals can prefer one point along d over another. That direction is unobservable, and Λd = ATW(Ad) = ATW·0 = 0, so d is exactly a null vector of Λ. Observability and the null space of Λ are the same statement told two ways.

Warm-up: two poses, one factor

Before the four-pose chain, do the smallest non-trivial case entirely by hand — two poses x0, x1 on a line, one odometry factor between them. The residual is r = x1 − x0 − u. Differentiate: ∂r/∂x0 = −1, ∂r/∂x1 = +1. So A is a single row:

A = [ −1, 1 ]

With unit weight, Λ = ATA is the outer product of that row with itself — a 2×2 matrix. Compute all four entries directly, one at a time:

Λ00 = (−1)(−1) = 1
Λ01 = (−1)(1) = −1
Λ10 = (1)(−1) = −1
Λ11 = (1)(1) = 1
Λ = [ [1, −1]; [−1, 1] ]

Multiply by the all-ones vector 1 = (1, 1), one row at a time:

row 0:  (1)(1) + (−1)(1) = 1 − 1 = 0
row 1:  (−1)(1) + (1)(1) = −1 + 1 = 0

So Λ·1 = (0, 0). The gauge is already here in the two-pose case: you can slide both poses by the same amount and the one measurement, which only sees x1 − x0, never notices. Make it concrete — suppose the true poses are x0 = 0, x1 = 2 and the odometry says u = 2. The residual is r = 2 − 0 − 2 = 0. Now shift both by +5, to x0 = 5, x1 = 7: the residual is r = 7 − 5 − 2 = 0, unchanged. The estimate moved a full 5 units and the cost did not flinch. That is unobservability you can feel in three arithmetic steps.

The two eigenvalues fall out of the trace-and-determinant pair of a 2×2:

trace = Λ00 + Λ11 = 1 + 1 = 2
det = Λ00Λ11 − Λ01Λ10 = (1)(1) − (−1)(−1) = 1 − 1 = 0
characteristic:  λ² − (trace)λ + det = λ² − 2λ + 0 = λ(λ − 2) = 0

So λ ∈ {0, 2}. One zero → a one-dimensional null space → exactly one unobservable direction, and it is the all-ones gauge. The nonzero eigenvalue, 2, is the observable direction — the difference x1 − x0, which the single factor measures directly and stiffly. Every larger pose graph is this same story with more poses: a null direction that is the gauge, and a spread of positive eigenvalues that are the parts the measurements actually pin down.

The four-pose chain, every entry shown

Now scale up. Four poses on a line, three odometry factors, no prior. The residual of the factor between poses k and k+1 is r = xk+1 − xk − u, so its Jacobian row is [… −1 … +1 …] — a −1 in column k and a +1 in column k+1. Stack the three rows (unit weight):

A = [ [−1, 1, 0, 0]; [0, −1, 1, 0]; [0, 0, −1, 1] ]

Form Λ = ATA by hand. Entry (i, j) of ATA is column i of A dotted with column j of A. Write the four columns down — each is a length-3 vector reading down the stacked rows:

col0 = (−1, 0, 0)
col1 = (1, −1, 0)
col2 = (0, 1, −1)
col3 = (0, 0, 1)

Now every one of the sixteen entries, computed as a dot product (the matrix is symmetric, so the ten on-and-above-diagonal entries fix the rest):

Λ00 = col0·col0 = (−1)²+0+0 = 1
Λ01 = col0·col1 = (−1)(1)+0·(−1)+0·0 = −1
Λ02 = col0·col2 = (−1)(0)+0(1)+0(−1) = 0
Λ03 = col0·col3 = (−1)(0)+0(0)+0(1) = 0
Λ11 = col1·col1 = (1)²+(−1)²+0 = 2
Λ12 = col1·col2 = (1)(0)+(−1)(1)+0(−1) = −1
Λ13 = col1·col3 = (1)(0)+(−1)(0)+0(1) = 0
Λ22 = col2·col2 = 0+(1)²+(−1)² = 2
Λ23 = col2·col3 = (0)(0)+(1)(0)+(−1)(1) = −1
Λ33 = col3·col3 = 0+0+(1)² = 1

Reflecting the off-diagonals (Λ1001, and so on) assembles the full matrix:

Λ = [ [1, −1, 0, 0]; [−1, 2, −1, 0]; [0, −1, 2, −1]; [0, 0, −1, 1] ]

Read what each entry means: the diagonal Λii counts how many factors touch pose i (the interior poses 1 and 2 sit in two factors each → 2; the endpoints 0 and 3 sit in one → 1), and the off-diagonal Λij = −1 exactly when poses i and j are directly linked by a factor. That is the graph Laplacian of a 4-node path, degree on the diagonal, −(adjacency) off it.

Why the null space is the all-ones vector — not a coincidence

You can see the zero eigenvalue coming without computing anything, and the argument generalises to any pose graph, not just this chain. A graph Laplacian has a structural property: every row sums to zero. Look at any row — say row 1, (−1, 2, −1, 0): the diagonal is the node's degree, and the off-diagonals are exactly one −1 per neighbour, so the negatives cancel the degree: −1 + 2 + (−1) + 0 = 0. This holds for every row because degree = number of neighbours by construction.

“Every row sums to zero” is precisely the statement Λ·1 = 0, because the i-th entry of Λ·1 is the sum of row i. So the all-ones vector is guaranteed to be a null vector for structural reasons — it is not a numerical accident of this particular chain. Verify it term by term for our matrix:

Λ · 1 = (1−1+0+0,  −1+2−1+0,  0−1+2−1,  0+0−1+1) = (0, 0, 0, 0)

Λ annihilates the all-ones vector. That means 1 is in the null space: you can add the same constant to every pose and every odometry residual is unchanged. Physically, the whole trajectory slides bodily along the axis and no relative measurement can tell. The eigenvalues confirm it — {0, 0.586, 2, 3.414}, where the interior values are 2 − √2 ≈ 0.586 and 2 + √2 ≈ 3.414 — exactly one is zero, and its eigenvector is (proportional to) the all-ones direction. (These are not arbitrary numbers: a path-graph Laplacian on n nodes has eigenvalues 2 − 2 cos(kπ/n) for k = 0…n−1, and k = 0 gives the zero. Here n = 4: k = 1 gives 2 − 2 cos45° = 2 − √2, k = 2 gives 2 − 2 cos90° = 2, k = 3 gives 2 − 2 cos135° = 2 + √2.)

The three nonzero eigenvectors are worth naming, because they are the trajectory shapes the graph actually constrains — the observable subspace, told one mode at a time:

λ = 0.586:  v ∝ (+0.65, +0.27, −0.27, −0.65)  — the gentle tilt / ramp (softest observable mode)
λ = 2.000:  v ∝ (+0.5, −0.5, −0.5, +0.5)  — a fold in the middle
λ = 3.414:  v ∝ (−0.27, +0.65, −0.65, +0.27)  — the fast zig-zag (stiffest mode)

The eigenvalue is the stiffness of that shape: the ramp (0.586) is the direction a solver pins down most weakly, so it is where residual noise leaks into the trajectory shape first — long-wavelength bending is exactly the drift you see in a real map. The zig-zag (3.414) is nailed down hard because adjacent poses disagree the most about it and every factor fights it. The zero mode sits below all of them: no stiffness at all, the free gauge. So the spread of the nonzero eigenvalues (a factor of ~5.8 between softest and stiffest) is what a solver feels as ill-conditioning even inside the observable subspace, and the zero is the part no amount of relative data will ever condition.

The unobservable direction is the global gauge. With relative measurements only, absolute position (and, in 2-D, absolute orientation) is free. Real systems have four unobservable degrees of freedom in the 3-D case with an IMU: three for absolute position and one for yaw (roll and pitch are pinned by gravity). This is not a bug — it is a structural property, and every consistent estimator must respect it.

The punchline: a loop closure does NOT fix the gauge

Here is the mistake almost everyone makes. "A loop closure will fix everything." No. A loop closure is also a relative measurement — it says pose i and pose j are a certain relative pose apart. So it is one more row in A of exactly the same shape as an odometry row. Rather than assert the result, let us build it and watch the gauge survive.

The loop closes 3→0: its residual is r = x0 − x3 − uloop, so ∂r/∂x0 = +1, ∂r/∂x3 = −1 — the Jacobian row is [1, 0, 0, −1]. Stack it under the three odometry rows to get a 4×4 Jacobian:

A′ = [ [−1, 1, 0, 0]; [0, −1, 1, 0]; [0, 0, −1, 1]; [1, 0, 0, −1] ]

Now form Λ′ = A′TA′. The key shortcut: A′TA′ = (sum over rows of the outer product of each row with itself). The first three rows are the odometry rows we already summed — they give back the original Laplacian Λ. So we only have to add the outer product of the new row v = (1, 0, 0, −1) with itself. That outer product v vT is nonzero only where v is nonzero — in components 0 and 3:

(v vT)00 = (1)(1) = +1
(v vT)33 = (−1)(−1) = +1
(v vT)03 = (1)(−1) = −1
(v vT)30 = (−1)(1) = −1
all other entries = 0

So the loop adds +1 to the two endpoint diagonals (0,0) and (3,3) — because poses 0 and 3 now each touch one extra factor — and writes −1 into (0,3) and (3,0) — because 0 and 3 are now directly linked. Add that, entry by entry, to the chain Laplacian Λ:

Λ′00 = Λ00 + 1 = 1 + 1 = 2
Λ′33 = Λ33 + 1 = 1 + 1 = 2
Λ′03 = Λ03 − 1 = 0 − 1 = −1
Λ′30 = Λ30 − 1 = 0 − 1 = −1

every other entry unchanged from Λ. The result:

Λ′ = [ [2, −1, 0, −1]; [−1, 2, −1, 0]; [0, −1, 2, −1]; [−1, 0, −1, 2] ]

This is the Laplacian of the 4-node ring — every pose now has degree 2, and the two −1 off-diagonals per row mark its two ring neighbours. And a ring Laplacian is still a Laplacian, so every row still sums to zero — the structural argument from above did not care whether the graph was a chain or a loop. Check Λ′·1 term by term:

Λ′ · 1 = (2−1+0−1,  −1+2−1+0,  0−1+2−1,  −1+0−1+2) = (0, 0, 0, 0)

The all-ones vector is annihilated again: the loop closure did not touch the gauge. Only now, having watched it survive, do we read the eigenvalues — {0, 2, 2, 4}. Still exactly one zero. The rank went from 3 to 3 (the new row was linearly dependent on the null-space complement, adding no new observable direction that pins the gauge); the smallest nonzero eigenvalue jumped from 0.586 to 2, which is the loop stiffening the observable subspace — that is the drift getting fixed — while the zero sits untouched. Fixing drift and fixing the gauge are visibly different operations on the spectrum: one lifts the small nonzero eigenvalues, the other would have to lift the zero, and no relative measurement ever can.

So the error splits cleanly into two kinds:

Kind of errorLives in…Fixed by…
Drift / internal inconsistencyThe observable subspace — the shape of the trajectory relative to itselfA loop closure (a relative constraint linking far-apart poses)
Global gauge (absolute position, yaw)The unobservable null spaceA prior / anchor (GPS, a known landmark, or just fixing pose 0)
Monocular scale (vision-only)Also unobservable — a fourth null directionA metric input (IMU, stereo, wheel, known object size)
The rule to carry: a loop closure corrects drift — the part of the error that makes the map inconsistent with itself. It cannot correct anything in the unobservable subspace. If your absolute position is wrong, no number of loop closures will save you; you need an anchor. If your monocular scale is wrong, the loop can even close geometrically while the whole map is the wrong size.
Observable vs unobservable, live

A pose chain with odometry (teal edges). Toggle a loop closure and an anchor. Watch the two error bars: drift (internal inconsistency, killed by the loop) and gauge (absolute offset, killed only by the anchor). Try closing the loop with no anchor — the shape snaps consistent but the whole thing floats.

CODE — measure observability directly

You never have to guess whether a direction is observable — you can read it off the information matrix. The unobservable subspace is the null space of Λ, and its dimension is n − rank(Λ). Build the pose-graph Λ from the Jacobians and inspect its smallest eigenvalue:

python
import numpy as np

# 4-pose chain, 3 odometry factors, r = x[k+1] - x[k] - u  ->  Jacobian row [-1, +1]
A = np.array([[-1, 1, 0, 0],
              [0, -1, 1, 0],
              [0, 0, -1, 1]], float)
Lam = A.T @ A                          # the information matrix (graph Laplacian)
w, v = np.linalg.eigh(Lam)
print("eigenvalues", np.round(w, 4))       # [0. 0.586 2. 3.414] — ONE zero
print("null vector", np.round(v[:, 0], 3)) # [-.5 -.5 -.5 -.5] — the global gauge

# add a loop closure 3 -> 0 (Jacobian row [1, 0, 0, -1]) and recheck
A2 = np.vstack([A, [1, 0, 0, -1]])
Lam2 = A2.T @ A2                       # the ring Laplacian: [[2,-1,0,-1],[-1,2,-1,0],[0,-1,2,-1],[-1,0,-1,2]]
print("Lam2 @ ones", Lam2 @ np.ones(4))    # [0. 0. 0. 0.] — gauge annihilated AGAIN
w2, v2 = np.linalg.eigh(Lam2)
print("with loop", np.round(w2, 4))         # [0. 2. 2. 4.] — STILL one zero: gauge survives
print("null vector", np.round(v2[:, 0], 3)) # [-.5 -.5 -.5 -.5] — same all-ones gauge

The library form is a rank test: np.linalg.matrix_rank(Lam) returns 3, so the 4-dimensional state has a 1-dimensional unobservable subspace — and it stays 1-dimensional after the loop closure. This one-liner is how you catch an under-constrained graph before you hand it to a solver that will silently return garbage along the null direction.

DESIGN — where does each constraint enter, at what rate?

In a real stack these live in different threads at different rates. A concrete visual-inertial system:

ConstraintRateLatency budgetFixes
IMU preintegration factor200 Hz → one factor per keyframe< 5 ms to preintegratelocal motion; pins roll/pitch via gravity
Visual odometry / keyframe factor15–30 Hz~20 ms/framelocal relative pose, scale (with IMU)
Loop closure1–5 Hz (place-recognition query)50–200 ms; runs off the critical pathdrift — the observable inconsistency
GPS / anchor prior1–10 Hz when availableloosegauge — absolute position

The loop closure runs slow and off the critical path on purpose: it is expensive (a database query plus a geometric check), it is rare, and a 200 ms latency on a correction that happens once a minute is invisible. The design consequence: place recognition lives in its own thread, and the backend only gets a loop-closure factor when that thread is confident.

DEBUG — the symptom of an unobservable you forgot

Symptom: your monocular VIO tracks beautifully, loops close, the reprojection error is textbook-healthy — but the map is 30% too small and every distance is wrong by the same factor. Cause: the scale direction is unobservable and has drifted; nothing in a pure-vision system observes it. The metric that reveals it: compare the estimated distance between two known-scale landmarks (or the IMU-integrated velocity) against vision. A constant ratio error — healthy residuals, wrong scale — is the fingerprint of an unobservable that drifted. Chapter 4 returns to this: a loop can close while a null-space error persists.

FRONTIER

The observability of VIO was pinned down rigorously by Hesch, Kottas, Bowman & Roumeliotis (2014), "Consistency Analysis and Improvement of Vision-aided Inertial Navigation," which showed that a naive EKF-VIO gains spurious information along the unobservable yaw direction because of inconsistent linearisation — making the filter overconfident about something it cannot see. Their fix, the First-Estimates Jacobian (and later OC-EKF, observability-constrained), forces the estimator's null space to match the true one. This is the modern reason the field is careful about where it linearises — the same theme the SLAM Backend lesson develops for smoothers.

You add a loop closure to a pure-odometry pose graph and the trajectory becomes internally consistent, but its absolute position is still floating in space. Why?

Chapter 2: Place Recognition

To close a loop the robot first has to answer a deceptively hard question: have I been here before? It is standing in aisle 7 looking at a wall of identical shelves, holding an image, and it has ten thousand past images in memory. Comparing the new image pixel-by-pixel against all ten thousand is both too slow and too brittle — the lighting changed, the robot is at a different angle, a forklift is parked in the way.

The trick that makes this tractable is to throw away almost everything and keep a summary: a fixed-length vector that captures what the place is made of, robust to viewpoint and lighting, cheap to compare. Two summaries that are close mean two images that are probably the same place. This is place recognition, and for fifteen years the dominant summary was the bag of visual words.

Bag of visual words, from zero

Detect features in the image (ORB, SIFT). Each feature is a high-dimensional descriptor — a little fingerprint of a corner or blob. Now the key idea: quantise every descriptor to its nearest entry in a pre-built vocabulary of, say, 106 "visual words" (learned by k-means over millions of descriptors from a training corpus). The image collapses to a histogram: how many features landed in each word. Order is thrown away — hence "bag."

Why this works: the same corner of the same shelf, seen from a slightly different angle, produces a descriptor that quantises to the same visual word. Two images of the same place therefore produce similar histograms even though not a single pixel matches. The histogram is viewpoint-tolerant by construction.

Not all words are equal — TF-IDF

A raw histogram overweights common words. In a warehouse the word "floor texture" fires in every image, so it tells you nothing about which place you are in. The word "that one cracked pillar with the yellow paint" fires almost nowhere — so when it matches, it is enormously informative.

This is exactly the problem text search solved decades ago, and the fix is the same: TF-IDF weighting. Weight each word by two factors:

weighti = tfi · idfi,   tfi = ni / ∑j nj,   idfi = log(N / dfi)

Where tfi (term frequency) is the fraction of this image's features in word i; N is the number of database images; dfi (document frequency) is how many database images contain word i at all. A word in every image has df = N, so idf = log(1) = 0 — it contributes nothing. A word in one image out of a thousand has idf = log(1000) ≈ 6.9 — a huge multiplier.

Hand-worked example

A 4-word vocabulary — {floor, door, shelf, pillar} — and a database of 3 past places, each a count histogram:

floordoorshelfpillar
Place A5040
Place B4303
Place C3231
query4241

Step 1 — document frequency. How many of the 3 places contain each word? "floor" appears in all 3 (df=3). "door", "shelf", "pillar" each appear in 2 (df=2).

idf = (log(3/3), log(3/2), log(3/2), log(3/2)) = (0, 0.405, 0.405, 0.405)

"floor" is the "sky is blue" word: it is everywhere, so its weight is exactly zero. Good — the floor should not help you localise.

Step 2 — the query vector. Query counts (4, 2, 4, 1) sum to 11, so tf = (0.364, 0.182, 0.364, 0.091). Multiply by idf:

tf·idf = (0, 0.0737, 0.1474, 0.0369),  then normalise → q̂ = (0, 0.436, 0.873, 0.218)

Step 3 — cosine similarity with each place. Do the same for A, B, C and take the dot products of the unit vectors:

CandidateCosine scoreWhy
Place A0.873lots of shelf, but no door/pillar
Place B0.463door + pillar but no shelf — wrong place
Place C0.9915door + shelf + pillar in the same proportion — the match

Place C wins clearly. That is the loop-closure candidate: the database image whose weighted histogram is most similar to the query. Chapter 3 is about whether to trust it.

Feel the idf weighting

Four visual words. Drag the slider to change how common the first word ("floor") is across the database. As it appears in more places its idf collapses to zero and its bar shrinks to nothing — while the rare, informative words keep their weight. Watch which candidate wins the cosine race at the bottom.

"floor" appears in 4 of 4 places

The inverted index — why this is fast

Cosine similarity against a million-image database sounds like a million dot products. It is not, because the histograms are 99.99% zeros. A DBoW-style system keeps an inverted index: for each visual word, a list of the database images that contain it. To score the query you only visit the words the query actually has, and only touch database images that share at least one word. A query with 300 features touches a few thousand images, not a million — sub-millisecond on a laptop.

Learned descriptors — NetVLAD and beyond

Bag-of-words breaks under severe appearance change — the same street in summer vs winter, day vs night, produces different features that quantise to different words. The modern answer is a learned global descriptor: a neural network trained to map an image to a single vector such that same-place images are close and different-place images are far, directly, without a hand-built vocabulary.

NetVLAD (Arandjelović et al., 2016) is the landmark: it makes the VLAD aggregation (residuals to cluster centres) differentiable, so the whole pipeline — feature extraction, cluster assignment, aggregation — trains end-to-end on a weakly-supervised "same place / different place" loss mined from Google Street View. The output is a compact vector (often 4096-D, PCA-reduced to 256-D) compared by dot product.

Bag of words (DBoW2)Learned (NetVLAD)
Vocabularyk-means, fixed after traininglearned soft assignment, differentiable
Appearance robustnessweak (same features required)strong (trained across seasons/lighting)
Descriptor sizesparse histogram + inverted indexdense 256–4096-D vector
Where it shinesreal-time SLAM, ORB-SLAM loop threadlarge-scale visual place recognition, day/night
The design choice: DBoW2 is what ships inside most real-time SLAM systems (ORB-SLAM2/3) because it is fast, sparse, and geometry-friendly. NetVLAD-class descriptors dominate the harder visual place recognition benchmarks where appearance changes drastically. Many modern systems run both: a learned global descriptor for the candidate shortlist, then bag-of-words feature matching for the geometry.

DEBUG — perceptual aliasing

Symptom: your place recogniser confidently matches aisle 3 to aisle 8 — two genuinely different corridors that happen to have identical shelving. Loop closures fire between them and the map folds. Cause: perceptual aliasing — different places produce near-identical descriptors. The metric that reveals it: track the score margin between the top-1 and top-2 candidates. A true loop has a dominant winner; an aliased scene has several places tied near the top. A low margin is the tell — and the reason Chapter 3's geometric verification exists: the descriptor got fooled, but the geometry will not.
In TF-IDF place recognition, a visual word that appears in every database image gets idf = log(N/N) = 0. What does that accomplish?

Chapter 3: Rejecting False Loop Closures

Chapter 2 gave us a candidate: "the query looks a lot like place C." But looks like is not is. In a warehouse full of identical shelving, the place recogniser will happily tell you aisle 3 is aisle 8. And a single false loop closure is catastrophic — far worse than missing a true one.

The asymmetry that governs everything here: a missed loop closure costs you a little accuracy — the drift you would have corrected stays. A false loop closure welds two unrelated places into one, and the optimizer, trusting it, folds the entire map to satisfy the lie. One false positive can destroy a map that a hundred missed true positives would only slightly degrade. So loop-closure acceptance must be precision-first: reject anything you are not sure of.

The appearance check is not enough

The bag-of-words score told us two images have similar content. It said nothing about whether they have consistent geometry. Two aisles of identical shelves have near-identical content and completely different geometry — the corners are in different absolute arrangements. So after the appearance match we demand a second, independent test: can the feature correspondences be explained by a single rigid transform? If yes, it is the same place. If no, the appearance match was an alias.

Geometric verification with RANSAC

We have a set of putative feature correspondences between the query image and the candidate: point pi in one matches point qi in the other. If this is a true loop, there exists one rigid transform (R, t) such that qi ≈ R·pi + t for most pairs. But many of the putative matches are wrong — the descriptor matcher makes mistakes. Those wrong matches are outliers, and a least-squares fit over all of them would be dragged off by the outliers.

RANSAC (RANdom SAmple Consensus) cuts through this. The recipe:

1. Sample
Pick the minimal number of correspondences to define the transform (2 pairs for a 2-D rigid transform)
2. Fit
Solve for (R, t) from just those 2 pairs
3. Score
Count inliers: pairs where ‖R·pi + t − qi‖ < τ
4. Repeat & keep best
After k iterations, accept the loop only if the best inlier count exceeds a threshold

How many iterations? Derive it.

You do not guess k — you compute it. Let w be the fraction of correspondences that are inliers, and s the minimal sample size. The probability that all s points in one random sample are inliers is ws. The probability that a single sample is not all-inlier is (1 − ws). After k independent samples, the probability that none was clean is (1 − ws)k. Set the probability of at least one clean sample to a target P (say 0.99) and solve:

P = 1 − (1 − ws)k  ⇒   k = log(1 − P) / log(1 − ws)

Worked example 1 — w = 0.6, s = 2. Suppose 40% of the correspondences are wrong, so the inlier fraction is w = 0.6, with s = 2 for a 2-D rigid transform. Then ws = 0.62 = 0.36. Plug in, keeping every intermediate:

1 − ws = 1 − 0.36 = 0.64
log(1 − P) = log(0.01) = −4.605
log(1 − ws) = log(0.64) = −0.446
k = (−4.605) / (−0.446) = 10.32 → ⌈10.32⌉ = 11 iterations

Eleven samples. That is the whole reason RANSAC is cheap: because the minimal sample is tiny, even at 40% outliers you need barely a dozen tries to hit a clean one with 99% confidence.

Worked example 2 — raise the sample size to s = 4. Keep the same inlier fraction w = 0.6 but suppose the model needs four points to fit (say a homography rather than a rigid transform). Now every intermediate shifts — watch what raising the exponent does:

ws = 0.64 = 0.6 × 0.6 × 0.6 × 0.6 = 0.1296
1 − ws = 1 − 0.1296 = 0.8704
log(1 − ws) = log(0.8704) = −0.1388
k = (−4.605) / (−0.1388) = 33.18 → ⌈33.18⌉ = 34 iterations

The count tripled — 11 → 34 — for the same outlier rate, purely because the sample grew from 2 points to 4. That is why you always use the smallest model that defines the transform: the required iterations grow roughly as w−s, so every extra point you demand in the minimal sample multiplies your work.

Compute it directly. The whole derivation is one line of code — no table lookup, no guessing. This is exactly what you would jot at a whiteboard to justify an iteration budget:

python
import math

def ransac_iters(w, s, P=0.99):
    # w = inlier fraction, s = minimal sample size, P = target confidence
    return math.ceil(math.log(1 - P) / math.log(1 - w**s))

print(ransac_iters(0.6, 2))  # -> 11
print(ransac_iters(0.6, 4))  # -> 34
print(ransac_iters(0.5, 2))  # -> 17  (worse inliers cost more tries)
print(ransac_iters(0.8, 2))  # ->  5  (cleaner matches, far cheaper)

How the inlier fraction moves the budget. Fix s = 2 and sweep w. The table grinds each case out to its final k so you can see the sensitivity — a place recogniser that returns cleaner correspondences (higher w) is worth far more than a faster RANSAC loop:

Inlier wws = w21 − wslog(1 − ws)k = −4.605 / log(·)⌈k⌉
0.50.250.75−0.28816.0117
0.60.360.64−0.44610.3211
0.80.640.36−1.0224.515

Halving the outliers (w: 0.5 → 0.8) collapses the budget from 17 tries to 5 — a 3.4× speedup that costs nothing at run time. This is why the frontier of loop closure (SuperGlue, below) invests so heavily in producing clean correspondences before RANSAC ever runs.

The threshold, not the fit, is the decision. The output of geometric verification is not really the transform — it is the inlier count. A true loop closure yields many inliers (the shared geometry supports one transform); a false one yields few (no transform explains random matches). Set the acceptance threshold high and you reject false loops at the cost of missing some true ones — exactly the precision-first trade the asymmetry demands.

Consistency checks beyond a single pair

Geometric verification catches an isolated false match. But sophisticated systems add a second layer of defence — temporal / structural consistency:

Chi-squared gating, worked out

RANSAC's inlier count is a raw vote; chi-squared gating is the statistical version of the same reject-if-inconsistent idea, and it is what a filtering-based backend uses directly. The candidate loop closure predicts a relative pose; the graph already believes a relative pose from odometry. Their disagreement is a residual r — a small vector, here the x/y position error in metres. But you cannot judge a raw residual: 20 cm of error is huge if your sensor is accurate to a centimetre and negligible if it is accurate to a metre. So you weight the residual by the inverse of its covariance Σ (how much error you expected on each axis) and form the Mahalanobis distance — error measured in units of its own standard deviation:

d2 = r Σ−1 r

Worked example. The loop closure leaves a residual r = (0.18, 0.10) metres. The expected error is σx = 0.1 m and σy = 0.2 m, so the covariance is diagonal, Σ = diag(0.12, 0.22) = diag(0.01, 0.04). For a diagonal Σ the inverse is just the reciprocal of each entry, and the Mahalanobis distance is a weighted sum of squares:

Σ−1 = diag(1/0.01, 1/0.04) = diag(100, 25)
d2 = (0.18)2/0.01 + (0.10)2/0.04
    = 0.0324/0.01 + 0.0100/0.04
    = 3.24 + 0.25 = 3.49

Now compare against the gate. The residual has 2 degrees of freedom (x and y), so the threshold is the 95th percentile of a χ2 distribution with 2 DOF, which is 5.99. Since d2 = 3.49 < 5.99, this loop closure is statistically consistent — accept it. Notice the x-axis alone contributes 3.24 of the 3.49: an 18 cm error on a 10 cm-σ axis is 1.8σ, already most of the budget, while the same absolute error on the looser y-axis would barely register.

Contrast a false loop that leaves a larger residual r = (0.30, 0.45): the same arithmetic gives 0.302/0.01 + 0.452/0.04 = 9.00 + 5.06 = 14.06, which exceeds 5.99 (and even the 99% gate of 9.21) — so gating rejects it. That single scalar comparison, d2 vs a χ2 threshold, is the entire acceptance test, and it is why every measurement in a filter carries a covariance: without Σ you have no ruler to decide whether a residual is "too big."

Why the covariance, not the raw error, is the ruler. A residual is meaningless without knowing how much error you expected. Mahalanobis distance converts metres into standard deviations, so a single χ2 threshold gates every loop closure regardless of scale or units — the same test works for a 1 cm indoor drone and a 1 m outdoor rover, because both are measured in units of their own uncertainty.
RANSAC on a true vs false loop

Left: a TRUE loop — most correspondences (teal) obey one rigid transform; a few (red) are wrong matches. Right: a FALSE loop — random correspondences fit nothing. Drag the inlier threshold and watch the accept/reject verdict flip. Set it too loose and the alias sneaks through — the warning fires.

inlier threshold τ 0.06

DEBUG — the false-loop signature

Symptom: the optimizer's total cost was falling nicely, then after one loop closure it jumped up and stayed high, and the map now has a visible crease where two corridors were fused. Cause: a false loop closure was accepted — a high-confidence appearance match that geometry should have rejected. The metric that reveals it: the post-optimisation residual of the loop-closure factor itself. A true loop settles to a small residual; a false one stays large because no consistent configuration satisfies it and the odometry simultaneously. Plot per-factor residuals after each optimisation and the false loop is the tall red bar. The fix in production: robust kernels (Huber, or switchable constraints / dynamic covariance scaling) that let the optimizer down-weight a factor it cannot satisfy.

FRONTIER

Rejecting false loops after they enter the graph is the domain of Switchable Constraints (Sünderhauf & Protzel, 2012) and Dynamic Covariance Scaling (Agarwal et al., 2013): instead of a hard accept/reject, each loop-closure factor gets a switch variable the optimizer can turn off if the constraint disagrees with everything else — making the backend robust to a fraction of false positives that slipped past the frontend. The frontend defence — learned verification — is advancing too: SuperGlue (Sarlin et al., 2020) replaced nearest-neighbour matching + RANSAC with a graph-neural-network matcher that reasons about all correspondences jointly, producing far cleaner inlier sets before RANSAC even runs.

Why is loop-closure acceptance deliberately precision-first (reject anything uncertain), rather than recall-first (accept generously)?

Chapter 4: Global vs Local Consistency

We have a verified loop closure. We feed it to the backend. Something snaps. But what, exactly, does it fix — and what does it not? Getting this precise is the difference between an engineer who understands SLAM and one who repeats "loop closure makes it accurate."

Two kinds of consistency

Local consistency means each measurement agrees with its neighbours: consecutive poses have plausible relative motion, the map near the robot lines up. Pure odometry is locally consistent everywhere — that is exactly why the first two aisles looked perfect.

Global consistency means the whole map agrees with itself, including places connected only through long chains of measurements. The doubled shelf wall is a global-consistency failure: locally each pass was fine, but the out-pass and the return-pass disagree by a metre because the drift between them was never constrained. A loop closure is precisely a global constraint — it links two poses that local measurements never connected.

What a loop closure actually does: it takes the accumulated error along the loop — the gap between where odometry says you are and where the loop says you are — and distributes it back over every edge in the cycle, inversely to each edge's weight. It does not snap pose N back onto pose 0 and leave the rest. It bends the whole chain, spreading the correction smoothly, so the doubled wall merges into one.

Where the correction goes — the residual-spreading law

This is the single most important quantitative fact in the chapter, and it comes straight from least squares. Consider a cycle of edges each measuring a relative pose, one of which is the loop closure with a total inconsistency (the loop error) of e. Minimising the sum of weighted squared residuals, the correction each edge k absorbs is:

δk = e · (1/wk) / ∑m (1/wm) = e · σk2 / ∑m σm2

Read it plainly: each edge takes a share of the loop error proportional to its variance. A confident (low-σ) edge barely moves; an uncertain (high-σ) edge absorbs most of the correction. With equal weights the error is split evenly — the corrected chain is smooth, not kinked.

Where the formula comes from — the 1-D normal equations

You do not have to take this on faith; it drops straight out of one-dimensional weighted least squares. Line up the loop's edges along a single axis. Let x0, x1, … xn be the pose coordinates, and let each edge k measure a relative step zk with weight wk = 1/σk2. The edge's residual is rk = (xk − xk−1) − zk, and we minimise J = ∑k wk rk2.

Now the trick that makes it a one-liner: a cycle is a closed chain, so the corrections are not free — they must sum to the loop error. Write δk for the change we make to edge k's step. The constraint is ∑k δk = e (the whole loop must close), and we want to spend that budget to minimise ∑k wk δk2. Form the Lagrangian L = ∑k wk δk2 − λ(∑k δk − e). Set ∂L/∂δk = 0:

2 wk δk − λ = 0  ⇒  δk = λ / (2 wk)

So each correction is proportional to 1/wk = σk2. Substitute back into ∑k δk = e to pin down λ: λ/2 · ∑m (1/wm) = e, hence λ/2 = e / ∑m σm2. Putting that back gives exactly the law above — each edge absorbs δk = e · σk2 / ∑m σm2. The full multi-dimensional pose-graph version (with Jacobians and the sparse information matrix) is derived in the SLAM Backend lesson; the 1-D case above is the whole idea with none of the bookkeeping.

Worked example — a 6-edge cycle by hand

Take the exact cycle the widget below animates: six edges with standard deviations σ = [1, 1, 1, 3, 1, 1] and a loop error e = 1.0. Edge 3 is the shaky one (three times the noise); the rest are confident. Do the arithmetic in the open.

First square the sigmas to get variances: σ2 = [1, 1, 1, 9, 1, 1]. Sum them for the denominator:

m σm2 = 1 + 1 + 1 + 9 + 1 + 1 = 14

Now the share each edge absorbs. The shaky edge 3 takes

δ3 = e · σ32 / 14 = 1.0 · 9 / 14 = 0.643

while every one of the five confident edges takes only

δk = e · 1 / 14 = 1.0 / 14 = 0.071

Sanity check that the corrections spend the whole loop error and no more — they must sum to e:

0.643 + 5 × 0.071 = 0.643 + 0.357 = 1.00 = e  ✓

That is the residual-spreading law made concrete: one edge that was three times noisier soaks up 64% of the correction while the five confident edges together absorb the remaining 36%, split evenly. The widget computes exactly these shares internally — drag its slider to reproduce the σ3 = 3 case and you will see edge 3's bar hit 0.64 of the total.

The takeaway is what the number means: the loop closure fixes global consistency by trusting each measurement in proportion to its confidence, never overruling a well-trusted edge to satisfy a shaky one.

Second worked example — two edges compete for the budget

One shaky edge is the easy case; the law's real teeth show when two edges are uncertain and must split the correction between themselves. Keep the loop error e = 1.0 but make edge 1 twice as noisy and edge 3 three times as noisy: σ = [1, 2, 1, 3, 1, 1]. Now two edges are competing for the same correction budget, and we want to see which one wins and by how much.

Square the sigmas to variances — and notice the squaring is what makes the competition lopsided: σ2 = [1, 4, 1, 9, 1, 1]. Sum for the denominator:

m σm2 = 1 + 4 + 1 + 9 + 1 + 1 = 17

The two noisy edges each take a share proportional to their variance:

δ1 = e · 4 / 17 = 0.235,   δ3 = e · 9 / 17 = 0.529

while each of the four confident edges takes only δk = e · 1 / 17 = 0.059. Sanity-check the budget — the six shares must sum to e:

0.529 + 0.235 + 4 × 0.059 = 0.529 + 0.235 + 0.235 = 1.00 = e  ✓

Here is the sharp lesson. Edge 3 is only 1.5× noisier than edge 1 in standard deviation (3 vs 2), yet it absorbs 2.25× as much correction (0.529 vs 0.235). The ratio is exactly (3/2)2 = 9/4 — because the law spreads error by variance, not by sigma, a modest gap in noise becomes a wide gap in who pays. Between them the two shaky edges soak up 13/17 ≈ 76% of the whole correction; the four confident edges together take the remaining 4/17 ≈ 24%. That squaring is why a single badly-modelled edge covariance can quietly dominate a solve: the optimiser dumps the loop error into whatever you told it was least certain, and it does so super-linearly.

Where the loop error goes

A 6-edge cycle absorbing a fixed loop error. Drag the slider to make one edge (edge 3) less certain (higher σ). Watch it soak up a larger share of the correction while the confident edges barely move — and watch the split stay proportional to variance, exactly as the formula says.

σ of edge 3 1.0×

What a loop closure does NOT fix

Now the sharp edge. Recall Chapter 1: a loop closure lives in the observable subspace. So:

ErrorDoes the loop fix it?
Internal drift (the doubled wall, the shear)Yes — this is exactly its job
Absolute position of the whole mapNo — unobservable gauge; needs an anchor
Monocular global scaleNo — the loop can close geometrically with the wrong scale
A systematic bias that also corrupts the loop measurementPartly — it closes the seam but cannot recover truth the bias hid from every sensor
Consistency is not accuracy. A loop closure makes the map consistent with itself. Whether it is accurate (close to ground truth) depends on whether the errors it corrected were observable. In the bench you are about to build, you will see the end-to-start gap collapse by a large factor — the map becomes consistent — while the per-pose distance from an unknowable ground truth barely moves, because the drift was a systematic heading bias the loop measurement shared. That gap between "consistent" and "accurate" is the whole lesson of this chapter.

The monocular-scale row, made concrete — scale is a second gauge freedom

The table gives the monocular-scale case one line; it deserves a number, because it is the most famous way a loop closure lies to you. A single camera recovers geometry only up to scale: every relative-pose measurement z is really a direction times an unknown length. So take our radius-2 ring, whose true adjacent chord is 2R·sin(π/N) = 1.531 m, and imagine the whole run was reconstructed at the wrong scale factor a = 1.5, making every chord 1.5 × 1.531 = 2.296 m.

Now compute the loop-closure residual r = (pj − pi) − z at the true scale and at the wrong scale. At a = 1: the poses are the true ring and the loop measurement is the true relative pose, so r = 0. At a = 1.5: every pose coordinate is 1.5× larger, and the loop measurement — also derived from the same monocular frame — is 1.5× larger too. So (pj − pi) and z scale together, and r = 0 again:

r(a) = a · (pj − pi)true − a · ztrue = a · 0 = 0   for every a

Read that carefully: the loop residual is identically zero at every scale. The optimiser sees no error to correct because there is none in its objective — a globally consistent map at 1.5× scale is exactly as valid, to the least-squares cost, as one at true scale. Scale is a second gauge freedom, sitting right beside the absolute-position gauge from the anchor row: the loop pins the map's shape but not its size. Only a metric input the cost can actually feel — a stereo baseline, an IMU's gravity-scaled accelerations, a wheel-encoder distance, one known landmark span — enters a residual that is nonzero at the wrong scale and thus removes the freedom. This is why monocular VO/SLAM pipelines are always paired with one metric sensor; the loop closure alone can close a geometrically perfect ring at the wrong size and report a healthy residual the whole time.

Worked example — drive two steps of the ring by hand

Let us make "the gap" a real number before the widget hands us one. The bench walks eight poses around a radius-2 ring and returns near the start. The true relative step between two adjacent ring poses is a chord, and a chord of a circle subtending an angle 2π/N has length 2R·sin(π/N). With R = 2 and N = 8:

|steptrue| = 2R · sin(π/8) = 2 · 2 · sin(22.5°) = 4 · 0.3827 = 1.531 m

The first true step points from pose 0 = (2, 0) to pose 1 = (1.414, 1.414), i.e. the vector (−0.586, 1.414). Now corrupt it the way the odometry does: rotate by the per-edge heading bias b = 0.11 rad and stretch it 2% long. Rotating (−0.586, 1.414) by 0.11 rad and scaling by 1.02 gives the odometry step

stepodo,0 ≈ (−0.752, 1.368),   |stepodo,0| = 1.561 m

One step in, the length error is only 1.561 − 1.531 = 0.03 m — tiny, locally consistent. But the heading bias accumulates: edge k is rotated by k·b, so by the last edge the direction is off by 7 × 0.11 = 0.77 rad ≈ 44°. Summing all seven curled-and-stretched steps, the dead-reckoned chain never returns to the start — the raw end-to-start displacement lands at roughly (−0.001, +0.028) while the loop says it should be loop_meas = (0.586, 1.414). The gap is the length of the difference:

gapbefore = |(dr0 − dr7) − loop_meas| ≈ |(−0.587, −1.386)| = √(0.344 + 1.921) = 1.506 m

So the seam is open by about 1.5 m before we optimise — that is real arithmetic, not a slogan. Feeding the loop closure to the backend collapses it to gapafter ≈ 0.009 m, a 175× shrink: the ring is now globally consistent. Here is the honest part. The mean distance of each pose from the unknowable truth ring is 0.87 m before closure and 0.90 m after — essentially unchanged. Consistency improved 175-fold; accuracy did not budge. The reason is exactly the residual rotation the loop cannot see: the whole ring is still rotated by roughly b·N/2 = 0.11 × 8 / 2 = 0.44 rad, the constant bias that every edge and the loop measurement all share. No relative constraint can observe a rotation that is baked into every measurement equally; only an external anchor (GPS, a known landmark) removes it.

Read the plot honestly. The teal (optimised) ring closes — the seam gap collapses from ~1.5 m to under 2 cm, so the map is now globally consistent. But the teal ring is still not the green truth ring: the constant heading bias curled the whole path, and the loop measurement, derived from the same drifted frame, cannot undo a bias that fooled every sensor. Consistency: fixed. Accuracy: bounded by observability. That is not a failure of the method — it is exactly what Chapter 1 predicted.

Why the anchor pins the gauge — a numerical trace of Lam[0,0]

The bench you just ran opens with one line that looks like bookkeeping but is doing all the gauge-fixing work: Lam[0,0] += 1.0 / sig_prior ** 2 with sig_prior = 1e-3. Follow the arithmetic and you see why that single entry pins the absolute-position gauge the loop closure leaves free. A prior of standard deviation 10−3 carries weight

wprior = 1 / σprior2 = 1 / (10−3)2 = 106

Compare that to the edges touching pose 0. The odometry edge (σ = 0.10) contributes wodo = 1/0.102 = 100, and the loop edge (σ = 0.02) contributes wloop = 1/0.022 = 2500. Pose 0's diagonal entry is the sum of everything anchored there:

Λ0,0 = wprior + wodo + wloop = 1 000 000 + 100 + 2500 = 1 002 600

The prior alone is 1 000 000 / 1 002 600 ≈ 99.7% of that number — it out-weighs the odometry edge by 104× and the loop edge by 400×. When the solver forms η0 = anchor / σprior2, that giant weight forces pose 0 to sit essentially exactly at the anchor coordinate: any deviation costs wprior · (deviation)2, so a millimetre of slack costs a million times more than a millimetre of odometry residual. That is how a rank-deficient normal-equation system becomes solvable: without the prior, Λ has a null space (translate the whole map and no relative residual changes — exactly the unobservable position gauge from Chapter 1), and np.linalg.solve would hit a singular matrix. The anchor prior adds the one entry that lifts Λ to full rank and nails down the free direction — not by measuring position, but by declaring one pose known. Drop sig_prior toward zero and the pin gets harder; raise it and pose 0 floats, and if you remove the prior entirely the solve fails on a singular Λ. The loop closure fixes shape; this one entry fixes where the shape sits.

DESIGN — when to re-optimise, and how much

A loop closure triggers a re-optimisation of the pose graph, and that is not free. On a 10,000-pose graph a full batch solve is expensive, so production systems are incremental:

StrategyWhat it doesCost
Full batch (g2o, Ceres)Re-solve the whole graphseconds on large graphs — too slow for every loop
Incremental (iSAM2)Re-solve only the part of the Bayes tree the loop touchedmilliseconds — ships in real time
Pose-graph only (then re-project map)Optimise poses, rigidly move the map points with themcheap; ORB-SLAM's loop-correction step

DEBUG — the optimiser diverged after a good loop

Symptom: a legitimately-verified loop closure fired, but the optimiser overshot and the map is now worse than before, with poses flung far from any plausible position. Cause: Gauss-Newton took a full step from a bad linearisation point — the loop error was large (a metre of drift), and the first linearised step is only valid for small corrections. The metric that reveals it: the cost increased after the step (a healthy GN step decreases it). The fix: Levenberg-Marquardt — it evaluates the true cost before committing and rejects any step that does not improve it, leashing the large first correction that a big loop closure induces. (Derived in the SLAM Backend lesson.)

FRONTIER

The incremental re-optimisation that makes real-time loop closure possible is iSAM2 (Kaess et al., 2012), which represents the factor graph as a Bayes tree and, when a loop closure arrives, re-eliminates only the affected cliques rather than the whole graph — turning a seconds-long batch solve into a millisecond update. It is the algorithm underneath GTSAM's smoother and the reason a robot can close a loop across a 10,000-keyframe map without stalling.

After a verified loop closure, the end-to-start gap of your trajectory collapses from 1.5 m to 1 cm, but the map is still noticeably off from GPS ground truth by a similar rotation everywhere. What happened?

Chapter 5: Map Reuse & Lifelong SLAM

So far the robot built a map once and closed loops within a single run. But the warehouse robot comes back tomorrow, and the day after, for years. That changes everything. Now the map is not a scratchpad — it is a persistent asset that must be stored, loaded, matched against on day two, and kept from growing without bound. This is lifelong SLAM, and it turns loop closure into a superpower: relocalization.

Relocalization = loop closure against a saved map

Relocalization is the same machinery as loop closure, pointed at a different target. Loop closure asks "have I been here during this run?"; relocalization asks "am I somewhere in the previously saved map?" The pipeline is identical:

Query
Current image → global descriptor (BoW / NetVLAD)
Retrieve
Nearest keyframes in the saved map's database
Verify
Geometric check (PnP / RANSAC) against the candidate's 3-D points
Snap
Recover the current pose in the map frame — the robot knows where it is

This is what lets a robot "wake up lost" (the kidnapped-robot problem) and recover: no odometry history, just an image and a saved map. It is also how a delivery robot resumes a route after a reboot, and how AR headsets re-anchor virtual content to a previously scanned room.

Trace the exact data through those four boxes so it is a system, not a slogan. Query: the current 640×480 image goes in, ORB extracts ~1000 keypoints each with a 32-byte descriptor, and the BoW pooling collapses them into one sparse word-count vector over a ~1M-word vocabulary — a few KB out. Retrieve: that vector indexes the inverted database and returns a shortlist of, say, the top-10 candidate keyframe IDs by BoW similarity — ten 8-byte IDs out. Verify: for each candidate, its stored 3-D map points (each a 12-byte XYZ) are matched to the query's 2-D keypoints and PnP+RANSAC runs the 35 iterations we will derive, returning an inlier count and a candidate pose — the decision is the inlier count, an integer. Snap: the winning candidate's map pose composes with the recovered relative pose to yield a single 4×4 (or 3×3 in 2-D) transform — the robot's pose in the map frame. Image in, one matrix out; every arrow carries a concrete payload.

CONCEPT — why one snap fixes the whole session

Here is the claim that makes relocalization feel like magic, and it deserves a derivation rather than a hand-wave: a single successful PnP snap against the saved map fixes ALL of the accumulated drift and gauge freedom of the resumed session at once. Not "reduces it" — removes it entirely, in one step. Why?

The resumed session has been dead-reckoning since boot. It has built a fresh local chain of poses, but that chain lives in an arbitrary frame: the robot picked its start as the origin, so every pose it knows is expressed relative to that private origin, not relative to the saved map. Call the current camera pose, as the session knows it internally, Tkf→current — the rigid transform from a saved keyframe's frame to where the camera is now, which is exactly what PnP+RANSAC recovers when it matches the current image against that keyframe's 3-D points.

The saved map already stores where that keyframe sits in the map frame: Tmap→kf. Compose them:

Tmap→current = Tmap→kf · Tkf→current

That one matrix product re-expresses the current pose in the map frame. And because every other pose in the resumed session is tied to the current pose by the (drift-corrupted but internally consistent) odometry chain, applying the same left-multiply Tmap→kf to the whole chain drops the entire session into the map. The private origin the session invented is gone; the gauge is pinned by the map. This is the same reason Chapter 1's loop closure fixed the gauge — relocalization is that fix pointed at a saved anchor instead of a within-run one.

Work it with real numbers so it is yours. Say the saved keyframe sits at map pose (3.0 m, 1.0 m, heading 30°), and PnP recovers the current camera at (0.4 m, −0.2 m, −10°) relative to that keyframe. Compose the two SE(2) transforms — rotate the keyframe-relative offset by the keyframe's 30°, add the keyframe's translation, and add the headings:

x = 3.0 + (0.4·cos30° − (−0.2)·sin30°) = 3.0 + 0.446 = 3.446 m
y = 1.0 + (0.4·sin30° + (−0.2)·cos30°) = 1.0 + 0.027 = 1.027 m
heading = 30° + (−10°) = 20°

The current camera is at (3.446 m, 1.027 m) heading 20° in the map frame — a full 6-DOF (here 3-DOF in 2-D) pose, recovered from one image and one snap, with no odometry history required. Every pose the session dead-reckoned before this instant snaps into the map by the same left-multiply. One PnP; whole session anchored.

Why one is enough: drift is an accumulation of relative errors along a chain, but a relocalization supplies an absolute tie between the current pose and the map. An absolute tie fixes the whole chain because the chain's internal shape was never the problem — only its placement in the world was. The snap places it. (This is exactly why a robot that never relocalizes drifts unboundedly: nothing ever supplies the absolute tie.)

"Same machinery, different target" is the headline, but the operational differences are where the engineering lives — tabulate them so the equivalence does not paper over what actually changes:

 Loop closureRelocalization
QuestionHave I been here this run?Am I in the saved map?
Database queriedKeyframes from the live sessionKeyframes loaded from disk
What it fixesAccumulated within-run driftUnknown session start + boot drift
Prior available?Yes — a good odometry guessOften none (kidnapped robot)
Cost of a false acceptFold the current map in halfTeleport into a map trusted for hours
OutputA between-pose factor added to the graphAn absolute pose in the map frame

The row that matters most is "prior available." Loop closure usually has a decent odometry guess to sanity-check a candidate against; relocalization frequently does not, which is why its acceptance bar (the inlier count we derive next) has to be strict enough to stand on geometry alone.

CONCEPT — how many inliers make a relocalization trustworthy

A snap is only as good as the geometric verification behind it, and the acceptance decision is a count, not a fit — the same lesson as Chapter 3's RANSAC. But relocalization raises the stakes: a false relocalization does not just fold a within-run map, it teleports the robot to the wrong place in a map it will then trust for hours. So we derive two numbers.

First, the iterations. Reuse Chapter 3's formula k = log(1−P) / log(1−ws), but with the PnP minimal sample s = 3 (three 3-D↔2-D correspondences fix a camera pose; this is P3P). For a coin-flip inlier fraction w = 0.5 and a 99% success target:

ws = 0.53 = 0.125
k = log(0.01) / log(1 − 0.125) = (−4.605) / (−0.1335) = 34.5 → 35 iterations

Thirty-five PnP hypotheses buy 99% confidence of hitting one clean 3-point sample even when half the matches are wrong — cheap, because the minimal sample is small.

Second, the inlier threshold. How many inliers must the winning hypothesis explain before you believe the snap? Frame it as a false-positive tail: if the candidate keyframe is actually the WRONG place, its matches are essentially random, and a random correspondence lands within the reprojection tolerance τ only by luck — say with probability p0 ≈ 0.05. With n = 24 correspondences, the chance that a wrong candidate coughs up at least m inliers by pure chance is the binomial tail:

P(false yields ≥ m inliers) = ∑i=m24 C(24,i) · 0.05i · 0.9524−i

Compute the tail by hand for a few thresholds: m=4 gives 0.030, m=5 gives 0.0060, and m=6 gives 0.00096 — the first threshold that drops the false-accept probability below 1 in 1000. So a rule of "accept only with ≥ 6 geometric inliers" makes an accidental relocalization a sub-0.1% event per candidate. That is the number you would defend at a whiteboard: not a guessed constant, but the m where the binomial tail crosses your error budget.

The relocalization decision is two thresholds, both derived: k = 35 iterations (from the inlier fraction and the P3P sample size) buys you a clean hypothesis; m ≥ 6 inliers (from the binomial false-accept tail) buys you the right to trust it. Loosen either and you re-admit the teleport.

DESIGN — what a map costs to store

A feature-based map is keyframes, each holding poses, 2-D keypoints, their descriptors, and the 3-D map points they observe. Put real bytes on it:

ItemSize
ORB descriptor32 bytes
Per feature (descriptor + 2-D keypoint + 3-D point)~52 bytes
Keyframe (1000 features + pose)~51 KB
1 km route at 1 keyframe / 0.5 m (2000 keyframes)~104 MB
NetVLAD global descriptor (4096-D, PCA to 256-D)16 KB → 1 KB per keyframe

Work the arithmetic by hand so the numbers are yours. Each ORB feature carries a 32-byte binary descriptor, an 8-byte 2-D keypoint (two floats), and a 12-byte 3-D map point (three floats): 32 + 8 + 12 = 52 bytes. A keyframe with 1000 features plus a 24-byte pose is 1000 × 52 + 24 ≈ 52 KB. A 1 km route at one keyframe every 0.5 m is 2000 keyframes, so 2000 × 52 KB ≈ 104 MB.

Now the lifelong number. An 8-hour shift at 1 m/s covers 8 × 3600 = 28,800 m; keeping one keyframe every 0.5 m is 57,600 keyframes; at 52 KB each that is 57,600 × 52 KB ≈ 3 GB per day. Keep that forever and within a month the map is bigger than RAM, the database query slows to a crawl, and the whole system grinds to a halt. Lifelong SLAM's central problem is not building the map — it is bounding it.

DESIGN — derive the break-even map size

The last paragraph asserted "within a month the query slows to a crawl." Do not assert it — derive the exact map size at which growing the map stops paying for itself. This is a different kind of worked example from the sizing multiplications above: instead of plugging numbers into a formula, we set up the two costs that trade off and solve for where they cross.

Relocalization has a benefit and a cost, and both scale with the map. The benefit is fixed per successful snap: a relocalization saves you the time to rebuild orientation from scratch — call it S, the wall-clock a cold restart would otherwise burn re-exploring until it re-anchors. Put a number on it: S ≈ 0.2 s = 200,000 µs. The cost is the database query itself. A place-recognition query shortlists candidates and geometrically verifies them, and in the regime that bites, that work grows linearly with the number of keyframes M in the map — call it cq · M, where cq is the query cost per keyframe. Put a number on it: cq ≈ 0.5 µs per keyframe.

The map is worth growing only while the query still costs less than the snap saves. The break-even is where they are equal:

cq · M = S  ⇒  M = S / cq

Substitute and turn the crank — every intermediate step:

M = 200,000 µs / 0.5 µs·KF−1 = 400,000 keyframes

Convert that to bytes at 52 KB/keyframe to feel it: 400,000 × 52 KB ≈ 20.8 GB. Past this size, each relocalization query costs more time than the relocalization saves — the map is now a net drag, and no amount of "keep everything, storage is cheap" reasoning survives it. Bounding is not tidiness; it is the point beyond which the feature stops working. (Change the constants and the number moves, but the method — equate the per-query cost to the per-snap saving and solve for M — is what you reproduce at a whiteboard.)

The lifelong-SLAM tension: more keyframes means better coverage and easier relocalization, but unbounded growth means the map eventually costs more to search than it saves. Every lifelong system is a policy for which keyframes to keep and which to discard — and that policy is where the engineering lives. The break-even M above is exactly the ceiling that policy has to hold the map under.

Why is the query cost linear in M in the first place? Because of what the retrieval database actually is, byte for byte. A bag-of-words database is an inverted index: for each visual word in the vocabulary, a posting list of the keyframes that contain it. To answer a query, you extract the query image's words, walk their posting lists, and accumulate a similarity score for every keyframe that shares a word — then verify the top handful geometrically. The accumulation touches every keyframe that shares any common word, and in a lifelong map where the same corridor is re-observed thousands of times, the popular words' posting lists grow with the number of keyframes. So the score-accumulation step is O(M) in the worst case — exactly the cq·M cost model the break-even used. (A vocabulary tree makes the word assignment logarithmic, but the posting-list accumulation is still linear in the matched keyframes; that is the term that eventually dominates.) This is why culling is not optional bookkeeping — every redundant keyframe you fail to drop lengthens the posting lists that every future query has to walk.

Keeping the map bounded

Before applying the rule, derive the two constants in it — the "90%" and the "3 other keyframes" are not arbitrary. Why ≥ 3 other observers, not 1 or 2? A 3-D map point is only usable for localization if it can be triangulated and remains geometrically constrained after a keyframe is removed. Triangulation needs at least 2 views to fix a point's depth; drop a keyframe that a point depends on, and if only 1 other view remains, that point is now under-constrained (a single ray, infinite depth ambiguity). Requiring the point to survive in ≥ 3 other keyframes guarantees that after culling this one, at least 3 views still see it — comfortably above the 2-view triangulation minimum, with one spare against a future cull. So "3" is "the triangulation minimum, plus margin so one more cull is still safe." And why 90%, not 100%? Because demanding every single point be redundant would refuse to cull a keyframe over one straggler point on the field's edge; 90% tolerates a few boundary points while still guaranteeing the keyframe's bulk geometry is preserved elsewhere. The two constants encode "the map point stays triangulable (≥3), and almost all of them do (90%)."

The culling rule is a bullet in every paper and a mystery in most implementations, so make it concrete. Take six keyframes, each with the set of map-point IDs it observes. The interior of a corridor is heavily co-observed; the frontier is not:

KeyframeObserved map-point IDs
KF0{1, 2, 3, 4, 5}
KF1{2, 3, 4, 5, 6}
KF2{3, 4, 5, 6}
KF3{3, 4, 5, 6, 7}
KF4{4, 5, 6, 7, 8}
KF5{20, 21, 22, 23, 24}

Hand-apply the ORB-SLAM rule to KF2: for each of its points, count how many other keyframes also see it, and it is "covered" if that count is ≥ 3.

All 4 of KF2's points are covered: redundancy = 4/4 = 1.00 > 0.9, so KF2 is culled. Now do KF5: its points {20…24} appear in no other keyframe, so 0/5 are covered, redundancy = 0.00 — KF5 stays, because it is the only witness to fresh space. One culling pass on this set drops exactly KF2 and keeps {0, 1, 3, 4, 5}: 6 keyframes become 5, and the survivors still cover every map point. That is the whole mechanism — the map shrinks where it is over-witnessed and never where it is thin.

The from-scratch loop above is the version you should be able to write at a whiteboard, but production code precomputes a co-observation count per map point once and reuses it, so a cull check is a table lookup instead of a nested scan. The same logic, one pass, no inner loop:

python
from collections import Counter

# count, ONCE, how many keyframes observe each map point
obs = Counter(p for pts in kfs.values() for p in pts)

def redundancy(k):
    pts = kfs[k]
    # "others" = total observers minus this keyframe itself (1)
    covered = sum(1 for p in pts if obs[p] - 1 >= 3)
    return covered / len(pts)

cull = [k for k in kfs if redundancy(k) >= 0.9]   # -> [2]

Precomputing obs turns the culling sweep from O(K²·P) — every keyframe against every other — into O(K·P) after one O(K·P) counting pass, which is why real systems maintain the co-observation counts incrementally as keyframes are added rather than recomputing them on each cull.

Culling is destructive — and that is the trap. A dropped keyframe is gone; you cannot un-cull it if next week's viewpoint would have matched it. So the ORB-SLAM rule is deliberately conservative (90% redundancy AND ≥ 3 surviving observers) precisely because a too-eager cull that deletes the only keyframe covering an angle you later approach from will silently degrade future relocalization there. This is the same precision-first asymmetry as false loop closures: the cost of keeping one extra keyframe is a few KB; the cost of dropping a needed one is a relocalization failure you cannot diagnose until you are standing in front of the gap. When in doubt, keep it — map summarisation and marginalisation are the gentler tools for the borderline cases.

The payoff is the bound. Every culling pass removes only over-witnessed interior keyframes, so the surviving count tracks the volume of space, not the hours logged — a robot circling one aisle all day adds keyframes only where coverage was thin, and the map size saturates instead of climbing. That saturation is exactly what the widget below animates.

The appearance-change problem

Day two is not day one. The lighting is different, boxes have moved, a season has passed. A map built in summer may not relocalize in winter with the same descriptors. Two responses:

ApproachIdea
Condition-invariant descriptorsTrain the global descriptor (NetVLAD, and successors) across seasons/times so same-place-different-appearance still matches
Experience maps (Churchill & Newman, 2013)Store multiple appearances of each place ("experiences") and match against whichever one looks like today — the map remembers the place in summer AND winter
Map update / change detectionDetect what changed, update those regions, keep the stable structure

Experience maps sound expensive — "store every appearance" — but the whole trick is that they do not. Put real bytes on one experience so the data flow is concrete. When a place fails to relocalize against its existing experiences, the system saves a new one: a global descriptor for retrieval (NetVLAD PCA-256 ≈ 1 KB) plus a compact bag of local features for the geometric snap — keep 200 ORB features at 52 bytes each = 200 × 52 ≈ 10.16 KB. So one experience is 1 + 10.16 ≈ 11.2 KB: two descriptor tiers, one to find the place and one to pin the pose.

Now the accumulation, and this is where the novelty gate earns its keep. A new experience is saved only when today's image fails to match any stored experience for that place — not on every pass. So a place that looks the same every day never grows past one experience; a place that swings between summer/winter and day/night accumulates a handful. Take a 2,000-place route where each place averages 4 distinct experiences over a year:

2,000 places × 4 experiences × 11.2 KB ≈ 87 MB

Eighty-seven megabytes to be robust across a year of appearance change — trivial. Contrast the naive alternative of storing an experience on every daily visit: 2,000 × 365 × 11.2 KB ≈ 7.8 GB, ninety times larger and almost all of it redundant copies of unchanged places. The novelty gate is the difference between a map that gets more robust and barely bigger, and one that drowns in duplicate appearances — the same "keep only what adds information" principle as keyframe culling, applied to appearance instead of geometry.

Two gates, one idea. Keyframe culling bounds the map in space (drop a keyframe when its geometry is already witnessed); the experience-map novelty gate bounds it in appearance (store a new experience only when today's look is not already remembered). Both keep growth tied to new information, never to elapsed time — and both are the reason lifelong SLAM is possible at all.

Put a number on the saturation the widget below plots, because "it plateaus" is a claim you should be able to size. Suppose the route is a loop of length L = 200 m and the culling policy keeps roughly one surviving keyframe every d = 2 m of distinct track once a region is well-covered. Then the steady-state map size is bounded by the geometry, not the clock:

KFbounded ≈ L / d = 200 m / 2 m = 100 keyframes

One hundred keyframes, forever — whether the robot patrols the loop for one hour or one year, because every lap after the first re-observes points already witnessed by ≥ 3 keyframes, so the redundancy rule culls the new keyframes as fast as they are proposed. Contrast the raw (no-cull) count over an 8-hour shift at 1 m/s with a keyframe every 0.5 m: 8×3600 / 0.5 = 57,600 keyframes, and climbing. The ratio 57,600 / 100 ≈ 576× is the entire value of culling in one number, and it is why the raw curve blows the RAM ceiling while the culled curve flattens. That flattening is what you are about to watch.

Keyframe budget over a lifelong run

A robot patrolling the same route for days. Toggle culling. Without it (raw), the map grows linearly with time and blows the budget. With culling, it saturates once the space is covered — new keyframes only appear where coverage is thin. Watch the map-size line hit the ceiling or plateau.

DEBUG — relocalization silently failing

Symptom: the robot relocalizes fine in the areas it mapped this morning but fails every time it enters the loading bay it mapped last week — it drives in "lost" and rebuilds a fresh, disconnected map. Cause: the loading bay's appearance changed (different pallets, sun through a door), so today's descriptors do not match the week-old keyframes; the retrieval step returns no candidate above threshold. The metric that reveals it: the relocalization success rate per map region — log every relocalization attempt and its outcome, bucketed by location. A region with a collapsing success rate is one whose appearance drifted; that is the signal to update the map there (or add an experience), not to lower the global threshold (which would readmit false matches everywhere else).

DEBUG — the false relocalization (the dangerous failure)

The failure above is loud: the robot knows it is lost. The opposite failure is silent and far worse — a confident wrong relocalization.

Symptom: the robot reports a successful relocalization, teleports its estimated pose across the building to an identical-looking aisle, and then confidently plans a path straight into a rack. Cause: perceptual aliasing — two different places (aisle 3 and aisle 9) share nearly identical appearance, so the global descriptor retrieved the wrong keyframe and the geometric check accepted it on too few inliers. The metric that reveals it: the geometric inlier count of the accepted snap, and a consistency gate against the pre-relocalization prior — a true relocalization lands near where dead-reckoning said the robot was; a teleport contradicts it by tens of metres. Log both. The fix is not to trust the retrieval score; it is to demand the inlier threshold the CONCEPT section derived: m ≥ 6 inliers before you believe the snap, because at that threshold a random alias survives with probability under 1 in 1000. Lowering it to chase a few extra true relocalizations re-admits exactly the teleport that folds the map.

This is why the two DEBUG cases pull in opposite directions and why the threshold is a genuine engineering decision, not a default. The appearance-drift failure tempts you to lower the acceptance bar so week-old keyframes still match; the aliasing failure demands you raise it so look-alike aisles never do. You cannot satisfy both globally — which is exactly why the per-region success-rate log matters: you update the drifted region's appearance (add an experience) rather than weakening the geometric bar that protects the whole map from teleports.

FRONTIER

The systems view of persistent, multi-session mapping was crystallised by Kimera (Rosinol et al., 2020) — a real-time metric-semantic SLAM library with a dedicated loop-closure and pose-graph module built for reuse — and by maplab (Schneider et al., 2018), an open research framework explicitly designed for map merging, re-localization across sessions, and lifelong map maintenance. The learned-relocalization frontier runs from PoseNet (Kendall et al., 2015), which regressed camera pose directly from an image, through modern scene-coordinate-regression methods that predict per-pixel 3-D map coordinates and solve PnP — trading the explicit keyframe database for a network that has memorised the scene.

A robot patrols the same warehouse aisle for eight hours. Without keyframe culling, what determines how fast its map grows?
Your relocalization module accepts any candidate whose geometric check yields ≥ 2 inliers, and it keeps teleporting the robot between two identical-looking aisles. Why does raising the threshold to ≥ 6 inliers fix it, while lowering the retrieval score threshold would not?

Chapter 6: The Bench

Everything so far, on one screen. A robot drives a loop through the warehouse. Its odometry drifts — you set how badly. The map shears exactly like the hook: perfect near the start, a metre off and doubled by the far wall. Then you trigger a loop closure and watch the whole trajectory snap back into a consistent ring, with the end-to-start gap collapsing in real time.

This is the whole lesson in one instrument. Drive up the drift and the shear grows. Fire the loop closure and the residual-spreading law (Chapter 4) bends the entire chain smooth. Read the end-to-start gap before and after — that single number is what a loop closure buys you. The bench is the test — but below we open its hood: the exact pose graph it assembles, the arithmetic of one node’s normal equation, and the one metric that exposes its most seductive failure.
Drift + loop-closure bench

Green dashed = the true loop the robot drove. Warm = raw odometry (drifts and shears). Teal = the optimised trajectory after you close the loop. Set the per-edge drift, press Drive to lay down the odometry, then Close loop to snap it consistent. The readout shows the end-to-start gap collapsing.

per-edge drift 0.11 rad
Set the drift and press Drive.

Push the drift slider to its maximum and drive: the warm ring peels wildly off the green truth, and the seam gap can exceed two metres — the doubled far wall. Close the loop and the teal ring snaps shut, the gap dropping below a couple of centimetres. Notice what does not happen: at extreme drift the teal ring closes but is still visibly rotated from the green truth — consistency without accuracy, exactly Chapter 4's warning made visible.

The bench runs the same pose-graph relaxation kernel you built in the Chapter 4 Code Lab — and the same one the Studio build session drives. Now open the Studio from the dock and build that kernel yourself: the drifted ring on the stage snaps back the instant your normal equations are right. But the bench is not a black box, and this chapter refuses to defer its depth to earlier ones. Below, we assemble by hand the exact graph the canvas above is solving — the same N = 10 ring, the same weights, the same anchor — and read every number it prints.

CONCEPT — why closing one seam bends the whole chain

The odometry the bench lays down is a chain: node x0 is trusted (the robot started there), and every later node is xi+1 = xi + zi, where zi is the measured step for edge i. Each step carries a heading error, and because heading integrates, a constant per-edge bias β makes the heading at edge k equal to k·β — the drift grows edge by edge, exactly what Chapter 1 called the super-linear seam.

A loop closure adds one more edge the graph did not have: a relative measurement from the last node back to the first, x0 − xN−1 = zloop. On its own that is a single equation. What makes it bend the entire ring is that the graph is solved as one weighted least-squares system, not edge by edge. Write the residual of edge k as rk = (xk+1−xk)−zk and weight it by wk = 1/σk2. The optimiser minimises the total Σk wk rk2. When the loop injects a mismatch e at the seam, the optimum spreads that error backward across every edge in inverse proportion to weight — this is the residual-spreading law from Chapter 4:

δk = e · (1/wk) / Σm(1/wm)

A stiff edge (large w, small σ) barely moves; a soft edge absorbs most of the correction. With nine equally-soft odometry edges (wodo = 100) and one very stiff loop edge (wloop = 2500), the correction is shared almost entirely by the odometry chain: each odometry edge takes (1/100) / (9/100 + 1/2500) = 0.01/0.0904 ≈ 11.06% of the closing error, and the loop edge itself takes only (1/2500)/0.0904 ≈ 0.44%. Nine tiny nudges, distributed, unshear the whole ring — that is why the far wall un-doubles even though you only measured one seam.

DESIGN — the rates and weights the bench actually uses

Every weight in the canvas above is a design decision, and the decisions are not arbitrary. Here is the full parameter set, straight out of _c6solve, with the reason each value is what it is.

Constraintσ (m)weight w = 1/σ2Why this stiffness
Prior / anchor on x01×10−31,000,000Pins the global gauge. Relative edges leave position + yaw unobservable (Ch 2); one near-rigid anchor removes the null space so the system is solvable.
Odometry edge xi→xi+10.10100Short-baseline steps are individually decent but accumulate. Soft enough that the chain can flex to absorb the loop correction.
Loop-closure edge xN−1→x00.022500A geometrically-verified place match is far more trustworthy than a single odometry step. Loop weight is 25× the odometry weight, so the seam is treated as near-truth and the chain bends to meet it — not the reverse.
The load-bearing ratio is wloop/wodo = 2500/100 = 25. If you flipped it — made the loop softer than the odometry — the optimiser would trust the drifted chain over the closure and barely move: the ring would stay sheared. The whole effect lives in the loop edge being the stiffest non-anchor constraint in the graph.

Those three weights are not just a stiffness policy — they are the exact numbers that land on the diagonal of Λ, one node at a time, and you can build that diagonal by hand without running anything. The rule is mechanical: a node’s diagonal is the sum of the weights of every edge that touches it, because each edge stamps +w onto both endpoints. Walk the ring node by node, count the edges each touches, and add their weights:

node kedges touching itdiagonal Λkk = Σ edge weights
0 (anchored)anchor prior + odo (0,1) + loop (9,0)1,000,000 + 100 + 2500 = 1,002,600
1odo (0,1) + odo (1,2)100 + 100 = 200
2odo (1,2) + odo (2,3)100 + 100 = 200
3odo (2,3) + odo (3,4)100 + 100 = 200
4odo (3,4) + odo (4,5)100 + 100 = 200
5odo (4,5) + odo (5,6)100 + 100 = 200
6odo (5,6) + odo (6,7)100 + 100 = 200
7odo (6,7) + odo (7,8)100 + 100 = 200
8odo (7,8) + odo (8,9)100 + 100 = 200
9 (seam)odo (8,9) + loop (9,0)100 + 2500 = 2600

Read the diagonal off the last column top to bottom and you have diag(Λ) = [1{,}002{,}600, 200, 200, 200, 200, 200, 200, 200, 200, 2600] — the exact vector Worked example 2 will confirm from the assembler. Three numbers do all the work. Every plain interior node (1 through 8) touches two identical odometry edges, so its diagonal is a flat 200; the drift is invisible here because stiffness lives in the weights, not the measurements. Node 0 is huge because the 106 anchor sits on top of its two edges — that is the gauge-pinning prior refusing to let node 0 move. Node 9 is 2600 because the stiff 2500 loop edge replaces one of its odometry neighbours — that is the seam being held near-rigid so the chain bends to it, not the reverse. The 25× ratio you set in the table above is exactly the difference between node 9’s 2600 and every interior node’s 200.

Worked example 1 — how a 0.11 rad bias becomes a metre of seam

Set the slider to β = 0.11 rad (its default) and drive. Each true step is a chord of the radius-2 ring: with N = 10 nodes the chord length is L = 2·sin(π/10)·2 = 1.236 m. The odometry rotates step i by the accumulated heading error i·β. Integrate the heading edge by edge, no formula, just addition:

head1 = 0.11   head2 = 0.22   head3 = 0.33   head4 = 0.44 rad

In degrees that is 6.3°, 12.6°, 18.9°, 25.2° — already a quarter-turn off by the fourth edge. A step of length L rotated by φ lands its tip 2·L·sin(φ/2) away from where the true step would have put it. Substitute:

Those per-edge displacements do not cancel — they compound in roughly the same direction because the bias is one-signed. Accumulate the actual end-position error and it climbs: 0.14 m after 1 edge, 0.42 m after 3, and by the ninth edge the drifted end sits 2.18 m from the true end. The end-to-start seam the bench reports is gapbefore = 1.802 m — the "metre off, doubled far wall" of the hook, produced entirely by that innocent 0.11 rad per edge.

Worked example 2 — one node’s normal equation, by hand

Now watch the loop pull the seam shut. The bench assembles a symmetric information matrix Λ and vector η where each edge (i, j) of weight w stamps +w onto Λii and Λjj, −w onto Λij and Λji, and pushes ±w·meas into η. Consider one interior node, x5. It touches exactly two odometry edges — (4,5) and (5,6) — so its diagonal accumulates:

Λ55 = wodo + wodo = 100 + 100 = 200

Its row of Λ (x-axis) is therefore […, −100, +200, −100, …] — the two off-diagonal −100 entries are the edges to its neighbours. Contrast the seam node x9: it carries one odometry edge and the stiff loop edge, so Λ9,9 = 100 + 2500 = 2600, and the anchored node x0 gets the prior on top of its two edges: Λ0,0 = 1{,}000{,}000 + 100 + 2500 = 1{,}002{,}600. Running the bench’s own assembler confirms the diagonal exactly:

diag(Λ) = [1{,}002{,}600,  200,  200,  200,  200,  200,  200,  200,  200,  2600]

The loop edge’s −2500 off-diagonal linking node 9 back to node 0 is the single entry that ties the two ends of the open chain together. Solve Λx = η and the seam collapses: at β = 0.11 the bench prints gap 1.802 m → 0.008 m — a 1.802 / 0.008 ≈ 226× tightening. Because the loop is 25× stiffer than any odometry edge, the chain does almost all the moving; the loop residual is driven to nearly zero while nine tiny per-edge nudges spread the correction, exactly as the δk law predicted.

Worked example 3 — read the whole η column of the N=10 ring, node by node

Worked example 2 built the left side of Λx = η — the diagonal. The right side η is just as mechanical, and reading it end to end shows you at a glance which nodes the solver is being pulled hard, and which barely at all. The rule, straight from edge(), is: for edge (i, j) with measurement meas, subtract w·meas from ηi and add w·meas to ηj. Work the x-axis. Take the nine measured x-steps of one drifted drive — call them z0…z8 — the measured x-closure zloop, and the anchor target x0true. For a concrete drive these are:

z = [1.176, 0.363, −0.588, −1.176, −1.176, −0.588, 0.363, 1.176, 1.176]    zloop = −1.902    x0true = 2.000

Every interior node k (that is, 1 through 8) is the j of the edge coming into it and the i of the edge leaving it, so it collects +wodozk−1 and −wodozk — a single tidy formula ηk = wodo(zk−1−zk). Node 0 additionally carries the anchor push wp·x0true and the loop edge’s +wloopzloop; node 9 carries the loop edge’s −wloopzloop. Walk it down the column, doing each subtraction by hand:

node kηk = Σ edge pushesvalue
0 (anchor+loop)wp(2.000) − 100(1.176) + 2500(−1.902)1,995,127.4
1100(1.176 − 0.363)81.3
2100(0.363 − (−0.588))95.1
3100((−0.588) − (−1.176))58.8
4100((−1.176) − (−1.176))0.0
5100((−1.176) − (−0.588))−58.8
6100((−0.588) − 0.363)−95.1
7100(0.363 − 1.176)−81.3
8100(1.176 − 1.176)0.0
9 (seam)100(1.176) − 2500(−1.902)4,872.6

The shape of that column is the physics of the solve. The eight interior entries are all small — between 0 and ±95 — because each is a difference of two nearly-equal odometry steps: wodo(zk−1−zk) is near zero whenever consecutive steps agree, which they mostly do. An interior node is barely being asked to move; it exists to relay the correction along the chain, not to originate it. The two extreme entries are the two constraints shouting. Node 0’s ≈2×106 is the anchor prior wp·2.000 = 2{,}000{,}000 swamping everything else — that is what pins the gauge. Node 9’s 4{,}872.6 is the stiff loop term −2500·(−1.902) = 4755 dominating its lone odometry push of 117.6 — that is the seam yanking node 9 toward the trusted closure. When you divide η through by the diagonal you built in Worked example 2, node 0’s huge η over its huge 106 diagonal gives a modest, pinned position; node 9’s loop-dominated η over its 2600 diagonal snaps it onto the seam; and the near-zero interior entries let those nodes drift into whatever smooth arc the neighbours dictate. Reading the right-hand side alone, before you solve a thing, already tells you the ends will hold and the middle will relax — exactly the bow the ATE table further down will measure.

There is a clean self-check hiding in that column, and it catches the commonest assembly bug. Add up just the eight interior entries: 81.3 + 95.1 + 58.8 + 0 − 58.8 − 95.1 − 81.3 + 0 = 0. They telescope, and they must: Σk=18wodo(zk−1−zk) = wodo(z0−z8), and because this ring closes with equal first and last x-steps (z0 = z8 = 1.176) the interior sum is exactly zero. That is not a coincidence of the numbers — it is the statement that the odometry chain, by itself, applies no net force to the interior; every push into one node is pulled back out of its neighbour. All the net force in the whole system lives in the two boundary entries, node 0 and node 9, which is precisely why those are the only two entries that are large. If you ever assemble η and the interior does not telescope to wodo(z0−zN−2), you have a sign error in one of the edge() pushes — the telescoping identity is a free unit test on your assembler.

Finally, hand this η and the diagonal from Worked example 2 to the solver and watch what the near-zero interior actually does. The dead-reckoning x-positions (cumulative sums of the measured steps) start at 0 and wander with the drift; the anchor insists node 0 belongs at 2.000 instead. Solving Λx = η applies a correction to every node — and the correction column is almost a straight ramp:

node kdead-reckon xksolved xkcorrection applied
00.0002.000+2.000
11.1763.306+2.130
21.5393.799+2.260
30.9513.341+2.390
4−0.2252.295+2.520
5−1.4011.249+2.650
6−1.9890.792+2.781
7−1.6261.285+2.911
8−0.4502.591+3.041
90.7263.897+3.171

The correction climbs almost linearly — roughly +0.130 m per node, from +2.000 at the anchor to +3.171 at the seam. That is the residual-spreading law in its purest visual form on one axis: the near-zero interior η entries mean the interior imposes almost no shape of its own, so the solver is free to interpolate a smooth linear correction between the two things it is told — node 0’s anchored target and node 9’s loop-pinned target. A large interior η would have forced a kink; a near-zero one lets the correction glide. This is exactly why the un-doubled ring comes out smooth rather than creased: the right-hand side is quiet everywhere the geometry does not demand otherwise, and a quiet right-hand side is what a straight-line correction looks like in the arithmetic.

Worked example 4 — solve a reduced Λx = η to numbers, by hand

The ten-node ring is too big to eliminate on paper, but the effect it demonstrates is fully visible in the smallest graph that still has a drifting chain and a loop: three nodes on one axis. This is not a toy analogy — it is a real pose graph with the bench’s exact stamp rule and the bench’s exact 25× stiffness ratio, small enough that every elimination step is arithmetic you can do in the margin.

Put nodes x0, x1, x2 on a line. The robot should end two metres from where it started, but its second odometry step over-reads: it measures z01 = 1.0 and z12 = 1.3 (a 0.3 m drift). Dead reckoning therefore lands node 2 at x2 = 0 + 1.0 + 1.3 = 2.3, and the seam against the trusted loop measurement zloop: x0−x2 = −2.0 is |(0−2.3)−(−2.0)| = 0.300 m — the miniature version of the doubled far wall.

Stamp the three edges with the bench’s rule (wodo = 100, wloop = 2500, anchor wp = 106 on x0). But before we “drop” anything, write the graph out in full — all three unknowns, anchor and all — so the anchor’s job is a number on the page, not a hand-wave. Accumulating each edge’s +w onto the diagonals and −w onto the off-diagonals, adding the anchor prior +106 onto Λ00, and pushing ±w·meas into η (with the anchor pushing +wp·0 = 0 for a target of x0 = 0), the complete 3×3 normal equation is:

[ 1{,}002{,}600  −100  −2500 ] [x0]  =  [ −5100 ]
[   −100     200   −100 ] [x1]     [  −30 ]
[  −2500  −100   2600 ] [x2]     [  5130 ]

Every entry in the anchor row earns its place too. Λ00 = 106 + 100 + 2500 = 1{,}002{,}600 is the anchor prior plus node 0’s two edges (the odometry edge to node 1 and the loop edge back from node 2); the −100 and −2500 off-diagonals are those same two edges reaching to nodes 1 and 2. On the right, η0 = wp·0 − wodoz01 + wloopzloop = 0 − 100(1.0) + 2500(−2.0) = −5100. Now eliminate the anchor row explicitly and watch it collapse the system by hand. Normalise row 0 by its pivot 1{,}002{,}600 to get [1, −100/1{,}002{,}600, −2500/1{,}002{,}600 | −5100/1{,}002{,}600] = [1, −9.974×10−5, −2.494×10−3 | −5.087×10−3]. That last entry already tells you the answer for x0: it is −0.0051 m, pinned within five millimetres of the enforced zero — the anchor did its one job. Now subtract (−100)×row 0 from row 1, and (−2500)×row 0 from row 2, to knock x0 out of both. Row 1’s diagonal becomes 200 − (−100)(−9.974×10−5) = 199.990; its x2 coefficient becomes −100 − (−100)(−2.494×10−3) = −100.249; and row 2 reduces to −100.249 and 2593.766 the same way:

[ 199.990  −100.249 ] [x1]  =  [ −30.509 ]
[−100.249  2593.766 ] [x2]     [ 5117.283 ]

This is the Schur complement of the anchor, and it is almost exactly the tidy 2×2 you would have written if you had simply dropped x0 at the start — the diagonal 199.990 is a whisker below 200, the coupling −100.249 a whisker beyond −100, the right side −30.509 and 5117.283 instead of −30 and 5130. Those tiny differences are the anchor’s residual coupling, each of order (edge weight)2/106 — a fraction of a percent, because the 106 pivot in the denominator crushes them. That is what “the anchor pins x0 = 0 so hard we can drop it” means numerically: eliminating the anchor row leaves the interior system unchanged to three decimals, so the reduced problem below is a legitimate shortcut, not an approximation you have to apologise for. From here we solve the clean reduced form the bench’s interior effectively sees:

The anchor pins x0 = 0 so hard we can drop that unknown and solve for (x1, x2) alone. Accumulating each edge’s +w onto the diagonals and −w onto the off-diagonals, and pushing ±w·meas into η, gives the clean 2×2 reduced normal equation:

[ 200  −100 ] [x1]  =  [ −30 ]
[−100  2600 ] [x2]     [ 5130 ]

Every entry earns its place. The 200 is node 1 touching two odometry edges (100+100); the 2600 is node 2 touching one odometry edge and the stiff loop edge (100+2500); the −100 off-diagonals are the odometry edge between them. On the right, η1 = wodoz01−wodoz12 = 100(1.0)−100(1.3) = −30, and η2 = wodoz12−wloopzloop = 100(1.3)−2500(−2.0) = 5130 — the enormous 5130 is the stiff loop pulling node 2 toward the trusted seam. Now eliminate. The determinant is 200·2600−(−100)(−100) = 520000−10000 = 510000, and Cramer’s rule gives:

x1 = [(−30)(2600)−(−100)(5130)] / 510000 = 435000/510000 = 29/34 = 0.8529 m
x2 = [(200)(5130)−(−100)(−30)] / 510000 = 1023000/510000 = 341/170 = 2.0059 m

If you prefer to eliminate the way the bench’s _solve actually does — Gaussian elimination with normalisation, exactly the code you read in the CODE block — the same two numbers fall out in two steps. Normalise row 1 by its pivot 200 to get [1, −½ | −3/20]. Eliminate x1 from row 2 by subtracting (−100)×row 1 from it: the 2600 becomes 2600−(−100)(−½) = 2550, and the right side becomes 5130−(−100)(−3/20) = 5115. Row 2 is now [0, 2550 | 5115], so x2 = 5115/2550 = 341/170 = 2.0059, and back-substitution gives x1 = −3/20−(−½)(2.0059) = 29/34 = 0.8529. This is not a different method that happens to agree — it is the literal sequence of arithmetic the for loops in _solve execute on this matrix, carried out by hand so you can see the pivoting and elimination the code hides behind two nested loops.

The seam that was 0.300 m open now reads |(x0−x2)−zloop| = |(0−2.0059)−(−2.0)| = 0.0059 m — a 51× collapse from a hand-solved 2×2, the same qualitative event the full bench prints as 1.802 m → 0.008 m. And look at how the correction split. The residual of each edge is (xj−xi)−meas: for edge (0,1) it is (0.8529−0)−1.0 = −0.1471; for edge (1,2) it is (2.0059−0.8529)−1.3 = −0.1471; for the loop edge it is (0−2.0059)−(−2.0) = −0.0059. The two odometry residuals settle at the same value −0.1471 while the loop residual is only −0.0059.

That equality is not luck — it is the δk law in miniature, and you can derive the ratio without solving anything. At the optimum, the weighted residuals of edges meeting at an unconstrained interior node must balance, so the two equal-weight odometry edges are forced to carry equal residual; and the ratio of odometry residual to loop residual is fixed by their weights: rodo/rloop = wloop/wodo = 2500/100 = 25. Check it: 0.1471/0.0059 ≈ 25. The soft edges each moved 25× more than the stiff loop edge — the residual-spreading law of Chapter 4, reproduced exactly by three numbers you can verify without a computer, and predictable from the weights alone before you ever form the matrix.

Lay the three edges side by side and something quietly perfect appears in the last column — the weighted residual w·r is identical on every edge:

edgewmeasmodeled xj−xiresidual rw·r
(0,1) odo1001.0000.8529−0.1471−14.71
(1,2) odo1001.3001.1529−0.1471−14.71
loop (2,0)2500−2.000−2.0059−0.0059−14.71

That the weighted residuals are all equal is not a fluke of these particular numbers — it is a theorem about any single-cycle pose graph. Around one loop the constraints form a chain of “springs,” and at equilibrium a series of springs carries the same tension throughout; here w·r is exactly that tension. The stiff loop spring stretches only 0.0059 m to hold 14.71 units of tension; each soft odometry spring must stretch 25× as far — 0.1471 m — to hold the same tension, because tension is stiffness times stretch and the stiffness ratio is 25. This is the residual-spreading law read as a physical equilibrium, and it is why you can predict the entire correction from the weights before touching the matrix: equal tension, stretch inversely proportional to stiffness.

One last number ties the whole toy together and connects it back to why we solved a least-squares system at all: the total weighted cost. Before the loop, dead reckoning left the two odometry residuals at zero (it trusts its own steps perfectly) and dumped the entire mismatch into the loop edge, whose residual was the full 0.300 m. The cost is χ2 = Σw·r2 = 2500·(0.300)2 = 225.0 — all of it in one edge, a sharp visible kink at the seam. After the optimise, the cost redistributes: 100·(0.1471)2 + 100·(0.1471)2 + 2500·(0.0059)2 = 2.163 + 2.163 + 0.087 = 4.41. The solver found the arrangement that drops total cost from 225.0 to 4.41 — a 51× reduction, the very same factor as the seam collapse, and no accident: minimising χ2 is what “spread the residual” means. And notice the check that proves you are at the true optimum without re-solving: the weighted residuals at the interior node balance exactly, wodor01 = 100·(−0.1471) = −14.71 and wodor12 = −14.71 — equal and opposite forces pulling node 1 in either direction, which is the calculus condition ∂χ2/∂x1 = 0 written as a force balance. When the weighted pushes on an interior node cancel, that node is exactly where least-squares wants it, and you have solved the graph.

That per-edge split is not special to the toy: the full ring does the same thing, nine ways. Take the β = 0.11 seam the bench actually closes — a closing error of e = 1.802 m. The residual-spreading law hands each of the nine odometry edges the identical share (1/wodo)/Σ(1/w) = 0.01/0.0904 = 11.06%, i.e. δ = 0.1106·1.802 = 0.1993 m of correction per edge, and leaves the stiff loop edge only 0.0080 m. Watch the running sum climb to the whole seam:

odometry edge kδk (m)sharerunning Σδ (m)
(0,1)0.199311.06%0.1993
(1,2)0.199311.06%0.3987
(2,3)0.199311.06%0.5980
(3,4)0.199311.06%0.7973
(4,5)0.199311.06%0.9967
(5,6)0.199311.06%1.1960
(6,7)0.199311.06%1.3954
(7,8)0.199311.06%1.5947
(8,9)0.199311.06%1.7940
loop (9,0)0.00800.44%1.8020

Nine equal nudges of 0.1993 m sum to 1.794 m; the loop edge contributes the last 0.008 m; together they account for the entire 1.802 m seam. This is the un-doubling of the far wall written out node by node: no single edge is asked to swallow the whole error, so no single edge visibly kinks — the ring comes out smooth precisely because the correction was spread, not dumped.

Why is every odometry share identical at 11.06%, rather than tapering off toward the anchor the way you might expect drift to? Because the bench gives all nine odometry edges the same weight wodo = 100. The δk law weights each edge’s share by 1/wk, so equal weights force equal shares — the running sum climbs in a straight line, 0.1993 m per step. This is a modelling choice, not a law of nature: if you believed a particular stretch of the drive was rougher (say the robot crossed a threshold strip where the wheels slipped), you would raise that edge’s σ, lower its w, and the residual-spreading law would automatically route more of the correction into it — the running sum would jump at that edge instead of stepping uniformly. The uniform staircase you see here is the signature of a homogeneous-odometry assumption, and the moment you break that assumption the correction profile reshapes itself to match where you said the error actually lived.

CODE — the from-scratch assembler the bench runs

This is the actual kernel behind the canvas above, per axis. No library call does the assembly — the edge() helper stamps the four Λ entries and two η entries that are a pose-graph edge; the anchor is one line; the solve is a dense linear solve. Solve x and y independently because the linearised ring decouples per axis.

python
import numpy as np

def close_loop_axis(rel, loop_meas, anchor, N,
                    sig_odo=0.10, sig_loop=0.02, sig_prior=1e-3):
    Lam = np.zeros((N, N)); eta = np.zeros(N)
    # anchor: pin node 0 to remove the unobservable gauge (Ch 2)
    wp = 1 / sig_prior**2          # = 1_000_000
    Lam[0, 0] += wp; eta[0] += anchor * wp
    def edge(i, j, meas, sig):    # ONE pose-graph edge = 4 Lam + 2 eta stamps
        w = 1 / sig**2
        Lam[i, i] += w; Lam[j, j] += w        # diagonals gain +w
        Lam[i, j] -= w; Lam[j, i] -= w        # off-diagonals gain -w
        eta[i]  -= w * meas; eta[j] += w * meas
    for i in range(N - 1):        # the odometry chain (soft, w=100)
        edge(i, i + 1, rel[i], sig_odo)
    edge(N - 1, 0, loop_meas, sig_loop)  # the ONE loop edge (stiff, w=2500)
    return np.linalg.solve(Lam, eta)      # dense solve; bench uses Gaussian elim

The whole loop-closure effect is the last edge() call. Comment it out and you solve a pure open chain: the anchor holds node 0, but nothing ties node 9 back, so the seam never closes. Add it back and the −w at Λ[N−1, 0] couples the two ends into one relaxable system.

Trace the assembler by hand — two full Λ rows from the edge() stamps

The code above is short enough to run in your head for the first and last odometry edges and watch two complete rows of Λ assemble. Nothing here is hidden inside a library; the four Lam[…] += lines in edge() are the entire mechanism. Start with Λ all zeros, then apply the anchor line and the two odometry edges that write into rows 0, 1, 8 and 9.

Edge (0,1), w = 100. The helper runs Lam[0,0] += 100; Lam[1,1] += 100; Lam[0,1] −= 100; Lam[1,0] −= 100. That is one edge’s complete footprint: two positive diagonal stamps and two negative off-diagonal stamps, all of magnitude w. Row 1 has now received its first contribution — a +100 on its own diagonal and a −100 in column 0.

Edge (1,2), w = 100. The next loop iteration stamps Lam[1,1] += 100 (row 1’s diagonal is now 100+100 = 200) and Lam[1,2] −= 100. Node 1 is a plain interior node touching exactly two odometry edges, so its finished row of Λ is:

row 1 = [−100, +200, −100, 0, 0, 0, 0, 0, 0, 0]

The two −100 entries sit in columns 0 and 2 — its neighbours — and the diagonal is the sum of the two edge weights it participates in. This is the canonical interior-node stencil of a chain: […,−w, +2w, −w,…]. Every one of nodes 1 through 8 has exactly this shape, shifted along the diagonal.

Edge (8,9), w = 100. Now the seam. This last odometry edge stamps Lam[8,8] += 100; Lam[9,9] += 100; Lam[8,9] −= 100; Lam[9,8] −= 100. But node 9 is not interior — it also receives the loop edge edge(9,0,…,sig_loop), whose w = 2500 stamps Lam[9,9] += 2500, Lam[9,0] −= 2500, and Lam[0,9] −= 2500. So node 9’s diagonal finishes at 100 + 2500 = 2600, and its row carries a second, far-away off-diagonal:

row 9 = [−2500, 0, 0, 0, 0, 0, 0, 0, −100, +2600]

That single −2500 in column 0 is the loop closure made concrete: it is the only entry in the entire matrix that reaches across the open chain to couple the last node to the first. Delete the final edge() call and this entry vanishes — row 9 collapses back to the interior stencil […,−100, +100], node 9 is tied only to node 8, and the ring can no longer close. Running the bench’s own assembler and reading its rows back confirms both by-hand rows to the digit, and the full diagonal is exactly the diag(Λ) = [1{,}002{,}600, 200, 200, 200, 200, 200, 200, 200, 200, 2600] printed in Worked example 2. Two edges, four stamps each, and you have reconstructed the two most interesting rows of the matrix the canvas is solving — no black box remains.

The right-hand side η assembles from the same two edges, using the other two lines of edge(): eta[i] −= w·meas and eta[j] += w·meas. Trace node 9’s entry the way we traced its Λ row. Edge (8,9) contributes +wodo·z89 to eta[9] (node 9 is the j of that edge), and the loop edge (9,0) contributes −wloop·zloop to eta[9] (node 9 is the i of that edge). So eta9 = wodoz89 − wloopzloop — and because wloop = 25·wodo, the loop term dominates the sum, which is precisely why node 9 is pulled hard toward the trusted seam rather than toward its drifted odometry neighbour. Node 1, being purely interior, gets eta1 = wodoz01 − wodoz12 — a small difference of two nearly equal odometry steps, near zero, which is why interior nodes barely move except to relay the correction along the chain. The structure you assembled by hand — a stiff, loop-dominated right side at the seam and a near-zero right side in the interior — is exactly the structure that makes the ring bend to meet the closure instead of dragging the closure toward the drift.

DEBUG — consistency without accuracy

Symptom: push the slider to its maximum (β = 0.22) and close the loop. The teal ring snaps shut — the readout reports the seam gap dropping to ~0.012 m, apparently a clean success — yet the ring is visibly rotated off the green truth. The map is self-consistent and completely wrong. Cause: a loop closure is a relative constraint. It fixes internal consistency (the drift, which is observable) but says nothing about the global rotation/position gauge, which relative measurements alone cannot pin (Ch 2). The near-rigid anchor fixes node 0’s position but not the whole ring’s orientation, so at extreme drift the consistent solution is a rotated one. The metric that reveals it: the seam-gap readout will lie to you here — it reads ~0 for both the good and the bad case. The distinguishing signal is absolute trajectory error (mean node distance from truth), which the gap metric cannot see. Measured against ground truth the bench shows ATE climbing with drift even as the gap stays pinned near zero: ATE = 0.33 m at β=0.03, 1.16 m at β=0.11, 2.03 m at β=0.22 — gap flat at ~0, ATE unbounded. Transferable tell: a seam gap near zero with a large ground-truth ATE means the loop closed a consistent-but-mis-gauged map — you need an absolute reference (GPS/known landmark/prior map), not a tighter loop.

That divergence is the whole point, so it is worth seeing rather than reading. The companion widget below plots the two metrics the bench computes at three drift levels — the seam gap after closure (what the readout shows you) against the absolute trajectory error against ground truth (what the readout cannot show you). Drag the drift and watch the seam-gap bar stay pinned at the floor while the ATE bar climbs unbounded. The two bars separating is the failure made visible: a loop closure buys you the left bar, never the right.

Read the three anchor points the widget interpolates between. At gentle drift β = 0.03 the closed seam is 0.002 m and the ATE is already 0.33 m — a ratio near 165×: even here the gap under-reports the true error by more than two orders of magnitude. At the default β = 0.11 the seam holds at 0.008 m while ATE more than triples to 1.16 m. At the maximum β = 0.22 the seam is still a tidy 0.012 m but ATE reaches 2.03 m — the ring is nearly its own diameter away from the truth, yet the readout you would ship to a dashboard reads “closed, ~1 cm.” The seam metric is flat across the whole slider; the ATE metric is monotone in drift. A monitor watching only the first would report a healthy map at every drift level — the exact blind spot the DEBUG callout above names, now plotted so you can watch the trusted number lie in real time.

The 0.33 m ATE at β = 0.03 is not a headline number the widget hands you — it is the mean of ten per-node distances, and it is worth computing by hand so you see where the map is wrong even when the seam says it is fine. Solve the ring at β = 0.03, then for each node measure the straight-line distance ‖xkopt−xktruth between the optimised pose and where the robot actually was. Two nodes are pinned: node 0 sits on the near-rigid anchor, and node 9 is dragged onto the seam by the stiff loop edge — both are essentially exact. Everything in between is free to bow, and the bow is worst on the far side of the ring, exactly opposite the two pinned ends:

node krole‖xkopt−xktruth (m)running Σ (m)
0anchored0.0000.000
1interior0.0940.094
2interior0.2300.324
3interior0.3870.711
4interior0.5311.242
5far side0.6231.865
6far side0.6322.497
7interior0.5333.030
8interior0.3193.349
9seam (loop-pinned)0.0033.352

Sum the ten distances — 3.352 m — and divide by 10: ATE = 0.335 m, the 0.33 m the DEBUG callout quotes, now unpacked. Notice the shape: 0 at the anchor, climbing to a maximum of 0.632 m at node 6 — the far wall, half a ring from both pinned ends — then falling back toward 0 at the loop-pinned seam. This is a bow, not a step: the two constraints hold the ends and the middle sags between them like a loaded beam. And here is the trap laid bare — at this same β = 0.03 the seam gap the readout shows is 0.002 m, so the ratio of true error to reported error is 0.335 / 0.002 ≈ 165×. The one node the readout can see (the seam) is the one node that is correct; the six nodes it cannot see carry more than half a metre of error apiece. That is precisely why a seam-gap dashboard is blind to a mis-gauged map.

The bow is worth one more look, because its shape is diagnostic, not just its height. The peak error 0.632 m at node 6 is 1.89× the mean — a beam pinned at both ends and loaded uniformly deflects most in the middle, and the ATE profile does exactly that, cresting between the two pins and decaying toward each. That single fact tells you where to look for the worst pose without computing anything: it is always the graph node furthest in edges from every hard constraint. Add a second loop closure — say node 4 also sees a landmark it saw earlier — and you introduce a third pin; the single long bow splits into two shorter, shallower bows, and the peak ATE roughly halves because no node is now more than a quarter-ring from a constraint. This is the design lever hiding behind the failure: the mis-gauge does not come from the loop being weak, it comes from there being too few absolute-or-verified constraints spread around the graph, so the unconstrained span between them is long enough to sag. The fix a real system reaches for is not a stiffer loop — the loop is already 25× the odometry — but more pins: additional verified place matches, a GPS fix, a known landmark, each one shortening the longest unconstrained span and flattening the bow it would otherwise carry.

The metric that lies vs the metric that tells — gap and ATE across drift

Teal = post-closure seam gap (near zero at every drift — the readout is happy). Red = absolute trajectory error vs the green truth (climbs with drift — the map is getting worse). When the red bar towers over the teal one, the loop closed a consistent-but-mis-gauged map.

per-edge drift 0.11 rad
 

FRONTIER

The pose-graph relaxation the bench runs is the classical backbone, and the reference synthesis is Grisetti, Kümmerle, Stachniss & Burgard, “A Tutorial on Graph-Based SLAM” (2010), which lays out exactly this Λ/η assembly and the intuition that a loop closure is one extra edge whose error spreads over the graph. The general-purpose solver that made it practical at scale is Kümmerle et al., “g2o: A General Framework for Graph Optimization” (2011) — the same sparse Gauss-Newton machinery, generalised over arbitrary variable and edge types, and still a default backend today. The modern move is from batch relaxation to incremental re-optimisation (iSAM2 / the Bayes tree), which re-eliminates only the cliques a new loop actually touches instead of re-solving the whole ring — the difference between the bench’s dense solve and what runs online on a robot mapping for hours.

On the bench at maximum drift you close the loop: the teal ring is self-consistent (seam gap reads ~0) yet visibly rotated off the green truth. Why does closing the loop fail to make it accurate?

Chapter 7: Field Guide

Your robot's map sheared a metre by the far wall and doubled the loading bay; you have thirty seconds before standup to name the cause and the metric that proves it. This chapter is that lookup — everything from this lesson compressed into tables you can scan on the job, with the load-bearing numbers worked out by hand so you can reproduce them at a whiteboard rather than recall them. Scan the tables to find the fact; drop into the worked derivations below when you need to show the fact, not just state it.

Cheat sheet — the load-bearing facts

IdeaThe one-liner
DriftAccumulation of small errors in relative measurements; grows super-linearly because heading feeds position
ObservabilityNull space of Λ = ATWA; the global gauge (position, yaw) is unobservable from relative measurements alone
Loop closureA relative constraint linking far-apart poses; fixes drift (observable), not the gauge (needs an anchor)
Bag of wordsQuantise features to a vocabulary → histogram; compare by TF-IDF-weighted cosine
idflog(N / df); a word in every image gets idf = 0 — it carries no place information
Geometric verificationRANSAC: sample minimal set, fit transform, count inliers; accept if inliers > threshold
RANSAC iterationsk = log(1−P) / log(1−ws); small s ⇒ few iterations even at high outlier rates
False-loop asymmetryMissed loop = a little drift; false loop = folded map. Be precision-first.
Residual spreadingδk = e·σk2 / Σσm2; loop error spreads inversely to weight
Consistency ≠ accuracyA loop closure makes the map self-consistent; accuracy is bounded by observability
RelocalizationLoop closure against a saved map; solves the kidnapped-robot / resume-after-reboot problem
Lifelong boundCull redundant keyframes so map size tracks space, not time

The numbers, worked by hand

A cheat-sheet formula you can only recite is a liability at the whiteboard. Here are the load-bearing formulas from the table above, each substituted with real values and carried to a number — every intermediate line visible, so you can reproduce them cold. The first three isolate one idea each; the fourth solves an entire loop closure end-to-end so you can see them work together.

1 — RANSAC iteration budget. How many random samples k do you need so that, with probability P, at least one sample is all-inliers? With inlier ratio w and minimal sample size s:
k = log(1 − P) / log(1 − ws)
Take a 2-D rigid fit (s = 2), an even split of good and bad matches (w = 0.5), and 99% confidence (P = 0.99):
  • ws = 0.52 = 0.25, so 1 − ws = 0.75
  • log(1 − P) = log(0.01) = −4.6052
  • log(0.75) = −0.28768
  • k = −4.6052 / −0.28768 = 16.01 → 17 iterations (round up)
The lever is s, not w. Push the outlier rate to 70% (w = 0.3) and you need only 49 iterations; but raise the sample to s = 3 at w = 0.5 and the same budget jumps to 35 — doubling the minimal set roughly doubles the cost. Fit the smallest model geometry allows.
2 — idf, and why the ubiquitous word is worthless. A visual word's inverse document frequency is idf = log(N / df), where N is the number of mapped places and df is how many of them contain the word. But idf alone is only half the story — what actually enters the score is the TF-IDF term, tf · idf, so let's carry three words all the way to that product. Say each is seen a few times in a query image of 50 total features (so tf = count / 50):
  • Ubiquitous word — appears in every place. N = 4, df = 4: idf = log(4/4) = log(1) = 0. Seen 3×, so tf = 3/50 = 0.060. TF-IDF term = 0.060 · 0 = 0.000. Its whole term collapses to zero no matter how often you see it: it can never move the ranking.
  • Middling word — in 10 of 1000 places. idf = log(1000/10) = log(100) = 4.605. Seen 3×: tf = 3/50 = 0.060, TF-IDF term = 0.060 · 4.605 = 0.276. A real but modest vote.
  • Rare landmark word — in 1 of 1000 places. idf = log(1000/1) = 6.908 (natural log). Seen just once: tf = 1/50 = 0.020, TF-IDF term = 0.020 · 6.908 = 0.138 — a single sighting of a rare word (0.138) already outweighs three sightings of the ubiquitous one (0.000) and rivals the middling word. That is the discriminative signal place recognition runs on.
This is the whole reason the histogram is idf-weighted before the cosine: it silences the words every corridor shares (their term is exactly zero) and amplifies the ones a single place owns.
3 — residual spreading. When you close a loop the seam error e is not dumped on one edge — the optimiser spreads it across the chain in proportion to each edge's variance (inversely to its weight/confidence):
δk = e · σk2 / Σm σm2
Spread a 1.0 rad seam over three edges with variances σ2 = [0.01, 0.04, 0.01]. The denominator is 0.01 + 0.04 + 0.01 = 0.06:
  • δ0 = 1.0 · 0.01 / 0.06 = 0.167 rad
  • δ1 = 1.0 · 0.04 / 0.06 = 0.667 rad
  • δ2 = 1.0 · 0.01 / 0.06 = 0.167 rad
The corrections sum to 0.167 + 0.667 + 0.167 = 1.000 rad — the whole seam is absorbed, and the loosest edge (the one you trusted least) takes the lion's share. That is why a loop closure bends the entire trajectory smooth instead of kinking one joint.
4 — a whole loop closure solved by hand. The three formulas above each isolate one idea; here is the end-to-end solve that ties them together — a four-pose chain on a line, drifted, with one loop-closure edge, taken all the way to corrected coordinates. Poses are x0x3; three odometry edges each measure “step forward by 1” (unit weight); a verified loop closure says the far end is really only 2.7 back from the start, not the 3.0 odometry dead-reckoned. We pin the gauge by fixing x0 = 0 (the anchor from the null-space story below), leaving three unknowns.
  • The drift, before we touch it. Dead-reckoning gives x = [0, 1, 2, 3]. The loop says the end-to-start span should be 2.7, so the raw end-to-start gap = 3.0 − 2.7 = 0.30. That 0.30 is the seam we must close.
  • Assemble Λ. Each edge (a→b) drops +w on the diagonal of a and b and −w on the off-diagonal (a,b) — that off-diagonal −w is the edge. Odometry edges have w = 1; we trust the verified loop three times as much, wL = 3. Because x0 is pinned, its row/column drop out and the loop’s −wL to x0 folds into the anchor, leaving a 3×3 system over [x1, x2, x3]:
Λ = [ [ 2, −1,  0], [−1,  2, −1], [ 0, −1,  4] ]    η = [0, 0, 9.1]T
The lone 4 in the bottom-right is 1 (the x2→x3 odometry edge) + 3 (the loop weight) — the loop-closure edge landing on the far pose. Solve Λx = η (forward-eliminate the tridiagonal by hand):
  • Row-reduce: from rows 1–2, x1 = x2/2 and x2 = 2x3/3, so the last row 4x3x2 = 9.1 becomes (4 − 2/3)x3 = 9.1 ⇒ x3 = 2.73.
  • Back-substitute: x2 = 1.82, x1 = 0.91. Corrected poses: [0, 0.91, 1.82, 2.73] — every odometry step shrank uniformly from 1.0 to 0.91.
  • The gap, after. End-to-start span is now 2.73, so the gap = 2.73 − 2.7 = 0.03 — the residual the loop can’t remove because odometry still gets a vote.
The seam shrank from 0.30 to 0.03 — exactly one order of magnitude — and it did so by bending every edge (the residual-spreading law of example 3), not by snapping one joint. Trust the loop more (raise wL) and the gap keeps shrinking toward zero; that trade — how hard to pull on the loop versus the odometry chain — is exactly what a robust back-end tunes.
5 — the score margin that flags a perceptual alias, every cosine line shown. Before you trust a loop-closure candidate you check how much it won by: the gap between the best place score and the runner-up. This is the number behind the “top-1 vs top-2 margin” debug row — and it’s the one place people wave their hands and say “the cosine came out 0.7,” so here is that cosine built from raw counts, term by term, nothing skipped.

Take a four-word vocabulary — [w0 (ubiquitous), wA, wB, wC] — over N = 3 mapped places. Raw word counts (a histogram per place):

  • Place A = [6, 5, 0, 0]   Place B = [6, 9, 3, 4]   Place C = [6, 0, 0, 5]. Corridor B is the near-twin: it shares A’s landmark word wA (9 sightings) and a little of C’s wC.

Step 1 — document frequency and idf. Count how many of the three places contain each word: df = [3, 2, 1, 2] (w0 in all three, wA in A&B, wB only in B, wC in B&C). Then idf = log(3 / df):

idf = [ log(3/3), log(3/2), log(3/1), log(3/2) ] = [ 0, 0.405, 1.099, 0.405 ]

The ubiquitous word’s idf is 0 exactly (it drops out); the singleton wB keeps the full log(3/1) = 1.099.

Step 2 — tf·idf, then L2-normalise each place to a unit direction (so cosine compares directions, not volume). For place B: sum = 6+9+3+4 = 22, tf = [0.273, 0.409, 0.136, 0.182], tf·idf = [0, 0.166, 0.150, 0.074], its norm √(0.166²+0.150²+0.074²) = 0.235, so the unit vector is:

  • Â = [0, 1.000, 0, 0]  (A’s tf·idf mass, once w0 is zeroed, sits entirely on wA)
  • = [0, 0.705, 0.637, 0.313]
  • = [0, 0, 0, 1.000]

Step 3 — the query, and the cosines as dot products. A query taken in corridor A has the same histogram as A, so its unit vector is = [0, 1.000, 0, 0]. Cosine = q̂ · placê, summed component by component:

  • score(A) = 0·0 + 1.000·1.000 + 0·0 + 0·0 = 1.000
  • score(B) = 0·0 + 1.000·0.705 + 0·0.637 + 0·0.313 = 0.705 (≈ 0.704)
  • score(C) = 0·0 + 1.000·0 + 0·0 + 0·1.000 = 0.000
  • Margin = 1.000 − 0.705 = 0.295 — the twin corridor scored almost as high. That thin margin is the alias alarm: accept this and you may fold two different corridors onto each other.

Contrast a query in the distinct room C, unit vector q̂ = [0, 0, 0, 1.000]:

  • score(C) = 0+0+0+1.000·1.000 = 1.000; score(B) = 0+0+0+1.000·0.313 = 0.313 (≈ 0.316); score(A) = 0+0+0+1.000·0 = 0.000.
  • Margin = 1.000 − 0.313 = 0.687 — more than double the aliased case. That is a candidate geometric verification will happily confirm.
The lesson: a high top-1 score is not enough; it’s the margin that separates a confident match from a coin-flip between two corridors that look alike — and every digit of it traces back to which words each place uniquely owns. Gate loop closures on the margin, then let RANSAC (example 4’s kernel) settle the survivors.

From scratch: why null(Λ) is the all-ones gauge

The cheat sheet asserts that the global gauge is unobservable. Here is the three-pose derivation that proves it — small enough to do on a napkin. Take three poses on a line, x0, x1, x2, and two relative (odometry) measurements, each residual r = xk+1 − xk − u. The Jacobian rows are the derivatives of those residuals w.r.t. the three poses:

A = [ [−1,  1,  0], [ 0, −1,  1] ]

With unit weights, the information matrix is Λ = ATA. Compute it entry by entry (column i of A dotted with column j):

Λ = ATA = [ [ 1, −1,  0], [−1,  2, −1], [ 0, −1,  1] ]

That is the path graph Laplacian. Now multiply it by the all-ones vector 1 = [1, 1, 1]T — each row sums to zero:

Λ · 1 = [1−1+0, −1+2−1, 0−1+1]T = [0, 0, 0]T

The eigenvalues are 0, 1, 3 — exactly one zero, so rank(Λ) = 2 = n − 1. The zero-eigenvalue direction is the all-ones vector: slide every pose by the same amount and no relative measurement changes, so no relative measurement (odometry or loop closure) can ever pin it down. That unobservable direction is the global position gauge; only an absolute constraint — a GPS fix, a prior, or fixing pose 0 — adds the missing rank and removes it. This is the algebraic fingerprint behind the whole "consistency ≠ accuracy" story.

System-design patterns

Put one budget in numbers before you draw a box. A concrete example of the first pattern below: the tracking front-end must hold a fixed 30 Hz (a 33 ms per-frame budget) or the estimator falls behind the robot. Place recognition + geometric verification costs tens of milliseconds and is bursty, so it runs on a separate thread at 1–5 Hz and hands the backend a single loop-closure factor only when verification passes. At 30 Hz vs 3 Hz that is a 10:1 rate split: the loop-closure thread may spend ~300 ms deliberating over a candidate without ever stealing a millisecond from the 33 ms tracking budget. The arrow from the loop thread to the backend carries one relative-pose factor (a 6-DoF mean + 6×6 information block, on the order of a hundred bytes) — tiny, infrequent, and off the critical path by construction.
SituationPattern
Real-time SLAM, loop closure must not stall trackingPlace recognition + verification in a separate thread; feed the backend a factor only when confident; 1–5 Hz off the critical path
Large graph, frequent loopsiSAM2 / Bayes tree incremental re-optimisation, not batch; re-eliminate only affected cliques
A fraction of false loops will slip throughRobust back-end: switchable constraints / dynamic covariance scaling let the optimizer disable a bad factor
Severe appearance change (day/night, seasons)Learned global descriptors (NetVLAD-class) for retrieval; experience maps to store multiple appearances
Vision-only, absolute scale mattersAdd a metric input (IMU/stereo); a loop can close with the wrong scale otherwise
Persistent multi-session mapKeyframe culling + map summarisation to bound size; relocalization to re-anchor each session
Put the memory budget in numbers too. The last two rows — culling and lifelong operation — are also a back-of-envelope, and the number decides the architecture. Size one keyframe: a 6-DoF pose (6×4 = 24 B) + ~1000 ORB descriptors (32 B each = 32 KB) + a couple of KB of ids/covisibility/timestamps ≈ 33 KB.
  • Size tracks time (the bug). Add one keyframe a second over an 8-hour shift: 8×3600 = 28,800 keyframes × 33 KB ≈ 980 MB, and climbing every shift — the “queries slow over hours” debug row made concrete.
  • Size tracks space (the fix). Cull to ~1 keyframe per metre and a 500 m warehouse loop needs only 500 keyframes ≈ 17 MB, and revisiting the same aisles adds nothing — the map saturates. That is a ~58× difference from one policy change.
  • What crosses the thread boundary. The loop thread hands the backend one factor: a 6-DoF mean (6 floats) + the upper triangle of a 6×6 information matrix (21 floats) = 27×4 = 108 bytes, a few times a second. The tracking loop, meanwhile, holds its 33 ms (30 Hz) budget while the loop thread luxuriates in 333 ms (3 Hz). The expensive part is off the critical path and the cheap part is all that ever touches it.
Interview move: quote the ratio (58×), not the vibe (“culling saves memory”). A number turns a hand-wave into a design decision.

Coding drills

Do these from a blank editor, numpy only, no peeking. Each maps to a Code Lab in this lesson.

Drill prompts are not enough — here is the first one written out in full, the TF-IDF place-recognition kernel from a blank editor. Read it and you should be able to reproduce it; the whole thing is six load-bearing lines plus the idf that silences the ubiquitous word.

python
import numpy as np

def best_place(db, query):
    # db: (N_places, V) integer word counts; query: (V,) word counts.
    N = db.shape[0]
    df = np.count_nonzero(db, axis=0)          # places containing each word
    idf = np.log(N / np.maximum(df, 1))       # ubiquitous word (df==N) -> log(1)=0
    Q = query * idf                             # idf-weight, then
    Q = Q / (np.linalg.norm(Q) + 1e-9)          # L2-normalise the query
    best_i, best_s = -1, -1.0
    for i in range(N):
        P = db[i] * idf
        P = P / (np.linalg.norm(P) + 1e-9)
        s = float(Q @ P)                        # cosine similarity
        if s > best_s: best_i, best_s = i, s
    return best_i, best_s

# A word in EVERY place has df==N, so its idf is log(N/N)=0 and it
# drops out of both P and Q — it can never move the ranking. QED.

What to say while writing it: "The count_nonzero along axis=0 is the document frequency; the log(N/df) is what makes a corridor-wide word contribute zero. I normalise after weighting so the cosine compares directions in idf space, not raw counts — a place I merely saw more features in should not win on volume alone." State the invariant (here, idf = 0 for the ubiquitous word) and let it fall out of the arithmetic.

Here is the second drill written out the same way — the RANSAC inlier-count kernel, the geometric-verification step that turns a place-recognition candidate into a trusted loop-closure factor. Fit a 2-D rigid transform from a minimal sample of two correspondences (rotation is a single angle, translation follows from one anchor point), map all the points, and count how many agree. A true loop lands a large count; a random pairing scatters.

python
import numpy as np

def count_inliers(src, dst, thresh):
    # src, dst: (M,2) putative correspondences between two views.
    # Fit a 2-D rigid transform from a MINIMAL sample (s=2), count agreers.
    M = src.shape[0]
    i, j = np.random.choice(M, 2, replace=False)   # the minimal sample
    ds = src[j] - src[i]                        # baseline in view A
    dd = dst[j] - dst[i]                        # baseline in view B
    ang = np.arctan2(dd[1], dd[0]) - np.arctan2(ds[1], ds[0])
    c, s = np.cos(ang), np.sin(ang)
    R = np.array([[c, -s], [s, c]])              # rotation from the angle diff
    t = dst[i] - R @ src[i]                     # translation from one anchor
    pred = src @ R.T + t                        # map ALL src points
    err = np.linalg.norm(pred - dst, axis=1)     # per-point disagreement
    return int((err < thresh).sum())            # the inlier count

# Wrap in a RANSAC loop: keep the best count over k samples (k from drill 5),
# accept the loop only if best > a threshold. A true 30-deg loop returns ~20/40;
# a shuffled (false) pairing returns ~2/40 — the count is the discriminator.

What to say while writing it: "Two points fix a rigid transform because rotation is one angle and translation is one vector — that is the whole reason s = 2 here, and why the iteration budget from drill 5 stays tiny. I fit from the minimal sample and score against everyone: the geometry either explains the other matches or it doesn't, and the count is a hard, cheap yes/no that no appearance descriptor can fake." That count is precisely the “inliers > threshold” test on the cheat sheet, and its precision-first bias is why a false loop — the expensive failure — almost never survives it.

And the observability drill, written out — the code that demonstrates the null-space derivation from earlier in the panel. Build Λ for a relative-only chain, and show its rank is n − 1 and its one null direction is the all-ones gauge. This is the check you run when a map is self-consistent but drifting off an external reference:

python
import numpy as np

def observability_gauge(edges, n):
    # edges: list of (a, b) RELATIVE measurements over n poses.
    A = np.zeros((len(edges), n))
    for r, (a, b) in enumerate(edges):
        A[r, b] = 1.0; A[r, a] = -1.0       # d(residual)/d(pose)
    Lam = A.T @ A                               # information matrix
    rank = np.linalg.matrix_rank(Lam)         # -> n-1 for a relative-only chain
    w, V = np.linalg.eigh(Lam)               # eigendecomposition
    null = V[:, np.argmin(np.abs(w))]        # eigenvector of the ~0 eigenvalue
    return Lam, rank, null / null[0]         # normalise -> [1,1,...,1]

# A 4-pose relative chain: rank is 3 (= n-1) and the null vector is
# [1,1,1,1] — slide every pose equally and no measurement changes. The
# gauge is unobservable until an ABSOLUTE constraint adds the missing rank.

What to say while writing it: "The rank deficiency is not a bug in my data — it is the physics. Relative measurements see differences of poses, so a global shift lives in the null space by construction. I don’t fight it with more loop closures (they’re relative too); I add one absolute factor — a prior, a GPS fix, or pinning pose 0 — and the rank fills in." This is the coded twin of the by-hand derivation two sections up, and the reason a perfectly consistent map can still sit rotated off the truth.

Now the fourth drill — the pose-graph loop closure itself, the coded twin of worked example 4. Assemble Λ and η for a drifted pose chain with one loop-closure edge, pin the gauge, solve, and watch the end-to-start gap shrink by an order of magnitude. This is the whole back-end in twelve lines; the off-diagonal −w is the edge, and the anchor is what stops the null-space gauge from floating away.

python
import numpy as np

def close_loop(n, odom, loop, w_odom=1.0, w_loop=3.0):
    # n poses on a line; odom: list of (a,b,measurement) unit steps;
    # loop: (a, b, measurement) verified long-range constraint.
    Lam = np.zeros((n, n)); eta = np.zeros(n)
    def add(a, b, u, wt):
        Lam[a,a]+=wt; Lam[b,b]+=wt; Lam[a,b]-=wt; Lam[b,a]-=wt   # -wt off-diag = the edge
        eta[a]-=wt*u; eta[b]+=wt*u
    for a,b,u in odom: add(a, b, u, w_odom)
    add(*loop, w_loop)                                 # trust the loop 3x the odometry
    # pin the gauge: fix pose 0 = 0, drop its row/column
    L = Lam[1:, 1:]; e = eta[1:] - Lam[1:, 0]*0.0
    x = np.linalg.solve(L, e)                        # the corrected poses x1..x_{n-1}
    return np.concatenate([[0.0], x])

# 4 poses, 3 unit-step odom edges, one loop saying end-to-start is really 2.7:
# x = close_loop(4, [(0,1,1),(1,2,1),(2,3,1)], (0,3,2.7))
# Lambda over x1,x2,x3 = [[2,-1,0],[-1,2,-1],[0,-1,4]], eta = [0,0,9.1];
# solves to [0, 0.91, 1.82, 2.73] -> end-to-start gap 0.30 shrinks to 0.03.

What to say while writing it: "The add helper is the entire grammar of a pose graph — each edge deposits +w on two diagonal entries and -w on the pair that connects them, and that -w off-diagonal is literally the drawn edge. Odometry gets w = 1; the verified loop gets w = 3, so the bottom-right entry becomes 1 + 3 = 4 — the loop landing on the far pose. Then I pin pose 0 before solving, because a relative-only Λ is rank-deficient (drill 4’s twin above) and would leave the gauge floating." The seam collapsing from 0.30 to 0.03 — one order of magnitude — is the residual-spreading law of example 3 doing its work across every edge at once.

And the fifth drill — the RANSAC iteration budget, the closed form from example 1 turned into the two lines you’d actually write. Given an outlier ratio and a minimal sample size, return how many random samples you need for 99% confidence, and confirm the lever is the sample size s, not the outlier rate.

python
import numpy as np

def ransac_iters(outlier_ratio, s, P=0.99):
    # outlier_ratio: fraction of bad matches; s: minimal sample size;
    # P: probability we want at least one all-inlier sample.
    w = 1.0 - outlier_ratio                          # inlier ratio
    p_allgood = w ** s                              # chance one sample is all inliers
    if p_allgood >= 1.0: return 1                     # guard: no outliers at all
    k = np.log(1.0 - P) / np.log(1.0 - p_allgood)  # the closed form
    return int(np.ceil(k))                          # always round UP

# A 2-D rigid fit (s=2) at a 50/50 in/outlier split needs ceil(16.0) = 17.
# Push outliers to 70% (w=0.3, s=2): 49. But raise s to 3 at the same 50/50: 35 --
# doubling the minimal set roughly doubles the budget. Fit the SMALLEST model.

What to say while writing it: "This is the number an interviewer pokes at — ‘matches are 70% outliers, how many iterations?’ The closed form is k = log(1−P)/log(1−ws), and the whole trick is that s sits in the exponent: at 50/50 the 2-point fit needs 17 and the 3-point fit needs 35, and the gap only widens as the data dirties. So I always fit the smallest model the geometry allows — two correspondences for a rigid transform (drill 2’s s = 2) — and I round up, because a fractional iteration still has to run." That is the same 17 that the calculator widget at the foot of this panel plots as its teal curve.

Drills 2 and 5 lock together into one number, so here is that join worked on real data — the RANSAC loop: run drill 5’s budget of 17 iterations, each calling drill 2’s count_inliers, keep the best count, and gate on it. Take 40 putative correspondences and a candidate 30° rigid loop:

Notice the budget and the kernel are inseparable: 17 iterations is enough because s = 2 keeps drill 5’s number tiny, and each iteration is cheap because drill 2 fits from just two points. Shrink the model and the whole verifier stays real-time.

Micro-example: iSAM2 vs batch — how few variables a loop actually re-touches

The system-design table above says “iSAM2 / Bayes tree incremental re-optimisation, not batch” — but how much does incremental actually save? That is a countable number, and countable numbers beat adjectives at a whiteboard. When you add a loop-closure factor between poses i and j, a batch solver (g2o, Ceres) re-linearises and re-solves every variable in the graph. iSAM2 re-eliminates only the variables the new factor marks — those on the Bayes-tree path from i and j up to their common ancestor, plus any whose linearisation point moved past the relinearisation threshold.

Worked: a tight indoor loop on a 1,200-pose graph. The robot has built up 1,200 poses and revisits a spot it saw about forty poses ago — the loop closes pose 1,200 back to pose 1,158.
  • Batch. Re-solves all 1,200 variables, every single loop, whether the loop was long or short.
  • iSAM2. The factor marks only the path spanning poses 1,158…1,200 — 1,200 − 1,158 + 1 = 43 variables re-eliminated.
  • The ratio. 1,200 / 43 ≈ 28× fewer variables touched for this loop — and because a short loop marks a short path, the win grows the longer the mission runs while the loops stay local. That 28× is why iSAM2 holds real-time re-optimisation where batch would stall the tracking thread.
  • The honest worst case. A full-lap loop — pose 1,200 back to pose 12 — naively marks 1,200 − 12 + 1 = 1,189 variables, only 1.01× better than batch. But this is exactly where iSAM2’s fluid relinearisation earns its keep: only the variables whose linearisation point actually moved past the threshold (typically ~1e-3) re-eliminate. If about 120 of them do, that is still a 10× win (1,200 / 120), and it never re-solves all 1,200 unconditionally the way batch does.
So the saving is real but loop-dependent: local loops are near-free (28×), and even the worst full-lap loop is bounded (10×) by only re-touching what genuinely changed. Interview move: state the mechanism (“it re-eliminates only the marked path plus the variables past the relinearisation threshold, not the whole graph”) and the ratio (28× local, ~10× worst-case). The mechanism explains the number, and the number proves you can size it.

Debugging scenarios — symptom → cause → the metric that reveals it

SymptomLikely causeMetric that reveals it
Map perfect near start, sheared and doubled far awayUncorrected drift; no loop closure yetEnd-to-start gap on the trajectory; per-pose covariance growing with distance
VIO tracks well, loops close, but everything is 30% too smallUnobservable scale drifted (vision-only)Constant ratio of estimated to metric distance (IMU velocity / known landmark)
Loop closures fire between two different corridorsPerceptual aliasingScore margin top-1 vs top-2 — a low margin flags the alias
Cost jumped after a loop and stayed high; a crease in the mapAccepted false loop closurePost-optimisation residual of the loop-closure factor — a tall red bar
Good loop fired, optimiser overshot, map worseFull Gauss-Newton step from a large loop errorCost increased after the step; fix with Levenberg-Marquardt
Trajectory self-consistent but rotated off GPSConsistency fixed, gauge/bias not (unobservable)Absolute error vs an external anchor stays constant after loop closure
Relocalizes today's map, fails in last week's loading bayAppearance change; retrieval returns no candidateRelocalization success rate bucketed by map region
Map grows without bound; queries slow over hoursNo keyframe culling; size tracks timeKeyframe count vs distance travelled — should saturate, not climb

The second debug row — “loops close but everything is 30% too small” — is the one candidates most often misdiagnose, so here it is worked out. The give-away is a constant ratio, and constant ratios are worth carrying to numbers.

Worked: the 30%-too-small VIO. A vision-only system has an unobservable scale gauge (the mirror image of the position gauge from the null-space derivation above — a monocular camera can't tell a small nearby scene from a large distant one). Suppose it settles 30% too small: every estimated length is 0.70× the metric truth. Measure four segments whose true lengths are 2.0, 5.0, 8.0, 12.5 m:
  • Estimated: 0.70×[2.0, 5.0, 8.0, 12.5] = [1.40, 3.50, 5.60, 8.75] m.
  • The tell — estimated / metric = 1.40/2.0, 3.50/5.0, 5.60/8.0, 8.75/12.5 = 0.70, 0.70, 0.70, 0.70. A constant ratio everywhere, not a scatter. Random error would give four different numbers; one number everywhere is the fingerprint of a scale gauge.
  • Why a loop closure won’t save you: the ring closes perfectly at 0.70× — it is a relative constraint, and a uniformly shrunk map is still self-consistent (consistency ≠ accuracy, once more). The loop residual is ~0; the metric error is 30%. No relative measurement can see it.
  • The fix is one absolute length. A landmark you know is 5.0 m appears as 3.50 m ⇒ recovered scale = 3.50/5.0 = 0.70, so multiply the whole map by 1/0.70 = 1.43. (An IMU supplies the same anchor: gravity and velocity carry true metres.) That absolute constraint adds the missing rank — exactly the role the anchor played in the pose-graph solve above.

Classical vs modern

ConcernClassicalModern
Place recognitionBag of words (DBoW2), FAB-MAPLearned global descriptors (NetVLAD, and successors)
Feature matching / verificationNN matching + RANSACSuperGlue / learned matchers, then RANSAC
Back-end robustness to false loopsHard accept/reject on inlier countSwitchable constraints, dynamic covariance scaling
Re-optimisationBatch (g2o, Ceres)Incremental (iSAM2 / Bayes tree)
RelocalizationKeyframe database + PnPScene-coordinate regression, learned pose (PoseNet lineage)
Lifelong appearanceSingle-appearance mapExperience maps, condition-invariant descriptors

Recommended reading

Iteration-budget calculator

The RANSAC row on the cheat sheet is the one number an interviewer loves to poke: "matches are 60% outliers — roughly how many iterations?" Answer it before you reach for the slider: w = 0.4, so w2 = 0.16, log(0.01)/log(0.84) = −4.6052/−0.17435 = 26.4 → 27 iterations for the 2-point fit — still cheap. The 3-point fit at the same 60% is 69.6 → 70, already 2.6× more, and it only diverges further as the data dirties. Drag the outlier ratio and watch the budget k = log(1−P) / log(1−ws) move. Two curves are drawn: the minimal 2-point rigid fit (s = 2, teal) versus a 3-point fit (s = 3, warm) at 99% confidence. The lesson of the whole panel is in the gap between them — the smaller model stays cheap far longer.

RANSAC iterations vs outlier ratio

The dot marks your chosen outlier ratio on each curve. Notice how the s=3 curve climbs away from s=2 as the data gets dirtier — doubling nothing, tripling everything.

outlier ratio 0.50
 
A visual word appears in every mapped place. What is its idf, and what does that do to its TF-IDF contribution?

Where this sits in the track

This lesson closes the loop — literally — on the SLAM sub-track. It builds directly on the pose-graph machinery of SLAM Backend, uses the place-recognition foundations from Place Recognition (BoW), and completes the picture begun in Classical SLAM and Modern SLAM. The factor-graph view underlying all of it lives in SLAM: Factor Graphs.

The one thing to remember: drift is inevitable, and no local sensor can see it — only a loop closure, a global constraint linking far-apart poses, can pull it back. But a loop closure is a relative measurement: it fixes consistency, not the unobservable gauge, and one false closure is worse than a hundred missed true ones. Recognise the place, verify the geometry, then let the residual-spreading law bend the whole trajectory smooth.

"The map is not the territory — but a consistent map is the best territory you will ever get from a drifting robot."