Introduction

Pick up almost any drone, phone or game controller and it knows, instantly, which way is up. Inside sits an IMU (inertial measurement unit): a gyroscope that measures how fast it's spinning, and an accelerometer that feels gravity pulling down. Neither sensor is trustworthy alone.

The gyroscope is precise for an instant but drifts: tiny errors pile up every millisecond until "up" quietly wanders away from reality. The accelerometer knows true "down" on average, but every step, vibration or bump adds noise that has nothing to do with orientation.

In 2010, engineer Sebastian Madgwick published a filter that fuses the two into a single rotating quaternion — a stable estimate of orientation, corrected dozens of times per second, cheap enough to run on the tiny processor inside a toy drone.

Try It

Below, a simulated sensor tilts and wobbles while its gyroscope drifts and its accelerometer picks up noisy vibration. The raw needle jitters and slowly wanders off; the filtered needle, run through the Madgwick update, stays close to the true tilt.

<p class="hint">A simulated sensor tilts back and forth. Its gyroscope drifts over time and its accelerometer is noisy — the <b>raw</b> dial (left) tracks the gyroscope alone; the <b>filtered</b> dial (right) runs the Madgwick gradient-descent correction every frame.</p>
<div class="stage">
  <div class="dial-wrap">
    <canvas id="dialRaw" width="160" height="160"></canvas>
    <span class="dial-label">Raw (gyro only)</span>
  </div>
  <div class="dial-wrap">
    <canvas id="dialFiltered" width="160" height="160"></canvas>
    <span class="dial-label">Filtered (Madgwick)</span>
  </div>
</div>
<div class="controls">
  <label>Accelerometer noise <span id="noiseVal">0.30</span>
    <input type="range" id="noise" min="0" max="1" step="0.01" value="0.30">
  </label>
  <label>Gyroscope drift <span id="driftVal">0.20</span>
    <input type="range" id="drift" min="0" max="1" step="0.01" value="0.20">
  </label>
  <label>Beta (correction gain) <span id="betaVal">0.15</span>
    <input type="range" id="beta" min="0" max="0.5" step="0.01" value="0.15">
  </label>
</div>
<div class="btns">
  <button id="toggle" type="button">Pause</button>
  <button id="reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status">Watch the two needles diverge as drift and noise build up.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.stage { display: flex; gap: 1.6rem; align-items: center; justify-content: center; margin: .5rem 0 1rem; flex-wrap: wrap; }
