Detecting HD Map Changes from Fleet LiDAR
The signal a fleet produces is not "the map is wrong here" — it is a residual field, computed by the localizer for its own purposes, that is large in some places and small in others. Turning that into something a map team can act on means three narrowing steps: rejecting the frames where the residual means the pose was wrong, clustering what remains, and describing the difference in terms the map understands.
This task implements those for change detection and map maintenance, with a hard constraint that the output be small enough to upload from every vehicle continuously.
Where the volume goes at each narrowing step:
Prerequisites #
- Python 3.10+, NumPy 1.24+, SciPy 1.11+, scikit-learn 1.4+ for the clustering.
- Input: the localizer's per-point residuals and their positions, the pose covariance, and the resident map tile.
- Upstream stage: NDT localization against an HD map, which produces both.
- Output: structured candidate records, a few kilobytes each.
Step-by-Step #
1. Test the residual field for spatial structure #
import numpy as np
def locally_structured(res: np.ndarray, xy: np.ndarray,
frac: float = 0.15, ratio: float = 4.0) -> bool:
"""Reject frames whose large residuals are spread across the whole sweep."""
if len(res) < 200:
return False
hi = res > np.percentile(res, 100 * (1 - frac))
if hi.sum() < 20:
return False
return np.linalg.norm(xy[hi].std(axis=0)) < np.linalg.norm(xy.std(axis=0)) / ratio
Key parameters: frac picks the top 15% of residuals, which is generous enough to catch a small change and tight enough to exclude ordinary noise; ratio at 4 requires the high-residual set to be four times more compact than the sweep. Expected output: a boolean, computed in microseconds from arrays that already exist.
2. Cluster the survivors #
from sklearn.cluster import DBSCAN
def cluster(xy: np.ndarray, hi: np.ndarray, eps_m: float = 1.2, min_pts: int = 25):
"""Compact candidate regions from the high-residual points."""
pts = xy[hi]
labels = DBSCAN(eps=eps_m, min_samples=min_pts).fit_predict(pts)
return [pts[labels == k] for k in sorted(set(labels)) if k >= 0]
DBSCAN rather than k-means because the number of changes is unknown and noise must be excluded rather than assigned. min_pts at 25 is the smallest cluster worth describing: below that the semantic extraction has nothing to fit.
3. Describe the difference semantically #
def describe(region_pts, map_tile, tol_m: float = 0.15) -> dict | None:
"""What differs here, in terms the map can express."""
observed = extract_boundary(region_pts)
if observed is None:
return None # nothing describable — drop it
mapped = map_tile.nearest_boundary(observed.centroid)
if mapped is None:
return {"kind": "added_boundary", "geometry": observed.simplified()}
offset = mapped.distance_to(observed)
if offset <= tol_m:
return None # within tolerance — not a change
return {"kind": "moved_boundary", "feature_id": mapped.id,
"offset_m": float(offset),
"direction_deg": float(mapped.bearing_to(observed))}
Returning None for an indescribable cluster is the third narrowing step and the one that removes wet-road reflections and low-sun artefacts: they produce residuals and clusters, and nothing that extracts as a boundary.
4. Emit a compact record #
import json, hashlib
def candidate_record(desc: dict, pose, tile_quadkey: str, vehicle_id: str) -> dict:
rec = {
"tile": tile_quadkey,
"vehicle": vehicle_id,
"observed_at": pose.time_iso,
"pose_sigma_m": float(pose.sigma_lat),
**desc,
}
rec["id"] = hashlib.sha256(
json.dumps({k: rec[k] for k in ("tile", "kind", "feature_id")
if k in rec}, sort_keys=True).encode()).hexdigest()[:16]
return rec
Deriving the identifier from the tile, kind and affected feature — not from the vehicle or the time — is what lets independent observations of the same change be counted as agreement rather than as separate candidates. Including pose_sigma_m lets the scoring stage discount observations taken while localization was poor.
What survives each filter, on one day of one vehicle:
Verification & Acceptance Criteria #
def assert_detector(records, labelled, uplink_budget_kb=500) -> None:
ids = {r["id"] for r in records}
assert len(ids) == len({(r["tile"], r["kind"], r.get("feature_id")) for r in records}), \
"candidate ids are not stable across vehicles — agreement cannot be counted"
tp = len(ids & labelled.real_ids)
assert tp / max(len(ids), 1) >= 0.5, "per-vehicle precision too low before scoring"
assert tp / max(len(labelled.real_ids), 1) >= 0.7, "detector missing real changes"
kb = sum(len(json.dumps(r)) for r in records) / 1024
assert kb <= uplink_budget_kb, f"{kb:.0f} kB exceeds the daily uplink budget"
Acceptance gate: candidate identifiers stable across vehicles observing the same change, which is the precondition for the scoring stage; per-vehicle precision ≥0.5 before agreement scoring; recall ≥0.7 against a labelled set; and total upload inside the daily budget, measured rather than assumed.
What each detector parameter trades, and which one is not a tuning knob:
Common Errors & Fixes #
Every frame in a tunnel becomes a candidate. The residual is global there, and the structure test should reject it. Confirm the test is running before the clustering, not after.
Candidates from different vehicles never match. The identifier includes the vehicle or the timestamp. Derive it from the tile, kind and feature only.
The uplink budget is blown by one drive. Geometry is being uploaded at full resolution. Simplify before emitting — a boundary candidate needs a handful of vertices, not the extracted polyline.
Wet weather produces hundreds of candidates. The clusters are real and indescribable, so the semantic filter should drop them. If it is not, extract_boundary is fitting to reflections; raise its inlier floor, as in the RANSAC discussion in extracting lane boundaries from point cloud data.
Detection cost dominates the compute budget. The semantic stage is running on every frame rather than on flagged regions. Gate it on the structure test.
FAQ #
Why not upload the point cloud and detect changes in the cloud? #
Because the uplink cannot carry it. A fleet of a thousand vehicles producing tens of megabytes a second of LiDAR would need a data budget nobody has, and the vast majority of it would describe a road that has not changed. Detecting onboard and uploading a described candidate of a few kilobytes moves the same information at a millionth of the cost, and keeps the raw evidence available locally for the small fraction of candidates a surveyor actually opens.
What makes a residual cluster a candidate rather than noise? #
Spatial compactness and a describable difference. Noise produces large residuals scattered across the frame; a change produces them in one place. Requiring a cluster with a minimum point count and a bounded extent removes the scatter, and requiring that a semantic feature can actually be extracted and compared removes clusters that are merely a wet road or a low sun. A cluster nobody can describe is not a candidate.
Should detection run on every frame? #
The structure test should, because it is nearly free and reads arrays the localizer already computed. The semantic comparison should not — it is the expensive stage and only needs to run on regions the test flags, which on a healthy map is a small fraction of driving. Running the expensive stage everywhere is how change detection acquires a reputation for being unaffordable onboard.
Related #
- Scoring and Triaging Map Change Candidates — what happens to these records once several vehicles have produced them.
- NDT Localization Against an HD Map — the source of the residual field this detector reads.
- Closing the Map Update Loop with Automated Repair — the end of the loop these candidates start.
Up one level: Change Detection & Map Maintenance — the stage this detector opens.