arthur/js/selftest.js
Your Name 941022b69f Teeth from mouth-interior image content
MediaPipe has no landmarks inside the lips, so teeth have to come from pixels.
Tracing the bright blob would give a new contour every frame with no vertex
correspondence - the exact boil docs/roto-puppet.md warns about.

So the measurement yields a scalar, not a shape: Otsu within the cavity, scanned
from the top for where the bright run stops, giving one line height per frame.
The teeth polygon is the inner lip ring clipped to that line, so the silhouette
is always the mouth's own shape and cannot disagree with the lips around it,
and the only per-frame variable is a single number that smooths trivially.

Presence gets hysteresis and minimum dwell, as plate selection does: a teeth
block blinking on and off for single frames is worse than one simply absent.

Tongue is not implemented. The same scalar approach would apply, gated on
redness rather than brightness, but it is not visible in the test footage - the
cavity reads dark with a bright upper-teeth band and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:12:09 -04:00

230 lines
11 KiB
JavaScript

// 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 } 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);
}
// 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;
}