Skip to content

Add grasp annotator#196

Open
matafela wants to merge 12 commits intomainfrom
cj/add-grasp-annotator
Open

Add grasp annotator#196
matafela wants to merge 12 commits intomainfrom
cj/add-grasp-annotator

Conversation

@matafela
Copy link
Copy Markdown
Collaborator

@matafela matafela commented Mar 23, 2026

Description

Add grasp annotator. Example: scripts/tutorials/grasp/grasp_generator.py

TODO:

  • Docs and comments
  • Collision filter.

Type of change

  • New feature (non-breaking change which adds functionality)

Screenshots

Screenshot from 2026-03-23 19-16-01

Checklist

  • I have run the black . command to format the code base.
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • Dependencies have been updated, if applicable.

chenjian added 2 commits March 23, 2026 18:59
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an interactive antipodal grasp annotation workflow (Viser-based) to generate grasp poses for rigid objects, plus a simulation demo showing a UR10 + gripper grasping a mug using the annotator.

Changes:

  • Introduce AntipodalSampler for sampling antipodal point pairs via Open3D raycasting.
  • Introduce GraspAnnotator (Viser UI) to select a mesh region, generate antipodal pairs, and compute an approach-aligned grasp pose.
  • Expose grasp annotation/pose generation on RigidObject.get_grasp_pose() and add an end-to-end mug grasp demo.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 16 comments.

File Description
examples/sim/demo/grasp_mug.py New demo that spawns UR10 + gripper and uses the new grasp annotator to compute a grasp pose and execute a grasp trajectory.
embodichain/toolkits/graspkit/pg_grasp/antipodal_sampler.py New sampler that generates antipodal point pairs using Open3D ray casting and length/angle constraints.
embodichain/toolkits/graspkit/pg_grasp/antipodal_annotator.py New Viser-based annotation tool for selecting grasp regions, caching results, and computing a grasp pose aligned to an approach direction.
embodichain/lab/sim/objects/rigid_object.py Integrates grasp annotation into core RigidObject via get_grasp_pose().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +87 to +94
config = SimulationManagerCfg(
headless=True,
sim_device=args.device,
enable_rt=args.enable_rt,
physics_dt=1.0 / 100.0,
num_envs=args.num_envs,
arena_space=2.5,
)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SimulationManagerCfg(headless=True, ...) ignores the --headless CLI argument (and will always run headless). Use headless=args.headless so the flag actually controls window creation; otherwise sim.open_window() later can be confusing/inconsistent.

Copilot uses AI. Check for mistakes.
return sim


def create_robot(sim: SimulationManager, position=[0.0, 0.0, 0.0]) -> Robot:
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_robot(..., position=[0.0, 0.0, 0.0]) uses a mutable list as a default argument. Switch to an immutable default (e.g., a tuple) or None + assignment to avoid shared-state bugs across calls.

Suggested change
def create_robot(sim: SimulationManager, position=[0.0, 0.0, 0.0]) -> Robot:
def create_robot(sim: SimulationManager, position=(0.0, 0.0, 0.0)) -> Robot:

Copilot uses AI. Check for mistakes.

