From 70d12bc9bbdc5cb24d90fbe796db3ae8b0f4b664 Mon Sep 17 00:00:00 2001 From: sangwooshin Date: Mon, 11 May 2026 19:09:23 -0500 Subject: [PATCH 1/4] [FEATURE] Add filter_link_idx to ContactForceSensor. ContactForceSensor now accepts filter_link_idx, mirroring ContactSensor: contacts with the sensor link whose other participant is one of the listed links are excluded from the reported force. The filter is applied on both ground-truth-update paths -- the vectorized zerocopy path (same 4D contact-vs-filter broadcast as ContactSensor, applied to the per-side contact masks before they are aggregated) and the _kernel_get_contacts_forces kernel (a small per-contact loop over the sensor's filter list). As in ContactSensor, filtered_sensor_idx gates the more expensive comparison so sensors without a filter keep the cheap path. Adds test_contact_force_sensor_filter_link_idx. --- genesis/engine/sensors/contact_force.py | 46 ++++++++++++++++++++- genesis/options/sensors/options.py | 15 +++++++ tests/test_sensors.py | 55 +++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/genesis/engine/sensors/contact_force.py b/genesis/engine/sensors/contact_force.py index 031031297..82d25eab1 100644 --- a/genesis/engine/sensors/contact_force.py +++ b/genesis/engine/sensors/contact_force.py @@ -30,12 +30,25 @@ def _kernel_get_contacts_forces( link_b: qd.types.ndarray(), links_quat: qd.types.ndarray(), sensors_link_idx: qd.types.ndarray(), + filter_links_idx: qd.types.ndarray(), # (n_sensors, max_filter_links); unused slots are -1 output: qd.types.ndarray(), ): for i_c, i_s, i_b in qd.ndrange(link_a.shape[-1], sensors_link_idx.shape[-1], output.shape[-1]): contact_data_link_a = link_a[i_b, i_c] contact_data_link_b = link_b[i_b, i_c] if contact_data_link_a == sensors_link_idx[i_s] or contact_data_link_b == sensors_link_idx[i_s]: + # When this sensor matches one side of the contact the "other" link is the counterpart; skip the + # contact for this sensor if the counterpart is in the sensor's filter list. (-1 padding entries + # never match a real link index.) + counterpart_a_filtered = 0 # other side (link_b) blacklisted, when this sensor is link_a + counterpart_b_filtered = 0 # other side (link_a) blacklisted, when this sensor is link_b + for i_f in range(filter_links_idx.shape[-1]): + f = filter_links_idx[i_s, i_f] + if f == contact_data_link_b: + counterpart_a_filtered = 1 + if f == contact_data_link_a: + counterpart_b_filtered = 1 + j_s = i_s * 3 # per-sensor output dimension is 3 quat_a = qd.Vector.zero(gs.qd_float, 4) @@ -51,10 +64,10 @@ def _kernel_get_contacts_forces( force_a = qd_inv_transform_by_quat(-force_vec, quat_a) force_b = qd_inv_transform_by_quat(force_vec, quat_b) - if contact_data_link_a == sensors_link_idx[i_s]: + if contact_data_link_a == sensors_link_idx[i_s] and counterpart_a_filtered == 0: for j in qd.static(range(3)): output[j_s + j, i_b] += force_a[j] - if contact_data_link_b == sensors_link_idx[i_s]: + if contact_data_link_b == sensors_link_idx[i_s] and counterpart_b_filtered == 0: for j in qd.static(range(3)): output[j_s + j, i_b] += force_b[j] @@ -203,6 +216,11 @@ class ContactForceSensorMetadata(RigidSensorMetadataMixin, SimpleSensorMetadata) min_force: torch.Tensor = make_tensor_field((0, 3)) max_force: torch.Tensor = make_tensor_field((0, 3)) + # (num_force_sensors, max_num_filter_links); unused slots are -1. + filter_links_idx: torch.Tensor = make_tensor_field((0, 0), dtype_factory=lambda: gs.tc_int) + # Indices into links_idx of sensors that have at least one filter link. Lets the GT update skip the + # 4D contact-vs-filter comparison for the (typically larger) subset of sensors with no filter. + filtered_sensor_idx: torch.Tensor = make_tensor_field((0,), dtype_factory=lambda: gs.tc_int) class ContactForceSensor( @@ -238,6 +256,20 @@ def build(self): self._shared_metadata.max_force, self._options.max_force, expand=(1, 3) ) + num_sensors, cur_num_filter_links = self._shared_metadata.filter_links_idx.shape + max_num_filter_links = max(cur_num_filter_links, len(self._options.filter_link_idx)) + filter_links_idx = torch.full((num_sensors + 1, max_num_filter_links), -1, dtype=gs.tc_int, device=gs.device) + filter_links_idx[:num_sensors, :cur_num_filter_links] = self._shared_metadata.filter_links_idx + filter_links_idx[num_sensors, : len(self._options.filter_link_idx)] = torch.tensor( + self._options.filter_link_idx, dtype=gs.tc_int, device=gs.device + ) + self._shared_metadata.filter_links_idx = filter_links_idx + + if len(self._options.filter_link_idx) > 0: + self._shared_metadata.filtered_sensor_idx = concat_with_tensor( + self._shared_metadata.filtered_sensor_idx, num_sensors, expand=(1,), dim=0 + ) + def _get_return_format(self) -> tuple[int, ...]: return (3,) @@ -276,6 +308,15 @@ def _update_raw_data( # Forces are aggregated BEFORE moving them in local frame for efficiency. force_mask_a = link_a[:, None] == shared_metadata.links_idx[None, :, None] force_mask_b = link_b[:, None] == shared_metadata.links_idx[None, :, None] + # Apply the (more expensive) filter-aware update only on sensors that declared a filter; other + # sensors keep the cheap masks computed above. + if shared_metadata.filtered_sensor_idx.numel() > 0: + filt = shared_metadata.filtered_sensor_idx + sub_filter = shared_metadata.filter_links_idx[filt][None, :, None, :] + filtered_a = (link_b[:, None, :, None] == sub_filter).any(dim=-1) + filtered_b = (link_a[:, None, :, None] == sub_filter).any(dim=-1) + force_mask_a[:, filt, :] = force_mask_a[:, filt, :] & ~filtered_a + force_mask_b[:, filt, :] = force_mask_b[:, filt, :] & ~filtered_b force_mask = force_mask_b.to(dtype=gs.tc_float) - force_mask_a.to(dtype=gs.tc_float) sensors_force = (force_mask[..., None] * force[:, None]).sum(dim=2) sensors_quat = links_quat[:, shared_metadata.links_idx] @@ -290,6 +331,7 @@ def _update_raw_data( link_b.contiguous(), links_quat.contiguous(), shared_metadata.links_idx, + shared_metadata.filter_links_idx, raw_data_T, ) diff --git a/genesis/options/sensors/options.py b/genesis/options/sensors/options.py index 6e920d7ef..557b35859 100644 --- a/genesis/options/sensors/options.py +++ b/genesis/options/sensors/options.py @@ -357,6 +357,10 @@ class ContactForce(RigidSensorOptionsMixin["ContactForceSensor"], SimpleSensorOp Parameters ---------- + filter_link_idx : array-like[int], optional + Global rigid link indices (solver link space). Contacts with the sensor link where the other + participant is one of these links are ignored (their force is not included). Default is empty + (no filtering). min_force : float | array-like[float, float, float], optional The minimum detectable absolute force per each axis. Values below this will be treated as 0. Default is 0. max_force : float | array-like[float, float, float], optional @@ -367,6 +371,8 @@ class ContactForce(RigidSensorOptionsMixin["ContactForceSensor"], SimpleSensorOp The scale factor for the debug force arrow. Defaults to 0.01. """ + filter_link_idx: OptionalIArrayType = Field(default_factory=tuple) + resolution: LaxVec3FType = 0.0 min_force: LaxNonNegativeUnboundedVec3FType = 0.0 @@ -380,6 +386,15 @@ def model_post_init(self, context: Any) -> None: if np.any(np.array(self.max_force) <= np.array(self.min_force)): gs.raise_exception(f"min_force should be less than max_force, got: {self.min_force} and {self.max_force}") + def validate_scene(self, scene: "Scene"): + super().validate_scene(scene) + if self.filter_link_idx: + n_links = scene.sim.rigid_solver.n_links + if np.any(np.array(self.filter_link_idx) < 0) or np.any(np.array(self.filter_link_idx) >= n_links): + gs.raise_exception( + f"ContactForce sensor filter_link_idx should be in range [0, {n_links}). Got {self.filter_link_idx}" + ) + class TemperatureProperties(NamedTuple): """ diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 38638e91b..8c65af111 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -949,6 +949,61 @@ def test_contact_sensor_filter_link_idx(show_viewer): assert filtered_data[1], "Contact sensor with filter_link_idx should still detect contact with the box" +@pytest.mark.required +def test_contact_force_sensor_filter_link_idx(show_viewer, tol): + """ContactForce sensor filter_link_idx drops the force contributed by contacts with listed links.""" + scene = gs.Scene( + sim_options=gs.options.SimOptions( + gravity=(0.0, 0.0, -10.0), + ), + profiling_options=gs.options.ProfilingOptions(show_FPS=False), + show_viewer=show_viewer, + ) + floor = scene.add_entity(morph=gs.morphs.Plane()) + box_on_floor = scene.add_entity( + morph=gs.morphs.Box( + size=(0.2, 0.2, 0.2), + pos=(0.0, 0.0, 0.1), + ), + ) + box = scene.add_entity( + morph=gs.morphs.Box( + size=(0.2, 0.2, 0.2), + pos=(0.0, 0.5, 0.1), + ), + ) + sensor = scene.add_sensor( + gs.sensors.ContactForce( + entity_idx=box_on_floor.idx, + ) + ) + sensor_filtered = scene.add_sensor( + gs.sensors.ContactForce( + entity_idx=box_on_floor.idx, + filter_link_idx=(floor.link_start,), + ) + ) + scene.build(n_envs=2) + box.set_pos( + ( + (0.0, 0.5, 0.1), # box not touching box_on_floor + (0.0, 0.0, 0.3), # box on top of box_on_floor + ) + ) + for _ in range(20): # make sure the boxes are stably resting + scene.step() + force = sensor.read() + force_filtered = sensor_filtered.read() + # env 0: box_on_floor only touches the floor. + assert torch.linalg.norm(force[0]) > 1.0, "ContactForce should report the floor support force" + assert force[0][2] > 1.0, "the floor pushes box_on_floor up (+z)" + assert torch.linalg.norm(force_filtered[0]) < tol, "filtering the floor (the only contact) leaves zero force" + # env 1: box_on_floor touches the floor (below) and the box (above). + assert torch.linalg.norm(force[1]) > 1.0, "ContactForce should report a non-zero net contact force" + assert force_filtered[1][2] < -1.0, "filtering the floor leaves only the box-on-top contact, which pushes down (-z)" + assert not torch.allclose(force[1], force_filtered[1], atol=tol), "filtering the floor should change the result" + + # ------------------------------------------------------------------------------------------ # ------------------------------------ Raycast Sensors ------------------------------------- # ------------------------------------------------------------------------------------------ From 2bfaeaa62559f7fe5f272f2fe491ef62bfe9e118 Mon Sep 17 00:00:00 2001 From: Trinity Chung Date: Tue, 7 Jul 2026 17:25:51 -0700 Subject: [PATCH 2/4] 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 _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. Consolidate the existing contact-force filtering while here: - the kernel now uses the shared _func_link_is_filtered helper instead of open-coding the per-contact filter flags; - the zerocopy filter masking (identical in Contact and ContactForce) is factored into _apply_counterpart_filter; - the build-time padding is factored into misc.append_filter_links_idx (used by all five 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. --- genesis/engine/sensors/contact_force.py | 96 ++++++++++----------- genesis/engine/sensors/kinematic_tactile.py | 67 ++++++++++---- genesis/options/sensors/options.py | 24 +++--- genesis/options/sensors/tactile.py | 28 +++++- genesis/utils/misc.py | 16 ++++ tests/test_sensors.py | 92 ++++++++++++++++++++ 6 files changed, 241 insertions(+), 82 deletions(-) diff --git a/genesis/engine/sensors/contact_force.py b/genesis/engine/sensors/contact_force.py index 82d25eab1..1af3a7c8c 100644 --- a/genesis/engine/sensors/contact_force.py +++ b/genesis/engine/sensors/contact_force.py @@ -9,7 +9,13 @@ from genesis.options.sensors import Contact as ContactSensorOptions from genesis.options.sensors import ContactForce as ContactForceSensorOptions from genesis.utils.geom import inv_transform_by_quat, qd_inv_transform_by_quat, transform_by_quat -from genesis.utils.misc import concat_with_tensor, make_tensor_field, qd_to_torch, tensor_to_array +from genesis.utils.misc import ( + append_filter_links_idx, + concat_with_tensor, + make_tensor_field, + qd_to_torch, + tensor_to_array, +) from genesis.utils.ring_buffer import TensorRingBuffer from .base_sensor import RigidSensorMetadataMixin, RigidSensorMixin, Sensor, SimpleSensor, SimpleSensorMetadata @@ -23,6 +29,30 @@ from .sensor_manager import SensorManager +@qd.func +def _func_link_is_filtered(filter_links_idx: qd.types.ndarray(), i_s: int, link: int): + """Whether ``link`` is in sensor ``i_s``'s filter list. ``-1`` padding entries never match a real link index.""" + is_filtered = False + for i_f in range(filter_links_idx.shape[-1]): + if filter_links_idx[i_s, i_f] == link: + is_filtered = True + return is_filtered + + +def _apply_counterpart_filter(is_a: torch.Tensor, is_b: torch.Tensor, link_a, link_b, shared_metadata) -> None: + """Zero out, in place, the contacts a filtered sensor should ignore. ``is_a``/``is_b`` are the per-side + "this contact touches the sensor link" masks, shape ``(B, n_sensors, n_contacts)``; when the sensor is on side + a its counterpart is ``link_b`` (and vice versa), so a contact is dropped when that counterpart is blacklisted. + Only the filtered-sensor rows are touched, so unfiltered sensors keep the cheap masks unchanged. + """ + filt = shared_metadata.filtered_sensor_idx + if filt.numel() == 0: + return + sub_filter = shared_metadata.filter_links_idx[filt][None, :, None, :] + is_a[:, filt, :] &= ~(link_b[:, None, :, None] == sub_filter).any(dim=-1) + is_b[:, filt, :] &= ~(link_a[:, None, :, None] == sub_filter).any(dim=-1) + + @qd.kernel def _kernel_get_contacts_forces( contact_forces: qd.types.ndarray(), @@ -37,18 +67,6 @@ def _kernel_get_contacts_forces( contact_data_link_a = link_a[i_b, i_c] contact_data_link_b = link_b[i_b, i_c] if contact_data_link_a == sensors_link_idx[i_s] or contact_data_link_b == sensors_link_idx[i_s]: - # When this sensor matches one side of the contact the "other" link is the counterpart; skip the - # contact for this sensor if the counterpart is in the sensor's filter list. (-1 padding entries - # never match a real link index.) - counterpart_a_filtered = 0 # other side (link_b) blacklisted, when this sensor is link_a - counterpart_b_filtered = 0 # other side (link_a) blacklisted, when this sensor is link_b - for i_f in range(filter_links_idx.shape[-1]): - f = filter_links_idx[i_s, i_f] - if f == contact_data_link_b: - counterpart_a_filtered = 1 - if f == contact_data_link_a: - counterpart_b_filtered = 1 - j_s = i_s * 3 # per-sensor output dimension is 3 quat_a = qd.Vector.zero(gs.qd_float, 4) @@ -64,10 +82,15 @@ def _kernel_get_contacts_forces( force_a = qd_inv_transform_by_quat(-force_vec, quat_a) force_b = qd_inv_transform_by_quat(force_vec, quat_b) - if contact_data_link_a == sensors_link_idx[i_s] and counterpart_a_filtered == 0: + # Accumulate the force on whichever side is the sensor link, unless the counterpart link is filtered. + if contact_data_link_a == sensors_link_idx[i_s] and not _func_link_is_filtered( + filter_links_idx, i_s, contact_data_link_b + ): for j in qd.static(range(3)): output[j_s + j, i_b] += force_a[j] - if contact_data_link_b == sensors_link_idx[i_s] and counterpart_b_filtered == 0: + if contact_data_link_b == sensors_link_idx[i_s] and not _func_link_is_filtered( + filter_links_idx, i_s, contact_data_link_a + ): for j in qd.static(range(3)): output[j_s + j, i_b] += force_b[j] @@ -121,15 +144,10 @@ def build(self): self._shared_metadata.expanded_links_idx, link_idx, expand=(1,), dim=0 ) - num_sensors, cur_num_filter_links = self._shared_metadata.filter_links_idx.shape - max_num_filter_links = max(cur_num_filter_links, len(self._options.filter_link_idx)) - filter_links_idx = torch.full((num_sensors + 1, max_num_filter_links), -1, dtype=gs.tc_int, device=gs.device) - filter_links_idx[:num_sensors, :cur_num_filter_links] = self._shared_metadata.filter_links_idx - filter_links_idx[num_sensors, : len(self._options.filter_link_idx)] = torch.tensor( - self._options.filter_link_idx, dtype=gs.tc_int, device=gs.device + num_sensors = self._shared_metadata.filter_links_idx.shape[0] + self._shared_metadata.filter_links_idx = append_filter_links_idx( + self._shared_metadata.filter_links_idx, self._options.filter_link_idx ) - self._shared_metadata.filter_links_idx = filter_links_idx - if len(self._options.filter_link_idx) > 0: self._shared_metadata.filtered_sensor_idx = concat_with_tensor( self._shared_metadata.filtered_sensor_idx, num_sensors, expand=(1,), dim=0 @@ -164,18 +182,9 @@ def _update_raw_data(cls, shared_context: None, shared_metadata: ContactSensorMe is_contact_a = link_a[..., None, :] == shared_metadata.expanded_links_idx[..., None] is_contact_b = link_b[..., None, :] == shared_metadata.expanded_links_idx[..., None] + _apply_counterpart_filter(is_contact_a, is_contact_b, link_a, link_b, shared_metadata) # Float-valued contact count per sensor (intermediate cache is float; bool projection in `_post_process`). result = (is_contact_a | is_contact_b).sum(dim=-1).to(dtype=gs.tc_float) - # Apply the (more expensive) filter-aware update only on sensors that declared a filter; other sensors keep the - # cheap aggregate result above. - if shared_metadata.filtered_sensor_idx.numel() > 0: - filt = shared_metadata.filtered_sensor_idx - sub_filter = shared_metadata.filter_links_idx[filt][None, :, None, :] - filtered_a = (link_b[:, None, :, None] == sub_filter).any(dim=-1) - filtered_b = (link_a[:, None, :, None] == sub_filter).any(dim=-1) - sub_is_a = is_contact_a[:, filt, :] - sub_is_b = is_contact_b[:, filt, :] - result[:, filt] = ((sub_is_a & ~filtered_a) | (sub_is_b & ~filtered_b)).sum(dim=-1).to(dtype=gs.tc_float) raw_data_T[:] = result.T @classmethod @@ -256,15 +265,10 @@ def build(self): self._shared_metadata.max_force, self._options.max_force, expand=(1, 3) ) - num_sensors, cur_num_filter_links = self._shared_metadata.filter_links_idx.shape - max_num_filter_links = max(cur_num_filter_links, len(self._options.filter_link_idx)) - filter_links_idx = torch.full((num_sensors + 1, max_num_filter_links), -1, dtype=gs.tc_int, device=gs.device) - filter_links_idx[:num_sensors, :cur_num_filter_links] = self._shared_metadata.filter_links_idx - filter_links_idx[num_sensors, : len(self._options.filter_link_idx)] = torch.tensor( - self._options.filter_link_idx, dtype=gs.tc_int, device=gs.device + num_sensors = self._shared_metadata.filter_links_idx.shape[0] + self._shared_metadata.filter_links_idx = append_filter_links_idx( + self._shared_metadata.filter_links_idx, self._options.filter_link_idx ) - self._shared_metadata.filter_links_idx = filter_links_idx - if len(self._options.filter_link_idx) > 0: self._shared_metadata.filtered_sensor_idx = concat_with_tensor( self._shared_metadata.filtered_sensor_idx, num_sensors, expand=(1,), dim=0 @@ -308,15 +312,7 @@ def _update_raw_data( # Forces are aggregated BEFORE moving them in local frame for efficiency. force_mask_a = link_a[:, None] == shared_metadata.links_idx[None, :, None] force_mask_b = link_b[:, None] == shared_metadata.links_idx[None, :, None] - # Apply the (more expensive) filter-aware update only on sensors that declared a filter; other - # sensors keep the cheap masks computed above. - if shared_metadata.filtered_sensor_idx.numel() > 0: - filt = shared_metadata.filtered_sensor_idx - sub_filter = shared_metadata.filter_links_idx[filt][None, :, None, :] - filtered_a = (link_b[:, None, :, None] == sub_filter).any(dim=-1) - filtered_b = (link_a[:, None, :, None] == sub_filter).any(dim=-1) - force_mask_a[:, filt, :] = force_mask_a[:, filt, :] & ~filtered_a - force_mask_b[:, filt, :] = force_mask_b[:, filt, :] & ~filtered_b + _apply_counterpart_filter(force_mask_a, force_mask_b, link_a, link_b, shared_metadata) force_mask = force_mask_b.to(dtype=gs.tc_float) - force_mask_a.to(dtype=gs.tc_float) sensors_force = (force_mask[..., None] * force[:, None]).sum(dim=2) sensors_quat = links_quat[:, shared_metadata.links_idx] diff --git a/genesis/engine/sensors/kinematic_tactile.py b/genesis/engine/sensors/kinematic_tactile.py index d71e93a22..f56f26609 100644 --- a/genesis/engine/sensors/kinematic_tactile.py +++ b/genesis/engine/sensors/kinematic_tactile.py @@ -13,7 +13,7 @@ from genesis.options.sensors import ContactDepthProbe as ContactDepthProbeOptions from genesis.options.sensors import ContactProbe as ContactProbeOptions from genesis.options.sensors import KinematicTaxel as KinematicTaxelOptions -from genesis.utils.misc import concat_with_tensor, make_tensor_field, tensor_to_array +from genesis.utils.misc import append_filter_links_idx, concat_with_tensor, make_tensor_field, tensor_to_array from genesis.utils.raycast_qd import ( closest_point_on_triangle, get_triangle_vertices, @@ -23,6 +23,7 @@ from .raycaster import RaycastContext from .base_sensor import RigidSensorMetadataMixin, RigidSensorMixin, SimpleSensor, SimpleSensorMetadata +from .contact_force import _func_link_is_filtered from .probe import ( ProbeSensorMetadataMixin, ProbeSensorMixin, @@ -52,6 +53,7 @@ def _func_query_contact_depth_penetration( probe_pos: qd.types.vector(3), probe_radius_gt: float, probe_radius_m: float, + filter_links_idx: qd.types.ndarray(), geoms_info: array_class.GeomsInfo, geoms_state: array_class.GeomsState, sensor_geoms_idx: qd.types.ndarray(), @@ -60,6 +62,11 @@ def _func_query_contact_depth_penetration( ): """ Max probe penetration from SDF over the sensor link's unique opposing geoms, dual-radius. + + Returns ``(max_pen_gt, max_pen_m)`` from a single SDF pass: both penetrations come from the same ``sd`` per geom + via ``pen = radius - sd``. Callers that do not need the noised-radius branch pass ``probe_radius_m == + probe_radius_gt`` and ignore the second return. Geoms whose owning link is in sensor ``i_s``'s + ``filter_links_idx`` list are skipped. """ max_pen_gt = gs.qd_float(0.0) max_pen_m = gs.qd_float(0.0) @@ -67,15 +74,16 @@ def _func_query_contact_depth_penetration( n_g = sensor_n_geoms[i_b, i_s] for i_g_ in range(n_g): i_g = sensor_geoms_idx[i_b, i_s, i_g_] - g_pos = geoms_state.pos[i_g, i_b] - g_quat = geoms_state.quat[i_g, i_b] - sd = sdf.sdf_func_world_local(geoms_info, sdf_info, probe_pos, i_g, g_pos, g_quat) - pen_gt = probe_radius_gt - sd - if pen_gt > max_pen_gt: - max_pen_gt = pen_gt - pen_m = probe_radius_m - sd - if pen_m > max_pen_m: - max_pen_m = pen_m + if not _func_link_is_filtered(filter_links_idx, i_s, geoms_info.link_idx[i_g]): + g_pos = geoms_state.pos[i_g, i_b] + g_quat = geoms_state.quat[i_g, i_b] + sd = sdf.sdf_func_world_local(geoms_info, sdf_info, probe_pos, i_g, g_pos, g_quat) + pen_gt = probe_radius_gt - sd + if pen_gt > max_pen_gt: + max_pen_gt = pen_gt + pen_m = probe_radius_m - sd + if pen_m > max_pen_m: + max_pen_m = pen_m return max_pen_gt, max_pen_m @@ -177,6 +185,7 @@ def _func_query_contact_depth( probe_pos: qd.types.vector(3), probe_radius_gt: float, probe_radius_m: float, + filter_links_idx: qd.types.ndarray(), geoms_info: array_class.GeomsInfo, geoms_state: array_class.GeomsState, rigid_global_info: array_class.RigidGlobalInfo, @@ -192,8 +201,9 @@ def _func_query_contact_depth( Iterates the per-(env, sensor) deduplicated opposing-geom list built by ``_kernel_build_sensor_geom_idx``; every geom in that list contacts the sensor's tracked link, so the reported contact link is recovered as ``geoms_info.link_idx[i_g]`` (the link owning the opposing geom). AABB pre-filter expands by - ``max(probe_radius_gt, probe_radius_m)`` so neither branch is - silently skipped. Callers without a noised radius pass ``probe_radius_m == probe_radius_gt``. + ``max(probe_radius_gt, probe_radius_m)`` so neither branch is silently skipped. Callers without a noised radius + pass ``probe_radius_m == probe_radius_gt``. Geoms whose owning link is in sensor ``i_s``'s ``filter_links_idx`` + list are skipped. """ max_pen_gt = gs.qd_float(0.0) contact_link_gt = gs.qd_int(-1) @@ -206,7 +216,9 @@ def _func_query_contact_depth( n_g = sensor_n_geoms[i_b, i_s] for i_g_ in range(n_g): i_g = sensor_geoms_idx[i_b, i_s, i_g_] - if func_point_in_geom_aabb(geoms_state, i_g, i_b, probe_pos, aabb_expansion): + if not _func_link_is_filtered(filter_links_idx, i_s, geoms_info.link_idx[i_g]) and func_point_in_geom_aabb( + geoms_state, i_g, i_b, probe_pos, aabb_expansion + ): g_pos = geoms_state.pos[i_g, i_b] g_quat = geoms_state.quat[i_g, i_b] sd = sdf.sdf_func_world_local(geoms_info, sdf_info, probe_pos, i_g, g_pos, g_quat) @@ -297,6 +309,7 @@ def _kernel_kinematic_taxel( shear_scalar: qd.types.ndarray(), twist_scalar: qd.types.ndarray(), links_idx: qd.types.ndarray(), + filter_links_idx: qd.types.ndarray(), sensor_cache_start: qd.types.ndarray(), sensor_probe_start: qd.types.ndarray(), n_probes_per_sensor: qd.types.ndarray(), @@ -363,6 +376,7 @@ def _kernel_kinematic_taxel( probe_pos, probe_radius, probe_radius_m, + filter_links_idx, geoms_info, geoms_state, rigid_global_info, @@ -429,6 +443,7 @@ def _kernel_contact_depth_probe( probe_radii_noise: qd.types.ndarray(), probe_gains: qd.types.ndarray(), links_idx: qd.types.ndarray(), + filter_links_idx: qd.types.ndarray(), sensor_cache_start: qd.types.ndarray(), sensor_probe_start: qd.types.ndarray(), sensor_geoms_idx: qd.types.ndarray(), @@ -475,6 +490,7 @@ def _kernel_contact_depth_probe( probe_pos, probe_radius, probe_radius_m, + filter_links_idx, geoms_info, geoms_state, sensor_geoms_idx, @@ -493,6 +509,7 @@ def _kernel_contact_depth_probe( @qd.kernel def _kernel_build_sensor_candidate_geom_mask( sensor_link_idx: qd.types.ndarray(), + filter_links_idx: qd.types.ndarray(), sensor_contacts_idx: qd.types.ndarray(), sensor_n_contacts: qd.types.ndarray(), collider_state: array_class.ColliderState, @@ -505,7 +522,8 @@ def _kernel_build_sensor_candidate_geom_mask( to skip triangles whose owning geom isn't in the sensor's current contact list. Only the geom on the side opposite the sensor link is marked (mirroring the SDF path's ``i_g = `` selection); marking the sensor's own geom would let the BVH closest-point test latch onto the sensor's own surface, pinning the reported depth to - ``probe_radius`` regardless of the pressing object. + ``probe_radius`` regardless of the pressing object. A contact whose counterpart link is in sensor ``i_s``'s + ``filter_links_idx`` list is skipped, so its opposing geom never becomes a BVH candidate. """ n_batches = sensor_n_contacts.shape[0] n_sensors = sensor_n_contacts.shape[1] @@ -517,9 +535,11 @@ def _kernel_build_sensor_candidate_geom_mask( n_c = sensor_n_contacts[i_b, i_s] for i_c_ in range(n_c): i_c = sensor_contacts_idx[i_b, i_s, i_c_] - if collider_state.contact_data.link_a[i_c, i_b] == link: + c_link_a = collider_state.contact_data.link_a[i_c, i_b] + c_link_b = collider_state.contact_data.link_b[i_c, i_b] + if c_link_a == link and not _func_link_is_filtered(filter_links_idx, i_s, c_link_b): sensor_candidate_geom_mask[i_b, i_s, collider_state.contact_data.geom_b[i_c, i_b]] = True - if collider_state.contact_data.link_b[i_c, i_b] == link: + if c_link_b == link and not _func_link_is_filtered(filter_links_idx, i_s, c_link_a): sensor_candidate_geom_mask[i_b, i_s, collider_state.contact_data.geom_a[i_c, i_b]] = True @@ -889,6 +909,12 @@ class KinematicTactileSensorMixin(ContactDepthQuerySensorMixin, ProbeSensorMixin subclasses add their own metadata. """ + def build(self): + super().build() + self._shared_metadata.filter_links_idx = append_filter_links_idx( + self._shared_metadata.filter_links_idx, self._options.filter_link_idx + ) + @dataclass class ContactDepthProbeMetadata( @@ -898,7 +924,8 @@ class ContactDepthProbeMetadata( RigidSensorMetadataMixin, SimpleSensorMetadata, ): - pass + # (n_sensors, max_num_filter_links); unused slots and empty filters are -1. See `append_filter_links_idx`. + filter_links_idx: torch.Tensor = make_tensor_field((0, 0), dtype_factory=lambda: gs.tc_int) class ContactDepthProbeSensor( @@ -958,6 +985,7 @@ def _update_current_timestep_data( shared_metadata.probe_radii_noise, shared_metadata.probe_gains, shared_metadata.links_idx, + shared_metadata.filter_links_idx, shared_metadata.sensor_cache_start, shared_metadata.sensor_probe_start, shared_metadata.sensor_geoms_idx, @@ -982,6 +1010,7 @@ def _update_current_timestep_data( shared_metadata.sensor_candidate_geom_mask = torch.zeros(mask_shape, dtype=gs.tc_bool, device=gs.device) _kernel_build_sensor_candidate_geom_mask( shared_metadata.links_idx, + shared_metadata.filter_links_idx, shared_metadata.sensor_contacts_idx, shared_metadata.sensor_n_contacts, solver.collider._collider_state, @@ -1129,6 +1158,8 @@ class KinematicTaxelMetadata( normal_exponent: torch.Tensor = make_tensor_field((0,)) shear_scalar: torch.Tensor = make_tensor_field((0,)) twist_scalar: torch.Tensor = make_tensor_field((0,)) + # (n_sensors, max_num_filter_links); unused slots and empty filters are -1. See `append_filter_links_idx`. + filter_links_idx: torch.Tensor = make_tensor_field((0, 0), dtype_factory=lambda: gs.tc_int) class KinematicTaxelSensor( @@ -1215,6 +1246,7 @@ def _update_current_timestep_data( shared_metadata.shear_scalar, shared_metadata.twist_scalar, shared_metadata.links_idx, + shared_metadata.filter_links_idx, shared_metadata.sensor_cache_start, shared_metadata.sensor_probe_start, shared_metadata.n_probes_per_sensor, @@ -1244,6 +1276,7 @@ def _update_current_timestep_data( shared_metadata.sensor_candidate_geom_mask = torch.zeros(mask_shape, dtype=gs.tc_bool, device=gs.device) _kernel_build_sensor_candidate_geom_mask( shared_metadata.links_idx, + shared_metadata.filter_links_idx, shared_metadata.sensor_contacts_idx, shared_metadata.sensor_n_contacts, solver.collider._collider_state, diff --git a/genesis/options/sensors/options.py b/genesis/options/sensors/options.py index 557b35859..1768f9c3b 100644 --- a/genesis/options/sensors/options.py +++ b/genesis/options/sensors/options.py @@ -66,6 +66,16 @@ def _check_len_match(value, expected_len: int, name: str, ref_name: str): ) +def _validate_filter_link_idx(scene: "Scene", filter_link_idx, sensor_name: str): + """Check that a contact sensor's ``filter_link_idx`` are valid global (solver-space) rigid link indices.""" + if filter_link_idx: + n_links = scene.sim.rigid_solver.n_links + if np.any(np.array(filter_link_idx) < 0) or np.any(np.array(filter_link_idx) >= n_links): + gs.raise_exception( + f"{sensor_name} filter_link_idx should be in range [0, {n_links}). Got {filter_link_idx}" + ) + + class SensorOptions(Options, Generic[SensorT]): """ Base class for all sensor options. @@ -343,12 +353,7 @@ class Contact(RigidSensorOptionsMixin["ContactSensor"], SimpleSensorOptions["Con def validate_scene(self, scene: "Scene"): super().validate_scene(scene) - if self.filter_link_idx: - n_links = scene.sim.rigid_solver.n_links - if np.any(np.array(self.filter_link_idx) < 0) or np.any(np.array(self.filter_link_idx) >= n_links): - gs.raise_exception( - f"Contact sensor filter_link_idx should be in range [0, {n_links}). Got {self.filter_link_idx}" - ) + _validate_filter_link_idx(scene, self.filter_link_idx, "Contact sensor") class ContactForce(RigidSensorOptionsMixin["ContactForceSensor"], SimpleSensorOptions["ContactForceSensor"]): @@ -388,12 +393,7 @@ def model_post_init(self, context: Any) -> None: def validate_scene(self, scene: "Scene"): super().validate_scene(scene) - if self.filter_link_idx: - n_links = scene.sim.rigid_solver.n_links - if np.any(np.array(self.filter_link_idx) < 0) or np.any(np.array(self.filter_link_idx) >= n_links): - gs.raise_exception( - f"ContactForce sensor filter_link_idx should be in range [0, {n_links}). Got {self.filter_link_idx}" - ) + _validate_filter_link_idx(scene, self.filter_link_idx, "ContactForce sensor") class TemperatureProperties(NamedTuple): diff --git a/genesis/options/sensors/tactile.py b/genesis/options/sensors/tactile.py index 11cc93828..fc6ba2a27 100644 --- a/genesis/options/sensors/tactile.py +++ b/genesis/options/sensors/tactile.py @@ -10,6 +10,7 @@ IArrayType, NonNegativeFloat, NonNegativeInt, + OptionalIArrayType, PositiveFloat, PositiveInt, UnitIntervalVec3Type, @@ -22,9 +23,11 @@ RigidSensorOptionsMixin, SensorT, SimpleSensorOptions, + _validate_filter_link_idx, ) if TYPE_CHECKING: + from genesis.engine.scene import Scene from genesis.engine.sensors.kinematic_tactile import ( ContactDepthProbeSensor, ContactProbeSensor, @@ -69,6 +72,25 @@ class TactileProbeSensorOptionsMixin(ProbeSensorOptionsMixin[SensorT]): contact_depth_query: Literal["sdf", "raycast"] | None = None +class SolverContactProbeSensorOptionsMixin(TactileProbeSensorOptionsMixin[SensorT]): + """ + Options for tactile probe sensors that resolve depth against the physics solver's active contact pairs (as + opposed to a sampled point cloud), and can therefore scope which contacts count by the counterpart link. + + Parameters + ---------- + filter_link_idx : array-like[int], optional + Global rigid link indices (solver link space). Contacts with the sensor link where the other participant is + one of these links are ignored (they contribute no penetration/force). Default is empty (no filtering). + """ + + filter_link_idx: OptionalIArrayType = Field(default_factory=tuple) + + def validate_scene(self, scene: "Scene"): + super().validate_scene(scene) + _validate_filter_link_idx(scene, self.filter_link_idx, f"{type(self).__name__} sensor") + + class PointCloudTactileSensorMixin(TactileProbeSensorOptionsMixin[SensorT]): """ Options mixin for tactile sensors that sample a point cloud from tracked link meshes. @@ -101,7 +123,7 @@ class PointCloudTactileSensorMixin(TactileProbeSensorOptionsMixin[SensorT]): class ContactProbe( RigidSensorOptionsMixin["ContactProbeSensor"], SimpleSensorOptions["ContactProbeSensor"], - TactileProbeSensorOptionsMixin["ContactProbeSensor"], + SolverContactProbeSensorOptionsMixin["ContactProbeSensor"], ): """ Returns boolean contact per probe based on the contact depth threshold. @@ -144,7 +166,7 @@ def model_post_init(self, context: Any) -> None: class ContactDepthProbe( RigidSensorOptionsMixin["ContactDepthProbeSensor"], SimpleSensorOptions["ContactDepthProbeSensor"], - TactileProbeSensorOptionsMixin["ContactDepthProbeSensor"], + SolverContactProbeSensorOptionsMixin["ContactDepthProbeSensor"], ): """ Returns contact depth in meters per probe. @@ -161,7 +183,7 @@ class ContactDepthProbe( class KinematicTaxel( RigidSensorOptionsMixin["KinematicTaxelSensor"], SimpleSensorOptions["KinematicTaxelSensor"], - TactileProbeSensorOptionsMixin["KinematicTaxelSensor"], + SolverContactProbeSensorOptionsMixin["KinematicTaxelSensor"], ): """ A tactile sensor which estimates force and torque per taxel by querying contact depth within the radius of the diff --git a/genesis/utils/misc.py b/genesis/utils/misc.py index a146b1dad..9d2010405 100644 --- a/genesis/utils/misc.py +++ b/genesis/utils/misc.py @@ -458,6 +458,22 @@ def _default_factory(): return field(default_factory=_default_factory) +def append_filter_links_idx(filter_links_idx: torch.Tensor, filter_link_idx: Sequence[int]) -> torch.Tensor: + """Append one contact sensor's filter-link indices as a new row of the shared ``(n_sensors, max_filter_links)`` + table, growing the column count to fit and back-filling unused slots (and empty filters) with ``-1``. + + ``-1`` is a sentinel that never matches a real link index, so kernels can scan every column unconditionally. The + table keeps at least one column so that even an all-empty table stays a valid (non-zero-dim) kernel argument. + """ + n_sensors, cur_max = filter_links_idx.shape + new_max = max(cur_max, len(filter_link_idx), 1) + out = torch.full((n_sensors + 1, new_max), -1, dtype=gs.tc_int, device=gs.device) + out[:n_sensors, :cur_max] = filter_links_idx + if len(filter_link_idx) > 0: + out[n_sensors, : len(filter_link_idx)] = torch.tensor(filter_link_idx, dtype=gs.tc_int, device=gs.device) + return out + + def try_get_display_size() -> tuple[int | None, int | None, float | None]: """ Try to connect to display if it exists and get the screen size. diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 8c65af111..fbf3c613d 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -2114,6 +2114,98 @@ def step_at(box_z): assert not h.any() and not p.any() +@pytest.mark.required +def test_contact_probe_sensor_filter_link_idx(show_viewer, tol): + """ContactProbe/ContactDepthProbe/KinematicTaxel filter_link_idx drops contacts whose counterpart is filtered. + + A free box is sandwiched between the ground (below) and a fixed sphere (above). The box's bottom probe sees the + ground and its top probe sees the sphere; filtering the ground link should zero out only the bottom probe. + """ + BOX_SIZE = 0.5 + PROBE_RADIUS = 0.05 + PENETRATION = 0.02 + SPHERE_RADIUS = 0.1 + STIFFNESS = 100.0 + + scene = gs.Scene( + sim_options=gs.options.SimOptions(gravity=(0.0, 0.0, 0.0)), + profiling_options=gs.options.ProfilingOptions(show_FPS=False), + show_viewer=show_viewer, + ) + floor = scene.add_entity(gs.morphs.Plane()) + box = scene.add_entity( + gs.morphs.Box( + size=(BOX_SIZE, BOX_SIZE, BOX_SIZE), + pos=(0.0, 0.0, BOX_SIZE / 2 - PENETRATION), # box bottom penetrates the ground + fixed=False, # probe will not detect fixed-fixed contact + ) + ) + box_top_z = BOX_SIZE - PENETRATION + scene.add_entity( + gs.morphs.Sphere( + radius=SPHERE_RADIUS, + pos=(0.0, 0.0, box_top_z + SPHERE_RADIUS - PENETRATION), # sphere penetrates the box top + fixed=True, + ) + ) + + TOP, BOTTOM = 0, 1 # probe indices + common_kwargs = dict( + entity_idx=box.idx, + probe_local_pos=((0.0, 0.0, BOX_SIZE / 2), (0.0, 0.0, -BOX_SIZE / 2)), + probe_radius=PROBE_RADIUS, + draw_debug=show_viewer, + ) + depth_probe = scene.add_sensor(gs.sensors.ContactDepthProbe(**common_kwargs)) + depth_probe_filtered = scene.add_sensor( + gs.sensors.ContactDepthProbe(filter_link_idx=(floor.link_start,), **common_kwargs) + ) + contact_probe_filtered = scene.add_sensor( + gs.sensors.ContactProbe(contact_threshold=0.002, filter_link_idx=(floor.link_start,), **common_kwargs) + ) + taxel = scene.add_sensor( + gs.sensors.KinematicTaxel( + normal_stiffness=STIFFNESS, + normal_damping=0.0, + shear_scalar=0.0, + twist_scalar=0.0, + **common_kwargs, + ) + ) + taxel_filtered = scene.add_sensor( + gs.sensors.KinematicTaxel( + normal_stiffness=STIFFNESS, + normal_damping=0.0, + shear_scalar=0.0, + twist_scalar=0.0, + filter_link_idx=(floor.link_start,), + **common_kwargs, + ) + ) + + scene.build(n_envs=0) + scene.step() + + depth = depth_probe.read_ground_truth() + depth_filtered = depth_probe_filtered.read_ground_truth() + contact_filtered = contact_probe_filtered.read_ground_truth() + force = taxel.read_ground_truth().force + force_filtered = taxel_filtered.read_ground_truth().force + + # Unfiltered: both the ground (bottom) and sphere (top) are detected. + assert depth[TOP] > tol and depth[BOTTOM] > tol, "both probes should detect a contact without filtering" + assert force[TOP, 2] < -tol, "top taxel is pushed down by the sphere" + assert force[BOTTOM, 2] > tol, "bottom taxel is pushed up by the ground" + + # Filtering the ground removes only the bottom probe's contact; the top probe (sphere) is untouched. + assert_allclose(depth_filtered[BOTTOM], 0.0, tol=gs.EPS) + assert_allclose(depth_filtered[TOP], depth[TOP], tol=tol) + assert not contact_filtered[BOTTOM], "bottom probe should report no contact once the ground is filtered" + assert contact_filtered[TOP], "top probe should still report contact with the sphere" + assert_allclose(force_filtered[BOTTOM], 0.0, tol=gs.EPS) + assert_allclose(force_filtered[TOP], force[TOP], tol=tol) + + @pytest.mark.required @pytest.mark.parametrize("n_envs", [0, 2]) def test_elastomer_sensor_sphere_ground_dilate_shear(show_viewer, tol, n_envs): From a673c74f653b25d183a1604229dd229848b6ae9c Mon Sep 17 00:00:00 2001 From: Trinity Chung Date: Wed, 8 Jul 2026 16:33:49 -0700 Subject: [PATCH 3/4] Rename _apply_counterpart_filter -> _drop_filtered_counterpart_contacts "apply ... filter" didn't convey what the function does. It clears mask entries for contacts whose counterpart link is filtered out, matching the wording already used for this behavior elsewhere (e.g. the test docstring "drops contacts whose counterpart is filtered"). The self-describing name replaces the docstring, which is dropped. --- genesis/engine/sensors/contact_force.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/genesis/engine/sensors/contact_force.py b/genesis/engine/sensors/contact_force.py index 1af3a7c8c..a79d9a685 100644 --- a/genesis/engine/sensors/contact_force.py +++ b/genesis/engine/sensors/contact_force.py @@ -39,12 +39,9 @@ def _func_link_is_filtered(filter_links_idx: qd.types.ndarray(), i_s: int, link: return is_filtered -def _apply_counterpart_filter(is_a: torch.Tensor, is_b: torch.Tensor, link_a, link_b, shared_metadata) -> None: - """Zero out, in place, the contacts a filtered sensor should ignore. ``is_a``/``is_b`` are the per-side - "this contact touches the sensor link" masks, shape ``(B, n_sensors, n_contacts)``; when the sensor is on side - a its counterpart is ``link_b`` (and vice versa), so a contact is dropped when that counterpart is blacklisted. - Only the filtered-sensor rows are touched, so unfiltered sensors keep the cheap masks unchanged. - """ +def _drop_filtered_counterpart_contacts( + is_a: torch.Tensor, is_b: torch.Tensor, link_a, link_b, shared_metadata +) -> None: filt = shared_metadata.filtered_sensor_idx if filt.numel() == 0: return @@ -182,7 +179,7 @@ def _update_raw_data(cls, shared_context: None, shared_metadata: ContactSensorMe is_contact_a = link_a[..., None, :] == shared_metadata.expanded_links_idx[..., None] is_contact_b = link_b[..., None, :] == shared_metadata.expanded_links_idx[..., None] - _apply_counterpart_filter(is_contact_a, is_contact_b, link_a, link_b, shared_metadata) + _drop_filtered_counterpart_contacts(is_contact_a, is_contact_b, link_a, link_b, shared_metadata) # Float-valued contact count per sensor (intermediate cache is float; bool projection in `_post_process`). result = (is_contact_a | is_contact_b).sum(dim=-1).to(dtype=gs.tc_float) raw_data_T[:] = result.T @@ -312,7 +309,7 @@ def _update_raw_data( # Forces are aggregated BEFORE moving them in local frame for efficiency. force_mask_a = link_a[:, None] == shared_metadata.links_idx[None, :, None] force_mask_b = link_b[:, None] == shared_metadata.links_idx[None, :, None] - _apply_counterpart_filter(force_mask_a, force_mask_b, link_a, link_b, shared_metadata) + _drop_filtered_counterpart_contacts(force_mask_a, force_mask_b, link_a, link_b, shared_metadata) force_mask = force_mask_b.to(dtype=gs.tc_float) - force_mask_a.to(dtype=gs.tc_float) sensors_force = (force_mask[..., None] * force[:, None]).sum(dim=2) sensors_quat = links_quat[:, shared_metadata.links_idx] From 37e61185302e39fb08a5a3808c9fa0efd2ab2b04 Mon Sep 17 00:00:00 2001 From: Trinity Chung Date: Wed, 8 Jul 2026 16:48:23 -0700 Subject: [PATCH 4/4] Rename SolverContactProbeSensorOptionsMixin -> FilterableTactileProbeSensorOptionsMixin The mixin's only contribution over TactileProbeSensorOptionsMixin is filter_link_idx (+ its validation); nothing "solver-contact" specific lives in it - that's in the sensor implementations. Name it for what it adds: the subset of tactile probes that support counterpart-link contact filtering. The point-cloud probes (Elastomer/Proximity) intentionally stay unfiltered - they already scope via track_link_idx (an allowlist), the dual of filter_link_idx. --- genesis/options/sensors/tactile.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/genesis/options/sensors/tactile.py b/genesis/options/sensors/tactile.py index fc6ba2a27..a2b19a666 100644 --- a/genesis/options/sensors/tactile.py +++ b/genesis/options/sensors/tactile.py @@ -72,7 +72,7 @@ class TactileProbeSensorOptionsMixin(ProbeSensorOptionsMixin[SensorT]): contact_depth_query: Literal["sdf", "raycast"] | None = None -class SolverContactProbeSensorOptionsMixin(TactileProbeSensorOptionsMixin[SensorT]): +class FilterableTactileProbeSensorOptionsMixin(TactileProbeSensorOptionsMixin[SensorT]): """ Options for tactile probe sensors that resolve depth against the physics solver's active contact pairs (as opposed to a sampled point cloud), and can therefore scope which contacts count by the counterpart link. @@ -123,7 +123,7 @@ class PointCloudTactileSensorMixin(TactileProbeSensorOptionsMixin[SensorT]): class ContactProbe( RigidSensorOptionsMixin["ContactProbeSensor"], SimpleSensorOptions["ContactProbeSensor"], - SolverContactProbeSensorOptionsMixin["ContactProbeSensor"], + FilterableTactileProbeSensorOptionsMixin["ContactProbeSensor"], ): """ Returns boolean contact per probe based on the contact depth threshold. @@ -166,7 +166,7 @@ def model_post_init(self, context: Any) -> None: class ContactDepthProbe( RigidSensorOptionsMixin["ContactDepthProbeSensor"], SimpleSensorOptions["ContactDepthProbeSensor"], - SolverContactProbeSensorOptionsMixin["ContactDepthProbeSensor"], + FilterableTactileProbeSensorOptionsMixin["ContactDepthProbeSensor"], ): """ Returns contact depth in meters per probe. @@ -183,7 +183,7 @@ class ContactDepthProbe( class KinematicTaxel( RigidSensorOptionsMixin["KinematicTaxelSensor"], SimpleSensorOptions["KinematicTaxelSensor"], - SolverContactProbeSensorOptionsMixin["KinematicTaxelSensor"], + FilterableTactileProbeSensorOptionsMixin["KinematicTaxelSensor"], ): """ A tactile sensor which estimates force and torque per taxel by querying contact depth within the radius of the