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
react_manip/cameras.py ADDED
@@ -0,0 +1,301 @@
1
+ """Shared camera contract. World cameras are fixed; robot cameras are body mounted.
2
+
3
+ No rendering is performed unless explicitly requested. Legacy MJCF names stay
4
+ available, while canonical names have the same meaning in every task.
5
+ """
6
+ from dataclasses import dataclass
7
+ import xml.etree.ElementTree as ET
8
+ import numpy as np
9
+
10
+ FIXED_CAMERAS = ('workspace_oblique', 'workspace_top', 'room_overview')
11
+ LABELS = {'workspace_oblique': 'Workspace oblique', 'workspace_top': 'Workspace top',
12
+ 'room_overview': 'Room overview', 'basket_top': 'Basket top'}
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Workspace:
17
+ center: tuple = (0., 0., .75)
18
+ half_size: tuple = (.6, .8)
19
+ forward: tuple = (1., 0.)
20
+ room_half_size: tuple = (3., 3.)
21
+
22
+
23
+ # Task-level declarations, not robot geometry or hidden object state. The
24
+ # table envelope is refined from the compiled static support where available.
25
+ WORKSPACES = {
26
+ 'find_object_in_drawer': Workspace((.10, 0., .80), (.65, .65)),
27
+ 'find_object_in_drawer1': Workspace((.10, 0., .80), (.65, .65)),
28
+ 'find_object_in_drawer2': Workspace((-.10, 0., .48), (.80, .75)),
29
+ 'rope_hang': Workspace((.55, 0., .85), (.55, .55)),
30
+ 'pan_flip': Workspace((.6, 0., 1.0), (.45, .4)),
31
+ 'rail_ball_to_cup': Workspace((.7, 0., .65), (.65, .5)),
32
+ 'conveyor_grasp': Workspace((2., 0., .9), (.9, .3), (0., -1.), (3.3, 2.5)),
33
+ 'laptop_into_drawer': Workspace((.1, 0., .8), (.65, .65)),
34
+ 'box_insertion': Workspace((.25, 0., .78), (.45, .8)),
35
+ 'button_indicator_matching': Workspace((.25, 0., .78), (.45, .8)),
36
+ 'pen_strike': Workspace((0., 0., .75), (.5, .5)),
37
+ 'pitch_pot': Workspace((-.25, 0., .18), (.65, .65)),
38
+ 'paddle_juggle': Workspace((.15, 0., .8), (.7, .7)),
39
+ 'phone_power_on': Workspace((0., 0., .15), (.5, .5)),
40
+ 'turntable_gamble': Workspace((-.05, 0., .54), (.65, .65)),
41
+ 'ball_to_target_hole': Workspace((0., 0., .75), (.7, .7)),
42
+ 'cup_lid_matching': Workspace(), 'leveling_board': Workspace(),
43
+ 'ring_toss': Workspace(), 'phone_cup': Workspace(),
44
+ 'car_drive': Workspace((1.25, 0., .07), (1.25, 1.), (1., 0.), (5., 5.)),
45
+ # Local target view; the room view covers the entire curling lane.
46
+ 'curling_sweep': Workspace((6., 0., .68), (1., .8), (1., 0.), (7., 3.)),
47
+ 'curling_push': Workspace((2.85, 0., .65), (2.65, .55), (1., 0.), (4., 2.)),
48
+ }
49
+
50
+
51
+ def _vec(x):
52
+ return ' '.join(f'{float(v):.9g}' for v in x)
53
+
54
+
55
+ def look_at(position, target, up=(0., 0., 1.)):
56
+ """MuJoCo basis: -Z optical forward, +Y image-up, +X image-right."""
57
+ z = np.asarray(position, float) - target
58
+ z /= np.linalg.norm(z)
59
+ x = np.cross(up, z)
60
+ if np.linalg.norm(x) < 1e-8:
61
+ raise ValueError('camera up is parallel to viewing direction')
62
+ x /= np.linalg.norm(x)
63
+ return np.column_stack((x, np.cross(z, x), z))
64
+
65
+
66
+ def fixed_poses(workspace):
67
+ c = np.array(workspace.center, float)
68
+ forward = np.r_[workspace.forward, 0.]
69
+ forward /= np.linalg.norm(forward)
70
+ side = np.cross(forward, [0, 0, 1])
71
+ # Prefer 1.5 m above the surface; enlarge for broad native workspaces
72
+ # instead of silently cropping them or using extreme fisheye-like FOVs.
73
+ height = max(1.5, max(workspace.half_size) / np.tan(np.deg2rad(37.5)) * 1.12)
74
+ top = c + [0, 0, height]
75
+ distance = max(1.6, max(workspace.half_size) * 1.7)
76
+ oblique = c + forward * distance + side * distance * .25 + [0, 0, distance]
77
+ room_center = c.copy()
78
+ if workspace.center[0] == 2. and workspace.forward == (0., -1.):
79
+ room_center[1] = 1.0
80
+ rx, ry = workspace.room_half_size
81
+ room = room_center + np.array([rx * .8, ry * .8, max(2.4, .9 * max(rx, ry))])
82
+ return {'workspace_top': (top, look_at(top, c, forward), 75.),
83
+ 'workspace_oblique': (oblique, look_at(oblique, c), 65.),
84
+ 'room_overview': (room, look_at(room, room_center), 85.)}
85
+
86
+
87
+ def ensure_scene_cameras(arena, task_id=None):
88
+ """Insert canonical fixed cameras before compilation; retain old names."""
89
+ world = arena.worldbody
90
+ task_id = task_id or arena.root.get('model', '')
91
+ workspace = WORKSPACES.get(task_id, Workspace())
92
+ poses = fixed_poses(workspace)
93
+ # Always reserve the optional regional view for this multi-zone task.
94
+ if 'conveyor' in task_id:
95
+ poses['basket_top'] = (np.array([2., 1.3, 2.25]), np.eye(3), 65.)
96
+ existing = {c.get('name') for c in world.iter('camera')}
97
+ for name, (position, rotation, fovy) in poses.items():
98
+ if name not in existing:
99
+ ET.SubElement(world, 'camera', name=name, pos=_vec(position),
100
+ xyaxes=_vec(np.r_[rotation[:, 0], rotation[:, 1]]), fovy=str(fovy))
101
+
102
+
103
+ def ensure_robot_cameras(robot):
104
+ """Normalize imported names using embodiment-owned sensor parameters."""
105
+ from copy import deepcopy
106
+ from .robots.sensors import sensor_profile, install_declared_cameras
107
+ install_declared_cameras(robot)
108
+ profile = sensor_profile(robot)
109
+ prefix = robot.naming_prefix
110
+ entries = [(b, c) for b in robot.worldbody.iter('body') for c in b.findall('camera')]
111
+ existing = {c.get('name') for _, c in entries}
112
+ for body, camera in entries:
113
+ name = camera.get('name', '')
114
+ canonical = None
115
+ if name.endswith('_head_camera'):
116
+ canonical = prefix + 'head'
117
+ elif name.endswith('eye_in_hand'):
118
+ arm = 'left' if 'left' in name else 'right'
119
+ canonical = prefix + 'wrist_' + arm
120
+ elif name.endswith('overhead_camera'):
121
+ canonical = prefix + 'overhead_debug'
122
+ if canonical and canonical not in existing:
123
+ clone = deepcopy(camera)
124
+ clone.set('name', canonical)
125
+ if canonical.endswith('_head') and profile.head_xyaxes is not None:
126
+ # Fixed mount pitch, not target tracking. The legacy head
127
+ # name retains its original horizontal optical axis.
128
+ for attr in ('quat', 'euler', 'axisangle', 'zaxis'):
129
+ clone.attrib.pop(attr, None)
130
+ clone.set('xyaxes', profile.head_xyaxes)
131
+ # The imported mast visual encloses the old x=.16 sensor.
132
+ # A small forward bracket puts the optical centre outside it.
133
+ position = np.fromstring(clone.get('pos', '0 0 0'), sep=' ')
134
+ if profile.head_min_x is not None:
135
+ position[0] = max(profile.head_min_x, position[0])
136
+ clone.set('pos', _vec(position))
137
+ camera.set('pos', _vec(position))
138
+ elif '_wrist_' in canonical and profile.wrist_target is not None:
139
+ # Mount the sensor on the eef rear/top face. The imported
140
+ # asset used a +Y side offset, which made the view look
141
+ # rolled by roughly 90 degrees. Use an explicit local
142
+ # position when the embodiment declares one, then aim at a
143
+ # point 6 cm beyond TCP with a tool-frame up vector.
144
+ position = (np.asarray(profile.wrist_position, dtype=float)
145
+ if profile.wrist_position is not None else
146
+ np.fromstring(clone.get('pos', '0 0 0'), sep=' '))
147
+ rotation = look_at(position, np.array(profile.wrist_target), profile.wrist_up)
148
+ for attr in ('quat', 'euler', 'axisangle', 'zaxis'):
149
+ clone.attrib.pop(attr, None)
150
+ camera.attrib.pop(attr, None)
151
+ camera.set('pos', _vec(position))
152
+ clone.set('pos', _vec(position))
153
+ clone.set('xyaxes', _vec(np.r_[rotation[:, 0], rotation[:, 1]]))
154
+ camera.set('xyaxes', clone.get('xyaxes'))
155
+ body.append(clone)
156
+ existing.add(canonical)
157
+
158
+
159
+ def raw_sim(env):
160
+ sim = getattr(env, 'sim', env)
161
+ model = getattr(sim.model, '_model', sim.model)
162
+ data = getattr(sim.data, '_data', sim.data)
163
+ return sim, model, data
164
+
165
+
166
+ def configure_fixed_cameras(env):
167
+ """Called after reset, never during step: no tracking or hidden-state input."""
168
+ import mujoco
169
+ _, model, data = raw_sim(env)
170
+ module = type(env).__module__.split('.')
171
+ task_id = getattr(env, 'task_id', module[1] if len(module) > 1 else '')
172
+ w = getattr(env, 'camera_workspace', WORKSPACES.get(task_id, Workspace()))
173
+ if isinstance(w, dict):
174
+ w = Workspace(**w)
175
+ center, half = np.array(w.center), np.array(w.half_size)
176
+ # Read only static table supports, not the randomly placed target objects.
177
+ if task_id != 'conveyor_grasp':
178
+ for name in ('table', 'table_top', 'table_top_col', 'table_collision'):
179
+ gid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, name)
180
+ if gid >= 0 and model.geom_type[gid] == mujoco.mjtGeom.mjGEOM_BOX:
181
+ extent = np.abs(data.geom_xmat[gid].reshape(3, 3)) @ model.geom_size[gid]
182
+ center = data.geom_xpos[gid].copy() + [0, 0, extent[2]]
183
+ half = extent[:2] * 1.08
184
+ break
185
+ if task_id == 'pitch_pot':
186
+ bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, 'pot')
187
+ if bid >= 0:
188
+ center = data.xpos[bid].copy() + [0, 0, .18]
189
+ half = np.array([.65, .65])
190
+ forward = np.array(w.forward)
191
+ robots = getattr(env, 'robots', ())
192
+ if robots:
193
+ root = robots[0].robot_model.root_body
194
+ bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, root)
195
+ if bid >= 0:
196
+ direction = center[:2] - data.xpos[bid, :2]
197
+ if np.linalg.norm(direction) > .1:
198
+ forward = direction / np.linalg.norm(direction)
199
+ poses = fixed_poses(Workspace(tuple(center), tuple(half), tuple(forward), w.room_half_size))
200
+ if robots and bid >= 0:
201
+ anchors = [center[:2] - half, center[:2] + half, data.xpos[bid, :2]]
202
+ if hasattr(env, 'bin_centers'):
203
+ anchors.extend(np.asarray(env.bin_centers)[:, :2])
204
+ anchors = np.asarray(anchors)
205
+ room_center = np.r_[(anchors.min(axis=0) + anchors.max(axis=0)) / 2, center[2]]
206
+ rx, ry = w.room_half_size
207
+ room_pos = room_center + [rx * .8, ry * .8, max(2.4, .9 * max(rx, ry))]
208
+ poses['room_overview'] = (room_pos, look_at(room_pos, room_center), 85.)
209
+ if task_id == 'car_drive':
210
+ ids = [i for i in range(model.ngeom) if (mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, i) or '').startswith('road_')]
211
+ if ids:
212
+ route_center = data.geom_xpos[ids].mean(axis=0)
213
+ room_pos = route_center + [4, -5, 5]
214
+ poses['room_overview'] = (room_pos, look_at(room_pos, route_center), 85.)
215
+ if hasattr(env, 'bin_centers'):
216
+ bins = np.asarray(env.bin_centers)
217
+ c = bins.mean(axis=0)
218
+ p = c + [0, 0, 1.5]
219
+ poses['basket_top'] = (p, look_at(p, c, np.r_[forward, 0]), 65.)
220
+ # Optional task-owned static framing, reconstructed from scene dimensions.
221
+ # Preserve it through both factory setup and hard resets.
222
+ poses.update(getattr(env, 'fixed_camera_poses', {}))
223
+ for name, (position, rotation, fovy) in poses.items():
224
+ cid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, name)
225
+ if cid < 0:
226
+ raise ValueError(f'{task_id}: missing canonical camera {name}')
227
+ model.cam_pos[cid] = position
228
+ mujoco.mju_mat2Quat(model.cam_quat[cid], rotation.flatten())
229
+ model.cam_fovy[cid] = fovy
230
+ mujoco.mj_forward(model, data)
231
+
232
+
233
+ def camera_specs(env):
234
+ import mujoco
235
+ _, model, _ = raw_sim(env)
236
+ result = []
237
+ for i in range(model.ncam):
238
+ name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_CAMERA, i)
239
+ canonical = name in FIXED_CAMERAS or name == 'basket_top' or name.endswith(('_head', '_wrist_right', '_wrist_left', '_overhead_debug'))
240
+ role = 'fixed' if model.cam_bodyid[i] == 0 else 'mounted'
241
+ default_policy = name in ('workspace_top', 'workspace_oblique') or name.endswith(('_head', '_wrist_right', '_wrist_left'))
242
+ result.append(dict(id=name, label=LABELS.get(name, name.replace('_head', ' head').replace('_wrist_right', ' right wrist').replace('_wrist_left', ' left wrist')),
243
+ canonical=canonical, display_default=canonical and not name.endswith('_overhead_debug'),
244
+ mounting=role, parent_body=mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, int(model.cam_bodyid[i])),
245
+ width=640, height=480, fovy=float(model.cam_fovy[i]), policy_default=default_policy,
246
+ calibrated=False, image_format='RGB uint8 HWC, top-left origin'))
247
+ def order(s):
248
+ n = s['id']
249
+ if n in FIXED_CAMERAS:
250
+ return (FIXED_CAMERAS.index(n), n)
251
+ return (3 if n.endswith('_head') else 4 if n.endswith('_wrist_right') else 5 if n.endswith('_wrist_left') else 6, n)
252
+ return sorted(result, key=order)
253
+
254
+
255
+ def display_camera_names(env, *, include_legacy=False):
256
+ """Standard operator views; retain legacy/debug cameras only by opt-in.
257
+
258
+ An unmigrated environment with no standard views keeps its original views.
259
+ This selection does not change model cameras or policy observations.
260
+ """
261
+ specs = camera_specs(env)
262
+ selected = [s['id'] for s in specs if s['display_default']]
263
+ if include_legacy or not selected:
264
+ return [s['id'] for s in specs]
265
+ return selected
266
+
267
+
268
+ # Fixed mounts, expressed in the robot mount frame (+X forward, +Z up).
269
+ # Task profiles describe static workspace framing, never target tracking.
270
+ POLICY_CAMERA = 'robot0_head_camera'
271
+ EGO_PROFILES = {
272
+ 'car_drive2': (50., 100.),
273
+ 'curling_push': (40., 80.),
274
+ 'conveyor_grasp': (70., 100.),
275
+ 'tilte_maze': (65., 60.),
276
+ # These two tasks use a tall vertical workspace. The generic 60-degree
277
+ # downward pitch puts the pan's toss arc and the ring-toss far row against
278
+ # (or beyond) the top edge of the 4:3 policy image. Lower pitch means the
279
+ # optical axis is tilted upward while preserving the robot-mounted pose.
280
+ 'pan_flip': (40., 85.),
281
+ 'ring_toss1': (50., 80.),
282
+ }
283
+
284
+
285
+ def configure_policy_camera(env):
286
+ """Pitch the robot main sensor; preserve its body attachment."""
287
+ import mujoco
288
+ _, model, data = raw_sim(env)
289
+ module = type(env).__module__.split('.')
290
+ task_id = getattr(env, 'task_id', module[1] if len(module) > 1 else '')
291
+ pitch, fovy = EGO_PROFILES.get(task_id, (60., 80.))
292
+ angle = np.deg2rad(pitch)
293
+ rotation = look_at(np.zeros(3), np.array([np.cos(angle), 0., -np.sin(angle)]))
294
+ for name in (POLICY_CAMERA, 'robot0_head'):
295
+ cid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, name)
296
+ if cid < 0:
297
+ continue
298
+ if model.cam_bodyid[cid] == 0:
299
+ raise ValueError(f'{name} must be mounted on the robot')
300
+ mujoco.mju_mat2Quat(model.cam_quat[cid], rotation.flatten())
301
+ model.cam_fovy[cid] = fovy
@@ -0,0 +1,248 @@
1
+ """Shared category-object assets and collision proxies, independent of tasks."""
2
+ from pathlib import Path
3
+ from typing import Any
4
+ import json
5
+ import xml.etree.ElementTree as ET
6
+ import numpy as np
7
+
8
+ OBJECT_LINEAR_SCALE = 1.55
9
+ OBJECT_CATEGORY_NAMES = (
10
+ "footwear",
11
+ "drinkware",
12
+ "electronics",
13
+ "toys",
14
+ "kitchen_tools",
15
+ "office_supplies",
16
+ "personal_care",
17
+ "planters",
18
+ )
19
+ CATEGORY_VARIANTS = {
20
+ category: tuple(range(10)) for category in OBJECT_CATEGORY_NAMES
21
+ }
22
+ CATEGORY_COLORS = (
23
+ (0.78, 0.16, 0.10, 1.0),
24
+ (0.10, 0.58, 0.22, 1.0),
25
+ (0.12, 0.30, 0.82, 1.0),
26
+ (0.66, 0.18, 0.72, 1.0),
27
+ (0.84, 0.48, 0.08, 1.0),
28
+ (0.10, 0.62, 0.66, 1.0),
29
+ (0.76, 0.28, 0.48, 1.0),
30
+ (0.38, 0.56, 0.18, 1.0),
31
+ )
32
+ # Keep the existing shared download cache and attribution paths intact.
33
+ ASSET_ROOT = Path(__file__).resolve().parents[1] / "assets/category_drawer_sorting"
34
+ ASSET_MANIFEST = ASSET_ROOT / "manifest.json"
35
+
36
+
37
+ def _load_asset_manifest() -> dict[str, Any]:
38
+ if not ASSET_MANIFEST.is_file():
39
+ return {"categories": {}}
40
+ return json.loads(ASSET_MANIFEST.read_text(encoding="utf-8"))
41
+
42
+
43
+ _EXTERNAL_ASSET_MANIFEST = _load_asset_manifest()
44
+
45
+
46
+ def _external_asset(category: int, variant: int) -> dict[str, Any] | None:
47
+ category_name = OBJECT_CATEGORY_NAMES[category]
48
+ entries = _EXTERNAL_ASSET_MANIFEST.get("categories", {}).get(category_name, ())
49
+ if variant >= len(entries):
50
+ return None
51
+ asset_id = str(entries[variant])
52
+ directory = ASSET_ROOT / "gso" / asset_id
53
+ metadata_path = directory / "cl_manip_asset.json"
54
+ if not metadata_path.is_file():
55
+ return None
56
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
57
+ mesh = directory / str(metadata.get("visual_mesh", "meshes/model.obj"))
58
+ texture = directory / str(metadata.get("texture", "materials/textures/texture.png"))
59
+ if not mesh.is_file() or not texture.is_file():
60
+ return None
61
+ return {**metadata, "mesh_path": mesh, "texture_path": texture}
62
+
63
+
64
+ def _vec(values) -> str:
65
+ return " ".join(f"{float(value):.6g}" for value in values)
66
+
67
+
68
+ def _variant_spec(category: int, variant: int) -> dict[str, Any]:
69
+ """Return one of ten deterministic, graspable variants per category."""
70
+
71
+ if not 0 <= category < len(OBJECT_CATEGORY_NAMES):
72
+ raise ValueError(f"invalid category index: {category}")
73
+ if not 0 <= variant < 10:
74
+ raise ValueError(f"variant must be in [0, 9], got {variant}")
75
+ scale = 0.86 + 0.028 * variant
76
+ aspect = 0.88 + 0.06 * (variant % 3)
77
+ height = 0.024 + 0.0015 * (variant % 5)
78
+ rgba = np.asarray(CATEGORY_COLORS[category], dtype=float).copy()
79
+ rgba[:3] *= 0.86 + 0.025 * (variant % 6)
80
+ if category == 0: # footwear fallback: elongated sole plus rounded toe
81
+ long = 0.040 * scale
82
+ width = 0.022 * aspect
83
+ half_height = 0.018 + 0.001 * (variant % 4)
84
+ toe = 0.019 + 0.0008 * (variant % 5)
85
+ geoms = [
86
+ ("box", (long, width, half_height), (-0.006, 0.0, 0.0), None),
87
+ ("sphere", (toe,), (long - 0.004, 0.0, 0.003), None),
88
+ ]
89
+ extent = np.array([long + toe, max(width, toe), max(half_height, toe + 0.003)])
90
+ elif category == 1: # drinkware fallback: upright canister
91
+ radius = 0.030 * scale
92
+ half_height = 0.030 + 0.0015 * (variant % 4)
93
+ geoms = [("cylinder", (radius, half_height), (0.0, 0.0, 0.0), None)]
94
+ extent = np.array([radius, radius, half_height])
95
+ elif category == 2: # electronics fallback: flat device with raised control area
96
+ long = 0.043 * scale
97
+ width = 0.030 * aspect
98
+ half_height = 0.014 + 0.001 * (variant % 4)
99
+ geoms = [
100
+ ("box", (long, width, half_height), (0.0, 0.0, 0.0), None),
101
+ ("box", (long * 0.45, width * 0.55, 0.004), (0.006, 0.0, half_height + 0.004), None),
102
+ ]
103
+ extent = np.array([long, width, half_height + 0.008])
104
+ elif category == 3: # toys fallback: stable dumbbell with rounded end caps
105
+ long = 0.040 * scale
106
+ radius = 0.018 + 0.001 * (variant % 4)
107
+ half_height = 0.023 + 0.001 * (variant % 3)
108
+ geoms = [
109
+ ("box", (long, 0.013, half_height), (0.0, 0.0, 0.0), None),
110
+ ("sphere", (radius,), (-long, 0.0, 0.006), None),
111
+ ("sphere", (radius,), (long, 0.0, 0.006), None),
112
+ ]
113
+ extent = np.array([long + radius, radius, max(half_height, radius + 0.006)])
114
+ elif category == 4: # kitchen tools: elongated handle with a broad head
115
+ long = 0.042 * scale
116
+ width = 0.013 + 0.001 * (variant % 3)
117
+ half_height = 0.012 + 0.001 * (variant % 4)
118
+ geoms = [
119
+ ("box", (long, width, half_height), (0.0, 0.0, 0.0), None),
120
+ ("sphere", (width * 1.45,), (long, 0.0, 0.002), None),
121
+ ]
122
+ extent = np.array([long + width * 1.45, width * 1.45, max(half_height, width * 1.45)])
123
+ elif category == 5: # office supplies: compact trays, holders and cartridges
124
+ long = 0.038 * scale
125
+ width = 0.029 * aspect
126
+ half_height = 0.020 + 0.0015 * (variant % 4)
127
+ geoms = [
128
+ ("box", (long, width, half_height), (0.0, 0.0, 0.0), None),
129
+ ("box", (long * 0.72, width * 0.65, 0.004), (0.0, 0.0, half_height + 0.004), None),
130
+ ]
131
+ extent = np.array([long, width, half_height + 0.008])
132
+ elif category == 6: # personal care: bottle-like containers
133
+ radius = 0.022 + 0.001 * (variant % 4)
134
+ half_height = 0.034 * scale
135
+ geoms = [
136
+ ("cylinder", (radius, half_height), (0.0, 0.0, 0.0), None),
137
+ ("cylinder", (radius * 0.52, 0.006), (0.0, 0.0, half_height + 0.006), None),
138
+ ]
139
+ extent = np.array([radius, radius, half_height + 0.012])
140
+ else: # planters: squat, stable containers
141
+ radius = 0.031 * scale
142
+ half_height = 0.027 + 0.0015 * (variant % 4)
143
+ geoms = [
144
+ ("cylinder", (radius, half_height), (0.0, 0.0, 0.0), None),
145
+ ("cylinder", (radius * 1.08, 0.004), (0.0, 0.0, half_height + 0.004), None),
146
+ ]
147
+ extent = np.array([radius * 1.08, radius * 1.08, half_height + 0.008])
148
+ # Normalize the horizontal grasp width, leaving clearance inside an
149
+ # 8 cm parallel gripper. Uniform scaling preserves primitive geometry.
150
+ scaled_extent = np.asarray(extent, dtype=float) * OBJECT_LINEAR_SCALE
151
+ grasp_width = 2.0 * float(np.min(scaled_extent[:2]))
152
+ grasp_scale = float(np.clip(grasp_width, 0.045, 0.060) / grasp_width)
153
+ object_scale = OBJECT_LINEAR_SCALE * grasp_scale
154
+ geoms = [
155
+ (
156
+ geom_type,
157
+ tuple(np.asarray(size, dtype=float) * object_scale),
158
+ tuple(np.asarray(offset, dtype=float) * object_scale),
159
+ quat,
160
+ )
161
+ for geom_type, size, offset, quat in geoms
162
+ ]
163
+ return {
164
+ "geoms": geoms,
165
+ "extent": np.asarray(extent, dtype=float) * object_scale,
166
+ "rgba": rgba,
167
+ }
168
+
169
+
170
+ def _add_geom(parent, *, name: str, geom_type: str, size, pos, rgba, collision: bool):
171
+ attrs = {
172
+ "name": name,
173
+ "type": geom_type,
174
+ "size": _vec(size),
175
+ "pos": _vec(pos),
176
+ "rgba": _vec(rgba),
177
+ "friction": "0.8 0.01 0.001",
178
+ # 1 m drops reach ~4.4 m/s: the default 20 ms soft response can
179
+ # penetrate a 5 cm tabletop. 5 ms remains above 2 physics steps.
180
+ "solref": "0.005 1",
181
+ # Include torsional and rolling resistance, not just sliding friction.
182
+ "condim": "6" if collision else "3",
183
+ "group": "0" if collision else "1",
184
+ "contype": "1" if collision else "0",
185
+ "conaffinity": "1" if collision else "0",
186
+ "density": "320" if collision else "0",
187
+ }
188
+ return ET.SubElement(parent, "geom", **attrs)
189
+
190
+
191
+ def _add_object(asset, world, *, name: str, category: int, variant: int, pos) -> np.ndarray:
192
+ spec = _variant_spec(category, variant)
193
+ body = ET.SubElement(world, "body", name=name, pos=_vec(pos))
194
+ ET.SubElement(body, "freejoint", name=f"{name}_free")
195
+ external = _external_asset(category, variant)
196
+ for index, (geom_type, size, offset, _quat) in enumerate(spec["geoms"]):
197
+ _add_geom(
198
+ body, name=f"{name}_collision_{index}", geom_type=geom_type,
199
+ size=size, pos=offset, rgba=spec["rgba"], collision=True,
200
+ )
201
+ if external is None:
202
+ _add_geom(
203
+ body, name=f"{name}_visual_{index}", geom_type=geom_type,
204
+ size=size, pos=offset, rgba=spec["rgba"], collision=False,
205
+ )
206
+ if spec["geoms"][0][0] == "cylinder":
207
+ # A flat, inset foot supports upright containers with stable box/box
208
+ # contacts. The analytic rim otherwise rocks on changing CCD points.
209
+ # Extend only 1 mm below the rim; preserve the visible cylinder body.
210
+ radius, half_height = spec["geoms"][0][1]
211
+ foot_half_width = radius * 0.65
212
+ _add_geom(
213
+ body, name=f"{name}_collision_foot", geom_type="box",
214
+ size=(foot_half_width, foot_half_width, 0.001),
215
+ pos=(0, 0, -half_height), rgba=spec["rgba"], collision=True,
216
+ )
217
+ if external is not None:
218
+ minimum = np.asarray(external["bounds_min"], dtype=float)
219
+ maximum = np.asarray(external["bounds_max"], dtype=float)
220
+ source_size = maximum - minimum
221
+ target_size = 2.0 * np.asarray(spec["extent"], dtype=float) * 0.96
222
+ # Fit each axis to the physical proxy. An inscribed uniform fit can
223
+ # make a thin scan nearly invisible inside a much larger collider.
224
+ mesh_scale = target_size / source_size
225
+ source_center = (minimum + maximum) / 2.0
226
+ mesh_name = f"{name}_scan_mesh"
227
+ texture_name = f"{name}_scan_texture"
228
+ material_name = f"{name}_scan_material"
229
+ ET.SubElement(
230
+ asset, "mesh", name=mesh_name,
231
+ file=str(external["mesh_path"].resolve()),
232
+ scale=_vec(mesh_scale),
233
+ )
234
+ ET.SubElement(
235
+ asset, "texture", name=texture_name, type="2d",
236
+ file=str(external["texture_path"].resolve()),
237
+ )
238
+ ET.SubElement(
239
+ asset, "material", name=material_name, texture=texture_name,
240
+ texuniform="false", specular="0.05", reflectance="0",
241
+ )
242
+ ET.SubElement(
243
+ body, "geom", name=f"{name}_scan_visual", type="mesh",
244
+ mesh=mesh_name, material=material_name,
245
+ pos=_vec(-source_center * mesh_scale),
246
+ group="1", contype="0", conaffinity="0", density="0",
247
+ )
248
+ return np.asarray(spec["extent"], dtype=float)
@@ -0,0 +1,60 @@
1
+ """Public API for the extensible, fixed-horizon react_manip.
2
+
3
+ The modules in :mod:`react_manip.core` deliberately do not import MuJoCo or
4
+ robosuite. This keeps the protocol, metrics, and registry usable by tooling
5
+ and CI on machines where the simulator is not installed.
6
+ """
7
+
8
+ from .metrics import SustainedSuccessTracker
9
+ from .aggregate import aggregate_results
10
+ from .compatibility import validate_task_robot
11
+ from .protocol import BenchmarkTask, Policy, StepResult, normalize_reset, normalize_step
12
+ from .registry import (
13
+ RobotRegistry,
14
+ TaskDefinition,
15
+ TaskRegistry,
16
+ get_robot_registry,
17
+ get_task_registry,
18
+ register_robot,
19
+ register_task,
20
+ )
21
+ from .runner import BenchmarkRunner, EvaluationResult
22
+ from .specs import SuccessRule, TaskSpec
23
+ from .scenarios import Scenario, sample_scenario
24
+ from .contracts import ObservationSpec, ActionSpec, FeedbackSpec
25
+ from .geometry import GeometryFeatureAdapter
26
+ from .observation import policy_observation
27
+ from .language import EpisodeLanguagePrompt, build_episode_prompt
28
+ from .visual_randomization import ensure_workspace_camera
29
+
30
+ __all__ = [
31
+ "BenchmarkRunner",
32
+ "BenchmarkTask",
33
+ "EvaluationResult",
34
+ "Policy",
35
+ "RobotRegistry",
36
+ "StepResult",
37
+ "SuccessRule",
38
+ "SustainedSuccessTracker",
39
+ "TaskDefinition",
40
+ "TaskRegistry",
41
+ "TaskSpec",
42
+ "get_robot_registry",
43
+ "get_task_registry",
44
+ "register_robot",
45
+ "register_task",
46
+ "normalize_reset",
47
+ "normalize_step",
48
+ "validate_task_robot",
49
+ "Scenario",
50
+ "sample_scenario",
51
+ "ObservationSpec",
52
+ "ActionSpec",
53
+ "FeedbackSpec",
54
+ "GeometryFeatureAdapter",
55
+ "policy_observation",
56
+ "aggregate_results",
57
+ "EpisodeLanguagePrompt",
58
+ "build_episode_prompt",
59
+ "ensure_workspace_camera",
60
+ ]
@@ -0,0 +1,59 @@
1
+ """Aggregate fixed-horizon episode results into benchmark-level statistics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from math import sqrt
6
+ from typing import Any, Iterable, Mapping
7
+
8
+
9
+ NUMERIC_FIELDS = (
10
+ "success_occupancy",
11
+ "longest_success_streak_sec",
12
+ "completed_events",
13
+ "event_rate_hz",
14
+ "failure_count",
15
+ "recoverable_failure_count",
16
+ "recovery_count",
17
+ "peak_force_N",
18
+ "mean_inference_latency_ms",
19
+ "mean_action_age_ms",
20
+ "real_time_factor",
21
+ )
22
+
23
+
24
+ def aggregate_results(results: Iterable[Any]) -> dict[str, Any]:
25
+ """Return deterministic summary statistics for a set of episodes."""
26
+
27
+ rows = [_as_mapping(item) for item in results]
28
+ if not rows:
29
+ raise ValueError("cannot aggregate an empty result set")
30
+ passed = [bool(row.get("passed", row.get("metrics", {}).get("passed", False))) for row in rows]
31
+ summary: dict[str, Any] = {
32
+ "episodes": len(rows),
33
+ "pass_count": sum(passed),
34
+ "pass_rate": round(sum(passed) / len(passed), 6),
35
+ }
36
+ for field in NUMERIC_FIELDS:
37
+ values = []
38
+ for row in rows:
39
+ value = row.get(field)
40
+ if value is None and isinstance(row.get("metrics"), Mapping):
41
+ value = row["metrics"].get(field)
42
+ if isinstance(value, (int, float)):
43
+ values.append(float(value))
44
+ if values:
45
+ mean = sum(values) / len(values)
46
+ variance = sum((value - mean) ** 2 for value in values) / len(values)
47
+ summary[f"mean_{field}"] = round(mean, 6)
48
+ summary[f"std_{field}"] = round(sqrt(variance), 6)
49
+ return summary
50
+
51
+
52
+ def _as_mapping(value: Any) -> Mapping[str, Any]:
53
+ if isinstance(value, Mapping):
54
+ return value
55
+ if hasattr(value, "to_dict"):
56
+ result = value.to_dict()
57
+ if isinstance(result, Mapping):
58
+ return result
59
+ raise TypeError(f"result must be a mapping or expose to_dict(), got {type(value).__name__}")