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>
557 lines
22 KiB
Python
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))
|