Foundation: Poses, Odometry and the First Filters
A robot that integrates its own wheels, why the step it takes must be exact, and the two ways to hold what it does not know about where it is.
Probabilistic robotics is a new and growing area in robotics, concerned with perception and control in the face of uncertainty.
In this chapter
A TurtleBot3 has two wheel encoders and no idea where it is. Every pose it will ever use is one it computed: the wheels turned by so much, so the robot must have moved by so much. That sentence hides three problems, and this chapter takes them in order.
The first is arithmetic. A pose in a plane is a position and a heading, and "moved by so much" is a motion applied in the robot's own frame. Add the two the obvious way and the answer is wrong in a way that does not go away when you shrink the time step. The second is noise: wheels slip and encoders count in whole ticks, so the same command does not always land in the same place, and what the robot knows after a move is a spread of places. The third is what to do with a spread. It can be held as a histogram that assumes nothing, or as two numbers that assume a great deal, and the chapter ends with one worked example of each.
The problem
Drive the robot on a circle: forward at m/s while turning at rad/s. Its path is a circle of radius m, and it takes s to come round. The robot's odometry has to track that path by adding up small motions, one per control step. The question is how far from the true position it ends up after 12 s, as a function of how long a step it takes.
Here is the experiment, run on the repository's own Rust. The orange poses are what the robot's
odometry computes, the dark ones are what the textbook update x += v cos θ Δt computes, and
the dashed circle is the truth.
- prediction
- ground truth
The orange error is below m at every step length you can set. The dark one is not. At s (8 steps) the Euler end point is 0.2134 m from the truth, at s (4 steps) it is 0.4374 m, and halving the step from 1 s to 0.5 s halves the error, from 0.1416 m to 0.0706 m. Euler is not wrong by a random walk or by a drift that gets worse with time; as you saw, every one of its poses sits on one circle, just not the right one. The rest of this chapter exists to say why, and why the exact version is the one the robot runs.
Wheels bring a second problem. The same command given twice does not give the same pose, and that is not a flaw of one robot. It is what the next widget shows.
- prediction
After one command the possible poses are a cloud. After several they are a curved band. Whatever the robot does next, it does with a distribution over where it is, and a pose-with-a-radius is not a good summary of that distribution. The last two widgets of the chapter hold such a distribution in the two ways filters usually do.
Building intuition
Before any algebra, three things to take from the widgets.
A step is a rotation and a translation at once. Moving forward while turning does not move the robot along its old heading and then rotate it; the heading changes during the step, so the direction of travel changes during it too. The exact step is the arc of a circle, and that is what the orange poses follow. The Euler step goes straight along the old heading and only then turns, so each step is a straight segment in the direction the robot was facing, never the direction it was turning through, and its corners cannot land on the circle.
The error of the cheap step is systematic. Euler's poses are a regular polygon whose corners lie on a bigger circle that is shifted sideways. Nothing about it is random, so nothing averages out.
Noise is not isotropic. In the second widget, an error in heading early on rotates every later step, so the error in position is stretched along the arc. A filter that wants to say how sure it is has to know that, which is why the next chapters work with a covariance in the pose's own tangent space rather than with a radius.
The mathematics
A pose, and how poses combine
A planar pose is with . It is also a rigid motion: the one that takes the origin to and rotates by . Written as a matrix, it is
Poses form a group, . The product applies in the body frame of ,
and the inverse undoes . In the repository, that product is Se2::compose, the
inverse is Se2::inverse, and Se2::between(a, b) is : where is as seen from .
The exponential map: one exact step
A twist is a velocity in the body frame, translation first. If the robot holds it for one unit of time, it moves along the arc the next formula gives, where and are two functions of :
DerivationWhere a and b come from
Hold the twist for one unit of time. At time the heading is , so the robot is moving in the world frame with velocity , where is the rotation matrix. The position after one unit of time is the integral of that:
As the matrix tends to the identity, and , which is straight-line motion. The repository switches to the series , below , because loses all its digits there.
A worked example: is a quarter circle of radius , so the robot ends at
, heading . The formula gives and , so .
The repository pins it as se2::tests::worked_example_quarter_arc, and the book's build checks it against the compiled module.
Two names the rest of the book uses. The step applies a body-frame twist to a pose, and is its inverse: the twist that carries to . One step of a constant velocity for time is therefore
and it is exact for a robot that really does hold a constant over the step. That last condition is the only approximation the step makes, and it does not depend on .
Why the Euler step fails, exactly
The Euler step moves along the old heading, then turns: , . It is not code the robot runs; the repository has it only as the strawman the first widget measures. Write and positions as complex numbers. Each step adds , so after steps
DerivationAll the Euler poses lie on one circle
Let . Then , so for every : all the poses lie on a circle of radius . Since ,
The true radius is , and for , so the Euler circle is larger. It is centred at , not at . At s, and the radius is 2.048 m against the true 2 m. Both circles are traversed at the same angular rate, so the error is bounded and periodic, and it vanishes as only at first order, which is the proportionality you saw: the book's checks assert every one of these numbers against the closed form, not against the Rust.
From wheels to a twist
The robot's wheels have radius and sit a distance apart. If the left wheel rolls an arc and the right wheel an arc , the body moves forward by their mean and turns by their difference over the track:
and the pose changes by . A worked example with the book's rover Rusty
( m): the left wheel rolls 0.72 m and the right 0.88 m. Then m, rad,
and the pose is . As a check that does not use the code: the arc has radius
m, so and , which is the same pair of numbers.
Test diff_drive::tests::worked_example_wheel_arcs pins it.
Noise: the same command, many poses
Real wheels do not do what they are told. The velocity motion model of Thrun, Burgard and Fox (Table 5.3) models that by perturbing the commanded speeds before taking the exact step. It draws
with zero-mean normal whose variances are , and
, takes the exact arc at , and then turns by a final .
The repository's default is . With every the model returns the
exact arc: for , , the end pose is
, which is
(velocity::tests::worked_example_exact_arc). The model is a sampler: to know what the robot
might do, draw many poses, as the second widget does.
Belief, and the Bayes filter
The robot's knowledge of where it is, given everything it has been told and sensed, is a probability distribution over poses. It changes in two ways. A motion spreads it out: given the control ,
A sensor reading narrows it by Bayes' rule, where is whatever number makes the result sum to one:
The number is the evidence: how likely the reading was before it was seen. A very small
evidence says the filter was surprised, and the repository's correct returns it so that the
caller can notice. Both formulas need a representation of . The first one is a histogram.
A worked example. A 4 m corridor of four 1 m cells, robot position unknown, so the belief is .
A sensor reports a likelihood of 0.2 in cells 0 and 2 and 0.8 in cells 1 and 3. Multiplying gives ,
which sums to , the evidence, and dividing by it gives . The test is
histogram::tests::worked_example_four_cells. Notice that two cells have the highest belief, and
that the belief still puts 20 per cent on the other two.
The Gaussian belief and the Kalman filter
A histogram over a corridor is 100 numbers. A histogram over at the same resolution is . The alternative is to assume the belief is a Gaussian and carry only and . If the motion and the measurement are linear and the noises Gaussian, that assumption is not an approximation: both steps of the Bayes filter map a Gaussian to a Gaussian, and the Kalman filter is their closed form. In one dimension, with Thrun's naming ( is motion noise, is measurement noise), the prediction is
and the correction multiplies the predicted Gaussian by the measurement's likelihood . Two Gaussians multiply to a Gaussian, and the result is
DerivationThe correction is a product of two Gaussians
Up to a constant, . Collect the terms in : the exponent is , which is a Gaussian with precision and mean . Writing ,
The mean sits at the precision-weighted average of the two, so it is halfway between them only if their variances are equal. Check with the repository's pinned example: has , , , which is .
The gain is the whole story. When the measurement is precise (), and the posterior follows the measurement. When it is noisy (), and the posterior ignores it. Nothing in the filter splits the difference half and half unless . The repository computes not as but in the Joseph form,
which is algebraically the same and stays symmetric and positive-definite when rounding errors accumulate.
A worked example (kalman::tests::worked_example_1d_cart): a cart believed to be at is commanded to move
with and then measured at with . The prediction is ; the innovation variance
is ; the gain is ; the posterior is . The posterior mean is 71 per cent of the way
from the prediction (1) to the measurement (1.7), not 50. Run the filter for a second step (, ) and it gives , .
The algorithm
Two tables in the style of Thrun's, with the symbols of this book, as the repository implements them.
- predict:
- correct: , with
- return and the evidence
- ,
- ,
- return , , and the evidence
Implementation in Rust
All of this is in three crates that do not know ROS exists: robotics_core, robotics_models and
robotics_filters. Every listing below is an excerpt of the file named in its title; the book's build
fails if a line of one is not in that file, in that order.
The pose and its exponential
/// A planar pose: translation `(x, y)` in metres and heading `θ` in radians, `θ ∈ (−π, π]`.
///
/// The struct is `Copy`; composing poses does not allocate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Se2 {
x: f64,
y: f64,
theta: f64,
}
// …
pub fn exp(tau: &Tangent2) -> Self {
let (vx, vy, w) = (tau[0], tau[1], tau[2]);
let (a, b) = if w.abs() < SMALL_ANGLE {
(1.0 - w * w / 6.0, w / 2.0 - w * w * w / 24.0)
} else {
let s = (0.5 * w).sin();
(w.sin() / w, 2.0 * s * s / w)
};
Self::new(a * vx - b * vy, b * vx + a * vy, w)
}
// …
pub fn compose(&self, other: &Se2) -> Se2 {
let (s, c) = self.theta.sin_cos();
Se2::new(
self.x + c * other.x - s * other.y,
self.y + s * other.x + c * other.y,
self.theta + other.theta,
)
}
// …
impl Manifold<3> for Se2 {
#[inline]
fn boxplus(&self, delta: &SVector<f64, 3>) -> Self {
self.compose(&Se2::exp(delta))
}
#[inline]
fn boxminus(&self, base: &Self) -> SVector<f64, 3> {
base.between(self).log()
}
}Se2::new normalizes the heading, so the angle is always in . exp is the formula above, with
the series for small ; boxplus is and boxminus is . The trait Manifold
is what every filter of later chapters is generic over.
Wheels to a pose
pub fn wheel_arcs_to_tangent(&self, ds_left: f64, ds_right: f64) -> Tangent2 {
Tangent2::new(
0.5 * (ds_right + ds_left),
0.0,
(ds_right - ds_left) / self.wheel_separation,
)
}
// …
fn apply(&mut self, delta: Tangent2, dt: f64) -> OdometryStep {
let step_pose = Se2::exp(&delta);
// Compound the covariance: the old uncertainty is carried through the new step
// (Ad of the inverse increment) and the fresh step noise is added.
let ad_inv = step_pose.inverse().adjoint();
self.cov = ad_inv * self.cov * ad_inv.transpose() + self.noise.step_covariance(&delta);
self.pose = self.pose.boxplus(&delta);WheelOdometry::apply is the odometry step: it advances the pose by and, in the same breath, carries the old
covariance through the new step with the adjoint of the inverse increment and adds the step's own noise. A pose
uncertainty expressed in the body frame has to be rotated into the next body frame, and the adjoint is how. The ROS
node wheel_odometry_node reads joint_states, calls this, and publishes /odom.
One limit belongs here, because the repository states it: the covariance this produces is a simple per-step noise model,
and measured against simulated wheel slip it is over-confident, which docs/SOURCE_MAPPING.md (deviation 4)
records and keeps as the raw-odometry baseline. A later chapter measures how over-confident; this one only asks
you not to trust the number as a calibrated probability.
The noisy step
/// Table 5.3: `v̂ = v + N(0, α₁v² + α₂ω²)`, `ω̂ = ω + N(0, α₃v² + α₄ω²)`,
/// `γ̂ = N(0, α₅v² + α₆ω²)`.
fn sample<R: Rng + ?Sized>(&self, x_prev: &Se2, u: &VelocityCmd, rng: &mut R) -> Se2 {
let a = &self.alphas.0;
let (v2, w2) = (u.v * u.v, u.omega * u.omega);
let v_hat = u.v + sample_normal(a[0] * v2 + a[1] * w2, rng);
let w_hat = u.omega + sample_normal(a[2] * v2 + a[3] * w2, rng);
let g_hat = sample_normal(a[4] * v2 + a[5] * w2, rng);
Self::integrate(x_prev, v_hat, w_hat, g_hat, u.dt)
}The generator rng is passed in. The repository has no entry point that reads the operating system's entropy,
so a run is a function of its seed, and every stochastic widget in this book shows its seed and lets you re-roll it.
The filters
pub trait BayesFilter {
/// The belief representation (a grid, a Gaussian, a particle set, …).
type Belief;
/// The control input type.
type Control;
/// The measurement type.
type Measurement;
// …
fn predict(&mut self, u: &Self::Control) -> Result<(), FilterError>;
// …
fn correct(&mut self, z: &Self::Measurement) -> Result<f64, FilterError>;
// …
}Both methods return a Result, which the 2005 book's trait does not (docs/SOURCE_MAPPING.md, deviation 2): a
production node must be able to refuse a control or a measurement that is not finite. correct returns the evidence.
fn correct(&mut self, likelihood: &Self::Measurement) -> Result<f64, FilterError> {
for i in 0..self.cells.len() {
let l = likelihood(self.center(i));
if !l.is_finite() || l < 0.0 {
return Err(FilterError::NonFinite("likelihood"));
}
self.cells[i] *= l;
}
self.normalize()
}That is Bayes' rule on a grid: multiply each cell by the likelihood at its centre, then normalize, which returns the sum
it divided by, the evidence. If every cell gets zero likelihood it returns ZeroLikelihood instead of a belief that
is not a distribution.
fn predict(&mut self, u: &SVector<f64, U>) -> Result<(), FilterError> {
if u.iter().any(|v| !v.is_finite()) {
return Err(FilterError::NonFinite("control"));
}
let mean = self.a * self.belief.mean() + self.b * u;
let cov = self.a * self.belief.cov() * self.a.transpose() + self.r;
self.belief = Gaussian::new(mean, cov)?;
Ok(())
}
fn correct(&mut self, z: &SVector<f64, M>) -> Result<f64, FilterError> {
// …
let innovation = z - self.c * mu;
let s = self.c * sigma * self.c.transpose() + self.q;
let chol = Cholesky::new(s).ok_or(FilterError::SingularInnovation)?;
let gain = sigma * self.c.transpose() * chol.inverse();
let mean = mu + gain * innovation;
let ikc = SMatrix::<f64, N, N>::identity() - gain * self.c;
let cov = ikc * sigma * ikc.transpose() + gain * self.q * gain.transpose();
self.belief = Gaussian::new(mean, cov)?;
// …
}The generic KalmanFilter<N, U, M> is Table 3.1 in a few dozen lines. Notice that the covariance update is the Joseph form,
and that Gaussian::new refuses a covariance that is not positive-definite, so a numerical failure becomes an error at the line that caused it.
The worked examples, run
The two examples of the chapter, reproduced by the book's build on the module the widgets use. Each number below is asserted
by npm run check.
wheel arcs (Rusty, ℓ = 0.16): Δs_L = 0.72, Δs_R = 0.88
Δs = 0.80 Δθ = 1.0 pose = (0.673176788, 0.367758155, 1.0)
1-D cart, A = B = C = 1, R = 0.25, Q = 0.5, prior N(0, 1)
step 1 u = 1 predict μ̄ = 1.0 Σ̄ = 1.25
z = 1.7 correct S = 1.75 K = 5/7 μ = 1.5 Σ = 2.5/7
step 2 u = 1 predict μ̄ = 2.5 Σ̄ = 4.25/7
z = 2.1 correct S = 7.75/7 K = 4.25/7.75 μ = 2.280645161290323 Σ = 0.2741935483870968Putting it together
The widget below is the cart of the example, with the gain as the thing to watch.
- prior
- prediction
- measurement
- posterior
Now the hallway. A robot that does not know where it starts walks a 10 m corridor with three identical doors and asks a
door detector, right 90 per cent of the time, after every 0.5 m. A HistogramFilter1D of 100 cells tracks it.
- prediction
- posterior
- ground truth
After the first "door" reading from the uniform belief, the belief has three equal bumps, each holding 22 per cent of the mass; the sensor cannot tell the doors apart, and that is a statement about the sensor, not a failure of the filter. Only later readings, with motion in between them, tell the doors apart. The filter's most probable cell is a poor answer to "where is the robot" whenever the belief has more than one bump, and a mean is no better: the mean of two bumps sits between them, where the robot is not. A belief is not a point.
Here is the problem of the chapter, restated as numbers.
- The step. The exact step ends within m of the true pose at every step length, and the Euler step is 0.2134 m off at s.
- The noise. The same command gives a cloud, and the cloud bends along the arc.
- The belief. A histogram assumes nothing and costs a cell per resolution element; with three coordinates it costs a million. A Kalman filter costs two numbers per dimension and is exact only when the world is linear and Gaussian, which a pose is not: it lives on a curved space, and the next chapters' filters work on its tangent space for exactly that reason.
What this chapter does not show, and the repository says it does not: the odometry covariance of Chapter 1 is not calibrated, the histogram filter is one-dimensional, and the Kalman filter in the widget is the scalar case. The first is measured in a later chapter, and the other two are the reason a later chapter exists.
Exercises
- Foundation: derive exerciseDifficulty 2 of 3One circle, which radius
Show that the Euler poses of the first widget lie on a single circle and find its radius in terms of , and . Which direction is its centre shifted from the true centre ?
Solution
With , is a geometric sum. Put : then and , a constant. Since the radius is . The centre is , which has a positive component: shifted to the right in the widget.
- Conceptual: predict, then check exerciseDifficulty 1 of 3A very noisy sensorPredict first
In the last widget, drag Q to its largest value. Where does the posterior end up?
- Conceptual: predict, then check exerciseDifficulty 2 of 3Which door?Predict first
Start the hallway widget with a uniform belief and a detector that says door. How many bumps does the belief have, and how tall are they?
- Foundation: derive exerciseDifficulty 2 of 3The gain from scratch
Derive and by multiplying the two Gaussians of the scalar correction, and evaluate them for , .
Solution
Add the precisions: , so . The mean is with . For the numbers given, and .
- Practical: write Rust exerciseDifficulty 1 of 3Wheels to a pose, in your own test
In
crates/robotics_models/src/diff_drive.rs, write a test that turns the wheel arcs into a pose withwheel_arcs_to_tangentandSe2::exp, and check the result against the closed form , rather than against another call of the same code.Solution sketch
, , so the expected pose is . Compare with
assert_relative_eq!at .worked_example_wheel_arcsin the same file is the template. - Practical: write Rust exerciseDifficulty 2 of 3A corridor that wraps
Add a test to
crates/robotics_filters/src/histogram.rsthat a wrappedHistogramFilter1Dreturns a one-cell belief to its starting cell afterpredict(&1.0)has been called once per cell of the corridor.Solution sketch
Use
HistogramFilter1D::uniform(4, 4.0, true),correctwith a likelihood that is 1 in one cell and 0 elsewhere, then callpredict(&1.0)four times and assert the belief is back on the same cell.exact_shift_moves_massshows the pattern for a single step.
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)
The motion model, Bayes filter and Kalman filter tables this chapter follows; the epigraph is the publisher's description of this book.
- Solà, J., Deray, J. and Atchuthan, D. (2018) A micro Lie theory for state estimation in robotics. arXiv:1812.01537.link to A micro Lie theory for state estimation in robotics (opens in a new tab)
Why the exponential map, the right perturbation and the adjoint in this chapter are written the way they are.
- Kalman, R. E. (1960) A new approach to linear filtering and prediction problems. Journal of Basic Engineering 82(1), 35–45.doi:10.1115/1.3662552 (opens in a new tab)
The original filter; the scalar case of this chapter is its simplest instance.
- Barfoot, T. D. (2017) State Estimation for Robotics. Cambridge University Press.doi:10.1017/9781316671528 (opens in a new tab)
A textbook treatment of poses as Lie group elements and of the Kalman filter on them, the path the next chapters take.
- Borenstein, J. and Feng, L. (1996) Measurement and correction of systematic odometry errors in mobile robots. IEEE Transactions on Robotics and Automation 12(6), 869–880.doi:10.1109/70.544770 (opens in a new tab)
Why wheel odometry is wrong in ways that do not average out, which is the reason to keep a distribution rather than a point.
- Macenski, S., Foote, T., Gerkey, B., Lalancette, C. and Woodall, W. (2022) Robot Operating System 2: Design, architecture, and uses in the wild. Science Robotics 7(66).doi:10.1126/scirobotics.abm6074 (opens in a new tab)
The middleware the repository's nodes run on; this chapter's crates deliberately do not depend on it.