Files
DeeJanuz--frametop/hands/rec/sides.py
T
DeeJanuzandClaude Opus 5.5 a533bb973a 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>
2026-10-02 22:24:13 -06:00

129 lines
5.4 KiB
Python

"""Which side camera is which, in recordings: the rules every reader shares.
ft-camd tells slam_left's buffers from slam_right's by the order XRService allocated them, and
some XRService starts reverse it: then each side camera's images carry the other's name. A
tracking ft-hands tells from the hands (hands/track/sides.h, HANDS_SWAP_SIDES=auto) and publishes
what it found in /run/user/UID/frametop-hands/sides.json (read_live). Two things are recorded:
swapped whether ft-camd's naming was backwards during the recording (the truth);
None while nobody knows. A hand recorder session has it in session.json
"sides": {"swapped", "decided_by", "evidence", ...}; an ft-hands --record
recording in DIR/sides.json.
names_swapped per recording file: whether the side cameras' names in it were exchanged from
ft-camd's (ft-hands --sides 1). A session's take.json "parts": {"sets.bin":
{"names_swapped": false}, "sets-2.bin": {...}}; DIR/sides.json "names_swapped":
[[first set, bool], ...] (it can change inside a recording when ft-hands decides).
A file's names are right when names_swapped == swapped. Readers rename slam_left <-> slam_right
(and their _dk dark frames) where they differ (needs_rename). Exports always carry the right
names, with names_swapped = swapped written next to them, so applying the rule again is harmless.
Standard library only (the window, validate.py on Windows, the maintainer's tools).
"""
import json
import os
import struct
import time
HDR = struct.Struct("<8sII") # fh_set_hdr_t (hands/track/record.h)
CAM = struct.Struct("<16sIIQQ") # fh_set_cam_t: name, width, height, capture_ns, dqbuf_ns
SIDES = {"slam_left": "slam_right", "slam_right": "slam_left",
"slam_left_dk": "slam_right_dk", "slam_right_dk": "slam_left_dk"}
LIVE_FRESH_S = 6.0 # ft-hands rewrites its sides.json at least every 2 s
def other_name(name):
"""slam_left <-> slam_right (and their dark frames); other names as they are."""
return SIDES.get(name, name)
def needs_rename(swapped, names_swapped):
"""True when a file's side cameras carry each other's names. Unknown truth: leave them."""
if swapped is None:
return False
return bool(names_swapped) != bool(swapped)
def rename_record(record):
"""One FHSET01 record (bytes) with slam_left and slam_right's names exchanged (and their
dark frames'). Only the 16-byte name fields change; pixels and lengths stay."""
out = bytearray(record)
ncams = HDR.unpack_from(out, 0)[1]
for k in range(ncams):
off = HDR.size + k * CAM.size
raw = bytes(out[off:off + 16])
name = raw.split(b"\0", 1)[0].decode(errors="replace")
new = other_name(name)
if new != name:
out[off:off + 16] = new.encode().ljust(16, b"\0")
return bytes(out)
# ------------------------------------------------------------------ sessions and recordings
def session_sides(session_json):
"""A session.json's "sides" (a dict), or {"swapped": None} when it has none."""
s = (session_json or {}).get("sides")
return s if isinstance(s, dict) else {"swapped": None}
def part_names_swapped(take_json, part):
"""Whether a take's part file (e.g. "sets-2.bin") was recorded with exchanged names."""
parts = (take_json or {}).get("parts")
entry = parts.get(part) if isinstance(parts, dict) else None
return bool(entry.get("names_swapped")) if isinstance(entry, dict) else False
def part_rename(session_json, take_json, part):
"""Should a session take's part file have its side cameras renamed when read?"""
return needs_rename(session_sides(session_json).get("swapped"), part_names_swapped(take_json, part))
def recording_runs(rec_dir):
"""An ft-hands --record recording (DIR/sets.bin + DIR/sides.json): [(first set, rename)],
or [(0, False)] when it has no sides.json (recorded before there was one)."""
try:
with open(os.path.join(rec_dir, "sides.json")) as f:
s = json.load(f)
except (OSError, ValueError):
return [(0, False)]
runs = s.get("names_swapped") or [[0, False]]
return [(int(first), needs_rename(s.get("swapped"), sw)) for first, sw in runs]
def rename_at(runs, i):
"""recording_runs()'s answer for set i."""
out = False
for first, rename in runs:
if i >= first:
out = rename
return out
# ------------------------------------------------------------------ the live decision
def run_dir():
return "/run/user/%d/frametop-hands" % os.getuid()
def read_live(path=None, ring_path=None, now_ns=None):
"""The tracking ft-hands' sides.json, or None if there's none or it's stale (ft-hands
rewrites it every 2 s and removes it when it exits). With ring_path, also None when it
was written for another ft-camd run than the ring there now."""
path = path or os.path.join(run_dir(), "sides.json")
try:
with open(path) as f:
s = json.load(f)
except (OSError, ValueError):
return None
now = time.clock_gettime_ns(time.CLOCK_MONOTONIC) if now_ns is None else now_ns
if not isinstance(s, dict) or (now - int(s.get("updated_ns", 0))) / 1e9 > LIVE_FRESH_S:
return None
if ring_path:
try:
if os.stat(ring_path).st_ino != s.get("ring_ino"):
return None
except OSError:
return None
return s