vidlabel 0.6.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.
- vidlabel/__init__.py +13 -0
- vidlabel/__main__.py +3 -0
- vidlabel/_arrow.py +40 -0
- vidlabel/agent.py +337 -0
- vidlabel/boxes.py +68 -0
- vidlabel/build.py +153 -0
- vidlabel/cache.py +184 -0
- vidlabel/cli.py +528 -0
- vidlabel/dataset.py +653 -0
- vidlabel/frames.py +408 -0
- vidlabel/masks.py +166 -0
- vidlabel/mcp_server.py +154 -0
- vidlabel/preview.py +258 -0
- vidlabel/probe.py +137 -0
- vidlabel/readplan.py +131 -0
- vidlabel/samples.py +336 -0
- vidlabel/torch_data.py +191 -0
- vidlabel/ui/__init__.py +1 -0
- vidlabel/ui/app.py +1154 -0
- vidlabel/ui/draft.py +761 -0
- vidlabel/ui/jobs.py +125 -0
- vidlabel/ui/ops.py +162 -0
- vidlabel/ui/static/app.js +735 -0
- vidlabel/ui/static/draw.js +355 -0
- vidlabel/ui/static/fonts/OFL-Bricolage-Grotesque.txt +93 -0
- vidlabel/ui/static/fonts/bricolage.woff2 +0 -0
- vidlabel/ui/static/index.html +39 -0
- vidlabel/ui/static/style.css +147 -0
- vidlabel/ui/static/timeline.js +296 -0
- vidlabel/ui/static/viewer.js +425 -0
- vidlabel/validate.py +402 -0
- vidlabel-0.6.0.dist-info/METADATA +70 -0
- vidlabel-0.6.0.dist-info/RECORD +35 -0
- vidlabel-0.6.0.dist-info/WHEEL +4 -0
- vidlabel-0.6.0.dist-info/entry_points.txt +2 -0
vidlabel/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""vidlabel: an open, directory-based format and library for video annotations."""
|
|
2
|
+
from vidlabel_spec import (SPEC_VERSION, FrameIndex, Label, Manifest, Rational, Region, Release, VideoMetadata, VideoSource, View,
|
|
3
|
+
Vocab)
|
|
4
|
+
|
|
5
|
+
from . import masks
|
|
6
|
+
from .build import Compiled, build
|
|
7
|
+
from .dataset import Dataset
|
|
8
|
+
from .samples import FrameSamples
|
|
9
|
+
from .validate import Issue, ValidationError, validate_dataset
|
|
10
|
+
|
|
11
|
+
__version__ = "0.6.0"
|
|
12
|
+
__all__ = ["Dataset", "FrameSamples", "Compiled", "build", "masks", "Issue", "ValidationError", "validate_dataset", "SPEC_VERSION",
|
|
13
|
+
"FrameIndex", "Label", "Manifest", "Rational", "Region", "Release", "VideoMetadata", "VideoSource", "View", "Vocab"]
|
vidlabel/__main__.py
ADDED
vidlabel/_arrow.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
import pyarrow.parquet as pq
|
|
8
|
+
from vidlabel_spec import ANNOTATION_COLUMNS, SPEC_VERSION
|
|
9
|
+
|
|
10
|
+
ROW_GROUP_SIZE = 20_000
|
|
11
|
+
|
|
12
|
+
_TYPES = {
|
|
13
|
+
"int32": pa.int32(), "int64": pa.int64(), "float32": pa.float32(),
|
|
14
|
+
"string": pa.string(), "dict_string": pa.string(), "binary": pa.large_binary(),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
ARROW_SCHEMA = pa.schema(
|
|
18
|
+
[pa.field(c.name, _TYPES[c.dtype], nullable=c.nullable) for c in ANNOTATION_COLUMNS],
|
|
19
|
+
metadata={b"vidlabel.spec_version": SPEC_VERSION.encode()},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_NO_DICT = {"mask"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_parquet(table: pa.Table, path: Path, row_group_size: int = ROW_GROUP_SIZE) -> None:
|
|
26
|
+
"""Atomic write (temp file + rename) with the settings the spec recommends."""
|
|
27
|
+
path = Path(path)
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
30
|
+
pq.write_table(
|
|
31
|
+
table, tmp, compression="zstd", compression_level=3, row_group_size=row_group_size,
|
|
32
|
+
use_dictionary=[n for n in table.column_names if n not in _NO_DICT],
|
|
33
|
+
write_statistics=True, write_page_index=True,
|
|
34
|
+
)
|
|
35
|
+
os.replace(tmp, path)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def empty_table(columns: list[str] | None = None) -> pa.Table:
|
|
39
|
+
t = ARROW_SCHEMA.empty_table()
|
|
40
|
+
return t.select(columns) if columns else t
|
vidlabel/agent.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""What an AI agent may do with a dataset, as plain functions (the MCP server in `vidlabel.mcp_server` is a thin layer over this).
|
|
2
|
+
|
|
3
|
+
The rules are the point:
|
|
4
|
+
|
|
5
|
+
- An agent can **read** (overview, annotations, tracks, a frame picture with the annotations drawn on it, validation).
|
|
6
|
+
- An agent can **propose**: add boxes, spans and polygon masks. They go into the agent's own *draft* (the same draft a person's
|
|
7
|
+
web-interface session uses, under the author `agent:<name>`), always as `origin="model"`, `status="unreviewed"`, with an optional
|
|
8
|
+
confidence and a short note. Every row is checked by the library before it is recorded, exactly like a row drawn by hand.
|
|
9
|
+
- An agent **cannot** approve or reject rows, change or delete rows that are already in the dataset, change `origin` or `author`, or
|
|
10
|
+
commit. Nothing reaches the dataset until a person takes the proposals into their own draft (web interface, *Proposals*) and commits.
|
|
11
|
+
- A batch is all or nothing: if one row is refused, none of the batch is recorded.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Sequence
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from .dataset import Dataset
|
|
21
|
+
from .ui import draft as D
|
|
22
|
+
|
|
23
|
+
MAX_ROWS_PER_CALL = 500
|
|
24
|
+
MAX_PENDING_ROWS = 20000 # one agent cannot pile up more than this in its draft
|
|
25
|
+
MAX_LIST = 500
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AgentError(ValueError):
|
|
29
|
+
"""A mistake the agent can read and fix; the message says what was wrong."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def agent_author(name: str | None) -> str:
|
|
33
|
+
"""`agent:<name>`. The name is reduced to lower-case letters, digits, `-` and `_`; if anything had to be changed, a short hash of the
|
|
34
|
+
original is added, so two different names never share a draft (also on case-insensitive disks)."""
|
|
35
|
+
import hashlib
|
|
36
|
+
raw = (name or "").strip() or "agent"
|
|
37
|
+
kept = "".join(c for c in raw.lower() if c in "abcdefghijklmnopqrstuvwxyz0123456789-_")[:32]
|
|
38
|
+
if kept != raw:
|
|
39
|
+
kept = (kept + "-" if kept else "") + hashlib.sha1(raw.encode("utf-8")).hexdigest()[:6]
|
|
40
|
+
return "agent:" + kept
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _id_out(i: Any) -> Any:
|
|
44
|
+
return str(i) if isinstance(i, int) else i
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _row_out(r: dict) -> dict:
|
|
48
|
+
o = {k: v for k, v in r.items() if k not in ("mask", "mask_codec", "mask_b64") and v is not None}
|
|
49
|
+
o["annotation_id"] = _id_out(r["annotation_id"])
|
|
50
|
+
o["has_mask"] = r.get("mask") is not None
|
|
51
|
+
if "_pending" in o:
|
|
52
|
+
o["pending"] = o.pop("_pending")
|
|
53
|
+
return {k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in o.items()}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _at(i):
|
|
57
|
+
return "" if i is None else f"item {i}: "
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _int(v: Any, what: str, i: int | None, lo: int = 0, hi: int = 2**53) -> int:
|
|
61
|
+
"""A whole number (`3` or `3.0`; not true/false, text, 1.5 or something huge)."""
|
|
62
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or (isinstance(v, float) and not v.is_integer()):
|
|
63
|
+
raise AgentError(f"{_at(i)}{what} must be a whole number, got {v!r}")
|
|
64
|
+
v = int(v)
|
|
65
|
+
if not lo <= v <= hi:
|
|
66
|
+
raise AgentError(f"{_at(i)}{what} {v} is out of range ({lo} to {hi})")
|
|
67
|
+
return v
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _number(v: Any, what: str, i: int | None) -> float:
|
|
71
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or not np.isfinite(v):
|
|
72
|
+
raise AgentError(f"{_at(i)}{what} must be a finite number, got {v!r}")
|
|
73
|
+
return float(v)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _label(v: Any, i: int) -> str:
|
|
77
|
+
if not isinstance(v, str) or not v.strip():
|
|
78
|
+
raise AgentError(f"item {i}: label must be a text, got {v!r}. Use one of the labels from `overview`")
|
|
79
|
+
return v
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AgentSession:
|
|
83
|
+
def __init__(self, path: str | Path, name: str | None = None):
|
|
84
|
+
self.ds = Dataset.open(path)
|
|
85
|
+
self.author = agent_author(name)
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------ reading
|
|
88
|
+
def overview(self) -> dict:
|
|
89
|
+
ds = self.ds
|
|
90
|
+
d = D.Draft.load(ds, self.author)
|
|
91
|
+
return {"path": str(ds.path), "spec_version": ds.manifest.spec_version, "stats": ds.stats(),
|
|
92
|
+
"labels": [l.model_dump(exclude_none=True) for l in ds.vocab.labels],
|
|
93
|
+
"videos": [{"video_id": v, "frames": ds.video(v).frame_count, "width": ds.video(v).display_size[0],
|
|
94
|
+
"height": ds.video(v).display_size[1], "fps": _fps(ds.video(v))} for v in ds.video_ids()],
|
|
95
|
+
"views": ds.view_ids(), "your_author_name": self.author, "your_pending_changes": d.summary(),
|
|
96
|
+
"rules": "You can read and propose. Proposals are added as origin=model, status=unreviewed, into your own draft; "
|
|
97
|
+
"a person reviews and commits them. You cannot approve, reject, delete existing rows or commit."}
|
|
98
|
+
|
|
99
|
+
def list_annotations(self, video_id: str, *, kind: str | None = None, label: str | None = None, status: str | None = None,
|
|
100
|
+
origin: str | None = None, track_id: int | None = None, frame_start: int | None = None,
|
|
101
|
+
frame_end: int | None = None, view_id: str | None = None, limit: int = 100, offset: int = 0) -> dict:
|
|
102
|
+
self._video(video_id)
|
|
103
|
+
if label is not None and label not in {l.name for l in self.ds.vocab.labels}:
|
|
104
|
+
raise AgentError(f"no label '{label}'. Labels: {[l.name for l in self.ds.vocab.labels]}")
|
|
105
|
+
if view_id is not None and view_id not in self.ds.view_ids():
|
|
106
|
+
raise AgentError(f"no view '{view_id}'. Views: {self.ds.view_ids()}")
|
|
107
|
+
if track_id is not None:
|
|
108
|
+
track_id = _int(track_id, "track_id", None)
|
|
109
|
+
limit, offset = _int(limit, "limit", None, -10**9, 10**9), _int(offset, "offset", None, -10**9, 10**12)
|
|
110
|
+
filters: dict[str, Any] = {"view_id": [view_id]}
|
|
111
|
+
for k, v in (("kind", kind), ("label", label), ("status", status), ("origin", origin), ("track_id", track_id)):
|
|
112
|
+
if v is not None:
|
|
113
|
+
filters[k] = v
|
|
114
|
+
if frame_start is not None or frame_end is not None:
|
|
115
|
+
lo = 0 if frame_start is None else _int(frame_start, "frame_start", None, -10**9, 2**53)
|
|
116
|
+
hi = self.ds.video(video_id).frame_count if frame_end is None else _int(frame_end, "frame_end", None, -10**9, 2**53)
|
|
117
|
+
filters["frames"] = (lo, hi)
|
|
118
|
+
try:
|
|
119
|
+
rows = D.Draft.load(self.ds, self.author).rows(video_id, include_deleted=False, **filters)
|
|
120
|
+
except (D.DraftError, ValueError) as e:
|
|
121
|
+
raise AgentError(str(e)) from None
|
|
122
|
+
limit, offset = max(1, min(limit, MAX_LIST)), max(0, offset)
|
|
123
|
+
part = rows[offset:offset + limit]
|
|
124
|
+
return {"video_id": video_id, "total": len(rows), "offset": offset, "returned": len(part), "rows": [_row_out(r) for r in part]}
|
|
125
|
+
|
|
126
|
+
def list_tracks(self, video_id: str, view_id: str | None = None) -> dict:
|
|
127
|
+
self._video(video_id)
|
|
128
|
+
if view_id is not None and view_id not in self.ds.view_ids():
|
|
129
|
+
raise AgentError(f"no view '{view_id}'. Views: {self.ds.view_ids()}")
|
|
130
|
+
return {"video_id": video_id, "tracks": self.ds.tracks(video_id, view_id=view_id)}
|
|
131
|
+
|
|
132
|
+
def validate(self, deep: bool = False) -> dict:
|
|
133
|
+
from .ui import ops
|
|
134
|
+
return ops.validate(self.ds, deep=bool(deep))
|
|
135
|
+
|
|
136
|
+
def frame_image(self, video_id: str, frame: int, *, annotations: bool = True, max_side: int = 768) -> bytes:
|
|
137
|
+
"""A JPEG of one frame, with the annotations on it (including your own pending proposals) drawn on top."""
|
|
138
|
+
import cv2
|
|
139
|
+
from .frames import FrameReader
|
|
140
|
+
from .preview import draw_annotations
|
|
141
|
+
meta = self._video(video_id)
|
|
142
|
+
frame = _int(frame, "frame", None, -10**9, 2**53)
|
|
143
|
+
max_side = _int(max_side, "max_side", None, -10**9, 10**9)
|
|
144
|
+
if not 0 <= frame < meta.frame_count:
|
|
145
|
+
raise AgentError(f"video '{video_id}' has frames 0 to {meta.frame_count - 1}; there is no frame {frame}")
|
|
146
|
+
path = self.ds.resolve_video_path(video_id, [])
|
|
147
|
+
if path is None or not Path(path).exists():
|
|
148
|
+
raise AgentError(f"the video file for '{video_id}' was not found on this computer, so no picture can be made")
|
|
149
|
+
reader = FrameReader(path, meta, None, index=self.ds.frame_index(video_id))
|
|
150
|
+
try:
|
|
151
|
+
rgb = reader.read(frame)
|
|
152
|
+
finally:
|
|
153
|
+
reader.close()
|
|
154
|
+
if annotations:
|
|
155
|
+
rows = D.Draft.load(self.ds, self.author).rows(video_id, include_deleted=False, frames=(frame, frame), view_id=[None])
|
|
156
|
+
rgb = draw_annotations(rgb, rows, frame)
|
|
157
|
+
max_side = max(16, min(max_side, 2048))
|
|
158
|
+
if max(rgb.shape[:2]) > max_side:
|
|
159
|
+
s = max_side / max(rgb.shape[:2])
|
|
160
|
+
rgb = cv2.resize(rgb, (round(rgb.shape[1] * s), round(rgb.shape[0] * s)), interpolation=cv2.INTER_AREA)
|
|
161
|
+
ok, buf = cv2.imencode(".jpg", cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 88])
|
|
162
|
+
if not ok:
|
|
163
|
+
raise AgentError("could not encode the picture")
|
|
164
|
+
return buf.tobytes()
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------ proposing
|
|
167
|
+
def propose_boxes(self, video_id: str, boxes: Sequence[dict], note: str | None = None) -> dict:
|
|
168
|
+
"""Each item: `frame`, `label`, `box` = [x0, y0, x1, y1] in the video's own pixels, optional `track_id`, `confidence` (0 to 1)."""
|
|
169
|
+
W, H = self._video(video_id).display_size
|
|
170
|
+
rows = []
|
|
171
|
+
for i, b in enumerate(self._items(boxes, "boxes")):
|
|
172
|
+
self._need(b, ("frame", "label", "box"), i)
|
|
173
|
+
self._only(b, ("frame", "label", "box", "track_id", "confidence"), i)
|
|
174
|
+
bx = b["box"]
|
|
175
|
+
if not (isinstance(bx, (list, tuple)) and len(bx) == 4):
|
|
176
|
+
raise AgentError(f"item {i}: box must be [x0, y0, x1, y1]")
|
|
177
|
+
bx = [_number(v, "every box value", i) for v in bx]
|
|
178
|
+
if bx[0] < 0 or bx[1] < 0 or bx[2] > W or bx[3] > H: # an agent that guesses coordinates is caught here
|
|
179
|
+
raise AgentError(f"item {i}: box {list(bx)} is outside the frame (0 to {W} wide, 0 to {H} high). Boxes use the video's own pixels")
|
|
180
|
+
r = {"kind": "box", "label": _label(b["label"], i), "start_frame": self._frame(video_id, b["frame"], i), "box": tuple(bx)}
|
|
181
|
+
rows.append(self._with_common(r, b, i))
|
|
182
|
+
return self._record(video_id, rows, note)
|
|
183
|
+
|
|
184
|
+
def propose_spans(self, video_id: str, spans: Sequence[dict], note: str | None = None) -> dict:
|
|
185
|
+
"""Label stretches of frames. Each item: `start_frame`, `end_frame` (inclusive), `label`, optional `confidence`."""
|
|
186
|
+
rows = []
|
|
187
|
+
for i, s in enumerate(self._items(spans, "spans")):
|
|
188
|
+
self._need(s, ("start_frame", "end_frame", "label"), i)
|
|
189
|
+
self._only(s, ("start_frame", "end_frame", "label", "confidence"), i)
|
|
190
|
+
r = {"kind": "marker", "label": _label(s["label"], i), "start_frame": self._frame(video_id, s["start_frame"], i),
|
|
191
|
+
"end_frame": self._frame(video_id, s["end_frame"], i)}
|
|
192
|
+
rows.append(self._with_common(r, s, i))
|
|
193
|
+
return self._record(video_id, rows, note)
|
|
194
|
+
|
|
195
|
+
def propose_masks(self, video_id: str, masks: Sequence[dict], note: str | None = None) -> dict:
|
|
196
|
+
"""Each item: `frame`, `label`, `polygon` = [[x, y], ...] (3 or more corners, the video's own pixels), optional `track_id`, `confidence`."""
|
|
197
|
+
meta = self._video(video_id)
|
|
198
|
+
W, H = meta.display_size
|
|
199
|
+
rows = []
|
|
200
|
+
for i, m in enumerate(self._items(masks, "masks")):
|
|
201
|
+
self._need(m, ("frame", "label", "polygon"), i)
|
|
202
|
+
self._only(m, ("frame", "label", "polygon", "track_id", "confidence"), i)
|
|
203
|
+
pts = self._polygon(m["polygon"], W, H, i)
|
|
204
|
+
r = {"kind": "mask", "label": _label(m["label"], i), "start_frame": self._frame(video_id, m["frame"], i), "mask": _fill_polygon(pts, W, H)}
|
|
205
|
+
rows.append(self._with_common(r, m, i))
|
|
206
|
+
return self._record(video_id, rows, note)
|
|
207
|
+
|
|
208
|
+
def retract(self, ids: Sequence[Any]) -> dict:
|
|
209
|
+
"""Take back proposals of yours that are still only in your draft (their ids look like `p3`). Rows already in the dataset
|
|
210
|
+
are not yours to remove."""
|
|
211
|
+
d = D.Draft.load(self.ds, self.author)
|
|
212
|
+
want = {str(i) for i in ids}
|
|
213
|
+
mine = {o["tmp"]: j for j, o in enumerate(d.ops) if o["op"] == "add"}
|
|
214
|
+
unknown = sorted(want - set(mine))
|
|
215
|
+
if unknown:
|
|
216
|
+
raise AgentError(f"not pending proposals of yours: {unknown[:5]}. Only ids like 'p3' from your own draft can be retracted")
|
|
217
|
+
removed = 0
|
|
218
|
+
for j in sorted((mine[t] for t in want), reverse=True):
|
|
219
|
+
removed += d.discard(j)
|
|
220
|
+
return {"retracted": removed, "pending": d.summary()}
|
|
221
|
+
|
|
222
|
+
def draft_status(self) -> dict:
|
|
223
|
+
d = D.Draft.load(self.ds, self.author)
|
|
224
|
+
st = d.state()
|
|
225
|
+
return {"author": self.author, "summary": st["summary"], "can_undo": st["can_undo"],
|
|
226
|
+
"edits": [{"video_id": o["video_id"], "what": o["label"], **({"note": o["note"]} if "note" in o else {})}
|
|
227
|
+
for o in st["ops"] if not o["undone"]][:200],
|
|
228
|
+
"next_step": "Ask a person to open Proposals in the vidlabel web interface (`vidlabel ui PATH`), take these into their "
|
|
229
|
+
"draft, review them and commit. You cannot commit."}
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------ internals
|
|
232
|
+
def _video(self, video_id: str):
|
|
233
|
+
try:
|
|
234
|
+
return self.ds.video(video_id)
|
|
235
|
+
except KeyError:
|
|
236
|
+
raise AgentError(f"no video '{video_id}'. Videos: {self.ds.video_ids()[:20]}") from None
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _items(items: Any, what: str) -> list:
|
|
240
|
+
if not isinstance(items, (list, tuple)) or not items:
|
|
241
|
+
raise AgentError(f"`{what}` must be a non-empty list")
|
|
242
|
+
if len(items) > MAX_ROWS_PER_CALL:
|
|
243
|
+
raise AgentError(f"at most {MAX_ROWS_PER_CALL} {what} per call; send the rest in another call")
|
|
244
|
+
if not all(isinstance(x, dict) for x in items):
|
|
245
|
+
raise AgentError(f"every item of `{what}` must be an object")
|
|
246
|
+
return list(items)
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _need(item: dict, keys: Sequence[str], i: int) -> None:
|
|
250
|
+
missing = [k for k in keys if item.get(k) is None]
|
|
251
|
+
if missing:
|
|
252
|
+
raise AgentError(f"item {i}: missing {missing}")
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _only(item: dict, allowed: Sequence[str], i: int) -> None:
|
|
256
|
+
extra = sorted(set(item) - set(allowed))
|
|
257
|
+
if extra:
|
|
258
|
+
raise AgentError(f"item {i}: not allowed here: {extra}. Allowed: {list(allowed)} (origin, status and author are set for you)")
|
|
259
|
+
|
|
260
|
+
def _frame(self, video_id: str, v: Any, i: int) -> int:
|
|
261
|
+
n = self.ds.video(video_id).frame_count
|
|
262
|
+
f = _int(v, "frame", i, 0, 2**53)
|
|
263
|
+
if f >= n:
|
|
264
|
+
raise AgentError(f"item {i}: frame {f} does not exist; video '{video_id}' has frames 0 to {n - 1}")
|
|
265
|
+
return f
|
|
266
|
+
|
|
267
|
+
@staticmethod
|
|
268
|
+
def _with_common(r: dict, src: dict, i: int) -> dict:
|
|
269
|
+
if src.get("track_id") is not None:
|
|
270
|
+
r["track_id"] = _int(src["track_id"], "track_id", i, 0, 2**53)
|
|
271
|
+
if src.get("confidence") is not None:
|
|
272
|
+
c = _number(src["confidence"], "confidence", i)
|
|
273
|
+
if not 0 <= c <= 1:
|
|
274
|
+
raise AgentError(f"item {i}: confidence must be between 0 and 1, got {c}")
|
|
275
|
+
r["confidence"] = c
|
|
276
|
+
r["origin"] = "model" # fixed: an agent's rows are never "human"
|
|
277
|
+
r["status"] = "unreviewed" # fixed: only a person approves or rejects
|
|
278
|
+
return r
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _polygon(poly: Any, W: int, H: int, i: int) -> np.ndarray:
|
|
282
|
+
try:
|
|
283
|
+
pts = np.asarray(poly, dtype=float)
|
|
284
|
+
except (TypeError, ValueError):
|
|
285
|
+
raise AgentError(f"item {i}: polygon must be a list of [x, y] pairs") from None
|
|
286
|
+
if pts.ndim != 2 or pts.shape[1] != 2 or len(pts) < 3 or not np.isfinite(pts).all():
|
|
287
|
+
raise AgentError(f"item {i}: polygon needs at least 3 corners, each [x, y]")
|
|
288
|
+
if (pts[:, 0] < 0).any() or (pts[:, 1] < 0).any() or (pts[:, 0] > W).any() or (pts[:, 1] > H).any():
|
|
289
|
+
raise AgentError(f"item {i}: polygon corners must lie inside the frame (0 to {W} wide, 0 to {H} high)")
|
|
290
|
+
x, y = pts[:, 0], pts[:, 1]
|
|
291
|
+
if abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) / 2 < 1:
|
|
292
|
+
raise AgentError(f"item {i}: the polygon has no area (corners on one line or point, or the outline crosses itself and cancels out)")
|
|
293
|
+
return pts
|
|
294
|
+
|
|
295
|
+
def _record(self, video_id: str, rows: list[dict], note: str | None) -> dict:
|
|
296
|
+
"""All or nothing: put every row in the draft, or none of them."""
|
|
297
|
+
self._video(video_id)
|
|
298
|
+
if note is not None and not isinstance(note, str):
|
|
299
|
+
raise AgentError("note must be text")
|
|
300
|
+
note = (note.strip()[:300] or None) if note else None
|
|
301
|
+
d = D.Draft.load(self.ds, self.author)
|
|
302
|
+
pending = d.summary()["added"]
|
|
303
|
+
if pending + len(rows) > MAX_PENDING_ROWS:
|
|
304
|
+
raise AgentError(f"your draft already holds {pending} proposals; the limit is {MAX_PENDING_ROWS}. A person needs to take them in, or retract some")
|
|
305
|
+
snap = (list(d.ops), d.cursor, dict(d.base), d.next_tmp)
|
|
306
|
+
ids = []
|
|
307
|
+
try:
|
|
308
|
+
for i, r in enumerate(rows):
|
|
309
|
+
try:
|
|
310
|
+
ids.append(d.add(video_id, r, note=note))
|
|
311
|
+
except (D.DraftError, ValueError, TypeError) as e:
|
|
312
|
+
raise AgentError(f"item {i}: {e}. Nothing from this call was recorded.") from None
|
|
313
|
+
except AgentError:
|
|
314
|
+
d.ops, d.cursor, d.base, d.next_tmp = snap
|
|
315
|
+
d.save()
|
|
316
|
+
raise
|
|
317
|
+
return {"recorded": len(ids), "ids": ids, "pending": d.summary(),
|
|
318
|
+
"next_step": "These are in your draft only. A person reviews them under Proposals in the web interface."}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _fps(meta) -> float | None:
|
|
322
|
+
f = getattr(meta, "fps", None)
|
|
323
|
+
if f is None:
|
|
324
|
+
return None
|
|
325
|
+
try:
|
|
326
|
+
return round(f.num / f.den, 4) if f.den else None
|
|
327
|
+
except AttributeError:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _fill_polygon(pts: np.ndarray, W: int, H: int) -> np.ndarray:
|
|
332
|
+
import cv2
|
|
333
|
+
m = np.zeros((H, W), np.uint8)
|
|
334
|
+
cv2.fillPoly(m, [np.round(pts).astype(np.int32)], 1)
|
|
335
|
+
if not m.any():
|
|
336
|
+
raise AgentError("the polygon covers no pixels (is it too small, or a line?)")
|
|
337
|
+
return m.astype(bool)
|
vidlabel/boxes.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Convert boxes between the common layouts. Stored boxes are `xyxy` in pixels (x1/y1 exclusive); training code often
|
|
2
|
+
wants something else, so conversion happens when reading, never in storage.
|
|
3
|
+
|
|
4
|
+
xyxy x0, y0, x1, y1 corners (Pascal VOC, torchvision)
|
|
5
|
+
xywh x0, y0, width, height top-left + size (COCO)
|
|
6
|
+
cxcywh cx, cy, width, height centre + size (YOLO)
|
|
7
|
+
|
|
8
|
+
`normalized=True` means values are fractions of the image size (YOLO files use cxcywh normalized).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
FORMATS = ("xyxy", "xywh", "cxcywh")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def convert_boxes(boxes, src: str = "xyxy", dst: str = "xyxy", *, size: tuple[int, int] | None = None,
|
|
18
|
+
src_normalized: bool = False, dst_normalized: bool = False) -> np.ndarray:
|
|
19
|
+
"""Convert an (N, 4) array of boxes. `size` is (width, height) of the image they refer to; it is required when
|
|
20
|
+
`src_normalized` or `dst_normalized` is set. Returns float32."""
|
|
21
|
+
for f in (src, dst):
|
|
22
|
+
if f not in FORMATS:
|
|
23
|
+
raise ValueError(f"unknown box format '{f}'; use one of {FORMATS}")
|
|
24
|
+
b = np.asarray(boxes, dtype=np.float64).reshape(-1, 4)
|
|
25
|
+
if (src_normalized or dst_normalized) and size is None:
|
|
26
|
+
raise ValueError("size=(width, height) is required to convert normalized boxes")
|
|
27
|
+
scale = np.array([size[0], size[1], size[0], size[1]], dtype=np.float64) if size else None
|
|
28
|
+
if src_normalized:
|
|
29
|
+
b = b * scale
|
|
30
|
+
a, c, d, e = b.T
|
|
31
|
+
if src == "xywh":
|
|
32
|
+
x0, y0, x1, y1 = a, c, a + d, c + e
|
|
33
|
+
elif src == "cxcywh":
|
|
34
|
+
x0, y0, x1, y1 = a - d / 2, c - e / 2, a + d / 2, c + e / 2
|
|
35
|
+
else:
|
|
36
|
+
x0, y0, x1, y1 = a, c, d, e
|
|
37
|
+
if dst == "xywh":
|
|
38
|
+
out = np.stack([x0, y0, x1 - x0, y1 - y0], axis=1)
|
|
39
|
+
elif dst == "cxcywh":
|
|
40
|
+
out = np.stack([(x0 + x1) / 2, (y0 + y1) / 2, x1 - x0, y1 - y0], axis=1)
|
|
41
|
+
else:
|
|
42
|
+
out = np.stack([x0, y0, x1, y1], axis=1)
|
|
43
|
+
if dst_normalized:
|
|
44
|
+
out = out / scale
|
|
45
|
+
return out.astype(np.float32)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def rotate_boxes(boxes, rotation: int, stored_size: tuple[int, int]) -> np.ndarray:
|
|
49
|
+
"""Move (N, 4) `xyxy` boxes from the stored frame to the displayed orientation of a rotated video.
|
|
50
|
+
|
|
51
|
+
`rotation` is the video's clockwise rotation (0, 90, 180 or 270, as in VideoMetadata.rotation) and `stored_size` is
|
|
52
|
+
(width, height) of the stored frame. Annotations in a vidlabel dataset use the displayed orientation, so a source that
|
|
53
|
+
drew boxes on the unrotated stored frame needs this before `add_annotations`. x1/y1 stay exclusive.
|
|
54
|
+
"""
|
|
55
|
+
if rotation not in (0, 90, 180, 270):
|
|
56
|
+
raise ValueError("rotation must be 0, 90, 180 or 270")
|
|
57
|
+
W, H = stored_size
|
|
58
|
+
b = np.asarray(boxes, dtype=np.float64).reshape(-1, 4)
|
|
59
|
+
x0, y0, x1, y1 = b.T
|
|
60
|
+
if rotation == 90:
|
|
61
|
+
out = np.stack([H - y1, x0, H - y0, x1], axis=1)
|
|
62
|
+
elif rotation == 180:
|
|
63
|
+
out = np.stack([W - x1, H - y1, W - x0, H - y0], axis=1)
|
|
64
|
+
elif rotation == 270:
|
|
65
|
+
out = np.stack([y0, W - x1, y1, W - x0], axis=1)
|
|
66
|
+
else:
|
|
67
|
+
out = b
|
|
68
|
+
return out.astype(np.float32)
|
vidlabel/build.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Compile per-video sources into one sorted, immutable training table, pinned by a content-derived release id."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
import pyarrow as pa
|
|
12
|
+
import pyarrow.compute as pc
|
|
13
|
+
import pyarrow.parquet as pq
|
|
14
|
+
from vidlabel_spec import SPEC_VERSION, Release, Vocab
|
|
15
|
+
|
|
16
|
+
from ._arrow import ARROW_SCHEMA, ROW_GROUP_SIZE, write_parquet
|
|
17
|
+
from .dataset import Dataset
|
|
18
|
+
from .validate import ValidationError, errors
|
|
19
|
+
|
|
20
|
+
SORT_KEYS = [("video_id", "ascending"), ("start_frame", "ascending"), ("annotation_id", "ascending")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sha256(path: Path) -> str:
|
|
24
|
+
h = hashlib.sha256()
|
|
25
|
+
with open(path, "rb") as f:
|
|
26
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
27
|
+
h.update(chunk)
|
|
28
|
+
return h.hexdigest()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build(ds: Dataset, *, statuses: Sequence[str] = ("unreviewed", "approved"), origins: Sequence[str] | None = None,
|
|
32
|
+
kinds: Sequence[str] | None = None, video_ids: Sequence[str] | None = None,
|
|
33
|
+
view_ids: Sequence[str | None] | None = None, row_group_size: int = ROW_GROUP_SIZE, check: bool = True) -> tuple[Release, Path]:
|
|
34
|
+
"""Write compiled/<release_id>/{annotations.parquet, vocab.json, release.json}. Rejected annotations are excluded by default.
|
|
35
|
+
|
|
36
|
+
vocab.json is a snapshot of the label vocabulary, so class indices derived from a pinned release cannot change when
|
|
37
|
+
labels are added to the dataset later.
|
|
38
|
+
|
|
39
|
+
The release id is the hash of the file's bytes: building an unchanged dataset again gives the same id, so a training run can
|
|
40
|
+
record it and later read exactly the same table. (Annotation ids are random, so re-creating the data gives a new id; share the folder.)
|
|
41
|
+
"""
|
|
42
|
+
if check:
|
|
43
|
+
bad = errors(ds.validate())
|
|
44
|
+
if bad:
|
|
45
|
+
raise ValidationError(bad)
|
|
46
|
+
ids = sorted(video_ids) if video_ids else sorted(ds.manifest.videos)
|
|
47
|
+
parts = []
|
|
48
|
+
for vid in ids:
|
|
49
|
+
t = ds.annotations(vid, kind=kinds, origin=origins, status=list(statuses), view_id=view_ids)
|
|
50
|
+
if t.num_rows:
|
|
51
|
+
parts.append(t.sort_by(SORT_KEYS))
|
|
52
|
+
table = pa.concat_tables(parts) if parts else ARROW_SCHEMA.empty_table()
|
|
53
|
+
out_root = ds.path / "compiled"
|
|
54
|
+
out_root.mkdir(exist_ok=True)
|
|
55
|
+
tmp = out_root / "_building.parquet"
|
|
56
|
+
write_parquet(table, tmp, row_group_size=row_group_size)
|
|
57
|
+
digest = _sha256(tmp)
|
|
58
|
+
release_id = "r" + digest[:12]
|
|
59
|
+
final = out_root / release_id
|
|
60
|
+
if final.exists():
|
|
61
|
+
tmp.unlink()
|
|
62
|
+
else:
|
|
63
|
+
final.mkdir()
|
|
64
|
+
shutil.move(str(tmp), final / "annotations.parquet")
|
|
65
|
+
if not (final / "vocab.json").exists():
|
|
66
|
+
(final / "vocab.json").write_text(ds.vocab.model_dump_json(indent=2), encoding="utf-8")
|
|
67
|
+
vocab_digest = _sha256(final / "vocab.json")
|
|
68
|
+
release = Release(
|
|
69
|
+
release_id=release_id, spec_version=SPEC_VERSION, created_at=datetime.now(timezone.utc),
|
|
70
|
+
n_rows=table.num_rows, n_videos=len(parts), row_group_size=row_group_size,
|
|
71
|
+
files={"annotations.parquet": f"sha256:{digest}", "vocab.json": f"sha256:{vocab_digest}"},
|
|
72
|
+
extra={"statuses": list(statuses), "origins": list(origins) if origins else None,
|
|
73
|
+
"kinds": list(kinds) if kinds else None, "video_ids": ids,
|
|
74
|
+
"view_ids": list(view_ids) if view_ids is not None else None},
|
|
75
|
+
)
|
|
76
|
+
if not (final / "release.json").exists():
|
|
77
|
+
(final / "release.json").write_text(release.model_dump_json(indent=2), encoding="utf-8")
|
|
78
|
+
else:
|
|
79
|
+
release = Release.model_validate_json((final / "release.json").read_text(encoding="utf-8"))
|
|
80
|
+
if "rejected" not in statuses: # a release with rejected rows is for inspection: it must not become what "latest" means
|
|
81
|
+
(out_root / "LATEST").write_text(release_id + "\n", encoding="utf-8")
|
|
82
|
+
return release, final
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Compiled:
|
|
86
|
+
"""Reader for a compiled release. Fast clip lookups use Parquet row-group statistics (the table is sorted)."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, path: str | Path):
|
|
89
|
+
p = Path(path)
|
|
90
|
+
if p.is_dir() and (p / "LATEST").exists(): # a compiled/ directory: use the newest release
|
|
91
|
+
p = p / (p / "LATEST").read_text(encoding="utf-8").strip()
|
|
92
|
+
self.path = p
|
|
93
|
+
self.release = Release.model_validate_json((p / "release.json").read_text(encoding="utf-8"))
|
|
94
|
+
self._pf = pq.ParquetFile(p / "annotations.parquet")
|
|
95
|
+
self._schema = self._pf.schema_arrow
|
|
96
|
+
md = self._pf.metadata
|
|
97
|
+
names = [md.schema.column(i).name for i in range(md.num_columns)]
|
|
98
|
+
ci = {n: names.index(n) for n in ("video_id", "start_frame", "end_frame")}
|
|
99
|
+
self._rg = []
|
|
100
|
+
for g in range(md.num_row_groups):
|
|
101
|
+
rg = md.row_group(g)
|
|
102
|
+
st = {n: rg.column(i).statistics for n, i in ci.items()}
|
|
103
|
+
self._rg.append((st["video_id"].min, st["video_id"].max, st["start_frame"].min, st["end_frame"].max)
|
|
104
|
+
if all(s is not None and s.has_min_max for s in st.values()) else None)
|
|
105
|
+
self.last_row_groups_read: list[int] = []
|
|
106
|
+
|
|
107
|
+
def __len__(self) -> int:
|
|
108
|
+
return self.release.n_rows
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def release_id(self) -> str:
|
|
112
|
+
"""The release id, e.g. `r3fa91c0b2d44`: derived from the table's contents."""
|
|
113
|
+
return self.release.release_id
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def vocab(self) -> Vocab | None:
|
|
117
|
+
"""The label vocabulary as it was when this release was built (None for releases made before it was saved)."""
|
|
118
|
+
p = self.path / "vocab.json"
|
|
119
|
+
return Vocab.model_validate_json(p.read_text(encoding="utf-8")) if p.exists() else None
|
|
120
|
+
|
|
121
|
+
def row_groups_for(self, video_id: str, start: int, end: int) -> list[int]:
|
|
122
|
+
"""Indices of the Parquet row groups that can contain annotations of `video_id` overlapping frames [start, end]."""
|
|
123
|
+
out = []
|
|
124
|
+
for g, s in enumerate(self._rg):
|
|
125
|
+
if s is None or (s[0] <= video_id <= s[1] and s[2] <= end and s[3] >= start):
|
|
126
|
+
out.append(g)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
def clip(self, video_id: str, start: int, end: int, columns: Sequence[str] | None = None,
|
|
130
|
+
view_id: str | None = None) -> pa.Table:
|
|
131
|
+
"""Annotations of one video overlapping frames [start, end] inclusive, reading only the row groups that can match.
|
|
132
|
+
|
|
133
|
+
`view_id` additionally keeps only rows labelled in that view."""
|
|
134
|
+
groups = self.row_groups_for(video_id, start, end)
|
|
135
|
+
self.last_row_groups_read = groups
|
|
136
|
+
cols = list(columns) if columns else None
|
|
137
|
+
read_cols = cols
|
|
138
|
+
if cols is not None: # need the filter columns even if not requested
|
|
139
|
+
read_cols = list(dict.fromkeys([*cols, "video_id", "start_frame", "end_frame", *(["view_id"] if view_id else [])]))
|
|
140
|
+
if not groups:
|
|
141
|
+
t = ARROW_SCHEMA.empty_table()
|
|
142
|
+
return t.select(cols) if cols else t
|
|
143
|
+
t = self._pf.read_row_groups(groups, columns=read_cols)
|
|
144
|
+
keep = pc.and_(pc.equal(t.column("video_id"), video_id),
|
|
145
|
+
pc.and_(pc.less_equal(t.column("start_frame"), end), pc.greater_equal(t.column("end_frame"), start)))
|
|
146
|
+
if view_id is not None:
|
|
147
|
+
keep = pc.and_(keep, pc.fill_null(pc.equal(t.column("view_id"), view_id), False))
|
|
148
|
+
t = t.filter(keep)
|
|
149
|
+
return t.select(cols) if cols else t
|
|
150
|
+
|
|
151
|
+
def read(self, columns: Sequence[str] | None = None) -> pa.Table:
|
|
152
|
+
"""The whole release table (or only `columns`) as a pyarrow Table, sorted by video, start frame and annotation id."""
|
|
153
|
+
return self._pf.read(columns=list(columns) if columns else None)
|