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
|
Become arthur: a standalone suite, not an Animator Pro front-end
The test renderer turned out to be the product. Everything that decides how the
work looks - stabilisation, reduction, timing, frame removal, palette - already
happens here, and the flat indexed output already reads the way it should.
The reason to leave is in the original design's own rule: never make a timing
decision that requires a full render to evaluate. Honouring that moved every
judgement out of Animator Pro, which left the host doing nothing but writing a
file, in exchange for modal UI, minutes-long renders, one-level undo, FLX delta
invariants, a single tween state and a cel singleton.
What does NOT change is the constraint. 320x200, indexed palette, flat fills,
no antialiasing - inherited, but load-bearing rather than accidental. The
rasteriser writes palette indices and expands to RGBA only at the end precisely
so nothing can soften an edge. Modern conveniences belong in the workflow.
Adds docs/design.md: the principles, carried over without the Poco/FLX/cel
machinery, plus architecture and an honest list of what is missing - the
largest gap being that plates still have nowhere to be drawn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:47:41 -04:00
|
|
|
// docs/design.md: because hold parts CUT between poses rather than
|
2026-09-24 14:38:07 -04:00
|
|
|
// 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';
|
2026-09-24 15:38:02 -04:00
|
|
|
import { stabilize, toRasterRing, selectKeys, activeKey, shiftIndex } from './pipeline.js';
|
2026-09-24 14:38:07 -04:00
|
|
|
import { IndexedRaster, hexToRgb } from './raster.js';
|
|
|
|
|
import { writeTake } from './take.js';
|
Teeth as an extracted blob contour, not a clipped band
The band filled the mouth because a band is the wrong reduction: the bright
region is a blob, and reading it as "everything above a line" throws the shape
away.
Extracting a contour reintroduces the vertex-correspondence problem that made
me avoid it, but for a blob there is a way out. Radial sampling from the
centroid along N fixed directions makes vertex k always mean "the extent in
direction k": correspondence holds by construction, the count is fixed, and
temporal smoothing cannot reorder anything. It also yields a star-shaped
reduction, which suits flat colour.
Tongue rejection, which the band had no way to express:
- pixels red relative to their own brightness are dropped (teeth are neutral)
- component choice is biased toward the top of the cavity, since area alone
picks the tongue when the mouth is wide
- separate inner and outer controls: cavity erode pulls the sampled region off
the lip edge, blob grow/erode resizes the found blob
Also: a knob wired in app.js but missing from index.html threw during wiring
and left a blank page with nothing useful in the console - which is exactly
what happened to teethDwell in the previous commit. el() now names the missing
id, and window.onerror surfaces it in the status line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:24:52 -04:00
|
|
|
import { otsuForTest, scaleRing } 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 ---- */
|
|
|
|
|
|
2026-09-24 15:25:39 -04:00
|
|
|
// 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'); }
|
|
|
|
|
}
|
2026-09-24 15:36:04 -04:00
|
|
|
// 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: '' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 15:25:39 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
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);
|
|
|
|
|
|
2026-09-24 15:38:02 -04:00
|
|
|
// mouth lead: a shift that "feels like it does nothing" is indistinguishable
|
|
|
|
|
// from one that does nothing, so assert the arithmetic directly.
|
|
|
|
|
ok('lead 0 is identity', [0, 5, 71].every((f) => shiftIndex(f, 0, 72) === f));
|
|
|
|
|
ok('positive lead moves the source frame forward', shiftIndex(10, 2, 72) === 12);
|
|
|
|
|
ok('negative lead moves it back', shiftIndex(10, -3, 72) === 7);
|
|
|
|
|
ok('lead clamps at the start', shiftIndex(1, -6, 72) === 0);
|
|
|
|
|
ok('lead clamps at the end', shiftIndex(70, 6, 72) === 71);
|
|
|
|
|
{
|
|
|
|
|
// ...and that it selects different POSES, not merely different indices.
|
|
|
|
|
// Checked across the whole track rather than at one pair: synthetic poses
|
|
|
|
|
// hold for nine-frame beats, so any single pair can legitimately be
|
|
|
|
|
// identical while the shift works perfectly.
|
|
|
|
|
const N = shapes.length;
|
|
|
|
|
let moved = 0, total = 0;
|
|
|
|
|
for (let f = 0; f < N; f++) {
|
|
|
|
|
const a = shapes[shiftIndex(f, 0, N)], b = shapes[shiftIndex(f, 3, N)];
|
|
|
|
|
let d = 0;
|
|
|
|
|
for (let i = 0; i < a.length; i++) d += Math.hypot(a[i].x - b[i].x, a[i].y - b[i].y);
|
|
|
|
|
total++;
|
|
|
|
|
if (d / a.length > 0.5) moved++;
|
|
|
|
|
}
|
|
|
|
|
ok('a lead of 3 changes the pose on a good share of frames', moved / total > 0.2,
|
|
|
|
|
`${moved}/${total} frames differ`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
// 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 as an extracted blob contour, not a clipped band
The band filled the mouth because a band is the wrong reduction: the bright
region is a blob, and reading it as "everything above a line" throws the shape
away.
Extracting a contour reintroduces the vertex-correspondence problem that made
me avoid it, but for a blob there is a way out. Radial sampling from the
centroid along N fixed directions makes vertex k always mean "the extent in
direction k": correspondence holds by construction, the count is fixed, and
temporal smoothing cannot reorder anything. It also yields a star-shaped
reduction, which suits flat colour.
Tongue rejection, which the band had no way to express:
- pixels red relative to their own brightness are dropped (teeth are neutral)
- component choice is biased toward the top of the cavity, since area alone
picks the tongue when the mouth is wide
- separate inner and outer controls: cavity erode pulls the sampled region off
the lip edge, blob grow/erode resizes the found blob
Also: a knob wired in app.js but missing from index.html threw during wiring
and left a blank page with nothing useful in the console - which is exactly
what happened to teethDwell in the previous commit. el() now names the missing
id, and window.onerror surfaces it in the status line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:24:52 -04:00
|
|
|
// scaleRing is what pulls the sampled region in from MediaPipe's inner lip
|
|
|
|
|
// landmarks, which sit slightly outside the real opening.
|
2026-09-24 15:12:09 -04:00
|
|
|
{
|
|
|
|
|
const ring = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }];
|
Teeth as an extracted blob contour, not a clipped band
The band filled the mouth because a band is the wrong reduction: the bright
region is a blob, and reading it as "everything above a line" throws the shape
away.
Extracting a contour reintroduces the vertex-correspondence problem that made
me avoid it, but for a blob there is a way out. Radial sampling from the
centroid along N fixed directions makes vertex k always mean "the extent in
direction k": correspondence holds by construction, the count is fixed, and
temporal smoothing cannot reorder anything. It also yields a star-shaped
reduction, which suits flat colour.
Tongue rejection, which the band had no way to express:
- pixels red relative to their own brightness are dropped (teeth are neutral)
- component choice is biased toward the top of the cavity, since area alone
picks the tongue when the mouth is wide
- separate inner and outer controls: cavity erode pulls the sampled region off
the lip edge, blob grow/erode resizes the found blob
Also: a knob wired in app.js but missing from index.html threw during wiring
and left a blank page with nothing useful in the console - which is exactly
what happened to teethDwell in the previous commit. el() now names the missing
id, and window.onerror surfaces it in the status line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 15:24:52 -04:00
|
|
|
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);
|
2026-09-24 15:12:09 -04:00
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
}
|