festifun/backend/festival4d/api.py
m3ultra e5d6b2c47f Integration (M8 + M9): annotation→3D, camera paths, README, v0.1.0
M8: POST /api/annotations resolves a bbox to a 3D point (ray → two-view
triangulation / nearest-point / centroid-depth) via lane B's geometry
primitives; correction panel + bbox-drawing UI; resolved anchors render live
in overlays and the 3D scene. CR-2 (additive): annotations.event_id +
PATCH /api/events/{id} + db.update_event.

M9: keyframed god's-eye camera paths (Catmull-Rom position + slerp
orientation, export/import), gizmos, path-mode camera hooks.

Verified: 101 backend tests pass (two-view corner recovery err 0.00000);
frontend build clean; live in-browser M8 triangulation (gap 0.21) + M9 path
(camera on keyframe at t) + M4 sync ~16ms. Cleanup: worktrees removed, debris
stash dropped, pristine demo DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:59:51 +10:00

256 lines
8.5 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
if result.point is not None:
label = "annotation"
color = None
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
anchor = db.add_anchor(label, result.point[0], result.point[1], result.point[2], color)
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,
"anchor": _anchor_dict(anchor) if anchor else None,
}