Aiming Maths and Calibration · Volume 6

Smoothing — Moving Averages, the One-Euro Filter and Kalman, Traded in Milliseconds and Millimetres

Every filter the open firmware family uses, read from the source and run in simulation at 209 Hz: how much jitter each removes, how much lag it adds on a slow drift and a fast swing, what happens when the gun stops, an out-of-bounds write in v6.2's averaging, and a proposal for this build

Figure 1 — Nine filters at 209 Hz. Left: the fraction of centroid jitter each leaves with the gun still. Right: the lag each adds on a steady slow sweep and a steady fast one. Fixed filters trade one against …
Figure 1 — Nine filters at 209 Hz. Left: the fraction of centroid jitter each leaves with the gun still. Right: the lag each adds on a steady slow sweep and a steady fast one. Fixed filters trade one against the other in a straight line; the One-Euro filter breaks the line by changing with speed; the Kalman filter removes lag on a steady sweep at the price of overshoot when the gun stops. Own work, from the dive's simulation.

6.1 Jitter and Lag Are One Trade

With the gun held perfectly still, the cursor still moves, because each emitter’s centroid carries noise from frame to frame. Volume 7 turns that noise into millimetres: for the Wii-class camera at 2.5 m, an assumed 0.1-pixel centroid noise becomes 0.8 mm of aim jitter, about 1.3 pixels on a 55-inch 1080p picture. A hand adds tremor on top. Smoothing removes some of it by averaging, and every average looks backward in time, so the cursor follows the gun late.

Two numbers describe any linear filter for this purpose. The noise ratio is the standard deviation of its output over that of its input, for white noise with the gun still. The lag is how far behind a steadily moving input its output settles, in milliseconds; for a linear filter that is the mean of its impulse response, the group delay at low frequency. The simulation (aim_maths.py sections 13 and 21) runs each filter at 209 Hz on white noise of 3.2 units (0.1 physical pixel of the Wii-class camera in OpenFIRE’s internal units), on steady sweeps at 2.6°/s and 258°/s, and on a 100 ms swing at 26°/s that stops.

6.2 OpenFIRE’s Averages

OpenFIRE v6.2 offers three run modes per profile (Open-Source Firmware, Volume 5 describes them as the App presents them). The code is in GetPosition(), OpenFIREcommon.cpp lines 679–701, and acts on the cursor position after the homography and the offsets:

Average:   mouseX = (arr[0] + arr[1]) / 2;                       (this frame and the last)
Average2:  mouseX = (mouseX + arr[0] + arr[1] + arr[2]) / 4;      (arr includes this frame)

Average2 therefore weights this frame twice and the two before once each: (2, 1, 1) / 4. For filters this simple the numbers can be written down:

Table 1 — Average2 therefore weights this frame twice and the two before once each: (2, 1, 1) / 4. For filters this simple the numbers can be written down

ModeWeightsNoise ratioLagLag in ms at 209 Hz
Normal11.0000
Average (App: “1-Frame Avg”, the default)(1, 1) / 21/√2 = 0.710.5 frame2.4
Average2 (App: “2-Frame Avg”)(2, 1, 1) / 4√6 / 4 = 0.610.75 frame3.6

The simulation agrees (0.71 and 0.63, 2.39 and 3.59 ms). These are the honest small filters the instruction book describes: they “reduce the motion jitter a bit without adding much if any noticeable lag”. They cannot do much more, because a moving average of N frames reduces noise only by √N while adding (N − 1)/2 frames of lag: four frames gives 0.52 and 7.2 ms.

6.3 Two Bugs in the Averaging Code

Prow Enhanced’s Average2 (SamcoEnhanced.ino lines 912–913) is

moveXAxis = (moveXAxis + arr[0] + arr[1] + arr[1] + 2) / 4;

which uses arr[1] twice and arr[2] never. Since the current frame is also stored in the array at a rotating index, the weights depend on which slot is current, and they cycle with a period of three frames: (2, 0, 2), (3, 1, 0) and (1, 2, 1) over the current frame and the two before it. A filter whose weights change every frame imposes a pattern of its own at a third of the frame rate, about 70 Hz. OpenFIRE’s version (6.2) is correct; this is history, and a warning about copying averaging code between forks.

