festifun/backend/tests/test_resolve.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

128 lines
5.0 KiB
Python

"""M8 annotation -> 3D resolution tests (integration phase).
The headline acceptance (spec M8): annotating the *same* stage corner from two videos
produces an anchor within 0.2 scene units of the true corner. We build the synthetic fixture
without ffmpeg (``run_ffmpeg=False`` populates the DB + point cloud only), forward-project a
known corner into each camera to get faithful bbox centers, then resolve and check the error.
Also covers: the single-view nearest-point fallback, the near-parallel/gap rejection path,
and the ``update_event`` correction helper.
"""
from __future__ import annotations
import numpy as np
import pytest
from festival4d import config, db, resolve, synthetic
from festival4d.geometry import quat_to_mat
@pytest.fixture(scope="module")
def fixture():
# No ffmpeg: this writes videos/poses/anchors/events into the DB and points.ply to WORK_DIR.
synthetic.build(run_ffmpeg=False)
return synthetic.STAGE_CORNERS
def _project(video, pose, world_pt):
"""Forward-project a world point into a video, returning (px, py, z_cam).
Mirrors the COLMAP pinhole the fixture uses: x_cam = R@X + t; px = fx*x/z + cx (y-down).
"""
q = [pose.qw, pose.qx, pose.qy, pose.qz]
t = np.array([pose.tx, pose.ty, pose.tz])
R = quat_to_mat(q)
x_cam = R @ np.asarray(world_pt, dtype=float) + t
z = x_cam[2]
px = pose.fx * x_cam[0] / z + pose.cx
py = pose.fy * x_cam[1] / z + pose.cy
return px, py, z
def _bbox_center(px, py, W, H, half=0.02):
"""A small normalized bbox centered on the projected pixel (clamped to [0,1])."""
cx, cy = px / W, py / H
return [
max(0.0, cx - half), max(0.0, cy - half),
min(1.0, cx + half), min(1.0, cy + half),
]
def _views_seeing(corner):
"""Videos whose first pose projects `corner` in front of the camera and inside the frame."""
seen = []
for v in db.get_videos():
poses = db.get_poses(v.id)
if not poses:
continue
p0 = poses[0]
px, py, z = _project(v, p0, corner)
if z > 0 and 0 <= px < v.width and 0 <= py < v.height:
seen.append((v, p0, px, py))
return seen
def test_two_view_triangulation_recovers_corner(fixture):
corners = fixture
# Pick a corner visible in at least two cameras.
chosen = None
for label, world in corners.items():
views = _views_seeing(world)
if len(views) >= 2:
chosen = (label, np.asarray(world, dtype=float), views)
break
assert chosen is not None, "no stage corner is visible in >=2 synthetic cameras"
label, world, views = chosen
event = db.add_event(t_global_s=5.0, event_type="artist_moment", source="ai",
description=f"located {label}")
# Annotate the corner from the first two cameras that see it, then resolve the second one
# (which triangulates against the first).
(v_a, pose_a, pxa, pya), (v_b, pose_b, pxb, pyb) = views[0], views[1]
bbox_a = _bbox_center(pxa, pya, v_a.width, v_a.height)
bbox_b = _bbox_center(pxb, pyb, v_b.width, v_b.height)
db.add_annotation(v_a.id, pose_a.t_video_s, *bbox_a, event_id=event.id)
res = resolve.resolve_annotation(v_b.id, pose_b.t_video_s, bbox_b, event_id=event.id)
assert res.method == "triangulated", f"expected triangulation, got {res.method}"
err = float(np.linalg.norm(np.asarray(res.point) - world))
assert err < 0.2, f"triangulated {label} off by {err:.3f} units (>0.2)"
# Sanity: the mutual-approach gap is tight for exact projections.
assert res.gap is not None and res.gap < 0.2
def test_single_view_falls_back_to_point_cloud(fixture):
corners = fixture
world = np.asarray(corners["Stage FL"], dtype=float)
views = _views_seeing(world)
assert views, "Stage FL should be visible in at least one camera"
v, pose, px, py = views[0]
bbox = _bbox_center(px, py, v.width, v.height)
# No event_id => no other view to triangulate => nearest point-cloud point (corner is in
# the cloud) or centroid-depth ray. Either way a point comes back.
res = resolve.resolve_annotation(v.id, pose.t_video_s, bbox, event_id=None)
assert res.method in {"nearest_point", "ray_depth"}
assert res.point is not None and len(res.point) == 3
if res.method == "nearest_point":
# The exact corner is seeded into the cloud, so the nearest hit should be very close.
assert float(np.linalg.norm(np.asarray(res.point) - world)) < resolve.NEAREST_RADIUS
def test_update_event_sets_user_source(fixture):
ev = db.add_event(t_global_s=9.0, event_type="candidate", source="audio_auto",
description="unlabeled")
updated = db.update_event(ev.id, event_type="pyro", source="user")
assert updated.event_type == "pyro"
assert updated.source == "user"
# Unspecified fields are preserved.
assert updated.description == "unlabeled"
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)