Skip to content

Commit 6f1b2ea

Browse files
committed
Extend filter_link_idx to the solver-contact probe sensors
ContactProbe, ContactDepthProbe, and KinematicTaxel now accept filter_link_idx, mirroring the Contact/ContactForce semantics: a contact with the sensor link is dropped when its counterpart link is in the filter list. The three share the counterpart check via a new _func_link_is_filtered device helper threaded into both contact-depth query funcs and their kernels, and a SolverContactProbeSensorOptionsMixin so the point-cloud tactile sensors (Elastomer/Proximity), which sample a point cloud rather than solver contacts, do not expose the option. Factor the shared build-time padding into misc.append_filter_links_idx (now used by Contact, ContactForce, and the tactile sensors) and the range validation into options._validate_filter_link_idx. The filter table keeps at least one column so the no-filter case is still a valid kernel argument. Adds test_contact_probe_sensor_filter_link_idx.
1 parent 0ff4c5b commit 6f1b2ea

6 files changed

Lines changed: 204 additions & 40 deletions

File tree

genesis/engine/sensors/contact_force.py

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
from genesis.options.sensors import Contact as ContactSensorOptions
1010
from genesis.options.sensors import ContactForce as ContactForceSensorOptions
1111
from genesis.utils.geom import inv_transform_by_quat, qd_inv_transform_by_quat, transform_by_quat
12-
from genesis.utils.misc import concat_with_tensor, make_tensor_field, qd_to_torch, tensor_to_array
12+
from genesis.utils.misc import (
13+
append_filter_links_idx,
14+
concat_with_tensor,
15+
make_tensor_field,
16+
qd_to_torch,
17+
tensor_to_array,
18+
)
1319
from genesis.utils.ring_buffer import TensorRingBuffer
1420

1521
from .base_sensor import RigidSensorMetadataMixin, RigidSensorMixin, Sensor, SimpleSensor, SimpleSensorMetadata
@@ -121,15 +127,10 @@ def build(self):
121127
self._shared_metadata.expanded_links_idx, link_idx, expand=(1,), dim=0
122128
)
123129

