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>
256 lines
8.5 KiB
Python
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,
|
|
}
|