oopsie-data-tools 0.2.0__py3-none-any.whl

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