NDT Localization Against an HD Map

Localizing against a map is registration with an asymmetry: one cloud is live and small, the other is prebuilt and large, and the large one does not change. The normal distributions transform exploits that asymmetry directly — the map is reduced once to a grid of local Gaussians, and every subsequent frame is scored against that grid rather than against points.

This task implements it for localization and map matching, and pays particular attention to the output most implementations discard: the Hessian, which is where the honest covariance comes from.

What the offline precomputation buys, per frame:

Per-Frame Cost With and Without Precomputed Voxel Distributions Two horizontal cost bars against a 100 millisecond budget line, one dominated by map voxelisation and one dominated by nothing. 100 ms frame budget per-frame voxelisation build map voxel distributions · 240 ms +12 precomputed with the tile lookup 2 ms + optimise 12 ms = 14 ms the distributions depend only on the map, so computing them per frame recomputes an unchanged quantity and nine numbers per voxel is usually a large compression over the raw map points inside it

Prerequisites #

  • Python 3.10+, NumPy 1.24+, SciPy 1.11+, open3d 0.18+ for the reference implementation.
  • Input: a map tile carrying precomputed voxel distributions, a motion-compensated live sweep, and a pose prior.
  • Upstream stage: motion compensation, as in motion-compensating LiDAR scans with SLERP.
  • Output: a 6-DOF pose with a covariance derived from the score surface.

Step-by-Step #

1. Precompute voxel distributions with the tile #

python
import numpy as np

def build_ndt(points: np.ndarray, voxel_m: float = 1.0, min_pts: int = 6):
    """Per-voxel mean and covariance, computed once and stored with the tile."""
    keys = np.floor(points / voxel_m).astype(np.int64)
    order = np.lexsort(keys.T)
    keys, points = keys[order], points[order]
    edges = np.flatnonzero(np.any(np.diff(keys, axis=0), axis=1)) + 1

    cells = {}
    for a, b in zip(np.r_[0, edges], np.r_[edges, len(points)]):
        if b - a < min_pts:
            continue                        # too few points for a stable covariance
        blk = points[a:b]
        mu = blk.mean(axis=0)
        cov = np.cov(blk.T) + 1e-4 * np.eye(3)   # regularize a planar cell
        cells[tuple(keys[a])] = (mu, np.linalg.inv(cov))
    return cells

Key parameters: voxel_m at 1 m is the usual outdoor choice — small enough to capture kerbs and poles, large enough that most cells have a stable covariance. The 1e-4 regularization keeps a perfectly planar cell, which is what a road surface produces, from having a singular covariance.

2. Seed from the propagated prior #

python
def seed_pose(prev_pose, odom_delta):
    """Start the optimiser from where odometry says the vehicle went."""
    return prev_pose @ odom_delta

NDT's basin is wider than ICP's but not unlimited. Seeding from odometry keeps the optimiser inside it; seeding from the previous pose without propagation costs a metre of error at 15 m/s and 100 ms, which at 1 m voxels is enough to score against the wrong cells.

3. Optimise the score #

python
from scipy.optimize import minimize

def ndt_score(pose_vec, cloud, cells, voxel_m):
    T = pose_from_vec(pose_vec)
    pts = (cloud @ T[:3, :3].T) + T[:3, 3]
    keys = np.floor(pts / voxel_m).astype(np.int64)
    total = 0.0
    for p, k in zip(pts, map(tuple, keys)):
        cell = cells.get(k)
        if cell is None:
            continue
        mu, inv = cell
        d = p - mu
        total -= np.exp(-0.5 * d @ inv @ d)      # negative: we minimise
    return total


def localize(cloud, cells, seed, voxel_m=1.0):
    res = minimize(ndt_score, vec_from_pose(seed), args=(cloud, cells, voxel_m),
                   method="Newton-CG", jac=True, hess=True)
    return pose_from_vec(res.x), res

Points falling in empty cells contribute nothing rather than a penalty, which is what makes NDT tolerant of a live sweep containing things the map does not — a lorry, a pedestrian, roadworks.

4. Derive the covariance from the Hessian #