OpenFIRE v6.2’s Average has a real one. moveIndex is shared by the two modes and never reset when the run mode changes. Average toggles it with moveIndex ^= 1 (line 682), and Average2 cycles it 0, 1, 2. Switch a profile from Average2 to Average while moveIndex is 2 and the toggle produces 3: the next line writes moveXAxisArr[3], one past the end of a three-element array (declared at OpenFIREcommon.h line 143), and from then on the index alternates between 3 and 2, the average reads only the stale slots 0 and 1, and the cursor freezes at an old position until the mode changes again. The ESP32 port found and fixed it (its OpenFIREcommon.cpp lines 1357–1360: “moveIndex can be 2 when coming from RunMode_Average2 … would then write past the end of the buffers (index 3) and keep alternating 3 and 2, freezing the average on stale samples”). The trigger is changing the run mode — directly, or by switching to a profile with a different one — at a moment when the index happens to be 2, one time in three. It is a reading of the code, not yet reproduced on a gun.

6.4 The Exponential Average

The exponential moving average (EMA) is the one-line low-pass filter on which the One-Euro filter is built:

y ← y + α·(x − y),     α = 1 / (1 + τ/T),     τ = 1 / (2π·f_c)

with T the frame period and f_c the cutoff frequency. Its lag on a steady sweep is exactly τ, (1 − α)/α frames (the simulation finds 10.6 ms at f_c = 15 Hz and 31.8 ms at 5 Hz), and its noise ratio for white noise is √(α / (2 − α)): 0.45 at 15 Hz, 0.28 at 5 Hz. It is a better trade than a moving average of the same lag, because it weights the recent past most heavily, but it is still one point on one line. Any fixed linear filter that halves the jitter adds lag of the order of 5 ms at this frame rate; any that quarters it, of the order of 30 ms.

6.5 The One-Euro Filter

The insight of Casiez, Roussel and Vogel’s “1€ Filter” (CHI 2012) is that jitter and lag matter at different times. When the pointer is still or moving slowly, the user sees jitter and does not notice lag. When it moves fast, the user sees lag and cannot see jitter. So make the EMA’s cutoff a function of speed:

dx  = (x − x_prev) / T
v   ← v + α(f_d)·(dx − v)                 (a smoothed speed, with its own cutoff f_d)
f_c = f_min + β·|v|
y   ← y + α(f_c)·(x − y)

Two parameters matter: f_min, the cutoff at rest, and β, how fast the cutoff opens with speed. The authors’ page gives the tuning procedure: set β to 0 and f_min to about 1 Hz; hold still and lower f_min until the jitter is acceptable; then move quickly and raise β, starting from values like 0.001 or 0.0001 and scaling by factors of ten, until the lag is acceptable.

The effect on lag is the useful property. On a steady sweep at speed v the EMA lags by τ = 1 / (2π f_c), so the position lag is v / (2π(f_min + β v)), which for large v approaches the constant 1/(2πβ), in position units. The filter’s lag in position stops growing with speed; its lag in time shrinks.

6.6 The ESP32 Port’s One-Euro Filter

The ESP32 port builds a One-Euro filter into its pipeline (OpenFIRE_Multi_One_Euro_Filter.cpp and .h, enabled by -D USE_MULTI_ONE_EURO_FILTER in platformio.ini line 117). It departs from the textbook in five ways:

  1. It filters the four emitter points, not the cursor, before the homography (OpenFIREcommon.cpp lines 1298–1317 of the port).
  2. One cutoff for all four points (“rigid body”, lines 165–172): each point’s speed is estimated, and the fastest sets the cutoff for all, so the quadrilateral is never distorted by its corners being filtered differently.
  3. Two derivative cutoffs (lines 79–80, 127–162): 1 Hz normally and 25 Hz when the speed estimate changes abruptly, blended between, so the speed estimate catches a sudden flick quickly.
  4. Beta and the snap thresholds scale with the camera’s physical pixel (lines 25–34), “normalised on the physical sensor pixel”, so the same tuning carries across the Wii-class and PixArt cameras; beta is reduced for points near the edge of the field, where the lens is worst (lines 85–113).
  5. A “micro-snap” (lines 188–194): when a point’s raw value has not changed since the last frame and the filtered value is within half a unit of it, the output jumps to the raw value, “eliminating only the fractional tail when the integer input is already still”.

Its parameters (header lines 43–84) are f_min = 0.1 Hz, f_d = 1 Hz (base) and 25 Hz (snap), f_c capped at 30 Hz, and for the Wii-class camera β = 0.0077 per internal unit per second (0.2464 / 32, the header noting that this reproduces “exactly … beta=0.0077 as before”). Run through the simulation with the textbook form (without the snap thresholds, edge attenuation or micro-snap, which need the multi-point context):

Table 2 — Its parameters (header lines 43–84) are fmin = 0.1 Hz, fd = 1 Hz (base) and 25 Hz (snap), fc capped at 30 Hz, and for the Wii-class camera β = 0.0077 per internal unit per second (0.2464 / 32, the header noting that this reproduces "exactly ... beta=0.0077 as before"). Run through the simulation with the textbook form (without the snap thresholds, edge attenuation or micro-snap, which need the multi-point context)

