Aiming Maths and Calibration · Volume 4

Calibration — What Each Trigger Pull Actually Solves For

OpenFIRE's six-shot routine read line by line and reduced to its six unknowns, the exact proof of why it works, the three places v6.2 gives away accuracy (a second-order offset map, a boresight rotated by the calibration roll, and a mean that is not the centre), and the Samco and Prow routines it replaced

Figure 1 — OpenFIRE's calibration as the code runs it: six trigger pulls. The first, at the centre, sets the boresight and the model emitter positions; the next four, at the top, bottom, left and right edges,…
Figure 1 — OpenFIRE's calibration as the code runs it: six trigger pulls. The first, at the centre, sets the boresight and the model emitter positions; the next four, at the top, bottom, left and right edges, each record one reading; the sixth, at the centre again, replaces the boresight. Together they solve for six numbers: the boresight and a stretch and shift on each axis. Own work, from the v6.2 source.

4.1 The Routine as the Code Runs It

Open-Source Firmware, Volume 5 describes calibration as a player meets it in the App and on the gun. Here is what the code records at each pull, from FW_Common::ExecCalMode() in OpenFIREcommon.cpp (v6.2).

At entry the profile’s four offsets are set to zero (lines 334–337), after backing up the old values so that cancelling can restore them (lines 324–331, 394–402). With zero offsets, the map() in GetPosition() is the identity, so during calibration mouseX and mouseY are the raw output of the homography on the 7680 × 4320 model screen. The stage counter starts at 0 and is incremented before the switch (line 414), so the first pull lands on the case named Cali_Top, and each case records what the player was aiming at for the previous prompt. The case names describe where the cursor goes next, not what was just shot.

Table 1 — 4.1 The Routine as the Code Runs It

PullPlayer aims atCase runWhat it records
1Centre of the pictureCali_Top (lines 426–459)Boresight adjX, adjY = the mean of the four points, rotated by the roll angle about the image centre (lines 440–443); TLled, TRled from the quad’s measured width and height (lines 445–446); the destination matrix is rebuilt (deinit(0), line 451)
2Top edgeCali_Bottom (line 460)topOffset = mouseY
3Bottom edgeCali_Left (line 476)bottomOffset = res_y − mouseY
4Left edgeCali_Right (line 492)leftOffset = mouseX
5Right edgeCali_Center (line 508)rightOffset = res_x − mouseX; the four offsets are written to the profile (lines 518–521)
6Centre againCali_Verify (line 530)Boresight recomputed exactly as in pull 1 (lines 538–541) and the matrix rebuilt; the gun enters verification mode

A seventh pull in verification mode accepts; A or B restarts with the boresight reset to the image centre (lines 575–588); C cancels and restores the backup. Each edge pull reads one coordinate: the top and bottom shots record only Y, the left and right only X. The other coordinate of each shot is discarded.

4.2 Why Six Numbers Are Enough

The routine solves for six numbers: the boresight (two) and a stretch and shift on each axis (four). That this is enough is not obvious, and it is worth proving, because the proof shows exactly which physical assumptions the method rests on.

Let H_model be the homography the firmware computes every frame, from the camera’s quadrilateral to the model screen’s emitter rectangle, (TLled, 0)–(TRled, 4320). Let H_real be the homography from the camera to the real picture, in picture coordinates. Both are homographies from the same camera image, and they differ only in where they say the emitters are. If the real emitters form a rectangle whose sides are parallel to the picture’s edges, then the map from the model rectangle to the real rectangle is

X_real = s_x · X_model + t_x
Y_real = s_y · Y_model + t_y

an independent stretch and shift on each axis, because one axis-aligned rectangle is carried onto another by exactly that. So H_real = A · H_model with A axis-aligned, and A has four numbers. Two readings per axis — where the left and right edges of the picture fall in model coordinates, and where the top and bottom do — determine them.

Add the boresight, and the whole relationship from four image points to the barrel’s aim point on the real picture is pinned by six numbers, at any distance, angle and roll, with nothing approximate about it. The simulation confirms it: with the exact map of 4.3 and a camera on the bore, the error is zero at every position tested (aim_maths.py section 3).

