Fusing Odometry and Map Constraints with an EKF
Odometry is smooth, available everywhere and drifts without bound. A map-relative scan match does not drift and is unavailable exactly where the map is featureless. Fusing them is the standard answer, and the standard way to get it wrong is to fuse them with hand-tuned constant covariances, which throws away the one thing each source knows about itself.
This task builds the filter for localization and map matching so that both sources carry their own uncertainty, outliers are gated rather than tuned around, and the lane assignment contributes as the weak lateral constraint it actually is.
What each input contributes, and where each stops contributing:
Prerequisites #
- Python 3.10+, NumPy 1.24+, SciPy 1.11+ (
stats.chi2). - Input: wheel and IMU odometry, a scan-match pose with covariance, and a lane assignment.
- Upstream stage: NDT localization against an HD map for the pose, and the graph matcher for the lane.
- Output: a fused pose and covariance at the odometry rate.
Step-by-Step #
1. Propagate with odometry #
import numpy as np
def predict(x, P, u, dt, Q):
"""Advance the planar state [x, y, theta] with a velocity/yaw-rate input."""
v, w = u
th = x[2]
x_next = x + dt * np.array([v * np.cos(th), v * np.sin(th), w])
F = np.array([[1.0, 0.0, -dt * v * np.sin(th)],
[0.0, 1.0, dt * v * np.cos(th)],
[0.0, 0.0, 1.0]])
return x_next, F @ P @ F.T + Q * dt
Scaling the process noise by dt rather than adding a per-step constant is what makes the filter behave identically at 50 Hz and 100 Hz — a surprisingly common source of a filter that was tuned at one rate and is over-confident at another.
2. Update from the scan match, using its covariance #
def update(x, P, z, R, H=None):
H = np.eye(3) if H is None else H
y = z - H @ x
y[2] = np.arctan2(np.sin(y[2]), np.cos(y[2])) # wrap the heading residual
S = H @ P @ H.T + R
K = P @ H.T @ np.linalg.inv(S)
x_new = x + K @ y
P_new = (np.eye(len(x)) - K @ H) @ P
return x_new, P_new, y, S
R is the matcher's own reported covariance, not a constant. Wrapping the heading residual is not optional: an unwrapped residual near ±π produces a correction of nearly a full turn.
3. Gate on Mahalanobis distance #
from scipy.stats import chi2
GATE = chi2.ppf(0.99, df=3) # ≈ 11.34
def accept(y, S) -> bool:
return float(y @ np.linalg.inv(S) @ y) <= GATE
The gate is derived, not chosen, so it can be reasoned about: at 99% confidence a valid observation is rejected one time in a hundred, and the rejection rate becomes a diagnostic. A gate firing on 20% of frames is telling you the covariances are wrong, not that the sensor is faulty.
4. Add the lane constraint as a lateral pseudo-measurement #
def lane_pseudo_measurement(x, P, lane, width_frac: float = 0.33):
"""The map says the vehicle is near this lane's centre — laterally only."""
n = lane.normal_at(x[:2]) # unit lateral direction
H = np.array([[n[0], n[1], 0.0]]) # observes lateral offset only
z = np.array([0.0]) # target: zero offset from centre
y = z - H @ x + H @ lane.centre_at(x[:2]).reshape(-1)
R = np.array([[(width_frac * lane.width) ** 2]])
S = H @ P @ H.T + R
K = P @ H.T @ np.linalg.inv(S)
return x + (K @ y).ravel(), (np.eye(3) - K @ H) @ P
Key parameters: width_frac at 0.33 makes the constraint weak — one standard deviation is about a metre on a normal lane — so it cannot overrule a good scan match and can still stop a long odometric stretch from drifting laterally out of the lane. The observation matrix has a single row precisely because the map says nothing about along-track position.
Why the lane constraint has to be lateral only:
Verification & Acceptance Criteria #
def assert_filter(track, truth) -> None:
err = np.abs(np.array(track.xy) - np.array(truth.xy))
nis = np.array(track.nis) # normalized innovation squared
assert 1.0 <= nis.mean() <= 5.0, f"NIS mean {nis.mean():.2f} — covariances are wrong"
rej = np.mean(track.gated)
assert rej <= 0.05, f"{rej:.1%} of updates gated — check R, not the sensor"
inside = np.mean([e <= 3.0 * s for e, s in zip(err[:, 0], track.sigma_lat)])
assert inside >= 0.99, "filter is over-confident"
Acceptance gate: mean normalized innovation squared between 1 and 5 for a three-dimensional measurement — the standard consistency check, and the one that catches both over- and under-confident covariances; gate rejection rate ≤5%; and ≥99% of lateral errors inside 3σ.
What the consistency statistics say, which is the only way to tell a tuned filter from a plausible one:
Common Errors & Fixes #
The estimate slides during a tunnel and reports high confidence. A constant R is being used. Pass the matcher's Hessian-derived covariance through.
Heading jumps by nearly 2π occasionally. The innovation is not wrapped. Wrap it in the update.
The gate rejects a fifth of all updates. The covariances are inconsistent, usually because process noise is far too small. Check the NIS before touching the gate.
The filter behaves differently at a higher odometry rate. Process noise is added per step rather than scaled by dt.
The lane constraint fights the scan match. width_frac is too small, making a weak prior into a strong one. A third of the lane width is about right; anything under a fifth starts to overrule real observations.
FAQ #
Why use the matcher's own covariance rather than a tuned constant? #
Because the matcher knows something the tuner cannot: how observable the pose was in that particular frame. A constant covariance makes the filter trust a tunnel frame exactly as much as an urban one, so the estimate follows a scan match that is sliding along the tunnel. Feeding the Hessian-derived covariance through means the filter automatically leans on odometry where the map stops helping, which is the behaviour a tuned constant is always trying and failing to approximate.
What is a lane pseudo-measurement? #
An observation the filter treats like a sensor reading but which comes from the map: given a lane assignment, the vehicle is near that lane's centre, laterally, with an uncertainty of roughly a third of the lane width. It is weak information, and that is the point — it cannot fight a good scan match, and it stops a purely odometric stretch from drifting laterally out of its lane. Applying it along-track as well is the mistake, since the map says nothing about where along the lane the vehicle is.
How should the Mahalanobis gate be set? #
From the chi-square distribution for the measurement's dimension at a chosen confidence — about 11.3 for three degrees of freedom at 99 percent. Setting it by eye either rejects good updates during genuine manoeuvres or admits the occasional wildly wrong scan match that a symmetric environment produced. Counting rejections is as important as making them: a gate that fires on more than a few percent of frames is reporting that the covariances are wrong, not that the sensor is.
Related #
- NDT Localization Against an HD Map — the source of the covariance this filter depends on.
- Map-Matching GNSS Traces to Lane-Level Geometry — the source of the lane assignment.
- Handling Coordinate Drift in Multi-Sensor Setups — the same covariance-gating discipline applied to extrinsic drift.
Up one level: Localization & Map Matching — the stage this filter closes.