Every sensor lies about something. Knowing the list is not the skill — the skill is knowing which lie, why the physics forces it, and what number on a dashboard reveals it first.
It is a Thursday afternoon and you are standing in a warehouse aisle with three engineers and a robot that will not move.
The robot is a warehouse AMR — an autonomous mobile robot that shuttles pallets between pick faces. It has stopped 1.8 metres short of a pallet of shrink-wrapped water bottles and it is refusing to path around it, because as far as the planner is concerned there is nothing there to path around. The costmap in front of the robot is clean, white, empty. The safety layer is the only thing holding it, and it is holding it because the front bumper's proximity sensor is unhappy about something the costmap says is not there.
Somebody pulls up the raw LiDAR scan. The two beams that should be painting the pallet return nothing at all. Not a wrong range — nothing. The scan simply skips those azimuths, as if the aisle continued for another twelve metres.
Somebody else pulls up the forward camera. The pallet is right there, in focus, well lit, unmistakable. Blue caps, printed labels, a glossy sheet of plastic film pulled tight over the whole stack.
The room goes quiet for a second, and then the staff engineer standing next to you says:
The answer is not a bug report. It is a property of how a time-of-flight rangefinder works, and if you understand that property you can predict a dozen other failures from the same principle without ever having seen them.
A scanning LiDAR fires a short laser pulse and waits for photons to come back to a small aperture. For that to work, the surface has to scatter some of the light diffusely — in all directions, so that a few of the returning photons happen to travel back along the outgoing ray. A clear, tight, glossy plastic film does almost none of that.
Three things happen to the pulse and none of them help:
That 0.04 is not a number you look up — it is one you can produce in two lines. It is the Fresnel reflectance at normal incidence — the fraction of power reflected when light crosses from a medium of refractive index n1 into one of index n2 along the surface normal:
This is the θ → 0 limit of the full Fresnel equations. At exactly normal incidence there is no plane of incidence to define, so the s- and p-polarised reflectances converge to the same value and you do not have to carry polarisation at all. That is why this one-line form is the one worth memorising: it is the only case where the answer is a single scalar.
Do the arithmetic on the page, by hand, so the number is yours. Air is n1 = 1.00. Polyethylene film is n2 ≈ 1.50 at 905 nm. The numerator is 1.00 − 1.50 = −0.50. The denominator is 1.00 + 1.50 = 2.50. The ratio is −0.50 / 2.50 = −0.20, and the sign disappears when you square: (−0.20)2 = 0.040.
One refinement, because a film has two surfaces and both of them reflect. The front interface (air → PE) reflects 0.040 and transmits 0.960. The back interface (PE → air) reflects 0.040 of what reached it, that is 0.960 × 0.040 = 0.0384, and 0.960 of that escapes forward through the front surface again: 0.0384 × 0.960 = 0.0369. Total reflected back toward the sensor:
and total transmitted straight through, 0.960 × 0.960 = 0.9216 ≈ 92%, with the remaining ~0.15% circulating in higher-order internal bounces. (Thin-film interference between the two surfaces does exist, but the film is 15–50 µm thick, the pulse is broadband and the film is crinkled, so the fringes wash out completely. Say that in one clause and move on.)
So the honest budget is: 92% gone forward, 7.7% coming back — and every bit of that 7.7% is still headed in the mirror direction, which is where item 3 takes over.
What is left is the film's diffuse component, and it is tiny. Run it through the range equation and you get an actual number.
For a diffuse (Lambertian) target that fills the beam, the received optical power is
where Pt is peak transmit power, ρ is the target's diffuse reflectivity at the laser wavelength, Ar is the receive aperture area, R is range, and η lumps optical transmission and atmospheric loss. The π and the R2 together are the Lambertian hemisphere: the target scatters Ptρ watts into π steradians, and your aperture catches the fraction Ar/R2 of that.
Put the warehouse numbers in. Pt = 25 W peak, a 25 mm aperture so Ar = π × (0.0125)2 = 4.909 × 10-4 m2, η = 0.8, and the pallet at R = 5 m.
| Surface | ρ at 905 nm | Arithmetic | Pr at 5 m |
|---|---|---|---|
| White cardboard | 0.80 | 25 × 0.80 × 4.909e−4 / (π × 25) × 0.8 | 100 µW |
| Brown corrugated box | 0.20 | one quarter of the above | 25 µW |
| Matte black rubber | 0.05 | one sixteenth | 6.3 µW |
| Black car paint | 0.02 | one fortieth | 2.5 µW |
| Clear shrink-wrap (diffuse part) | 0.005 | one hundred and sixtieth | 0.63 µW |
Let me do the first row completely, so every later row is transparent. 25 × 0.80 = 20. Then 4.909 × 10-4 divided by (π × 25) = 4.909e−4 / 78.54 = 6.250 × 10-6. So 20 × 6.250e−6 = 1.250 × 10-4, and finally × 0.8 = 1.000 × 10-4 W = 100 µW. Every other row is that number scaled by ρ/0.8.
Four of the five rows in that table are ordinary Lambertian reflectivities you could look up. The fifth is the punchline of the whole chapter, so it is the one you have to be able to build from the 7.7% we just derived — otherwise the 0.63 µW is just a number you read somewhere.
Start from the upper bound. If all 7.7% of the reflected energy were scattered perfectly diffusely — a film sandblasted to a matte finish — then by definition ρ = 0.077, and shrink-wrap would look like a dark grey box and cause nobody any trouble. It does not, because the film is smooth: almost all of that 7.7% stays inside a narrow lobe about the mirror direction. Only the part scattered wide by surface roughness and sub-surface inhomogeneity becomes a diffuse return, and there is a standard measurement for exactly that fraction.
Haze (ASTM D1003) is defined as the fraction of transmitted light deviated by more than 2.5° from the incident direction. It is the industry's standard number for "how much does this film scatter", it appears on every film spec sheet, and for clear polyolefin shrink film it runs about 2–10% — toward the top of that band once the film has been stretched over a pallet, crinkled and picked up warehouse dust. Take 6.5%. Two honest caveats to say out loud: haze is defined on transmission and measured in the visible, and I am using it as a proxy for the wide-angle scattering fraction in reflection at 905 nm. It is a modelling assumption, not a measurement, and naming it as one is part of the answer.
That is the table's last row, and it lands squarely inside the 0.002–0.010 band that clear film occupies in measured 905 nm return data. The budget is: 92% transmits, 7.2% stays in the specular lobe, 0.5% scatters wide enough to come home.
Now run the other 93.5% and you get something better than a number — you get a prediction. Model the film's micro-facets as a slope distribution with RMS slope σs ≈ 5° = 0.087 rad (extrusion texture plus crinkle). The mirror doubles every tilt, so the reflected energy occupies a cone of half-angle Θ = 2σs = 0.175 rad, and its solid angle is Ωlobe ≈ πΘ2. Equate that lobe's return to the Lambertian form — which spreads Ptρ over π steradians — and the effective reflectivity of the specular part is
Read that carefully, because it is not a small number. When the local surface normal happens to point at the sensor, shrink-wrap has an effective reflectivity of 2.36 — three times brighter than white cardboard (2.36 / 0.80 = 2.95). When it does not, you get 0.005. The material is not dim; it is bimodal, and the bimodality is the diagnostic signature.
How often are you lucky? The lobe tolerates a surface tilt of about Θ/2 = 5° before the return walks off the aperture. A wrapped pallet face is a row of bottles roughly 80 mm in pitch, so on each 40 mm-radius bottle the band with |α| ≤ 5° is an arc of 2 × 0.040 × 0.087 = 7.0 mm. That is 7.0 / 80 = 8.7% of the face. With 1800 azimuths per revolution the angular pitch is 0.2° = 3.49 mrad, which at 5 m is 17.5 mm between adjacent beams — so you expect roughly one strong return in every eleven azimuths and nothing at all in between.
Now compare against the detector. A practical avalanche-photodiode receiver in this class needs on the order of 0.01 to 0.05 µW within the 5 ns pulse window to declare a detection with acceptable false-alarm rate. White cardboard at 5 m delivers 100 µW — a margin of about 2000×. Shrink-wrap delivers 0.63 µW. That is still above the floor if you get the full diffuse component. Take it out to 20 m and it falls to 0.039 µW, right on the threshold, and any tilt at all removes the specular contribution that was carrying it.
And the camera? The camera is passive. It does not care whether a surface returns energy to a particular 25 mm aperture at a particular nanosecond. It integrates whatever ambient light arrives over a 10 ms exposure, from every direction, off the printed labels, the coloured caps, the water meniscus, and the specular glints on the film that are so bright they saturate a few pixels. Transparency does not hide an object from a camera; it just changes what the object looks like.
Pick a material, then drag the range. The teal curve is Pr = PtρArη/(πR2). The red line is the detection floor. Where the curve dives under it, the scan has a hole — not a wrong range, a missing point.
What to look for. Slide the reflectivity from 0.80 down to 0.02 at a fixed 20 m and watch the returned power fall by a factor of forty while the range does not change at all. That is the asymmetry that surprises people: reflectivity is a multiplicative factor on the whole budget, and there is no distance at which a dark target becomes easier. The horizontal red line does not move, so the crossing point moves in as √ρ.
Everything above is arithmetic you can do on paper, which means it is also arithmetic you can type. It is small enough to finish in one sitting, and it separates understanding the equation from having merely heard it: the interesting half is not computing Pr, it is inverting it for Rmax.
Follow the lesson's usual order — hand arithmetic first (done, above), then from scratch, then the vectorised one-liner. From scratch, with nothing imported but math:
python import math def lidar_max_range(P_t, rho, aperture_d, eta, floor_W): # P_t peak transmit power, W rho diffuse reflectivity, 0..1 # aperture_d receive aperture diameter, m eta optical + atmospheric throughput # floor_W detection floor, W -> returns max range, metres A_r = math.pi * (aperture_d / 2.0) ** 2 # receive area from the DIAMETER # Forward: P_r = P_t * rho * A_r * eta / (pi * R**2) # Set P_r = floor_W and solve: floor_W * pi * R**2 = P_t * rho * A_r * eta return math.sqrt(P_t * rho * A_r * eta / (math.pi * floor_W)) def received_power(P_t, rho, aperture_d, eta, R): A_r = math.pi * (aperture_d / 2.0) ** 2 return P_t * rho * A_r * eta / (math.pi * R * R) P_t, d, eta, floor_W = 25.0, 0.025, 0.80, 2.0e-8 # 25 W, 25 mm, 80%, 20 nW for rho in (0.80, 0.20, 0.05, 0.02, 0.005): print("rho=%6.3f P_r(5m)=%8.3f uW R_max=%7.2f m" % ( rho, received_power(P_t, rho, d, eta, 5.0) * 1e6, lidar_max_range(P_t, rho, d, eta, floor_W)))
And the printed output, which is the point — the middle column is the table you just derived by hand, reproduced by the code, and the right-hand column is the new information the inversion buys you:
output
rho= 0.800 P_r(5m)= 100.000 uW R_max= 353.55 m
rho= 0.200 P_r(5m)= 25.000 uW R_max= 176.78 m
rho= 0.050 P_r(5m)= 6.250 uW R_max= 88.39 m
rho= 0.020 P_r(5m)= 2.500 uW R_max= 55.90 m
rho= 0.005 P_r(5m)= 0.625 uW R_max= 27.95 m
Check it against the hand arithmetic before you trust it — this habit is most of what separates a computation from a guess. The first Pr is 100.000 µW, which is the number we computed by hand. The last is 0.625 µW, which is the 0.63 in the table. And the Rmax column obeys the square root: 353.55 / 176.78 = 2.000, and √(0.80 / 0.20) = √4 = 2. Two ratios, one line, no debugger.
Then the vectorised form, which is what actually ships in an analysis notebook:
python import numpy as np rho = np.array([0.80, 0.20, 0.05, 0.02, 0.005]) A_r = np.pi * (d / 2.0) ** 2 R_max = np.sqrt(P_t * rho * A_r * eta / (np.pi * floor_W)) # array([353.55, 176.78, 88.39, 55.90, 27.95]) def range_at_reflectivity(R_spec, rho_spec, rho): # Datasheet says "R_spec m at rho_spec". Everything but rho cancels -- # P_t, A_r, eta and the floor are all the same unit -- so this ONE line # is the whole safety argument, and it is the line to memorise. return R_spec * np.sqrt(rho / rho_spec) print(range_at_reflectivity(120.0, 0.10, 0.03)) # 65.727... m
Three things to keep straight while you type it, because they carry most of the understanding. One: "I take the aperture diameter and halve it — getting Ar = πd2 instead of π(d/2)2 is a factor of four and it is the single most common slip in this problem." Two: "the inversion is the interesting half; Pr tells you whether a shot worked, Rmax tells you what you can promise a safety reviewer." Three: "in range_at_reflectivity every hardware term cancels, which is why the datasheet's stated reflectivity is the only footnote that matters — and why I would ask for the detection floor and the false-alarm rate it was measured at before I signed anything."
floor_W was the optimistic end of the 0.01–0.05 µW band; pass the pessimistic floor_W = 5.0e-8 and the same call returns 17.68 m. A single parameter you cannot pin down moves the answer by 37%, so the honest deliverable is not a number, it is a number with the floor it assumed and the measurement that would pin it down. That answer is worth more than a tighter estimate.Missing points are the polite version. The dangerous version of the same physics is the mirror return, and anyone who deploys robots around glass will meet it.
A flat, clean glass door is a good specular reflector. If your beam happens to hit it near normal incidence, you get a strong, valid-looking return at the true range to the glass — correct, and lucky. Every other beam reflects off at twice the angle of incidence, travels somewhere else in the room, scatters off something real, and part of that energy retraces the path home.
The LiDAR has no idea any of that happened. It measured a total flight time, so it reports a range equal to the total path length, placed along the direction the beam originally left in. Geometrically, that is exactly the mirror image of the real object reflected across the plane of the glass.
Work it. Put the sensor at the origin, a glass wall in the plane x = 3 m with its normal along x, and a stack of boxes at (1.0, 2.0) — behind and to the left of the robot. The mirror image of that stack across x = 3 is (2 × 3 − 1.0, 2.0) = (5.0, 2.0). The reported range is √(5.02 + 2.02) = √29 = 5.385 m, and the beam that produced it left at atan2(2.0, 5.0) = 21.8°. So the map grows a phantom stack of boxes two metres past the glass wall, in an aisle that does not exist.
Turn the range equation around and it predicts a second failure that surprises people who have only thought about weak returns.
A retroreflector — the corner-cube tape on a safety vest, a road sign, a warehouse fiducial — sends light back along the incoming direction instead of scattering it over a hemisphere. Its effective reflectivity in the Lambertian formula is not 0.9; it is in the tens or hundreds, because the geometry concentrates the return instead of spreading it. Put ρeff = 50 into the same expression at R = 2 m and you get 25 × 50 × 4.909e−4 / (π × 4) × 0.8 = 39 mW — roughly 60 times the power you get from white cardboard at the same range, and about two million times the detection floor.
That is not a helpfully strong signal; it is an overload. The receiver saturates, the amplifier takes time to recover, and the observable symptoms are specific and worth naming:
The summary worth carrying: the LiDAR's dynamic range problem is two-sided. Below a few hundred nanowatts you get nothing; above a few milliwatts you get too much, and both ends produce geometry errors rather than honest noise. Blooming and range walk are the two names to keep.
Back in the aisle. Three hypotheses were on the table: (a) the LiDAR's extrinsic is wrong so the beams are pointing above the pallet, (b) a driver or filter is dropping those azimuths, (c) the material is not returning enough light.
Somebody held a sheet of brown cardboard flat against the front of the pallet. The returns appeared instantly, at the right range, in the right azimuths.
That single action falsified (a) and (b) at once — the geometry was fine and the pipeline was fine, since nothing about beam direction or software changed — and confirmed (c). It cost thirty seconds, and it is the kind of debugging worth internalising: design an experiment that discriminates rather than one that merely gathers more data.
The generalisable rule: when several hypotheses predict the same symptom, look for the intervention whose predicted outcomes differ across them. Adding more logging usually does not; changing one physical variable usually does.
The second half of the staff engineer's question — what would you have put in the design? — is the one that separates teams that ship from teams that patch. Four answers land, and they are all cheap:
This chapter of the material is impossible to fake and impossible to memorise. There are only about nine sensors on a robot and anyone can list them. What separates engineers is whether they can answer the second question — what does this one lie about, and why does the physics force it?
A useful way to hold it in your head: every sensor is a measurement channel with an unstated assumption, and each failure mode is that assumption being violated.
| Sensor | The unstated assumption | What breaks it | What you see |
|---|---|---|---|
| Camera | The scene has texture, and enough photons arrive during the exposure | Blank wall, darkness, direct sun, motion blur | Tracker loses features; VIO scale drifts |
| Rolling-shutter camera | The whole image was captured at one instant | Any rotation faster than a few deg/s | Straight poles lean; calibration "drifts" with speed |
| Stereo | The same texture is visible in both images and can be matched | Textureless, repetitive, occluded, or specular surfaces | Holes, or worse, confident wrong depth |
| Active depth (IR) | The projected pattern is brighter than the ambient | Sunlight, another identical camera | Depth collapses outdoors at noon |
| LiDAR | The surface scatters light back diffusely | Glass, mirrors, water, shrink-wrap, black paint | Missing returns — a hole read as free space |
| Spinning LiDAR | The sweep is instantaneous | Robot moving or turning during the 100 ms sweep | Bent walls; double walls in the map at corners |
| IMU | Bias is known and constant | Temperature, time, vibration, switch-on repeatability | Position error growing as t3 |
| Wheel odometry | The wheels roll without slipping and have the modelled radius | Slip, load changes, tyre wear, a pebble | Heading error that no amount of averaging removes |
| GNSS / RTK | The signal came straight from the satellite | Buildings, foliage, tunnels, jamming | Confident position off by decimetres or metres |
| Radar | Whatever reflected is a single compact object | Multipath under vehicles, ground clutter | Ghost targets; near-zero angular resolution |
Read that column of assumptions again. Almost every real-world autonomy incident you have read about is one row of it.
"Failure mode" is a loose phrase, and tightening it is where the value is. There are exactly five things a sensor channel can do wrong, and each one has a different observable and a different mitigation.
| Kind | What the data does | What reveals it | What it costs you |
|---|---|---|---|
| Dropout | Measurements vanish entirely | Message-rate monitor; per-azimuth return-count histogram | Cheap, if your semantics say "unknown"; lethal if they say "free" |
| Bias | Every measurement shifted the same way | Innovation mean non-zero over a long window | Averaging does not help; error grows with integration time |
| Noise inflation | Same mean, larger spread than the model claims | NIS above its χ2 band with zero-mean innovations | Filter becomes over-confident; gates start rejecting good data |
| Latency / mis-stamping | Right value, wrong time | Innovation correlated with velocity or turn rate | Looks exactly like a calibration error — see Time & Sync |
| Confident-wrong | A plausible measurement of something that is not there | Cross-sensor disagreement; nothing internal to the sensor | The one that hurts people. Mirror returns, RTK integer slips, stereo mismatches on repetitive texture |
Notice the last row. The first four are all detectable from within a single sensor's own statistics. Confident-wrong is not. A mirror return has a normal intensity, a normal range, a smooth time history and a consistent geometry — there is nothing in the LiDAR's own output that flags it. The only instrument that can see it is a second sensor whose physics is different. Hold on to that; it is the hinge of Chapter 6.
Every system-design section in this lesson costs its arrows in real numbers, so we need a concrete robot. Here it is, and it stays fixed for the rest of the lesson: an indoor warehouse AMR, with an outdoor yard variant that adds GNSS and radar.
| Sensor | Rate | Payload per sample | Bandwidth | Where the time comes from |
|---|---|---|---|---|
| Front camera, 1920×1080 mono8 | 30 Hz | 2.07 MB | 62.2 MB/s | Hardware strobe, mid-exposure |
| Stereo pair, 2 × 1280×720 mono8 | 30 Hz | 1.84 MB | 55.3 MB/s | Shared trigger line, both sensors |
| LiDAR, 16 beams × 1800 azimuths | 10 Hz sweep | 288 000 pts/s × 16 B | 4.6 MB/s | PPS-disciplined, per-firing |
| IMU, 6-axis | 200 Hz | 12 B + stamp | 3.2 kB/s | Data-ready interrupt on GPIO |
| Wheel encoders, 2 × 4096 CPR | 50 Hz | 16 B | 0.8 kB/s | MCU tick, forwarded over CAN |
| GNSS/RTK (yard variant) | 5 Hz | ~100 B | 0.5 kB/s | The receiver's own GPS time |
| Radar, 77 GHz (yard variant) | 20 Hz | 256 dets × 20 B | 102 kB/s | Frame start, free-running |
Two things jump out of that table and both of them shape real systems. First, the cameras are 96% of the bandwidth and about 1% of the message count — which is why every design conversation about copies, zero-copy transports and DDS tuning is really a conversation about images. Second, the IMU is 20× the rate of anything else and produces almost no bytes, which is why it is the sensor everything else gets aligned to.
Five lessons on this site already teach the underlying theory properly, and you should read them if any derivation here feels unfamiliar. This lesson deliberately does not re-derive what they already do well.
| If you want… | Read |
|---|---|
| The intrinsic matrix K derived from similar triangles, homogeneous coordinates, and the full calibration procedure | Camera Models & Calibration |
| How LiDAR, IMU and encoders work as physical devices, plus point clouds, line extraction and ICP | Sensors: LiDAR, IMU & ICP |
| Strapdown mechanisation, the full IMU error model, Allan variance, and the grades of IMU | IMU & Inertial Navigation |
| Loose, tight and deep GNSS coupling, the error-state filter, and the lever arm | INS/GNSS Coupling |
| Clock domains, hardware triggering, and where a timestamp actually lands | Time, Clocks & Sensor Alignment |
| Covariance propagation, Mahalanobis gating, and robust costs | Uncertainty, Least Squares & Robust Costs |
What this lesson spends its words on instead is the fluency: the derivation you can produce on a blank page, the system design with real byte counts and real millisecond budgets, the failure taxonomy with the exact metric that separates each cause from its nearest decoy, and the tradeoff you can defend without hedging.
| Lens | The question that tests it | What a weak answer sounds like |
|---|---|---|
| CONCEPT | "Derive the depth uncertainty of a stereo pair. Now tell me the range at which it exceeds 10 cm." | "Depth error grows with distance" — true, useless, and not the exponent. |
| DESIGN | "Lay out the perception front end. Where does de-skew go, and what does it need?" | Boxes and arrows with no rates, no bytes, and no ordering constraint. |
| CODE | "Undistort a pixel. There is no closed form — go." | Reaching for cv2.undistortPoints and having nothing when it is taken away. |
| DEBUG | "The map has double walls only at corners. Why?" | Guessing calibration, then guessing the solver, then stopping. |
| FRONTIER | "Would you still buy a spinning LiDAR in 2026?" | Naming a vendor without naming a physical property that changed. |
Every numbered chapter that follows carries all five, on its own topic. Chapter 7 is the payoff — a bench where you drive a robot through a scene, switch hazards on, and watch which sensors survive. Chapter 8 is the cheat sheet you reread on the train.
Each numbered chapter takes one sensor family, derives the thing you must be able to derive, places it in a system with real numbers, implements it twice, breaks it in a named way, and points at what is replacing it.
One more framing before we start. The skill this lesson builds is not experience with any particular sensor. It is that, handed a sensor you have never touched, you would know which questions to ask of its datasheet. Those questions are the same every time: what is the measurement channel, what does it assume, what is the noise model, what is the latency, and what does it do when the assumption fails?
A camera is the cheapest, densest, most informative sensor you can bolt to a robot, and it is the one engineers most often think they understand. The forward model is genuinely simple. The three things that bite are all on the inverse side, and all three show up in the field.
Here is the forward pipeline in the form you should be able to write without hesitating, because where in the sequence you put the distortion is the part people get wrong.
Read the arrows carefully. Distortion acts on normalised coordinates, between the perspective divide and the multiplication by K. Getting that order wrong is the single most common silent error in hand-rolled camera code, and it is the reason someone's calibration "works" at the image centre and falls apart at the edges. If you apply D after K, then k1 has units of inverse pixels squared and every value in the literature is wrong by f2.
The Brown-Conrady model — the one behind cv2.calibrateCamera, ROS camera_info, Kalibr and almost everything else — splits D into a radial part and a tangential part:
Every symbol: x, y are the ideal pinhole normalised coordinates (dimensionless, equal to the tangent of the angle from the optical axis). r is radial distance from the principal point in those units. k1, k2, k3 are radial coefficients — the even-powered series comes from the fact that a rotationally symmetric lens can only produce a function of r2, and it must be odd in the displacement, so the displacement is r times an even series. p1, p2 are tangential (decentring) coefficients, non-zero only because the lens elements are not perfectly coaxial with the sensor.
Take a realistic 1280×720 wide-angle camera: fx = fy = 600 px, c = (640, 360), k1 = −0.28, k2 = 0.07, and ignore tangential terms for the hand pass. Project the normalised point (x, y) = (0.35, 0.20). That direction is about 19.3° off axis horizontally — roughly two thirds of the way to the edge of the frame.
The ideal pinhole pixel would have been (600 × 0.35 + 640, 600 × 0.20 + 360) = (850.0, 480.0). So the lens pulled this point 9.17 px left and 5.24 px up, a radial displacement of √(9.172 + 5.242) = 10.56 px. Ten pixels is not a rounding error — it is roughly thirty times the sub-pixel precision of a good corner detector, and it is why nobody triangulates on raw pixels.
Now the question that actually gets asked: you have the pixel (840.833, 474.762) and you want the ray it came from. Undistort it.
You cannot solve x(1 + k1r2 + k2r4) = xd for x in closed form, because in terms of r it is a degree-5 polynomial (degree 7 with k3) and there is no general radical solution above degree 4. Almost every library therefore runs a fixed-point iteration: guess that the undistorted radius is the distorted radius, evaluate the distortion factor there, divide it out, repeat.
Run it on our point. Start with (xd, yd) = (0.3347220, 0.1912697), so r2(0) = 0.1486229.
| Iteration | r2 used | radial factor | x estimate | error in pixels |
|---|---|---|---|---|
| 0 (start) | — | — | 0.3347220 | 9.167 |
| 1 | 0.1486229 | 0.9599318 | 0.3486935 | 0.784 |
| 2 | 0.1612891 | 0.9566601 | 0.3498860 | 0.068 |
| 3 | 0.1623942 | 0.9563757 | 0.3499900 | 0.00598 |
| 4 | 0.1624907 | 0.9563508 | 0.3499991 | 0.00052 |
| 5 | 0.1624992 | 0.9563486 | 0.3499999 | 0.000046 |
Each row shrinks the error by roughly a factor of 11.5. That ratio is not magic: near the fixed point the iteration contracts at a rate equal to the derivative of the map, which here is about |2k1r2| / (1 + k1r2) ≈ 0.0910/0.956 ≈ 0.095. One digit of pixel accuracy per iteration, near enough.
Here is the assumption buried in every equation above: that all the pixels in the frame saw the world at the same moment. For a global shutter sensor, which transfers every pixel's charge to a shielded storage node simultaneously, that is true. For a rolling shutter sensor, which resets and reads the array one row at a time, it is false, and the amount by which it is false is a number you can compute.
Let the readout of the whole frame take tr seconds over Nrows rows. Row v is exposed centred at
so the camera is not a projection from one pose but from a continuum of poses. The correct model is
where T(t) is the camera trajectory. That is an implicit equation — the row you land on determines the pose you should have used to compute the row — and solving it is why rolling-shutter-aware bundle adjustment is materially harder than the global-shutter kind.
An AMR turns a corner at 60°/s = 1.047198 rad/s. The camera is 1920×1080 at 30 fps with a rolling shutter whose readout spans the full frame period, tr = 33.33 ms. Focal length f = 600 px. A vertical pole sits in front of the robot. How tilted does it look?
One degree does not sound like much. Now ask what it does downstream. If that pole is a vertical line feature feeding a heading estimate, you have just injected 1.1° of heading error that appears only while turning at 60°/s. Slow down to 10°/s and it drops to 0.19°. Stop and it vanishes. An error that scales with angular rate and disappears at rest is indistinguishable from a gyro scale-factor error unless you know to look for it.
The rotation term has a property that makes it especially nasty: it does not depend on depth. Every pixel in the frame, near or far, shears by the same amount. Translation-induced rolling-shutter distortion does depend on depth — the image velocity of a point at depth Z under lateral speed v is f·v/Z. At v = 1.5 m/s and Z = 3 m with f = 600, that is 600 × 1.5 / 3 = 300 px/s, so over the same 33.3 ms readout the near object shears by 10 px while a point at 30 m shears by 1 px. Rotation shears everything uniformly; translation shears the foreground.
The faint outline is what a global shutter would have recorded. The solid shape is what the rolling shutter records while the robot rotates and translates. Push the yaw rate up, then reduce the readout time and watch the two converge.
What to look for. With lateral speed at zero, every object in the frame — the near pole and the far pole — leans by exactly the same angle. Now set yaw to zero and lateral speed to 3 m/s: the near pole shears hard and the far pole barely moves. That difference is the discriminator. If a colleague shows you a skewed image and asks whether it is rotation or translation, the answer is in whether the skew is depth-dependent.
The exercise that proves you own this: draw the camera path from photons to features, and put numbers on it. Here is the path, with the numbers from our AMR.
The one decision in that diagram worth defending. Notice that nothing undistorts the image. Undistorting a full 1080p frame means a bilinear resample of 2.07 M pixels driven by a remap table; stored as two float32 channels that table is 16.6 MB, and even in 16-bit fixed point it is 8.3 MB — far outside any L2 cache, so the operation is memory-bound and costs about 4–6 ms per frame on a Jetson-class CPU, plus 250–500 MB/s of extra memory traffic at 30 Hz. Undistorting the 400 points you actually use costs microseconds. The rule: undistort the sparse thing, never the dense thing, unless something downstream genuinely needs a rectified image (dense stereo does; a feature tracker does not).
The latency budget, end to end. Photons land over the 10 ms exposure, so the honest capture time is exposure start + 5 ms. The last row leaves the sensor 33.3 ms after the first. Debayer and any ISP work costs about 3 ms. The DMA hand-off and the pool copy cost 0.4 ms. Feature extraction costs 5 ms. Total from mid-exposure to a usable feature set: 5 + 33.3 + 3 + 0.4 + 5 ≈ 46 ms. At 1.5 m/s the robot has travelled 6.9 cm in that time, and at 3 m/s it has travelled 13.8 cm. That number is the reason the estimator must consume a timestamped measurement and never "the latest image" — see Time, Clocks & Sensor Alignment for what happens when it does not.
| Design question | The answer that lands | The number behind it |
|---|---|---|
| Global or rolling shutter? | Global if the platform rotates faster than a few deg/s and you are not going to model the shutter | tr < 1/(fω) = 1.59 ms is unachievable; skew is 20.9 px at 60°/s |
| Undistort image or points? | Points, unless dense stereo needs rectification | 4–6 ms and 16.6 MB of LUT versus tens of microseconds |
| Where does the timestamp go? | Mid-exposure, from a hardware strobe, not from driver-callback time | The exposure alone is 10 ms of ambiguity; auto-exposure makes it vary frame to frame |
| Auto-exposure on or off? | Off or bounded, on any robot doing estimation | A 10→30 ms exposure change moves the effective capture instant by 10 ms = 1.5 cm at 1.5 m/s |
The from-scratch version, which is what you write when OpenCV is not on the table:
python import numpy as np def distort(xn, yn, k1, k2, k3, p1, p2): # xn, yn are NORMALISED (already divided by Z). Never pass pixels here. r2 = xn * xn + yn * yn radial = 1.0 + k1 * r2 + k2 * r2 ** 2 + k3 * r2 ** 3 xd = xn * radial + 2 * p1 * xn * yn + p2 * (r2 + 2 * xn * xn) yd = yn * radial + p1 * (r2 + 2 * yn * yn) + 2 * p2 * xn * yn return xd, yd def undistort_points(u, v, K, dist, iters=15): # pixels -> normalised distorted -> normalised ideal, by fixed point k1, k2, p1, p2, k3 = dist xd = (u - K[0, 2]) / K[0, 0] yd = (v - K[1, 2]) / K[1, 1] x, y = xd.copy(), yd.copy() # guess: the distorted point itself for _ in range(iters): r2 = x * x + y * y radial = 1.0 + k1 * r2 + k2 * r2 ** 2 + k3 * r2 ** 3 dx = 2 * p1 * x * y + p2 * (r2 + 2 * x * x) dy = p1 * (r2 + 2 * y * y) + 2 * p2 * x * y x = (xd - dx) / radial # subtract tangential, divide out radial y = (yd - dy) / radial return x, y
Three things to keep straight while you type it, because they carry most of the understanding. One: "distortion lives in normalised coordinates, so I strip K first." Two: "there is no closed form, so this is a fixed point; I initialise at the distorted point because the map is a mild contraction." Three: "fifteen iterations is overkill — five gets me to a twentieth of a pixel — but it is branch-free and vectorises, which matters more than the flop count."
And the production form, which you should also know exists and what it costs:
python import cv2 # sparse: 400 points, microseconds. P=None means "return normalised", not pixels. xy = cv2.undistortPoints(pts.reshape(-1, 1, 2), K, dist).reshape(-1, 2) # dense: build the map ONCE at startup, then remap each frame (4-6 ms at 1080p) map1, map2 = cv2.initUndistortRectifyMap(K, dist, None, Knew, (w, h), cv2.CV_16SC2) rect = cv2.remap(img, map1, map2, cv2.INTER_LINEAR)
Note CV_16SC2 in that call. That is the fixed-point map format, and choosing it over CV_32FC2 halves the map from 16.6 MB to 8.3 MB. On a memory-bound operation that is close to a 2× speed-up for free, and knowing it is the kind of detail that reads as "has actually profiled this".
Symptom. calibrateCamera returns an RMS reprojection error of 0.19 px. Everyone is pleased. But a straight door frame near the image edge is visibly curved in the undistorted image, and a stereo rig built on this calibration produces depth that is 4% wrong in the outer third of the frame and correct in the middle.
Root cause. The checkerboard never went near the corners. The optimiser fitted k1, k2 and k3 to data spanning r ∈ [0, 0.42] and is now being evaluated at r = 0.78. A degree-6 polynomial extrapolated 85% beyond its data does whatever it likes, and the reported RMS — which is computed only on the observed corners — is blind to all of it.
The metric that reveals it. Not the RMS. Two things:
The decoy to rule out. The nearest wrong answer is "the distortion model is the wrong order — add k3, or drop to k1 only". The discriminator: refit with and without k3 and compare two numbers. If RMS barely moves (0.19 → 0.18 px) but the predicted corner displacement changes by several pixels, the higher-order term is unconstrained by the data, and the fix is more board coverage, not more parameters. If instead RMS drops materially, the model really was too simple. One refit, two numbers, unambiguous answer.
Symptom. Your VIO's online extrinsic estimate for the camera-to-IMU rotation is stable at rest and during straight driving, but shifts by up to a degree during turns, and always in the direction of the turn. Someone proposes that the bracket is flexing.
Root cause. Rolling shutter. The features are spread over the image, so on average they were captured at tr/2 after the frame timestamp. The estimator attributes that half-readout time offset to whatever free parameter can absorb it, and the camera-IMU rotation is the most flexible one available.
The metric that reveals it. Bucket the log by mean angular rate and plot estimated extrinsic yaw offset against |ω|. A mechanical flex would correlate with linear acceleration or with temperature, and would show hysteresis. A shutter artefact gives a straight line through the origin whose slope is tr/2. With tr = 33.3 ms the predicted slope is 16.7 ms, so at ω = 60°/s you expect an apparent offset of 60 × 0.0167 = 1.0° — and that matches the observation. Predicting the slope from the datasheet before looking at the plot is what turns a guess into a diagnosis.
Global shutter stopped being expensive. The classic argument for tolerating rolling shutter was cost and pixel size. Sony's Pregius S generation (2019 onward) put back-illuminated global-shutter pixels at 2.5 µm, which removed most of the resolution and sensitivity penalty. If you are specifying a new robot camera in 2026 and you have rotation in your motion profile, "we chose rolling shutter to save money" is a position you now have to defend rather than assume.
The twelve-parameter model is not sacred. Schöps, Larsson, Pollefeys and Sattler, "Why Having 10,000 Parameters in Your Camera Model Is Better Than Twelve" (CVPR 2020), showed that a dense per-pixel or per-region camera model, fitted with a proper calibration procedure, reduces reprojection error well below what Brown-Conrady can reach — because real lenses are not rotationally symmetric polynomials, and the residual structure a global model leaves behind is systematic, not noise. The practical takeaway: if your residuals show spatial structure rather than white scatter, the model is the limit, not the data.
Event cameras remove the shutter entirely. Each pixel independently reports log-intensity changes with microsecond timestamps, so there is no frame, no exposure and no readout order. Gallego et al., "Event-based Vision: A Survey" (IEEE TPAMI, 2022) is the reference; the properties that matter for robotics are ~1 µs temporal resolution, over 120 dB of dynamic range, and no motion blur. The cost is that every algorithm you know assumes frames, so you are rebuilding the front end.
Rolling shutter as a first-class model. Schubert, Demmel, Usenko, Stückler and Cremers, "Direct Sparse Odometry with Rolling Shutter" (ECCV 2018), showed that treating the shutter properly — a continuous-time trajectory rather than one pose per frame — recovers most of the accuracy lost to a rolling sensor. This is the same continuous-time idea that Chapter 3 uses to de-skew a LiDAR sweep, and noticing that the two problems are identical is a genuinely strong thing to say out loud.
One camera throws depth away. Two cameras get it back — but not uniformly, and not everywhere. A stereo rig is precise up close and progressively useless with range, and the exponent in "progressively" is the number that decides designs.
Two cameras with parallel optical axes, identical focal length f (in pixels), separated by a baseline B (in metres). A world point at depth Z projects into the left image at uL and the right at uR. The disparity is d = uL − uR, measured in pixels.
Similar triangles give the relation in one line. The point sits at horizontal offset X from the left camera and X − B from the right, so uL = fX/Z + c and uR = f(X − B)/Z + c, and subtracting kills X and c entirely:
Notice what disappeared: the point's lateral position, the principal point, and any common shift of both cameras. Disparity depends on depth and nothing else. That is the whole reason stereo works, and it is also the reason a stereo rig is exquisitely sensitive to anything that breaks the "identical, parallel, rectified" assumption — a relative rotation between the two cameras does not cancel out.
Now differentiate. This is the step most people skip, and it is the whole result. Write Z as a power of d first, because then it is one line of calculus with no quotient rule:
That is still expressed in disparity, and the useful form is in range. So substitute d = fB/Z and write out every intermediate expression — this is the step that usually gets hand-waved:
Squaring fB/Z put (fB)2 in the denominator and Z2 in the numerator; the single fB out front then cancels one of the two factors of fB below. That one cancellation is the entire trick. Take the magnitude and multiply by the matching noise:
Depth uncertainty is quadratic in range. Doubling the distance quadruples the error. Nothing about better matching changes the exponent; it only changes σd.
Keep one caveat in your pocket, because it returns in Worked example 4: this is a linearisation, and it is only trustworthy while the disparity error is small next to the disparity itself. At Z = 10 m on the rig below, d = 8.4 px and the derivative claims 102/84 = 1.190 m of depth per pixel of disparity. The exact difference across a whole 0.244 px of error is 84/8.4 − 84/8.644 = 0.2827 m, which is 1.157 m per pixel — 2.8% lower, because Z(d) is convex and the tangent line always sits above the true curve. Under about half a pixel the two agree to a few percent; at several pixels they diverge badly and you should quote the exact form and say so.
Take a realistic robot stereo head: f = 700 px, B = 0.12 m, and a good sub-pixel matcher achieving σd = 0.2 px. Then fB = 84.0 px·m, and σZ = Z2 × 0.2 / 84.0 = Z2 / 420.
| Z | disparity d = 84/Z | σZ = Z2/420 | relative error | verdict |
|---|---|---|---|---|
| 1 m | 84.0 px | 12/420 = 2.4 mm | 0.24% | Better than most LiDAR |
| 2 m | 42.0 px | 4/420 = 9.5 mm | 0.48% | Fine for manipulation |
| 5 m | 16.8 px | 25/420 = 59.5 mm | 1.19% | Fine for obstacle avoidance |
| 10 m | 8.4 px | 100/420 = 238 mm | 2.38% | Marginal — a pallet is 1.2 m |
| 20 m | 4.2 px | 400/420 = 952 mm | 4.76% | Do not plan on this |
Read the disparity column. At 20 m the entire signal is 4.2 pixels wide. A half-pixel matching bias — entirely achievable with a rectification error, a slight defocus, or a slanted surface — is a 12% depth error. Stereo does not fail at long range because the algorithm gets worse; it fails because the measurement itself shrinks to nothing.
The inversion is the useful engineering move. If the requirement is "depth to 10 cm at 10 m", then 0.10 = 100 × σd/(fB), so you need fB ≥ 1000σd = 200 px·m. At f = 700 that is B ≥ 0.286 m. So: "To hit ten centimetres at ten metres with this lens I need about a thirty-centimetre baseline." That sentence, produced in fifteen seconds, is worth more than any amount of qualitative discussion.
A longer baseline is not free. Three things get worse and you should name all three.
Minimum range. The matcher searches a finite disparity range 0 … dmax. Anything closer than Zmin = fB/dmax is simply outside the search and returns nothing. With dmax = 128:
| Baseline B | σZ at 10 m | Zmin (dmax = 128) | Occlusion band, 1 m object vs. distant background |
|---|---|---|---|
| 0.05 m | 0.571 m | 0.27 m | 35 px |
| 0.12 m | 0.238 m | 0.66 m | 84 px |
| 0.30 m | 0.095 m | 1.64 m | 210 px |
| 1.00 m | 0.029 m | 5.47 m | 700 px |
The 0.30 m rig meets the 10 cm requirement and cannot see anything closer than 1.64 m. On a warehouse AMR that has to detect a pallet jack tine at 0.8 m, that trade is fatal — which is exactly why real robots carry two depth modalities rather than one perfect one.
Occlusion. Anything visible to one camera and hidden from the other has no match, and the geometry tells you exactly how wide that dead band is. Both surfaces shift left in the right image by their own disparity; the foreground shifts further because its disparity is larger, so it slides over a strip of background. The width of that strip in the reference image is therefore the disparity difference across the edge, dfg − dbg — not the foreground disparity outright. For a 1 m object against a distant wall with B = 0.12 and f = 700 that is 84.0 − 0 = 84 px, which is where the table's last column comes from; for the pallet-and-aisle case used later in this chapter (2.0 m foreground, 8 m background) it is 42.0 − 10.5 = 31.5 px. Only when the background is effectively at infinity does the band equal the foreground disparity. "Double the baseline, double the band" still holds, because both disparities scale with B and so does their difference.
| Foreground | Background | dfg | dbg | Band = dfg − dbg | Background surface hidden |
|---|---|---|---|---|---|
| 1.0 m | far wall (∞) | 84.0 px | 0.0 px | 84.0 px | unbounded |
| 1.0 m | 3.0 m | 84.0 px | 28.0 px | 56.0 px | 56.0 × 3/700 = 0.24 m |
| 2.0 m (pallet) | 8.0 m (aisle end) | 42.0 px | 10.5 px | 31.5 px | 31.5 × 8/700 = 0.36 m |
| 2.0 m | 2.5 m | 42.0 px | 33.6 px | 8.4 px | 8.4 × 2.5/700 = 0.03 m |
The last column is the one that matters operationally: multiply the band in pixels by Zbg/f and you get the metres of background surface that no stereo rig with this baseline can ever see. A 0.36 m strip of aisle floor hidden behind every pallet leg is a permanent hole in the costmap, not a tuning problem, and no amount of algorithm work removes it — only a different viewpoint does.
One more fact worth volunteering, because it is a free debugging tell: with the left camera as reference the occlusion bands appear on the left side of foreground objects, since that is the background the rightward-displaced camera loses. If your invalid-pixel mask hugs the right edges instead, you have swapped the two images or the sign of your disparity convention, and every depth in the frame is wrong in a way that still looks plausible.
Overlap and rectification error. A wider baseline means a smaller shared field of view and a longer, more flexible mechanical structure between the two sensors. A 0.02° relative yaw between the cameras shifts disparity by f × tan(0.02°) = 700 × 3.49e−4 = 0.24 px uniformly — which at 10 m is a 28.3 cm depth bias affecting every pixel identically. That is not noise, it is a scale error in your entire depth map, and it does not average away.
"Stereo needs texture" is the answer everybody gives. It is true and it is one quarter of the answer. There are four failure geometries, they produce different artefacts, and they need different fixes.
| Failure | What the cost curve looks like | The metric that names it | The fix |
|---|---|---|---|
| Textureless (painted wall, floor) | Flat — every disparity costs about the same, and the absolute cost is high | Low curvature at the minimum; high minimum cost | Project a pattern, or accept a hole |
| Repetitive (rack of totes, brick, fence) | Several equally deep, equally sharp minima spaced by the pattern period | Uniqueness ratio (second-best / best) ≈ 1.0, with low minimum cost | Wider window, left-right check, semi-global smoothness |
| Occluded (behind an edge) | No good minimum in either direction; asymmetric between L→R and R→L | Left-right consistency: |dLR − dRL| > 1 px | Mark invalid; never interpolate across it |
| Specular / transparent | A sharp, confident minimum at the wrong disparity | Nothing internal — the highlight moves with viewpoint, so the match is genuinely good | Cross-modal check, or polarisation |
The last row is the confident-wrong class from Chapter 0, and it is why the two obvious quality metrics are not enough. A specular highlight on a metal shelf appears at a different physical location in each image, so the matcher finds a strong, unique, left-right-consistent match at a disparity that corresponds to no surface at all. Every internal check passes.
An active stereo or structured-light camera projects a near-infrared pattern to manufacture texture. The design question is whether the projected pattern is brighter than the ambient background within the receiver's optical passband. Work the photon budget.
That is a 5000-fold swing in the thing that matters, and it is why the identical camera is superb in a warehouse and returns nothing in a yard at noon. The physics also tells you the only three levers: more projector power (limited by eye safety and heat), a narrower filter (limited by the emitter's linewidth and its thermal drift), or a shorter exposure with a pulsed projector so the ambient integrates for less time. Naming those three, in that order, is a complete answer.
The teal curve is σZ = Z2σd/(fB). The warm line is a constant-σ LiDAR for comparison. The shaded strip on the left is the blind zone below Zmin = fB/dmax. Drag the baseline and watch the two ends fight each other.
What to look for. Set the requirement in your head — say 10 cm at 10 m — and drag the baseline until the teal curve crosses the 10 m gridline below 0.10 m. Now look at where the grey blind zone has moved to. That is the entire stereo design conversation, and the bench makes it impossible to pretend the two ends are independent.
Anyone who has shipped a stereo rig learns to worry about rectification. Here is the arithmetic that makes the worry concrete. Suppose the two cameras have a relative yaw error of δ = 0.02° that your calibration did not capture — a bracket that warmed up, a bolt that settled. That rotation shifts every match horizontally by f·tan(δ) = 700 × 3.4907e−4 = 0.244 px, uniformly across the image.
| Rel. yaw error | Disparity bias | Depth error at 2 m | Depth error at 10 m |
|---|---|---|---|
| 0.005° | 0.061 px | 0.3 cm | 7.2 cm |
| 0.010° | 0.122 px | 0.6 cm | 14.3 cm |
| 0.020° | 0.244 px | 1.2 cm | 28.3 cm |
| 0.050° | 0.611 px | 2.9 cm | 67.8 cm |
Which model the table uses, and why the two disagree. Every entry above is the exact form fB/d − fB/(d + δd): at 10 m that is 84/8.4 − 84/8.6443 = 10.000 − 9.717 = 28.3 cm. The linearised form derived earlier in this chapter, Z2·δd/(fB) = 100 × 0.2443/84, gives 29.1 cm. Both are defensible and the 3% gap between them is itself the teaching point: it is the signature of a perturbation large enough that the tangent approximation is starting to break down. Quote the linearised number when you are sizing a design (it is conservative — it overestimates, because Z(d) is convex), and the exact number when you are reconciling against a measurement. Saying which one you used, unprompted, is a small thing that reads as rigour.
Two features of that table matter. First, the error is signed and systematic — every pixel in the frame reports depth in the same wrong direction, so it looks like a clean, plausible depth map that is uniformly wrong. Second, it is invisible up close and enormous far away, which means a bench test at 1.5 m passes and the field failure appears at 10 m. Anyone who has debugged this recognises the shape immediately: a robot that grabs boxes perfectly and consistently misjudges the aisle end.
The design consequence is mechanical, not algorithmic: the two imagers must share a rigid, thermally matched mount, and the rig needs an online check. The cheapest one is the vertical disparity residual — after rectification, matched points should have identical row coordinates, so the mean vertical offset across the frame is a free, continuously available measure of rectification health that needs no target.
"Depth camera" is four different physical devices, and picking one is a standard design prompt. The honest comparison:
| Modality | Useful range | Error law | Dies on | Best at |
|---|---|---|---|---|
| Passive stereo | 0.7–15 m | σ ∝ Z2 | Texture-free, repetitive, specular surfaces | Outdoors, sunlight, long baselines, no power budget for illumination |
| Active stereo (IR pattern) | 0.3–6 m | σ ∝ Z2 | Sunlight; a second identical unit | Indoor blank walls — it manufactures the texture stereo needs |
| Indirect ToF | 0.3–8 m | σ roughly constant, then wraps | Sunlight, multipath in corners, phase wrap | Dense depth at short range, no baseline, no occlusion band |
| LiDAR | 0.5–120 m | σ roughly constant (2–3 cm) | Glass, mirrors, black paint, fog | Long range and the only one whose error does not grow |
The row that decides most arguments is the error law. Stereo is better than LiDAR below the crossover — with our f = 700, B = 0.12 rig and a 3 cm LiDAR, stereo wins out to √(0.03 × 84 / 0.2) = 3.55 m — and progressively worse beyond it. That single crossover number, computed live, is a much stronger answer than any list of pros and cons.
"Would you run stereo on the CPU?" is really a question about the size of the cost volume. Compute it.
28 GB/s of memory traffic is roughly the entire DRAM bandwidth of a Jetson Orin NX. That single number answers the question: dense stereo is a memory-bandwidth problem, not a compute problem, and it is why it lives on an FPGA, an ASIC, or a GPU with the cost volume tiled to stay in shared memory. If you must run it on a CPU, the levers in order of effectiveness are: reduce D (which raises Zmin), downsample (which raises σd in metres per pixel but lowers W×H quadratically), or drop from 8 aggregation paths to 4 (which costs you accuracy on diagonal structures).
The output-size trap. Emitting a full 1280×720 point cloud at 30 Hz is 0.92 M points × 12 bytes = 11.1 MB per frame, 331 MB/s. Compare that with what you started with: two mono8 images, 2 × 0.92 MB = 1.84 MB per stereo frame, 55.3 MB/s. The cloud is exactly 331/55.3 = 6.0× the raw stereo input. Every experienced perception engineer has watched a team saturate a link by publishing a dense cloud that nothing downstream needed at full density.
The design answer is to publish the disparity image and let each consumer reproject only the region it cares about. Price it honestly, because this is where the arithmetic usually goes wrong: 1280 × 720 = 921,600 pixels, and at uint16 that is 2 bytes each, so 1.84 MB per frame and 55.3 MB/s — the same 1.84 MB you were already carrying for the stereo pair, one channel at two bytes instead of two channels at one. So the disparity image is not a 6× saving over the cloud in the sense of being cheap; it is a 6× saving in the sense that publishing the cloud costs six times what publishing the disparity image costs, and the disparity image is free — it is bandwidth your link already carries.
If you genuinely need it cheaper, the only lever is bits per pixel, and every bit you drop is bought from either disparity range or sub-pixel resolution. The bit budget is fixed by dmax: covering 0…128 px of integer disparity needs 8 bits, and OpenCV's 4 fractional bits need 4 more, so 12 bits — which is why the payload is uint16 and not uint8. Squeeze it into one byte and you must give something up:
| Encoding | Frame / stream | Disparity range | Sub-pixel step | σZ at 10 m (matcher σd = 0.2 px) |
|---|---|---|---|---|
| uint16, 4 frac bits | 1.84 MB / 55.3 MB/s | 0–4095 px | 1/16 px | 23.8 cm (quantisation negligible) |
| uint8, 4 frac bits | 0.92 MB / 27.6 MB/s | 0–15.9 px → Zmin = 5.3 m | 1/16 px | 23.8 cm, but nothing closer than 5.3 m exists |
| uint8, 1 frac bit | 0.92 MB / 27.6 MB/s | 0–127.5 px | 1/2 px | 29.3 cm (+23%) |
| uint8, integer only | 0.92 MB / 27.6 MB/s | 0–255 px | 1 px | 41.8 cm (+76%) |
Those last two columns come from the quantiser: a uniform step q contributes σq = q/√12 pixels, which adds in quadrature with the matcher. At q = 0.5 px that is 0.144 px, so σd = √(0.202 + 0.1442) = 0.247 px and σZ(10 m) = 100 × 0.247/84 = 0.293 m. At q = 1 px it is 0.289 px, σd = 0.351 px, and σZ(10 m) = 0.418 m. Halving the bandwidth costs you between a quarter and three quarters of your far-field accuracy — which is a real trade, and a defensible one on a robot that only uses stereo below 5 m, but it is a trade you should make with those two numbers in hand rather than by picking a type that felt small. The genuinely free win is that a disparity image is spatially smooth in a way the raw texture images are not, so lossless compression pays off far better on it; measure the ratio on your own scenes before you budget for it.
| Budget | Number | Why it is the number |
|---|---|---|
| Latency, trigger to disparity | ~28 ms | exposure 8 + readout 5 (global shutter) + rectify 2 + SGM 10 + post 3 |
| Memory bandwidth | 28 GB/s | 8 SGM paths × 118 MB × 30 Hz — the binding constraint |
| Output bandwidth | 55.3 MB/s as disparity, 331 MB/s as a cloud | the cloud costs 6.0× the disparity image, which is the bandwidth you already carry — publish the image, reproject at the consumer |
| Trigger skew tolerance | < 100 µs | at 60°/s and f = 700, 100 µs is 0.073 px of disparity bias — 1% of depth at 10 m |
The exercise that makes it stick: implement disparity for one row, with sub-pixel refinement.
python import numpy as np def disparity_row(left, right, y, half=3, dmax=64): # SAD over a (2*half+1) square window, searching RIGHTWARD in the right image H, W = left.shape out = np.full(W, np.nan) for x in range(half + dmax, W - half): ref = left[y-half:y+half+1, x-half:x+half+1].astype(np.float64) cost = np.empty(dmax + 1) for d in range(dmax + 1): tgt = right[y-half:y+half+1, x-d-half:x-d+half+1].astype(np.float64) cost[d] = np.abs(ref - tgt).sum() d0 = int(cost.argmin()) if d0 == 0 or d0 == dmax: # minimum on the edge: no parabola, no trust continue # --- rejection BEFORE refinement, not after --- second = np.min(cost[np.abs(np.arange(dmax+1) - d0) > 2]) if second < 1.15 * cost[d0]: # uniqueness ratio: repetitive texture continue curv = cost[d0-1] - 2*cost[d0] + cost[d0+1] if curv <= 1e-6: # flat valley: textureless continue # --- sub-pixel: fit a parabola through the three costs --- out[x] = d0 - 0.5 * (cost[d0+1] - cost[d0-1]) / curv return out
Do the sub-pixel step by hand once so you can say it without the code in front of you. Suppose the SAD costs at d = 23, 24, 25 are 180, 120, 150. The curvature is 180 − 2(120) + 150 = 90. The offset is −0.5 × (150 − 180) / 90 = −0.5 × (−30)/90 = +0.1667. So d* = 24.1667. With f = 700, B = 0.12 that moves the depth from 84/24 = 3.500 m to 84/24.1667 = 3.4759 m — a 24 mm correction from a single sixth of a pixel. Sub-pixel is not polish; at 3.5 m one pixel is 14 cm.
Two things to say while writing it. First: "I reject before I refine — there is no point fitting a parabola to a valley that is either flat or one of several." Second: "the two rejection tests measure different things: the ratio test catches repetitive texture, the curvature test catches no texture, and a real pipeline needs both plus a left-right consistency pass."
The production form, and the parameters that matter:
python import cv2 sgbm = cv2.StereoSGBM_create( minDisparity=0, numDisparities=128, # sets Z_min = f*B/128 blockSize=5, P1=8 * 5 * 5, P2=32 * 5 * 5, # smoothness: P1 for +-1, P2 for jumps uniquenessRatio=10, # percent — the repetitive-texture guard disp12MaxDiff=1, # left-right consistency, in pixels speckleWindowSize=100, speckleRange=2) disp = sgbm.compute(rectL, rectR).astype(np.float32) / 16.0 # output is 4 fractional bits Z = f * B / np.where(disp > 0, disp, np.nan)
That / 16.0 is the single most common bug in stereo code written by people who did not read the docs: OpenCV returns disparity as a fixed-point int16 with four fractional bits, so forgetting it makes every depth sixteen times too small and everything looks like it is in your face.
Disparity is not a point cloud, and the step between them hides a failure that puts phantom obstacles directly in the robot's path.
The reprojection is a single matrix. For a rectified pair with the reference camera's intrinsics (f, cx, cy) and baseline B:
with Q written out row by row, so that nothing is hidden inside the word "matrix":
Do this once by hand and you will never again have to trust that the library agrees with the geometry. Take the rig we have used throughout — f = 700 px, B = 0.12 m, cx = 640, cy = 360 — and one matched pixel: u = 800, v = 300, d = 42 px. The input vector is [u v d 1]T = [800 300 42 1]T. Multiply, one row at a time, every product visible:
| Row of Q | Dot product with [800, 300, 42, 1] | Result |
|---|---|---|
| [1, 0, 0, −cx] | 1·800 + 0·300 + 0·42 + (−640)·1 = 800 − 640 | Xh = 160 |
| [0, 1, 0, −cy] | 0·800 + 1·300 + 0·42 + (−360)·1 = 300 − 360 | Yh = −60 |
| [0, 0, 0, f] | 0·800 + 0·300 + 0·42 + 700·1 | Zh = 700 |
| [0, 0, 1/B, 0] | 0·800 + 0·300 + (1/0.12)·42 + 0·1 = 8.3333 × 42 | W = 350 |
Now the perspective divide, which is the step that turns four homogeneous numbers into three metres:
Check the depth against the relation we derived at the top of the chapter: Z = fB/d = 84.0/42.0 = 2.000 m. They agree exactly, which is the point — the matrix is not new physics, it is the same similar-triangles relation with the lateral coordinates carried along. Reading the rows back tells you why the last row is where the depth actually lives: W = d/B, so Z = Zh/W = f/(d/B) = fB/d, and the other two follow with no extra work:
Sanity-check the first one on our pixel: (800 − 640) × 0.12/42 = 19.2/42 = 0.4571 m. Same answer, no matrix required — and now the error claim is algebra rather than assertion. All three coordinates carry the same factor 1/d, so a fractional error in disparity produces the same fractional error in X, Y and Z: σX/|X| = σY/|Y| = σZ/Z = σd/d. The lateral position of a far point is exactly as uncertain, in relative terms, as its range, so the absolute lateral uncertainty grows with how far off axis the point is. At 10 m with σd = 0.2 px that fraction is 0.2/8.4 = 2.38%, so a point 10 m out and 5 m off to the side carries σX = 0.0238 × 5 m = 11.9 cm of lateral uncertainty alongside its 0.0238 × 10 m = 23.8 cm of range uncertainty. A point on the optical axis, by contrast, has X = 0 and therefore no lateral uncertainty at all from this source — the error ellipsoid is a needle pointing back at the camera, and it fans out as you move off axis.
The degraded twin. Now run the identical pixel through the identical matrix with the 0.02° relative yaw from Worked example 4, which adds a uniform δd = +0.2443 px to every match. Only the third input changes: d = 42.0000 → 42.2443 px. Three of the four rows do not involve d at all, so Xh, Yh and Zh are untouched at 160, −60 and 700. The fourth row moves:
and the whole point moves with it, because W is the common divisor:
| Quantity | Clean (d = 42.0000) | Biased (d = 42.2443) | Change |
|---|---|---|---|
| W | 350.000 | 352.036 | +0.582% |
| X = Xh/W | 0.45714 m | 0.45450 m | −2.64 mm |
| Y = Yh/W | −0.17143 m | −0.17044 m | +0.99 mm (toward the axis) |
| Z = Zh/W | 2.00000 m | 1.98843 m | −11.57 mm |
That −11.6 mm is the same number the yaw table gave for 2 m (1.2 cm), reached by a completely different route — a good habit: when two derivations in the same chapter can be made to agree, make them agree out loud.
The structure of that change is worth more than the change itself. Every coordinate was divided by the same W, so the biased point is the clean point scaled by 42.0/42.2443 = 0.99422 — a pure radial contraction toward the camera centre, 0.58% at this range. That is why a fronto-parallel wall survives the bias looking perfect: every pixel on it shares one disparity, so every point contracts by one factor, and a plane maps to a plane. Fit a plane, get a beautiful fit, and conclude the depth is healthy.
But the contraction factor is d/(d + δd), which depends on range: 0.58% at 2 m and δd/(d + δd) = 0.2443/8.6443 = 2.83% at 10 m, nearly five times larger. So a surface that spans range — the floor, an aisle receding from the robot — is not contracted, it is bent. That gives you the second free online check to sit beside the vertical-disparity residual: fit a plane to the visible floor and plot the residual against range. Healthy rectification gives white scatter; a yaw bias gives a residual that grows monotonically with range and keeps one sign, with a 1.2 cm to 28.3 cm span across 2–10 m that no plane fit can absorb.
The flying pixel. A matching window that straddles a depth discontinuity contains texture from both surfaces, so the cost minimum lands somewhere between the two true disparities. Work a concrete case: a pallet edge at 2.0 m gives d = 84/2.0 = 42.0 px, and the aisle wall 8 m behind it gives d = 84/8.0 = 10.5 px. A window split evenly across the edge produces a compromise near d = 26.25 px, which reprojects to Z = 84/26.25 = 3.20 m.
That is a 3-D point suspended in mid-air — 1.2 m behind the pallet and 4.8 m in front of the wall — and it is in free space the robot might want to drive through. Every depth discontinuity in the scene produces a string of them, which is why raw stereo clouds look like they have been sprayed with confetti along every edge.
The mitigation is a disparity-gradient filter: reject any pixel whose matching window spans a disparity range larger than a threshold, since a physically real surface cannot be at two depths inside one window. The check costs a min-max over the window and it is the difference between a costmap you can plan on and one you cannot. It is also a good thing to volunteer, because it demonstrates you have looked at a real cloud rather than a benchmark's ground truth.
But what threshold? "Two pixels" is the answer people give, and it is wrong at one range and right at another, because the legitimate disparity gradient of a real surface depends on how far away it is. Derive it. Take a planar surface tilted by α from fronto-parallel. One pixel of image step corresponds to Z/f metres of lateral motion on the surface, so the depth changes by ΔZ = (Z/f)·tanα per pixel. Differentiating d = fB/Z gives |dd/dZ| = d/Z, so the disparity changes by
The Z cancels, and what is left says the legitimate gradient scales with the disparity itself. At 2 m (d = 42) a steeply slanted floor at α = 80° gives (42/700) × 5.671 = 0.34 px per pixel, so 1.7 px across a 5-px window. At 1 m (d = 84) the same physical slant gives 0.68 px per pixel — 3.4 px across the same window. A fixed 2 px threshold therefore keeps the slanted floor at 2 m and throws away the identical floor at 1 m. Rearranging the same equation removes the range dependence entirely:
so threshold on the implied slant angle, not on raw disparity gradient. Check it against the pallet edge: the window spans 31.5 px over 5 px of image, a gradient of 6.3 px/px, giving tanα = 6.3 × 700/42 = 105 and α = 89.45° — a surface almost exactly edge-on to the camera, which is precisely what a depth discontinuity pretends to be. Reject above ~80° and the flying pixels go while the real floor stays.
Both halves of the step — the reprojection and the filter — in numpy, with no library doing the thinking:
python import numpy as np from numpy.lib.stride_tricks import sliding_window_view def reproject(disp, f, B, cx, cy, min_disp=1.0): # disp: float32 HxW in PIXELS (already divided by 16 if it came from OpenCV) H, W = disp.shape u, v = np.meshgrid(np.arange(W, dtype=np.float64), np.arange(H, dtype=np.float64)) valid = disp > min_disp # d -> 0 sends Z -> inf; guard BEFORE dividing d = np.where(valid, disp, np.nan) Z = f * B / d # row 3 over row 4 of Q X = (u - cx) * B / d # row 1 over row 4 Y = (v - cy) * B / d # row 2 over row 4 return np.stack([X, Y, Z], axis=-1), valid def slant_mask(disp, f, w=5, max_slant_deg=80.0): # A real surface cannot be at two depths inside one window. Normalising the # gradient by d/f turns the threshold into a range-independent slant angle. win = sliding_window_view(disp, (w, w)) # (H-w+1, W-w+1, w, w) lo = np.nanmin(win, axis=(-2, -1)) hi = np.nanmax(win, axis=(-2, -1)) k = w // 2 ctr = disp[k:k + lo.shape[0], k:k + lo.shape[1]] grad = (hi - lo) / w # px of disparity per px of image slant = np.degrees(np.arctan(grad * f / np.maximum(ctr, 1e-6))) keep = np.zeros_like(disp, dtype=bool) keep[k:k + lo.shape[0], k:k + lo.shape[1]] = slant < max_slant_deg return keep
Then the library form, and the trap inside it:
python Q = np.float64([[1, 0, 0, -cx], [0, 1, 0, -cy], [0, 0, 0, f], [0, 0, 1/B, 0]]) pts = cv2.reprojectImageTo3D(disp, Q, handleMissingValues=True) # TRAP: missing values are not NaN — OpenCV writes Z = 10000.0 (ten kilometres). pts = pts[np.isfinite(pts).all(-1) & (pts[..., 2] < 100.0)]
Three things to keep straight while writing that. First: "I guard the divide before I do it, because d = 0 is not an error condition in the data, it is the normal encoding of 'no match', and it produces infinities that survive all the way into a voxel grid." Second: "handleMissingValues writes 10000.0, not NaN, so an unfiltered cloud contains a wall of points ten kilometres away — which silently blows up any structure sized from the cloud's bounding box, and is the reason someone's octree allocated 40 GB." Third, the subtle one: "the Q that stereoRectify hands back has (cx − cx′)/Tx in the bottom-right corner rather than zero. It is zero only when the two rectified principal points agree; if it is not, my hand-written version and OpenCV's will disagree, and OpenCV is right."
Symptom. The depth camera returns a valid-pixel fraction of 0.94 in the warehouse and 0.11 in the loading yard at midday. It recovers at dusk. Someone suggests the exposure is wrong.
Root cause. The projected NIR pattern is at 0.92% contrast against filtered sunlight, as computed above. There is no exposure that fixes a signal buried under the shot noise of the background.
The metric that reveals it, and the decoy it rules out. Two candidate causes predict the same symptom: (a) the projector is swamped, (b) the sensor is saturating and losing all texture to clipping. They are separated by a thirty-second experiment: turn the projector off and re-measure the valid fraction.
The continuous version to put on a dashboard: log valid-pixel fraction against measured ambient irradiance, and against the fraction of pixels at the top of the histogram. Two curves, two causes, no argument.
Symptom. In front of a rack of identical grey totes, the depth map shows a smooth surface at 3.1 m. The real rack is at 2.3 m. There is no noise, no speckle, no holes — it looks like the best depth in the whole frame. The robot plans through it.
Root cause. The totes repeat with a period of about 22 px in the image. The matcher found the minimum one period off, and because every pixel in the region found the same wrong minimum, the result is spatially consistent and survives the smoothness prior, the speckle filter, and often the left-right check as well (both directions can agree on the same periodic alias).
The metric that reveals it. The uniqueness ratio: second-best cost divided by best cost, over disparities more than one or two away from the winner. Healthy texture gives 1.5–3.0. Repetitive structure gives 1.00–1.05. Publish the per-pixel ratio as a confidence channel and this region lights up as the least trustworthy in the frame, even though every other quality signal calls it the best.
Learned stereo replaced hand-built cost volumes. Lipson, Teed and Deng, "RAFT-Stereo: Multilevel Recurrent Field Transforms for Stereo Matching" (3DV 2021), carried the recurrent refinement idea from optical flow into stereo and pushed error rates well below SGM on standard benchmarks, particularly in the thin-structure and textureless regimes where classical matching is weakest. Wen et al., "FoundationStereo" (CVPR 2025), took the next step of training one model to generalise zero-shot across domains rather than fine-tuning per dataset. The exponent in σZ ∝ Z2 does not change — that is geometry, not algorithms — but σd gets smaller and the invalid-pixel fraction drops a lot.
Monocular metric depth stopped being a joke. Yang et al., "Depth Anything V2" (NeurIPS 2024), and Piccinelli et al., "UniDepth: Universal Monocular Metric Depth Estimation" (CVPR 2024), produce dense metric depth from a single image with usable accuracy in familiar domains. The honest position: these are excellent priors and poor measurements. They fill the holes stereo leaves, they resolve which side of an edge is nearer, and they must never be the only evidence behind a safety decision, because their failure mode is confident-wrong on out-of-distribution scenes and they carry no geometric constraint that could contradict them.
The dated claim to watch. Anything stated here about which network holds a benchmark is perishable; the geometry is not. If you are asked about the frontier, lead with the invariant (quadratic depth error, occlusion band equal to the disparity, uniqueness as the only internal guard against repetition) and treat model names as illustrations. That framing survives the next release cycle and reads as judgement rather than currency.
Chapter 0 covered what LiDAR is blind to. This chapter is about the failure that is always present, on every scan, on every moving robot, and that most people have never had to write down: a spinning LiDAR does not take a picture, it paints one, and while it paints the canvas moves.
A 10 Hz spinning LiDAR completes one revolution in 100 ms. Points at azimuth 0° and points at azimuth 359° are separated by 100 ms of robot motion. Every driver hands you a single message with a single header timestamp, and if you believe that timestamp you have quietly asserted that the robot was frozen for a tenth of a second.
It was not. At a modest 1.5 m/s the robot translates 15 cm during one sweep, and at 60°/s of yaw it rotates 6°. Both of those are far larger than the sensor's own 2–3 cm range noise, so motion distortion dominates the error budget of a moving LiDAR and everything downstream inherits it.
The fix is to treat the sweep as what it is: a sequence of measurements taken from a sequence of poses. Choose a reference instant — conventionally the end of the sweep, because that is the pose you want to publish — and transform every point into that frame.
Every symbol. pi is the point as the sensor measured it, expressed in the sensor frame at the instant of that firing — that is just (r cosα, r sinα) in 2-D, or the beam direction times the range in 3-D. ξ = (v, ω) is the body twist, assumed constant across the sweep. ti − tend is the time offset, and it is negative for every point except the last one. exp(·) is the SE(2) or SE(3) exponential, which turns a constant twist held for a duration into a rigid transform.
"The SE(2) exponential" is easy to say and easy to hand-wave, and the hand-wave is exactly where understanding usually stops. So derive it. The rotation half is not the problem: hold yaw rate ω for a duration dt and you have turned by θ = ω·dt, full stop, no correction factor anywhere. The translation is the problem, and it is worth being precise about why.
The velocity v is a body-frame quantity — "1.5 m/s out the front of the robot". But the robot's front is turning while that 1.5 m/s accumulates. At the start of the interval the velocity points one way; a millisecond later it points somewhere slightly different. You cannot multiply a rotating vector by a duration. You have to integrate it, re-expressing the body velocity in the fixed frame at every instant along the way:
Here s is a dummy variable running over the interval, R(ωs) is how much the body has already turned by time s, and v is constant (that is the constant-twist assumption). Write the 2×2 product out component by component with v = (vx, vy):
Two elementary integrals appear, and only two. Both are first-year calculus:
Now substitute θ = ω·dt, which means 1/ω = dt/θ, and factor the dt out of both entries. What is left is a matrix acting on v, times dt:
That matrix is V(θ), the left Jacobian of SO(2). It is not a fudge factor, not an approximation, and not something you memorise: it is what the integral evaluated to. And it has a one-line interpretation that makes it stick — divide the integral by dt and you are averaging R(φ) over φ from 0 to θ:
The limit is the sanity check. Let θ → 0. Then sinθ/θ → 1 and (1−cosθ)/θ → θ/2 → 0, so V → I and Δp → v·dt — exactly the naive answer everyone writes first. So the naive form is not wrong; it is the zeroth-order truncation of this integral, valid when the body barely turned during the interval.
Better still, the series tells you the size of what you dropped. Using sinθ/θ = 1 − θ2/6 + … and (1−cosθ)/θ = θ/2 − θ3/24 + …, the error of the naive form is
The along-track term is second order in θ and the across-track term is first order. So the naive small-angle de-skew loses almost nothing in the direction of travel and everything it loses is lateral — which is worth knowing, because lateral is the direction that bends walls. Step 4 of the worked example below quotes a 2.0 mm discrepancy; that number is (θ/2)·|v·dt| = 0.026180 × 0.075 = 1.963 mm of lateral offset plus a mere (θ2/6)·|v·dt| = 0.000457 × 0.075 = 0.034 mm along track. Now you know exactly where the 2 mm went.
Robot driving forward at v = 1.5 m/s while yawing at ω = 60°/s = 1.047198 rad/s. The LiDAR spins at 10 Hz, so tend − tstart = 0.100 s. Take the beam that fires exactly halfway through the sweep, at ti − tend = −0.050 s, and suppose it measures a range of 10.000 m straight ahead in the sensor frame at that instant, so pi = (10.000, 0.000).
So a point the raw cloud places at (10.000, 0.000) actually belongs at (9.911, −0.521). The correction is 52.9 cm, of which 52.1 cm is lateral. Now sweep the offset across the whole revolution and the picture becomes unmistakable:
| ti − tend | Δθ | Corrected position of a 10 m return | Lateral error if you skip de-skew |
|---|---|---|---|
| −100 ms (sweep start) | −6.00° | (9.7955, −1.0374) | 103.7 cm |
| −75 ms | −4.50° | (9.8568, −0.7802) | 78.0 cm |
| −50 ms | −3.00° | (9.9113, −0.5214) | 52.1 cm |
| −25 ms | −1.50° | (9.9591, −0.2613) | 26.1 cm |
| 0 ms (sweep end) | 0.00° | (10.0000, 0.0000) | 0 cm |
A flat wall 10 m away becomes a metre-long smear, monotonically increasing with how early in the sweep the beam fired. Against a sensor whose own range noise is 2 cm, the distortion is fifty times the noise. Saying "LiDAR is accurate to a couple of centimetres" and stopping there is the tell of someone who has never processed a moving scan.
"Spinning versus solid-state" is a stock debate, and the stock answer — "no moving parts, more reliable" — is worth almost nothing. The properties that actually change your software are these:
| Property | Mechanical spinning | MEMS / solid-state | Why your code cares |
|---|---|---|---|
| Field of view | 360° horizontal | Typically 60–120° | Loop closure and re-localisation get much harder with a forward-only FOV; you need several units and their extrinsics |
| Scan pattern | Repeating rings, uniform azimuth | Often non-repetitive (rosette, Lissajous) | Ring structure is what makes fast ground segmentation and ring-based feature extraction possible; a non-repetitive pattern breaks LOAM-style feature code entirely |
| Point density over time | Constant per frame | Accumulates — more integration, more points | You choose an integration window, which trades density against motion distortion. There is no "frame". |
| Motion distortion | Spread over the full revolution | Spread over your chosen window | Same problem, different knob — and with a non-repetitive pattern you cannot assume azimuth encodes time |
| Failure mode | Bearing wear, slip-ring noise, imbalance | Mirror resonance drift, thermal pointing error | Different calibration-maintenance story over a fleet lifetime |
The line that summarises it: "The mechanically interesting difference is the moving parts; the software-interesting difference is that a non-repetitive scan pattern destroys the assumption that azimuth index is a proxy for time and that neighbouring points are neighbours in space. Anything I wrote that indexes into a ring has to be rewritten."
The beam is not a line. A typical divergence is about 3 milliradians, so the illuminated spot has diameter 0.003 × R — at 30 m that is 0.003 × 30 = 0.090 m, which is the third row below and every other row is the same single multiplication:
| Range | Spot diameter | What that means |
|---|---|---|
| 5 m | 1.5 cm | Smaller than most features — clean returns |
| 10 m | 3.0 cm | Comparable to a chair leg |
| 30 m | 9.0 cm | Wider than a sign post; edges get soft |
| 50 m | 15 cm | Wider than a human arm |
| 100 m | 30 cm | Wider than a bollard — you cannot resolve it |
When the spot straddles a depth discontinuity — half on a pole at 30 m and half on a wall at 45 m — the receiver sees two echoes inside one measurement. A sensor that reports the amplitude-weighted first moment of that merged waveform places a mixed pixel at an intermediate range that corresponds to nothing physical. These are the "flying pixels" that pepper every real cloud along object edges. The right move is to compute where it lands rather than gesture at "somewhere in between", so here is the whole calculation.
Step 1 — the return model, stated explicitly. For a surface that fills the beam, received power goes as
Every symbol. ρ is the diffuse (Lambertian) reflectivity of the surface. f is the fraction of the beam's cross-section that lands on it, so the two surfaces' f values sum to 1. R is range. The exponent deserves a sentence, because "why R2 and not R4?" is a real follow-up: the receiver collects a solid angle Ar/R2 of the reflected light, which alone would give 1/R4 — but the illuminated area also grows as R2 for a diverging beam on an extended surface, and more illuminated area means more reflecting area. Those two R2 factors cancel one against the other, leaving 1/R2 for a beam-filling target. (A target smaller than the beam does not grow with range, so it keeps the full 1/R4. That distinction is why thin wires and small retroreflectors behave so differently at range.)
Step 2 — evaluate both returns. Take a bare concrete pole, ρ = 0.3, at R = 30 m, with the spot half on it; and a painted wall, ρ = 0.5, at R = 45 m, taking the other half. Constants of proportionality cancel in the ratio, so drop them:
Notice the competition before you go further: the wall is 1.67× more reflective, but it is 1.5× further, and 1.52 = 2.25 beats 1.67. The near, darker surface wins. That is the whole reason the phantom does not land at the midpoint.
Step 3 — the intensity-weighted centroid. The reported range is the first moment of the merged return:
So the phantom sits at 36.4 m, not at the 37.5 m midpoint — pulled 1.1 m toward the pole by the inverse-square weighting, exactly as step 2 predicted. It floats 6.4 m past the pole and 8.6 m short of the wall, in open air, and the spot that produced it is 0.003 × 36.4 = 10.9 cm across. Nothing in the point's own record — range, intensity, ring, timestamp — says anything is wrong.
Two consequences you should volunteer. First, dual-return mode exists for exactly this reason: reporting both the strongest and the last echo lets you keep the far surface and identify the edge. Second, a mixed pixel is a confident-wrong measurement with normal intensity, so no per-point quality metric catches it — the standard defence is a neighbourhood consistency filter that removes points whose range differs sharply from both azimuthal neighbours.
Chapter 0 covered material failures. Weather is the other axis, and it has two distinct mechanisms that people conflate.
Attenuation follows Beer-Lambert over the two-way path: transmission = exp(−2αR), where α is the extinction coefficient in km−1 and R is the range in km. With α ≈ 1 km−1 in heavy rain, a 50 m target retains exp(−2 × 1 × 0.050) = exp(−0.1) = 0.905 of its energy — a 9.5% loss. Rain is a surprisingly minor attenuator.
Fog is not. To talk about fog in numbers you need the bridge between the extinction coefficient and the thing the weather report gives you, which is Koschmieder's relation: meteorological visibility V is the range at which contrast falls to the 2% threshold, and that works out to
So α ≈ 20 km−1 is not a 50 m fog — it is 3.912/20 = 0.196 km, a 196 m fog, the kind you would drive through with fog lights on. Even that costs the LiDAR dearly: the same 50 m target retains exp(−2 × 20 × 0.050) = exp(−2.0) = 0.135, an 87% loss, which takes most low-reflectivity targets under the detection floor. Now tighten to a genuine 50 m visibility: α = 3.912/0.050 = 78 km−1, and the 50 m target retains exp(−2 × 78 × 0.050) = exp(−7.8) ≈ 4 × 10−4. Four parts in ten thousand. The target is simply gone, and no amount of gain helps because the noise floor came up with it.
Backscatter is worse and more interesting. Droplets in the beam path return energy from ranges closer than the target. A leading-edge detector triggers on the first thing above threshold, so the sensor reports a phantom point a few metres ahead in open air. The observable signature is distinctive: a diffuse halo of low-intensity points at short range that moves with the robot rather than staying fixed in the world, and that vanishes from the last-return channel. That is why last-return mode is the standard rain setting — the raindrops are the first echo, the road is the last one.
Top-down. Grey is the true rectangular room. Orange is the raw cloud assembled as if the sweep were instantaneous. Teal is the same cloud de-skewed with the true twist. Raise the yaw rate and watch the walls peel away.
What to look for. Press "flip the dt sign". The walls become perfectly straight again — and the whole room shifts, because you have de-skewed into the sweep-start frame instead of the sweep-end frame. That is the demonstration that straightness is not a correctness test. The only correctness test is whether the cloud registers against a map built in the same convention, which is why this bug survives code review and dies in the field.
The ordering constraint that gets violated in real pipelines. De-skew must come before voxel downsampling and before any feature extraction. Downsampling a distorted cloud averages points that were taken from different poses, so it bakes the distortion in irreversibly and it is not even a consistent average — the bias depends on how many early-sweep versus late-sweep points landed in each voxel. Put de-skew immediately after assembly and before everything else.
The dependency that makes it a design problem. De-skew needs to know the motion during the sweep, which means it needs a pose source that spans the sweep interval. That rules out the output of the very estimator that consumes the cloud — you would be using the answer to compute the question. The three real options, and the one you should pick:
| Twist source | Latency | Cost | Verdict |
|---|---|---|---|
| IMU integration over the sweep | None — IMU leads the LiDAR | Needs gyro bias; drift over 100 ms is negligible | The standard answer. 100 ms of gyro integration at 0.01°/s bias is 0.001° of error |
| Wheel odometry twist | ~20 ms | No rotation observability during slip | Acceptable indoors on good floors; fails exactly when you need it |
| Previous filter output, extrapolated | One full cycle | Feedback loop between estimate and measurement | Works, but couples the de-skew to estimator errors — be ready to defend it |
The budget, stated in numbers. Latency from first firing to a registered pose: 100 ms of sweep accumulation (unavoidable, and it is why the effective LiDAR latency is half a sweep even before processing) + 2 ms assembly + < 1 ms de-skew + 4 ms filtering + 20 ms registration = ~127 ms, of which 79% is waiting for the sensor to finish turning. That is the number that motivates processing sub-sweep slices at 40 Hz instead of full revolutions at 10 Hz, and being able to say it is what separates someone who has tuned a stack from someone who has read about one.
python import numpy as np def se2_exp(v, w, s): # the pose reached by holding twist (v, w) for time s, as (theta, translation) th = w * s if abs(th) < 1e-9: return th, np.array([v[0] * s, v[1] * s]) A, Bc = np.sin(th) / th, (1 - np.cos(th)) / th V = np.array([[A, -Bc], [Bc, A]]) return th, V @ (np.asarray(v) * s) def deskew(rng, az, t, t_end, v, w): # rng, az, t are per-point. Returns the cloud in the SWEEP-END frame. out = np.empty((len(rng), 2)) for i in range(len(rng)): dt = t[i] - t_end # NEGATIVE for every point but the last th, dp = se2_exp(v, w, dt) R = np.array([[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]]) p = np.array([rng[i] * np.cos(az[i]), rng[i] * np.sin(az[i])]) out[i] = R @ p + dp return out
The vectorised production form does the same arithmetic without the Python loop, and in practice you interpolate a real IMU-derived trajectory rather than assuming one constant twist:
python # vectorised, constant twist — this is what runs at 10 Hz dt = t - t_end th = w * dt c, s = np.cos(th), np.sin(th) px, py = rng * np.cos(az), rng * np.sin(az) A = np.where(np.abs(th) < 1e-9, 1.0, np.sin(th) / np.where(th == 0, 1, th)) Bc = np.where(np.abs(th) < 1e-9, 0.0, (1 - c) / np.where(th == 0, 1, th)) X = c * px - s * py + (A * v[0] - Bc * v[1]) * dt Y = s * px + c * py + (Bc * v[0] + A * v[1]) * dt # in a real stack you do not assume constant twist — you SLERP the IMU trajectory: # pose_i = interpolate(imu_poses, t[i]) # 200 Hz IMU spans the 100 ms sweep # p_end = T_end.inverse() * pose_i * p_i
Symptom. The occupancy map is crisp along straight aisles and shows two parallel walls 15–20 cm apart wherever the robot turned. Straight-line sections are fine. The effect is worse for far walls than near ones.
Root cause. Motion distortion during the sweep, uncorrected. Registration finds a compromise pose between the early-sweep and late-sweep halves of the smeared cloud, and consecutive smeared scans disagree, so the map accumulates both.
The metric that reveals it, and the three decoys it beats. Fit a plane (or line) to a known flat wall in each scan and record the RMS residual. Then scatter that residual against |ω| for every scan in the log. The four candidate causes make four different predictions, and this one plot separates them all:
| Cause | Residual vs |ω| | Residual vs speed | Residual at rest |
|---|---|---|---|
| Motion distortion | Linear, through the origin | Weakly linear | Zero |
| Extrinsic calibration error | Flat | Flat | Non-zero, same magnitude |
| Time offset between LiDAR and odometry | Linear | Linear | Zero |
| Sensor range noise | Flat | Flat | Non-zero, small |
Motion distortion and a time offset both scale with rate and both vanish at rest — so one more test is needed, and it is decisive: de-skew the log offline and re-plot. If the slope goes to zero, it was distortion. If a slope remains, you have a residual time offset, and the offset in seconds is that remaining slope divided by the range at which you measured it. This is the same "divide the bias by the rate to get seconds" move from Chapter 1, and it is worth noticing that it keeps working.
Symptom. Somebody enables de-skew. Straight walls are now beautifully straight. Localisation error doubles, and the robot consistently believes it is about 15 cm behind where it is.
Root cause. The reference instant. The de-skew used dt = ti rather than ti − tend, producing a cloud that is internally consistent in the sweep-start frame while the published pose timestamp says sweep end. The result is a fixed lag of one sweep period.
The metric that reveals it. Not scan quality — scan quality got better, which is exactly why this bug ships. The tell is that the position error is a constant offset along the direction of travel whose magnitude equals speed × sweep period: 1.5 × 0.100 = 15 cm, and it scales with speed while staying stationary in the body frame. Plot the localisation residual in the body frame rather than the world frame and it collapses to a single point instead of a cloud. A calibration error would also give a constant body-frame offset, but it would not scale with speed; divide by speed and if you get a number equal to your sweep period, you have found it.
FMCW measures velocity per point. Frequency-modulated continuous-wave LiDAR mixes the return with a local copy of the transmitted chirp and reads range from the beat frequency and radial velocity from the Doppler shift on the very same measurement. That changes this chapter structurally: with a per-point radial velocity you can segment moving objects from a single sweep with no association or tracking, and you get a direct observation of the ego-motion component that de-skew needs. Behroozpour et al., "Lidar System Architectures and Circuits" (IEEE Communications Magazine, 2017), is the readable architectural reference. FMCW is also immune to interference from other LiDARs and to sunlight, because only a return coherent with your own chirp produces a beat.
Continuous-time registration made de-skew part of the estimate. Dellenbach, Deschaud, Jacquet and Goulette, "CT-ICP: Real-time Elastic LiDAR Odometry with Loop Closure" (ICRA 2022), stopped treating de-skew as a preprocessing step with an externally supplied twist and instead parameterised each scan by its start and end poses, optimising both inside the registration. The scan is allowed to be elastic. This removes the circular dependency in the design table above — you no longer need a twist before you can register — and it is the single best answer to "how would you do de-skew today".
The convergence worth naming. Rolling-shutter cameras and spinning LiDARs have exactly the same problem: a measurement spread over time, modelled as if it were instantaneous. Both are solved the same way, by replacing "one pose per frame" with a continuous-time trajectory that the estimator can query at any timestamp. Noticing that — that Chapter 1's rolling shutter and Chapter 3's motion distortion are one problem — is the kind of connection that separates knowing the material from seeing the structure.
An IMU is the only sensor on the robot that never loses lock, never gets occluded, never cares about weather or texture or darkness, and runs at 200 Hz for a few dollars. It is also the only one whose error grows without bound in the absence of anything else. Both halves of that sentence are why every modern estimator is built around it.
The question is always some version of: "How far do you drift on IMU alone?" The wrong answer is a number. The right answer is three numbers with three different exponents, because which one dominates depends entirely on how long you have been drifting.
An accelerometer bias ba is a constant offset added to every reading. The navigator integrates it twice, so
With a modest ba = 0.02 m/s2 (about 2 milli-g, entirely typical of an uncalibrated consumer MEMS part): at t = 1 s the error is 0.5 × 0.02 × 1 = 1.0 cm. At t = 10 s it is 0.5 × 0.02 × 100 = 1.00 m. At t = 60 s it is 0.5 × 0.02 × 3600 = 36.0 m.
A gyro bias does not enter position directly. It enters through gravity, and the chain of reasoning is the single most valuable derivation in inertial navigation.
Work it numerically. bg = 0.01°/s (a decent consumer-grade in-run stability) = 0.01 × π/180 = 1.745329 × 10-4 rad/s.
| t | tilt θ = bgt | leaked accel g·sinθ | perr = g bg t3/6 |
|---|---|---|---|
| 1 s | 1.745e−4 rad = 0.010° | 0.001712 m/s2 | 9.81 × 1.745e−4 × 1/6 = 0.29 mm |
| 10 s | 1.745e−3 rad = 0.100° | 0.017122 m/s2 | 9.81 × 1.745e−4 × 1000/6 = 0.285 m |
| 60 s | 1.047e−2 rad = 0.600° | 0.102728 m/s2 | 9.81 × 1.745e−4 × 216000/6 = 61.6 m |
Sixty-one metres in one minute, from a bias of one hundredth of a degree per second. That number is the answer to "why can't we just use the IMU in the tunnel", and it is worth committing to memory.
Set the two equal and solve. ½bat2 = (1/6)g bgt3 gives t = 3ba / (g bg). With ba = 0.02 and bg = 1.745e−4:
At that instant both terms equal 12.28 m. Below 35 seconds the accelerometer bias is the bigger problem; above it the gyro takes over and never gives it back, because a cubic beats a quadratic forever. This is precisely why a robot that gets a GPS or LiDAR fix every second or two can live with a cheap gyro, and a robot that must coast for a minute cannot.
Bias is a systematic error; noise is a random one, and it grows more slowly. Model the accelerometer's white noise by its velocity random walk N, in units of m/s per √s — the standard deviation of the velocity error you accumulate in one second of integration.
Integrating white noise once gives a random walk with variance N2t — that is the definition of N. Integrating twice takes four more lines, and they are worth owning, because the factor of 1/3 is exactly where hand-waving hides. Reciting "standard Wiener result" is where the point is lost.
The discrete sanity check — and it is literally what experiment B in the Code Lab below measures. Step at interval dt. Sample j contributes a velocity kick ajdt whose variance is N2dt (that is the density-to-per-sample conversion sig_a = VRW / sqrt(DT) you will see in the starter). Once delivered, that kick rides in the velocity for every remaining step, so by time t it has displaced the robot by (t − j·dt) × (ajdt). The kicks are independent, so variances add:
Same answer by a different route, and this one makes the mechanism visible: early noise hurts quadratically more than late noise, because an early velocity error has the entire remaining interval to accumulate into position. A noise sample at t = 0 gets the full lever arm t; one at t = 59 s of a 60 s run gets a lever arm of 1 s and contributes 3600× less variance. Hold that picture and the √3 stops being a memorised constant: it is the price of averaging a lever arm that shrinks linearly to zero.
Datasheets usually quote VRW in m/s per √hour. Convert: 0.1 m/s/√hr ÷ √3600 = 0.1/60 = 0.001667 m/s/√s. Then
| t | Arithmetic | σp | Compare: gyro bias at 0.01°/s |
|---|---|---|---|
| 1 s | 0.001667 × 1 / 1.732 | 0.96 mm | 0.29 mm |
| 10 s | 0.001667 × 31.62 / 1.732 | 3.04 cm | 28.5 cm |
| 60 s | 0.001667 × 464.8 / 1.732 | 0.447 m | 61.6 m |
The three exponents in one line, ordered by how long they take to matter: white noise t1.5, accel bias t2, gyro bias t3. There is a fourth — angle random walk in the gyro, which enters attitude as √t and therefore position as t2.5 — and mentioning it unprompted is a nice touch, though it rarely dominates.
The Allan deviation plot is a log-log graph of σ(τ) against averaging time τ, and the whole point is that each error source has its own slope, so you read the parameters straight off the curve.
| Region | Slope on log-log | What it is | How to read the number |
|---|---|---|---|
| Short τ | −1/2 | White noise (ARW / VRW) | The value at τ = 1 s is the random walk coefficient, in units per √s |
| The bottom of the bowl | 0 (flat) | Bias instability | B = σmin / 0.664, where 0.664 = √(2 ln 2 / π). It is a computed constant, not a fudge: push a 1/f flicker-noise spectrum through the Allan-variance transfer function and 0.6643 is what falls out |
| Long τ | +1/2 | Rate random walk (bias wandering) | On this branch σ(τ) = K√(τ/3), so K = σ(τ)·√(3/τ). Read at τ = 3 s and the factor is exactly 1, i.e. K = σ(3 s), in °/s per √s |
All three recipes are the same trick, and saying so is faster than memorising three rules: each error source obeys a power law σ(τ) = C · τslope with a known constant folded in, so you evaluate the law at the one τ where that constant becomes 1 and read the coefficient straight off the plot. White noise obeys σ(τ) = N/√τ, so at τ = 1 s, σ = N with no scaling at all. Rate random walk obeys σ(τ) = K√(τ/3), so at τ = 3 s, σ = K. The flat region is the odd one out only because its constant, 0.664, is not 1 at any τ — it is a property of the flicker spectrum rather than of where you stand on the axis, so it never cancels and you always divide by it.
Worked, the +1/2 branch. A gyro whose curve passes through σ = 0.004°/s at τ = 300 s on the rising side has
Round-trip it before you quote it, which takes three seconds and catches the commonest mistake. Walk that same +1/2 line back down from τ = 300 s to τ = 3 s: τ falls by 100×, and on a +1/2 slope σ falls by √100 = 10×, giving σ(3 s) = 0.0004°/s — which is K, as the recipe promised. If your K does not survive that round trip you read a point off the wrong branch of the bowl, and on a real Allan plot the two branches meet in a broad flat minimum where it is genuinely easy to do so. The defensive habit: only read a coefficient from a τ at least a decade away from σmin, where the slope is unambiguously ±1/2.
Worked: a gyro's Allan deviation bottoms out at σmin = 0.002°/s at τ = 100 s. Then the bias instability is B = 0.002 / 0.664 = 0.00301°/s = 10.8°/hr. That single number tells you which grade of device you are holding:
| Grade | Gyro bias stability | Position error at 10 s (free inertial) | at 60 s |
|---|---|---|---|
| Consumer MEMS | ~30°/hr | 0.24 m | 51 m |
| Industrial MEMS | ~3°/hr | 2.4 cm | 5.1 m |
| Tactical | ~0.3°/hr | 2.4 mm | 0.51 m |
| Navigation (FOG/RLG) | ~0.01°/hr | 0.079 mm | 1.7 cm |
Every cell in that table is one formula, so regenerate it rather than memorise it. Each entry is p(t) = g·bgt3/6 with bg converted out of °/hr by multiplying by π/(180 × 3600) = 4.8481 × 10-6 rad/s per °/hr. Do the last row by hand: 0.01 × 4.8481e−6 = 4.8481e−8 rad/s; times 9.81 = 4.756e−7; times t3/6 = 1000/6 = 166.67 gives 7.93e−5 m = 0.079 mm, and times 216000/6 = 36000 gives 0.01712 m = 1.71 cm.
Because p is linear in bg, the position column is a clean image of the specification column: consumer to navigation is 30 / 0.01 = 3000× in bias stability and therefore exactly 3000× in position error — 0.23780 m ÷ 0.079267 mm = 3000.0. The rounded display values give 3030 only because 0.24 is itself rounded. Nothing in this table is approximate except the grade labels themselves, which is worth saying out loud, because it means you can invert it: a coasting requirement in metres maps one-to-one onto a line item in the bill of materials, and the mapping is a single multiplication.
The engineering move: given a coasting requirement, invert the cubic and name the grade. "You want under 20 cm after 30 seconds of tunnel? p = g bgt3/6 gives bg = 6 × 0.20 / (9.81 × 27000) = 4.5e−6 rad/s = 0.94°/hr — that is tactical grade, five figures, and it changes the bill of materials. Or you find an aiding source, which is what Chapter 6 is about."
Log-log position error against coasting time. Each curve is a straight line whose slope is its exponent: 1.5, 2, 3. Change the sensor grade and watch which one wins — and where they cross.
What to look for. Drag the gyro bias down to navigation grade (0.01°/hr) and the cubic line drops below the others across the whole plotted range — that is what a hundred thousand dollars of ring-laser gyro buys. Now push the VRW up: the noise line moves but its slope never changes. Nothing you buy changes an exponent. The only thing that changes an exponent is adding a different sensor, which resets the clock.
The IMU is trivially cheap in bandwidth and the least trivial in timing. Its whole design story is about when, not how much.
The tradeoff, in numbers. Reading the IMU one sample at a time costs an interrupt every 5 ms — 200 per second, each with its own hardware-accurate timestamp. Using the on-chip FIFO with a watermark of 10 samples costs 20 interrupts per second, a 20× reduction in CPU overhead, and adds 10 × 5 ms = 50 ms of latency to the newest sample plus the obligation to back-date the older nine by assuming a perfectly regular sample interval. On a 200 Hz control loop that 50 ms is ten control periods, which is unacceptable; on a 10 Hz mapping pipeline it is invisible. The right answer depends on the fastest consumer, and saying so is the answer.
Do not hand-wave "some latency". At 200 Hz the sample period is T = 5 ms. A FIFO that raises its interrupt when W samples are queued hands you a batch in which the newest sample is fresh and the oldest has been sitting in silicon for (W − 1)·T. So there are two numbers, and you need both:
The mean is the average over the batch of (W − 1 − k)·T, which is the arithmetic series (W − 1)/2. The worst case is the oldest sample's in-FIFO age, (W − 1)·T, plus one more period for the ISR to be scheduled and the burst read to complete — round it to W·T and you are safely conservative. Tabulate it against the two consumers that actually exist on this robot:
| Watermark W | IRQ rate | Mean added latency | Worst case | 200 Hz control loop (5 ms budget) | 10 Hz mapper (100 ms budget) |
|---|---|---|---|---|---|
| 1 | 200 /s | 0 ms | 5 ms | fits, exactly — and every sample carries its own hardware stamp | fits, 20× over |
| 4 | 50 /s | 7.5 ms | 20 ms | blows it 4× | fits, 5× over |
| 10 | 20 /s | 22.5 ms | 50 ms | blows it 10× | fits, half the budget spent |
| 32 | 6.25 /s | 77.5 ms | 160 ms | blows it 32× | blows it 1.6× |
Read the last two columns and the architecture answers itself. There is no single W that serves both consumers: the control loop is only feedable by W = 1, and the mapper is comfortable anywhere up to W = 10. So the design is not a compromise value of W, it is two paths off one device — a per-sample interrupt path for control and attitude, and a batched path for mapping, logging and the vibration monitor. Proposing that split unprompted is the move; proposing "W = 8, it's a good middle ground" is the answer that fails, because it is the one value that satisfies nobody.
Notice what never entered the argument: bandwidth. Twelve bytes at 200 Hz is 2.4 kB/s whatever W is, and a W = 10 burst is 120 B, which at 10 MHz SPI is 120 × 8 / 107 = 96 µs of transfer. Even at W = 32 the burst is 384 B and 307 µs. The IMU is a latency budget and an interrupt budget, never a bandwidth budget — the opposite of the camera in Chapter 1 and the LiDAR in Chapter 3, and naming that inversion is worth a sentence.
The hardware gives you exactly one trustworthy timestamp per batch, taken when the watermark fired. Every other sample's time is reconstructed:
That reconstruction quietly assumes the device's sample interval is exactly T. It is not. The IMU runs on its own oscillator, typically specified to ±1–2%, and it is not disciplined to your host clock. At 1% the true interval is 5.05 ms rather than 5.00 ms, so each back-dated step accrues 0.05 ms of error and the oldest sample in a W = 10 batch is mis-stamped by
Is 0.45 ms bad? The only way to know is to multiply it by whatever you are integrating. At a yaw rate of 1 rad/s it is 0.45 mrad = 0.026° of attitude — below the tilt errors in the table at the top of this chapter. At 1.5 m/s it is 0.7 mm of position. Both are under the noise floor, so W = 10 is safe on this count. Now run the same sum at W = 100: 99 × 0.05 ms = 4.95 ms, which is 5 mrad (0.28°) and 7.5 mm — and, crucially, that error is systematic. It has the same sign in every batch, so it integrates rather than averaging away, exactly like the wheel-radius error in Chapter 5. The watermark is bounded by the clock-drift budget as well as by the latency budget, and the second bound bites long before most people expect.
The senior answer is not a bigger safety factor on T, it is a measured T: regress hardware timestamps against sample index over a few minutes and estimate the device's true mean interval as a slowly-varying state, exactly the offset-estimation machinery in Time & Sync. A 1% error on a nominal 5 ms is 3 µs of slope per sample; over 60 s of samples that is a residual you can fit to well under 0.1%, which shrinks the W = 100 penalty from 4.95 ms to under 0.5 ms without touching the hardware.
Boxes and arrows are not a design. Every arrow leaving the driver carries a declared type, shape, rate, frame and unit — and if you cannot state them, you have not designed the interface, you have drawn it:
| Consumer | Consumes | Rate / batching | Emits |
|---|---|---|---|
| Attitude filter | (1, 7) float64 — [t, ωx, ωy, ωz, ax, ay, az]; body frame; rad/s and m/s2; t in the host monotonic clock | 200 Hz, W = 1 (needs the hardware stamp) | quaternion + 3×3 attitude covariance at 200 Hz |
| VIO preintegration | (N, 7) float64, same columns, N = samples spanning one keyframe pair | once per keyframe pair | 15-dim relative state + 15×15 covariance |
| LiDAR de-skew (Ch 3) | (N, 7) float64 spanning the sweep, N ≈ 20 for a 10 Hz sweep | 10 Hz, W ≤ 10 | one body twist per sweep (6-vector) |
| Vibration monitor | raw un-decimated a only, no gyro columns | 200 Hz, W = 32 is fine — it does not care about latency | high-frequency RMS scalar at 1 Hz |
Unpack the preintegration row, because that is the one they follow up on. The measurement itself is 9-dimensional — a relative rotation ΔR, a relative velocity Δv and a relative position Δp between the two keyframes. It is carried inside the estimator's standard 15-dimensional error-state block [δp, δv, δθ, δba, δbg] so that the Jacobians ∂Δ/∂b are available and the whole integral can be corrected to first order when the filter's bias estimate moves, instead of being recomputed from raw samples. 9 + 6 = 15; that decomposition is the answer to "why 15 and not 9".
Now count the bytes both ways, because the intuition most people carry is wrong. At 30 Hz keyframing, N ≈ 6.7, so the input is 7 × 7 × 8 = 392 B and the output is (15 + 225) × 8 = 1920 B — preintegration makes the message roughly five times bigger. At 2 Hz keyframing, N = 100, the input is 5600 B and the output is still 1920 B, so now it compresses about 3×. The win was never bytes; it is states in the optimisation. Those N samples become zero extra nodes in the factor graph instead of N, and a graph whose node count is set by keyframes rather than by IMU samples is the difference between a solvable problem and an intractable one. Being able to say that with the byte counts in hand, and to name the regime where the message actually grows, is the difference between having read about preintegration and having budgeted for it.
The other timing detail that bites. The on-chip low-pass filter has group delay — typically a few milliseconds, and it changes when you change the filter bandwidth. If you tune the bandwidth down to reduce noise, you have silently added a time offset to every IMU sample and shifted your camera-IMU extrinsic estimate. That is a genuine field bug and it belongs in the same family as the rolling-shutter offset from Chapter 1: a bias proportional to rate, hiding in a geometry parameter. See Time & Sync for how to estimate it rather than guess it.
Preintegration, in one paragraph. Between two consecutive 30 Hz camera frames there are 200/30 ≈ 6.7 IMU samples. Naively, adding those to a factor graph adds 6.7 states per frame. IMU preintegration compresses them into a single relative-motion constraint plus its covariance, computed once in the body frame so it does not have to be recomputed when the estimator's bias estimate changes — instead a first-order correction is applied. The result is a graph with one node per keyframe instead of one per IMU sample, which is the difference between a solvable problem and an intractable one. The mechanics live in Modern VIO; what you need here is the reason it exists.
The 1-D version of strapdown that shows exactly where the t3 comes from:
python import numpy as np g = 9.81 def strapdown_1d(gyro, fx_body, fy_body, dt): # gyro: measured rate. fx/fy: measured specific force in the BODY frame. th = vx = px = 0.0 for k in range(len(gyro)): th += gyro[k] * dt # attitude first — everything depends on it ax = np.cos(th) * fx_body[k] - np.sin(th) * fy_body[k] # nav-frame gravity is (0, -g): no horizontal component to subtract, # so the ENTIRE horizontal error above comes from the wrong theta vx += ax * dt px += vx * dt return px
Run that with gyro = b_g constant, fx_body = 0, fy_body = g and it reproduces g·bgt3/6 to within the Euler discretisation error. That is the entire derivation, executable in eight lines, and being able to type it while explaining it is worth more than any amount of recitation.
The overlapping Allan deviation, which you compute from a two-hour static log:
python def allan_dev(x, fs, taus): # x: static gyro or accel samples. Overlapping estimator — lower variance # of the estimate than the non-overlapping one, for free. theta = np.cumsum(x) / fs # integrate to the "angle" series out = [] for tau in taus: m = int(round(tau * fs)) if m < 1 or 2 * m >= len(theta): out.append(np.nan); continue d = theta[2*m:] - 2 * theta[m:-m] + theta[:-2*m] out.append(np.sqrt(np.mean(d ** 2) / (2 * tau ** 2))) return np.array(out) # the library one-liner, if you are allowed one: # import allantools; t2, ad, _, _ = allantools.oadev(x, rate=fs, data_type='freq')
Three practical notes to volunteer. The log must be at least ten times the longest τ you intend to read, so a bias-instability floor at τ = 100 s needs a 1000 s log minimum and two hours is the usual recommendation. The device must be genuinely still and thermally settled, because a temperature ramp shows up as a spurious +1 slope that people misread as rate random walk. And the resulting numbers describe that unit at that temperature — switch-on-to-switch-on bias repeatability is a different specification entirely, and it is usually much worse.
Symptom. An IMU characterised on a granite block shows an Allan bias instability of 8°/hr. Bolted to the robot with the drive motors running, the effective bias is 40°/hr, and it is worse at some speeds than others. Nobody has changed the firmware.
Root cause: vibration rectification error. The accelerometer's response is not perfectly linear, so a zero-mean vibration does not average to zero — the quadratic term in its response rectifies the vibration into a DC offset. The specification is quoted in units of mg per g2 of RMS vibration. Aliasing makes it worse: any vibration above half the output rate folds down into your band and cannot be filtered out afterwards, because it is already indistinguishable from real motion by the time you see it.
The metric that reveals it, and the decoy it beats. Two candidate causes predict "worse on the robot": vibration rectification and self-heating from the surrounding electronics. Distinguish them with two plots:
Turn the motors off and watch: an instant recovery is rectification, a slow exponential decay is thermal. The fixes are also different — rectification needs mechanical isolation and an anti-alias filter before the ADC, thermal needs a temperature model or a better thermal path. Prescribing the wrong one wastes a hardware revision.
Symptom. A careful bias calibration is stored in flash. Some days the robot's initial heading is off by 2°, other days it is fine. Re-running the calibration always produces a good result — for that session.
Root cause. Confusing two specifications that live in different columns of the datasheet. In-run bias stability (what the Allan floor measures) describes how much the bias wanders during one power-up. Switch-on-to-switch-on repeatability describes how much it changes between power-ups, and for consumer MEMS parts it is often five to twenty times worse. A stored calibration corrects a bias the device no longer has.
The metric that reveals it. Power-cycle the unit fifty times, take a 60-second stationary average each time, and plot the histogram of those averages. In-run stability predicts a tight cluster; switch-on repeatability shows up as a spread across boots that is far wider than the within-boot variation. Quote both numbers side by side and the diagnosis is immediate. The architectural consequence is the answer that lands: estimate the bias as a filter state initialised from the stored value rather than trusting the stored value, and add a stationarity detector so the estimator can refine it whenever the robot is still.
Learned inertial odometry. Liu et al., "TLIO: Tight Learned Inertial Odometry" (IEEE RA-L, 2020), trains a network to regress a displacement and its uncertainty from a window of raw IMU samples, then feeds that as a measurement into a conventional filter. The framing is what matters: the learned component supplies a statistical motion prior that the physics alone cannot — humans and wheeled robots do not move arbitrarily — while the estimator around it stays classical, auditable and equipped with a fallback. Cioffi et al., "Learned Inertial Odometry for Autonomous Drone Racing" (RA-L 2023), pushed the same idea onto a platform with violent dynamics.
The grade ladder is shifting downward. Industrial MEMS parts with sub-degree-per-hour bias stability now exist at prices that would have bought consumer grade a decade ago, which moves the crossover time in the table above by a factor of several. The design consequence is concrete: the coasting duration you can survive without aiding roughly triples for each factor of 30 in bias stability, since the error is cubic in time.
The invariant to lead with. Whatever changes, the exponents do not. t1.5 for noise, t2 for accelerometer bias, t3 for gyro bias — those come from integration and from gravity, not from manufacturing. Every improvement moves a coefficient. Only aiding — a camera, a LiDAR, a wheel, a satellite — resets the clock, and that is precisely the argument Chapter 6 makes from information theory rather than from anecdote.
These three get one chapter because they belong together: each is nearly useless at what the others do well, and that is exactly the property Chapter 6 is going to turn into an argument. The goal here is not to derive all three exhaustively. It is to be able to say, for each one, what it is actually for.
Wheel odometry is the highest-rate, lowest-latency, cheapest relative motion source on a ground robot, and it is beautifully precise over one metre and hopeless over one hundred. Understanding why requires separating two kinds of error that behave completely differently.
Systematic errors are wrong constants in the model — a wheel radius that is 1% off, a track width that is 2 mm off. They are deterministic, repeatable, and they grow linearly with distance travelled. Non-systematic errors are slip, a pebble, a bump, a wet patch. They are random and grow as the square root of distance. Borenstein and Feng's UMBmark work established the essential point: on a decent floor, systematic error dominates by an order of magnitude, and it is the one you can actually fix.
Differential drive, track width b = 0.50 m, nominal wheel radius 0.10 m. Suppose the right wheel's effective radius is 1% larger than the left's — call that ratio Ed = 1.01. This is a completely ordinary manufacturing and wear tolerance; nobody would notice by looking.
Command the robot to drive straight: both wheels turn through the same angle, so the encoder counts are equal and the odometry confidently reports a straight line. Physically, the right wheel covers 1% more ground, so the robot curves left. Over a commanded arc length s:
Eleven degrees of heading error over ten metres, from a one-percent wheel mismatch, on a robot whose encoders resolve 0.15 mm. That is the number that ends every "why can't we just use encoders" conversation. And note the structure: the heading error is proportional to distance driven, so it does not average out, it does not shrink with a better encoder, and no amount of filtering removes it — only calibration or an absolute heading reference does.
A pure scale error is milder and equally unfixable by averaging: if both wheels are 1% small in the model, 100 m of driving reports 99 m, a 1 m error. Both errors are calibrated by the same experiment — UMBmark drives a 4 m square in both directions and separates the two by comparing the two return-position clusters, because a scale error is symmetric under reversal and a wheel-ratio error is not.
An encoder with 1024 cycles per revolution in quadrature gives 4096 counts per revolution. With a 0.10 m wheel the circumference is 2π × 0.10 = 0.6283 m, so one count is 0.6283 / 4096 = 0.1534 mm of travel. Excellent — until you ask for velocity.
| Speed | Travel per 50 Hz period | Counts per period | Quantisation noise on velocity |
|---|---|---|---|
| 1.50 m/s | 30.0 mm | 196 | ±0.3% |
| 0.05 m/s | 1.0 mm | 6.5 | ±7.7% |
| 0.01 m/s | 0.2 mm | 1.3 | ±38% |
At creep speed you are counting one or two ticks per period, so the velocity estimate is essentially a random number. This is why a velocity controller tuned at 1.5 m/s oscillates during a precision docking approach, and why serious drives use timer-capture (measuring the interval between edges rather than the count per period), which inverts the problem: precise at low speed, coarse at high speed. Naming that inversion is a strong answer.
A GNSS receiver measures the travel time of signals from satellites and solves for position. Two things determine the accuracy, and the second is the one that matters.
Code (pseudorange) solutions correlate the broadcast ranging code. Resolution is limited to a few percent of a chip, and after ionospheric and clock modelling you land at roughly 3 m for standard positioning, or 0.5 m with differential corrections. Carrier-phase solutions measure the phase of the carrier wave itself, whose L1 wavelength is c/1575.42 MHz = 0.1903 m. Phase is measurable to about 1% of a cycle, so the precision is a couple of millimetres — but only modulo one wavelength. You know the fractional part and not the integer number of whole cycles.
RTK resolves those integers using a base station and the geometry over time. When it succeeds you get a fixed solution at 1–2 cm plus about 1 ppm of the baseline (so 1 cm at 10 km). When it does not, you get a float solution at a few decimetres. So far so good.
The observables that catch it, in the order you should name them:
Multipath is the other headline failure and it behaves differently. A signal reflecting off a building arrives later than the direct one, so the measured pseudorange is too long, biased in one direction, and correlated over the seconds it takes to drive past the reflector. Two markers distinguish it: the carrier-to-noise ratio C/N0 dips on the affected satellites, and the error is strongly correlated with satellite elevation — low-elevation satellites are the multipath sources, which is why an elevation mask of 15° is standard. Note the statistical crime: multipath error is time-correlated, so treating consecutive fixes as independent double-counts the information exactly as described in Uncertainty, Least Squares & Robust Costs.
Two more properties worth having ready, because they are asked constantly. A single-antenna receiver cannot observe heading while stationary — heading comes from the velocity vector, which is undefined at rest, so a robot that boots stationary has no yaw until it moves (dual-antenna receivers solve this by measuring the baseline attitude directly). And GNSS delivers absolute, drift-free position at a low rate with high latency — 5 to 10 Hz with 50–150 ms of processing lag — which is the exact photographic negative of an IMU. Coupling architectures are covered in INS/GNSS Coupling.
Put a number on it, because "multipath makes it worse" is not an answer. For a specular reflection off a vertical plane at perpendicular distance d from the antenna, with the satellite at elevation E and its azimuth offset from the wall normal by Δaz, the mirror-image construction gives the extra path length as
A C/A code chip is c/1.023 MHz = 293 m long, and a receiver's correlator only rejects reflections delayed by more than about 1.5 chips (440 m). Both of these are far inside the vulnerable window, so the reflected energy blends into the correlation peak and pulls it late. The induced pseudorange error is a fraction of Δρ set by the reflection's relative amplitude — typically up to half — so a few metres. Two structural facts follow: the error is positive-biased (a detour is never shorter), and it is correlated over the seconds you spend near that wall, which is exactly the condition under which treating consecutive fixes as independent will over-confidently drag your filter.
Heading is the awkward state. The gyro gives you heading rate and drifts; single-antenna GNSS gives you course over ground only while you are moving; a magnetometer promises absolute heading for two dollars. Then you put it on a robot.
A current-carrying wire produces a field B = μ0I/(2πr), and μ0/(2π) = 2 × 10-7 T·m/A. Earth's horizontal field is about 20 µT at mid-latitudes. So:
| Disturbance | Field at the sensor | Heading error = arctan(B/20µT) |
|---|---|---|
| 1 A motor lead at 15 cm | 1.33 µT | 3.8° |
| 1 A at 5 cm | 4.00 µT | 11.3° |
| 5 A at 15 cm | 6.67 µT | 18.4° |
| 20 A traction current at 30 cm | 13.3 µT | 33.7° |
Those errors are load-dependent, so they change with acceleration, with payload, and with the slope of the floor — a hard-iron calibration performed at rest corrects none of it. Add a steel shelf rack, a forklift driving past, and the reinforcing bar in the concrete, and you have an absolute heading reference that is worse than the drift it was meant to correct. The answer that lands: use a dual-antenna GNSS receiver if you need absolute heading outdoors, use GNSS course-over-ground plus gyro once moving, and treat a magnetometer as a coarse initialisation with a ±30° gate — never as a measurement your filter trusts.
Automotive FMCW radar transmits a linear frequency chirp of bandwidth B over duration Tc, mixes the echo with the transmitted waveform, and reads the beat frequency, which is proportional to round-trip delay:
With B = 1 GHz, Tc = 50 µs and a target at R = 30 m: fb = 2 × 1e9 × 30 / (2.998e8 × 50e−6) = 6e10 / 14990 = 4.00 MHz — comfortably digitised by a cheap ADC, which is a large part of why radar is cheap.
Three resolutions follow, and their ratio is the whole story.
What it buys you in exchange is the property no optical sensor has: 77 GHz passes through rain, fog, dust, snow and darkness essentially unattenuated, because the wavelength is thousands of times longer than the droplets that scatter light. Chapter 6 will make this precise, but hold the shape of it: radar's failure modes are almost perfectly uncorrelated with camera's and LiDAR's, which is worth more than any single specification.
Radar's own failures are specific and you should name them: ghost targets from multipath under and between vehicles (a bounce off the road and then a car produces a detection at a range that is the sum of two legs); ground clutter, which is why raw radar needs constant-false-alarm-rate detection rather than a fixed threshold; near-zero elevation resolution on most units, so an overhead sign and a stopped vehicle are the same detection — the failure behind several well-known highway-automation incidents; and micro-Doppler smearing on rotating or articulated targets.
Imaging radar increases N by using MIMO: Ntx transmitters and Nrx receivers synthesise Ntx × Nrx virtual elements. With 12 × 16 = 192:
| Virtual elements N | Δθ = 2/N | Cross-range at 50 m | Can it separate two cars side by side at 50 m? |
|---|---|---|---|
| 4 | 28.6° | 25.0 m | No — not even close |
| 8 | 14.3° | 12.5 m | No |
| 16 | 7.2° | 6.25 m | Marginal for lanes, no for cars |
| 192 | 0.60° | 0.52 m | Yes — and it starts to have shape |
That table is the entire "4D imaging radar" story in one place, and it explains why the category appeared: angular resolution improves as 1/N, so getting from useless to useful took a 24× increase in channel count, which took a decade of silicon.
Grey is the true 20 × 10 m loop. Teal is what odometry believes. Purple dots are GNSS fixes. Give the wheels a 1% radius mismatch and watch the loop peel open; switch the satellites to multipath or to a wrong integer fix and watch the dots lie in two different ways.
What to look for. Set Ed = 1.010 and the odometry loop opens by metres — then set it back to 1.000 and add twelve slip events, and notice that the error is far smaller and differently shaped: slip produces a jagged random walk, a wheel-ratio error produces a smooth systematic curl. That difference is diagnostic. Then switch the satellites to "wrong integer fix": the dots stay tight and consistent, they are simply offset by 38 cm, and nothing about their scatter reveals it.
The design question here is never "how do I read an encoder". It is: given three sources with wildly different rates, latencies and error structures, what does each one get to influence?
| Source | Rate / latency | Payload | What it should drive | What it must NOT drive |
|---|---|---|---|---|
| Wheel odometry | 50 Hz / ~20 ms | 16 B twist + covariance | Velocity control, LiDAR de-skew fallback, short-horizon prediction | Absolute position or long-horizon heading |
| GNSS / RTK | 5 Hz / 50–150 ms | ~100 B position + status + ratio | Absolute position, slow bias correction, global heading once moving | Anything at control rate; anything ungated |
| Radar | 20 Hz / ~30 ms | 256 detections × 20 B = 5 kB, 102 kB/s | Longitudinal range and closing speed, weather-robust obstacle presence | Object extent, lateral position, classification |
The latency budget that decides the architecture. A GNSS fix stamped for time t arrives at t + 120 ms. At 3 m/s the robot has moved 36 cm since that measurement was valid. You therefore cannot apply it as a correction to the current state; you must either keep a short state buffer and re-apply the intervening motion, or run an out-of-sequence update. Getting this wrong produces a position estimate that oscillates at the GNSS rate with amplitude equal to latency times speed — a distinctive 5 Hz sawtooth of 36 cm. Recognising that sawtooth on a plot is a party trick worth having.
The ordering constraint. Every one of these three needs a gate before it reaches the estimator, and the gate needs different evidence for each: a slip detector for odometry (compare wheel-derived yaw rate against the gyro — they disagree only when a wheel is slipping), the ambiguity ratio and a chi-square innovation test for GNSS, and a CFAR plus a track-consistency check for radar. A design that draws three arrows into a filter with no gates is the single most common weak answer to this prompt.
The bandwidth footnote. All three together are 108 kB/s — a thousandth of the camera. Their cost is never bytes; it is the timing discipline and the gating logic. Say that explicitly, because it reframes the whole design conversation away from throughput and toward correctness.
python import numpy as np def dead_reckon(dl, dr, b, Ed=1.0, Es=1.0): # dl, dr: per-step wheel arc lengths from encoder counts (metres). # Ed: right/left effective radius ratio. Es: overall scale error. # The TRUE motion uses the real radii; odometry uses the nominal ones, # which is why the two diverge even with perfect encoders. x = y = th = 0.0 P = np.zeros((3, 3)) kl, kr = 1e-4, 1e-4 # slip variance per metre travelled path = [] for i in range(len(dl)): L, R = Es * dl[i], Es * Ed * dr[i] ds, dth = (L + R) / 2.0, (R - L) / b # midpoint integration — first order in dth, and enough at 50 Hz x += ds * np.cos(th + dth / 2) y += ds * np.sin(th + dth / 2) th += dth # covariance: Jacobians wrt state and wrt the two wheel arcs Fx = np.array([[1, 0, -ds * np.sin(th)], [0, 1, ds * np.cos(th)], [0, 0, 1]]) Fu = np.array([[0.5 * np.cos(th), 0.5 * np.cos(th)], [0.5 * np.sin(th), 0.5 * np.sin(th)], [-1.0 / b, 1.0 / b]]) Q = np.diag([kl * abs(L), kr * abs(R)]) P = Fx @ P @ Fx.T + Fu @ Q @ Fu.T path.append((x, y, th, np.sqrt(P[0, 0] + P[1, 1]))) return path
Two things to point out while you write it. The noise Q is proportional to distance travelled, not to time — a stationary robot accumulates no odometry uncertainty, and getting that wrong is the classic reason a parked robot's covariance balloons overnight. And the covariance this produces models only slip; it says nothing about Ed or Es, which are biases. If those are unknown, they belong in the state vector or in a calibration, never in Q, because inflating Q to cover a bias makes the filter humble in the wrong direction — it widens the ellipse symmetrically while the true error walks off in one direction.
The GNSS side, where the interesting code is entirely in the gating:
python def accept_fix(fix, pred, P_pred): # fix.status: 0=none 1=single 2=float 4=fixed | fix.ratio: AR test statistic if fix.status != 4: R = np.diag([0.5, 0.5, 1.0]) ** 2 # float: decimetres, not centimetres elif fix.ratio < 3.0: return None # marginal integer fix — trust nothing else: R = np.diag([0.015, 0.015, 0.03]) ** 2 y = fix.pos - pred # innovation S = P_pred + R d2 = y @ np.linalg.solve(S, y) if d2 > 16.27: # chi-square, 3 DOF, 99.9% log_warn("fix rejected: d2=%.1f, err/lambda=%.2f" % (d2, np.linalg.norm(y) / 0.1903)) return None return R
And the radar side, which is the one people least expect to be simple. The entire signal chain is two Fourier transforms:
python import numpy as np c, B, Tc, lam, M = 3e8, 1e9, 50e-6, 3.893e-3, 128 def range_doppler(iq): # iq is (n_fast, n_chirps) complex baseband. FAST time -> range, # SLOW time (chirp index) -> velocity. That is the whole algorithm. win_f = np.hanning(iq.shape[0])[:, None] win_s = np.hanning(iq.shape[1])[None, :] Rmap = np.fft.rfft(iq * win_f, axis=0) # beat frequency -> range bin RD = np.fft.fftshift(np.fft.fft(Rmap * win_s, axis=1), axes=1) rng = np.arange(RD.shape[0]) * c / (2 * B) # 15 cm per bin at B = 1 GHz vel = np.arange(-M//2, M//2) * lam / (2 * M * Tc) # 0.30 m/s per bin return np.abs(RD), rng, vel def cfar_1d(x, n_train=16, n_guard=4, alpha=8.0): # constant false alarm rate: the threshold is LOCAL, because clutter # power varies enormously with range. A fixed threshold is unusable. k = n_train + n_guard pad = np.pad(x, k, mode='edge') win = np.ones(2*k + 1); win[k-n_guard:k+n_guard+1] = 0 noise = np.convolve(pad, win / win.sum(), mode='same')[k:-k] return x > alpha * noise
Three numbers to quote while writing it. The range axis is c/(2B) per bin, so 15 cm at 1 GHz of chirp bandwidth. The velocity axis is λ/(2 M Tc) per bin, so with 128 chirps of 50 µs that is 0.30 m/s — and the whole frame took 128 × 50 µs = 6.4 ms, which is why radar latency is trivial compared to a LiDAR sweep. And the angle is not in this code at all, because it comes from combining the range-Doppler maps of the N virtual receive channels, which is exactly why angular resolution is a function of channel count rather than of signal processing.
The cfar_1d function is the part that separates people who have used radar from people who have read about it: the detection threshold must be local, because ground clutter power spans orders of magnitude across the range axis, and a fixed threshold produces either a wall of false alarms up close or complete blindness far away.
The log line is the part worth stealing. Printing the rejected innovation in units of L1 wavelengths means that when someone pastes the log into a chat, a value of 1.99 or 3.01 tells you instantly that the receiver resolved the integers wrongly rather than that the robot jumped. Instrumenting for the failure mode you predicted is the difference between a system you can debug remotely and one that requires a site visit.
Symptom. A yard robot's fused position steps sideways by about 38 cm, holds there for ninety seconds, then steps back. During the whole episode the receiver reports status fixed and a horizontal sigma of 0.014 m. The IMU and wheels agree with each other and disagree with the satellite.
Root cause. A wrong integer ambiguity resolution: 38 cm is 1.997 L1 wavelengths.
The metric that reveals it, and the three decoys it beats. Divide the error by 0.1903 m and look at how close the result is to an integer. Then check three more things, each of which eliminates a competitor:
| Candidate cause | What it would look like | Why it is ruled out here |
|---|---|---|
| Wrong integer fix | Error ≈ integer × 0.1903 m; step change; ratio near threshold | Matches: 0.38/0.1903 = 1.997, and the ratio log shows 3.1 |
| Multipath | Gradual bias, C/N0 dip on low-elevation satellites, error grows and decays | Ruled out: this was a discontinuous step and C/N0 was flat |
| Lever-arm error (antenna offset) | Error rotates with the robot's heading — fixed in the body frame | Ruled out: the offset stayed fixed in the world frame while the robot turned |
| Base-station coordinate error | Constant offset present in every session, everywhere on the site | Ruled out: it appeared and disappeared mid-mission |
Each decoy is eliminated by a different observable — step versus ramp, body-frame versus world-frame, session-constant versus transient. Walking through that table beats naming the right cause immediately, because it shows the reasoning transfers.
Symptom. A robot closes a 60 m loop about 2.5 m short and rotated. Someone inflates the odometry covariance so the filter trusts it less. Loop closure improves slightly; the estimate now jitters visibly between LiDAR updates and the controller starts hunting.
Root cause. The error is systematic (a wheel-ratio bias), and covariance describes random error. Inflating Q tells the filter "this measurement is noisy", which is false — it is precise and wrong. The filter responds by weighting odometry less everywhere, including in the short-horizon prediction where it was the best thing available, so the prediction degrades and the corrections get larger and more visible.
The metric that reveals it. Plot the odometry-versus-truth error against distance driven on log axes. A random error gives slope 0.5; a systematic one gives slope 1.0. Here the slope is 1.0, which says: calibrate, or estimate the parameter, but do not model it as noise. The confirming experiment is UMBmark — drive the same square clockwise and anticlockwise. Random error gives two overlapping clusters of return points; a wheel-ratio bias gives two clusters displaced in opposite directions, and the separation between their centroids gives you Ed directly.
Radar became a geometry sensor, barely. High-channel-count MIMO units (Continental ARS540, Arbe, Uhnder's digital-code modulation) took N from 8 to 192 or more, dropping angular resolution from 14° to under 1° and adding usable elevation. That crosses the threshold where radar detections can be clustered into objects with extent rather than treated as bare range-rate reports. The invariant to hold on to: Δθ ≈ 2/N, so every claim about imaging radar is really a claim about channel count.
Radar odometry stopped being a curiosity. Barnes, Gadd, Murcutt, Newman and Posner, "The Oxford Radar RobotCar Dataset" (ICRA 2020), and Barnes and Posner, "Under the Radar: Learning to Predict Robust Keypoints for Odometry Estimation and Metric Localisation in Radar" (ICRA 2020), showed that a scanning radar can carry a localisation stack on its own through weather that removes both camera and LiDAR. That is the sharpest possible demonstration of the complementarity argument in the next chapter.
GNSS got more constellations and better correction models. Multi-constellation, multi-frequency receivers (GPS, Galileo, BeiDou, GLONASS on L1/L2/L5) shorten time-to-fix and make wrong fixes rarer, and network RTK plus PPP-RTK removes the single-base-station dependency. None of it removes the failure mode: a wrong fix is still reported as fixed. The gate is the engineering, not the receiver.
Wheel odometry did not change at all, and that is worth saying. It is the same differential-drive kinematics it was in 1990, with the same Ed and Es, calibrated by the same square-path experiment. Asked what is new here, the honest answer is "nothing, and that is a feature — it is the one measurement on the robot whose model I trust completely, right up to the instant a wheel slips, which is why the interesting work is slip detection, not slip modelling."
"We fuse the sensors" is what everybody says. It is not an argument — it is a slogan. There is a real argument, it takes four steps, and every step is arithmetic.
Two independent measurements of the same scalar, with variances σ12 and σ22. The maximum-likelihood combination weights each by its precision (the reciprocal of its variance), and the result has
Precisions add. That is the whole of linear fusion, and it is why the information form of the Kalman filter is the natural one for multi-sensor work — adding a sensor is literally adding a matrix. Now take it seriously with numbers.
Fuse a good LiDAR-based position estimate (σ1 = 0.10 m) with a worse one from a second LiDAR of the same type (σ2 = 0.30 m):
Now the same arithmetic in two dimensions, where covariance is a shape rather than a number. Sensor A knows the x direction well and y poorly — think of a LiDAR looking down a long corridor, which pins your lateral position against the walls and tells you very little about where you are along it. Sensor B is the reverse: a wheel odometer, precise along the direction of travel, mute about lateral offset.
Invert each to get its information matrix, add them, invert back:
Compare the areas of the one-sigma ellipses, which is the honest measure of how much you know about a 2-D quantity. Area = π√detΣ.
| Estimate | √detΣ | 1σ ellipse area | Shape |
|---|---|---|---|
| Sensor A alone | 0.05 × 1.00 = 0.0500 | 0.1571 m2 | A 20:1 sliver along y |
| Sensor B alone | 1.00 × 0.05 = 0.0500 | 0.1571 m2 | A 20:1 sliver along x |
| Fused | 0.049942 = 0.0024938 | 0.00783 m2 | A circle |
A 20-fold reduction in the area of the uncertainty region — against a 5% improvement in the same-axis case. Same equation, same amount of hardware, four hundred times the payoff. The difference is entirely in the orientation of the two information ellipsoids.
The addition rule assumed independence. Real sensors share error sources: two cameras behind the same windscreen see the same rain; two LiDARs on the same mast share its flex; every sensor on the robot shares the same clock, the same calibration campaign, the same extrinsic reference frame. Redo the derivation with correlation ρ.
For two scalar estimates with correlation ρ, the optimal weight and the resulting variance are
Take two identical sensors, σ1 = σ2 = 0.10 m. The naive independent answer is 0.10/√2 = 0.07071 m. Now sweep ρ:
| ρ | True σf | What the filter claims | Overconfidence in σ | Expected NIS |
|---|---|---|---|---|
| 0.00 | 0.07071 | 0.07071 | 1.00× | 1.00 — consistent |
| 0.30 | 0.08062 | 0.07071 | 1.14× | 1.30 |
| 0.60 | 0.08944 | 0.07071 | 1.26× | 1.60 |
| 0.90 | 0.09747 | 0.07071 | 1.38× | 1.90 |
| 0.99 | 0.09975 | 0.07071 | 1.41× | 1.99 |
Work the ρ = 0.90 row so you can reproduce it. Numerator: 0.01 × 0.01 × (1 − 0.81) = 1e−4 × 0.19 = 1.9 × 10-5. Denominator: 0.01 + 0.01 − 2 × 0.9 × 0.1 × 0.1 = 0.02 − 0.018 = 0.002. Ratio: 1.9e−5/0.002 = 0.0095, so σf = 0.09747 m. The second sensor bought a 2.5% improvement, not 29%.
Notice the limit: as ρ → 1, σf → σ1. A perfectly correlated second sensor adds exactly zero information, which is obvious once stated and is exactly what a naive fusion refuses to believe. And the failure is silent — the filter reports a shrinking covariance while the error does not shrink. That is the "filter that lies" from Uncertainty, Least Squares & Robust Costs, arriving through a different door.
Two defences, and you should know both. If you can estimate the correlation, use the formula above. If you cannot — which is the usual case, because the shared error sources are exactly the ones you did not model — use covariance intersection:
The weights sum to one instead of both being one, which makes the result consistent for any correlation whatsoever. The price: on our orthogonal 2-D example the optimal-w covariance-intersection result is σ = 0.0706 in both axes against the Kalman answer's 0.0499 — twice the ellipse area. And for two identical sensors, covariance intersection returns σ = 0.10, correctly refusing to claim any improvement at all. Conservative and never wrong, versus optimal and wrong whenever you were mistaken about independence. Being able to state that trade is the answer to "how do you fuse two estimates that might share information".
Error reduction is the boring half. The important half is that some quantities are not observable at all to a given sensor, at any noise level, for any amount of time — and adding a second modality can make them observable. This is a different kind of argument and it is the one that actually justifies most real sensor suites.
| Sensor | Observes directly | Cannot observe, ever, alone |
|---|---|---|
| Monocular camera | Bearing to features; motion up to a scale factor | Metric scale. A doll's house and a real house are the same image |
| IMU | Angular rate, specific force | Position, velocity, heading about gravity — all drift without bound |
| Wheel odometry | Along-track distance, yaw rate | Absolute position; lateral slip; anything after a wheel lifts |
| LiDAR in a corridor | Lateral position, heading | Along-corridor position — the walls look identical |
| Single-antenna GNSS | Absolute position; course while moving | Heading at rest; attitude of any kind |
| Radar | Range, radial velocity | Object extent, lateral position to better than metres |
Now pair them and watch the holes fill. Monocular camera plus IMU is the canonical case, and the mechanism is worth deriving because it is the reason VIO exists at all.
Monocular visual odometry produces a trajectory known only up to an unknown scale s: the camera reports a displacement of, say, 0.05 "visual units" over half a second, and 0.05 units could be five centimetres or five metres.
The accelerometer measures metric specific force. Double-integrate it over the same window and you have a metric displacement. Setting the two equal gives one equation in the one unknown:
With a constant acceleration a = 1.0 m/s2 over T = 0.5 s, the inertial displacement is ½ × 1.0 × 0.25 = 0.125 m. So s = 0.125 / 0.05 = 2.5 metres per visual unit. Scale recovered.
Now the crucial follow-up, which is where the real engineering lives: how well is it recovered, and when does this fail? The IMU's own displacement noise over 0.5 s with a VRW of 0.1 m/s/√hr is N·T1.5/√3 = 0.001667 × 0.3536 / 1.732 = 0.340 mm. Divide that by the inertial displacement and you get the relative uncertainty on the scale:
| Excitation a | Inertial displacement over 0.5 s | Scale uncertainty | Verdict |
|---|---|---|---|
| 1.00 m/s2 | 125.0 mm | 0.27% | Excellent |
| 0.20 m/s2 | 25.0 mm | 1.36% | Usable |
| 0.05 m/s2 | 6.25 mm | 5.4% | Marginal |
| 0.01 m/s2 | 1.25 mm | 27% | Effectively unobservable |
Everything above assumed the sensors are healthy. The last step of the argument is about what happens when they are not, and it is a statement about correlation between failure events rather than between measurement errors.
| Hazard | Camera | Stereo | LiDAR | IMU | Wheel | GNSS | Radar |
|---|---|---|---|---|---|---|---|
| Glass / shrink-wrap | OK | weak | DEAD | OK | OK | OK | weak |
| Darkness | DEAD | DEAD | OK | OK | OK | OK | OK |
| Heavy rain / fog | weak | weak | weak | OK | OK | weak | OK |
| Featureless corridor | weak | DEAD | weak along-track | OK | OK | n/a | weak |
| Tunnel / indoors | OK | OK | OK | OK | OK | DEAD | OK |
| Wheel slip | OK | OK | OK | OK | DEAD | OK | OK |
| Fast rotation | weak (blur, shutter) | weak | weak (distortion) | OK | OK | OK | OK |
| Direct sun into the lens | DEAD | DEAD | weak | OK | OK | OK | OK |
Two rows carry the entire design lesson. Rain and fog degrade camera and LiDAR together — they are both optical, so their failures are correlated, and a camera-plus-LiDAR suite has a single point of environmental failure no matter how carefully you fuse them. That is the whole argument for putting a 77 GHz radar on an outdoor robot: not that radar is good, it is mediocre at almost everything, but that its failures are uncorrelated with the optical ones.
And fast rotation degrades camera and LiDAR together too, for the reasons in Chapters 1 and 3 — both integrate over time, so both smear. The sensor that gets better during fast rotation is the gyro, whose signal-to-noise ratio rises with the signal. That complementarity is not a coincidence; it is why the IMU sits at the centre of every modern estimator.
Two 1σ ellipses and their fusion. Rotate sensor B from aligned to orthogonal and watch the fused area collapse. Then raise the correlation and watch the honest ellipse (dashed) pull away from the one a naive filter would report.
What to look for. At 0° the two ellipses lie on top of each other and the fused one barely shrinks — that is the 5% case. Sweep to 90° and it collapses to a small circle — the 20× case. Now add correlation: the solid fused ellipse (what the filter reports) does not move, while the dashed honest one grows. The gap between them is the lie, and it is invisible from inside the filter.
This is the calculation that turns the whole lesson into a specification, and it is the single most useful thing in this chapter. It combines Chapter 4's drift law with a fusion budget, and it takes twenty seconds.
Between absolute fixes the robot coasts on the IMU, so its error grows as g·bgt3/6. Give the drift a budget — say it may contribute at most 5 cm before the next fix arrives — and invert:
With a consumer gyro at 30°/hr = 1.4544 × 10-4 rad/s, the coefficient g·bg/6 = 9.81 × 1.4544e−4 / 6 = 2.378 × 10-4 m/s3. So Tmax = (0.05 / 2.378e−4)1/3 = (210.3)1/3 = 5.95 s.
| Gyro grade | Coast time for a 5 cm budget | for a 20 cm budget | Minimum aiding rate |
|---|---|---|---|
| Consumer, 30°/hr | 5.95 s | 9.44 s | 0.17 Hz |
| Industrial, 3°/hr | 12.8 s | 20.3 s | 0.08 Hz |
| Tactical, 0.3°/hr | 27.6 s | 43.8 s | 0.04 Hz |
Note the cube root: a 100× improvement in gyro bias buys only a 4.6× longer coast. That is a brutal exchange rate, and it is why almost every real system chooses to buy aiding availability rather than gyro grade. Ten thousand dollars of tactical gyro extends your tunnel from six seconds to twenty-eight; a second modality that works in the tunnel removes the limit entirely.
The steady-state error follows from combining the fix accuracy with the coast: with a 3 cm LiDAR fix and a consumer gyro, at a 1 s aiding interval the drift contributes 0.2 mm and the answer is just the fix accuracy, 3.0 cm; at 5 s the drift is 3.0 cm and the combined error is 4.2 cm; at 10 s the drift is 23.8 cm and it swamps everything. The aiding interval at which drift equals fix accuracy is the design point, and computing it out loud is one of the highest-value thirty seconds you can spend in a system-design round.
The design question is not "do we fuse" but "at what level", and there are three answers with three different cost structures.
| Coupling | What crosses the boundary | Bandwidth | Buys | Costs |
|---|---|---|---|---|
| Loose | Each sensor solves for a pose; the filter fuses poses | ~100 B per sensor per update | Modularity; you can swap a sensor without touching the filter | The per-sensor solutions are correlated with each other and with the filter's own state, and nobody tracks it |
| Tight | Raw measurements — pseudoranges, feature bearings, scan residuals | Feature bearings: 400 × 12 B = 4.8 kB per frame | Works with partial data (three satellites, twenty features), and the correlations are handled correctly | The filter now knows about every sensor's model; changing a sensor changes the filter |
| Deep | Correlator-level signals; the estimator steers the tracking loops | MHz-rate, inside the receiver | Holds lock at signal levels where a normal receiver drops out | Requires access to receiver internals; rarely available outside defence |
The number that decides it. A loosely coupled GNSS fusion needs four satellites to produce a pose; below that it contributes nothing. A tightly coupled one uses every pseudorange it gets, so two satellites in an urban canyon still constrain two directions. On a site with intermittent sky view that is the difference between a usable and an unusable system, and it costs about 4 kB/s of extra plumbing. That trade — tight coupling degrades gracefully, loose coupling degrades cliff-edge — is the design sentence to have ready.
The layer everyone forgets: fault detection and exclusion. Fusion without gating is worse than no fusion, because it launders a bad measurement into a confident state. The architecture that actually ships has three parts:
The degradation ladder is the part that impresses, because it forces you to say what the robot does rather than what it computes. State it in terms of observability: "with LiDAR and camera both down in fog, I still observe along-track motion from the wheels and heading rate from the gyro, but nothing pins absolute position — so the robot may continue at reduced speed along a known route for a bounded distance derived from the drift law, and must stop before the accumulated uncertainty exceeds the aisle clearance." Chapter 4's cubic gives you that bound in metres.
python import numpy as np def fuse_information(means, covs, valid): # The whole of multi-sensor fusion, when the sensors are independent: # add the information matrices, add the information vectors, invert once. n = len(means[0]) Y = np.zeros((n, n)) # information matrix = sum of Sigma_i^-1 y = np.zeros(n) # information vector = sum of Sigma_i^-1 mu_i for mu, S, ok in zip(means, covs, valid): if not ok: # a degraded sensor contributes ZERO, not a guess continue Oi = np.linalg.inv(S) Y += Oi y += Oi @ mu P = np.linalg.inv(Y) return P @ y, P def fuse_ci(S1, S2, w=0.5): # covariance intersection: consistent for ANY unknown correlation, # because the weights sum to one instead of both being one. return np.linalg.inv(w * np.linalg.inv(S1) + (1 - w) * np.linalg.inv(S2))
Three things to say while writing it. "The valid mask is the whole safety story — a degraded sensor must contribute zero information, not inflated information, because inflating a covariance still lets a wrong mean pull the estimate." "I invert once at the end, not per sensor, and in production I would keep the state in information form so there is no inversion in the loop at all." And "if I cannot argue that these sensors are independent, this function is optimistic and I should be using the second one."
Sanity-check the code against the hand arithmetic: with the two orthogonal sensors above, fuse_information returns diag(0.0024938, 0.0024938) and fuse_ci at w = 0.5 returns diag(0.005, 0.005) — exactly twice the area, which is the price of not knowing the correlation.
Symptom. A team adds a second front camera. The reported position sigma drops from 8.0 cm to 5.7 cm exactly as the √2 rule predicts. Measured against motion capture, the RMS error does not move at all — it stays at 8.1 cm.
Root cause. The two cameras sit 12 cm apart behind the same window, on the same bracket, calibrated in the same session against the same target, sharing the same clock. Their errors are correlated at something like ρ = 0.9, so the true fused sigma is 0.0975 — barely better than one camera — while the filter, assuming independence, claims 0.0707.
The metric that reveals it. Log both cameras' innovation sequences against the filter and compute their cross-correlation at lag zero. Independent sensors give a value near zero with a noise floor of 1/√N. Here it will sit near 0.9, and the prediction is quantitative: with ρ = 0.9 the expected NIS is 1.90 rather than 1.00, so a NIS monitor sitting on the fused update reads about twice its nominal value. Two numbers — the cross-correlation and the NIS — and both are computable without ground truth, which means they run on a customer's robot.
The decoy. The obvious wrong diagnosis is "the per-camera covariance is too optimistic; inflate R". That would also raise NIS. It is ruled out by the cross-correlation: an over-tight R inflates each camera's innovations independently and leaves them uncorrelated with each other. Correlation between the two streams is specific to a shared error source, and the fix is different too — move a camera, re-calibrate them separately, or fuse with covariance intersection.
Symptom. LiDAR-only localisation runs at 4 cm RMS. Add GNSS and the fused estimate is 40 cm RMS with occasional 2 m excursions. Somebody proposes weighting GNSS down.
Root cause. An ungated sensor with a heavy-tailed error distribution. In the yard, a few per cent of the GNSS fixes are multipath-biased or wrongly fixed, and because they are handed to the filter with a 1.5 cm covariance, each one is a 100-sigma pull on a state that was previously correct.
The metric that reveals it. Per-sensor NIS, plotted as a histogram rather than a mean. A sensor that is uniformly too confident shows a whole distribution shifted upward — median and p95 both high. A sensor with occasional gross outliers shows a normal median and an enormous p99. Here the GNSS NIS median will be near 1.0 and its p99 in the thousands. That distinction is the diagnosis: median tells you about the noise model, the tail tells you about outliers, and they need different fixes.
Weighting the sensor down globally is the wrong fix and it is worth being able to say why: it discards the 97% of fixes that were correct in order to survive the 3% that were not, so you lose the absolute position information you added GNSS for in the first place. The right fix is a chi-square gate plus the ambiguity-ratio check from Chapter 5 — reject the bad fixes and keep the good ones at their true, tight covariance.
Learned fusion moved from late to intermediate. Liu et al., "BEVFusion: Multi-Task Multi-Sensor Fusion with a Unified Bird's-Eye View Representation" (ICRA 2023), projects camera and LiDAR features into a shared bird's-eye-view space and fuses there, rather than fusing detections after the fact. The property that matters for this chapter: because the fusion happens in a shared representation, the network can use camera evidence where LiDAR is sparse and vice versa — which is the orientation argument from step 2, learned rather than hand-specified. The property that should worry you: nothing in the architecture guarantees graceful behaviour when one modality is degraded rather than absent, and a confidently wrong LiDAR (a mirror) is exactly that case.
Degradation-aware fusion is the open problem. The honest state of the art is that most learned fusion assumes both modalities are healthy, and the robustness work — modality dropout during training, per-modality confidence heads — is active rather than settled. This is a good thing to say when asked about the frontier, because it is true and it shows you can distinguish a benchmark result from a shipped system.
The camera-only argument, and how to handle it. The question of whether a vision-only stack suffices comes up on every team, and the strongest position is neither yes nor no: "Camera-only is defensible when your failure taxonomy shows no hazard that removes a spanning set of the states you need, and when you have the data scale to cover the tail. On a highway with a large fleet that argument can be made. In a warehouse with shrink-wrapped pallets, glass partitions and forklifts, the mirror and transparency failures are confident-wrong rather than absent, and no amount of camera data fixes a physically ambiguous measurement — so I would keep a second modality whose physics differs. What would change my mind is a measured failure rate on the specific hazards, not a benchmark." That answer names the criterion instead of picking a tribe, and criteria are what good decisions are made of.
Everything in the previous six chapters was a claim about one sensor at a time. This chapter puts all seven on one robot, drives it round a loop, and lets you break them one at a time to watch what the suite does about it.
The bench is not decorative. Every degradation factor below comes from a number derived earlier in the lesson, and the fused uncertainty is computed by literally adding information matrices — the arithmetic of Chapter 6, running live. If the picture surprises you, the surprise is in the physics, not in the animation.
The top panel is the warehouse loop from above. The robot drives it continuously. When you enable a hazard, a coloured arc appears on the section of the loop where that hazard lives, and the robot's sensors degrade while it is inside that arc and recover when it leaves.
The bottom panel is the live sensor dashboard. Each bar is one sensor's precision — the reciprocal of its variance — on a logarithmic scale, split into the two directions that matter for a robot in an aisle: along-track (how far down the aisle you are) and cross-track (how far from the shelf you are). The bottom bar is the fused result, computed by summing every healthy sensor's precision in each direction.
The two numbers to watch are on the last row: the fused sigma, and the sigma of the best single sensor at that instant. The whole point of the bench is what happens to the gap between them.
Toggle hazards, watch the bars. The bottom row is the fused estimate. Try enabling rain and darkness together — two hazards, both optical, and the suite loses far more than either alone suggests.
Each sensor contributes a precision in each direction. The nominal values are the ones this lesson computed:
| Sensor | Nominal σ along-track | Nominal σ cross-track | Where the number came from |
|---|---|---|---|
| Camera (visual odometry) | 0.08 m | 0.08 m | Feature-based VO on a textured aisle |
| Stereo depth | 0.15 m | 0.15 m | Ch 2: σZ = Z2/420 at about 8 m |
| LiDAR | 0.03 m | 0.03 m | Ch 3: range noise, after de-skew |
| IMU (over the aiding interval) | 0.60 m | 0.60 m | Ch 4: consumer gyro coasting a few seconds |
| Wheel odometry | 0.25 m | 2.00 m | Ch 5: precise along-track, blind to lateral slip |
| GNSS / RTK | 1.50 m | 1.50 m | Ch 5: yard-grade with partial sky view |
| Radar | 0.50 m | 3.00 m | Ch 5: 15 cm range resolution, 14° angular |
Look at the wheel and radar rows. Both are terrible in one direction and respectable in the other — exactly the anisotropy that Chapter 6 showed is worth twenty times more than raw accuracy. And look at the IMU: 60 cm, the worst absolute number in the table, and it is the sensor that never degrades in any row of the hazard matrix.
The hazards multiply those precisions by the factors below. Each one traces to a mechanism from an earlier chapter.
| Hazard | What it does | Mechanism |
|---|---|---|
| Glass bay | LiDAR ×0.03, stereo ×0.15 | Ch 0: no diffuse backscatter, plus mirror returns; stereo fails on specular highlights |
| Rain | Camera ×0.15, stereo ×0.15, LiDAR ×0.30, GNSS ×0.5 | Ch 3: backscatter and attenuation; Ch 1: droplets on the lens. Radar untouched |
| Tunnel | GNSS ×0 | Ch 5: no sky, no satellites. Everything else unaffected |
| Featureless corridor | LiDAR along-track ×0.02, camera ×0.05, stereo ×0.02 | Ch 2 and Ch 6: the classic corridor degeneracy — identical walls constrain lateral position and say nothing about along-track position |
| Wheel slip | Wheel ×0.02 | Ch 5: the encoders are precise and the model is void |
| Fast rotation | Camera ×0.15, stereo ×0.20, LiDAR ×0.35 | Ch 1 rolling-shutter skew, Ch 3 motion distortion, plus motion blur. Both are time-integrating sensors |
| Darkness | Camera ×0, stereo ×0 | Passive sensors need photons. LiDAR, radar and the IMU do not |
Being explicit about a model's limits is itself an engineering skill, so here are the three simplifications, each of which makes the real world worse than the picture:
That third point is the one to volunteer unprompted. "The fallback is not a floor, it is a countdown whose length I can compute" — one sentence that carries both the drift law and the safety argument.
Sooner or later somebody walks up to your desk and says: "Our AMR localises fine in aisle 3 and loses itself in aisle 7. Aisle 7 has a glass partition. Ten minutes — what do you check and in what order?" Everything in this chapter exists to make the next ninety seconds automatic. Nothing here is new material; it is the six chapters above compressed into the form you need when the clock is real — the three derivations you should be able to do on a blank page, the numbers you can produce from memory, the failure table you walk when the symptom arrives, and the drill that trains the retrieval speed.
The test that separates recall from understanding is getting a result rather than stating it. In sensing there are three such results, and they come up so reliably that they are worth over-learning. Each one is five steps. There are no gaps in what follows — if a step is not obvious, that is the step to rehearse.
Derivation 1 — why stereo depth error is quadratic in range. Draw two cameras and ask: how does depth uncertainty grow?
Once the derivation is down, the follow-up is always numeric: f = 700 px, B = 0.12 m, σd = 0.2 px gives fB/σd = 84/0.2 = 420, so σZ = Z2/420 — 2.4 cm at 3 m, 54 cm at 15 m.
Derivation 2 — why gyro bias produces a cubic position error. The question: your IMU has 1°/hr of bias — how far do you drift in twenty seconds? The trap is to integrate the gyro twice, which gives an angle error, not a position error. The position error comes from gravity.
The invert is the real question: given a position budget σp, the coast time is t = (6σp/(g bg))1/3. Because it is a cube root, a tenfold better gyro buys you only 2.15× more coasting. That single sentence is worth more than any amount of vocabulary about MEMS grades.
Derivation 3 — why precisions (not variances) add when you fuse. Write Σf−1 = Σ1−1 + Σ2−1 and ask where it comes from. Two independent Gaussian measurements of the same state x.
The natural follow-up is "and what breaks it?" — step 2. The product-of-likelihoods step is where independence enters, and it is the only place it enters, which is why correlation silently destroys the gain and why covariance intersection replaces the two unit weights with w and 1 − w that sum to one.
| Concept | The 30-second explanation | Key equation | Tool | Classic paper | Recent work |
|---|---|---|---|---|---|
| Pinhole + distortion | Divide by Z to get a direction, warp it by a polynomial in radius, then scale to pixels. Distortion lives between the divide and K, never after. | xd = x(1 + k1r2 + k2r4 + k3r6) + tangential | OpenCV, Kalibr | Brown, "Decentering Distortion of Lenses" (1966); Zhang, TPAMI 2000 | Schöps et al., "10,000 Parameters > Twelve", CVPR 2020 |
| Undistortion | No closed form above degree 4. Fixed-point iteration: guess the distorted point, divide out the factor, repeat. Roughly one digit per pass. | x(n+1) = xd / (1 + k1r2(n) + …) | cv2.undistortPoints |
Same as above | Kannala-Brandt fisheye for r beyond the contraction limit |
| Rolling shutter | Each row has its own capture time, so the camera projects from a trajectory, not a pose. Rotation shears everything equally; translation shears the foreground more. | t(v) = t0 + v·tr/Nrows (Nrows = image rows, v = row index, tr = readout time); skew = fωtr (px, with f in px and ω in rad/s) | Global-shutter sensors; RS-aware VIO | Ait-Aider et al., ECCV 2006 | Schubert et al., "DSO with Rolling Shutter", ECCV 2018 |
| Stereo depth | Disparity is inversely proportional to depth, so depth error is quadratic in range. Baseline buys far-field precision and costs near-field coverage. | Z = fB/d; σZ = Z2σd/(fB) | OpenCV SGBM; FPGA/ASIC stereo | Hirschmüller, SGM, TPAMI 2008 | Lipson et al., RAFT-Stereo, 3DV 2021; Wen et al., FoundationStereo, CVPR 2025 |
| Matching failure taxonomy | Textureless gives a flat cost curve, repetitive gives several sharp minima, occlusion breaks left-right agreement, specular gives a confident wrong match. | uniqueness = Csecond/Cbest | Uniqueness ratio, L-R check | Scharstein & Szeliski taxonomy, IJCV 2002 | Learned confidence heads |
| LiDAR range budget | Returned power goes as reflectivity over range squared, so maximum range scales as the square root of reflectivity. Datasheets quote 10%. | Pr = PtρArη/(πR2); Rmax ∝ √ρ | Dual return; intensity channel | Standard lidar equation | Behroozpour et al., FMCW architectures, IEEE Comm. Mag. 2017 |
| Motion de-skew | A sweep is a trajectory. Transform each point by the pose at its own firing time, referenced to the sweep END. The sign of dt is where people fail. | pend = exp((ti − tend)ξ∧) pi | IMU-driven de-skew; FAST-LIO | Zhang & Singh, LOAM, RSS 2014 | Dellenbach et al., CT-ICP, ICRA 2022 |
| IMU drift laws | Noise gives t1.5, accelerometer bias gives t2, gyro bias gives t3 because a tilt mis-resolves gravity into a growing horizontal acceleration. | p = g·bgt3/6 (bg in rad/s); σp = VRW·t1.5/√3 (VRW = velocity random walk, units m/s/√s, read off the Allan deviation slope at τ = 1 s) | Allan deviation; ZUPT | Titterton & Weston; IEEE Std 952-1997 | Liu et al., TLIO, RA-L 2020; Cioffi et al., RA-L 2023 |
| Wheel odometry error | Systematic error grows linearly with distance and dominates; slip grows as its square root. A 1% wheel mismatch on a 0.5 m track is 0.2 rad = 11° of heading over 10 m — the track width is half the answer, so never quote the degrees without it. | Δθ = (Ed − 1)s/b (Ed = right/left diameter ratio, s = distance driven in m, b = wheel track in m; result in radians) | UMBmark square test | Borenstein & Feng, IEEE T-RA 1996 | Unchanged — the work moved to slip detection |
| RTK integer ambiguity | Carrier phase is precise but known only modulo 19 cm. A wrongly resolved integer reports "fixed" with 1 cm sigma and is off by a multiple of a wavelength. | λL1 = 0.1903 m; ratio test ≥ 3 | RTKLIB; LAMBDA | Teunissen, LAMBDA, J. Geodesy 1995 | PPP-RTK, multi-constellation network corrections |
| Radar resolutions | Range from beat frequency, velocity from chirp-to-chirp phase, angle from aperture. Range is centimetres; angle is degrees. It is a ranging sensor, not a shape sensor. | ΔR = c/2B (B = swept bandwidth, Hz); Δθ ≈ 2/Nch (Nch = virtual channels; assumes λ/2 element spacing, and the 2/Nch result is in radians) | CFAR; MIMO virtual arrays | Richards, Radar Signal Processing | Barnes & Posner, "Under the Radar", ICRA 2020 |
| Fusion | Precisions add as matrices. Same-direction fusion buys a few percent; orthogonal fusion buys orders of magnitude; correlation silently removes the gain. | Σf−1 = Σ1−1 + Σ2−1 | Information filter; covariance intersection | Julier & Uhlmann, covariance intersection, ACC 1997 | Liu et al., BEVFusion, ICRA 2023 |
| Preintegration | Compress the IMU samples between two keyframes into one relative-motion factor plus a bias Jacobian, so the graph has one node per keyframe rather than per sample. | ΔR, Δv, Δp with first-order bias correction | GTSAM, VINS-Mono | Lupton & Sukkarieh, T-RO 2012 | Forster et al., "On-Manifold Preintegration", T-RO 2017 |
Prompt A: "Specify the sensor suite for a warehouse AMR that must drive 1.2 m aisles at 1.5 m/s and never touch a shelf."
Framework. Start from the requirement, not the catalogue, and do the arithmetic in the open so every step can be audited.
Step 1 — turn the geometry into a sigma. The chassis is 0.70 m wide in a 1.20 m aisle. Clearance per side = (1.20 − 0.70)/2 = 0.50/2 = 0.25 m. That is the distance at which the robot touches a shelf, so it is a 100% failure, not a budget; divide it down to a sigma: budget σ = 0.25/3 = 0.0833 m. Then volunteer the modelling choice before you are asked: we use a 1-D 3σ factor because the constraint is lateral only and the aisle walls are parallel — the along-track error does not touch anything. If you wanted a 2-D 99.7% containment radius instead, the Rayleigh factor is 3.44 rather than 3.00, giving 0.25/3.44 = 0.073 m. That does not change the sensor decision, but it is the correction to volunteer before anyone asks.
Step 2 — go sensor by sensor and ask which delivers 8 cm, and in which direction. LiDAR at 3 cm does, easily, until it meets glass. Cameras deliver it laterally against textured racking and not at all against shrink-wrap in the dark. Wheels deliver continuity and drift systematically. Then ask the second question — what happens when each one fails — and the answer is the hazard matrix: cameras for the shrink-wrapped pallets and the mirror surfaces, wheels and IMU for continuity, no GNSS because there is no sky.
Step 3 — convert the budget into a time, because that is what actually sizes the suite. Inverting derivation 2, the inertial coast that fits inside the budget is t = (6σp/(g·bg))1/3. At consumer gyro grade, bg = 30°/hr = 1.45×10-4 rad/s, so t = (6 × 0.083/(9.81 × 1.45×10-4))1/3 = (0.498/1.423×10-3)1/3 = 3501/3 = 7.0 s. So any hazard that lasts longer than seven seconds needs a survivor — a sensor that still observes lateral position while the others are blind — and the length of the longest glass bay in the building, divided by 1.5 m/s, is the number that decides whether you buy one. Name a number, then name what it is a budget for.
Prompt B: "Design the perception front end. Where does de-skew go and what does it need?"
Framework. Draw the path and defend the ordering. Assembly, then de-skew, then filtering, then registration — de-skew before voxel downsampling, because downsampling a distorted cloud averages points from different poses and bakes the error in. De-skew needs a twist source spanning the sweep, which rules out the estimator's own output; the IMU at 200 Hz is the answer, and 100 ms of gyro integration at consumer bias contributes 0.001°. Put numbers on the latency: 100 ms of sweep accumulation dominates everything else in the chain, which is why sub-sweep slices at 40 Hz are worth considering. Then volunteer the failure: if de-skew is referenced to the wrong end of the sweep, the walls straighten and the pose lags by speed × sweep period.
Prompt C: "Our depth camera works indoors and returns nothing in the yard. Fix it."
Framework. Compute the contrast budget out loud, both sides, with the multiplications shown.
Ambient side. Solar spectral irradiance at 850 nm is about 0.95 W/m2/nm at the ground. A narrowband interference filter in front of the sensor passes 30 nm of it. So the background the sensor integrates is 0.95 × 30 = 28.5 W/m2.
Projector side. Assume 1.0 W of optical output — we assume 1 W because that is roughly the Class-1 eye-safe ceiling for an 850 nm diffuse projector at this aperture, so it is an engineering choice, not a datasheet lookup, and saying so is the point. Spread it over a 60° × 45° field of view at 2 m. The illuminated patch is 2 × 2 × tan(30°) = 2 × 2 × 0.5774 = 2.31 m wide by 2 × 2 × tan(22.5°) = 2 × 2 × 0.4142 = 1.66 m tall, an area of 2.31 × 1.66 = 3.83 m2. Irradiance from the projector is therefore 1.0/3.83 = 0.26 W/m2.
The ratio is the whole answer. 0.26/28.5 = 0.0092 = 0.9% modulation — the signal you are trying to demodulate is under one percent of the light already on the sensor, and it falls as 1/R2 while the sun does not fall at all. Then state that no exposure setting fixes a signal under the background shot noise, and name the only three levers: more projector power (eye safety), a narrower filter (emitter linewidth and thermal drift), or a pulsed projector with a short exposure so the ambient integrates for less time. Finish with the architectural answer: outdoors you want passive stereo with a longer baseline, because sunlight is texture rather than interference for a passive sensor.
Prompt D: "Justify keeping a second modality when a vision-only stack is cheaper."
Framework. Refuse the framing and supply a criterion. The question is whether any single hazard removes a spanning set of your state space. Build the hazard matrix; find the rows where camera and stereo both go to zero — darkness, direct sun, shrink-wrap, glass — and ask what observes position in those rows. If the answer is "the IMU, for about nine seconds", you have your justification, and it is a number rather than an opinion. Then name what would change your mind: a measured per-hazard failure rate on this site, not a benchmark score.
Drill 1 — undistort a pixel with no library.
python # in: u, v float pixels (one point, scalar) # K fx, fy, cx, cy — floats, pixels # dist k1, k2, p1, p2, k3 — floats, dimensionless # out: (x, y) float normalised camera coords, dimensionless (Z = 1 plane) # worked: u,v = (1180, 300), fx = fy = 600, cx = 640, cy = 360, # k1 = -0.28, k2 = 0.09, k3 = p1 = p2 = 0 # xd, yd = (1180-640)/600, (300-360)/600 = (0.9000, -0.1000) # pass 1 -> ( 1.08314, -0.12035) pass 2 -> ( 1.13293, -0.12588) # pass 3 -> ( 1.14195, -0.12688) pass 4 -> ( 1.14328, -0.12703) # pass 5 -> ( 1.14347, -0.12705) converged: x = 1.14350, y = -0.12706 # check: re-apply the forward model to (1.14350, -0.12706) -> (0.9, -0.1) exactly x = xd = (u - cx) / fx; y = yd = (v - cy) / fy for _ in range(10): r2 = x*x + y*y rad = 1 + k1*r2 + k2*r2**2 + k3*r2**3 dx = 2*p1*x*y + p2*(r2 + 2*x*x) dy = p1*(r2 + 2*y*y) + 2*p2*x*y x, y = (xd - dx)/rad, (yd - dy)/rad
What to say while writing: "K comes off first because distortion is defined in normalised coordinates. There is no closed form, so this is a contraction mapping seeded at the distorted point; it gains about a digit per pass and ten is generous. It will stop contracting for a strongly wide-angle lens at the corners, which is when I would switch to Kannala-Brandt."
Drill 2 — de-skew a scan.
python # in: rng, az, t float32[N], N ≈ 24000 points per 100 ms sweep at 10 Hz # rng metres, az radians, t seconds (per-point firing time) # t_end float seconds — timestamp of the LAST firing in the sweep # w float rad/s (yaw rate), v float m/s (body-x speed) # out: X, Y float32[N] metres, expressed in the SWEEP-END body frame # assumes: planar yaw-only rotation, constant twist over the sweep, and v # along body-x (lateral velocity dropped). All three are stated out # loud in review — each is a real error term, not a formality. dt = t - t_end # NEGATIVE — reference is the sweep END th = w * dt px, py = rng*np.cos(az), rng*np.sin(az) X = np.cos(th)*px - np.sin(th)*py + v*dt # small-angle translation Y = np.sin(th)*px + np.cos(th)*py
What to say: "Constant twist is an approximation; in production I interpolate the IMU trajectory and use the exact SE(3) exponential, whose translation term differs from v·dt by about 2 mm at 60°/s over 50 ms. The bit I would flag in review is the sign — a straight wall proves nothing, because the wrong reference frame also produces a straight wall."
Drill 3 — block-match one row with rejection.
python d0 = int(cost.argmin()) second = cost[np.abs(np.arange(len(cost)) - d0) > 2].min() if second < 1.15 * cost[d0]: return np.nan # repetitive texture curv = cost[d0-1] - 2*cost[d0] + cost[d0+1] if curv <= 0: return np.nan # no texture return d0 - 0.5*(cost[d0+1] - cost[d0-1]) / curv # sub-pixel
What to say: "Reject before refining. The two tests measure different failures — the ratio catches repetition, the curvature catches absence of texture — and the sub-pixel parabola matters more than it looks: at 3.5 m one pixel of disparity is 14 cm."
Drill 4 — fuse N heterogeneous estimates.
python Y = np.zeros((n, n)); y = np.zeros(n) for mu, S, ok in zip(means, covs, valid): if not ok: continue # degraded contributes ZERO, not a guess Oi = np.linalg.inv(S); Y += Oi; y += Oi @ mu P = np.linalg.inv(Y); x = P @ y
What to say: "Information adds, so fusion is a sum. One inversion at the end, and in production I keep the state in information form so there is none in the loop. The validity mask is the safety story — a bad sensor must contribute nothing, because inflating its covariance still lets a wrong mean pull the estimate. And if I cannot argue independence, I use covariance intersection and pay twice the ellipse area for consistency."
| Symptom | Root cause | The metric that reveals it | The value that separates it from its decoy |
|---|---|---|---|
| Camera-IMU extrinsic shifts about 1° only while turning at 60°/s | Rolling shutter — the mean feature is half a readout late | Apparent extrinsic offset plotted against |ω| | Slope = tr/2 = 16.7 ms. A mechanical flex correlates with acceleration and shows hysteresis; a shutter artefact is a straight line through the origin |
| Calibration RMS 0.19 px, undistorted image still bows at the corners | Checkerboard never covered the corners; k2/k3 are extrapolating | Corner-count histogram binned by radius; residual binned by radius | Max observed r = 0.42 against an image corner at r = 0.78. Refit with and without k3: unchanged RMS plus a large change in predicted corner warp means unconstrained, not under-modelled |
| Smooth, hole-free depth at 3.1 m in front of a rack that is at 2.3 m | Periodic texture; the matcher locked one period off, consistently | Uniqueness ratio, per pixel | 1.00–1.05 for repetitive structure against 1.5–3.0 for healthy texture. Left-right consistency and smoothness both pass, because they reward agreement |
| Double walls in the map at corners only | Uncorrected motion distortion in the sweep | Plane-fit residual on a known flat wall, scattered against |ω| | Linear through the origin and zero at rest. A calibration error is flat in ω and non-zero at rest; a time offset is also linear, so de-skew the log offline — if a slope remains, it is timing |
| De-skew enabled: walls straighten, localisation error doubles | Referenced to the sweep start instead of the sweep end | Localisation residual plotted in the body frame | A constant offset of speed × sweep period = 0.15 m at 1.5 m/s and 10 Hz. Divide the body-frame error by speed; if you get the sweep period, that is it |
| IMU is 8°/hr on the bench and 40°/hr on the robot | Vibration rectification — nonlinearity converts vibration into DC bias | Bias against RMS vibration; bias against die temperature | Rectification is quadratic in vibration and recovers instantly when the motors stop; self-heating lags by a thermal time constant of tens of seconds |
| Stored IMU calibration works when made, wrong on some boots | Switch-on repeatability, not in-run stability | Histogram of 60-second stationary means across 50 power cycles | Between-boot spread far exceeding within-boot spread. Fix: estimate the bias as a state, seeded from the stored value |
| RTK reports fixed and 1.4 cm; truth says 38 cm | Wrongly resolved integer ambiguity | Error divided by 0.1903 m; the ambiguity ratio at the fix | 1.997 wavelengths and a ratio of 3.1. Multipath ramps and dips C/N0; a lever-arm error is fixed in the body frame; a base-station error is constant across the whole session |
| Loop closes 2.5 m short; inflating Q makes it jitter | Systematic wheel-radius error, not noise | Log-log slope of error against distance driven | Slope 1.0 for a bias, 0.5 for random error. Confirm with UMBmark: clockwise and anticlockwise clusters displaced in opposite directions |
| Second camera shrinks the covariance but not the error | Common-mode correlation through window, bracket, calibration and clock | Lag-zero cross-correlation of the two innovation streams; fused NIS | ρ near 0.9 and NIS near 1.9. An over-tight R would also raise NIS but leaves the two streams uncorrelated |
| Fusion is worse than the best single sensor | An ungated sensor with heavy tails | Per-sensor NIS histogram, not its mean | Normal median with an enormous p99 means outliers — add a gate. A shifted median means the noise model is wrong — fix R |
| Problem | Classical | Modern | When to use which |
|---|---|---|---|
| Camera model | Brown-Conrady, 12 parameters | Dense per-region models; self-calibrating implicit models | Classical unless your residuals show spatial structure rather than white scatter. A 12-parameter model with structured residuals is at its limit, not under-fitted |
| Shutter | Buy global shutter | Continuous-time trajectory in the estimator | Buy global shutter if you can; model it if the sensor is fixed. Modelling costs solver complexity and buys back most of the accuracy |
| Stereo | SGM on FPGA | RAFT-Stereo, FoundationStereo | Classical when you need bounded latency and a failure mode you can characterise; learned when the invalid-pixel fraction is what limits you. The Z2 law is unchanged either way |
| Depth from one camera | Impossible — scale is unobservable | Depth Anything V2, UniDepth | Learned monocular depth is an excellent prior and a poor measurement. Use it to fill holes and order edges; never as sole evidence for a safety decision |
| LiDAR de-skew | Constant twist from the IMU, applied as preprocessing | CT-ICP — the scan is elastic and the de-skew is part of the estimate | Classical when you have a good IMU and a rigid platform. Continuous-time when the motion is aggressive or when the circular dependency on a twist source bothers you |
| LiDAR hardware | Mechanical spinning, 360°, ring structure | Solid-state, non-repetitive patterns; FMCW with per-point velocity | Spinning when you need 360° and ring-based feature code. Solid-state for reliability and cost; expect to rewrite anything that indexes rings. FMCW when moving-object segmentation matters |
| IMU aiding | ZUPT, non-holonomic constraints, hand-tuned R | Learned displacement priors with predicted uncertainty (TLIO) | Classical is auditable and enough for wheeled robots. Learned when the platform has strong motion statistics you cannot express as a constraint |
| Radar | 8-channel, detections only, range and range-rate | 192-channel MIMO imaging with elevation; learned radar odometry | Classical radar as a weather-robust ranging channel. Imaging radar when you need it to contribute geometry — but check Δθ = 2/N before believing a marketing claim |
| Fusion | Loosely coupled EKF on per-sensor poses | Tight coupling; learned BEV fusion | Loose for modularity and swap-ability. Tight when partial data matters — three satellites, twenty features — because tight degrades gracefully and loose degrades cliff-edge. Learned fusion when the sensors are dense and healthy; be sceptical of its behaviour under degraded rather than absent modalities |
| Unknown correlation | Assume independence and hope | Covariance intersection; explicit correlation modelling | Covariance intersection whenever you cannot argue independence. It costs twice the ellipse area and it is never inconsistent, which is the right trade for anything safety-adjacent |
The one book. Paul Groves, Principles of GNSS, Inertial, and Multisensor Integrated Navigation Systems (2nd edition, Artech House, 2013). It is the only book that treats every sensor in this lesson with the same rigour and the same error-model vocabulary, and its chapters on GNSS error sources and INS error propagation are the reference for two of the six chapters here. If you read one thing on sensing, read its error-model chapters.
Five papers, and why each one.
Five repositories, and exactly what to look at.
modules/calib3d/src/undistort.dispatch.cpp. Look at the fixed-point loop in undistortPoints, count the iterations, and find the divergence guard. Then compare it with the code you wrote in Chapter 1's lab.include/IMU_Processing.hpp. Look at the backward-propagation de-skew: how the per-point timestamps are used, and which end of the sweep is the reference. This is Chapter 3's lab in production form.allantools) — look at oadev and understand why the overlapping estimator has lower variance than the non-overlapping one for the same data. Then run it on two hours from your own IMU.src/lambda.c and src/rtkpos.c. Look at where the ratio test is computed and where the threshold is applied. That single constant is the difference between the RTK failure in Chapter 5 shipping and being caught.One significant figure, out loud, in under ten seconds each. These are the numbers that settle design arguments before anyone opens a datasheet.
| # | Question | Working | Answer |
|---|---|---|---|
| 1 | A LiDAR is rated 100 m at 10% reflectivity. Range against 2.5% black paint? | R ∝ √ρ: 100 × √(0.025/0.10) = 100 × 0.5 | 50 m |
| 2 | Stereo f = 700, B = 0.12, σd = 0.2 px. Depth sigma at 15 m? | Z2/420 = 225/420 | 0.54 m |
| 3 | 10 Hz LiDAR at 2 m/s. Translation smear across one sweep? | 0.1 s × 2 m/s | 20 cm |
| 4 | Rolling shutter, f = 800 px, tr = 20 ms, yaw 90°/s. Skew? | fωtr = 800 × 1.571 × 0.020 | 25 px |
| 5 | Gyro bias 1°/hr, coast 20 s. Position error? | g bgt3/6 = 9.81 × 4.85e−6 × 8000/6 | 6 cm |
| 6 | An RTK fix is 0.57 m out. How many L1 wavelengths? | 0.57 / 0.1903 | 3.0 — a wrong integer |
| 7 | Radar with 16 virtual channels. Cross-range resolution at 40 m? | Δθ = 2/16 = 0.125 rad; × 40 | 5 m |
| 8 | 1% wheel-radius mismatch, track 0.4 m, over 5 m of driving. Heading error? | 0.01 × 5 / 0.4 = 0.125 rad | 7° |
| 9 | Two independent estimates, both σ = 0.2 m. Fused sigma? | 0.2/√2 | 0.14 m |
| 10 | SGM cost volume, 1920 × 1080 × 192 disparities at 1 byte? | 1920 × 1080 × 192 | 400 MB |
| 11 | 1920 × 1080 mono8 at 60 Hz. Link bandwidth? | 2.07 MB × 60 | 124 MB/s |
| 12 | Indirect ToF at 60 MHz modulation. Unambiguous range? | c/(2f) = 3e8/1.2e8 | 2.5 m |
| 13 | Consumer gyro, 30°/hr. Coast time for a 5 cm budget? | (6 × 0.05/(9.81 × 1.45e−4))1/3 | 6 s |
| 14 | Two identical sensors, σ = 0.1 m, correlated at ρ = 0.9. True fused sigma? | √(1e−4 × 0.19 / 0.002) | 0.097 m — almost no gain |
| 15 | Beam divergence 3 mrad. Spot size at 60 m? | 0.003 × 60 | 18 cm |
Want to test the retrieval speed for real? The Studio button at the top of this lesson runs a timed practice session on exactly this material.
"What I cannot create, I do not understand."
— and what I cannot break, I do not trust.
Bridges from this lesson: Camera Models & Calibration · Sensors: LiDAR, IMU & ICP · IMU & Inertial Navigation · INS/GNSS Coupling · Time & Sync · Uncertainty & Robust Costs · Modern VIO