vidlabel 0.6.0__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.
Files changed (35) hide show
  1. vidlabel-0.6.0/.gitignore +43 -0
  2. vidlabel-0.6.0/PKG-INFO +70 -0
  3. vidlabel-0.6.0/README.md +29 -0
  4. vidlabel-0.6.0/pyproject.toml +50 -0
  5. vidlabel-0.6.0/src/vidlabel/__init__.py +13 -0
  6. vidlabel-0.6.0/src/vidlabel/__main__.py +3 -0
  7. vidlabel-0.6.0/src/vidlabel/_arrow.py +40 -0
  8. vidlabel-0.6.0/src/vidlabel/agent.py +337 -0
  9. vidlabel-0.6.0/src/vidlabel/boxes.py +68 -0
  10. vidlabel-0.6.0/src/vidlabel/build.py +153 -0
  11. vidlabel-0.6.0/src/vidlabel/cache.py +184 -0
  12. vidlabel-0.6.0/src/vidlabel/cli.py +528 -0
  13. vidlabel-0.6.0/src/vidlabel/dataset.py +653 -0
  14. vidlabel-0.6.0/src/vidlabel/frames.py +408 -0
  15. vidlabel-0.6.0/src/vidlabel/masks.py +166 -0
  16. vidlabel-0.6.0/src/vidlabel/mcp_server.py +154 -0
  17. vidlabel-0.6.0/src/vidlabel/preview.py +258 -0
  18. vidlabel-0.6.0/src/vidlabel/probe.py +137 -0
  19. vidlabel-0.6.0/src/vidlabel/readplan.py +131 -0
  20. vidlabel-0.6.0/src/vidlabel/samples.py +336 -0
  21. vidlabel-0.6.0/src/vidlabel/torch_data.py +191 -0
  22. vidlabel-0.6.0/src/vidlabel/ui/__init__.py +1 -0
  23. vidlabel-0.6.0/src/vidlabel/ui/app.py +1154 -0
  24. vidlabel-0.6.0/src/vidlabel/ui/draft.py +761 -0
  25. vidlabel-0.6.0/src/vidlabel/ui/jobs.py +125 -0
  26. vidlabel-0.6.0/src/vidlabel/ui/ops.py +162 -0
  27. vidlabel-0.6.0/src/vidlabel/ui/static/app.js +735 -0
  28. vidlabel-0.6.0/src/vidlabel/ui/static/draw.js +355 -0
  29. vidlabel-0.6.0/src/vidlabel/ui/static/fonts/OFL-Bricolage-Grotesque.txt +93 -0
  30. vidlabel-0.6.0/src/vidlabel/ui/static/fonts/bricolage.woff2 +0 -0
  31. vidlabel-0.6.0/src/vidlabel/ui/static/index.html +39 -0
  32. vidlabel-0.6.0/src/vidlabel/ui/static/style.css +147 -0
  33. vidlabel-0.6.0/src/vidlabel/ui/static/timeline.js +296 -0
  34. vidlabel-0.6.0/src/vidlabel/ui/static/viewer.js +425 -0
  35. vidlabel-0.6.0/src/vidlabel/validate.py +402 -0
