Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/python/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions src/python/isaaccapture/cts/common/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!--
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->

# 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`,
status-list HTML and submission archive.

- **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.
- **The submission archive (`panel/bundle.py`) takes the schema's parts as arguments**:
each schema's `panel/bundle.py` passes the result groups, the frame series and its own
package for `tool`. The archive carries the inputs that cannot be regenerated, above
all the labels sidecar. Keep `inputs` (file hashes, labels, `tool.commit`) apart from
outputs; the packed report is a cache and the recording wins. A missing companion goes
in `inputs.missing`, not an error. It is a zip so `report.json` reads without
decompressing the recording.
- **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).
9 changes: 9 additions & 0 deletions src/python/isaaccapture/cts/common/pytest.ini
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions src/python/isaaccapture/cts/common/src/cts_common/__init__.py
Original file line number Diff line number Diff line change
@@ -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."""
Original file line number Diff line number Diff line change
@@ -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.
"""
89 changes: 89 additions & 0 deletions src/python/isaaccapture/cts/common/src/cts_common/checks/base.py
Original file line number Diff line number Diff line change
@@ -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: ...
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading