From b986b46969c42c54787877f425ead831875ba085 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Wed, 29 Jul 2026 17:20:48 -0700 Subject: [PATCH 01/17] Add a Rerun debug view of build-time placement validation Placement solving is sim-free, so its candidate layouts have been hard to inspect without starting Isaac Sim. Opting in via ObjectPlacerParams now streams every candidate to a Rerun viewer (or an .rrd, for headless runs). - Add PlacementRerunVisualizer: one process-wide view that draws each candidate's object boxes and the checks that accepted or rejected it, one frame per candidate on a `candidate` timeline. - Turn it on with ObjectPlacerParams.debug_visualize / debug_visualize_rrd_path; ObjectPlacer builds the view before its validators so a check can add its own layer to it. - Let the cuRobo reachability check layer on what only it knows: the robot base frame, the top-down grasps it solved, per-target reachable/unreachable verdicts, and its IK error scalars. - Keep frames aligned across checks: expensive checks only see the candidates that passed the cheap ones, so the placer tells the view which candidates the running check was handed. Signed-off-by: Xinjie Yao --- isaaclab_arena/relations/object_placer.py | 49 ++++ .../relations/object_placer_params.py | 19 ++ .../relations/placement_visualizer.py | 242 ++++++++++++++++++ .../tests/test_placement_visualizer.py | 108 ++++++++ .../ik_reachability_validator.py | 28 +- .../reachability_visualizer.py | 95 +++++++ .../tests/test_ik_reachability_validator.py | 64 ++++- 7 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 isaaclab_arena/relations/placement_visualizer.py create mode 100644 isaaclab_arena/tests/test_placement_visualizer.py create mode 100644 isaaclab_arena_curobo/reachability_visualizer.py diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 0035172d78..0e1420d632 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -14,6 +14,7 @@ from isaaclab_arena.relations.placement_result import PlacementResult from isaaclab_arena.relations.placement_validation import PlacementValidationResults from isaaclab_arena.relations.placement_validators import build_validators +from isaaclab_arena.relations.placement_visualizer import get_or_create_placement_visualizer from isaaclab_arena.relations.relation_solver import RelationSolver from isaaclab_arena.relations.relations import ( FaceTo, @@ -78,6 +79,8 @@ class ObjectPlacer: def __init__(self, params: ObjectPlacerParams | None = None): self.params = params or ObjectPlacerParams() self._solver = RelationSolver(params=self.params.solver_params) + # Populated before the validators are built so a check can add its own layer to the same view. + self.params.debug_visualizer = get_or_create_placement_visualizer(self.params) self._validators: list[PlacementValidator] = build_validators(self.params) def place( @@ -598,6 +601,8 @@ def _validate_candidates( # Per-check count of layouts evaluated by that check num_layouts_evaluated_by_check: dict[str, int] = {} layout_pass_verdicts_by_check: dict[str, list[bool]] = {} + # Layouts are drawn before the checks run so a check's own layer lands on top of its candidate. + candidate_indices = self._log_candidate_layouts(positions, orientations, bboxes) self._run_inexpensive_checks( positions, @@ -606,6 +611,7 @@ def _validate_candidates( collision_objects, layout_pass_verdicts_by_check, num_layouts_evaluated_by_check, + candidate_indices, ) self._run_expensive_checks( positions, @@ -615,7 +621,9 @@ def _validate_candidates( required, layout_pass_verdicts_by_check, num_layouts_evaluated_by_check, + candidate_indices, ) + self._log_candidate_verdicts(candidate_indices, layout_pass_verdicts_by_check) if layout_pass_verdicts_by_check: summary = ", ".join( f"{check}={sum(verdicts)}/{num_layouts_evaluated_by_check[check]}" @@ -640,11 +648,14 @@ def _run_inexpensive_checks( collision_objects: list[CollisionObject], layout_pass_verdicts_by_check: dict[str, list[bool]], num_layouts_evaluated_by_check: dict[str, int], + candidate_indices: list[int] | None = None, ) -> None: """Run every inexpensive validator on all candidates, recording verdicts and evaluated counts.""" num_candidates = len(positions) for validator in self._validators: if not validator.run_after_inexpensive_checks: + if self.params.debug_visualizer is not None: + self.params.debug_visualizer.set_active_candidates(candidate_indices or []) layout_pass_verdicts_by_check[validator.check] = validator.validate_batch( positions, orientations, bboxes, collision_objects ) @@ -659,6 +670,7 @@ def _run_expensive_checks( required: set[str] | None, layout_pass_verdicts_by_check: dict[str, list[bool]], num_layouts_evaluated_by_check: dict[str, int], + candidate_indices: list[int] | None = None, ) -> None: """Run each expensive validator only on candidates that passed the required inexpensive checks.""" num_candidates = len(positions) @@ -669,6 +681,10 @@ def _run_expensive_checks( for i in range(num_candidates) if self._passes_required_checks(layout_pass_verdicts_by_check, required, i) ] + if self.params.debug_visualizer is not None: + self.params.debug_visualizer.set_active_candidates( + [(candidate_indices or [])[i] for i in passed_layout_indices] + ) # only passed layouts are validated verdicts_over_passed_layout = validator.validate_batch( [positions[i] for i in passed_layout_indices], @@ -682,6 +698,39 @@ def _run_expensive_checks( layout_pass_verdicts_by_check[validator.check] = verdicts num_layouts_evaluated_by_check[validator.check] = len(passed_layout_indices) + def _log_candidate_layouts( + self, + positions: list[dict[PlaceableAsset, tuple[float, float, float]]], + orientations: list[dict[PlaceableAsset, float]], + bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], + ) -> list[int] | None: + """Draw every candidate of this batch in the Rerun debug view, returning their timeline indices. + + None when the debug view is off, which is the default. + """ + visualizer = self.params.debug_visualizer + if visualizer is None: + return None + candidate_indices = visualizer.next_batch_indices(len(positions)) + for slot, candidate_index in enumerate(candidate_indices): + anchors = set(get_anchor_objects(list(positions[slot]))) + visualizer.log_layout(candidate_index, positions[slot], orientations[slot], bboxes[slot], anchors) + return candidate_indices + + def _log_candidate_verdicts( + self, + candidate_indices: list[int] | None, + layout_pass_verdicts_by_check: dict[str, list[bool]], + ) -> None: + """Annotate each drawn candidate with the checks that accepted or rejected it.""" + visualizer = self.params.debug_visualizer + if visualizer is None or candidate_indices is None: + return + for slot, candidate_index in enumerate(candidate_indices): + visualizer.log_verdicts( + candidate_index, {check: verdicts[slot] for check, verdicts in layout_pass_verdicts_by_check.items()} + ) + @staticmethod def _passes_required_checks( layout_pass_verdicts_by_check: dict[str, list[bool]], diff --git a/isaaclab_arena/relations/object_placer_params.py b/isaaclab_arena/relations/object_placer_params.py index b6192c7769..df80fbcaff 100644 --- a/isaaclab_arena/relations/object_placer_params.py +++ b/isaaclab_arena/relations/object_placer_params.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from isaaclab_arena.embodiments.embodiment_base import EmbodimentBase + from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer @dataclass @@ -84,3 +85,21 @@ class ObjectPlacerParams: reachability_config: ReachabilityConfig = field(default_factory=ReachabilityConfig) """Tuning for the optional ``ik_reachable`` build-time check. See ReachabilityConfig for more details.""" + + debug_visualize: bool = False + """If True, stream every validated candidate layout to a spawned Rerun viewer window. + + Debug aid, off by default. Needs the ``rerun-sdk`` package and a reachable display (the container + forwards ``DISPLAY``); the viewer is its own process, so this never starts Isaac Sim. Checks that + can say more about a candidate add their own layer -- the cuRobo reachability check draws the + grasps it solved and the robot's collision spheres.""" + + debug_visualize_rrd_path: str | None = None + """Path to record the debug visualization to as a Rerun ``.rrd`` file, for headless runs. + + Enables the visualization on its own; combine with ``debug_visualize`` to both record and watch live.""" + + debug_visualizer: PlacementRerunVisualizer | None = None + """The live Rerun view the debug fields above ask for; populated by ObjectPlacer, not by callers. + + Carried here so validators, which only receive these params, can add their own layer to it.""" diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py new file mode 100644 index 0000000000..e9446e6fc0 --- /dev/null +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -0,0 +1,242 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Rerun debug view of build-time placement validation, sim-free (no SimApp). + +Rerun's viewer is a separate process fed by the logging SDK, so nothing here touches Isaac Sim -- the +window comes up while layouts are being solved, before any simulation exists. Enabled through +``ObjectPlacerParams.debug_visualize`` / ``debug_visualize_rrd_path``. + +Every candidate layout is one frame of the ``candidate`` timeline, so scrubbing it shows what was +solved and which checks rejected it. Checks that know more about a candidate than its boxes add their +own layer under ``world/robot`` (see the cuRobo reachability check). +""" + +from __future__ import annotations + +import math +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams + from isaaclab_arena.relations.placement_asset import PlaceableAsset + from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox + +CANDIDATE_TIMELINE = "candidate" +"""Rerun timeline whose sequence index is the candidate layout number.""" + +LAYOUT_ENTITY = "world/layout" +"""Entity path of the candidate's object boxes.""" + +ROBOT_ENTITY = "world/robot" +"""Entity path reserved for check-specific robot layers; cleared per candidate so a check that skips a +candidate does not leave its previous frame's geometry on screen.""" + +ANCHOR_COLOR = (140, 140, 150) +"""Color of the layout's anchors, which are fixed and only act as obstacles.""" + +MOVABLE_COLOR = (70, 130, 220) +"""Color of the objects placement actually solves for.""" + +ACCEPTED_COLOR = (40, 200, 80) +"""Color marking a candidate every required check accepted.""" + +REJECTED_COLOR = (220, 50, 50) +"""Color marking a candidate at least one check rejected.""" + +_ACTIVE_VISUALIZER: PlacementRerunVisualizer | None = None +"""The process's live view. Placement builds several placers (pool, per-reset solves) that would +otherwise each reset Rerun's global recording and fight over the same viewer port and ``.rrd``.""" + + +def get_or_create_placement_visualizer(params: ObjectPlacerParams) -> PlacementRerunVisualizer | None: + """Return the process's Rerun view of placement validation, or None when the params ask for none. + + Args: + params: Placement parameters carrying the ``debug_visualize`` / ``debug_visualize_rrd_path`` fields. + """ + global _ACTIVE_VISUALIZER + if not params.debug_visualize and params.debug_visualize_rrd_path is None: + return None + if _ACTIVE_VISUALIZER is None: + _ACTIVE_VISUALIZER = PlacementRerunVisualizer( + spawn=params.debug_visualize, rrd_path=params.debug_visualize_rrd_path + ) + return _ACTIVE_VISUALIZER + + +def find_rerun_viewer_executable() -> str | None: + """Return the path of the Rerun viewer binary shipped with ``rerun-sdk``, or None if absent. + + Isaac Sim's Python does not put the packaged ``rerun_cli`` directory on PATH, so ``rr.spawn()`` + fails to find the viewer unless it is passed explicitly. + """ + import rerun as rr + + executable = Path(rr.__file__).parents[1] / "rerun_cli" / "rerun" + return str(executable) if executable.is_file() else None + + +class PlacementRerunVisualizer: + """Streams every validated candidate layout to Rerun, one frame per candidate. + + Holds only plain attributes (the recording itself is Rerun's process-global stream) so the + placement event config can deep-copy the pool that owns it. + """ + + def __init__(self, app_id: str = "arena_placement", spawn: bool = True, rrd_path: str | None = None) -> None: + """Start the recording and, unless recording headlessly, spawn a viewer window. + + Args: + app_id: Rerun application id, shown in the viewer title. + spawn: Whether to spawn a local viewer window and stream to it. + rrd_path: Optional path to also record the stream to, for replay on another machine. + """ + import rerun as rr + + rr.init(app_id, spawn=False) + sinks: list = [] + if spawn: + # connect=False: the sink is set below, so spawn only has to bring the viewer process up. + rr.spawn(connect=False, executable_path=find_rerun_viewer_executable()) + sinks.append(rr.GrpcSink()) + if rrd_path is not None: + Path(rrd_path).parent.mkdir(parents=True, exist_ok=True) + sinks.append(rr.FileSink(rrd_path)) + assert sinks, "PlacementRerunVisualizer needs a viewer to spawn or an .rrd path to record to." + rr.set_sinks(*sinks) + rr.log("world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True) + self._next_candidate_index = 0 + self._active_candidate_indices: list[int] = [] + + def __deepcopy__(self, memory_map: dict[int, object]) -> PlacementRerunVisualizer: + """Return the live view for ``copy.deepcopy`` instead of duplicating it. + + Isaac Lab's configclass deep-copies the placement event params that carry the pool, and a + duplicated view would keep its own candidate counter and overwrite frames this one already drew. + + Args: + memory_map: ``copy.deepcopy``'s ``id(original) -> copy`` cache. + """ + memory_map[id(self)] = self + return self + + @property + def num_logged_candidates(self) -> int: + """How many candidate layouts have been given a frame so far.""" + return self._next_candidate_index + + def next_batch_indices(self, num_candidates: int) -> list[int]: + """Reserve and return one timeline index per candidate of the batch about to be validated. + + Indices keep counting across batches so a pool that refills several times does not overwrite + its earlier frames. + """ + start = self._next_candidate_index + self._next_candidate_index += num_candidates + return list(range(start, self._next_candidate_index)) + + def set_active_candidates(self, candidate_indices: list[int]) -> None: + """Declare which candidates the validator about to run will see, in the order it sees them. + + Expensive checks only run on the candidates that passed the cheap ones, so their batch position + is not the candidate number; this is what lets them log against the right frame. + """ + self._active_candidate_indices = list(candidate_indices) + + def candidate_index_for_slot(self, slot: int) -> int: + """Timeline index of the ``slot``-th candidate in the batch the running validator was given.""" + return self._active_candidate_indices[slot] + + def set_time(self, candidate_index: int) -> None: + """Point the recording at one candidate's frame, so subsequent logs land on it.""" + import rerun as rr + + rr.set_time(CANDIDATE_TIMELINE, sequence=candidate_index) + + def log_layout( + self, + candidate_index: int, + positions: dict[PlaceableAsset, tuple[float, float, float]], + orientations: dict[PlaceableAsset, float], + bboxes: dict[PlaceableAsset, AxisAlignedBoundingBox], + anchors: set[PlaceableAsset], + ) -> None: + """Log one candidate's solved layout as boxes in the world frame. + + Args: + candidate_index: Timeline index to log against. + positions: Solved (x, y, z) per object. + orientations: Absolute world Z-yaw per object; objects without one are drawn unrotated. + bboxes: Per-object local bounding box. + anchors: The layout's anchor objects, drawn in the anchor color. + """ + import rerun as rr + + self.set_time(candidate_index) + # A check that skips this candidate must not leave its previous candidate's robot on screen. + rr.log(ROBOT_ENTITY, rr.Clear(recursive=True)) + + objects = list(positions) + centers, half_sizes, quaternions = [], [], [] + for obj in objects: + yaw = orientations.get(obj, 0.0) + bbox = bboxes[obj] + local_center = [float(v) for v in bbox.center[0].tolist()] + cos_yaw, sin_yaw = math.cos(yaw), math.sin(yaw) + rotated_center = ( + cos_yaw * local_center[0] - sin_yaw * local_center[1], + sin_yaw * local_center[0] + cos_yaw * local_center[1], + local_center[2], + ) + position = positions[obj] + centers.append([position[i] + rotated_center[i] for i in range(3)]) + half_sizes.append([0.5 * float(v) for v in bbox.size[0].tolist()]) + quaternions.append(rr.Quaternion(xyzw=[0.0, 0.0, math.sin(0.5 * yaw), math.cos(0.5 * yaw)])) + + rr.log( + LAYOUT_ENTITY, + rr.Boxes3D( + centers=centers, + half_sizes=half_sizes, + quaternions=quaternions, + colors=[ANCHOR_COLOR if obj in anchors else MOVABLE_COLOR for obj in objects], + labels=[obj.name for obj in objects], + fill_mode=rr.components.FillMode.MajorWireframe, + ), + ) + + def log_verdicts(self, candidate_index: int, verdicts_by_check: dict[str, bool]) -> None: + """Log which checks accepted one candidate, as a text line and an accepted/rejected marker. + + Args: + candidate_index: Timeline index to log against. + verdicts_by_check: Per-check verdict for this candidate. + """ + import rerun as rr + + self.set_time(candidate_index) + failed = [check for check, passed in verdicts_by_check.items() if not passed] + accepted = not failed + rr.log( + f"{LAYOUT_ENTITY}/verdict", + rr.TextLog( + f"candidate {candidate_index}: {'accepted' if accepted else 'rejected'}" + + (f" (failed: {', '.join(failed)})" if failed else ""), + level=rr.TextLogLevel.INFO if accepted else rr.TextLogLevel.WARN, + ), + ) + for check, passed in verdicts_by_check.items(): + rr.log(f"checks/{check}", rr.Scalars(float(passed))) + + def close(self) -> None: + """Flush pending data so a recorded ``.rrd`` is readable without waiting for interpreter exit. + + A spawned viewer keeps running afterwards, so the layouts stay inspectable. + """ + import rerun as rr + + rr.get_global_data_recording().flush() diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py new file mode 100644 index 0000000000..568e2459c6 --- /dev/null +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the Rerun debug view of build-time placement validation. + +Placement is sim-free, so these run a real solve and a real recording, headless: the ``.rrd`` sink +replaces the viewer window, and no Isaac Sim or GPU is involved. +""" + +from __future__ import annotations + +import pytest + +from isaaclab_arena.relations import placement_visualizer +from isaaclab_arena.relations.object_placer import ObjectPlacer +from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams +from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer +from isaaclab_arena.relations.relation_solver_params import RelationSolverParams +from isaaclab_arena.relations.relations import IsAnchor, On +from isaaclab_arena.tests.dummy_object import DummyObject +from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox +from isaaclab_arena.utils.pose import Pose + +MAX_PLACEMENT_ATTEMPTS = 3 +"""Candidate layouts solved per placement, i.e. the number of frames one place() should draw.""" + + +@pytest.fixture(autouse=True) +def _fresh_process_visualizer(monkeypatch): + """Drop the process-wide view between tests so each one gets its own recording and ``.rrd``.""" + monkeypatch.setattr(placement_visualizer, "_ACTIVE_VISUALIZER", None) + + +def _desk_and_box() -> list[DummyObject]: + """A desk anchor with a box placed on it -- the smallest layout with something to solve.""" + desk = DummyObject( + name="desk", + bounding_box=AxisAlignedBoundingBox(min_point=(0.0, 0.0, 0.0), max_point=(1.0, 1.0, 0.1)), + ) + desk.set_initial_pose(Pose(position_xyz=(0.0, 0.0, 0.0), rotation_xyzw=(0.0, 0.0, 0.0, 1.0))) + desk.add_relation(IsAnchor()) + box = DummyObject( + name="box", + bounding_box=AxisAlignedBoundingBox(min_point=(0.0, 0.0, 0.0), max_point=(0.2, 0.2, 0.2)), + ) + box.add_relation(On(desk, clearance_m=0.01)) + return [desk, box] + + +def _placer_params(**overrides) -> ObjectPlacerParams: + """Placement params for a small, deterministic solve.""" + return ObjectPlacerParams( + solver_params=RelationSolverParams(max_iters=200, convergence_threshold=1e-3), + apply_positions_to_objects=False, + max_placement_attempts=MAX_PLACEMENT_ATTEMPTS, + placement_seed=5, + **overrides, + ) + + +def test_placement_has_no_debug_view_by_default(): + """The debug view is opt-in, so a default placement never touches Rerun.""" + placer = ObjectPlacer(_placer_params()) + + assert placer.params.debug_visualizer is None + + +def test_placement_records_every_candidate_layout(tmp_path): + """Recording to an .rrd draws every candidate the solve produced, one frame each, without a viewer.""" + rrd_path = tmp_path / "placement.rrd" + placer = ObjectPlacer(_placer_params(debug_visualize_rrd_path=str(rrd_path))) + + placer.place(_desk_and_box(), num_envs=1) + visualizer = placer.params.debug_visualizer + visualizer.close() + + assert visualizer.num_logged_candidates == MAX_PLACEMENT_ATTEMPTS + assert rrd_path.is_file() and rrd_path.stat().st_size > 0 + + +def test_placement_shares_one_debug_view_across_placers(tmp_path): + """Every placer in the process draws into the same view, keeping one viewer and one timeline.""" + params = _placer_params(debug_visualize_rrd_path=str(tmp_path / "placement.rrd")) + + first = ObjectPlacer(params) + second = ObjectPlacer(_placer_params(debug_visualize_rrd_path=str(tmp_path / "ignored.rrd"))) + + assert first.params.debug_visualizer is second.params.debug_visualizer + + +def test_candidate_frames_keep_counting_across_batches(tmp_path): + """A pool that refills gets fresh frames, so a later batch does not overwrite an earlier one.""" + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) + + assert visualizer.next_batch_indices(3) == [0, 1, 2] + assert visualizer.next_batch_indices(2) == [3, 4] + + +def test_active_candidates_map_batch_position_to_frame(tmp_path): + """A check that only ran on some candidates still resolves each one's own frame.""" + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) + + visualizer.set_active_candidates([1, 4]) + + assert visualizer.candidate_index_for_slot(0) == 1 + assert visualizer.candidate_index_for_slot(1) == 4 diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index 6bb779b02c..86d6ff4ba9 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -31,6 +31,7 @@ from isaaclab_arena.relations.collision_object import CollisionObject from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox + from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer def get_object_world_pose_from_layout( @@ -78,6 +79,17 @@ def __init__(self, params: ObjectPlacerParams) -> None: self._base_quat_xyzw = base_pose.rotation_xyzw # Guards the zero-target warning so it fires once per validator, not once per candidate layout. self._warned_no_targets = False + self._visualizer = params.debug_visualizer + self._rerun_layer = self._make_rerun_layer(params) + + @staticmethod + def _make_rerun_layer(params: ObjectPlacerParams) -> ReachabilityRerunLayer | None: + """Return this check's layer of the placement debug view, or None when that view is off.""" + if params.debug_visualizer is None: + return None + from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer + + return ReachabilityRerunLayer(params.debug_visualizer) @classmethod def is_available(cls, params: ObjectPlacerParams) -> bool: @@ -99,12 +111,13 @@ def validate_batch( bboxes: list[dict[ObjectBase, AxisAlignedBoundingBox]], collision_objects: list[CollisionObject], ) -> list[bool]: - return [self._validate(positions[i], orientations[i]) for i in range(len(positions))] + return [self._validate(positions[i], orientations[i], batch_slot=i) for i in range(len(positions))] def _validate( self, positions: dict[ObjectBase, tuple[float, float, float]], orientations: dict[ObjectBase, float], + batch_slot: int, ) -> bool: """Whether the robot can reach a top-down grasp at the target objects in one candidate layout. @@ -149,12 +162,23 @@ def _validate( ) for obj in targets ]) - feasible, _, _ = solve_ik_feasibility( + feasible, position_error, rotation_error = solve_ik_feasibility( self._solver, grasp_poses, position_threshold=self._ik_pos_threshold, rotation_threshold=self._ik_rot_threshold, ) + if self._rerun_layer is not None: + self._rerun_layer.log_candidate( + candidate_index=self._visualizer.candidate_index_for_slot(batch_slot), + base_pos=self._base_pos, + base_quat_xyzw=self._base_quat_xyzw, + target_names=[obj.name for obj in targets], + grasp_poses_base_frame=grasp_poses, + feasible=feasible, + position_error=position_error, + rotation_error=rotation_error, + ) return bool(feasible.all().item()) def _select_reachability_targets(self, objects: list[ObjectBase], anchors: set[ObjectBase]) -> list[ObjectBase]: diff --git a/isaaclab_arena_curobo/reachability_visualizer.py b/isaaclab_arena_curobo/reachability_visualizer.py new file mode 100644 index 0000000000..8e43cf6bb5 --- /dev/null +++ b/isaaclab_arena_curobo/reachability_visualizer.py @@ -0,0 +1,95 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""The reachability check's layer of the placement Rerun debug view, sim-free (no SimApp). + +Core placement already draws each candidate layout's boxes (see +``isaaclab_arena.relations.placement_visualizer``); this adds what only the IK check knows -- where the +robot stands, the top-down grasps it solved, and whether each one was reachable. Everything is logged +against the same candidate frame, so the two layers compose. +""" + +from __future__ import annotations + +import torch + +from isaaclab_arena.relations.placement_visualizer import ROBOT_ENTITY, PlacementRerunVisualizer + +REACHABLE_COLOR = (40, 200, 80) +"""Color of a grasp the robot can reach.""" + +UNREACHABLE_COLOR = (220, 50, 50) +"""Color of a grasp the robot cannot reach, i.e. the one that rejected the layout.""" + +BASE_AXIS_LENGTH = 0.2 +"""Length (m) of the drawn robot base frame axes.""" + +GRASP_AXIS_LENGTH = 0.1 +"""Length (m) of the drawn grasp frame axes.""" + +BASE_ENTITY = f"{ROBOT_ENTITY}/base" +"""Entity path of the robot base frame; the grasps below it are logged in that frame.""" + + +class ReachabilityRerunLayer: + """Draws the reachability check's verdict for a candidate into the shared placement view.""" + + def __init__(self, visualizer: PlacementRerunVisualizer) -> None: + """Bind the layer to the placement view it draws into. + + Args: + visualizer: The process's placement debug view, which owns the recording and the timeline. + """ + self._visualizer = visualizer + + def log_candidate( + self, + candidate_index: int, + base_pos: tuple[float, float, float], + base_quat_xyzw: tuple[float, float, float, float], + target_names: list[str], + grasp_poses_base_frame: torch.Tensor, + feasible: torch.Tensor, + position_error: torch.Tensor, + rotation_error: torch.Tensor, + ) -> None: + """Log the robot's side of one evaluated candidate. + + Args: + candidate_index: Timeline index of the candidate, as assigned by the placement view. + base_pos: Robot base position in the world frame. + base_quat_xyzw: Robot base orientation in the world frame. + target_names: Names of the objects a grasp was solved for, aligned with the tensors below. + grasp_poses_base_frame: ``(b, 4, 4)`` grasp transforms in the robot base frame. + feasible: ``(b,)`` per-grasp IK verdict. + position_error: ``(b,)`` per-grasp IK position error (m). + rotation_error: ``(b,)`` per-grasp IK rotation error (rad). + """ + import rerun as rr + + self._visualizer.set_time(candidate_index) + # Grasps are solved in the robot base frame, so they are logged as children of the base + # transform and Rerun composes them back into the world frame. + rr.log(BASE_ENTITY, rr.Transform3D(translation=base_pos, quaternion=rr.Quaternion(xyzw=base_quat_xyzw))) + rr.log(BASE_ENTITY, rr.TransformAxes3D(BASE_AXIS_LENGTH)) + + grasps = grasp_poses_base_frame.detach().cpu() + for i, name in enumerate(target_names): + reachable = bool(feasible[i].item()) + color = REACHABLE_COLOR if reachable else UNREACHABLE_COLOR + entity = f"{BASE_ENTITY}/grasps/{name}" + rr.log(entity, rr.Transform3D(translation=grasps[i, :3, 3], mat3x3=grasps[i, :3, :3])) + rr.log(entity, rr.TransformAxes3D(GRASP_AXIS_LENGTH)) + rr.log( + f"{entity}/verdict", + rr.Points3D( + [[0.0, 0.0, 0.0]], + colors=[color], + radii=0.015, + labels=[f"{name}: {'reachable' if reachable else 'unreachable'}"], + ), + ) + rr.log(f"errors/{name}/position_m", rr.Scalars(float(position_error[i].item()))) + rr.log(f"errors/{name}/rotation_rad", rr.Scalars(float(rotation_error[i].item()))) diff --git a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py index 76eb0fceed..4c69475f36 100644 --- a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py +++ b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py @@ -178,16 +178,76 @@ def _make_unstamped_desk_box_pool(num_envs: int = 1, min_layouts_per_env: int = ) -def _make_reachability_validator(embodiment): - """Construct the registered ReachabilityValidator with ``embodiment`` set on its params.""" +def _make_reachability_validator(embodiment, visualizer=None): + """Construct the registered ReachabilityValidator with ``embodiment`` set on its params. + + ``visualizer`` stands in for the placement debug view ObjectPlacer would have populated. + """ from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena_curobo.ik_reachability_validator import ReachabilityValidator params = ObjectPlacerParams() params.reachability_config.embodiment = embodiment + params.debug_visualizer = visualizer return ReachabilityValidator(params) +@pytest.mark.curobo_deps +def test_validator_skips_visualization_by_default(monkeypatch): + """The debug view is opt-in: placement without one means the check adds no layer.""" + _patch_curobo(monkeypatch, feasible_fn=lambda n: [True] * n) + validator = _make_reachability_validator(_fake_embodiment()) + + assert validator._rerun_layer is None + + +@pytest.mark.curobo_deps +def test_validator_draws_each_candidate_on_its_own_frame(monkeypatch): + """The check draws on the frame of the candidate it was handed, not on its position in the batch. + + Expensive checks only see the candidates that passed the cheap ones, so the two differ. + """ + _patch_curobo(monkeypatch, feasible_fn=lambda n: [False] * n) + visualizer = MagicMock() + # The batch the check is given is candidates 7 and 9 of the run. + visualizer.candidate_index_for_slot.side_effect = [7, 9] + validator = _make_reachability_validator(_fake_embodiment(), visualizer=visualizer) + drawn: list[dict] = [] + monkeypatch.setattr(validator._rerun_layer, "log_candidate", lambda **kwargs: drawn.append(kwargs)) + + layout = _make_desk_box_pool().layouts_per_env()[0][0] + assert validator.validate_batch( + [layout.positions, layout.positions], [layout.orientations, layout.orientations], [{}, {}], [] + ) == [False, False] + + assert [entry["candidate_index"] for entry in drawn] == [7, 9] + assert [entry["target_names"] for entry in drawn] == [["box"], ["box"]] + + +def test_reachability_layer_records_to_rrd(tmp_path): + """The layer's grasps and verdicts reach a recording without a viewer or cuRobo.""" + from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer + from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer + + rrd_path = tmp_path / "placement.rrd" + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(rrd_path)) + layer = ReachabilityRerunLayer(visualizer) + + layer.log_candidate( + candidate_index=0, + base_pos=(0.0, 0.0, 0.0), + base_quat_xyzw=(0.0, 0.0, 0.0, 1.0), + target_names=["box"], + grasp_poses_base_frame=torch.eye(4).unsqueeze(0), + feasible=torch.tensor([False]), + position_error=torch.tensor([0.3]), + rotation_error=torch.tensor([0.1]), + ) + visualizer.close() + + assert rrd_path.is_file() and rrd_path.stat().st_size > 0 + + @pytest.mark.curobo_deps def test_validator_accepts_when_all_grasps_feasible(monkeypatch): """A layout passes when every movable-object grasp is feasible.""" From 2cde0260537fc1c6e252b5b05f3b1d0941a9140f Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Thu, 30 Jul 2026 15:08:58 -0700 Subject: [PATCH 02/17] Enable the placement debug view from an env graph YAML - Add debug_visualize / debug_visualize_rrd_path to PlacementValidatorSpec so an env's YAML can ask for the Rerun view without editing Python. - Forward both through build_checks_for_placer_params into ObjectPlacerParams. - Turn the view on in the butter_raisin_box scene as a worked example. - Cover the default-off and forwarding paths in the graph-spec tests. Signed-off-by: Xinjie Yao --- .../arena_env_graph_conversion_utils.py | 4 +++ .../environment_spec/arena_env_graph_types.py | 16 ++++++++++ .../tests/test_arena_env_graph_spec.py | 31 ++++++++++++++++++- .../robolab/scenes/butter_raisin_box.yaml | 1 + 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py b/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py index 56edc58522..856edcb931 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py @@ -62,6 +62,10 @@ def build_checks_for_placer_params(graph_spec: ArenaEnvGraphSpec) -> ObjectPlace enabled_checks=set(enabled_checks) if enabled_checks is not None else None, required_checks=set(required_checks) if required_checks is not None else None, solver_params=RelationSolverParams(verbose=False, save_position_history=False), + debug_visualize=placement_validators is not None and placement_validators.debug_visualize, + debug_visualize_rrd_path=( + placement_validators.debug_visualize_rrd_path if placement_validators is not None else None + ), ) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_types.py b/isaaclab_arena/environment_spec/arena_env_graph_types.py index 595e331217..e5e495ab25 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_types.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_types.py @@ -307,6 +307,22 @@ class PlacementValidatorSpec(BaseModel): ), ) + debug_visualize: bool = Field( + default=False, + description=( + "Stream every candidate layout the checks evaluate to a spawned Rerun viewer window. Debug " + "aid, off by default; needs a reachable display. The viewer is its own process, so this " + "never starts Isaac Sim." + ), + ) + debug_visualize_rrd_path: str | None = Field( + default=None, + description=( + "Path to record the debug visualization to as a Rerun .rrd file, for headless runs. Enables " + "the visualization on its own; combine with debug_visualize to both record and watch live." + ), + ) + @model_validator(mode="after") def _validate_required_subset(self) -> PlacementValidatorSpec: if self.enabled_checks is not None and self.required_checks is not None: diff --git a/isaaclab_arena/tests/test_arena_env_graph_spec.py b/isaaclab_arena/tests/test_arena_env_graph_spec.py index c1248fc8be..1b6db3d563 100644 --- a/isaaclab_arena/tests/test_arena_env_graph_spec.py +++ b/isaaclab_arena/tests/test_arena_env_graph_spec.py @@ -15,7 +15,11 @@ from isaaclab_arena.assets.object_type import ObjectType from isaaclab_arena.assets.registries import ObjectRelationLibraryRegistry, TaskRegistry from isaaclab_arena.environment_spec.arena_env_graph_spec import ArenaEnvGraphSpec -from isaaclab_arena.environment_spec.arena_env_graph_types import CliOverrideSpec, TaskCompositionType +from isaaclab_arena.environment_spec.arena_env_graph_types import ( + CliOverrideSpec, + PlacementValidatorSpec, + TaskCompositionType, +) from isaaclab_arena.relations.relations import AtPosition, IsAnchor, On, PositionLimitsBox, PositionLimitsCylindrical from isaaclab_arena.tests.utils.constants import TestConstants @@ -461,3 +465,28 @@ def test_a_spec_naming_a_searched_simready_asset_by_its_search_name_is_rejected( assert result.returncode != 0 assert "Unknown asset registry_name 'simready_replay_teapot'" in result.stderr + + +def test_graph_spec_leaves_placement_debug_view_off_by_default(): + """A graph YAML that says nothing about debug visualization builds placement params with it off.""" + from isaaclab_arena.environment_spec.arena_env_graph_conversion_utils import build_checks_for_placer_params + + params = build_checks_for_placer_params(ArenaEnvGraphSpec.from_yaml(_GRAPH)) + + assert not params.debug_visualize + assert params.debug_visualize_rrd_path is None + + +def test_graph_spec_forwards_placement_debug_view_to_placer_params(): + """The YAML's placement_validators debug fields reach the params ObjectPlacer reads.""" + from isaaclab_arena.environment_spec.arena_env_graph_conversion_utils import build_checks_for_placer_params + + spec = ArenaEnvGraphSpec.from_yaml(_GRAPH) + spec.placement_validators = PlacementValidatorSpec( + debug_visualize=True, debug_visualize_rrd_path="/tmp/placement.rrd" + ) + + params = build_checks_for_placer_params(spec) + + assert params.debug_visualize + assert params.debug_visualize_rrd_path == "/tmp/placement.rrd" diff --git a/isaaclab_arena_environments/robolab/scenes/butter_raisin_box.yaml b/isaaclab_arena_environments/robolab/scenes/butter_raisin_box.yaml index 2441171409..015a6d33c2 100644 --- a/isaaclab_arena_environments/robolab/scenes/butter_raisin_box.yaml +++ b/isaaclab_arena_environments/robolab/scenes/butter_raisin_box.yaml @@ -21,6 +21,7 @@ objects: placement_validators: enabled_checks: [no_overlap, on_relation, ik_reachable] required_checks: [no_overlap, on_relation] + debug_visualize: true relations: - kind: is_anchor subject: maple_table_robolab From fac3d86e02d88a18436e3db8a2286d6c231d7c75 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Thu, 30 Jul 2026 17:08:41 -0700 Subject: [PATCH 03/17] Close the placement debug viewer when the run ends - Spawn the Rerun viewer under `setpriv --pdeathsig TERM` instead of `rr.spawn()`, so the kernel closes the window when the run exits. - Isaac Sim's `SimulationApp.close()` ends in `os._exit()`, so no `atexit` hook can do this. - Stops a stale viewer from holding port 9876, which silently made the next run's spawn a no-op. - Wait for the viewer to serve before logging, replacing the readiness wait `rr.spawn()` did. Signed-off-by: Xinjie Yao --- .../environment_spec/arena_env_graph_types.py | 2 +- .../relations/object_placer_params.py | 6 +- .../relations/placement_visualizer.py | 88 ++++++++++++++++--- .../tests/test_placement_visualizer.py | 32 +++++++ 4 files changed, 113 insertions(+), 15 deletions(-) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_types.py b/isaaclab_arena/environment_spec/arena_env_graph_types.py index e5e495ab25..9ecd3e5d2c 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_types.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_types.py @@ -312,7 +312,7 @@ class PlacementValidatorSpec(BaseModel): description=( "Stream every candidate layout the checks evaluate to a spawned Rerun viewer window. Debug " "aid, off by default; needs a reachable display. The viewer is its own process, so this " - "never starts Isaac Sim." + "never starts Isaac Sim, and it closes with the run." ), ) debug_visualize_rrd_path: str | None = Field( diff --git a/isaaclab_arena/relations/object_placer_params.py b/isaaclab_arena/relations/object_placer_params.py index df80fbcaff..4bafc84a1f 100644 --- a/isaaclab_arena/relations/object_placer_params.py +++ b/isaaclab_arena/relations/object_placer_params.py @@ -90,9 +90,9 @@ class ObjectPlacerParams: """If True, stream every validated candidate layout to a spawned Rerun viewer window. Debug aid, off by default. Needs the ``rerun-sdk`` package and a reachable display (the container - forwards ``DISPLAY``); the viewer is its own process, so this never starts Isaac Sim. Checks that - can say more about a candidate add their own layer -- the cuRobo reachability check draws the - grasps it solved and the robot's collision spheres.""" + forwards ``DISPLAY``); the viewer is its own process, so this never starts Isaac Sim, and it is + closed when the run exits. Checks that can say more about a candidate add their own layer -- the + cuRobo reachability check draws the grasps it solved and the robot's collision spheres.""" debug_visualize_rrd_path: str | None = None """Path to record the debug visualization to as a Rerun ``.rrd`` file, for headless runs. diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index e9446e6fc0..d29112ce0e 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -12,13 +12,19 @@ Every candidate layout is one frame of the ``candidate`` timeline, so scrubbing it shows what was solved and which checks rejected it. Checks that know more about a candidate than its boxes add their own layer under ``world/robot`` (see the cuRobo reachability check). + +The spawned window belongs to the run: it comes up during placement, stays up for the rest of the +run, and dies with the process that spawned it. Record to an ``.rrd`` to inspect layouts afterwards. """ from __future__ import annotations import math +import socket +import subprocess +import time from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams @@ -47,6 +53,18 @@ REJECTED_COLOR = (220, 50, 50) """Color marking a candidate at least one check rejected.""" +VIEWER_HOST = "127.0.0.1" +"""Interface the spawned viewer is reached on; it always runs alongside the process that logs to it.""" + +VIEWER_PORT = 9876 +"""Port the spawned viewer serves on; Rerun's default, so ``rerun --connect`` finds it unprompted.""" + +VIEWER_SHUTDOWN_TIMEOUT_S = 10.0 +"""How long an explicit close() waits for the viewer window to go away before giving up on it.""" + +VIEWER_STARTUP_TIMEOUT_S = 20.0 +"""How long spawning waits for the viewer window to start serving before letting placement run on.""" + _ACTIVE_VISUALIZER: PlacementRerunVisualizer | None = None """The process's live view. Placement builds several placers (pool, per-reset solves) that would otherwise each reset Rerun's global recording and fight over the same viewer port and ``.rrd``.""" @@ -80,12 +98,52 @@ def find_rerun_viewer_executable() -> str | None: return str(executable) if executable.is_file() else None -class PlacementRerunVisualizer: - """Streams every validated candidate layout to Rerun, one frame per candidate. +def spawn_viewer_process() -> tuple[subprocess.Popen, Any]: + """Spawn a viewer window that dies with this process; return it and the sink that streams to it. - Holds only plain attributes (the recording itself is Rerun's process-global stream) so the - placement event config can deep-copy the pool that owns it. + ``setpriv --pdeathsig`` rather than ``rerun.spawn()``, which detaches the viewer and drops its + pid: the kernel then closes the window even on the hard ``os._exit`` Isaac Sim shuts down with. """ + import rerun as rr + + executable = find_rerun_viewer_executable() + assert executable is not None, "rerun-sdk ships no viewer binary here; record to an .rrd instead." + viewer_process = subprocess.Popen([ + "setpriv", + "--pdeathsig", + "TERM", + "--", + executable, + f"--port={VIEWER_PORT}", + "--memory-limit=75%", + "--server-memory-limit=1GiB", + # Wait for this run's recording instead of opening on the welcome screen. + "--expect-data-soon", + ]) + _wait_until_viewer_serves(viewer_process) + return viewer_process, rr.GrpcSink(url=f"rerun+http://{VIEWER_HOST}:{VIEWER_PORT}/proxy") + + +def _wait_until_viewer_serves(viewer_process: subprocess.Popen) -> None: + """Block until the spawned viewer answers on its port, so the first candidates are not lost. + + Never fatal -- a view that fails to come up does not stop the run it was only meant to explain. + """ + deadline = time.monotonic() + VIEWER_STARTUP_TIMEOUT_S + while time.monotonic() < deadline: + if viewer_process.poll() is not None: + print("WARNING: the Rerun viewer exited while starting; placement will not be visualized.") + return + with socket.socket() as probe: + probe.settimeout(0.2) + if probe.connect_ex((VIEWER_HOST, VIEWER_PORT)) == 0: + return + time.sleep(0.1) + print(f"WARNING: the Rerun viewer did not serve within {VIEWER_STARTUP_TIMEOUT_S:.0f}s; layouts may be missing.") + + +class PlacementRerunVisualizer: + """Streams every validated candidate layout to Rerun, one frame per candidate.""" def __init__(self, app_id: str = "arena_placement", spawn: bool = True, rrd_path: str | None = None) -> None: """Start the recording and, unless recording headlessly, spawn a viewer window. @@ -99,10 +157,10 @@ def __init__(self, app_id: str = "arena_placement", spawn: bool = True, rrd_path rr.init(app_id, spawn=False) sinks: list = [] + self._viewer_process: subprocess.Popen | None = None if spawn: - # connect=False: the sink is set below, so spawn only has to bring the viewer process up. - rr.spawn(connect=False, executable_path=find_rerun_viewer_executable()) - sinks.append(rr.GrpcSink()) + self._viewer_process, viewer_sink = spawn_viewer_process() + sinks.append(viewer_sink) if rrd_path is not None: Path(rrd_path).parent.mkdir(parents=True, exist_ok=True) sinks.append(rr.FileSink(rrd_path)) @@ -233,10 +291,18 @@ def log_verdicts(self, candidate_index: int, verdicts_by_check: dict[str, bool]) rr.log(f"checks/{check}", rr.Scalars(float(passed))) def close(self) -> None: - """Flush pending data so a recorded ``.rrd`` is readable without waiting for interpreter exit. + """Flush pending data and shut down the viewer window this run spawned. Idempotent. - A spawned viewer keeps running afterwards, so the layouts stay inspectable. + Only needed to close the window early -- a run that just exits leaves it to the viewer's + parent-death signal. """ import rerun as rr - rr.get_global_data_recording().flush() + # None once Rerun's own shutdown hook has torn the recording down ahead of this call. + recording = rr.get_global_data_recording() + if recording is not None: + recording.flush() + viewer_process, self._viewer_process = self._viewer_process, None + if viewer_process is not None: + viewer_process.terminate() + viewer_process.wait(timeout=VIEWER_SHUTDOWN_TIMEOUT_S) diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index 568e2459c6..176059e9d0 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -90,6 +90,38 @@ def test_placement_shares_one_debug_view_across_placers(tmp_path): assert first.params.debug_visualizer is second.params.debug_visualizer +class _FakeViewerProcess: + """Stands in for the spawned viewer window, so the test needs no display.""" + + def __init__(self) -> None: + self.terminate_calls = 0 + + def terminate(self) -> None: + self.terminate_calls += 1 + + def wait(self, timeout: float | None = None) -> int: + return 0 + + +def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch): + """The window belongs to the run, so closing the view takes it down instead of leaving it on the port.""" + import rerun as rr + + viewer = _FakeViewerProcess() + # Record where the viewer's live stream would go, so the test needs neither a display nor a port. + monkeypatch.setattr( + placement_visualizer, + "spawn_viewer_process", + lambda: (viewer, rr.FileSink(str(tmp_path / "viewer_stand_in.rrd"))), + ) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, rrd_path=str(tmp_path / "p.rrd")) + + visualizer.close() + visualizer.close() + + assert viewer.terminate_calls == 1 + + def test_candidate_frames_keep_counting_across_batches(tmp_path): """A pool that refills gets fresh frames, so a later batch does not overwrite an earlier one.""" visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) From 1bbb3a83c59c8627780d0fe4d441aa72457e29f8 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Thu, 30 Jul 2026 22:42:03 -0700 Subject: [PATCH 04/17] Address review of the placement debug view - Take `debug_visualize: true` out of the shipped butter/raisin scene, where it spawned a viewer window on every build, and move the worked example to a test-data graph YAML. - Guard that with a test asserting no versioned env under `isaaclab_arena_environments` asks for it. - Track which candidates each check was run on, so the view no longer draws an expensive check as rejecting a layout it skipped; the summary count now derives from the same record. - Warn when the viewer port is already served, and confirm the spawned viewer is still alive once it answers, so a run cannot silently log into somebody else's window. - Fall back to killing a viewer that ignores SIGTERM instead of raising out of close(). Signed-off-by: Xinjie Yao --- isaaclab_arena/relations/object_placer.py | 45 +++++++++----- .../relations/placement_visualizer.py | 43 ++++++++++--- .../tests/test_arena_env_graph_spec.py | 30 +++++---- .../placement_debug_view_env_graph.yaml | 42 +++++++++++++ .../tests/test_placement_visualizer.py | 62 ++++++++++++++++++- .../robolab/scenes/butter_raisin_box.yaml | 1 - 6 files changed, 182 insertions(+), 41 deletions(-) create mode 100644 isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 0e1420d632..38dce766af 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -598,8 +598,8 @@ def _validate_candidates( # required_checks=None means "every enabled check is required"; an empty set means no checks. required = self.params.required_checks num_candidates = len(positions) - # Per-check count of layouts evaluated by that check - num_layouts_evaluated_by_check: dict[str, int] = {} + # Per-check candidate indices that check was actually run on; expensive checks skip candidates. + evaluated_slots_by_check: dict[str, list[int]] = {} layout_pass_verdicts_by_check: dict[str, list[bool]] = {} # Layouts are drawn before the checks run so a check's own layer lands on top of its candidate. candidate_indices = self._log_candidate_layouts(positions, orientations, bboxes) @@ -610,7 +610,7 @@ def _validate_candidates( bboxes, collision_objects, layout_pass_verdicts_by_check, - num_layouts_evaluated_by_check, + evaluated_slots_by_check, candidate_indices, ) self._run_expensive_checks( @@ -620,13 +620,13 @@ def _validate_candidates( collision_objects, required, layout_pass_verdicts_by_check, - num_layouts_evaluated_by_check, + evaluated_slots_by_check, candidate_indices, ) - self._log_candidate_verdicts(candidate_indices, layout_pass_verdicts_by_check) + self._log_candidate_verdicts(candidate_indices, layout_pass_verdicts_by_check, evaluated_slots_by_check) if layout_pass_verdicts_by_check: summary = ", ".join( - f"{check}={sum(verdicts)}/{num_layouts_evaluated_by_check[check]}" + f"{check}={sum(verdicts)}/{len(evaluated_slots_by_check[check])}" for check, verdicts in layout_pass_verdicts_by_check.items() ) print(f"[placement] Validated {num_candidates} candidate layout(s); passed per check: {summary}") @@ -647,10 +647,10 @@ def _run_inexpensive_checks( bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], collision_objects: list[CollisionObject], layout_pass_verdicts_by_check: dict[str, list[bool]], - num_layouts_evaluated_by_check: dict[str, int], - candidate_indices: list[int] | None = None, + evaluated_slots_by_check: dict[str, list[int]], + candidate_indices: list[int] | None, ) -> None: - """Run every inexpensive validator on all candidates, recording verdicts and evaluated counts.""" + """Run every inexpensive validator on all candidates, recording verdicts and evaluated slots.""" num_candidates = len(positions) for validator in self._validators: if not validator.run_after_inexpensive_checks: @@ -659,7 +659,7 @@ def _run_inexpensive_checks( layout_pass_verdicts_by_check[validator.check] = validator.validate_batch( positions, orientations, bboxes, collision_objects ) - num_layouts_evaluated_by_check[validator.check] = num_candidates + evaluated_slots_by_check[validator.check] = list(range(num_candidates)) def _run_expensive_checks( self, @@ -669,8 +669,8 @@ def _run_expensive_checks( collision_objects: list[CollisionObject], required: set[str] | None, layout_pass_verdicts_by_check: dict[str, list[bool]], - num_layouts_evaluated_by_check: dict[str, int], - candidate_indices: list[int] | None = None, + evaluated_slots_by_check: dict[str, list[int]], + candidate_indices: list[int] | None, ) -> None: """Run each expensive validator only on candidates that passed the required inexpensive checks.""" num_candidates = len(positions) @@ -681,9 +681,9 @@ def _run_expensive_checks( for i in range(num_candidates) if self._passes_required_checks(layout_pass_verdicts_by_check, required, i) ] - if self.params.debug_visualizer is not None: + if self.params.debug_visualizer is not None and candidate_indices is not None: self.params.debug_visualizer.set_active_candidates( - [(candidate_indices or [])[i] for i in passed_layout_indices] + [candidate_indices[i] for i in passed_layout_indices] ) # only passed layouts are validated verdicts_over_passed_layout = validator.validate_batch( @@ -696,7 +696,7 @@ def _run_expensive_checks( for sub_idx, cand_idx in enumerate(passed_layout_indices): verdicts[cand_idx] = verdicts_over_passed_layout[sub_idx] layout_pass_verdicts_by_check[validator.check] = verdicts - num_layouts_evaluated_by_check[validator.check] = len(passed_layout_indices) + evaluated_slots_by_check[validator.check] = passed_layout_indices def _log_candidate_layouts( self, @@ -721,14 +721,25 @@ def _log_candidate_verdicts( self, candidate_indices: list[int] | None, layout_pass_verdicts_by_check: dict[str, list[bool]], + evaluated_slots_by_check: dict[str, list[int]], ) -> None: - """Annotate each drawn candidate with the checks that accepted or rejected it.""" + """Annotate each drawn candidate with the checks that accepted or rejected it. + + A check that skipped a candidate is left off it, so the view never shows an expensive check + rejecting a layout it was never run on. + """ visualizer = self.params.debug_visualizer if visualizer is None or candidate_indices is None: return + evaluated_slots = {check: set(slots) for check, slots in evaluated_slots_by_check.items()} for slot, candidate_index in enumerate(candidate_indices): visualizer.log_verdicts( - candidate_index, {check: verdicts[slot] for check, verdicts in layout_pass_verdicts_by_check.items()} + candidate_index, + { + check: verdicts[slot] + for check, verdicts in layout_pass_verdicts_by_check.items() + if slot in evaluated_slots[check] + }, ) @staticmethod diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index d29112ce0e..37cdceafe0 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -65,6 +65,12 @@ VIEWER_STARTUP_TIMEOUT_S = 20.0 """How long spawning waits for the viewer window to start serving before letting placement run on.""" +VIEWER_PROBE_TIMEOUT_S = 0.2 +"""How long one connection attempt to the viewer port may take before it counts as unanswered.""" + +VIEWER_PROBE_INTERVAL_S = 0.1 +"""How long to wait between connection attempts while the viewer window starts.""" + _ACTIVE_VISUALIZER: PlacementRerunVisualizer | None = None """The process's live view. Placement builds several placers (pool, per-reset solves) that would otherwise each reset Rerun's global recording and fight over the same viewer port and ``.rrd``.""" @@ -108,6 +114,11 @@ def spawn_viewer_process() -> tuple[subprocess.Popen, Any]: executable = find_rerun_viewer_executable() assert executable is not None, "rerun-sdk ships no viewer binary here; record to an .rrd instead." + if _viewer_port_answers(): + print( + f"WARNING: something already serves on port {VIEWER_PORT}; this run's candidate layouts will " + "stream into that window rather than a new one." + ) viewer_process = subprocess.Popen([ "setpriv", "--pdeathsig", @@ -124,6 +135,13 @@ def spawn_viewer_process() -> tuple[subprocess.Popen, Any]: return viewer_process, rr.GrpcSink(url=f"rerun+http://{VIEWER_HOST}:{VIEWER_PORT}/proxy") +def _viewer_port_answers() -> bool: + """Whether anything at all is serving on the viewer port -- not necessarily our own viewer.""" + with socket.socket() as probe: + probe.settimeout(VIEWER_PROBE_TIMEOUT_S) + return probe.connect_ex((VIEWER_HOST, VIEWER_PORT)) == 0 + + def _wait_until_viewer_serves(viewer_process: subprocess.Popen) -> None: """Block until the spawned viewer answers on its port, so the first candidates are not lost. @@ -131,14 +149,17 @@ def _wait_until_viewer_serves(viewer_process: subprocess.Popen) -> None: """ deadline = time.monotonic() + VIEWER_STARTUP_TIMEOUT_S while time.monotonic() < deadline: + if _viewer_port_answers(): + # An answering port is not proof it is ours: a viewer that lost the race for it exits + # right after, leaving this run logging into somebody else's window. + if viewer_process.poll() is None: + return + print(f"WARNING: the Rerun viewer exited; layouts will stream to whatever else holds port {VIEWER_PORT}.") + return if viewer_process.poll() is not None: print("WARNING: the Rerun viewer exited while starting; placement will not be visualized.") return - with socket.socket() as probe: - probe.settimeout(0.2) - if probe.connect_ex((VIEWER_HOST, VIEWER_PORT)) == 0: - return - time.sleep(0.1) + time.sleep(VIEWER_PROBE_INTERVAL_S) print(f"WARNING: the Rerun viewer did not serve within {VIEWER_STARTUP_TIMEOUT_S:.0f}s; layouts may be missing.") @@ -272,7 +293,7 @@ def log_verdicts(self, candidate_index: int, verdicts_by_check: dict[str, bool]) Args: candidate_index: Timeline index to log against. - verdicts_by_check: Per-check verdict for this candidate. + verdicts_by_check: Verdict per check that ran on this candidate; one that skipped it is absent. """ import rerun as rr @@ -303,6 +324,12 @@ def close(self) -> None: if recording is not None: recording.flush() viewer_process, self._viewer_process = self._viewer_process, None - if viewer_process is not None: - viewer_process.terminate() + if viewer_process is None: + return + viewer_process.terminate() + try: viewer_process.wait(timeout=VIEWER_SHUTDOWN_TIMEOUT_S) + except subprocess.TimeoutExpired: + # A window wedged past SIGTERM would otherwise keep the port and outlive the run. + viewer_process.kill() + viewer_process.wait() diff --git a/isaaclab_arena/tests/test_arena_env_graph_spec.py b/isaaclab_arena/tests/test_arena_env_graph_spec.py index 1b6db3d563..23511eeda1 100644 --- a/isaaclab_arena/tests/test_arena_env_graph_spec.py +++ b/isaaclab_arena/tests/test_arena_env_graph_spec.py @@ -12,20 +12,18 @@ import pytest from pydantic import ValidationError +import isaaclab_arena_environments from isaaclab_arena.assets.object_type import ObjectType from isaaclab_arena.assets.registries import ObjectRelationLibraryRegistry, TaskRegistry from isaaclab_arena.environment_spec.arena_env_graph_spec import ArenaEnvGraphSpec -from isaaclab_arena.environment_spec.arena_env_graph_types import ( - CliOverrideSpec, - PlacementValidatorSpec, - TaskCompositionType, -) +from isaaclab_arena.environment_spec.arena_env_graph_types import CliOverrideSpec, TaskCompositionType from isaaclab_arena.relations.relations import AtPosition, IsAnchor, On, PositionLimitsBox, PositionLimitsCylindrical from isaaclab_arena.tests.utils.constants import TestConstants TEST_DATA_DIR = Path(__file__).parent / "test_data" _GRAPH = TEST_DATA_DIR / "pick_and_place_maple_table_env_graph.yaml" _OBJECT_SET_GRAPH = TEST_DATA_DIR / "object_set_maple_table_env_graph.yaml" +_DEBUG_VIEW_GRAPH = TEST_DATA_DIR / "placement_debug_view_env_graph.yaml" def test_graph_spec_loads_pick_and_place_yaml(): @@ -478,15 +476,21 @@ def test_graph_spec_leaves_placement_debug_view_off_by_default(): def test_graph_spec_forwards_placement_debug_view_to_placer_params(): - """The YAML's placement_validators debug fields reach the params ObjectPlacer reads.""" + """A YAML asking for the debug view reaches the params ObjectPlacer reads, both fields intact.""" from isaaclab_arena.environment_spec.arena_env_graph_conversion_utils import build_checks_for_placer_params - spec = ArenaEnvGraphSpec.from_yaml(_GRAPH) - spec.placement_validators = PlacementValidatorSpec( - debug_visualize=True, debug_visualize_rrd_path="/tmp/placement.rrd" - ) - - params = build_checks_for_placer_params(spec) + params = build_checks_for_placer_params(ArenaEnvGraphSpec.from_yaml(_DEBUG_VIEW_GRAPH)) assert params.debug_visualize - assert params.debug_visualize_rrd_path == "/tmp/placement.rrd" + assert params.debug_visualize_rrd_path == "/tmp/placement_debug_view.rrd" + + +def test_graph_spec_leaves_shipped_envs_out_of_the_debug_view(): + """The debug view spawns a window on every build, so no env under version control may ask for it.""" + asking_for_the_view = [ + yaml_path + for yaml_path in Path(isaaclab_arena_environments.__file__).parent.rglob("*.yaml") + if "debug_visualize: true" in yaml_path.read_text() + ] + + assert not asking_for_the_view, f"debug_visualize must stay off in shipped envs: {asking_for_the_view}" diff --git a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml new file mode 100644 index 0000000000..2a3c658aa4 --- /dev/null +++ b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml @@ -0,0 +1,42 @@ +# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +# Worked example of turning the placement Rerun debug view on from a graph YAML. Shipped envs leave +# it off: it spawns a viewer window on every build. +env_name: llm_gen_maple_table_robolab_PickAndPlaceTask +embodiment: + id: franka_ik + registry_name: franka_ik +background: + id: maple_table_robolab + registry_name: maple_table_robolab +objects: +- id: rubiks_cube_hot3d_robolab + registry_name: rubiks_cube_hot3d_robolab +- id: bowl_ycb_robolab + registry_name: bowl_ycb_robolab +placement_validators: + enabled_checks: [no_overlap, on_relation] + required_checks: [no_overlap, on_relation] + debug_visualize: true + debug_visualize_rrd_path: /tmp/placement_debug_view.rrd +relations: +- kind: is_anchor + subject: maple_table_robolab +- kind: 'on' + subject: rubiks_cube_hot3d_robolab + reference: maple_table_robolab +- kind: 'on' + subject: bowl_ycb_robolab + reference: maple_table_robolab +task: + composition: atomic + description: pick up the rubiks cube and place it in the bowl + subtasks: + - kind: PickAndPlaceTask + params: + pick_up_object: rubiks_cube_hot3d_robolab + destination_location: bowl_ycb_robolab + background_scene: maple_table_robolab diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index 176059e9d0..eb3aa651c7 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -11,6 +11,8 @@ from __future__ import annotations +import subprocess + import pytest from isaaclab_arena.relations import placement_visualizer @@ -91,18 +93,39 @@ def test_placement_shares_one_debug_view_across_placers(tmp_path): class _FakeViewerProcess: - """Stands in for the spawned viewer window, so the test needs no display.""" + """Stands in for the spawned viewer window, so the test needs no display. - def __init__(self) -> None: + Args: + wedged: Whether the window ignores SIGTERM, i.e. whether the first wait() times out. + """ + + def __init__(self, wedged: bool = False) -> None: self.terminate_calls = 0 + self.kill_calls = 0 + self._wedged = wedged def terminate(self) -> None: self.terminate_calls += 1 + def kill(self) -> None: + self.kill_calls += 1 + def wait(self, timeout: float | None = None) -> int: + if self._wedged and self.kill_calls == 0: + raise subprocess.TimeoutExpired(cmd="rerun", timeout=timeout) return 0 +class _RecordingVisualizer: + """Captures what the placer draws, so its verdict bookkeeping can be asserted without Rerun.""" + + def __init__(self) -> None: + self.verdicts_by_candidate: dict[int, dict[str, bool]] = {} + + def log_verdicts(self, candidate_index: int, verdicts_by_check: dict[str, bool]) -> None: + self.verdicts_by_candidate[candidate_index] = verdicts_by_check + + def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch): """The window belongs to the run, so closing the view takes it down instead of leaving it on the port.""" import rerun as rr @@ -122,6 +145,41 @@ def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch assert viewer.terminate_calls == 1 +def test_closing_a_wedged_viewer_falls_back_to_killing_it(tmp_path, monkeypatch): + """A window that ignores SIGTERM still has to go, or it outlives the run holding the viewer port.""" + import rerun as rr + + viewer = _FakeViewerProcess(wedged=True) + monkeypatch.setattr( + placement_visualizer, + "spawn_viewer_process", + lambda: (viewer, rr.FileSink(str(tmp_path / "viewer_stand_in.rrd"))), + ) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, rrd_path=str(tmp_path / "p.rrd")) + + visualizer.close() + + assert viewer.kill_calls == 1 + + +def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(): + """Expensive checks only see candidates the cheap ones passed; the rest are unevaluated, not failed.""" + placer = ObjectPlacer(_placer_params()) + visualizer = _RecordingVisualizer() + placer.params.debug_visualizer = visualizer + + placer._log_candidate_verdicts( + candidate_indices=[0, 1], + layout_pass_verdicts_by_check={"no_overlap": [True, False], "ik_reachable": [True, False]}, + evaluated_slots_by_check={"no_overlap": [0, 1], "ik_reachable": [0]}, + ) + + assert visualizer.verdicts_by_candidate == { + 0: {"no_overlap": True, "ik_reachable": True}, + 1: {"no_overlap": False}, + } + + def test_candidate_frames_keep_counting_across_batches(tmp_path): """A pool that refills gets fresh frames, so a later batch does not overwrite an earlier one.""" visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) diff --git a/isaaclab_arena_environments/robolab/scenes/butter_raisin_box.yaml b/isaaclab_arena_environments/robolab/scenes/butter_raisin_box.yaml index 015a6d33c2..2441171409 100644 --- a/isaaclab_arena_environments/robolab/scenes/butter_raisin_box.yaml +++ b/isaaclab_arena_environments/robolab/scenes/butter_raisin_box.yaml @@ -21,7 +21,6 @@ objects: placement_validators: enabled_checks: [no_overlap, on_relation, ik_reachable] required_checks: [no_overlap, on_relation] - debug_visualize: true relations: - kind: is_anchor subject: maple_table_robolab From 0b164e3dabcb11bd4cd2f86bb1bde18ebeb71b9c Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Thu, 30 Jul 2026 22:48:04 -0700 Subject: [PATCH 05/17] Reject a candidate in the debug view only on required checks - Acceptance now follows the placer, which gates layouts on required_checks alone. - A failure the placer does not gate on reads as accepted, naming the advisory check. - Pull the wording out into summarize_candidate_verdict() so the rule is unit-testable. Signed-off-by: Xinjie Yao --- isaaclab_arena/relations/object_placer.py | 1 + .../relations/placement_visualizer.py | 37 ++++++++++++++---- .../tests/test_placement_visualizer.py | 38 ++++++++++++++++++- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 38dce766af..851de547ae 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -740,6 +740,7 @@ def _log_candidate_verdicts( for check, verdicts in layout_pass_verdicts_by_check.items() if slot in evaluated_slots[check] }, + self.params.required_checks, ) @staticmethod diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index 37cdceafe0..61c1e10e26 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -163,6 +163,29 @@ def _wait_until_viewer_serves(viewer_process: subprocess.Popen) -> None: print(f"WARNING: the Rerun viewer did not serve within {VIEWER_STARTUP_TIMEOUT_S:.0f}s; layouts may be missing.") +def summarize_candidate_verdict( + candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None +) -> tuple[str, bool]: + """Describe how placement judged one candidate, as ``(message, accepted)``. + + Acceptance follows the placer: only required checks gate a layout, so a candidate that failed + nothing else is accepted and the failure is reported as advisory rather than as a rejection. + + Args: + candidate_index: Timeline index of the candidate, used in the message. + verdicts_by_check: Verdict per check that ran on this candidate. + required_checks: Checks that gate acceptance; None means every check that ran gates it. + """ + failed = [check for check, passed in verdicts_by_check.items() if not passed] + blocking = [check for check in failed if required_checks is None or check in required_checks] + advisory = [check for check in failed if check not in blocking] + if blocking: + return f"candidate {candidate_index}: rejected (failed: {', '.join(blocking)})", False + if advisory: + return f"candidate {candidate_index}: accepted (failed but not required: {', '.join(advisory)})", True + return f"candidate {candidate_index}: accepted", True + + class PlacementRerunVisualizer: """Streams every validated candidate layout to Rerun, one frame per candidate.""" @@ -288,25 +311,23 @@ def log_layout( ), ) - def log_verdicts(self, candidate_index: int, verdicts_by_check: dict[str, bool]) -> None: + def log_verdicts( + self, candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None + ) -> None: """Log which checks accepted one candidate, as a text line and an accepted/rejected marker. Args: candidate_index: Timeline index to log against. verdicts_by_check: Verdict per check that ran on this candidate; one that skipped it is absent. + required_checks: Checks that gate acceptance; None means every check that ran gates it. """ import rerun as rr self.set_time(candidate_index) - failed = [check for check, passed in verdicts_by_check.items() if not passed] - accepted = not failed + message, accepted = summarize_candidate_verdict(candidate_index, verdicts_by_check, required_checks) rr.log( f"{LAYOUT_ENTITY}/verdict", - rr.TextLog( - f"candidate {candidate_index}: {'accepted' if accepted else 'rejected'}" - + (f" (failed: {', '.join(failed)})" if failed else ""), - level=rr.TextLogLevel.INFO if accepted else rr.TextLogLevel.WARN, - ), + rr.TextLog(message, level=rr.TextLogLevel.INFO if accepted else rr.TextLogLevel.WARN), ) for check, passed in verdicts_by_check.items(): rr.log(f"checks/{check}", rr.Scalars(float(passed))) diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index eb3aa651c7..00c9d16840 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -18,7 +18,7 @@ from isaaclab_arena.relations import placement_visualizer from isaaclab_arena.relations.object_placer import ObjectPlacer from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams -from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer +from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer, summarize_candidate_verdict from isaaclab_arena.relations.relation_solver_params import RelationSolverParams from isaaclab_arena.relations.relations import IsAnchor, On from isaaclab_arena.tests.dummy_object import DummyObject @@ -121,9 +121,13 @@ class _RecordingVisualizer: def __init__(self) -> None: self.verdicts_by_candidate: dict[int, dict[str, bool]] = {} + self.required_checks: set[str] | None = None - def log_verdicts(self, candidate_index: int, verdicts_by_check: dict[str, bool]) -> None: + def log_verdicts( + self, candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None + ) -> None: self.verdicts_by_candidate[candidate_index] = verdicts_by_check + self.required_checks = required_checks def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch): @@ -162,6 +166,36 @@ def test_closing_a_wedged_viewer_falls_back_to_killing_it(tmp_path, monkeypatch) assert viewer.kill_calls == 1 +def test_only_required_checks_reject_a_candidate(): + """The view has to agree with the placer, which gates layouts on required_checks alone.""" + verdicts = {"no_overlap": True, "ik_reachable": False} + + message, accepted = summarize_candidate_verdict(3, verdicts, required_checks={"no_overlap"}) + + assert accepted + assert message == "candidate 3: accepted (failed but not required: ik_reachable)" + + +def test_a_failed_required_check_rejects_a_candidate(): + """A failure the placer does gate on reads as a rejection, naming what blocked it.""" + verdicts = {"no_overlap": False, "ik_reachable": False} + + message, accepted = summarize_candidate_verdict(3, verdicts, required_checks={"no_overlap"}) + + assert not accepted + assert message == "candidate 3: rejected (failed: no_overlap)" + + +def test_every_check_gates_a_candidate_when_none_are_named_required(): + """required_checks=None means every check that ran is required, matching ObjectPlacerParams.""" + verdicts = {"no_overlap": True, "ik_reachable": False} + + message, accepted = summarize_candidate_verdict(3, verdicts, required_checks=None) + + assert not accepted + assert message == "candidate 3: rejected (failed: ik_reachable)" + + def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(): """Expensive checks only see candidates the cheap ones passed; the rest are unevaluated, not failed.""" placer = ObjectPlacer(_placer_params()) From 6455b7edbc496bf4029736f6b2739ac30a653a28 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Thu, 30 Jul 2026 22:52:10 -0700 Subject: [PATCH 06/17] Document how to turn the placement debug view on - Show the env graph YAML and Python routes in the visualizer module, pointing at the worked example. - Say the same from the reachability check, whose layer only appears once that view is on. - Drop ACCEPTED_COLOR / REJECTED_COLOR, which nothing has ever drawn with. Signed-off-by: Xinjie Yao --- .../relations/placement_visualizer.py | 20 +++++++++++-------- .../ik_reachability_validator.py | 6 ++++++ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index 61c1e10e26..07db51d5a7 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -6,8 +6,18 @@ """Rerun debug view of build-time placement validation, sim-free (no SimApp). Rerun's viewer is a separate process fed by the logging SDK, so nothing here touches Isaac Sim -- the -window comes up while layouts are being solved, before any simulation exists. Enabled through -``ObjectPlacerParams.debug_visualize`` / ``debug_visualize_rrd_path``. +window comes up while layouts are being solved, before any simulation exists. + +Turn it on from an env graph YAML (worked example: +``isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml``):: + + placement_validators: + debug_visualize: true # spawn a viewer window; needs a reachable display + debug_visualize_rrd_path: /tmp/placement.rrd # and/or record, for headless runs + +or in Python with ``ObjectPlacerParams(debug_visualize=True)``. Either field alone enables the view. +Shipped envs leave it off, since it spawns a window on every build; enable it while debugging a scene +whose layouts look wrong, then take it back out. Every candidate layout is one frame of the ``candidate`` timeline, so scrubbing it shows what was solved and which checks rejected it. Checks that know more about a candidate than its boxes add their @@ -47,12 +57,6 @@ MOVABLE_COLOR = (70, 130, 220) """Color of the objects placement actually solves for.""" -ACCEPTED_COLOR = (40, 200, 80) -"""Color marking a candidate every required check accepted.""" - -REJECTED_COLOR = (220, 50, 50) -"""Color marking a candidate at least one check rejected.""" - VIEWER_HOST = "127.0.0.1" """Interface the spawned viewer is reached on; it always runs alongside the process that logs to it.""" diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index 86d6ff4ba9..3ba7aa0eaa 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -7,6 +7,12 @@ 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. + +To see why a layout was called unreachable, turn on the placement debug view -- ``debug_visualize: true`` under +``placement_validators`` in the env graph YAML, or ``ObjectPlacerParams(debug_visualize=True)``. This check then adds its +own layer to it (the robot base, the grasps it solved, reachable/unreachable per target, IK error plots); see +``isaaclab_arena_curobo.reachability_visualizer``. Nothing is drawn without a registered cuRobo config for the +embodiment, since the check delists itself entirely in that case. """ from __future__ import annotations From 73892d7032ef49b36653672603eab263b947ede0 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Thu, 30 Jul 2026 22:59:55 -0700 Subject: [PATCH 07/17] enbale ik --- .../tests/test_data/placement_debug_view_env_graph.yaml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml index 2a3c658aa4..8a8f27c681 100644 --- a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml +++ b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml @@ -2,9 +2,6 @@ # All rights reserved. # # SPDX-License-Identifier: Apache-2.0 - -# Worked example of turning the placement Rerun debug view on from a graph YAML. Shipped envs leave -# it off: it spawns a viewer window on every build. env_name: llm_gen_maple_table_robolab_PickAndPlaceTask embodiment: id: franka_ik @@ -18,7 +15,7 @@ objects: - id: bowl_ycb_robolab registry_name: bowl_ycb_robolab placement_validators: - enabled_checks: [no_overlap, on_relation] + enabled_checks: [no_overlap, on_relation, ik_reachability] required_checks: [no_overlap, on_relation] debug_visualize: true debug_visualize_rrd_path: /tmp/placement_debug_view.rrd From 4addaea8717c903d8b1cefafce3c6e507511e90c Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Sun, 2 Aug 2026 21:58:14 -0700 Subject: [PATCH 08/17] Address review of the placement debug view Keep ObjectPlacerParams data-only: checks reach the live Rerun view through get_active_placement_visualizer() rather than a field on the params, and the per-batch layout/verdict logging moves off ObjectPlacer onto the visualizer that owns it. Rename debug_visualize_rrd_path to debug_visualize_output_path and the opaque batch "slot" to batch_index, shorten the docstrings that described code living elsewhere, and fix the enabled check name in the worked YAML example (ik_reachability -> ik_reachable), which was being silently dropped as unregistered. Signed-off-by: Xinjie Yao --- .../arena_env_graph_conversion_utils.py | 4 +- .../environment_spec/arena_env_graph_types.py | 6 +- isaaclab_arena/relations/object_placer.py | 95 +++++--------- .../relations/object_placer_params.py | 17 +-- .../relations/placement_visualizer.py | 118 +++++++++++++----- .../tests/test_arena_env_graph_spec.py | 4 +- .../placement_debug_view_env_graph.yaml | 4 +- .../tests/test_placement_visualizer.py | 75 ++++++----- .../ik_reachability_validator.py | 31 +++-- .../reachability_visualizer.py | 6 +- .../tests/test_ik_reachability_validator.py | 21 ++-- 11 files changed, 195 insertions(+), 186 deletions(-) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py b/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py index 856edcb931..24b568f1df 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py @@ -63,8 +63,8 @@ def build_checks_for_placer_params(graph_spec: ArenaEnvGraphSpec) -> ObjectPlace required_checks=set(required_checks) if required_checks is not None else None, solver_params=RelationSolverParams(verbose=False, save_position_history=False), debug_visualize=placement_validators is not None and placement_validators.debug_visualize, - debug_visualize_rrd_path=( - placement_validators.debug_visualize_rrd_path if placement_validators is not None else None + debug_visualize_output_path=( + placement_validators.debug_visualize_output_path if placement_validators is not None else None ), ) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_types.py b/isaaclab_arena/environment_spec/arena_env_graph_types.py index 9ecd3e5d2c..b8b5ee0b68 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_types.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_types.py @@ -315,11 +315,11 @@ class PlacementValidatorSpec(BaseModel): "never starts Isaac Sim, and it closes with the run." ), ) - debug_visualize_rrd_path: str | None = Field( + debug_visualize_output_path: str | None = Field( default=None, description=( - "Path to record the debug visualization to as a Rerun .rrd file, for headless runs. Enables " - "the visualization on its own; combine with debug_visualize to both record and watch live." + "Path to record the debug visualization to as a Rerun .rrd file, for headless runs. Set on " + "its own it records without opening a window; set alongside debug_visualize it does both." ), ) diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 851de547ae..69472871ae 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -79,8 +79,9 @@ class ObjectPlacer: def __init__(self, params: ObjectPlacerParams | None = None): self.params = params or ObjectPlacerParams() self._solver = RelationSolver(params=self.params.solver_params) - # Populated before the validators are built so a check can add its own layer to the same view. - self.params.debug_visualizer = get_or_create_placement_visualizer(self.params) + # Created before the validators are built: a check picks the view up as it is constructed, so + # that it can draw its own layer into the frames this placer opens. + self._visualizer = get_or_create_placement_visualizer(self.params) self._validators: list[PlacementValidator] = build_validators(self.params) def place( @@ -598,11 +599,14 @@ def _validate_candidates( # required_checks=None means "every enabled check is required"; an empty set means no checks. required = self.params.required_checks num_candidates = len(positions) - # Per-check candidate indices that check was actually run on; expensive checks skip candidates. - evaluated_slots_by_check: dict[str, list[int]] = {} + # Per-check batch indices that check was actually run on; expensive checks skip candidates. + evaluated_batch_indices_by_check: dict[str, list[int]] = {} layout_pass_verdicts_by_check: dict[str, list[bool]] = {} - # Layouts are drawn before the checks run so a check's own layer lands on top of its candidate. - candidate_indices = self._log_candidate_layouts(positions, orientations, bboxes) + # None unless the debug view is on. The layouts are drawn before the checks run, so that a + # check drawing into the view (e.g. the cuRobo one, its grasps) finds the frame it draws onto. + candidate_indices = ( + self._visualizer.log_layout_batch(positions, orientations, bboxes) if self._visualizer is not None else None + ) self._run_inexpensive_checks( positions, @@ -610,7 +614,7 @@ def _validate_candidates( bboxes, collision_objects, layout_pass_verdicts_by_check, - evaluated_slots_by_check, + evaluated_batch_indices_by_check, candidate_indices, ) self._run_expensive_checks( @@ -620,13 +624,19 @@ def _validate_candidates( collision_objects, required, layout_pass_verdicts_by_check, - evaluated_slots_by_check, + evaluated_batch_indices_by_check, candidate_indices, ) - self._log_candidate_verdicts(candidate_indices, layout_pass_verdicts_by_check, evaluated_slots_by_check) + if self._visualizer is not None and candidate_indices is not None: + self._visualizer.log_verdict_batch( + candidate_indices, + layout_pass_verdicts_by_check, + evaluated_batch_indices_by_check, + self.params.required_checks, + ) if layout_pass_verdicts_by_check: summary = ", ".join( - f"{check}={sum(verdicts)}/{len(evaluated_slots_by_check[check])}" + f"{check}={sum(verdicts)}/{len(evaluated_batch_indices_by_check[check])}" for check, verdicts in layout_pass_verdicts_by_check.items() ) print(f"[placement] Validated {num_candidates} candidate layout(s); passed per check: {summary}") @@ -647,19 +657,19 @@ def _run_inexpensive_checks( bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], collision_objects: list[CollisionObject], layout_pass_verdicts_by_check: dict[str, list[bool]], - evaluated_slots_by_check: dict[str, list[int]], + evaluated_batch_indices_by_check: dict[str, list[int]], candidate_indices: list[int] | None, ) -> None: - """Run every inexpensive validator on all candidates, recording verdicts and evaluated slots.""" + """Run every inexpensive validator on all candidates, recording verdicts and evaluated indices.""" num_candidates = len(positions) for validator in self._validators: if not validator.run_after_inexpensive_checks: - if self.params.debug_visualizer is not None: - self.params.debug_visualizer.set_active_candidates(candidate_indices or []) + if self._visualizer is not None: + self._visualizer.set_active_candidates(candidate_indices or []) layout_pass_verdicts_by_check[validator.check] = validator.validate_batch( positions, orientations, bboxes, collision_objects ) - evaluated_slots_by_check[validator.check] = list(range(num_candidates)) + evaluated_batch_indices_by_check[validator.check] = list(range(num_candidates)) def _run_expensive_checks( self, @@ -669,7 +679,7 @@ def _run_expensive_checks( collision_objects: list[CollisionObject], required: set[str] | None, layout_pass_verdicts_by_check: dict[str, list[bool]], - evaluated_slots_by_check: dict[str, list[int]], + evaluated_batch_indices_by_check: dict[str, list[int]], candidate_indices: list[int] | None, ) -> None: """Run each expensive validator only on candidates that passed the required inexpensive checks.""" @@ -681,10 +691,8 @@ def _run_expensive_checks( for i in range(num_candidates) if self._passes_required_checks(layout_pass_verdicts_by_check, required, i) ] - if self.params.debug_visualizer is not None and candidate_indices is not None: - self.params.debug_visualizer.set_active_candidates( - [candidate_indices[i] for i in passed_layout_indices] - ) + if self._visualizer is not None and candidate_indices is not None: + self._visualizer.set_active_candidates([candidate_indices[i] for i in passed_layout_indices]) # only passed layouts are validated verdicts_over_passed_layout = validator.validate_batch( [positions[i] for i in passed_layout_indices], @@ -696,52 +704,7 @@ def _run_expensive_checks( for sub_idx, cand_idx in enumerate(passed_layout_indices): verdicts[cand_idx] = verdicts_over_passed_layout[sub_idx] layout_pass_verdicts_by_check[validator.check] = verdicts - evaluated_slots_by_check[validator.check] = passed_layout_indices - - def _log_candidate_layouts( - self, - positions: list[dict[PlaceableAsset, tuple[float, float, float]]], - orientations: list[dict[PlaceableAsset, float]], - bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], - ) -> list[int] | None: - """Draw every candidate of this batch in the Rerun debug view, returning their timeline indices. - - None when the debug view is off, which is the default. - """ - visualizer = self.params.debug_visualizer - if visualizer is None: - return None - candidate_indices = visualizer.next_batch_indices(len(positions)) - for slot, candidate_index in enumerate(candidate_indices): - anchors = set(get_anchor_objects(list(positions[slot]))) - visualizer.log_layout(candidate_index, positions[slot], orientations[slot], bboxes[slot], anchors) - return candidate_indices - - def _log_candidate_verdicts( - self, - candidate_indices: list[int] | None, - layout_pass_verdicts_by_check: dict[str, list[bool]], - evaluated_slots_by_check: dict[str, list[int]], - ) -> None: - """Annotate each drawn candidate with the checks that accepted or rejected it. - - A check that skipped a candidate is left off it, so the view never shows an expensive check - rejecting a layout it was never run on. - """ - visualizer = self.params.debug_visualizer - if visualizer is None or candidate_indices is None: - return - evaluated_slots = {check: set(slots) for check, slots in evaluated_slots_by_check.items()} - for slot, candidate_index in enumerate(candidate_indices): - visualizer.log_verdicts( - candidate_index, - { - check: verdicts[slot] - for check, verdicts in layout_pass_verdicts_by_check.items() - if slot in evaluated_slots[check] - }, - self.params.required_checks, - ) + evaluated_batch_indices_by_check[validator.check] = passed_layout_indices @staticmethod def _passes_required_checks( diff --git a/isaaclab_arena/relations/object_placer_params.py b/isaaclab_arena/relations/object_placer_params.py index 4bafc84a1f..4e7b054c9e 100644 --- a/isaaclab_arena/relations/object_placer_params.py +++ b/isaaclab_arena/relations/object_placer_params.py @@ -12,7 +12,6 @@ if TYPE_CHECKING: from isaaclab_arena.embodiments.embodiment_base import EmbodimentBase - from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer @dataclass @@ -87,19 +86,9 @@ class ObjectPlacerParams: """Tuning for the optional ``ik_reachable`` build-time check. See ReachabilityConfig for more details.""" debug_visualize: bool = False - """If True, stream every validated candidate layout to a spawned Rerun viewer window. + """If True, stream every validated candidate layout to a spawned Rerun viewer window.""" - Debug aid, off by default. Needs the ``rerun-sdk`` package and a reachable display (the container - forwards ``DISPLAY``); the viewer is its own process, so this never starts Isaac Sim, and it is - closed when the run exits. Checks that can say more about a candidate add their own layer -- the - cuRobo reachability check draws the grasps it solved and the robot's collision spheres.""" - - debug_visualize_rrd_path: str | None = None + debug_visualize_output_path: str | None = None """Path to record the debug visualization to as a Rerun ``.rrd`` file, for headless runs. - Enables the visualization on its own; combine with ``debug_visualize`` to both record and watch live.""" - - debug_visualizer: PlacementRerunVisualizer | None = None - """The live Rerun view the debug fields above ask for; populated by ObjectPlacer, not by callers. - - Carried here so validators, which only receive these params, can add their own layer to it.""" + Set on its own it records without opening a window; set alongside ``debug_visualize`` it does both.""" diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index 07db51d5a7..b86e27bc3d 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -5,26 +5,14 @@ """Rerun debug view of build-time placement validation, sim-free (no SimApp). -Rerun's viewer is a separate process fed by the logging SDK, so nothing here touches Isaac Sim -- the -window comes up while layouts are being solved, before any simulation exists. - -Turn it on from an env graph YAML (worked example: -``isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml``):: - - placement_validators: - debug_visualize: true # spawn a viewer window; needs a reachable display - debug_visualize_rrd_path: /tmp/placement.rrd # and/or record, for headless runs - -or in Python with ``ObjectPlacerParams(debug_visualize=True)``. Either field alone enables the view. -Shipped envs leave it off, since it spawns a window on every build; enable it while debugging a scene -whose layouts look wrong, then take it back out. - -Every candidate layout is one frame of the ``candidate`` timeline, so scrubbing it shows what was -solved and which checks rejected it. Checks that know more about a candidate than its boxes add their -own layer under ``world/robot`` (see the cuRobo reachability check). - -The spawned window belongs to the run: it comes up during placement, stays up for the rest of the -run, and dies with the process that spawned it. Record to an ``.rrd`` to inspect layouts afterwards. +Every candidate layout the checks evaluate becomes one frame of the ``candidate`` timeline: the +solved boxes, plus the verdict of each check that ran on it. A check that knows more about a +candidate than its boxes logs its own entities onto that same frame -- its *layer* -- as the cuRobo +reachability check does in ``isaaclab_arena_curobo.reachability_visualizer``. + +Turn the view on with ``ObjectPlacerParams.debug_visualize`` (a viewer window) and/or +``debug_visualize_output_path`` (a recording); worked YAML example in +``isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml``. """ from __future__ import annotations @@ -36,6 +24,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from isaaclab_arena.relations.relations import get_anchor_objects + if TYPE_CHECKING: from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena.relations.placement_asset import PlaceableAsset @@ -84,18 +74,27 @@ def get_or_create_placement_visualizer(params: ObjectPlacerParams) -> PlacementR """Return the process's Rerun view of placement validation, or None when the params ask for none. Args: - params: Placement parameters carrying the ``debug_visualize`` / ``debug_visualize_rrd_path`` fields. + params: Placement parameters carrying the ``debug_visualize`` / ``debug_visualize_output_path`` fields. """ global _ACTIVE_VISUALIZER - if not params.debug_visualize and params.debug_visualize_rrd_path is None: + if not params.debug_visualize and params.debug_visualize_output_path is None: return None if _ACTIVE_VISUALIZER is None: _ACTIVE_VISUALIZER = PlacementRerunVisualizer( - spawn=params.debug_visualize, rrd_path=params.debug_visualize_rrd_path + spawn=params.debug_visualize, output_path=params.debug_visualize_output_path ) return _ACTIVE_VISUALIZER +def get_active_placement_visualizer() -> PlacementRerunVisualizer | None: + """Return the process's Rerun view of placement validation, or None when no placer asked for one. + + How a check reaches the view it draws its own layer into: the placer creates the view before it + builds the checks, so this is set by the time a check is constructed. + """ + return _ACTIVE_VISUALIZER + + def find_rerun_viewer_executable() -> str | None: """Return the path of the Rerun viewer binary shipped with ``rerun-sdk``, or None if absent. @@ -193,13 +192,13 @@ def summarize_candidate_verdict( class PlacementRerunVisualizer: """Streams every validated candidate layout to Rerun, one frame per candidate.""" - def __init__(self, app_id: str = "arena_placement", spawn: bool = True, rrd_path: str | None = None) -> None: + def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_path: str | None = None) -> None: """Start the recording and, unless recording headlessly, spawn a viewer window. Args: app_id: Rerun application id, shown in the viewer title. spawn: Whether to spawn a local viewer window and stream to it. - rrd_path: Optional path to also record the stream to, for replay on another machine. + output_path: Optional ``.rrd`` path to also record the stream to, for replay elsewhere. """ import rerun as rr @@ -209,10 +208,10 @@ def __init__(self, app_id: str = "arena_placement", spawn: bool = True, rrd_path if spawn: self._viewer_process, viewer_sink = spawn_viewer_process() sinks.append(viewer_sink) - if rrd_path is not None: - Path(rrd_path).parent.mkdir(parents=True, exist_ok=True) - sinks.append(rr.FileSink(rrd_path)) - assert sinks, "PlacementRerunVisualizer needs a viewer to spawn or an .rrd path to record to." + if output_path is not None: + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + sinks.append(rr.FileSink(output_path)) + assert sinks, "PlacementRerunVisualizer needs a viewer to spawn or an output path to record to." rr.set_sinks(*sinks) rr.log("world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True) self._next_candidate_index = 0 @@ -253,9 +252,9 @@ def set_active_candidates(self, candidate_indices: list[int]) -> None: """ self._active_candidate_indices = list(candidate_indices) - def candidate_index_for_slot(self, slot: int) -> int: - """Timeline index of the ``slot``-th candidate in the batch the running validator was given.""" - return self._active_candidate_indices[slot] + def candidate_index_for_batch_index(self, batch_index: int) -> int: + """Timeline index of the ``batch_index``-th candidate in the batch the running validator was given.""" + return self._active_candidate_indices[batch_index] def set_time(self, candidate_index: int) -> None: """Point the recording at one candidate's frame, so subsequent logs land on it.""" @@ -263,6 +262,31 @@ def set_time(self, candidate_index: int) -> None: rr.set_time(CANDIDATE_TIMELINE, sequence=candidate_index) + def log_layout_batch( + self, + positions: list[dict[PlaceableAsset, tuple[float, float, float]]], + orientations: list[dict[PlaceableAsset, float]], + bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], + ) -> list[int]: + """Draw every candidate of one batch, returning the timeline index reserved for each. + + Args: + positions: Solved (x, y, z) per object, per candidate. + orientations: Absolute world Z-yaw per object, per candidate. + bboxes: Per-object local bounding box, per candidate. + """ + candidate_indices = self.next_batch_indices(len(positions)) + for batch_index, candidate_index in enumerate(candidate_indices): + anchors = set(get_anchor_objects(list(positions[batch_index]))) + self.log_layout( + candidate_index, + positions[batch_index], + orientations[batch_index], + bboxes[batch_index], + anchors, + ) + return candidate_indices + def log_layout( self, candidate_index: int, @@ -315,6 +339,36 @@ def log_layout( ), ) + def log_verdict_batch( + self, + candidate_indices: list[int], + verdicts_by_check: dict[str, list[bool]], + evaluated_batch_indices_by_check: dict[str, list[int]], + required_checks: set[str] | None, + ) -> None: + """Annotate every drawn candidate of one batch with the checks that accepted or rejected it. + + A check that skipped a candidate is left off it, so the view never shows an expensive check + rejecting a layout it was never run on. + + Args: + candidate_indices: Timeline index of each candidate of the batch, as ``log_layout_batch`` returned. + verdicts_by_check: Verdict per candidate of the batch, per check. + evaluated_batch_indices_by_check: Batch indices each check actually ran on. + required_checks: Checks that gate acceptance; None means every check that ran gates it. + """ + evaluated = {check: set(indices) for check, indices in evaluated_batch_indices_by_check.items()} + for batch_index, candidate_index in enumerate(candidate_indices): + self.log_verdicts( + candidate_index, + { + check: verdicts[batch_index] + for check, verdicts in verdicts_by_check.items() + if batch_index in evaluated[check] + }, + required_checks, + ) + def log_verdicts( self, candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None ) -> None: diff --git a/isaaclab_arena/tests/test_arena_env_graph_spec.py b/isaaclab_arena/tests/test_arena_env_graph_spec.py index 23511eeda1..1a027814b5 100644 --- a/isaaclab_arena/tests/test_arena_env_graph_spec.py +++ b/isaaclab_arena/tests/test_arena_env_graph_spec.py @@ -472,7 +472,7 @@ def test_graph_spec_leaves_placement_debug_view_off_by_default(): params = build_checks_for_placer_params(ArenaEnvGraphSpec.from_yaml(_GRAPH)) assert not params.debug_visualize - assert params.debug_visualize_rrd_path is None + assert params.debug_visualize_output_path is None def test_graph_spec_forwards_placement_debug_view_to_placer_params(): @@ -482,7 +482,7 @@ def test_graph_spec_forwards_placement_debug_view_to_placer_params(): params = build_checks_for_placer_params(ArenaEnvGraphSpec.from_yaml(_DEBUG_VIEW_GRAPH)) assert params.debug_visualize - assert params.debug_visualize_rrd_path == "/tmp/placement_debug_view.rrd" + assert params.debug_visualize_output_path == "/tmp/placement_debug_view.rrd" def test_graph_spec_leaves_shipped_envs_out_of_the_debug_view(): diff --git a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml index 8a8f27c681..19000c5197 100644 --- a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml +++ b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml @@ -15,10 +15,10 @@ objects: - id: bowl_ycb_robolab registry_name: bowl_ycb_robolab placement_validators: - enabled_checks: [no_overlap, on_relation, ik_reachability] + enabled_checks: [no_overlap, on_relation, ik_reachable] required_checks: [no_overlap, on_relation] debug_visualize: true - debug_visualize_rrd_path: /tmp/placement_debug_view.rrd + debug_visualize_output_path: /tmp/placement_debug_view.rrd relations: - kind: is_anchor subject: maple_table_robolab diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index 00c9d16840..09e1b6c341 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -18,7 +18,11 @@ from isaaclab_arena.relations import placement_visualizer from isaaclab_arena.relations.object_placer import ObjectPlacer from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams -from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer, summarize_candidate_verdict +from isaaclab_arena.relations.placement_visualizer import ( + PlacementRerunVisualizer, + get_active_placement_visualizer, + summarize_candidate_verdict, +) from isaaclab_arena.relations.relation_solver_params import RelationSolverParams from isaaclab_arena.relations.relations import IsAnchor, On from isaaclab_arena.tests.dummy_object import DummyObject @@ -64,32 +68,32 @@ def _placer_params(**overrides) -> ObjectPlacerParams: def test_placement_has_no_debug_view_by_default(): """The debug view is opt-in, so a default placement never touches Rerun.""" - placer = ObjectPlacer(_placer_params()) + ObjectPlacer(_placer_params()) - assert placer.params.debug_visualizer is None + assert get_active_placement_visualizer() is None def test_placement_records_every_candidate_layout(tmp_path): """Recording to an .rrd draws every candidate the solve produced, one frame each, without a viewer.""" - rrd_path = tmp_path / "placement.rrd" - placer = ObjectPlacer(_placer_params(debug_visualize_rrd_path=str(rrd_path))) + output_path = tmp_path / "placement.rrd" + placer = ObjectPlacer(_placer_params(debug_visualize_output_path=str(output_path))) placer.place(_desk_and_box(), num_envs=1) - visualizer = placer.params.debug_visualizer + visualizer = get_active_placement_visualizer() visualizer.close() assert visualizer.num_logged_candidates == MAX_PLACEMENT_ATTEMPTS - assert rrd_path.is_file() and rrd_path.stat().st_size > 0 + assert output_path.is_file() and output_path.stat().st_size > 0 def test_placement_shares_one_debug_view_across_placers(tmp_path): """Every placer in the process draws into the same view, keeping one viewer and one timeline.""" - params = _placer_params(debug_visualize_rrd_path=str(tmp_path / "placement.rrd")) + ObjectPlacer(_placer_params(debug_visualize_output_path=str(tmp_path / "placement.rrd"))) + view_of_first_placer = get_active_placement_visualizer() - first = ObjectPlacer(params) - second = ObjectPlacer(_placer_params(debug_visualize_rrd_path=str(tmp_path / "ignored.rrd"))) + ObjectPlacer(_placer_params(debug_visualize_output_path=str(tmp_path / "ignored.rrd"))) - assert first.params.debug_visualizer is second.params.debug_visualizer + assert get_active_placement_visualizer() is view_of_first_placer class _FakeViewerProcess: @@ -116,20 +120,6 @@ def wait(self, timeout: float | None = None) -> int: return 0 -class _RecordingVisualizer: - """Captures what the placer draws, so its verdict bookkeeping can be asserted without Rerun.""" - - def __init__(self) -> None: - self.verdicts_by_candidate: dict[int, dict[str, bool]] = {} - self.required_checks: set[str] | None = None - - def log_verdicts( - self, candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None - ) -> None: - self.verdicts_by_candidate[candidate_index] = verdicts_by_check - self.required_checks = required_checks - - def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch): """The window belongs to the run, so closing the view takes it down instead of leaving it on the port.""" import rerun as rr @@ -141,7 +131,7 @@ def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch "spawn_viewer_process", lambda: (viewer, rr.FileSink(str(tmp_path / "viewer_stand_in.rrd"))), ) - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, rrd_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, output_path=str(tmp_path / "p.rrd")) visualizer.close() visualizer.close() @@ -159,7 +149,7 @@ def test_closing_a_wedged_viewer_falls_back_to_killing_it(tmp_path, monkeypatch) "spawn_viewer_process", lambda: (viewer, rr.FileSink(str(tmp_path / "viewer_stand_in.rrd"))), ) - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, rrd_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, output_path=str(tmp_path / "p.rrd")) visualizer.close() @@ -196,19 +186,26 @@ def test_every_check_gates_a_candidate_when_none_are_named_required(): assert message == "candidate 3: rejected (failed: ik_reachable)" -def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(): +def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(tmp_path, monkeypatch): """Expensive checks only see candidates the cheap ones passed; the rest are unevaluated, not failed.""" - placer = ObjectPlacer(_placer_params()) - visualizer = _RecordingVisualizer() - placer.params.debug_visualizer = visualizer + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) + drawn_verdicts: dict[int, dict[str, bool]] = {} + monkeypatch.setattr( + visualizer, + "log_verdicts", + lambda candidate_index, verdicts_by_check, required_checks: drawn_verdicts.update( + {candidate_index: verdicts_by_check} + ), + ) - placer._log_candidate_verdicts( + visualizer.log_verdict_batch( candidate_indices=[0, 1], - layout_pass_verdicts_by_check={"no_overlap": [True, False], "ik_reachable": [True, False]}, - evaluated_slots_by_check={"no_overlap": [0, 1], "ik_reachable": [0]}, + verdicts_by_check={"no_overlap": [True, False], "ik_reachable": [True, False]}, + evaluated_batch_indices_by_check={"no_overlap": [0, 1], "ik_reachable": [0]}, + required_checks=None, ) - assert visualizer.verdicts_by_candidate == { + assert drawn_verdicts == { 0: {"no_overlap": True, "ik_reachable": True}, 1: {"no_overlap": False}, } @@ -216,7 +213,7 @@ def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(): def test_candidate_frames_keep_counting_across_batches(tmp_path): """A pool that refills gets fresh frames, so a later batch does not overwrite an earlier one.""" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) assert visualizer.next_batch_indices(3) == [0, 1, 2] assert visualizer.next_batch_indices(2) == [3, 4] @@ -224,9 +221,9 @@ def test_candidate_frames_keep_counting_across_batches(tmp_path): def test_active_candidates_map_batch_position_to_frame(tmp_path): """A check that only ran on some candidates still resolves each one's own frame.""" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) visualizer.set_active_candidates([1, 4]) - assert visualizer.candidate_index_for_slot(0) == 1 - assert visualizer.candidate_index_for_slot(1) == 4 + assert visualizer.candidate_index_for_batch_index(0) == 1 + assert visualizer.candidate_index_for_batch_index(1) == 4 diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index 3ba7aa0eaa..d33928b46c 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -8,11 +8,8 @@ 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. -To see why a layout was called unreachable, turn on the placement debug view -- ``debug_visualize: true`` under -``placement_validators`` in the env graph YAML, or ``ObjectPlacerParams(debug_visualize=True)``. This check then adds its -own layer to it (the robot base, the grasps it solved, reachable/unreachable per target, IK error plots); see -``isaaclab_arena_curobo.reachability_visualizer``. Nothing is drawn without a registered cuRobo config for the -embodiment, since the check delists itself entirely in that case. +To see why a layout was called unreachable, turn the placement debug view on (``ObjectPlacerParams.debug_visualize``); +this check then draws its own layer into it, via ``isaaclab_arena_curobo.reachability_visualizer``. """ from __future__ import annotations @@ -24,6 +21,7 @@ from isaaclab_arena.relations.placement_validation import PlacementCheck from isaaclab_arena.relations.placement_validator_registry import register_validator from isaaclab_arena.relations.placement_validators import PlacementValidator +from isaaclab_arena.relations.placement_visualizer import get_active_placement_visualizer from isaaclab_arena.relations.relations import RequiresReachability, get_anchor_objects from isaaclab_arena.utils.pose import Pose from isaaclab_arena.utils.yaw import rotate_quat_by_yaw, yaw_from_quat_xyzw @@ -85,17 +83,18 @@ def __init__(self, params: ObjectPlacerParams) -> None: self._base_quat_xyzw = base_pose.rotation_xyzw # Guards the zero-target warning so it fires once per validator, not once per candidate layout. self._warned_no_targets = False - self._visualizer = params.debug_visualizer - self._rerun_layer = self._make_rerun_layer(params) + self._visualizer = get_active_placement_visualizer() + self._rerun_layer = self._make_rerun_layer() @staticmethod - def _make_rerun_layer(params: ObjectPlacerParams) -> ReachabilityRerunLayer | None: + def _make_rerun_layer() -> ReachabilityRerunLayer | None: """Return this check's layer of the placement debug view, or None when that view is off.""" - if params.debug_visualizer is None: + visualizer = get_active_placement_visualizer() + if visualizer is None: return None from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer - return ReachabilityRerunLayer(params.debug_visualizer) + return ReachabilityRerunLayer(visualizer) @classmethod def is_available(cls, params: ObjectPlacerParams) -> bool: @@ -117,19 +116,25 @@ def validate_batch( bboxes: list[dict[ObjectBase, AxisAlignedBoundingBox]], collision_objects: list[CollisionObject], ) -> list[bool]: - return [self._validate(positions[i], orientations[i], batch_slot=i) for i in range(len(positions))] + return [self._validate(positions[i], orientations[i], batch_index=i) for i in range(len(positions))] def _validate( self, positions: dict[ObjectBase, tuple[float, float, float]], orientations: dict[ObjectBase, float], - batch_slot: int, + batch_index: int, ) -> bool: """Whether the robot can reach a top-down grasp at the target objects in one candidate layout. Rebuilds each object's world pose and a per-object collision cuboid, syncs them into the solver's world, then batches a single IK solve over the target objects' top-down grasps. A layout with nothing to grasp (anchor-only, or no target present) is trivially reachable. + + Args: + positions: Solved (x, y, z) per object. + orientations: Absolute world Z-yaw per object. + batch_index: Position of this candidate in the batch this check was given, which the debug + view maps back to the candidate's own frame. """ objects = list(positions.keys()) anchors = set(get_anchor_objects(objects)) @@ -176,7 +181,7 @@ def _validate( ) if self._rerun_layer is not None: self._rerun_layer.log_candidate( - candidate_index=self._visualizer.candidate_index_for_slot(batch_slot), + candidate_index=self._visualizer.candidate_index_for_batch_index(batch_index), base_pos=self._base_pos, base_quat_xyzw=self._base_quat_xyzw, target_names=[obj.name for obj in targets], diff --git a/isaaclab_arena_curobo/reachability_visualizer.py b/isaaclab_arena_curobo/reachability_visualizer.py index 8e43cf6bb5..39ef981d69 100644 --- a/isaaclab_arena_curobo/reachability_visualizer.py +++ b/isaaclab_arena_curobo/reachability_visualizer.py @@ -5,10 +5,8 @@ """The reachability check's layer of the placement Rerun debug view, sim-free (no SimApp). -Core placement already draws each candidate layout's boxes (see -``isaaclab_arena.relations.placement_visualizer``); this adds what only the IK check knows -- where the -robot stands, the top-down grasps it solved, and whether each one was reachable. Everything is logged -against the same candidate frame, so the two layers compose. +Draws where the robot stands, the top-down grasps it solved for a candidate layout, and whether each +one was reachable. """ from __future__ import annotations diff --git a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py index 4c69475f36..f5ae4a49a3 100644 --- a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py +++ b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py @@ -178,17 +178,20 @@ def _make_unstamped_desk_box_pool(num_envs: int = 1, min_layouts_per_env: int = ) -def _make_reachability_validator(embodiment, visualizer=None): +def _make_reachability_validator(embodiment, monkeypatch=None, visualizer=None): """Construct the registered ReachabilityValidator with ``embodiment`` set on its params. - ``visualizer`` stands in for the placement debug view ObjectPlacer would have populated. + ``visualizer`` stands in for the placement debug view ObjectPlacer would have opened before it + built the check; without one the check finds no view to draw into. """ + from isaaclab_arena.relations import placement_visualizer as placement_visualizer_module from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena_curobo.ik_reachability_validator import ReachabilityValidator + if monkeypatch is not None: + monkeypatch.setattr(placement_visualizer_module, "_ACTIVE_VISUALIZER", visualizer) params = ObjectPlacerParams() params.reachability_config.embodiment = embodiment - params.debug_visualizer = visualizer return ReachabilityValidator(params) @@ -196,7 +199,7 @@ def _make_reachability_validator(embodiment, visualizer=None): def test_validator_skips_visualization_by_default(monkeypatch): """The debug view is opt-in: placement without one means the check adds no layer.""" _patch_curobo(monkeypatch, feasible_fn=lambda n: [True] * n) - validator = _make_reachability_validator(_fake_embodiment()) + validator = _make_reachability_validator(_fake_embodiment(), monkeypatch) assert validator._rerun_layer is None @@ -210,8 +213,8 @@ def test_validator_draws_each_candidate_on_its_own_frame(monkeypatch): _patch_curobo(monkeypatch, feasible_fn=lambda n: [False] * n) visualizer = MagicMock() # The batch the check is given is candidates 7 and 9 of the run. - visualizer.candidate_index_for_slot.side_effect = [7, 9] - validator = _make_reachability_validator(_fake_embodiment(), visualizer=visualizer) + visualizer.candidate_index_for_batch_index.side_effect = [7, 9] + validator = _make_reachability_validator(_fake_embodiment(), monkeypatch, visualizer=visualizer) drawn: list[dict] = [] monkeypatch.setattr(validator._rerun_layer, "log_candidate", lambda **kwargs: drawn.append(kwargs)) @@ -229,8 +232,8 @@ def test_reachability_layer_records_to_rrd(tmp_path): from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer - rrd_path = tmp_path / "placement.rrd" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(rrd_path)) + output_path = tmp_path / "placement.rrd" + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(output_path)) layer = ReachabilityRerunLayer(visualizer) layer.log_candidate( @@ -245,7 +248,7 @@ def test_reachability_layer_records_to_rrd(tmp_path): ) visualizer.close() - assert rrd_path.is_file() and rrd_path.stat().st_size > 0 + assert output_path.is_file() and output_path.stat().st_size > 0 @pytest.mark.curobo_deps From fdd4554b905dee82da11cb2de7844909e9f4e4d2 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Sun, 2 Aug 2026 22:19:54 -0700 Subject: [PATCH 09/17] Simplify the placement debug view docstrings Say what each piece does in plain terms: shorter constant docs, one straightforward line per function, and the remaining rationale (setpriv, the shared view, the deepcopy guard) stated directly instead of alluded to. Signed-off-by: Xinjie Yao --- .../environment_spec/arena_env_graph_types.py | 4 +- isaaclab_arena/relations/object_placer.py | 7 +- .../relations/object_placer_params.py | 2 +- .../relations/placement_visualizer.py | 82 +++++++++---------- .../tests/test_placement_visualizer.py | 4 +- .../ik_reachability_validator.py | 11 +-- .../reachability_visualizer.py | 8 +- 7 files changed, 58 insertions(+), 60 deletions(-) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_types.py b/isaaclab_arena/environment_spec/arena_env_graph_types.py index b8b5ee0b68..a59f1db6bd 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_types.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_types.py @@ -318,8 +318,8 @@ class PlacementValidatorSpec(BaseModel): debug_visualize_output_path: str | None = Field( default=None, description=( - "Path to record the debug visualization to as a Rerun .rrd file, for headless runs. Set on " - "its own it records without opening a window; set alongside debug_visualize it does both." + "Path to record the debug visualization to as a Rerun .rrd file, for headless runs. On its " + "own it records without opening a window; with debug_visualize it does both." ), ) diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 69472871ae..f954c4a82e 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -79,8 +79,7 @@ class ObjectPlacer: def __init__(self, params: ObjectPlacerParams | None = None): self.params = params or ObjectPlacerParams() self._solver = RelationSolver(params=self.params.solver_params) - # Created before the validators are built: a check picks the view up as it is constructed, so - # that it can draw its own layer into the frames this placer opens. + # Created before the validators, since a check looks the view up when it is constructed. self._visualizer = get_or_create_placement_visualizer(self.params) self._validators: list[PlacementValidator] = build_validators(self.params) @@ -602,8 +601,8 @@ def _validate_candidates( # Per-check batch indices that check was actually run on; expensive checks skip candidates. evaluated_batch_indices_by_check: dict[str, list[int]] = {} layout_pass_verdicts_by_check: dict[str, list[bool]] = {} - # None unless the debug view is on. The layouts are drawn before the checks run, so that a - # check drawing into the view (e.g. the cuRobo one, its grasps) finds the frame it draws onto. + # None unless the debug view is on. Drawn before the checks run, so that a check drawing into + # the view (the cuRobo one draws its grasps) finds the candidate's frame already there. candidate_indices = ( self._visualizer.log_layout_batch(positions, orientations, bboxes) if self._visualizer is not None else None ) diff --git a/isaaclab_arena/relations/object_placer_params.py b/isaaclab_arena/relations/object_placer_params.py index 4e7b054c9e..8732e883f4 100644 --- a/isaaclab_arena/relations/object_placer_params.py +++ b/isaaclab_arena/relations/object_placer_params.py @@ -91,4 +91,4 @@ class ObjectPlacerParams: debug_visualize_output_path: str | None = None """Path to record the debug visualization to as a Rerun ``.rrd`` file, for headless runs. - Set on its own it records without opening a window; set alongside ``debug_visualize`` it does both.""" + On its own it records without opening a window; with ``debug_visualize`` it does both.""" diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index b86e27bc3d..969b49b0fe 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -5,13 +5,12 @@ """Rerun debug view of build-time placement validation, sim-free (no SimApp). -Every candidate layout the checks evaluate becomes one frame of the ``candidate`` timeline: the -solved boxes, plus the verdict of each check that ran on it. A check that knows more about a -candidate than its boxes logs its own entities onto that same frame -- its *layer* -- as the cuRobo -reachability check does in ``isaaclab_arena_curobo.reachability_visualizer``. +Each candidate layout becomes one frame of the ``candidate`` timeline, showing the solved object +boxes and the verdict of every check that ran on it. A check may draw more onto the same frame; see +``isaaclab_arena_curobo.reachability_visualizer``. -Turn the view on with ``ObjectPlacerParams.debug_visualize`` (a viewer window) and/or -``debug_visualize_output_path`` (a recording); worked YAML example in +Turn on with ``ObjectPlacerParams.debug_visualize`` (viewer window) and/or +``debug_visualize_output_path`` (recording). YAML example: ``isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml``. """ @@ -38,40 +37,40 @@ """Entity path of the candidate's object boxes.""" ROBOT_ENTITY = "world/robot" -"""Entity path reserved for check-specific robot layers; cleared per candidate so a check that skips a -candidate does not leave its previous frame's geometry on screen.""" +"""Entity path checks draw the robot into. Cleared on each candidate, so a check that skipped one +leaves no stale geometry behind.""" ANCHOR_COLOR = (140, 140, 150) -"""Color of the layout's anchors, which are fixed and only act as obstacles.""" +"""Color of the layout's anchor objects.""" MOVABLE_COLOR = (70, 130, 220) -"""Color of the objects placement actually solves for.""" +"""Color of the objects placement solves for.""" VIEWER_HOST = "127.0.0.1" -"""Interface the spawned viewer is reached on; it always runs alongside the process that logs to it.""" +"""Interface the spawned viewer is reached on.""" VIEWER_PORT = 9876 """Port the spawned viewer serves on; Rerun's default, so ``rerun --connect`` finds it unprompted.""" VIEWER_SHUTDOWN_TIMEOUT_S = 10.0 -"""How long an explicit close() waits for the viewer window to go away before giving up on it.""" +"""How long close() waits for the viewer to exit before killing it.""" VIEWER_STARTUP_TIMEOUT_S = 20.0 -"""How long spawning waits for the viewer window to start serving before letting placement run on.""" +"""How long spawning waits for the viewer to start serving.""" VIEWER_PROBE_TIMEOUT_S = 0.2 -"""How long one connection attempt to the viewer port may take before it counts as unanswered.""" +"""Timeout of one connection attempt to the viewer port.""" VIEWER_PROBE_INTERVAL_S = 0.1 -"""How long to wait between connection attempts while the viewer window starts.""" +"""Delay between connection attempts while the viewer starts.""" _ACTIVE_VISUALIZER: PlacementRerunVisualizer | None = None -"""The process's live view. Placement builds several placers (pool, per-reset solves) that would -otherwise each reset Rerun's global recording and fight over the same viewer port and ``.rrd``.""" +"""The process's view, shared by every placer. A run builds several (the pool, the per-reset solves), +and one view each would reset Rerun's global recording and compete for the viewer port.""" def get_or_create_placement_visualizer(params: ObjectPlacerParams) -> PlacementRerunVisualizer | None: - """Return the process's Rerun view of placement validation, or None when the params ask for none. + """Return the process's view, creating it on first use, or None when the params ask for no view. Args: params: Placement parameters carrying the ``debug_visualize`` / ``debug_visualize_output_path`` fields. @@ -87,10 +86,9 @@ def get_or_create_placement_visualizer(params: ObjectPlacerParams) -> PlacementR def get_active_placement_visualizer() -> PlacementRerunVisualizer | None: - """Return the process's Rerun view of placement validation, or None when no placer asked for one. + """Return the process's view, or None when no placer asked for one. - How a check reaches the view it draws its own layer into: the placer creates the view before it - builds the checks, so this is set by the time a check is constructed. + This is how a check finds the view to draw into: the placer creates it before building the checks. """ return _ACTIVE_VISUALIZER @@ -98,8 +96,8 @@ def get_active_placement_visualizer() -> PlacementRerunVisualizer | None: def find_rerun_viewer_executable() -> str | None: """Return the path of the Rerun viewer binary shipped with ``rerun-sdk``, or None if absent. - Isaac Sim's Python does not put the packaged ``rerun_cli`` directory on PATH, so ``rr.spawn()`` - fails to find the viewer unless it is passed explicitly. + Isaac Sim's Python leaves the packaged ``rerun_cli`` directory off PATH, so the viewer has to be + located and passed explicitly. """ import rerun as rr @@ -108,10 +106,11 @@ def find_rerun_viewer_executable() -> str | None: def spawn_viewer_process() -> tuple[subprocess.Popen, Any]: - """Spawn a viewer window that dies with this process; return it and the sink that streams to it. + """Spawn a viewer window and return it with the sink that streams to it. - ``setpriv --pdeathsig`` rather than ``rerun.spawn()``, which detaches the viewer and drops its - pid: the kernel then closes the window even on the hard ``os._exit`` Isaac Sim shuts down with. + Spawned under ``setpriv --pdeathsig``, so the kernel closes the window when this process dies, + including on the hard ``os._exit`` Isaac Sim shuts down with. ``rr.spawn()`` cannot do this: it + detaches the viewer and drops its pid. """ import rerun as rr @@ -139,16 +138,16 @@ def spawn_viewer_process() -> tuple[subprocess.Popen, Any]: def _viewer_port_answers() -> bool: - """Whether anything at all is serving on the viewer port -- not necessarily our own viewer.""" + """Whether anything is serving on the viewer port; not necessarily our own viewer.""" with socket.socket() as probe: probe.settimeout(VIEWER_PROBE_TIMEOUT_S) return probe.connect_ex((VIEWER_HOST, VIEWER_PORT)) == 0 def _wait_until_viewer_serves(viewer_process: subprocess.Popen) -> None: - """Block until the spawned viewer answers on its port, so the first candidates are not lost. + """Wait until the spawned viewer answers on its port, so the first candidates are not lost. - Never fatal -- a view that fails to come up does not stop the run it was only meant to explain. + Warns rather than raises: a view that fails to come up must not stop the run. """ deadline = time.monotonic() + VIEWER_STARTUP_TIMEOUT_S while time.monotonic() < deadline: @@ -171,8 +170,8 @@ def summarize_candidate_verdict( ) -> tuple[str, bool]: """Describe how placement judged one candidate, as ``(message, accepted)``. - Acceptance follows the placer: only required checks gate a layout, so a candidate that failed - nothing else is accepted and the failure is reported as advisory rather than as a rejection. + Matches how the placer decides: only required checks reject a layout, so a candidate that failed + just the others is accepted and its failures are reported as advisory. Args: candidate_index: Timeline index of the candidate, used in the message. @@ -193,7 +192,7 @@ class PlacementRerunVisualizer: """Streams every validated candidate layout to Rerun, one frame per candidate.""" def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_path: str | None = None) -> None: - """Start the recording and, unless recording headlessly, spawn a viewer window. + """Start the recording, and spawn a viewer window to stream it to unless ``spawn`` is False. Args: app_id: Rerun application id, shown in the viewer title. @@ -218,10 +217,10 @@ def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_p self._active_candidate_indices: list[int] = [] def __deepcopy__(self, memory_map: dict[int, object]) -> PlacementRerunVisualizer: - """Return the live view for ``copy.deepcopy`` instead of duplicating it. + """Return this view instead of a copy, since ``copy.deepcopy`` must not duplicate it. - Isaac Lab's configclass deep-copies the placement event params that carry the pool, and a - duplicated view would keep its own candidate counter and overwrite frames this one already drew. + Isaac Lab's configclass deep-copies the placement event params that carry the pool. A copy + would count candidates on its own and overwrite frames this view already drew. Args: memory_map: ``copy.deepcopy``'s ``id(original) -> copy`` cache. @@ -237,8 +236,7 @@ def num_logged_candidates(self) -> int: def next_batch_indices(self, num_candidates: int) -> list[int]: """Reserve and return one timeline index per candidate of the batch about to be validated. - Indices keep counting across batches so a pool that refills several times does not overwrite - its earlier frames. + Indices keep counting across batches, so a refilling pool does not overwrite earlier frames. """ start = self._next_candidate_index self._next_candidate_index += num_candidates @@ -247,8 +245,8 @@ def next_batch_indices(self, num_candidates: int) -> list[int]: def set_active_candidates(self, candidate_indices: list[int]) -> None: """Declare which candidates the validator about to run will see, in the order it sees them. - Expensive checks only run on the candidates that passed the cheap ones, so their batch position - is not the candidate number; this is what lets them log against the right frame. + Expensive checks only run on the candidates that passed the cheap ones, so a candidate's + position in their batch is not its candidate number. """ self._active_candidate_indices = list(candidate_indices) @@ -348,8 +346,8 @@ def log_verdict_batch( ) -> None: """Annotate every drawn candidate of one batch with the checks that accepted or rejected it. - A check that skipped a candidate is left off it, so the view never shows an expensive check - rejecting a layout it was never run on. + A check that skipped a candidate is left off it, so the view never shows a check rejecting a + layout it never ran on. Args: candidate_indices: Timeline index of each candidate of the batch, as ``log_layout_batch`` returned. @@ -393,7 +391,7 @@ def log_verdicts( def close(self) -> None: """Flush pending data and shut down the viewer window this run spawned. Idempotent. - Only needed to close the window early -- a run that just exits leaves it to the viewer's + Only needed to close the window early; a run that just exits leaves that to the viewer's parent-death signal. """ import rerun as rr diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index 09e1b6c341..0f4d32a137 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -5,8 +5,8 @@ """Tests for the Rerun debug view of build-time placement validation. -Placement is sim-free, so these run a real solve and a real recording, headless: the ``.rrd`` sink -replaces the viewer window, and no Isaac Sim or GPU is involved. +Placement is sim-free, so these run a real solve and a real recording headless: the ``.rrd`` sink +stands in for the viewer window, and no Isaac Sim or GPU is involved. """ from __future__ import annotations diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index d33928b46c..7733bccc51 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -8,8 +8,9 @@ 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. -To see why a layout was called unreachable, turn the placement debug view on (``ObjectPlacerParams.debug_visualize``); -this check then draws its own layer into it, via ``isaaclab_arena_curobo.reachability_visualizer``. +To see why a layout was called unreachable, turn the placement debug view on +(``ObjectPlacerParams.debug_visualize``); this check then draws into it, via +``isaaclab_arena_curobo.reachability_visualizer``. """ from __future__ import annotations @@ -88,7 +89,7 @@ def __init__(self, params: ObjectPlacerParams) -> None: @staticmethod def _make_rerun_layer() -> ReachabilityRerunLayer | None: - """Return this check's layer of the placement debug view, or None when that view is off.""" + """Return what this check draws into the placement debug view, or None when that view is off.""" visualizer = get_active_placement_visualizer() if visualizer is None: return None @@ -133,8 +134,8 @@ def _validate( Args: positions: Solved (x, y, z) per object. orientations: Absolute world Z-yaw per object. - batch_index: Position of this candidate in the batch this check was given, which the debug - view maps back to the candidate's own frame. + batch_index: Position of this candidate in the batch this check was given. The debug view + maps it back to the candidate's own frame. """ objects = list(positions.keys()) anchors = set(get_anchor_objects(objects)) diff --git a/isaaclab_arena_curobo/reachability_visualizer.py b/isaaclab_arena_curobo/reachability_visualizer.py index 39ef981d69..3caed76294 100644 --- a/isaaclab_arena_curobo/reachability_visualizer.py +++ b/isaaclab_arena_curobo/reachability_visualizer.py @@ -19,7 +19,7 @@ """Color of a grasp the robot can reach.""" UNREACHABLE_COLOR = (220, 50, 50) -"""Color of a grasp the robot cannot reach, i.e. the one that rejected the layout.""" +"""Color of a grasp the robot cannot reach, which is what rejected the layout.""" BASE_AXIS_LENGTH = 0.2 """Length (m) of the drawn robot base frame axes.""" @@ -32,10 +32,10 @@ class ReachabilityRerunLayer: - """Draws the reachability check's verdict for a candidate into the shared placement view.""" + """Draws the reachability check's verdict for a candidate into the placement debug view.""" def __init__(self, visualizer: PlacementRerunVisualizer) -> None: - """Bind the layer to the placement view it draws into. + """Bind to the placement debug view this draws into. Args: visualizer: The process's placement debug view, which owns the recording and the timeline. @@ -53,7 +53,7 @@ def log_candidate( position_error: torch.Tensor, rotation_error: torch.Tensor, ) -> None: - """Log the robot's side of one evaluated candidate. + """Log the robot's side of one candidate this check evaluated. Args: candidate_index: Timeline index of the candidate, as assigned by the placement view. From 2af6e449427d0e84235a2e358f5faac0cc4c784d Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Sun, 2 Aug 2026 22:34:41 -0700 Subject: [PATCH 10/17] comments --- .../arena_env_graph_conversion_utils.py | 4 +- .../environment_spec/arena_env_graph_types.py | 6 +- isaaclab_arena/relations/object_placer.py | 94 +++++++--- .../relations/object_placer_params.py | 17 +- .../relations/placement_visualizer.py | 176 ++++++------------ .../tests/test_arena_env_graph_spec.py | 4 +- .../placement_debug_view_env_graph.yaml | 4 +- .../tests/test_placement_visualizer.py | 79 ++++---- .../ik_reachability_validator.py | 34 ++-- .../reachability_visualizer.py | 14 +- .../tests/test_ik_reachability_validator.py | 21 +-- 11 files changed, 223 insertions(+), 230 deletions(-) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py b/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py index 24b568f1df..856edcb931 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py @@ -63,8 +63,8 @@ def build_checks_for_placer_params(graph_spec: ArenaEnvGraphSpec) -> ObjectPlace required_checks=set(required_checks) if required_checks is not None else None, solver_params=RelationSolverParams(verbose=False, save_position_history=False), debug_visualize=placement_validators is not None and placement_validators.debug_visualize, - debug_visualize_output_path=( - placement_validators.debug_visualize_output_path if placement_validators is not None else None + debug_visualize_rrd_path=( + placement_validators.debug_visualize_rrd_path if placement_validators is not None else None ), ) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_types.py b/isaaclab_arena/environment_spec/arena_env_graph_types.py index a59f1db6bd..9ecd3e5d2c 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_types.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_types.py @@ -315,11 +315,11 @@ class PlacementValidatorSpec(BaseModel): "never starts Isaac Sim, and it closes with the run." ), ) - debug_visualize_output_path: str | None = Field( + debug_visualize_rrd_path: str | None = Field( default=None, description=( - "Path to record the debug visualization to as a Rerun .rrd file, for headless runs. On its " - "own it records without opening a window; with debug_visualize it does both." + "Path to record the debug visualization to as a Rerun .rrd file, for headless runs. Enables " + "the visualization on its own; combine with debug_visualize to both record and watch live." ), ) diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index f954c4a82e..851de547ae 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -79,8 +79,8 @@ class ObjectPlacer: def __init__(self, params: ObjectPlacerParams | None = None): self.params = params or ObjectPlacerParams() self._solver = RelationSolver(params=self.params.solver_params) - # Created before the validators, since a check looks the view up when it is constructed. - self._visualizer = get_or_create_placement_visualizer(self.params) + # Populated before the validators are built so a check can add its own layer to the same view. + self.params.debug_visualizer = get_or_create_placement_visualizer(self.params) self._validators: list[PlacementValidator] = build_validators(self.params) def place( @@ -598,14 +598,11 @@ def _validate_candidates( # required_checks=None means "every enabled check is required"; an empty set means no checks. required = self.params.required_checks num_candidates = len(positions) - # Per-check batch indices that check was actually run on; expensive checks skip candidates. - evaluated_batch_indices_by_check: dict[str, list[int]] = {} + # Per-check candidate indices that check was actually run on; expensive checks skip candidates. + evaluated_slots_by_check: dict[str, list[int]] = {} layout_pass_verdicts_by_check: dict[str, list[bool]] = {} - # None unless the debug view is on. Drawn before the checks run, so that a check drawing into - # the view (the cuRobo one draws its grasps) finds the candidate's frame already there. - candidate_indices = ( - self._visualizer.log_layout_batch(positions, orientations, bboxes) if self._visualizer is not None else None - ) + # Layouts are drawn before the checks run so a check's own layer lands on top of its candidate. + candidate_indices = self._log_candidate_layouts(positions, orientations, bboxes) self._run_inexpensive_checks( positions, @@ -613,7 +610,7 @@ def _validate_candidates( bboxes, collision_objects, layout_pass_verdicts_by_check, - evaluated_batch_indices_by_check, + evaluated_slots_by_check, candidate_indices, ) self._run_expensive_checks( @@ -623,19 +620,13 @@ def _validate_candidates( collision_objects, required, layout_pass_verdicts_by_check, - evaluated_batch_indices_by_check, + evaluated_slots_by_check, candidate_indices, ) - if self._visualizer is not None and candidate_indices is not None: - self._visualizer.log_verdict_batch( - candidate_indices, - layout_pass_verdicts_by_check, - evaluated_batch_indices_by_check, - self.params.required_checks, - ) + self._log_candidate_verdicts(candidate_indices, layout_pass_verdicts_by_check, evaluated_slots_by_check) if layout_pass_verdicts_by_check: summary = ", ".join( - f"{check}={sum(verdicts)}/{len(evaluated_batch_indices_by_check[check])}" + f"{check}={sum(verdicts)}/{len(evaluated_slots_by_check[check])}" for check, verdicts in layout_pass_verdicts_by_check.items() ) print(f"[placement] Validated {num_candidates} candidate layout(s); passed per check: {summary}") @@ -656,19 +647,19 @@ def _run_inexpensive_checks( bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], collision_objects: list[CollisionObject], layout_pass_verdicts_by_check: dict[str, list[bool]], - evaluated_batch_indices_by_check: dict[str, list[int]], + evaluated_slots_by_check: dict[str, list[int]], candidate_indices: list[int] | None, ) -> None: - """Run every inexpensive validator on all candidates, recording verdicts and evaluated indices.""" + """Run every inexpensive validator on all candidates, recording verdicts and evaluated slots.""" num_candidates = len(positions) for validator in self._validators: if not validator.run_after_inexpensive_checks: - if self._visualizer is not None: - self._visualizer.set_active_candidates(candidate_indices or []) + if self.params.debug_visualizer is not None: + self.params.debug_visualizer.set_active_candidates(candidate_indices or []) layout_pass_verdicts_by_check[validator.check] = validator.validate_batch( positions, orientations, bboxes, collision_objects ) - evaluated_batch_indices_by_check[validator.check] = list(range(num_candidates)) + evaluated_slots_by_check[validator.check] = list(range(num_candidates)) def _run_expensive_checks( self, @@ -678,7 +669,7 @@ def _run_expensive_checks( collision_objects: list[CollisionObject], required: set[str] | None, layout_pass_verdicts_by_check: dict[str, list[bool]], - evaluated_batch_indices_by_check: dict[str, list[int]], + evaluated_slots_by_check: dict[str, list[int]], candidate_indices: list[int] | None, ) -> None: """Run each expensive validator only on candidates that passed the required inexpensive checks.""" @@ -690,8 +681,10 @@ def _run_expensive_checks( for i in range(num_candidates) if self._passes_required_checks(layout_pass_verdicts_by_check, required, i) ] - if self._visualizer is not None and candidate_indices is not None: - self._visualizer.set_active_candidates([candidate_indices[i] for i in passed_layout_indices]) + if self.params.debug_visualizer is not None and candidate_indices is not None: + self.params.debug_visualizer.set_active_candidates( + [candidate_indices[i] for i in passed_layout_indices] + ) # only passed layouts are validated verdicts_over_passed_layout = validator.validate_batch( [positions[i] for i in passed_layout_indices], @@ -703,7 +696,52 @@ def _run_expensive_checks( for sub_idx, cand_idx in enumerate(passed_layout_indices): verdicts[cand_idx] = verdicts_over_passed_layout[sub_idx] layout_pass_verdicts_by_check[validator.check] = verdicts - evaluated_batch_indices_by_check[validator.check] = passed_layout_indices + evaluated_slots_by_check[validator.check] = passed_layout_indices + + def _log_candidate_layouts( + self, + positions: list[dict[PlaceableAsset, tuple[float, float, float]]], + orientations: list[dict[PlaceableAsset, float]], + bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], + ) -> list[int] | None: + """Draw every candidate of this batch in the Rerun debug view, returning their timeline indices. + + None when the debug view is off, which is the default. + """ + visualizer = self.params.debug_visualizer + if visualizer is None: + return None + candidate_indices = visualizer.next_batch_indices(len(positions)) + for slot, candidate_index in enumerate(candidate_indices): + anchors = set(get_anchor_objects(list(positions[slot]))) + visualizer.log_layout(candidate_index, positions[slot], orientations[slot], bboxes[slot], anchors) + return candidate_indices + + def _log_candidate_verdicts( + self, + candidate_indices: list[int] | None, + layout_pass_verdicts_by_check: dict[str, list[bool]], + evaluated_slots_by_check: dict[str, list[int]], + ) -> None: + """Annotate each drawn candidate with the checks that accepted or rejected it. + + A check that skipped a candidate is left off it, so the view never shows an expensive check + rejecting a layout it was never run on. + """ + visualizer = self.params.debug_visualizer + if visualizer is None or candidate_indices is None: + return + evaluated_slots = {check: set(slots) for check, slots in evaluated_slots_by_check.items()} + for slot, candidate_index in enumerate(candidate_indices): + visualizer.log_verdicts( + candidate_index, + { + check: verdicts[slot] + for check, verdicts in layout_pass_verdicts_by_check.items() + if slot in evaluated_slots[check] + }, + self.params.required_checks, + ) @staticmethod def _passes_required_checks( diff --git a/isaaclab_arena/relations/object_placer_params.py b/isaaclab_arena/relations/object_placer_params.py index 8732e883f4..4bafc84a1f 100644 --- a/isaaclab_arena/relations/object_placer_params.py +++ b/isaaclab_arena/relations/object_placer_params.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from isaaclab_arena.embodiments.embodiment_base import EmbodimentBase + from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer @dataclass @@ -86,9 +87,19 @@ class ObjectPlacerParams: """Tuning for the optional ``ik_reachable`` build-time check. See ReachabilityConfig for more details.""" debug_visualize: bool = False - """If True, stream every validated candidate layout to a spawned Rerun viewer window.""" + """If True, stream every validated candidate layout to a spawned Rerun viewer window. - debug_visualize_output_path: str | None = None + Debug aid, off by default. Needs the ``rerun-sdk`` package and a reachable display (the container + forwards ``DISPLAY``); the viewer is its own process, so this never starts Isaac Sim, and it is + closed when the run exits. Checks that can say more about a candidate add their own layer -- the + cuRobo reachability check draws the grasps it solved and the robot's collision spheres.""" + + debug_visualize_rrd_path: str | None = None """Path to record the debug visualization to as a Rerun ``.rrd`` file, for headless runs. - On its own it records without opening a window; with ``debug_visualize`` it does both.""" + Enables the visualization on its own; combine with ``debug_visualize`` to both record and watch live.""" + + debug_visualizer: PlacementRerunVisualizer | None = None + """The live Rerun view the debug fields above ask for; populated by ObjectPlacer, not by callers. + + Carried here so validators, which only receive these params, can add their own layer to it.""" diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index 969b49b0fe..07db51d5a7 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -5,13 +5,26 @@ """Rerun debug view of build-time placement validation, sim-free (no SimApp). -Each candidate layout becomes one frame of the ``candidate`` timeline, showing the solved object -boxes and the verdict of every check that ran on it. A check may draw more onto the same frame; see -``isaaclab_arena_curobo.reachability_visualizer``. +Rerun's viewer is a separate process fed by the logging SDK, so nothing here touches Isaac Sim -- the +window comes up while layouts are being solved, before any simulation exists. -Turn on with ``ObjectPlacerParams.debug_visualize`` (viewer window) and/or -``debug_visualize_output_path`` (recording). YAML example: -``isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml``. +Turn it on from an env graph YAML (worked example: +``isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml``):: + + placement_validators: + debug_visualize: true # spawn a viewer window; needs a reachable display + debug_visualize_rrd_path: /tmp/placement.rrd # and/or record, for headless runs + +or in Python with ``ObjectPlacerParams(debug_visualize=True)``. Either field alone enables the view. +Shipped envs leave it off, since it spawns a window on every build; enable it while debugging a scene +whose layouts look wrong, then take it back out. + +Every candidate layout is one frame of the ``candidate`` timeline, so scrubbing it shows what was +solved and which checks rejected it. Checks that know more about a candidate than its boxes add their +own layer under ``world/robot`` (see the cuRobo reachability check). + +The spawned window belongs to the run: it comes up during placement, stays up for the rest of the +run, and dies with the process that spawned it. Record to an ``.rrd`` to inspect layouts afterwards. """ from __future__ import annotations @@ -23,8 +36,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from isaaclab_arena.relations.relations import get_anchor_objects - if TYPE_CHECKING: from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena.relations.placement_asset import PlaceableAsset @@ -37,67 +48,59 @@ """Entity path of the candidate's object boxes.""" ROBOT_ENTITY = "world/robot" -"""Entity path checks draw the robot into. Cleared on each candidate, so a check that skipped one -leaves no stale geometry behind.""" +"""Entity path reserved for check-specific robot layers; cleared per candidate so a check that skips a +candidate does not leave its previous frame's geometry on screen.""" ANCHOR_COLOR = (140, 140, 150) -"""Color of the layout's anchor objects.""" +"""Color of the layout's anchors, which are fixed and only act as obstacles.""" MOVABLE_COLOR = (70, 130, 220) -"""Color of the objects placement solves for.""" +"""Color of the objects placement actually solves for.""" VIEWER_HOST = "127.0.0.1" -"""Interface the spawned viewer is reached on.""" +"""Interface the spawned viewer is reached on; it always runs alongside the process that logs to it.""" VIEWER_PORT = 9876 """Port the spawned viewer serves on; Rerun's default, so ``rerun --connect`` finds it unprompted.""" VIEWER_SHUTDOWN_TIMEOUT_S = 10.0 -"""How long close() waits for the viewer to exit before killing it.""" +"""How long an explicit close() waits for the viewer window to go away before giving up on it.""" VIEWER_STARTUP_TIMEOUT_S = 20.0 -"""How long spawning waits for the viewer to start serving.""" +"""How long spawning waits for the viewer window to start serving before letting placement run on.""" VIEWER_PROBE_TIMEOUT_S = 0.2 -"""Timeout of one connection attempt to the viewer port.""" +"""How long one connection attempt to the viewer port may take before it counts as unanswered.""" VIEWER_PROBE_INTERVAL_S = 0.1 -"""Delay between connection attempts while the viewer starts.""" +"""How long to wait between connection attempts while the viewer window starts.""" _ACTIVE_VISUALIZER: PlacementRerunVisualizer | None = None -"""The process's view, shared by every placer. A run builds several (the pool, the per-reset solves), -and one view each would reset Rerun's global recording and compete for the viewer port.""" +"""The process's live view. Placement builds several placers (pool, per-reset solves) that would +otherwise each reset Rerun's global recording and fight over the same viewer port and ``.rrd``.""" def get_or_create_placement_visualizer(params: ObjectPlacerParams) -> PlacementRerunVisualizer | None: - """Return the process's view, creating it on first use, or None when the params ask for no view. + """Return the process's Rerun view of placement validation, or None when the params ask for none. Args: - params: Placement parameters carrying the ``debug_visualize`` / ``debug_visualize_output_path`` fields. + params: Placement parameters carrying the ``debug_visualize`` / ``debug_visualize_rrd_path`` fields. """ global _ACTIVE_VISUALIZER - if not params.debug_visualize and params.debug_visualize_output_path is None: + if not params.debug_visualize and params.debug_visualize_rrd_path is None: return None if _ACTIVE_VISUALIZER is None: _ACTIVE_VISUALIZER = PlacementRerunVisualizer( - spawn=params.debug_visualize, output_path=params.debug_visualize_output_path + spawn=params.debug_visualize, rrd_path=params.debug_visualize_rrd_path ) return _ACTIVE_VISUALIZER -def get_active_placement_visualizer() -> PlacementRerunVisualizer | None: - """Return the process's view, or None when no placer asked for one. - - This is how a check finds the view to draw into: the placer creates it before building the checks. - """ - return _ACTIVE_VISUALIZER - - def find_rerun_viewer_executable() -> str | None: """Return the path of the Rerun viewer binary shipped with ``rerun-sdk``, or None if absent. - Isaac Sim's Python leaves the packaged ``rerun_cli`` directory off PATH, so the viewer has to be - located and passed explicitly. + Isaac Sim's Python does not put the packaged ``rerun_cli`` directory on PATH, so ``rr.spawn()`` + fails to find the viewer unless it is passed explicitly. """ import rerun as rr @@ -106,11 +109,10 @@ def find_rerun_viewer_executable() -> str | None: def spawn_viewer_process() -> tuple[subprocess.Popen, Any]: - """Spawn a viewer window and return it with the sink that streams to it. + """Spawn a viewer window that dies with this process; return it and the sink that streams to it. - Spawned under ``setpriv --pdeathsig``, so the kernel closes the window when this process dies, - including on the hard ``os._exit`` Isaac Sim shuts down with. ``rr.spawn()`` cannot do this: it - detaches the viewer and drops its pid. + ``setpriv --pdeathsig`` rather than ``rerun.spawn()``, which detaches the viewer and drops its + pid: the kernel then closes the window even on the hard ``os._exit`` Isaac Sim shuts down with. """ import rerun as rr @@ -138,16 +140,16 @@ def spawn_viewer_process() -> tuple[subprocess.Popen, Any]: def _viewer_port_answers() -> bool: - """Whether anything is serving on the viewer port; not necessarily our own viewer.""" + """Whether anything at all is serving on the viewer port -- not necessarily our own viewer.""" with socket.socket() as probe: probe.settimeout(VIEWER_PROBE_TIMEOUT_S) return probe.connect_ex((VIEWER_HOST, VIEWER_PORT)) == 0 def _wait_until_viewer_serves(viewer_process: subprocess.Popen) -> None: - """Wait until the spawned viewer answers on its port, so the first candidates are not lost. + """Block until the spawned viewer answers on its port, so the first candidates are not lost. - Warns rather than raises: a view that fails to come up must not stop the run. + Never fatal -- a view that fails to come up does not stop the run it was only meant to explain. """ deadline = time.monotonic() + VIEWER_STARTUP_TIMEOUT_S while time.monotonic() < deadline: @@ -170,8 +172,8 @@ def summarize_candidate_verdict( ) -> tuple[str, bool]: """Describe how placement judged one candidate, as ``(message, accepted)``. - Matches how the placer decides: only required checks reject a layout, so a candidate that failed - just the others is accepted and its failures are reported as advisory. + Acceptance follows the placer: only required checks gate a layout, so a candidate that failed + nothing else is accepted and the failure is reported as advisory rather than as a rejection. Args: candidate_index: Timeline index of the candidate, used in the message. @@ -191,13 +193,13 @@ def summarize_candidate_verdict( class PlacementRerunVisualizer: """Streams every validated candidate layout to Rerun, one frame per candidate.""" - def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_path: str | None = None) -> None: - """Start the recording, and spawn a viewer window to stream it to unless ``spawn`` is False. + def __init__(self, app_id: str = "arena_placement", spawn: bool = True, rrd_path: str | None = None) -> None: + """Start the recording and, unless recording headlessly, spawn a viewer window. Args: app_id: Rerun application id, shown in the viewer title. spawn: Whether to spawn a local viewer window and stream to it. - output_path: Optional ``.rrd`` path to also record the stream to, for replay elsewhere. + rrd_path: Optional path to also record the stream to, for replay on another machine. """ import rerun as rr @@ -207,20 +209,20 @@ def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_p if spawn: self._viewer_process, viewer_sink = spawn_viewer_process() sinks.append(viewer_sink) - if output_path is not None: - Path(output_path).parent.mkdir(parents=True, exist_ok=True) - sinks.append(rr.FileSink(output_path)) - assert sinks, "PlacementRerunVisualizer needs a viewer to spawn or an output path to record to." + if rrd_path is not None: + Path(rrd_path).parent.mkdir(parents=True, exist_ok=True) + sinks.append(rr.FileSink(rrd_path)) + assert sinks, "PlacementRerunVisualizer needs a viewer to spawn or an .rrd path to record to." rr.set_sinks(*sinks) rr.log("world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True) self._next_candidate_index = 0 self._active_candidate_indices: list[int] = [] def __deepcopy__(self, memory_map: dict[int, object]) -> PlacementRerunVisualizer: - """Return this view instead of a copy, since ``copy.deepcopy`` must not duplicate it. + """Return the live view for ``copy.deepcopy`` instead of duplicating it. - Isaac Lab's configclass deep-copies the placement event params that carry the pool. A copy - would count candidates on its own and overwrite frames this view already drew. + Isaac Lab's configclass deep-copies the placement event params that carry the pool, and a + duplicated view would keep its own candidate counter and overwrite frames this one already drew. Args: memory_map: ``copy.deepcopy``'s ``id(original) -> copy`` cache. @@ -236,7 +238,8 @@ def num_logged_candidates(self) -> int: def next_batch_indices(self, num_candidates: int) -> list[int]: """Reserve and return one timeline index per candidate of the batch about to be validated. - Indices keep counting across batches, so a refilling pool does not overwrite earlier frames. + Indices keep counting across batches so a pool that refills several times does not overwrite + its earlier frames. """ start = self._next_candidate_index self._next_candidate_index += num_candidates @@ -245,14 +248,14 @@ def next_batch_indices(self, num_candidates: int) -> list[int]: def set_active_candidates(self, candidate_indices: list[int]) -> None: """Declare which candidates the validator about to run will see, in the order it sees them. - Expensive checks only run on the candidates that passed the cheap ones, so a candidate's - position in their batch is not its candidate number. + Expensive checks only run on the candidates that passed the cheap ones, so their batch position + is not the candidate number; this is what lets them log against the right frame. """ self._active_candidate_indices = list(candidate_indices) - def candidate_index_for_batch_index(self, batch_index: int) -> int: - """Timeline index of the ``batch_index``-th candidate in the batch the running validator was given.""" - return self._active_candidate_indices[batch_index] + def candidate_index_for_slot(self, slot: int) -> int: + """Timeline index of the ``slot``-th candidate in the batch the running validator was given.""" + return self._active_candidate_indices[slot] def set_time(self, candidate_index: int) -> None: """Point the recording at one candidate's frame, so subsequent logs land on it.""" @@ -260,31 +263,6 @@ def set_time(self, candidate_index: int) -> None: rr.set_time(CANDIDATE_TIMELINE, sequence=candidate_index) - def log_layout_batch( - self, - positions: list[dict[PlaceableAsset, tuple[float, float, float]]], - orientations: list[dict[PlaceableAsset, float]], - bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], - ) -> list[int]: - """Draw every candidate of one batch, returning the timeline index reserved for each. - - Args: - positions: Solved (x, y, z) per object, per candidate. - orientations: Absolute world Z-yaw per object, per candidate. - bboxes: Per-object local bounding box, per candidate. - """ - candidate_indices = self.next_batch_indices(len(positions)) - for batch_index, candidate_index in enumerate(candidate_indices): - anchors = set(get_anchor_objects(list(positions[batch_index]))) - self.log_layout( - candidate_index, - positions[batch_index], - orientations[batch_index], - bboxes[batch_index], - anchors, - ) - return candidate_indices - def log_layout( self, candidate_index: int, @@ -337,36 +315,6 @@ def log_layout( ), ) - def log_verdict_batch( - self, - candidate_indices: list[int], - verdicts_by_check: dict[str, list[bool]], - evaluated_batch_indices_by_check: dict[str, list[int]], - required_checks: set[str] | None, - ) -> None: - """Annotate every drawn candidate of one batch with the checks that accepted or rejected it. - - A check that skipped a candidate is left off it, so the view never shows a check rejecting a - layout it never ran on. - - Args: - candidate_indices: Timeline index of each candidate of the batch, as ``log_layout_batch`` returned. - verdicts_by_check: Verdict per candidate of the batch, per check. - evaluated_batch_indices_by_check: Batch indices each check actually ran on. - required_checks: Checks that gate acceptance; None means every check that ran gates it. - """ - evaluated = {check: set(indices) for check, indices in evaluated_batch_indices_by_check.items()} - for batch_index, candidate_index in enumerate(candidate_indices): - self.log_verdicts( - candidate_index, - { - check: verdicts[batch_index] - for check, verdicts in verdicts_by_check.items() - if batch_index in evaluated[check] - }, - required_checks, - ) - def log_verdicts( self, candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None ) -> None: @@ -391,7 +339,7 @@ def log_verdicts( def close(self) -> None: """Flush pending data and shut down the viewer window this run spawned. Idempotent. - Only needed to close the window early; a run that just exits leaves that to the viewer's + Only needed to close the window early -- a run that just exits leaves it to the viewer's parent-death signal. """ import rerun as rr diff --git a/isaaclab_arena/tests/test_arena_env_graph_spec.py b/isaaclab_arena/tests/test_arena_env_graph_spec.py index 1a027814b5..23511eeda1 100644 --- a/isaaclab_arena/tests/test_arena_env_graph_spec.py +++ b/isaaclab_arena/tests/test_arena_env_graph_spec.py @@ -472,7 +472,7 @@ def test_graph_spec_leaves_placement_debug_view_off_by_default(): params = build_checks_for_placer_params(ArenaEnvGraphSpec.from_yaml(_GRAPH)) assert not params.debug_visualize - assert params.debug_visualize_output_path is None + assert params.debug_visualize_rrd_path is None def test_graph_spec_forwards_placement_debug_view_to_placer_params(): @@ -482,7 +482,7 @@ def test_graph_spec_forwards_placement_debug_view_to_placer_params(): params = build_checks_for_placer_params(ArenaEnvGraphSpec.from_yaml(_DEBUG_VIEW_GRAPH)) assert params.debug_visualize - assert params.debug_visualize_output_path == "/tmp/placement_debug_view.rrd" + assert params.debug_visualize_rrd_path == "/tmp/placement_debug_view.rrd" def test_graph_spec_leaves_shipped_envs_out_of_the_debug_view(): diff --git a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml index 19000c5197..8a8f27c681 100644 --- a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml +++ b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml @@ -15,10 +15,10 @@ objects: - id: bowl_ycb_robolab registry_name: bowl_ycb_robolab placement_validators: - enabled_checks: [no_overlap, on_relation, ik_reachable] + enabled_checks: [no_overlap, on_relation, ik_reachability] required_checks: [no_overlap, on_relation] debug_visualize: true - debug_visualize_output_path: /tmp/placement_debug_view.rrd + debug_visualize_rrd_path: /tmp/placement_debug_view.rrd relations: - kind: is_anchor subject: maple_table_robolab diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index 0f4d32a137..00c9d16840 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -5,8 +5,8 @@ """Tests for the Rerun debug view of build-time placement validation. -Placement is sim-free, so these run a real solve and a real recording headless: the ``.rrd`` sink -stands in for the viewer window, and no Isaac Sim or GPU is involved. +Placement is sim-free, so these run a real solve and a real recording, headless: the ``.rrd`` sink +replaces the viewer window, and no Isaac Sim or GPU is involved. """ from __future__ import annotations @@ -18,11 +18,7 @@ from isaaclab_arena.relations import placement_visualizer from isaaclab_arena.relations.object_placer import ObjectPlacer from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams -from isaaclab_arena.relations.placement_visualizer import ( - PlacementRerunVisualizer, - get_active_placement_visualizer, - summarize_candidate_verdict, -) +from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer, summarize_candidate_verdict from isaaclab_arena.relations.relation_solver_params import RelationSolverParams from isaaclab_arena.relations.relations import IsAnchor, On from isaaclab_arena.tests.dummy_object import DummyObject @@ -68,32 +64,32 @@ def _placer_params(**overrides) -> ObjectPlacerParams: def test_placement_has_no_debug_view_by_default(): """The debug view is opt-in, so a default placement never touches Rerun.""" - ObjectPlacer(_placer_params()) + placer = ObjectPlacer(_placer_params()) - assert get_active_placement_visualizer() is None + assert placer.params.debug_visualizer is None def test_placement_records_every_candidate_layout(tmp_path): """Recording to an .rrd draws every candidate the solve produced, one frame each, without a viewer.""" - output_path = tmp_path / "placement.rrd" - placer = ObjectPlacer(_placer_params(debug_visualize_output_path=str(output_path))) + rrd_path = tmp_path / "placement.rrd" + placer = ObjectPlacer(_placer_params(debug_visualize_rrd_path=str(rrd_path))) placer.place(_desk_and_box(), num_envs=1) - visualizer = get_active_placement_visualizer() + visualizer = placer.params.debug_visualizer visualizer.close() assert visualizer.num_logged_candidates == MAX_PLACEMENT_ATTEMPTS - assert output_path.is_file() and output_path.stat().st_size > 0 + assert rrd_path.is_file() and rrd_path.stat().st_size > 0 def test_placement_shares_one_debug_view_across_placers(tmp_path): """Every placer in the process draws into the same view, keeping one viewer and one timeline.""" - ObjectPlacer(_placer_params(debug_visualize_output_path=str(tmp_path / "placement.rrd"))) - view_of_first_placer = get_active_placement_visualizer() + params = _placer_params(debug_visualize_rrd_path=str(tmp_path / "placement.rrd")) - ObjectPlacer(_placer_params(debug_visualize_output_path=str(tmp_path / "ignored.rrd"))) + first = ObjectPlacer(params) + second = ObjectPlacer(_placer_params(debug_visualize_rrd_path=str(tmp_path / "ignored.rrd"))) - assert get_active_placement_visualizer() is view_of_first_placer + assert first.params.debug_visualizer is second.params.debug_visualizer class _FakeViewerProcess: @@ -120,6 +116,20 @@ def wait(self, timeout: float | None = None) -> int: return 0 +class _RecordingVisualizer: + """Captures what the placer draws, so its verdict bookkeeping can be asserted without Rerun.""" + + def __init__(self) -> None: + self.verdicts_by_candidate: dict[int, dict[str, bool]] = {} + self.required_checks: set[str] | None = None + + def log_verdicts( + self, candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None + ) -> None: + self.verdicts_by_candidate[candidate_index] = verdicts_by_check + self.required_checks = required_checks + + def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch): """The window belongs to the run, so closing the view takes it down instead of leaving it on the port.""" import rerun as rr @@ -131,7 +141,7 @@ def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch "spawn_viewer_process", lambda: (viewer, rr.FileSink(str(tmp_path / "viewer_stand_in.rrd"))), ) - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, output_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, rrd_path=str(tmp_path / "p.rrd")) visualizer.close() visualizer.close() @@ -149,7 +159,7 @@ def test_closing_a_wedged_viewer_falls_back_to_killing_it(tmp_path, monkeypatch) "spawn_viewer_process", lambda: (viewer, rr.FileSink(str(tmp_path / "viewer_stand_in.rrd"))), ) - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, output_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, rrd_path=str(tmp_path / "p.rrd")) visualizer.close() @@ -186,26 +196,19 @@ def test_every_check_gates_a_candidate_when_none_are_named_required(): assert message == "candidate 3: rejected (failed: ik_reachable)" -def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(tmp_path, monkeypatch): +def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(): """Expensive checks only see candidates the cheap ones passed; the rest are unevaluated, not failed.""" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) - drawn_verdicts: dict[int, dict[str, bool]] = {} - monkeypatch.setattr( - visualizer, - "log_verdicts", - lambda candidate_index, verdicts_by_check, required_checks: drawn_verdicts.update( - {candidate_index: verdicts_by_check} - ), - ) + placer = ObjectPlacer(_placer_params()) + visualizer = _RecordingVisualizer() + placer.params.debug_visualizer = visualizer - visualizer.log_verdict_batch( + placer._log_candidate_verdicts( candidate_indices=[0, 1], - verdicts_by_check={"no_overlap": [True, False], "ik_reachable": [True, False]}, - evaluated_batch_indices_by_check={"no_overlap": [0, 1], "ik_reachable": [0]}, - required_checks=None, + layout_pass_verdicts_by_check={"no_overlap": [True, False], "ik_reachable": [True, False]}, + evaluated_slots_by_check={"no_overlap": [0, 1], "ik_reachable": [0]}, ) - assert drawn_verdicts == { + assert visualizer.verdicts_by_candidate == { 0: {"no_overlap": True, "ik_reachable": True}, 1: {"no_overlap": False}, } @@ -213,7 +216,7 @@ def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(tmp_path, def test_candidate_frames_keep_counting_across_batches(tmp_path): """A pool that refills gets fresh frames, so a later batch does not overwrite an earlier one.""" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) assert visualizer.next_batch_indices(3) == [0, 1, 2] assert visualizer.next_batch_indices(2) == [3, 4] @@ -221,9 +224,9 @@ def test_candidate_frames_keep_counting_across_batches(tmp_path): def test_active_candidates_map_batch_position_to_frame(tmp_path): """A check that only ran on some candidates still resolves each one's own frame.""" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) visualizer.set_active_candidates([1, 4]) - assert visualizer.candidate_index_for_batch_index(0) == 1 - assert visualizer.candidate_index_for_batch_index(1) == 4 + assert visualizer.candidate_index_for_slot(0) == 1 + assert visualizer.candidate_index_for_slot(1) == 4 diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index 7733bccc51..3ba7aa0eaa 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -8,9 +8,11 @@ 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. -To see why a layout was called unreachable, turn the placement debug view on -(``ObjectPlacerParams.debug_visualize``); this check then draws into it, via -``isaaclab_arena_curobo.reachability_visualizer``. +To see why a layout was called unreachable, turn on the placement debug view -- ``debug_visualize: true`` under +``placement_validators`` in the env graph YAML, or ``ObjectPlacerParams(debug_visualize=True)``. This check then adds its +own layer to it (the robot base, the grasps it solved, reachable/unreachable per target, IK error plots); see +``isaaclab_arena_curobo.reachability_visualizer``. Nothing is drawn without a registered cuRobo config for the +embodiment, since the check delists itself entirely in that case. """ from __future__ import annotations @@ -22,7 +24,6 @@ from isaaclab_arena.relations.placement_validation import PlacementCheck from isaaclab_arena.relations.placement_validator_registry import register_validator from isaaclab_arena.relations.placement_validators import PlacementValidator -from isaaclab_arena.relations.placement_visualizer import get_active_placement_visualizer from isaaclab_arena.relations.relations import RequiresReachability, get_anchor_objects from isaaclab_arena.utils.pose import Pose from isaaclab_arena.utils.yaw import rotate_quat_by_yaw, yaw_from_quat_xyzw @@ -84,18 +85,17 @@ def __init__(self, params: ObjectPlacerParams) -> None: self._base_quat_xyzw = base_pose.rotation_xyzw # Guards the zero-target warning so it fires once per validator, not once per candidate layout. self._warned_no_targets = False - self._visualizer = get_active_placement_visualizer() - self._rerun_layer = self._make_rerun_layer() + self._visualizer = params.debug_visualizer + self._rerun_layer = self._make_rerun_layer(params) @staticmethod - def _make_rerun_layer() -> ReachabilityRerunLayer | None: - """Return what this check draws into the placement debug view, or None when that view is off.""" - visualizer = get_active_placement_visualizer() - if visualizer is None: + def _make_rerun_layer(params: ObjectPlacerParams) -> ReachabilityRerunLayer | None: + """Return this check's layer of the placement debug view, or None when that view is off.""" + if params.debug_visualizer is None: return None from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer - return ReachabilityRerunLayer(visualizer) + return ReachabilityRerunLayer(params.debug_visualizer) @classmethod def is_available(cls, params: ObjectPlacerParams) -> bool: @@ -117,25 +117,19 @@ def validate_batch( bboxes: list[dict[ObjectBase, AxisAlignedBoundingBox]], collision_objects: list[CollisionObject], ) -> list[bool]: - return [self._validate(positions[i], orientations[i], batch_index=i) for i in range(len(positions))] + return [self._validate(positions[i], orientations[i], batch_slot=i) for i in range(len(positions))] def _validate( self, positions: dict[ObjectBase, tuple[float, float, float]], orientations: dict[ObjectBase, float], - batch_index: int, + batch_slot: int, ) -> bool: """Whether the robot can reach a top-down grasp at the target objects in one candidate layout. Rebuilds each object's world pose and a per-object collision cuboid, syncs them into the solver's world, then batches a single IK solve over the target objects' top-down grasps. A layout with nothing to grasp (anchor-only, or no target present) is trivially reachable. - - Args: - positions: Solved (x, y, z) per object. - orientations: Absolute world Z-yaw per object. - batch_index: Position of this candidate in the batch this check was given. The debug view - maps it back to the candidate's own frame. """ objects = list(positions.keys()) anchors = set(get_anchor_objects(objects)) @@ -182,7 +176,7 @@ def _validate( ) if self._rerun_layer is not None: self._rerun_layer.log_candidate( - candidate_index=self._visualizer.candidate_index_for_batch_index(batch_index), + candidate_index=self._visualizer.candidate_index_for_slot(batch_slot), base_pos=self._base_pos, base_quat_xyzw=self._base_quat_xyzw, target_names=[obj.name for obj in targets], diff --git a/isaaclab_arena_curobo/reachability_visualizer.py b/isaaclab_arena_curobo/reachability_visualizer.py index 3caed76294..8e43cf6bb5 100644 --- a/isaaclab_arena_curobo/reachability_visualizer.py +++ b/isaaclab_arena_curobo/reachability_visualizer.py @@ -5,8 +5,10 @@ """The reachability check's layer of the placement Rerun debug view, sim-free (no SimApp). -Draws where the robot stands, the top-down grasps it solved for a candidate layout, and whether each -one was reachable. +Core placement already draws each candidate layout's boxes (see +``isaaclab_arena.relations.placement_visualizer``); this adds what only the IK check knows -- where the +robot stands, the top-down grasps it solved, and whether each one was reachable. Everything is logged +against the same candidate frame, so the two layers compose. """ from __future__ import annotations @@ -19,7 +21,7 @@ """Color of a grasp the robot can reach.""" UNREACHABLE_COLOR = (220, 50, 50) -"""Color of a grasp the robot cannot reach, which is what rejected the layout.""" +"""Color of a grasp the robot cannot reach, i.e. the one that rejected the layout.""" BASE_AXIS_LENGTH = 0.2 """Length (m) of the drawn robot base frame axes.""" @@ -32,10 +34,10 @@ class ReachabilityRerunLayer: - """Draws the reachability check's verdict for a candidate into the placement debug view.""" + """Draws the reachability check's verdict for a candidate into the shared placement view.""" def __init__(self, visualizer: PlacementRerunVisualizer) -> None: - """Bind to the placement debug view this draws into. + """Bind the layer to the placement view it draws into. Args: visualizer: The process's placement debug view, which owns the recording and the timeline. @@ -53,7 +55,7 @@ def log_candidate( position_error: torch.Tensor, rotation_error: torch.Tensor, ) -> None: - """Log the robot's side of one candidate this check evaluated. + """Log the robot's side of one evaluated candidate. Args: candidate_index: Timeline index of the candidate, as assigned by the placement view. diff --git a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py index f5ae4a49a3..4c69475f36 100644 --- a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py +++ b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py @@ -178,20 +178,17 @@ def _make_unstamped_desk_box_pool(num_envs: int = 1, min_layouts_per_env: int = ) -def _make_reachability_validator(embodiment, monkeypatch=None, visualizer=None): +def _make_reachability_validator(embodiment, visualizer=None): """Construct the registered ReachabilityValidator with ``embodiment`` set on its params. - ``visualizer`` stands in for the placement debug view ObjectPlacer would have opened before it - built the check; without one the check finds no view to draw into. + ``visualizer`` stands in for the placement debug view ObjectPlacer would have populated. """ - from isaaclab_arena.relations import placement_visualizer as placement_visualizer_module from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena_curobo.ik_reachability_validator import ReachabilityValidator - if monkeypatch is not None: - monkeypatch.setattr(placement_visualizer_module, "_ACTIVE_VISUALIZER", visualizer) params = ObjectPlacerParams() params.reachability_config.embodiment = embodiment + params.debug_visualizer = visualizer return ReachabilityValidator(params) @@ -199,7 +196,7 @@ def _make_reachability_validator(embodiment, monkeypatch=None, visualizer=None): def test_validator_skips_visualization_by_default(monkeypatch): """The debug view is opt-in: placement without one means the check adds no layer.""" _patch_curobo(monkeypatch, feasible_fn=lambda n: [True] * n) - validator = _make_reachability_validator(_fake_embodiment(), monkeypatch) + validator = _make_reachability_validator(_fake_embodiment()) assert validator._rerun_layer is None @@ -213,8 +210,8 @@ def test_validator_draws_each_candidate_on_its_own_frame(monkeypatch): _patch_curobo(monkeypatch, feasible_fn=lambda n: [False] * n) visualizer = MagicMock() # The batch the check is given is candidates 7 and 9 of the run. - visualizer.candidate_index_for_batch_index.side_effect = [7, 9] - validator = _make_reachability_validator(_fake_embodiment(), monkeypatch, visualizer=visualizer) + visualizer.candidate_index_for_slot.side_effect = [7, 9] + validator = _make_reachability_validator(_fake_embodiment(), visualizer=visualizer) drawn: list[dict] = [] monkeypatch.setattr(validator._rerun_layer, "log_candidate", lambda **kwargs: drawn.append(kwargs)) @@ -232,8 +229,8 @@ def test_reachability_layer_records_to_rrd(tmp_path): from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer - output_path = tmp_path / "placement.rrd" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(output_path)) + rrd_path = tmp_path / "placement.rrd" + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(rrd_path)) layer = ReachabilityRerunLayer(visualizer) layer.log_candidate( @@ -248,7 +245,7 @@ def test_reachability_layer_records_to_rrd(tmp_path): ) visualizer.close() - assert output_path.is_file() and output_path.stat().st_size > 0 + assert rrd_path.is_file() and rrd_path.stat().st_size > 0 @pytest.mark.curobo_deps From 60a12a247890968436791791c1ceb9629f284e39 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Mon, 3 Aug 2026 12:14:46 -0700 Subject: [PATCH 11/17] refactor --- .../arena_env_graph_conversion_utils.py | 4 +- .../environment_spec/arena_env_graph_types.py | 2 +- isaaclab_arena/relations/object_placer.py | 81 +++++++--------- .../relations/object_placer_params.py | 8 +- .../relations/placement_visualizer.py | 95 +++++++++++-------- .../tests/test_arena_env_graph_spec.py | 4 +- .../placement_debug_view_env_graph.yaml | 2 +- .../tests/test_placement_visualizer.py | 61 ++++++------ .../ik_reachability_validator.py | 30 +++--- .../tests/test_ik_reachability_validator.py | 15 +-- 10 files changed, 152 insertions(+), 150 deletions(-) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py b/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py index 856edcb931..24b568f1df 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_conversion_utils.py @@ -63,8 +63,8 @@ def build_checks_for_placer_params(graph_spec: ArenaEnvGraphSpec) -> ObjectPlace required_checks=set(required_checks) if required_checks is not None else None, solver_params=RelationSolverParams(verbose=False, save_position_history=False), debug_visualize=placement_validators is not None and placement_validators.debug_visualize, - debug_visualize_rrd_path=( - placement_validators.debug_visualize_rrd_path if placement_validators is not None else None + debug_visualize_output_path=( + placement_validators.debug_visualize_output_path if placement_validators is not None else None ), ) diff --git a/isaaclab_arena/environment_spec/arena_env_graph_types.py b/isaaclab_arena/environment_spec/arena_env_graph_types.py index 9ecd3e5d2c..9cf5882c95 100644 --- a/isaaclab_arena/environment_spec/arena_env_graph_types.py +++ b/isaaclab_arena/environment_spec/arena_env_graph_types.py @@ -315,7 +315,7 @@ class PlacementValidatorSpec(BaseModel): "never starts Isaac Sim, and it closes with the run." ), ) - debug_visualize_rrd_path: str | None = Field( + debug_visualize_output_path: str | None = Field( default=None, description=( "Path to record the debug visualization to as a Rerun .rrd file, for headless runs. Enables " diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 851de547ae..5398fc57eb 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -79,8 +79,8 @@ class ObjectPlacer: def __init__(self, params: ObjectPlacerParams | None = None): self.params = params or ObjectPlacerParams() self._solver = RelationSolver(params=self.params.solver_params) - # Populated before the validators are built so a check can add its own layer to the same view. - self.params.debug_visualizer = get_or_create_placement_visualizer(self.params) + # Created before the validators are built, since a check looks the view up when it is constructed. + self._visualizer = get_or_create_placement_visualizer(self.params) self._validators: list[PlacementValidator] = build_validators(self.params) def place( @@ -598,8 +598,9 @@ def _validate_candidates( # required_checks=None means "every enabled check is required"; an empty set means no checks. required = self.params.required_checks num_candidates = len(positions) - # Per-check candidate indices that check was actually run on; expensive checks skip candidates. - evaluated_slots_by_check: dict[str, list[int]] = {} + # Per check, which layouts of this batch it actually ran on; expensive checks skip candidates, + # and their verdicts for the skipped ones are padded False rather than measured. + evaluated_layout_indices_by_check: dict[str, list[int]] = {} layout_pass_verdicts_by_check: dict[str, list[bool]] = {} # Layouts are drawn before the checks run so a check's own layer lands on top of its candidate. candidate_indices = self._log_candidate_layouts(positions, orientations, bboxes) @@ -610,7 +611,7 @@ def _validate_candidates( bboxes, collision_objects, layout_pass_verdicts_by_check, - evaluated_slots_by_check, + evaluated_layout_indices_by_check, candidate_indices, ) self._run_expensive_checks( @@ -620,13 +621,19 @@ def _validate_candidates( collision_objects, required, layout_pass_verdicts_by_check, - evaluated_slots_by_check, + evaluated_layout_indices_by_check, candidate_indices, ) - self._log_candidate_verdicts(candidate_indices, layout_pass_verdicts_by_check, evaluated_slots_by_check) + if self._visualizer is not None and candidate_indices is not None: + self._visualizer.log_batch_verdicts( + candidate_indices, + layout_pass_verdicts_by_check, + evaluated_layout_indices_by_check, + self.params.required_checks, + ) if layout_pass_verdicts_by_check: summary = ", ".join( - f"{check}={sum(verdicts)}/{len(evaluated_slots_by_check[check])}" + f"{check}={sum(verdicts)}/{len(evaluated_layout_indices_by_check[check])}" for check, verdicts in layout_pass_verdicts_by_check.items() ) print(f"[placement] Validated {num_candidates} candidate layout(s); passed per check: {summary}") @@ -647,19 +654,19 @@ def _run_inexpensive_checks( bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], collision_objects: list[CollisionObject], layout_pass_verdicts_by_check: dict[str, list[bool]], - evaluated_slots_by_check: dict[str, list[int]], + evaluated_layout_indices_by_check: dict[str, list[int]], candidate_indices: list[int] | None, ) -> None: - """Run every inexpensive validator on all candidates, recording verdicts and evaluated slots.""" + """Run every inexpensive validator on all candidates, recording verdicts and evaluated layouts.""" num_candidates = len(positions) for validator in self._validators: if not validator.run_after_inexpensive_checks: - if self.params.debug_visualizer is not None: - self.params.debug_visualizer.set_active_candidates(candidate_indices or []) + if self._visualizer is not None: + self._visualizer.set_active_candidates(candidate_indices or []) layout_pass_verdicts_by_check[validator.check] = validator.validate_batch( positions, orientations, bboxes, collision_objects ) - evaluated_slots_by_check[validator.check] = list(range(num_candidates)) + evaluated_layout_indices_by_check[validator.check] = list(range(num_candidates)) def _run_expensive_checks( self, @@ -669,7 +676,7 @@ def _run_expensive_checks( collision_objects: list[CollisionObject], required: set[str] | None, layout_pass_verdicts_by_check: dict[str, list[bool]], - evaluated_slots_by_check: dict[str, list[int]], + evaluated_layout_indices_by_check: dict[str, list[int]], candidate_indices: list[int] | None, ) -> None: """Run each expensive validator only on candidates that passed the required inexpensive checks.""" @@ -681,10 +688,8 @@ def _run_expensive_checks( for i in range(num_candidates) if self._passes_required_checks(layout_pass_verdicts_by_check, required, i) ] - if self.params.debug_visualizer is not None and candidate_indices is not None: - self.params.debug_visualizer.set_active_candidates( - [candidate_indices[i] for i in passed_layout_indices] - ) + if self._visualizer is not None and candidate_indices is not None: + self._visualizer.set_active_candidates([candidate_indices[i] for i in passed_layout_indices]) # only passed layouts are validated verdicts_over_passed_layout = validator.validate_batch( [positions[i] for i in passed_layout_indices], @@ -696,7 +701,7 @@ def _run_expensive_checks( for sub_idx, cand_idx in enumerate(passed_layout_indices): verdicts[cand_idx] = verdicts_over_passed_layout[sub_idx] layout_pass_verdicts_by_check[validator.check] = verdicts - evaluated_slots_by_check[validator.check] = passed_layout_indices + evaluated_layout_indices_by_check[validator.check] = passed_layout_indices def _log_candidate_layouts( self, @@ -708,40 +713,20 @@ def _log_candidate_layouts( None when the debug view is off, which is the default. """ - visualizer = self.params.debug_visualizer + visualizer = self._visualizer if visualizer is None: return None candidate_indices = visualizer.next_batch_indices(len(positions)) - for slot, candidate_index in enumerate(candidate_indices): - anchors = set(get_anchor_objects(list(positions[slot]))) - visualizer.log_layout(candidate_index, positions[slot], orientations[slot], bboxes[slot], anchors) - return candidate_indices - - def _log_candidate_verdicts( - self, - candidate_indices: list[int] | None, - layout_pass_verdicts_by_check: dict[str, list[bool]], - evaluated_slots_by_check: dict[str, list[int]], - ) -> None: - """Annotate each drawn candidate with the checks that accepted or rejected it. - - A check that skipped a candidate is left off it, so the view never shows an expensive check - rejecting a layout it was never run on. - """ - visualizer = self.params.debug_visualizer - if visualizer is None or candidate_indices is None: - return - evaluated_slots = {check: set(slots) for check, slots in evaluated_slots_by_check.items()} - for slot, candidate_index in enumerate(candidate_indices): - visualizer.log_verdicts( + for layout_index, candidate_index in enumerate(candidate_indices): + anchors = set(get_anchor_objects(list(positions[layout_index]))) + visualizer.log_layout( candidate_index, - { - check: verdicts[slot] - for check, verdicts in layout_pass_verdicts_by_check.items() - if slot in evaluated_slots[check] - }, - self.params.required_checks, + positions[layout_index], + orientations[layout_index], + bboxes[layout_index], + anchors, ) + return candidate_indices @staticmethod def _passes_required_checks( diff --git a/isaaclab_arena/relations/object_placer_params.py b/isaaclab_arena/relations/object_placer_params.py index 4bafc84a1f..a9e57cfe26 100644 --- a/isaaclab_arena/relations/object_placer_params.py +++ b/isaaclab_arena/relations/object_placer_params.py @@ -12,7 +12,6 @@ if TYPE_CHECKING: from isaaclab_arena.embodiments.embodiment_base import EmbodimentBase - from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer @dataclass @@ -94,12 +93,7 @@ class ObjectPlacerParams: closed when the run exits. Checks that can say more about a candidate add their own layer -- the cuRobo reachability check draws the grasps it solved and the robot's collision spheres.""" - debug_visualize_rrd_path: str | None = None + debug_visualize_output_path: str | None = None """Path to record the debug visualization to as a Rerun ``.rrd`` file, for headless runs. Enables the visualization on its own; combine with ``debug_visualize`` to both record and watch live.""" - - debug_visualizer: PlacementRerunVisualizer | None = None - """The live Rerun view the debug fields above ask for; populated by ObjectPlacer, not by callers. - - Carried here so validators, which only receive these params, can add their own layer to it.""" diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index 07db51d5a7..2ae3f645a1 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -5,26 +5,16 @@ """Rerun debug view of build-time placement validation, sim-free (no SimApp). -Rerun's viewer is a separate process fed by the logging SDK, so nothing here touches Isaac Sim -- the -window comes up while layouts are being solved, before any simulation exists. +Draws each candidate layout as one frame of the ``candidate`` timeline: the solved object boxes, and +the verdict of every check that ran on it. A check may add its own layer under ``world/robot``. -Turn it on from an env graph YAML (worked example: -``isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml``):: +Turn it on with ``ObjectPlacerParams.debug_visualize`` for a viewer window, or +``debug_visualize_output_path`` for an ``.rrd`` recording; either one alone is enough. From an env +graph YAML (worked example: ``isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml``):: placement_validators: - debug_visualize: true # spawn a viewer window; needs a reachable display - debug_visualize_rrd_path: /tmp/placement.rrd # and/or record, for headless runs - -or in Python with ``ObjectPlacerParams(debug_visualize=True)``. Either field alone enables the view. -Shipped envs leave it off, since it spawns a window on every build; enable it while debugging a scene -whose layouts look wrong, then take it back out. - -Every candidate layout is one frame of the ``candidate`` timeline, so scrubbing it shows what was -solved and which checks rejected it. Checks that know more about a candidate than its boxes add their -own layer under ``world/robot`` (see the cuRobo reachability check). - -The spawned window belongs to the run: it comes up during placement, stays up for the rest of the -run, and dies with the process that spawned it. Record to an ``.rrd`` to inspect layouts afterwards. + debug_visualize: true + debug_visualize_output_path: /tmp/placement.rrd """ from __future__ import annotations @@ -48,8 +38,7 @@ """Entity path of the candidate's object boxes.""" ROBOT_ENTITY = "world/robot" -"""Entity path reserved for check-specific robot layers; cleared per candidate so a check that skips a -candidate does not leave its previous frame's geometry on screen.""" +"""Entity path for check-specific robot layers; cleared on every candidate, so nothing carries over.""" ANCHOR_COLOR = (140, 140, 150) """Color of the layout's anchors, which are fixed and only act as obstacles.""" @@ -76,26 +65,31 @@ """How long to wait between connection attempts while the viewer window starts.""" _ACTIVE_VISUALIZER: PlacementRerunVisualizer | None = None -"""The process's live view. Placement builds several placers (pool, per-reset solves) that would -otherwise each reset Rerun's global recording and fight over the same viewer port and ``.rrd``.""" +"""The process's live view, shared by every placer: the recording, the viewer port and the output path +are all process-wide, so a second view would fight the first over them.""" def get_or_create_placement_visualizer(params: ObjectPlacerParams) -> PlacementRerunVisualizer | None: """Return the process's Rerun view of placement validation, or None when the params ask for none. Args: - params: Placement parameters carrying the ``debug_visualize`` / ``debug_visualize_rrd_path`` fields. + params: Placement parameters carrying the ``debug_visualize`` / ``debug_visualize_output_path`` fields. """ global _ACTIVE_VISUALIZER - if not params.debug_visualize and params.debug_visualize_rrd_path is None: + if not params.debug_visualize and params.debug_visualize_output_path is None: return None if _ACTIVE_VISUALIZER is None: _ACTIVE_VISUALIZER = PlacementRerunVisualizer( - spawn=params.debug_visualize, rrd_path=params.debug_visualize_rrd_path + spawn=params.debug_visualize, output_path=params.debug_visualize_output_path ) return _ACTIVE_VISUALIZER +def get_active_placement_visualizer() -> PlacementRerunVisualizer | None: + """Return the view created by ``get_or_create_placement_visualizer``, or None if there is none.""" + return _ACTIVE_VISUALIZER + + def find_rerun_viewer_executable() -> str | None: """Return the path of the Rerun viewer binary shipped with ``rerun-sdk``, or None if absent. @@ -193,13 +187,13 @@ def summarize_candidate_verdict( class PlacementRerunVisualizer: """Streams every validated candidate layout to Rerun, one frame per candidate.""" - def __init__(self, app_id: str = "arena_placement", spawn: bool = True, rrd_path: str | None = None) -> None: + def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_path: str | None = None) -> None: """Start the recording and, unless recording headlessly, spawn a viewer window. Args: app_id: Rerun application id, shown in the viewer title. spawn: Whether to spawn a local viewer window and stream to it. - rrd_path: Optional path to also record the stream to, for replay on another machine. + output_path: Optional path to also record the stream to, for replay on another machine. """ import rerun as rr @@ -209,10 +203,10 @@ def __init__(self, app_id: str = "arena_placement", spawn: bool = True, rrd_path if spawn: self._viewer_process, viewer_sink = spawn_viewer_process() sinks.append(viewer_sink) - if rrd_path is not None: - Path(rrd_path).parent.mkdir(parents=True, exist_ok=True) - sinks.append(rr.FileSink(rrd_path)) - assert sinks, "PlacementRerunVisualizer needs a viewer to spawn or an .rrd path to record to." + if output_path is not None: + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + sinks.append(rr.FileSink(output_path)) + assert sinks, "PlacementRerunVisualizer needs a viewer to spawn or an output path to record to." rr.set_sinks(*sinks) rr.log("world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True) self._next_candidate_index = 0 @@ -246,16 +240,12 @@ def next_batch_indices(self, num_candidates: int) -> list[int]: return list(range(start, self._next_candidate_index)) def set_active_candidates(self, candidate_indices: list[int]) -> None: - """Declare which candidates the validator about to run will see, in the order it sees them. - - Expensive checks only run on the candidates that passed the cheap ones, so their batch position - is not the candidate number; this is what lets them log against the right frame. - """ + """Set the batch ``candidate_index_for_layout`` resolves against, in the order the check sees it.""" self._active_candidate_indices = list(candidate_indices) - def candidate_index_for_slot(self, slot: int) -> int: - """Timeline index of the ``slot``-th candidate in the batch the running validator was given.""" - return self._active_candidate_indices[slot] + def candidate_index_for_layout(self, layout_index: int) -> int: + """Timeline index of the layout at ``layout_index`` of the active batch.""" + return self._active_candidate_indices[layout_index] def set_time(self, candidate_index: int) -> None: """Point the recording at one candidate's frame, so subsequent logs land on it.""" @@ -336,6 +326,35 @@ def log_verdicts( for check, passed in verdicts_by_check.items(): rr.log(f"checks/{check}", rr.Scalars(float(passed))) + def log_batch_verdicts( + self, + candidate_indices: list[int], + verdicts_by_check: dict[str, list[bool]], + evaluated_layout_indices_by_check: dict[str, list[int]], + required_checks: set[str] | None, + ) -> None: + """Log the verdicts of one validated batch, one candidate at a time. + + A check is left off the candidates it did not run on, so a skipped layout is not drawn as failed. + + Args: + candidate_indices: Timeline index of each layout of the batch, in batch order. + verdicts_by_check: Per check, its verdict for every layout of the batch. + evaluated_layout_indices_by_check: Per check, which layouts of the batch it ran on. + required_checks: Checks that gate acceptance; None means every check that ran gates it. + """ + evaluated = {check: set(indices) for check, indices in evaluated_layout_indices_by_check.items()} + for layout_index, candidate_index in enumerate(candidate_indices): + self.log_verdicts( + candidate_index, + { + check: verdicts[layout_index] + for check, verdicts in verdicts_by_check.items() + if layout_index in evaluated[check] + }, + required_checks, + ) + def close(self) -> None: """Flush pending data and shut down the viewer window this run spawned. Idempotent. diff --git a/isaaclab_arena/tests/test_arena_env_graph_spec.py b/isaaclab_arena/tests/test_arena_env_graph_spec.py index 23511eeda1..1a027814b5 100644 --- a/isaaclab_arena/tests/test_arena_env_graph_spec.py +++ b/isaaclab_arena/tests/test_arena_env_graph_spec.py @@ -472,7 +472,7 @@ def test_graph_spec_leaves_placement_debug_view_off_by_default(): params = build_checks_for_placer_params(ArenaEnvGraphSpec.from_yaml(_GRAPH)) assert not params.debug_visualize - assert params.debug_visualize_rrd_path is None + assert params.debug_visualize_output_path is None def test_graph_spec_forwards_placement_debug_view_to_placer_params(): @@ -482,7 +482,7 @@ def test_graph_spec_forwards_placement_debug_view_to_placer_params(): params = build_checks_for_placer_params(ArenaEnvGraphSpec.from_yaml(_DEBUG_VIEW_GRAPH)) assert params.debug_visualize - assert params.debug_visualize_rrd_path == "/tmp/placement_debug_view.rrd" + assert params.debug_visualize_output_path == "/tmp/placement_debug_view.rrd" def test_graph_spec_leaves_shipped_envs_out_of_the_debug_view(): diff --git a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml index 8a8f27c681..113b6a7d13 100644 --- a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml +++ b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml @@ -18,7 +18,7 @@ placement_validators: enabled_checks: [no_overlap, on_relation, ik_reachability] required_checks: [no_overlap, on_relation] debug_visualize: true - debug_visualize_rrd_path: /tmp/placement_debug_view.rrd + debug_visualize_output_path: /tmp/placement_debug_view.rrd relations: - kind: is_anchor subject: maple_table_robolab diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index 00c9d16840..8a27b27c6a 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -18,7 +18,11 @@ from isaaclab_arena.relations import placement_visualizer from isaaclab_arena.relations.object_placer import ObjectPlacer from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams -from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer, summarize_candidate_verdict +from isaaclab_arena.relations.placement_visualizer import ( + PlacementRerunVisualizer, + get_active_placement_visualizer, + summarize_candidate_verdict, +) from isaaclab_arena.relations.relation_solver_params import RelationSolverParams from isaaclab_arena.relations.relations import IsAnchor, On from isaaclab_arena.tests.dummy_object import DummyObject @@ -64,18 +68,18 @@ def _placer_params(**overrides) -> ObjectPlacerParams: def test_placement_has_no_debug_view_by_default(): """The debug view is opt-in, so a default placement never touches Rerun.""" - placer = ObjectPlacer(_placer_params()) + ObjectPlacer(_placer_params()) - assert placer.params.debug_visualizer is None + assert get_active_placement_visualizer() is None def test_placement_records_every_candidate_layout(tmp_path): """Recording to an .rrd draws every candidate the solve produced, one frame each, without a viewer.""" rrd_path = tmp_path / "placement.rrd" - placer = ObjectPlacer(_placer_params(debug_visualize_rrd_path=str(rrd_path))) + placer = ObjectPlacer(_placer_params(debug_visualize_output_path=str(rrd_path))) placer.place(_desk_and_box(), num_envs=1) - visualizer = placer.params.debug_visualizer + visualizer = get_active_placement_visualizer() visualizer.close() assert visualizer.num_logged_candidates == MAX_PLACEMENT_ATTEMPTS @@ -84,12 +88,12 @@ def test_placement_records_every_candidate_layout(tmp_path): def test_placement_shares_one_debug_view_across_placers(tmp_path): """Every placer in the process draws into the same view, keeping one viewer and one timeline.""" - params = _placer_params(debug_visualize_rrd_path=str(tmp_path / "placement.rrd")) + params = _placer_params(debug_visualize_output_path=str(tmp_path / "placement.rrd")) first = ObjectPlacer(params) - second = ObjectPlacer(_placer_params(debug_visualize_rrd_path=str(tmp_path / "ignored.rrd"))) + second = ObjectPlacer(_placer_params(debug_visualize_output_path=str(tmp_path / "ignored.rrd"))) - assert first.params.debug_visualizer is second.params.debug_visualizer + assert first._visualizer is second._visualizer is get_active_placement_visualizer() class _FakeViewerProcess: @@ -116,20 +120,6 @@ def wait(self, timeout: float | None = None) -> int: return 0 -class _RecordingVisualizer: - """Captures what the placer draws, so its verdict bookkeeping can be asserted without Rerun.""" - - def __init__(self) -> None: - self.verdicts_by_candidate: dict[int, dict[str, bool]] = {} - self.required_checks: set[str] | None = None - - def log_verdicts( - self, candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None - ) -> None: - self.verdicts_by_candidate[candidate_index] = verdicts_by_check - self.required_checks = required_checks - - def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch): """The window belongs to the run, so closing the view takes it down instead of leaving it on the port.""" import rerun as rr @@ -196,19 +186,26 @@ def test_every_check_gates_a_candidate_when_none_are_named_required(): assert message == "candidate 3: rejected (failed: ik_reachable)" -def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(): +def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(tmp_path, monkeypatch): """Expensive checks only see candidates the cheap ones passed; the rest are unevaluated, not failed.""" - placer = ObjectPlacer(_placer_params()) - visualizer = _RecordingVisualizer() - placer.params.debug_visualizer = visualizer + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) + drawn_verdicts: dict[int, dict[str, bool]] = {} + monkeypatch.setattr( + visualizer, + "log_verdicts", + lambda candidate_index, verdicts_by_check, required_checks: drawn_verdicts.update( + {candidate_index: verdicts_by_check} + ), + ) - placer._log_candidate_verdicts( + visualizer.log_batch_verdicts( candidate_indices=[0, 1], - layout_pass_verdicts_by_check={"no_overlap": [True, False], "ik_reachable": [True, False]}, - evaluated_slots_by_check={"no_overlap": [0, 1], "ik_reachable": [0]}, + verdicts_by_check={"no_overlap": [True, False], "ik_reachable": [True, False]}, + evaluated_layout_indices_by_check={"no_overlap": [0, 1], "ik_reachable": [0]}, + required_checks=None, ) - assert visualizer.verdicts_by_candidate == { + assert drawn_verdicts == { 0: {"no_overlap": True, "ik_reachable": True}, 1: {"no_overlap": False}, } @@ -228,5 +225,5 @@ def test_active_candidates_map_batch_position_to_frame(tmp_path): visualizer.set_active_candidates([1, 4]) - assert visualizer.candidate_index_for_slot(0) == 1 - assert visualizer.candidate_index_for_slot(1) == 4 + assert visualizer.candidate_index_for_layout(0) == 1 + assert visualizer.candidate_index_for_layout(1) == 4 diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index 3ba7aa0eaa..f536ddf26b 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -8,11 +8,8 @@ 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. -To see why a layout was called unreachable, turn on the placement debug view -- ``debug_visualize: true`` under -``placement_validators`` in the env graph YAML, or ``ObjectPlacerParams(debug_visualize=True)``. This check then adds its -own layer to it (the robot base, the grasps it solved, reachable/unreachable per target, IK error plots); see -``isaaclab_arena_curobo.reachability_visualizer``. Nothing is drawn without a registered cuRobo config for the -embodiment, since the check delists itself entirely in that case. +With the placement debug view on (``ObjectPlacerParams.debug_visualize``), the check also draws what it solved for each +candidate -- the robot base, the grasps, and their IK errors; see ``isaaclab_arena_curobo.reachability_visualizer``. """ from __future__ import annotations @@ -24,6 +21,7 @@ from isaaclab_arena.relations.placement_validation import PlacementCheck from isaaclab_arena.relations.placement_validator_registry import register_validator from isaaclab_arena.relations.placement_validators import PlacementValidator +from isaaclab_arena.relations.placement_visualizer import get_active_placement_visualizer from isaaclab_arena.relations.relations import RequiresReachability, get_anchor_objects from isaaclab_arena.utils.pose import Pose from isaaclab_arena.utils.yaw import rotate_quat_by_yaw, yaw_from_quat_xyzw @@ -85,17 +83,18 @@ def __init__(self, params: ObjectPlacerParams) -> None: self._base_quat_xyzw = base_pose.rotation_xyzw # Guards the zero-target warning so it fires once per validator, not once per candidate layout. self._warned_no_targets = False - self._visualizer = params.debug_visualizer - self._rerun_layer = self._make_rerun_layer(params) + self._visualizer = get_active_placement_visualizer() + self._rerun_layer = self._make_rerun_layer() @staticmethod - def _make_rerun_layer(params: ObjectPlacerParams) -> ReachabilityRerunLayer | None: + def _make_rerun_layer() -> ReachabilityRerunLayer | None: """Return this check's layer of the placement debug view, or None when that view is off.""" - if params.debug_visualizer is None: + visualizer = get_active_placement_visualizer() + if visualizer is None: return None from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer - return ReachabilityRerunLayer(params.debug_visualizer) + return ReachabilityRerunLayer(visualizer) @classmethod def is_available(cls, params: ObjectPlacerParams) -> bool: @@ -117,19 +116,24 @@ def validate_batch( bboxes: list[dict[ObjectBase, AxisAlignedBoundingBox]], collision_objects: list[CollisionObject], ) -> list[bool]: - return [self._validate(positions[i], orientations[i], batch_slot=i) for i in range(len(positions))] + return [self._validate(positions[i], orientations[i], layout_index=i) for i in range(len(positions))] def _validate( self, positions: dict[ObjectBase, tuple[float, float, float]], orientations: dict[ObjectBase, float], - batch_slot: int, + layout_index: int, ) -> bool: """Whether the robot can reach a top-down grasp at the target objects in one candidate layout. Rebuilds each object's world pose and a per-object collision cuboid, syncs them into the solver's world, then batches a single IK solve over the target objects' top-down grasps. A layout with nothing to grasp (anchor-only, or no target present) is trivially reachable. + + Args: + positions: Solved (x, y, z) per object. + orientations: Absolute world Z-yaw per object. + layout_index: Position of this layout in the batch given to ``validate_batch``. """ objects = list(positions.keys()) anchors = set(get_anchor_objects(objects)) @@ -176,7 +180,7 @@ def _validate( ) if self._rerun_layer is not None: self._rerun_layer.log_candidate( - candidate_index=self._visualizer.candidate_index_for_slot(batch_slot), + candidate_index=self._visualizer.candidate_index_for_layout(layout_index), base_pos=self._base_pos, base_quat_xyzw=self._base_quat_xyzw, target_names=[obj.name for obj in targets], diff --git a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py index 4c69475f36..a53bddd4b2 100644 --- a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py +++ b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py @@ -178,17 +178,20 @@ def _make_unstamped_desk_box_pool(num_envs: int = 1, min_layouts_per_env: int = ) -def _make_reachability_validator(embodiment, visualizer=None): +def _make_reachability_validator(embodiment, monkeypatch=None, visualizer=None): """Construct the registered ReachabilityValidator with ``embodiment`` set on its params. - ``visualizer`` stands in for the placement debug view ObjectPlacer would have populated. + ``visualizer`` stands in for the placement debug view ObjectPlacer would have created, which the + check looks up in the visualizer module instead of receiving through its params. """ + from isaaclab_arena.relations import placement_visualizer as placement_visualizer_module from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena_curobo.ik_reachability_validator import ReachabilityValidator + if monkeypatch is not None: + monkeypatch.setattr(placement_visualizer_module, "_ACTIVE_VISUALIZER", visualizer) params = ObjectPlacerParams() params.reachability_config.embodiment = embodiment - params.debug_visualizer = visualizer return ReachabilityValidator(params) @@ -196,7 +199,7 @@ def _make_reachability_validator(embodiment, visualizer=None): def test_validator_skips_visualization_by_default(monkeypatch): """The debug view is opt-in: placement without one means the check adds no layer.""" _patch_curobo(monkeypatch, feasible_fn=lambda n: [True] * n) - validator = _make_reachability_validator(_fake_embodiment()) + validator = _make_reachability_validator(_fake_embodiment(), monkeypatch) assert validator._rerun_layer is None @@ -210,8 +213,8 @@ def test_validator_draws_each_candidate_on_its_own_frame(monkeypatch): _patch_curobo(monkeypatch, feasible_fn=lambda n: [False] * n) visualizer = MagicMock() # The batch the check is given is candidates 7 and 9 of the run. - visualizer.candidate_index_for_slot.side_effect = [7, 9] - validator = _make_reachability_validator(_fake_embodiment(), visualizer=visualizer) + visualizer.candidate_index_for_layout.side_effect = [7, 9] + validator = _make_reachability_validator(_fake_embodiment(), monkeypatch, visualizer=visualizer) drawn: list[dict] = [] monkeypatch.setattr(validator._rerun_layer, "log_candidate", lambda **kwargs: drawn.append(kwargs)) From b60882d15a1d7684026f26a5f04e7cd42b9229ce Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Mon, 3 Aug 2026 15:04:10 -0700 Subject: [PATCH 12/17] refactor --- isaaclab_arena/relations/object_placer.py | 61 +++----- .../relations/object_placer_params.py | 11 +- .../relations/placement_visualizer.py | 147 ++++++++++-------- .../tests/test_placement_visualizer.py | 48 +++--- .../ik_reachability_validator.py | 13 +- .../reachability_visualizer.py | 23 ++- .../tests/test_ik_reachability_validator.py | 12 +- 7 files changed, 154 insertions(+), 161 deletions(-) diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 5398fc57eb..7ce644cedf 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -598,12 +598,14 @@ def _validate_candidates( # required_checks=None means "every enabled check is required"; an empty set means no checks. required = self.params.required_checks num_candidates = len(positions) - # Per check, which layouts of this batch it actually ran on; expensive checks skip candidates, - # and their verdicts for the skipped ones are padded False rather than measured. + # Per check, which layouts of this batch (each refill) it actually ran on evaluated_layout_indices_by_check: dict[str, list[int]] = {} layout_pass_verdicts_by_check: dict[str, list[bool]] = {} - # Layouts are drawn before the checks run so a check's own layer lands on top of its candidate. - candidate_indices = self._log_candidate_layouts(positions, orientations, bboxes) + + # Layouts are drawn before the checks run so a check's own layer lands on top of its layout. + layout_indices_across_batch: list[int] | None = None + if self._visualizer is not None: + layout_indices_across_batch = self._visualizer.log_layout_batch(positions, orientations, bboxes) self._run_inexpensive_checks( positions, @@ -612,7 +614,7 @@ def _validate_candidates( collision_objects, layout_pass_verdicts_by_check, evaluated_layout_indices_by_check, - candidate_indices, + layout_indices_across_batch, ) self._run_expensive_checks( positions, @@ -622,11 +624,11 @@ def _validate_candidates( required, layout_pass_verdicts_by_check, evaluated_layout_indices_by_check, - candidate_indices, + layout_indices_across_batch, ) - if self._visualizer is not None and candidate_indices is not None: - self._visualizer.log_batch_verdicts( - candidate_indices, + if self._visualizer is not None and layout_indices_across_batch is not None: + self._visualizer.log_layout_batch_verdicts( + layout_indices_across_batch, layout_pass_verdicts_by_check, evaluated_layout_indices_by_check, self.params.required_checks, @@ -655,14 +657,14 @@ def _run_inexpensive_checks( collision_objects: list[CollisionObject], layout_pass_verdicts_by_check: dict[str, list[bool]], evaluated_layout_indices_by_check: dict[str, list[int]], - candidate_indices: list[int] | None, + layout_indices_across_batch: list[int] | None, ) -> None: """Run every inexpensive validator on all candidates, recording verdicts and evaluated layouts.""" num_candidates = len(positions) for validator in self._validators: if not validator.run_after_inexpensive_checks: if self._visualizer is not None: - self._visualizer.set_active_candidates(candidate_indices or []) + self._visualizer.set_active_layout_indices_across_batch(layout_indices_across_batch or []) layout_pass_verdicts_by_check[validator.check] = validator.validate_batch( positions, orientations, bboxes, collision_objects ) @@ -677,7 +679,7 @@ def _run_expensive_checks( required: set[str] | None, layout_pass_verdicts_by_check: dict[str, list[bool]], evaluated_layout_indices_by_check: dict[str, list[int]], - candidate_indices: list[int] | None, + layout_indices_across_batch: list[int] | None, ) -> None: """Run each expensive validator only on candidates that passed the required inexpensive checks.""" num_candidates = len(positions) @@ -688,8 +690,10 @@ def _run_expensive_checks( for i in range(num_candidates) if self._passes_required_checks(layout_pass_verdicts_by_check, required, i) ] - if self._visualizer is not None and candidate_indices is not None: - self._visualizer.set_active_candidates([candidate_indices[i] for i in passed_layout_indices]) + if self._visualizer is not None and layout_indices_across_batch is not None: + self._visualizer.set_active_layout_indices_across_batch( + [layout_indices_across_batch[i] for i in passed_layout_indices] + ) # only passed layouts are validated verdicts_over_passed_layout = validator.validate_batch( [positions[i] for i in passed_layout_indices], @@ -698,36 +702,11 @@ def _run_expensive_checks( collision_objects, ) verdicts = [False] * num_candidates - for sub_idx, cand_idx in enumerate(passed_layout_indices): - verdicts[cand_idx] = verdicts_over_passed_layout[sub_idx] + for layout_index_within_batch, verdict in zip(passed_layout_indices, verdicts_over_passed_layout): + verdicts[layout_index_within_batch] = verdict layout_pass_verdicts_by_check[validator.check] = verdicts evaluated_layout_indices_by_check[validator.check] = passed_layout_indices - def _log_candidate_layouts( - self, - positions: list[dict[PlaceableAsset, tuple[float, float, float]]], - orientations: list[dict[PlaceableAsset, float]], - bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], - ) -> list[int] | None: - """Draw every candidate of this batch in the Rerun debug view, returning their timeline indices. - - None when the debug view is off, which is the default. - """ - visualizer = self._visualizer - if visualizer is None: - return None - candidate_indices = visualizer.next_batch_indices(len(positions)) - for layout_index, candidate_index in enumerate(candidate_indices): - anchors = set(get_anchor_objects(list(positions[layout_index]))) - visualizer.log_layout( - candidate_index, - positions[layout_index], - orientations[layout_index], - bboxes[layout_index], - anchors, - ) - return candidate_indices - @staticmethod def _passes_required_checks( layout_pass_verdicts_by_check: dict[str, list[bool]], diff --git a/isaaclab_arena/relations/object_placer_params.py b/isaaclab_arena/relations/object_placer_params.py index a9e57cfe26..0d0945d486 100644 --- a/isaaclab_arena/relations/object_placer_params.py +++ b/isaaclab_arena/relations/object_placer_params.py @@ -86,14 +86,7 @@ class ObjectPlacerParams: """Tuning for the optional ``ik_reachable`` build-time check. See ReachabilityConfig for more details.""" debug_visualize: bool = False - """If True, stream every validated candidate layout to a spawned Rerun viewer window. - - Debug aid, off by default. Needs the ``rerun-sdk`` package and a reachable display (the container - forwards ``DISPLAY``); the viewer is its own process, so this never starts Isaac Sim, and it is - closed when the run exits. Checks that can say more about a candidate add their own layer -- the - cuRobo reachability check draws the grasps it solved and the robot's collision spheres.""" + """If True, stream every validated candidate layout to a spawned Rerun viewer window. Off by default.""" debug_visualize_output_path: str | None = None - """Path to record the debug visualization to as a Rerun ``.rrd`` file, for headless runs. - - Enables the visualization on its own; combine with ``debug_visualize`` to both record and watch live.""" + """Path to record the debug visualization to as a Rerun ``.rrd`` file, for headless runs.""" diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index 2ae3f645a1..228f46f85c 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -5,9 +5,6 @@ """Rerun debug view of build-time placement validation, sim-free (no SimApp). -Draws each candidate layout as one frame of the ``candidate`` timeline: the solved object boxes, and -the verdict of every check that ran on it. A check may add its own layer under ``world/robot``. - Turn it on with ``ObjectPlacerParams.debug_visualize`` for a viewer window, or ``debug_visualize_output_path`` for an ``.rrd`` recording; either one alone is enough. From an env graph YAML (worked example: ``isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml``):: @@ -26,19 +23,21 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from isaaclab_arena.relations.relations import get_anchor_objects + if TYPE_CHECKING: from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena.relations.placement_asset import PlaceableAsset from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox -CANDIDATE_TIMELINE = "candidate" -"""Rerun timeline whose sequence index is the candidate layout number.""" +LAYOUT_TIMELINE = "layout" +"""Rerun timeline whose sequence index is the layout number.""" LAYOUT_ENTITY = "world/layout" -"""Entity path of the candidate's object boxes.""" +"""Entity path of the layout's object boxes.""" ROBOT_ENTITY = "world/robot" -"""Entity path for check-specific robot layers; cleared on every candidate, so nothing carries over.""" +"""Entity path for check-specific robot layers; cleared on every layout, so nothing carries over.""" ANCHOR_COLOR = (140, 140, 150) """Color of the layout's anchors, which are fixed and only act as obstacles.""" @@ -114,7 +113,7 @@ def spawn_viewer_process() -> tuple[subprocess.Popen, Any]: assert executable is not None, "rerun-sdk ships no viewer binary here; record to an .rrd instead." if _viewer_port_answers(): print( - f"WARNING: something already serves on port {VIEWER_PORT}; this run's candidate layouts will " + f"WARNING: something already serves on port {VIEWER_PORT}; this run's layouts will " "stream into that window rather than a new one." ) viewer_process = subprocess.Popen([ @@ -141,7 +140,7 @@ def _viewer_port_answers() -> bool: def _wait_until_viewer_serves(viewer_process: subprocess.Popen) -> None: - """Block until the spawned viewer answers on its port, so the first candidates are not lost. + """Block until the spawned viewer answers on its port, so the first layouts are not lost. Never fatal -- a view that fails to come up does not stop the run it was only meant to explain. """ @@ -161,31 +160,29 @@ def _wait_until_viewer_serves(viewer_process: subprocess.Popen) -> None: print(f"WARNING: the Rerun viewer did not serve within {VIEWER_STARTUP_TIMEOUT_S:.0f}s; layouts may be missing.") -def summarize_candidate_verdict( - candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None +def summarize_layout_verdict( + layout_index_across_batch: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None ) -> tuple[str, bool]: - """Describe how placement judged one candidate, as ``(message, accepted)``. - - Acceptance follows the placer: only required checks gate a layout, so a candidate that failed - nothing else is accepted and the failure is reported as advisory rather than as a rejection. + """Describe how placement judged one layout, as ``(message, accepted)``. Args: - candidate_index: Timeline index of the candidate, used in the message. - verdicts_by_check: Verdict per check that ran on this candidate. + layout_index_across_batch: Timeline index of the layout, used in the message. + verdicts_by_check: Verdict per check that ran on this layout. required_checks: Checks that gate acceptance; None means every check that ran gates it. """ failed = [check for check, passed in verdicts_by_check.items() if not passed] blocking = [check for check in failed if required_checks is None or check in required_checks] advisory = [check for check in failed if check not in blocking] + layout = f"layout {layout_index_across_batch}" if blocking: - return f"candidate {candidate_index}: rejected (failed: {', '.join(blocking)})", False + return f"{layout}: rejected (failed: {', '.join(blocking)})", False if advisory: - return f"candidate {candidate_index}: accepted (failed but not required: {', '.join(advisory)})", True - return f"candidate {candidate_index}: accepted", True + return f"{layout}: accepted (failed but not required: {', '.join(advisory)})", True + return f"{layout}: accepted", True class PlacementRerunVisualizer: - """Streams every validated candidate layout to Rerun, one frame per candidate.""" + """Streams every validated layout to Rerun, one frame per layout.""" def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_path: str | None = None) -> None: """Start the recording and, unless recording headlessly, spawn a viewer window. @@ -209,14 +206,14 @@ def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_p assert sinks, "PlacementRerunVisualizer needs a viewer to spawn or an output path to record to." rr.set_sinks(*sinks) rr.log("world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True) - self._next_candidate_index = 0 - self._active_candidate_indices: list[int] = [] + self._next_layout_index_across_batch = 0 + self._active_layout_indices_across_batch: list[int] = [] def __deepcopy__(self, memory_map: dict[int, object]) -> PlacementRerunVisualizer: """Return the live view for ``copy.deepcopy`` instead of duplicating it. Isaac Lab's configclass deep-copies the placement event params that carry the pool, and a - duplicated view would keep its own candidate counter and overwrite frames this one already drew. + duplicated view would keep its own layout counter and overwrite frames this one already drew. Args: memory_map: ``copy.deepcopy``'s ``id(original) -> copy`` cache. @@ -225,46 +222,46 @@ def __deepcopy__(self, memory_map: dict[int, object]) -> PlacementRerunVisualize return self @property - def num_logged_candidates(self) -> int: - """How many candidate layouts have been given a frame so far.""" - return self._next_candidate_index + def num_logged_layouts(self) -> int: + """How many layouts have been given a frame so far.""" + return self._next_layout_index_across_batch - def next_batch_indices(self, num_candidates: int) -> list[int]: - """Reserve and return one timeline index per candidate of the batch about to be validated. + def reserve_layout_indices_across_batch(self, num_layouts: int) -> list[int]: + """Reserve and return one timeline index per layout of the batch about to be validated. Indices keep counting across batches so a pool that refills several times does not overwrite its earlier frames. """ - start = self._next_candidate_index - self._next_candidate_index += num_candidates - return list(range(start, self._next_candidate_index)) + start = self._next_layout_index_across_batch + self._next_layout_index_across_batch += num_layouts + return list(range(start, self._next_layout_index_across_batch)) - def set_active_candidates(self, candidate_indices: list[int]) -> None: - """Set the batch ``candidate_index_for_layout`` resolves against, in the order the check sees it.""" - self._active_candidate_indices = list(candidate_indices) + def set_active_layout_indices_across_batch(self, layout_indices_across_batch: list[int]) -> None: + """Set the batch ``get_layout_index_across_batch`` resolves against, in the order the check sees it.""" + self._active_layout_indices_across_batch = list(layout_indices_across_batch) - def candidate_index_for_layout(self, layout_index: int) -> int: - """Timeline index of the layout at ``layout_index`` of the active batch.""" - return self._active_candidate_indices[layout_index] + def get_layout_index_across_batch(self, layout_index_within_batch: int) -> int: + """Timeline index of the layout at ``layout_index_within_batch`` of the active batch.""" + return self._active_layout_indices_across_batch[layout_index_within_batch] - def set_time(self, candidate_index: int) -> None: - """Point the recording at one candidate's frame, so subsequent logs land on it.""" + def set_time(self, layout_index_across_batch: int) -> None: + """Point the recording at one layout's frame, so subsequent logs land on it.""" import rerun as rr - rr.set_time(CANDIDATE_TIMELINE, sequence=candidate_index) + rr.set_time(LAYOUT_TIMELINE, sequence=layout_index_across_batch) def log_layout( self, - candidate_index: int, + layout_index_across_batch: int, positions: dict[PlaceableAsset, tuple[float, float, float]], orientations: dict[PlaceableAsset, float], bboxes: dict[PlaceableAsset, AxisAlignedBoundingBox], anchors: set[PlaceableAsset], ) -> None: - """Log one candidate's solved layout as boxes in the world frame. + """Log one layout's solved layout as boxes in the world frame. Args: - candidate_index: Timeline index to log against. + layout_index_across_batch: Timeline index to log against. positions: Solved (x, y, z) per object. orientations: Absolute world Z-yaw per object; objects without one are drawn unrotated. bboxes: Per-object local bounding box. @@ -272,8 +269,8 @@ def log_layout( """ import rerun as rr - self.set_time(candidate_index) - # A check that skips this candidate must not leave its previous candidate's robot on screen. + self.set_time(layout_index_across_batch) + # A check that skips this layout must not leave its previous layout's robot on screen. rr.log(ROBOT_ENTITY, rr.Clear(recursive=True)) objects = list(positions) @@ -305,20 +302,44 @@ def log_layout( ), ) - def log_verdicts( - self, candidate_index: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None + def log_layout_batch( + self, + positions: list[dict[PlaceableAsset, tuple[float, float, float]]], + orientations: list[dict[PlaceableAsset, float]], + bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], + ) -> list[int]: + """Log every solved layout of one batch, returning the timeline index each was drawn on. + + Args: + positions: Solved (x, y, z) per object, one dict per layout. + orientations: Absolute world Z-yaw per object, one dict per layout. + bboxes: Per-object local bounding box, one dict per layout. + """ + layout_indices_across_batch = self.reserve_layout_indices_across_batch(len(positions)) + for layout_index_within_batch, layout_index_across_batch in enumerate(layout_indices_across_batch): + self.log_layout( + layout_index_across_batch, + positions[layout_index_within_batch], + orientations[layout_index_within_batch], + bboxes[layout_index_within_batch], + anchors=set(get_anchor_objects(list(positions[layout_index_within_batch]))), + ) + return layout_indices_across_batch + + def log_layout_verdicts( + self, layout_index_across_batch: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None ) -> None: - """Log which checks accepted one candidate, as a text line and an accepted/rejected marker. + """Log which checks accepted one layout, as a text line and an accepted/rejected marker. Args: - candidate_index: Timeline index to log against. - verdicts_by_check: Verdict per check that ran on this candidate; one that skipped it is absent. + layout_index_across_batch: Timeline index to log against. + verdicts_by_check: Verdict per check that ran on this layout; one that skipped it is absent. required_checks: Checks that gate acceptance; None means every check that ran gates it. """ import rerun as rr - self.set_time(candidate_index) - message, accepted = summarize_candidate_verdict(candidate_index, verdicts_by_check, required_checks) + self.set_time(layout_index_across_batch) + message, accepted = summarize_layout_verdict(layout_index_across_batch, verdicts_by_check, required_checks) rr.log( f"{LAYOUT_ENTITY}/verdict", rr.TextLog(message, level=rr.TextLogLevel.INFO if accepted else rr.TextLogLevel.WARN), @@ -326,31 +347,31 @@ def log_verdicts( for check, passed in verdicts_by_check.items(): rr.log(f"checks/{check}", rr.Scalars(float(passed))) - def log_batch_verdicts( + def log_layout_batch_verdicts( self, - candidate_indices: list[int], + layout_indices_across_batch: list[int], verdicts_by_check: dict[str, list[bool]], evaluated_layout_indices_by_check: dict[str, list[int]], required_checks: set[str] | None, ) -> None: - """Log the verdicts of one validated batch, one candidate at a time. + """Log the check verdicts of one validated batch of layouts, one layout at a time. - A check is left off the candidates it did not run on, so a skipped layout is not drawn as failed. + A check is left off the layouts it did not run on, so a skipped one is not drawn as failed. Args: - candidate_indices: Timeline index of each layout of the batch, in batch order. + layout_indices_across_batch: Timeline index of each layout of the batch, in batch order. verdicts_by_check: Per check, its verdict for every layout of the batch. evaluated_layout_indices_by_check: Per check, which layouts of the batch it ran on. required_checks: Checks that gate acceptance; None means every check that ran gates it. """ evaluated = {check: set(indices) for check, indices in evaluated_layout_indices_by_check.items()} - for layout_index, candidate_index in enumerate(candidate_indices): - self.log_verdicts( - candidate_index, + for layout_index_within_batch, layout_index_across_batch in enumerate(layout_indices_across_batch): + self.log_layout_verdicts( + layout_index_across_batch, { - check: verdicts[layout_index] + check: verdicts[layout_index_within_batch] for check, verdicts in verdicts_by_check.items() - if layout_index in evaluated[check] + if layout_index_within_batch in evaluated[check] }, required_checks, ) diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index 8a27b27c6a..4a721e5622 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -21,7 +21,7 @@ from isaaclab_arena.relations.placement_visualizer import ( PlacementRerunVisualizer, get_active_placement_visualizer, - summarize_candidate_verdict, + summarize_layout_verdict, ) from isaaclab_arena.relations.relation_solver_params import RelationSolverParams from isaaclab_arena.relations.relations import IsAnchor, On @@ -82,7 +82,7 @@ def test_placement_records_every_candidate_layout(tmp_path): visualizer = get_active_placement_visualizer() visualizer.close() - assert visualizer.num_logged_candidates == MAX_PLACEMENT_ATTEMPTS + assert visualizer.num_logged_layouts == MAX_PLACEMENT_ATTEMPTS assert rrd_path.is_file() and rrd_path.stat().st_size > 0 @@ -131,7 +131,7 @@ def test_closing_the_view_shuts_down_the_viewer_it_spawned(tmp_path, monkeypatch "spawn_viewer_process", lambda: (viewer, rr.FileSink(str(tmp_path / "viewer_stand_in.rrd"))), ) - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, rrd_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, output_path=str(tmp_path / "p.rrd")) visualizer.close() visualizer.close() @@ -149,7 +149,7 @@ def test_closing_a_wedged_viewer_falls_back_to_killing_it(tmp_path, monkeypatch) "spawn_viewer_process", lambda: (viewer, rr.FileSink(str(tmp_path / "viewer_stand_in.rrd"))), ) - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, rrd_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=True, output_path=str(tmp_path / "p.rrd")) visualizer.close() @@ -160,46 +160,46 @@ def test_only_required_checks_reject_a_candidate(): """The view has to agree with the placer, which gates layouts on required_checks alone.""" verdicts = {"no_overlap": True, "ik_reachable": False} - message, accepted = summarize_candidate_verdict(3, verdicts, required_checks={"no_overlap"}) + message, accepted = summarize_layout_verdict(3, verdicts, required_checks={"no_overlap"}) assert accepted - assert message == "candidate 3: accepted (failed but not required: ik_reachable)" + assert message == "layout 3: accepted (failed but not required: ik_reachable)" def test_a_failed_required_check_rejects_a_candidate(): """A failure the placer does gate on reads as a rejection, naming what blocked it.""" verdicts = {"no_overlap": False, "ik_reachable": False} - message, accepted = summarize_candidate_verdict(3, verdicts, required_checks={"no_overlap"}) + message, accepted = summarize_layout_verdict(3, verdicts, required_checks={"no_overlap"}) assert not accepted - assert message == "candidate 3: rejected (failed: no_overlap)" + assert message == "layout 3: rejected (failed: no_overlap)" def test_every_check_gates_a_candidate_when_none_are_named_required(): """required_checks=None means every check that ran is required, matching ObjectPlacerParams.""" verdicts = {"no_overlap": True, "ik_reachable": False} - message, accepted = summarize_candidate_verdict(3, verdicts, required_checks=None) + message, accepted = summarize_layout_verdict(3, verdicts, required_checks=None) assert not accepted - assert message == "candidate 3: rejected (failed: ik_reachable)" + assert message == "layout 3: rejected (failed: ik_reachable)" def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(tmp_path, monkeypatch): """Expensive checks only see candidates the cheap ones passed; the rest are unevaluated, not failed.""" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) drawn_verdicts: dict[int, dict[str, bool]] = {} monkeypatch.setattr( visualizer, - "log_verdicts", - lambda candidate_index, verdicts_by_check, required_checks: drawn_verdicts.update( - {candidate_index: verdicts_by_check} + "log_layout_verdicts", + lambda layout_index_across_batch, verdicts_by_check, required_checks: drawn_verdicts.update( + {layout_index_across_batch: verdicts_by_check} ), ) - visualizer.log_batch_verdicts( - candidate_indices=[0, 1], + visualizer.log_layout_batch_verdicts( + layout_indices_across_batch=[0, 1], verdicts_by_check={"no_overlap": [True, False], "ik_reachable": [True, False]}, evaluated_layout_indices_by_check={"no_overlap": [0, 1], "ik_reachable": [0]}, required_checks=None, @@ -213,17 +213,17 @@ def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(tmp_path, def test_candidate_frames_keep_counting_across_batches(tmp_path): """A pool that refills gets fresh frames, so a later batch does not overwrite an earlier one.""" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) - assert visualizer.next_batch_indices(3) == [0, 1, 2] - assert visualizer.next_batch_indices(2) == [3, 4] + assert visualizer.reserve_layout_indices_across_batch(3) == [0, 1, 2] + assert visualizer.reserve_layout_indices_across_batch(2) == [3, 4] -def test_active_candidates_map_batch_position_to_frame(tmp_path): +def test_active_candidates_map_index_within_batch_to_frame(tmp_path): """A check that only ran on some candidates still resolves each one's own frame.""" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(tmp_path / "p.rrd")) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) - visualizer.set_active_candidates([1, 4]) + visualizer.set_active_layout_indices_across_batch([1, 4]) - assert visualizer.candidate_index_for_layout(0) == 1 - assert visualizer.candidate_index_for_layout(1) == 4 + assert visualizer.get_layout_index_across_batch(0) == 1 + assert visualizer.get_layout_index_across_batch(1) == 4 diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index f536ddf26b..d774bf58db 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -116,13 +116,15 @@ def validate_batch( bboxes: list[dict[ObjectBase, AxisAlignedBoundingBox]], collision_objects: list[CollisionObject], ) -> list[bool]: - return [self._validate(positions[i], orientations[i], layout_index=i) for i in range(len(positions))] + return [ + self._validate(positions[i], orientations[i], layout_index_within_batch=i) for i in range(len(positions)) + ] def _validate( self, positions: dict[ObjectBase, tuple[float, float, float]], orientations: dict[ObjectBase, float], - layout_index: int, + layout_index_within_batch: int, ) -> bool: """Whether the robot can reach a top-down grasp at the target objects in one candidate layout. @@ -133,7 +135,7 @@ def _validate( Args: positions: Solved (x, y, z) per object. orientations: Absolute world Z-yaw per object. - layout_index: Position of this layout in the batch given to ``validate_batch``. + layout_index_within_batch: Position of this layout in the batch given to ``validate_batch``. """ objects = list(positions.keys()) anchors = set(get_anchor_objects(objects)) @@ -179,8 +181,9 @@ def _validate( rotation_threshold=self._ik_rot_threshold, ) if self._rerun_layer is not None: - self._rerun_layer.log_candidate( - candidate_index=self._visualizer.candidate_index_for_layout(layout_index), + layout_index_across_batch = self._visualizer.get_layout_index_across_batch(layout_index_within_batch) + self._rerun_layer.log_layout( + layout_index_across_batch=layout_index_across_batch, base_pos=self._base_pos, base_quat_xyzw=self._base_quat_xyzw, target_names=[obj.name for obj in targets], diff --git a/isaaclab_arena_curobo/reachability_visualizer.py b/isaaclab_arena_curobo/reachability_visualizer.py index 8e43cf6bb5..bb4c4a600d 100644 --- a/isaaclab_arena_curobo/reachability_visualizer.py +++ b/isaaclab_arena_curobo/reachability_visualizer.py @@ -4,11 +4,8 @@ # SPDX-License-Identifier: Apache-2.0 """The reachability check's layer of the placement Rerun debug view, sim-free (no SimApp). - -Core placement already draws each candidate layout's boxes (see -``isaaclab_arena.relations.placement_visualizer``); this adds what only the IK check knows -- where the -robot stands, the top-down grasps it solved, and whether each one was reachable. Everything is logged -against the same candidate frame, so the two layers compose. +Adds to existing visualizer what only the IK check knows -- where the +robot stands, the top-down grasps it solved, and whether each one was reachable. """ from __future__ import annotations @@ -34,19 +31,19 @@ class ReachabilityRerunLayer: - """Draws the reachability check's verdict for a candidate into the shared placement view.""" + """Draws the reachability check's verdict for a layout into the shared placement view.""" def __init__(self, visualizer: PlacementRerunVisualizer) -> None: - """Bind the layer to the placement view it draws into. + """Bind the layer to the placement visualizer. Args: - visualizer: The process's placement debug view, which owns the recording and the timeline. + visualizer: The process's placement visualizer, which owns the recording and the timeline. """ self._visualizer = visualizer - def log_candidate( + def log_layout( self, - candidate_index: int, + layout_index_across_batch: int, base_pos: tuple[float, float, float], base_quat_xyzw: tuple[float, float, float, float], target_names: list[str], @@ -55,10 +52,10 @@ def log_candidate( position_error: torch.Tensor, rotation_error: torch.Tensor, ) -> None: - """Log the robot's side of one evaluated candidate. + """Log the robot's side of one evaluated layout. Args: - candidate_index: Timeline index of the candidate, as assigned by the placement view. + layout_index_across_batch: Timeline index of the layout, as assigned by the placement view. base_pos: Robot base position in the world frame. base_quat_xyzw: Robot base orientation in the world frame. target_names: Names of the objects a grasp was solved for, aligned with the tensors below. @@ -69,7 +66,7 @@ def log_candidate( """ import rerun as rr - self._visualizer.set_time(candidate_index) + self._visualizer.set_time(layout_index_across_batch) # Grasps are solved in the robot base frame, so they are logged as children of the base # transform and Rerun composes them back into the world frame. rr.log(BASE_ENTITY, rr.Transform3D(translation=base_pos, quaternion=rr.Quaternion(xyzw=base_quat_xyzw))) diff --git a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py index a53bddd4b2..1d9cd87610 100644 --- a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py +++ b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py @@ -213,17 +213,17 @@ def test_validator_draws_each_candidate_on_its_own_frame(monkeypatch): _patch_curobo(monkeypatch, feasible_fn=lambda n: [False] * n) visualizer = MagicMock() # The batch the check is given is candidates 7 and 9 of the run. - visualizer.candidate_index_for_layout.side_effect = [7, 9] + visualizer.get_layout_index_across_batch.side_effect = [7, 9] validator = _make_reachability_validator(_fake_embodiment(), monkeypatch, visualizer=visualizer) drawn: list[dict] = [] - monkeypatch.setattr(validator._rerun_layer, "log_candidate", lambda **kwargs: drawn.append(kwargs)) + monkeypatch.setattr(validator._rerun_layer, "log_layout", lambda **kwargs: drawn.append(kwargs)) layout = _make_desk_box_pool().layouts_per_env()[0][0] assert validator.validate_batch( [layout.positions, layout.positions], [layout.orientations, layout.orientations], [{}, {}], [] ) == [False, False] - assert [entry["candidate_index"] for entry in drawn] == [7, 9] + assert [entry["layout_index_across_batch"] for entry in drawn] == [7, 9] assert [entry["target_names"] for entry in drawn] == [["box"], ["box"]] @@ -233,11 +233,11 @@ def test_reachability_layer_records_to_rrd(tmp_path): from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer rrd_path = tmp_path / "placement.rrd" - visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, rrd_path=str(rrd_path)) + visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(rrd_path)) layer = ReachabilityRerunLayer(visualizer) - layer.log_candidate( - candidate_index=0, + layer.log_layout( + layout_index_across_batch=0, base_pos=(0.0, 0.0, 0.0), base_quat_xyzw=(0.0, 0.0, 0.0, 1.0), target_names=["box"], From b4e2a84a1e7adfab72b8304f9d0e56c891159378 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Tue, 4 Aug 2026 09:41:35 -0700 Subject: [PATCH 13/17] lint --- isaaclab_arena/relations/placement_visualizer.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index 228f46f85c..5e163ea907 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -377,11 +377,7 @@ def log_layout_batch_verdicts( ) def close(self) -> None: - """Flush pending data and shut down the viewer window this run spawned. Idempotent. - - Only needed to close the window early -- a run that just exits leaves it to the viewer's - parent-death signal. - """ + """Flush pending data and shut down the viewer window this run spawned.""" import rerun as rr # None once Rerun's own shutdown hook has torn the recording down ahead of this call. From 59735c83b61fa4285d76c17b84668628aa9e3b12 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Tue, 4 Aug 2026 09:52:10 -0700 Subject: [PATCH 14/17] refactor --- isaaclab_arena/relations/object_placer.py | 3 +-- .../relations/placement_validators.py | 16 +++++++++---- .../relations/placement_visualizer.py | 11 +++------ .../placement_debug_view_env_graph.yaml | 6 ++--- .../tests/test_placement_visualizer.py | 23 +++++++++++-------- .../ik_reachability_validator.py | 17 ++++++-------- .../tests/test_ik_reachability_validator.py | 17 ++++---------- 7 files changed, 44 insertions(+), 49 deletions(-) diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 7ce644cedf..887521b957 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -79,9 +79,8 @@ class ObjectPlacer: def __init__(self, params: ObjectPlacerParams | None = None): self.params = params or ObjectPlacerParams() self._solver = RelationSolver(params=self.params.solver_params) - # Created before the validators are built, since a check looks the view up when it is constructed. self._visualizer = get_or_create_placement_visualizer(self.params) - self._validators: list[PlacementValidator] = build_validators(self.params) + self._validators: list[PlacementValidator] = build_validators(self.params, self._visualizer) def place( self, diff --git a/isaaclab_arena/relations/placement_validators.py b/isaaclab_arena/relations/placement_validators.py index 65ee5612eb..0abe7591b3 100644 --- a/isaaclab_arena/relations/placement_validators.py +++ b/isaaclab_arena/relations/placement_validators.py @@ -29,6 +29,7 @@ from isaaclab_arena.relations.collision_object import CollisionObject from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena.relations.placement_asset import PlaceableAsset + from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer from isaaclab_arena.relations.warp_mesh_manager import WarpMeshAndSphereCache from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox @@ -48,8 +49,9 @@ class PlacementValidator(ABC): set this flag, so an expensive check (e.g. IK reachability) never runs on a layout rejected on cheaper geometry.""" - def __init__(self, params: ObjectPlacerParams) -> None: + def __init__(self, params: ObjectPlacerParams, visualizer: PlacementRerunVisualizer | None = None) -> None: self._params = params + self._visualizer = visualizer @classmethod def is_available(cls, params: ObjectPlacerParams) -> bool: @@ -83,7 +85,9 @@ def get_build_time_checks() -> tuple[str, ...]: return tuple(PlacementValidatorRegistry().get_all_keys()) -def build_validators(params: ObjectPlacerParams) -> list[PlacementValidator]: +def build_validators( + params: ObjectPlacerParams, visualizer: PlacementRerunVisualizer | None = None +) -> list[PlacementValidator]: """Construct the enabled build-time validators in registration order. A registered check whose is_available() returns False is delisted; a check named in @@ -92,6 +96,8 @@ def build_validators(params: ObjectPlacerParams) -> list[PlacementValidator]: Args: params: Placement params injected into each registered validator. + visualizer: The caller's debug view, injected into each validator so a check can draw its + own visualization layer on it; None when the run has no view. """ registry = PlacementValidatorRegistry() registered_checks = get_build_time_checks() @@ -104,7 +110,7 @@ def build_validators(params: ObjectPlacerParams) -> list[PlacementValidator]: for check in registered_checks: validator_cls = registry.get_validator_by_name(check) if validator_cls.is_available(params): - validators.append(validator_cls(params)) + validators.append(validator_cls(params, visualizer)) return validators @@ -355,8 +361,8 @@ class NoOverlapValidator(PlacementValidator): check = PlacementCheck.NO_OVERLAP - def __init__(self, params: ObjectPlacerParams) -> None: - super().__init__(params) + def __init__(self, params: ObjectPlacerParams, visualizer: PlacementRerunVisualizer | None = None) -> None: + super().__init__(params, visualizer) self._cpu_mesh_manager: WarpMeshAndSphereCache | None = None def validate_batch( diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index 5e163ea907..54813bce46 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -64,16 +64,16 @@ """How long to wait between connection attempts while the viewer window starts.""" _ACTIVE_VISUALIZER: PlacementRerunVisualizer | None = None -"""The process's live view, shared by every placer: the recording, the viewer port and the output path -are all process-wide, so a second view would fight the first over them.""" +"""The process's live view tied to a placer: the recording, the viewer port and the output path.""" def get_or_create_placement_visualizer(params: ObjectPlacerParams) -> PlacementRerunVisualizer | None: - """Return the process's Rerun view of placement validation, or None when the params ask for none. + """Return the process's Rerun view of placement validation, or None when no one asks for it. Args: params: Placement parameters carrying the ``debug_visualize`` / ``debug_visualize_output_path`` fields. """ + # Global to avoid creating multiple visualizers for the same process. global _ACTIVE_VISUALIZER if not params.debug_visualize and params.debug_visualize_output_path is None: return None @@ -84,11 +84,6 @@ def get_or_create_placement_visualizer(params: ObjectPlacerParams) -> PlacementR return _ACTIVE_VISUALIZER -def get_active_placement_visualizer() -> PlacementRerunVisualizer | None: - """Return the view created by ``get_or_create_placement_visualizer``, or None if there is none.""" - return _ACTIVE_VISUALIZER - - def find_rerun_viewer_executable() -> str | None: """Return the path of the Rerun viewer binary shipped with ``rerun-sdk``, or None if absent. diff --git a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml index 113b6a7d13..786dd92b29 100644 --- a/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml +++ b/isaaclab_arena/tests/test_data/placement_debug_view_env_graph.yaml @@ -4,8 +4,8 @@ # SPDX-License-Identifier: Apache-2.0 env_name: llm_gen_maple_table_robolab_PickAndPlaceTask embodiment: - id: franka_ik - registry_name: franka_ik + id: droid + registry_name: droid_abs_joint_pos background: id: maple_table_robolab registry_name: maple_table_robolab @@ -15,7 +15,7 @@ objects: - id: bowl_ycb_robolab registry_name: bowl_ycb_robolab placement_validators: - enabled_checks: [no_overlap, on_relation, ik_reachability] + enabled_checks: [no_overlap, on_relation, ik_reachable] required_checks: [no_overlap, on_relation] debug_visualize: true debug_visualize_output_path: /tmp/placement_debug_view.rrd diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index 4a721e5622..891edbb95c 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -18,11 +18,7 @@ from isaaclab_arena.relations import placement_visualizer from isaaclab_arena.relations.object_placer import ObjectPlacer from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams -from isaaclab_arena.relations.placement_visualizer import ( - PlacementRerunVisualizer, - get_active_placement_visualizer, - summarize_layout_verdict, -) +from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer, summarize_layout_verdict from isaaclab_arena.relations.relation_solver_params import RelationSolverParams from isaaclab_arena.relations.relations import IsAnchor, On from isaaclab_arena.tests.dummy_object import DummyObject @@ -68,9 +64,9 @@ def _placer_params(**overrides) -> ObjectPlacerParams: def test_placement_has_no_debug_view_by_default(): """The debug view is opt-in, so a default placement never touches Rerun.""" - ObjectPlacer(_placer_params()) + placer = ObjectPlacer(_placer_params()) - assert get_active_placement_visualizer() is None + assert placer._visualizer is None def test_placement_records_every_candidate_layout(tmp_path): @@ -79,7 +75,7 @@ def test_placement_records_every_candidate_layout(tmp_path): placer = ObjectPlacer(_placer_params(debug_visualize_output_path=str(rrd_path))) placer.place(_desk_and_box(), num_envs=1) - visualizer = get_active_placement_visualizer() + visualizer = placer._visualizer visualizer.close() assert visualizer.num_logged_layouts == MAX_PLACEMENT_ATTEMPTS @@ -93,7 +89,16 @@ def test_placement_shares_one_debug_view_across_placers(tmp_path): first = ObjectPlacer(params) second = ObjectPlacer(_placer_params(debug_visualize_output_path=str(tmp_path / "ignored.rrd"))) - assert first._visualizer is second._visualizer is get_active_placement_visualizer() + assert first._visualizer is not None and first._visualizer is second._visualizer + + +def test_a_placer_with_the_view_off_gives_its_checks_no_view(tmp_path): + """A placer that asked for no view leaves its checks viewless, even while another's view is live.""" + ObjectPlacer(_placer_params(debug_visualize_output_path=str(tmp_path / "placement.rrd"))) + + placer = ObjectPlacer(_placer_params()) + + assert all(validator._visualizer is None for validator in placer._validators) class _FakeViewerProcess: diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index d774bf58db..d56134a575 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -21,7 +21,6 @@ from isaaclab_arena.relations.placement_validation import PlacementCheck from isaaclab_arena.relations.placement_validator_registry import register_validator from isaaclab_arena.relations.placement_validators import PlacementValidator -from isaaclab_arena.relations.placement_visualizer import get_active_placement_visualizer from isaaclab_arena.relations.relations import RequiresReachability, get_anchor_objects from isaaclab_arena.utils.pose import Pose from isaaclab_arena.utils.yaw import rotate_quat_by_yaw, yaw_from_quat_xyzw @@ -34,6 +33,7 @@ from isaaclab_arena.assets.object_base import ObjectBase from isaaclab_arena.relations.collision_object import CollisionObject from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams + from isaaclab_arena.relations.placement_visualizer import PlacementRerunVisualizer from isaaclab_arena.utils.bounding_box import AxisAlignedBoundingBox from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer @@ -66,8 +66,8 @@ class ReachabilityValidator(PlacementValidator): check = PlacementCheck.IK_REACHABLE run_after_inexpensive_checks = True - def __init__(self, params: ObjectPlacerParams) -> None: - super().__init__(params) + def __init__(self, params: ObjectPlacerParams, visualizer: PlacementRerunVisualizer | None = None) -> None: + super().__init__(params, visualizer) config = params.reachability_config self._grasp_z_offset = config.grasp_z_offset_m self._ik_pos_threshold = config.ik_position_threshold_m @@ -83,18 +83,15 @@ def __init__(self, params: ObjectPlacerParams) -> None: self._base_quat_xyzw = base_pose.rotation_xyzw # Guards the zero-target warning so it fires once per validator, not once per candidate layout. self._warned_no_targets = False - self._visualizer = get_active_placement_visualizer() self._rerun_layer = self._make_rerun_layer() - @staticmethod - def _make_rerun_layer() -> ReachabilityRerunLayer | None: - """Return this check's layer of the placement debug view, or None when that view is off.""" - visualizer = get_active_placement_visualizer() - if visualizer is None: + def _make_rerun_layer(self) -> ReachabilityRerunLayer | None: + """Return this check's layer of the placement visualizer, or None when no one asked for it.""" + if self._visualizer is None: return None from isaaclab_arena_curobo.reachability_visualizer import ReachabilityRerunLayer - return ReachabilityRerunLayer(visualizer) + return ReachabilityRerunLayer(self._visualizer) @classmethod def is_available(cls, params: ObjectPlacerParams) -> bool: diff --git a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py index 1d9cd87610..3699df9b11 100644 --- a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py +++ b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py @@ -178,28 +178,21 @@ def _make_unstamped_desk_box_pool(num_envs: int = 1, min_layouts_per_env: int = ) -def _make_reachability_validator(embodiment, monkeypatch=None, visualizer=None): - """Construct the registered ReachabilityValidator with ``embodiment`` set on its params. - - ``visualizer`` stands in for the placement debug view ObjectPlacer would have created, which the - check looks up in the visualizer module instead of receiving through its params. - """ - from isaaclab_arena.relations import placement_visualizer as placement_visualizer_module +def _make_reachability_validator(embodiment, visualizer=None): + """Construct the registered ReachabilityValidator with ``embodiment`` set on its params.""" from isaaclab_arena.relations.object_placer_params import ObjectPlacerParams from isaaclab_arena_curobo.ik_reachability_validator import ReachabilityValidator - if monkeypatch is not None: - monkeypatch.setattr(placement_visualizer_module, "_ACTIVE_VISUALIZER", visualizer) params = ObjectPlacerParams() params.reachability_config.embodiment = embodiment - return ReachabilityValidator(params) + return ReachabilityValidator(params, visualizer) @pytest.mark.curobo_deps def test_validator_skips_visualization_by_default(monkeypatch): """The debug view is opt-in: placement without one means the check adds no layer.""" _patch_curobo(monkeypatch, feasible_fn=lambda n: [True] * n) - validator = _make_reachability_validator(_fake_embodiment(), monkeypatch) + validator = _make_reachability_validator(_fake_embodiment()) assert validator._rerun_layer is None @@ -214,7 +207,7 @@ def test_validator_draws_each_candidate_on_its_own_frame(monkeypatch): visualizer = MagicMock() # The batch the check is given is candidates 7 and 9 of the run. visualizer.get_layout_index_across_batch.side_effect = [7, 9] - validator = _make_reachability_validator(_fake_embodiment(), monkeypatch, visualizer=visualizer) + validator = _make_reachability_validator(_fake_embodiment(), visualizer=visualizer) drawn: list[dict] = [] monkeypatch.setattr(validator._rerun_layer, "log_layout", lambda **kwargs: drawn.append(kwargs)) From 67d5f34183d7c8e318fbcbe0a4476a8dbb0a3f82 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Tue, 4 Aug 2026 10:12:07 -0700 Subject: [PATCH 15/17] review --- isaaclab_arena/relations/object_placer.py | 2 +- isaaclab_arena_curobo/ik_reachability_validator.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 887521b957..87dbff216c 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -601,7 +601,7 @@ def _validate_candidates( evaluated_layout_indices_by_check: dict[str, list[int]] = {} layout_pass_verdicts_by_check: dict[str, list[bool]] = {} - # Layouts are drawn before the checks run so a check's own layer lands on top of its layout. + # for debugging visualization tracking which layouts were checked layout_indices_across_batch: list[int] | None = None if self._visualizer is not None: layout_indices_across_batch = self._visualizer.log_layout_batch(positions, orientations, bboxes) diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index d56134a575..226c1ab662 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -7,9 +7,6 @@ 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. - -With the placement debug view on (``ObjectPlacerParams.debug_visualize``), the check also draws what it solved for each -candidate -- the robot base, the grasps, and their IK errors; see ``isaaclab_arena_curobo.reachability_visualizer``. """ from __future__ import annotations From 71934a5ad55f16b706339c3d126d04fb15d53306 Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Tue, 4 Aug 2026 13:15:58 -0700 Subject: [PATCH 16/17] frame --- .../ik_reachability_validator.py | 17 ++++++++--------- .../reachability_visualizer.py | 13 ++++++++----- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/isaaclab_arena_curobo/ik_reachability_validator.py b/isaaclab_arena_curobo/ik_reachability_validator.py index 226c1ab662..c50f41954d 100644 --- a/isaaclab_arena_curobo/ik_reachability_validator.py +++ b/isaaclab_arena_curobo/ik_reachability_validator.py @@ -74,10 +74,9 @@ def __init__(self, params: ObjectPlacerParams, visualizer: PlacementRerunVisuali position_threshold=self._ik_pos_threshold, rotation_threshold=self._ik_rot_threshold, ) - # TODO(xinjieyao, 2026-07-22): Switch to solved pose of the robot base - base_pose = config.embodiment.get_initial_pose() - self._base_pos = base_pose.position_xyz - self._base_quat_xyzw = base_pose.rotation_xyzw + robot_base_pose_w = config.embodiment.get_initial_pose() + self._robot_base_pos_w = robot_base_pose_w.position_xyz + self._robot_base_quat_w_xyzw = robot_base_pose_w.rotation_xyzw # Guards the zero-target warning so it fires once per validator, not once per candidate layout. self._warned_no_targets = False self._rerun_layer = self._make_rerun_layer() @@ -142,7 +141,7 @@ def _validate( get_aabb_collision_cuboid_for_object(obj, world_poses[obj].position_xyz, world_poses[obj].rotation_xyzw) for obj in objects ] - self._solver.update_world(cuboids, self._base_pos, self._base_quat_xyzw) + self._solver.update_world(cuboids, self._robot_base_pos_w, self._robot_base_quat_w_xyzw) # non-anchor objects with a RequiresReachability relation targets = self._select_reachability_targets(objects, anchors) @@ -161,8 +160,8 @@ def _validate( top_down_grasp_pose_from_world_poses( world_poses[obj].position_xyz, world_poses[obj].rotation_xyzw, - self._base_pos, - self._base_quat_xyzw, + self._robot_base_pos_w, + self._robot_base_quat_w_xyzw, self._grasp_z_offset, device=self._solver.device, ) @@ -178,8 +177,8 @@ def _validate( layout_index_across_batch = self._visualizer.get_layout_index_across_batch(layout_index_within_batch) self._rerun_layer.log_layout( layout_index_across_batch=layout_index_across_batch, - base_pos=self._base_pos, - base_quat_xyzw=self._base_quat_xyzw, + robot_base_pos_w=self._robot_base_pos_w, + robot_base_quat_w_xyzw=self._robot_base_quat_w_xyzw, target_names=[obj.name for obj in targets], grasp_poses_base_frame=grasp_poses, feasible=feasible, diff --git a/isaaclab_arena_curobo/reachability_visualizer.py b/isaaclab_arena_curobo/reachability_visualizer.py index bb4c4a600d..f900638e55 100644 --- a/isaaclab_arena_curobo/reachability_visualizer.py +++ b/isaaclab_arena_curobo/reachability_visualizer.py @@ -44,8 +44,8 @@ def __init__(self, visualizer: PlacementRerunVisualizer) -> None: def log_layout( self, layout_index_across_batch: int, - base_pos: tuple[float, float, float], - base_quat_xyzw: tuple[float, float, float, float], + robot_base_pos_w: tuple[float, float, float], + robot_base_quat_w_xyzw: tuple[float, float, float, float], target_names: list[str], grasp_poses_base_frame: torch.Tensor, feasible: torch.Tensor, @@ -56,8 +56,8 @@ def log_layout( Args: layout_index_across_batch: Timeline index of the layout, as assigned by the placement view. - base_pos: Robot base position in the world frame. - base_quat_xyzw: Robot base orientation in the world frame. + robot_base_pos_w: Robot base frame position in the world frame. + robot_base_quat_w_xyzw: Robot base frame orientation in the world frame. target_names: Names of the objects a grasp was solved for, aligned with the tensors below. grasp_poses_base_frame: ``(b, 4, 4)`` grasp transforms in the robot base frame. feasible: ``(b,)`` per-grasp IK verdict. @@ -69,7 +69,10 @@ def log_layout( self._visualizer.set_time(layout_index_across_batch) # Grasps are solved in the robot base frame, so they are logged as children of the base # transform and Rerun composes them back into the world frame. - rr.log(BASE_ENTITY, rr.Transform3D(translation=base_pos, quaternion=rr.Quaternion(xyzw=base_quat_xyzw))) + rr.log( + BASE_ENTITY, + rr.Transform3D(translation=robot_base_pos_w, quaternion=rr.Quaternion(xyzw=robot_base_quat_w_xyzw)), + ) rr.log(BASE_ENTITY, rr.TransformAxes3D(BASE_AXIS_LENGTH)) grasps = grasp_poses_base_frame.detach().cpu() From ec9cfe526e40a47e4d4919cf868088f7cec3421e Mon Sep 17 00:00:00 2001 From: Xinjie Yao Date: Tue, 4 Aug 2026 14:33:40 -0700 Subject: [PATCH 17/17] cleanup --- isaaclab_arena/relations/object_placer.py | 21 ++----- .../relations/placement_visualizer.py | 55 ++++++++++++------- .../tests/test_placement_visualizer.py | 23 ++++++-- .../tests/test_ik_reachability_validator.py | 4 +- 4 files changed, 60 insertions(+), 43 deletions(-) diff --git a/isaaclab_arena/relations/object_placer.py b/isaaclab_arena/relations/object_placer.py index 87dbff216c..2b565514d0 100644 --- a/isaaclab_arena/relations/object_placer.py +++ b/isaaclab_arena/relations/object_placer.py @@ -601,10 +601,8 @@ def _validate_candidates( evaluated_layout_indices_by_check: dict[str, list[int]] = {} layout_pass_verdicts_by_check: dict[str, list[bool]] = {} - # for debugging visualization tracking which layouts were checked - layout_indices_across_batch: list[int] | None = None if self._visualizer is not None: - layout_indices_across_batch = self._visualizer.log_layout_batch(positions, orientations, bboxes) + self._visualizer.start_new_batch(positions, orientations, bboxes) self._run_inexpensive_checks( positions, @@ -613,7 +611,6 @@ def _validate_candidates( collision_objects, layout_pass_verdicts_by_check, evaluated_layout_indices_by_check, - layout_indices_across_batch, ) self._run_expensive_checks( positions, @@ -623,11 +620,9 @@ def _validate_candidates( required, layout_pass_verdicts_by_check, evaluated_layout_indices_by_check, - layout_indices_across_batch, ) - if self._visualizer is not None and layout_indices_across_batch is not None: - self._visualizer.log_layout_batch_verdicts( - layout_indices_across_batch, + if self._visualizer is not None: + self._visualizer.log_batch_verdicts( layout_pass_verdicts_by_check, evaluated_layout_indices_by_check, self.params.required_checks, @@ -656,14 +651,11 @@ def _run_inexpensive_checks( collision_objects: list[CollisionObject], layout_pass_verdicts_by_check: dict[str, list[bool]], evaluated_layout_indices_by_check: dict[str, list[int]], - layout_indices_across_batch: list[int] | None, ) -> None: """Run every inexpensive validator on all candidates, recording verdicts and evaluated layouts.""" num_candidates = len(positions) for validator in self._validators: if not validator.run_after_inexpensive_checks: - if self._visualizer is not None: - self._visualizer.set_active_layout_indices_across_batch(layout_indices_across_batch or []) layout_pass_verdicts_by_check[validator.check] = validator.validate_batch( positions, orientations, bboxes, collision_objects ) @@ -678,7 +670,6 @@ def _run_expensive_checks( required: set[str] | None, layout_pass_verdicts_by_check: dict[str, list[bool]], evaluated_layout_indices_by_check: dict[str, list[int]], - layout_indices_across_batch: list[int] | None, ) -> None: """Run each expensive validator only on candidates that passed the required inexpensive checks.""" num_candidates = len(positions) @@ -689,10 +680,8 @@ def _run_expensive_checks( for i in range(num_candidates) if self._passes_required_checks(layout_pass_verdicts_by_check, required, i) ] - if self._visualizer is not None and layout_indices_across_batch is not None: - self._visualizer.set_active_layout_indices_across_batch( - [layout_indices_across_batch[i] for i in passed_layout_indices] - ) + if self._visualizer is not None: + self._visualizer.set_active_layouts(passed_layout_indices) # only passed layouts are validated verdicts_over_passed_layout = validator.validate_batch( [positions[i] for i in passed_layout_indices], diff --git a/isaaclab_arena/relations/placement_visualizer.py b/isaaclab_arena/relations/placement_visualizer.py index 54813bce46..4eeb90d686 100644 --- a/isaaclab_arena/relations/placement_visualizer.py +++ b/isaaclab_arena/relations/placement_visualizer.py @@ -177,15 +177,15 @@ def summarize_layout_verdict( class PlacementRerunVisualizer: - """Streams every validated layout to Rerun, one frame per layout.""" + """Draws every validated layout into a Rerun recording, one frame per layout.""" def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_path: str | None = None) -> None: - """Start the recording and, unless recording headlessly, spawn a viewer window. + """Start the recording, streaming to a spawned viewer window and/or writing it to a file. Args: app_id: Rerun application id, shown in the viewer title. spawn: Whether to spawn a local viewer window and stream to it. - output_path: Optional path to also record the stream to, for replay on another machine. + output_path: Optional path to record the stream to. """ import rerun as rr @@ -201,7 +201,13 @@ def __init__(self, app_id: str = "arena_placement", spawn: bool = True, output_p assert sinks, "PlacementRerunVisualizer needs a viewer to spawn or an output path to record to." rr.set_sinks(*sinks) rr.log("world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True) + # Next unused timeline frame to attach to a layout, e.g. 5 after frame 0-4 are drawn self._next_layout_index_across_batch = 0 + + # Across-batch index of each layout of the current batch, e.g. [3, 4] for a 2-layout batch after frames 0-2 + self._layout_indices_across_batch: list[int] = [] + + # Across-batch index of the layout the next check runs on, e.g. [4] when only layout 1 of that batch passed self._active_layout_indices_across_batch: list[int] = [] def __deepcopy__(self, memory_map: dict[int, object]) -> PlacementRerunVisualizer: @@ -221,22 +227,31 @@ def num_logged_layouts(self) -> int: """How many layouts have been given a frame so far.""" return self._next_layout_index_across_batch - def reserve_layout_indices_across_batch(self, num_layouts: int) -> list[int]: + def _reserve_layout_indices(self, num_layouts: int) -> list[int]: """Reserve and return one timeline index per layout of the batch about to be validated. Indices keep counting across batches so a pool that refills several times does not overwrite its earlier frames. + + Args: + num_layouts: How many layouts the batch holds. """ start = self._next_layout_index_across_batch self._next_layout_index_across_batch += num_layouts return list(range(start, self._next_layout_index_across_batch)) - def set_active_layout_indices_across_batch(self, layout_indices_across_batch: list[int]) -> None: - """Set the batch ``get_layout_index_across_batch`` resolves against, in the order the check sees it.""" - self._active_layout_indices_across_batch = list(layout_indices_across_batch) + def set_active_layouts(self, layout_indices_within_batch: list[int]) -> None: + """Narrow the current batch to the layouts the next check runs on, in the order it sees them. + + Args: + layout_indices_within_batch: Positions within the current batch, as the check received them. + """ + self._active_layout_indices_across_batch = [ + self._layout_indices_across_batch[i] for i in layout_indices_within_batch + ] def get_layout_index_across_batch(self, layout_index_within_batch: int) -> int: - """Timeline index of the layout at ``layout_index_within_batch`` of the active batch.""" + """Across-batch index of the layout the active subset holds at ``layout_index_within_batch``.""" return self._active_layout_indices_across_batch[layout_index_within_batch] def set_time(self, layout_index_across_batch: int) -> None: @@ -253,7 +268,7 @@ def log_layout( bboxes: dict[PlaceableAsset, AxisAlignedBoundingBox], anchors: set[PlaceableAsset], ) -> None: - """Log one layout's solved layout as boxes in the world frame. + """Draw one solved layout as boxes in the world frame. Args: layout_index_across_batch: Timeline index to log against. @@ -297,21 +312,23 @@ def log_layout( ), ) - def log_layout_batch( + def start_new_batch( self, positions: list[dict[PlaceableAsset, tuple[float, float, float]]], orientations: list[dict[PlaceableAsset, float]], bboxes: list[dict[PlaceableAsset, AxisAlignedBoundingBox]], - ) -> list[int]: - """Log every solved layout of one batch, returning the timeline index each was drawn on. + ) -> None: + """Give every solved layout of a new batch its own frame, and make that batch the current one. + + Every layout starts active, until a check narrows the batch with ``set_active_layouts``. Args: positions: Solved (x, y, z) per object, one dict per layout. orientations: Absolute world Z-yaw per object, one dict per layout. bboxes: Per-object local bounding box, one dict per layout. """ - layout_indices_across_batch = self.reserve_layout_indices_across_batch(len(positions)) - for layout_index_within_batch, layout_index_across_batch in enumerate(layout_indices_across_batch): + self._layout_indices_across_batch = self._reserve_layout_indices(len(positions)) + for layout_index_within_batch, layout_index_across_batch in enumerate(self._layout_indices_across_batch): self.log_layout( layout_index_across_batch, positions[layout_index_within_batch], @@ -319,7 +336,7 @@ def log_layout_batch( bboxes[layout_index_within_batch], anchors=set(get_anchor_objects(list(positions[layout_index_within_batch]))), ) - return layout_indices_across_batch + self._active_layout_indices_across_batch = list(self._layout_indices_across_batch) def log_layout_verdicts( self, layout_index_across_batch: int, verdicts_by_check: dict[str, bool], required_checks: set[str] | None @@ -342,25 +359,23 @@ def log_layout_verdicts( for check, passed in verdicts_by_check.items(): rr.log(f"checks/{check}", rr.Scalars(float(passed))) - def log_layout_batch_verdicts( + def log_batch_verdicts( self, - layout_indices_across_batch: list[int], verdicts_by_check: dict[str, list[bool]], evaluated_layout_indices_by_check: dict[str, list[int]], required_checks: set[str] | None, ) -> None: - """Log the check verdicts of one validated batch of layouts, one layout at a time. + """Log the check verdicts of the current batch of layouts, one layout at a time. A check is left off the layouts it did not run on, so a skipped one is not drawn as failed. Args: - layout_indices_across_batch: Timeline index of each layout of the batch, in batch order. verdicts_by_check: Per check, its verdict for every layout of the batch. evaluated_layout_indices_by_check: Per check, which layouts of the batch it ran on. required_checks: Checks that gate acceptance; None means every check that ran gates it. """ evaluated = {check: set(indices) for check, indices in evaluated_layout_indices_by_check.items()} - for layout_index_within_batch, layout_index_across_batch in enumerate(layout_indices_across_batch): + for layout_index_within_batch, layout_index_across_batch in enumerate(self._layout_indices_across_batch): self.log_layout_verdicts( layout_index_across_batch, { diff --git a/isaaclab_arena/tests/test_placement_visualizer.py b/isaaclab_arena/tests/test_placement_visualizer.py index 891edbb95c..4218e3a9e3 100644 --- a/isaaclab_arena/tests/test_placement_visualizer.py +++ b/isaaclab_arena/tests/test_placement_visualizer.py @@ -51,6 +51,14 @@ def _desk_and_box() -> list[DummyObject]: return [desk, box] +def _layout_batch(num_layouts: int): + """``(positions, orientations, bboxes)`` for a batch of identical desk+box layouts.""" + desk, box = _desk_and_box() + positions = {desk: (0.0, 0.0, 0.0), box: (0.0, 0.0, 0.2)} + bboxes = {obj: obj.get_bounding_box() for obj in positions} + return [positions] * num_layouts, [{}] * num_layouts, [bboxes] * num_layouts + + def _placer_params(**overrides) -> ObjectPlacerParams: """Placement params for a small, deterministic solve.""" return ObjectPlacerParams( @@ -203,8 +211,8 @@ def test_a_check_that_skipped_a_candidate_is_not_drawn_as_rejecting_it(tmp_path, ), ) - visualizer.log_layout_batch_verdicts( - layout_indices_across_batch=[0, 1], + visualizer.start_new_batch(*_layout_batch(2)) + visualizer.log_batch_verdicts( verdicts_by_check={"no_overlap": [True, False], "ik_reachable": [True, False]}, evaluated_layout_indices_by_check={"no_overlap": [0, 1], "ik_reachable": [0]}, required_checks=None, @@ -220,15 +228,20 @@ def test_candidate_frames_keep_counting_across_batches(tmp_path): """A pool that refills gets fresh frames, so a later batch does not overwrite an earlier one.""" visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) - assert visualizer.reserve_layout_indices_across_batch(3) == [0, 1, 2] - assert visualizer.reserve_layout_indices_across_batch(2) == [3, 4] + visualizer.start_new_batch(*_layout_batch(3)) + visualizer.start_new_batch(*_layout_batch(2)) + + assert visualizer.num_logged_layouts == 5 + # The second batch's own layout 0 is frame 3, picking up where the first batch stopped. + assert visualizer.get_layout_index_across_batch(0) == 3 def test_active_candidates_map_index_within_batch_to_frame(tmp_path): """A check that only ran on some candidates still resolves each one's own frame.""" visualizer = PlacementRerunVisualizer(app_id="arena_test", spawn=False, output_path=str(tmp_path / "p.rrd")) + visualizer.start_new_batch(*_layout_batch(5)) - visualizer.set_active_layout_indices_across_batch([1, 4]) + visualizer.set_active_layouts([1, 4]) assert visualizer.get_layout_index_across_batch(0) == 1 assert visualizer.get_layout_index_across_batch(1) == 4 diff --git a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py index 3699df9b11..5a57a1bfd1 100644 --- a/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py +++ b/isaaclab_arena_curobo/tests/test_ik_reachability_validator.py @@ -231,8 +231,8 @@ def test_reachability_layer_records_to_rrd(tmp_path): layer.log_layout( layout_index_across_batch=0, - base_pos=(0.0, 0.0, 0.0), - base_quat_xyzw=(0.0, 0.0, 0.0, 1.0), + robot_base_pos_w=(0.0, 0.0, 0.0), + robot_base_quat_w_xyzw=(0.0, 0.0, 0.0, 1.0), target_names=["box"], grasp_poses_base_frame=torch.eye(4).unsqueeze(0), feasible=torch.tensor([False]),