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,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,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()
|