The 1€ Filter: Smooth Robot Teleop Without the Lag
Noise makes a teleoperated arm buzz at rest and lag in motion, and one fixed smoothing setting can't fix both. A visual tour of the filters in the drawer — moving average, EMA, Holt, Kalman, median, offline smoothing — and the ten lines that adapt.
Hold a leader arm and the robot copies your hand. Twist your wrist; the follower twists. Squeeze the trigger; the gripper closes. For a few seconds it feels like teleoperation has finally stopped being a research problem.
Then you hold still. The robot trembles. Its servos whine through a few hundred micro-corrections a second, and if a dataset is recording the demonstration, it is now learning a motion you never made.
Move fast and the other failure arrives: the arm trails your hand like it’s on a rubber band, late to every waypoint.
Every teleop stack I’ve read ships some filter to fix the trembling — almost always one fixed setting, chosen once and never revisited. This post is about why one setting can’t be right, and about the filter I reach for instead: the 1€ filter.
But first, the whole drawer. Every filter you might put in that slot gets the same treatment below: one idea, one live demo, the arithmetic at 60 Hz, where it shows up in real pipelines, and how it betrays you. The 1€ filter is the last one we open, and by then you’ll have felt the problem it solves in your fingers. The figures are interactive where it matters, so you can feel it instead of taking my word for it.
One promise before we start. Every simulated figure in this post runs on the same nine-second test signal — hold, swing, hold, slow drift, swing, hold — and reports the same two numbers, defined once and scaled identically everywhere. Learn to read the signal once; every later figure is déjà vu.
Two failures, one trade
A fixed filter cannot tell your motion from your noise, so it smooths both.
At rest, the signal is almost pure noise: encoder quantization, IMU jitter, a little timing wobble in a webcam tracker. Here smoothing is pure benefit. Cut the noise and the robot goes still, and nothing is moving fast enough to go late.
In motion, the same smoothing turns into pure cost. The filter reports where your hand was. The stronger it is, the older that position gets — and trailing the leader by 100 ms is visible in a quick demo and disqualifying in a haptic or bilateral rig.
Two meters capture the pair, and they follow us through the whole post:
- buzz — how far the filtered output sits from the truth while the hand is still, as a percentage of the sensor’s own noise. 100% means the filter does nothing; lower is quieter.
- trail — how far the output sits from the truth while the hand is moving, converted into milliseconds using the hand’s own speed. It answers one question: how old is the position the robot is following?
One slider is enough to see the trap. Smoothing up: the robot stops buzzing but lags every move. Smoothing down: it tracks your hand and buzzes at rest. For any fixed filter the trade is monotone — improving one meter always costs the other. That’s not a tuning failure; it’s the shape of the trade, and it’s what every fixed method in this post inherits, no matter how clever it gets. File that thought away — one of the filters further down gets to leave the curve.
Open the drawer
Before the methods, one distinction decides everything for a live robot: causality.
A filter is causal if its output at time t is computed only from samples up to time t. It can run inside the control loop. A filter is non-causal if it wants samples from the future — perfectly possible offline, over a recording, and impossible live, because the future hasn’t arrived.
Everything until the last section of this post is causal. The offline family gets its own section, clearly marked, because it belongs to a different job: cleaning a dataset, not moving an arm.
| Method | Causal? | What it assumes | Signature failure | Where you meet it |
|---|---|---|---|---|
| Moving average | yes | nothing | uniform lag, worse as w grows | firmware sensor pre-processing |
| EMA | yes | nothing | lag proportional to speed | gaze and pose smoothing everywhere |
| Holt (double EMA) | yes | the current trend continues | overshoot at every reversal | VR tracker prediction |
| Kalman | yes | a motion model (e.g. constant velocity) | wrong model → buzz and trail at once | landmark fusion, telemetry |
| Median | yes | outliers are rare | no noise averaging at all, staircase output | spike and packet-drop cleanup |
| Butterworth, Savitzky–Golay, Gauss, splines | no | the whole recording exists | cannot run live, period | dataset post-processing |
This catalog is well-trodden ground — my companion reading for it is the Deelvin overview of time-series filtering algorithms. The difference here is the stage: every method gets shown on teleoperation data, with the two meters above, so they compare on the same ruler.
One more thing before the tour: every causal filter below has a strength dial, and the dials are not in the same units. Some are windows, some are alphas, some are trust parameters. Where there’s a way to express the dial in hertz, I’ll do it — hertz is the unit that survives a change of sample rate, which matters, because your loop rate will change on you.
The moving average
The oldest tool in the drawer. Keep the last w samples, output their mean:
No assumptions, no tuning beyond w, five lines of code. Here it is on the test signal:
Read the meters at w = 15: about a third of the sensor’s buzz survives, and the trail lands near 115 ms. Push to w = 50 and buzz falls under 20% — but the trail stretches past 300 ms, which on a real arm is the difference between mimicking a person and mocking one. Pull to w = 5 and the trail drops to about 33 ms while half the noise walks right back in.
const buf: number[] = []
function movingAverage(x: number, w: number) {
buf.push(x)
if (buf.length > w) buf.shift()
return buf.reduce((a, b) => a + b, 0) / buf.length
}
You mostly meet the moving average below your code: IMU firmware averages a handful of samples before reporting a reading, and some servo buses smooth encoder ticks the same way. It’s the default there because it’s five lines and assumes nothing.
Its failure has two shapes. Every time your hand stops, the output needs about w/2 samples to arrive, so a burst of quick strokes turns into a staircase of smeared plateaus — motion no human hand makes. And the lag is identical whether you move at 1 mm/s or 1 m/s, which would be fine if your hand moved at one speed. It doesn’t. (File that clause away too — it’s the crack the 1€ filter lives in.)
Citation
For attribution, please cite this work as:
Shimizu, Kye (2026). The 1€ Filter: Smooth Robot Teleop Without the Lag. kyeshimizu.com. https://kyeshimizu.com/blog/2026-10-16-one-euro-filter
@misc{shimizu20262026,
author = {Kye Shimizu},
title = {The 1€ Filter: Smooth Robot Teleop Without the Lag},
year = {2026},
url = {https://kyeshimizu.com/blog/2026-10-16-one-euro-filter},
note = {kyeshimizu.com}
}