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.
Files changed (99) hide show
  1. assets/materials/realistic/common/ceramic.png +0 -0
  2. assets/materials/realistic/common/cream-plaster.png +0 -0
  3. assets/materials/realistic/common/gray-felt.png +0 -0
  4. assets/materials/realistic/common/gray-plaster.png +0 -0
  5. assets/materials/realistic/common/gray-woodgrain.png +0 -0
  6. assets/materials/realistic/common/light-gray-floor-tile.png +0 -0
  7. assets/materials/realistic/common/light-gray-plaster.png +0 -0
  8. assets/materials/realistic/common/metal.png +0 -0
  9. assets/materials/realistic/common/oak-wood.png +0 -0
  10. assets/materials/realistic/common/steel-brushed.png +0 -0
  11. assets/materials/realistic/common/steel-scratched.png +0 -0
  12. assets/materials/realistic/common/varnished-wood-panels.png +0 -0
  13. assets/materials/realistic/common/walnut-wood.png +0 -0
  14. assets/materials/realistic/common/white-bricks.png +0 -0
  15. assets/materials/realistic/common/wood-tiles.png +0 -0
  16. assets/materials/realistic/tiles074/Tiles074_2K_Color.png +0 -0
  17. assets/materials/realistic/woodfloor049/WoodFloor049_2K_Color.png +0 -0
  18. configs/box_insertion/profile_default.json +204 -0
  19. react_manip/__init__.py +12 -0
  20. react_manip/asset_cli.py +18 -0
  21. react_manip/builtin.py +168 -0
  22. react_manip/cameras.py +301 -0
  23. react_manip/category_objects.py +248 -0
  24. react_manip/core/__init__.py +60 -0
  25. react_manip/core/aggregate.py +59 -0
  26. react_manip/core/compatibility.py +64 -0
  27. react_manip/core/contracts.py +42 -0
  28. react_manip/core/geometry.py +269 -0
  29. react_manip/core/language.py +116 -0
  30. react_manip/core/metrics.py +196 -0
  31. react_manip/core/observation.py +59 -0
  32. react_manip/core/protocol.py +91 -0
  33. react_manip/core/registry.py +94 -0
  34. react_manip/core/runner.py +498 -0
  35. react_manip/core/scenarios.py +110 -0
  36. react_manip/core/specs.py +203 -0
  37. react_manip/core/timeouts.py +44 -0
  38. react_manip/core/visual_randomization.py +261 -0
  39. react_manip/evaluation/__init__.py +4 -0
  40. react_manip/evaluation/metrics.py +17 -0
  41. react_manip/evaluation/protocols.py +326 -0
  42. react_manip/evaluation/viewer.py +240 -0
  43. react_manip/instances/__init__.py +6 -0
  44. react_manip/instances/candidate_bank.py +109 -0
  45. react_manip/instances/public.py +95 -0
  46. react_manip/instances/records.py +345 -0
  47. react_manip/instances/runtime.py +306 -0
  48. react_manip/instances/suite.py +231 -0
  49. react_manip/instances/trajectory.py +59 -0
  50. react_manip/instances/variation.py +118 -0
  51. react_manip/legacy.py +318 -0
  52. react_manip/policies/__init__.py +5 -0
  53. react_manip/policies/base.py +79 -0
  54. react_manip/rendering.py +58 -0
  55. react_manip/robot_registry.py +31 -0
  56. react_manip/robots/__init__.py +33 -0
  57. react_manip/robots/adapters.py +135 -0
  58. react_manip/robots/agilex_adapter.py +35 -0
  59. react_manip/robots/agilex_description.py +74 -0
  60. react_manip/robots/agilex_robosuite.py +246 -0
  61. react_manip/robots/arm_actions.py +17 -0
  62. react_manip/robots/base.py +119 -0
  63. react_manip/robots/catalog.py +18 -0
  64. react_manip/robots/legacy_agilex_robosuite.py +169 -0
  65. react_manip/robots/mobile_base.py +33 -0
  66. react_manip/robots/mounts.py +80 -0
  67. react_manip/robots/resolution.py +103 -0
  68. react_manip/robots/sawyer_robosuite.py +147 -0
  69. react_manip/robots/sensors.py +137 -0
  70. react_manip/utils/__init__.py +1 -0
  71. react_manip/visual_task.py +50 -0
  72. react_manip-0.1.0.dist-info/METADATA +446 -0
  73. react_manip-0.1.0.dist-info/RECORD +99 -0
  74. react_manip-0.1.0.dist-info/WHEEL +5 -0
  75. react_manip-0.1.0.dist-info/entry_points.txt +2 -0
  76. react_manip-0.1.0.dist-info/licenses/LICENSE +131 -0
  77. react_manip-0.1.0.dist-info/top_level.txt +5 -0
  78. scripts/add_phone_lean_to_bank.py +242 -0
  79. scripts/agilex_wheeled_builder.py +133 -0
  80. scripts/audit_task_solvability.py +150 -0
  81. scripts/build_agilex_assets.py +317 -0
  82. scripts/build_legacy_agilex_assets.py +244 -0
  83. scripts/evaluate_all_tasks.py +309 -0
  84. scripts/evaluate_policy.py +281 -0
  85. scripts/piperx_assets.lock.json +181 -0
  86. scripts/pygame_demo.py +360 -0
  87. scripts/pygame_task_debug.py +242 -0
  88. scripts/regenerate_instance_bank.py +192 -0
  89. scripts/remove_task_from_bank.py +103 -0
  90. scripts/select_task_instances.py +64 -0
  91. scripts/verify_curling_reachability.py +206 -0
  92. scripts/verify_piperx_tasks.py +81 -0
  93. tasks/box_insertion/__init__.py +3 -0
  94. tasks/box_insertion/assets/scene.xml +50 -0
  95. tasks/box_insertion/env.py +642 -0
  96. tasks/box_insertion/evaluation.json +16 -0
  97. tasks/box_insertion/instance_contract.json +321 -0
  98. tasks/box_insertion/scoring.py +23 -0
  99. 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']