Degeneracy Resilience Guide
RKO-LIO's degeneracy-resilience feature set is ~30 expert-tuned parameters
spread across several files. This page turns that into four presets under
lidarslam/param/presets/ plus a symptom
lookup so you don't have to read the research notes to pick a config.
Everything here is opt-in and default-off; unmodified rko_lio behavior is
unchanged unless you layer one of these preset files in. The validated
numbers below come from
docs/research/lidar-degeneracy-radar-intensity-ab-2026-07.md
(NTNU LiDAR Degeneracy Datasets fog/tunnel sequences, plus a HILTI 2022
exp07 negative-result check). Read that page for the full methodology.
Is your odometry degenerating?
| Symptom | Root cause | Preset | Sensors needed |
|---|---|---|---|
| Trajectory reach is much shorter than the true traveled distance in a long, straight, self-similar corridor/tunnel | Single-axis (translation-along-travel) degeneracy: ICP's Hessian is genuinely weak along the corridor axis, so small residuals get absorbed as "no motion" | tunnel_radar.ros.yaml if you have radar; tunnel_intensity_no_radar.ros.yaml if you don't and the corridor has distinctive reflectivity markings (see limitations) |
LiDAR + IMU (+ radar, strongly preferred) |
| Pose freezes in fog/dust/smoke despite real motion: point count drops, IMU still shows walking/driving vibration, reported velocity goes to ~0 | "Clutter lock" -- aerosol returns travel with the sensor, so ICP sees a healthy, well-conditioned Hessian and confidently reports near-zero motion. Eigenvalue-based weak-direction gates never fire in this mode -- only a second, disagreeing sensor can catch it | corridor_fog_radar.ros.yaml |
LiDAR + IMU + radar (required -- no radar-less fix is validated for this failure mode) |
| The whole traverse bends smoothly downward (or upward): a long, actually-level tunnel maps as a parabola-in-z (tens of meters of height sag over a few hundred meters) even though reach and lateral tracking look fine | Slow pitch/roll drift relative to gravity. The built-in min_beta orientation regularization cannot correct this by construction: its reference comes from a body-acceleration Kalman filter whose measurement is built with the current rotation estimate, so the reference drifts along with the estimate (measured on the NTNU tunnel: a 0.5 -> 6.3 deg monotonic gravity tilt that survives a 40x stronger min_beta weight) |
gravity_window_alignment: true (included in tunnel_radar.ros.yaml and corridor_fog_radar.ros.yaml): the ~20 s window mean of raw accelerometer samples, rotated into the world frame, is an absolute up reference; the residual tilt is fed back as a small, capped, roll/pitch-only correction. Verify with gravity_alignment_summary.json (dump_results: true) -- last_tilt_deg should stay small. Preset-scoped on purpose: on a general handheld walk with frequent turns (HILTI exp07) it slightly worsened control-point APE (0.208 -> 0.264 m), and on a driving robot (MID-360) the magnitude gate kept it fully dormant (byte-identical trajectory) |
LiDAR + IMU (accelerometer only -- no extra sensor) |
| Otherwise-good tracking but consistent drift along just one axis (e.g. lateral creep down a flat-walled corridor, or vertical creep under a low, featureless ceiling) | A single Hessian eigenvalue is persistently weak along that one world-frame axis. Confirm with degeneracy_persistence.csv (dumped when dump_results: true; columns include confirmed, axis_tx..axis_rz) before reaching for a preset -- it tells you which axis and how often it's confirmed |
Same as the corridor/tunnel row if an external sensor (radar or a distinctively textured surface) observes that axis; otherwise degeneracy_aware_solve alone (in tunnel_radar.ros.yaml / tunnel_intensity_no_radar.ros.yaml, minus the radar/intensity gates) still blends a geometric/IMU prior into the confirmed weak direction, with no cross-sensor validation behind it |
LiDAR + IMU, cross-sensor gate only if the weak axis is externally observable |
If none of these match -- registration is failing outright, or you were "kidnapped" (jumped/lost track) -- see Honest limitations below; this guide is about graceful degradation, not recovery from a lost track.
How to enable
Preset files under lidarslam/param/presets/ contain only the
degeneracy-feature parameters (Hessian gates, radar fusion, intensity
gates). They deliberately do not set extrinsics, voxel_size,
min_range/max_range, or other base sensor parameters -- those stay in
your own sensor config. Layer a preset as a second --params-file,
after your sensor config, so its values win:
# Offline (rosbag) -- fog / clutter-lock example
ros2 run rko_lio offline_node --ros-args \
--params-file <your_sensor_config>.ros.yaml \
--params-file lidarslam/param/presets/corridor_fog_radar.ros.yaml \
-p bag_path:=/path/to/bag \
-p imu_topic:=/imu -p lidar_topic:=/lidar/points -p base_frame:=base_link \
-p radar_topic:=/radar/cloud \
-p dump_results:=true -p results_dir:=results -p run_name:=fog_run
# Online (live topics) -- tunnel example
ros2 run rko_lio online_node --ros-args \
--params-file <your_sensor_config>.ros.yaml \
--params-file lidarslam/param/presets/tunnel_radar.ros.yaml \
-p imu_topic:=/imu -p lidar_topic:=/lidar/points -p base_frame:=base_link \
-p radar_topic:=/radar/cloud
You can also drive this through
lidarslam/launch/rko_lio_slam.launch.py
by passing rko_param_file:=<merged_yaml> (that launch file accepts only
one RKO-LIO param file argument, so merge your sensor config and the chosen
preset into one file first if you use it instead of ros2 run).
Every preset (including degeneracy_off.ros.yaml, the documented no-op
baseline) has TODO-USER-marked knobs where the value is placeholder or
rig-specific -- search each file for TODO-USER before running it.
offline_node hangs at end-of-bag by design (it waits forever for a scan
past the last IMU sample); stop it with timeout -s INT <seconds> ros2 run
... so dump_results still fires on shutdown.
How to verify it's working
With dump_results: true, rko_lio writes summary JSON files to
<results_dir>/<run_name>_<index>/ on shutdown:
radar_velocity_fusion_summary.json(written wheneverradar_velocity_fusion: true):prior_attempt_count-- scans where a radar ego-velocity estimate was computed and offered as a prior.fused_scan_count-- scans where the Hessian-weak-direction prior actually got blended in. In the tunnel run this fired on 1981 scans; in fog it fired on only 84 scans and did not help by itself (fog's degeneracy isn't a Hessian weak-direction problem -- see the symptom table).disagreement_corrected_scan_count-- scans whereradar_disagreement_gateoverrode ICP's translation. This is the number that matters for fog/clutter-lock and for the tunnel's remaining gap: healthy runs show hundreds of corrected scans over a fog/tunnel-length sequence, not a handful.intensity_constraint_summary.json(written wheneverintensity_constraint: trueorintensity_disagreement_gate: true):prior_attempt_count/prior_applied_count-- persistence-gated profile-prior activity (intensity_constraint).disagreement_attempt_count/disagreement_valid_shift_count-- expect roughly 40% of attempts to yield a valid shift correlation; this is the normal hit rate for scan-to-scan reflectivity correlation, not a fault.disagreement_corrected_scan_count-- scans where the gate overrode ICP. The validated radar-less tunnel recovery corrected 635 scans.disagreement_exceeded_threshold_count-- a high count here is not automatically good news. On HILTI exp07's self-similar corridor, 52% of attempts exceeded the disagreement threshold and were "corrected" -- and every one of those corrections made the trajectory worse (APE 0.318 m -> 0.94-2.39 m). A busy counter means the gate is firing, not that it's helping; you still need to A/B the actual trajectory error or reach distance againstdegeneracy_off.ros.yamlbefore trusting a new environment.degeneracy_persistence.csv-- per-scan diagnostics for the Hessian-eigenvalue gates (confirmed,consecutive_scans,matched_absolute_cosine,intervention_count, and the weak-axis vector). Use it to check which axis is degenerate before picking a preset, per the third symptom-table row.
There is no single "PASS" threshold -- these counters tell you the gates are engaging, not that engagement is correct for your data. Always compare against a ground truth or a known-good baseline run when moving to a new environment.
Honest limitations
- Intensity gate applicability is narrow and unforgiving. It only helps when geometry is self-similar but reflectivity texture is distinctive (the NTNU tunnel). It is harmful when the texture is also self-similar/periodic (HILTI exp07: 2.9-7.5x worse APE, at every threshold tried) and harmful when the texture is fake/sensor-coupled (fog aerosol: worse than baseline). There is no automatic detector for which case you're in yet -- you have to look at the data and A/B it.
- Radar speed is systematically underestimated by narrow-FOV
single-chip radar hardware (observed ~0.62 speed ratio vs. LIO in the fog
cross-check).
radar_velocity_scalecorrects this, but the correct value is a rig/geometry-specific calibration (1.05 for the NTNU tunnel radar, 1.10 already overshoots), not a constant you can copy between rigs or even between environments on the same rig -- the same 1.05 that helps the tunnel makes fog slightly worse. Only tune it against a sequence with trustworthy distance ground truth. - Soft degeneracy without radar remains unsolved. In the tunnel dataset, confirmed Hessian-weak-direction windows cover only ~17% of the route; the rest of the distance error accumulates during "soft" degeneracy that never triggers a hard eigenvalue gate. The intensity disagreement gate recovers some of this (radar-less tunnel: 98.67 m -> 153.75 m of ~500 m true), but nowhere near what radar achieves (457-495 m), and only under the narrow applicability condition above. There is currently no radar-less fix for fog-style clutter lock at all.
- Kidnap/registration-failure handling is stop-and-split, not
relocalize, for mapping workflows. All presets in this repo ship with
enable_kidnap_relocalizationandreset_on_registration_failureleft at their defaults (off) in your own sensor config -- these presets don't touch them. Automatic global relocalization after a lost track has been observed to converge to a plausible-looking but wrong global pose in earlier sweeps on this codebase; treat a registration failure or long scan gap as a hard segment break to review and stitch manually (e.g. in the pose graph) rather than trusting an automatic global relocalization to silently recover the right pose.