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>
822 lines
35 KiB
Python
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))
|