Skip to content
Closed
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
1 change: 1 addition & 0 deletions examples/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ if(BUILD_EXAMPLES)
add_subdirectory(soma_body_publisher)
add_subdirectory(soma_hand_publisher)
add_subdirectory(haptic_feedback)
add_subdirectory(tactile)
add_subdirectory(sharpa_avatar)
add_subdirectory(vehicle_teleop)
elseif(BUILD_EXAMPLE_TELEOP_ROS2)
Expand Down
7 changes: 7 additions & 0 deletions examples/tactile/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20)

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

# Run the tactile example

1. From the IsaacCapture repository root, install the tactile example.

```bash
uv pip install -e ./examples/tactile
```

2. In the first terminal, start the printer and the local CloudXR runtime.

```bash
python -m isaaccapture_examples.tactile.printer --accept-eula
```

3. In the second terminal, start the feeder.

```bash
python -m isaaccapture_examples.tactile.feeder
```

4. Confirm that the first terminal prints `TactileMetadata`, followed by the first and last taxel values once per second.

5. Press `Ctrl+C` in both terminals.
20 changes: 20 additions & 0 deletions examples/tactile/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 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 = "isaaccapture-examples-tactile"
version = "0.0.0"
description = "IsaacCapture tactile feeder and printer"
requires-python = ">=3.11,<3.14"
dependencies = ["isaaccapture[cloudxr]"]

[tool.hatch.build.targets.wheel]
only-include = ["python/isaaccapture_examples/tactile"]
sources = ["python"]

[tool.uv.sources]
isaaccapture = { path = "../..", editable = true }
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Minimal tactile transport example."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

DEFAULT_COLLECTION_ID = "tactile/example"
TACTILE_MAX_SAMPLE_SIZE = 16_384
149 changes: 149 additions & 0 deletions examples/tactile/python/isaaccapture_examples/tactile/feeder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Publish synthetic three-axis tactile data and immutable metadata."""

from __future__ import annotations

import argparse
import math
import time

from isaaccapture.deviceio_session import DeviceIOSession
from isaaccapture.deviceio_trackers import TensorPushTracker
from isaaccapture.oxr import OpenXRSession
from isaaccapture.schema import (
TRACKER_METADATA_TENSOR_IDENTIFIER,
IsaacCapturePoseTactileBinding,
OpenXRHandTactileBinding,
Point,
Pose,
ProfileHandling,
Quaternion,
TactileBindingProfile,
TactileData,
TactileHandedness,
TactileMeasurement,
TactileMetadata,
TactileNode,
TactileQuantity,
TactileSkinWeight,
TactileTaxel,
TactileUnit,
TactileValueFrame,
make_tactile_binding_profile,
tracker_metadata_collection_id,
)

from .common import DEFAULT_COLLECTION_ID, TACTILE_MAX_SAMPLE_SIZE

TAXEL_COUNT = 8
PUBLISH_RATE_HZ = 30.0
METADATA_INTERVAL_S = 1.0


def _pose(x: float, y: float) -> Pose:
return Pose(Point(x, y, 0.0), Quaternion(0.0, 0.0, 0.0, 1.0))


def _metadata() -> TactileMetadata:
nodes = [
TactileNode("thumb_distal", _pose(-0.02, 0.04)),
TactileNode("index_distal", _pose(0.02, 0.06)),
]
taxels = [
TactileTaxel(
f"taxel-{index}",
_pose(-0.035 + index * 0.01, 0.08),
[TactileSkinWeight(index % len(nodes), 1.0)],
)
for index in range(TAXEL_COUNT)
]
openxr_binding = TactileBindingProfile(
openxr_hand=OpenXRHandTactileBinding(
TactileHandedness.LEFT,
[4, 9],
)
)
pose_binding = TactileBindingProfile(
isaaccapture_pose=IsaacCapturePoseTactileBinding(
"core.HandPose",
tracker_name="hand",
channel="left",
subject_id="example-operator",
device_instance_id="example-glove-left",
skeleton_instance_id="example-left-hand",
node_pose_indices=[4, 9],
)
)
profiles = [
make_tactile_binding_profile(openxr_binding, ProfileHandling.REQUIRED),
make_tactile_binding_profile(pose_binding, ProfileHandling.OPTIONAL),
]
measurement = TactileMeasurement(
TactileQuantity.FORCE,
TactileUnit.NEWTON,
3,
TactileValueFrame.TAXEL,
)
return TactileMetadata("example-glove-left", measurement, taxels, nodes, profiles)


def _frame(elapsed_s: float) -> TactileData:
values: list[float] = []
for taxel in range(TAXEL_COUNT):
normal_force = 1.0 + 0.75 * math.sin(
2.0 * math.pi * 0.5 * elapsed_s + taxel * 0.3
)
values.extend((0.0, 0.0, normal_force))
return TactileData(values)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--collection-id", default=DEFAULT_COLLECTION_ID)
args = parser.parse_args(argv)

metadata = _metadata().to_bytes()
data_pusher = TensorPushTracker(
args.collection_id, "tactile", TACTILE_MAX_SAMPLE_SIZE
)
metadata_pusher = TensorPushTracker(
tracker_metadata_collection_id(args.collection_id),
TRACKER_METADATA_TENSOR_IDENTIFIER,
len(metadata),
)
trackers = [data_pusher, metadata_pusher]

print(
f"Publishing {TAXEL_COUNT} taxels on {args.collection_id}; press Ctrl+C to stop."
)
with (
OpenXRSession(
"TactileExampleFeeder",
DeviceIOSession.get_required_extensions(trackers),
) as oxr_session,
DeviceIOSession.run(trackers, oxr_session.get_handles()) as session,
):
started = time.monotonic()
frame_index = 0
last_metadata_push = -math.inf
try:
while True:
deadline = started + frame_index / PUBLISH_RATE_HZ
if (remaining := deadline - time.monotonic()) > 0:
time.sleep(remaining)
session.update()
elapsed_s = time.monotonic() - started
data_pusher.push(session, _frame(elapsed_s).to_bytes())
if elapsed_s - last_metadata_push >= METADATA_INTERVAL_S:
metadata_pusher.push(session, metadata)
last_metadata_push = elapsed_s
frame_index += 1
except KeyboardInterrupt:
pass
return 0


if __name__ == "__main__":
raise SystemExit(main())
85 changes: 85 additions & 0 deletions examples/tactile/python/isaaccapture_examples/tactile/printer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Receive tactile data through TactileTracker and print one update per second."""