def create_mug(sim: SimulationManager):
mug_cfg = RigidObjectCfg(
uid="table",
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_mug() sets uid="table", which is misleading and can cause collisions/confusion with actual table assets (and other examples already use uid="table" for a table object). Rename the UID to something like "mug"/"cup" to reflect the asset being spawned.

Suggested change
uid="table",
uid="mug",

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +45
from dexsim.utility.path import get_resources_data_path

from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
from embodichain.lab.sim.objects import Robot, RigidObject
from embodichain.lab.sim.utility.action_utils import interpolate_with_distance
from embodichain.lab.sim.shapes import MeshCfg
from embodichain.lab.sim.solvers import PytorchSolverCfg
from embodichain.data import get_data_path
from embodichain.utils import logger
from embodichain.lab.sim.cfg import (
JointDrivePropertiesCfg,
RobotCfg,
LightCfg,
RigidBodyAttributesCfg,
RigidObjectCfg,
URDFCfg,
)
from embodichain.lab.sim.shapes import MeshCfg
from embodichain.toolkits.graspkit.pg_grasp.antipodal_annotator import (
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are duplicated/unused imports in this new script: MeshCfg is imported twice, and get_resources_data_path is imported but never used. Please remove duplicates/unused imports to keep the example clean and lint-friendly.

Copilot uses AI. Check for mistakes.
Comment on lines +110 to +111
hit_point_pairs = torch.cat(
[hit_points[:, None, :], hit_origins[:, None, :]], dim=1
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_raycast_result() builds hit_point_pairs as [hit_point, surface_origin] (see concatenation order), but downstream (GraspAnnotator.get_approach_grasp_poses() and _extend_hit_point_pairs()) treats index 0 as the surface/origin point and index 1 as the hit point. This mismatch can flip the grasp axis and make visualization/pose computation inconsistent. Please standardize the convention (either swap here or swap all downstream uses) and document the ordering clearly.

Suggested change
hit_point_pairs = torch.cat(
[hit_points[:, None, :], hit_origins[:, None, :]], dim=1
# Convention: hit_point_pairs[..., 0, :] is the surface/origin point,
# and hit_point_pairs[..., 1, :] is the raycast hit point.
hit_point_pairs = torch.cat(
[hit_origins[:, None, :], hit_points[:, None, :]], dim=1

Copilot uses AI. Check for mistakes.
Comment on lines +1128 to +1130
if hasattr(self, "_grasp_annotator") is False:
self._grasp_annotator = GraspAnnotator(cfg=cfg)
if hasattr(self, "_hit_point_pairs") is False or cfg.force_regenerate:
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_grasp_pose() caches _grasp_annotator on first call and reuses it thereafter, but it also takes a cfg parameter. If callers pass a different cfg later (e.g., different viser_port / sampling params), it will be silently ignored because the existing annotator isn’t updated/recreated. Consider recreating the annotator when cfg changes (or remove cfg from the method signature and configure via a setter/constructor).

Suggested change
if hasattr(self, "_grasp_annotator") is False:
self._grasp_annotator = GraspAnnotator(cfg=cfg)
if hasattr(self, "_hit_point_pairs") is False or cfg.force_regenerate:
# (Re)create grasp annotator if it does not exist yet or if the
# configuration has changed since the last call.
annotator_needs_update = (
not hasattr(self, "_grasp_annotator")
or not hasattr(self, "_grasp_annotator_cfg")
or self._grasp_annotator_cfg != cfg
)
if annotator_needs_update:
self._grasp_annotator = GraspAnnotator(cfg=cfg)
self._grasp_annotator_cfg = cfg
# Invalidate cached hit point pairs so they will be regenerated
# with the new annotator / configuration.
if hasattr(self, "_hit_point_pairs"):
del self._hit_point_pairs
if not hasattr(self, "_hit_point_pairs") or cfg.force_regenerate:

Copilot uses AI. Check for mistakes.
Comment on lines +246 to +252
grasp_xpos = mug.get_grasp_pose(
approach_direction=torch.tensor(
[0, 0, -1], dtype=torch.float32, device=sim.device
), # gripper approach direction in the world frame
cfg=grasp_cfg,
is_visual=True, # visualize selected grasp pose finally
)
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline comment says the approach_direction is in the mug local frame, but GraspAnnotator.get_approach_grasp_poses() treats approach_direction as a world-frame vector (it compares directly against world-transformed point pairs). Either update the comment (if world-frame is intended) or transform the vector by the mug pose before passing it in.

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +28
cfg: AntipodalSamplerCfg = AntipodalSamplerCfg(),
):
self.mesh: o3d.t.geometry.TriangleMesh | None = None
self.cfg = cfg
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both AntipodalSampler.__init__(cfg: AntipodalSamplerCfg = AntipodalSamplerCfg()) and the dataclass field defaults elsewhere use an instantiated config object as a default argument. If any code mutates cfg, that mutation will be shared across instances. Prefer cfg: AntipodalSamplerCfg | None = None + cfg = cfg or AntipodalSamplerCfg() (or field(default_factory=...) for dataclass fields).

Suggested change
cfg: AntipodalSamplerCfg = AntipodalSamplerCfg(),
):
self.mesh: o3d.t.geometry.TriangleMesh | None = None
self.cfg = cfg
cfg: AntipodalSamplerCfg | None = None,
):
self.mesh: o3d.t.geometry.TriangleMesh | None = None
self.cfg = cfg or AntipodalSamplerCfg()

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +31
@dataclass
class GraspAnnotatorCfg:
viser_port: int = 15531
use_largest_connected_component: bool = False
antipodal_sampler_cfg: AntipodalSamplerCfg = AntipodalSamplerCfg()
force_regenerate: bool = False
max_deviation_angle: float = np.pi / 12

Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GraspAnnotatorCfg.antipodal_sampler_cfg: AntipodalSamplerCfg = AntipodalSamplerCfg() uses a mutable object as a dataclass default. Use field(default_factory=AntipodalSamplerCfg) to avoid sharing the same config instance across GraspAnnotatorCfg objects.

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +19
This script demonstrates the creation and simulation of a robot with a soft object,
and performs a pressing task in a simulated environment.
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module docstring describes a "soft object" and a "pressing task", but this script demonstrates grasp annotation and a mug grasp trajectory. Please update the docstring to match the actual behavior so examples are self-describing.

Suggested change
This script demonstrates the creation and simulation of a robot with a soft object,
and performs a pressing task in a simulated environment.
This script demonstrates a grasping example in simulation: it creates a robot and a
mug object, uses grasp annotation and antipodal grasp sampling utilities, and
executes a mug grasp trajectory in a simulated environment.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 25, 2026 11:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 17 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +398 to +402
convex_mesh_files = ["mesh_hull_1.obj", "mesh_hull_2.obj"]
convex_meshes = load_convex_meshes(convex_mesh_files)
if not convex_meshes:
print("No convex hulls loaded. Exiting.")
return
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main() calls load_convex_meshes(), but that function is not defined/imported in this module, so running main() will fail. Either implement/import it or remove the unused main() benchmark scaffold.

Copilot uses AI. Check for mistakes.
RigidObjectCfg,
URDFCfg,
)
from embodichain.lab.sim.shapes import MeshCfg
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MeshCfg is imported twice in this file. Please remove the duplicate import to avoid confusion.

Suggested change
from embodichain.lab.sim.shapes import MeshCfg

Copilot uses AI. Check for mistakes.
Comment on lines +490 to +528
from embodichain.data import get_data_path

bottle_a_path = get_data_path("ScannedBottle/moliwulong_processed.ply")
bottle_b_path = get_data_path("ScannedBottle/yibao_processed.ply")

bottle_a_mesh = trimesh.load(bottle_a_path)
bottle_b_mesh = trimesh.load(bottle_b_path)
bottle_a_verts = torch.tensor(bottle_a_mesh.vertices, dtype=torch.float32)
bottle_a_faces = torch.tensor(bottle_a_mesh.faces, dtype=torch.int64)
bottle_b_verts = torch.tensor(bottle_b_mesh.vertices, dtype=torch.float32)
bottle_b_faces = torch.tensor(bottle_b_mesh.faces, dtype=torch.int64)

collision_checker = BatchConvexCollisionChecker(bottle_a_verts, bottle_a_faces)
poses = torch.tensor(
[
[
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 1.0],
[0, 0, 0, 1],
],
[
[1, 0, 0, 0.05],
[0, -1, 0, 0],
[0, 0, -1, 0],
[0, 0, 0, 1],
],
]
)
check_cfg = BatchConvexCollisionCheckerCfg(
debug=False,
n_query_mesh_samples=32768,
collsion_threshold=-0.003,
)
collisions, penetrations = collision_checker.query(
bottle_b_verts, bottle_b_faces, poses, cfg=check_cfg
)
print("Collisions:", collisions)
print("Penetrations:", penetrations)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module defines main() but the if __name__ == "__main__": block below doesn't call it, and there is other example code in the file. Please consolidate to a single entry point (or remove the example code) to avoid confusion and bit-rot.

Suggested change
from embodichain.data import get_data_path
bottle_a_path = get_data_path("ScannedBottle/moliwulong_processed.ply")
bottle_b_path = get_data_path("ScannedBottle/yibao_processed.ply")
bottle_a_mesh = trimesh.load(bottle_a_path)
bottle_b_mesh = trimesh.load(bottle_b_path)
bottle_a_verts = torch.tensor(bottle_a_mesh.vertices, dtype=torch.float32)
bottle_a_faces = torch.tensor(bottle_a_mesh.faces, dtype=torch.int64)
bottle_b_verts = torch.tensor(bottle_b_mesh.vertices, dtype=torch.float32)
bottle_b_faces = torch.tensor(bottle_b_mesh.faces, dtype=torch.int64)
collision_checker = BatchConvexCollisionChecker(bottle_a_verts, bottle_a_faces)
poses = torch.tensor(
[
[
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 1.0],
[0, 0, 0, 1],
],
[
[1, 0, 0, 0.05],
[0, -1, 0, 0],
[0, 0, -1, 0],
[0, 0, 0, 1],
],
]
)
check_cfg = BatchConvexCollisionCheckerCfg(
debug=False,
n_query_mesh_samples=32768,
collsion_threshold=-0.003,
)
collisions, penetrations = collision_checker.query(
bottle_b_verts, bottle_b_faces, poses, cfg=check_cfg
)
print("Collisions:", collisions)
print("Penetrations:", penetrations)
main()

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +41
from embodichain.toolkits.graspkit.pg_grasp.antipodal_annotator import (
GraspAnnotator,
GraspAnnotatorCfg,
)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Importing GraspAnnotator (and its dependencies like viser) at module import time makes embodichain.lab.sim.objects.rigid_object depend on the annotator stack even when users never call get_grasp_pose(). Consider moving these imports inside get_grasp_pose() (or guarding with a lazy/optional import) to reduce import-time overhead and optional-dep failures.

