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.
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.
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.
| Layer | What it decides | What happens when it is wrong |
|---|---|---|
| Frontend | Which 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. |
| Backend | Where everything is, given the constraints (continuous) | Slow convergence, a local minimum, a worse estimate — but the errors are graded, and the residuals scream. |
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:
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.
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
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
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
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
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.
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.
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.
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.
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.
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.
"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:
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.
| Stage | Work | Time | Running total |
|---|---|---|---|
| Undistort + pyramid | 2.85 Mpx resample | 2.0 ms | 2.0 ms |
| FAST detect + NMS + bucket | 2.85 Mpx × 3 ns | 8.6 ms | 10.6 ms |
| BRIEF describe | 1000 kp × 256 tests | 1.8 ms | 12.4 ms |
| Guided match + ratio + mutual | 1000 kp × ~12 candidates | 2.6 ms | 15.0 ms |
| RANSAC (219 iters × 5-pt) | see Chapter 3 | 2.2 ms | 17.2 ms |
| PnP + Gauss–Newton refine | 200 pts × 4 iters | 0.9 ms | 18.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.
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."
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.
| Ch | Failure | Observable symptom | The metric that reveals it |
|---|---|---|---|
| 1 | Feature clustering | Healthy inlier count and reprojection error, but heading drifts | Keypoint spatial entropy over an 8×6 grid; pose covariance eigenvalue ratio |
| 2 | Repetitive structure | Match count collapses 5× while feature count is unchanged | Ratio-test rejection rate: 30% → 85% |
| 3 | Planar degeneracy | 85% inliers, sub-pixel residuals, translation direction flips | Homography inliers / essential inliers > 0.9 |
| 4 | Brightness-constancy violation | Pose spike exactly at an auto-exposure step | Correlation of pose-increment norm with frame mean-intensity delta |
| 5 | Registration degeneracy | Excellent fitness score, pose slides along the corridor | Smallest eigenvalue of JTJ; Zhang's degeneracy factor |
| 6 | Low-parallax triangulation | New landmarks appear at absurd depth; scale wanders | Median parallax angle across matches, gated at 1° |
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.
Every numbered chapter here works its topic five ways, because owning a system — rather than recognising it — takes all five:
| Layer | The question it answers | What 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.
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.
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.
| Property | Means | What kills it |
|---|---|---|
| Repeatability | The same physical point is detected again from a new viewpoint | Detecting on a surface that changes appearance — a specular highlight, a shadow edge, a reflection |
| Locality | The measurement comes from a small patch, so occlusion only kills the keypoints it covers | Large support regions; global descriptors |
| Distinctiveness | Its descriptor is far from every other descriptor in the image | Repetitive structure — the exact thing warehouses are made of |
| Efficiency | Thousands per frame inside a millisecond budget | Anything 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.
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:
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:
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.
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.
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.
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.
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.
Take this patch, with the centre pixel at (0,0), x increasing right and y increasing down:
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:
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.
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:
| Level | Image size | Area share | Features (of 1000) |
|---|---|---|---|
| 0 | 1280 × 720 | 32.30% | 323 |
| 1 | 1067 × 600 | 22.43% | 224 |
| 2 | 889 × 500 | 15.58% | 156 |
| 3 | 741 × 417 | 10.82% | 108 |
| 4 | 617 × 347 | 7.51% | 75 |
| 5 | 514 × 289 | 5.22% | 52 |
| 6 | 429 × 241 | 3.62% | 36 |
| 7 | 357 × 201 | 2.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:
DistributeOctTree repeatedly splits nodes containing more than one keypoint until the node count reaches the target, then keeps the highest-response keypoint in each node. Result: exactly N features, spread as evenly as the image content allows.Bandwidth. Your descriptor choice is a bandwidth and memory decision before it is an accuracy decision. At 1000 keypoints per frame and 30 Hz:
| Descriptor | Bytes each | Per frame | Sustained | vs raw image |
|---|---|---|---|---|
| ORB / BRIEF, 256-bit | 32 | 31 KB | 0.96 MB/s | 0.035× |
| SIFT, 128 × uint8 | 128 | 125 KB | 3.84 MB/s | 0.14× |
| XFeat, 64 × float32 | 256 | 250 KB | 7.68 MB/s | 0.28× |
| SuperPoint, 256 × float16 | 512 | 500 KB | 15.4 MB/s | 0.56× |
| SuperPoint, 256 × float32 | 1024 | 1000 KB | 30.7 MB/s | 1.11× |
| Raw 1280×720 mono reference: 0.92 MB/frame, 27.6 MB/s | ||||
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.
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.
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
Worked, so the numbers mean something:
| Distribution over 48 cells | H (bits) | H / Hmax | Reading |
|---|---|---|---|
| Perfectly uniform | 5.585 | 1.000 | Ideal, never happens |
| 600 in 3 cells, 400 spread over 45 | 4.119 | 0.737 | Acceptable |
| All 1000 in 8 cells | 3.000 | 0.537 | Alarm |
| All 1000 in 4 cells | 2.000 | 0.358 | The 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.
This is a landscape section, not a depth section — a map of who is who and why.
| Method | Year | The one idea | Why you would or would not ship it |
|---|---|---|---|
| SuperPoint | 2018 | Self-supervised via homographic adaptation; joint detector + 256-d descriptor head | The baseline everyone compares to. 1 KB/keypoint and a GPU. |
| R2D2 | 2019 | Separate repeatability and reliability maps — not every repeatable point is discriminative | Best single idea in the family. Slow. |
| DISK | 2020 | Trains the whole detect–describe–match pipeline with a policy-gradient reward on matches | Strong on wide baselines; heavy. |
| ALIKED | 2023 | Deformable sampling so the descriptor support follows the local geometry | Good accuracy per FLOP. |
| XFeat | CVPR 2024 | Explicitly designed for CPU: 64-d descriptors, ~1.7 ms per frame on an ordinary laptop core | The first learned detector that is a genuine ORB replacement on embedded hardware. |
| DeDoDe | 3DV 2024 | Decouples detection from description entirely and trains each on its own objective | Research-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.
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.
A putative match (a, b) can be wrong in two structurally different ways, and each has its own test.
| The question | Failure it catches | The 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 good | Ratio 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.
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.
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.
Binary descriptors, Hamming distance out of 256 bits, τ = 0.8.
| Pair | d1 | d2 | r = d1/d2 | Decision | What it really was |
|---|---|---|---|---|---|
| A | 38 | 142 | 38/142 = 0.268 | accept | A crisp printed label. Textbook good match. |
| B | 110 | 180 | 110/180 = 0.611 | accept | A blurred corner. The absolute distance 110 looks terrible; the ratio says it is still the only plausible candidate. |
| C | 96 | 104 | 96/104 = 0.923 | reject | The fourth shelf upright. Two candidates, both plausible, no way to choose. |
| D | 31 | 34 | 31/34 = 0.912 | reject | Two 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.
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.
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.
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:
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.
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
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.
Start from the cost of the naive thing, because that is what motivates every optimisation after it.
| Strategy | Comparisons | Arithmetic | Time (one Orin CPU core) |
|---|---|---|---|
| Brute force, 256-bit binary | 1000 × 1000 = 106 | 4 XOR + 4 POPCNT each ≈ 8 × 106 instructions | ~2.7 ms |
| Brute force, 256-d float32 | 106 dot products | 2 × 106 × 256 = 512 MFLOP | ~51 ms (0.4 ms on the GPU) |
| Guided by projection, r = 25 px | 1000 × 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
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.
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.
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.
| Condition | Features/frame | Rejection rate | Diagnosis |
|---|---|---|---|
| Healthy aisle | 1000 | ~30% | Normal |
| Repetitive structure | 1000 | ~85% | Features are fine; appearance is ambiguous |
| Dark / blurred / blank wall | ~120 | ~30% | Detection failed, not matching |
| Exposure step | 1000 | ~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.
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.
| Method | Year | The one idea | Cost |
|---|---|---|---|
| SuperGlue | CVPR 2020 | Attention 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 |
| LoFTR | CVPR 2021 | Detector-free: coarse-to-fine dense matching on transformer features. Works on textureless surfaces where no detector fires at all | Heavy; offline |
| LightGlue | ICCV 2023 | SuperGlue made adaptive — early exit on easy pairs, prune confidently unmatchable points. Often 5–10× faster at equal accuracy | ~10–30 ms/pair |
| RoMa | CVPR 2024 | Dense robust matching on frozen DINOv2 features with a coarse-to-fine refiner; strongest available under extreme viewpoint change | Offline |
| MASt3R | ECCV 2024 | Predicts 3D pointmaps for both images in one shared frame and reads matches off the geometry — matching stops being a 2D appearance problem | Offline; 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.
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.
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:
Both logarithms are negative, so N is positive. If you cannot say why the inequality flipped, the derivation was recited.
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.
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.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 |
|---|---|---|---|---|---|---|---|
| 2 — line, 2D rigid | 113 | 49 | 27 | 17 | 11 | 7 | 5 |
| 3 — P3P | 574 | 169 | 70 | 35 | 19 | 11 | 7 |
| 4 — homography | 2,876 | 567 | 178 | 72 | 34 | 17 | 9 |
| 5 — essential | 14,389 | 1,893 | 448 | 146 | 57 | 26 | 12 |
| 7 — fundamental | 359,777 | 21,055 | 2,809 | 588 | 163 | 54 | 20 |
| 8 — linear fundamental | 1,798,893 | 70,188 | 7,025 | 1,177 | 272 | 78 | 26 |
| All at p = 0.99, rounded up. Read across for "how much does bad matching cost me"; read down for "why minimal solvers matter". | |||||||
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.
The formula is five lines; the assumptions are the engineering.
| Assumption | Where it breaks | What 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 advance | Always. 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 model | Degeneracy. 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 threshold | Threshold 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. |
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:
| Residual | d | χ2d,0.95 | t at σ = 1 px |
|---|---|---|---|
| Point-to-epipolar-line (Sampson), F or E | 1 | 3.841 | √3.841 = 1.96 px |
| Symmetric transfer / reprojection, homography or PnP | 2 | 5.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:
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.
Assumption 2 says you do not know w. The standard resolution is to start pessimistic and update as evidence arrives:
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.
The cost of RANSAC is iterations times the per-iteration cost, and the per-iteration cost has two parts:
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.
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:
| Technique | Idea | What 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.
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
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:
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 inliers | Reading | What to do |
|---|---|---|
| < 0.5 | General 3D structure. E is identifiable. | Use E, proceed. |
| 0.5 – 0.9 | Mixed. A dominant plane plus some off-plane structure. | Use E but widen the pose covariance; do not create landmarks from the plane. |
| > 0.9 | Degenerate. 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.
| Work | Year | The one idea |
|---|---|---|
| OANet | ICCV 2019 | Learn inlier/outlier classification on the correspondence set directly, with permutation-invariant layers plus a differentiable pooling that captures local consensus. |
| MAGSAC++ | CVPR 2020 | Marginalise over the noise scale σ instead of picking a threshold. Now OpenCV's USAC_MAGSAC. |
| "RANSAC in 2020" | 2020–21 | Barath 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. |
| VSAC | ICCV 2021 | A cheap independence test on the sample plus dominant-plane detection built in — degeneracy handling promoted from an afterthought to a first-class step. |
| LightGlue confidence | ICCV 2023 | A 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.
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.
Start from the physical claim. Brightness constancy says that the same surface point, seen from two poses, produces the same intensity:
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:
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:
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:
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.
Take the warehouse AMR: f = 500 px, 30 Hz.
| Motion | Pixel velocity | Displacement per frame | Inside a 3 px basin? |
|---|---|---|---|
| Translating 1 m/s, feature at Z = 5 m | f·v/Z = 500×1/5 = 100 px/s | 3.3 px | Marginal at level 0 |
| Yawing 0.5 rad/s (a gentle turn) | f·ω = 500×0.5 = 250 px/s | 8.3 px | No |
| Yawing 2 rad/s (a fast turn) | f·ω = 1000 px/s | 33.3 px | Nowhere 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.
Real cameras violate brightness constancy constantly, and each violation has a name:
| Violation | Cause | Effect on I |
|---|---|---|
| Auto-exposure / auto-gain | The camera is doing its job | Global multiplicative and additive shift, changing between frames |
| Vignetting | Lens falloff toward the corners | Spatially varying multiplicative factor, fixed per lens |
| Non-linear response | The sensor's transfer curve is not linear in irradiance | A fixed monotone warp of every intensity |
| Non-Lambertian surfaces | Polished concrete, steel, glass | Intensity depends on viewing angle — unfixable by calibration |
| Flicker | Mains-frequency lighting beating against the exposure | Global intensity oscillation at a few Hz |
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:
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.
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.
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.
| Feature-based (indirect) | Direct | |
|---|---|---|
| Assumes | A sparse set of points is re-identifiable by appearance | Brightness is constant (or affinely modelled) |
| Minimises | Reprojection error, in pixels | Photometric error, in intensity levels |
| Per keyframe pair | ~1000 points × 2 = 2,000 residuals | ~2000 points × 8-px pattern = 16,000 residuals |
| Needs before the optimizer | Detect (9 ms) + describe (2 ms) + match (3 ms) = 14 ms | Nothing. Warp and sample directly — but 8× the optimizer work |
| Convergence basin | Whole image (matching is global) | ~3 px at the fine level, ~24 px with a 4-level pyramid |
| Low-texture surfaces | Fails — no corners fire | Works — any non-zero gradient contributes |
| Auto-exposure step | Mostly survives — descriptors are contrast-normalised | Fails unless the affine model is present |
| Relocalisation from nothing | Works — descriptors are the index | Impossible alone; needs a separate appearance module |
| Outlier handling | RANSAC on discrete correspondences — a clean partition | Robust 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.
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.
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.
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:
| System | Year | The one idea |
|---|---|---|
| DSO | TPAMI 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-SLAM | NeurIPS 2021 | Learned dense optical flow feeding a differentiable dense bundle adjustment layer, iterated. Neither direct nor feature-based — the correspondence is predicted, then geometry refines it. |
| DPVO | NeurIPS 2023 | DROID'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-SLAM | ECCV 2024 | DPVO plus loop closure and global optimisation — a complete learned system, real-time on a single GPU. |
| MASt3R-SLAM | CVPR 2025 | Two-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.
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.
Given N putative correspondences (pi, qi), find the rotation R and translation t minimising
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:
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 − bi‖2. Expanding the square:
(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:
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
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.
Step 3 — the shortcut that exists only in 2D. You do not need an SVD in the plane. The optimal angle is
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.
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:
Sliding along the surface is free, which is the correct model of what a plane actually tells you.
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):
Step 3 — the point-to-point step is the mean of those (translation only, since the geometry is not rotating):
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:
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.
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.
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:
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.
| d | Euclidean | dTΣ−1d | score |
|---|---|---|---|
| (0.1, 0.1) | 0.1414 | 0.43956 + 0.43956 − 0.26374 = 0.61538 | e−0.30769 = 0.735 |
| (0.1, −0.1) | 0.1414 | 0.43956 + 0.43956 + 0.26374 = 1.14286 | e−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 search | KD-tree, every iteration | KD-tree + normals | None — a voxel lookup, O(1) |
| Objective | Piecewise smooth (jumps when correspondences flip) | Piecewise smooth | Smooth — differentiable everywhere |
| Convergence basin | Narrow | Narrow | Wider, set by voxel size |
| Iterations to converge | 30–50 | 5–10 | 10–20 |
| Sensitive to | Initialisation, sampling density | Normal quality | Voxel size — too small is spiky, too large is blurred |
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:
| Rejector | Rule | Catches |
|---|---|---|
| Distance | Drop pairs with d > τ·median(d), typically τ = 2–3 | Dynamic 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 compatibility | Drop pairs whose surface normals differ by more than ~45° | Points matched across a corner onto the wrong face. |
| Duplicate / many-to-one | Keep only the best source point claiming each target point | The 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.
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.
| Stage | Detail | Cost |
|---|---|---|
| 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 m | 230,400 → ~15,000 points in a warehouse-sized scene | ~4 ms |
| KD-tree build on the local map | n log n ≈ 20,000 × 14.3 = 286,000 operations | ~5 ms |
| NN queries, per iteration | 15,000 queries × log n | ~4 ms |
| Solve + apply, per iteration | 3×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:
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 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.
Three experiments, and each one settles an argument.
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:
| Signal | Healthy dock scan | Degenerate dock scan |
|---|---|---|
fitness | 0.95 | 0.97 — better! |
inlier_rmse | 0.03 m | 0.021 m — better! |
| λmin of JTJ | ~2,400 | ~12 |
| λmax / λmin | ~35 | > 900 |
| Eigenvector for λmin | no meaning | points 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:
| Work | Year | The one idea |
|---|---|---|
| Generalized-ICP | RSS 2009 | The 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. |
| VGICP | ICRA 2021 | GICP's accuracy without the nearest-neighbour search, by aggregating covariances over voxels — and it parallelises onto a GPU. |
| GeoTransformer | CVPR 2022 | Learned 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-ICP | RA-L 2023 | The 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_gicp | 2024 | An 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.
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.
| 2D–2D (essential) | 3D–2D (PnP) | 3D–3D (registration) | |
|---|---|---|---|
| Inputs | Two image point sets | Map points + their image observations | Two point clouds |
| Needs | Calibration K | Calibration + a map | Depth in both frames |
| Gives | R and the direction of t | Full 6-DOF metric pose | Full 6-DOF metric pose |
| DOF recovered | 5 — scale is unobservable | 6 | 6 |
| Minimal sample s | 5 (Nistér) or 7/8 (F) | 3 (P3P), up to 4 solutions | 3 (non-collinear) |
| RANSAC N at w = 0.6 | 57 | 19 | 19 |
| Degenerates on | Pure rotation; planar scenes | Coplanar map points; all points collinear | Corridors, planes — Chapter 5 |
| Used for | Bootstrapping only | Every tracked frame | LiDAR frontends |
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.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:
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.
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:
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.
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
Step 4 — the normal equation. With one measurement and one unknown, JTJ = 1002 = 10,000 and JTr = 100 × 4.0 = 400, so
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 here | Meaning |
|---|---|---|---|
| tx (sideways) | f/Z | 100 px/m | 1 cm of sideslip = 1 px |
| tz (forward) | −f·X/Z2 | −10 px/m | Ten times weaker — forward motion is always the hardest to see |
| θy (yaw) | f(1 + X2/Z2) | 505 px/rad | 1° 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.
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:
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 B | How you get it | Parallax α | σZ | σZ/Z |
|---|---|---|---|---|
| 0.05 m | One frame at 1.5 m/s, 30 Hz | 0.286° | 4.00 m | 40% |
| 0.17 m | ~3 frames | 0.974° | 1.18 m | 11.8% |
| 0.50 m | ~10 frames — a keyframe interval | 2.862° | 0.40 m | 4.0% |
| 1.00 m | ~20 frames | 5.711° | 0.20 m | 2.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 1°. 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.
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.
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.
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.
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.
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.
| Signal | Healthy driving frame | Pure-rotation frame |
|---|---|---|
| Inlier ratio | 0.88 | 0.91 — better |
| Median reprojection error | 0.35 px | 0.28 px — better |
| Median parallax | 2.1° | 0.04° |
| H inliers / E inliers | 0.42 | 0.98 |
| New landmark depth spread | 3–15 m | 0.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.9 | Pure rotation — do not estimate t, do not triangulate | Planar scene — decompose H instead of E |
| H/E inliers < 0.5 | Nearly stationary with 3D structure — safe, but triangulate nothing yet | Healthy — 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.
| Work | Year | The one idea |
|---|---|---|
| SQPnP | ECCV 2020 | PnP 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. |
| DPVO | NeurIPS 2023 | Sparse learned patch tracking feeding a differentiable bundle adjustment — the correspondence stage is learned, the pose stage stays geometric. |
| Reloc3r | CVPR 2025 | Feed-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-SLAM | CVPR 2025 | Real-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."
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.
The controls are not independent, and that coupling is the whole lesson. Here is the chain, in the order the data flows.
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.
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.
| τ | matches kept | w | rotation error |
|---|---|---|---|
| 0.80 | 64 | 0.406 | 0.209° |
| 0.60 | 23 | 0.913 | 0.164° — the minimum |
| 0.50 | 16 | 0.938 | 0.218° |
| 0.40 | 13 | 1.000 | 0.316° — the worst |
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 this | Because it answers | If it is wrong, stop here |
|---|---|---|---|
| 1 | Matches kept, out of the putative total | Did 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. |
| 2 | Inlier ratio w | Is 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. |
| 3 | Required N against your budget | Can 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. |
| 4 | Measured against theory | Is the algorithm behaving as derived? | A large gap means an assumption has broken — usually degenerate samples — or your implementation has. Those are different fixes. |
| 5 | RANSAC inlier count against matches kept | Did 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. |
| 6 | Rotation and translation error | Did 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. |
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.
Every simulation leaves things out, and knowing exactly what is worth more than the simulation itself.
| The bench | A real frontend | Why it matters |
|---|---|---|
| Outliers are uniformly random | Outliers are structured — a moving forklift produces a self-consistent set of wrong matches | Structured 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 = 2 | Essential matrix s = 5, or P3P s = 3 | The exponent is what makes N explode. At w = 0.4, s = 2 needs 27 iterations and s = 5 needs 448. |
| Inlier noise is isotropic Gaussian | Localisation noise varies with scale, blur and gradient direction | A fixed threshold is right on average and wrong per point — which is what MAGSAC++ addresses. |
| w is stationary within a run | w changes frame to frame with lighting, motion and scene content | The 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 that | Chapter 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.
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.
| Concept | 30-second explanation | Key equation | Tool | Classic paper | 2024+ 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 | — |
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.
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."
| Symptom | Root cause | The metric that reveals it | The 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 λmax/λmin 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. |
| Stage | Classical | Learned | When to use which |
|---|---|---|---|
| Detection | FAST + Harris ranking, 8-level pyramid, quadtree distribution | SuperPoint, 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. |
| Description | 256-bit BRIEF, 32 B, ~2 ns per comparison | 256-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. |
| Matching | NN + ratio test + mutual, guided by the predicted pose | SuperGlue, 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. |
| Verification | RANSAC with an adaptive stopping rule | OANet, 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. |
| Tracking | Indirect (reprojection) or direct (photometric) or semi-direct | DROID-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. |
| Registration | Point-to-plane ICP, GICP, NDT | GeoTransformer, 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. |
| Pose | P3P / SQPnP + Gauss–Newton | Reloc3r, 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 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.
Five repositories, and what to look at in each.
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.opencv/opencv — modules/calib3d/src/usac/. The PROSAC sampler, the local optimisation and the MAGSAC scoring, all in readable C++ with the papers cited inline.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.cvg/LightGlue — lightglue/lightglue.py, specifically the confidence head and the early-exit logic. It is the cleanest available implementation of "know when to stop."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.Answer each to one significant figure, out loud, without notes. If any takes more than ten seconds, that is the one to practise.
| Question | Answer | How you get there |
|---|---|---|
| RANSAC iterations for a 2D line at 60% outliers, p = 0.99 | 27 | ln(0.01)/ln(1−0.42) = −4.605/−0.174 |
| Same, for the essential matrix at 50% outliers, p = 0.999 | 218 | ln(0.001)/ln(1−0.55) = −6.908/−0.0317 |
| Inlier threshold for a 2-DOF reprojection residual at σ = 1 px | 2.45 px | √5.991 |
| ORB descriptor bandwidth, 1000 keypoints at 30 Hz | 0.96 MB/s | 1000 × 32 B × 30 |
| SuperPoint float32 descriptor bandwidth, same | 30.7 MB/s | 1000 × 1024 B × 30 — larger than the raw image |
| Total pyramid pixels, 1280×720, 8 levels at scale 1.2 | 2.85 Mpx | 921,600 × (1−0.6948)/(1−0.694) = ×3.10 |
| Candidates in a 25 px guided window, 1000 keypoints on 1280×720 | 2.1 | 1000 × π(25)2/921,600 |
| Association hypotheses, 5 measurements against 20 landmarks | 4.1 million | 215 |
| Depth uncertainty at Z = 10 m, B = 5 cm, f = 500, σd = 1 px | 4 m — 40% | Z2σd/(fB) = 100/25 |
| Baseline needed for 1° of parallax at Z = 10 m | 17.5 cm | 10 × tan(1°) |
| Pixel motion from 2 rad/s of yaw at f = 500, 30 Hz | 33 px/frame | fω/30 — no depth in it |
| Points in one 128-beam LiDAR scan | 230,000 | 128 × 1800 |
| Deskew error at 1.5 m/s over a 100 ms sweep | 15 cm | 1.5 × 0.1 |
| Failed frames per minute at a 99% per-frame success rate, 30 Hz | 18 | 0.01 × 30 × 60 |
| 1° of yaw expressed as sideways translation, f = 500, Z = 5 m | 8.8 cm | 505 × 0.01745 px, divided by 100 px/m |
The practice protocol, spread over a week, for making this material permanent:
"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.