Homogeneous Object Placement on Environment Reset#594
Homogeneous Object Placement on Environment Reset#594
Conversation
Greptile SummaryThis PR introduces homogeneous object placement on environment reset by adding a Confidence Score: 5/5Safe to merge; all remaining findings are P2 style/performance suggestions with no correctness impact. Prior P1 concerns (empty-tensor guard, missing test coverage) are resolved. The two open findings are a P2 performance suggestion (batching write calls) and a P2 test fragility. Neither blocks correctness or data integrity. placement_events.py (write-call batching) and test_placement_events.py (pool.remaining assertion). Important Files Changed
Reviews (3): Last reviewed commit: "align code style and variable name" | Re-trigger Greptile |
| if env_ids is None: | ||
| return |
There was a problem hiding this comment.
None guard does not cover empty tensors
Isaac Lab's EventManager passes a torch.Tensor (never None) for reset-mode events, so the is None branch is dead code. More importantly, an empty tensor (len(env_ids) == 0) would still reach placer.place(objects, num_envs=0, ...), which calls self._solver.solve(objects, []) with no initial positions — a path whose behaviour under the solver is unverified. The guard should cover both cases:
| if env_ids is None: | |
| return | |
| if env_ids is None or len(env_ids) == 0: | |
| return |
There was a problem hiding this comment.
🤖 Isaac Lab Review Bot
Summary
This PR moves object placement from a one-time build step to a per-reset event, so each environment reset produces a fresh collision-free layout. The design is sound: a single EventTermCfg(mode="reset") replaces the old build-time ObjectPlacer.place() call, and the new solve_and_place_objects function handles the solver invocation + sim writes. Tests cover the ObjectPlacer randomness properties but not the reset event itself.
Design Assessment
Design is sound. Registering placement as a reset event via EventTermCfg is the right Isaac Lab pattern for per-episode randomization. The make_configclass → combine_configclass_instances approach for injecting the new event alongside existing events is clean and consistent with how other configs are composed.
The split of concerns is clear: _solve_relations() builds the config, compose_manager_cfg() wires it into the event pipeline, and solve_and_place_objects does the actual work at reset time.
Findings
🟡 Warning: Failed placements are written to sim without guarding — isaaclab_arena/relations/placement_events.py:68-98
When the solver fails for some environments (result.success == False), the code still writes those failed positions into the simulation. The old code logged a warning but didn't apply positions (since it ran at build time and the sim wasn't live yet). Now that placement happens during resets, writing invalid positions could produce overlapping objects in the running simulation.
Consider skipping pose writes for environments where the solver failed:
for local_idx, cur_env in enumerate(env_ids.tolist()):
if not results_per_env[local_idx].success:
continue # keep previous layout for this env
positions = results_per_env[local_idx].positionsThis is not critical because the solver's "best effort" positions are typically close to valid even when validation fails, but it would be more defensive.
🟡 Warning: Per-env tensor allocation in hot loop — isaaclab_arena/relations/placement_events.py:90-98
Each iteration creates torch.tensor([cur_env], device=env.device) twice (for pose and velocity writes). For many environments with many objects, this is O(envs × objects × 2) small tensor allocations per reset. The existing set_object_pose_per_env in events.py has the same pattern, so this is consistent with the codebase, but worth noting for future optimization — pre-allocating the env_id tensor outside the loop would reduce GPU allocation overhead:
for local_idx, cur_env in enumerate(env_ids.tolist()):
env_id_tensor = torch.tensor([cur_env], device=env.device)
...
asset.write_root_pose_to_sim(pose_t_xyz_q_xyzw, env_ids=env_id_tensor)
asset.write_root_velocity_to_sim(torch.zeros(1, 6, device=env.device), env_ids=env_id_tensor)At minimum, this avoids creating the same [cur_env] tensor twice per object. A further optimization would batch all objects for a given env_id into a single write.
🔵 Suggestion: Test gap — solve_and_place_objects is untested — isaaclab_arena/tests/test_placement_events.py
The three tests are good coverage of ObjectPlacer randomness properties (unseeded produces different layouts, seeded is reproducible, multi-env diversity). However, they test the placer directly — not the solve_and_place_objects reset event function. The reset event adds important logic on top: the torch.inference_mode(False) context, the rotation computation, the sim write loop, and the env_ids mapping.
A unit test with a mock ManagerBasedEnv verifying that solve_and_place_objects correctly calls write_root_pose_to_sim for the right env_ids (and skips anchors) would strengthen confidence in the integration layer.
🔵 Suggestion: _enable_periodic_reset closure captures episode_length_s by reference — isaaclab_arena_environments/gr1_table_multi_object_no_collision_environment.py:108-115
The inner function _enable_periodic_reset captures episode_length_s from the enclosing scope. This works correctly here because the variable is only assigned once before the closure is created. No bug, just noting for awareness — if this pattern were extended with a loop, the classic closure-over-loop-variable issue could arise.
Test Coverage
- New code tested: Partial —
ObjectPlacerrandomness is well-tested - Gaps: The
solve_and_place_objectsfunction (the actual new event) has no direct test. The_enable_periodic_resetcallback path is also untested. - Test quality: Good — tests are deterministic (seeded vs unseeded), isolated, and cover the key behavioral property (distinct layouts per reset/per env)
CI Status
Pre-commit check: ⏳ pending
Verdict
Minor fixes needed
The architecture is clean and follows Isaac Lab conventions. The main actionable concern is writing failed solver results to sim (🟡) — adding a guard for failed placements would prevent edge-case overlapping objects. The per-env tensor allocation (🟡) is a pre-existing pattern in the codebase but worth a small optimization. The test gap is real but not blocking since the core placer logic is well-covered.
alexmillane
left a comment
There was a problem hiding this comment.
This is looking pretty good!
I have some small comments, but the big one is about speed and the potential use of a solution pool, in order to run bigger problem sizes, less frequently.
Let's discuss this. It's a good first stab at the problem.
| # Clear per-object reset events on solver-managed non-anchor objects. | ||
| # Without this, both the per-object event (set_object_pose) and the | ||
| # solver event (solve_and_place_objects) would fire on reset, with | ||
| # unpredictable ordering. | ||
| anchor_objects_set = set(get_anchor_objects(objects_with_relations)) | ||
| for obj in objects_with_relations: | ||
| if obj not in anchor_objects_set and obj.event_cfg is not None: | ||
| obj.event_cfg = None |
There was a problem hiding this comment.
I'm wondering if it's an error that non-anchor objects have pose-reset events in the case of relational solving. Do you know if what cases this is actually doing something?
There was a problem hiding this comment.
Right now non-anchor objects don't have set_initial_pose() called, so it's not an error and these object never gets a pose-reset event
There was a problem hiding this comment.
Rather than setting the their event_cfg to None should we just assert obj.event_cfg = None, "Relational object solving should not be combined with explicit setting of poses on non-anchor objects.
Right now, if a user tries to combine these things, the code will silently ignore the explicit poses. IMO it's better to fail and tell the user that they're doing something incorrect.
| placer_params = ObjectPlacerParams( | ||
| placement_seed=getattr(self.args, "placement_seed", None), | ||
| ) |
There was a problem hiding this comment.
I think we should make an unguarded access of self.args.placement_seed. Something has gone wrong if we end up here without placement_seed.
There was a problem hiding this comment.
Sure, we will use self.args.placement_seed.
| reset_solver_params = replace(placer_params.solver_params, save_position_history=False) | ||
| reset_params = replace( | ||
| placer_params, | ||
| solver_params=reset_solver_params, | ||
| apply_positions_to_objects=False, | ||
| verbose=False, | ||
| placement_seed=None, | ||
| ) |
There was a problem hiding this comment.
Why do we have to force the following params:
- save_position_history=False,
- apply_positions_to_objects=False
- verbose=False
I can see the justification for the placement_seed (although perhaps we should set that to false one level above and then warn if it's not None at this level).
There was a problem hiding this comment.
We have now moved them to construction into arena_env_builder.py. save_position_history is turned off for object reset because it is for verification purpose. We don't use apply_positions_to_objects because we write them directly to sim. verbose is turned off so we removed this forcing here.
| if isinstance(result, MultiEnvPlacementResult): | ||
| results_per_env = result.results | ||
| else: | ||
| results_per_env = [result] |
There was a problem hiding this comment.
This strikes me as a little strange. If we immediately convert the single environment solution to a list on return, should we just always return a MultiEnvPlacementResult?
There was a problem hiding this comment.
This comes from the ObjectPlacer where we have MultiEnvPlacementResult for n_envs>1 and PlacementResult for n_envs=1. Do we want to edit the design in that level?
There was a problem hiding this comment.
Yea, maybe we should leave that for another MR. I think we should consider it in general.
| # Always write positions, even when validation failed — the solver's best-effort | ||
| # is still better than the USD defaults (objects at origin). |
There was a problem hiding this comment.
I guess we warn if the solving failed? Seems like we should warn before writing.
| pose_t_xyz_q_xyzw = pose.to_tensor(device=env.device).unsqueeze(0) | ||
| pose_t_xyz_q_xyzw[0, :3] += env.scene.env_origins[cur_env, :] | ||
| asset.write_root_pose_to_sim(pose_t_xyz_q_xyzw, env_ids=env_id_tensor) | ||
| asset.write_root_velocity_to_sim(zero_velocity, env_ids=env_id_tensor) |
There was a problem hiding this comment.
Should we put this in a function somewhere if it's not already?
There was a problem hiding this comment.
These 4 lines are similar in some other code for setting object poses, but has slight differences. I would suggest we keep it for now and do the cleaning later since this touches lots of things in relation solver.
| def solve_and_place_objects( | ||
| env: ManagerBasedEnv, | ||
| env_ids: torch.Tensor, | ||
| objects: list[ObjectBase], | ||
| placer_params: ObjectPlacerParams, | ||
| ) -> None: | ||
| """Coordinated reset event that re-runs the relation solver and writes fresh poses. | ||
|
|
||
| Registered as a single ``EventTermCfg(mode="reset")`` that replaces per-object | ||
| pose events for solver-managed objects. Each call solves for the subset of | ||
| environments being reset, producing a new random layout per env. | ||
|
|
||
| Args: | ||
| env: The Isaac Lab environment. | ||
| env_ids: 1-D tensor of environment indices being reset. | ||
| objects: All objects (including anchors) participating in relation solving. | ||
| placer_params: Parameters forwarded to ``ObjectPlacer``. Seed is forced to | ||
| ``None`` so each reset gets fresh randomness. | ||
| """ |
There was a problem hiding this comment.
One big question I have about this approach is that it's effectively gotten rid of our batching behaviour. One can imagine that over time, the (potentially 1000s) of environments in an evaluation will all eventually reset at different indices, which will mean that this event fires for 1 env frequently.
With the current design that will mean solving a lot of small problems frequently, rather than solving one big problem infrequently. This is likely to mean poor performance on the GPU.
How does the performance look. What do you think about the approach of solving a bigger batched problem and saving to a solution pool?
There was a problem hiding this comment.
I tested with num_envs=200 and num_envs=10 (both 10 max_attempts). They both take like 10 seconds. So the solver batches well, but the per-call overhead is significant for repeated resets. We could have a pool, but let me think about it and have a better design
There was a problem hiding this comment.
Yea that's matches expectation. The solve time is basically independent of batch size (at the small sizes we're dealing with). I think that makes having a pool look like a good option (because we get additional solutions for free at solve-time).
Let me know when you'd like me to re-review :)
| # Run the ObjectPlacer (default on_relation_z_tolerance_m accommodates solver residual). | ||
| # Positions are applied to objects via set_initial_pose (single-env: Pose/PoseRange, | ||
| # multi-env: PosePerEnv), so each object's event_cfg handles its own reset. | ||
| placement_seed = getattr(self.args, "placement_seed", None) | ||
| placer = ObjectPlacer(params=ObjectPlacerParams(placement_seed=placement_seed)) | ||
| num_envs = self.args.num_envs | ||
| result = placer.place(objects_with_relations, num_envs=num_envs) |
There was a problem hiding this comment.
So here, we've removed the upfront solve. So at the moment we're enforcing that we have a new solution per reset.
I could imagine that some users may still want that behavior. It would be great if we had a parameter that controlled resolve_on_reset.
This actually relates to another comment further down about a solution pool. If we change the design slightly to draw solutions from a pool, implementing reset_on_solve = False is easy: we just don't remove solutions from the pool when they're used.
8bbff08 to
9029b28
Compare
bf61210 to
987545e
Compare
Summary
Pool-based object placement with fresh layouts on environment reset
Detailed description
What changed:
PlacementPoolthat pre-solves valid layouts at build time and serves them on reset viaplacement_events.py.ArenaEnvBuilder._solve_relations()to use the pool, with--resolve_on_reset/--no-resolve_on_resetcontrolling behavior.--episode_length_stogr1_table_multi_object_no_collisionfor periodic resets.Impact