diff --git a/hands/rec/DESIGN.md b/hands/rec/DESIGN.md index 740975e..12000b6 100644 --- a/hands/rec/DESIGN.md +++ b/hands/rec/DESIGN.md @@ -311,6 +311,7 @@ On 2026-10-02 a whole session showed "I can't see your hands": after the headset - `calibration.json` and `device.json`, when the session has them. - Per take: `prompts.jsonl`, `poses.jsonl`, `take.json`, and `sets.bin.zst` (sets in deleted ranges removed, then zstd -10 with 2 threads). - The side cameras are named right in every exported set when the session's `sides.swapped` is known: parts that need it get slam_left and slam_right (and their `_dk`) exchanged in the set headers as they're compressed. The exported take.json says `"parts": {"sets.bin": {"names_swapped": }}`, and the manifest's take entry `"sides": {"names_swapped", "renamed_sets"}`. Unknown, the names stay as recorded. + - `poses.jsonl` and `prompts.jsonl` without what's in the deleted ranges: no poses and no live-tracker `feedback` there (the prompt timeline stays). With the checklist's controllers at `none`, the controllers' poses are null and `feedback` has no `controller` (`takes.export_jsonl`): controllers left switched on still get tracked, and their poses would pass for the hands' ground truth. - `SHA256SUMS`. Compression runs at nice 19. While it runs with the headset worn, the window notes that VR may stutter a little (exports are done in the headset). Worn is judged the way `frame-job` does: `vrcompositor` runs and a `/sys/class/backlight/*/brightness` reads over 0 (SteamVR turns the panel off 5 s after the headset comes off). CPU work while in VR causes stutter. The proximity sensor is no use here: it read 9-43 with the headset sitting unworn. diff --git a/hands/rec/takes.py b/hands/rec/takes.py index 2960426..eeffe95 100755 --- a/hands/rec/takes.py +++ b/hands/rec/takes.py @@ -126,6 +126,40 @@ def in_ranges(t, ranges): return any(a <= t <= b for a, b in ranges) +def export_jsonl(src, dst, ranges, keep_controllers): + """Copy a take's poses.jsonl or prompts.jsonl for export: + - poses in the deleted ranges are left out, and the live tracker's feedback there (the + prompt timeline stays: it says what was asked, and shows nothing); + - without controllers (the checklist's "none"), the controllers' poses are null and feedback + carries no controller state: controllers lying about still get logged, and those poses + would read as hands' ground truth. + Lines that don't parse are copied as they are (validate.py reports them).""" + poses = os.path.basename(src) == "poses.jsonl" + with open(src, encoding="utf-8") as f, open(dst, "w", encoding="utf-8", newline="\n") as out: + for line in f: + try: + obj = json.loads(line) + except ValueError: + out.write(line) + continue + if not isinstance(obj, dict): + out.write(line) + continue + t = obj.get("t") + gone = isinstance(t, int) and in_ranges(t, ranges) + if poses: + if gone: + continue + if not keep_controllers: + obj["left"] = obj["right"] = None + elif obj.get("event") == "feedback": + if gone: + continue + if not keep_controllers: + obj.pop("controller", None) + out.write(json.dumps(obj, separators=(", ", ": ")) + "\n") + + def merge_ranges(ranges): """Sorted, with overlapping or touching ranges joined.""" out = [] @@ -472,17 +506,18 @@ class Store: total = sum(t["bytes"] for t in takes) or 1 done = 0 take_entries = [] + keep_controllers = (meta.get("checklist") or {}).get("controllers") == "straps" for t in takes: if cancelled(): raise Cancelled() tdir = self.take_dir(session, t["id"]) out = os.path.join(work, "takes", t["id"]) os.makedirs(out) + ranges = t["ranges"] for name in ("prompts.jsonl", "poses.jsonl"): if os.path.isfile(os.path.join(tdir, name)): - shutil.copyfile(os.path.join(tdir, name), os.path.join(out, name)) + export_jsonl(os.path.join(tdir, name), os.path.join(out, name), ranges, keep_controllers) index = take_index(tdir) - ranges = t["ranges"] keep = [i for i in range(len(index)) if not in_ranges(index.time_ns(i), ranges)] # one file, named right where the decision is known (sides.py): its names_swapped is # then the session's swapped; unknown, it's the parts' own (None if they differ) diff --git a/hands/rec/tests/test_validate.py b/hands/rec/tests/test_validate.py index b4a72cf..065f0a9 100644 --- a/hands/rec/tests/test_validate.py +++ b/hands/rec/tests/test_validate.py @@ -281,6 +281,44 @@ class ValidateTest(unittest.TestCase): self.assertNotEqual(os.system(f"{sys.executable} {validate.__file__} {d} >/dev/null"), 0) +class ExportJsonlTest(unittest.TestCase): + """takes.export_jsonl: deleted ranges and the controllers when the checklist says none.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="handrec-jsonl-test-") + ctrl = {"m": [0.0] * 12, "r": 200, "ok": True} + with open(os.path.join(self.tmp, "poses.jsonl"), "w") as f: + for t in range(10): + f.write(json.dumps({"t": t, "hmd": ctrl, "left": ctrl, "right": ctrl}) + "\n") + with open(os.path.join(self.tmp, "prompts.jsonl"), "w") as f: + for e in ({"t": 1, "event": "prompt", "id": "a"}, {"t": 4, "event": "feedback", "left": True}, + {"t": 7, "event": "feedback", "left": True, "controller": {"left": "ok"}}): + f.write(json.dumps(e) + "\n") + os.makedirs(os.path.join(self.tmp, "out")) + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def run_export(self, name, keep): + out = os.path.join(self.tmp, "out", name) + takes.export_jsonl(os.path.join(self.tmp, name), out, [[3, 5]], keep) + with open(out) as f: + return [json.loads(line) for line in f] + + def test_no_controllers(self): + poses = self.run_export("poses.jsonl", False) + self.assertEqual([p["t"] for p in poses], [0, 1, 2, 6, 7, 8, 9]) # 3-5 deleted + self.assertTrue(all(p["left"] is None and p["right"] is None and p["hmd"] for p in poses)) + prompts = self.run_export("prompts.jsonl", False) + self.assertEqual([e["t"] for e in prompts], [1, 7]) # the feedback at 4 was in a deleted range + self.assertNotIn("controller", prompts[1]) + + def test_with_controllers(self): + poses = self.run_export("poses.jsonl", True) + self.assertTrue(all(p["left"] and p["right"] for p in poses)) + self.assertIn("controller", self.run_export("prompts.jsonl", True)[1]) + + class SessionFilesTest(unittest.TestCase): """session.py's device.json, and session ids with a suffix."""