Skip to content

Commit 7bf99d8

Browse files
authored
Collision Constraints for Objects (#460)
## Summary Add NoCollision relation and loss strategy ## Detailed description Add NoCollision relation Add NoCollisionLossStrategy Add Isaac Sim NoCollision example Add GR1 table multi-object NoCollision environment
1 parent 5c4a5bf commit 7bf99d8

15 files changed

Lines changed: 904 additions & 26 deletions

isaaclab_arena/environments/arena_env_builder.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
from isaaclab_arena.metrics.recorder_manager_utils import metrics_to_recorder_manager_cfg
2828
from isaaclab_arena.relations.object_placer import ObjectPlacer
29+
from isaaclab_arena.relations.relations import IsAnchor, NoCollision
2930
from isaaclab_arena.tasks.no_task import NoTask
3031
from isaaclab_arena.utils.configclass import combine_configclass_instances
3132
from isaaclab_arena.utils.multiprocess import get_local_rank
@@ -67,12 +68,23 @@ def _get_objects_with_relations(self) -> list[Object | ObjectReference]:
6768
objects_with_relations.append(asset)
6869
return objects_with_relations
6970

71+
def _add_pairwise_no_collision(self, objects_with_relations: list[Object | ObjectReference]) -> None:
72+
"""Add NoCollision between every pair of non-anchor objects (if not already present)."""
73+
non_anchors = [
74+
obj for obj in objects_with_relations if not any(isinstance(r, IsAnchor) for r in obj.get_relations())
75+
]
76+
for i, obj_a in enumerate(non_anchors):
77+
for obj_b in non_anchors[i + 1 :]:
78+
has_no_collision = any(isinstance(r, NoCollision) and r.parent is obj_b for r in obj_a.get_relations())
79+
if not has_no_collision:
80+
obj_a.add_relation(NoCollision(obj_b))
81+
7082
def _solve_relations(self) -> None:
7183
"""Solve spatial relations for objects in the scene.
7284
7385
This method:
7486
1. Collects all objects from the scene that have relations
75-
2. Finds the anchor object (marked with IsAnchor)
87+
2. Adds NoCollision between every pair of non-anchor objects (if not already present)
7688
3. Runs the ObjectPlacer to solve spatial constraints
7789
4. Applies solved positions to objects
7890
"""
@@ -83,7 +95,9 @@ def _solve_relations(self) -> None:
8395
print("No objects with relations found in scene. Skipping relation solving.")
8496
return
8597

86-
# Run the ObjectPlacer
98+
self._add_pairwise_no_collision(objects_with_relations)
99+
100+
# Run the ObjectPlacer (default on_relation_z_tolerance_m accommodates solver residual).
87101
placer = ObjectPlacer()
88102
result = placer.place(objects=objects_with_relations)
89103

isaaclab_arena/evaluation/policy_runner.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ def rollout_policy(
111111
raise RuntimeError(f"Error rolling out policy: {e}")
112112

113113
else:
114-
# only compute metrics if env has metrics registered
115-
if hasattr(env.cfg, "metrics"):
114+
# Only compute metrics if env has a non-None metrics list (e.g. NoTask leaves metrics as None).
115+
if hasattr(env.cfg, "metrics") and env.cfg.metrics is not None:
116116
# NOTE(xinjieyao, 2025-10-07): lazy import to prevent app stalling caused by omni.kit
117117
from isaaclab_arena.metrics.metrics import compute_metrics
118118

isaaclab_arena/relations/loss_primitives.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,41 @@ def single_point_linear_loss(
122122
target = torch.tensor(target, dtype=value.dtype, device=value.device)
123123

124124
return slope * torch.abs(value - target)
125+
126+
127+
def interval_overlap_axis_loss(
128+
child_min: torch.Tensor,
129+
child_max: torch.Tensor,
130+
parent_min: torch.Tensor | float,
131+
parent_max: torch.Tensor | float,
132+
slope: float = 1.0,
133+
) -> torch.Tensor:
134+
"""ReLU-style interval overlap: zero when separated, slope * overlap length otherwise.
135+
136+
Used by NoCollisionLossStrategy for per-axis overlap. Intervals [child_min, child_max]
137+
and [parent_min, parent_max]; loss is zero when they do not overlap, else
138+
slope * overlap_length.
139+
140+
Args:
141+
child_min: Child interval min (tensor for gradient flow).
142+
child_max: Child interval max (tensor).
143+
parent_min: Parent interval min (tensor or float).
144+
parent_max: Parent interval max (tensor or float).
145+
slope: Gradient magnitude (default: 1.0).
146+
147+
Returns:
148+
Zero when intervals are separated; otherwise slope * overlap length.
149+
"""
150+
assert isinstance(child_min, torch.Tensor), f"child_min must be a torch.Tensor, got {type(child_min)}"
151+
assert isinstance(child_max, torch.Tensor), f"child_max must be a torch.Tensor, got {type(child_max)}"
152+
assert torch.all(child_min <= child_max), "child_min must be <= child_max for valid interval [child_min, child_max]"
153+
if not isinstance(parent_min, torch.Tensor):
154+
parent_min = torch.tensor(parent_min, dtype=child_min.dtype, device=child_min.device)
155+
if not isinstance(parent_max, torch.Tensor):
156+
parent_max = torch.tensor(parent_max, dtype=child_max.dtype, device=child_max.device)
157+
assert torch.all(
158+
parent_min <= parent_max
159+
), "parent_min must be <= parent_max for valid interval [parent_min, parent_max]"
160+
overlap_high = torch.minimum(child_max, parent_max)
161+
overlap_low = torch.maximum(child_min, parent_min)
162+
return single_boundary_linear_loss(overlap_high, overlap_low, slope=slope, penalty_side="greater")

isaaclab_arena/relations/object_placer.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams
1212
from isaaclab_arena.relations.placement_result import PlacementResult
1313
from isaaclab_arena.relations.relation_solver import RelationSolver
14-
from isaaclab_arena.relations.relations import RandomAroundSolution, RotateAroundSolution, get_anchor_objects
14+
from isaaclab_arena.relations.relations import On, RandomAroundSolution, RotateAroundSolution, get_anchor_objects
1515
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox, get_random_pose_within_bounding_box
1616
from isaaclab_arena.utils.pose import Pose
1717

@@ -185,20 +185,51 @@ def _generate_initial_positions(
185185
positions[obj] = random_pose.position_xyz
186186
return positions
187187

188-
def _validate_placement(
188+
def _validate_on_relations(
189189
self,
190190
positions: dict[Object | ObjectReference, tuple[float, float, float]],
191191
) -> bool:
192-
"""Validate that no two objects overlap in 3D.
193-
194-
Checks all object pairs for axis-aligned bounding box overlap.
192+
"""Validate each On relation; logic matches OnLossStrategy (relation_loss_strategies.py).
195193
196-
Args:
197-
positions: Dictionary mapping objects to their solved (x, y, z) positions.
198-
199-
Returns:
200-
True if no overlaps exist, False otherwise.
194+
1. X: child's footprint entirely within parent's X extent.
195+
2. Y: child's footprint entirely within parent's Y extent.
196+
3. Z: child_bottom in (parent_top, parent_top+clearance_m], within on_relation_z_tolerance_m.
201197
"""
198+
for obj in positions:
199+
for rel in obj.get_relations():
200+
if not isinstance(rel, On):
201+
continue
202+
parent = rel.parent
203+
if parent not in positions:
204+
continue
205+
child_world = obj.get_bounding_box().translated(positions[obj])
206+
parent_world = parent.get_bounding_box().translated(positions[parent])
207+
# 1 & 2: Same as OnLossStrategy X/Y band (child's footprint within parent).
208+
if (
209+
child_world.min_point[0] < parent_world.min_point[0]
210+
or child_world.max_point[0] > parent_world.max_point[0]
211+
or child_world.min_point[1] < parent_world.min_point[1]
212+
or child_world.max_point[1] > parent_world.max_point[1]
213+
):
214+
if self.params.verbose:
215+
print(f" On relation: '{obj.name}' XY outside parent (retrying)")
216+
return False
217+
# 3. Z: same as OnLossStrategy; child_bottom in (parent_top, parent_top+clearance_m], within on_relation_z_tolerance_m.
218+
parent_top_z = parent_world.max_point[2]
219+
clearance_m = rel.clearance_m
220+
child_bottom_z = child_world.min_point[2]
221+
eps_z = self.params.on_relation_z_tolerance_m
222+
if child_bottom_z <= parent_top_z - eps_z or child_bottom_z > parent_top_z + clearance_m + eps_z:
223+
if self.params.verbose:
224+
print(f" On relation: '{obj.name}' Z outside band (retrying)")
225+
return False
226+
return True
227+
228+
def _validate_no_overlap(
229+
self,
230+
positions: dict[Object | ObjectReference, tuple[float, float, float]],
231+
) -> bool:
232+
"""Check that no two objects overlap in 3D (axis-aligned bbox with margin)."""
202233
objects = list(positions.keys())
203234
for i in range(len(objects)):
204235
for j in range(i + 1, len(objects)):
@@ -211,9 +242,22 @@ def _validate_placement(
211242
if self.params.verbose:
212243
print(f" Overlap between '{a.name}' and '{b.name}'")
213244
return False
214-
215245
return True
216246

247+
def _validate_placement(
248+
self,
249+
positions: dict[Object | ObjectReference, tuple[float, float, float]],
250+
) -> bool:
251+
"""Validate that no two objects overlap in 3D and On relations are satisfied.
252+
253+
Args:
254+
positions: Dictionary mapping objects to their solved (x, y, z) positions.
255+
256+
Returns:
257+
True if no overlaps exist and On relations hold, False otherwise.
258+
"""
259+
return self._validate_no_overlap(positions) and self._validate_on_relations(positions)
260+
217261
def _apply_positions(
218262
self,
219263
positions: dict[Object | ObjectReference, tuple[float, float, float]],

isaaclab_arena/relations/object_placer_params.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,7 @@ class ObjectPlacerParams:
3838
"""Minimum separation (meters) required between object bounding boxes.
3939
Set to 0.0 to only reject actual overlaps. A small positive value (e.g. 0.005)
4040
adds a safety margin between objects."""
41+
42+
on_relation_z_tolerance_m: float = 5e-3
43+
"""Tolerance (meters) for On-relation Z validation. Valid Z band is extended to
44+
(parent_top - tolerance, parent_top + clearance_m + tolerance]. Default 5e-3 accommodates solver residual."""

isaaclab_arena/relations/relation_loss_strategies.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
from typing import TYPE_CHECKING
1111

1212
from isaaclab_arena.relations.loss_primitives import (
13+
interval_overlap_axis_loss,
1314
linear_band_loss,
1415
single_boundary_linear_loss,
1516
single_point_linear_loss,
1617
)
1718
from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox
1819

1920
if TYPE_CHECKING:
20-
from isaaclab_arena.relations.relations import AtPosition, NextTo, On, Relation
21+
from isaaclab_arena.relations.relations import AtPosition, NextTo, On, Relation, NoCollision
2122

2223
from isaaclab_arena.relations.relations import Side
2324

@@ -305,6 +306,84 @@ def compute_loss(
305306
return relation.relation_loss_weight * total_loss
306307

307308

309+
class NoCollisionLossStrategy(RelationLossStrategy):
310+
"""Loss strategy for NoCollision relations.
311+
312+
Computes loss based on:
313+
1. X overlap: zero when child and parent are separated along X; else overlap length
314+
2. Y overlap: zero when separated along Y; else overlap length
315+
3. Z overlap: zero when separated along Z; else overlap length
316+
4. Volume loss: slope * (overlap_x * overlap_y * overlap_z)
317+
"""
318+
319+
def __init__(self, slope: float = 10.0, debug: bool = False):
320+
"""
321+
Args:
322+
slope: Gradient magnitude for overlap volume loss (default: 10.0).
323+
Loss scales with slope times overlap volume.
324+
debug: If True, print detailed loss component breakdown.
325+
"""
326+
self.slope = slope
327+
self.debug = debug
328+
329+
def compute_loss(
330+
self,
331+
relation: "NoCollision",
332+
child_pos: torch.Tensor,
333+
child_bbox: AxisAlignedBoundingBox,
334+
parent_world_bbox: AxisAlignedBoundingBox,
335+
) -> torch.Tensor:
336+
"""Compute loss for NoCollision relation.
337+
338+
Args:
339+
relation: NoCollision relation with relation_loss_weight.
340+
child_pos: Child object position tensor (x, y, z) in world coords.
341+
child_bbox: Child object local bounding box.
342+
parent_world_bbox: Parent bounding box in world coordinates.
343+
344+
Returns:
345+
Weighted loss tensor.
346+
"""
347+
# Parent world extents from the world bounding box, expanded by clearance_m
348+
c = relation.clearance_m
349+
parent_x_min = parent_world_bbox.min_point[0] - c
350+
parent_x_max = parent_world_bbox.max_point[0] + c
351+
parent_y_min = parent_world_bbox.min_point[1] - c
352+
parent_y_max = parent_world_bbox.max_point[1] + c
353+
parent_z_min = parent_world_bbox.min_point[2] - c
354+
parent_z_max = parent_world_bbox.max_point[2] + c
355+
356+
# Child world extents
357+
child_world_min = child_pos + torch.tensor(child_bbox.min_point, dtype=child_pos.dtype, device=child_pos.device)
358+
child_world_max = child_pos + torch.tensor(child_bbox.max_point, dtype=child_pos.dtype, device=child_pos.device)
359+
360+
# 1. Per-axis overlap: zero when separated; else overlap length (default slope 1.0 gives length in m)
361+
overlap_x = interval_overlap_axis_loss(child_world_min[0], child_world_max[0], parent_x_min, parent_x_max)
362+
overlap_y = interval_overlap_axis_loss(child_world_min[1], child_world_max[1], parent_y_min, parent_y_max)
363+
overlap_z = interval_overlap_axis_loss(child_world_min[2], child_world_max[2], parent_z_min, parent_z_max)
364+
365+
# 2. Volume loss: slope * product of per-axis overlap lengths (overlap volume when slope 1.0)
366+
overlap_volume = overlap_x * overlap_y * overlap_z
367+
total_loss = self.slope * overlap_volume
368+
369+
if self.debug:
370+
print(
371+
f" [NoCollision] X: overlap={overlap_x.item():.6f} (child_x=[{child_world_min[0].item():.4f},"
372+
f" {child_world_max[0].item():.4f}], parent_x=[{parent_x_min:.4f}, {parent_x_max:.4f}])"
373+
)
374+
print(
375+
f" [NoCollision] Y: overlap={overlap_y.item():.6f} (child_y=[{child_world_min[1].item():.4f},"
376+
f" {child_world_max[1].item():.4f}], parent_y=[{parent_y_min:.4f}, {parent_y_max:.4f}])"
377+
)
378+
print(
379+
f" [NoCollision] Z: overlap={overlap_z.item():.6f} (child_z=[{child_world_min[2].item():.4f},"
380+
f" {child_world_max[2].item():.4f}], parent_z=[{parent_z_min:.4f}, {parent_z_max:.4f}])"
381+
)
382+
print(f" [NoCollision] volume={overlap_volume.item():.6f}, loss={total_loss.item():.6f}")
383+
384+
return relation.relation_loss_weight * total_loss
385+
386+
308387
class AtPositionLossStrategy(UnaryRelationLossStrategy):
309388
"""Loss strategy for AtPosition relations.
310389

isaaclab_arena/relations/relation_solver_params.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,20 @@
88
from isaaclab_arena.relations.relation_loss_strategies import (
99
AtPositionLossStrategy,
1010
NextToLossStrategy,
11+
NoCollisionLossStrategy,
1112
OnLossStrategy,
1213
RelationLossStrategy,
1314
UnaryRelationLossStrategy,
1415
)
15-
from isaaclab_arena.relations.relations import AtPosition, NextTo, On, RelationBase
16+
from isaaclab_arena.relations.relations import AtPosition, NextTo, NoCollision, On, RelationBase
1617

1718

1819
def _default_strategies() -> dict[type[RelationBase], RelationLossStrategy | UnaryRelationLossStrategy]:
1920
"""Factory for default loss strategies."""
2021
return {
2122
NextTo: NextToLossStrategy(slope=10.0),
2223
On: OnLossStrategy(slope=100.0),
24+
NoCollision: NoCollisionLossStrategy(slope=100.0),
2325
AtPosition: AtPositionLossStrategy(slope=100.0),
2426
}
2527

isaaclab_arena/relations/relations.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,33 @@ def __init__(
117117
self.clearance_m = clearance_m
118118

119119

120+
class NoCollision(Relation):
121+
"""Represents a 'no collision' relationship between two objects.
122+
123+
This relation specifies that the child and parent bounding boxes must not
124+
overlap. Adding NoCollision on one side is enough; the solver counts each
125+
unordered pair once.
126+
127+
Note: Loss computation is handled by NoCollisionLossStrategy in relation_loss_strategies.py.
128+
"""
129+
130+
def __init__(
131+
self,
132+
parent: Object | ObjectReference,
133+
relation_loss_weight: float = 1.0,
134+
clearance_m: float = 0.01,
135+
):
136+
"""
137+
Args:
138+
parent: The other object that this object must not collide with.
139+
relation_loss_weight: Weight for the relationship loss function.
140+
clearance_m: Minimum clearance between bounding boxes in meters (default: 1cm).
141+
"""
142+
super().__init__(parent, relation_loss_weight)
143+
assert clearance_m >= 0.0, f"clearance_m must be non-negative, got {clearance_m}"
144+
self.clearance_m = clearance_m
145+
146+
120147
class IsAnchor(RelationBase):
121148
"""Marker indicating this object is an anchor for relation solving.
122149

0 commit comments

Comments
 (0)