IMU integration starts with the measurement model: what a gyroscope and accelerometer report, in which frame, and with which biases. A wrong gravity sign propagates into every later state update. By the end, you should be able to write both equations from memory and explain why stationary data cannot reveal every state variable.
New to matrices, derivatives, or uncertainty? Start with the mathematical toolkit. It introduces the notation used throughout these lessons.
1 · The bug
An early version of glass-lio's tight coupling averaged the accelerometer over a static
window and used that mean as gravity. The estimator then accelerated slowly and steadily away,
with Z falling further every second.The post-mortem is in the code,
where the fix lives: glasslio_node.cpp,
lines 349–358. Nothing crashed and nothing reported an error. One equation explains it.
Before the equation, extract everything the symptom already tells you. A quadratic fall means a constant acceleration: \(z = \tfrac12 a t^2\). Its size was diagnostic: a small fraction of \(g\), far too large to be gyro bias or round-off and far too small to be a units mistake, but exactly the size of a cheap MEMS accelerometer's bias. So before reading any code you can already guess that some accelerometer bias ended up being treated as a real acceleration. The rest of this lesson shows precisely how.
A habit worth keeping
Characterise every symptom before you debug it: its shape over time and its rough size relative to the physical quantities involved. “It drifts” is not actionable. “It accelerates constantly, at about the size of a sensor bias” rules out most suspects at once.
2 · The two equations
This is how Forster et al. write the IMU model:Forster, Carlone, Dellaert & Scaramuzza, On-Manifold Preintegration for Real-Time Visual-Inertial Odometry, IEEE T-RO, §V, eqs. (27)–(28). This page uses their notation.
- \(\tilde{x}\)
- A tilde means measured: what the sensor reports, not the truth.
- \({}_{B}x,\ {}_{W}x\)
- The frame a vector is expressed in: \(B\) is the body (IMU) frame, \(W\) the world frame.
- \(\mathbf{R}_{WB}\)
- Rotates body-frame vectors into the world, so \(\mathbf{R}_{WB}^{\top}\) takes world vectors into the body frame.
- \({}_{W}\mathbf{a}\)
- The sensor's true acceleration, in the world.
- \({}_{W}\mathbf{g}\)
- The gravity vector in the world. It points down: with \(z\) up, \(\mathbf{g} = [0,\,0,\,-g]^{\top}\).
- \(\mathbf{b}\)
- Bias: a slowly drifting offset, added in the body frame.
- \(\boldsymbol{\eta}\)
- White noise.
The gyroscope equation holds no surprises: true rate plus bias plus noise. All the interesting behaviour is in the accelerometer.Forster also neglects the Earth's rotation, which amounts to treating \(W\) as an inertial frame (§V).
Negligible per step, fatal per minute
A bias can be negligible over one step and fatal over a minute. Over one LiDAR scan, an
uncorrected gyro bias rotates the estimate by far less than anything the LiDAR could notice.
Integrated for a minute without correction it becomes a heading error large enough that a map
built from it is not a map. This is why glass-lio subtracts the bias at the source, before any
integration, even though no single scan would show the difference.The
measured initialisation values are in
docs/implementation/1-imu-init.md, §1 and §5.
3 · The sign that matters: specific force
An accelerometer doesn't measure acceleration \(\mathbf{a}\). It measures \(\mathbf{a} - \mathbf{g}\), expressed in its own body frame. This is called specific force, and two cases make it concrete:
- At rest, level. \(\mathbf{a} = 0\), so \(\tilde{\mathbf{a}} = -\mathbf{R}_{WB}^{\top}\mathbf{g}\), which reads \(+g\) on the axis pointing up. The sensor is feeling the table push it up.
- In free fall. \(\mathbf{a} = \mathbf{g}\), so \(\tilde{\mathbf{a}} = 0\). A falling accelerometer reads nothing.
glass-lio's code relies on this. ImuInit averages the static accelerometer
readings, stores the mean as gravity_, and rotates that vector onto +Z,
pointing up.imu_init.cpp,
lines 73–79. Strictly, by eq. (28) that mean is \(-\mathbf{R}_{WB}^{\top}\mathbf{g} + \mathbf{b}^{a}\):
specific force, not gravity. The variable name is loose, but the maths is consistent.
The node then seeds the estimator's gravity pointing down, at the standard magnitude.
Three more cases, each a real situation
- An elevator starting upward. The vertical reading exceeds \(g\). You feel heavier because the sensor genuinely reads more.
- A tilted sensor at rest. The “up” reading is shared between axes, so part of it appears on a horizontal body axis. None of that is motion; all of it is the frame.
- A car accelerating forward. The reading is gravity's upward part plus a forward part. Its direction tilts away from vertical, and from inside the car that is indistinguishable from parking on a slope.
The last case is the reason the accelerometer cannot, by itself, tell tilt from acceleration, so an initialisation that treats the mean reading as gravity needs a quiet interval. Section 5 shows glass-lio's variation checks and why they cannot rule out steady acceleration.
Units trap
The Livox reports specific force in units of g, not m/s², so at rest it reads about one.
That's why glass-lio has an imu.accel_in_g setting and multiplies every
accelerometer sample by accel_scale before integrating.lio_estimator.cpp,
lines 270–275.
The units trap deserves a second look, because of how quietly it fails. Forget the scaling and three things still work perfectly. Deskewing uses only the gyro, so it is untouched. Initialisation takes only the direction of the mean reading, and scaling a vector does not rotate it, so the world frame is still levelled correctly. Loosely coupled odometry never integrates acceleration at all. The system looks healthy. Then tight coupling integrates acceleration for the first time, every acceleration is too small by a factor of \(g\), and the estimator falls apart in a way that looks like a deep modelling bug.
glass-lio therefore checks the one quantity that does expose the mistake: the
magnitude. After scaling, the mean reading at rest must be close to standard gravity, or the
node logs an error that names the setting to fix.glasslio_node.cpp, lines 375–379.
A check like this costs one line and turns a mystery into an error message. Write one for every
unit conversion you depend on.
4 · The bug, explained
Put \(\mathbf{a} = 0\) into eq. (28) and drop the noise. A stationary IMU reports
Every sample in a static window gives the same sum. Averaging more samples makes the sum more precise, but never separates it into its two parts. And those parts behave differently:
- the gravity term is fixed in the world, so in the body frame it moves whenever the robot turns;
- the bias is fixed in the body, so it turns along with the robot.
The early version treated the whole sum as a world-fixed gravity vector, which baked \(\mathbf{b}^{a}\) into the world frame. As soon as the robot turned, that baked-in bias pointed the wrong way. It became an orientation-dependent fake acceleration, on the scale of \(|\mathbf{b}^{a}|\), which was integrated twice into position. The fix was to seed gravity at its known magnitude and estimate \(\mathbf{b}^{a}\) as a body-frame state.The same equation also tells you when the two become separable: once \(\mathbf{R}_{WB}\) changes, the gravity term moves and the bias doesn't. Suitable changes of orientation, together with motion constraints from other measurements, can separate the terms. Pure yaw about gravity leaves the gravity reading unchanged and is not sufficient.
The error, written down exactly
You can derive the fake acceleration in four lines, and it is worth doing once. Call the attitude at initialisation \(\mathbf{R}_0\). The faulty initialisation sets its gravity estimate from the mean reading,
Later, at attitude \(\mathbf{R}(t)\), the estimator inverts eq. (28) assuming no bias: \(\hat{\mathbf{a}} = \mathbf{R}(t)\,\tilde{\mathbf{a}} + \hat{\mathbf{g}}\). Substitute the true reading and simplify:
Every property of the bug is visible in that last term:
- It is zero at the initial attitude, so the estimator behaves perfectly until the first turn. Short straight tests pass.
- It grows with the turn: after a half-turn the bias's world direction has reversed, so the error reaches twice the bias, and it is integrated twice into position.
- Its direction depends on the path, which is why it looked like drift in an arbitrary direction rather than an obvious offset.
Seed gravity at its true magnitude, carry \(\mathbf{b}^{a}\) as a body-frame state, and the term disappears, because the bias is then subtracted in the frame where it actually lives.
Explain the idea
“An accelerometer measures specific force, \(\mathbf{a} - \mathbf{g}\), in the body frame. At rest you only ever see \(-\mathbf{R}^{\top}\mathbf{g} + \mathbf{b}^{a}\) as one sum, so a static initialisation can't separate gravity from the accelerometer bias. You constrain gravity's magnitude and use sufficiently varied motion and external motion measurements to separate bias from gravity and attitude.”
5 · What a static window can and cannot give you
glass-lio initialises from a short window of IMU data during which the platform is assumed still. Apply the two equations with \(\boldsymbol{\omega} = 0\) and \(\mathbf{a} = 0\) and you get, averaged over the window:
The first average is the gyro bias: nothing else survives. The second is the sum from section 4. glass-lio uses its direction to level the world, rotating it onto \(+Z\), and ignores its measured magnitude in favour of standard gravity. Here is the complete inventory of what the window delivers:
| Quantity | From a static window? | Why |
|---|---|---|
| gyro bias | yes | true rate is zero, so the mean is the bias |
| roll and pitch | yes, approximately | the direction of the mean specific force, tilted slightly by \(\mathbf{b}^{a}\) |
| yaw | no | rotating about gravity changes no reading; it is set to zero |
| accel bias | no | only its sum with gravity is visible |
| velocity | no | see “rest versus constant velocity” below |
The yaw row is not a shortcut. Gravity is a single vector, so it pins down two of the three rotational degrees of freedom and says nothing about heading. Choosing zero yaw and saying so is the honest response to an unobservable quantity.
Two checks, and why the second one exists
The averages above describe rest or constant velocity with negligible rotation.
glass-lio accepts a window only when both of these heuristics hold:imu_init.cpp, lines 46–71,
with the thresholds in livox_mid_360.yaml, lines 259–265.
- no sample rotates faster than a configured rate, measured as the maximum gyro norm;
- no axis of the accelerometer varies by more than a configured amount in standard deviation.
The gyro check misses straight-line acceleration. The accelerometer standard-deviation check detects acceleration that changes during the window, but a steady push can pass both checks: its constant forward acceleration is absorbed into the estimated gravity direction. These gates are practical stationarity heuristics, not proof that the platform was at rest.
When a window fails, it slides forward by one sample rather than being discarded wholesale. That detail matters more than it looks: clearing the buffer only ever tests non-overlapping blocks, so a genuine rest that straddles a block boundary is rejected by both halves. On handheld recordings, the sliding version finds rests that the clearing version rejects.
Rest versus constant velocity
Neither check can tell rest from constant velocity. Both give zero rotation and zero acceleration, because that is what “not accelerating” means, and eq. (28) contains no velocity at all. glass-lio's test bag begins with the robot already cruising. The window passes, correctly: gravity is uncorrupted when acceleration is zero. But the estimator is seeded with zero velocity, which is wrong, and the tightly coupled solver needs a correct velocity to start. glass-lio solves this by running the loosely coupled LiDAR odometry for a few scans, which measures the velocity, before switching the IMU in.
Read the log carefully
“Static window detected” means the samples passed the rotation-rate and acceleration-variation gates. It does not prove rest or even zero acceleration: constant velocity and steady acceleration can both pass, although only the former leaves gravity alignment uncorrupted.
6 · What the model leaves out
Eqs. (27)–(28) are a model, and every model is a list of things deliberately ignored. Knowing the list tells you when the model stops being good enough.
The Earth's rotation
A perfect gyro at rest on the ground reads the Earth's rotation. Next to a MEMS gyro's bias that is a tiny fraction, far inside the bias uncertainty, so treating the world frame as non-rotating costs nothing measurable. For a navigation-grade gyro, whose bias is orders of magnitude smaller, the Earth's rotation is the biggest signal in the reading and has to be modelled. The same equation, different sensor, different verdict.
The lever arm
The accelerometer measures the specific force at its own location. If the body rotates, a point at offset \(\mathbf{r}\) from the rotation centre accelerates even when the centre does not:
Inside the Mid-360 the IMU chip sits a few centimetres from the LiDAR's optical
origin.livox_mid_360.yaml, line 281.
The centripetal term grows with the square of the turn rate, and during brisk turns it can rival
the accelerometer bias. glass-lio tracks the pose of the IMU body itself, so the offset enters only
when converting between LiDAR and IMU positions; for a system that tracked a different point on the
vehicle, this term would be one of the first to show up.
Scale factor, misalignment, and temperature
Real sensors also have a small gain error per axis, axes that are not perfectly orthogonal, and biases that drift with temperature. On a good MEMS part these effects are small, and the measured magnitude of gravity at rest deviates from the standard value only slightly, through exactly these effects and the accelerometer bias. At that level they are absorbed by the bias states and the noise model. Estimators for poorer sensors add explicit intrinsic-calibration states; glass-lio does not need to.
7 · Four noise parameters, and what they mean
The noise terms \(\boldsymbol{\eta}\) cannot be subtracted, but they can be sized, and the
sizing decides how much the solver believes the IMU. glass-lio configures four parameters for the
Mid-360:livox_mid_360.yaml, lines 226–234.
| Setting | What it describes |
|---|---|
gyro_noise | white noise on each gyro sample |
accel_noise | white noise on each accelerometer sample |
bias_rw_gyro | how fast the gyro bias may wander |
bias_rw_accel | how fast the accelerometer bias may wander |
The first two are densities: they describe noise per root-hertz, so the variance of an integrated quantity grows linearly with time (the toolkit's section 5 converts them). The second two describe the bias as a random walk: not a constant, but something that drifts slowly, and only slowly. That slowness is itself information. Without it, a gyro bias and a genuine slow turn would be indistinguishable, and the solver could explain any rotation by adjusting the bias.
These parameters come from the datasheet and are then checked on data. They are physical properties of the part, not tuning knobs. Lesson 5 shows how they become the covariance that decides, scan by scan, whether the IMU or the LiDAR wins an argument.
8 · Where this lives in the code
Every preintegration step in glass-core starts from eqs. (27)–(28) rearranged, by
subtracting the current bias estimates:preintegration.cpp,
lines 29–33. The noise terms \(\boldsymbol{\eta}\) aren't subtracted, because they can't be.
They come back as the covariance the preintegration carries (lines 64–66).
const Eigen::Vector3d w = gyro - bias_gyro_;
const Eigen::Vector3d a = accel - bias_accel_;
Notice what that function doesn't do: it never touches gravity. So where does \(\mathbf{g}\) come back in? That's the next lesson.
9 · Practice
Answer from memory, without scrolling back up. Having to work to recall it is what makes it stick.
A level IMU sits still on a table, with its \(z\) axis pointing up. Ignoring bias and noise, what does the accelerometer's \(z\) axis read?
\(\mathbf{a} = 0\), so \(\tilde{\mathbf{a}} = -\mathbf{R}^{\top}\mathbf{g} = -[0,\,0,\,-g]^{\top}\), which is \(+g\): the table pushing up. If you chose \(-g\), you probably used \(\mathbf{a} + \mathbf{g}\), or a gravity vector pointing up.
The same IMU is dropped and is now in free fall. What does its \(z\) axis read?
In free fall \(\mathbf{a} = \mathbf{g}\), so \(\mathbf{a} - \mathbf{g} = 0\). An accelerometer measures specific force, and a falling sensor feels none.
In eq. (28), which frame is the accelerometer bias \(\mathbf{b}^{a}\) expressed in?
It's added after \(\mathbf{R}_{WB}^{\top}\) has brought everything into \(B\), so it turns with the IMU. Gravity sits inside the bracket, fixed in \(W\).
A static window averages a great many accelerometer samples. Why still can't it separate gravity from \(\mathbf{b}^{a}\)?
Every sample gives the same \(-\mathbf{R}^{\top}\mathbf{g} + \mathbf{b}^{a}\). More samples make that sum more precise but can't split it. Additional information is needed: for example, known orientations with different gravity projections, or sufficiently excited motion measured by LiDAR. More samples at one unknown static attitude cannot do it.
A tightly coupled estimator falls at a small, steady acceleration, starting only after the robot's first turn. The most likely cause is…
The size, a small fraction of \(g\), matches a MEMS accelerometer bias; a units error would be off by a factor of \(g\). And the error term \((\mathbf{R}(t)-\mathbf{R}_0)\mathbf{b}^{a}\) is zero until the attitude changes, which is why it starts with the first turn.
With gravity estimated as the static mean, how large is the fake acceleration after a half-turn?
The error is \((\mathbf{R}(t) - \mathbf{R}_0)\,\mathbf{b}^{a}\). A half-turn reverses the bias's world direction, so the difference has twice its length, and it is integrated twice into position.
A robot is pushed in a straight line, accelerating gently, during the initialisation window. Which check stops the bad initialisation?
A straight push has no rotation, so the gyro check passes. Only the accelerometer's variability exposes it; without that check the push would be averaged into “gravity” and tilt the world frame.
A static window reports success while the robot cruises at constant velocity. Which initial value is now wrong?
Constant velocity means zero acceleration and zero rotation, so the gyro bias and the gravity direction are both still valid. Velocity does not appear in either measurement equation, so the window cannot see it. glass-lio measures it with a few scans of LiDAR odometry instead.
From memory: write both measurement models, gyroscope and accelerometer, and label the frame of each term. Then explain the principle in your own words.
- Tildes on the measured side only
- \(\mathbf{R}_{WB}^{\top}\) in front of the accelerometer bracket
- \(\mathbf{a} - \mathbf{g}\), not \(\mathbf{a} + \mathbf{g}\)
- Bias and noise added outside the bracket, in \(B\)
- Gravity inside the bracket, in \(W\)
Missed a box? Good: that's the useful kind of mistake. Try this box again tomorrow, before rereading anything.
From memory: derive the fake acceleration produced when the static mean is used as gravity, starting from \(\hat{\mathbf{g}} = -\mathbf{R}_0\tilde{\mathbf{a}}_0\). Then list what a static window can and cannot estimate.
- \(\hat{\mathbf{g}} = \mathbf{g} - \mathbf{R}_0\mathbf{b}^{a}\), with the bias rotated into the world
- The error vanishes at the initial attitude and grows with the turn
- Estimable: gyro bias, roll, pitch
- Not estimable: yaw, accelerometer bias, velocity
- You mentioned both motion checks, and why the accelerometer one exists
If the derivation stalled, redo section 4's substitution line by line tomorrow.