Suggested change
from embodichain.toolkits.graspkit.pg_grasp.antipodal_annotator import (
GraspAnnotator,
GraspAnnotatorCfg,
)
try:
from embodichain.toolkits.graspkit.pg_grasp.antipodal_annotator import (
GraspAnnotator,
GraspAnnotatorCfg,
)
except ImportError:
logger.warning(
"Optional dependency 'embodichain.toolkits.graspkit.pg_grasp.antipodal_annotator' "
"could not be imported. Grasp-related functionality may be unavailable."
)
GraspAnnotator = None # type: ignore[assignment]
GraspAnnotatorCfg = None # type: ignore[assignment]

Copilot uses AI. Check for mistakes.
Comment on lines +1187 to +1190
obj_pose=poses[0],
grasp_pose=grasp_poses[0],
open_length=open_lengths[0],
)
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open_lengths contains tensors from get_approach_grasp_poses(), but visualize_grasp_pose() expects open_length: float and uses it in Open3D geometry transforms. Convert to a Python float (e.g., open_length.item()) before passing it to avoid runtime type errors in visualization.

Copilot uses AI. Check for mistakes.
Comment on lines +110 to +112
def create_robot(sim: SimulationManager, position=[0.0, 0.0, 0.0]) -> Robot:
"""
Create and configure a robot with an arm and a dexterous hand in the simulation.
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_robot(sim, position=[...]) uses a mutable list as a default argument. This can lead to surprising behavior if the list is ever mutated; use None default and set a new list inside the function.

Copilot uses AI. Check for mistakes.
Comment on lines +57 to +61
pickle.dump(self.plane_equations, open(self.cache_path, "wb"))
else:
# load precomputed plane equations from cache
self.plane_equations = pickle.load(open(self.cache_path, "rb"))

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caches plane equations using pickle in a user-writable cache path and later loads it with pickle.load(). Pickle is unsafe for untrusted files and can lead to arbitrary code execution if the cache is tampered with. Prefer a safe serialization format (e.g., np.savez/json) or validate contents before loading.

Copilot uses AI. Check for mistakes.
Comment on lines +242 to +246
penetration = -max_over_planes # [B, P]

# A point collides if its penetration exceeds the threshold
collides = penetration > threshold # [B, P]

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collides = penetration > threshold doesn't match the docstring's stated threshold semantics (and flips meaning depending on whether threshold is positive/negative). Please define threshold consistently (e.g., as safety margin outside hull) and update the comparison + docstring accordingly.

Copilot uses AI. Check for mistakes.
raise ValueError(
"ray_origin and surface_origin must have the same number of rays"
)

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_raycast_result() uses self.mesh but doesn't check that it has been initialized (it will be None if sample() wasn't called first). Add a guard with a clear error message (or accept a mesh argument) to avoid confusing NoneType errors.

Suggested change
# Ensure that the mesh has been initialized before performing raycasting
if getattr(self, "mesh", None) is None:
raise ValueError(
"AntipodalSampler mesh is not initialized. "
"Call sample() to initialize the mesh before calling get_raycast_result()."
)

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +26
class BatchConvexCollisionCheckerCfg:
collsion_threshold: float = 0.0
n_query_mesh_samples: int = 4096
debug: bool = False


Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Config field name collsion_threshold is misspelled. Since this is part of the public config API, please rename to collision_threshold (and keep a backwards-compatible alias if needed).

Suggested change
class BatchConvexCollisionCheckerCfg:
collsion_threshold: float = 0.0
n_query_mesh_samples: int = 4096
debug: bool = False
class BatchConvexCollisionCheckerCfg:
# Correctly spelled field name for the collision threshold.
collision_threshold: float = 0.0
# Backwards-compatible alias for the misspelled field name.
collsion_threshold: float = 0.0
n_query_mesh_samples: int = 4096
debug: bool = False
def __post_init__(self) -> None:
"""
Ensure backwards compatibility between the old misspelled
`collsion_threshold` field and the new `collision_threshold` field.
- If only `collsion_threshold` is set, propagate it to
`collision_threshold` and log a deprecation warning.
- If only `collision_threshold` is set, propagate it to
`collsion_threshold` so legacy reads still work.
"""
# Case 1: user provided the old misspelled name.
if (
self.collsion_threshold != 0.0
and self.collision_threshold == 0.0
):
logger.warning(
"BatchConvexCollisionCheckerCfg: `collsion_threshold` is deprecated; "
"please use `collision_threshold` instead."
)
self.collision_threshold = self.collsion_threshold
# Case 2: user provided the new correct name; keep alias in sync.
elif (
self.collision_threshold != 0.0
and self.collsion_threshold == 0.0
):
self.collsion_threshold = self.collision_threshold

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 30, 2026 02:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 14 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +298 to +299
# A point collides if its penetration exceeds the threshold
collides = penetration > threshold # [B, P]
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check_collision_single_hull()’s threshold semantics don’t match the docstring: the doc says points with signed distance to the hull interior <= threshold are colliding, but the implementation uses collides = penetration > threshold, which instead ignores shallow penetrations and never flags near-outside points for positive thresholds. Please clarify intended semantics and adjust either the doc or the condition so threshold behaves as expected.

Suggested change
# A point collides if its penetration exceeds the threshold
collides = penetration > threshold # [B, P]
# A point collides if its signed distance to the hull interior is <= threshold
collides = max_over_planes <= threshold # [B, P]

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +42
from embodichain.toolkits.graspkit.pg_grasp.antipodal_annotator import (
GraspAnnotator,
GraspAnnotatorCfg,
)
import torch.nn.functional as F
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Importing GraspAnnotator at module import time pulls in heavy optional UI dependencies (e.g., viser, trimesh, open3d) whenever RigidObject is imported, even if grasp annotation is never used. To reduce baseline dependencies and import overhead (and avoid failures in minimal/headless installs), consider moving these imports inside get_grasp_pose() and raising a clear error if optional deps are missing.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +93
open_lengths_repeat = open_lengths[:, None].repeat(1, 3)
left_finger_poses = grasp_poses.clone()
left_finger_poses[:, :3, 3] -= left_finger_poses[:, :3, 0] * open_lengths_repeat

right_finger_poses = grasp_poses.clone()
right_finger_poses[:, :3, 3] += (
right_finger_poses[:, :3, 0] * open_lengths_repeat
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open_lengths appears to represent the full gripper opening (it is computed as the distance between antipodal points and used as ±open_length/2 in visualize_grasp_pose). Here each finger is offset by open_length, which makes the effective opening 2*open_length and can over-report collisions. Consider offsetting each finger by open_length / 2 (and updating naming/docs accordingly).

Suggested change
open_lengths_repeat = open_lengths[:, None].repeat(1, 3)
left_finger_poses = grasp_poses.clone()
left_finger_poses[:, :3, 3] -= left_finger_poses[:, :3, 0] * open_lengths_repeat
right_finger_poses = grasp_poses.clone()
right_finger_poses[:, :3, 3] += (
right_finger_poses[:, :3, 0] * open_lengths_repeat
half_open_lengths_repeat = (open_lengths[:, None] * 0.5).repeat(1, 3)
left_finger_poses = grasp_poses.clone()
left_finger_poses[:, :3, 3] -= (
left_finger_poses[:, :3, 0] * half_open_lengths_repeat
)
right_finger_poses = grasp_poses.clone()
right_finger_poses[:, :3, 3] += (
right_finger_poses[:, :3, 0] * half_open_lengths_repeat

Copilot uses AI. Check for mistakes.
Comment on lines +143 to +180
"""Generate grid-sampled points on the surface of an axis-aligned box.

