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>
This commit is contained in:
m3ultra 2026-07-16 12:15:35 +10:00
parent 8dca2f5154
commit 7c13f519d2
9 changed files with 343 additions and 6 deletions

View File

@ -235,14 +235,28 @@ 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)
# 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 {
@ -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}

View File

@ -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))

View File

@ -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

44
docs/ideas.md Normal file
View File

@ -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.

View File

@ -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);

View File

@ -67,6 +67,10 @@ export class Annotator {
<div class="ap-meta dim">${ev.source}${ev.confidence != null ? ` · ${(ev.confidence * 100).toFixed(0)}%` : ""}</div>
<button class="ap-annotate btn">📍 Annotate location</button>
<div class="ap-hint dim"></div>
<div class="ap-anchors">
<div class="ap-anchors-hd dim">anchors <span class="ap-anchors-n"></span></div>
<div class="ap-anchors-list"></div>
</div>
</div>`;
this.panel.querySelector(".ap-x").addEventListener("click", () => this.close());
@ -76,6 +80,45 @@ export class Annotator {
this.panel.querySelector(".ap-annotate").addEventListener("click", () =>
this.annotating ? this.stopAnnotateMode() : this.startAnnotateMode()
);
this._renderAnchorList();
}
/** Render the deletable anchor list in the panel (M8 / Phase 4). */
_renderAnchorList() {
const list = this.panel?.querySelector(".ap-anchors-list");
const n = this.panel?.querySelector(".ap-anchors-n");
if (!list) return;
if (n) n.textContent = `(${state.anchors.length})`;
if (state.anchors.length === 0) {
list.innerHTML = `<div class="dim ap-anchors-empty">none yet</div>`;
return;
}
list.innerHTML = state.anchors
.map(
(a) =>
`<div class="ap-anchor-row" data-id="${a.id}">` +
`<span class="ap-anchor-dot" style="background:${a.color || "#59d499"}"></span>` +
`<span class="ap-anchor-label" title="${a.label || ""}">${a.label || "(unnamed)"}</span>` +
`<button class="ap-anchor-del" title="Delete anchor">✕</button></div>`
)
.join("");
for (const row of list.querySelectorAll(".ap-anchor-row")) {
const id = Number(row.dataset.id);
row.querySelector(".ap-anchor-del").addEventListener("click", () => this._deleteAnchor(id));
}
}
async _deleteAnchor(id) {
try {
const resp = await fetch(`${API_BASE}/api/anchors/${id}`, { method: "DELETE" });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const i = state.anchors.findIndex((a) => a.id === id);
if (i >= 0) state.anchors.splice(i, 1);
emit("anchors-changed", null); // scene3d refresh; overlays read state.anchors each frame
this._renderAnchorList();
} catch (err) {
this._hint(`<span class="bad">delete failed: ${err.message}</span>`);
}
}
_hint(msg) {
@ -241,15 +284,23 @@ export class Annotator {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const res = await resp.json();
if (res.anchor) {
state.anchors.push(res.anchor);
// Upsert: a supersede returns an EXISTING anchor id (moved in place) — replace, don't
// duplicate. A fresh annotation returns a new id — append.
const i = state.anchors.findIndex((a) => a.id === res.anchor.id);
if (i >= 0) state.anchors[i] = res.anchor;
else state.anchors.push(res.anchor);
emit("anchors-changed", res.anchor);
this._renderAnchorList();
const how =
res.method === "triangulated"
? `triangulated (gap ${res.gap != null ? res.gap.toFixed(2) : "?"})`
: res.method === "nearest_point"
? "nearest point-cloud point"
: "ray at scene depth";
this._hint(`anchor placed via <b>${how}</b>. Annotate another view to refine.`);
const note = res.superseded
? "refined the anchor in place."
: "Annotate another view to refine.";
this._hint(`anchor placed via <b>${how}</b>. ${note}`);
} else {
this._hint('<span class="bad">could not resolve a 3D point (no poses?).</span>');
}

View File

@ -56,3 +56,19 @@ Instead append an entry here and work around it locally; the integration agent a
the timebase + pose contracts are byte-for-byte unchanged. Full suite stays green.
- **Decision:** **APPLIED** (integration, 2026-07-16). Additive, backward-compatible, and
required for M8 acceptance. Frontend and backend M8 both depend on it.
### CR-3 — phase4 — 2026-07-16 — Anchor lifecycle: supersede + delete
- **File:** `api.py` (`create_annotation`, new `DELETE /api/anchors/{id}`), `db.py`
(`get_anchor`/`update_anchor`/`delete_anchor`, `get_annotations` event filter)
- **Problem:** Round-3 review found each annotation created a NEW anchor, so the two-view
"refine" flow left two identically-labeled anchors (a rough fallback + the triangulation), and
there was no way to remove an anchor at all.
- **Change (additive — no existing shape altered):** one anchor per event — a later view that
triangulates supersedes the fallback anchor **in place** (same id; `create_annotation` response
gains a `superseded` bool); a later fallback links to the existing anchor without moving it.
New `DELETE /api/anchors/{id}` (unlinks annotations first, 404 on unknown). `db` gains
`get_anchor`/`update_anchor`/`delete_anchor` and an `event_id` filter on `get_annotations`.
- **Workaround in place:** none — applied directly (Phase 4, ownership matrix ended). Every
pre-existing route/shape/column and the timebase + pose contracts are unchanged; the annotation
response only gained a field. Suite 101 → **103** (added supersede + delete tests).
- **Decision:** **APPLIED** (phase4, 2026-07-16). Fixes the sole Round-3 wart; strictly additive.

27
plan/ISSUES.md Normal file
View File

@ -0,0 +1,27 @@
# Field Issues
Problems found when the pipeline meets **real footage** (or real APIs), logged here instead of
being rushed into fixes mid-field-test (per plan/10-integration.md item 4). Coordinator triages;
polish/fixes land in a later phase.
**Format** (newest at the bottom):
```
### ISSUE-<n><area><date><one-line title>
- **Symptom:** what was observed (exact error / measurement)
- **Root cause:** why (if known; else "unknown — needs investigation")
- **Fix / workaround:** what was done, or "OPEN"
- **Evidence:** command output, test name, or measurement proving symptom/fix
- **Status:** OPEN | FIXED | WONTFIX
```
---
<!-- entries below -->
### ISSUE-1 — events (AI classifier) — 2026-07-16 — gemini-2.5-flash 404 for new API keys
- **Symptom:** `events` with a valid `GEMINI_API_KEY` returned `404 NOT_FOUND … models/gemini-2.5-flash is no longer available to new users`; 0/3 candidates classified (degraded to candidates-only).
- **Root cause:** Google retired `gemini-2.5-flash` for newly-provisioned API keys; the model id was hardcoded in `GeminiClassifier`.
- **Fix / workaround:** made the Gemini model env-overridable (`FESTIVAL4D_GEMINI_MODEL`), default `gemini-3.1-flash-lite` (verified to accept inline video + JSON-schema structured output). Coordinator hotfix, commit `8dca2f5`.
- **Evidence:** post-fix run `classified=3 candidates_only=0`; labels `light_show` / `bass_drop` with coherent descriptions; suite 101 passed.
- **Status:** FIXED

41
plan/status/phase4.md Normal file
View File

@ -0,0 +1,41 @@
# Status — phase4 (polish + field test)
## Round 1 — 2026-07-16 — STATUS: polish ready_to_merge; field test AWAITING FOOTAGE
**Directives acknowledged:** round 3 + addendum of plan/DIRECTIVES.md (anchor supersede + delete,
docs/ideas.md, suite ≥101, field-test protocol + ISSUES.md; local `.env` is machine-local).
### Polish pass — DONE
- [x] **Anchor supersede (fixes the Round-3 wart).** One anchor per event: first annotation
creates it (single-view fallback); a later view that triangulates **supersedes it in place**
(same anchor id, response gains `superseded: true`); a later fallback links without moving.
Annotations with no event stay independent.
- Evidence: reproduced the old "2 anchors for 1 object" scenario → now **1 anchor**, both
annotations link to the same id, superseded position err **0.0000** from ground truth
(`tests/test_resolve.py::test_two_view_annotation_supersedes_to_single_anchor`).
- [x] **`DELETE /api/anchors/{id}`** (additive) — unlinks annotations (NULLs `resolved_anchor_id`)
then deletes; 404 on unknown. `db.get_anchor`/`update_anchor`/`delete_anchor` +
`get_annotations(event_id=)` added.
- Evidence: `tests/test_resolve.py::test_delete_anchor_unlinks_annotations_and_404`.
- [x] **Frontend delete affordance** — the correction panel now has a scrollable **anchors list**
(color dot + label + ✕). Delete → `DELETE` → splice `state.anchors``anchors-changed`
(scene3d refresh; overlays read state each frame). `_submit` upserts by id so a supersede
replaces (not duplicates) the anchor in state. `npm run build` clean (18 modules).
- [x] **`docs/ideas.md`** created (gaussian splatting, MP4 path export, realtime ingest,
multi-modal audio, object tracking, QoL).
- [x] **CR-3** filed + APPLIED in plan/CHANGE_REQUESTS.md (strictly additive).
- [x] **Suite: 101 → 103 passed** (`../.venv/bin/python -m pytest`), frontend build clean.
### Field test — AWAITING FOOTAGE (protocol ready)
Scaffolding in place: this file + `plan/ISSUES.md` (seeded with ISSUE-1, the gemini-2.5-flash
retirement, FIXED). When 24 real overlapping clips land in `data/raw/`, run
`ingest → sync → reconstruct → events → serve` and record here: per-pair sync confidence +
offsets, COLMAP registration rate, and which milestones survived real footage. File failures as
`plan/ISSUES.md` entries, don't rush fixes.
**Blockers / notes for coordinator:**
- Field test cannot start until the human provides real footage (carried item).
- Human focused-browser M4 sync eyeball still open (carried from Round 2/3).
- `.env` (Gemini + OpenRouter creds) is machine-local + gitignored; present on this machine.
**Next:** merge `phase4/polish-field-test``main`; run the field test when footage arrives.