Skip to content

Add Rotation Initialization for Objects#738

Open
zhx06 wants to merge 2 commits into
mainfrom
zxiao/random_object_rotation_init
Open

Add Rotation Initialization for Objects#738
zhx06 wants to merge 2 commits into
mainfrom
zxiao/random_object_rotation_init

Conversation

@zhx06
Copy link
Copy Markdown
Collaborator

@zhx06 zhx06 commented May 29, 2026

Summary

Add random yaw initialization for placed objects

Detailed description

  • Add --random_yaw_init flag for per-object random z-rotation.
  • Add conservative rotated-AABB support so collision checks account for yaw.
  • Add related tests.

Copy link
Copy Markdown
Contributor

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Choose a reason for hiding this comment

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

🤖 Isaac Lab Review Bot

Summary

This PR adds random yaw initialization for placed objects via a new --random_yaw_init CLI flag. The implementation uses conservative bounding boxes (axis-aligned boxes enclosing the rotated object) for collision detection, ensuring layout validity while adding scene variety. The code is well-structured, follows Arena patterns, and includes comprehensive test coverage.

Findings

🔵 Suggestion - isaaclab_arena/utils/pose.py:60-67 - Consider caching or inlining the quaternion composition for hot paths. The current implementation creates two temporary Pose objects per call to rotate_quat_by_yaw. While correct, if this is called in tight loops (multi-env × objects), a direct quaternion multiply formula would be more efficient:

# Direct Z-rotation composition (avoids Pose allocation):
# yaw_q = (0, 0, sin(θ/2), cos(θ/2)); base_q = (x, y, z, w)
# result_w = w*cos - z*sin; result_z = w*sin + z*cos; result_x/y unchanged for pure-Z

🔵 Suggestion - isaaclab_arena/relations/object_placer.py:232 - The yaw is sampled uniformly in [-π, π) but the comment says "uniform in [-pi, pi)". Since torch.rand produces [0, 1), the formula (2u - 1) * π actually gives [-π, π), which is correct. However, for symmetry, [0, 2π) might be more conventional (same distribution, different range). This is purely stylistic.

🔵 Suggestion - isaaclab_arena/relations/placement_result.py:31-32 - The orientations field uses field(default_factory=dict) which is the right pattern, but consider documenting that this dict is keyed by ObjectBase instances (same as positions) in the docstring for API clarity.

🟡 Warning - isaaclab_arena/relations/relation_solver_state.py:112-119 - When initial_orientations is provided but an object has no entry in some env's dict, d.get(obj, 0.0) correctly defaults to 0. However, the conditional if any(yaw != 0.0 for yaw in yaws) means a single non-zero yaw causes bbox rotation for all envs. This is intentional (and documented), but worth noting: if only env[0] has yaw=π/2 while others have 0, the per-env rotated bbox tensor will have different values per row, which is correct. The logic is sound.

🔵 Suggestion - isaaclab_arena/utils/bounding_box.py:231-239 - The Z-extent broadcasting logic handles the N=1, M>1 case explicitly. Consider adding a brief comment explaining this edge case for future maintainers:

# When N=1 box is rotated by M angles, Z extents must broadcast to match M rows.

Test Coverage

Excellent. The PR adds 8 new/modified test files with thorough coverage:

  • test_bounding_box.py: Tests rotated_around_z for single and batched angles, including 45°/90° edge cases
  • test_no_collision_loss.py: End-to-end test proving the solver uses rotated bboxes (regression guard)
  • test_object_placer_reproducibility.py: Tests yaw composition with markers, multi-env distinct yaws, state mismatch errors
  • test_placement_events.py: Runtime path verification for solve_and_place_objects with yawed poses
  • test_validate_placement.py: Rotation-aware overlap detection

The tests are well-designed—test_solver_uses_rotated_bbox_for_collision would fail if the solver reverted to unrotated bboxes, providing effective regression protection.

CI Status

⏳ Pre-commit check is pending

Verdict

No issues found. Clean implementation with proper device handling, correct quaternion math, and defensive coding (e.g., yaw == 0.0 fast paths). The conservative AABB approach is the right trade-off for a non-optimized parameter. Ready to merge once CI passes.


