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
6 changes: 3 additions & 3 deletions .github/workflows/build-ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ jobs:
-v /tmp:/tmp \
-e PYTHONUNBUFFERED=1 \
--entrypoint bash teleop_ros2_ref:${{ env.ROS_DISTRO }} -c \
"source /usr/local/bin/teleop-env-setup && exec uv run --no-sync teleop_ros2_node.py --ros-args -p mode:=$mode -p hand_retargeter:=$hand_retargeter -p mcap_replay_path:=$REPLAY_MCAP" >/dev/null
"source /usr/local/bin/teleop-env-setup && exec uv run --no-sync python -m isaacteleop_examples.teleop_ros2 --ros-args -p mode:=$mode -p hand_retargeter:=$hand_retargeter -p mcap_replay_path:=$REPLAY_MCAP" >/dev/null

deadline=$((SECONDS + READINESS_TIMEOUT_SEC))
while [ "$SECONDS" -lt "$deadline" ]; do
Expand All @@ -767,7 +767,7 @@ jobs:
if docker exec \
-e PYTHONUNBUFFERED=1 \
"$RUN_NAME" bash -c \
"source /usr/local/bin/teleop-env-setup && exec uv run --no-sync python -m integration_tests.teleop_ros2_topic_verifier --mode $mode --hand-retargeter $hand_retargeter"; then
"source /usr/local/bin/teleop-env-setup && exec uv run --no-sync python -m isaacteleop_examples.teleop_ros2.integration_tests.teleop_ros2_topic_verifier --mode $mode --hand-retargeter $hand_retargeter"; then
verifier_rc=0
else
verifier_rc=$?
Expand Down Expand Up @@ -848,7 +848,7 @@ jobs:
-e PYTHONUNBUFFERED=1 \
-e ACCEPT_CLOUDXR_EULA=Y \
--entrypoint bash "$IMAGE" -c \
"source /usr/local/bin/teleop-env-setup && exec uv run --no-sync teleop_ros2_node.py --ros-args -p mode:=$MODE -p hand_retargeter:=$HAND_RETARGETER -p hand_tracking_plugin:=$HAND_TRACKING_PLUGIN -p cloudxr_accept_eula:=true" >/dev/null
"source /usr/local/bin/teleop-env-setup && exec uv run --no-sync python -m isaacteleop_examples.teleop_ros2 --ros-args -p mode:=$MODE -p hand_retargeter:=$HAND_RETARGETER -p hand_tracking_plugin:=$HAND_TRACKING_PLUGIN -p cloudxr_accept_eula:=true" >/dev/null