PropertyPort’s parametersTextbook start (f_min 1 Hz, β 0.007)
Jitter left with the gun still0.070.15
Time constant at rest1.6 s0.16 s
Lag on a 2.6°/s sweep62 ms49 ms
Lag on a 258°/s sweep5.3 ms0.7 ms
Lag at the moment a 100 ms, 26°/s swing stops1.6 physical px1.6 physical px
Position lag ceiling, 1/(2πβ)0.65 physical px0.71 physical px
Cutoff reaches its 30 Hz cap at31°/snone

aim_maths.py section 13 and 21. Physical pixels are Wii-class camera pixels, 0.258° each.

Read together, the numbers describe a filter tuned hard for steadiness. At rest it removes 93 % of the jitter, with a 1.6-second time constant that would make the cursor crawl if the micro-snap did not cut the tail off once the raw input stops changing. On slow aiming it lags by tens of milliseconds, but its position lag never exceeds about two-thirds of a sensor pixel, 0.17°. On a fast swing the 30 Hz cap sets the lag at 5.3 ms. The weak point is the transition: a short flick does not last long enough for the 1 Hz derivative filter to see the speed, and the cursor arrives late — 1.6 sensor pixels, 0.4°, behind the gun at the moment it stops, which is 18 mm on the screen at 2.5 m. The port’s 25 Hz “snap” derivative is designed for exactly that case and will shorten it; the simulation does not model the snap, so the real figure should be better.

6.7 A Kalman Filter

A Kalman filter with a constant-velocity model treats the aim point as a position and a velocity, predicts both forward one frame, and corrects them by the new measurement, weighted by how much it trusts the model (process noise, here a white acceleration of spectral density q) against the measurement (noise σ):

predict:  x ← x + T·v,  P ← F P Fᵀ + Q
update:   K = P Hᵀ (H P Hᵀ + σ²)⁻¹,  [x, v] ← [x, v] + K·(z − x)

In steady state the gain K is constant, and the filter is a fixed second-order linear filter. Its defining property is that it has no lag on a steady sweep, because it estimates the velocity and predicts with it. The price is overshoot: when the gun stops, the velocity estimate takes time to fall, and the cursor runs past the stopping point and comes back. With σ = 3.2 units and q = 10⁷ units²/s³ (chosen to make its jitter ratio match OpenFIRE’s Average, 0.70), the simulation gives zero lag on both sweeps, zero lag at the moment of stopping, and an overshoot of 8.4 units, a quarter of a sensor pixel. Raising q tracks faster and smooths less; lowering it smooths more and overshoots further.

The cost is a few dozen floating-point operations per axis per frame, nothing on an RP2350, and two tuning parameters with physical meaning. It is the right filter if the noise is well characterised and overshoot is acceptable. For a light gun, where the most important moment is a sudden stop on a target followed by a trigger pull, overshoot is the wrong failure.

6.8 Filter the Points or the Cursor?

v6.2 filters the cursor, after the homography. The ESP32 port filters the four points, before it. For small movements the homography is locally linear, so filtering its inputs with a shared cutoff and filtering its output are nearly equivalent, and the cursor costs a quarter of the arithmetic (two values, not eight). Filtering the points has two advantages that the port uses: the filter sees each point’s position in the field, so it can trust points near the lens’s edge less; and a rebuilt corner (Volume 2, 2.7) is smoothed along with the real ones instead of jumping. Its disadvantage is that the filter’s lag shifts the quadrilateral as a whole, and a lagging quadrilateral pushed through the homography is not exactly the same as a lagging cursor when the player is also moving. Both are defensible. For a fork that keeps v6.2’s structure, filtering the cursor is the simpler change.

6.9 What Lag Costs at the Trigger

The game reads the cursor when the trigger is pulled. A cursor that lags the gun by t milliseconds during a swing at ω degrees per second is ω · t degrees behind it, and the shot lands that far short on the screen:

Table 3 — The game reads the cursor when the trigger is pulled. A cursor that lags the gun by t milliseconds during a swing at ω degrees per second is ω · t degrees behind it, and the shot lands that far short on the screen

Swing rate2.4 ms lag (Average)5.3 ms10.6 ms (EMA 15 Hz)31.8 ms (EMA 5 Hz)
10°/s (tracking a slow target)1.0 mm2.3 mm4.6 mm14 mm
30°/s3.1 mm6.9 mm14 mm42 mm
100°/s (a snap shot that does not stop)10.5 mm23 mm46 mm139 mm

At 2.5 m from the screen; aim_maths.py section 21.

