Robotics Engineering · Lesson 6 of 26

The SLAM
Frontend

Everything that happens before the optimizer — the part that has to decide what corresponds to what, and the part that actually fails in the field.

Prerequisites: dot products + the idea of least squares. Everything else is built here.
9
Chapters
9
Simulations
3
Code Labs

Chapter 0: The Fold

It is 2:15 on a Thursday afternoon and a bug report has just landed on your desk. Attached is a top-down plot of a warehouse floor: grey walls, a thin blue line tracing where the robot thinks it went, and a red rectangle where a shelf actually is.

The blue line goes through the shelf.

The run lasted eleven minutes. The optimizer converged — final cost 41.2, gradient norm below tolerance, no rank deficiency, Ceres reported CONVERGENCE, not NO_CONVERGENCE. Every single residual in the final solve is under a pixel. And the map is through a wall. Your job is to explain what happened.

This is the whole lesson in one plot. The backend did exactly what you asked it to do. It found the configuration of poses and landmarks that best explains the measurements you handed it. The measurements were wrong — not noisy, wrong — and a least-squares solver has no way to know the difference between "this landmark is here" and "this landmark is the fourth identical shelf upright down the aisle, and I have mistaken it for the first."

Why the frontend is the hard part

Here is the uncomfortable truth about modern SLAM, and the reason this lesson exists.

The backend is a solved problem. Sparse nonlinear least squares over a pose graph with a Schur complement on the landmark block is textbook material with three mature open-source implementations — Ceres, GTSAM, g2o. You will spend a week learning to call one and you will never write a Levenberg–Marquardt loop in anger. The theory has not moved much since 2010. Lesson 7 of this track (SLAM Backend) will make you fluent in it, and that fluency is worth exactly as much as any other library fluency.

The frontend is not solved. The frontend is the part that takes photons or LiDAR returns and produces the two things the backend consumes: constraints and the claim that those constraints are correct. That second product is the hard one. It is called data association — deciding which measurement corresponds to which landmark — and it is a discrete decision inside a continuous optimisation, which means the backend cannot fix it, smooth it, or even detect it.

Anyone who has shipped a SLAM system knows this, because they have lived it. That is why this lesson spends twenty minutes of material on the frontend for every five on the backend — and why most of those twenty are about failure.

LayerWhat it decidesWhat happens when it is wrong
FrontendWhich measurement belongs to which landmark (discrete)A wrong constraint enters the graph with full confidence. The optimizer trusts it and bends everything else to accommodate it.
BackendWhere everything is, given the constraints (continuous)Slow convergence, a local minimum, a worse estimate — but the errors are graded, and the residuals scream.
The asymmetry that defines the job: a continuous error of 5 cm costs you 5 cm. A discrete error — one wrong association — can cost you the entire map. That is why the frontend spends most of its compute not on estimating things but on refusing things.

What "one bad association" actually does

Let us make the opening scenario concrete with a number you can hold. A pose graph is a set of poses connected by constraints. Each constraint says "pose j is at this offset from pose i, and I believe that to within an information weight Ω." The optimizer minimises the weighted sum of squared disagreements:

C = Σ(i,j) Ωij · ‖ (xj − xi) − zij2

where xi is the position of pose i, zij is the measured offset, and Ωij is how strongly you believe it. Suppose you have a clean chain of 60 odometry constraints, each with Ω = 1, and you add one loop-closure constraint claiming pose 58 is at the same place as pose 4 — because the shelf upright at pose 58 looked exactly like the shelf upright at pose 4.

Worked example 1 — the tug of war, every step

Think of each constraint as a spring. The odometry chain from pose 4 to pose 58 is 54 springs in series, and springs in series get weaker, not stronger — the same way resistors in parallel do. Do the arithmetic in full, because the conclusion is unbelievable until you have.

Step 1 — the stiffness of one odometry link. Wheel odometry over a 5 cm step with 1 cm of standard deviation gives σ = 0.01 m, so the information (the inverse variance) is

Ωodom = 1 / σ2 = 1 / (0.01)2 = 1 / 0.0001 = 10,000 m−2

To keep the numbers readable, normalise everything by that: call one odometry link stiffness 1.

Step 2 — the stiffness of the chain between the two poses the false loop connects. Springs in series add compliance (the reciprocal of stiffness), not stiffness. Fifty-four links each of stiffness 1 give total compliance 54, so

kchain = 1 / (54 × 1) = 0.0185

Step 3 — the stiffness of the false loop. A visual loop closure that survived RANSAC with 200 inliers is genuinely good: its position uncertainty is perhaps σ = 0.001 m, so its information is 1/(0.001)2 = 1,000,000 m−2, which normalised by the 10,000 above is

Ωloop = 1,000,000 / 10,000 = 100

Step 4 — who wins. Two springs pulling a point in opposite directions settle at the weighted average of their rest positions. The fraction of the error absorbed by the chain is

f = Ωloop / (Ωloop + kchain) = 100 / (100 + 0.0185) = 100 / 100.0185 = 0.99981

99.98% of the false loop's claim is absorbed by deforming the map. If the false loop claims a 3.0 m offset that is actually wrong by 3.0 m, the map moves 3.0 × 0.99981 = 2.9994 m to accommodate it, and the residual left on the false constraint is 3.0 − 2.9994 = 0.0006 m. Six tenths of a millimetre.

Now look at what that does to your robust kernel. A Huber kernel with a threshold of 0.05 m never activates, because the residual it sees is 0.0006 m — eighty times below threshold. The outlier made itself invisible by winning. This is the precise mechanical reason the answer to the chapter quiz is what it is, and it is worth being able to produce this arithmetic in ninety seconds at a whiteboard.

The ratio of the two stiffnesses, 100 / 0.0185 = 5,400, is the number to remember. The map does not bend a little. It folds.

One wrong association, one folded map

A robot drives a rectangular aisle loop (grey = ground truth). Drag the slider to add a single false loop-closure constraint between the current pose and an early pose, then raise its information weight. Everything else is perfect: no odometry noise, no other outliers. Watch the optimizer — which is working correctly — destroy the map on the strength of one lie.

False loop weight Ω 0
Claimed match: pose 14
Ω = 0 — no false constraint. Estimate sits on truth.

Read the slider range carefully, because it is the lesson. The chain stiffness between the two poses is roughly 1/45 = 0.022, so Ω = 0.05 already outweighs the entire odometry chain by more than two to one — and at Ω = 0.05 the trajectory is already through the shelf. By Ω = 0.2 the estimate is unrecognisable, and the slider stops at 0.6. A real visual loop closure carries Ω ≈ 100, which is four hundred times further right than this slider goes.

That saturation is the point. There is no "safe" weight for a false constraint. The map is fully folded long before the weight reaches anything a real system would assign, and the optimizer reports a lower cost than before you added the constraint. It is doing its job. You gave it a lie and it believed you.

The frontend, drawn honestly

Here is the pipeline, with the shapes and rates that make it real rather than decorative. Assume a warehouse AMR: one global-shutter mono camera at 1280×720, 30 Hz, plus a 16-beam LiDAR at 10 Hz, on a Jetson Orin Nano.

Raw frame
(720, 1280) uint8 — 0.92 MB/frame, 27.6 MB/s at 30 Hz
↓ undistort + pyramid (8 levels, scale 1.2)
Image pyramid
2.85 Mpixel total across levels — 3.10× the base image
↓ FAST detection + bucketing, ~9 ms
Keypoints
(1000, 2) float32 + (1000,) octave + (1000,) angle — 12 KB
↓ BRIEF descriptor, 256 bits each, ~2 ms
Descriptors
(1000, 32) uint8 — 32 KB/frame, 0.96 MB/s. 28× smaller than the image.
↓ guided matching + ratio test + mutual check, ~3 ms
Putative correspondences
(M, 2) int32, M ≈ 300–600. Perhaps 70% of them are right.
↓ RANSAC geometric verification, ~2 ms
Verified inliers + model
mask (M,) bool, plus E (3,3) or a pose. Now perhaps 99.5% right.
↓ PnP against the local map + refinement, ~1 ms
Constraint for the backend
T (4,4) SE(3) + Ω (6,6) information matrix — 152 bytes

Read that last line again. Twenty-two megabytes per second of photons become 152 bytes of constraint. Every stage of the frontend is a compression, and every compression is a decision you can get wrong. The backend never sees the image. It sees the 152 bytes and believes them.

Worked example 2 — the latency budget, computed not asserted

"It runs at 30 Hz" is not an answer. Here is how you build the number from first principles.

Step 1 — the period. 30 Hz means one frame every 1 / 30 = 0.03333 s = 33.3 ms. That is the entire budget for everything that must happen once per frame.

Step 2 — how many pixels the detector actually touches. ORB builds an 8-level pyramid with a scale factor of 1.2, so each level has 1/1.22 = 1/1.44 = 0.6944 of the previous level's area. The total is a geometric series:

1 + 0.6944 + 0.69442 + … + 0.69447 = (1 − 0.69448) / (1 − 0.6944) = (1 − 0.0538) / 0.3056 = 3.096

So a 1280×720 = 921,600-pixel image becomes 921,600 × 3.096 = 2.85 million pixels of detection work per frame. Not 0.92 million. Getting this factor of three wrong is how frontend budgets are missed.

Step 3 — convert pixels to time. A FAST corner test on a modern ARM core, vectorised, costs roughly 3 ns per pixel including the non-maximum suppression pass. 2.85e6 × 3e−9 s = 0.00855 s = 8.6 ms. Round to 9.

Step 4 — the rest, and the headroom.

StageWorkTimeRunning total
Undistort + pyramid2.85 Mpx resample2.0 ms2.0 ms
FAST detect + NMS + bucket2.85 Mpx × 3 ns8.6 ms10.6 ms
BRIEF describe1000 kp × 256 tests1.8 ms12.4 ms
Guided match + ratio + mutual1000 kp × ~12 candidates2.6 ms15.0 ms
RANSAC (219 iters × 5-pt)see Chapter 32.2 ms17.2 ms
PnP + Gauss–Newton refine200 pts × 4 iters0.9 ms18.1 ms

18.1 ms of a 33.3 ms budget = 54% utilisation. That is the number you quote, and the 15.2 ms you did not spend is not slack — it is the margin that absorbs a slow frame, a page fault, a scheduler hiccup, and the mapping thread stealing a core.

Why frontend latency is a correctness property, not a performance one. Miss the deadline and you drop a frame. Dropping a frame doubles the baseline between the frames you do track. A doubled baseline means larger apparent motion, which means the search radius for guided matching must double, which means four times the candidates, which means matching gets slower and less precise — so you drop the next frame too. This is a positive feedback loop with no damping term, and it is why "it usually runs at 30 Hz" is a failing answer. The honest metric is p99.9 frame latency, not mean.

A day in this job, concretely

Before the theory, it helps to know what the day-to-day work of a frontend engineer actually looks like.

Notice the shape. Almost none of it is "make the estimator more accurate." Almost all of it is "find the wrong correspondence and explain how you know it is wrong."

The failure taxonomy

Each of the six chapters that follow owns one named failure. Here they are together, because "what breaks in a frontend?" deserves a structured answer rather than an anecdote.

ChFailureObservable symptomThe metric that reveals it
1Feature clusteringHealthy inlier count and reprojection error, but heading driftsKeypoint spatial entropy over an 8×6 grid; pose covariance eigenvalue ratio
2Repetitive structureMatch count collapses 5× while feature count is unchangedRatio-test rejection rate: 30% → 85%
3Planar degeneracy85% inliers, sub-pixel residuals, translation direction flipsHomography inliers / essential inliers > 0.9
4Brightness-constancy violationPose spike exactly at an auto-exposure stepCorrelation of pose-increment norm with frame mean-intensity delta
5Registration degeneracyExcellent fitness score, pose slides along the corridorSmallest eigenvalue of JTJ; Zhang's degeneracy factor
6Low-parallax triangulationNew landmarks appear at absurd depth; scale wandersMedian parallax angle across matches, gated at 1°
The pattern across all six: every one of them produces healthy-looking primary metrics. Inlier count fine. Reprojection error fine. Solver converged. That is not a coincidence — the failures that are easy to see were fixed years ago. The ones that remain are the ones your primary metrics are blind to, which is exactly why each row above names a secondary metric.

What mastery here looks like

By the end of this lesson you should be able to do five distinct things with this material, and they are worth naming up front because each one exercises a different muscle.

  1. Tell the story. Explain a SLAM frontend end to end in the shape problem → system → the one decision that mattered → the number it moved.
  2. Derive. RANSAC's iteration count, ICP's closed form, and the epipolar constraint — from a blank page, without hesitation. All three are in this lesson.
  3. Design. Lay out the frontend for a given robot with budgets in real numbers, including the blocks you refuse to place.
  4. Debug. Face a healthy-looking metric and a broken outcome, and reach for evidence before naming a cause.
  5. Defend a tradeoff against push-back — usually classical versus learned. What matters is not which side you take; it is whether you can name the observation that would change your mind.
The sentence that organises everything: "The frontend's job is not to find correspondences. It is to find correspondences and know which ones to throw away, inside a fixed time budget, on hardware that will not get faster." Every design decision in the next six chapters follows from that sentence.

The five layers, and what this lesson does not repeat

Every numbered chapter here works its topic five ways, because owning a system — rather than recognising it — takes all five:

LayerThe question it answersWhat it actually builds
CONCEPT"Can you derive it from a blank page?"Rebuilding it, not just recalling it
DESIGN"Where does it sit, and what does it cost?"The shapes, rates and budgets
CODE"Can you implement the core?"Hands that can do what the mouth claims
DEBUG"It is broken like this. Now what?"The failure modes, not just the happy path
FRONTIER"What is changing?"Staying current, and defending a tradeoff

This lesson does not re-teach the underlying geometry. Four lessons on this site already do that properly:

What this lesson adds: the derivations done in full, the numbers worth having memorised, the failure taxonomy with the metric that reveals each one, and the tradeoffs you have to defend when someone senior disagrees with you. If a chapter here restates one of those five lessons, it has failed.

The one sentence that matters most: the weak answer to "what is the hardest part of SLAM?" is "the optimisation, because it is high-dimensional and non-convex." The strong answer is "data association, because it is the only part where an error is unbounded and undetectable from the inside."
A colleague says "our SLAM system is robust because we use a Huber kernel on every residual in the backend." What is the single strongest objection?

Chapter 1: Detect & Describe

Take a single frame from a warehouse aisle: concrete floor, steel uprights, cardboard boxes, a strip light. Now try this: circle ten places you would put a keypoint — and then say why those ten and not the ten next to them.

That question is not about Harris. It is about what a keypoint is for. And a keypoint is for exactly one thing: to be found again, in the next frame, from a different viewpoint, under different light, by an algorithm that has no idea what a shelf is.

What this chapter adds, and what it does not repeat. The Features, RANSAC & SfM lesson derives the image gradient, the Harris structure tensor and the SIFT descriptor from zero, with the eigenvalue argument in full. Multi-View Geometry covers the detector/descriptor landscape. Read those for the theory. This chapter is about the four things that matter most in practice: the segment test you can run in your head, the bandwidth of your descriptor choice, the spatial distribution problem nobody teaches, and the failure that looks perfectly healthy.

CONCEPT — the four properties, then the test you can do by hand

Before any algorithm, state the requirements. Holding these four in order is the difference between having thought about the problem and having memorised a detector name.

PropertyMeansWhat kills it
RepeatabilityThe same physical point is detected again from a new viewpointDetecting on a surface that changes appearance — a specular highlight, a shadow edge, a reflection
LocalityThe measurement comes from a small patch, so occlusion only kills the keypoints it coversLarge support regions; global descriptors
DistinctivenessIts descriptor is far from every other descriptor in the imageRepetitive structure — the exact thing warehouses are made of
EfficiencyThousands per frame inside a millisecond budgetAnything requiring a per-pixel neural forward pass without a GPU

A corner satisfies all four and an edge satisfies only the first two — which is the entire content of the Harris eigenvalue argument, compressed. Along an edge you can slide the window without changing what you see, so the position along the edge is unrecoverable. That is the aperture problem, and it will come back in Chapter 4 wearing a different costume.

Now the practical part. Nobody runs Harris in a 30 Hz frontend; Harris needs image derivatives, a smoothing convolution and a 2×2 determinant per pixel. What runs is FAST (Features from Accelerated Segment Test, Rosten & Drummond 2006), and it is a decision you can execute by hand.

The FAST segment test in one sentence: take the 16 pixels on a Bresenham circle of radius 3 around a candidate pixel p; p is a corner if there exists a contiguous arc of at least N of those 16 that are all brighter than Ip+t, or all darker than Ip−t.

Worked example 1 — run FAST-9 by hand

Centre pixel Ip = 100, threshold t = 20. So "brighter" means > 120 and "darker" means < 80. Here are the sixteen ring pixels, in order around the circle:

p1..16 = 64, 58, 71, 128, 139, 147, 155, 151, 144, 136, 141, 149, 133, 76, 62, 69

Step 1 — the high-speed pre-test. Before doing any of this, FAST checks only four pixels: p1, p5, p9, p13 (the compass points). For an arc of 9 to exist, at least three of those four must be all-bright or all-dark — because any 9 contiguous pixels out of 16 must contain at least three compass points. Here: p1=64 (dark), p5=139 (bright), p9=144 (bright), p13=133 (bright). Three bright. Pre-test passes, so we continue. Roughly 90% of pixels in a real image are discarded on those four comparisons alone, and that is where FAST's speed comes from.

Step 2 — classify all sixteen. Brighter than 120: p4 through p13. Darker than 80: p14, p15, p16, p1, p2, p3.

Step 3 — longest contiguous arcs. The bright run is p4…p13, which is 13 − 4 + 1 = 10 pixels. The dark run wraps around the end of the array: p14, p15, p16, p1, p2, p3 = 6 pixels.

Step 4 — the verdict. 10 ≥ 9, so this is a corner under FAST-9.

Step 5 — the score, for non-maximum suppression. Two adjacent pixels will both pass the test, and you want only one keypoint. Rosten's score V is the sum of absolute differences over the winning arc, less the threshold:

V = Σx ∈ arc |Ix − Ip| − t

Adding those up: 28 + 39 + 47 + 55 + 51 + 44 + 36 + 41 + 49 + 33 = 423, so V = 423 − 20 = 403. The neighbour with the higher V survives NMS.

Step 6 — now raise the threshold to t = 40 and redo it. "Brighter" now means > 140. Only p6=147, p7=155, p8=151, p9=144 qualify contiguously (p10=136 breaks it), then p11=141, p12=149 form a run of 2. Longest bright arc = 4. Darker than 60: only p2=58. Longest dark arc = 1. Neither reaches 9, so at t = 40 this pixel is not a corner.

