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,333 @@
1
+ """Minimal rollout-side client for the minimal annotation server.
2
+
3
+ The rollout annnotator is responsible for:
4
+ - waiting for a language instruction
5
+ - recording step data
6
+ - after rollout: saving MP4s, show them in the web UI, wait for Save
7
+ - write annotation into episode HDF5 via EpisodeRecorder (failure_annotation attr)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import atexit
13
+ import datetime
14
+ import json
15
+ import logging
16
+ import os
17
+ import socket
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import time
22
+ import urllib.parse
23
+ import urllib.request
24
+ import webbrowser
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from oopsie_data_tools.annotation_tool.episode_recorder import EpisodeRecorder
29
+ from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class WebRolloutAnnotator:
35
+ def __init__(
36
+ self,
37
+ robot_profile: RobotProfile,
38
+ data_root_dir: Path | str,
39
+ operator_name: str,
40
+ annotator_name: str | None = None,
41
+ port: int = 5001,
42
+ wait_for_annotation: bool = True,
43
+ open_browser: bool = True,
44
+ resume_session_name: str | None = None,
45
+ ) -> None:
46
+ self.robot_profile = robot_profile
47
+ self.data_root_dir = Path(data_root_dir)
48
+ self.data_root_dir = self.data_root_dir.resolve()
49
+ self.operator_name = operator_name.strip()
50
+ self.annotator_name = annotator_name.strip() if annotator_name is not None else self.operator_name
51
+ self.port = port
52
+ self.wait_for_annotation = wait_for_annotation
53
+ self.open_browser = open_browser
54
+ self._proc: subprocess.Popen | None = None
55
+ self._active_recorder = EpisodeRecorder(
56
+ robot_profile=self.robot_profile,
57
+ data_root_dir=self.data_root_dir,
58
+ resume_session_name=resume_session_name,
59
+ operator_name=self.operator_name,
60
+ )
61
+
62
+ # ------------------------------------------------------------------
63
+ # Server lifecycle
64
+ # ------------------------------------------------------------------
65
+
66
+ def start(self, startup_timeout_s: float = 30.0) -> None:
67
+ """Start the annotation server unless something is already on the port.
68
+
69
+ Raises:
70
+ RuntimeError: If the server does not come up within ``startup_timeout_s``.
71
+ Continuing regardless would leave the rollout loop polling an address that
72
+ will never answer, with no output to explain why.
73
+ """
74
+ self.data_root_dir.mkdir(parents=True, exist_ok=True)
75
+
76
+ if not self._is_port_open():
77
+ self._spawn_server(startup_timeout_s)
78
+
79
+ if self.open_browser:
80
+ webbrowser.open(f"http://localhost:{self.port}/")
81
+
82
+ def _spawn_server(self, startup_timeout_s: float) -> None:
83
+ self._proc = subprocess.Popen(
84
+ [
85
+ # Not "python": inside a venv that may not exist, or may be a different
86
+ # interpreter than the one running this rollout.
87
+ sys.executable,
88
+ "-m",
89
+ "oopsie_data_tools.annotation_tool.annotator_server",
90
+ "--samples-dir",
91
+ str(self.data_root_dir),
92
+ "--port",
93
+ str(self.port),
94
+ "--annotator-name",
95
+ self.annotator_name,
96
+ "--with-rollouts",
97
+ "--no-browser",
98
+ ],
99
+ # stderr is kept so a failure to start is visible; discarding it was why a
100
+ # server that died on startup looked identical to one that was merely slow.
101
+ stdout=subprocess.DEVNULL,
102
+ )
103
+ atexit.register(self.stop)
104
+
105
+ deadline = time.monotonic() + startup_timeout_s
106
+ while time.monotonic() < deadline:
107
+ if self._is_port_open():
108
+ break
109
+ if self._proc.poll() is not None:
110
+ raise RuntimeError(
111
+ f"Annotation server exited immediately with code {self._proc.returncode}. "
112
+ "Its error output is above."
113
+ )
114
+ time.sleep(0.25)
115
+ else:
116
+ self.stop()
117
+ raise RuntimeError(
118
+ f"Annotation server did not start on port {self.port} within "
119
+ f"{startup_timeout_s:g}s. Pass a larger startup_timeout_s if this machine is "
120
+ "slow, or check whether the port is taken by something else."
121
+ )
122
+
123
+ def stop(self) -> None:
124
+ if self._proc is None:
125
+ return
126
+ self._proc.terminate()
127
+ try:
128
+ # Reap it, so a long rollout loop does not leave zombies behind.
129
+ self._proc.wait(timeout=5)
130
+ except subprocess.TimeoutExpired:
131
+ self._proc.kill()
132
+ self._proc.wait(timeout=5)
133
+ self._proc = None
134
+
135
+ def _is_port_open(self) -> bool:
136
+ with socket.socket() as s:
137
+ return s.connect_ex(("localhost", self.port)) == 0
138
+
139
+ # ------------------------------------------------------------------
140
+ # Task coordination
141
+ # ------------------------------------------------------------------
142
+
143
+ def wait_for_task(self) -> str:
144
+ """Block until the browser submits a language instruction, then claim it.
145
+
146
+ Polling errors are expected and transient (the server may still be starting), so
147
+ they are logged rather than raised — but they are no longer swallowed silently: a
148
+ server that has died used to leave this loop spinning forever with no output at all.
149
+ """
150
+ consecutive_failures = 0
151
+ while True:
152
+ try:
153
+ state = self._api_get("/api/task/state")
154
+ consecutive_failures = 0
155
+ if (
156
+ isinstance(state, dict)
157
+ and state.get("status") == "pending"
158
+ and state.get("pending_instruction")
159
+ ):
160
+ instruction = str(state["pending_instruction"])
161
+ self._api_post("/api/task/start", {"instruction": instruction})
162
+ return instruction
163
+ except Exception as e:
164
+ consecutive_failures += 1
165
+ # ~5s of failures, then every ~30s, so a dead server is visible without
166
+ # flooding the console during a normal wait.
167
+ if consecutive_failures == 10 or consecutive_failures % 60 == 0:
168
+ logger.warning(
169
+ "Cannot reach the annotation server at http://localhost:%s (%s). "
170
+ "Still waiting; is it still running?",
171
+ self.port,
172
+ e,
173
+ )
174
+ time.sleep(0.5)
175
+
176
+ # ------------------------------------------------------------------
177
+ # Optional recording passthrough
178
+ # ------------------------------------------------------------------
179
+
180
+ def reset_episode_recorder(self) -> None:
181
+ if self._active_recorder is not None:
182
+ self._active_recorder.reset_episode_recorder()
183
+
184
+ def record_step(self, observation: dict[str, Any], action: dict[str, Any]) -> None:
185
+ if self._active_recorder is not None:
186
+ self._active_recorder.record_step(observation=observation, action=action)
187
+
188
+ @property
189
+ def episode_name(self) -> str:
190
+ """Name of the episode being recorded — the stem of its ``.h5`` and MP4 files.
191
+
192
+ Callers wanting to log which episode a result belongs to used to reach for a
193
+ ``_last_sample_id`` attribute that has never existed.
194
+ """
195
+ return "" if self._active_recorder is None else self._active_recorder.save_fname
196
+
197
+ # ------------------------------------------------------------------
198
+ # Post-rollout workflow
199
+ # ------------------------------------------------------------------
200
+
201
+ def finish_rollout(
202
+ self,
203
+ instruction: str,
204
+ recorder: EpisodeRecorder | None = None,
205
+ ) -> dict[str, Any] | None:
206
+ if not isinstance(instruction, str) or not instruction.strip():
207
+ raise ValueError(f"Instruction must be a non-empty string, got {instruction!r}")
208
+
209
+ # Every use below must go through active_recorder. Three of them used to reach for
210
+ # self._active_recorder instead, so passing recorder= validated and stamped the
211
+ # wrong object — and raised AttributeError before reaching the guard right below
212
+ # whenever self._active_recorder was None, which is the case that guard is for.
213
+ active_recorder = recorder or self._active_recorder
214
+ if active_recorder is None:
215
+ raise ValueError("EpisodeRecorder is required (pass recorder=...).")
216
+ sample_id = active_recorder.save_fname
217
+
218
+ data = {
219
+ "language_instruction": instruction,
220
+ "metadata": {
221
+ "episode_id": sample_id,
222
+ "operator_name": self.operator_name,
223
+ },
224
+ }
225
+ # Raises if invalid, so an unwritable episode is never saved.
226
+ active_recorder._validate_pre_save(data)
227
+
228
+ # 1. Save videos under the recorder's per-session folder
229
+ video_paths = active_recorder._save_videos()
230
+ video_urls = {
231
+ cam: self._video_url_from_abs_path(Path(p))
232
+ for cam, p in video_paths.items()
233
+ }
234
+ data["video_paths"] = video_paths
235
+
236
+ # 2. Save the episode HDF5 straight away, before the annotation arrives, and patch
237
+ # the annotation in later. The file is not submittable until then, but writing it
238
+ # now lets the UI show the videos without waiting for the human.
239
+ h5_path = active_recorder.save(data)
240
+
241
+ # 3. Wait for annotation from human annotator
242
+ logger.info(
243
+ "Waiting for annotation from human annotator at http://localhost:%s ...", self.port
244
+ )
245
+ self._api_post(
246
+ "/api/task/annotating",
247
+ {
248
+ "sample_id": sample_id,
249
+ "video_urls": video_urls,
250
+ "language_instruction": instruction,
251
+ },
252
+ )
253
+ annotation: dict[str, Any] | None = None
254
+ if self.wait_for_annotation:
255
+ annotation = self._wait_for_annotation(sample_id)
256
+ # Reset UI to idle regardless of save/write outcome
257
+ self._api_post("/api/task/done", {})
258
+
259
+ # 4. Write annotation into episode HDF5 in-place
260
+ if annotation:
261
+ # Mirror the old behavior of also writing annotation into a sidecar JSON
262
+ # to keep it co-located with the MP4s.
263
+ sample_json = active_recorder.session_dir / f"{sample_id}.json"
264
+ metadata = {
265
+ "language_instruction": instruction,
266
+ "camera_names": list(video_paths.keys()),
267
+ "timestamp": datetime.datetime.now().isoformat(),
268
+ "annotation": {
269
+ k: v
270
+ for k, v in annotation.items()
271
+ if k not in {"annotated_at", "annotator", "source"}
272
+ },
273
+ }
274
+ fd, tmp = tempfile.mkstemp(
275
+ dir=active_recorder.session_dir, suffix=".json.tmp"
276
+ )
277
+ try:
278
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
279
+ json.dump(metadata, f, indent=2, ensure_ascii=False)
280
+ Path(tmp).replace(sample_json)
281
+ except Exception:
282
+ try:
283
+ Path(tmp).unlink(missing_ok=True)
284
+ except Exception:
285
+ pass
286
+ raise
287
+
288
+ # Patch the already-saved episode HDF5 in-place
289
+ EpisodeRecorder.patch_h5_failure_annotation(Path(h5_path), annotation)
290
+
291
+ return annotation
292
+
293
+ def _wait_for_annotation(self, sample_id: str) -> dict[str, Any]:
294
+ while True:
295
+ data = self._api_get(f"/api/annotations/{sample_id}")
296
+ if not isinstance(data, dict):
297
+ time.sleep(0.5)
298
+ continue
299
+ if data.get("__annotation_skipped__"):
300
+ return {}
301
+ inner = (
302
+ data.get("annotation")
303
+ if isinstance(data.get("annotation"), dict)
304
+ else None
305
+ )
306
+ if inner and inner.get("__annotation_skipped__"):
307
+ return {}
308
+ if data:
309
+ # minimal server returns the annotation dict directly
310
+ return inner if inner is not None else data
311
+ time.sleep(0.5)
312
+
313
+ def _video_url_from_abs_path(self, abs_path: Path) -> str:
314
+ rel = abs_path.resolve().relative_to(self.data_root_dir.resolve()).as_posix()
315
+ return f"/videos-path/{urllib.parse.quote(rel, safe='/')}"
316
+
317
+ # ------------------------------------------------------------------
318
+ # HTTP helpers
319
+ # ------------------------------------------------------------------
320
+
321
+ def _api_get(self, path: str) -> Any:
322
+ url = f"http://localhost:{self.port}{path}"
323
+ with urllib.request.urlopen(url, timeout=5) as resp:
324
+ return json.loads(resp.read())
325
+
326
+ def _api_post(self, path: str, data: dict[str, Any]) -> Any:
327
+ url = f"http://localhost:{self.port}{path}"
328
+ body = json.dumps(data).encode()
329
+ req = urllib.request.Request(
330
+ url, data=body, headers={"Content-Type": "application/json"}
331
+ )
332
+ with urllib.request.urlopen(req, timeout=5) as resp:
333
+ return json.loads(resp.read())