The proof’s assumptions are the method’s limits:

  1. The emitters lie in the screen’s plane. If they stand proud of it, the homography describes their plane, not the picture’s.
  2. They form a rectangle, square to the picture. A bar fixed at a slight angle, or one emitter a few millimetres out of place, makes the true map from model to picture a general homography, which an axis-aligned A cannot represent.
  3. The boresight is a fixed image point. True for a camera tilted from the bore; false for one offset from it (Volume 5).
  4. The lens is a pinhole, or has been corrected to one (Volume 1).

Sections 4.8 and 4.9 put numbers on the first two; Volume 5 on the third.

4.3 The Offset Map: v6.2 Gets the Direction Wrong

The readings define where the picture’s edges fall on the model screen: the top edge at Y = T (topOffset), the bottom at Y = 4320 − B, the left at X = L, the right at X = 7680 − R. The map A must therefore carry T to 0 and 4320 − B to 4320:

Y_screen = (Y − T) · 4320 / (4320 − T − B)          (exact)

v6.2 instead applies (GetPosition(), lines 676–677)

mouseY = map(Y, 0, 4320, −T, 4320 + B)  =  −T + Y · (4320 + T + B) / 4320

which stretches the range outward from the model screen’s own edges. At the top edge, where Y = T, it returns T · (T + B) / 4320, not 0. The two maps agree to first order in the offsets and differ at second order, and the error at each edge is

edge error = L · (L + R) / res        (in model units, per axis)

The ESP32 port found this and replaced the map. Its comment, at OpenFIREcommon.cpp lines 1338–1346 of the port, is exact: “aiming at an edge it returned offset * (offset1 + offset2) / res instead of the edge itself, an error of a few pixels on every calibrated edge (about 3-6 px with typical Square setups, up to 9-19 px).” Its MapCalibratedAxis() (lines 1168–1189) computes the exact inverse in 32-bit integers, with limits that treat an impossible calibration as none.

Figure 2 — The horizontal aim error across the picture that v6.2's offset map leaves, for four distances between the emitter bars and the picture's edges. The error is zero at the centre and largest at the ed…
Figure 2 — The horizontal aim error across the picture that v6.2's offset map leaves, for four distances between the emitter bars and the picture's edges. The error is zero at the centre and largest at the edges the calibration was taken on, always pulling the cursor inward. The exact map leaves no error. Own work, from the dive's simulation.

How large it is depends on how far the emitters sit outside the picture, because that sets the offsets. On a 55-inch picture with the square layout:

Table 2 — How large it is depends on how far the emitters sit outside the picture, because that sets the offsets. On a 55-inch picture with the square layout

Bars outside the pictureReadings L = R (of 7680)Edge error, horizontalEdge error, vertical
0 mm000
15 mm1601.1 mm (1.7 px)0.6 mm
30 mm3093.9 mm (6.2 px)2.2 mm
50 mm4899.9 mm (15.5 px)5.6 mm
80 mm72721.8 mm (34.4 px)12.3 mm

aim_maths.py section 2b; px are 1080p pixels on the 55-inch picture. The port’s “3–6 px typical, 9–19 px” agrees with the 30–80 mm rows.

Three properties make this error distinctive. It is the same at every distance and angle, because it lives in the output map, not the geometry; moving does not change it, and re-calibrating does not remove it. It always pulls the cursor toward the centre, so a player who aims exactly at an edge sees the cursor short of it — which matters for off-screen reloading, since the cursor must be clamped at the edge before the gun counts as off-screen (Volume 7). And it explains a piece of folk wisdom: OpenFIRE’s own guidance to put the emitters as close to the picture’s edges as possible (Open-Source Firmware, Volume 3) is, for the vertical axis, the advice that keeps this error small. With the exact map it is no longer needed for accuracy (it still helps the camera’s field of view, IR Point Tracking, Volume 4).

4.4 What TLled and TRled Are For

On the first pull, v6.2 sets the model screen’s emitter columns from the quadrilateral’s measured width W and height H, “assuming height is 100%” (line 444):

TLled = 3840 − (W · 4320 / H) / 2        TRled = 3840 + (W · 4320 / H) / 2

that is, it scales the model rectangle to the same aspect ratio as the camera sees the emitters. The proof in 4.2 says any rectangle would do, since the offsets absorb the difference. The simulation agrees — and shows what the estimate is really for:

Table 3 — that is, it scales the model rectangle to the same aspect ratio as the camera sees the emitters. The proof in 4.2 says any rectangle would do, since the offsets absorb the difference. The simulation agrees — and shows what the estimate is really for