Six faces of the box are each sampled independently on a regular 2-D grid.
Grid resolution per face is derived automatically from ``dense``:
the number of sample points along an edge of length *L* is
``max(2, round(L * dense) + 1)``, so ``dense`` behaves as
*approximate samples per unit length*.

Edge and corner points are shared across adjacent faces and are included
exactly once (no duplicates).

Args:
size: Box dimensions ``(sx, sy, sz)``. Accepts a sequence of three
floats or a 1-D :class:`torch.Tensor` of length 3.
dense: Approximate number of grid sample points per unit length along
each edge. Higher values yield denser point clouds.
device: Target PyTorch device for the returned tensor.

Returns:
Float tensor of shape ``(N, 3)`` containing surface points expressed
in the box's local frame (origin at the box centre).

Example:
>>> pts = box_surface_grid((0.1, 0.06, 0.03), dense=200.0)
>>> pts.shape
torch.Size([..., 3])
"""
if isinstance(size, torch.Tensor):
sx, sy, sz = size[0].item(), size[1].item(), size[2].item()
else:
sx, sy, sz = float(size[0]), float(size[1]), float(size[2])

hx, hy, hz = sx / 2.0, sy / 2.0, sz / 2.0

# ── grid resolution per axis (at least 2 points to span the full edge) ──
nx = max(2, round(sx / dense) + 1)
ny = max(2, round(sy / dense) + 1)
nz = max(2, round(sz / dense) + 1)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

box_surface_grid()’s docstring says the number of samples along an edge is round(L * dense) + 1 (dense = samples per unit length), but the implementation uses round(L / dense) + 1 (dense = spacing). Please align the docstring and variable naming with the actual behavior to avoid callers passing the wrong units.

Copilot uses AI. Check for mistakes.
Comment on lines +389 to +424
valid_mask = (
positive_angle - torch.pi / 2
).abs() <= self.cfg.max_deviation_angle
valid_grasp_x = grasp_x[valid_mask]
valid_centers = centers[valid_mask]

# compute grasp poses using antipodal point pairs and approach direction
valid_grasp_poses = GraspAnnotator._grasp_pose_from_approach_direction(
valid_grasp_x, approach_direction, valid_centers
)
valid_open_lengths = torch.norm(
origin_points_[valid_mask] - hit_points_[valid_mask], dim=-1
)
# select non-collide grasp poses

is_colliding, max_penetration = self._collision_checker.query(
object_pose, valid_grasp_poses, valid_open_lengths
)

# get best grasp pose
valid_grasp_poses = valid_grasp_poses[~is_colliding]
valid_open_lengths = valid_open_lengths[~is_colliding]
valid_centers = valid_centers[~is_colliding]
valid_grasp_x = F.normalize(valid_grasp_poses[:, :3, 0], dim=-1)

cos_angle = torch.clamp(
(valid_grasp_x * approach_direction).sum(dim=-1), -1.0, 1.0
)
positive_angle = torch.abs(torch.acos(cos_angle))
angle_cost = torch.abs(positive_angle - 0.5 * torch.pi) / (0.5 * torch.pi)
center_distance = torch.norm(valid_centers - center, dim=-1)
center_cost = center_distance / center_distance.max()
length_cost = 1 - valid_open_lengths / valid_open_lengths.max()
total_cost = 0.4 * angle_cost + 0.3 * length_cost + 0.3 * center_cost
best_idx = torch.argmin(total_cost)
best_grasp_pose = valid_grasp_poses[best_idx]
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_approach_grasp_poses() can crash when no valid antipodal pairs remain after filtering (e.g., valid_mask is empty or all grasps collide): downstream ops like .max(), .argmin(), and normalizations on empty tensors will raise. Add explicit checks after each filtering stage and return a clear error/None (and have callers handle it) when no feasible grasp is found.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +62
if not os.path.isfile(self.cache_path):
# generate convex hulls and extract plane equations, then cache to disk
self.plane_equations = BatchConvexCollisionChecker._compute_plane_equations(
base_mesh_verts_np, base_mesh_faces_np, max_decomposition_hulls
)
pickle.dump(self.plane_equations, open(self.cache_path, "wb"))
else:
# load precomputed plane equations from cache
self.plane_equations = pickle.load(open(self.cache_path, "rb"))

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache file is written/read without file locking. If multiple processes/threads construct BatchConvexCollisionChecker for the same mesh concurrently, the cache can be corrupted and subsequent loads will fail. Consider writing to a temp file and atomically renaming, and/or using a file lock around cache writes/reads.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +173
# 2) Sample rotation angles in the range [eps, max_angle]
theta = (
torch.rand(n, 1, device=v.device, dtype=v.dtype) * (max_angle - eps) + eps
)

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AntipodalSamplerCfg’s doc says setting max_angle=0 disables random disturbance, but _random_rotate_unit_vectors() still samples a non-zero theta (and the current formula becomes problematic when max_angle < eps). Add an early return when max_angle <= 0 (or clamp the sampling range) so the behavior matches the config contract.

Copilot uses AI. Check for mistakes.
Comment on lines +419 to +422
center_distance = torch.norm(valid_centers - center, dim=-1)
center_cost = center_distance / center_distance.max()
length_cost = 1 - valid_open_lengths / valid_open_lengths.max()
total_cost = 0.4 * angle_cost + 0.3 * length_cost + 0.3 * center_cost
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cost normalization divides by center_distance.max() / valid_open_lengths.max() without guarding for the max being 0 (or the tensors being length 1 with zero variance). This can produce NaNs/infs and make grasp selection unstable. Add an epsilon clamp (e.g., max().clamp_min(eps)) or conditional handling.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +68
def _init_pc_template(self):
self.root_template = box_surface_grid(
size=(
self.cfg.max_open_length,
self.cfg.y_thickness,
self.cfg.root_z_width,
),
dense=self.cfg.rough_dense,
)
self.left_template = box_surface_grid(
size=(self.cfg.x_thickness, self.cfg.y_thickness, self.cfg.finger_length),
dense=self.cfg.rough_dense,
)
self.right_template = box_surface_grid(
size=(self.cfg.x_thickness, self.cfg.y_thickness, self.cfg.finger_length),
dense=self.cfg.rough_dense,
)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gripper point-cloud templates are created on the default box_surface_grid() device (CPU). If grasp_poses are on CUDA, transform_points_batch() will error due to device mismatch. Create templates on cfg.device (or grasp_poses.device) and ensure dtype/device match the incoming poses.

Copilot uses AI. Check for mistakes.
cache_path = self._get_cache_dir(self.vertices, self.triangles)
if os.path.exists(cache_path) and not self.cfg.force_regenerate:
logger.log_info(
f"Found existing antipodal retult. Loading cached antipodal pairs from {cache_path}"
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log message typo: "retult" → "result".

Suggested change
f"Found existing antipodal retult. Loading cached antipodal pairs from {cache_path}"
f"Found existing antipodal result. Loading cached antipodal pairs from {cache_path}"

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 30, 2026 11:33
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 16 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +388 to +430
# filter perpendicular antipodal point
grasp_x = F.normalize(hit_points_ - origin_points_, dim=-1)
cos_angle = torch.clamp((grasp_x * approach_direction).sum(dim=-1), -1.0, 1.0)
positive_angle = torch.abs(torch.acos(cos_angle))
valid_mask = (
positive_angle - torch.pi / 2
).abs() <= self.cfg.max_deviation_angle
valid_grasp_x = grasp_x[valid_mask]
valid_centers = centers[valid_mask]

# compute grasp poses using antipodal point pairs and approach direction
valid_grasp_poses = GraspAnnotator._grasp_pose_from_approach_direction(
valid_grasp_x, approach_direction, valid_centers
)
valid_open_lengths = torch.norm(
origin_points_[valid_mask] - hit_points_[valid_mask], dim=-1
)
# select non-collide grasp poses
is_colliding, max_penetration = self._collision_checker.query(
object_pose,
valid_grasp_poses,
valid_open_lengths,
is_visual=is_visual,
collision_threshold=0.0,
)
# get best grasp pose
valid_grasp_poses = valid_grasp_poses[~is_colliding]
valid_open_lengths = valid_open_lengths[~is_colliding]
valid_centers = valid_centers[~is_colliding]
valid_grasp_x = F.normalize(valid_grasp_poses[:, :3, 0], dim=-1)

cos_angle = torch.clamp(
(valid_grasp_x * approach_direction).sum(dim=-1), -1.0, 1.0
)
positive_angle = torch.abs(torch.acos(cos_angle))
angle_cost = torch.abs(positive_angle - 0.5 * torch.pi) / (0.5 * torch.pi)
center_distance = torch.norm(valid_centers - mesh_center, dim=-1)
center_cost = center_distance / center_distance.max()
length_cost = 1 - valid_open_lengths / valid_open_lengths.max()
total_cost = 0.3 * angle_cost + 0.3 * length_cost + 0.4 * center_cost
best_idx = torch.argmin(total_cost)
best_grasp_pose = valid_grasp_poses[best_idx]
best_open_length = valid_open_lengths[best_idx]
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_approach_grasp_poses() doesn't handle the case where no grasps survive filtering/collision checking. If valid_mask is empty or all candidates collide, the code will hit .max() on empty tensors and argmin will error. Please add an explicit check and return a sentinel/raise a clear exception when no valid grasp can be found.

Copilot uses AI. Check for mistakes.
Comment on lines +550 to +557
cfg = GraspAnnotatorCfg(
force_regenerate=True,
)
tool = GraspAnnotator(cfg=cfg)
hit_point_pairs = tool.annotate(
vertices=torch.from_numpy(vertices).float(),
triangles=torch.from_numpy(triangles).long(),
)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main() CLI wiring is inconsistent with the GraspAnnotator API: GraspAnnotator requires vertices and triangles in __init__, but main() instantiates it with only cfg, and then calls tool.annotate(vertices=..., triangles=...) even though annotate() takes no parameters. As written, running this module as a script will crash; please align main() with the actual class signature.

Suggested change
cfg = GraspAnnotatorCfg(
force_regenerate=True,
)
tool = GraspAnnotator(cfg=cfg)
hit_point_pairs = tool.annotate(
vertices=torch.from_numpy(vertices).float(),
triangles=torch.from_numpy(triangles).long(),
)
vertices_tensor = torch.from_numpy(vertices).float()
triangles_tensor = torch.from_numpy(triangles).long()
cfg = GraspAnnotatorCfg(
force_regenerate=True,
)
tool = GraspAnnotator(
cfg=cfg,
vertices=vertices_tensor,
triangles=triangles_tensor,
)
hit_point_pairs = tool.annotate()

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +170
@staticmethod
def batch_point_convex_query(
plane_equations: torch.Tensor,
plane_equation_counts: torch.Tensor,
batch_points: torch.Tensor,
device: torch.device,
collision_threshold: float = -0.003,
):
plane_equations_wp = wp.from_torch(plane_equations)
plane_equation_counts_wp = wp.from_torch(plane_equation_counts)
batch_points_wp = wp.from_torch(batch_points)

n_pose = batch_points.shape[0]
n_point = batch_points.shape[1]
n_convex = plane_equations.shape[0]
point_convex_signed_distance_wp = wp.full(
shape=(n_pose, n_point, n_convex),
value=-float("inf"),
dtype=float,
device=standardize_device_string(device),
) # [n_pose, n_point, n_convex]
wp.launch(
kernel=convex_signed_distance_kernel,
dim=(n_pose, n_point, n_convex),
inputs=(batch_points_wp, plane_equations_wp, plane_equation_counts_wp),
outputs=(point_convex_signed_distance_wp,),
device=standardize_device_string(device),
)
point_convex_signed_distance = wp.to_torch(point_convex_signed_distance_wp)
# import ipdb; ipdb.set_trace()
point_signed_distance = point_convex_signed_distance.min(
dim=-1
).values # [n_pose, n_point]
is_point_collide = point_signed_distance <= collision_threshold
return point_signed_distance, is_point_collide

def query_batch_points(
self,
batch_points: torch.Tensor,
collision_threshold: float = 0.0,
is_visual: bool = False,
):
n_batch = batch_points.shape[0]
point_signed_distance, is_point_collide = (
BatchConvexCollisionChecker.batch_point_convex_query(
self.plane_equations["plane_equations"],
self.plane_equations["plane_equation_counts"],
batch_points,
device=self.device,
collision_threshold=collision_threshold,
)
)
is_pose_collide = is_point_collide.any(dim=-1) # [B]
pose_surface_distance = point_signed_distance.min(dim=-1).values # [B]
if is_visual:
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New collision-checking logic (convex decomposition caching + Warp kernel query) is introduced here but there are no automated tests covering it. Since the repo already has graspkit tests (tests/toolkits/test_pg_grasp.py), please add unit tests for BatchConvexCollisionChecker.query_batch_points() (e.g., on a simple box mesh with known inside/outside points) to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +17
The tutorial corresponds to the ``grasp_generator.py`` script in the ``scripts/tutorials/grasp`` directory.

.. dropdown:: Code for grasp_generator.py
:icon: code

.. literalinclude:: ../../../scripts/tutorials/grasp/grasp_generator.py
:language: python
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tutorial references scripts/tutorials/grasp/grasp_generator.py via multiple literalinclude directives, but there is no such script in the repo (only grasp_mug.py exists under scripts/tutorials/grasp). This will break the Sphinx build; either add the referenced script or update the doc to include the correct filename/path.

Copilot uses AI. Check for mistakes.
) -> torch.Tensor:
inv_obj_pose = obj_pose.clone()
inv_obj_pose[:3, :3] = obj_pose[:3, :3].T
inv_obj_pose[:3, 3] = -obj_pose[:3, 3] @ obj_pose[:3, :3]
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inverse transform for obj_pose is computed incorrectly: after setting inv_obj_pose[:3,:3] = R^T, the translation should be -R^T @ t, but the code uses -(t @ R), which is not equivalent for column-vector convention. This will yield wrong gripper point clouds in object frame and can invalidate collision results.

Suggested change
inv_obj_pose[:3, 3] = -obj_pose[:3, 3] @ obj_pose[:3, :3]
inv_obj_pose[:3, 3] = -(obj_pose[:3, :3].T @ obj_pose[:3, 3])

Copilot uses AI. Check for mistakes.
Comment on lines +200 to +225
) -> Tuple[torch.Tensor, torch.Tensor]:
query_mesh = trimesh.Trimesh(
vertices=query_mesh_verts.to("cpu").numpy(),
faces=query_mesh_faces.to("cpu").numpy(),
)
n_query = cfg.n_query_mesh_samples
n_batch = poses.shape[0]
query_points_np = query_mesh.sample(n_query).astype(np.float32)
query_points = torch.tensor(
query_points_np, device=poses.device
) # [n_query, 3]
penetration_result = torch.zeros(size=(n_batch, n_query), device=poses.device)
penetration_result.fill_(-float("inf"))
collision_result = torch.zeros(
size=(n_batch, n_query), dtype=torch.bool, device=poses.device
)
collision_result.fill_(False)
for normals, offsets in self.plane_equations:
normals_torch = torch.tensor(normals, device=poses.device)
offsets_torch = torch.tensor(offsets, device=poses.device)
penetration, collides = check_collision_single_hull(
normals_torch,
offsets_torch,
transform_points_batch(query_points, poses),
cfg.collsion_threshold,
)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

query() iterates with for normals, offsets in self.plane_equations:, but self.plane_equations is a dict (with keys plane_equations / plane_equation_counts) as set in __init__. This loop will iterate over the dict keys and crash when trying to unpack. Either remove query() if it's obsolete, or update it to use the packed tensors in self.plane_equations[...] (or store the original list separately).

Copilot uses AI. Check for mistakes.
Comment on lines +187 to +189
nx = max(2, round(sx / dense) + 1)
ny = max(2, round(sy / dense) + 1)
nz = max(2, round(sz / dense) + 1)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

box_surface_grid()'s docstring defines dense as "samples per unit length" with a formula round(L * dense) + 1, but the implementation uses round(L / dense) + 1. Please either fix the implementation or update the docstring/parameter name (e.g., treat it as a spacing) so users configure it correctly.

Suggested change
nx = max(2, round(sx / dense) + 1)
ny = max(2, round(sy / dense) + 1)
nz = max(2, round(sz / dense) + 1)
nx = max(2, round(sx * dense) + 1)
ny = max(2, round(sy * dense) + 1)
nz = max(2, round(sz * dense) + 1)

Copilot uses AI. Check for mistakes.
Comment on lines +1167 to +1168
grasp_poses: tuple[torch.Tensor] = []
open_lengths: tuple[torch.Tensor] = []
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type annotations are misleading here: grasp_poses: tuple[torch.Tensor] = [] and open_lengths: tuple[torch.Tensor] = [] are initialized as lists and later appended to. Prefer list[torch.Tensor] (or build tensors directly) to avoid type confusion for readers and static checkers.

Suggested change
grasp_poses: tuple[torch.Tensor] = []
open_lengths: tuple[torch.Tensor] = []
grasp_poses: list[torch.Tensor] = []
open_lengths: list[torch.Tensor] = []

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +20
"""
This script demonstrates the creation and simulation of a robot with a soft object,
and performs a pressing task in a simulated environment.
"""
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions an example examples/sim/demo/grasp_mug.py, but no such file exists in the repo; this PR adds scripts/tutorials/grasp/grasp_mug.py instead. Please either update the PR description (and docs) or add the example at the stated path to avoid confusion for users.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +101
open_lengths_repeat = (
open_lengths[:, None] + self.cfg.open_check_margin
).repeat(1, 3)
left_finger_poses = grasp_poses.clone()
left_finger_poses[:, :3, 3] -= left_finger_poses[:, :3, 0] * open_lengths_repeat

right_finger_poses = grasp_poses.clone()
right_finger_poses[:, :3, 3] += (
right_finger_poses[:, :3, 0] * open_lengths_repeat
)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_get_gripper_pc() offsets finger poses by open_lengths along the gripper x-axis, but open_lengths is described/used elsewhere as the jaw opening width (distance between contact points). Typically each finger should be offset by open_length/2 from the centerline; otherwise the modeled gripper opening becomes 2× too large and collision checking will be inaccurate.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 30, 2026 11:42
@matafela matafela changed the title Draft: Add grasp annotator Add grasp annotator Mar 30, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 10 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +374 to +443
def get_grasp_poses(
self,
hit_point_pairs: torch.Tensor,
object_pose: torch.Tensor,
approach_direction: torch.Tensor,
is_visual: bool = False,
) -> torch.Tensor:
"""Get grasp pose given approach direction