The field note buried in step 6. The threshold t is not a tuning knob you set once. It is a contrast requirement, and contrast collapses in exactly the places you most need features: a dim aisle, an overexposed loading-dock door, a white wall. This is why production detectors run an adaptive t — ORB-SLAM detects with t = 20 and, if a cell yields nothing, re-runs that cell with t = 7. Knowing that is the difference between using FAST and merely naming it.

Why 9, and not 8 or 12

Here is a derivation that takes twenty seconds and impresses people, because almost nobody has done it.

Model a perfect straight edge through the centre pixel: exactly half the ring is on the bright side and half on the dark side. Sixteen pixels, so the longest bright arc is 8 and the longest dark arc is 8.

Set N = 8 and every edge pixel in the image fires. Set N = 9 and edges are rejected by exactly one pixel, while a 90° corner — where the dark wedge subtends 90° of the 360° circle, taking 16 × 90/360 = 4 ring pixels — leaves a bright arc of 16 − 4 = 12, comfortably above 9.

N = 9 is the smallest integer that rejects a straight edge. That is the whole reason. FAST-12 (the original) is stricter and faster because the four-point pre-test becomes exact, but it misses shallow corners; FAST-9 with a learned decision tree is what OpenCV ships by default.

The segment test, live — find the corner/edge boundary yourself

A synthetic wedge of dark against bright, with its apex on the candidate pixel. The 16 ring pixels are classified: green = brighter, blue = darker, grey = within threshold. The thick arc is the longest contiguous run. Open the wedge past 180° and watch FAST-9 refuse to call it a corner — that is the derivation above, happening in front of you.

Wedge opening 90°
Contrast c 100
Threshold t 20
 

Two things to find with those sliders. First, set the contrast to 40 and walk the threshold up: the test dies the moment t exceeds c/2, because the ring pixels sit at Ip ± c/2 and the test needs a strict margin of t. That inequality, c > 2t, is the contrast budget of your detector and it is worth knowing cold. Second, hold contrast high and open the wedge: 90° gives a bright arc of 12, 180° gives exactly 8, and 8 < 9.

CONCEPT — orientation and the descriptor

FAST gives you where. It gives you no orientation and no scale, so a raw FAST keypoint cannot survive the camera rotating. ORB (Rublee et al. 2011) bolts both on cheaply, and the orientation trick is the second thing you should be able to compute by hand.

The intensity centroid assumes the patch's brightness is not symmetric about the corner — which is true, because a corner has a bright side. Compute the first moments of the patch, take the vector from the centre to the centroid, and call its angle the keypoint's orientation.

mpq = Σx,y xp yq I(x,y)     C = ( m10/m00 , m01/m00 )     θ = atan2( m01 , m10 )

Worked example 2 — the orientation of a 3×3 patch, and one Hamming distance

Take this patch, with the centre pixel at (0,0), x increasing right and y increasing down:

 10   20   90
 15   60  120
 20   70  130

m00, the total mass: 10 + 20 + 90 + 15 + 60 + 120 + 20 + 70 + 130 = 535.

m10 = Σ x · I. The x = −1 column contributes −(10 + 15 + 20) = −45; the x = 0 column contributes 0; the x = +1 column contributes +(90 + 120 + 130) = +340. So m10 = −45 + 340 = 295.

m01 = Σ y · I. The y = −1 row contributes −(10 + 20 + 90) = −120; the y = +1 row contributes +(20 + 70 + 130) = +220. So m01 = −120 + 220 = 100.

The centroid sits at (295/535, 100/535) = (0.5514, 0.1869) pixels from the centre — up and to the right, which matches the visibly bright right-hand column.

The angle is θ = atan2(100, 295) = atan(0.3390) = 18.73°. Every BRIEF sampling pair is then rotated by that angle before the comparison, which is what makes ORB rotation-invariant.

And the descriptor distance. BRIEF is 256 binary tests: "is the pixel at offset a brighter than the pixel at offset b?" Distance between two descriptors is the Hamming distance — XOR then popcount. On eight bits, to keep it hand-sized:

a = 10110110    b = 10010010    a ⊕ b = 00100100    popcount = 2

On 256 bits, a single 64-bit CPU can do this in four XOR + four POPCNT instructions — about 2 nanoseconds. That number is the entire reason binary descriptors survived the deep-learning era on embedded hardware, and it is the number to quote when someone proposes swapping in a float descriptor.

DESIGN — scale, distribution, and the bandwidth you just signed up for

Scale. A corner at 2 m is a blob at 10 m. ORB handles this with an 8-level pyramid at scale factor 1.2 and detects independently on each level. The feature budget must be split across levels, and the standard split is proportional to level area:

LevelImage sizeArea shareFeatures (of 1000)
01280 × 72032.30%323
11067 × 60022.43%224
2889 × 50015.58%156
3741 × 41710.82%108
4617 × 3477.51%75
5514 × 2895.22%52
6429 × 2413.62%36
7357 × 2012.52%25

The shares are 0.6944i divided by their sum 3.096, which is the geometric series from Chapter 0. Note the consequence: the top three levels together get 113 features, and those are the only features that will still be matchable when the robot approaches a shelf and everything grows.

Distribution. This is the part textbooks skip. A raw "take the top 1000 by score" will put 800 of them on the one high-contrast object in the frame, because that object genuinely has the strongest corners. Production detectors force a spatial spread instead:

Bandwidth. Your descriptor choice is a bandwidth and memory decision before it is an accuracy decision. At 1000 keypoints per frame and 30 Hz:

DescriptorBytes eachPer frameSustainedvs raw image
ORB / BRIEF, 256-bit3231 KB0.96 MB/s0.035×
SIFT, 128 × uint8128125 KB3.84 MB/s0.14×
XFeat, 64 × float32256250 KB7.68 MB/s0.28×
SuperPoint, 256 × float16512500 KB15.4 MB/s0.56×
SuperPoint, 256 × float3210241000 KB30.7 MB/s1.11×
Raw 1280×720 mono reference: 0.92 MB/frame, 27.6 MB/s
Read the last row twice. A float32 SuperPoint descriptor set is larger than the image it came from. If your architecture ships descriptors between processes, over a network, or into a map file that must persist for a year across a fleet, that 32× multiplier against ORB is the dominant cost — not the GPU time. This is the objection to raise in a design review, and almost nobody raises it.

Where it sits in the system. Detection and description live on the tracking thread, before anything geometric. They consume an undistorted image — calibration comes first, because a detector run on a fisheye image produces keypoints whose neighbourhood is warped differently at the edge than at the centre, which quietly destroys descriptor repeatability across the frame. Output shapes: keypoints (N, 2) float32, octave (N,) uint8, angle (N,) float32, response (N,) float32, descriptors (N, 32) uint8.

CODE — the segment test from scratch, then the library

Write it from scratch once, narrating the pre-test to yourself as you go — the narration is where the understanding lives.

python — from scratch
import numpy as np

# Bresenham circle of radius 3, in ring order (dy, dx)
RING = [(-3, 0), (-3, 1), (-2, 2), (-1, 3), (0, 3), (1, 3), (2, 2), (3, 1),
        (3, 0), (3, -1), (2, -2), (1, -3), (0, -3), (-1, -3), (-2, -2), (-3, -1)]

def longest_run(mask):
    """longest CIRCULAR run of True in a length-16 boolean list"""
    if all(mask): return len(mask)
    best = run = 0
    for v in mask + mask:              # doubling handles the wrap-around
        run = run + 1 if v else 0
        best = max(best, min(run, len(mask)))
    return best

def fast_corners(img, t=20, N=9):
    H, W = img.shape
    img = img.astype(np.int16)
    kps = []
    for y in range(3, H - 3):
        for x in range(3, W - 3):
            Ip = img[y, x]
            # --- high-speed pre-test: compass points 0, 4, 8, 12 ---
            comp = [img[y + RING[k][0], x + RING[k][1]] for k in (0, 4, 8, 12)]
            if sum(c > Ip + t for c in comp) < 3 and \
               sum(c < Ip - t for c in comp) < 3:
                continue                      # ~90% of pixels die here
            ring = [img[y + dy, x + dx] for dy, dx in RING]
            b = [v > Ip + t for v in ring]
            d = [v < Ip - t for v in ring]
            if longest_run(b) >= N or longest_run(d) >= N:
                arc = [abs(v - Ip) for v, f in zip(ring, b if longest_run(b) >= N else d) if f]
                kps.append((x, y, sum(arc) - t))   # (x, y, score V)
    return kps

