Skip to main content

imu_calib/
lib.rs

1//! Kalibr-compatible IMU intrinsic calibration for Rust.
2//!
3//! * [`allan`] estimates noise densities and bias random walks from a long
4//!   stationary recording.
5//! * [`estimate`] estimates scale, axis misalignment and biases from static
6//!   orientations and the rotations between them.
7//! * [`ImuIntrinsics`] and [`ImuCorrector`] hold those parameters in Kalibr's
8//!   YAML layout and apply them to a live stream.
9//!
10//! ```rust
11//! use imu_calib::ImuIntrinsics;
12//!
13//! let intrinsics = ImuIntrinsics::identity();
14//! let corrector = intrinsics.corrector().unwrap();
15//!
16//! let (accel, gyro) = corrector.correct([0.0, 0.0, 9.81], [0.01, -0.02, 0.0]);
17//! ```
18
19use nalgebra as na;
20
21mod conv;
22
23use conv::{mat3, unvec3, vec3};
24/// Plain-array matrix and vector types. The public API is expressed entirely
25/// in these, so `nalgebra` stays an implementation detail of this crate.
26pub use conv::{Mat3, Mat4, Vec3, IDENTITY3, ZERO3, ZERO_VEC3};
27
28pub mod allan;
29pub mod estimate;
30
31#[cfg(feature = "kalibr")]
32pub mod kalibr;
33
34/// IMU message container compatible with standard ROS sensor messages.
35#[derive(Debug, Clone, PartialEq, Default)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
37pub struct ImuMsg {
38    /// Orientation quaternion `[x, y, z, w]`.
39    pub orientation: [f64; 4],
40    /// Row-major 3x3 covariance matrix for orientation.
41    pub orientation_covariance: [f64; 9],
42    /// Angular velocity `[x, y, z]` in rad/s.
43    pub angular_velocity: [f64; 3],
44    /// Row-major 3x3 covariance matrix for angular velocity.
45    pub angular_velocity_covariance: [f64; 9],
46    /// Linear acceleration `[x, y, z]` in m/s^2.
47    pub linear_acceleration: [f64; 3],
48    /// Row-major 3x3 covariance matrix for linear acceleration.
49    pub linear_acceleration_covariance: [f64; 9],
50}
51
52impl ImuMsg {
53    /// Create a new `ImuMsg` with the given linear acceleration and angular velocity.
54    pub fn new(linear_accel: &[f64; 3], angular_vel: &[f64; 3]) -> Self {
55        Self {
56            linear_acceleration: *linear_accel,
57            angular_velocity: *angular_vel,
58            ..Default::default()
59        }
60    }
61}
62
63/// Which of Kalibr's IMU intrinsic models a parameter set belongs to.
64///
65/// Selected on the Kalibr command line with `--imu-models`.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum ImuModel {
68    /// `calibrated`: only noise parameters, no deterministic intrinsics.
69    #[default]
70    Calibrated,
71    /// `scale-misalignment`: scale, misalignment and g-sensitivity.
72    ScaleMisalignment,
73    /// `scale-misalignment-size-effect`: additionally per-axis accelerometer
74    /// lever arms. This crate parses the lever arms but does not apply them.
75    ScaleMisalignmentSizeEffect,
76}
77
78impl ImuModel {
79    /// The string Kalibr writes into the `model` field of its YAML output.
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            ImuModel::Calibrated => "calibrated",
83            ImuModel::ScaleMisalignment => "scale-misalignment",
84            ImuModel::ScaleMisalignmentSizeEffect => "scale-misalignment-size-effect",
85        }
86    }
87
88    /// Parse the `model` field of a Kalibr YAML file.
89    pub fn from_str_kalibr(s: &str) -> anyhow::Result<Self> {
90        match s {
91            "calibrated" => Ok(ImuModel::Calibrated),
92            "scale-misalignment" => Ok(ImuModel::ScaleMisalignment),
93            "scale-misalignment-size-effect" => Ok(ImuModel::ScaleMisalignmentSizeEffect),
94            other => Err(anyhow::anyhow!("unknown Kalibr IMU model `{other}`")),
95        }
96    }
97}
98
99/// Continuous-time stochastic error model of an IMU.
100///
101/// These are exactly the four values Kalibr reads from its input `imu0.yaml`,
102/// plus the update rate used to discretise them. Obtain them with
103/// [`allan::AllanEstimator`] from a long stationary recording, or read them off
104/// the datasheet.
105#[derive(Debug, Clone, Copy, PartialEq)]
106pub struct ImuNoise {
107    /// Accelerometer white-noise density σ_a \[m/s²/√Hz\].
108    pub accel_noise_density: f64,
109    /// Accelerometer bias random-walk σ_ba \[m/s³/√Hz\].
110    pub accel_random_walk: f64,
111    /// Gyroscope white-noise density σ_g \[rad/s/√Hz\].
112    pub gyro_noise_density: f64,
113    /// Gyroscope bias random-walk σ_bg \[rad/s²/√Hz\].
114    pub gyro_random_walk: f64,
115    /// IMU update rate \[Hz\].
116    pub update_rate: f64,
117}
118
119impl Default for ImuNoise {
120    fn default() -> Self {
121        Self {
122            accel_noise_density: 0.0,
123            accel_random_walk: 0.0,
124            gyro_noise_density: 0.0,
125            gyro_random_walk: 0.0,
126            update_rate: 200.0,
127        }
128    }
129}
130
131impl ImuNoise {
132    /// Discrete-time accelerometer noise σ: `σ_d = σ / √Δt`.
133    pub fn accel_noise_discrete(&self) -> f64 {
134        self.accel_noise_density * self.update_rate.sqrt()
135    }
136
137    /// Discrete-time gyroscope noise σ: `σ_d = σ / √Δt`.
138    pub fn gyro_noise_discrete(&self) -> f64 {
139        self.gyro_noise_density * self.update_rate.sqrt()
140    }
141
142    /// Discrete-time accelerometer bias random-walk σ: `σ_d = σ · √Δt`.
143    pub fn accel_bias_discrete(&self) -> f64 {
144        self.accel_random_walk / self.update_rate.sqrt()
145    }
146
147    /// Discrete-time gyroscope bias random-walk σ: `σ_d = σ · √Δt`.
148    pub fn gyro_bias_discrete(&self) -> f64 {
149        self.gyro_random_walk / self.update_rate.sqrt()
150    }
151}
152
153/// Kalibr-compatible IMU intrinsic parameters.
154///
155/// The matrices are stored exactly as Kalibr reports them, i.e. in the
156/// ideal → raw direction described at the [crate] level. Build an
157/// [`ImuCorrector`] with [`corrector`](Self::corrector) to apply them.
158#[derive(Debug, Clone)]
159pub struct ImuIntrinsics {
160    /// Which Kalibr model these parameters belong to.
161    pub model: ImuModel,
162
163    /// Accelerometer scale-misalignment matrix `M_a` (lower-triangular 3×3).
164    ///
165    /// Kalibr YAML key `accelerometers.M`.
166    pub accel_m: Mat3,
167
168    /// Accelerometer bias `b_a` \[m/s²\].
169    ///
170    /// Kalibr models the bias as a time-varying B-spline and does not export
171    /// it; this is estimated by [`estimate`] and stored as an extension.
172    pub accel_bias: Vec3,
173
174    /// Gyroscope scale-misalignment matrix `M_g` (lower-triangular 3×3).
175    ///
176    /// Kalibr YAML key `gyroscopes.M`.
177    pub gyro_m: Mat3,
178
179    /// Rotation `C_gyro_i` from the accelerometer frame to the gyroscope frame.
180    ///
181    /// Kalibr YAML key `gyroscopes.C_gyro_i`.
182    pub c_gyro_i: Mat3,
183
184    /// G-sensitivity `A` \[(rad/s)/(m/s²)\]: acceleration coupling into the gyro.
185    ///
186    /// Kalibr YAML key `gyroscopes.A`. Zero when not calibrated.
187    pub gyro_a: Mat3,
188
189    /// Gyroscope bias `b_g` \[rad/s\]. See [`accel_bias`](Self::accel_bias).
190    pub gyro_bias: Vec3,
191
192    /// Stochastic error model.
193    pub noise: ImuNoise,
194
195    /// Topic the calibration was recorded from (Kalibr YAML key `rostopic`).
196    pub rostopic: Option<String>,
197
198    /// Transformation from the body frame to this IMU (Kalibr key `T_i_b`).
199    ///
200    /// Identity for the reference IMU of a Kalibr run.
201    pub t_i_b: Option<Mat4>,
202
203    /// Time offset with respect to IMU0 \[s\] (Kalibr key `time_offset`).
204    pub time_offset: f64,
205
206    /// Per-axis accelerometer lever arms of the `scale-misalignment-size-effect`
207    /// model \[m\], in the order `rx_i, ry_i, rz_i`. Parsed but not applied.
208    pub accel_lever_arms: Option<[Vec3; 3]>,
209}
210
211impl Default for ImuIntrinsics {
212    fn default() -> Self {
213        Self::identity()
214    }
215}
216
217impl ImuIntrinsics {
218    /// An identity (pass-through) parameter set with zero noise.
219    pub fn identity() -> Self {
220        Self {
221            model: ImuModel::Calibrated,
222            accel_m: IDENTITY3,
223            accel_bias: ZERO_VEC3,
224            gyro_m: IDENTITY3,
225            c_gyro_i: IDENTITY3,
226            gyro_a: ZERO3,
227            gyro_bias: ZERO_VEC3,
228            noise: ImuNoise::default(),
229            rostopic: None,
230            t_i_b: None,
231            time_offset: 0.0,
232            accel_lever_arms: None,
233        }
234    }
235
236    /// Pre-compute the inverted matrices needed to correct measurements.
237    ///
238    /// Fails if `M_a` or `M_g` is singular, which means the parameters are not
239    /// a valid calibration.
240    pub fn corrector(&self) -> anyhow::Result<ImuCorrector> {
241        ImuCorrector::new(self)
242    }
243
244    /// Per-axis accelerometer scale factors, the diagonal of `M_a`.
245    pub fn accel_scale(&self) -> [f64; 3] {
246        [self.accel_m[0][0], self.accel_m[1][1], self.accel_m[2][2]]
247    }
248
249    /// Per-axis gyroscope scale factors, the diagonal of `M_g`.
250    pub fn gyro_scale(&self) -> [f64; 3] {
251        [self.gyro_m[0][0], self.gyro_m[1][1], self.gyro_m[2][2]]
252    }
253}
254
255/// Applies [`ImuIntrinsics`] to raw measurements.
256///
257/// Holds the pre-inverted matrices so that correcting one sample costs two
258/// matrix-vector products. Create it with [`ImuIntrinsics::corrector`].
259#[derive(Debug, Clone)]
260pub struct ImuCorrector {
261    /// `M_a⁻¹`
262    accel_inv: na::Matrix3<f64>,
263    /// `C_gyro_iᵀ · M_g⁻¹`
264    gyro_inv: na::Matrix3<f64>,
265    /// `A · C_gyro_i`
266    gyro_accel_coupling: na::Matrix3<f64>,
267    accel_bias: na::Vector3<f64>,
268    gyro_bias: na::Vector3<f64>,
269}
270
271impl ImuCorrector {
272    /// Build a corrector from a parameter set.
273    pub fn new(intrinsics: &ImuIntrinsics) -> anyhow::Result<Self> {
274        let accel_inv = mat3(&intrinsics.accel_m)
275            .try_inverse()
276            .ok_or_else(|| anyhow::anyhow!("accelerometer matrix M_a is singular"))?;
277        let gyro_m_inv = mat3(&intrinsics.gyro_m)
278            .try_inverse()
279            .ok_or_else(|| anyhow::anyhow!("gyroscope matrix M_g is singular"))?;
280        let c_gyro_i = mat3(&intrinsics.c_gyro_i);
281
282        Ok(Self {
283            accel_inv,
284            gyro_inv: c_gyro_i.transpose() * gyro_m_inv,
285            gyro_accel_coupling: mat3(&intrinsics.gyro_a) * c_gyro_i,
286            accel_bias: vec3(&intrinsics.accel_bias),
287            gyro_bias: vec3(&intrinsics.gyro_bias),
288        })
289    }
290
291    /// A corrector that passes measurements through unchanged.
292    pub fn identity() -> Self {
293        Self {
294            accel_inv: na::Matrix3::identity(),
295            gyro_inv: na::Matrix3::identity(),
296            gyro_accel_coupling: na::Matrix3::zeros(),
297            accel_bias: na::Vector3::zeros(),
298            gyro_bias: na::Vector3::zeros(),
299        }
300    }
301
302    /// Correct a raw accelerometer reading: `a = M_a⁻¹ (a_raw − b_a)`.
303    pub fn correct_accel(&self, raw_accel: [f64; 3]) -> [f64; 3] {
304        let a = self.accel_inv * (na::Vector3::from(raw_accel) - self.accel_bias);
305        unvec3(&a)
306    }
307
308    /// Correct a raw gyroscope reading, given the already corrected acceleration:
309    /// `w = C_gyro_iᵀ M_g⁻¹ (w_raw − b_g − A C_gyro_i a)`.
310    pub fn correct_gyro(&self, raw_gyro: [f64; 3], corrected_accel: [f64; 3]) -> [f64; 3] {
311        let w_raw = na::Vector3::from(raw_gyro);
312        let a = na::Vector3::from(corrected_accel);
313        let w = self.gyro_inv * (w_raw - self.gyro_bias - self.gyro_accel_coupling * a);
314        unvec3(&w)
315    }
316
317    /// Correct both readings, returning `(accel, gyro)`.
318    pub fn correct(&self, raw_accel: [f64; 3], raw_gyro: [f64; 3]) -> ([f64; 3], [f64; 3]) {
319        let a = self.correct_accel(raw_accel);
320        let w = self.correct_gyro(raw_gyro, a);
321        (a, w)
322    }
323
324    /// The gyroscope bias implied by an averaged stationary measurement.
325    ///
326    /// At rest the true angular rate is zero, so
327    ///
328    /// ```text
329    /// b_g = w_raw − A · C_gyro_i · a_ideal
330    /// ```
331    ///
332    /// Pass the mean raw accelerometer and gyroscope readings of a stationary
333    /// stretch. The result goes straight into [`ImuIntrinsics::gyro_bias`].
334    /// Worth doing at start-up: bias moves with temperature and between power
335    /// cycles, scale and misalignment do not.
336    pub fn gyro_bias_from_static(
337        &self,
338        mean_raw_accel: [f64; 3],
339        mean_raw_gyro: [f64; 3],
340    ) -> [f64; 3] {
341        let a = na::Vector3::from(self.correct_accel(mean_raw_accel));
342        let b = na::Vector3::from(mean_raw_gyro) - self.gyro_accel_coupling * a;
343        unvec3(&b)
344    }
345
346    /// Correct an [`ImuMsg`] in place.
347    pub fn correct_msg_in_place(&self, imu: &mut ImuMsg) {
348        let (a, w) = self.correct(imu.linear_acceleration, imu.angular_velocity);
349        imu.linear_acceleration = a;
350        imu.angular_velocity = w;
351    }
352
353    /// Correct an [`ImuMsg`], returning the corrected message.
354    pub fn correct_msg(&self, mut imu: ImuMsg) -> ImuMsg {
355        self.correct_msg_in_place(&mut imu);
356        imu
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use conv::{unmat3, unvec3};
364
365    const EPS: f64 = 1e-12;
366
367    /// A small rotation, as `C_gyro_i`.
368    fn small_rotation() -> na::Matrix3<f64> {
369        na::Rotation3::from_euler_angles(0.001, -0.002, 0.0015)
370            .matrix()
371            .to_owned()
372    }
373
374    fn assert_close(a: [f64; 3], b: [f64; 3], eps: f64) {
375        for i in 0..3 {
376            assert!((a[i] - b[i]).abs() < eps, "{a:?} != {b:?}");
377        }
378    }
379
380    #[test]
381    fn identity_passes_through() {
382        let c = ImuIntrinsics::identity().corrector().unwrap();
383        let (a, g) = c.correct([1.0, 2.0, 3.0], [0.1, 0.2, 0.3]);
384        assert_close(a, [1.0, 2.0, 3.0], EPS);
385        assert_close(g, [0.1, 0.2, 0.3], EPS);
386    }
387
388    #[test]
389    fn correction_inverts_the_kalibr_forward_model() {
390        let accel_m = na::Matrix3::new(1.01, 0.0, 0.0, 0.004, 0.99, 0.0, -0.002, 0.003, 1.02);
391        let accel_bias = na::Vector3::new(0.05, -0.1, 0.2);
392        let gyro_m = na::Matrix3::new(0.98, 0.0, 0.0, 0.003, 1.01, 0.0, -0.001, 0.002, 0.99);
393        let gyro_bias = na::Vector3::new(0.01, -0.02, 0.005);
394        let gyro_a = na::Matrix3::new(
395            1e-3, 2e-4, -3e-4, //
396            -5e-4, 7e-4, 1e-4, //
397            2e-4, -1e-4, 6e-4,
398        );
399        let c_gyro_i = small_rotation();
400
401        let mut intr = ImuIntrinsics::identity();
402        intr.accel_m = unmat3(&accel_m);
403        intr.accel_bias = unvec3(&accel_bias);
404        intr.gyro_m = unmat3(&gyro_m);
405        intr.gyro_bias = unvec3(&gyro_bias);
406        intr.gyro_a = unmat3(&gyro_a);
407        intr.c_gyro_i = unmat3(&c_gyro_i);
408
409        let a_ideal = na::Vector3::new(0.3, -1.2, 9.7);
410        let w_ideal = na::Vector3::new(0.4, -0.15, 0.9);
411
412        // Forward model, exactly as Kalibr writes it in IccSensors.py.
413        let a_raw = accel_m * a_ideal + accel_bias;
414        let w_raw = gyro_m * (c_gyro_i * w_ideal) + gyro_a * (c_gyro_i * a_ideal) + gyro_bias;
415
416        let c = intr.corrector().unwrap();
417        let (a, w) = c.correct(a_raw.into(), w_raw.into());
418
419        assert_close(a, a_ideal.into(), 1e-12);
420        assert_close(w, w_ideal.into(), 1e-12);
421    }
422
423    #[test]
424    fn static_gyro_bias_is_recovered() {
425        let accel_m = na::Matrix3::new(1.01, 0.0, 0.0, 0.004, 0.99, 0.0, -0.002, 0.003, 1.02);
426        let accel_bias = na::Vector3::new(0.05, -0.1, 0.2);
427        let gyro_a = na::Matrix3::new(
428            1e-3, 2e-4, -3e-4, //
429            -5e-4, 7e-4, 1e-4, //
430            2e-4, -1e-4, 6e-4,
431        );
432        let c_gyro_i = small_rotation();
433        let true_bias = na::Vector3::new(0.011, -0.023, 0.006);
434
435        let mut intr = ImuIntrinsics::identity();
436        intr.accel_m = unmat3(&accel_m);
437        intr.accel_bias = unvec3(&accel_bias);
438        intr.gyro_a = unmat3(&gyro_a);
439        intr.c_gyro_i = unmat3(&c_gyro_i);
440
441        // A stationary sensor in some arbitrary attitude.
442        let a_ideal = na::Vector3::new(1.7, -3.1, 9.15);
443        let a_raw = accel_m * a_ideal + accel_bias;
444        let w_raw = gyro_a * (c_gyro_i * a_ideal) + true_bias;
445
446        // The stored bias must not influence the estimate.
447        intr.gyro_bias = [99.0, -99.0, 99.0];
448        let c = intr.corrector().unwrap();
449        let estimated = c.gyro_bias_from_static(a_raw.into(), w_raw.into());
450        assert_close(estimated, true_bias.into(), 1e-12);
451    }
452
453    #[test]
454    fn singular_matrix_is_rejected() {
455        let mut intr = ImuIntrinsics::identity();
456        intr.accel_m = ZERO3;
457        assert!(intr.corrector().is_err());
458    }
459
460    #[test]
461    fn noise_discretisation() {
462        let noise = ImuNoise {
463            accel_noise_density: 1.86e-3,
464            accel_random_walk: 4.33e-4,
465            gyro_noise_density: 1.87e-4,
466            gyro_random_walk: 2.66e-5,
467            update_rate: 200.0,
468        };
469        let dt: f64 = 1.0 / 200.0;
470        assert!((noise.accel_noise_discrete() - 1.86e-3 / dt.sqrt()).abs() < 1e-15);
471        assert!((noise.gyro_noise_discrete() - 1.87e-4 / dt.sqrt()).abs() < 1e-15);
472        assert!((noise.accel_bias_discrete() - 4.33e-4 * dt.sqrt()).abs() < 1e-15);
473        assert!((noise.gyro_bias_discrete() - 2.66e-5 * dt.sqrt()).abs() < 1e-15);
474    }
475
476    #[test]
477    fn msg_correction() {
478        let mut intr = ImuIntrinsics::identity();
479        intr.accel_bias = [1.0, 1.0, 1.0];
480        intr.gyro_bias = [0.5, 0.5, 0.5];
481        let c = intr.corrector().unwrap();
482
483        let corrected = c.correct_msg(ImuMsg::new(&[2.0, 3.0, 4.0], &[1.5, 2.5, 3.5]));
484        assert_close(corrected.linear_acceleration, [1.0, 2.0, 3.0], EPS);
485        assert_close(corrected.angular_velocity, [1.0, 2.0, 3.0], EPS);
486    }
487}