Skip to content

Adds contact point location reporting to ContactSensor #2842

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Aug 14, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion source/isaaclab/config/extension.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]

# Note: Semantic Versioning is used: https://semver.org/
version = "0.40.11"
version = "0.41.0"

# Description
title = "Isaac Lab framework for Robot Learning"
Expand Down
14 changes: 14 additions & 0 deletions source/isaaclab/docs/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
Changelog
---------

0.41.0 (2025-07-2)
~~~~~~~~~~~~~~~~~~

Added
^^^^^

* Added :attr:`~isaaclab.sensors.contact_sensor.ContactSensorCfg.track_contact_points` to toggle tracking of contact
point locations between sensor bodies and filtered bodies.
* Added :attr:`~isaaclab.sensors.contact_sensor.ContactSensorCfg.max_contact_data_per_prim` to configure the maximum
amount of contacts per sensor body.
* Added :attr:`~isaaclab.sensors.contact_sensor.ContactSensorData.contact_pos_w` data field for tracking contact point
locations.


0.40.11 (2025-06-27)
~~~~~~~~~~~~~~~~~~~~

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ def reset(self, env_ids: Sequence[int] | None = None):
self._data.last_air_time[env_ids] = 0.0
self._data.current_contact_time[env_ids] = 0.0
self._data.last_contact_time[env_ids] = 0.0
# reset contact positions
if self.cfg.track_contact_points:
self._data.contact_pos_w[env_ids, :] = torch.nan
# buffer used during contact position aggregation
self._contact_position_aggregate_buffer[env_ids, :] = torch.nan

def find_bodies(self, name_keys: str | Sequence[str], preserve_order: bool = False) -> tuple[list[int], list[str]]:
"""Find bodies in the articulation based on the name keys.
Expand Down Expand Up @@ -278,7 +283,9 @@ def _initialize_impl(self):
# create a rigid prim view for the sensor
self._body_physx_view = self._physics_sim_view.create_rigid_body_view(body_names_glob)
self._contact_physx_view = self._physics_sim_view.create_rigid_contact_view(
body_names_glob, filter_patterns=filter_prim_paths_glob
body_names_glob,
filter_patterns=filter_prim_paths_glob,
max_contact_data_count=self.cfg.max_contact_data_count_per_prim * len(body_names),
)
# resolve the true count of bodies
self._num_bodies = self.body_physx_view.count // self._num_envs
Expand All @@ -304,6 +311,19 @@ def _initialize_impl(self):
if self.cfg.track_pose:
self._data.pos_w = torch.zeros(self._num_envs, self._num_bodies, 3, device=self._device)
self._data.quat_w = torch.zeros(self._num_envs, self._num_bodies, 4, device=self._device)
# -- position of contact points
if self.cfg.track_contact_points:
self._data.contact_pos_w = torch.full(
(self._num_envs, self._num_bodies, self.contact_physx_view.filter_count, 3),
torch.nan,
device=self._device,
)
# buffer used during contact position aggregation
self._contact_position_aggregate_buffer = torch.full(
(self._num_bodies * self._num_envs, self.contact_physx_view.filter_count, 3),
torch.nan,
device=self._device,
)
# -- air/contact time between contacts
if self.cfg.track_air_time:
self._data.last_air_time = torch.zeros(self._num_envs, self._num_bodies, device=self._device)
Expand Down Expand Up @@ -348,6 +368,25 @@ def _update_buffers_impl(self, env_ids: Sequence[int]):
pose[..., 3:] = convert_quat(pose[..., 3:], to="wxyz")
self._data.pos_w[env_ids], self._data.quat_w[env_ids] = pose.split([3, 4], dim=-1)

# obtain contact points
if self.cfg.track_contact_points:
_, buffer_contact_points, _, _, buffer_count, buffer_start_indices = (
self.contact_physx_view.get_contact_data(dt=self._sim_physics_dt)
)
# unpack the contact points: see RigidContactView.get_contact_data() documentation for details:
# https://docs.omniverse.nvidia.com/kit/docs/omni_physics/107.3/extensions/runtime/source/omni.physics.tensors/docs/api/python.html#omni.physics.tensors.impl.api.RigidContactView.get_net_contact_forces
for i in range(self._num_bodies * self._num_envs):
for j in range(self.contact_physx_view.filter_count):
start_index_ij = buffer_start_indices[i, j]
count_ij = buffer_count[i, j]
self._contact_position_aggregate_buffer[i, j, :] = torch.mean(
buffer_contact_points[start_index_ij : (start_index_ij + count_ij), :], dim=0
)
# reshape from [num_env*num_bodies, num_filter_shapes, 3] to [num_env, num_bodies, num_filter_shapes, 3]
self._data.contact_pos_w[env_ids] = self._contact_position_aggregate_buffer.view(
-1, self._num_bodies, self.contact_physx_view.filter_count, 3
)[env_ids]

# obtain the air time
if self.cfg.track_air_time:
# -- time elapsed since last update
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ class ContactSensorCfg(SensorBaseCfg):
track_pose: bool = False
"""Whether to track the pose of the sensor's origin. Defaults to False."""

