Hands: step mode and pose pictures for the hand recorder

The first real session moved on every 5 s with text only, too fast to follow.
Each step now waits for Next (Space or the window's button), counts down 3-2-1
while recording, then holds. P pauses, R redoes a step, S skips a section;
"Advance by itself" (--auto) keeps the old timed flow. Nothing records while
a step waits: each step is its own recording part.

The panel and the window show a picture of each pose (hands/rec/poses,
generated by make_poses.py from a parametric hand, MIT) and a diagram of where
to hold the hands and how far out. prompts.jsonl gains ready, wait and redo
events; session.json gains mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-10-02 16:34:45 -06:00
1 parent 1fb6e7a38e
commit 35196e2955
49 files changed
+2896 -194

No files matched your search

+69 -13
View File
@@ -11,6 +11,7 @@ The plan this belongs to is `~/Desktop/Projects/frame-hands/notes/hands-plan.md`
| `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/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/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/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/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.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/ft-handrec` | The host launcher, like `input-settings/ft-input-settings`. |
@@ -24,13 +25,16 @@ Every part runs in the dev container, as ft-hands and Input Settings do. The hos
- **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. - **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 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 take: `ft-hands --record-only --record TAKE_DIR --record-for SECONDS --record-hz 10 --status 0`. It runs as a plain child process of the session, ended with SIGTERM if the take stops early. SIGTERM ends ft-hands' loop, and `Recorder` writes out its queue when it's destroyed. - **A recording ft-hands** runs once per recording part: `ft-hands --record-only --record DIR --record-for SECONDS --record-hz 10 --status 0`. 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.
- **ft-handpanel** runs as a child process with `--watch-stdin`. It shows the panel and logs poses during each take. - **ft-handpanel** runs as a child process with `--watch-stdin`. It shows the panel and logs poses during each take.
Test hooks: Test hooks:
- `--ring PATH` goes to both ft-hands (`ft-ringplay` publishes a recording there, so the whole flow runs without the headset). - `--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. - `--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`. - `--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.
- `--auto` runs the timed flow; `--poses DIR` takes the pose pictures from DIR; `--plan` prints the sections, their steps and length.
## Files ## Files
@@ -38,11 +42,12 @@ Test hooks:
~/.local/share/frametop/hands/contrib/ ~/.local/share/frametop/hands/contrib/
profile.json consent and contributor id (below) profile.json consent and contributor id (below)
sessions/<YYYYMMDD-HHMMSS>/ (a second session started in the same second gets -2, and so on) sessions/<YYYYMMDD-HHMMSS>/ (a second session started in the same second gets -2, and so on)
session.json the session: checklist answers, lighting, versions, takes session.json the session: checklist answers, lighting, versions, mode, takes
calibration.json /persist/xrservice.json with identifying fields removed (below) 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) device.json the rig's pose in the CAD frame from /persist/device_config.json (below)
takes/<NN>-<section>/ takes/<NN>-<section>/
sets.bin ft-hands' recording (FHSET01, hands/track/record.h), 10 sets/s 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) prompts.jsonl what the person was asked to do, when (below)
poses.jsonl head and controller poses from ft-handpanel (below) poses.jsonl head and controller poses from ft-handpanel (below)
take.json {"section", "title", "started_ns", "ended_ns", "status": "complete"|"stopped"|"skipped", take.json {"section", "title", "started_ns", "ended_ns", "status": "complete"|"stopped"|"skipped",
@@ -69,9 +74,11 @@ No name, email, or account. The contributor id is random, so several sessions fr
"checklist": {"objects": ["pencil", "phone", "cup", "keyboard", "mouse", "gamepad", "small"], "own_objects": ["..."], "checklist": {"objects": ["pencil", "phone", "cup", "keyboard", "mouse", "gamepad", "small"], "own_objects": ["..."],
"controllers": "straps|none", "sleeves": "short|long|", "rings": false, "watch": false, "notes": ""}, "controllers": "straps|none", "sleeves": "short|long|", "rings": false, "watch": false, "notes": ""},
"device": {"steamos": "<VERSION_ID from /etc/os-release>", "steamvr": "<version if known>", "cameras": [{"name", "width", "height"}]}, "device": {"steamos": "<VERSION_ID from /etc/os-release>", "steamvr": "<version if known>", "cameras": [{"name", "width", "height"}]},
"takes": ["01-hand-size", "..."]} "mode": "step|auto", "takes": ["01-hand-size", "..."]}
``` ```
A session started before step mode existed has no `mode`: it ran as `auto`.
### calibration.json ### 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. 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.
@@ -100,10 +107,24 @@ One JSON object per line:
{"t": 123, "event": "feedback", "left": true, "right": false, "palm_m": [0.0, 0.0]} {"t": 123, "event": "feedback", "left": true, "right": false, "palm_m": [0.0, 0.0]}
{"t": 123, "event": "pause"} {"t": 123, "event": "pause"}
{"t": 123, "event": "resume"} {"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": "end", "status": "complete|stopped|skipped"} {"t": 123, "event": "end", "status": "complete|stopped|skipped"}
``` ```
`feedback` is written about twice a second. It's the live tracker's view, kept for later checks; it's not a label. `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 `<section>/intro`. 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)" and redone prompts as "(redone)"; `FORMAT.md` in the dataset repo has the rules.
### poses.jsonl ### poses.jsonl
@@ -118,7 +139,8 @@ ft-handpanel writes one line per sample, 250 a second, from `GetDeviceToAbsolute
## The panel (`ft-handpanel`) ## 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 into three shared DMA-BUFs SteamVR imports once, as ft-gazepanel does, and is drawn again only when something changes. - **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 ...`. - **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. - **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.
@@ -135,20 +157,39 @@ Commands (UTF-8; `|` starts a new line in text):
| `hands <left> <right>` | Two chips, "Left hand" and "Right hand", each `seen` (green), `lost` (orange) or `off` (hidden). | | `hands <left> <right>` | Two chips, "Left hand" and "Right hand", each `seen` (green), `lost` (orange) or `off` (hidden). |
| `bar <target 0..1> <current 0..1 or -1> [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. | | `bar <target 0..1> <current 0..1 or -1> [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. | | `paused on` / `paused off` | A "Paused" overlay over the picture. |
| `image <path> [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 <position\|-> <distance\|->` / `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 <text>` | The cyan line under the instruction (empty clears it). |
| `big <text>` | Large cyan text under the instruction: the countdown (empty clears it). |
| `keys <text>` | The faint key hints along the bottom. |
| `rec on` / `rec off` | The red "Rec" by the step line. |
| `target <x> <y> <z> [show\|hold <0..1>\|done]` / `target off` | The touch target: a small sphere-like dot about 2 cm across, in its own overlay (`frametop.handpanel.target`). The point is in the head frame (metres, +x right, +y up, -z forward). The first `target` with a new point places it in the room with the current HMD pose, and it stays there. Later commands with the same point change only the state. `hold` draws a filling ring, `done` turns it green. Reply: `ok <room x> <room y> <room z>`. | | `target <x> <y> <z> [show\|hold <0..1>\|done]` / `target off` | The touch target: a small sphere-like dot about 2 cm across, in its own overlay (`frametop.handpanel.target`). The point is in the head frame (metres, +x right, +y up, -z forward). The first `target` with a new point places it in the room with the current HMD pose, and it stays there. Later commands with the same point change only the state. `hold` draws a filling ring, `done` turns it green. Reply: `ok <room x> <room y> <room z>`. |
| `poses start <path>` / `poses stop` | Log poses to the path (appending, `poses.jsonl` format above) from a thread at 250 Hz, until stopped. | | `poses start <path>` / `poses stop` | Log poses to the path (appending, `poses.jsonl` format above) from a thread at 250 Hz, until stopped. |
| `devices` | Reply: `ok hmd <r> left <r or -> right <r or ->`, the current `ETrackingResult` values (`-` for no device in that role). | | `devices` | Reply: `ok hmd <r> left <r or -> right <r or ->`, the current `ETrackingResult` values (`-` for no device in that role). |
| `head` | Reply: `ok <12 floats>`, the current HMD pose (standing universe). | | `head` | Reply: `ok <12 floats>`, the current HMD pose (standing universe). |
| `ping` | `ok shown` or `ok hidden`. | | `ping` | `ok shown` or `ok hidden`. |
It quits on SteamVR's quit event, as ft-gazepanel does. It quits on SteamVR's quit event, as ft-gazepanel does. `--no-vr --dump DIR` writes each picture to `DIR/panel.pam`, to check the layout without a headset.
## The pose pictures (`poses/`)
`poses/poses.json` maps the script's `pose` ids to pictures: `{"<pose>": {"file": "<name>.png", "two_hands": false, "caption": "..."}}`. Each PNG is RGBA, square (512x512), drawn as the wearer sees it: a right hand, unless `two_hands` (then it shows both). How a prompt shows it (`session.pose_view`):
| Prompt's `hands` | Picture |
|---|---|
| `right` (and `any`, `none`, empty) | as drawn |
| `left` | flipped left to right (`image ... mirror`) |
| `both`, not `two_hands` | a flipped copy on the left, the picture on the right (`image ... both`) |
| anything, `two_hands` | as drawn |
A pose with no entry, or whose file is missing, shows no picture: the text alone. The session reads `poses.json` when it starts. The window shows the same picture (QML `Image.mirror`), its caption, and the same diagram.
## The script (`script.json`) ## The script (`script.json`)
```json ```json
{"version": 1, {"version": 1,
"sections": [ "sections": [
{"id": "hand-size", "title": "Hand size", "requires": [], "intro": "text shown for 4 s before the first prompt", {"id": "hand-size", "title": "Hand size", "requires": [], "intro": "text shown before the first prompt: 4 s, or until Next", "go": "Hold",
"prompts": [{"text": "...", "seconds": 8, "hands": "both", "pose": "flat", "distance": "near"}]}, "prompts": [{"text": "...", "seconds": 8, "hands": "both", "pose": "flat", "distance": "near"}]},
{"id": "objects", "title": "Things you hold", "requires": ["objects"], "for_each": "object", {"id": "objects", "title": "Things you hold", "requires": ["objects"], "for_each": "object",
"prompts": [{"text": "Pick up the {object} and use it the way you normally would.", "seconds": 15, "hands": "both", "object": "{object}"}]}, "prompts": [{"text": "Pick up the {object} and use it the way you normally would.", "seconds": 15, "hands": "both", "object": "{object}"}]},
@@ -160,11 +201,26 @@ It quits on SteamVR's quit event, as ft-gazepanel does.
``` ```
- `requires`: `objects` (at least one object ticked), `controllers` (straps ticked). A section whose requirements aren't met is skipped and logged. - `requires`: `objects` (at least one object ticked), `controllers` (straps ticked). A section whose requirements aren't met is skipped and logged.
- `go`: the word the countdown ends on, "Go" unless set ("Hold" for the still poses).
- `kind`: - `kind`:
- `prompts` (the default): each prompt shows for its `seconds` with a countdown. - `prompts` (the default): each prompt shows for its `seconds` with a countdown.
- `targets`: each target shows until the live index tip is within 3 cm of it for `hold_s`, or `timeout_s` passes. - `targets`: each target shows until the live index tip is within 3 cm of it for `hold_s`, or `timeout_s` passes.
- `bar`: the target marker sweeps near to far and back, `reps` times per height, at `period_s` per sweep. The current marker follows the live palm distance. - `bar`: the target marker sweeps near to far and back, `reps` times per height, at `period_s` per sweep. The current marker follows the live palm distance.
- Each section is one take, one recording. Prompts within it are marked in `prompts.jsonl`. - Each section is one take. Prompts within it are marked in `prompts.jsonl`.
### Step mode (the default) and auto mode
The first in-headset session (2026-10-02) went too fast: each prompt advanced after 4-8 s, before there was time to read it and find the hand shape. So by default every step waits:
1. **Ready.** The panel shows the step: section title, "step N of M", the instruction, the pose picture, the where-to diagram, and "Ready? Press Space or click Next". The hand chips show which hands are seen, with no warnings yet. It waits as long as it takes, and nothing records.
2. **Countdown.** Next starts a new recording part and a "ready" event, and the panel counts 3, 2, 1 (big), recorded so the hold is captured from its first frame. In the push sections the bar sits at near meanwhile.
3. **Hold.** The `prompt` event, the section's word ("Hold" or "Go") and the time-left bar for the prompt's seconds (or the bar's sweeps, or the targets). Then a `wait` event and the part stops.
Steps that wait: each prompt, each bar height, and the first touch-the-dot target (the others follow straight on, as each waits for the touch anyway). Before a section, one screen shows the section's intro (with its `before` text, such as putting on the controllers) and waits for Next too; the welcome screen as well. The take starts with the section's first countdown, so a section skipped at its intro leaves no take. A pause in a hold works as before (the part stops; resume starts the next one).
Auto mode ("Advance by itself" on the checklist page, `session.py --auto`) is the old timed flow: the welcome, the between and before screens, the intro (recorded) and each prompt for its seconds, one recording per take. R still works there: it restarts the step running.
Holds are 5 s for still poses (4 s counting fingers), 8-10 s for movements. The core session (no objects, no controllers) records about 11 min in 62 steps; with 5 s of reading a step that's about 16 min. Everything ticked: about 16.5 min recorded in 86 steps. The window and `session.py --plan` give these (`plan_summary`); in step mode they leave the reading time out and say so.
The sections, in this order (see the plan): The sections, in this order (see the plan):
1. hand size 1. hand size
@@ -189,9 +245,9 @@ Before section 8: "Put on both controllers and tighten the straps". Before secti
### Controls ### Controls
- The window has Start, Pause/Resume, Skip section and Stop. Space pauses and Esc stops while the window has focus. - The window has Start, a big Next (while a step waits), Pause/Resume, Redo step, Skip section and Stop. While it has focus: Space is Next, P pauses or resumes, R redoes, S skips the section, Esc stops. The panel's bottom line and the window list them. The window also shows the step's picture, diagram, countdown and "Hold".
- The panel's text says what to do: "Pause: Space in the Hand recorder window". - **R (redo).** During a step's countdown or hold: that step starts again from its ready screen. At a step's ready screen: the step before it (in this section) goes again. Either way a `redo` event marks the range of the try being redone, so its labels are skipped; the sets stay, to delete in review if wanted.
- A pause stops the take's recording and starts it again on resume as the next part of the same take (`sets.bin` is appended to as a second recording file `sets-2.bin`, and so on). takes.py reads all parts in order. - A pause stops the take's recording and starts it again on resume as the next part of the same take (`sets-2.bin`, and so on). takes.py reads all parts in order. A pause while a step waits only shows "Paused"; a Next pressed while paused doesn't count.
## Review and export (`takes.py`, the window) ## Review and export (`takes.py`, the window)
@@ -230,7 +286,7 @@ Before section 8: "Put on both controllers and tighten the straps". Before secti
Errors get a plain explanation: not logged in, a token Hugging Face rejects (401), a token that can't open a pull request (403), terms not accepted, dataset not found, network errors. `--dry-run` does everything except the network calls and the record, and lists what it would upload. Errors get a plain explanation: not logged in, a token Hugging Face rejects (401), a token that can't open a pull request (403), terms not accepted, dataset not found, network errors. `--dry-run` does everything except the network calls and the record, and lists what it would upload.
- **The page** shows the login (`whoami`, with "Check again"). If nobody is logged in, it explains how to run `distrobox enter dev -- hf auth login` in Konsole with a write token: the token goes only into that terminal. The page then has Upload and Cancel, the phase with a progress bar (a share while the export is checked, a sweep while it's sent, as `huggingface_hub` reports no progress), and the pull request's link when it's done. If this export was uploaded before, the page says so, and uploading it again asks first. A stale export can't be uploaded. - **The page** shows the login (`whoami`, with "Check again"). If nobody is logged in, it explains how to run `distrobox enter dev -- hf auth login` in Konsole with a write token: the token goes only into that terminal. The page then has Upload and Cancel, the phase with a progress bar (a share while the export is checked, a sweep while it's sent, as `huggingface_hub` reports no progress), and the pull request's link when it's done. If this export was uploaded before, the page says so, and uploading it again asks first. A stale export can't be uploaded.
- **While the texts are drafts**, Upload stays off unless `FT_HANDREC_ALLOW_UPLOAD=1`, so the maintainer can rehearse against a private test repo. `FT_HANDREC_DATASET` overrides `HF_DATASET`. `ft-handrec --hub-dry-run` makes Upload a dry run: no network, so it isn't held back by the drafts. - **While the texts are drafts**, Upload stays off unless `FT_HANDREC_ALLOW_UPLOAD=1`, so the maintainer can rehearse against a private test repo. `FT_HANDREC_DATASET` overrides `HF_DATASET`. `ft-handrec --hub-dry-run` makes Upload a dry run: no network, so it isn't held back by the drafts.
- **Rehearsal: `hands/rec/rehearse.sh [--repo ID]`** runs it all without the headset, in the dev container, in one `frame-job --local` scope when frame-job is installed. `ft-ringplay` plays 30 s of a recording into a ring in `/run/user/UID`. A tracking ft-hands that's already running is used, or one is started on that ring. `ft-handpanel --no-vr` stands in for the panel. `session.py --no-start` records a two-section test script, about 11 s and about 200 MB once exported. Then `takes.py` exports, `validate.py` checks, and `hub.py` uploads: a dry run by default, or for real to `--repo ID` with `FT_HANDREC_ALLOW_UPLOAD=1`. It prints a summary, deletes its temporary folders (camera images of a room) and stops everything it started, Ctrl+C included. The `--no-vr` panel logs no poses, so `poses.jsonl` is missing there (a warning). - **Rehearsal: `hands/rec/rehearse.sh [--repo ID]`** runs it all without the headset, in the dev container, in one `frame-job --local` scope when frame-job is installed. `ft-ringplay` plays 30 s of a recording into a ring in `/run/user/UID`. A tracking ft-hands that's already running is used, or one is started on that ring. `ft-handpanel --no-vr` stands in for the panel. `session.py --no-start --next-after 0.3` records a two-section test script in step mode, three parts of 6 s (countdown and hold), about 360 MB once exported. Then `takes.py` exports, `validate.py` checks, and `hub.py` uploads: a dry run by default, or for real to `--repo ID` with `FT_HANDREC_ALLOW_UPLOAD=1`. It prints a summary, deletes its temporary folders (camera images of a room) and stops everything it started, Ctrl+C included. The `--no-vr` panel logs no poses, so `poses.jsonl` is missing there (a warning).
## Licensing and consent (texts in `CONSENT.md`) ## Licensing and consent (texts in `CONSENT.md`)
+3 -2
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build the hand recorder's headset panel ft-handpanel in the dev container on the Frame # Build the hand recorder's headset panel ft-handpanel in the dev container on the Frame
# (hands/rec/build/; it also runs there). Like gaze/build.sh: the pinned public OpenVR header # (hands/rec/build/; it also runs there). Like gaze/build.sh: the pinned public OpenVR header
# (the DMA-BUF import is newer than the header shipped with SteamVR's samples) and stb_truetype # (the DMA-BUF import is newer than the header shipped with SteamVR's samples), stb_truetype
# for the text. # for the text and stb_image (PNG only) for the pose pictures, at the same stb commit.
set -euo pipefail set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
"$root/scripts/sync.sh" >/dev/null "$root/scripts/sync.sh" >/dev/null
@@ -11,6 +11,7 @@ openvr=v2.15.6
[ -f build/include/openvr-$openvr ] || { curl -fsSL "https://raw.githubusercontent.com/ValveSoftware/openvr/$openvr/headers/openvr.h" -o build/include/openvr.h && touch build/include/openvr-$openvr; } [ -f build/include/openvr-$openvr ] || { curl -fsSL "https://raw.githubusercontent.com/ValveSoftware/openvr/$openvr/headers/openvr.h" -o build/include/openvr.h && touch build/include/openvr-$openvr; }
stb=2c980bb59875b0d32144a71867fbdebb2f77cd20 stb=2c980bb59875b0d32144a71867fbdebb2f77cd20
[ -f build/include/stb-$stb ] || { curl -fsSL "https://raw.githubusercontent.com/nothings/stb/$stb/stb_truetype.h" -o build/include/stb_truetype.h && touch build/include/stb-$stb; } [ -f build/include/stb-$stb ] || { curl -fsSL "https://raw.githubusercontent.com/nothings/stb/$stb/stb_truetype.h" -o build/include/stb_truetype.h && touch build/include/stb-$stb; }
[ -f build/include/stb_image-$stb ] || { curl -fsSL "https://raw.githubusercontent.com/nothings/stb/$stb/stb_image.h" -o build/include/stb_image.h && touch build/include/stb_image-$stb; }
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -Wno-missing-field-initializers -Ibuild/include $(pkg-config --cflags gbm libdrm) \ g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -Wno-missing-field-initializers -Ibuild/include $(pkg-config --cflags gbm libdrm) \
-o build/ft-handpanel panel/ft-handpanel.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 \ -o build/ft-handpanel panel/ft-handpanel.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 \
$(pkg-config --libs gbm libdrm) -lpthread $(pkg-config --libs gbm libdrm) -lpthread
+37 -8
View File
@@ -6,8 +6,10 @@ A Kirigami (QML) app with a Python backend. It runs in the dev container:
changes. Agreeing writes profile.json with a random contributor id. changes. Agreeing writes profile.json with a random contributor id.
- Before you start: the checklist (objects, controller straps, lighting, sleeves, privacy, - Before you start: the checklist (objects, controller straps, lighting, sleeves, privacy,
free space) and what will happen. Start hands it to the session runner (session.py). free space) and what will happen. Start hands it to the session runner (session.py).
- Session: the runner's live status, Start, Pause/Resume, Skip section and Stop (Space - Session: the runner's live status with the step's pose picture and where-to diagram, Next,
pauses and Esc stops while the window has focus). The prompts appear in the headset. Pause/Resume, Redo, Skip section and Stop (Space: Next, P, R, S and Esc while the window has
focus). The prompts appear in the headset. Each step waits for Next unless "Advance by
itself" was ticked.
- Review: sessions, their takes, and a viewer for one frame set at a time, where ranges, - Review: sessions, their takes, and a viewer for one frame set at a time, where ranges,
takes and sessions can be deleted (takes.py). takes and sessions can be deleted (takes.py).
- Export: compress what's kept into exports/<session>/ at nice 19 (takes.py), with a warning - Export: compress what's kept into exports/<session>/ at nice 19 (takes.py), with a warning
@@ -66,7 +68,7 @@ LIGHTING = [("dim", "Dim: one lamp only"), ("room", "Normal room light"), ("dayl
SLEEVES = [("short", "Short sleeves or bare arms"), ("long", "Long sleeves"), ("", "Rather not say")] SLEEVES = [("short", "Short sleeves or bare arms"), ("long", "Long sleeves"), ("", "Rather not say")]
HANDEDNESS = [("", "Rather not say"), ("right", "Right-handed"), ("left", "Left-handed"), HANDEDNESS = [("", "Rather not say"), ("right", "Right-handed"), ("left", "Left-handed"),
("both", "Both (ambidextrous)")] ("both", "Both (ambidextrous)")]
ACTIVE_STATES = ("starting", "intro", "running", "paused", "between") ACTIVE_STATES = ("starting", "intro", "ready", "countdown", "running", "paused", "between")
# Shown side by side in the viewer at this height; thumbnails are smaller. # Shown side by side in the viewer at this height; thumbnails are smaller.
SET_HEIGHT = 480 SET_HEIGHT = 480
THUMB_HEIGHT = 96 THUMB_HEIGHT = 96
@@ -210,7 +212,7 @@ class Backend(QObject):
def __init__(self, store, session_options=None, hub_dry_run=False): def __init__(self, store, session_options=None, hub_dry_run=False):
super().__init__() super().__init__()
self.store = store self.store = store
self._session_options = session_options or {} # test hooks for Session: dry_run, speed self._session_options = session_options or {} # test hooks for Session: dry_run, speed, poses_dir
self._hub_dry_run = hub_dry_run self._hub_dry_run = hub_dry_run
self._login = {"state": "dry" if hub_dry_run else "unknown"} self._login = {"state": "dry" if hub_dry_run else "unknown"}
self._login_busy = False self._login_busy = False
@@ -384,8 +386,21 @@ class Backend(QObject):
def sessionId(self): def sessionId(self):
return self._session_id return self._session_id
@Slot("QVariantMap", str, result=bool) @Slot("QVariantMap", bool, result=str)
def startSession(self, checklist, lighting): def planText(self, checklist, auto):
"""How long a session with these answers takes (session.plan_summary)."""
mod = self._runner()
if not mod:
return ""
try:
script = mod.load_script(SCRIPT_PATH)
plan, _ = mod.build_plan(script, dict(checklist))
return mod.plan_summary(script, plan, auto=auto)
except Exception as e:
return f"Couldn't read the script: {e}"
@Slot("QVariantMap", str, bool, result=bool)
def startSession(self, checklist, lighting, auto):
if self.sessionActive: if self.sessionActive:
return False return False
mod = self._runner() mod = self._runner()
@@ -400,7 +415,7 @@ class Backend(QObject):
checklist["own_objects"] = [o.strip() for o in checklist.get("own_objects", []) if str(o).strip()] checklist["own_objects"] = [o.strip() for o in checklist.get("own_objects", []) if str(o).strip()]
try: try:
self._session = mod.Session(self.store.base, self.store.profile(), checklist, lighting, SCRIPT_PATH, self._session = mod.Session(self.store.base, self.store.profile(), checklist, lighting, SCRIPT_PATH,
on_status=lambda s: self._statusArrived.emit(dict(s)), on_status=lambda s: self._statusArrived.emit(dict(s)), auto=auto,
**self._session_options) **self._session_options)
except Exception as e: except Exception as e:
self.message.emit(f"Couldn't set up the session: {e}", True) self.message.emit(f"Couldn't set up the session: {e}", True)
@@ -434,6 +449,16 @@ class Backend(QObject):
except Exception as e: except Exception as e:
self.message.emit(f"{name}: {e}", True) self.message.emit(f"{name}: {e}", True)
@Slot()
def nextStep(self):
if self.sessionActive and self._status.get("waiting"):
self._control("next_step")
@Slot()
def redo(self):
if self.sessionActive and self._status.get("can_redo"):
self._control("redo")
@Slot() @Slot()
def togglePause(self): def togglePause(self):
if self._status.get("state") == "paused": if self._status.get("state") == "paused":
@@ -897,6 +922,7 @@ def main():
ap.add_argument("--dry-run", action="store_true", ap.add_argument("--dry-run", action="store_true",
help="test: sessions start no processes and print the panel's commands") help="test: sessions start no processes and print the panel's commands")
ap.add_argument("--speed", type=float, default=1.0, help="test, with --dry-run: run sessions this much faster") ap.add_argument("--speed", type=float, default=1.0, help="test, with --dry-run: run sessions this much faster")
ap.add_argument("--poses", help="test: the pose pictures' folder (default hands/rec/poses)")
ap.add_argument("--hub-dry-run", action="store_true", ap.add_argument("--hub-dry-run", action="store_true",
help="test: Upload checks the export and says what it would send, with no network calls") help="test: Upload checks the export and says what it would send, with no network calls")
a, qt_args = ap.parse_known_args() a, qt_args = ap.parse_known_args()
@@ -910,7 +936,10 @@ def main():
store = takes.Store(a.base) store = takes.Store(a.base)
engine = QQmlApplicationEngine() engine = QQmlApplicationEngine()
engine.addImageProvider("frames", FrameProvider(store)) engine.addImageProvider("frames", FrameProvider(store))
backend = Backend(store, {"dry_run": True, "speed": a.speed} if a.dry_run else {}, hub_dry_run=a.hub_dry_run) options = {"dry_run": True, "speed": a.speed} if a.dry_run else {}
if a.poses:
options["poses_dir"] = a.poses
backend = Backend(store, options, hub_dry_run=a.hub_dry_run)
app.aboutToQuit.connect(backend.shutdown) app.aboutToQuit.connect(backend.shutdown)
engine.rootContext().setContextProperty("backend", backend) engine.rootContext().setContextProperty("backend", backend)
engine.rootContext().setContextProperty("startPage", a.page) engine.rootContext().setContextProperty("startPage", a.page)
+242 -13
View File
@@ -383,19 +383,36 @@ Kirigami.ApplicationWindow {
Controls.Label { Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26 Layout.maximumWidth: Kirigami.Units.gridUnit * 26
wrapMode: Text.Wrap wrapMode: Text.Wrap
text: "Press Start, then put the headset on. A panel in the headset says what to do, one " text: "Press Start, then put the headset on. A panel in the headset shows each step: a picture "
+ "section at a time: hand poses, gestures, typing and the mouse, your objects, touching a " + "of the hand pose, where to hold your hands, and what to do. The sections: hand poses, "
+ "dot, and moves with the controllers on and off. Each section is recorded as one take.\n\n" + "gestures, typing and the mouse, your objects, touching a dot, and moves with the "
+ "Space in this window pauses and Esc stops. You can also skip a section.\n\n" + "controllers on and off. Each section is recorded as one take.\n\n"
+ "Each step waits until you're ready: press Space or click Next in this window. A 3-2-1 "
+ "countdown follows, then hold the pose until the bar runs out. Nothing is recorded while "
+ "a step waits. P pauses, R records the last step again, S skips a section and Esc stops.\n\n"
+ "Nothing leaves the headset. Afterwards you watch the takes in Review, delete anything " + "Nothing leaves the headset. Afterwards you watch the takes in Review, delete anything "
+ "you don't want to share, and only then export." + "you don't want to share, and only then export."
} }
Controls.CheckBox {
id: autoAdvance
Kirigami.FormData.label: "Pace:"
text: "Advance by itself (no Next between steps)"
}
Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
wrapMode: Text.Wrap
opacity: 0.7
text: (autoAdvance.checked
? "Each step shows for a few seconds and the next follows by itself. Quicker if you "
+ "know the steps already. "
: "") + "Length: " + backend.planText(checklist.answers(), autoAdvance.checked) + "."
}
Controls.Button { Controls.Button {
text: "Start" text: "Start"
icon.name: "media-record" icon.name: "media-record"
enabled: checklist.ready enabled: checklist.ready
onClicked: { onClicked: {
if (backend.startSession(checklist.answers(), lighting.currentValue)) if (backend.startSession(checklist.answers(), lighting.currentValue, autoAdvance.checked))
root.show(sessionPage) root.show(sessionPage)
} }
} }
@@ -417,17 +434,35 @@ Kirigami.ApplicationWindow {
title: "Session" title: "Session"
readonly property var st: backend.status readonly property var st: backend.status
readonly property string state: st.state || "" readonly property string state: st.state || ""
readonly property bool waiting: !!st.waiting
readonly property bool stepMode: st.mode !== "auto"
readonly property var stateText: ({ readonly property var stateText: ({
starting: "Starting…", intro: "Get ready", running: "Recording", paused: "Paused", starting: "Starting…", intro: "Get ready", ready: "Get ready", countdown: "Starting",
between: "Between sections", done: "Done", stopped: "Stopped", error: "Error" running: "Recording", paused: "Paused", between: "Between sections", done: "Done",
stopped: "Stopped", error: "Error"
}) })
// Space and Esc while the window has focus (the panel in the headset says so too). // The keys while the window has focus (the panel in the headset lists them too).
Shortcut { Shortcut {
sequence: "Space" sequence: "Space"
enabled: backend.sessionActive enabled: backend.sessionActive
onActivated: backend.nextStep()
}
Shortcut {
sequence: "P"
enabled: backend.sessionActive
onActivated: backend.togglePause() onActivated: backend.togglePause()
} }
Shortcut {
sequence: "R"
enabled: backend.sessionActive
onActivated: backend.redo()
}
Shortcut {
sequence: "S"
enabled: backend.sessionActive
onActivated: backend.skipSection()
}
Shortcut { Shortcut {
sequence: "Esc" sequence: "Esc"
enabled: backend.sessionActive enabled: backend.sessionActive
@@ -449,6 +484,114 @@ Kirigami.ApplicationWindow {
} }
} }
// The pose picture as the panel shows it: a right hand as drawn, a left hand flipped,
// both hands as a flipped copy beside it (DESIGN.md "The pose pictures").
component PosePicture: Row {
property string path
property string mode
property real side: Kirigami.Units.gridUnit * 9
spacing: Kirigami.Units.smallSpacing
visible: path !== ""
Image {
visible: parent.mode === "both"
width: visible ? parent.side * 0.75 : 0
height: width
source: parent.path ? "file://" + parent.path : ""
fillMode: Image.PreserveAspectFit
mirror: true
smooth: true
mipmap: true
}
Image {
width: parent.mode === "both" ? parent.side * 0.75 : parent.side
height: width
source: parent.path ? "file://" + parent.path : ""
fillMode: Image.PreserveAspectFit
mirror: parent.mode === "mirror"
smooth: true
mipmap: true
}
}
// Where to hold the hands: a front view (left, centre, right, up, down; the push
// sections' chest, desk and eye) and how far out (near, mid, far), as on the panel.
component WhereDiagram: RowLayout {
id: where
property string position
property string distance
readonly property var cell: ({ centre: [1, 1], center: [1, 1], chest: [1, 1], left: [0, 1],
right: [2, 1], up: [1, 0], eye: [1, 0], down: [1, 2],
desk: [1, 2] })[position] || null
readonly property int step: ["near", "mid", "far"].indexOf(distance)
readonly property real unit: Kirigami.Units.gridUnit * 1.1
readonly property color accent: "#4cd9ff"
spacing: Kirigami.Units.gridUnit
visible: cell !== null || step >= 0
ColumnLayout {
visible: where.cell !== null
Grid {
Layout.alignment: Qt.AlignHCenter
columns: 3
spacing: 2
Repeater {
model: 9
Rectangle {
required property int index
width: where.unit * 1.33
height: where.unit
radius: 3
readonly property bool on: where.cell !== null && index === where.cell[1] * 3 + where.cell[0]
color: on ? where.accent
: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.12)
}
}
}
Controls.Label {
Layout.alignment: Qt.AlignHCenter
font: Kirigami.Theme.smallFont
text: ({ left: "To your left", right: "To your right", up: "Up high", down: "Down low",
chest: "Chest height", desk: "Desk height", eye: "Eye level" })[where.position]
|| "In front"
}
}
ColumnLayout {
visible: where.step >= 0
Row {
Layout.alignment: Qt.AlignHCenter
spacing: where.unit * 0.6
height: where.unit * 1.2
// the head, seen from the side, then the arm's reach
Rectangle {
width: where.unit
height: width
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.6)
}
Repeater {
model: 3
Rectangle {
required property int index
readonly property bool on: index === where.step
width: on ? where.unit * 0.8 : where.unit * 0.35
height: width
radius: width / 2
anchors.verticalCenter: parent.verticalCenter
color: on ? where.accent : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
Kirigami.Theme.textColor.b, 0.35)
}
}
}
Controls.Label {
Layout.alignment: Qt.AlignHCenter
font: Kirigami.Theme.smallFont
text: ["Close: a hand's length", "Halfway out", "Arm stretched out"][Math.max(0, where.step)]
}
}
}
header: Kirigami.InlineMessage { header: Kirigami.InlineMessage {
visible: backend.runnerError !== "" || sessionView.state === "error" visible: backend.runnerError !== "" || sessionView.state === "error"
position: Kirigami.InlineMessage.Position.Header position: Kirigami.InlineMessage.Position.Header
@@ -475,16 +618,63 @@ Kirigami.ApplicationWindow {
visible: sessionView.state !== "" visible: sessionView.state !== ""
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
RowLayout {
Layout.fillWidth: true
Kirigami.Heading { Kirigami.Heading {
level: 1 level: 1
text: sessionView.stateText[sessionView.state] || sessionView.state text: sessionView.stateText[sessionView.state] || sessionView.state
} }
Item { Layout.fillWidth: true }
Controls.Label {
visible: !!sessionView.st.take && sessionView.state !== "paused"
&& (sessionView.state === "countdown" || sessionView.state === "running"
|| !sessionView.stepMode)
text: "● Recording"
color: Kirigami.Theme.negativeTextColor
}
}
Controls.Label { Controls.Label {
visible: (sessionView.st.section_index || 0) > 0 visible: (sessionView.st.section_index || 0) > 0
text: "Section " + sessionView.st.section_index + " of " + sessionView.st.section_count text: "Section " + sessionView.st.section_index + " of " + sessionView.st.section_count
+ (sessionView.st.title ? ": " + sessionView.st.title : "") + (sessionView.st.title ? ": " + sessionView.st.title : "")
+ ((sessionView.st.step_index || 0) > 0 && sessionView.stepMode
? " · step " + sessionView.st.step_index + " of " + sessionView.st.step_count : "")
font.bold: true font.bold: true
} }
RowLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.gridUnit * 1.5
ColumnLayout {
visible: !!sessionView.st.image || !!sessionView.st.position || !!sessionView.st.distance
spacing: Kirigami.Units.largeSpacing
PosePicture {
Layout.alignment: Qt.AlignHCenter
path: sessionView.st.image || ""
mode: sessionView.st.image_mode || ""
}
Controls.Label {
Layout.alignment: Qt.AlignHCenter
Layout.maximumWidth: Kirigami.Units.gridUnit * 14
visible: !!sessionView.st.image && !!sessionView.st.caption
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
opacity: 0.7
font: Kirigami.Theme.smallFont
text: sessionView.st.caption || ""
}
WhereDiagram {
id: where
Layout.alignment: Qt.AlignHCenter
position: sessionView.st.position || ""
distance: sessionView.st.distance || ""
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Kirigami.Units.largeSpacing
Controls.Label { Controls.Label {
Layout.fillWidth: true Layout.fillWidth: true
visible: !!sessionView.st.prompt visible: !!sessionView.st.prompt
@@ -492,12 +682,41 @@ Kirigami.ApplicationWindow {
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.4 font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.4
text: sessionView.st.prompt || "" text: sessionView.st.prompt || ""
} }
// The countdown's 3, 2, 1, then the hold's word, big
Controls.Label { Controls.Label {
visible: sessionView.st.seconds_left !== undefined && sessionView.st.seconds_left !== null visible: !!sessionView.st.big && sessionView.state !== "paused"
&& sessionView.st.seconds_left >= 0 text: sessionView.st.big || ""
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 4
font.bold: true
color: Kirigami.Theme.highlightColor
}
Controls.Label {
visible: !sessionView.waiting && sessionView.state !== "countdown"
&& sessionView.state !== "ready" && sessionView.st.seconds_left !== undefined
&& sessionView.st.seconds_left !== null && sessionView.st.seconds_left > 0
text: Math.ceil(sessionView.st.seconds_left || 0) + " s left" text: Math.ceil(sessionView.st.seconds_left || 0) + " s left"
opacity: 0.7 opacity: 0.7
} }
Controls.Button {
visible: sessionView.waiting && sessionView.state !== "paused"
focusPolicy: Qt.NoFocus
text: "Next"
icon.name: "go-next"
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.5
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
onClicked: backend.nextStep()
}
Controls.Label {
visible: sessionView.waiting && sessionView.state !== "paused"
opacity: 0.7
text: "Ready? Press Space or click Next. A 3-2-1 countdown starts the recording."
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
}
Kirigami.InlineMessage { Kirigami.InlineMessage {
Layout.fillWidth: true Layout.fillWidth: true
visible: !!sessionView.st.note visible: !!sessionView.st.note
@@ -513,7 +732,7 @@ Kirigami.ApplicationWindow {
} }
Controls.Label { Controls.Label {
visible: !!sessionView.st.take visible: !!sessionView.st.take
text: "Recording to take " + (sessionView.st.take || "") text: "Take " + (sessionView.st.take || "")
opacity: 0.7 opacity: 0.7
font: Kirigami.Theme.smallFont font: Kirigami.Theme.smallFont
} }
@@ -524,8 +743,10 @@ Kirigami.ApplicationWindow {
Layout.fillWidth: true Layout.fillWidth: true
wrapMode: Text.Wrap wrapMode: Text.Wrap
opacity: 0.7 opacity: 0.7
text: "The instructions appear in the headset. While this window has focus, Space pauses " text: "The instructions appear in the headset. While this window has focus: "
+ "and resumes, and Esc stops. Stopping keeps what's recorded so far." + (sessionView.stepMode ? "Space: next · " : "")
+ "P: pause or resume · R: record the last step again · S: skip section · Esc: stop. "
+ "Stopping keeps what's recorded so far."
} }
RowLayout { RowLayout {
// No keyboard focus on the buttons, so Space always reaches the shortcut. // No keyboard focus on the buttons, so Space always reaches the shortcut.
@@ -543,6 +764,14 @@ Kirigami.ApplicationWindow {
icon.name: sessionView.state === "paused" ? "media-playback-start" : "media-playback-pause" icon.name: sessionView.state === "paused" ? "media-playback-start" : "media-playback-pause"
onClicked: backend.togglePause() onClicked: backend.togglePause()
} }
Controls.Button {
visible: backend.sessionActive
enabled: !!sessionView.st.can_redo
focusPolicy: Qt.NoFocus
text: "Redo step"
icon.name: "edit-undo"
onClicked: backend.redo()
}
Controls.Button { Controls.Button {
visible: backend.sessionActive visible: backend.sessionActive
focusPolicy: Qt.NoFocus focusPolicy: Qt.NoFocus
+273 -18
View File
@@ -24,6 +24,18 @@
// at the hand's position (-1: not seen). The labels are split at // at the hand's position (-1: not seen). The labels are split at
// "|", or else at the first space (default "Near" and "Far") // "|", or else at the first space (default "Near" and "Far")
// paused on|off "Paused" over the picture // paused on|off "Paused" over the picture
// image <path> [mirror|both] / image off
// the pose picture (a PNG, square, RGBA, a right hand as the wearer
// sees it) in a column on the left, the text moving right: mirror
// flips it (a left hand), both shows a flipped copy on its left
// where <position|-> <distance|-> / where off
// a small diagram under the picture: where to hold the hands, a
// front view (centre, left, right, up, down; or the bar's chest,
// desk, eye), and how far out (near, mid, far)
// action <text> a cyan line under the instruction: "Ready? Press Space ..."
// big <text> large, under the instruction: the countdown's 3, 2, 1, then "Hold"
// keys <text> a small faint line along the bottom: the window's keys
// rec on|off a red "Rec" dot by the step line while recording
// target <x> <y> <z> [show|hold <0..1>|done] / target off // target <x> <y> <z> [show|hold <0..1>|done] / target off
// the touch target, about 2 cm across. The point is in the head // the touch target, about 2 cm across. The point is in the head
// frame (metres, +x right, +y up, -z forward). The first command // frame (metres, +x right, +y up, -z forward). The first command
@@ -61,6 +73,14 @@
#define STB_TRUETYPE_IMPLEMENTATION #define STB_TRUETYPE_IMPLEMENTATION
#include "stb_truetype.h" #include "stb_truetype.h"
#define STB_IMAGE_IMPLEMENTATION
#define STBI_ONLY_PNG
#define STBI_NO_HDR
#define STBI_NO_LINEAR
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-function" // helpers only other formats use
#include "stb_image.h"
#pragma GCC diagnostic pop
#include <drm_fourcc.h> #include <drm_fourcc.h>
#include <fcntl.h> #include <fcntl.h>
@@ -274,15 +294,146 @@ void Text(Picture &p, const std::string &s, int size, int x, int cy, double lum,
Text(p, s, size, x, cy, lum, lum, lum, align); Text(p, s, size, x, cy, lum, lum, lum, align);
} }
// A pose picture as loaded (straight alpha), and scaled copies of it as drawn.
struct Image {
std::string path;
Picture src{0, 0, {}};
std::map<std::pair<int, bool>, Picture> scaled; // (side, mirrored) -> picture
};
// A PNG into img; false (and img empty) if it can't be read.
bool LoadImage(Image &img, const std::string &path) {
img = Image{};
int w = 0, h = 0, n = 0;
unsigned char *px = stbi_load(path.c_str(), &w, &h, &n, 4);
if (!px) return false;
img.path = path;
img.src = Picture{w, h, std::vector<uint8_t>(px, px + size_t(w) * h * 4)};
stbi_image_free(px);
return true;
}
// The picture fitted into a side x side square (centred, aspect kept), averaging the source
// pixels each output pixel covers, weighted by alpha so transparent edges don't darken.
const Picture &Scaled(Image &img, int side, bool mirror) {
auto [it, fresh] = img.scaled.try_emplace({side, mirror}, Picture{side, side, {}});
Picture &out = it->second;
if (!fresh) return out;
out.px.assign(size_t(side) * side * 4, 0);
const Picture &src = img.src;
const double scale = std::max(src.w, src.h) / double(side); // source pixels per output pixel
const double ox = (side - src.w / scale) / 2, oy = (side - src.h / scale) / 2;
for (int y = 0; y < side; ++y)
for (int x = 0; x < side; ++x) {
const double sx0 = (x - ox) * scale, sy0 = (y - oy) * scale;
const int x0 = std::max(0, int(std::floor(sx0))), y0 = std::max(0, int(std::floor(sy0)));
const int x1 = std::min(src.w, int(std::ceil(sx0 + scale))), y1 = std::min(src.h, int(std::ceil(sy0 + scale)));
double r = 0, g = 0, b = 0, a = 0;
int count = 0;
for (int sy = y0; sy < y1; ++sy)
for (int sx = x0; sx < x1; ++sx) {
const uint8_t *q = &src.px[(size_t(sy) * src.w + (mirror ? src.w - 1 - sx : sx)) * 4];
const double qa = q[3] / 255.0;
r += q[0] * qa, g += q[1] * qa, b += q[2] * qa, a += qa, ++count;
}
if (!count || a <= 0) continue;
uint8_t *o = &out.px[(size_t(y) * side + x) * 4];
o[0] = uint8_t(std::lround(r / a)), o[1] = uint8_t(std::lround(g / a)), o[2] = uint8_t(std::lround(b / a));
o[3] = uint8_t(std::lround(255 * a / count));
}
return out;
}
void DrawPicture(Picture &dst, const Picture &src, int x0, int y0) {
for (int y = 0; y < src.h; ++y)
for (int x = 0; x < src.w; ++x) {
const uint8_t *q = &src.px[(size_t(y) * src.w + x) * 4];
if (q[3]) Blend(dst, x0 + x, y0 + y, q[0] / 255.0, q[1] / 255.0, q[2] / 255.0, q[3] / 255.0);
}
}
// The where-to diagram's cells: a front view, 3 x 3, (column, row) from the top left.
bool WhereCell(const std::string &pos, int &col, int &row) {
col = 1, row = 1;
if (pos == "left") col = 0;
else if (pos == "right") col = 2;
else if (pos == "up" || pos == "eye") row = 0;
else if (pos == "down" || pos == "desk") row = 2;
else if (pos != "centre" && pos != "center" && pos != "chest") return false;
return true;
}
const char *WhereLabel(const std::string &pos) {
if (pos == "left") return "To your left";
if (pos == "right") return "To your right";
if (pos == "up") return "Up high";
if (pos == "down") return "Down low";
if (pos == "chest") return "Chest height";
if (pos == "desk") return "Desk height";
if (pos == "eye") return "Eye level";
return "In front";
}
int DistanceStep(const std::string &dist) { return dist == "near" ? 0 : dist == "mid" ? 1 : dist == "far" ? 2 : -1; }
const char *DistanceLabel(int k) { return k == 0 ? "Close" : k == 1 ? "Halfway out" : "Arm out"; }
constexpr double kCyan[3] = {0.3, 0.85, 1.0};
// The diagram in x0..x0+w, y0..y0+h: the front view on the left, the distance on the right
// (or either alone, centred), each with its caption under it.
void DrawWhere(Picture &pic, int x0, int y0, int w, int h, const std::string &pos, const std::string &dist) {
const double ppd = kPxPerDeg;
int col, row;
const bool grid = WhereCell(pos, col, row);
const int step = DistanceStep(dist);
const int parts = int(grid) + int(step >= 0);
if (!parts) return;
const int capSize = int(ppd * 0.75), capH = int(capSize * 1.6), partW = w / parts;
const int boxH = h - capH;
int px = x0;
if (grid) {
const int gw = std::min(partW - int(ppd * 0.6), boxH * 4 / 3), gh = gw * 3 / 4;
const int gx = px + (partW - gw) / 2, gy = y0 + (boxH - gh) / 2;
RoundRect(pic, gx, gy, gx + gw, gy + gh, ppd * 0.3, 1, 1, 1, 0.08);
for (int k = 1; k < 3; ++k) {
Rect(pic, gx + gw * k / 3 - 1, gy + 3, gx + gw * k / 3 + 1, gy + gh - 3, 1, 1, 1, 0.18);
Rect(pic, gx + 3, gy + gh * k / 3 - 1, gx + gw - 3, gy + gh * k / 3 + 1, 1, 1, 1, 0.18);
}
const int pad = std::max(3, int(ppd * 0.12));
RoundRect(pic, gx + gw * col / 3 + pad, gy + gh * row / 3 + pad, gx + gw * (col + 1) / 3 - pad,
gy + gh * (row + 1) / 3 - pad, ppd * 0.18, kCyan[0], kCyan[1], kCyan[2], 0.9);
Text(pic, WhereLabel(pos), capSize, px + partW / 2, y0 + boxH + capH / 2, 0.85);
px += partW;
}
if (step >= 0) {
// Seen from the side: the head on the left, the arm's reach to the right, three marks.
const int cy = y0 + boxH / 2, head = int(ppd * 0.55);
const int hx = px + int(ppd * 0.4) + head, ax0 = hx + head + int(ppd * 0.3), ax1 = px + partW - int(ppd * 0.5);
Disc(pic, hx, cy, head, 0, 1, 1, 1, 0.75);
RoundRect(pic, hx + head / 3, cy - head / 2, hx + head + int(ppd * 0.2), cy + head / 3, 3, 0.25, 0.25, 0.28, 1);
Rect(pic, ax0, cy - 1, ax1, cy + 1, 1, 1, 1, 0.3);
for (int k = 0; k < 3; ++k) {
const double x = ax0 + (ax1 - ax0) * (k + 1) / 3.0;
if (k == step) Disc(pic, x, cy, ppd * 0.38, 0, kCyan[0], kCyan[1], kCyan[2], 0.95);
else Disc(pic, x, cy, ppd * 0.16, 0, 1, 1, 1, 0.45);
}
Text(pic, DistanceLabel(step), capSize, px + partW / 2, y0 + boxH + capH / 2, 0.85);
}
}
struct Panel { struct Panel {
Picture pic{kW, kH, {}}; Picture pic{kW, kH, {}};
std::string title, step, text, note; std::string title, step, text, note, action, big, keys;
double countdown = -1; // the share of the prompt's time left, or -1: off double countdown = -1; // the share of the prompt's time left, or -1: off
std::string hands[2] = {"off", "off"}; std::string hands[2] = {"off", "off"};
bool barOn = false; bool barOn = false;
double barTarget = 0, barCurrent = -1; double barTarget = 0, barCurrent = -1;
std::string nearLabel = "Near", farLabel = "Far"; std::string nearLabel = "Near", farLabel = "Far";
bool paused = false; bool paused = false, rec = false;
Image image; // the pose picture, if image.path isn't empty
std::string imageMode; // "", "mirror" or "both"
std::string wherePos, whereDist; // the diagram's, "" for none
// What the last Draw laid out, for --no-vr's printout. // What the last Draw laid out, for --no-vr's printout.
std::vector<std::string> lines, noteLines; std::vector<std::string> lines, noteLines;
double textDeg = 0; double textDeg = 0;
@@ -301,23 +452,34 @@ void Draw(Panel &p) {
const double ppd = kPxPerDeg, faint = 0.75; const double ppd = kPxPerDeg, faint = 0.75;
const int margin = int(ppd * 1.6), width = kW - 2 * margin; const int margin = int(ppd * 1.6), width = kW - 2 * margin;
// The title and the step share the top line; a faint rule under them. // The title and the step share the top line, a red "Rec" by the step while recording; a
// faint rule under them.
int top = margin; int top = margin;
if (!p.title.empty() || !p.step.empty()) { if (!p.title.empty() || !p.step.empty() || p.rec) {
const int titleSize = int(ppd * 1.6), stepSize = int(ppd * 0.9), cy = margin + titleSize / 2; const int titleSize = int(ppd * 1.6), stepSize = int(ppd * 0.9), cy = margin + titleSize / 2;
Text(pic, p.step, stepSize, kW - margin, cy, faint, kRight); Text(pic, p.step, stepSize, kW - margin, cy, faint, kRight);
if (p.rec) {
const int rx = kW - margin - TextWidth(p.step, stepSize) - (p.step.empty() ? 0 : int(ppd * 0.8));
Text(pic, "Rec", stepSize, rx, cy, 1.0, 0.45, 0.4, kRight);
Disc(pic, rx - TextWidth("Rec", stepSize) - ppd * 0.45, cy, ppd * 0.25, 0, 0.95, 0.2, 0.15, 1);
}
Text(pic, p.title, titleSize, margin, cy, 1.0, kLeft); Text(pic, p.title, titleSize, margin, cy, 1.0, kLeft);
top = cy + int(titleSize * 0.85); top = cy + int(titleSize * 0.85);
Rect(pic, margin, top, kW - margin, top + 2, 1, 1, 1, 0.15); Rect(pic, margin, top, kW - margin, top + 2, 1, 1, 1, 0.15);
top += int(ppd * 0.8); top += int(ppd * 0.8);
} }
// From the bottom up: the countdown, the chips, the near/far bar. The instruction gets what's left. // From the bottom up: the keys, the countdown, the chips, the near/far bar. The rest gets what's left.
int bottom = kH - margin; int bottom = kH - margin, foot = kH - int(ppd * 0.9);
if (!p.keys.empty()) {
Text(pic, p.keys, int(ppd * 0.7), kW / 2, foot, 0.55);
foot -= int(ppd * 1.0);
bottom = std::min(bottom, foot - int(ppd * 0.4));
}
if (p.countdown >= 0) { if (p.countdown >= 0) {
const int y = kH - int(ppd * 0.9), th = std::max(4, int(ppd * 0.22)); const int y = foot, th = std::max(4, int(ppd * 0.22));
Rect(pic, margin, y, kW - margin, y + th, 1, 1, 1, 0.15); Rect(pic, margin, y, kW - margin, y + th, 1, 1, 1, 0.15);
Rect(pic, margin, y, margin + int(std::lround(width * p.countdown)), y + th, 0.3, 0.85, 1.0, 0.95); Rect(pic, margin, y, margin + int(std::lround(width * p.countdown)), y + th, kCyan[0], kCyan[1], kCyan[2], 0.95);
bottom = std::min(bottom, y - int(ppd * 0.6)); bottom = std::min(bottom, y - int(ppd * 0.6));
} }
if (p.hands[0] != "off" || p.hands[1] != "off") { if (p.hands[0] != "off" || p.hands[1] != "off") {
@@ -342,25 +504,72 @@ void Draw(Panel &p) {
Text(pic, p.farLabel, labelSize, x1, ly, faint, kRight); Text(pic, p.farLabel, labelSize, x1, ly, faint, kRight);
const double ring = ppd * 0.65; const double ring = ppd * 0.65;
Disc(pic, x0 + (x1 - x0) * p.barTarget, ty, ring, ring - ppd * 0.14, 1, 1, 1, 0.95); Disc(pic, x0 + (x1 - x0) * p.barTarget, ty, ring, ring - ppd * 0.14, 1, 1, 1, 0.95);
if (p.barCurrent >= 0) Disc(pic, x0 + (x1 - x0) * p.barCurrent, ty, ppd * 0.4, 0, 0.3, 0.85, 1.0, 0.95); if (p.barCurrent >= 0) Disc(pic, x0 + (x1 - x0) * p.barCurrent, ty, ppd * 0.4, 0, kCyan[0], kCyan[1], kCyan[2], 0.95);
bottom = ty - int(ring) - int(ppd * 0.8); bottom = ty - int(ring) - int(ppd * 0.8);
} }
// The instruction, then the note, centred together in the space between. A long instruction // The pose picture and the diagram in a column on the left, centred together; the text
// gets smaller, down to 1 degree a line, before it runs over. // takes the rest. "both": the picture flipped (the left hand) beside it as it is.
const int noteSize = int(ppd * 1.1), noteLh = int(noteSize * 1.3), noteGap = int(ppd * 0.6); int textX0 = margin, textX1 = kW - margin;
p.noteLines = Wrap(p.note, noteSize, width); int col, row;
const bool imageOn = !p.image.path.empty();
const bool whereOn = WhereCell(p.wherePos, col, row) || DistanceStep(p.whereDist) >= 0;
if (imageOn || whereOn) {
const int colW = int(ppd * 14), gap = int(ppd * 0.6);
const int whereH = whereOn ? int(ppd * 4.4) : 0, between = imageOn && whereOn ? gap : 0;
const bool both = p.imageMode == "both";
const int side = imageOn ? std::max(0, std::min(both ? (colW - gap) / 2 : colW, bottom - top - whereH - between)) : 0;
int y = top + std::max(0, (bottom - top - side - between - whereH) / 2);
if (side > 0) {
const int pw = both ? 2 * side + gap : side, x = margin + (colW - pw) / 2;
RoundRect(pic, x - gap / 2, y - gap / 2, x + pw + gap / 2, y + side + gap / 2, ppd * 0.6, 1, 1, 1, 0.05);
if (both) {
DrawPicture(pic, Scaled(p.image, side, true), x, y);
DrawPicture(pic, Scaled(p.image, side, false), x + side + gap, y);
} else {
DrawPicture(pic, Scaled(p.image, side, p.imageMode == "mirror"), x, y);
}
y += side + between;
}
if (whereOn) DrawWhere(pic, margin, y, colW, whereH, p.wherePos, p.whereDist);
textX0 = margin + colW + int(ppd * 1.2);
}
const int textW = textX1 - textX0, textCx = (textX0 + textX1) / 2;
// The instruction, the note, the big countdown and the action line, centred together in
// the space between. A long instruction gets smaller, down to 1 degree a line, before it runs over.
const int noteSize = int(ppd * 1.1), noteLh = int(noteSize * 1.3), gapY = int(ppd * 0.6);
const int bigSize = int(ppd * 3.0), bigLh = int(bigSize * 1.1), actionSize = int(ppd * 1.05), actionLh = int(actionSize * 1.4);
p.noteLines = Wrap(p.note, noteSize, textW);
const std::vector<std::string> actionLines = Wrap(p.action, actionSize, textW);
int size = 0, lh = 0, block = 0; int size = 0, lh = 0, block = 0;
for (p.textDeg = 1.4;; p.textDeg -= 0.1) { for (p.textDeg = 1.4;; p.textDeg -= 0.1) {
size = int(ppd * p.textDeg), lh = int(size * 1.3); size = int(ppd * p.textDeg), lh = int(size * 1.3);
p.lines = Wrap(p.text, size, width); p.lines = Wrap(p.text, size, textW);
block = int(p.lines.size()) * lh + (p.noteLines.empty() ? 0 : noteGap + int(p.noteLines.size()) * noteLh); block = int(p.lines.size()) * lh + (p.noteLines.empty() ? 0 : gapY + int(p.noteLines.size()) * noteLh) +
(p.big.empty() ? 0 : gapY + bigLh) + (actionLines.empty() ? 0 : gapY + int(actionLines.size()) * actionLh);
if (block <= bottom - top || p.textDeg < 1.05) break; if (block <= bottom - top || p.textDeg < 1.05) break;
} }
int y = top + std::max(0, (bottom - top - block) / 2); int y = top + std::max(0, (bottom - top - block) / 2);
for (const std::string &line : p.lines) Text(pic, line, size, kW / 2, y + lh / 2, 1.0), y += lh; bool first = true;
if (!p.noteLines.empty()) y += p.lines.empty() ? 0 : noteGap; auto gapBefore = [&] { if (!first) y += gapY; first = false; };
for (const std::string &line : p.noteLines) Text(pic, line, noteSize, kW / 2, y + noteLh / 2, 1.0, 0.62, 0.3), y += noteLh; if (!p.lines.empty()) {
first = false;
for (const std::string &line : p.lines) Text(pic, line, size, textCx, y + lh / 2, 1.0), y += lh;
}
if (!p.noteLines.empty()) {
gapBefore();
for (const std::string &line : p.noteLines) Text(pic, line, noteSize, textCx, y + noteLh / 2, 1.0, 0.62, 0.3), y += noteLh;
}
if (!p.big.empty()) {
gapBefore();
Text(pic, p.big, bigSize, textCx, y + bigLh / 2, kCyan[0], kCyan[1], kCyan[2]), y += bigLh;
}
if (!actionLines.empty()) {
gapBefore();
for (const std::string &line : actionLines)
Text(pic, line, actionSize, textCx, y + actionLh / 2, kCyan[0], kCyan[1], kCyan[2]), y += actionLh;
}
if (p.paused) { if (p.paused) {
// The picture stays faintly behind, so it's clear what resumes; the word sits on a // The picture stays faintly behind, so it's clear what resumes; the word sits on a
@@ -432,6 +641,15 @@ void Print(const Panel &p, const Target &t, bool visible, int number) {
p.nearLabel.c_str(), p.farLabel.c_str()); p.nearLabel.c_str(), p.farLabel.c_str());
else std::printf("bar: off\n"); else std::printf("bar: off\n");
std::printf("paused: %s\n", p.paused ? "on" : "off"); std::printf("paused: %s\n", p.paused ? "on" : "off");
std::printf("big: %s\naction: %s\nkeys: %s\nrec: %s\n", p.big.c_str(), p.action.c_str(), p.keys.c_str(),
p.rec ? "on" : "off");
if (!p.image.path.empty())
std::printf("image: %s %dx%d%s%s\n", p.image.path.c_str(), p.image.src.w, p.image.src.h,
p.imageMode.empty() ? "" : " ", p.imageMode.c_str());
else std::printf("image: off\n");
if (!p.wherePos.empty() || !p.whereDist.empty())
std::printf("where: %s %s\n", p.wherePos.empty() ? "-" : p.wherePos.c_str(), p.whereDist.empty() ? "-" : p.whereDist.c_str());
else std::printf("where: off\n");
if (t.on) if (t.on)
std::printf("target: %s %.2f, head %.3f %.3f %.3f, room %.3f %.3f %.3f\n", t.state.c_str(), t.progress, t.head[0], std::printf("target: %s %.2f, head %.3f %.3f %.3f, room %.3f %.3f %.3f\n", t.state.c_str(), t.progress, t.head[0],
t.head[1], t.head[2], t.room[0], t.room[1], t.room[2]); t.head[1], t.head[2], t.room[0], t.room[1], t.room[2]);
@@ -805,6 +1023,43 @@ int main(int argc, char **argv) {
} else if (Is(buf, "paused", &rest)) { } else if (Is(buf, "paused", &rest)) {
if (!std::strcmp(rest, "on") || !std::strcmp(rest, "off")) p.paused = rest[1] == 'n', dirty = true; if (!std::strcmp(rest, "on") || !std::strcmp(rest, "off")) p.paused = rest[1] == 'n', dirty = true;
else reply = "error usage: paused on|off"; else reply = "error usage: paused on|off";
} else if (Is(buf, "rec", &rest)) {
if (!std::strcmp(rest, "on") || !std::strcmp(rest, "off")) p.rec = rest[1] == 'n', dirty = true;
else reply = "error usage: rec on|off";
} else if (Is(buf, "action", &rest)) {
p.action = rest, dirty = true;
} else if (Is(buf, "big", &rest)) {
p.big = rest, dirty = true;
} else if (Is(buf, "keys", &rest)) {
p.keys = rest, dirty = true;
} else if (Is(buf, "image", &rest)) {
// image <path> [mirror|both]: the path may hold spaces, the mode is the last word
std::string path = rest, mode;
for (const char *m : {" mirror", " both"}) {
const size_t n = std::strlen(m);
if (path.size() > n && !path.compare(path.size() - n, n, m)) mode = m + 1, path.resize(path.size() - n);
}
if (path.empty()) {
reply = "error usage: image <path> [mirror|both] | image off";
} else if (path == "off") {
p.image = Image{}, p.imageMode.clear(), dirty = true;
} else if (path != p.image.path && !LoadImage(p.image, path)) {
reply = "error can't read " + path + ": " + stbi_failure_reason();
p.imageMode.clear(), dirty = true; // no picture rather than the last one
} else {
p.imageMode = mode, dirty = true;
}
} else if (Is(buf, "where", &rest)) {
char pos[16] = "", dist[16] = "";
if (!std::strcmp(rest, "off")) {
p.wherePos.clear(), p.whereDist.clear(), dirty = true;
} else if (std::sscanf(rest, "%15s %15s", pos, dist) >= 1) {
p.wherePos = std::strcmp(pos, "-") ? pos : "";
p.whereDist = *dist && std::strcmp(dist, "-") ? dist : "";
dirty = true;
} else {
reply = "error usage: where <position|-> <distance|-> | where off";
}
} else if (Is(buf, "target", &rest)) { } else if (Is(buf, "target", &rest)) {
char state[16] = "show"; char state[16] = "show";
double prog = 0; double prog = 0;
+50
View File
@@ -0,0 +1,50 @@
# Pose images for the hand recorder
Small pictures of each hand pose in `script.json`, shown on the headset panel next to the prompt text.
- `<id>.png`: one image per pose, 512x512 RGBA with a transparent background. Made for a dark panel and still readable at about 250 px.
- `poses.json`: maps each pose id to `{"file", "two_hands", "caption"}`.
- `contact-sheet.png`: every image in a grid with its id, for review. The panel doesn't use it.
- `make_poses.py`: the generator that makes all of the above.
## How the images are drawn
Every image shows a right hand as the wearer sees it from their own eyes. "Palm toward you" shows the palm, with its creases. "Back toward you" shows the back of the hand, with the nails and knuckles. Fingers point up unless the pose says otherwise.
- **Left-hand prompts:** the panel mirrors the image horizontally.
- **"Both" prompts:** the panel shows two copies, one of them mirrored.
- **`two_hands: true`:** the image already shows the whole scene: both hands, or one hand with an object, as in `hold`. Show it once, without mirroring or copying. This applies to `cross`, `overlap`, `near-face`, `typing`, `lift`, `switch`, `hold`, `touch-stick` and `touch-stick-desk`.
- **`touch-stick`:** shows the left hand holding the controller while the right index touches its thumbstick. For prompts where the right hand holds the controller (`controllers: ["right"]`), mirror it.
Motion poses show the start pose, a faint blue "ghost" of the end pose, and orange arrows. Objects (keyboard, mouse, bottle, bar, controller, screen, head and headset) are plain grey shapes.
Besides the pose ids in `script.json`, these extra ids exist for prompts that need a different picture:
| id | for |
| --- | --- |
| `push` | the bar sections: palms out, push out and back |
| `push-controller` | the same with controllers on |
| `touch-stick-desk` | touching the thumbstick of a controller lying on the desk |
| `no-hands` | the no-hands section: hands down, out of view |
| `open-close` | already used by the bar sections |
`count-5` is the same picture as `spread`.
## Regenerating
You need Python 3 with numpy and Pillow. The script uses about one core-minute per image at the default quality, so don't run it on the headset. Run it on a build machine:
```sh
python3 -m venv venv && venv/bin/pip install numpy pillow
venv/bin/python make_poses.py --jobs 6 # all images, poses.json, contact sheet
venv/bin/python make_poses.py --only fist,ok # just some (poses.json is left alone)
venv/bin/python make_poses.py --ss 1 --jobs 6 # rough and about 8x faster, for trying things
```
`--out DIR` writes somewhere other than this folder.
Each pose is a short entry in the `POSES` table in `make_poses.py`: a caption, the `two_hands` flag, and a function that returns the scene. A scene is the camera, layers of hands and objects (a layer can be a faint ghost), and arrows. Hands are built from joint angles: flexion at each finger's three joints, sideways spread, and four thumb angles. A thumb can also be given a target point, such as "touch the index fingertip", and a small solver finds the angles. The presets near `FLAT`, `FIST` and `OK` are a good place to start a new pose.
## License
MIT, like the rest of the repository. The script draws everything itself. A hand made of a palm slab and tapered capsules is ray-marched as a signed distance field, then shaded and outlined. No photos, downloaded images, scanned or research hand models, or AI image generators are involved, so the images carry no other terms.
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+182
View File
@@ -0,0 +1,182 @@
{
"flat": {
"file": "flat.png",
"two_hands": false,
"caption": "Palm toward you, fingers together"
},
"flat-back": {
"file": "flat-back.png",
"two_hands": false,
"caption": "Back of the hand toward you"
},
"spread": {
"file": "spread.png",
"two_hands": false,
"caption": "Fingers spread wide"
},
"open": {
"file": "open.png",
"two_hands": false,
"caption": "Open and relaxed"
},
"fist": {
"file": "fist.png",
"two_hands": false,
"caption": "A fist"
},
"point": {
"file": "point.png",
"two_hands": false,
"caption": "Point with your index finger"
},
"pinch": {
"file": "pinch.png",
"two_hands": false,
"caption": "Thumb and index tips touching"
},
"ok": {
"file": "ok.png",
"two_hands": false,
"caption": "OK sign: a ring, three fingers up"
},
"thumbs-up": {
"file": "thumbs-up.png",
"two_hands": false,
"caption": "Thumbs up"
},
"claw": {
"file": "claw.png",
"two_hands": false,
"caption": "Fingers curled like a claw"
},
"count-1": {
"file": "count-1.png",
"two_hands": false,
"caption": "One: index finger"
},
"count-2": {
"file": "count-2.png",
"two_hands": false,
"caption": "Two: index and middle"
},
"count-3": {
"file": "count-3.png",
"two_hands": false,
"caption": "Three: index, middle, ring"
},
"count-4": {
"file": "count-4.png",
"two_hands": false,
"caption": "Four: thumb folded in"
},
"count-5": {
"file": "count-5.png",
"two_hands": false,
"caption": "Five: all fingers spread"
},
"turn-in-out": {
"file": "turn-in-out.png",
"two_hands": false,
"caption": "Turn your wrist: palm in, palm out"
},
"turn-up-down": {
"file": "turn-up-down.png",
"two_hands": false,
"caption": "Palm down, turn it up, then back"
},
"bend": {
"file": "bend.png",
"two_hands": false,
"caption": "Bend your wrist up, down, side to side"
},
"pinch-tap": {
"file": "pinch-tap.png",
"two_hands": false,
"caption": "Tap thumb and index together"
},
"pinch-drag": {
"file": "pinch-drag.png",
"two_hands": false,
"caption": "Pinch, move to the side, let go"
},
"grab": {
"file": "grab.png",
"two_hands": false,
"caption": "Close your hand around a bar, open it"
},
"open-close": {
"file": "open-close.png",
"two_hands": false,
"caption": "Open and close your hand"
},
"cross": {
"file": "cross.png",
"two_hands": true,
"caption": "Cross your hands, then uncross"
},
"overlap": {
"file": "overlap.png",
"two_hands": true,
"caption": "One hand over the other, slide around"
},
"near-face": {
"file": "near-face.png",
"two_hands": true,
"caption": "Hands near your face, not touching"
},
"screen-point": {
"file": "screen-point.png",
"two_hands": false,
"caption": "Point at a screen at arm's length"
},
"typing": {
"file": "typing.png",
"two_hands": true,
"caption": "Type on the keyboard"
},
"lift": {
"file": "lift.png",
"two_hands": true,
"caption": "Lift your hands off the keyboard, put them back"
},
"mouse": {
"file": "mouse.png",
"two_hands": false,
"caption": "Hand on the mouse, move and click"
},
"switch": {
"file": "switch.png",
"two_hands": true,
"caption": "Switch between keyboard and mouse"
},
"hold": {
"file": "hold.png",
"two_hands": true,
"caption": "Pick it up, use it, put it down"
},
"push": {
"file": "push.png",
"two_hands": false,
"caption": "Palms out, push out and back"
},
"push-controller": {
"file": "push-controller.png",
"two_hands": false,
"caption": "Controller on, push out and back"
},
"touch-stick": {
"file": "touch-stick.png",
"two_hands": true,
"caption": "Touch the thumbstick with your other index"
},
"touch-stick-desk": {
"file": "touch-stick-desk.png",
"two_hands": true,
"caption": "Touch the thumbstick of the controller on the desk"
},
"no-hands": {
"file": "no-hands.png",
"two_hands": false,
"caption": "Hands down, out of view"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+5 -2
View File
@@ -11,8 +11,10 @@
# drafts that also needs FT_HANDREC_ALLOW_UPLOAD=1 in the environment. # drafts that also needs FT_HANDREC_ALLOW_UPLOAD=1 in the environment.
# --capture DIR the recording to play (default the frame-hands capture # --capture DIR the recording to play (default the frame-hands capture
# rec-20260930-103803-lit); --from S: start this far into it (60) # rec-20260930-103803-lit); --from S: start this far into it (60)
# --prompt-seconds N each test prompt's length (3): about 11 s are recorded in all, a few # --prompt-seconds N each test prompt's length (3). The session runs in step mode, Next
# hundred MB before compression # pressed by itself (--next-after): each prompt is a recording part of
# its own, with its 3 s countdown, so about 18 s are recorded in all, a
# few hundred MB before compression
# --keep keep the temporary folders (camera images of a room: delete them after) # --keep keep the temporary folders (camera images of a room: delete them after)
# #
# It runs in the dev container (where ft-hands, ft-handpanel, zstd and huggingface_hub are), # It runs in the dev container (where ft-hands, ft-handpanel, zstd and huggingface_hub are),
@@ -162,6 +164,7 @@ sys.exit(0 if session.Panel().cmd('ping', reply=True) else 1)" 2>/dev/null && br
sleep 0.2 sleep 0.2
done done
run python3 "$here/session.py" --ring "$ring" --no-start --base "$base" --script "$work/script.json" --lighting room \ run python3 "$here/session.py" --ring "$ring" --no-start --base "$base" --script "$work/script.json" --lighting room \
--next-after 0.3 \
</dev/null > >(indent) </dev/null > >(indent)
sid=$(ls "$base/sessions" | tail -1) sid=$(ls "$base/sessions" | tail -1)
[ -n "$sid" ] || { echo "rehearse: the session left nothing" >&2; exit 1; } [ -n "$sid" ] || { echo "rehearse: the session left nothing" >&2; exit 1; }
+36 -36
View File
@@ -3,7 +3,7 @@
"intro_s": 4, "intro_s": 4,
"between_s": 3, "between_s": 3,
"welcome": {"title": "Hand recorder", "seconds": 7, "welcome": {"title": "Hand recorder", "seconds": 7,
"text": "Follow the steps on this panel. Each one has a bar that shows its time left.|Pause: Space in the Hand recorder window."}, "text": "Follow the steps on this panel. Each one shows what to do with your hands, and where. A bar shows the time left."},
"done": {"title": "All done", "seconds": 6, "done": {"title": "All done", "seconds": 6,
"text": "Thank you! You can take off the headset.|Review your recordings in the Hand recorder window."}, "text": "Thank you! You can take off the headset.|Review your recordings in the Hand recorder window."},
"stopped": {"title": "Stopped", "seconds": 4, "stopped": {"title": "Stopped", "seconds": 4,
@@ -11,16 +11,16 @@
"objects": {"pencil": "pencil or pen", "phone": "phone", "cup": "cup", "keyboard": "keyboard", "objects": {"pencil": "pencil or pen", "phone": "phone", "cup": "cup", "keyboard": "keyboard",
"mouse": "mouse", "gamepad": "gamepad", "small": "small thing"}, "mouse": "mouse", "gamepad": "gamepad", "small": "small thing"},
"sections": [ "sections": [
{"id": "hand-size", "title": "Hand size", "requires": [], {"id": "hand-size", "title": "Hand size", "requires": [], "go": "Hold",
"intro": "First, your hand size. Hold each pose still until the bar runs out.", "intro": "First, your hand size. Hold each pose still until the bar runs out.",
"defaults": {"hands": "both", "distance": "mid", "position": "centre"}, "defaults": {"hands": "both", "distance": "mid", "position": "centre"},
"prompts": [ "prompts": [
{"text": "Both hands flat, palms toward you, about 40 cm away. Fingers together.", "seconds": 8, "pose": "flat"}, {"text": "Both hands flat, palms toward you, about 40 cm away. Fingers together.", "seconds": 6, "pose": "flat"},
{"text": "Turn them around: backs of your hands toward you.", "seconds": 6, "pose": "flat-back"}, {"text": "Turn them around: backs of your hands toward you.", "seconds": 5, "pose": "flat-back"},
{"text": "Palms toward you again, fingers spread wide.", "seconds": 6, "pose": "spread"} {"text": "Palms toward you again, fingers spread wide.", "seconds": 5, "pose": "spread"}
]}, ]},
{"id": "static-poses", "title": "Hand poses", "requires": [], {"id": "static-poses", "title": "Hand poses", "requires": [], "go": "Hold",
"intro": "Now some poses. Close means a hand's length away. Far means your arm stretched out.", "intro": "Now some poses. Close means a hand's length away. Far means your arm stretched out.",
"prompts": [ "prompts": [
{"text": "Both hands: open and relaxed. Halfway out, in front of you.", "seconds": 5, "hands": "both", "pose": "open", "distance": "mid", "position": "centre"}, {"text": "Both hands: open and relaxed. Halfway out, in front of you.", "seconds": 5, "hands": "both", "pose": "open", "distance": "mid", "position": "centre"},
@@ -67,24 +67,24 @@
"intro": "Slow wrist turns. Keep your fingers relaxed.", "intro": "Slow wrist turns. Keep your fingers relaxed.",
"defaults": {"pose": "open", "distance": "mid"}, "defaults": {"pose": "open", "distance": "mid"},
"prompts": [ "prompts": [
{"text": "Both hands open, palms toward you. Slowly turn them palms away, then back.", "seconds": 8, "hands": "both", "pose": "turn-in-out"}, {"text": "Both hands open, palms toward you. Slowly turn them palms away, then back.", "seconds": 10, "hands": "both", "pose": "turn-in-out"},
{"text": "Left hand, fingers up. Slowly turn your wrist: palm in, palm out.", "seconds": 7, "hands": "left", "pose": "turn-in-out"}, {"text": "Left hand, fingers up. Slowly turn your wrist: palm in, palm out.", "seconds": 9, "hands": "left", "pose": "turn-in-out"},
{"text": "Right hand, fingers up. Slowly turn your wrist: palm in, palm out.", "seconds": 7, "hands": "right", "pose": "turn-in-out"}, {"text": "Right hand, fingers up. Slowly turn your wrist: palm in, palm out.", "seconds": 9, "hands": "right", "pose": "turn-in-out"},
{"text": "Both hands flat, palms down. Slowly turn them palms up, then down again.", "seconds": 8, "hands": "both", "pose": "turn-up-down"}, {"text": "Both hands flat, palms down. Slowly turn them palms up, then down again.", "seconds": 10, "hands": "both", "pose": "turn-up-down"},
{"text": "Bend both wrists slowly up and down, then side to side.", "seconds": 8, "hands": "both", "pose": "bend"} {"text": "Bend both wrists slowly up and down, then side to side.", "seconds": 10, "hands": "both", "pose": "bend"}
]}, ]},
{"id": "gestures", "title": "Gestures", "requires": [], {"id": "gestures", "title": "Gestures", "requires": [],
"intro": "Now some movements. Go at a calm, normal pace.", "intro": "Now some movements. Go at a calm, normal pace.",
"defaults": {"hands": "both"}, "defaults": {"hands": "both"},
"prompts": [ "prompts": [
{"text": "Tap your thumb and index finger together a few times, both hands.", "seconds": 6, "pose": "pinch-tap"}, {"text": "Tap your thumb and index finger together a few times, both hands.", "seconds": 8, "pose": "pinch-tap"},
{"text": "Pinch, move your hand slowly to the side, then let go. Do it again.", "seconds": 8, "pose": "pinch-drag"}, {"text": "Pinch, move your hand slowly to the side, then let go. Do it again.", "seconds": 10, "pose": "pinch-drag"},
{"text": "Close your hands as if grabbing a bar, then open them. A few times.", "seconds": 6, "pose": "grab"}, {"text": "Close your hands as if grabbing a bar, then open them. A few times.", "seconds": 8, "pose": "grab"},
{"text": "Cross your hands in front of you, then uncross them. Slowly.", "seconds": 8, "pose": "cross"}, {"text": "Cross your hands in front of you, then uncross them. Slowly.", "seconds": 10, "pose": "cross"},
{"text": "Hold one hand over the other, palms toward you, and slide them around.", "seconds": 6, "pose": "overlap"}, {"text": "Hold one hand over the other, palms toward you, and slide them around.", "seconds": 8, "pose": "overlap"},
{"text": "Bring your hands close to your face and move them around it. Don't touch the headset.", "seconds": 6, "pose": "near-face", "distance": "near"}, {"text": "Bring your hands close to your face and move them around it. Don't touch the headset.", "seconds": 8, "pose": "near-face", "distance": "near"},
{"text": "Reach out to where a screen would be, about arm's length, and point at things on it.", "seconds": 6, "pose": "screen-point", "distance": "far"} {"text": "Reach out to where a screen would be, about arm's length, and point at things on it.", "seconds": 8, "pose": "screen-point", "distance": "far"}
]}, ]},
{"id": "desk-work", "title": "Desk work", "requires": [], {"id": "desk-work", "title": "Desk work", "requires": [],
@@ -93,10 +93,10 @@
"prompts": [ "prompts": [
{"when": "objects:keyboard", "text": "Type on your keyboard the way you normally do. Any text is fine.", "seconds": 10, "pose": "typing", "object": "keyboard"}, {"when": "objects:keyboard", "text": "Type on your keyboard the way you normally do. Any text is fine.", "seconds": 10, "pose": "typing", "object": "keyboard"},
{"when": "!objects:keyboard", "text": "Rest your hands on the desk and pretend to type.", "seconds": 10, "pose": "typing"}, {"when": "!objects:keyboard", "text": "Rest your hands on the desk and pretend to type.", "seconds": 10, "pose": "typing"},
{"when": "objects:keyboard", "text": "Lift your hands off the keyboard and put them back, a few times.", "seconds": 6, "pose": "lift", "object": "keyboard"}, {"when": "objects:keyboard", "text": "Lift your hands off the keyboard and put them back, a few times.", "seconds": 8, "pose": "lift", "object": "keyboard"},
{"when": "objects:mouse", "text": "Use your mouse: move it around and click a few times.", "seconds": 10, "hands": "right", "pose": "mouse", "object": "mouse"}, {"when": "objects:mouse", "text": "Use your mouse: move it around and click a few times.", "seconds": 10, "hands": "right", "pose": "mouse", "object": "mouse"},
{"when": "!objects:mouse", "text": "Put one hand flat on the desk and slide it around, as if using a mouse.", "seconds": 8, "hands": "right", "pose": "mouse"}, {"when": "!objects:mouse", "text": "Put one hand flat on the desk and slide it around, as if using a mouse.", "seconds": 10, "hands": "right", "pose": "mouse"},
{"when": "objects:keyboard", "text": "Switch between typing and the mouse a few times.", "seconds": 8, "pose": "switch", "object": "keyboard"} {"when": "objects:keyboard", "text": "Switch between typing and the mouse a few times.", "seconds": 10, "pose": "switch", "object": "keyboard"}
]}, ]},
{"id": "objects", "title": "Things you hold", "requires": ["objects"], "for_each": "object", {"id": "objects", "title": "Things you hold", "requires": ["objects"], "for_each": "object",
@@ -117,9 +117,9 @@
[0.25, -0.05, -0.50], [0.05, -0.10, -0.55]]}, [0.25, -0.05, -0.50], [0.05, -0.10, -0.55]]},
{"id": "controller-push", "title": "Depth with controllers", "kind": "bar", "requires": ["controllers"], {"id": "controller-push", "title": "Depth with controllers", "kind": "bar", "requires": ["controllers"],
"before": {"text": "Put on both controllers and tighten the straps.|Take your time: Space pauses.", "seconds": 25}, "before": {"text": "Put on both controllers and tighten the straps. Take your time.", "seconds": 25},
"intro": "Follow the marker on the bar with both hands: out as it moves right, back as it moves left.", "intro": "Follow the marker on the bar with both hands: out as it moves right, back as it moves left.",
"defaults": {"hands": "both", "pose": "open", "controller": true, "controllers": ["left", "right"]}, "defaults": {"hands": "both", "pose": "open", "picture": "push-controller", "controller": true, "controllers": ["left", "right"]},
"near_m": 0.2, "far_m": 0.6, "period_s": 6, "lead_s": 3, "reps": 5, "near_m": 0.2, "far_m": 0.6, "period_s": 6, "lead_s": 3, "reps": 5,
"heights": [ "heights": [
{"id": "chest", "text": "Chest height: hands open, palms out like a stop sign. Push out and back with the marker."}, {"id": "chest", "text": "Chest height: hands open, palms out like a stop sign. Push out and back with the marker."},
@@ -129,8 +129,8 @@
{"id": "right", "text": "Now off to your right side. Push out and back with the marker.", "reps": 2} {"id": "right", "text": "Now off to your right side. Push out and back with the marker.", "reps": 2}
], ],
"prompts": [ "prompts": [
{"text": "Slowly turn your wrists: palms out, palms toward you, and back.", "seconds": 8, "pose": "turn-in-out", "distance": "mid"}, {"text": "Slowly turn your wrists: palms out, palms toward you, and back.", "seconds": 10, "pose": "turn-in-out", "distance": "mid"},
{"text": "Open and close your hands a few times.", "seconds": 6, "pose": "open-close", "distance": "mid"} {"text": "Open and close your hands a few times.", "seconds": 8, "pose": "open-close", "distance": "mid"}
]}, ]},
{"id": "bridge", "title": "Controller and fingertip", "requires": ["controllers"], {"id": "bridge", "title": "Controller and fingertip", "requires": ["controllers"],
@@ -142,20 +142,20 @@
{"text": "Keep touching it while you move your left hand halfway out.", "seconds": 6, "hands": "both", "pose": "touch-stick", "distance": "mid", "controllers": ["left"]}, {"text": "Keep touching it while you move your left hand halfway out.", "seconds": 6, "hands": "both", "pose": "touch-stick", "distance": "mid", "controllers": ["left"]},
{"text": "Keep touching it with your left arm stretched out.", "seconds": 6, "hands": "both", "pose": "touch-stick", "distance": "far", "controllers": ["left"]}, {"text": "Keep touching it with your left arm stretched out.", "seconds": 6, "hands": "both", "pose": "touch-stick", "distance": "far", "controllers": ["left"]},
{"text": "Swap: put the right controller on, then take the left one off.", "seconds": 12, "hands": "both"}, {"text": "Swap: put the right controller on, then take the left one off.", "seconds": 12, "hands": "both"},
{"text": "Right hand close to you. Touch the right thumbstick with your left index fingertip.", "seconds": 6, "hands": "both", "pose": "touch-stick", "distance": "near", "controllers": ["right"]}, {"text": "Right hand close to you. Touch the right thumbstick with your left index fingertip.", "seconds": 6, "hands": "both", "pose": "touch-stick", "picture_mirror": true, "distance": "near", "controllers": ["right"]},
{"text": "Keep touching it while you move your right hand halfway out.", "seconds": 6, "hands": "both", "pose": "touch-stick", "distance": "mid", "controllers": ["right"]}, {"text": "Keep touching it while you move your right hand halfway out.", "seconds": 6, "hands": "both", "pose": "touch-stick", "picture_mirror": true, "distance": "mid", "controllers": ["right"]},
{"text": "Keep touching it with your right arm stretched out.", "seconds": 6, "hands": "both", "pose": "touch-stick", "distance": "far", "controllers": ["right"]}, {"text": "Keep touching it with your right arm stretched out.", "seconds": 6, "hands": "both", "pose": "touch-stick", "picture_mirror": true, "distance": "far", "controllers": ["right"]},
{"text": "Take off the right controller and put it on the desk in front of you.", "seconds": 8, "hands": "both"}, {"text": "Take off the right controller and put it on the desk in front of you.", "seconds": 8, "hands": "both"},
{"text": "Touch its thumbstick with your index fingertip, from the front.", "seconds": 5, "hands": "any", "pose": "touch-stick", "position": "down"}, {"text": "Touch its thumbstick with your index fingertip, from the front.", "seconds": 5, "hands": "any", "pose": "touch-stick", "picture": "touch-stick-desk", "position": "down"},
{"text": "Now touch the thumbstick reaching from the left.", "seconds": 5, "hands": "any", "pose": "touch-stick", "position": "left"}, {"text": "Now touch the thumbstick reaching from the left.", "seconds": 5, "hands": "any", "pose": "touch-stick", "picture": "touch-stick-desk", "position": "left"},
{"text": "Now from the right.", "seconds": 5, "hands": "any", "pose": "touch-stick", "position": "right"}, {"text": "Now from the right.", "seconds": 5, "hands": "any", "pose": "touch-stick", "picture": "touch-stick-desk", "position": "right"},
{"text": "Now from straight above, with your other hand.", "seconds": 5, "hands": "any", "pose": "touch-stick", "position": "up"} {"text": "Now from straight above, with your other hand.", "seconds": 5, "hands": "any", "pose": "touch-stick", "picture": "touch-stick-desk", "position": "up"}
]}, ]},
{"id": "bare-push", "title": "Depth, bare hands", "kind": "bar", "requires": [], {"id": "bare-push", "title": "Depth, bare hands", "kind": "bar", "requires": [],
"before": {"when": "controllers", "text": "Take the controllers off and put them out of view.", "seconds": 15}, "before": {"when": "controllers", "text": "Take the controllers off and put them out of view.", "seconds": 15},
"intro": "Follow the marker on the bar with both hands: out as it moves right, back as it moves left.", "intro": "Follow the marker on the bar with both hands: out as it moves right, back as it moves left.",
"defaults": {"hands": "both", "pose": "open"}, "defaults": {"hands": "both", "pose": "open", "picture": "push"},
"near_m": 0.2, "far_m": 0.6, "period_s": 6, "lead_s": 3, "reps": 4, "near_m": 0.2, "far_m": 0.6, "period_s": 6, "lead_s": 3, "reps": 4,
"heights": [ "heights": [
{"id": "chest", "text": "Chest height: hands open, palms out like a stop sign. Push out and back with the marker."}, {"id": "chest", "text": "Chest height: hands open, palms out like a stop sign. Push out and back with the marker."},
@@ -165,14 +165,14 @@
{"id": "right", "text": "Now off to your right side. Push out and back with the marker.", "reps": 2} {"id": "right", "text": "Now off to your right side. Push out and back with the marker.", "reps": 2}
], ],
"prompts": [ "prompts": [
{"text": "Slowly turn your wrists: palms out, palms toward you, and back.", "seconds": 8, "pose": "turn-in-out", "distance": "mid"}, {"text": "Slowly turn your wrists: palms out, palms toward you, and back.", "seconds": 10, "pose": "turn-in-out", "distance": "mid"},
{"text": "Open and close your hands a few times.", "seconds": 6, "pose": "open-close", "distance": "mid"} {"text": "Open and close your hands a few times.", "seconds": 8, "pose": "open-close", "distance": "mid"}
]}, ]},
{"id": "no-hands", "title": "No hands", "requires": [], {"id": "no-hands", "title": "No hands", "requires": [],
"intro": "Last one: put your hands down, out of view.", "intro": "Last one: put your hands down, out of view.",
"prompts": [ "prompts": [
{"text": "Keep your hands out of view and look slowly around the room.", "seconds": 10, "hands": "none"} {"text": "Keep your hands out of view and look slowly around the room.", "seconds": 10, "hands": "none", "picture": "no-hands"}
]} ]}
] ]
} }
+451 -102
View File
@@ -11,9 +11,13 @@ gives feedback ("I can't see your left hand").
Plain Python, standard library only: ft_handrec.py imports it, and it runs from the command line Plain Python, standard library only: ft_handrec.py imports it, and it runs from the command line
for testing (in the dev container): for testing (in the dev container):
python3 hands/rec/session.py --dry-run --speed 20 # no processes: prints the panel commands python3 hands/rec/session.py --dry-run --speed 20 --next-after 0.2 # no processes: prints the panel commands
python3 hands/rec/session.py --ring /tmp/ring --base /tmp/hr # ft-ringplay's frames, no headset needed python3 hands/rec/session.py --ring /tmp/ring --base /tmp/hr # ft-ringplay's frames, no headset needed
Step mode (the default) shows each step and waits for Next (Space in the window, n or Enter
here); a 3-2-1 countdown, recorded, then the hold. Only the countdowns and holds are recorded:
each is a part of the take's recording (sets-N.bin). --auto is the old timed flow.
All _ns times are CLOCK_MONOTONIC nanoseconds. All _ns times are CLOCK_MONOTONIC nanoseconds.
""" """
import argparse import argparse
@@ -40,6 +44,7 @@ FT_HANDS = os.path.join(HANDS, "build", "ft-hands")
FT_CAMD = os.path.join(HANDS, "build", "ft-camd") FT_CAMD = os.path.join(HANDS, "build", "ft-camd")
PANEL_BIN = os.path.join(HERE, "build", "ft-handpanel") PANEL_BIN = os.path.join(HERE, "build", "ft-handpanel")
SCRIPT_PATH = os.path.join(HERE, "script.json") SCRIPT_PATH = os.path.join(HERE, "script.json")
POSES_DIR = os.path.join(HERE, "poses") # the pose pictures: poses.json and its PNGs
BASE_DIR = os.path.expanduser("~/.local/share/frametop/hands/contrib") BASE_DIR = os.path.expanduser("~/.local/share/frametop/hands/contrib")
PANEL_SOCKET = "ft_handpanel" PANEL_SOCKET = "ft_handpanel"
CAMD_UNIT = "frametop-handrec-camd.service" CAMD_UNIT = "frametop-handrec-camd.service"
@@ -52,7 +57,12 @@ LOST_S = 1.5 # an asked-for hand lost this long gets a note
CONTROLLER_LOST_S = 1.0 # a controller off 200 (Running_OK) this long gets a note CONTROLLER_LOST_S = 1.0 # a controller off 200 (Running_OK) this long gets a note
TOUCH_M = 0.03 # touch the dot: the index tip within this of the dot TOUCH_M = 0.03 # touch the dot: the index tip within this of the dot
MIN_FREE = 1.5e9 # stop the session before the disk fills MIN_FREE = 1.5e9 # stop the session before the disk fills
RESUME_HINT = "Paused. Resume: Space in the Hand recorder window" COUNTDOWN_S = 3 # step mode: the 3-2-1 before each step, recorded
FIRST_SET_S = 3.0 # step mode: how long the hold may wait for its recording's first set
RESUME_HINT = "Paused. Resume: P in the Hand recorder window"
READY_TEXT = "Ready? Press Space or click Next"
KEYS_STEP = "Hand recorder window: Space next \u00b7 P pause \u00b7 R redo \u00b7 S skip section \u00b7 Esc stop"
KEYS_AUTO = "Hand recorder window: P pause \u00b7 R redo \u00b7 S skip section \u00b7 Esc stop"
def mono_ns(): def mono_ns():
@@ -470,6 +480,13 @@ class Recorder:
def exited(self): def exited(self):
return self.proc.poll() is not None return self.proc.poll() is not None
def has_data(self):
"""It has written a set (ft-hands starts recording within a few tens of milliseconds)."""
try:
return os.path.getsize(os.path.join(self.dir, "sets.bin")) > 0
except OSError:
return False
def stop(self): def stop(self):
"""End it (SIGTERM: ft-hands writes out its queue) and put the part in place.""" """End it (SIGTERM: ft-hands writes out its queue) and put the part in place."""
if self.proc.poll() is None: if self.proc.poll() is None:
@@ -490,6 +507,40 @@ class Recorder:
return self.proc.returncode return self.proc.returncode
# ------------------------------------------------------------------------------------------
# The pose pictures (poses/poses.json: {"<pose>": {"file": "<name>.png", "two_hands", "caption"}})
def load_poses(poses_dir):
"""{pose id: {"path", "two_hands", "caption"}} from poses_dir/poses.json, or {} without one."""
try:
with open(os.path.join(poses_dir, "poses.json")) as f:
raw = json.load(f)
except (OSError, ValueError):
return {}
out = {}
for pose, e in (raw.items() if isinstance(raw, dict) else ()):
if isinstance(e, dict) and isinstance(e.get("file"), str) and e["file"]:
out[pose] = {"path": os.path.join(poses_dir, os.path.basename(e["file"])),
"two_hands": bool(e.get("two_hands")), "caption": clean_text(e.get("caption", ""))}
return out
def pose_view(poses, p):
"""A prompt's picture: (path, mode, caption), mode "" (as drawn: a right hand, or both hands),
"mirror" (a left hand) or "both" (a mirrored copy on the left, the picture on the right).
The prompt's "picture" (a pose id) picks a picture other than its pose's, and
"picture_mirror" mirrors a two-hand picture. ("", "", "") when there's none."""
e = poses.get(p.get("picture") or p.get("pose") or "")
if not e or not os.path.isfile(e["path"]):
return "", "", ""
hands = p.get("hands")
if e["two_hands"]:
mode = "mirror" if p.get("picture_mirror") else ""
else:
mode = "mirror" if hands == "left" else "both" if hands == "both" else ""
return e["path"], mode, e["caption"]
# ------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------
# The script # The script
@@ -595,17 +646,30 @@ def build_plan(script, checklist):
return plan, skipped return plan, skipped
def section_seconds(s, worst=False): def section_steps(s):
"""A section's length in script seconds (targets: about 4 s each, or the timeout if worst).""" """The steps that wait for Next in step mode: each prompt and bar height, and the first target."""
t = s["intro_s"] + sum(p["seconds"] for p in s["prompts"]) return (len(s["prompts"]) + (len(s.get("heights") or []) if s["kind"] == "bar" else 0)
+ (1 if s["kind"] == "targets" and s.get("targets") else 0))
def section_seconds(s, worst=False, auto=True):
"""A section's length in script seconds (targets: about 4 s each, or the timeout if worst).
In step mode, what's recorded: a countdown before each step and the holds; the intro and
the waits for Next aren't."""
t = (s["intro_s"] if auto else COUNTDOWN_S * section_steps(s)) + sum(p["seconds"] for p in s["prompts"])
if s["kind"] == "targets": if s["kind"] == "targets":
t += len(s["targets"]) * (s.get("timeout_s", 8) if worst else min(4.0, s.get("timeout_s", 8))) t += len(s["targets"]) * (s.get("timeout_s", 8) if worst else min(4.0, s.get("timeout_s", 8)))
if s["kind"] == "bar": if s["kind"] == "bar":
t += sum(s.get("lead_s", 3) + h["reps"] * s.get("period_s", 6) for h in s["heights"]) lead = s.get("lead_s", 3) if auto else 0
t += sum(lead + h["reps"] * s.get("period_s", 6) for h in s["heights"])
return t return t
def plan_seconds(script, plan, worst=False): def plan_seconds(script, plan, worst=False, auto=True):
"""The session's length: in auto mode all of it; in step mode only what's recorded, as the
time spent reading each step before pressing Next is up to the person (plan_steps)."""
if not auto:
return sum(section_seconds(s, worst, auto=False) for s in plan)
t = (script.get("welcome") or {}).get("seconds", 0) + (script.get("done") or {}).get("seconds", 0) t = (script.get("welcome") or {}).get("seconds", 0) + (script.get("done") or {}).get("seconds", 0)
for i, s in enumerate(plan): for i, s in enumerate(plan):
t += section_seconds(s, worst) t += section_seconds(s, worst)
@@ -613,6 +677,20 @@ def plan_seconds(script, plan, worst=False):
return t return t
def plan_steps(plan):
return sum(section_steps(s) for s in plan)
def plan_summary(script, plan, auto=False):
"""The length in words, for the window and --plan."""
minutes = max(1, round(plan_seconds(script, plan, auto=auto) / 60))
if auto:
return "about %d min, each step advancing by itself" % minutes
n = plan_steps(plan)
return ("about %d min of recording in %d steps, plus the time you take to read each step before "
"pressing Next (at 5 s a step, about %d min more)" % (minutes, n, max(1, round(n * 5 / 60))))
# ------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------
# The session # The session
@@ -628,6 +706,10 @@ class _Fail(Exception):
pass pass
class _Redo(Exception):
pass
def _git_describe(): def _git_describe():
try: try:
r = subprocess.run(["git", "-C", REPO, "describe", "--always", "--dirty", "--tags"], r = subprocess.run(["git", "-C", REPO, "describe", "--always", "--dirty", "--tags"],
@@ -665,12 +747,18 @@ def _steamvr_version():
class Session: class Session:
"""One recording session. start() runs it in its own thread; pause(), resume(), skip() """One recording session. start() runs it in its own thread; next_step(), pause(), resume(),
and stop() steer it from any thread. on_status(dict) is called from the session thread redo(), skip() and stop() steer it from any thread. on_status(dict) is called from the
whenever something changes (see STATUS_KEYS).""" session thread whenever something changes (the keys of _status).
auto=False is step mode: each step waits for next_step(), then a recorded countdown, then
the hold; nothing is recorded while it waits. auto=True: the timed flow, each prompt
advancing by itself, one recording per take. next_after (a test hook): press Next by itself
after that many seconds of waiting. poses_dir: the pose pictures (poses/)."""
def __init__(self, base_dir, profile, checklist, lighting_choice, script_path, *, ring=None, def __init__(self, base_dir, profile, checklist, lighting_choice, script_path, *, ring=None,
start_processes=True, dry_run=False, speed=1.0, on_status=None, hands_dir=None, panel_bin=None): start_processes=True, dry_run=False, speed=1.0, on_status=None, hands_dir=None, panel_bin=None,
auto=False, next_after=None, poses_dir=None):
self.base_dir = os.path.abspath(os.path.expanduser(base_dir or BASE_DIR)) self.base_dir = os.path.abspath(os.path.expanduser(base_dir or BASE_DIR))
self.profile = dict(profile or {}) self.profile = dict(profile or {})
self.checklist = dict(checklist or {}) self.checklist = dict(checklist or {})
@@ -683,17 +771,26 @@ class Session:
self.on_status = on_status self.on_status = on_status
self.hands_dir = hands_dir or run_dir() self.hands_dir = hands_dir or run_dir()
self.panel_bin = panel_bin or PANEL_BIN self.panel_bin = panel_bin or PANEL_BIN
self.auto = bool(auto)
self.next_after = next_after
self.print = print # where dry-run panel commands go (the CLI's stdout) self.print = print # where dry-run panel commands go (the CLI's stdout)
self.script = load_script(self.script_path) self.script = load_script(self.script_path)
self.plan, self.skipped = build_plan(self.script, self.checklist) self.plan, self.skipped = build_plan(self.script, self.checklist)
self._poses = load_poses(poses_dir or POSES_DIR)
self.session_dir = "" self.session_dir = ""
self._thread = None self._thread = None
self._lock = threading.Lock() self._lock = threading.Lock()
self._want = {"pause": False, "skip": False, "stop": False} self._want = {"pause": False, "skip": False, "stop": False, "next": False, "redo": False}
self._wake = threading.Event() self._wake = threading.Event()
self._status = {"state": "starting", "section": "", "title": "", "section_index": 0, # state: starting, intro, ready (a step waits for Next), countdown, running (a hold),
"section_count": len(self.plan), "prompt": "", "seconds_left": 0.0, "note": "", # between, paused, done, stopped, error. waiting: Next is wanted. big: the countdown's
"hands": {"left": None, "right": None}, "take": None, "error": ""} # number, then the hold's word ("Hold", "Go"), as the panel shows them. The picture and the
# diagram: image (a path or ""), image_mode ("", "mirror", "both"), position, distance.
self._status = {"state": "starting", "mode": "auto" if self.auto else "step", "section": "", "title": "",
"section_index": 0, "section_count": len(self.plan), "step_index": 0, "step_count": 0,
"prompt": "", "seconds_left": 0.0, "note": "", "hands": {"left": None, "right": None},
"take": None, "error": "", "waiting": False, "countdown": 0, "big": "", "can_redo": False,
"image": "", "image_mode": "", "caption": "", "position": "", "distance": ""}
self._last_emit = 0.0 self._last_emit = 0.0
self._log_file = None self._log_file = None
self._panel = None self._panel = None
@@ -706,6 +803,11 @@ class Session:
self._live = None # the hands file's last read self._live = None # the hands file's last read
self._hands_file = HandsFile(os.path.join(self.hands_dir, "hands")) self._hands_file = HandsFile(os.path.join(self.hands_dir, "hands"))
self._paused = False self._paused = False
self._recording = False # a recording part is running
self._paused_recording = False
self._waiting = False # waiting for Next: no notes about lost hands
self._redo_ok = False # R does something now
self._step_t0 = None # the step's first event (ready or prompt), for R
self._fb = {} # feedback timers self._fb = {} # feedback timers
# --- controls (any thread) # --- controls (any thread)
@@ -720,6 +822,14 @@ class Session:
self._want[key] = value self._want[key] = value
self._wake.set() self._wake.set()
def next_step(self):
"""Step mode: start the step that's waiting (its countdown)."""
self._set("next", True)
def redo(self):
"""Record a step again: the one running, or at a step's ready screen the one before."""
self._set("redo", True)
def pause(self): def pause(self):
self._set("pause", True) self._set("pause", True)
@@ -763,12 +873,15 @@ class Session:
self._log_file.flush() self._log_file.flush()
def _event(self, event, **fields): def _event(self, event, **fields):
"""A line in the take's prompts.jsonl; returns its time."""
t = mono_ns()
if not self._take: if not self._take:
return return t
line = {"t": mono_ns(), "event": event} line = {"t": t, "event": event}
line.update(fields) line.update(fields)
self._take["prompts"].write(json.dumps(line) + "\n") self._take["prompts"].write(json.dumps(line) + "\n")
self._take["prompts"].flush() self._take["prompts"].flush()
return t
# --- the run # --- the run
def _run(self): def _run(self):
@@ -838,7 +951,7 @@ class Session:
"calibration_removed": removed, "calibration_removed": removed,
"script": {"version": self.script.get("version"), "sections": [s["id"] for s in self.plan], "script": {"version": self.script.get("version"), "sections": [s["id"] for s in self.plan],
"skipped": self.skipped}, "skipped": self.skipped},
"takes": [], "status": "recording"} "mode": "auto" if self.auto else "step", "takes": [], "status": "recording"}
if self.dry_run: if self.dry_run:
self._session_json["dry_run"] = True self._session_json["dry_run"] = True
if self.speed != 1: if self.speed != 1:
@@ -850,7 +963,9 @@ class Session:
self._panel.cmd("show") self._panel.cmd("show")
self._panel.cmd("paused off") self._panel.cmd("paused off")
for key, c in (("note", "note "), ("countdown", "countdown off"), ("hands", "hands off off"), for key, c in (("note", "note "), ("countdown", "countdown off"), ("hands", "hands off off"),
("bar", "bar off"), ("target", "target off")): ("bar", "bar off"), ("target", "target off"), ("image", "image off"), ("where", "where off"),
("big", "big "), ("action", "action "), ("rec", "rec off"),
("keys", "keys " + (KEYS_AUTO if self.auto else KEYS_STEP))):
self._panel.set(key, c) self._panel.set(key, c)
def _write_calibration(self): def _write_calibration(self):
@@ -1007,6 +1122,9 @@ class Session:
"seconds": 4}}[state] "seconds": 4}}[state]
try: try:
self._panel.cmd("paused off") self._panel.cmd("paused off")
for key, c in (("big", "big "), ("action", "action "), ("rec", "rec off"), ("keys", "keys "),
("bar", "bar off"), ("target", "target off")):
self._panel.set(key, c)
self._screen(state, screen, controls=False) self._screen(state, screen, controls=False)
except (_Stop, _Skip, _Fail): except (_Stop, _Skip, _Fail):
pass pass
@@ -1016,18 +1134,23 @@ class Session:
self._log_file = None self._log_file = None
screen = {"done": self.script.get("done"), "stopped": self.script.get("stopped")}.get(state) or {} screen = {"done": self.script.get("done"), "stopped": self.script.get("stopped")}.get(state) or {}
self._emit(state=state, error=error, seconds_left=0.0, note="", take=None, section="", self._emit(state=state, error=error, seconds_left=0.0, note="", take=None, section="",
prompt=error or screen.get("text", "").replace("|", "\n"), hands={"left": None, "right": None}) prompt=error or screen.get("text", "").replace("|", "\n"), hands={"left": None, "right": None},
waiting=False, countdown=0, big="", can_redo=False, image="", image_mode="", caption="",
position="", distance="")
# --- the timing loop # --- the timing loop
def _controls(self): def _controls(self):
"""Handle a pause (blocking until resumed), skip and stop. True if a pause happened.""" """Handle a pause (blocking until resumed), skip, stop and redo (only where _redo_ok:
else it's dropped). True if a pause happened."""
with self._lock: with self._lock:
want = dict(self._want) want = dict(self._want)
self._want["skip"] = False self._want["skip"] = self._want["redo"] = False
if want["stop"]: if want["stop"]:
raise _Stop() raise _Stop()
if want["skip"]: if want["skip"]:
raise _Skip() raise _Skip()
if want["redo"] and self._redo_ok:
raise _Redo()
if not want["pause"]: if not want["pause"]:
return False return False
self._pause() self._pause()
@@ -1037,13 +1160,16 @@ class Session:
self._wake.clear() self._wake.clear()
with self._lock: with self._lock:
want = dict(self._want) want = dict(self._want)
self._want["skip"] = False self._want["skip"] = self._want["redo"] = False
if want["skip"]: redo = want["redo"] and self._redo_ok
if want["skip"] or redo:
self._want["pause"] = False self._want["pause"] = False
if want["stop"]: if want["stop"]:
outcome = _Stop outcome = _Stop
elif want["skip"]: elif want["skip"]:
outcome = _Skip outcome = _Skip
elif redo:
outcome = _Redo
elif not want["pause"]: elif not want["pause"]:
outcome = True outcome = True
self._unpause(record=outcome is True) self._unpause(record=outcome is True)
@@ -1054,7 +1180,8 @@ class Session:
def _pause(self): def _pause(self):
self._paused = True self._paused = True
self._state_before = self._status["state"] self._state_before = self._status["state"]
if self._take: self._paused_recording = self._recording
if self._recording:
self._stop_recording() self._stop_recording()
self._event("pause") self._event("pause")
self._panel.cmd("paused on") self._panel.cmd("paused on")
@@ -1067,7 +1194,9 @@ class Session:
self._panel.cmd("paused off") self._panel.cmd("paused off")
self._panel.set("note", "note ") self._panel.set("note", "note ")
self._fb.clear() self._fb.clear()
if self._take and record: with self._lock:
self._want["next"] = False # a Next pressed while paused doesn't count
if self._take and self._paused_recording and record:
self._event("resume") self._event("resume")
self._start_recording() self._start_recording()
self._emit(state=self._state_before, note="") self._emit(state=self._state_before, note="")
@@ -1115,6 +1244,7 @@ class Session:
self._live = live self._live = live
p = self._prompt or {} p = self._prompt or {}
asked = p.get("hands", "") asked = p.get("hands", "")
# While a step waits for Next the chips show what's seen, with no notes yet.
seen = {s: (bool(live[s]) if live else None) for s in ("left", "right")} seen = {s: (bool(live[s]) if live else None) for s in ("left", "right")}
# the chips: the asked-for hands, seen or lost, while the tracker publishes # the chips: the asked-for hands, seen or lost, while the tracker publishes
chips = [] chips = []
@@ -1124,7 +1254,7 @@ class Session:
self._panel.set("hands", "hands %s %s" % tuple(chips)) self._panel.set("hands", "hands %s %s" % tuple(chips))
# a note when an asked-for hand stays lost, or a hand shows when none is wanted # a note when an asked-for hand stays lost, or a hand shows when none is wanted
notes = [] notes = []
if live is not None and asked: if live is not None and asked and not self._waiting:
if asked == "none": if asked == "none":
missing = [] if not (seen["left"] or seen["right"]) else ["shown"] missing = [] if not (seen["left"] or seen["right"]) else ["shown"]
elif asked == "any": elif asked == "any":
@@ -1143,14 +1273,14 @@ class Session:
notes.append("I can't see your %s hand: bring it into view" % missing[0]) notes.append("I can't see your %s hand: bring it into view" % missing[0])
else: else:
fb.pop("lost_key", None) fb.pop("lost_key", None)
notes = self._controller_feedback(now, p) + notes notes = ([] if self._waiting else self._controller_feedback(now, p)) + notes
note = notes[0] if notes else "" note = notes[0] if notes else ""
if not self._paused: if not self._paused:
self._panel.set("note", "note " + note) self._panel.set("note", "note " + note)
hands = {"left": seen["left"], "right": seen["right"]} hands = {"left": seen["left"], "right": seen["right"]}
if note != self._status["note"] or hands != self._status["hands"]: if note != self._status["note"] or hands != self._status["hands"]:
self._emit(note=note, hands=hands) self._emit(note=note, hands=hands)
if self._take and live is not None and now - fb.get("logged", 0) >= 0.5: if self._take and self._recording and live is not None and now - fb.get("logged", 0) >= 0.5:
fb["logged"] = now fb["logged"] = now
self._event("feedback", left=seen["left"], right=seen["right"], self._event("feedback", left=seen["left"], right=seen["right"],
palm_m=[round(live[s]["palm_m"], 4) if live[s] else None for s in ("left", "right")]) palm_m=[round(live[s]["palm_m"], 4) if live[s] else None for s in ("left", "right")])
@@ -1199,19 +1329,58 @@ class Session:
if text is not None: if text is not None:
self._panel.set("text", "text " + clean_text(text)) self._panel.set("text", "text " + clean_text(text))
def _view(self, p=None):
"""The prompt's picture and where-to diagram, on the panel and in the status (sent with
the next _emit); none without a prompt."""
p = p or {}
path, mode, caption = pose_view(self._poses, p)
pos, dist = p.get("position") or "", p.get("distance") or ""
self._panel.set("image", "image %s%s" % (path, " " + mode if mode else "") if path else "image off")
self._panel.set("where", "where %s %s" % (clean_text(pos) or "-", clean_text(dist) or "-")
if pos or dist else "where off")
self._status.update(image=path, image_mode=mode, caption=caption, position=pos, distance=dist)
def _await_next(self):
"""Step mode: wait for Next (or the next_after test hook), handling the controls and the
hands chips. Nothing records meanwhile."""
with self._lock:
self._want["next"] = False # one pressed during the hold doesn't skip this
self._waiting = True
self._panel.set("action", "action " + READY_TEXT)
self._emit(waiting=True, seconds_left=0.0)
t0 = time.monotonic()
try:
while True:
self._controls()
self._feedback()
with self._lock:
go, self._want["next"] = self._want["next"], False
if go or (self.next_after is not None and time.monotonic() - t0 >= self.next_after):
break
self._wake.wait(TICK_S)
self._wake.clear()
finally:
self._waiting = False
self._panel.set("action", "action ")
self._emit(waiting=False)
def _screen(self, state, screen, controls=True): def _screen(self, state, screen, controls=True):
"""A screen of its own (welcome, done): title, text, a few seconds. controls=False: the """A screen of its own (welcome, done): title, text, a few seconds, or in step mode
session's end, which reports its state once all is done.""" until Next. controls=False: the session's end, which reports its state once all is done."""
if not screen: if not screen:
return return
self._prompt = None self._prompt = None
self._show(screen.get("title", ""), "", screen.get("text", "")) self._show(screen.get("title", ""), "", screen.get("text", ""))
self._view()
self._panel.set("countdown", "countdown off") self._panel.set("countdown", "countdown off")
if controls: if controls:
self._emit(state=state, title=screen.get("title", ""), prompt=screen.get("text", "").replace("|", "\n"), self._emit(state=state, title=screen.get("title", ""), prompt=screen.get("text", "").replace("|", "\n"),
section="", seconds_left=float(screen.get("seconds", 0))) section="", seconds_left=float(screen.get("seconds", 0)))
try: try:
if self.auto:
self._wait(screen.get("seconds", 0), countdown=False) self._wait(screen.get("seconds", 0), countdown=False)
else:
self._await_next()
except _Skip: except _Skip:
pass pass
else: else:
@@ -1221,11 +1390,14 @@ class Session:
def _section(self, i, s): def _section(self, i, s):
step = "Section %d of %d" % (i + 1, len(self.plan)) step = "Section %d of %d" % (i + 1, len(self.plan))
status = {"section": s["id"], "title": s["title"], "section_index": i + 1, "section_count": len(self.plan)} status = {"section": s["id"], "title": s["title"], "section_index": i + 1, "section_count": len(self.plan),
"step_index": 0, "step_count": section_steps(s) if not self.auto else 0, "can_redo": False}
try: try:
before = s.get("before") before = s.get("before")
if before or i > 0:
self._prompt = None self._prompt = None
self._view()
if self.auto:
if before or i > 0:
text = before["text"] if before else "Next: %s" % s["title"] text = before["text"] if before else "Next: %s" % s["title"]
secs = before.get("seconds", 10) if before else self.script.get("between_s", 3) secs = before.get("seconds", 10) if before else self.script.get("between_s", 3)
self._show("Get ready" if before else s["title"], step, text) self._show("Get ready" if before else s["title"], step, text)
@@ -1236,13 +1408,17 @@ class Session:
self._run_prompt(s, {"id": s["id"] + "/intro", "text": s.get("intro", ""), "seconds": s["intro_s"], self._run_prompt(s, {"id": s["id"] + "/intro", "text": s.get("intro", ""), "seconds": s["intro_s"],
"hands": "", "pose": "", "distance": "", "position": "", "object": "", "hands": "", "pose": "", "distance": "", "position": "", "object": "",
"controller": False, "controllers": []}, step, intro=True) "controller": False, "controllers": []}, step, intro=True)
else:
# One screen before the section: what to get ready, and the intro. The take
# starts with the first step's countdown, so a section skipped here leaves none.
text = "|".join(t for t in ((before or {}).get("text", ""), s.get("intro", "")) if t)
self._show(s["title"], step, text)
self._panel.set("countdown", "countdown off")
self._emit(state="intro", prompt=text.replace("|", "\n"), take=None, seconds_left=0.0, **status)
self._await_next()
self._status["state"] = "running" # sent with the first prompt self._status["state"] = "running" # sent with the first prompt
if s["kind"] == "targets": self._steps(i, s, step)
self._targets(s, step) if self._take:
elif s["kind"] == "bar":
self._bar(s, step)
for p in s["prompts"]:
self._run_prompt(s, p, step)
self._end_take("complete") self._end_take("complete")
except _Skip: except _Skip:
self._log("skipped %s" % s["id"]) self._log("skipped %s" % s["id"])
@@ -1251,10 +1427,12 @@ class Session:
except _Stop: except _Stop:
raise raise
finally: finally:
self._panel.set("bar", "bar off") self._redo_ok = False
self._panel.set("target", "target off") self._status["big"] = ""
for key, c in (("bar", "bar off"), ("target", "target off"), ("big", "big "), ("action", "action ")):
self._panel.set(key, c)
def _start_take(self, i, s): def _start_take(self, i, s, record=True):
n = len(self._session_json["takes"]) + 1 n = len(self._session_json["takes"]) + 1
take_id = "%02d-%s" % (n, s["id"]) take_id = "%02d-%s" % (n, s["id"])
d = os.path.join(self.session_dir, "takes", take_id) d = os.path.join(self.session_dir, "takes", take_id)
@@ -1267,19 +1445,27 @@ class Session:
self._session_json["takes"].append(take_id) self._session_json["takes"].append(take_id)
self._save_session() self._save_session()
self._event("take", section=s["id"], take=take_id) self._event("take", section=s["id"], take=take_id)
if record:
self._start_recording() self._start_recording()
self._log("take %s" % take_id) self._log("take %s" % take_id)
self._emit(force=False, take=take_id)
def _start_recording(self): def _start_recording(self):
t = self._take t = self._take
t["part"] += 1 t["part"] += 1
if not self.dry_run: if not self.dry_run:
# a safety net only: the session ends the recording itself # a safety net only: the session ends the recording itself
remaining = section_seconds(t["section"], worst=True) / self.speed remaining = section_seconds(t["section"], worst=True, auto=self.auto) / self.speed
self._recorder = Recorder(t["dir"], t["part"], remaining * 1.5 + 60, self.ring, self._log_file) self._recorder = Recorder(t["dir"], t["part"], remaining * 1.5 + 60, self.ring, self._log_file)
self._recording = True
self._panel.cmd("poses start " + os.path.join(t["dir"], "poses.jsonl")) self._panel.cmd("poses start " + os.path.join(t["dir"], "poses.jsonl"))
self._panel.set("rec", "rec on")
def _stop_recording(self): def _stop_recording(self):
if not self._recording:
return
self._recording = False
self._panel.set("rec", "rec off")
self._panel.cmd("poses stop", reply=not self.dry_run, timeout=1.0) self._panel.cmd("poses stop", reply=not self.dry_run, timeout=1.0)
if self._recorder: if self._recorder:
rec, self._recorder = self._recorder, None rec, self._recorder = self._recorder, None
@@ -1289,7 +1475,6 @@ class Session:
def _end_take(self, status): def _end_take(self, status):
t = self._take t = self._take
try: try:
if not self._paused:
self._stop_recording() self._stop_recording()
finally: finally:
self._event("end", status=status) self._event("end", status=status)
@@ -1301,32 +1486,181 @@ class Session:
self._log("take %s %s" % (t["id"], status)) self._log("take %s %s" % (t["id"], status))
def _prompt_event(self, p): def _prompt_event(self, p):
self._event("prompt", id=p["id"], text=p["text"], hands=p["hands"], pose=p["pose"], distance=p["distance"], return self._event("prompt", id=p["id"], text=p["text"], hands=p["hands"], pose=p["pose"],
position=p["position"], object=p["object"], controller=bool(p["controller"]), distance=p["distance"], position=p["position"], object=p["object"],
**({"controllers": p["controllers"]} if p["controllers"] else {})) controller=bool(p["controller"]), **({"controllers": p["controllers"]} if p["controllers"] else {}))
def _begin_prompt(self, s, p, step): def _begin_prompt(self, s, p, step, state="running"):
self._prompt = p self._prompt = p
self._fb.pop("lost_key", None) self._fb.pop("lost_key", None)
self._show(s["title"], step, p["text"]) self._show(s["title"], step, p["text"])
self._prompt_event(p) self._view(p)
self._emit(prompt=p["text"].replace("|", "\n"), seconds_left=float(p["seconds"])) t = self._prompt_event(p)
if self._step_t0 is None:
self._step_t0 = t
self._emit(state=state, prompt=p["text"].replace("|", "\n"), seconds_left=float(p["seconds"]), countdown=0)
self._log(" %s" % p["id"]) self._log(" %s" % p["id"])
def _run_prompt(self, s, p, step, intro=False): def _run_prompt(self, s, p, step, intro=False):
if intro and not p["text"]: if intro and not p["text"]:
return return
self._begin_prompt(s, p, step) self._begin_prompt(s, p, step, state="intro" if intro else "running")
self._wait(p["seconds"]) self._wait(p["seconds"])
def _targets(self, s, step): # --- steps: a section's prompts, bar heights and targets
hold_s, timeout_s = float(s.get("hold_s", 1.0)), float(s.get("timeout_s", 8)) def _step_list(self, s):
"""[{"kind": "prompt"|"bar"|"target", "p": prompt, "ready": waits for Next in step mode, ...}]."""
out = []
defaults = s.get("defaults") or {} defaults = s.get("defaults") or {}
if s["kind"] == "targets":
for k, pt in enumerate(s["targets"]): for k, pt in enumerate(s["targets"]):
p = {"id": "%s/%d" % (s["id"], k + 1), "text": s.get("text", ""), "seconds": timeout_s, p = {"id": "%s/%d" % (s["id"], k + 1), "text": s.get("text", ""), "seconds": float(s.get("timeout_s", 8)),
"hands": defaults.get("hands", "any"), "pose": defaults.get("pose", "point"), "distance": "", "hands": defaults.get("hands", "any"), "pose": defaults.get("pose", "point"), "distance": "",
"position": "", "object": "", "controller": False, "controllers": []} "position": "", "object": "", "controller": False, "controllers": []}
self._begin_prompt(s, p, step) out.append({"kind": "target", "p": p, "pt": pt, "ready": k == 0})
elif s["kind"] == "bar":
lead = float(s.get("lead_s", 3)) if self.auto else 0.0 # step mode: the countdown shows the bar at near
for h in s["heights"]:
p = {"id": "%s/%s" % (s["id"], h["id"]), "text": h["text"], "hands": defaults.get("hands", "both"),
"pose": defaults.get("pose", "open"), "distance": "", "position": h["id"], "object": "",
"picture": h.get("picture", defaults.get("picture", "")),
"controller": bool(defaults.get("controller", False)),
"controllers": list(defaults.get("controllers") or []),
"seconds": lead + h["reps"] * float(s.get("period_s", 6))}
out.append({"kind": "bar", "p": p, "h": h, "lead": lead, "ready": True})
out += [{"kind": "prompt", "p": p, "ready": True} for p in s["prompts"]]
return out
def _steps(self, i, s, label):
"""Run a section's steps. Step mode: each that's "ready" (and each to do again) shows
first and waits for Next; then the recorded countdown; the recording stops after a
step unless the next one follows straight on (the targets after the first). R: the
step running starts again; at a ready screen, the step before goes again. Either way
the range done before is marked with a "redo" event."""
steps = self._step_list(s)
done = [] # the steps finished in this take: (index, id, from_ns, to_ns), for R
again = set() # steps to do again: they wait for Next too
k = 0
while k < len(steps):
st, p = steps[k], steps[k]["p"]
where = label if self.auto else "%s · step %d of %d" % (label, k + 1, len(steps))
self._status.update(step_index=k + 1, step_count=len(steps))
started, self._step_t0 = None, None
try:
if not self.auto and (st["ready"] or k in again):
self._redo_ok = bool(done)
self._ready(s, st, where, can_redo=bool(done))
self._await_next()
self._redo_ok = True
started = self._countdown(i, s, st)
else:
self._redo_ok = True
self._emit(force=False, can_redo=True)
self._run_step(s, st, where)
done.append((k, p["id"], started if started is not None else self._step_t0, mono_ns()))
if not self.auto and (k + 1 == len(steps) or steps[k + 1]["ready"] or k + 1 in again):
self._hold_end()
again.discard(k)
k += 1
except _Redo:
self._redo_ok = False
from_ns = started if started is not None else self._step_t0
self._panel.set("target", "target off")
self._panel.set("bar", "bar off")
if from_ns is not None: # R during the step: it starts again
self._event("redo", id=p["id"], **{"from": from_ns, "to": mono_ns()})
self._log(" redo %s" % p["id"])
if not self.auto:
self._hold_end()
elif self._take and not self._recording: # R ended a pause: record again
self._event("resume")
self._start_recording()
again.add(k)
elif done: # R at its ready screen: the step before goes again
k, pid, a, b = done.pop()
self._event("redo", id=pid, **{"from": a, "to": b})
self._log(" redo %s" % pid)
again.add(k)
def _ready(self, s, st, where, can_redo):
"""Step mode: show the step (text, picture, diagram) with "Ready?"."""
p = st["p"]
self._prompt = p # the chips show which hands are seen while the person gets ready
self._fb.pop("lost_key", None)
self._show(s["title"], where, p["text"])
self._view(p)
for key, c in (("big", "big "), ("countdown", "countdown off"), ("bar", "bar off"), ("target", "target off")):
self._panel.set(key, c)
self._emit(state="ready", prompt=p["text"].replace("|", "\n"), seconds_left=float(p["seconds"]),
can_redo=can_redo, countdown=0, big="")
def _countdown(self, i, s, st):
"""Step mode: start recording and count 3-2-1 (logged as a "ready" event, so labels
cover only the hold), then make sure the recording has its first set. Returns the
ready event's time."""
p = st["p"]
if not self._take:
self._start_take(i, s, record=False)
t = self._event("ready", id=p["id"], seconds=COUNTDOWN_S)
self._step_t0 = t
self._start_recording()
tick = None
if st["kind"] == "bar": # the bar at near, where the sweep starts
labels = "%s %s" % (s.get("near_label", "Near"), s.get("far_label", "Far"))
near, far = float(s.get("near_m", 0.2)), float(s.get("far_m", 0.6))
def tick(dt):
self._panel.set("bar", "bar 0.000 %.3f %s" % (self._palm_share(near, far), labels))
return False
for n in range(COUNTDOWN_S, 0, -1):
self._panel.set("big", "big %d" % n)
self._emit(state="countdown", countdown=n, big=str(n), can_redo=True)
self._wait(1.0, tick, countdown=False)
self._first_set()
self._panel.set("big", "big " + s.get("go", "Go"))
self._status["big"] = s.get("go", "Go") # sent with the prompt
return t
def _first_set(self):
"""The hold starts once its recording has a set. ft-hands --record-only writes its first
within about 30 ms of starting, so the countdown covers it; this is a safety net."""
rec = self._recorder
if not rec:
return
end = time.monotonic() + FIRST_SET_S
while not rec.has_data():
if rec.exited():
raise _Fail("The recording stopped by itself: see session.log")
if self._want["stop"]:
raise _Stop()
if time.monotonic() > end:
self._log("recording part %d: no set yet after the countdown" % rec.part)
return
time.sleep(0.02)
def _hold_end(self):
"""Step mode: the step is over. A "wait" event ends its labels and the recording stops
until the next countdown."""
self._event("wait")
self._stop_recording()
self._status["big"] = ""
for key, c in (("big", "big "), ("countdown", "countdown off"), ("bar", "bar off"), ("target", "target off")):
self._panel.set(key, c)
def _run_step(self, s, st, where):
if st["kind"] != "bar":
self._panel.set("bar", "bar off")
if st["kind"] == "target":
self._target(s, st, where)
elif st["kind"] == "bar":
self._bar(s, st, where)
else:
self._run_prompt(s, st["p"], where)
def _target(self, s, st, where):
hold_s, timeout_s = float(s.get("hold_s", 1.0)), float(s.get("timeout_s", 8))
p, pt = st["p"], st["pt"]
self._begin_prompt(s, p, where)
xyz = "%.3f %.3f %.3f" % tuple(pt) xyz = "%.3f %.3f %.3f" % tuple(pt)
reply = self._panel.cmd("target %s show" % xyz, reply=True) or "" reply = self._panel.cmd("target %s show" % xyz, reply=True) or ""
self._panel.sent["target"] = "target %s show" % xyz self._panel.sent["target"] = "target %s show" % xyz
@@ -1334,31 +1668,31 @@ class Session:
room = [float(v) for v in tok[1:4]] if tok[:1] == ["ok"] and len(tok) >= 4 else None room = [float(v) for v in tok[1:4]] if tok[:1] == ["ok"] and len(tok) >= 4 else None
target = {"id": p["id"], "head": list(pt), "room": room} target = {"id": p["id"], "head": list(pt), "room": room}
self._event("target", state="show", **target) self._event("target", state="show", **target)
st = {"hold": 0.0, "sent": 0.0, "holding": False} state = {"hold": 0.0, "sent": 0.0, "holding": False}
def tick(dt, pt=pt, room=room, target=target, st=st, xyz=xyz): def tick(dt):
d = self._tip_distance(pt, room) d = self._tip_distance(pt, room)
if d is not None and d <= TOUCH_M: if d is not None and d <= TOUCH_M:
if not st["holding"]: if not state["holding"]:
st["holding"] = True state["holding"] = True
self._event("target", state="hold", **target) self._event("target", state="hold", **target)
st["hold"] += dt state["hold"] += dt
elif st["holding"]: elif state["holding"]:
st["holding"], st["hold"] = False, 0.0 state["holding"], state["hold"] = False, 0.0
self._panel.set("target", "target %s show" % xyz) self._panel.set("target", "target %s show" % xyz)
if st["holding"]: if state["holding"]:
frac = min(1.0, st["hold"] / hold_s) frac = min(1.0, state["hold"] / hold_s)
if time.monotonic() - st["sent"] >= 0.1 or frac >= 1: if time.monotonic() - state["sent"] >= 0.1 or frac >= 1:
st["sent"] = time.monotonic() state["sent"] = time.monotonic()
self._panel.set("target", "target %s hold %.2f" % (xyz, frac)) self._panel.set("target", "target %s hold %.2f" % (xyz, frac))
return st["hold"] >= hold_s return state["hold"] >= hold_s
done = self._wait(timeout_s, tick) done = self._wait(timeout_s, tick)
state = "done" if done else "timeout" result = "done" if done else "timeout"
if done: if done:
self._panel.set("target", "target %s done" % xyz) self._panel.set("target", "target %s done" % xyz)
self._event("target", state=state, **target) self._event("target", state=result, **target)
self._log(" %s %s" % (p["id"], state)) self._log(" %s %s" % (p["id"], result))
if done: if done:
self._wait(0.6, countdown=False) self._wait(0.6, countdown=False)
self._panel.set("target", "target off") self._panel.set("target", "target off")
@@ -1391,35 +1725,28 @@ class Session:
return -1.0 return -1.0
return max(0.0, min(1.0, (sum(ds) / len(ds) - near) / (far - near))) return max(0.0, min(1.0, (sum(ds) / len(ds) - near) / (far - near)))
def _bar(self, s, step): def _bar(self, s, st, where):
"""One height of the push out and back: the target sweeps near to far and back."""
near, far = float(s.get("near_m", 0.2)), float(s.get("far_m", 0.6)) near, far = float(s.get("near_m", 0.2)), float(s.get("far_m", 0.6))
period, lead = float(s.get("period_s", 6)), float(s.get("lead_s", 3)) period, lead = float(s.get("period_s", 6)), st["lead"]
labels = "%s %s" % (s.get("near_label", "Near"), s.get("far_label", "Far")) labels = "%s %s" % (s.get("near_label", "Near"), s.get("far_label", "Far"))
defaults = s.get("defaults") or {} p = st["p"]
for h in s["heights"]: self._begin_prompt(s, p, where)
p = {"id": "%s/%s" % (s["id"], h["id"]), "text": h["text"], "hands": defaults.get("hands", "both"), state = {"t": 0.0, "sent": 0.0}
"pose": defaults.get("pose", "open"), "distance": "", "position": h["id"], "object": "",
"controller": bool(defaults.get("controller", False)),
"controllers": list(defaults.get("controllers") or [])}
total = lead + h["reps"] * period
p["seconds"] = total
self._begin_prompt(s, p, step)
st = {"t": 0.0, "sent": 0.0}
def tick(dt, st=st): def tick(dt):
st["t"] += dt state["t"] += dt
sweep = st["t"] - lead sweep = state["t"] - lead
target = 0.0 if sweep <= 0 else 1 - abs(1 - 2 * ((sweep % period) / period)) target = 0.0 if sweep <= 0 else 1 - abs(1 - 2 * ((sweep % period) / period))
if time.monotonic() - st["sent"] >= 0.1: if time.monotonic() - state["sent"] >= 0.1:
st["sent"] = time.monotonic() state["sent"] = time.monotonic()
cur = self._palm_share(near, far) cur = self._palm_share(near, far)
self._panel.set("bar", "bar %.3f %.3f %s" % (target, cur, labels)) self._panel.set("bar", "bar %.3f %.3f %s" % (target, cur, labels))
if sweep > 0: if sweep > 0:
self._event("bar", target=round(target, 3), current=None if cur < 0 else round(cur, 3)) self._event("bar", target=round(target, 3), current=None if cur < 0 else round(cur, 3))
return False return False
self._wait(total, tick) self._wait(p["seconds"], tick)
self._panel.set("bar", "bar off")
# ------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------
@@ -1439,6 +1766,11 @@ def main():
ap.add_argument("--panel", help="the panel program (default hands/rec/build/ft-handpanel)") ap.add_argument("--panel", help="the panel program (default hands/rec/build/ft-handpanel)")
ap.add_argument("--hands-dir", help="where the hands file is (default /run/user/UID/frametop-hands)") ap.add_argument("--hands-dir", help="where the hands file is (default /run/user/UID/frametop-hands)")
ap.add_argument("--plan", action="store_true", help="print the sections and their length, and exit") ap.add_argument("--plan", action="store_true", help="print the sections and their length, and exit")
ap.add_argument("--auto", action="store_true",
help="advance by itself: each prompt for its time, no waiting for Next (the old timed flow)")
ap.add_argument("--next-after", type=float, metavar="S",
help="test: press Next by itself after S seconds of waiting (real time)")
ap.add_argument("--poses", help="the pose pictures' folder, with poses.json (default hands/rec/poses)")
a = ap.parse_args() a = ap.parse_args()
known = ("pencil", "phone", "cup", "keyboard", "mouse", "gamepad", "small") known = ("pencil", "phone", "cup", "keyboard", "mouse", "gamepad", "small")
@@ -1462,15 +1794,21 @@ def main():
last = {} last = {}
def on_status(st): def on_status(st):
key = (st["state"], st["section"], st["prompt"], st["note"], st["take"], key = (st["state"], st["section"], st["prompt"], st["note"], st["take"], st["waiting"], st["countdown"],
st["hands"]["left"], st["hands"]["right"], st["error"]) st["hands"]["left"], st["hands"]["right"], st["error"])
if key == last.get("key"): if key == last.get("key"):
return return
last["key"] = key last["key"] = key
hands = "".join("%s%s" % (s[0].upper(), {True: "+", False: "-", None: "?"}[st["hands"][s]]) hands = "".join("%s%s" % (s[0].upper(), {True: "+", False: "-", None: "?"}[st["hands"][s]])
for s in ("left", "right")) for s in ("left", "right"))
line = "[%6.1f] %-8s %d/%d %-16s %s %s" % (time.monotonic() - t0, st["state"], st["section_index"], state = "%s %d" % (st["state"], st["countdown"]) if st["state"] == "countdown" else st["state"]
st["section_count"], st["section"] or "-", hands, st["prompt"]) line = "[%6.1f] %-11s %d/%d %-16s %s %s" % (time.monotonic() - t0, state, st["section_index"],
st["section_count"], st["section"] or "-", hands,
st["prompt"].replace("\n", " | "))
if st["image"]:
line += " [%s%s]" % (os.path.basename(st["image"]), " " + st["image_mode"] if st["image_mode"] else "")
if st["waiting"]:
line += " (waiting for Next)"
if st["note"]: if st["note"]:
line += " (%s)" % st["note"] line += " (%s)" % st["note"]
if st["error"]: if st["error"]:
@@ -1478,15 +1816,22 @@ def main():
print(line, flush=True) print(line, flush=True)
s = Session(base, profile, checklist, a.lighting, a.script, ring=a.ring, start_processes=not a.no_start, s = Session(base, profile, checklist, a.lighting, a.script, ring=a.ring, start_processes=not a.no_start,
dry_run=a.dry_run, speed=a.speed, on_status=on_status, hands_dir=a.hands_dir, panel_bin=a.panel) dry_run=a.dry_run, speed=a.speed, on_status=on_status, hands_dir=a.hands_dir, panel_bin=a.panel,
est, worst = plan_seconds(s.script, s.plan), plan_seconds(s.script, s.plan, worst=True) auto=a.auto, next_after=a.next_after, poses_dir=a.poses)
print("%d sections, about %.1f min (at most %.1f)%s" % (len(s.plan), est / 60, worst / 60, est = plan_seconds(s.script, s.plan, auto=a.auto)
worst = plan_seconds(s.script, s.plan, worst=True, auto=a.auto)
print("%d sections, %s mode: about %.1f min%s (at most %.1f)%s" % (
len(s.plan), "auto" if a.auto else "step", est / 60, "" if a.auto else " recorded", worst / 60,
", %gx speed" % a.speed if a.speed != 1 else "")) ", %gx speed" % a.speed if a.speed != 1 else ""))
if not a.auto:
print(" %d steps wait for Next: add your reading time (at 5 s a step, %.1f min)"
% (plan_steps(s.plan), plan_steps(s.plan) * 5 / 60))
for sk in s.skipped: for sk in s.skipped:
print(" skipping %s: %s" % (sk["section"], sk["reason"])) print(" skipping %s: %s" % (sk["section"], sk["reason"]))
if a.plan: if a.plan:
for sec in s.plan: for sec in s.plan:
print(" %-18s %-9s %3d prompts %5.0f s" % (sec["id"], sec["kind"], len(sec["prompts"]), section_seconds(sec))) print(" %-18s %-9s %3d prompts %3d steps %5.0f s" % (sec["id"], sec["kind"], len(sec["prompts"]),
section_steps(sec), section_seconds(sec, auto=a.auto)))
return 0 return 0
if not a.dry_run: if not a.dry_run:
light = ring_lighting(a.ring) light = ring_lighting(a.ring)
@@ -1501,19 +1846,23 @@ def main():
signal.signal(signal.SIGINT, on_signal) signal.signal(signal.SIGINT, on_signal)
signal.signal(signal.SIGTERM, on_signal) signal.signal(signal.SIGTERM, on_signal)
if sys.stdin.isatty():
def keys(): def keys():
print("keys: p pause, r resume, s skip, q stop (then Enter)", flush=True) # From a terminal, or lines piped in (a test); /dev/null ends at once.
if sys.stdin.isatty():
print("keys (then Enter): n or just Enter next, p pause/resume, r redo, s skip section, q stop", flush=True)
for line in sys.stdin: for line in sys.stdin:
c = line.strip()[:1] c = line.strip()[:1].lower()
if c == "p": if c in ("n", ""):
s.pause() s.next_step()
elif c == "p":
s.resume() if s.state == "paused" else s.pause()
elif c == "r": elif c == "r":
s.resume() s.redo()
elif c == "s": elif c == "s":
s.skip() s.skip()
elif c == "q": elif c == "q":
s.stop(wait=0) s.stop(wait=0)
if sys.stdin is not None:
threading.Thread(target=keys, daemon=True).start() threading.Thread(target=keys, daemon=True).start()
s.start() s.start()
while s._thread.is_alive(): while s._thread.is_alive():
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""Tests for session.py's step mode (dry runs, no processes): the ready, countdown and hold
timeline in prompts.jsonl, R (redo), the timed flow (--auto), the pose pictures' display rule,
the length estimates, and a take recorded in many parts through review, export and validate.
python3 hands/rec/tests/test_session.py
"""
import json
import os
import shutil
import sys
import tempfile
import time
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(HERE))
import session # noqa: E402
import takes # noqa: E402
import validate # noqa: E402
from test_validate import fhset, make_session, SESSION # noqa: E402
SCRIPT = {
"version": 1, "intro_s": 1, "between_s": 1,
"welcome": {"title": "Test", "seconds": 1, "text": "A test."},
"done": {"title": "Done", "seconds": 0, "text": "Done."},
"stopped": {"title": "Stopped", "seconds": 0, "text": "Stopped."},
"sections": [
{"id": "poses", "title": "Poses", "intro": "Some poses.", "go": "Hold",
"prompts": [{"text": "Fist.", "seconds": 4, "hands": "left", "pose": "fist", "distance": "near",
"position": "left"},
{"text": "Open.", "seconds": 4, "hands": "both", "pose": "open"}]}]}
class SessionTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="handrec-session-test-")
self.script = os.path.join(self.tmp, "script.json")
with open(self.script, "w") as f:
json.dump(SCRIPT, f)
self.panel = []
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def session(self, **kw):
kw.setdefault("speed", 10)
s = session.Session(os.path.join(self.tmp, "base"), {}, {}, "room", self.script, dry_run=True,
poses_dir=os.path.join(self.tmp, "no-poses"), **kw)
s.print = self.panel.append
return s
def wait_for(self, s, pred, what, timeout=10):
end = time.monotonic() + timeout
while time.monotonic() < end:
st = dict(s._status)
if pred(st):
return st
time.sleep(0.005)
self.fail("timed out waiting for %s: %s" % (what, s._status))
def waiting(self, s, state, prompt=None):
return self.wait_for(s, lambda st: st["state"] == state and st["waiting"]
and (prompt is None or st["prompt"] == prompt), "%s %s" % (state, prompt or ""))
def events(self, s):
with open(os.path.join(s.session_dir, "takes", "01-poses", "prompts.jsonl")) as f:
return [json.loads(line) for line in f]
def test_step_flow(self):
s = self.session(next_after=0.0)
s.start()
s.join(20)
self.assertEqual(s.state, "done")
ev = self.events(s)
self.assertEqual([e["event"] for e in ev],
["take", "ready", "prompt", "wait", "ready", "prompt", "wait", "end"])
self.assertEqual(ev[1]["id"], ev[2]["id"])
self.assertEqual(ev[1]["seconds"], session.COUNTDOWN_S)
self.assertTrue(ev[1]["t"] < ev[2]["t"] < ev[3]["t"] < ev[4]["t"])
# the countdown is recorded (3 s at 10x speed), and the hold after it
self.assertGreater(ev[2]["t"] - ev[1]["t"], 0.25e9)
with open(os.path.join(s.session_dir, "session.json")) as f:
self.assertEqual(json.load(f)["mode"], "step")
# the panel: Ready?, then the countdown, then the section's word for the hold, the diagram
self.assertIn("panel: action " + session.READY_TEXT, self.panel)
self.assertLess(self.panel.index("panel: big 3"), self.panel.index("panel: big 1"))
self.assertIn("panel: big Hold", self.panel)
self.assertIn("panel: where left near", self.panel)
self.assertIn("panel: rec on", self.panel)
def test_redo(self):
s = self.session()
s.start()
self.waiting(s, "starting")
s.next_step()
self.waiting(s, "intro")
s.redo() # nothing to do again yet: dropped
s.next_step()
st = self.waiting(s, "ready", "Fist.")
self.assertFalse(st["can_redo"])
s.next_step()
self.wait_for(s, lambda st: st["state"] == "running", "the hold")
s.redo() # during the hold: it starts again
self.waiting(s, "ready", "Fist.")
s.next_step()
st = self.waiting(s, "ready", "Open.")
self.assertTrue(st["can_redo"])
s.redo() # at the next step's ready screen: the one before goes again
self.waiting(s, "ready", "Fist.")
s.next_step()
self.waiting(s, "ready", "Open.")
s.next_step()
s.join(20)
self.assertEqual(s.state, "done")
ev = self.events(s)
names = [e["event"] for e in ev]
self.assertEqual(names, ["take", "ready", "prompt", "redo", "wait", "ready", "prompt", "wait", "redo",
"ready", "prompt", "wait", "ready", "prompt", "wait", "end"])
first, second = ev[3], ev[8]
self.assertEqual(first["id"], "poses/fist/left/near/left")
self.assertEqual(first["from"], ev[1]["t"]) # from its countdown
self.assertTrue(ev[2]["t"] < first["to"] <= ev[4]["t"])
self.assertEqual((second["from"], second["id"]), (ev[5]["t"], first["id"]))
self.assertTrue(ev[6]["t"] < second["to"] <= ev[7]["t"])
# what labels keep: prompts outside every redo range
kept = [e["id"] for e in ev if e["event"] == "prompt"
and not any(r["from"] <= e["t"] <= r["to"] for r in ev if r["event"] == "redo")]
self.assertEqual(kept, ["poses/fist/left/near/left", "poses/open/both"])
def test_pause_while_waiting(self):
s = self.session()
s.start()
self.waiting(s, "starting")
s.next_step()
self.waiting(s, "intro")
s.next_step()
self.waiting(s, "ready", "Fist.")
s.pause()
self.wait_for(s, lambda st: st["state"] == "paused", "paused")
s.next_step() # ignored while paused
s.resume()
st = self.waiting(s, "ready", "Fist.")
s.stop(wait=10)
self.assertEqual(s.state, "stopped")
# nothing recorded: no take
self.assertEqual(os.listdir(os.path.join(s.session_dir, "takes")), [])
def test_auto(self):
s = self.session(auto=True, speed=20)
s.start()
s.join(20)
self.assertEqual(s.state, "done")
ev = self.events(s)
self.assertEqual([e["event"] for e in ev], ["take", "prompt", "prompt", "prompt", "end"])
self.assertEqual(ev[1]["id"], "poses/intro")
def test_auto_redo_after_pause(self):
s = self.session(auto=True)
s.start()
self.wait_for(s, lambda st: st["state"] == "running" and st["prompt"] == "Fist.", "the first prompt")
s.pause()
self.wait_for(s, lambda st: st["state"] == "paused", "paused")
s.redo() # ends the pause; the prompt starts again, recording again
s.join(20)
self.assertEqual(s.state, "done")
names = [e["event"] for e in self.events(s)]
self.assertEqual(names, ["take", "prompt", "prompt", "pause", "redo", "resume", "prompt", "prompt", "end"])
def test_pose_view(self):
d = os.path.join(self.tmp, "poses")
os.makedirs(d)
for name in ("fist.png", "cross.png"):
open(os.path.join(d, name), "wb").close()
with open(os.path.join(d, "poses.json"), "w") as f:
json.dump({"fist": {"file": "fist.png", "two_hands": False, "caption": "A fist"},
"cross": {"file": "../cross.png", "two_hands": True, "caption": ""},
"ok": {"file": "ok.png", "two_hands": False}}, f)
poses = session.load_poses(d)
fist = os.path.join(d, "fist.png")
self.assertEqual(session.pose_view(poses, {"pose": "fist", "hands": "right"}), (fist, "", "A fist"))
self.assertEqual(session.pose_view(poses, {"pose": "fist", "hands": "left"})[1], "mirror")
self.assertEqual(session.pose_view(poses, {"pose": "fist", "hands": "both"})[1], "both")
self.assertEqual(session.pose_view(poses, {"pose": "fist", "hands": "any"})[1], "")
# two hands drawn already; the file stays in the folder
self.assertEqual(session.pose_view(poses, {"pose": "cross", "hands": "both"})[:2],
(os.path.join(d, "cross.png"), ""))
self.assertEqual(session.pose_view(poses, {"pose": "ok", "hands": "both"}), ("", "", "")) # no file
self.assertEqual(session.pose_view(poses, {"pose": "claw", "hands": "both"}), ("", "", ""))
self.assertEqual(session.load_poses(os.path.join(self.tmp, "none")), {})
def test_plan(self):
plan, _ = session.build_plan(SCRIPT, {})
self.assertEqual(session.plan_steps(plan), 2)
self.assertEqual(session.plan_seconds(SCRIPT, plan, auto=False), 2 * session.COUNTDOWN_S + 8)
self.assertEqual(session.plan_seconds(SCRIPT, plan, auto=True), 1 + 1 + 8)
self.assertIn("2 steps", session.plan_summary(SCRIPT, plan))
class ManyPartsTest(unittest.TestCase):
"""A take recorded in 40 parts (step mode stops the recording between steps): review reads
them in order, export makes one stream, validate passes."""
def setUp(self):
if not takes.find_zstd():
self.skipTest("no zstd")
self.tmp = tempfile.mkdtemp(prefix="handrec-parts-test-")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_parts(self):
make_session(self.tmp)
tdir = os.path.join(self.tmp, "sessions", SESSION, "takes", "01-hand-size")
t0 = 5 * 10 ** 12
for part in range(1, 41):
name = "sets.bin" if part == 1 else "sets-%d.bin" % part
with open(os.path.join(tdir, name), "wb") as f:
for i in range(3): # 3 sets a part, 10 s apart between parts
f.write(fhset(t0 + part * 10 ** 10 + i * 10 ** 8, part))
store = takes.Store(self.tmp)
index = takes.take_index(tdir)
self.assertEqual(len(index.parts), 40)
self.assertEqual(len(index), 120)
times = [index.time_ns(i) for i in range(len(index))]
self.assertEqual(times, sorted(times)) # sets-10.bin after sets-9.bin
self.assertAlmostEqual(index.duration_s(), 40 * 0.2) # the gaps between parts don't count
path = store.export(SESSION, low_priority=False)
r = validate.validate(path)
self.assertEqual(r.errors, [])
self.assertEqual(r.summary["sets"], 120 + 30)
if __name__ == "__main__":
unittest.main()