Model columnsOffsets L, RWorst error, exact mapWorst error, v6.2 map
From W/H at calibration (1721, 5959)309, 30904.1 mm
v6.2’s uncalibrated defaults (2000, 5680)773, 773022.3 mm
Deliberately wrong (1000, 6680)−893, −893029.7 mm

aim_maths.py section 15, 55-inch, bars 30 mm out, Wii-class camera, results identical at 2.5 m head-on and at 1.8 m from 30°.

With the exact map, TLled and TRled do nothing. With v6.2’s map they keep the horizontal offsets small, and so keep the second-order error small. The aspect estimate is a workaround for the map, whether or not it was written as one. A fork that adopts the exact map can keep it (it costs nothing) or drop it; either way the result is the same.

4.5 The Boresight Shot: a Mean Is Not a Centre

The boresight should be the image position of the point the barrel aims at during the centre shot, the picture’s centre. v6.2 uses the mean of the four emitter images (testMedianX(), testMedianY()). Seen head-on, the two coincide for a symmetric layout. Seen from an angle, they do not: perspective compresses the far side of the emitter rectangle, and the mean of four projected corners is not the projection of the rectangle’s centre.

The simulation calibrates from several angles and then tests both where the player calibrated and after he moves to stand head-on:

Table 4 — The simulation calibrates from several angles and then tests both where the player calibrated and after he moves to stand head-on

Calibrated fromMean-to-centre gap in the imageWorst error where calibratedWorst error after moving head-on
Head-on000
20° to the side23 arcmin1.1 mm4.1 mm
30°31 arcmin1.6 mm9.8 mm
40°36 arcmin2.0 mm18.5 mm

aim_maths.py section 4; 55-inch, 2.5 m, Wii-class.

The error is small where the player calibrated, because the four edge shots partly absorb it there, and grows when he moves. A player who calibrates from the sofa at an angle and later plays standing in front of the screen carries a boresight error into every shot.

There is an exact fix, and it is one line. The centre of a rectangle is where its diagonals cross, and a homography carries straight lines to straight lines, so the image of the emitter rectangle’s centre is the intersection of the image quadrilateral’s diagonals, from any viewpoint. If the emitter rectangle is centred on the picture — the square layout’s bars are, when mounted symmetrically — that point is the image of the picture’s centre. Replacing the mean with the diagonal intersection, the simulation’s errors in the table above all fall to zero (section 19). The cost is one 2 × 2 solve on one frame per calibration.

4.6 The Boresight Shot: Rotated by the Roll

Lines 440–443 do not store the mean as it is. They rotate it about the image centre (512 × 4, 384 × 4) by OpenFIREsquare.Ang(), the mean angle of the quadrilateral’s top and bottom edges at that moment:

adjX = (mX − 2048)·cos(ang) − (mY − 1536)·sin(ang) + 2048
adjY = (mX − 2048)·sin(ang) + (mY − 1536)·cos(ang) + 1536

This is the roll correction of Samco 2IR (Volume 2, 2.3) carried into a place where it no longer belongs. In the similarity method, the measured point was rotated back every frame to undo the gun’s cant. In the homography method, the four points are never rotated; the homography itself handles roll exactly. The boresight is a fixed point in the camera’s image — where the aim point appears — and it does not move when the gun cants. Rotating it by the cant at the moment of calibration moves it away from where it belongs, by an amount proportional to that cant and to the boresight’s distance from the image centre.

If the gun is held level at calibration, ang ≈ 0 and nothing happens. If the camera sits on the bore, the boresight is at the centre and the rotation moves nothing. The error appears when both conditions fail — a camera tilted a little from the bore, and a gun canted a little during calibration. With the camera pitched 2° from the bore:

Table 5 — If the gun is held level at calibration, ang ≈ 0 and nothing happens. If the camera sits on the bore, the boresight is at the centre and the rotation moves nothing. The error appears when both conditions fail — a camera tilted a little from the bore, and a gun canted a little during calibration. With the camera pitched 2° from the bore

Cant held during calibrationWorst error playing level, v6.2Without the rotation
0°00
5°1.0 mm0.2 mm
10°3.2 mm0.4 mm
20°11.6 mm0.7 mm

aim_maths.py section 6; 55-inch, 2.5 m, Wii-class. The residual without the rotation is the mean-versus-centre effect of 4.5, which the diagonal fix also removes.

