festifun/backend/festival4d/synthetic.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

557 lines
22 KiB
Python

"""Synthetic fixture generator (spec M0) — the universal test bed.
``python -m festival4d synthetic`` builds a complete fake project so every later layer is
testable without real footage:
- **3 fake videos** (``cam0/1/2.mp4``, 640x360, 30 fps, 20 s), each a distinct ``testsrc2``
visual but sharing the *same* synthesized music-like audio, shifted by known offsets
(0, +1370, -842 ms). ``cam0`` is the reference (offset 0).
- **Fake poses**: 3 camera trajectories on arcs around a "stage" box at the origin,
looking at the stage, written into ``camera_poses`` (COLMAP world->camera convention).
- **Point cloud**: random points on the stage box + ground plane, written to
``points.ply`` in the exact binary-little-endian format the real COLMAP path produces.
- **Seeded events + anchors** so the timeline and overlays have data immediately.
- **``ground_truth.json``**: offsets, drift, audio pulse (bang) times, events, and stage
corners — part of the frozen contract (lanes A and D assert against it).
FROZEN CONTRACT after foundation. The module is factored so the numeric pieces
(audio shifts, poses, PLY round-trip) are unit-testable without invoking ffmpeg.
"""
from __future__ import annotations
import json
import logging
import shutil
import struct
import subprocess
import tempfile
import wave
from pathlib import Path
import numpy as np
from festival4d import config, db
from festival4d.geometry import mat_to_quat
log = logging.getLogger("festival4d.synthetic")
# Audio timeline: the master signal covers global time [G_START, G_END] so that even the
# negatively-offset video (started before global 0) samples a valid window.
G_START = -2.0
G_END = 24.0
BEAT_INTERVAL_S = 0.5 # regular beat clicks
BANG_TIMES_S = (3.0, 10.0, 17.0) # loud transients (M7 candidates); well separated
TONE_FREQS_HZ = (220.0, 330.0)
# Camera intrinsics (pinhole, after undistortion) for the synthetic cameras.
SYNTH_HFOV_DEG = 60.0
# Stage geometry (world coords, +y up, +z toward the cameras/audience).
STAGE_HALF_W = 3.0 # x extent
STAGE_HALF_D = 2.0 # z extent
STAGE_HEIGHT = 1.0 # y top
STAGE_TARGET = (0.0, 0.5, 0.0) # cameras aim here
STAGE_CORNERS = {
"Stage FL": (-STAGE_HALF_W, STAGE_HEIGHT, STAGE_HALF_D),
"Stage FR": (STAGE_HALF_W, STAGE_HEIGHT, STAGE_HALF_D),
"Stage BL": (-STAGE_HALF_W, STAGE_HEIGHT, -STAGE_HALF_D),
"Stage BR": (STAGE_HALF_W, STAGE_HEIGHT, -STAGE_HALF_D),
}
# Seeded timeline events (pre-classified) so lane C can render markers immediately.
SEED_EVENTS = [
(3.0, "bass_drop", 0.94, "Bass drops and the crowd erupts as the beat hits."),
(6.0, "crowd_wave", 0.72, "A wave ripples through the audience."),
(8.0, "quiet_moment", 0.61, "A brief hush before the next build."),
(10.0, "pyro", 0.88, "Pyro columns fire across the front of the stage."),
(13.0, "light_show", 0.90, "Sweeping beams sync to the breakdown."),
(17.0, "confetti", 0.81, "Confetti cannons burst over the crowd."),
(19.0, "artist_moment", 0.77, "The artist steps to the edge for a solo."),
]
# Fonts to try for the "CAM N" label (optional nicety; skipped if none exist).
_FONT_CANDIDATES = (
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Supplemental/Helvetica.ttc",
"/Library/Fonts/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
)
_PLY_DTYPE = np.dtype(
[("x", "<f4"), ("y", "<f4"), ("z", "<f4"),
("red", "u1"), ("green", "u1"), ("blue", "u1")]
)
# ---------------------------------------------------------------------------
# Audio
# ---------------------------------------------------------------------------
def synth_master_audio(sr: int = config.SYNTH_AUDIO_SR) -> tuple[np.ndarray, dict]:
"""Synthesize the master music-like signal over global time [G_START, G_END].
Returns the float32 signal (peak-normalized to ~0.95) and a ground-truth dict with the
sample rate, the window start, and the beat/bang times (global seconds).
"""
n = int(round((G_END - G_START) * sr))
t = G_START + np.arange(n) / sr
sig = np.zeros(n, dtype=np.float64)
# Low tone bed (kept quiet so it never dominates the transients GCC-PHAT keys on).
for f in TONE_FREQS_HZ:
sig += 0.05 * np.sin(2.0 * np.pi * f * t)
# Regular beat clicks: short 2 kHz bursts with a fast exponential decay -> sharp,
# broadband-ish transients that give a clean cross-correlation peak.
beat_times = np.arange(np.ceil(G_START / BEAT_INTERVAL_S) * BEAT_INTERVAL_S,
G_END, BEAT_INTERVAL_S)
for bt in beat_times:
_add_transient(sig, t, sr, center=bt, amp=0.25, freq=2000.0, decay_s=0.03)
# Loud "bangs" (bass drops / pyro): louder, longer, low-frequency thump + noise burst.
rng = np.random.default_rng(7)
for bang in BANG_TIMES_S:
_add_transient(sig, t, sr, center=bang, amp=0.9, freq=60.0, decay_s=0.18)
_add_noise_burst(sig, t, sr, center=bang, amp=0.5, decay_s=0.12, rng=rng)
# Faint noise floor for realism (SNR stays high so sync remains exact).
sig += 0.004 * rng.standard_normal(n)
peak = float(np.max(np.abs(sig)))
if peak > 0:
sig *= 0.95 / peak
ground_truth = {
"sample_rate": sr,
"g_start_s": G_START,
"g_end_s": G_END,
"beat_interval_s": BEAT_INTERVAL_S,
"bang_times_global_s": list(BANG_TIMES_S),
"pulse_times_global_s": list(BANG_TIMES_S), # alias lane D matches candidates against
}
return sig.astype(np.float32), ground_truth
def _add_transient(sig, t, sr, center, amp, freq, decay_s):
"""Add an exponentially decaying sinusoid burst centered at global time ``center``."""
length = int(decay_s * 5 * sr)
start = int(round((center - G_START) * sr))
i0 = max(0, start)
i1 = min(len(sig), start + length)
if i1 <= i0:
return
local = (np.arange(i0, i1) - start) / sr
env = np.exp(-local / decay_s)
sig[i0:i1] += amp * env * np.sin(2.0 * np.pi * freq * local)
def _add_noise_burst(sig, t, sr, center, amp, decay_s, rng):
length = int(decay_s * 5 * sr)
start = int(round((center - G_START) * sr))
i0 = max(0, start)
i1 = min(len(sig), start + length)
if i1 <= i0:
return
local = (np.arange(i0, i1) - start) / sr
env = np.exp(-local / decay_s)
sig[i0:i1] += amp * env * rng.standard_normal(i1 - i0)
def slice_for_offset(master: np.ndarray, sr: int, offset_ms: float,
duration_s: float) -> np.ndarray:
"""Extract a video's audio window from the master (spec M0 known-offset shift).
A video with ``offset_ms`` has local time ``tau`` mapping to global ``offset_ms/1000 +
tau`` (drift 0), so its audio is the master window starting at that global time.
"""
start_global = offset_ms / 1000.0
start = int(round((start_global - G_START) * sr))
length = int(round(duration_s * sr))
if start < 0 or start + length > len(master):
raise ValueError(f"offset {offset_ms} ms window falls outside the master signal")
return master[start:start + length]
def write_wav(path: Path, signal: np.ndarray, sr: int) -> None:
"""Write a mono 16-bit PCM WAV (stdlib only)."""
path.parent.mkdir(parents=True, exist_ok=True)
clipped = np.clip(signal, -1.0, 1.0)
pcm = (clipped * 32767.0).astype("<i2")
with wave.open(str(path), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes(pcm.tobytes())
def read_wav(path: Path) -> tuple[np.ndarray, int]:
"""Read a mono 16-bit PCM WAV back to float [-1, 1] (used by tests)."""
with wave.open(str(path), "rb") as w:
sr = w.getframerate()
frames = w.readframes(w.getnframes())
pcm = np.frombuffer(frames, dtype="<i2").astype(np.float64) / 32767.0
return pcm, sr
# ---------------------------------------------------------------------------
# Geometry: poses + point cloud
# ---------------------------------------------------------------------------
def intrinsics(width: int, height: int) -> dict:
fx = (width / 2.0) / np.tan(np.radians(SYNTH_HFOV_DEG) / 2.0)
return {"fx": float(fx), "fy": float(fx), "cx": width / 2.0, "cy": height / 2.0}
def look_at_colmap(center, target, world_up=(0.0, 1.0, 0.0)) -> tuple[np.ndarray, np.ndarray]:
"""Build a COLMAP world->camera pose (q=[w,x,y,z], t) for a camera at ``center``
looking at ``target`` with the given world up. Camera axes: +x right, +y down, +z fwd.
"""
C = np.asarray(center, dtype=np.float64)
T = np.asarray(target, dtype=np.float64)
up = np.asarray(world_up, dtype=np.float64)
z_c = T - C
z_c /= np.linalg.norm(z_c)
if abs(np.dot(up, z_c)) > 0.999: # looking near-vertical: pick a safe up
up = np.array([0.0, 0.0, 1.0])
up_c = up - np.dot(up, z_c) * z_c # image up (perp to forward)
up_c /= np.linalg.norm(up_c)
x_c = np.cross(z_c, up_c) # right
y_c = -up_c # down
R_c2w = np.column_stack([x_c, y_c, z_c]) # camera->world
R_w2c = R_c2w.T # world->camera (COLMAP R)
t = -R_w2c @ C
q = mat_to_quat(R_w2c)
return q, t
def camera_track(cam_index: int, duration_s: float, fps: float,
width: int, height: int) -> list[dict]:
"""One camera's trajectory: a gentle arc in front of the stage, ~2 poses/second."""
base_az = np.radians([-35.0, 0.0, 35.0][cam_index])
base_radius = 9.0
base_height = [1.6, 2.4, 2.0][cam_index]
sweep = np.radians(12.0)
intr = intrinsics(width, height)
poses = []
step = 0.5 # seconds -> ~2 poses/second
n = int(round(duration_s / step)) + 1
for k in range(n):
t_video = min(k * step, duration_s)
phase = 2.0 * np.pi * (t_video / duration_s)
az = base_az + sweep * np.sin(phase)
radius = base_radius + 0.5 * np.sin(phase + cam_index)
height_y = base_height + 0.3 * np.sin(phase * 2.0)
center = (radius * np.sin(az), height_y, radius * np.cos(az))
q, t = look_at_colmap(center, STAGE_TARGET)
poses.append({
"frame_idx": int(round(t_video * fps)),
"t_video_s": float(t_video),
"qw": float(q[0]), "qx": float(q[1]), "qy": float(q[2]), "qz": float(q[3]),
"tx": float(t[0]), "ty": float(t[1]), "tz": float(t[2]),
**intr,
"registered": True,
})
return poses
def generate_point_cloud(seed: int = 42) -> tuple[np.ndarray, np.ndarray]:
"""Random points on the stage box + ground plane (with the named corners included).
Returns ``(points Nx3 float32, colors Nx3 uint8)``.
"""
rng = np.random.default_rng(seed)
pts: list[np.ndarray] = []
cols: list[np.ndarray] = []
hw, hd, h = STAGE_HALF_W, STAGE_HALF_D, STAGE_HEIGHT
stage_color = np.array([210, 90, 60]) # warm stage
ground_color = np.array([70, 80, 70]) # dim ground
# Stage box: sample points on all 6 faces.
n_face = 260
faces = [
# (fixed axis, value, u range, v range) -> build points
("x", -hw), ("x", hw), ("z", -hd), ("z", hd), ("y", 0.0), ("y", h),
]
for axis, val in faces:
u = rng.uniform(-hw, hw, n_face)
v = rng.uniform(0.0, h, n_face) if axis in ("x", "z") else rng.uniform(-hd, hd, n_face)
w = rng.uniform(-hd, hd, n_face)
if axis == "x":
p = np.column_stack([np.full(n_face, val), v, w])
elif axis == "z":
p = np.column_stack([u, v, np.full(n_face, val)])
else: # y
p = np.column_stack([u, np.full(n_face, val), w])
pts.append(p)
jitter = rng.integers(-15, 16, size=(n_face, 3))
cols.append(np.clip(stage_color + jitter, 0, 255))
# Ground plane: scattered points on y=0 out to a radius, excluding the stage footprint.
n_ground = 1600
gx = rng.uniform(-12.0, 12.0, n_ground)
gz = rng.uniform(-12.0, 12.0, n_ground)
on_stage = (np.abs(gx) < hw) & (np.abs(gz) < hd)
gx, gz = gx[~on_stage], gz[~on_stage]
gp = np.column_stack([gx, np.zeros_like(gx), gz])
pts.append(gp)
jitter = rng.integers(-12, 13, size=(len(gx), 3))
cols.append(np.clip(ground_color + jitter, 0, 255))
# Include the exact named stage corners so the M8 nearest-point fallback can hit them.
corners = np.array(list(STAGE_CORNERS.values()))
pts.append(corners)
cols.append(np.tile(np.array([255, 230, 120]), (len(corners), 1)))
points = np.vstack(pts).astype(np.float32)
colors = np.vstack(cols).astype(np.uint8)
return points, colors
def write_ply(path: Path, points: np.ndarray, colors: np.ndarray) -> None:
"""Write a binary-little-endian PLY (x,y,z float + red,green,blue uchar). FROZEN format."""
points = np.asarray(points, dtype=np.float32).reshape(-1, 3)
colors = np.asarray(colors, dtype=np.uint8).reshape(-1, 3)
if len(points) != len(colors):
raise ValueError("points and colors length mismatch")
n = len(points)
arr = np.empty(n, dtype=_PLY_DTYPE)
arr["x"], arr["y"], arr["z"] = points[:, 0], points[:, 1], points[:, 2]
arr["red"], arr["green"], arr["blue"] = colors[:, 0], colors[:, 1], colors[:, 2]
assert arr.dtype.itemsize == 15 # no padding — 3*4 + 3*1
header = (
"ply\n"
"format binary_little_endian 1.0\n"
"comment Festival 4D point cloud\n"
f"element vertex {n}\n"
"property float x\n"
"property float y\n"
"property float z\n"
"property uchar red\n"
"property uchar green\n"
"property uchar blue\n"
"end_header\n"
)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
f.write(header.encode("ascii"))
f.write(arr.tobytes())
def read_ply(path: Path) -> tuple[np.ndarray, np.ndarray]:
"""Read a PLY written by :func:`write_ply` (used by tests). Returns (points, colors)."""
with open(path, "rb") as f:
line = f.readline()
if line.strip() != b"ply":
raise ValueError("not a PLY file")
n = 0
while True:
line = f.readline()
if not line:
raise ValueError("unexpected EOF in PLY header")
if line.startswith(b"element vertex"):
n = int(line.split()[-1])
if line.strip() == b"end_header":
break
arr = np.frombuffer(f.read(n * _PLY_DTYPE.itemsize), dtype=_PLY_DTYPE, count=n)
points = np.column_stack([arr["x"], arr["y"], arr["z"]]).astype(np.float32)
colors = np.column_stack([arr["red"], arr["green"], arr["blue"]]).astype(np.uint8)
return points, colors
# ---------------------------------------------------------------------------
# Video rendering (ffmpeg)
# ---------------------------------------------------------------------------
def _find_font() -> str | None:
for candidate in _FONT_CANDIDATES:
if Path(candidate).exists():
return candidate
return None
_DRAWTEXT_AVAILABLE: bool | None = None
def _has_drawtext() -> bool:
"""Whether this ffmpeg build includes the ``drawtext`` filter (needs libfreetype)."""
global _DRAWTEXT_AVAILABLE
if _DRAWTEXT_AVAILABLE is None:
try:
out = subprocess.run(
["ffmpeg", "-hide_banner", "-filters"],
check=True, capture_output=True, text=True,
).stdout
_DRAWTEXT_AVAILABLE = any(
line.split()[1] == "drawtext"
for line in out.splitlines()
if len(line.split()) > 1
)
except Exception:
_DRAWTEXT_AVAILABLE = False
return _DRAWTEXT_AVAILABLE
def render_video(out_path: Path, wav_path: Path, cam_index: int,
width: int, height: int, fps: float, duration_s: float) -> None:
"""Render one synthetic clip: a distinct ``testsrc2`` visual muxed with ``wav_path``."""
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg not found on PATH — required for the synthetic fixture")
out_path.parent.mkdir(parents=True, exist_ok=True)
hue = cam_index * 47
vf = f"hue=h={hue}"
font = _find_font()
if font and _has_drawtext():
vf += (f",drawtext=fontfile='{font}':text='CAM {cam_index}':"
"x=24:y=24:fontsize=44:fontcolor=white:box=1:boxcolor=black@0.5")
src = f"testsrc2=size={width}x{height}:rate={fps}:duration={duration_s}"
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", src,
"-i", str(wav_path),
"-vf", vf,
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "28",
"-profile:v", "baseline", "-level", "3.1",
"-movflags", "+faststart",
"-c:a", "aac", "-b:a", "128k",
"-shortest",
str(out_path),
]
subprocess.run(cmd, check=True)
def ffprobe_video(path: Path) -> dict:
"""Probe width/height/fps/duration of a video with ffprobe."""
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,avg_frame_rate:format=duration",
"-of", "json", str(path)],
check=True, capture_output=True, text=True,
).stdout
data = json.loads(out)
stream = data["streams"][0]
num, den = stream["avg_frame_rate"].split("/")
fps = float(num) / float(den) if float(den) else 0.0
return {
"width": int(stream["width"]),
"height": int(stream["height"]),
"fps": fps,
"duration_s": float(data["format"]["duration"]),
}
# ---------------------------------------------------------------------------
# Orchestration
# ---------------------------------------------------------------------------
def build(base_dir: Path | str | None = None, duration_s: float | None = None,
run_ffmpeg: bool = True) -> dict:
"""Generate the full synthetic project. Returns a summary dict.
``base_dir`` defaults to ``config.DATA_DIR``. ``duration_s`` defaults to the fixture's
20 s (tests may pass a shorter value). ``run_ffmpeg=False`` skips video rendering (for
fast, hermetic tests of the DB/pose/PLY path).
"""
base = Path(base_dir) if base_dir is not None else config.DATA_DIR
duration = float(duration_s) if duration_s is not None else config.SYNTH_DURATION_S
raw = base / "raw"
work = base / "work"
raw.mkdir(parents=True, exist_ok=True)
work.mkdir(parents=True, exist_ok=True)
sr = config.SYNTH_AUDIO_SR
w, h, fps = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H, config.SYNTH_FPS
log.info("synthetic: building project at %s (duration=%.1fs, ffmpeg=%s)",
base, duration, run_ffmpeg)
db.init_engine(base / "project.db")
db.reset_db()
master, audio_gt = synth_master_audio(sr)
videos_gt = []
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
for i, offset_ms in enumerate(config.SYNTH_OFFSETS_MS):
filename = f"cam{i}.mp4"
out_path = raw / filename
clip = slice_for_offset(master, sr, offset_ms, duration)
if run_ffmpeg:
wav_path = tmp / f"cam{i}.wav"
write_wav(wav_path, clip, sr)
render_video(out_path, wav_path, i, w, h, fps, duration)
probed = ffprobe_video(out_path)
vw, vh, vfps, vdur = (probed["width"], probed["height"],
probed["fps"], probed["duration_s"])
else:
vw, vh, vfps, vdur = w, h, float(fps), duration
video = db.add_video(
filename=filename, duration_s=vdur, fps=vfps, width=vw, height=vh,
offset_ms=float(offset_ms), drift_ppm=0.0, sync_confidence=1.0,
)
db.set_poses(video.id, camera_track(i, duration, fps, w, h))
videos_gt.append({
"video_id": video.id, "filename": filename,
"offset_ms": float(offset_ms), "drift_ppm": 0.0,
"is_reference": i == 0,
})
log.info("synthetic: cam%d offset=%+.0fms -> %s", i, offset_ms, out_path.name)
# Point cloud
points, colors = generate_point_cloud()
write_ply(work / "points.ply", points, colors)
# Anchors (stage corners)
corner_colors = {"Stage FL": "#ff5533", "Stage FR": "#ffaa33",
"Stage BL": "#33aaff", "Stage BR": "#aa55ff"}
for label, (x, y, z) in STAGE_CORNERS.items():
db.add_anchor(label, x, y, z, corner_colors.get(label))
# Seeded events
for t_global, event_type, conf, desc in SEED_EVENTS:
db.add_event(t_global_s=t_global, event_type=event_type, source="ai",
duration_s=1.0, confidence=conf, description=desc)
# Ground truth JSON
ground_truth = {
"videos": videos_gt,
"reference_filename": "cam0.mp4",
"audio": audio_gt,
"events": [
{"t_global_s": t, "event_type": et, "confidence": c, "description": d}
for (t, et, c, d) in SEED_EVENTS
],
"scene": {
"stage_corners": {k: list(v) for k, v in STAGE_CORNERS.items()},
"stage_target": list(STAGE_TARGET),
"point_count": int(len(points)),
},
}
gt_path = work / "ground_truth.json"
gt_path.write_text(json.dumps(ground_truth, indent=2))
summary = {
"base_dir": str(base),
"videos": [v["filename"] for v in videos_gt],
"points_ply": str(work / "points.ply"),
"point_count": int(len(points)),
"ground_truth": str(gt_path),
"events": len(SEED_EVENTS),
"anchors": len(STAGE_CORNERS),
}
log.info("synthetic: done — %d videos, %d points, %d events, %d anchors",
len(videos_gt), len(points), len(SEED_EVENTS), len(STAGE_CORNERS))
return summary
if __name__ == "__main__": # pragma: no cover
logging.basicConfig(level=logging.INFO)
print(json.dumps(build(), indent=2))