Update (5b21206): The new commit addresses reviewer comments and adds significant improvements:

Changes reviewed:

  1. Metrics system refactor - MetricBase.compute_metric_from_recording() replaced with get_metric_term_cfg() returning a MetricTermCfg containing a callable. The env now has a MetricsManager and exposes compute_metrics() directly. This is a cleaner separation of config vs computation.
  2. Module split - isaaclab_arena_manager_based_env.py split into _env.py (runtime) and _env_cfg.py (config). Good for maintainability.
  3. Bug fix - compile_env_notebook.py: Fixed rotation_wxyzrotation_xyzw typo.
  4. Parameter tuning - PickAndPlaceTask.force_threshold changed from 1.0 → 0.1 (more sensitive success detection).
  5. Docs - Added CloudXR network requirement notices to teleoperation workflows.
  6. Tests updated - All tests now use env.unwrapped.compute_metrics() instead of the removed compute_metrics(env) function.

Assessment: All changes look good. The metrics refactor follows Isaac Lab patterns (configclass + term cfg + manager). No new issues introduced.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 29, 2026

Greptile Summary

This PR introduces per-object random yaw initialization for non-anchor placeable objects, adding a --random_yaw_init CLI flag and threading the feature end-to-end from candidate generation through the solver, validation, pooling, and final pose application.

  • Orientation sampling: each candidate independently draws a uniform yaw in [-π, π) per non-anchor object using the existing seeded generator (after position draws), so positions are reproducible regardless of whether yaw init is on.
  • Conservative AABB: a new rotated_around_z(angle) method on AxisAlignedBoundingBox refits the enclosing axis-aligned box after rotation; this batched-aware implementation handles scalar angles (N-box × 1 angle) and per-env tensors (1-box × M angles, or N==M), and is used uniformly by both the solver's loss computation and the post-solve validation.
  • Quaternion composition: rotate_quat_by_yaw composes the sampled yaw on top of any existing RotateAroundSolution marker via the Hamilton product, with a fast early-exit for zero yaw.
  • Storage: PlacementResult.orientations (defaulting to an empty dict) carries the per-object yaw through the placement pool to the runtime reset event, ensuring the same yaw written into the solver is the one applied to the physics scene.

Confidence Score: 5/5

The changes are self-contained: existing code paths are unaffected when random_yaw_init is off (empty dicts default all yaws to 0.0), and the rotated-bbox math is well-tested at multiple granularities.

The quaternion composition, conservative AABB refit, per-candidate seeding, and batched bbox shape handling are all correct. Loss strategies implicitly broadcast (batch_size, 3) bboxes correctly with existing (N,3) position tensors. The PlacementResult.orientations field propagates the yaw consistently through pooling and reset events.

No files require special attention. The most complex new code — rotated_around_z in bounding_box.py and the _local_bboxes precomputation in relation_solver_state.py — is directly covered by the new tests.

Important Files Changed

