Choosing a Registration Method for Sparse Scans
The comparison in registration method selection sets out what ICP, NDT and a global pass each cost and each buy. What it leaves open is the decision, and a decision made once — in a design document, for a scene that no longer resembles what the fleet drives — is how a stack ends up running ICP through tunnels.
This task replaces that with three measurements taken from the scan pair in front of you. Each costs microseconds, each is a useful diagnostic in its own right, and together they select the method.
The three measurements and what each one rules out:
Prerequisites #
- Python 3.10+, NumPy 1.24+, SciPy 1.11+ (
spatial.cKDTree). - Input: a source and target cloud with an initial pose estimate.
- Upstream stage: motion compensation and downsampling.
- Output: a method name, the three measurements, and a covariance-inflation flag.
Step-by-Step #
1. Measure overlap #
import numpy as np
from scipy.spatial import cKDTree
def overlap(src: np.ndarray, tgt: np.ndarray, radius: float = 0.5) -> float:
"""Fraction of source points with a target point inside the radius."""
d, _ = cKDTree(tgt).query(src, distance_upper_bound=radius)
return float(np.isfinite(d).mean())
Measured at the initial pose, not the final one, because that is the information available when the decision has to be made. Expected output: a fraction; urban scan pairs one frame apart typically return 0.75–0.9, and anything under 0.3 means the two clouds are barely looking at the same place.
2. Measure density #
def ground_density(points: np.ndarray, cell_m: float = 1.0) -> float:
"""Median points per square metre, over occupied cells only."""
keys = np.floor(points[:, :2] / cell_m).astype(np.int64)
_, counts = np.unique(keys, axis=0, return_counts=True)
return float(np.median(counts) / (cell_m ** 2))
Median over occupied cells rather than mean over the bounding box: a scan with one dense patch and a lot of empty space has a high mean density and few correspondences anywhere useful.
3. Measure structure rank #
def structure_rank(normals: np.ndarray, tol: float = 0.05) -> int:
"""Effective rank of the surface-normal scatter — how many directions are constrained."""
w = np.linalg.eigvalsh(normals.T @ normals / len(normals))
return int((w / w.max() > tol).sum())
A ground plane alone gives rank 1; a ground plane plus a wall gives 2; a corner gives 3. The tolerance decides how weak a direction still counts as constrained, and 0.05 is deliberately generous — the point is to catch the outright degenerate cases, not to grade good scenes.
4. Apply the rule and record it #
from dataclasses import dataclass
@dataclass(frozen=True)
class Choice:
method: str
inflate_covariance: bool
overlap: float
density: float
rank: int
def choose(src, tgt, normals, prior_error_m: float) -> Choice:
ov, den, rk = overlap(src, tgt), ground_density(src), structure_rank(normals)
if ov < 0.30 or prior_error_m > 1.5:
method = "global+ndt"
elif den < 20.0:
method = "ndt"
else:
method = "icp_point_to_plane"
return Choice(method, rk < 3, ov, den, rk)
prior_error_m enters separately because it is a property of the seed rather than of the scan: after a GNSS outage or a stop of unknown duration, no scan measurement can rescue a prior outside the basin.
The method mix across three environments, and why one fixed choice fails somewhere:
Verification & Acceptance Criteria #
def assert_selection(choices, results) -> None:
for c, r in zip(choices, results):
assert r.recorded_choice == c, "the recorded choice does not match the run"
if c.method == "icp_point_to_plane":
assert c.density >= 20.0 and c.overlap >= 0.30, "ICP chosen outside its band"
if c.inflate_covariance:
assert r.sigma_along > 3.0 * r.sigma_lat, "rank deficiency not reflected in σ"
diverged = [r for r in results if r.final_rmse > 0.20]
assert len(diverged) / len(results) <= 0.02, "divergence rate over 2%"
Acceptance gate: the recorded choice matching the run; ICP selected only inside its density and overlap band; a rank-deficient scene producing a visibly anisotropic covariance; and a divergence rate ≤2%, which is the outcome measure the whole procedure exists to move.
What each measurement costs, against the divergence it prevents:
Common Errors & Fixes #
Registration diverges in suburbs and works downtown. ICP is being used at densities where it has too few correspondences. Measure density and switch to NDT below the threshold.
Overlap is high and the fit is still wrong. Overlap was measured at the final pose rather than the initial one, so it is measuring the fit rather than informing it.
Rank is 3 everywhere, including tunnels. The tolerance is too generous, or normals are being computed over too large a neighbourhood so the tunnel walls and ground blur together. Tighten the neighbourhood before tightening the tolerance.
The global pass runs on every frame. prior_error_m is being estimated from the previous residual rather than from the odometry gap. Use the propagated prior's own uncertainty.
Choices are not reproducible. The measurements are being taken after downsampling with a random seed. Pin the downsampling, or measure before it.
FAQ #
Why does sparsity favour NDT over ICP? #
Because ICP's cost is a sum over point-to-point correspondences, and a sparse cloud has few of them — so the objective is noisy and the fit is driven by whichever handful of points happened to land near a surface. NDT scores against a local distribution instead, which pools the map's structure regardless of how many live points fall in a cell, so its objective degrades gracefully as the live cloud thins. Below about 20 points per square metre the difference is large enough to change which method converges.
What is structure rank and why measure it? #
It is the effective rank of the scatter matrix of surface normals in the overlapping region, and it says how many directions the geometry actually constrains. A scene with walls in two directions plus a ground plane has rank three and constrains all six degrees of freedom; a tunnel has rank two and leaves along-track free. Measuring it before registering tells you which directions the result will be unreliable in, whichever method you use.
When is a global pass mandatory rather than optional? #
Whenever the initial pose error is outside the refiner's basin, which for ICP on urban scans is around 0.6 metres and for NDT around 1.5. That is a property of the seed rather than of the scan, so it is checked separately: after a GNSS outage, after a stop of unknown duration, or on the first frame of a session, the prior is simply not good enough and a global pass is required regardless of what the three scan measurements say.
Related #
- Benchmarking ICP vs NDT Registration Accuracy — where the thresholds in this rule were measured.
- Global Registration with FPFH Features and RANSAC — the pass this rule selects when overlap or the prior is poor.
- NDT Localization Against an HD Map — where the rank measurement reappears as a covariance.
Up one level: Registration Method Selection — the decision stage this procedure implements.