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 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
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
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)