Skip to content

Commit 6473d3c

Browse files
authored
Add standalone physics settle check over pooled layouts (#769)
## Summary When a solver returns a pooled layouts, there are 2 types of validations. One is analytical, fast, does not require SimApp, e.g. check no overlapping & on relation (existing). The other involves stepping physics, slow but more closely reflects how end user consumes, e.g. physics stability. In an ideal world, placement_enev shall only use valid layout everytime thru `env.reset()` ## Detailed description ### Why Agentic Env Gen requires a post-validation step to rule out envs with unstable physics, and re-trigger generation if nothing valid. It has to be automatic. ### How `python isaaclab_arena/scripts/run_placement_pool_validation.py (--viz kit --render) --num_envs 10 pick_and_place_maple_table` The standalone script checks if physics settled by advancing physics only, over the pool of layouts returned by the solver, and report results. ### What 1. Refactored existing validation: - added a `PlacementValidationChecklist` data class to contain each individual validation check results; - included a required/optional validation failures to let user decide which optimized layouts shall be selected as the best-ranked. 2. Added `physics_settle.py` to execute physics stepping without rendering, and check linear_vel, and ang_vel if below thresholds. 3. Added `placement_pool_validation.py` orchestras how many episodes over how many envs needed for the fixed pool of layouts 4. Added user-interface `run_placement_pool_validation.py` to execute the check ## Next Add integration with Pool filling and lookup table. Ideal pipeline: Each time pool is filled (after solver, before placement event) -> [Optional] run physics check for all env's pool unvalidated layouts & log it down -> Select (valid) one to load in placement events --------- Signed-off-by: Xinjie Yao <xyao@nvidia.com>
1 parent e3c2353 commit 6473d3c

15 files changed

Lines changed: 999 additions & 61 deletions

isaaclab_arena/environments/arena_env_builder.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from isaaclab_arena.metrics.metric_base import MetricBase
3131
from isaaclab_arena.metrics.metric_term_cfg import MetricTermCfg
3232
from isaaclab_arena.metrics.recorder_manager_utils import metrics_to_recorder_manager_cfg
33+
from isaaclab_arena.relations.placement_events import PLACEMENT_RESET_EVENT_NAME
3334
from isaaclab_arena.tasks.no_task import NoTask
3435
from isaaclab_arena.utils.configclass import combine_configclass_instances, make_configclass
3536
from isaaclab_arena.utils.isaaclab_utils.simulation_app import reapply_viewer_cfg
@@ -189,7 +190,7 @@ def compose_manager_cfg(self) -> IsaacLabArenaManagerBasedRLEnvCfg:
189190
if self._placement_event_cfg is not None:
190191
PlacementEventCfg = make_configclass(
191192
"PlacementEventCfg",
192-
[("placement_reset", EventTermCfg, self._placement_event_cfg)],
193+
[(PLACEMENT_RESET_EVENT_NAME, EventTermCfg, self._placement_event_cfg)],
193194
)
194195
placement_event_cfg = PlacementEventCfg()
195196
variations_event_cfg = self._compose_variations_event_cfg()

isaaclab_arena/relations/object_placer.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from isaaclab_arena.relations.bounding_box_helpers import assign_variants_for_envs, build_per_env_bounding_boxes
1313
from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams
1414
from isaaclab_arena.relations.placement_result import MultiEnvPlacementResult, PlacementResult
15+
from isaaclab_arena.relations.placement_validation import PlacementCheck, PlacementValidationResults
1516
from isaaclab_arena.relations.relation_solver import RelationSolver
1617
from isaaclab_arena.relations.relations import (
1718
IsAnchor,
@@ -38,12 +39,17 @@ class PlacementCandidate:
3839
positions: dict[ObjectBase, tuple[float, float, float]]
3940
"""Solved positions for each object."""
4041

41-
is_valid: bool
42-
"""Whether the placement passed validation checks."""
42+
validation_results: PlacementValidationResults
43+
"""Per-check validation results for this candidate's layout."""
4344

4445
orientations: dict[ObjectBase, float] = field(default_factory=dict)
4546
"""Per-object yaw (radians about Z) sampled for this candidate. Empty when unrotated."""
4647

48+
@property
49+
def is_valid(self) -> bool:
50+
"""True when all validation checks pass."""
51+
return self.validation_results.do_all_required_validation_checks_pass()
52+
4753

4854
class ObjectPlacer:
4955
"""High-level API for placing objects according to their spatial relations.
@@ -102,6 +108,16 @@ def place(
102108
)
103109
results_per_env = [env_results[0] for env_results in ranked_results_per_env]
104110

111+
if self.params.verbose:
112+
# If no valid layout is found, print the failed checks for the lowest-loss fallback
113+
# So we can debug the placement problem and it still produces some layouts for the envs
114+
for env_idx, result in enumerate(results_per_env):
115+
if not result.success:
116+
print(
117+
f" env {env_idx}: no valid layout; using lowest-loss fallback "
118+
f"(failed: {result.validation_results.get_failed_validation_check_names})"
119+
)
120+
105121
if self.params.apply_positions_to_objects:
106122
positions_per_env = [r.positions for r in results_per_env]
107123
orientations_per_env = [r.orientations for r in results_per_env]
@@ -235,7 +251,7 @@ def _place_ranked(
235251
ranked_results = [
236252
[
237253
PlacementResult(
238-
success=candidate.is_valid,
254+
validation_results=candidate.validation_results,
239255
positions=candidate.positions,
240256
final_loss=candidate.loss,
241257
attempts=attempts_per_result,
@@ -257,13 +273,19 @@ def _rank_candidates(
257273
num_envs: int,
258274
candidates_per_env: int,
259275
) -> list[list[PlacementCandidate]]:
260-
"""Return one loss-sorted candidate slice per env (valid candidates first)."""
276+
"""Return one ranked candidate slice per env: most validation checks passed first, then lowest loss."""
261277
ranked_candidate_slices: list[list[PlacementCandidate]] = []
262278
for cur_env in range(num_envs):
263279
start = cur_env * candidates_per_env
264280
env_candidates = candidates[start : start + candidates_per_env]
265281
ranked_candidate_slices.append(
266-
sorted(env_candidates, key=lambda candidate: (not candidate.is_valid, candidate.loss))
282+
sorted(
283+
env_candidates,
284+
key=lambda candidate: (
285+
*candidate.validation_results.get_number_of_required_and_optional_failures,
286+
candidate.loss,
287+
),
288+
)
267289
)
268290
return ranked_candidate_slices
269291

@@ -602,17 +624,26 @@ def _validate_placement(
602624
self,
603625
positions: dict[ObjectBase, tuple[float, float, float]],
604626
env_bboxes: dict[ObjectBase, AxisAlignedBoundingBox],
605-
) -> bool:
627+
) -> PlacementValidationResults:
606628
"""Validate that no two objects overlap in 3D and On relations are satisfied.
607629
608630
Args:
609631
positions: Dictionary mapping objects to their solved (x, y, z) positions.
610632
env_bboxes: Per-object bboxes for the current env, each with shape (1, 3).
611633
612634
Returns:
613-
True if no overlaps exist and On relations hold, False otherwise.
635+
PlacementValidationResults containing the validation results.
614636
"""
615-
return self._validate_no_overlap(positions, env_bboxes) and self._validate_on_relations(positions, env_bboxes)
637+
no_overlap = self._validate_no_overlap(positions, env_bboxes)
638+
on_relation = self._validate_on_relations(positions, env_bboxes)
639+
640+
return PlacementValidationResults(
641+
validation_results={
642+
PlacementCheck.NO_OVERLAP: no_overlap,
643+
PlacementCheck.ON_RELATION: on_relation,
644+
},
645+
required_checks={PlacementCheck.NO_OVERLAP, PlacementCheck.ON_RELATION},
646+
)
616647

617648
def _apply_poses(
618649
self,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) 2025-2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
from dataclasses import dataclass
7+
8+
9+
@dataclass
10+
class PhysicsSettleParams:
11+
"""Configuration for the in-sim physics settle check."""
12+
13+
num_steps: int = 5
14+
"""Number of env steps to advance before reading back object state in the settle check. The settle
15+
check converts this to ``num_steps * decimation`` physics substeps internally."""
16+
17+
lin_vel_thresh: float = 0.1
18+
"""Max per-object linear speed (m/s) after settling. Above this the layout is considered unsettled."""
19+
20+
ang_vel_thresh: float = 0.1
21+
"""Max per-object angular speed (rad/s) after settling. Above this the layout is considered unsettled."""

isaaclab_arena/relations/placement_events.py

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,86 @@
1313
from isaaclab_arena.relations.pooled_object_placer import PooledObjectPlacer
1414
from isaaclab_arena.relations.relations import RotateAroundSolution, get_anchor_objects
1515
from isaaclab_arena.utils.pose import Pose, rotate_quat_by_yaw
16+
from isaaclab_arena.utils.velocity import Velocity
1617

1718
if TYPE_CHECKING:
1819
from isaaclab_arena.assets.object_base import ObjectBase
20+
from isaaclab_arena.relations.placement_result import PlacementResult
1921

2022
IDENTITY_ROTATION_XYZW = (0.0, 0.0, 0.0, 1.0)
2123

24+
# Name of the reset event term that owns the pooled object placer
25+
PLACEMENT_RESET_EVENT_NAME = "placement_reset"
26+
27+
28+
def get_placement_pool(env) -> PooledObjectPlacer | None:
29+
"""Return the pooled placer registered on the env, or ``None`` when the env has no pooled placement.
30+
31+
Lets a runtime caller reach the pool (e.g. to run the post-reset settle check) from the env alone,
32+
without holding the builder. The pool is reached through the env's event manager.
33+
34+
Args:
35+
env: The gym-wrapped Isaac Lab env; the base env is reached via ``env.unwrapped``.
36+
"""
37+
try:
38+
term_cfg = env.unwrapped.event_manager.get_term_cfg(PLACEMENT_RESET_EVENT_NAME)
39+
except ValueError:
40+
return None
41+
return term_cfg.params.get("placement_pool")
42+
2243

2344
def get_rotation_xyzw(obj: ObjectBase) -> tuple[float, float, float, float]:
2445
"""Return the RotateAroundSolution rotation for *obj*, or identity if none."""
2546
rotate_marker = next((r for r in obj.get_relations() if isinstance(r, RotateAroundSolution)), None)
2647
return rotate_marker.get_rotation_xyzw() if rotate_marker else IDENTITY_ROTATION_XYZW
2748

2849

50+
def get_base_rotation_per_object(objects: list[ObjectBase]) -> dict[ObjectBase, tuple[float, float, float, float]]:
51+
"""Return the base rotation for each object."""
52+
return {obj: get_rotation_xyzw(obj) for obj in objects}
53+
54+
55+
def get_movable_object_names(
56+
objects: list[ObjectBase],
57+
anchor_objects_set: set[ObjectBase],
58+
) -> list[str]:
59+
"""Return the names of non-anchor objects."""
60+
return [obj.name for obj in objects if obj not in anchor_objects_set]
61+
62+
63+
def write_layout_to_sim(
64+
env: ManagerBasedEnv,
65+
env_id: int,
66+
result: PlacementResult,
67+
anchor_objects_set: set[ObjectBase],
68+
base_rotations: dict[ObjectBase, tuple[float, float, float, float]],
69+
) -> None:
70+
"""Write one env's solved layout into the sim.
71+
72+
Even writing zero velocity, the sim will still apply gravity and other forces from collisions,
73+
so collided objects will still be subject to move.
74+
75+
Args:
76+
env: The Isaac Lab ManagerBasedEnv environment.
77+
env_id: The environment index.
78+
result: The placement result to write to the sim.
79+
anchor_objects_set: The set of anchor objects.
80+
base_rotations: The base rotations for all objects.
81+
"""
82+
env_id_tensor = torch.tensor([env_id], device=env.device)
83+
zero_velocity = Velocity.zero().to_tensor(device=env.device).unsqueeze(0)
84+
for obj, pos in result.positions.items():
85+
if obj in anchor_objects_set:
86+
continue
87+
asset = env.scene[obj.name]
88+
rotation_xyzw = rotate_quat_by_yaw(base_rotations[obj], result.orientations.get(obj, 0.0))
89+
pose = Pose(position_xyz=pos, rotation_xyzw=rotation_xyzw)
90+
pose_t_xyz_q_xyzw = pose.to_tensor(device=env.device).unsqueeze(0)
91+
pose_t_xyz_q_xyzw[0, :3] += env.scene.env_origins[env_id, :]
92+
asset.write_root_pose_to_sim(pose_t_xyz_q_xyzw, env_ids=env_id_tensor)
93+
asset.write_root_velocity_to_sim(zero_velocity, env_ids=env_id_tensor)
94+
95+
2996
def solve_and_place_objects(
3097
env: ManagerBasedEnv,
3198
env_ids: torch.Tensor | None,
@@ -61,24 +128,14 @@ def solve_and_place_objects(
61128
results_by_env = dict(zip(reset_env_ids, reset_results))
62129

63130
anchor_objects_set = set(get_anchor_objects(objects))
64-
base_rotations = {obj: get_rotation_xyzw(obj) for obj in objects if obj not in anchor_objects_set}
131+
base_rotations = get_base_rotation_per_object(objects)
65132

66-
zero_velocity = torch.zeros(1, 6, device=env.device)
67133
for cur_env in reset_env_ids:
68-
env_id_tensor = torch.tensor([cur_env], device=env.device)
69134
result = results_by_env[cur_env]
70135
if not result.success:
71136
print(
72137
"Warning: Writing best-loss fallback placement for "
73138
f"env {cur_env}; layout failed strict placement validation."
74139
)
75-
for obj, pos in result.positions.items():
76-
if obj in anchor_objects_set:
77-
continue
78-
asset = env.scene[obj.name]
79-
rotation_xyzw = rotate_quat_by_yaw(base_rotations[obj], result.orientations.get(obj, 0.0))
80-
pose = Pose(position_xyz=pos, rotation_xyzw=rotation_xyzw)
81-
pose_t_xyz_q_xyzw = pose.to_tensor(device=env.device).unsqueeze(0)
82-
pose_t_xyz_q_xyzw[0, :3] += env.scene.env_origins[cur_env, :]
83-
asset.write_root_pose_to_sim(pose_t_xyz_q_xyzw, env_ids=env_id_tensor)
84-
asset.write_root_velocity_to_sim(zero_velocity, env_ids=env_id_tensor)
140+
# only write the non-anchor objects to the sim
141+
write_layout_to_sim(env, cur_env, result, anchor_objects_set, base_rotations)

0 commit comments

Comments
 (0)