// Assertions over the stages below detection. Runs in the browser so the exact // module graph the tool uses is what gets tested. // // The ring-simplicity check exists because "fixed topology" is load-bearing in // docs/design.md: because hold parts CUT between poses rather than // interpolating, a ring whose vertex order is wrong self-intersects and renders // as blocks meeting at corners. It is invisible at some vertex counts and obvious // at others, so it needs an assertion rather than an eyeball. import { LIPS_OUTER, LIPS_INNER, FACE_OVAL, RIGID, subsampleSlots, subsampleRing } from './landmarks.js'; import { fitSimilarity, applySim, procrustesMean, smoothTransforms } from './mathutil.js'; import { stabilize, toRasterRing, selectKeys, activeKey, shiftIndex } from './pipeline.js'; import { IndexedRaster, hexToRgb } from './raster.js'; import { writeTake } from './take.js'; import { otsuForTest, scaleRing } from './interior.js'; import { synthDense } from './synth.js'; const results = []; const ok = (name, cond, detail = '') => results.push({ name, pass: !!cond, detail }); /* ---- geometry helpers ---- */ function segmentsCross(a, b, c, d) { const o = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x)); const o1 = o(a, b, c), o2 = o(a, b, d), o3 = o(c, d, a), o4 = o(c, d, b); return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0; } // A closed ring is simple if no pair of non-adjacent edges crosses. function ringSelfIntersections(pts) { const n = pts.length, hits = []; for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { if (i === j || (j + 1) % n === i || (i + 1) % n === j) continue; if (segmentsCross(pts[i], pts[(i + 1) % n], pts[j], pts[(j + 1) % n])) hits.push([i, j]); } } return hits; } const spreadX = (frames, slot) => { const xs = frames.map((f) => f[slot].x); return Math.max(...xs) - Math.min(...xs); }; /* ---- the tests ---- */ // Cross-check every el('id') in app.js against the ids in index.html. // // This bug class has bitten twice: a knob wired in app.js but absent from the // markup throws during wiring, which aborts the rest of the module and leaves a // blank page. The symptom ("nothing happens") points nowhere near the cause, so // it is worth an automated check rather than vigilance. export async function runWiring() { const out = []; try { const [app, html] = await Promise.all([ fetch('./js/app.js').then((r) => r.text()), fetch('./index.html').then((r) => r.text()), ]); const ids = new Set([...app.matchAll(/\bel\(\s*['"]([\w-]+)['"]\s*\)/g)].map((m) => m[1])); const list = app.match(/for \(const id of \[([\s\S]*?)\]\)/); if (list) { for (const m of list[1].matchAll(/'([\w]+)'/g)) { ids.add(m[1]); ids.add(m[1] + 'v'); } } // Duplicate keys in an object literal are silent in JS - the last one wins. // In opts() that meant the teeth vertex slider was quietly driving the lip // vertex count while the lip slider did nothing at all. const lit = app.match(/const opts = \(\) => \(\{([\s\S]*?)\n\}\);/); if (lit) { const keys = [...lit[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)].map((m) => m[1]); const dupes = keys.filter((k, i) => keys.indexOf(k) !== i); out.push({ name: `opts() has no duplicate keys (${keys.length} checked)`, pass: dupes.length === 0, detail: [...new Set(dupes)].join(', ') }); } else { out.push({ name: 'opts() literal found for duplicate-key check', pass: false, detail: '' }); } const have = new Set([...html.matchAll(/id="([\w-]+)"/g)].map((m) => m[1])); const missing = [...ids].filter((i) => !have.has(i)); out.push({ name: `every el() id exists in index.html (${ids.size} checked)`, pass: missing.length === 0, detail: missing.join(', ') }); const unused = [...have].filter((i) => !ids.has(i)); out.push({ name: 'no orphaned ids in index.html', pass: unused.length === 0, detail: unused.join(', ') }); } catch (e) { out.push({ name: 'wiring check ran', pass: false, detail: e.message }); } return out; } export function run() { results.length = 0; // tables ok('LIPS_OUTER has 20 distinct ids', new Set(LIPS_OUTER).size === 20); ok('LIPS_INNER has 20 distinct ids', new Set(LIPS_INNER).size === 20); ok('FACE_OVAL has 36 distinct ids', new Set(FACE_OVAL).size === 36); ok('RIGID excludes every lip vertex', !RIGID.some((i) => LIPS_OUTER.includes(i) || LIPS_INNER.includes(i)), 'a moving feature in the rigid set bleeds performance into stabilisation'); // subsampling preserves order and count at every budget for (let n = 4; n <= 16; n += 2) { const s = subsampleSlots(20, n); const mono = s.every((v, i) => i === 0 || v > s[i - 1]); ok(`subsampleSlots(20,${n}) is strictly increasing, n=${n}`, mono && s.length === n, s.join(',')); } ok('subsampleRing agrees with subsampleSlots', subsampleRing(LIPS_OUTER, 8).join(',') === subsampleSlots(20, 8).map((s) => LIPS_OUTER[s]).join(',')); const dense = synthDense(72); // rings must be simple at EVERY vertex budget, on every frame for (const [label, table] of [['outer', LIPS_OUTER], ['inner', LIPS_INNER]]) { let worst = null; for (let n = 4; n <= 16 && !worst; n += 2) { const slots = subsampleSlots(table.length, n); for (let f = 0; f < dense.length; f++) { const pts = slots.map((s) => dense[f][table[s]]); const hits = ringSelfIntersections(pts); if (hits.length) { worst = `verts=${n} frame=${f} edges ${JSON.stringify(hits[0])}`; break; } } } ok(`${label} ring is simple at every vertex budget`, !worst, worst || ''); } // FACE_OVAL traversal: never checked before, and a wrong ordering here shows up // as a lumpy plate rather than an obvious bowtie, so it needs asserting. { let bad = null; for (let f = 0; f < dense.length && !bad; f++) { const h = ringSelfIntersections(FACE_OVAL.map((i) => dense[f][i])); if (h.length) bad = `frame ${f} edges ${JSON.stringify(h[0])}`; } ok('FACE_OVAL is a simple ring on every frame', !bad, bad || ''); } // similarity fit recovers a known transform const src = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 2, y: 3 }]; const truth = { s: 1.7, theta: 0.6, tx: 4, ty: -2 }; const dst = src.map((p) => applySim(truth, p)); const got = fitSimilarity(src, dst); ok('fitSimilarity recovers a known transform', Math.abs(got.s - truth.s) < 1e-9 && Math.abs(got.theta - truth.theta) < 1e-9 && Math.abs(got.tx - truth.tx) < 1e-9 && Math.abs(got.ty - truth.ty) < 1e-9, `s=${got.s.toFixed(6)} th=${got.theta.toFixed(6)}`); // stabilisation: head motion out, mouth motion kept const stab = stabilize(dense, 0); const rawSpread = spreadX(dense, 133); const stabSpread = (() => { const xs = stab.eyes.map((e) => e[0].x); return Math.max(...xs) - Math.min(...xs); })(); ok('stabilisation removes >90% of head translation', stabSpread < rawSpread * 0.1, `raw ${rawSpread.toFixed(4)} -> ${stabSpread.toFixed(4)}`); const apRange = Math.max(...stab.aperture) - Math.min(...stab.aperture); ok('stabilisation preserves mouth motion', apRange > 0.05, `aperture range ${apRange.toFixed(4)}`); // ASPECT: a shape that is circular in PIXEL space must stay circular in raster // space. MediaPipe normalises x by width and y by height, so for a portrait // frame equal normalised numbers are unequal pixel distances; feeding those // straight through stretches everything horizontally by H/W. This asserts the // isotropic conversion, and fails at ~1.78 for a 1080x1920 clip without it. for (const [W, H] of [[1080, 1920], [1920, 1080], [640, 640]]) { const aspect = W / H; const N = 24, cx = 0.5, cy = 0.5, rPx = 200; // a true circle of radius rPx, expressed in MediaPipe normalised coords const circleFrames = []; for (let t = 0; t < 4; t++) { const pts = new Array(478).fill(null).map(() => ({ x: 0.5, y: 0.5, z: 0 })); RIGID.forEach((id, k) => { const a = (k / RIGID.length) * Math.PI * 2; pts[id] = { x: cx + (120 * Math.cos(a)) / W, y: cy + (120 * Math.sin(a)) / H, z: 0 }; }); LIPS_OUTER.forEach((id, k) => { const a = -(k / LIPS_OUTER.length) * Math.PI * 2; pts[id] = { x: cx + (rPx * Math.cos(a)) / W, y: cy + (rPx * Math.sin(a)) / H, z: 0 }; }); FACE_OVAL.forEach((id, k) => { const a = -(k / FACE_OVAL.length) * Math.PI * 2; pts[id] = { x: cx + (420 * Math.cos(a)) / W, y: cy + (420 * Math.sin(a)) / H, z: 0 }; }); circleFrames.push(pts); } const st2 = stabilize(circleFrames, 0, aspect); const ring = toRasterRing(st2.outer[0], LIPS_OUTER, 16, (p) => p); const xs = ring.map((p) => p.x), ys = ring.map((p) => p.y); const ratio = (Math.max(...xs) - Math.min(...xs)) / (Math.max(...ys) - Math.min(...ys)); ok(`circle stays circular at ${W}x${H}`, Math.abs(ratio - 1) < 0.02, `w/h ratio ${ratio.toFixed(4)}`); } // key selection const xf = (p) => ({ x: p.x * 320, y: p.y * 200 }); const shapes = stab.outer.map((r) => toRasterRing(r, LIPS_OUTER, 8, xf)); const sel = selectKeys(shapes, { minHold: 2, distThresh: 0.6, velSmooth: 3, exposure: 2 }); ok('keys are strictly increasing in f', sel.keys.every((k, i) => i === 0 || k.f > sel.keys[i - 1].f)); ok('keys respect the minimum hold', sel.keys.every((k, i) => i === 0 || k.src - sel.keys[i - 1].src >= 2)); ok('keys land on the exposure grid', sel.keys.every((k) => k.f % 2 === 0)); ok('selection reduces candidates', sel.keys.length < sel.candidates.length, `${sel.candidates.length} candidates -> ${sel.keys.length} keys`); ok('first key is frame 0', sel.keys[0].f === 0); ok('activeKey holds between keys', activeKey(sel.keys, sel.keys[1].f - 1).f === sel.keys[0].f); // mouth lead: a shift that "feels like it does nothing" is indistinguishable // from one that does nothing, so assert the arithmetic directly. ok('lead 0 is identity', [0, 5, 71].every((f) => shiftIndex(f, 0, 72) === f)); ok('positive lead moves the source frame forward', shiftIndex(10, 2, 72) === 12); ok('negative lead moves it back', shiftIndex(10, -3, 72) === 7); ok('lead clamps at the start', shiftIndex(1, -6, 72) === 0); ok('lead clamps at the end', shiftIndex(70, 6, 72) === 71); { // ...and that it selects different POSES, not merely different indices. // Checked across the whole track rather than at one pair: synthetic poses // hold for nine-frame beats, so any single pair can legitimately be // identical while the shift works perfectly. const N = shapes.length; let moved = 0, total = 0; for (let f = 0; f < N; f++) { const a = shapes[shiftIndex(f, 0, N)], b = shapes[shiftIndex(f, 3, N)]; let d = 0; for (let i = 0; i < a.length; i++) d += Math.hypot(a[i].x - b[i].x, a[i].y - b[i].y); total++; if (d / a.length > 0.5) moved++; } ok('a lead of 3 changes the pose on a good share of frames', moved / total > 0.2, `${moved}/${total} frames differ`); } // rasteriser: indexed, hard-edged, no blending const r = new IndexedRaster(64, 48); r.clear(0); r.fillPoly([{ x: 8, y: 8 }, { x: 56, y: 8 }, { x: 56, y: 40 }, { x: 8, y: 40 }], 2); const present = new Set(r.buf); ok('raster contains only written indices', present.size === 2 && present.has(0) && present.has(2), `indices ${[...present].join(',')}`); let count = 0; for (const v of r.buf) if (v === 2) count++; ok('axis-aligned rect fills the exact pixel count', count === 48 * 32, `${count} vs ${48 * 32}`); const pal = ['#000000', '#ffffff', '#ff8800']; const img = r.toImageData(pal, 2); const seen = new Set(); for (let i = 0; i < img.data.length; i += 4) { seen.add(`${img.data[i]},${img.data[i + 1]},${img.data[i + 2]}`); } const allowed = new Set(pal.map((h) => hexToRgb(h).join(','))); ok('palette expansion introduces no intermediate colours', [...seen].every((c) => allowed.has(c)), `${seen.size} distinct colours`); // scaleRing is what pulls the sampled region in from MediaPipe's inner lip // landmarks, which sit slightly outside the real opening. { const ring = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }]; const small = scaleRing(ring, 0.5); const w = Math.max(...small.map((p) => p.x)) - Math.min(...small.map((p) => p.x)); ok('scaleRing(0.5) halves the extent', Math.abs(w - 5) < 1e-9, `width ${w}`); const same = scaleRing(ring, 1); ok('scaleRing(1) is identity', same.every((p, i) => Math.abs(p.x - ring[i].x) < 1e-9)); let cx = 0; for (const p of small) cx += p.x; ok('scaleRing keeps the centroid', Math.abs(cx / 4 - 5) < 1e-9); } // Otsu on a uniform region must report near-zero class separation. It will // still return a threshold - that is what Otsu does - so the separation is the // only thing that distinguishes "found teeth" from "split noise in a dark // mouth", which is what made the band fill the whole cavity. { const flat = new Uint32Array(256); flat[40] = 500; const f = otsuForTest(flat, 500); ok('uniform region yields ~no class separation', Math.abs(f.mBright - f.mDark) / 255 < 0.02, `sep ${((f.mBright - f.mDark) / 255).toFixed(4)}`); const noisy = new Uint32Array(256); for (let i = 30; i <= 60; i++) noisy[i] = 20; // dark cavity, some spread const nz = otsuForTest(noisy, 31 * 20); ok('dark-but-noisy region stays below a sane gate', (nz.mBright - nz.mDark) / 255 < 0.14, `sep ${((nz.mBright - nz.mDark) / 255).toFixed(4)}`); const teeth = new Uint32Array(256); for (let i = 20; i <= 45; i++) teeth[i] = 40; // cavity for (let i = 180; i <= 220; i++) teeth[i] = 30; // teeth const tt = otsuForTest(teeth, 26 * 40 + 41 * 30); ok('real bright/dark split clears the gate', (tt.mBright - tt.mDark) / 255 > 0.4, `sep ${((tt.mBright - tt.mDark) / 255).toFixed(4)}`); } // take writer round-trip const take = { name: 'test', frames: 72, width: 320, height: 200, exposure: 2, palette: [{ name: 'bg' }, { name: 'skin' }], slot: { x: 160, y: 100 }, parts: [ { name: 'head', kind: 'plate', z: 0, interp: 'hold', keys: [{ f: 0, plate: 0 }] }, { name: 'mouth', kind: 'poly', z: 30, color: 'skin', interp: 'hold', keys: sel.keys.map((k) => ({ f: k.f, src: k.src, pts: shapes[k.src] })) }, ], }; const text = writeTake(take); const keyLines = text.split('\n').filter((l) => l.startsWith('key') && l.includes('n=')); ok('every key line declares n= matching its point count', keyLines.every((l) => { const n = +l.match(/n=(\d+)/)[1]; const pts = l.split(/n=\d+\s+/)[1].trim().split(/\s+/); return pts.length === n; }), `${keyLines.length} key lines`); ok('take declares a plate and a part table', /^plate\s+0/m.test(text) && /^part\s+mouth/m.test(text)); ok('coordinates are integers', !/-?\d+\.\d/.test(text.split('\n').filter((l) => l.startsWith('key')).join(''))); return results; }