mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-12 05:00:38 +02:00
Hand recorder: hooks for the standalone Hand Recorder
frametop-hand-recorder (a separate repo, with this one as a submodule) ships hands/ with its own
dashboard window and binaries built for the SteamOS host. A standalone.json at the top of its tree
marks it (takes.standalone()):
- session.py starts ft-hands directly instead of through distrobox, for recording and tracking
- session.json's and the manifest's tool name its build ("ft-handrec <describe> (<name> <version>)")
- the repair hints say to run its install command again (fix_hint)
ft_handrec.py takes --qml and --style for that window, and has toolVersion and standalone for it.
The Makefile passes LDFLAGS to the ft-hands links (its release links them statically), and
test_qml_backend.py checks another window with FT_HANDREC_QML.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ba49af487
commit
dfd1bbca0f
7 files changed
+138
-31
No files matched your search
+5
-3
@@ -10,6 +10,8 @@ CFLAGS ?= -O2 -g -Wall -Wextra -Wno-unused-parameter
|
||||
CXXFLAGS ?= -O2 -g -Wall -Wextra -Wno-unused-parameter -Wno-psabi
|
||||
CXXFLAGS += -std=c++17 -fopenmp -I$(NCNN)/include/ncnn
|
||||
LDLIBS = $(NCNN)/lib/libncnn.a -ljsoncpp -fopenmp -lpthread
|
||||
# The standalone Hand Recorder's release build passes LDFLAGS=-static (it runs on the host).
|
||||
LDFLAGS ?=
|
||||
|
||||
CAMD = camd/camd.c camd/tp.c camd/xrcams.c
|
||||
TRACK = track/calib.cpp track/nets.cpp track/tracker.cpp track/io.cpp track/record.cpp track/pinch.cpp track/sides.cpp
|
||||
@@ -24,15 +26,15 @@ build/ft-camd: $(CAMD) camd/tp.h camd/xrcams.h camd/fhring.h
|
||||
|
||||
build/ft-hands: track/main.cpp $(TRACK) $(HDR) $(NCNN)/lib/libncnn.a
|
||||
@mkdir -p build
|
||||
$(CXX) $(CXXFLAGS) -o $@ track/main.cpp $(TRACK) $(LDLIBS)
|
||||
$(CXX) $(CXXFLAGS) $(LDFLAGS) -o $@ track/main.cpp $(TRACK) $(LDLIBS)
|
||||
|
||||
build/ft-handreplay: track/replay.cpp $(TRACK) $(HDR) $(NCNN)/lib/libncnn.a
|
||||
@mkdir -p build
|
||||
$(CXX) $(CXXFLAGS) -o $@ track/replay.cpp $(TRACK) $(LDLIBS)
|
||||
$(CXX) $(CXXFLAGS) $(LDFLAGS) -o $@ track/replay.cpp $(TRACK) $(LDLIBS)
|
||||
|
||||
build/sides-test: tests/sides_test.cpp $(TRACK) $(HDR) $(NCNN)/lib/libncnn.a
|
||||
@mkdir -p build
|
||||
$(CXX) $(CXXFLAGS) -o $@ tests/sides_test.cpp $(TRACK) $(LDLIBS)
|
||||
$(CXX) $(CXXFLAGS) $(LDFLAGS) -o $@ tests/sides_test.cpp $(TRACK) $(LDLIBS)
|
||||
|
||||
check: build/sides-test
|
||||
build/sides-test
|
||||
|
||||
@@ -23,6 +23,17 @@ The plan this belongs to is `~/Desktop/Projects/frame-hands/notes/hands-plan.md`
|
||||
|
||||
Every part runs in the dev container, as ft-hands and Input Settings do. The host Python isn't used: it lacks PySide6 and zstd. `setup/dev-container.sh` gains `zstd`.
|
||||
|
||||
### The standalone Hand Recorder
|
||||
|
||||
[frametop-hand-recorder](https://github.com/DeeJanuz/frametop-hand-recorder) is the recorder for people without Frametop. It pins this repo as a submodule and ships the parts above with its own window, a Qt Quick Controls `main.qml` sized for SteamVR's dashboard. Its release builds the binaries for the SteamOS host: ft-camd and ft-hands statically (`make LDFLAGS=-static`), and ft-handpanel against SteamVR's `libopenvr_api`. Everything runs on the host, the Python from a venv (`ft_handrec.py --qml ITS_QML --style Basic`).
|
||||
|
||||
Its tarball keeps this tree's layout (`hands/build/`, `hands/rec/`, `hands/models/`), so the paths here don't change. A `standalone.json` at the top marks it (`takes.standalone()`):
|
||||
- `session.py` starts ft-hands directly, not through distrobox.
|
||||
- session.json's `tool` and the manifest's are `ft-handrec <describe> (frametop-hand-recorder <version>)`.
|
||||
- The repair hints say to run its install command again.
|
||||
|
||||
The recordings go to the same `~/.local/share/frametop/hands/contrib`, so either recorder shows both's sessions.
|
||||
|
||||
## Processes during a session
|
||||
|
||||
- **ft-camd** publishes the camera ring. If it isn't running, the session starts it as the transient user unit `frametop-handrec-camd.service`, the way `hands/ft-cutouts` starts `frametop-cutouts-camd.service` (needs `hands/build/ft-camd` with capabilities: `hands/run.sh caps`). An ft-camd already running from ft-cutouts or ft-handsctl is used as it is.
|
||||
|
||||
+18
-3
@@ -24,7 +24,9 @@ Everything lives under ~/.local/share/frametop/hands/contrib (--base). Nothing i
|
||||
unless the person presses Upload; while CONSENT.md or UPLOAD.md is a draft, Upload stays off
|
||||
unless FT_HANDREC_ALLOW_UPLOAD=1 (the maintainer's rehearsal against a test repo, picked with
|
||||
FT_HANDREC_DATASET). --hub-dry-run does everything but the network calls.
|
||||
Launch with hands/rec/ft-handrec (host wrapper).
|
||||
Launch with hands/rec/ft-handrec (host wrapper). The standalone Hand Recorder
|
||||
(github.com/DeeJanuz/frametop-hand-recorder) runs this backend on the host, from a venv, with
|
||||
its own window for SteamVR's dashboard: --qml its main.qml, --style Basic.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
@@ -292,6 +294,16 @@ class Backend(QObject):
|
||||
def consentText(self):
|
||||
return read_text(CONSENT_PATH) or "CONSENT.md is missing."
|
||||
|
||||
@Property(str, constant=True)
|
||||
def toolVersion(self):
|
||||
"""What session.json records as the tool (takes.tool_version), for the window to show."""
|
||||
return takes.tool_version()
|
||||
|
||||
@Property(bool, constant=True)
|
||||
def standalone(self):
|
||||
"""The standalone Hand Recorder's install, not a Frametop checkout (takes.standalone)."""
|
||||
return takes.standalone() is not None
|
||||
|
||||
@Property(str, constant=True)
|
||||
def consentVersion(self):
|
||||
return consent_version()
|
||||
@@ -1173,6 +1185,9 @@ def main():
|
||||
help="test: Upload checks the export and says what it would send, with no network calls")
|
||||
ap.add_argument("--ignore-cameras", action="store_true",
|
||||
help="start sessions even if the camera check (hands/camcheck.py) finds the upper cameras off")
|
||||
ap.add_argument("--qml", default=os.path.join(HERE, "main.qml"),
|
||||
help="the window (default: this folder's main.qml, which needs Kirigami)")
|
||||
ap.add_argument("--style", default="org.kde.desktop", help="the Qt Quick Controls style")
|
||||
a, qt_args = ap.parse_known_args()
|
||||
app = QGuiApplication([sys.argv[0]] + qt_args)
|
||||
app.setApplicationName("ft-handrec")
|
||||
@@ -1180,7 +1195,7 @@ def main():
|
||||
app.setDesktopFileName("ft-handrec")
|
||||
if not QIcon.themeName():
|
||||
QIcon.setThemeName("breeze")
|
||||
QQuickStyle.setStyle("org.kde.desktop")
|
||||
QQuickStyle.setStyle(a.style)
|
||||
store = takes.Store(a.base)
|
||||
engine = QQmlApplicationEngine()
|
||||
engine.addImageProvider("frames", FrameProvider(store))
|
||||
@@ -1195,7 +1210,7 @@ def main():
|
||||
app.aboutToQuit.connect(backend.shutdown)
|
||||
engine.rootContext().setContextProperty("backend", backend)
|
||||
engine.rootContext().setContextProperty("startPage", a.page)
|
||||
engine.load(QUrl.fromLocalFile(os.path.join(HERE, "main.qml")))
|
||||
engine.load(QUrl.fromLocalFile(os.path.abspath(a.qml)))
|
||||
if not engine.rootObjects():
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
+25
-22
@@ -51,6 +51,7 @@ sys.path.insert(0, HANDS)
|
||||
import camcheck # noqa: E402 (hands/camcheck.py: are all four mono cameras running?)
|
||||
sys.path.insert(0, HERE)
|
||||
import sides # noqa: E402 (hands/rec/sides.py: which side camera is which)
|
||||
import takes # noqa: E402 (hands/rec/takes.py: the version, and the standalone recorder's build info)
|
||||
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")
|
||||
@@ -60,6 +61,9 @@ 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"
|
||||
# The standalone Hand Recorder (takes.standalone()): its binaries run on the host, so ft-hands
|
||||
# starts directly rather than in the dev container, and repairs mean its install command.
|
||||
STANDALONE = takes.standalone()
|
||||
|
||||
RECORD_HZ = 10
|
||||
SIDES_READ_S = 0.5 # how often the tracking ft-hands' side camera decision is read
|
||||
@@ -131,6 +135,13 @@ def host_command(*cmd):
|
||||
exe] + list(cmd)
|
||||
|
||||
|
||||
def fix_hint(frametop):
|
||||
"""How to repair a missing or broken part: Frametop's command, or the standalone install's."""
|
||||
if STANDALONE:
|
||||
return STANDALONE.get("reinstall") or "run the Hand Recorder's install command again"
|
||||
return frametop
|
||||
|
||||
|
||||
def host_path(path):
|
||||
"""A host file, from the container (/run/host) or the host itself."""
|
||||
for p in ("/run/host" + path, path):
|
||||
@@ -369,10 +380,10 @@ def start_camd(path=None, log=lambda line: None, stop=lambda: False, timeout=15.
|
||||
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")
|
||||
raise RuntimeError("ft-camd isn't built: " + fix_hint("hands/build.sh"))
|
||||
caps = subprocess.run(["getcap", FT_CAMD], capture_output=True, text=True) if shutil.which("getcap") else None
|
||||
if caps is not None and "cap_sys_ptrace" not in caps.stdout:
|
||||
raise RuntimeError("ft-camd needs its capabilities: hands/run.sh caps (asks for sudo)")
|
||||
raise RuntimeError("ft-camd needs its capabilities: " + fix_hint("hands/run.sh caps (asks for sudo)"))
|
||||
started = start_unit(CAMD_UNIT, "the camera broker", [FT_CAMD, "--status", "60"], log)
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end:
|
||||
@@ -614,7 +625,7 @@ class Recorder:
|
||||
"--sides", "auto" if swap is None else "1" if swap else "0"]
|
||||
if ring:
|
||||
argv += ["--ring", ring]
|
||||
if not in_container():
|
||||
if not in_container() and not STANDALONE:
|
||||
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()
|
||||
@@ -1117,15 +1128,6 @@ 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:
|
||||
@@ -1193,8 +1195,8 @@ def camera_text(result):
|
||||
return camcheck.USER_TEXT
|
||||
if camcheck.is_ring_short(result):
|
||||
return ("The headset's tracking cameras are all running, but the recorder can't read some of them (%s). "
|
||||
"Close the Hand Recorder, run ~/frametop/hands/rec/install.sh again, and open it again. If that "
|
||||
"doesn't help, ask in the Frametop Discord." % result.get("reason", ""))
|
||||
"Close the Hand Recorder, %s, and open it again. If that doesn't help, ask in the Frametop "
|
||||
"Discord." % (result.get("reason", ""), fix_hint("run ~/frametop/hands/rec/install.sh again")))
|
||||
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", ""))
|
||||
|
||||
@@ -1444,7 +1446,7 @@ class Session:
|
||||
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")
|
||||
raise _Fail("ft-hands isn't built: " + fix_hint("hands/build.sh"))
|
||||
self._ensure_ring(ring_path)
|
||||
if not tracker_running():
|
||||
if self.start_processes:
|
||||
@@ -1462,7 +1464,7 @@ class Session:
|
||||
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"),
|
||||
"schema": 1, "tool": takes.tool_version(), "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
"contributor": self.profile.get("contributor", ""),
|
||||
"lighting": lighting_record(self.lighting_choice, lighting),
|
||||
"checklist": self.checklist,
|
||||
@@ -1599,11 +1601,12 @@ class Session:
|
||||
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"]
|
||||
argv = [FT_HANDS, "--no-gestures", "--status", "0"]
|
||||
if not STANDALONE: # built in the dev container: it runs there
|
||||
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", "--"] + argv
|
||||
if self.ring:
|
||||
argv += ["--ring", self.ring]
|
||||
self._start_unit(HANDS_UNIT, "hand tracking for feedback", argv)
|
||||
@@ -1626,7 +1629,7 @@ class Session:
|
||||
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")
|
||||
raise _Fail("ft-handpanel isn't built: " + fix_hint("hands/rec/build.sh"))
|
||||
self._panel_proc = subprocess.Popen([self.panel_bin, "--watch-stdin"], stdin=subprocess.PIPE,
|
||||
stdout=self._log_file, stderr=self._log_file)
|
||||
end = time.monotonic() + 15
|
||||
|
||||
+19
-1
@@ -83,8 +83,26 @@ def find_zstd():
|
||||
return next((p for p in ZSTD_PATHS if os.access(p, os.X_OK)), None)
|
||||
|
||||
|
||||
def standalone(path=None):
|
||||
"""The standalone Hand Recorder's build info, or None in a Frametop checkout. Its release
|
||||
(github.com/DeeJanuz/frametop-hand-recorder) ships this repo's tree with standalone.json at
|
||||
the top: {"name", "version", "frametop": this repo's git describe, "reinstall": how to repair
|
||||
an install}. Its binaries are built for the SteamOS host, so nothing runs in the dev container."""
|
||||
try:
|
||||
with open(path or os.path.join(REPO, "standalone.json")) as f:
|
||||
info = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return info if isinstance(info, dict) else None
|
||||
|
||||
|
||||
def tool_version():
|
||||
"""ft-handrec plus the checkout's git describe, for session.json and the manifest."""
|
||||
"""ft-handrec plus the checkout's git describe, for session.json and the manifest. The
|
||||
standalone Hand Recorder has no checkout: the describe it was built from, and its version."""
|
||||
info = standalone()
|
||||
if info:
|
||||
return "ft-handrec %s (%s %s)" % (info.get("frametop") or "unknown", info.get("name") or "standalone",
|
||||
info.get("version") or "unknown")
|
||||
try:
|
||||
out = subprocess.run(["git", "-C", REPO, "describe", "--always", "--dirty", "--tags"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"""main.qml against ft_handrec.Backend: every backend.name(...) the window calls is a slot, and
|
||||
every backend.name it reads is a property or a slot. A method that lost its @Slot shows up in
|
||||
QML only as "is not a function" when its button is pressed (2026-10-03: Export did nothing).
|
||||
Needs PySide6 (the dev container); skipped without it.
|
||||
Needs PySide6 (the dev container); skipped without it. FT_HANDREC_QML=PATH checks another
|
||||
window against the same backend (the standalone Hand Recorder's, in its CI).
|
||||
|
||||
python3 hands/rec/tests/test_qml_backend.py
|
||||
"""
|
||||
@@ -14,6 +15,7 @@ import unittest
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REC = os.path.dirname(HERE)
|
||||
sys.path.insert(0, REC)
|
||||
QML = os.environ.get("FT_HANDREC_QML") or os.path.join(REC, "main.qml")
|
||||
|
||||
|
||||
class QmlBackendTest(unittest.TestCase):
|
||||
@@ -25,7 +27,7 @@ class QmlBackendTest(unittest.TestCase):
|
||||
meta = self.meta = ft_handrec.Backend.staticMetaObject
|
||||
self.slots = {bytes(meta.method(i).name()).decode() for i in range(meta.methodCount())}
|
||||
self.props = {meta.property(i).name() for i in range(meta.propertyCount())}
|
||||
with open(os.path.join(REC, "main.qml")) as f:
|
||||
with open(QML) as f:
|
||||
self.qml = f.read()
|
||||
|
||||
def test_calls_are_slots(self):
|
||||
|
||||
@@ -630,5 +630,61 @@ class ManyPartsTest(unittest.TestCase):
|
||||
self.assertEqual(r.summary["sets"], 120 + 30)
|
||||
|
||||
|
||||
class StandaloneTest(unittest.TestCase):
|
||||
"""The standalone Hand Recorder's tree (standalone.json at the top): ft-hands runs on the
|
||||
host, the version comes from the build info, and repairs point at its install command."""
|
||||
INFO = {"name": "frametop-hand-recorder", "version": "0.1.0", "frametop": "4ba49af",
|
||||
"reinstall": "run the install command again"}
|
||||
|
||||
def test_build_info(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "standalone.json")
|
||||
self.assertIsNone(takes.standalone(path))
|
||||
with open(path, "w") as f:
|
||||
json.dump(self.INFO, f)
|
||||
self.assertEqual(takes.standalone(path), self.INFO)
|
||||
with open(path, "w") as f:
|
||||
f.write("[1]")
|
||||
self.assertIsNone(takes.standalone(path))
|
||||
with mock.patch.object(takes, "standalone", return_value=self.INFO):
|
||||
self.assertEqual(takes.tool_version(), "ft-handrec 4ba49af (frametop-hand-recorder 0.1.0)")
|
||||
|
||||
def recorder_argv(self, standalone):
|
||||
with tempfile.TemporaryDirectory() as d, mock.patch.object(session, "STANDALONE", standalone), \
|
||||
mock.patch.object(session, "in_container", return_value=False), \
|
||||
mock.patch.object(session.subprocess, "Popen") as popen:
|
||||
session.Recorder(d, 1, 10, None, None)
|
||||
return popen.call_args[0][0]
|
||||
|
||||
def tracker_argv(self, standalone):
|
||||
fake = mock.Mock(ring=None)
|
||||
with mock.patch.object(session, "STANDALONE", standalone), \
|
||||
mock.patch.object(session.subprocess, "run") as run:
|
||||
session.Session._start_tracker(fake)
|
||||
return fake._start_unit.call_args[0][2], run
|
||||
|
||||
def test_ft_hands_on_the_host(self):
|
||||
self.assertEqual(self.recorder_argv(self.INFO)[0], session.FT_HANDS)
|
||||
argv, run = self.tracker_argv(self.INFO)
|
||||
self.assertEqual(argv[0], session.FT_HANDS)
|
||||
self.assertIn("--no-gestures", argv)
|
||||
run.assert_not_called() # no container to bring up
|
||||
|
||||
def test_ft_hands_in_the_dev_container(self):
|
||||
self.assertTrue(self.recorder_argv(None)[0].endswith("distrobox"))
|
||||
argv, _ = self.tracker_argv(None)
|
||||
self.assertEqual(argv[:4], [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--"])
|
||||
self.assertEqual(argv[4], session.FT_HANDS)
|
||||
|
||||
def test_fix_hint(self):
|
||||
with mock.patch.object(session, "STANDALONE", self.INFO):
|
||||
self.assertEqual(session.fix_hint("hands/build.sh"), "run the install command again")
|
||||
text = session.camera_text({"status": "degraded", "reason": "2 of 4", "ring_missing": ["upper_left"]})
|
||||
with mock.patch.object(session, "STANDALONE", None):
|
||||
self.assertEqual(session.fix_hint("hands/build.sh"), "hands/build.sh")
|
||||
self.assertIn("run the install command again", text)
|
||||
self.assertNotIn("~/frametop", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user