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,406 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate a set of valid test HDF5 episodes with synthetic MP4 videos.
|
|
3
|
+
|
|
4
|
+
Writes files into OUTPUT_DIR (default: oopsie_data_tools/test/fixtures/samples/).
|
|
5
|
+
Each fixture covers a distinct annotation scenario so that the annotation tool,
|
|
6
|
+
annotator server, and any downstream readers can be exercised without real data.
|
|
7
|
+
|
|
8
|
+
Scenarios created
|
|
9
|
+
-----------------
|
|
10
|
+
episode_unannotated – minimally annotated success; the general-purpose "valid episode"
|
|
11
|
+
episode_no_annotations – no annotation group at all: passes lenient validation, fails strict
|
|
12
|
+
episode_success – annotated as success by "test_annotator", with notes
|
|
13
|
+
episode_failure – annotated as failure with taxonomy by "test_annotator"
|
|
14
|
+
episode_multi_camera – two cameras (left + wrist), annotated as success
|
|
15
|
+
episode_legacy_v1 – a taxonomy v1 annotation, prose values and all, for back-compat
|
|
16
|
+
|
|
17
|
+
Usage
|
|
18
|
+
-----
|
|
19
|
+
uv run python -m oopsie_data_tools.test.fixtures.make_valid [OUTPUT_DIR]
|
|
20
|
+
|
|
21
|
+
The videos are solid-color 64×64 MP4s (10 frames each). They are tiny
|
|
22
|
+
(< 20 KB) and require only imageio + libx264.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import h5py
|
|
33
|
+
import imageio
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
from oopsie_data_tools.annotation_tool.annotation_schema import write_annotation_attrs
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Helpers
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
_STR_DTYPE = h5py.string_dtype(encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
_ROBOT_PROFILE = json.dumps(
|
|
45
|
+
{
|
|
46
|
+
"policy_name": "test_policy",
|
|
47
|
+
"robot_name": "test_robot",
|
|
48
|
+
"is_biarm": False,
|
|
49
|
+
"uses_mobile_base": False,
|
|
50
|
+
"gripper_name": "test_gripper",
|
|
51
|
+
"control_freq": 10,
|
|
52
|
+
"camera_names": ["front"],
|
|
53
|
+
"robot_state_keys": ["joint_position", "gripper_position"],
|
|
54
|
+
"robot_state_joint_names": ["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
|
|
55
|
+
"action_space": ["joint_velocity", "gripper_position"],
|
|
56
|
+
"action_joint_names": ["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
|
|
57
|
+
"orientation_representation": None,
|
|
58
|
+
"controller": None,
|
|
59
|
+
"gains": None,
|
|
60
|
+
"intrinsic_calibration_matrix": None,
|
|
61
|
+
"extrinsic_calibration_matrix": None,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_ROBOT_PROFILE_MULTI_CAM = json.dumps(
|
|
66
|
+
{
|
|
67
|
+
**json.loads(_ROBOT_PROFILE),
|
|
68
|
+
"camera_names": ["left", "wrist"],
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _write_video(path: Path, color: tuple[int, int, int], n_frames: int = 25, size: int = 224) -> None:
|
|
74
|
+
frames = np.full((n_frames, size, size, 3), color, dtype=np.uint8)
|
|
75
|
+
with imageio.get_writer(
|
|
76
|
+
str(path),
|
|
77
|
+
format="FFMPEG",
|
|
78
|
+
mode="I",
|
|
79
|
+
fps=10,
|
|
80
|
+
codec="libx264",
|
|
81
|
+
output_params=["-crf", "28"],
|
|
82
|
+
) as writer:
|
|
83
|
+
for frame in frames:
|
|
84
|
+
writer.append_data(frame)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _robot_states(n: int = 20) -> dict[str, np.ndarray]:
|
|
88
|
+
return {
|
|
89
|
+
"joint_position": np.random.randn(n, 7).astype(np.float64),
|
|
90
|
+
"gripper_position": np.random.randn(n, 1).astype(np.float64),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _actions(n: int = 20) -> dict[str, np.ndarray | None]:
|
|
95
|
+
return {
|
|
96
|
+
"joint_velocity": np.random.randn(n, 7).astype(np.float64),
|
|
97
|
+
"gripper_position": np.random.randn(n, 1).astype(np.float64),
|
|
98
|
+
"cartesian_position": None,
|
|
99
|
+
"cartesian_velocity": None,
|
|
100
|
+
"joint_position": None,
|
|
101
|
+
"base_position": None,
|
|
102
|
+
"base_velocity": None,
|
|
103
|
+
"gripper_velocity": None,
|
|
104
|
+
"gripper_binary": None,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _write_base_h5(
|
|
109
|
+
f: h5py.File,
|
|
110
|
+
*,
|
|
111
|
+
episode_id: str,
|
|
112
|
+
language_instruction: str,
|
|
113
|
+
camera_video_paths: dict[str, str],
|
|
114
|
+
robot_profile_json: str = _ROBOT_PROFILE,
|
|
115
|
+
n_steps: int = 20,
|
|
116
|
+
) -> None:
|
|
117
|
+
f.attrs["schema"] = "oopsiedata_format_v1"
|
|
118
|
+
f.attrs["episode_id"] = episode_id
|
|
119
|
+
f.attrs["robot_profile"] = robot_profile_json
|
|
120
|
+
f.attrs["language_instruction"] = language_instruction
|
|
121
|
+
f.attrs["operator_name"] = "test_operator"
|
|
122
|
+
f.attrs["lab_id"] = "test_lab"
|
|
123
|
+
f.attrs["timestamp"] = time.time()
|
|
124
|
+
|
|
125
|
+
obs_group = f.create_group("observations")
|
|
126
|
+
vp_group = obs_group.create_group("video_paths")
|
|
127
|
+
for cam, rel_path in camera_video_paths.items():
|
|
128
|
+
vp_group.create_dataset(cam, data=rel_path, dtype=_STR_DTYPE)
|
|
129
|
+
rs = obs_group.create_group("robot_states")
|
|
130
|
+
for key, arr in _robot_states(n_steps).items():
|
|
131
|
+
rs.create_dataset(key, data=arr, dtype=np.float64)
|
|
132
|
+
|
|
133
|
+
actions_group = f.create_group("actions")
|
|
134
|
+
for key, val in _actions(n_steps).items():
|
|
135
|
+
if val is None:
|
|
136
|
+
actions_group.create_dataset(key, data=h5py.Empty(dtype=np.float64))
|
|
137
|
+
else:
|
|
138
|
+
actions_group.create_dataset(key, data=val, dtype=np.float64)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _write_annotation(
|
|
142
|
+
f: h5py.File,
|
|
143
|
+
*,
|
|
144
|
+
annotator: str,
|
|
145
|
+
outcome: str,
|
|
146
|
+
episode_description: str = "",
|
|
147
|
+
side_effect_category: list[str] | None = None,
|
|
148
|
+
severity: str = "",
|
|
149
|
+
additional_notes: str = "",
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Write a v2 annotation through the real writer.
|
|
152
|
+
|
|
153
|
+
Going through ``write_annotation_attrs`` rather than hand-rolling the attrs is the
|
|
154
|
+
point: a fixture that spells the attr set out itself keeps passing after a schema
|
|
155
|
+
change, which is exactly when it should fail.
|
|
156
|
+
"""
|
|
157
|
+
ea = f.require_group("episode_annotations")
|
|
158
|
+
ag = ea.require_group(annotator)
|
|
159
|
+
write_annotation_attrs(
|
|
160
|
+
ag,
|
|
161
|
+
{
|
|
162
|
+
"outcome": outcome,
|
|
163
|
+
"timestamp": "2026-04-21T10:00:00+00:00",
|
|
164
|
+
"episode_description": episode_description,
|
|
165
|
+
"side_effect_category": list(side_effect_category or []),
|
|
166
|
+
"severity": severity,
|
|
167
|
+
"additional_notes": additional_notes,
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _write_legacy_v1_annotation(
|
|
173
|
+
f: h5py.File,
|
|
174
|
+
*,
|
|
175
|
+
annotator: str,
|
|
176
|
+
success: float,
|
|
177
|
+
failure_description: str = "",
|
|
178
|
+
failure_category: list[str] | None = None,
|
|
179
|
+
severity: str = "",
|
|
180
|
+
success_category: str = "",
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Write the pre-v2 attr set verbatim, prose values and all.
|
|
183
|
+
|
|
184
|
+
Deliberately hand-rolled: this fixture's job is to pin what v1 files actually look
|
|
185
|
+
like on disk, so it must not follow the writer as it moves on.
|
|
186
|
+
"""
|
|
187
|
+
ea = f.require_group("episode_annotations")
|
|
188
|
+
ag = ea.require_group(annotator)
|
|
189
|
+
taxonomy: dict = {
|
|
190
|
+
"failure_category": list(failure_category or []),
|
|
191
|
+
"severity": severity,
|
|
192
|
+
}
|
|
193
|
+
if success_category:
|
|
194
|
+
taxonomy["success_category"] = success_category
|
|
195
|
+
ag.attrs["schema"] = "oopsie_failure_taxonomy_v1"
|
|
196
|
+
ag.attrs["source"] = "human"
|
|
197
|
+
ag.attrs["timestamp"] = "2026-04-21T10:00:00+00:00"
|
|
198
|
+
ag.attrs["success"] = success
|
|
199
|
+
ag.attrs["failure_description"] = failure_description
|
|
200
|
+
ag.attrs["taxonomy_schema"] = "oopsiedata_taxonomy_schema_v1"
|
|
201
|
+
ag.attrs["taxonomy"] = json.dumps(taxonomy, ensure_ascii=False)
|
|
202
|
+
ag.attrs["additional_notes"] = ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Public helper for per-test fixture generation
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def write_valid_episode(
|
|
211
|
+
out_dir: Path,
|
|
212
|
+
stem: str = "episode",
|
|
213
|
+
*,
|
|
214
|
+
cam_color: tuple[int, int, int] = (120, 160, 200),
|
|
215
|
+
n: int = 20,
|
|
216
|
+
) -> Path:
|
|
217
|
+
"""Write a single valid episode (HDF5 + MP4) into *out_dir* and return the HDF5 path.
|
|
218
|
+
|
|
219
|
+
Intended for use in pytest fixtures that need an isolated valid episode,
|
|
220
|
+
e.g. ``tmp_path``-based per-test fixtures.
|
|
221
|
+
"""
|
|
222
|
+
mp4_name = f"{stem}_front.mp4"
|
|
223
|
+
_write_video(out_dir / mp4_name, color=cam_color)
|
|
224
|
+
h5_path = out_dir / f"{stem}.h5"
|
|
225
|
+
with h5py.File(h5_path, "w") as f:
|
|
226
|
+
_write_base_h5(
|
|
227
|
+
f,
|
|
228
|
+
episode_id=stem,
|
|
229
|
+
language_instruction="pick up the block",
|
|
230
|
+
camera_video_paths={"front": mp4_name},
|
|
231
|
+
n_steps=n,
|
|
232
|
+
)
|
|
233
|
+
_write_annotation(
|
|
234
|
+
f,
|
|
235
|
+
annotator="test_annotator",
|
|
236
|
+
outcome="success",
|
|
237
|
+
)
|
|
238
|
+
return h5_path
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Fixture writers
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def make_unannotated(out_dir: Path) -> None:
|
|
247
|
+
# Despite the name, this *is* annotated — a minimal success. It is the general-purpose
|
|
248
|
+
# "valid episode" behind the `valid_episode` fixture, and validation through the CLI
|
|
249
|
+
# checks annotations unconditionally, so it has to carry one to be usable as such.
|
|
250
|
+
# For an episode with no annotation group, use make_no_annotations below.
|
|
251
|
+
_write_video(out_dir / "episode_unannotated_front.mp4", color=(80, 120, 200))
|
|
252
|
+
with h5py.File(out_dir / "episode_unannotated.h5", "w") as f:
|
|
253
|
+
_write_base_h5(
|
|
254
|
+
f,
|
|
255
|
+
episode_id="episode_unannotated",
|
|
256
|
+
language_instruction="pick up the red block",
|
|
257
|
+
camera_video_paths={"front": "episode_unannotated_front.mp4"},
|
|
258
|
+
)
|
|
259
|
+
_write_annotation(
|
|
260
|
+
f,
|
|
261
|
+
annotator="test_annotator",
|
|
262
|
+
outcome="success",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def make_no_annotations(out_dir: Path) -> None:
|
|
267
|
+
"""A structurally valid episode carrying no ``episode_annotations`` group.
|
|
268
|
+
|
|
269
|
+
This is what a freshly recorded, not-yet-annotated episode looks like: it satisfies
|
|
270
|
+
every structural rule, so it passes lenient validation, and fails the strict check
|
|
271
|
+
that ``oopsie-data validate`` and ``upload`` actually run.
|
|
272
|
+
"""
|
|
273
|
+
_write_video(out_dir / "episode_no_annotations_front.mp4", color=(80, 120, 200))
|
|
274
|
+
with h5py.File(out_dir / "episode_no_annotations.h5", "w") as f:
|
|
275
|
+
_write_base_h5(
|
|
276
|
+
f,
|
|
277
|
+
episode_id="episode_no_annotations",
|
|
278
|
+
language_instruction="pick up the red block",
|
|
279
|
+
camera_video_paths={"front": "episode_no_annotations_front.mp4"},
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def make_success(out_dir: Path) -> None:
|
|
284
|
+
_write_video(out_dir / "episode_success_front.mp4", color=(60, 180, 80))
|
|
285
|
+
with h5py.File(out_dir / "episode_success.h5", "w") as f:
|
|
286
|
+
_write_base_h5(
|
|
287
|
+
f,
|
|
288
|
+
episode_id="episode_success",
|
|
289
|
+
language_instruction="place the cup on the tray",
|
|
290
|
+
camera_video_paths={"front": "episode_success_front.mp4"},
|
|
291
|
+
)
|
|
292
|
+
_write_annotation(
|
|
293
|
+
f,
|
|
294
|
+
annotator="test_annotator",
|
|
295
|
+
outcome="success",
|
|
296
|
+
additional_notes="Clean success, no issues.",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def make_failure(out_dir: Path) -> None:
|
|
301
|
+
_write_video(out_dir / "episode_failure_front.mp4", color=(200, 60, 60))
|
|
302
|
+
with h5py.File(out_dir / "episode_failure.h5", "w") as f:
|
|
303
|
+
_write_base_h5(
|
|
304
|
+
f,
|
|
305
|
+
episode_id="episode_failure",
|
|
306
|
+
language_instruction="stack the two blocks",
|
|
307
|
+
camera_video_paths={"front": "episode_failure_front.mp4"},
|
|
308
|
+
)
|
|
309
|
+
_write_annotation(
|
|
310
|
+
f,
|
|
311
|
+
annotator="test_annotator",
|
|
312
|
+
outcome="failure",
|
|
313
|
+
episode_description="Robot grasped the block but dropped it during transport.",
|
|
314
|
+
side_effect_category=["grasp", "manipulation"],
|
|
315
|
+
severity="medium",
|
|
316
|
+
additional_notes="Happens consistently at the same waypoint.",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def make_multi_camera(out_dir: Path) -> None:
|
|
321
|
+
_write_video(out_dir / "episode_multi_camera_left.mp4", color=(160, 80, 200))
|
|
322
|
+
_write_video(out_dir / "episode_multi_camera_wrist.mp4", color=(200, 160, 40))
|
|
323
|
+
with h5py.File(out_dir / "episode_multi_camera.h5", "w") as f:
|
|
324
|
+
_write_base_h5(
|
|
325
|
+
f,
|
|
326
|
+
episode_id="episode_multi_camera",
|
|
327
|
+
language_instruction="open the drawer",
|
|
328
|
+
camera_video_paths={
|
|
329
|
+
"left": "episode_multi_camera_left.mp4",
|
|
330
|
+
"wrist": "episode_multi_camera_wrist.mp4",
|
|
331
|
+
},
|
|
332
|
+
robot_profile_json=_ROBOT_PROFILE_MULTI_CAM,
|
|
333
|
+
)
|
|
334
|
+
_write_annotation(
|
|
335
|
+
f,
|
|
336
|
+
annotator="test_annotator",
|
|
337
|
+
outcome="success",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def make_legacy_v1(out_dir: Path) -> None:
|
|
342
|
+
"""A file still carrying the v1 annotation schema.
|
|
343
|
+
|
|
344
|
+
Readers upcast v1 on the fly and never rewrite it, so this pins that path: prose
|
|
345
|
+
category and severity values, ``failure_description`` rather than
|
|
346
|
+
``episode_description``, and a partial taxonomy that v1 validation used to reject.
|
|
347
|
+
"""
|
|
348
|
+
_write_video(out_dir / "episode_legacy_v1_front.mp4", color=(90, 140, 90))
|
|
349
|
+
with h5py.File(out_dir / "episode_legacy_v1.h5", "w") as f:
|
|
350
|
+
_write_base_h5(
|
|
351
|
+
f,
|
|
352
|
+
episode_id="episode_legacy_v1",
|
|
353
|
+
language_instruction="stack the red block on the blue block",
|
|
354
|
+
camera_video_paths={"front": "episode_legacy_v1_front.mp4"},
|
|
355
|
+
)
|
|
356
|
+
_write_legacy_v1_annotation(
|
|
357
|
+
f,
|
|
358
|
+
annotator="test_annotator",
|
|
359
|
+
success=0.0,
|
|
360
|
+
failure_description="Gripper closed early and knocked the block over.",
|
|
361
|
+
failure_category=["Grasp failure (at contact)", "Collision failure"],
|
|
362
|
+
severity="Medium severity - some damage or risk of damage or significant reset required, but can be reattempted",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# Entry point
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
_DEFAULT_OUT = Path(__file__).resolve().parent / "samples"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def main() -> None:
|
|
374
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
375
|
+
parser.add_argument(
|
|
376
|
+
"output_dir",
|
|
377
|
+
nargs="?",
|
|
378
|
+
type=Path,
|
|
379
|
+
default=_DEFAULT_OUT,
|
|
380
|
+
help=f"Destination directory (default: {_DEFAULT_OUT})",
|
|
381
|
+
)
|
|
382
|
+
args = parser.parse_args()
|
|
383
|
+
out: Path = args.output_dir
|
|
384
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
|
|
386
|
+
for maker in [
|
|
387
|
+
make_unannotated,
|
|
388
|
+
make_no_annotations,
|
|
389
|
+
make_success,
|
|
390
|
+
make_failure,
|
|
391
|
+
make_multi_camera,
|
|
392
|
+
make_legacy_v1,
|
|
393
|
+
]:
|
|
394
|
+
name = maker.__name__.replace("make_", "")
|
|
395
|
+
print(f" writing {name}...", end=" ", flush=True)
|
|
396
|
+
maker(out)
|
|
397
|
+
print("done")
|
|
398
|
+
|
|
399
|
+
h5_files = list(out.glob("*.h5"))
|
|
400
|
+
mp4_files = list(out.glob("*.mp4"))
|
|
401
|
+
print(f"\nFixtures written to: {out}")
|
|
402
|
+
print(f" {len(h5_files)} HDF5 files, {len(mp4_files)} MP4 files")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
main()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""What is stored on disk and what the form hands over must mean the same thing.
|
|
2
|
+
|
|
3
|
+
Validation no longer has an opinion here — only ``outcome`` is required, and every taxonomy
|
|
4
|
+
field is optional. What remains is the UI's tick, which tells an annotator whether the
|
|
5
|
+
outcome they picked still has blank fields. That answer must not depend on whether the
|
|
6
|
+
annotation came out of an HDF5 file or straight off the form, so both shapes are run through
|
|
7
|
+
:func:`read_annotation_attrs` and compared.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from oopsie_data_tools.annotation_tool.annotation_schema import (
|
|
17
|
+
OUTCOMES,
|
|
18
|
+
read_annotation_attrs,
|
|
19
|
+
write_annotation_attrs,
|
|
20
|
+
)
|
|
21
|
+
from oopsie_data_tools.annotation_tool.annotator_server import _annotation_tick_level
|
|
22
|
+
from oopsie_data_tools.utils.validation.annotation_completeness import (
|
|
23
|
+
OUTCOME_EXPECTED_FIELDS,
|
|
24
|
+
completeness_flags,
|
|
25
|
+
is_complete,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
NONE, PARTIAL, COMPLETE = 0, 1, 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _h5_attrs(outcome, category, description, severity) -> dict:
|
|
32
|
+
"""Annotation as the HDF5 stores it: category and severity inside a taxonomy blob."""
|
|
33
|
+
return {
|
|
34
|
+
"episode_description": description,
|
|
35
|
+
"taxonomy": json.dumps(
|
|
36
|
+
{
|
|
37
|
+
"outcome": outcome,
|
|
38
|
+
"side_effect_category": category,
|
|
39
|
+
"severity": severity,
|
|
40
|
+
}
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _form_dict(outcome, category, description, severity) -> dict:
|
|
46
|
+
"""The same annotation as the form hands it over: flat keys."""
|
|
47
|
+
return {
|
|
48
|
+
"outcome": outcome,
|
|
49
|
+
"side_effect_category": category,
|
|
50
|
+
"episode_description": description,
|
|
51
|
+
"severity": severity,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize(
|
|
56
|
+
"category,description,severity",
|
|
57
|
+
[
|
|
58
|
+
([], "", ""),
|
|
59
|
+
(["grasp"], "slipped", "low"),
|
|
60
|
+
([], "slipped", ""),
|
|
61
|
+
(["grasp"], "", "low"),
|
|
62
|
+
([""], " ", " "), # whitespace and an empty string are not "filled"
|
|
63
|
+
("grasp", "slipped", "low"), # category as a scalar, from older records
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
def test_both_shapes_agree_on_which_fields_are_filled(category, description, severity):
|
|
67
|
+
"""The stored and in-flight representations must resolve to the same flags."""
|
|
68
|
+
stored = read_annotation_attrs(_h5_attrs("failure", category, description, severity))
|
|
69
|
+
direct = _form_dict("failure", category, description, severity)
|
|
70
|
+
|
|
71
|
+
assert completeness_flags(stored) == completeness_flags(direct)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_whitespace_does_not_count_as_filled():
|
|
75
|
+
flags = completeness_flags(_form_dict("failure", [], " ", "\t"))
|
|
76
|
+
assert flags == {
|
|
77
|
+
"episode_description": False,
|
|
78
|
+
"side_effect_category": False,
|
|
79
|
+
"severity": False,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_an_empty_category_list_is_not_filled():
|
|
84
|
+
flags = completeness_flags(_form_dict("failure", [], "x", "x"))
|
|
85
|
+
assert flags["side_effect_category"] is False
|
|
86
|
+
assert flags["episode_description"] is True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── The tick, per outcome ─────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.parametrize(
|
|
93
|
+
"label,outcome,category,description,severity,tick",
|
|
94
|
+
[
|
|
95
|
+
# A clean success asks for nothing else, so it is complete the moment it is picked.
|
|
96
|
+
("clean success", "success", [], "", "", COMPLETE),
|
|
97
|
+
# Anything set on a clean success is ignored: those fields are never shown for it.
|
|
98
|
+
("clean success ignores extras", "success", ["grasp"], "x", "low", COMPLETE),
|
|
99
|
+
("suboptimal without a description", "success_suboptimal", [], "", "", PARTIAL),
|
|
100
|
+
("suboptimal with a description", "success_suboptimal", [], "clumsy", "", COMPLETE),
|
|
101
|
+
# Category and severity are not shown for suboptimal, so they cannot hold it back.
|
|
102
|
+
("suboptimal ignores category", "success_suboptimal", [], "clumsy", "", COMPLETE),
|
|
103
|
+
("side-effect, nothing filled", "success_side_effect", [], "", "", PARTIAL),
|
|
104
|
+
("side-effect, partly filled", "success_side_effect", ["collision"], "", "", PARTIAL),
|
|
105
|
+
(
|
|
106
|
+
"side-effect, fully filled",
|
|
107
|
+
"success_side_effect",
|
|
108
|
+
["collision"],
|
|
109
|
+
"clipped a cup",
|
|
110
|
+
"low",
|
|
111
|
+
COMPLETE,
|
|
112
|
+
),
|
|
113
|
+
# Previously this combination was rejected outright; now it is simply unfinished.
|
|
114
|
+
("failure, nothing filled", "failure", [], "", "", PARTIAL),
|
|
115
|
+
("failure, only a description", "failure", [], "slipped", "", PARTIAL),
|
|
116
|
+
("failure, fully filled", "failure", ["grasp"], "slipped", "low", COMPLETE),
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
def test_tick_level_per_outcome(label, outcome, category, description, severity, tick):
|
|
120
|
+
form = _form_dict(outcome, category, description, severity)
|
|
121
|
+
assert _annotation_tick_level(form) == tick, label
|
|
122
|
+
|
|
123
|
+
stored = read_annotation_attrs(_h5_attrs(outcome, category, description, severity))
|
|
124
|
+
assert _annotation_tick_level(stored) == tick, f"{label} (via stored attrs)"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_an_unannotated_episode_ticks_none():
|
|
128
|
+
assert _annotation_tick_level({"outcome": ""}) == NONE
|
|
129
|
+
assert _annotation_tick_level({}) == NONE
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_an_unrecognized_outcome_ticks_none():
|
|
133
|
+
"""A slug the vocabulary does not know is not a partial annotation — it is no signal."""
|
|
134
|
+
assert _annotation_tick_level({"outcome": "sort_of_worked"}) == NONE
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_expected_fields_cover_every_outcome():
|
|
138
|
+
"""The tick's field table and the schema's outcome list must not drift apart."""
|
|
139
|
+
assert set(OUTCOME_EXPECTED_FIELDS) == set(OUTCOMES)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_a_round_trip_through_hdf5_preserves_completeness(tmp_path):
|
|
143
|
+
"""Whatever the writer stores must read back with the same tick it went in with."""
|
|
144
|
+
import h5py
|
|
145
|
+
|
|
146
|
+
form = _form_dict("success_side_effect", ["collision"], "clipped a cup", "low")
|
|
147
|
+
path = tmp_path / "round_trip.h5"
|
|
148
|
+
with h5py.File(path, "w") as f:
|
|
149
|
+
write_annotation_attrs(f.require_group("episode_annotations/a"), form)
|
|
150
|
+
with h5py.File(path, "r") as f:
|
|
151
|
+
stored = read_annotation_attrs(f["episode_annotations/a"].attrs)
|
|
152
|
+
|
|
153
|
+
assert is_complete(stored) is True
|
|
154
|
+
assert _annotation_tick_level(stored) == COMPLETE
|