Full frontend built against the synthetic API (no dependence on lanes A/B/D): M4 — synchronized playback: master clock from performance.now() (never a <video>); per-video correction via requestVideoFrameCallback (exact mediaTime) with a currentTime+half-frame fallback — hard-seek >150ms / nudge playbackRate +/-5% 20-150ms / lock <20ms; out-of-range videos pause+dim; single audio source; dev sync-error overlay. Timebase mirrors config.py (lib/timebase.js). Verified: seek is exact to 0ms; continuous inter-video desync 13ms mean / 46ms max. M5 — 3D viewer: PLY point cloud, per-video camera paths + current-pose frusta, OrbitControls, snap-to-camera (intrinsics->PerspectiveCamera fov) + free roam. All COLMAP->Three.js via the frozen lib/pose.js; pose interpolation in lib/poseTrack.js (slerp+lerp of contract inputs). M6 — anchor overlays: letterbox-correct per-video canvas; anchors projected via pose.js; behind-camera cull. Projection agrees with an independent pinhole to 1.45e-13 px. Timeline — colored event markers + legend, hover tooltip, click-to-jump; draggable scrubber. Phase-3 seams (annotate.js M8, camPath.js M9) left as stubs. No frozen files edited; no new deps; no change requests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
5.3 KiB
JavaScript
138 lines
5.3 KiB
JavaScript
// 3D -> 2D anchor projection onto each video's overlay canvas — the "x-ray" HUD (spec M6).
|
|
//
|
|
// For each visible video we build a Three.js PerspectiveCamera at that video's CURRENT pose
|
|
// (from the FROZEN colmapToThreejs helper, matrixWorld path) with an fov derived from the
|
|
// stored intrinsics, project every anchor, drop anchors behind the camera (camera-space z >= 0),
|
|
// convert NDC -> native video pixels -> canvas pixels through the letterbox content rect, and
|
|
// draw a dot + halo + label. Anchors are drawn regardless of real-world occlusion — that IS the
|
|
// x-ray feature. We consume pose.js; we never reimplement the COLMAP->Three.js conversion.
|
|
|
|
import * as THREE from "three";
|
|
import { colmapToThreejs } from "./lib/pose.js";
|
|
import { poseAt } from "./lib/poseTrack.js";
|
|
import { contentRect } from "./videoGrid.js";
|
|
import { state } from "./state.js";
|
|
|
|
// Reused across all videos/frames (every synthetic video shares intrinsics + resolution, and
|
|
// we fully overwrite the matrices each call, so a single scratch camera is safe and cheap).
|
|
const _cam = new THREE.PerspectiveCamera();
|
|
_cam.matrixAutoUpdate = false;
|
|
const _m4 = new THREE.Matrix4();
|
|
const _v = new THREE.Vector3();
|
|
|
|
function configureProjector(pose, W, H) {
|
|
// Centered-principal-point pinhole -> PerspectiveCamera (spec M5/M6 prototype assumption;
|
|
// the synthetic fixture has cx=W/2, cy=H/2 exactly).
|
|
const fy = pose.intrinsics.fy;
|
|
_cam.fov = (2 * Math.atan(H / (2 * fy)) * 180) / Math.PI;
|
|
_cam.aspect = W / H;
|
|
_cam.near = 0.01;
|
|
_cam.far = 2000;
|
|
_cam.updateProjectionMatrix();
|
|
|
|
const { matrixWorld } = colmapToThreejs(pose.q, pose.t);
|
|
_m4.fromArray(matrixWorld);
|
|
_cam.matrixWorld.copy(_m4);
|
|
_cam.matrixWorldInverse.copy(_m4).invert();
|
|
}
|
|
|
|
/** Project a world point to native video pixels, or null if behind the camera. */
|
|
function projectToVideoPx(x, y, z, W, H) {
|
|
_v.set(x, y, z);
|
|
// Behind-camera test in camera space: Three.js cameras look down -z, so a visible point has
|
|
// camera-space z < 0. Do this BEFORE .project() (which mutates _v into NDC).
|
|
_v.applyMatrix4(_cam.matrixWorldInverse);
|
|
if (_v.z >= 0) return null;
|
|
_v.applyMatrix4(_cam.projectionMatrix); // perspective divide happens inside .project(); do it manually
|
|
// _v is now clip space already divided (applyMatrix4 on a Vector3 divides by w). NDC in [-1,1].
|
|
const u = (_v.x * 0.5 + 0.5) * W;
|
|
const vpx = (1 - (_v.y * 0.5 + 0.5)) * H; // flip Y: NDC +y is up, pixels grow downward
|
|
return { u, v: vpx };
|
|
}
|
|
|
|
/**
|
|
* Project a world point to native video pixels through the FROZEN pose helper for a given
|
|
* COLMAP pose (as returned by poseAt) and video resolution. Returns {u, v} or null (behind).
|
|
* Single source of truth for overlay projection — used by drawOverlay and by tests.
|
|
*/
|
|
export function projectWorldToVideoPx(pose, x, y, z, W, H) {
|
|
configureProjector(pose, W, H);
|
|
return projectToVideoPx(x, y, z, W, H);
|
|
}
|
|
|
|
function drawMarker(ctx, x, y, anchor) {
|
|
const color = anchor.color || "#59d499";
|
|
// halo
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 5.5, 0, Math.PI * 2);
|
|
ctx.fillStyle = "rgba(255,255,255,0.85)";
|
|
ctx.fill();
|
|
// dot
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
|
ctx.fillStyle = color;
|
|
ctx.fill();
|
|
// label
|
|
const text = anchor.label || "";
|
|
if (text) {
|
|
ctx.font = "600 11px -apple-system, system-ui, sans-serif";
|
|
ctx.textBaseline = "middle";
|
|
const tx = x + 9;
|
|
const ty = y;
|
|
const w = ctx.measureText(text).width;
|
|
ctx.fillStyle = "rgba(6,8,14,0.72)";
|
|
ctx.fillRect(tx - 3, ty - 8, w + 6, 16);
|
|
ctx.fillStyle = "#e6e8ee";
|
|
ctx.fillText(text, tx, ty + 0.5);
|
|
}
|
|
}
|
|
|
|
/** Redraw one cell's overlay for the frame currently displayed by its <video>. */
|
|
export function drawOverlay(cell) {
|
|
const { video, canvas } = cell;
|
|
const ctx = canvas.getContext("2d");
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const elW = video.clientWidth;
|
|
const elH = video.clientHeight;
|
|
if (elW === 0 || elH === 0) return;
|
|
|
|
// Resolution-match the canvas backing store to the element (dpr-aware).
|
|
const bw = Math.round(elW * dpr);
|
|
const bh = Math.round(elH * dpr);
|
|
if (canvas.width !== bw || canvas.height !== bh) {
|
|
canvas.width = bw;
|
|
canvas.height = bh;
|
|
canvas.style.width = elW + "px";
|
|
canvas.style.height = elH + "px";
|
|
}
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, elW, elH);
|
|
|
|
if (!state.hasPoses || !state.inRange[cell.id]) return;
|
|
if (video.videoWidth === 0) return;
|
|
const poses = state.poses[cell.id];
|
|
if (!poses || poses.length === 0 || state.anchors.length === 0) return;
|
|
|
|
// Use the frame the <video> is ACTUALLY showing (currentTime), not the master target, so the
|
|
// overlay tracks the displayed picture even mid-correction.
|
|
const pose = poseAt(poses, video.currentTime);
|
|
if (!pose) return;
|
|
|
|
const W = video.videoWidth;
|
|
const H = video.videoHeight;
|
|
configureProjector(pose, W, H);
|
|
const rect = contentRect(video);
|
|
|
|
for (const a of state.anchors) {
|
|
const p = projectToVideoPx(a.x, a.y, a.z, W, H);
|
|
if (!p) continue;
|
|
const cx = rect.ox + (p.u / W) * rect.cw;
|
|
const cy = rect.oy + (p.v / H) * rect.ch;
|
|
// Skip anchors that fall well outside the visible content rect.
|
|
const m = 24;
|
|
if (cx < rect.ox - m || cx > rect.ox + rect.cw + m) continue;
|
|
if (cy < rect.oy - m || cy > rect.oy + rect.ch + m) continue;
|
|
drawMarker(ctx, cx, cy, a);
|
|
}
|
|
}
|