track_contact_points: bool = False
"""Whether to track the contact point locations. Defaults to False."""

max_contact_data_count_per_prim: int = 4
"""The maximum number of contacts across all batches of the sensor to keep track of. Default is 4."""

track_air_time: bool = False
"""Whether to track the air/contact time of the bodies (time between contacts). Defaults to False."""

Expand Down Expand Up @@ -49,6 +55,7 @@ class ContactSensorCfg(SensorBaseCfg):
single primitive in that environment. If the sensor primitive corresponds to multiple primitives, the
filtering will not work as expected. Please check :class:`~isaaclab.sensors.contact_sensor.ContactSensor`
for more details.
If track_contact_points is true, then filter_prim_paths_expr cannot be an empty list!
"""

visualizer_cfg: VisualizationMarkersCfg = CONTACT_SENSOR_MARKER_CFG.replace(prim_path="/Visuals/ContactSensor")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ class ContactSensorData:
If the :attr:`ContactSensorCfg.track_pose` is False, then this quantity is None.
"""

contact_pos_w: torch.Tensor | None = None
"""Average of the positions of contact points between sensor body and filter prim in world frame.

Shape is (N, B, M, 3), where N is the number of sensors, B is number of bodies in each sensor
and M is the number of filtered bodies.

Collision pairs not in contact will result in nan.

Note:
If the :attr:`ContactSensorCfg.track_contact_points` is False, then this quantity is None.
If the :attr:`ContactSensorCfg.filter_prim_paths_expr` is empty, then this quantity is an empty tensor.
If the :attr:`ContactSensorCfg.max_contact_data_per_prim` is not specified or less than 1, then this quantity
will not be calculated.
"""