fail() {
echo "Error: $1"
Expand Down
6 changes: 3 additions & 3 deletions docs/source/device/trackers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,9 @@ reads the PICO ``XR_BD_body_tracking`` extension directly.

- :code-file:`examples/schemaio/full_body_printer.cpp`
- :code-file:`examples/mcap_record_replay/cpp/record_full_body.cpp`
- :code-file:`examples/mcap_record_replay/python/live_full_body.py`
- :code-file:`examples/mcap_record_replay/python/record_full_body.py`
- :code-file:`examples/mcap_record_replay/python/replay_full_body.py`
- :code-file:`examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_full_body.py`
- :code-file:`examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_full_body.py`
- :code-file:`examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_full_body.py`

.. note::

Expand Down
33 changes: 17 additions & 16 deletions docs/source/references/mcap_record_replay.rst
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ Runnable Example
----------------

A complete record / replay example lives at
``examples/mcap_record_replay/python/``:
``examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/``:

- ``common.py`` — pipeline builders (``build_hand_pipeline()``,
``build_controller_pipeline()``, ``build_full_body_pipeline()``) plus the
Expand Down Expand Up @@ -178,10 +178,9 @@ From the example directory:

.. code-block:: bash

cd examples/mcap_record_replay/python
uv sync
uv run python live_full_body.py --accept-eula
uv run python live_full_body.py --port 8090 --accept-eula # change viser port
uv pip install -e ./examples/mcap_record_replay
python -m isaacteleop_examples.mcap_record_replay.live_full_body --accept-eula
python -m isaacteleop_examples.mcap_record_replay.live_full_body --port 8090 --accept-eula

Open the printed URL (default ``http://localhost:8080``) in a browser. The
viewers bind every interface, since they run where the headset is and get
Expand All @@ -194,19 +193,21 @@ From the example directory:

.. code-block:: bash

cd examples/mcap_record_replay/python
uv sync
uv run python record_hand.py # 5 s → ../recordings/hands_<timestamp>.mcap
uv run python record_hand.py 10 # record for 10 seconds
uv run python record_hand.py 10 out.mcap # custom output path
uv pip install -e ./examples/mcap_record_replay
R="python -m isaacteleop_examples.mcap_record_replay.record_hand"
$R # 5 s → ./recordings/hands_<timestamp>.mcap
$R 10 # record for 10 seconds
$R 10 out.mcap # custom output path

Recordings are written to ``./recordings/`` relative to where you run the
command, and the replay scripts look there when given no path.

The example never downloads a published wheel — it runs against the
``isaacteleop`` next to it. Above, ``uv sync`` builds it from this checkout; the
first sync compiles the extension modules and takes a few minutes, later ones
reuse the cached build, and the install is editable, so edits under
``src/python/`` need no rebuild. From
``install/examples/mcap_record_replay/python`` (after ``cmake --install``) it
picks up the wheel you just built instead — see
``isaacteleop`` next to it, built from this checkout. The first install
compiles the extension modules and takes a few minutes; later ones reuse the
cached build, and the install is editable, so edits under ``src/python/`` need
no rebuild. From ``install/examples/mcap_record_replay`` (after ``cmake
--install``) it picks up the wheel you just built instead — see
:doc:`/getting_started/build_from_source/index`.

An active OpenXR runtime / headset must be connected, just like any other
Expand Down
2 changes: 1 addition & 1 deletion examples/mcap_record_replay/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ cmake_minimum_required(VERSION 3.20)
add_subdirectory(cpp)

include(${CMAKE_SOURCE_DIR}/cmake/InstallPythonExample.cmake)
install_python_example(DESTINATION examples/mcap_record_replay/python)
install_python_example(DESTINATION examples/mcap_record_replay)
35 changes: 35 additions & 0 deletions examples/mcap_record_replay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!--
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->

# MCAP record / replay

Record DeviceIO tracking to an MCAP file and replay it into a viser 3D view.
Live viewers are included for watching without recording.

```bash
uv pip install -e ./examples/mcap_record_replay
python -m isaacteleop_examples.mcap_record_replay.record_hand # 5 s
python -m isaacteleop_examples.mcap_record_replay.replay_hand # newest take
```

Recordings are written to `./recordings/` relative to where you run the
command; a replay given no path picks the newest file there.

The live and replay viewers bind every interface, so a browser on another
machine can reach them at `http://<this-host>:8080`. Pass `--host 127.0.0.1` to
keep a viewer local.

| Channel | Live | Record | Replay |
| --- | --- | --- | --- |
| Hands | `live_hand` | `record_hand` | `replay_hand` |
| Controllers | `live_controller` | `record_controller` | `replay_controller` |
| Full body | `live_full_body` | `record_full_body` | `replay_full_body` |
| VIVE SE3 trackers | — | `record_se3_vive` | `replay_se3_vive` |

`record_*` takes an optional duration in seconds and an optional output path.
Recording needs a live OpenXR runtime; replay needs only the file.

A C++ recorder lives in `cpp/`. Docs:
`docs/source/references/mcap_record_replay.rst`.
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mcap-record-replay-example"
# The dist name mirrors the import path, so an installed example claims no bare
# top-level name in site-packages.
name = "isaacteleop-examples-mcap-record-replay"
version = "0.0.0" # Internal example - not versioned
description = "Isaac Teleop MCAP record / replay example with viser visualization"
requires-python = ">=3.11,<3.14"
Expand All @@ -15,8 +21,16 @@ dependencies = [
"viser>=0.2.0",
]

# `isaacteleop_examples` is a PEP 420 namespace shared by every example dist and
# must stay without an __init__.py. `only-include` + `sources`, not `packages`:
# the latter keeps only the last path component and would root the wheel at a
# bare top-level `mcap_record_replay/`.
[tool.hatch.build.targets.wheel]
only-include = ["python/isaacteleop_examples/mcap_record_replay"]
sources = ["python"]

[tool.uv.sources]
# Editable, so edits under src/python/ need no rebuild. Dropped from the
# installed copy, which has no repo above it.
# TODO(#880): nothing runs this example in CI; neither path is covered.
isaacteleop = { path = "../../..", editable = true }
isaacteleop = { path = "../..", editable = true }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,71 @@
)


class GroundGrid:
"""The ground plane and the default camera, anchored to what is tracked.

The session asks OpenXR for a stage (floor-relative) space, but a runtime
that cannot supply one falls back to a head-relative origin: y=0 then sits
at eye height and the skeleton hangs below a grid drawn at zero. Following
the lowest tracked joint puts the grid on the floor in either space, and
the camera is framed against that floor rather than against y=0 -- aiming
at a fixed height leaves the subject at the bottom of the viewport in a
head-relative space.
"""

def __init__(self, server, handle, smoothing: float = 0.05):
self._server = server
self._handle = handle
self._smoothing = smoothing
self._y: float | None = None

@server.on_client_connect
def _(client) -> None:
self._frame(client)

def _frame(self, client) -> None:
"""Stand back from the floor at eye height, looking at torso height."""
floor = 0.0 if self._y is None else self._y
client.camera.position = (0.0, floor + 1.5, 2.5)
client.camera.look_at = (0.0, floor + 0.9, 0.0)

def follow(self, positions: np.ndarray, valid: np.ndarray) -> None:
points = np.asarray(positions, dtype=np.float32)[np.asarray(valid, dtype=bool)]
if points.size == 0:
return
lowest = float(np.min(points[:, 1]))
# Ease toward it: a single mistracked frame should not drop the floor.
first = self._y is None
self._y = lowest if first else self._y + self._smoothing * (lowest - self._y)
self._handle.position = (0.0, self._y, 0.0)

# Re-aim once, when the floor is first known. Doing it every frame would
# fight the mouse.
if first:
for client in self._server.get_clients().values():
self._frame(client)


def setup_scene(server) -> GroundGrid:
"""Up axis, ground grid and a starting camera, shared by every viewer here.

viser's ``add_grid`` defaults to the XY plane, which stands up as a wall
once the up direction is +y -- it has to be ``xz`` to lie on the ground.
Returns the grid so a caller with tracked joints can keep it on the floor.
"""
server.scene.set_up_direction("+y")
grid = server.scene.add_grid(
name="/grid",
width=6.0,
height=6.0,
plane="xz",
cell_size=0.25,
section_size=1.0,
)

return GroundGrid(server, grid)


_ZERO_POSITIONS = np.zeros((NUM_HAND_JOINTS, 3), dtype=np.float32)


Expand Down Expand Up @@ -456,7 +521,8 @@ def update(self, state: dict) -> None:
class FullBodyViz:
"""Viser handles for full-body skeleton (joint cloud + skeleton segments)."""

def __init__(self, server: viser.ViserServer):
def __init__(self, server: viser.ViserServer, ground: "GroundGrid | None" = None):
self._ground = ground
self.color = np.array(TRACKED_COLOR, dtype=np.float32)
zero_pts = np.zeros((len(BODY_JOINT_NAMES), 3), dtype=np.float32)
zero_segs = np.zeros((0, 2, 3), dtype=np.float32)
Expand Down Expand Up @@ -494,3 +560,6 @@ def update(self, positions: np.ndarray | None, valid: np.ndarray | None) -> None
segs = _valid_bone_segments(positions, valid_bool)
self.bones.points = segs
self.bones.colors = np.tile(self.color, (segs.shape[0], 2, 1))

if self._ground is not None:
self._ground.follow(positions, valid_bool)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
Visualize live OpenXR controller poses in real time with viser.

``CloudXRLauncher`` starts the CloudXR runtime and WSS proxy automatically.
Open the URL viser prints (default http://localhost:8080) in a browser to see
Open the URL viser prints in a browser (binds all interfaces, so another
machine can reach it at http://<this-host>:8080) to see
aim / grip points for both controllers, a ray between them, and a live HUD
showing thumbstick, trigger, squeeze, and button state.

Expand All @@ -26,7 +27,8 @@
from isaacteleop.cloudxr import CloudXRLauncher
from isaacteleop.teleop_session_manager import TeleopSession, TeleopSessionConfig

from common import (
from .common import (
setup_scene,
ControllerViz,
LEFT_COLOR,
RIGHT_COLOR,
Expand All @@ -47,8 +49,7 @@ def main(argv: list[str]) -> int:
args = parser.parse_args(argv[1:])

server = viser.ViserServer(host=args.host, port=args.port)
server.scene.set_up_direction("+y")
server.scene.add_grid(name="/grid", width=2.0, height=2.0, cell_size=0.1)
setup_scene(server)

config = TeleopSessionConfig(
app_name="LiveControllerExample",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
Visualize live OpenXR full-body pose tracking in real time with viser.

``CloudXRLauncher`` starts the CloudXR runtime and WSS proxy automatically.
Open the URL viser prints (default http://localhost:8080) in a browser to see
Open the URL viser prints in a browser (binds all interfaces, so another
machine can reach it at http://<this-host>:8080) to see
the full PICO body skeleton — joints colored green when valid, red when lost —
updating live as you move.

Expand All @@ -28,7 +29,7 @@
from isaacteleop.retargeting_engine.tensor_types.indices import FullBodyInputIndex
from isaacteleop.teleop_session_manager import TeleopSession, TeleopSessionConfig

from common import BODY_JOINT_NAMES, FullBodyViz, build_full_body_pipeline
from .common import BODY_JOINT_NAMES, FullBodyViz, build_full_body_pipeline, setup_scene


def main(argv: list[str]) -> int:
Expand All @@ -43,8 +44,7 @@ def main(argv: list[str]) -> int:
args = parser.parse_args(argv[1:])

server = viser.ViserServer(host=args.host, port=args.port)
server.scene.set_up_direction("+y")
server.scene.add_grid(name="/grid", width=2.0, height=2.0, cell_size=0.1)
ground = setup_scene(server)

config = TeleopSessionConfig(
app_name="LiveFullBodyExample",
Expand All @@ -57,7 +57,7 @@ def main(argv: list[str]) -> int:
print("[live] waiting for headset connection… (Ctrl+C to stop)")

with TeleopSession(config) as session:
viz = FullBodyViz(server)
viz = FullBodyViz(server, ground)
print(
f"[live] viser listening on {args.host}:{args.port} "
f"(http://localhost:{args.port})"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
Visualize live OpenXR hand-tracking in real time with viser.

``CloudXRLauncher`` starts the CloudXR runtime and WSS proxy automatically.
Open the URL viser prints (default http://localhost:8080) in a browser to see
Open the URL viser prints in a browser (binds all interfaces, so another
machine can reach it at http://<this-host>:8080) to see
both hands rendered as joint clouds + bone segments, updating live as you move.

Usage:
Expand All @@ -26,7 +27,7 @@
from isaacteleop.cloudxr import CloudXRLauncher
from isaacteleop.teleop_session_manager import TeleopSession, TeleopSessionConfig

from common import HandViz, LEFT_COLOR, RIGHT_COLOR, build_hand_pipeline
from .common import HandViz, LEFT_COLOR, RIGHT_COLOR, build_hand_pipeline, setup_scene


def main(argv: list[str]) -> int:
Expand All @@ -41,8 +42,7 @@ def main(argv: list[str]) -> int:
args = parser.parse_args(argv[1:])

server = viser.ViserServer(host=args.host, port=args.port)
server.scene.set_up_direction("+y")
server.scene.add_grid(name="/grid", width=2.0, height=2.0, cell_size=0.1)
setup_scene(server)

config = TeleopSessionConfig(
app_name="LiveHandExample",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
Usage:
python record_controller.py [duration_seconds] [output.mcap] [--accept-eula]

Defaults: 5 seconds → ../recordings/controllers_<timestamp>.mcap
Defaults: 5 seconds → ./recordings/controllers_<timestamp>.mcap

See: https://nvidia.github.io/IsaacTeleop/main/references/mcap_record_replay.html
"""
Expand All @@ -28,7 +28,7 @@
from isaacteleop.retargeting_engine.tensor_types.indices import ControllerInputIndex
from isaacteleop.teleop_session_manager import TeleopSession, TeleopSessionConfig

from common import build_controller_pipeline
from .common import build_controller_pipeline


def main(argv: list[str]) -> int:
Expand All @@ -46,7 +46,7 @@ def main(argv: list[str]) -> int:
mcap_path = Path(args.output)
mcap_path.parent.mkdir(parents=True, exist_ok=True)
else:
out_dir = Path(__file__).resolve().parent.parent / "recordings"
out_dir = Path.cwd() / "recordings"
out_dir.mkdir(exist_ok=True)
mcap_path = out_dir / f"controllers_{datetime.now():%Y%m%d_%H%M%S}.mcap"

Expand Down
Loading
Loading