python
def pose_covariance(res, floor_m=0.02, inflate=4.0) -> np.ndarray:
    """Covariance from the score Hessian, inflated along weak directions."""
    cov = np.linalg.inv(res.hess)
    w, V = np.linalg.eigh(cov)
    weak = w > (floor_m ** -2)              # small Hessian eigenvalue => large variance
    w[weak] *= inflate
    return V @ np.diag(w) @ V.T

This is the step that makes a tunnel behave correctly. The along-track eigenvalue collapses because every cross-section scores the same, the corresponding variance blows up, and the filter downstream weights the NDT observation accordingly instead of trusting a pose that is sliding.

What the Hessian eigenvalues look like across three scenes:

Hessian-Derived Uncertainty in Three Scenes Three uncertainty ellipses — round and small, long and thin along track, and large in both directions — with the scene that produces each. structured urban tunnel open field lateral 0.04 m · along 0.05 m lateral 0.05 m · along 1.8 m both directions unconstrained the middle ellipse is the one a fixed covariance gets most dangerously wrong — lateral is still excellent

Verification & Acceptance Criteria #

python
def assert_ndt_localization(track, truth) -> None:
    lat = np.abs(lateral_errors(track, truth))
    assert np.percentile(lat, 95) <= 0.15, "lateral p95 over budget"

    inside = np.mean([e <= 3.0 * s for e, s in zip(lat, track.sigma_lat)])
    assert inside >= 0.99, "covariance is optimistic"

    tunnel = track.in_tunnel
    assert np.mean(track.sigma_along[tunnel]) > 5.0 * np.mean(track.sigma_along[~tunnel]), \
        "covariance did not grow in the tunnel — observability is not being read"

Acceptance gate: lateral p95 ≤0.15 m; ≥99% of errors inside 3σ; and along-track uncertainty growing by at least fivefold through an unobservable stretch, which is the check that proves the Hessian is being used rather than a constant.

What the voxel size trades, which is the one parameter worth sweeping on your own map:

Voxel Size and What It Trades Four rows pairing an NDT voxel size with cell stability, captured structure and basin width. voxel size against what the score can see 0.25 m very fine most cells below the point minimum sparse score 0.5 m fine kerbs and poles captured, cells stable accurate, narrow basin 1.0 m the outdoor default structure captured, basin comfortable the working choice 3.0 m coarse fine structure averaged away wide basin, low accuracy sweep it against your own map rather than adopting a number — the right value tracks the density of your survey

Common Errors & Fixes #

Localization is fine and the frame budget is missed. Voxel distributions are being built per frame. Precompute them with the tile.

The pose slides in a tunnel and the covariance does not move. A fixed covariance is being reported. Derive it from the Hessian and inflate along weak eigenvectors.

Singular covariance in road-surface cells. A perfectly planar cell has a rank-2 covariance. Regularize with a small diagonal term, as in step 1.

The optimiser fails to converge after a stop. The seed used the previous pose without odometry propagation, and the vehicle moved. Propagate.

Score is dominated by a nearby lorry. It is not — points in cells the map does not have contribute nothing. If the score is being pulled, the lorry is being scored against cells that describe the road behind it; raise min_pts so sparse cells do not become distributions.

FAQ #

Why precompute the NDT map offline? #

Because the voxel means and covariances depend only on the map, which does not change between frames, and computing them per frame would dominate the cycle. Storing them with the tile turns per-frame localization into a scoring problem over a lookup table, and it also makes the representation compact: a voxel's distribution is nine numbers regardless of how many map points fell inside it, which is often a large compression over the raw cloud.

What does the Hessian tell you about observability? #

Its eigenvalues say how sharply the score falls off in each direction of pose space, so a small eigenvalue means the optimiser cannot tell where it is along that eigenvector. In a tunnel the along-track eigenvalue collapses while the lateral one stays healthy, which is exactly the geometric fact that every cross-section looks alike. Inverting the Hessian gives a covariance that carries that structure, which is why it is worth using rather than a fixed uncertainty.

How does NDT differ from ICP for this job? #

NDT scores a point against a local distribution rather than against a nearest neighbour, so it needs no correspondence search and its score surface is smooth. That makes it more tolerant of a poor initial guess and less sensitive to differing point densities between the live sweep and the map, both of which matter for localization. ICP is more accurate when it converges from a good seed, which is why some stacks run NDT to get close and a short ICP to finish.

Up one level: Localization & Map Matching — the stage this localizer is the metric half of.