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);
<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);
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.
Comments
Loading comments...