Documentation
¶
Overview ¶
Package headflow recovers how far a head has turned from the pictures its headset's camera takes, in pure Go with CGO_ENABLED=0.
It exists because some headsets will not say. A VITURE Beast holds its own 3DOF tracking and uses it to anchor the picture it is given, but it publishes no orientation at all: measured three ways on 2026-09-07 -- listening for unsolicited frames, asking for the documented orientation stream, and sweeping every readable message with the head still and then moving -- and none of them produced a number. Its camera, however, is an ordinary UVC device, and a turn of the head is plainly visible in it.
What it does, and why it is this crude ¶
A yaw moves the whole picture sideways together. So each column of a frame is summed into one number, and that one-dimensional profile is slid against the previous frame's until it fits. There is no feature detection, no calibration against a model, and no state beyond the last profile. On a 1920x1080 frame it costs under a millisecond.
What it cannot do ¶
- It measures YAW ONLY. Pitch and roll move the picture too, and this reads the horizontal component of whatever happened.
- It needs something to look at. A blank wall or a dark room offers nothing to match, and Shift says so through its confidence rather than inventing a number -- see Tracker.Confidence.
- It is not a pose. There is no position, no gravity, and no absolute heading: only how far the view has turned since it was last recentred.
What it is good enough for, measured ¶
Held on a head and swept back and forth, the residual after returning to the starting point was 1.14% of the distance travelled -- about 2.6 degrees per there-and-back. Stationary, over five minutes and 7474 frames, it accumulated exactly nothing: it does not invent movement. The camera pipeline delivers a frame between 46 and 107 milliseconds after it was taken.
Those three together decide what this is for. Choosing WHICH SCREEN somebody is facing -- a discrete question, where the screens are tens of degrees apart -- is comfortably within them. Holding a picture locked to the head at 1:1 is not: that wants latency under 20 milliseconds and drift near zero.
⛔ AND HELD AT ARM'S LENGTH IT IS NINE TIMES WORSE, which is worth knowing before testing it that way. Sweeping the camera on the end of an arm moves it half a metre through the room as well as turning it, and near things then slide further than far ones. The single best-fit shift is a compromise between them, and it is biased: the same experiment gave 10.53% instead of 1.14%. On a head the camera turns nearly about its own centre and there is little parallax to compromise with.
Index ¶
Constants ¶
const ( // BinWidth is how many columns are grouped before matching. // // ⭐ IT WIDENS THE REACH AND CUTS THE COST AT ONCE. The search steps in // BINS, so each step covers BinWidth columns: the same number of steps // reaches four times further while costing a quarter as much. A yaw moves // the whole picture together, so the detail inside a bin carries none of // the motion. BinWidth = 4 // MaxShift bounds the search, in bins. // // ⛔ A BOUND THAT IS REACHED IS A BOUND THAT WAS TOO SMALL. An earlier // version searched 48 COLUMNS and reported a peak of exactly 48 while a // head was turning: the search hitting its own wall, not a measurement. // 64 bins is 256 columns, and a head sweeping fast was measured at 24. MaxShift = 64 // RowStep is how many rows are skipped between the ones summed. // // ⛔ IT IS A MARGIN, NOT A TASTE. Sensor grain is independent between // frames, so what pulls the scene out of it is averaging rows -- and noise // falls only as the square root of how many are kept. Swept against a test // image whose grain is redrawn per frame, matching holds at 1, 4 and 8 and // FAILS at 16. Four sits two doublings below the failure. RowStep = 4 )
Defaults measured on a VITURE Beast's camera, 2026-09-07.
const ColumnsPerDegree = 26.5
ColumnsPerDegree is how far the picture slides for one degree of turn, on a VITURE Beast's camera.
⭐ MEASURED, NOT LOOKED UP, AND IT CALIBRATES ITSELF. A full turn ends where it began -- the person checks that by eye against a landmark -- so the travel accumulated over one IS 360 degrees expressed in columns, with no protractor and no vendor number to trust. Three turns, two of them at half the speed of the other, gave 9628, 9332 and 9640 columns: agreement within 3%.
The horizontal field of view it implies, 1920/26.5 ≈ 72 degrees, is the free control on the whole method. It was not supplied, it came out -- and a broken calculation would have produced something absurd instead.
⛔ A FOURTH TURN READ 10848 AND IS EXCLUDED. It had the tidiest frame-to-frame agreement of the four, and reasoning from it gave a calibration 13% wrong and a repeatability five times too pessimistic. Three concordant measurements decide; the tidiest one does not. (The person confirmed they may have gone past their landmark on it, which would account for exactly that excess.)
const MinConfidence = 0.3
MinConfidence is where a match stops being worth believing.
⛔ IT IS A REFUSAL, NOT A FILTER. Below this there is nothing wrong with the arithmetic -- the picture simply has nothing in it to match, which is what a dark room or a blank wall looks like. A tracker that quietly returned its last value, or zero, would be indistinguishable from one that was working. Saying so is what lets an application tell somebody "it is too dark to follow your head" instead of drifting silently.
Variables ¶
This section is empty.
Functions ¶
func Profile ¶
Profile reduces a frame to one number per bin: the brightness of that slice of the picture, summed down a band through its middle.
The middle band only, because the ceiling and the floor of a room are the parts most likely to be blank, and a blank contributes nothing but noise.
⛔ THE CHANNELS ARE SUMMED UNWEIGHTED, not turned into a luminance. The same transform is applied to both frames and only the SHIFT between them survives it, so the weights would be three multiplies per pixel bought for nothing.
func Shift ¶
Shift reports how far b has slid relative to a, in BINS, and how much to believe it.
The confidence runs from 0 to 1: how much better the winning offset fits than the profile's own natural variation. Below about 0.3 there is nothing to believe -- see Tracker for what to do about that.
⛔⛔ IT SEARCHES OUTWARD FROM ZERO, AND THAT IS NOT A DETAIL. Sweeping from -MaxShift upward keeps whichever offset was tried FIRST when several score alike -- and on a picture with no structure, every offset scores alike. A dark room was therefore once read as a slide of -64: a violent turn, from a camera that could see nothing, which is the most dangerous answer this can give. Trying 0, +1, -1, +2, -2 ... makes a tie fall to the smallest movement, so no information reads as no motion.
⛔ AND THE BOUND IS NOT A SATURATION SIGNAL. A slide of 84 bins against a 64-bin search does not come back as 64 -- it was measured coming back as 58, an interior value indistinguishable from a real reading. What separates them is the residual, which is what the confidence is made of.
Types ¶
type Tracker ¶
type Tracker struct {
// contains filtered or unexported fields
}
A Tracker turns a stream of frames into how far the view has turned.
It is not safe for concurrent use: feed it from one goroutine.
func (*Tracker) Confidence ¶
Confidence is how well the last frame matched, from 0 to 1.
func (*Tracker) Feed ¶
Feed offers a frame and reports the yaw after it, in radians, and whether the frame could be used at all.
⛔ A REFUSED FRAME DOES NOT MOVE THE YAW, and it does not reset it either. The view is wherever it was; this simply could not see it move. An application should keep showing the last position and say that tracking is lost, rather than treat "no information" as "no motion" -- they look the same in a single number and mean opposite things.
func (*Tracker) Recenter ¶
func (t *Tracker) Recenter()
Recenter makes the current view the origin.
⭐ IT IS THE ANSWER TO DRIFT, AND IT IS CHEAP. Error accumulates only while the view is moving -- stationary, this was measured accumulating exactly nothing over five minutes -- so an application that recentres whenever it knows the true heading, such as when a gaze settles on a screen whose position it knows, never lets the error run further than a single journey.
func (*Tracker) SetYaw ¶
SetYaw moves the origin so that the view reads yaw right now. It is Recenter with somewhere other than zero to land on.
func (*Tracker) Unusable ¶
Unusable is how many frames in a row were refused. One or two is a blur; a steady count is a room with nothing to see in it.
func (*Tracker) Yaw ¶
Yaw is how far the view has turned since the last Tracker.Recenter, in radians, positive to the right.