Skip to content

Commit 5a59704

Browse files
authored
Merge pull request #114 from rsasaki0109/agent/validate-imu-time-offset
Validate LiDAR IMU time offset
2 parents 0c7c759 + 6ea123a commit 5a59704

9 files changed

Lines changed: 487 additions & 7 deletions

CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,10 @@ if(BUILD_TESTING)
753753
NAME koide_imu_yaw_prediction_analysis
754754
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_koide_imu_yaw_prediction_analysis.py
755755
)
756+
add_test(
757+
NAME imu_time_offset_analysis
758+
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_imu_time_offset_analysis.py
759+
)
756760
add_test(
757761
NAME benchmark_compare_runs
758762
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_benchmark_compare_runs.py
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# LiDAR-IMU time-offset experiment
2+
3+
This experiment tests whether a fixed timestamp correction is identifiable and safe enough for
4+
a bounded runtime A/B test. It does not change production parameters or global localization.
5+
6+
The approach follows LI-Init's motion-alignment initialization and LI-Calib's continuous-time
7+
treatment of asynchronous measurements:
8+
9+
- [Robust Real-time LiDAR-inertial Initialization](https://arxiv.org/abs/2202.11006)
10+
- [Targetless Calibration of LiDAR-IMU System Based on Continuous-time Batch Estimation](https://arxiv.org/abs/2007.14759)
11+
12+
The analyzer integrates gyro measurements over consecutive LiDAR/reference intervals while
13+
jointly fitting rotation and bias. A fine offset sweep is supplemented by a quadratic sub-grid
14+
estimate, the score at zero offset, the width of the near-optimal basin, and independent
15+
30-second window estimates. A runtime candidate must improve rotational RMSE over zero by 2%,
16+
have a one-percent basin no wider than 30 ms, and remain stable within 10 ms MAD across windows.
17+
18+
Run all 11 Koide sequences with:
19+
20+
```bash
21+
python3 experiments/imu_time_offset/run_dataset_analysis.py \
22+
--data-root /media/sasaki/aiueo/datasets/koide_hard_localization \
23+
--output-dir /media/sasaki/aiueo/datasets/koide_hard_localization/generated/imu_time_offset_20260714
24+
```
25+
26+
## Decision
27+
28+
Do not promote timestamp correction to production. The offline sweep found stable 1--4 ms
29+
estimates on most normal sequences and a much larger 46 ms estimate on `outdoor_kidnap_a`.
30+
That largest, most identifiable candidate was used as the bounded runtime gate. Compared with
31+
zero offset over the same 30-second replay, 46 ms increased translation RMSE from 0.1147 m to
32+
0.2012 m and rotation RMSE from 0.378 degrees to 1.204 degrees. Final translation error rose
33+
from 0.284 m to 1.097 m and final rotation error from 0.365 degrees to 7.058 degrees.
34+
35+
The offline objective aligns gyro rotation with the ground-truth trajectory, but improving that
36+
objective does not guarantee better interaction with the current preintegration, scan timing,
37+
and NDT correction loop. The runtime parameter candidate was therefore removed. Production
38+
behavior remains unchanged; only the offline identifiability analyzer and recorded rejection
39+
evidence are retained.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Offline LiDAR-IMU time-offset validation experiment."""
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"schema_version": 1,
3+
"date": "2026-07-14",
4+
"dataset_sequence_count": 11,
5+
"offline_analysis": {
6+
"full_results": "/media/sasaki/aiueo/datasets/koide_hard_localization/generated/imu_time_offset_20260714/summary.json",
7+
"normal_sequence_offset_range_ms": [1.1, 3.6],
8+
"outdoor_kidnap_a_offset_ms": 46.0,
9+
"outdoor_kidnap_b_offset_ms": 35.4,
10+
"outdoor_kidnap_b_window_mad_ms": 19.9
11+
},
12+
"runtime_ab": {
13+
"sequence": "outdoor_kidnap_a",
14+
"duration_sec": 30.0,
15+
"baseline_offset_ms": 0.0,
16+
"candidate_offset_ms": 45.9895,
17+
"baseline": {
18+
"translation_rmse_m": 0.11468331420202565,
19+
"translation_error_last_m": 0.2836281166450853,
20+
"rotation_rmse_deg": 0.37751401062455,
21+
"rotation_error_last_deg": 0.365461493708264,
22+
"matched_sample_count": 38,
23+
"deskew_applied_ratio": 0.526
24+
},
25+
"candidate": {
26+
"translation_rmse_m": 0.2012479323409892,
27+
"translation_error_last_m": 1.09686234117455,
28+
"rotation_rmse_deg": 1.2035264410757562,
29+
"rotation_error_last_deg": 7.058011831891512,
30+
"matched_sample_count": 36,
31+
"deskew_applied_ratio": 0.684
32+
},
33+
"artifacts": "/media/sasaki/aiueo/datasets/koide_hard_localization/generated/imu_time_offset_20260714/runtime_ab_verified"
34+
},
35+
"promotion_decision": "rejected",
36+
"reason": "The largest stable offline offset regressed every runtime trajectory metric, so the timestamp correction candidate was removed from production code.",
37+
"production_runtime_changed": false
38+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
#!/usr/bin/env python3
2+
"""Evaluate LiDAR-IMU temporal-offset identifiability on every Koide sequence."""
3+
4+
import argparse
5+
import json
6+
from pathlib import Path
7+
import statistics
8+
import subprocess
9+
import sys
10+
11+
12+
SEQUENCES = {
13+
"indoor_easy_01": ("/imu", "/points2/decompressed", "indoor"),
14+
"indoor_easy_02": ("/imu", "/points2/decompressed", "indoor"),
15+
"indoor_hard_01": ("/imu", "/points2/decompressed", "indoor"),
16+
"indoor_kidnap_01": ("/imu", "/points2/decompressed", "indoor"),
17+
"indoor_kidnap_02": ("/imu", "/points2/decompressed", "indoor"),
18+
"outdoor_hard_01a": ("/livox/imu", "/livox/points", "outdoor"),
19+
"outdoor_hard_01b": ("/livox/imu", "/livox/points", "outdoor"),
20+
"outdoor_hard_02a": ("/livox/imu", "/livox/points", "outdoor"),
21+
"outdoor_hard_02b": ("/livox/imu", "/livox/points", "outdoor"),
22+
"outdoor_kidnap_a": ("/livox/imu", "/livox/points", "outdoor"),
23+
"outdoor_kidnap_b": ("/livox/imu", "/livox/points", "outdoor"),
24+
}
25+
26+
27+
def classify_offset_candidate(metrics):
28+
reasons = []
29+
if metrics["window_count"] < 2:
30+
reasons.append("insufficient_windows")
31+
if metrics["search_boundary_window_count"] > 0:
32+
reasons.append("window_hit_search_boundary")
33+
if metrics["relative_rmse_improvement_over_zero"] < 0.02:
34+
reasons.append("less_than_2pct_better_than_zero")
35+
width = metrics["one_percent_best_width_sec"]
36+
if width is None or width > 0.03:
37+
reasons.append("broad_minimum")
38+
mad = metrics["window_offset_mad_sec"]
39+
if mad is None or mad > 0.01:
40+
reasons.append("window_offset_unstable")
41+
median = metrics["window_offset_median_sec"]
42+
if median is None or abs(metrics["refined_offset_sec"] - median) > 0.015:
43+
reasons.append("full_and_window_estimates_disagree")
44+
return {
45+
"decision": "runtime_ab_candidate" if not reasons else "reject",
46+
"reasons": reasons,
47+
}
48+
49+
50+
def compact_result(name, family, report):
51+
alignment = report["between_cloud_alignment"]
52+
identifiable = alignment["offset_identifiability"]
53+
windows = report["time_offset_windows"]
54+
metrics = {
55+
"sequence": name,
56+
"family": family,
57+
"point_time_available": bool(report["point_time_hypotheses"]),
58+
"best_grid_offset_sec": alignment["imu_minus_reference_time_offset_sec"],
59+
"refined_offset_sec": identifiable["quadratic_refined_offset_sec"],
60+
"zero_offset_rmse_rad_s": identifiable["zero_offset_rmse_rad_s"],
61+
"best_offset_rmse_rad_s": identifiable["best_offset_rmse_rad_s"],
62+
"relative_rmse_improvement_over_zero": identifiable[
63+
"relative_rmse_improvement_over_zero"],
64+
"one_percent_best_width_sec": identifiable["one_percent_best_width_sec"],
65+
"best_at_search_boundary": identifiable["best_at_search_boundary"],
66+
"window_count": windows["window_count"],
67+
"window_offset_median_sec": windows.get("offset_median_sec"),
68+
"window_offset_mad_sec": windows.get("offset_mad_sec"),
69+
"window_offset_min_sec": windows.get("offset_min_sec"),
70+
"window_offset_max_sec": windows.get("offset_max_sec"),
71+
"search_boundary_window_count": windows.get(
72+
"search_boundary_window_count", 0),
73+
}
74+
metrics.update(classify_offset_candidate(metrics))
75+
return metrics
76+
77+
78+
def aggregate(results):
79+
families = {}
80+
for family in sorted({item["family"] for item in results}):
81+
selected = [item for item in results if item["family"] == family]
82+
families[family] = {
83+
"sequence_count": len(selected),
84+
"runtime_ab_candidate_count": sum(
85+
item["decision"] == "runtime_ab_candidate" for item in selected),
86+
"median_refined_offset_sec": statistics.median(
87+
item["refined_offset_sec"] for item in selected),
88+
"max_abs_refined_offset_sec": max(
89+
abs(item["refined_offset_sec"]) for item in selected),
90+
}
91+
return families
92+
93+
94+
def markdown_report(summary):
95+
lines = [
96+
"# Koide LiDAR-IMU time-offset analysis",
97+
"",
98+
"Positive offset means the matching IMU measurement has a later timestamp than LiDAR.",
99+
"The runtime gate requires a narrow optimum, at least 2% improvement over zero, and",
100+
"agreement across 30-second windows. Indoor clouds have no per-point time field, so",
101+
"their estimates can validate IMU prediction timing but cannot enable point deskew.",
102+
"",
103+
"| sequence | offset ms | zero improvement | width ms | window median/MAD ms | decision |",
104+
"|---|---:|---:|---:|---:|---|",
105+
]
106+
for item in summary["sequences"]:
107+
median = item["window_offset_median_sec"]
108+
mad = item["window_offset_mad_sec"]
109+
window = "n/a" if median is None or mad is None else f"{median*1000:.1f}/{mad*1000:.1f}"
110+
width = item["one_percent_best_width_sec"]
111+
lines.append(
112+
f"| {item['sequence']} | {item['refined_offset_sec']*1000:.1f} | "
113+
f"{item['relative_rmse_improvement_over_zero']*100:.2f}% | "
114+
f"{'n/a' if width is None else f'{width*1000:.1f}'} | {window} | "
115+
f"{item['decision']} |")
116+
lines.extend(["", f"Overall decision: **{summary['promotion_decision']}**", ""])
117+
return "\n".join(lines)
118+
119+
120+
def main():
121+
repo = Path(__file__).resolve().parents[2]
122+
parser = argparse.ArgumentParser()
123+
parser.add_argument("--data-root", type=Path, required=True)
124+
parser.add_argument("--output-dir", type=Path, required=True)
125+
parser.add_argument("--sequence", action="append", choices=SEQUENCES)
126+
parser.add_argument("--max-scans", type=int, default=0)
127+
parser.add_argument("--offset-step-sec", type=float, default=0.001)
128+
parser.add_argument("--window-sec", type=float, default=30.0)
129+
parser.add_argument("--window-step-sec", type=float, default=0.005)
130+
parser.add_argument(
131+
"--analyzer", type=Path,
132+
default=repo / "scripts" / "analyze_koide_imu_consistency.py")
133+
args = parser.parse_args()
134+
135+
names = args.sequence or list(SEQUENCES)
136+
assets = args.data_root / "generated" / "localization_gif_benchmarks" / "assets"
137+
details = args.output_dir / "sequences"
138+
details.mkdir(parents=True, exist_ok=True)
139+
results = []
140+
for name in names:
141+
imu_topic, cloud_topic, family = SEQUENCES[name]
142+
output = details / f"{name}.json"
143+
command = [
144+
sys.executable, str(args.analyzer),
145+
"--bag", str(args.data_root / "sequences" / name),
146+
"--reference", str(assets / f"{name}_reference.csv"),
147+
"--output", str(output),
148+
"--imu-topic", imu_topic,
149+
"--cloud-topic", cloud_topic,
150+
"--max-scans", str(args.max_scans),
151+
"--time-offset-step-sec", str(args.offset_step_sec),
152+
"--time-offset-window-sec", str(args.window_sec),
153+
"--time-offset-window-step-sec", str(args.window_step_sec),
154+
]
155+
print(f"Analyzing {name}...", flush=True)
156+
subprocess.run(command, check=True, stdout=subprocess.DEVNULL)
157+
results.append(compact_result(name, family, json.loads(output.read_text())))
158+
159+
candidates = [item for item in results if item["decision"] == "runtime_ab_candidate"]
160+
deskew_candidates = [item for item in candidates if item["point_time_available"]]
161+
summary = {
162+
"schema_version": 1,
163+
"purpose": "LiDAR-IMU temporal-offset validation; no global localization",
164+
"sequence_count": len(results),
165+
"all_expected_sequences_analyzed": set(names) == set(SEQUENCES),
166+
"sequences": results,
167+
"families": aggregate(results),
168+
"runtime_ab_candidates": [item["sequence"] for item in candidates],
169+
"deskew_runtime_ab_candidates": [item["sequence"] for item in deskew_candidates],
170+
"promotion_decision": (
171+
"run_bounded_runtime_ab" if deskew_candidates else
172+
"reject_runtime_offset_no_stable_deskew_candidate"),
173+
}
174+
args.output_dir.mkdir(parents=True, exist_ok=True)
175+
(args.output_dir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")
176+
(args.output_dir / "summary.md").write_text(markdown_report(summary))
177+
print(args.output_dir / "summary.json")
178+
179+
180+
if __name__ == "__main__":
181+
main()

0 commit comments

Comments
 (0)