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,119 @@
1
+ """Convert the orientation half of a cartesian action into a scalar-last quaternion.
2
+
3
+ A robot profile declares how its cartesian actions encode orientation
4
+ (``orientation_representation``); the recorder normalizes everything to
5
+ ``(x, y, z, qx, qy, qz, qw)`` before writing an episode, so downstream consumers only
6
+ ever see quaternions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+
13
+ import numpy as np
14
+ from scipy.spatial.transform import Rotation as R
15
+
16
+ _EULER_FORMATS = ("xyz", "zyx", "xyx", "XYZ", "ZYX", "XYX")
17
+
18
+
19
+ class RotOption(Enum):
20
+ # Upper- and lower-case Euler orders are distinct on purpose: scipy reads uppercase as
21
+ # intrinsic rotations and lowercase as extrinsic ones.
22
+ XYZ = 0
23
+ ZYX = 1
24
+ XYX = 2
25
+ QUAT = 3
26
+ MATRIX = 4
27
+ ROT6D = 5
28
+ ROTVEC = 6
29
+ xyz = 7
30
+ zyx = 8
31
+ xyx = 9
32
+
33
+ @staticmethod
34
+ def from_string(s: str) -> "RotOption":
35
+ if s.startswith("euler_"):
36
+ euler_format = s[len("euler_") :]
37
+ if euler_format in _EULER_FORMATS:
38
+ return RotOption[euler_format]
39
+ raise ValueError(
40
+ f"Unsupported Euler format: {euler_format!r}. "
41
+ f"Expected one of {', '.join(_EULER_FORMATS)}."
42
+ )
43
+ try:
44
+ return {
45
+ "quat": RotOption.QUAT,
46
+ "matrix": RotOption.MATRIX,
47
+ "rot6d": RotOption.ROT6D,
48
+ "rotvec": RotOption.ROTVEC,
49
+ }[s]
50
+ except KeyError:
51
+ raise ValueError(
52
+ f"Unsupported rotation option: {s!r}. Expected 'quat', 'matrix', 'rot6d', "
53
+ "'rotvec', or 'euler_<order>'."
54
+ ) from None
55
+
56
+
57
+ # How many elements each representation contributes to a flat action vector.
58
+ _EXPECTED_SIZE = {
59
+ RotOption.QUAT: 4,
60
+ RotOption.MATRIX: 9,
61
+ RotOption.ROT6D: 6,
62
+ RotOption.ROTVEC: 3,
63
+ **{RotOption[f]: 3 for f in _EULER_FORMATS},
64
+ }
65
+
66
+
67
+ class ActionQuatConversion:
68
+ """Convert the rotation slice of a cartesian action to a scalar-last quaternion."""
69
+
70
+ def __init__(self, source_format: RotOption, is_biarm: bool = False) -> None:
71
+ self.rot_option = source_format
72
+ self.is_biarm = is_biarm
73
+
74
+ def _to_quat(self, arr: np.ndarray) -> np.ndarray:
75
+ expected = _EXPECTED_SIZE.get(self.rot_option)
76
+ if expected is None:
77
+ raise ValueError(f"Unsupported rotation option: {self.rot_option}")
78
+ if arr.size != expected:
79
+ raise ValueError(
80
+ f"{self.rot_option.name} orientation expects {expected} value(s), got "
81
+ f"{arr.size}. Check orientation_representation in the robot profile against "
82
+ "the action your policy emits."
83
+ )
84
+
85
+ if self.rot_option == RotOption.QUAT:
86
+ return arr
87
+ if self.rot_option == RotOption.MATRIX:
88
+ # Flat in the action vector, so reshape before handing it to scipy.
89
+ return R.from_matrix(arr.reshape(3, 3)).as_quat()
90
+ if self.rot_option == RotOption.ROT6D:
91
+ # First two columns of the rotation matrix; rebuild the third by Gram-Schmidt.
92
+ rot6d = arr.reshape(2, 3)
93
+ u1 = rot6d[0] / np.linalg.norm(rot6d[0])
94
+ u2 = rot6d[1] - np.dot(rot6d[1], u1) * u1
95
+ u2 /= np.linalg.norm(u2)
96
+ u3 = np.cross(u1, u2)
97
+ return R.from_matrix(np.stack([u1, u2, u3], axis=1)).as_quat()
98
+ if self.rot_option == RotOption.ROTVEC:
99
+ return R.from_rotvec(arr).as_quat()
100
+ return R.from_euler(self.rot_option.name, arr).as_quat() # (x, y, z, w) order
101
+
102
+ def _convert_arm(self, action: np.ndarray) -> np.ndarray:
103
+ arm_rot = self._to_quat(action[3:])
104
+ return np.concatenate([action[:3], arm_rot]) # position + converted rotation
105
+
106
+ def convert_position(self, action: np.ndarray) -> np.ndarray:
107
+ action = np.asarray(action)
108
+ if not self.is_biarm:
109
+ return self._convert_arm(action)
110
+
111
+ action_dim = len(action)
112
+ if action_dim % 2:
113
+ raise ValueError(
114
+ f"Biarm cartesian action must have an even length to split per arm, got "
115
+ f"{action_dim}."
116
+ )
117
+ arm1_action = self._convert_arm(action[: action_dim // 2])
118
+ arm2_action = self._convert_arm(action[action_dim // 2 :])
119
+ return np.concatenate([arm1_action, arm2_action])
@@ -0,0 +1,108 @@
1
+ """The starter robot profile emitted by ``oopsie-data new-profile``.
2
+
3
+ Lives in Python rather than as packaged data on purpose: nothing in this toolkit
4
+ reads configuration out of an installed package (see ``utils.paths``). The checked-in
5
+ ``configs/robot_profiles/template.yaml`` is a copy of this string kept for browsing on
6
+ GitHub, and ``test_new_profile.py`` fails if the two drift apart.
7
+
8
+ The skeleton is deliberately **not** loadable as written. Every required field is blank,
9
+ so ``load_robot_profile`` rejects it until a human fills it in — recording an episode
10
+ against a half-edited profile would stamp placeholder metadata into uploaded data.
11
+ """
12
+
13
+ PROFILE_TEMPLATE = """# Any config related to the robot setup, observation, action will be stored here.
14
+
15
+
16
+ # ===============================
17
+ # ===== Robot Setup Related =====
18
+ # ===============================
19
+
20
+ policy_name: # str (Rq) (Example: "pi0.5", "openvla", "diffusion_policy_custom" ...)
21
+ robot_name: # str (Required) (Example: "franka_panda", "franka_research_3", "ur5e", "yam_pro" ...)
22
+ gripper_name: # str (Required) (Example: "robotiq_2f_85", "robotiq_2f_140", "yam_gripper" ...)
23
+ is_biarm: # bool (Required)
24
+ uses_mobile_base: # bool (Required)
25
+ control_freq: # int (Required)
26
+ camera_names: # list of str (Required) (Example: ["left", "right", "wrist"])
27
+ - # str
28
+
29
+
30
+ # ===============================
31
+ # ===== Observation Related =====
32
+ # ===============================
33
+
34
+ # Observation keys. Options: "joint_position", "gripper_position", "cartesian_position", "base_position"
35
+ # "gripper_position" is always required.
36
+ # Joint-space actions require "joint_position"; Cartesian actions require "cartesian_position".
37
+ robot_state_keys: # list of str
38
+ - # str
39
+
40
+ # If cartesian_position is in the robot_state_keys, specify the orientation representation. Options:
41
+ # 1. "euler_{format}" (where format is "xyz", "xyx" ... (shape (3,))
42
+ # 2. "quat" (quaternion should be scalar last, shape (4,))
43
+ # 3. "matrix" (rotation matrix, shape (3, 3))
44
+ # 4. "rot6d" (first two columns of the rotation matrix, flattened, shape (6,). This is used by openpi)
45
+ # 5. "rotvec" (axis-angle, shape (3,))
46
+ robot_state_orientation_representation: # str
47
+
48
+
49
+ # ==========================
50
+ # ===== Action Related =====
51
+ # ==========================
52
+
53
+ # Action space (Required)
54
+ # Options: "joint_velocity", "joint_position", "cartesian_position", "cartesian_velocity"
55
+ # Gripper options: "gripper_position", "gripper_velocity", "gripper_binary"
56
+ # Base options: "base_velocity", "base_position"
57
+ action_space: # list of str
58
+ - # str
59
+
60
+
61
+ # If action space is cartesian position, specify the orientation representation. Options:
62
+ # 1. "euler_{format}" (where format is "xyz", "xyx" ... (shape (3,))
63
+ # 2. "quat" (quaternion should be scalar last, shape (4,))
64
+ # 3. "matrix" (rotation matrix, shape (3, 3))
65
+ # 4. "rot6d" (first two columns of the rotation matrix, flattened, shape (6,). This is used by openpi)
66
+ # 5. "rotvec" (axis-angle, shape (3,))
67
+ orientation_representation: # str
68
+
69
+
70
+ # ==========================
71
+ # ===== Optional Keys =====
72
+ # ==========================
73
+
74
+ # Required when "joint_position" is in robot_state_keys.
75
+ # Identifies what each index in robot_state["joint_position"] corresponds to.
76
+ robot_state_joint_names: # list of str
77
+ - # str
78
+
79
+ # For knowing what each index in the action["joint_position"] corresponds to
80
+ action_joint_names: # list of str
81
+ - # str
82
+
83
+
84
+ # Options: "OSC", "joint_position", "joint_velocity"
85
+ controller: # str
86
+ gains:
87
+ joint_position:
88
+ kp: # list of float
89
+ kd: # list of float
90
+
91
+ joint_velocity:
92
+ kv: # list of float
93
+
94
+ osc:
95
+ kp_pos: # list of float
96
+ kd_pos: # list of float
97
+ kp_ori: # list of float
98
+ kd_ori: # list of float
99
+
100
+ intrinsic_calibration_matrix:
101
+ <str>: # camera name str
102
+ - # 3 x list of three floats (camera intrinsics for left camera)
103
+
104
+ extrinsic_calibration_matrix:
105
+ <str>: # camera name str
106
+ - # 4 x list of four floats (camera extrinsics in the form of a transformation matrix)
107
+ # ========================
108
+ """
@@ -0,0 +1,5 @@
1
+ """Episode loading and semantic validation against the oopsiedata schema."""
2
+
3
+ from oopsie_data_tools.utils.validation.errors import EpisodeValidationError
4
+
5
+ __all__ = ["EpisodeValidationError"]
@@ -0,0 +1,67 @@
1
+ """One definition of when an annotation counts as *finished* for its outcome.
2
+
3
+ Only ``outcome`` is required, so this module no longer speaks for the validator -- a partial
4
+ annotation is perfectly valid to upload. What it speaks for is the annotation UI's tick,
5
+ which still needs to distinguish "outcome recorded" from "everything this outcome asks
6
+ about is filled in", so annotators can find episodes worth revisiting.
7
+
8
+ The per-outcome field list below mirrors which fields the annotation form actually shows for
9
+ each outcome. If one moves, the other must.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Mapping
15
+
16
+ # Mirrors the conditional fields in the annotation form. `success` is a clean run: there is
17
+ # nothing further to say about it, so it is complete the moment it is chosen.
18
+ OUTCOME_EXPECTED_FIELDS: dict = {
19
+ "success": (),
20
+ "success_suboptimal": ("episode_description",),
21
+ "success_side_effect": ("episode_description", "side_effect_category", "severity"),
22
+ "failure": ("episode_description", "side_effect_category", "severity"),
23
+ }
24
+
25
+
26
+ def _is_filled(value: Any) -> bool:
27
+ """A scalar field counts as filled when it has non-whitespace content."""
28
+ if value is None:
29
+ return False
30
+ if isinstance(value, bytes):
31
+ value = value.decode("utf-8", errors="replace")
32
+ return bool(str(value).strip())
33
+
34
+
35
+ def _category_is_filled(value: Any) -> bool:
36
+ """``side_effect_category`` may arrive as a list (the form) or a scalar (older records).
37
+
38
+ Blank entries do not count, so ``[""]`` is empty. The stored form drops them on the way
39
+ to disk, and a list that looks filled here but not after a round-trip would make the
40
+ tick flip for no visible reason.
41
+ """
42
+ if isinstance(value, (list, tuple)):
43
+ return any(_is_filled(v) for v in value)
44
+ return _is_filled(value)
45
+
46
+
47
+ def _field_is_filled(annotation: Mapping[str, Any], field: str) -> bool:
48
+ value = annotation.get(field)
49
+ if field == "side_effect_category":
50
+ return _category_is_filled(value)
51
+ return _is_filled(value)
52
+
53
+
54
+ def completeness_flags(annotation: Mapping[str, Any]) -> dict:
55
+ """Per-field filled flags, restricted to the fields this outcome actually asks about.
56
+
57
+ An outcome the vocabulary does not know gets an empty mapping rather than an error: the
58
+ caller's job is to render a tick, not to reject data.
59
+ """
60
+ outcome = str(annotation.get("outcome", "") or "").strip().lower()
61
+ expected = OUTCOME_EXPECTED_FIELDS.get(outcome, ())
62
+ return {field: _field_is_filled(annotation, field) for field in expected}
63
+
64
+
65
+ def is_complete(annotation: Mapping[str, Any]) -> bool:
66
+ """Whether every field this outcome asks about is filled (trivially true for ``success``)."""
67
+ return all(completeness_flags(annotation).values())
@@ -0,0 +1,93 @@
1
+ """Lightweight, attribute-only diversity heuristics for a dataset (issue #40).
2
+
3
+ Warns (never blocks by default) when a dataset shows very low task or annotation
4
+ diversity — a nudge to catch copy-pasted annotations or an inattentive annotator.
5
+ Reads only HDF5 attrs (never opens videos or decodes arrays) so it is cheap to run
6
+ over a whole session directory, and it never raises.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections import Counter
13
+ from typing import Any
14
+
15
+ import h5py
16
+
17
+ from oopsie_data_tools.annotation_tool.annotation_schema import read_annotation_attrs
18
+ from oopsie_data_tools.utils.h5 import find_episode_files
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Below this many episodes the ratios are too noisy to be meaningful.
23
+ MIN_EPISODES_FOR_CHECK = 5
24
+ # A single description accounting for more than this fraction looks copy-pasted.
25
+ DUP_DESCRIPTION_FRACTION = 0.8
26
+
27
+
28
+ def check_diversity(samples_dir: str) -> list[str]:
29
+ """Return (and WARN-log) low-diversity messages for a session directory.
30
+
31
+ Args:
32
+ samples_dir: Directory containing ``*.h5`` / ``*.hdf5`` episodes.
33
+
34
+ Returns:
35
+ A list of human-readable warning strings (empty if nothing notable).
36
+ """
37
+ files = find_episode_files(samples_dir)
38
+ if len(files) < MIN_EPISODES_FOR_CHECK:
39
+ return []
40
+
41
+ instructions: list[str] = []
42
+ descriptions: list[str] = []
43
+ for path in files:
44
+ try:
45
+ with h5py.File(path, "r") as f:
46
+ instructions.append(_attr_str(f, "language_instruction"))
47
+ ea = f.get("episode_annotations")
48
+ if isinstance(ea, h5py.Group):
49
+ for name in ea.keys():
50
+ sub = ea[name]
51
+ if not isinstance(sub, h5py.Group):
52
+ continue
53
+ # read_annotation_attrs so v1 (failure_description) and v2
54
+ # (episode_description) files both feed the copy-paste check.
55
+ desc = str(
56
+ read_annotation_attrs(sub.attrs).get("episode_description", "")
57
+ ).lower()
58
+ if desc:
59
+ descriptions.append(desc)
60
+ except Exception:
61
+ continue
62
+
63
+ warnings: list[str] = []
64
+ n = len(instructions)
65
+ distinct_instructions = len({i for i in instructions if i})
66
+ if n >= MIN_EPISODES_FOR_CHECK and distinct_instructions <= 1:
67
+ warnings.append(
68
+ f"Low task diversity: all {n} episodes share a single language_instruction."
69
+ )
70
+ elif n >= 10 and distinct_instructions / n < 0.1:
71
+ warnings.append(
72
+ f"Low task diversity: only {distinct_instructions} distinct instruction(s) "
73
+ f"across {n} episodes."
74
+ )
75
+
76
+ if len(descriptions) >= MIN_EPISODES_FOR_CHECK:
77
+ top_desc, top_n = Counter(descriptions).most_common(1)[0]
78
+ if top_n / len(descriptions) > DUP_DESCRIPTION_FRACTION:
79
+ warnings.append(
80
+ f"Possible copied annotations: {top_n}/{len(descriptions)} episode "
81
+ f'descriptions are identical ("{top_desc[:60]}...").'
82
+ )
83
+
84
+ for w in warnings:
85
+ logger.warning("[diversity] %s", w)
86
+ return warnings
87
+
88
+
89
+ def _attr_str(group: h5py.Group | h5py.File, key: str) -> str:
90
+ value: Any = group.attrs.get(key, "")
91
+ if isinstance(value, bytes):
92
+ value = value.decode("utf-8", errors="replace")
93
+ return str(value).strip()
@@ -0,0 +1,54 @@
1
+ """Schema-agnostic episode representation used by loader and validator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Optional
7
+
8
+ import numpy as np
9
+
10
+ from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile
11
+
12
+
13
+ @dataclass
14
+ class VideoInfo:
15
+ """Camera video metadata, constructable from an MP4 file or in-memory frames."""
16
+
17
+ frame_count: int
18
+ fps: float
19
+ width: int
20
+ height: int
21
+
22
+ @classmethod
23
+ def from_frames(cls, frames: list[np.ndarray], fps: float) -> VideoInfo:
24
+ """Build VideoInfo from in-memory frame buffers (recorder pre-save path)."""
25
+ if not frames:
26
+ raise ValueError("Cannot build VideoInfo from empty frame list")
27
+ h, w = frames[0].shape[:2]
28
+ return cls(frame_count=len(frames), fps=fps, width=w, height=h)
29
+
30
+
31
+ @dataclass
32
+ class EpisodeData:
33
+ """Normalized episode data, independent of source schema.
34
+
35
+ Populated by episode_loader (from HDF5) or constructed directly by the
36
+ recorder for pre-save validation. Both always supply a robot profile:
37
+ episode_loader rejects any schema that does not embed one.
38
+ """
39
+
40
+ robot_profile: RobotProfile
41
+ language_instruction: str
42
+ episode_id: str
43
+ lab_id: str
44
+ operator_name: str
45
+ trajectory_length: int
46
+ control_freq: float
47
+ # Eagerly-loaded arrays; shape (T, ...) for each key.
48
+ observations: dict[str, np.ndarray]
49
+ # Only non-empty action datasets; shape (T, ...) for each key.
50
+ actions: dict[str, np.ndarray]
51
+ # Per-camera metadata.
52
+ videos: dict[str, VideoInfo]
53
+ # Optional: annotator_name → {attr_key: attr_val}.
54
+ annotations: Optional[dict[str, dict[str, Any]]] = field(default=None)
@@ -0,0 +1,201 @@
1
+ """Load episode HDF5 files into the schema-agnostic EpisodeData representation.
2
+
3
+ This module owns all file I/O:
4
+ - HDF5 readability checks
5
+ - Schema detection
6
+ - Video file existence and MP4 metadata extraction
7
+
8
+ It does NOT perform semantic validation — that is episode_validator's job.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Any
15
+
16
+ import cv2
17
+ import h5py
18
+ import numpy as np
19
+
20
+ from oopsie_data_tools.utils.h5 import decode_h5_scalar
21
+ from oopsie_data_tools.utils.robot_profile.robot_profile import robot_profile_from_json
22
+ from oopsie_data_tools.utils.validation.episode_data import EpisodeData, VideoInfo
23
+ from oopsie_data_tools.utils.validation.errors import EpisodeValidationError
24
+
25
+ OOPSIE_DATA_SCHEMA_V1 = "oopsiedata_format_v1"
26
+
27
+ _OOPSIE_V1_REQUIRED_ROOT_ATTRS = (
28
+ "schema",
29
+ "episode_id",
30
+ "language_instruction",
31
+ "lab_id",
32
+ "operator_name",
33
+ "robot_profile",
34
+ )
35
+
36
+
37
+ # ── HDF5 scalar helpers ────────────────────────────────────────────────────────
38
+
39
+
40
+ def _read_string_dataset(ds: h5py.Dataset) -> str:
41
+ return decode_h5_scalar(ds[()]).strip()
42
+
43
+
44
+ # ── Video loading ──────────────────────────────────────────────────────────────
45
+
46
+
47
+ def load_video_info(mp4_path: str) -> VideoInfo:
48
+ """Open an MP4 and extract frame metadata. Raises EpisodeValidationError on any failure."""
49
+ if not os.path.exists(mp4_path):
50
+ raise EpisodeValidationError(f"Video file does not exist: {mp4_path}")
51
+ if not os.path.isfile(mp4_path):
52
+ raise EpisodeValidationError(f"Video path is not a file: {mp4_path}")
53
+
54
+ cap = cv2.VideoCapture(mp4_path)
55
+ try:
56
+ if not cap.isOpened():
57
+ raise EpisodeValidationError(f"Could not open video: {mp4_path}")
58
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
59
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
60
+ fps = cap.get(cv2.CAP_PROP_FPS)
61
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
62
+ if not (width > 0 and height > 0):
63
+ raise EpisodeValidationError(f"Invalid dimensions ({width}x{height}): {mp4_path}")
64
+ if not (fps > 0):
65
+ raise EpisodeValidationError(f"Invalid FPS ({fps}): {mp4_path}")
66
+ if not (frame_count > 0):
67
+ raise EpisodeValidationError(f"Invalid frame count ({frame_count}): {mp4_path}")
68
+ return VideoInfo(frame_count=frame_count, fps=fps, width=width, height=height)
69
+ finally:
70
+ cap.release()
71
+
72
+
73
+ def _resolve_video_path(rel: str, h5_dir: str, label: str) -> str:
74
+ if not rel:
75
+ raise EpisodeValidationError(f"Empty video path for {label}")
76
+ return os.path.normpath(os.path.join(h5_dir, rel))
77
+
78
+
79
+ # ── Schema-specific loaders ────────────────────────────────────────────────────
80
+
81
+
82
+ def _load_oopsie_v1(f: h5py.File, h5_dir: str) -> EpisodeData:
83
+ for attr in _OOPSIE_V1_REQUIRED_ROOT_ATTRS:
84
+ if attr not in f.attrs:
85
+ raise EpisodeValidationError(f"Missing root attr: {attr}")
86
+
87
+ try:
88
+ profile = robot_profile_from_json(decode_h5_scalar(f.attrs["robot_profile"]))
89
+ except ValueError as e:
90
+ raise EpisodeValidationError(f"Invalid robot_profile JSON: {e}") from e
91
+
92
+ if "observations" not in f:
93
+ raise EpisodeValidationError("Missing group: observations")
94
+ if "robot_states" not in f["observations"]:
95
+ raise EpisodeValidationError("Missing group: observations/robot_states")
96
+ if "actions" not in f:
97
+ raise EpisodeValidationError("Missing group: actions")
98
+
99
+ rs = f["observations/robot_states"]
100
+ for key in profile.robot_state_keys:
101
+ if key not in rs:
102
+ raise EpisodeValidationError(f"Missing observations/robot_states/{key}")
103
+ observations = {k: rs[k][()] for k in rs.keys()}
104
+
105
+ action_group = f["actions"]
106
+ for key in profile.action_space:
107
+ if key not in action_group:
108
+ raise EpisodeValidationError(f"Missing actions/{key}")
109
+ ds = action_group[key]
110
+ if ds.shape is None:
111
+ raise EpisodeValidationError(
112
+ f"actions/{key} is in profile.action_space but stored as h5py.Empty"
113
+ )
114
+ # Load only non-empty action datasets.
115
+ actions: dict[str, np.ndarray] = {
116
+ k: action_group[k][()] for k in action_group.keys() if action_group[k].shape is not None
117
+ }
118
+
119
+ if "video_paths" not in f["observations"]:
120
+ raise EpisodeValidationError("Missing group: observations/video_paths")
121
+ vp_group = f["observations/video_paths"]
122
+ for cam in profile.camera_names:
123
+ if cam not in vp_group:
124
+ raise EpisodeValidationError(f"Missing observations/video_paths/{cam}")
125
+ videos: dict[str, VideoInfo] = {}
126
+ for cam in vp_group.keys():
127
+ rel = _read_string_dataset(vp_group[cam])
128
+ abs_path = _resolve_video_path(rel, h5_dir, f"camera {cam}")
129
+ videos[cam] = load_video_info(abs_path)
130
+
131
+ annotations = _load_annotations_oopsie_v1(f)
132
+
133
+ trajectory_length = next(iter(observations.values())).shape[0]
134
+
135
+ return EpisodeData(
136
+ robot_profile=profile,
137
+ language_instruction=decode_h5_scalar(f.attrs["language_instruction"]),
138
+ episode_id=decode_h5_scalar(f.attrs["episode_id"]),
139
+ lab_id=decode_h5_scalar(f.attrs["lab_id"]),
140
+ operator_name=decode_h5_scalar(f.attrs["operator_name"]),
141
+ trajectory_length=trajectory_length,
142
+ control_freq=float(profile.control_freq),
143
+ observations=observations,
144
+ actions=actions,
145
+ videos=videos,
146
+ annotations=annotations,
147
+ )
148
+
149
+
150
+ def _load_annotations_oopsie_v1(f: h5py.File) -> dict[str, dict[str, Any]] | None:
151
+ if "episode_annotations" not in f:
152
+ return None
153
+ ea = f["episode_annotations"]
154
+ # Everything in this layer reports failure as EpisodeValidationError. A file storing
155
+ # episode_annotations as a dataset used to reach .keys() and raise AttributeError,
156
+ # which escapes that contract and surfaces as an "unexpected error" with no
157
+ # indication of what is actually wrong with the file.
158
+ if not isinstance(ea, h5py.Group):
159
+ raise EpisodeValidationError(
160
+ "episode_annotations must be a group of per-annotator subgroups, got "
161
+ f"{type(ea).__name__}"
162
+ )
163
+ annotations: dict[str, dict[str, Any]] = {}
164
+ for annotator in ea.keys():
165
+ subgroup = ea[annotator]
166
+ if not isinstance(subgroup, h5py.Group):
167
+ raise EpisodeValidationError(
168
+ f"episode_annotations/{annotator} must be a group, got {type(subgroup).__name__}"
169
+ )
170
+ annotations[annotator] = {k: subgroup.attrs[k] for k in subgroup.attrs}
171
+ return annotations
172
+
173
+
174
+ def load_episode_from_h5(h5_path: str) -> EpisodeData:
175
+ """Load an episode HDF5 file into a schema-agnostic EpisodeData.
176
+
177
+ Raises EpisodeValidationError with a descriptive message if the file is unreadable,
178
+ structurally invalid, or references missing/corrupt video files.
179
+ """
180
+ resolved = os.path.abspath(os.path.normpath(h5_path))
181
+ if not os.path.exists(resolved):
182
+ raise EpisodeValidationError(f"H5 file does not exist: {resolved}")
183
+ if not os.path.isfile(resolved):
184
+ raise EpisodeValidationError(f"H5 path is not a file: {resolved}")
185
+
186
+ try:
187
+ f = h5py.File(resolved, "r")
188
+ except Exception as e:
189
+ raise EpisodeValidationError(f"H5 file is not readable: {resolved}. Error: {e}") from e
190
+
191
+ h5_dir = os.path.dirname(resolved)
192
+ try:
193
+ schema = decode_h5_scalar(f.attrs.get("schema", ""))
194
+ if schema == OOPSIE_DATA_SCHEMA_V1:
195
+ return _load_oopsie_v1(f, h5_dir)
196
+ else:
197
+ raise EpisodeValidationError(
198
+ f"Unsupported or missing schema: '{schema}' in file: {resolved}"
199
+ )
200
+ finally:
201
+ f.close()