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.
- vidlabel-0.6.0/.gitignore +43 -0
- vidlabel-0.6.0/PKG-INFO +70 -0
- vidlabel-0.6.0/README.md +29 -0
- vidlabel-0.6.0/pyproject.toml +50 -0
- vidlabel-0.6.0/src/vidlabel/__init__.py +13 -0
- vidlabel-0.6.0/src/vidlabel/__main__.py +3 -0
- vidlabel-0.6.0/src/vidlabel/_arrow.py +40 -0
- vidlabel-0.6.0/src/vidlabel/agent.py +337 -0
- vidlabel-0.6.0/src/vidlabel/boxes.py +68 -0
- vidlabel-0.6.0/src/vidlabel/build.py +153 -0
- vidlabel-0.6.0/src/vidlabel/cache.py +184 -0
- vidlabel-0.6.0/src/vidlabel/cli.py +528 -0
- vidlabel-0.6.0/src/vidlabel/dataset.py +653 -0
- vidlabel-0.6.0/src/vidlabel/frames.py +408 -0
- vidlabel-0.6.0/src/vidlabel/masks.py +166 -0
- vidlabel-0.6.0/src/vidlabel/mcp_server.py +154 -0
- vidlabel-0.6.0/src/vidlabel/preview.py +258 -0
- vidlabel-0.6.0/src/vidlabel/probe.py +137 -0
- vidlabel-0.6.0/src/vidlabel/readplan.py +131 -0
- vidlabel-0.6.0/src/vidlabel/samples.py +336 -0
- vidlabel-0.6.0/src/vidlabel/torch_data.py +191 -0
- vidlabel-0.6.0/src/vidlabel/ui/__init__.py +1 -0
- vidlabel-0.6.0/src/vidlabel/ui/app.py +1154 -0
- vidlabel-0.6.0/src/vidlabel/ui/draft.py +761 -0
- vidlabel-0.6.0/src/vidlabel/ui/jobs.py +125 -0
- vidlabel-0.6.0/src/vidlabel/ui/ops.py +162 -0
- vidlabel-0.6.0/src/vidlabel/ui/static/app.js +735 -0
- vidlabel-0.6.0/src/vidlabel/ui/static/draw.js +355 -0
- vidlabel-0.6.0/src/vidlabel/ui/static/fonts/OFL-Bricolage-Grotesque.txt +93 -0
- vidlabel-0.6.0/src/vidlabel/ui/static/fonts/bricolage.woff2 +0 -0
- vidlabel-0.6.0/src/vidlabel/ui/static/index.html +39 -0
- vidlabel-0.6.0/src/vidlabel/ui/static/style.css +147 -0
- vidlabel-0.6.0/src/vidlabel/ui/static/timeline.js +296 -0
- vidlabel-0.6.0/src/vidlabel/ui/static/viewer.js +425 -0
- 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
|
vidlabel-0.6.0/PKG-INFO
ADDED
|
@@ -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.
|
vidlabel-0.6.0/README.md
ADDED
|
@@ -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,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)
|