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>
119 lines
4.7 KiB
Python
119 lines
4.7 KiB
Python
"""Fixture tests for synthetic.py (foundation-owned; synthetic.py is a frozen contract).
|
|
|
|
These lock the synthetic fixture's contract: known audio offsets are recoverable, bang
|
|
times stand out for M7 candidate detection, the PLY round-trips, generated poses face the
|
|
stage under the frozen conversion, and ``build`` populates the DB + files as ground truth.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from scipy.signal import correlate
|
|
|
|
from festival4d import config, db, synthetic
|
|
from festival4d.geometry import colmap_to_threejs
|
|
|
|
|
|
def _estimate_offset_ms(clip_ref, clip_v, sr):
|
|
"""Recover clip_v's offset vs the reference via cross-correlation (see derivation)."""
|
|
corr = correlate(clip_v, clip_ref, mode="full")
|
|
lag = int(np.argmax(corr)) - (len(clip_ref) - 1)
|
|
return -lag / sr * 1000.0
|
|
|
|
|
|
def test_audio_slices_recover_known_offsets():
|
|
sr = config.SYNTH_AUDIO_SR
|
|
master, _ = synthetic.synth_master_audio(sr)
|
|
ref = synthetic.slice_for_offset(master, sr, 0.0, config.SYNTH_DURATION_S)
|
|
for offset_ms in config.SYNTH_OFFSETS_MS:
|
|
clip = synthetic.slice_for_offset(master, sr, offset_ms, config.SYNTH_DURATION_S)
|
|
recovered = _estimate_offset_ms(ref, clip, sr)
|
|
assert abs(recovered - offset_ms) < 1.0, (offset_ms, recovered)
|
|
|
|
|
|
def test_bang_times_dominate_energy():
|
|
"""Loud bangs (M7 candidates) must clearly exceed the regular-beat energy floor."""
|
|
sr = config.SYNTH_AUDIO_SR
|
|
master, gt = synthetic.synth_master_audio(sr)
|
|
|
|
def window_rms(center_s, half=0.05):
|
|
i0 = int((center_s - synthetic.G_START - half) * sr)
|
|
i1 = int((center_s - synthetic.G_START + half) * sr)
|
|
return float(np.sqrt(np.mean(master[i0:i1] ** 2)))
|
|
|
|
bang_rms = [window_rms(t) for t in gt["bang_times_global_s"]]
|
|
# a spread of non-bang beat times for the baseline
|
|
beat_rms = [window_rms(t) for t in (1.0, 4.5, 7.0, 11.5, 14.0)]
|
|
assert min(bang_rms) > 3.0 * np.median(beat_rms)
|
|
|
|
|
|
def test_ply_roundtrip(tmp_path):
|
|
pts, cols = synthetic.generate_point_cloud()
|
|
path = tmp_path / "points.ply"
|
|
synthetic.write_ply(path, pts, cols)
|
|
pts2, cols2 = synthetic.read_ply(path)
|
|
assert pts2.shape == pts.shape and cols2.shape == cols.shape
|
|
np.testing.assert_allclose(pts2, pts, atol=1e-6)
|
|
np.testing.assert_array_equal(cols2, cols)
|
|
|
|
|
|
def test_generated_poses_face_the_stage():
|
|
"""Each synthetic pose, run through the frozen conversion, must look at the stage."""
|
|
poses = synthetic.camera_track(1, config.SYNTH_DURATION_S, config.SYNTH_FPS,
|
|
config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H)
|
|
target = np.array(synthetic.STAGE_TARGET)
|
|
for p in poses:
|
|
q = [p["qw"], p["qx"], p["qy"], p["qz"]]
|
|
t = [p["tx"], p["ty"], p["tz"]]
|
|
position, R = colmap_to_threejs(q, t)
|
|
look = R @ np.array([0.0, 0.0, -1.0]) # Three.js camera looks down -z
|
|
expected = target - position
|
|
expected /= np.linalg.norm(expected)
|
|
np.testing.assert_allclose(look, expected, atol=1e-9)
|
|
|
|
|
|
def test_build_no_ffmpeg_populates_db_and_files(tmp_path):
|
|
summary = synthetic.build(base_dir=tmp_path, duration_s=5.0, run_ffmpeg=False)
|
|
|
|
videos = db.get_videos()
|
|
assert len(videos) == 3
|
|
by_name = {v.filename: v for v in videos}
|
|
assert by_name["cam0.mp4"].offset_ms == 0.0
|
|
assert by_name["cam1.mp4"].offset_ms == 1370.0
|
|
assert by_name["cam2.mp4"].offset_ms == -842.0
|
|
for v in videos:
|
|
assert len(db.get_poses(v.id)) > 0
|
|
assert db.get_poses(v.id)[0].registered is True
|
|
|
|
assert db.has_poses()
|
|
assert len(db.get_anchors()) == 4
|
|
assert len(db.get_events()) == len(synthetic.SEED_EVENTS)
|
|
|
|
ply = tmp_path / "work" / "points.ply"
|
|
assert ply.exists()
|
|
pts, _ = synthetic.read_ply(ply)
|
|
assert len(pts) == summary["point_count"] > 1000
|
|
|
|
gt = json.loads((tmp_path / "work" / "ground_truth.json").read_text())
|
|
assert [vv["offset_ms"] for vv in gt["videos"]] == [0.0, 1370.0, -842.0]
|
|
assert gt["reference_filename"] == "cam0.mp4"
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
|
|
reason="ffmpeg/ffprobe required for the full fixture build")
|
|
def test_build_full_ffmpeg_renders_seekable_videos(tmp_path):
|
|
synthetic.build(base_dir=tmp_path, duration_s=4.0, run_ffmpeg=True)
|
|
raw = tmp_path / "raw"
|
|
for name in ("cam0.mp4", "cam1.mp4", "cam2.mp4"):
|
|
path = raw / name
|
|
assert path.exists() and path.stat().st_size > 10_000
|
|
videos = db.get_videos()
|
|
assert len(videos) == 3
|
|
for v in videos:
|
|
assert 3.5 < v.duration_s < 4.6 # ~4 s clips
|
|
assert v.width == 640 and v.height == 360
|