oopsie-data-tools 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oopsie_data_tools/__init__.py +7 -0
- oopsie_data_tools/annotation_tool/__init__.py +5 -0
- oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
- oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
- oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
- oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
- oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
- oopsie_data_tools/cli.py +894 -0
- oopsie_data_tools/init_wizard.py +329 -0
- oopsie_data_tools/skill/SKILL.md +98 -0
- oopsie_data_tools/skill/reference/conversion.md +257 -0
- oopsie_data_tools/skill/reference/format.md +112 -0
- oopsie_data_tools/skill/reference/robot-profile.md +104 -0
- oopsie_data_tools/skill/reference/setup.md +216 -0
- oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
- oopsie_data_tools/test/__init__.py +1 -0
- oopsie_data_tools/test/conftest.py +174 -0
- oopsie_data_tools/test/fixtures/__init__.py +0 -0
- oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
- oopsie_data_tools/test/fixtures/make_valid.py +406 -0
- oopsie_data_tools/test/test_annotation_completeness.py +154 -0
- oopsie_data_tools/test/test_annotation_schema.py +190 -0
- oopsie_data_tools/test/test_annotator_server.py +240 -0
- oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
- oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
- oopsie_data_tools/test/test_cli_config.py +111 -0
- oopsie_data_tools/test/test_cli_tools.py +438 -0
- oopsie_data_tools/test/test_contributor_config.py +38 -0
- oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
- oopsie_data_tools/test/test_credentials_location.py +106 -0
- oopsie_data_tools/test/test_diversity.py +33 -0
- oopsie_data_tools/test/test_episode_recorder.py +335 -0
- oopsie_data_tools/test/test_episode_video_paths.py +173 -0
- oopsie_data_tools/test/test_hf_upload.py +234 -0
- oopsie_data_tools/test/test_init_wizard.py +193 -0
- oopsie_data_tools/test/test_install_skill.py +120 -0
- oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
- oopsie_data_tools/test/test_new_profile.py +84 -0
- oopsie_data_tools/test/test_paths.py +137 -0
- oopsie_data_tools/test/test_python38_compat.py +79 -0
- oopsie_data_tools/test/test_record_step_purity.py +127 -0
- oopsie_data_tools/test/test_robot_setup.py +244 -0
- oopsie_data_tools/test/test_rollout_annotator.py +134 -0
- oopsie_data_tools/test/test_rotation_utils.py +126 -0
- oopsie_data_tools/test/test_validate.py +494 -0
- oopsie_data_tools/utils/__init__.py +1 -0
- oopsie_data_tools/utils/claude_skill.py +96 -0
- oopsie_data_tools/utils/contributor_config.py +91 -0
- oopsie_data_tools/utils/conversion_utils.py +220 -0
- oopsie_data_tools/utils/h5.py +60 -0
- oopsie_data_tools/utils/h5_inspect.py +167 -0
- oopsie_data_tools/utils/hf_limits.py +22 -0
- oopsie_data_tools/utils/hf_upload.py +235 -0
- oopsie_data_tools/utils/log.py +35 -0
- oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
- oopsie_data_tools/utils/paths.py +162 -0
- oopsie_data_tools/utils/restructure.py +402 -0
- oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
- oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
- oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
- oopsie_data_tools/utils/robot_profile/template.py +108 -0
- oopsie_data_tools/utils/validation/__init__.py +5 -0
- oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
- oopsie_data_tools/utils/validation/diversity.py +93 -0
- oopsie_data_tools/utils/validation/episode_data.py +54 -0
- oopsie_data_tools/utils/validation/episode_loader.py +201 -0
- oopsie_data_tools/utils/validation/episode_validator.py +315 -0
- oopsie_data_tools/utils/validation/errors.py +17 -0
- oopsie_data_tools/utils/validation/validation_utils.py +88 -0
- oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
- oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
- oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
- oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
- oopsie_data_tools-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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())
|