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,850 @@
1
+ #!/usr/bin/env python3
2
+ """Minimal web UI for instruction -> rollout -> annotate -> save.
3
+
4
+ Flow for in the loop annotation (with rollouts):
5
+ 1) Browser submits a language instruction.
6
+ 2) External rollout loop polls the instruction, runs policy, saves MP4s & HDF5, then
7
+ signals this server to show the videos + annotation form.
8
+ 3) Browser fills the annotation form + clicks Save.
9
+ 4) Rollout loop polls the saved annotation and writes it into the episode HDF5
10
+ (via EpisodeRecorder).
11
+
12
+ Tool can also be launched as a standalone module to browse and annotate existing HDF5 episodes, without any rollout loop.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import logging
20
+ import os
21
+ import threading
22
+ import webbrowser
23
+ from dataclasses import dataclass
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any
27
+ from urllib.parse import quote, unquote
28
+
29
+ import h5py
30
+ from flask import Flask, abort, jsonify, request, send_file
31
+
32
+ from oopsie_data_tools.annotation_tool.annotation_schema import (
33
+ ANNOTATION_SCHEMA_CURRENT,
34
+ OUTCOME_SUCCESS,
35
+ OUTCOMES,
36
+ SEVERITIES,
37
+ SIDE_EFFECT_CATEGORIES,
38
+ parse_taxonomy,
39
+ read_annotation_attrs,
40
+ write_annotation_attrs,
41
+ )
42
+ from oopsie_data_tools.utils.validation.annotation_completeness import is_complete
43
+
44
+ app = Flask(__name__)
45
+
46
+
47
+ def _json_payload() -> dict[str, Any]:
48
+ payload = request.get_json(silent=True)
49
+ return payload if isinstance(payload, dict) else {}
50
+
51
+
52
+ def _now_iso() -> str:
53
+ return datetime.now(timezone.utc).isoformat()
54
+
55
+
56
+ def validate_annotation_payload(payload: dict[str, Any]) -> str:
57
+ """Return an error message if the payload uses vocabulary the schema does not know.
58
+
59
+ Stored values are opaque slugs, so a typo would otherwise reach disk unnoticed — where
60
+ v1's prose values at least looked obviously wrong. Only ``outcome`` is required; every
61
+ other field is optional and is checked only if present.
62
+ """
63
+ outcome = str(payload.get("outcome", "") or "").strip().lower()
64
+ if outcome not in OUTCOME_SUCCESS:
65
+ return f"outcome must be one of {list(OUTCOMES)}, got {payload.get('outcome')!r}"
66
+
67
+ categories = payload.get("side_effect_category") or []
68
+ if not isinstance(categories, (list, tuple)):
69
+ categories = [categories]
70
+ unknown = [c for c in categories if str(c).strip() not in SIDE_EFFECT_CATEGORIES]
71
+ if unknown:
72
+ return f"unrecognized side_effect_category: {unknown}"
73
+
74
+ severity = str(payload.get("severity", "") or "").strip()
75
+ if severity and severity not in SEVERITIES:
76
+ return f"severity must be one of {list(SEVERITIES)}, got {severity!r}"
77
+
78
+ return ""
79
+
80
+
81
+ @dataclass
82
+ class ServerConfig:
83
+ samples_dir: Path
84
+ annotator_name: str
85
+ # HDF5 browser + annotation form only (no instruction / rollout cards).
86
+ browse_only: bool = False
87
+
88
+
89
+ class Runtime:
90
+ def __init__(self, cfg: ServerConfig) -> None:
91
+ self.cfg = cfg
92
+ self._lock = threading.Lock()
93
+ self.task_state: dict[str, Any] = {
94
+ "status": "idle", # idle | pending | running | annotating
95
+ "pending_instruction": None,
96
+ "last_instruction": "",
97
+ "current_sample": None, # dict: {sample_id, video_urls, language_instruction}
98
+ }
99
+ self.annotations: dict[str, Any] = {} # sample_id -> annotation dict
100
+
101
+ def set_pending_instruction(self, instruction: str) -> None:
102
+ with self._lock:
103
+ self.task_state["pending_instruction"] = instruction
104
+ self.task_state["status"] = "pending"
105
+
106
+ def start_task(self, instruction: str) -> None:
107
+ with self._lock:
108
+ self.task_state["last_instruction"] = instruction
109
+ self.task_state["pending_instruction"] = None
110
+ self.task_state["status"] = "running"
111
+
112
+ def set_annotating(
113
+ self, sample_id: str, video_urls: dict[str, str], language_instruction: str
114
+ ) -> None:
115
+ with self._lock:
116
+ self.task_state["current_sample"] = {
117
+ "sample_id": sample_id,
118
+ "video_urls": video_urls,
119
+ "language_instruction": language_instruction,
120
+ }
121
+ self.task_state["status"] = "annotating"
122
+
123
+ def mark_done(self) -> None:
124
+ with self._lock:
125
+ if self.task_state.get("status") == "annotating":
126
+ cs = self.task_state.get("current_sample")
127
+ if isinstance(cs, dict):
128
+ sid = str(cs.get("sample_id", "")).strip()
129
+ if sid and sid not in self.annotations:
130
+ # Lets rollout poll observe "skip" (Back) vs still waiting.
131
+ self.annotations[sid] = {"__annotation_skipped__": True}
132
+ self.task_state["status"] = "idle"
133
+ self.task_state["current_sample"] = None
134
+
135
+ def stamp_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
136
+ """Attach annotator, timestamp and schema to an annotation payload.
137
+
138
+ Split out from :meth:`save_annotation` because the two annotation routes persist
139
+ very differently: the rollout route parks the result in memory for the rollout loop
140
+ to collect, while the HDF5 browser writes straight into the file and needs no
141
+ in-memory entry at all.
142
+ """
143
+ reserved = {"annotated_at", "annotator", "source", "schema", "__annotation_skipped__"}
144
+ annotation = {k: v for k, v in payload.items() if k not in reserved}
145
+ annotation.update(
146
+ {
147
+ "annotated_at": _now_iso(),
148
+ "annotator": self.cfg.annotator_name,
149
+ "source": "human",
150
+ "schema": ANNOTATION_SCHEMA_CURRENT,
151
+ }
152
+ )
153
+ return annotation
154
+
155
+ def save_annotation(self, sample_id: str, payload: dict[str, Any]) -> dict[str, Any]:
156
+ """Stamp an annotation and hold it for the rollout loop polling ``sample_id``."""
157
+ annotation = self.stamp_annotation(payload)
158
+ with self._lock:
159
+ self.annotations[sample_id] = annotation
160
+ return annotation
161
+
162
+ def get_annotation(self, sample_id: str) -> dict[str, Any] | None:
163
+ with self._lock:
164
+ ann = self.annotations.get(sample_id)
165
+ return ann if isinstance(ann, dict) else None
166
+
167
+
168
+ def _get_runtime() -> Runtime:
169
+ rt = app.extensions.get("annotator_runtime")
170
+ if isinstance(rt, Runtime):
171
+ return rt
172
+ raise RuntimeError("Runtime not configured. Call configure_runtime().")
173
+
174
+
175
+ def configure_runtime(
176
+ samples_dir: Path,
177
+ annotator_name: str,
178
+ *,
179
+ browse_only: bool = False,
180
+ ) -> Runtime:
181
+ cfg = ServerConfig(
182
+ samples_dir=samples_dir.resolve(),
183
+ annotator_name=annotator_name,
184
+ browse_only=browse_only,
185
+ )
186
+ rt = Runtime(cfg)
187
+ app.extensions["annotator_runtime"] = rt
188
+ return rt
189
+
190
+
191
+ @app.get("/")
192
+ def index() -> str:
193
+ html = _load_template("annotator.html")
194
+ browse = "true" if _get_runtime().cfg.browse_only else "false"
195
+ name_js = json.dumps(_get_runtime().cfg.annotator_name)
196
+ html = html.replace(
197
+ "<script>",
198
+ f"<script>\n const ANNOTATOR_BROWSE_ONLY = {browse};\n"
199
+ f" const ANNOTATOR_NAME = {name_js};\n",
200
+ 1,
201
+ )
202
+ return html
203
+
204
+
205
+ @app.get("/api/task/state")
206
+ def api_task_state():
207
+ rt = _get_runtime()
208
+ # Let the UI distinguish rollout idle vs annotation-only (browse) idle.
209
+ return jsonify({**rt.task_state, "browse_only": rt.cfg.browse_only})
210
+
211
+
212
+ @app.post("/api/task/submit")
213
+ def api_task_submit():
214
+ instruction = str(_json_payload().get("instruction", "")).strip()
215
+ if not instruction:
216
+ return jsonify({"error": "instruction is required"}), 400
217
+ _get_runtime().set_pending_instruction(instruction)
218
+ return jsonify({"status": "ok"})
219
+
220
+
221
+ @app.post("/api/task/start")
222
+ def api_task_start():
223
+ instruction = str(_json_payload().get("instruction", "")).strip()
224
+ if not instruction:
225
+ return jsonify({"error": "instruction is required"}), 400
226
+ _get_runtime().start_task(instruction)
227
+ return jsonify({"status": "ok"})
228
+
229
+
230
+ @app.post("/api/task/annotating")
231
+ def api_task_annotating():
232
+ payload = _json_payload()
233
+ sample_id = str(payload.get("sample_id", "")).strip()
234
+ video_urls = payload.get("video_urls", {})
235
+ language_instruction = str(payload.get("language_instruction", "")).strip()
236
+ if not sample_id:
237
+ return jsonify({"error": "sample_id is required"}), 400
238
+ if not isinstance(video_urls, dict) or not video_urls:
239
+ return jsonify({"error": "video_urls must be a non-empty object"}), 400
240
+ _get_runtime().set_annotating(sample_id, video_urls, language_instruction)
241
+ return jsonify({"status": "ok"})
242
+
243
+
244
+ @app.post("/api/task/done")
245
+ def api_task_done():
246
+ _get_runtime().mark_done()
247
+ return jsonify({"status": "ok"})
248
+
249
+
250
+ @app.get("/api/annotations/<sample_id>")
251
+ def api_get_annotation(sample_id: str):
252
+ ann = _get_runtime().get_annotation(sample_id)
253
+ return jsonify(ann or {})
254
+
255
+
256
+ @app.post("/api/annotations")
257
+ def api_save_annotation_json() -> Any:
258
+ """Save annotation with ``sample_id`` in the JSON body (avoids slashes in URL paths)."""
259
+ payload = _json_payload()
260
+ if not isinstance(payload, dict):
261
+ return jsonify({"error": "invalid JSON body"}), 400
262
+ sample_id = str(payload.get("sample_id", "")).strip()
263
+ if not sample_id:
264
+ return jsonify({"error": "sample_id is required"}), 400
265
+ answers = {k: v for k, v in payload.items() if k != "sample_id"}
266
+ error = validate_annotation_payload(answers)
267
+ if error:
268
+ return jsonify({"error": error}), 400
269
+ ann = _get_runtime().save_annotation(sample_id, answers)
270
+ return jsonify({"status": "saved", "annotation": ann})
271
+
272
+
273
+ @app.get("/videos-path/<path:video_path>")
274
+ def serve_video_by_path(video_path: str):
275
+ rt = _get_runtime()
276
+ samples_root = rt.cfg.samples_dir.resolve()
277
+ target_path = (samples_root / video_path).resolve()
278
+ try:
279
+ target_path.relative_to(samples_root)
280
+ except ValueError:
281
+ abort(403)
282
+ if not target_path.exists() or not target_path.is_file():
283
+ abort(404)
284
+ if target_path.suffix.lower() == ".mp4":
285
+ return send_file(target_path, mimetype="video/mp4")
286
+ return send_file(target_path)
287
+
288
+
289
+ def _relative_to_samples_dir(samples_root: Path, path: Path) -> str | None:
290
+ try:
291
+ return path.resolve().relative_to(samples_root.resolve()).as_posix()
292
+ except ValueError:
293
+ return None
294
+
295
+
296
+ def _resolve_video_path(
297
+ samples_root: Path, raw_path: str, h5_path: Path
298
+ ) -> Path | None:
299
+ candidate = Path(raw_path)
300
+
301
+ if candidate.is_absolute():
302
+ if candidate.exists():
303
+ return candidate
304
+ else:
305
+ local_candidate = (h5_path.parent / candidate).resolve()
306
+ if local_candidate.exists():
307
+ return local_candidate
308
+
309
+ basename_candidate = (h5_path.parent / candidate.name).resolve()
310
+ if basename_candidate.exists():
311
+ return basename_candidate
312
+
313
+ samples_root_candidate = (samples_root.resolve() / candidate.name).resolve()
314
+ if samples_root_candidate.exists():
315
+ return samples_root_candidate
316
+
317
+ return None
318
+
319
+
320
+ def _decode_h5_value(value: Any) -> Any:
321
+ if isinstance(value, (bytes, bytearray)):
322
+ return value.decode("utf-8", errors="replace")
323
+
324
+ if hasattr(value, "shape") and getattr(value, "shape", None) == ():
325
+ try:
326
+ return _decode_h5_value(value.item())
327
+ except Exception:
328
+ return value
329
+
330
+ if hasattr(value, "tolist"):
331
+ try:
332
+ listed = value.tolist()
333
+ if isinstance(listed, list):
334
+ return [_decode_h5_value(v) for v in listed]
335
+ return _decode_h5_value(listed)
336
+ except Exception:
337
+ pass
338
+
339
+ return value
340
+
341
+
342
+ def _read_h5_attr(group: h5py.Group | h5py.File, key: str, default: Any = "") -> Any:
343
+ return _decode_h5_value(group.attrs.get(key, default))
344
+
345
+
346
+ class H5PathError(ValueError):
347
+ """Invalid or unsafe HDF5 path from query string; carries HTTP status for API responses."""
348
+
349
+ def __init__(self, message: str, status: int = 400) -> None:
350
+ super().__init__(message)
351
+ self.message = message
352
+ self.status = status
353
+
354
+
355
+ def _safe_h5_path_from_query(samples_root: Path) -> Path:
356
+ raw = str(request.args.get("path", "")).strip()
357
+ if not raw:
358
+ raise H5PathError("path query parameter is required", 400)
359
+ rel = Path(unquote(raw))
360
+ target = (samples_root.resolve() / rel).resolve()
361
+ try:
362
+ target.relative_to(samples_root.resolve())
363
+ except ValueError as e:
364
+ raise H5PathError("path escapes samples directory", 403) from e
365
+ if target.suffix.lower() != ".h5":
366
+ raise H5PathError("path must refer to an .h5 file", 400)
367
+ if not target.exists() or not target.is_file():
368
+ raise H5PathError("HDF5 file not found", 404)
369
+ return target
370
+
371
+
372
+ def _annotator_subgroup_looks_annotated(fa: h5py.Group) -> bool:
373
+ src = str(_read_h5_attr(fa, "source", "") or "").strip().lower()
374
+ if src == "human":
375
+ return True
376
+ if _read_h5_attr(fa, "success", None) is not None:
377
+ return True
378
+ # episode_description is v2; failure_description is the v1 spelling.
379
+ for key in ("episode_description", "failure_description", "taxonomy"):
380
+ if str(_read_h5_attr(fa, key, "") or "").strip():
381
+ return True
382
+ return False
383
+
384
+
385
+ def _read_existing_annotation_dict(ea: h5py.Group, annotator_name: str) -> dict[str, Any]:
386
+ """Same episode_annotations extraction as ``/api/h5/sample`` (subgroup first, else JSON).
387
+
388
+ Decoding — including the v1 upcast — is :func:`read_annotation_attrs`' job, so the form
389
+ sees v2 keys and slugs regardless of which schema the file on disk uses. Nothing is
390
+ rewritten here: a v1 file stays v1 until the annotator actually saves.
391
+ """
392
+ existing_annotation: dict[str, Any] = {}
393
+ if annotator_name in ea.keys() and isinstance(ea[annotator_name], h5py.Group):
394
+ existing_annotation = read_annotation_attrs(ea[annotator_name].attrs)
395
+
396
+ if not existing_annotation:
397
+ raw_failure = _read_h5_attr(ea, "failure_annotation", "")
398
+ raw_s = str(raw_failure or "").strip()
399
+ if raw_s:
400
+ try:
401
+ parsed = json.loads(raw_s)
402
+ if isinstance(parsed, dict) and parsed:
403
+ # The pre-subgroup layout stored the flat annotation dict as JSON.
404
+ # Route it through the same upcast so one definition serves both.
405
+ existing_annotation = read_annotation_attrs(
406
+ {
407
+ "success": parsed.get("success"),
408
+ "failure_description": parsed.get("failure_description", ""),
409
+ "additional_notes": parsed.get("additional_notes", ""),
410
+ "taxonomy": json.dumps(
411
+ {
412
+ "failure_category": parsed.get("failure_category", []),
413
+ "severity": parsed.get("severity", ""),
414
+ "success_category": parsed.get("success_category", ""),
415
+ }
416
+ ),
417
+ }
418
+ )
419
+ except Exception:
420
+ pass
421
+
422
+ return existing_annotation
423
+
424
+
425
+ def _annotation_tick_level(ann: dict[str, Any]) -> int:
426
+ """0 = not annotated, 1 = outcome only, 2 = complete for that outcome.
427
+
428
+ Validation no longer requires anything beyond ``outcome``, so this is purely a prompt:
429
+ it flags episodes where the chosen outcome asks about fields the annotator left blank.
430
+ Which fields each outcome asks about lives in :mod:`annotation_completeness`, alongside
431
+ the mirror of the form's conditional logic.
432
+ """
433
+ if str(ann.get("outcome", "")).strip().lower() not in OUTCOME_SUCCESS:
434
+ return 0
435
+ return 2 if is_complete(ann) else 1
436
+
437
+
438
+ def _h5_annotation_tick_level(h5f: h5py.File, annotator_name: str) -> int:
439
+ ea = h5f.get("episode_annotations")
440
+ if not isinstance(ea, h5py.Group):
441
+ return 0
442
+ ann = _read_existing_annotation_dict(ea, annotator_name)
443
+ level = _annotation_tick_level(ann)
444
+ if level > 0:
445
+ return level
446
+ raw = _read_h5_attr(ea, "failure_annotation", "")
447
+ raw_s = str(raw or "").strip()
448
+ if raw_s:
449
+ try:
450
+ parsed = json.loads(raw_s)
451
+ if isinstance(parsed, dict):
452
+ if len(parsed) > 0:
453
+ return 1
454
+ elif bool(parsed):
455
+ return 1
456
+ except Exception:
457
+ return 1
458
+ if annotator_name in ea.keys() and isinstance(ea[annotator_name], h5py.Group):
459
+ if _annotator_subgroup_looks_annotated(ea[annotator_name]):
460
+ return 1
461
+ return 0
462
+
463
+
464
+ def _dataset_summary(ds: h5py.Dataset) -> dict[str, Any]:
465
+ """Shape/dtype summary for a dataset, tolerant of ``h5py.Empty`` (null) datasets."""
466
+ shape = ds.shape
467
+ is_tuple = isinstance(shape, tuple)
468
+ empty = shape is None or (is_tuple and (len(shape) == 0 or 0 in shape))
469
+ return {
470
+ "shape": list(shape) if is_tuple else None,
471
+ "dtype": str(ds.dtype),
472
+ "empty": bool(empty),
473
+ }
474
+
475
+
476
+ def _summarize_episode_fields(h5f: h5py.File) -> dict[str, Any]:
477
+ """Compact, read-only summary of everything logged in an episode (issue #30).
478
+
479
+ Reads only attrs + dataset shapes/dtypes (no array or video decoding) so the
480
+ annotator can double-check that all fields were captured correctly.
481
+ """
482
+ attr_keys = ("schema", "episode_id", "operator_name", "lab_id", "language_instruction", "timestamp")
483
+ fields: dict[str, Any] = {
484
+ "attributes": {k: _read_h5_attr(h5f, k, "") for k in attr_keys},
485
+ # parse_taxonomy is reused here as a generic "JSON object attr, missing is empty"
486
+ # decoder — robot_profile is stored the same way the taxonomy is.
487
+ "robot_profile": parse_taxonomy(_read_h5_attr(h5f, "robot_profile", "")),
488
+ "robot_states": {},
489
+ "actions": {},
490
+ "video_paths": {},
491
+ }
492
+
493
+ obs = h5f.get("observations")
494
+ if isinstance(obs, h5py.Group):
495
+ rs = obs.get("robot_states")
496
+ if isinstance(rs, h5py.Group):
497
+ for key in rs.keys():
498
+ if isinstance(rs[key], h5py.Dataset):
499
+ fields["robot_states"][key] = _dataset_summary(rs[key])
500
+ vp = obs.get("video_paths")
501
+ if isinstance(vp, h5py.Group):
502
+ for cam in vp.keys():
503
+ try:
504
+ fields["video_paths"][cam] = _decode_h5_value(vp[cam][()])
505
+ except Exception:
506
+ continue
507
+
508
+ ag = h5f.get("actions")
509
+ if isinstance(ag, h5py.Group):
510
+ for key in ag.keys():
511
+ if isinstance(ag[key], h5py.Dataset):
512
+ fields["actions"][key] = _dataset_summary(ag[key])
513
+
514
+ fields["trajectory_length"] = next(
515
+ (s["shape"][0] for s in fields["robot_states"].values() if s.get("shape")),
516
+ None,
517
+ )
518
+ ea = h5f.get("episode_annotations")
519
+ fields["annotators"] = list(ea.keys()) if isinstance(ea, h5py.Group) else []
520
+ return fields
521
+
522
+
523
+ def _has_other_human_annotation(h5f: h5py.File, annotator_name: str) -> bool:
524
+ """Whether a human other than ``annotator_name`` has annotated this episode (#26)."""
525
+ ea = h5f.get("episode_annotations")
526
+ if not isinstance(ea, h5py.Group):
527
+ return False
528
+ for name in ea.keys():
529
+ if name == annotator_name:
530
+ continue
531
+ sub = ea[name]
532
+ if not isinstance(sub, h5py.Group):
533
+ continue
534
+ source = str(_read_h5_attr(sub, "source", "") or "").strip().lower()
535
+ if source and source != "human":
536
+ continue # VLM / automated annotators don't count as "another annotator"
537
+ if _annotator_subgroup_looks_annotated(sub):
538
+ return True
539
+ return False
540
+
541
+
542
+ @app.get("/api/h5/list")
543
+ def api_h5_list():
544
+ rt = _get_runtime()
545
+ root = rt.cfg.samples_dir.resolve()
546
+ entries: list[dict[str, Any]] = []
547
+ for p in root.rglob("*.h5"):
548
+ if not p.is_file():
549
+ continue
550
+ rel = p.resolve().relative_to(root).as_posix()
551
+ tick_level = 0
552
+ others = False
553
+ try:
554
+ with h5py.File(p, "r") as h5f:
555
+ tick_level = _h5_annotation_tick_level(h5f, rt.cfg.annotator_name)
556
+ others = _has_other_human_annotation(h5f, rt.cfg.annotator_name)
557
+ except Exception:
558
+ tick_level = 0
559
+ others = False
560
+ annotated = tick_level >= 1
561
+ entries.append(
562
+ {
563
+ "id": rel.replace("/", "__"),
564
+ "rel_path": rel,
565
+ "display_name": p.name,
566
+ "mtime": p.stat().st_mtime,
567
+ "annotated": annotated,
568
+ "annotation_tick_level": tick_level,
569
+ "annotated_by_others": others,
570
+ }
571
+ )
572
+ entries.sort(key=lambda e: e.get("rel_path") or "")
573
+ return jsonify(entries)
574
+
575
+
576
+ @app.get("/api/annotations/recent")
577
+ def api_recent_annotations():
578
+ """The current annotator's recent distinct annotations, for the autofill picker (#27)."""
579
+ rt = _get_runtime()
580
+ root = rt.cfg.samples_dir.resolve()
581
+ ann_name = rt.cfg.annotator_name
582
+ try:
583
+ limit = int(request.args.get("limit", 15))
584
+ except (TypeError, ValueError):
585
+ limit = 15
586
+ limit = max(1, min(limit, 100))
587
+
588
+ files = [p for p in root.rglob("*.h5") if p.is_file()]
589
+ files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
590
+
591
+ seen: set[str] = set()
592
+ recent: list[dict[str, Any]] = []
593
+ for p in files:
594
+ if len(recent) >= limit:
595
+ break
596
+ try:
597
+ with h5py.File(p, "r") as h5f:
598
+ ea = h5f.get("episode_annotations")
599
+ if not isinstance(ea, h5py.Group):
600
+ continue
601
+ # Only the current annotator's OWN subgroup — never the legacy
602
+ # episode-level failure_annotation fallback, which isn't
603
+ # annotator-scoped and could surface someone else's labels (#27).
604
+ if ann_name not in ea.keys() or not isinstance(ea[ann_name], h5py.Group):
605
+ continue
606
+ ann = _read_existing_annotation_dict(ea, ann_name)
607
+ except Exception:
608
+ continue
609
+ # Anything but a clean success has details worth copying — a suboptimal-execution
610
+ # description is as reusable as a failure's.
611
+ if str(ann.get("outcome", "")).strip().lower() in ("", "success"):
612
+ continue
613
+ key = json.dumps(
614
+ {
615
+ "side_effect_category": sorted(
616
+ str(x) for x in (ann.get("side_effect_category") or [])
617
+ ),
618
+ "severity": str(ann.get("severity", "")).strip(),
619
+ "episode_description": str(ann.get("episode_description", "")).strip(),
620
+ },
621
+ sort_keys=True,
622
+ )
623
+ if key in seen:
624
+ continue
625
+ seen.add(key)
626
+ rel = p.resolve().relative_to(root).as_posix()
627
+ recent.append({**ann, "__source_rel_path": rel, "__source_name": p.stem})
628
+ return jsonify(recent)
629
+
630
+
631
+ @app.get("/api/h5/sample")
632
+ def api_h5_sample():
633
+ rt = _get_runtime()
634
+ samples_root = rt.cfg.samples_dir.resolve()
635
+ try:
636
+ h5_path = _safe_h5_path_from_query(samples_root)
637
+ except H5PathError as e:
638
+ return jsonify({"error": e.message}), e.status
639
+
640
+ video_urls: dict[str, str] = {}
641
+ existing_annotation: dict[str, Any] = {}
642
+ episode_fields: dict[str, Any] = {}
643
+ metadata: dict[str, Any] = {
644
+ "rel_path": h5_path.resolve().relative_to(samples_root).as_posix()
645
+ }
646
+
647
+ with h5py.File(h5_path, "r") as h5f:
648
+ metadata["language_instruction"] = (
649
+ str(_read_h5_attr(h5f, "language_instruction", "")) or ""
650
+ )
651
+ metadata["episode_id"] = str(_read_h5_attr(h5f, "episode_id", "")) or ""
652
+ metadata["operator_name"] = str(_read_h5_attr(h5f, "operator_name", "")) or ""
653
+ episode_fields = _summarize_episode_fields(h5f)
654
+
655
+ ea = h5f.get("episode_annotations")
656
+ if isinstance(ea, h5py.Group):
657
+ ann_name = rt.cfg.annotator_name
658
+ if ann_name in ea.keys() and isinstance(ea[ann_name], h5py.Group):
659
+ fa = ea[ann_name]
660
+ metadata["success"] = _read_h5_attr(fa, "success", None)
661
+ existing_annotation = _read_existing_annotation_dict(ea, ann_name)
662
+
663
+ video_paths_group = h5f.get("observations/video_paths")
664
+ if isinstance(video_paths_group, h5py.Group):
665
+ for cam in video_paths_group.keys():
666
+ try:
667
+ raw_path = _decode_h5_value(video_paths_group[cam][()])
668
+ except Exception:
669
+ continue
670
+ if not isinstance(raw_path, str) or not raw_path:
671
+ continue
672
+ vp = _resolve_video_path(samples_root, raw_path, h5_path)
673
+ if vp is None:
674
+ continue
675
+ rel = _relative_to_samples_dir(samples_root, vp)
676
+ if rel is None:
677
+ continue
678
+ video_urls[str(cam)] = f"/videos-path/{quote(rel, safe='/')}"
679
+
680
+ # Fallback: if HDF5 doesn't store `observations/video_paths/*`, try to infer
681
+ # videos from files next to the H5: `<stem>_<cam>.mp4`.
682
+ if not video_urls:
683
+ stem = h5_path.stem
684
+ for mp4 in sorted(h5_path.parent.glob(f"{stem}_*.mp4")):
685
+ cam = mp4.stem[len(stem) + 1 :] # after "<stem>_"
686
+ if not cam:
687
+ continue
688
+ rel = _relative_to_samples_dir(samples_root, mp4)
689
+ if rel is None:
690
+ continue
691
+ video_urls[cam] = f"/videos-path/{quote(rel, safe='/')}"
692
+
693
+ return jsonify(
694
+ {
695
+ "rel_path": metadata["rel_path"],
696
+ "metadata": metadata,
697
+ "video_urls": video_urls,
698
+ "existing_annotation": existing_annotation,
699
+ "episode_fields": episode_fields,
700
+ }
701
+ )
702
+
703
+
704
+ @app.post("/api/h5/annotations")
705
+ def api_h5_save_annotation():
706
+ rt = _get_runtime()
707
+ samples_root = rt.cfg.samples_dir.resolve()
708
+ try:
709
+ h5_path = _safe_h5_path_from_query(samples_root)
710
+ except H5PathError as e:
711
+ return jsonify({"error": e.message}), e.status
712
+
713
+ payload = _json_payload()
714
+ if not isinstance(payload, dict):
715
+ return jsonify({"error": "invalid JSON body"}), 400
716
+ error = validate_annotation_payload(payload)
717
+ if error:
718
+ return jsonify({"error": error}), 400
719
+
720
+ # Stamp only. The annotation's home is the HDF5 file written below; keying the
721
+ # in-memory rollout dict by an absolute path would put two unrelated kinds of key
722
+ # into it for no reader.
723
+ ann = rt.stamp_annotation(payload)
724
+
725
+ try:
726
+ with h5py.File(h5_path, "r+") as f:
727
+ ea = f.require_group("episode_annotations")
728
+ ag = ea.require_group(ann["annotator"])
729
+ write_annotation_attrs(ag, ann)
730
+ except OSError as e:
731
+ return jsonify({"error": f"could not write HDF5: {e}"}), 500
732
+ except Exception as e:
733
+ return jsonify({"error": f"could not update annotation: {e}"}), 500
734
+
735
+ return jsonify({"status": "saved", "annotation": ann})
736
+
737
+
738
+ @app.post("/api/h5/instruction")
739
+ def api_h5_set_instruction():
740
+ """Overwrite an episode's ``language_instruction`` root attr (issue #31)."""
741
+ rt = _get_runtime()
742
+ samples_root = rt.cfg.samples_dir.resolve()
743
+ try:
744
+ h5_path = _safe_h5_path_from_query(samples_root)
745
+ except H5PathError as e:
746
+ return jsonify({"error": e.message}), e.status
747
+
748
+ instruction = str(_json_payload().get("instruction", "")).strip()
749
+ if not instruction:
750
+ return jsonify({"error": "instruction is required"}), 400
751
+
752
+ try:
753
+ with h5py.File(h5_path, "r+") as f:
754
+ f.attrs["language_instruction"] = instruction
755
+ except OSError as e:
756
+ return jsonify({"error": f"could not write HDF5: {e}"}), 500
757
+ except Exception as e:
758
+ return jsonify({"error": f"could not update instruction: {e}"}), 500
759
+
760
+ return jsonify({"status": "saved", "language_instruction": instruction})
761
+
762
+
763
+ TEMPLATE_DIR = Path(__file__).parent / "ui"
764
+
765
+
766
+ def _load_template(filename: str) -> str:
767
+ path = (TEMPLATE_DIR / filename).resolve()
768
+ if not path.exists():
769
+ raise FileNotFoundError(f"Missing template: {path}")
770
+ return path.read_text(encoding="utf-8")
771
+
772
+
773
+ def run_server(
774
+ samples_dir: Path,
775
+ annotator_name: str,
776
+ port: int = 5001,
777
+ open_browser: bool = True,
778
+ with_rollouts: bool = False,
779
+ debug: bool = False,
780
+ ) -> int:
781
+ """Configure the runtime and serve the annotation UI (blocks until interrupted).
782
+
783
+ Shared by this module's ``main()`` and the ``oopsie-data annotate`` CLI command.
784
+
785
+ ``debug`` is off by default and should stay off outside development: it enables the
786
+ Werkzeug debugger, whose interactive console executes arbitrary Python on request, and
787
+ the reloader, which re-executes this process and so loses any state answered at startup.
788
+ """
789
+ samples_dir = Path(samples_dir).resolve()
790
+ samples_dir.mkdir(parents=True, exist_ok=True)
791
+ configure_runtime(
792
+ samples_dir=samples_dir,
793
+ annotator_name=annotator_name.strip(),
794
+ browse_only=bool(not with_rollouts),
795
+ )
796
+
797
+ # WERKZEUG_RUN_MAIN marks the reloader's child process; only the parent should open a
798
+ # browser. Only reachable with debug=True, but harmless and correct either way.
799
+ if open_browser and os.environ.get("WERKZEUG_RUN_MAIN") != "true":
800
+ webbrowser.open(f"http://localhost:{port}/")
801
+
802
+ logging.getLogger("werkzeug").setLevel(logging.WARNING)
803
+
804
+ app.run(host="localhost", port=port, debug=debug)
805
+ return 0
806
+
807
+
808
+ def main() -> int:
809
+ parser = argparse.ArgumentParser(description="Annotator server")
810
+ parser.add_argument(
811
+ "--samples-dir",
812
+ type=Path,
813
+ default=Path("samples"),
814
+ help="Directory containing saved MP4s (and HDF5 episodes) for this session",
815
+ )
816
+ parser.add_argument("--port", type=int, default=5001)
817
+ parser.add_argument(
818
+ "--annotator-name",
819
+ type=str,
820
+ required=True,
821
+ help="Annotator name to stamp into saved annotations",
822
+ )
823
+ parser.add_argument("--no-browser", action="store_true")
824
+ parser.add_argument(
825
+ "--with-rollouts",
826
+ action="store_true",
827
+ help="HDF5 browser + annotation form + rollouts",
828
+ )
829
+ parser.add_argument(
830
+ "--debug",
831
+ action="store_true",
832
+ help=(
833
+ "Development only: enable the Werkzeug reloader and debugger. The debugger's "
834
+ "console executes arbitrary Python on request — never use it on a shared machine."
835
+ ),
836
+ )
837
+ args = parser.parse_args()
838
+
839
+ return run_server(
840
+ samples_dir=args.samples_dir,
841
+ annotator_name=args.annotator_name,
842
+ port=args.port,
843
+ open_browser=not args.no_browser,
844
+ with_rollouts=args.with_rollouts,
845
+ debug=args.debug,
846
+ )
847
+
848
+
849
+ if __name__ == "__main__":
850
+ raise SystemExit(main())