festifun/backend/festival4d/synthetic.py
type-two 8f2a488c51 foundation3 (M18): complete Friend-Tracks foundation — fixture ground truth, contracts, stubs
Completes M18 on top of the WIP checkpoint (6a703c3). Three lanes (H/J/K) depend only on
what this freezes.

- synthetic.py: base testsrc2 saturation/brightness-muted so its magenta/cyan colour bars
  don't swamp lane H's colour detector; the two moving markers stay the only detectable blobs.
- Frontend (step 6): friendTracks.js stub (never-throws, fx.js pattern) wired into main.js;
  hidden #btn-friends + #friends-panel; state.tracks loaded when manifest.has_tracks.
- Tests (step 8): test_tracks_foundation.py (20) — DB helpers, /api/tracks routes, solve
  degradation, manifest.has_tracks, frozen OOK protocol + encoders, track_truth shape, the
  projection round-trip (rendered pixels back-project to 3D truth, worst err <1e-3 → lane H's
  median<0.3 reachable by construction), and a real-ffmpeg render test proving marker A is the
  dominant magenta blob after base-muting.
- CR-6: the two sanctioned M18 test-lock edits (test_api manifest key-set +has_tracks;
  test_capsule bundle paths +api/tracks). Additive; flagged for integration3 ratification.

