festifun/frontend/src/lib/pose.js
m3ultra cf6a6fb2c8 Foundation (M0 + M3 + frozen contracts): scaffold, synthetic fixtures, full API
What now works:
- M0 scaffold: pyproject (all spec deps), uv/py3.12 env, `python -m festival4d`
  CLI registering synthetic|ingest|sync|reconstruct|events|serve. Vite hello page.
- Synthetic fixture (synthetic.py): 3 shifted-audio videos (offsets 0/+1370/-842 ms),
  camera-arc poses, stage point cloud -> points.ply, seeded events + anchors,
  ground_truth.json. `python -m festival4d synthetic` populates data/ + DB.
- DB schema exactly per spec §2 (db.py) + CRUD helpers all lanes use.
- M3 API (api.py) full against synthetic data: manifest/poses/pointcloud/anchors/
  events/detect/annotations; Range-capable video serving (206 verified); CORS for any
  localhost origin.
- Frozen geometry contract: geometry.colmap_to_threejs (M5 math) + unit test (3 known
  vectors, random round-trip, scipy oracle); mirrored frontend/src/lib/pose.js with
  identical POSE_TEST_VECTORS. Lane-B stubs: slerp_pose, ray_from_pixel, triangulate_rays,
  nearest_point_on_ray.
- Classifier contract (events_ai.py): MomentClassification model + MomentClassifier
  protocol + Gemini/Claude/Local provider stubs.
- Lane-owned modules stubbed with final signatures (ingest, audio_sync, frames, sfm,
  events_ai); cli/api catch NotImplementedError and degrade gracefully.
- plan/CHANGE_REQUESTS.md created; plan/status/foundation.md updated.

Acceptance: pytest 24 passed; serve endpoints verified via curl + browser (video seek,
manifest fetch cross-origin, pose.js self-test, 0 console errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:45:55 +10:00

144 lines
5.1 KiB
JavaScript

// COLMAP world->camera pose -> Three.js camera pose (spec M5).
//
// FROZEN CONTRACT. This is the JavaScript mirror of `backend/festival4d/geometry.py`
// (`colmap_to_threejs`, `quat_to_mat`). The math and the POSE_TEST_VECTORS below are
// duplicated verbatim on the Python side (`backend/tests/test_geometry.py`) so both agree.
// Lanes B and C CONSUME this; do not reimplement the conversion elsewhere. Changing it
// requires a change request and a synchronized edit to both files + both vector sets.
//
// Conventions:
// - Quaternions are COLMAP order [w, x, y, z] (scalar first), unit norm.
// - Pose (q, t) is world->camera: x_cam = R(q) * x_world + t.
// - COLMAP camera axes: +x right, +y down, +z forward.
// - Three.js cameras look down -z with +y up; hence the diag(1, -1, -1) flip.
const SQRT1_2 = 0.7071067811865476;
// Camera-axis flip: COLMAP camera-local (x right, y down, z forward) -> Three.js (x right,
// y up, z backward). Same _FLIP_YZ as the Python side.
const FLIP_YZ = [
[1, 0, 0],
[0, -1, 0],
[0, 0, -1],
];
/**
* Convert a unit quaternion [w, x, y, z] to a 3x3 rotation matrix (row-major nested array).
* Hamilton convention, right-handed, active rotation (COLMAP world->camera when q is a
* COLMAP pose quaternion).
* @param {number[]} q - [w, x, y, z]
* @returns {number[][]} 3x3 rotation matrix
*/
export function quatToMat(q) {
const [w, x, y, z] = q;
const n = w * w + x * x + y * y + z * z;
if (n < 1e-12) throw new Error("quaternion has near-zero norm");
const s = 2.0 / n;
const wx = s * w * x, wy = s * w * y, wz = s * w * z;
const xx = s * x * x, xy = s * x * y, xz = s * x * z;
const yy = s * y * y, yz = s * y * z, zz = s * z * z;
return [
[1.0 - (yy + zz), xy - wz, xz + wy],
[xy + wz, 1.0 - (xx + zz), yz - wx],
[xz - wy, yz + wx, 1.0 - (xx + yy)],
];
}
function transpose3(m) {
return [
[m[0][0], m[1][0], m[2][0]],
[m[0][1], m[1][1], m[2][1]],
[m[0][2], m[1][2], m[2][2]],
];
}
function matMul3(a, b) {
const out = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i++)
for (let j = 0; j < 3; j++)
out[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
return out;
}
function matVec3(m, v) {
return [
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
];
}
/**
* Convert a COLMAP world->camera pose to a Three.js camera pose (spec M5). FROZEN.
* @param {number[]} q - COLMAP world->camera quaternion [w, x, y, z]
* @param {number[]} t - COLMAP world->camera translation [tx, ty, tz]
* @returns {{position: number[], rotation: number[][], matrixWorld: number[]}}
* position - camera center in world coords, C = -R^T t (assign to camera.position)
* rotation - Three.js camera world rotation R_three = R^T * diag(1,-1,-1),
* 3x3 row-major (feed to camera.setRotationFromMatrix via a Matrix4)
* matrixWorld - column-major 16-array [R_three | position], ready for
* THREE.Matrix4().fromArray(...) when driving the camera by matrix.
*/
export function colmapToThreejs(q, t) {
const R = quatToMat(q); // world -> cam
const Rc2w = transpose3(R); // cam -> world
const position = matVec3(Rc2w, [-t[0], -t[1], -t[2]]); // C = -R^T t
const rotation = matMul3(Rc2w, FLIP_YZ); // R_three
// Column-major Matrix4 with rotation in the upper-left 3x3 and translation = position.
const m = rotation;
const p = position;
const matrixWorld = [
m[0][0], m[1][0], m[2][0], 0,
m[0][1], m[1][1], m[2][1], 0,
m[0][2], m[1][2], m[2][2], 0,
p[0], p[1], p[2], 1,
];
return { position, rotation, matrixWorld };
}
// --- FROZEN test vectors (must equal POSE_TEST_VECTORS in backend/tests/test_geometry.py).
export const POSE_TEST_VECTORS = [
{
name: "identity",
q: [1.0, 0.0, 0.0, 0.0],
t: [0.0, 0.0, -10.0],
position: [0.0, 0.0, 10.0],
rotation: [[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]],
},
{
name: "yaw90",
q: [SQRT1_2, 0.0, SQRT1_2, 0.0],
t: [0.0, 0.0, 10.0],
position: [10.0, 0.0, 0.0],
rotation: [[0.0, 0.0, 1.0], [0.0, -1.0, 0.0], [1.0, 0.0, 0.0]],
},
{
name: "lookat",
q: [0.0, 1.0, 0.0, 0.0],
t: [0.0, 0.0, 8.0],
position: [0.0, 0.0, 8.0],
rotation: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
},
];
/**
* Self-check the conversion against POSE_TEST_VECTORS. Returns true on success, throws on
* mismatch. Called by main.js so the frozen contract is verified live in the browser.
* @param {number} [atol=1e-9]
*/
export function selfTest(atol = 1e-9) {
for (const vec of POSE_TEST_VECTORS) {
const { position, rotation } = colmapToThreejs(vec.q, vec.t);
for (let i = 0; i < 3; i++) {
if (Math.abs(position[i] - vec.position[i]) > atol)
throw new Error(`pose.js selfTest[${vec.name}]: position mismatch at ${i}`);
for (let j = 0; j < 3; j++) {
if (Math.abs(rotation[i][j] - vec.rotation[i][j]) > atol)
throw new Error(`pose.js selfTest[${vec.name}]: rotation mismatch at ${i},${j}`);
}
}
}
return true;
}