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,326 @@
|
|
|
1
|
+
"""Reusable evaluation schedules selected by TaskSpec.evaluation.
|
|
2
|
+
|
|
3
|
+
Custom protocol factories receive config and return an object with run(context).
|
|
4
|
+
The context supplies the generic runner, task factory and policy factory.
|
|
5
|
+
"""
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
import importlib
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from react_manip.core.aggregate import aggregate_results
|
|
13
|
+
from react_manip.core.runner import BenchmarkRunner, _call_with_supported_kwargs
|
|
14
|
+
from react_manip.core.timeouts import (
|
|
15
|
+
USE_DEFAULT_TASK_WALL_TIMEOUT,
|
|
16
|
+
resolve_task_wall_timeout_sec,
|
|
17
|
+
)
|
|
18
|
+
from .metrics import pass_metrics
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _positive(value, name, *, allow_zero=False):
|
|
22
|
+
if type(value) is not int or value < (0 if allow_zero else 1):
|
|
23
|
+
raise ValueError(f'{name} must be an integer >= {0 if allow_zero else 1}')
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class EvaluationContext:
|
|
29
|
+
spec: Any
|
|
30
|
+
factory: Callable
|
|
31
|
+
policy_factory: Callable
|
|
32
|
+
robot_id: str | None = None
|
|
33
|
+
seed: int = 0
|
|
34
|
+
scenario: Any = None
|
|
35
|
+
groups: int = 1
|
|
36
|
+
episodes: int | None = None
|
|
37
|
+
runner: Any = field(default_factory=BenchmarkRunner)
|
|
38
|
+
runner_options: dict = field(default_factory=dict)
|
|
39
|
+
on_result: Callable | None = None
|
|
40
|
+
viewer: Any = None
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
from react_manip.core.compatibility import resolve_task_robot
|
|
44
|
+
self.robot_id = resolve_task_robot(self.spec, self.robot_id)
|
|
45
|
+
|
|
46
|
+
def final_view(self, env, step, result):
|
|
47
|
+
if self.viewer is None:
|
|
48
|
+
return
|
|
49
|
+
hz = self.runner_options.get('control_hz') or self.spec.control_hz
|
|
50
|
+
horizon = self.runner_options.get('horizon_sec') or self.spec.horizon_sec
|
|
51
|
+
if result.done or step >= max(1, round(hz*horizon)):
|
|
52
|
+
from react_manip.core.observation import policy_observation
|
|
53
|
+
self.viewer.observe(env, policy_observation(result.observation, self.spec, env), result.info)
|
|
54
|
+
|
|
55
|
+
def emit(self, row):
|
|
56
|
+
if self.on_result:
|
|
57
|
+
self.on_result(row)
|
|
58
|
+
|
|
59
|
+
def runner_options_for_episode(self, task_deadline: float | None = None) -> dict:
|
|
60
|
+
"""Return runner options without the protocol-only task budget."""
|
|
61
|
+
|
|
62
|
+
options = {k: v for k, v in self.runner_options.items()
|
|
63
|
+
if k != 'task_wall_timeout_sec'}
|
|
64
|
+
if task_deadline is not None:
|
|
65
|
+
remaining = max(1e-6, task_deadline - time.perf_counter())
|
|
66
|
+
configured = options.get('episode_wall_timeout_sec')
|
|
67
|
+
if configured is None:
|
|
68
|
+
configured = getattr(self.runner, 'episode_wall_timeout_sec', None)
|
|
69
|
+
if configured is None:
|
|
70
|
+
configured = remaining
|
|
71
|
+
options['episode_wall_timeout_sec'] = min(float(configured), remaining)
|
|
72
|
+
return options
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class IndependentProtocol:
|
|
76
|
+
def __init__(self, config):
|
|
77
|
+
self.config = dict(config)
|
|
78
|
+
_positive(config.get('episodes', 3), 'episodes')
|
|
79
|
+
|
|
80
|
+
def run(self, context):
|
|
81
|
+
if context.groups != 1:
|
|
82
|
+
raise ValueError('--groups is only supported by grouped protocols')
|
|
83
|
+
n = context.episodes if context.episodes is not None else self.config.get('episodes', 3)
|
|
84
|
+
_positive(n, 'episodes')
|
|
85
|
+
rows = []
|
|
86
|
+
task_timeout = resolve_task_wall_timeout_sec(
|
|
87
|
+
context.spec.task_id,
|
|
88
|
+
context.runner_options.get('task_wall_timeout_sec', USE_DEFAULT_TASK_WALL_TIMEOUT),
|
|
89
|
+
)
|
|
90
|
+
task_started = time.perf_counter()
|
|
91
|
+
task_deadline = None if task_timeout is None else task_started + task_timeout
|
|
92
|
+
timed_out = False
|
|
93
|
+
for i in range(n):
|
|
94
|
+
if task_deadline is not None and time.perf_counter() >= task_deadline:
|
|
95
|
+
timed_out = True
|
|
96
|
+
break
|
|
97
|
+
if context.viewer:
|
|
98
|
+
context.viewer.begin_episode(context.spec.task_id, f'{context.spec.task_id}_episode_{i:04d}',
|
|
99
|
+
'evaluation', i, context.runner_options.get('control_hz') or context.spec.control_hz)
|
|
100
|
+
try:
|
|
101
|
+
result = context.runner.evaluate(
|
|
102
|
+
context.spec, context.factory, context.policy_factory(),
|
|
103
|
+
robot_id=context.robot_id, seed=context.seed+i,
|
|
104
|
+
env_kwargs={'scenario': deepcopy(context.scenario)} if context.scenario is not None else None,
|
|
105
|
+
observation_callback=context.viewer.observe if context.viewer else None,
|
|
106
|
+
step_callback=context.final_view if context.viewer else None,
|
|
107
|
+
**context.runner_options_for_episode(task_deadline))
|
|
108
|
+
finally:
|
|
109
|
+
if context.viewer:
|
|
110
|
+
context.viewer.end_episode()
|
|
111
|
+
row = result.to_dict()
|
|
112
|
+
rows.append(row)
|
|
113
|
+
context.emit(row)
|
|
114
|
+
if row.get('wall_clock_timeout'):
|
|
115
|
+
timed_out = True
|
|
116
|
+
break
|
|
117
|
+
return {'protocol': {**self.config, 'episodes': n}, 'episodes': rows,
|
|
118
|
+
'summary': aggregate_results(rows) if rows else None,
|
|
119
|
+
'valid': not timed_out and len(rows) == n and not any('error' in row for row in rows),
|
|
120
|
+
'wall_clock_timeout': timed_out,
|
|
121
|
+
'timeout_scope': 'task' if timed_out else None,
|
|
122
|
+
'task_wall_time_sec': time.perf_counter() - task_started,
|
|
123
|
+
'completed_episodes': len(rows),
|
|
124
|
+
'skipped_episodes': n - len(rows)}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class _EpisodePolicy:
|
|
128
|
+
"""Separate episode initialization from the legacy policy.reset contract."""
|
|
129
|
+
def __init__(self, policy, phase, index, seed):
|
|
130
|
+
self.policy, self.phase, self.index, self.seed = policy, phase, index, seed
|
|
131
|
+
self.policy_id = getattr(policy, 'policy_id', type(policy).__name__)
|
|
132
|
+
self.requires_geometry_truth = getattr(policy, 'requires_geometry_truth', False)
|
|
133
|
+
|
|
134
|
+
def set_action_dim(self, action_dim):
|
|
135
|
+
if hasattr(self.policy, 'set_action_dim'):
|
|
136
|
+
self.policy.set_action_dim(action_dim)
|
|
137
|
+
|
|
138
|
+
def reset(self, task_spec=None, **kwargs):
|
|
139
|
+
_call_with_supported_kwargs(self.policy.begin_episode, {
|
|
140
|
+
'phase': self.phase, 'episode_index': self.index,
|
|
141
|
+
'seed': self.seed, 'task_spec': task_spec})
|
|
142
|
+
|
|
143
|
+
def act(self, observation, info):
|
|
144
|
+
return _call_with_supported_kwargs(self.policy.act, {'observation': observation, 'info': info})
|
|
145
|
+
|
|
146
|
+
def end_episode(self, **feedback):
|
|
147
|
+
_call_with_supported_kwargs(self.policy.end_episode, {
|
|
148
|
+
**feedback, 'phase': self.phase, 'episode_index': self.index})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class PracticeExamProtocol:
|
|
152
|
+
def __init__(self, config):
|
|
153
|
+
self.config = deepcopy(config)
|
|
154
|
+
self.practice = _positive(config.get('practice_episodes'), 'practice_episodes', allow_zero=True)
|
|
155
|
+
self.exam = _positive(config.get('exam_episodes'), 'exam_episodes')
|
|
156
|
+
self.online = config.get('exam_adaptation') == 'online'
|
|
157
|
+
if config.get('exam_adaptation') not in ('frozen', 'online'):
|
|
158
|
+
raise ValueError('unsupported exam_adaptation')
|
|
159
|
+
scoring = config.get('scoring', {})
|
|
160
|
+
self.score_key = scoring.get('score_key') if self.online else None
|
|
161
|
+
if self.online:
|
|
162
|
+
if scoring.get('primary') != 'mean_exam_task_score' or not isinstance(self.score_key, str) or not self.score_key:
|
|
163
|
+
raise ValueError('online evaluation requires mean_exam_task_score and score_key')
|
|
164
|
+
elif scoring.get('primary') != 'exam_success_rate' or scoring.get('scale') != 100:
|
|
165
|
+
raise ValueError('practice_exam requires exam_success_rate scoring with scale=100')
|
|
166
|
+
self.ks = scoring.get('pass_k', [1])
|
|
167
|
+
if not isinstance(self.ks, list) or not self.ks or len(set(self.ks)) != len(self.ks):
|
|
168
|
+
raise ValueError('pass_k must be a nonempty list of distinct integers')
|
|
169
|
+
pass_metrics([False]*self.exam, self.ks)
|
|
170
|
+
|
|
171
|
+
def run(self, context):
|
|
172
|
+
if context.episodes is not None:
|
|
173
|
+
raise ValueError('--episodes cannot override task-owned practice/exam counts')
|
|
174
|
+
_positive(context.groups, 'groups')
|
|
175
|
+
if context.scenario is not None and context.groups != 1:
|
|
176
|
+
raise ValueError('a single --scenario requires --groups 1')
|
|
177
|
+
groups, all_rows = [], []
|
|
178
|
+
task_timeout = resolve_task_wall_timeout_sec(
|
|
179
|
+
context.spec.task_id,
|
|
180
|
+
context.runner_options.get('task_wall_timeout_sec', USE_DEFAULT_TASK_WALL_TIMEOUT),
|
|
181
|
+
)
|
|
182
|
+
task_started = time.perf_counter()
|
|
183
|
+
task_deadline = None if task_timeout is None else task_started + task_timeout
|
|
184
|
+
timed_out = False
|
|
185
|
+
for group_index in range(context.groups):
|
|
186
|
+
if task_deadline is not None and time.perf_counter() >= task_deadline:
|
|
187
|
+
timed_out = True
|
|
188
|
+
break
|
|
189
|
+
# A factory must return a fresh policy, so adaptation never crosses groups.
|
|
190
|
+
policy = context.policy_factory()
|
|
191
|
+
required = ('begin_episode', 'end_episode')
|
|
192
|
+
if not self.online:
|
|
193
|
+
required += ('get_adaptation_state', 'set_adaptation_state')
|
|
194
|
+
missing = [name for name in required if not callable(getattr(policy, name, None))]
|
|
195
|
+
if missing:
|
|
196
|
+
raise TypeError('practice_exam policy is missing: ' + ', '.join(missing))
|
|
197
|
+
group_seed = context.seed + group_index
|
|
198
|
+
# RGB is managed by the evaluator adapter. Native default offscreen
|
|
199
|
+
# contexts can open GLFW even for state-only/headless evaluation.
|
|
200
|
+
kwargs = {'seed': group_seed, 'robot_id': context.robot_id,
|
|
201
|
+
'has_offscreen_renderer': False, 'use_camera_obs': False}
|
|
202
|
+
if context.scenario is not None:
|
|
203
|
+
kwargs['scenario'] = deepcopy(context.scenario)
|
|
204
|
+
if context.runner_options.get('render'):
|
|
205
|
+
kwargs.update(has_renderer=True, has_offscreen_renderer=False, use_camera_obs=False)
|
|
206
|
+
env = _call_with_supported_kwargs(context.factory, kwargs)
|
|
207
|
+
rows = []
|
|
208
|
+
try:
|
|
209
|
+
scenario = getattr(env, 'scenario', None)
|
|
210
|
+
if scenario is None:
|
|
211
|
+
raise ValueError('practice_exam requires an environment exposing a fixed Scenario')
|
|
212
|
+
digest = scenario.digest
|
|
213
|
+
scenario_record = deepcopy(scenario.to_dict())
|
|
214
|
+
if scenario.task_id != context.spec.task_id:
|
|
215
|
+
raise ValueError('environment Scenario belongs to a different task')
|
|
216
|
+
if context.scenario is not None and digest != context.scenario.digest:
|
|
217
|
+
raise ValueError('environment did not preserve the requested Scenario')
|
|
218
|
+
snapshot = None
|
|
219
|
+
from react_manip.instances.variation import TASKS as VARIED_TASKS, construct_sampled_episode
|
|
220
|
+
varied = context.spec.task_id in VARIED_TASKS
|
|
221
|
+
if varied and (self.practice, self.exam) != (10,5):
|
|
222
|
+
raise ValueError('Episode variation requires the released 10/5 schedule')
|
|
223
|
+
for phase, count in (('practice', self.practice), ('exam', self.exam)):
|
|
224
|
+
if phase == 'exam' and not self.online:
|
|
225
|
+
snapshot = deepcopy(policy.get_adaptation_state())
|
|
226
|
+
for index in range(count):
|
|
227
|
+
if task_deadline is not None and time.perf_counter() >= task_deadline:
|
|
228
|
+
timed_out = True
|
|
229
|
+
break
|
|
230
|
+
if phase == 'exam' and not self.online:
|
|
231
|
+
policy.set_adaptation_state(deepcopy(snapshot))
|
|
232
|
+
# Distinct seeds for trial randomness, independent of scene sampling.
|
|
233
|
+
episode_seed = group_seed*1000003 + (0 if phase == 'practice' else self.practice) + index
|
|
234
|
+
variation = None
|
|
235
|
+
if varied:
|
|
236
|
+
next_env, variation = construct_sampled_episode(scenario, context.robot_id,
|
|
237
|
+
index + (0 if phase == 'practice' else self.practice), episode_seed,
|
|
238
|
+
render=bool(context.runner_options.get('render')))
|
|
239
|
+
env.close()
|
|
240
|
+
env = next_env
|
|
241
|
+
expected_digest = env.scenario.digest if varied else digest
|
|
242
|
+
session = _EpisodePolicy(policy, phase, index, episode_seed)
|
|
243
|
+
def check_scenario(current_env, step, result):
|
|
244
|
+
if current_env.scenario.digest != expected_digest:
|
|
245
|
+
raise ValueError('Scenario changed within a practice/exam group')
|
|
246
|
+
context.final_view(current_env, step, result)
|
|
247
|
+
if context.viewer:
|
|
248
|
+
context.viewer.begin_episode(context.spec.task_id,
|
|
249
|
+
f'{context.spec.task_id}_group_{group_index:04d}_{phase}_{index:04d}',
|
|
250
|
+
phase, index, context.runner_options.get('control_hz') or context.spec.control_hz)
|
|
251
|
+
try:
|
|
252
|
+
result = context.runner.evaluate(
|
|
253
|
+
context.spec, context.factory, session, env=env, stop_on_done=True,
|
|
254
|
+
robot_id=context.robot_id, seed=None if varied else episode_seed,
|
|
255
|
+
observation_callback=context.viewer.observe if context.viewer else None,
|
|
256
|
+
step_callback=check_scenario, score_key=self.score_key,
|
|
257
|
+
**context.runner_options_for_episode(task_deadline))
|
|
258
|
+
finally:
|
|
259
|
+
if context.viewer:
|
|
260
|
+
context.viewer.end_episode()
|
|
261
|
+
if env.scenario.digest != expected_digest:
|
|
262
|
+
raise ValueError('Scenario changed within a practice/exam group')
|
|
263
|
+
row = {**result.to_dict(), 'group_index': group_index,
|
|
264
|
+
'phase': phase, 'episode_index': index}
|
|
265
|
+
if varied:
|
|
266
|
+
row['episode_variation'] = variation
|
|
267
|
+
rows.append(row)
|
|
268
|
+
all_rows.append(row)
|
|
269
|
+
context.emit(row)
|
|
270
|
+
if row.get('wall_clock_timeout'):
|
|
271
|
+
timed_out = True
|
|
272
|
+
break
|
|
273
|
+
if self.online and "error" in row:
|
|
274
|
+
raise ValueError(row["error"])
|
|
275
|
+
if timed_out:
|
|
276
|
+
break
|
|
277
|
+
if timed_out:
|
|
278
|
+
break
|
|
279
|
+
exam_rows = [row for row in rows if row['phase'] == 'exam']
|
|
280
|
+
metrics = pass_metrics([row['passed'] for row in exam_rows], self.ks)
|
|
281
|
+
metrics['exam_scores'] = [row['episode_score'] for row in exam_rows] if self.online else None
|
|
282
|
+
metrics['score'] = sum(metrics['exam_scores'])/self.exam if self.online else 100*metrics['pass_rate']
|
|
283
|
+
groups.append({'group_index': group_index, 'seed': group_seed,
|
|
284
|
+
'scenario': scenario_record, 'episodes': rows, 'summary': metrics})
|
|
285
|
+
finally:
|
|
286
|
+
env.close()
|
|
287
|
+
if timed_out:
|
|
288
|
+
break
|
|
289
|
+
if timed_out:
|
|
290
|
+
return {
|
|
291
|
+
'protocol': self.config,
|
|
292
|
+
'groups': groups,
|
|
293
|
+
'episodes': all_rows,
|
|
294
|
+
'summary': None,
|
|
295
|
+
'valid': False,
|
|
296
|
+
'wall_clock_timeout': True,
|
|
297
|
+
'timeout_scope': 'task',
|
|
298
|
+
'task_wall_time_sec': time.perf_counter() - task_started,
|
|
299
|
+
'completed_episodes': len(all_rows),
|
|
300
|
+
'skipped_episodes': context.groups * (self.practice + self.exam) - len(all_rows),
|
|
301
|
+
}
|
|
302
|
+
count = len(groups)
|
|
303
|
+
summary = {'groups': count, 'exam_episodes': count*self.exam,
|
|
304
|
+
'pass_count': sum(g['summary']['pass_count'] for g in groups),
|
|
305
|
+
'pass_rate': sum(g['summary']['pass_rate'] for g in groups)/count,
|
|
306
|
+
'score': sum(g['summary']['score'] for g in groups)/count}
|
|
307
|
+
for key in ('pass_at_k', 'pass_all_k'):
|
|
308
|
+
summary[key] = {str(k): sum(g['summary'][key][str(k)] for g in groups)/count for k in self.ks}
|
|
309
|
+
return {'protocol': self.config, 'groups': groups, 'episodes': all_rows, 'summary': summary,
|
|
310
|
+
'valid': not any('error' in row for row in all_rows)}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def load_protocol(spec):
|
|
314
|
+
config = dict(spec.evaluation) or {'protocol': 'independent', 'episodes': 3}
|
|
315
|
+
name = config.get('protocol')
|
|
316
|
+
if name == 'independent':
|
|
317
|
+
return IndependentProtocol(config)
|
|
318
|
+
if name == 'practice_exam':
|
|
319
|
+
return PracticeExamProtocol(config)
|
|
320
|
+
if name == 'custom':
|
|
321
|
+
module, name = config['factory'].split(':', 1)
|
|
322
|
+
protocol = getattr(importlib.import_module(module), name)(config=config)
|
|
323
|
+
if not callable(getattr(protocol, 'run', None)):
|
|
324
|
+
raise TypeError('custom protocol must expose run(context)')
|
|
325
|
+
return protocol
|
|
326
|
+
raise ValueError(f'unknown evaluation protocol: {name!r}')
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Optional evaluator UI. Displays the policy's existing RGB, never steps the env."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SkipEpisode(Exception):
|
|
9
|
+
"""Raised by the interactive viewer when the current episode is skipped."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compose_policy_images(images):
|
|
13
|
+
"""Main image on the left, up to two wrist views on the right; no overlays."""
|
|
14
|
+
from PIL import Image
|
|
15
|
+
if not 1 <= len(images) <= 3:
|
|
16
|
+
raise ValueError('evaluation viewer requires one to three policy cameras')
|
|
17
|
+
names = list(images)
|
|
18
|
+
for name, frame in images.items():
|
|
19
|
+
if not re.fullmatch(r'[A-Za-z0-9_-]+', name):
|
|
20
|
+
raise ValueError('invalid policy camera ID')
|
|
21
|
+
if not isinstance(frame, np.ndarray) or frame.ndim != 3 or frame.shape[2] != 3 or frame.dtype != np.uint8:
|
|
22
|
+
raise ValueError('policy cameras must provide uint8 HWC RGB')
|
|
23
|
+
if len(names) == 1:
|
|
24
|
+
frame = images[names[0]]
|
|
25
|
+
return frame, {names[0]: (0, 0, frame.shape[1], frame.shape[0])}
|
|
26
|
+
width, height = 960, 480
|
|
27
|
+
canvas = np.zeros((height, width, 3), dtype=np.uint8)
|
|
28
|
+
boxes = [(0, 0, 640, 480), (640, 0, 320, 240), (640, 240, 320, 240)]
|
|
29
|
+
layout = {}
|
|
30
|
+
for name, (x, y, w, h) in zip(names, boxes):
|
|
31
|
+
source = Image.fromarray(images[name])
|
|
32
|
+
scale = min(w/source.width, h/source.height)
|
|
33
|
+
source = source.resize((max(1, round(source.width*scale)), max(1, round(source.height*scale))), Image.Resampling.LANCZOS)
|
|
34
|
+
ox, oy = x + (w-source.width)//2, y + (h-source.height)//2
|
|
35
|
+
canvas[oy:oy+source.height, ox:ox+source.width] = np.asarray(source)
|
|
36
|
+
layout[name] = (ox, oy, source.width, source.height)
|
|
37
|
+
return canvas, layout
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_dependencies(display, video):
|
|
41
|
+
try:
|
|
42
|
+
if display:
|
|
43
|
+
import pygame
|
|
44
|
+
if video:
|
|
45
|
+
import imageio.v2
|
|
46
|
+
import imageio_ffmpeg
|
|
47
|
+
imageio_ffmpeg.get_ffmpeg_exe()
|
|
48
|
+
except (ImportError, RuntimeError) as exc:
|
|
49
|
+
raise ImportError('Install dependencies: python -m pip install -r requirements.txt') from exc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EvaluationViewer:
|
|
53
|
+
def __init__(self, *, enabled=False, video_dir=None):
|
|
54
|
+
self.enabled = enabled
|
|
55
|
+
self.video_dir = Path(video_dir) if video_dir is not None else None
|
|
56
|
+
self.active = enabled or self.video_dir is not None
|
|
57
|
+
self.aborted = False
|
|
58
|
+
self.skipped = False
|
|
59
|
+
self.paused = False
|
|
60
|
+
self.debug = False
|
|
61
|
+
self.writer = None
|
|
62
|
+
self.camera_writers = {}
|
|
63
|
+
self.pg = None
|
|
64
|
+
self.frames = 0
|
|
65
|
+
|
|
66
|
+
def __enter__(self):
|
|
67
|
+
check_dependencies(self.enabled, self.video_dir is not None)
|
|
68
|
+
if self.video_dir is not None:
|
|
69
|
+
self.video_dir.mkdir(parents=True, exist_ok=False)
|
|
70
|
+
if self.enabled:
|
|
71
|
+
import pygame
|
|
72
|
+
self.pg = pygame
|
|
73
|
+
try:
|
|
74
|
+
pygame.display.init()
|
|
75
|
+
pygame.font.init()
|
|
76
|
+
self.screen = pygame.display.set_mode((1280, 720))
|
|
77
|
+
pygame.display.set_caption('CL-Manip evaluation | policy RGB')
|
|
78
|
+
self.font = pygame.font.SysFont('Arial', 18)
|
|
79
|
+
self.clock = pygame.time.Clock()
|
|
80
|
+
except BaseException:
|
|
81
|
+
pygame.display.quit()
|
|
82
|
+
pygame.font.quit()
|
|
83
|
+
raise
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def __exit__(self, *exc):
|
|
87
|
+
self.end_episode()
|
|
88
|
+
if self.pg:
|
|
89
|
+
self.pg.display.quit()
|
|
90
|
+
self.pg.font.quit()
|
|
91
|
+
|
|
92
|
+
def begin_episode(self, task, episode, phase, index, hz):
|
|
93
|
+
self.end_episode()
|
|
94
|
+
self.task, self.episode, self.phase = task, episode, phase
|
|
95
|
+
self.index, self.hz, self.frames = index, hz, 0
|
|
96
|
+
self.skipped = False
|
|
97
|
+
self.camera = None
|
|
98
|
+
self.cameras = ()
|
|
99
|
+
self.image_shapes = {}
|
|
100
|
+
self.layout = {}
|
|
101
|
+
self.instruction_scroll = 0
|
|
102
|
+
if self.video_dir is not None:
|
|
103
|
+
import imageio.v2 as imageio
|
|
104
|
+
# IDs are evaluator records, but never permit paths to escape output.
|
|
105
|
+
if Path(episode).name != episode or episode in ('.', '..'):
|
|
106
|
+
raise ValueError('invalid episode ID for video filename')
|
|
107
|
+
self.writer = imageio.get_writer(str(self.video_dir / (episode + '.mp4')),
|
|
108
|
+
fps=hz, codec='libx264', quality=8,
|
|
109
|
+
macro_block_size=1, ffmpeg_log_level='error')
|
|
110
|
+
|
|
111
|
+
def _events(self):
|
|
112
|
+
for event in self.pg.event.get():
|
|
113
|
+
if event.type == self.pg.QUIT or (event.type == self.pg.KEYDOWN and event.key == self.pg.K_ESCAPE):
|
|
114
|
+
self.aborted = True
|
|
115
|
+
raise RuntimeError('evaluation aborted by viewer; no valid protocol score')
|
|
116
|
+
if event.type == self.pg.KEYDOWN:
|
|
117
|
+
if event.key == self.pg.K_s:
|
|
118
|
+
self.skipped = True
|
|
119
|
+
raise SkipEpisode('episode skipped by viewer (S)')
|
|
120
|
+
if event.key == self.pg.K_SPACE:
|
|
121
|
+
self.paused = not self.paused
|
|
122
|
+
elif event.key == self.pg.K_DOWN:
|
|
123
|
+
self.instruction_scroll += 1
|
|
124
|
+
elif event.key == self.pg.K_UP:
|
|
125
|
+
self.instruction_scroll = max(0, self.instruction_scroll - 1)
|
|
126
|
+
elif event.key == self.pg.K_TAB:
|
|
127
|
+
self.debug = not self.debug
|
|
128
|
+
|
|
129
|
+
def _text(self, text, y, color=(230, 230, 230)):
|
|
130
|
+
# Wrap by rendered width, including long instructions without spaces.
|
|
131
|
+
line = ''
|
|
132
|
+
for char in str(text):
|
|
133
|
+
if char == '\n' or self.font.size(line + char)[0] > 300:
|
|
134
|
+
self.screen.blit(self.font.render(line, True, color), (970, y))
|
|
135
|
+
y += 21
|
|
136
|
+
line = ''
|
|
137
|
+
if y > 475:
|
|
138
|
+
return y
|
|
139
|
+
if char != '\n':
|
|
140
|
+
line += char
|
|
141
|
+
self.screen.blit(self.font.render(line, True, color), (970, y))
|
|
142
|
+
return y + 24
|
|
143
|
+
|
|
144
|
+
def _instruction(self, text, y):
|
|
145
|
+
lines, line = [], ''
|
|
146
|
+
for word in str(text).split():
|
|
147
|
+
candidate = (line + ' ' + word).strip()
|
|
148
|
+
if line and self.font.size(candidate)[0] > 300:
|
|
149
|
+
lines.append(line)
|
|
150
|
+
line = word
|
|
151
|
+
else:
|
|
152
|
+
line = candidate
|
|
153
|
+
if line:
|
|
154
|
+
lines.append(line)
|
|
155
|
+
visible = max(1, (480 - y) // 21)
|
|
156
|
+
self.instruction_scroll = min(self.instruction_scroll, max(0, len(lines) - visible))
|
|
157
|
+
for line in lines[self.instruction_scroll:self.instruction_scroll + visible]:
|
|
158
|
+
self.screen.blit(self.font.render(line, True, (230, 230, 230)), (970, y))
|
|
159
|
+
y += 21
|
|
160
|
+
|
|
161
|
+
def observe(self, env, observation, info):
|
|
162
|
+
images = observation.get('images', {})
|
|
163
|
+
frame, layout = compose_policy_images(images)
|
|
164
|
+
ids = tuple(images)
|
|
165
|
+
shapes = {k: list(v.shape) for k, v in images.items()}
|
|
166
|
+
if self.cameras and (ids != self.cameras or shapes != self.image_shapes):
|
|
167
|
+
raise ValueError('policy camera IDs/order/resolution changed during an episode')
|
|
168
|
+
if not self.cameras:
|
|
169
|
+
self.cameras, self.image_shapes, self.layout = ids, shapes, layout
|
|
170
|
+
self.camera = ids[0]
|
|
171
|
+
if self.writer is not None:
|
|
172
|
+
import imageio.v2 as imageio
|
|
173
|
+
for name in ids:
|
|
174
|
+
self.camera_writers[name] = imageio.get_writer(
|
|
175
|
+
str(self.video_dir / (self.episode + '.' + name + '.mp4')),
|
|
176
|
+
fps=self.hz, codec='libx264', quality=8,
|
|
177
|
+
macro_block_size=1, ffmpeg_log_level='error')
|
|
178
|
+
if self.enabled:
|
|
179
|
+
self._events()
|
|
180
|
+
while True:
|
|
181
|
+
self.screen.fill((24, 26, 30))
|
|
182
|
+
surface = self.pg.surfarray.make_surface(frame.transpose(1, 0, 2))
|
|
183
|
+
scale = min(960/frame.shape[1], 720/frame.shape[0])
|
|
184
|
+
size = (round(frame.shape[1]*scale), round(frame.shape[0]*scale))
|
|
185
|
+
origin = ((960-size[0])//2, (720-size[1])//2)
|
|
186
|
+
self.screen.blit(self.pg.transform.scale(surface, size), origin)
|
|
187
|
+
for name, (x, y, _, _) in layout.items():
|
|
188
|
+
label = self.font.render(name, True, (255,255,255), (24,26,30))
|
|
189
|
+
self.screen.blit(label, (origin[0]+round(x*scale)+4, origin[1]+round(y*scale)+4))
|
|
190
|
+
y = 12
|
|
191
|
+
values = [self.task, f'{self.phase} episode {self.index + 1}',
|
|
192
|
+
', '.join(self.cameras), f'Simulation: {float(env.sim.data.time):.2f} s',
|
|
193
|
+
'PAUSED' if self.paused else 'RUNNING',
|
|
194
|
+
'Space: pause / resume', 'S: skip episode',
|
|
195
|
+
'Tab: external debug view', 'Esc: abort evaluation', 'Up/Down: scroll instruction']
|
|
196
|
+
for key in ('total_score', 'score', 'success_state'):
|
|
197
|
+
if key in info:
|
|
198
|
+
values.append(f'{key}: {info[key]}')
|
|
199
|
+
for value in values:
|
|
200
|
+
if y > 475:
|
|
201
|
+
break
|
|
202
|
+
y = self._text(value, y)
|
|
203
|
+
self._instruction(info.get('language_prompt', ''), y)
|
|
204
|
+
if self.debug:
|
|
205
|
+
debug_frame = env.render_camera('room_overview', width=320, height=240)
|
|
206
|
+
surface = self.pg.surfarray.make_surface(debug_frame.transpose(1, 0, 2))
|
|
207
|
+
self.screen.blit(surface, (960, 480))
|
|
208
|
+
self.screen.blit(self.font.render('DEBUG - not policy input', True, (255, 200, 80)), (970, 480))
|
|
209
|
+
self.pg.display.flip()
|
|
210
|
+
if not self.paused:
|
|
211
|
+
break
|
|
212
|
+
self.clock.tick(30)
|
|
213
|
+
self._events()
|
|
214
|
+
self.clock.tick(self.hz)
|
|
215
|
+
if self.writer is not None:
|
|
216
|
+
self.writer.append_data(frame)
|
|
217
|
+
for name, writer in self.camera_writers.items():
|
|
218
|
+
writer.append_data(images[name])
|
|
219
|
+
self.frames += 1
|
|
220
|
+
|
|
221
|
+
def end_episode(self):
|
|
222
|
+
if self.writer is not None:
|
|
223
|
+
writer, self.writer = self.writer, None
|
|
224
|
+
try:
|
|
225
|
+
writer.close()
|
|
226
|
+
finally:
|
|
227
|
+
for camera_writer in self.camera_writers.values():
|
|
228
|
+
camera_writer.close()
|
|
229
|
+
self.camera_writers.clear()
|
|
230
|
+
(self.video_dir / (self.episode + '.json')).write_text(json.dumps({
|
|
231
|
+
'task_id': self.task, 'episode_id': self.episode, 'phase': self.phase,
|
|
232
|
+
'camera': self.camera, 'cameras': list(self.cameras),
|
|
233
|
+
'image_shapes': self.image_shapes, 'layout_xywh': self.layout,
|
|
234
|
+
'composite_video': self.episode + '.mp4',
|
|
235
|
+
'camera_videos': {n: self.episode + '.' + n + '.mp4' for n in self.cameras},
|
|
236
|
+
'fps': self.hz, 'frames': self.frames,
|
|
237
|
+
'image_source': 'same-tick policy RGB; composite plus separate views; no debug overlay',
|
|
238
|
+
'aborted': self.aborted,
|
|
239
|
+
'skipped': self.skipped,
|
|
240
|
+
}, indent=2) + '\n')
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Versioned, explicit task/episode instances (simulator imports are lazy)."""
|
|
2
|
+
from .records import digest, load_record, save_record, validate_record, validate_pair
|
|
3
|
+
from .public import InstanceBundle, create_instance, open_instance, task_score
|
|
4
|
+
|
|
5
|
+
__all__ = ['digest', 'load_record', 'save_record', 'validate_record', 'validate_pair',
|
|
6
|
+
'InstanceBundle', 'create_instance', 'open_instance', 'task_score']
|