Probabilistic Sensor Models
Where p(z | x, m) comes from — the four causes behind a range reading, how to learn their weights from data with EM, why likelihood fields beat physics inside a filter, and what multiplying hundreds of "independent" beams really costs.
Herein lies a key advantage of probabilistic techniques over classical robotics: in practice, we can get away with extremely crude models.
In this chapter
Chapter 9 built the half of the generative story that makes the robot less certain. This chapter builds the half that makes it more certain: the measurement model , the function that turns a laser scan into an opinion about where the robot is.
Sensing fails differently from moving. Wheel slip is a small perturbation of a mostly-correct answer, which is why one Gaussian per motion leg is a defensible model. A LiDAR beam does not perturb — it switches. It either measures the wall the map predicts, or it measures the person standing in front of the wall, or it measures nothing at all because the wall was black felt, or it returns a number that came from a neighbouring transducer's echo. Four categorically different things happen, and a model that averages them is a model of nothing.
So the chapter has two aha moments and they pull in opposite directions. The first is that a mixture of four dead-simple densities reproduces the messy histogram a real sensor produces, and that its parameters can be learned from data instead of guessed. The second is that the likelihood you then compute is almost always far too confident, because multiplying hundreds of "independent" beams counts the same evidence over and over. Managing that overconfidence turns out to matter more than the fidelity of the model that caused it.
The problem: one pose, ten thousand beams
Park Rusty in the corridor outside room A and aim a single beam south, through the doorway, at the far wall of the apartment. The map says the range is 4.4 m. Fire the beam ten thousand times and histogram what comes back.
Nothing about that shape is a bell curve, and the deviations are not small.
There is a hump where the map says there should be one, and it is roughly Gaussian, roughly 11 cm wide. That is the part of the sensor everyone models: timing resolution, surface roughness, beam divergence. It is also, in an empty corridor, only about 86% of the mass — and with people walking through, 70%.
There is a shoulder in front of the wall. Every reading in it is shorter than the map predicts, never longer, because the only thing that can produce it is an object the map does not contain — and objects the map does not contain occlude, they do not become transparent. The asymmetry is physics, not convention.
There is a spike piled up at and a thin floor everywhere else. The spike is beams that hit nothing detectable: black upholstery, glass, a surface angled so the light left and never came back. The floor is everything else — crosstalk, multipath, a stray reflection off a passing elbow — and it is uniform because we have nothing better to say about it.
Building intuition
Four causes, and the hardware that makes them
The four components are not a curve-fitting convenience. Each is a distinct failure mode of a real device, and knowing which is which is what lets you predict how the weights will move when you change rooms or change sensors.
| Component | What it is | Ultrasonic ranger | 2-D LiDAR |
|---|---|---|---|
| Correct return, corrupted | Cone width, speed-of-sound drift with temperature | Time-of-flight quantization, surface roughness, beam divergence | |
| Something closer than the map | People, chair legs, and specular pre-returns off an angled surface | People, chair legs, an open door the map has closed | |
| No return at all | Specular reflection away from the transducer; absorptive foam | Black or retro-dark surfaces, glass, grazing incidence, beyond range | |
| A number with no cause | Crosstalk between transducers in a ring — sound is slow, echoes linger | Multipath, sunlight, interference from another robot's LiDAR |
Two entries deserve emphasis because they are the ones people get backwards. Ultrasonic sensors produce and for the same reason — a smooth wall at an angle acts as a mirror — and whether you get a short reading or no reading depends on what the redirected pulse eventually hits. And in a sonar ring is dominated by crosstalk, which is why the component's weight depends on how many transducers you fire at once, a fact no amount of staring at one beam will reveal.
Where the exponential comes from
The shoulder's exponential shape is usually presented as an assumption. It is not: it is a consequence, and the derivation takes two lines.
Model unmodelled objects as a homogeneous Poisson process of rate along the ray — "clutter is scattered along the beam at some objects-per-metre, independently of where the wall is." The reading is short exactly when the first clutter object lies before the wall, and the distance to the first point of a Poisson process satisfies
Condition on the reading actually being short — that is, on the first clutter point lying before the wall, an event of probability — and you get with its normalizer already attached. The widget above generates its shoulder by literally sampling that Poisson field, so the weight it recovers is the true probability of clutter, , to within a percent. Its estimate of itself is a different story, and Step 6 of the EM derivation below explains exactly why.
The mathematics
The scan, and the assumption that makes it tractable
| Symbol | Meaning |
|---|---|
| A scan: K individual range readings taken at one time step. | |
| The true range of beam k — ray cast into the map m from the hypothesised pose. | |
| The sensor's maximum range. A reading equal to it is a *failure*, not a measurement. | |
| The intrinsic parameters: four mixture weights on the simplex, plus two shape parameters. | |
| Distance from a point to the nearest occupied cell of m — the likelihood field. | |
| A feature: range, bearing, and signature of one detected landmark. | |
| The correspondence variable: which map landmark feature i came from, or none. | |
| Feature measurement noise. A Q, not an R — measurement noise, per Chapter 6. | |
| Tempering exponent: the model is used as p^{1/κ}. κ = 1 is the raw product. |
A scan is a set of readings, and the model of the whole scan is the product of the models of its parts:
This is a conditional independence assumption — that given the pose and the map, the beams do not share anything. It is false. Thrun says so on the page where he introduces it, and then defers the consequences; we will not defer them, because they are the difference between a filter that works and one that is confidently lost. The last section of this chapter measures the damage.
The beam model
Per beam, four causes, four densities, four weights that sum to one:
with, writing for the ray-cast range and for the indicator of a condition,
Three details that a first implementation usually gets wrong, and that the book's unit tests pin:
- Both truncations carry a normalizer. lives on and on . Drop and the model quietly prefers hypotheses with short true ranges, because a shorter interval concentrates the same unit of mass. Exercise 1 makes you find what breaks.
- is a point mass, not a density. It integrates to 1 by itself. Mixing a point mass and three densities in one equation is a mild abuse that everybody commits; in code it means the max-range case is a branch, not a formula.
- depends on the hypothesis. Every candidate pose needs its own ray cast, which is what makes this model expensive and what the likelihood field will eliminate.
- In
- a scan of K ranges, a hypothesised pose, the map
- Out
- q = p(z_t | x_t, m)
- for to do
- compute for the measurement using ray casting into from
- endfor
- return
In practice line 5 is a sum of logs. Per-beam likelihoods of order underflow f64 at around
600 beams, and a hypothesis that is merely wrong — per-beam likelihoods of — underflows
after 100. More importantly, the log form is the one tempering acts on linearly, which the last
section needs.
Learning instead of guessing it
The six intrinsic parameters are usually hand-tuned, and hand-tuning is defensible — the epigraph to this chapter is the reason. But there is no need. Given a log of range readings taken from known poses in a known map, the parameters that maximize can be computed, because the only thing standing in the way is the unobserved cause of each beam, and that is exactly what EM handles.
DerivationEM for the beam model's intrinsic parameters
Step 1 — name the missing data. Attach to each reading a latent label saying which cause produced it. With the labels known, the log-likelihood separates completely:
Each component's parameters appear in exactly one term, so with labels this would be four independent one-line maximum-likelihood problems. That is the entire reason EM is worth doing here.
Step 2 — E-step: replace the labels by their posteriors. The labels are unknown, so take the expectation of Step 1 over given the current . The required posterior is Bayes rule on a four-way discrete variable:
The denominator is the mixture likelihood of the reading — the same number beam_range_finder_model
computes per beam. So the E-step costs one extra pass over data you were evaluating anyway.
Step 3 — M-step for the weights. Maximizing subject to with a Lagrange multiplier gives , so , and the constraint fixes the constant because :
The weights are just the average responsibility. Nothing about this step knows what the components are.
Step 4 — M-step for . Only the term contains . Dropping the truncation normalizer for the moment, the objective is the usual weighted Gaussian log-likelihood in the residuals , whose maximizer is the responsibility-weighted second moment:
Step 5 — M-step for . Likewise, is maximized at the reciprocal of the responsibility-weighted mean:
Iterate Steps 2–5. Each iteration is once the are cached, and the data log-likelihood never decreases — which makes a dip in the trace a bug report, not a result.
Step 6 — the bias nobody mentions. Step 5 is the maximum-likelihood estimator for an untruncated exponential, but is truncated at . For truncated data the sample mean is smaller than ,
so the estimator converges to something larger than the truth, and the error blows up when is small. Measured on 40 000 synthetic beams generated with and no random component in the data: at m the formula above predicts a limit of and EM returns ; at m it predicts and EM returns . The fix is one Newton step on the truncated likelihood, and the reason we implement Table 6.2 unmodified anyway is that is the parameter the posterior is least sensitive to — but you should know that the number EM hands you is not the clutter density.
- In
- ranges Z with the poses X they were taken from, and the map
- Out
- Θ = (z_hit, z_short, z_max, z_rand, σ_hit, λ_short)
- repeat
- for all do
- , and likewise
- endfor
- , and likewise
- until convergence
- return
The widget at the top of this chapter runs exactly this, on data generated by geometry rather than by the model being fitted. With the corridor quiet, EM recovers mixture weights of against a generator whose true weights are and m against a true m. Turn on "people in the corridor" and it returns against a truth of — triples, does not move, and a little short mass leaks into because a clutter return far from the wall looks exactly like a random one. Twenty-four iterations over 4 500 beams take about 50 ms.
Likelihood fields
The beam model has a physical story and two practical problems, and Thrun states both plainly: it is unsmooth in , and it is expensive. Both come from the same place — the ray cast on line 3.
Unsmoothness is the serious one. Move a hypothesis 2 cm sideways and a beam that was grazing a door frame now slips past it; jumps from 0.8 m to 6 m; that beam's likelihood collapses from "excellent" to . The pose-likelihood surface is therefore full of cliffs, and everything that has to search it — a particle filter with a finite number of particles, a hill climber, an optimizer — suffers.
The likelihood field is the fix, and it is worth being honest about what kind of fix it is. It is not a better model of the sensor. It is not a generative model of at all — it does not normalize over , and it cannot tell you what reading to expect. It is a scoring function that happens to be smooth, cheap, and empirically excellent.
The construction is: project each reading to the point in the world it claims to have hit,
The middle bracket is the sensor's mounting offset rotated into the world frame — applied to — and the last term walks the measured distance along the beam's own bearing. Then ask only how far that point is from the nearest obstacle — any obstacle, no correspondence, no ray:
Max-range readings are skipped outright. "I saw nothing" projects to a point 8 m away that means nothing at all, and scoring it would punish poses for a beam that carries no information.
- In
- a scan, a pose, the map (as a precomputed distance field)
- Out
- q, a smooth score — not a normalized density over z
- for all do
- if then
- endif
- endfor
- return
Line 6 is a nearest-neighbour search over every occupied cell, which sounds catastrophic and is free, because it does not depend on the scan. Compute it once for every cell of the map and the per-beam cost becomes an array lookup. That precomputation is the distance transform, and the modern algorithm for it is exact and linear.
DerivationThe exact Euclidean distance transform in O(N)
Step 1 — write it as a minimization over a sampled function. Let at occupied cells and elsewhere. Then the squared distance transform is
which is a min-plus convolution of with a parabola. Nothing about it requires to be binary, which is why the same routine also serves as the max-product message pass in belief propagation and the inner loop of a chamfer matcher.
Step 2 — separate the axes. Squared Euclidean distance is a sum over coordinates, so the minimization factors:
A 2-D transform is a 1-D transform down every column followed by a 1-D transform along every row. Separability is exactly what a chamfer sweep gives up: it propagates costs along an 8-neighbour mask, so its error accumulates with distance from the nearest obstacle, while the separable form is exact at every cell no matter how far away the nearest obstacle is.
Step 3 — the 1-D transform is a lower envelope of parabolas. Each sample contributes the parabola . All of them have the same curvature, so any two intersect exactly once, and the lower envelope of of them has at most pieces in left-to-right order. Sweep the samples left to right maintaining a stack of the parabolas currently on the envelope and the breakpoints between them: a new parabola either takes over the rightmost region — push it — or it dominates the parabola on top, in which case pop and retry. Each parabola is pushed once and popped at most once, so the sweep is , and a second pass reads the envelope off at each grid position. Two passes per axis, overall, and the result is exact — Felzenszwalb and Huttenlocher's algorithm computes the true lattice minimum, not an approximation of it.
Step 4 — what remains inexact, measured. The transform is exact; the map is not. Rasterizing a wall onto a grid moves it by up to half a cell diagonal, and that error passes straight through: on the Apartment at a 5 cm cell, the exact transform of the rasterized map differs from the true continuous distance-to-wall by at most cm and on average 2.5 cm — and both numbers halve when the cell does (1.8 cm and 1.2 cm at 2.5 cm). Meanwhile the whole field builds in about 1.5 ms, the same as the chamfer sweep it replaces. So the sensible engineering statement is: never pay for an approximate transform, and spend your accuracy budget on the grid resolution instead.
The likelihood field's blind spot is that it cannot tell "the beam went through a wall" from "the beam stopped at a wall". Only the endpoint is scored, so a pose that puts a 6 m reading's endpoint onto a wall in the next room is rewarded exactly as much as the correct one. Thrun's fix — score only endpoints whose ray stays inside known free space — costs a ray cast and gives back the unsmoothness that the field existed to remove, so almost nobody does it; the practical mitigation is to keep max-range and implausibly long readings out of the field entirely.
Map correlation, in one paragraph
There is a third way to score a scan that neither ray-casts nor looks up distances: turn the scan into a little local map and correlate it with the global one. With the mean cell value over both maps,
It is the odd one out: clipping a correlation coefficient at zero and calling it a probability is not derivable from anything, and the score is invariant to a global brightness shift in a way a likelihood should not be. It earns its place because it degrades gracefully when the map is partly wrong, and because its descendant — correlative scan matching over a discretized pose grid — is the workhorse of Chapter 16.
Features, landmarks, and the variable that will become the villain
Sometimes the front end does not hand you 180 ranges but a handful of detections: "a corner at 3.2 m, bearing rad, signature 7". Feature extraction itself — line fitting, corner detection, blob detection — is a perception topic and we let Chapter 18 have it. What matters here is the model of the detection, and the bookkeeping variable that comes with it.
That variable is the correspondence : which landmark in the map produced feature . Give it to the model and everything is easy. Withhold it and you have the data-association problem that Chapter 11 spends half its length on and that breaks more SLAM systems than any other single cause. Introducing one chapter early, while it is still harmless, is deliberate.
With the correspondence known, , the model is a noisy polar observation of a known point:
- In
- a feature (r, φ, s), its known identity, the pose, the map
- Out
- q = p(f_t^i | c_t^i, x_t, m)
- return
The bearing residual must be wrapped into before it is squared. A predicted and an observed are two degrees apart, and a model that thinks they are 358 degrees apart will reject the correct landmark and then confidently accept the wrong one — the failure Chapter 3 built the wrap-around machinery to prevent.
Because a feature has fewer degrees of freedom than a pose, the model can be run backwards.
DerivationSampling poses from one landmark observation
Step 1 — invert Bayes rule under a flat prior. We want . Bayes gives , and if the pose prior is uniform the second factor is a constant. What is left is the measurement model, read as a function of the pose.
Step 2 — count the constraints. The reading supplies two numbers, ; the pose has three. One degree of freedom is therefore unconstrained by construction, and no cleverness will recover it. Geometrically: the range confines the robot to a circle of radius about the landmark, and the bearing then fixes the heading given where on that circle it sits.
Step 3 — sample the free parameter, then the noise. Draw the angular position of the robot around the landmark uniformly, , and perturb the observation by its own noise, , . Perturbing the observation rather than the prediction is legitimate because a Gaussian is symmetric in its two arguments — the same trick that makes Chapter 9's odometry sampler a three-liner.
Step 4 — place the pose. Sitting at angle around the landmark puts the robot at , from which the landmark bears in world coordinates. The heading that makes the landmark appear at relative bearing is therefore , which Table 6.5 writes as — the same angle, and worth checking rather than trusting.
Step 5 — the caveat. The prior is not uniform in reality: poses inside walls are impossible, and the robot was somewhere plausible a moment ago. Everything this sampler produces must therefore be treated as a proposal to be reweighted, which is exactly how Chapter 12 uses it in the mixture-MCL proposal that lets a kidnapped robot recover in one step.
- In
- a feature and its known identity, the map
- Out
- a pose x_t drawn from p(x_t | f_t^i, c_t^i, m) under a uniform pose prior
- return
The counting argument is worth stating once, because it governs every feature-based estimator in Part V. Each detection supplies two numbers about the pose — a range and a bearing — and the pose has three unknowns. One detection therefore leaves a one-parameter family of explanations; two detections give four constraints on three unknowns and the family collapses to a blob whose size is pure measurement noise, about 20 cm across for m. Adding a third detection buys you not localization but redundancy — which is exactly what Chapter 11 needs, because redundancy is the only thing that can catch a wrong correspondence.
The lie of independence
Now the part that the baseline defers and that decides whether your filter survives contact with a real building.
says the beams are independent given the pose. They are not, and the dependence is not subtle: a person standing in front of the robot corrupts twenty adjacent beams at once, and a map that is 2 cm off corrupts every beam in the same direction, forever.
DerivationWhat false independence costs, and the three ways to pay it back
Step 1 — the extreme case. Suppose two beams are perfectly correlated: . The honest likelihood of the pair is — the second reading adds nothing. The product rule reports . Every likelihood ratio between two hypotheses is therefore squared, so the posterior odds are squared, and a hypothesis that was twice as likely becomes four times as likely on no new evidence at all.
Step 2 — the general case. If a scan of beams carries only beams' worth of independent information, the product overstates every log-odds by the factor . The log-likelihood grows linearly in whether or not the information does: for genuinely independent measurements of the same quantity the posterior width would fall like , and the table below falls faster still, because each new beam also brings geometry the earlier ones missed. What matters is that the width is driven by and is completely blind to whether the map is right. The result is not a wrong estimate; it is a certain estimate, and a filter that is certain stops listening.
Step 3 — measured, on the Apartment. Take the corridor pose in the widget above and a map that is 1% too wide, which is the kind of defect an unlucky wheel-radius calibration bakes into a SLAM-built map. The posterior over the along-corridor coordinate behaves like this:
| beams | , correct map | , 1% error | peak offset, 1% error | truth inside the 95% set, 1% error |
|---|---|---|---|---|
| 4 | 7.97 cm | 7.86 cm | −7.0 cm | yes |
| 9 | 3.96 cm | 4.00 cm | −6.5 cm | no |
| 30 | 1.56 cm | 1.57 cm | −3.5 cm | no |
| 90 | 0.96 cm | 0.73 cm | −4.5 cm | no |
| 180 | 0.41 cm | 0.68 cm | −4.0 cm | no |
With a perfect map the truth stays inside the interval at every . With a 1% map error, the estimate is stuck about 4 cm off and the claimed uncertainty keeps shrinking around the wrong answer: at the truth is roughly six standard deviations outside the filter's own belief. The lag-1 correlation of the beam residuals tells you which regime you are in without knowing the truth — it is with the correct map and with the wrong one.
Step 4 — the three repairs are one repair. Subsample every -th beam; inflate ; or temper,
All three flatten the likelihood surface; they differ in what else they do. Subsampling throws away of the data, so the peak moves as well as widening — measured on the same scan, tempering gives cm with the peak where the full scan put it ( cm), while subsampling to 18 beams gives a comparable cm but drags the peak to cm and still fails to cover the truth. Inflating widens each beam's tolerance but also changes which beams count as outliers, which is a different intervention wearing the same coat. Tempering is the cleanest: one scalar, no data discarded, and it acts linearly on the log-likelihood you already compute.
Step 5 — where comes from. It is not free. Tempering has a respectable statistical
home — a generalized Bayesian update with a scaled loss, in the sense of Bissiri, Holmes and
Walker — but the theory does not hand you the number. Calibrate it: run the filter on logged data
with ground truth and choose the smallest whose 95% credible sets actually contain the
truth 95% of the time. In the experiment above that is for a 1% map error and
for a perfect one, and if that sounds like a lot of tuning, note that AMCL's
laser_max_beams parameter is the same knob with worse manners.
Implementation in Rust
The sensor crate exports one trait, the range-finder models that implement it, and one model that
deliberately does not — a feature detection is not a scan, and pretending otherwise would put a
Vec<f64> where a (r, φ, s) belongs. All of it is consumed unchanged by
Chapter 11 and
Chapter 12, so the interface matters more than any of the
models behind it.
use nalgebra::{Matrix3, Point2};
use pr_core::geom::se2::SE2;
use sim::World; // Chapter 4's polyline world *is* the map, until Chapter 13
/// One sweep of a range finder: ranges plus the bearings they were taken at.
///
/// Bearings are stored, not assumed. A sub-sampled scan is still a valid `Scan`,
/// which is what makes tempering-by-subsampling a two-line operation instead of
/// an index-arithmetic bug farm.
#[derive(Clone, Debug)]
pub struct Scan {
pub ranges: Vec<f64>,
/// Bearings relative to the robot's heading, radians.
pub bearings: Vec<f64>,
pub max_range: f64,
}
/// Anything Chapters 11–12 can localize with.
///
/// Log-likelihood, not likelihood: the product for any hypothesis that is even
/// slightly wrong underflows `f64` within a hundred beams, and tempering is a
/// single multiply in log space.
pub trait SensorModel {
fn log_likelihood(&self, scan: &Scan, x: &SE2, map: &World) -> f64;
/// Cheap enough to call per particle per step? Chapter 12 branches on it.
fn is_cheap(&self) -> bool {
false
}
}
/// Θ — the six intrinsic parameters of the beam model.
///
/// The four weights are *not* a `[f64; 4]`: naming them stops the classic
/// transposition bug where `z_short` and `z_max` get swapped and the model
/// starts believing every dropout is a person.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BeamIntrinsics {
pub z_hit: f64,
pub z_short: f64,
pub z_max: f64,
pub z_rand: f64,
pub sigma_hit: f64,
pub lambda_short: f64,
pub max_range: f64,
}
impl BeamIntrinsics {
/// The weights must live on the simplex; everything downstream assumes it.
pub fn is_normalized(&self) -> bool {
(self.z_hit + self.z_short + self.z_max + self.z_rand - 1.0).abs() < 1e-9
}
}
/// A range-bearing-signature detection, Thrun's f = (r, φ, s).
#[derive(Clone, Copy, Debug)]
pub struct Feature {
pub r: f64,
pub phi: f64,
pub s: f64,
}
/// The map's view of a landmark. `Q` is the feature noise covariance Q_t.
#[derive(Clone, Copy, Debug)]
pub struct Landmark {
pub xy: Point2<f64>,
pub signature: f64,
}
pub type FeatureNoise = Matrix3<f64>;The beam model itself is a direct transcription of Table 6.1, with the two truncation normalizers spelled out because they are what a reader will otherwise skip.
use crate::{BeamIntrinsics, Scan, SensorModel};
use pr_core::geom::se2::SE2;
use sim::{ray_cast, World};
use statrs::distribution::{ContinuousCDF, Normal};
pub struct BeamModel {
pub intr: BeamIntrinsics,
/// Use every `stride`-th beam. See `tempering.rs` for why this is a lie
/// that is sometimes worth telling.
pub stride: usize,
}
/// Within this of `max_range`, a reading *is* a max-range reading. Floating
/// point equality against a sensor's advertised limit is not a plan.
const MAX_EPS: f64 = 1e-9;
impl BeamModel {
/// p(z | x, m) for one beam — Table 6.1, lines 4–7.
pub fn beam_likelihood(&self, z: f64, z_star: f64) -> f64 {
let i = &self.intr;
// p_hit: a Gaussian truncated to [0, z_max]. η is the reciprocal of the
// mass that survives truncation; without it, hypotheses whose predicted
// range sits near a boundary are silently favoured.
let p_hit = if (0.0..=i.max_range).contains(&z) {
let n = Normal::new(z_star, i.sigma_hit).expect("sigma_hit > 0");
let mass = n.cdf(i.max_range) - n.cdf(0.0);
if mass > 1e-12 {
let d = (z - z_star) / i.sigma_hit;
(-0.5 * d * d).exp() / (i.sigma_hit * (2.0 * std::f64::consts::PI).sqrt()) / mass
} else {
0.0
}
} else {
0.0
};
// p_short: the nearest hit of a Poisson clutter field of rate λ along
// the ray, truncated at the wall.
let p_short = if z >= 0.0 && z <= z_star && z_star > 0.0 {
let eta = 1.0 / (1.0 - (-i.lambda_short * z_star).exp());
eta * i.lambda_short * (-i.lambda_short * z).exp()
} else {
0.0
};
// p_max is a point mass: it integrates to 1 on its own.
let p_max = if z >= i.max_range - MAX_EPS { 1.0 } else { 0.0 };
let p_rand = if z >= 0.0 && z < i.max_range { 1.0 / i.max_range } else { 0.0 };
i.z_hit * p_hit + i.z_short * p_short + i.z_max * p_max + i.z_rand * p_rand
}
/// The four *unweighted* component densities, for the EM E-step.
pub fn components(&self, z: f64, z_star: f64) -> [f64; 4] {
let one_hot = |c: usize| {
let mut i = self.intr;
i.z_hit = (c == 0) as u8 as f64;
i.z_short = (c == 1) as u8 as f64;
i.z_max = (c == 2) as u8 as f64;
i.z_rand = (c == 3) as u8 as f64;
BeamModel { intr: i, stride: 1 }.beam_likelihood(z, z_star)
};
[one_hot(0), one_hot(1), one_hot(2), one_hot(3)]
}
}
impl SensorModel for BeamModel {
/// `beam_range_finder_model` — Table 6.1, in log space.
fn log_likelihood(&self, scan: &Scan, x: &SE2, map: &World) -> f64 {
let mut q = 0.0;
for k in (0..scan.ranges.len()).step_by(self.stride.max(1)) {
let z_star = ray_cast(map, x.x(), x.y(), x.theta() + scan.bearings[k], scan.max_range);
q += self.beam_likelihood(scan.ranges[k], z_star).max(1e-300).ln();
}
q
}
}
/// `learn_intrinsic_parameters` — Table 6.2.
///
/// Takes ranges paired with the ray-cast range each *should* have had. Caching
/// `z_star` outside the loop is what turns EM from "one ray cast per beam per
/// iteration" into "one ray cast per beam, ever".
pub fn learn_intrinsics(
z: &[f64],
z_star: &[f64],
init: BeamIntrinsics,
iters: usize,
) -> (BeamIntrinsics, Vec<f64>) {
let mut intr = init;
let mut trace = Vec::with_capacity(iters);
for _ in 0..iters {
let model = BeamModel { intr, stride: 1 };
let (mut s_hit, mut s_short, mut s_max, mut s_rand) = (0.0, 0.0, 0.0, 0.0);
let (mut sq_hit, mut z_short_sum, mut ll) = (0.0, 0.0, 0.0);
for (&zi, &si) in z.iter().zip(z_star) {
let c = model.components(zi, si);
let w = [intr.z_hit * c[0], intr.z_short * c[1], intr.z_max * c[2], intr.z_rand * c[3]];
let total: f64 = w.iter().sum();
if total <= 0.0 {
continue; // no cause explains this reading; refuse to guess
}
let e = [w[0] / total, w[1] / total, w[2] / total, w[3] / total];
s_hit += e[0];
s_short += e[1];
s_max += e[2];
s_rand += e[3];
sq_hit += e[0] * (zi - si).powi(2);
z_short_sum += e[1] * zi;
ll += total.max(1e-300).ln();
}
trace.push(ll / z.len() as f64);
let n = z.len() as f64;
intr = BeamIntrinsics {
z_hit: s_hit / n,
z_short: s_short / n,
z_max: s_max / n,
z_rand: s_rand / n,
// A component with no responsibility keeps its shape parameter
// rather than recomputing it from an empty sum.
sigma_hit: if s_hit > 1e-9 { (sq_hit / s_hit).sqrt().max(1e-3) } else { intr.sigma_hit },
lambda_short: if z_short_sum > 1e-9 {
(s_short / z_short_sum).clamp(1e-3, 50.0)
} else {
intr.lambda_short
},
..intr
};
}
(intr, trace)
}The likelihood field is two files' worth of idea in one: the distance transform, then the lookup.
use crate::{BeamIntrinsics, Scan, SensorModel};
use pr_core::geom::se2::SE2;
use sim::World;
/// Distance to the nearest occupied cell, in metres, on a regular grid.
pub struct DistanceField {
cells: Vec<f32>,
nx: usize,
ny: usize,
res: f64,
origin: (f64, f64),
}
/// Stands in for +∞ with a real number, so the envelope arithmetic below never
/// evaluates ∞ − ∞.
const INF: f64 = 1e20;
/// Felzenszwalb–Huttenlocher, Figure 1: the lower envelope of the parabolas
/// z ↦ (z − q)² + f(q). All parabolas share a curvature, so any two meet
/// exactly once and the envelope can be built by one left-to-right sweep.
fn dt_1d(f: &[f64], out: &mut [f64]) {
let n = f.len();
let mut v = vec![0usize; n]; // parabola indices currently on the envelope
let mut z = vec![0.0f64; n + 1]; // breakpoints between them
let mut k = 0;
v[0] = 0;
z[0] = -INF;
z[1] = INF;
for q in 1..n {
let mut s = intersect(f, q, v[k]);
while k > 0 && s <= z[k] {
k -= 1; // the top parabola is entirely above the new one: pop it
s = intersect(f, q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = INF;
}
let mut k = 0;
for q in 0..n {
while z[k + 1] < q as f64 {
k += 1;
}
let d = q as f64 - v[k] as f64;
out[q] = d * d + f[v[k]];
}
}
fn intersect(f: &[f64], q: usize, p: usize) -> f64 {
((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2 * q as f64 - 2 * p as f64)
}
impl DistanceField {
/// Rasterize the map, transform down the columns, transform along the rows,
/// take one square root per cell. O(N), and exact on the lattice.
///
/// `rasterize` walks each wall at a third of a cell and returns the seed
/// grid: 0.0 at a cell a wall passes through, INF elsewhere. Indices are
/// clamped, not dropped — the Apartment's shell lies exactly on the
/// bounding box, and a wall that falls off the grid leaves the whole border
/// looking like open space.
pub fn from_map(map: &World, res: f64) -> Self {
let (nx, ny, mut f) = rasterize(map, res);
let mut buf = vec![0.0; nx.max(ny)];
for i in 0..nx {
let col: Vec<f64> = (0..ny).map(|j| f[j * nx + i]).collect();
dt_1d(&col, &mut buf[..ny]);
for j in 0..ny {
f[j * nx + i] = buf[j];
}
}
for j in 0..ny {
let row: Vec<f64> = (0..nx).map(|i| f[j * nx + i]).collect();
dt_1d(&row, &mut buf[..nx]);
f[j * nx..j * nx + nx].copy_from_slice(&buf[..nx]);
}
let cells = f.iter().map(|d| (d.sqrt() * res) as f32).collect();
Self { cells, nx, ny, res, origin: (map.bounds.min_x, map.bounds.min_y) }
}
/// Nearest-cell lookup, clamped at the border. Queries outside the map are
/// charged the extra Euclidean distance so an off-map endpoint reads as
/// *unlikely* rather than merely uninformative.
#[inline]
pub fn dist(&self, x: f64, y: f64) -> f64 {
let i = (((x - self.origin.0) / self.res - 0.5).round() as isize)
.clamp(0, self.nx as isize - 1) as usize;
let j = (((y - self.origin.1) / self.res - 0.5).round() as isize)
.clamp(0, self.ny as isize - 1) as usize;
self.cells[j * self.nx + i] as f64
}
}
pub struct LikelihoodField {
pub field: DistanceField,
pub intr: BeamIntrinsics,
/// Sensor pose in the robot frame — Thrun's (x_k,sens, y_k,sens).
pub sensor_offset: (f64, f64),
}
impl SensorModel for LikelihoodField {
/// `likelihood_field_range_finder_model` — Table 6.3. No ray casting, no
/// correspondence, and max-range readings are skipped outright: "I saw
/// nothing" is not evidence about where the nearest wall is.
fn log_likelihood(&self, scan: &Scan, x: &SE2, _map: &World) -> f64 {
let (c, s) = (x.theta().cos(), x.theta().sin());
let sx = x.x() + c * self.sensor_offset.0 - s * self.sensor_offset.1;
let sy = x.y() + s * self.sensor_offset.0 + c * self.sensor_offset.1;
let mut q = 0.0;
for (k, &z) in scan.ranges.iter().enumerate() {
if z >= scan.max_range - 1e-9 {
continue;
}
let a = x.theta() + scan.bearings[k];
let d = self.field.dist(sx + z * a.cos(), sy + z * a.sin());
let g = (-0.5 * (d / self.intr.sigma_hit).powi(2)).exp()
/ (self.intr.sigma_hit * (2.0 * std::f64::consts::PI).sqrt());
q += (self.intr.z_hit * g + self.intr.z_rand / self.intr.max_range)
.max(1e-300)
.ln();
}
q
}
fn is_cheap(&self) -> bool {
true
}
}The landmark model and its inverse are short enough to show together, and the inverse is the one that will matter later.
use crate::{Feature, FeatureNoise, Landmark};
use pr_core::geom::se2::{wrap_angle, SE2};
use rand::distr::{Distribution, Uniform};
use rand::Rng;
use rand_distr::Normal;
pub struct LandmarkModel {
/// Q_t = diag(σ_r², σ_φ², σ_s²).
pub q: FeatureNoise,
}
impl LandmarkModel {
/// `landmark_model_known_correspondence` — Table 6.4, in log space.
pub fn log_likelihood(&self, f: &Feature, j: &Landmark, x: &SE2) -> f64 {
let (dx, dy) = (j.xy.x - x.x(), j.xy.y - x.y());
let r_hat = dx.hypot(dy);
// The wrap is not optional: an unwrapped 2π error in the bearing
// residual rejects the correct landmark and accepts a wrong one.
let phi_hat = wrap_angle(dy.atan2(dx) - x.theta());
let e = [f.r - r_hat, wrap_angle(f.phi - phi_hat), f.s - j.signature];
(0..3)
.map(|i| {
let var = self.q[(i, i)];
-0.5 * (e[i] * e[i] / var + (2.0 * std::f64::consts::PI * var).ln())
})
.sum()
}
/// `sample_landmark_model_known_correspondence` — Table 6.5.
///
/// Two constraints, three degrees of freedom: γ̂ is the one the reading
/// cannot see, so it is drawn uniformly. The result is a *proposal*, not a
/// belief — the pose prior it assumes (uniform over the plane) is wrong in
/// every building ever built.
pub fn sample_pose<R: Rng + ?Sized>(&self, f: &Feature, j: &Landmark, rng: &mut R) -> SE2 {
let gamma = Uniform::new(0.0, std::f64::consts::TAU).unwrap().sample(rng);
let r_hat = (f.r + Normal::new(0.0, self.q[(0, 0)].sqrt()).unwrap().sample(rng)).max(0.0);
let phi_hat = f.phi + Normal::new(0.0, self.q[(1, 1)].sqrt()).unwrap().sample(rng);
SE2::new(
j.xy.x + r_hat * gamma.cos(),
j.xy.y + r_hat * gamma.sin(),
wrap_angle(gamma - std::f64::consts::PI - phi_hat),
)
}
}Finally, the wrapper that makes honesty configurable. It takes any SensorModel and returns a
weaker one — which is the whole content of Step 4 of the overconfidence derivation, as twelve lines
of Rust.
use crate::{Scan, SensorModel};
use pr_core::geom::se2::SE2;
use sim::World;
/// A sensor model that has been told to be less sure of itself.
///
/// `kappa` divides the log-likelihood — the p^{1/κ} of Thrun §6.3.4 and the
/// scaled loss of generalized Bayesian updating. `stride` throws beams away
/// instead. They are not interchangeable: tempering keeps the peak where the
/// full scan puts it, subsampling moves it, so prefer κ and use `stride` only
/// when the cost of the beams themselves is the problem.
pub struct Tempered<S: SensorModel> {
pub inner: S,
pub kappa: f64,
pub stride: usize,
}
impl<S: SensorModel> SensorModel for Tempered<S> {
fn log_likelihood(&self, scan: &Scan, x: &SE2, map: &World) -> f64 {
let stride = self.stride.max(1);
let thinned = if stride == 1 {
scan.clone()
} else {
Scan {
ranges: scan.ranges.iter().step_by(stride).copied().collect(),
bearings: scan.bearings.iter().step_by(stride).copied().collect(),
max_range: scan.max_range,
}
};
self.inner.log_likelihood(&thinned, x, map) / self.kappa.max(1e-6)
}
fn is_cheap(&self) -> bool {
self.inner.is_cheap()
}
}A worked example you can check by hand
Five beams, one wall, one EM iteration. The wall is at m for every beam, the sensor's limit is m, and we start from maximal ignorance: all four weights at , m, .
The components. With and , the truncation normalizer for is — five sigma from both boundaries, so it may be treated as 1, and the values are plain normal densities. For , .
| 4.5 | 0.352065 | 0.057412 | 0 | 0.1 |
| 5.0 | 0.398943 | 0.044713 | 0 | 0.1 |
| 3.0 | 0.053991 | 0.121542 | 0 | 0.1 |
| 7.0 | 0.053991 | 0 | 0 | 0.1 |
| 10.0 | 0.0000015 | 0 | 1 | 0 |
Two entries are worth pausing on. The readings at 3.0 and 7.0 have identical — both are two metres from the wall — but only the short one can be explained by clutter, which is the entire asymmetry of the model in one row. And the reading at exactly has , because is defined on ; the max-range spike belongs to alone.
The E-step. All four weights are equal, so the responsibilities are just the components normalized:
| 4.5 | 0.691032 | 0.112689 | 0 | 0.196279 |
| 5.0 | 0.733815 | 0.082245 | 0 | 0.183940 |
| 3.0 | 0.195951 | 0.441116 | 0 | 0.362933 |
| 7.0 | 0.350611 | 0 | 0 | 0.649389 |
| 10.0 | 0.000001 | 0 | 0.999999 | 0 |
Check the first row by hand: , and . The mixture likelihood of that reading is .
The M-step. Average the columns for the weights, and take the responsibility-weighted moments for the shapes:
The mean log-likelihood per beam before this step is ; after it, it has risen. Notice what one iteration did: the hit weight went from 0.25 to 0.39 because most readings are hits, the max weight went to exactly because exactly one of five beams maxed out, and grew, because with a fat starting the model claims the reading at 7.0 as a hit and pays for it in variance. Ten more iterations and it will have sorted that out.
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
const Z: [f64; 5] = [4.5, 5.0, 3.0, 7.0, 10.0];
const Z_STAR: [f64; 5] = [5.0, 5.0, 5.0, 5.0, 5.0];
fn start() -> BeamIntrinsics {
BeamIntrinsics {
z_hit: 0.25,
z_short: 0.25,
z_max: 0.25,
z_rand: 0.25,
sigma_hit: 1.0,
lambda_short: 0.5,
max_range: 10.0,
}
}
/// The worked example in the text, to six decimals.
#[test]
fn toy_em_step() {
let model = BeamModel { intr: start(), stride: 1 };
let c = model.components(4.5, 5.0);
assert_relative_eq!(c[0], 0.352065, epsilon = 1e-6);
assert_relative_eq!(c[1], 0.057412, epsilon = 1e-6);
assert_relative_eq!(model.beam_likelihood(4.5, 5.0), 0.127369, epsilon = 1e-6);
let (fitted, trace) = learn_intrinsics(&Z, &Z_STAR, start(), 1);
assert_relative_eq!(trace[0], -2.275038, epsilon = 1e-6);
assert_relative_eq!(fitted.z_hit, 0.394282, epsilon = 1e-6);
assert_relative_eq!(fitted.z_short, 0.127210, epsilon = 1e-6);
assert_relative_eq!(fitted.z_max, 0.200000, epsilon = 1e-6);
assert_relative_eq!(fitted.z_rand, 0.278508, epsilon = 1e-6);
assert_relative_eq!(fitted.sigma_hit, 1.093905, epsilon = 1e-6);
assert_relative_eq!(fitted.lambda_short, 0.283737, epsilon = 1e-6);
assert!(fitted.is_normalized());
}
/// EM's defining property. A dip here is a bug, not a local optimum.
#[test]
fn em_log_likelihood_is_monotone() {
let (_, trace) = learn_intrinsics(&Z, &Z_STAR, start(), 40);
for w in trace.windows(2) {
assert!(w[1] >= w[0] - 1e-12, "log-likelihood decreased: {w:?}");
}
}
/// The exact transform must equal the O(N²) definition, bit for bit.
#[test]
fn distance_transform_is_exact() {
let map = sim::apartment();
let field = DistanceField::from_map(&map, 0.4);
let brute = brute_force_distance(&map, 0.4);
for (a, b) in field.cells.iter().zip(&brute) {
assert_eq!(a, b);
}
}
}Both implementations exist and are checked against each other: crates/sensor is the canonical
Rust, and web/lib/models/beam-em.ts, web/lib/models/landmark-sampling.ts and
web/lib/mapping/edt.ts are the TypeScript port that runs the widgets on this page. The worked
example above is an assertion in both, and the exact distance transform is checked against its own
definition in both — on the Apartment at a 40 cm cell, they agree to the last bit.
Putting it together
The race
The two range-finder models, head to head on one map and one scan — the other two are not commensurable with them and get a paragraph instead. The numbers below are what the Likelihood Field Explorer above computes: the same code, the same 40-beam scan in the Apartment's room B, the same 68 × 50 grid of hypotheses, run headless on a laptop. The widget prints its own throughput live, so you can check them against the machine you are reading this on.
| evaluations / ms | evaluations / ms, cluttered | largest step in the profile | largest step, cluttered | |
|---|---|---|---|---|
| beam model | 203 | 104 | 11.5 nats | 56.1 nats |
| likelihood field | 329 | 330 | 5.7 nats | 4.7 nats |
Read the columns as two independent arguments. Cost: the beam model's price is set by the complexity of the map, because every evaluation ray-casts against every wall; adding 26 chair legs halves its throughput and does not touch the field's, which pays a fixed of lookups no matter how baroque the room. Smoothness: the beam model's profile takes a 56-nat step between neighbouring hypotheses 14 mm apart, which is a factor of in likelihood — a cliff that will strand any optimizer and starve any particle that lands on the wrong side. The field's residual roughness is not geometry at all but its own grid: halve the cell size and it halves too, which is exactly the behaviour you want from an approximation.
Two other results belong here, from the sections above. The correlation model is the most robust to a map that is partly wrong and the least defensible as a probability. The landmark model costs per feature instead of per scan and throws away almost everything — which is fine when the features are distinctive, and catastrophic when they are not, because then becomes a discrete search that no amount of Gaussian machinery will save you from.
Which model, when
Use the likelihood field for localization in a known static map. It is what AMCL runs by default, it is smooth enough for a particle filter with a few thousand particles, and its weaknesses — no notion of occlusion, no penalty for seeing through walls — do not bite when the map is right and the environment is mostly static.
Use the beam model when the environment is dynamic enough that is doing real work, or when you need a generative model — to simulate a sensor, to detect that a reading is surprising, or to reason about what the robot would see from a hypothetical pose, as Chapter 24 does when it evaluates where to go next.
Use correlation when the map is known to be partly wrong. Use features when the front end gives you features and the world gives you distinctive ones.
And in every case, temper. The configuration this book carries forward is
Tempered<LikelihoodField> with chosen by calibration on logged data — the exact object
Chapter 12 constructs when it builds its MCL theater, and the
reason its particle cloud stays honest for long enough to converge.
What comes next
Chapter 11 takes the landmark model and the correspondence variable and builds EKF localization on them, at which point stops being notation and starts being the hardest part of the problem. Chapter 12 takes the range-finder models and makes them particle weights, where the smoothness argument above turns into the difference between converging and not. Chapter 13 inverts the question entirely — instead of with the map known, it asks for with the pose known — and Chapter 25 replaces the whole mixture with a network that is calibrated against exactly the diagnostics this chapter built.
Exercises
- Foundation exerciseDifficulty 2 of 3The normalizer you were tempted to drop
Derive the truncation normalizer of on in terms of the standard normal CDF, and show it equals . Then answer the engineering question: for m and m, for which values of does differ from 1 by more than 1%? Finally, argue about EM: if you omit entirely, which of the four components absorbs the missing mass, and in which direction does move?
- Foundation exerciseDifficulty 3 of 3Fix the biased λ update
Step 6 of the EM derivation shows that Table 6.2's update is the estimator for an untruncated exponential. Write the log-likelihood of the truncated exponential on , differentiate it, and show that the maximum-likelihood solves , where is the responsibility-weighted mean. Implement one Newton step on this equation, and verify against the measured numbers in the text: for , m, the uncorrected estimator lands near 1.46 while the corrected one should land near 1.0.
- Foundation exerciseDifficulty 2 of 3Why the exponential, exactly
The text derives from a homogeneous Poisson clutter field along the ray. Redo it for a non-homogeneous field whose rate grows with distance — a decent model of a corridor that gets busier further from the robot. Show that the nearest-hit density becomes , and explain what fitting a single-rate exponential to data generated this way will do to the estimated .
- Conceptual exerciseDifficulty 1 of 3Predict, then check: who moves when the corridor fills
In the Beam Mixture Mixer, before you touch anything: write down which two of the six parameters you expect to move when "people in the corridor" is switched on, and in which direction. Now switch it on, press Fit (EM), and compare. Explain any parameter that moved which you did not predict — in particular, why some of the extra short mass ends up in rather than , and what that tells you about how identifiable the four components really are.
- Conceptual exerciseDifficulty 2 of 3Find the tempering that buys back honesty
In the Overconfidence Meter with the map error on and , find the smallest integer for which the truth is back inside the 95% set. Then set with — roughly the same amount of "evidence discarded" — and observe that it does not achieve the same thing. Explain the difference in terms of what each operation does to the peak versus the width, and say which one you would deploy on a robot whose map error you cannot measure directly.
- Practical exerciseDifficulty 2 of 3Implement map correlation and race it
Implement
map_correlation_modelagainst theSensorModeltrait: rasterize the scan into a local grid, correlate with the global map, clip at zero, take the log. Add it to the race table. Find a map error under which it beats the likelihood field, and explain why. Then answer the uncomfortable question: since is not a density in , what exactly goes wrong if you use it as an importance weight in Chapter 12's particle filter — and why does it work anyway? - Practical exerciseDifficulty 3 of 3A field that knows the doors can open
Extend
DistanceFieldto take a set of maps — doors open, doors closed — and store the per-cell minimum distance over the set. Show that this is still one distance transform if you rasterize all variants into a single seed grid, and one transform per variant plus a pointwise min if you want to know which variant explained the scan. Wire it into the Likelihood Field Explorer and measure what it costs in peak sharpness at the correct pose. Then relate it to the dynamic-environment handling of Chapter 12: when is "assume the most convenient world" a better model than "estimate which world you are in"?
References
- Thrun, S., Burgard, W., and Fox, D. (2005) Probabilistic Robotics. MIT Press.link to Probabilistic Robotics (opens in a new tab)
Chapter 6 is the source of every algorithm in this chapter — Tables 6.1 through 6.5 — and of the epigraph. The EM derivation here follows §6.3.2–6.3.3 with the truncation bias of Step 6 added.
- Felzenszwalb, P. F. and Huttenlocher, D. P. (2012) Distance Transforms of Sampled Functions. Theory of Computing 8(19), 415–428.doi:10.4086/toc.2012.v008a019 (opens in a new tab)
The exact O(N) transform that makes likelihood fields effectively free to build. The lower-envelope-of-parabolas argument in this chapter's second derivation is theirs, and the same routine reappears as a max-product message pass in Chapter 22.
- Plagemann, C., Kersting, K., Pfaff, P., and Burgard, W. (2007) Gaussian Beam Processes: A Nonparametric Bayesian Measurement Model for Range Finders. Proceedings of Robotics: Science and Systems III.doi:10.15607/RSS.2007.III.018 (opens in a new tab)
What happens when you refuse to pick between the beam model and the endpoint model: a Gaussian process over the whole scan that learns the correlations this chapter's independence assumption denies. The right next step if the overconfidence section bothered you.
- Olson, E. B. (2009) Real-Time Correlative Scan Matching. Proceedings of the IEEE International Conference on Robotics and Automation (ICRA), 4387–4393.link to Real-Time Correlative Scan Matching (opens in a new tab)
The correlation model of §6.5 grown into a practical algorithm, with the multi-resolution trick that makes an exhaustive pose search real-time. Chapter 16 builds on it directly.
- Bissiri, P. G., Holmes, C. C., and Walker, S. G. (2016) A General Framework for Updating Belief Distributions. Journal of the Royal Statistical Society: Series B 78(5), 1103–1130.doi:10.1111/rssb.12158 (opens in a new tab)
The statistical justification for tempering. A posterior built from a scaled loss rather than a likelihood is a coherent belief update, which is what turns the 1/κ hack of §6.3.4 into something you can defend.
- Zhu, D., Wang, C., Wang, W., Garg, R., Scherer, S., and Meng, M. Q.-H. (2021) VDB-EDT: An Efficient Euclidean Distance Transform Algorithm Based on VDB Data Structure. arXiv:2105.04419.link to VDB-EDT: An Efficient Euclidean Distance Transform Algorithm Based on VDB Data Structure (opens in a new tab)
Where distance fields went after 2012: sparse hierarchical storage and incremental updates, so the field can be maintained online in 3-D at map scale rather than rebuilt. The modern answer to 'what if the map changes?'
- Kuang, H., Chen, X., Guadagnino, T., Zimmerman, N., Behley, J., and Stachniss, C. (2023) IR-MCL: Implicit Representation-Based Online Global Localization. IEEE Robotics and Automation Letters.doi:10.1109/LRA.2023.3239318 (opens in a new tab)
This chapter's mixture, replaced by a neural occupancy field that synthesizes the scan it expects. It is the clearest modern demonstration that the observation model — not the filter — is what limits localization accuracy, and it is where Chapter 25 picks up.
- Macenski, S., Martín, F., White, R., and Ginés Clavero, J. (2020) The Marathon 2: A Navigation System. Proceedings of the IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS).doi:10.1109/IROS45743.2020.9341207 (opens in a new tab)
Evidence that this chapter is current practice: Nav2's AMCL exposes exactly these models as `laser_model_type` (beam versus likelihood_field), and `laser_max_beams` is the subsampling knob of the overconfidence section.