The proposal for the fork is to delete the rotation, storing the diagonal intersection as it is. The calibration-shots figure records the rotation as v6.2 has it.

4.7 One Frame per Shot

Each pull records the reading from one camera frame: whatever GetPosition() last produced. That frame carries the centroid noise of all four points. For the Wii-class camera at 2.5 m, with an assumed 0.1-physical-pixel noise per point (a figure IR Point Tracking, Volume 6 sets out to measure), the boresight from one frame has a standard deviation of 0.56 mm on the screen. Averaged over 70 frames, a third of a second at 209 Hz, it falls to 0.07 mm (aim_maths.py section 20).

Prow Enhanced did exactly that average for its centre shot (CalCenter(), SamcoEnhanced.ino lines 993–1034: “accumulate center position over a bit of time for some averaging”, up to 333 ms while the trigger is held). OpenFIRE dropped it. The camera noise is not the main reason to restore it: a hand-held gun also wanders, and the trigger pull itself jerks it. An average over a held trigger, taken from a frame or two after the pull begins, averages out tremor and avoids recording the moment of the jerk. The same applies to every edge shot.

4.8 What Calibration Cannot Absorb

The proof in 4.2 assumed a rectangle of emitters, square to the picture, in its plane. Break those assumptions and the simulation shows what is left (calibrated at 2.5 m head-on with the exact map and the diagonal boresight, then tested):

Table 6 — The proof in 4.2 assumed a rectangle of emitters, square to the picture, in its plane. Break those assumptions and the simulation shows what is left (calibrated at 2.5 m head-on with the exact map and the diagonal boresight, then tested)

Emitter faultWhere calibrated1.5 m3.5 m2.5 m, 30° aside2.0 m, 40° the other side
None00000
Top-right emitter 10 mm too far right5.2 mm6.16.15.46.3
Top-right emitter 10 mm too high7.1 mm7.88.07.37.0
Top bar rotated 1°9.0 mm9.09.09.09.0
Top bar 15 mm proud of the screen1.4 mm3.61.69.814.8
All four emitters 40 mm proud06.82.924.939.9

aim_maths.py section 16; 55-inch, Wii-class.

Two kinds of error appear. A misplaced or tilted emitter produces an error that is about the same everywhere — a fixed distortion of the mapping — of the order of the misplacement itself or larger: a 1° tilt of a 730 mm bar moves each end by about 6 mm, and the aim error is 9 mm. An emitter out of the screen plane produces an error that is zero where the player calibrated and grows with the angle from which he sees the screen, as the parallax between the two planes opens up: 40 mm of standoff costs 40 mm from 40° to the side. The practical rules follow: mount the bars level and symmetric to a millimetre or two, using the App’s alignment assistant (Open-Source Firmware, Volume 5), and as close to the screen’s surface as the bezel allows.

4.9 A Four-Corner Calibration

The axis-aligned map A has four numbers because v6.2 reads one coordinate per edge shot. If instead the player shoots the four corners of the picture (or four points inset from them) and both coordinates of each are kept, there are eight readings: enough to fit a full homography from the model screen to the picture in place of A. That map absorbs a misplaced or tilted emitter in the screen plane completely, at the calibration position, because the true model-to-picture map is then a homography.

Table 7 — 4.9 A Four-Corner Calibration

Emitter fault, four-corner calibrationWhere calibrated1.5 m3.5 m30° aside40° the other side
Top-right emitter 10 mm too far right0.1 mm1.51.41.11.3
Top-right emitter 10 mm too high0.1 mm1.51.41.11.3
Top bar 15 mm proud02.31.08.413.5
All four 40 mm proud06.82.924.939.9

aim_maths.py section 22; corner targets inset 5 % from the picture’s corners.

A misplaced emitter drops from 5–8 mm to about 1.5 mm. What remains is the boresight: with an asymmetric emitter quadrilateral, the diagonals no longer cross at the image of the picture’s centre, the boresight is slightly wrong, and a boresight error is an angle, which grows with distance away from where it was calibrated. Out-of-plane emitters are not helped at all; that error is parallax, and no single-position calibration can remove it. The cost is four corner shots in place of the four edge shots, with a computation that already exists in the firmware (computeSquareToQuad), and a change to the App’s calibration screen. It is a proposal for Our Firmware, not a necessity: a correctly mounted bar does not need it.

4.10 Samco, Prow and GUN4IR

