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.
Files changed (74) hide show
  1. oopsie_data_tools/__init__.py +7 -0
  2. oopsie_data_tools/annotation_tool/__init__.py +5 -0
  3. oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
  4. oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
  5. oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
  6. oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
  7. oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
  8. oopsie_data_tools/cli.py +894 -0
  9. oopsie_data_tools/init_wizard.py +329 -0
  10. oopsie_data_tools/skill/SKILL.md +98 -0
  11. oopsie_data_tools/skill/reference/conversion.md +257 -0
  12. oopsie_data_tools/skill/reference/format.md +112 -0
  13. oopsie_data_tools/skill/reference/robot-profile.md +104 -0
  14. oopsie_data_tools/skill/reference/setup.md +216 -0
  15. oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
  16. oopsie_data_tools/test/__init__.py +1 -0
  17. oopsie_data_tools/test/conftest.py +174 -0
  18. oopsie_data_tools/test/fixtures/__init__.py +0 -0
  19. oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
  20. oopsie_data_tools/test/fixtures/make_valid.py +406 -0
  21. oopsie_data_tools/test/test_annotation_completeness.py +154 -0
  22. oopsie_data_tools/test/test_annotation_schema.py +190 -0
  23. oopsie_data_tools/test/test_annotator_server.py +240 -0
  24. oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
  25. oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
  26. oopsie_data_tools/test/test_cli_config.py +111 -0
  27. oopsie_data_tools/test/test_cli_tools.py +438 -0
  28. oopsie_data_tools/test/test_contributor_config.py +38 -0
  29. oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
  30. oopsie_data_tools/test/test_credentials_location.py +106 -0
  31. oopsie_data_tools/test/test_diversity.py +33 -0
  32. oopsie_data_tools/test/test_episode_recorder.py +335 -0
  33. oopsie_data_tools/test/test_episode_video_paths.py +173 -0
  34. oopsie_data_tools/test/test_hf_upload.py +234 -0
  35. oopsie_data_tools/test/test_init_wizard.py +193 -0
  36. oopsie_data_tools/test/test_install_skill.py +120 -0
  37. oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
  38. oopsie_data_tools/test/test_new_profile.py +84 -0
  39. oopsie_data_tools/test/test_paths.py +137 -0
  40. oopsie_data_tools/test/test_python38_compat.py +79 -0
  41. oopsie_data_tools/test/test_record_step_purity.py +127 -0
  42. oopsie_data_tools/test/test_robot_setup.py +244 -0
  43. oopsie_data_tools/test/test_rollout_annotator.py +134 -0
  44. oopsie_data_tools/test/test_rotation_utils.py +126 -0
  45. oopsie_data_tools/test/test_validate.py +494 -0
  46. oopsie_data_tools/utils/__init__.py +1 -0
  47. oopsie_data_tools/utils/claude_skill.py +96 -0
  48. oopsie_data_tools/utils/contributor_config.py +91 -0
  49. oopsie_data_tools/utils/conversion_utils.py +220 -0
  50. oopsie_data_tools/utils/h5.py +60 -0
  51. oopsie_data_tools/utils/h5_inspect.py +167 -0
  52. oopsie_data_tools/utils/hf_limits.py +22 -0
  53. oopsie_data_tools/utils/hf_upload.py +235 -0
  54. oopsie_data_tools/utils/log.py +35 -0
  55. oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
  56. oopsie_data_tools/utils/paths.py +162 -0
  57. oopsie_data_tools/utils/restructure.py +402 -0
  58. oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
  59. oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
  60. oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
  61. oopsie_data_tools/utils/robot_profile/template.py +108 -0
  62. oopsie_data_tools/utils/validation/__init__.py +5 -0
  63. oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
  64. oopsie_data_tools/utils/validation/diversity.py +93 -0
  65. oopsie_data_tools/utils/validation/episode_data.py +54 -0
  66. oopsie_data_tools/utils/validation/episode_loader.py +201 -0
  67. oopsie_data_tools/utils/validation/episode_validator.py +315 -0
  68. oopsie_data_tools/utils/validation/errors.py +17 -0
  69. oopsie_data_tools/utils/validation/validation_utils.py +88 -0
  70. oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
  71. oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
  72. oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
  73. oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
  74. 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)