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,329 @@
|
|
|
1
|
+
"""Interactive setup wizard behind ``oopsie-data init``.
|
|
2
|
+
|
|
3
|
+
Writes ``contributor_config.yaml`` — the lab id and HuggingFace token every contributor
|
|
4
|
+
needs before uploading — into the config directory chosen in :func:`choose_target_dir`,
|
|
5
|
+
which follows the lookup order documented in :mod:`oopsie_data_tools.utils.paths`.
|
|
6
|
+
|
|
7
|
+
Robot profiles are not created here: they are hand-written YAML, documented in
|
|
8
|
+
``configs/robot_profiles/template.yaml`` and on the docs site.
|
|
9
|
+
|
|
10
|
+
Every question can be answered ahead of time with a flag, so a fully-flagged invocation
|
|
11
|
+
runs unattended (see ``oopsie-data init --help``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import stat
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Callable, Sequence
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from oopsie_data_tools.utils import paths
|
|
26
|
+
from oopsie_data_tools.utils.contributor_config import read_contributor_config
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
CONTRIBUTOR_CONFIG_NAME = "contributor_config.yaml"
|
|
31
|
+
PLACEHOLDER_LAB_ID = "your_lab_id"
|
|
32
|
+
REGISTRATION_URL = "https://forms.gle/9arwZHAvRjvbozoT7"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WizardAbort(Exception):
|
|
36
|
+
"""Raised when the wizard cannot continue (no TTY, or the user gave up on a question)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── prompt helpers ────────────────────────────────────────────────────────────
|
|
40
|
+
#
|
|
41
|
+
# Same contract as cli._prompt_annotator_name: never block on a non-interactive stdin,
|
|
42
|
+
# re-ask a bounded number of times, strip every answer.
|
|
43
|
+
|
|
44
|
+
_MAX_ATTEMPTS = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _require_tty(what: str) -> None:
|
|
48
|
+
if not sys.stdin.isatty():
|
|
49
|
+
raise WizardAbort(
|
|
50
|
+
f"Cannot ask for {what}: stdin is not a terminal. "
|
|
51
|
+
"Pass the value as a command line flag to run non-interactively."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _with_default(prompt: str, default: str | None) -> str:
|
|
56
|
+
return f"{prompt} [{default}]: " if default else f"{prompt}: "
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ask(
|
|
60
|
+
prompt: str,
|
|
61
|
+
default: str | None = None,
|
|
62
|
+
validate: Callable[[str], str | None] | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Ask for a string. ``validate`` returns an error message, or None if the value is fine."""
|
|
65
|
+
_require_tty(prompt)
|
|
66
|
+
for _ in range(_MAX_ATTEMPTS):
|
|
67
|
+
answer = input(_with_default(prompt, default)).strip()
|
|
68
|
+
if not answer and default is not None:
|
|
69
|
+
answer = default
|
|
70
|
+
if not answer:
|
|
71
|
+
print(" Please enter a value.")
|
|
72
|
+
continue
|
|
73
|
+
error = validate(answer) if validate else None
|
|
74
|
+
if error:
|
|
75
|
+
print(f" {error}")
|
|
76
|
+
continue
|
|
77
|
+
return answer
|
|
78
|
+
raise WizardAbort(f"No valid answer for: {prompt}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def ask_optional(prompt: str, default: str | None = None) -> str:
|
|
82
|
+
"""Ask for a string that may be left empty."""
|
|
83
|
+
_require_tty(prompt)
|
|
84
|
+
answer = input(_with_default(prompt, default)).strip()
|
|
85
|
+
if not answer and default is not None:
|
|
86
|
+
return default
|
|
87
|
+
return answer
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def ask_bool(prompt: str, default: bool = False) -> bool:
|
|
91
|
+
_require_tty(prompt)
|
|
92
|
+
hint = "[Y/n]" if default else "[y/N]"
|
|
93
|
+
for _ in range(_MAX_ATTEMPTS):
|
|
94
|
+
answer = input(f"{prompt} {hint}: ").strip().lower()
|
|
95
|
+
if not answer:
|
|
96
|
+
return default
|
|
97
|
+
if answer in ("y", "yes"):
|
|
98
|
+
return True
|
|
99
|
+
if answer in ("n", "no"):
|
|
100
|
+
return False
|
|
101
|
+
print(" Please answer y or n.")
|
|
102
|
+
raise WizardAbort(f"No valid answer for: {prompt}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def ask_choice(prompt: str, options: Sequence[str], default: str | None = None) -> str:
|
|
106
|
+
_require_tty(prompt)
|
|
107
|
+
print(f"\n{prompt}")
|
|
108
|
+
for index, option in enumerate(options, start=1):
|
|
109
|
+
marker = " <- default" if option == default else ""
|
|
110
|
+
print(f" {index}) {option}{marker}")
|
|
111
|
+
|
|
112
|
+
def _validate(value: str) -> str | None:
|
|
113
|
+
if value in options:
|
|
114
|
+
return None
|
|
115
|
+
if value.isdigit() and 1 <= int(value) <= len(options):
|
|
116
|
+
return None
|
|
117
|
+
return f"Please enter a number between 1 and {len(options)}."
|
|
118
|
+
|
|
119
|
+
answer = ask("Choice", default, _validate)
|
|
120
|
+
return options[int(answer) - 1] if answer.isdigit() else answer
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── config directory ──────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def choose_target_dir() -> Path:
|
|
127
|
+
"""Pick the directory to write the config into.
|
|
128
|
+
|
|
129
|
+
``$OOPSIE_CONFIG_DIR`` (set directly or via ``--config-dir``) wins outright. Otherwise
|
|
130
|
+
the per-user config directory is the default everywhere; inside a checkout we still
|
|
131
|
+
offer the checkout's ``configs/``, but never pick it for you.
|
|
132
|
+
|
|
133
|
+
The default deliberately points away from the checkout. The file holds a HuggingFace
|
|
134
|
+
token, and a token inside a git working tree is one ``git add -A`` away from being
|
|
135
|
+
published — which is exactly how it used to be committed.
|
|
136
|
+
"""
|
|
137
|
+
env_dir = paths.env_config_dir()
|
|
138
|
+
if env_dir is not None:
|
|
139
|
+
logger.info("Using config directory from $%s: %s", paths.ENV_CONFIG_DIR, env_dir)
|
|
140
|
+
return env_dir
|
|
141
|
+
|
|
142
|
+
repo_dir = paths.repo_config_dir()
|
|
143
|
+
user_dir = paths.user_config_dir()
|
|
144
|
+
if repo_dir is None or not sys.stdin.isatty():
|
|
145
|
+
logger.info("Using config directory: %s", user_dir)
|
|
146
|
+
return user_dir
|
|
147
|
+
|
|
148
|
+
user_option = f"{user_dir} (your user config directory)"
|
|
149
|
+
repo_option = f"{repo_dir} (this checkout — holds a token inside a git working tree)"
|
|
150
|
+
choice = ask_choice(
|
|
151
|
+
"Where should your config be saved? Both are found automatically.",
|
|
152
|
+
[user_option, repo_option],
|
|
153
|
+
default=user_option,
|
|
154
|
+
)
|
|
155
|
+
return repo_dir if choice.startswith(str(repo_dir)) else user_dir
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _shell_rc_path() -> Path:
|
|
159
|
+
shell = os.path.basename(os.environ.get("SHELL", "")).strip()
|
|
160
|
+
name = ".zshrc" if shell == "zsh" else ".bashrc"
|
|
161
|
+
return Path.home() / name
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def advise_persisting_config_dir(target_dir: Path, from_flag: bool = False) -> None:
|
|
165
|
+
"""Tell the user how to make ``target_dir`` stick, if it would not be found next run.
|
|
166
|
+
|
|
167
|
+
``from_flag`` marks a location that came from ``--config-dir``: the CLI exports that into
|
|
168
|
+
the environment for the current process only, so it must not count as persisted.
|
|
169
|
+
"""
|
|
170
|
+
if target_dir in (paths.user_config_dir(), paths.repo_config_dir()):
|
|
171
|
+
return # found by the normal lookup order
|
|
172
|
+
if paths.env_config_dir() == target_dir and not from_flag:
|
|
173
|
+
return # already exported in the user's environment
|
|
174
|
+
|
|
175
|
+
export_line = f'export {paths.ENV_CONFIG_DIR}="{target_dir}"'
|
|
176
|
+
rc_path = _shell_rc_path()
|
|
177
|
+
logger.warning(
|
|
178
|
+
"\nOne more step: %s is not a location oopsie-data looks in by default, so a new "
|
|
179
|
+
"terminal will not find it.\nAdd this line to %s to make it stick:\n\n %s\n",
|
|
180
|
+
target_dir, rc_path, export_line,
|
|
181
|
+
)
|
|
182
|
+
if sys.stdin.isatty() and ask_bool(f"Append that line to {rc_path} now?", default=False):
|
|
183
|
+
with rc_path.open("a", encoding="utf-8") as handle:
|
|
184
|
+
handle.write(f"\n# Added by oopsie-data init\n{export_line}\n")
|
|
185
|
+
logger.info("Done. Run 'source %s' or open a new terminal.", rc_path)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ── contributor config ────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _read_existing_yaml(path: Path) -> dict[str, Any]:
|
|
192
|
+
if not path.is_file():
|
|
193
|
+
return {}
|
|
194
|
+
try:
|
|
195
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
196
|
+
except yaml.YAMLError:
|
|
197
|
+
return {}
|
|
198
|
+
return data if isinstance(data, dict) else {}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _mask(token: str) -> str:
|
|
202
|
+
return f"{token[:5]}…{token[-4:]}" if len(token) > 12 else "…"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _validate_lab_id(value: str) -> str | None:
|
|
206
|
+
if value == PLACEHOLDER_LAB_ID:
|
|
207
|
+
return f"'{PLACEHOLDER_LAB_ID}' is the placeholder value — use the lab id you were given."
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _verify_token(token: str) -> None:
|
|
212
|
+
"""Confirm the token works, without ``login()`` writing it to the HF cache."""
|
|
213
|
+
try:
|
|
214
|
+
from huggingface_hub import whoami
|
|
215
|
+
|
|
216
|
+
info = whoami(token=token)
|
|
217
|
+
logger.info(" Token verified — logged in as %s", info["name"])
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.warning(
|
|
220
|
+
" Could not verify the token (%s). Saving it anyway; "
|
|
221
|
+
"'oopsie-data upload' will tell you if it is wrong.", e
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def step_credentials(
|
|
226
|
+
target_dir: Path,
|
|
227
|
+
lab_id: str | None = None,
|
|
228
|
+
hf_token: str | None = None,
|
|
229
|
+
verify_token: bool = True,
|
|
230
|
+
force: bool = False,
|
|
231
|
+
) -> Path | None:
|
|
232
|
+
"""Write ``contributor_config.yaml``. Returns the path, or None if nothing was written."""
|
|
233
|
+
path = target_dir / CONTRIBUTOR_CONFIG_NAME
|
|
234
|
+
existing = _read_existing_yaml(path)
|
|
235
|
+
existing_lab_id = str(existing.get("lab_id") or "").strip()
|
|
236
|
+
existing_token = str(existing.get("huggingface_token") or "").strip()
|
|
237
|
+
|
|
238
|
+
logger.info("\n── Contributor config ─────────────────────────────────")
|
|
239
|
+
logger.info("Writing to %s", path)
|
|
240
|
+
if path.is_file() and not force:
|
|
241
|
+
if existing_lab_id:
|
|
242
|
+
logger.info("This file already has lab_id '%s'.", existing_lab_id)
|
|
243
|
+
if not sys.stdin.isatty():
|
|
244
|
+
logger.error(
|
|
245
|
+
"%s already exists. Re-run with --force to overwrite it non-interactively.", path
|
|
246
|
+
)
|
|
247
|
+
return None
|
|
248
|
+
if not ask_bool("Update it?", default=True):
|
|
249
|
+
logger.info("Keeping the existing contributor config.")
|
|
250
|
+
return path
|
|
251
|
+
|
|
252
|
+
if lab_id is None:
|
|
253
|
+
logger.info(
|
|
254
|
+
"Your lab id and HuggingFace token come from the registration form: %s", REGISTRATION_URL
|
|
255
|
+
)
|
|
256
|
+
logger.info("Use the exact lab id you were given — capitalization matters.")
|
|
257
|
+
lab_id = ask("Lab id", existing_lab_id or None, _validate_lab_id)
|
|
258
|
+
else:
|
|
259
|
+
error = _validate_lab_id(lab_id)
|
|
260
|
+
if error:
|
|
261
|
+
raise WizardAbort(error)
|
|
262
|
+
|
|
263
|
+
if hf_token is None:
|
|
264
|
+
hf_token = ask_optional(
|
|
265
|
+
"HuggingFace token (leave blank to set $HF_TOKEN instead)",
|
|
266
|
+
_mask(existing_token) if existing_token else None,
|
|
267
|
+
)
|
|
268
|
+
if existing_token and hf_token == _mask(existing_token):
|
|
269
|
+
hf_token = existing_token # user accepted the masked default: keep the real value
|
|
270
|
+
|
|
271
|
+
if hf_token and verify_token:
|
|
272
|
+
_verify_token(hf_token)
|
|
273
|
+
|
|
274
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
path.write_text(
|
|
276
|
+
yaml.safe_dump({"lab_id": lab_id, "huggingface_token": hf_token}, sort_keys=False),
|
|
277
|
+
encoding="utf-8",
|
|
278
|
+
)
|
|
279
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR) # contains a credential
|
|
280
|
+
|
|
281
|
+
# Prove the file round-trips through the reader the rest of the toolkit uses.
|
|
282
|
+
saved_lab_id, _ = read_contributor_config(path)
|
|
283
|
+
logger.info("Saved. Episodes will be uploaded to OopsieData-Submissions/%s", saved_lab_id)
|
|
284
|
+
if not hf_token:
|
|
285
|
+
logger.info("No token saved — set $HF_TOKEN in your environment before uploading.")
|
|
286
|
+
return path
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ── entry point ───────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def run_init(
|
|
293
|
+
config_dir_from_flag: bool = False,
|
|
294
|
+
lab_id: str | None = None,
|
|
295
|
+
hf_token: str | None = None,
|
|
296
|
+
verify_token: bool = True,
|
|
297
|
+
force: bool = False,
|
|
298
|
+
) -> int:
|
|
299
|
+
"""Run the wizard. Returns a shell-style exit code."""
|
|
300
|
+
try:
|
|
301
|
+
target_dir = choose_target_dir()
|
|
302
|
+
written = step_credentials(
|
|
303
|
+
target_dir,
|
|
304
|
+
lab_id=lab_id,
|
|
305
|
+
hf_token=hf_token,
|
|
306
|
+
verify_token=verify_token,
|
|
307
|
+
force=force,
|
|
308
|
+
)
|
|
309
|
+
if written is None:
|
|
310
|
+
return 1 # refused to touch an existing config; the reason is already logged
|
|
311
|
+
advise_persisting_config_dir(target_dir, from_flag=config_dir_from_flag)
|
|
312
|
+
|
|
313
|
+
# Profiles deliberately do not live next to the credentials: they belong with the
|
|
314
|
+
# robot code that loads them (see oopsie_data_tools.utils.paths).
|
|
315
|
+
logger.info("\nSetup complete. Your credentials are saved in %s", target_dir)
|
|
316
|
+
logger.info("Run 'oopsie-data show-config' at any time to see what is in use.")
|
|
317
|
+
logger.info(
|
|
318
|
+
"Next: write a robot profile and keep it next to your robot code, e.g. %s.\n"
|
|
319
|
+
"Copy one of the examples or the commented template from the repo's "
|
|
320
|
+
"configs/robot_profiles/, then load it with load_robot_profile(<path>).",
|
|
321
|
+
paths.write_profiles_dir(),
|
|
322
|
+
)
|
|
323
|
+
return 0
|
|
324
|
+
except WizardAbort as e:
|
|
325
|
+
logger.error("%s", e)
|
|
326
|
+
return 1
|
|
327
|
+
except (KeyboardInterrupt, EOFError):
|
|
328
|
+
logger.info("\nAborted — nothing further was written.")
|
|
329
|
+
return 130
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oopsie-data
|
|
3
|
+
description: Use when working with Oopsie Data robotic manipulation failure datasets — recording rollouts, annotating episodes, validating the HDF5 format, or uploading a submission with the oopsie-data CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Oopsie Data
|
|
7
|
+
|
|
8
|
+
Oopsie Data is a community dataset of **robotic manipulation failures**. Contributors record
|
|
9
|
+
rollouts, annotate what went wrong, and upload them to a shared HuggingFace repo. The
|
|
10
|
+
`oopsie-data` CLI is the single entry point for all of it — there are no side scripts.
|
|
11
|
+
|
|
12
|
+
Run `oopsie-data <command> --help` before constructing an invocation rather than guessing at
|
|
13
|
+
flags.
|
|
14
|
+
|
|
15
|
+
## The workflow
|
|
16
|
+
|
|
17
|
+
Once per contributor, then once per recording session:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
oopsie-data init # lab id + HuggingFace token (one time)
|
|
21
|
+
oopsie-data new-profile # robot profile skeleton, then fill it in by hand
|
|
22
|
+
# ... record episodes by calling EpisodeRecorder from the robot control loop
|
|
23
|
+
oopsie-data annotate --samples-dir ./samples # label episodes in a browser UI
|
|
24
|
+
oopsie-data validate --path ./samples # check against oopsiedata_format_v1
|
|
25
|
+
oopsie-data upload --path ./samples # validates again, then publishes
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Recording is the step that produces the episodes everything after it consumes; it is code the
|
|
29
|
+
user adds to their own control loop, not a CLI command. See `reference/setup.md`.
|
|
30
|
+
|
|
31
|
+
Some users prefer to convert pre-recorded data instead of recording through `EpisodeRecorder`.
|
|
32
|
+
That path writes the HDF5 directly rather than going through the recorder, so it bypasses every
|
|
33
|
+
recording-time check — see `reference/conversion.md`.
|
|
34
|
+
|
|
35
|
+
Supporting commands: `show-config` (which config files are in effect), `submissions` (what the
|
|
36
|
+
lab has already uploaded), `inspect <file.h5>` (dump one episode's structure — a debugging aid
|
|
37
|
+
that works even on files `validate` rejects; the path is positional, there is no `--path`),
|
|
38
|
+
`restructure` (split a directory HuggingFace would reject), `install-skill` (copy this skill
|
|
39
|
+
into a Claude configuration).
|
|
40
|
+
|
|
41
|
+
Inside a git checkout that was set up with `uv sync`, prefix every command with `uv run`. A
|
|
42
|
+
pip- or uv-installed package puts `oopsie-data` on `PATH` directly.
|
|
43
|
+
|
|
44
|
+
## Rules
|
|
45
|
+
|
|
46
|
+
**Never invent identity or annotation content.** `lab_id` comes from the registration form,
|
|
47
|
+
`operator_name` and `annotator_name` are the human's, and an episode's `outcome` and
|
|
48
|
+
`episode_description` record what actually happened in a rollout. If one is missing, ask. Do
|
|
49
|
+
not fill in a plausible value.
|
|
50
|
+
|
|
51
|
+
**Never hand-write `contributor_config.yaml`.** Run `oopsie-data init`. It rejects the
|
|
52
|
+
`your_lab_id` placeholder, writes the file mode 0600, and keeps the token out of the checkout
|
|
53
|
+
by defaulting to the per-user config directory. Writing the YAML yourself loses all three.
|
|
54
|
+
|
|
55
|
+
**Uploads are public and effectively irreversible.** `oopsie-data upload` publishes to
|
|
56
|
+
`OopsieData-Submissions/<lab_id>` on HuggingFace. Confirm with the user before running it, even
|
|
57
|
+
if they asked for the whole pipeline in one go. `--skip-upload` runs every check and publishes
|
|
58
|
+
nothing; use it as the dry run.
|
|
59
|
+
|
|
60
|
+
**Never reach for `--skip-validate`.** Validation is the only thing standing between a
|
|
61
|
+
malformed episode and the shared dataset. If validation fails, ask the user how to fix the
|
|
62
|
+
data. Do not assume you know better than the validator or the user.
|
|
63
|
+
|
|
64
|
+
**A robot profile that has not been filled in will not load, by design.** `new-profile` writes
|
|
65
|
+
a skeleton whose required fields are blank so a half-edited profile cannot stamp placeholder
|
|
66
|
+
metadata into recorded episodes. If loading one raises, fill in the fields; do not work around
|
|
67
|
+
the loader.
|
|
68
|
+
|
|
69
|
+
**Do not change code under `oopsie_data_tools/` to make a check pass.** Configs, robot profiles
|
|
70
|
+
and the user's own robot script are yours to edit; the toolkit is not, without the user's
|
|
71
|
+
explicit permission. A failing validator is a finding to report, not an obstacle to remove.
|
|
72
|
+
|
|
73
|
+
**Credentials and robot profiles are different things** with separate lookup chains.
|
|
74
|
+
`contributor_config.yaml` belongs to the person, robot profiles belong next to the robot code.
|
|
75
|
+
`oopsie-data show-config` prints both chains and marks what is actually in use — start any
|
|
76
|
+
config confusion there, and see `reference/setup.md` for the resolution order.
|
|
77
|
+
|
|
78
|
+
## Reference files
|
|
79
|
+
|
|
80
|
+
Read the one you need; do not read all five.
|
|
81
|
+
|
|
82
|
+
- `reference/setup.md` — installing, where configs live, `init`, choosing an annotation
|
|
83
|
+
workflow, and wiring `EpisodeRecorder` into a robot control loop. The onboarding path.
|
|
84
|
+
- `reference/robot-profile.md` — every profile field, which are required, the legal values,
|
|
85
|
+
and the questions to ask the user when filling one in.
|
|
86
|
+
- `reference/format.md` — the `oopsiedata_format_v1` HDF5 layout and every rule the validator
|
|
87
|
+
enforces. Read before theorizing about why an episode was rejected.
|
|
88
|
+
- `reference/conversion.md` — the same schema from the writing side, for converting an existing
|
|
89
|
+
dataset into `oopsiedata_format_v1` instead of recording it.
|
|
90
|
+
- `reference/troubleshooting.md` — common errors, and the mistakes that pass validation
|
|
91
|
+
silently and must be checked by a human.
|
|
92
|
+
|
|
93
|
+
## Reference
|
|
94
|
+
|
|
95
|
+
- Documentation: <https://oopsie-data.com>
|
|
96
|
+
- Toolkit source: <https://github.com/oopsie-data/oopsie-data-tools>
|
|
97
|
+
- `oopsie-data --help` lists every command; `oopsie-data <command> --help` documents its flags
|
|
98
|
+
and shows worked examples.
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# oopsiedata_format_v1 — Full Schema Reference
|
|
2
|
+
|
|
3
|
+
Use this skill whenever writing or debugging a converter that produces Oopsie HDF5 episode files. It describes the canonical `oopsiedata_format_v1` schema as enforced by `oopsie_data_tools`.
|
|
4
|
+
|
|
5
|
+
`reference/format.md` is the same schema from the reading side; this page is the writing side. Where they disagree, the validator source wins — check it rather than guessing.
|
|
6
|
+
|
|
7
|
+
You follow these steps:
|
|
8
|
+
|
|
9
|
+
1. Create a new robot profile with `oopsie-data new-profile`, which writes into `./robot_profiles/` by default. Leave it where the command put it — profiles are looked up at `$OOPSIE_ROBOT_PROFILES_DIR`, then `./robot_profiles` or `./configs/robot_profiles`, so a bare `./configs/` is not found. Double check with the user that this robot profile is correct and no unverified information has been entered.
|
|
10
|
+
2. Create the conversion script. Follow DRY and make sure to reuse utilities from `oopsie_data_tools.utils.conversion_utils`. Make sure to read all relevant information before attempting to write the conversion script.
|
|
11
|
+
3. Wait for the user to run the conversion script with the full correct input path.
|
|
12
|
+
|
|
13
|
+
## Validation entry points (`oopsie_data_tools`)
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
oopsie_data_tools/utils/validation/episode_loader.py # HDF5 → EpisodeData
|
|
17
|
+
oopsie_data_tools/utils/validation/episode_validator.py # semantic checks
|
|
18
|
+
oopsie_data_tools/utils/validation/validation_utils.py # public API
|
|
19
|
+
oopsie_data_tools/utils/robot_profile/robot_profile.py # RobotProfile
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Run validation:
|
|
23
|
+
```bash
|
|
24
|
+
oopsie-data validate --path /path/to/directory
|
|
25
|
+
oopsie-data inspect /path/to/episode.h5 # structure dump, works on files validate rejects
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## HDF5 File Structure
|
|
31
|
+
|
|
32
|
+
### Root attributes (all required)
|
|
33
|
+
|
|
34
|
+
| Attribute | Type | Notes |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `schema` | str | Must be `"oopsiedata_format_v1"` |
|
|
37
|
+
| `episode_id` | str | Non-empty unique identifier |
|
|
38
|
+
| `language_instruction` | str | Non-empty task description |
|
|
39
|
+
| `lab_id` | str | Non-empty; not `"your_lab_id"` |
|
|
40
|
+
| `operator_name` | str | Non-empty |
|
|
41
|
+
| `robot_profile` | str | JSON-serialized `RobotProfile` (use `robot_profile_to_json`) |
|
|
42
|
+
|
|
43
|
+
### `/observations/` group
|
|
44
|
+
|
|
45
|
+
**`/observations/video_paths/`** — one string dataset per camera:
|
|
46
|
+
- Key = camera name (must match `profile.camera_names`)
|
|
47
|
+
- Value = relative path to MP4 from the HDF5 file's directory
|
|
48
|
+
|
|
49
|
+
**`/observations/robot_states/`** — one dataset per robot state key:
|
|
50
|
+
- Keys must equal `profile.robot_state_keys` exactly — every declared key present, and nothing
|
|
51
|
+
undeclared (an extra key is rejected, not ignored)
|
|
52
|
+
- Shape: `(T, D)` float64
|
|
53
|
+
- `joint_position`: `(T, n_joints)` — names from `robot_state_joint_names`, and the count must match
|
|
54
|
+
- `gripper_position`: `(T, 1)` or `(T, n_fingers)` — DOF is not checked
|
|
55
|
+
- `cartesian_position`: **`(T, 7)`**, or `(T, 14)` when `is_biarm` — `[x, y, z, qx, qy, qz, qw]`
|
|
56
|
+
per arm, scalar-last. Not euler: convert before writing, or the episode is rejected.
|
|
57
|
+
|
|
58
|
+
`gripper_position` is the only unconditionally required state key. `joint_position` is required
|
|
59
|
+
only when `action_space` holds `joint_position`/`joint_velocity`, and `cartesian_position` only
|
|
60
|
+
when it holds `cartesian_position`/`cartesian_velocity` — the state must observe the space the
|
|
61
|
+
action controls. Mixing joint and Cartesian actions requires both.
|
|
62
|
+
|
|
63
|
+
### `/actions/` group
|
|
64
|
+
|
|
65
|
+
- Keys in `profile.action_space`: stored as `(T, D)` float64 arrays (non-empty)
|
|
66
|
+
- All other canonical action keys: stored as `h5py.Empty(dtype=np.float64)`
|
|
67
|
+
|
|
68
|
+
Canonical action keys (write Empty for any not in action_space):
|
|
69
|
+
```
|
|
70
|
+
cartesian_position, cartesian_velocity,
|
|
71
|
+
joint_position, joint_velocity,
|
|
72
|
+
base_position, base_velocity,
|
|
73
|
+
gripper_velocity, gripper_position, gripper_binary
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `/episode_annotations/` group (required by both `validate` and `upload`)
|
|
77
|
+
|
|
78
|
+
Structure: `episode_annotations/{annotator_name}/` — a group with HDF5 attributes:
|
|
79
|
+
|
|
80
|
+
| Attr | Type | Notes |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `success` | float | Required; must be in [0.0, 1.0] |
|
|
83
|
+
| `episode_description` | str | Optional free text |
|
|
84
|
+
| `taxonomy` | str | JSON: `{"outcome": "...", "side_effect_category": [...], "severity": "..."}` |
|
|
85
|
+
|
|
86
|
+
`outcome` is one of four slugs — `success`, `success_suboptimal`, `success_side_effect`,
|
|
87
|
+
`failure` — and is the only taxonomy field that matters to validation. It must agree in sign
|
|
88
|
+
with `success` (`failure` iff `success < 0.5`); all three `success_*` outcomes write
|
|
89
|
+
`success = 1.0`, so a consumer that only reads the float sees them alike.
|
|
90
|
+
|
|
91
|
+
`side_effect_category` and `severity` are stored as stable slugs, not prose:
|
|
92
|
+
|
|
93
|
+
| Field | Allowed values |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `side_effect_category` | `reaching`, `grasp`, `manipulation`, `sequencing_semantic`, `collision`, `hardware`, `not_attempted`, `other` |
|
|
96
|
+
| `severity` | `low`, `medium`, `catastrophic` |
|
|
97
|
+
|
|
98
|
+
Every field except `outcome` is optional — a partial annotation is valid, and so is a failure
|
|
99
|
+
with no taxonomy at all.
|
|
100
|
+
|
|
101
|
+
Files written by an older release carry `schema = oopsie_failure_taxonomy_v1`,
|
|
102
|
+
`failure_description` instead of `episode_description`, prose values instead of slugs, and no
|
|
103
|
+
`outcome`. Readers upcast those on the fly and never rewrite them, so both versions can sit in
|
|
104
|
+
one dataset. `oopsie_data_tools/utils/migrate_taxonomy_v2.py` converts them in place if you
|
|
105
|
+
want them normalized.
|
|
106
|
+
|
|
107
|
+
A converter that emits no `episode_annotations` produces structurally valid files that still fail `oopsie-data validate` with `Annotations dict is empty, must be provided for upload`. Either carry the source dataset's labels across, or plan to run `oopsie-data annotate` afterwards.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## RobotProfile fields
|
|
112
|
+
|
|
113
|
+
Defined in `oopsie_data_tools/utils/robot_profile/robot_profile.py`. Serialized as JSON into `f.attrs["robot_profile"]`.
|
|
114
|
+
|
|
115
|
+
**Required fields** — these nine, and no others, are what `REQUIRED_KEYS` enforces:
|
|
116
|
+
|
|
117
|
+
| Field | Type | Example |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `policy_name` | str | `"pi0_droid"` |
|
|
120
|
+
| `robot_name` | str | `"franka_research_3"` |
|
|
121
|
+
| `gripper_name` | str | `"robotiq_2f_85"` |
|
|
122
|
+
| `is_biarm` | bool | `false` |
|
|
123
|
+
| `uses_mobile_base` | bool | `false` |
|
|
124
|
+
| `control_freq` | int | `10` |
|
|
125
|
+
| `camera_names` | list[str] | `["left", "right", "wrist"]` |
|
|
126
|
+
| `robot_state_keys` | list[str] | Must include `"gripper_position"`, plus whatever `action_space` implies (see above) |
|
|
127
|
+
| `action_space` | list[str] | ≥1 arm key + ≥1 gripper key (see below) |
|
|
128
|
+
|
|
129
|
+
**`action_space` validity rules** (`is_valid_action_space`):
|
|
130
|
+
- **at least one** arm key from: `{joint_position, joint_velocity, cartesian_position, cartesian_velocity}`
|
|
131
|
+
- **at least one** gripper key from: `{gripper_position, gripper_velocity, gripper_binary}`
|
|
132
|
+
- **at most one** base key from: `{base_velocity, base_position}`
|
|
133
|
+
- No other keys allowed
|
|
134
|
+
|
|
135
|
+
Declaring two arm keys or two gripper keys is legal — but every declared key must then be written as a real (non-Empty) array. `uses_mobile_base: true` requires a base key; the converse is not checked.
|
|
136
|
+
|
|
137
|
+
**Optional fields:**
|
|
138
|
+
|
|
139
|
+
| Field | Notes |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `robot_state_joint_names` | Required if `joint_position` is in `robot_state_keys` (enforced at profile load). One entry per joint DOF; the count is checked against the recorded array |
|
|
142
|
+
| `action_joint_names` | Required if `action_space` includes `joint_position` or `joint_velocity` |
|
|
143
|
+
| `robot_state_orientation_representation` | Declares how `cartesian_position` state encodes orientation. Options: `"quat"`, `"matrix"`, `"rot6d"`, `"rotvec"`, `"euler_<order>"` where order ∈ `xyz, zyx, xyx, XYZ, ZYX, XYX` (**case matters**: lowercase extrinsic, uppercase intrinsic) |
|
|
144
|
+
| `orientation_representation` | Same, for `cartesian_position` **actions** only — `cartesian_velocity` is never converted |
|
|
145
|
+
| `controller` | str — `"OSC"`, `"joint_position"`, `"joint_velocity"` |
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Validation constraints
|
|
150
|
+
|
|
151
|
+
| Check | Limit |
|
|
152
|
+
|---|---|
|
|
153
|
+
| Video min dimension | 180 px |
|
|
154
|
+
| Video max dimension | 1280 px |
|
|
155
|
+
| Episode duration (trajectory_length / control_freq) | 1 – 600 seconds |
|
|
156
|
+
| Frame count vs trajectory_length tolerance | max(5, 10% of T) |
|
|
157
|
+
| Video duration vs expected duration | ≤ 0.5 s |
|
|
158
|
+
| All obs/action arrays same length T | strict |
|
|
159
|
+
| Frame counts across cameras | within 1 of each other |
|
|
160
|
+
|
|
161
|
+
**Orientation conversion does not happen here.** `orientation_representation` is applied by
|
|
162
|
+
`EpisodeRecorder.record_step`, not by the validator or the loader. A converter writing HDF5
|
|
163
|
+
directly bypasses that entirely, so it must write `cartesian_position` as scalar-last
|
|
164
|
+
quaternions itself. Declaring `orientation_representation: euler_xyz` and then writing euler
|
|
165
|
+
angles produces a file that is rejected on width (6 ≠ 7) — or, worse, silently mislabels data
|
|
166
|
+
if the widths happen to line up.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Minimal write pattern (Python)
|
|
171
|
+
|
|
172
|
+
Prefer the helpers in `oopsie_data_tools.utils.conversion_utils` over hand-rolling any of this —
|
|
173
|
+
they are written against the definitions the validator uses, so they cannot drift from it.
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
import h5py, numpy as np
|
|
177
|
+
from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile
|
|
178
|
+
from oopsie_data_tools.utils.conversion_utils import (
|
|
179
|
+
write_root_attrs, write_video_paths, write_actions, write_episode_annotations,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
profile = RobotProfile(
|
|
183
|
+
policy_name="my_policy",
|
|
184
|
+
robot_name="franka_research_3",
|
|
185
|
+
gripper_name="robotiq_2f_85",
|
|
186
|
+
is_biarm=False,
|
|
187
|
+
uses_mobile_base=False,
|
|
188
|
+
control_freq=10,
|
|
189
|
+
camera_names=["left", "right", "wrist"],
|
|
190
|
+
robot_state_keys=["joint_position", "gripper_position"],
|
|
191
|
+
robot_state_joint_names=[f"joint_{i}" for i in range(1, 8)],
|
|
192
|
+
action_space=["joint_velocity", "gripper_position"],
|
|
193
|
+
action_joint_names=[f"joint_{i}" for i in range(1, 8)],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
h5_path = "episode.h5"
|
|
197
|
+
|
|
198
|
+
with h5py.File(h5_path, "w") as f:
|
|
199
|
+
write_root_attrs(
|
|
200
|
+
f,
|
|
201
|
+
episode_id="episode_000001",
|
|
202
|
+
language_instruction="Pick up the cup",
|
|
203
|
+
lab_id="UT Austin", # the real one from registration — never invent it
|
|
204
|
+
operator_name="Alice",
|
|
205
|
+
robot_profile=profile,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Paths are stored relative to the episode file; pass them however you have them.
|
|
209
|
+
write_video_paths(
|
|
210
|
+
f,
|
|
211
|
+
{cam: f"videos/episode_000001_{cam}.mp4" for cam in profile.camera_names},
|
|
212
|
+
h5_path,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
rs = f["observations"].create_group("robot_states")
|
|
216
|
+
rs.create_dataset("joint_position", data=joint_pos_array, dtype=np.float64) # (T, 7)
|
|
217
|
+
rs.create_dataset("gripper_position", data=gripper_pos_array, dtype=np.float64) # (T, 1)
|
|
218
|
+
|
|
219
|
+
# Fills in h5py.Empty for every canonical key outside action_space.
|
|
220
|
+
write_actions(
|
|
221
|
+
f,
|
|
222
|
+
{"joint_velocity": joint_vel_array, "gripper_position": gripper_act_array},
|
|
223
|
+
profile.action_space,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Goes into episode_annotations/<annotator_name>/ — attrs on the parent group are
|
|
227
|
+
# invisible to the loader, and the episode then fails as unannotated.
|
|
228
|
+
write_episode_annotations(f, annotator_name="my_annotator", success=1.0)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
For a failure, add whatever you know — none of these are required:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
write_episode_annotations(
|
|
235
|
+
f,
|
|
236
|
+
annotator_name="my_annotator",
|
|
237
|
+
success=0.0,
|
|
238
|
+
episode_description="Robot grasped the cup but dropped it in transit.",
|
|
239
|
+
side_effect_category=["grasp"],
|
|
240
|
+
severity="medium",
|
|
241
|
+
)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`outcome` defaults to the coarse reading of `success`, which can only ever be `success` or
|
|
245
|
+
`failure`. Pass it explicitly to record one of the two qualified successes:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
write_episode_annotations(
|
|
249
|
+
f,
|
|
250
|
+
annotator_name="my_annotator",
|
|
251
|
+
success=1.0,
|
|
252
|
+
outcome="success_side_effect",
|
|
253
|
+
episode_description="Completed the task but knocked over a nearby cup.",
|
|
254
|
+
side_effect_category=["collision"],
|
|
255
|
+
severity="low",
|
|
256
|
+
)
|
|
257
|
+
```
|