Hands (experimental, deferred)
Hand tracking from the headset's own cameras. It's deferred: it costs a lot of the headset's CPU and needs more work, so install.sh doesn't offer it and the README doesn't list it. It still builds and runs, installed by hand (below), for working on it.
It serves two things in Frametop:
- Hand cutouts: where your hand is between an eye and a screen, that eye sees the room through the screen (ft-screens,
screens/handcut.cpp), so your hands show over the screens the way they do on a Vision Pro. The hole is hand-shaped, built from the skeleton (see "Hand cutouts and near input" below). - Rendered hands: with Room View off (a VR game, SteamVR Home, the dashboard, or the panels over the void), ft-screens doesn't cut, and ft-handview draws your hands instead, Quest-style, over everything (
screens/handview.cpp; see "Rendered hands" below). - Near input on Frametop's panels: poke a screen with your index finger to click, pinch its bar, edges or corners to move or resize it, hold it with both hands to stretch it (ft-screens,
screens/handinput.cpp; see "Hand cutouts and near input"). - Pinches and grips: with
POINTER_HANDS=1, the pointer helper takes them as clicks and drags. Look at something and pinch to click it, with the eye tracker doing the looking (gaze/), or close your hand to press and drag what the pointer is on. See "Pinches and grips in the pointer" below.
Two programs, each a user service that stops when SteamVR does:
ft-camd(camd/, C) borrows XRService's camera buffers and publishes the four IR tracking cameras' frames to a shared-memory ring. It runs on the host.ft-hands(track/, C++) finds hands in those frames with MediaPipe's palm and landmark models on ncnn, triangulates them, and publishes them. It runs in the dev container.
hands/run.sh install builds them, gives ft-camd its capabilities, installs both services, and links hands/ft-handsctl into ~/.local/bin. install.sh doesn't install it. When they run is HANDS_MODE in ~/.config/frametop.conf, set on the Hands page of Frametop Input Settings or with ft-handsctl mode; hands/handsmode.py does the starting and stopping, for these two and the hand view (frametop-handview.service, when it's installed):
off(the default): nothing starts them.ft-handsctl onstarts hand tracking andft-handsctl offstops it, as before; it stops with SteamVR.frametop: they run while the Frametop desktop does. The session (session/frametop-session.sh, screens backend) starts them once ft-screens is up and stops them as it ends.always: they're wanted bysteamvr.service(systemctl --user add-wants), so they run whenever SteamVR does, in games and the SteamVR dashboard too, with or without the desktop. Pausing Frametop for a VR game leaves them running and unparked.
Choosing a mode applies it at once: the services start or stop, with no desktop restart. Without ft-camd's capabilities nothing starts, and the Hands page says to run hands/run.sh caps (it asks for the password in the terminal; the page never does). hands/run.sh install and caps apply the mode too, but stop nothing, so what ft-handsctl on or ft-cutouts on started keeps running.
For the cutouts alone, hands/ft-cutouts on starts the same two programs with ft-hands' --no-gestures: your hands show through the screens, and no pinch or grip is detected, so nothing clicks or drags. It runs this checkout's build as transient user units, so it needs hands/build.sh and ft-camd's capabilities (hands/run.sh caps) but not hands/run.sh install. It and ft-handsctl on stop each other's services, and it stops with SteamVR too.
ft-handsctl on | off # on the Frame: start or stop hand tracking (SteamVR must be running)
ft-handsctl mode [frametop|always|off] # HANDS_MODE: show it, or set and apply it
ft-handsctl status # the services, and ft-hands' last status lines
ft-handsctl log [lines]
ft-handsctl cutouts on|off|state # ft-screens' hand cutouts, without stopping tracking
ft-handsctl gestures # pinches, grips and middle pinches, live (tools/watch_gestures.py --distance)
hands/ft-cutouts on | off | status # the cutouts only: no pinches or grips
hands/run.sh install # build, give ft-camd its capabilities (sudo, once per build), install, apply HANDS_MODE
hands/run.sh start|stop # start or stop the services
hands/run.sh restart # after changing a setting
hands/run.sh status
hands/run.sh log [lines]
hands/run.sh caps # after rebuilding ft-camd (a rebuild clears its capabilities)
hands/run.sh uncaps # take them back, unless the Hand Recorder or the services use them
hands/run.sh uninstall # the services, then uncaps
Settings in ~/.config/frametop.conf (FT_<name> in the environment overrides them), read when ft-camd and ft-hands start:
HANDS_SWAP_SIDES=auto(the default): ft-hands tells from the hands which side camera is which, and corrects ft-camd's names when they're backwards (see "Which camera is which" below).1forces them exchanged and0forces ft-camd's names; ft-hands still checks, and if the hands disagree it logs a warning and publishes the hands' answer as the truth (sides.json), so recordings are labelled right. The example config said0until 2026-10-05;scripts/conf-migrate.sh(run byinstall.shandhands/rec/install.sh) turns that untouched line intoauto.HANDS_CPUS=5,6,7: the CPUs the model threads run on (below).HANDS_CAMERAS(auto),HANDS_BRIGHT(all),HANDS_BRIGHT_ON(40),HANDS_BRIGHT_OFF(25): which cameras ft-hands tracks with, as--cams,--bright,--bright-onand--bright-off(see ft-hands).HANDS_CAMERAS=monoalso keeps ft-camd off the colour cameras.HANDS_MODELS(unset:hands/models/ncnn, the stock MediaPipe models): a folder holdingpalm.ncnn.*andhand.ncnn.*, as--models. Use it to run fine-tuned models, such as the ones trained on the hand dataset, without passing options to every launcher. Those folders usually lack the*-int8files, so--int8won't load them.HANDS_COLOR_LEFT(color_video0),HANDS_COLOR_CROP(subtract): how the colour module's calibration maps onto its images, as--color-leftand--color-crop.HANDS_SKELETON=1: fit a skeleton with your bone lengths and publish it (see "Skeleton" below);0publishes the filtered landmarks as before. As--skeleton.
The pointer helper's POINTER_HANDS and POINTER_PINCH_*/POINTER_GRIP_* settings are in "Pinches and grips in the pointer" below.
Files, all in /run/user/UID/frametop-hands/ (private to the user; not /run/user/UID/frametop/, which the desktop session deletes whenever it starts):
| File | Written by | Layout | Read by |
|---|---|---|---|
cam-ring |
ft-camd | camd/fhring.h |
ft-hands, tools/ring.py |
hands |
ft-hands | include/fh_hands.h (version 2: version 1, then a skeleton per hand) |
ft-screens (screens/handcut.cpp, version 1's part), the hand recorder (rec/session.py) |
gestures |
ft-hands | include/fh_gestures.h |
the pointer helper (pointer/helper/ft-pointer.cpp), tools/watch_gestures.py |
The source keeps the fh_ names and magic strings of frame-hands, the project it started as, so recordings made with it still work.
ft-camd
XRService owns the headset cameras. ft-camd borrows its DMA-BUFs read-only with pidfd_getfd, the same way FrameEyeCameraFeed does. It never touches XRService's V4L2 descriptors. discovery in camd/xrcams.c is adapted from FrameEyeCameraFeed (MIT, see camd/LICENSE.FrameEyeCameraFeed).
Polling buffers for changes can catch a frame while the camera is still writing it. Instead, ft-camd listens to the v4l2:v4l2_dqbuf tracepoint, which fires when XRService takes a buffer. It gives the buffer index, the sequence number and the capture timestamp. ft-camd learns which DMA-BUF holds each V4L2 index by watching which buffer changes at each dequeue:
- Right after XRService allocates its buffers, the mapping is allocation order.
- After XRService restarts streaming, the order is shuffled, and the mapping is learned index by index.
- The two upper cameras share one run of buffers. For them, only allocation order can tell the cameras apart.
- It also re-maps an index on the fly when its buffer holds no new frame.
Privileges. Setting up needs three things. pidfd_getfd on XRService needs CAP_SYS_PTRACE, because the Frame has ptrace_scope=1. The system-wide tracepoint needs CAP_PERFMON, because perf_event_paranoid is 2. Its format files are root-only, which needs CAP_DAC_READ_SEARCH. hands/run.sh install gives the binary those capabilities with sudo setcap. File capabilities need a filesystem mounted without nosuid. The Frame's /home (ext4) has no nosuid. ft-camd drops them all once it has set up, before it reads a frame, and then runs as you. XRService runs as you too. It also runs under sudo, for trying it by hand, and then drops to the user who ran sudo. It reads nothing from the ring's readers.
The ring is mode 0600, in a folder only you can write. Frame handling:
- Only complete, bright frames are published. The cameras alternate a normal exposure with a near-black one, so each camera gets 30 of its 60 fps.
- A copy torn by the camera overwriting the buffer is dropped.
- Each copy takes about 0.1 ms, and a cache sync about 0.15 ms.
Options:
--dark R: a frame dimmer than R times the camera's recent brightest counts as near-black. Default 0.4.--with-dark: also publish the near-black frames, as extra ring cameras flaggedFH_CAM_DARK. They show only light sources, so they're no use for hands.--with-color: also publish the two Arcturus colour cameras, flaggedFH_CAM_COLOR. The service leaves it off and runs the mono cameras only (see "Known issues"). To try the colour cameras, add it toExecStartinhands/frametop-camd.serviceand runhands/run.sh installagain; ft-hands then picks the cameras by the light. Each is the luma of the 10-bit frame's valid 1972x2464 (the top 8 bits), at half size (--color-scale 2: 986x1232). They run at--color-idle(2 fps), enough for ft-hands to tell how bright it is, until a reader asks for more in/run/user/UID/frametop-hands/color-fps(ft-hands writes 30 while it tracks or records with them), up to--color-fps(30; the cameras run at 60).HANDS_CAMERAS=monoleaves them out. Frames that carry the module's warped half-size copy are dropped. Theircapture_nsis on the colour module's clock (2.2 s off the mono cameras' on 2026-09-29), so line them up with the mono cameras bydqbuf_ns. Each frame costs about 0.65 ms of cache sync and 1.1 ms of decoding, so both cameras at 30 fps take about 11% of a core.- Each mono camera's latest near-black frame's mean goes in the ring (
dark_mean): a short fixed exposure, so it follows the room's IR light, sunlight above all. - The ring holds 8 cameras: 4 mono, plus 4 dark twins or 2 colour cameras.
- Colour isn't reliable yet. In the lit-room test of 2026-09-30, the colour cameras kept losing their buffer mapping while the headset was worn: 30 frames in a row looked unchanged, the camera relearned, and after 5 relearns ft-camd exited. Each relearn probed all 32 colour buffers, a whole-buffer cache sync each, which also made the mono cameras miss frames. Runs with the headset idle had none of this. So the passthrough compositor may be writing into the colour buffers while Room View shows. Since then a colour camera never takes the mono ones down: it probes at most 4 buffers a frame, and one that goes stale twice in a row is paused (10 s, doubling up to 160 s) and learned again, without ft-camd exiting. Whether a frame is new is judged on the luma rows only: the chroma after them hardly changes in a lit room.
FT_CAMD_DEBUG=1in the environment: at each stale colour frame, ft-camd logs to stderr the camera, the frame's time, V4L2 index and sequence number, and, for every candidate buffer, how many of its sampled words changed, in all and in the last eighth of the samples.--sensor S: only the mono cameras whose sensor name contains S.--status S: a status line every S seconds (0: never).
It exits when XRService exits, or when a camera's buffers keep going stale, which means XRService has reallocated them. The service starts it again, and it attaches to the new buffers.
Which camera is which: video9 is slam_right (sensor og01a1b 4-0036), video13 is slam_left (4-0060), video6 is upper_left and video7 is upper_right; without the colour module the side pair is on video0 (slam_right) and video3. XRService says so in its log: Found camera 'slam_left': ... v4l_subdev=/dev/v4l-subdev30 names each sensor, and each TrackingCameraInit line gives the device and subdev it opened. ft-hands and camcheck.py name the devices that way. Until 2026-10-05 they named them by the TrackingCameraInit index instead, which is only the order XRService opens them in (index 0 was slam_right on every start logged), and ft-camd bound each buffer queue to a device by the order of XRService's file descriptors, which changes when XRService restarts its cameras. The two mistakes made the side names come out swapped on most starts and right on some. ft-camd now asks each device which buffers it holds (VIDIOC_QUERYBUF names the descriptor XRService queued at each index) and only falls back to the order if that fails. With the names swapped, every hand is seen by one camera only, at the wrong depth, and the hand holes land beside the hands. ft-hands still checks by itself, in case (track/sides.h, HANDS_SWAP_SIDES=auto):
- Whenever a hand's landmarks are found in two cameras at once (one of them a side camera), it intersects the rays through the 21 landmarks twice: once with the calibrations as named, once with the two side cameras exchanged. The same hand seen the right way meets within a few mm, in front of both cameras and as far away as its size says. The wrong way misses by centimetres or meets behind a camera.
- With the names wrong, the tracker never gets such pairs on its own: it hands the hand over to where the wrong calibration puts it and finds nothing there. So 5 times a second while undecided, the check places a tracked hand in 3D under the other naming and runs the landmark model where that puts it in the other side camera.
- It decides after 10 votes one way and none the other, or 20 with at most a fifth the other way, over at least 1 s. That takes about 1-2 s of hands in view. If the names are backwards, it exchanges them; the tracked views move with their images. Then it checks once more, more strictly.
- The log says what it found (
side cameras: SWAPPED, now exchanged after 3.2 s (votes ...)). So does/run/user/UID/frametop-hands/sides.json, which the hand recorder reads. Recordings get aDIR/sides.json(hands/rec/sides.py has the rules). --record-onlycan't tell (it tracks nothing): it records ft-camd's names unless--sides 0|1says otherwise.
tools/check_sides.py --ring is the independent check from the scene (ORB matches meeting under each naming): exit 0 as named, 3 swapped, 2 can't tell. --pair upper checks the upper pair the same way: in every recording so far (3 XRService starts, both side namings) the upper pair was named right. The colour cameras are video3 (arcimx616 0-0010) and video0 (0-001a); which of them is passthrough_left in the module's calibration is for tools/check_color.py to settle, on a recording with texture in view.
ft-hands
hands/build/ft-hands # status every 5 s; Ctrl+C to stop
hands/build/ft-hands --int8 # the 8-bit models (models/ncnn/*-int8.ncnn.*)
Run it in the dev container (distrobox enter dev -- ...). It reads the factory calibration from /persist (/run/host/persist in the container).
Options:
--threads N: model threads, pinned to the--cpuslist. Default 3.--cpus LIST: CPUs for the model threads and the main loop. Default5,6,7(HANDS_CPUS). SteamOS starts user processes on CPUs 0-4, and XRService's head tracking runs on 2-3. With the headset on, a step took 8.4 ms on 5-7 against 13.2 ms on 2-4, and SteamVR's frame timing didn't change (2026-09-29, three rounds of the same replayed frames).--contrast MODEorPALM/HAND: how crops are equalized before the models see them:clahe[:CLIP],none, orstretch(1st-99th percentile). Defaultclahe:2/none. In the dim recording, CLAHE let the palm search find about 10% more hands, but it made the landmarks jitter more (published median 6.9 mm, against 6.0 mm with plain landmark crops).--sides auto|0|1: which side camera is which (HANDS_SWAP_SIDES, see "Which camera is which");--swap-sidesis--sides 1.--seconds N: stop after N seconds.--status S: how often to print status, in seconds.--models DIR: where the models are.--nice N: niceness. Default 5, so the VR stack wins contested CPUs.--no-publish: don't write the hands and gestures files.- Parked: while
/run/user/UID/frametop-hands/parkexists, ft-hands processes no frames (the models stay loaded), publishes no hands, and ends any gesture down, as lost; it writes0tohands-wantthere every second, and ft-camd stops copying the mono cameras' frames while that file is fresh (3 s) and says 0. The input relay parks it while Frametop is paused for a VR game with hand interaction on (POINTER_HANDS=1), instead of stopping the services, and unparks it while the dashboard is open, so the hands work there; it tracks again from a palm search. Not while recording. --no-gestures: hands for the cutouts only. No pinch or grip detection, so nothing reaches the pointer and a closing hand doesn't raise the rate to 30 Hz. The gestures file is removed at start.ft-cutoutsruns it this way.--record DIR,--record-for S,--record-hz N: save every frame set for S seconds (default 120) toDIR/sets.bin, or at most N sets a second with--record-hz(the hand recorder uses 10). Every set is about 80 MB/s. Sending the tracker SIGUSR1 (pkill -USR1 -x ft-hands) starts a recording in~/.local/share/frametop/hands/rec-<time>without a restart. Recordings are images of your hands and room: they stay on the headset unless you move them.--record-only: record without tracking or publishing, so it can run beside the live tracker. Give it--record DIR, since SIGUSR1 would reach both trackers. Withft-camd --with-dark, recordings also hold each camera's newest dark frame as<name>_dk, which doubles the rate. With--with-color, each colour camera's newest frame is saved with every set, ascolor_video<N>, which adds about 70 MB/s. Run the recorder at normal I/O priority: idle I/O priority stalled a 165 MB/s recording.--reacquire S(HANDS_REACQUIRE, default 1.5): how long after losing a hand the palm search aims at where it should be (see "Scheduling"). 0 turns it off.--pair-sides 0|1(HANDS_PAIR_SIDES, default 1): two hands seen together are one left and one right (see "Scheduling").--hold S(default 0.6): how long a lost hand tracked for 30+ frames is held before it's forgotten (0.3 s with--reacquire 0, as before).--keep-presence P: the landmark presence a tracked view needs to stay tracked. New views always need 0.5. Default 0.5. Lowering it to 0.2 barely helped in the bright recording, because lost hands drop to near-zero presence.--ring PATH: read frames from another ring, such asft-ringplay's.--cams auto|mono|color|all(HANDS_CAMERAS, defaultauto): which cameras to track with. The mono IR cameras light the hands themselves and track well in dim rooms, but in bright light they expose for the room and the hands come out dark. The colour pair is the other way round.autogoes by the colour frames' mean brightness: at--bright-on(HANDS_BRIGHT_ON, 40) or over for 2 s it tracks with--bright(HANDS_BRIGHT:all, every camera, the default, orcolor), and under--bright-off(HANDS_BRIGHT_OFF, 25) for 2 s with the mono cameras again. A dim evening room read 9. The switch is logged (cameras: mono -> all (...)), and the status line gives the colour level, the mono cameras' ambient IR, and how many steps had colour frames. Colour frames arrive on their own schedule, so a step holds the mono set, the colour pair, or both, and views wait in their camera for its next frame. With no colour cameras in the ring (ft-camd without--with-color, as the service runs it), ft-hands tracks with the mono cameras whatever this says.--color-left NODE(HANDS_COLOR_LEFT,color_video0) and--color-crop subtract|none(HANDS_COLOR_CROP,subtract): how the colour module's calibration maps onto the images. Not settled yet:tools/check_color.pyon a recording with a lit, textured view tells.--skeleton 0|1(HANDS_SKELETON, 1): the skeleton fit (below). With 0 no skeleton is published, and version 1's points and capsules are the filtered landmarks, as before 2026-10-08.--grip-begin R,--grip-end R: the grip detector (below). Defaults 1.2 and 1.45.--gesture-log: print what the pinch and grip detectors measure, 10 times a second: each hand's thumb-to-index distance (world and triangulated), its palm-down reading and its finger curl.
Gestures (/run/user/UID/frametop-hands/gestures, include/fh_gestures.h), for the pointer helper:
- A pinch: the thumb and index tips within 2 cm, ending past 3.5 cm (see "Pinch" below).
- A grip, a closed hand: every finger's tip nearer the wrist than 1.2 times its knuckle is (from the model's 3D hand, so hand size doesn't matter), ending when they open past 1.45 on average. It begins only on a hand seen open within the last second (closing it is the gesture), with the palm at most 35 degrees below straight ahead and at least 15 cm in front of the eyes, and not with the thumb within 3 cm of the index tip (that's a pinch with the other fingers curled). A grip ends a pinch on the same hand, as lost. In the 2026-09-30 lit recording (no deliberate fists), the checks cut false grips from 14 to 6, all with the hands on the desk while looking down at it. The pointer helper ignores grips that begin more than
POINTER_GRIP_BELOW(0.35 m) below the eyes, which it can tell and ft-hands can't. - A middle pinch (gestures version 3): the thumb on the middle fingertip, with the pinch's thresholds. It begins only with the thumb at least 1 cm nearer the middle tip than the index tip, the index extended (its tip at least 1.5 times as far from the wrist as its knuckle), and the hand tracked for 5 frames; a pinch and a middle pinch on one hand exclude each other, and a pinch begins only with the thumb nearer the index tip than the middle one. The pointer helper's hand ray turns it into a right click or scrolling. Closing the hand into a grab brought the middle tip to the thumb with the index curled: before the index and frames rules, the recorder's gestures take (PR #8, about 95 s of pinches and grabs, none of them middle pinches) gave 6 false middle pinches; after, 1. The 2026-09-30 lit recording (typing and 26 deliberate pinches) gave none, and the same 48 pinches as without the middle pinch.
tools/watch_gestures.py --distanceshows them live;ft-handreplay --timelinelogs them (middle pinches aspinch2) and each hand's finger curl.
The status line also says how often a hand was on each side (by where the wrist is), and why views and hands came and went: views lost (the landmark model stopped seeing the hand), handoff misses (a crop projected from the hand's 3D position found nothing), duplicates, splits (two views disagreed in 3D), and hands created, merged and forgotten.
Scheduling
- Each hand is tracked in its best two cameras, the way MediaPipe tracks: the landmark model runs on a crop placed from the previous landmarks, with no palm detection.
- A hand seen in too few cameras is projected into the others through the calibration. Where it lands well inside a camera, that camera gets a crop to try. This is how a hand raised out of the side cameras reaches the upper ones.
- The palm detector runs only while fewer than two hands are tracked, at most 5 times a second, on a few zoomed tiles per search. Tiles are picked in proportion to how likely hands are there. Each tile is turned so the expected shoulder-to-hand direction points up. There are 42 tiles and a search tries 2 (3 with no hand tracked), so even the likeliest tile comes round only every 10-11 searches: about 2 s at 5 searches a second, and 4-5 s live, where a search round takes about 50 ms and the tracker manages 2-3 a second.
- Re-acquiring a lost hand (
--reacquire, on by default since 2026-10-08). A hand that stops being seen is first held, not published: 0.6 s if it was tracked for 30 frames or more (--hold), else 0.3 s. Meanwhile its crops are retried where it was heading (its last position plus its velocity). From the first step it's missing and for 1.5 s after it's forgotten, the palm search aims up to 2 tiles at where it should be, in the cameras that see that spot best. The aimed tiles grow with the time since the loss, and they run every processed set while such a hand is missing, not only 5 times a second. The rotation keeps at least one tile per search. In replays this brought a lost hand back on the next processed set (median 0.05 s, against 0.12-0.25 s), and the slowest cases from seconds down to under 1 s (see frame-hands'notes/reacquire.md). - Left and right (
--pair-sides, on by default since 2026-10-08). Each hand used to be called left or right by a running average of the landmark model's call, and both hands could get the same side for seconds. Now each hand also accumulates the model's calls as fading log-odds, and two hands seen together are one left and one right: the hand with more right-hand evidence is the right one, and which wrist is further right only tips a close call. - Frame sets are processed at 30 Hz while a hand moves faster than 0.25 m/s (or a pinch is down or closing), at 15 Hz otherwise, and at 5 Hz while no hand is in view. A reader that needs the full rate writes it to
/run/user/UID/frametop-hands/want-hzand keeps the file fresh: ft-screens writes 30 there while a hand is near one of its panels, because at 15 Hz a poke's press would come up to 67 ms later. A file older than 1 s lapses.
3D
- Two or more views: each landmark is triangulated from the camera rays, weighted by the model's presence score. The median ray distance is reported as the residual.
- Pairing views across cameras. The side cameras sit side by side, so two hands next to each other at the same height fall on the same epipolar lines, and rays to two different hands can nearly meet close to the cameras. That made phantom hands 12-15 cm in front of the eyes, which tore holes through the screens. Each step now scores every way of pairing the views in two cameras and keeps the best. A pair scores well when its rays meet, when each view's apparent size matches the triangulated distance, and when the model calls both the same hand. The size check uses a fixed prior: with the model's average hand, clean pairs measure 0.71-1.51 times the one-view distance, and mismatched pairs mostly far less.
- One view: depth comes from the model's metric world landmarks, their spread across the palm against the angle it covers in the image, scaled by the user's hand size (learned while two views are available). That distance is off by 10-30% and wanders about 10% between frames, so a hand that drops to one camera keeps its last distance and drifts toward the one-view guess by 10% a frame.
- Smoothing. The landmarks go through a One Euro filter: it smooths hard while the hand is still (tracking noise is several mm per frame) and hardly at all while it moves fast. The palm speed that sets the update rate is the filtered one; the raw speed read about 0.25 m/s from noise alone. The gestures (pinch point, palm-down reading) use these filtered landmarks. What's published is the skeleton's (below), unless
--skeleton 0. - Capsules. Forearms follow the hand's own axis, and nothing within 12 cm in front of the eyes is published.
How good the depth is, measured from recordings (2026-09-30, --depth below): the two lower cameras see the hands about 77% of the time, a lower and an upper camera 7-12%, and one camera 12-15%. Depth is the noisy direction. With the lower pair, it jitters 4-6 times as much as sideways position (published: 3-7 mm against 1-2 mm). The one-camera guess is a median 2-6 cm off. When a camera drops out, drifting 10% a frame toward that guess is worse than keeping the last distance (after 0.5 s a median 23-30 mm off, against 11-12 mm).
Skeleton
After the 3D step, ft-hands fits a skeleton with your own bone lengths to each hand (track/skeleton.h), so fingers keep their length and don't wobble. It's the base for a hand mesh, colliders and the hand ray. --skeleton 0 (HANDS_SKELETON=0) turns it off.
- Bone lengths, per side. From clean stereo frames (residual under 4 mm, the model sure of the side), each of 30 numbers is learned: the thumb's CMC and the four knuckles' places in the palm, and the 15 finger bones. A landmark whose two rays miss each other by more than 4 mm, a finger bent more than 90 degrees, or a bone far off the average hand's proportions (under 0.7 or over 1.5 times them) gives no sample. Each settles on the median of its first 45 samples (3 s of a clean hand at 15 Hz), then moves at most 0.02 mm a sample. Until then the estimate is pulled toward a prior: the other side's lengths if it has more, else MediaPipe's average hand times
Hand::scale. Nothing is saved: each start learns again. - The fit, every frame. The palm is rigid: its frame from the wrist and knuckles, then Gauss-Newton on its rotation and position. Then the thumb and each finger as a chain from its base: the thumb's CMC turns three ways (±50 degrees from a rest pose, ±35 twist), its MCP bends and spreads a little, its IP is a hinge; a finger's knuckle bends (-35 to 95 degrees), spreads (±25, the little finger ±30) and twists a little (±25), the two joints above it are hinges (-10 to 110, -20 to 90). A chain's base may sit up to 15 mm off the rigid palm, pulled back with a 4 mm prior: the measured knuckles wander far more than a palm bends (the little finger's by 3 cm across the palm). Each chain runs 4 Gauss-Newton steps (the limits as clamps) twice, from the measured bone directions and from last frame's pose, and keeps the better fit, so it doesn't hop between two poses that fit about as well.
- Weights. Stereo: each landmark by its own ray miss (2 mm at least), and 3.5 times less sure along the line of sight. One view: 3 mm across, 15 mm along it.
- Which side. The model's, but a tracked hand keeps its side while the model's running call is between 0.25 and 0.75. The mirror image is tried when the fit is worse than 15 mm rms, and taken after it fits at least twice as well 3 frames in a row; it then sticks while the hand is tracked (a finger can't bend backward, so a hand fitted as the wrong side fits badly once its fingers bend). Switching restarts the filter.
- Filtering the pose, not the points. The palm centre and the palm's rotation go through One Euro filters (2 Hz still, plus 30 Hz per m/s and 10 Hz per rad/s); each finger's angles and base through one whose cutoff follows that finger's own speed (2 Hz plus 3 Hz per rad/s), so a pinch on a still palm doesn't lag the way the One Euro on the points does. A chain that fits its landmarks worse than 15 mm this frame moves its filter less (by the square of 15 mm over its error): that's mostly a hidden landmark misplaced.
- Elbow. frame-hands' arm model (
notes/forearm.md): a neck pivot fixed in the head frame, shoulders 18 cm to each side and 6 cm down, upper arm 1.5 times the forearm, the forearm 2.8 times the palm (wrist to middle knuckle), the elbow on the circle the two bones allow, turned 3 degrees from straight down, and never more than 75 degrees off the hand's own axis. ft-hands has no HMD pose, so "down" is the head frame's -y: published withFH_FIT_ELBOW_HEAD_UP. The model's other parameters (neck and shoulder offsets) are its starting values: the fitted ones are on dody. - Published in the hands file's version 2 (
include/fh_hands.h): after version 1's struct, which is unchanged, anfh_hand_fit_tper hand with the 21 joints (position, a rough radius, and a quaternion in OpenXR's hand-joint axes), the bone lengths, the joint angles, the elbow, the pinch point (between the index and middle knuckles) and the thumb-index contact point, the thumb-index and thumb-middle distances, the fit's rms and how settled the calibration is, and the palm's velocity and angular velocity. Those two are in the head frame and include the head's own motion; for motion in the room, difference room-frame points across publishes asscreens/handcut.cppdoes. Version 1's points and capsules are the skeleton's too, so ft-screens' cutouts get it without a change. The hand recorder's reader takes version 1 or 2. - The gestures don't use it: pinches still measure the model's world landmarks, and the pinch point and palm-down reading still come from the filtered landmarks.
ft-handreplay --pinch-fittedcounts pinches on the fitted tips for comparison: they begin about as often as on the triangulated tips, more than on the world landmarks.
What it measured, ft-handreplay on three recordings and three takes of the 2026-10-03 hand-recorder session (frame-hands notes/skeleton-fit.md has every number):
| 2026-09-29/30 recordings, 30 Hz | dim (typing) | lit | bright |
|---|---|---|---|
| Bone length spread per track (median CV): measured / One Euro / skeleton | 0.41 / 0.37 / 0.05 | 0.24 / 0.22 / 0.03 | 0.39 / 0.35 / 0.01 |
| Index tip jitter, hand and fingers still, mm (median; 90%): One Euro / skeleton | 4.5 / 3.9; 16.9 / 24.3 | 3.4 / 2.8; 11.0 / 14.7 | 4.2 / 3.2; 11.5 / 10.2 |
| Thumb tip jitter, still, mm (median; 90%) | 3.0 / 2.7; 12.0 / 11.8 | 2.4 / 2.7; 7.8 / 12.0 | 3.0 / 2.2; 10.1 / 8.7 |
| Index tip jitter, palm over 0.3 m/s, median mm | 14.0 / 11.2 | 11.0 / 8.6 | 17.7 / 14.7 |
| Palm jitter, still, median mm | 1.8 / 1.8 | 1.9 / 1.8 | 1.9 / 1.8 |
| Tips trailing, moving over 0.5 m/s, median mm | 6.5 / 5.7 | 5.1 / 4.9 | 5.7 / 4.9 |
| Tips trailing, fingers moving on the palm, median mm | 7.8 / 3.5 | 5.9 / 4.7 | 4.6 / 3.5 |
| Palm normal's turn per update, palm still, fingers moving, median degrees | 2.8 / 1.7 | 2.2 / 1.3 | 2.3 / 2.0 |
| Fit rms against the measured landmarks, stereo, median mm | 10.4 | 9.0 | 7.6 |
| Calibration settled after each side's first sample, s (left, right) | 20.5, 10.9 | 10.8, 3.5 | 15.6, 8.9 |
- Against the auto-labels (frame-hands' teacher, triangulated, smoothed and fitted: not ground truth; three takes of the PR #3 session, 10 Hz, stereo hands): mean landmark error measured 10.2, 12.1 and 7.0 mm, the fit before its filter 10.4, 12.2 and 6.5 mm (pose sweeps, gestures, desk work). The index tip: 8.1, 7.7 and 7.9 mm measured, 8.7, 8.7 and 5.5 mm fitted. The labels' ring and little knuckles sit far from MediaPipe's (their wrist to little knuckle is 110-140 mm against our 80-99), so part of every number is the two models' conventions.
- Cost: 41-43 us a hand (median; 90% 61-70 us, 99% 91-164 us) on an A720 core, the fit alone replayed on CPU 3; 320-350 us on an A520 (CPU 0). ft-hands runs on CPUs 5-7, which are A720s and the X4.
- Pinches don't change: the same counts in every replay, because they don't use the skeleton. On the fitted tips they'd begin more often (lit: 77 against 51; dim: 50 against 35), which is why they stay on the world landmarks.
- Worse: the fingertips' jitter tail with the hand still in the dim and lit recordings (index 90% 24 against 17 mm, 15 against 11 mm; thumb in lit 12 against 8 mm). That's the price of each finger's own cutoff: a misplaced tip that the skeleton can reach (a few frames of a hidden finger) gets through where the palm-speed One Euro held it. The fitted landmarks also sit further from a centred average of the measured ones (moving fast: 11.7 against 9.3 mm in lit), mostly the fit's own residual: the labels don't say the fit is the worse of the two.
- Elbow: the forearm comes out 231-247 mm, a median 21-27 degrees off the hand's own axis. With gravity's up from the takes' HMD poses instead of the head's, it moves a median 0.6-2.2 degrees (90%: 2.2-5.0): the head-up assumption costs little at a desk. No elbow labels are here to say how right it is.
Pinch
ft-hands detects a pinch per hand (track/pinch.h) and publishes it to the gestures file. The layout, and how to read it without missing quick taps, is in include/fh_gestures.h.
- A pinch begins when the thumb and index tips come within
--pinch-begin(default 0.020 m). It ends when they open past--pinch-end(0.035 m) for 2 processed frames in a row, or when the hand stays lost for 0.25 s (flagged lost). - The distance comes from MediaPipe's world landmarks: the model's own 3D hand pose, averaged over the hand's views, at the user's hand size.
--pinch-triangulateduses the triangulated tips instead. On two recordings without deliberate pinches, the world landmarks came under 2 cm in 0.2-1% of frames, against 3.3-4.5% for the triangulated tips. In the dim recording, typing still gave 2 pinches a minute (see the next point). --pinch-palm-down MAXholds back pinches begun with the palm facing down (MAX is the palm normal's share of the head's up axis). The default, 1, turns it off. A close held back that way has to open again before a pinch can begin. Typing curls the thumb onto the index: in the lit recording of 2026-09-30, typing on a keyboard in the lap began 23 pinches in about 2 minutes, all with the palm facing down (0.69-1.00), while the 26 deliberate ones read 0.00-0.50. But in the headset, deliberate pinches with the hand raised in front read 0.90-0.99 too, so the limit is off. Typing is caught by the pointer helper instead: the input relay tells it when you press a key, and no pinch begins withinPOINTER_PINCH_TYPINGof one.- A hand a pinch is down on stays with that side until the pinch ends. The left/right call is a running average of the model's, and when it flipped mid-pinch, the other side took the same hand and both sides pinched at once.
- The pinch point is between the index and middle knuckles, which hold still while the fingers open and close. The tips' midpoint moved 1-2 cm as a pinch opened, which dragged every release off its press. A drag is the pinch point now, minus where it was when the pinch began, both turned into the room with the HMD pose at their capture times.
tools/watch_gestures.pyprints begins, ends and drag offsets live, and--distanceprints each hand's distance.
Pinches and grips in the pointer
With POINTER_HANDS=1, the pointer helper (pointer/helper/ft-pointer.cpp) reads the gestures file every frame. It's off by default.
The hand ray (POINTER_HAND_RAY=1, the default with POINTER_HANDS): a bare hand raised in front and pointing, palm away, shows a ray from it, Quest-style (include/fh_ray.h: from a shoulder estimated from the head, through the knuckles), and the ray drives the pointer's virtual controller, so it works on SteamVR's dashboard, Steam's pages, overlays and Frametop's panels like a controller's laser. With the ray up, a pinch clicks (a held pinch drags), a middle pinch right-clicks (held and moved, it scrolls), and while a pinch carries a panel, reaching out pushes it away and pulling in brings it near. With the hand low or the palm toward you, there's no ray, and pinches and grips work as below. A hand holding a controller, or whose controller was used in the last seconds, has no ray and no gestures (the helper's handoff, pointer/helper/handoff.h). The rules, timings and measurements are at the top of ft-pointer.cpp ("Handoff", "Hand ray") and in frame-hands' notes/hand-interaction.md. pointer/tools/rayeval runs the ray over a replay (ft-handreplay --ray).
- Pinch to click. In gaze mode a pinch works like the mouse's press: the pointer stops where the gaze put it, and the click comes when the pinch opens, where the pointer is then. A quick tap clicks where you looked. Held, the pinching hand moves the pointer to correct the gaze, and the correction is a lesson for the gaze tracker, as with the mouse.
- Without gaze mode, a pinch is a real press, like the mouse's button: pressed when it closes, released when it opens, and while it's held the hand drags the pointer. A tap is still a click where the pointer is.
- Grip to drag. Closing the hand presses where the pointer is, the hand moves the pointer, and opening the hand releases. So a title bar moves its window, a panel's grab bar carries the panel, and text gets selected.
- A pinch ended by losing the hand, or by a grip taking over, doesn't click.
- The hand's movement is taken in the room, from where the eye was when the gesture began, so turning your head doesn't move the pointer. The first gesture while the pointer is off only wakes it. Gestures are ignored in a VR game (unless the dashboard is up), with the headset off, and while the mouse's button is held.
Settings in ~/.config/frametop.conf:
POINTER_HANDS(0): 1 turns pinches and grips on.POINTER_PINCH_GAIN(0.5): a held pinch moves the pointer this many times the hand's angle, seen from the eye. Under 1 gives precision.POINTER_PINCH_DEADZONE(1.5): how many degrees the pinching hand moves before the pointer does, so a tap's jitter and the pinch point shifting as the fingers close don't move it.POINTER_GRIP_GAIN(1): a grip moves the pointer this many times the hand's angle.POINTER_GRIP_BELOW(0.35): grips that begin more than this many metres below the eyes are ignored, because hands resting on a desk curl like a loose fist. Pinches have no such limit: deliberate ones sat 0.35-0.45 m below the eyes with the elbow resting.POINTER_PINCH_TYPING(1): no pinch begins within this many seconds of a key press, because typing touches thumb to index.POINTER_HAND_RAY(1),POINTER_RAY_DEADZONE(2.5 degrees the ray turns during a pinch before the press drags),POINTER_RAY_PUSH(1),POINTER_RAY_SCROLL(1; -1 turns scrolling round),POINTER_RAY_RELEASE(2 s after the ray goes before a pointer it woke lets go),POINTER_RAY_GRACE(0.5 s a hand keeps the laser after its ray drops; the other hand's ray takes it after that, or at once by pinching or raising): the hand ray.
Recordings
hands/build.sh --tools also builds the offline tools.
ft-handreplay DIR runs a recording through the tracker with the live scheduling and reports how well it kept the hands: hands per set, left and right coverage, track lengths, pinches, jitter, and the same reasons as the status line. --publish-dump FILE also writes what ft-hands would have published after each processed set (the hands and gestures files' bytes, and the set's dequeue time), for ft-screens' offline tests; a hand-recorder take's poses.jsonl and prompts.jsonl copied next to the dump as NAME.poses.jsonl and NAME.prompts.jsonl give them the head's poses and the touch take's targets (scripts/test-handinput.sh, scripts/bench-matte.sh). To match the live tracker, replay with --reacquire 1.5 --reacquire-fast --pair-sides --hold 0.6 and the models in use.
hands/build/ft-handreplay ~/.local/share/frametop/hands/rec-20260929-120000 --cost --oracle 10 --timeline /tmp/tl.txt
--cost: instead of timing the steps, charge each round of model calls what it typically costs live (10 ms landmarks, 18 ms palms), so results repeat exactly.--oracle N: every N-th set, also search every tile of every camera, and report how often the tracker had the hands that full search could find.--slow F: live, the tracker skips sets that arrive while it's busy. Replay counts each step's time times F as busy (default 1; the headset is busier live).--timeline FILE: a line per processed set and hand, with pinch events and distances.--ray FILE: a line per processed set and hand with what the pointer helper's hand ray reads (time, side, stereo, pinch and grip state, the published points, the wrist's frame, the elbow), forpointer/tools/rayevalwith the same session'sft-handtestprobe log (head poses).--cams mono|color|all: which cameras to track with (defaultmono).colortracks with the Arcturus pair alone, for comparing it with the IR cameras on the same recording. It needs a recording made withft-camd --with-color.--color-left NODE(color_video0orcolor_video3) and--color-crop subtract|nonesay how the module's calibration maps onto the images;tools/check_color.pyfinds out.- The skeleton (see "Skeleton"): the report ends with its measures for the measured, One Euro filtered and fitted landmarks (and the fit before its filter): its time per hand, also on its own pinned to a little and a big core; its rms; each side's calibration and when it settled; how much bone lengths spread per track; jitter at rest and moving for the palm and the thumb and index tips; how far the tips trail during fast motion (against a centred average of the measured tips); how steady the palm's normal is; and the elbow.
--no-skeletonreplays without it;--skel NAME=VALUEchanges a setting (skel::Params);--labels FILEcompares with frame-hands' auto-labels (labels.jsonl),--skeleton-dump FILEwrites the matched hands;--hmd-poses FILE(a take'sposes.jsonl) compares the elbow with gravity's up against the head's;--pinch-fittedcounts pinches on the fitted tips too. - A hand-recorder take is a recording too: its
sets.binis followed bysets-2.binand later parts, which are read in order. It has nosides.json: whether its side cameras need exchanging istake.json'snames_swappedagainstsession.json'ssides(rec/sides.py), so give--sides 0or1(--sides autochecks from the hands). --depth FILE: a line per hand per processed set fortools/depth_report.py, which measures the depth without ground truth: how the hands were seen, the noise along the line of sight against across it, each camera's one-view distance against the triangulated one, and what a camera dropping out would do.- The pinch, contrast and presence options are ft-hands'.
--reacquire S(off unless given;--reacquire-fastsearches every set as ft-hands does,--reacquire-tiles N,--reacquire-clear M),--pair-sides,--hold S,--near Fand--search-budget Nare the tracker's re-acquire settings, off or at their old values by default so old replays repeat. --cost-hand MS,--cost-palm MS: with--cost, what a round of landmark and palm calls costs. The defaults (10, 18) are the headset's idle costs. The 2026-10-08 live probe (CPUs 0,1,4 beside SteamVR, 3 threads) measured about 14 and 52, and the live tracker loses hands far more at those costs.--drop-every S: the re-acquire test. Every S s, while two hands are tracked, one of them is made invisible for 0.35 s (just past the 0.3 s the tracker holds a lost hand), and the report gives how long it then took to have two hands again: the time to find a hand that never left the view.- With
--timeline, the tracker also writes why views and hands came and went (evlines: lost, miss, split, search and its tiles, palms kept or rejected, new, forget, pair-sides), and--oraclewrites what the full search found.tools/gap_report.py TIMELINE --listturns that into every gap of 0.3 s or more per side, with its cause.tools/replay_gaps.sh OUT MODELS "ARGS" REC...runs several replays at once (on the 7i through frame-job) and reports them.
ft-ringplay DIR --ring PATH [--from S] [--to S] [--loop] publishes a recording into a ring file in real time, as ft-camd would, so ft-hands --ring PATH --no-publish runs the same frames run after run. It needs no privileges, and it skips the dark frames.
Tools
Python, with NumPy and OpenCV. setup/dev-container.sh doesn't install them, because Fedora's python3-opencv pulls in over a gigabyte; in the dev container, run sudo dnf install python3-numpy python3-opencv once. Off the Frame, FRAME_JOB_DEVICE_ROOT can point at a folder with copies of the headset's calibration files.
tools/check_sides.py --ring(or a recording, a sets file,--pair upper,--calib DIR,--json): are the side cameras named right, from the scene? ft-handreplay's--sides file|0|1|autoreplays with DIR/sides.json's names (the default), as recorded, exchanged, or as auto decides, and reports what the side check found and when.tools/check_color.py REC: how the colour module's calibration maps onto its images.tools/show_set.py REC: a recording's frame sets as images.tools/watch_gestures.py [--distance]: pinches and grips, live.tools/depth_report.py DEPTH: the depth measures above.tools/cut_sets.py REC OUT [--sets N | --at I,J,...]: copies a few frame sets (by default 8, spread evenly) out of a recording into a small one, to look at or check elsewhere without moving gigabytes. Plain Python, so it also runs on the Frame's host.tools/convert_models.py: howmodels/ncnnwas made from the OpenCV Zoo ONNX ports of MediaPipe's models (seemodels/NOTICE).
To try the hand cutouts without restarting the desktop, screens/build/ft-handtest [--distance m] [--width m] [--seconds s] [--matte mesh|capsules] [--margin deg] (built by screens/build.sh, run in the dev container, with hand tracking on) shows a test panel of its own, a light grid 1 m wide and 0.8 m ahead by default, and cuts your hands out of it the way ft-screens cuts them out of the screens. Every 2 s it prints the composite's cost.
Hand cutouts and near input
ft-screens reads the hands file (version 2's skeleton when it's there, else version 1's points) and the gestures file once per tick (screens/handcut.cpp). It cuts only with Room View on: with it off, a hole would show the void or the game, not your hand (see "Rendered hands"). cutouts state ends with room view on|off (why); cutouts roomview auto|on|off forces it.
The matte (HANDS_MATTE=mesh, the default) is hand-shaped: a tapered capsule per finger bone, with real fingers' half-widths (10.5 mm at the index's knuckle down to 7.5 mm at its tip, scaled by the hand's palm length); the palm as a 2.4 cm slab through the wrist and the knuckles, widened to the hand's edges; the thumb's web; and an 18 cm stub of the forearm toward the skeleton's elbow (along the hand's axis without a skeleton). The palm, web and forearm are convex pieces, each drawn as its outline grown by the margin with a soft 2 px edge. HANDS_MATTE=capsules draws ft-hands' capsules instead, as before, for comparison (cutouts matte mesh|capsules switches it live).
Its margin is an angle seen from the eyes, so it covers the same share of the tracking error at any distance: HANDS_MATTE_MARGIN (1.5 degrees) with the hand still, plus HANDS_MATTE_MOTION (25 ms) of the hand's motion, at most HANDS_MATTE_MOTION_MAX (1.5 degrees) more. The cutout placement probes of 2026-10-08 (frame-hands captures/probe-2026100*/video_analysis) measured the cutout points against the hand Room View shows: 1.8-2.4 degrees median still (p90 4.1-4.7), 2.3-3.1 moving at 0.1-0.8 m/s, 4-6 above 0.8 m/s. Part of that is the video landmark model's own error, and an edge needs only the error across it (about 70% of a point's). A margin covering the p90 (4.5 degrees, 3 cm at 40 cm) would turn each finger into a 7 cm blob; 1.5 covers about the median. cutouts margin DEG [MS [MAX]] changes it live, and so does the Hand cutout margin slider on the Hands page of Frametop Input Settings (0-3 degrees in quarter steps; it sends the still margin only and writes HANDS_MATTE_MARGIN). The prediction lead stays 36 ms (HANDS_MATTE_LEAD, cutouts lead): the probes' best lead for fast hands was 6-14 ms, but 36 costs only 0.1-0.2 degrees.
The probes also saw the hand's picture off by an amount that depended on its distance, but not the same way in any two runs, so the correction is a hook, off: HANDS_MATTE_SHIFT="0.35:0.5,-0.3 0.6:-1,0" would turn the matte's hands by those yaw and pitch angles (degrees, right and up positive) at those distances from the head, in between linearly (cutouts shift ...|off live).
Cost, offline (scripts/bench-matte.sh on the probe-moves and lit replays, a 3440x1935 screen 0.6 m ahead, GPU time to finish each buffer on an otherwise idle GPU): the mesh 3.9-4.1 ms per full buffer against 3.8-3.9 for the capsules, and 3.4-3.6 against 3.0-3.4 when only the hands' boxes are redrawn (the margin is wider than the capsules' 4 mm). CPU 0.2 ms per composite either way; building and projecting the matte 14-24 us median.
Near input (screens/handinput.cpp, frame-hands notes/hand-interaction.md sections 3 and 5): a bare hand whose fingertip or pinch point is over a panel and within 15 cm in front of it is near it, and ft-screens tells the pointer helper so ("near"), which then leaves that hand's pinches alone. Near a panel:
- Poke to click: the index fingertip presses when it comes at the panel's face (user, 2026-10-08): at or past the surface for two hands frames in a row, seen by two cameras, moving into it at 4 cm/s or more when it crossed (over the last 100 ms), its last bone within
HANDS_POKE_ANGLE(50) degrees of the panel's normal, the index straight and either nearer the surface than the other fingertips or the hand pointing (the index straighter than each other finger by 0.2), and not from a hand resting low by the body (palm more thanHANDS_POKE_REST_BELOW, 0.30 m, under the eyes and withinHANDS_POKE_REST_NEAR, 0.32 m, of the head across the floor; it stays resting until 5 cm higher or further out). So typing under a low panel doesn't poke it, and reaching out to one does. It lets go 1.5 cm out, or 1 cm back from the deepest point (so a tracker that has the finger behind the panel still lets go). The click stays where it was pressed until the fingertip slides 1.5 cm; then it drags. No new press for 150 ms after a release; a finger 8 cm through the panel lets go and needs to come back out first. Within 5 cm the pointer follows the fingertip. The curve, reset, dock and close buttons take pokes too. - Pinch to move or resize: a pinch that begins on the bar, an edge, the top, a floating window's title bar, a corner or the resize tab takes the panel. Moved, it follows the pinch point 1:1 (closer and farther too) and keeps facing you; by a corner it resizes. A pinch on the content does nothing: the content takes a pinch only through the hand ray.
- Two hands: both hands holding one panel stretch it (its width follows their distance, past a 1 cm dead zone) and turn it with the line between them; let go with one and the other carries on.
- A glow shows where the fingertip is on the screen (a ring that tightens as it comes to the surface, a dot while pressed), and where the pinch point is near a grab zone (blue while it holds); the bar brightens under a pinch point.
- It takes the helper's word on which hands are bare and whether hand input is allowed ("hands"); without that word for 3 s, near input is off.
HANDS_NEAR(1): 0 turns it off,forceturns it on without the helper (testing).ft-screenscommandhandinput on|off|force|state.
The palm menu (screens/palmmenu.cpp, frame-hands notes/hand-interaction.md section 11), like the Vision Pro's home indicator: turn a bare hand palm up toward your face, open, and look at it. After 0.3 s a small ring (2.5 cm) shows 4 cm above the palm and follows it. A thumb-index pinch tap by that hand opens the Steam menu (steam/ft-steam menu, what the input relay's steam_menu runs), or closes the SteamVR dashboard if it's open. The ring turns blue while the pinch is held, and the release does it; a pinch that's lost, or let go after the hand turned over, does nothing. Turn the hand away for 0.15 s and the ring goes.
- Palm up means: the palm's normal within 60 degrees of the line to your head (it leaves past 75), the palm within 45 degrees of where your head points (leaves past 55), 15-70 cm from the head, and the middle, ring and little fingers open (tip-to-wrist over knuckle-to-wrist 1.5 on average, leaves under 1.3). A palm up in your lap while you look at a screen doesn't count. The hand ray needs the palm turned away, so the two never overlap.
- While the ring shows, ft-screens reports that hand near ("near"), so the pointer helper leaves its pinches alone (no gaze click), and near input leaves it too. A pinch that began before the ring showed stays the helper's.
- Where: a bare hand (not one holding a controller), with hand input allowed: on the desktop, and during a VR game while its dashboard is open or Frametop's panels are turned back on. So in a game it closes the Steam menu it opened; with the dashboard closed, the game keeps your hands.
HANDS_PALM_MENU(1): 0 turns it off;ft-screenscommandpalmmenu on|off|state.
The "approach from front" rules, on recordings: in the hand recorder's desk take (PR #3, with head poses), typing under panels 0.30-0.40 m ahead (at eye height, or 0.45 m low and tilted up) made no press; the hand on the mouse (0.37-0.40 m out, past the resting zone) made 2 presses in 13 s under the nearest ones. In its touch-the-dot take the fingertip stopped 0-2 cm short of the dot in the air, its last bone 44-63 degrees off the line from the head: with a panel 3 cm nearer, 2 of 6 touches pressed at 50 degrees, 3 at 65. HANDS_POKE_REST_NEAR=0.42 takes the mouse hand out too, but then a low dot 25 cm ahead no longer takes a poke.
Offline, scripts/test-handinput.sh [DUMP...] runs its unit tests and plays recorded hands (ft-handreplay --publish-dump) against panels placed around them. On the probe-moves (v4), lit and dim recordings, in 1 s and longer stretches where a hand held still and pointed (184 s in all; the fingertip still moved 19-23 mm in depth within each, median), a panel 3 cm beyond the fingertip got no press, 2 cm one (in 42 s of probe-moves), 1 cm 7, 0.5 cm 18. frame-hands notes/hand-interaction.md section 10 has the rest.
Rendered hands
ft-handview (screens/handview.cpp, built by screens/build.sh) draws each tracked hand when Room View is off: a light, see-through hand (about a third opaque) with a bright rim where it meets the background or a part of it further back (a finger over the palm), fading out over the 10 cm behind the wrist. It's built from the same pieces as the cutouts' matte with no margin (screens/handmesh.cpp): a tube with round ends per finger bone, four more from the wrist to the knuckles, the palm's slab, the thumb's web and the forearm's stub. With Room View on it draws nothing.
Each hand is an overlay of its own (sort order 100, over the panels and the dashboard): a square placed at the hand, facing your head, side by side, each half what one eye sees of the hand projected onto the square. The compositor keeps a placed overlay still in the room as your head turns, so a hand only waits for its own motion; a head-locked layer would have to be redrawn at the display's rate to stay put. It's drawn for each new hands frame and, while the hand moves, at most every 20 ms in between (moved ahead like the cutouts), on the GPU: the body into a scratch buffer, then the rim into a DMA-BUF SteamVR imports. The --demo hands (two hands 45 cm ahead, drawn 30 times a second each) cost 0.5-0.7 ms of CPU per hand per draw (0.3 of it the mesh) and about 4.5% of one core in all on 2026-10-09; idle it's 0.2%, and with Room View on it wakes 10 times a second.
A hand holding a controller isn't drawn: the pointer helper sends ft-handview its word ("hands L R allowed" on @ft_handview, as to ft-screens); while the helper is silent (3 s), a hand within 15 cm of its side's controller counts as holding it. The hands draw in VR games too (hand input keeps its own rules), but only while hand tracking runs: the relay parks it while Frametop is paused for a game, unless the dashboard is open.
Room View (screens/roomview.h): SteamVR has no public "is Room View on". In a VR game, the compositor's VREvent_RoomViewShown and VREvent_RoomViewHidden tell it; with no game, the construct shows Room View when camera/enableConstructRoomView is on (the default on the Frame). The passthrough shared state (VR_CameraPassthroughState, read only) has an enabled byte; it read 1 on 2026-10-09 and isn't yet known to follow the dashboard's Room View button, so only a 0 counts (off). HANDS_ROOMVIEW=auto|on|off in ~/.config/frametop.conf forces it for both programs.
screens/build/ft-handview [--status S] [--res PX] [--demo] [--seconds S] # in the dev container (scripts/in-box)
echo -n state | socat -t 1 - ABSTRACT-SENDTO:ft_handview,bind= # the status line; also: roomview auto|on|off
screens/frametop-handview.service runs it as a user service (--status 60), started and stopped with SteamVR; the installer doesn't install it on its own.
Camera check
Hand tracking needs all four mono cameras and the headset's IR light. With the Arcturus colour module attached, SteamVR's XRService loads an FPGA image ("VCINT") onto the module every time it opens the cameras: when SteamVR starts and after every wake. When that load fails, XRService runs only the two side cameras, the frames come out darker and noisier, and ft-hands finds no hands at all. It happened on 2026-10-02 at 17:02, after the headset slept; the hand recorder then said "I can't see your hands" for a whole session.
hands/camcheck.py (system Python, standard library) tells whether the four cameras run: ok, degraded: upper cameras and IR light off (VCINT FPGA failed to load) (or another degraded: reason), or unknown (SteamVR not running, the cameras closed while the headset sleeps). It prints the log lines and other evidence it used; --json is for programs; the exit status is 0, 1 or 2. It reads:
- the running XRService's log (
~/.local/share/Steam/logs/xrservice.txt): the last camera start (from the FPGA check to the next "Closing tracking camera interfaces"), its VCINT result,Upper cameras FPGA interleaving support: N,Created N tasks (T tracking, P passthrough)and theTrackingCameraInitlines. A wake that works prints no "Created N tasks", so an older one doesn't count; - which
/dev/video*XRService has open (/proc/PID/fd; video9 and video13 are the side pair, video6 and video7 the upper pair). Only on the host: the dev container can't read another process's open files, so there it's skipped; - ft-camd's ring header, when it runs: how many mono cameras it publishes.
The hand recorder runs it before a session (DESIGN.md, "Camera check"). hands/tests/test_camcheck.py runs it on the 2026-10-02 log cut at several points, and on made-up logs.
The watcher (off by default)
hands/ft-camwatch follows the XRService log (a stat every 2 s, reading only what's new). On a VCINT failure it posts a notification in the Frametop desktop, on the desktop's own D-Bus, found through its plasmashell as decoration/apply.sh does. With CAMWATCH_AUTO_RESTART=1 in ~/.config/frametop.conf it also restarts SteamVR, but only:
- while the headset isn't worn (frame-job's check:
vrcompositorruns and a/sys/class/backlight/*/brightnessis over 0), and after it has been off forCAMWATCH_IDLE_S(60); - with no app Steam launched (
SteamLaunch AppId=Nin a process's arguments;CAMWATCH_IGNORE_APPIDSlists ids that don't count) and nobody on the remote desktop (an established connection to the VNC port,VNC_PORT, 5900); - once per failure, and not again within
CAMWATCH_COOLDOWN_MIN(30) of the last automatic restart. It remembers both in~/.local/state/frametop/camwatch.json, so its own restart doesn't reset them.
It logs every decision to the journal. ft-camwatch --once prints the state and what it would do, and does nothing; --dry-run keeps watching without acting. hands/frametop-camwatch.service is the unit (a template, @REPO@ as in the others; nothing installs or enables it yet). It isn't PartOf=steamvr.service, so it outlives the restart it asks for. CAMWATCH_NOTIFY=0 turns the notification off. hands/tests/test_camwatch.py tests its decisions with made-up inputs.
What a SteamVR restart does to Frametop
Read from the code on the experimental branch, not tried live:
- ft-screens quits when SteamVR does: on
VREvent_Quitit ends its Wayland display (screens/vr.cpp,ft_vr_poll;screens/compositor.c,handle_vr_event). It never connects to SteamVR again:ft_vr_initruns once, at its start. - KWin runs nested in ft-screens, so the Frametop desktop ends with it, every window in it too (the hand recorder's as well). Its unit,
frametop-desktop, is a transientsystemd-rununit withRestart=no, so the desktop doesn't come back by itself: start it again (Desktop in the library, ordesktops.sh start). - When the unit stops, systemd ends what's left in it.
session/keep-apps.shmoves programs started in the desktop out of the unit first, but onlydesktops.sh stopruns it; here they stop too. Programs in the dev container (ft-screens, the hand recorder) are in the container's cgroup and end when their Wayland connection goes. - The units that are
PartOf=steamvr.servicerestart with it:frametop-camd,frametop-hands, the pointer helper, gaze and power, and the hand recorder's own transient ft-camd and ft-hands units.
Verified, and what's a guess
Verified, from the XRService logs of 2026-10-01 and 2026-10-02 and the running system:
- The failure's log lines and its effect: "Failed to load VCINT FPGA image when passthrough cameras are connected", interleaving support 0, "Created 4 tasks (2 tracking, 2 passthrough)", and only video9 and video13 opened. At 19:38-19:59 XRService held only those two of the four (plus video0 and video3), and ft-camd published two mono cameras.
- A wake's load can work and can fail. Both wakes in the logs started from an FPGA that answered nothing ("ERROR/UNKNOWN"): the one at 2026-10-01 16:39 loaded VCINT, the one at 2026-10-02 17:02 failed ("FPGA config_done signal did not assert").
- After a reboot the FPGA reads PASSTHRU and SteamVR's start loads VCINT (2026-10-01 21:53, 2026-10-02 13:39).
- A SteamVR restart within a boot found VCINT still loaded and loaded nothing (2026-10-01 15:27): XRService checks the FPGA when it starts and loads only when it must.
Guesses, not tested:
- Whether a SteamVR restart fixes it. After a failed load the FPGA doesn't answer, so a new XRService would run the same load a wake runs, which has worked once and failed once. It's never been tried after a failure. If it doesn't help, only a reboot is known to work (the FPGA comes up as PASSTHRU, and the load at SteamVR's start has worked both times).
- That the IR light is off because of the FPGA: the frames are darker and the illuminator ring isn't seen, and the FPGA loader lists a
room_led_enpin, but nothing shows the light's state directly. - That a sleep and wake (taking the headset off long enough) would retry the load too: it should, since every wake loads VCINT, but no failure has been followed by a wake yet.
- How the Frametop desktop behaves on a SteamVR restart (above): read from the code only.
- That Steam-launched apps carry
SteamLaunch AppId=N: from Steam on other Linux systems; no VR game has run on the Frame to confirm it.
Build
hands/build.sh builds in the dev container on the Frame, into hands/build/, with hands/Makefile. The first build fetches ncnn at a pinned tag (NCNN_TAG in the Makefile) and builds it into hands/build/ncnn, which takes a few minutes; NCNN=DIR points at an ncnn install already built instead. ft-camd is linked statically, because it runs on the host, which has an older glibc than the container.
Known issues
- The side cameras could come out swapped (fixed 2026-10-05, see "Which camera is which"). If they still do, ft-hands corrects it from the hands (
HANDS_SWAP_SIDES=auto, the default): until it has seen about 1-2 s of hands in both namings' reach, the cutouts may sit beside the hands. ft-camd's log saysbound by VIDIOC_QUERYBUFfor each camera when its buffers were matched exactly. - The colour cameras can't be used while the headset is worn. The colour module then writes only a half-size image into the top-left quarter of its buffers, and ft-camd drops those frames. So the service runs the mono cameras only, and tracking in bright light, where the mono cameras see dark hands, doesn't get the colour pair's help.
- The colour calibration mapping isn't settled. Which colour camera is
passthrough_left(HANDS_COLOR_LEFT) and how the module's crop applies (HANDS_COLOR_CROP) still needtools/check_color.pyon a recording with a lit, textured view. - Depth when one camera loses the hand. A hand seen in one camera drifts 10% per update toward the one-camera depth guess (
kMonoDepthGain, 0.1, intrack/tracker.cpp). In the 2026-09-30 replays that was worse than keeping the last distance (see "3D" above). A smaller gain, such as 0.02, is the next thing to try. - Pinches aren't reliable enough for everyday use yet. That's why hand tracking is off by default (
HANDS_MODE=off: onlyft-handsctl onstarts it), andPOINTER_HANDSis 0 by default. - SteamVR can leave the upper cameras and the IR light off after a wake, and then no hands are found. See "Camera check" above:
camcheck.pytells, the hand recorder won't start a session, and the fix is a SteamVR restart or a reboot. - The skeleton (2026-10-08) is tested on recordings only, not in the headset: the cutouts now follow it (version 1's points and capsules), so check them there;
HANDS_SKELETON=0brings back the filtered landmarks. - Floating windows don't get hand cutouts or glows. Their panels show crops of the client buffer, which the cutouts' side-by-side buffer doesn't match (
screens/vr.cpp,UpdateCutouts). Near input works on them. - The matte and near input are tested offline only (2026-10-08):
HEADSET-TESTS.mdhas the first headset tests.