Table 8 — 4.10 Samco, Prow and GUN4IR

FirmwareShotsUnknownsWhat it solves for
Samco 2IR2: near top-left, near bottom-right (Samco_2.0_2IR.ino lines 142–168)4Offset and gain per axis of a linear map from the de-rolled bar midpoint (Volume 2, 2.2)
Prow Enhanced / Samco 4IRCentre held 1/3 s; then vertical and horizontal range trimmed by eye with A/B (SamcoEnhanced.ino lines 36–55, 993–1100)4Centre (2) and the two scale factors that relate the quadrilateral’s height to the screen range (Volume 2, 2.5)
OpenFIRE v6.26 (4.1)6Boresight (2), axis-aligned stretch and shift (4)
GUN4IR”five-step calibration of camera offset and LED placement” (user guide v1.2, as summarised in Open-Source Firmware, Volume 1)not knownClosed source

Only OpenFIRE’s routine solves for a model that is exact under its assumptions. Samco 2IR’s and Prow’s solve exactly for models that are not (Volume 2).

4.11 Why This, and Why Not the Others

Proposed, for Our Firmware: keep OpenFIRE’s six-shot routine and its App screens, with four changes, in order of value:

  1. The exact offset map (the ESP32 port’s MapCalibratedAxis()): removes 4 mm at 30 mm bar gaps and 22 mm at 80 mm, pose-independent (4.3).
  2. The boresight as the diagonal intersection, not rotated: removes 4–19 mm for a player who calibrated at an angle and then moved, and up to 12 mm for a canted calibration with a tilted camera (4.5, 4.6).
  3. Average each shot over the held trigger, skipping the first frames after the pull: removes calibration noise and the trigger jerk (4.7).
  4. Optionally, a four-corner mode that fits a full homography: absorbs emitter misplacement (4.9).

Table 9 — 4.11 Why This, and Why Not the Others

OptionWhy not, or why
Keep v6.2’s routine unchangedLeaves the three errors above; each is a few lines to fix and the ESP32 port has already fixed one
Adopt the ESP32 port’s calibration wholesaleIt fixes the map but keeps the mean and the rotation (port lines 707–716 and 805–814 match v6.2’s); its other changes come with its own camera code (decision row P-RE18 is still open)
Four-corner calibration as the defaultIts benefit is for badly mounted bars; a well-mounted bar does not need it, and corners near the bezel are harder to hit than edge midpoints
Nine-point least-squares calibrationWould also average noise and absorb a little lens error, but nine shots is a chore at every profile change, and the averaging of item 3 does most of the same work
Prow-style trim by eyeOnly made sense for a model with free scale factors; the homography has none to trim

4.12 What Could Not Be Verified

  • Every error figure is from the simulation with an ideal camera; none is a bench measurement.
  • Whether the rotation of the boresight was meant to compensate for something the simulation does not model. No comment in the source explains it, and it predates OpenFIRE: the same expression rotates the output position every frame in Prow (SamcoPositionEnhanced.cpp lines 233–237), where it belonged.
  • GUN4IR’s calibration mathematics.

Sources

  1. TeamOpenFIRE, OpenFIRE-Firmware at 8b651a25 (v6.2): OpenFIREmain/OpenFIREcommon.cpp (ExecCalMode, GetPosition), OpenFIREmain/OpenFIREconstant.h (calibration stage enum, lines 73–79), libraries/OpenFIREPosition/OpenFIRE_Square.cpp — read 2026-10-19.
  2. Alessandro Satanassi, OpenFIRE-Firmware-ESP32 at f6458fc9: lightgun/src/OpenFIREcommon.cpp (MapCalibratedAxis and its comment) — read 2026-10-19.
  3. Mike Lynch, ir-light-gun (Prow Enhanced) at aeab7d1c: SamcoEnhanced/SamcoEnhanced.ino, libraries/SamcoPositionEnhanced/SamcoPositionEnhanced.cpp — read 2026-10-19.
  4. Sam Ballantyne, IR-Light-Gun (Samco) at 929490f3: Samco_2IR/Samco_2.0_2IR/Samco_2.0_2IR.ino — read 2026-10-19.
  5. Own simulation, 04-templates/aim_maths.py, sections 2b, 3, 4, 6, 15, 16, 19, 20 and 22, run 2026-10-19.

Comments (0)

  1. Loading…

Comments are held for moderation — nothing appears until approved.