Filename Overview
isaaclab_arena/utils/bounding_box.py Adds rotated_around_z() method that correctly computes the conservative enclosing AABB for arbitrary Z-rotations, handling both scalar angles and batched tensors via PyTorch broadcasting.
isaaclab_arena/utils/pose.py Adds rotate_quat_by_yaw() using the Hamilton product q_base ⊗ q_yaw; math is correct for pure-Z base rotations (identity or RotateAroundSolution), returns fast early-exit when yaw is 0.
isaaclab_arena/relations/relation_solver_state.py Precomputes per-env rotated bboxes in _local_bboxes at init time; correctly handles all shape combinations (N=1 box × M envs, N boxes × 1 angle, N==M) and skips rotation when all yaws are zero.
isaaclab_arena/relations/object_placer.py Adds orientation generation alongside position generation; seeds are reset per-candidate before both calls so positions are reproducible regardless of random_yaw_init; validation and apply paths both thread orientations correctly.
isaaclab_arena/relations/relation_solver.py Switches from obj.get_bounding_box() to state.get_local_bbox(obj) throughout; loss strategies receive (batch_size, 3) bboxes when rotated, which broadcast correctly with existing (N,3) child_pos arithmetic.
isaaclab_arena/relations/placement_result.py Adds orientations field with default_factory=dict, keeping backward compatibility when random_yaw_init is off (empty dict yields 0.0 via .get(obj, 0.0)).
isaaclab_arena/relations/placement_events.py Switches from pre-computed rotation dict to per-event rotate_quat_by_yaw calls using the layout's stored orientations; semantically correct.
isaaclab_arena/environments/relation_solver_interface.py Correctly propagates random_yaw_init through both static and dynamic placement paths, applying rotate_quat_by_yaw with the layout's stored per-object yaw.
isaaclab_arena/relations/object_placer_params.py Adds random_yaw_init: bool = False with clear docstring; default keeps existing behavior unchanged.
isaaclab_arena/cli/isaaclab_arena_cli.py Adds --random_yaw_init CLI flag with informative help text explaining the conservative bbox behavior.
isaaclab_arena/environments/arena_env_builder.py One-line change forwarding random_yaw_init from parsed args to solve_and_apply_relation_placement.
isaaclab_arena/tests/test_bounding_box.py Adds two tests covering single-angle and batched-angle paths for rotated_around_z, including a numerical check against rotated_90_around_z at pi/2.
isaaclab_arena/tests/test_no_collision_loss.py Adds test proving the solver no-overlap loss increases when a long box is yaw-rotated into a neighbouring cube.
isaaclab_arena/tests/test_object_placer_reproducibility.py Adds five tests covering non-anchor-only yaw, marker composition, mismatch assertion, multi-env distinct yaws, and quaternion composition correctness.
isaaclab_arena/tests/test_placement_events.py Adds end-to-end test for solve_and_place_objects with random_yaw_init=True, checking qx/qy near zero and at least one object has non-zero qz.
isaaclab_arena/tests/test_validate_placement.py Adds test confirming 90° yaw on a long box causes conservative AABB to overlap a previously-clear cube, verifying yaw-aware validation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[ObjectPlacer.place] --> B[_generate_initial_positions per candidate]
    A --> C[_generate_initial_orientations per candidate]
    C --> D{random_yaw_init?}
    D -- Yes --> E[Sample yaw uniform in neg-pi to pi per non-anchor]
    D -- No --> F[Return empty dict]
    E --> G[RelationSolver.solve with initial_orientations]
    F --> G
    B --> G
    G --> H[RelationSolverState: Precompute _local_bboxes rotated_around_z per-env]
    H --> I[Loss computation uses rotated bboxes]
    I --> J[_validate_placement uses same rotated bboxes]
    J --> K[PlacementCandidate with .orientations stored]
    K --> L{apply_positions_to_objects?}
    L -- Yes --> M[_apply_positions: rotate_quat_by_yaw per env]
    L -- No --> N[PlacementResult .orientations in pool]
    N --> O[PooledObjectPlacer samples on reset]
    O --> P1[solve_and_place_objects: rotate_quat_by_yaw runtime]
    O --> P2[_apply_static_initial_poses: rotate_quat_by_yaw at init]
    M --> Q[Pose written to sim]
    P1 --> Q
    P2 --> Q
Loading

Reviews (2): Last reviewed commit: "address comments" | Re-trigger Greptile

Comment thread isaaclab_arena/utils/pose.py Outdated
Comment on lines +56 to +63
def rotate_quat_by_yaw(
base_xyzw: tuple[float, float, float, float], yaw_rad: float
) -> tuple[float, float, float, float]:
"""Rotate ``base_xyzw`` (xyzw) by an extra yaw about Z. Returns base unchanged when yaw is 0."""
if yaw_rad == 0.0:
return base_xyzw
yaw_xyzw = (0.0, 0.0, math.sin(yaw_rad / 2.0), math.cos(yaw_rad / 2.0))
return Pose(rotation_xyzw=base_xyzw).multiply(Pose(rotation_xyzw=yaw_xyzw)).rotation_xyzw
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The function routes through compose_posesmatrix_from_quat → matrix multiply → quat_from_matrix to compose two quaternions that are always pure Z-rotations. Direct quaternion multiplication is simpler, removes the isaaclab import dependency from the fast path, and avoids the round-trip precision loss through matrix form. Since base_xyzw can only ever be a pure-Z quaternion in this codebase (identity or from RotateAroundSolution), a direct formula works correctly.

