hand recorder: take.json keeps camera clock samples

sets.bin's capture_ns is CLOCK_MONOTONIC_RAW; poses.jsonl and prompts.jsonl are
CLOCK_MONOTONIC. On 2026-10-03 the two were 0.80 s apart during a session and
1.11 s apart five hours later, so images can't be paired with poses by
capture_ns. take.json now samples RAW minus MONOTONIC as each recording part
starts and stops (as ft-hands' raw_minus_mono_ns), so readers can put each
exposure on the poses' clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-10-03 13:32:32 -06:00
1 parent ea483f7ce3
commit 8321a99121
3 files changed
+23 -2

No files matched your search

+3 -2
View File
@@ -57,11 +57,12 @@ Test hooks:
poses.jsonl head and controller poses from ft-handpanel (below) poses.jsonl head and controller poses from ft-handpanel (below)
take.json {"section", "title", "started_ns", "ended_ns", "status": "complete"|"stopped"|"skipped", take.json {"section", "title", "started_ns", "ended_ns", "status": "complete"|"stopped"|"skipped",
"deleted": [[from_ns, to_ns], ...], "notes": "", "deleted": [[from_ns, to_ns], ...], "notes": "",
"parts": {"sets.bin": {"names_swapped": false}, "sets-2.bin": {...}}} "parts": {"sets.bin": {"names_swapped": false}, "sets-2.bin": {...}},
"clock": [[mono_ns, raw_minus_mono_ns], ...]}
exports/<session>/ what export writes (below) exports/<session>/ what export writes (below)
``` ```
All `_ns` times are CLOCK_MONOTONIC nanoseconds, the clock of `dqbuf_ns` in sets.bin and of `capture_ns` in the hands file. sets.bin's `capture_ns` is the camera clock (CLOCK_MONOTONIC_RAW). All `_ns` times are CLOCK_MONOTONIC nanoseconds, the clock of `dqbuf_ns` in sets.bin and of `capture_ns` in the hands file. sets.bin's `capture_ns` is the camera clock (CLOCK_MONOTONIC_RAW). The two drift apart with NTP's corrections: on 2026-10-03 RAW ran 0.80 s ahead, gaining about 10 ppm. take.json `"clock"` samples the difference (RAW minus MONOTONIC, as ft-hands' `raw_minus_mono_ns()`) when each part starts and stops, so a set's exposure time on the poses' clock is `capture_ns - raw_minus_mono_ns`, interpolated between samples. `dqbuf_ns` is on the right clock already, but a few ms after the exposure. Takes recorded before 2026-10-03 have no `"clock"`.
### profile.json ### profile.json
+14
View File
@@ -99,6 +99,18 @@ def mono_ns():
return time.clock_gettime_ns(time.CLOCK_MONOTONIC) return time.clock_gettime_ns(time.CLOCK_MONOTONIC)
def clock_sample():
"""[CLOCK_MONOTONIC ns, CLOCK_MONOTONIC_RAW - CLOCK_MONOTONIC ns], as ft-hands' raw_minus_mono_ns().
sets.bin's capture_ns is on the RAW clock and everything else on MONOTONIC; the two drift
apart with NTP's corrections (0.8 s apart and ~10 ppm on 2026-10-03), so take.json keeps
samples to turn capture_ns into MONOTONIC: capture_ns - offset, interpolated by time."""
a = time.clock_gettime_ns(time.CLOCK_MONOTONIC)
r = time.clock_gettime_ns(time.CLOCK_MONOTONIC_RAW)
b = time.clock_gettime_ns(time.CLOCK_MONOTONIC)
mid = (a + b) // 2
return [mid, r - mid]
def run_dir(): def run_dir():
return "/run/user/%d/frametop-hands" % os.getuid() return "/run/user/%d/frametop-hands" % os.getuid()
@@ -2010,6 +2022,7 @@ class Session:
def _start_recording(self): def _start_recording(self):
t = self._take t = self._take
t["part"] += 1 t["part"] += 1
t["json"].setdefault("clock", []).append(clock_sample())
if not self.dry_run: if not self.dry_run:
# a safety net only: the session ends the recording itself # a safety net only: the session ends the recording itself
remaining = section_seconds(t["section"], worst=True, auto=self.auto) / self.speed remaining = section_seconds(t["section"], worst=True, auto=self.auto) / self.speed
@@ -2031,6 +2044,7 @@ class Session:
self._log("recording part %d ended (%s)" % (rec.part, code)) self._log("recording part %d ended (%s)" % (rec.part, code))
if self._take: # how this part's side cameras are named (sides.py) if self._take: # how this part's side cameras are named (sides.py)
self._take["json"].setdefault("parts", {})[rec.file] = {"names_swapped": rec.names_swapped} self._take["json"].setdefault("parts", {})[rec.file] = {"names_swapped": rec.names_swapped}
self._take["json"].setdefault("clock", []).append(clock_sample())
write_json(os.path.join(self._take["dir"], "take.json"), self._take["json"]) write_json(os.path.join(self._take["dir"], "take.json"), self._take["json"])
def _end_take(self, status): def _end_take(self, status):
+6
View File
@@ -89,6 +89,12 @@ class SessionTest(SessionBase):
self.assertGreater(ev[2]["t"] - ev[1]["t"], 0.25e9) self.assertGreater(ev[2]["t"] - ev[1]["t"], 0.25e9)
with open(os.path.join(s.session_dir, "session.json")) as f: with open(os.path.join(s.session_dir, "session.json")) as f:
self.assertEqual(json.load(f)["mode"], "step") self.assertEqual(json.load(f)["mode"], "step")
# a clock sample as each part starts (2 steps: 2 parts), for capture_ns (RAW) -> MONOTONIC
with open(os.path.join(s.session_dir, "takes", "01-poses", "take.json")) as f:
clock = json.load(f)["clock"]
self.assertEqual(len(clock), 2)
self.assertTrue(all(len(c) == 2 and all(isinstance(v, int) for v in c) for c in clock))
self.assertLess(clock[0][0], clock[1][0])
# the panel: Ready?, then the countdown, then the section's word for the hold, the diagram # the panel: Ready?, then the countdown, then the section's word for the hold, the diagram
self.assertIn("panel: action " + session.READY_TEXT, self.panel) self.assertIn("panel: action " + session.READY_TEXT, self.panel)
self.assertLess(self.panel.index("panel: big 3"), self.panel.index("panel: big 1")) self.assertLess(self.panel.index("panel: big 3"), self.panel.index("panel: big 1"))