def orientation(patch):
    """ORB intensity centroid: theta = atan2(m01, m10)"""
    h, w = patch.shape
    ys, xs = np.mgrid[-(h // 2):h // 2 + 1, -(w // 2):w // 2 + 1]
    m10 = (xs * patch).sum()
    m01 = (ys * patch).sum()
    return np.arctan2(m01, m10)

Then the production form, which you should reach for immediately after showing you can write the above:

python — production
import cv2
orb = cv2.ORB_create(nfeatures=1000, scaleFactor=1.2, nlevels=8,
                     fastThreshold=20, edgeThreshold=31,
                     scoreType=cv2.ORB_HARRIS_SCORE)     # Harris re-ranks the FAST candidates
kp, des = orb.detectAndCompute(gray, None)
# kp : list of cv2.KeyPoint  (pt, angle, octave, response, size)
# des: (N, 32) uint8  — 256 bits each

The detail worth mentioning unprompted: scoreType=ORB_HARRIS_SCORE means OpenCV detects with FAST (cheap) and then ranks with Harris (accurate). You get FAST's speed and Harris's ordering. That two-stage pattern — cheap generate, expensive rank — recurs everywhere in this lesson.

DEBUG — the failure that looks perfectly healthy

Symptom. A ground robot driving a straight aisle. Inlier count 340 out of 400 matches (85%). Reprojection error 0.29 px median. Tracking never lost. And the reported heading drifts 3–4° over a 20 m run, always in the same direction, and only in that aisle.

Every primary metric is green. The bug is feature clustering: the aisle is featureless concrete and blank shelf backs, except for one bank of printed labels at eye height on the left. All 400 features are in a 200 × 120 pixel box on the left third of the image.

Why that produces a heading bias and not just noise. Pose is estimated by minimising reprojection error over the observed features. If all features occupy a narrow cone, rotating the camera slightly about the vertical axis moves them almost the same way as translating the camera sideways. The two motions become nearly indistinguishable — the information matrix has a small eigenvalue in the yaw–sideslip direction — and the estimator splits the difference. Consistently. In the same direction, because the geometry is the same every time.

The metric that reveals it. Not inlier ratio — that is computed among the features you have and is blind to where they are. Use keypoint spatial entropy: bin the keypoints into an 8×6 grid of cells, normalise the counts to a distribution p, and compute

H = − Σk pk log2 pk ,    Hmax = log2 48 = 5.585 bits

Worked, so the numbers mean something:

Distribution over 48 cellsH (bits)H / HmaxReading
Perfectly uniform5.5851.000Ideal, never happens
600 in 3 cells, 400 spread over 454.1190.737Acceptable
All 1000 in 8 cells3.0000.537Alarm
All 1000 in 4 cells2.0000.358The aisle above

Ship the alarm at H/Hmax < 0.6. The confirming second signal is the ratio of largest to smallest eigenvalue of the pose information matrix JTΩJ: healthy frames sit around 20–80, the clustered frames in this aisle sit above 3,000.

The fix, and the wrong fix. The wrong fix is to detect more features — you will get 2000 features in the same box. The right fix is grid bucketing with a per-cell cap plus an adaptive threshold, so that empty cells re-run FAST at t = 7 and contribute weak features that are nevertheless in the right place. Weak features in the right place beat strong features in the wrong place, and being able to say that sentence is the point of this section.

FRONTIER — learned keypoints, briefly

This is a landscape section, not a depth section — a map of who is who and why.

MethodYearThe one ideaWhy you would or would not ship it
SuperPoint2018Self-supervised via homographic adaptation; joint detector + 256-d descriptor headThe baseline everyone compares to. 1 KB/keypoint and a GPU.
R2D22019Separate repeatability and reliability maps — not every repeatable point is discriminativeBest single idea in the family. Slow.
DISK2020Trains the whole detect–describe–match pipeline with a policy-gradient reward on matchesStrong on wide baselines; heavy.
ALIKED2023Deformable sampling so the descriptor support follows the local geometryGood accuracy per FLOP.
XFeatCVPR 2024Explicitly designed for CPU: 64-d descriptors, ~1.7 ms per frame on an ordinary laptop coreThe first learned detector that is a genuine ORB replacement on embedded hardware.
DeDoDe3DV 2024Decouples detection from description entirely and trains each on its own objectiveResearch-grade; the decoupling argument is the takeaway.

The defensible position: learned detectors win clearly on repeatability under viewpoint and illumination change, and that advantage is largest exactly where classical detectors fail — low texture, motion blur, day/night. They lose on determinism, bandwidth, and the fact that a failure mode you have never seen cannot be characterised. XFeat (2024) is the paper that makes the tradeoff genuinely live on a CPU-only robot, and it is the one to name.

Your detector reports 1000 features per frame, 85% RANSAC inlier ratio, and 0.3 px median reprojection error — yet heading drifts a few degrees in one particular corridor and nowhere else. What do you measure next, and why is inlier ratio useless here?

Chapter 2: Match & Associate

Start with two numbers: 1000 and 1000. Descriptors from frame A, descriptors from frame B. How many comparisons? And of the matches you hand the estimator, how many are wrong?

The first number is easy. The second is the job.

The one fact that organises this entire chapter. Every query descriptor has a nearest neighbour. Always. Even when its true correspondence is not in the other image at all — occluded, out of frame, or a reflection that no longer exists. Nearest-neighbour matching cannot say "I do not know"; it can only say "this one." So the entire craft of matching is adding the ability to say I do not know.
Bridging, not repeating. Features, RANSAC & SfM introduces the Lowe ratio test with a worked example, and Classical SLAM covers nearest-neighbour and maximum-likelihood data association inside a filter. This chapter derives why the ratio test is the right statistic, computes the operating point you should ship, adds the mutual-consistency test those lessons do not cover, and puts real numbers on the combinatorics that make gating non-optional.

CONCEPT — two independent questions, two independent tests

A putative match (a, b) can be wrong in two structurally different ways, and each has its own test.

The questionFailure it catchesThe test
Is b distinctive among the candidates for a?a's true match is absent, or the scene is repetitive, so several b's are equally goodRatio test — compare the best distance to the second best
Is the relationship symmetric?Many a's collapse onto one popular b — a "descriptor attractor"Mutual consistency — b's own best match must be a

They are independent because they read different slices of the distance matrix: the ratio test reads one row, mutual consistency reads a row and a column. That independence is why applying both is worth more than tightening either one.

Deriving the ratio test — it is a likelihood ratio in disguise

Why compare to the second-nearest neighbour rather than thresholding the absolute distance? Here is the argument. It takes ninety seconds and is worth having ready.

Step 1 — absolute thresholds do not transfer. Descriptor distance depends on texture richness, blur, contrast and the descriptor's own scaling. A distance of 60 bits out of 256 is an excellent match on a crisp label and a hopeless one on a blurred one. There is no single number that works across an image, let alone across a fleet.

Step 2 — the second-nearest neighbour is a free noise estimate. If a's true partner is present, the nearest neighbour d1 is drawn from the "correct match" distribution while the second-nearest d2 is drawn from the "unrelated descriptor" distribution. If a's true partner is absent, both d1 and d2 come from the unrelated distribution — so they will be close together.

Step 3 — therefore r = d1/d2 is a local, self-calibrating statistic. The denominator absorbs exactly the local conditions the numerator suffers from. Blur inflates both; the ratio does not move. This is the same trick as a control group, and saying it that way lands well.

r = d1 / d2 ,    accept if r < τ     (Lowe 2004: τ = 0.8)

Step 4 — where 0.8 comes from. Lowe measured the two distributions on 40,000 keypoints and chose the threshold that maximised useful yield. At τ = 0.8, roughly 90% of false matches are eliminated while fewer than 5% of correct matches are discarded. That asymmetry — throw away 5% of the good to remove 90% of the bad — is the entire justification, and the next widget lets you watch it move.

Worked example 1 — four candidate matches, decided by hand

Binary descriptors, Hamming distance out of 256 bits, τ = 0.8.

Paird1d2r = d1/d2DecisionWhat it really was
A3814238/142 = 0.268acceptA crisp printed label. Textbook good match.
B110180110/180 = 0.611acceptA blurred corner. The absolute distance 110 looks terrible; the ratio says it is still the only plausible candidate.
C9610496/104 = 0.923rejectThe fourth shelf upright. Two candidates, both plausible, no way to choose.
D313431/34 = 0.912rejectTwo very good candidates — a logo printed twice on the same box. Tiny absolute distance, still ambiguous.

Rows B and D are the ones to point at. B has a large absolute distance and is accepted; D has a tiny absolute distance and is rejected. Any absolute threshold gets both of them backwards. That single observation is the derivation, compressed into a sentence you can say while drawing the table.

The ratio-test operating point — move τ and watch the tradeoff

Two ratio distributions: green = queries whose true partner is present, red = queries whose true partner is absent or ambiguous. The vertical line is your threshold τ; everything left of it is accepted. The second slider is the scene overlap — the fraction of query features that actually have a partner, which collapses on a fast turn or a large viewpoint change.

Threshold τ 0.80
Scene overlap π 0.60
 

Three things to do with it. One: park τ at 0.8 with overlap 0.6 and confirm the numbers — recall 0.95, false-acceptance 0.10, and F1 at its maximum. Lowe's threshold is not folklore; it is the argmax. Two: drag the overlap down to 0.2, which is what a fast turn looks like, and watch precision at τ = 0.8 collapse while recall does not move at all — precision depends on the prior, recall does not. Three: at overlap 0.2, find the new F1-optimal τ. It is well below 0.8. The correct threshold is a function of your scene, and shipping one constant is a choice you should be able to defend.

CONCEPT — mutual consistency, and why it is not redundant

The ratio test reads one row of the distance matrix. It is blind to a pathology that lives in the columns: many-to-one collapse. A single descriptor in image B — a specular highlight, a low-contrast strut, a compression artefact — can be the nearest neighbour of a dozen different A descriptors, each with a comfortable ratio, because none of those A descriptors has a real partner and this one attractor happens to sit closest to all of them.

The fix costs one extra argmin along the other axis:

accept (a, b) only if   NNA→B(a) = b  and  NNB→A(b) = a

Twelve A descriptors all pointing at one b produce at most one surviving match, because b points back at only one of them. Eleven false matches deleted by an operation over a matrix you already computed.

Say the cost out loud. Mutual consistency is not free in recall: a correct match dies when a slightly-better wrong candidate claims the same target first. On the synthetic bench in the Code Lab below, adding mutual consistency on top of the ratio test moves precision from 0.843 to 0.977 and costs one point of recall (0.86 → 0.85). Being able to quote both sides of that trade is what separates "I use cross-check" from "I know what cross-check costs."

Worked example 2 — the combinatorics, and why gating is not optional

Now the discrete side. Suppose you have M measurements in this frame and N landmarks in the local map. Each measurement can be assigned to any landmark or to "none of them" — it might be a new landmark, or spurious. So the number of complete association hypotheses is

(N + 1)M

Step 1 — a modest case. M = 5 measurements, N = 20 landmarks. Hypotheses = 215. Building it up: 212 = 441, 213 = 9,261, 214 = 194,481, 215 = 4,084,101. Four million hypotheses from five measurements.

Step 2 — a realistic case. M = 10, N = 50. 5110 = 1.19 × 1017. At one hypothesis per nanosecond that is 3.8 years per frame. Exhaustive association is not slow; it is impossible, and it becomes impossible at scales that sound small.

Step 3 — gating collapses it. Project each landmark into the image with the predicted pose and its covariance, and keep only the landmarks whose 95% Mahalanobis gate contains the measurement — the chi-square test from Uncertainty & Robust Costs. In a well-predicted frame that shrinks the candidate set from 20 landmarks to about 2, so the hypothesis count becomes 35 = 243.

Step 4 — the reduction factor. 4,084,101 / 243 = 16,807×, and it came entirely from using the motion model you already had. Prediction is the cheapest form of data association. That sentence is the answer to "how does your system scale."

Step 5 — the residual problem gating does not solve. Individual gating tests each measurement against each landmark alone. A set of associations can be individually compatible and jointly absurd: measurement 1 to landmark A and measurement 2 to landmark B might each pass their own gate while the pair implies the robot is in two places at once. JCBB (Joint Compatibility Branch and Bound, Neira & Tardós 2001) searches over sets with a joint Mahalanobis distance and a chi-square threshold on the full stacked dimension. It is the classic answer to this problem, and worth knowing by name.

DESIGN — nobody brute-forces anything

Start from the cost of the naive thing, because that is what motivates every optimisation after it.

StrategyComparisonsArithmeticTime (one Orin CPU core)
Brute force, 256-bit binary1000 × 1000 = 1064 XOR + 4 POPCNT each ≈ 8 × 106 instructions~2.7 ms
Brute force, 256-d float32106 dot products2 × 106 × 256 = 512 MFLOP~51 ms (0.4 ms on the GPU)
Guided by projection, r = 25 px1000 × 2.13 = 2,130~6 µs
Bag-of-words restricted (DBoW2)~105~0.3 ms

Where 2.13 comes from, because the number looks invented until you derive it. Project a map point into the image with the predicted pose, and search a circular window of radius r = 25 px. The expected number of the frame's 1000 keypoints falling in that window, if they were spread uniformly over 1280 × 720, is

1000 × πr2 / (1280 × 720) = 1000 × 1963.5 / 921,600 = 2.13

So guided matching turns a million comparisons into two thousand — a 470× reduction — and it costs nothing but the motion prediction you were already computing. This is the highest-leverage single decision in the whole matching stage.

Choosing r honestly. Not by tuning. Propagate the pose covariance through the projection to get the predicted pixel standard deviation σu, and set r = 3σu. When tracking is healthy σu sits at 3–8 px; after a dropped frame it doubles, the window grows, the candidate count quadruples, and the ratio test starts rejecting more. That chain is worth narrating, because it shows you understand that the matcher's difficulty is set by the tracker's health — the feedback loop from Chapter 0, seen from the other end.

Bag-of-words, in one paragraph. Quantise every descriptor against a vocabulary tree (DBoW2: branching factor 10, depth 6, so 106 leaves) and restrict matching to descriptors that land in the same node at some intermediate level. It buys 10–30× on unguided matching, and — more importantly — the same index answers "which past keyframe looks like this one," which is the input to relocalisation and loop closure in Lesson 8.

The asymmetry worth drawing out: the 30 Hz tracking path is guided and costs microseconds of comparison; the expensive unguided path (relocalisation, loop-closure candidates) runs only at keyframe rate, roughly 3–5 Hz. Two matchers, two budgets, two accuracy requirements. A frontend diagram with a single matcher box is the sign of one that has never run.

CODE — the matcher, from scratch and then not

python — from scratch
import numpy as np

def hamming_matrix(dA, dB):
    """dA (Na,32) uint8, dB (Nb,32) uint8  ->  (Na,Nb) int distances"""
    x = np.bitwise_xor(dA[:, None, :], dB[None, :, :])      # (Na,Nb,32)
    return np.unpackbits(x, axis=-1).sum(-1)                     # popcount

def match(dA, dB, tau=0.8, mutual=True):
    D = hamming_matrix(dA, dB).astype(np.float64)
    order = np.argsort(D, axis=1)
    j1, j2 = order[:, 0], order[:, 1]
    rows = np.arange(len(dA))
    d1, d2 = D[rows, j1], D[rows, j2]

    keep = d1 < tau * np.maximum(d2, 1e-9)                   # ratio test — ONE row
    if mutual:
        back = D.argmin(axis=0)                                # each B's best A — the COLUMN
        keep &= (back[j1] == rows)                                # symmetry
    return np.stack([rows[keep], j1[keep]], axis=1)           # (M,2) index pairs

Two things to narrate while writing it. The argsort is wasteful — you only need the two smallest, so np.argpartition(D, 2, axis=1) is the right call at scale — the kind of detail you only learn by profiling. And the mutual check is one extra argmin over a matrix you already have: near-zero cost, large precision gain.

python — production
import cv2
bf = cv2.BFMatcher(cv2.NORM_HAMMING)              # NORM_L2 for SIFT / SuperPoint
knn = bf.knnMatch(desA, desB, k=2)
good = [m for m, n in knn if m.distance < 0.8 * n.distance]

# crossCheck=True gives mutual consistency but is INCOMPATIBLE with knnMatch(k=2),
# so one OpenCV call cannot give you both. Do the ratio test with knnMatch and
# filter mutually yourself — or run knnMatch both ways and intersect.

That comment is a genuine API trap and worth knowing: cv2.BFMatcher(crossCheck=True) silently requires match(), not knnMatch(k=2). Code that claims to use both usually uses only one.

DEBUG — the aisle where matching dies

Symptom. The robot enters aisle 7. Feature count stays flat at 1000 per frame. Match count falls from 420 to 80. Tracking survives, but the pose covariance inflates and the system declares a new keyframe every third frame. Nothing errors.

This is repetitive structure, and warehouses are built out of it: identical uprights every 1.2 m, identical bin labels, identical pallet corners. The ratio test is working exactly as designed — it sees two equally good candidates and refuses to guess. The refusal is correct. The problem is that appearance alone genuinely cannot resolve the ambiguity.

The metric that reveals it: the ratio-test rejection rate — the fraction of query features whose best match was discarded because r ≥ τ. Log it per frame; it costs nothing, since you already computed r.

ConditionFeatures/frameRejection rateDiagnosis
Healthy aisle1000~30%Normal
Repetitive structure1000~85%Features are fine; appearance is ambiguous
Dark / blurred / blank wall~120~30%Detection failed, not matching
Exposure step1000~55%Descriptors shifted globally; recovers in 2–3 frames

The pair of numbers is the diagnosis. Feature count and rejection rate together separate three failures that all present as "match count dropped". Holding that as a table rather than a guess is the difference between a senior diagnosis and a staff one.

The wrong fix is to raise τ to 0.95 so the matches come back. Drag the widget above there: the count recovers and precision falls off a cliff, so you have converted a visible failure into an invisible one — and Chapter 0 already showed what a confident wrong constraint does to a map.

The right fix is to stop asking appearance to do geometry's job. Guided matching with a 25 px projected window means the fourth upright, 300 px away, is never a candidate at all — so the second-nearest neighbour becomes a genuinely unrelated descriptor again and the ratio test recovers its discriminating power. The ambiguity did not go away; you removed it from the question. Secondary measures: reduce τ slightly rather than raise it, because in this scene a marginal match is more likely to be wrong; and let the keyframe policy fire more aggressively so baselines stay short and the search window stays tight.

FRONTIER — the matcher stopped being a nearest-neighbour search

This is the sub-field where learning has most clearly won, and the reason is structural: matching is a joint assignment problem, and nearest-neighbour search solves it one row at a time.

MethodYearThe one ideaCost
SuperGlueCVPR 2020Attention over both keypoint sets jointly, then a differentiable Sinkhorn solve for the optimal assignment — with an explicit dustbin for "no match"~70 ms/pair on a good GPU
LoFTRCVPR 2021Detector-free: coarse-to-fine dense matching on transformer features. Works on textureless surfaces where no detector fires at allHeavy; offline
LightGlueICCV 2023SuperGlue made adaptive — early exit on easy pairs, prune confidently unmatchable points. Often 5–10× faster at equal accuracy~10–30 ms/pair
RoMaCVPR 2024Dense robust matching on frozen DINOv2 features with a coarse-to-fine refiner; strongest available under extreme viewpoint changeOffline
MASt3RECCV 2024Predicts 3D pointmaps for both images in one shared frame and reads matches off the geometry — matching stops being a 2D appearance problemOffline; the direction to watch

What actually changed conceptually: the dustbin. SuperGlue's most-copied idea is not attention, it is that the assignment matrix carries an explicit extra row and column meaning "this point matches nothing." That is the ability to say I do not know — the same thing the ratio test and mutual consistency were hand-built to approximate — now learned end to end and enforced globally rather than per row.

The defensible position for a 30 Hz embedded frontend: keep the classical matcher on the tracking path, where guided matching has already reduced the problem to two candidates and a learned matcher has almost nothing to add. Put the learned matcher on the relocalisation and loop-closure path, which is unguided, wide-baseline and low-rate — exactly where classical matching is weakest. Same lesson as Chapter 1: put learning where the geometry is hardest and the rate is lowest.

Your matcher already runs a Lowe ratio test at 0.8. A colleague proposes adding a mutual-consistency check and you have to justify the extra pass. What does it catch that the ratio test structurally cannot, and what does it cost?

Chapter 3: RANSAC & Geometric Verification

Four hundred putative matches. About forty percent of them are wrong. You are going to fit an essential matrix. How many RANSAC iterations do you need — and what does that number not buy you?

The first half of that question is standard. The second half is where the engineering lives.

Bridging, not repeating. Features, RANSAC & SfM derives the iteration formula and walks a line-fitting example. This chapter derives it again with every assumption named out loud, because the assumptions are where it breaks; then adds the inlier threshold derivation, the adaptive stopping rule, the cost model in milliseconds, and the degeneracy that makes a perfect RANSAC result completely wrong.

CONCEPT — the derivation, in five lines and four assumptions

Fix the notation before you start, because a derivation is only as trustworthy as its symbols:

Line 1. The probability that one drawn point is an inlier is w.

Line 2. The probability that all s points in one minimal sample are inliers is ws — because the draws are independent.

Line 3. So the probability that a given sample is contaminated (contains at least one outlier) is 1 − ws.

Line 4. The probability that all N samples are contaminated is (1 − ws)N. That is the event we want to avoid, so we require it to be at most 1 − p.

Line 5. Take logs of both sides. Because 1 − ws < 1, its logarithm is negative, so the inequality flips:

(1 − ws)N ≤ 1 − p  ⇒  N · ln(1 − ws) ≤ ln(1 − p)  ⇒  N ≥ ln(1 − p) / ln(1 − ws)

Both logarithms are negative, so N is positive. If you cannot say why the inequality flipped, the derivation was recited.

Worked example 1 — a 2D line at 60% outliers

s = 2 (two points define a line), w = 0.4, p = 0.99.

Step 1. ws = 0.42 = 0.4 × 0.4 = 0.16. Only sixteen samples in a hundred are clean.

Step 2. 1 − ws = 1 − 0.16 = 0.84.

Step 3. ln(1 − p) = ln(0.01) = −4.60517.

Step 4. ln(0.84) = −0.174353.

Step 5. N = −4.60517 / −0.174353 = 26.41, so take N = 27.

Twenty-seven iterations to survive sixty percent garbage. That number surprises people, and it is the reason RANSAC is used at all.

Worked example 2 — the essential matrix at 50% outliers, 99.9% confidence

s = 5 (Nistér's five-point algorithm), w = 0.5, p = 0.999.

Step 1. ws = 0.55 = 1/32 = 0.03125. Three clean samples in a hundred.

Step 2. 1 − 0.03125 = 0.96875.

Step 3. ln(0.001) = −6.907755.

Step 4. ln(0.96875) = −0.031749.

Step 5. N = −6.907755 / −0.031749 = 217.58, so N = 218.

Drop the confidence to p = 0.99 and it becomes 4.60517/0.031749 = 145.05, so 146 — the last factor of ten in confidence costs you 50% more iterations, which is cheap. Confidence is not where the cost lives.

Where the cost lives is s and w, and the table below is the one worth memorising to one significant figure.

s ↓   w →0.20.30.40.50.60.70.8
2 — line, 2D rigid1134927171175
3 — P3P574169703519117
4 — homography2,8765671787234179
5 — essential14,3891,893448146572612
7 — fundamental359,77721,0552,8095881635420
8 — linear fundamental1,798,89370,1887,0251,1772727826
All at p = 0.99, rounded up. Read across for "how much does bad matching cost me"; read down for "why minimal solvers matter".
The two readings of that table. Read a row: for the essential matrix, going from 70% inliers to 40% multiplies your work by 448/26 = 17×. That is the entire economic argument for the ratio test and mutual consistency in Chapter 2 — they are not accuracy features, they are runtime features. Read a column: at w = 0.5, moving from the 8-point linear fundamental solver to the 5-point essential solver takes you from 1,177 iterations to 146, an saving. That is why people spent years finding minimal solvers, and it is why calibrating your camera (which lets you use the 5-point instead of the 7- or 8-point) has a runtime payoff on top of the accuracy one.
The iteration count, plotted — drag w and watch the exponential bite

N against inlier ratio on a logarithmic vertical axis, one curve per minimal sample size. The dot marks your current operating point. Two things to feel: the curves are straight-ish on a log axis (so N is roughly exponential in s), and every curve goes vertical on the left.

Inlier ratio w 0.50
Sample size s 5
Confidence p 0.990
 

CONCEPT — the four assumptions, and where each one breaks

The formula is five lines; the assumptions are the engineering.

AssumptionWhere it breaksWhat you do
1. Draws are independent (sampling with replacement)Small correspondence sets. With 400 matches and 200 inliers, the exact probability of a clean 5-sample is 0.03047, not ws = 0.03125 — a 2.5% optimism. Under ~50 matches it becomes material.Note it; use the hypergeometric form only if the set is genuinely tiny.
2. w is known in advanceAlways. You have no idea what fraction is good until you have fitted something.Adaptive N — see below. This is not optional.
3. A clean sample yields a correct modelDegeneracy. Five inliers that all lie on a plane give an essential matrix that fits them perfectly and describes a motion that never happened.An explicit degeneracy test. The DEBUG section below.
4. Inliers are separable by a distance thresholdThreshold too tight → w collapses and N explodes; too loose → outliers get in and the refined model is dragged.Derive t from the noise, not from taste.

Deriving the threshold instead of tuning it

The inlier threshold t is a knob everyone tunes and almost nobody derives, which makes deriving it a cheap way to stand out.

Model the localisation noise on a correct correspondence as zero-mean Gaussian with standard deviation σ per pixel coordinate — σ ≈ 1 px for a corner detector without subpixel refinement, ≈ 0.3 px with it. Then the squared residual divided by σ2 follows a chi-square distribution whose degrees of freedom equal the dimension of the residual:

t2 = χ2d, 0.95 · σ2
Residualdχ2d,0.95t at σ = 1 px
Point-to-epipolar-line (Sampson), F or E13.841√3.841 = 1.96 px
Symmetric transfer / reprojection, homography or PnP25.991√5.991 = 2.45 px

Now feel the cost of getting it wrong. Suppose the true σ is 1 px and the true inlier ratio at the correct threshold is w = 0.5. Set t = 1.0 px instead of 1.96, and you keep only the residuals within one standard deviation — about 68% of the genuine inliers — so the apparent inlier ratio drops to 0.5 × 0.68 = 0.34. Feed that back into the formula with s = 5:

0.345 = 0.0045435  →  ln(1 − 0.0045435) = −0.0045539  →  N = 4.60517/0.0045539 = 1,011

146 iterations became 1,011 — a 7× runtime penalty — purely from a threshold set by feel. And it is a silent penalty: the system still works, it just eats your frame budget. That is a genuinely good thing to have found on a real system.

Adaptive N — the four lines that make RANSAC practical

Assumption 2 says you do not know w. The standard resolution is to start pessimistic and update as evidence arrives:

Initialise
N = ∞, best_inliers = 0, k = 0
↓ while k < N
Sample and fit
draw s correspondences, solve the minimal problem
Score
count points with residual < t
↓ if this beats the best so far
Update the budget
w ← best_inliers / total, then N ← ln(1−p) / ln(1−ws)
↻ k ← k + 1

The effect is dramatic in the good case. If the data is actually 80% inliers, the first decent sample sets w = 0.8 and N drops to 12, so the loop exits after roughly fifteen iterations rather than the 146 you would have budgeted for w = 0.5. Adaptive RANSAC is fast when the data is easy and only slow when it has to be, which is exactly the property you want inside a fixed frame budget.

Cap it anyway. An adaptive loop on genuinely hopeless data (w = 0.15, s = 5) computes N ≈ 60,000 and blows the frame. Ship max_iters and treat hitting it as a signal: log it, and let the tracker declare the frame untrusted rather than pretending.

DESIGN — two milliseconds, spent deliberately

The cost of RANSAC is iterations times the per-iteration cost, and the per-iteration cost has two parts:

T = N × ( Tsolve + M × Tresidual )

For the warehouse AMR: N = 218, the Nistér 5-point solver takes about 10 µs (it builds a 10th-degree polynomial and extracts its real roots), M = 400 correspondences, and a Sampson residual is about 5 ns.

T = 218 × (10 µs + 400 × 5 ns) = 218 × (10 + 2) µs = 218 × 12 µs = 2.6 ms

Note the split: 83% of the cost is the minimal solver, not the scoring. The instinct is to assume the opposite and optimise the scoring loop. On this workload that would recover at most 0.4 ms. The way to make RANSAC faster is to run fewer iterations, and there are three named ways to do that:

TechniqueIdeaWhat it buys
PROSAC (2005)Do not sample uniformly. Sort correspondences by descriptor quality (the ratio r is right there) and draw from a growing prefix of the sorted list.If the top 20% of matches are 90% inliers, then within that prefix w = 0.9 and N = ln(0.01)/ln(1−0.95) = 4.605/0.8928 = 5.16 → 6 iterations instead of 218. A 36× reduction for a sort you can afford.
LO-RANSAC (2003)When a sample beats the best so far, refine it by least squares on its inliers and re-score. A nearly-good sample becomes a good model.Reaches a given quality in fewer iterations, and the final model is better than any minimal solve.
MAGSAC++ (CVPR 2020)Stop choosing t. Marginalise the model quality over a range of plausible noise scales and weight each point by its marginal likelihood.Removes assumption 4 entirely. Now the default USAC_MAGSAC in OpenCV.

Where geometric verification sits. Immediately after matching and before any pose is trusted. Its inputs are (M, 2) correspondence indices; its outputs are an (M,) boolean inlier mask and a model. The mask is the product that matters — the model from RANSAC is a by-product you usually throw away and re-estimate from all the inliers, because a model fitted to a minimal sample is the noisiest possible estimate.

The sentence to have ready: "RANSAC's output is not a model, it is a partition. The model you ship is the one you re-fit on the inlier set afterwards." Returning the minimal-sample model is the classic mistake.

CODE — adaptive RANSAC from scratch, then the library

python — from scratch
import numpy as np, math

def ransac(data, fit, residual, s, t, p=0.99, max_iters=2000):
    """fit(sample)->model ; residual(model, data)->(n,) distances"""
    n = len(data)
    rng = np.random.default_rng(0)
    best_mask = np.zeros(n, bool)
    N, k = float('inf'), 0

    while k < N and k < max_iters:
        idx = rng.choice(n, s, replace=False)
        model = fit(data[idx])
        k += 1
        if model is None:                          # degenerate minimal sample
            continue
        mask = residual(model, data) < t
        if mask.sum() > best_mask.sum():
            best_mask = mask
            w = max(best_mask.sum() / n, 1e-6)      # the adaptive step
            denom = max(1 - w ** s, 1e-12)
            N = math.log(1 - p) / math.log(denom)

    # RANSAC returns a PARTITION. Re-fit on the whole inlier set.
    return fit(data[best_mask]), best_mask, k

Then the production call, and the flags that matter:

python — production
import cv2
E, mask = cv2.findEssentialMat(pts1, pts2, K,
                                  method=cv2.USAC_MAGSAC,   # not RANSAC; MAGSAC++ since 4.5.4
                                  prob=0.999,               # this is p
                                  threshold=1.0)            # pixels — derive it, do not guess
n_in, R, t, _ = cv2.recoverPose(E, pts1, pts2, K, mask=mask)

# The degeneracy check that recoverPose will NOT do for you:
H, maskH = cv2.findHomography(pts1, pts2, cv2.USAC_MAGSAC, 2.45)
if maskH.sum() > 0.9 * mask.sum():
    # the inliers are (near) coplanar — E is not identifiable from them
    pass

DEBUG — the failure RANSAC cannot see

Symptom. The AMR drives aisle 7, where a long flat shelf face fills most of the frame. RANSAC on the essential matrix reports 85% inliers. Median Sampson residual 0.4 px. Tracking never drops. And the reported translation direction flips between consecutive frames, and the trajectory folds.

Every RANSAC health metric is excellent, and that is the tell. This is assumption 3 failing: a clean sample does not guarantee a correct model when the inliers are degenerate.

The geometry. If all the matched points lie on a plane, the two views are related exactly by a homography H. Any essential matrix consistent with that homography also explains the data perfectly — and there is a one-parameter family of them. RANSAC finds one member of that family, essentially at random, and each frame it picks a different member. Hence the flipping. The inliers are real inliers; the model is unidentifiable from them.

The metric that reveals it. Fit both models to the same correspondences and compare how well each explains the data:

RH = SH / (SH + SE)

where SH and SE are the inlier scores of the homography and the essential matrix. ORB-SLAM's initialisation uses exactly this and chooses the homography when RH > 0.45. The simpler form to remember is the ratio of inlier counts:

H inliers / E inliersReadingWhat to do
< 0.5General 3D structure. E is identifiable.Use E, proceed.
0.5 – 0.9Mixed. A dominant plane plus some off-plane structure.Use E but widen the pose covariance; do not create landmarks from the plane.
> 0.9Degenerate. A homography explains the data as well as E does, so E carries no extra information.Do not estimate E. Decompose H, or fall back on the other sensor, or refuse the frame.

Why the decoys do not fit. Rolling shutter would produce a skew that scales with angular rate, not a direction flip at constant speed. A sync error would be speed-dependent. Too few features would be noisy and jumpy, not smooth with a great inlier ratio. A clean, high-confidence model that changes its mind every frame is the signature of an unidentifiable parameter, and that phrasing transfers to every estimation problem you will ever debug.

The second degeneracy worth naming while you are there: pure rotation. With zero translation, E = [t]×R is identically zero and the five-point solver returns noise. Chapter 6 gives the parallax gate that catches it.

FRONTIER — robust estimation after RANSAC

WorkYearThe one idea
OANetICCV 2019Learn inlier/outlier classification on the correspondence set directly, with permutation-invariant layers plus a differentiable pooling that captures local consensus.
MAGSAC++CVPR 2020Marginalise over the noise scale σ instead of picking a threshold. Now OpenCV's USAC_MAGSAC.
"RANSAC in 2020"2020–21Barath et al.'s systematic benchmark: the honest finding is that the combination (PROSAC ordering + LO + MAGSAC scoring + a good stopping rule) beats any single clever idea.
VSACICCV 2021A cheap independence test on the sample plus dominant-plane detection built in — degeneracy handling promoted from an afterthought to a first-class step.
LightGlue confidenceICCV 2023A learned matcher that emits calibrated confidences raises w before RANSAC even starts, which the table above says is worth more than any RANSAC improvement.

The position to defend: RANSAC is not going away, because it is the only component in the frontend with a provable guarantee — you can state the probability that it found a clean sample. What is going away is uniform sampling and hand-set thresholds. The realistic 2026 stack is a learned matcher supplying confidences into a PROSAC-ordered, locally-optimised, MAGSAC-scored solver, with an explicit degeneracy test bolted on the end because none of the above will catch a plane for you.

A teammate reports "RANSAC gave us 85% inliers and 0.4 px residual, so the pose is good." You know the scene is a flat warehouse wall. What is wrong with that inference, and what single measurement settles it?

Chapter 4: Direct vs Feature-Based

Your detector finds forty corners on a warehouse wall. The wall is not flat white — it has scuffs, a paint seam, a gentle brightness gradient from the strip light. So you just discarded nine hundred thousand pixels, most of which carry some information. Can you defend that?

This is the direct-versus-indirect question, and answering it well means understanding that feature extraction is a lossy compression with an assumption baked in, not a free lunch.

The bargain, stated once and then unpacked. Feature-based methods assume a small set of points can be re-identified by appearance, and buy invariance to brightness, a huge convergence basin, and the ability to relocalise from nothing. Direct methods assume brightness is constant, and buy every gradient pixel in the image, sub-pixel precision without a descriptor, and operation on surfaces where no corner exists. Each pays for its purchase with the assumption it made.

CONCEPT — derive the photometric residual and where it leads

Start from the physical claim. Brightness constancy says that the same surface point, seen from two poses, produces the same intensity:

I2( π(T · π−1(x, d)) ) = I1(x)

Reading it right to left: take pixel x in the first image, un-project it with its inverse depth d to a 3D point, move it by the pose T, project it back into the second image, and look up the intensity there. The claim is that it equals the intensity you started with. The photometric residual is the amount by which that claim fails:

r(T) = I2( w(x; T, d) ) − I1(x)

Note what is not in that equation: no descriptor, no matching, no discrete correspondence. The correspondence is implied by the pose. That is the whole idea, and it is why direct methods have no data-association problem in the sense of Chapter 2 — they have a much harder optimisation problem instead.

Now linearise, because the residual is nonlinear in T. For a small increment δ to the warp parameters:

r(T ⊕ δ) ≈ r(T) + ∇I2 · ∂w/∂δ · δ

Stack all the pixels, call the row vector J = ∇I2 · ∂w/∂δ, and minimise the sum of squares. You get the Gauss–Newton normal equations, which are the Lucas–Kanade equations:

( Σ JTJ ) δ = − Σ JT r
The observation that ties this chapter to Chapter 1. For a pure 2D translation, ∂w/∂δ is the identity, so J = ∇I and the left-hand matrix is Σ ∇I ∇IT — the structure tensor. That is the same 2×2 matrix Harris thresholds to decide whether a pixel is a corner. Direct methods solve a linear system whose matrix is the corner detector. The difference is that a feature-based pipeline thresholds that matrix and keeps only the well-conditioned locations, while a direct method keeps everything and lets the conditioning sort itself out in the sum. Once you see that identity, the two families stop looking like strangers.

Worked example 1 — one Lucas–Kanade step, by hand

Reduce to one dimension so the arithmetic fits on a whiteboard. Template T sampled at two pixels; current image I; unknown shift p.

Suppose at those two pixels the image gradients are g1 = +10 and g2 = −6 intensity levels per pixel, and the current photometric errors (I − T at the current estimate) are e1 = −4 and e2 = +3 intensity levels.

Step 1 — write the linearised residual. ri(p) = ei + gi p.

Step 2 — the cost. C(p) = (e1 + g1p)2 + (e2 + g2p)2.

Step 3 — differentiate and set to zero. dC/dp = 2g1(e1+g1p) + 2g2(e2+g2p) = 0, so p (g12 + g22) = −(g1e1 + g2e2).

Step 4 — the Hessian. Σg2 = 102 + (−6)2 = 100 + 36 = 136.

Step 5 — the gradient term. Σg·e = (10)(−4) + (−6)(3) = −40 − 18 = −58.

Step 6 — the step. p = −(−58)/136 = 58/136 = 0.4265 pixels.

Apply it, re-sample the image at the new position, recompute e, repeat. Two things to notice, and to say. First, that 136 in the denominator is the information: a low-gradient region contributes a tiny number and the step becomes huge and untrustworthy, which is exactly the aperture problem. Second, the whole method rests on the linearisation being valid, which requires p to be small compared with the width of the image structure — typically 1–3 pixels at the finest pyramid level. That is the convergence basin, and it is the direct method's central weakness.

The convergence basin, in numbers you can quote

Take the warehouse AMR: f = 500 px, 30 Hz.

MotionPixel velocityDisplacement per frameInside a 3 px basin?
Translating 1 m/s, feature at Z = 5 mf·v/Z = 500×1/5 = 100 px/s3.3 pxMarginal at level 0
Yawing 0.5 rad/s (a gentle turn)f·ω = 500×0.5 = 250 px/s8.3 pxNo
Yawing 2 rad/s (a fast turn)f·ω = 1000 px/s33.3 pxNowhere close

Rotation is the killer, and the reason is structural: translation-induced pixel motion scales as f·v/Z and gets small for distant scene points, but rotation-induced pixel motion is f·ω with no depth in it at all. Every pixel moves, near and far alike. This is why direct methods are always paired with (a) a coarse-to-fine pyramid — four levels at scale 2 multiplies the 3 px basin by 23 = 8, giving about 24 px — and (b) an IMU prior, which predicts the rotation so accurately that the residual motion is back inside the basin. A direct VO with no IMU and no pyramid does not survive a head turn, and being able to say why in one sentence is the point.

CONCEPT — the bargain broken: brightness is not constant

Real cameras violate brightness constancy constantly, and each violation has a name:

ViolationCauseEffect on I
Auto-exposure / auto-gainThe camera is doing its jobGlobal multiplicative and additive shift, changing between frames
VignettingLens falloff toward the cornersSpatially varying multiplicative factor, fixed per lens
Non-linear responseThe sensor's transfer curve is not linear in irradianceA fixed monotone warp of every intensity
Non-Lambertian surfacesPolished concrete, steel, glassIntensity depends on viewing angle — unfixable by calibration
FlickerMains-frequency lighting beating against the exposureGlobal intensity oscillation at a few Hz

Worked example 2 — what one auto-exposure step costs, and how two numbers fix it

The auto-exposure controller raises the gain by 20% between two frames. Nothing moved.

Step 1 — the residual. At a pixel whose true intensity is I1 = 120, the new intensity is I2 = 1.2 × 120 = 144, so the photometric residual is r = 144 − 120 = 24 intensity levels. A well-tracked pixel normally sits at r ≈ 3–5. This is a five to eight times inflation of every residual in the image, simultaneously.

Step 2 — what motion would explain it. Where the local gradient is 10 intensity levels per pixel, the optimizer must move by 24 / 10 = 2.4 pixels to make that residual vanish. That is the same order as the entire convergence basin at the finest level, and it appears in one frame with no warning.

Step 3 — whether it becomes a pose error. Be precise here, because the honest answer is better than the dramatic one. The apparent displacement at each pixel points along that pixel's own gradient. If gradient directions are isotropic, much of the effect cancels in the sum and you mostly get inflated residuals rather than a large bias. If the scene has a dominant gradient direction — a corridor, a horizon, a long shelf edge, which is to say most robot environments — it does not cancel and you get a bias along that direction.

Step 4 — what is guaranteed to break regardless. The residual budget. The median photometric residual jumps from about 4 to about 24; the robust kernel, tuned for the 4, down-weights nearly every measurement; the effective number of constraints collapses; and the covariance you report becomes fiction. Even in the lucky isotropic case, your uncertainty is now wrong even if your mean is not.

Step 5 — the fix, and why it is so cheap. Put the violation into the model. Replace the residual with an affine brightness version carrying two extra unknowns per frame:

r = I2(w(x)) − ( a · I1(x) + b )

With a = 1.2 and b = 0, the residual returns to exactly zero at every pixel. Two unknowns cancel a corruption of sixteen thousand measurements, which is one of the best returns on a parameter in all of robotics, and DSO adds exactly these (as ea and b, per keyframe) for exactly this reason. Beyond that, DSO does offline photometric calibration: it measures the sensor response curve and the vignette once, and reads the exposure time out of the frame metadata, so the affine parameters only have to absorb what calibration missed.

The brightness-constancy bench — break the assumption and watch the estimator lie

A 1D intensity profile. Teal is the template; orange is the current frame, shifted by the true amount and then scaled by the gain. Two Lucas–Kanade estimators run on it: plain (brightness constancy assumed) and affine (two extra unknowns a and b). Add gain and watch them separate.

True shift (px) 2.5
Exposure gain a 1.00
Black level b 0
 

Set the gain to 1.00 and both estimators recover the shift to three decimals: with brightness constancy holding, the two extra parameters cost nothing at all. Now drag the gain to 1.30. The plain estimator's answer walks away from the truth and keeps walking, while the affine estimator sits on the true value and simply reports a ≈ 1.30 — it absorbed the violation into the parameter that models it. The black-level slider does the same thing additively.

Then leave the gain at 1.00 and walk the true shift outward until both estimators break together. That point is the convergence basin, it is set by the spatial frequency of the image content and nothing else, and no brightness model rescues you from it. This demo profile is deliberately smooth, so its basin is wide (around 18–20 px); a real image at pyramid level 0 carries energy right down to the pixel scale and its basin is the 1–3 px quoted above. Same phenomenon, different content.

Two independent failure modes, two independent fixes: an affine model for the photometric one, a pyramid or an IMU prior for the geometric one. Conflating them is the classic way to get stuck.

DESIGN — where each method actually sits

Feature-based (indirect)Direct
AssumesA sparse set of points is re-identifiable by appearanceBrightness is constant (or affinely modelled)
MinimisesReprojection error, in pixelsPhotometric error, in intensity levels
Per keyframe pair~1000 points × 2 = 2,000 residuals~2000 points × 8-px pattern = 16,000 residuals
Needs before the optimizerDetect (9 ms) + describe (2 ms) + match (3 ms) = 14 msNothing. Warp and sample directly — but 8× the optimizer work
Convergence basinWhole image (matching is global)~3 px at the fine level, ~24 px with a 4-level pyramid
Low-texture surfacesFails — no corners fireWorks — any non-zero gradient contributes
Auto-exposure stepMostly survives — descriptors are contrast-normalisedFails unless the affine model is present
Relocalisation from nothingWorks — descriptors are the indexImpossible alone; needs a separate appearance module
Outlier handlingRANSAC on discrete correspondences — a clean partitionRobust kernels only — there is nothing discrete to reject

The last row is the one most people miss. A direct method has no RANSAC, because RANSAC needs a discrete hypothesis to test. Its only defence against a moving forklift filling a third of the frame is a robust kernel, and a robust kernel — as Chapter 0 established — down-weights what is currently large, which is not the same thing as rejecting what is wrong.

The third option nobody mentions: semi-direct. SVO (Forster et al. 2014) uses a sparse direct image alignment step to get the pose to within a pixel or two, then switches to feature-based refinement and reprojection error. It gets the direct method's speed and precision for the fine alignment and the feature-based method's outlier machinery and relocalisation for everything else. Whenever the question is framed as a binary, "semi-direct" is the answer — the field settled the argument a decade ago.

Numbers for the frame budget. A sparse direct alignment over 2000 points with an 8-pixel pattern and 4 pyramid levels costs roughly 16,000 × 4 = 64,000 residual evaluations, each a bilinear sample plus a subtract, at about 15 ns = ~1 ms per Gauss–Newton iteration, and you want 5–10 of them. So 5–10 ms, against the 14 ms that detection, description and matching cost. Direct is not obviously cheaper; it moves the cost from the frontend into the optimizer.

CODE — inverse-compositional alignment from scratch

The trick worth showing is the inverse-compositional formulation (Baker & Matthews 2004): use the template's gradient rather than the current image's, so the Hessian is constant across iterations and can be inverted once. This is the difference between a research implementation and one that runs at 30 Hz.

python — from scratch
import numpy as np

def sample(img, x):
    """1-D linear interpolation, clamped at the edges"""
    x = np.clip(x, 0, len(img) - 1.001)
    i = np.floor(x).astype(int); f = x - i
    return img[i] * (1 - f) + img[i + 1] * f

def align_ic(T, I, iters=30, affine=True):
    """shift of I relative to T. affine=True adds gain a and offset b."""
    xs = np.arange(len(T), dtype=float)
    gT = np.gradient(T)                       # template gradient — computed ONCE
    p, a, b = 0.0, 1.0, 0.0
    for _ in range(iters):
        Iw = sample(I, xs + p)
        r = Iw - (a * T + b)                    # photometric residual
        if affine:
            J = np.stack([gT, -T, -np.ones_like(T)], axis=1)   # (n,3)
        else:
            J = gT[:, None]                                    # (n,1)
        Hm = J.T @ J
        d = np.linalg.solve(Hm + 1e-9 * np.eye(len(Hm)), -J.T @ r)
        p += d[0]
        if affine:
            a -= d[1]; b -= d[2]
        if abs(d[0]) < 1e-7:
            break
    return p, a, b
python — production
import cv2
# Sparse pyramidal LK — the workhorse for direct/semi-direct tracking
p1, st, err = cv2.calcOpticalFlowPyrLK(
        prev_gray, gray, p0, None,
        winSize=(21, 21),                 # the aperture — bigger = more stable, less local
        maxLevel=3,                        # 4 levels: basin ~3px -> ~24px
        criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01))
good = st.ravel() == 1              # LK reports its OWN failures — use this
# For a full direct SLAM system: DSO (github.com/JakobEngel/dso) — note that it
# requires photometric calibration files, not just K. That requirement IS the method.

DEBUG — the pose spike with a timestamp you can look up

Symptom. A direct VO on an AMR. The trajectory is smooth for minutes at a time, then shows a sharp translation spike of 8–15 cm in a single frame, then continues smoothly. It happens two or three times per run, always when the robot passes from an aisle into the open floor or approaches the loading-dock door.

The location clue is the whole diagnosis: those are the places where the scene's overall brightness changes fast enough that the auto-exposure controller steps the gain.

The metric that reveals it, in three parts.

  1. Log the frame mean intensity per frame — one number, essentially free.
  2. Log the median photometric residual per frame — you compute it anyway.
  3. Correlate the pose-increment norm against |Δ mean intensity| across the run.

On a healthy run the correlation is near zero. On this failure it is strongly positive, and the spike frames sit at Δmean > 8 intensity levels with the median residual jumping from about 4 to about 20–25 — exactly the arithmetic of worked example 2.

The decoy this separates from. Every symptom above is also consistent with motion blur from a fast turn. Two things distinguish them without ambiguity: motion blur correlates with gyro rate, not with intensity change; and motion blur degrades the residual gradually over several frames, while an exposure step is one frame wide with clean frames on both sides. Plot the pose spike against gyro rate and against Δmean intensity, and whichever one lights up is the answer.

The fix ladder, in order of how much you should like it:

  1. Add the affine brightness parameters (a, b) per frame. Two unknowns. This should have been there from the start.
  2. Do offline photometric calibration — response curve, vignette — and read the exposure time from the frame metadata so the model has less to absorb.
  3. Lock the exposure where the product allows it. Effective, and often refused by the perception team who need auto-exposure for detection.
  4. Switch that segment to feature-based tracking. Descriptors are contrast-normalised and largely survive a gain step. This is the semi-direct argument again, arriving from the failure side.

FRONTIER — the frontend is being absorbed

SystemYearThe one idea
DSOTPAMI 2018 (arXiv 2016)Sparse direct done properly: photometric calibration, affine brightness, joint optimisation of pose, inverse depth and the calibration — the reference direct system.
DROID-SLAMNeurIPS 2021Learned dense optical flow feeding a differentiable dense bundle adjustment layer, iterated. Neither direct nor feature-based — the correspondence is predicted, then geometry refines it.
DPVONeurIPS 2023DROID's accuracy at a fraction of the cost by tracking sparse patches rather than a dense field. The first learned VO with a plausible embedded story.
DPV-SLAMECCV 2024DPVO plus loop closure and global optimisation — a complete learned system, real-time on a single GPU.
MASt3R-SLAMCVPR 2025Two-view 3D reconstruction as the frontend: no detector, no descriptor, no explicit matcher, and no camera calibration required.

What the trend actually is, stated so it survives the next paper: the direct/indirect distinction is being dissolved from underneath. Both are hand-designed answers to "how do I get correspondence," and learned models are becoming a third answer that is better than either on the hard cases. What is not being dissolved is the geometry behind them — DROID-SLAM's core is still a bundle adjustment, MASt3R-SLAM still does a pose graph. Learning is replacing the correspondence stage; it is not replacing the estimator. That framing is the safe, defensible answer to any "will learning replace SLAM" question, and it has stayed true for eight years.

You propose a direct VO for a robot that must operate in a warehouse with long stretches of blank painted wall. Your manager asks what could go wrong that would not go wrong with a feature-based system. Give the two strongest answers.

Chapter 5: Point Cloud Registration

Now switch sensors. Forget cameras: two LiDAR scans, twenty thousand points each, taken a tenth of a second apart. There are no descriptors, no keypoints, and no correspondences at all — just two bags of coordinates. Find the transform between them.

This is the problem that separates people who have used ICP from people who have debugged it, and the fastest way to get it wrong is to say "run ICP" without saying what ICP is.

The chicken and egg, stated precisely. To know which source point corresponds to which target point, you need the transform. To compute the transform, you need the correspondences. ICP — Iterative Closest Point, Besl & McKay 1992 — breaks the loop by alternating: guess the transform, assign each source point to its closest target point, solve for the transform that best explains those assignments, repeat. It is coordinate descent on a joint problem, which is why it converges monotonically and why it converges to a local minimum.

CONCEPT — the closed form, derived

Given N putative correspondences (pi, qi), find the rotation R and translation t minimising

E(R, t) = Σi ‖ R pi + t − qi2

This has an exact solution in closed form, which surprises people who expect to iterate. Four steps.

Step 1 — the translation decouples. Differentiate with respect to t and set to zero:

∂E/∂t = 2 Σi (R pi + t − qi) = 0  ⇒  N t = Σqi − R Σpi  ⇒  t = q̄ − R p̄

The optimal translation always maps the source centroid onto the target centroid, whatever R turns out to be. This is the single most useful fact in the derivation, and it is what makes the rest tractable.

Step 2 — substitute it back. Write ai = pi − p̄ and bi = qi − q̄ for the centred clouds. Then E = Σ ‖R ai − bi2. Expanding the square:

E = Σ ( aiTai + biTbi ) − 2 Σ biT R ai

(using RTR = I so ‖Ra‖ = ‖a‖). The first bracket does not contain R at all, so minimising E is the same as maximising Σ biT R ai.

Step 3 — turn it into a trace. A scalar equals its own trace, and the trace is cyclic, so bTRa = tr(bTRa) = tr(R a bT). Summing:

Σ biT R ai = tr( R H )    where    H = Σi ai biT

H is the cross-covariance matrix — 2×2 in the plane, 3×3 in space. It is the only thing about your point clouds that the rotation depends on.

Step 4 — maximise the trace with an SVD. Take H = UΣVT. Then tr(RH) = tr(R U Σ VT) = tr(VTR U Σ). Call M = VTRU, which is orthogonal because everything in it is. Then tr(MΣ) = Σk mkkσk, and since every entry of an orthogonal matrix satisfies |mkk| ≤ 1 while every σk ≥ 0, the maximum is reached when M = I. Therefore VTRU = I, giving

R = V UT ,    with the guard    R = V · diag(1, …, 1, det(VUT)) · UT
The guard is the detail that matters. The maximisation above searched over all orthogonal matrices, and orthogonal includes reflections (det = −1). When the data is nearly degenerate — collinear points from a corridor wall, or a genuinely mirrored configuration — the unguarded V UT can come back with determinant −1, which is a reflection, not a rotation. Flipping the sign of the last column of V gives the best proper rotation. Writing the SVD solution with the determinant check means you have implemented this; omitting it means you have only read about it.

Worked example 1 — three points, by hand, exactly

Source P = (0,0), (2,0), (1,3). Target Q is P rotated by an angle whose cosine is 0.8 and sine is 0.6 (that is 36.87°, the 3–4–5 triangle), then translated by (2, −1). Recover the transform.

Step 1 — centroids. p̄ = ((0+2+1)/3, (0+0+3)/3) = (3/3, 3/3) = (1, 1). Applying the true transform to each point gives Q = (2, −1), (3.6, 0.2), (1.0, 1.4), whose centroid is q̄ = ((2+3.6+1.0)/3, (−1+0.2+1.4)/3) = (6.6/3, 0.6/3) = (2.2, 0.2).

Step 2 — centre both clouds.

a1 = (−1, −1)   a2 = (1, −1)   a3 = (0, 2)
b1 = (−0.2, −1.4)   b2 = (1.4, −0.2)   b3 = (−1.2, 1.6)

Step 3 — the shortcut that exists only in 2D. You do not need an SVD in the plane. The optimal angle is

θ = atan2( Σ(axby − aybx) , Σ(axbx + ayby) )

The numerator sums the cross products, the denominator sums the dot products. Compute them term by term.

Cross products:
a1×b1 = (−1)(−1.4) − (−1)(−0.2) = 1.4 − 0.2 = 1.2
a2×b2 = (1)(−0.2) − (−1)(1.4) = −0.2 + 1.4 = 1.2
a3×b3 = (0)(1.6) − (2)(−1.2) = 0 + 2.4 = 2.4
Sum = 1.2 + 1.2 + 2.4 = 4.8

Dot products:
a1·b1 = (−1)(−0.2) + (−1)(−1.4) = 0.2 + 1.4 = 1.6
a2·b2 = (1)(1.4) + (−1)(−0.2) = 1.4 + 0.2 = 1.6
a3·b3 = (0)(−1.2) + (2)(1.6) = 0 + 3.2 = 3.2
Sum = 1.6 + 1.6 + 3.2 = 6.4

Step 4 — the angle. θ = atan2(4.8, 6.4) = arctan(0.75) = 36.87°. Exact, with no numerical method anywhere. And 4.8/6.4 = 3/4 is the 3–4–5 triangle we started from, which is a satisfying check to point out.

Step 5 — the translation. R = [[0.8, −0.6], [0.6, 0.8]], so R p̄ = (0.8−0.6, 0.6+0.8) = (0.2, 1.4), and t = q̄ − R p̄ = (2.2 − 0.2, 0.2 − 1.4) = (2, −1). Exactly the truth.

For the record, the same numbers through the SVD route: H = Σ a bT = [[1.6, 1.2], [−3.6, 4.8]], and V UT comes out as the same rotation with determinant +1. The 2D shortcut and the general SVD agree, as they must — but knowing the shortcut lets you do a 2D registration on a whiteboard in ninety seconds.

CONCEPT — point-to-plane, and why it converges five times faster

Point-to-point treats every correspondence as a full vector constraint. But a LiDAR point on a wall does not know where on the wall it is — the beam happened to land there. Constraining its lateral position is inventing information.

Point-to-plane (Chen & Medioni 1991) projects the residual onto the target's surface normal, so only the component perpendicular to the surface counts:

E = Σi [ niT ( R pi + t − qi ) ]2

Sliding along the surface is free, which is the correct model of what a plane actually tells you.

Worked example 2 — the same wall, both cost functions

A wall along the x-axis, sampled every metre at x = 0, 1, 2, 3, 4, all with y = 0, normal n = (0, 1). The source scan is the same wall displaced by (0.70, 0.10) — so the truth is a correction of (−0.70, −0.10), and note that 0.70 is more than half the 1 m point spacing.

Step 1 — nearest-neighbour correspondences. The source point (0.70, 0.10) is at distance √(0.302 + 0.102) = √0.10 = 0.3162 from the target (1, 0), and at √(0.702 + 0.102) = √0.50 = 0.7071 from (0, 0). So it snaps to (1, 0) — the wrong physical point, one metre along the wall. Same for (1.70, 0.10)→(2,0), (2.70,0.10)→(3,0), (3.70,0.10)→(4,0). The last source point (4.70, 0.10) has no target beyond it, so it snaps back to (4, 0) at distance 0.7071.

Step 2 — the point-to-point residuals (target minus source):

(0.30, −0.10)   (0.30, −0.10)   (0.30, −0.10)   (0.30, −0.10)   (−0.70, −0.10)

Step 3 — the point-to-point step is the mean of those (translation only, since the geometry is not rotating):

x: (0.30 × 4 − 0.70) / 5 = (1.20 − 0.70)/5 = 0.50/5 = +0.10     y: (−0.10 × 5)/5 = −0.10

The y correction is right. The x correction is +0.10, in the wrong direction — the truth is −0.70. The discretisation of the wall has manufactured a lateral force out of nothing, and on the next iteration the source sits at 0.80 with the same correspondences, so it keeps going the wrong way until the single edge point balances the other four. That is the classic ICP sliding artefact, and it is why point-to-point on a flat surface takes thirty to fifty iterations and lands somewhere arbitrary.

Step 4 — the point-to-plane residuals are nT applied to the same vectors, which simply reads off the second component:

−0.10   −0.10   −0.10   −0.10   −0.10

All five identical. The spurious x components, including the edge point's −0.70, are projected away exactly. The step is a pure y translation of −0.10, which is correct and complete in one iteration, and the x direction is left correctly unconstrained because a flat wall genuinely contains no information about sliding along it.

The sentence to say: "Point-to-plane converges in five to ten iterations where point-to-point takes thirty to fifty, and the reason is not that it is a better optimiser — it is that it stops asserting information the sensor never measured." Generalised Alignment-ICP (GICP, Segal et al. RSS 2009) takes the same idea further: model both clouds as locally planar with covariances and use a plane-to-plane cost, so you get the point-to-plane behaviour without needing reliable normals on the source.

CONCEPT — NDT, the version with no correspondences at all

NDT (Normal Distributions Transform, Biber & Straßer 2003) removes the nearest-neighbour search entirely. Voxelise the target once; fit a Gaussian to the points in each voxel; then score a transform by how likely the transformed source points are under those Gaussians.

score(T) = − Σi exp( −½ (T pi − μk)T Σk−1 (T pi − μk) )

Take one voxel containing five points: (0.1, 0.2), (0.3, 0.1), (0.2, 0.4), (0.5, 0.3), (0.4, 0.5).

Mean: x: (0.1+0.3+0.2+0.5+0.4)/5 = 1.5/5 = 0.30. y: (0.2+0.1+0.4+0.3+0.5)/5 = 1.5/5 = 0.30. So μ = (0.30, 0.30).

Centred points: (−0.2,−0.1), (0,−0.2), (−0.1,0.1), (0.2,0), (0.1,0.2).

Scatter: Σx2 = 0.04+0+0.01+0.04+0.01 = 0.10. Σy2 = 0.01+0.04+0.01+0+0.04 = 0.10. Σxy = 0.02+0−0.01+0+0.02 = 0.03. Dividing by n−1 = 4:

Σ = [ [0.0250, 0.0075], [0.0075, 0.0250] ]

Inverse: det = 0.0250×0.0250 − 0.00752 = 0.000625 − 0.00005625 = 0.00056875, so Σ−1 = [[43.956, −13.187], [−13.187, 43.956]].

Now the payoff. Take two displacements of equal Euclidean length 0.1414: d = (0.1, 0.1) along the voxel's long axis, and d = (0.1, −0.1) across it.

dEuclideandTΣ−1dscore
(0.1, 0.1)0.14140.43956 + 0.43956 − 0.26374 = 0.61538e−0.30769 = 0.735
(0.1, −0.1)0.14140.43956 + 0.43956 + 0.26374 = 1.14286e−0.57143 = 0.565

Same distance, different score. The voxel has learned the local surface orientation from its own points and penalises movement across the surface more than movement along it — which is the point-to-plane idea, obtained for free from a covariance instead of a normal estimate. This is why NDT is anisotropic without any explicit normal computation, and it is the answer to "what does NDT buy you over ICP" that shows you have read past the abstract.

ICP (point-to-point)ICP (point-to-plane)NDT
Correspondence searchKD-tree, every iterationKD-tree + normalsNone — a voxel lookup, O(1)
ObjectivePiecewise smooth (jumps when correspondences flip)Piecewise smoothSmooth — differentiable everywhere
Convergence basinNarrowNarrowWider, set by voxel size
Iterations to converge30–505–1010–20
Sensitive toInitialisation, sampling densityNormal qualityVoxel size — too small is spiky, too large is blurred

CONCEPT — correspondence rejection, the step nobody writes down

Every ICP iteration produces a correspondence for every source point, including points that have no business having one: a person walking through the scan, a pallet that moved, the parts of the scan that fell outside the previous view. Four standard rejectors, in rough order of value:

RejectorRuleCatches
DistanceDrop pairs with d > τ·median(d), typically τ = 2–3Dynamic objects, non-overlapping regions. The single highest-value rejector.
Trimmed (TrICP)Sort by distance, keep the best fraction φ (say 80%)Same, but with a guaranteed inlier count — useful when the overlap is known.
Normal compatibilityDrop pairs whose surface normals differ by more than ~45°Points matched across a corner onto the wrong face.
Duplicate / many-to-oneKeep only the best source point claiming each target pointThe same collapse as Chapter 2's mutual-consistency test — the identical pathology in a different sensor.

The median-scaled distance rejector deserves the emphasis, because it is adaptive. A fixed threshold like "reject beyond 1 m" is right at the start of a badly-initialised registration and hopelessly loose at convergence. Scaling by the current median tightens automatically as the alignment improves — and KISS-ICP (2023) built its whole reputation on doing exactly this well.

DESIGN — the numbers that make or break a LiDAR frontend

A 128-beam spinning LiDAR at 10 Hz produces 128 × 1800 = 230,400 points per scan, which is 2.3 million points per second. You have 100 ms per scan, and you are not getting all of it.

StageDetailCost
Deskew (motion compensation)Each point has its own timestamp within the 100 ms sweep; at 1.5 m/s the first and last points are 15 cm apart in the world~3 ms
Voxel downsample, 0.4 m230,400 → ~15,000 points in a warehouse-sized scene~4 ms
KD-tree build on the local mapn log n ≈ 20,000 × 14.3 = 286,000 operations~5 ms
NN queries, per iteration15,000 queries × log n~4 ms
Solve + apply, per iteration3×3 SVD plus a transform<0.5 ms

Now do the budget. Point-to-point at 40 iterations: 3 + 4 + 5 + 40 × 4.5 = 192 ms. That misses the 100 ms deadline by a factor of two. Point-to-plane at 8 iterations: 3 + 4 + 5 + 8 × 4.5 = 48 ms. Comfortable. The choice of cost function is a real-time decision before it is an accuracy decision, and stating it that way is the point of this section.

Three architectural decisions that follow from those numbers:

CODE — ICP from scratch, then the library

python — from scratch
import numpy as np

def best_rigid(A, B):
    """closed-form R, t minimising ||R a + t - b||^2   (Arun / Umeyama / Kabsch)"""
    ca, cb = A.mean(0), B.mean(0)
    H = (A - ca).T @ (B - cb)                       # cross-covariance
    U, S, Vt = np.linalg.svd(H)
    d = np.sign(np.linalg.det(Vt.T @ U.T))         # the REFLECTION GUARD
    D = np.diag([1.0] * (len(S) - 1) + [d])
    R = Vt.T @ D @ U.T
    return R, cb - R @ ca

def icp(src, dst, T0=None, iters=40, tau=2.5):
    R = np.eye(src.shape[1]) if T0 is None else T0[0]
    t = np.zeros(src.shape[1]) if T0 is None else T0[1]
    for _ in range(iters):
        S = (R @ src.T).T + t
        d2 = ((S[:, None, :] - dst[None, :, :]) ** 2).sum(-1)   # use a KD-tree for real n
        j = d2.argmin(1)
        d = np.sqrt(d2[np.arange(len(S)), j])
        keep = d < tau * np.median(d)                # ADAPTIVE rejection
        if keep.sum() < src.shape[1] + 1:
            break
        dR, dt = best_rigid(S[keep], dst[j][keep])
        R, t = dR @ R, dR @ t + dt                   # compose, do not replace
    return R, t

The two lines to narrate: the reflection guard, and R, t = dR @ R, dR @ t + dt. That second one is a composition, not an assignment — the step you just solved is expressed in the current frame, so it multiplies on the left. Getting that wrong produces an ICP that oscillates rather than converges, and it is the single most common bug in a from-scratch implementation. Frames & Transforms owns why.

python — production
import open3d as o3d
reg = o3d.pipelines.registration.registration_icp(
        source, target,
        max_correspondence_distance=0.5,      # the rejector — the most important argument
        init=T_init,                             # from the IMU or constant velocity. NEVER identity
        estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPlane())
print(reg.transformation, reg.fitness, reg.inlier_rmse)
# fitness = fraction of source points with a correspondence inside the threshold.
# HIGH FITNESS DOES NOT MEAN CORRECT — see the DEBUG section below.
The ICP bench — step it, and find the edge of the basin

The same notched room, scanned twice. Teal is the second scan — note that a stack of boxes has occluded part of one wall and appears as a blob in front of the gap. Orange is the first scan under the current estimate, and the faint lines are the live nearest-neighbour correspondences. Step one iteration at a time and watch the assignments flip.

Initial angle 35°
Reject beyond τ × median 2.5
 

Three experiments, and each one settles an argument.

  1. Start at 35°, τ = 2.5, Run 30. The angle error goes to 0.000° and the RMS to zero, and the readout shows 124 of 132 pairs kept — the eight rejected ones are exactly the source points whose wall the boxes occluded. The rejector found them without being told they existed.
  2. Same start, τ = 9 (rejection off). Those eight orphans now snap onto the blob and stay in the solve, and the final angle is 2.2° wrong with a non-zero RMS. Eight bad pairs out of 132 — six percent — cost two degrees of heading. That is the number to quote when someone asks whether correspondence rejection is worth the code.
  3. Start at 110°, τ = 2.5, Run 30. It converges just as confidently, the residual falls just as smoothly, and the answer is sixty-five degrees wrong. Walk the initial angle down and find the edge: somewhere between 35° and 60° the basin gives out. A converged ICP tells you nothing about whether it converged to the right thing, which is the entire subject of the next section.

DEBUG — the corridor where ICP is confidently lost

Symptom. The AMR registers against the prior map every 100 ms. In the 40 m loading dock — long, straight, featureless walls on both sides — reg.fitness is 0.97 and inlier_rmse is 0.021 m, the best numbers anywhere on the site. And the reported position slides several metres along the dock over a minute, then snaps back when the robot reaches the end wall.

The registration is not failing. It is succeeding at a problem that does not have a unique answer. In a corridor, translating along the corridor axis maps the wall onto itself, so the cost function is flat in that direction. Every position along the dock is an equally good optimum, and ICP returns whichever one its initialisation was nearest — which means it returns whatever the motion prior said, with no correction at all. The dead-reckoning error accumulates unopposed while every quality metric reports excellence.

Why fitness and RMSE are structurally blind to this. Both measure residuals. A degenerate direction has zero residual by definition — that is what degenerate means. You cannot detect an unobservable direction by looking at how well you fit the data; you have to look at the curvature of the cost.

The metric that reveals it. Form the registration information matrix JTJ (6×6 in 3D, 3×3 in the plane) at the solution and take its eigen-decomposition. Zhang, Kaess & Singh's "On Degeneracy of Optimization-based State Estimation Problems" (ICRA 2016) defines the degeneracy factor as the smallest eigenvalue, and the practical readout is:

SignalHealthy dock scanDegenerate dock scan
fitness0.950.97 — better!
inlier_rmse0.03 m0.021 m — better!
λmin of JTJ~2,400~12
λmax / λmin~35> 900
Eigenvector for λminno meaningpoints along the dock — it names the unobservable direction

That last row is the whole diagnosis. The eigenvector does not merely tell you that something is wrong; it tells you which degree of freedom is unconstrained, in metres and radians you can read. Ship the eigen-decomposition alongside fitness and this failure becomes a one-line log message instead of a two-day investigation.

What to do about it, in ascending order of correctness:

  1. Solution remapping (Zhang 2016): project the ICP update onto the well-conditioned eigenvectors only, and take the degenerate directions from the prior instead. The registration contributes what it knows and stays silent about what it does not.
  2. Report an honest covariance. Set the information along the degenerate eigenvector to near zero so the backend weights it correctly — a pose graph handles a constraint that says "I know my lateral position and nothing about my longitudinal position" perfectly well, if you tell it that.
  3. Fuse another sensor. Wheel odometry constrains exactly the along-corridor direction the LiDAR cannot see. This is the complementary-failure argument from The Sensor Suite, arriving with a concrete number attached.
The transferable form: "Residual-based metrics measure how well you fit; eigenvalue-based metrics measure how much you are constrained. A degenerate direction has zero residual by construction, so no amount of looking at fit quality will ever find it." That sentence works for ICP, for bundle adjustment, for calibration, and for any least-squares problem you will meet.

FRONTIER — registration since 2020

WorkYearThe one idea
Generalized-ICPRSS 2009The classic to know: model both clouds as locally planar with covariances (plane-to-plane), which subsumes point-to-point and point-to-plane as special cases.
VGICPICRA 2021GICP's accuracy without the nearest-neighbour search, by aggregating covariances over voxels — and it parallelises onto a GPU.
GeoTransformerCVPR 2022Learned coarse-to-fine correspondence using geometric self-attention over superpoints; registers cloud pairs with very low overlap, where ICP has no basin at all.
KISS-ICPRA-L 2023The counter-result: plain point-to-point ICP with careful deskewing, adaptive voxel downsampling and an adaptive rejection threshold matches or beats far more elaborate systems across many datasets, with essentially no parameters.
small_gicp2024An engineering contribution rather than an algorithmic one: a header-only, multi-threaded GICP/VGICP that is several times faster than the standard implementations. Frequently the right answer in production.

The position to hold: KISS-ICP is the most useful paper on this list, because its message is that the preprocessing was always the hard part. Deskewing, adaptive downsampling and adaptive rejection contributed more than a decade of cost-function refinement. That is an unfashionable claim, it is well supported, and it maps directly onto how you should prioritise work on a real robot.

Your LiDAR registration reports fitness 0.97 and inlier RMSE 0.021 m — the best numbers on the site — while the robot's position drifts several metres along a featureless loading dock. What is happening, and what single quantity would have caught it?

Chapter 6: Motion Estimation

You have four hundred verified correspondences. Now recover the motion. And before you write anything — there are three different ways to do that, and they need three different things. Which one are you about to use, and why?

Most people reach for the essential matrix. On a robot that already has a map, that is the wrong answer, and knowing why is worth more than knowing the five-point algorithm.

Bridging, not repeating. Multi-View Geometry derives the epipolar constraint, the essential and fundamental matrices and their degree-of-freedom counts in full, and Features, RANSAC & SfM builds the eight-point algorithm and triangulation. This chapter is about choosing among the three motion estimators, the P3P constraint you can write down from the law of cosines, the Gauss–Newton step you can do by hand, and the parallax gate that decides whether any of it is trustworthy.

CONCEPT — three estimators, one decision

2D–2D (essential)3D–2D (PnP)3D–3D (registration)
InputsTwo image point setsMap points + their image observationsTwo point clouds
NeedsCalibration KCalibration + a mapDepth in both frames
GivesR and the direction of tFull 6-DOF metric poseFull 6-DOF metric pose
DOF recovered5 — scale is unobservable66
Minimal sample s5 (Nistér) or 7/8 (F)3 (P3P), up to 4 solutions3 (non-collinear)
RANSAC N at w = 0.6571919
Degenerates onPure rotation; planar scenesCoplanar map points; all points collinearCorridors, planes — Chapter 5
Used forBootstrapping onlyEvery tracked frameLiDAR frontends
The answer the table is pointing at. A running SLAM system almost never uses the essential matrix. It uses it once, during initialisation, when there is no map yet. From the moment the map exists, every frame is tracked by PnP against the map — ORB-SLAM's TrackLocalMap does exactly this. The reasons are all in the table: PnP gives six degrees of freedom instead of five, it is metric because the map is metric, its minimal sample is 3 instead of 5 (so RANSAC needs 19 iterations instead of 57), and it drifts far less because it is measured against a map rather than against the previous frame. Frame-to-frame essential-matrix VO is what you build when you have nothing else.

CONCEPT — P3P, from the law of cosines

P3P asks: given three known 3D points and the three image rays they project onto, where is the camera? The elegant part is that you do not solve for the pose directly. You solve for the three unknown distances from the camera centre to the points, and once you have those you have three 3D points in camera coordinates, and Chapter 5's closed form finishes the job.

Let s1, s2, s3 be those distances, let θij be the angle between rays i and j (known exactly from the calibrated image coordinates), and let dij be the distance between world points i and j (known from the map). The law of cosines on each of the three triangles gives:

s12 + s22 − 2 s1 s2 cosθ12 = d122
s22 + s32 − 2 s2 s3 cosθ23 = d232
s12 + s32 − 2 s1 s3 cosθ13 = d132

Three quadratics in three unknowns. Eliminating two of them yields a quartic in the remaining one, so there are up to 4 geometrically valid solutions (the degree-8 polynomial you sometimes see also counts the mirrored ones behind the camera). This is why P3P always needs a fourth point: not to constrain the pose, but to disambiguate among four candidate poses that all explain the first three perfectly.

Worked example 1 — one P3P constraint, with numbers

Take the first equation with cosθ12 = 0.9 and d12 = 2.0 m.

Step 1 — what the angle actually is. θ12 = arccos(0.9) = 25.84°. Two map points 2 m apart, subtending 26° at the camera.

Step 2 — write the constraint. s12 + s22 − 2(0.9) s1s2 = 4, that is s12 + s22 − 1.8 s1s2 = 4. This is a conic in the (s1, s2) plane — an entire curve of solutions, which is exactly why one pair is not enough.

Step 3 — pick the symmetric point on that curve to get a number you can check. Set s1 = s2 = s:

s2 + s2 − 1.8 s2 = 0.2 s2 = 4  ⇒  s2 = 20  ⇒  s = √20 = 4.472 m

Step 4 — verify geometrically. Two points at 4.472 m separated by 25.84° form an isosceles triangle whose base is 2 × 4.472 × sin(25.84°/2) = 2 × 4.472 × sin(12.92°) = 2 × 4.472 × 0.22361 = 2.000 m. Which is d12. The constraint is satisfied exactly, and you have just checked a P3P equation with a pocket calculator.

Step 5 — the step that completes it. That is one point on a conic. Two more equations cut it down to a quartic, giving up to four poses, and a fourth correspondence picks the right one. So inside RANSAC a P3P sample is three points plus one for disambiguation, and you score all four candidate poses against the rest of the data. That last detail is the one people miss.

Worked example 2 — one Gauss–Newton PnP step, entirely by hand

P3P gives a coarse pose from three points. Then you refine against all the inliers by minimising reprojection error. Here is one step of that, small enough for a whiteboard.

Setup. f = 500 px, principal point cx = 320. A map point sits at (X, Y, Z) = (0.5, 0, 5.0) in the current camera frame. You observe it at uobs = 374.0. Solve for the camera's sideways translation tx.

Step 1 — predict. upred = f·X/Z + cx = 500 × 0.5/5.0 + 320 = 50 + 320 = 370.0 px.

Step 2 — residual. r = uobs − upred = 374.0 − 370.0 = 4.0 px.

Step 3 — the Jacobian. Moving the point sideways by δX changes u by f·δX/Z, so

∂u/∂tx = f/Z = 500/5.0 = 100 px per metre

Step 4 — the normal equation. With one measurement and one unknown, JTJ = 1002 = 10,000 and JTr = 100 × 4.0 = 400, so

δtx = 400 / 10,000 = 0.04 m

Four centimetres. Sanity-check it the other way: 4 px at 100 px per metre is 0.04 m. The Gauss–Newton machinery, on one measurement, reduces to dividing by the gain — which tells you what the Hessian is.

Step 5 — the other Jacobians, and the number that matters. The same point gives:

Parameter∂u/∂·Value hereMeaning
tx (sideways)f/Z100 px/m1 cm of sideslip = 1 px
tz (forward)−f·X/Z2−10 px/mTen times weaker — forward motion is always the hardest to see
θy (yaw)f(1 + X2/Z2)505 px/rad1° of yaw = 8.81 px

Now put the last two rows together. 8.81 px of image motion could be 1° of yaw, or it could be 8.81/100 = 8.8 cm of sideways translation. From a single point at a single depth, those two hypotheses are indistinguishable. What separates them is having points at different depths: the yaw Jacobian barely changes with Z while the translation Jacobian scales as 1/Z, so a scene with depth variety breaks the ambiguity and a scene without it does not.

This is Chapter 1's failure, arriving from the algebra. When all your features cluster on one bank of labels at one depth, the yaw and sideslip columns of the Jacobian become nearly parallel, JTJ becomes ill-conditioned, and the estimator splits the difference — consistently, because the geometry is the same every pass. The metric was keypoint spatial entropy; the mechanism is right here, in two partial derivatives. Connecting the symptom to the derivative is what "understanding a failure mode" actually means.

CONCEPT — the parallax gate, and why a stationary robot is dangerous

The second degeneracy from Chapter 3, done properly. E = [t]×R. If the camera rotates without translating, t = 0, so [t]× = 0 and E is identically zero. The five-point solver, handed a matrix that should be zero, returns whatever the noise says. No error, no warning, and a plausible-looking translation direction that is pure noise.

The measure of how close you are to that catastrophe is the parallax angle: the angle subtended at a scene point by the two camera centres. For baseline B and depth Z it is α ≈ arctan(B/Z). Its effect on triangulation is the number to have memorised.

Derive the depth uncertainty. Depth from a two-view pair is Z = fB/d, where d is the disparity in pixels. Differentiate:

∂Z/∂d = −fB/d2 = −Z2/(fB)  ⇒  σZ = Z2 σd / (f B)

Depth uncertainty grows with the square of depth and falls with the baseline. Now put a robot's numbers in: f = 500 px, σd = 1 px, a landmark at Z = 10 m.

Baseline BHow you get itParallax ασZσZ/Z
0.05 mOne frame at 1.5 m/s, 30 Hz0.286°4.00 m40%
0.17 m~3 frames0.974°1.18 m11.8%
0.50 m~10 frames — a keyframe interval2.862°0.40 m4.0%
1.00 m~20 frames5.711°0.20 m2.0%

Read the first row. Triangulating a 10 m landmark from consecutive frames gives a depth estimate with forty percent uncertainty. That landmark is not a measurement; it is a rumour. Put it in the map and it corrupts everything that later matches against it.

Read the third row. A ten-times-longer baseline gives ten-times-better depth. That is the entire argument for keyframes — not memory, not compute, but the fact that a wide baseline is the only way to make triangulation mean anything. Whenever someone asks "why keyframes?", this table is the answer.

The gate itself. ORB-SLAM refuses to triangulate a new landmark unless the parallax exceeds about . From the table, 1° corresponds to B/Z = tan(1°) = 0.01746 — a baseline of at least 1.75% of the depth, which at Z = 10 m means 17.5 cm of travel. Nearby points clear the gate almost immediately; distant points may never clear it, and are correctly used to constrain rotation only. That last observation — distant points constrain rotation, near points constrain translation — is worth committing to memory.

The parallax bench — why a short baseline is not a small error

Two camera centres separated by the baseline B, both observing one landmark at depth Z. Each ray carries the angular uncertainty produced by 1 px of localisation noise; the shaded region where the two wedges overlap is the landmark's actual uncertainty. Shrink the baseline and watch that region stretch into a needle pointing away from the cameras — the needle is the depth uncertainty.

Baseline B (m) 0.50
Depth Z (m) 10.0
 

Slide B down to 0.05 m at Z = 10 m and the readout says 40% — the same number as the table, drawn. Then hold B and slide Z out: the uncertainty grows as Z2, so doubling the depth quadruples the error. Notice that the baseline at which the 1° gate opens moves with Z, and that is the point: a fixed baseline threshold in metres is wrong; the gate has to be an angle.

DESIGN — the decision tree a real frontend runs

Do I have a map?
No → initialisation. Yes → tracking.
↓ NO — bootstrap
Fit BOTH H and E
Score both; if SH/(SH+SE) > 0.45 the scene is planar, so decompose H, otherwise decompose E. Triangulate an initial map. Scale is arbitrary — fix it from the IMU, the wheels, or a known object.
↓ YES — every subsequent frame
Predict the pose
Constant velocity or IMU preintegration. Costs nothing. Buys the 470× matching reduction from Chapter 2.
Project the local map, match in a window
~200 map points matched, radius r = 3σu
P3P + RANSAC
s = 3, w ≈ 0.6 → 19 iterations. Four candidate poses per sample, all scored.
Gauss–Newton refine on all inliers
200 points × 4 iterations = 800 residual evaluations, ~0.3 ms, with a Huber kernel
Keyframe? Then triangulate
Only for matches whose parallax exceeds 1°. Everything else waits.

Two numbers to carry away from that diagram. RANSAC for P3P at 60% inliers needs 19 iterations, not the 57 the essential matrix would need — a 3× saving that comes purely from the smaller minimal sample. And the refinement is 800 residual evaluations, roughly 0.3 ms, which is why the whole pose stage fits inside the 1 ms budgeted for it back in Chapter 0.

The keyframe policy, since it falls out of the parallax table. Insert a keyframe when any of: more than ~20 frames have passed; tracked map points fall below ~90% of the reference keyframe's count; or the accumulated baseline exceeds a few percent of the median tracked depth. That last criterion is the parallax gate promoted to a scheduling decision, and it is the one that adapts correctly when the robot slows or stops — a stationary robot accumulates no baseline, inserts no keyframes, triangulates nothing, and stays safe.

CODE — the PnP refinement step, then the library

python — from scratch
import numpy as np

def skew(v):
    return np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]])

