Scan-to-map point-to-plane ICP, solved by Gauss-Newton on SE(3). This is the
stage that produces pose_; everything else feeds it or consumes it.
Code: registration.cpp,
registration.hpp.
The solver lives separately in gauss_newton.hpp
— see gauss_newton.md.
Self-check: test_registration.cpp.
The loop, four steps:
PREDICT → ASSOCIATE → SOLVE → ACCEPT or COAST
↑ └──────────────┘
(IMU) repeat, max 30×
ICP is a local optimizer. It has a small basin of convergence, and outside it, it does not fail loudly — it locks onto the wrong wall and reports a confident, low-residual, completely wrong fit. The prior's entire job is to land the initial guess inside that basin.
with
Rotation comes from the integrated gyro and is genuinely good — the same integration that drives deskew (deskew.md), reused. Rotation is the dangerous DoF for ICP (a few degrees of error moves distant points metres), so this is the prior that matters most.
Translation comes from a constant-velocity model — see §3.5, it has history.
ICP breaks a chicken-and-egg problem — you need the pose to find correspondences, and the correspondences to find the pose — by alternating: assume the current pose is right, find correspondences, solve, repeat.
repeat (max_iterations):
1. ASSOCIATE q = T·p ; find the nearest map plane to q
2. SOLVE T ← argmin Σ (point-to-plane residual)²
That alternation is the ICP loop. In this codebase it is literally the callback the solver invokes each iteration — see gauss_newton.md.
Correspondence is a hash lookup, not a KD-tree query. The map caches one plane per
voxel, so we hash q to its voxel, scan the 27-cell neighbourhood, and take the
nearest valid plane within max_correspondence_distance. Constant work per point.
This is the single biggest reason registration is fast enough — see the GICP
comparison in pipeline.md.
Why the 27 cells are enough: with
map.voxel_size ≥ max_correspondence_distance, any plane within the search radius ofqmust live inq's own voxel or one of its 26 neighbours. Shrinkmap.voxel_sizebelow the correspondence distance and the search silently becomes incomplete — it will miss valid correspondences that lie two cells away, and you will never see an error, only a worse fit.
A LiDAR never re-samples the same physical point. It hits the same surface in different spots, every scan, forever. Point-to-point ICP asks "which scan point corresponds to which map point?" — a question with no correct answer, because the two clouds sample the same wall at different, arbitrary places. Forcing point A onto point B injects an error equal to their spacing along the surface.
Point-to-plane asks a question that does have an answer: how far is this point from the surface? Penalising only the distance along the normal lets points slide freely across the surface — which is exactly what a wall does and does not constrain.
For correspondence i with plane (cᵢ, nᵢ):
One scalar per correspondence, not three. That is also why it converges faster: the cost surface has no spurious minima from tangential mismatch.
Linearise about the current
To first order,
giving the
⚠️ The rotation block is(q × n), not(n × q). Swap them and every rotational update flips sign — the solver walks away from the solution while still reporting a plausible residual. This is the class of bug that "still looks like a working estimator", and the reason the Jacobian is checked against finite differences rather than trusted.
The perturbation side is not a free choice. It is fixed by the frame the correction
lives in. Here the correction is expressed in the world frame, so it composes on
the left. In GyrInt the increment is measured in the body frame, so it
composes on the right (R ← R·Exp(Δθ)). Same manifold, same Exp, opposite side.
See gauss_newton.md.
use_constant_velocity is on. Without it, t_guess = t_pose — the guess carries
no translation at all, so if the worker ever drops a scan (see max_queue_size),
the robot has physically moved metres by the time the next scan is processed, and ICP
starts hopelessly far from the answer.
Enabling it made the pose accelerate to ~26 m/s and finish 133 m from the start — while the fit still looked healthy.
guess is too far ahead
→ correspondence distance is loose enough that ICP "converges" near it
→ that mis-registered scan is INSERTED INTO THE MAP
→ the map itself drifts
→ the next scan aligns to the drifted map, v grows
→ runaway
The map closing the loop is what made this vicious. The estimator and its own reference drifted together, staying mutually consistent — so no residual ever complained. A low error was not evidence against it. This is the defining hazard of estimator code: the failure is self-confirming.
The loop is broken at the insert. A rejected scan is no longer added to the map (§3.6), so a bad guess can no longer poison the reference it is measured against. The feedback path simply doesn't exist any more.
If you ever see the pose accelerating away with a healthy rmse, this parameter is
still the first thing to turn off.
Velocity is currently finite-differenced from the very poses it helps produce — a feedback loop by construction. You are carrying an accelerometer, a device that measures acceleration directly. Only a tightly-coupled estimator can use it, and doing so retires this parameter entirely. See pipeline.md.
if (!valid || rmse > max_rmse) → keep the prediction, flag DIVERGED, do NOT insert
where valid = (correspondences ≥ min_correspondences) && the solve was finite
&& the translation eigenvalue ratio ≥ min_translation_eigenvalue_ratio
Note what is not in that condition: converged.
Hitting max_iterations is not failure. ICP routinely plateaus above eps while
sitting on a perfectly good fit. An earlier version treated !converged as a
rejection, threw away good poses, and froze the estimator. valid — enough
correspondences, finite solve — plus the residual is the trust signal. converged is
diagnostics.
On failure we coast on the prediction: keep the IMU-predicted pose and move on. The asymmetry is deliberate —
- a wrong pose inserted into the map poisons it permanently, and the map is what every future scan is measured against;
- a slightly stale pose recovers on the very next scan.
So when in doubt, refuse. Under-constrained (correspondences < min_correspondences)
is refused for the same reason: the 6-DoF problem genuinely has no answer, and
inventing one is worse than admitting it.
min_correspondences and max_rmse both look at the SOLVE'S OUTPUT. Neither looks at
whether the geometry that produced it could support a unique answer in every
direction. A scene whose plane normals cluster into one or two directions — an open
outdoor stretch: mostly ground plus a wall — leaves one translation direction with
almost no normal support. Nothing in the data resists sliding along it, so
Gauss-Newton solves for whatever noise happens to be there, and the pose it hands back
can still report a plausible correspondence count and a low RMSE at the wrong
position, because locally the (now-shifted) fit still looks fine.
First caught on M3DGR's Outdoor01 sequence
(RTK ground truth — glasslio's first run against real absolute GT, not just its own
registration residual): the estimate's path length came out to 38,657 m against a
true 346 m — not drift, an oscillation. Per-scan pose jumps of 2–4 m sustained for
hundreds of scans, rmse sitting at its normal 0.10–0.16 the whole time, correspondence
counts never dropping below 300. pose_trusted never flagged a single one of them.
The fix: after the solve converges, re-run associate() once more at the
converged pose (§3.2's lambda, no extra Gauss-Newton iterations — see
alignPointToPlane) to get H_t = sum(w_i · normal_i · normal_i^T), the translation
block of H. Its smallest eigenvalue divided by its trace is a scene-scale-invariant
measure of how well the weakest direction is constrained — the RAW eigenvalue is
useless here, since it scales with correspondence count and cannot tell
"under-constrained" from "just fewer points."
Calibrated empirically, not guessed: the indoor test bag's ratio never drops below
0.117 at the 5th percentile across ~180 scans; Outdoor01's divergence window never
rises above 0.034 across ~110 scans. The two do not overlap — min_translation_eigenvalue_ratio
sits at 0.05, in the gap.
This alone was not enough. Gating out degenerate scans stops them being trusted,
but registerScanLoose coasts on use_constant_velocity (§3.5) when it refuses —
and Outdoor01's degeneracy is not a brief patch, it is sustained. A rejected scan
means a stale map; a stale map means the next scan degenerates too; the cascade
free-integrates on the IMU alone with nothing ever correcting it, and the pose ran
away to hundreds of metres — the exact runaway §3.5 already documents, just
triggered by sustained degeneracy instead of a dropped-scan gap. Tight coupling
(imu_prior_weight: 1.0) is what actually rescues Outdoor01: the IMU becomes a
prior in the same solve rather than a fallback behind a rejection cliff, so it
takes over smoothly, scan by scan, exactly where the LiDAR term is weak. RPE went
from 22.6 m rmse (max 96.9 m) to 0.29 m rmse (max 1.9 m); path length from 111x
truth to 1.1x. Loose plus this gate alone is not the fix — tight coupling is what
the gate was clearing the way for.
A large APE (~36 m) remains even under tight coupling, alongside a rotational
discrepancy against GT that sits at 148–180° from t=0 and never grows — bounded, not
drift. M3DGR's own calibration.md publishes the Mid-360-to-its-own-IMU extrinsic
(confirmed to match this file's lidar_to_imu exactly) but no ground-truth-to-sensor
extrinsic at all — RTK/mocap orientation is in an unpublished frame with no known
rotation to the Mid-360's own. That gap, not a tracking bug, is the leading
explanation: a real heading bug would show as growing drift, and neither a pure
rotation nor a single-axis mirror of the estimate collapsed it in testing. RPE and
path length don't depend on this open question; APE and rotational error do.
| Param | Why |
|---|---|
max_correspondence_distance |
The key knob. Too small: fast motion puts the true correspondence out of reach and it never converges. Too large: it matches the wrong wall, and re-opens the runaway. |
max_rmse |
The coast threshold (§3.6). |
huber_delta |
Robust threshold — see gauss_newton.md. |
min_correspondences |
Below this the problem is under-constrained; refuse. |
min_translation_eigenvalue_ratio |
The degeneracy gate (§3.6.1). Below this, some translation direction has essentially no normal support; refuse regardless of RMSE. |
max_iterations |
Cost ceiling. Hitting it is not failure. |
use_constant_velocity |
§3.5. |