from __future__ import annotations

import argparse
import time

from isaaccapture.cloudxr import CloudXRLauncher
from isaaccapture.deviceio_session import DeviceIOSession
from isaaccapture.deviceio_trackers import TactileTracker
from isaaccapture.oxr import OpenXRSession
from isaaccapture.schema import TactileMetadata

from .common import DEFAULT_COLLECTION_ID

PRINT_INTERVAL_S = 1.0


def _print_metadata(metadata: TactileMetadata) -> None:
measurement = metadata.measurement
print("TactileMetadata")
print(f" schema_version: {metadata.schema_version}")
print(f" shape_frame_id: {metadata.shape_frame_id}")
print(
" measurement: "
f"{measurement.quantity.name}, {measurement.unit.name}, "
f"components={measurement.component_count}, frame={measurement.frame.name}"
)
print(f" nodes: {[node.node_id for node in metadata.nodes]}")
print(f" taxels: {[taxel.taxel_id for taxel in metadata.taxels]}")
print(
" profiles: "
f"{[f'{profile.profile_id}@{profile.version_major}.{profile.version_minor}' for profile in metadata.profiles]}"
)


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--collection-id", default=DEFAULT_COLLECTION_ID)
CloudXRLauncher.add_launcher_arguments(parser)
args = parser.parse_args(argv)

tracker = TactileTracker(args.collection_id)
trackers = [tracker]
print(f"Waiting for tactile data on {args.collection_id}; press Ctrl+C to stop.")
with (
CloudXRLauncher.launch_context(args),
OpenXRSession(
"TactileExamplePrinter",
DeviceIOSession.get_required_extensions(trackers),
) as oxr_session,
DeviceIOSession.run(trackers, oxr_session.get_handles()) as session,
):
metadata = None
next_print = time.monotonic()
try:
while True:
session.update()
if (
metadata is None
and (metadata := tracker.get_metadata(session)) is not None
):
_print_metadata(metadata)

data = tracker.get_data(session)
now = time.monotonic()
if metadata is not None and data is not None and now >= next_print:
component_count = metadata.measurement.component_count
values = list(data.values)
print(
f"first={values[:component_count]} "
f"last={values[-component_count:]}"
)
next_print = now + PRINT_INTERVAL_S
time.sleep(0.005)
except KeyboardInterrupt:
pass
return 0


if __name__ == "__main__":
raise SystemExit(main())
8 changes: 8 additions & 0 deletions src/core/deviceio_trackers/trackers.toml
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,11 @@ python_accessor = "get_spacemouse_data"
name = "gamepad"
table = "GamepadSnapshot"
python_accessor = "get_gamepad_data"

# About 1300 three-axis taxels fit in the default data collection. Geometry, binding,
# and calibration are immutable tracker metadata and must fit TRACKER_METADATA_MAX_SIZE.
[[tracker]]
name = "tactile"
table = "TactileData"
metadata = "TactileMetadata"
max_flatbuffer_size = 16384
19 changes: 19 additions & 0 deletions src/core/schema/cpp/inc/schema/tactile_profiles.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

#pragma once

#include <string_view>

namespace core
{

// Provisional IsaacCapture registry identifiers. The payloads use external standards where
// named, but the profiles themselves are not Khronos or ROS standards.
inline constexpr std::string_view TACTILE_OPENXR_HAND_PROFILE_ID = "com.nvidia.isaaccapture.tactile.openxr-hand-binding";
inline constexpr std::string_view TACTILE_URDF_PROFILE_ID = "com.nvidia.isaaccapture.tactile.urdf-binding";
inline constexpr std::string_view TACTILE_ISAACCAPTURE_POSE_PROFILE_ID =
"com.nvidia.isaaccapture.tactile.pose-stream-binding";
inline constexpr std::string_view TACTILE_BINDING_PROFILE_SCHEMA = "core.TactileBindingProfile";

} // namespace core
Loading
Loading