.dial-wrap { display: flex; flex-direction: column; align-items: center; gap: .35rem; }
.dial-label { font-size: .8rem; font-weight: 700; color: #1d3557; text-transform: uppercase; letter-spacing: .03em; }
canvas { background: #eef2f5; border: 1px solid #cdd9e3; border-radius: 50%; }
.controls { display: flex; flex-direction: column; gap: .55rem; max-width: 420px; margin: 0 auto .8rem; }
.controls label { font-size: .85rem; font-weight: 600; color: #333; display: flex; flex-direction: column; gap: .2rem; }
.controls input[type="range"] { width: 100%; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: center; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.status { font-size: .85rem; color: #444; margin: .6rem 0 0; text-align: center; min-height: 1.4em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

// Madgwick orientation filter demo, simplified to a single tilt angle for clarity.
// True angle simulation — the sensor really oscillates like a slow pendulum.
let trueAngle = 0;      // the ground-truth tilt angle (radians), never shown directly to the player
let gyroBiasAngle = 0;  // accumulated gyroscope drift (bias) that grows over time
let rawAngle = 0;       // angle estimated by blindly integrating the noisy gyroscope
let filteredAngle = 0;  // angle estimated by the Madgwick gradient-descent filter
let t = 0;
let running = true;

const noiseEl = document.getElementById('noise');
const driftEl = document.getElementById('drift');
const betaEl = document.getElementById('beta');
const noiseVal = document.getElementById('noiseVal');
const driftVal = document.getElementById('driftVal');
const betaVal = document.getElementById('betaVal');
const statusEl = document.getElementById('status');

function syncLabels(){
  noiseVal.textContent = Number(noiseEl.value).toFixed(2);
  driftVal.textContent = Number(driftEl.value).toFixed(2);
  betaVal.textContent = Number(betaEl.value).toFixed(2);
}
[noiseEl, driftEl, betaEl].forEach(el => el.addEventListener('input', syncLabels));
syncLabels();

// Keep an angle within [-pi, pi] so it doesn't grow without bound.
function wrap(a){ while(a > Math.PI) a -= 2*Math.PI; while(a < -Math.PI) a += 2*Math.PI; return a; }

// Simulate one gyroscope reading for this frame.
function gyroStep(dt){
  const noise = Number(noiseEl.value);
  const drift = Number(driftEl.value);
  // True angular velocity: derivative of the true angle's oscillation.
  const trueOmega = 0.6 * Math.cos(t * 0.5);
  gyroBiasAngle += drift * 0.05 * dt * 60;
  const gyroNoise = (Math.random() - 0.5) * noise * 0.15;
  const measuredOmega = trueOmega + gyroNoise;
  return { trueOmega, measuredOmega };
}

// Simulate one accelerometer-derived angle reading for this frame.
function accelReading(noise){
  // The accelerometer would read gravity's tilt; here we perturb the true angle with noise.
  const shake = (Math.random() - 0.5) * noise * 0.9;
  return trueAngle + shake;
}

// One Madgwick update step: predict from the gyroscope, then correct with the accelerometer via a gradient-descent-style pull toward the measured angle.
function madgwickUpdate(gyroOmegaMeasured, accelAngle, dt){
  const beta = Number(betaEl.value);
  // Predict step - integrate the (noisy, biased) gyroscope reading.
  const predicted = filteredAngle + gyroOmegaMeasured * dt + gyroBiasAngle * dt;
  // Error function - how far the gyro-only prediction is from what the accelerometer sees.
  const error = wrap(accelAngle - predicted);
  // Gradient-descent correction - nudge the prediction toward the accelerometer by beta times the error.
  const correction = beta * error;
  return predicted + correction;
}

function step(){
  if(!running) return;
  const dt = 1/30;
  t += dt;
  trueAngle = 0.9 * Math.sin(t * 0.5);

  const { measuredOmega } = gyroStep(dt);
  const noise = Number(noiseEl.value);

  // The raw estimate only ever integrates the gyroscope, so bias accumulates unchecked.
  rawAngle = wrap(rawAngle + measuredOmega * dt + gyroBiasAngle * dt);

  const accelAngle = accelReading(noise);
  filteredAngle = wrap(madgwickUpdate(measuredOmega, accelAngle, dt));

  drawDial('dialRaw', rawAngle, '#e63946');
  drawDial('dialFiltered', filteredAngle, '#0a7d33');

  const driftDeg = Math.abs(rawAngle - trueAngle) * (180/Math.PI);
  const filtDeg = Math.abs(filteredAngle - trueAngle) * (180/Math.PI);
  statusEl.textContent = i18nFmt('Raw error: %raw% deg | Filtered error: %filt% deg', { raw: driftDeg.toFixed(1), filt: filtDeg.toFixed(1) });
}

function drawDial(canvasId, angle, color){
  const cv = document.getElementById(canvasId);
  const ctx = cv.getContext('2d');
  const w = cv.width, h = cv.height, cx = w/2, cy = h/2, r = w/2 - 12;
  ctx.clearRect(0,0,w,h);
  // Draw fixed reference ticks at top and bottom of the dial.
  ctx.strokeStyle = '#cdd9e3';
  ctx.lineWidth = 1;
  ctx.beginPath(); ctx.moveTo(cx, 10); ctx.lineTo(cx, 22); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(cx, h-10); ctx.lineTo(cx, h-22); ctx.stroke();
  // Draw the needle at the current estimated angle.
  const nx = cx + r * Math.sin(angle);
  const ny = cy - r * Math.cos(angle);
  ctx.strokeStyle = color;
  ctx.lineWidth = 4;
  ctx.lineCap = 'round';
  ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(nx, ny); ctx.stroke();
  ctx.fillStyle = color;
  ctx.beginPath(); ctx.arc(cx, cy, 5, 0, Math.PI*2); ctx.fill();
}

document.getElementById('toggle').addEventListener('click', () => {
  running = !running;
  document.getElementById('toggle').textContent = running ? 'Pause' : 'Resume';
});
document.getElementById('reset').addEventListener('click', () => {
  rawAngle = 0; filteredAngle = 0; gyroBiasAngle = 0; t = 0;
  statusEl.textContent = 'Watch the two needles diverge as drift and noise build up.';
  drawDial('dialRaw', 0, '#e63946');
  drawDial('dialFiltered', 0, '#0a7d33');
});

drawDial('dialRaw', 0, '#e63946');
drawDial('dialFiltered', 0, '#0a7d33');
setInterval(step, 1000/30);
Result
Console

Turn up the noise and drift sliders and watch the raw reading fall apart while the filtered one barely flinches. Then drag beta — the correction gain — down toward zero and watch the filter start trusting the drifting gyroscope again, exactly like a real IMU with the wrong tuning.

The Real Complexity

The Madgwick filter is not a search over possibilities — there is nothing to check or brute-force. It is a fixed, tiny calculation repeated at every sample:

  • Predict. Integrate the gyroscope's angular velocity to advance the current orientation quaternion qq by one time step: qω=q+q˙ω Δtq_{\omega} = q + \dot{q}_{\omega}\,\Delta t.
  • Measure the error. Rotate the reference "down" vector by the predicted qq and compare it against what the accelerometer actually feels. The mismatch is an error function f(q,a)f(q, a) — zero exactly when the orientation is consistent with gravity.
  • Descend the gradient. Compute ∇f\nabla f, the analytic gradient of that error with respect to the four quaternion components, and nudge qq a small step down that gradient, scaled by a gain β\beta: qε=−β⋅∇f∥∇f∥q_{\varepsilon} = -\beta \cdot \dfrac{\nabla f}{\lVert \nabla f \rVert}.
  • Fuse and normalize. Blend the gyroscope prediction with the gradient correction, then rescale the quaternion back to unit length so it stays a valid rotation.

Every step is a fixed handful of multiplications and one square root — O(1) per sample, no matrix inversion, unlike a full Kalman filter that must propagate and invert a covariance matrix. That is the whole trick: β\beta is a single dial that trades trusting the gyroscope (smooth but drifting) against trusting the accelerometer (noisy but unbiased), and gradient descent finds a good blend every single sample without ever "solving" anything in the traditional sense.

Where It Matters

Fusing a drifting rotation sensor with a noisy reference direction is a problem that shows up anywhere something needs to know its own orientation, cheaply and in real time:

  • Drones and flight controllers: the attitude estimate that keeps a quadcopter level runs a Madgwick-style filter thousands of times a second on a tiny microcontroller.
  • Phones and wearables: screen auto-rotate, step counting and fitness tracking all depend on a stable "which way is this device pointing" signal from cheap MEMS chips.
  • VR and AR headsets: head-tracking latency has to be near zero, so lightweight gradient-descent fusion beats heavier alternatives for the orientation part of the pipeline.
  • Robotics: legged and wheeled robots fuse IMUs the same way to keep their internal sense of "which way is up" from drifting during a mission.

Understand why a single gradient-descent step is enough to fuse two unreliable sensors, and you've met the same idea behind every Kalman filter-based estimator: combine a noisy model with a noisy measurement, weighted by how much you trust each.

Conclusion

Neither the gyroscope nor the accelerometer inside your phone is, on its own, good enough to track orientation. The Madgwick filter's insight is that you don't need a heavyweight solver to fuse them — one small step down an error gradient, applied every sample, is enough to keep a quaternion honest.

So the next time a drone hovers steady in the wind, or your phone's screen flips at exactly the right moment, there's a tiny piece of calculus running underneath, quietly turning noise into a rotation you can trust.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/madgwick-ahrs/Content licensed under CC BY-NC 4.0.