Suggested change
def rotate_quat_by_yaw(
base_xyzw: tuple[float, float, float, float], yaw_rad: float
) -> tuple[float, float, float, float]:
"""Rotate ``base_xyzw`` (xyzw) by an extra yaw about Z. Returns base unchanged when yaw is 0."""
if yaw_rad == 0.0:
return base_xyzw
yaw_xyzw = (0.0, 0.0, math.sin(yaw_rad / 2.0), math.cos(yaw_rad / 2.0))
return Pose(rotation_xyzw=base_xyzw).multiply(Pose(rotation_xyzw=yaw_xyzw)).rotation_xyzw
def rotate_quat_by_yaw(
base_xyzw: tuple[float, float, float, float], yaw_rad: float
) -> tuple[float, float, float, float]:
"""Rotate ``base_xyzw`` (xyzw) by an extra yaw about Z. Returns base unchanged when yaw is 0."""
if yaw_rad == 0.0:
return base_xyzw
bx, by, bz, bw = base_xyzw
sz = math.sin(yaw_rad / 2.0)
cz = math.cos(yaw_rad / 2.0)
# Hamilton product: base * yaw_quat (both are Z-axis rotations, so they commute)
return (
bx * cz + by * sz,
-bx * sz + by * cz,
bz * cz + bw * sz,
-bz * sz + bw * cz,
)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines 148 to 154
poses = []
for env_idx in range(num_envs):
pos = layouts[env_idx].positions.get(obj)
if pos is None:
break
rotation_xyzw = rotate_quat_by_yaw(get_rotation_xyzw(obj), layouts[env_idx].orientations.get(obj, 0.0))
poses.append(Pose(position_xyz=pos, rotation_xyzw=rotation_xyzw))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 get_rotation_xyzw(obj) is called once per env_idx inside the inner loop, but it returns the same base rotation for every environment (it reads from the object's static marker, not from the layout). Hoisting it out avoids num_envs redundant calls per object.

Suggested change
poses = []
for env_idx in range(num_envs):
pos = layouts[env_idx].positions.get(obj)
if pos is None:
break
rotation_xyzw = rotate_quat_by_yaw(get_rotation_xyzw(obj), layouts[env_idx].orientations.get(obj, 0.0))
poses.append(Pose(position_xyz=pos, rotation_xyzw=rotation_xyzw))
poses = []
base_rotation_xyzw = get_rotation_xyzw(obj)
for env_idx in range(num_envs):
pos = layouts[env_idx].positions.get(obj)
if pos is None:
break
rotation_xyzw = rotate_quat_by_yaw(base_rotation_xyzw, layouts[env_idx].orientations.get(obj, 0.0))
poses.append(Pose(position_xyz=pos, rotation_xyzw=rotation_xyzw))

Comment on lines +214 to +217
device = self._min_point.device
angles = torch.as_tensor(angle_rad, dtype=torch.float32, device=device).reshape(-1) # (M,)

cos = torch.cos(angles).unsqueeze(1) # (M, 1)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The docstring states that N and M must satisfy N==1, M==1, or N==M, but there is no runtime check. When N>1 and M>1 and N!=M, the broadcast of (N, 4) corners against (M, 1) cos/sin will raise a cryptic size-mismatch error rather than a clear message. A short assertion would make the constraint visible at call time.

Suggested change
device = self._min_point.device
angles = torch.as_tensor(angle_rad, dtype=torch.float32, device=device).reshape(-1) # (M,)
cos = torch.cos(angles).unsqueeze(1) # (M, 1)
device = self._min_point.device
angles = torch.as_tensor(angle_rad, dtype=torch.float32, device=device).reshape(-1) # (M,)
n, m = self._min_point.shape[0], angles.shape[0]
assert n == 1 or m == 1 or n == m, (
f"rotated_around_z: box count (N={n}) and angle count (M={m}) must satisfy N==1, M==1, or N==M."
)
cos = torch.cos(angles).unsqueeze(1) # (M, 1)

zhx06 added 2 commits May 29, 2026 09:31
Signed-off-by: zhx06 <zihaox@nvidia.com>
@zhx06 zhx06 force-pushed the zxiao/random_object_rotation_init branch from a3ea787 to 5b21206 Compare May 29, 2026 16:31
resolve_on_reset: Optional override for whether to draw fresh layouts from
the placement pool on reset. When ``False``, fixed per-environment
initial poses are applied immediately.
random_yaw_init: If True, give each non-anchor object a random fixed yaw about Z
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Docstring is lil jorgan-y, to make it clean by
If True, randomly rotates non-anchor objects around the vertical (Z) axis at startup to add visual variety to the scene.

return compose_poses(self, other)


def rotate_quat_by_yaw(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can you add a unit test for this math func?

base_xyzw: tuple[float, float, float, float], yaw_rad: float
) -> tuple[float, float, float, float]:
"""Rotate base_xyzw (xyzw) by an extra yaw about Z. Returns base unchanged when yaw is 0."""
if yaw_rad == 0.0:
Copy link
Copy Markdown
Collaborator

@xyao-nv xyao-nv May 29, 2026

Choose a reason for hiding this comment

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

How about wrapping yaw_rad to [-pi, pi] to avoid unnecessary compute and potential errors caused by precision?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I see, in _generate_initial_orientations() it outs [-pi, pi].
But for this helper func alone, it could be reused with user defined yaw_rad.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Got it, thanks for clarifying! Since _generate_initial_orientations() outputs [-π, π] but this helper expects [0, 2π], the ranges are intentionally different. Makes sense to keep them as-is. 👍

"""Number of attempts made."""

orientations: dict[ObjectBase, float] = field(default_factory=dict)
"""Per-object fixed yaw (radians about Z) for this layout. Empty when unrotated."""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It will be more informative if you clarify w.r.t to what frame.

fixed yaw

bbox = obj.get_bounding_box().to(self._device)
if initial_orientations is not None:
yaws = [d.get(obj, 0.0) for d in initial_orientations]
if any(yaw != 0.0 for yaw in yaws):
Copy link
Copy Markdown
Collaborator

@xyao-nv xyao-nv May 29, 2026

Choose a reason for hiding this comment

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

again, I strongly encourage wrapping yaw before branching and applying the transformation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Either you can ensure yaw is only provided thru _generate_initial_orientations(), so [-pi, pi] is enforced.
Or you can add a 'if not wrapper, wrap it' to be safe.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both approaches work. The second option (defensive wrap) is more robust if yaw could come from other sources in the future. Thanks for clarifying!

env_id_tensor = torch.tensor([cur_env], device=env.device)
positions = layouts_per_env[local_idx].positions
result = layouts_per_env[local_idx]
positions = result.positions
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is it needed? You are only reusing it once next line.
results.orientations stays its own tho.

via the same conservative enclosing AABB the solver uses, so validation matches solving."""
bbox = obj.get_bounding_box()
yaw = (orientations or {}).get(obj, 0.0)
if yaw != 0.0:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

wrapping

else:
poses = [
Pose(position_xyz=positions_per_env[env_idx][obj], rotation_xyzw=rotation_xyzw)
Pose(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Shall this func be updated to _apply_pose()

self._opt_idx_tensor = None
self._optimizable_positions = None

# Precompute each object's local bbox on the solver device. Rotate to the conservative
Copy link
Copy Markdown
Collaborator

@xyao-nv xyao-nv May 29, 2026

Choose a reason for hiding this comment

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

# Precompute original bounding boxes on the GPU/device.
# If an object is rotated (yaw != 0) in any environment, calculate a larger,
# unrotated "enclosing" box to fit it, creating a separate box per environment.
# Otherwise, keep a single base box and let the loss function broadcast it to save memory.

^ Is my understanding correct?

action="store_true",
default=False,
help=(
"Initialize non-anchor placeable objects with a random yaw about the up (Z) axis for scene variety. "
Copy link
Copy Markdown
Collaborator

@xyao-nv xyao-nv May 29, 2026

Choose a reason for hiding this comment

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

help=(
        "Randomly rotate objects (except anchors) around the Z-axis for scene variety. "
        "Collisions use a larger enclosing box; the solver won't optimize this rotation."
    ),

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