arthur/js/selftest.js
Your Name f9b8ec8617 Fix teeth vertex slider driving the lip vertex count
opts() declared `verts` twice - once from the lip slider and again from the
teeth slider. Duplicate keys in an object literal are silent in JS and the last
one wins, so the teeth vertex control was quietly setting the lip vertex budget
while the lip control did nothing at all.

Renamed to teethVerts, with interior.js reading it under that name.

selftest now parses the opts() literal and fails on duplicate keys, verified to
catch this exact case.

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

290 lines
14 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 { 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);
// 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;
}