Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,9 @@ project.spdx
# MuJoCo writes this to the working directory, not next to the model.
MUJOCO_LOG.TXT

# examples/mcap_record_replay writes here by default (Path.cwd() / "recordings"),
# and its README has you run from the repo root.
/recordings/

# Runtime device files injected by MCP server infrastructure
.mcp.json
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
61 changes: 33 additions & 28 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 @@ -168,19 +168,18 @@ A C++ recorder lives at ``examples/mcap_record_replay/cpp/``:
- ``record_full_body.cpp`` — records the ``full_body`` channel by passing a
``core::McapRecordingConfig`` to ``DeviceIOSession::run()``. It uses the
same channel base name as ``FullBodySource``, so the resulting file replays
with ``replay_full_body.py``.
with ``replay_full_body``.

Live preview
^^^^^^^^^^^^

From the example directory:
From the repo root:

.. 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
Comment thread
jiwenc-nv marked this conversation as resolved.

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 @@ -189,23 +188,25 @@ opened from another machine; pass ``--host 127.0.0.1`` to keep one local.
Recording
^^^^^^^^^

From the example directory:
From the repo root:

.. 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.
Comment thread
jiwenc-nv marked this conversation as resolved.

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 All @@ -221,19 +222,23 @@ Replay runs headless — no headset required:

.. code-block:: bash

uv run python replay_hand.py # newest file in ../recordings/
uv run python replay_hand.py path/to/file.mcap # explicit file
uv run python replay_hand.py --loop # repeat until Ctrl+C
uv run python replay_hand.py --port 8090 # change viser port
R="python -m isaacteleop_examples.mcap_record_replay.replay_hand"
$R # newest hands_*.mcap under ./recordings/
$R path/to/file.mcap # explicit file
$R --loop # repeat until Ctrl+C
$R --port 8090 # change viser port

Open the printed URL (default ``http://localhost:8080``) in a browser to see the
left (green) and right (blue) hand skeletons update each frame.

The ``record_controller.py`` / ``replay_controller.py`` and
``record_full_body.py`` / ``replay_full_body.py`` pairs use the same CLI
(positional MCAP path, ``--host``, ``--port``, ``--loop``).
``live_full_body.py`` accepts ``--host``, ``--port``, and the CloudXR launcher
flags (including ``--accept-eula``).
The ``record_controller`` / ``replay_controller`` and ``record_full_body`` /
``replay_full_body`` module pairs use the same CLI (positional MCAP path,
``--host``, ``--port``, ``--loop``). ``live_full_body`` accepts ``--host``,
``--port``, and the CloudXR launcher flags (including ``--accept-eula``).

``uv run`` (instead of an activated venv) needs an explicit ``--python 3.11``
— without it, ``uv`` may resolve against the system Python and fail to match
the lockfile.

API Reference
-------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
TRACKED_COLOR: tuple[float, float, float] = (0.25, 0.85, 0.35)


# Duplicated verbatim in mcap_record_replay/common.py: each example package
# is self-contained (see examples/README.md), so this stays a copy rather
# than a cross-example dependency. Keep the two in sync by hand.
class GroundGrid:
"""The ground plane and the default camera, anchored to what is tracked.

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)
41 changes: 41 additions & 0 deletions examples/mcap_record_replay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!--
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 matching recording there
(e.g. `replay_hand` picks the newest `hands_*.mcap`) and exits if none
match -- no silent fallback to a wrong-type recording.

`uv run` (instead of an activated venv) needs an explicit `--python 3.11` --
without it, `uv` may resolve against the system Python and fail to match the
lockfile.

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,74 @@
)


# Duplicated verbatim in deviceio_live_view/deviceio_viser.py: each example
# package is self-contained (see examples/README.md), so this stays a copy
# rather than a cross-example dependency. Keep the two in sync by hand.
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 +524,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 +563,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,12 +5,13 @@
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.

Usage:
python live_controller.py [--port 8080] [--host 127.0.0.1] [--accept-eula]
python -m isaacteleop_examples.mcap_record_replay.live_controller [--port 8080] [--host 127.0.0.1] [--accept-eula]

Press Ctrl+C to stop.

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,12 +5,13 @@
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
Comment thread
jiwenc-nv marked this conversation as resolved.
the full PICO body skeleton — joints colored green when valid, red when lost —
updating live as you move.

Usage:
python live_full_body.py [--port 8080] [--host 127.0.0.1] [--accept-eula]
python -m isaacteleop_examples.mcap_record_replay.live_full_body [--port 8080] [--host 127.0.0.1] [--accept-eula]

Press Ctrl+C to stop.

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
Loading
Loading