mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 07:00:14 +02:00
* Input relay: typing with the pointer helper down no longer ends the relay Typing on a pass-through keyboard tells the helper "typing". With the helper not running (SteamVR off), that send raised ConnectionRefusedError and the relay exited, dropping every grab until systemd restarted it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-steam: open Steam's menu through Steam's own UI steam/ft-steam menu opens the SteamVR dashboard on Steam's menu, or closes the dashboard if it's up, without pointer mode: it asks Steam's UI over its debugging port to show its dashboard overlay (ShowVROverlay, what Steam calls itself) and focus the Steam frame's left menu (MenuStore.OpenMainMenu). ft-steam check says whether those calls still exist, and update-check.py runs it, since a Steam client update can rename them. The CDP client moves from display-settings/steam_settings.py to steam/steamui.py, so both use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Shortcuts: Steam menu, commands, and modifier taps Key combinations (and mouse and controller buttons) get two new actions: - steam_menu: Open Steam menu / close dashboard (steam/ft-steam menu). - command:CMD: run CMD with sh -c, as the relay's service, with layout/, float/ and steam/ on its PATH. Input Settings offers it for key combinations as Run a command... Both work without pointer mode. A modifier on its own is now a key combination too: a tap, pressed and released with no other key, mouse button, or scroll in between. A bound tap sends the desktop F24 before the release, so Plasma's launcher stays shut. The defaults gain a Meta tap for the Steam menu; this replaces META_DASHBOARD, which only worked in pointer mode. input/test/keys-test.py runs the relay against fake devices with every outgoing socket renamed, so it's safe next to the live relay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Relay: share a key combination's Meta release with frame-voice A Meta+key combination (Meta+J for gaze_left, say) hides Meta's release from the desktop, and the relay skipped share_key for it too. frame-voice saw Meta go down on @frametop_keys and never come up, so it held all dictated text back, waiting for that release. Keys of grabbed keyboards are now shared as pressed, before key_binding() decides what the desktop gets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: ft-cutouts, the hand cutouts without pinches and grips hands/ft-cutouts on|off|status starts ft-camd and ft-hands as transient user units with ft-hands' new --no-gestures: hands are published for ft-screens' cutouts, but no pinch or grip is detected, so nothing clicks or drags and a closing hand doesn't raise the tracking rate. It needs a build and ft-camd's capabilities, not hands/run.sh install. Its units conflict with ft-handsctl's, so each stops the other, and they stop with SteamVR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-cutouts status: only the current run's tracker lines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: say why gaze mode can't work yet, and open the calibration whenever it's missing Turning gaze mode on without a calibration opened Calibrate only on the off-to-on change, and only if it could open right then. With the headset off, the eye tracker silent, or the panel not built, or with gaze mode already on when the gaze service started, nothing opened and nothing said why: the pointer just stayed a mouse. - The gaze service now checks every second: gaze mode on, no calibration for the tracker in use, eyes seen -> the full calibration opens. One that closes unfinished opens again only after the headset comes off and on, gaze mode off and on, or Calibrate, so it doesn't loop. A start that fails retries every 10 s. - Its status says why gaze mode can't work yet (checks.problem): not calibrated and opening, open, closed unfinished, or can't open and why. - Input Settings shows that under the Gaze pointer switch, along with the gaze service not installed or not running and our tracker missing its frame grabber. - ft-gazectl on notes a missing calibration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: install our eye tracker, prefer it, and say why a calibration dot wasn't taken A user on a fresh install got "Calibration failed: only 0 of 21 dots" with no reason. The installer never installed our tracker, so gaze used SteamVR's, and the only way SteamVR's tracker rejects a dot is losing an eye for most of the look. The panel just showed a red ring. - install.sh: step 9/10 installs our tracker (gaze/tracker/install.sh) after gaze mode, yes by default; it needs sudo, so --yes runs it only when sudo won't prompt. If it fails, gaze keeps SteamVR's tracker. Configs that still say GAZE_TRACKER=steam (the old template) are asked whether to switch. - GAZE_TRACKER=auto, the new default: ours when it's installed (the frame grabber, its unit, and ft-eyes' Python), else SteamVR's. ft-gazed rechecks every second, so installing it switches over. Input Settings lists Own tracker first as recommended, and says how to install it when it's missing (checking the host's /etc through /run/host from the dev container). - The calibration panel has a note line, orange over the instructions: why a dot wasn't taken (an eye lost, a blink, the eyes disagreeing for SteamVR's tracker, from steady_samples' new drop counts; ft-eyes' reply for ours), what a click is still waiting for after 1.5 s, and a failed calibration's most common reason, which the Gaze page shows too. steady_samples keeps the same samples as before (checked on 2037 windows of recordings); a lost eye is named before a blink, since its openness reads 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * README: link the Frametop Discord Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Wait for a new container to finish setting up before entering it container-up.sh starts the dev container in a scope of its own, so distrobox enter finds it running and skips its wait for distrobox-init. On a fresh install, init was still setting up passwordless sudo when dev-container.sh ran sudo dnf install, and sudo asked for a password with no terminal to read it from. container-up.sh now waits for container_setup_done itself, and the container's sudo calls use -n, so a password prompt fails at once with a clear message. Fixes #9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Prevent small desktop overlay pointer movements from starting a drag * Clear reported drag state on controller release * Click stability: only a hand controller's press starts it The 3D mouse drives SteamVR's laser through the ft_pointer virtual controller, so its events reach the screens the same way a controller's do. The filter held every press, which turned the mouse's short drags (selecting a character or two, nudging a slider) into clicks. Mark button events from hand controllers and start the filter only on those. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input relay: retry a new device until udev gives it to the input group A new /dev/input node is root:root 0600 until udev applies GROUP=input. The scan probed each new node once and marked it seen even when the open failed, so a node caught in that gap was never opened. Behind a KVM, a switch brings back a hub of devices at once: on the Frame, four nodes failed with EACCES in one switch, the keyboard was never grabbed, and its keys went to gamescope instead of the desktop screens. A node that isn't readable yet now waits for the next scan. * Screens: take a screen's overlays from one copy in the catcher While a button pressed on a screen is held, UpdateCatcher checks every tick whether the laser is still on one of the screen's overlays. It built that list from s.All().begin() and s.All().end(), but All() returns a std::array by value: iterators into two different temporaries, which is undefined behaviour. A clang build of ft-screens got a garbage length, threw std::length_error, and aborted on the first click, taking KWin and the desktop with it. * Gaze: leave SteamVR's gaze action alone during VR games From curiousjtuber's PR #13: with the gaze service running, SteamVR restarted its eye tracker every 10 to 13 s of Beat Saber, as if the headset came off, and each restart took input focus from the game. The PR stopped every read in a game. Only the action path reaches SteamVR (UpdateActionState on the gaze set at overlay-global priority, then GetEyeTrackingDataRelativeToNow); the mmap and our tracker are read-only files. So only the action is skipped while a scene app runs, and gaze keeps moving the pointer over the dashboard in a game. The action source is only used with --source action. Co-Authored-By: CuriousJ <curious.j.tuber@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: --record-hz, and the hand recorder's design (hands/rec/DESIGN.md) ft-hands --record-hz N records at most N frame sets a second, for the hand recorder (10). DESIGN.md lays out the recorder: the headset panel, the session runner and its script, the files, review and export, consent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: ft-handpanel, the hand recorder's headset panel A head-locked SteamVR overlay for the hand recorder (hands/rec/DESIGN.md): 1.2 m ahead, 12 degrees up, 36 degrees wide, drawn with stb_truetype into three shared DMA-BUFs as ft-gazepanel does. It shows the title, step, wrapped instruction, note, countdown, hand chips, near/far bar and a "Paused" cover, driven over @ft_handpanel. It also places the touch target, a 2 cm dot in its own overlay fixed in the room where the head was at the first command for that point, and logs head and controller poses to poses.jsonl at 250 Hz from a thread of its own. Both threads take one lock around OpenVR calls. --no-vr prints each picture's state to stdout (and --dump writes the pictures), for testing without a headset. hands/rec/build.sh builds it in the dev container. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the recorder's worn check goes by the panel's backlight, as frame-job does Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the hand recorder's session runner and guided script hands/rec/session.py runs a recording session from script.json: it starts ft-camd and a tracking ft-hands as transient units only if they aren't running, records each section as one take (ft-hands --record-only at 10 sets/s, a new sets-N.bin after each pause), drives ft-handpanel, and writes session.json, calibration.json (identifying fields removed), prompts.jsonl and take.json. Feedback comes from the live hands file and, in the controller sections, from the panel's device poll. It also runs from the command line (--dry-run, --speed, --ring, --no-start). hands/rec/script.json: 11 sections, about 9 minutes without the object and controller sections. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the hand recorder's window, review and export hands/rec/ft_handrec.py + main.qml (Kirigami, dev container; host launcher hands/rec/ft-handrec): consent (CONSENT.md, asked again when its version changes; profile.json with a random contributor id), the before-you-start checklist with the lighting and free-space checks, the session controls (Space pauses, Esc stops), review with a frame-set viewer that deletes ranges, takes and sessions, export with progress and cancel (warns while the headset is worn), and the upload page (UPLOAD.md, the huggingface-cli command; HF_DATASET is a placeholder). --dry-run runs sessions without processes, for testing. hands/rec/takes.py (standard library): indexes sets.bin and sets-N.bin without reading pixels, reads one set's cameras, keeps deleted ranges in take.json, and exports: deleted sets left out, zstd -10 -T2 at nice 19, manifest.json and SHA256SUMS, nothing left behind on cancel. CONSENT.md and UPLOAD.md are drafts pending a legal review; the window says contributions aren't open yet. The dev container gains zstd. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: upload from the hand recorder's window, export checks, a rehearsal hands/rec/validate.py (standard library; Linux and Windows, Python 3.12+) checks an export before upload and when it's received: SHA256SUMS, an allow-list of files, the manifest's schema and keys, the consent version, a uuid4 contributor, no identifying fields in calibration.json or device.json, every sets.bin.zst decompressed to its end as a stream with each FHSET01 header checked against the manifest, jsonl lines, the total size. It decompresses with compression.zstd, zstandard or the zstd program. validate.py DIR [--json]. hands/rec/hub.py uploads an export with huggingface_hub, as a pull request to contributions/<contributor>/<session>: validate first, refuse a repeat of the same export, check the login (whoami) and access (auth_check), upload_folder(create_pr=True) with the manifest summary as the description, then record the PR under "uploads" in session.json. Errors are explained (terms not accepted, not found, 401/403, network). --dry-run makes no network calls. FT_HANDREC_DATASET overrides HF_DATASET (DeeJanuz/frametop-hands); while the texts are drafts a real upload needs FT_HANDREC_ALLOW_UPLOAD=1. The Upload page shows the login with "Check again" and how to run hf auth login in a terminal (the token never enters the window), then Upload with a phase, progress and Cancel (hub.py as a child process), the PR link, and a warning for an export uploaded before. The manual command stays as the fallback. ft-handrec --hub-dry-run. session.py also saves device.json: cv.cad_from_cal and head from /persist/device_config.json, the labeller's shape, nothing identifying; export copies it. Session ids with a -N suffix are accepted everywhere. hands/rec/rehearse.sh runs it all without the headset: ft-ringplay plays 30 s of a capture into a ring, session.py records a short test script with ft-handpanel --no-vr and a tracker, then export, validate and a dry-run upload (--repo ID uploads for real). It runs in one frame-job scope, deletes its data and stops its processes, also on Ctrl+C. hands/rec/tests/test_validate.py covers good and broken exports and hub.py without the network. The dev container gains python3-huggingface-hub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * README: Frametop doesn't work on the SteamOS beta yet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit072a294941) * Hands: step mode and pose pictures for the hand recorder The first real session moved on every 5 s with text only, too fast to follow. Each step now waits for Next (Space or the window's button), counts down 3-2-1 while recording, then holds. P pauses, R redoes a step, S skips a section; "Advance by itself" (--auto) keeps the old timed flow. Nothing records while a step waits: each step is its own recording part. The panel and the window show a picture of each pose (hands/rec/poses, generated by make_poses.py from a parametric hand, MIT) and a diagram of where to hold the hands and how far out. prompts.jsonl gains ready, wait and redo events; session.json gains mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the headset button as Next, clearer push steps The headset's right-side click button (KEY_SELECT on gpio-keys, read without a grab) now works the session: Next while a step waits, pause during a hold, resume while paused. With no mouse connected the hints lead with it. The push sections say plainly to push straight out from the headset and pull back, with a side-view picture of the head, the headset and the arrow, and the bar's ends read "At your chest" and "Arm out". The bar labels are sent as one field, so labels with spaces no longer split. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-floatd: a launch for a missing app no longer kills the control socket Gio.DesktopAppInfo.new() returns NULL for a desktop file that doesn't exist, and PyGObject raises TypeError ("constructor returned NULL") rather than returning None, so launch()'s `if info is None` never ran. The exception escaped the control socket's GLib callback, GLib dropped the watch, and ft-floatd stopped answering everything: the float key, dock, Launch as Standalone, and profiles, until the desktop restarted. Found on the Frame (2026-10-02): a profile saved with RustDesk's Flatpak open records its window's app id, com.carriez.flutter_hbb, which has no desktop file (the Flatpak's is com.rustdesk.RustDesk). `ft-layout use` on that profile asked ft-floatd to launch it, and ft-floatd went silent. With this, that launch replies "error no app com.carriez.flutter_hbb" and the profile's other apps open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-floatd: profiles keep and relaunch Flatpak apps whose window names another app id An X11 window in a Flatpak can give KWin an app id with no desktop file: RustDesk's says com.carriez.flutter_hbb (its GTK application id), and the Flatpak's desktop file is com.rustdesk.RustDesk. A profile recorded that id, so it couldn't relaunch the app (PR #16 keeps that from killing ft-floatd's socket). And the window's pid is the sandbox's own, so a launched window matched neither by process nor by app id, and didn't float. desktop_name() finds the desktop file whose StartupWMClass names the window's class (or app id) when the app id has none. Capture records that name, a profile claims open windows by it, and a launch's window matches by it. Profiles saved before this keep the old id; save them again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: check the tracking cameras before recording, and watch for losing them After the headset wakes, XRService sometimes fails to load the colour module's VCINT FPGA image; then only the two side cameras run, without the IR light, and the tracker finds no hands. hands/camcheck.py reads XRService's log, the video nodes it holds and ft-camd's ring, and says ok, degraded or unknown. The recorder won't start while degraded (--ignore-cameras overrides it), offers a confirmed SteamVR restart, and stops the first hand-size step when the tracker sees no hand at all. ft-camwatch (unit file only, not enabled) follows the log, notifies, and with CAMWATCH_AUTO_RESTART=1 restarts SteamVR when the headset isn't worn and nothing else uses VR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: shorter recording sessions with pose sweeps The second real session took 16 minutes, half of it 36 still poses. Labels come from the auto-labeller, so what matters is variety, not clean holds. A sweep step shows a strip of pose pictures and lights one every 4 s while the hands move slowly near and far; each cue is a prompt event with "cue": true. The core session is now 15 steps, about 5 minutes recorded. The pose groups, the one-hand sweeps' groups and the cue order are shuffled per session, seeded from its id and saved in session.json. A quick round (--quick, or the checklist's choice) is about 2 minutes for extra lighting. Touch the dot has 6 dots, the push sections two heights. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input relay test: let the fake devices past the udev permission check Since the relay leaves a node it can't read yet for the next scan (12f2e84, PR #12), it checks os.access first, and the test's fake /dev/input paths don't exist, so the relay never opened them and every key check failed. The fake os now says they're readable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pause Frametop for VR games Frametop kept using the headset during games: with gaze mode off, our eye tracker still took about 60% of a core, remote desktop about 2 cores while on, and KWin kept drawing hidden screens because ft-screens sent their frame callbacks at 90 Hz. Pausing gives that back, and resuming brings back only what pausing stopped. It's also a way to keep the gaze service and our eye tracker off during games, which PR #13 asked for. Paused (input/game_pause.py, run by the input relay): - frametop-gaze stops (ft-eyegrab then idles by itself), and hand tracking and remote desktop stop if they run - the desktop hides and slows down: ft-screens "pause on" hides every panel and sends KWin a frame callback once a second; or, with pause_desktop "close", the desktop closes and starts again on resume - the relay lets go of the 3D mouse, typing goes to Steam, and mapped buttons and key combinations do only pause_toggle, steam_menu and commands Toggled by both thumbsticks clicked together twice (configurable), read passively from vrserver's web socket (input/vrws.py) so it works in games and takes nothing from them; by the new pause_toggle action; by input/ft-pause; and, with pause_auto (default on), by a VR game starting and ending, which the pointer helper now reports ("vrgame 1|0"). Frametop Input Settings has a Games page for it. update-check.py checks the web socket, and doesn't count a paused gaze service as failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: ft-hands works out which side camera is which ft-camd tells the side cameras apart by XRService's buffer allocation order, which some XRService starts reverse; both of 2026-10-02's starts did, so the cutouts missed the hands. HANDS_SWAP_SIDES=auto (the default) has ft-hands vote from hands seen in both side cameras: the landmark rays meet in front of both cameras only under the right naming. While undecided it probes the exchanged naming with the landmark model. It decides in about 2 s of hands (right on all 7 recordings replayed), swaps the views in place, and publishes sides.json. 0 and 1 still force it, with a warning when the hands disagree. Recordings carry each part's naming and the session's decision; review, export, validate and ft-handreplay put the names right, and takes.py sides records a decision by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: idle while the gaze isn't used The gaze service ran ft-gaze and our own eye tracker all the time: with gaze mode off, ft-eyes still took about 60% of a core, and ft-eyegrab, ft-gaze and ft-gazed 3 to 4% each. Now ft-gaze and our tracker run only while gaze mode is on and someone wears the headset, while a check or the calibration is open or asked for, or under a "wake" lease, which the Gaze page of Frametop Input Settings renews while it's open. 30 s after the last use they stop, and the frame grabber idles with our tracker. - The pointer helper answers "gaze ? headset" with worn|away (SteamVR's activity level for the headset); an older helper answers it as before, and the service then goes by gaze mode alone. - A quick check, calibration, or fit check asked for while idle wakes the tracker and opens once it sends; the automatic calibration waits quietly while it starts. - Status has "awake" and "idle" (why), and the Gaze page shows it. - gaze/test/idle-test.py runs the service with a fake helper and ft-gaze, offline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * update-check: a still controller isn't a broken web socket vrserver sends a controller's state only when something on it changes, and one lying still or asleep may not even send its first one. The check subscribed to one controller and failed after 3 s of silence. It now subscribes to all, and silence after a good handshake is a skip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the recorder measures the light itself The checklist page measures the light when it opens, starting ft-camd if nothing runs it (and stopping it on quit), instead of saying the cameras aren't running. The round's lighting defaults to what the cameras measure: daylight or indoor, from the mono cameras' ambient infrared. Lamps give off little infrared, so dim and normal rooms read alike; picking dim, room or daylight still overrides it. session.json gets source, measured and ambient_ir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: the recorder's host commands run from the home folder host-spawn starts a host command in the caller's folder. Started from /tmp, the app's folder in the container is /run/host/tmp, which the host doesn't have, so starting ft-camd (and every other host command) exited 127. host_command now runs from home (env -C), and the launcher cds there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input Settings: the Games page is Game optimization Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: push steps say to follow the hollow circle, not the blue dot The dot is the current tracker's distance guess, often wrong; the ring is where the hands should be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Eye tracker: one thread for OpenCV and numpy Nothing called cv2.setNumThreads, so OpenCV kept a pool of one worker per core for pupil windows of 140 to 240 px. Live, its idle workers spun and yielded about 14,000 times a second each, about a quarter of a core, next to SteamVR's compositor. numpy's OpenBLAS also started 8 threads that never had work. eyes_pupil.py now sets OpenCV to one thread, and ft-eyes sets OPENBLAS_NUM_THREADS and OMP_NUM_THREADS to 1 before numpy loads (a value already in the environment wins). Replaying fit1 into a scratch share (ft-eyes-replay, 14 s measured, capped at one core with the replay): threads 13 -> 1, involuntary context switches 3,812/s -> 430/s, system time 6.9% -> 1.6% of a core. Under that cap the frames it kept up with went from 21-36 to 57-69 a second per eye. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: read the overlay list every 20 s, not every second The helper ran `vrcmd --overlays` once a second while the pointer was awake, and in gaze mode the pointer never sleeps. Each run is a shell plus vrcmd, a new SteamVR client, about 26 to 30 ms of CPU, so about 3% of a core all the time. The list is now read every 20 seconds, and at once (at most once a second) when it may have changed: the pointer waking, the dashboard opening or closing or creating an overlay, the scene app changing, an "overlays" request, and a left click that hit nothing, which may be on a panel that came up since. The thread waits on a condition variable instead of waking every 100 ms, so it sleeps while paused. The main loop looks the keys up again as soon as a new list is in, rather than at its next 1 s tick. Overlays already on the list still show and hide within 50 ms, from the IsOverlayVisible poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: ft-eyes and ft-gaze below SteamVR's priority ft-eyes and ft-gaze run in the dev container through distrobox, so they live in podman's libpod scope: frametop-gaze.service's limits never reach them, and they ran at nice 0 next to vrcompositor and vrserver, also at nice 0. - ft-eyes sets itself to nice 10 and SCHED_BATCH at start, before its threads. Batch turns off wakeup preemption, so a frame ft-eyes wakes up for can wait out a running compositor's turn; a few ms late costs the gaze little. - ft-gaze sets nice 5 before its threads start, but stays SCHED_OTHER: each sample goes on to the pointer, and batch would add the same wait to every one of them. - Both only ever lower their priority (a higher nice already set wins), and a failure is logged and ignored. Checked in the dev container: nice 0 -> 10, policy 0 -> 3 (SCHED_BATCH) without any capability. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: sleep on the command socket while the pointer is off The main loop slept a fixed 8 ms, about 116 wakeups a second, whether the pointer was awake or not, and every second it looked up every overlay's handle and read a string property from all 64 device slots to find its own device. With the pointer off and hand gestures off, the loop now waits in poll() on its command socket for up to 250 ms, or 20 ms while mapped Frame controller buttons are being read (SteamVR input has no event to wait for). A mouse command ends the wait at once. The headset's activity level, the game check, and the "vrgame" and "gazeawake" repeats keep going at that pace. The 50 ms visibility poll and the 1 s handle lookups run only while the pointer is awake, and waking forces both. The device index is looked for only while it's unknown, and again after SteamVR activates or deactivates a device. The HMD pose history is kept only with hand gestures on, its one user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remote desktop: connect FreeRDP only while a VNC viewer is connected vnc-bridge.sh kept FreeRDP connected to krdpserver from the moment remote desktop started, so krdp captured and H.264-encoded every KWin redraw in software (openh264) with nobody watching: krdpserver 55-78% of a core, xfreerdp 16-27%, Xvnc 6-11%, with 0 clients on :5900. krdp 6.7 creates its screencast session per RDP connection and drops it when the connection closes, so krdpserver itself idles without one and stays up. The bridge now counts established connections to Xvnc's port with ss, starts FreeRDP when a viewer appears (the desktop shows about 3 s later; the VNC screen is black until then) and stops it 45 s after the last one leaves (VNC_IDLE_SEC). Xvnc has no client hook, so its log output, which it writes for every connection, wakes the bridge early; otherwise it looks every 5 s while idle (0.1% of a core measured, against 0.9% for ss once a second) and every second while FreeRDP runs. The layout check runs only while FreeRDP runs. While a viewer is connected the bridge sends "watch 15" to ft-screens (@ft_screens) at once and every 5 s, so screens at a reduced frame rate (out of view, headset on a stand) stream at full rate; it lapses by itself if the bridge dies, and an ft-screens without the command just answers an error. The window search after starting FreeRDP now ends when FreeRDP exits instead of polling for 30 s. krdp on 127.0.0.1 with a fresh password, VNC on the tailnet address with VncAuth, and remote-ctl.sh start/stop (pause and resume) are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remote desktop: read the layout only after it changes While FreeRDP ran, vnc-bridge.sh called ft-layout remote-view every 5 s, which scans all of /proc for plasmashell and runs kscreen-doctor -j: about 4.4% of a core for a layout that rarely changes. It now stats the two files the answer depends on, the nested KWin's ~/.config/frametop/kwinoutputconfig.json (positions, scales, primary) and ~/.config/frametop-layout.json (screen sizes), once a second while FreeRDP runs. After either changes it reads the layout every 2 s for 10 s, since KWin's outputs follow the file a few seconds later; otherwise once a minute, in case a change touched neither. With no VNC viewer connected nothing runs (previous commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Eye tracker: ft-eyes sleeps until the next frame is due ft-eyes looked for new frames about 1,000 times a second: each pass of its loop asked the control socket with a non-blocking recvfrom (a BlockingIOError nearly every time), read both cameras' counters, and slept 1 ms. The frames come every 11.1 ms per camera, and only as counters in ft-eyegrab's shared memory, so there's no fd to wait on. Now each pass ends in select() on the control socket, with a timeout until 2 ms before the next frame of either camera is due (from when its last one was seen), then every 1 ms until it comes. A command wakes it at once. A camera with no frame for 0.1 s (headset off, grabber idle) isn't waited for, and with both stopped it looks every 20 ms. Waiting for the frame grabber's file uses the same select, 0.2 s at a time, instead of sleeping through commands. A new frame is still seen within about 1 ms of when it lands. On a synthetic share at 90 Hz per camera, on a heavily loaded headset (load average 23, so ft-eyes rarely sat idle), its waits went from 178 to 81 a second; unloaded, the old loop's 1 ms sleeps add up to about 1,000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: frame rates by attention, ticks in step with the display ft-screens gave KWin a frame callback for every committed screen on each tick, and the tick was an 11 ms timer set again after each run, so it slid through the display's frame and came about 85 times a second at 90 Hz: the desktop repeated a frame several times a second (judder in scrolling and video), and KWin drew every screen in one burst at a random point of vrcompositor's frame. Hidden screens got the same 90 Hz unless Frametop was paused for a game. - Ticks run on a timerfd at absolute times, once per display frame, 1 ms after the vsync (IVRSystem::GetTimeSinceLastVsync and the HMD's display frequency, read once a second), so KWin gets its callbacks early in the frame. Measured with --no-vr: 91 wakeups a second instead of about 85. On the Frame the vsync times SteamVR reports lie on a 90 Hz grid. - Each screen's callbacks come at a rate for how much of it you see (vr.cpp, UpdateAttention): every frame while focused (within 12 degrees of where your head points, a laser or the mouse on it in the last 1.5 s, carried, or typed on), 15 a second for the rest of what you see (within 60 degrees), and 1 a second when hidden, behind you, or paused. Levels rise at once and fall after 1.5 s (focused) or 0.5 s (in view). KWin draws a screen only after its callback and its apps wait for theirs, so this throttles the apps too. A screen where nothing changes costs nothing at any rate, as before. - A video in view keeps every frame: 8 commits in a row that each redraw 6% or more of the screen, at 10 a second or more, count as one (from the surface's buffer damage). - "rates F V H" / --rates set the three rates (default 0 15 1, 0 = every frame), "rates?" shows them and each screen's level, "watch S" gives everything full rate for S seconds for a remote viewer (vnc-bridge.sh renews it), and "phase MS" moves the ticks for tuning. - ft-screens' main thread runs at nice -5 after the session starts: SteamOS allows down to -8 once the soft RLIMIT_NICE is raised, and KWin waits on these ticks. It had spent nearly 3 times as long waiting to run as running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: skip unchanged work while the pointer is awake Every frame (about 116 a second) the helper tested the cursor ray against every visible overlay twice with ComputeOverlayIntersection, set the dot's alpha, width, transform and visibility (five calls into SteamVR), and sent the driver a pose datagram, even with the mouse and the head still. Now a frame reuses the last collision result when the mouse, the anchor (1 mm) and the eye (5 mm) haven't moved and no overlay showed, hid, or changed handle. The passes still run at least every 100 ms, since overlays move on their own (a floating window's controls follow it), and always while dragging. The dots' setters go to SteamVR only when their value changes: the placement when the dot moved 0.2 mm or the eye 5 mm, which turns or resizes it by well under 1%, and the width on a 0.5% change. The plain pose goes to the driver only when the laser's origin moved 0.2 mm or its direction 0.04 deg (0.1 mm where it lands, 15 cm on), and at least every 100 ms; the driver keeps the last pose and reports it every frame. A tilt's pose, a placement, or waking sends the next one regardless. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Session: blur, background contrast and animations off by default The nested kwinrc had no [Plugins] group, so KWin ran its default blur and background contrast effects, and kdeglobals had no AnimationDurationFactor, so animations ran at full length. KWin renders through zink on Turnip, on the GPU vrcompositor needs, and blur re-renders what's behind every translucent panel and menu; each animation frame is another frame for KWin and ft-screens. Before KWin starts, the session script now writes [Plugins] blurEnabled=false and contrastEnabled=false to $XDG_CONFIG_HOME/kwinrc and [KDE] AnimationDurationFactor=0 to its kdeglobals, each only if the desktop's own file has no value for it. It does this once and records that in $XDG_CONFIG_HOME/frametoprc ([Defaults] effects=1), because System Settings deletes a key put back to its default: without the marker, turning blur back on wouldn't survive a restart. The ids blur and contrast are the built-in effects of KWin 6.2.5 on SteamOS (both enabled by default in its plugin metadata). Tested against a temporary XDG_CONFIG_HOME: fresh config, an existing blurEnabled=true kept, and a deleted key not rewritten. README and docs/reference.md say how to turn them back on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Session: don't autostart Discover's notifier or IBus in the desktop The nested Plasma session runs the system's XDG autostart entries. Discover's update notifier (/etc/xdg/autostart/org.kde.discover.notifier.desktop) started plasma-discover --mode update inside it, 520-620 MB resident and about 9% of a core, with flatpak-system-helper and AppStream downloads behind it. IBus started a nested ibus-daemon with kimpanel and ibus-extension-gtk3, which no app in the desktop can use: KWin's input method is ft-textinput (zwp_input_method_v1, focus reports only; the VR keyboard types through ft-screens' seat), and the session already drops QT_IM_MODULE, GTK_IM_MODULE and XMODIFIERS. Nothing in Frametop talks to IBus. Before Plasma starts, the session script copies both entries into $XDG_CONFIG_HOME/autostart with Hidden=true, which plasma-session honours for that desktop only. It does this once ([Defaults] autostart=1 in frametoprc) and skips a name the user already has a file for, so deleting the copy brings the program back. The geoclue demo agent stays (it answers apps' location requests outside GNOME and idles at 0%), and orca's entry is OnlyShowIn GNOME-family desktops, so it never ran. Tested against a temporary XDG_CONFIG_HOME, including an existing user ibus.desktop left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input relay: never block on the pointer helper's socket The relay sent to @ft_pointer_helper on a blocking socket. When the helper stalled, a layout placement or grabprobe holds it for seconds while ft-gazed keeps filling its socket at 90 Hz, the relay's one loop blocked with it: keyboards, the volume keys (which must never reach gamescope), and pausing all stopped until the helper read again. The socket is non-blocking now. A command the helper doesn't take (EAGAIN) waits in a queue, and everything after it queues behind it so the order holds; tick() sends what it can on each loop, and the select timeout drops to 20 ms while anything waits. Mouse moves add up into one queued move. A scroll notch is dropped rather than queued, since scrolling seconds late is no use; its release still goes. Presses, releases, show, hide, and the rest are kept, so no button stays down. The queue holds at most 512 commands. While paused, the configured pointer's queue still drains, so the releases and "hide" from standing down arrive. A "vrbind" that hits a full socket is sent again on the next loop instead of being lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer driver: parse outside the lock, report only changes Handle() held the state lock through a chain of up to a dozen sscanf calls per command, and RunFrame, which vrserver calls every frame, takes the same lock, so a burst of commands (about 116 poses a second, plus moves and buttons) could hold up vrserver's frame. Commands are now parsed into locals first, and the lock is held only to store the result. RunFrame also called UpdateBooleanComponent six times and UpdateScalarComponent twice every frame, and TrackedDevicePoseUpdated every frame even while disconnected. Components now go to SteamVR only when they change (all of them on the first frame). The pose still goes out every frame while the device is connected, as a tracked device's should; the disconnected pose goes out once. The helper now sends a pose only when it changes, so the comment says the driver keeps the last one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ft-powerd: ask SteamVR every 100 ms, not on every input event The loop polled the input devices with a 100 ms timeout and then, on every wake, did SteamVR's part: PollNextEvent, the headset's activity level and every device's pose, all IPC calls to vrserver. Input wakes it at once so the displays come on with the first key or motion, but a moving mouse sends hundreds of events a second, so moving the mouse meant hundreds of rounds of IPC a second instead of 10. Every wake still drains the input devices and the control socket and counts input as use straight away; SteamVR's part, and the backlight read that goes with it, now run only when 100 ms have passed since the last time, and poll sleeps until then. Built in the dev container (power/build.sh, no warnings); not run, since the live ft-powerd holds @ft_powerd. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Eye tracker: ft-eyegrab checks only the slot each camera writes next While copying, ft-eyegrab woke every 300 us (about 1,500 to 3,000 times a second) and fingerprinted all eight slots each time: 8 x 256 strided reads from DMA-BUF memory. - Each look now checks only the slot each camera writes next. The order is known (camera 0 3,0,1,2; camera 1 7,5,4,6,5,7,6,4), and the next slot follows from the last two; the table starts from those orders and learns from every frame, so a SteamVR update that changes them costs a few seconds of full scans, not frames. - A camera with nothing in its expected slot 1.5 frames after its last one, or with no order yet, gets all four slots checked, as before. A frame that turns up in an unexpected slot means full scans for that camera for 2 s. - A slot's fingerprint is taken again when it stops being one of the two in use, so a later check sees only a new frame. A frame is still passed on when its camera starts the frame after next. - Between frames it sleeps until 2.5 ms before the next is due, then looks every 1 ms, with 0.5 ms of timer slack (PR_SET_TIMERSLACK, --share only). With no frames from either camera for 0.5 s (headset off) it looks every 4 ms. - --rec keeps its 0.3 ms polls (and the expected-slot checks), for its timestamps. Tested offline by building poll_frames against simulated cameras that write each frame in four bursts, the last after the next frame starts (6 s, both cameras): 1,076 frames passed on, none torn or skipped, with the known orders and with camera 1 in a different order. Wakeups 1,486/s -> 207/s, the poller's CPU 3.6% -> 0.8% of a core (in plain memory; the real DMA-BUF reads cost more), and a frame's start is seen 1.35 ms after it begins on average instead of 0.76. With a camera stalling 15 ms every 2 s, the old poller passed on 22 torn frames and the new one 8 or fewer. Built (glibc 2.38 symbols at most, the host has 2.39), not installed: it runs as root from /etc/frametop, so it takes effect only after gaze/tracker/install.sh (sudo). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: upload from the headset, then plug in and leave it Export and upload are done in the headset now: the export page only notes that VR may stutter a little. Upload opens the pull request first (a draft) and shows its link, telling the person to plug in the headset and leave it until it says Uploaded; the files then go to refs/pr/N, and the pull request is marked open at the end. A retry of the same export goes on in the same pull request. While an export or upload runs, a host unit holds a logind sleep inhibitor so the Frame stays awake with the headset off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Remote Access: check the status every 5 s instead of every 2 s While its window was open, Frametop Remote Access ran remote-ctl.sh status every 2 s, and each run spawns bash, curl (the tailnet name from tailscaled) and python3 to parse it. It now checks every 5 s, plus when the window comes to the front and once more 2 s after turning remote access on or off or changing the password, so a change still shows within a couple of seconds. A check doesn't start while one is still running. Doing the check in-process would duplicate remote-ctl.sh's idea of "running", which the session and the pause code share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * update-check: KWin's blur and contrast effect ids, retest hints The session now turns KWin's blur and contrast effects off by id (blurEnabled and contrastEnabled in the desktop's kwinrc), and a KWin that renamed them would quietly leave them on. The check looks for their built-in factories (KWin::blur_factory, KWin::contrast_factory) in kwin_wayland, which it already reads for --output-count, and warns if one is gone. The kwin and plasma-workspace retest hints gain the blur and the hidden autostart entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pause gesture: look the controllers up every 30 s, not every 3 s The gesture reader fetched vrserver's /input/getstate.json over HTTP every 3 seconds, the whole time the relay runs, to notice a controller's root path changing when the 3D mouse takes or gives back its hand role. It now looks them up when it connects, when a message comes from a device path it doesn't know (at most every 3 s; the device is read from the message with two string searches, not a JSON parse of all 160 a second), 1.5 s after the relay's 3D mouse connects or lets go (the relay tells it through GamePause.controllers_changed), and otherwise every 30 s. The keys test's pause stub gets the new method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Input relay: send mouse motion at most every 4 ms The relay sent the helper one "move" per SYN_REPORT, so a 1000 Hz mouse sent 1000 datagrams a second to a helper whose loop runs every 8 ms, and each one went through a dozen sscanf and strncmp tests in the helper before reaching the move handler. In a 200 ms test at 1000 Hz, 149 reports now make 45 moves with the same total. flush() on a report now sends only once 4 ms have passed since the last move; tick() sends the rest when due, and the select timeout shrinks to match. Buttons and the gaze keys still flush first, unconditionally, so a click lands where the pointer was. In the helper, "move" is now tested first in the command dispatch, and its handling is one lambda. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: ft-gaze prints and reads only the sources in use ft-gaze computed and printed all six sources for every sample: about 1.3 KB of JSON a line with our tracker (practice2), 120 KB a second through podman's stdio relay for ft-gazed to json.loads 90 times a second. It also read SteamVR's gaze action for every sample outside games (UpdateActionState and GetEyeTrackingDataRelativeToNow, two calls into vrserver, 180 a second), though with our tracker ft-gazed only uses own and mmap1. - ft-gaze takes --sources LIST (action, mmap1, mmap2, left, right, own, and eye for the EYE object; all by default, so the probe and ft-eyes-session are unchanged), and with --watch-stdin a line "sources LIST" on stdin switches them. A source left out isn't read and prints as {"ok":0} ("eye" as null), so every line keeps the same keys. An older ft-gaze ignores both, and prints everything as before. - ft-gazed asks for what it reads: own,mmap1 with our tracker; left,right,mmap1 with SteamVR's eyes; the source plus mmap1 and mmap2 on the older one-source path. While a check or the calibration runs or waits to open, all of them, since checks record every source (the calibration fits the action's correction too) and the fit check reads "eye". It switches as soon as that changes, well inside the check's 0.45 s settle. - So the action is read only during checks, or with --source action. On recorded samples, a line with own and mmap1 is about 700 bytes instead of 1,300 (practice2), and one with left, right and mmap1 about 550 instead of 940 (test1). gaze/test/idle-test.py now checks that ft-gaze starts with every source for a check and is then switched to those in use, without the action or own; all its checks pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: don't put a vanished panel back in the visibility map The panel-edge test read visible[edgeKey], and when the last panel the cursor touched was gone from the overlay list, that added it back as hidden. The map then had more entries than there are handles, which made the 50 ms visibility poll run every frame, and since the last commit it also counted as a visibility change each time, so unchanged frames were never reused. The edge test now looks the key up without adding it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: ft-gaze's loop runs every 4 ms instead of 2 ft-gaze's loop slept 2 ms, so 500 times a second it read the head pose (GetDeviceToAbsoluteTrackingPose), checked the eye tracker's counter, and drained SteamVR's events, for samples that come 90 times a second. It now sleeps 4 ms. A new sample is printed within 4 ms of appearing, 2 on average (was 1), and the pose history still has a pose within 2 ms of any sample's time, which keeps the head-pose error under 0.2 degrees for a head turning 100 degrees a second. Sleeping until the next sample is due would have thinned the pose history to 11 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Gaze: the hidden panel waits for a command instead of waking every 50 ms The calibration panel runs for as long as the gaze service does, hidden nearly all the time, and it woke 20 to 30 times a second to look at its socket and SteamVR's events: about 0.9% of a core, the main cost left with gaze idle. It now waits in poll() on its command socket: up to a second while hidden, and up to 10 ms while shown, as before (it still drains SteamVR's events each pass, so a quit is acknowledged within a second while hidden). A command wakes it at once, so "show" draws sooner than before. With --watch-stdin, its stdin closing wakes it as well, so stopping it doesn't wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pointer: ft-screens announces new panels to the helper The helper now reads SteamVR's list of panels every 20 s instead of every second, so a panel made in between (a floating window's menu, frametop.float.N.sub.K, or the Frametop keyboard the first time it opens) couldn't be clicked with the mouse until the next read. ft-screens now sends "overlay <key>" to @ft_pointer_helper right after it makes one, and the helper adds it to its list at once (only frametop.* keys). An older helper ignores it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hands: fix Export doing nothing, and show it's busy at onceaf2ea7cput _stay_awake between exportSession and its @Slot, so the window's Export button called a method QML couldn't see. A new test checks every backend call in main.qml against Backend's slots and properties. Export and Upload now say Exporting…/Uploading… with a spinner the moment they're pressed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lazy susan: Meta+Alt+Tab spins the panels around you - ft-screens "spin next|prev|<degrees>": every unpinned screen and floating window turns together about a vertical axis through your head (0.3 s, eased), so the next panel on the right or left comes to straight ahead; the arrangement stays as it is. Taps during a spin add to it, from where the panels are headed; grabbing a panel or placing it (ft-layout, ft-floatd) takes it out of the spin - when a spin settles, the panel in front gets the pointer (recenter), typing (as after a click), and KWin's active window: its floating window, or the top window on a screen (ft-floatd "front N", the KWin script's activate-output). KWin's outputs follow the screens' new places (ft-layout scale), as after a move - the input relay: spin_next and spin_prev actions, Meta+Alt+Tab and Meta+Alt+Shift+Tab by default; Frametop Input Settings lists them. Not Meta+Tab: that's Cmd+Tab on a Mac reached through a remote desktop like RustDesk, and the relay would take the Mac's app switcher. Meta+Alt+Tab (Cmd+Option+Tab) is unused on macOS, Windows, and KDE Used on the Frame (SteamOS 0.3.0 build 20260922) with one screen and three or four floating windows, through RustDesk to a Mac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder: login command works from Frametop's Konsole Frametop's Konsole sets XDG_RUNTIME_DIR=/run/user/UID/frametop, where podman finds no container state, so 'distrobox enter dev -- hf auth login' failed with a crun error. The command the Upload page shows now sets the real runtime folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder: log in from the Upload page, three-step page, no terminal The Upload page is now three numbered steps: choose the export, log in to Hugging Face, upload. Log in runs hub.py login, huggingface_hub's browser login (OAuth device code, as hf auth login does): the link opens in the browser and the page shows the code to enter, with Copy code and Cancel. hub.py saves the token; the window never sees one, and nobody pastes one. The terminal upload and the login command are gone from the page, and UPLOAD.md is now a short 'About uploading' under the steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder: take.json keeps camera clock samples sets.bin's capture_ns is CLOCK_MONOTONIC_RAW; poses.jsonl and prompts.jsonl are CLOCK_MONOTONIC. On 2026-10-03 the two were 0.80 s apart during a session and 1.11 s apart five hours later, so images can't be paired with poses by capture_ns. take.json now samples RAW minus MONOTONIC as each recording part starts and stops (as ft-hands' raw_minus_mono_ns), so readers can put each exposure on the poses' clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder export: no controller poses without controllers, nothing in deleted ranges When the checklist says no controllers, exported poses.jsonl has left and right null and feedback lines carry no controller state: controllers left switched on still get tracked (one wandered 2 m in a real session) and would read as the hands' ground truth. Poses and live-tracker feedback inside deleted ranges are left out too, as the images there are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * hand recorder: final consent text (2026-10-03), residency check, installer Consent 2026-10-03, after a non-lawyer review: who runs this and how to reach them, the dataset is public (Hugging Face, possibly abroad), the Hugging Face username shows next to the contributor id, purposes (no identification), safety, the maintainer grant passes to whoever maintains Frametop next, withdrawal before and after merge, rights such as the GDPR's, and what a new version means. Residents of Illinois, Texas and Washington can't take part for now (biometric privacy laws): a third checkbox, profile consent.region_ok, checked by validate.py from this consent version on. The DRAFT banners are gone, so uploads no longer need FT_HANDREC_ALLOW_UPLOAD. hands/rec/install.sh installs the recorder on a Frame with Frametop: container packages, hand tracking and panel builds, ft-camd's capabilities, menu entry. test_qml_backend also checks each call's argument count against the slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: a reset button next to the grab bar, clickable in VR games Each desktop screen gets a reset button left of its bar (a reticle). It puts every screen back in its layout around where you are now, like Meta+Shift+R (ft-layout apply). In a VR game the screens leave the controllers to the game (the outside_games and dashboard modes), so a controller couldn't click any of their controls. Aiming a hand controller at the reset button now sets MakeOverlaysInteractiveIfVisible on that button's overlay alone, so the trigger clicks it; the flag clears half a second after the aim leaves a zone twice as wide, and the game gets the controllers back. The aim comes from the laser poses ft-screens already reads to show the controls. The ft-layout spawn is now RunLayout(cmd), shared with the arrange. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Click stability: 32 logical pixels by default, not 8 8 is about 0.2 degrees on a 3.4 m wide 3440-pixel screen 2 m away, so a trigger press turned into a drag unless the hand was very still. 32 (about 0.9 degrees) felt much better in the headset (2026-10-03). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: in games, pointing a controller at a panel turns its laser on SteamVR's own floating windows take the laser while a controller points at them in a game and give it back when it points away. Frametop's panels didn't: with the controllers left to the game (outside_games, the default, or dashboard), they couldn't be clicked without the dashboard. ft-screens now sets MakeOverlaysInteractiveIfVisible on a screen or floating window while a hand controller's laser pose meets it, its controls, or its popups (UpdateAim; curved screens hit on their cylinder), and clears it 0.3 s after the aim leaves a wider margin. A drag or a held button keeps it on. The keyboard, one overlay, uses ComputeOverlayIntersection and now follows the mode when a game starts or ends while it's open. This replaces the reset button's own aim zone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: find the controllers' laser tip during VR games too GetComponentStateForDevicePath with no input source handle fails for every render model component while a VR game runs (checked 2026-10-03 with a game up: all 21 components of frame_controller_right). TipOffset then fell back to the controller's pose, which aims 40 degrees above the Frame controller's laser. In games, pointing at a screen's middle missed it and pointing below it hit, so the new aim-to-laser only worked from the bottom; the controls' reveal and pin/roll aim were off the same way. GetComponentState still answers then, with the same tip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * input-relay: Add mute key as volume key Add KEY_MUTE as volume key. It will be mapped to KEY_MACRO28 and use wpctl to toggle the mute of the default audio sink. The toggle of the mute state will be done once when the key is pressed instead of continuously toggling it when it is held down. This allows the mute button on keyboards to work properly. Signed-off-by: SuperTuxii <123881249+SuperTuxii@users.noreply.github.com> * Session: bring back a taskbar saved on a screen the desktop doesn't have Plasma 6.2.5 keeps a panel on a screen number and never moves one whose number is past the screen count, so a taskbar saved on a spare output (#18, lastScreen=8 with three screens) or on a screen a smaller layout dropped stayed hidden. Before Plasma starts, session/fix-panels.py moves such a panel and its tray's containment to screen 0 (the primary), keeping its widgets, unless screen 0 already has a panel on that edge. doctor.sh checks the panels' screens, and report.sh lists them with the live outputs and panels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * keys-test: the spin bindings (Meta+Alt+Tab, Meta+Alt+Shift+Tab), not while paused Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Menu entries: own programs for Reset Screen Layout and Hide/Show Screens Reset Screen Layout and Hide/Show Screens both ran ft-layout. Steam lists entries by program, so Hide/Show launched Reset. Each gets a wrapper. From PR #17 (only this part of 9618be8; its host_command change is for the Nix packages). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Screens: release a held button that can't come up on a screen Pausing, or hiding the screen a button went down on, took the laser off it mid-click; the pause gesture's second thumbstick click does that. SteamVR's laser mouse then forgot the button ("Mouse down count is 1 but states are all false"), no release came, and the catcher kept showing whenever the pressing laser was off the panels, even while paused. Being interactive, it kept the VR game's controllers from it until the desktop restarted (2026-10-04, Beat Saber). ft-screens now releases a held button when it's paused, when the screen it went down on is hidden, or, during VR games, when the pressing hand controller has held nothing for a second. GetControllerState answers overlay apps only while a game runs; outside games a hold is never cut. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Signed-off-by: SuperTuxii <123881249+SuperTuxii@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Codex <codex@localhost> Co-authored-by: CuriousJ <curious.j.tuber@gmail.com> Co-authored-by: Patrick McDavid <fusionjunky@gmail.com> Co-authored-by: SuperTuxii <123881249+SuperTuxii@users.noreply.github.com> Co-authored-by: John Murray <5672686+JRMurr@users.noreply.github.com>
2588 lines
117 KiB
Python
Executable File
2588 lines
117 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, 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) 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
|
|
|
|
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 select
|
|
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)
|
|
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")
|
|
SCRIPT_PATH = os.path.join(HERE, "script.json")
|
|
POSES_DIR = os.path.join(HERE, "poses") # the pose pictures: poses.json and its PNGs
|
|
BASE_DIR = os.path.expanduser("~/.local/share/frametop/hands/contrib")
|
|
PANEL_SOCKET = "ft_handpanel"
|
|
CAMD_UNIT = "frametop-handrec-camd.service"
|
|
HANDS_UNIT = "frametop-handrec-hands.service"
|
|
|
|
RECORD_HZ = 10
|
|
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"
|
|
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"
|
|
KEYS_STEP_BUTTON = ("Headset button: next, pause \u00b7 Window: Space next \u00b7 P pause \u00b7 R redo "
|
|
"\u00b7 S skip \u00b7 Esc stop")
|
|
KEYS_AUTO_BUTTON = "Headset button: pause \u00b7 Window: P pause \u00b7 R redo \u00b7 S skip section \u00b7 Esc stop"
|
|
# 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.")
|
|
|
|
|
|
def mono_ns():
|
|
return time.clock_gettime_ns(time.CLOCK_MONOTONIC)
|
|
|
|
|
|
def clock_sample():
|
|
"""[CLOCK_MONOTONIC ns, CLOCK_MONOTONIC_RAW - CLOCK_MONOTONIC ns], as ft-hands' raw_minus_mono_ns().
|
|
sets.bin's capture_ns is on the RAW clock and everything else on MONOTONIC; the two drift
|
|
apart with NTP's corrections (0.8 s apart and ~10 ppm on 2026-10-03), so take.json keeps
|
|
samples to turn capture_ns into MONOTONIC: capture_ns - offset, interpolated by time."""
|
|
a = time.clock_gettime_ns(time.CLOCK_MONOTONIC)
|
|
r = time.clock_gettime_ns(time.CLOCK_MONOTONIC_RAW)
|
|
b = time.clock_gettime_ns(time.CLOCK_MONOTONIC)
|
|
mid = (a + b) // 2
|
|
return [mid, r - mid]
|
|
|
|
|
|
def run_dir():
|
|
return "/run/user/%d/frametop-hands" % os.getuid()
|
|
|
|
|
|
def in_container():
|
|
return os.path.exists("/run/.containerenv") or os.path.exists("/.dockerenv")
|
|
|
|
|
|
def host_command(*cmd):
|
|
"""argv to run a command on the SteamOS host from the dev container (as in
|
|
display-settings: distrobox-host-exec needs the user's real session bus). It runs from the
|
|
home folder: host-spawn starts the command in the caller's folder, and a container-only
|
|
one such as /run/host/tmp doesn't exist on the host (every command then exits 127)."""
|
|
exe = shutil.which("distrobox-host-exec")
|
|
if not in_container() or not exe:
|
|
return list(cmd)
|
|
return ["env", "-C", os.path.expanduser("~"), "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%d/bus" % os.getuid(),
|
|
exe] + list(cmd)
|
|
|
|
|
|
def host_path(path):
|
|
"""A host file, from the container (/run/host) or the host itself."""
|
|
for p in ("/run/host" + path, path):
|
|
if os.path.exists(p):
|
|
return p
|
|
return None
|
|
|
|
|
|
def write_json(path, data):
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
json.dump(data, f, indent=1)
|
|
f.write("\n")
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def clean_text(s):
|
|
"""Text for a panel command: one line; "|" is the panel's line break, so callers use it."""
|
|
return " ".join(str(s).replace("\r", " ").replace("\n", " ").split())
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# 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
|
|
|
|
|
|
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."""
|
|
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 unit_active(unit):
|
|
return subprocess.run(host_command("systemctl", "--user", "-q", "is-active", unit),
|
|
capture_output=True, timeout=30).returncode == 0
|
|
|
|
|
|
def start_unit(unit, what, argv, log=lambda line: None):
|
|
"""Start argv as a transient user unit that stops with SteamVR. True if it started it,
|
|
False if it was running already; raises RuntimeError if it couldn't."""
|
|
if unit_active(unit):
|
|
log("%s is running already" % unit)
|
|
return False
|
|
cmd = host_command("systemd-run", "--user", "--quiet", "--collect", "--unit=" + unit,
|
|
"--description=Frametop hand recorder: " + what,
|
|
"-p", "PartOf=steamvr.service", "-p", "After=steamvr.service",
|
|
"-p", "Restart=on-failure", "-p", "RestartSec=3", "-p", "TimeoutStopSec=5", *argv)
|
|
for attempt in range(3): # distrobox-host-exec has failed once, silently, and worked again
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
if r.returncode == 0 or unit_active(unit):
|
|
log("started %s" % unit)
|
|
return True
|
|
log("starting %s failed (exit %d): %s" % (unit, r.returncode, (r.stderr or r.stdout).strip()))
|
|
time.sleep(0.5)
|
|
raise RuntimeError("couldn't start %s (exit %d): %s" % (unit, r.returncode, (r.stderr or r.stdout).strip()))
|
|
|
|
|
|
def stop_unit(unit):
|
|
subprocess.run(host_command("systemctl", "--user", "stop", unit), capture_output=True, timeout=30)
|
|
|
|
|
|
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: 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: 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)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# 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:
|
|
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)
|
|
|
|
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):
|
|
"""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", str(RECORD_HZ), "--status", "0",
|
|
"--sides", "auto" if swap is None else "1" if swap else "0"]
|
|
if ring:
|
|
argv += ["--ring", ring]
|
|
if not in_container():
|
|
argv = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--"] + 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 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 headset's button, and mice (/proc/bus/input/devices, linux/input.h)
|
|
#
|
|
# The Frame's click button on its right side is KEY_SELECT on the "gpio-keys" device. It's read
|
|
# as any other reader does, never grabbed (EVIOCGRAB): Frametop's input relay, ft-powerd, SteamVR
|
|
# and gamescope read the same device, and the relay remaps its volume keys.
|
|
|
|
INPUT_DEVICES = "/proc/bus/input/devices"
|
|
INPUT_EVENT = struct.Struct("@llHHi") # struct input_event: a timeval, type, code, value (24 bytes on 64-bit)
|
|
EV_KEY, EV_REL = 0x01, 0x02
|
|
REL_X, REL_Y = 0x00, 0x01
|
|
KEY_SELECT = 353
|
|
BUTTON_DEVICE = "gpio-keys"
|
|
BUTTON_DEBOUNCE_S = 0.3 # presses closer than this count once
|
|
|
|
|
|
def parse_input_devices(text):
|
|
"""/proc/bus/input/devices as [{"name", "phys", "sysfs", "bus", "handlers": [...], "bits": {"EV": int, ...}}]."""
|
|
out, dev = [], None
|
|
for line in text.splitlines() + [""]:
|
|
line = line.strip()
|
|
if not line:
|
|
if dev:
|
|
out.append(dev)
|
|
dev = None
|
|
continue
|
|
if dev is None:
|
|
dev = {"name": "", "phys": "", "sysfs": "", "bus": 0, "handlers": [], "bits": {}}
|
|
tag, _, rest = line.partition(": ")
|
|
if tag == "I":
|
|
m = re.search(r"Bus=([0-9a-fA-F]+)", rest)
|
|
dev["bus"] = int(m.group(1), 16) if m else 0
|
|
elif tag == "N":
|
|
dev["name"] = rest.partition("=")[2].strip('"')
|
|
elif tag == "P":
|
|
dev["phys"] = rest.partition("=")[2]
|
|
elif tag == "S":
|
|
dev["sysfs"] = rest.partition("=")[2]
|
|
elif tag == "H":
|
|
dev["handlers"] = rest.partition("=")[2].split()
|
|
elif tag == "B":
|
|
key, _, words = rest.partition("=")
|
|
value = 0
|
|
for w in words.split(): # the highest long first
|
|
try:
|
|
value = (value << 64) | int(w, 16)
|
|
except ValueError:
|
|
value = 0
|
|
break
|
|
dev["bits"][key] = value
|
|
return out
|
|
|
|
|
|
def _event_node(dev):
|
|
for h in dev["handlers"]:
|
|
if re.fullmatch(r"event\d+", h):
|
|
return "/dev/input/" + h
|
|
return None
|
|
|
|
|
|
def find_button(devices):
|
|
"""The headset button's event device: "gpio-keys" with KEY_SELECT, or None."""
|
|
for dev in devices:
|
|
if dev["name"] == BUTTON_DEVICE and dev["bits"].get("KEY", 0) >> KEY_SELECT & 1:
|
|
return _event_node(dev)
|
|
return None
|
|
|
|
|
|
def real_mouse(dev):
|
|
"""A pointing device a person holds: relative X and Y, not made in software. uinput devices
|
|
(Frametop's virtual mouse, frame-voice's keyboard) sit under /devices/virtual/input or on the
|
|
virtual bus (6); Bluetooth mice come through uhid, also under /devices/virtual, so those count."""
|
|
bits = dev["bits"]
|
|
if not (bits.get("EV", 0) >> EV_REL & 1) or (bits.get("REL", 0) & 3) != 3: # REL_X and REL_Y
|
|
return False
|
|
sysfs = dev["sysfs"]
|
|
if dev["bus"] == 0x06 or "virtual" in dev["name"].lower() or "uinput" in dev["phys"]:
|
|
return False
|
|
return not sysfs.startswith("/devices/virtual/") or sysfs.startswith("/devices/virtual/misc/uhid/")
|
|
|
|
|
|
def read_input_devices(path=INPUT_DEVICES):
|
|
try:
|
|
with open(path) as f:
|
|
return parse_input_devices(f.read())
|
|
except OSError:
|
|
return []
|
|
|
|
|
|
def mouse_connected(path=INPUT_DEVICES):
|
|
return any(real_mouse(d) for d in read_input_devices(path))
|
|
|
|
|
|
def button_presses(data):
|
|
"""KEY_SELECT key-downs in a run of input_event structs (value 1; releases and autorepeat
|
|
are left out). Returns (how many, the bytes left over after the last whole event)."""
|
|
n, usable = 0, len(data) - len(data) % INPUT_EVENT.size
|
|
for off in range(0, usable, INPUT_EVENT.size):
|
|
_, _, etype, code, value = INPUT_EVENT.unpack_from(data, off)
|
|
if etype == EV_KEY and code == KEY_SELECT and value == 1:
|
|
n += 1
|
|
return n, data[usable:]
|
|
|
|
|
|
class ButtonReader:
|
|
"""Reads the headset button on a thread and calls on_press() per press, debounced. path:
|
|
an event device or, for testing, a FIFO carrying input_event structs. It's opened read-only
|
|
and shared; if it can't be opened (no device, no permission, /dev/input not reachable in a
|
|
container) it says so in the log and tries again now and then."""
|
|
|
|
def __init__(self, path, on_press, log=None, debounce_s=BUTTON_DEBOUNCE_S):
|
|
self.path, self.on_press, self.log = path, on_press, log or (lambda s: None)
|
|
self.debounce_s = debounce_s
|
|
self.ok = False # opened at least once
|
|
self._stop = threading.Event()
|
|
self._last = -1e9
|
|
self._thread = threading.Thread(target=self._run, name="handrec-button", daemon=True)
|
|
|
|
def start(self):
|
|
self._thread.start()
|
|
return self
|
|
|
|
def stop(self):
|
|
self._stop.set()
|
|
self._thread.join(2)
|
|
|
|
def _press(self, n):
|
|
now = time.monotonic()
|
|
if n and now - self._last >= self.debounce_s:
|
|
self._last = now
|
|
self.on_press()
|
|
|
|
def _run(self):
|
|
failed = False
|
|
while not self._stop.is_set():
|
|
try:
|
|
fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC)
|
|
except OSError as e:
|
|
if not failed:
|
|
self.log("headset button: can't open %s (%s): the window's keys still work" % (self.path, e.strerror))
|
|
failed = True
|
|
self._stop.wait(5)
|
|
continue
|
|
if failed or not self.ok:
|
|
self.log("headset button: reading %s" % self.path)
|
|
self.ok, failed = True, False
|
|
rest = b""
|
|
try:
|
|
while not self._stop.is_set():
|
|
r, _, _ = select.select([fd], [], [], 0.2)
|
|
if not r:
|
|
continue
|
|
try:
|
|
data = os.read(fd, INPUT_EVENT.size * 64)
|
|
except BlockingIOError:
|
|
continue
|
|
if not data: # a FIFO's writer left: open it again
|
|
self._stop.wait(0.2)
|
|
break
|
|
n, rest = button_presses(rest + data)
|
|
self._press(n)
|
|
except OSError as e: # the device went away
|
|
self.log("headset button: %s: %s" % (self.path, e.strerror))
|
|
self._stop.wait(2)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# The pose pictures (poses/poses.json: {"<pose>": {"file": "<name>.png", "two_hands", "caption"}})
|
|
|
|
def load_poses(poses_dir):
|
|
"""{pose id: {"path", "two_hands", "caption"}} from poses_dir/poses.json, or {} without one."""
|
|
try:
|
|
with open(os.path.join(poses_dir, "poses.json")) as f:
|
|
raw = json.load(f)
|
|
except (OSError, ValueError):
|
|
return {}
|
|
out = {}
|
|
for pose, e in (raw.items() if isinstance(raw, dict) else ()):
|
|
if isinstance(e, dict) and isinstance(e.get("file"), str) and e["file"]:
|
|
out[pose] = {"path": os.path.join(poses_dir, os.path.basename(e["file"])),
|
|
"two_hands": bool(e.get("two_hands")), "caption": clean_text(e.get("caption", ""))}
|
|
return out
|
|
|
|
|
|
def pose_view(poses, p):
|
|
"""A prompt's picture: (path, mode, caption), mode "" (as drawn: a right hand, or both hands),
|
|
"mirror" (a left hand) or "both" (a mirrored copy on the left, the picture on the right).
|
|
The prompt's "picture" (a pose id) picks a picture other than its pose's, and
|
|
"picture_mirror" mirrors a two-hand picture. ("", "", "") when there's none."""
|
|
e = poses.get(p.get("picture") or p.get("pose") or "")
|
|
if not e or not os.path.isfile(e["path"]):
|
|
return "", "", ""
|
|
hands = p.get("hands")
|
|
if e["two_hands"]:
|
|
mode = "mirror" if p.get("picture_mirror") else ""
|
|
else:
|
|
mode = "mirror" if hands == "left" else "both" if hands == "both" else ""
|
|
return e["path"], mode, e["caption"]
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# The script
|
|
|
|
HAND_VALUES = ("left", "right", "both", "none", "any", "")
|
|
PROMPT_KEYS = ("text", "seconds", "hands", "pose", "distance", "position", "object", "controller")
|
|
|
|
|
|
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 [])
|
|
if p["hands"] not in HAND_VALUES:
|
|
raise ValueError("%s: hands %r" % (section["id"], p["hands"]))
|
|
return p
|
|
|
|
|
|
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 ("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 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
|
|
|
|
|
|
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]
|
|
_set_ids(s, prompts)
|
|
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") 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")]
|
|
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
|
|
|
|
|
|
def _git_describe():
|
|
try:
|
|
r = subprocess.run(["git", "-C", REPO, "describe", "--always", "--dirty", "--tags"],
|
|
capture_output=True, text=True, timeout=5)
|
|
return r.stdout.strip() or "unknown"
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return "unknown"
|
|
|
|
|
|
def _os_version():
|
|
path = "/run/host/etc/os-release" if in_container() else "/etc/os-release"
|
|
try:
|
|
with open(path) as f:
|
|
for line in f:
|
|
if line.startswith("VERSION_ID="):
|
|
return line.split("=", 1)[1].strip().strip('"')
|
|
except OSError:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def _steamvr_version():
|
|
for db in ("/usr/lib/holo/pacmandb", "/var/lib/pacman"): # SteamOS keeps it in the image
|
|
found = sorted(glob.glob(("/run/host" if in_container() else "") + db + "/local/deckard-steamvr-rel-*"))
|
|
if found:
|
|
return os.path.basename(found[-1])[len("deckard-steamvr-rel-"):]
|
|
p = host_path("/opt/steamvr/bin/version.txt")
|
|
if p:
|
|
try:
|
|
with open(p) as f:
|
|
return "build " + f.read().strip()
|
|
except OSError:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def camera_check():
|
|
"""camcheck.check() (the XRService log, its open cameras where readable, ft-camd's ring);
|
|
never raises: a failure is "unknown"."""
|
|
try:
|
|
return camcheck.check()
|
|
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
|
|
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):
|
|
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.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)
|
|
# 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.session_dir = ""
|
|
self._thread = None
|
|
self._lock = threading.Lock()
|
|
self._want = {"pause": False, "skip": False, "stop": False, "next": False, "redo": 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": "",
|
|
"ready_text": READY_TEXT, "button": False, "mouse": True,
|
|
"camera": None, "nohands": False, "strip": [], "cue": -1, "quick": self.quick}
|
|
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_press(self):
|
|
"""The headset's button: 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 redo(self):
|
|
"""Record a step again: the one running, or at a step's ready screen the one before."""
|
|
self._set("redo", True)
|
|
|
|
def pause(self):
|
|
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:
|
|
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.dry_run or self.ring or not self.check_cameras:
|
|
return False
|
|
r = self.camera_check_fn()
|
|
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: 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")
|
|
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": "ft-handrec " + _git_describe(), "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"), "sections": [s["id"] for s in self.plan],
|
|
"skipped": self.skipped},
|
|
"mode": "auto" if self.auto else "step", "quick": self.quick,
|
|
"shuffle": {"seed": self.seed, "sweeps": plan_record(self.plan)},
|
|
"takes": [], "status": "recording", "sides": {"swapped": None}}
|
|
self._read_sides(force=True)
|
|
cam = self._status.get("camera")
|
|
if cam:
|
|
self._session_json["camera"] = {"status": cam.get("status"), "reason": cam.get("reason", "")}
|
|
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"),
|
|
("big", "big "), ("action", "action "), ("rec", "rec off"), ("strip", "strip 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_press, log=self._log, debounce_s=BUTTON_DEBOUNCE_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"])))
|
|
|
|
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):
|
|
up = os.path.join(REPO, "scripts", "container-up.sh")
|
|
if os.access(up, os.X_OK):
|
|
subprocess.run(host_command(up), capture_output=True, timeout=120)
|
|
argv = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--", FT_HANDS,
|
|
"--no-gestures", "--status", "0"]
|
|
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 _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: 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)
|
|
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()
|
|
self._log("%s%s" % (state, ": " + error if error else ""))
|
|
if self._stop_note and state == "stopped":
|
|
self._log(self._stop_note)
|
|
self._stop_units()
|
|
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._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)
|
|
|
|
# --- 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")
|
|
self._panel.cmd("paused on")
|
|
self._panel.set("note", "note " + RESUME_HINT)
|
|
self._emit(state="paused", note=RESUME_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 _Fail("The recording stopped by itself: see session.log")
|
|
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 and where-to diagram, on the panel and in the status (sent with
|
|
the next _emit); none without a prompt."""
|
|
p = p or {}
|
|
path, mode, caption = ("", "", "") if p.get("cues") else pose_view(self._poses, p)
|
|
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 _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 = bool(self._button and 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)
|
|
|
|
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())
|
|
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._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("cue"): # a sweep's cue: the pose highlighted from now, within the step
|
|
extra.update(cue=True, step=p["step"])
|
|
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)
|
|
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")
|
|
self._wait(p["seconds"])
|
|
|
|
# --- 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": "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 _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 _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)
|
|
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")):
|
|
self._panel.set(key, c)
|
|
self._emit(state="ready", prompt=p["text"].replace("|", "\n"), seconds_left=float(p["seconds"]),
|
|
can_redo=can_redo, countdown=0, big="")
|
|
|
|
def _countdown(self, i, s, st):
|
|
"""Step mode: start recording and count 3-2-1 (logged as a "ready" event, so labels
|
|
cover only the hold), then make sure the recording has its first set. Returns the
|
|
ready event's time."""
|
|
p = st["p"]
|
|
if not self._take:
|
|
self._start_take(i, s, record=False)
|
|
t = self._event("ready", id=p["id"], seconds=COUNTDOWN_S)
|
|
self._step_t0 = t
|
|
self._start_recording()
|
|
tick = None
|
|
if st["kind"] == "bar": # the bar at near, where the sweep starts
|
|
labels = "%s|%s" % (s.get("near_label", "Near"), s.get("far_label", "Far"))
|
|
near, far = float(s.get("near_m", 0.2)), float(s.get("far_m", 0.6))
|
|
|
|
def tick(dt):
|
|
self._panel.set("bar", "bar 0.000 %.3f %s" % (self._palm_share(near, far), labels))
|
|
return False
|
|
for n in range(COUNTDOWN_S, 0, -1):
|
|
self._panel.set("big", "big %d" % n)
|
|
self._emit(state="countdown", countdown=n, big=str(n), can_redo=True)
|
|
self._wait(1.0, tick, countdown=False)
|
|
self._first_set()
|
|
self._panel.set("big", "big " + s.get("go", "Go"))
|
|
self._status["big"] = s.get("go", "Go") # sent with the prompt
|
|
return t
|
|
|
|
def _first_set(self):
|
|
"""The hold starts once its recording has a set. ft-hands --record-only writes its first
|
|
within about 30 ms of starting, so the countdown covers it; this is a safety net."""
|
|
rec = self._recorder
|
|
if not rec:
|
|
return
|
|
end = time.monotonic() + FIRST_SET_S
|
|
while not rec.has_data():
|
|
if rec.exited():
|
|
raise _Fail("The recording stopped by itself: see session.log")
|
|
if self._want["stop"]:
|
|
raise _Stop()
|
|
if time.monotonic() > end:
|
|
self._log("recording part %d: no set yet after the countdown" % rec.part)
|
|
return
|
|
time.sleep(0.02)
|
|
|
|
def _hold_end(self):
|
|
"""Step mode: the step is over. A "wait" event ends its labels and the recording stops
|
|
until the next countdown."""
|
|
self._event("wait")
|
|
self._stop_recording()
|
|
self._status["big"] = ""
|
|
for key, c in (("big", "big "), ("countdown", "countdown off"), ("bar", "bar off"), ("target", "target off")):
|
|
self._panel.set(key, c)
|
|
|
|
def _run_step(self, s, st, where):
|
|
if st["kind"] != "bar":
|
|
self._panel.set("bar", "bar off")
|
|
if st["kind"] == "target":
|
|
self._target(s, st, where)
|
|
elif st["kind"] == "bar":
|
|
self._bar(s, st, where)
|
|
elif st["kind"] == "sweep":
|
|
self._sweep(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 _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 main():
|
|
ap = argparse.ArgumentParser(description="Run a hand recording session (the hand recorder's session runner).")
|
|
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("--no-headset-button", action="store_true",
|
|
help="don't read the headset's button (gpio-keys KEY_SELECT: Next, pause, resume)")
|
|
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
|
|
profile = {}
|
|
try:
|
|
with open(os.path.join(base, "profile.json")) as f:
|
|
profile = json.load(f)
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
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["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, a.script, ring=a.ring, start_processes=not a.no_start,
|
|
dry_run=a.dry_run, speed=a.speed, on_status=on_status, hands_dir=a.hands_dir, panel_bin=a.panel,
|
|
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)
|
|
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", 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)
|
|
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())
|