oopsie-data-tools 0.2.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.
- oopsie_data_tools/__init__.py +7 -0
- oopsie_data_tools/annotation_tool/__init__.py +5 -0
- oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
- oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
- oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
- oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
- oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
- oopsie_data_tools/cli.py +894 -0
- oopsie_data_tools/init_wizard.py +329 -0
- oopsie_data_tools/skill/SKILL.md +98 -0
- oopsie_data_tools/skill/reference/conversion.md +257 -0
- oopsie_data_tools/skill/reference/format.md +112 -0
- oopsie_data_tools/skill/reference/robot-profile.md +104 -0
- oopsie_data_tools/skill/reference/setup.md +216 -0
- oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
- oopsie_data_tools/test/__init__.py +1 -0
- oopsie_data_tools/test/conftest.py +174 -0
- oopsie_data_tools/test/fixtures/__init__.py +0 -0
- oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
- oopsie_data_tools/test/fixtures/make_valid.py +406 -0
- oopsie_data_tools/test/test_annotation_completeness.py +154 -0
- oopsie_data_tools/test/test_annotation_schema.py +190 -0
- oopsie_data_tools/test/test_annotator_server.py +240 -0
- oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
- oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
- oopsie_data_tools/test/test_cli_config.py +111 -0
- oopsie_data_tools/test/test_cli_tools.py +438 -0
- oopsie_data_tools/test/test_contributor_config.py +38 -0
- oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
- oopsie_data_tools/test/test_credentials_location.py +106 -0
- oopsie_data_tools/test/test_diversity.py +33 -0
- oopsie_data_tools/test/test_episode_recorder.py +335 -0
- oopsie_data_tools/test/test_episode_video_paths.py +173 -0
- oopsie_data_tools/test/test_hf_upload.py +234 -0
- oopsie_data_tools/test/test_init_wizard.py +193 -0
- oopsie_data_tools/test/test_install_skill.py +120 -0
- oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
- oopsie_data_tools/test/test_new_profile.py +84 -0
- oopsie_data_tools/test/test_paths.py +137 -0
- oopsie_data_tools/test/test_python38_compat.py +79 -0
- oopsie_data_tools/test/test_record_step_purity.py +127 -0
- oopsie_data_tools/test/test_robot_setup.py +244 -0
- oopsie_data_tools/test/test_rollout_annotator.py +134 -0
- oopsie_data_tools/test/test_rotation_utils.py +126 -0
- oopsie_data_tools/test/test_validate.py +494 -0
- oopsie_data_tools/utils/__init__.py +1 -0
- oopsie_data_tools/utils/claude_skill.py +96 -0
- oopsie_data_tools/utils/contributor_config.py +91 -0
- oopsie_data_tools/utils/conversion_utils.py +220 -0
- oopsie_data_tools/utils/h5.py +60 -0
- oopsie_data_tools/utils/h5_inspect.py +167 -0
- oopsie_data_tools/utils/hf_limits.py +22 -0
- oopsie_data_tools/utils/hf_upload.py +235 -0
- oopsie_data_tools/utils/log.py +35 -0
- oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
- oopsie_data_tools/utils/paths.py +162 -0
- oopsie_data_tools/utils/restructure.py +402 -0
- oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
- oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
- oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
- oopsie_data_tools/utils/robot_profile/template.py +108 -0
- oopsie_data_tools/utils/validation/__init__.py +5 -0
- oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
- oopsie_data_tools/utils/validation/diversity.py +93 -0
- oopsie_data_tools/utils/validation/episode_data.py +54 -0
- oopsie_data_tools/utils/validation/episode_loader.py +201 -0
- oopsie_data_tools/utils/validation/episode_validator.py +315 -0
- oopsie_data_tools/utils/validation/errors.py +17 -0
- oopsie_data_tools/utils/validation/validation_utils.py +88 -0
- oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
- oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
- oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
- oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
- oopsie_data_tools-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""Episode recorder for saving robot evaluation data in HDF5 format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import h5py
|
|
12
|
+
import imageio
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from oopsie_data_tools.annotation_tool.annotation_schema import (
|
|
16
|
+
annotation_attrs_dict,
|
|
17
|
+
success_to_outcome,
|
|
18
|
+
write_annotation_attrs,
|
|
19
|
+
)
|
|
20
|
+
from oopsie_data_tools.utils.contributor_config import read_contributor_config
|
|
21
|
+
from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile, robot_profile_to_json
|
|
22
|
+
from oopsie_data_tools.utils.robot_profile.rotation_utils import ActionQuatConversion
|
|
23
|
+
from oopsie_data_tools.utils.validation.episode_data import EpisodeData, VideoInfo
|
|
24
|
+
from oopsie_data_tools.utils.validation.episode_validator import validate_episode
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
REQUIRED_OBSERVATION_KEYS = ["robot_state", "image_observation"]
|
|
29
|
+
|
|
30
|
+
VALID_ACTION_KEYS = {
|
|
31
|
+
"cartesian_position",
|
|
32
|
+
"cartesian_velocity",
|
|
33
|
+
"joint_position",
|
|
34
|
+
"joint_velocity",
|
|
35
|
+
"base_position",
|
|
36
|
+
"base_velocity",
|
|
37
|
+
"gripper_velocity",
|
|
38
|
+
"gripper_position",
|
|
39
|
+
"gripper_binary",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_mp4(video_path: Path, frames: np.ndarray, fps: float) -> None:
|
|
44
|
+
"""Write RGB frames to an MP4 file.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
video_path (Path): Destination path for the MP4 file.
|
|
48
|
+
frames (np.ndarray): Video frames with shape ``(T, H, W, 3)``.
|
|
49
|
+
fps (float): Output video frame rate.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If ``frames`` does not have shape ``(T, H, W, 3)`` or
|
|
53
|
+
contains zero timesteps.
|
|
54
|
+
"""
|
|
55
|
+
if frames.ndim != 4 or frames.shape[-1] != 3:
|
|
56
|
+
raise ValueError(f"Expected frames with shape (T,H,W,3), got {frames.shape}")
|
|
57
|
+
if frames.shape[0] == 0:
|
|
58
|
+
raise ValueError("Cannot write video with zero frames")
|
|
59
|
+
|
|
60
|
+
with imageio.get_writer(
|
|
61
|
+
str(video_path),
|
|
62
|
+
format="FFMPEG",
|
|
63
|
+
mode="I",
|
|
64
|
+
fps=float(fps),
|
|
65
|
+
codec="libx264",
|
|
66
|
+
) as writer:
|
|
67
|
+
for frame in frames:
|
|
68
|
+
writer.append_data(np.asarray(frame, dtype=np.uint8))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EpisodeRecorder:
|
|
72
|
+
"""Record rollout observations and persist them in HDF5 format.
|
|
73
|
+
|
|
74
|
+
The recorder stores proprioception, optional video references, and
|
|
75
|
+
annotation metadata under the robotic failure upload schema.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
robot_profile: RobotProfile,
|
|
81
|
+
data_root_dir: Path | str,
|
|
82
|
+
operator_name: str,
|
|
83
|
+
resume_session_name: str | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Initialize a recorder instance.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
robot_profile (RobotProfile): Robot profile.
|
|
89
|
+
data_root_dir (str): Base output directory for saved artifacts.
|
|
90
|
+
operator_name (str): Name of the operator recording the episode.
|
|
91
|
+
session_name (str | None): Optional unique session name
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: If ``data_root_dir`` is not a valid directory.
|
|
95
|
+
"""
|
|
96
|
+
self.data_root_dir = Path(data_root_dir)
|
|
97
|
+
self.session_name = (
|
|
98
|
+
resume_session_name
|
|
99
|
+
if resume_session_name is not None
|
|
100
|
+
else datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
101
|
+
)
|
|
102
|
+
self.session_dir = self.data_root_dir / self.session_name
|
|
103
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
self.operator_name = operator_name
|
|
105
|
+
|
|
106
|
+
self.robot_profile = robot_profile
|
|
107
|
+
self.camera_names = robot_profile.camera_names
|
|
108
|
+
|
|
109
|
+
self.quat_conversion = (
|
|
110
|
+
ActionQuatConversion(
|
|
111
|
+
self.robot_profile.get_rot_option(),
|
|
112
|
+
is_biarm=self.robot_profile.is_biarm,
|
|
113
|
+
)
|
|
114
|
+
if self.robot_profile.orientation_representation
|
|
115
|
+
else None
|
|
116
|
+
)
|
|
117
|
+
self.robot_state_quat_conversion = (
|
|
118
|
+
ActionQuatConversion(
|
|
119
|
+
self.robot_profile.get_robot_state_rot_option(),
|
|
120
|
+
is_biarm=self.robot_profile.is_biarm,
|
|
121
|
+
)
|
|
122
|
+
if self.robot_profile.robot_state_orientation_representation
|
|
123
|
+
else None
|
|
124
|
+
)
|
|
125
|
+
self.frames: dict[str, list[np.ndarray]] = {}
|
|
126
|
+
self.timesteps: list[dict[str, Any]] = []
|
|
127
|
+
self.timestamp: float = 0.0
|
|
128
|
+
self.save_fname: str = ""
|
|
129
|
+
# Names handed out by this recorder, so back-to-back episodes stay distinct even
|
|
130
|
+
# before the first one has been written to disk.
|
|
131
|
+
self._used_names: set[str] = set()
|
|
132
|
+
# A fresh recorder is ready to record. This used to be left to an explicit
|
|
133
|
+
# reset_episode_recorder() call, so going straight to record_step() raised
|
|
134
|
+
# AttributeError on save_fname — an undocumented ordering requirement.
|
|
135
|
+
self.reset_episode_recorder()
|
|
136
|
+
|
|
137
|
+
self.lab_id, _ = read_contributor_config()
|
|
138
|
+
|
|
139
|
+
def reset_episode_recorder(self) -> None:
|
|
140
|
+
"""Reset the buffers and start a new episode."""
|
|
141
|
+
ts = datetime.datetime.now()
|
|
142
|
+
self.timestamp = ts.timestamp()
|
|
143
|
+
self.save_fname = self._unused_episode_name(ts)
|
|
144
|
+
self.frames = {cam: [] for cam in self.camera_names}
|
|
145
|
+
self.timesteps = []
|
|
146
|
+
|
|
147
|
+
def _unused_episode_name(self, ts: datetime.datetime) -> str:
|
|
148
|
+
"""A ``%Y%m%d_%H%M%S`` name, suffixed if that second is already taken.
|
|
149
|
+
|
|
150
|
+
Episode names are second-resolution, so a fast rollout loop can start two episodes
|
|
151
|
+
within the same second and have the later one overwrite the earlier one's HDF5 and
|
|
152
|
+
MP4s. Callers used to work around this by sleeping between episodes.
|
|
153
|
+
"""
|
|
154
|
+
base = ts.strftime("%Y%m%d_%H%M%S")
|
|
155
|
+
candidate = base
|
|
156
|
+
attempt = 1
|
|
157
|
+
while (self.session_dir / f"{candidate}.h5").exists() or candidate in self._used_names:
|
|
158
|
+
attempt += 1
|
|
159
|
+
candidate = f"{base}_{attempt}"
|
|
160
|
+
self._used_names.add(candidate)
|
|
161
|
+
return candidate
|
|
162
|
+
|
|
163
|
+
def record_step(
|
|
164
|
+
self, observation: dict[str, Any], action: dict[str, np.ndarray]
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Append one rollout timestep to in-memory buffers.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
observation (dict[str, Any]): Observation payload containing state
|
|
170
|
+
and optional images.
|
|
171
|
+
action (dict[str, np.ndarray]): Dictionary of action vector applied at this timestep.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
None: This method only updates in-memory buffers.
|
|
175
|
+
"""
|
|
176
|
+
# TODO: Make sure all checks are present here
|
|
177
|
+
# Returns normalized copies; the caller's dicts are left untouched.
|
|
178
|
+
robot_state, action = self._check_and_normalize_step_data(observation, action)
|
|
179
|
+
|
|
180
|
+
# Buffer frames for each configured camera (if available)
|
|
181
|
+
for cam in self.camera_names:
|
|
182
|
+
frame = self._get_camera_frame(observation["image_observation"], cam)
|
|
183
|
+
if frame is not None:
|
|
184
|
+
self.frames[cam].append(np.asarray(frame, dtype=np.uint8))
|
|
185
|
+
|
|
186
|
+
# Buffer timestep data
|
|
187
|
+
step_data = {"robot_state": {}, "action_dict": {}}
|
|
188
|
+
for key in self.robot_profile.robot_state_keys:
|
|
189
|
+
step_data["robot_state"][key] = np.asarray(robot_state[key], dtype=np.float32)
|
|
190
|
+
step_data["action_dict"] = {
|
|
191
|
+
"cartesian_position": action.get("cartesian_position", None),
|
|
192
|
+
"cartesian_velocity": action.get("cartesian_velocity", None),
|
|
193
|
+
"joint_position": action.get("joint_position", None),
|
|
194
|
+
"joint_velocity": action.get("joint_velocity", None),
|
|
195
|
+
"base_position": action.get("base_position", None),
|
|
196
|
+
"base_velocity": action.get("base_velocity", None),
|
|
197
|
+
"gripper_velocity": action.get("gripper_velocity", None),
|
|
198
|
+
"gripper_position": action.get("gripper_position", None),
|
|
199
|
+
"gripper_binary": action.get("gripper_binary", None),
|
|
200
|
+
}
|
|
201
|
+
self.timesteps.append(step_data)
|
|
202
|
+
|
|
203
|
+
def _save_videos(self) -> dict[str, str]:
|
|
204
|
+
"""Write one MP4 per camera into the session directory.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
``{camera: absolute mp4 path}``. Cameras that buffered no frames are skipped
|
|
208
|
+
rather than crashing in ``np.stack`` on an empty list.
|
|
209
|
+
"""
|
|
210
|
+
video_paths: dict[str, str] = {}
|
|
211
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
fps = float(self.robot_profile.control_freq)
|
|
213
|
+
for cam_name, frames in self.frames.items():
|
|
214
|
+
if not frames:
|
|
215
|
+
continue
|
|
216
|
+
video_path = self.session_dir / f"{self.save_fname}_{cam_name}.mp4"
|
|
217
|
+
write_mp4(video_path=video_path, frames=np.asarray(frames), fps=fps)
|
|
218
|
+
video_paths[cam_name] = str(video_path.resolve())
|
|
219
|
+
return video_paths
|
|
220
|
+
|
|
221
|
+
def finish_rollout(self, instruction: str, success: float | None = None) -> None:
|
|
222
|
+
data = {
|
|
223
|
+
"language_instruction": instruction,
|
|
224
|
+
"metadata": {
|
|
225
|
+
"episode_id": self.save_fname,
|
|
226
|
+
"operator_name": self.operator_name,
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
if success is not None:
|
|
230
|
+
# A stub the human annotator fills in later: the outcome the float implies, and
|
|
231
|
+
# nothing else. Built through annotation_attrs_dict so it cannot drift from what
|
|
232
|
+
# the annotation tool writes.
|
|
233
|
+
stub = annotation_attrs_dict(
|
|
234
|
+
{
|
|
235
|
+
"outcome": success_to_outcome(success),
|
|
236
|
+
"timestamp": datetime.datetime.now().timestamp(),
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
stub["success"] = success
|
|
240
|
+
data["episode_annotations"] = {self.operator_name: stub}
|
|
241
|
+
self._validate_pre_save(data)
|
|
242
|
+
# 1. Save videos under the recorder's per-session folder
|
|
243
|
+
video_paths = self._save_videos()
|
|
244
|
+
|
|
245
|
+
data["video_paths"] = video_paths
|
|
246
|
+
|
|
247
|
+
self.save(
|
|
248
|
+
data
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def save(self, data: dict[str, Any]) -> Path:
|
|
252
|
+
"""Persist the currently buffered episode to disk.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
metadata (dict[str, Any]): Save metadata containing language and
|
|
256
|
+
annotation fields.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Path: Path to the written HDF5 episode file.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
ValueError: If no rollout steps were recorded.
|
|
263
|
+
"""
|
|
264
|
+
if len(self.timesteps) == 0:
|
|
265
|
+
raise ValueError("No steps recorded. Call record_step() first.")
|
|
266
|
+
|
|
267
|
+
# Converts the absolute paths finish_rollout() hands over into paths relative to
|
|
268
|
+
# the episode file, which is what the HDF5 stores. Batch 4 folds this together with
|
|
269
|
+
# _save_videos so there is one writer.
|
|
270
|
+
data["video_paths"] = self._resolve_video_paths(
|
|
271
|
+
output_dir=self.session_dir,
|
|
272
|
+
provided_video_paths=data.get("video_paths", {}),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Save HDF5 file to disk
|
|
276
|
+
h5_filename = f"{self.save_fname}.h5"
|
|
277
|
+
h5_path = self.session_dir / h5_filename
|
|
278
|
+
self._save_h5(h5_path, data)
|
|
279
|
+
|
|
280
|
+
return h5_path
|
|
281
|
+
|
|
282
|
+
def _check_and_normalize_step_data(
|
|
283
|
+
self, observation: dict[str, Any], action: dict[str, np.ndarray]
|
|
284
|
+
) -> tuple[dict[str, Any], dict[str, np.ndarray]]:
|
|
285
|
+
"""Validate one rollout step and return its normalized robot state and action.
|
|
286
|
+
|
|
287
|
+
Normalizing means converting any ``cartesian_position`` orientation into a
|
|
288
|
+
scalar-last quaternion, per the robot profile's ``orientation_representation``.
|
|
289
|
+
|
|
290
|
+
The converted values are *returned* rather than written back into the caller's
|
|
291
|
+
dicts. They used to be written back, which made this validator quietly responsible
|
|
292
|
+
for feeding the recording buffer — and made "stop mutating the caller's input" a
|
|
293
|
+
change that would have silently recorded raw euler/rot6d values labelled as
|
|
294
|
+
quaternions.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
observation (dict[str, Any]): Observation payload to validate.
|
|
298
|
+
action (dict[str, np.ndarray]): Action dict to validate.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
A ``(robot_state, action)`` pair of new dicts, safe to buffer.
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
ValueError: If required observation keys are missing, action is
|
|
305
|
+
empty, action contains unrecognized keys, or all action values
|
|
306
|
+
are None.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
# Make sure the observation is a dictionary
|
|
310
|
+
if not isinstance(observation, dict):
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"observation must be a dictionary, got {type(observation)}"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
missing_obs = [k for k in REQUIRED_OBSERVATION_KEYS if k not in observation]
|
|
316
|
+
if missing_obs:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
f"observation is missing required keys: {missing_obs}. "
|
|
319
|
+
f"Required: {REQUIRED_OBSERVATION_KEYS}. Please pass it in your record_step() call"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
robot_state = observation["robot_state"]
|
|
323
|
+
missing_robot_state_keys = [
|
|
324
|
+
k for k in self.robot_profile.robot_state_keys if k not in robot_state
|
|
325
|
+
]
|
|
326
|
+
if missing_robot_state_keys:
|
|
327
|
+
raise ValueError(
|
|
328
|
+
f"robot_state is missing required keys: {missing_robot_state_keys}. "
|
|
329
|
+
f"Required: {self.robot_profile.robot_state_keys}. Please pass it in your record_step() call. Double check that the passed keys match the robot profile you initialized the recorder with."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
image_observation = observation["image_observation"]
|
|
333
|
+
missing_image_observation_keys = [
|
|
334
|
+
k for k in self.robot_profile.camera_names if k not in image_observation
|
|
335
|
+
]
|
|
336
|
+
if missing_image_observation_keys:
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"image_observation is missing required keys: {missing_image_observation_keys}. "
|
|
339
|
+
f"Required: {self.robot_profile.camera_names} that you provided in the robot setup. Please pass it in your record_step() call. Double check that the passed keys match the robot profile you initialized the recorder with."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Make sure the action is a dictionary
|
|
343
|
+
if not isinstance(action, dict):
|
|
344
|
+
raise ValueError(f"action must be a dictionary, got {type(action)}")
|
|
345
|
+
|
|
346
|
+
# Make sure action has at least one key
|
|
347
|
+
if not action:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"action must not be empty. Valid keys: {VALID_ACTION_KEYS}. Please pass it in your record_step() call. Double check that the passed keys match the robot profile you initialized the recorder with."
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Make sure the action keys are valid
|
|
353
|
+
invalid_action = set(action.keys()) - set(VALID_ACTION_KEYS)
|
|
354
|
+
if invalid_action:
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"action contains unrecognized keys: {sorted(invalid_action)}. "
|
|
357
|
+
f"Valid keys: {VALID_ACTION_KEYS}"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Make sure the action keys agree between robot_profile and the action dict
|
|
361
|
+
profile_action_keys = set(self.robot_profile.action_space)
|
|
362
|
+
action_keys = set(action.keys())
|
|
363
|
+
if action_keys != profile_action_keys:
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"action keys {action_keys} must match the robot profile action_space {profile_action_keys}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
# Make sure action values are not None
|
|
369
|
+
if any(v is None for v in action.values()):
|
|
370
|
+
raise ValueError(
|
|
371
|
+
f"action contains None values for keys: {[k for k, v in action.items() if v is None]}. "
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Shallow copies from here on: everything below normalizes values, and the caller's
|
|
375
|
+
# dicts must come back out of record_step() exactly as they went in.
|
|
376
|
+
action = dict(action)
|
|
377
|
+
robot_state = dict(robot_state)
|
|
378
|
+
|
|
379
|
+
# TODO: only cartesian_position is normalized and shape-checked; the other action
|
|
380
|
+
# keys are recorded as given.
|
|
381
|
+
if "cartesian_position" in action:
|
|
382
|
+
action["cartesian_position"] = self._normalize_cartesian(
|
|
383
|
+
action["cartesian_position"], self.quat_conversion, "action"
|
|
384
|
+
)
|
|
385
|
+
self._check_quaternion_convention(action["cartesian_position"])
|
|
386
|
+
|
|
387
|
+
if "cartesian_position" in robot_state:
|
|
388
|
+
robot_state["cartesian_position"] = self._normalize_cartesian(
|
|
389
|
+
robot_state["cartesian_position"],
|
|
390
|
+
self.robot_state_quat_conversion,
|
|
391
|
+
"observation",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
return robot_state, action
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _normalize_cartesian(
|
|
398
|
+
value: Any, conversion: ActionQuatConversion | None, label: str
|
|
399
|
+
) -> np.ndarray:
|
|
400
|
+
"""Convert a cartesian pose to ``(x, y, z, qx, qy, qz, qw)`` and check its shape."""
|
|
401
|
+
arr = np.asarray(value)
|
|
402
|
+
if conversion is not None:
|
|
403
|
+
arr = np.asarray(conversion.convert_position(arr))
|
|
404
|
+
if arr.shape not in ((7,), (14,)):
|
|
405
|
+
raise ValueError(
|
|
406
|
+
f"{label}['cartesian_position'] must have shape (7,) or (14,) — "
|
|
407
|
+
f"[x, y, z, qx, qy, qz, qw], got shape {arr.shape}"
|
|
408
|
+
)
|
|
409
|
+
quat_norm = np.linalg.norm(arr[3:7])
|
|
410
|
+
if not np.isclose(quat_norm, 1.0, atol=1e-2):
|
|
411
|
+
raise ValueError(
|
|
412
|
+
f"{label}['cartesian_position'][3:7] must be a unit scalar-last quaternion "
|
|
413
|
+
f"(norm ≈ 1.0), got norm {quat_norm:.6f}"
|
|
414
|
+
)
|
|
415
|
+
return arr
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def _check_quaternion_convention(arr: np.ndarray) -> None:
|
|
419
|
+
"""Warn on a pose that looks scalar-first. A heuristic, so it never raises."""
|
|
420
|
+
if abs(arr[6]) > 0.99 and np.linalg.norm(arr[3:6]) < 0.1:
|
|
421
|
+
logger.warning(
|
|
422
|
+
"action['cartesian_position'][3:7] looks like (w,x,y,z) order rather than "
|
|
423
|
+
"the expected (x,y,z,w)."
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# TODO: Polish this function!
|
|
427
|
+
def _save_h5(self, path: Path, data: dict[str, Any]) -> None:
|
|
428
|
+
"""Write buffered rollout data and metadata into one HDF5 file.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
path (Path): Target HDF5 file path.
|
|
432
|
+
metadata (dict[str, Any]): Normalized metadata payload.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
None: This method only performs file I/O side effects.
|
|
436
|
+
"""
|
|
437
|
+
str_dtype = h5py.string_dtype(encoding="utf-8")
|
|
438
|
+
with h5py.File(path, "w") as f:
|
|
439
|
+
# 1. Save the metadata attributes
|
|
440
|
+
f.attrs["schema"] = "oopsiedata_format_v1"
|
|
441
|
+
f.attrs["episode_id"] = data["metadata"]["episode_id"]
|
|
442
|
+
f.attrs.create(
|
|
443
|
+
"robot_profile",
|
|
444
|
+
robot_profile_to_json(self.robot_profile),
|
|
445
|
+
dtype=h5py.string_dtype(encoding="utf-8"),
|
|
446
|
+
)
|
|
447
|
+
f.attrs["language_instruction"] = data.get("language_instruction", "")
|
|
448
|
+
f.attrs["operator_name"] = data["metadata"]["operator_name"]
|
|
449
|
+
f.attrs["lab_id"] = self.lab_id
|
|
450
|
+
f.attrs["timestamp"] = self.timestamp
|
|
451
|
+
|
|
452
|
+
# 2. Save episode annotations if any
|
|
453
|
+
if "episode_annotations" in data:
|
|
454
|
+
ea_group = f.create_group("episode_annotations")
|
|
455
|
+
for annotator_name, annotation in data["episode_annotations"].items():
|
|
456
|
+
ag = ea_group.require_group(annotator_name)
|
|
457
|
+
for attr_key, attr_val in annotation.items():
|
|
458
|
+
if attr_val is None:
|
|
459
|
+
continue
|
|
460
|
+
ag.attrs[attr_key] = attr_val
|
|
461
|
+
|
|
462
|
+
# 3. Save the per-camera MP4 file paths (strings), not inlined frame tensors.
|
|
463
|
+
observations_group = f.create_group("observations")
|
|
464
|
+
video_paths_group = observations_group.create_group("video_paths")
|
|
465
|
+
video_paths = data.get("video_paths", {})
|
|
466
|
+
if not isinstance(video_paths, dict):
|
|
467
|
+
video_paths = {}
|
|
468
|
+
for cam in self.camera_names:
|
|
469
|
+
raw_video_path = str(video_paths.get(cam, "")).strip()
|
|
470
|
+
if not raw_video_path:
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
episode_dir = path.parent.resolve()
|
|
474
|
+
video_path_obj = Path(raw_video_path).expanduser()
|
|
475
|
+
if not video_path_obj.is_absolute():
|
|
476
|
+
video_path_obj = episode_dir / video_path_obj
|
|
477
|
+
rel_video_path = os.path.relpath(video_path_obj.resolve(), start=episode_dir)
|
|
478
|
+
|
|
479
|
+
video_paths_group.create_dataset(
|
|
480
|
+
cam,
|
|
481
|
+
data=rel_video_path.replace(os.sep, "/"),
|
|
482
|
+
dtype=str_dtype,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# 3. Save the robot state data
|
|
486
|
+
robot_states = observations_group.create_group("robot_states")
|
|
487
|
+
for key in self.robot_profile.robot_state_keys:
|
|
488
|
+
robot_states.create_dataset(
|
|
489
|
+
key,
|
|
490
|
+
data=np.stack(
|
|
491
|
+
[t["robot_state"][key] for t in self.timesteps], axis=0
|
|
492
|
+
),
|
|
493
|
+
dtype=np.float64,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# 4. Save the action data
|
|
497
|
+
action_group = f.create_group("actions")
|
|
498
|
+
|
|
499
|
+
for action_key in self.timesteps[0]["action_dict"]:
|
|
500
|
+
action_values = [t["action_dict"][action_key] for t in self.timesteps]
|
|
501
|
+
if all(v is None for v in action_values):
|
|
502
|
+
action_group.create_dataset(
|
|
503
|
+
action_key, data=h5py.Empty(dtype=np.float64)
|
|
504
|
+
)
|
|
505
|
+
else:
|
|
506
|
+
action_group.create_dataset(
|
|
507
|
+
action_key,
|
|
508
|
+
data=np.stack(action_values, axis=0),
|
|
509
|
+
dtype=np.float64,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
def _resolve_video_paths(
|
|
513
|
+
self,
|
|
514
|
+
output_dir: Path,
|
|
515
|
+
provided_video_paths: Any,
|
|
516
|
+
) -> dict[str, str]:
|
|
517
|
+
"""Resolve per-camera MP4 paths to paths relative to the episode file.
|
|
518
|
+
|
|
519
|
+
On the ``finish_rollout`` path every camera arrives in ``provided_video_paths``
|
|
520
|
+
(absolute, already written by :meth:`_save_videos`) and this only relativizes them —
|
|
521
|
+
which is what the HDF5 stores. A recorder driven directly through :meth:`save`
|
|
522
|
+
supplies nothing, and the buffered frames are written here instead, through the same
|
|
523
|
+
:func:`write_mp4`.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
output_dir (Path): Directory used as the base for relative path
|
|
527
|
+
storage.
|
|
528
|
+
provided_video_paths (Any): Optional external mapping from camera to
|
|
529
|
+
MP4 path.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
dict[str, str]: Mapping from camera name to relative MP4 path.
|
|
533
|
+
"""
|
|
534
|
+
paths: dict[str, str] = {}
|
|
535
|
+
provided = (
|
|
536
|
+
provided_video_paths if isinstance(provided_video_paths, dict) else {}
|
|
537
|
+
)
|
|
538
|
+
# Both sides of every relpath below must be resolved otherwise complex
|
|
539
|
+
# relative paths can produce a convoluted path
|
|
540
|
+
base = Path(output_dir).resolve()
|
|
541
|
+
|
|
542
|
+
for cam in self.camera_names:
|
|
543
|
+
provided_path = str(provided.get(cam, ""))
|
|
544
|
+
if provided_path:
|
|
545
|
+
abs_path = Path(provided_path).expanduser().resolve()
|
|
546
|
+
if abs_path.suffix.lower() != ".mp4":
|
|
547
|
+
abs_path = abs_path.with_suffix(".mp4")
|
|
548
|
+
paths[cam] = os.path.relpath(abs_path, start=base)
|
|
549
|
+
continue
|
|
550
|
+
|
|
551
|
+
frames = self.frames.get(cam, [])
|
|
552
|
+
if len(frames) == 0:
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
video_path = output_dir / f"{self.save_fname}_{cam}.mp4"
|
|
556
|
+
fps = float(self.robot_profile.control_freq)
|
|
557
|
+
write_mp4(video_path=video_path, frames=np.asarray(frames), fps=fps)
|
|
558
|
+
paths[cam] = os.path.relpath(video_path.resolve(), start=base)
|
|
559
|
+
|
|
560
|
+
return paths
|
|
561
|
+
|
|
562
|
+
def _get_camera_frame(
|
|
563
|
+
self, observation: dict[str, Any], cam_name: str
|
|
564
|
+
) -> np.ndarray | None:
|
|
565
|
+
"""Extract a camera frame from supported observation key patterns.
|
|
566
|
+
|
|
567
|
+
Args:
|
|
568
|
+
observation (dict[str, Any]): Observation dictionary for one
|
|
569
|
+
timestep.
|
|
570
|
+
cam_name (str): Canonical camera name to resolve.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
np.ndarray | None: Camera frame array when available, otherwise
|
|
574
|
+
``None``.
|
|
575
|
+
"""
|
|
576
|
+
candidates = (cam_name, f"image_{cam_name}", f"{cam_name}_image")
|
|
577
|
+
for key in candidates:
|
|
578
|
+
if key in observation:
|
|
579
|
+
return np.asarray(observation[key])
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
def _validate_pre_save(self, data: dict[str, Any]) -> None:
|
|
583
|
+
"""Perform final validation checks before saving the episode.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
data (dict[str, Any]): Full episode data payload to validate.
|
|
587
|
+
|
|
588
|
+
Raises:
|
|
589
|
+
ValueError: If any required fields are missing or invalid.
|
|
590
|
+
"""
|
|
591
|
+
episode_data = EpisodeData(
|
|
592
|
+
robot_profile=self.robot_profile,
|
|
593
|
+
language_instruction=data.get("language_instruction", ""),
|
|
594
|
+
episode_id=data["metadata"]["episode_id"],
|
|
595
|
+
lab_id=self.lab_id,
|
|
596
|
+
operator_name=data["metadata"]["operator_name"],
|
|
597
|
+
trajectory_length=len(self.timesteps),
|
|
598
|
+
control_freq=float(self.robot_profile.control_freq),
|
|
599
|
+
observations={
|
|
600
|
+
key: np.stack([t["robot_state"][key] for t in self.timesteps], axis=0)
|
|
601
|
+
for key in self.robot_profile.robot_state_keys
|
|
602
|
+
},
|
|
603
|
+
# Only the keys actually recorded, which record_step guarantees are exactly
|
|
604
|
+
# profile.action_space. The buffer carries all nine VALID_ACTION_KEYS with None
|
|
605
|
+
# in the unused slots; stacking those produced object arrays of None that the
|
|
606
|
+
# validator's ndim guards happened to skip. episode_loader builds this dict from
|
|
607
|
+
# the non-empty datasets only, so this keeps the two construction sites agreeing
|
|
608
|
+
# on what `actions` means.
|
|
609
|
+
actions={
|
|
610
|
+
key: np.stack([t["action_dict"][key] for t in self.timesteps], axis=0)
|
|
611
|
+
for key in self.robot_profile.action_space
|
|
612
|
+
},
|
|
613
|
+
videos={
|
|
614
|
+
cam: VideoInfo.from_frames(self.frames[cam], fps=self.robot_profile.control_freq) for cam in self.camera_names
|
|
615
|
+
},
|
|
616
|
+
annotations=data.get("episode_annotations", None),
|
|
617
|
+
)
|
|
618
|
+
validate_episode(episode_data)
|
|
619
|
+
|
|
620
|
+
@staticmethod
|
|
621
|
+
def patch_h5_failure_annotation(
|
|
622
|
+
h5_path: Path,
|
|
623
|
+
annotation: dict[str, Any],
|
|
624
|
+
) -> None:
|
|
625
|
+
"""Patch an existing episode HDF5 with a human annotation.
|
|
626
|
+
|
|
627
|
+
This is used when the episode is saved immediately after rollout and the
|
|
628
|
+
human annotation arrives later. *annotation* is the annotation-tool dict —
|
|
629
|
+
``annotator``, ``outcome`` and whatever taxonomy fields that outcome asks about.
|
|
630
|
+
"""
|
|
631
|
+
if not h5_path.exists():
|
|
632
|
+
raise FileNotFoundError(str(h5_path))
|
|
633
|
+
|
|
634
|
+
with h5py.File(h5_path, "r+") as f:
|
|
635
|
+
episode_annotations = f.require_group("episode_annotations")
|
|
636
|
+
annotation_group = episode_annotations.require_group(
|
|
637
|
+
annotation["annotator"]
|
|
638
|
+
)
|
|
639
|
+
write_annotation_attrs(annotation_group, annotation)
|
|
640
|
+
|
|
641
|
+
@property
|
|
642
|
+
def num_steps(self) -> int:
|
|
643
|
+
"""Return the number of recorded timesteps.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
int: Number of timesteps currently buffered.
|
|
647
|
+
"""
|
|
648
|
+
return len(self.timesteps)
|