2026-09-24 14:38:07 -04:00
|
|
|
// 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 { 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 || '');
|
|
|
|
|
}
|
|
|
|
|
|
Registered photo underlay as the plate reference
The generated face oval was never going to be good enough to draw from:
MediaPipe's face oval is the FACE boundary, cut at the hairline and excluding
hair, ears, jaw and neck, so it is an egg by construction. Segmentation would
give a real head outline but costs a 16MB model and per-frame inference for a
shape that gets replaced by a drawing anyway.
So the plate layer becomes switchable, and the useful modes are photographic:
the source frame mapped into raster space through the same transform chain the
contours go through. Registration is the whole point - the head sits still and
a drawing traced from the underlay is already aligned to the mouth. An
unregistered underlay would be decoration.
- underlay.js: pixel->raster affine (a general affine, since MediaPipe
normalises x by width and y by height), registered draw, palette posterise
- plate modes: photo / photo dim / posterized / oval / oval+photo / none, B cycles
- worksheet cells are registered composites rather than raw crops
- Save frame 4x writes a 1280x800 PNG to draw on
- selftest: FACE_OVAL simplicity, which was never asserted; a wrong ordering
there reads as a lumpy plate rather than an obvious bowtie
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 14:57:59 -04:00
|
|
|
// 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 || '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
// 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
|
2026-09-24 14:51:15 -04:00
|
|
|
const stab = stabilize(dense, 0);
|
2026-09-24 14:38:07 -04:00
|
|
|
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)}`);
|
|
|
|
|
|
2026-09-24 15:03:12 -04:00
|
|
|
// 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)}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
// 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`);
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|