oopsie-data-tools 0.2.2__tar.gz → 0.3__tar.gz
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-0.2.2 → oopsie_data_tools-0.3}/PKG-INFO +2 -1
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/annotation_tool/annotator_server.py +91 -118
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/annotation_tool/episode_recorder.py +9 -42
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/annotation_tool/rollout_annotator.py +47 -63
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/annotation_tool/ui/annotator.html +183 -309
- oopsie_data_tools-0.3/oopsie_data_tools/cli.py +851 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/init_wizard.py +23 -54
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/skill/reference/format.md +37 -8
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/skill/reference/setup.md +2 -4
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/skill/reference/troubleshooting.md +2 -1
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/conftest.py +2 -22
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/fixtures/make_invalid.py +24 -96
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_annotation_schema.py +0 -7
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_annotator_server.py +48 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_annotator_server_guards.py +0 -7
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_cli_config.py +10 -33
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_cli_tools.py +13 -10
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_credentials_location.py +3 -3
- oopsie_data_tools-0.3/oopsie_data_tools/test/test_episode_recorder.py +201 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_hf_upload.py +1 -9
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_init_wizard.py +3 -3
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_install_skill.py +17 -69
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_paths.py +0 -2
- oopsie_data_tools-0.3/oopsie_data_tools/test/test_robot_setup.py +150 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_validate.py +23 -131
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/contributor_config.py +15 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/conversion_utils.py +3 -29
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/h5_inspect.py +84 -95
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/hf_upload.py +10 -13
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/migrate_taxonomy_v2.py +0 -38
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/paths.py +0 -17
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/restructure.py +20 -62
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/validation/episode_loader.py +8 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/validation/episode_validator.py +13 -6
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/pyproject.toml +2 -1
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/uv.lock +4 -1
- oopsie_data_tools-0.2.2/oopsie_data_tools/cli.py +0 -1065
- oopsie_data_tools-0.2.2/oopsie_data_tools/test/test_episode_recorder.py +0 -335
- oopsie_data_tools-0.2.2/oopsie_data_tools/test/test_python38_compat.py +0 -79
- oopsie_data_tools-0.2.2/oopsie_data_tools/test/test_robot_setup.py +0 -244
- oopsie_data_tools-0.2.2/oopsie_data_tools/test/test_rollout_annotator.py +0 -134
- oopsie_data_tools-0.2.2/oopsie_data_tools/test/test_skill_matches_cli.py +0 -97
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/.github/ISSUE_TEMPLATE/help-request--data-conversion.md +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/.github/ISSUE_TEMPLATE/help-request--robot-profile.md +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/.github/ISSUE_TEMPLATE/improvement-suggestion.md +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/.github/workflows/ci.yml +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/.github/workflows/publish.yml +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/.gitignore +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/.python-version +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/LICENSE +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/README.md +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/configs/robot_profiles/act_plus_plus_robot_profile.yaml +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/configs/robot_profiles/openpi_example_robot_profile.yaml +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/configs/robot_profiles/template.yaml +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/conversion_script_examples/_common.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/conversion_script_examples/convert_ar_aloha.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/conversion_script_examples/convert_rlds.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/conversion_script_examples/convert_soar.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/inference_examples/act_plus_plus/run_policy.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/inference_examples/act_plus_plus/run_policy_browser.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/inference_examples/act_plus_plus/run_policy_bulk.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/inference_examples/act_plus_plus/run_policy_inloop.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/inference_examples/openpi/run_policy_bulk.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/examples/inference_examples/openpi/run_policy_inloop.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/__init__.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/annotation_tool/__init__.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/annotation_tool/annotation_schema.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/skill/SKILL.md +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/skill/reference/conversion.md +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/skill/reference/robot-profile.md +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/__init__.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/fixtures/__init__.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/fixtures/make_valid.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_annotation_completeness.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_bulk_inference_end_to_end.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_cli_json.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_contributor_config.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_conversion_utils_annotations.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_conversion_utils_writers.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_diversity.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_episode_video_paths.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_migrate_taxonomy_v2.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_new_profile.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_record_step_purity.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/test/test_rotation_utils.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/__init__.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/claude_skill.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/h5.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/hf_limits.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/log.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/robot_profile/__init__.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/robot_profile/robot_profile.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/robot_profile/rotation_utils.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/robot_profile/template.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/validation/__init__.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/validation/annotation_completeness.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/validation/diversity.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/validation/episode_data.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/validation/errors.py +0 -0
- {oopsie_data_tools-0.2.2 → oopsie_data_tools-0.3}/oopsie_data_tools/utils/validation/validation_utils.py +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oopsie-data-tools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3
|
|
4
4
|
Summary: Collect, annotate, validate and upload robotic manipulation rollout data.
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.8
|
|
7
|
+
Requires-Dist: click>=8.1.3
|
|
7
8
|
Requires-Dist: flask>=3.0.0
|
|
8
9
|
Requires-Dist: h5py>=3.7.0
|
|
9
10
|
Requires-Dist: huggingface-hub>=0.36.2
|
|
@@ -14,17 +14,16 @@ Tool can also be launched as a standalone module to browse and annotate existing
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
import argparse
|
|
18
17
|
import json
|
|
19
18
|
import logging
|
|
20
|
-
import
|
|
19
|
+
import socket
|
|
21
20
|
import threading
|
|
22
21
|
import webbrowser
|
|
23
22
|
from dataclasses import dataclass
|
|
24
23
|
from datetime import datetime, timezone
|
|
25
24
|
from pathlib import Path
|
|
26
25
|
from typing import Any
|
|
27
|
-
from urllib.parse import quote
|
|
26
|
+
from urllib.parse import quote
|
|
28
27
|
|
|
29
28
|
import h5py
|
|
30
29
|
from flask import Flask, abort, jsonify, request, send_file
|
|
@@ -257,8 +256,6 @@ def api_get_annotation(sample_id: str):
|
|
|
257
256
|
def api_save_annotation_json() -> Any:
|
|
258
257
|
"""Save annotation with ``sample_id`` in the JSON body (avoids slashes in URL paths)."""
|
|
259
258
|
payload = _json_payload()
|
|
260
|
-
if not isinstance(payload, dict):
|
|
261
|
-
return jsonify({"error": "invalid JSON body"}), 400
|
|
262
259
|
sample_id = str(payload.get("sample_id", "")).strip()
|
|
263
260
|
if not sample_id:
|
|
264
261
|
return jsonify({"error": "sample_id is required"}), 400
|
|
@@ -348,15 +345,19 @@ class H5PathError(ValueError):
|
|
|
348
345
|
|
|
349
346
|
def __init__(self, message: str, status: int = 400) -> None:
|
|
350
347
|
super().__init__(message)
|
|
351
|
-
self.message = message
|
|
352
348
|
self.status = status
|
|
353
349
|
|
|
354
350
|
|
|
351
|
+
@app.errorhandler(H5PathError)
|
|
352
|
+
def _h5_path_error(e: H5PathError):
|
|
353
|
+
return jsonify({"error": str(e)}), e.status
|
|
354
|
+
|
|
355
|
+
|
|
355
356
|
def _safe_h5_path_from_query(samples_root: Path) -> Path:
|
|
356
357
|
raw = str(request.args.get("path", "")).strip()
|
|
357
358
|
if not raw:
|
|
358
359
|
raise H5PathError("path query parameter is required", 400)
|
|
359
|
-
rel = Path(
|
|
360
|
+
rel = Path(raw)
|
|
360
361
|
target = (samples_root.resolve() / rel).resolve()
|
|
361
362
|
try:
|
|
362
363
|
target.relative_to(samples_root.resolve())
|
|
@@ -443,21 +444,9 @@ def _h5_annotation_tick_level(h5f: h5py.File, annotator_name: str) -> int:
|
|
|
443
444
|
level = _annotation_tick_level(ann)
|
|
444
445
|
if level > 0:
|
|
445
446
|
return level
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
447
|
+
sub = ea.get(annotator_name)
|
|
448
|
+
if isinstance(sub, h5py.Group) and _annotator_subgroup_looks_annotated(sub):
|
|
449
|
+
return 1
|
|
461
450
|
return 0
|
|
462
451
|
|
|
463
452
|
|
|
@@ -539,6 +528,49 @@ def _has_other_human_annotation(h5f: h5py.File, annotator_name: str) -> bool:
|
|
|
539
528
|
return False
|
|
540
529
|
|
|
541
530
|
|
|
531
|
+
_H5_META_CACHE: dict[tuple[str, str], tuple[tuple[float, int], dict[str, Any]]] = {}
|
|
532
|
+
_H5_META_LOCK = threading.Lock()
|
|
533
|
+
_H5_META_CACHE_MAX = 5000
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _h5_meta(path: Path, annotator_name: str) -> dict[str, Any]:
|
|
537
|
+
"""Annotation summary of one episode, cached on (mtime, size).
|
|
538
|
+
|
|
539
|
+
The browser polls ``/api/h5/list`` every 1.5 s per open tab, so without this every
|
|
540
|
+
poll would reopen every episode in the tree.
|
|
541
|
+
"""
|
|
542
|
+
try:
|
|
543
|
+
st = path.stat()
|
|
544
|
+
except OSError:
|
|
545
|
+
return {"tick_level": 0, "annotated_by_others": False, "annotation": None}
|
|
546
|
+
key = (str(path), annotator_name)
|
|
547
|
+
stamp = (st.st_mtime, st.st_size)
|
|
548
|
+
with _H5_META_LOCK:
|
|
549
|
+
hit = _H5_META_CACHE.get(key)
|
|
550
|
+
if hit is not None and hit[0] == stamp:
|
|
551
|
+
return hit[1]
|
|
552
|
+
|
|
553
|
+
meta: dict[str, Any] = {"tick_level": 0, "annotated_by_others": False, "annotation": None}
|
|
554
|
+
try:
|
|
555
|
+
with h5py.File(path, "r") as h5f:
|
|
556
|
+
meta["tick_level"] = _h5_annotation_tick_level(h5f, annotator_name)
|
|
557
|
+
meta["annotated_by_others"] = _has_other_human_annotation(h5f, annotator_name)
|
|
558
|
+
ea = h5f.get("episode_annotations")
|
|
559
|
+
# Only the current annotator's OWN subgroup — never the legacy
|
|
560
|
+
# episode-level failure_annotation fallback, which isn't annotator-scoped
|
|
561
|
+
# and could surface someone else's labels (#27).
|
|
562
|
+
if isinstance(ea, h5py.Group) and isinstance(ea.get(annotator_name), h5py.Group):
|
|
563
|
+
meta["annotation"] = _read_existing_annotation_dict(ea, annotator_name)
|
|
564
|
+
except Exception:
|
|
565
|
+
meta = {"tick_level": 0, "annotated_by_others": False, "annotation": None}
|
|
566
|
+
|
|
567
|
+
with _H5_META_LOCK:
|
|
568
|
+
if len(_H5_META_CACHE) >= _H5_META_CACHE_MAX:
|
|
569
|
+
_H5_META_CACHE.clear()
|
|
570
|
+
_H5_META_CACHE[key] = (stamp, meta)
|
|
571
|
+
return meta
|
|
572
|
+
|
|
573
|
+
|
|
542
574
|
@app.get("/api/h5/list")
|
|
543
575
|
def api_h5_list():
|
|
544
576
|
rt = _get_runtime()
|
|
@@ -548,15 +580,9 @@ def api_h5_list():
|
|
|
548
580
|
if not p.is_file():
|
|
549
581
|
continue
|
|
550
582
|
rel = p.resolve().relative_to(root).as_posix()
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
583
|
+
meta = _h5_meta(p, rt.cfg.annotator_name)
|
|
584
|
+
tick_level = meta["tick_level"]
|
|
585
|
+
others = meta["annotated_by_others"]
|
|
560
586
|
annotated = tick_level >= 1
|
|
561
587
|
entries.append(
|
|
562
588
|
{
|
|
@@ -593,18 +619,8 @@ def api_recent_annotations():
|
|
|
593
619
|
for p in files:
|
|
594
620
|
if len(recent) >= limit:
|
|
595
621
|
break
|
|
596
|
-
|
|
597
|
-
|
|
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:
|
|
622
|
+
ann = _h5_meta(p, ann_name)["annotation"]
|
|
623
|
+
if ann is None:
|
|
608
624
|
continue
|
|
609
625
|
# Anything but a clean success has details worth copying — a suboptimal-execution
|
|
610
626
|
# description is as reusable as a failure's.
|
|
@@ -632,10 +648,7 @@ def api_recent_annotations():
|
|
|
632
648
|
def api_h5_sample():
|
|
633
649
|
rt = _get_runtime()
|
|
634
650
|
samples_root = rt.cfg.samples_dir.resolve()
|
|
635
|
-
|
|
636
|
-
h5_path = _safe_h5_path_from_query(samples_root)
|
|
637
|
-
except H5PathError as e:
|
|
638
|
-
return jsonify({"error": e.message}), e.status
|
|
651
|
+
h5_path = _safe_h5_path_from_query(samples_root)
|
|
639
652
|
|
|
640
653
|
video_urls: dict[str, str] = {}
|
|
641
654
|
existing_annotation: dict[str, Any] = {}
|
|
@@ -705,14 +718,9 @@ def api_h5_sample():
|
|
|
705
718
|
def api_h5_save_annotation():
|
|
706
719
|
rt = _get_runtime()
|
|
707
720
|
samples_root = rt.cfg.samples_dir.resolve()
|
|
708
|
-
|
|
709
|
-
h5_path = _safe_h5_path_from_query(samples_root)
|
|
710
|
-
except H5PathError as e:
|
|
711
|
-
return jsonify({"error": e.message}), e.status
|
|
721
|
+
h5_path = _safe_h5_path_from_query(samples_root)
|
|
712
722
|
|
|
713
723
|
payload = _json_payload()
|
|
714
|
-
if not isinstance(payload, dict):
|
|
715
|
-
return jsonify({"error": "invalid JSON body"}), 400
|
|
716
724
|
error = validate_annotation_payload(payload)
|
|
717
725
|
if error:
|
|
718
726
|
return jsonify({"error": error}), 400
|
|
@@ -727,10 +735,8 @@ def api_h5_save_annotation():
|
|
|
727
735
|
ea = f.require_group("episode_annotations")
|
|
728
736
|
ag = ea.require_group(ann["annotator"])
|
|
729
737
|
write_annotation_attrs(ag, ann)
|
|
730
|
-
except OSError as e:
|
|
731
|
-
return jsonify({"error": f"could not write HDF5: {e}"}), 500
|
|
732
738
|
except Exception as e:
|
|
733
|
-
return jsonify({"error": f"could not
|
|
739
|
+
return jsonify({"error": f"could not write annotation: {e}"}), 500
|
|
734
740
|
|
|
735
741
|
return jsonify({"status": "saved", "annotation": ann})
|
|
736
742
|
|
|
@@ -740,10 +746,7 @@ def api_h5_set_instruction():
|
|
|
740
746
|
"""Overwrite an episode's ``language_instruction`` root attr (issue #31)."""
|
|
741
747
|
rt = _get_runtime()
|
|
742
748
|
samples_root = rt.cfg.samples_dir.resolve()
|
|
743
|
-
|
|
744
|
-
h5_path = _safe_h5_path_from_query(samples_root)
|
|
745
|
-
except H5PathError as e:
|
|
746
|
-
return jsonify({"error": e.message}), e.status
|
|
749
|
+
h5_path = _safe_h5_path_from_query(samples_root)
|
|
747
750
|
|
|
748
751
|
instruction = str(_json_payload().get("instruction", "")).strip()
|
|
749
752
|
if not instruction:
|
|
@@ -752,10 +755,8 @@ def api_h5_set_instruction():
|
|
|
752
755
|
try:
|
|
753
756
|
with h5py.File(h5_path, "r+") as f:
|
|
754
757
|
f.attrs["language_instruction"] = instruction
|
|
755
|
-
except OSError as e:
|
|
756
|
-
return jsonify({"error": f"could not write HDF5: {e}"}), 500
|
|
757
758
|
except Exception as e:
|
|
758
|
-
return jsonify({"error": f"could not
|
|
759
|
+
return jsonify({"error": f"could not write instruction: {e}"}), 500
|
|
759
760
|
|
|
760
761
|
return jsonify({"status": "saved", "language_instruction": instruction})
|
|
761
762
|
|
|
@@ -770,21 +771,29 @@ def _load_template(filename: str) -> str:
|
|
|
770
771
|
return path.read_text(encoding="utf-8")
|
|
771
772
|
|
|
772
773
|
|
|
774
|
+
def _port_in_use(port: int, host: str = "localhost") -> bool:
|
|
775
|
+
"""Whether something is already listening on ``host:port``.
|
|
776
|
+
|
|
777
|
+
Connecting rather than test-binding: a bind probe also trips on sockets in TIME_WAIT,
|
|
778
|
+
which werkzeug's SO_REUSEADDR would have accepted anyway.
|
|
779
|
+
"""
|
|
780
|
+
try:
|
|
781
|
+
with socket.create_connection((host, port), timeout=0.5):
|
|
782
|
+
return True
|
|
783
|
+
except OSError:
|
|
784
|
+
return False
|
|
785
|
+
|
|
786
|
+
|
|
773
787
|
def run_server(
|
|
774
788
|
samples_dir: Path,
|
|
775
789
|
annotator_name: str,
|
|
776
790
|
port: int = 5001,
|
|
777
791
|
open_browser: bool = True,
|
|
778
792
|
with_rollouts: bool = False,
|
|
779
|
-
debug: bool = False,
|
|
780
793
|
) -> int:
|
|
781
794
|
"""Configure the runtime and serve the annotation UI (blocks until interrupted).
|
|
782
795
|
|
|
783
|
-
|
|
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.
|
|
796
|
+
Backs the ``oopsie-data annotate`` CLI command.
|
|
788
797
|
"""
|
|
789
798
|
samples_dir = Path(samples_dir).resolve()
|
|
790
799
|
samples_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -794,57 +803,21 @@ def run_server(
|
|
|
794
803
|
browse_only=bool(not with_rollouts),
|
|
795
804
|
)
|
|
796
805
|
|
|
797
|
-
#
|
|
798
|
-
#
|
|
799
|
-
if
|
|
806
|
+
# Checked before the browser opens: werkzeug would otherwise fail its own bind a
|
|
807
|
+
# moment later, leaving a tab pointed at whatever already owns the port.
|
|
808
|
+
if _port_in_use(port):
|
|
809
|
+
logging.getLogger(__name__).error(
|
|
810
|
+
"Port %d is already in use — another annotator is probably still running "
|
|
811
|
+
"(open http://localhost:%d/ to check). Stop it, or pass --port with a free port.",
|
|
812
|
+
port,
|
|
813
|
+
port,
|
|
814
|
+
)
|
|
815
|
+
return 1
|
|
816
|
+
|
|
817
|
+
if open_browser:
|
|
800
818
|
webbrowser.open(f"http://localhost:{port}/")
|
|
801
819
|
|
|
802
820
|
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
|
803
821
|
|
|
804
|
-
app.run(host="localhost", port=port
|
|
822
|
+
app.run(host="localhost", port=port)
|
|
805
823
|
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())
|
|
@@ -17,7 +17,7 @@ from oopsie_data_tools.annotation_tool.annotation_schema import (
|
|
|
17
17
|
success_to_outcome,
|
|
18
18
|
write_annotation_attrs,
|
|
19
19
|
)
|
|
20
|
-
from oopsie_data_tools.utils
|
|
20
|
+
from oopsie_data_tools.utils import contributor_config
|
|
21
21
|
from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile, robot_profile_to_json
|
|
22
22
|
from oopsie_data_tools.utils.robot_profile.rotation_utils import ActionQuatConversion
|
|
23
23
|
from oopsie_data_tools.utils.validation.episode_data import EpisodeData, VideoInfo
|
|
@@ -88,7 +88,7 @@ class EpisodeRecorder:
|
|
|
88
88
|
robot_profile (RobotProfile): Robot profile.
|
|
89
89
|
data_root_dir (str): Base output directory for saved artifacts.
|
|
90
90
|
operator_name (str): Name of the operator recording the episode.
|
|
91
|
-
|
|
91
|
+
resume_session_name (str | None): Optional unique session name
|
|
92
92
|
|
|
93
93
|
Raises:
|
|
94
94
|
ValueError: If ``data_root_dir`` is not a valid directory.
|
|
@@ -134,7 +134,7 @@ class EpisodeRecorder:
|
|
|
134
134
|
# AttributeError on save_fname — an undocumented ordering requirement.
|
|
135
135
|
self.reset_episode_recorder()
|
|
136
136
|
|
|
137
|
-
self.lab_id, _ = read_contributor_config()
|
|
137
|
+
self.lab_id, _ = contributor_config.read_contributor_config()
|
|
138
138
|
|
|
139
139
|
def reset_episode_recorder(self) -> None:
|
|
140
140
|
"""Reset the buffers and start a new episode."""
|
|
@@ -180,24 +180,13 @@ class EpisodeRecorder:
|
|
|
180
180
|
# Buffer frames for each configured camera (if available)
|
|
181
181
|
for cam in self.camera_names:
|
|
182
182
|
frame = self._get_camera_frame(observation["image_observation"], cam)
|
|
183
|
-
|
|
184
|
-
self.frames[cam].append(np.asarray(frame, dtype=np.uint8))
|
|
183
|
+
self.frames[cam].append(np.asarray(frame, dtype=np.uint8))
|
|
185
184
|
|
|
186
185
|
# Buffer timestep data
|
|
187
186
|
step_data = {"robot_state": {}, "action_dict": {}}
|
|
188
187
|
for key in self.robot_profile.robot_state_keys:
|
|
189
188
|
step_data["robot_state"][key] = np.asarray(robot_state[key], dtype=np.float32)
|
|
190
|
-
step_data["action_dict"] = {
|
|
191
|
-
"cartesian_position": action.get("cartesian_position", None),
|
|
192
|
-
"cartesian_velocity": action.get("cartesian_velocity", None),
|
|
193
|
-
"joint_position": action.get("joint_position", None),
|
|
194
|
-
"joint_velocity": action.get("joint_velocity", None),
|
|
195
|
-
"base_position": action.get("base_position", None),
|
|
196
|
-
"base_velocity": action.get("base_velocity", None),
|
|
197
|
-
"gripper_velocity": action.get("gripper_velocity", None),
|
|
198
|
-
"gripper_position": action.get("gripper_position", None),
|
|
199
|
-
"gripper_binary": action.get("gripper_binary", None),
|
|
200
|
-
}
|
|
189
|
+
step_data["action_dict"] = {k: action.get(k) for k in sorted(VALID_ACTION_KEYS)}
|
|
201
190
|
self.timesteps.append(step_data)
|
|
202
191
|
|
|
203
192
|
def _save_videos(self) -> dict[str, str]:
|
|
@@ -252,7 +241,7 @@ class EpisodeRecorder:
|
|
|
252
241
|
"""Persist the currently buffered episode to disk.
|
|
253
242
|
|
|
254
243
|
Args:
|
|
255
|
-
|
|
244
|
+
data (dict[str, Any]): Save metadata containing language and
|
|
256
245
|
annotation fields.
|
|
257
246
|
|
|
258
247
|
Returns:
|
|
@@ -349,14 +338,6 @@ class EpisodeRecorder:
|
|
|
349
338
|
f"action must not be empty. Valid keys: {VALID_ACTION_KEYS}. Please pass it in your record_step() call. Double check that the passed keys match the robot profile you initialized the recorder with."
|
|
350
339
|
)
|
|
351
340
|
|
|
352
|
-
# Make sure the action keys are valid
|
|
353
|
-
invalid_action = set(action.keys()) - set(VALID_ACTION_KEYS)
|
|
354
|
-
if invalid_action:
|
|
355
|
-
raise ValueError(
|
|
356
|
-
f"action contains unrecognized keys: {sorted(invalid_action)}. "
|
|
357
|
-
f"Valid keys: {VALID_ACTION_KEYS}"
|
|
358
|
-
)
|
|
359
|
-
|
|
360
341
|
# Make sure the action keys agree between robot_profile and the action dict
|
|
361
342
|
profile_action_keys = set(self.robot_profile.action_space)
|
|
362
343
|
action_keys = set(action.keys())
|
|
@@ -429,7 +410,7 @@ class EpisodeRecorder:
|
|
|
429
410
|
|
|
430
411
|
Args:
|
|
431
412
|
path (Path): Target HDF5 file path.
|
|
432
|
-
|
|
413
|
+
data (dict[str, Any]): Normalized metadata payload.
|
|
433
414
|
|
|
434
415
|
Returns:
|
|
435
416
|
None: This method only performs file I/O side effects.
|
|
@@ -562,22 +543,8 @@ class EpisodeRecorder:
|
|
|
562
543
|
def _get_camera_frame(
|
|
563
544
|
self, observation: dict[str, Any], cam_name: str
|
|
564
545
|
) -> np.ndarray | None:
|
|
565
|
-
"""
|
|
566
|
-
|
|
567
|
-
Args:
|
|
568
|
-
observation (dict[str, Any]): Observation dictionary for one
|
|
569
|
-
timestep.
|
|
570
|
-
cam_name (str): Canonical camera name to resolve.
|
|
571
|
-
|
|
572
|
-
Returns:
|
|
573
|
-
np.ndarray | None: Camera frame array when available, otherwise
|
|
574
|
-
``None``.
|
|
575
|
-
"""
|
|
576
|
-
candidates = (cam_name, f"image_{cam_name}", f"{cam_name}_image")
|
|
577
|
-
for key in candidates:
|
|
578
|
-
if key in observation:
|
|
579
|
-
return np.asarray(observation[key])
|
|
580
|
-
return None
|
|
546
|
+
"""The frame for *cam_name*; the key is guaranteed present by the step check."""
|
|
547
|
+
return np.asarray(observation[cam_name])
|
|
581
548
|
|
|
582
549
|
def _validate_pre_save(self, data: dict[str, Any]) -> None:
|
|
583
550
|
"""Perform final validation checks before saving the episode.
|
|
@@ -44,14 +44,14 @@ class WebRolloutAnnotator:
|
|
|
44
44
|
resume_session_name: str | None = None,
|
|
45
45
|
) -> None:
|
|
46
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()
|
|
47
|
+
self.data_root_dir = Path(data_root_dir).resolve()
|
|
49
48
|
self.operator_name = operator_name.strip()
|
|
50
49
|
self.annotator_name = annotator_name.strip() if annotator_name is not None else self.operator_name
|
|
51
50
|
self.port = port
|
|
52
51
|
self.wait_for_annotation = wait_for_annotation
|
|
53
52
|
self.open_browser = open_browser
|
|
54
53
|
self._proc: subprocess.Popen | None = None
|
|
54
|
+
self._poll_failures = 0
|
|
55
55
|
self._active_recorder = EpisodeRecorder(
|
|
56
56
|
robot_profile=self.robot_profile,
|
|
57
57
|
data_root_dir=self.data_root_dir,
|
|
@@ -83,10 +83,12 @@ class WebRolloutAnnotator:
|
|
|
83
83
|
self._proc = subprocess.Popen(
|
|
84
84
|
[
|
|
85
85
|
# Not "python": inside a venv that may not exist, or may be a different
|
|
86
|
-
# interpreter than the one running this rollout.
|
|
86
|
+
# interpreter than the one running this rollout. Goes through the CLI so
|
|
87
|
+
# there is one place that parses these flags.
|
|
87
88
|
sys.executable,
|
|
88
89
|
"-m",
|
|
89
|
-
"oopsie_data_tools.
|
|
90
|
+
"oopsie_data_tools.cli",
|
|
91
|
+
"annotate",
|
|
90
92
|
"--samples-dir",
|
|
91
93
|
str(self.data_root_dir),
|
|
92
94
|
"--port",
|
|
@@ -140,37 +142,42 @@ class WebRolloutAnnotator:
|
|
|
140
142
|
# Task coordination
|
|
141
143
|
# ------------------------------------------------------------------
|
|
142
144
|
|
|
143
|
-
def
|
|
144
|
-
"""
|
|
145
|
+
def _poll(self, path: str) -> Any:
|
|
146
|
+
"""One GET of *path*, or None if the server could not be reached.
|
|
145
147
|
|
|
146
148
|
Polling errors are expected and transient (the server may still be starting), so
|
|
147
|
-
they are logged rather than raised — but
|
|
148
|
-
|
|
149
|
+
they are logged rather than raised — but not swallowed silently: a server that has
|
|
150
|
+
died would otherwise leave the callers spinning forever with no output at all.
|
|
149
151
|
"""
|
|
150
|
-
|
|
152
|
+
try:
|
|
153
|
+
result = self._api_get(path)
|
|
154
|
+
self._poll_failures = 0
|
|
155
|
+
return result
|
|
156
|
+
except Exception as e:
|
|
157
|
+
self._poll_failures += 1
|
|
158
|
+
# ~5s of failures, then every ~30s, so a dead server is visible without
|
|
159
|
+
# flooding the console during a normal wait.
|
|
160
|
+
if self._poll_failures == 10 or self._poll_failures % 60 == 0:
|
|
161
|
+
logger.warning(
|
|
162
|
+
"Cannot reach the annotation server at http://localhost:%s (%s). "
|
|
163
|
+
"Still waiting; is it still running?",
|
|
164
|
+
self.port,
|
|
165
|
+
e,
|
|
166
|
+
)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def wait_for_task(self) -> str:
|
|
170
|
+
"""Block until the browser submits a language instruction, then claim it."""
|
|
151
171
|
while True:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
)
|
|
160
|
-
|
|
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
|
-
)
|
|
172
|
+
state = self._poll("/api/task/state")
|
|
173
|
+
if (
|
|
174
|
+
isinstance(state, dict)
|
|
175
|
+
and state.get("status") == "pending"
|
|
176
|
+
and state.get("pending_instruction")
|
|
177
|
+
):
|
|
178
|
+
instruction = str(state["pending_instruction"])
|
|
179
|
+
self._api_post("/api/task/start", {"instruction": instruction})
|
|
180
|
+
return instruction
|
|
174
181
|
time.sleep(0.5)
|
|
175
182
|
|
|
176
183
|
# ------------------------------------------------------------------
|
|
@@ -178,21 +185,15 @@ class WebRolloutAnnotator:
|
|
|
178
185
|
# ------------------------------------------------------------------
|
|
179
186
|
|
|
180
187
|
def reset_episode_recorder(self) -> None:
|
|
181
|
-
|
|
182
|
-
self._active_recorder.reset_episode_recorder()
|
|
188
|
+
self._active_recorder.reset_episode_recorder()
|
|
183
189
|
|
|
184
190
|
def record_step(self, observation: dict[str, Any], action: dict[str, Any]) -> None:
|
|
185
|
-
|
|
186
|
-
self._active_recorder.record_step(observation=observation, action=action)
|
|
191
|
+
self._active_recorder.record_step(observation=observation, action=action)
|
|
187
192
|
|
|
188
193
|
@property
|
|
189
194
|
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
|
|
195
|
+
"""Name of the episode being recorded — the stem of its ``.h5`` and MP4 files."""
|
|
196
|
+
return self._active_recorder.save_fname
|
|
196
197
|
|
|
197
198
|
# ------------------------------------------------------------------
|
|
198
199
|
# Post-rollout workflow
|
|
@@ -206,13 +207,9 @@ class WebRolloutAnnotator:
|
|
|
206
207
|
if not isinstance(instruction, str) or not instruction.strip():
|
|
207
208
|
raise ValueError(f"Instruction must be a non-empty string, got {instruction!r}")
|
|
208
209
|
|
|
209
|
-
# Every use below must go through active_recorder
|
|
210
|
-
#
|
|
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.
|
|
210
|
+
# Every use below must go through active_recorder, not self._active_recorder, or
|
|
211
|
+
# passing recorder= would validate and stamp the wrong object.
|
|
213
212
|
active_recorder = recorder or self._active_recorder
|
|
214
|
-
if active_recorder is None:
|
|
215
|
-
raise ValueError("EpisodeRecorder is required (pass recorder=...).")
|
|
216
213
|
sample_id = active_recorder.save_fname
|
|
217
214
|
|
|
218
215
|
data = {
|
|
@@ -292,22 +289,9 @@ class WebRolloutAnnotator:
|
|
|
292
289
|
|
|
293
290
|
def _wait_for_annotation(self, sample_id: str) -> dict[str, Any]:
|
|
294
291
|
while True:
|
|
295
|
-
data = self.
|
|
296
|
-
if
|
|
297
|
-
|
|
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
|
|
292
|
+
data = self._poll(f"/api/annotations/{sample_id}")
|
|
293
|
+
if isinstance(data, dict) and data:
|
|
294
|
+
return {} if data.get("__annotation_skipped__") else data
|
|
311
295
|
time.sleep(0.5)
|
|
312
296
|
|
|
313
297
|
def _video_url_from_abs_path(self, abs_path: Path) -> str:
|