diff --git a/backend/festival4d/api.py b/backend/festival4d/api.py index 316a6f4..9e9d6bf 100644 --- a/backend/festival4d/api.py +++ b/backend/festival4d/api.py @@ -235,15 +235,29 @@ def create_annotation(payload: AnnotationIn) -> dict: ) anchor = None + superseded = False if result.point is not None: + px, py, pz = result.point 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) + + # 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, @@ -251,5 +265,22 @@ def create_annotation(payload: AnnotationIn) -> dict: "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} diff --git a/backend/festival4d/db.py b/backend/festival4d/db.py index ecd299e..fc8fbaf 100644 --- a/backend/festival4d/db.py +++ b/backend/festival4d/db.py @@ -35,6 +35,7 @@ from sqlalchemy import ( create_engine, delete, select, + update, ) from sqlalchemy.engine import Engine from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker @@ -318,6 +319,57 @@ def get_anchors() -> list[Anchor]: 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 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 # --------------------------------------------------------------------------- @@ -419,11 +471,15 @@ def add_annotation( return annotation -def get_annotations(video_id: int | None = None) -> list[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)) diff --git a/backend/tests/test_resolve.py b/backend/tests/test_resolve.py index 3d40933..3df0b6e 100644 --- a/backend/tests/test_resolve.py +++ b/backend/tests/test_resolve.py @@ -125,3 +125,62 @@ def test_update_event_sets_user_source(fixture): def test_missing_video_raises(fixture): with pytest.raises(KeyError): resolve.resolve_annotation(9999, 0.0, [0.4, 0.4, 0.6, 0.6], event_id=None) + + +# --- Phase 4: anchor supersede + delete (API-level, no ffmpeg) -------------------------------- + +def _views_and_bbox(corner): + views = _views_seeing(corner) + out = [] + for (v, p, px, py) in views: + out.append((v, p, _bbox_center(px, py, v.width, v.height))) + return out + + +def test_two_view_annotation_supersedes_to_single_anchor(fixture): + """Two annotations of the same event from two views yield ONE anchor (the fallback is + superseded in place by the triangulation), not a sibling pair (the Round-3 wart).""" + from festival4d.api import AnnotationIn, create_annotation + + corner = np.asarray(fixture["Stage FR"], dtype=float) + vb = _views_and_bbox(corner) + assert len(vb) >= 2, "Stage FR should be visible in >=2 cameras" + event = db.add_event(t_global_s=5.0, event_type="pyro", source="ai", description="p4 pyro") + + n0 = len(db.get_anchors()) + (va, pa, bba), (vb2, pb, bbb) = vb[0], vb[1] + r1 = create_annotation(AnnotationIn(video_id=va.id, t_video_s=pa.t_video_s, bbox=bba, event_id=event.id)) + r2 = create_annotation(AnnotationIn(video_id=vb2.id, t_video_s=pb.t_video_s, bbox=bbb, event_id=event.id)) + + assert r2["method"] == "triangulated" and r2["superseded"] is True + assert r1["anchor_id"] == r2["anchor_id"] # same anchor, updated in place + assert len(db.get_anchors()) - n0 == 1 # ONE anchor, not two + # Both annotations link to that single anchor. + links = {a.resolved_anchor_id for a in db.get_annotations(event_id=event.id)} + assert links == {r2["anchor_id"]} + # Superseded position is the accurate triangulation. + anchor = db.get_anchor(r2["anchor_id"]) + assert float(np.linalg.norm([anchor.x - corner[0], anchor.y - corner[1], anchor.z - corner[2]])) < 0.2 + + +def test_delete_anchor_unlinks_annotations_and_404(fixture): + from fastapi import HTTPException + + from festival4d.api import AnnotationIn, create_annotation, delete_anchor + + corner = np.asarray(fixture["Stage BL"], dtype=float) + vb = _views_and_bbox(corner) + assert vb, "Stage BL should be visible in >=1 camera" + event = db.add_event(t_global_s=12.0, event_type="confetti", source="ai", description="p4 del") + v, p, bb = vb[0] + r = create_annotation(AnnotationIn(video_id=v.id, t_video_s=p.t_video_s, bbox=bb, event_id=event.id)) + aid = r["anchor_id"] + assert aid is not None + + assert delete_anchor(aid) == {"deleted": aid} + assert db.get_anchor(aid) is None + # The annotation that resolved to it is unlinked, not orphaned with a dangling FK. + assert all(a.resolved_anchor_id != aid for a in db.get_annotations(event_id=event.id)) + with pytest.raises(HTTPException) as exc: + delete_anchor(aid) + assert exc.value.status_code == 404 diff --git a/docs/ideas.md b/docs/ideas.md new file mode 100644 index 0000000..c2b1c42 --- /dev/null +++ b/docs/ideas.md @@ -0,0 +1,44 @@ +# Festival 4D — Future Ideas + +Parking lot for post-v0.1.0 extensions (spec §5 future-extensions). Not committed work — a place +to capture direction so the prototype's scope stays honest. Roughly ordered by payoff-to-effort. + +## Neural rendering — Gaussian Splatting +Replace the sparse COLMAP point cloud with a **3D Gaussian Splatting** model for photorealistic +free-roam instead of a dot cloud. COLMAP poses + images already feed splatting trainers +(e.g. `gsplat`, Inria 3DGS) directly, so the reconstruction step's output is reusable. The viewer +would swap `Points` for a splat renderer (a WebGL splat viewer, or bake to a mesh). Biggest visual +upgrade; heaviest compute (needs a GPU training pass per project). + +## Cinematic export — render the camera path to MP4 +M9 already produces keyframed god's-eye paths. Add a headless render: step `t_global` at a fixed +fps, drive the Three.js camera along the path, capture frames (offscreen canvas → `captureStream` +or server-side `puppeteer` + `ffmpeg`), and mux to MP4. Turns the app from a viewer into a +"director's cut" clip exporter. Path JSON export/import is already in place, so this is additive. + +## Real-time / streaming ingest +Instead of pre-recorded files, ingest live phone feeds (WebRTC/RTMP). Requires: rolling audio +sync on a sliding window (GCC-PHAT already windowed for drift — reuse it), incremental SfM or +pose tracking against a pre-built map, and a streaming transport in the frontend. Large effort; +the offline pipeline is the right foundation. + +## Multi-modal audio analysis for moment detection +Today's `events` step finds candidates from RMS + spectral flux, then a VLM classifies video. +Add **audio-native** analysis in parallel: beat/tempo tracking, drop detection from +spectrograms, song-boundary segmentation. Fuse audio-derived and vision-derived labels for +higher-confidence events and beat-aligned auto camera cuts. Cheap; complements the existing +classifier providers. + +## Object / artist tracking +Extend M8 (manual bbox → 3D anchor) with automatic per-frame tracking (e.g. a detector + +tracker), so an anchor follows the lead performer across time instead of being static. Enables +a "follow artist" auto-camera mode in the 3D viewer. + +## Quality-of-life +- **Standalone anchor manager** — the delete UI currently lives in the event correction panel; + a dedicated always-available anchor list would decouple anchor management from events. +- **Persist camera paths server-side** — paths are JSON export/import only; a `paths` table + + endpoints would let them live with the project. +- **Per-project workspaces** — the app assumes one project at a time (SQLite at `data/project.db`). +- **Sync-graph visualization** — show which videos aligned into which connected component when + some clips share no audio. diff --git a/frontend/index.html b/frontend/index.html index c2f70cb..4bc19f1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -179,6 +179,18 @@ .ap-meta { text-transform: capitalize; } .ap-annotate { width: 100%; text-align: center; } .ap-hint { line-height: 1.35; min-height: 1.1em; } + .ap-anchors { border-top: 1px solid var(--border); margin-top: 0.15rem; padding-top: 0.4rem; } + .ap-anchors-hd { font-size: 0.72rem; margin-bottom: 0.25rem; } + .ap-anchors-list { max-height: 132px; overflow-y: auto; display: flex; flex-direction: column; gap: 0.15rem; } + .ap-anchors-empty { font-size: 0.74rem; } + .ap-anchor-row { display: flex; align-items: center; gap: 0.35rem; font-size: 0.74rem; } + .ap-anchor-dot { width: 8px; height: 8px; border-radius: 3px; flex: 0 0 auto; } + .ap-anchor-label { flex: 1 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .ap-anchor-del { + background: none; border: none; color: var(--dim); cursor: pointer; font-size: 0.75rem; + padding: 0 0.15rem; flex: 0 0 auto; + } + .ap-anchor-del:hover { color: var(--bad); } .annot-rect { position: absolute; border: 1.5px solid var(--accent); background: rgba(89,212,153,0.14); diff --git a/frontend/src/annotate.js b/frontend/src/annotate.js index c5fec8f..4c385d7 100644 --- a/frontend/src/annotate.js +++ b/frontend/src/annotate.js @@ -67,6 +67,10 @@ export class Annotator {
+