Most players stop the gun on the target before pulling the trigger, so the column that matters most is the lag left at the moment of stopping (6.6): zero for the Kalman filter, a fraction of a pixel for the moving averages, and around a sensor pixel and a half for an EMA or One-Euro filter tuned for steadiness. A player who fires on the move, leading a target, is hurt by every millisecond.

6.10 Tuning on the Bench

The simulation’s centroid noise is an assumption; the filter cannot be tuned until it is measured. The procedure:

  1. With the gun clamped on a rest at the playing distance, log the raw cursor position for ten seconds at the full frame rate. (v6.2’s IR test view prints positions only every 50 ms, OpenFIREcommon.cpp line 769, so this needs a debug build of the fork that streams every frame over serial.) Its standard deviation is the jitter the filter must reduce, and its spectrum shows whether it is white.
  2. Hand-hold the gun on a target for ten seconds, and log again. The difference is tremor, which no amount of camera improvement removes.
  3. Sweep the gun across a printed scale at a steady rate on a turntable or a pendulum, logging raw and filtered positions, and measure the lag directly.
  4. Tune f_min, then β, by the authors’ procedure (6.5), against those logs, and check the stop-and-shoot case by eye.

6.11 Why This, and Why Not the Others

Proposed: ship the fork with v6.2’s Average (the default) and the ESP32 port’s index fix; add a One-Euro filter on the cursor as a fourth run mode, tuned on the bench from f_min ≈ 1 Hz and β ≈ 0.007 per internal unit per second, with a fast derivative path for flicks; decide the default after the bench logs of 6.10.

Table 4 — Proposed: ship the fork with v6.2's Average (the default) and the ESP32 port's index fix; add a One-Euro filter on the cursor as a fourth run mode, tuned on the bench from f_min ≈ 1 Hz and β ≈ 0.007 per internal unit per second, with a fast derivative path for flicks; decide the default after the bench logs of 6.10.

OptionWhy not, or why
Average (v6.2 default)Kept as the default for now: 0.71 jitter for 2.4 ms lag and no overshoot; known behaviour
Average2Kept as an option: a little more smoothing (0.61) for 3.6 ms
Longer moving averages or a fixed EMAOne line, one trade: halving jitter costs about 5 ms, quartering it 30 ms (6.4)
One-Euro on the cursorProposed as a new mode: steadiness at rest without lag at speed; the textbook form is a dozen lines; tuning needs the bench data
The ESP32 port’s One-Euro on the pointsVery steady (0.07) but tuned hard for it: 62 ms on slow aiming and 1.6 sensor pixels late on a flick in the simulation, before its snap path; it belongs to the port’s camera code, which this build has not adopted (decision row P-RE18)
Kalman, constant velocityNo lag on a sweep, but overshoot on the stop that precedes most shots (6.7); worth trying only if bench logs show the One-Euro filter’s flick lag matters
No smoothingAcceptable on a camera with low enough noise; unknown until measured

6.12 What Could Not Be Verified

  • All noise and lag figures are from simulation with an assumed noise level; the real centroid noise and hand tremor are unmeasured.
  • The ESP32 port’s snap thresholds, edge attenuation and micro-snap were read but not simulated; its real flick lag should be shorter than the textbook figure here.
  • The v6.2 run-mode bug (6.3) has not been reproduced on hardware.
  • The Kalman tuning is one illustrative choice, not an optimum.

Sources

  1. TeamOpenFIRE, OpenFIRE-Firmware at 8b651a25 (v6.2): OpenFIREmain/OpenFIREcommon.cpp lines 679–701 and 769, OpenFIREcommon.h line 143 — read 2026-10-19.
  2. Mike Lynch, ir-light-gun (Prow Enhanced) at aeab7d1c: SamcoEnhanced/SamcoEnhanced.ino lines 899–916 — read 2026-10-19.
  3. Alessandro Satanassi, OpenFIRE-Firmware-ESP32 at f6458fc9: lightgun/lib/OpenFIREPosition/OpenFIRE_Multi_One_Euro_Filter.cpp and .h; lightgun/src/OpenFIREcommon.cpp lines 1224–1381; lightgun/platformio.ini lines 115–117 — read 2026-10-19.
  4. Géry Casiez, Nicolas Roussel and Daniel Vogel, “1€ Filter: A Simple Speed-based Low-pass Filter for Noisy Input in Interactive Systems”, Proceedings of CHI ‘12, ACM, 2012, pp. 2527–2530; tuning procedure from the authors’ page, https://gery.casiez.net/1euro/ — read 2026-10-19.
  5. Own simulation, 04-templates/aim_maths.py, sections 13 and 21, run 2026-10-19.

Comments (0)

  1. Loading…

Comments are held for moderation — nothing appears until approved.