Add an optional build-time, sim-free gate to keep only the object layouts a robot can actually reach, using cuRobo IK (Part 2/3)#914
Conversation
| # Load the extension providing the gate, then pull its validator into the pool's solve loop. | ||
| extensions = ["isaaclab_arena_curobo"] | ||
| import_extensions(extensions) | ||
| placer_params.reachability_validator = resolve_reachability_validator( |
There was a problem hiding this comment.
I cannot think of other ways to pass this callable as optional to solver call. Open for suggestions.
There was a problem hiding this comment.
Is it possible to make it an input to solve_and_apply_relation_placement, so that we can also remove it from ObjectPlacerParams? The reachability check is quite detached from the solver, so I don't have a very good suggestion at this moment
| device: str = "cuda:0" | ||
| language_instruction: str | None = None | ||
|
|
||
| validate_reachability: bool = False |
There was a problem hiding this comment.
Because curobo does not support pip, so leaving reachability validation as optional
- Add optional ObjectPlacerParams.layout_validator; PooledObjectPlacer gates each geometry-valid candidate on it, so the solve loop reject-&-refills until every env has enough accepted layouts. Core stays cuRobo-free (generic Callable). - Add sim-free cuRobo oracle (simfree_ik): standalone MotionGen/IKSolver over a bounding-box collision world -- no SimulationApp, Isaac Lab env, or env.reset. - Add make_simfree_ik_layout_validator: build the solver from an embodiment and return a layout_validator closure (bbox world + top-down grasp IK per movable object). - Add run_ik_gated_placement.py: attach the validator before compose so the eval env is built once with an IK-reachable pool (no throwaway env; recorder/pool/RNG stay pristine). - Tests: core gating + reject-&-refill, and the factory closure with cuRobo mocked. Signed-off-by: Xinjie Yao <xyao@nvidia.com>
Let an environment declare a build-time layout validator on its placement config, resolved through a string-keyed registry an extension populates on import, so core never imports cuRobo. - Add ObjectPlacerParams.layout_validator_key/_kwargs so an env declares the gate its placement wants (e.g. "simfree_ik" for cuRobo IK reachability). - Move extension loading onto ArenaEnvBuilderCfg.enable_extensions and import it in the builder, so both the argparse and typed run-config paths resolve declared keys identically; demote --layout_validator_key to an override. - Register "simfree_ik" from isaaclab_arena_curobo on import via a lazy factory; factor shared frame math into curobo_frame_utils. - Replace run_ik_gated_placement.py with test_ik_gated_placement.py driving the env-declared gate end-to-end (10/10 layouts IK-reachable). Signed-off-by: Xinjie Yao <xyao@nvidia.com>
Collapse the string-keyed layout-validator indirection into a single reachability-validator slot selected by a boolean, so a run asks for the gate with --validate_reachability instead of naming a key. Core still never imports cuRobo; the provider registers into the slot via --enable_extensions. - Replace ObjectPlacerParams.layout_validator_key/_kwargs and the keyed registry with a single slot plus ArenaEnvBuilderCfg.validate_reachability (+ reachability_validator_kwargs); the builder fills placer_params.layout_validator from the slot when the flag is set. - Swap --layout_validator_key for the --validate_reachability store_true flag. - Register the sim-free cuRobo IK validator into the single slot on import. - Update test_ik_gated_placement.py to drive the flag (10/10 IK-reachable). Signed-off-by: Xinjie Yao <xyao@nvidia.com>
Name the two IK paths for what actually differs instead of the vague "simfree": the standalone path (no live env) vs the env-coupled path, and the grasp-pose pair by its input source. - Rename the sim-free family to "standalone": simfree_ik.py -> standalone_ik.py, SimFreeIKReachability -> StandaloneIKReachability, SimFreeCuboid -> StandaloneCuboid, check_ik_feasibility_simfree -> check_ik_feasibility_standalone, make_simfree_ik_layout_validator -> make_standalone_ik_layout_validator, and the layout-validator module + its test. - Rename the grasp-pose pair so the diff is the input source: top_down_grasp_pose_in_robot_frame -> top_down_grasp_pose_from_env (reads a live env), and top_down_grasp_pose_simfree -> top_down_grasp_pose_from_world_poses (takes explicit world poses). - Finish the WIP top_down_grasp_matrix: import Pose and convert the grasp rotation matrix to a quaternion before building the Pose (rotation_xyzw is a 4-quat), fixing an undefined name and a matrix-as-quaternion mismatch; plus an "assymetric" typo. Validated in the cuRobo image: build-time IK gate still 10/10 layouts reachable; mocked validator unit test passes. Signed-off-by: Xinjie Yao <xyao@nvidia.com>
3134be9 to
314de34
Compare
zhx06
left a comment
There was a problem hiding this comment.
Focused pass on the reachability-solver interface — i.e. how the IK gate validates the geometry-valid layouts. The plumbing (registry indirection, geometry-first short-circuit, reject-&-refill) is clean and keeps core vendor-agnostic. My main concern is that the current IK check is pose-residual only and does not yet enforce the collision world it goes to some trouble to build, so "IK reachable" doesn't actually validate the geometric/collision feasibility a grasp needs. Inline notes below.
| num_poses = positions.shape[0] | ||
| pos_err = ik_result.position_error.view(num_poses, -1) | ||
| rot_err = ik_result.rotation_error.view(num_poses, -1) | ||
| best_idx = pos_err.argmin(dim=1, keepdim=True) |
There was a problem hiding this comment.
Subtle false-negative in the best-seed selection: you pick the seed with minimum position error (pos_err.argmin), then test that one seed's rotation error against rotation_threshold. A different seed could satisfy both thresholds while the min-position seed fails rotation, and you'd wrongly report infeasible. Feasibility should be "does any seed meet both bars":
ok = (pos_err < position_threshold) & (rot_err < rotation_threshold)
feasible = ok.any(dim=1)Once collision success is added (comment below), the per-pose reduction should likewise be over successful seeds only, otherwise best_pos_err/best_rot_err can report an in-collision solution's residuals.
There was a problem hiding this comment.
- Current (argmin): it first ranks seeds by position error alone, picks that one seed, then checks both position and rotation on it. So the rotation test is applied to whatever seed happened to win on position.
- Yours (.any): each seed must clear both thresholds itself; a pose is feasible if any seed does.
Actually internally for Curobo, right now.
It optimizes num_seeds (12) in parallel, but it returns only the top return_seeds = 1 solution per pose — has already reduced to its own single best seed.
I can adopt yours.
zhx06
left a comment
There was a problem hiding this comment.
Follow-up focused on the relation-solver side of the interface — where the gate actually plugs into PooledObjectPlacer. The predicate hook itself is a clean seam, but the placement of the call inside the refill loop has a real efficiency cost, and the Callable[..., bool] contract hides a mutation. Notes inline.
| env_results = ranked_results_per_env[cur_env][:layouts_per_env] | ||
| valid_results = [r for r in env_results if r.success] | ||
| # if reachability validator is not set, results are only checked through geometry validation | ||
| valid_results = [r for r in env_results if r.success and self._passes_reachability_validator(r)] |
There was a problem hiding this comment.
The expensive validator runs more than it needs to, in two ways:
-
Already-full envs are re-validated every batch.
_solve_and_storeloops up tomax_placement_attemptsbatches, sizing each batch by the least-full env (max_missing = target - min(available)). But this line computesvalid_results— and thus runs the IK validator — for every env, including ones wheremissing <= 0(theelsebranch on line 231 just bumps a counter and stores nothing). So an env that filled in batch 0 keeps paying a full round of cuRobo IK solves on every later batch until the slowest env catches up. -
More candidates validated than stored. Even for a not-yet-full env you validate all
layouts_per_envcandidates, then keep onlyvalid_results[:missing].
Suggest gating on missing before validating and stopping once missing are accepted, e.g.:
missing = target_num_layouts_per_env - self._env_pools[cur_env].available
if missing <= 0:
continue # env already satisfied — don't run the validator
valid_results = []
for r in env_results:
if r.success and self._passes_reachability_validator(r):
valid_results.append(r)
if len(valid_results) >= missing:
breakThis keeps the total_solved/total_valid log honest for the envs that matter and can cut IK calls substantially when envs fill at different rates.
There was a problem hiding this comment.
Well it shall be another improvement MR and you can add it according to your agent suggestion :)
| @@ -203,7 +219,8 @@ def _store_env_matched_results( | |||
| fallback_envs = [] | |||
| for cur_env in range(self._num_envs): | |||
| env_results = ranked_results_per_env[cur_env][:layouts_per_env] | |||
There was a problem hiding this comment.
When a reachability_validator is set, produce more geometry candidates than needed so the IK gate has fallbacks in one batch — otherwise unreachable top-missing candidates force a full geometry+cuRobo re-solve every refill. Concretely: pass results_per_env = layouts_per_env * overfetch (e.g. 4×, gated on self._reachability_validator) into place_ranked_per_env, drop this [:layouts_per_env] slice so the tail is reachable, and stop validating at missing (the :223 early-stop). Geometry sampling is cheap next to a cuRobo solve, so this trades a bit of sampling for far fewer expensive batches.
| allow_best_loss_fallbacks: bool = True | ||
| """Whether pooled placement may use best-loss layouts when no valid layout is found.""" | ||
|
|
||
| reachability_validator: Callable[[PlacementResult], bool] | None = None |
There was a problem hiding this comment.
The type advertises a pure predicate — Callable[[PlacementResult], bool] — but the cuRobo validator supplied by the extension mutates the argument: make_standalone_ik_layout_validator stamps a required PlacementCheck.IK_REACHABLE onto result.validation_results (flipping result.success when it rejects). A caller reading only this signature won't expect the passed result to change, and the stamped .success=False then conflates "geometry failed" with "IK rejected" for any downstream consumer that inspects the layout. Worth (a) documenting the side-effect here, and (b) confirming the .success conflation is intended — an audit reader can no longer tell a geometry failure from a reachability rejection.
| env_results = ranked_results_per_env[cur_env][:layouts_per_env] | ||
| valid_results = [r for r in env_results if r.success] | ||
| # if reachability validator is not set, results are only checked through geometry validation | ||
| valid_results = [r for r in env_results if r.success and self._passes_reachability_validator(r)] |
There was a problem hiding this comment.
It seems _passes_reachability_validator is time-consuming. Can we check missing first to avoid extra computing?
zhx06
left a comment
There was a problem hiding this comment.
A few more fixable items on the core relation-solver side (the geometry path that feeds the gate), independent of cuRobo.
| and getattr(init_state, "pos", None) is not None | ||
| and getattr(init_state, "rot", None) is not None | ||
| ): | ||
| return tuple(float(v) for v in init_state.pos), tuple(float(v) for v in init_state.rot) |
There was a problem hiding this comment.
Quaternion-convention bug. The return contract is rotation_xyzw, and the initial_pose branch honors it. But Isaac Lab's init_state.rot is wxyz (identity (1,0,0,0)), so returning it verbatim mislabels wxyz as xyzw — identity becomes (1,0,0,0) read as xyzw, i.e. a 180° rotation about X. The default fallback correctly uses xyzw identity (0,0,0,1), which makes the mismatch concrete. get_base_pose() feeds base_quat_xyzw straight into the IK validator, so a non-identity robot base orientation gets corrupted. Convert:
from isaaclab.utils.math import convert_quat
rot_xyzw = convert_quat(torch.tensor(init_state.rot), to="xyzw")
return tuple(float(v) for v in init_state.pos), tuple(float(v) for v in rot_xyzw)There was a problem hiding this comment.
are you/your agent sure Lab's init_state.rot is wxyz?
| kwargs: Extra tuning forwarded verbatim to the entry point (e.g. IK thresholds). | ||
| """ | ||
| for module_name in module_names or []: | ||
| factory = getattr(importlib.import_module(module_name), _REACHABILITY_VALIDATOR_FACTORY_ATTR, None) |
There was a problem hiding this comment.
import_module raises a raw ImportError when the extension's deps aren't installed (e.g. cuRobo missing), which is exactly the case the CLI help promises to handle ("make sure dev environment has curobo deps installed"). Wrap it so a missing extension is skipped or re-raised with that guidance instead of a bare ImportError:
try:
module = importlib.import_module(module_name)
except ImportError as e:
raise RuntimeError(f"{module_name} enabled for --validate_reachability but import failed: {e}. Install its deps (e.g. cuRobo).") from e| # Pull the gate exported by an enabled extension into the pool's solve loop. resolve_* | ||
| # imports the extension by name, so no cuRobo import appears in core. | ||
| placer_params.reachability_validator = resolve_reachability_validator( | ||
| embodiment, ["isaaclab_arena_curobo"], **self.cfg.reachability_validator_kwargs |
There was a problem hiding this comment.
The extension list is hardcoded ["isaaclab_arena_curobo"] here, which quietly contradicts the registry's stated design ("the module names come from config ... no cuRobo import appears in core"). The string is fine, but consider sourcing it from a config field (e.g. self.cfg.reachability_validator_modules) so core carries no vendor name and a different backend can be wired without editing the builder.
Gate the standalone (build-time) IK reachability check on a collision-free joint solution, not just pose convergence: solve_ik_feasibility grows a require_collision_free flag that also requires cuRobo's success at the best-error seed. StandaloneIKReachability opts in and, mirroring the live planner's contact planning, disables its hand-link collision spheres so a top-down grasp is not rejected for touching its own target object. The env-coupled planner leaves the flag off, so its behavior is unchanged. Signed-off-by: Xinjie Yao <xyao@nvidia.com>
| ik_rot_threshold: float = 0.1, | ||
| device: str | torch.device | None = None, | ||
| stamp_results: bool = True, | ||
| ) -> Callable[[PlacementResult], bool]: |
There was a problem hiding this comment.
I think the cuRobo validator should remain read-only with respect to Arena-owned state such as PlacementResult. Could it simply return whether the layout is reachable and let for example PooledObjectPlacer record the result?
alexmillane
left a comment
There was a problem hiding this comment.
Thanks for putting this together. Great that it's working!
I've left a few comments. I think we need to investigate the class heirachy and see how we can build this into the solver such that's not passed in from the top level.
| The pool's solve loop calls it on each geometry-valid candidate; a candidate is stored only when the robot can reach a | ||
| top-down grasp at every movable object, so the loop keeps solving (reject-&-refill) until every env has enough reachable layouts. | ||
|
|
||
| Requires a CUDA GPU. |
There was a problem hiding this comment.
I think our whole framework requires a CUDA GPU right? Suggestion to remove this?
| arena_group.add_argument( | ||
| "--validate_reachability", | ||
| action="store_true", | ||
| default=False, | ||
| help=( | ||
| "Gate build-time pooled placement on reachability, storing only layouts whose objects the" | ||
| " robot can reach (cuRobo top-down-grasp IK). isaaclab_arena_curobo is automatically imported" | ||
| " when this flag is set. Please make sure dev environment has curobo deps installed." | ||
| ), | ||
| ) |
There was a problem hiding this comment.
Is there some way that we can enable this on a per-environment level so a user needn't worry about whether or not curobo validation is required? This shifts the responsibility from the person running the environment to the person creating the environment, which makes more sense to me.
| raise RuntimeError("scene_config must be populated with a `robot` before calling `set_joint_initial_pos`.") | ||
| self.scene_config.robot.init_state.joint_pos.update(joint_pos) | ||
|
|
||
| def get_base_pose(self) -> tuple[tuple[float, float, float], tuple[float, float, float, float]]: |
There was a problem hiding this comment.
elsewhere we use our Pose object to communicate poses. Any reason to do something different here?
If you need the pose as tuples, suggestion to add a conversion to our pose object.
| # Gate pooled placement on reachability on demand. | ||
| if self.cfg.validate_reachability and placer_params.reachability_validator is None: | ||
| embodiment = self.arena_env.embodiment | ||
| assert embodiment is not None, "--validate_reachability requires an environment with a robot embodiment." | ||
| # Pull the gate exported by an enabled extension into the pool's solve loop. resolve_* | ||
| # imports the extension by name, so no cuRobo import appears in core. | ||
| placer_params.reachability_validator = resolve_reachability_validator( | ||
| embodiment, ["isaaclab_arena_curobo"], **self.cfg.reachability_validator_kwargs | ||
| ) |
There was a problem hiding this comment.
I feel like this validator (actually, all validators) should only be visible inside the solver. They should be activated in the config, and then the solver should sort out how they're exectude. Having one particular validator appearing on the top-level in env-builder seems a bit off.
Is there some reason that we need this here? One reason is that currently this is controlled by a flag. See above for my comment on that.
| reachability_validator_kwargs: dict[str, Any] = field(default_factory=dict) | ||
| """Keyword tuning forwarded to the reachability-validator factory (e.g. IK thresholds). Ignored when | ||
| ``validate_reachability`` is ``False`` or a callable was set directly on the placer params.""" |
There was a problem hiding this comment.
Are we in control of the validator, or is this a curobo thing? If this is us, could we pass the kwargs as a dataclass. A dataclass communicates valid args to the outside word. With a dict, a user has no idea what options are available. They just put some stuff in, maybe it works, maybe it crashes.
| pose_ctx: Object providing ``_to_curobo_device``, ``_make_pose`` and ``logger``. A ``CuroboPlanner`` or a ``StandaloneIKReachability``. | ||
| ik_solver: cuRobo ``IKSolver`` exposing ``solve_batch``. |
There was a problem hiding this comment.
I'm a little surprised that we seem to pass in two curobo solver-related objects.
pose_ctx: CuroboPlanner
and
ik_solver: IKSolver
What's the difference here? Why do we need so planner/solvers to solve the feasibility problem?
| name: str | ||
| center_xyz: tuple[float, float, float] | ||
| dims_xyz: tuple[float, float, float] | ||
| quat_wxyz: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0) |
There was a problem hiding this comment.
elsewhere in the framework we use xyzw. I suppose that this is because curobo uses wxyz?
I would tend to stick to the isaaclab convention within our code, and then convert at the last moment.
| center_xyz: tuple[float, float, float] | ||
| dims_xyz: tuple[float, float, float] | ||
| quat_wxyz: tuple[float, float, float, float] = (1.0, 0.0, 0.0, 0.0) |
There was a problem hiding this comment.
Could we combine the center and orientation into one of our pose objects?
| def get_object_world_pose_from_layout( | ||
| result: PlacementResult, | ||
| obj: ObjectBase, | ||
| base_rotations: dict, | ||
| ) -> tuple[tuple[float, float, float], tuple[float, ...]]: |
There was a problem hiding this comment.
Maybe this is a limitation of the sovler, but I'm wondering why we're passing around base_rotations separately.
Could we not combine the rotations into the PlacementResult and treat the solution as one complete thing. Not something that we need to add some random rotations to?
There was a problem hiding this comment.
Ah maybe this is because were trying to stay away from kit?
| return top_down_grasp_matrix(t_R_O, q_R_O_xyzw, grasp_z_offset, align_yaw_to_object) | ||
|
|
||
|
|
||
| class StandaloneIKReachability: |
There was a problem hiding this comment.
How does this class relate to the non-standalone version? Presumably, they do very similar things? Do they currently follow completely different code paths?
Is there some way we could unify them. For example the kit-requiring version can hold one of these?
Summary
Today the solver only checks that objects fit geometrically, before placement event. This MR adds a second, optional check: a layout is stored only when it passes geometry and the robot can reach a top-down grasp at every movable object. The placement loop keeps re-sampling until every env has enough reachable layouts.
Detailed description
--validate_reachabilitywired throughArenaEnvBuilderCfginto the placement pool. Off by default.reachability_validator:A small registry pulls up the validator and provides the curobo ik entry point. So the core stays curobo-agnostic.How to
Run in curobo image (-c). Pass --validate_reachability on the command line.
Logs will show how many layouts are rejected after validation upon each refill
Note
TODO