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,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())
|