festifun/backend/festival4d/api.py
m3ultra 7c13f519d2 Phase 4 polish: anchor supersede + delete, docs/ideas, field-test scaffolding
Fixes the sole Round-3 wart: annotating one object from two views now yields
ONE anchor (the single-view fallback is superseded in place by the
triangulation, same id) instead of a sibling pair. Adds DELETE /api/anchors/{id}
(unlinks annotations, 404 on unknown) + a deletable anchor list in the
correction panel. db gains get/update/delete_anchor + get_annotations(event_id).
CR-3 filed (additive). Suite 101 -> 103; frontend build clean; delete UI
verified live (state + DOM + 3D scene stay consistent).

Also: docs/ideas.md (future extensions), plan/ISSUES.md (seeded with the
gemini-2.5-flash retirement, FIXED), plan/status/phase4.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:15:35 +10:00

287 lines
9.9 KiB
Python

"""FastAPI app (spec M3). FROZEN after foundation — lanes never edit routes or shapes.
Serves the synthetic project fully so lane C can treat this API as finished:
GET /api/manifest -> {videos:[{id,filename,url,duration_s,fps,width,height,
offset_ms,drift_ppm}], t_global_max, has_poses}
GET /api/videos/{id}/poses -> [{frame_idx,t_video_s,q:[w,x,y,z],t:[x,y,z],
intrinsics:{fx,fy,cx,cy},registered}]
GET /api/pointcloud -> points.ply (binary; Range-capable)
GET /api/anchors -> [...] POST /api/anchors
GET /api/events -> [...]
POST /api/events/detect {t_global_s?, window_s?} -> runs M7 detection
POST /api/annotations {video_id, t_video_s, bbox:[x0,y0,x1,y1]} -> {anchor_id?, point?}
Source videos in ``data/raw`` are mounted at ``/media`` via Starlette ``StaticFiles``,
which supports HTTP Range (required for ``<video>`` seeking; verify ``curl -H "Range:
bytes=0-100"`` -> 206). Stubbed lane entrypoints (events detection, M8 annotation
resolution) raise ``NotImplementedError``; we catch it here and degrade to a 200 with a
note so the frozen routes never 500 while a lane is still stubbed.
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from festival4d import config, db
log = logging.getLogger("festival4d.api")
app = FastAPI(title="Festival 4D", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=config.FRONTEND_ORIGINS,
allow_origin_regex=config.FRONTEND_ORIGIN_REGEX,
allow_methods=["*"],
allow_headers=["*"],
)
# Bind the DB and ensure the media directory exists so the static mount is valid even
# before the synthetic fixture has been generated.
db.init_engine()
db.init_db()
config.RAW_DIR.mkdir(parents=True, exist_ok=True)
# HTTP Range-capable static serving of source videos (spec M3, pitfall #3).
app.mount("/media", StaticFiles(directory=config.RAW_DIR), name="media")
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class AnchorIn(BaseModel):
label: str
x: float
y: float
z: float
color: str | None = None
class DetectIn(BaseModel):
t_global_s: float | None = None
window_s: float | None = None
class AnnotationIn(BaseModel):
video_id: int
t_video_s: float
bbox: list[float] = Field(min_length=4, max_length=4) # [x0, y0, x1, y1] normalized
event_id: int | None = None # CR-2: link to the event this annotation locates (M8)
class EventPatch(BaseModel):
event_type: str | None = None
source: str | None = None
description: str | None = None
confidence: float | None = None
# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
def _video_dict(v) -> dict:
return {
"id": v.id,
"filename": v.filename,
"url": f"/media/{v.filename}",
"duration_s": v.duration_s,
"fps": v.fps,
"width": v.width,
"height": v.height,
"offset_ms": v.offset_ms,
"drift_ppm": v.drift_ppm,
}
def _pose_dict(p) -> dict:
return {
"frame_idx": p.frame_idx,
"t_video_s": p.t_video_s,
"q": [p.qw, p.qx, p.qy, p.qz],
"t": [p.tx, p.ty, p.tz],
"intrinsics": {"fx": p.fx, "fy": p.fy, "cx": p.cx, "cy": p.cy},
"registered": p.registered,
}
def _anchor_dict(a) -> dict:
return {"id": a.id, "label": a.label, "x": a.x, "y": a.y, "z": a.z, "color": a.color}
def _event_dict(e) -> dict:
return {
"id": e.id,
"t_global_s": e.t_global_s,
"duration_s": e.duration_s,
"event_type": e.event_type,
"confidence": e.confidence,
"description": e.description,
"source": e.source,
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/api/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/api/manifest")
def manifest() -> dict:
videos = db.get_videos()
t_global_max = 0.0
for v in videos:
offset_s = (v.offset_ms or 0.0) / 1000.0
t_global_max = max(t_global_max, offset_s + v.duration_s)
return {
"videos": [_video_dict(v) for v in videos],
"t_global_max": t_global_max,
"has_poses": db.has_poses(),
}
@app.get("/api/videos/{video_id}/poses")
def video_poses(video_id: int) -> list[dict]:
if db.get_video(video_id) is None:
raise HTTPException(status_code=404, detail=f"no video with id={video_id}")
return [_pose_dict(p) for p in db.get_poses(video_id)]
@app.get("/api/pointcloud")
def pointcloud() -> FileResponse:
if not config.POINTS_PLY.exists():
raise HTTPException(status_code=404, detail="no point cloud (run `synthetic` or `reconstruct`)")
return FileResponse(
config.POINTS_PLY,
media_type="application/octet-stream",
filename="points.ply",
)
@app.get("/api/anchors")
def get_anchors() -> list[dict]:
return [_anchor_dict(a) for a in db.get_anchors()]
@app.post("/api/anchors")
def create_anchor(payload: AnchorIn) -> dict:
anchor = db.add_anchor(payload.label, payload.x, payload.y, payload.z, payload.color)
return _anchor_dict(anchor)
@app.get("/api/events")
def get_events() -> list[dict]:
return [_event_dict(e) for e in db.get_events()]
@app.post("/api/events/detect")
def detect_events(payload: DetectIn) -> dict:
from festival4d import events_ai
try:
result = events_ai.run_events(t_global_s=payload.t_global_s, window_s=payload.window_s)
except NotImplementedError as exc:
log.info("events detection not implemented yet: %s", exc)
return {
"result": None,
"note": "moment detection not implemented yet (lane D / M7)",
"events": [_event_dict(e) for e in db.get_events()],
}
return {"result": result, "events": [_event_dict(e) for e in db.get_events()]}
@app.patch("/api/events/{event_id}")
def patch_event(event_id: int, payload: EventPatch) -> dict:
"""Correct an event in place (M8). A user correction defaults ``source`` to ``'user'``."""
if db.get_event(event_id) is None:
raise HTTPException(status_code=404, detail=f"no event with id={event_id}")
source = payload.source
if source is None and (payload.event_type is not None or payload.description is not None):
source = "user" # any user-driven correction is attributed to the user
event = db.update_event(
event_id,
event_type=payload.event_type,
source=source,
description=payload.description,
confidence=payload.confidence,
)
return _event_dict(event)
@app.post("/api/annotations")
def create_annotation(payload: AnnotationIn) -> dict:
from festival4d import resolve
x0, y0, x1, y1 = payload.bbox
if db.get_video(payload.video_id) is None:
raise HTTPException(status_code=404, detail=f"no video with id={payload.video_id}")
annotation = db.add_annotation(
payload.video_id, payload.t_video_s, x0, y0, x1, y1, event_id=payload.event_id
)
# M8: cast a ray through the bbox center; triangulate against another view of the same
# event, else fall back to the nearest point-cloud point / centroid depth.
result = resolve.resolve_annotation(
payload.video_id, payload.t_video_s, payload.bbox, event_id=payload.event_id
)
anchor = None
superseded = False
if result.point is not None:
px, py, pz = result.point
label = "annotation"
if payload.event_id is not None:
ev = db.get_event(payload.event_id)
if ev is not None:
label = ev.description or ev.event_type or label
# One anchor per event: the first annotation creates it (a single-view fallback); a later
# view that TRIANGULATES supersedes the anchor in place rather than dropping a sibling. A
# later fallback links to the existing anchor without moving it (don't regress a good
# triangulation). Annotations with no event stay independent (each makes its own anchor).
existing_id = _existing_event_anchor_id(payload.event_id) if payload.event_id else None
if existing_id is None:
anchor = db.add_anchor(label, px, py, pz, None)
elif result.method == "triangulated":
anchor = db.update_anchor(existing_id, px, py, pz, label=label)
superseded = True
else:
anchor = db.get_anchor(existing_id)
if anchor is not None:
db.set_annotation_anchor(annotation.id, anchor.id)
return {
"annotation_id": annotation.id,
"anchor_id": anchor.id if anchor else None,
"point": result.point,
"method": result.method,
"gap": result.gap,
"superseded": superseded,
"anchor": _anchor_dict(anchor) if anchor else None,
}
def _existing_event_anchor_id(event_id: int) -> int | None:
"""The anchor resolved from a prior annotation of this event (most recent), or None."""
for ann in reversed(db.get_annotations(event_id=event_id)):
if ann.resolved_anchor_id is not None:
return ann.resolved_anchor_id
return None
@app.delete("/api/anchors/{anchor_id}")
def delete_anchor(anchor_id: int) -> dict:
"""Remove an anchor (M8 / Phase 4). Unlinks any annotations that resolved to it."""
if not db.delete_anchor(anchor_id):
raise HTTPException(status_code=404, detail=f"no anchor with id={anchor_id}")
return {"deleted": anchor_id}