react-manip 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assets/materials/realistic/common/ceramic.png +0 -0
- assets/materials/realistic/common/cream-plaster.png +0 -0
- assets/materials/realistic/common/gray-felt.png +0 -0
- assets/materials/realistic/common/gray-plaster.png +0 -0
- assets/materials/realistic/common/gray-woodgrain.png +0 -0
- assets/materials/realistic/common/light-gray-floor-tile.png +0 -0
- assets/materials/realistic/common/light-gray-plaster.png +0 -0
- assets/materials/realistic/common/metal.png +0 -0
- assets/materials/realistic/common/oak-wood.png +0 -0
- assets/materials/realistic/common/steel-brushed.png +0 -0
- assets/materials/realistic/common/steel-scratched.png +0 -0
- assets/materials/realistic/common/varnished-wood-panels.png +0 -0
- assets/materials/realistic/common/walnut-wood.png +0 -0
- assets/materials/realistic/common/white-bricks.png +0 -0
- assets/materials/realistic/common/wood-tiles.png +0 -0
- assets/materials/realistic/tiles074/Tiles074_2K_Color.png +0 -0
- assets/materials/realistic/woodfloor049/WoodFloor049_2K_Color.png +0 -0
- configs/box_insertion/profile_default.json +204 -0
- react_manip/__init__.py +12 -0
- react_manip/asset_cli.py +18 -0
- react_manip/builtin.py +168 -0
- react_manip/cameras.py +301 -0
- react_manip/category_objects.py +248 -0
- react_manip/core/__init__.py +60 -0
- react_manip/core/aggregate.py +59 -0
- react_manip/core/compatibility.py +64 -0
- react_manip/core/contracts.py +42 -0
- react_manip/core/geometry.py +269 -0
- react_manip/core/language.py +116 -0
- react_manip/core/metrics.py +196 -0
- react_manip/core/observation.py +59 -0
- react_manip/core/protocol.py +91 -0
- react_manip/core/registry.py +94 -0
- react_manip/core/runner.py +498 -0
- react_manip/core/scenarios.py +110 -0
- react_manip/core/specs.py +203 -0
- react_manip/core/timeouts.py +44 -0
- react_manip/core/visual_randomization.py +261 -0
- react_manip/evaluation/__init__.py +4 -0
- react_manip/evaluation/metrics.py +17 -0
- react_manip/evaluation/protocols.py +326 -0
- react_manip/evaluation/viewer.py +240 -0
- react_manip/instances/__init__.py +6 -0
- react_manip/instances/candidate_bank.py +109 -0
- react_manip/instances/public.py +95 -0
- react_manip/instances/records.py +345 -0
- react_manip/instances/runtime.py +306 -0
- react_manip/instances/suite.py +231 -0
- react_manip/instances/trajectory.py +59 -0
- react_manip/instances/variation.py +118 -0
- react_manip/legacy.py +318 -0
- react_manip/policies/__init__.py +5 -0
- react_manip/policies/base.py +79 -0
- react_manip/rendering.py +58 -0
- react_manip/robot_registry.py +31 -0
- react_manip/robots/__init__.py +33 -0
- react_manip/robots/adapters.py +135 -0
- react_manip/robots/agilex_adapter.py +35 -0
- react_manip/robots/agilex_description.py +74 -0
- react_manip/robots/agilex_robosuite.py +246 -0
- react_manip/robots/arm_actions.py +17 -0
- react_manip/robots/base.py +119 -0
- react_manip/robots/catalog.py +18 -0
- react_manip/robots/legacy_agilex_robosuite.py +169 -0
- react_manip/robots/mobile_base.py +33 -0
- react_manip/robots/mounts.py +80 -0
- react_manip/robots/resolution.py +103 -0
- react_manip/robots/sawyer_robosuite.py +147 -0
- react_manip/robots/sensors.py +137 -0
- react_manip/utils/__init__.py +1 -0
- react_manip/visual_task.py +50 -0
- react_manip-0.1.0.dist-info/METADATA +446 -0
- react_manip-0.1.0.dist-info/RECORD +99 -0
- react_manip-0.1.0.dist-info/WHEEL +5 -0
- react_manip-0.1.0.dist-info/entry_points.txt +2 -0
- react_manip-0.1.0.dist-info/licenses/LICENSE +131 -0
- react_manip-0.1.0.dist-info/top_level.txt +5 -0
- scripts/add_phone_lean_to_bank.py +242 -0
- scripts/agilex_wheeled_builder.py +133 -0
- scripts/audit_task_solvability.py +150 -0
- scripts/build_agilex_assets.py +317 -0
- scripts/build_legacy_agilex_assets.py +244 -0
- scripts/evaluate_all_tasks.py +309 -0
- scripts/evaluate_policy.py +281 -0
- scripts/piperx_assets.lock.json +181 -0
- scripts/pygame_demo.py +360 -0
- scripts/pygame_task_debug.py +242 -0
- scripts/regenerate_instance_bank.py +192 -0
- scripts/remove_task_from_bank.py +103 -0
- scripts/select_task_instances.py +64 -0
- scripts/verify_curling_reachability.py +206 -0
- scripts/verify_piperx_tasks.py +81 -0
- tasks/box_insertion/__init__.py +3 -0
- tasks/box_insertion/assets/scene.xml +50 -0
- tasks/box_insertion/env.py +642 -0
- tasks/box_insertion/evaluation.json +16 -0
- tasks/box_insertion/instance_contract.json +321 -0
- tasks/box_insertion/scoring.py +23 -0
- tasks/box_insertion/task_spec.json +333 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Versioned task metadata and success criteria.
|
|
2
|
+
|
|
3
|
+
Task metadata is intentionally data-oriented. A new task can provide a
|
|
4
|
+
``TaskSpec`` in Python or load the same fields from JSON/YAML without changing
|
|
5
|
+
the benchmark runner.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
import json
|
|
14
|
+
from .contracts import ObservationSpec, ActionSpec, FeedbackSpec, parse_contracts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SuccessRule:
|
|
19
|
+
"""Rule used to turn step-level signals into a benchmark pass/fail.
|
|
20
|
+
|
|
21
|
+
``min_occupancy`` is the fraction of control ticks with a valid success
|
|
22
|
+
state. ``min_longest_streak_sec`` prevents a policy from alternating
|
|
23
|
+
between success and failure while still achieving a high average. Event
|
|
24
|
+
tasks use ``min_events`` for their throughput requirement.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
min_occupancy: float = 0.95
|
|
28
|
+
min_longest_streak_sec: float = 0.0
|
|
29
|
+
min_events: int = 0
|
|
30
|
+
max_failures: int | None = 0
|
|
31
|
+
require_terminal_success: bool = False
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not 0.0 <= self.min_occupancy <= 1.0:
|
|
35
|
+
raise ValueError("min_occupancy must be in [0, 1]")
|
|
36
|
+
if self.min_longest_streak_sec < 0:
|
|
37
|
+
raise ValueError("min_longest_streak_sec must be non-negative")
|
|
38
|
+
if self.min_events < 0:
|
|
39
|
+
raise ValueError("min_events must be non-negative")
|
|
40
|
+
if self.max_failures is not None and self.max_failures < 0:
|
|
41
|
+
raise ValueError("max_failures must be non-negative or None")
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_mapping(cls, value: Mapping[str, Any] | None) -> "SuccessRule":
|
|
45
|
+
if isinstance(value, cls):
|
|
46
|
+
return value
|
|
47
|
+
value = value or {}
|
|
48
|
+
return cls(
|
|
49
|
+
min_occupancy=float(value.get("min_occupancy", 0.95)),
|
|
50
|
+
min_longest_streak_sec=float(value.get("min_longest_streak_sec", 0.0)),
|
|
51
|
+
min_events=int(value.get("min_events", 0)),
|
|
52
|
+
max_failures=(
|
|
53
|
+
None
|
|
54
|
+
if value.get("max_failures", 0) is None
|
|
55
|
+
else int(value.get("max_failures", 0))
|
|
56
|
+
),
|
|
57
|
+
require_terminal_success=bool(value.get("require_terminal_success", False)),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class TaskSpec:
|
|
63
|
+
"""Stable metadata contract for a benchmark task."""
|
|
64
|
+
|
|
65
|
+
task_id: str
|
|
66
|
+
task_version: str = "1.0"
|
|
67
|
+
api_version: str = "2.0"
|
|
68
|
+
task_type: str = "continuous" # continuous | cyclic | event | one_shot
|
|
69
|
+
required_arms: int = 1
|
|
70
|
+
required_gripper: str | None = None
|
|
71
|
+
required_eef_dof: int | None = None
|
|
72
|
+
supported_robots: tuple[str, ...] = ()
|
|
73
|
+
horizon_sec: float = 60.0
|
|
74
|
+
control_hz: float = 20.0
|
|
75
|
+
done_behavior: str = "stop" # stop | reset | fail_and_stop | fail_and_reset
|
|
76
|
+
success: SuccessRule = field(default_factory=SuccessRule)
|
|
77
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
78
|
+
observation: ObservationSpec = field(default_factory=ObservationSpec)
|
|
79
|
+
action: ActionSpec = field(default_factory=ActionSpec)
|
|
80
|
+
feedback: FeedbackSpec = field(default_factory=FeedbackSpec)
|
|
81
|
+
evaluation: Mapping[str, Any] = field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
if not isinstance(self.success, SuccessRule):
|
|
85
|
+
object.__setattr__(self, "success", SuccessRule.from_mapping(self.success))
|
|
86
|
+
for key, cls in (("observation", ObservationSpec), ("action", ActionSpec), ("feedback", FeedbackSpec)):
|
|
87
|
+
if not isinstance(getattr(self, key), cls): object.__setattr__(self, key, parse_contracts({key: getattr(self, key)})[("observation", "action", "feedback").index(key)])
|
|
88
|
+
if not self.task_id or any(ch.isspace() for ch in self.task_id):
|
|
89
|
+
raise ValueError("task_id must be a non-empty, whitespace-free string")
|
|
90
|
+
if self.task_type not in {"continuous", "cyclic", "event", "one_shot"}:
|
|
91
|
+
raise ValueError(f"unsupported task_type: {self.task_type}")
|
|
92
|
+
# Native task-level environments may intentionally have no robot
|
|
93
|
+
# embodiment (for example a scene-level curling actuator). Robot
|
|
94
|
+
# operation tasks still declare one or more arms explicitly.
|
|
95
|
+
if self.required_arms < 0:
|
|
96
|
+
raise ValueError("required_arms must be >= 0")
|
|
97
|
+
if self.required_eef_dof is not None and self.required_eef_dof < 1:
|
|
98
|
+
raise ValueError("required_eef_dof must be >= 1")
|
|
99
|
+
if self.horizon_sec <= 0 or self.control_hz <= 0:
|
|
100
|
+
raise ValueError("horizon_sec and control_hz must be positive")
|
|
101
|
+
if self.done_behavior not in {"stop", "reset", "fail_and_stop", "fail_and_reset"}:
|
|
102
|
+
raise ValueError(f"unsupported done_behavior: {self.done_behavior}")
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def horizon_steps(self) -> int:
|
|
106
|
+
return max(1, int(round(self.horizon_sec * self.control_hz)))
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def supports_all_robots(self) -> bool:
|
|
110
|
+
"""Whether the task accepts the canonical robot IDs without narrowing.
|
|
111
|
+
|
|
112
|
+
Empty ``supported_robots`` is an intentional wildcard for task-level
|
|
113
|
+
scenes. It means the selected embodiment is recorded as context; it
|
|
114
|
+
does not imply that an arm model is physically compiled into the scene.
|
|
115
|
+
"""
|
|
116
|
+
return not self.supported_robots
|
|
117
|
+
|
|
118
|
+
def supports_robot(self, robot_id: str) -> bool:
|
|
119
|
+
return self.supports_all_robots or str(robot_id).lower() in {
|
|
120
|
+
item.lower() for item in self.supported_robots
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> "TaskSpec":
|
|
125
|
+
data = dict(value)
|
|
126
|
+
supported = data.get("supported_robots", ())
|
|
127
|
+
if isinstance(supported, str):
|
|
128
|
+
supported = (supported,)
|
|
129
|
+
return cls(
|
|
130
|
+
task_id=str(data["task_id"]),
|
|
131
|
+
task_version=str(data.get("task_version", "1.0")),
|
|
132
|
+
api_version=str(data.get("api_version", "2.0")),
|
|
133
|
+
task_type=str(data.get("task_type", "continuous")),
|
|
134
|
+
required_arms=int(data.get("required_arms", 1)),
|
|
135
|
+
required_gripper=data.get("required_gripper"),
|
|
136
|
+
required_eef_dof=(
|
|
137
|
+
None if data.get("required_eef_dof") is None else int(data["required_eef_dof"])
|
|
138
|
+
),
|
|
139
|
+
supported_robots=tuple(str(item) for item in supported),
|
|
140
|
+
horizon_sec=float(data.get("horizon_sec", 60.0)),
|
|
141
|
+
control_hz=float(data.get("control_hz", 20.0)),
|
|
142
|
+
done_behavior=str(data.get("done_behavior", "stop")),
|
|
143
|
+
success=SuccessRule.from_mapping(data.get("success")),
|
|
144
|
+
evaluation=dict(data.get("evaluation", {})),
|
|
145
|
+
metadata=dict(data.get("metadata", {})),
|
|
146
|
+
observation=parse_contracts(data)[0], action=parse_contracts(data)[1], feedback=parse_contracts(data)[2],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def to_dict(self) -> dict[str, Any]:
|
|
150
|
+
return {
|
|
151
|
+
"api_version": self.api_version,
|
|
152
|
+
"task_id": self.task_id,
|
|
153
|
+
"task_version": self.task_version,
|
|
154
|
+
"task_type": self.task_type,
|
|
155
|
+
"required_arms": self.required_arms,
|
|
156
|
+
"required_gripper": self.required_gripper,
|
|
157
|
+
"required_eef_dof": self.required_eef_dof,
|
|
158
|
+
"supported_robots": list(self.supported_robots),
|
|
159
|
+
"horizon_sec": self.horizon_sec,
|
|
160
|
+
"control_hz": self.control_hz,
|
|
161
|
+
"done_behavior": self.done_behavior,
|
|
162
|
+
"success": {
|
|
163
|
+
"min_occupancy": self.success.min_occupancy,
|
|
164
|
+
"min_longest_streak_sec": self.success.min_longest_streak_sec,
|
|
165
|
+
"min_events": self.success.min_events,
|
|
166
|
+
"max_failures": self.success.max_failures,
|
|
167
|
+
"require_terminal_success": self.success.require_terminal_success,
|
|
168
|
+
},
|
|
169
|
+
"evaluation": dict(self.evaluation),
|
|
170
|
+
"metadata": dict(self.metadata),
|
|
171
|
+
"observation": {"modalities": list(self.observation.modalities), "default_policy_view": list(self.observation.default_policy_view), "geometry_view": self.observation.geometry_view, "fields": dict(self.observation.fields), "visibility": dict(self.observation.visibility)},
|
|
172
|
+
"action": {"type": self.action.type, "shape": list(self.action.shape), "bounds": list(self.action.bounds), "frame": self.action.frame, "fields": dict(self.action.fields)},
|
|
173
|
+
"feedback": {"fields": dict(self.feedback.fields), "events": list(self.feedback.events), "success_signals": list(self.feedback.success_signals)},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_task_spec(path: str | Path) -> TaskSpec:
|
|
178
|
+
"""Load a JSON or YAML task specification.
|
|
179
|
+
|
|
180
|
+
YAML is optional so the core package remains dependency-light. A clear
|
|
181
|
+
error is raised if a YAML file is used without PyYAML installed.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
path = Path(path)
|
|
185
|
+
raw = path.read_text(encoding="utf-8")
|
|
186
|
+
if path.suffix.lower() == ".json":
|
|
187
|
+
data = json.loads(raw)
|
|
188
|
+
else:
|
|
189
|
+
try:
|
|
190
|
+
import yaml # type: ignore
|
|
191
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
192
|
+
raise RuntimeError("Loading YAML specs requires PyYAML; use JSON instead") from exc
|
|
193
|
+
data = yaml.safe_load(raw)
|
|
194
|
+
if not isinstance(data, Mapping):
|
|
195
|
+
raise ValueError(f"task spec must be a mapping: {path}")
|
|
196
|
+
data = dict(data)
|
|
197
|
+
evaluation = data.get("evaluation", {})
|
|
198
|
+
if "config" in evaluation:
|
|
199
|
+
if set(evaluation) != {"config"}:
|
|
200
|
+
raise ValueError("evaluation.config cannot be combined with inline settings")
|
|
201
|
+
config_path = path.parent / evaluation["config"]
|
|
202
|
+
data["evaluation"] = json.loads(config_path.read_text(encoding="utf-8"))
|
|
203
|
+
return TaskSpec.from_mapping(data)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Wall-clock limits shared by all evaluator entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# These are infrastructure limits, distinct from a task's simulation horizon.
|
|
9
|
+
# They can be overridden by callers/CLI when a policy or machine needs a
|
|
10
|
+
# different budget.
|
|
11
|
+
DEFAULT_EPISODE_WALL_TIMEOUT_SEC = 30.0 * 60.0
|
|
12
|
+
DEFAULT_TASK_WALL_TIMEOUT_SEC = 60.0 * 60.0
|
|
13
|
+
TASK_WALL_TIMEOUT_OVERRIDES_SEC = {
|
|
14
|
+
"conveyor_grasp": 2.0 * 60.0 * 60.0,
|
|
15
|
+
"find_object_in_drawer2": 4.0 * 60.0 * 60.0,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Distinguish an omitted task timeout from an explicit ``None``, which keeps
|
|
19
|
+
# the existing Python API behavior of disabling the task-level deadline.
|
|
20
|
+
USE_DEFAULT_TASK_WALL_TIMEOUT = object()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_timeout(value: float | None, name: str) -> float | None:
|
|
24
|
+
"""Validate an optional positive wall-clock timeout."""
|
|
25
|
+
|
|
26
|
+
if value is None:
|
|
27
|
+
return None
|
|
28
|
+
value = float(value)
|
|
29
|
+
if not math.isfinite(value) or value <= 0:
|
|
30
|
+
raise ValueError(f"{name} must be a finite positive number")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_task_wall_timeout_sec(task_id: str) -> float:
|
|
35
|
+
"""Return the release default for all episodes belonging to one task."""
|
|
36
|
+
|
|
37
|
+
return TASK_WALL_TIMEOUT_OVERRIDES_SEC.get(task_id, DEFAULT_TASK_WALL_TIMEOUT_SEC)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_task_wall_timeout_sec(task_id: str, configured=USE_DEFAULT_TASK_WALL_TIMEOUT) -> float | None:
|
|
41
|
+
"""Resolve an omitted, explicitly disabled, or explicitly overridden limit."""
|
|
42
|
+
|
|
43
|
+
value = default_task_wall_timeout_sec(task_id) if configured is USE_DEFAULT_TASK_WALL_TIMEOUT else configured
|
|
44
|
+
return validate_timeout(value, "task_wall_timeout_sec")
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Shared pre-compilation visuals, reconstructed on every hard reset.
|
|
2
|
+
|
|
3
|
+
Ambient and shadow-free fill lights approximate diffuse illumination; this
|
|
4
|
+
is not physically based global illumination or an area-light renderer.
|
|
5
|
+
"""
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from .scenarios import Scenario
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_scenario(value):
|
|
14
|
+
return Scenario.load(value) if isinstance(value, (str, os.PathLike)) else value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _StandaloneArena:
|
|
18
|
+
"""Minimal Arena-compatible wrapper for repository-owned MJCF strings."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, xml: str):
|
|
21
|
+
self.root = ET.fromstring(xml)
|
|
22
|
+
self.asset = self.root.find("asset")
|
|
23
|
+
if self.asset is None:
|
|
24
|
+
self.asset = ET.Element("asset")
|
|
25
|
+
self.root.insert(0, self.asset)
|
|
26
|
+
self.worldbody = self.root.find("worldbody")
|
|
27
|
+
if self.worldbody is None:
|
|
28
|
+
raise ValueError("MJCF visual randomization requires a worldbody")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_workspace_camera(arena, *, name: str = "workspace_topview") -> None:
|
|
32
|
+
"""Ensure every compiled scene has a canonical overhead work-area view.
|
|
33
|
+
|
|
34
|
+
The camera is fixed in world coordinates at approximately 1.5 m height and
|
|
35
|
+
points straight down. It is intentionally a non-physical sensor: adding
|
|
36
|
+
it never changes collision,
|
|
37
|
+
contacts, dynamics or task geometry. Tasks may add a more tightly framed
|
|
38
|
+
camera under another name, but this stable name is guaranteed for RL and
|
|
39
|
+
frontends.
|
|
40
|
+
"""
|
|
41
|
+
from react_manip.cameras import ensure_scene_cameras
|
|
42
|
+
ensure_scene_cameras(arena)
|
|
43
|
+
worldbody = getattr(arena, "worldbody", None)
|
|
44
|
+
if worldbody is None:
|
|
45
|
+
raise ValueError("workspace camera requires an arena with worldbody")
|
|
46
|
+
if any(node.get("name") == name for node in worldbody.iter("camera")):
|
|
47
|
+
return
|
|
48
|
+
ET.SubElement(
|
|
49
|
+
worldbody, "camera", name=name, pos="0 0 1.5",
|
|
50
|
+
xyaxes="1 0 0 0 1 0", fovy="75",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def apply_visual_scenario_xml(xml: str, scenario) -> str:
|
|
55
|
+
"""Apply the shared room / material / lighting contract to plain MJCF.
|
|
56
|
+
|
|
57
|
+
Robosuite tasks call :func:`apply_visual_scenario` while constructing an
|
|
58
|
+
``Arena``. Native MuJoCo tasks use this equivalent pre-compilation path so
|
|
59
|
+
the same sampled visual parameters reach every task model.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
arena = _StandaloneArena(xml)
|
|
63
|
+
apply_visual_scenario(arena, scenario)
|
|
64
|
+
return ET.tostring(arena.root, encoding="unicode")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Names are deliberately semantic rather than tied to one task. File-backed
|
|
68
|
+
# PNGs are preferred for the default sampling pools because they preserve the
|
|
69
|
+
# scale variation, grain, and small imperfections seen on real surfaces.
|
|
70
|
+
# Procedural entries remain as lightweight fallbacks for headless/minimal
|
|
71
|
+
# installations. The fourth value controls texrepeat (spatial scale).
|
|
72
|
+
PALETTES = {
|
|
73
|
+
# File-backed materials (MuJoCo currently consumes PNG Color maps). The
|
|
74
|
+
# robosuite images are vendored into this repository so sampling does not
|
|
75
|
+
# depend on a site-package path at runtime.
|
|
76
|
+
"realistic-tiles": ("file", "assets/materials/realistic/tiles074/Tiles074_2K_Color.png", "", "3 3"),
|
|
77
|
+
"realistic-wood": ("file", "assets/materials/realistic/woodfloor049/WoodFloor049_2K_Color.png", "", "2 8"),
|
|
78
|
+
"realistic-tile-wall": ("file", "assets/materials/realistic/tiles074/Tiles074_2K_Color.png", "", "2 2"),
|
|
79
|
+
"realistic-wood-panel": ("file", "assets/materials/realistic/woodfloor049/WoodFloor049_2K_Color.png", "", "1 5"),
|
|
80
|
+
"oak-wood-realistic": ("file", "assets/materials/realistic/common/oak-wood.png", "", "2 2"),
|
|
81
|
+
"walnut-wood-realistic": ("file", "assets/materials/realistic/common/walnut-wood.png", "", "2 2"),
|
|
82
|
+
"gray-woodgrain-realistic": ("file", "assets/materials/realistic/common/gray-woodgrain.png", "", "2 2"),
|
|
83
|
+
"varnished-wood-realistic": ("file", "assets/materials/realistic/common/varnished-wood-panels.png", "", "1 2"),
|
|
84
|
+
"ceramic-realistic": ("file", "assets/materials/realistic/common/ceramic.png", "", "2 2"),
|
|
85
|
+
"concrete-realistic": ("file", "assets/materials/realistic/common/light-gray-plaster.png", "", "2 2"),
|
|
86
|
+
"gray-carpet-realistic": ("file", "assets/materials/realistic/common/gray-felt.png", "", "3 3"),
|
|
87
|
+
"white-brick-realistic": ("file", "assets/materials/realistic/common/white-bricks.png", "", "2 2"),
|
|
88
|
+
"cream-plaster-realistic": ("file", "assets/materials/realistic/common/cream-plaster.png", "", "2 2"),
|
|
89
|
+
"brushed-steel-realistic": ("file", "assets/materials/realistic/common/steel-brushed.png", "", "4 2"),
|
|
90
|
+
"scratched-steel-realistic": ("file", "assets/materials/realistic/common/steel-scratched.png", "", "4 2"),
|
|
91
|
+
# Floors: common tile, wood, stone-like, rubber, carpet and linoleum.
|
|
92
|
+
# Legacy procedural entries are retained for compatibility. Profiles use
|
|
93
|
+
# only a small fraction of them as intentionally simple fallback surfaces.
|
|
94
|
+
"light-gray-floor-tile": ("file", "assets/materials/realistic/common/light-gray-floor-tile.png", "", "4 4"),
|
|
95
|
+
"wood-tiles": ("file", "assets/materials/realistic/common/wood-tiles.png", "", "2 2"),
|
|
96
|
+
"light-wood-parquet": ("file", "assets/materials/realistic/common/oak-wood.png", "", "2 5"),
|
|
97
|
+
"dark-wood-parquet": ("file", "assets/materials/realistic/common/varnished-wood-panels.png", "", "2 5"),
|
|
98
|
+
"slate-stone": ("file", "assets/materials/realistic/common/ceramic.png", "", "3 3"),
|
|
99
|
+
"sandstone": ("file", "assets/materials/realistic/common/cream-plaster.png", "", "3 3"),
|
|
100
|
+
"terrazzo": ("checker", ".72 .69 .61", ".42 .43 .40", "12 12"),
|
|
101
|
+
"rubber-mat": ("flat", ".07 .08 .08", ".07 .08 .08", "1 1"),
|
|
102
|
+
"blue-carpet": ("checker", ".10 .18 .26", ".16 .27 .37", "18 18"),
|
|
103
|
+
"cream-linoleum": ("gradient", ".82 .78 .68", ".62 .59 .52", "2 2"),
|
|
104
|
+
# Walls: paint/plaster, concrete, tile, brick and wood accents.
|
|
105
|
+
"white-plaster": ("file", "assets/materials/realistic/common/cream-plaster.png", "", "2 2"),
|
|
106
|
+
"cool-white-paint": ("file", "assets/materials/realistic/common/light-gray-plaster.png", "", "2 2"),
|
|
107
|
+
"gray-plaster": ("file", "assets/materials/realistic/common/gray-plaster.png", "", "2 2"),
|
|
108
|
+
"industrial-concrete": ("file", "assets/materials/realistic/common/light-gray-plaster.png", "", "3 3"),
|
|
109
|
+
"blue-plaster": ("flat", ".28 .46 .65", ".28 .46 .65", "1 1"),
|
|
110
|
+
"sage-plaster": ("flat", ".47 .58 .49", ".47 .58 .49", "1 1"),
|
|
111
|
+
"warm-plaster": ("flat", ".73 .57 .39", ".73 .57 .39", "1 1"),
|
|
112
|
+
"sand-plaster": ("gradient", ".78 .69 .54", ".56 .45 .32", "2 3"),
|
|
113
|
+
"muted-yellow-paint": ("flat", ".72 .65 .38", ".72 .65 .38", "1 1"),
|
|
114
|
+
"soft-pink-paint": ("flat", ".68 .46 .47", ".68 .46 .47", "1 1"),
|
|
115
|
+
"brick-accent": ("file", "assets/materials/realistic/common/white-bricks.png", "", "2 2"),
|
|
116
|
+
# Backwards-compatible aliases used by the desk randomizer.
|
|
117
|
+
"light-wood": ("file", "assets/materials/realistic/common/oak-wood.png", "", "2 2"),
|
|
118
|
+
"dark-wood": ("file", "assets/materials/realistic/common/walnut-wood.png", "", "2 2"),
|
|
119
|
+
"maple-wood": ("file", "assets/materials/realistic/common/oak-wood.png", "", "2 2"),
|
|
120
|
+
"walnut-wood": ("file", "assets/materials/realistic/common/walnut-wood.png", "", "2 2"),
|
|
121
|
+
"white-laminate": ("flat", ".76 .77 .74", ".76 .77 .74", "1 1"),
|
|
122
|
+
"black-laminate": ("flat", ".08 .09 .10", ".08 .09 .10", "1 1"),
|
|
123
|
+
"brushed-metal": ("file", "assets/materials/realistic/common/steel-brushed.png", "", "4 2"),
|
|
124
|
+
# Drawer fronts / interiors use a separate namespace in Profiles so the
|
|
125
|
+
# same sampled desk material can coexist with a contrasting cabinet.
|
|
126
|
+
"drawer-light-metal": ("file", "assets/materials/realistic/common/metal.png", "", "4 2"),
|
|
127
|
+
"drawer-dark-metal": ("file", "assets/materials/realistic/common/steel-scratched.png", "", "4 2"),
|
|
128
|
+
"drawer-white-laminate": ("flat", ".72 .73 .70", ".72 .73 .70", "1 1"),
|
|
129
|
+
"drawer-blue-metal": ("gradient", ".18 .31 .42", ".07 .13 .19", "16 3"),
|
|
130
|
+
"drawer-walnut": ("file", "assets/materials/realistic/common/walnut-wood.png", "", "2 2"),
|
|
131
|
+
}
|
|
132
|
+
# key RGB, fill RGB, ambient RGB, light count, key casts shadow
|
|
133
|
+
RIGS = {
|
|
134
|
+
"overcast": (".22 .25 .30", ".17 .19 .22", ".20 .22 .25", 4, False),
|
|
135
|
+
"warm_indoor": (".34 .25 .16", ".19 .15 .11", ".17 .14 .11", 3, False),
|
|
136
|
+
"daylight_side": (".48 .45 .38", ".12 .17 .23", ".12 .14 .17", 3, True),
|
|
137
|
+
"cool_office": (".27 .32 .39", ".17 .21 .27", ".17 .19 .23", 4, False),
|
|
138
|
+
"ceiling_pair": (".28 .28 .28", ".19 .19 .19", ".18 .18 .18", 3, False),
|
|
139
|
+
"hard_sun": (".75 .68 .50", ".05 .07 .10", ".08 .08 .08", 3, True),
|
|
140
|
+
"backlit": (".10 .14 .22", ".45 .38 .25", ".10 .12 .16", 3, True),
|
|
141
|
+
"mixed_daylight": (".55 .50 .42", ".25 .15 .08", ".22 .20 .17", 4, True),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def apply_visual_scenario(arena, scenario):
|
|
146
|
+
scenario = resolve_scenario(scenario)
|
|
147
|
+
ensure_workspace_camera(arena)
|
|
148
|
+
if scenario is None:
|
|
149
|
+
return
|
|
150
|
+
p = scenario.parameters
|
|
151
|
+
# Profile fields are grouped under ``appearance`` / ``runtime``; the
|
|
152
|
+
# sampler intentionally strips that grouping in the resolved Scenario,
|
|
153
|
+
# yielding ``parameters.room`` and ``parameters.lighting``. Older
|
|
154
|
+
# hand-authored scenarios use the same short form, so accept both layouts
|
|
155
|
+
# and guarantee that sampled values reach the compiled MuJoCo model.
|
|
156
|
+
appearance = p.get("appearance", {}) or {}
|
|
157
|
+
runtime = p.get("runtime", {}) or {}
|
|
158
|
+
room = appearance.get("room", p.get("room", {})) or {}
|
|
159
|
+
lighting = runtime.get("lighting", p.get("lighting", {})) or {}
|
|
160
|
+
desk = appearance.get("desk", p.get("desk", {})) or {}
|
|
161
|
+
drawer = appearance.get("drawer", p.get("drawer", {})) or {}
|
|
162
|
+
slot = appearance.get("slot", p.get("slot", {})) or {}
|
|
163
|
+
block = appearance.get("block", p.get("block", {})) or {}
|
|
164
|
+
layout = lighting.get("layout", "overcast")
|
|
165
|
+
if layout not in RIGS:
|
|
166
|
+
raise ValueError(f"Unknown lighting layout: {layout}")
|
|
167
|
+
scale = float(lighting.get("intensity_scale", 1))
|
|
168
|
+
azimuth = float(lighting.get("azimuth_deg", 45))
|
|
169
|
+
if not math.isfinite(scale) or not 0 < scale <= 2 or not math.isfinite(azimuth):
|
|
170
|
+
raise ValueError("Invalid lighting intensity / azimuth")
|
|
171
|
+
for parent in arena.worldbody.iter():
|
|
172
|
+
for child in list(parent):
|
|
173
|
+
# Task-local lights (for example button indicator point lights)
|
|
174
|
+
# are part of the interaction and must survive room-light
|
|
175
|
+
# randomization. Only replace the shared room rig here.
|
|
176
|
+
if (child.tag == "light" and not child.get("name", "").startswith("indicator_light_")) \
|
|
177
|
+
or child.get("name", "").startswith("room_ceiling"):
|
|
178
|
+
parent.remove(child)
|
|
179
|
+
visual = arena.root.find("visual")
|
|
180
|
+
if visual is None:
|
|
181
|
+
visual = ET.SubElement(arena.root, "visual")
|
|
182
|
+
head = visual.find("headlight")
|
|
183
|
+
if head is None:
|
|
184
|
+
head = ET.SubElement(visual, "headlight")
|
|
185
|
+
head.attrib.update(active="0", ambient="0 0 0", diffuse="0 0 0", specular="0 0 0")
|
|
186
|
+
key, fill, ambient, count, shadow = RIGS[layout]
|
|
187
|
+
def rgb(raw, factor=1):
|
|
188
|
+
return " ".join(str(float(v) * scale * factor) for v in raw.split())
|
|
189
|
+
for i in range(count):
|
|
190
|
+
a = math.radians(azimuth + i * 360 / count)
|
|
191
|
+
ET.SubElement(arena.worldbody, "light", name=f"visual_light_{i}",
|
|
192
|
+
pos=f"{3*math.cos(a)} {3*math.sin(a)} 4",
|
|
193
|
+
dir=f"{-math.cos(a)} {-math.sin(a)} -1", directional="true",
|
|
194
|
+
diffuse=rgb(key if i == 0 else fill),
|
|
195
|
+
ambient=rgb(ambient, 1.7 / count), specular="0 0 0",
|
|
196
|
+
castshadow="true" if i == 0 and shadow else "false")
|
|
197
|
+
def material(name, choice):
|
|
198
|
+
if choice not in PALETTES:
|
|
199
|
+
raise ValueError(f"Unknown texture palette: {choice}")
|
|
200
|
+
builtin, c1, c2, texrepeat = PALETTES[choice]
|
|
201
|
+
attrs = dict(name=name+"_tex", type="2d")
|
|
202
|
+
if builtin == "file":
|
|
203
|
+
# MuJoCo resolves file assets relative to the generated XML's
|
|
204
|
+
# compiler directory. Use a normalized absolute path here so
|
|
205
|
+
# this remains valid for both robosuite's temporary XML and
|
|
206
|
+
# direct MjModel.from_xml_string callers.
|
|
207
|
+
attrs["file"] = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", c1))
|
|
208
|
+
else: attrs.update(builtin=builtin, rgb1=c1, rgb2=c2, width="256", height="256")
|
|
209
|
+
ET.SubElement(arena.asset, "texture", **attrs)
|
|
210
|
+
ET.SubElement(arena.asset, "material", name=name, texture=name+"_tex",
|
|
211
|
+
texrepeat=texrepeat, texuniform="true", specular="0", reflectance="0")
|
|
212
|
+
material("sampled_floor", room.get("floor_texture", "light-gray-floor-tile"))
|
|
213
|
+
material("sampled_wall", room.get("wall_texture", "white-plaster"))
|
|
214
|
+
floors = [g for g in arena.worldbody.iter("geom") if g.get("name", "") in ("floor", "floor_visual", "floor_vis")]
|
|
215
|
+
for g in floors:
|
|
216
|
+
g.set("material", "sampled_floor"); g.set("rgba", "1 1 1 1")
|
|
217
|
+
if floors and not any(g.get("group") == "1" for g in floors):
|
|
218
|
+
g = deepcopy(floors[0]); g.set("name", "sampled_floor_visual")
|
|
219
|
+
g.set("group", "1"); g.set("contype", "0"); g.set("conaffinity", "0")
|
|
220
|
+
arena.worldbody.append(g)
|
|
221
|
+
walls = [g for g in arena.worldbody.iter("geom") if g.get("name", "").startswith("room_wall")]
|
|
222
|
+
if not walls:
|
|
223
|
+
# Legacy enclosures are visual only; no new task collisions.
|
|
224
|
+
for i, (pos, size) in enumerate((("0 -8 2", "8 .05 2"), ("0 8 2", "8 .05 2"), ("-8 0 2", ".05 8 2"), ("8 0 2", ".05 8 2"))):
|
|
225
|
+
walls.append(ET.SubElement(arena.worldbody, "geom", name=f"room_wall_visual_{i}", type="box", pos=pos, size=size, group="1", contype="0", conaffinity="0"))
|
|
226
|
+
for g in walls:
|
|
227
|
+
g.set("material", "sampled_wall"); g.set("rgba", "1 1 1 1")
|
|
228
|
+
desk_texture = desk.get("texture")
|
|
229
|
+
if desk_texture:
|
|
230
|
+
material("sampled_desk", desk_texture)
|
|
231
|
+
# Tasks use wood_mat, table_mat or pedestal_mat depending on whether
|
|
232
|
+
# the surface is a desk, a lab table, or a turntable pedestal. This
|
|
233
|
+
# is a visual-only replacement; all geom friction/contact attributes
|
|
234
|
+
# remain untouched.
|
|
235
|
+
desk_names = {"table", "table_top", "table_top_vis", "desk", "desk_top"}
|
|
236
|
+
for g in arena.worldbody.iter("geom"):
|
|
237
|
+
if g.get("material") in {"wood_mat", "table_mat", "pedestal_mat"} or g.get("name", "") in desk_names:
|
|
238
|
+
g.set("material", "sampled_desk")
|
|
239
|
+
drawer_texture = drawer.get("texture")
|
|
240
|
+
if drawer_texture:
|
|
241
|
+
material("sampled_drawer", drawer_texture)
|
|
242
|
+
# Restrict this replacement to drawer parts. In particular, do not
|
|
243
|
+
# recolour conveyor rails or other task-specific metal components.
|
|
244
|
+
for g in arena.worldbody.iter("geom"):
|
|
245
|
+
if g.get("name", "").startswith("drawer_"):
|
|
246
|
+
g.set("material", "sampled_drawer")
|
|
247
|
+
slot_texture = slot.get("texture")
|
|
248
|
+
if slot_texture:
|
|
249
|
+
material("sampled_slot", slot_texture)
|
|
250
|
+
for g in arena.worldbody.iter("geom"):
|
|
251
|
+
name = g.get("name", "")
|
|
252
|
+
if g.get("material") == "box_mat" or name.startswith("slot_"):
|
|
253
|
+
g.set("material", "sampled_slot")
|
|
254
|
+
g.set("rgba", "1 1 1 1")
|
|
255
|
+
block_texture = block.get("texture")
|
|
256
|
+
if block_texture:
|
|
257
|
+
material("sampled_block", block_texture)
|
|
258
|
+
for g in arena.worldbody.iter("geom"):
|
|
259
|
+
if g.get("material") == "block_mat" or g.get("name", "") == "block_visual":
|
|
260
|
+
g.set("material", "sampled_block")
|
|
261
|
+
g.set("rgba", "1 1 1 1")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Finite-sample, within-scenario pass metrics (never pool different scenarios)."""
|
|
2
|
+
from math import comb
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def pass_metrics(outcomes, ks):
|
|
6
|
+
outcomes = list(outcomes)
|
|
7
|
+
n, c = len(outcomes), sum(bool(x) for x in outcomes)
|
|
8
|
+
if not n:
|
|
9
|
+
raise ValueError('exam results must not be empty')
|
|
10
|
+
result = {'exam_episodes': n, 'pass_count': c, 'pass_rate': c/n,
|
|
11
|
+
'pass_at_k': {}, 'pass_all_k': {}}
|
|
12
|
+
for k in ks:
|
|
13
|
+
if type(k) is not int or not 1 <= k <= n:
|
|
14
|
+
raise ValueError('K must be an integer in [1, exam_episodes]')
|
|
15
|
+
result['pass_at_k'][str(k)] = 1 - comb(n-c, k)/comb(n, k)
|
|
16
|
+
result['pass_all_k'][str(k)] = comb(c, k)/comb(n, k)
|
|
17
|
+
return result
|