// 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/roto-puppet.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 } from './pipeline.js'; import { IndexedRaster, hexToRgb } from './raster.js'; import { writeTake } from './take.js'; import { clipPolyAbove, otsuForTest } 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 ---- */ 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); // 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`); // teeth band: the inner ring clipped to a height. The point of doing it this // way is that the silhouette is the mouth's own shape, so it can never // disagree with the lips - assert that rather than the pixel measurement. { const ring = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }]; const half = clipPolyAbove(ring, 5); ok('clip at mid height halves the box', half.length === 4 && Math.max(...half.map((p) => p.y)) === 5, `${half.length} pts, maxY ${Math.max(...half.map((p) => p.y))}`); ok('clip above everything keeps the ring', clipPolyAbove(ring, 99).length === 4); ok('clip below everything empties it', clipPolyAbove(ring, -1).length === 0); const w = clipPolyAbove(ring, 5); ok('clipped band keeps the ring width', Math.min(...w.map((p) => p.x)) === 0 && Math.max(...w.map((p) => p.x)) === 10); // a non-convex ring must not gain or lose x-extent from clipping const tri = [{ x: 0, y: 0 }, { x: 8, y: 2 }, { x: 4, y: 9 }]; const ct = clipPolyAbove(tri, 4); ok('clip of a non-convex-ish ring stays within its x range', Math.min(...ct.map((p) => p.x)) >= 0 && Math.max(...ct.map((p) => p.x)) <= 8); } // 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; }