Skip to content

Commit c856ddc

Browse files
committed
Add IMU preintegration regression test + estimator docs (#36, #77)
The IMU estimator that issues #36 (imu preintegration) and #77 (IMU angular velocity + estimator) ask for is already implemented -- on-manifold preintegration (Forster et al. 2017) inside a sliding-window Gauss-Newton smoother with online bias estimation, plus a twist EKF and twist-prediction layers and a prediction/correction guard. But the preintegration core math had no test, and there was no user-facing doc to point the issues at. This closes both gaps: * test/test_imu_preintegration.cpp -- numerical regression tests for the preintegration core in the repo's plain-assert style: reset/identity, out-of-range dt rejection, pure-rotation and pure-translation closed-form matches, zero residual on a consistent trajectory, covariance symmetry/PSD/ growth, and -- the decisive one -- the bias Jacobians matching a finite-difference re-integration at a perturbed bias (validates all five d_*/d_bias accumulators and correctByBias together). Pure Eigen, so it builds standalone (g++ -I include -I /usr/include/eigen3) and under colcon as the 'imu_preintegration' test. Registered in CMakeLists.txt. * docs/imu_estimation.md -- maps the layered estimation options (twist prediction / twist EKF / IMU preintegration smoother / twist GTSAM smoother) to the issues, documents parameters, the guard/fallback safety layer, how to enable it (the existing imu_topic launch arg), and the honest validation state (math verified + HDL smoke; a controlled LiDAR+IMU+GT accuracy ranking is still open, so it is not presented as an accuracy claim). Linked from README and the reliability_roadmap triage, which now records #36/#77 as implemented.
1 parent 856e2cf commit c856ddc

5 files changed

Lines changed: 375 additions & 2 deletions

File tree

CMakeLists.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,15 @@ if(BUILD_TESTING)
374374
COMMAND test_imu_preintegration_guard_policy
375375
)
376376

