From 10cca0179a1a181259c1f189cf7f3054e4320c7a Mon Sep 17 00:00:00 2001 From: Ivan Yang Date: Fri, 9 Oct 2026 23:00:37 -0700 Subject: [PATCH 1/6] feat(cts): conformance test suite for hand and full-body device integrations Adds the CTS under src/python/isaaccapture/cts/. A take recorded with a device is checked from its MCAP and gets one verdict: pass, fail (the device is at fault), retake (the take was not performed as scripted) or insufficient_data. A browser panel replays the take beside the results. Two suites share cts/common: hand (core.HandPoseRecord, an eleven-step timed script, a preview until its tolerances are calibrated) and full body (core.FullBodyPoseRecord, a ten-step script paced by the performer). Each has capture/, which records through TeleopSession; checker/, which depends only on mcap and flatbuffers; and oracle/, which generates synthetic recordings with known defects for the checker's tests. cts/ stays out of the wheel (src/python/CMakeLists.txt, pyproject.toml, tests/python/packaging/test_wheel_contents.py), the spoken cues are tracked through Git LFS, and the root README links the two guides. Signed-off-by: Ivan Yang --- .gitattributes | 3 + README.md | 4 + pyproject.toml | 1 + src/python/CMakeLists.txt | 3 + src/python/isaaccapture/cts/common/AGENTS.md | 27 + src/python/isaaccapture/cts/common/pytest.ini | 9 + .../cts/common/src/cts_common/__init__.py | 4 + .../common/src/cts_common/checks/__init__.py | 7 + .../cts/common/src/cts_common/checks/base.py | 89 ++ .../src/cts_common/checks/continuity.py | 91 ++ .../common/src/cts_common/checks/coverage.py | 96 ++ .../src/cts_common/checks/quaternion.py | 58 ++ .../cts/common/src/cts_common/checks/rate.py | 141 +++ .../common/src/cts_common/checks/schema.py | 47 + .../src/cts_common/checks/segmentation.py | 149 +++ .../src/cts_common/checks/timestamps.py | 137 +++ .../common/src/cts_common/checks/values.py | 98 ++ .../cts/common/src/cts_common/frames.py | 71 ++ .../cts/common/src/cts_common/labels.py | 151 +++ .../cts/common/src/cts_common/mcap_reader.py | 81 ++ .../common/src/cts_common/panel/__init__.py | 4 + .../cts/common/src/cts_common/panel/render.py | 93 ++ .../cts/common/src/cts_common/panel/sample.py | 46 + .../common/src/cts_common/panel/skeleton.py | 117 +++ .../cts/common/src/cts_common/panel/status.py | 56 + .../cts/common/src/cts_common/panel/track.py | 148 +++ .../cts/common/src/cts_common/report.py | 273 +++++ .../cts/common/src/cts_common/vectors.py | 100 ++ .../cts/common/tests/test_boundaries.py | 70 ++ .../isaaccapture/cts/full_body/README.md | 281 +++++ .../cts/full_body/capture/README.md | 13 + .../cts/full_body/capture/capture_panel.py | 403 ++++++++ .../cts/full_body/capture/cues.py | 67 ++ .../full_body/capture/cues/a_pose_still.wav | 3 + .../cts/full_body/capture/cues/beep.wav | 3 + .../cts/full_body/capture/cues/closing.wav | 3 + .../cts/full_body/capture/cues/index.json | 17 + .../full_body/capture/cues/index.json.license | 2 + .../full_body/capture/cues/left_arm_raise.wav | 3 + .../full_body/capture/cues/left_leg_raise.wav | 3 + .../full_body/capture/cues/march_in_place.wav | 3 + .../full_body/capture/cues/neutral_stance.wav | 3 + .../capture/cues/right_arm_raise.wav | 3 + .../capture/cues/right_leg_raise.wav | 3 + .../cts/full_body/capture/cues/squat_x2.wav | 3 + .../capture/cues/t_pose_hold_close.wav | 3 + .../capture/cues/t_pose_hold_open.wav | 3 + .../cts/full_body/capture/cues/tick.wav | 3 + .../cts/full_body/capture/live.py | 118 +++ .../cts/full_body/capture/make_labels.py | 265 +++++ .../cts/full_body/capture/pytest.ini | 15 + .../cts/full_body/capture/record.sh | 59 ++ .../cts/full_body/capture/render.py | 267 +++++ .../cts/full_body/capture/requirements.txt | 11 + .../cts/full_body/capture/session.py | 25 + .../cts/full_body/capture/setup_env.sh | 44 + .../cts/full_body/capture/steps.py | 206 ++++ .../cts/full_body/capture/tests/test_steps.py | 156 +++ .../cts/full_body/checker/.gitignore | 11 + .../cts/full_body/checker/AGENTS.md | 175 ++++ .../cts/full_body/checker/README.md | 206 ++++ .../cts/full_body/checker/pytest.ini | 7 + .../full_body/checker/requirements-dev.txt | 1 + .../full_body/checker/requirements-panel.txt | 7 + .../cts/full_body/checker/requirements.txt | 5 + .../cts/full_body/checker/setup_env.sh | 58 ++ .../checker/src/full_body_cts/__init__.py | 23 + .../checker/src/full_body_cts/_schema.py | 29 + .../src/full_body_cts/checks/__init__.py | 135 +++ .../src/full_body_cts/checks/consistency.py | 58 ++ .../src/full_body_cts/checks/envelope.py | 64 ++ .../src/full_body_cts/checks/geometry.py | 666 ++++++++++++ .../src/full_body_cts/checks/orientation.py | 195 ++++ .../src/full_body_cts/checks/posture.py | 694 +++++++++++++ .../src/full_body_cts/checks/segmentation.py | 281 +++++ .../checker/src/full_body_cts/cli.py | 81 ++ .../checker/src/full_body_cts/frames.py | 56 + .../checker/src/full_body_cts/labels.py | 19 + .../checker/src/full_body_cts/mcap_source.py | 89 ++ .../src/full_body_cts/panel/__init__.py | 7 + .../src/full_body_cts/panel/__main__.py | 70 ++ .../checker/src/full_body_cts/panel/app.py | 275 +++++ .../checker/src/full_body_cts/panel/bundle.py | 178 ++++ .../checker/src/full_body_cts/panel/render.py | 83 ++ .../checker/src/full_body_cts/panel/status.py | 47 + .../checker/src/full_body_cts/panel/track.py | 54 + .../checker/src/full_body_cts/profile.py | 177 ++++ .../checker/src/full_body_cts/report.py | 65 ++ .../cts/full_body/checker/tests/conftest.py | 34 + .../checker/tests/known_deviations.py | 83 ++ .../cts/full_body/checker/tests/synth.py | 362 +++++++ .../checker/tests/test_boundaries.py | 73 ++ .../full_body/checker/tests/test_checks.py | 772 ++++++++++++++ .../cts/full_body/checker/tests/test_cli.py | 28 + .../full_body/checker/tests/test_oracle.py | 114 +++ .../full_body/checker/tests/test_panel_app.py | 108 ++ .../checker/tests/test_panel_boundary.py | 47 + .../checker/tests/test_panel_bundle.py | 228 +++++ .../checker/tests/test_panel_status.py | 130 +++ .../checker/tests/test_panel_track.py | 158 +++ .../checker/tests/test_pico_shapes.py | 84 ++ .../checker/tests/test_posture_measurement.py | 371 +++++++ .../cts/full_body/oracle/.gitignore | 8 + .../cts/full_body/oracle/AGENTS.md | 57 ++ .../cts/full_body/oracle/README.md | 155 +++ .../cts/full_body/oracle/fixtures_index.json | 831 +++++++++++++++ .../cts/full_body/oracle/generate.sh | 28 + .../cts/full_body/oracle/generate_fixtures.py | 693 +++++++++++++ .../cts/full_body/oracle/mcap_io.py | 88 ++ .../cts/full_body/oracle/posture_fixtures.py | 387 +++++++ .../cts/full_body/oracle/posture_script.py | 423 ++++++++ .../cts/full_body/oracle/posture_verify.py | 452 ++++++++ .../cts/full_body/oracle/skeleton.py | 377 +++++++ .../cts/full_body/oracle/toolchain.py | 24 + .../cts/full_body/oracle/verify_fixtures.py | 635 ++++++++++++ src/python/isaaccapture/cts/hand/AGENTS.md | 30 + src/python/isaaccapture/cts/hand/README.md | 370 +++++++ .../isaaccapture/cts/hand/capture/.gitignore | 6 + .../isaaccapture/cts/hand/capture/README.md | 98 ++ .../cts/hand/capture/capture_panel.py | 398 ++++++++ .../isaaccapture/cts/hand/capture/cues.py | 66 ++ .../cts/hand/capture/cues/beep.wav | 3 + .../cts/hand/capture/cues/closing.wav | 3 + .../cts/hand/capture/cues/count.wav | 3 + .../cts/hand/capture/cues/fist.wav | 3 + .../hand/capture/cues/flat_on_table_close.wav | 3 + .../hand/capture/cues/flat_on_table_open.wav | 3 + .../cts/hand/capture/cues/index.json | 18 + .../cts/hand/capture/cues/index.json.license | 2 + .../cts/hand/capture/cues/left_tip_roll.wav | 3 + .../cts/hand/capture/cues/palms_together.wav | 3 + .../cts/hand/capture/cues/pinch_index.wav | 3 + .../cts/hand/capture/cues/pinch_little.wav | 3 + .../cts/hand/capture/cues/pinch_middle.wav | 3 + .../cts/hand/capture/cues/pinch_ring.wav | 3 + .../cts/hand/capture/cues/right_tip_roll.wav | 3 + .../cts/hand/capture/cues/tick.wav | 3 + .../cts/hand/capture/cues/tips_together.wav | 3 + .../isaaccapture/cts/hand/capture/live.py | 74 ++ .../cts/hand/capture/make_labels.py | 134 +++ .../isaaccapture/cts/hand/capture/pytest.ini | 9 + .../isaaccapture/cts/hand/capture/record.sh | 46 + .../isaaccapture/cts/hand/capture/render.py | 221 ++++ .../cts/hand/capture/requirements.txt | 8 + .../isaaccapture/cts/hand/capture/session.py | 50 + .../cts/hand/capture/setup_env.sh | 42 + .../isaaccapture/cts/hand/capture/steps.py | 175 ++++ .../isaaccapture/cts/hand/capture/table.py | 44 + .../hand/capture/tests/test_make_labels.py | 77 ++ .../cts/hand/capture/tests/test_steps.py | 85 ++ .../cts/hand/capture/tests/test_table.py | 38 + .../isaaccapture/cts/hand/checker/.gitignore | 11 + .../isaaccapture/cts/hand/checker/AGENTS.md | 40 + .../isaaccapture/cts/hand/checker/README.md | 169 +++ .../isaaccapture/cts/hand/checker/pytest.ini | 7 + .../cts/hand/checker/requirements-dev.txt | 1 + .../cts/hand/checker/requirements-panel.txt | 5 + .../cts/hand/checker/requirements.txt | 5 + .../cts/hand/checker/setup_env.sh | 58 ++ .../cts/hand/checker/src/hand_cts/__init__.py | 23 + .../cts/hand/checker/src/hand_cts/_schema.py | 34 + .../checker/src/hand_cts/checks/__init__.py | 55 + .../hand/checker/src/hand_cts/checks/base.py | 211 ++++ .../checker/src/hand_cts/checks/envelope.py | 52 + .../checker/src/hand_cts/checks/placement.py | 264 +++++ .../hand/checker/src/hand_cts/checks/shape.py | 772 ++++++++++++++ .../checker/src/hand_cts/checks/windows.py | 964 ++++++++++++++++++ .../cts/hand/checker/src/hand_cts/cli.py | 80 ++ .../cts/hand/checker/src/hand_cts/frames.py | 91 ++ .../cts/hand/checker/src/hand_cts/labels.py | 62 ++ .../hand/checker/src/hand_cts/mcap_source.py | 101 ++ .../checker/src/hand_cts/panel/__init__.py | 4 + .../checker/src/hand_cts/panel/__main__.py | 60 ++ .../hand/checker/src/hand_cts/panel/app.py | 301 ++++++ .../hand/checker/src/hand_cts/panel/status.py | 46 + .../hand/checker/src/hand_cts/panel/track.py | 79 ++ .../cts/hand/checker/src/hand_cts/profile.py | 91 ++ .../cts/hand/checker/src/hand_cts/report.py | 53 + .../cts/hand/checker/tests/conftest.py | 36 + .../cts/hand/checker/tests/synth.py | 109 ++ .../cts/hand/checker/tests/test_boundaries.py | 59 ++ .../cts/hand/checker/tests/test_checks.py | 484 +++++++++ .../cts/hand/checker/tests/test_cli.py | 39 + .../cts/hand/checker/tests/test_oracle.py | 47 + .../cts/hand/checker/tests/test_panel.py | 108 ++ .../isaaccapture/cts/hand/oracle/.gitignore | 8 + .../isaaccapture/cts/hand/oracle/README.md | 27 + .../cts/hand/oracle/fixtures_index.json | 296 ++++++ .../isaaccapture/cts/hand/oracle/generate.sh | 28 + .../cts/hand/oracle/generate_fixtures.py | 466 +++++++++ .../cts/hand/oracle/hand_model.py | 324 ++++++ .../isaaccapture/cts/hand/oracle/mcap_io.py | 100 ++ .../isaaccapture/cts/hand/oracle/script.py | 271 +++++ .../isaaccapture/cts/hand/oracle/toolchain.py | 26 + .../cts/hand/oracle/verify_fixtures.py | 216 ++++ tests/python/packaging/test_wheel_contents.py | 7 + 196 files changed, 23054 insertions(+) create mode 100644 src/python/isaaccapture/cts/common/AGENTS.md create mode 100644 src/python/isaaccapture/cts/common/pytest.ini create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/__init__.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/__init__.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/base.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/continuity.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/coverage.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/quaternion.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/rate.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/schema.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/segmentation.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/timestamps.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/checks/values.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/frames.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/labels.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/mcap_reader.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/panel/__init__.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/panel/render.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/panel/sample.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/panel/skeleton.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/panel/status.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/panel/track.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/report.py create mode 100644 src/python/isaaccapture/cts/common/src/cts_common/vectors.py create mode 100644 src/python/isaaccapture/cts/common/tests/test_boundaries.py create mode 100644 src/python/isaaccapture/cts/full_body/README.md create mode 100644 src/python/isaaccapture/cts/full_body/capture/README.md create mode 100644 src/python/isaaccapture/cts/full_body/capture/capture_panel.py create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues.py create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/a_pose_still.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/beep.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/closing.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/index.json create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/index.json.license create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/left_arm_raise.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/left_leg_raise.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/march_in_place.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/neutral_stance.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/right_arm_raise.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/right_leg_raise.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/squat_x2.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/t_pose_hold_close.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/t_pose_hold_open.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/cues/tick.wav create mode 100644 src/python/isaaccapture/cts/full_body/capture/live.py create mode 100644 src/python/isaaccapture/cts/full_body/capture/make_labels.py create mode 100644 src/python/isaaccapture/cts/full_body/capture/pytest.ini create mode 100755 src/python/isaaccapture/cts/full_body/capture/record.sh create mode 100644 src/python/isaaccapture/cts/full_body/capture/render.py create mode 100644 src/python/isaaccapture/cts/full_body/capture/requirements.txt create mode 100644 src/python/isaaccapture/cts/full_body/capture/session.py create mode 100755 src/python/isaaccapture/cts/full_body/capture/setup_env.sh create mode 100644 src/python/isaaccapture/cts/full_body/capture/steps.py create mode 100644 src/python/isaaccapture/cts/full_body/capture/tests/test_steps.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/.gitignore create mode 100644 src/python/isaaccapture/cts/full_body/checker/AGENTS.md create mode 100644 src/python/isaaccapture/cts/full_body/checker/README.md create mode 100644 src/python/isaaccapture/cts/full_body/checker/pytest.ini create mode 100644 src/python/isaaccapture/cts/full_body/checker/requirements-dev.txt create mode 100644 src/python/isaaccapture/cts/full_body/checker/requirements-panel.txt create mode 100644 src/python/isaaccapture/cts/full_body/checker/requirements.txt create mode 100755 src/python/isaaccapture/cts/full_body/checker/setup_env.sh create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/__init__.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/_schema.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/checks/__init__.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/checks/consistency.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/checks/envelope.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/checks/geometry.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/checks/orientation.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/checks/posture.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/checks/segmentation.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/cli.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/frames.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/labels.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/mcap_source.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/panel/__init__.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/panel/__main__.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/panel/app.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/panel/bundle.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/panel/render.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/panel/status.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/panel/track.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/profile.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/src/full_body_cts/report.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/conftest.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/known_deviations.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/synth.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_boundaries.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_checks.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_cli.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_oracle.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_panel_app.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_panel_boundary.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_panel_bundle.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_panel_status.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_panel_track.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_pico_shapes.py create mode 100644 src/python/isaaccapture/cts/full_body/checker/tests/test_posture_measurement.py create mode 100644 src/python/isaaccapture/cts/full_body/oracle/.gitignore create mode 100644 src/python/isaaccapture/cts/full_body/oracle/AGENTS.md create mode 100644 src/python/isaaccapture/cts/full_body/oracle/README.md create mode 100644 src/python/isaaccapture/cts/full_body/oracle/fixtures_index.json create mode 100755 src/python/isaaccapture/cts/full_body/oracle/generate.sh create mode 100644 src/python/isaaccapture/cts/full_body/oracle/generate_fixtures.py create mode 100644 src/python/isaaccapture/cts/full_body/oracle/mcap_io.py create mode 100644 src/python/isaaccapture/cts/full_body/oracle/posture_fixtures.py create mode 100644 src/python/isaaccapture/cts/full_body/oracle/posture_script.py create mode 100644 src/python/isaaccapture/cts/full_body/oracle/posture_verify.py create mode 100644 src/python/isaaccapture/cts/full_body/oracle/skeleton.py create mode 100644 src/python/isaaccapture/cts/full_body/oracle/toolchain.py create mode 100644 src/python/isaaccapture/cts/full_body/oracle/verify_fixtures.py create mode 100644 src/python/isaaccapture/cts/hand/AGENTS.md create mode 100644 src/python/isaaccapture/cts/hand/README.md create mode 100644 src/python/isaaccapture/cts/hand/capture/.gitignore create mode 100644 src/python/isaaccapture/cts/hand/capture/README.md create mode 100644 src/python/isaaccapture/cts/hand/capture/capture_panel.py create mode 100644 src/python/isaaccapture/cts/hand/capture/cues.py create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/beep.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/closing.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/count.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/fist.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/flat_on_table_close.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/flat_on_table_open.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/index.json create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/index.json.license create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/left_tip_roll.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/palms_together.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/pinch_index.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/pinch_little.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/pinch_middle.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/pinch_ring.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/right_tip_roll.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/tick.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/cues/tips_together.wav create mode 100644 src/python/isaaccapture/cts/hand/capture/live.py create mode 100644 src/python/isaaccapture/cts/hand/capture/make_labels.py create mode 100644 src/python/isaaccapture/cts/hand/capture/pytest.ini create mode 100755 src/python/isaaccapture/cts/hand/capture/record.sh create mode 100644 src/python/isaaccapture/cts/hand/capture/render.py create mode 100644 src/python/isaaccapture/cts/hand/capture/requirements.txt create mode 100644 src/python/isaaccapture/cts/hand/capture/session.py create mode 100755 src/python/isaaccapture/cts/hand/capture/setup_env.sh create mode 100644 src/python/isaaccapture/cts/hand/capture/steps.py create mode 100644 src/python/isaaccapture/cts/hand/capture/table.py create mode 100644 src/python/isaaccapture/cts/hand/capture/tests/test_make_labels.py create mode 100644 src/python/isaaccapture/cts/hand/capture/tests/test_steps.py create mode 100644 src/python/isaaccapture/cts/hand/capture/tests/test_table.py create mode 100644 src/python/isaaccapture/cts/hand/checker/.gitignore create mode 100644 src/python/isaaccapture/cts/hand/checker/AGENTS.md create mode 100644 src/python/isaaccapture/cts/hand/checker/README.md create mode 100644 src/python/isaaccapture/cts/hand/checker/pytest.ini create mode 100644 src/python/isaaccapture/cts/hand/checker/requirements-dev.txt create mode 100644 src/python/isaaccapture/cts/hand/checker/requirements-panel.txt create mode 100644 src/python/isaaccapture/cts/hand/checker/requirements.txt create mode 100755 src/python/isaaccapture/cts/hand/checker/setup_env.sh create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/__init__.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/_schema.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/checks/__init__.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/checks/base.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/checks/envelope.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/checks/placement.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/checks/shape.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/checks/windows.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/cli.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/frames.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/labels.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/mcap_source.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/panel/__init__.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/panel/__main__.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/panel/app.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/panel/status.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/panel/track.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/profile.py create mode 100644 src/python/isaaccapture/cts/hand/checker/src/hand_cts/report.py create mode 100644 src/python/isaaccapture/cts/hand/checker/tests/conftest.py create mode 100644 src/python/isaaccapture/cts/hand/checker/tests/synth.py create mode 100644 src/python/isaaccapture/cts/hand/checker/tests/test_boundaries.py create mode 100644 src/python/isaaccapture/cts/hand/checker/tests/test_checks.py create mode 100644 src/python/isaaccapture/cts/hand/checker/tests/test_cli.py create mode 100644 src/python/isaaccapture/cts/hand/checker/tests/test_oracle.py create mode 100644 src/python/isaaccapture/cts/hand/checker/tests/test_panel.py create mode 100644 src/python/isaaccapture/cts/hand/oracle/.gitignore create mode 100644 src/python/isaaccapture/cts/hand/oracle/README.md create mode 100644 src/python/isaaccapture/cts/hand/oracle/fixtures_index.json create mode 100755 src/python/isaaccapture/cts/hand/oracle/generate.sh create mode 100644 src/python/isaaccapture/cts/hand/oracle/generate_fixtures.py create mode 100644 src/python/isaaccapture/cts/hand/oracle/hand_model.py create mode 100644 src/python/isaaccapture/cts/hand/oracle/mcap_io.py create mode 100644 src/python/isaaccapture/cts/hand/oracle/script.py create mode 100644 src/python/isaaccapture/cts/hand/oracle/toolchain.py create mode 100644 src/python/isaaccapture/cts/hand/oracle/verify_fixtures.py diff --git a/.gitattributes b/.gitattributes index ce629912c..9d5bb7a31 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,6 +21,9 @@ # Video assets are tracked by default. *.mp4 filter=lfs diff=lfs merge=lfs -text +# Audio assets are tracked by default. +*.wav filter=lfs diff=lfs merge=lfs -text + # # Exceptions for small source SVGs (webpack-inlined; not binary assets). deps/cloudxr/webxr_client/src/icons/*.svg -filter -diff -merge text diff --git a/README.md b/README.md index 90a037907..1b7ca4399 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ Our [documentation page](https://nvidia.github.io/IsaacCapture) provides everyth - [Quick installation steps](https://nvidia.github.io/IsaacCapture/main/getting_started/quick_start.html) - [How to build from source](https://nvidia.github.io/IsaacCapture/main/getting_started/build_from_source.html) +### Check a Device Integration + +The CTS records one scripted take from a hand-tracking or full-body device and checks it against the data a retargeter needs; start with the [hand](src/python/isaaccapture/cts/hand/README.md) or [full-body](src/python/isaaccapture/cts/full_body/README.md) guide. + ### Install & Run Isaac Lab diff --git a/pyproject.toml b/pyproject.toml index 695d84129..2115adac8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,7 @@ install.components = ["isaacteleop_wheel", "isaacteleop_binaries"] # TODO(1.9): drop the isaacteleop entry. [tool.scikit-build.wheel] packages = { isaaccapture = "src/python/isaaccapture", "isaacteleop.py" = "src/compat/isaacteleop.py" } +exclude = ["isaaccapture/cts/**"] # `rebuild = false`: no recompile on import, so build products (.so, .pyi, py.typed, # the vendored CloudXR runtime) are frozen until a rebuild + re-install. Authored .py diff --git a/src/python/CMakeLists.txt b/src/python/CMakeLists.txt index ed49ad14a..1134b5fd4 100644 --- a/src/python/CMakeLists.txt +++ b/src/python/CMakeLists.txt @@ -26,6 +26,9 @@ file(GLOB_RECURSE ISAAC_TELEOP_PYTHON_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/isaaccapture/viz/robot/*.png" ) +# cts/ is the CTS tooling, run in place from the source tree and never shipped. +list(FILTER ISAAC_TELEOP_PYTHON_SOURCES EXCLUDE REGEX "^isaaccapture/cts/") + # viz/__init__.py does a top-level `from ._viz import ...`, so without _viz it is # unimportable -- and packages.find would enumerate it into a BUILD_VIZ=OFF wheel. if(NOT BUILD_VIZ) diff --git a/src/python/isaaccapture/cts/common/AGENTS.md b/src/python/isaaccapture/cts/common/AGENTS.md new file mode 100644 index 000000000..fe041c720 --- /dev/null +++ b/src/python/isaaccapture/cts/common/AGENTS.md @@ -0,0 +1,27 @@ + + +# Agent notes — `cts/common/` + +**CRITICAL:** complete the mandatory `AGENTS.md` preflight in [`../../../../../AGENTS.md`](../../../../../AGENTS.md) +before editing here. A change here reaches every schema's checker, so also read the +`AGENTS.md` of each checker under `../*/checker/`. + +`cts_common` is what the per-schema checkers share: file-order MCAP reading by +schema name, the accumulator model, the envelope and payload checks, verdict +aggregation, `Mark`, the label sidecar, and the viewer's `Sample`, `Track`, `Skeleton` +and status-list HTML. + +- **Schema-agnostic.** No record type, joint table, profile or check group lives here; + `tests/test_boundaries.py` rejects the names. A shared check carries no `group` — each + schema package subclasses it to set one. +- **Same dependency floor as the checkers:** `mcap` and `flatbuffers`, no + `isaaccapture`. viser only in `panel/skeleton.py`. +- **The oracles import nothing from here.** Their quaternion arithmetic is duplicated on + purpose; the full-body and hand `test_boundaries.py` assert it. +- **Changing a shared check changes two verdicts.** Run both checkers' suites. +- **After moving code in or out of here, run pre-commit, not only the tests.** Under + `from __future__ import annotations` a name used only in a type hint is never + evaluated, so a dropped import passes every test and fails ruff (F821). diff --git a/src/python/isaaccapture/cts/common/pytest.ini b/src/python/isaaccapture/cts/common/pytest.ini new file mode 100644 index 000000000..a32f9acc8 --- /dev/null +++ b/src/python/isaaccapture/cts/common/pytest.ini @@ -0,0 +1,9 @@ +; SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +; SPDX-License-Identifier: Apache-2.0 + +; No venv of its own: run with either checker's, e.g. +; cd src/python/isaaccapture/cts/common && ../hand/checker/.venv/bin/python -m pytest +[pytest] +pythonpath = src +testpaths = tests +addopts = -q --strict-markers diff --git a/src/python/isaaccapture/cts/common/src/cts_common/__init__.py b/src/python/isaaccapture/cts/common/src/cts_common/__init__.py new file mode 100644 index 000000000..055e26201 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""What every schema's CTS checker shares: reading, the check model, the verdict.""" diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/__init__.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/__init__.py new file mode 100644 index 000000000..f36fca14d --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The accumulator model, and the envelope and payload checks every schema runs. + +Shared checks carry no ``group``; each schema package subclasses them to set one. +""" diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/base.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/base.py new file mode 100644 index 000000000..164443fbd --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/base.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The accumulator contract: checks are incremental, via ``update(frame)`` / ``result()``.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, ClassVar, Mapping + +from ..frames import Frame + + +class Status(StrEnum): + PASS = "pass" + FAIL = "fail" + INSUFFICIENT_DATA = "insufficient_data" + + +class Severity(StrEnum): + HARD = "hard" + SOFT = "soft" + ADVISORY = "advisory" + + +class Attribution(StrEnum): + DEVICE = "device" + PERFORMANCE = "performance" + + +@dataclass(frozen=True, slots=True) +class Outcome: + status: Status + detail: str = "" + measurements: Mapping[str, Any] = field(default_factory=dict) + + # Per-result override of the check's ``attribution``. + attribution: "Attribution | None" = None + + +class Check(ABC): + """Subclasses set the class-level fields and implement ``_update`` / ``_result``. + + ``name`` must match the fixture index's ``expected_failing_check`` for its defect. + """ + + name: ClassVar[str] + # Group a result is reported under; set by each schema package. + group: ClassVar[str] + # HARD and SOFT failures count toward the verdict; ADVISORY failures are reported only. + severity: ClassVar[Severity] = Severity.HARD + # Who a failure is blamed on: DEVICE gives a fail verdict, PERFORMANCE a retake. + attribution: ClassVar[Attribution] = Attribution.DEVICE + summary: ClassVar[str] = "" + min_frames: ClassVar[int] = 1 + + # False when the answer depends on what the subject did; an unanswered required check + # makes the verdict insufficient_data. + required: ClassVar[bool] = True + + # False for a check that only reports a measurement; it returns PASS to mean "measured". + judged: ClassVar[bool] = True + + # Names of checks whose failure makes this one's measurement meaningless; the result + # becomes insufficient_data, never a pass. + depends_on: ClassVar[tuple[str, ...]] = () + + def __init__(self) -> None: + self.frames_seen = 0 + + def update(self, frame: Frame) -> None: + self.frames_seen += 1 + self._update(frame) + + def result(self) -> Outcome: + if self.frames_seen < self.min_frames: + return Outcome( + Status.INSUFFICIENT_DATA, + f"{self.frames_seen} frames, needs at least {self.min_frames}", + ) + return self._result() + + @abstractmethod + def _update(self, frame: Frame) -> None: ... + + @abstractmethod + def _result(self) -> Outcome: ... diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/continuity.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/continuity.py new file mode 100644 index 000000000..b42e84774 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/continuity.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import math + +from ..frames import Frame, JointPose +from .base import Check, Outcome, Severity, Status + + +class MaxJointVelocity(Check): + name = "continuity.max_joint_velocity" + severity = Severity.HARD + summary = "No joint moves faster than a human limb can" + min_frames = 2 + + # Speed above which a joint movement counts as a violation, m/s. + MAX_SPEED_MPS = 20.0 + + # Intervals shorter than this are discarded as clock artefacts, seconds. + MIN_INTERVAL_S = 0.004 + + def __init__(self) -> None: + super().__init__() + self.previous_joints: tuple[JointPose, ...] | None = None + self.previous_time: int | None = None + self.samples = 0 + self.peak_speed = 0.0 + self.peak_joint: int | None = None + self.peak_frame: int | None = None + self.violations = 0 + self.discarded_intervals = 0 + + def _update(self, frame: Frame) -> None: + joints, time_ns = frame.joints, frame.sample_time_ns + previous, previous_time = self.previous_joints, self.previous_time + + if joints is not None and time_ns is not None: + self.previous_joints, self.previous_time = joints, time_ns + + if ( + joints is None + or time_ns is None + or previous is None + or previous_time is None + ): + return + dt = (time_ns - previous_time) / 1e9 + if dt <= 0: + return + if dt < self.MIN_INTERVAL_S: + self.discarded_intervals += 1 + return + + for index, (before, after) in enumerate(zip(previous, joints)): + # An invalid joint carries a zero pose, which would read as a teleport. + if not (before.is_valid and after.is_valid): + continue + delta = [a - b for a, b in zip(after.position, before.position)] + if not all(math.isfinite(component) for component in delta): + continue + speed = math.sqrt(sum(component * component for component in delta)) / dt + self.samples += 1 + if speed > self.MAX_SPEED_MPS: + self.violations += 1 + if speed > self.peak_speed: + self.peak_speed = speed + self.peak_joint = index + self.peak_frame = frame.sequence + + def _result(self) -> Outcome: + if self.samples == 0: + return Outcome(Status.INSUFFICIENT_DATA, "no consecutive valid joint pairs") + measurements = { + "samples": self.samples, + "peak_speed_mps": self.peak_speed, + "peak_joint": self.peak_joint, + "peak_frame": self.peak_frame, + "violations": self.violations, + "discarded_short_intervals": self.discarded_intervals, + } + if self.violations == 0: + return Outcome(Status.PASS, f"peak {self.peak_speed:.1f} m/s", measurements) + return Outcome( + Status.FAIL, + f"joint {self.peak_joint} reaches {self.peak_speed:.0f} m/s at frame " + f"{self.peak_frame} ({self.violations} samples past " + f"{self.MAX_SPEED_MPS:.0f} m/s)", + measurements, + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/coverage.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/coverage.py new file mode 100644 index 000000000..ea185efe8 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/coverage.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections import deque + +from ..frames import Frame +from .base import Check, Outcome, Severity, Status + + +class PayloadPresenceRate(Check): + name = "coverage.payload_presence_rate" + severity = Severity.HARD + summary = "Most records actually carry a pose" + + # Minimum fraction of records that carry a `data` table. + MIN_PRESENCE_RATE = 0.95 + + def __init__(self) -> None: + super().__init__() + self.with_payload = 0 + + def _update(self, frame: Frame) -> None: + if frame.has_payload: + self.with_payload += 1 + + def _result(self) -> Outcome: + rate = self.with_payload / self.frames_seen + measurements = { + "records": self.frames_seen, + "with_payload": self.with_payload, + "rate": rate, + } + if rate >= self.MIN_PRESENCE_RATE: + return Outcome(Status.PASS, f"{rate:.1%} carry a pose", measurements) + return Outcome( + Status.FAIL, + f"only {rate:.0%} of {self.frames_seen} records carry a `data` table, so " + f"the integration is producing almost no pose data", + measurements, + ) + + +class ValidityTrend(Check): + name = "coverage.validity_trend" + severity = Severity.SOFT + summary = "Per-joint validity coverage does not decay over the session" + + # Frames in the head and tail windows that are compared. + WINDOW = 120 + MAX_COVERAGE_DROP = 0.25 + min_frames = 2 * WINDOW + + def __init__(self) -> None: + super().__init__() + self.head: list[float] = [] + self.tail: deque[float] = deque(maxlen=self.WINDOW) + + def _update(self, frame: Frame) -> None: + if frame.joints is None: + return + coverage = sum(1 for joint in frame.joints if joint.is_valid) / len( + frame.joints + ) + if len(self.head) < self.WINDOW: + self.head.append(coverage) + self.tail.append(coverage) + + def _result(self) -> Outcome: + if len(self.head) < self.WINDOW or len(self.tail) < self.WINDOW: + return Outcome( + Status.INSUFFICIENT_DATA, + f"needs {2 * self.WINDOW} frames carrying joints", + ) + start = sum(self.head) / len(self.head) + end = sum(self.tail) / len(self.tail) + drop = start - end + measurements = { + "start_coverage": start, + "end_coverage": end, + "drop": drop, + "window_frames": self.WINDOW, + } + if drop <= self.MAX_COVERAGE_DROP: + return Outcome( + Status.PASS, + f"coverage {start:.0%} -> {end:.0%}", + measurements, + ) + return Outcome( + Status.FAIL, + f"validity coverage decays from {start:.0%} to {end:.0%} over the " + f"session, so trackers are dropping out progressively", + measurements, + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/quaternion.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/quaternion.py new file mode 100644 index 000000000..6bfb80055 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/quaternion.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import math + +from ..frames import Frame +from .base import Check, Outcome, Severity, Status + + +class UnitNormOnValidJoints(Check): + name = "quaternion.unit_norm_on_valid_joints" + severity = Severity.HARD + summary = "Orientations on valid joints are unit quaternions" + + # Allowed deviation of the quaternion norm from 1; invalid joints are skipped. + TOLERANCE = 1e-3 + + def __init__(self) -> None: + super().__init__() + self.samples = 0 + self.violations = 0 + self.offending_joints: set[int] = set() + self.worst_norm: float | None = None + + def _update(self, frame: Frame) -> None: + for index, joint in frame.valid_joints(): + norm = math.sqrt(sum(c * c for c in joint.orientation)) + if not math.isfinite(norm): + continue + self.samples += 1 + error = abs(norm - 1.0) + if error <= self.TOLERANCE: + continue + self.violations += 1 + self.offending_joints.add(index) + if self.worst_norm is None or error > abs(self.worst_norm - 1.0): + self.worst_norm = norm + + def _result(self) -> Outcome: + if self.samples == 0: + return Outcome(Status.INSUFFICIENT_DATA, "no valid orientations seen") + measurements = { + "samples": self.samples, + "violations": self.violations, + "joints": sorted(self.offending_joints), + "worst_norm": self.worst_norm, + } + if self.violations == 0: + return Outcome(Status.PASS, "all unit norm", measurements) + return Outcome( + Status.FAIL, + f"{self.violations} of {self.samples} valid orientations off the unit " + f"sphere on joints {sorted(self.offending_joints)}, worst norm " + f"{self.worst_norm:.3f}", + measurements, + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/rate.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/rate.py new file mode 100644 index 000000000..5b0994f11 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/rate.py @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import statistics +from typing import ClassVar + +from ..frames import Frame +from .base import Check, Outcome, Severity, Status + + +class _IntervalCheck(Check): + """Collects intervals between sample times.""" + + def __init__(self) -> None: + super().__init__() + self.previous: int | None = None + self.intervals_ns: list[int] = [] + + def _update(self, frame: Frame) -> None: + if frame.sample_time_ns is None: + return + if self.previous is not None: + self.intervals_ns.append(frame.sample_time_ns - self.previous) + self.previous = frame.sample_time_ns + + def _forward_intervals(self) -> list[int]: + return [d for d in self.intervals_ns if d > 0] + + +class IntervalRegularity(_IntervalCheck): + name = "rate.interval_regularity" + severity = Severity.SOFT + summary = "Frame intervals are regular around the nominal period" + min_frames = 30 + + # Largest median absolute deviation of the interval, as a fraction of the median. + MAX_RELATIVE_MAD = 0.2 + + def _result(self) -> Outcome: + intervals = self._forward_intervals() + if len(intervals) < 10: + return Outcome( + Status.INSUFFICIENT_DATA, f"{len(intervals)} forward intervals" + ) + median = statistics.median(intervals) + if median <= 0: + return Outcome(Status.INSUFFICIENT_DATA, "no positive median interval") + mad = statistics.median([abs(d - median) for d in intervals]) + relative = mad / median + measurements = { + "median_interval_ms": median / 1e6, + "nominal_rate_hz": 1e9 / median, + "mad_ms": mad / 1e6, + "relative_mad": relative, + "min_interval_ms": min(intervals) / 1e6, + "max_interval_ms": max(intervals) / 1e6, + } + if relative <= self.MAX_RELATIVE_MAD: + return Outcome( + Status.PASS, + f"{1e9 / median:.1f} Hz, jitter {mad / 1e6:.2f} ms", + measurements, + ) + return Outcome( + Status.FAIL, + f"interval jitter {mad / 1e6:.1f} ms is {relative:.0%} of the " + f"{median / 1e6:.1f} ms median period", + measurements, + ) + + +class MinimumRate(_IntervalCheck): + name = "rate.minimum_rate" + severity = Severity.HARD + summary = "The median frame rate is not below the schema's minimum" + min_frames = 30 + + # Lowest median frame rate accepted, hertz; None leaves the check unanswered. + MIN_HZ: ClassVar[float | None] = None + + def _result(self) -> Outcome: + intervals = self._forward_intervals() + if len(intervals) < 10: + return Outcome( + Status.INSUFFICIENT_DATA, f"{len(intervals)} forward intervals" + ) + median = statistics.median(intervals) + if median <= 0: + return Outcome(Status.INSUFFICIENT_DATA, "no positive median interval") + hz = 1e9 / median + measurements = {"median_rate_hz": hz, "min_rate_hz": self.MIN_HZ} + if self.MIN_HZ is None: + return Outcome( + Status.INSUFFICIENT_DATA, "no minimum rate set", measurements + ) + if hz >= self.MIN_HZ: + return Outcome(Status.PASS, f"{hz:.1f} Hz", measurements) + return Outcome( + Status.FAIL, + f"{hz:.1f} Hz is below the {self.MIN_HZ:g} Hz minimum", + measurements, + ) + + +class FrameGaps(_IntervalCheck): + name = "rate.frame_gaps" + severity = Severity.SOFT + summary = "The stream has no dropped blocks of frames" + min_frames = 10 + + # An interval longer than this multiple of the median counts as a gap. + GAP_MULTIPLE = 5.0 + + def _result(self) -> Outcome: + intervals = self._forward_intervals() + if len(intervals) < 5: + return Outcome( + Status.INSUFFICIENT_DATA, f"{len(intervals)} forward intervals" + ) + median = statistics.median(intervals) + if median <= 0: + return Outcome(Status.INSUFFICIENT_DATA, "no positive median interval") + limit = median * self.GAP_MULTIPLE + gaps = [d for d in intervals if d > limit] + measurements = { + "median_interval_ms": median / 1e6, + "gap_threshold_ms": limit / 1e6, + "gaps": len(gaps), + "gap_lengths_ms": [round(d / 1e6, 1) for d in sorted(gaps, reverse=True)], + "lost_seconds": sum(gaps) / 1e9, + } + if not gaps: + return Outcome(Status.PASS, "no gaps", measurements) + return Outcome( + Status.FAIL, + f"{len(gaps)} gaps beyond {limit / 1e6:.0f} ms " + f"({', '.join(f'{d / 1e6:.0f} ms' for d in sorted(gaps, reverse=True)[:6])})", + measurements, + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/schema.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/schema.py new file mode 100644 index 000000000..a0ba69c87 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/schema.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from ..frames import Frame +from .base import Check, Outcome, Severity, Status + + +class JointsFieldPresent(Check): + name = "schema.required_field_present.joints" + severity = Severity.HARD + summary = "A record carrying a pose also carries its joints" + + def __init__(self) -> None: + super().__init__() + self.with_payload = 0 + self.missing_joints = 0 + self.first_frame: int | None = None + + def _update(self, frame: Frame) -> None: + if not frame.has_payload: + return + self.with_payload += 1 + if frame.has_joints: + return + self.missing_joints += 1 + if self.first_frame is None: + self.first_frame = frame.sequence + + def _result(self) -> Outcome: + if self.with_payload == 0: + return Outcome(Status.INSUFFICIENT_DATA, "no record carried a pose") + measurements = { + "records_with_payload": self.with_payload, + "missing_joints": self.missing_joints, + "first_frame": self.first_frame, + } + if self.missing_joints == 0: + return Outcome(Status.PASS, "joints present on every pose", measurements) + return Outcome( + Status.FAIL, + f"{self.missing_joints} of {self.with_payload} records carry a " + f"pose whose joints field is absent, first at frame " + f"{self.first_frame}", + measurements, + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/segmentation.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/segmentation.py new file mode 100644 index 000000000..be86b2680 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/segmentation.py @@ -0,0 +1,149 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Label-window checks that read only the sidecar and the sample clock.""" + +from __future__ import annotations + +from typing import Any + +from ..frames import Frame +from ..labels import StepTimeline +from .base import Attribution, Check, Outcome, Severity, Status + + +class LabelCheck(Check): + """A check that reads the recording through the label windows.""" + + needs_timeline = True + required = False + + def __init__(self, timeline: StepTimeline | None = None, **_: Any) -> None: + super().__init__() + self.timeline = timeline + + +class LabelWindowsWellformed(LabelCheck): + name = "segmentation.label_windows_wellformed" + severity = Severity.HARD + attribution = Attribution.DEVICE + summary = "The label windows are ordered, non-overlapping, and named once each" + + def _update(self, frame: Frame) -> None: + return + + def _result(self) -> Outcome: + if self.timeline is None: + return Outcome(Status.INSUFFICIENT_DATA, "no motion labels to check") + defects = self.timeline.defects() + between_s = self.timeline.unlabelled_between_ns / 1e9 + measurements = { + "steps": len(self.timeline.steps), + "unlabelled_between_s": between_s, + "defects": [f"{d.kind}: {d.detail}" for d in defects], + } + if not defects: + return Outcome( + Status.PASS, + f"{len(self.timeline.steps)} windows in order and non-overlapping, " + f"with {between_s:.1f} s between them unlabelled", + measurements, + ) + kinds = sorted({d.kind for d in defects}) + return Outcome( + Status.FAIL, + f"the label windows are not well formed ({', '.join(kinds)}); every " + f"measurement downstream would describe the wrong frames", + measurements, + ) + + +class LabelAlignment(LabelCheck): + name = "segmentation.label_alignment" + severity = Severity.HARD + attribution = Attribution.DEVICE + summary = "Every labelled window is backed by the frames it describes" + + # Fraction of its own length a window may stick out past the recorded span. + MAX_UNCOVERED_FRACTION = 0.02 + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.first_ns: int | None = None + self.last_ns: int | None = None + self.per_window: dict[int, int] = {} + self.unlabelled = 0 + self.labelled = 0 + + def _update(self, frame: Frame) -> None: + if frame.sample_time_ns is None: + return + if self.first_ns is None: + self.first_ns = frame.sample_time_ns + self.last_ns = frame.sample_time_ns + if self.timeline is None: + return + step = self.timeline.step_at(frame.sample_time_ns) + if step is None: + self.unlabelled += 1 + return + self.labelled += 1 + self.per_window[step.index] = self.per_window.get(step.index, 0) + 1 + + def _covered(self, step) -> float: + """Fraction of a window's own span that the recording actually spans.""" + length = step.end_ns - step.start_ns + if length <= 0: + return 0.0 + overlap = min(step.end_ns, self.last_ns) - max(step.start_ns, self.first_ns) + return max(0.0, min(1.0, overlap / length)) + + def _result(self) -> Outcome: + if self.timeline is None: + return Outcome(Status.INSUFFICIENT_DATA, "no motion labels to align") + span = self.timeline.span_ns + if span is None or self.first_ns is None or self.last_ns is None: + return Outcome( + Status.INSUFFICIENT_DATA, + "no timestamped frames to align labels against", + ) + + lead_in_s = (span[0] - self.first_ns) / 1e9 + tail_s = (self.last_ns - span[1]) / 1e9 + starved, clipped = [], [] + for step in self.timeline.steps: + if not self.per_window.get(step.index): + starved.append(step.label) + elif self._covered(step) < 1.0 - self.MAX_UNCOVERED_FRACTION: + clipped.append(f"{step.label} ({self._covered(step):.0%} covered)") + + measurements = { + "lead_in_s": lead_in_s, + "tail_s": tail_s, + "unlabelled_fraction": self.unlabelled + / max(1, self.labelled + self.unlabelled), + "unlabelled_frames": self.unlabelled, + "windows_without_frames": starved, + "windows_partly_outside": clipped, + } + + if starved: + return Outcome( + Status.FAIL, + f"no frames fall inside {', '.join(starved)}, so the labels do not " + f"belong to this recording", + measurements, + ) + if clipped: + return Outcome( + Status.FAIL, + f"the recording ran out part way through {', '.join(clipped)}, so " + f"those windows would be measured from a fragment", + measurements, + ) + return Outcome( + Status.PASS, + f"all {len(self.timeline.steps)} windows are backed by frames, with " + f"{lead_in_s:.1f} s of lead-in and {tail_s:.1f} s of tail unlabelled", + measurements, + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/timestamps.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/timestamps.py new file mode 100644 index 000000000..dedef6a9b --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/timestamps.py @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from ..frames import Frame +from .base import Check, Outcome, Severity, Status + + +class Monotonic(Check): + name = "timestamps.monotonic" + severity = Severity.HARD + summary = "Sample timestamps never step backwards" + min_frames = 2 + + def __init__(self) -> None: + super().__init__() + self.previous_sample: int | None = None + self.previous_log: int | None = None + self.sample_regressions = 0 + self.log_regressions = 0 + self.worst_step_ns = 0 + self.first_frame: int | None = None + + def _update(self, frame: Frame) -> None: + if frame.sample_time_ns is not None: + if ( + self.previous_sample is not None + and frame.sample_time_ns < self.previous_sample + ): + self.sample_regressions += 1 + step = self.previous_sample - frame.sample_time_ns + self.worst_step_ns = max(self.worst_step_ns, step) + if self.first_frame is None: + self.first_frame = frame.sequence + self.previous_sample = frame.sample_time_ns + + if self.previous_log is not None and frame.log_time_ns < self.previous_log: + self.log_regressions += 1 + self.previous_log = frame.log_time_ns + + def _result(self) -> Outcome: + measurements = { + "sample_regressions": self.sample_regressions, + "log_time_regressions": self.log_regressions, + "worst_step_ms": self.worst_step_ns / 1e6, + "first_frame": self.first_frame, + } + if self.sample_regressions == 0 and self.log_regressions == 0: + return Outcome(Status.PASS, "monotonic", measurements) + return Outcome( + Status.FAIL, + f"{self.sample_regressions} backward sample-time steps " + f"({self.log_regressions} in logTime), worst " + f"{self.worst_step_ns / 1e6:.1f} ms, first at frame {self.first_frame}", + measurements, + ) + + +class AvailableNotBeforeSample(Check): + name = "timestamps.available_not_before_sample" + severity = Severity.HARD + summary = "A sample is never available before it was taken" + + def __init__(self) -> None: + super().__init__() + self.samples = 0 + self.inversions = 0 + self.worst_ns = 0 + + def _update(self, frame: Frame) -> None: + if frame.available_time_ns is None or frame.sample_time_ns is None: + return + self.samples += 1 + deficit = frame.sample_time_ns - frame.available_time_ns + if deficit > 0: + self.inversions += 1 + self.worst_ns = max(self.worst_ns, deficit) + + def _result(self) -> Outcome: + if self.samples == 0: + return Outcome(Status.INSUFFICIENT_DATA, "no timestamps present") + measurements = { + "samples": self.samples, + "inversions": self.inversions, + "worst_ms": self.worst_ns / 1e6, + } + if self.inversions == 0: + return Outcome(Status.PASS, "available >= sample", measurements) + return Outcome( + Status.FAIL, + f"{self.inversions} of {self.samples} records became available up to " + f"{self.worst_ns / 1e6:.1f} ms before the sample was taken", + measurements, + ) + + +class DeviceClockDistinct(Check): + name = "timestamps.device_clock_distinct" + severity = Severity.ADVISORY + summary = "The raw device clock is not a copy of the local common clock" + + # Largest fraction of records whose device and common-clock times may be identical. + MAX_IDENTICAL_RATE = 0.5 + + def __init__(self) -> None: + super().__init__() + self.samples = 0 + self.identical = 0 + + def _update(self, frame: Frame) -> None: + if frame.device_time_ns is None or frame.sample_time_ns is None: + return + self.samples += 1 + if frame.device_time_ns == frame.sample_time_ns: + self.identical += 1 + + def _result(self) -> Outcome: + if self.samples == 0: + return Outcome(Status.INSUFFICIENT_DATA, "no timestamps present") + rate = self.identical / self.samples + measurements = { + "samples": self.samples, + "identical": self.identical, + "rate": rate, + } + if rate <= self.MAX_IDENTICAL_RATE: + return Outcome( + Status.PASS, f"{rate:.1%} of records share a value", measurements + ) + return Outcome( + Status.FAIL, + f"sample_time_raw_device_clock equals sample_time_local_common_clock on " + f"{rate:.0%} of records, so there is no independent device clock for " + f"cross-device sync", + measurements, + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/checks/values.py b/src/python/isaaccapture/cts/common/src/cts_common/checks/values.py new file mode 100644 index 000000000..d199a76ac --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/checks/values.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import math + +from ..frames import Frame +from .base import Check, Outcome, Severity, Status + + +class Finite(Check): + name = "values.finite" + severity = Severity.HARD + summary = "Positions and orientations on valid joints are finite" + + def __init__(self) -> None: + super().__init__() + self.nan_components = 0 + self.inf_components = 0 + self.offending_joints: set[int] = set() + self.first_frame: int | None = None + + def _update(self, frame: Frame) -> None: + # Invalid joints carry arbitrary poses and are skipped. + for index, joint in frame.valid_joints(): + for value in (*joint.position, *joint.orientation): + if math.isnan(value): + self.nan_components += 1 + elif math.isinf(value): + self.inf_components += 1 + else: + continue + self.offending_joints.add(index) + if self.first_frame is None: + self.first_frame = frame.sequence + + def _result(self) -> Outcome: + total = self.nan_components + self.inf_components + measurements = { + "nan_components": self.nan_components, + "inf_components": self.inf_components, + "joints": sorted(self.offending_joints), + "first_frame": self.first_frame, + } + if total == 0: + return Outcome(Status.PASS, "all finite", measurements) + return Outcome( + Status.FAIL, + f"{self.nan_components} NaN and {self.inf_components} Inf components on " + f"{len(self.offending_joints)} valid joints, first at frame " + f"{self.first_frame}", + measurements, + ) + + +class ZeroPoseOnValidJoint(Check): + name = "values.zero_pose_on_valid_joint" + severity = Severity.HARD + summary = "A joint marked valid does not sit at exactly the origin" + + # Largest fraction of valid joints whose position may be exactly zero. + MAX_ZERO_RATE = 0.01 + + def __init__(self) -> None: + super().__init__() + self.zero_valid = 0 + self.valid_total = 0 + self.offending_joints: set[int] = set() + + def _update(self, frame: Frame) -> None: + for index, joint in frame.valid_joints(): + self.valid_total += 1 + if any(joint.position): + continue + self.zero_valid += 1 + self.offending_joints.add(index) + + def _result(self) -> Outcome: + if self.valid_total == 0: + return Outcome(Status.INSUFFICIENT_DATA, "no valid joints seen") + rate = self.zero_valid / self.valid_total + measurements = { + "zero_position_valid_joints": self.zero_valid, + "valid_joint_samples": self.valid_total, + "rate": rate, + "joints": sorted(self.offending_joints), + } + if rate <= self.MAX_ZERO_RATE: + return Outcome( + Status.PASS, f"{rate:.2%} of valid joints at origin", measurements + ) + return Outcome( + Status.FAIL, + f"{rate:.1%} of valid joint samples report position (0,0,0) across " + f"joints {sorted(self.offending_joints)}", + measurements, + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/frames.py b/src/python/isaaccapture/cts/common/src/cts_common/frames.py new file mode 100644 index 000000000..bd9d35f29 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/frames.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Protocols for the decoded frames the shared checks read, and for frame sources.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterator, Protocol, Sequence, runtime_checkable + + +class JointPose(Protocol): + @property + def position(self) -> tuple[float, float, float]: ... + + @property + def orientation(self) -> tuple[float, float, float, float]: ... + + @property + def is_valid(self) -> bool: ... + + +class Frame(Protocol): + @property + def sequence(self) -> int: ... + + @property + def log_time_ns(self) -> int: ... + + @property + def has_payload(self) -> bool: ... + + @property + def available_time_ns(self) -> int | None: ... + + @property + def sample_time_ns(self) -> int | None: ... + + @property + def device_time_ns(self) -> int | None: ... + + @property + def joints(self) -> Sequence[JointPose] | None: ... + + @property + def has_joints(self) -> bool: ... + + def valid_joints(self) -> Iterator[tuple[int, JointPose]]: ... + + +@dataclass(frozen=True, slots=True) +class SourceMetadata: + """Container-level facts about the matched channel.""" + + schema_name: str | None + schema_encoding: str | None + schema_data: bytes | None + message_encoding: str | None + topic: str | None + profile: str | None + channel_found: bool + + +@runtime_checkable +class FrameSource(Protocol): + """Iterating must yield frames in the order they appear in the source.""" + + @property + def metadata(self) -> SourceMetadata: ... + + def __iter__(self) -> Iterator[Frame]: ... diff --git a/src/python/isaaccapture/cts/common/src/cts_common/labels.py b/src/python/isaaccapture/cts/common/src/cts_common/labels.py new file mode 100644 index 000000000..b30743bf6 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/labels.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Motion-step labels and the timeline the window checks read them through. + +Labels live in a sidecar JSON beside the recording, timed in +``sample_time_local_common_clock``. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar + +SIDECAR_SUFFIX = ".labels.json" + + +@dataclass(frozen=True, slots=True) +class Step: + index: int + label: str + start_ns: int + end_ns: int + is_still_window: bool + + @property + def duration_s(self) -> float: + return (self.end_ns - self.start_ns) / 1e9 + + def contains(self, sample_ns: int) -> bool: + return self.start_ns <= sample_ns < self.end_ns + + +@dataclass(frozen=True, slots=True) +class Defect: + kind: str + detail: str + + +@dataclass(frozen=True) +class StepTimeline: + """Subclassed per schema to name the script's still windows.""" + + # What ``is_still_window`` defaults to for a sidecar row that does not say. + still_labels: ClassVar[frozenset[str]] = frozenset() + + steps: tuple[Step, ...] + nominal_rate_hz: float | None = None + source: str = "" + + @classmethod + def load(cls, path: str | Path) -> StepTimeline: + payload = json.loads(Path(path).read_text()) + steps = tuple( + Step( + index=int(raw["index"]), + label=str(raw["label"]), + start_ns=int(raw["start_ns"]), + end_ns=int(raw["end_ns"]), + is_still_window=bool( + raw.get("is_still_window", raw["label"] in cls.still_labels) + ), + ) + for raw in payload["steps"] + ) + return cls( + steps=steps, + nominal_rate_hz=payload.get("nominal_rate_hz"), + source=str(path), + ) + + @classmethod + def beside(cls, recording: str | Path) -> StepTimeline | None: + """Loads ``.labels.json``, or returns None when there is none.""" + path = Path(recording) + sidecar = path.with_name(path.stem + SIDECAR_SUFFIX) + return cls.load(sidecar) if sidecar.is_file() else None + + def step_at(self, sample_ns: int | None) -> Step | None: + if sample_ns is None: + return None + for step in self.steps: + if step.contains(sample_ns): + return step + return None + + def named(self, label: str) -> tuple[Step, ...]: + return tuple(step for step in self.steps if step.label == label) + + def one(self, label: str) -> Step | None: + found = self.named(label) + return found[0] if len(found) == 1 else None + + @property + def span_ns(self) -> tuple[int, int] | None: + if not self.steps: + return None + return ( + min(step.start_ns for step in self.steps), + max(step.end_ns for step in self.steps), + ) + + @property + def unlabelled_between_ns(self) -> int: + """Nanoseconds between consecutive windows that no window claims.""" + ordered = sorted(self.steps, key=lambda step: step.start_ns) + return sum( + max(0, later.start_ns - earlier.end_ns) + for earlier, later in zip(ordered, ordered[1:]) + ) + + def defects(self) -> tuple[Defect, ...]: + """Structural faults in the labels: empty, out-of-order, overlapping, duplicate.""" + found: list[Defect] = [] + if not self.steps: + return (Defect("empty", "the sidecar declares no steps"),) + + for step in self.steps: + if step.end_ns <= step.start_ns: + found.append( + Defect( + "empty_window", + f"step {step.index} {step.label!r} ends at or before it starts", + ) + ) + + ordered = sorted(self.steps, key=lambda step: step.start_ns) + if [step.index for step in ordered] != [step.index for step in self.steps]: + found.append( + Defect("out_of_order", "step indices do not follow the start times") + ) + for earlier, later in zip(ordered, ordered[1:]): + if later.start_ns < earlier.end_ns: + found.append( + Defect( + "overlap", + f"{earlier.label!r} and {later.label!r} overlap by " + f"{(earlier.end_ns - later.start_ns) / 1e9:.2f} s", + ) + ) + + duplicated = sorted( + {step.label for step in self.steps if len(self.named(step.label)) > 1} + ) + if duplicated: + found.append( + Defect("duplicate_label", f"more than one window named {duplicated}") + ) + return tuple(found) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/mcap_reader.py b/src/python/isaaccapture/cts/common/src/cts_common/mcap_reader.py new file mode 100644 index 000000000..b5b2aca43 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/mcap_reader.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Walks an MCAP file for the channels that declare one schema, matched by schema name. + +Read in file order; make_reader() re-sorts by log time and hides out-of-order timestamps. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterator + +from mcap.records import Channel, Header, Message, Schema +from mcap.stream_reader import StreamReader + +from .frames import SourceMetadata + + +def scan(path: str | Path, schema_name: str) -> SourceMetadata: + """Finds the first matching channel without decoding any payload.""" + profile = None + schemas: dict[int, Schema] = {} + with Path(path).open("rb") as handle: + for record in StreamReader(handle).records: + if isinstance(record, Header): + profile = record.profile + elif isinstance(record, Schema): + schemas[record.id] = record + elif isinstance(record, Channel): + schema = schemas.get(record.schema_id) + if schema is not None and schema.name == schema_name: + return SourceMetadata( + schema_name=schema.name, + schema_encoding=schema.encoding, + schema_data=schema.data, + message_encoding=record.message_encoding, + topic=record.topic, + profile=profile, + channel_found=True, + ) + return SourceMetadata( + schema_name=None, + schema_encoding=None, + schema_data=None, + message_encoding=None, + topic=None, + profile=profile, + channel_found=False, + ) + + +def topics(path: str | Path, schema_name: str) -> tuple[str, ...]: + """Every channel topic declaring ``schema_name``, in registration order.""" + found: list[str] = [] + schemas: dict[int, Schema] = {} + with Path(path).open("rb") as handle: + for record in StreamReader(handle).records: + if isinstance(record, Schema): + schemas[record.id] = record + elif isinstance(record, Channel): + schema = schemas.get(record.schema_id) + if schema is not None and schema.name == schema_name: + found.append(record.topic) + return tuple(found) + + +def messages(path: str | Path, schema_name: str) -> Iterator[tuple[str, Message]]: + """``(topic, message)`` for every message on a matching channel, in file order.""" + schemas: dict[int, Schema] = {} + channels: dict[int, str] = {} + with Path(path).open("rb") as handle: + for record in StreamReader(handle).records: + if isinstance(record, Schema): + schemas[record.id] = record + elif isinstance(record, Channel): + schema = schemas.get(record.schema_id) + if schema is not None and schema.name == schema_name: + channels[record.id] = record.topic + elif isinstance(record, Message) and record.channel_id in channels: + yield channels[record.channel_id], record diff --git a/src/python/isaaccapture/cts/common/src/cts_common/panel/__init__.py b/src/python/isaaccapture/cts/common/src/cts_common/panel/__init__.py new file mode 100644 index 000000000..e203b4eef --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/panel/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Viewer pieces. ``skeleton.py`` needs the optional viser extra; ``sample.py`` does not.""" diff --git a/src/python/isaaccapture/cts/common/src/cts_common/panel/render.py b/src/python/isaaccapture/cts/common/src/cts_common/panel/render.py new file mode 100644 index 000000000..f9f4589f8 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/panel/render.py @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""HTML for the panels' status lists.""" + +from __future__ import annotations + +from collections.abc import Sequence +from html import escape + +from ..report import CheckResult, Mark, Report, Verdict +from .status import Group + +# background, foreground +MARK_COLOURS: dict[Mark, tuple[str, str]] = { + Mark.PASS: ("#cfe9d8", "#12502c"), + Mark.FAIL: ("#c0322b", "#ffffff"), + Mark.MEAS: ("#ffd98a", "#5a3b00"), + Mark.UNANSWERED: ("#c9c9c9", "#2b2b2b"), + Mark.NOTE: ("#c9ddf0", "#123f5f"), +} + +VERDICT_COLOURS: dict[Verdict, tuple[str, str]] = { + Verdict.PASS: ("#1f7a44", "#ffffff"), + Verdict.FAIL: ("#a32820", "#ffffff"), + Verdict.RETAKE: ("#b07100", "#ffffff"), + Verdict.INSUFFICIENT_DATA: ("#5a5a5a", "#ffffff"), +} + +VERDICT_MEANING: dict[Verdict, str] = { + Verdict.PASS: "nothing in this recording argues against the integration", + Verdict.FAIL: "a CTS failure attributable to the device or its plugin", + Verdict.RETAKE: "unusable because of how it was performed; the device is not implicated", + Verdict.INSUFFICIENT_DATA: "the recording cannot answer the question", +} + + +def badge(mark: Mark) -> str: + background, foreground = MARK_COLOURS[mark] + return ( + f'{mark}' + ) + + +def result_rows(results: Sequence[CheckResult]) -> str: + if not results: + return "" + rows = "".join( + f'{badge(r.mark)}' + f'' + f'{escape(r.name)}
' + f'{escape(r.detail)}' + for r in results + ) + # The top row sits under the folder's own edge and gets clipped without the padding. + return ( + f'
' + f'{rows}
' + ) + + +def group_heading(group: Group) -> str: + """The folder label: the group's title and tally.""" + return f"{group.title} — {group.tally}" + + +def group_body(group: Group) -> str: + return result_rows(group.results) + + +def verdict_banner(report: Report) -> str: + background, foreground = VERDICT_COLOURS[report.verdict] + return ( + f'
' + f'
{str(report.verdict).upper()}
' + f'
' + f"{escape(VERDICT_MEANING[report.verdict])}
" + ) + + +def facts(pairs: Sequence[tuple[str, str]]) -> str: + rows = "".join( + f'{escape(label)}' + f'' + f"{escape(value)}" + for label, value in pairs + ) + return f'{rows}
' diff --git a/src/python/isaaccapture/cts/common/src/cts_common/panel/sample.py b/src/python/isaaccapture/cts/common/src/cts_common/panel/sample.py new file mode 100644 index 000000000..5fa0a01b5 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/panel/sample.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""One frame reduced to what a viewer draws. Pure Python, no renderer.""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import nan +from typing import Protocol + +Vec3 = tuple[float, float, float] + + +class Topology(Protocol): + @property + def joint_names(self) -> tuple[str, ...]: ... + + def bones(self) -> tuple[tuple[int, int], ...]: ... + + +@dataclass(frozen=True, slots=True) +class Sample: + """One frame reduced to what a viewer draws. + + ``positions`` holds each joint's last valid position, ``None`` if never valid; + ``valid`` says whether a drawn joint is live or held. + """ + + sequence: int + t_s: float + positions: tuple[Vec3 | None, ...] + valid: tuple[bool, ...] + interval_ms: float | None + step: str | None + + @property + def valid_count(self) -> int: + return sum(self.valid) + + @property + def rate_hz(self) -> float: + """NaN for the first frame and for any interval that did not move forward.""" + if self.interval_ms is None or self.interval_ms <= 0.0: + return nan + return 1000.0 / self.interval_ms diff --git a/src/python/isaaccapture/cts/common/src/cts_common/panel/skeleton.py b/src/python/isaaccapture/cts/common/src/cts_common/panel/skeleton.py new file mode 100644 index 000000000..2fc32e4b6 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/panel/skeleton.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Draws one skeleton in a viser scene. The only module here that imports viser.""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np +import viser + +from .sample import Sample, Topology + +LIVE_JOINT_COLOUR = (86, 196, 138) +HELD_JOINT_COLOUR = (214, 74, 62) +BONE_COLOUR = (150, 158, 172) + + +class Skeleton: + """Joint cloud, bones, and a name beside every held joint. + + ``root`` is the scene path. ``joint_colours`` replaces the live colour per joint, and + a bone takes its child's; a held joint is always drawn in ``HELD_JOINT_COLOUR``. + """ + + def __init__( + self, + server: viser.ViserServer, + profile: Topology, + root: str = "/body", + point_size: float = 0.022, + joint_colours: Sequence[tuple[int, int, int]] | None = None, + ) -> None: + self._names = profile.joint_names + self._live = ( + tuple(joint_colours) + if joint_colours is not None + else (LIVE_JOINT_COLOUR,) * len(self._names) + ) + self._bone_live = ( + self._live + if joint_colours is not None + else (BONE_COLOUR,) * len(self._names) + ) + self._bones = profile.bones() + self._points = server.scene.add_point_cloud( + f"{root}/joints", + points=np.zeros((0, 3), np.float32), + colors=np.zeros((0, 3), np.uint8), + point_size=point_size, + point_shape="circle", + ) + self._lines = server.scene.add_line_segments( + f"{root}/bones", + points=np.zeros((0, 2, 3), np.float32), + colors=np.zeros((0, 2, 3), np.uint8), + thickness=2.5, + thickness_units="screen", + ) + self._labels = [ + server.scene.add_label(f"{root}/name/{name}", name, visible=False) + for name in self._names + ] + self._named: set[int] = set() + + def draw(self, sample: Sample, name_held: bool) -> None: + points: list[tuple[float, float, float]] = [] + colours: list[tuple[int, int, int]] = [] + for index, position in enumerate(sample.positions): + if position is None: + continue + points.append(position) + colours.append( + self._live[index] if sample.valid[index] else HELD_JOINT_COLOUR + ) + self._points.points = np.asarray(points, np.float32).reshape(-1, 3) + self._points.colors = np.asarray(colours, np.uint8).reshape(-1, 3) + + segments: list[tuple] = [] + segment_colours: list[tuple] = [] + for parent, child in self._bones: + start, end = sample.positions[parent], sample.positions[child] + if start is None or end is None: + continue + live = sample.valid[parent] and sample.valid[child] + colour = self._bone_live[child] if live else HELD_JOINT_COLOUR + segments.append((start, end)) + segment_colours.append((colour, colour)) + self._lines.points = np.asarray(segments, np.float32).reshape(-1, 2, 3) + self._lines.colors = np.asarray(segment_colours, np.uint8).reshape(-1, 2, 3) + + wanted = ( + { + index + for index, position in enumerate(sample.positions) + if position is not None and not sample.valid[index] + } + if name_held + else set() + ) + for index in wanted: + position = sample.positions[index] + assert position is not None + self._labels[index].position = position + for index in wanted - self._named: + self._labels[index].visible = True + for index in self._named - wanted: + self._labels[index].visible = False + self._named = wanted + + def held_names(self, sample: Sample) -> list[str]: + return [ + self._names[index] + for index, position in enumerate(sample.positions) + if position is not None and not sample.valid[index] + ] diff --git a/src/python/isaaccapture/cts/common/src/cts_common/panel/status.py b/src/python/isaaccapture/cts/common/src/cts_common/panel/status.py new file mode 100644 index 000000000..026b4cf97 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/panel/status.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Orders results worst first and groups them.""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass + +from ..report import CheckResult, Mark, Report + +# Marks ordered worst first. +WORST_FIRST = (Mark.FAIL, Mark.UNANSWERED, Mark.MEAS, Mark.NOTE, Mark.PASS) + + +@dataclass(frozen=True, slots=True) +class Group: + code: str + title: str + results: tuple[CheckResult, ...] + + @property + def mark(self) -> Mark: + """The worst mark in the group.""" + return min((r.mark for r in self.results), key=WORST_FIRST.index) + + @property + def tally(self) -> str: + counts = Counter(r.mark for r in self.results) + return ", ".join( + f"{counts[mark]} {mark}" for mark in WORST_FIRST if counts[mark] + ) + + +def worst_first(results: tuple[CheckResult, ...]) -> tuple[CheckResult, ...]: + """Sorts results worst mark first, keeping report order within a mark.""" + return tuple(sorted(results, key=lambda r: WORST_FIRST.index(r.mark))) + + +def grouped(report: Report, titles: Sequence[tuple[str, str]]) -> tuple[Group, ...]: + """Groups with checks in them, in the declared order. An empty group is not shown.""" + by_code: dict[str, list[CheckResult]] = {} + for result in report.results: + by_code.setdefault(result.group, []).append(result) + return tuple( + Group(code, title, worst_first(tuple(by_code[code]))) + for code, title in titles + if code in by_code + ) + + +def picked(report: Report, names: Sequence[str]) -> tuple[CheckResult, ...]: + by_name = {result.name: result for result in report.results} + return tuple(by_name[name] for name in names if name in by_name) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/panel/track.py b/src/python/isaaccapture/cts/common/src/cts_common/panel/track.py new file mode 100644 index 000000000..a154cd0d1 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/panel/track.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Accumulates the frames a viewer draws, via ``update(frame)`` / ``track()``.""" + +from __future__ import annotations + +from bisect import bisect_left, bisect_right +from dataclasses import dataclass + +from ..frames import Frame +from ..labels import StepTimeline +from .sample import Sample, Topology, Vec3 + +SAMPLE_CLOCK = "sample_time_local_common_clock" +LOG_CLOCK = "log_time" + + +@dataclass(frozen=True, slots=True) +class Track: + samples: tuple[Sample, ...] + times_s: tuple[float, ...] + clock: str + profile: Topology + + @property + def duration_s(self) -> float: + return self.times_s[-1] if self.times_s else 0.0 + + @property + def joint_count(self) -> int: + return len(self.profile.joint_names) + + @property + def min_valid_count(self) -> int: + return min((s.valid_count for s in self.samples), default=0) + + def joints_ever_invalid(self) -> tuple[int, ...]: + return tuple( + index + for index in range(self.joint_count) + if any(not s.valid[index] for s in self.samples) + ) + + def index_at(self, t_s: float) -> int: + """The last sample at or before ``t_s``, clamped to the track.""" + if not self.times_s: + return 0 + return max(0, bisect_right(self.times_s, t_s) - 1) + + def rate_window(self, index: int, span_s: float) -> tuple[list[float], list[float]]: + """Instantaneous rate over the trailing ``span_s`` seconds ending at ``index``. + + Samples with no rate are left out; one NaN suppresses a whole uPlot series. + """ + if not self.samples: + return ([], []) + end = min(max(index, 0), len(self.samples) - 1) + if self.times_s[end] < span_s: + chosen = self.samples[: bisect_right(self.times_s, span_s)] + else: + start = bisect_left(self.times_s, self.times_s[end] - span_s) + chosen = self.samples[start : end + 1] + drawable = [s for s in chosen if s.rate_hz == s.rate_hz] + return ([s.t_s for s in drawable], [s.rate_hz for s in drawable]) + + def rate_extent(self) -> tuple[float, float] | None: + """Lowest and highest instantaneous rate in the take, or None if it has none.""" + finite = [s.rate_hz for s in self.samples if s.rate_hz == s.rate_hz] + return (min(finite), max(finite)) if finite else None + + def validity_series(self) -> tuple[list[float], list[float]]: + """Valid joint count against time, over the whole take.""" + return ( + [s.t_s for s in self.samples], + [float(s.valid_count) for s in self.samples], + ) + + +class TrackBuilder: + """Accumulates a ``Track``, one frame at a time; ``origin_ns`` pins time zero.""" + + def __init__( + self, + profile: Topology, + timeline: StepTimeline | None = None, + origin_ns: int | None = None, + ) -> None: + self._timeline = timeline + self._profile = profile + self._count = len(profile.joint_names) + self._samples: list[Sample] = [] + self._held: list[Vec3 | None] = [None] * self._count + self._origin_ns = origin_ns + self._previous_ns: int | None = None + self._clocks: set[str] = set() + + def update(self, frame: Frame) -> None: + stamp, clock = self._stamp(frame) + self._clocks.add(clock) + if self._origin_ns is None: + self._origin_ns = stamp + interval_ms = ( + None if self._previous_ns is None else (stamp - self._previous_ns) / 1e6 + ) + self._previous_ns = stamp + + valid = [False] * self._count + if frame.joints is not None: + for index, joint in enumerate(frame.joints): + if joint.is_valid: + valid[index] = True + self._held[index] = joint.position + + self._samples.append( + Sample( + sequence=frame.sequence, + t_s=(stamp - self._origin_ns) / 1e9, + positions=tuple(self._held), + valid=tuple(valid), + interval_ms=interval_ms, + step=self._step(frame), + ) + ) + + @staticmethod + def _stamp(frame: Frame) -> tuple[int, str]: + """The clock the rate checks use, falling back to the container's log time. + + Never substitute a local clock for a frame without a sample time. + """ + if frame.sample_time_ns is None: + return (frame.log_time_ns, LOG_CLOCK) + return (frame.sample_time_ns, SAMPLE_CLOCK) + + def _step(self, frame: Frame) -> str | None: + if self._timeline is None or frame.sample_time_ns is None: + return None + step = self._timeline.step_at(frame.sample_time_ns) + return step.label if step is not None else None + + def track(self) -> Track: + return Track( + samples=tuple(self._samples), + times_s=tuple(s.t_s for s in self._samples), + clock=" and ".join(sorted(self._clocks)), + profile=self._profile, + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/report.py b/src/python/isaaccapture/cts/common/src/cts_common/report.py new file mode 100644 index 000000000..fb02888f6 --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/report.py @@ -0,0 +1,273 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Running checks over a frame source, and the report that comes out. + +Verdicts aggregate results by severity and attribution: ``fail`` blames the device, +``retake`` blames how the take was performed. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, replace +from enum import StrEnum +from math import isfinite +from typing import Any, Iterable, Sequence + +from .checks.base import Attribution, Check, Severity, Status +from .frames import FrameSource, SourceMetadata + + +class Verdict(StrEnum): + PASS = "pass" + FAIL = "fail" + RETAKE = "retake" + INSUFFICIENT_DATA = "insufficient_data" + + +class Mark(StrEnum): + """How one result is shown: pass, FAIL, meas (not judged), n/a (unanswered), note (advisory).""" + + PASS = "pass" + FAIL = "FAIL" + MEAS = "meas" + UNANSWERED = "n/a" + NOTE = "note" + + +@dataclass(frozen=True, slots=True) +class CheckResult: + name: str + group: str + severity: Severity + attribution: Attribution + summary: str + status: Status + detail: str + measurements: dict[str, Any] + required: bool = True + judged: bool = True + + @property + def counts_toward_verdict(self) -> bool: + return self.severity is not Severity.ADVISORY + + @property + def mark(self) -> Mark: + if self.status is Status.INSUFFICIENT_DATA: + return Mark.UNANSWERED + if self.status is Status.FAIL: + return Mark.FAIL if self.counts_toward_verdict else Mark.NOTE + return Mark.PASS if self.judged else Mark.MEAS + + +@dataclass(frozen=True, slots=True) +class Report: + source: str + frames: int + metadata: SourceMetadata + results: tuple[CheckResult, ...] + verdict: Verdict + notes: tuple[str, ...] = () + + @property + def advisories(self) -> tuple[CheckResult, ...]: + return tuple( + r + for r in self.results + if not r.counts_toward_verdict and r.status is Status.FAIL + ) + + @property + def unanswered(self) -> tuple[CheckResult, ...]: + return tuple(r for r in self.results if r.status is Status.INSUFFICIENT_DATA) + + @property + def failures(self) -> tuple[CheckResult, ...]: + return tuple( + r + for r in self.results + if r.counts_toward_verdict and r.status is Status.FAIL + ) + + def to_dict(self) -> dict[str, Any]: + schema_data = self.metadata.schema_data + return { + "source": self.source, + "verdict": str(self.verdict), + "frames": self.frames, + "container": { + "schema_name": self.metadata.schema_name, + "schema_encoding": self.metadata.schema_encoding, + "schema_sha256": ( + hashlib.sha256(schema_data).hexdigest() if schema_data else None + ), + "schema_bytes": len(schema_data) if schema_data else None, + "message_encoding": self.metadata.message_encoding, + "topic": self.metadata.topic, + "profile": self.metadata.profile, + "channel_found": self.metadata.channel_found, + }, + "notes": list(self.notes), + "checks": [ + { + "name": r.name, + "group": r.group, + "mark": str(r.mark), + "severity": str(r.severity), + "attribution": str(r.attribution), + "judged": r.judged, + "status": str(r.status), + "required": r.required, + "detail": r.detail, + "measurements": json_safe(r.measurements), + } + for r in self.results + ], + } + + def to_json(self, indent: int = 2) -> str: + return json.dumps( + self.to_dict(), indent=indent, sort_keys=False, allow_nan=False + ) + + def to_text(self) -> str: + lines = [ + f"source {self.source}", + f"frames {self.frames}", + f"topic {self.metadata.topic or '-'} " + f"schema {self.metadata.schema_name or '-'}", + f"verdict {str(self.verdict).upper()}", + "", + ] + for r in self.results: + lines.append(f" [{r.mark:<4}] {r.name:<45} {r.detail}") + unanswered = self.unanswered + if unanswered: + lines.append("") + lines.append(f" {len(unanswered)} checks could not be answered:") + lines.extend(f" {r.name}: {r.detail}" for r in unanswered) + if self.notes: + lines.append("") + lines.extend(f" note: {note}" for note in self.notes) + return "\n".join(lines) + + +def json_safe(value: Any) -> Any: + """Recursively replaces non-finite floats with None; strict JSON has no NaN or Infinity.""" + if isinstance(value, float) and not isfinite(value): + return None + if isinstance(value, dict): + return {key: json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [json_safe(item) for item in value] + return value + + +def suppress_dependents( + results: Sequence[CheckResult], checks: Sequence[Check] +) -> list[CheckResult]: + """Turns results whose preconditions failed into "cannot conclude". + + Suppression propagates along dependency chains. + """ + by_name = {result.name: result for result in results} + blocked: dict[str, str] = {} + dependencies = {check.name: check.depends_on for check in checks} + + changed = True + while changed: + changed = False + for name, needs in dependencies.items(): + if name in blocked: + continue + for required_name in needs: + upstream = by_name.get(required_name) + if upstream is None: + continue + if upstream.status is Status.FAIL or required_name in blocked: + blocked[name] = required_name + changed = True + break + + return [ + replace( + result, + status=Status.INSUFFICIENT_DATA, + detail=( + f"not judged: {blocked[result.name]} failed, so this measurement would " + f"describe the wrong frames" + ), + ) + if result.name in blocked and result.status is not Status.INSUFFICIENT_DATA + else result + for result in results + ] + + +def aggregate(results: Iterable[CheckResult]) -> Verdict: + counted = [r for r in results if r.counts_toward_verdict] + if any( + r.status is Status.FAIL and r.attribution is Attribution.DEVICE for r in counted + ): + return Verdict.FAIL + if any( + r.status is Status.FAIL and r.attribution is Attribution.PERFORMANCE + for r in counted + ): + return Verdict.RETAKE + # Only an unanswered required check makes the verdict insufficient_data. + if any(r.status is Status.INSUFFICIENT_DATA and r.required for r in counted): + return Verdict.INSUFFICIENT_DATA + if not any(r.status in (Status.PASS, Status.FAIL) for r in counted): + return Verdict.INSUFFICIENT_DATA + return Verdict.PASS + + +def evaluate( + source: FrameSource, active: Sequence[Check] +) -> tuple[int, list[CheckResult]]: + """One pass over ``source`` feeding every check; the frame count and the results.""" + frames = 0 + for frame in source: + frames += 1 + for check in active: + check.update(frame) + + results = [] + for check in active: + outcome = check.result() + results.append( + CheckResult( + name=check.name, + group=check.group, + severity=check.severity, + attribution=outcome.attribution or check.attribution, + judged=check.judged, + summary=check.summary, + status=outcome.status, + detail=outcome.detail, + measurements=dict(outcome.measurements), + required=check.required, + ) + ) + + return frames, suppress_dependents(results, active) + + +def assemble( + source: FrameSource, + frames: int, + results: Sequence[CheckResult], + notes: Sequence[str] = (), +) -> Report: + return Report( + source=str(getattr(source, "path", source.__class__.__name__)), + frames=frames, + metadata=source.metadata, + results=tuple(results), + verdict=aggregate(results), + notes=tuple(notes), + ) diff --git a/src/python/isaaccapture/cts/common/src/cts_common/vectors.py b/src/python/isaaccapture/cts/common/src/cts_common/vectors.py new file mode 100644 index 000000000..7fc68682a --- /dev/null +++ b/src/python/isaaccapture/cts/common/src/cts_common/vectors.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Vector and quaternion arithmetic on plain tuples; quaternions are x,y,z,w.""" + +from __future__ import annotations + +import math + +Vector = tuple[float, float, float] +Quaternion = tuple[float, float, float, float] + + +def sub(a: Vector, b: Vector) -> Vector: + return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) + + +def dot(a: Vector, b: Vector) -> float: + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + + +def cross(a: Vector, b: Vector) -> Vector: + return ( + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ) + + +def norm(a: Vector) -> float: + return math.sqrt(dot(a, a)) + + +def normalised(a: Vector) -> Vector | None: + length = norm(a) + if length < 1e-9 or not math.isfinite(length): + return None + return (a[0] / length, a[1] / length, a[2] / length) + + +def is_finite(a: tuple[float, ...]) -> bool: + return all(math.isfinite(component) for component in a) + + +def rotate(q: Quaternion, v: Vector) -> Vector: + """Rotates world-frame vector ``v`` by orientation ``q``.""" + x, y, z, w = q + first = (y * v[2] - z * v[1], z * v[0] - x * v[2], x * v[1] - y * v[0]) + second = ( + y * first[2] - z * first[1], + z * first[0] - x * first[2], + x * first[1] - y * first[0], + ) + return ( + v[0] + 2.0 * w * first[0] + 2.0 * second[0], + v[1] + 2.0 * w * first[1] + 2.0 * second[1], + v[2] + 2.0 * w * first[2] + 2.0 * second[2], + ) + + +def rotate_by_inverse(q: Quaternion, v: Vector) -> Vector: + """Expresses world-frame vector ``v`` in the local frame of orientation ``q``.""" + x, y, z, w = q + # The inverse of a unit quaternion is its conjugate. + return rotate((-x, -y, -z, w), v) + + +def as_wxyz(stored: Quaternion) -> Quaternion: + """Reads ``stored`` as w,x,y,z and returns the x,y,z,w quaternion.""" + a, b, c, d = stored + return (b, c, d, a) + + +def conjugate(q: Quaternion) -> Quaternion: + return (-q[0], -q[1], -q[2], q[3]) + + +def multiply(a: Quaternion, b: Quaternion) -> Quaternion: + ax, ay, az, aw = a + bx, by, bz, bw = b + return ( + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz, + ) + + +def relative(parent: Quaternion, child: Quaternion) -> Quaternion: + """The child's rotation expressed in the parent's frame.""" + return multiply(conjugate(parent), child) + + +def signed_angle_about(q: Quaternion, axis: Vector) -> float: + """Radians of ``q`` about ``axis``, signed by the right-hand rule.""" + unit = normalised(axis) + if unit is None: + return 0.0 + along = dot((q[0], q[1], q[2]), unit) + return 2.0 * math.atan2(along, q[3]) diff --git a/src/python/isaaccapture/cts/common/tests/test_boundaries.py b/src/python/isaaccapture/cts/common/tests/test_boundaries.py new file mode 100644 index 000000000..2e8fc290a --- /dev/null +++ b/src/python/isaaccapture/cts/common/tests/test_boundaries.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""What keeps this package shareable, asserted mechanically.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +PACKAGE = Path(__file__).resolve().parents[1] / "src" / "cts_common" +RENDERER = PACKAGE / "panel" / "skeleton.py" + +IMPORTS_VISER = re.compile(r"^\s*(?:import viser|from viser)", re.MULTILINE) +SCHEMA_SPECIFIC = re.compile(r"\b(?:full_body_cts|hand_cts|FullBodyPose|HandPose)\w*") + + +def _modules() -> list[Path]: + return sorted(PACKAGE.rglob("*.py")) + + +# The package and its legacy import alias. +ISAAC_PACKAGE = re.compile(r"\bisaac(?:capture|teleop)\b") + + +def test_no_isaaccapture(): + offenders = [p.name for p in _modules() if ISAAC_PACKAGE.search(p.read_text())] + assert offenders == [], f"must not depend on isaaccapture: {offenders}" + + +def test_names_no_schema_package_or_record(): + offenders = [ + p.relative_to(PACKAGE).as_posix() + for p in _modules() + if SCHEMA_SPECIFIC.search(p.read_text()) + ] + assert offenders == [], f"schema-specific code belongs in its package: {offenders}" + + +def test_only_the_skeleton_imports_viser(): + offenders = [ + p.relative_to(PACKAGE).as_posix() + for p in _modules() + if p != RENDERER and IMPORTS_VISER.search(p.read_text()) + ] + assert offenders == [], f"viser belongs in panel/skeleton.py alone: {offenders}" + + +def test_everything_else_imports_with_viser_blocked(): + probe = ( + "import sys; sys.modules['viser'] = None;" + "import cts_common.report, cts_common.labels," + " cts_common.mcap_reader, cts_common.panel.sample," + " cts_common.panel.track, cts_common.panel.status," + " cts_common.panel.render;" + "import cts_common.checks.continuity, cts_common.checks.coverage," + " cts_common.checks.quaternion, cts_common.checks.rate," + " cts_common.checks.schema, cts_common.checks.timestamps," + " cts_common.checks.values" + ) + done = subprocess.run( + [sys.executable, "-c", probe], + check=False, + cwd=PACKAGE.parent, + capture_output=True, + text=True, + ) + assert done.returncode == 0, done.stderr diff --git a/src/python/isaaccapture/cts/full_body/README.md b/src/python/isaaccapture/cts/full_body/README.md new file mode 100644 index 000000000..020ae2f2c --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/README.md @@ -0,0 +1,281 @@ + + +# Recording a take + +One recording of a prescribed ten-step motion script, plus the label sidecar that +[the checker](checker/README.md) reads. A panel in your browser tells the performer +what to do; they press a button once they are in each pose. + +**Linux only**: recording goes through Isaac Teleop, whose wheel is `linux_x86_64`. The +checker has no such constraint, so a take recorded here can be verified on macOS too. + +Nothing under [`capture/`](capture) is meant to be read or edited — see +[Do not edit the script](#do-not-edit-the-script). + +## Before you start + +- **The project built**, so that an `isaaccapture` wheel exists under `install/wheels/` + or `build/wheels/`. +- **`uv`, `curl` and `unzip`** on the same Linux host. +- **`git-lfs`**, so the clone holds the spoken cues as audio rather than LFS pointers. +- **`aplay`** — the spoken cues go through it. It comes from `alsa-utils` + (`apt install alsa-utils` on Debian or Ubuntu). Install it before your first take: a + missing `aplay` fails part way into the recording rather than at startup. +- **A headset**, plus CloudXR installed (by default at `~/.cloudxr`) — see + [Get the headset streaming](#get-the-headset-streaming). It has to be connected even + when your own device produces the joints, because the OpenXR session comes from + CloudXR. **Body tracking** on the headset is needed only where the headset is also + the body source. +- **A performer who can stand up and follow spoken instructions.** Two people is easier + than one — see the note about the space bar below. + +## Set up, once per clone + +Two scripts, in this order. The checker's comes first because it generates what the +panel reads the recording back with. + +```bash +src/python/isaaccapture/cts/full_body/checker/setup_env.sh +src/python/isaaccapture/cts/full_body/capture/setup_env.sh +``` + +Both are idempotent and write nothing outside their own directory. Re-run them after +pulling. + +## Get the headset streaming + +**Every take needs this, including one whose joints come from your own device.** CloudXR +is the OpenXR runtime the capture itself runs on, and it has no system to hand out until +a headset has loaded the web client and pressed CONNECT. Skipping it does not raise: +**Start recording** blocks inside session creation, logging `OpenXR HMD form factor is +unavailable; waiting for a system...` once a second, and Python does not take Ctrl+C +there. To record without a headset, see +[Recording from your own device](#recording-from-your-own-device). + +Every `isaaccapture` command below runs under the interpreter `capture/setup_env.sh` +built. Nothing else on the host has the package, and Ubuntu has no bare `python` at all. + +```bash +PY=src/python/isaaccapture/cts/full_body/capture/.venv/bin/python +``` + +**Accept the CloudXR EULA, once per machine.** Review the licence, then + +```bash +$PY -m isaaccapture.cloudxr.service run --accept-eula +``` + +It is remembered in `~/.cloudxr/run/eula_accepted`, so that is once per install +directory, not once per take. + +**Start the service in a terminal of its own and leave it open.** + +```bash +$PY -m isaaccapture.cloudxr.service run +``` + +`run` is the foreground form and prints the runtime's log, which is where a headset that +will not connect says why; Ctrl+C stops it. `start` is the same service detached, with +`stop`, `status` and `logs` beside it. The runtime is a host singleton on port 48322, so +run one form or the other, never both. `record.sh` starts its own when nothing is up, +but a runtime takes tens of seconds to come up and one of these outlives any number of +takes. + +**Connect the headset over Wi-Fi.** Nothing reaches the panel until this is done, and it +sits at *waiting for a frame with valid joints* meanwhile. Put the headset on the same +subnet as this host, then + +```bash +$PY -m isaaccapture.cloudxr.webclient --print-only +``` + +That prints the streaming target `https://:48322/` and the client URL with +that address already filled in. **Open the streaming target in the headset's browser +first and accept the self-signed certificate** — until you have, the client page loads +normally and CONNECT fails with nothing in either log. Then open the client URL and +press CONNECT. + +Without `--print-only` the same command types that URL into the headset over USB `adb`, +which saves the typing and needs USB debugging authorised. The cable carries `adb`, +never the video. Re-run either form whenever the headset browser gets closed; it does +not disturb a running take. + +`record.sh` passes anything it does not understand through to the panel, so the runtime +flags work from there too — `--cloudxr-install-dir` (default `~/.cloudxr`), +`--cloudxr-device-profile` (default `Quest3`), and the rest of +`capture_panel.py --help`. + +Two references worth having open the first time: + +- [`docs/source/references/cloudxr.rst`](../../../../../docs/source/references/cloudxr.rst) — + device profiles, foreground vs detached service, out-of-band and USB-only setups. +- [`docs/source/device/body_tracking.rst`](../../../../../docs/source/device/body_tracking.rst) + — PICO only, and skippable if your joints come from elsewhere: which Motion Tracker + configurations are supported (5, 3 or 2 trackers), how to calibrate them, and what + body tracking needs from the headset. **Read it before your first PICO take**, since + without body tracking the recording holds no usable joints and only the container + checks can run. + +## Recording from your own device + +The joints come from the headset's own `body.pico-xr` backend unless you say otherwise. +Another full-body device arrives as a **vendor**, a backend id the session resolves when +it is constructed, fed by a **plugin** process the session launches. Both are +`capture_panel.py` flags, and `record.sh` passes them through: + +```bash +src/python/isaaccapture/cts/full_body/capture/record.sh mysuit \ + --plugin my_body_plugin \ + --vendor body.my-vendor \ + --vendor-param collection_id=my_body +``` + +- `--plugin NAME` names a directory under `plugins/` or `install/plugins/`, so the + plugin must be installed (`cmake --install build`) first. It is launched as required: + one that fails to load stops the take rather than recording a file with no body in it. +- `--vendor ID` is rejected at session construction if the registry does not know it, + not at the first frame. +- `--vendor-param KEY=VALUE` is repeatable and free-form. `collection_id` must match + what the plugin publishes under; a mismatch records an empty take and looks exactly + like a device that never connected. +- **A headset still has to be connected.** Not for the joints — for the OpenXR session, + which CloudXR will not create until one is. A suit has no trigger either, so the + performer opens every window with the space bar alone. + +Everything in [Record](#record) below then applies unchanged, and so does the checker: +it reads the recorded `full_body` channel and never asks what produced it. + +Dropping the headset means handing the process a different OpenXR runtime: +`--no-launch-cloudxr-runtime` leaves `XR_RUNTIME_JSON` alone, so a system runtime such +as Monado serves the session instead. It is documented under *Use a system OpenXR +runtime* in [`cloudxr.rst`](../../../../../docs/source/references/cloudxr.rst); this +capture flow has not been validated with one. + +**No full-body plugin ships in this tree.** `install/plugins/` holds controller, pedal and +leader-arm plugins, and the registry has the one vendor. Write the backend and its +plugin first — +[`docs/source/device/trackers.rst`](../../../../../docs/source/device/trackers.rst) under *Vendor +Selection*. + +## Record + +```bash +src/python/isaaccapture/cts/full_body/capture/record.sh pico4u +``` + +The argument names the device and only decides where the files land; `pico4u` is the +default. + +Open the panel at . It binds every interface, so the headset's +own browser can reach it at `http://:8081` as well. + +The cues sit in a column down the right edge and the skeleton gets the room to the left +of it. Drag the column's inner edge to change the split. If the text is too small to +read from where the performer stands, zoom the browser in with `Ctrl+=` — it scales the +text and the column together, and the browser remembers the setting for that address. + +The panel idles. Nothing is open against the device and no file exists yet. Press +**Start recording** when the performer is standing ready — that is when the recording is +created. The script itself begins at the first frame with valid joints, which is normally +the next moment but is not the same one. + +If ten seconds go by with no valid joint, a red box appears at the bottom left of the +skeleton area listing what to check. It stops nothing: the take is still running, the +file is still being written, and the script still starts at the first valid frame +whenever it arrives. The box clears itself when it does. + +Then, for each of the ten steps: + +1. The cue is spoken and shown on the panel — *"T pose. Hold."* +2. The performer gets into the pose. Take as long as you need; nothing is running out. +3. **Press the controller trigger, or the space bar.** +4. A beep. Hold the pose while the countdown on the panel runs down. +5. A tick. That pose is recorded, and the next cue follows. + +Two things to know before your first take: + +- **The space bar is a key in the _browser_, not in the terminal.** It works in whichever + browser has the panel open and focused — the headset's or the one on your desk. With + two people, the one at the screen does the pressing. +- **Pressing at any other moment does nothing, deliberately, and the panel will not + react.** It is not broken and the press is not queued. Press once, when the performer + is in the pose. + +If the performer already knows the pose, they can press during the spoken cue to cut it +short and go straight to waiting. + +After the tenth pose the recording closes, *"Done. You can stop now."* plays, and the +panel reports what the labels came out as. The first rows are about the file — record +count, timestamps, whether all ten windows resolved. The rest are named after the steps +and re-derive that pose from the recording, so a `BAD` means the window does not hold +the pose it claims. How many are `BAD` says whose fault it is: one against otherwise +good ones is that step, pressed at the wrong moment or performed wrongly; most of them +at once, with degenerate numbers like `pelvis drops 0 cm`, is the joint stream, and +recording again reproduces it exactly. + +The process keeps serving the report; Ctrl+C when you have read it. + +## What you get + +One directory per take, under `~/isaaccapture-captures/`, named for the device and the +moment you pressed the button: + +```text +pico4u_2026-03-04_101530/pico4u_2026-03-04_101530-body.mcap the recording + /pico4u_2026-03-04_101530-body.labels.json the motion-step windows + /pico4u_2026-03-04_101530-body.json what produced it + /pico4u_2026-03-04_101530-body.log the panel's output +``` + +Nothing is ever overwritten. Run `record.sh` again for another take and both are kept. + +The four files travel together, which is why they share a directory and repeat its name: +sending a take is sending the directory, and the recording still says what it is once it +is out of there. The labels **cannot** be regenerated afterwards. + +Do not create a `latest` shortcut. The checker looks for +the labels at `.labels.json` **as you spelled the recording**, so a symlink +finds none beside itself and can turn a `retake` into a `pass` with the posture checks +silently unanswered. Give the full path. + +Then run the checks — [`checker/README.md`](checker/README.md) covers reading the +verdict and packaging a take to send. + +## If something goes wrong + +| What you see | What to do | +|---|---| +| `Command 'python' not found` | Use the interpreter the setup built, `src/python/isaaccapture/cts/full_body/capture/.venv/bin/python`. Ubuntu has `python3` and no `python`, and neither has `isaaccapture`. | +| `missing …/.venv; run …/setup_env.sh` | Run the two setup scripts above. | +| `no isaaccapture wheel in …/wheels; build the repo first` | Build the project, then re-run `capture/setup_env.sh`. | +| `run …/checker/setup_env.sh first` | You ran the two setup scripts in the wrong order. | +| **the session did not open**, on the panel | CloudXR could not start, or a `--plugin` failed to load. The message names the reason. Nothing was recorded; fix it and run `record.sh` again. | +| **Start recording** does nothing and the terminal repeats `waiting for a system` | No headset is connected to the runtime, so there is no OpenXR system yet. Connect it as above and the take goes on by itself. Ctrl+C is not delivered during that wait — close the terminal if you need out. | +| The client page loads but CONNECT does nothing | The headset has not accepted the runtime's self-signed certificate. Open `https://:48322/` in the headset browser, click through the warning, then go back to the client. Failing that: same subnet, and the host firewall allows 48322. | +| The service prints `running` but a second one will not start | The runtime is a host singleton on 48322. One is already up — `service status` names it, `service stop` ends a detached one, Ctrl+C a foreground one. | +| **no joint has been valid yet**, in the cue column | On PICO this is usually the browser, not the hardware or a licence: use the headset's own browser, which grants WebXR body tracking on a consumer 4 Ultra. | +| **No body data after 10 s**, in red under the skeleton | The same thing as the row above, once it has lasted ten seconds, with the causes spelled out. Two red things, one problem. Nothing has been lost: no joint has been valid since you pressed **Start recording**, so the script has not begun. Fix what the box names and the script starts on its own — same take, same file, nothing to redo. | +| No sound, or a `FileNotFoundError` naming `aplay` | `aplay` is missing. Install `alsa-utils` and record again. | +| `… is a Git LFS pointer, not audio; run git lfs pull` | The clone was made without git-lfs, so the cue WAVs are placeholders. Install git-lfs and run `git lfs pull` in the repository. | +| The panel is not at 8081 | Something else held that port, so it moved to the next free one. Use the `http://localhost:…` line the run prints, which is always the real one. | +| The headset's browser cannot load the panel | The port is not reachable from the headset. Check the host firewall; the panel itself listens on every interface. | +| One `BAD` row in the label report, the rest `ok` | That pose was pressed at the wrong moment or performed wrongly. Record another take. | +| Most rows `BAD`, with degenerate numbers (`pelvis drops 0 cm`, both hands at one height) | The joint stream is frozen: every window holds the same still pose, and one row can still read `ok` by accident. Re-performing the script changes nothing; the device or plugin is not delivering changing joints. The checker confirms it with `continuity.max_joint_velocity` at `peak 0.0 m/s`. | + +## Do not edit the script + +The ten steps, their spoken cues and their hold durations are part of the CTS +specification, and the checker looks its measurements up by step name. Changing any of +them under `capture/` silently changes what the verdict means. + +If a take stops with + +```text +the wording of cue 'squat_x2' changed, so squat_x2.wav no longer says it +``` + +then the capture side is out of sync with its own audio. Report it rather than working +around it. diff --git a/src/python/isaaccapture/cts/full_body/capture/README.md b/src/python/isaaccapture/cts/full_body/capture/README.md new file mode 100644 index 000000000..681d3272a --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/README.md @@ -0,0 +1,13 @@ + + +# capture/ + +What records a take: the spoken motion script, the viser panel the performer follows, +and the label sidecar the posture checks read. + +Nothing in here is meant to be run directly or edited. The step-by-step for recording — +setup, the headset, the ten steps, what you get and what to do when it goes wrong — is +one level up, in [`../README.md`](../README.md). diff --git a/src/python/isaaccapture/cts/full_body/capture/capture_panel.py b/src/python/isaaccapture/cts/full_body/capture/capture_panel.py new file mode 100644 index 000000000..c46050e18 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/capture_panel.py @@ -0,0 +1,403 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Records one take of the motion script, paced by the performer. + +Run it through ``record.sh``: + + python capture_panel.py OUTPUT.mcap [options] +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import threading +import time +from pathlib import Path + +import viser + +from isaaccapture.cloudxr import CloudXRLauncher, NoopContext +from isaaccapture.deviceio import McapRecordingConfig, TrackerVendor +from isaaccapture.teleop_session_manager import ( + PluginConfig, + TeleopSession, + TeleopSessionConfig, +) + +from cts_common.panel.sample import Sample, Vec3 +from cts_common.panel.skeleton import Skeleton +from full_body_cts.frames import NUM_JOINTS, Frame +from full_body_cts.profile import FULL_BODY + +import cues +import make_labels +import render +from live import LiveFrameSource, build_pipeline +from session import STEPS +from steps import KEYBOARD, TRIGGER, Phase, Press, Sound, Take + +# Device polling period and sidebar update period, seconds. +STEP_PERIOD_S = 1.0 / 60.0 +GUI_PERIOD_S = 0.1 + +# Seconds to keep recording after the last window closes. +TAIL_S = 2.0 + +# Default viser port. +DEFAULT_PORT = 8081 + +# Docked control panel width, pixels. +PANEL_WIDTH_PX = 480 + +# Seconds with no valid joint before the panel shows the possible causes. +NO_DATA_S = 10.0 + + +class HeldJoints: + """Last valid position per joint, which is where an invalid one is drawn.""" + + def __init__(self) -> None: + self._held: list[Vec3 | None] = [None] * NUM_JOINTS + self.ever_valid = False + + def sample(self, frame: Frame) -> Sample: + """One frame as the ``Sample`` the skeleton draws; ``t_s`` and ``interval_ms`` are unset.""" + valid = [False] * NUM_JOINTS + if frame.joints is not None: + for index, joint in enumerate(frame.joints): + if joint.is_valid: + valid[index] = True + self._held[index] = joint.position + self.ever_valid = self.ever_valid or any(valid) + return Sample( + sequence=frame.sequence, + t_s=0.0, + positions=tuple(self._held), + valid=tuple(valid), + interval_ms=None, + step=None, + ) + + +class CapturePanel: + def __init__( + self, + server: viser.ViserServer, + launcher: CloudXRLauncher | NoopContext, + recording: Path, + vendor: TrackerVendor | None = None, + plugins: tuple[PluginConfig, ...] = (), + ) -> None: + self._server = server + self._launcher = launcher + self._recording = recording + self._vendor = vendor + self._plugins = plugins + self._spoken = {label: cues.wav(label, text) for label, _, text in STEPS} + self._cue_process: subprocess.Popen | None = None + self._armed = threading.Event() + self._key = threading.Event() + self._sample: Sample | None = None + self._listed: tuple[int, bool] | None = None + + # Light theme: the mark colours in `render` assume a light background. + server.gui.configure_theme(control_width="large", dark_mode=False) + # `set_width` is the docked width; the theme's `control_width` applies when floated. + server.gui.main_panel.dock_right() + server.gui.main_panel.set_width(PANEL_WIDTH_PX) + server.scene.set_up_direction("+y") + server.scene.add_grid( + "/floor", width=3.0, height=3.0, plane="xz", cell_size=0.25 + ) + server.scene.add_frame("/origin", axes_length=0.3, axes_radius=0.004) + server.initial_camera.position = (2.2, 1.6, 2.6) + server.initial_camera.look_at = (0.0, 0.9, 0.0) + self._skeleton = Skeleton(server, FULL_BODY) + self._build_gui() + self._build_warning() + + def _build_warning(self) -> None: + """The no-data causes panel, floated at the canvas's bottom-left and hidden until they apply.""" + panel = self._server.gui.add_panel(visible=False) + panel.float(x=20, y=-20, width=440) + with panel.add_tab("No data"): + self._warning_text = self._server.gui.add_html("") + self._warning = panel + self._warned = False + + def _build_gui(self) -> None: + gui = self._server.gui + attached, detail = self._runtime() + self._idle = gui.add_html( + render.ready_to_start(attached, detail, self._destination()) + ) + self._start = gui.add_button("Start recording") + self._status = gui.add_html("") + self._steps = gui.add_html("") + self._report = gui.add_html("") + + @self._start.on_click + def _(_: viser.GuiEvent) -> None: + # Only raise a flag; the take runs on the thread that owns the session. + self._armed.set() + + # viser 1.1 exposes no key events, so the command palette's hotkey is the binding. + # Never disable it: a greyed-out control would show that a press did not count. + self._command = gui.add_command( + "Step ready", + description="open the current window -- same as the controller trigger", + hotkey="space", + ) + + @self._command.on_trigger + def _(_: viser.GuiEvent) -> None: + self._key.set() + + def _runtime(self) -> tuple[bool, str]: + """Whether a CloudXR runtime is still there, with a description.""" + try: + self._launcher.health_check() + except RuntimeError as gone: + return (False, str(gone)) + return (True, "attached") + + def _destination(self) -> str: + return f"{self._recording.parent.name}/{self._recording.name}" + + def run(self) -> None: + print( + f"[capture] idle -- press start in the browser; take is {self._recording}" + ) + while not self._armed.wait(0.2): + pass + self._start.visible = False + self._idle.visible = False + try: + self._record() + except RuntimeError as failed: + # Show the reason on the page as well as the terminal. + self._status.content = render.session_failed(failed) + print(f"[capture] session failed: {failed}", file=sys.stderr) + print("[capture] serving the report -- Ctrl+C to stop") + while True: + time.sleep(0.5) + + def _record(self) -> None: + pipeline, body = build_pipeline(self._vendor) + config = TeleopSessionConfig( + app_name="FullBodyCtsCapture", + pipeline=pipeline, + plugins=list(self._plugins), + mcap_config=McapRecordingConfig(str(self._recording)), + ) + take = Take( + cue_seconds={ + label: cues.duration_of(path) for label, path in self._spoken.items() + }, + end_tone_s=cues.duration_of(cues.END_TONE), + ) + with TeleopSession(config) as session: + self._loop(LiveFrameSource(session, body), take) + # Spoken after the file is closed. + cues.play(cues.wav("closing", cues.CLOSING)) + self._label(take) + + def _loop(self, source: LiveFrameSource, take: Take) -> None: + held = HeldJoints() + started = time.monotonic() + drawn = 0.0 + finished_at: float | None = None + + while True: + live = source.step() + if live.frame is not None: + self._sample = held.sample(live.frame) + self._skeleton.draw(self._sample, name_held=True) + + now = time.monotonic() + if take.phase is Phase.IDLE and held.ever_valid: + self._play(take.start(now), take) + # Time first, then the press: a hold that ran out on this tick closes before the press is offered. + self._play(take.advance(now, source.records), take) + + # A press is offered at the record this step observed; a close takes the count written so far. + if live.pressed: + self._offer(take, now, source.records - 1, TRIGGER) + elif self._key.is_set(): + self._key.clear() + self._offer(take, now, source.records - 1, KEYBOARD) + + if now - drawn > GUI_PERIOD_S: + drawn = now + self._draw_sidebar(take, now, now - started, held.ever_valid) + self._warn(held.ever_valid, now - started) + + if take.phase is Phase.DONE: + finished_at = finished_at or now + if now - finished_at > TAIL_S: + return + time.sleep(STEP_PERIOD_S) + + def _warn(self, ever_valid: bool, elapsed_s: float) -> None: + """Show the causes once the take has run this long with nothing valid; advisory only.""" + show = not ever_valid and elapsed_s > NO_DATA_S + if show and not self._warned: + self._warned = True + vendor = None if self._vendor is None else self._vendor.id + self._warning_text.content = render.no_data(NO_DATA_S, vendor) + print(f"[capture] no body data after {NO_DATA_S:.0f}s", file=sys.stderr) + self._warning.visible = show + + def _play(self, sound: Sound | None, take: Take) -> None: + if sound is Sound.CUE: + self._cue_process = cues.play(self._spoken[take.label]) + elif sound is Sound.START: + cues.play(cues.START_TONE) + elif sound is Sound.END: + cues.play(cues.END_TONE) + + def _offer(self, take: Take, now_s: float, frame: int, source: str) -> None: + outcome = take.press(now_s, frame, source) + if outcome is Press.ACCEPTED: + cues.play(cues.START_TONE) + elif outcome is Press.CUT_CUE: + self._silence() + # IGNORED must stay invisible. + + def _silence(self) -> None: + process = self._cue_process + self._cue_process = None + if process is not None: + process.terminate() + + def _draw_sidebar( + self, take: Take, now_s: float, elapsed_s: float, ever_valid: bool + ) -> None: + sample = self._sample + valid = sample.valid_count if sample is not None else 0 + held = self._skeleton.held_names(sample) if sample is not None else [] + with self._server.atomic(): + self._status.content = ( + render.recording(self._recording.name, elapsed_s) + + render.step_block(take, now_s) + + render.next_up(take) + + render.joints(valid, NUM_JOINTS, held, ever_valid) + ) + listed = (take.index, take.phase is Phase.DONE) + if listed != self._listed: + self._listed = listed + self._steps.content = render.step_list(take) + + def _label(self, take: Take) -> None: + """Turn the recorded windows into the sidecar, now that the file is closed.""" + sidecar, checks = make_labels.write(self._recording, take.windows) + name = sidecar.name if sidecar is not None else "no sidecar written" + self._report.content = render.wrote_labels(name, checks) + print(f"[capture] {name}") + for label, ok, detail in checks: + print(f" {'ok ' if ok else 'BAD '} {label:<24} {detail}") + + +def _vendor( + parser: argparse.ArgumentParser, vendor_id: str | None, params: list[str] +) -> TrackerVendor | None: + pairs: dict[str, str] = {} + for item in params: + key, separator, value = item.partition("=") + if not separator or not key: + parser.error(f"--vendor-param wants KEY=VALUE, got {item!r}") + pairs[key] = value + if vendor_id is None: + if pairs: + parser.error("--vendor-param needs --vendor") + return None + print(f"[capture] full body from {vendor_id} {pairs or ''}".rstrip()) + return TrackerVendor(vendor_id, pairs) + + +def _plugins( + parser: argparse.ArgumentParser, names: list[str] +) -> tuple[PluginConfig, ...]: + """Plugin processes the session launches, searched for the way the examples do.""" + if not names: + return () + repo = Path(__file__).resolve().parents[6] + roots = [ + path + for path in (repo / "plugins", repo / "install" / "plugins") + if path.is_dir() + ] + if not roots: + parser.error( + f"--plugin given but neither {repo}/plugins nor {repo}/install/plugins " + "exists; run `cmake --install build`" + ) + for name in names: + print(f"[capture] plugin {name}") + return tuple( + PluginConfig( + plugin_name=name, + plugin_root_id=name, + search_paths=roots, + required=True, + ) + for name in names + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="record.sh", description=__doc__) + parser.add_argument("output", type=Path, help="where to write the take") + parser.add_argument( + "--host", + default="0.0.0.0", + help="bind address (default: all interfaces, so the headset can reach it)", + ) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument( + "--vendor", + help="full-body backend id, e.g. body.noitom (default: the tracker's own " + "body.pico-xr)", + ) + parser.add_argument( + "--vendor-param", + action="append", + default=[], + metavar="KEY=VALUE", + help="parameter for --vendor, repeatable, e.g. collection_id=noitom_mocap", + ) + parser.add_argument( + "--plugin", + action="append", + default=[], + metavar="NAME", + help="plugin to launch for the session, repeatable, e.g. noitom_mocap", + ) + CloudXRLauncher.add_launcher_arguments(parser) + args = parser.parse_args(argv) + vendor = _vendor(parser, args.vendor, args.vendor_param) + plugins = _plugins(parser, args.plugin) + + args.output.parent.mkdir(parents=True, exist_ok=True) + with CloudXRLauncher.launch_context(args) as launcher: + if launcher.owns_runtime: + print( + f"[capture] CloudXR runtime started (WSS log: {launcher.wss_log_path})" + ) + server = viser.ViserServer(host=args.host, port=args.port) + # viser moves to the next free port, so args.port is not necessarily where it landed. + print(f"[capture] http://localhost:{server.get_port()}") + try: + CapturePanel(server, launcher, args.output, vendor, plugins).run() + except KeyboardInterrupt: + pass + finally: + server.stop() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/python/isaaccapture/cts/full_body/capture/cues.py b/src/python/isaaccapture/cts/full_body/capture/cues.py new file mode 100644 index 000000000..0f0bef2c7 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The spoken cues and the two window tones, played through ``aplay``. + +The WAVs in ``cues/`` are pre-rendered; nothing is synthesised at run time. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +import wave +from pathlib import Path + +HERE = Path(__file__).resolve().parent +CUES = HERE / "cues" + +# Window start and end tones; they must sound different. +START_TONE = CUES / "beep.wav" +END_TONE = CUES / "tick.wav" + +#: The one spoken line that is not a step, played once the file is closed. +CLOSING = "Done. You can stop now." + + +def _require_audio(path: Path) -> None: + """Exit if ``path`` is a Git LFS pointer rather than audio.""" + with path.open("rb") as handle: + if handle.read(24).startswith(b"version https://git-lfs"): + raise SystemExit( + f"{path} is a Git LFS pointer, not audio; run `git lfs pull`" + ) + + +def wav(label: str, text: str) -> Path: + """The recorded WAV for one cue; exits if the cue text no longer matches ``cues/index.json``.""" + index = json.loads((CUES / "index.json").read_text()) + recorded = index["text_sha1"].get(label) + current = hashlib.sha1(text.encode(), usedforsecurity=False).hexdigest()[:16] + if recorded is None: + raise SystemExit(f"{CUES}/index.json has no cue named {label!r}") + if recorded != current: + raise SystemExit( + f"the wording of cue {label!r} changed, so {label}.wav no longer says it:\n" + f" now: {text!r}\n" + f"re-render it with piper at voice {index['voice']} and length scale " + f"{index['length_scale']}, then update index.json" + ) + _require_audio(CUES / f"{label}.wav") + return CUES / f"{label}.wav" + + +def duration_of(path: Path) -> float: + _require_audio(path) + with wave.open(str(path)) as handle: + return handle.getnframes() / handle.getframerate() + + +def play(path: Path) -> subprocess.Popen: + """Start a sound and return its process handle without waiting.""" + return subprocess.Popen( + ["aplay", "-q", str(path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/a_pose_still.wav b/src/python/isaaccapture/cts/full_body/capture/cues/a_pose_still.wav new file mode 100644 index 000000000..9283aaa4b --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/a_pose_still.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1f7f9beb841f2c4943220b346a0cfdf16e6dc143ea11a78085a531e30b29668 +size 100908 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/beep.wav b/src/python/isaaccapture/cts/full_body/capture/cues/beep.wav new file mode 100644 index 000000000..e0f892b0e --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/beep.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2405c26e1b526193ab7f756e37b687ad670a9c9b94ac6c896de7645acc547d1 +size 5336 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/closing.wav b/src/python/isaaccapture/cts/full_body/capture/cues/closing.wav new file mode 100644 index 000000000..416010f40 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/closing.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c91fac4d00b8cd5695189c0b4f347ec6b2b282e73f1a01f9dbf938625b273d5c +size 109612 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/index.json b/src/python/isaaccapture/cts/full_body/capture/cues/index.json new file mode 100644 index 000000000..cc0253a99 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/index.json @@ -0,0 +1,17 @@ +{ + "voice": "en_GB-alba-medium", + "length_scale": "1.25", + "text_sha1": { + "a_pose_still": "7ecb6110f5bd59fe", + "t_pose_hold_open": "c413c900701e2b9a", + "neutral_stance": "ff367e7cba1ab75e", + "left_arm_raise": "2606a393f6a371ac", + "right_arm_raise": "3c0893460c548b46", + "left_leg_raise": "ee15d520a5c95a96", + "right_leg_raise": "80f63578017ead61", + "squat_x2": "a7e06943553c3724", + "march_in_place": "e92febe14c6be290", + "t_pose_hold_close": "9ea066fd1d846010", + "closing": "5b9a4c18a75801ef" + } +} diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/index.json.license b/src/python/isaaccapture/cts/full_body/capture/cues/index.json.license new file mode 100644 index 000000000..817df2442 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/index.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/left_arm_raise.wav b/src/python/isaaccapture/cts/full_body/capture/cues/left_arm_raise.wav new file mode 100644 index 000000000..8b8b5670a --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/left_arm_raise.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39a842cec8a26850cd4623ddf3f6a082998b7246e66ab2536f09d007de402558 +size 109100 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/left_leg_raise.wav b/src/python/isaaccapture/cts/full_body/capture/cues/left_leg_raise.wav new file mode 100644 index 000000000..f05ac267f --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/left_leg_raise.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eee24f4ab021ecc7c7152b60b31ed6ecee10f0a8796ad67fbc65c543d13b8eb7 +size 51244 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/march_in_place.wav b/src/python/isaaccapture/cts/full_body/capture/cues/march_in_place.wav new file mode 100644 index 000000000..6e4aa15b7 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/march_in_place.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:783a82507fdb8ba9e60fa5114005a831bdc6b7089de5798e7d06137f9f4a14ed +size 73260 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/neutral_stance.wav b/src/python/isaaccapture/cts/full_body/capture/cues/neutral_stance.wav new file mode 100644 index 000000000..8d3f60f05 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/neutral_stance.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1610a4a4a71d95572e9a3e61c976400181a434554839375257f4a572406e1432 +size 98860 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/right_arm_raise.wav b/src/python/isaaccapture/cts/full_body/capture/cues/right_arm_raise.wav new file mode 100644 index 000000000..3fefcf3aa --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/right_arm_raise.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abf7734b794cd00d775c041ee654db51b71acefc2339c4fa0db3dc0ffbeeaafe +size 103980 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/right_leg_raise.wav b/src/python/isaaccapture/cts/full_body/capture/cues/right_leg_raise.wav new file mode 100644 index 000000000..3208ddf11 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/right_leg_raise.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca85fb91bc0f1a2229d740d3a90d4e86daf35500f63c3b2e87acc8651c00dbbf +size 45612 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/squat_x2.wav b/src/python/isaaccapture/cts/full_body/capture/cues/squat_x2.wav new file mode 100644 index 000000000..37b31dc02 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/squat_x2.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b2e5bd161cfa1121664c5ea7052a70c179449fa260a06f119b7fffd1e894503 +size 100396 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/t_pose_hold_close.wav b/src/python/isaaccapture/cts/full_body/capture/cues/t_pose_hold_close.wav new file mode 100644 index 000000000..3a2f6f5fd --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/t_pose_hold_close.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:485917d8f8afee3628690c8e877a98bd9abe9cadb59978b758e7d947c821067e +size 92716 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/t_pose_hold_open.wav b/src/python/isaaccapture/cts/full_body/capture/cues/t_pose_hold_open.wav new file mode 100644 index 000000000..055a0c151 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/t_pose_hold_open.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a9a54ee5344ed8bb962198e2ce521812416da53d35fddc79796ff0e5ccb02807 +size 99884 diff --git a/src/python/isaaccapture/cts/full_body/capture/cues/tick.wav b/src/python/isaaccapture/cts/full_body/capture/cues/tick.wav new file mode 100644 index 000000000..11c20043a --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/cues/tick.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2eb5ca8c7ce82e8c5478d77665c851c4f39e3773f2ba652d88b110fab562f03f +size 3572 diff --git a/src/python/isaaccapture/cts/full_body/capture/live.py b/src/python/isaaccapture/cts/full_body/capture/live.py new file mode 100644 index 000000000..19fbaa116 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/live.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Frames and trigger presses out of a running ``TeleopSession``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from isaaccapture.deviceio import TrackerVendor +from isaaccapture.retargeting_engine.deviceio_source_nodes import ( + ControllersSource, + FullBodySource, +) +from isaaccapture.retargeting_engine.interface import OutputCombiner +from isaaccapture.retargeting_engine.tensor_types.indices import ControllerInputIndex + +from full_body_cts.frames import Frame, JointPose + +# Trigger hysteresis thresholds: pressed at or above DOWN, released at or below UP. +TRIGGER_DOWN = 0.6 +TRIGGER_UP = 0.3 + + +@dataclass(frozen=True, slots=True) +class LiveStep: + """One ``session.step()``: the record it wrote, if it wrote one, and the press.""" + + frame: Frame | None + pressed: bool + + +def build_pipeline( + vendor: TrackerVendor | None = None, +) -> tuple[OutputCombiner, FullBodySource]: + """The body and controllers channels; ``vendor=None`` selects the default ``body.pico-xr`` backend.""" + controllers = ControllersSource(name="controllers") + body = FullBodySource(name="full_body", vendor=vendor) + pipeline = OutputCombiner( + { + "controller_left": controllers.output(ControllersSource.LEFT), + "controller_right": controllers.output(ControllersSource.RIGHT), + "full_body": body.output(FullBodySource.FULL_BODY), + } + ) + return pipeline, body + + +class LiveFrameSource: + """Steps the session and reports what came back.""" + + def __init__(self, session: Any, body: FullBodySource) -> None: + self._session = session + self._tracker = body.get_tracker() + self._records = 0 + self._down = False + + @property + def records(self) -> int: + """Records written so far, which is the index the next one will carry.""" + return self._records + + def step(self) -> LiveStep: + result = self._session.step() + # An empty pose means no record was written; count records, not steps. + pose = self._tracker.get_body_pose(self._session.deviceio_session) + frame = None + if pose is not None: + frame = _frame(self._records, pose) + self._records += 1 + return LiveStep(frame=frame, pressed=self._edge(result)) + + def _edge(self, result: Any) -> bool: + pulled = max( + _trigger(result["controller_left"]), + _trigger(result["controller_right"]), + ) + if self._down: + self._down = pulled > TRIGGER_UP + return False + self._down = pulled >= TRIGGER_DOWN + return self._down + + +def _trigger(controller: Any) -> float: + if controller.is_none: + return 0.0 + return float(controller[ControllerInputIndex.TRIGGER_VALUE]) + + +def _frame(sequence: int, pose: Any) -> Frame: + """One live pose as a checker ``Frame``; every time field is absent or zero. + + Never substitute a local clock for the device timestamps. + """ + joints = pose.joints + decoded = None + if joints is not None: + # Copy out of the strided views that alias the serialised buffer. + decoded = tuple( + JointPose( + position=(float(p[0]), float(p[1]), float(p[2])), + orientation=(float(q[0]), float(q[1]), float(q[2]), float(q[3])), + is_valid=bool(valid), + ) + for p, q, valid in zip( + joints.positions, joints.orientations, joints.is_valid + ) + ) + return Frame( + sequence=sequence, + log_time_ns=0, + publish_time_ns=0, + has_payload=True, + all_joint_poses_tracked=bool(pose.all_joint_poses_tracked), + joints=decoded, + ) diff --git a/src/python/isaaccapture/cts/full_body/capture/make_labels.py b/src/python/isaaccapture/cts/full_body/capture/make_labels.py new file mode 100644 index 000000000..fd4191e3e --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/make_labels.py @@ -0,0 +1,265 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Writes the label sidecar from the windows the panel recorded. + +Opens the finished recording read-only, resolves each window's record numbers to +``sample_time_local_common_clock`` timestamps, and writes the sidecar the checker loads +beside the file. +""" + +from __future__ import annotations + +import json +import math +import statistics +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +from full_body_cts.frames import Frame +from full_body_cts.mcap_source import McapFrameSource +from full_body_cts.profile import FULL_BODY + +from steps import Window + +J = { + name: FULL_BODY.joint_names.index(name) + for name in ("PELVIS", "LEFT_ANKLE", "RIGHT_ANKLE", "LEFT_HAND", "RIGHT_HAND") +} + +NOTE = "Step labels for the recording beside this file." +CLOCK_DOMAIN = "sample_time_local_common_clock (system monotonic, nanoseconds)" + +#: One row of the report the panel shows after the file closes: name, ok, detail. +Check = tuple[str, bool, str] + + +@dataclass(frozen=True, slots=True) +class Geometry: + """The few distances ``verify()`` re-derives a window's motion from.""" + + separation: float + left_hand_y: float + right_hand_y: float + left_ankle_y: float + right_ankle_y: float + pelvis_y: float + + +@dataclass(frozen=True, slots=True) +class Record: + """One record of the body channel, addressed by its position in the file.""" + + sequence: int + sample_ns: int | None + geometry: Geometry | None + + +def read(recording: Path) -> list[Record]: + """Every record in file order; none is skipped, so a record number indexes the list.""" + return [ + Record( + sequence=frame.sequence, + sample_ns=frame.sample_time_ns, + geometry=_geometry(frame), + ) + for frame in McapFrameSource(str(recording)) + ] + + +def _geometry(frame: Frame) -> Geometry | None: + joints = frame.joints + if joints is None or not all(joints[index].is_valid for index in J.values()): + return None + left, right = joints[J["LEFT_HAND"]], joints[J["RIGHT_HAND"]] + return Geometry( + separation=math.dist(left.position, right.position), + left_hand_y=left.position[1], + right_hand_y=right.position[1], + left_ankle_y=joints[J["LEFT_ANKLE"]].position[1], + right_ankle_y=joints[J["RIGHT_ANKLE"]].position[1], + pelvis_y=joints[J["PELVIS"]].position[1], + ) + + +def audit(records: Sequence[Record], windows: Sequence[Window]) -> list[Check]: + """What has to hold before a frame number may be read as a record number. + + Reported, not raised; never map the numbers onto the nearest records on a mismatch. + """ + reach = max((window.end_frame for window in windows), default=0) + renumbered = sum( + 1 for position, record in enumerate(records) if record.sequence != position + ) + unstamped = sum(1 for record in records if record.sample_ns is None) + return [ + ( + "frames recorded", + reach <= len(records), + f"{len(records)} records, windows reach {reach}", + ), + ( + "sequence in file order", + renumbered == 0, + "every sequence equals its position" + if renumbered == 0 + else f"{renumbered} records are numbered otherwise", + ), + ( + "sample clock present", + unstamped == 0, + "every record stamped" + if unstamped == 0 + else f"{unstamped} records carry no sample time", + ), + ] + + +def lay_out(records: Sequence[Record], windows: Sequence[Window]) -> list[dict]: + """The sidecar's step list: each window's record numbers resolved to timestamps.""" + first_ns = next( + (record.sample_ns for record in records if record.sample_ns is not None), None + ) + if first_ns is None: + return [] + + steps = [] + for window in windows: + start_ns = _stamp(records, window.start_frame) + end_ns = _stamp(records, window.end_frame) + if start_ns is None or end_ns is None: + continue + steps.append( + { + "index": window.index, + "label": window.label, + "start_ns": start_ns, + "end_ns": end_ns, + "start_s_from_first_sample": round((start_ns - first_ns) / 1e9, 6), + "end_s_from_first_sample": round((end_ns - first_ns) / 1e9, 6), + "is_still_window": window.is_still_window, + "start_frame": window.start_frame, + "end_frame": window.end_frame, + # Which input opened the window. + "boundary_source": window.source, + } + ) + return steps + + +def _stamp(records: Sequence[Record], frame: int) -> int | None: + """The sample time of record ``frame``; one past the last record resolves to the final sample plus 1 ns.""" + if 0 <= frame < len(records): + return records[frame].sample_ns + if frame == len(records) and records and records[-1].sample_ns is not None: + return records[-1].sample_ns + 1 + return None + + +def _mean(window: Sequence[Geometry], field: str) -> float: + return statistics.mean(getattr(geometry, field) for geometry in window) + + +def verify(records: Sequence[Record], steps: Sequence[dict]) -> list[Check]: + """Re-derives each window's motion from signals the press did not use.""" + measurable = [record.geometry for record in records if record.geometry is not None] + if not measurable: + return [("all steps", False, "no frames with all needed joints valid")] + standing = statistics.median(geometry.pelvis_y for geometry in measurable) + + checks: list[Check] = [] + for step in steps: + window = [ + record.geometry + for record in records[step["start_frame"] : step["end_frame"]] + if record.geometry is not None + ] + label = step["label"] + if not window: + checks.append((label, False, "no measurable frames in the window")) + continue + + separation = _mean(window, "separation") + left_hand = _mean(window, "left_hand_y") + right_hand = _mean(window, "right_hand_y") + left_ankle = _mean(window, "left_ankle_y") + right_ankle = _mean(window, "right_ankle_y") + dip = standing - min(geometry.pelvis_y for geometry in window) + + if label in ("t_pose_hold_open", "t_pose_hold_close"): + checks.append( + (label, separation > 1.30, f"hands {separation * 100:.0f} cm apart") + ) + elif label == "left_arm_raise": + checks.append( + ( + label, + left_hand > right_hand + 0.25, + f"left hand {left_hand:.2f} m vs right {right_hand:.2f} m", + ) + ) + elif label == "right_arm_raise": + checks.append( + ( + label, + right_hand > left_hand + 0.25, + f"right hand {right_hand:.2f} m vs left {left_hand:.2f} m", + ) + ) + elif label == "left_leg_raise": + checks.append( + ( + label, + left_ankle > right_ankle + 0.04, + f"left ankle {left_ankle:+.3f} m vs right {right_ankle:+.3f} m", + ) + ) + elif label == "right_leg_raise": + checks.append( + ( + label, + right_ankle > left_ankle + 0.04, + f"right ankle {right_ankle:+.3f} m vs left {left_ankle:+.3f} m", + ) + ) + elif label == "squat_x2": + checks.append((label, dip > 0.15, f"pelvis drops {dip * 100:.0f} cm")) + return checks + + +def write( + recording: Path, windows: Sequence[Window] +) -> tuple[Path | None, list[Check]]: + """Reads the take back and writes ``.labels.json`` beside it. + + Returns the sidecar and every check run on the way; a sidecar with no steps is not written. + """ + records = read(recording) + checks = audit(records, windows) + steps = lay_out(records, windows) + checks.append( + ( + "windows labelled", + len(steps) == len(windows), + f"{len(steps)} of {len(windows)} resolved to timestamps", + ) + ) + checks.extend(verify(records, steps)) + if not steps: + return (None, checks) + + stamped = [record.sample_ns for record in records if record.sample_ns is not None] + span_s = (stamped[-1] - stamped[0]) / 1e9 if len(stamped) > 1 else 0.0 + + payload = { + "note": NOTE, + "clock_domain": CLOCK_DOMAIN, + "nominal_rate_hz": round(len(stamped) / span_s, 1) if span_s > 0 else None, + "records": len(records), + "steps": steps, + "mcap": recording.name, + } + sidecar = recording.with_name(recording.stem + ".labels.json") + sidecar.write_text(json.dumps(payload, indent=2) + "\n") + return (sidecar, checks) diff --git a/src/python/isaaccapture/cts/full_body/capture/pytest.ini b/src/python/isaaccapture/cts/full_body/capture/pytest.ini new file mode 100644 index 000000000..ff30898ca --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/pytest.ini @@ -0,0 +1,15 @@ +; SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +; SPDX-License-Identifier: Apache-2.0 + +; The capture scripts are run by path, not installed, so `from session import STEPS` +; resolves through this directory. pytest has to put it on sys.path the same way. +; +; Run these with the *checker's* venv, which is where pytest lives: +; +; cd src/python/isaaccapture/cts/full_body/capture && ../checker/.venv/bin/python -m pytest +; +; That venv has no isaaccapture in it, which is the point: the state machine under test +; imports neither it nor viser, so the tests run on a machine with nothing built. +[pytest] +pythonpath = . +testpaths = tests diff --git a/src/python/isaaccapture/cts/full_body/capture/record.sh b/src/python/isaaccapture/cts/full_body/capture/record.sh new file mode 100755 index 000000000..b1bc06245 --- /dev/null +++ b/src/python/isaaccapture/cts/full_body/capture/record.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Records one take of the motion script. Run it again for another take; nothing is +# ever overwritten. +# +# ./record.sh [device] +# +# ~/isaaccapture-captures/__