def refine_pose(R, t, P_w, uv, K, iters=5, huber=2.45):
    """Gauss-Newton on SE(3). P_w (N,3) world points, uv (N,2) observations."""
    fx, fy = K[0, 0], K[1, 1]
    for _ in range(iters):
        Pc = (R @ P_w.T).T + t                       # into the camera frame
        X, Y, Z = Pc[:, 0], Pc[:, 1], Pc[:, 2]
        proj = np.stack([fx * X / Z + K[0, 2], fy * Y / Z + K[1, 2]], 1)
        r = (uv - proj).reshape(-1)                  # (2N,)

        # d(proj)/d(Pc) — the 2x3 projection Jacobian
        Jp = np.zeros((len(P_w), 2, 3))
        Jp[:, 0, 0] = fx / Z;  Jp[:, 0, 2] = -fx * X / Z ** 2
        Jp[:, 1, 1] = fy / Z;  Jp[:, 1, 2] = -fy * Y / Z ** 2

        # d(Pc)/d(xi) for a left-multiplied SE(3) increment: [ I | -skew(Pc) ]
        J = np.zeros((len(P_w), 2, 6))
        for i in range(len(P_w)):
            dPc = np.hstack([np.eye(3), -skew(Pc[i])])       # (3,6)
            J[i] = -Jp[i] @ dPc
        J = J.reshape(-1, 6)

        # Huber weights: full weight inside the threshold, threshold/|r| outside
        a = np.abs(r)
        w = np.where(a <= huber, 1.0, huber / np.maximum(a, 1e-9))
        Hm = J.T @ (w[:, None] * J)
        g = J.T @ (w * r)
        xi = np.linalg.solve(Hm + 1e-9 * np.eye(6), -g)         # (rho, phi)

        th = np.linalg.norm(xi[3:])                                # exponential map
        dR = np.eye(3) + skew(xi[3:]) if th < 1e-6 else \
             np.eye(3) + np.sin(th)/th * skew(xi[3:]) + \
             (1-np.cos(th))/th**2 * skew(xi[3:]) @ skew(xi[3:])
        R, t = dR @ R, dR @ t + xi[:3]
    return R, t
