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,402 @@
1
+ """Split a session directory with too many files into numbered subfolders.
2
+
3
+ Backs ``oopsie-data restructure``. HuggingFace Hub enforces a per-directory file limit
4
+ (:data:`FILE_LIMIT`), and a long recording session blows past it easily — one HDF5 plus
5
+ one MP4 per camera, per episode. ``hf_upload.check_folder_size`` refuses the upload and
6
+ points here.
7
+
8
+ This is a NON-DESTRUCTIVE operation: files are COPIED to a new output folder and the source
9
+ is never modified — not even the HDF5 files, whose stored video paths are rewritten only in
10
+ the copy. After verifying the output you must delete the original yourself.
11
+
12
+ The output is a complete copy of the source tree in which **every** oversized directory has
13
+ been split: a directory holding 12,000 episodes appears in the output at the same relative
14
+ path but as ``0000/``, ``0500/``, … underneath it, while directories that are already small
15
+ enough are copied through unchanged. Nesting therefore works, including re-running this on
16
+ output it produced.
17
+
18
+ Detection and repair used to disagree — oversized directories were found recursively but
19
+ only the source *root* was ever split, so a source with episodes at the root and an
20
+ oversized subdirectory would restructure the wrong directory and report success, leaving
21
+ the upload just as blocked. Both halves walk the tree now.
22
+
23
+ Video paths stored inside the HDF5 files are resolved to absolute paths before copying, so
24
+ relative paths, absolute paths, and paths containing ``..`` all work. Each video is copied
25
+ flat into the same subfolder as the HDF5 that references it, and the path stored in the
26
+ HDF5 *copy* is rewritten to match.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import os
33
+ import shutil
34
+ from pathlib import Path
35
+
36
+ import h5py
37
+
38
+ from oopsie_data_tools.utils.hf_limits import BATCH_SIZE, FILE_LIMIT
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ def files_per_dir(root: Path) -> dict[Path, int]:
44
+ """Count files (non-recursively) in every directory under *root*."""
45
+ return {Path(dirpath): len(filenames) for dirpath, _, filenames in os.walk(root)}
46
+
47
+
48
+ def oversized_dirs(root: Path) -> list[tuple[Path, int]]:
49
+ return [(d, n) for d, n in files_per_dir(root).items() if n > FILE_LIMIT]
50
+
51
+
52
+ def collect_h5_files(directory: Path) -> list[Path]:
53
+ """Return a sorted list of the HDF5 files directly in *directory*."""
54
+ return sorted(
55
+ p for p in directory.iterdir() if p.is_file() and p.suffix.lower() in (".h5", ".hdf5")
56
+ )
57
+
58
+
59
+ def read_video_paths(h5_path: Path) -> dict[str, str]:
60
+ """Return ``{camera: stored_path_string}`` from ``observations/video_paths``."""
61
+ paths: dict[str, str] = {}
62
+ try:
63
+ with h5py.File(h5_path, "r") as f:
64
+ vp = f.get("observations/video_paths")
65
+ if vp is None:
66
+ return paths
67
+ for cam in vp.keys():
68
+ raw = vp[cam][()]
69
+ if isinstance(raw, bytes):
70
+ raw = raw.decode("utf-8", errors="replace")
71
+ paths[cam] = str(raw).strip()
72
+ except Exception as exc:
73
+ logger.warning(" ! Could not read video paths from %s: %s", h5_path.name, exc)
74
+ return paths
75
+
76
+
77
+ def resolve_video_path(stored_path: str, h5_dir: Path) -> Path:
78
+ """Resolve a stored video path to an absolute Path."""
79
+ p = Path(stored_path)
80
+ return p.resolve() if p.is_absolute() else (h5_dir / p).resolve()
81
+
82
+
83
+ def write_video_paths(h5_path: Path, new_paths: dict[str, str]) -> None:
84
+ """Overwrite the video path datasets in the HDF5 file at *h5_path*."""
85
+ str_dtype = h5py.string_dtype(encoding="utf-8")
86
+ with h5py.File(h5_path, "r+") as f:
87
+ vp = f["observations/video_paths"]
88
+ for cam, rel in new_paths.items():
89
+ if cam in vp:
90
+ del vp[cam]
91
+ vp.create_dataset(cam, data=rel, dtype=str_dtype)
92
+
93
+
94
+ def dirs_to_split(source: Path) -> tuple[list[Path], list[Path]]:
95
+ """Split the oversized directories under *source* into fixable and unfixable.
96
+
97
+ Batching is keyed on HDF5 files, so an oversized directory holding none of them cannot
98
+ be split by this command — a videos-only folder, say. Those are returned separately so
99
+ the caller can say so out loud rather than silently leaving them oversized.
100
+
101
+ Returns:
102
+ ``(splittable, unfixable)``, both sorted.
103
+ """
104
+ splittable, unfixable = [], []
105
+ for directory, _count in sorted(oversized_dirs(source)):
106
+ (splittable if collect_h5_files(directory) else unfixable).append(directory)
107
+ return splittable, unfixable
108
+
109
+
110
+ def estimate_bytes(source: Path, h5_files: list[Path]) -> int:
111
+ """Total bytes the output would occupy: the whole source tree, plus any video it
112
+ references from outside it.
113
+
114
+ The output is a full copy of *source*, not just of the episodes being split, so
115
+ estimating from the HDF5 files alone would badly undercount a tree that also holds
116
+ directories small enough to pass through untouched.
117
+
118
+ Each unique absolute path is counted once even if several HDF5 files reference it.
119
+ """
120
+ total = 0
121
+ seen: set[Path] = set()
122
+
123
+ for dirpath, _dirnames, filenames in os.walk(source):
124
+ for name in filenames:
125
+ p = Path(dirpath) / name
126
+ if p not in seen and p.is_file():
127
+ total += p.stat().st_size
128
+ seen.add(p)
129
+
130
+ # Videos can live outside the source tree entirely (stored paths may contain "..").
131
+ for h5 in h5_files:
132
+ for stored in read_video_paths(h5).values():
133
+ abs_v = resolve_video_path(stored, h5.parent)
134
+ if abs_v not in seen and abs_v.is_file():
135
+ total += abs_v.stat().st_size
136
+ seen.add(abs_v)
137
+ return total
138
+
139
+
140
+ def subfolder_name(start_idx: int, n_total: int) -> str:
141
+ """Zero-padded folder name for the batch starting at *start_idx*.
142
+
143
+ All names are padded to the same width so the directories sort correctly.
144
+ Examples for 10 000 files: 0000, 0500, 1000, …, 9500.
145
+ """
146
+ max_start = ((n_total - 1) // BATCH_SIZE) * BATCH_SIZE
147
+ width = max(3, len(str(max_start)))
148
+ return str(start_idx).zfill(width)
149
+
150
+
151
+ def _unique_video_dest(
152
+ abs_video: Path,
153
+ cam: str,
154
+ h5_stem: str,
155
+ used: dict[str, Path],
156
+ ) -> str:
157
+ """Return a filename (no directory) for *abs_video* that is unique within *used*.
158
+
159
+ Episode stems are second-resolution timestamps, so two sessions can easily contribute
160
+ the same ``<stem>_<cam>.mp4`` to one batch. This used to return the colliding name
161
+ regardless, silently overwriting the first episode's video during the copy.
162
+
163
+ Re-asking for a video already registered returns the same name, so the function is safe
164
+ to call more than once per file.
165
+ """
166
+ stem = f"{h5_stem}_{cam}"
167
+ suffix = abs_video.suffix
168
+
169
+ candidate = f"{stem}{suffix}"
170
+ attempt = 1
171
+ while used.get(candidate, abs_video) != abs_video:
172
+ attempt += 1
173
+ candidate = f"{stem}_{attempt}{suffix}"
174
+
175
+ used[candidate] = abs_video
176
+ return candidate
177
+
178
+
179
+ def restructure(output: Path, h5_files: list[Path]) -> set[Path]:
180
+ """Batch *h5_files* into numbered subfolders under *output*.
181
+
182
+ Returns:
183
+ The absolute source paths consumed — the HDF5 files and every video they
184
+ reference. :func:`copy_through` skips these so nothing is copied twice.
185
+ """
186
+ consumed: set[Path] = set()
187
+ n = len(h5_files)
188
+ n_batches = (n + BATCH_SIZE - 1) // BATCH_SIZE
189
+ logger.info(
190
+ "\n[restructure] %d HDF5 file(s) → %d subfolder(s) of up to %d each",
191
+ n, n_batches, BATCH_SIZE,
192
+ )
193
+
194
+ for batch_start in range(0, n, BATCH_SIZE):
195
+ batch = h5_files[batch_start : batch_start + BATCH_SIZE]
196
+ sub = output / subfolder_name(batch_start, n)
197
+ sub.mkdir(parents=True, exist_ok=True)
198
+ logger.info("\n Subfolder %s/ (%d HDF5 files)", sub.name, len(batch))
199
+
200
+ # Which filenames this subfolder already holds, so collisions across HDF5 files in
201
+ # the same batch are detected rather than silently overwriting.
202
+ used_filenames: dict[str, Path] = {}
203
+
204
+ for i, h5_src in enumerate(batch, 1):
205
+ h5_dir = h5_src.parent
206
+ stored_video_paths = read_video_paths(h5_src)
207
+
208
+ # Copy the HDF5 first so the destination exists before we patch it.
209
+ h5_dst = sub / h5_src.name
210
+ shutil.copy2(h5_src, h5_dst)
211
+ consumed.add(h5_src)
212
+
213
+ # Resolve each video to an absolute path, copy it flat into the subfolder, and
214
+ # record what the new relative path will be.
215
+ new_rel_paths: dict[str, str] = {}
216
+ for cam, stored in stored_video_paths.items():
217
+ abs_video = resolve_video_path(stored, h5_dir)
218
+ if not abs_video.exists():
219
+ logger.warning(
220
+ " ! video not found, skipping: %s (ref'd by %s)",
221
+ abs_video, h5_src.name,
222
+ )
223
+ # Leave the original path; validation will catch this later.
224
+ new_rel_paths[cam] = stored
225
+ continue
226
+
227
+ dest_name = _unique_video_dest(abs_video, cam, h5_src.stem, used_filenames)
228
+ shutil.copy2(abs_video, sub / dest_name)
229
+ consumed.add(abs_video)
230
+ # Stored relative to the HDF5 location, which is now the same folder.
231
+ new_rel_paths[cam] = dest_name
232
+
233
+ # Always write the updated paths back into the HDF5 copy so it is
234
+ # self-consistent regardless of how the original paths were formatted.
235
+ if new_rel_paths:
236
+ write_video_paths(h5_dst, new_rel_paths)
237
+
238
+ logger.info(" [%d/%d] %s", i, len(batch), h5_src.name)
239
+
240
+ return consumed
241
+
242
+
243
+ def copy_through(source: Path, output: Path, consumed: set[Path]) -> int:
244
+ """Copy every file under *source* that the split did not already place into *output*.
245
+
246
+ Preserves each file's path relative to *source*, so directories that were already small
247
+ enough come through untouched and the output is a complete dataset rather than only the
248
+ episodes that needed splitting.
249
+
250
+ Returns:
251
+ The number of files copied.
252
+ """
253
+ copied = 0
254
+ for dirpath, dirnames, filenames in os.walk(source):
255
+ here = Path(dirpath)
256
+ # Never descend into the output, in case it was placed inside the source.
257
+ dirnames[:] = [d for d in dirnames if (here / d).resolve() != output]
258
+ for name in filenames:
259
+ src = here / name
260
+ if src in consumed:
261
+ continue
262
+ dst = output / src.relative_to(source)
263
+ dst.parent.mkdir(parents=True, exist_ok=True)
264
+ shutil.copy2(src, dst)
265
+ copied += 1
266
+ return copied
267
+
268
+
269
+ def run_restructure(source: Path, output: Path | None, assume_yes: bool) -> int:
270
+ """Full interactive flow: check, estimate, confirm, copy. Returns an exit code."""
271
+ source = Path(source).expanduser().resolve()
272
+ if not source.is_dir():
273
+ logger.error("Source is not a directory: %s", source)
274
+ return 1
275
+
276
+ output = (
277
+ Path(output).expanduser().resolve()
278
+ if output
279
+ else source.parent / (source.name + "_restructured")
280
+ )
281
+
282
+ logger.info("=" * 60)
283
+ logger.info(" Large-Folder Restructure Utility")
284
+ logger.info("=" * 60)
285
+
286
+ # ── Step 1: does any directory exceed the file limit? ─────────────────────
287
+ logger.info("\n[check] Scanning %s ...", source)
288
+ over = oversized_dirs(source)
289
+ if not over:
290
+ logger.info("[check] No directory exceeds %d files. No restructuring needed.", FILE_LIMIT)
291
+ return 0
292
+
293
+ logger.info("[check] Director(ies) exceeding %d files:", FILE_LIMIT)
294
+ for d, n in over:
295
+ logger.info(" %s (%d files)", d, n)
296
+
297
+ # ── Step 2: work out which of them we can actually split ──────────────────
298
+ splittable, unfixable = dirs_to_split(source)
299
+ if unfixable:
300
+ logger.warning(
301
+ "[check] These are oversized but hold no HDF5 files, so they cannot be split\n"
302
+ " by episode and are copied through as they are:"
303
+ )
304
+ for d in unfixable:
305
+ logger.warning(" %s", d)
306
+ if not splittable:
307
+ logger.error(
308
+ "[check] No oversized directory under %s contains HDF5 files, so there is\n"
309
+ " nothing to split. Reorganise these by hand.",
310
+ source,
311
+ )
312
+ return 1
313
+
314
+ h5_by_dir = {d: collect_h5_files(d) for d in splittable}
315
+ all_h5 = [h5 for files in h5_by_dir.values() for h5 in files]
316
+ for d, files in h5_by_dir.items():
317
+ logger.info("[check] %d HDF5 file(s) to split in %s", len(files), d)
318
+
319
+ if output == source or source in output.parents:
320
+ logger.error(
321
+ "Output must not be inside the source (%s is under %s); "
322
+ "pick a destination outside it.",
323
+ output, source,
324
+ )
325
+ return 1
326
+
327
+ # ── Step 3: estimate copy size ────────────────────────────────────────────
328
+ logger.info(
329
+ "\n[size] Estimating copy size "
330
+ "(resolving video paths from all HDF5 files — may take a moment) ..."
331
+ )
332
+ total_bytes = estimate_bytes(source, all_h5)
333
+ logger.info("[size] Estimated copy size: %.2f GB", total_bytes / 1e9)
334
+
335
+ # ── Step 4: explicit consent ──────────────────────────────────────────────
336
+ n_batches = sum((len(f) + BATCH_SIZE - 1) // BATCH_SIZE for f in h5_by_dir.values())
337
+ logger.info("\n" + "=" * 60)
338
+ logger.info(" ACTION REQUIRED — please read carefully before proceeding")
339
+ logger.info("=" * 60)
340
+ logger.info(
341
+ "\nThis will CREATE a new folder:\n"
342
+ " %s\n"
343
+ "\nIt will hold a complete copy of the source tree. Each of the %d\n"
344
+ "oversized director(ies) above is replaced by %d subfolder(s) in total,\n"
345
+ "named by starting HDF5 index (e.g. 000/, 500/, 1000/, …), each holding\n"
346
+ "up to %d HDF5 files and the video files they reference. Directories\n"
347
+ "already under the limit are copied through unchanged.\n"
348
+ "\nVideo paths stored inside each HDF5 file will be rewritten to\n"
349
+ "point to the copied video location.\n"
350
+ "\nEstimated disk space needed: %.2f GB\n"
351
+ "\nThe SOURCE FOLDER WILL NOT BE MODIFIED. Once you have verified\n"
352
+ "that the restructured copy is complete and valid, you must\n"
353
+ "MANUALLY DELETE the original source to reclaim space:\n"
354
+ " %s\n"
355
+ " (approx. %.2f GB to free)",
356
+ output, len(splittable), n_batches, BATCH_SIZE,
357
+ total_bytes / 1e9,
358
+ source, total_bytes / 1e9,
359
+ )
360
+
361
+ if output.exists() and any(output.iterdir()):
362
+ logger.warning(
363
+ "\n[warn] Output folder already exists and is not empty:\n"
364
+ " %s\n"
365
+ " Existing files with the same names will be overwritten.",
366
+ output,
367
+ )
368
+ logger.info(
369
+ "\nThis operation is not robust to interruptions! Please ensure that it\n"
370
+ "runs in full. If it is interrupted, delete the output folder and restart."
371
+ )
372
+
373
+ if not assume_yes:
374
+ confirm = input('\nType exactly "yes" to proceed, anything else to abort: ').strip()
375
+ if confirm != "yes":
376
+ logger.info("\nAborted. No files were written.")
377
+ return 0
378
+
379
+ # ── Step 5: split each oversized directory, then copy the rest through ────
380
+ output.mkdir(parents=True, exist_ok=True)
381
+ consumed: set[Path] = set()
382
+ for directory, files in h5_by_dir.items():
383
+ # Each split lands at the directory's own position in the output tree, so the
384
+ # result mirrors the source rather than flattening it.
385
+ logger.info("\n[restructure] %s", directory)
386
+ consumed |= restructure(output / directory.relative_to(source), files)
387
+
388
+ copied = copy_through(source, output, consumed)
389
+ logger.info("\n[copy] %d file(s) copied through unchanged.", copied)
390
+
391
+ logger.info("\n" + "=" * 60)
392
+ logger.info(" Done!")
393
+ logger.info("=" * 60)
394
+ logger.info("\nRestructured data written to:\n %s", output)
395
+ logger.info(
396
+ "\nREMINDER: Verify the restructured folder before deleting the original.\n"
397
+ "To delete the original once satisfied:\n"
398
+ " rm -rf %s\n"
399
+ " (will free approx. %.2f GB)",
400
+ source, total_bytes / 1e9,
401
+ )
402
+ return 0
@@ -0,0 +1 @@
1
+ """Robot profile loading and rotation-representation conversion."""
@@ -0,0 +1,240 @@
1
+ """Load robot / lab profiles for annotation and episode recording."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import yaml
11
+
12
+ from oopsie_data_tools.utils.robot_profile.rotation_utils import RotOption
13
+
14
+ ACTION_SPACE_SET_1 = {
15
+ "joint_position",
16
+ "joint_velocity",
17
+ "cartesian_position",
18
+ "cartesian_velocity",
19
+ }
20
+
21
+ ACTION_SPACE_SET_2 = {
22
+ "gripper_position",
23
+ "gripper_velocity",
24
+ "gripper_binary",
25
+ }
26
+
27
+ ACTION_SPACE_SET_3 = {
28
+ "base_velocity",
29
+ "base_position",
30
+ }
31
+
32
+ REQUIRED_KEYS = frozenset(
33
+ {
34
+ "policy_name",
35
+ "robot_name",
36
+ "gripper_name",
37
+ "control_freq",
38
+ "is_biarm",
39
+ "uses_mobile_base",
40
+ "camera_names",
41
+ "robot_state_keys",
42
+ "action_space",
43
+ }
44
+ )
45
+
46
+ REQUIRED_ROBOT_STATE_KEYS = frozenset(
47
+ {
48
+ "gripper_position",
49
+ }
50
+ )
51
+
52
+ # The state has to observe whatever space the action controls: velocity commands still
53
+ # need the corresponding *position* state. Applied as a union over the action space, so a
54
+ # profile mixing joint and cartesian actions needs both state keys. Base actions are not
55
+ # covered — nothing yet requires base_position state for a base action.
56
+ ARM_ACTION_REQUIRED_ROBOT_STATE_KEY = {
57
+ "joint_position": "joint_position",
58
+ "joint_velocity": "joint_position",
59
+ "cartesian_position": "cartesian_position",
60
+ "cartesian_velocity": "cartesian_position",
61
+ }
62
+
63
+
64
+ @dataclasses.dataclass(frozen=True)
65
+ class RobotProfile:
66
+ """Robot / dataset identity and recording semantics (paths, joints, cameras).
67
+
68
+ Options specific to :class:`WebRolloutAnnotator` (browser server port, blocking
69
+ until annotation, resuming a session directory) are *not* part of this profile;
70
+ pass those separately when constructing the annotator, e.g. via
71
+ :meth:`WebRolloutAnnotator.from_robot_profile`.
72
+ """
73
+ policy_name: str
74
+ robot_name: str
75
+ is_biarm: bool
76
+ uses_mobile_base: bool
77
+ gripper_name: str
78
+ control_freq: int
79
+ camera_names: List[str]
80
+ robot_state_keys: List[str]
81
+ robot_state_joint_names: List[str]
82
+ action_space: List[str]
83
+ action_joint_names: Optional[List[str]] = None
84
+ orientation_representation: Optional[str] = None
85
+ robot_state_orientation_representation: Optional[str] = None
86
+ controller: Optional[str] = None
87
+ gains: Optional[Dict[str, Any]] = None
88
+ intrinsic_calibration_matrix: Optional[Dict[str, Any]] = None
89
+ extrinsic_calibration_matrix: Optional[Dict[str, Any]] = None
90
+
91
+ def get_rot_option(self) -> RotOption | None:
92
+ if self.orientation_representation is None:
93
+ return None
94
+ return RotOption.from_string(self.orientation_representation)
95
+
96
+ def get_robot_state_rot_option(self) -> RotOption | None:
97
+ if self.robot_state_orientation_representation is None:
98
+ return None
99
+ return RotOption.from_string(self.robot_state_orientation_representation)
100
+
101
+
102
+ def robot_profile_to_json(profile: RobotProfile) -> str:
103
+ """Serialize ``RobotProfile`` to a JSON string (for HDF5 file attributes)."""
104
+ return json.dumps(dataclasses.asdict(profile), ensure_ascii=False)
105
+
106
+
107
+ def load_robot_profile(path: Path | str) -> RobotProfile:
108
+ """Parse a robot profile YAML file into ``Robotprofile``."""
109
+ p = Path(path)
110
+ if not p.is_file():
111
+ raise FileNotFoundError(f"Robot profile file not found: {p}")
112
+ raw = yaml.safe_load(p.read_text())
113
+ return robot_profile_from_raw(raw)
114
+
115
+
116
+ def robot_profile_from_json(payload: str) -> RobotProfile:
117
+ """Parse a robot profile JSON string into ``RobotProfile``."""
118
+ try:
119
+ raw = json.loads(payload)
120
+ except Exception as e:
121
+ raise ValueError(f"Invalid robot_profile JSON: {e}") from e
122
+ return robot_profile_from_raw(raw)
123
+
124
+
125
+ def is_valid_action_space(action_space):
126
+ s = set(action_space)
127
+
128
+ arm_count = len(s & ACTION_SPACE_SET_1)
129
+ gripper_count = len(s & ACTION_SPACE_SET_2)
130
+ base_count = len(s & ACTION_SPACE_SET_3)
131
+
132
+ return (
133
+ arm_count >= 1 and
134
+ gripper_count >= 1 and
135
+ base_count <= 1 and
136
+ len(s) == arm_count + gripper_count + base_count # no extras
137
+ )
138
+
139
+ def robot_profile_from_raw(raw: Any) -> RobotProfile:
140
+ """Validate and parse raw mapping data into ``RobotProfile``."""
141
+ if not isinstance(raw, dict):
142
+ raise ValueError(f"Robot profile must be a mapping, got {type(raw).__name__}")
143
+
144
+ missing = [k for k in REQUIRED_KEYS if k not in raw]
145
+ if missing:
146
+ raise ValueError(f"Robot profile missing keys: {missing}")
147
+
148
+ action_space = list(raw["action_space"])
149
+ if not is_valid_action_space(action_space):
150
+ raise ValueError(
151
+ f"Invalid action_space: {action_space!r}. "
152
+ "Expected at least 1 arm action from "
153
+ f"{sorted(ACTION_SPACE_SET_1)}, "
154
+ "at least 1 gripper action from "
155
+ f"{sorted(ACTION_SPACE_SET_2)}, "
156
+ "at most 1 base action from "
157
+ f"{sorted(ACTION_SPACE_SET_3)}, "
158
+ "and no other keys."
159
+ )
160
+
161
+ required_robot_state_keys = set(REQUIRED_ROBOT_STATE_KEYS)
162
+ required_robot_state_keys.update(
163
+ ARM_ACTION_REQUIRED_ROBOT_STATE_KEY[action]
164
+ for action in action_space
165
+ if action in ARM_ACTION_REQUIRED_ROBOT_STATE_KEY
166
+ )
167
+ robot_state_keys = list(raw["robot_state_keys"])
168
+ missing_robot_state_keys = sorted(required_robot_state_keys - set(robot_state_keys))
169
+ if missing_robot_state_keys:
170
+ raise ValueError(
171
+ "Robot profile missing robot state keys required by its action_space: "
172
+ f"{missing_robot_state_keys}"
173
+ )
174
+
175
+ robot_state_joint_names = _optional_str_list(
176
+ raw.get("robot_state_joint_names")
177
+ )
178
+ if "joint_position" in robot_state_keys and not robot_state_joint_names:
179
+ raise ValueError(
180
+ "robot_state_joint_names is required when joint_position is included "
181
+ "in robot_state_keys"
182
+ )
183
+
184
+ action_joint_names = _optional_str_list(raw.get("action_joint_names"))
185
+ if (
186
+ any(k in {"joint_position", "joint_velocity"} for k in action_space)
187
+ and not action_joint_names
188
+ ):
189
+ raise ValueError(
190
+ "action_joint_names is required for joint_position and joint_velocity "
191
+ "action spaces"
192
+ )
193
+
194
+ if bool(raw["uses_mobile_base"]) and set(action_space).isdisjoint(ACTION_SPACE_SET_3):
195
+ raise ValueError(
196
+ f"Invalid action_space {action_space!r} for mobile base: must include at least one of "
197
+ f"{sorted(ACTION_SPACE_SET_3)}"
198
+ )
199
+
200
+ return RobotProfile(
201
+ policy_name=raw["policy_name"],
202
+ robot_name=raw["robot_name"],
203
+ # Both are in REQUIRED_KEYS, so there is no default to fall back on; bool() only
204
+ # normalizes a blank YAML value (``is_biarm:``) into False.
205
+ is_biarm=bool(raw["is_biarm"]),
206
+ uses_mobile_base=bool(raw["uses_mobile_base"]),
207
+ gripper_name=raw["gripper_name"],
208
+ control_freq=raw["control_freq"],
209
+ camera_names=list(raw["camera_names"]),
210
+ # Observation Related
211
+ robot_state_keys=robot_state_keys,
212
+ robot_state_joint_names=robot_state_joint_names or [],
213
+ # Action Related
214
+ action_space=action_space,
215
+ action_joint_names=action_joint_names,
216
+ orientation_representation=raw.get("orientation_representation", None),
217
+ # Optional Keys
218
+ robot_state_orientation_representation=raw.get("robot_state_orientation_representation", None),
219
+ controller=raw.get("controller"),
220
+ gains=raw.get("gains"),
221
+ intrinsic_calibration_matrix=_calibration_matrix(raw, "intrinsic"),
222
+ extrinsic_calibration_matrix=_calibration_matrix(raw, "extrinsic"),
223
+ )
224
+
225
+
226
+ def _calibration_matrix(raw: dict[str, Any], kind: str) -> dict[str, Any] | None:
227
+ """Read a calibration matrix under either spelling.
228
+
229
+ Profiles written from the bundled template use the spaced key
230
+ (``intrinsic calibration matrix``); the underscored key is canonical.
231
+ """
232
+ return raw.get(f"{kind}_calibration_matrix", raw.get(f"{kind} calibration matrix"))
233
+
234
+
235
+ def _optional_str_list(value: Any) -> list[str] | None:
236
+ if value is None:
237
+ return None
238
+ if isinstance(value, list):
239
+ return [str(x) for x in value]
240
+ raise ValueError(f"Expected a list or null, got {type(value).__name__}")