Skip to content

Commit 8d23121

Browse files
authored
Merge pull request #112 from rsasaki0109/agent/koide-imu-yaw-prediction
Validate Koide IMU rotation prediction
2 parents f64c651 + 53208a7 commit 8d23121

11 files changed

Lines changed: 897 additions & 20 deletions

CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,10 @@ if(BUILD_TESTING)
749749
NAME analyze_koide_imu_consistency
750750
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_analyze_koide_imu_consistency.py
751751
)
752+
add_test(
753+
NAME koide_imu_yaw_prediction_analysis
754+
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_koide_imu_yaw_prediction_analysis.py
755+
)
752756
add_test(
753757
NAME benchmark_compare_runs
754758
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test/test_benchmark_compare_runs.py
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Koide IMU rotation prediction experiment
2+
3+
This experiment checks whether gyro-only relative rotation can safely seed local NDT tracking.
4+
It does not add global localization and does not integrate acceleration or position.
5+
6+
`run_dataset_analysis.py` analyzes all 11 Koide sequences. It compares raw identity axes,
7+
signed-axis permutations, and a fitted Wahba rotation; searches LiDAR/IMU time offset; reports
8+
gravity convention, apparent bias, static-TF agreement, and timestamp coverage. The standalone
9+
`imu_rotation_prediction.hpp` candidate uses bounded interpolation, trapezoidal SO(3)
10+
integration, and coverage/duration/sample-gap rejection. Its test can be run with:
11+
12+
```bash
13+
g++ -std=c++17 -I/usr/include/eigen3 -Iexperiments/imu_yaw_prediction \
14+
experiments/imu_yaw_prediction/test_imu_rotation_prediction.cpp -o /tmp/test_imu_rotation_prediction
15+
/tmp/test_imu_rotation_prediction
16+
```
17+
18+
The full offline result is stored outside the repository at
19+
`/media/sasaki/aiueo/datasets/koide_hard_localization/generated/imu_yaw_validation_20260714`.
20+
The compact result and runtime A/B gates are in `results.json`.
21+
22+
## Decision
23+
24+
Do not promote this candidate to runtime. Indoor `indoor_easy_01` regressed from 0.053 m to
25+
1.314 m translation RMSE and from 1.68 to 2.42 degrees rotation RMSE. Outdoor runs were close,
26+
but `outdoor_kidnap_a` also slightly worsened final rotation error. The full-loop indoor runs
27+
were therefore stopped by the early regression gate rather than spending additional runtime on
28+
an already rejected method. The production LiDAR-only path and global-localization behavior are
29+
unchanged.
30+
31+
One contaminated run is intentionally excluded: an interrupted benchmark left a rosbag process
32+
on the same ROS domain, producing alternating scan timestamps about 64 seconds apart. Clean runs
33+
used isolated domains and no surviving replay processes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Koide local yaw-prediction calibration experiments."""
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#ifndef LIDAR_LOCALIZATION_EXPERIMENT_IMU_ROTATION_PREDICTION_HPP_
2+
#define LIDAR_LOCALIZATION_EXPERIMENT_IMU_ROTATION_PREDICTION_HPP_
3+
4+
#include <Eigen/Core>
5+
#include <Eigen/Geometry>
6+
7+
#include <algorithm>
8+
#include <cmath>
9+
#include <cstddef>
10+
#include <deque>
11+
#include <iterator>
12+
#include <vector>
13+
14+
namespace lidar_localization
15+
{
16+
17+
enum class ImuRotationPredictionStatus
18+
{
19+
kReady = 0,
20+
kDisabled,
21+
kInvalidInterval,
22+
kDurationTooLarge,
23+
kInsufficientCoverage,
24+
kSampleGapTooLarge,
25+
kNonFiniteSample,
26+
};
27+
28+
inline const char * imuRotationPredictionStatusName(ImuRotationPredictionStatus status)
29+
{
30+
switch (status) {
31+
case ImuRotationPredictionStatus::kReady: return "imu_rotation_prediction_ready";
32+
case ImuRotationPredictionStatus::kDisabled: return "imu_rotation_prediction_disabled";
33+
case ImuRotationPredictionStatus::kInvalidInterval:
34+
return "imu_rotation_prediction_invalid_interval";
35+
case ImuRotationPredictionStatus::kDurationTooLarge:
36+
return "imu_rotation_prediction_duration_too_large";
37+
case ImuRotationPredictionStatus::kInsufficientCoverage:
38+
return "imu_rotation_prediction_insufficient_coverage";
39+
case ImuRotationPredictionStatus::kSampleGapTooLarge:
40+
return "imu_rotation_prediction_sample_gap_too_large";
41+
case ImuRotationPredictionStatus::kNonFiniteSample:
42+
return "imu_rotation_prediction_non_finite_sample";
43+
}
44+
return "imu_rotation_prediction_unknown";
45+
}
46+
47+
struct ImuRotationSample
48+
{
49+
double stamp_sec{0.0};
50+
Eigen::Vector3d angular_velocity_base_rad_s{Eigen::Vector3d::Zero()};
51+
};
52+
53+
struct ImuRotationPredictionResult
54+
{
55+
ImuRotationPredictionStatus status{ImuRotationPredictionStatus::kInsufficientCoverage};
56+
Eigen::Matrix3f relative_rotation{Eigen::Matrix3f::Identity()};
57+
double duration_sec{0.0};
58+
double max_sample_gap_sec{0.0};
59+
std::size_t integrated_sample_count{0};
60+
61+
bool ready() const {return status == ImuRotationPredictionStatus::kReady;}
62+
};
63+
64+
class ImuRotationPredictionBuffer
65+
{
66+
public:
67+
explicit ImuRotationPredictionBuffer(double history_duration_sec = 2.0)
68+
: history_duration_sec_(history_duration_sec) {}
69+
70+
bool addSample(double stamp_sec, const Eigen::Vector3d & angular_velocity_base_rad_s)
71+
{
72+
if (!std::isfinite(stamp_sec) || !angular_velocity_base_rad_s.allFinite()) {
73+
return false;
74+
}
75+
if (!samples_.empty() && stamp_sec <= samples_.back().stamp_sec) {
76+
return false;
77+
}
78+
samples_.push_back({stamp_sec, angular_velocity_base_rad_s});
79+
const double oldest_stamp = stamp_sec - history_duration_sec_;
80+
while (samples_.size() > 2 && samples_[1].stamp_sec < oldest_stamp) {
81+
samples_.pop_front();
82+
}
83+
return true;
84+
}
85+
86+
void clear() {samples_.clear();}
87+
std::size_t size() const {return samples_.size();}
88+
89+
ImuRotationPredictionResult predict(
90+
double start_sec, double end_sec, double max_duration_sec,
91+
double max_sample_gap_sec) const
92+
{
93+
ImuRotationPredictionResult result;
94+
result.duration_sec = end_sec - start_sec;
95+
if (!std::isfinite(start_sec) || !std::isfinite(end_sec) || end_sec <= start_sec) {
96+
result.status = ImuRotationPredictionStatus::kInvalidInterval;
97+
return result;
98+
}
99+
if (max_duration_sec <= 0.0 || result.duration_sec > max_duration_sec) {
100+
result.status = ImuRotationPredictionStatus::kDurationTooLarge;
101+
return result;
102+
}
103+
if (
104+
samples_.size() < 2 || samples_.front().stamp_sec > start_sec ||
105+
samples_.back().stamp_sec < end_sec)
106+
{
107+
result.status = ImuRotationPredictionStatus::kInsufficientCoverage;
108+
return result;
109+
}
110+
111+
std::vector<ImuRotationSample> window;
112+
window.reserve(samples_.size() + 2);
113+
window.push_back({start_sec, interpolate(start_sec)});
114+
for (const auto & sample : samples_) {
115+
if (sample.stamp_sec > start_sec && sample.stamp_sec < end_sec) {
116+
window.push_back(sample);
117+
}
118+
}
119+
window.push_back({end_sec, interpolate(end_sec)});
120+
121+
Eigen::Quaterniond rotation = Eigen::Quaterniond::Identity();
122+
for (std::size_t index = 1; index < window.size(); ++index) {
123+
const double dt = window[index].stamp_sec - window[index - 1].stamp_sec;
124+
result.max_sample_gap_sec = std::max(result.max_sample_gap_sec, dt);
125+
if (!std::isfinite(dt) || dt <= 0.0 ||
126+
max_sample_gap_sec <= 0.0 || dt > max_sample_gap_sec)
127+
{
128+
result.status = ImuRotationPredictionStatus::kSampleGapTooLarge;
129+
return result;
130+
}
131+
const Eigen::Vector3d mean_rate = 0.5 * (
132+
window[index - 1].angular_velocity_base_rad_s +
133+
window[index].angular_velocity_base_rad_s);
134+
if (!mean_rate.allFinite()) {
135+
result.status = ImuRotationPredictionStatus::kNonFiniteSample;
136+
return result;
137+
}
138+
const Eigen::Vector3d rotation_vector = mean_rate * dt;
139+
const double angle = rotation_vector.norm();
140+
if (angle > 1e-12) {
141+
rotation = (rotation * Eigen::Quaterniond(
142+
Eigen::AngleAxisd(angle, rotation_vector / angle))).normalized();
143+
}
144+
++result.integrated_sample_count;
145+
}
146+
result.relative_rotation = rotation.toRotationMatrix().cast<float>();
147+
if (!result.relative_rotation.allFinite()) {
148+
result.status = ImuRotationPredictionStatus::kNonFiniteSample;
149+
result.relative_rotation = Eigen::Matrix3f::Identity();
150+
return result;
151+
}
152+
result.status = ImuRotationPredictionStatus::kReady;
153+
return result;
154+
}
155+
156+
private:
157+
Eigen::Vector3d interpolate(double stamp_sec) const
158+
{
159+
auto right = std::lower_bound(
160+
samples_.begin(), samples_.end(), stamp_sec,
161+
[](const ImuRotationSample & sample, double stamp) {
162+
return sample.stamp_sec < stamp;
163+
});
164+
if (right == samples_.begin()) {
165+
return right->angular_velocity_base_rad_s;
166+
}
167+
if (right == samples_.end()) {
168+
return samples_.back().angular_velocity_base_rad_s;
169+
}
170+
if (right->stamp_sec == stamp_sec) {
171+
return right->angular_velocity_base_rad_s;
172+
}
173+
const auto left = std::prev(right);
174+
const double fraction =
175+
(stamp_sec - left->stamp_sec) / (right->stamp_sec - left->stamp_sec);
176+
return left->angular_velocity_base_rad_s + fraction * (
177+
right->angular_velocity_base_rad_s - left->angular_velocity_base_rad_s);
178+
}
179+
180+
double history_duration_sec_{2.0};
181+
std::deque<ImuRotationSample> samples_;
182+
};
183+
184+
inline Eigen::Matrix4f applyImuRotationPrediction(
185+
const Eigen::Matrix4f & accepted_pose,
186+
const Eigen::Matrix4f & translation_seed,
187+
const Eigen::Matrix3f & relative_rotation)
188+
{
189+
Eigen::Matrix4f result = translation_seed;
190+
result.block<3, 3>(0, 0) =
191+
accepted_pose.block<3, 3>(0, 0) * relative_rotation;
192+
return result;
193+
}
194+
195+
} // namespace lidar_localization
196+
197+
#endif // LIDAR_LOCALIZATION_EXPERIMENT_IMU_ROTATION_PREDICTION_HPP_
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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_yaw_validation_20260714/summary.json",
7+
"indoor_rate_rmse_rad_s": {
8+
"identity_median": 1.0536,
9+
"identity_worst": 2.49,
10+
"signed_axis_median": 0.0775,
11+
"signed_axis_worst": 0.356,
12+
"wahba_median": 0.0361,
13+
"wahba_worst": 0.35
14+
},
15+
"outdoor_rate_rmse_rad_s": {
16+
"identity_median": 0.0651,
17+
"identity_worst": 0.0813,
18+
"wahba_median": 0.0628,
19+
"wahba_worst": 0.0799
20+
},
21+
"maximum_absolute_time_offset_sec": {
22+
"indoor": 0.005,
23+
"outdoor_hard": 0.005,
24+
"outdoor_kidnap": 0.045
25+
}
26+
},
27+
"runtime_ab": [
28+
{
29+
"sequence": "indoor_easy_01",
30+
"duration_sec": 8.0,
31+
"lidar_only": {"translation_rmse_m": 0.05284, "rotation_rmse_deg": 1.67677},
32+
"imu_rotation": {"translation_rmse_m": 1.31443, "rotation_rmse_deg": 2.41686},
33+
"decision": "reject"
34+
},
35+
{
36+
"sequence": "outdoor_hard_01a",
37+
"duration_sec": 8.0,
38+
"lidar_only": {"translation_rmse_m": 0.17624, "rotation_rmse_deg": 0.60179},
39+
"imu_rotation": {"translation_rmse_m": 0.17416, "rotation_rmse_deg": 0.53217},
40+
"decision": "insufficient_for_promotion"
41+
},
42+
{
43+
"sequence": "outdoor_kidnap_a",
44+
"duration_sec": 30.0,
45+
"lidar_only": {"translation_rmse_m": 0.22, "rotation_rmse_deg": 0.60276, "rotation_error_last_deg": 3.87328},
46+
"imu_rotation": {"translation_rmse_m": 0.20934, "rotation_rmse_deg": 0.58145, "rotation_error_last_deg": 3.98249},
47+
"decision": "reject_last_rotation_regression"
48+
}
49+
],
50+
"promotion_decision": "rejected",
51+
"reason": "The candidate regressed indoor translation and rotation, and slightly regressed final rotation on the outdoor kidnap run. It remains experiment-only and is not connected to runtime parameters."
52+
}

0 commit comments

Comments
 (0)