Iterated Error-State Kalman Filter (ESIKF) LiDAR-inertial localization — implemented from the equations up.
No filter framework, no SLAM library. The IMU propagation, the on-manifold iterated update, and every Jacobian in between are derived by hand and written directly in C++ (~700 lines for the filter itself). This repo is the localization core extracted from my handheld 3D mapping stack; the sensor drivers, mapping backend, and web GUI live in a private repo.
Verified on a 10 km handheld Livox Mid-360 dataset. A real cross-session example is included for download: a handheld walk localized against a prior map built from a different session an hour earlier, tracking at 5–9 ms per frame (single thread) with 3.5 cm ATE RMSE against the localization session's own SLAM trajectory aligned into the map frame:
recording (chunk_XXXX/*.bin) prior map (.ply/.pcd/.xyz)
│ │
LidarReplay KnnMap
frames + IMU stream nanoflann kd-tree
│ │
└────────────► EsikfLio ◄──────────────┘
│
1. IMU propagation (18-dim error-state covariance)
2. motion deskewing (per-point SE(3) interpolation)
3. point-to-plane matching (5-NN + PCA plane fit)
4. iterated on-manifold Kalman update
│
trajectory.txt
The nominal state lives on the manifold, the error state in its tangent space:
with
For each IMU sample with bias-corrected measurements
and the error state propagates as EsikfLio::esikf_loop, matching the code layout):
where
Each LiDAR point carries its own timestamp inside the sweep. The IMU propagation above is stored per-sample as an Sophus::interpolate), then pulled back into the body frame at sweep end. So the update below sees a rigid, motion-compensated scan.
For each of up to 2,000 randomly sampled points: find the 5 nearest map points (kd-tree), fit a local plane by PCA (normal = eigenvector of the smallest eigenvalue, with curvature and distance gating), and form the point-to-plane residual
Because the update iterates while the linearization point moves away from the prior, the prior covariance must be pulled back through the retraction. With
The correction is injected back onto the manifold (
On startup, roll and pitch are initialized by gravity alignment over the first ~200 stationary IMU samples; yaw and position come from the user-supplied initial pose.
Dependencies: Eigen3, Sophus, TBB, spdlog, nlohmann-json. (nanoflann is vendored, BSD license.)
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -jTwo separate handheld Mid-360 sessions in the same building, recorded ~70 minutes apart. The first session built the prior map (with my mapping stack); the second session's recording (~65 s indoor walk) is what gets localized — so the map has never seen this data. Both ship as a release asset (~110 MB):
tools/get_example_data.sh # downloads + writes example_data/params.json
./build/esikf_lio_core example_data/params.json
python3 tools/plot_trajectory.py trajectory.txt \
--map example_data/esikf-lio-example/map.ply \
--gt example_data/esikf-lio-example/ref_keyframes.txt # optional, needs matplotlibref_keyframes.txt is the reference trajectory: the localization session's own SLAM keyframe poses, brought into the map frame by map-to-map ICP alignment. It is SLAM output rather than survey-grade ground truth, so read the ATE as an upper bound — it contains both sessions' SLAM error and the alignment error, not just the filter's. Both files are TUM format, so evo works directly (evo_ape tum ref_keyframes.txt trajectory.txt); the plot tool above reports the same interpolated ATE.
The image above is exactly this run: 648 frames, 82–96 % of the 2 000 sampled points matching per frame, converging in ~2 iterations, 4–7 cm point-to-plane residual RMS against the prior map, ATE 3.5 cm RMSE / 7.2 cm max vs the reference.
The recording format is produced by my own handheld mapper, so the repo ships a generator that synthesizes a dataset — a 10 m × 10 m room as the prior map and a 10-second stationary recording (IMU @ 200 Hz, LiDAR @ 10 Hz) inside it:
python3 tools/make_demo_data.py # writes demo_data/
./build/esikf_lio_core demo_data/params.jsonThe sensor sits at (0, 0, 1); the estimated trajectory should stay within ~1 mm of it. On this synthetic run the filter converges in 1–2 iterations per frame with sub-millimetre residual RMS — a sanity check that the propagation, deskewing, and update chain are consistent end to end.
To see the result (requires matplotlib):
python3 tools/plot_trajectory.py trajectory.txt --map demo_data/map.xyzwhich renders a top-down view of the trajectory over the map plus a height profile to trajectory.png.
./build/esikf_lio_core config/params.jsonparams.json (see config/params.example.json):
| key | meaning |
|---|---|
recording_path |
directory containing chunk_XXXX/{lidar_imu.bin, lidar_frames.bin} |
map_path |
prior map — .ply (ascii/binary), .pcd (ascii/binary) or .xyz |
lidar_imu_tf |
LiDAR→IMU extrinsic, "tx,ty,tz,rx,ry,rz" (m, deg, ZYX) |
init_pose |
initial pose guess in the map frame, same format |
replay_speed |
0 = as fast as possible, 1 = wall-clock |
Output: trajectory.txt — TUM format (timestamp tx ty tz qx qy qz qw), the LiDAR sensor pose in the map frame per LiDAR frame — plus per-second console stats: processing time, iterations used, valid-match ratio, residual RMS, and position/rotation σ from the covariance.
Recordings are 120-second chunks written by my handheld mapper. Per chunk:
lidar_imu.bin— packedImuRecord(80 bytes:t, acc[3], gyr[3], so3[3], all doubles)lidar_frames.bin— repeated[t: double][n: uint32][n × LidarPointRecord], whereLidarPointRecordis 21 packed bytes:delta_t, x, y, z, reflect(floats) +tag(uint8, Livox confidence — low-confidence points are filtered on load)
MIT — see LICENSE. src/map/nanoflann.hpp is © its authors, BSD-licensed.
