frametop

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:

Two programs, each a user service that stops when SteamVR does:

They don’t start with SteamVR. hands/run.sh install builds them, gives ft-camd its capabilities, installs both services disabled, and links hands/ft-handsctl into ~/.local/bin. Then ft-handsctl on starts hand tracking and ft-handsctl off stops it. install.sh doesn’t install it.

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 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 and grips, 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 disabled
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:

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 ft-screens (screens/handcut.cpp)
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:

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:

Options:

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):

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:

Gestures (/run/user/UID/frametop-hands/gestures, include/fh_gestures.h), for the pointer helper:

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

3D

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).

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.

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.

Settings in ~/.config/frametop.conf:

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.

hands/build/ft-handreplay ~/.local/share/frametop/hands/rec-20260929-120000 --cost --oracle 10 --timeline /tmp/tl.txt

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.

To try the hand cutouts without restarting the desktop, screens/build/ft-handtest [--distance m] [--width m] [--seconds s] (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.

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 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:

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:

Verified, and what’s a guess

Verified, from the XRService logs of 2026-10-01 and 2026-10-02 and the running system:

Guesses, not tested:

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