Sensor Models and the Simulator
How a simulated lidar produces a scan you can replay from a seed, what a range reading means as a probability, how the beam model and the likelihood field score a pose, and why a scan's likelihood has to be tempered.
To navigate reliably in indoor environments, a mobile robot must know where it is.
In this chapter
Chapter 2 ended with a pose that drifts, and a promise that a second kind of evidence would correct it. The evidence is a lidar: 360 distances, ten times a second. A distance means nothing to a probabilistic filter until a model says how probable it is, and the whole of localization, from Chapter 4 on, will lean on one function: the probability of a scan if the robot were at a given pose in a given map.
That function has two ends and this chapter builds both. At one end is a simulator that produces scans a reader can replay from a seed, so that every later number can be reproduced. At the other are the sensor models that score a scan, and three questions about them: what does a single reading mean, what is a cheap way to score a hundred poses a scan, and how sure is a score entitled to be?
The problem
A lidar sweeps a beam a degree at a time and reports how far it went. Rusty, the repository's simulator, does the same thing on a map made of line segments: a 13 m × 13 m arena, 21 segments in all, with box obstacles and a wall stub. Each beam is cast exactly against the segments, Gaussian noise of 2 cm is added, one beam in a hundred returns nothing, and anything beyond 8 m is nothing. Drag the robot around.
- measurement
- prediction
- ground truth
Green dots are what the robot would read. Two kinds of beam read nothing, and they look the same in the data: a wall that is farther than 8 m away, and a dropout. From the start pose of the widget 57 of the 360 beams reach no wall within range, and a handful more are lost; the reading alone cannot tell which is which, a map can. (From the middle of the arena, with noise and dropout off, 53 beams still reach nothing, because the corners are more than 8 m away.) That is the first lesson of a sensor model: a missing return is a piece of evidence, and an ambiguous one.
The second lesson is about what the question is. A localizer will hold many guesses of the pose and ask, of each, how probable it is that
the lidar would have read this. The guess that makes the scan likely wins. So a sensor model is a function p(z | x, m) of the scan z, the pose x
and the map m, and the rest of the chapter is two ways of writing it, and the problem each has.
Same Rust, different noise, again
The note at the start of Chapter 2 applies here without change. The simulator's random streams come from robotics_core::seeded_rng, and a seed
names different noise on this page (a 32-bit WebAssembly module) than on the robot. The scan of the widget above is this page's own draw; the same
scan, ch03_lidar_scan(1, 2, 0.4, 0.02, 0.01, seed 7), has the fingerprint 8a526fd3ad0062b1 where the repository
runs natively and f7e036522696593c here, and the build pins both. What is deterministic and has no random stream (the ray cast, the beam density,
the worked EM example, the distance transform, the landmark model) is the same on both and is checked against closed forms and the repository's own numbers.
Building intuition: a simulator you can replay
The forward model
Write the sensor's pose as , the robot's pose composed with the mount, 5 cm ahead of the base. Beam points at the bearing . Its ideal range is where the ray first meets a wall. For the ray and a segment , solve the two-by-two system with cross products, and , and accept the hit when and lies on the segment (with a tolerance so that a ray through a shared corner cannot leak). The first hit is the smallest , if it is within 8 m. The measurement is then
unless the beam is dropped, which happens with a fixed probability and also when there is no wall at all.
A worked example, with no random stream in it, so that it is the same on the page and on the robot: from the origin, facing +x, with no noise, beam 180 (straight ahead) reads
7.45 m, because the east wall is at 7.5 m and the sensor is 5 cm ahead of the base; beam 0 (straight behind) reads 5.55 m; beam 90 reads 4.0 m; beam 270 reads nothing, because the
wall it points at is 9 m away. The repository pins them by hand in tests/lidar_regression.rs, and this book's build checks them against the compiled module.
/// First intersection of the ray `origin + t·(cos angle, sin angle)`, `t ≥ 0`, with
/// any segment, if it lies within `max_range`. Rays parallel to a segment do not hit
/// it (a grazing ray along a wall is a measure-zero event for a real sensor too).
pub fn raycast(&self, origin: &Vector2<f64>, angle: f64, max_range: f64) -> Option<f64> {
let d = Vector2::new(angle.cos(), angle.sin());
let mut best = f64::INFINITY;
for seg in &self.segments {
let e = seg.b - seg.a;
let denom = cross(&d, &e);
if denom.abs() < 1e-15 {
continue;
}
let ao = seg.a - origin;
let t = cross(&ao, &e) / denom;
let s = cross(&ao, &d) / denom;
if t >= 0.0 && (-PARAM_EPS..=1.0 + PARAM_EPS).contains(&s) && t < best {
best = t;
}
}
(best <= max_range).then_some(best)
} pub fn scan(&mut self, world: &World, body: &Se2, stamp: Timestamp) -> LaserScan {
let ideal = self.ideal_ranges(world, body);
let c = &self.cfg;
let mut ranges = Vec::with_capacity(c.beams);
// …
for z_star in ideal {
let u: f64 = self.rng.random::<f64>();
let n: f64 = self.rng.sample(StandardNormal);
let dropped = u < c.dropout_probability;
let z = match (dropped, z_star) {
(false, Some(z)) => Some(z + c.range_sigma * n),
_ => None,
};
let r = match (c.no_return, z) {
(NoReturnPolicy::Infinity, None) => f64::INFINITY,
(NoReturnPolicy::Infinity, Some(z)) if z > c.range_max => f64::INFINITY,
(NoReturnPolicy::Infinity, Some(z)) if z < c.range_min => f64::NEG_INFINITY,
(NoReturnPolicy::Infinity, Some(z)) => z,
(NoReturnPolicy::MaxRange, None) => c.range_max,
(NoReturnPolicy::MaxRange, Some(z)) => z.clamp(0.0, c.range_max),
};
ranges.push(r as f32);
}
// …
}Two things in the second listing carry the next idea. Every beam draws a uniform number and a normal one whether or not it uses them. And the encoding of
"nothing" is a choice: in the default, REP 117 (+inf for no return or beyond range, −inf for closer than 0.12 m); in the book's convention, the sensor's limit, 8 m. The toggle in the
widget switches between them, and the beam model below scores both identically, because it treats a non-finite reading as a max-range one.
Replay
The simulator promises a thing a reader can test. It has one random generator for each noise source, wheel slip, gyro white noise, gyro bias drift and the lidar, each derived from the master seed. Because every beam draws its two numbers regardless, changing the dropout probability does not reshuffle the noise of the beams that survive. Because the generators are separate, taking the lidar away cannot change a bit of the others.
Read the table by columns. B repeats A and agrees everywhere. C, with no lidar, agrees with A in the encoders, the gyro and the true pose, and has no scans. D takes the next seed, and everything that has noise in it changes, except the encoders. That is not an accident: the simulator advances the wheel angle by the commanded rate, and slip and the radius error act on how far the robot travels, not on how far the wheel turned. An encoder cannot see them. This is the whole of the wheel-odometry problem of Chapter 2, in one column of identical fingerprints.
The mathematics
The beam model
Given the distance the beam should have travelled, a range reading is one of four things, and the beam model gives each its density:
The hit is the wall with noise, a Gaussian around cut to : , with the Gaussian's mass on that interval. The short reading is something unexpected between the sensor and the wall, a person or a chair, whose distance falls off exponentially and which is cut off at : on . The max reading is a point mass at the sensor's limit, and the random reading is a flat floor, . The weights are the book's defaults, 0.74, 0.10, 0.06 and 0.10, and m, per metre.
A score for a whole scan multiplies the beams, , with found by ray casting the map from the hypothesised pose. The density integrates to one, as a density must, and the build checks it at three expected ranges.
/// Component densities for measured `z` and expected `z_star`.
pub fn components(&self, z: f64, z_star: f64) -> BeamComponents {
let p = &self.p;
let zmax = p.max_range;
let hit = if (0.0..=zmax).contains(&z) {
let mass =
normal_cdf((zmax - z_star) / p.sigma_hit) - normal_cdf(-z_star / p.sigma_hit);
if mass > 1e-12 {
normal_pdf(z - z_star, p.sigma_hit) / mass
} else {
0.0
}
} else {
0.0
};
let short = if z_star > 0.0 && (0.0..=z_star).contains(&z) {
p.lambda_short * (-p.lambda_short * z).exp() / (1.0 - (-p.lambda_short * z_star).exp())
} else {
0.0
};
let max = if z >= zmax - 1e-9 { 1.0 } else { 0.0 };
// …
/// Mixture likelihood `p(z | z*)`.
pub fn likelihood(&self, z: f64, z_star: f64) -> f64 {
let c = self.components(z, z_star);
let p = &self.p;
p.z_hit * c.hit + p.z_short * c.short + p.z_max * c.max + p.z_rand * c.rand
}Learning the parameters
Six numbers sit in that model, and the book's rule is that nobody should guess them. Given readings with their expected ranges , expectation–maximization finds them. The E-step shares each reading among the four components in proportion to how well each explains it, . The M-step re-estimates each parameter from its share:
A worked example the repository pins in beam::tests::worked_example_ch10_em_iteration, with five readings of a wall at 5 m:
readings 4.5, 5, 3, 7, 10 at z* = 5, z_max = 10, uniform weights ¼, σ_hit = 1, λ = ½
after one EM step:
z_hit = 0.394282 z_short = 0.127210
z_max = 0.2 z_rand = 0.278508
σ_hit = 1.093905 λ_short = 0.283737 (0.2837390 in full precision)
mean log-likelihood per reading, before the step: −2.275038- posterior
- ground truth
The weights land within 0.02 of the truth, within 2 mm, and the log-likelihood climbs within a few iterations; two things are not clean, because the repository implements the book's update as printed.
settles above its true value, because the update ignores that is cut off at ; and for the same reason the M-step is not an exact maximization, so the trace can dip by about nats per beam near the end (docs/SOURCE_MAPPING.md, deviation 8). The page's readings are drawn from the model itself, by code that is
marked in robotics_wasm as not being crate code (the crate has the density and the fit, not a sampler), so the demonstration is that the fitting code finds a known answer, not that a real lidar obeys the model.
/// One EM iteration of `learn_intrinsic_parameters` (Thrun Table 6.2) over readings
/// `z` paired with their ray-cast expectations `z_star` (cached outside the loop).
///
/// E-step: responsibilities `e_{i,c} = z_c p_c(z_i) / Σ_c' z_c' p_c'(z_i)`.
/// M-step: `z_c = mean_i e_{i,c}`,
/// `σ_hit² = Σ e_{i,hit}(z_i − z_i*)² / Σ e_{i,hit}`,
/// `λ_short = Σ e_{i,short} / Σ e_{i,short} z_i`.
///
/// Readings no component can explain are skipped (and counted); a component with no
/// responsibility keeps its shape parameter; `σ_hit ≥ 1 mm` and
/// `λ_short ∈ [1e-3, 50]` keep the next model valid. Non-finite readings are treated
/// as max-range returns, as everywhere else in this model. `λ_short` inherits the
/// truncation bias the book documents (Chapter 10, EM derivation, step 6).
pub fn em_step(&self, z: &[f64], z_star: &[f64]) -> Result<EmStep, ModelError> {
// …
for (&zi, &si) in z.iter().zip(z_star) {
let zi = if zi.is_finite() { zi } else { p.max_range };
let c = self.components(zi, si);
let w = [
p.z_hit * c.hit,
p.z_short * c.short,
p.z_max * c.max,
p.z_rand * c.rand,
];
let total: f64 = w.iter().sum();
if total <= 0.0 || !total.is_finite() {
continue;
}
used += 1;
s_hit += w[0] / total;
s_short += w[1] / total;
s_max += w[2] / total;
s_rand += w[3] / total;
sq_hit += w[0] / total * (zi - si).powi(2);
z_short_sum += w[1] / total * zi;
ll += total.ln();
// …
let n = used as f64;
let params = BeamModelParams {
z_hit: s_hit / n,
z_short: s_short / n,
z_max: s_max / n,
z_rand: s_rand / n,
sigma_hit: if s_hit > 1e-9 {
(sq_hit / s_hit).sqrt().max(1e-3)
} else {
p.sigma_hit
},
lambda_short: if z_short_sum > 1e-9 {
(s_short / z_short_sum).clamp(1e-3, 50.0)
} else {
p.lambda_short
},
max_range: p.max_range,
};The likelihood field
The beam model needs a ray cast against the map for every beam of every hypothesis. A cheaper score drops each measured end point into the map and asks one question of it: how far is the nearest wall? Precompute the answer once for every cell of a grid, with the exact Euclidean distance transform (Felzenszwalb and Huttenlocher, in linear time), and the score of a beam is a lookup,
with the distance from the -th end point to the nearest wall, and defaults , , m. Max-range beams are skipped, since they have no end point. This is not a model of how scans are generated; it is a smooth, cheap score that a particle filter can use, and the repository says so in its docs.
/// Log-likelihood of `scan` from robot pose `pose`, with the sensor mounted at
/// `sensor_offset` in the body frame, using every `stride`-th valid beam.
pub fn log_likelihood<F: DistanceField>(
&self,
scan: &LaserScan,
pose: &Se2,
sensor_offset: &Se2,
field: &F,
stride: usize,
) -> f64 {
let sensor = pose.compose(sensor_offset);
let p = &self.p;
let max = p.max_range.min(scan.range_max);
scan.valid_beams_strided(stride)
.filter(|(_, r)| *r < max - 1e-9)
.map(|(b, r)| {
let end = sensor.act(&Vector2::new(r * b.cos(), r * b.sin()));
let d = field.distance(&end);
(p.z_hit * normal_pdf(d, p.sigma_hit) + p.z_rand / p.max_range)
.max(1e-300)
.ln()
})
.sum()
}The picture below is the field of the arena, brightest next to a wall, with a scan on it. The distance map is rasterized by the page's own wiring (the repository has no rasterizer yet; it is marked in robotics_wasm as not being crate code), which draws each wall as a one-cell-thick outline on a 5 cm grid. Remember that: it will matter.
- posterior
- measurement
- ground truth
Both models peak at the truth. The beam model's peak is sharper (on every pose and seed the build tries, within 1 cm of the truth along x and y, and within 0.01 rad in heading), the likelihood field's is within 3 cm and about twice as wide (never under 1.7 times on the poses and seeds the build tries). In exchange the field is smooth, so a search or a particle filter can use it; and it costs a table lookup per beam instead of a ray cast against 21 segments, 7 560 segment tests per 360-beam scan.
Too sure of itself
A scan has about 300 usable beams, and both models multiply them as if each were independent evidence. They are not independent: a 5 cm map cell biases every beam that meets it the same way, and a person walking through the scan blocks twenty adjacent beams. The product then overstates the information by roughly the ratio of the number of beams to the number of independent ones, and the posterior is narrower than the scan can justify.
The remedy is to raise the likelihood to a power, with . In log space it is a multiplication, so the surface flattens without its peak moving, and no data are thrown away (subsampling beams would move the peak; inflating would change which beams count as outliers). The repository adds the rule that sets from a target: the largest whose weights keep the effective sample size above a given fraction.
/// `β · ln p`. `−∞` (zero likelihood) stays `−∞`; any finite value stays finite.
#[inline]
pub fn apply_log(&self, log_likelihood: f64) -> f64 {
self.beta * log_likelihood
// …
let ess_ratio = |beta: f64| -> f64 {
let lw: Vec<f64> = log_prior
.iter()
.zip(log_likelihoods)
.map(|(p, l)| p + beta * l)
.collect();
let max = lw.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if max == f64::NEG_INFINITY {
return 0.0;
}
let (s, s2) = lw.iter().fold((0.0, 0.0), |(s, s2), l| {
let w = (l - max).exp();
(s + w, s2 + w * w)
});
s * s / s2 / n
// …
if ess_ratio(1.0) >= target_ratio {
return Ok(Self::NONE);
}
if ess_ratio(min.beta) < target_ratio {
return Ok(min);
}
let (mut lo, mut hi) = (min.beta, 1.0);
for _ in 0..50 {
let mid = 0.5 * (lo + hi);
if ess_ratio(mid) >= target_ratio {
lo = mid;
} else {
hi = mid;
}
}
Self::new(lo)
}How over-confident, and does tempering cure it? The widget measures it. It simulates 64 scans, 8 poses with 8 noise seeds each, scores each along x, and asks of the posterior over that line: how large is the error of its mean against the true pose, how large is the standard deviation it claims, and what is the root-mean-square of their ratio. A calibrated posterior has a ratio of 1.
- posterior
- ground truth
With the likelihood field and the posterior's mean is about 2.4 cm from the truth, rms, while it claims a standard deviation of about 1.7 cm: the ratio is about 1.4, over-confident. Tempering to brings it to about 0.9 and below 1, and the posterior's mean does not move. With the beam model the ratio is already 0.4 to 0.55, and its error 0.3 cm: calibrated, even conservative. That says something specific about where the over-confidence comes from. In this simulator the beams' noise is independent and there is no person to walk through the scan, so the multiplication alone is not the cause; the cause is the rasterized map, whose 5 cm cells bias every beam of the likelihood field in the same way. The beam model casts rays against the exact segments and has no such error. The repository's reason for tempering, correlated beams from unmodeled obstacles, is the real-world version of the same thing, and is described here, not measured, because Rusty has no obstacle that moves.
A different sensor: a landmark
A feature detector that reports an identified landmark gives a range and a bearing in the sensor frame. With the landmark's position in the sensor frame, the prediction is , , and
Everything depends on the word wrap. A bearing is an angle, and the difference of two angles near is not their subtraction: a reading of rad against a prediction of is apart as numbers and rad apart as angles.
- posterior
- prediction
- measurement
LandmarkModel::residual) and the two are 0.08 rad apart, as they are. Drag the reading across the seam: the wrapped curve moves smoothly and keeps its total mass, the unwrapped one loses mass whenever the reading is within a few hundredths of a radian of it. Nothing here is random: the reading is whatever the slider says.The Jacobian of the prediction with respect to a right (body-frame) perturbation of the pose is what an EKF on SE(2) needs, and it is short:
DerivationThe landmark Jacobian
Let be the landmark in the robot's body frame and the landmark in the sensor frame, with the mount. A right perturbation , , changes the body-frame point to , so , and times that. The range and bearing of have the Jacobian by the chain rule. The product is the matrix, and the repository's test and this book's check compare it with a central difference of the prediction.
/// Residual `z ⊟ ẑ = (r − r̂, wrap(φ − φ̂))`.
pub fn residual(z: &RangeBearing, predicted: &RangeBearing) -> Vector2<f64> {
Vector2::new(
z.range - predicted.range,
angle_diff(z.bearing, predicted.bearing),
)
}
// …
/// Jacobian `∂ĥ/∂δ` of the prediction with respect to a right (body-frame)
/// perturbation of the pose, `x ⊞ δ = x·exp(δ)`, `δ = (v_x, v_y, ω)` — the `H` an EKF
/// on SE(2) needs.
pub fn jacobian(
&self,
pose: &Se2,
landmark: &Vector2<f64>,
) -> Result<SMatrix<f64, 2, 3>, ModelError> {
// …
let p = self.in_sensor(pose, landmark)?;
let pb = pose.act_inverse(landmark);
// exp(−δ) p_b ≈ p_b − (v + ω × p_b) ⇒ ∂p_b/∂δ = [[−1, 0, p_by], [0, −1, −p_bx]]
let dpb = SMatrix::<f64, 2, 3>::new(-1.0, 0.0, pb.y, 0.0, -1.0, -pb.x);
let rt = self.sensor.rotation().inverse().into_inner();
let dp = rt * dpb;
let r2 = p.norm_squared();
let r = r2.sqrt();
let dh = Matrix2::new(p.x / r, p.y / r, -p.y / r2, p.x / r2);
Ok(dh * dp)
}
// …
/// `ln p(z | x, m_j)` (Table 6.4 in log space).
pub fn log_likelihood(
&self,
pose: &Se2,
landmark: &Vector2<f64>,
z: &RangeBearing,
) -> Result<f64, ModelError> {
if !(z.range.is_finite() && z.bearing.is_finite()) {
return Err(ModelError::InvalidData("measurement must be finite"));
}
let e = Self::residual(z, &self.predict(pose, landmark)?);
let (sr, sb) = (self.sigma_range, self.sigma_bearing);
Ok(-0.5 * ((e.x / sr).powi(2) + (e.y / sb).powi(2)) - (sr * sb).ln() - (TAU).ln())
}The model needs the landmark's identity: which landmark a detection belongs to is the data-association problem of Chapter 4, and a detection without one is an error here, not a guess.
The algorithm
- for each beam : , or if there is no hit
- a non-finite is read as
- return , each term floored at
- for each usable beam with : end point
- return
Putting it together
The problem of the opening, as numbers a run produced. All of them are this page's own draws; the comparison between rows is what holds on both targets, and the build asserts it.
| beam model | likelihood field | |
|---|---|---|
| peak of the score along x, y, from the true pose | within 1 cm | within 3 cm |
| width of the peak (within 10 nats of it), field over beam | about 2 times wider (never under 1.7) | |
| work per hypothesised pose | 360 ray casts, 7 560 segment tests | one distance lookup per usable beam |
| rms error of the posterior mean, over 64 scans | about 0.3 cm | about 2.4 cm |
| rms of error / claimed σ at β = 1 (1 is calibrated) | 0.4 to 0.55 | about 1.4 |
| the same, tempered to β = 0.5 | below 1 | about 0.9 |
And the simulator, in the properties that make every other number in the book reproducible: a seed replays a scan and a whole run bit for bit; removing the lidar changes no other stream; the encoders do not depend on the seed at all.
What the repository says does not work
The limits recorded in docs/SIMULATION.md and docs/SOURCE_MAPPING.md are these. Rusty is not a collision model: the robot is never stopped by a wall, and its arena is 21 static segments, so there is no
moving obstacle to correlate the beams, which is why the over-confidence measured here comes from the map and not from people. Ray casting is analytic over every segment, with no spatial index, which is cheaper than
maintaining one for 21 segments and is revisited when maps get large. The EM follows the book's table including its untruncated M-steps. The landmark model needs the landmark's identity. And
the likelihood field is a score, not a probability of anything: its units are a log density per beam end point, and two numbers from different scans are comparable only
because they count the same beams.
Exercises
- Foundation: derive exerciseDifficulty 2 of 3Why the normalizer
Show that integrates to 1 on and say what would be wrong with the mixture if the normalizer were left out.
Solution
is the Gaussian's mass on , so dividing by it makes the cut-off density integrate to 1. Without it the hit component would lose the mass of the Gaussian's tails outside the interval: little at the middle of the range, and visible near or , where the mixture would integrate to less than 1 and a scan taken close to a wall would be systematically under-scored.
- Foundation: derive exerciseDifficulty 2 of 3The heading column of the Jacobian
For a sensor at the robot's origin, derive the third column of the landmark Jacobian, the derivative of with respect to a heading perturbation .
Solution
With the sensor at the origin , and . The range is unchanged to first order, because , and the bearing changes by : turning the robot by turns every bearing by . The column is .
- Conceptual: predict, then check exerciseDifficulty 1 of 3Nothing in rangePredict first
In the first widget, set the noise and the dropout to 0 and drag the robot to the middle of the arena. Do any beams still read infinity?
- Conceptual: predict, then check exerciseDifficulty 2 of 3Which peak is widerPredict first
In the scoring widget, slide the hypothesis along x. Which model's peak is wider, the beam model's or the likelihood field's?
- Conceptual: predict, then check exerciseDifficulty 2 of 3Does the beam model need temperingPredict first
In the tempering widget, switch to the beam model at β = 1. Is its posterior over-confident?
- Practical: write Rust exerciseDifficulty 2 of 3A room you can score by hand
In
crates/robotics_models/src/sensor/likelihood_field.rs, add a test that builds aDistanceGridfor a 1 m × 1 m room (the occupancy of its four walls), takes the scan of a point sensor at the room's centre, and checks thatlog_likelihoodat the centre is higher than at a pose 10 cm off. Which parameter of the model decides how much higher?Solution
Build the mask with the four walls as occupied cells, call
DistanceGrid::from_occupancy, produce aLaserScanwhose end points are the walls from the centre, and compareLikelihoodFieldModel::log_likelihoodat the two poses. decides: the score falls as in the exponent, so a narrower penalizes a 10 cm shift more.
References
- Thrun, S., Burgard, W. and Fox, D. (2005) Probabilistic Robotics. MIT Press, Intelligent Robotics and Autonomous Agents series.link to Probabilistic Robotics (opens in a new tab)
Chapter 6: the beam model, its parameter learning by EM (Section 6.3.2), the likelihood field (Section 6.4) and the landmark model (Section 6.6); the tables this chapter follows.
- Dellaert, F., Fox, D., Burgard, W. and Thrun, S. (1999) Monte Carlo Localization for Mobile Robots. Proceedings of the IEEE International Conference on Robotics and Automation (ICRA), vol. 2, 1322–1328.doi:10.1109/ROBOT.1999.772544 (opens in a new tab)
The consumer of these models: particle-filter localization, the Monte Carlo half of localization that Chapter 4 points to, and the source of the epigraph.
- 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 linear-time exact Euclidean distance transform the likelihood field is built on; the repository implements it and checks it against brute force.
- Dempster, A. P., Laird, N. M. and Rubin, D. B. (1977) Maximum Likelihood from Incomplete Data via the EM Algorithm. Journal of the Royal Statistical Society, Series B 39(1), 1–38.doi:10.1111/j.2517-6161.1977.tb01600.x (opens in a new tab)
The algorithm behind the learning of the beam model's six parameters.
- 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 generalized-Bayes reading of a weighted update, with a weight on the loss called a temperature: the justification of raising a likelihood to a power β.
- Macenski, S., Martin, F., White, R. and Ginés Clavero, J. (2020) The Marathon 2: A Navigation System. IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS), 2718–2725.doi:10.1109/IROS45743.2020.9341207 (opens in a new tab)
Nav2, the navigation stack this repository benchmarks against; its AMCL localizer offers the beam and likelihood-field models of this chapter, with the likelihood field as its default.
- Chen, X., Läbe, T., Nardi, L., Behley, J. and Stachniss, C. (2020) Learning an Overlap-based Observation Model for 3D LiDAR Localization. IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS), 4602–4608.doi:10.1109/IROS45743.2020.9340769 (opens in a new tab)
A learned observation model inside Monte Carlo localization, set against the hand-built beam end-point model: the direction a sensor model takes when its parameters are learned from data.
- Chen, R., Yin, H., Jiao, Y., Dissanayake, G., Wang, Y. and Xiong, R. (2021) Deep Samplable Observation Model for Global Localization and Kidnapping. IEEE Robotics and Automation Letters 6(2), 2296–2303.doi:10.1109/LRA.2021.3061339 (opens in a new tab)
A learned model of a 2-D laser scan trained only on simulated observations, which makes the quality of a lidar simulator a research question.
- Kuang, H., Pan, Y., Zhong, X., Wiesmann, L., Behley, J. and Stachniss, C. (2025) Improving Indoor Localization Accuracy by Using an Efficient Implicit Neural Map Representation. IEEE International Conference on Robotics and Automation (ICRA), 8027–8033.doi:10.1109/ICRA55743.2025.11128666 (opens in a new tab)
Replaces the distance transform of a likelihood field with a learned distance network inside Monte Carlo localization: the same idea as this chapter's field, with a different map.