Measuring HD Map Accuracy Against Survey Control
An accuracy figure is only worth what its ground truth is worth, and the commonest way to produce a worthless one is to measure against control the map was built from. This task produces a defensible number for HD map quality assurance and certification: a deterministic holdout taken before the build, a resolution step that matches each withheld point to what it was surveyed on, a distribution rather than a mean, and a bias/scatter split that tells you whether you have a datum problem or a survey problem.
Where the withheld set has to sit relative to the pipeline — before the datum fit, not after it:
Prerequisites #
- Python 3.10+, NumPy 1.24+, SciPy 1.11+ (
spatial.cKDTree), pyproj 3.6+. - Input: a surveyed control set in a known datum and epoch, each point carrying the identifier of the feature it was observed on.
- Upstream stage: the split must happen before any pipeline stage consumes control, including the datum alignment described in coordinate reference systems for AVs.
- Output: an accuracy report — per-stratum counts, lateral and vertical statistics, and the mean error vector.
Step-by-Step #
1. Split the control set deterministically and by stratum #
An unstratified split concentrates the holdout wherever control is dense, which is normally motorways.
import numpy as np
def stratified_holdout(ids, points, road_class, frac=0.3, seed=20260809):
"""Withhold `frac` of control within each road class, reproducibly."""
rng = np.random.default_rng(seed)
fit, held = {}, {}
for cls in sorted(set(road_class)):
idx = [i for i, c in enumerate(road_class) if c == cls]
rng.shuffle(idx)
cut = int(len(idx) * (1.0 - frac))
for i in idx[:cut]:
fit[ids[i]] = points[i]
for i in idx[cut:]:
held[ids[i]] = points[i]
return fit, held
Key parameters: seed is recorded in the validation record so the split can be reproduced by an auditor; frac at 0.3 leaves enough control to fit the datum while giving the holdout statistical weight. Expected output: two dicts whose key sets are disjoint and whose union is the input.
2. Resolve every withheld point against the map #
Match by feature identifier where the survey recorded one, and fall back to nearest-feature only when it did not — a nearest-feature match on a multi-lane carriageway can silently pick the wrong lane.
from scipy.spatial import cKDTree
def resolve(held: dict, map_features: dict, max_snap_m: float = 0.5):
"""Map each withheld point to the mapped position it should coincide with."""
ids = sorted(map_features)
tree = cKDTree(np.array([map_features[i] for i in ids]))
resolved, unmatched = {}, []
for pid, p in held.items():
if pid in map_features: # surveyed feature still exists
resolved[pid] = map_features[pid]
continue
d, j = tree.query(p, distance_upper_bound=max_snap_m)
if np.isfinite(d):
resolved[pid] = map_features[ids[j]]
else:
unmatched.append(pid)
return resolved, unmatched
unmatched is not an error to suppress: a withheld point with no feature within half a metre is either a coverage gap or a gross error, and both belong in the report.
3. Report the distribution, split lateral from vertical #
def error_stats(resolved: dict, held: dict) -> dict:
common = sorted(set(resolved) & set(held))
d = np.array([resolved[k] for k in common]) - np.array([held[k] for k in common])
lat = np.linalg.norm(d[:, :2], axis=1)
vert = np.abs(d[:, 2])
return {
"n": len(common),
"lateral": {"rmse": float(np.sqrt((lat ** 2).mean())),
"p95": float(np.percentile(lat, 95)),
"max": float(lat.max())},
"vertical": {"rmse": float(np.sqrt((vert ** 2).mean())),
"p95": float(np.percentile(vert, 95)),
"max": float(vert.max())},
"mean_vector_m": d.mean(axis=0).tolist(),
}
The gate is written against lateral.p95 and lateral.max; vertical is reported and gated separately because its acceptable magnitude is different, not because it matters less.
Reading the mean error vector: the same p95 can mean two completely different defects.
Verification & Acceptance Criteria #
def assert_accuracy_report(report, held, fit, criteria) -> None:
assert not (set(held) & set(fit)), "holdout leaked into the fit set"
for cls, stats in report["by_class"].items():
assert stats["n"] >= 100, f"{cls}: only {stats['n']} withheld points"
assert stats["lateral"]["p95"] <= criteria.p95_m, f"{cls} p95 over budget"
assert stats["lateral"]["max"] <= criteria.max_m, f"{cls} max over budget"
bias = np.linalg.norm(report["mean_vector_m"][:2])
assert bias <= 0.02, f"systematic shift of {bias:.3f} m — fix upstream"
Acceptance gate: holdout and fit sets disjoint; ≥100 withheld points per road class in the claim; lateral p95 ≤0.10 m and max ≤0.25 m; and a horizontal mean error vector ≤0.02 m, because anything larger is a bias rather than noise and has a cause worth finding.
How an accuracy claim decays when the holdout stops being held out:
Common Errors & Fixes #
Accuracy is suspiciously good on one road class. That class's holdout is tiny, so the percentile is estimated from a handful of points. Stratify the split and gate on per-class counts.
Every point resolves but the errors are large and directional. A datum or epoch mismatch, visible as the mean vector. Check that the survey epoch and the map epoch agree before touching the extraction.
Many points come back unmatched. Either the map has a coverage gap there, or the survey identifiers were not carried through the build. Both are findings; suppressing them by widening max_snap_m converts a coverage gap into a fictitious accuracy number.
The number changes when the pipeline is re-run on identical inputs. The split seed is not pinned, so a different holdout is measured each time. Pin the seed and record it.
FAQ #
How many control points does an accuracy claim need? #
Enough that the 95th percentile is stable, which in practice means at least a few hundred withheld points spread across the road classes in the claim. The count that matters is per stratum, not overall: two thousand motorway points and eleven residential ones support a motorway claim and nothing else. Report the count alongside the statistic so a reader can judge how much weight the tail estimate carries.
Should the error be measured in 3D or in the horizontal plane? #
Both, reported separately. Lateral error is what a lane-keeping controller consumes and it is the number a lane-level claim rests on; vertical error matters for grade, drainage and for sensors that project onto the road surface, and it is usually several times larger because GNSS height is weaker than GNSS position. Collapsing them into one 3D distance produces a figure that is dominated by the vertical component and answers neither question.
What does a non-zero mean error vector tell you? #
That the release has a systematic shift rather than random scatter — a datum, epoch or projection mismatch somewhere upstream. It is good news in the sense that a bias is correctable and scatter is not, but it must be fixed at its source rather than subtracted out: a pipeline that applies a measured offset is a pipeline that will apply the wrong offset the moment the underlying mismatch changes.
Related #
- Building a Map Release QA Gate in Python — where these statistics become a pass or a refusal.
- Producing ISO 26262 Traceability for Map Artefacts — recording which split and which toolchain produced a given number.
- Converting WGS84 to UTM for AV Pipelines — the projection step a systematic bias usually traces back to.
Up one level: HD Map Quality Assurance & Certification — the assurance stage this measurement is the core of.