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';
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
import { clipPolyAbove, otsuForTest } from './interior.js';
|
2026-09-24 14:38:07 -04:00
|
|
|
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`);
|
|
|
|
|
|
2026-09-24 15:12:09 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
Fix teeth band filling the whole mouth
Three causes, all of them mine:
Otsu always returns a split, including on a homogeneous region - given a dark
cavity with no teeth it invents a threshold and calls half the pixels bright.
The gate is now the separation between the two class means, which is the only
thing that says whether the split means anything. Coverage was the wrong
signal: it is high both when the mouth is full of teeth and when the region is
uniformly dark and Otsu has split noise.
The row scan tracked the last qualifying row anywhere rather than where the run
from the top stops, so one bright row near the bottom - a lit lower lip inside
the ring - pushed the line to full height. It now breaks at the first failing
row once the run has started.
MediaPipe's inner lip landmarks sit slightly outside the real opening, so the
sampled region included lip pixels, which are bright and sit exactly at the
boundary where they do most damage. The ring is now eroded toward its centroid
before sampling, with the amount exposed as a knob.
Adds a diagnostic panel showing the sampled crop, pixels above threshold, and
the resolved line, because tuning this from numbers alone does not tell you
whether the region being measured is even the right region.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:14:49 -04:00
|
|
|
// 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)}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
// 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;
|
|
|
|
|
}
|