Make hand tracking a Frametop component

- Programs: ft-camd (the camera broker), ft-hands (the tracker), and
  ft-handreplay and ft-ringplay for recordings, built by hands/build.sh
  into hands/build/ with one Makefile. The first build fetches ncnn at
  frame-hands' pinned tag and builds it with the same options.
- ft-camd gets its privileges from file capabilities (CAP_SYS_PTRACE,
  CAP_PERFMON, CAP_DAC_READ_SEARCH) that hands/run.sh install sets with
  sudo, and drops them once set up. It still works under sudo. It runs
  on the host, linked statically, as frametop-camd.service. ft-hands
  runs in the dev container as frametop-hands.service. Both start and
  stop with SteamVR.
- Files move to /run/user/UID/frametop/ (cam-ring, hands, gestures),
  not $XDG_RUNTIME_DIR, which a terminal in the Frametop desktop has
  its own of. SIGUSR1 recordings go to ~/.local/share/frametop/hands.
- The calibration is read through /run/host in the container.
- Settings: HANDS_SWAP_SIDES and HANDS_CPUS in frametop.conf.
- install.sh offers hand tracking as an optional last step.
- The container gets jsoncpp-devel, glibc-static, and NumPy and OpenCV
  for the Python tools.
- tools/ring.py reads the ring, and models/NOTICE credits the
  Apache-2.0 models.

Checked: ft-handreplay gives identical summaries and byte-identical
depth dumps to frame-hands' fh-replay on both 2026-09-29 recordings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-30 09:15:07 -06:00
1 parent 3e3d31c728
commit 499035216c
41 files changed
+727 -402

No files matched your search

+18 -5
View File
@@ -13,6 +13,7 @@ the image.
Everything here returns metres in the head frame.
"""
import json
import os
import numpy as np
@@ -91,11 +92,23 @@ class Camera:
return np.degrees(np.arccos(np.clip(self.unproject(uv)[:, 2], -1, 1)))
def device_path(path):
"""A headset file such as /persist/xrservice.json. In the dev container the host's / is
at /run/host (distrobox doesn't mount /persist); off the Frame, FRAME_JOB_DEVICE_ROOT can
point at a folder with copies of them."""
root = os.environ.get('FRAME_JOB_DEVICE_ROOT')
if root:
return root + path
if not os.access(path, os.R_OK) and os.access('/run/host' + path, os.R_OK):
return '/run/host' + path
return path
def load(xrservice=XRSERVICE_JSON, device=DEVICE_JSON):
"""{calibration name: Camera} for the tracking cameras, posed in the head frame."""
with open(xrservice) as f:
with open(device_path(xrservice)) as f:
rig = json.load(f)
with open(device) as f:
with open(device_path(device)) as f:
dev = json.load(f)
cad_from_cam0 = _pose(dev['cv']['cad_from_cal'])
head_from_cad = np.linalg.inv(_pose(dev['head']))
@@ -110,18 +123,18 @@ def load(xrservice=XRSERVICE_JSON, device=DEVICE_JSON):
def load_color(eeprom=ARCTURUS_EEPROM, device=DEVICE_JSON, scale=2, crop='subtract'):
"""{"passthrough_left"/"passthrough_right": Camera} for the Arcturus color cameras, posed in
the head frame, for fh-camd --with-color's images (luma at 1/scale size).
the head frame, for ft-camd --with-color's images (luma at 1/scale size).
Their calibration is in the CAD frame (mm) with pixel coordinates on the full 2464x2464
sensor; each camera also has a cropRegion. crop says how that maps to the delivered
image: 'subtract' (image x = sensor x - cropRegion.x) or 'none'. tools/check_color.py
tells which fits.
"""
with open(eeprom, 'rb') as f:
with open(device_path(eeprom), 'rb') as f:
raw = f.read()
i = raw.rfind(b'{', 0, raw.find(b'"alignment_method"'))
rig, _ = json.JSONDecoder().raw_decode(raw[i:].decode('latin1'))
with open(device) as f:
with open(device_path(device)) as f:
dev = json.load(f)
head_from_cad = np.linalg.inv(_pose(dev['head']))
cams = {}