python — production
import cv2
ok, rvec, tvec, inl = cv2.solvePnPRansac(
        objectPoints=P_w, imagePoints=uv, cameraMatrix=K, distCoeffs=None,
        flags=cv2.SOLVEPNP_AP3P,      # algebraic P3P — the right minimal solver for RANSAC
        reprojectionError=2.45,       # sqrt(chi2 at 2 dof, 95%) with sigma = 1 px — Chapter 3
        confidence=0.999, iterationsCount=200)
# then polish on the inlier set with an iterative solver
rvec, tvec = cv2.solvePnPRefineVVS(P_w[inl.ravel()], uv[inl.ravel()], K, None, rvec, tvec)

# SOLVEPNP_SQPNP (OpenCV >= 4.6) is globally optimal and needs no initial guess;
# it superseded EPNP as the recommended non-minimal solver. Know the name.

DEBUG — the robot that is standing still and thinks it is moving

Symptom. The AMR is parked at a charging dock, motionless, while a technician pans the camera to inspect a shelf. The VO reports 3–8 cm of translation per frame in a direction that changes essentially at random, and the map grows a cloud of new landmarks scattered between 0.5 m and 400 m of depth. Inlier ratio 91%. No errors.

This is the pure-rotation degeneracy, and it produces the worst kind of corruption: the bad landmarks persist. The robot eventually drives away, and every future frame that matches one of those 400 m phantoms is dragged by it.