377+
add_executable(test_imu_preintegration
378+
test/test_imu_preintegration.cpp)
379+
target_include_directories(test_imu_preintegration PRIVATE
380+
include)
381+
add_test(
382+
NAME imu_preintegration
383+
COMMAND test_imu_preintegration
384+
)
385+
377386
add_executable(test_prediction_state_policy
378387
test/test_prediction_state_policy.cpp)
379388
target_include_directories(test_prediction_state_policy PRIVATE

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ git branch -D <branch>
294294
| Bringup troubleshooting | [docs/troubleshooting.md](docs/troubleshooting.md) |
295295
| Map alignment / pose offset | [docs/map_alignment.md](docs/map_alignment.md) |
296296
| Pose covariance semantics | [docs/pose_covariance.md](docs/pose_covariance.md) |
297+
| IMU estimation (preintegration) | [docs/imu_estimation.md](docs/imu_estimation.md) |
297298
| Frame / TF contract | [docs/frame_contract.md](docs/frame_contract.md) |
298299

299300
## ROS 2 Support

docs/imu_estimation.md

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# IMU Estimation
2+
3+
This page documents the IMU-aided estimation already implemented in the package,
4+
in response to the recurring requests in
5+
[#36 (imu preintegration)](https://github.com/rsasaki0109/lidar_localization_ros2/issues/36)
6+
and
7+
[#77 (utilization of IMU angular velocity and the introduction of an
8+
estimator)](https://github.com/rsasaki0109/lidar_localization_ros2/issues/77).
9+
Both are implemented; this page is the map of what exists, how to turn it on, and
10+
what is and is not validated.
11+
12+
## What is implemented
13+
14+
The node offers a **layered** set of prediction/estimation options, cheapest
15+
first. Each is opt-in; the default bringup uses none of them (pure NDT/GICP
16+
tracking) so the IMU path never affects users who do not enable it.
17+
18+
| Layer | Parameter | What it does | Addresses |
19+
| --- | --- | --- | --- |
20+
| Twist prediction | `use_twist_prediction` (+ `twist_prediction_use_angular_velocity`) | Constant-twist motion prediction between scans, using the IMU/odom angular velocity to rotate the seed | #77 (angular velocity) |
21+
| Twist EKF | `use_twist_ekf` | A small constant-velocity EKF on the twist, feeding both the prediction seed and the published covariance | #77 (estimator) |
22+
| **IMU preintegration + smoother** | `use_imu` + `use_imu_preintegration` | On-manifold IMU preintegration (Forster et al. 2017) inside a sliding-window Gauss-Newton optimizer with 6-DOF NDT priors and online gyro/accel bias estimation | #36, #77 |
23+
| Twist GTSAM smoother | `use_gtsam_smoother` | Sliding-window odometry+NDT smoother (no raw IMU) | #77 (estimator) |
24+
25+
The headline item is the **IMU preintegration estimator**:
26+
27+
- `include/lidar_localization/imu_preintegration.hpp` — on-manifold
28+
preintegration of gyro+accel into a relative `(delta_p, delta_v, delta_R)`
29+
with covariance propagation and first-order bias Jacobians, pure Eigen.
30+
- `include/lidar_localization/imu_gtsam_smoother.hpp` — a sliding-window
31+
Gauss-Newton optimizer over per-pose state `[p, v, rpy]` (9 DOF) plus a shared
32+
`[bias_gyro, bias_accel]` (6 DOF), with preintegration factors between poses
33+
and Huber-robust NDT priors weighted by alignment fitness.
34+
- `include/lidar_localization/imu_preintegration_guard_policy.hpp` — the safety
35+
layer (below).
36+
37+
## Correctness evidence
38+
39+
The preintegration math is the subtle, easy-to-get-wrong core, so it has a
40+
numerical regression test, `test/test_imu_preintegration.cpp`, that pins it
41+
against closed-form answers and a finite-difference check:
42+
43+
- `reset()` is identity; out-of-range `dt` (≤0 or >0.5 s sensor dropouts) is
44+
ignored;
45+
- **pure rotation** (constant gyro, zero specific force) reproduces
46+
`delta_R = Exp(omega·t)` exactly, with zero `delta_p`/`delta_v`;
47+
- **pure translation** (constant specific force) reproduces `delta_v = a·t`,
48+
`delta_p = ½·a·t²` exactly (the discrete scheme is exact for constant accel);
49+
- the **residual is zero** for a world trajectory built to be consistent with
50+
the integrated specific force and gravity;
51+
- the **bias Jacobians match finite differences**: the first-order
52+
`correctByBias()` update reproduces a full re-integration at a perturbed bias
53+
to within the O(δ²) truncation error — this validates all five
54+
`d_*/d_bias` accumulators together;
55+
- the propagated covariance is symmetric, positive-semidefinite, and grows with
56+
integration time.
57+
58+
The test is pure Eigen (no ROS), so it also builds and runs standalone:
59+
60+
```bash
61+
g++ -std=c++17 -I include -I /usr/include/eigen3 \
62+
test/test_imu_preintegration.cpp -o /tmp/t && /tmp/t
63+
```
64+
65+
It is registered in `CMakeLists.txt` and runs under `colcon test` as
66+
`imu_preintegration`.
67+
68+
## Safety: prediction/correction guard and fallback
69+
70+
A LiDAR localizer must not let a bad IMU integration drag the pose. The guard
71+
policy (`imu_preintegration_guard_policy.hpp`) enforces that:
72+
73+
- if the IMU prediction and the NDT correction disagree by more than
74+
`imu_prediction_correction_guard_translation_m` (2.0 m) or
75+
`imu_prediction_correction_guard_yaw_deg` (4.0°), or the smoother update is
76+
non-finite / implausibly large, the node enters **fallback mode** and reverts
77+
to non-IMU prediction until the disagreement clears;
78+
- the guard is itself unit-tested (`test_imu_preintegration_guard_policy.cpp`).
79+
80+
This is the same design principle as the rest of the stack (see
81+
[pose_covariance.md](pose_covariance.md), the G3 reinitialization guard): a
82+
component that feeds itself must not be able to diverge unbounded.
83+
84+
## Parameters
85+
86+
| Parameter | Default | Meaning |
87+
| --- | --- | --- |
88+
| `use_imu` | `false` | Master enable for the IMU input |
89+
| `use_imu_preintegration` | `true` | Use the preintegration smoother (only active when `use_imu`) |
90+
| `imu_preintegration_use_base_frame_transform` | `false` | Transform IMU samples into the base frame before integrating |
91+
| `imu_gyro_noise_density` | `0.01` | rad/s/√Hz |
92+
| `imu_accel_noise_density` | `0.1` | m/s²/√Hz |
93+
| `imu_gyro_random_walk` | `0.0001` | rad/s²/√Hz |
94+
| `imu_accel_random_walk` | `0.001` | m/s³/√Hz |
95+
| `imu_bias_prior_sigma_gyro` | `0.01` | gyro bias prior σ |
96+
| `imu_bias_prior_sigma_accel` | `0.1` | accel bias prior σ |
97+
| `imu_ndt_sigma_z` / `_roll` / `_pitch` | `0.1` / `0.05` / `0.05` | NDT prior σ on the axes IMU constrains most |
98+
| `imu_prediction_correction_guard_translation_m` | `2.0` | fallback if IMU-vs-NDT disagree beyond this |
99+
| `imu_prediction_correction_guard_yaw_deg` | `4.0` | fallback if IMU-vs-NDT yaw disagree beyond this |
100+
101+
Set the noise densities from your IMU datasheet (or an Allan-variance run) for
102+
best results; the defaults are MEMS-grade ballpark values.
103+
104+
## How to enable
105+
106+
```bash
107+
ros2 launch lidar_localization_ros2 lidar_localization.launch.py \
108+
localization_param_dir:=/path/to/params.yaml \
109+
imu_topic:=/your/imu
110+
```
111+
112+
`lidar_localization.launch.py` exposes an `imu_topic` argument (default `/imu`)
113+
that is remapped onto the node's `/imu` subscription, so no manual remapping is
114+
needed. In the params file:
115+
116+
```yaml
117+
/**:
118+
ros__parameters:
119+
use_imu: true
120+
use_imu_preintegration: true
121+
```
122+
123+
The MID-360 launch (`mid360_legged_localization.launch.py`) already wires
124+
`imu_topic:=/livox/imu` as a worked example.
125+
126+
## Validation state (honest limits)
127+
128+
- The preintegration **math** is verified (the regression test above) and the
129+
guard prevents divergence.
130+
- The estimator is exercised by the **HDL IMU smoke / throughput check** in the
131+
release suite, which is a *pipeline safety* gate, **not** an accuracy ranking
132+
(see [competitive_roadmap.md](competitive_roadmap.md)).
133+
- A controlled public **LiDAR + IMU + GT** accuracy benchmark is still open
134+
(the Boreas / Koide tracks). Until that lands, the IMU estimator is offered as
135+
a working, math-verified, guarded option — **not** as a validated accuracy
136+
improvement over pure NDT on a specific dataset. Do not cite it as an accuracy
137+
claim without a controlled run.
138+
139+
## Related docs
140+
141+
- [pose_covariance.md](pose_covariance.md) — the twist-EKF hybrid covariance path
142+
- [interfaces.md](interfaces.md) — topics, including the optional `/imu` input
143+
- [mid360_legged_jetson.md](mid360_legged_jetson.md) — a real IMU bringup
144+
- [competitive_roadmap.md](competitive_roadmap.md) — dataset/validation strategy

docs/reliability_roadmap.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ Last triaged: 2026-06-10
4141
| [#54](https://github.com/rsasaki0109/lidar_localization_ros2/issues/54) | `corrent_pose_with_cov_stamped_ptr_` locking | enhancement | P3 | open | code audit if reproduced under concurrency |
4242
| [#25](https://github.com/rsasaki0109/lidar_localization_ros2/issues/25) | specifying lidar frame id | docs | P3 | open | already configurable; improve launch examples |
4343
| [#50](https://github.com/rsasaki0109/lidar_localization_ros2/issues/50) | enhance stability | enhancement | P4 | open | track through public regression, not ad-hoc tuning |
44-
| [#77](https://github.com/rsasaki0109/lidar_localization_ros2/issues/77) | IMU angular velocity estimator | enhancement | P4 | open | keep in experiments; not a v1.1 reliability target |
45-
| [#36](https://github.com/rsasaki0109/lidar_localization_ros2/issues/36) | imu preintegration | enhancement | P4 | open | covered by guarded IMU path + public regression |
44+
| [#77](https://github.com/rsasaki0109/lidar_localization_ros2/issues/77) | IMU angular velocity estimator | enhancement | P4 | open | **implemented** (twist prediction / twist EKF / IMU preintegration smoother); documented in [imu_estimation.md](imu_estimation.md). Open only on a controlled LiDAR+IMU+GT accuracy ranking |
45+
| [#36](https://github.com/rsasaki0109/lidar_localization_ros2/issues/36) | imu preintegration | enhancement | P4 | open | **implemented** (Forster on-manifold preintegration + sliding-window smoother + guard); math verified by `test_imu_preintegration`; documented in [imu_estimation.md](imu_estimation.md). Open only on a controlled accuracy ranking |
4646

4747
## Sprint 1 Targets
4848

test/test_imu_preintegration.cpp

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Numerical regression tests for the on-manifold IMU preintegration core
2+
// (include/lidar_localization/imu_preintegration.hpp, Forster et al. 2017).
3+
//
4+
// The preintegration math is the heart of the IMU estimator that issues #36
5+
// (imu preintegration) and #77 (IMU angular-velocity utilization + estimator)
6+
// ask for, yet only its *guard* policy had a test -- the integration, bias
7+
// Jacobians, residual and covariance propagation were unverified. These tests
8+
// pin them against closed-form answers and a finite-difference check, in the
9+
// repository's plain-assert style. They are pure Eigen (no ROS), so they build
10+
// and run standalone:
11+
//
12+
// g++ -std=c++17 -I include -I /usr/include/eigen3 \
13+
// test/test_imu_preintegration.cpp -o /tmp/t && /tmp/t
14+
15+
#include "lidar_localization/imu_preintegration.hpp"
16+
17+
#include <Eigen/Eigenvalues>
18+
19+
#include <cassert>
20+
#include <cmath>
21+
#include <vector>
22+
23+
namespace {
24+
25+
bool close(double a, double b, double tol) { return std::abs(a - b) < tol; }
26+
27+
bool vclose(const Eigen::Vector3d & a, const Eigen::Vector3d & b, double tol)
28+
{
29+
return (a - b).norm() < tol;
30+
}
31+
32+
// Geodesic distance between two rotations, in radians.
33+
double rot_diff(const Eigen::Matrix3d & A, const Eigen::Matrix3d & B)
34+
{
35+
return so3::Log(A.transpose() * B).norm();
36+
}
37+
38+
struct ImuTick
39+
{
40+
Eigen::Vector3d gyro;
41+
Eigen::Vector3d accel;
42+
double dt;
43+
};
44+
45+
// Integrate a fixed measurement stream under a given bias, returning the
46+
// (jacobian-bearing) preintegration object.
47+
ImuPreintegration integrate_stream(
48+
const std::vector<ImuTick> & ticks,
49+
const Eigen::Vector3d & bg, const Eigen::Vector3d & ba)
50+
{
51+
ImuPreintegration pre;
52+
pre.reset();
53+
for (const auto & t : ticks) {
54+
pre.integrate(t.gyro, t.accel, bg, ba, t.dt);
55+
}
56+
return pre;
57+
}
58+
59+
// ---------------------------------------------------------------------------
60+
61+
void test_reset_is_identity()
62+
{
63+
ImuPreintegration pre;
64+
pre.integrate({0.3, -0.1, 0.2}, {1.0, 0.0, 9.0}, {0, 0, 0}, {0, 0, 0}, 0.01);
65+
pre.reset();
66+
assert(vclose(pre.delta_p, Eigen::Vector3d::Zero(), 1e-12));
67+
assert(vclose(pre.delta_v, Eigen::Vector3d::Zero(), 1e-12));
68+
assert(rot_diff(pre.delta_R, Eigen::Matrix3d::Identity()) < 1e-12);
69+
assert(close(pre.dt_sum, 0.0, 1e-12));
70+
assert(pre.covariance.norm() < 1e-12);
71+
}
72+
73+
void test_dt_outside_range_is_ignored()
74+
{
75+
// The integrator rejects non-positive and >0.5 s steps (sensor dropouts).
76+
ImuPreintegration pre;
77+
pre.reset();
78+
pre.integrate({0.3, 0.0, 0.0}, {1.0, 0.0, 0.0}, {0, 0, 0}, {0, 0, 0}, 0.0);
79+
pre.integrate({0.3, 0.0, 0.0}, {1.0, 0.0, 0.0}, {0, 0, 0}, {0, 0, 0}, -0.01);
80+
pre.integrate({0.3, 0.0, 0.0}, {1.0, 0.0, 0.0}, {0, 0, 0}, {0, 0, 0}, 0.6);
81+
assert(close(pre.dt_sum, 0.0, 1e-12));
82+
assert(rot_diff(pre.delta_R, Eigen::Matrix3d::Identity()) < 1e-12);
83+
}
84+
85+
void test_pure_rotation_matches_analytic()
86+
{
87+
// Constant angular velocity, zero specific force -> delta_R = Exp(omega * t),
88+
// and position/velocity stay zero (no acceleration).
89+
const Eigen::Vector3d omega(0.2, -0.3, 0.15);
90+
const double dt = 0.005;
91+
const int n = 200; // 1.0 s
92+
ImuPreintegration pre;
93+
pre.reset();
94+
for (int i = 0; i < n; ++i) {
95+
pre.integrate(omega, Eigen::Vector3d::Zero(), {0, 0, 0}, {0, 0, 0}, dt);
96+
}
97+
const Eigen::Matrix3d expected_R = so3::Exp(omega * (dt * n));
98+
assert(rot_diff(pre.delta_R, expected_R) < 1e-9);
99+
assert(vclose(pre.delta_v, Eigen::Vector3d::Zero(), 1e-12));
100+
assert(vclose(pre.delta_p, Eigen::Vector3d::Zero(), 1e-12));
101+
assert(close(pre.dt_sum, dt * n, 1e-9));
102+
}
103+
104+
void test_pure_translation_matches_analytic()
105+
{
106+
// Zero rotation, constant specific force a -> the discrete scheme is exact for
107+
// constant acceleration: delta_v = a*t, delta_p = 0.5*a*t^2.
108+
const Eigen::Vector3d a(0.5, -1.0, 2.0);
109+
const double dt = 0.01;
110+
const int n = 100; // 1.0 s
111+
ImuPreintegration pre;
112+
pre.reset();
113+
for (int i = 0; i < n; ++i) {
114+
pre.integrate(Eigen::Vector3d::Zero(), a, {0, 0, 0}, {0, 0, 0}, dt);
115+
}
116+
const double t = dt * n;
117+
assert(rot_diff(pre.delta_R, Eigen::Matrix3d::Identity()) < 1e-12);
118+
assert(vclose(pre.delta_v, a * t, 1e-9));
119+
assert(vclose(pre.delta_p, 0.5 * a * t * t, 1e-9));
120+
}
121+
122+
void test_residual_zero_for_consistent_trajectory()
123+
{
124+
// Build a world trajectory consistent with constant world acceleration
125+
// a_world and identity attitude. The IMU measures specific force
126+
// accel_raw = a_world - g, so a preintegration of accel_raw plus the gravity
127+
// terms in computeResidual must reproduce the trajectory exactly (residual 0).
128+
const Eigen::Vector3d g(0, 0, -9.81);
129+
const Eigen::Vector3d a_world(0.3, -0.2, 0.1);
130+
const Eigen::Vector3d accel_raw = a_world - g;
131+
const double dt = 0.01;
132+
const int n = 100;
133+
ImuPreintegration pre;
134+
pre.params.gravity = g;
135+
pre.reset();
136+
for (int i = 0; i < n; ++i) {
137+
pre.integrate(Eigen::Vector3d::Zero(), accel_raw, {0, 0, 0}, {0, 0, 0}, dt);
138+
}
139+
const double t = dt * n;
140+
const Eigen::Matrix3d R = Eigen::Matrix3d::Identity();
141+
const Eigen::Vector3d p_i(2.0, -3.0, 1.0);
142+
const Eigen::Vector3d v_i(1.0, 0.5, -0.2);
143+
const Eigen::Vector3d v_j = v_i + a_world * t;
144+
const Eigen::Vector3d p_j = p_i + v_i * t + 0.5 * a_world * t * t;
145+
const Eigen::Matrix<double, 9, 1> r =
146+
pre.computeResidual(p_i, v_i, R, p_j, v_j, R);
147+
assert(r.norm() < 1e-6);
148+
}
149+
150+
void test_bias_jacobian_matches_finite_difference()
151+
{
152+
// The decisive test: the first-order bias correction (correctByBias, using the
153+
// accumulated d_*/d_b Jacobians) must match a full re-integration at the
154+
// perturbed bias, to within the O(delta^2) truncation error. This validates
155+
// d_dp_d_bg, d_dp_d_ba, d_dv_d_bg, d_dv_d_ba and d_dR_d_bg together.
156+
std::vector<ImuTick> ticks;
157+
for (int i = 0; i < 50; ++i) {
158+
const double s = 0.02 * i;
159+
ticks.push_back({
160+
Eigen::Vector3d(0.1 + 0.05 * std::sin(s), -0.2, 0.3 * std::cos(s)),
161+
Eigen::Vector3d(0.5, -1.0 + 0.2 * s, 9.0),
162+
0.01});
163+
}
164+
const Eigen::Vector3d bg0(0.01, -0.02, 0.015);
165+
const Eigen::Vector3d ba0(0.05, 0.03, -0.04);
166+
const Eigen::Vector3d dbg(1e-4, -2e-4, 1.5e-4);
167+
const Eigen::Vector3d dba(2e-4, 1e-4, -1e-4);
168+
169+
const ImuPreintegration nominal = integrate_stream(ticks, bg0, ba0);
170+
const ImuPreintegration truth = integrate_stream(ticks, bg0 + dbg, ba0 + dba);
171+
172+
ImuPreintegration corrected = nominal;
173+
corrected.correctByBias(dbg, dba);
174+
175+
// First-order match: residual error must be far below the perturbation size
176+
// (which moved delta_p/delta_v by ~1e-3) -- i.e. the Jacobians explain it.
177+
assert(vclose(corrected.delta_p, truth.delta_p, 1e-6));
178+
assert(vclose(corrected.delta_v, truth.delta_v, 1e-6));
179+
assert(rot_diff(corrected.delta_R, truth.delta_R) < 1e-6);
180+
181+
// Sanity: the perturbation actually moved the state well above the tolerance,
182+
// so the test is not trivially passing on a no-op.
183+
assert((nominal.delta_p - truth.delta_p).norm() > 1e-5);
184+
}
185+
186+
void test_covariance_symmetric_and_grows()
187+
{
188+
ImuPreintegration pre;
189+
pre.reset();
190+
auto step = [&]() {
191+
pre.integrate({0.1, -0.2, 0.05}, {0.4, -0.3, 9.2}, {0, 0, 0}, {0, 0, 0}, 0.01);
192+
};
193+
step();
194+
const double cov_after_1 = pre.covariance.trace();
195+
for (int i = 0; i < 49; ++i) step();
196+
const double cov_after_50 = pre.covariance.trace();
197+
198+
// Symmetric.
199+
assert((pre.covariance - pre.covariance.transpose()).norm() < 1e-12);
200+
// Monotone growth of total uncertainty as more noisy steps accumulate.
201+
assert(cov_after_50 > cov_after_1);
202+
// Positive semidefinite: smallest eigenvalue is non-negative.
203+
Eigen::SelfAdjointEigenSolver<Eigen::Matrix<double, 9, 9>> es(pre.covariance);
204+
assert(es.eigenvalues().minCoeff() > -1e-12);
205+
}
206+
207+
} // namespace
208+
209+
int main()
210+
{
211+
test_reset_is_identity();
212+
test_dt_outside_range_is_ignored();
213+
test_pure_rotation_matches_analytic();
214+
test_pure_translation_matches_analytic();
215+
test_residual_zero_for_consistent_trajectory();
216+
test_bias_jacobian_matches_finite_difference();
217+
test_covariance_symmetric_and_grows();
218+
return 0;
219+
}

0 commit comments

Comments
 (0)