diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index 9761e7f440..53e72da834 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -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 @@ -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=$? @@ -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" diff --git a/docs/source/device/trackers.rst b/docs/source/device/trackers.rst index ba6bbfcfed..a8ccc1c818 100644 --- a/docs/source/device/trackers.rst +++ b/docs/source/device/trackers.rst @@ -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:: diff --git a/docs/source/references/mcap_record_replay.rst b/docs/source/references/mcap_record_replay.rst index 241e4e2774..30614d8701 100644 --- a/docs/source/references/mcap_record_replay.rst +++ b/docs/source/references/mcap_record_replay.rst @@ -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 @@ -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 @@ -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_.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_.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 diff --git a/examples/mcap_record_replay/CMakeLists.txt b/examples/mcap_record_replay/CMakeLists.txt index 7a977c5e27..d59cf24f12 100644 --- a/examples/mcap_record_replay/CMakeLists.txt +++ b/examples/mcap_record_replay/CMakeLists.txt @@ -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) diff --git a/examples/mcap_record_replay/README.md b/examples/mcap_record_replay/README.md new file mode 100644 index 0000000000..f77976d153 --- /dev/null +++ b/examples/mcap_record_replay/README.md @@ -0,0 +1,35 @@ + + +# 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://: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`. diff --git a/examples/mcap_record_replay/python/pyproject.toml b/examples/mcap_record_replay/pyproject.toml similarity index 53% rename from examples/mcap_record_replay/python/pyproject.toml rename to examples/mcap_record_replay/pyproject.toml index 440fe3d8c2..9bed222988 100644 --- a/examples/mcap_record_replay/python/pyproject.toml +++ b/examples/mcap_record_replay/pyproject.toml @@ -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" @@ -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 } diff --git a/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/__init__.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/__init__.py new file mode 100644 index 0000000000..52a7a9daf0 --- /dev/null +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/examples/mcap_record_replay/python/common.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/common.py similarity index 86% rename from examples/mcap_record_replay/python/common.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/common.py index 835db44bd8..76bf5340bb 100644 --- a/examples/mcap_record_replay/python/common.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/common.py @@ -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) @@ -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) @@ -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) diff --git a/examples/mcap_record_replay/python/live_controller.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_controller.py similarity index 93% rename from examples/mcap_record_replay/python/live_controller.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_controller.py index 850fe12802..ffaeb769eb 100644 --- a/examples/mcap_record_replay/python/live_controller.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_controller.py @@ -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://:8080) to see aim / grip points for both controllers, a ray between them, and a live HUD showing thumbstick, trigger, squeeze, and button state. @@ -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, @@ -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", diff --git a/examples/mcap_record_replay/python/live_full_body.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_full_body.py similarity index 91% rename from examples/mcap_record_replay/python/live_full_body.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_full_body.py index 883a1f39d9..fcd699146d 100644 --- a/examples/mcap_record_replay/python/live_full_body.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_full_body.py @@ -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://:8080) to see the full PICO body skeleton — joints colored green when valid, red when lost — updating live as you move. @@ -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: @@ -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", @@ -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})" diff --git a/examples/mcap_record_replay/python/live_hand.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_hand.py similarity index 92% rename from examples/mcap_record_replay/python/live_hand.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_hand.py index 7349dea622..cf946f5c2e 100644 --- a/examples/mcap_record_replay/python/live_hand.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/live_hand.py @@ -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://:8080) to see both hands rendered as joint clouds + bone segments, updating live as you move. Usage: @@ -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: @@ -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", diff --git a/examples/mcap_record_replay/python/record_controller.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_controller.py similarity index 94% rename from examples/mcap_record_replay/python/record_controller.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_controller.py index 87fb58bce3..9fef543089 100644 --- a/examples/mcap_record_replay/python/record_controller.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_controller.py @@ -12,7 +12,7 @@ Usage: python record_controller.py [duration_seconds] [output.mcap] [--accept-eula] -Defaults: 5 seconds → ../recordings/controllers_.mcap +Defaults: 5 seconds → ./recordings/controllers_.mcap See: https://nvidia.github.io/IsaacTeleop/main/references/mcap_record_replay.html """ @@ -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: @@ -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" diff --git a/examples/mcap_record_replay/python/record_full_body.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_full_body.py similarity index 94% rename from examples/mcap_record_replay/python/record_full_body.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_full_body.py index d2c76c3577..0c6a03bb92 100644 --- a/examples/mcap_record_replay/python/record_full_body.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_full_body.py @@ -12,7 +12,7 @@ Usage: python record_full_body.py [duration_seconds] [output.mcap] [--accept-eula] -Defaults: 5 seconds → ../recordings/full_body_.mcap +Defaults: 5 seconds → ./recordings/full_body_.mcap See: https://nvidia.github.io/IsaacTeleop/main/references/mcap_record_replay.html """ @@ -30,7 +30,7 @@ from isaacteleop.retargeting_engine.tensor_types.indices import FullBodyInputIndex from isaacteleop.teleop_session_manager import TeleopSession, TeleopSessionConfig -from common import BODY_JOINT_NAMES, build_full_body_pipeline +from .common import BODY_JOINT_NAMES, build_full_body_pipeline def main(argv: list[str]) -> int: @@ -48,7 +48,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"full_body_{datetime.now():%Y%m%d_%H%M%S}.mcap" diff --git a/examples/mcap_record_replay/python/record_hand.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_hand.py similarity index 94% rename from examples/mcap_record_replay/python/record_hand.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_hand.py index cefa06ee8a..af39998253 100644 --- a/examples/mcap_record_replay/python/record_hand.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_hand.py @@ -12,7 +12,7 @@ Usage: python record_hand.py [duration_seconds] [output.mcap] [--accept-eula] -Defaults: 5 seconds → ../recordings/hands_.mcap +Defaults: 5 seconds → ./recordings/hands_.mcap See: https://nvidia.github.io/IsaacTeleop/main/references/mcap_record_replay.html """ @@ -27,7 +27,7 @@ from isaacteleop.deviceio import McapRecordingConfig from isaacteleop.teleop_session_manager import TeleopSession, TeleopSessionConfig -from common import build_hand_pipeline +from .common import build_hand_pipeline def main(argv: list[str]) -> int: @@ -45,7 +45,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"hands_{datetime.now():%Y%m%d_%H%M%S}.mcap" diff --git a/examples/mcap_record_replay/python/record_se3_vive.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_se3_vive.py similarity index 97% rename from examples/mcap_record_replay/python/record_se3_vive.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_se3_vive.py index 3e19b47c7d..aba35c90e0 100644 --- a/examples/mcap_record_replay/python/record_se3_vive.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/record_se3_vive.py @@ -15,7 +15,7 @@ (VIVEHub tracker_server running, or VIVE_SE3_SYNTHETIC=1 for a smoke test) With no arguments it records 10 s of every collection the pusher currently -advertises (see --collections), to a timestamped file under ../recordings/. +advertises (see --collections), to a timestamped file under ./recordings/. Usage: source ~/.cloudxr/run/cloudxr.env @@ -85,7 +85,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"se3_vive_{datetime.now():%Y%m%d_%H%M%S}.mcap" diff --git a/examples/mcap_record_replay/python/replay_controller.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_controller.py similarity index 94% rename from examples/mcap_record_replay/python/replay_controller.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_controller.py index 426d9b7c3b..12e9d6d20f 100644 --- a/examples/mcap_record_replay/python/replay_controller.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_controller.py @@ -12,7 +12,7 @@ Usage: python replay_controller.py [path/to/file.mcap] [--port 8080] [--loop] -If no path is given, the newest file under ``../recordings/`` is used. +If no path is given, the newest file under ``./recordings/`` is used. ``--loop`` keeps replaying the file end-to-end until the process is killed. See: https://nvidia.github.io/IsaacTeleop/main/references/mcap_record_replay.html @@ -33,7 +33,8 @@ TeleopSessionConfig, ) -from common import ( +from .common import ( + setup_scene, ControllerViz, LEFT_COLOR, RIGHT_COLOR, @@ -67,7 +68,7 @@ def resolve_mcap(path_arg: str | None) -> Path: sys.exit(f"[replay] error: {path} does not exist") return path - recordings = Path(__file__).resolve().parent.parent / "recordings" + recordings = Path.cwd() / "recordings" candidates = list(recordings.glob("*.mcap")) if not candidates: sys.exit( @@ -136,8 +137,7 @@ def main(argv: list[str]) -> int: duration_s = mcap_duration_s(mcap_path) 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) viz_left = ControllerViz(server, "controller_left", LEFT_COLOR) viz_right = ControllerViz(server, "controller_right", RIGHT_COLOR) diff --git a/examples/mcap_record_replay/python/replay_full_body.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_full_body.py similarity index 93% rename from examples/mcap_record_replay/python/replay_full_body.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_full_body.py index 336a2f4b03..1ddacb4e28 100644 --- a/examples/mcap_record_replay/python/replay_full_body.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_full_body.py @@ -11,7 +11,7 @@ Usage: python replay_full_body.py [path/to/file.mcap] [--port 8080] [--loop] -If no path is given, the newest file under ``../recordings/`` is used. +If no path is given, the newest file under ``./recordings/`` is used. ``--loop`` keeps replaying the file end-to-end until the process is killed. See: https://nvidia.github.io/IsaacTeleop/main/references/mcap_record_replay.html @@ -34,7 +34,7 @@ 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 mcap_duration_s(path: Path) -> float: @@ -62,7 +62,7 @@ def resolve_mcap(path_arg: str | None) -> Path: sys.exit(f"[replay] error: {path} does not exist") return path - recordings = Path(__file__).resolve().parent.parent / "recordings" + recordings = Path.cwd() / "recordings" candidates = list(recordings.glob("*.mcap")) if not candidates: sys.exit( @@ -137,9 +137,8 @@ def main(argv: list[str]) -> int: duration_s = mcap_duration_s(mcap_path) 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) - viz = FullBodyViz(server) + ground = setup_scene(server) + viz = FullBodyViz(server, ground) print( f"[replay] viser listening on {args.host}:{args.port} " diff --git a/examples/mcap_record_replay/python/replay_hand.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_hand.py similarity index 93% rename from examples/mcap_record_replay/python/replay_hand.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_hand.py index 0402badc68..e7f8c40f22 100644 --- a/examples/mcap_record_replay/python/replay_hand.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_hand.py @@ -11,7 +11,7 @@ Usage: python replay_hand.py [path/to/file.mcap] [--port 8080] [--loop] -If no path is given, the newest file under ``../recordings/`` is used. +If no path is given, the newest file under ``./recordings/`` is used. ``--loop`` keeps replaying the file end-to-end until the process is killed. See: https://nvidia.github.io/IsaacTeleop/main/references/mcap_record_replay.html @@ -33,7 +33,7 @@ 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 mcap_duration_s(path: Path) -> float: @@ -61,7 +61,7 @@ def resolve_mcap(path_arg: str | None) -> Path: sys.exit(f"[replay] error: {path} does not exist") return path - recordings = Path(__file__).resolve().parent.parent / "recordings" + recordings = Path.cwd() / "recordings" candidates = list(recordings.glob("*.mcap")) if not candidates: sys.exit( @@ -132,8 +132,7 @@ def main(argv: list[str]) -> int: duration_s = mcap_duration_s(mcap_path) 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) viz_left = HandViz(server, "hand_left", LEFT_COLOR) viz_right = HandViz(server, "hand_right", RIGHT_COLOR) diff --git a/examples/mcap_record_replay/python/replay_se3_vive.py b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_se3_vive.py similarity index 95% rename from examples/mcap_record_replay/python/replay_se3_vive.py rename to examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_se3_vive.py index cb8638d19d..772e39f9f9 100644 --- a/examples/mcap_record_replay/python/replay_se3_vive.py +++ b/examples/mcap_record_replay/python/isaacteleop_examples/mcap_record_replay/replay_se3_vive.py @@ -9,7 +9,7 @@ tracker as a coordinate frame in a viser 3D view (browser). Replay needs no OpenXR runtime and no hardware. -With no arguments it replays the most recent recording under ../recordings/, +With no arguments it replays the most recent recording under ./recordings/, auto-discovers its collections, and plays back at the recording's own capture rate. Open the printed viser URL to see the trackers in 3D. @@ -39,10 +39,10 @@ def _summary(mcap_path: Path): def resolve_mcap(path_arg: str | None) -> Path: - """Use the given path, or the newest .mcap under ../recordings/.""" + """Use the given path, or the newest .mcap under ./recordings/.""" if path_arg: return Path(path_arg) - recordings = Path(__file__).resolve().parent.parent / "recordings" + recordings = Path.cwd() / "recordings" candidates = list(recordings.glob("se3_vive_*.mcap")) or list( recordings.glob("*.mcap") ) @@ -112,8 +112,11 @@ class Se3Viz: def __init__(self, server, collections: list[str]): import viser # noqa: F401 (import here so --no-viz runs without viser) - server.scene.set_up_direction("+y") - server.scene.add_grid(name="/grid", width=2.0, height=2.0, cell_size=0.1) + # Also deferred: common imports viser at module level, so a top-level + # import here would defeat --no-viz. + from .common import setup_scene + + setup_scene(server) self._frames = {} self._labels = {} for i, cid in enumerate(collections): @@ -178,7 +181,7 @@ def main(argv: list[str]) -> int: parser.add_argument( "mcap", nargs="?", - help="Recording to replay (default: newest under ../recordings/)", + help="Recording to replay (default: newest under ./recordings/)", ) parser.add_argument( "--collections", diff --git a/examples/teleop_ros2/AGENTS.md b/examples/teleop_ros2/AGENTS.md index 1ae9f5a165..8875c0b95e 100644 --- a/examples/teleop_ros2/AGENTS.md +++ b/examples/teleop_ros2/AGENTS.md @@ -12,7 +12,7 @@ core library. Consumed by Isaac ROS Teleop. ## Docker Validation - This reference integration needs ROS 2 plus the built `isaacteleop` wheel, which are not present in the dev container. Run/validate it inside the `examples/teleop_ros2/Dockerfile` image (the same path CI's `test-teleop-ros2` job uses), not directly on the host. -- To exercise it without live XR hardware, replay an MCAP fixture: build the image, run the installed `teleop_ros2_mcap_generator` to write a fixture, then run `teleop_ros2_node.py` with `-p mode:= -p mcap_replay_path:=` and check topics (e.g. via `integration_tests/teleop_ros2_topic_verifier.py`). Replay mode does not launch CloudXR, so no GPU/NGC runtime is required. Share the fixture across containers with `-v /tmp:/tmp` and `--network host` for ROS 2 discovery. +- To exercise it without live XR hardware, replay an MCAP fixture: build the image, run the installed `teleop_ros2_mcap_generator` to write a fixture, then run `python -m isaacteleop_examples.teleop_ros2` with `-p mode:= -p mcap_replay_path:=` and check topics (e.g. via `python -m isaacteleop_examples.teleop_ros2.integration_tests.teleop_ros2_topic_verifier`). Replay mode does not launch CloudXR, so no GPU/NGC runtime is required. Share the fixture across containers with `-v /tmp:/tmp` and `--network host` for ROS 2 discovery. - The image build disables some CI gates (`-DENABLE_CLANG_FORMAT_CHECK=OFF`, `-DBUILD_TESTING=OFF`), so a green Docker build does not mean C++ formatting or ctest pass. Validate those separately. - When creating temporary Docker images for `examples/teleop_ros2` validation, remove them before finishing the task unless the user explicitly asks to keep them. diff --git a/examples/teleop_ros2/CMakeLists.txt b/examples/teleop_ros2/CMakeLists.txt index 7f0bdde568..0a2851e00e 100644 --- a/examples/teleop_ros2/CMakeLists.txt +++ b/examples/teleop_ros2/CMakeLists.txt @@ -8,7 +8,7 @@ include(${CMAKE_SOURCE_DIR}/cmake/InstallPythonExample.cmake) add_subdirectory(cpp/integration_tests) -install_python_example(DESTINATION examples/teleop_ros2/python) +install_python_example(DESTINATION examples/teleop_ros2) install(FILES README.md Dockerfile DESTINATION examples/teleop_ros2 diff --git a/examples/teleop_ros2/Dockerfile b/examples/teleop_ros2/Dockerfile index f85ffdffa6..2308b36b78 100644 --- a/examples/teleop_ros2/Dockerfile +++ b/examples/teleop_ros2/Dockerfile @@ -183,7 +183,7 @@ EOF FROM base AS runtime_base ARG PYTHON_VERSION -WORKDIR /opt/isaacteleop/install/examples/teleop_ros2/python +WORKDIR /opt/isaacteleop/install/examples/teleop_ros2 # The node launches the CloudXR runtime in-process via CloudXRLauncher, so this # image needs GPU access and the NVIDIA Vulkan/EGL ICDs. Run with --gpus all. @@ -254,4 +254,5 @@ COPY tests/python/examples/teleop_ros2/ tests/ FROM runtime_base AS runtime # Use --no-sync because the venv is already provisioned above. -ENTRYPOINT ["/usr/local/bin/teleop-entrypoint", "uv", "run", "--no-sync", "teleop_ros2_node.py"] +ENTRYPOINT ["/usr/local/bin/teleop-entrypoint", "uv", "run", "--no-sync", \ + "python", "-m", "isaacteleop_examples.teleop_ros2"] diff --git a/examples/teleop_ros2/README.md b/examples/teleop_ros2/README.md index 96b4d0570d..14da2acce0 100644 --- a/examples/teleop_ros2/README.md +++ b/examples/teleop_ros2/README.md @@ -59,7 +59,7 @@ Source-tree users can populate the same local asset directory from the repo root python3 examples/teleop_ros2/scripts/fetch_sharpa_wave_urdfs.py ``` -Robot assets are never downloaded by `teleop_ros2_node.py` at runtime. +Robot assets are never downloaded by the node at runtime. ### OpenXR hand input sources diff --git a/examples/teleop_ros2/assets/urdf/sharpa_standalone/README.md b/examples/teleop_ros2/assets/urdf/sharpa_standalone/README.md index a00745b700..cbd5eb9459 100644 --- a/examples/teleop_ros2/assets/urdf/sharpa_standalone/README.md +++ b/examples/teleop_ros2/assets/urdf/sharpa_standalone/README.md @@ -18,4 +18,4 @@ Source-tree users can populate this directory from the repo root: python3 examples/teleop_ros2/scripts/fetch_sharpa_wave_urdfs.py ``` -These robot model assets are not fetched at runtime by `teleop_ros2_node.py`. +These robot model assets are not fetched at runtime by the node. diff --git a/examples/teleop_ros2/pyproject.toml b/examples/teleop_ros2/pyproject.toml new file mode 100644 index 0000000000..2ac72d3593 --- /dev/null +++ b/examples/teleop_ros2/pyproject.toml @@ -0,0 +1,38 @@ +# 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] +# The dist name mirrors the import path, so an installed example claims no bare +# top-level name in site-packages. +name = "isaacteleop-examples-teleop-ros2" +version = "0.1.0" +description = "Isaac Teleop ROS 2 reference publisher" +requires-python = ">=3.11,<3.14" +dependencies = [ + "isaacteleop[cloudxr,grounding,retargeters,wuji]", + "msgpack", + "msgpack-numpy", + # The ROS node installs pink_ik and dexpilot together. Require nlopt>=2.8 so + # dex-retargeting resolves to 0.5.x, keeping the env on NumPy 2 / Pinocchio 3. + "nlopt>=2.8.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", +] + +[tool.pytest.ini_options] +testpaths = ["../../tests/python/examples/teleop_ros2"] + +# `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 `teleop_ros2/`. +[tool.hatch.build.targets.wheel] +only-include = ["python/isaacteleop_examples/teleop_ros2"] +sources = ["python"] diff --git a/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/__init__.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/__init__.py new file mode 100644 index 0000000000..52a7a9daf0 --- /dev/null +++ b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/__main__.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/__main__.py new file mode 100644 index 0000000000..a52b943b84 --- /dev/null +++ b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/__main__.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from .teleop_ros2_node import main + +raise SystemExit(main()) diff --git a/examples/teleop_ros2/python/assets.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/assets.py similarity index 100% rename from examples/teleop_ros2/python/assets.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/assets.py diff --git a/examples/teleop_ros2/python/constants.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/constants.py similarity index 100% rename from examples/teleop_ros2/python/constants.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/constants.py diff --git a/examples/teleop_ros2/python/geometry.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/geometry.py similarity index 100% rename from examples/teleop_ros2/python/geometry.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/geometry.py diff --git a/examples/teleop_ros2/python/integration_tests/__init__.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/integration_tests/__init__.py similarity index 100% rename from examples/teleop_ros2/python/integration_tests/__init__.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/integration_tests/__init__.py diff --git a/examples/teleop_ros2/python/integration_tests/teleop_ros2_topic_verifier.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/integration_tests/teleop_ros2_topic_verifier.py similarity index 99% rename from examples/teleop_ros2/python/integration_tests/teleop_ros2_topic_verifier.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/integration_tests/teleop_ros2_topic_verifier.py index 6d93a5d7a7..223ff53989 100755 --- a/examples/teleop_ros2/python/integration_tests/teleop_ros2_topic_verifier.py +++ b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/integration_tests/teleop_ros2_topic_verifier.py @@ -16,7 +16,7 @@ import msgpack import rclpy -from constants import ( +from ..constants import ( HAND_RETARGETERS, LEFT_SHARPA_WAVE_JOINT_NAMES, LEFT_WUJI_HAND_JOINT_NAMES, diff --git a/examples/teleop_ros2/python/messages.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/messages.py similarity index 99% rename from examples/teleop_ros2/python/messages.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/messages.py index e1457181af..a8c65d1829 100644 --- a/examples/teleop_ros2/python/messages.py +++ b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/messages.py @@ -25,14 +25,14 @@ HeadInputIndex, ) -from constants import BODY_JOINT_NAMES, HAND_POSE_JOINT_INDICES, HAND_POSE_NAMES -from geometry import ( +from .constants import BODY_JOINT_NAMES, HAND_POSE_JOINT_INDICES, HAND_POSE_NAMES +from .geometry import ( apply_manus_controller_to_hand_pose, apply_transform_to_pose, make_transform, to_pose, ) -from tensor_group_helpers import ( +from .tensor_group_helpers import ( controller_aim_is_valid, hand_wrist_is_valid, head_is_valid, diff --git a/examples/teleop_ros2/python/node_parameters.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/node_parameters.py similarity index 98% rename from examples/teleop_ros2/python/node_parameters.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/node_parameters.py index 2a78d195a5..7f7ae0c7b2 100644 --- a/examples/teleop_ros2/python/node_parameters.py +++ b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/node_parameters.py @@ -19,7 +19,7 @@ from pathlib import Path import numpy as np -from constants import ( +from .constants import ( HAND_RETARGETERS, HAND_TRACKING_PLUGINS, TELEOP_MODES, @@ -228,7 +228,9 @@ def _load_config_asset_root(node: Node) -> Path: f"config_asset_root directory not found: {config_asset_root}" ) else: - config_asset_root = Path(__file__).resolve().parents[1] + # Four levels up is the example root, where configs/ and assets/ sit -- + # in the source tree and in the install tree alike. + config_asset_root = Path(__file__).resolve().parents[3] node.get_logger().info(f"Config/asset root: {config_asset_root}") return config_asset_root diff --git a/examples/teleop_ros2/python/session_config.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/session_config.py similarity index 98% rename from examples/teleop_ros2/python/session_config.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/session_config.py index f8da4a691d..5fcbd58d4a 100644 --- a/examples/teleop_ros2/python/session_config.py +++ b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/session_config.py @@ -6,12 +6,12 @@ from collections.abc import Sequence -from assets import ( +from .assets import ( resolve_dex_sharpa_config, resolve_dex_sharpa_urdf, resolve_sharpa_mjcf, ) -from constants import ( +from .constants import ( DEX_HANDTRACKING_TO_BASELINK_FRAME_TRANSFORM, LEFT_FINGER_JOINT_NAMES, LEFT_SHARPA_WAVE_JOINT_NAMES, @@ -49,12 +49,12 @@ SessionMode, TeleopSessionConfig, ) -from node_parameters import NodeParameters -from teleop_ros2_retargeters import ( +from .node_parameters import NodeParameters +from .teleop_ros2_retargeters import ( HandTrackingGateRetargeter, JointNameAliasRetargeter, ) -from tensor_group_helpers import joint_names_from_group_type +from .tensor_group_helpers import joint_names_from_group_type def _maybe_alias_hand_joints( diff --git a/examples/teleop_ros2/python/teleop_profiles.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_profiles.py similarity index 99% rename from examples/teleop_ros2/python/teleop_profiles.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_profiles.py index 93ad8bb03f..1ee26cf92c 100644 --- a/examples/teleop_ros2/python/teleop_profiles.py +++ b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_profiles.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from typing import TypedDict, cast -from constants import ( +from .constants import ( SHARPA_HAND_RETARGETERS, HandRetargeter, HandTrackingPlugin, diff --git a/examples/teleop_ros2/python/teleop_ros2_node.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_ros2_node.py similarity index 99% rename from examples/teleop_ros2/python/teleop_ros2_node.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_ros2_node.py index d804c48e01..332a2f1e07 100755 --- a/examples/teleop_ros2/python/teleop_ros2_node.py +++ b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_ros2_node.py @@ -55,7 +55,7 @@ from isaacteleop.cloudxr import CloudXRLauncher from isaacteleop.cloudxr.oob_teleop_env import TELEOP_CLIENT_ROUTE_ENV from isaacteleop.teleop_session_manager import SessionMode, TeleopSession -from messages import ( +from .messages import ( build_controller_msg, build_ee_output_from_controllers, build_ee_output_from_hands, @@ -65,17 +65,17 @@ build_head_output, build_root_command_output, ) -from teleop_profiles import ( +from .teleop_profiles import ( PublishType, SessionResult, resolve_teleop_profile_spec, validate_session_result, ) -from node_parameters import ( +from .node_parameters import ( NodeParameters, create_node_parameters, ) -from session_config import build_session_config +from .session_config import build_session_config class TeleopRos2Node(Node): diff --git a/examples/teleop_ros2/python/teleop_ros2_retargeters/__init__.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_ros2_retargeters/__init__.py similarity index 100% rename from examples/teleop_ros2/python/teleop_ros2_retargeters/__init__.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_ros2_retargeters/__init__.py diff --git a/examples/teleop_ros2/python/teleop_ros2_retargeters/hand_tracking_gate_retargeter.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_ros2_retargeters/hand_tracking_gate_retargeter.py similarity index 100% rename from examples/teleop_ros2/python/teleop_ros2_retargeters/hand_tracking_gate_retargeter.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_ros2_retargeters/hand_tracking_gate_retargeter.py diff --git a/examples/teleop_ros2/python/teleop_ros2_retargeters/joint_name_alias_retargeter.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_ros2_retargeters/joint_name_alias_retargeter.py similarity index 100% rename from examples/teleop_ros2/python/teleop_ros2_retargeters/joint_name_alias_retargeter.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/teleop_ros2_retargeters/joint_name_alias_retargeter.py diff --git a/examples/teleop_ros2/python/tensor_group_helpers.py b/examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/tensor_group_helpers.py similarity index 100% rename from examples/teleop_ros2/python/tensor_group_helpers.py rename to examples/teleop_ros2/python/isaacteleop_examples/teleop_ros2/tensor_group_helpers.py diff --git a/examples/teleop_ros2/python/pyproject.toml b/examples/teleop_ros2/python/pyproject.toml deleted file mode 100644 index d6d31f5cd0..0000000000 --- a/examples/teleop_ros2/python/pyproject.toml +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[project] -name = "teleop-ros2-ref" -version = "0.1.0" -description = "Isaac Teleop ROS 2 reference publisher" -requires-python = ">=3.11,<3.14" -dependencies = [ - "isaacteleop[cloudxr,grounding,retargeters,wuji]", - "msgpack", - "msgpack-numpy", - # The ROS node installs pink_ik and dexpilot together. Require nlopt>=2.8 so - # dex-retargeting resolves to 0.5.x, keeping the env on NumPy 2 / Pinocchio 3. - "nlopt>=2.8.0", -] - -[project.optional-dependencies] -dev = [ - "pytest", -] - -[tool.pytest.ini_options] -pythonpath = ["."] -testpaths = ["../../../tests/python/examples/teleop_ros2"] diff --git a/rigs/full_body.yaml b/rigs/full_body.yaml index a764eac1c0..d31cd6d390 100644 --- a/rigs/full_body.yaml +++ b/rigs/full_body.yaml @@ -16,7 +16,7 @@ # fresh full_body_.mcap into examples/mcap_record_replay/recordings/ # (gitignored; created if missing; recorder args: [duration_seconds, default # 5 s] [output.mcap | output_dir/]); exiting after ~5 s is SUCCESS — replay -# headless with examples/mcap_record_replay/python/replay_full_body.py, which +# headless with `python -m isaacteleop_examples.mcap_record_replay.replay_full_body`, which # picks up the newest recording there by default. # See rigs/se3_tracker.yaml for the fully annotated exemplar of every key. name: full_body diff --git a/src/plugins/noitom_mocap/README.md b/src/plugins/noitom_mocap/README.md index 636ec5fdf2..f6d8cc750a 100644 --- a/src/plugins/noitom_mocap/README.md +++ b/src/plugins/noitom_mocap/README.md @@ -63,7 +63,7 @@ script: uv run python examples/noitom/record_noitom_full_body.py \ 10 examples/noitom/recordings/noitom_full_body.mcap -cd examples/mcap_record_replay/python -uv sync -uv run python replay_full_body.py ../../noitom/recordings/noitom_full_body.mcap +uv pip install -e ./examples/mcap_record_replay +python -m isaacteleop_examples.mcap_record_replay.replay_full_body \ + examples/noitom/recordings/noitom_full_body.mcap ``` diff --git a/src/plugins/vive_se3_tracker/README.md b/src/plugins/vive_se3_tracker/README.md index 4ff1b95f57..23ddfba7d3 100644 --- a/src/plugins/vive_se3_tracker/README.md +++ b/src/plugins/vive_se3_tracker/README.md @@ -102,16 +102,15 @@ with no arguments. Stale files from a crashed run are cleared on the next startu ## Recording / replay (Python examples) -Both run with no arguments once the plugin is up (from the example directory, so -`uv` picks up its `pyproject.toml`): +Both run with no arguments once the plugin is up: ```bash -cd examples/mcap_record_replay/python -# records 10 s of every advertised collection to ../recordings/.mcap -uv run record_se3_vive.py +uv pip install -e ./examples/mcap_record_replay +# records 10 s of every advertised collection to ./recordings/.mcap +python -m isaacteleop_examples.mcap_record_replay.record_se3_vive # replays the newest recording, auto-discovering collections + capture rate, # and serves a viser 3D view at http://localhost:8080 -uv run replay_se3_vive.py +python -m isaacteleop_examples.mcap_record_replay.replay_se3_vive ``` Under the hood this is just the standard MCAP tooling — a `core::Se3Tracker(cid)` diff --git a/tests/python/examples/teleop_ros2/CMakeLists.txt b/tests/python/examples/teleop_ros2/CMakeLists.txt index 6262a3568b..202e5faa39 100644 --- a/tests/python/examples/teleop_ros2/CMakeLists.txt +++ b/tests/python/examples/teleop_ros2/CMakeLists.txt @@ -3,7 +3,7 @@ # # teleop_ros2 unit tests. Each test_*.py registers as its own ctest entry under # the ``teleop_ros2`` label. BUILD_EXAMPLE_TELEOP_ROS2 supplies the required ROS -# environment; the example source directory supplies its importable modules. +# environment; conftest.py puts the example's namespace root on sys.path. file(GLOB TEST_FILES RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}" @@ -20,7 +20,7 @@ foreach(test_file ${TEST_FILES}) ) set_tests_properties("teleop_ros2_${test_name}" PROPERTIES ENVIRONMENT - "PYTHONPATH=${CMAKE_BINARY_DIR}/python_package/$:${CMAKE_SOURCE_DIR}/examples/teleop_ros2/python" + "PYTHONPATH=${CMAKE_BINARY_DIR}/python_package/$" LABELS "teleop_ros2" ) endforeach() diff --git a/tests/python/examples/teleop_ros2/conftest.py b/tests/python/examples/teleop_ros2/conftest.py new file mode 100644 index 0000000000..e94dc66882 --- /dev/null +++ b/tests/python/examples/teleop_ros2/conftest.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Resolve `isaacteleop_examples.teleop_ros2` against the in-tree source, so a +# bare `pytest` works and the ctest ENVIRONMENT needs no example path. +# +# These files are also copied into the teleop_ros2 container +# (Dockerfile: `COPY tests/python/examples/teleop_ros2/ tests/`), where +# tests/python/repo_paths.py does not exist and the example is already installed +# into the venv by `uv sync`. So the source-tree wiring is conditional: without +# it, importing repo_paths raises and every test errors during collection. + +import sys +from pathlib import Path + +_tests_python = Path(__file__).resolve().parents[2] + +if (_tests_python / "repo_paths.py").is_file(): + if str(_tests_python) not in sys.path: + sys.path.insert(0, str(_tests_python)) + + from repo_paths import repo_root # noqa: E402 + + # python/, not python/isaacteleop_examples/: that is a PEP 420 namespace. Do + # not add an __init__.py to make an import work -- it breaks the installed + # wheel's ability to share the namespace. + sys.path.insert(0, str(repo_root() / "examples" / "teleop_ros2" / "python")) diff --git a/tests/python/examples/teleop_ros2/test_geometry.py b/tests/python/examples/teleop_ros2/test_geometry.py index d191fe9d2c..c57120459f 100644 --- a/tests/python/examples/teleop_ros2/test_geometry.py +++ b/tests/python/examples/teleop_ros2/test_geometry.py @@ -8,7 +8,7 @@ import pytest from scipy.spatial.transform import Rotation -from geometry import ( +from isaacteleop_examples.teleop_ros2.geometry import ( apply_manus_controller_to_hand_pose, apply_transform_to_pose, to_pose, diff --git a/tests/python/examples/teleop_ros2/test_messages.py b/tests/python/examples/teleop_ros2/test_messages.py index 37df919f2a..faed1651b3 100644 --- a/tests/python/examples/teleop_ros2/test_messages.py +++ b/tests/python/examples/teleop_ros2/test_messages.py @@ -31,8 +31,8 @@ RobotHandJoints, ) -from constants import BODY_JOINT_NAMES, HAND_POSE_NAMES -from messages import ( +from isaacteleop_examples.teleop_ros2.constants import BODY_JOINT_NAMES, HAND_POSE_NAMES +from isaacteleop_examples.teleop_ros2.messages import ( build_controller_msg, build_ee_output_from_controllers, build_ee_output_from_hands, diff --git a/tests/python/examples/teleop_ros2/test_teleop_profiles.py b/tests/python/examples/teleop_ros2/test_teleop_profiles.py index 26ba576d67..7451c54b77 100644 --- a/tests/python/examples/teleop_ros2/test_teleop_profiles.py +++ b/tests/python/examples/teleop_ros2/test_teleop_profiles.py @@ -7,8 +7,8 @@ from types import SimpleNamespace import pytest -import session_config -from constants import ( +from isaacteleop_examples.teleop_ros2 import session_config +from isaacteleop_examples.teleop_ros2.constants import ( LEFT_WUJI_HAND_JOINT_NAMES, RIGHT_WUJI_HAND_JOINT_NAMES, TELEOP_MODES, @@ -18,7 +18,7 @@ TeleopMode, resolve_hand_retargeter, ) -from teleop_profiles import ( +from isaacteleop_examples.teleop_ros2.teleop_profiles import ( TELEOP_PROFILE_SPECS, PublishType, TeleopProfile,