Suite: 14 pre-existing files sum to 189 (floor held) + 20 new = 209 passed, exit 0.
Frontend `npm run build` clean. Do NOT merge — coordinator reviews + merges (DIRECTIVES R6 #3).

Recovered by the coordinator session after the foundation3 agent's isolation worktree was
removed mid-run, leaving the work uncommitted; every file independently reviewed, full suite +
build re-run green, then committed. No work redone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:18:53 +10:00

822 lines
35 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.
- **Two moving markers** (phase 6 / M18) composited into every camera's video *through the
same camera projection the poses use*, plus ``track_truth.json`` — the 3D ground truth lane
H recovers. Marker A is a solid magenta disc (color mode, ``hue:150``); marker B is a
blinking bright disc speaking the frozen OOK protocol with ID 5 (``code:5``). See
:func:`marker_position`, :func:`render_marker_overlay`, :func:`build_track_truth`.
- **``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, marker geometry) 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, tracker_detect
from festival4d.geometry import mat_to_quat, quat_to_mat, slerp_pose
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
# ---------------------------------------------------------------------------
# Moving markers (phase 6 / M18). Two wearable markers travel known 3D paths across the
# stage; each camera's frame shows them at the pixel its OWN pose projects the 3D point to,
# so the rendered pixels and ``track_truth.json`` are consistent BY CONSTRUCTION — the marker
# pixel is derived from the ground-truth 3D point through the same projection lane H inverts.
#
# Marker A — solid magenta (#ff00ff) disc, color mode, marker_key "hue:150". Slow circle
# (radius 2, y≈1.5) centered on the stage.
# Marker B — blinking bright disc, blink mode, ID 5, marker_key "code:5". A different path;
# the LED follows the frozen OOK protocol (tracker_detect) in GLOBAL time, so
# every camera sees the same on/off state at the same t_global.
# Discs are small (radius ≈2.5% of frame height ⇒ ≪2% frame area) and composited AFTER all
# existing content, so the 189-test floor (offsets/events/poses/SfM) is untouched (pitfall #6).
# ---------------------------------------------------------------------------
MARKER_A_KEY = tracker_detect.color_marker_key(tracker_detect.MAGENTA_OPENCV_HUE) # "hue:150"
MARKER_A_HEX = "#ff00ff"
MARKER_A_BGR = (255, 0, 255) # OpenCV BGR for magenta (drawn AFTER the hue filter)
MARKER_B_ID = 5
MARKER_B_KEY = tracker_detect.code_marker_key(MARKER_B_ID) # "code:5"
MARKER_B_ON_BGR = (255, 255, 255) # bright white while the LED is ON
# testsrc2's base pattern contains solid full-saturation magenta AND cyan colour bars — the
# EXACT two marker hues (contract #1) — plus other bright bars. Left as-is they swamp lane H's
# colour detection (the magenta bar is a far bigger "magenta blob" than marker A's disc) and
# starve marker B's white-blink luminance contrast. So the base is MUTED before the markers are
# composited on top: saturation crushed (no high-saturation magenta/cyan survives) and dimmed
# (the bright-white blink disc pops in luminance). The fully-saturated discs are then the only
# high-S magenta/cyan and the only bright small blobs in frame — the fixture lane H is graded on
# is actually solvable by construction. Tests assert geometry/audio/PLY, never base pixels.
BASE_SATURATION = 0.28 # hue-filter saturation multiplier applied to the base
BASE_BRIGHTNESS = -0.28 # eq brightness offset applied to the base (dim it)
MARKER_DISC_HEIGHT_FRAC = 0.025 # disc radius / frame height (≈9 px @ 360 -> 0.11% area)
MARKER_A_PERIOD_S = 20.0 # one slow loop across the fixture span
MARKER_B_PERIOD_S = 16.0 # different path + rate so the two never lock together
MARKER_TRUTH_DT_S = 0.05 # track_truth.json sampling step (t_global seconds)
def marker_position(marker_key: str, t_global: float) -> tuple[float, float, float]:
"""Ground-truth 3D position (Three.js scene space) of a marker at ``t_global`` seconds.
Pure and total over all real ``t_global`` (the markers exist for the whole span). This is
THE ground truth: it is what ``track_truth.json`` records and what a correct solver
recovers. FROZEN — lane H's acceptance is measured against these paths.
"""
if marker_key == MARKER_A_KEY:
theta = 2.0 * np.pi * t_global / MARKER_A_PERIOD_S
return (2.0 * float(np.cos(theta)), 1.5, 2.0 * float(np.sin(theta)))
if marker_key == MARKER_B_KEY:
phi = 2.0 * np.pi * t_global / MARKER_B_PERIOD_S
return (
2.4 * float(np.sin(phi)),
1.0 + 0.4 * float(np.sin(2.0 * phi)),
-0.8 + 1.2 * float(np.cos(phi)),
)
raise KeyError(f"unknown marker_key {marker_key!r}")
MARKER_KEYS = (MARKER_A_KEY, MARKER_B_KEY)
def _interp_pose(poses: list[dict], t_video: float):
"""Interpolate a COLMAP pose ``(q, t, (fx,fy,cx,cy))`` at ``t_video`` from ``camera_track``
output (ascending by ``t_video_s``). Mirrors ``resolve._pose_at`` / the frontend ``poseAt``
exactly — slerp the rotation, lerp the translation via the FROZEN ``geometry.slerp_pose`` —
so the marker pixels a solver back-projects land on the same 3D point. Clamps (no extrap.).
"""
def tup(p):
return ([p["qw"], p["qx"], p["qy"], p["qz"]],
[p["tx"], p["ty"], p["tz"]],
(p["fx"], p["fy"], p["cx"], p["cy"]))
if t_video <= poses[0]["t_video_s"]:
return tup(poses[0])
if t_video >= poses[-1]["t_video_s"]:
return tup(poses[-1])
lo, hi = 0, len(poses) - 1
while hi - lo > 1:
mid = (lo + hi) // 2
if poses[mid]["t_video_s"] <= t_video:
lo = mid
else:
hi = mid
a, b = poses[lo], poses[hi]
span = b["t_video_s"] - a["t_video_s"]
alpha = (t_video - a["t_video_s"]) / span if span > 1e-9 else 0.0
q, t = slerp_pose(
[a["qw"], a["qx"], a["qy"], a["qz"]], [a["tx"], a["ty"], a["tz"]],
[b["qw"], b["qx"], b["qy"], b["qz"]], [b["tx"], b["ty"], b["tz"]],
alpha,
)
return list(q), list(t), (a["fx"], a["fy"], a["cx"], a["cy"])
def project_point(q, t, intr, X) -> tuple[float, float, float] | None:
"""Project world point ``X`` through a COLMAP world->camera pose (the inverse of
``geometry.ray_from_pixel``). Returns ``(px, py, z_cam)`` in pixels, or ``None`` if the
point is behind the camera. COLMAP camera axes: +x right, +y down, +z forward.
"""
R = quat_to_mat(q) # world -> cam
Xc = R @ np.asarray(X, dtype=np.float64).reshape(3) + np.asarray(t, dtype=np.float64).reshape(3)
z = float(Xc[2])
if z <= 1e-6:
return None
fx, fy, cx, cy = intr
return fx * float(Xc[0]) / z + cx, fy * float(Xc[1]) / z + cy, z
def _disc_radius_px(height: int) -> int:
return max(6, int(round(MARKER_DISC_HEIGHT_FRAC * height)))
def render_marker_overlay(out_dir: Path, poses: list[dict], offset_ms: float,
width: int, height: int, fps: float, duration_s: float) -> int:
"""Write a transparent BGRA PNG per frame with the two markers drawn at their projected
pixels (phase 6 / M18). Composited over one camera's clip by :func:`render_video`.
For frame ``k`` at local ``t_video = k/fps``: convert to ``t_global`` with the FROZEN
``config.t_global_from_video``, look up each marker's 3D truth at ``t_global``, and project
it through this camera's pose interpolated at ``t_video``. Marker A is always drawn; marker
B is drawn only when its LED is ON per the OOK schedule (``tracker_detect.led_on``, in
global time). Off-frame / behind-camera projections are simply not drawn. Returns frame count.
"""
import cv2
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
radius = _disc_radius_px(height)
n = int(round(duration_s * fps))
for k in range(n):
t_video = k / fps
t_global = config.t_global_from_video(t_video, offset_ms, 0.0)
q, t, intr = _interp_pose(poses, t_video)
img = np.zeros((height, width, 4), dtype=np.uint8) # transparent BGRA
_draw_marker_disc(img, q, t, intr, marker_position(MARKER_A_KEY, t_global),
MARKER_A_BGR, radius)
if tracker_detect.led_on(t_global, MARKER_B_ID):
_draw_marker_disc(img, q, t, intr, marker_position(MARKER_B_KEY, t_global),
MARKER_B_ON_BGR, radius)
if not cv2.imwrite(str(out_dir / f"{k:05d}.png"), img):
raise RuntimeError(f"failed to write marker overlay frame: {out_dir}/{k:05d}.png")
return n
def _draw_marker_disc(img, q, t, intr, X, bgr, radius) -> None:
import cv2
proj = project_point(q, t, intr, X)
if proj is None:
return
px, py, _ = proj
h, w = img.shape[:2]
if not (0.0 <= px < w and 0.0 <= py < h):
return # off this camera's frame ⇒ not visible here
cv2.circle(img, (int(round(px)), int(round(py))), radius,
(int(bgr[0]), int(bgr[1]), int(bgr[2]), 255), thickness=-1, lineType=cv2.LINE_8)
def build_track_truth(g_lo: float, g_hi: float, dt: float = MARKER_TRUTH_DT_S) -> dict:
"""The ``track_truth.json`` body: each marker's 3D path sampled over ``[g_lo, g_hi]``.
Shape (spec M18): ``{"markers": [{"marker_key", "points": [{"t_global_s","x","y","z"}]}]}``
in Three.js scene space. ``generated_by`` is added for provenance (extra keys are fine).
"""
n = max(1, int(round((g_hi - g_lo) / dt)) + 1)
markers = []
for key in MARKER_KEYS:
points = []
for i in range(n):
tg = g_lo + i * dt
x, y, z = marker_position(key, tg)
points.append({"t_global_s": round(tg, 6), "x": x, "y": y, "z": z})
markers.append({"marker_key": key, "points": points})
return {"markers": markers, "generated_by": "festival4d.synthetic"}
def render_blink_clip(out_path: Path, marker_id: int = MARKER_B_ID, fps: float = 30.0,
duration_s: float = 3.0, width: int = 320, height: int = 240) -> Path:
"""Render a small clip of a single blinking badge at frame center (phase 6 / M24 helper).
A fixed-position bright disc blinking ``marker_id`` in the frozen OOK protocol on a black
background — no camera geometry, so lane K's ``badge_selftest --synthetic`` can round-trip
the ID through ``tracker_detect``'s blink decoder without a full fixture. No audio track.
"""
import cv2
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg not found on PATH — required to render a blink clip")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
radius = _disc_radius_px(height)
cx, cy = width // 2, height // 2
n = int(round(duration_s * fps))
with tempfile.TemporaryDirectory() as tmp:
frames_dir = Path(tmp)
for k in range(n):
t = k / fps
img = np.zeros((height, width, 3), dtype=np.uint8) # black background
if tracker_detect.led_on(t, marker_id):
cv2.circle(img, (cx, cy), radius, (255, 255, 255), thickness=-1,
lineType=cv2.LINE_8)
cv2.imwrite(str(frames_dir / f"{k:05d}.png"), img)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-framerate", str(fps), "-start_number", "0",
"-i", str(frames_dir / "%05d.png"),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "20",
"-movflags", "+faststart",
str(out_path),
]
subprocess.run(cmd, check=True)
return out_path
# ---------------------------------------------------------------------------
# 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,
poses: list[dict] | None = None, offset_ms: float = 0.0) -> None:
"""Render one synthetic clip: a distinct ``testsrc2`` visual muxed with ``wav_path``.
When ``poses`` is given (this camera's ``camera_track``), the two moving markers (M18) are
composited on top via a per-frame RGBA overlay produced by :func:`render_marker_overlay`
(drawn AFTER the base visual + label, so existing behavior/tests are untouched). ``offset_ms``
aligns the marker schedule to global time for this camera.
"""
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)
# Per-camera hue shift for distinctness, then mute saturation + brightness so the base
# never collides with the fully-saturated markers composited on top (see BASE_* above).
hue = cam_index * 47
vf = f"hue=h={hue}:s={BASE_SATURATION},eq=brightness={BASE_BRIGHTNESS}"
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}"
common_out = [
"-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),
]
if poses is None:
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", src,
"-i", str(wav_path),
"-vf", vf,
*common_out,
]
subprocess.run(cmd, check=True)
return
# Markers: pre-render the RGBA overlay frames, then composite them over the base visual.
with tempfile.TemporaryDirectory() as ov:
ov_dir = Path(ov)
render_marker_overlay(ov_dir, poses, offset_ms, width, height, fps, duration_s)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", src, # 0: base video
"-i", str(wav_path), # 1: audio
"-framerate", str(fps), "-start_number", "0",
"-i", str(ov_dir / "%05d.png"), # 2: marker overlay
"-filter_complex", f"[0:v]{vf}[bg];[bg][2:v]overlay=0:0:eof_action=pass[vo]",
"-map", "[vo]", "-map", "1:a",
*common_out,
]
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)
poses = camera_track(i, duration, fps, w, h)
if run_ffmpeg:
wav_path = tmp / f"cam{i}.wav"
write_wav(wav_path, clip, sr)
# Pass this camera's poses + offset so the M18 markers render through the same
# projection lane H inverts (the marker pixel derives from its 3D truth).
render_video(out_path, wav_path, i, w, h, fps, duration,
poses=poses, offset_ms=float(offset_ms))
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, poses)
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))
# Friend-track ground truth (M18): the two markers' 3D paths over the union of the
# cameras' global coverage. Written for both ffmpeg and no-ffmpeg builds — it is pure
# geometry (the same paths the markers are rendered from), so tests can check it cheaply.
offsets_s = [o / 1000.0 for o in config.SYNTH_OFFSETS_MS]
track_truth = build_track_truth(min(offsets_s), max(offsets_s) + duration)
tt_path = work / "track_truth.json"
tt_path.write_text(json.dumps(track_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),
"track_truth": str(tt_path),
"markers": [m["marker_key"] for m in track_truth["markers"]],
"events": len(SEED_EVENTS),
"anchors": len(STAGE_CORNERS),
}
log.info("synthetic: done — %d videos, %d points, %d events, %d anchors, %d markers",
len(videos_gt), len(points), len(SEED_EVENTS), len(STAGE_CORNERS),
len(track_truth["markers"]))
return summary
if __name__ == "__main__": # pragma: no cover
logging.basicConfig(level=logging.INFO)
print(json.dumps(build(), indent=2))