Args:
hit_point_pairs (torch.Tensor): (N, 2, 3) tensor of N antipodal point pairs. Each pair consists of a hit point and its corresponding surface point.
object_pose (torch.Tensor): (4, 4) homogeneous transformation matrix representing the pose of the object in the world frame.
approach_direction (torch.Tensor): (3,) unit vector representing the desired approach direction of the gripper in the world frame.

Returns:
torch.Tensor: (4, 4) homogeneous transformation matrix representing the grasp pose in the world frame that aligns the gripper's approach direction with the given approach_direction. Returns None if no valid grasp pose can be found.
"""
origin_points = hit_point_pairs[:, 0, :]
hit_points = hit_point_pairs[:, 1, :]
origin_points_ = self._apply_transform(origin_points, object_pose)
hit_points_ = self._apply_transform(hit_points, object_pose)
centers = (origin_points_ + hit_points_) / 2

mesh_vert_transformed = self._apply_transform(self.vertices, object_pose)
mesh_center = mesh_vert_transformed.mean(dim=0)

# filter perpendicular antipodal point
grasp_x = F.normalize(hit_points_ - origin_points_, dim=-1)
cos_angle = torch.clamp((grasp_x * approach_direction).sum(dim=-1), -1.0, 1.0)
positive_angle = torch.abs(torch.acos(cos_angle))
valid_mask = (
positive_angle - torch.pi / 2
).abs() <= self.cfg.max_deviation_angle
valid_grasp_x = grasp_x[valid_mask]
valid_centers = centers[valid_mask]

# compute grasp poses using antipodal point pairs and approach direction
valid_grasp_poses = GraspAnnotator._grasp_pose_from_approach_direction(
valid_grasp_x, approach_direction, valid_centers
)
valid_open_lengths = torch.norm(
origin_points_[valid_mask] - hit_points_[valid_mask], dim=-1
)
# select non-collide grasp poses
is_colliding, max_penetration = self._collision_checker.query(
object_pose,
valid_grasp_poses,
valid_open_lengths,
is_visual=is_visual,
collision_threshold=0.0,
)
# get best grasp pose
valid_grasp_poses = valid_grasp_poses[~is_colliding]
valid_open_lengths = valid_open_lengths[~is_colliding]
valid_centers = valid_centers[~is_colliding]
valid_grasp_x = F.normalize(valid_grasp_poses[:, :3, 0], dim=-1)

cos_angle = torch.clamp(
(valid_grasp_x * approach_direction).sum(dim=-1), -1.0, 1.0
)
positive_angle = torch.abs(torch.acos(cos_angle))
angle_cost = torch.abs(positive_angle - 0.5 * torch.pi) / (0.5 * torch.pi)
center_distance = torch.norm(valid_centers - mesh_center, dim=-1)
center_cost = center_distance / center_distance.max()
length_cost = 1 - valid_open_lengths / valid_open_lengths.max()
total_cost = 0.3 * angle_cost + 0.3 * length_cost + 0.4 * center_cost
best_idx = torch.argmin(total_cost)
best_grasp_pose = valid_grasp_poses[best_idx]
best_open_length = valid_open_lengths[best_idx]
return best_grasp_pose, best_open_length
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_grasp_poses() is annotated/ documented as returning a single (4,4) tensor (and “None if no valid grasp”), but it actually returns (best_grasp_pose, best_open_length) and will crash if valid_mask is empty or if all grasps are filtered out as colliding (e.g. .max() on empty tensors). Please (1) fix the return type/docstring, and (2) add explicit handling for “no valid grasps” with a clear exception or a None/sentinel return.

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +213
def query_batch_points(
self,
batch_points: torch.Tensor,
collision_threshold: float = 0.0,
is_visual: bool = False,
) -> torch.Tensor:
""" Query collision status for a batch of point clouds. The collision status is determined by checking if the signed distance from any point in the cloud to the convex hulls is less than or equal to the specified collision threshold.
Args:
batch_points: [B, n_point, 3] batch of point clouds to query for collision status.
collision_threshold: Collision threshold in meters. A point is considered colliding if its signed distance to the hull interior is <= this threshold. This allows for a margin of error in collision checking, where a small positive threshold can be used to consider points near the surface as colliding, and a small negative threshold can be used to allow for slight penetration without considering it a collision.
is_visual: Whether to visualize the collision checking results for debugging purposes. If set to True, the code will generate visualizations of the query points colored by their collision status (e.g., red for colliding points and green for non-colliding points) along with the original mesh. This can help in understanding and verifying the collision checking process, especially during development and testing.
Returns:
is_pose_collide: [B, ] boolean tensor indicating whether each point cloud in the"""
n_batch = batch_points.shape[0]
point_signed_distance, is_point_collide = (
BatchConvexCollisionChecker.batch_point_convex_query(
self.plane_equations["plane_equations"],
self.plane_equations["plane_equation_counts"],
batch_points,
device=self.device,
collision_threshold=collision_threshold,
)
)
is_pose_collide = is_point_collide.any(dim=-1) # [B]
pose_surface_distance = point_signed_distance.min(dim=-1).values # [B]
if is_visual:
# visualize result
frame = o3d.geometry.TriangleMesh.create_coordinate_frame(size=0.1)
for i in range(n_batch):
query_points_o3d = o3d.geometry.PointCloud()
query_points_np = batch_points[i].cpu().numpy()
query_points_o3d.points = o3d.utility.Vector3dVector(query_points_np)
query_points_color = np.zeros_like(query_points_np)
query_points_color[is_point_collide[i].cpu().numpy()] = [
1.0,
0,
0,
] # red for colliding points
query_points_color[~is_point_collide[i].cpu().numpy()] = [
0,
1.0,
0,
] # green for non-colliding points
query_points_o3d.colors = o3d.utility.Vector3dVector(query_points_color)
o3d.visualization.draw_geometries(
[self.mesh, query_points_o3d, frame], mesh_show_back_face=True
)
return is_pose_collide, pose_surface_distance

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New collision-checking code paths (BatchConvexCollisionChecker.query_batch_points, SimpleGripperCollisionChecker.query, and box_surface_grid) aren’t covered by unit tests, and there is already a tests/toolkits/test_pg_grasp.py suite for graspkit. Adding a small deterministic test (e.g., a box/sphere mesh with a known colliding/non-colliding point cloud) would prevent regressions in the distance sign convention and point-cloud sampling density.

Copilot uses AI. Check for mistakes.
Comment on lines +198 to +200
nx = max(2, round(sx / dense) + 1)
ny = max(2, round(sy / dense) + 1)
nz = max(2, round(sz / dense) + 1)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

box_surface_grid() computes grid resolution using round(sx / dense), but the docstring defines dense as “samples per unit length” and the formula should scale with size (round(sx * dense)). As written, increasing dense makes the point cloud sparser, which breaks collision-check fidelity.

Suggested change
nx = max(2, round(sx / dense) + 1)
ny = max(2, round(sy / dense) + 1)
nz = max(2, round(sz / dense) + 1)
nx = max(2, round(sx * dense) + 1)
ny = max(2, round(sy * dense) + 1)
nz = max(2, round(sz * dense) + 1)

Copilot uses AI. Check for mistakes.
"plane_equations": plane_equations,
"plane_equation_counts": plane_equations_counts,
}
pickle.dump(self.plane_equations, open(self.cache_path, "wb"))
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The convex decomposition cache is pickling torch.Tensors that are created on self.device (which may be CUDA). Pickling CUDA tensors can make the cache non-portable (e.g., loading on CPU-only machines) and can be brittle across device indices. Prefer saving CPU tensors / numpy arrays (or torch.save with map_location='cpu') and moving to self.device after loading.

Suggested change
pickle.dump(self.plane_equations, open(self.cache_path, "wb"))
# Save CPU tensors to make the cache portable across devices
plane_equations_cpu = {
"plane_equations": plane_equations.cpu(),
"plane_equation_counts": plane_equations_counts.cpu(),
}
pickle.dump(plane_equations_cpu, open(self.cache_path, "wb"))

Copilot uses AI. Check for mistakes.
) -> torch.Tensor:
inv_obj_pose = obj_pose.clone()
inv_obj_pose[:3, :3] = obj_pose[:3, :3].T
inv_obj_pose[:3, 3] = -obj_pose[:3, 3] @ obj_pose[:3, :3]
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The object-pose inverse is computed incorrectly. For a standard homogeneous transform [R t; 0 1], the inverse translation should be -Rᵀ @ t, not -t @ R. This will place the gripper point cloud in the wrong frame and invalidate collision results. Consider using the existing embodichain.utils.math.pose_inv() helper to avoid convention mistakes.

Suggested change
inv_obj_pose[:3, 3] = -obj_pose[:3, 3] @ obj_pose[:3, :3]
inv_obj_pose[:3, 3] = -obj_pose[:3, :3].T @ obj_pose[:3, 3]

Copilot uses AI. Check for mistakes.
Comment on lines +563 to +569
force_regenerate=True,
)
tool = GraspAnnotator(cfg=cfg)
hit_point_pairs = tool.annotate(
vertices=torch.from_numpy(vertices).float(),
triangles=torch.from_numpy(triangles).long(),
)
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main() example is currently broken: GraspAnnotator is instantiated without the required vertices/triangles args, and annotate() is called with vertices=/triangles= keyword args even though annotate() takes no parameters. Update main() to match the current GraspAnnotator API (or adjust the class API to match the intended CLI).

Suggested change
force_regenerate=True,
)
tool = GraspAnnotator(cfg=cfg)
hit_point_pairs = tool.annotate(
vertices=torch.from_numpy(vertices).float(),
triangles=torch.from_numpy(triangles).long(),
)
viser_port=args.port,
use_largest_connected_component=args.largest_component,
force_regenerate=True,
)
tool = GraspAnnotator(
cfg=cfg,
vertices=torch.from_numpy(vertices).float(),
triangles=torch.from_numpy(triangles).long(),
)
hit_point_pairs = tool.annotate()

Copilot uses AI. Check for mistakes.
Comment on lines +1180 to +1183
vertices = self._entities[0].get_vertices()
triangles = self._entities[0].get_triangles()
scale = self._entities[0].get_body_scale()
vertices = vertices * scale
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the is_visual block, vertices, triangles, and scale are recomputed but never used (the visualizer uses self._grasp_annotator’s stored mesh). This is dead code and can be removed to avoid confusion and extra work on large meshes.

Suggested change
vertices = self._entities[0].get_vertices()
triangles = self._entities[0].get_triangles()
scale = self._entities[0].get_body_scale()
vertices = vertices * scale

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +49
@dataclass
class BatchConvexCollisionCheckerCfg:
""" Configuration for BatchConvexCollisionChecker."""

collsion_threshold: float = 0.0
""" Collision threshold in meters. A point is considered colliding if its signed distance to the hull interior is <= this threshold. This allows for a margin of error in collision checking, where a small positive threshold can be used to consider points near the surface as colliding, and a small negative threshold can be used to allow for slight penetration without considering it a collision."""
n_query_mesh_samples: int = 4096
""" Number of points to sample from the query mesh surface for collision checking. A higher number of samples can provide a more accurate collision check at the cost of increased computation time. The optimal number may depend on the complexity of the mesh and the required precision of collision detection."""
debug: bool = False
""" Whether to visualize the collision checking results for debugging purposes. If set to True, the code will generate visualizations of the query points colored by their collision status (e.g., red for colliding points and green for non-colliding points) along with the original mesh. This can help in understanding and verifying the collision checking process, especially during development and testing."""


Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BatchConvexCollisionCheckerCfg exposes collsion_threshold (typo) as part of the public config API. This is easy to miss and forces callers to use the misspelling. Rename to collision_threshold (and consider a backwards-compatible alias if needed).

Suggested change
@dataclass
class BatchConvexCollisionCheckerCfg:
""" Configuration for BatchConvexCollisionChecker."""
collsion_threshold: float = 0.0
""" Collision threshold in meters. A point is considered colliding if its signed distance to the hull interior is <= this threshold. This allows for a margin of error in collision checking, where a small positive threshold can be used to consider points near the surface as colliding, and a small negative threshold can be used to allow for slight penetration without considering it a collision."""
n_query_mesh_samples: int = 4096
""" Number of points to sample from the query mesh surface for collision checking. A higher number of samples can provide a more accurate collision check at the cost of increased computation time. The optimal number may depend on the complexity of the mesh and the required precision of collision detection."""
debug: bool = False
""" Whether to visualize the collision checking results for debugging purposes. If set to True, the code will generate visualizations of the query points colored by their collision status (e.g., red for colliding points and green for non-colliding points) along with the original mesh. This can help in understanding and verifying the collision checking process, especially during development and testing."""
@dataclass(init=False)
class BatchConvexCollisionCheckerCfg:
""" Configuration for BatchConvexCollisionChecker."""
collision_threshold: float = 0.0
""" Collision threshold in meters. A point is considered colliding if its signed distance to the hull interior is <= this threshold. This allows for a margin of error in collision checking, where a small positive threshold can be used to consider points near the surface as colliding, and a small negative threshold can be used to allow for slight penetration without considering it a collision."""
n_query_mesh_samples: int = 4096
""" Number of points to sample from the query mesh surface for collision checking. A higher number of samples can provide a more accurate collision check at the cost of increased computation time. The optimal number may depend on the complexity of the mesh and the required precision of collision detection."""
debug: bool = False
""" Whether to visualize the collision checking results for debugging purposes. If set to True, the code will generate visualizations of the query points colored by their collision status (e.g., red for colliding points and green for non-colliding points) along with the original mesh. This can help in understanding and verifying the collision checking process, especially during development and testing."""
def __init__(
self,
collision_threshold: float = 0.0,
n_query_mesh_samples: int = 4096,
debug: bool = False,
collsion_threshold: Union[float, None] = None,
):
"""
Initialize the configuration.
Parameters
----------
collision_threshold:
Preferred, correctly spelled collision threshold in meters.
n_query_mesh_samples:
Number of points to sample from the query mesh surface.
debug:
Whether to enable debugging visualizations.
collsion_threshold:
Deprecated alias for ``collision_threshold`` kept for backward compatibility.
"""
# Resolve threshold value, preferring the deprecated alias if explicitly provided.
if collsion_threshold is not None and collsion_threshold != collision_threshold:
logger.warning(
"BatchConvexCollisionCheckerCfg: both 'collision_threshold' and "
"'collsion_threshold' were provided; using value from deprecated "
"'collsion_threshold'."
)
effective_threshold = collsion_threshold
elif collsion_threshold is not None:
# Only deprecated alias provided
logger.warning(
"BatchConvexCollisionCheckerCfg: 'collsion_threshold' is deprecated; "
"use 'collision_threshold' instead."
)
effective_threshold = collsion_threshold
else:
effective_threshold = collision_threshold
self.collision_threshold = effective_threshold
# Backward-compatible attribute for any code still referencing the typo.
self.collsion_threshold = effective_threshold
self.n_query_mesh_samples = n_query_mesh_samples
self.debug = debug

Copilot uses AI. Check for mistakes.
Comment on lines +175 to +179
# 2) Sample rotation angles in the range [eps, max_angle]
theta = (
torch.rand(n, 1, device=v.device, dtype=v.dtype) * (max_angle - eps) + eps
)

Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_random_rotate_unit_vectors() does not actually disable disturbance when max_angle=0 (it still samples a small non-zero angle because of the eps term and because the sampling range becomes [0, eps]). Either update the docstring/config expectations, or explicitly short-circuit when max_angle <= 0 so the function returns the input vectors unchanged.

Copilot uses AI. Check for mistakes.
# ----------------------------------------------------------------------------

import warp as wp
from typing import Any
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any is imported but unused in this module. Consider removing it to keep the kernel module minimal and avoid unused-import noise in editors/static analysis.

Suggested change
from typing import Any

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants