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
36 changes: 30 additions & 6 deletions cmake/InstallPythonExample.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@
# way it pins isaacteleop to the version this build produced and drops any
# [tool.uv.sources], so the installed example resolves the wheel next to it.
#
# Two source layouts are accepted while the examples are converted to the
# convention in examples/README.md (NVIDIA/IsaacTeleop#985), picked by where
# pyproject.toml sits. Converted: at the example root, sources under python/.
# Legacy: under python/. Drop the legacy branch once every example has moved.
#
# Usage:
# install_python_example(DESTINATION examples/oxr/python)
# install_python_example(DESTINATION examples/<name>) # converted
# install_python_example(DESTINATION examples/<name>/python) # legacy
# install_python_example(DESTINATION examples/teleop_ros2/python
# EXTRA_UV_EXTRA_BUILD_DEPS "nlopt = [\"numpy\"]")
# ==============================================================================
Expand All @@ -32,8 +38,17 @@ macro(install_python_example)
endif()

# Read the bare pyproject.toml and append uv configuration for the
# installed environment.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/python/pyproject.toml" _PYPROJECT_BASE)
# installed environment. `find-links` is relative to the installed
# pyproject, so its depth follows the layout: <prefix>/examples/<name>/ for
# a converted example, one level deeper for a legacy one.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/pyproject.toml")
set(_IPE_PYPROJECT "${CMAKE_CURRENT_SOURCE_DIR}/pyproject.toml")
set(_IPE_FIND_LINKS "../../wheels")
else()
set(_IPE_PYPROJECT "${CMAKE_CURRENT_SOURCE_DIR}/python/pyproject.toml")
set(_IPE_FIND_LINKS "../../../wheels")
endif()
file(READ "${_IPE_PYPROJECT}" _PYPROJECT_BASE)
Comment thread
jiwenc-nv marked this conversation as resolved.

# A source-tree [tool.uv.sources] path is relative to the pyproject, so from
# the installed copy it points at the install prefix, which is not a Python
Expand All @@ -47,7 +62,7 @@ macro(install_python_example)
string(REGEX REPLACE "\"(isaacteleop(\\[[^]]*\\])?)\""
"\"\\1==${ISAAC_TELEOP_PYPROJECT_VERSION}\"" _PYPROJECT_BASE "${_PYPROJECT_BASE}")
set(_TOOL_UV_BLOCK "[tool.uv]
find-links = [\"../../../wheels\"]
find-links = [\"${_IPE_FIND_LINKS}\"]
python-preference = \"only-managed\"
environments = [\"python_version == '${ISAAC_TELEOP_PYTHON_VERSION}'\"]
required-environments = [\"sys_platform == 'linux' and platform_machine == '${_IPE_PLATFORM_MACHINE}'\"]
Expand All @@ -66,8 +81,17 @@ ${_IPE_EXTRA_UV_EXTRA_BUILD_DEPS}
DESTINATION ${_IPE_DESTINATION}
)

# Install Python example sources
install(DIRECTORY python/
# Install Python example sources. A converted example keeps its python/
# directory, so the namespace root in the install tree matches the source
# tree; a legacy one flattens python/ into the destination.
# `python` without a trailing slash installs the directory itself, so the
# namespace root survives; `python/` installs its contents, flattening it.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/pyproject.toml")
set(_IPE_SOURCE_DIR "python")
else()
set(_IPE_SOURCE_DIR "python/")
endif()
install(DIRECTORY ${_IPE_SOURCE_DIR}
Comment thread
jiwenc-nv marked this conversation as resolved.
DESTINATION ${_IPE_DESTINATION}
FILES_MATCHING
PATTERN "*.py"
Expand Down
14 changes: 10 additions & 4 deletions docs/source/references/mcap_record_replay.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,13 @@ additional source nodes (``HeadSource``, ``ControllersSource``, …) in
``common.py``.

For a live browser view of **all** human DeviceIO trackers at once (hands, head,
controllers, and full body), see ``examples/deviceio_live_view/python/``. It is
set up the same way as this example — the ``uv sync`` notes under `Recording`_
apply to it too.
controllers, and full body), see ``examples/deviceio_live_view/`` and its
``README.md``:

.. code-block:: bash

uv pip install -e ./examples/deviceio_live_view
python -m isaacteleop_examples.deviceio_live_view --accept-eula

A C++ recorder lives at ``examples/mcap_record_replay/cpp/``:

Expand All @@ -157,7 +161,9 @@ From the example directory:
uv run python live_full_body.py --accept-eula
uv run python live_full_body.py --port 8090 --accept-eula # change viser port

Open the printed URL (default ``http://localhost:8080``) in a browser.
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
opened from another machine; pass ``--host 127.0.0.1`` to keep one local.

Recording
^^^^^^^^^
Expand Down
2 changes: 1 addition & 1 deletion examples/deviceio_live_view/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
cmake_minimum_required(VERSION 3.20)

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

# DeviceIO Live View

Draws live DeviceIO human tracking — hands, head, controllers, full body — in the
browser with [viser](https://viser.studio). Trackers that are inactive or absent
are hidden rather than drawn in an error color. `CloudXRLauncher` starts the
CloudXR runtime and WSS proxy itself, so there is nothing to launch separately.

```bash
uv pip install -e ./examples/deviceio_live_view
python -m isaacteleop_examples.deviceio_live_view --accept-eula
```

Open the URL it prints (default <http://localhost:8080>). It binds every
interface, so another machine on the network can reach it at
`http://<this-host>:8080`; pass `--host 127.0.0.1` to keep it local, `--port` to
move it. Ctrl+C stops it.
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 = "deviceio-live-view-example"
# The dist name mirrors the import path, so an installed example claims no bare
# top-level name in site-packages.
name = "isaacteleop-examples-deviceio-live-view"
version = "0.0.0" # Internal example - not versioned
description = "Isaac Teleop live DeviceIO viewer with viser visualization"
requires-python = ">=3.11,<3.14"
Expand All @@ -14,8 +20,14 @@ dependencies = [
"viser>=0.2.0",
]

# `only-include` + `sources`, not `packages`, and no __init__.py above
# deviceio_live_view -- see the packaging rules in examples/README.md.
[tool.hatch.build.targets.wheel]
only-include = ["python/isaacteleop_examples/deviceio_live_view"]
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
@@ -0,0 +1,8 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import sys

from .live_deviceio import main

sys.exit(main(sys.argv))
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,71 @@
TRACKED_COLOR: tuple[float, float, float] = (0.25, 0.85, 0.35)


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)


def build_all_human_pipeline():
"""Wire every human-related DeviceIO source into one pipeline."""
hands = HandsSource(name=HANDS_CHANNEL)
Expand Down Expand Up @@ -447,7 +512,8 @@ class HumanDeviceIOViz:
Inactive or absent trackers are hidden instead of drawn in the invalid color.
"""

def __init__(self, server: viser.ViserServer):
def __init__(self, server: viser.ViserServer, ground: GroundGrid | None = None):
self._ground = ground
self.hand_left = HandViz(server, "hand_left", LEFT_COLOR)
self.hand_right = HandViz(server, "hand_right", RIGHT_COLOR)
self.head = HeadViz(server)
Expand Down Expand Up @@ -501,6 +567,8 @@ def _update_full_body_if_active(self, full_body) -> tuple[bool, int]:
self.full_body.points.visible = True
self.full_body.bones.visible = True
self.full_body.update(positions, valid)
if self._ground is not None:
self._ground.follow(positions, valid)
return True, n_valid

def update(self, result) -> dict[str, bool | int]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,44 +9,44 @@
trackers are hidden rather than shown in an error color.

``CloudXRLauncher`` starts the CloudXR runtime and WSS proxy automatically.
Open the URL viser prints (default http://localhost:8080) in a browser.
Open the URL viser prints in a browser. Binds all interfaces by default, so
another machine on the network can reach it at http://<this-host>:8080.

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

Press Ctrl+C to stop.
"""

import argparse
import sys
import time

import viser

from isaacteleop.cloudxr import CloudXRLauncher
from isaacteleop.teleop_session_manager import TeleopSession, TeleopSessionConfig

from deviceio_viser import (
from .deviceio_viser import (
BODY_JOINT_NAMES,
HumanDeviceIOViz,
build_all_human_pipeline,
setup_scene,
)


def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Viser HTTP bind address (default: 127.0.0.1; pass 0.0.0.0 to expose externally)",
default="0.0.0.0",
help="Viser HTTP bind address (default: 0.0.0.0, all interfaces; pass 127.0.0.1 to keep it local)",
)
parser.add_argument("--port", type=int, default=8080, help="Viser HTTP port")
CloudXRLauncher.add_launcher_arguments(parser)
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="LiveDeviceIOExample",
Expand All @@ -59,8 +59,11 @@ def main(argv: list[str]) -> int:
print("[live] waiting for headset connection… (Ctrl+C to stop)")

with TeleopSession(config) as session:
viz = HumanDeviceIOViz(server)
print(f"[live] viser running at http://localhost:{args.port}")
viz = HumanDeviceIOViz(server, ground)
print(
f"[live] viser listening on {args.host}:{args.port} "
f"(http://localhost:{args.port})"
)
try:
while True:
result = session.step()
Expand All @@ -83,7 +86,3 @@ def main(argv: list[str]) -> int:

print("[live] stopped")
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
9 changes: 6 additions & 3 deletions examples/mcap_record_replay/python/live_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Viser HTTP bind address (default: 127.0.0.1; pass 0.0.0.0 to expose externally)",
default="0.0.0.0",
help="Viser HTTP bind address (default: 0.0.0.0, all interfaces; pass 127.0.0.1 to keep it local)",
)
parser.add_argument("--port", type=int, default=8080, help="Viser HTTP port")
CloudXRLauncher.add_launcher_arguments(parser)
Expand All @@ -63,7 +63,10 @@ def main(argv: list[str]) -> int:
with TeleopSession(config) as session:
viz_left = ControllerViz(server, "controller_left", LEFT_COLOR)
viz_right = ControllerViz(server, "controller_right", RIGHT_COLOR)
print(f"[live] viser running at http://localhost:{args.port}")
print(
f"[live] viser listening on {args.host}:{args.port} "
f"(http://localhost:{args.port})"
)
_last_step_t = time.time()
_missed = 0
try:
Expand Down
9 changes: 6 additions & 3 deletions examples/mcap_record_replay/python/live_full_body.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Viser HTTP bind address (default: 127.0.0.1; pass 0.0.0.0 to expose externally)",
default="0.0.0.0",
help="Viser HTTP bind address (default: 0.0.0.0, all interfaces; pass 127.0.0.1 to keep it local)",
)
parser.add_argument("--port", type=int, default=8080, help="Viser HTTP port")
CloudXRLauncher.add_launcher_arguments(parser)
Expand All @@ -58,7 +58,10 @@ def main(argv: list[str]) -> int:

with TeleopSession(config) as session:
viz = FullBodyViz(server)
print(f"[live] viser running at http://localhost:{args.port}")
print(
f"[live] viser listening on {args.host}:{args.port} "
f"(http://localhost:{args.port})"
)
try:
while True:
result = session.step()
Expand Down
9 changes: 6 additions & 3 deletions examples/mcap_record_replay/python/live_hand.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--host",
default="127.0.0.1",
help="Viser HTTP bind address (default: 127.0.0.1; pass 0.0.0.0 to expose externally)",
default="0.0.0.0",
help="Viser HTTP bind address (default: 0.0.0.0, all interfaces; pass 127.0.0.1 to keep it local)",
)
parser.add_argument("--port", type=int, default=8080, help="Viser HTTP port")
CloudXRLauncher.add_launcher_arguments(parser)
Expand All @@ -57,7 +57,10 @@ def main(argv: list[str]) -> int:
with TeleopSession(config) as session:
viz_left = HandViz(server, "hand_left", LEFT_COLOR)
viz_right = HandViz(server, "hand_right", RIGHT_COLOR)
print(f"[live] viser running at http://localhost:{args.port}")
print(
f"[live] viser listening on {args.host}:{args.port} "
f"(http://localhost:{args.port})"
)
_last_step_t = time.time()
_missed = 0
try:
Expand Down
Loading
Loading