vidlabel-spec 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_spec-0.6.0/.gitignore +43 -0
- vidlabel_spec-0.6.0/PKG-INFO +37 -0
- vidlabel_spec-0.6.0/README.md +12 -0
- vidlabel_spec-0.6.0/pyproject.toml +35 -0
- vidlabel_spec-0.6.0/src/vidlabel_spec/__init__.py +11 -0
- vidlabel_spec-0.6.0/src/vidlabel_spec/__main__.py +11 -0
- vidlabel_spec-0.6.0/src/vidlabel_spec/_columns.py +84 -0
- vidlabel_spec-0.6.0/src/vidlabel_spec/_models.py +288 -0
- vidlabel_spec-0.6.0/src/vidlabel_spec/_schema.py +25 -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,37 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vidlabel-spec
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Specification (pydantic models + JSON Schema) for the vidlabel video annotation format
|
|
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: pydantic>=2.5
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# vidlabel-spec
|
|
27
|
+
|
|
28
|
+
The specification of the [vidlabel](https://github.com/HIManish-G/pyvidlabel) video annotation format: Pydantic models and the JSON Schema for the files in a
|
|
29
|
+
dataset. Most people want the `vidlabel` package, which depends on this one.
|
|
30
|
+
|
|
31
|
+
**Status: alpha (0.6.0).** Versioned together with `vidlabel`; see the [changelog](https://github.com/HIManish-G/pyvidlabel/blob/main/docs/changelog.md).
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install vidlabel-spec
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
MIT licensed.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# vidlabel-spec
|
|
2
|
+
|
|
3
|
+
The specification of the [vidlabel](https://github.com/HIManish-G/pyvidlabel) video annotation format: Pydantic models and the JSON Schema for the files in a
|
|
4
|
+
dataset. Most people want the `vidlabel` package, which depends on this one.
|
|
5
|
+
|
|
6
|
+
**Status: alpha (0.6.0).** Versioned together with `vidlabel`; see the [changelog](https://github.com/HIManish-G/pyvidlabel/blob/main/docs/changelog.md).
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
pip install vidlabel-spec
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
MIT licensed.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vidlabel-spec"
|
|
7
|
+
version = "0.6.0"
|
|
8
|
+
description = "Specification (pydantic models + JSON Schema) for the vidlabel video annotation format"
|
|
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 = ["pydantic>=2.5"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/vidlabel_spec"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/HIManish-G/pyvidlabel"
|
|
33
|
+
Documentation = "https://github.com/HIManish-G/pyvidlabel/wiki"
|
|
34
|
+
Issues = "https://github.com/HIManish-G/pyvidlabel/issues"
|
|
35
|
+
Changelog = "https://github.com/HIManish-G/pyvidlabel/blob/main/docs/changelog.md"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from ._columns import (ANNOTATION_COLUMNS, COLUMN_NAMES, REQUIRED_COLUMNS, ColumnSpec, Kind, MaskCodec, Origin,
|
|
2
|
+
Status, is_valid_origin)
|
|
3
|
+
from ._models import (SPEC_VERSION, FrameIndex, Label, Manifest, Rational, Region, Release, VideoMetadata, VideoSource, View,
|
|
4
|
+
Vocab)
|
|
5
|
+
from ._schema import json_schema, write_json_schema
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ANNOTATION_COLUMNS", "COLUMN_NAMES", "REQUIRED_COLUMNS", "ColumnSpec", "Kind", "MaskCodec", "Origin", "Status",
|
|
9
|
+
"SPEC_VERSION", "FrameIndex", "Label", "Manifest", "Rational", "Region", "Release", "VideoMetadata", "VideoSource", "View",
|
|
10
|
+
"Vocab", "json_schema", "write_json_schema", "is_valid_origin",
|
|
11
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""`python -m vidlabel_spec [out.json]` writes the JSON Schema."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from ._schema import json_schema, write_json_schema
|
|
5
|
+
|
|
6
|
+
if len(sys.argv) > 1:
|
|
7
|
+
write_json_schema(sys.argv[1])
|
|
8
|
+
else:
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
print(json.dumps(json_schema(), indent=2))
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""The annotation table: one row per annotation, the same columns for every kind of annotation.
|
|
2
|
+
|
|
3
|
+
Time markers, boxes and masks all live in this single table. Who or what made an
|
|
4
|
+
annotation is a column (`origin`, `tool`, ...), never a separate file or folder.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Kind(str, Enum):
|
|
15
|
+
MARKER = "marker" # a point or span in time, no geometry
|
|
16
|
+
BOX = "box" # a bounding box on one frame
|
|
17
|
+
MASK = "mask" # a pixel mask on one frame (also carries its bounding box)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Origin(str, Enum):
|
|
21
|
+
HUMAN = "human"
|
|
22
|
+
MODEL = "model"
|
|
23
|
+
IMPORTED = "imported" # converted from another tool or dataset; name it in `tool`
|
|
24
|
+
OTHER = "other"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_CUSTOM_ORIGIN_RE = re.compile(r"^x-[a-z0-9][a-z0-9._-]{0,62}$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_valid_origin(value) -> bool:
|
|
31
|
+
"""True for the built-in origins, or a custom one: 'x-' + lowercase letters, digits, '.', '_' or '-'."""
|
|
32
|
+
v = value.value if isinstance(value, Enum) else value
|
|
33
|
+
return isinstance(v, str) and (v in {o.value for o in Origin} or bool(_CUSTOM_ORIGIN_RE.match(v)))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Status(str, Enum):
|
|
37
|
+
UNREVIEWED = "unreviewed"
|
|
38
|
+
APPROVED = "approved"
|
|
39
|
+
REJECTED = "rejected"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MaskCodec(str, Enum):
|
|
43
|
+
BITPACK_ZSTD = "bitpack_zstd" # crop to bbox, bit-pack rows, zstd (see docs/SPEC.md)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ColumnSpec(BaseModel):
|
|
47
|
+
model_config = ConfigDict(frozen=True)
|
|
48
|
+
name: str
|
|
49
|
+
dtype: str # one of: int32, int64, float32, string, dict_string, binary
|
|
50
|
+
nullable: bool
|
|
51
|
+
description: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
ANNOTATION_COLUMNS: tuple[ColumnSpec, ...] = (
|
|
55
|
+
ColumnSpec(name="annotation_id", dtype="int64", nullable=False,
|
|
56
|
+
description="Stable unique id. Random 63-bit integer assigned on creation; never reused."),
|
|
57
|
+
ColumnSpec(name="video_id", dtype="dict_string", nullable=False, description="Which video (matches videos/<video_id>/)."),
|
|
58
|
+
ColumnSpec(name="start_frame", dtype="int32", nullable=False,
|
|
59
|
+
description="First frame, 0-indexed in display order. A point in time has start_frame == end_frame."),
|
|
60
|
+
ColumnSpec(name="end_frame", dtype="int32", nullable=False, description="Last frame, inclusive."),
|
|
61
|
+
ColumnSpec(name="kind", dtype="dict_string", nullable=False, description="marker | box | mask"),
|
|
62
|
+
ColumnSpec(name="label", dtype="dict_string", nullable=False, description="A label name from vocab.json."),
|
|
63
|
+
ColumnSpec(name="track_id", dtype="int32", nullable=True,
|
|
64
|
+
description="Ties boxes/masks of one object across frames. One id means one object within a video and view (rows labelled in different views have separate id spaces)."),
|
|
65
|
+
ColumnSpec(name="x0", dtype="float32", nullable=True, description="Box left edge, pixels, source-video coordinates."),
|
|
66
|
+
ColumnSpec(name="y0", dtype="float32", nullable=True, description="Box top edge, pixels (origin top-left)."),
|
|
67
|
+
ColumnSpec(name="x1", dtype="float32", nullable=True, description="Box right edge (exclusive)."),
|
|
68
|
+
ColumnSpec(name="y1", dtype="float32", nullable=True, description="Box bottom edge (exclusive)."),
|
|
69
|
+
ColumnSpec(name="mask", dtype="binary", nullable=True, description="Encoded mask bytes, interpreted by mask_codec."),
|
|
70
|
+
ColumnSpec(name="mask_codec", dtype="dict_string", nullable=True, description="How `mask` is encoded."),
|
|
71
|
+
ColumnSpec(name="view_id", dtype="dict_string", nullable=True,
|
|
72
|
+
description="Optional: the view (crop / focused version) this was labelled in. Geometry is always in source-video coordinates."),
|
|
73
|
+
ColumnSpec(name="origin", dtype="dict_string", nullable=False, description="human | model | imported | other, or a custom value starting with 'x-' (e.g. x-crowd)"),
|
|
74
|
+
ColumnSpec(name="tool", dtype="dict_string", nullable=True, description="Tool or model name that produced it."),
|
|
75
|
+
ColumnSpec(name="tool_version", dtype="dict_string", nullable=True, description="Tool or model version."),
|
|
76
|
+
ColumnSpec(name="author", dtype="dict_string", nullable=True, description="Person responsible, if any."),
|
|
77
|
+
ColumnSpec(name="confidence", dtype="float32", nullable=True, description="0..1, usually only for model output."),
|
|
78
|
+
ColumnSpec(name="status", dtype="dict_string", nullable=False, description="unreviewed | approved | rejected"),
|
|
79
|
+
ColumnSpec(name="extra", dtype="string", nullable=True,
|
|
80
|
+
description="Optional JSON object (as text) for anything custom. Use your own top-level key, e.g. extra.mytool."),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
COLUMN_NAMES: tuple[str, ...] = tuple(c.name for c in ANNOTATION_COLUMNS)
|
|
84
|
+
REQUIRED_COLUMNS: tuple[str, ...] = tuple(c.name for c in ANNOTATION_COLUMNS if not c.nullable)
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Metadata models. Core fields are strict (unknown fields are rejected); anything custom goes in `extra`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from bisect import bisect_left, bisect_right
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
SPEC_VERSION = "0.6.0"
|
|
12
|
+
|
|
13
|
+
_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
14
|
+
_HASH_RE = re.compile(r"^[a-z0-9]+:[0-9a-f]+$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Strict(BaseModel):
|
|
18
|
+
# Same rule as GEFF: custom data must live inside `extra`, so the core stays checkable.
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_id(v: str) -> str:
|
|
23
|
+
if not _ID_RE.fullmatch(v) or v in (".", ".."):
|
|
24
|
+
raise ValueError("ids may only contain letters, digits, '.', '_' and '-', and cannot be '.' or '..'")
|
|
25
|
+
return v
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Rational(_Strict):
|
|
29
|
+
num: int
|
|
30
|
+
den: int = Field(gt=0)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def value(self) -> float:
|
|
34
|
+
return self.num / self.den
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VideoSource(_Strict):
|
|
38
|
+
"""One known location of the video file. Several may be listed; paths move, hashes don't."""
|
|
39
|
+
uri: str
|
|
40
|
+
note: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FrameIndex(_Strict):
|
|
44
|
+
"""Optional per-frame timing of one video, stored in videos/<video_id>/frame_index.json.
|
|
45
|
+
|
|
46
|
+
Read from the file's packet table (no decoding), so it is cheap to make. It lets a reader seek by timestamp, including
|
|
47
|
+
in variable-frame-rate video, check where a seek really landed, and plan reads so each keyframe interval is decoded once.
|
|
48
|
+
|
|
49
|
+
`pts` holds one presentation timestamp per frame, in `timebase` ticks, in display order (so `pts[i]` belongs to frame
|
|
50
|
+
`i`) and strictly increasing. `keyframes` lists the frame numbers where decoding can start without earlier frames.
|
|
51
|
+
"""
|
|
52
|
+
content_hash: str | None = Field(default=None, description="Hash of the file this was read from; a different hash means the index is stale.")
|
|
53
|
+
timebase: Rational
|
|
54
|
+
pts: list[int] = Field(min_length=1)
|
|
55
|
+
keyframes: list[int] = Field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
@field_validator("content_hash")
|
|
58
|
+
@classmethod
|
|
59
|
+
def _v_hash(cls, v: str | None) -> str | None:
|
|
60
|
+
if v is not None and not _HASH_RE.match(v):
|
|
61
|
+
raise ValueError("content_hash must look like '<algo>:<lowercase hex>'")
|
|
62
|
+
return v
|
|
63
|
+
|
|
64
|
+
@model_validator(mode="after")
|
|
65
|
+
def _v_order(self) -> "FrameIndex":
|
|
66
|
+
p, k = self.pts, self.keyframes
|
|
67
|
+
if any(b <= a for a, b in zip(p, p[1:])):
|
|
68
|
+
raise ValueError("pts must be strictly increasing (one timestamp per frame, in display order)")
|
|
69
|
+
if any(b <= a for a, b in zip(k, k[1:])):
|
|
70
|
+
raise ValueError("keyframes must be strictly increasing frame numbers")
|
|
71
|
+
if k and (k[0] < 0 or k[-1] >= len(p)):
|
|
72
|
+
raise ValueError("keyframes must be frame numbers inside the video")
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def frame_count(self) -> int:
|
|
77
|
+
return len(self.pts)
|
|
78
|
+
|
|
79
|
+
def seconds(self, frame: int) -> float:
|
|
80
|
+
"""Time of `frame` in seconds from the first frame."""
|
|
81
|
+
return (self.pts[frame] - self.pts[0]) * self.timebase.num / self.timebase.den
|
|
82
|
+
|
|
83
|
+
def frame_at(self, ticks: float, tolerance: float = 0.3) -> int | None:
|
|
84
|
+
"""The frame whose timestamp is within `tolerance` ticks of `ticks` (absolute, like `pts`), or None."""
|
|
85
|
+
j = bisect_left(self.pts, ticks)
|
|
86
|
+
best = min((c for c in (j - 1, j) if 0 <= c < len(self.pts)), key=lambda c: abs(self.pts[c] - ticks), default=None)
|
|
87
|
+
return best if best is not None and abs(self.pts[best] - ticks) <= tolerance else None
|
|
88
|
+
|
|
89
|
+
def keyframe_before(self, frame: int) -> int:
|
|
90
|
+
"""The last keyframe at or before `frame` (0 if the list is empty): where decoding for `frame` has to start."""
|
|
91
|
+
j = bisect_right(self.keyframes, frame)
|
|
92
|
+
return self.keyframes[j - 1] if j else 0
|
|
93
|
+
|
|
94
|
+
def decode_cost(self, frame: int) -> int:
|
|
95
|
+
"""Frames that must be decoded to reach `frame` from a cold start: its distance from the previous keyframe, plus one."""
|
|
96
|
+
return frame - self.keyframe_before(frame) + 1
|
|
97
|
+
|
|
98
|
+
def interval_stats(self) -> dict[str, float]:
|
|
99
|
+
"""Keyframe spacing: `min`, `median`, `max` interval in frames, and `mean_cost`, the average frames decoded to reach a random frame."""
|
|
100
|
+
k = self.keyframes or [0]
|
|
101
|
+
gaps = sorted([b - a for a, b in zip(k, k[1:])] + [len(self.pts) - k[-1]])
|
|
102
|
+
n = len(self.pts)
|
|
103
|
+
# frames in an interval of length g cost 1..g, so the mean cost is the sum of g*(g+1)/2 over intervals, divided by n
|
|
104
|
+
total = sum(g * (g + 1) // 2 for g in gaps)
|
|
105
|
+
return {"min": gaps[0], "median": gaps[len(gaps) // 2], "max": gaps[-1], "mean_cost": total / n}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class VideoMetadata(_Strict):
|
|
109
|
+
"""Generic facts about one video. Lives in videos/<video_id>/metadata.json."""
|
|
110
|
+
video_id: str
|
|
111
|
+
content_hash: str | None = Field(default=None, description="'<algo>:<hex>', e.g. 'sha256:ab12...'. Lets anyone detect a re-encoded or wrong file.")
|
|
112
|
+
duration_s: float | None = Field(default=None, ge=0)
|
|
113
|
+
frame_count: int = Field(ge=0)
|
|
114
|
+
fps: Rational
|
|
115
|
+
timebase: Rational | None = None
|
|
116
|
+
is_vfr: bool = False
|
|
117
|
+
width: int = Field(gt=0)
|
|
118
|
+
height: int = Field(gt=0)
|
|
119
|
+
rotation: int = Field(default=0, description="Clockwise degrees (0, 90, 180, 270) applied to the stored frame to display it upright. 90 and 270 swap width and height.")
|
|
120
|
+
codec: str | None = None
|
|
121
|
+
pix_fmt: str | None = None
|
|
122
|
+
bit_rate: int | None = Field(default=None, ge=0, description="Bits per second as reported by the container/stream.")
|
|
123
|
+
sources: list[VideoSource] = Field(default_factory=list)
|
|
124
|
+
ingested_at: datetime | None = None
|
|
125
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
126
|
+
|
|
127
|
+
_v_id = field_validator("video_id")(_check_id)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def display_size(self) -> tuple[int, int]:
|
|
131
|
+
"""(width, height) as displayed, i.e. after rotation. All annotation coordinates use this orientation."""
|
|
132
|
+
return (self.height, self.width) if self.rotation in (90, 270) else (self.width, self.height)
|
|
133
|
+
|
|
134
|
+
@field_validator("content_hash")
|
|
135
|
+
@classmethod
|
|
136
|
+
def _v_hash(cls, v: str | None) -> str | None:
|
|
137
|
+
if v is not None and not _HASH_RE.match(v):
|
|
138
|
+
raise ValueError("content_hash must look like '<algo>:<lowercase hex>'")
|
|
139
|
+
return v
|
|
140
|
+
|
|
141
|
+
@field_validator("rotation")
|
|
142
|
+
@classmethod
|
|
143
|
+
def _v_rot(cls, v: int) -> int:
|
|
144
|
+
if v not in (0, 90, 180, 270):
|
|
145
|
+
raise ValueError("rotation must be 0, 90, 180 or 270")
|
|
146
|
+
return v
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Region(_Strict):
|
|
150
|
+
x: int = Field(ge=0)
|
|
151
|
+
y: int = Field(ge=0)
|
|
152
|
+
width: int = Field(gt=0)
|
|
153
|
+
height: int = Field(gt=0)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class View(_Strict):
|
|
157
|
+
"""A named crop and/or time range of a video. No new video is made; annotations stay in source coordinates.
|
|
158
|
+
|
|
159
|
+
`region` is in the video's displayed orientation. If the crop is resized before it reaches a pipeline, give
|
|
160
|
+
`output_size` (width, height) so coordinates can be converted back. `allowed_labels` optionally limits which
|
|
161
|
+
labels may be used in this view (e.g. a caption strip that only allows caption labels).
|
|
162
|
+
"""
|
|
163
|
+
view_id: str
|
|
164
|
+
video_id: str
|
|
165
|
+
name: str | None = None
|
|
166
|
+
region: Region | None = None
|
|
167
|
+
output_size: tuple[int, int] | None = Field(
|
|
168
|
+
default=None, description="(width, height) the region is resized to when shown to a pipeline. Omit if not resized.")
|
|
169
|
+
frame_range: tuple[int, int] | None = Field(default=None, description="Inclusive [first, last] frame.")
|
|
170
|
+
frame_ranges: list[tuple[int, int]] | None = Field(
|
|
171
|
+
default=None, description="Several inclusive [first, last] frame ranges, in increasing order and not overlapping "
|
|
172
|
+
"(e.g. every December-February). Use this or frame_range, not both.")
|
|
173
|
+
allowed_labels: list[str] | None = Field(default=None, description="If set, annotations in this view may only use these labels.")
|
|
174
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
175
|
+
|
|
176
|
+
_v_id = field_validator("view_id", "video_id")(_check_id)
|
|
177
|
+
|
|
178
|
+
@model_validator(mode="after")
|
|
179
|
+
def _v_checks(self) -> "View":
|
|
180
|
+
if self.frame_range is not None:
|
|
181
|
+
if self.frame_range[0] < 0 or self.frame_range[0] > self.frame_range[1]:
|
|
182
|
+
raise ValueError("frame_range must be [first, last] with 0 <= first <= last")
|
|
183
|
+
if self.frame_ranges is not None:
|
|
184
|
+
if self.frame_range is not None:
|
|
185
|
+
raise ValueError("give frame_range or frame_ranges, not both")
|
|
186
|
+
if not self.frame_ranges:
|
|
187
|
+
raise ValueError("frame_ranges must not be empty (omit it to use every frame)")
|
|
188
|
+
prev_end = -1
|
|
189
|
+
for a, b in self.frame_ranges:
|
|
190
|
+
if a < 0 or a > b:
|
|
191
|
+
raise ValueError("each frame_ranges entry must be [first, last] with 0 <= first <= last")
|
|
192
|
+
if a <= prev_end:
|
|
193
|
+
raise ValueError("frame_ranges must be in increasing order and must not overlap")
|
|
194
|
+
prev_end = b
|
|
195
|
+
if self.output_size is not None:
|
|
196
|
+
if self.region is None:
|
|
197
|
+
raise ValueError("output_size needs a region")
|
|
198
|
+
if self.output_size[0] <= 0 or self.output_size[1] <= 0:
|
|
199
|
+
raise ValueError("output_size must be positive")
|
|
200
|
+
return self
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def ranges(self) -> list[tuple[int, int]] | None:
|
|
204
|
+
"""The view's frame ranges as a list (from `frame_range` or `frame_ranges`), or None when every frame is in the view."""
|
|
205
|
+
if self.frame_ranges is not None:
|
|
206
|
+
return [tuple(r) for r in self.frame_ranges]
|
|
207
|
+
return [tuple(self.frame_range)] if self.frame_range is not None else None
|
|
208
|
+
|
|
209
|
+
def contains_frames(self, start: int, end: int) -> bool:
|
|
210
|
+
"""Whether frames start..end (inclusive) all lie inside one of the view's ranges (always True without ranges)."""
|
|
211
|
+
r = self.ranges
|
|
212
|
+
return True if r is None else any(a <= start and end <= b for a, b in r)
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def scale(self) -> tuple[float, float]:
|
|
216
|
+
"""(x, y) factor from source pixels to view pixels (1, 1 if the crop is not resized)."""
|
|
217
|
+
if self.region is not None and self.output_size is not None:
|
|
218
|
+
return (self.output_size[0] / self.region.width, self.output_size[1] / self.region.height)
|
|
219
|
+
return (1.0, 1.0)
|
|
220
|
+
|
|
221
|
+
def to_source_box(self, box: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
|
|
222
|
+
"""Box in this view's pixels -> box in full source-video pixels."""
|
|
223
|
+
x0, y0, x1, y1 = (float(v) for v in box)
|
|
224
|
+
if self.region is None:
|
|
225
|
+
return (x0, y0, x1, y1)
|
|
226
|
+
sx, sy = self.scale
|
|
227
|
+
r = self.region
|
|
228
|
+
return (r.x + x0 / sx, r.y + y0 / sy, r.x + x1 / sx, r.y + y1 / sy)
|
|
229
|
+
|
|
230
|
+
def to_view_box(self, box: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
|
|
231
|
+
"""Box in full source-video pixels -> box in this view's pixels."""
|
|
232
|
+
x0, y0, x1, y1 = (float(v) for v in box)
|
|
233
|
+
if self.region is None:
|
|
234
|
+
return (x0, y0, x1, y1)
|
|
235
|
+
sx, sy = self.scale
|
|
236
|
+
r = self.region
|
|
237
|
+
return ((x0 - r.x) * sx, (y0 - r.y) * sy, (x1 - r.x) * sx, (y1 - r.y) * sy)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Label(_Strict):
|
|
241
|
+
name: str
|
|
242
|
+
parent: str | None = None
|
|
243
|
+
description: str | None = None
|
|
244
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class Vocab(_Strict):
|
|
248
|
+
"""The one shared list of labels."""
|
|
249
|
+
version: str = "0"
|
|
250
|
+
labels: list[Label] = Field(default_factory=list)
|
|
251
|
+
|
|
252
|
+
@model_validator(mode="after")
|
|
253
|
+
def _v_labels(self) -> "Vocab":
|
|
254
|
+
names = [l.name for l in self.labels]
|
|
255
|
+
if len(set(names)) != len(names):
|
|
256
|
+
raise ValueError("label names must be unique")
|
|
257
|
+
missing = {l.parent for l in self.labels if l.parent is not None} - set(names)
|
|
258
|
+
if missing:
|
|
259
|
+
raise ValueError(f"unknown parent label(s): {sorted(missing)}")
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class Manifest(_Strict):
|
|
264
|
+
"""Dataset-level index. Lives in manifest.json."""
|
|
265
|
+
spec_version: str = SPEC_VERSION
|
|
266
|
+
videos: list[str] = Field(default_factory=list)
|
|
267
|
+
views: list[str] = Field(default_factory=list)
|
|
268
|
+
vocab_version: str | None = None
|
|
269
|
+
extra: dict[str, Any] = Field(default_factory=dict)
|
|
270
|
+
|
|
271
|
+
@field_validator("videos", "views")
|
|
272
|
+
@classmethod
|
|
273
|
+
def _ids_are_safe(cls, v: list[str]) -> list[str]:
|
|
274
|
+
for item in v:
|
|
275
|
+
_check_id(item)
|
|
276
|
+
return v
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Release(_Strict):
|
|
280
|
+
"""Describes one compiled (training) build. release_id is derived from content, so a training run can pin it."""
|
|
281
|
+
release_id: str
|
|
282
|
+
spec_version: str
|
|
283
|
+
created_at: datetime
|
|
284
|
+
n_rows: int
|
|
285
|
+
n_videos: int
|
|
286
|
+
row_group_size: int
|
|
287
|
+
files: dict[str, str] = Field(description="file name -> 'sha256:<hex>'")
|
|
288
|
+
extra: dict[str, Any] = Field(default_factory=dict, description="Build filters etc.")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ._columns import ANNOTATION_COLUMNS
|
|
7
|
+
from ._models import SPEC_VERSION, FrameIndex, Manifest, Release, VideoMetadata, View, Vocab
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def json_schema() -> dict:
|
|
11
|
+
"""JSON Schema for every metadata file plus the annotation columns, for implementers in other languages."""
|
|
12
|
+
return {
|
|
13
|
+
"spec_version": SPEC_VERSION,
|
|
14
|
+
"manifest": Manifest.model_json_schema(),
|
|
15
|
+
"video_metadata": VideoMetadata.model_json_schema(),
|
|
16
|
+
"frame_index": FrameIndex.model_json_schema(),
|
|
17
|
+
"view": View.model_json_schema(),
|
|
18
|
+
"vocab": Vocab.model_json_schema(),
|
|
19
|
+
"release": Release.model_json_schema(),
|
|
20
|
+
"annotation_columns": [c.model_dump() for c in ANNOTATION_COLUMNS],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_json_schema(path: str | Path) -> None:
|
|
25
|
+
Path(path).write_text(json.dumps(json_schema(), indent=2) + "\n", encoding="utf-8")
|