124-
num_sensors, cur_num_filter_links = self._shared_metadata.filter_links_idx.shape
125-
max_num_filter_links = max(cur_num_filter_links, len(self._options.filter_link_idx))
126-
filter_links_idx = torch.full((num_sensors + 1, max_num_filter_links), -1, dtype=gs.tc_int, device=gs.device)
127-
filter_links_idx[:num_sensors, :cur_num_filter_links] = self._shared_metadata.filter_links_idx
128-
filter_links_idx[num_sensors, : len(self._options.filter_link_idx)] = torch.tensor(
129-
self._options.filter_link_idx, dtype=gs.tc_int, device=gs.device
130+
num_sensors = self._shared_metadata.filter_links_idx.shape[0]
131+
self._shared_metadata.filter_links_idx = append_filter_links_idx(
132+
self._shared_metadata.filter_links_idx, self._options.filter_link_idx
130133
)
131-
self._shared_metadata.filter_links_idx = filter_links_idx
132-
133134
if len(self._options.filter_link_idx) > 0:
134135
self._shared_metadata.filtered_sensor_idx = concat_with_tensor(
135136
self._shared_metadata.filtered_sensor_idx, num_sensors, expand=(1,), dim=0
@@ -256,15 +257,10 @@ def build(self):
256257
self._shared_metadata.max_force, self._options.max_force, expand=(1, 3)
257258
)
258259

259-
num_sensors, cur_num_filter_links = self._shared_metadata.filter_links_idx.shape
260-
max_num_filter_links = max(cur_num_filter_links, len(self._options.filter_link_idx))
261-
filter_links_idx = torch.full((num_sensors + 1, max_num_filter_links), -1, dtype=gs.tc_int, device=gs.device)
262-
filter_links_idx[:num_sensors, :cur_num_filter_links] = self._shared_metadata.filter_links_idx
263-
filter_links_idx[num_sensors, : len(self._options.filter_link_idx)] = torch.tensor(
264-
self._options.filter_link_idx, dtype=gs.tc_int, device=gs.device
260+
num_sensors = self._shared_metadata.filter_links_idx.shape[0]
261+
self._shared_metadata.filter_links_idx = append_filter_links_idx(
262+
self._shared_metadata.filter_links_idx, self._options.filter_link_idx
265263
)
266-
self._shared_metadata.filter_links_idx = filter_links_idx
267-
268264
if len(self._options.filter_link_idx) > 0:
269265
self._shared_metadata.filtered_sensor_idx = concat_with_tensor(
270266
self._shared_metadata.filtered_sensor_idx, num_sensors, expand=(1,), dim=0

genesis/engine/sensors/kinematic_tactile.py

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from genesis.options.sensors import ContactDepthProbe as ContactDepthProbeOptions
1414
from genesis.options.sensors import ContactProbe as ContactProbeOptions
1515
from genesis.options.sensors import KinematicTaxel as KinematicTaxelOptions
16-
from genesis.utils.misc import concat_with_tensor, make_tensor_field, tensor_to_array
16+
from genesis.utils.misc import append_filter_links_idx, concat_with_tensor, make_tensor_field, tensor_to_array
1717

1818
from .base_sensor import RigidSensorMetadataMixin, RigidSensorMixin, SimpleSensor, SimpleSensorMetadata
1919
from .probe import (
@@ -33,13 +33,25 @@
3333
from .sensor_manager import SensorManager
3434

3535

36+
@qd.func
37+
def _func_link_is_filtered(filter_links_idx: qd.types.ndarray(), i_s: int, link: int):
38+
"""Whether ``link`` is in sensor ``i_s``'s filter list. ``-1`` padding entries never match a real link index."""
39+
is_filtered = False
40+
for i_f in range(filter_links_idx.shape[-1]):
41+
if filter_links_idx[i_s, i_f] == link:
42+
is_filtered = True
43+
return is_filtered
44+
45+
3646
@qd.func
3747
def _func_query_contact_depth_penetration(
3848
i_b: int,
3949
probe_pos: qd.types.vector(3),
4050
probe_radius_gt: float,
4151
probe_radius_m: float,
4252
sensor_link_idx: int,
53+
filter_links_idx: qd.types.ndarray(),
54+
i_s: int,
4355
geoms_info: array_class.GeomsInfo,
4456
geoms_state: array_class.GeomsState,
4557
collider_state: array_class.ColliderState,
@@ -50,7 +62,8 @@ def _func_query_contact_depth_penetration(
5062
5163
Returns ``(max_pen_gt, max_pen_m)`` from a single SDF pass: both penetrations come from the same ``sd`` per contact
5264
via ``pen = radius - sd``. Callers that do not need the noised-radius branch pass ``probe_radius_m ==
53-
probe_radius_gt`` and ignore the second return.
65+
probe_radius_gt`` and ignore the second return. Contacts whose counterpart link is in sensor ``i_s``'s
66+
``filter_links_idx`` list are skipped.
5467
"""
5568
max_pen_gt = gs.qd_float(0.0)
5669
max_pen_m = gs.qd_float(0.0)
@@ -65,9 +78,10 @@ def _func_query_contact_depth_penetration(
6578

6679
for side in qd.static(range(2)):
6780
c_link = c_link_a if side == 0 else c_link_b
81+
c_link_other = c_link_b if side == 0 else c_link_a
6882
i_g = c_geom_b if side == 0 else c_geom_a
6983

70-
if c_link == sensor_link_idx:
84+
if c_link == sensor_link_idx and not _func_link_is_filtered(filter_links_idx, i_s, c_link_other):
7185
g_pos = geoms_state.pos[i_g, i_b]
7286
g_quat = geoms_state.quat[i_g, i_b]
7387
sd = sdf.sdf_func_world_local(geoms_info, sdf_info, probe_pos, i_g, g_pos, g_quat)
@@ -88,6 +102,8 @@ def _func_query_contact_depth(
88102
probe_radius_gt: float,
89103
probe_radius_m: float,
90104
sensor_link_idx: int,
105+
filter_links_idx: qd.types.ndarray(),
106+
i_s: int,
91107
geoms_info: array_class.GeomsInfo,
92108
geoms_state: array_class.GeomsState,
93109
rigid_global_info: array_class.RigidGlobalInfo,
@@ -101,7 +117,8 @@ def _func_query_contact_depth(
101117
102118
Returns ``(max_pen_gt, contact_link_gt, contact_normal_gt, max_pen_m, contact_link_m, contact_normal_m)``. AABB
103119
pre-filter expands by ``max(probe_radius_gt, probe_radius_m)`` so neither branch is silently skipped. Callers
104-
without a noised radius pass ``probe_radius_m == probe_radius_gt``.
120+
without a noised radius pass ``probe_radius_m == probe_radius_gt``. Contacts whose counterpart link is in sensor
121+
``i_s``'s ``filter_links_idx`` list are skipped.
105122
"""
106123
max_pen_gt = gs.qd_float(0.0)
107124
contact_link_gt = gs.qd_int(-1)
@@ -123,9 +140,14 @@ def _func_query_contact_depth(
123140
# Check if either side of this contact involves the sensor link.
124141
for side in qd.static(range(2)):
125142
c_link = c_link_a if side == 0 else c_link_b
143+
c_link_other = c_link_b if side == 0 else c_link_a
126144
i_g = c_geom_b if side == 0 else c_geom_a
127145

128-
if c_link == sensor_link_idx and func_point_in_geom_aabb(geoms_state, i_g, i_b, probe_pos, aabb_expansion):
146+
if (
147+
c_link == sensor_link_idx
148+
and not _func_link_is_filtered(filter_links_idx, i_s, c_link_other)
149+
and func_point_in_geom_aabb(geoms_state, i_g, i_b, probe_pos, aabb_expansion)
150+
):
129151
g_pos = geoms_state.pos[i_g, i_b]
130152
g_quat = geoms_state.quat[i_g, i_b]
131153
sd = sdf.sdf_func_world_local(geoms_info, sdf_info, probe_pos, i_g, g_pos, g_quat)
@@ -139,11 +161,11 @@ def _func_query_contact_depth(
139161
)
140162
if pen_gt > max_pen_gt and pen_gt > eps:
141163
max_pen_gt = pen_gt
142-
contact_link_gt = c_link_b if side == 0 else c_link_a
164+
contact_link_gt = c_link_other
143165
contact_normal_gt = normal
144166
if pen_m > max_pen_m and pen_m > eps:
145167
max_pen_m = pen_m
146-
contact_link_m = c_link_b if side == 0 else c_link_a
168+
contact_link_m = c_link_other
147169
contact_normal_m = normal
148170

149171
return max_pen_gt, contact_link_gt, contact_normal_gt, max_pen_m, contact_link_m, contact_normal_m
@@ -161,6 +183,7 @@ def _kernel_kinematic_taxel(
161183
shear_scalar: qd.types.ndarray(),
162184
twist_scalar: qd.types.ndarray(),
163185
links_idx: qd.types.ndarray(),
186+
filter_links_idx: qd.types.ndarray(),
164187
sensor_cache_start: qd.types.ndarray(),
165188
sensor_probe_start: qd.types.ndarray(),
166189
n_probes_per_sensor: qd.types.ndarray(),
@@ -211,6 +234,8 @@ def _kernel_kinematic_taxel(
211234
probe_radius,
212235
probe_radius_m,
213236
sensor_link_idx,
237+
filter_links_idx,
238+
i_s,
214239
geoms_info,
215240
geoms_state,
216241
rigid_global_info,
@@ -305,6 +330,7 @@ def _kernel_contact_depth_probe(
305330
probe_radii: qd.types.ndarray(),
306331
probe_radii_noise: qd.types.ndarray(),
307332
links_idx: qd.types.ndarray(),
333+
filter_links_idx: qd.types.ndarray(),
308334
sensor_cache_start: qd.types.ndarray(),
309335
sensor_probe_start: qd.types.ndarray(),
310336
collider_state: array_class.ColliderState,
@@ -343,6 +369,8 @@ def _kernel_contact_depth_probe(
343369
probe_radius,
344370
probe_radius_m,
345371
sensor_link_idx,
372+
filter_links_idx,
373+
i_s,
346374
geoms_info,
347375
geoms_state,
348376
collider_state,
@@ -368,6 +396,9 @@ def __init__(
368396
def build(self):
369397
super().build()
370398
self._shared_metadata.solver.collider.activate_sdf()
399+
self._shared_metadata.filter_links_idx = append_filter_links_idx(
400+
self._shared_metadata.filter_links_idx, self._options.filter_link_idx
401+
)
371402

372403
def _draw_debug_probes(self, context: "RasterizerContext", get_is_contact: Callable[[object], object]):
373404
for obj in self._debug_objects:
@@ -392,7 +423,8 @@ def _draw_debug_probes(self, context: "RasterizerContext", get_is_contact: Calla
392423

393424
@dataclass
394425
class ContactDepthProbeMetadata(ProbeSensorMetadataMixin, RigidSensorMetadataMixin, SimpleSensorMetadata):
395-
pass
426+
# (n_sensors, max_num_filter_links); unused slots and empty filters are -1. See `append_filter_links_idx`.
427+
filter_links_idx: torch.Tensor = make_tensor_field((0, 0), dtype_factory=lambda: gs.tc_int)
396428

397429

398430
class ContactDepthProbeSensor(
@@ -433,6 +465,7 @@ def _update_current_timestep_data(
433465
shared_metadata.probe_radii,
434466
shared_metadata.probe_radii_noise,
435467
shared_metadata.links_idx,
468+
shared_metadata.filter_links_idx,
436469
shared_metadata.sensor_cache_start,
437470
shared_metadata.sensor_probe_start,
438471
solver.collider._collider_state,
@@ -522,6 +555,8 @@ class KinematicTaxelMetadata(ProbesWithNormalSensorMetadataMixin, RigidSensorMet
522555
normal_exponent: torch.Tensor = make_tensor_field((0,))
523556
shear_scalar: torch.Tensor = make_tensor_field((0,))
524557
twist_scalar: torch.Tensor = make_tensor_field((0,))
558+
# (n_sensors, max_num_filter_links); unused slots and empty filters are -1. See `append_filter_links_idx`.
559+
filter_links_idx: torch.Tensor = make_tensor_field((0, 0), dtype_factory=lambda: gs.tc_int)
525560

526561

527562
class KinematicTaxelSensor(
@@ -597,6 +632,7 @@ def _update_current_timestep_data(
597632
shared_metadata.shear_scalar,
598633
shared_metadata.twist_scalar,
599634
shared_metadata.links_idx,
635+
shared_metadata.filter_links_idx,
600636
shared_metadata.sensor_cache_start,
601637
shared_metadata.sensor_probe_start,
602638
shared_metadata.n_probes_per_sensor,

genesis/options/sensors/options.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ def _check_len_match(value, expected_len: int, name: str, ref_name: str):
6161
)
6262

6363

64+
def _validate_filter_link_idx(scene: "Scene", filter_link_idx, sensor_name: str):
65+
"""Check that a contact sensor's ``filter_link_idx`` are valid global (solver-space) rigid link indices."""
66+
if filter_link_idx:
67+
n_links = scene.sim.rigid_solver.n_links
68+
if np.any(np.array(filter_link_idx) < 0) or np.any(np.array(filter_link_idx) >= n_links):
69+
gs.raise_exception(
70+
f"{sensor_name} filter_link_idx should be in range [0, {n_links}). Got {filter_link_idx}"
71+
)
72+
73+
6474
class SensorOptions(Options, Generic[SensorT]):
6575
"""
6676
Base class for all sensor options.
@@ -313,12 +323,7 @@ class Contact(RigidSensorOptionsMixin["ContactSensor"], SimpleSensorOptions["Con
313323

314324
def validate_scene(self, scene: "Scene"):
315325
super().validate_scene(scene)
316-
if self.filter_link_idx:
317-
n_links = scene.sim.rigid_solver.n_links
318-
if np.any(np.array(self.filter_link_idx) < 0) or np.any(np.array(self.filter_link_idx) >= n_links):
319-
gs.raise_exception(
320-
f"Contact sensor filter_link_idx should be in range [0, {n_links}). Got {self.filter_link_idx}"
321-
)
326+
_validate_filter_link_idx(scene, self.filter_link_idx, "Contact sensor")
322327

323328

324329
class ContactForce(RigidSensorOptionsMixin["ContactForceSensor"], SimpleSensorOptions["ContactForceSensor"]):
@@ -358,12 +363,7 @@ def model_post_init(self, context: Any) -> None:
358363

359364
def validate_scene(self, scene: "Scene"):
360365
super().validate_scene(scene)
361-
if self.filter_link_idx:
362-
n_links = scene.sim.rigid_solver.n_links
363-
if np.any(np.array(self.filter_link_idx) < 0) or np.any(np.array(self.filter_link_idx) >= n_links):
364-
gs.raise_exception(
365-
f"ContactForce sensor filter_link_idx should be in range [0, {n_links}). Got {self.filter_link_idx}"
366-
)
366+
_validate_filter_link_idx(scene, self.filter_link_idx, "ContactForce sensor")
367367

368368

369369
class TemperatureProperties(NamedTuple):

genesis/options/sensors/tactile.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
NonNegativeFloat,
99
NonNegativeInt,
1010
NumericType,
11+
OptionalIArrayType,
1112
PositiveFloat,
1213
PositiveInt,
1314
UnitIntervalVec4Type,
@@ -22,9 +23,11 @@
2223
RigidSensorOptionsMixin,
2324
SensorT,
2425
SimpleSensorOptions,
26+
_validate_filter_link_idx,
2527
)
2628

2729
if TYPE_CHECKING:
30+
from genesis.engine.scene import Scene
2831
from genesis.engine.sensors.kinematic_tactile import (
2932
ContactDepthProbeSensor,
3033
ContactProbeSensor,
@@ -58,6 +61,25 @@ class TactileProbeSensorOptionsMixin(ProbeSensorOptionsMixin[SensorT]):
5861
debug_contact_color: UnitIntervalVec4Type = (1.0, 0.2, 0.0, 0.8)
5962

6063

64+
class SolverContactProbeSensorOptionsMixin(TactileProbeSensorOptionsMixin[SensorT]):
65+
"""
66+
Options for tactile probe sensors that resolve depth against the physics solver's active contact pairs (as
67+
opposed to a sampled point cloud), and can therefore scope which contacts count by the counterpart link.
68+
69+
Parameters
70+
----------
71+
filter_link_idx : array-like[int], optional
72+
Global rigid link indices (solver link space). Contacts with the sensor link where the other participant is
73+
one of these links are ignored (they contribute no penetration/force). Default is empty (no filtering).
74+
"""
75+
76+
filter_link_idx: OptionalIArrayType = Field(default_factory=tuple)
77+
78+
def validate_scene(self, scene: "Scene"):
79+
super().validate_scene(scene)
80+
_validate_filter_link_idx(scene, self.filter_link_idx, f"{type(self).__name__} sensor")
81+
82+
6183
class PointCloudTactileSensorMixin(TactileProbeSensorOptionsMixin[SensorT]):
6284
"""
6385
Parameters
@@ -85,7 +107,7 @@ class PointCloudTactileSensorMixin(TactileProbeSensorOptionsMixin[SensorT]):
85107
class ContactProbe(
86108
RigidSensorOptionsMixin["ContactProbeSensor"],
87109
SimpleSensorOptions["ContactProbeSensor"],
88-
TactileProbeSensorOptionsMixin["ContactProbeSensor"],
110+
SolverContactProbeSensorOptionsMixin["ContactProbeSensor"],
89111
):
90112
"""
91113
Returns boolean contact per probe based on the contact depth threshold.
@@ -102,7 +124,7 @@ class ContactProbe(
102124
class ContactDepthProbe(
103125
RigidSensorOptionsMixin["ContactDepthProbeSensor"],
104126
SimpleSensorOptions["ContactDepthProbeSensor"],
105-
TactileProbeSensorOptionsMixin["ContactDepthProbeSensor"],
127+
SolverContactProbeSensorOptionsMixin["ContactDepthProbeSensor"],
106128
):
107129
"""
108130
Returns contact depth in meters per probe.
@@ -112,7 +134,7 @@ class ContactDepthProbe(
112134
class KinematicTaxel(
113135
RigidSensorOptionsMixin["KinematicTaxelSensor"],
114136
SimpleSensorOptions["KinematicTaxelSensor"],
115-
TactileProbeSensorOptionsMixin["KinematicTaxelSensor"],
137+
SolverContactProbeSensorOptionsMixin["KinematicTaxelSensor"],
116138
ProbesWithNormalSensorOptionsMixin["KinematicTaxelSensor"],
117139
):
118140
"""

genesis/utils/misc.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,22 @@ def _default_factory():
458458
return field(default_factory=_default_factory)
459459

460460

461+
def append_filter_links_idx(filter_links_idx: torch.Tensor, filter_link_idx: Sequence[int]) -> torch.Tensor:
462+
"""Append one contact sensor's filter-link indices as a new row of the shared ``(n_sensors, max_filter_links)``
463+
table, growing the column count to fit and back-filling unused slots (and empty filters) with ``-1``.
464+
465+
``-1`` is a sentinel that never matches a real link index, so kernels can scan every column unconditionally. The
466+
table keeps at least one column so that even an all-empty table stays a valid (non-zero-dim) kernel argument.
467+
"""
468+
n_sensors, cur_max = filter_links_idx.shape
469+
new_max = max(cur_max, len(filter_link_idx), 1)
470+
out = torch.full((n_sensors + 1, new_max), -1, dtype=gs.tc_int, device=gs.device)
471+
out[:n_sensors, :cur_max] = filter_links_idx
472+
if len(filter_link_idx) > 0:
473+
out[n_sensors, : len(filter_link_idx)] = torch.tensor(filter_link_idx, dtype=gs.tc_int, device=gs.device)
474+
return out
475+
476+
461477
def try_get_display_size() -> tuple[int | None, int | None, float | None]:
462478
"""
463479
Try to connect to display if it exists and get the screen size.

0 commit comments

Comments
 (0)