The metric that reveals it: median parallax angle across the matched correspondences. Cheap — for each match, the angle between the two bearing vectors after de-rotating by the estimated R.

SignalHealthy driving framePure-rotation frame
Inlier ratio0.880.91 — better
Median reprojection error0.35 px0.28 px — better
Median parallax2.1°0.04°
H inliers / E inliers0.420.98
New landmark depth spread3–15 m0.5–400 m

Both primary metrics get better, which is the tell — and they get better for a comprehensible reason: with no translation a homography explains the data exactly, so the residuals really are smaller. The model is fitting beautifully. It is just not the model you wanted.

How this separates from Chapter 3's planar degeneracy, since both show a high H/E ratio. Parallax. A planar scene under real translation still has healthy parallax (2–3°); a pure rotation has none (< 0.1°).

Parallax < 0.1°Parallax > 1°
H/E inliers > 0.9Pure rotation — do not estimate t, do not triangulatePlanar scene — decompose H instead of E
H/E inliers < 0.5Nearly stationary with 3D structure — safe, but triangulate nothing yetHealthy — the normal operating point

Two numbers, four quadrants, and the two most common frontend degeneracies are separated. That compact diagnostic beats any anecdote.

The fix. Gate on parallax before doing anything irreversible: estimate a rotation-only model, do not estimate t, do not triangulate, do not create a keyframe. Report a rotation-only pose increment with an enormous covariance on translation, so the backend correctly learns nothing about position from that frame instead of learning something false.

