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.
@@ -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")