Files
DeeJanuz--frametop/hands/tools/ring.py
T
DeeJanuzandClaude Opus 5.5 499035216c 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>
2026-09-30 09:15:07 -06:00

81 lines
3.2 KiB
Python

"""Read frames from ft-camd's shared-memory ring (layout: camd/fhring.h)."""
import mmap
import os
import struct
import time
import numpy as np
RING_FILE = '/run/user/%d/frametop/cam-ring' % os.getuid()
MAGIC = b'FHRING01'
HDR = struct.Struct('<8sIIIIQqQ16x') # 64 bytes
CAM = struct.Struct('<32s32siIIIIIQQQQQ32x') # 160 bytes
SLOT = struct.Struct('<QQQQQIf16x') # 64 bytes
MAX_CAMS = 8
LATEST_OFF = 32 + 32 + 4 * 6 + 8 * 2 # cam.latest within fh_ring_cam_t
HEARTBEAT_OFF = 40
class Frame:
__slots__ = ('cam', 'frame', 'capture_ns', 'dqbuf_ns', 'publish_ns', 'v4l2_seq', 'mean', 'image')
def __init__(self, cam, fields, image):
self.cam = cam
(_, self.frame, self.capture_ns, self.dqbuf_ns, self.publish_ns, self.v4l2_seq, self.mean) = fields
self.image = image
class RingCamera:
def __init__(self, index, fields):
(sensor, name, self.node, self.format, self.width, self.height, self.stride, self.nslots,
self.slot_offset, self.slot_bytes, _latest, _pub, _drop) = fields
self.index = index
self.sensor = sensor.split(b'\0', 1)[0].decode()
self.name = name.split(b'\0', 1)[0].decode()
self.latest_off = HDR.size + index * CAM.size + LATEST_OFF
def __repr__(self):
return 'RingCamera(video%d %s %dx%d)' % (self.node, self.sensor, self.width, self.height)
class Ring:
def __init__(self, path=RING_FILE):
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, hdr_bytes, ncams, _, file_bytes, self.writer_pid, _ = HDR.unpack_from(self.map, 0)
if magic != MAGIC or version != 1:
raise RuntimeError('%s is not an ft-camd ring (magic %r version %d)' % (path, magic, version))
self.cams = [RingCamera(i, CAM.unpack_from(self.map, HDR.size + i * CAM.size)) for i in range(ncams)]
def heartbeat_ns(self):
return struct.unpack_from('<Q', self.map, HEARTBEAT_OFF)[0]
def alive(self, max_age=1.0):
hb = self.heartbeat_ns()
return hb != 0 and (time.clock_gettime_ns(time.CLOCK_MONOTONIC) - hb) / 1e9 < max_age
def latest(self, cam):
return struct.unpack_from('<Q', self.map, cam.latest_off)[0]
def read(self, cam, n=None):
"""Copy frame n (default: the newest) of a camera, or None if it's gone or being written."""
for _ in range(3):
if n is None or n == 0:
n = self.latest(cam)
if n == 0:
return None
off = cam.slot_offset + (n % cam.nslots) * cam.slot_bytes
fields = SLOT.unpack_from(self.map, off)
if fields[0] != 2 * n + 2:
return None
start = off + SLOT.size
image = np.frombuffer(self.map, np.uint8, cam.stride * cam.height, start).reshape(cam.height, cam.stride)
image = image[:, :cam.width].copy()
if struct.unpack_from('<Q', self.map, off)[0] == fields[0]:
return Frame(cam, fields, image)
n = None # overwritten while copying: take the newest
return None