Files
Frametop--frametop/hands/rec/session.py
T
DeeJanuzandClaude Opus 5.5 c51aa53902 Recorders: a release records its version, not "unknown"
A release's folder has no .git, so git describe found nothing and every
Hand Recorder session and export from a release said "ft-handrec unknown",
and the live tracker's "frametop" was empty. recorder/common.tree_version
reads the release's .frametop-release (VERSION, COMMIT) when there's no
checkout: "ft-handrec v0.3.0-exp.12 release 789bb44". A checkout keeps its
git describe; a checkout's own BOX-only .frametop-release is ignored. The
eye recorder uses the same helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:59:19 -06:00

3385 lines
162 KiB
Python
Executable File

#!/usr/bin/env python3
"""The hand recorder's session runner (DESIGN.md: "Processes during a session", "Files", "The
script", "Feedback while recording", "Controls").
It reads script.json (or a bounty's round script), starts what the session needs (ft-camd and
a tracking ft-hands, only if they aren't running), and runs the sections in order. Each section
is one take: a recording ft-hands child process (--record-only, 10 sets a second or the
script's record_hz) plus prompts.jsonl, take.json and the panel's poses.jsonl. The headset panel (ft-handpanel) shows the prompts; the live hands file
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
for testing (in the dev container):
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 --board # the bounty board, and which are done
python3 hands/rec/session.py --bounty index-taps --dry-run --speed 20 --next-after 0.2 # a bounty's round
python3 hands/rec/session.py --debug # a debug session: Frametop's hand tracking used as usual, recorded
Before anything starts it runs the camera check (hands/camcheck.py) and won't start while the
upper cameras are off (--ignore-cameras overrides it). If the hand-size section's first step sees
no hands at all, it stops that step and asks: try again, or stop (DESIGN.md, "Camera check").
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.
"""
import argparse
import glob
import hashlib
import json
import math
import mmap
import os
import random
import re
import shutil
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
import traceback
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HERE))
HANDS = os.path.join(REPO, "hands")
sys.path.insert(0, HANDS)
import camcheck # noqa: E402 (hands/camcheck.py: are all four mono cameras running?)
sys.path.insert(0, HERE)
import sides # noqa: E402 (hands/rec/sides.py: which side camera is which)
import takes # noqa: E402 (hands/rec/takes.py: the version, and the standalone recorder's build info)
sys.path.insert(0, REPO)
import livelog # noqa: E402 (hands/rec/livelog.py: a debug session's copy of the live tracker's output)
import voicelog # noqa: E402 (hands/rec/voicelog.py: a debug session's microphone, if ticked)
import eventlog # noqa: E402 (hands/rec/eventlog.py: a debug session's copy of what the panels and pointer did)
# What the recorders share (recorder/), under the names this module always had: the window,
# the standalone recorder's spike and the tests use them from here.
from recorder.common import (mono_ns, clock_sample, in_container, host_command, host_path, # noqa: E402,F401
write_json, clean_text, tree_version, os_version as _os_version,
steamvr_version as _steamvr_version)
from recorder import bounties # noqa: E402
from recorder.units import unit_active, start_unit, stop_unit, pause_units, resume_units, restart_units # noqa: E402,F401
from recorder.button import (INPUT_DEVICES, INPUT_EVENT, EV_KEY, EV_REL, REL_X, REL_Y, KEY_SELECT, # noqa: E402,F401
BUTTON_DEVICE, BUTTON_DOUBLE_S, BUTTON_HOLD_S, BUTTON_BOUNCE_S, parse_input_devices,
find_button, real_mouse, read_input_devices, mouse_connected, button_events,
ButtonGestures, ButtonReader)
FT_HANDS = os.path.join(HANDS, "build", "ft-hands")
FT_CAMD = os.path.join(HANDS, "build", "ft-camd")
PANEL_BIN = os.path.join(HERE, "build", "ft-handpanel")
# Runs a program in the container this tree was built for: "dev" in a clone, the release's own in a
# release (it starts the container first)
IN_BOX = os.path.join(REPO, "scripts", "in-box")
SCRIPT_PATH = os.path.join(HERE, "script.json")
DEBUG_SCRIPT = os.path.join(HERE, "debug.json") # a debug session: Frametop's own hand tracking, used as usual
BOARD_PATH = os.path.join(HERE, "bounties.json") # the bounty board shipped with the recorder
BOUNTIES_DIR = os.path.join(HERE, "bounties") # a bounty's round script: bounties/<script>.json
POSES_DIR = os.path.join(HERE, "poses") # the pose pictures: poses.json and its PNGs
DEMOS_DIR = os.path.join(HERE, "demos") # the demo clips: <id>.gif (DESIGN.md "Demo clips")
RECORDED_DEMOS_DIR = os.path.join(HERE, "demos-recorded") # the same parts from real camera frames
# The demo clips a contributor follows: recorded (a real person's hands, from a session's side
# cameras: demo/camgifs.py) or skeleton (the drawn hand, its palm light and its back dark).
GUIDANCE = (("recorded", "Recorded hands"), ("skeleton", "Skeleton hands"))
BASE_DIR = os.path.expanduser("~/.local/share/frametop/hands/contrib")
PANEL_SOCKET = "ft_handpanel"
CAMD_UNIT = "frametop-handrec-camd.service"
# Frametop's live hand tracking (hands/run.sh, ft-handsctl): an ft-camd the recorder uses as it is
LIVE_CAMD_UNIT, LIVE_HANDS_UNIT = "frametop-camd.service", "frametop-hands.service"
# Frametop's drawn hands and hand pointer: off for a session, so no hand overlay is in view and no gesture
# clicks or opens Steam's menu while the person performs the steps (Session._quiet_hands)
QUIET_UNITS = ("frametop-handview.service", "frametop-pointer.service")
PAUSED_UNITS_FILE = "handrec-paused-units" # in run_dir(): the ones to start again, if a session didn't
SIDE_CAMERAS = ("slam_left", "slam_right") # a recording needs these (Session._first_set)
CAMERAS_BACK_S = 120 # how long a lost recording waits for the cameras (Session._cameras_back)
STALL_S = 3.0 # a recording with no new set for this long is lost (Session._watch_recording)
MAX_CAMERA_LOSSES = 3 # then the session stops
CAMERAS_TITLE = "Waiting for the cameras"
CAMERAS_TEXT = ("The headset's cameras restarted (SteamVR moves them when the colour camera module is unplugged "
"or plugged in), or they stopped. If you took the headset off, put it back on. This step goes "
"again once they're back.")
HANDS_UNIT = "frametop-handrec-hands.service"
CONF_PATH = os.path.expanduser("~/.config/frametop.conf")
LIVE_KEYS = re.compile(r"^(HANDS|POINTER)_[A-Z0-9_]+$") # the settings a debug session records
MARK_FLASH_S = 1.0 # a debug session's mark: the panel shows "Marked" this long
DEBUG_NEEDS_HANDS = ("A testing session records Frametop's own hand tracking, and it isn't running. Turn on "
"hand tracking in Frametop (or start the desktop), then try again.")
# The standalone Hand Recorder (takes.standalone()): its binaries run on the host, so ft-hands
# starts directly rather than in the dev container, and repairs mean its install command.
STANDALONE = takes.standalone()
# A Frametop release (pack/install-release.sh) rather than a clone
RELEASE = os.path.exists(os.path.join(REPO, ".frametop-release"))
RECORD_HZ = 10 # sets a second; a script's "record_hz" sets its own (the bounties: 30)
SIDES_READ_S = 0.5 # how often the tracking ft-hands' side camera decision is read
TICK_S = 0.05 # the session loop's step (real time)
FRESH_S = 0.3 # the hands file counts as live if published this recently
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
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
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"
RESUME_HINT_BUTTON = "Paused. Resume: press the headset button, or P in the Hand recorder window"
READY_TEXT = "Ready? Press Space or click Next"
# With the headset's button: it leads when no mouse is connected (the window's Next can't be clicked).
READY_BUTTON = "Ready? Press the button on the right side of the headset"
READY_BUTTON_MOUSE = "Ready? Press Space, click Next, or press the headset button"
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"
# The headset button: a press, two (redo) and a hold (stop): ButtonGestures. The window's keys follow
# by letter: the window lists what they do.
KEYS_STEP_BUTTON = ("Headset button: press for next or pause \u00b7 press twice to redo \u00b7 hold to stop "
"\u00b7 Window keys: Space P R S Esc")
KEYS_AUTO_BUTTON = ("Headset button: press to pause \u00b7 press twice to redo \u00b7 hold to stop "
"\u00b7 Window keys: P R S Esc")
# The early no-hands stop (DESIGN.md, "Camera check"): the first step of this section has both
# hands up. If the live tracker publishes through its hold and never sees a hand, the session
# stops that step and asks: try again, or stop. Only when the tracker published in at least
# HANDS_CHECK_PUBLISHED of at least HANDS_CHECK_MIN_READS reads (about 20 a second): without
# a tracker it can't tell.
HANDS_CHECK_SECTION = "hand-size"
HANDS_CHECK_MIN_READS = 10
HANDS_CHECK_PUBLISHED = 0.5
NO_HANDS_TITLE = "I can't see your hands"
NO_HANDS_TEXT = "The hand tracker didn't see either of your hands during that whole step."
NO_HANDS_RETRY = ("Try again: hold both hands up in front of you, about 40 cm away. To stop instead: "
"Esc or Stop in the Hand recorder window.")
NO_HANDS_RETRY_BUTTON = ("Try again: hold both hands up in front of you, about 40 cm away, and press the headset "
"button. To stop instead: hold the button, or Esc or Stop in the Hand recorder window.")
def run_dir():
return "/run/user/%d/frametop-hands" % os.getuid()
def fix_hint(frametop):
"""How to repair a missing or broken part: Frametop's command, or the standalone install's.
A Frametop release never builds (its programs come in its image), so a build there means
installing the release again."""
if STANDALONE:
return STANDALONE.get("reinstall") or "run the Hand Recorder's install command again"
if RELEASE and frametop.endswith("build.sh"):
return "install this Frametop release again"
return frametop
# ------------------------------------------------------------------------------------------
# The camera ring (camd/fhring.h; tools/ring.py's layout, struct only)
RING_HDR = struct.Struct("<8sIIIIQqQ16x") # 64 bytes
RING_CAM = struct.Struct("<32s32siIIIIIQQQQQIf24x") # 160 bytes
RING_SLOT = struct.Struct("<QQQQQIf16x") # 64 bytes, the image follows
FH_CAM_DARK, FH_CAM_COLOR = 1, 2
class Ring:
def __init__(self, path):
fd = os.open(path, os.O_RDONLY)
try:
self.map = mmap.mmap(fd, 0, mmap.MAP_SHARED, mmap.PROT_READ)
finally:
os.close(fd)
magic, version, _, ncams, _, _, self.writer_pid, _ = RING_HDR.unpack_from(self.map, 0)
if magic != b"FHRING01" or version != 1:
self.map.close()
raise ValueError("%s isn't a camera ring" % path)
self.cams = []
for i in range(min(ncams, 8)):
f = RING_CAM.unpack_from(self.map, RING_HDR.size + i * RING_CAM.size)
self.cams.append({
"index": i, "sensor": f[0].split(b"\0", 1)[0].decode(errors="replace"),
"name": f[1].split(b"\0", 1)[0].decode(errors="replace"), "node": f[2],
"width": f[4], "height": f[5], "stride": f[6], "nslots": f[7],
"slot_offset": f[8], "slot_bytes": f[9], "flags": f[13]})
def close(self):
self.map.close()
def alive(self, max_age=1.0):
hb = struct.unpack_from("<Q", self.map, 40)[0]
return hb != 0 and (mono_ns() - hb) / 1e9 < max_age
def _cam_field(self, cam, fmt, off):
return struct.unpack_from(fmt, self.map, RING_HDR.size + cam["index"] * RING_CAM.size + off)[0]
def latest(self, cam):
return self._cam_field(cam, "<Q", 104)
def dark_mean(self, cam):
return self._cam_field(cam, "<f", 132)
def mono(self):
"""The mono tracking cameras (no dark twins, no colour cameras, also by name for
ft-ringplay's rings, which carry no flags)."""
return [c for c in self.cams if not c["flags"] & (FH_CAM_DARK | FH_CAM_COLOR)
and not c["name"].endswith("_dk") and not c["name"].startswith("color")]
def frame_mean(self, cam):
"""The newest frame's mean luma: ft-camd's from the slot header, else (ft-ringplay
leaves it 0) measured on a sparse grid. None if there's no complete frame."""
n = self.latest(cam)
if not n:
return None
off = cam["slot_offset"] + (n % cam["nslots"]) * cam["slot_bytes"]
seq, _, _, _, _, _, mean = RING_SLOT.unpack_from(self.map, off)
if seq != 2 * n + 2:
return None
if mean <= 0:
img, total, count = off + RING_SLOT.size, 0, 0
for y in range(0, cam["height"], 16):
row = self.map[img + y * cam["stride"]: img + y * cam["stride"] + cam["width"]: 16]
total += sum(row)
count += len(row)
mean = total / count if count else 0.0
if struct.unpack_from("<Q", self.map, off)[0] != seq:
return None
return float(mean)
def ring_lighting(ring_path=None, samples=5, interval=0.2):
"""Each mono camera's brightness now: {"<cam>": {"mean": float, "dark_mean": float}}, the
newest frames' mean luma and ft-camd's near-black frame mean (the room's IR light), averaged
over about a second. None if no camera ring is running."""
path = ring_path or os.path.join(run_dir(), "cam-ring")
try:
ring = Ring(path)
except (OSError, ValueError):
return None
try:
if not ring.alive():
return None
sums = {}
for k in range(samples):
for cam in ring.mono():
m = ring.frame_mean(cam)
s = sums.setdefault(cam["name"], [0.0, 0, 0.0, 0])
if m is not None:
s[0] += m
s[1] += 1
s[2] += ring.dark_mean(cam)
s[3] += 1
if k + 1 < samples:
time.sleep(interval)
out = {name: {"mean": round(s[0] / s[1], 2) if s[1] else 0.0,
"dark_mean": round(s[2] / s[3], 2) if s[3] else 0.0} for name, s in sums.items()}
return out or None
finally:
ring.close()
def _light_levels(ring):
vals = [v for v in (ring or {}).values() if isinstance(v, dict)]
if not vals:
return None
return (sum(float(v.get("mean", 0)) for v in vals) / len(vals),
sum(float(v.get("dark_mean", 0)) for v in vals) / len(vals))
def _close(a, b, share=0.15, floor=1.0):
"""Within 15% of the larger; the floor keeps near-black levels (a dim room's dark mean is a
few levels) from looking different over sensor noise."""
return abs(a - b) <= max(share * max(abs(a), abs(b)), floor)
def similar_lighting(base_dir, lighting):
"""An earlier session whose cameras saw about the same light (the mean of every mono
camera's mean and dark_mean, each within 15%): (session_id, chosen), the latest such, or
None. lighting is ring_lighting()'s dict, or session.json's {"chosen", "ring"}."""
if not lighting:
return None
ring = lighting.get("ring") if isinstance(lighting.get("ring"), dict) else lighting
now = _light_levels(ring)
if not now:
return None
for path in sorted(glob.glob(os.path.join(base_dir, "sessions", "*", "session.json")), reverse=True):
try:
with open(path) as f:
light = json.load(f).get("lighting") or {}
except (OSError, ValueError):
continue
then = _light_levels(light.get("ring"))
if then and _close(now[0], then[0]) and _close(now[1], then[1]):
return os.path.basename(os.path.dirname(path)), light.get("chosen", "")
return None
DAYLIGHT_IR = 6.0 # ambient IR (the mono cameras' mean dark_mean) from which it's daylight; see classify_lighting
def ambient_ir(ring):
"""The mono cameras' mean dark_mean (the room's infrared light, as ft-hands logs it), or
None if none has one. ring is ring_lighting()'s dict."""
vals = [float(v.get("dark_mean", 0)) for v in (ring or {}).values() if isinstance(v, dict)]
vals = [v for v in vals if v > 0]
return round(sum(vals) / len(vals), 2) if vals else None
def classify_lighting(ring):
""""daylight" or "indoor" from the room's infrared light, "" if it can't tell. Sunlight
carries a lot of infrared; lamps and LEDs hardly any, so a dim room and a bright one read
about the same (2026-10: 1.8 by one lamp, 2.2 in a lamp-lit room) and aren't told apart.
Nor does a sunlit room reliably: the first daylight round (dataset PR #5, big sunlit windows)
read 2.38, since the windows are a small part of each picture and the mean barely moves. So
"indoor" means "no strong daylight on the cameras", and the window asks people to pick
daylight themselves. Review corrects a missed one from the pictures (sunlit windows, the time
of day): hands standing out little from the room goes with daylight but also with pale rooms
at night, so it isn't a test either."""
ir = ambient_ir(ring)
if ir is None:
return ""
return "daylight" if ir >= DAYLIGHT_IR else "indoor"
def lighting_record(choice, ring):
"""session.json's "lighting": chosen is the person's pick, or with "auto" (or none) what
the cameras measured; measured and ambient_ir are always the cameras' reading."""
measured = classify_lighting(ring)
picked = choice if choice in ("dim", "room", "daylight") else ""
return {"chosen": picked or measured, "source": "picked" if picked else "measured",
"measured": measured, "ambient_ir": ambient_ir(ring), "ring": ring or {}}
def ring_alive(path=None):
try:
r = Ring(path or os.path.join(run_dir(), "cam-ring"))
except (OSError, ValueError):
return False
try:
return r.alive()
finally:
r.close()
def start_camd(path=None, log=lambda line: None, stop=lambda: False, timeout=15.0):
"""Make sure ft-camd fills the camera ring: start it (CAMD_UNIT) if nothing does. True if
it started it, False if a ring was live already; raises RuntimeError if it can't."""
path = path or os.path.join(run_dir(), "cam-ring")
if ring_alive(path):
return False
if not os.access(FT_CAMD, os.X_OK):
raise RuntimeError("ft-camd isn't built: " + fix_hint("hands/build.sh"))
caps = subprocess.run(["getcap", FT_CAMD], capture_output=True, text=True) if shutil.which("getcap") else None
if caps is not None and "cap_sys_ptrace" not in caps.stdout:
raise RuntimeError("ft-camd needs its capabilities: " + fix_hint("hands/run.sh caps (asks for sudo)"))
started = start_unit(CAMD_UNIT, "the camera broker", [FT_CAMD, "--status", "60"], log)
end = time.monotonic() + timeout
while time.monotonic() < end:
if ring_alive(path):
return started
if stop():
return started
time.sleep(0.1)
raise RuntimeError("ft-camd didn't start (is SteamVR running?): journalctl --user -u " + CAMD_UNIT)
def openvr_env():
"""The environment for an OpenVR client (ft-handpanel): Frametop's desktop points
XDG_CONFIG_HOME at ~/.config/frametop, where OpenVR finds no openvrpaths.vrpath."""
env = dict(os.environ)
config = env.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
home = os.path.expanduser("~/.config")
if (not os.path.exists(os.path.join(config, "openvr", "openvrpaths.vrpath"))
and os.path.exists(os.path.join(home, "openvr", "openvrpaths.vrpath"))):
env["XDG_CONFIG_HOME"] = home
return env
def start_panel(panel_bin=None, stdout=None, timeout=15.0):
"""Start ft-handpanel (--watch-stdin: it ends when the returned process's stdin closes) and
wait until it answers. Returns the process; raises RuntimeError if it can't."""
panel_bin = panel_bin or PANEL_BIN
if not os.access(panel_bin, os.X_OK):
raise RuntimeError("ft-handpanel isn't built: " + fix_hint("hands/rec/build.sh"))
out = stdout if stdout is not None else subprocess.DEVNULL
proc = subprocess.Popen([panel_bin, "--watch-stdin"], stdin=subprocess.PIPE, stdout=out, stderr=out,
env=openvr_env())
panel = Panel()
try:
end = time.monotonic() + timeout
while time.monotonic() < end:
if panel.cmd("ping", reply=True, timeout=0.2):
return proc
if proc.poll() is not None:
raise RuntimeError("ft-handpanel exited (is SteamVR running?)")
time.sleep(0.1)
finally:
panel.close()
proc.kill()
raise RuntimeError("ft-handpanel doesn't answer")
def steamvr_running():
return bool(find_processes("vrserver"))
READY_PARTS = (("steamvr", "SteamVR"), ("cameras", "The cameras"), ("hands", "Hand tracking"),
("panel", "The headset panel"))
READY_WAIT_S = 20.0 # how long get_ready waits for each part it starts
def get_ready(frametop_hands=False, need_live=False, panel_bin=None, on_part=lambda key, state, text: None,
log=lambda line: None):
"""Start what a round needs before its checklist, in order: SteamVR (only checked), the
cameras (ft-camd), hand tracking (ft-hands) and the headset panel (ft-handpanel). With
Frametop's hand tracking installed (frametop_hands) its own units start; otherwise the
recorder's. need_live: a testing session, which records Frametop's own tracking.
on_part(key, state, text) reports each part ("busy", "ok" or "failed"). Returns
{"ok", "error", "units" (the units it started, to stop later), "panel" (the ft-handpanel
process it started, or None)}."""
out = {"ok": False, "error": "", "units": [], "panel": None}
def fail(key, text):
on_part(key, "failed", text)
out["error"] = text
return out
def wait(test):
end = time.monotonic() + READY_WAIT_S
while time.monotonic() < end:
if test():
return True
time.sleep(0.2)
return test()
def start_live(key):
if unit_active(LIVE_HANDS_UNIT):
return True
r = subprocess.run(host_command("systemctl", "--user", "start", LIVE_HANDS_UNIT),
capture_output=True, text=True, timeout=60)
if r.returncode != 0:
fail(key, "Frametop's hand tracking didn't start: %s" % (r.stderr or r.stdout).strip())
return False
out["units"].append(LIVE_HANDS_UNIT)
log("started %s" % LIVE_HANDS_UNIT)
return True
on_part("steamvr", "busy", "")
if not steamvr_running():
return fail("steamvr", "SteamVR isn't running. Put the headset on (or start SteamVR), then try again.")
on_part("steamvr", "ok", "Running")
if need_live and not frametop_hands:
return fail("cameras", DEBUG_NEEDS_HANDS)
on_part("cameras", "busy", "")
if not ring_alive():
if frametop_hands:
if not start_live("cameras"):
return out
if not wait(ring_alive):
return fail("cameras", "The cameras didn't start (journalctl --user -u %s)" % LIVE_CAMD_UNIT)
else:
try:
if start_camd(log=log, timeout=READY_WAIT_S):
out["units"].append(CAMD_UNIT)
except RuntimeError as e:
return fail("cameras", str(e))
on_part("cameras", "ok", "Running")
on_part("hands", "busy", "")
if not tracker_running() or (need_live and not unit_active(LIVE_HANDS_UNIT)):
if frametop_hands:
if not start_live("hands"):
return out
else:
argv = [FT_HANDS, "--no-gestures", "--status", "0"]
if not STANDALONE: # built in Frametop's container: it runs there
argv = [IN_BOX] + argv
try:
if start_unit(HANDS_UNIT, "hand tracking for feedback", argv, log):
out["units"].append(HANDS_UNIT)
except RuntimeError as e:
return fail("hands", str(e))
if not wait(tracker_running):
return fail("hands", "Hand tracking didn't start")
on_part("hands", "ok", "Running")
on_part("panel", "busy", "")
panel = Panel()
try:
running = bool(panel.cmd("ping", reply=True))
finally:
panel.close()
if not running:
try:
out["panel"] = start_panel(panel_bin)
except RuntimeError as e:
return fail("panel", str(e))
on_part("panel", "ok", "Ready (it shows when the round starts)")
out["ok"] = True
return out
# ------------------------------------------------------------------------------------------
# The factory calibration, without what identifies the unit
ID_TOKENS = {"serial", "sn", "uuid", "guid", "mac", "id", "ids", "identifier"}
def _key_identifies(key):
k = str(key)
if re.search(r"serial|uuid", k, re.I):
return True
tokens = re.findall(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+", k)
return any(t.lower() in ID_TOKENS for t in tokens)
def _value_identifies(value):
"""A string holding something like a serial number: a run of 8 or more letters and digits
with at least 4 digits and a letter (sensor names like og01a1b are shorter)."""
if not isinstance(value, str):
return False
for run in re.findall(r"[A-Za-z0-9]+", value):
if len(run) >= 8 and sum(c.isdigit() for c in run) >= 4 and any(c.isalpha() for c in run):
return True
return False
def strip_calibration(xrservice_json_dict):
"""/persist/xrservice.json without what identifies the unit: keys naming a serial, sn,
uuid, mac or id, and string values that look like serial numbers. Returns (cleaned dict,
removed key paths such as "cameras[0].serial")."""
removed = []
def walk(o, path):
if isinstance(o, dict):
out = {}
for k, v in o.items():
p = "%s.%s" % (path, k) if path else str(k)
if _key_identifies(k) or _value_identifies(v):
removed.append(p)
continue
out[k] = walk(v, p)
return out
if isinstance(o, list):
out = []
for i, v in enumerate(o):
p = "%s[%d]" % (path, i)
if _value_identifies(v):
removed.append(p)
out.append(None) # keep the other items' positions
continue
out.append(walk(v, p))
return out
return o
return walk(xrservice_json_dict, ""), removed
# ------------------------------------------------------------------------------------------
# The live hands file (include/fh_hands.h)
HANDS_HDR = struct.Struct("<8sIIQQQII16x") # 64 bytes
HAND = struct.Struct("<IIff63fI") # 272 bytes
FH_HAND_RIGHT = 1
PALM = (0, 5, 9, 13, 17) # wrist and knuckles
INDEX_TIP = 8
class HandsFile:
"""Reads ft-hands' hands file under its sequence lock. read() gives None while no tracker
publishes (no file, or nothing for FRESH_S), else {"left": hand|None, "right": hand|None}
with hand = {"palm": [x,y,z], "palm_m": float, "tip": [x,y,z]} in the head frame."""
def __init__(self, path):
self.path = path
def read(self):
try:
fd = os.open(self.path, os.O_RDONLY)
except OSError:
return None
try:
size = HANDS_HDR.size + 2 * HAND.size
for _ in range(4):
data = os.pread(fd, size, 0)
if len(data) < size:
return None
magic, version, _, seq, _, publish_ns, nhands, _ = HANDS_HDR.unpack_from(data, 0)
if magic != b"FHHANDS1" or version < 1: # 2 appends the skeleton; the start is unchanged
return None
if seq % 2 or os.pread(fd, 8, 16) != data[16:24]:
continue
if (mono_ns() - publish_ns) / 1e9 > FRESH_S:
return None
out = {"left": None, "right": None}
for k in range(min(nhands, 2)):
f = HAND.unpack_from(data, HANDS_HDR.size + k * HAND.size)
side = "right" if f[1] & FH_HAND_RIGHT else "left"
if out[side]:
continue
pts = [f[4 + 3 * i: 7 + 3 * i] for i in range(21)]
palm = [sum(pts[i][j] for i in PALM) / len(PALM) for j in range(3)]
out[side] = {"palm": palm, "palm_m": math.sqrt(sum(v * v for v in palm)),
"tip": list(pts[INDEX_TIP])}
return out
return None
finally:
os.close(fd)
# ------------------------------------------------------------------------------------------
# The panel (ft-handpanel, @ft_handpanel)
def _lit_cmd(a):
"""The panel command lighting an act's (or a steady light's) part."""
return " ".join(["win", a["win"], "lit", a["lit"], a["colour"]] + ([a["label"]] if a["label"] else []))
def _pose_reply(reply):
"""The 12 numbers of an "ok <3x4 pose>" reply (row-major, the room's standing universe), or None."""
tok = (reply or "").split()
try:
return [round(float(v), 5) for v in tok[1:13]] if tok[:1] == ["ok"] and len(tok) == 13 else None
except ValueError:
return None
class Panel:
"""Commands to ft-handpanel over its datagram socket. Commands that need an answer wait
for it; the rest don't, and their replies are read (and errors logged) later. With
dry=True nothing is sent: commands are printed and replies made up."""
def __init__(self, name=PANEL_SOCKET, dry=False, log=None, out=None):
self.name, self.dry, self.log, self.out = name, dry, log or (lambda s: None), out or print
self.sock = None
self.sent = {}
self.pending = 0 # commands sent and not answered yet
if not dry:
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC)
self.sock.bind("\0ft_handrec.%d.%d" % (os.getpid(), id(self) & 0xffff))
self.sock.setblocking(False)
def close(self):
if self.sock:
self.sock.close()
self.sock = None
def _drain(self):
while self.sock:
try:
r = self.sock.recv(4096).decode(errors="replace")
except (BlockingIOError, OSError):
return
self.pending = max(0, self.pending - 1)
if r.startswith("error"):
self.log("panel: %s" % r)
def cmd(self, text, reply=False, timeout=0.5):
"""Send a command; with reply=True, its answer ("ok ..." or "error ..."), or None.
The panel answers every command in order, so the answer is the one after those of
the commands still unanswered."""
if self.dry:
self.out("panel: %s" % text)
if not reply:
return None
if text.startswith("devices"):
return "ok hmd 200 left 200 right 200"
return "ok shown" if text == "ping" else "ok"
if not self.sock:
return None
self._drain()
try:
self.sock.sendto(text.encode(), "\0" + self.name)
except OSError:
return None
self.pending += 1
if not reply:
return None
end = time.monotonic() + timeout
while time.monotonic() < end:
try:
self.sock.settimeout(max(0.01, end - time.monotonic()))
r = self.sock.recv(4096).decode(errors="replace")
except OSError:
break
finally:
self.sock.setblocking(False)
self.pending = max(0, self.pending - 1)
if self.pending == 0:
return r
if r.startswith("error"):
self.log("panel: %s" % r)
self.pending = 0 # a lost answer: start counting afresh
return None
def set(self, key, text):
"""Send a command only if it changes what that part of the panel shows."""
if self.sent.get(key) == text:
return
self.sent[key] = text
self.cmd(text)
# ------------------------------------------------------------------------------------------
# Processes
def find_processes(name):
"""[(pid, argv)] of running processes called name (the container shares the host's PIDs)."""
out = []
for pid in os.listdir("/proc"):
if not pid.isdigit():
continue
try:
with open("/proc/%s/comm" % pid) as f:
if f.read().strip() != name:
continue
with open("/proc/%s/cmdline" % pid, "rb") as f:
argv = [a.decode(errors="replace") for a in f.read().split(b"\0") if a]
except OSError:
continue
out.append((int(pid), argv))
return out
def tracker_running():
"""A tracking ft-hands (not a --record-only recorder)."""
return any("--record-only" not in argv for _, argv in find_processes("ft-hands"))
class Recorder:
"""One part of a take's recording: ft-hands --record-only as a child process. Part 1
writes TAKE/sets.bin; part N (after a pause) records into TAKE/.part-N and is moved to
TAKE/sets-N.bin when it ends (ft-hands never overwrites, and always writes DIR/sets.bin)."""
def __init__(self, take_dir, part, seconds, ring, log_file, swap=None, hz=RECORD_HZ):
"""swap: the side cameras' decision (sides.py), passed on as --sides 1 or 0; None (not
known yet) records them as ft-camd names them (--sides auto, whatever the config says)."""
self.take_dir, self.part = take_dir, part
self.dir = take_dir if part == 1 else os.path.join(take_dir, ".part-%d" % part)
self.file = "sets.bin" if part == 1 else "sets-%d.bin" % part
self.names_swapped = bool(swap)
argv = [FT_HANDS, "--record-only", "--record", self.dir, "--record-for", "%.0f" % max(seconds, 5),
"--record-hz", "%g" % hz, "--status", "0",
"--sides", "auto" if swap is None else "1" if swap else "0"]
if ring:
argv += ["--ring", ring]
if not in_container() and not STANDALONE:
argv = [IN_BOX] + argv
self.proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log_file, stderr=log_file)
self.started_ns = mono_ns()
def exited(self):
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 sets_written(self):
"""How many sets the part has written so far (0 before the first)."""
try:
path = os.path.join(self.dir, "sets.bin")
with open(path, "rb") as f:
head = f.read(16)
magic, _, size = struct.unpack("<8sII", head)
return os.path.getsize(path) // size if magic.rstrip(b"\0") == b"FHSET01" and size else 0
except (OSError, struct.error):
return 0
def cameras(self):
"""The cameras in the first set written (their calibration names), [] before there is one."""
try:
with open(os.path.join(self.dir, "sets.bin"), "rb") as f:
data = f.read(16 + 8 * 40)
magic, n, _ = struct.unpack_from("<8sII", data, 0)
if magic.rstrip(b"\0") != b"FHSET01" or len(data) < 16 + n * 40:
return []
return [struct.unpack_from("<16s", data, 16 + i * 40)[0].split(b"\0", 1)[0].decode(errors="replace")
for i in range(n)]
except (OSError, struct.error):
return []
def stop(self):
"""End it (SIGTERM: ft-hands writes out its queue) and put the part in place. Afterwards
names_swapped is what ft-hands says it applied (its DIR/sides.json, which goes into
take.json's "parts" and is removed here)."""
if self.proc.poll() is None:
self.proc.send_signal(signal.SIGTERM)
try:
self.proc.wait(15)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait()
side_file = os.path.join(self.dir, "sides.json")
try:
with open(side_file) as f:
runs = json.load(f).get("names_swapped") or []
if runs:
self.names_swapped = bool(runs[0][1]) # --record-only never changes it
os.remove(side_file)
except (OSError, ValueError, TypeError, IndexError):
pass
if self.part > 1:
src = os.path.join(self.dir, "sets.bin")
if os.path.exists(src):
os.replace(src, os.path.join(self.take_dir, "sets-%d.bin" % self.part))
try:
os.rmdir(self.dir)
except OSError:
pass
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"]
def demo_view(demos_dir, p):
"""A prompt's demo clip: (path, mode), mode "" (as made: a right hand, or both hands) or
"mirror" (the prompt's "demo_mirror": a right hand's clip for the left hand). ("", "")
when it has none, or its file isn't there."""
demo = p.get("demo") or ""
path = os.path.join(demos_dir, os.path.basename(demo) + ".gif") if demo else ""
if not path or not os.path.isfile(path):
return "", ""
return path, "mirror" if p.get("demo_mirror") else ""
def has_clips(demos_dir):
try:
return any(n.endswith(".gif") for n in os.listdir(demos_dir))
except OSError:
return False
def default_guidance(recorded_dir=None):
"""Recorded clips when there are any, else the skeleton ones."""
return "recorded" if has_clips(recorded_dir or RECORDED_DEMOS_DIR) else "skeleton"
def guided_demo_view(guidance, skeleton_dir, recorded_dir, p):
"""demo_view in the guidance's folder, else the other one (a part with no clip of that kind):
(path, mode, key), key true for a skeleton clip (the panel and window show its colours' key)."""
dirs = [(recorded_dir, False), (skeleton_dir, True)]
if guidance != "recorded":
dirs.reverse()
for d, key in dirs:
path, mode = demo_view(d, p)
if path:
return path, mode, key
return "", "", False
# ------------------------------------------------------------------------------------------
# The script
HAND_VALUES = ("left", "right", "both", "none", "any", "")
PROMPT_KEYS = ("text", "seconds", "hands", "pose", "distance", "position", "object", "controller", "demo")
def _when_ok(cond, ctx):
"""A prompt's or block's "when": "controllers", "objects:keyboard", either negated with "!",
or a list of them, all to hold."""
if not cond:
return True
if isinstance(cond, list): # all of them
return all(_when_ok(c, ctx) for c in cond)
neg = cond.startswith("!")
c = cond.lstrip("!")
value = c[len("objects:"):] in ctx["objects"] if c.startswith("objects:") else bool(ctx.get(c))
return value != neg
def _prompt(section, raw, ctx, subst=None):
p = dict(section.get("defaults") or {})
p.update(raw)
p.pop("when", None)
for k, v in (subst or {}).items():
for key in ("text", "object"):
if isinstance(p.get(key), str):
p[key] = p[key].replace("{%s}" % k, v)
if subst and "object_key" in subst:
p["object"] = subst["object_key"]
for key in PROMPT_KEYS:
p.setdefault(key, False if key == "controller" else 0 if key == "seconds" else "")
p["controllers"] = list(p.get("controllers") or [])
p["switch"] = _switch_list(section, p.get("switch"))
if p["hands"] not in HAND_VALUES:
raise ValueError("%s: hands %r" % (section["id"], p["hands"]))
return p
def _switch_list(section, raw):
"""A step's pose changes (DESIGN.md, "Two poses in a step"): "backs toward you" (at half
time), or [{"at": share of the step's time, "to": the pose after, "text": the instruction
after (optional; a scene's acts keep their own)}] in order."""
if not raw:
return []
if isinstance(raw, str):
raw = [{"at": 0.5, "to": raw}]
out, last = [], 0.0
for w in raw if isinstance(raw, list) else [raw]:
at = w.get("at") if isinstance(w, dict) else None
to = w.get("to") if isinstance(w, dict) else None
if not isinstance(at, (int, float)) or not last < at < 1 or not isinstance(to, str) or not to.strip():
raise ValueError("%s: switch %r: each needs \"at\" (after the one before, under 1) and \"to\"" % (section["id"], w))
if isinstance(w, dict) and not isinstance(w.get("text", ""), str):
raise ValueError("%s: switch %r: \"text\" is the instruction after" % (section["id"], w))
out.append({"at": float(at), "to": to.strip(), "text": w.get("text", "") if isinstance(w, dict) else ""})
last = at
return out
def _set_ids(section, prompts):
seen = {}
for p in prompts:
if not p.get("id"):
parts = [section["id"]] + [re.sub(r"[^a-z0-9]+", "-", str(p[k]).lower()).strip("-")
for k in ("demo", "pose", "hands", "distance", "position", "object") if p[k]]
p["id"] = "/".join(parts) if len(parts) > 1 else "%s/%d" % (section["id"], len(seen) + 1)
n = seen.get(p["id"], 0) + 1
seen[p["id"]] = n
if n > 1:
p["id"] = "%s#%d" % (p["id"], n)
def load_script(path):
with open(path) as f:
script = json.load(f)
if script.get("version") != 1 or not isinstance(script.get("sections"), list):
raise ValueError("%s: not a version 1 script" % path)
return script
def bounty_script(script, bounties_dir=None):
"""A bounty's round script's path."""
return os.path.join(bounties_dir or BOUNTIES_DIR, script + ".json")
def have_bounty_script(script, bounties_dir=None):
return bool(bounties.ID.match(script)) and os.path.exists(bounty_script(script, bounties_dir))
def load_board(base_dir=None):
"""The board: the shipped one, or the copy fetched from the dataset when it's newer (hub.py board)."""
cache = os.path.join(base_dir or BASE_DIR, "board-cache.json")
return bounties.newest(bounties.load(BOARD_PATH), bounties.load(cache))
def session_seed(session_id):
"""The shuffle's seed for a session: from its id, so the same id gives the same plan."""
return int(hashlib.sha256(session_id.encode()).hexdigest()[:8], 16)
def _sweep_prompts(s, raw, ctx, rng):
"""A sweep section's steps (DESIGN.md, "Sweeps"): each a prompt with "cues", the pose of each
cue_s slot in order. A step names its cues, or takes a group: "next" (the groups in turn, in
the shuffled order) or "any" (a group drawn at random, different for each "any" while there
are groups left). With rng (the session's shuffle) the groups' order, the "any" draws and the
cues' order within a step are shuffled, unless the section says "shuffle": false or the
step "fixed": true; without it, everything goes in the script's order."""
groups = [list(g) for g in raw.get("groups") or []]
shuffle = rng is not None and raw.get("shuffle", True)
order = list(range(len(groups)))
pool = list(range(len(groups)))
if shuffle:
rng.shuffle(order)
rng.shuffle(pool)
taken, drawn, counts = 0, 0, {}
out = []
for step in raw.get("sweeps") or []:
if not _when_ok(step.get("when"), ctx):
continue
g = step.get("group")
if step.get("cues"):
cues = list(step["cues"])
elif g == "next" and groups:
cues, taken = list(groups[order[taken % len(order)]]), taken + 1
elif g == "any" and groups:
cues, drawn = list(groups[pool[drawn % len(pool)]]), drawn + 1
elif isinstance(g, int) and 0 <= g < len(groups):
cues = list(groups[g])
else:
raise ValueError("%s: a sweep needs cues or a group" % s["id"])
if shuffle and not step.get("fixed"):
rng.shuffle(cues)
cue_s = float(step.get("cue_s", raw.get("cue_s", 4)))
slots = max(len(cues), int(math.ceil(float(step.get("seconds", raw.get("step_s", cue_s * len(cues)))) / cue_s
- 1e-9))) if step.get("cycle", raw.get("cycle", True)) else len(cues)
p = _prompt(s, {k: v for k, v in step.items() if k not in ("group", "cues", "fixed", "cycle", "quick")}, ctx)
p["cues"] = [cues[k % len(cues)] for k in range(slots)]
p["cue_s"] = cue_s
p["seconds"] = cue_s * slots
p["pose"] = p["cues"][0]
if not step.get("id"):
counts[p["hands"]] = counts.get(p["hands"], 0) + 1
p["id"] = "%s/%s-%d" % (s["id"], p["hands"] or "any", counts[p["hands"]])
else:
p["id"] = "%s/%s" % (s["id"], step["id"])
if step.get("quick") is False:
p["quick"] = False
out.append(p)
return out
# A scene's gestures (DESIGN.md, "Scenes"): the lit part's colour and label for each, and what
# the band says while it's lit. A no-go is drawn hollow with a cross.
# A scene step whose acts switch between the hands (L or R on the button) gives each act at
# least this long lit, so there's time to see which hand and use it (user, 2026-10-14).
CHOICE_MIN_S = 2.0
GESTURES = {
"tap": ("cyan", "", "Tap: thumb to index fingertip"),
"double": ("cyan", "2", "Double tap"),
"hold": ("cyan", "hold", "Press and hold: thumb to index, about 1 s"),
"middle": ("purple", "", "Middle tap: thumb to middle fingertip"),
"middle-hold": ("purple", "hold", "Middle press and hold, about 1 s"),
"nogo": ("orange", "", "Don't touch: stop about 1 cm short"),
"pinch": ("cyan", "", "Pinch it: thumb and index"),
"card": ("cyan", "", "Grab it like a playing card: thumb, index and middle"),
"fist": ("green", "", "Grab it with your fist"),
"touch": ("orange", "", "Touch it with an open hand: no grab"),
# Near touch and whole-hand gestures (2026-10-15: the testing sessions' refused pokes, missed and
# false lazy susans, "grab that" taking the wrong screen)
"poke": ("cyan", "", "Poke it: index fingertip in, then back out"),
"poke-hold": ("cyan", "hold", "Poke it and keep it pressed, about 1 s"),
"poke-slide": ("cyan", "slide", "Poke it and slide the fingertip down, as if scrolling"),
"sweep": ("cyan", "", "Sweep it aside: open hand on its edge, palm leading"),
"pull": ("green", "", "Open hand out to it, then close a fist and pull it to you"),
"push": ("cyan", "", "Push it away: open hand, palm flat against it"),
"rest": ("cyan", "", ""),
}
SCENE_LOOKS = re.compile(r"^(plain|grid:[1-6]x[1-6]|list:([1-9]|10))$")
SCENE_PART = r"(bar|left|right|top|bottom|tl|tr|bl|br|button:\d+|item:\d+)"
SCENE_PARTS = re.compile(r"^(none|%s(\+%s)*)$" % (SCENE_PART, SCENE_PART)) # "tl+br": a two-hand grab
def _cells(look):
"""How many buttons or rows a window's look has (0: plain)."""
m = re.match(r"^grid:(\d)x(\d)$", look)
if m:
return int(m.group(1)) * int(m.group(2))
m = re.match(r"^list:(\d+)$", look)
return int(m.group(1)) if m else 0
def _act(p, a, windows):
"""One act of a scene step, filled in: {"s", "win", "lit", "gesture", "colour", "label", "hand",
"say", "ghost"}. The colour and label come from the gesture unless the act gives them."""
g = a.get("gesture", "tap")
if g not in GESTURES:
raise ValueError("%s: gesture %r" % (p.get("id") or p.get("text", "")[:30], g))
colour, label, say = GESTURES[g]
win = a.get("win") or windows[0]["id"]
if win not in [w["id"] for w in windows]:
raise ValueError("%s: no window %r" % (p.get("text", "")[:30], win))
lit = a.get("lit", "none" if g == "rest" else "bar")
if not SCENE_PARTS.match(lit):
raise ValueError("lit %r" % lit)
ghost = a.get("ghost")
if ghost is not None:
ghost = [float(v) for v in ghost] + [0.0] * (6 - len(ghost))
if len(ghost) not in (6, 7):
raise ValueError("ghost %r: dx dy dz yaw pitch roll [scale]" % a.get("ghost"))
return {"s": float(a.get("s", 2.0)), "win": win, "lit": lit, "gesture": g, "colour": a.get("colour", colour),
"label": a.get("label", label), "hand": a.get("hand", ""), "say": a.get("say") or say or p.get("say", ""),
"ghost": ghost}
def _scene_buttons(p, a, windows, rng):
"""Paced acts: the windows' buttons (or rows) lit one at a time, every every_s, lit for lit_s
with an unlit rest after; the gesture drawn from mix (weights), each in its colour; with
hands, each act names the hand to use (L or R on the button)."""
every = float(a.get("every_s", 1.5))
lit_s = min(every, float(a.get("lit_s", every * 0.7)))
hands = list(a.get("hands") or [])
if len(set(hands)) > 1 and lit_s < CHOICE_MIN_S: # a hand to pick: lit long enough, the rest kept
every, lit_s = every - lit_s + CHOICE_MIN_S, CHOICE_MIN_S
n = int(a.get("count") or max(1, round(float(a.get("seconds", 12)) / every)))
wins = [w for w in windows if w["id"] in (a.get("windows") or [w["id"] for w in windows]) and _cells(w["look"])]
if not wins:
raise ValueError("%s: buttons need a window with a grid or a list" % p.get("text", "")[:30])
mix = a.get("mix") or {a.get("gesture", "tap"): 1}
names, weights = list(mix), [float(mix[k]) for k in mix]
says = a.get("says") or {}
out, last = [], None
for _ in range(n):
w = rng.choice(wins)
cells = _cells(w["look"])
part = "button" if w["look"].startswith("grid") else "item"
k = rng.randrange(cells)
if cells > 1:
while (w["id"], k) == last:
k = rng.randrange(cells)
last = (w["id"], k)
g = rng.choices(names, weights)[0]
act = {"s": lit_s, "win": w["id"], "lit": "%s:%d" % (part, k), "gesture": g}
if g in says:
act["say"] = says[g]
if hands:
h = rng.choice(hands)
mark = GESTURES[g][1]
act.update(hand=h, label=("L" if h == "left" else "R") + ("_" + mark if mark else ""))
out.append(_act(p, act, windows))
if every - lit_s > 0.05:
out.append(_act(p, {"s": every - lit_s, "win": w["id"], "gesture": "rest", "say": out[-1]["say"]}, windows))
return out
def _scene_moves(p, a, windows, rng):
"""Grabs: each move lights a part to grab and, with a ghost, shows where to take the window;
one every every_s, shuffled unless "shuffle": false, each followed by a let-go rest of gap_s.
A move is a ghost ([dx, dy, dz, yaw, pitch, roll, scale], in the window's frame) or
{"ghost", "grab", "win", "gesture", "hand", "label", "say", "s"}, each defaulting to the
generator's own."""
moves = [dict(m) if isinstance(m, dict) else {"ghost": m} for m in a.get("moves") or []]
if not moves:
raise ValueError("%s: moves needs a list" % p.get("text", "")[:30])
if a.get("shuffle", True):
rng.shuffle(moves)
n = int(a.get("count") or len(moves))
out = []
for j in range(n):
m = moves[j % len(moves)]
act = {"s": float(m.get("s", a.get("every_s", 4))), "win": m.get("win", a.get("win")),
"lit": m.get("grab", a.get("grab", "bar")), "gesture": m.get("gesture", a.get("gesture", "pinch")),
"ghost": m.get("ghost"), "say": m.get("say", a.get("say")), "hand": m.get("hand", a.get("hand", ""))}
if "label" in m:
act["label"] = m["label"]
out.append(_act(p, act, windows))
gap = float(a.get("gap_s", 1.0))
if gap > 0:
out.append(_act(p, {"s": gap, "win": out[-1]["win"], "gesture": "rest", "say": a.get("rest_say", "Let go")},
windows))
return out
def _scene_step(s, p, rng):
"""A scene step's windows and acts (DESIGN.md, "Scenes"), into p["scene"]; its seconds are the
acts'. "acts" is a list of acts and generators ({"gen": "buttons"|"moves", ...})."""
windows = []
for w in p.get("windows") or []:
look = w.get("look", "plain")
if not SCENE_LOOKS.match(look) or len(w.get("at") or []) != 3 or len(w.get("size") or []) != 2:
raise ValueError("%s: window %r" % (s["id"], w))
windows.append({"id": str(w.get("id", "a")), "at": [float(v) for v in w["at"]],
"size": [float(v) for v in w["size"]], "turn": [float(v) for v in (w.get("turn") or [0, 0])],
"look": look})
if not windows or len(windows) > 4 or len({w["id"] for w in windows}) != len(windows):
raise ValueError("%s: a scene step needs 1 to 4 windows, each its own id" % s["id"])
acts = []
spec = p.get("acts") or []
for a in [spec] if isinstance(spec, dict) else spec:
gen = a.get("gen")
if gen == "buttons":
acts += _scene_buttons(p, a, windows, rng)
elif gen == "moves":
acts += _scene_moves(p, a, windows, rng)
elif gen:
raise ValueError("%s: gen %r" % (s["id"], gen))
else:
acts.append(_act(p, a, windows))
if not acts:
raise ValueError("%s: a scene step needs acts" % s["id"])
asked = [a for a in acts if a["gesture"] != "rest"]
if len({a["hand"] for a in asked if a["hand"] in ("left", "right")}) > 1:
short = [a for a in asked if a["s"] < CHOICE_MIN_S - 1e-9]
if short:
raise ValueError("%s: its acts switch hands, so each needs at least %g s (%g here)"
% (p.get("id") or s["id"], CHOICE_MIN_S, short[0]["s"]))
# Lit for the whole step (one hand holds a pinch while the other taps): on windows no act uses.
steady = [_act(p, dict(x, s=0), windows) for x in p.get("steady") or []]
if {x["win"] for x in steady} & {a["win"] for a in acts}:
raise ValueError("%s: a steady light's window is one the acts use" % s["id"])
p["scene"] = {"windows": windows, "acts": acts, "steady": steady}
p["seconds"] = round(sum(a["s"] for a in acts), 3)
for key in ("acts", "windows", "steady"):
p.pop(key, None)
p.setdefault("say", acts[0]["say"])
if not p.get("pose"):
p["pose"] = next((a["gesture"] for a in acts if a["gesture"] != "rest"), "")
def build_plan(script, checklist, seed=None, quick=False):
"""The sections this session runs, prompts expanded for the checklist, and the ones
skipped: (plan, skipped) with skipped = [{"section", "reason"}]. seed: the shuffle of the
sweeps (session_seed; None: the script's order). quick: the quick round, only sections
marked "quick" and, in those, no step marked "quick": false."""
objects = [o for o in (checklist.get("objects") or []) if o]
own = [o for o in (checklist.get("own_objects") or []) if str(o).strip()]
ctx = {"objects": set(objects) | set(own), "controllers": checklist.get("controllers") == "straps"}
names = script.get("objects") or {}
plan, skipped = [], []
for raw in script["sections"]:
sid = raw["id"]
if quick and not raw.get("quick"):
skipped.append({"section": sid, "reason": "not in a quick round"})
continue
missing = [r for r in raw.get("requires") or [] if not ctx.get(r)]
if missing:
skipped.append({"section": sid, "reason": "needs " + ", ".join(missing)})
continue
s = dict(raw)
s["kind"] = raw.get("kind", "prompts")
s["intro_s"] = float(raw.get("intro_s", script.get("intro_s", 4)))
prompts = []
if s["kind"] == "sweep":
rng = random.Random("%d/%s" % (seed, sid)) if seed is not None else None
prompts = _sweep_prompts(s, raw, ctx, rng)
if quick:
prompts = [p for p in prompts if p.get("quick") is not False]
elif raw.get("for_each") == "object":
skip = set(raw.get("skip_objects") or [])
for o in [o for o in objects if o not in skip] + own:
label = clean_text(names.get(o, o)).replace("|", "/")
for p in raw.get("prompts") or []:
if _when_ok(p.get("when"), ctx):
prompts.append(_prompt(s, p, ctx, {"object": label, "object_key": clean_text(o)}))
else:
prompts = [_prompt(s, p, ctx) for p in raw.get("prompts") or [] if _when_ok(p.get("when"), ctx)
and not (quick and p.get("quick") is False)]
for p in prompts: # a prompt may have cues too (hand size): its own, in its order
if p.get("cues"):
cue_s = float(p.get("cue_s", raw.get("cue_s", 4)))
p["cues"], p["cue_s"] = list(p["cues"]), cue_s
p["seconds"] = cue_s * len(p["cues"])
p["pose"] = p["cues"][0]
if s["kind"] == "scene": # a step's own id goes under its section's, as a sweep step's
for p in prompts:
if p.get("id") and "/" not in p["id"]:
p["id"] = "%s/%s" % (sid, p["id"])
_set_ids(s, prompts)
if s["kind"] == "scene":
# The acts' draws, per step: the session's (seed), or the step's own for a plan
# without one, so --plan shows the same each time.
for p in prompts:
_scene_step(s, p, random.Random("%s/%s/%s" % (seed, sid, p["id"])))
s["prompts"] = prompts
if s["kind"] == "bar":
heights = []
for h in raw.get("heights") or []:
h = {"id": h} if isinstance(h, str) else dict(h)
h.setdefault("text", (raw.get("height_text") or {}).get(h["id"], raw.get("text", "")))
h.setdefault("reps", raw.get("reps", 5))
heights.append(h)
s["heights"] = heights
before = raw.get("before")
s["before"] = before if before and _when_ok(before.get("when"), ctx) else None
if s["kind"] in ("prompts", "sweep", "scene") and not prompts:
skipped.append({"section": sid, "reason": "nothing to do"})
continue
if s["kind"] == "targets" and not raw.get("targets"):
skipped.append({"section": sid, "reason": "no targets"})
continue
plan.append(s)
return plan, skipped
def plan_record(plan):
"""What the shuffle chose, for session.json: each section's sweep steps and their cues."""
out = {}
for s in plan:
steps = [{"id": p["id"], "hands": p["hands"], "cues": p["cues"]} for p in s["prompts"] if p.get("cues")]
steps += [{"id": p["id"], "hands": p["hands"],
"acts": [[a["gesture"], a["win"], a["lit"], a["hand"]] for a in p["scene"]["acts"]]}
for p in s["prompts"] if p.get("scene")]
if steps:
out[s["id"]] = steps
return out
def section_steps(s):
"""The steps that wait for Next in step mode: each prompt and bar height, and the first target."""
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":
t += len(s["targets"]) * (s.get("timeout_s", 8) if worst else min(4.0, s.get("timeout_s", 8)))
if s["kind"] == "bar":
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
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)
for i, s in enumerate(plan):
t += section_seconds(s, worst)
t += s["before"]["seconds"] if s.get("before") else (script.get("between_s", 3) if i else 0)
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 (the plan of a quick round or a full one)."""
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
class _Skip(Exception):
pass
class _Stop(Exception):
pass
class _Fail(Exception):
pass
class _Redo(Exception):
pass
class _CamerasLost(_Fail):
"""The recording stopped by itself or has no side cameras: usually ft-camd restarting after
XRService moved the cameras (camd.c, SILENT_S). Step mode waits for them and does the step
again (Session._cameras_back); anywhere else it fails the session like any _Fail."""
_camd_restarted = set() # ft-camd pids camera_check(repair=True) has restarted: once each
def _unit_pid(unit):
r = subprocess.run(host_command("systemctl", "--user", "show", "-p", "MainPID", "--value", unit),
capture_output=True, text=True, timeout=30)
try:
return int(r.stdout.strip() or 0)
except ValueError:
return 0
def live_settings(conf_path=None):
"""The HANDS_* and POINTER_* lines of ~/.config/frametop.conf, comments dropped; a value
naming a path keeps only its last part (a path says whose home it is)."""
out = {}
try:
with open(conf_path or CONF_PATH) as f:
lines = f.read().splitlines()
except OSError:
return out
for line in lines:
key, eq, value = line.partition("=")
key = key.strip()
if not eq or not LIVE_KEYS.match(key):
continue
value = value.split("#", 1)[0].strip().strip("\"'")
if "/" in value:
value = os.path.basename(value.rstrip("/"))
out[key] = value
return out
def live_tracker(unit=LIVE_HANDS_UNIT):
"""What Frametop's live ft-hands is: {"frametop": its tree's version (common.tree_version), "models":
{file: sha256 (16 hex)}} of the models it loads (HANDS_MODELS, else hands/models/student
or the stock hands/models/ncnn next to it). Empty fields when it can't be told."""
out = {"frametop": "", "models": {}}
try:
r = subprocess.run(host_command("systemctl", "--user", "show", "-p", "ExecStart", "--value", unit),
capture_output=True, text=True, timeout=30)
exe = next((w for w in r.stdout.split() if w.endswith("/ft-hands")), "")
except (OSError, subprocess.SubprocessError):
exe = ""
repo = os.path.dirname(os.path.dirname(os.path.dirname(exe))) if exe else ""
if repo:
out["frametop"] = tree_version(repo)
conf = {}
try:
with open(CONF_PATH) as f:
for line in f:
key, eq, value = line.partition("=")
if eq and key.strip() == "HANDS_MODELS":
conf["HANDS_MODELS"] = value.split("#", 1)[0].strip().strip("\"'")
except OSError:
pass
dirs = [os.path.expanduser(conf["HANDS_MODELS"])] if conf.get("HANDS_MODELS") else []
if repo:
dirs += [os.path.join(repo, "hands", "models", d) for d in ("student", "ncnn")]
for d in dirs:
files = sorted(glob.glob(os.path.join(d, "*.param")) + glob.glob(os.path.join(d, "*.bin")))
if files:
for path in files:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
out["models"][os.path.basename(path)] = h.hexdigest()[:16]
out["models_dir"] = os.path.basename(d.rstrip("/"))
break
return out
def camera_check(repair=False):
"""camcheck.check() (the XRService log, its open cameras where readable, ft-camd's ring);
never raises: a failure is "unknown". repair (the window, never during a session): when
XRService runs every tracking camera but the recorder's own ft-camd doesn't publish them all
(it started while some were missing), restart it, once per ft-camd, and check again."""
try:
r = camcheck.check()
pid = (r.get("ring") or {}).get("writer_pid")
if repair and camcheck.is_ring_short(r) and pid not in _camd_restarted:
if _unit_pid(CAMD_UNIT) == pid:
_camd_restarted.add(pid)
stop_unit(CAMD_UNIT)
start_camd()
elif _unit_pid(LIVE_CAMD_UNIT) == pid:
# Frametop's live hand tracking: its ft-hands names the cameras when it starts,
# so it goes again after ft-camd
_camd_restarted.add(pid)
restart_units([LIVE_CAMD_UNIT, LIVE_HANDS_UNIT])
end = time.monotonic() + 15
while not ring_alive() and time.monotonic() < end:
time.sleep(0.2)
else:
return r
r = camcheck.check()
r["evidence"].append("restarted ft-camd (pid %d) because it published only some of the cameras" % pid)
return r
except Exception as e:
return {"status": "unknown", "summary": "unknown: the camera check failed (%s)" % e,
"reason": str(e), "evidence": []}
def camera_text(result):
"""What to tell the person about a camera check that isn't ok ("" if it's ok or unknown)."""
if not result or result.get("status") != "degraded":
return ""
if camcheck.is_vcint_failure(result):
return camcheck.USER_TEXT
if camcheck.is_ring_short(result) and "moved them" in result.get("reason", ""):
return ("SteamVR moved the headset's tracking cameras after the camera broker started (the colour "
"camera module was unplugged or plugged in), so the recorder can't read the side cameras. Close "
"the Hand Recorder and open it again: it restarts the camera broker.")
if camcheck.is_ring_short(result):
return ("The headset's tracking cameras are all running, but the recorder can't read some of them (%s). "
"Close the Hand Recorder, %s, and open it again. If that doesn't help, ask in the Frametop "
"Discord." % (result.get("reason", ""), fix_hint("run %s again" % os.path.join(REPO, "hands", "rec", "install.sh"))))
return ("Not all of the headset's tracking cameras are running (%s). Restart SteamVR, or restart the "
"headset if that doesn't fix it." % result.get("reason", ""))
class Session:
"""One recording session. start() runs it in its own thread; next_step(), pause(), resume(),
redo(), skip() and stop() steer it from any thread. on_status(dict) is called from the
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/). button: read the
headset's button (not in a dry run unless button_device, a test hook, names the device or
a FIFO of input_event structs). check_cameras: run the camera check first and don't start
if it fails (not in a dry run, nor with ring). hands_reader (a test hook): an object whose
read() stands in for the hands file, read in a dry run too. quick: the quick round (a few
sections, about 3 min). seed: the sweeps' shuffle (default: from the session's id)."""
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,
auto=False, next_after=None, poses_dir=None, button=True, button_device=None,
check_cameras=True, hands_reader=None, quick=False, seed=None, demos_dir=None,
recorded_demos_dir=None, guidance=None, bounty=None):
self.base_dir = os.path.abspath(os.path.expanduser(base_dir or BASE_DIR))
self.profile = dict(profile or {})
self.checklist = dict(checklist or {})
self.lighting_choice = lighting_choice or ""
self.script_path = script_path or SCRIPT_PATH
self.ring = ring
self.start_processes = start_processes
self.dry_run = dry_run
self.speed = max(float(speed or 1.0), 0.01)
self.on_status = on_status
self.hands_dir = hands_dir or run_dir()
self.panel_bin = panel_bin or PANEL_BIN
self.auto = bool(auto)
self.next_after = next_after
self.button, self.button_device = bool(button), button_device
self.check_cameras = bool(check_cameras)
self.quick = bool(quick)
self.seed = seed # set from the session's id when it starts, unless given
# the camera check (a test hook: tests swap it); a dry run looks at no real cameras
self.camera_check_fn = (lambda: None) if dry_run else camera_check
self.repair_cameras_fn = (lambda: None) if dry_run else (lambda: camera_check(repair=True))
self._camera_losses = 0
# Frametop's drawn hands and hand pointer, paused for the session (a test hook: tests empty it)
self.quiet_units = () if dry_run or ring else QUIET_UNITS
self._rec_watch = None # (recorder, sets written, when that changed): _watch_recording
self.input_devices = INPUT_DEVICES # where mice are looked for (a test hook)
self._button = None # the ButtonReader
self.print = print # where dry-run panel commands go (the CLI's stdout)
self.script = load_script(self.script_path)
# A debug session (debug.json): one long free step with Frametop's own hand tracking on,
# its output copied beside the recording (livelog.py); the button marks what went wrong.
self.debug = bool(self.script.get("debug"))
if self.debug:
self.quiet_units = ()
self.consent_kind = takes.round_consent_kind(bool(bounty), self.debug) # DESIGN.md "Testing"
self.live_dir = self.hands_dir # where Frametop's ft-hands publishes (a test hook)
self._live_log = None # the LiveLog of the recording part running
self._event_log = None # ...and the EventLog: what Frametop did with the hands (fh_events.h)
# the person's voice beside it, if they ticked "Record my voice" (voicelog.py): what they
# say about what went wrong becomes flags later, transcribed on the maintainer's machines
# (not in a dry run: a rehearsal records nothing)
self.voice = self.debug and bool(self.checklist.get("voice")) and not dry_run
self._voice_log = None
self._marks = 0 # marks asked for (any thread); _marked: those logged
self._marked = 0
self.record_hz = max(1.0, min(30.0, float(self.script.get("record_hz", RECORD_HZ))))
# the board's entry this round is for (bounties.validate's, plus the board's "updated"), or None
self.bounty = dict(bounty) if bounty else None
self._scene_on = False # a scene's windows are up (_place_scene)
# the plan in the script's order; the session's own shuffle comes with its id (_make_dir)
self.plan, self.skipped = build_plan(self.script, self.checklist, seed=seed, quick=self.quick)
self._poses = load_poses(poses_dir or POSES_DIR)
self._demos_dir = demos_dir or DEMOS_DIR
self._recorded_demos_dir = recorded_demos_dir or RECORDED_DEMOS_DIR
self.guidance = guidance if guidance in dict(GUIDANCE) else default_guidance(self._recorded_demos_dir)
self.session_dir = ""
self._thread = None
self._lock = threading.Lock()
self._want = {"pause": False, "skip": False, "stop": False, "next": False, "redo": False, "end": False}
self._wake = threading.Event()
# state: starting, intro, ready (a step waits for Next), countdown, running (a hold),
# between, paused, done, stopped, error. waiting: Next is wanted. big: the countdown's
# 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": "",
"demo": "", "demo_mode": "", "demo_key": False, "demo_run": 0, "ready_text": READY_TEXT, "button": False, "mouse": True,
"camera": None, "nohands": False, "strip": [], "cue": -1, "quick": self.quick,
"debug": self.debug, "marks": 0}
self._last_emit = 0.0
self._log_file = None
self._panel = None
self._panel_proc = None
self._units = [] # transient units this session started
self._session_json = None
self._take = None # the take in progress: dict
self._recorder = None
self._prompt = None # the prompt shown: dict (hands, controllers)
self._live = None # the hands file's last read
self._hands_file = hands_reader or HandsFile(os.path.join(self.hands_dir, "hands"))
self._read_hands = not dry_run or hands_reader is not None
self._hold_watch = None # the no-hands check's counts during a hold
self._stop_note = "" # why the session stopped, when it says more than "stopped"
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._ring_path = None
self._sides_read = 0.0 # when the side camera decision was last read
# --- controls (any thread)
def start(self):
if self._thread:
raise RuntimeError("the session has started already")
self._thread = threading.Thread(target=self._run, name="handrec-session", daemon=True)
self._thread.start()
def _set(self, key, value):
with self._lock:
self._want[key] = value
self._wake.set()
def button_gesture(self, gesture):
"""The headset's button (ButtonGestures). A press: Next while a step waits, pause during
a countdown or hold (and auto mode's timed screens), resume while paused. A double press:
redo, as R. A hold: stop, as Esc. In a debug session's free step a press (or a double:
someone marking twice quickly) marks the moment and a hold ends the step (the session is
done, not stopped); a debug session has no redo, which would throw its recording away."""
if self.debug and self._status["state"] == "running" and gesture in ("press", "double", "hold"):
if gesture == "hold":
self.finish()
else:
self.mark()
self._log("headset button (%s)" % ("finish" if gesture == "hold" else "mark"))
return
if self.debug and gesture == "double":
return
if gesture == "press":
self.button_press()
elif gesture == "double":
if self._status["state"] in ("done", "stopped", "error") or not self._status.get("can_redo"):
return
self.redo()
self._log("headset button (redo)")
elif gesture == "hold":
if self._status["state"] in ("done", "stopped", "error"):
return
self._log("headset button (stop)")
self.stop(wait=0)
def button_press(self):
"""The headset button's press: Next while a step waits, pause during a countdown or hold
(and auto mode's timed screens), resume while paused."""
with self._lock:
paused = self._want["pause"]
state = self._status["state"]
if paused:
self.resume()
elif self._waiting:
self.next_step()
elif state in ("countdown", "running", "intro", "between"):
self.pause()
else:
return
self._log("headset button (%s)" % ("resume" if paused else "next" if self._waiting else "pause"))
def next_step(self):
"""Step mode: start the step that's waiting (its countdown)."""
self._set("next", True)
def mark(self):
"""A debug session: something went wrong just now (a "mark" event in the take)."""
with self._lock:
self._marks += 1
self._wake.set()
def finish(self):
"""A debug session: end the free step there; the session is done."""
self._set("end", True)
def redo(self):
"""Record a step again: the one running, or at a step's ready screen the one before. Not in
a debug session: its one step is the whole recording."""
if self.debug:
return
self._set("redo", True)
def pause(self):
self._set("pause", True)
def resume(self):
self._set("pause", False)
def skip(self):
self._set("skip", True)
def stop(self, wait=20.0):
"""Stop. The take in progress is kept as far as it got. Blocks until the session has
written its files (up to wait seconds), unless called from the session thread."""
self._set("stop", True)
if self._thread and threading.current_thread() is not self._thread and wait:
self._thread.join(wait)
def join(self, timeout=None):
if self._thread:
self._thread.join(timeout)
@property
def state(self):
return self._status["state"]
# --- status and logging
def _emit(self, force=True, **changes):
self._status.update(changes)
now = time.monotonic()
if not force and now - self._last_emit < 0.25:
return
self._last_emit = now
if self.on_status:
try:
self.on_status(dict(self._status, hands=dict(self._status["hands"])))
except Exception:
self._log("on_status: " + traceback.format_exc())
def _log(self, text):
if self._log_file:
self._log_file.write("%s %s\n" % (time.strftime("%H:%M:%S"), text))
self._log_file.flush()
def _event(self, event, **fields):
"""A line in the take's prompts.jsonl; returns its time."""
t = mono_ns()
if not self._take:
return t
line = {"t": t, "event": event}
line.update(fields)
self._take["prompts"].write(json.dumps(line) + "\n")
self._take["prompts"].flush()
return t
# --- the run
def _run(self):
try:
self._resume_quiet() # left paused by a session that crashed
if self.debug and not self.dry_run:
eventlog.stop_writers(self.live_dir) # ...or asking for events
if self._preflight():
return
self._make_dir()
self._emit(state="starting")
self._setup()
self._screen("starting", self.script.get("welcome") or {})
for i, s in enumerate(self.plan):
self._section(i, s)
self._finish("done")
except _Stop:
self._finish("stopped")
except _Fail as e:
self._finish("error", str(e))
except Exception as e:
self._log(traceback.format_exc())
self._finish("error", "%s: %s" % (type(e).__name__, e))
def _preflight(self):
"""The camera check, before anything is made or started (camcheck.py): with the upper
cameras off a session records nothing useful. True if it stopped the session (state
"error", no session folder)."""
if self.debug and not (self.dry_run or self.ring) and not unit_active(LIVE_HANDS_UNIT):
self._emit(state="error", error=DEBUG_NEEDS_HANDS, prompt=DEBUG_NEEDS_HANDS)
return True
if self.dry_run or self.ring or not self.check_cameras:
return False
r = self.camera_check_fn()
if camcheck.is_ring_short(r): # an ft-camd left on moved cameras: nothing records yet, so restart it
r = self.repair_cameras_fn() or r
self._status["camera"] = r
text = camera_text(r)
if not text:
return False
self._emit(state="error", error=text, prompt=text, camera=r)
return True
def _make_dir(self):
sessions = os.path.join(self.base_dir, "sessions")
os.makedirs(sessions, exist_ok=True)
sid = time.strftime("%Y%m%d-%H%M%S")
path, n = os.path.join(sessions, sid), 1
while True:
try:
os.mkdir(path)
break
except FileExistsError:
n += 1
path = os.path.join(sessions, "%s-%d" % (sid, n))
os.mkdir(os.path.join(path, "takes"))
self.session_dir = path
if self.seed is None:
self.seed = session_seed(os.path.basename(path))
# the same sections and steps, the sweeps shuffled for this session
self.plan, self.skipped = build_plan(self.script, self.checklist, seed=self.seed, quick=self.quick)
self._log_file = open(os.path.join(path, "session.log"), "a", buffering=1)
self._log("session %s%s, script %s" % (os.path.basename(path), " (dry run)" if self.dry_run else "",
self.script_path))
for s in self.skipped:
self._log("skipping %s: %s" % (s["section"], s["reason"]))
cam = self._status.get("camera")
if cam:
self._log("camera check: %s" % cam.get("summary", cam.get("status")))
for line in cam.get("evidence", []):
self._log(" " + line)
elif not self.check_cameras and not self.dry_run:
self._log("camera check skipped (--ignore-cameras)")
def _setup(self):
ring_path = self.ring or os.path.join(self.hands_dir, "cam-ring")
self._ring_path = ring_path
if not self.dry_run:
if not os.access(FT_HANDS, os.X_OK):
raise _Fail("ft-hands isn't built: " + fix_hint("hands/build.sh"))
self._ensure_ring(ring_path)
if not tracker_running():
if self.start_processes:
self._start_tracker()
else:
self._log("no tracking ft-hands found: feedback only if one publishes")
self._quiet_hands()
lighting = ring_lighting(ring_path)
cams = []
try:
ring = Ring(ring_path)
cams = [{"name": c["name"], "width": c["width"], "height": c["height"]}
for c in ring.cams if not c["flags"] & FH_CAM_DARK and not c["name"].endswith("_dk")]
ring.close()
except (OSError, ValueError):
pass
removed = self._write_calibration() + self._write_device()
self._session_json = {
"schema": 1, "tool": takes.tool_version(), "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"contributor": self.profile.get("contributor", ""),
"lighting": lighting_record(self.lighting_choice, lighting),
"checklist": self.checklist,
"device": {"steamos": _os_version(), "steamvr": _steamvr_version(), "cameras": cams},
"calibration_removed": removed,
"script": {"version": self.script.get("version"), "name": self.script.get("name", ""),
"sections": [s["id"] for s in self.plan],
"skipped": self.skipped},
"mode": "auto" if self.auto else "step", "quick": self.quick, "guidance": self.guidance,
"shuffle": {"seed": self.seed, "sweeps": plan_record(self.plan)},
"record_hz": self.record_hz,
"takes": [], "status": "recording", "sides": {"swapped": None},
"consent": {"kind": self.consent_kind,
"version": (self.profile.get(takes.CONSENT_KEYS[self.consent_kind]) or {}).get("version", "")}}
if self.bounty:
self._session_json["bounty"] = {"id": self.bounty["id"], "title": self.bounty.get("title", ""),
"board_updated": self.bounty.get("updated")}
self._read_sides(force=True)
cam = self._status.get("camera")
if cam:
self._session_json["camera"] = {"status": cam.get("status"), "reason": cam.get("reason", "")}
# which device each calibrated camera was (XRService's numbering) and whether XRService
# ran the side cameras through the ISP (no colour module): to check the names later
self._session_json["device"]["camera_map"] = cam.get("map") or {}
self._session_json["device"]["isp"] = (cam.get("episode") or {}).get("isp")
if self.debug:
# testing only, never training (the debug dataset's terms); what the live tracker was
self._session_json["debug"] = {"use": "testing", "max_s": max(section_seconds(s) for s in self.plan),
"settings": live_settings(),
**(live_tracker() if not self.dry_run else {"frametop": "", "models": {}})}
if self.dry_run:
self._session_json["dry_run"] = True
if self.speed != 1:
self._session_json["speed"] = self.speed
self._save_session()
self._panel = Panel(dry=self.dry_run, log=self._log, out=self.print)
if not self.dry_run:
self._start_panel()
self._start_button()
self._panel.cmd("show")
self._panel.cmd("paused off")
for key, c in (("note", "note "), ("countdown", "countdown off"), ("hands", "hands off off"),
("bar", "bar off"), ("target", "target off"), ("image", "image off"), ("where", "where off"),
("demo", "demo off"), ("big", "big "), ("action", "action "), ("rec", "rec off"), ("strip", "strip off"), ("split", "split off")):
self._panel.set(key, c)
self._hints(action=False)
def _start_button(self):
"""The headset button's reader, unless turned off (a dry run has none unless a test
device is given). Without the device the session goes on: the window's keys work."""
if not self.button or (self.dry_run and not self.button_device):
return
path = self.button_device or find_button(read_input_devices())
if not path:
self._log("headset button: no %s device with KEY_SELECT in %s" % (BUTTON_DEVICE, INPUT_DEVICES))
return
self._button = ButtonReader(path, self.button_gesture, log=self._log, double_s=BUTTON_DOUBLE_S,
hold_s=BUTTON_HOLD_S, bounce_s=BUTTON_BOUNCE_S).start()
end = time.monotonic() + 0.5 # opened in a moment, or it isn't reachable
while not self._button.ok and time.monotonic() < end:
time.sleep(0.01)
def _write_calibration(self):
src = host_path("/persist/xrservice.json")
if not src:
self._log("no /persist/xrservice.json: no calibration.json")
return []
with open(src) as f:
clean, removed = strip_calibration(json.load(f))
write_json(os.path.join(self.session_dir, "calibration.json"), clean)
return removed
def _write_device(self):
"""device.json: the rig's pose in the CAD frame from /persist/device_config.json, only
cv.cad_from_cal (Cam0 in CAD) and head (the head in CAD), in the shape the labeller reads
(frame-hands train/label, as its cut.py writes it). The rest of that file names the unit
(serial number, EDID). Returns what was removed, as "device.json:<path>"."""
src = host_path("/persist/device_config.json")
if not src:
self._log("no /persist/device_config.json: no device.json")
return []
try:
with open(src) as f:
dev = json.load(f)
picked = {"cv": {"cad_from_cal": dev["cv"]["cad_from_cal"]}, "head": dev["head"]}
except (OSError, ValueError, KeyError, TypeError) as e:
self._log("device_config.json unreadable (%s): no device.json" % e)
return []
clean, removed = strip_calibration(picked)
write_json(os.path.join(self.session_dir, "device.json"), clean)
return ["device.json:" + r for r in removed]
def _save_session(self):
if self._session_json is not None:
write_json(os.path.join(self.session_dir, "session.json"), self._session_json)
def _read_sides(self, force=False):
"""The tracking ft-hands' side camera decision (sides.py, read_live) into session.json's
"sides": {"swapped", "decided_by", "state", "evidence", "decided_at"}. Later recording
parts are named right (Recorder's swap); parts before it are renamed when read. Without
a tracking ft-hands it stays undecided ("swapped": null): export then leaves the names,
and the maintainer's check (hub_review check) tells."""
now = time.monotonic()
if self._session_json is None or (not force and now - self._sides_read < SIDES_READ_S):
return
self._sides_read = now
live = sides.read_live(os.path.join(self.hands_dir, "sides.json"), self._ring_path)
if not live or live.get("swapped") is None:
return
cur = self._session_json.get("sides") or {}
swapped = bool(live["swapped"])
if cur.get("swapped") is not None and bool(cur["swapped"]) == swapped:
if live.get("state") != cur.get("state"): # e.g. decided -> confirmed
cur["state"] = live.get("state")
self._save_session()
return
new = {"swapped": swapped, "decided_by": live.get("decided_by"), "state": live.get("state"),
"evidence": live.get("evidence"), "decided_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"decided_ns": mono_ns()}
if cur.get("swapped") is not None:
new["reversed_from"] = cur
self._session_json["sides"] = new
self._save_session()
self._log("side cameras: %s (%s, %s)" % ("SWAPPED" if swapped else "as named", new["decided_by"],
json.dumps(new["evidence"])))
if new["state"] == "forced, disagrees":
self._log("side cameras: HANDS_SWAP_SIDES in ~/.config/frametop.conf forces names the hands say are "
"backwards; the recording goes by the hands. Set HANDS_SWAP_SIDES=auto.")
def _sides_swapped(self):
"""session.json's decision: True, False, or None (not known yet)."""
return (self._session_json or {}).get("sides", {}).get("swapped")
def _ensure_ring(self, path):
if ring_alive(path):
return
if self.ring:
raise _Fail("No frames in %s (start ft-ringplay first)" % path)
if not self.start_processes:
raise _Fail("ft-camd isn't running (and --no-start)")
try:
if start_camd(path, self._log, lambda: self._want["stop"]):
self._units.append(CAMD_UNIT)
except RuntimeError as e:
raise _Fail(str(e))
if self._want["stop"]:
raise _Stop()
def _start_tracker(self):
argv = [FT_HANDS, "--no-gestures", "--status", "0"]
if not STANDALONE: # built in Frametop's container: it runs there
argv = [IN_BOX] + argv
if self.ring:
argv += ["--ring", self.ring]
self._start_unit(HANDS_UNIT, "hand tracking for feedback", argv)
def _start_unit(self, unit, what, argv):
try:
if start_unit(unit, what, argv, self._log):
self._units.append(unit)
except RuntimeError as e:
raise _Fail(str(e))
def _quiet_hands(self):
"""Frametop's drawn hands and hand pointer off until the session ends: the hand overlay
covers what the person watches, and gestures would click or open Steam's menu."""
if self.quiet_units:
pause_units(self.quiet_units, os.path.join(self.hands_dir, PAUSED_UNITS_FILE), self._log)
def _resume_quiet(self):
if self.quiet_units:
resume_units(os.path.join(self.hands_dir, PAUSED_UNITS_FILE), self._log)
def _stop_units(self):
for unit in reversed(self._units):
stop_unit(unit)
self._log("stopped %s" % unit)
self._units = []
def _start_panel(self):
if self._panel.cmd("ping", reply=True):
self._log("using the ft-handpanel that's running")
return
if not os.access(self.panel_bin, os.X_OK):
raise _Fail("ft-handpanel isn't built: " + fix_hint("hands/rec/build.sh"))
self._panel_proc = subprocess.Popen([self.panel_bin, "--watch-stdin"], stdin=subprocess.PIPE,
stdout=self._log_file, stderr=self._log_file, env=openvr_env())
end = time.monotonic() + 15
while time.monotonic() < end:
if self._panel.cmd("ping", reply=True, timeout=0.2):
return
if self._panel_proc.poll() is not None:
raise _Fail("ft-handpanel exited (is SteamVR running?): see session.log")
time.sleep(0.1)
raise _Fail("ft-handpanel doesn't answer")
def _stop_panel(self):
if self._panel:
self._panel.cmd("hide")
if self._panel_proc:
try:
self._panel_proc.stdin.close()
self._panel_proc.wait(3)
except (OSError, subprocess.TimeoutExpired):
self._panel_proc.terminate()
try:
self._panel_proc.wait(3)
except subprocess.TimeoutExpired:
self._panel_proc.kill()
self._panel_proc = None
if self._panel:
self._panel.close()
def _finish(self, state, error=""):
if self._take:
self._end_take({"done": "complete", "stopped": "stopped"}.get(state, "stopped"))
if self._session_json is not None:
self._session_json["status"] = state
self._session_json["ended"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
if error:
self._session_json["error"] = error
if self._stop_note and state == "stopped":
self._session_json["stop_reason"] = self._stop_note
self._save_session()
if state == "done" and self.bounty and not self.dry_run:
self._mark_done()
self._log("%s%s" % (state, ": " + error if error else ""))
if self._stop_note and state == "stopped":
self._log(self._stop_note)
self._stop_units()
self._resume_quiet()
if self._button:
self._button.stop()
self._button = None
if self._panel:
screen = {"done": self.script.get("done") or {}, "stopped": self.script.get("stopped") or {},
"error": {"title": "Something went wrong", "text": "See the Hand recorder window.",
"seconds": 4}}[state]
try:
self._panel.cmd("paused off")
for key, c in (("big", "big "), ("action", "action "), ("rec", "rec off"), ("keys", "keys "),
("bar", "bar off"), ("target", "target off"), ("strip", "strip off")):
self._panel.set(key, c)
self._clear_scene()
self._screen(state, screen, controls=False)
except (_Stop, _Skip, _Fail):
pass
self._stop_panel()
if self._log_file:
self._log_file.close()
self._log_file = None
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="",
prompt=error or (self._stop_note if state == "stopped" else "") or screen.get("text", "").replace("|", "\n"),
hands={"left": None, "right": None}, nohands=False,
waiting=False, countdown=0, big="", can_redo=False, image="", image_mode="", caption="",
position="", distance="", strip=[], cue=-1, demo="", demo_mode="", demo_key=False)
def _mark_done(self):
"""The bounty counted in profile.json, read again first (the window may have changed it)."""
path = os.path.join(self.base_dir, "profile.json")
try:
with open(path) as f:
profile = json.load(f)
except (OSError, ValueError):
return # no consent on file: nothing to count it in
bounties.mark_done(profile, self.bounty["id"], os.path.basename(self.session_dir))
write_json(path, profile)
self.profile = profile
# --- the timing loop
def _controls(self):
"""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:
want = dict(self._want)
self._want["skip"] = self._want["redo"] = False
if want["stop"]:
raise _Stop()
if want["skip"]:
raise _Skip()
if want["redo"] and self._redo_ok:
raise _Redo()
if not want["pause"]:
return False
self._pause()
outcome = None
while outcome is None:
self._wake.wait(0.2)
self._wake.clear()
with self._lock:
want = dict(self._want)
self._want["skip"] = self._want["redo"] = False
redo = want["redo"] and self._redo_ok
if want["skip"] or redo:
self._want["pause"] = False
if want["stop"]:
outcome = _Stop
elif want["skip"]:
outcome = _Skip
elif redo:
outcome = _Redo
elif not want["pause"]:
outcome = True
self._unpause(record=outcome is True)
if outcome is not True:
raise outcome()
return True
def _pause(self):
self._paused = True
self._state_before = self._status["state"]
self._paused_recording = self._recording
if self._recording:
self._stop_recording()
self._event("pause")
hint = RESUME_HINT_BUTTON if self._button_ok() else RESUME_HINT
self._panel.cmd("paused on")
self._panel.set("note", "note " + hint)
self._emit(state="paused", note=hint)
self._log("paused")
def _unpause(self, record=True):
self._paused = False
self._panel.cmd("paused off")
self._panel.set("note", "note ")
self._fb.clear()
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._start_recording()
self._emit(state=self._state_before, note="")
self._log("resumed")
def _wait(self, seconds, tick=None, countdown=True):
"""Let `seconds` (script time) pass, handling the controls and feedback. tick(dt) runs
every step with the script time passed; it ends the wait early by returning True.
Returns True if tick ended it, else False."""
left = float(seconds)
last = time.monotonic()
last_cd = -1.0
while True:
if self._controls():
last = time.monotonic()
now = time.monotonic()
dt = (now - last) * self.speed
last = now
left -= dt
self._feedback()
if tick and tick(dt):
return True
if countdown and seconds > 0 and (now - last_cd >= 0.25 or left <= 0):
last_cd = now
self._panel.set("countdown", "countdown %.2f" % max(0.0, min(1.0, left / seconds)))
self._emit(force=False, seconds_left=round(max(left, 0.0), 1))
if left <= 0:
return False
self._wake.wait(TICK_S)
self._wake.clear()
# --- feedback
def _feedback(self):
now = time.monotonic()
fb = self._fb
if self._panel_proc and self._panel_proc.poll() is not None:
raise _Fail("The headset panel closed (did SteamVR quit?)")
if self._recorder and self._recorder.exited():
raise _CamerasLost("The recording stopped by itself: see session.log")
self._watch_recording(now)
if not self.dry_run and now - fb.get("disk", 0) > 5:
fb["disk"] = now
if shutil.disk_usage(self.session_dir).free < MIN_FREE:
raise _Fail("The disk is nearly full: the session stopped")
if not self.dry_run:
self._read_sides()
live = self._hands_file.read() if self._read_hands else None
self._live = live
w = self._hold_watch
if w is not None:
w["reads"] += 1
if live is not None:
w["published"] += 1
if live["left"] or live["right"]:
w["seen"] += 1
p = self._prompt or {}
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")}
# the chips: the asked-for hands, seen or lost, while the tracker publishes
chips = []
for side in ("left", "right"):
show = live is not None and (asked in (side, "both", "any"))
chips.append(("seen" if seen[side] else "lost") if show else "off")
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
notes = []
if live is not None and asked and not self._waiting:
if asked == "none":
missing = [] if not (seen["left"] or seen["right"]) else ["shown"]
elif asked == "any":
missing = [] if (seen["left"] or seen["right"]) else ["any"]
else:
missing = [s for s in ("left", "right") if asked in (s, "both") and not seen[s]]
key = ",".join(missing)
if key != fb.get("lost_key"):
fb["lost_key"], fb["lost_since"] = key, now
if missing and now - fb["lost_since"] > LOST_S:
if missing == ["shown"]:
notes.append("I can see a hand: keep them out of view")
elif missing == ["any"] or len(missing) == 2:
notes.append("I can't see your hands: bring them into view")
else:
notes.append("I can't see your %s hand: bring it into view" % missing[0])
else:
fb.pop("lost_key", None)
notes = ([] if self._waiting else self._controller_feedback(now, p)) + notes
note = notes[0] if notes else ""
if not self._paused:
self._panel.set("note", "note " + note)
hands = {"left": seen["left"], "right": seen["right"]}
if note != self._status["note"] or hands != self._status["hands"]:
self._emit(note=note, hands=hands)
if self._take and self._recording and live is not None and now - fb.get("logged", 0) >= 0.5:
fb["logged"] = now
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")])
def _controller_feedback(self, now, p):
"""Sections with controllers: `devices` once a second; a result other than 200 for
more than a second gets a note, and each change goes into prompts.jsonl."""
sides = p.get("controllers") or []
fb = self._fb
if not sides:
fb.pop("ctl", None)
return []
ctl = fb.setdefault("ctl", {"polled": 0, "r": {}, "bad_since": {}, "lost": []})
if now - ctl["polled"] >= 1.0:
ctl["polled"] = now
reply = self._panel.cmd("devices", reply=True, timeout=0.3) or ""
tok = reply.split()
vals = dict(zip(tok[1::2], tok[2::2]))
if tok[:1] == ["ok"] and "hmd" in vals:
ctl["r"] = {s: vals.get(s, "-") for s in ("left", "right")}
for s in sides:
r = ctl["r"].get(s)
if r is None or r == "200":
ctl["bad_since"].pop(s, None)
else:
ctl["bad_since"].setdefault(s, now)
lost = sorted(s for s, t in ctl["bad_since"].items() if s in sides and now - t > CONTROLLER_LOST_S)
if lost != ctl["lost"]:
ctl["lost"] = lost
self._event("feedback", controller={s: (None if ctl["r"].get(s) in (None, "-") else int(ctl["r"][s]))
for s in ("left", "right")}, controller_lost=lost)
notes = []
for s in lost:
if ctl["r"].get(s) == "-":
notes.append("I can't find the %s controller: is it on?" % s)
else:
notes.append("The %s controller lost tracking: turn your palm slightly toward you" % s)
return notes
# --- sections and takes
def _show(self, title=None, step=None, text=None):
if title is not None:
self._panel.set("title", "title " + clean_text(title))
if step is not None:
self._panel.set("step", "step " + clean_text(step))
if text is not None:
self._panel.set("text", "text " + clean_text(text))
def _view(self, p=None):
"""The prompt's picture or demo clip and where-to diagram, on the panel and in the status
(sent with the next _emit); none without a prompt. A demo clip takes the picture's place."""
p = p or {}
demo, demo_mode, demo_key = guided_demo_view(self.guidance, self._demos_dir, self._recorded_demos_dir, p)
# a scene's act names its gesture in "pose": the windows show what to do, not a picture
path, mode, caption = ("", "", "") if p.get("cues") or p.get("scene") or demo else pose_view(self._poses, p)
self._panel.set("demo", "demo %s%s%s" % (demo, " " + demo_mode if demo_mode else "", "" if demo_key else " nokey")
if demo else "demo off")
self._status.update(demo=demo, demo_mode=demo_mode, demo_key=demo_key)
self._strip(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 _cue_name(self, pose):
return clean_text((self.script.get("cue_names") or {}).get(pose) or pose.replace("-", " ").capitalize())
def _strip(self, p):
"""A sweep's strip of pictures, the cue's highlighted (panel "strip", status strip and
cue), or none. One hand per picture, as both hands make the same shape: a left hand's
flipped, the rest as drawn."""
poses = list(dict.fromkeys(p.get("cues") or [])) # each once, in the step's order
if not poses:
self._panel.set("strip", "strip off")
self._status.update(strip=[], cue=-1)
return
items = []
for pose in poses:
path, mode, _ = pose_view(self._poses, {"pose": pose, "hands": p.get("hands")})
items.append({"image": path, "mode": "" if mode == "both" else mode, "label": self._cue_name(pose)})
cue = poses.index(p["pose"]) if p.get("pose") in poses else -1
self._panel.set("strip", "strip %d %s" % (cue, ";".join(
"%s|%s|%s" % (e["image"] or "-", e["mode"] or "-", e["label"].replace("|", "/").replace(";", ","))
for e in items)))
self._status.update(strip=items, cue=cue)
def _button_ok(self):
return bool(self._button and self._button.ok)
def _hints(self, action=True):
"""The Next hint (with action) and the key line for what's there now: the headset button
leads when no mouse is connected. Looked at again for each step, so a mouse plugged in counts."""
button = self._button_ok()
mouse = mouse_connected(self.input_devices)
text = (READY_BUTTON_MOUSE if mouse else READY_BUTTON) if button else READY_TEXT
keys = (KEYS_AUTO_BUTTON if self.auto else KEYS_STEP_BUTTON) if button else (KEYS_AUTO if self.auto else KEYS_STEP)
if action:
self._panel.set("action", "action " + text)
self._panel.set("keys", "keys " + keys)
self._status.update(ready_text=text, button=button, mouse=mouse)
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._hints()
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):
"""A screen of its own (welcome, done): title, text, a few seconds, or in step mode
until Next. controls=False: the session's end, which reports its state once all is done."""
if not screen:
return
self._prompt = None
self._show(screen.get("title", ""), "", screen.get("text", ""))
self._view()
self._panel.set("countdown", "countdown off")
if controls:
self._emit(state=state, title=screen.get("title", ""), prompt=screen.get("text", "").replace("|", "\n"),
section="", seconds_left=float(screen.get("seconds", 0)))
try:
if self.auto:
self._wait(screen.get("seconds", 0), countdown=False)
else:
self._await_next()
except _Skip:
pass
else:
end = time.monotonic() + screen.get("seconds", 0) / self.speed
while time.monotonic() < end and not self._want["stop"]:
time.sleep(TICK_S)
def _section(self, i, s):
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),
"step_index": 0, "step_count": section_steps(s) if not self.auto else 0, "can_redo": False}
try:
before = s.get("before")
self._prompt = None
self._view()
if self.auto:
if before or i > 0:
text = before["text"] if before else "Next: %s" % s["title"]
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._emit(state="between", prompt=text.replace("|", "\n"), take=None, **status)
self._wait(secs)
self._start_take(i, s)
self._status.update(state="intro", take=self._take["id"], **status) # sent with the intro
self._run_prompt(s, {"id": s["id"] + "/intro", "text": s.get("intro", ""), "seconds": s["intro_s"],
"hands": "", "pose": "", "distance": "", "position": "", "object": "",
"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._steps(i, s, step)
if self._take:
self._end_take("complete")
except _Skip:
self._log("skipped %s" % s["id"])
if self._take:
self._end_take("skipped")
except _Stop:
raise
finally:
self._redo_ok = False
self._status["big"] = ""
for key, c in (("bar", "bar off"), ("target", "target off"), ("big", "big "), ("action", "action ")):
self._panel.set(key, c)
self._clear_scene()
def _start_take(self, i, s, record=True):
n = len(self._session_json["takes"]) + 1
take_id = "%02d-%s" % (n, s["id"])
d = os.path.join(self.session_dir, "takes", take_id)
os.makedirs(d)
self._take = {"id": take_id, "dir": d, "section": s, "part": 0,
"prompts": open(os.path.join(d, "prompts.jsonl"), "a", buffering=1),
"json": {"section": s["id"], "title": s["title"], "started_ns": mono_ns(), "ended_ns": None,
"status": "stopped", "deleted": [], "notes": ""}}
write_json(os.path.join(d, "take.json"), self._take["json"])
self._session_json["takes"].append(take_id)
self._save_session()
self._event("take", section=s["id"], take=take_id)
if record:
self._start_recording()
self._log("take %s" % take_id)
self._emit(force=False, take=take_id)
def _start_recording(self):
t = self._take
t["part"] += 1
t["json"].setdefault("clock", []).append(clock_sample())
if not self.dry_run:
# a safety net only: the session ends the recording itself
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,
swap=self._sides_swapped(), hz=self.record_hz)
if self.debug:
name = "live.bin" if t["part"] == 1 else "live-%d.bin" % t["part"]
self._live_log = livelog.LiveLog(self.live_dir, os.path.join(t["dir"], name)).start()
self._live_log.name = name
name = "events.jsonl" if t["part"] == 1 else "events-%d.jsonl" % t["part"]
self._event_log = eventlog.EventLog(self.live_dir, os.path.join(t["dir"], name)).start()
self._event_log.name = name
if self.voice:
name = "voice.bin" if t["part"] == 1 else "voice-%d.bin" % t["part"]
self._voice_log = voicelog.VoiceLog(os.path.join(t["dir"], name)).start()
self._voice_log.name = name
if self._voice_log.error:
self._log("voice: %s" % self._voice_log.error)
self._recording = True
self._panel.cmd("poses start " + os.path.join(t["dir"], "poses.jsonl"))
self._panel.set("rec", "rec on")
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)
if self._live_log:
log, self._live_log = self._live_log, None
counts = log.stop()
self._log("live log %s: %d hands, %d gestures" % (log.name, counts["hands"], counts["gestures"]))
if self._take:
self._take["json"].setdefault("live", {})[log.name] = counts
write_json(os.path.join(self._take["dir"], "take.json"), self._take["json"])
if self._event_log:
log, self._event_log = self._event_log, None
counts = log.stop()
self._log("events %s: %d (%s)" % (log.name, counts["events"], eventlog.describe(counts["by"])))
if self._take:
self._take["json"].setdefault("events", {})[log.name] = counts
write_json(os.path.join(self._take["dir"], "take.json"), self._take["json"])
if self._voice_log:
log, self._voice_log = self._voice_log, None
counts = log.stop()
self._log("voice %s: %.1f s, peak %.3f%s" % (log.name, counts["seconds"], counts["peak"],
", " + counts["error"] if counts["error"] else ""))
if self._take:
self._take["json"].setdefault("voice", {})[log.name] = counts
write_json(os.path.join(self._take["dir"], "take.json"), self._take["json"])
if self._recorder:
rec, self._recorder = self._recorder, None
code = rec.stop()
self._log("recording part %d ended (%s)" % (rec.part, code))
if self._take: # how this part's side cameras are named (sides.py)
self._take["json"].setdefault("parts", {})[rec.file] = {"names_swapped": rec.names_swapped}
self._take["json"].setdefault("clock", []).append(clock_sample())
write_json(os.path.join(self._take["dir"], "take.json"), self._take["json"])
def _end_take(self, status):
t = self._take
try:
self._stop_recording()
finally:
self._event("end", status=status)
self._take = None
t["prompts"].close()
t["json"]["ended_ns"] = mono_ns()
t["json"]["status"] = status
write_json(os.path.join(t["dir"], "take.json"), t["json"])
self._log("take %s %s" % (t["id"], status))
def _prompt_event(self, p):
extra = {"controllers": p["controllers"]} if p["controllers"] else {}
if p.get("demo"):
extra["demo"] = p["demo"]
if p.get("cue"): # a sweep's cue: the pose highlighted from now, within the step
extra.update(cue=True, step=p["step"])
if p.get("act"): # a scene's act: what's lit (and the ghost's pose in the room) from now
extra["act"] = p["act"]
if p.get("switch") and not p.get("cue"): # the step's pose changes, each logged as a "switch" when it comes
extra["switch"] = p["switch"]
return self._event("prompt", id=p["id"], text=p["text"], hands=p["hands"], pose=p["pose"],
distance=p["distance"], position=p["position"], object=p["object"],
controller=bool(p["controller"]), **extra)
def _begin_prompt(self, s, p, step, state="running"):
self._prompt = p
self._fb.pop("lost_key", None)
self._show(s["title"], step, p["text"])
self._view(p)
if state == "running" and self._status["demo"]:
# The hold starts: the clip from its start, so it can be followed (the window's too:
# demo_run counts the starts).
self._panel.cmd("demo restart")
self._status["demo_run"] += 1
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"])
def _run_prompt(self, s, p, step, intro=False):
if intro and not p["text"]:
return
self._begin_prompt(s, p, step, state="intro" if intro else "running")
if self.debug and not intro:
self._free(p)
return
self._wait(p["seconds"], None if intro else self._switches(p))
def _free(self, p):
"""A debug session's free step: the panel out of the way while the person uses Frametop
as usual, up to the step's seconds. The button's press is a "mark" (the panel flashes
"Marked"), its hold (or finish()) ends the step early, with an "end_early" event."""
with self._lock:
self._want["end"] = False
self._marks = self._marked
self._panel.cmd("hide")
flash = [0.0]
def tick(dt):
with self._lock:
marks, end = self._marks, self._want["end"]
while self._marked < marks:
self._marked += 1
self._event("mark", id=p["id"], n=self._marked)
self._log(" mark %d" % self._marked)
self._panel.set("big", "big Marked")
self._panel.cmd("show")
flash[0] = MARK_FLASH_S
self._emit(marks=self._marked)
if flash[0] > 0:
flash[0] -= dt / self.speed
if flash[0] <= 0:
self._panel.cmd("hide")
self._panel.set("big", "big ")
if end:
self._event("end_early", id=p["id"])
self._log(" finished early")
return end
try:
self._wait(p["seconds"], tick)
finally:
with self._lock:
self._want["end"] = False
self._panel.set("big", "big ")
self._panel.cmd("show")
def _switches(self, p, tick=None):
"""A step's pose changes: the panel's ticks on the time-left bar ("Switch to ..."), and
a "switch" event as each one's time comes, so the labels say which pose when. Returns
the tick for _wait (tick, if given, runs after)."""
sw, seconds = p.get("switch") or [], float(p["seconds"])
if not sw or seconds <= 0:
self._panel.set("split", "split off")
return tick
self._panel.set("split", "split " + ";".join("%.3f %s" % (1 - w["at"], clean_text(w["to"]).replace(";", ","))
for w in sw))
state = {"t": 0.0, "k": 0}
def both(dt):
state["t"] += dt
while state["k"] < len(sw) and state["t"] >= sw[state["k"]]["at"] * seconds - 1e-9:
w = sw[state["k"]]
state["k"] += 1
self._event("switch", id=p["id"], to=w["to"], k=state["k"])
self._log(" switch to %s" % w["to"])
if w["text"] and not p.get("scene"):
self._panel.set("text", "text " + clean_text(w["text"]))
self._status["prompt"] = w["text"].replace("|", "\n")
self._emit()
return tick(dt) if tick else False
return both
# --- steps: a section's prompts, bar heights and targets
def _step_list(self, s):
"""[{"kind": "prompt"|"sweep"|"bar"|"target", "p": prompt, "ready": waits for Next in step mode, ...}]."""
out = []
defaults = s.get("defaults") or {}
if s["kind"] == "targets":
for k, pt in enumerate(s["targets"]):
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": "",
"position": "", "object": "", "controller": False, "controllers": []}
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": "scene" if p.get("scene") else "sweep" if p.get("cues") else "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
retry = False # after the no-hands stop: Next was pressed there, go straight to the countdown
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))
if not retry:
self._await_next()
retry = False
self._redo_ok = True
started = self._countdown(i, s, st)
else:
retry = False
self._redo_ok = True
self._emit(force=False, can_redo=True)
check = self._read_hands and s["id"] == HANDS_CHECK_SECTION and k == 0 and st["kind"] in ("prompt", "sweep")
self._hold_watch = {"reads": 0, "published": 0, "seen": 0} if check else None
try:
self._run_step(s, st, where)
finally:
watch, self._hold_watch = self._hold_watch, None
if check and self._no_hands_seen(p, watch):
self._no_hands(s, st, where, started if started is not None else self._step_t0, watch)
again.add(k)
retry = True
continue
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 _CamerasLost as e:
if self.auto:
raise
from_ns = started if started is not None else self._step_t0
self._hold_end()
if from_ns is not None:
self._event("redo", id=p["id"], **{"from": from_ns, "to": mono_ns()})
self._cameras_back(p, str(e), where)
again.add(k)
retry = False
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 _watch_recording(self, now):
"""A recording that has started writing sets but writes no new one for STALL_S is lost
(_CamerasLost): the cameras stopped or moved, and the rest of the hold would be empty."""
rec = self._recorder
if rec is None or self.dry_run or not hasattr(rec, "sets_written"):
self._rec_watch = None
return
n = rec.sets_written()
w = self._rec_watch
if w is None or w[0] is not rec or n != w[1]:
self._rec_watch = (rec, n, now)
elif n > 0 and now - w[2] > STALL_S:
self._rec_watch = None
raise _CamerasLost("The recording got no new frames for %.0f s (after %d sets)" % (now - w[2], n))
def _cameras_back(self, p, why, where):
"""A step's recording was lost (_CamerasLost): its try is marked redone. Wait for the
camera check to pass, restarting a camera broker left on moved cameras (camera_check's
repair), then the step shows again. _Fail if they don't come back, or too often."""
self._camera_losses += 1
self._log(" %s: %s (%d this session); waiting for the cameras" % (p["id"], why, self._camera_losses))
if self._camera_losses > MAX_CAMERA_LOSSES:
raise _Fail("The recording kept stopping (%d times): %s" % (self._camera_losses, why))
self._prompt = None
self._view()
self._show(CAMERAS_TITLE, where, CAMERAS_TEXT)
for key, c in (("big", "big "), ("countdown", "countdown off"), ("note", "note ")):
self._panel.set(key, c)
self._emit(state="between", prompt=CAMERAS_TEXT, big="", countdown=0, seconds_left=0.0, can_redo=False)
if self.ring:
return
end, r = time.monotonic() + CAMERAS_BACK_S / self.speed, None
while time.monotonic() < end:
if self._want["stop"]:
raise _Stop()
r = self.repair_cameras_fn()
if r is None: # a dry run: nothing to check
return
if r.get("status") == "ok":
self._log(" cameras back: %s" % r.get("summary", "ok"))
return
self._wake.wait(2.0 / self.speed)
self._wake.clear()
summary = (r or {}).get("summary", "not run")
for line in (r or {}).get("evidence", []):
self._log(" " + line)
raise _Fail("The recording stopped and the cameras didn't come back (camera check: %s). %s"
% (summary, camera_text(r) or why))
def _no_hands_seen(self, p, w):
"""The no-hands check's verdict on a hold (logged either way): True if the tracker
published through it and never saw a hand."""
enough = w["reads"] >= HANDS_CHECK_MIN_READS and w["published"] >= HANDS_CHECK_PUBLISHED * w["reads"]
if not enough:
self._log(" hands check %s: the tracker published in %d of %d reads: can't tell"
% (p["id"], w["published"], w["reads"]))
return False
self._log(" hands check %s: a hand in %d of %d reads (%d published)"
% (p["id"], w["seen"], w["reads"], w["published"]))
return w["seen"] == 0
def _no_hands(self, s, st, where, from_ns, w):
"""The first step saw no hands at all: stop it (its range marked as redone, so it gets
no labels), run the camera check, say so, and wait. Next or R tries the step again; Stop
(Esc) ends the session with the camera check's result; S skips the section."""
p = st["p"]
if not self.auto:
self._hold_end()
elif self._recording:
self._stop_recording()
self._event("pause")
self._event("nohands", id=p["id"], reads=w["reads"], published=w["published"])
if from_ns is not None:
self._event("redo", id=p["id"], **{"from": from_ns, "to": mono_ns()})
cam = self.camera_check_fn() if self.check_cameras else None
cam_text = camera_text(cam)
summary = (cam or {}).get("summary", "not run")
self._log(" no hands seen in %s: asking to try again or stop; camera check: %s" % (p["id"], summary))
for line in (cam or {}).get("evidence", []):
self._log(" " + line)
text = "%s|%s|%s" % (NO_HANDS_TEXT, cam_text or "The camera check found nothing wrong (%s)." % summary,
NO_HANDS_RETRY_BUTTON if self._button_ok() else NO_HANDS_RETRY)
self._stop_note = "Stopped: no hands were seen in the first step. Camera check: %s." % summary
if cam_text:
self._stop_note += " " + cam_text
self._prompt = None
self._view()
self._show(NO_HANDS_TITLE, where, text)
for key, c in (("big", "big "), ("countdown", "countdown off"), ("bar", "bar off"), ("target", "target off"),
("note", "note "), ("hands", "hands off off")):
self._panel.set(key, c)
self._emit(state="nohands", prompt=text.replace("|", "\n"), camera=cam, nohands=True, can_redo=True,
big="", countdown=0, seconds_left=0.0)
self._redo_ok = True
try:
self._await_next()
except _Redo:
pass # R here is the same as Next: try again
except _Skip:
self._stop_note = ""
self._emit(nohands=False)
raise
self._stop_note = ""
self._log(" trying %s again" % p["id"])
self._emit(nohands=False)
if self.auto and self._take and not self._recording:
self._event("resume")
self._start_recording()
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"),
("split", "split off")):
self._panel.set(key, c)
self._clear_scene()
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"] == "scene": # the windows go up for the countdown, the panel to its band
self._place_scene(p)
self._panel.set("text", "text " + clean_text(p.get("say") or ""))
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 _CamerasLost("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)
# Without the side pair a recording is no use for training (2026-10-12: XRService moved
# the side cameras while ft-camd kept publishing the old devices, and ft-hands left them
# out of a whole session). Stop at once rather than record the rest of the session.
cams = rec.cameras()
missing = [c for c in SIDE_CAMERAS if c not in cams]
if cams and missing:
self._log("recording part %d has only %s" % (rec.part, " ".join(cams)))
raise _CamerasLost("The recording is missing the side cameras (%s), so it can't be used. Close the "
"Hand Recorder and open it again: it checks the cameras and restarts the camera "
"broker if it needs to." % " ".join(missing))
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"),
("split", "split off")):
self._panel.set(key, c)
self._clear_scene()
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)
elif st["kind"] == "sweep":
self._sweep(s, st, where)
elif st["kind"] == "scene":
self._scene(s, st, where)
else:
self._run_prompt(s, st["p"], where)
def _sweep(self, s, st, where):
"""A sweep: the strip's pictures highlighted in turn, one every cue_s, while the hands
keep moving. Each cue is a prompt event of its own, with that pose, "cue": true and the
step's id, so the timeline tags each pose roughly; the time-left bar covers the step."""
p = st["p"]
cues, cue_s = p["cues"], p["cue_s"]
seen = {}
def cue_prompt(j):
pose = cues[j]
seen[pose] = seen.get(pose, 0) + 1
return dict(p, id="%s/%s%s" % (p["id"], pose, "#%d" % seen[pose] if seen[pose] > 1 else ""),
pose=pose, cue=True, step=p["id"])
self._begin_prompt(s, cue_prompt(0), where)
state = {"t": 0.0, "j": 0}
def tick(dt):
state["t"] += dt
j = min(len(cues) - 1, int(state["t"] / cue_s + 1e-9))
if j != state["j"]:
state["j"] = j
cp = cue_prompt(j)
self._prompt = cp
self._strip(cp)
self._prompt_event(cp)
self._emit()
self._log(" cue %s" % cp["id"])
return False
self._wait(p["seconds"], tick)
def _place_scene(self, p):
"""A scene step's windows into the room, where the head faces now (levelled), and the
panel to its band along the top. A "scene" event logs each window's pose in the room."""
self._panel.cmd("win clear")
self._panel.set("layout", "layout top")
placed = []
for w in p["scene"]["windows"]:
reply = self._panel.cmd("win %s place %.3f %.3f %.3f %.3f %.3f %.1f %.1f %s" % (
w["id"], *w["at"], *w["size"], *w["turn"], w["look"]), reply=True) or ""
placed.append(dict(w, room=_pose_reply(reply)))
self._scene_on = True
self._event("scene", id=p["id"], windows=placed)
def _clear_scene(self):
if self._scene_on:
self._scene_on = False
self._panel.cmd("win clear")
self._event("scene", windows=[])
self._panel.set("layout", "layout full")
def _scene(self, s, st, where):
"""A scene step: its acts in turn, each lighting a window's part (and showing a ghost
where to take it) for its seconds. Each act is a prompt event of its own, with "cue": true,
the step's id and "act" (window, part, gesture, hand, the ghost's pose in the room), so the
timeline says what was asked where and when; the time-left bar covers the step."""
p = st["p"]
acts = p["scene"]["acts"]
if not self._scene_on: # auto mode: no countdown put them up
self._place_scene(p)
for a in p["scene"]["steady"]:
self._panel.cmd(_lit_cmd(a))
self._event("steady", win=a["win"], lit=a["lit"], gesture=a["gesture"], hand=a["hand"])
state = {"t": 0.0, "j": -1, "prev": None}
ends = []
for a in acts:
ends.append((ends[-1] if ends else 0.0) + a["s"])
def act(j):
a, prev = acts[j], state["prev"]
if prev and (prev["win"] != a["win"] or a["lit"] == "none"):
self._panel.cmd("win %s lit none" % prev["win"])
if a["lit"] != "none":
self._panel.cmd(_lit_cmd(a))
ghost = None
if a["ghost"]:
ghost = _pose_reply(self._panel.cmd("win %s ghost %s" % (a["win"], " ".join("%g" % v for v in a["ghost"])),
reply=True) or "")
elif prev and prev["ghost"]:
self._panel.cmd("win %s ghost off" % prev["win"])
state["prev"] = a
info = {k: a[k] for k in ("win", "lit", "gesture", "hand", "label")}
if a["ghost"]:
info.update(ghost=a["ghost"], ghost_room=ghost)
return dict(p, id="%s/%d-%s" % (p["id"], j + 1, a["gesture"]), text=a["say"] or p.get("say", ""),
pose=a["gesture"], cue=True, step=p["id"], act=info, seconds=a["s"])
def tick(dt):
state["t"] += dt
j = min(len(acts) - 1, next((k for k, e in enumerate(ends) if state["t"] < e - 1e-9), len(acts) - 1))
while state["j"] < j: # each act in turn, even when a slow tick passed a short one
state["j"] += 1
ap = act(state["j"])
self._prompt = ap
self._prompt_event(ap)
self._log(" act %s" % ap["id"])
if state["j"] == j:
self._panel.set("text", "text " + clean_text(ap["text"]))
self._status["prompt"] = ap["text"]
self._emit()
return False
state["j"] = 0
first = act(0)
self._begin_prompt(s, dict(first, seconds=p["seconds"]), where)
self._panel.set("text", "text " + clean_text(first["text"]))
self._wait(p["seconds"], self._switches(p, tick))
if self.auto:
self._clear_scene()
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)
reply = self._panel.cmd("target %s show" % xyz, reply=True) or ""
self._panel.sent["target"] = "target %s show" % xyz
tok = reply.split()
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}
self._event("target", state="show", **target)
state = {"hold": 0.0, "sent": 0.0, "holding": False}
def tick(dt):
d = self._tip_distance(pt, room)
if d is not None and d <= TOUCH_M:
if not state["holding"]:
state["holding"] = True
self._event("target", state="hold", **target)
state["hold"] += dt
elif state["holding"]:
state["holding"], state["hold"] = False, 0.0
self._panel.set("target", "target %s show" % xyz)
if state["holding"]:
frac = min(1.0, state["hold"] / hold_s)
if time.monotonic() - state["sent"] >= 0.1 or frac >= 1:
state["sent"] = time.monotonic()
self._panel.set("target", "target %s hold %.2f" % (xyz, frac))
return state["hold"] >= hold_s
done = self._wait(timeout_s, tick)
result = "done" if done else "timeout"
if done:
self._panel.set("target", "target %s done" % xyz)
self._event("target", state=result, **target)
self._log(" %s %s" % (p["id"], result))
if done:
self._wait(0.6, countdown=False)
self._panel.set("target", "target off")
def _tip_distance(self, pt, room):
"""The nearest seen index tip's distance from the target: in the room (with the head's
pose now) when the panel placed the target there, else in the head frame."""
live = self._live
if not live:
return None
tips = [live[s]["tip"] for s in ("left", "right") if live[s]]
if not tips:
return None
ref = pt
if room is not None:
reply = (self._panel.cmd("head", reply=True, timeout=0.1) or "").split()
if reply[:1] == ["ok"] and len(reply) == 13:
m = [float(v) for v in reply[1:]]
tips = [[m[4 * r] * t[0] + m[4 * r + 1] * t[1] + m[4 * r + 2] * t[2] + m[4 * r + 3] for r in range(3)]
for t in tips]
ref = room
return min(math.dist(t, ref) for t in tips)
def _palm_share(self, near, far):
live = self._live
if not live:
return -1.0
ds = [live[s]["palm_m"] for s in ("left", "right") if live[s]]
if not ds:
return -1.0
return max(0.0, min(1.0, (sum(ds) / len(ds) - near) / (far - near)))
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))
period, lead = float(s.get("period_s", 6)), st["lead"]
labels = "%s|%s" % (s.get("near_label", "Near"), s.get("far_label", "Far"))
p = st["p"]
self._begin_prompt(s, p, where)
state = {"t": 0.0, "sent": 0.0}
def tick(dt):
state["t"] += dt
sweep = state["t"] - lead
target = 0.0 if sweep <= 0 else 1 - abs(1 - 2 * ((sweep % period) / period))
if time.monotonic() - state["sent"] >= 0.1:
state["sent"] = time.monotonic()
cur = self._palm_share(near, far)
self._panel.set("bar", "bar %.3f %.3f %s" % (target, cur, labels))
if sweep > 0:
self._event("bar", target=round(target, 3), current=None if cur < 0 else round(cur, 3))
return False
self._wait(p["seconds"], tick)
# ------------------------------------------------------------------------------------------
# The command line
def print_board(base):
board = load_board(base)
profile = {}
try:
with open(os.path.join(base, "profile.json")) as f:
profile = json.load(f)
except (OSError, ValueError):
pass
if not board:
print("no bounty board (%s)" % BOARD_PATH)
return
print("bounty board of %s" % board["updated"])
for b in bounties.view(board, profile, profile.get("checklist") or {}, have_bounty_script):
print(" %-12s %-7s %4s min done %d %-20s %s" % (b["id"], b["priority"], "%g" % b["minutes"] if b["minutes"] else "?",
b["done"], b["title"], b["available"]))
def main():
ap = argparse.ArgumentParser(description="Run a hand recording session (the hand recorder's session runner).")
ap.add_argument("--bounty", metavar="ID", help="a round for this bounty (its id on the board): its own script")
ap.add_argument("--board", action="store_true", help="print the bounty board and exit")
ap.add_argument("--debug", action="store_true",
help="a debug session (debug.json): use Frametop's hand tracking as usual, its output recorded too")
ap.add_argument("--dry-run", action="store_true", help="run no processes; print the panel commands")
ap.add_argument("--speed", type=float, default=1.0, help="run the script this many times faster")
ap.add_argument("--ring", help="read frames from this ring (ft-ringplay's) instead of ft-camd's")
ap.add_argument("--no-start", action="store_true", help="start no ft-camd or tracking ft-hands")
ap.add_argument("--base", help="where sessions go (default %s; a temporary folder with --dry-run)" % BASE_DIR)
ap.add_argument("--objects", default="", help="ticked objects, comma-separated (unknown names are your own)")
ap.add_argument("--controllers", action="store_true", help="controllers with the straps")
ap.add_argument("--lighting", default="auto", choices=("auto", "dim", "room", "daylight"),
help="this round's light (auto: indoor or daylight, from the cameras)")
ap.add_argument("--script", default=SCRIPT_PATH)
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("--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)")
ap.add_argument("--demos", help="the skeleton demo clips' folder, <id>.gif (default hands/rec/demos)")
ap.add_argument("--recorded-demos", help="the recorded demo clips' folder (default hands/rec/demos-recorded)")
ap.add_argument("--guidance", choices=[k for k, _ in GUIDANCE],
help="the demo clips to follow (default recorded when there are any, else skeleton)")
ap.add_argument("--no-headset-button", action="store_true",
help="don't read the headset's button (gpio-keys KEY_SELECT: a press is Next, pause or resume; "
"two are redo; a hold is stop)")
ap.add_argument("--button-device", metavar="PATH",
help="test: read the button from this event device or FIFO of input_event structs (also in a dry run)")
ap.add_argument("--quick", action="store_true",
help="a quick round (about 3 min, for another lighting): hand size, the two-hand sweeps, touch, no hands")
ap.add_argument("--seed", type=int, help="the sweeps' shuffle (default: from the session's id; --plan: the script's order)")
ap.add_argument("--ignore-cameras", action="store_true",
help="start even if the camera check (hands/camcheck.py) finds the upper cameras off")
a = ap.parse_args()
known = ("pencil", "phone", "cup", "keyboard", "mouse", "gamepad", "small")
names = [o.strip() for o in a.objects.split(",") if o.strip()]
checklist = {"objects": [o for o in names if o in known], "own_objects": [o for o in names if o not in known],
"controllers": "straps" if a.controllers else "none", "sleeves": "", "rings": False,
"watch": False, "notes": ""}
base = a.base
if not base and a.dry_run:
import tempfile
base = tempfile.mkdtemp(prefix="handrec-dry-")
base = base or BASE_DIR
if a.board:
print_board(base)
return 0
profile = {}
try:
with open(os.path.join(base, "profile.json")) as f:
profile = json.load(f)
except (OSError, ValueError):
pass
entry, script = None, a.script
if a.debug:
if a.bounty or a.quick or script != SCRIPT_PATH:
ap.error("--debug runs debug.json: no --bounty, --quick or --script")
script = DEBUG_SCRIPT
if a.bounty:
if a.quick or script != SCRIPT_PATH:
ap.error("--bounty runs the bounty's own script: no --quick or --script")
board = load_board(base)
entry = next((b for b in bounties.view(board, profile, checklist, have_bounty_script) if b["id"] == a.bounty), None)
if not entry:
print("no bounty %s on the board (--board lists them)" % a.bounty)
return 2
if entry["available"]:
print("%s: %s" % (entry["title"], entry["available"]))
return 2
entry = dict(entry, updated=board["updated"])
script = bounty_script(entry["script"])
print("bounty %s: %s" % (entry["id"], entry["title"]))
t0 = time.monotonic()
last = {}
def on_status(st):
key = (st["state"], st["section"], st["prompt"], st["note"], st["take"], st["waiting"], st["countdown"],
st["hands"]["left"], st["hands"]["right"], st["error"])
if key == last.get("key"):
return
last["key"] = key
hands = "".join("%s%s" % (s[0].upper(), {True: "+", False: "-", None: "?"}[st["hands"][s]])
for s in ("left", "right"))
state = "%s %d" % (st["state"], st["countdown"]) if st["state"] == "countdown" else st["state"]
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["demo"]:
line += " [%s%s]" % (os.path.basename(st["demo"]), " " + st["demo_mode"] if st["demo_mode"] else "")
if st["waiting"]:
line += " (waiting for Next)"
if st["note"]:
line += " (%s)" % st["note"]
if st["error"]:
line += " ERROR: %s" % st["error"]
print(line, flush=True)
s = Session(base, profile, checklist, a.lighting, 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,
auto=a.auto, next_after=a.next_after, poses_dir=a.poses, button=not a.no_headset_button,
button_device=a.button_device, check_cameras=not a.ignore_cameras, quick=a.quick, seed=a.seed,
demos_dir=a.demos, recorded_demos_dir=a.recorded_demos, guidance=a.guidance, bounty=entry)
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, %s mode: about %.1f min%s (at most %.1f)%s" % (
len(s.plan), " (a quick round)" if a.quick else "", "auto" if a.auto else "step", est / 60, "" if a.auto else " recorded", worst / 60,
", %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:
print(" skipping %s: %s" % (sk["section"], sk["reason"]))
if a.plan:
for sec in s.plan:
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)))
for p in sec["prompts"]:
if p.get("cues"):
print(" %-26s %-5s %3.0f s %s" % (p["id"], p["hands"], p["seconds"], " ".join(p["cues"])))
return 0
if not a.dry_run and not a.ring:
cam = camera_check()
print("cameras: %s" % cam["summary"])
for line in cam.get("evidence", []):
print(" " + line)
if camera_text(cam):
print(camera_text(cam))
if not a.ignore_cameras:
print("Not starting (--ignore-cameras starts anyway).")
return 3
if not a.dry_run:
light = ring_lighting(a.ring)
match = similar_lighting(base, {"chosen": a.lighting, "ring": light}) if light else None
print("lighting: %s" % (json.dumps(light) if light else "no camera ring"))
if light:
print(" measured: %s (ambient IR %s)" % (classify_lighting(light) or "can't tell", ambient_ir(light)))
if match:
print(" about the same light as session %s (%s)" % match)
def on_signal(*_):
print("stopping", flush=True)
threading.Thread(target=s.stop, daemon=True).start()
signal.signal(signal.SIGINT, on_signal)
signal.signal(signal.SIGTERM, on_signal)
def keys():
# 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"
"; debug: m mark, f finish", flush=True)
for line in sys.stdin:
c = line.strip()[:1].lower()
if c in ("n", ""):
s.next_step()
elif c == "p":
s.resume() if s.state == "paused" else s.pause()
elif c == "r":
s.redo()
elif c == "s":
s.skip()
elif c == "q":
s.stop(wait=0)
elif c == "m":
s.mark()
elif c == "f":
s.finish()
if sys.stdin is not None:
threading.Thread(target=keys, daemon=True).start()
s.start()
while s._thread.is_alive():
s.join(0.5)
print("session: %s" % s.session_dir)
return 0 if s.state in ("done", "stopped") else 1
if __name__ == "__main__":
sys.exit(main())