@@ -0,0 +1,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .eggs/
8
+
9
+ # Virtual environments
10
+ venv/
11
+ .venv/
12
+ env/
13
+
14
+ # Test / tooling caches
15
+ .pytest_cache/
16
+ .coverage
17
+ htmlcov/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ wiki-out/
21
+ # Editors and OS
22
+ .vscode/
23
+ .idea/
24
+ .DS_Store
25
+ Thumbs.db
26
+
27
+ # Local datasets and test data (keep real data out of the repo)
28
+ my_ds/
29
+ preview_out/
30
+ sample_ds/
31
+ *.parquet.tmp
32
+ *.json.tmp
33
+
34
+ # Video files
35
+ *.mp4
36
+ *.mov
37
+ *.mkv
38
+ *.avi
39
+ *.webm
40
+ *.mxf
41
+ # axe-core, installed only to run the accessibility test
42
+ node_modules/
43
+ package-lock.json
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.5
2
+ Name: vidlabel
3
+ Version: 0.6.0
4
+ Summary: Open, directory-based format and Python library for video annotations: time markers, boxes, masks, tracks.
5
+ Project-URL: Homepage, https://github.com/HIManish-G/pyvidlabel
6
+ Project-URL: Documentation, https://github.com/HIManish-G/pyvidlabel/wiki
7
+ Project-URL: Issues, https://github.com/HIManish-G/pyvidlabel/issues
8
+ Project-URL: Changelog, https://github.com/HIManish-G/pyvidlabel/blob/main/docs/changelog.md
9
+ License: MIT
10
+ Keywords: annotation,bounding boxes,computer vision,dataset,labelling,masks,video
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: numpy>=1.24
24
+ Requires-Dist: pyarrow>=14
25
+ Requires-Dist: typer>=0.12
26
+ Requires-Dist: vidlabel-spec==0.6.0
27
+ Requires-Dist: zstandard>=0.22
28
+ Provides-Extra: mcp
29
+ Requires-Dist: mcp>=1.23; extra == 'mcp'
30
+ Requires-Dist: opencv-python-headless>=4.8; extra == 'mcp'
31
+ Provides-Extra: torch
32
+ Requires-Dist: opencv-python-headless>=4.8; extra == 'torch'
33
+ Requires-Dist: torch>=2.0; extra == 'torch'
34
+ Provides-Extra: ui
35
+ Requires-Dist: opencv-python-headless>=4.8; extra == 'ui'
36
+ Requires-Dist: starlette>=0.37; extra == 'ui'
37
+ Requires-Dist: uvicorn>=0.29; extra == 'ui'
38
+ Provides-Extra: video
39
+ Requires-Dist: opencv-python-headless>=4.8; extra == 'video'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # vidlabel
43
+
44
+ An open, directory-based format and Python library for **video annotation data**: time markers and spans, bounding boxes,
45
+ masks, tracks and labels in one annotation table (JSON + Parquet). Video pixels are not stored; datasets point at your videos.
46
+
47
+ **Status: alpha (0.6.0).** The format and API can still change between minor versions; see the
48
+ [changelog](https://github.com/HIManish-G/pyvidlabel/blob/main/docs/changelog.md).
49
+
50
+ ```
51
+ pip install vidlabel # library and command line
52
+ pip install "vidlabel[video]" # + OpenCV: read frames, preview, check-video
53
+ pip install "vidlabel[ui]" # + local web interface: vidlabel ui my_dataset
54
+ pip install "vidlabel[mcp]" # + MCP server for AI agents: vidlabel mcp my_dataset
55
+ pip install "vidlabel[torch]" # + PyTorch datasets
56
+ ```
57
+
58
+ Python 3.10 or newer. `vidlabel-spec` (the format's models and JSON Schema) is installed with it.
59
+
60
+ ```python
61
+ from vidlabel import Dataset, VideoMetadata
62
+
63
+ ds = Dataset.create("my_dataset")
64
+ ds.add_video(VideoMetadata(video_id="clip", frame_count=100, fps={"num": 25, "den": 1}, width=640, height=360))
65
+ ds.add_labels(["cat"])
66
+ ds.add_annotations([{"kind": "box", "label": "cat", "start_frame": 0, "box": (10, 10, 200, 150), "origin": "human"}], "clip")
67
+ print(ds.validate())
68
+ ```
69
+
70
+ Documentation, format specification and guides: <https://github.com/HIManish-G/pyvidlabel> ([wiki](https://github.com/HIManish-G/pyvidlabel/wiki)). MIT licensed.
@@ -0,0 +1,29 @@
1
+ # vidlabel
2
+
3
+ An open, directory-based format and Python library for **video annotation data**: time markers and spans, bounding boxes,
4
+ masks, tracks and labels in one annotation table (JSON + Parquet). Video pixels are not stored; datasets point at your videos.
5
+
6
+ **Status: alpha (0.6.0).** The format and API can still change between minor versions; see the
7
+ [changelog](https://github.com/HIManish-G/pyvidlabel/blob/main/docs/changelog.md).
8
+
9
+ ```
10
+ pip install vidlabel # library and command line
11
+ pip install "vidlabel[video]" # + OpenCV: read frames, preview, check-video
12
+ pip install "vidlabel[ui]" # + local web interface: vidlabel ui my_dataset
13
+ pip install "vidlabel[mcp]" # + MCP server for AI agents: vidlabel mcp my_dataset
14
+ pip install "vidlabel[torch]" # + PyTorch datasets
15
+ ```
16
+
17
+ Python 3.10 or newer. `vidlabel-spec` (the format's models and JSON Schema) is installed with it.
18
+
19
+ ```python
20
+ from vidlabel import Dataset, VideoMetadata
21
+
22
+ ds = Dataset.create("my_dataset")
23
+ ds.add_video(VideoMetadata(video_id="clip", frame_count=100, fps={"num": 25, "den": 1}, width=640, height=360))
24
+ ds.add_labels(["cat"])
25
+ ds.add_annotations([{"kind": "box", "label": "cat", "start_frame": 0, "box": (10, 10, 200, 150), "origin": "human"}], "clip")
26
+ print(ds.validate())
27
+ ```
28
+
29
+ Documentation, format specification and guides: <https://github.com/HIManish-G/pyvidlabel> ([wiki](https://github.com/HIManish-G/pyvidlabel/wiki)). MIT licensed.
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vidlabel"
7
+ version = "0.6.0"
8
+ description = "Open, directory-based format and Python library for video annotations: time markers, boxes, masks, tracks."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ keywords = ["video", "annotation", "labelling", "bounding boxes", "masks", "dataset", "computer vision"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Scientific/Engineering :: Image Recognition",
25
+ ]
26
+ dependencies = [
27
+ "vidlabel-spec==0.6.0",
28
+ "numpy>=1.24",
29
+ "pyarrow>=14",
30
+ "zstandard>=0.22",
31
+ "typer>=0.12",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ video = ["opencv-python-headless>=4.8"]
36
+ ui = ["starlette>=0.37", "uvicorn>=0.29", "opencv-python-headless>=4.8"]
37
+ mcp = ["mcp>=1.23", "opencv-python-headless>=4.8"]
38
+ torch = ["opencv-python-headless>=4.8", "torch>=2.0"]
39
+
40
+ [project.scripts]
41
+ vidlabel = "vidlabel.cli:app"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/vidlabel"]
45
+
46
+ [project.urls]
47
+ Homepage = "https://github.com/HIManish-G/pyvidlabel"
48
+ Documentation = "https://github.com/HIManish-G/pyvidlabel/wiki"
49
+ Issues = "https://github.com/HIManish-G/pyvidlabel/issues"
50
+ Changelog = "https://github.com/HIManish-G/pyvidlabel/blob/main/docs/changelog.md"
@@ -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"]
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
@@ -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
@@ -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)
@@ -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)