festifun/backend/festival4d/db.py
type-two 6a703c314d foundation3 WIP checkpoint (steps 2-5,7): DB/API/CLI/config/capsule + tracker stubs
Incomplete: step 1 (fixture markers + track_truth.json), step 6 (frontend wiring),
step 8 (tests) not done; suite not yet run. Salvaged from interrupted prior session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:55:29 +10:00

696 lines
24 KiB
Python

"""SQLAlchemy engine/session and schema (spec §2) plus small CRUD helpers.
FROZEN CONTRACT after foundation: the schema (table/column names and types) and the
helper signatures are what every lane depends on. Lanes write through these helpers only.
Schema (spec §2)::
videos(id, filename, duration_s, fps, width, height, offset_ms, drift_ppm,
sync_confidence, created_at)
camera_poses(id, video_id FK, frame_idx, t_video_s, qw,qx,qy,qz, tx,ty,tz,
fx,fy,cx,cy, registered) # COLMAP world->camera; registered=False => interpolated
anchors(id, label, x,y,z, color)
events(id, t_global_s, duration_s, event_type, confidence, description, source)
annotations(id, video_id FK, t_video_s, x0,y0,x1,y1, resolved_anchor_id FK NULL)
paths(id, name, created_at, path_json) # phase 5 (M16): saved camPath JSON, as text
tracks(id, marker_key, label, color, created_at) # phase 6 (M18/M20): friend tracks
track_points(id, track_id FK, t_global_s, x,y,z, quality, views) # ordered by t_global_s
Engine management: :func:`init_engine` (re)binds the module to a SQLite file. It defaults
to ``config.DB_PATH`` but tests point it at a temp file. Helpers open and commit their own
short-lived sessions and return detached ORM instances (``expire_on_commit=False``), so the
returned objects are safe to read after the session closes.
"""
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Iterator, Sequence
from sqlalchemy import (
Boolean,
Float,
ForeignKey,
Integer,
String,
create_engine,
delete,
select,
update,
)
from sqlalchemy.engine import Engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
from festival4d import config
# ---------------------------------------------------------------------------
# ORM models
# ---------------------------------------------------------------------------
class Base(DeclarativeBase):
pass
class Video(Base):
__tablename__ = "videos"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
filename: Mapped[str] = mapped_column(String, nullable=False, unique=True)
duration_s: Mapped[float] = mapped_column(Float, nullable=False)
fps: Mapped[float] = mapped_column(Float, nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False)
offset_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
drift_ppm: Mapped[float | None] = mapped_column(Float, nullable=True)
sync_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[str] = mapped_column(String, nullable=False)
class CameraPose(Base):
__tablename__ = "camera_poses"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id"), nullable=False, index=True)
frame_idx: Mapped[int] = mapped_column(Integer, nullable=False)
t_video_s: Mapped[float] = mapped_column(Float, nullable=False)
# COLMAP world->camera quaternion [w,x,y,z] + translation
qw: Mapped[float] = mapped_column(Float, nullable=False)
qx: Mapped[float] = mapped_column(Float, nullable=False)
qy: Mapped[float] = mapped_column(Float, nullable=False)
qz: Mapped[float] = mapped_column(Float, nullable=False)
tx: Mapped[float] = mapped_column(Float, nullable=False)
ty: Mapped[float] = mapped_column(Float, nullable=False)
tz: Mapped[float] = mapped_column(Float, nullable=False)
# pinhole intrinsics after undistortion
fx: Mapped[float] = mapped_column(Float, nullable=False)
fy: Mapped[float] = mapped_column(Float, nullable=False)
cx: Mapped[float] = mapped_column(Float, nullable=False)
cy: Mapped[float] = mapped_column(Float, nullable=False)
registered: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
class Anchor(Base):
__tablename__ = "anchors"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
label: Mapped[str] = mapped_column(String, nullable=False)
x: Mapped[float] = mapped_column(Float, nullable=False)
y: Mapped[float] = mapped_column(Float, nullable=False)
z: Mapped[float] = mapped_column(Float, nullable=False)
color: Mapped[str | None] = mapped_column(String, nullable=True)
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
t_global_s: Mapped[float] = mapped_column(Float, nullable=False)
duration_s: Mapped[float | None] = mapped_column(Float, nullable=True)
event_type: Mapped[str] = mapped_column(String, nullable=False)
confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
description: Mapped[str | None] = mapped_column(String, nullable=True)
source: Mapped[str] = mapped_column(String, nullable=False) # 'audio_auto' | 'ai' | 'user'
class Annotation(Base):
__tablename__ = "annotations"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
video_id: Mapped[int] = mapped_column(ForeignKey("videos.id"), nullable=False, index=True)
t_video_s: Mapped[float] = mapped_column(Float, nullable=False)
x0: Mapped[float] = mapped_column(Float, nullable=False)
y0: Mapped[float] = mapped_column(Float, nullable=False)
x1: Mapped[float] = mapped_column(Float, nullable=False)
y1: Mapped[float] = mapped_column(Float, nullable=False)
# Nullable link to the event this annotation locates (CR-2, M8). Two annotations sharing an
# event_id from different videos triangulate to a 3D anchor. Nullable => additive/back-compat.
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id"), nullable=True)
resolved_anchor_id: Mapped[int | None] = mapped_column(
ForeignKey("anchors.id"), nullable=True
)
class SavedPath(Base):
"""A saved keyframed camera path (phase 5 contract #3). ``path_json`` is the frozen
camPath JSON (``{"version": 1, "keyframes": [...]}``) stored verbatim as text —
the DB never interprets it beyond POST-time validation in the API layer."""
__tablename__ = "paths"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[str] = mapped_column(String, nullable=False)
path_json: Mapped[str] = mapped_column(String, nullable=False)
class Track(Base):
"""A friend track (phase 6 contract #2): a wearable marker's identity + metadata.
``marker_key`` is the detector-emitted identity — ``"hue:<opencv_hue>"`` for a color
marker or ``"code:<id>"`` for a blink badge (see ``tracker_detect``). The per-timestep
3D path lives in :class:`TrackPoint`. ``label`` / ``color`` are user-editable.
"""
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
marker_key: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str | None] = mapped_column(String, nullable=True)
color: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[str] = mapped_column(String, nullable=False)
class TrackPoint(Base):
"""One solved position of a track at a master-timeline instant (phase 6 contract #2).
Coordinates are **Three.js scene space**. ``views`` = number of cameras used to solve the
point (1 ⇒ single-view ground-plane fallback, contract #5); ``quality`` is 0..1.
Points are always read ordered by ``t_global_s``.
"""
__tablename__ = "track_points"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"), nullable=False, index=True)
t_global_s: Mapped[float] = mapped_column(Float, nullable=False)
x: Mapped[float] = mapped_column(Float, nullable=False)
y: Mapped[float] = mapped_column(Float, nullable=False)
z: Mapped[float] = mapped_column(Float, nullable=False)
quality: Mapped[float | None] = mapped_column(Float, nullable=True)
views: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ---------------------------------------------------------------------------
# Engine / session management
# ---------------------------------------------------------------------------
_engine: Engine | None = None
_SessionLocal: sessionmaker[Session] | None = None
_bound_path: Path | None = None
def init_engine(db_path: str | Path | None = None) -> Engine:
"""(Re)bind the module to a SQLite database file and return the engine.
Called with no argument uses ``config.DB_PATH``. Idempotent for the same path.
"""
global _engine, _SessionLocal, _bound_path
path = Path(db_path) if db_path is not None else config.DB_PATH
if _engine is not None and _bound_path == path:
return _engine
path.parent.mkdir(parents=True, exist_ok=True)
if _engine is not None:
_engine.dispose()
_engine = create_engine(
f"sqlite:///{path}",
connect_args={"check_same_thread": False},
future=True,
)
_SessionLocal = sessionmaker(bind=_engine, expire_on_commit=False, future=True)
_bound_path = path
return _engine
def engine() -> Engine:
if _engine is None:
init_engine()
assert _engine is not None
return _engine
@contextmanager
def session_scope() -> Iterator[Session]:
"""Transactional session context; commits on success, rolls back on error."""
if _SessionLocal is None:
init_engine()
assert _SessionLocal is not None
session = _SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def init_db() -> None:
"""Create all tables if they do not exist."""
Base.metadata.create_all(engine())
def reset_db() -> None:
"""Drop and recreate all tables (used by the synthetic fixture generator)."""
Base.metadata.drop_all(engine())
Base.metadata.create_all(engine())
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# ---------------------------------------------------------------------------
# Videos
# ---------------------------------------------------------------------------
def add_video(
filename: str,
duration_s: float,
fps: float,
width: int,
height: int,
offset_ms: float | None = None,
drift_ppm: float | None = None,
sync_confidence: float | None = None,
) -> Video:
"""Insert a video row and return it (used by ingest + synthetic)."""
with session_scope() as s:
video = Video(
filename=filename,
duration_s=duration_s,
fps=fps,
width=width,
height=height,
offset_ms=offset_ms,
drift_ppm=drift_ppm,
sync_confidence=sync_confidence,
created_at=_now_iso(),
)
s.add(video)
s.flush()
s.refresh(video)
return video
def get_videos() -> list[Video]:
with session_scope() as s:
return list(s.scalars(select(Video).order_by(Video.id)))
def get_video(video_id: int) -> Video | None:
with session_scope() as s:
return s.get(Video, video_id)
def update_video_sync(
video_id: int,
offset_ms: float | None,
drift_ppm: float | None,
sync_confidence: float | None,
) -> None:
"""Persist the audio-sync solution for a video (lane A / M1)."""
with session_scope() as s:
video = s.get(Video, video_id)
if video is None:
raise KeyError(f"no video with id={video_id}")
video.offset_ms = offset_ms
video.drift_ppm = drift_ppm
video.sync_confidence = sync_confidence
# ---------------------------------------------------------------------------
# Camera poses
# ---------------------------------------------------------------------------
def set_poses(video_id: int, poses: Iterable[dict]) -> int:
"""Replace all poses for a video with ``poses`` (lane B / M2, and synthetic).
Each dict must have keys: frame_idx, t_video_s, qw, qx, qy, qz, tx, ty, tz,
fx, fy, cx, cy, and optionally ``registered`` (default True). Returns the count.
This is atomic: existing poses are only deleted inside the same transaction that
inserts the new ones, so a caller that raises before returning never corrupts the DB.
"""
rows = list(poses)
with session_scope() as s:
s.execute(delete(CameraPose).where(CameraPose.video_id == video_id))
for p in rows:
s.add(
CameraPose(
video_id=video_id,
frame_idx=int(p["frame_idx"]),
t_video_s=float(p["t_video_s"]),
qw=float(p["qw"]), qx=float(p["qx"]), qy=float(p["qy"]), qz=float(p["qz"]),
tx=float(p["tx"]), ty=float(p["ty"]), tz=float(p["tz"]),
fx=float(p["fx"]), fy=float(p["fy"]), cx=float(p["cx"]), cy=float(p["cy"]),
registered=bool(p.get("registered", True)),
)
)
return len(rows)
def get_poses(video_id: int) -> list[CameraPose]:
with session_scope() as s:
return list(
s.scalars(
select(CameraPose)
.where(CameraPose.video_id == video_id)
.order_by(CameraPose.t_video_s)
)
)
def has_poses() -> bool:
with session_scope() as s:
return s.scalar(select(CameraPose.id).limit(1)) is not None
# ---------------------------------------------------------------------------
# Anchors
# ---------------------------------------------------------------------------
def add_anchor(label: str, x: float, y: float, z: float, color: str | None = None) -> Anchor:
with session_scope() as s:
anchor = Anchor(label=label, x=x, y=y, z=z, color=color)
s.add(anchor)
s.flush()
s.refresh(anchor)
return anchor
def get_anchors() -> list[Anchor]:
with session_scope() as s:
return list(s.scalars(select(Anchor).order_by(Anchor.id)))
def get_anchor(anchor_id: int) -> Anchor | None:
with session_scope() as s:
return s.get(Anchor, anchor_id)
def update_anchor(
anchor_id: int,
x: float,
y: float,
z: float,
label: str | None = None,
color: str | None = None,
) -> Anchor:
"""Move an anchor in place (M8 supersede). Position always updates; label/color only if given.
Raises ``KeyError`` if the id is unknown.
"""
with session_scope() as s:
anchor = s.get(Anchor, anchor_id)
if anchor is None:
raise KeyError(f"no anchor with id={anchor_id}")
anchor.x, anchor.y, anchor.z = x, y, z
if label is not None:
anchor.label = label
if color is not None:
anchor.color = color
s.flush()
s.refresh(anchor)
return anchor
def patch_anchor(anchor_id: int, label: str | None = None, color: str | None = None) -> Anchor:
"""Rename / recolor an anchor without moving it (phase 5 contract #4, M13).
Only provided fields change. Raises ``KeyError`` if the id is unknown.
"""
with session_scope() as s:
anchor = s.get(Anchor, anchor_id)
if anchor is None:
raise KeyError(f"no anchor with id={anchor_id}")
if label is not None:
anchor.label = label
if color is not None:
anchor.color = color
s.flush()
s.refresh(anchor)
return anchor
def delete_anchor(anchor_id: int) -> bool:
"""Delete an anchor, first unlinking any annotations that resolved to it.
Returns ``True`` if a row was deleted, ``False`` if the id was unknown. SQLite doesn't
enforce the FK by default, so we NULL ``annotations.resolved_anchor_id`` explicitly to keep
the link consistent.
"""
with session_scope() as s:
anchor = s.get(Anchor, anchor_id)
if anchor is None:
return False
s.execute(
update(Annotation)
.where(Annotation.resolved_anchor_id == anchor_id)
.values(resolved_anchor_id=None)
)
s.delete(anchor)
return True
# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------
def add_event(
t_global_s: float,
event_type: str,
source: str,
duration_s: float | None = None,
confidence: float | None = None,
description: str | None = None,
) -> Event:
with session_scope() as s:
event = Event(
t_global_s=t_global_s,
duration_s=duration_s,
event_type=event_type,
confidence=confidence,
description=description,
source=source,
)
s.add(event)
s.flush()
s.refresh(event)
return event
def get_events() -> list[Event]:
with session_scope() as s:
return list(s.scalars(select(Event).order_by(Event.t_global_s)))
def clear_events(source: str | None = None) -> int:
"""Delete events, optionally only those from a given ``source``. Returns count deleted."""
with session_scope() as s:
stmt = delete(Event)
if source is not None:
stmt = stmt.where(Event.source == source)
result = s.execute(stmt)
return int(result.rowcount or 0)
def get_event(event_id: int) -> Event | None:
with session_scope() as s:
return s.get(Event, event_id)
def update_event(
event_id: int,
event_type: str | None = None,
source: str | None = None,
description: str | None = None,
confidence: float | None = None,
) -> Event:
"""Patch an existing event in place (M8 correction). Only provided fields change.
Returns the updated row; raises ``KeyError`` if the id is unknown.
"""
with session_scope() as s:
event = s.get(Event, event_id)
if event is None:
raise KeyError(f"no event with id={event_id}")
if event_type is not None:
event.event_type = event_type
if source is not None:
event.source = source
if description is not None:
event.description = description
if confidence is not None:
event.confidence = confidence
s.flush()
s.refresh(event)
return event
# ---------------------------------------------------------------------------
# Saved camera paths (phase 5 / M16)
# ---------------------------------------------------------------------------
def add_path(name: str, path_json: str) -> SavedPath:
with session_scope() as s:
path = SavedPath(name=name, path_json=path_json, created_at=_now_iso())
s.add(path)
s.flush()
s.refresh(path)
return path
def get_paths() -> list[SavedPath]:
with session_scope() as s:
return list(s.scalars(select(SavedPath).order_by(SavedPath.id)))
def get_path(path_id: int) -> SavedPath | None:
with session_scope() as s:
return s.get(SavedPath, path_id)
def delete_path(path_id: int) -> bool:
"""Delete a saved path. Returns ``True`` if a row was deleted, ``False`` if unknown."""
with session_scope() as s:
path = s.get(SavedPath, path_id)
if path is None:
return False
s.delete(path)
return True
# ---------------------------------------------------------------------------
# Friend tracks (phase 6 / M18 contract #2). Written by the solver (lane H / M20),
# read by the API + frontend. TrackPoints are always returned ordered by t_global_s.
# ---------------------------------------------------------------------------
def add_track(marker_key: str, label: str | None = None, color: str | None = None) -> Track:
"""Insert a track row (no points yet) and return it."""
with session_scope() as s:
track = Track(marker_key=marker_key, label=label, color=color, created_at=_now_iso())
s.add(track)
s.flush()
s.refresh(track)
return track
def get_tracks() -> list[Track]:
"""All tracks, ordered by id."""
with session_scope() as s:
return list(s.scalars(select(Track).order_by(Track.id)))
def get_track(track_id: int) -> Track | None:
with session_scope() as s:
return s.get(Track, track_id)
def get_track_points(track_id: int) -> list[TrackPoint]:
"""A track's points, ordered by ``t_global_s`` (contract #2)."""
with session_scope() as s:
return list(
s.scalars(
select(TrackPoint)
.where(TrackPoint.track_id == track_id)
.order_by(TrackPoint.t_global_s)
)
)
def set_track_points(track_id: int, points: Iterable[dict]) -> int:
"""Replace all points for a track with ``points`` (solver output, M20). Returns the count.
Each dict needs ``t_global_s, x, y, z`` and optionally ``quality`` (default None) and
``views`` (default None). Atomic: the delete + inserts share one transaction, so a caller
that raises mid-way never leaves a half-written track. Raises ``KeyError`` if the track
is unknown.
"""
rows = list(points)
with session_scope() as s:
if s.get(Track, track_id) is None:
raise KeyError(f"no track with id={track_id}")
s.execute(delete(TrackPoint).where(TrackPoint.track_id == track_id))
for p in rows:
s.add(
TrackPoint(
track_id=track_id,
t_global_s=float(p["t_global_s"]),
x=float(p["x"]), y=float(p["y"]), z=float(p["z"]),
quality=None if p.get("quality") is None else float(p["quality"]),
views=None if p.get("views") is None else int(p["views"]),
)
)
return len(rows)
def patch_track(track_id: int, label: str | None = None, color: str | None = None) -> Track:
"""Rename / recolor a track (M21 panel). Only provided fields change.
Raises ``KeyError`` if the id is unknown.
"""
with session_scope() as s:
track = s.get(Track, track_id)
if track is None:
raise KeyError(f"no track with id={track_id}")
if label is not None:
track.label = label
if color is not None:
track.color = color
s.flush()
s.refresh(track)
return track
def delete_track(track_id: int) -> bool:
"""Delete a track and its points. Returns ``True`` if a row was deleted, else ``False``.
SQLite doesn't cascade by default, so the points are removed explicitly.
"""
with session_scope() as s:
track = s.get(Track, track_id)
if track is None:
return False
s.execute(delete(TrackPoint).where(TrackPoint.track_id == track_id))
s.delete(track)
return True
def has_tracks() -> bool:
"""Whether any track exists (drives ``manifest.has_tracks``)."""
with session_scope() as s:
return s.scalar(select(Track.id).limit(1)) is not None
# ---------------------------------------------------------------------------
# Annotations
# ---------------------------------------------------------------------------
def add_annotation(
video_id: int,
t_video_s: float,
x0: float,
y0: float,
x1: float,
y1: float,
event_id: int | None = None,
resolved_anchor_id: int | None = None,
) -> Annotation:
with session_scope() as s:
annotation = Annotation(
video_id=video_id,
t_video_s=t_video_s,
x0=x0, y0=y0, x1=x1, y1=y1,
event_id=event_id,
resolved_anchor_id=resolved_anchor_id,
)
s.add(annotation)
s.flush()
s.refresh(annotation)
return annotation
def get_annotations(
video_id: int | None = None, event_id: int | None = None
) -> list[Annotation]:
with session_scope() as s:
stmt = select(Annotation).order_by(Annotation.id)
if video_id is not None:
stmt = stmt.where(Annotation.video_id == video_id)
if event_id is not None:
stmt = stmt.where(Annotation.event_id == event_id)
return list(s.scalars(stmt))
def set_annotation_anchor(annotation_id: int, anchor_id: int) -> None:
with session_scope() as s:
annotation = s.get(Annotation, annotation_id)
if annotation is None:
raise KeyError(f"no annotation with id={annotation_id}")
annotation.resolved_anchor_id = anchor_id