festifun/backend/tests/test_geometry.py
m3ultra cf6a6fb2c8 Foundation (M0 + M3 + frozen contracts): scaffold, synthetic fixtures, full API
What now works:
- M0 scaffold: pyproject (all spec deps), uv/py3.12 env, `python -m festival4d`
  CLI registering synthetic|ingest|sync|reconstruct|events|serve. Vite hello page.
- Synthetic fixture (synthetic.py): 3 shifted-audio videos (offsets 0/+1370/-842 ms),
  camera-arc poses, stage point cloud -> points.ply, seeded events + anchors,
  ground_truth.json. `python -m festival4d synthetic` populates data/ + DB.
- DB schema exactly per spec §2 (db.py) + CRUD helpers all lanes use.
- M3 API (api.py) full against synthetic data: manifest/poses/pointcloud/anchors/
  events/detect/annotations; Range-capable video serving (206 verified); CORS for any
  localhost origin.
- Frozen geometry contract: geometry.colmap_to_threejs (M5 math) + unit test (3 known
  vectors, random round-trip, scipy oracle); mirrored frontend/src/lib/pose.js with
  identical POSE_TEST_VECTORS. Lane-B stubs: slerp_pose, ray_from_pixel, triangulate_rays,
  nearest_point_on_ray.
- Classifier contract (events_ai.py): MomentClassification model + MomentClassifier
  protocol + Gemini/Claude/Local provider stubs.
- Lane-owned modules stubbed with final signatures (ingest, audio_sync, frames, sfm,
  events_ai); cli/api catch NotImplementedError and degrade gracefully.
- plan/CHANGE_REQUESTS.md created; plan/status/foundation.md updated.

Acceptance: pytest 24 passed; serve endpoints verified via curl + browser (video seek,
manifest fetch cross-origin, pose.js self-test, 0 console errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:45:55 +10:00

111 lines
4.1 KiB
Python

"""Tests for geometry.py.
The ``colmap_to_threejs`` tests below are a **FROZEN CONTRACT** (foundation). The embedded
``POSE_TEST_VECTORS`` are duplicated verbatim in ``frontend/src/lib/pose.js`` so both sides
provably agree — if you touch the conversion math, update both files and both vector sets,
via a change request. Lane B owns this file but must not modify these three cases; it adds
tests for slerp_pose / ray_from_pixel / triangulate_rays / nearest_point_on_ray below them.
"""
from __future__ import annotations
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from festival4d.geometry import (
colmap_to_threejs,
mat_to_quat,
quat_to_mat,
)
# Sqrt(2)/2, spelled out so it matches the JS constant character-for-character.
SQRT1_2 = 0.7071067811865476
# --- FROZEN test vectors (must equal POSE_TEST_VECTORS in frontend/src/lib/pose.js) ----
# Each: COLMAP world->camera (q=[w,x,y,z], t) -> Three.js (position, rotation 3x3 row-major).
POSE_TEST_VECTORS = [
{
"name": "identity",
"q": [1.0, 0.0, 0.0, 0.0],
"t": [0.0, 0.0, -10.0],
"position": [0.0, 0.0, 10.0],
"rotation": [[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]],
},
{
"name": "yaw90", # +90 deg about world Y
"q": [SQRT1_2, 0.0, SQRT1_2, 0.0],
"t": [0.0, 0.0, 10.0],
"position": [10.0, 0.0, 0.0],
"rotation": [[0.0, 0.0, 1.0], [0.0, -1.0, 0.0], [1.0, 0.0, 0.0]],
},
{
"name": "lookat", # camera at +Z looking at origin -> Three.js rotation is identity
"q": [0.0, 1.0, 0.0, 0.0],
"t": [0.0, 0.0, 8.0],
"position": [0.0, 0.0, 8.0],
"rotation": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
},
]
@pytest.mark.parametrize("vec", POSE_TEST_VECTORS, ids=lambda v: v["name"])
def test_colmap_to_threejs_known_vectors(vec):
position, rotation = colmap_to_threejs(vec["q"], vec["t"])
np.testing.assert_allclose(position, vec["position"], atol=1e-9)
np.testing.assert_allclose(rotation, vec["rotation"], atol=1e-9)
@pytest.mark.parametrize("vec", POSE_TEST_VECTORS, ids=lambda v: v["name"])
def test_threejs_rotation_is_proper(vec):
"""R_three must be a proper rotation (orthonormal, det = +1)."""
_, R = colmap_to_threejs(vec["q"], vec["t"])
np.testing.assert_allclose(R @ R.T, np.eye(3), atol=1e-9)
assert abs(np.linalg.det(R) - 1.0) < 1e-9
def _colmap_pose(R_w2c: np.ndarray, center: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Build a COLMAP (q, t) from a world->camera rotation and a world camera center."""
t = -R_w2c @ center
q = mat_to_quat(R_w2c)
return q, t
def test_colmap_to_threejs_roundtrip_random_poses():
"""Forward COLMAP->Three.js then invert it; recover the original (q, t)."""
rng = np.random.default_rng(20260716)
flip = np.diag([1.0, -1.0, -1.0])
for _ in range(50):
R_w2c = Rotation.random(random_state=rng).as_matrix()
center = rng.uniform(-10, 10, size=3)
q, t = _colmap_pose(R_w2c, center)
position, R_three = colmap_to_threejs(q, t)
# position is the camera center
np.testing.assert_allclose(position, center, atol=1e-9)
# invert: R_c2w = R_three @ flip -> R_w2c = R_c2w^T -> t = -R_w2c @ C
R_c2w = R_three @ flip
R_back = R_c2w.T
t_back = -R_back @ position
q_back = mat_to_quat(R_back)
np.testing.assert_allclose(t_back, t, atol=1e-9)
# quaternions are double-cover; compare canonical (w>=0) forms
q_canon = q if q[0] >= 0 else -q
np.testing.assert_allclose(q_back, q_canon, atol=1e-9)
def test_quat_to_mat_matches_scipy_oracle():
"""quat_to_mat agrees with an independent implementation (scipy)."""
rng = np.random.default_rng(11)
for _ in range(50):
# scipy quaternions are scalar-LAST [x,y,z,w]; ours are scalar-FIRST [w,x,y,z]
q_xyzw = rng.normal(size=4)
q_xyzw /= np.linalg.norm(q_xyzw)
q_wxyz = np.array([q_xyzw[3], q_xyzw[0], q_xyzw[1], q_xyzw[2]])
np.testing.assert_allclose(
quat_to_mat(q_wxyz), Rotation.from_quat(q_xyzw).as_matrix(), atol=1e-9
)