# Hand recorder: design (Phase 1 of the hands plan) The hand recorder guides a person through recording their hands with the headset's cameras. It saves the recordings as files, lets them review and delete anything, then exports a package to contribute to the open hand dataset. Recordings never leave the headset unless the person uploads them. The plan this belongs to is `~/Desktop/Projects/frame-hands/notes/hands-plan.md` (on the maintainer's Frame). In short, the dataset trains a small hand model for Frametop's hand cutouts. ## Parts | Part | What it does | |---|---| | `hands/rec/panel/ft-handpanel.cpp` (C++, dev container) | The headset panel: a SteamVR overlay fixed to the head that shows the prompts. It also places the "touch the dot" target in the room and logs head and controller poses. Driven over `@ft_handpanel`. | | `hands/rec/session.py` (Python, no Qt) | The session runner. It reads `script.json`, starts and stops the recordings, and drives the panel. It reads the live hands file for feedback and writes each take's files. It also runs from the command line (`--dry-run`) for testing. | | `hands/rec/script.json` | The guided script: sections, prompts, timings. | | `hands/rec/poses/` | The pose pictures, `poses.json` and its PNGs (below). | | `hands/rec/takes.py` (Python, no Qt) | Reads sessions and takes from disk: frame sets for review, deleted ranges, export (compress, strip, manifest, checksums). | | `hands/rec/ft_handrec.py` + `main.qml` (PySide6 + Kirigami, dev container) | The desktop window: consent, the before-you-start checklist, the session controls, review, export and upload instructions. | | `hands/rec/ft-handrec` | The host launcher, like `input-settings/ft-input-settings`. | | `hands/rec/build.sh` | Builds `ft-handpanel` into `hands/rec/build/`, like `gaze/build.sh`. | | `hands/rec/CONSENT.md`, `hands/rec/UPLOAD.md` | The texts the window shows. | | ft-hands `--record-hz N` (done) | Records at most N frame sets a second. The recorder uses 10. | | `hands/camcheck.py` (shared, standard library) | Are all four mono cameras running? The recorder runs it before a session and when a step sees no hands (below; `hands/README.md`, "Camera check"). | Every part runs in the dev container, as ft-hands and Input Settings do. The host Python isn't used: it lacks PySide6 and zstd. `setup/dev-container.sh` gains `zstd`. ## Processes during a session - **ft-camd** publishes the camera ring. If it isn't running, the session starts it as the transient user unit `frametop-handrec-camd.service`, the way `hands/ft-cutouts` starts `frametop-cutouts-camd.service` (needs `hands/build/ft-camd` with capabilities: `hands/run.sh caps`). An ft-camd already running from ft-cutouts or ft-handsctl is used as it is. - **A tracking ft-hands** gives feedback through the hands file: which hands are seen, the palm's distance, the index tip. If none is running, the session starts `ft-hands --no-gestures --status 0` (unit `frametop-handrec-hands.service`). An ft-hands already running is used as it is. - **A recording ft-hands** runs once per recording part: `ft-hands --record-only --record DIR --record-for SECONDS --record-hz 10 --status 0 --sides auto|0|1` (below, "Side cameras"). It runs as a plain child process of the session, ended with SIGTERM when the part ends. SIGTERM ends ft-hands' loop, and `Recorder` writes out its queue when it's destroyed. In step mode (below) a part is one step's countdown and hold, so a take has one part per step (about 40 in the hand poses); in auto mode a take is one part, plus one more after each pause. `--record-for` is only a safety net. - Why a process per part rather than one kept alive and paused: measured in the dev container with `ft-ringplay`'s ring (2026-10-02), `ft-hands --record-only` writes its first set 16-27 ms after it starts and ends 4-6 ms after SIGTERM, so a new part costs nothing the 3 s countdown doesn't cover. Every reader already takes parts in order (`takes.py`, `validate.py` through the export's single stream, the labeller's `fhl_io.py`, numbering `sets-10.bin` after `sets-9.bin`), ft-hands needs no new control, and nothing is written while a step waits. Before the hold starts the session checks that the part has written a set (`Recorder.has_data`, up to 3 s more), so the hold is recorded from its first frame. - **Side cameras.** ft-camd can name the two side cameras the wrong way round (hands/README.md, "Which camera is which"). The tracking ft-hands decides from the hands within about 2 s of them being in view (`HANDS_SWAP_SIDES=auto`, hands/track/sides.h) and publishes that in `/run/user/UID/frametop-hands/sides.json`. The session reads it (`sides.py`, `read_live`) and stores it in session.json `"sides"`. Each later part is recorded named right (`--sides 1` or `0`). Parts recorded before the decision use ft-camd's names (`--sides auto`; a record-only ft-hands can't tell), and readers rename them (`sides.py`). Each part's `names_swapped` goes into take.json `"parts"`. Without a tracking ft-hands nothing decides: `"swapped": null`, the names stay as recorded, and the maintainer's check (`hub_review check`, check_sides on a few sets per take) tells. `takes.py sides SESSION --set swapped|named` records a decision by hand. - **ft-handpanel** runs as a child process with `--watch-stdin`. It shows the panel and logs poses during each take. - **The headset button's reader** is a thread of the session (`ButtonReader`, below), not a process. Test hooks: - `--ring PATH` goes to both ft-hands (`ft-ringplay` publishes a recording there, so the whole flow runs without the headset). - `--no-start` uses only what's already running. - `--dry-run` runs no processes and only prints the panel commands, with timing sped up by `--speed X`. - `--next-after S` presses Next by itself after S seconds of waiting (real time), so a step-mode session runs unattended. Lines on stdin steer it too: `n` or an empty line is Next, `p` pause or resume, `r` redo, `s` skip the section, `q` stop. - `--no-headset-button` leaves the headset's button alone (`ft-handrec --no-headset-button` too). `--button-device PATH` reads it from PATH instead, an event device or a FIFO of `input_event` structs, also in a dry run: a simulated button for tests. - `--auto` runs the timed flow; `--poses DIR` takes the pose pictures from DIR; `--plan` prints the sections, their steps and length. - `--ignore-cameras` (`ft-handrec --ignore-cameras` too) starts even when the camera check fails. A dry run and `--ring` skip the check by themselves. ## Files ``` ~/.local/share/frametop/hands/contrib/ profile.json consent and contributor id (below) sessions// (a second session started in the same second gets -2, and so on) session.json the session: checklist answers, lighting, versions, mode, takes calibration.json /persist/xrservice.json with identifying fields removed (below) device.json the rig's pose in the CAD frame from /persist/device_config.json (below) takes/-
/ sets.bin ft-hands' recording (FHSET01, hands/track/record.h), 10 sets/s: the first part sets-2.bin, sets-3.bin, ... the next parts (step mode: one per step; any mode: after a pause) prompts.jsonl what the person was asked to do, when (below) poses.jsonl head and controller poses from ft-handpanel (below) take.json {"section", "title", "started_ns", "ended_ns", "status": "complete"|"stopped"|"skipped", "deleted": [[from_ns, to_ns], ...], "notes": "", "parts": {"sets.bin": {"names_swapped": false}, "sets-2.bin": {...}}} exports// what export writes (below) ``` All `_ns` times are CLOCK_MONOTONIC nanoseconds, the clock of `dqbuf_ns` in sets.bin and of `capture_ns` in the hands file. sets.bin's `capture_ns` is the camera clock (CLOCK_MONOTONIC_RAW). ### profile.json ```json {"schema": 1, "contributor": "", "consent": {"version": "2026-10-02", "accepted": "", "adult": true}, "optional": {"handedness": "right|left|both|", "notes": ""}} ``` No name, email, or account. The contributor id is random, so several sessions from one person can be held out together in evaluation. Withdrawal also goes by that id. ### session.json ```json {"schema": 1, "tool": "ft-handrec ", "started": "", "contributor": "", "lighting": {"chosen": "dim|room|daylight|indoor", "source": "measured|picked", "measured": "indoor|daylight|", "ambient_ir": 0.0, "ring": {"": {"mean": 0.0, "dark_mean": 0.0}}}, "checklist": {"objects": ["pencil", "phone", "cup", "keyboard", "mouse", "gamepad", "small"], "own_objects": ["..."], "controllers": "straps|none", "sleeves": "short|long|", "rings": false, "watch": false, "notes": ""}, "device": {"steamos": "", "steamvr": "", "cameras": [{"name", "width", "height"}]}, "mode": "step|auto", "quick": false, "shuffle": {"seed": 123, "sweeps": {"pose-sweeps": [{"id", "hands", "cues"}]}}, "takes": ["01-hand-size", "..."], "camera": {"status": "ok|unknown|degraded", "reason": "..."}, "stop_reason": "...", "sides": {"swapped": true|false|null, "decided_by": "auto|config|option|manual", "state": "decided|confirmed|...", "evidence": {"as_named": 0, "swapped": 10, "seconds": 1.8, "miss_mm": [-1, 4.2], "probes": 30, "found": 10}, "decided_at": "", "decided_ns": 0}} ``` `sides`: whether ft-camd's side camera names were backwards during the session (`swapped`), as the live tracker decided it (above, "Side cameras"); `null` while nobody knows. A part's names are right when its take.json `names_swapped` equals `swapped`. Parts without a `parts` entry were recorded with ft-camd's names. Sessions from before this have no `sides`. `camera` is the camera check's verdict at the start (no paths or log lines: those go to `session.log`). `stop_reason` is there when a session was stopped at the no-hands screen, with the check's result. A session started before step mode existed has no `mode`: it ran as `auto`. `quick` and `shuffle` (below, "Sweeps" and "Quick round") are missing from sessions before the sweeps. ### calibration.json This is a copy of `/persist/xrservice.json` (`/run/host/persist/` in the container). Keep the cameras' intrinsics and extrinsics, and drop anything that identifies the unit: keys containing `serial`, `sn`, `uuid`, `mac` or `id`, or values that look like serial numbers. List what was removed in `session.json` (`"calibration_removed": [...]`), so a reviewer can check it. ### device.json The head frame needs the rig's pose in the CAD frame, from `/persist/device_config.json`. Only two of its keys are kept, `cv.cad_from_cal` (Cam0 in the CAD frame) and `head` (the head in CAD), in the shape the labeller in frame-hands `train/label` reads, as its `cut.py` writes it: ```json {"cv": {"cad_from_cal": {"method": "FrontAndUpperCamPositions", "plus_x": [x, y, z], "plus_z": [x, y, z], "position": [x, y, z]}}, "head": {"plus_x": [x, y, z], "plus_z": [x, y, z], "position": [x, y, z]}} ``` The rest of that file identifies the unit (serial number, display EDID) and is never copied. The two kept keys go through `strip_calibration` as well, and anything it removes is listed in `calibration_removed` as `device.json:`. Sessions recorded before device.json existed have none: they still validate, with a warning, and the labeller falls back to another unit's pose. ### prompts.jsonl One JSON object per line: ```json {"t": 123, "event": "take", "section": "static-poses", "take": "03-static-poses"} {"t": 123, "event": "prompt", "id": "static-poses/fist/left/near", "text": "...", "hands": "left|right|both|none", "pose": "fist", "distance": "near|mid|far|", "position": "centre|left|right|up|down|", "object": "", "controller": false} {"t": 123, "event": "target", "id": "touch/3", "head": [x, y, z], "room": [x, y, z], "state": "show|hold|done|timeout"} {"t": 123, "event": "bar", "target": 0.0} {"t": 123, "event": "feedback", "left": true, "right": false, "palm_m": [0.0, 0.0]} {"t": 123, "event": "pause"} {"t": 123, "event": "resume"} {"t": 123, "event": "ready", "id": "static-poses/fist/left/near", "seconds": 3} {"t": 123, "event": "wait"} {"t": 123, "event": "redo", "id": "static-poses/fist/left/near", "from": 123, "to": 123} {"t": 123, "event": "nohands", "id": "hand-size/flat/both", "reads": 120, "published": 118} {"t": 123, "event": "end", "status": "complete|stopped|skipped"} ``` `feedback` is written about twice a second while recording. It's the live tracker's view, kept for later checks; it's not a label. A prompt holds from its `prompt` until the next `prompt`, `ready`, `wait` or `end`. A step in step mode reads: ``` ready Next pressed: recording part N starts, the 3-2-1 countdown runs (recorded, no label) prompt the hold: its labels start here (bar, target, feedback, pause/resume during the hold) wait the hold is over: no labels from here; part N stops ``` The touch-the-dot targets after the first follow straight on: no `ready` or `wait` between them. `redo` marks a try done again (R): `from` is that step's `ready` (or its `prompt` if it had none), `to` its end. Its prompt is skipped; the sets stay. In auto mode there's no `ready` or `wait`, and the intro is a recorded prompt `
/intro`. `nohands` marks the first hand-size step stopped because no hand was seen (below, "Camera check"); a `redo` over the same range follows it, so the try gets no labels. A sweep step (below, "Sweeps") has a `prompt` per cue, each with its `pose`, `"cue": true` and `"step"`, the step's id; its `ready`, `wait` and `redo` carry the step's id. Readers that knew only `prompt` and `end` keep working, but they'd give the countdown to the step before: `hub_review.py` (frame-hands `train/hub`) shows it as "(countdown)", redone prompts as "(redone)" and cues as "[pose] text"; `FORMAT.md` in the dataset repo has the rules. ### poses.jsonl ft-handpanel writes one line per sample, 250 a second, from `GetDeviceToAbsoluteTrackingPose(TrackingUniverseStanding, 0)`: ```json {"t": 123, "hmd": {"m": [12 floats, row-major 3x4], "r": 200, "ok": true}, "left": {"m": [...], "r": 200, "ok": true}, "right": null} ``` `r` is `ETrackingResult` (200 = Running_OK, 201 = Running_OutOfRange, and so on). `left` and `right` are the devices holding those controller roles, or null. No device serials are logged. ## The panel (`ft-handpanel`) - **Placement.** A SteamVR overlay fixed to the head, like `gaze/panel/ft-gazepanel.cpp`: key `frametop.handpanel`, sort order 250. It sits 1.2 m ahead, centred 12 degrees above straight ahead, so the hands stay clear below it. It's 36 degrees wide, 4:3, 1024x768 pixels, dim and see-through. It's drawn on the CPU with stb_truetype (and stb_image for the pose pictures, PNG only, the same pinned stb commit) into three shared DMA-BUFs SteamVR imports once, as ft-gazepanel does, and is drawn again only when something changes. - **Layout.** The title and step on top (a red "Rec" by the step while recording), a rule. With a pose picture or a diagram, a 14-degree column on the left holds the picture (or the flipped copy and the picture side by side) and the where-to diagram under it; the text takes the right. The text column holds the instruction, the orange note, the big countdown ("3", "2", "1", then "Hold" or "Go") and the cyan action line ("Ready? Press Space or click Next"), centred together. At the bottom: the near/far bar, the hand chips, the time-left bar and the key hints. - **Socket.** Abstract unix datagram `@ft_handpanel` (`--socket NAME`). A sender with an address gets `ok ...` or `error ...`. - **Options:** `--watch-stdin` (quit when stdin closes), `--socket NAME`, `--distance M`, `--no-vr`. `--no-vr` makes no SteamVR connection and prints each picture's text to stdout: for testing without a headset. Commands (UTF-8; `|` starts a new line in text): | Command | Effect | |---|---| | `show` / `hide` | The panel. A "show" makes it visible with its first picture. | | `title ` | Big line at the top. | | `step ` | Small line at the top right, e.g. `Section 3 of 11`. | | `text ` | The instruction, large, wrapped to the panel's width, centred. | | `note ` | An orange line under the instruction: a warning ("I can't see your left hand"). Empty clears it. | | `countdown <0..1>` / `countdown off` | A thin bar along the bottom: the share of this prompt's time left. | | `hands ` | Two chips, "Left hand" and "Right hand", each `seen` (green), `lost` (orange) or `off` (hidden). | | `bar [near label] [far label]` / `bar off` | The near/far bar for the push out and back: a horizontal track with a target marker and the hand's current position. | | `paused on` / `paused off` | A "Paused" overlay over the picture. | | `image [mirror\|both]` / `image off` | The pose picture, a PNG (below), in the left column. `mirror` flips it (a left hand); `both` draws a flipped copy on its left. A file that can't be read: `error ...` and no picture. | | `where ` / `where off` | The where-to diagram under the picture: a 3x3 front view with the asked cell lit (`centre`, `left`, `right`, `up`, `down`, and the push sections' `chest`, `desk`, `eye`), and a side view of the head and three marks for `near` ("Close"), `mid` ("Halfway out") and `far` ("Arm out"). `-` leaves that half out. | | `action ` | The cyan line under the instruction (empty clears it). | | `big ` | Large cyan text under the instruction: the countdown (empty clears it). | | `keys ` | The faint key hints along the bottom. | | `rec on` / `rec off` | The red "Rec" by the step line. | | `strip \|\|