Markers/badges -> detection -> cross-view triangulation -> track ribbons + follow-a-friend + LED badge hardware kit. Coordinator-merges policy this phase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 KiB
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; the newest round of 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.pyshows 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 att_video, tracks live att_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)
- 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).
- DB:
tracks+track_pointstables (contract #2) +db.pyhelpers (add_track,get_tracks,set_track_points,patch_track,delete_track). - 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— updatetest_api.py's key-set lock consciously in the same commit. - CLI:
python -m festival4d track [--detect-only|--solve-only]→ lane-H stubs. - Backend stubs:
tracker_detect.py(run_detect()),tracker_solve.py(run_solve()) —NotImplementedErrorwith the full contract in the docstring. - Frontend: stub
frontend/src/friendTracks.jsimported frommain.js,init()+ per-framefriendTracks.update(tGlobal)wired into the master loop (fx.js pattern); hidden 👣 Friends button#btn-friends+ empty#friends-panelcontainer inindex.html;state.tracks = []loaded at boot whenmanifest.has_tracks. - Capsule: add
api/trackstocapsule.py's bake list (additive; update lane G's bundle-structure test consciously in the same commit, CR-style note in the commit message). - 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)
- Marker hues (color mode): magenta
#ff00ffand cyan#00ffffare the two supported fixture hues; real shoots may configure others viaFESTIVAL4D_MARKER_HUES(comma-sep hex). - 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 byt_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}]}]. - 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/cynormalized 0..1 (bbox-annotation convention). - 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. - 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 getviews = 1andquality ≤ 0.5. - 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
- Time alignment is the whole game. Detections happen at
t_video; association happens att_global. Onet_video_from_globalinversion mistake silently shears every track. Use the frozen helpers; test association against ground truth, not against itself. - 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).
- 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. - Don't fight the camera owners. Follow-mode (M22) shares
pathModewith camPath — mirror camPath's contention checks exactly, or two drivers will interleave writes. - rAF throttle (standing): verify with the
__f4dpump in hidden panes (localStorage f4dDebug=1); the WebAudio timer exemption gives you ~50 HzsetTimeout. - 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-.md, and the latest round of plan/DIRECTIVES.md. Execute your lane on branch
lane/<x>-<name>; maintain plan/status/lane-.md; obey the ownership table and frozen-files contract; push your branch and report — the coordinator reviews and merges. Do not merge to main.