Source code for annie.core.models

"""Domain models for Annie.

These are pure data structures with no I/O and no framework dependencies. They
form the vocabulary shared by every other layer: parsers produce them, the
service layer arranges them into a manifest, and the UI renders them.

The geometry uses image-pixel coordinates with the origin at the top-left, ``x``
increasing rightwards and ``y`` increasing downwards — the convention used by the
underlying detection/tracking CSV files.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
    from pathlib import Path

LANDMARK_NAMES: tuple[str, ...] = (
    "left_eye",
    "right_eye",
    "nose",
    "left_mouth",
    "right_mouth",
)
"""The five named facial landmark points carried by every detection row.

Ordered to match the shared 17-column CSV schema.
"""

RowStatus = Literal["linked", "video_only", "annotation_only"]
"""The three states a manifest row can be in. See :mod:`annie.scanning`."""


[docs] @dataclass(slots=True) class BBox: """A single face bounding box with confidence and named landmarks. Attributes: x: Left edge of the box, in pixels. y: Top edge of the box, in pixels. w: Box width, in pixels. h: Box height, in pixels. score: Detector confidence in ``[0, 1]``. landmarks: Mapping of landmark name (see :data:`LANDMARK_NAMES`) to its ``(x, y)`` pixel coordinate. track_id: The owning track's id when the box came from a ``.track`` file, otherwise ``None`` (e.g. a raw ``.vdet`` detection). """ x: int y: int w: int h: int score: float landmarks: dict[str, tuple[int, int]] = field(default_factory=dict) track_id: int | None = None @property def x2(self) -> int: """Right edge of the box (``x + w``), in pixels.""" return self.x + self.w @property def y2(self) -> int: """Bottom edge of the box (``y + h``), in pixels.""" return self.y + self.h @property def area(self) -> int: """Box area in square pixels (clamped to be non-negative).""" return max(self.w, 0) * max(self.h, 0)
[docs] def contains(self, px: int, py: int) -> bool: """Return whether the pixel ``(px, py)`` lies inside this box (inclusive). Args: px: X coordinate of the query point, in pixels. py: Y coordinate of the query point, in pixels. Returns: ``True`` if the point is within the box bounds, ``False`` otherwise. """ return self.x <= px <= self.x2 and self.y <= py <= self.y2
[docs] @dataclass(slots=True) class FrameAnnotation: """All face boxes belonging to a single video frame. A ``.vdet`` frame may carry several boxes (one per detected face); a ``.track`` frame carries exactly one. Attributes: frame_idx: Zero-based frame index within the source video. boxes: The bounding boxes present on this frame. """ frame_idx: int boxes: list[BBox] = field(default_factory=list)
[docs] @dataclass(slots=True) class Event: """A temporal annotation produced by the Annotator tab. Attributes: event_id: Stable identifier, unique within a video's annotation file. name: Human-readable label. start: Start time in seconds. end: End time in seconds. metadata: Optional free-form key/value extras. """ event_id: str name: str start: float end: float metadata: dict[str, str] = field(default_factory=dict)
NO_ACTIVE_TRACK = -1 """Sentinel ``track_id`` meaning "no active / protagonist track"."""
[docs] @dataclass(slots=True) class VideoEntry: """One Browse row: a single video with all of its annotations aggregated. Unlike a per-annotation fan-out, a video is represented once and carries its raw detections (``.vdet``) together with every derived track. This is what the Browse tab renders and what the overview metrics are computed from. Attributes: video_id: The video stem used for matching, labelling, and as the review key. video_path: Path to the source ``.mp4``, or ``None`` when annotation-only. vdet_path: Path to the raw-detection ``.vdet``, or ``None`` if absent. track_paths: Paths to the video's ``__track{N}.csv`` files, ordered by track id. track_ids: The track indices, aligned with ``track_paths``. active_track_id: The resolved protagonist track id, or ``-1`` if none. status: One of :data:`RowStatus`. labels: Per-video label values gathered from CSV label sources, keyed by column name (e.g. ``{"Sentiment": "negative", "Angry": "0.33"}``). Drives the Browse label tags and the label filters. label_origins: Where each label value came from, keyed like ``labels`` — e.g. ``"intake.xlsx › visits · subject = 4021"`` for a table row, or ``"path field {part}"`` for a nested-layout field. Shown on hover in Browse. row_id: The video's **1-based position in the whole scanned dataset**, assigned once by :mod:`annie.dataset.scanning` over the full sorted manifest. It identifies the sample, not its slot in whatever list is on screen: filtering the Browse tab or queueing a subset into the Annotator leaves each row's number untouched, so "I stopped at 3400" still means something after a restart. ``0`` when unassigned (an entry built by hand rather than by a scan). """ video_id: str video_path: Path | None = None vdet_path: Path | None = None track_paths: list[Path] = field(default_factory=list) track_ids: list[int] = field(default_factory=list) active_track_id: int = NO_ACTIVE_TRACK status: RowStatus = "linked" labels: dict[str, str] = field(default_factory=dict) label_origins: dict[str, str] = field(default_factory=dict) row_id: int = 0 @property def has_video(self) -> bool: """Whether a source video file is present.""" return self.video_path is not None @property def has_vdet(self) -> bool: """Whether a raw-detection ``.vdet`` is present.""" return self.vdet_path is not None @property def has_track(self) -> bool: """Whether at least one track file is present.""" return bool(self.track_paths) @property def has_active_track(self) -> bool: """Whether a valid protagonist track is assigned (``active_track_id >= 0``).""" return self.active_track_id >= 0 @property def label(self) -> str: """The on-screen label (the video id).""" return self.video_id @property def key(self) -> str: """Stable per-video identity, used as the SQLite review-status key.""" return self.video_id
[docs] def hit_test( click_xy: tuple[int, int], annotation: FrameAnnotation, *, smallest_wins: bool = True, ) -> int | None: """Return the ``track_id`` of the box under a click, or ``None``. Used by the protagonist correction flow: the click is tested against every box on the frame. When boxes overlap, the smallest enclosing box wins by default (configurable), which makes it possible to select a face nested inside a larger one. Args: click_xy: The ``(x, y)`` click position in image-pixel space. annotation: The boxes drawn on the clicked frame. smallest_wins: If ``True``, ties break to the smallest-area box; if ``False``, to the largest. Returns: The enclosing box's ``track_id``, or ``None`` if the click hit no box (or the hit box has no track id). """ px, py = click_xy hits = [box for box in annotation.boxes if box.contains(px, py)] if not hits: return None chosen = min(hits, key=lambda b: b.area) if smallest_wins else max(hits, key=lambda b: b.area) return chosen.track_id