FRONTIER — pose estimation, 2020 onward

WorkYearThe one idea
SQPnPECCV 2020PnP as a sequential quadratic program on the rotation manifold: globally optimal, no initial guess, and fast. Replaced EPnP as OpenCV's recommended non-minimal solver in 4.6.
DPVONeurIPS 2023Sparse learned patch tracking feeding a differentiable bundle adjustment — the correspondence stage is learned, the pose stage stays geometric.
Reloc3rCVPR 2025Feed-forward relative pose regression from an image pair with no map and no explicit correspondences, trained at scale. Strong for relocalisation; still not a metric tracker.
MASt3R-SLAMCVPR 2025Real-time SLAM built on a learned two-view 3D reconstruction prior, with no camera calibration required. The most complete demonstration so far that the classical frontend can be replaced end to end.

The honest reading. The eight-year pattern holds: learning keeps eating the correspondence problem and keeps leaving the estimation problem alone, because geometry has properties learning does not — metric consistency by construction, a residual you can inspect, a covariance that means something, and degradation you can predict. Reloc3r and MASt3R-SLAM are the first systems to attack the estimation side seriously, and the right position for 2026 is: run them shadow-mode to mine hard cases and to bootstrap relocalisation, and keep the geometric estimator in the loop that moves the robot. When someone pushes back, name the switching condition: "I move pose estimation onto a learned model when it produces a calibrated uncertainty I can validate against ground truth on my own domain, and not before."

A stationary robot pans its camera. VO reports 3–8 cm of translation per frame in random directions, with a 91% inlier ratio and 0.28 px reprojection error — both better than while driving. Name the failure, the one number that catches it, and how you tell it apart from a planar scene.

Chapter 7: The Frontend Bench

Six chapters, six pieces. This chapter wires them into one machine and hands you the controls.

The bench runs a complete frontend on a synthetic scene: a matcher produces putative correspondences, a ratio test filters them, RANSAC verifies the survivors geometrically, and a two-point rigid solver estimates the motion. You drive three knobs — how hard the scene is, where you set the ratio threshold, and how many RANSAC iterations you can afford — and the machine reports both what it did and what the theory said it should do.

What makes this a bench and not a demo. The second panel plots the RANSAC success probability from Chapter 3, P(N) = 1 − (1 − ws)N, as a smooth curve — and on top of it, the measured success rate from a hundred independent Monte Carlo runs of the actual algorithm. If the derivation is right, the dots sit on the curve. If they drift off it, either the derivation's assumptions have broken or your implementation has, and telling those two apart is the skill.

How the three knobs are wired together

The controls are not independent, and that coupling is the whole lesson. Here is the chain, in the order the data flows.

Scene difficulty ρ
The fraction of the matcher's 300 raw putative matches that are wrong before any filtering. Repetitive shelving and fast motion push this up.
↓ feeds the ratio test
Ratio threshold τ
Keeps a true match with probability FT(τ) and a false one with probability FF(τ) — the two distributions from Chapter 2. This sets both M, how many matches survive, and w, the fraction of survivors that are correct.
↓ w and M feed RANSAC
Iteration budget N
Chapter 3 says you need ln(0.01)/ln(1−w2) iterations. Whether the budget you can afford reaches that number is the question.
Motion estimate
Two-point rigid solve on the best consensus set, refit on all its inliers — Chapter 5's closed form, Chapter 6's decision.
The tension to find with your hands: tightening τ raises w, which makes RANSAC exponentially cheaper — and simultaneously lowers M, which means fewer constraints on the final estimate and more variance in the answer. There is an optimum, it moves with ρ, and no textbook will tell you where it is for your scene. Finding it on this bench is a rehearsal for finding it on a bag file.
The scene — what the estimator actually sees

Every surviving correspondence is drawn as a line from its position in frame 1 to its position in frame 2. Teal = a genuine correspondence, red = a false one that got through the ratio test. The orange outline is the scene under your estimated transform; the pale outline is the truth. When the two separate, the frontend has lied to the backend.

Scene difficulty ρ 0.45
Ratio threshold τ 0.80
RANSAC budget N 40
 
Theory against measurement — the derivation, made falsifiable

The teal curve is P(N) = 1 − (1 − ws)N with s = 2 and the w your ratio threshold actually produced. The orange dots are the measured fraction of 100 independent RANSAC runs whose best model was within 1° of the truth after that many iterations. The dashed line is your current budget.

 

Five experiments, and what each one is teaching

  1. Verify the derivation. The defaults: ρ = 0.45, τ = 0.80, N = 40. The ratio test keeps 168 of the 300 putative matches at an inlier ratio of w = 0.851; the formula demands four iterations for 99% confidence, and the dots ride the curve (theory 1.000, measured 0.970). That is Chapter 3's five-line derivation surviving contact with an actual implementation, and it is why you can quote N from a formula instead of tuning it.
  2. Turn the ratio test off and starve. Push ρ to 0.85 and τ to 1.00 — a matcher that accepts its nearest neighbour unconditionally. All 300 matches survive, w collapses to 0.140, and the readout says the formula demands 233 iterations while your budget is 40: 5.8× too small. Theory puts success at 0.547 and measurement at 0.490, so roughly half your frames are wrong and nothing in the system says so. Now push ρ to 0.90 and it stops being subtle: w = 0.097, the required budget is 491 iterations — 12.3× too small — and the reported rotation is off by 0.26° on a frame where it happened to be lucky. This is where a real frontend should log "hit max_iters" and mark the pose untrusted rather than hand the backend a confident lie.
  3. Buy w back with τ. Return to ρ = 0.85 and drag τ down to 0.85. The kept count falls from 300 to 98, w jumps from 0.140 to 0.408, and the required iterations fall from 233 to 26 — comfortably inside the budget you already had. A ninefold reduction in required work, from moving one threshold. This is the most useful reflex in the lesson: when RANSAC is too slow, the fix is almost never a faster RANSAC. It is a better filter in front of it.
  4. Overtighten, and meet the opposite failure. Set ρ = 0.90 and walk τ from 0.80 down to 0.40, watching the rotation error rather than the success curve:
    τmatches keptwrotation error
    0.80640.4060.209°
    0.60230.9130.164° — the minimum
    0.50160.9380.218°
    0.40131.0000.316° — the worst
    At τ = 0.40 the inlier ratio is perfect and the estimate is the worst on the sweep. RANSAC has nothing left to reject; the estimator is now limited by how few constraints it is averaging over, and error falls roughly as one over the square root of the count. Two failure modes, opposite directions, one knob — and the optimum between them, around 0.6 here, is a property of your scene rather than of the algorithm. That is why "we use 0.8 because Lowe said so" is a weak answer.
  5. Find where the theory is optimistic. Look at the gaps: 0.547 against 0.490 in experiment 2, and at ρ = 0.90 with τ = 0.40 a theory of 1.000 against a measurement of 0.830. The dots sit consistently below the curve, and that is assumption 3 from Chapter 3 — a sample free of outliers is not guaranteed to yield a correct model. With only thirteen surviving matches, two inliers that happen to lie close together define the rotation badly, so the sample is clean and the model is still wrong. The formula is an upper bound on success, not a prediction of it, and knowing which direction the bound errs in is the sort of detail that matters on a real system. It is also why production RANSAC re-fits on the consensus set rather than shipping the minimal-sample model, which this bench does too.
What to carry out of this chapter. The frontend is not five independent modules that each need tuning. It is one pipeline with a single currency — the inlier ratio w — that every earlier stage spends into and every later stage draws on. Detection puts features where they can be matched; matching filters what cannot be trusted; RANSAC converts trust into a partition; the estimator converts the partition into a constraint. Every design argument in this lesson is an argument about where to spend effort to raise w, because w is exponential in everything downstream.

Reading the bench the way you would read a bag file

The five experiments above are the bench driving you. This section is you driving the bench — the diagnostic order to read the six numbers in, which is the same order you would read a real frontend's telemetry.

#Read thisBecause it answersIf it is wrong, stop here
1Matches kept, out of the putative totalDid the matcher produce anything at all?If it is near zero, nothing downstream matters. Look at detection and at whether the ratio test is rejecting everything — the Chapter 2 table separates those in one glance.
2Inlier ratio wIs the material any good?Everything downstream is exponential in this number. If it is under about 0.3, do not tune RANSAC — fix the filter.
3Required N against your budgetCan you afford the verification the data demands?If the ratio exceeds 1, your success probability is whatever the theory curve says at your budget, not the 99% you asked for.
4Measured against theoryIs the algorithm behaving as derived?A large gap means an assumption has broken — usually degenerate samples — or your implementation has. Those are different fixes.
5RANSAC inlier count against matches keptDid geometry agree with appearance?A big drop from step 1 to here means the matcher and the geometry disagree, which is the healthy case. No drop at a high ρ is suspicious — it means a structured outlier set formed its own consensus.
6Rotation and translation errorDid any of it work?This is the only row you cannot log on a real robot, because you have no ground truth. Everything above it is your substitute, and that is exactly why the substitutes have to be good.
The point of that ordering. Rows 1 through 5 are all computable on the robot, during the mission, with no ground truth. Row 6 is not. A frontend that only knows whether it is working by comparing against a motion-capture rig does not know whether it is working. Design the observability in, and design it upstream — by the time the error is measurable, the map is already wrong.

Two things this bench teaches about real systems

How to design the experiment. Sweep the matcher threshold against scene difficulty on recorded bags, and plot the inlier ratio and the required RANSAC budget as surfaces over that grid. The threshold is not a constant — it is the argmax of a curve whose shape depends on how much of the previous frame is still visible, and the honest artefact is the surface, not a number. That converts a tuning question into an engineering one.

What to ship. Log w, the required N, and the achieved N on every frame. Three floats, essentially free, and together they are a complete statement of whether verification succeeded on evidence rather than on hope. Then alarm on the ratio of required to achieved, because that is the one that fires before the map folds rather than after. Notice what that does: it takes the derivation from Chapter 3 and turns it into production telemetry. A derivation you can log is worth more than a derivation you can recite.

What the bench simplifies, stated honestly

Every simulation leaves things out, and knowing exactly what is worth more than the simulation itself.

The benchA real frontendWhy it matters
Outliers are uniformly randomOutliers are structured — a moving forklift produces a self-consistent set of wrong matchesStructured outliers can form their own consensus set and win RANSAC. This is the dynamic-object problem, and it is why RANSAC alone is not enough.
The model is a 2D rigid transform, s = 2Essential matrix s = 5, or P3P s = 3The exponent is what makes N explode. At w = 0.4, s = 2 needs 27 iterations and s = 5 needs 448.
Inlier noise is isotropic GaussianLocalisation noise varies with scale, blur and gradient directionA fixed threshold is right on average and wrong per point — which is what MAGSAC++ addresses.
w is stationary within a runw changes frame to frame with lighting, motion and scene contentThe adaptive stopping rule exists exactly because w is unknown and non-stationary.
Success is "within 1°"Success is "the backend does not fold" — and one surviving outlier can do thatChapter 0. A 99% success rate per frame at 30 Hz is eighteen failures per minute, and each one is a chance to poison the map.

That last row deserves the arithmetic, because it is the sort of number that ends an argument. At 30 frames per second, a 99% per-frame success rate means 0.01 × 30 × 60 = 18 failed frames every minute. A 99.9% rate still means 1.8 per minute. This is why a production frontend does not stop at RANSAC: it adds a degeneracy test, a parallax gate, a consistency check across consecutive frames, and a keyframe policy that refuses to commit anything it is unsure about. Verification is not one step. It is a ladder, and each rung buys a decimal place.

Chapter 8: The Field Guide

Everything above, compressed into the form you reach for in the field. Read section 7 first if you have twenty minutes; read section 1 first if you have five.

1 · The cheat sheet

Concept30-second explanationKey equationToolClassic paper2024+ paper
FAST detection A pixel is a corner if 9 of the 16 pixels on a radius-3 circle are contiguously brighter or darker by more than t. Four compass points reject ~90% of pixels first. arc ≥ 9 of 16 cv2.FAST, ORB Rosten & Drummond, ECCV 2006 XFeat, CVPR 2024
ORB orientation Intensity centroid: the vector from the patch centre to its brightness centroid gives an angle; rotate the BRIEF pattern by it. θ = atan2(m01, m10) cv2.ORB_create Rublee et al., ICCV 2011 ALIKED, TIM 2023
Lowe ratio test The second-nearest neighbour is a free local noise estimate, so the ratio self-calibrates. At 0.8 it removes ~90% of false matches for ~5% of true ones. d1/d2 < 0.8 knnMatch(k=2) Lowe, IJCV 2004 LightGlue, ICCV 2023
Mutual consistency Reads the column as well as the row; kills many-to-one collapse onto an attractor descriptor. One extra argmin. NN(a)=b and NN(b)=a crossCheck=True Folklore SuperGlue dustbin, CVPR 2020
Data association (N+1)M hypotheses; gating with a chi-square test shrinks 20 candidates to ~2 and 4.08M hypotheses to 243. (N+1)M JCBB Neira & Tardós, T-RA 2001
RANSAC iterations Probability all N samples are contaminated is (1−ws)N; require it below 1−p and take logs. N = ln(1−p)/ln(1−ws) USAC_MAGSAC Fischler & Bolles, CACM 1981 VSAC, ICCV 2021
Inlier threshold Derive it from noise, not taste: the squared residual over σ2 is chi-square with the residual's dimension. t2 = χ2d,0.95σ2 threshold= Hartley & Zisserman, 2004 MAGSAC++, CVPR 2020
Planar degeneracy On a plane a family of essential matrices fits equally; RANSAC picks one at random each frame and translation flips. SH/(SH+SE) > 0.45 findHomography Mur-Artal et al., T-RO 2015 VSAC, ICCV 2021
Photometric error Skip descriptors: warp pixels by the pose and difference intensities. Correspondence is implied by the pose. r = I2(w(x;T,d)) − I1(x) DSO Engel et al., TPAMI 2018 DPV-SLAM, ECCV 2024
Lucas–Kanade Linearise the photometric residual; the Hessian is the structure tensor, which is why the corner detector and the tracker share a matrix. (ΣJTJ)δ = −ΣJTr calcOpticalFlowPyrLK Baker & Matthews, IJCV 2004 DPVO, NeurIPS 2023
Affine brightness Two unknowns per frame absorb an auto-exposure step that otherwise corrupts sixteen thousand residuals. r = I2(w) − (a I1 + b) DSO photometric calib Engel et al., TPAMI 2018
ICP closed form Translation decouples via centroids; the rotation maximises tr(RH) and comes from the SVD of the cross-covariance, with a determinant guard. R = V·diag(1,…,det)·UT Open3D, PCL Arun et al., TPAMI 1987 KISS-ICP, RA-L 2023
Point-to-plane Project the residual onto the surface normal so sliding is free; 5–10 iterations instead of 30–50. E = Σ[nT(Rp+t−q)]2 PointToPlane Chen & Medioni, IVC 1992 small_gicp, 2024
NDT Voxelise, fit a Gaussian per voxel, score by Mahalanobis likelihood — no correspondence search and a smooth objective. Σexp(−½dTΣ−1d) PCL NDT, autoware Biber & Straßer, IROS 2003 VGICP, ICRA 2021
Registration degeneracy Fitness and RMSE measure residuals; a degenerate direction has zero residual by construction. Look at the curvature instead. λmin(JTJ) Custom logging Zhang et al., ICRA 2016
P3P Law of cosines on three triangles gives three quadratics in the ray lengths, reducing to a quartic — up to 4 poses, so you need a fourth point. si2+sj2−2sisjcosθij = dij2 SOLVEPNP_AP3P Gao et al., TPAMI 2003 SQPnP, ECCV 2020
Parallax gate Depth uncertainty scales as Z2/(fB), so a short baseline is not a small error — it is a 40% error. σZ = Z2σd/(fB) ORB-SLAM gate, 1° Mur-Artal et al., T-RO 2015 MASt3R-SLAM, CVPR 2025
Pure rotation t = 0 makes E identically zero, so the five-point solver returns noise while residuals improve. E = [t]×R Parallax logging Hartley & Zisserman, 2004

