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:
parent
8dca2f5154
commit
7c13f519d2
@ -235,14 +235,28 @@ def create_annotation(payload: AnnotationIn) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
anchor = None
|
anchor = None
|
||||||
|
superseded = False
|
||||||
if result.point is not None:
|
if result.point is not None:
|
||||||
|
px, py, pz = result.point
|
||||||
label = "annotation"
|
label = "annotation"
|
||||||
color = None
|
|
||||||
if payload.event_id is not None:
|
if payload.event_id is not None:
|
||||||
ev = db.get_event(payload.event_id)
|
ev = db.get_event(payload.event_id)
|
||||||
if ev is not None:
|
if ev is not None:
|
||||||
label = ev.description or ev.event_type or label
|
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)
|
db.set_annotation_anchor(annotation.id, anchor.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -251,5 +265,22 @@ def create_annotation(payload: AnnotationIn) -> dict:
|
|||||||
"point": result.point,
|
"point": result.point,
|
||||||
"method": result.method,
|
"method": result.method,
|
||||||
"gap": result.gap,
|
"gap": result.gap,
|
||||||
|
"superseded": superseded,
|
||||||
"anchor": _anchor_dict(anchor) if anchor else None,
|
"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}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ from sqlalchemy import (
|
|||||||
create_engine,
|
create_engine,
|
||||||
delete,
|
delete,
|
||||||
select,
|
select,
|
||||||
|
update,
|
||||||
)
|
)
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
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)))
|
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
|
# Events
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -419,11 +471,15 @@ def add_annotation(
|
|||||||
return 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:
|
with session_scope() as s:
|
||||||
stmt = select(Annotation).order_by(Annotation.id)
|
stmt = select(Annotation).order_by(Annotation.id)
|
||||||
if video_id is not None:
|
if video_id is not None:
|
||||||
stmt = stmt.where(Annotation.video_id == video_id)
|
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))
|
return list(s.scalars(stmt))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -125,3 +125,62 @@ def test_update_event_sets_user_source(fixture):
|
|||||||
def test_missing_video_raises(fixture):
|
def test_missing_video_raises(fixture):
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
resolve.resolve_annotation(9999, 0.0, [0.4, 0.4, 0.6, 0.6], event_id=None)
|
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
44
docs/ideas.md
Normal 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.
|
||||||
@ -179,6 +179,18 @@
|
|||||||
.ap-meta { text-transform: capitalize; }
|
.ap-meta { text-transform: capitalize; }
|
||||||
.ap-annotate { width: 100%; text-align: center; }
|
.ap-annotate { width: 100%; text-align: center; }
|
||||||
.ap-hint { line-height: 1.35; min-height: 1.1em; }
|
.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 {
|
.annot-rect {
|
||||||
position: absolute; border: 1.5px solid var(--accent); background: rgba(89,212,153,0.14);
|
position: absolute; border: 1.5px solid var(--accent); background: rgba(89,212,153,0.14);
|
||||||
|
|||||||
@ -67,6 +67,10 @@ export class Annotator {
|
|||||||
<div class="ap-meta dim">${ev.source}${ev.confidence != null ? ` · ${(ev.confidence * 100).toFixed(0)}%` : ""}</div>
|
<div class="ap-meta dim">${ev.source}${ev.confidence != null ? ` · ${(ev.confidence * 100).toFixed(0)}%` : ""}</div>
|
||||||
<button class="ap-annotate btn">📍 Annotate location</button>
|
<button class="ap-annotate btn">📍 Annotate location</button>
|
||||||
<div class="ap-hint dim"></div>
|
<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>`;
|
</div>`;
|
||||||
|
|
||||||
this.panel.querySelector(".ap-x").addEventListener("click", () => this.close());
|
this.panel.querySelector(".ap-x").addEventListener("click", () => this.close());
|
||||||
@ -76,6 +80,45 @@ export class Annotator {
|
|||||||
this.panel.querySelector(".ap-annotate").addEventListener("click", () =>
|
this.panel.querySelector(".ap-annotate").addEventListener("click", () =>
|
||||||
this.annotating ? this.stopAnnotateMode() : this.startAnnotateMode()
|
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) {
|
_hint(msg) {
|
||||||
@ -241,15 +284,23 @@ export class Annotator {
|
|||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
const res = await resp.json();
|
const res = await resp.json();
|
||||||
if (res.anchor) {
|
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);
|
emit("anchors-changed", res.anchor);
|
||||||
|
this._renderAnchorList();
|
||||||
const how =
|
const how =
|
||||||
res.method === "triangulated"
|
res.method === "triangulated"
|
||||||
? `triangulated (gap ${res.gap != null ? res.gap.toFixed(2) : "?"})`
|
? `triangulated (gap ${res.gap != null ? res.gap.toFixed(2) : "?"})`
|
||||||
: res.method === "nearest_point"
|
: res.method === "nearest_point"
|
||||||
? "nearest point-cloud point"
|
? "nearest point-cloud point"
|
||||||
: "ray at scene depth";
|
: "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 {
|
} else {
|
||||||
this._hint('<span class="bad">could not resolve a 3D point (no poses?).</span>');
|
this._hint('<span class="bad">could not resolve a 3D point (no poses?).</span>');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.
|
the timebase + pose contracts are byte-for-byte unchanged. Full suite stays green.
|
||||||
- **Decision:** **APPLIED** (integration, 2026-07-16). Additive, backward-compatible, and
|
- **Decision:** **APPLIED** (integration, 2026-07-16). Additive, backward-compatible, and
|
||||||
required for M8 acceptance. Frontend and backend M8 both depend on it.
|
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
27
plan/ISSUES.md
Normal 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
41
plan/status/phase4.md
Normal 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 2–4 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.
|
||||||
Loading…
Reference in New Issue
Block a user