quat_w: torch.Tensor | None = None
"""Orientation of the sensor origin in quaternion (w, x, y, z) in world frame.

Expand Down
11 changes: 7 additions & 4 deletions source/isaaclab/test/sensors/check_contact_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,8 @@ def design_scene():
cfg = sim_utils.GroundPlaneCfg()
cfg.func("/World/defaultGroundPlane", cfg)
# Lights
cfg = sim_utils.SphereLightCfg()
cfg.func("/World/Light/GreySphere", cfg, translation=(4.5, 3.5, 10.0))
cfg.func("/World/Light/WhiteSphere", cfg, translation=(-4.5, 3.5, 10.0))
cfg = sim_utils.DomeLightCfg(intensity=2000)
cfg.func("/World/Light/DomeLight", cfg, translation=(-4.5, 3.5, 10.0))


"""
Expand Down Expand Up @@ -103,7 +102,11 @@ def main():
robot = Articulation(cfg=robot_cfg)
# Contact sensor
contact_sensor_cfg = ContactSensorCfg(
prim_path="/World/envs/env_.*/Robot/.*_SHANK", track_air_time=True, debug_vis=not args_cli.headless
prim_path="/World/envs/env_.*/Robot/.*_FOOT",
track_air_time=True,
track_contact_points=True,
debug_vis=not args_cli.headless,
filter_prim_paths_expr=["/World/defaultGroundPlane/GroundPlane/CollisionPlane"],
)
contact_sensor = ContactSensor(cfg=contact_sensor_cfg)
# filter collisions within each environment instance
Expand Down
113 changes: 86 additions & 27 deletions source/isaaclab/test/sensors/test_contact_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,33 +412,63 @@ def _run_contact_sensor_test(
"""
for device in devices:
for terrain in terrains:
with build_simulation_context(device=device, dt=sim_dt, add_lighting=True) as sim:
sim._app_control_on_stop_handle = None
scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False)
scene_cfg.terrain = terrain
scene_cfg.shape = shape_cfg
scene_cfg.contact_sensor = ContactSensorCfg(
prim_path=shape_cfg.prim_path,
track_pose=True,
debug_vis=False,
update_period=0.0,
track_air_time=True,
history_length=3,
)
scene = InteractiveScene(scene_cfg)

# Check that contact processing is enabled
assert not carb_settings_iface.get("/physics/disableContactProcessing")

# Play the simulator
sim.reset()

_test_sensor_contact(
scene["shape"], scene["contact_sensor"], ContactTestMode.IN_CONTACT, sim, scene, sim_dt, durations
)
_test_sensor_contact(
scene["shape"], scene["contact_sensor"], ContactTestMode.NON_CONTACT, sim, scene, sim_dt, durations
)
for track_contact_points in [True, False]:
with build_simulation_context(device=device, dt=sim_dt, add_lighting=True) as sim:
sim._app_control_on_stop_handle = None

scene_cfg = ContactSensorSceneCfg(num_envs=1, env_spacing=1.0, lazy_sensor_update=False)
scene_cfg.terrain = terrain
scene_cfg.shape = shape_cfg
test_contact_position = False
if (type(shape_cfg.spawn) is sim_utils.SphereCfg) and (terrain.terrain_type == "plane"):
test_contact_position = True
elif track_contact_points:
continue

if track_contact_points:
if terrain.terrain_type == "plane":
filter_prim_paths_expr = [terrain.prim_path + "/terrain/GroundPlane/CollisionPlane"]
elif terrain.terrain_type == "generator":
filter_prim_paths_expr = [terrain.prim_path + "/terrain/mesh"]
else:
filter_prim_paths_expr = []

scene_cfg.contact_sensor = ContactSensorCfg(
prim_path=shape_cfg.prim_path,
track_pose=True,
debug_vis=False,
update_period=0.0,
track_air_time=True,
history_length=3,
track_contact_points=track_contact_points,
filter_prim_paths_expr=filter_prim_paths_expr,
)
scene = InteractiveScene(scene_cfg)

# Play the simulation
sim.reset()

# Run contact time and air time tests.
_test_sensor_contact(
shape=scene["shape"],
sensor=scene["contact_sensor"],
mode=ContactTestMode.IN_CONTACT,
sim=sim,
scene=scene,
sim_dt=sim_dt,
durations=durations,
test_contact_position=test_contact_position,
)
_test_sensor_contact(
shape=scene["shape"],
sensor=scene["contact_sensor"],
mode=ContactTestMode.NON_CONTACT,
sim=sim,
scene=scene,
sim_dt=sim_dt,
durations=durations,
test_contact_position=test_contact_position,
)


def _test_sensor_contact(
Expand All @@ -449,6 +479,7 @@ def _test_sensor_contact(
scene: InteractiveScene,
sim_dt: float,
durations: list[float],
test_contact_position: bool = False,
):
"""Test for the contact sensor.

Expand Down Expand Up @@ -515,6 +546,8 @@ def _test_sensor_contact(
expected_last_air_time=expected_last_test_contact_time,
dt=duration + sim_dt,
)
if test_contact_position:
_test_contact_position(shape, sensor, mode)
# switch the contact mode for 1 dt step before the next contact test begins.
shape.write_root_pose_to_sim(root_pose=reset_pose)
# perform simulation step
Expand All @@ -525,6 +558,32 @@ def _test_sensor_contact(
expected_last_reset_contact_time = 2 * sim_dt


def _test_contact_position(shape: RigidObject, sensor: ContactSensor, mode: ContactTestMode) -> None:
"""Test for the contact positions (only implemented for sphere and flat terrain)
checks that the contact position is radius distance away from the root of the object
Args:
shape: The contact prim used for the contact sensor test.
sensor: The sensor reporting data to be verified by the contact sensor test.
mode: The contact test mode: either contact with ground plane or air time.
"""
if sensor.cfg.track_contact_points:
# check shape of the contact_pos_w tensor
num_bodies = sensor.num_bodies
assert sensor._data.contact_pos_w.shape == (sensor.num_instances / num_bodies, num_bodies, 1, 3)
# check contact positions
if mode == ContactTestMode.IN_CONTACT:
contact_position = sensor._data.pos_w + torch.tensor(
[[0.0, 0.0, -shape.cfg.spawn.radius]], device=sensor._data.pos_w.device
)
assert torch.all(
torch.abs(torch.norm(sensor._data.contact_pos_w - contact_position.unsqueeze(1), p=2, dim=-1)) < 1e-2
).item()
elif mode == ContactTestMode.NON_CONTACT:
assert torch.all(torch.isnan(sensor._data.contact_pos_w)).item()
else:
assert sensor._data.contact_pos_w is None


def _check_prim_contact_state_times(
sensor: ContactSensor,
expected_air_time: float,
Expand Down