2 · System-design patterns

Prompt A — "Design the perception frontend for a warehouse AMR: one mono camera at 1280×720 and 30 Hz, a 16-beam LiDAR at 10 Hz, wheel odometry, on a Jetson Orin Nano."

Framework: open with the budget, not the blocks. 33.3 ms per camera frame; state that you will spend 18 ms and keep 15 ms of margin, then justify each line. Then draw the pipeline: undistort → detect with bucketing → describe → guided match using the predicted pose → ratio test → mutual consistency → RANSAC + degeneracy test → PnP against the local map → keyframe decision gated on parallax. Name the two paths explicitly — the 30 Hz guided tracking path and the 3–5 Hz unguided relocalisation path — because a single matcher box is the tell of a design that has never run. Close on the LiDAR: deskew first (15 cm at walking pace), scan-to-map not scan-to-scan, initialised from wheel odometry, and log the smallest eigenvalue of JTJ because the loading dock is a corridor.

Prompt B — "Your frontend has a 15 ms budget and it is taking 40 ms. Where do you look?"

Framework: profile before theorising, then attack in this order. (1) Detection dominates — 2.85 Mpx across the pyramid at ~3 ns each is 8.6 ms. Cut pyramid levels or resolution before anything clever. (2) Matching is unguided — if you are brute-forcing 1000×1000 you are burning 2.7 ms for nothing; a projected 25 px window makes it 2,130 comparisons. (3) RANSAC is starved of w — 83% of RANSAC's cost is the minimal solver, so the lever is fewer iterations, and fewer iterations come from a higher w, and a higher w comes from the ratio test and PROSAC ordering. State the number: at w = 0.7 versus 0.4, the essential matrix needs 26 versus 448 iterations. (4) Only then consider descriptors: float32 SuperPoint is 1 MB per frame, larger than the image.

Prompt C — "Would you replace ORB with SuperPoint plus LightGlue on this robot?"

Framework: split perception from estimation, then cost it. Learned detection genuinely wins on repeatability under blur and lighting change. It costs a GPU that the planner also wants, 32× the descriptor bandwidth, and a validation burden across a 400-robot fleet whose cameras are not identical. Answer: not on the tracking path, where guided matching has already reduced the problem to two candidates and there is almost nothing to win — yes on the relocalisation and loop-closure path, which is unguided, wide-baseline, low-rate and exactly where classical matching is weakest. Then name the switching condition: "I move it onto the tracking path when it fits the power budget with margin and I can characterise its failure on my own domain." Mention XFeat (CVPR 2024) as the CPU-only option that could change the calculation.

Prompt D — "A colleague wants to drop RANSAC because the backend uses a Huber kernel."

Framework: this is Chapter 0, and it is the highest-value objection in the lesson. A robust kernel down-weights residuals that are large under the current estimate. A false constraint with a high information weight moves the estimate until its own residual is small — run the arithmetic: a loop closure at Ω = 100 against a 54-link odometry chain of effective stiffness 0.0185 absorbs 99.98% of its own error, leaving a residual of 0.6 mm on a 3 m lie, eighty times below any sensible Huber threshold. Robust backends handle wrong values; they do not handle wrong correspondences, because a wrong correspondence rewrites what "large" means. Geometric verification is the only stage that makes a discrete decision, and discrete errors are the unbounded ones.

3 · Coding drills

Drill 1 — "Implement RANSAC with an adaptive iteration count." The key lines:

the 10 lines that matterN, k = float('inf'), 0
while k < N and k < max_iters:
    idx   = rng.choice(n, s, replace=False)
    model = fit(data[idx]); k += 1
    if model is None: continue
    mask = residual(model, data) < t
    if mask.sum() > best.sum():
        best = mask
        w = max(best.sum()/n, 1e-6)
        N = math.log(1-p) / math.log(max(1-w**s, 1e-12))
return fit(data[best]), best        # RE-FIT on the consensus set

What matters while writing it: "N starts at infinity because I do not know w — that is assumption two of the derivation, and the adaptive update is how you discharge it." Then: "I am capping iterations, and hitting the cap is a signal I want logged, not swallowed." Then, on the last line: "RANSAC returns a partition, not a model; the minimal-sample model is the noisiest estimate available, so I re-fit."

Drill 2 — "Implement the Lowe ratio test and mutual consistency."

the 6 lines that matterorder = np.argpartition(D, 2, axis=1)[:, :2]     # not argsort — you need two
j1, j2 = order[:, 0], order[:, 1]
rows   = np.arange(len(D))
keep   = D[rows, j1] < 0.8 * D[rows, j2]         # the ROW test
back   = D.argmin(axis=0)                          # the COLUMN test
keep  &= (back[j1] == rows)

What matters: "argpartition rather than argsort, because I only need the two smallest." And: "The ratio test reads one row, mutual consistency reads a column — that is why they are not redundant. Mutual catches many-to-one collapse onto an attractor descriptor, which no row-wise test can see."

Drill 3 — "Implement one ICP iteration."

the 8 lines that matterS    = (R @ src.T).T + t
d2   = ((S[:, None] - dst[None]) ** 2).sum(-1)      # KD-tree in production
j    = d2.argmin(1); d = np.sqrt(d2[np.arange(len(S)), j])
keep = d < 2.5 * np.median(d)                       # ADAPTIVE rejection
A, B = S[keep], dst[j][keep]
H    = (A - A.mean(0)).T @ (B - B.mean(0))
U, _, Vt = np.linalg.svd(H)
D    = np.diag([1, 1, np.sign(np.linalg.det(Vt.T @ U.T))])   # reflection guard
dR   = Vt.T @ D @ U.T;  R, t = dR @ R, dR @ t + (B.mean(0) - dR @ A.mean(0))

What matters: "The determinant guard, because V UT is only guaranteed orthogonal and orthogonal includes reflections — collinear corridor scans hit that constantly." And: "The last line composes rather than assigns; the increment is expressed in the current frame, so it multiplies on the left. Getting that backwards makes ICP oscillate instead of converge."

Drill 4 — "Write the Gauss–Newton reprojection step."

the 6 lines that matterPc   = (R @ Pw.T).T + t
proj = np.stack([fx*Pc[:,0]/Pc[:,2] + cx, fy*Pc[:,1]/Pc[:,2] + cy], 1)
r    = (uv - proj).reshape(-1)
J    = -Jproj @ np.hstack([np.eye(3), -skew(Pc)])       # chain rule onto se(3)
xi   = np.linalg.solve(J.T @ (w[:,None]*J), -J.T @ (w*r))
R, t = exp_so3(xi[3:]) @ R, exp_so3(xi[3:]) @ t + xi[:3]

What matters: "∂u/∂tx is f/Z and ∂u/∂tz is −fX/Z2, so forward motion is ten times weaker than sideways at a typical geometry — that asymmetry is why forward-facing cameras have poor depth-of-travel observability." And: "w is a Huber weight, and one Huber-weighted Gauss–Newton pass is IRLS."

4 · Debugging scenarios

SymptomRoot causeThe metric that reveals itThe value that distinguishes it
Heading drifts a few degrees per 20 m in one specific corridor. Inliers 85%, reprojection 0.3 px. Feature clustering — all keypoints on one high-contrast object, so yaw and sideslip are nearly indistinguishable Keypoint spatial entropy over an 8×6 grid, normalised by log248 = 5.585 Healthy > 0.74; alarm < 0.6; this case ~0.36. Confirm with λmaxmin of the pose information matrix: 20–80 healthy, > 3000 here.
Match count falls 5× entering an aisle; feature count unchanged; keyframes fire every third frame. Repetitive structure — the ratio test correctly refuses to guess between identical shelf uprights Ratio-test rejection rate, logged per frame 30% normal → 85% here. If the feature count also fell to ~120 it is a detection problem instead; if rejection is ~55% and recovers in 2–3 frames it is an exposure step.
85% inliers, 0.4 px residual, and the translation direction flips between frames. Planar degeneracy — a family of essential matrices fits a plane equally well Homography inliers / essential inliers on the same correspondences < 0.5 general 3D; > 0.9 degenerate. Combined with parallax > 1° this is a plane; with parallax < 0.1° it is pure rotation.
Direct VO shows an 8–15 cm translation spike in a single frame, two or three times per run, always near a doorway. Brightness-constancy violation — an auto-exposure step Correlate pose-increment norm with |Δ frame mean intensity|; log the median photometric residual Δmean > 8 levels and median residual jumping 4 → 20–25 on exactly the spike frame. Motion blur instead correlates with gyro rate and degrades over several frames, not one.
LiDAR registration reports the best fitness on site (0.97, RMSE 0.021 m) while position slides metres along a dock. Registration degeneracy — the cost is flat along the corridor axis Smallest eigenvalue of JTJ and its eigenvector (Zhang, ICRA 2016) λmin ~2400 healthy → ~12 here; condition number > 900; and the eigenvector points along the dock, naming the unobservable DOF.
Parked robot, panned camera. VO reports 3–8 cm/frame of random-direction translation; new landmarks at 0.5–400 m. Pure rotation — E = [t]×R is identically zero Median parallax angle across matches 2.1° healthy → 0.04° here. Note both inlier ratio (0.91) and reprojection error (0.28 px) improve, which is the tell.
Map folds after eleven minutes. Ceres reports CONVERGENCE; every final residual is sub-pixel. One false loop closure — a wrong association that won the tug of war Per-constraint residual before optimisation, and a consistency check across three consecutive place-recognition queries Ω = 100 against a chain stiffness of 0.0185 absorbs 99.98% of the error, so the post-optimisation residual is ~0.6 mm on a 3 m lie. Only the pre-optimisation residual shows it.

5 · Classical vs modern — and when to use which

StageClassicalLearnedWhen to use which
DetectionFAST + Harris ranking, 8-level pyramid, quadtree distributionSuperPoint, ALIKED, XFeat Classical whenever the scene is textured and the budget is CPU-only. Learned when low texture, motion blur or day/night change dominates — and XFeat (2024) is the first that fits an embedded CPU.
Description256-bit BRIEF, 32 B, ~2 ns per comparison256-d float, 1 KB, GEMM on a GPU Classical if descriptors cross a process, a network or a persisted map — the 32× bandwidth is usually the binding constraint, not accuracy.
MatchingNN + ratio test + mutual, guided by the predicted poseSuperGlue, LightGlue, LoFTR, RoMa Classical on the guided 30 Hz path where the candidate set is already ~2. Learned on the unguided wide-baseline relocalisation path, which runs at 3–5 Hz and is where classical is weakest.
VerificationRANSAC with an adaptive stopping ruleOANet, CLNet, learned confidences Classical, always — it is the only frontend component with a probabilistic guarantee you can state. Use learned confidences to order the sampling (PROSAC), not to replace the consensus.
TrackingIndirect (reprojection) or direct (photometric) or semi-directDROID-SLAM, DPVO, DPV-SLAM Semi-direct is the settled classical answer. Learned wins on hard sequences and costs a GPU plus a validation burden; DPVO (2023) is the first with a plausible embedded story.
RegistrationPoint-to-plane ICP, GICP, NDTGeoTransformer, learned descriptors Classical for consecutive scans with a motion prior — KISS-ICP (2023) shows careful preprocessing beats algorithmic cleverness. Learned only for very-low-overlap pairs, where ICP has no basin at all.
PoseP3P / SQPnP + Gauss–NewtonReloc3r, MASt3R-SLAM Classical in the loop that moves the robot: metric by construction, inspectable residual, meaningful covariance, predictable degradation. Learned shadow-mode to mine hard cases and bootstrap relocalisation.
The one-sentence version of this table: "Learning is better at correspondence; geometry is better at estimation. Put the learned component where its failure is recoverable, and keep an interpretable residual anywhere the product has to be debugged in the field." That sentence has been correct for eight years, which is the best evidence available that it will still be correct next year.

6 · Recommended reading

The one book. Multiple View Geometry in Computer Vision, Hartley & Zisserman, 2nd edition. Chapters 4 (estimation and RANSAC), 9–11 (epipolar geometry and the fundamental matrix) and Appendix 6 (iterative estimation) cover most of this lesson's geometry with more rigour than any paper. If you want one robotics book instead, Introduction to Visual SLAM by Gao and Zhang is the practical companion, with working C++ for every chapter here.

Five papers, and why each.

  1. Lowe, "Distinctive Image Features from Scale-Invariant Keypoints" (IJCV 2004). Read section 7.1 for the ratio-test justification. It is the clearest example in the field of choosing a statistic by measuring two distributions rather than by argument.
  2. Mur-Artal, Montiel & Tardós, "ORB-SLAM" (T-RO 2015). The reference frontend. Read for the automatic initialisation (the H-versus-E model selection), the covisibility graph, and the survival-of-the-fittest keyframe policy — each is a design decision with a stated reason.
  3. Barath et al., "MAGSAC++" (CVPR 2020). The modern answer to "how do I pick the inlier threshold" — do not. Also the gateway to the RANSAC-benchmark literature, whose honest conclusion is that the combination beats any single idea.
  4. Vizzo et al., "KISS-ICP" (RA-L 2023). The most useful paper on this list, because its message is unfashionable and well supported: deskewing, adaptive downsampling and an adaptive rejection threshold matter more than a decade of cost-function refinement.
  5. Lindenberger, Sarlin & Pollefeys, "LightGlue" (ICCV 2023). The state of learned matching, and the clearest exposition of adaptive computation — exit early on easy pairs, prune points you are confident cannot match. Read it alongside SuperGlue (CVPR 2020) for the dustbin idea.

Five repositories, and what to look at in each.

  1. UZ-SLAMLab/ORB_SLAM3 — open src/ORBextractor.cc and read DistributeOctTree. It is the spatial-distribution machinery this lesson's Chapter 1 debug section is about, and almost no textbook describes it.
  2. opencv/opencvmodules/calib3d/src/usac/. The PROSAC sampler, the local optimisation and the MAGSAC scoring, all in readable C++ with the papers cited inline.
  3. PRBonn/kiss-icp — roughly a thousand lines total. Read KissICP.cpp and count how much of it is preprocessing versus registration. That ratio is the paper's argument.
  4. cvg/LightGluelightglue/lightglue.py, specifically the confidence head and the early-exit logic. It is the cleanest available implementation of "know when to stop."
  5. JakobEngel/dso — read the photometric calibration documentation before the code. The fact that a direct method needs a response curve and a vignette map, not just K, is the method's central tradeoff made concrete.

7 · The numbers drill, and the practice protocol

Answer each to one significant figure, out loud, without notes. If any takes more than ten seconds, that is the one to practise.

QuestionAnswerHow you get there
RANSAC iterations for a 2D line at 60% outliers, p = 0.9927ln(0.01)/ln(1−0.42) = −4.605/−0.174
Same, for the essential matrix at 50% outliers, p = 0.999218ln(0.001)/ln(1−0.55) = −6.908/−0.0317
Inlier threshold for a 2-DOF reprojection residual at σ = 1 px2.45 px√5.991
ORB descriptor bandwidth, 1000 keypoints at 30 Hz0.96 MB/s1000 × 32 B × 30
SuperPoint float32 descriptor bandwidth, same30.7 MB/s1000 × 1024 B × 30 — larger than the raw image
Total pyramid pixels, 1280×720, 8 levels at scale 1.22.85 Mpx921,600 × (1−0.6948)/(1−0.694) = ×3.10
Candidates in a 25 px guided window, 1000 keypoints on 1280×7202.11000 × π(25)2/921,600
Association hypotheses, 5 measurements against 20 landmarks4.1 million215
Depth uncertainty at Z = 10 m, B = 5 cm, f = 500, σd = 1 px4 m — 40%Z2σd/(fB) = 100/25
Baseline needed for 1° of parallax at Z = 10 m17.5 cm10 × tan(1°)
Pixel motion from 2 rad/s of yaw at f = 500, 30 Hz33 px/framefω/30 — no depth in it
Points in one 128-beam LiDAR scan230,000128 × 1800
Deskew error at 1.5 m/s over a 100 ms sweep15 cm1.5 × 0.1
Failed frames per minute at a 99% per-frame success rate, 30 Hz180.01 × 30 × 60
1° of yaw expressed as sideways translation, f = 500, Z = 5 m8.8 cm505 × 0.01745 px, divided by 100 px/m

The practice protocol, spread over a week, for making this material permanent:

  1. Day 1 — derivations, on paper, twice each. The RANSAC iteration count, the ICP closed form including the determinant guard, and the photometric linearisation. Twice, because the first time you are recalling and the second time you are rebuilding.
  2. Day 2 — the numbers table above, out loud, cold. Anything over ten seconds goes on a card.
  3. Day 3 — draw the frontend from memory and say aloud what breaks if each block is deleted. Then draw it again with the millisecond budget written on each block.
  4. Day 4 — the failure taxonomy. For each of the seven scenarios in section 4, state symptom, metric and distinguishing value without looking. These are what separate senior from staff, and they are the part everyone skips.
  5. Day 5 — run the Studio cold (the button at the top of this page): it is a timed practice session on exactly this material, and its debrief will tell you which chapter to revisit.
  6. Day 6 — the tradeoff. Practise stating one in three parts, in under thirty seconds: the assumption it makes, the failure it accepts, and the observation that would change your mind.
  7. Day 7 — rest. Re-reading on the last day measures your short-term memory, not your understanding.
Where to go next. Lesson 7 — SLAM Backend takes the 152-byte constraints this lesson produces and shows what the optimizer does with them: pose graphs, sparsity, marginalisation and IMU preintegration. Lesson 8 — Drift & Loop Closure is the direct sequel to Chapter 0, because rejecting false loop closures is the frontend problem that decides whether the map folds. And Lesson 5 — Calibration is upstream of everything here: every equation in this lesson assumed K was correct.

"The first principle is that you must not fool yourself —
and you are the easiest person to fool."
— Richard Feynman

Every failure in this lesson was a system fooling itself with a healthy-looking metric.
The whole job is knowing which second number to look at.