Compare commits
11 Commits
lane/e-dir
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52674bbaf1 | ||
|
|
9bc1dae41d | ||
|
|
9306a3f362 | ||
|
|
8f2a488c51 | ||
|
|
6a703c314d | ||
|
|
56e569fd7a | ||
|
|
bf9e41be7f | ||
|
|
f7ba7f2095 | ||
|
|
fea0d5cb9e | ||
|
|
bbcee47afd | ||
|
|
a791bb37d5 |
@ -26,6 +26,22 @@
|
||||
"port": 5173,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "festifun-web-alt",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--prefix",
|
||||
"frontend",
|
||||
"--",
|
||||
"--port",
|
||||
"5177",
|
||||
"--strictPort"
|
||||
],
|
||||
"port": 5177,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "festifun-web-dist",
|
||||
"runtimeExecutable": "uv",
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -7,3 +7,5 @@ __pycache__/
|
||||
.DS_Store
|
||||
frontend/dist/
|
||||
.env
|
||||
data/capsule/
|
||||
.claude/worktrees/
|
||||
|
||||
@ -23,6 +23,17 @@ Phase 5 (foundation2; shapes frozen in plan/20-phase5.md contracts):
|
||||
DELETE /api/paths/{id}
|
||||
PATCH /api/anchors/{id} {label?, color?} -> updated anchor dict
|
||||
|
||||
Phase 6 (foundation3 / M18; shapes frozen in plan/30-phase6.md contract #2):
|
||||
|
||||
GET /api/tracks -> [{id, marker_key, label, color, points:[{t_global_s,x,y,z,
|
||||
quality,views}]}] (points ordered by t_global_s)
|
||||
PATCH /api/tracks/{id} {label?, color?} -> updated track dict (404 on missing)
|
||||
DELETE /api/tracks/{id} -> {deleted: id} (404 on missing)
|
||||
POST /api/tracks/solve -> {result, tracks[, note]} — runs lane H's detect->solve
|
||||
(tracker_solve.run_solve); while lane H is stubbed it
|
||||
degrades to a VALID empty result + "note" (house pattern).
|
||||
manifest gains "has_tracks": bool.
|
||||
|
||||
Source videos in ``data/raw`` are mounted at ``/media`` via Starlette ``StaticFiles``,
|
||||
which supports HTTP Range (required for ``<video>`` seeking; verify ``curl -H "Range:
|
||||
bytes=0-100"`` -> 206). Stubbed lane entrypoints (events detection, M8 annotation
|
||||
@ -118,6 +129,11 @@ class PathIn(BaseModel):
|
||||
path_json: str = Field(alias="json")
|
||||
|
||||
|
||||
class TrackPatch(BaseModel):
|
||||
label: str | None = None
|
||||
color: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -162,6 +178,24 @@ def _event_dict(e) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _track_dict(t) -> dict:
|
||||
"""A track with its points inlined (phase 6 contract #2), ordered by t_global_s."""
|
||||
return {
|
||||
"id": t.id,
|
||||
"marker_key": t.marker_key,
|
||||
"label": t.label,
|
||||
"color": t.color,
|
||||
"points": [
|
||||
{
|
||||
"t_global_s": p.t_global_s,
|
||||
"x": p.x, "y": p.y, "z": p.z,
|
||||
"quality": p.quality, "views": p.views,
|
||||
}
|
||||
for p in db.get_track_points(t.id)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -183,6 +217,8 @@ def manifest() -> dict:
|
||||
"has_capture": capture.capture_enabled(),
|
||||
"has_poses": db.has_poses(),
|
||||
"has_splat": config.SPLAT_PLY.exists(),
|
||||
"has_tracks": db.has_tracks(), # phase 6 (M18): any solved friend track ⇒ frontend loads them
|
||||
|
||||
# Always False from the live server; the capsule (M17) bakes a manifest with true,
|
||||
# which makes the frontend hide all write UI (phase-5 contract #5).
|
||||
"capsule": False,
|
||||
@ -465,3 +501,52 @@ def delete_anchor(anchor_id: int) -> dict:
|
||||
if not db.delete_anchor(anchor_id):
|
||||
raise HTTPException(status_code=404, detail=f"no anchor with id={anchor_id}")
|
||||
return {"deleted": anchor_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 6 (foundation3 / M18). Friend tracks. Shapes frozen in plan/30-phase6.md contract #2.
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.get("/api/tracks")
|
||||
def get_tracks() -> list[dict]:
|
||||
"""All friend tracks with their points inlined (contract #2)."""
|
||||
return [_track_dict(t) for t in db.get_tracks()]
|
||||
|
||||
|
||||
@app.patch("/api/tracks/{track_id}")
|
||||
def patch_track(track_id: int, payload: TrackPatch) -> dict:
|
||||
"""Rename / recolor a track in place (M21 friends panel)."""
|
||||
try:
|
||||
track = db.patch_track(track_id, label=payload.label, color=payload.color)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail=f"no track with id={track_id}")
|
||||
return _track_dict(track)
|
||||
|
||||
|
||||
@app.delete("/api/tracks/{track_id}")
|
||||
def delete_track(track_id: int) -> dict:
|
||||
"""Delete a track and its points (M21)."""
|
||||
if not db.delete_track(track_id):
|
||||
raise HTTPException(status_code=404, detail=f"no track with id={track_id}")
|
||||
return {"deleted": track_id}
|
||||
|
||||
|
||||
@app.post("/api/tracks/solve")
|
||||
def solve_tracks() -> dict:
|
||||
"""Run marker detection + track solving (M19+M20) and return the resulting tracks.
|
||||
|
||||
Dispatches into lane H's ``tracker_solve.run_solve`` (which itself consumes
|
||||
``tracker_detect``'s detections). While lane H is stubbed both raise
|
||||
``NotImplementedError``; we catch it and degrade to a VALID empty result + a note, so the
|
||||
frozen route never 500s — the house pattern (mirrors ``POST /api/events/detect``)."""
|
||||
from festival4d import tracker_solve
|
||||
|
||||
try:
|
||||
result = tracker_solve.run_solve()
|
||||
except NotImplementedError as exc:
|
||||
log.info("track solving not implemented yet: %s", exc)
|
||||
return {
|
||||
"result": None,
|
||||
"note": "friend-track solving not implemented yet (lane H / M19-M20)",
|
||||
"tracks": [_track_dict(t) for t in db.get_tracks()],
|
||||
}
|
||||
return {"result": result, "tracks": [_track_dict(t) for t in db.get_tracks()]}
|
||||
|
||||
@ -6,7 +6,7 @@ Contract: :func:`build_capsule` writes a self-contained bundle to ``out_dir`` (d
|
||||
- copy ``frontend/dist`` (error clearly if missing — tell the user to ``npm run build``);
|
||||
- copy media from ``config.RAW_DIR``;
|
||||
- bake every read-only API response to files at the *same relative paths*
|
||||
(``api/manifest``, ``api/events``, ``api/anchors``, ``api/beats``,
|
||||
(``api/manifest``, ``api/events``, ``api/anchors``, ``api/tracks``, ``api/beats``,
|
||||
``api/videos/{id}/poses``, ``api/pointcloud``, ``api/splat``, ``api/audio/{id}`` —
|
||||
extracting audio via ``ingest.extract_audio_hq`` if not yet cached);
|
||||
- inject ``<script>window.__F4D_API_BASE__=""</script>`` into the copied ``index.html``;
|
||||
@ -265,6 +265,8 @@ def build_capsule(out_dir: str | Path | None = None,
|
||||
_bake_json(api_dir / "manifest", manifest)
|
||||
_bake_json(api_dir / "events", api.get_events())
|
||||
_bake_json(api_dir / "anchors", api.get_anchors())
|
||||
# Friend tracks (M18/M20) — read-only in a capsule so ribbons + follow still work zero-backend.
|
||||
_bake_json(api_dir / "tracks", api.get_tracks())
|
||||
for v in videos:
|
||||
_bake_json(api_dir / "videos" / str(v.id) / "poses", api.video_poses(v.id))
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ to (in ``ingest.py``, ``audio_sync.py``, ``sfm.py``, ``events_ai.py``); they nev
|
||||
edit this file or ``api.py``.
|
||||
|
||||
Subcommands: ``synthetic | ingest | sync | reconstruct | events | features | direct |
|
||||
capsule | serve``.
|
||||
track | capsule | serve``.
|
||||
|
||||
Every subcommand dispatches to a single lane entrypoint. While a lane is still a stub,
|
||||
its entrypoint raises :class:`NotImplementedError`; we catch that here and print a clear
|
||||
@ -76,6 +76,23 @@ def _cmd_direct(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_track(args: argparse.Namespace) -> int:
|
||||
"""Detect wearable markers and solve them into 3D friend tracks (lane H / M19-M20).
|
||||
|
||||
Runs detect -> solve by default; ``--detect-only`` / ``--solve-only`` run one stage.
|
||||
Both stages dispatch into lane-H stubs, so while unimplemented this prints the
|
||||
"not implemented yet" message and exits 2 (same as every other stubbed subcommand)."""
|
||||
from festival4d import tracker_detect, tracker_solve
|
||||
|
||||
if not args.solve_only:
|
||||
result = tracker_detect.run_detect()
|
||||
log.info("track detect: %s", result)
|
||||
if not args.detect_only:
|
||||
result = tracker_solve.run_solve()
|
||||
log.info("track solve: %s", result)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_capsule(args: argparse.Namespace) -> int:
|
||||
from festival4d import capsule
|
||||
|
||||
@ -134,6 +151,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="seconds of lead-in before each event (default 2.0)")
|
||||
p_dir.set_defaults(func=_cmd_direct)
|
||||
|
||||
p_trk = sub.add_parser("track", help="detect markers + solve friend tracks (lane H / M19-M20)")
|
||||
trk_mode = p_trk.add_mutually_exclusive_group()
|
||||
trk_mode.add_argument("--detect-only", dest="detect_only", action="store_true",
|
||||
help="run marker detection only (write detections.json)")
|
||||
trk_mode.add_argument("--solve-only", dest="solve_only", action="store_true",
|
||||
help="run track solving only (from an existing detections.json)")
|
||||
p_trk.set_defaults(func=_cmd_track, detect_only=False, solve_only=False)
|
||||
|
||||
p_cap = sub.add_parser("capsule", help="bake a zero-backend shareable bundle (lane G / M17)")
|
||||
p_cap.add_argument("--out", default=None, help="output dir (default data/capsule/)")
|
||||
p_cap.set_defaults(func=_cmd_capsule)
|
||||
|
||||
@ -46,6 +46,8 @@ POINTS_PLY = WORK_DIR / "points.ply" # reconstructed / synthetic point cl
|
||||
SPLAT_PLY = WORK_DIR / "splat.ply" # optional 3DGS splat (trained externally, e.g. via MODELBEAST — see docs/modelbeast-crossover.md); viewer prefers it over the point cloud
|
||||
SYNC_JSON = WORK_DIR / "sync.json" # exported sync solution (lane A)
|
||||
GROUND_TRUTH_JSON = WORK_DIR / "ground_truth.json" # synthetic fixture ground truth
|
||||
DETECTIONS_JSON = WORK_DIR / "detections.json" # marker detections (phase 6 / M19, contract #3)
|
||||
TRACK_TRUTH_JSON = WORK_DIR / "track_truth.json" # synthetic marker ground truth (phase 6 / M18)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
|
||||
@ -13,6 +13,8 @@ Schema (spec §2)::
|
||||
events(id, t_global_s, duration_s, event_type, confidence, description, source)
|
||||
annotations(id, video_id FK, t_video_s, x0,y0,x1,y1, resolved_anchor_id FK NULL)
|
||||
paths(id, name, created_at, path_json) # phase 5 (M16): saved camPath JSON, as text
|
||||
tracks(id, marker_key, label, color, created_at) # phase 6 (M18/M20): friend tracks
|
||||
track_points(id, track_id FK, t_global_s, x,y,z, quality, views) # ordered by t_global_s
|
||||
|
||||
Engine management: :func:`init_engine` (re)binds the module to a SQLite file. It defaults
|
||||
to ``config.DB_PATH`` but tests point it at a temp file. Helpers open and commit their own
|
||||
@ -143,6 +145,43 @@ class SavedPath(Base):
|
||||
path_json: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
|
||||
class Track(Base):
|
||||
"""A friend track (phase 6 contract #2): a wearable marker's identity + metadata.
|
||||
|
||||
``marker_key`` is the detector-emitted identity — ``"hue:<opencv_hue>"`` for a color
|
||||
marker or ``"code:<id>"`` for a blink badge (see ``tracker_detect``). The per-timestep
|
||||
3D path lives in :class:`TrackPoint`. ``label`` / ``color`` are user-editable.
|
||||
"""
|
||||
|
||||
__tablename__ = "tracks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
marker_key: Mapped[str] = mapped_column(String, nullable=False)
|
||||
label: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
color: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
|
||||
class TrackPoint(Base):
|
||||
"""One solved position of a track at a master-timeline instant (phase 6 contract #2).
|
||||
|
||||
Coordinates are **Three.js scene space**. ``views`` = number of cameras used to solve the
|
||||
point (1 ⇒ single-view ground-plane fallback, contract #5); ``quality`` is 0..1.
|
||||
Points are always read ordered by ``t_global_s``.
|
||||
"""
|
||||
|
||||
__tablename__ = "track_points"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"), nullable=False, index=True)
|
||||
t_global_s: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
x: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
y: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
z: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
quality: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
views: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine / session management
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -508,6 +547,107 @@ def delete_path(path_id: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Friend tracks (phase 6 / M18 contract #2). Written by the solver (lane H / M20),
|
||||
# read by the API + frontend. TrackPoints are always returned ordered by t_global_s.
|
||||
# ---------------------------------------------------------------------------
|
||||
def add_track(marker_key: str, label: str | None = None, color: str | None = None) -> Track:
|
||||
"""Insert a track row (no points yet) and return it."""
|
||||
with session_scope() as s:
|
||||
track = Track(marker_key=marker_key, label=label, color=color, created_at=_now_iso())
|
||||
s.add(track)
|
||||
s.flush()
|
||||
s.refresh(track)
|
||||
return track
|
||||
|
||||
|
||||
def get_tracks() -> list[Track]:
|
||||
"""All tracks, ordered by id."""
|
||||
with session_scope() as s:
|
||||
return list(s.scalars(select(Track).order_by(Track.id)))
|
||||
|
||||
|
||||
def get_track(track_id: int) -> Track | None:
|
||||
with session_scope() as s:
|
||||
return s.get(Track, track_id)
|
||||
|
||||
|
||||
def get_track_points(track_id: int) -> list[TrackPoint]:
|
||||
"""A track's points, ordered by ``t_global_s`` (contract #2)."""
|
||||
with session_scope() as s:
|
||||
return list(
|
||||
s.scalars(
|
||||
select(TrackPoint)
|
||||
.where(TrackPoint.track_id == track_id)
|
||||
.order_by(TrackPoint.t_global_s)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def set_track_points(track_id: int, points: Iterable[dict]) -> int:
|
||||
"""Replace all points for a track with ``points`` (solver output, M20). Returns the count.
|
||||
|
||||
Each dict needs ``t_global_s, x, y, z`` and optionally ``quality`` (default None) and
|
||||
``views`` (default None). Atomic: the delete + inserts share one transaction, so a caller
|
||||
that raises mid-way never leaves a half-written track. Raises ``KeyError`` if the track
|
||||
is unknown.
|
||||
"""
|
||||
rows = list(points)
|
||||
with session_scope() as s:
|
||||
if s.get(Track, track_id) is None:
|
||||
raise KeyError(f"no track with id={track_id}")
|
||||
s.execute(delete(TrackPoint).where(TrackPoint.track_id == track_id))
|
||||
for p in rows:
|
||||
s.add(
|
||||
TrackPoint(
|
||||
track_id=track_id,
|
||||
t_global_s=float(p["t_global_s"]),
|
||||
x=float(p["x"]), y=float(p["y"]), z=float(p["z"]),
|
||||
quality=None if p.get("quality") is None else float(p["quality"]),
|
||||
views=None if p.get("views") is None else int(p["views"]),
|
||||
)
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def patch_track(track_id: int, label: str | None = None, color: str | None = None) -> Track:
|
||||
"""Rename / recolor a track (M21 panel). Only provided fields change.
|
||||
|
||||
Raises ``KeyError`` if the id is unknown.
|
||||
"""
|
||||
with session_scope() as s:
|
||||
track = s.get(Track, track_id)
|
||||
if track is None:
|
||||
raise KeyError(f"no track with id={track_id}")
|
||||
if label is not None:
|
||||
track.label = label
|
||||
if color is not None:
|
||||
track.color = color
|
||||
s.flush()
|
||||
s.refresh(track)
|
||||
return track
|
||||
|
||||
|
||||
def delete_track(track_id: int) -> bool:
|
||||
"""Delete a track and its points. Returns ``True`` if a row was deleted, else ``False``.
|
||||
|
||||
SQLite doesn't cascade by default, so the points are removed explicitly.
|
||||
"""
|
||||
with session_scope() as s:
|
||||
track = s.get(Track, track_id)
|
||||
if track is None:
|
||||
return False
|
||||
s.execute(delete(TrackPoint).where(TrackPoint.track_id == track_id))
|
||||
s.delete(track)
|
||||
return True
|
||||
|
||||
|
||||
def has_tracks() -> bool:
|
||||
"""Whether any track exists (drives ``manifest.has_tracks``)."""
|
||||
with session_scope() as s:
|
||||
return s.scalar(select(Track.id).limit(1)) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Annotations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -11,11 +11,17 @@ testable without real footage:
|
||||
- **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) are unit-testable without invoking ffmpeg.
|
||||
(audio shifts, poses, PLY round-trip, marker geometry) are unit-testable without invoking
|
||||
ffmpeg.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -31,8 +37,8 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from festival4d import config, db
|
||||
from festival4d.geometry import mat_to_quat
|
||||
from festival4d import config, db, tracker_detect
|
||||
from festival4d.geometry import mat_to_quat, quat_to_mat, slerp_pose
|
||||
|
||||
log = logging.getLogger("festival4d.synthetic")
|
||||
|
||||
@ -363,6 +369,219 @@ def read_ply(path: Path) -> tuple[np.ndarray, np.ndarray]:
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -396,25 +615,30 @@ def _has_drawtext() -> bool:
|
||||
|
||||
|
||||
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``."""
|
||||
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}"
|
||||
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}"
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", src,
|
||||
"-i", str(wav_path),
|
||||
"-vf", vf,
|
||||
common_out = [
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "28",
|
||||
"-profile:v", "baseline", "-level", "3.1",
|
||||
"-movflags", "+faststart",
|
||||
@ -422,7 +646,33 @@ def render_video(out_path: Path, wav_path: Path, cam_index: int,
|
||||
"-shortest",
|
||||
str(out_path),
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
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:
|
||||
@ -481,11 +731,15 @@ def build(base_dir: Path | str | None = None, duration_s: float | None = None,
|
||||
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)
|
||||
render_video(out_path, wav_path, i, w, h, fps, duration)
|
||||
# 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"])
|
||||
@ -496,7 +750,7 @@ def build(base_dir: Path | str | None = None, duration_s: float | None = None,
|
||||
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))
|
||||
db.set_poses(video.id, poses)
|
||||
videos_gt.append({
|
||||
"video_id": video.id, "filename": filename,
|
||||
"offset_ms": float(offset_ms), "drift_ppm": 0.0,
|
||||
@ -537,17 +791,28 @@ def build(base_dir: Path | str | None = None, duration_s: float | None = None,
|
||||
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",
|
||||
len(videos_gt), len(points), len(SEED_EVENTS), 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
|
||||
|
||||
|
||||
|
||||
171
backend/festival4d/tracker_detect.py
Normal file
171
backend/festival4d/tracker_detect.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""Marker detection (spec M19, lane H) — STUB with the frozen contracts baked in.
|
||||
|
||||
foundation3 ships this file with:
|
||||
|
||||
- the **concrete, foundation-owned protocol constants + pure encoders** every side of the
|
||||
system must agree on (the blink OOK protocol #4, the color-mode marker-key convention #1).
|
||||
``synthetic.py`` renders the fixture markers with these, ``scripts/badge_selftest.py``
|
||||
(lane K) imports this decoder path, and ``hardware/badge/protocol.h`` (lane K) duplicates
|
||||
the constants with a documented copy-check. **One source of truth lives here.**
|
||||
- a **stub** :func:`run_detect` that raises ``NotImplementedError``. Lane H fills the body
|
||||
(and adds ``backend/tests/test_tracker_detect.py``); it must not change the constants or
|
||||
the emitted ``marker_key`` conventions, only implement the detection logic.
|
||||
|
||||
Nothing in this module raises at import time — ``synthetic.py`` imports the constants and the
|
||||
``encode_word`` / ``led_on`` helpers, so the import must stay side-effect-free and light
|
||||
(no OpenCV at module scope; import ``cv2`` inside ``run_detect``).
|
||||
|
||||
================================================================================
|
||||
FROZEN CONTRACTS (plan/30-phase6.md — copy, do not reinterpret)
|
||||
================================================================================
|
||||
|
||||
Contract #1 — Marker hues (color mode)
|
||||
--------------------------------------
|
||||
Magenta ``#ff00ff`` and cyan ``#00ffff`` are the two supported fixture hues; real shoots may
|
||||
configure others via ``FESTIVAL4D_MARKER_HUES`` (comma-separated hex). Detection converts to
|
||||
HSV and gates on **saturation AND value before hue** (pitfall #2 — stage lighting lies about
|
||||
color). A color blob's ``marker_key`` is ``"hue:<H>"`` where ``<H>`` is the **OpenCV hue**
|
||||
(0..179) of the configured marker color, i.e. ``round(matplotlib_hue_degrees / 2)``:
|
||||
magenta → ``hue:150``, cyan → ``hue:90``. Use :func:`color_marker_key` — never format the
|
||||
string by hand.
|
||||
|
||||
Contract #2 — Schema (what the solver, not the detector, writes; here for context)
|
||||
----------------------------------------------------------------------------------
|
||||
``tracks(id INTEGER PK, marker_key TEXT NOT NULL, label TEXT, color TEXT, created_at TEXT)``
|
||||
``track_points(id INTEGER PK, track_id FK, t_global_s FLOAT, x FLOAT, y FLOAT, z FLOAT,
|
||||
quality FLOAT, views INTEGER)`` — points ordered by ``t_global_s``;
|
||||
``views`` = cameras used (1 ⇒ ground-plane fallback); coordinates in **Three.js scene space**.
|
||||
|
||||
Contract #3 — detections.json (this module's OUTPUT — write via `db`? No: a JSON file)
|
||||
--------------------------------------------------------------------------------------
|
||||
Write ``config.DETECTIONS_JSON`` (``data/work/detections.json``) with EXACTLY this shape::
|
||||
|
||||
{"videos": {"<video_id>": [{"t_video_s": float, "marker_key": str,
|
||||
"cx": float, "cy": float, "area": float,
|
||||
"conf": float 0..1}]},
|
||||
"generated_by": "festival4d.tracker_detect"}
|
||||
|
||||
``cx`` / ``cy`` are **normalized 0..1** (the bbox-annotation convention: ``cx = px / width``,
|
||||
``cy = py / height``). ``area`` is the blob area in pixels. Detections at ``t_video`` (the
|
||||
frame's local time) — the solver converts to ``t_global`` with the FROZEN
|
||||
``config.t_global_from_video`` helper (pitfall #1). No markers / dark / markerless video →
|
||||
a valid-empty ``{"videos": {}, "generated_by": ...}``, never a crash.
|
||||
|
||||
Contract #4 — Blink protocol (OOK, camera-decodable)
|
||||
----------------------------------------------------
|
||||
Bit period **133 ms** (≈4 frames @ 30 fps). One **word** = preamble ``11100`` + 6-bit ID
|
||||
(MSB first) + even parity = **12 bits**, repeated continuously (~1.6 s/word). ID **63** is
|
||||
reserved for the sync beacon. Decoding needs **≥24 fps** video and **≥1 full word** of
|
||||
visibility. Decode by **integrating brightness per bit window using the video's real fps**
|
||||
(``meta.fps``) — never by counting frames (pitfall #3). Emit ``marker_key = "code:<id>"``
|
||||
via :func:`code_marker_key`. The constants below are the byte-identical source for
|
||||
``hardware/badge/protocol.h`` (lane K).
|
||||
|
||||
Contract #5 — Ground plane (solver context)
|
||||
-------------------------------------------
|
||||
Synthetic ground is ``y = 0`` (scene space). Real projects estimate it as the
|
||||
5th-percentile-y horizontal plane of the point cloud. Single-view fallback intersects the
|
||||
pixel ray with this plane; such points get ``views = 1`` and ``quality ≤ 0.5``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
log = logging.getLogger("festival4d.tracker_detect")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contract #4 — blink OOK protocol. FROZEN. Byte-identical to hardware/badge/protocol.h.
|
||||
# ---------------------------------------------------------------------------
|
||||
BLINK_BIT_PERIOD_S: float = 0.133 # 133 ms bit period (≈4 frames @ 30 fps)
|
||||
BLINK_PREAMBLE: tuple[int, ...] = (1, 1, 1, 0, 0) # start-of-word marker
|
||||
BLINK_ID_BITS: int = 6 # payload width (0..63)
|
||||
BLINK_WORD_BITS: int = len(BLINK_PREAMBLE) + BLINK_ID_BITS + 1 # +1 parity = 12
|
||||
BLINK_BEACON_ID: int = 63 # reserved: the sync/calibration beacon
|
||||
BLINK_MIN_FPS: float = 24.0 # decoder needs at least this frame rate
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contract #1 — color mode marker hues. OpenCV hue is 0..179 (degrees / 2).
|
||||
# ---------------------------------------------------------------------------
|
||||
MAGENTA_OPENCV_HUE: int = 150 # #ff00ff
|
||||
CYAN_OPENCV_HUE: int = 90 # #00ffff
|
||||
DEFAULT_MARKER_HUES_HEX: tuple[str, ...] = ("#ff00ff", "#00ffff")
|
||||
|
||||
|
||||
def encode_word(marker_id: int) -> list[int]:
|
||||
"""The 12-bit OOK word for ``marker_id`` (contract #4). Pure; no I/O.
|
||||
|
||||
``preamble(11100) + 6-bit ID MSB-first + even-parity bit``. The parity bit is chosen so
|
||||
the **whole word has an even number of 1-bits** (parity over preamble+ID). This is the
|
||||
exact bit sequence a badge emits, ``synthetic.py`` renders, and lane H's decoder inverts.
|
||||
|
||||
>>> encode_word(5)
|
||||
[1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1]
|
||||
"""
|
||||
mid = int(marker_id)
|
||||
if not 0 <= mid < (1 << BLINK_ID_BITS):
|
||||
raise ValueError(f"marker_id {marker_id} out of range 0..{(1 << BLINK_ID_BITS) - 1}")
|
||||
id_bits = [(mid >> (BLINK_ID_BITS - 1 - i)) & 1 for i in range(BLINK_ID_BITS)]
|
||||
body = list(BLINK_PREAMBLE) + id_bits
|
||||
parity = sum(body) % 2 # even parity: word weight (incl. parity) is even
|
||||
return body + [parity]
|
||||
|
||||
|
||||
def led_on(t_seconds: float, marker_id: int, epoch: float = 0.0) -> bool:
|
||||
"""Whether the badge LED for ``marker_id`` is lit at wall-clock time ``t_seconds``.
|
||||
|
||||
The word repeats continuously from ``epoch``; a single physical badge blinks in real
|
||||
(global) time, so every camera that captures a frame at the same ``t_global`` sees the
|
||||
same LED state. Used by the synthetic fixture to render marker B faithfully.
|
||||
"""
|
||||
bit_index = int((t_seconds - epoch) // BLINK_BIT_PERIOD_S)
|
||||
word = encode_word(marker_id)
|
||||
return bool(word[bit_index % BLINK_WORD_BITS])
|
||||
|
||||
|
||||
def color_marker_key(opencv_hue: int) -> str:
|
||||
"""The ``marker_key`` for a color blob of the given OpenCV hue (contract #1)."""
|
||||
return f"hue:{int(opencv_hue)}"
|
||||
|
||||
|
||||
def code_marker_key(marker_id: int) -> str:
|
||||
"""The ``marker_key`` for a decoded blink ID (contract #4)."""
|
||||
return f"code:{int(marker_id)}"
|
||||
|
||||
|
||||
def marker_hues_hex() -> list[str]:
|
||||
"""Configured color-mode hues as hex strings (``FESTIVAL4D_MARKER_HUES`` or the default)."""
|
||||
env = os.environ.get("FESTIVAL4D_MARKER_HUES")
|
||||
if env:
|
||||
hues = [h.strip() for h in env.split(",") if h.strip()]
|
||||
if hues:
|
||||
return hues
|
||||
return list(DEFAULT_MARKER_HUES_HEX)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lane H fills these. Signatures FROZEN.
|
||||
# ---------------------------------------------------------------------------
|
||||
def run_detect(sample_fps: float = 8.0) -> dict:
|
||||
"""Detect markers in every video and write ``detections.json`` (spec M19, contract #3).
|
||||
|
||||
Lane H implements:
|
||||
1. Sample frames per video with the ``frames.py`` machinery at ~``sample_fps`` fps
|
||||
(document the exact rate; ~8 fps comfortably oversamples the 133 ms bit period).
|
||||
2. **Color mode:** BGR→HSV; gate on saturation AND value *before* hue (pitfall #2);
|
||||
connected components per configured hue (:func:`marker_hues_hex`); blob centroid →
|
||||
normalized ``cx``/``cy``; ``marker_key = color_marker_key(<opencv_hue>)``.
|
||||
3. **Blink mode:** track bright blobs across the sampled window; integrate brightness
|
||||
per :data:`BLINK_BIT_PERIOD_S` window using the video's **real fps** (``meta.fps``,
|
||||
pitfall #3); correlate against :data:`BLINK_PREAMBLE`; check even parity; emit
|
||||
``marker_key = code_marker_key(<id>)``.
|
||||
4. Write ``config.DETECTIONS_JSON`` in the contract-#3 shape. No markers / dark /
|
||||
markerless → valid-empty ``{"videos": {}, "generated_by": "festival4d.tracker_detect"}``.
|
||||
|
||||
Returns a summary dict (per-video detection counts, decoded blink IDs). Never crash on a
|
||||
markerless or unreadable video — log and continue.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"marker detection not implemented yet (lane H / M19) — fill tracker_detect.run_detect"
|
||||
)
|
||||
72
backend/festival4d/tracker_solve.py
Normal file
72
backend/festival4d/tracker_solve.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Track solving (spec M20, lane H) — STUB with the frozen contract in the docstring.
|
||||
|
||||
foundation3 ships the stub; lane H fills :func:`run_solve` (and adds
|
||||
``backend/tests/test_tracker_solve.py``). This is pure orchestration over the FROZEN
|
||||
geometry primitives — it must NEVER inline pose math (read ``resolve.py`` first; it is the
|
||||
exact per-timestep pattern), and it writes through the ``db`` track helpers only.
|
||||
|
||||
================================================================================
|
||||
FROZEN CONTRACTS (plan/30-phase6.md — copy, do not reinterpret)
|
||||
================================================================================
|
||||
|
||||
Input — ``config.DETECTIONS_JSON`` (contract #3, written by ``tracker_detect.run_detect``)::
|
||||
|
||||
{"videos": {"<video_id>": [{"t_video_s", "marker_key", "cx", "cy", "area", "conf"}]},
|
||||
"generated_by": "festival4d.tracker_detect"}
|
||||
|
||||
``cx``/``cy`` are normalized 0..1; multiply by the video's width/height for pixel coords.
|
||||
|
||||
Output — the ``tracks`` / ``track_points`` tables (contract #2), via ``db`` helpers::
|
||||
|
||||
tracks(id, marker_key, label, color, created_at)
|
||||
track_points(id, track_id, t_global_s, x, y, z, quality, views) # ordered by t_global_s
|
||||
# coordinates in Three.js scene space; views = cameras used (1 ⇒ ground-plane fallback)
|
||||
|
||||
Algorithm (spec M20 — the parts that bite):
|
||||
1. Load detections; group by ``marker_key``.
|
||||
2. **Time alignment is the whole game** (pitfall #1): a detection is found at ``t_video``;
|
||||
association happens at ``t_global``. Convert with the FROZEN
|
||||
``config.t_global_from_video(t_video, offset_ms, drift_ppm)`` ONLY — never re-derive it.
|
||||
3. Per time bucket (**≈0.25 s** of ``t_global``), collect same-``marker_key`` detections
|
||||
across cameras at matching ``t_global``:
|
||||
- **2+ views → triangulate** through ``geometry`` primitives exactly as ``resolve.py``
|
||||
does (``geometry.ray_from_pixel`` per view + ``geometry.triangulate_rays``, or a
|
||||
multi-ray least squares built on them). Record ``views`` and a ``quality`` derived
|
||||
from the residual/gap.
|
||||
- **1 view → ray ∩ ground plane** (contract #5): the synthetic ground is ``y = 0``;
|
||||
real projects estimate the 5th-percentile-y horizontal plane of the point cloud and
|
||||
record it in the result dict. Such points get ``views = 1`` and ``quality ≤ 0.5``.
|
||||
4. Smooth each track (moving average or Catmull-Rom — your call; **document which**).
|
||||
5. Solid color for hue markers, palette color for code markers.
|
||||
6. Write via ``db.add_track`` + ``db.set_track_points``. **Re-solve replaces that marker's
|
||||
track** (idempotent — never stack duplicates on repeated runs).
|
||||
7. Degradation: no detections → zero tracks + a note; never crash.
|
||||
|
||||
``POST /api/tracks/solve`` and ``python -m festival4d track`` both run detect→solve
|
||||
end-to-end once this and ``tracker_detect`` are filled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("festival4d.tracker_solve")
|
||||
|
||||
# Poses are keyed every ~0.5 s; bucket association at 0.25 s (spec M20). Kept here so lane H
|
||||
# and any test reference one number.
|
||||
BUCKET_S: float = 0.25
|
||||
GROUND_PLANE_Y: float = 0.0 # synthetic fixture ground (contract #5, scene space)
|
||||
SINGLE_VIEW_MAX_QUALITY: float = 0.5 # ground-plane fallback quality cap (contract #5)
|
||||
|
||||
|
||||
def run_solve() -> dict:
|
||||
"""Solve detections into 3D friend tracks (spec M20, see module docstring).
|
||||
|
||||
Lane H implements the full detect-output → ``tracks``/``track_points`` pipeline described
|
||||
above. Returns a summary dict (tracks written, per-track point counts + coverage, the
|
||||
ground-plane used, and any degradation note). Idempotent: re-running replaces each
|
||||
marker's track rather than stacking duplicates.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"track solving not implemented yet (lane H / M20) — fill tracker_solve.run_solve"
|
||||
)
|
||||
@ -34,7 +34,10 @@ def test_manifest_shape(client):
|
||||
data = client.get("/api/manifest").json()
|
||||
# Exact-set lock: additive manifest fields are fine, but must be added here consciously
|
||||
# (this assertion has already caught two silent drifts — has_splat and has_capture).
|
||||
assert set(data) == {"videos", "t_global_max", "has_poses", "has_splat", "has_capture", "capsule"}
|
||||
# phase 6 (foundation3 / M18): has_tracks added — see plan/CHANGE_REQUESTS.md CR-6.
|
||||
assert set(data) == {"videos", "t_global_max", "has_poses", "has_splat", "has_capture",
|
||||
"capsule", "has_tracks"}
|
||||
assert data["has_tracks"] is False # synthetic build seeds no solved tracks (solver = lane H)
|
||||
assert data["has_poses"] is True
|
||||
assert data["capsule"] is False # live server is never a capsule (M17 bakes true)
|
||||
assert data["has_capture"] is False # capture routes are opt-in (FESTIVAL4D_CAPTURE)
|
||||
|
||||
@ -137,6 +137,8 @@ def test_bundle_structure(bundle):
|
||||
out, summary = bundle
|
||||
for rel in ("index.html", "serve.py", "assets/index.js",
|
||||
"api/manifest", "api/events", "api/anchors",
|
||||
# phase 6 (foundation3 / M18): friend tracks baked read-only — CR-6.
|
||||
"api/tracks",
|
||||
"api/beats", "api/pointcloud", "api/splat",
|
||||
# the paths listing lives at paths/index.html so per-id files can
|
||||
# coexist under the same URL prefix (see capsule.py)
|
||||
|
||||
351
backend/tests/test_tracks_foundation.py
Normal file
351
backend/tests/test_tracks_foundation.py
Normal file
@ -0,0 +1,351 @@
|
||||
"""foundation3 (phase 6 / M18) contract tests — the ground three lanes stand on.
|
||||
|
||||
Covers what foundation3 owns and freezes, NOT the lane implementations (detection = lane H's
|
||||
test_tracker_detect.py; solving = test_tracker_solve.py; ribbons = lane J's live-browser
|
||||
evidence):
|
||||
|
||||
A. tracks / track_points DB helpers (contract #2) — round-trips, replace + delete semantics.
|
||||
B. /api/tracks routes (contract #2) + POST /api/tracks/solve degradation + manifest.has_tracks.
|
||||
C. The frozen OOK blink protocol + color marker_key convention (contracts #1, #4) — pure,
|
||||
no OpenCV. These constants are byte-identical to hardware/badge/protocol.h (lane K).
|
||||
D. The synthetic fixture (M18 step 1): track_truth.json shape, marker paths, and — the one
|
||||
that matters most — a projection ROUND-TRIP proving the rendered marker pixels
|
||||
back-project to the ground-truth 3D point through the frozen geometry primitives. If this
|
||||
passes, lane H's M20 recovery (median 3D error < 0.3) is achievable by construction.
|
||||
|
||||
Hermetic: no ffmpeg (geometry only), temp data dir from conftest. DB-touching tests clean up
|
||||
after themselves so later modules see the tables as they found them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import shutil
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from festival4d import config, db, synthetic, tracker_detect
|
||||
|
||||
_HAS_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# A. DB helpers (contract #2)
|
||||
# ===========================================================================
|
||||
def _use_temp_db(tmp_path):
|
||||
db.init_engine(tmp_path / "t.db")
|
||||
db.reset_db()
|
||||
|
||||
|
||||
def test_add_and_get_tracks_ordered_by_id(tmp_path):
|
||||
_use_temp_db(tmp_path)
|
||||
a = db.add_track("hue:150", label="Alice", color="#ff00ff")
|
||||
b = db.add_track("code:5")
|
||||
assert db.has_tracks() is True
|
||||
tracks = db.get_tracks()
|
||||
assert [t.id for t in tracks] == sorted(t.id for t in tracks) == [a.id, b.id]
|
||||
assert tracks[0].marker_key == "hue:150" and tracks[0].label == "Alice"
|
||||
assert tracks[1].label is None and tracks[1].color is None # optional fields default None
|
||||
assert db.get_track(a.id).marker_key == "hue:150"
|
||||
assert db.get_track(999999) is None
|
||||
|
||||
|
||||
def test_has_tracks_false_on_empty(tmp_path):
|
||||
_use_temp_db(tmp_path)
|
||||
assert db.has_tracks() is False
|
||||
assert db.get_tracks() == []
|
||||
|
||||
|
||||
def test_set_track_points_replaces_and_orders(tmp_path):
|
||||
_use_temp_db(tmp_path)
|
||||
t = db.add_track("code:5")
|
||||
# Insert out of order; helper must return them sorted by t_global_s (contract #2).
|
||||
n = db.set_track_points(t.id, [
|
||||
{"t_global_s": 2.0, "x": 1, "y": 2, "z": 3, "quality": 0.9, "views": 2},
|
||||
{"t_global_s": 1.0, "x": 0, "y": 0, "z": 0, "quality": 0.4, "views": 1},
|
||||
])
|
||||
assert n == 2
|
||||
pts = db.get_track_points(t.id)
|
||||
assert [p.t_global_s for p in pts] == [1.0, 2.0]
|
||||
assert pts[0].views == 1 and pts[0].quality == 0.4
|
||||
assert pts[1].views == 2
|
||||
# Re-solve replaces (idempotent — never stacks duplicates, per M20 contract).
|
||||
db.set_track_points(t.id, [{"t_global_s": 5.0, "x": 9, "y": 9, "z": 9}])
|
||||
pts = db.get_track_points(t.id)
|
||||
assert len(pts) == 1 and pts[0].t_global_s == 5.0
|
||||
assert pts[0].quality is None and pts[0].views is None # omitted optionals -> None
|
||||
|
||||
|
||||
def test_set_track_points_unknown_track_raises(tmp_path):
|
||||
_use_temp_db(tmp_path)
|
||||
with pytest.raises(KeyError):
|
||||
db.set_track_points(424242, [{"t_global_s": 0.0, "x": 0, "y": 0, "z": 0}])
|
||||
|
||||
|
||||
def test_patch_track_partial_and_missing(tmp_path):
|
||||
_use_temp_db(tmp_path)
|
||||
t = db.add_track("hue:150", label="Old", color="#111111")
|
||||
db.patch_track(t.id, label="New") # color untouched
|
||||
got = db.get_track(t.id)
|
||||
assert got.label == "New" and got.color == "#111111"
|
||||
db.patch_track(t.id, color="#00ffff")
|
||||
assert db.get_track(t.id).color == "#00ffff"
|
||||
with pytest.raises(KeyError):
|
||||
db.patch_track(424242, label="x")
|
||||
|
||||
|
||||
def test_delete_track_removes_points(tmp_path):
|
||||
_use_temp_db(tmp_path)
|
||||
t = db.add_track("code:5")
|
||||
db.set_track_points(t.id, [{"t_global_s": 0.0, "x": 0, "y": 0, "z": 0}])
|
||||
assert db.delete_track(t.id) is True
|
||||
assert db.get_track(t.id) is None
|
||||
assert db.get_track_points(t.id) == [] # points cascade (SQLite doesn't by default)
|
||||
assert db.delete_track(t.id) is False # already gone
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# B. /api/tracks routes (contract #2)
|
||||
# ===========================================================================
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from festival4d import api
|
||||
|
||||
with TestClient(api.app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _api_db(client):
|
||||
"""Point the API at a clean per-test db and restore the shared engine afterwards."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
d = Path(tempfile.mkdtemp(prefix="tracks-api-"))
|
||||
db.init_engine(d / "api.db")
|
||||
db.reset_db()
|
||||
yield
|
||||
db.init_engine(config.DB_PATH)
|
||||
|
||||
|
||||
def test_get_tracks_shape(client, _api_db):
|
||||
t = db.add_track("hue:150", label="Alice", color="#ff00ff")
|
||||
db.set_track_points(t.id, [
|
||||
{"t_global_s": 1.0, "x": 0.1, "y": 1.5, "z": 2.0, "quality": 0.9, "views": 2},
|
||||
{"t_global_s": 1.25, "x": 0.2, "y": 1.5, "z": 1.9, "quality": 0.5, "views": 1},
|
||||
])
|
||||
listing = client.get("/api/tracks").json()
|
||||
assert len(listing) == 1
|
||||
row = listing[0]
|
||||
assert set(row) == {"id", "marker_key", "label", "color", "points"}
|
||||
assert row["marker_key"] == "hue:150" and row["label"] == "Alice"
|
||||
assert [p["t_global_s"] for p in row["points"]] == [1.0, 1.25] # ordered
|
||||
assert set(row["points"][0]) == {"t_global_s", "x", "y", "z", "quality", "views"}
|
||||
assert row["points"][1]["views"] == 1
|
||||
|
||||
|
||||
def test_patch_and_delete_routes(client, _api_db):
|
||||
t = db.add_track("code:5")
|
||||
patched = client.patch(f"/api/tracks/{t.id}", json={"label": "Bob", "color": "#00ffff"}).json()
|
||||
assert patched["label"] == "Bob" and patched["color"] == "#00ffff"
|
||||
# partial patch leaves the other field
|
||||
assert client.patch(f"/api/tracks/{t.id}", json={"color": "#fff"}).json()["label"] == "Bob"
|
||||
assert client.patch("/api/tracks/999999", json={"label": "x"}).status_code == 404
|
||||
assert client.delete(f"/api/tracks/{t.id}").json() == {"deleted": t.id}
|
||||
assert client.get("/api/tracks").json() == []
|
||||
assert client.delete(f"/api/tracks/{t.id}").status_code == 404
|
||||
|
||||
|
||||
def test_solve_degrades_while_stubbed(client, _api_db):
|
||||
# Lane H is a stub (NotImplementedError). The frozen route must NOT 500 — it returns a
|
||||
# valid empty result + a note (the house pattern, mirrors POST /api/events/detect).
|
||||
resp = client.post("/api/tracks/solve")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["result"] is None
|
||||
assert "not implemented" in body["note"].lower()
|
||||
assert body["tracks"] == [] # no tracks solved yet
|
||||
|
||||
|
||||
def test_manifest_has_tracks_reflects_db(client, _api_db):
|
||||
assert client.get("/api/manifest").json()["has_tracks"] is False
|
||||
db.add_track("hue:150")
|
||||
assert client.get("/api/manifest").json()["has_tracks"] is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# C. Frozen blink OOK protocol + color marker_key (contracts #1, #4)
|
||||
# ===========================================================================
|
||||
def test_protocol_constants_match_spec():
|
||||
assert tracker_detect.BLINK_BIT_PERIOD_S == 0.133
|
||||
assert tracker_detect.BLINK_PREAMBLE == (1, 1, 1, 0, 0)
|
||||
assert tracker_detect.BLINK_ID_BITS == 6
|
||||
assert tracker_detect.BLINK_WORD_BITS == 12 # 5 preamble + 6 id + 1 parity
|
||||
assert tracker_detect.BLINK_BEACON_ID == 63
|
||||
assert tracker_detect.BLINK_MIN_FPS == 24.0
|
||||
assert tracker_detect.MAGENTA_OPENCV_HUE == 150 # #ff00ff, hue 300°/2
|
||||
assert tracker_detect.CYAN_OPENCV_HUE == 90 # #00ffff, hue 180°/2
|
||||
|
||||
|
||||
def test_encode_word_id5_exact_and_even_parity():
|
||||
word = tracker_detect.encode_word(5)
|
||||
assert word == [1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1] # preamble + 000101 + parity
|
||||
assert len(word) == 12
|
||||
for mid in range(0, 64):
|
||||
w = tracker_detect.encode_word(mid)
|
||||
assert tuple(w[:5]) == tracker_detect.BLINK_PREAMBLE
|
||||
assert sum(w) % 2 == 0 # even parity over the whole word
|
||||
# 6-bit ID MSB-first recovers the value
|
||||
recovered = sum(bit << (5 - i) for i, bit in enumerate(w[5:11]))
|
||||
assert recovered == mid
|
||||
|
||||
|
||||
def test_encode_word_rejects_out_of_range():
|
||||
for bad in (-1, 64, 100):
|
||||
with pytest.raises(ValueError):
|
||||
tracker_detect.encode_word(bad)
|
||||
|
||||
|
||||
def test_led_on_matches_encoded_word_windows():
|
||||
# The LED state at bit-window k equals bit k of the repeating word (global time).
|
||||
word = tracker_detect.encode_word(5)
|
||||
period = tracker_detect.BLINK_BIT_PERIOD_S
|
||||
for k in range(len(word) * 2): # two full words
|
||||
t_mid = (k + 0.5) * period # sample mid-window
|
||||
assert tracker_detect.led_on(t_mid, 5) == bool(word[k % len(word)])
|
||||
|
||||
|
||||
def test_marker_key_helpers_and_hue_env(monkeypatch):
|
||||
assert tracker_detect.color_marker_key(150) == "hue:150"
|
||||
assert tracker_detect.code_marker_key(5) == "code:5"
|
||||
monkeypatch.delenv("FESTIVAL4D_MARKER_HUES", raising=False)
|
||||
assert tracker_detect.marker_hues_hex() == ["#ff00ff", "#00ffff"]
|
||||
monkeypatch.setenv("FESTIVAL4D_MARKER_HUES", "#ff0000, #00ff00")
|
||||
assert tracker_detect.marker_hues_hex() == ["#ff0000", "#00ff00"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# D. Synthetic fixture: track_truth shape, marker paths, projection round-trip
|
||||
# ===========================================================================
|
||||
def test_track_truth_shape_and_both_markers():
|
||||
tt = synthetic.build_track_truth(0.0, 2.0, dt=0.05)
|
||||
assert set(tt) >= {"markers"}
|
||||
keys = {m["marker_key"] for m in tt["markers"]}
|
||||
assert keys == {"hue:150", "code:5"} # marker A (color) + marker B (blink)
|
||||
for m in tt["markers"]:
|
||||
ts = [p["t_global_s"] for p in m["points"]]
|
||||
assert ts == sorted(ts) # monotonically increasing
|
||||
assert len(ts) == len(set(ts)) # no dup timesteps
|
||||
assert set(m["points"][0]) == {"t_global_s", "x", "y", "z"} # Three.js scene space
|
||||
|
||||
|
||||
def test_marker_paths_and_unknown_key():
|
||||
# Marker A: a circle radius 2 at y=1.5 (contract, spec M18).
|
||||
for tg in (0.0, 3.7, 11.2):
|
||||
x, y, z = synthetic.marker_position("hue:150", tg)
|
||||
assert y == pytest.approx(1.5)
|
||||
assert math.hypot(x, z) == pytest.approx(2.0, abs=1e-9)
|
||||
# Marker B follows a different path (never identical to A) so the two never lock.
|
||||
assert synthetic.marker_position("code:5", 3.7) != synthetic.marker_position("hue:150", 3.7)
|
||||
with pytest.raises(KeyError):
|
||||
synthetic.marker_position("code:99", 0.0)
|
||||
|
||||
|
||||
def test_marker_discs_are_small():
|
||||
# Both discs together must be well under 2% of frame area (spec M18: ≤2%, pitfall #6).
|
||||
w, h = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H
|
||||
r = synthetic._disc_radius_px(h)
|
||||
two_discs = 2 * math.pi * r * r
|
||||
assert two_discs / (w * h) < 0.02
|
||||
|
||||
|
||||
def test_projection_round_trip_recovers_ground_truth():
|
||||
"""THE foundation contract: rendered marker pixels back-project to the 3D truth.
|
||||
|
||||
For each marker at several t_global: project its ground-truth 3D point through every
|
||||
camera's interpolated pose (exactly as render_marker_overlay draws the pixel), then treat
|
||||
the in-frame pixels as a perfect detector's output and triangulate them back through the
|
||||
FROZEN geometry primitives. Recovery must be ~exact on the noise-free fixture — this is
|
||||
what makes lane H's M20 (median 3D error < 0.3) reachable by construction.
|
||||
"""
|
||||
from festival4d import geometry
|
||||
|
||||
w, h = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H
|
||||
fps, dur = float(config.SYNTH_FPS), config.SYNTH_DURATION_S
|
||||
# Poses per camera, generated the same way build() does (no DB, no ffmpeg needed).
|
||||
cam_poses = [synthetic.camera_track(i, dur, fps, w, h)
|
||||
for i in range(len(config.SYNTH_OFFSETS_MS))]
|
||||
|
||||
worst = 0.0
|
||||
checked = 0
|
||||
for key in synthetic.MARKER_KEYS:
|
||||
for t_global in (3.0, 6.5, 10.0, 14.25, 17.5): # inside every camera's valid span
|
||||
truth = np.array(synthetic.marker_position(key, t_global))
|
||||
rays = []
|
||||
for i, offset_ms in enumerate(config.SYNTH_OFFSETS_MS):
|
||||
t_video = config.t_video_from_global(t_global, offset_ms, 0.0)
|
||||
q, t, intr = synthetic._interp_pose(cam_poses[i], t_video)
|
||||
proj = synthetic.project_point(q, t, intr, truth)
|
||||
if proj is None:
|
||||
continue
|
||||
px, py, _ = proj
|
||||
if not (0.0 <= px < w and 0.0 <= py < h):
|
||||
continue # off this camera's frame
|
||||
fx, fy, cx, cy = intr
|
||||
rays.append(geometry.ray_from_pixel(q, t, fx, fy, cx, cy, px, py))
|
||||
assert len(rays) >= 2, f"{key} @ {t_global}s: need 2+ views, got {len(rays)}"
|
||||
point, gap = geometry.triangulate_rays(rays[0][0], rays[0][1], rays[1][0], rays[1][1])
|
||||
err = float(np.linalg.norm(point - truth))
|
||||
worst = max(worst, err)
|
||||
checked += 1
|
||||
assert checked >= 8
|
||||
# Noise-free synthetic geometry: recovery is essentially exact, far under the 0.3 M20 bar.
|
||||
assert worst < 1e-3, f"worst round-trip error {worst:.2e} (>1e-3)"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg/ffprobe required to render the fixture video")
|
||||
def test_rendered_marker_a_is_the_dominant_magenta_blob(tmp_path):
|
||||
"""The base-muting contract (BASE_SATURATION / BASE_BRIGHTNESS) actually renders a solvable
|
||||
fixture: testsrc2's own solid magenta/cyan colour bars must NOT out-mass marker A's disc, or
|
||||
lane H's colour detector locks onto a bar and M19/M20 fail. This emulates that detector —
|
||||
largest saturated-magenta blob in a real rendered frame — and asserts it lands on the disc.
|
||||
Guards against a future un-muting of the base swamping detection.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
dur = 2.0
|
||||
synthetic.build(base_dir=tmp_path, duration_s=dur, run_ffmpeg=True)
|
||||
try:
|
||||
w, h = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H
|
||||
poses = synthetic.camera_track(0, dur, float(config.SYNTH_FPS), w, h)
|
||||
t_video = 1.0
|
||||
q, t, intr = synthetic._interp_pose(poses, t_video)
|
||||
t_global = config.t_global_from_video(t_video, config.SYNTH_OFFSETS_MS[0], 0.0)
|
||||
proj = synthetic.project_point(q, t, intr,
|
||||
synthetic.marker_position(synthetic.MARKER_A_KEY, t_global))
|
||||
assert proj and 0 <= proj[0] < w and 0 <= proj[1] < h, "marker A not in cam0 frame at t=1s"
|
||||
|
||||
cap = cv2.VideoCapture(str(tmp_path / "raw" / "cam0.mp4"))
|
||||
cap.set(cv2.CAP_PROP_POS_MSEC, t_video * 1000.0)
|
||||
ok, img = cap.read()
|
||||
cap.release()
|
||||
assert ok, "could not read cam0 frame"
|
||||
|
||||
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||||
hh, ss, vv = hsv[:, :, 0].astype(int), hsv[:, :, 1], hsv[:, :, 2]
|
||||
# OpenCV-hue magenta ≈150, gated on high saturation AND value (pitfall #2).
|
||||
mask = (((hh >= 145) & (hh <= 155)) & (ss > 170) & (vv > 170)).astype(np.uint8)
|
||||
n, _lab, stats, cent = cv2.connectedComponentsWithStats(mask, 8)
|
||||
assert n > 1, "no saturated-magenta blob at all — marker A not rendered?"
|
||||
biggest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
|
||||
cx, cy = cent[biggest]
|
||||
dist = math.hypot(cx - proj[0], cy - proj[1])
|
||||
assert dist < 10.0, (
|
||||
f"dominant magenta blob at ({cx:.0f},{cy:.0f}) is {dist:.0f}px from marker A's "
|
||||
f"projected centre ({proj[0]:.0f},{proj[1]:.0f}) — base bars are swamping the disc"
|
||||
)
|
||||
finally:
|
||||
db.init_engine(config.DB_PATH) # build() rebound the engine; restore the shared one
|
||||
@ -3,11 +3,12 @@
|
||||
Parking lot for post-v0.1.0 extensions (spec §5 future-extensions). Not committed work — a place
|
||||
to capture direction so the prototype's scope stays honest. Roughly ordered by payoff-to-effort.
|
||||
|
||||
> **Status 2026-07-17:** splatting ✅ shipped (see `modelbeast-crossover.md`); spatial audio ✅
|
||||
> shipped (🎧, WebAudio master clock). Cinematic export → **M12**, multi-modal audio analysis →
|
||||
> **M10/M11**, anchor manager → **M13**, path persistence → **M16** — all now specced in
|
||||
> `plan/20-phase5.md`. Still parked here: realtime ingest, object/artist tracking,
|
||||
> per-project workspaces, sync-graph visualization, per-moment splats (crossover doc §3).
|
||||
> **Status 2026-07-17 (evening):** splatting ✅ shipped (see `modelbeast-crossover.md`);
|
||||
> spatial audio ✅ shipped (🎧, WebAudio master clock). Cinematic export ✅ **M12**,
|
||||
> multi-modal audio analysis ✅ **M10/M11**, anchor manager ✅ **M13**, path persistence ✅
|
||||
> **M16** — all SHIPPED in phase 5 (`plan/20-phase5.md`, suite floor 189). Still parked here:
|
||||
> realtime ingest, object/artist tracking, per-project workspaces, sync-graph visualization,
|
||||
> per-moment splats (crossover doc §3), and the new hardware ideas below.
|
||||
|
||||
## Neural rendering — Gaussian Splatting
|
||||
Replace the sparse COLMAP point cloud with a **3D Gaussian Splatting** model for photorealistic
|
||||
@ -40,6 +41,32 @@ Extend M8 (manual bbox → 3D anchor) with automatic per-frame tracking (e.g. a
|
||||
tracker), so an anchor follows the lead performer across time instead of being static. Enables
|
||||
a "follow artist" auto-camera mode in the 3D viewer.
|
||||
|
||||
## Stereo / spatial-video ingest (split the eyes, pin the scale)
|
||||
Spatial video (iPhone 15 Pro+ MV-HEVC, QooCam, Canon dual-fisheye) is two synced views with a
|
||||
**factory-known baseline**. Ingest tweak: detect multi-view files and split L/R into two
|
||||
pipeline videos (ffmpeg can demux the streams; identical offset, shared audio). Payoff beyond
|
||||
one extra viewpoint: COLMAP reconstructions are scale-free, and a known stereo baseline anchors
|
||||
**metric scale** for the whole scene — real-meter units for anchors, paths, and spatial audio
|
||||
distances. Cheap ingest change, high leverage.
|
||||
|
||||
## Depth-camera seeding (small venues only)
|
||||
RGB-D cams (ZED 2i, OAK-D, LiDAR phones) can seed/regularize splat training (depth-supervised
|
||||
3DGS → fewer floaters, faster convergence) and densify the point cloud without SfM guessing.
|
||||
Honest constraint: consumer depth dies past ~10–15 m (LiDAR ~5 m), so this only pays at club /
|
||||
small-venue range, not a festival main stage. Ingest: accept a per-video depth track or
|
||||
per-frame depth PNGs alongside the RGB.
|
||||
|
||||
## Microcontroller rig kit (Arduino as crew, not camera)
|
||||
An ESP32-CAM is not concert-grade — but a microcontroller on the rig earns its place three ways:
|
||||
- **Sync/calibration beacon:** an LED blinking a known pattern visible to all cameras =
|
||||
visual ground truth to validate the audio sync (and a free calibration target).
|
||||
- **Servo pan/tilt or slider mount:** one slowly-sweeping fixed camera contributes rich
|
||||
parallax from a single device; the per-frame pose track already handles moving cameras.
|
||||
- **Record start/stop trigger** for the whole rig.
|
||||
DIY camera *nodes* should be Raspberry Pi + **Global Shutter** camera module (kills
|
||||
rolling-shutter wobble under fast stage lights) pushing into the existing `/capture` endpoint
|
||||
(`FESTIVAL4D_CAPTURE=1`) — which was built for exactly this.
|
||||
|
||||
## Quality-of-life
|
||||
- **Standalone anchor manager** — the delete UI currently lives in the event correction panel;
|
||||
a dedicated always-available anchor list would decouple anchor management from events.
|
||||
|
||||
@ -252,12 +252,16 @@
|
||||
title="Moment FX: bursts and pulses on event markers (M15)">✨</button>
|
||||
<button class="btn" id="btn-anchors" style="display:none"
|
||||
title="Anchors & friend tags (M13)">⚓</button>
|
||||
<button class="btn" id="btn-friends" style="display:none"
|
||||
title="Friend tracks: ribbons, moving labels, follow-a-friend (M21/M22)">👣 Friends</button>
|
||||
<input type="file" id="path-file" accept="application/json,.json" hidden />
|
||||
</div>
|
||||
<div id="scene-hint">drag to orbit · 1–9 snap to camera · K add keyframe · Esc detach</div>
|
||||
<div id="annot-panel" class="write-ui"></div>
|
||||
<!-- M13 anchor manager mounts here (lane F); hidden until anchorPanel.js is filled -->
|
||||
<div id="anchor-panel" style="display:none"></div>
|
||||
<!-- M21 friends panel mounts here (lane J); hidden until friendTracks.js is filled -->
|
||||
<div id="friends-panel" style="display:none"></div>
|
||||
</div>
|
||||
<div id="grid-pane">
|
||||
<div id="video-grid"></div>
|
||||
|
||||
41
frontend/src/friendTracks.js
Normal file
41
frontend/src/friendTracks.js
Normal file
@ -0,0 +1,41 @@
|
||||
// Friend tracks (spec M21 ribbons + moving labels + panel, M22 follow-a-friend cam; lane J).
|
||||
// STUB shipped by foundation3 — lane J fills the body.
|
||||
//
|
||||
// main.js imports this, calls friendTracks.init() in buildUI (alongside the other lane
|
||||
// modules) and friendTracks.update(state.tGlobal) every animation frame — the fx.js pattern
|
||||
// (after transport.tick(), next to camPath.update / fx.update, before scene3d.update()).
|
||||
// While stubbed, both are safe no-ops and the hidden #btn-friends button stays hidden; a
|
||||
// broken or unfilled module degrades to no UI, exactly like the phase-5 stubs.
|
||||
//
|
||||
// Contract lane J must honor (frozen by foundation3):
|
||||
// - state.tracks is loaded at boot by main.js when manifest.has_tracks, in the
|
||||
// GET /api/tracks shape (contract #2):
|
||||
// [{ id, marker_key, label, color,
|
||||
// points: [{ t_global_s, x, y, z, quality, views }] }] // points sorted by t_global_s
|
||||
// - Scene objects go through scene3d.addObject / scene3d.removeObject ONLY, and every
|
||||
// helper is registered with scene3d.registerHelper so photo mode (M14) hides ribbons +
|
||||
// labels. (Read anchorPanel.js for the panel style/UX; do not edit it.)
|
||||
// - Rename / recolor / delete round-trip through PATCH / DELETE /api/tracks/{id}; those
|
||||
// controls carry the `write-ui` class so the capsule (M17) hides them while ribbons +
|
||||
// follow still work.
|
||||
// - Follow-a-friend (M22) shares scene3d pathMode with camPath — mirror camPath's "someone
|
||||
// else took the camera" contention checks exactly (pitfall #4), or two drivers fight.
|
||||
// - update(tGlobal) MUST NEVER THROW: it runs inside the master loop and a throw kills
|
||||
// playback. Guard the body and disable the module on error (fx.js does this).
|
||||
|
||||
export const friendTracks = {
|
||||
ready: false,
|
||||
|
||||
/** Called once by main.js (buildUI). Lane J (M21): unhide #btn-friends, build the friends
|
||||
* panel in #friends-panel, wire the toggle that renders ribbons + moving labels. */
|
||||
init() {
|
||||
// Stub: #btn-friends stays hidden until lane J fills M21. No-op is the safe degrade.
|
||||
},
|
||||
|
||||
/** Called by main.js every animation frame with the master clock. Lane J (M21/M22):
|
||||
* interpolate each track's position at tGlobal, move ribbons/labels, drive the chase cam.
|
||||
* MUST NEVER THROW. */
|
||||
update(_tGlobal) {
|
||||
// Stub: no tracks rendered yet (M21/M22).
|
||||
},
|
||||
};
|
||||
@ -23,8 +23,10 @@ import { anchorPanel } from "./anchorPanel.js";
|
||||
import { photoMode } from "./photoMode.js";
|
||||
import { fx } from "./fx.js";
|
||||
import { pathsStore } from "./pathsStore.js";
|
||||
// Phase-6 lane module (stub until lane J fills it — M21 ribbons/panel, M22 follow-a-friend).
|
||||
import { friendTracks } from "./friendTracks.js";
|
||||
|
||||
const laneModules = { director, exportVideo, anchorPanel, photoMode, fx, pathsStore };
|
||||
const laneModules = { director, exportVideo, anchorPanel, photoMode, fx, pathsStore, friendTracks };
|
||||
|
||||
async function getJSON(path) {
|
||||
const resp = await fetch(API_BASE + path);
|
||||
@ -58,6 +60,7 @@ async function boot() {
|
||||
link.style.display = "";
|
||||
}
|
||||
state.hasSplat = !!manifest.has_splat;
|
||||
state.hasTracks = !!manifest.has_tracks; // phase 6 (M18): any solved friend track
|
||||
// Capsule (M17): a static baked bundle has no write API — hide everything .write-ui.
|
||||
state.capsule = !!manifest.capsule;
|
||||
document.body.classList.toggle("capsule", state.capsule);
|
||||
@ -71,6 +74,16 @@ async function boot() {
|
||||
state.anchors = anchors;
|
||||
state.events = events;
|
||||
|
||||
// Friend tracks (M18): only fetched when the backend reports solved tracks, so a project
|
||||
// without them pays nothing. Failure is non-fatal — tracks are an overlay, not core.
|
||||
if (state.hasTracks) {
|
||||
try {
|
||||
state.tracks = await getJSON("/api/tracks");
|
||||
} catch (err) {
|
||||
console.warn("[festival4d] friend tracks failed to load", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hasPoses) {
|
||||
const lists = await Promise.all(
|
||||
state.videos.map((v) => getJSON(`/api/videos/${v.id}/poses`))
|
||||
@ -267,6 +280,7 @@ function startLoop() {
|
||||
transport.tick();
|
||||
for (const cell of cells) drawOverlay(cell);
|
||||
fx.update(state.tGlobal); // M15: playhead-crossing effects (no-op while stubbed)
|
||||
friendTracks.update(state.tGlobal); // M21/M22: move ribbons/labels + chase cam (no-op while stubbed)
|
||||
camPath.update(state.tGlobal); // M9: drive the viewer camera before the scene renders
|
||||
scene3d.update();
|
||||
timeline.update(state.tGlobal);
|
||||
|
||||
@ -26,6 +26,8 @@ export const state = {
|
||||
poses: {}, // id -> [{frame_idx, t_video_s, q:[w,x,y,z], t:[x,y,z], intrinsics, registered}]
|
||||
anchors: [], // [{id, label, x, y, z, color}]
|
||||
events: [], // [{id, t_global_s, duration_s, event_type, confidence, description, source}]
|
||||
hasTracks: false, // manifest.has_tracks (M18): any solved friend track => load + render tracks
|
||||
tracks: [], // [{id, marker_key, label, color, points:[{t_global_s,x,y,z,quality,views}]}] (M18)
|
||||
|
||||
// transport / playback
|
||||
tGlobal: 0,
|
||||
|
||||
214
plan/30-phase6.md
Normal file
214
plan/30-phase6.md
Normal file
@ -0,0 +1,214 @@
|
||||
# Phase 6 — Friend Tracks (M18–M24): markers → moving people in the 4D scene
|
||||
|
||||
Canonical spec for phase 6. **Everything frozen in `../OPUS_BUILD_INSTRUCTIONS.md` (M0–M9)
|
||||
and `20-phase5.md` (M10–M17 contracts) still applies.** Standing protocol (status files,
|
||||
directives loop, evidence discipline, per-agent worktrees) is unchanged from
|
||||
[`README.md`](README.md); the newest round of [`DIRECTIVES.md`](DIRECTIVES.md) always wins.
|
||||
|
||||
## The idea
|
||||
|
||||
A friend tag today is a *static* anchor. Phase 6 makes friends **move**: wearable markers
|
||||
(distinct-hue patches or blinking LED badges) are detected in every camera, associated across
|
||||
synced views, triangulated per timestep, and become **tracks** — `t_global → (x,y,z)` paths
|
||||
rendered as ribbons in the 3D scene with moving labels, a follow-a-friend camera mode, and
|
||||
capsule support. Same machinery as annotation resolution (M8), run automatically over time.
|
||||
|
||||
## Context — what exists that you build on (read, don't re-invent)
|
||||
|
||||
- `geometry.py` — COLMAP↔Three.js conversion, ray casting, triangulation primitives
|
||||
(`resolve.py` shows exactly how a bbox becomes a 3D point; your solver is that, per-timestep).
|
||||
- `frames.py` — frame sampling via ffmpeg (SfM uses it; detection re-uses it).
|
||||
- `config.t_video_from_global` / `t_global_from_video` — the FROZEN timebase; detections are
|
||||
found at `t_video`, tracks live at `t_global`.
|
||||
- The synthetic fixture (`synthetic.py`) — every acceptance below is against it first.
|
||||
- Suite floor is **189 passed**. It only goes up.
|
||||
|
||||
## Phase map
|
||||
|
||||
```
|
||||
Phase 6a (serial, ONE agent) Phase 6b (parallel, one agent per lane) Phase 6c (serial)
|
||||
┌───────────────────────┐ ┌ lane/h-tracker ─── M19 M20 ──────┐ ┌─────────────────┐
|
||||
│ foundation3 │ ├ lane/j-ribbons ─── M21 M22 ──────┤ │ integration3 │
|
||||
│ fixture ground truth +│──merge──┤ ├──merge─│ full pass + │
|
||||
│ contracts + stubs │ └ lane/k-badges ──── M23 M24 ──────┘ │ tracked field │
|
||||
│ branch: foundation3 │ (independent — merge in any order) │ test → v0.3.0 │
|
||||
└───────────────────────┘ └────────────────────────────────── └─────────────────┘
|
||||
```
|
||||
|
||||
Lanes depend **only** on foundation3, never on each other.
|
||||
|
||||
## Milestones
|
||||
|
||||
### M18 — foundation3: fixture ground truth, contracts, stubs (serial, ONE agent)
|
||||
1. **Synthetic fixture upgrade** (`synthetic.py` — coordinator-sanctioned edit): render **two
|
||||
moving markers** into every camera's video via the existing camera projection:
|
||||
- marker A: a solid bright disc (frozen hue, e.g. pure magenta #ff00ff) following a known
|
||||
smooth trajectory across the stage (e.g. a slow circle, radius ~2, y≈1.5);
|
||||
- marker B: a **blinking** white/bright disc following a different known path, blinking the
|
||||
frozen protocol below with ID=5.
|
||||
Markers must be small (≤2 % of frame area) and rendered *after* the existing content so the
|
||||
full existing suite stays green (offsets, events, poses, SfM tests untouched). Write ground
|
||||
truth to `data/work/track_truth.json`: `{"markers": [{"marker_key": ..., "points":
|
||||
[{"t_global_s", "x", "y", "z"}]}]}` (Three.js scene space).
|
||||
2. **DB:** `tracks` + `track_points` tables (contract #2) + `db.py` helpers
|
||||
(`add_track`, `get_tracks`, `set_track_points`, `patch_track`, `delete_track`).
|
||||
3. **API:** `GET /api/tracks`, `PATCH /api/tracks/{id}` `{label?, color?}`,
|
||||
`DELETE /api/tracks/{id}`, `POST /api/tracks/solve` (dispatches into the lane-H stub with
|
||||
the house degradation: valid empty result + note while stubbed). Manifest gains
|
||||
`"has_tracks": bool` — update `test_api.py`'s key-set lock consciously in the same commit.
|
||||
4. **CLI:** `python -m festival4d track [--detect-only|--solve-only]` → lane-H stubs.
|
||||
5. **Backend stubs:** `tracker_detect.py` (`run_detect()`), `tracker_solve.py`
|
||||
(`run_solve()`) — `NotImplementedError` with the full contract in the docstring.
|
||||
6. **Frontend:** stub `frontend/src/friendTracks.js` imported from `main.js`, `init()` +
|
||||
per-frame `friendTracks.update(tGlobal)` wired into the master loop (fx.js pattern);
|
||||
hidden **👣 Friends** button `#btn-friends` + empty `#friends-panel` container in
|
||||
`index.html`; `state.tracks = []` loaded at boot when `manifest.has_tracks`.
|
||||
7. **Capsule:** add `api/tracks` to `capsule.py`'s bake list (additive; update lane G's
|
||||
bundle-structure test consciously in the same commit, CR-style note in the commit message).
|
||||
8. Tests for every new route shape; suite ≥ 189 + new tests. Merge fast — three lanes wait.
|
||||
|
||||
### M19 — Marker detection (lane H, backend)
|
||||
Fill `tracker_detect.py`. `run_detect()`: sample frames (~8 fps via `frames.py` machinery) from
|
||||
every video; detect markers two ways: **color mode** (HSV blob of the frozen marker hues;
|
||||
`marker_key = "hue:<bucket>"`) and **blink mode** (per-blob brightness over a sliding window,
|
||||
decode the frozen OOK protocol; `marker_key = "code:<id>"`). Write
|
||||
`data/work/detections.json` (contract #3). OpenCV (`opencv-python-headless`) is already a
|
||||
dependency. No markers → valid-empty detections, never a crash.
|
||||
**Accept:** on the synthetic fixture, ≥90 % of sampled frames where a marker is visible yield
|
||||
a detection within 3 px (normalized ≤0.008) of the projected ground-truth center; blink ID 5
|
||||
decoded; tests in `test_tracker_detect.py`.
|
||||
|
||||
### M20 — Track solving (lane H, backend)
|
||||
Fill `tracker_solve.py`. `run_solve()`: load detections; group by `marker_key`; per time bucket
|
||||
(≈0.25 s) collect same-key detections across cameras at matching `t_global`; **2+ views →
|
||||
triangulate** through `geometry` primitives (the `resolve.py` pattern — never inline pose
|
||||
math); **1 view → ray ∩ ground plane** (contract #5); smooth each track (moving average or
|
||||
Catmull-Rom, your call — document it); write `tracks`/`track_points` through `db` helpers;
|
||||
solid color for hue markers, palette color for code markers. `POST /api/tracks/solve` and the
|
||||
CLI now run detect→solve end-to-end.
|
||||
**Accept:** synthetic recovery — median 3D error of both tracks < 0.3 scene units against
|
||||
`track_truth.json`, ≥80 % temporal coverage of each marker's visible span; single-view
|
||||
fallback exercised by a test that hides all-but-one camera's detections; degradation (no
|
||||
detections → zero tracks + note); tests in `test_tracker_solve.py`.
|
||||
|
||||
### M21 — Ribbons, moving labels, friends panel (lane J, frontend)
|
||||
Fill `friendTracks.js`. On init (when `manifest.has_tracks`): unhide 👣; fetch
|
||||
`/api/tracks` into `state.tracks`. Toggle renders per-track: a **ribbon** polyline through the
|
||||
points (track color) + a **moving marker** (sphere + label sprite, position interpolated at
|
||||
the current `t_global`, hidden outside the track's time span) — objects via
|
||||
`scene3d.addObject/removeObject` only, registered with `scene3d.registerHelper` so photo mode
|
||||
hides them. Panel in `#friends-panel` (anchorPanel.js is the style/UX reference — read it,
|
||||
don't edit it): list tracks (dot, label, time span), rename/recolor via
|
||||
`PATCH /api/tracks/{id}` (controls carry `write-ui`), delete, and a **Follow** button per row.
|
||||
**Accept:** live-browser evidence — ribbons + moving labels track the synthetic markers in
|
||||
play and scrub; rename/recolor/delete round-trip; photo mode hides ribbons; capsule mode
|
||||
hides write controls but keeps ribbons/follow.
|
||||
|
||||
### M22 — Follow-a-friend camera (lane J, frontend)
|
||||
Chase cam on any track: `scene3d.enterPathMode()` hands you the camera (the camPath.js
|
||||
pattern — read it); every `update(tGlobal)` compute the track position, place the camera at a
|
||||
smoothed offset (behind/above, ~4 units, low-pass the position so it glides), look at the
|
||||
friend (`applyCameraPose`). Esc / Free-roam / snap-to-camera exits (pathMode already handles
|
||||
contention — mirror camPath's "someone else took the camera" check).
|
||||
**Accept:** live-browser evidence — camera glides after the moving marker in play and scrub;
|
||||
clean handoff to/from free-roam, snap-to-cam, and camPath playback; no fighting.
|
||||
|
||||
### M23 — LED badge firmware + hardware kit (lane K, hardware/docs)
|
||||
`hardware/badge/badge.ino` — ESP32/ATtiny-compatible Arduino sketch blinking the frozen OOK
|
||||
protocol (contract #4): configurable 6-bit ID, bright single LED or WS2812; plus
|
||||
`hardware/beacon/beacon.ino` — the sync/calibration beacon (distinct reserved ID 63,
|
||||
double-brightness). `docs/hardware.md`: parts list (boards, LEDs, battery, diffuser), build
|
||||
photos placeholders, wearing guidance (chest/hat height, diffusion, spacing), and the
|
||||
camera-side truth: what ranges/framerates the protocol survives (≥24 fps, exposure caveats).
|
||||
**Accept:** sketches compile (`arduino-cli compile` for esp32 + attiny if available — else
|
||||
document exact compile commands and mark unverified); protocol constants byte-identical to
|
||||
contract #4 (import/duplicate them in ONE header with a test-facing copy check documented).
|
||||
|
||||
### M24 — Bench self-test tool (lane K, backend script)
|
||||
`scripts/badge_selftest.py`: given a short phone clip of a badge on a desk, run the SAME
|
||||
decoder path as lane H (`tracker_detect` blink mode) and print the decoded ID + confidence —
|
||||
John's hardware smoke test before a shoot. Works on a synthetic self-test too:
|
||||
`--synthetic` renders a blinking clip via the fixture helpers and asserts round-trip.
|
||||
**Accept:** `uv run python scripts/badge_selftest.py --synthetic` decodes the rendered ID;
|
||||
clear failure text for: no blob, ID mismatch, too-short clip. Small test in
|
||||
`test_badge_selftest.py` (synthetic path only).
|
||||
|
||||
## Contracts (frozen at foundation3 merge)
|
||||
|
||||
1. **Marker hues (color mode):** magenta `#ff00ff` and cyan `#00ffff` are the two supported
|
||||
fixture hues; real shoots may configure others via `FESTIVAL4D_MARKER_HUES` (comma-sep hex).
|
||||
2. **Schema:**
|
||||
`tracks(id INTEGER PK, marker_key TEXT NOT NULL, label TEXT, color TEXT, created_at TEXT)`
|
||||
`track_points(id INTEGER PK, track_id FK, t_global_s FLOAT, x FLOAT, y FLOAT, z FLOAT,
|
||||
quality FLOAT, views INTEGER)` — points ordered by `t_global_s`; `views` = cameras used
|
||||
(1 = ground-plane fallback); coordinates in **Three.js scene space**.
|
||||
`GET /api/tracks` → `[{id, marker_key, label, color, points: [{t_global_s,x,y,z,quality,views}]}]`.
|
||||
3. **detections.json:** `{"videos": {"<video_id>": [{"t_video_s": float, "marker_key": str,
|
||||
"cx": float, "cy": float, "area": float, "conf": float 0..1}]}, "generated_by":
|
||||
"festival4d.tracker_detect"}` — `cx`/`cy` normalized 0..1 (bbox-annotation convention).
|
||||
4. **Blink protocol (OOK, camera-decodable):** bit period **133 ms** (4 frames @ 30 fps);
|
||||
word = preamble `11100` + 6-bit ID (MSB first) + even parity = 12 bits, repeated
|
||||
continuously (~1.6 s/word). ID 63 reserved for the sync beacon. Decoder needs ≥24 fps
|
||||
video and ≥1 full word of visibility.
|
||||
5. **Ground plane:** the synthetic fixture's ground is `y = 0` (scene space). For real
|
||||
projects the solver estimates it as the 5th-percentile-y horizontal plane of the point
|
||||
cloud and records it in its result dict. Single-view fallback intersects the pixel ray
|
||||
with this plane; such points get `views = 1` and `quality ≤ 0.5`.
|
||||
6. **Frozen during 6b:** everything frozen in phases 1–5 **plus** all phase-5 lane modules
|
||||
(`director.js`, `exportVideo.js`, `anchorPanel.js`, `photoMode.js`, `fx.js`,
|
||||
`pathsStore.js`, `audio_features.py`, `director.py`, `capsule.py`) and foundation3's edits
|
||||
(`synthetic.py`, `main.js`, `index.html`, api/cli/db). Lanes fill their own new files only.
|
||||
Believe a frozen file must change → `plan/CHANGE_REQUESTS.md`, work around, integration3
|
||||
adjudicates. Stub-era test assertions superseded by your milestone landing follow the
|
||||
CR-1/-4/-5 pattern: minimal edit + CR entry.
|
||||
|
||||
## integration3 scope (Phase 6c)
|
||||
Merge order irrelevant; resolve CRs; full synthetic pass (evidence per milestone); raise the
|
||||
suite floor; **tracked field test** — real clips with at least one badge/marker in frame
|
||||
(combine with the still-open phase-5 field test); failures → `plan/ISSUES.md`; propose
|
||||
`v0.3.0` (with `v0.2.0` if the phase-5 field test passes in the same session).
|
||||
|
||||
## File ownership (Phase 6b)
|
||||
|
||||
| Owner | Files |
|
||||
|---|---|
|
||||
| Lane H | `backend/festival4d/tracker_detect.py`, `tracker_solve.py`, `backend/tests/test_tracker_detect.py`, `test_tracker_solve.py` |
|
||||
| Lane J | `frontend/src/friendTracks.js` |
|
||||
| Lane K | `hardware/**`, `docs/hardware.md`, `scripts/badge_selftest.py`, `backend/tests/test_badge_selftest.py` |
|
||||
| Every agent | its own `plan/status/<lane>.md` |
|
||||
| **Coordinator only** | `plan/DIRECTIVES.md` |
|
||||
| **Frozen during 6b** | contract #6 above |
|
||||
|
||||
## Phase-6 pitfalls
|
||||
|
||||
1. **Time alignment is the whole game.** Detections happen at `t_video`; association happens
|
||||
at `t_global`. One `t_video_from_global` inversion mistake silently shears every track.
|
||||
Use the frozen helpers; test association against ground truth, not against itself.
|
||||
2. **Stage lighting lies about color.** The color detector must gate on saturation+value, not
|
||||
hue alone, and the acceptance is on the *synthetic* fixture — note in your status file that
|
||||
real-world hue robustness is a known field-test risk (that's why blink mode exists).
|
||||
3. **Blink decoding vs. video fps:** cameras at 24–60 fps sample the 133 ms bit period
|
||||
differently. Decode by integrating brightness per bit window using the video's actual fps
|
||||
(`meta.fps`), never by counting frames.
|
||||
4. **Don't fight the camera owners.** Follow-mode (M22) shares `pathMode` with camPath —
|
||||
mirror camPath's contention checks exactly, or two drivers will interleave writes.
|
||||
5. **rAF throttle (standing):** verify with the `__f4d` pump in hidden panes
|
||||
(`localStorage f4dDebug=1`); the WebAudio timer exemption gives you ~50 Hz `setTimeout`.
|
||||
6. **Fixture compatibility:** foundation3's synthetic edit runs UNDER the existing 189 tests —
|
||||
markers must not move offsets, events, poses, or break SfM-adjacent tests.
|
||||
|
||||
## Kickoff prompts (copy-paste one per Opus 4.8 session, in run order)
|
||||
|
||||
**foundation3 (first, alone):**
|
||||
> Clone ssh://git@100.71.119.27:222/monster/festifun.git. Read OPUS_BUILD_INSTRUCTIONS.md,
|
||||
> plan/20-phase5.md, plan/30-phase6.md (M18 is yours), and the latest round of
|
||||
> plan/DIRECTIVES.md. Execute foundation3 on branch `foundation3`; maintain
|
||||
> plan/status/foundation3.md per plan/status/TEMPLATE.md; suite ≥ 189 + your tests; push the
|
||||
> branch and report — the coordinator reviews and merges.
|
||||
|
||||
**lane H / lane J / lane K (parallel, after foundation3 merges):**
|
||||
> Clone ssh://git@100.71.119.27:222/monster/festifun.git and create your own git worktree
|
||||
> before any edit. Read OPUS_BUILD_INSTRUCTIONS.md, plan/30-phase6.md, plan/lane-<X>.md, and
|
||||
> the latest round of plan/DIRECTIVES.md. Execute your lane on branch `lane/<x>-<name>`;
|
||||
> maintain plan/status/lane-<X>.md; obey the ownership table and frozen-files contract; push
|
||||
> your branch and report — the coordinator reviews and merges. Do not merge to main.
|
||||
@ -108,4 +108,28 @@ Instead append an entry here and work around it locally; the integration agent a
|
||||
- **Workaround in place:** applied the minimal edit directly per the CR-1/CR-4 precedent (test
|
||||
file, not a frozen contract file; `api.py`/`cli.py` byte-for-byte unchanged), including the
|
||||
merge reconciliation with CR-4's side of the same loop. Suite green on the merged tree.
|
||||
- **Decision:** (pending — coordinator/integration2)
|
||||
- **Decision:** **APPLIED — RATIFIED by coordinator at merge** (2026-07-17). Same
|
||||
supersession pattern as CR-1/CR-4; the shape-locked director test is stronger than the
|
||||
stub assertion it replaces. Suite 169 → **189** on the merged tree.
|
||||
|
||||
### CR-6 — foundation3 — 2026-07-17 — Conscious test-lock updates for the two additive M18 contract fields
|
||||
- **File:** `backend/tests/test_api.py::test_manifest_shape` (manifest exact-set lock) and
|
||||
`backend/tests/test_capsule.py::test_bundle_structure` (baked-paths list).
|
||||
- **Problem:** Not a supersession — these are the two *deliberate* lock edits M18 mandates
|
||||
(spec `plan/30-phase6.md` steps 3 & 7). foundation3 adds an additive manifest field
|
||||
`has_tracks` (drives the frontend loading friend tracks) and an additive capsule bake
|
||||
`api/tracks` (so ribbons + follow work zero-backend). The manifest key-set lock is
|
||||
intentionally exact ("has already caught two silent drifts"), and the capsule
|
||||
bundle-structure test enumerates baked paths, so both must be updated **consciously in the
|
||||
same commit** as the source change — the spec calls for exactly this + a CR note.
|
||||
- **Proposed change:** (a) add `"has_tracks"` to the `test_manifest_shape` exact-set and assert
|
||||
it is `False` on the live synthetic build (no solver has run — solving is lane H); (b) add
|
||||
`"api/tracks"` to the `test_bundle_structure` baked-paths tuple so the test now asserts the
|
||||
new read-only bake exists. No behavior removed; both are stronger post-edit.
|
||||
- **Workaround in place:** N/A — these are foundation-sanctioned edits to test files (not to a
|
||||
frozen *contract* file: `db.py`/`api.py`/`capsule.py` were extended additively per M18, and
|
||||
`synthetic.py`'s marker code is a coordinator-sanctioned M18 edit). Applied directly in the
|
||||
foundation3 commit per the house pattern; flagged here for integration3 to ratify.
|
||||
- **Decision:** **APPLIED — RATIFIED by coordinator at merge** (2026-07-17). Both edits are
|
||||
the spec-mandated conscious lock updates (30-phase6.md steps 3 & 7), additive and stronger
|
||||
post-edit. Suite 189 → **209** on the merged tree.
|
||||
|
||||
@ -4,6 +4,76 @@ Written **only** by the coordinator (the human's planning session). Agents: read
|
||||
|
||||
---
|
||||
|
||||
## Round 6 — 2026-07-17 — PHASE 6 planned: Friend Tracks (M18–M24)
|
||||
|
||||
**Spec:** `plan/30-phase6.md` (canonical). Lane briefs: `plan/lane-H-tracker.md`,
|
||||
`plan/lane-J-ribbons.md`, `plan/lane-K-badges.md`. Kickoff prompts are at the bottom of the
|
||||
spec — the human launches each Opus session with one.
|
||||
|
||||
**Directives:**
|
||||
1. **Now: `foundation3` only** (one agent, branch `foundation3`, M18). Contracts correctness
|
||||
beats speed — three lanes hard-depend on your fixture ground truth, schema, detections
|
||||
shape, and blink protocol. The synthetic-fixture edit must keep all 189 existing tests
|
||||
green (spec pitfall #6). Maintain `plan/status/foundation3.md`. **Push your branch; do NOT
|
||||
merge to main** — this phase the coordinator merges after review (see #4).
|
||||
2. **After foundation3 merges — lanes in priority order** (all three concurrently if capacity
|
||||
allows): **H** (tracker — longest, validates every contract), **J** (ribbons/follow — the
|
||||
visible payoff), **K** (badges — independent, hardware-facing). Per-agent worktrees remain
|
||||
MANDATORY.
|
||||
3. **Merge policy (changed from Round 4):** agents do NOT merge to main and do NOT tag.
|
||||
Push your lane branch + status file, report completion to the human; the human pings the
|
||||
coordinator session, which reviews (independent suite re-run, ownership check, CR
|
||||
adjudication) and merges in completion order. Everything else from Round 0 protocol
|
||||
(status cadence, blocker rule, evidence discipline) unchanged.
|
||||
4. **Review loop:** coordinator reads `plan/status/<lane>.md` + the branch diff on each ping;
|
||||
findings come back as a new DIRECTIVES round or direct merge. Stub-era assertions
|
||||
superseded by a landing milestone: CR entry + minimal edit (CR-1/-4/-5 house pattern).
|
||||
5. **integration3** (after all three merge): full synthetic pass, floor raise, tracked field
|
||||
test (fold in the still-open phase-5 field test), `v0.3.0` proposal. `v0.2.0` remains held
|
||||
per Round 5.
|
||||
|
||||
**For the human:** (a) launch foundation3 first, alone, with its kickoff prompt; tell the
|
||||
coordinator when its status file says ready; (b) then launch H, J, K in parallel; (c) the
|
||||
field-test gate is unchanged — real clips in `data/raw/`, now ideally with a marker or badge
|
||||
in frame; (d) if you order badge parts, `docs/hardware.md` (lane K) will have the list.
|
||||
|
||||
## Round 5 — 2026-07-17 — PHASE 5 COMPLETE on synthetic; floor 189; v0.2.0 waits on the field test
|
||||
|
||||
**Execution report (all same-day):** foundation2 → lanes E/G/F in parallel worktrees (per
|
||||
directive 3) → integration2. Every lane merged after independent coordinator verification
|
||||
(suite re-run in the lane's worktree, then on the merged tree, live-browser checks per
|
||||
feature). Full evidence: plan/status/{foundation2,lane-E,lane-F,lane-G,integration2}.md.
|
||||
|
||||
**Accepted:** M10–M17 all pass acceptance on the synthetic fixture. Highlights: beats recover
|
||||
the 120 BPM ground truth (worst beat 30 ms); director output is beat-snapped and shape-locked
|
||||
through frozen geometry helpers; export produced a 13.98 s vp9/opus webm (±5% ✓, audio track
|
||||
live) driven headless via the `__f4d` pump; the capsule boots with ZERO off-origin requests,
|
||||
seeks over Range, hides all write UI. Lane F self-verified live in isolated servers — model
|
||||
evidence discipline, worth repeating.
|
||||
|
||||
**Ratified:** CR-4 (lane G) and CR-5 (lane E) — stub-era test assertions superseded by
|
||||
milestones landing, CR-1 precedent. **The test floor is now 189.** Build stays clean.
|
||||
|
||||
**Standing decisions:**
|
||||
1. **v0.2.0 tag is HELD** until the real-footage field test runs (it is integration2's actual
|
||||
milestone; synthetic-only doesn't earn the tag). Failures go to plan/ISSUES.md, not fixes.
|
||||
2. Stub-era assertions in foundation tests are now an acknowledged lifecycle: when a milestone
|
||||
lands, retiring its stub assertion via a CR entry + pointer to the real coverage is the
|
||||
house pattern (CR-1/-4/-5). Foundation agents: prefer writing stub tests that survive the
|
||||
landing (assert shape, not stub-ness) so future lanes don't need CRs.
|
||||
3. `.gitignore` gained `data/capsule/` and `.claude/worktrees/`.
|
||||
|
||||
**For the human (the entire remaining critical path):**
|
||||
(a) **Drop 2–4 real overlapping clips into `data/raw/`** — the field test and v0.2.0 gate on
|
||||
this and only this.
|
||||
(b) Focused-window eyeball pass (10 min, headphones): sync panel stays green during playback;
|
||||
🎧 spatial audio while flying around; 🎬 auto-path ride; ⏺ export and CHECK THE FRAMES
|
||||
(headless verified duration/audio only — rendered frame quality needs human eyes);
|
||||
📷 photo PNG; ✨ FX bursts at full rAF rate; anchor panel roundtrip.
|
||||
(c) Optional but high-impact: train a splat for the real project
|
||||
(`scripts/splat_via_modelbeast.sh`) before capsuling it — photo mode + capsule get
|
||||
dramatically better.
|
||||
|
||||
## Round 4 — 2026-07-17 — Fresh-eyes upgrades landed; PHASE 5 begins (M10–M17)
|
||||
|
||||
**Coordinator work landed directly on `main` (self-reviewed + live-verified):**
|
||||
|
||||
@ -2,9 +2,12 @@
|
||||
|
||||
**Read [`../OPUS_BUILD_INSTRUCTIONS.md`](../OPUS_BUILD_INSTRUCTIONS.md) first — it is the canonical spec** (architecture, repo layout, data model, milestone details M0–M9, pitfalls). This directory only organizes *who builds what, in what order, without colliding*. Lane briefs reference spec milestones by number; they do not restate them.
|
||||
|
||||
> **Current phase: 5** (M10–M17) — spec and organization in [`20-phase5.md`](20-phase5.md), lane
|
||||
> briefs `lane-E/F/G-*.md`. Phases 1–4 below are complete (v0.1.0 + polish); their protocol
|
||||
> sections (git, status, directives loop, evidence discipline) remain the standing rules.
|
||||
> **Current phase: 6** (M18–M24, Friend Tracks) — spec and organization in
|
||||
> [`30-phase6.md`](30-phase6.md), lane briefs `lane-H/J/K-*.md`, run order in DIRECTIVES
|
||||
> Round 6 (note: this phase the **coordinator merges**, agents only push branches).
|
||||
> Phases 1–5 below are complete (v0.1.0 shipped; phase 5 M10–M17 accepted on synthetic,
|
||||
> suite floor 189, v0.2.0 held for the field test); their protocol sections (git, status,
|
||||
> directives loop, evidence discipline) remain the standing rules.
|
||||
|
||||
## Phase map
|
||||
|
||||
|
||||
37
plan/lane-H-tracker.md
Normal file
37
plan/lane-H-tracker.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Lane H — Tracker (M19 detection, M20 solving)
|
||||
|
||||
You turn wearable markers in the videos into 3D friend tracks. Backend only.
|
||||
Spec: `30-phase6.md` M19/M20 + contracts #1–#5. Branch `lane/h-tracker`, own worktree.
|
||||
|
||||
## What foundation3 gives you
|
||||
- Stubs `tracker_detect.py` / `tracker_solve.py` with the contracts in their docstrings;
|
||||
API (`POST /api/tracks/solve` etc.), CLI (`track`), DB helpers, and the synthetic fixture's
|
||||
two moving markers + `data/work/track_truth.json` ground truth — all already wired.
|
||||
- You fill the two stub bodies and write the two test files. Nothing else.
|
||||
|
||||
## M19 — detection, the parts that bite
|
||||
- Sample frames with the `frames.py` machinery (~8 fps is plenty; document your rate).
|
||||
- Color mode: convert to HSV, gate on **saturation AND value** before hue (pitfall #2);
|
||||
connected components; centroid → normalized cx/cy. Hue buckets from contract #1 +
|
||||
`FESTIVAL4D_MARKER_HUES`.
|
||||
- Blink mode: track candidate bright blobs across the sampled window, integrate brightness
|
||||
per 133 ms bit window **using the video's real fps** (pitfall #3), correlate against the
|
||||
preamble, check parity, emit `code:<id>`.
|
||||
- Empty/dark/markerless video → valid-empty detections. Never crash.
|
||||
|
||||
## M20 — solving, the parts that bite
|
||||
- `t_video → t_global` through the frozen helpers ONLY (pitfall #1).
|
||||
- Triangulation through `geometry` primitives exactly as `resolve.py` does — read it first.
|
||||
- Bucket ≈0.25 s; 2+ cameras → triangulate (record `views`, `quality` from residual);
|
||||
1 camera → ray ∩ ground plane per contract #5 (`views=1`, `quality ≤ 0.5`).
|
||||
- Smooth per track; write through `db` helpers; re-solve replaces that marker's tracks
|
||||
(idempotent — don't stack duplicates on every run).
|
||||
|
||||
## Acceptance (evidence in plan/status/lane-H.md)
|
||||
- [ ] Detection: ≥90 % recall on visible synthetic markers, ≤0.008 normalized center error;
|
||||
blink ID 5 decoded (`test_tracker_detect.py`)
|
||||
- [ ] Solve: median 3D error < 0.3 vs `track_truth.json`, ≥80 % coverage; single-view
|
||||
fallback test; idempotent re-solve; degradations (`test_tracker_solve.py`)
|
||||
- [ ] CLI + `POST /api/tracks/solve` run detect→solve end-to-end on the fixture
|
||||
- [ ] Suite ≥ foundation3's count + yours, green; stub-era assertions superseded by your
|
||||
landing → CR entry (CR-1/-4/-5 pattern)
|
||||
38
plan/lane-J-ribbons.md
Normal file
38
plan/lane-J-ribbons.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Lane J — Ribbons & follow-a-friend (M21, M22)
|
||||
|
||||
You make tracks visible and rideable. Pure frontend — ONE file: `frontend/src/friendTracks.js`.
|
||||
Spec: `30-phase6.md` M21/M22 + contract #2. Branch `lane/j-ribbons`, own worktree.
|
||||
|
||||
## What foundation3 gives you
|
||||
- Your stub module imported from main.js with `init()` + per-frame `update(tGlobal)` already
|
||||
called; hidden `#btn-friends` + `#friends-panel` in index.html; `state.tracks` loaded at
|
||||
boot when `manifest.has_tracks`.
|
||||
- Read-only building blocks you may CALL but never edit: `scene3d.addObject/removeObject`,
|
||||
`registerHelper` (photo mode hides you for free), `enterPathMode`/`exitPathMode`/
|
||||
`applyCameraPose` (the camPath.js pattern — read that file first), `focusOn`;
|
||||
`PATCH/DELETE /api/tracks/{id}`. anchorPanel.js is your UX/style reference (inject your own
|
||||
<style> tag; reuse the CSS vars). Mark mutating controls `write-ui`.
|
||||
|
||||
## M21 — ribbons + panel, the parts that bite
|
||||
- Interpolate the moving marker between track points at the current tGlobal (linear is fine);
|
||||
HIDE it outside the track's span — don't park it at the ends.
|
||||
- Rebuild ribbons only on tracks-changed, not per frame; per-frame work = one position lerp
|
||||
per visible track (fx.js shows the hot-path discipline).
|
||||
- Empty tracks (solver not run) → panel says so, no errors; capsule mode: ribbons + follow
|
||||
stay, write controls hidden.
|
||||
|
||||
## M22 — follow cam, the parts that bite
|
||||
- Mirror camPath's contention handling EXACTLY: if `scene3d.pathMode` goes false under you
|
||||
(snap-to-cam, Esc, free-roam), stop following without a fight (pitfall #4).
|
||||
- Low-pass the camera position (e.g. lerp factor ~0.08/frame) so it glides; look at the
|
||||
friend, offset ~4 units behind/above; scrubbing far must not whiplash (clamp the lerp on
|
||||
big jumps: teleport if the target moved > ~5 units).
|
||||
- Starting a camPath playback or clicking 🎬 must cleanly cancel follow mode (and vice versa).
|
||||
|
||||
## Acceptance (evidence in plan/status/lane-J.md — you likely can't drive a browser;
|
||||
write defensively, list exactly what the coordinator must verify live)
|
||||
- [ ] Ribbons + moving labels track the synthetic markers in play AND scrub
|
||||
- [ ] Panel: rename/recolor (PATCH) + delete round-trip; `write-ui` on mutators
|
||||
- [ ] Follow glides after a friend; clean handoffs with free-roam / snap / camPath / 🎬
|
||||
- [ ] Photo mode hides ribbons (registerHelper); capsule keeps read-only features
|
||||
- [ ] `npm run build` clean; backend suite unchanged and green
|
||||
40
plan/lane-K-badges.md
Normal file
40
plan/lane-K-badges.md
Normal file
@ -0,0 +1,40 @@
|
||||
# Lane K — LED badges & bench kit (M23 firmware+docs, M24 self-test)
|
||||
|
||||
You build the wearable side: badge firmware speaking the frozen blink protocol, the hardware
|
||||
guide, and the bench self-test John runs before a shoot. Spec: `30-phase6.md` M23/M24 +
|
||||
contract #4. Branch `lane/k-badges`, own worktree.
|
||||
|
||||
## M23 — firmware + docs, the parts that bite
|
||||
- Contract #4 is FROZEN: 133 ms bit, `11100` preamble + 6-bit ID + even parity, continuous
|
||||
repeat, ID 63 = sync beacon. Keep the constants in ONE header (`hardware/badge/protocol.h`)
|
||||
shared by badge.ino and beacon.ino; document the manual-sync rule with
|
||||
`tracker_detect.py`'s decoder constants (can't import across languages — state both
|
||||
locations in the header comment and in docs/hardware.md).
|
||||
- badge.ino: plain Arduino APIs (millis()-paced, no delay()-drift), single bright LED on a
|
||||
PWM pin OR a WS2812 (compile-time #define), ID set by #define (document per-badge flashing).
|
||||
Hardware is never ideal on paper: expose a `BIT_TRIM_US` calibration knob for clock drift.
|
||||
- beacon.ino: ID 63, double LED brightness/size guidance.
|
||||
- docs/hardware.md: parts list with rough prices (ESP32 dev board / ATtiny85, LED + resistor
|
||||
or WS2812, LiPo/coin cell, diffuser ping-pong ball), wiring, flashing (`arduino-cli`
|
||||
commands), wearing guidance (chest/hat, diffusion, one badge per person, ≥24 fps cameras,
|
||||
exposure caveat: blown-out video kills OOK contrast).
|
||||
- Compile check: `arduino-cli compile --fqbn esp32:esp32:esp32` (and attiny if the core is
|
||||
installable). If arduino-cli isn't available in your environment, document the exact
|
||||
commands and mark the box unverified — do NOT claim compiled without output.
|
||||
|
||||
## M24 — bench self-test, the parts that bite
|
||||
- `scripts/badge_selftest.py` must run lane H's REAL decoder (`tracker_detect` blink path) —
|
||||
import it, don't re-implement; if lane H hasn't merged yet, code against the frozen
|
||||
contract #3/#4 shapes and mark the integration box pending (integration3 re-runs it).
|
||||
- `--synthetic` mode: render a small blinking clip using the fixture's marker-rendering
|
||||
helpers (foundation3 exposes them in synthetic.py) and assert the ID round-trips.
|
||||
- Clear, John-readable failure messages: no blob found / decoded wrong ID / clip too short
|
||||
(< 1 word). Exit codes: 0 ok, 1 decode-fail, 2 usage.
|
||||
|
||||
## Acceptance (evidence in plan/status/lane-K.md)
|
||||
- [ ] protocol.h constants byte-identical to contract #4; badge + beacon sketches compile
|
||||
(output pasted) or compile commands documented + marked unverified
|
||||
- [ ] docs/hardware.md complete enough that John can order parts from it
|
||||
- [ ] `uv run python scripts/badge_selftest.py --synthetic` decodes the rendered ID
|
||||
(`test_badge_selftest.py` covers it); failure paths print actionable text
|
||||
- [ ] Backend suite green; `npm` untouched
|
||||
57
plan/status/foundation3.md
Normal file
57
plan/status/foundation3.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Status — foundation3 (Phase 6a / M18)
|
||||
|
||||
## Round 6 — 2026-07-17 — STATUS: ready_to_merge
|
||||
|
||||
**Directives acknowledged:** round 6 of plan/DIRECTIVES.md. Directive #1: `foundation3` only,
|
||||
branch `foundation3`, push — do NOT merge. Contracts correctness beats speed; keep 189 green.
|
||||
|
||||
**Acceptance checklist** (M18, plan/30-phase6.md §M18):
|
||||
- [x] 1. Synthetic fixture: two moving markers (A magenta `hue:150`, B blink `code:5`)
|
||||
composited through each camera's real pose projection; `data/work/track_truth.json`
|
||||
built from the SAME 3D paths (`synthetic.build_track_truth`, Three.js scene space).
|
||||
`synthetic.project_point` is the exact inverse of `geometry.ray_from_pixel`. Base
|
||||
`testsrc2` is saturation/brightness-muted (`BASE_SATURATION`/`BASE_BRIGHTNESS`) so its
|
||||
own magenta/cyan colour bars don't swamp lane H's colour detector — the discs are the
|
||||
only high-sat magenta/cyan + only bright small blobs (verified by a real-render test).
|
||||
- [x] 2. DB: `Track`/`TrackPoint` tables (contract #2) + `add_track`/`get_tracks`/`get_track`/
|
||||
`get_track_points`/`set_track_points`/`patch_track`/`delete_track`/`has_tracks`.
|
||||
- [x] 3. API: GET /api/tracks, PATCH/DELETE /api/tracks/{id}, POST /api/tracks/solve (degrades
|
||||
to valid empty + note while lane H stubbed), manifest `has_tracks`. Key-set lock edit → CR-6.
|
||||
- [x] 4. CLI: `python -m festival4d track [--detect-only|--solve-only]` → lane-H stubs.
|
||||
- [x] 5. Backend stubs: `tracker_detect.py`/`tracker_solve.py` — `NotImplementedError` + full
|
||||
contracts #1–#5 in docstrings; foundation-owned frozen constants (blink OOK, hue keys)
|
||||
live in `tracker_detect` as the single source of truth (`synthetic` imports them).
|
||||
- [x] 6. Frontend: `friendTracks.js` stub (never-throws, fx.js pattern) wired into `main.js`
|
||||
loop; hidden `#btn-friends` + `#friends-panel`; `state.tracks` loaded when `has_tracks`.
|
||||
`npm run build` clean (27 modules, 851 ms).
|
||||
- [x] 7. Capsule: `capsule.py` bakes `api/tracks` (additive). Bundle-structure test edit → CR-6.
|
||||
- [x] 8. Tests: `test_tracks_foundation.py` (20) — DB helpers, routes, solve degradation,
|
||||
manifest, frozen protocol/encoders, track_truth shape, the **projection round-trip**, and
|
||||
a real-ffmpeg render test proving marker A is the dominant magenta blob after base-muting.
|
||||
|
||||
**Done this round:** all 8 steps land. Evidence:
|
||||
- Full suite **209 passed, exit 0** (`uv run pytest -q`) — the 14 pre-existing files sum to
|
||||
exactly 189 (floor held), + 20 new in `test_tracks_foundation.py` (all passed, none skipped).
|
||||
- `test_projection_round_trip_recovers_ground_truth`: projects both markers' 3D truth through
|
||||
every camera pose, back-projects via frozen `ray_from_pixel`, triangulates via
|
||||
`triangulate_rays` — **worst recovery error < 1e-3** across ≥8 samples in 2+ views. Lane H's
|
||||
M20 target (median 3D error < 0.3) is reachable by construction (~300× margin).
|
||||
- `encode_word(5) == [1,1,1,0,0,0,0,0,1,0,1,1]`; all 64 IDs preamble+parity+MSB-recover checked.
|
||||
- Frontend production build clean; manifest `has_tracks` reflects the DB live.
|
||||
- CR-6 filed (plan/CHANGE_REQUESTS.md): the two sanctioned M18 test-lock edits (manifest key-set,
|
||||
capsule bundle paths) — additive, flagged for integration3 ratification.
|
||||
|
||||
**Recovery note (2026-07-17):** a prior foundation3 attempt left M18 half-done and **uncommitted**
|
||||
in a stale locked worktree — steps 2/3/4/5/7 (DB/API/CLI/stubs/capsule) + the `synthetic.py`
|
||||
marker code present, but step 1's ground-truth never validated, steps 6/8 (frontend/tests) not
|
||||
started, suite never run. This session salvaged it: committed the WIP as checkpoint `6a703c3`,
|
||||
then completed steps 1/6/8 and verified every inherited contract choice against the spec. The
|
||||
one real bug found: the base `testsrc2` visual carried solid magenta+cyan bars (the marker hues)
|
||||
that would have swamped lane H's colour detector — fixed by muting base saturation/brightness
|
||||
and locked with a real-render regression test (marker A dominant-blob within 2.5 px, 24/24
|
||||
frames). No work was redone. Do NOT merge — coordinator reviews + merges (DIRECTIVES R6 #3).
|
||||
|
||||
**Blockers / questions for coordinator:** none. Ready for review + merge.
|
||||
|
||||
**Next:** coordinator reviews `git diff main..foundation3` + this file, merges to `main`.
|
||||
After merge, lanes H / J / K launch in parallel worktrees (DIRECTIVES R6 #2).
|
||||
49
plan/status/integration2.md
Normal file
49
plan/status/integration2.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Status — integration2
|
||||
|
||||
## Round 1 — 2026-07-17 — STATUS: merged (synthetic pass complete; field test GATED on real clips)
|
||||
|
||||
**Directives acknowledged:** Round 4. Single-agent phase 5c, main working directory on
|
||||
branch `integration2`. Coordinator and integration agent are the same session this round.
|
||||
|
||||
**Merge state:** foundation2, lane/g-capsule, lane/f-social, lane/e-director2 all merged to
|
||||
`main` in completion order; every merge independently re-verified (suite re-run in the lane
|
||||
worktree before merging, then on the merged tree). CR-4 and CR-5 ratified at merge (CR-1
|
||||
precedent; decisions recorded in plan/CHANGE_REQUESTS.md). Suite: 127 → 169 → **189 passed**;
|
||||
`npm run build` clean at every step.
|
||||
|
||||
**Full-system synthetic pass — evidence per milestone:**
|
||||
- **M10 (beats):** `ingest → features` on the repo project: 29 beats, tempo **120.0 BPM**
|
||||
(ground truth 120), beat grid at 0.5 s spacing (~+20 ms bias, well inside ±50 ms), 40
|
||||
onsets → `data/work/beats.json` matches contract #1.
|
||||
- **M11 (director):** CLI `direct --top-n 4` emits frozen-shape camPath JSON, keyframe times
|
||||
ON the beat grid (3.02, 4.02, …). Live browser: 🎬 visible, click → **10 keyframes loaded,
|
||||
all beat-snapped (≤11 ms), path auto-plays** (⏹ Path state).
|
||||
- **M12 (export):** live browser via the `__f4d` pump at 50 Hz (hidden pane, rAF=0 — pitfall
|
||||
#2 workaround; WebAudio grants the timer exemption): full-length export auto-stopped at the
|
||||
last keyframe → `video/webm;codecs=vp9,opus`, **duration 13.98 s vs 14.0 expected (±5% ✓)**,
|
||||
audio track present and decoding (webkitAudioDecodedByteCount 28 770 after 1.5 s), state
|
||||
restored, ⏺ re-armed. NOT verifiable headless: rendered-frame quality (needs a focused
|
||||
window — flagged for the human).
|
||||
- **M13–M15 (lane F):** lane self-verified live in isolated servers (see plan/status/lane-F.md
|
||||
— full create→rename→recolor→jump→delete roundtrip, 3840×3093 helper-free PNG, both FX
|
||||
types, 0.101 ms/frame worst case). Coordinator re-check on merged main: all modules
|
||||
ready=true, buttons visible, anchor panel lists 4 anchors with tag/jump/edit/delete, FX
|
||||
fires on a scrub crossing of bass_drop, **zero console errors**.
|
||||
- **M16 (paths):** live: save (via module's own prompt flow) → dropdown refreshes → load
|
||||
reproduces keyframes **exactly** (deep-equal on toJSON) → delete refreshes. 30 lane tests.
|
||||
- **M17 (capsule):** baked from the real dist (19 files, 6.8 MB), served by bundle
|
||||
`serve.py`: app boots in the browser with **zero off-origin requests**
|
||||
(performance.getEntriesByType — nothing to :8000), `capsule: true`, all `.write-ui` hidden,
|
||||
**video seek to t=10 exact** (Range/206 over serve.py), `/api/audio/1` → 200 (WebAudio
|
||||
clock live), 4 anchors + 7 events + point cloud + timeline all present. Screenshot on file.
|
||||
|
||||
**Housekeeping:** `.gitignore` now covers `data/capsule/` and `.claude/worktrees/`
|
||||
(lane G's note).
|
||||
|
||||
**Field test:** NOT RUN — gated on 2–4 real overlapping clips in `data/raw/` (the standing
|
||||
human item since Round 2). Per the adjusted plan (DIRECTIVES Round 5) the **v0.2.0 tag waits**
|
||||
for the field test; the synthetic integration pass is complete and accepted.
|
||||
|
||||
**Blockers:** none for code. Human items listed in DIRECTIVES Round 5.
|
||||
|
||||
**Next:** field test on real footage when clips land → ISSUES.md entries → v0.2.0.
|
||||
Loading…
Reference in New Issue
Block a user