2026-09-24 14:38:07 -04:00
|
|
|
|
import { FaceLandmarker, FilesetResolver } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/vision_bundle.mjs';
|
|
|
|
|
|
import { LIPS_OUTER, LIPS_INNER, FACE_OVAL } from './landmarks.js';
|
2026-09-24 15:38:02 -04:00
|
|
|
|
import { stabilize, toRasterRing, smoothContours, suggestPlateFrames, heldFrame, shiftIndex } from './pipeline.js';
|
2026-09-24 14:38:07 -04:00
|
|
|
|
import { IndexedRaster } from './raster.js';
|
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
|
|
|
|
import { drawRegistered, posterizeInto } from './underlay.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 { extractTeeth } from './interior.js';
|
|
|
|
|
|
import { applySim } from './mathutil.js';
|
2026-09-24 14:38:07 -04:00
|
|
|
|
import { writeTake } from './take.js';
|
|
|
|
|
|
import { synthDense } from './synth.js';
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const RW = 320, RH = 200, ZOOM = 2, THUMB = 92;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const PALETTE = [
|
|
|
|
|
|
{ name: 'bg', hex: '#12141c' },
|
|
|
|
|
|
{ name: 'skin_base', hex: '#b07a5a' },
|
|
|
|
|
|
{ name: 'skin_dark', hex: '#7a4f3a' },
|
|
|
|
|
|
{ name: 'mouth_dark', hex: '#24161a' },
|
2026-09-24 15:12:09 -04:00
|
|
|
|
{ name: 'teeth', hex: '#d9cfc2' },
|
2026-09-24 14:38:07 -04:00
|
|
|
|
];
|
2026-09-24 15:12:09 -04:00
|
|
|
|
const IDX = { bg: 0, base: 1, dark: 2, mouth: 3, teeth: 4 };
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
dense: null, images: [], stab: null, xform: null,
|
2026-09-24 14:51:15 -04:00
|
|
|
|
outer: null, inner: null, plates: null, hidden: null,
|
|
|
|
|
|
keep: new Set(), // frames that get their own plate drawing
|
|
|
|
|
|
frame: 0, playing: false, faceBox: null,
|
2026-09-24 14:53:09 -04:00
|
|
|
|
fps: 12, audio: null, // fps comes from manifest.json, never guessed
|
2026-09-24 15:03:12 -04:00
|
|
|
|
aspect: 1, // imgW/imgH; converts MediaPipe's anisotropic space
|
2026-09-24 15:38:02 -04:00
|
|
|
|
lead: 0, // performance-track offset in frames
|
2026-09-24 15:12:09 -04:00
|
|
|
|
interior: null, // per-frame teeth measurement from image content
|
|
|
|
|
|
teeth: null, // resolved per-frame {show, t} after knobs
|
2026-09-24 14:38:07 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
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 el = (id) => {
|
|
|
|
|
|
const n = document.getElementById(id);
|
|
|
|
|
|
// A knob present in the code but missing from the markup used to throw during
|
|
|
|
|
|
// wiring and leave a blank page with nothing in the console worth reading.
|
|
|
|
|
|
if (!n) throw new Error(`missing element #${id} — knob wired in app.js but not in index.html`);
|
|
|
|
|
|
return n;
|
|
|
|
|
|
};
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const opts = () => ({
|
|
|
|
|
|
verts: +el('verts').value,
|
2026-09-24 15:34:08 -04:00
|
|
|
|
lead: +el('lead').value,
|
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
|
|
|
|
teethOn: +el('teethOn').value / 100, // minimum Otsu class separation
|
2026-09-24 15:12:09 -04:00
|
|
|
|
teethDwell: +el('teethDwell').value,
|
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
|
|
|
|
teethSmooth: +el('teethSmooth').value,
|
|
|
|
|
|
cavityErode: +el('teethErode').value / 100,
|
|
|
|
|
|
tongueReject: +el('tongueReject').value / 100,
|
|
|
|
|
|
blobGrow: +el('blobGrow').value,
|
|
|
|
|
|
topBias: +el('topBias').value / 100,
|
2026-09-24 15:36:04 -04:00
|
|
|
|
teethVerts: +el('teethVerts').value,
|
2026-09-24 14:38:07 -04:00
|
|
|
|
smoothWin: +el('smoothWin').value,
|
2026-09-24 14:51:15 -04:00
|
|
|
|
contourSmooth: +el('contourSmooth').value,
|
2026-09-24 14:38:07 -04:00
|
|
|
|
apertureThresh: +el('apertureThresh').value / 1000,
|
2026-09-24 14:51:15 -04:00
|
|
|
|
tol: +el('tol').value / 1000,
|
2026-09-24 14:38:07 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function status(msg, kind = '') {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
el('status').textContent = msg;
|
|
|
|
|
|
el('status').className = kind;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
/* ---------- loading ---------- */
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const loadImage = (src) => new Promise((r) => {
|
|
|
|
|
|
const im = new Image();
|
|
|
|
|
|
im.onload = () => r(im); im.onerror = () => r(null); im.src = src;
|
|
|
|
|
|
});
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:53:09 -04:00
|
|
|
|
// The extraction rate is read, not assumed. Guessing it would desynchronise
|
|
|
|
|
|
// audio from picture, which is the one thing this view exists to show.
|
|
|
|
|
|
async function loadManifest() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await fetch('./manifest.json', { cache: 'no-store' });
|
|
|
|
|
|
if (!r.ok) return null;
|
|
|
|
|
|
return await r.json();
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function attachAudio(name) {
|
|
|
|
|
|
const a = el('audio');
|
|
|
|
|
|
if (!name) { a.removeAttribute('src'); a.hidden = true; state.audio = null; return; }
|
|
|
|
|
|
a.src = './' + name;
|
|
|
|
|
|
a.hidden = false;
|
|
|
|
|
|
state.audio = a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
|
async function loadFrameSequence() {
|
|
|
|
|
|
const dir = el('framedir').value.replace(/\/$/, '');
|
|
|
|
|
|
const imgs = [];
|
|
|
|
|
|
for (let i = 1; i <= 900; i++) {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const im = await loadImage(`${dir}/${String(i).padStart(4, '0')}.png`);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
if (!im) break;
|
|
|
|
|
|
imgs.push(im);
|
|
|
|
|
|
if (i % 10 === 0) status(`loading frames… ${i}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return imgs;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let landmarker = null;
|
|
|
|
|
|
async function initLandmarker() {
|
|
|
|
|
|
if (landmarker) return landmarker;
|
|
|
|
|
|
status('loading MediaPipe wasm…');
|
|
|
|
|
|
const fileset = await FilesetResolver.forVisionTasks(
|
|
|
|
|
|
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/wasm');
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// GPU is faster but unavailable in some contexts; fall back rather than fail.
|
|
|
|
|
|
for (const delegate of ['GPU', 'CPU']) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
landmarker = await FaceLandmarker.createFromOptions(fileset, {
|
|
|
|
|
|
baseOptions: { modelAssetPath: './face_landmarker.task', delegate },
|
|
|
|
|
|
runningMode: 'IMAGE', numFaces: 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
status(`landmarker ready (${delegate})`);
|
|
|
|
|
|
return landmarker;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (delegate === 'CPU') throw e;
|
|
|
|
|
|
console.warn('GPU delegate failed, falling back to CPU:', e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function detectAll(images) {
|
|
|
|
|
|
const lm = await initLandmarker();
|
|
|
|
|
|
const cv = document.createElement('canvas');
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const dense = [], missing = [];
|
2026-09-24 14:38:07 -04:00
|
|
|
|
for (let i = 0; i < images.length; i++) {
|
|
|
|
|
|
const im = images[i];
|
|
|
|
|
|
cv.width = im.naturalWidth; cv.height = im.naturalHeight;
|
|
|
|
|
|
cv.getContext('2d').drawImage(im, 0, 0);
|
|
|
|
|
|
const out = lm.detect(cv);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
if (out.faceLandmarks && out.faceLandmarks.length) dense.push(out.faceLandmarks[0]);
|
|
|
|
|
|
else { missing.push(i); dense.push(dense.length ? dense[dense.length - 1] : null); }
|
|
|
|
|
|
if (i % 4 === 0) status(`detecting… ${i + 1}/${images.length}`);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
if (dense[0] === null) throw new Error('no face found in the first frame');
|
|
|
|
|
|
return { dense, missing };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 15:12:09 -04:00
|
|
|
|
// Interior measurement is a function of pixels alone, so it runs once with
|
|
|
|
|
|
// detection and the knobs re-resolve it instantly afterwards.
|
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
|
|
|
|
function measureAll(images, dense, o) {
|
2026-09-24 15:12:09 -04:00
|
|
|
|
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
|
|
|
|
return dense.map((lm, i) =>
|
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
|
|
|
|
extractTeeth(images[i], LIPS_INNER.map((k) => lm[k]), ctx, o));
|
2026-09-24 15:12:09 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// Extraction keys on every knob that changes the pixels examined, so the cache
|
|
|
|
|
|
// is keyed on exactly those and a change to anything else stays instant.
|
|
|
|
|
|
const extractKey = (o) =>
|
2026-09-24 15:36:04 -04:00
|
|
|
|
[o.cavityErode, o.tongueReject, o.blobGrow, o.topBias, o.teethVerts].join('|');
|
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
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
/* ---------- build ---------- */
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function makeXform(stab, neutral) {
|
|
|
|
|
|
const oval = stab.oval[neutral];
|
2026-09-24 14:38:07 -04:00
|
|
|
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
|
|
|
|
for (const p of oval) {
|
|
|
|
|
|
x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
|
|
|
|
|
|
x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
|
|
|
|
|
|
}
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const s = (RH * 0.80) / (y1 - y0);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
|
|
|
|
|
return (p) => ({ x: (p.x - cx) * s + RW / 2, y: (p.y - cy) * s + RH / 2 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function rebuild(resetKeep) {
|
2026-09-24 14:38:07 -04:00
|
|
|
|
if (!state.dense) return;
|
|
|
|
|
|
const o = opts();
|
2026-09-24 15:38:02 -04:00
|
|
|
|
state.lead = o.lead;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const N = state.dense.length;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 15:03:12 -04:00
|
|
|
|
state.stab = stabilize(state.dense, o.smoothWin, state.aspect);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const ap = state.stab.aperture;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const head = Math.max(1, Math.floor(N / 4));
|
|
|
|
|
|
let neutral = 0;
|
|
|
|
|
|
for (let i = 0; i < head; i++) if (ap[i] < ap[neutral]) neutral = i;
|
|
|
|
|
|
state.neutral = neutral;
|
|
|
|
|
|
state.xform = makeXform(state.stab, neutral);
|
|
|
|
|
|
|
|
|
|
|
|
// Mouth is traced, so it costs nothing: a key on EVERY frame. Only the plate,
|
|
|
|
|
|
// which a human draws, gets decimated.
|
|
|
|
|
|
state.outer = smoothContours(
|
|
|
|
|
|
state.stab.outer.map((r) => toRasterRing(r, LIPS_OUTER, o.verts, state.xform)), o.contourSmooth);
|
|
|
|
|
|
state.inner = smoothContours(
|
|
|
|
|
|
state.stab.inner.map((r) => toRasterRing(r, LIPS_INNER, o.verts, state.xform)), o.contourSmooth);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const apMax = Math.max(...ap);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
state.hidden = ap.map((v) => v / apMax < o.apertureThresh);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
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
|
|
|
|
if (state.images.length && state.extractKey !== extractKey(o)) {
|
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
|
|
|
|
state.interior = measureAll(state.images, state.dense, o);
|
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
|
|
|
|
state.extractKey = extractKey(o);
|
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
|
|
|
|
}
|
2026-09-24 15:12:09 -04:00
|
|
|
|
state.teeth = resolveTeeth(o);
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// Plate outline per frame, so a kept frame shows its own head shape.
|
|
|
|
|
|
state.plates = state.stab.oval.map((r) => r.map(state.xform));
|
|
|
|
|
|
|
|
|
|
|
|
if (resetKeep || !state.keep.size) {
|
|
|
|
|
|
state.keep = new Set(Array.from({ length: N }, (_, i) => i));
|
|
|
|
|
|
}
|
|
|
|
|
|
// Frame 0 must always be kept: something has to be on screen at the start.
|
|
|
|
|
|
state.keep.add(0);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
state.faceBox = faceBoxes();
|
|
|
|
|
|
drawAll();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Face bounding box per frame in image space, for legible strip thumbnails.
|
|
|
|
|
|
function faceBoxes() {
|
|
|
|
|
|
return state.dense.map((lm) => {
|
|
|
|
|
|
let x0 = 1, y0 = 1, x1 = 0, y1 = 0;
|
|
|
|
|
|
for (const i of FACE_OVAL) {
|
|
|
|
|
|
x0 = Math.min(x0, lm[i].x); y0 = Math.min(y0, lm[i].y);
|
|
|
|
|
|
x1 = Math.max(x1, lm[i].x); y1 = Math.max(y1, lm[i].y);
|
|
|
|
|
|
}
|
|
|
|
|
|
const mx = (x1 - x0) * 0.18, my = (y1 - y0) * 0.14;
|
|
|
|
|
|
return { x0: x0 - mx, y0: y0 - my, x1: x1 + mx, y1: y1 + my };
|
|
|
|
|
|
});
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 15:12:09 -04:00
|
|
|
|
// Presence gets hysteresis and a minimum dwell, the same treatment plate
|
|
|
|
|
|
// selection gets: a teeth block that blinks on and off for single frames is
|
|
|
|
|
|
// worse than one that is simply absent. Appearing needs a clear signal, staying
|
|
|
|
|
|
// needs only a weak one.
|
|
|
|
|
|
function resolveTeeth(o) {
|
|
|
|
|
|
const N = state.dense.length;
|
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
|
|
|
|
if (!state.interior) return new Array(N).fill({ show: false, pts: null });
|
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
|
|
|
|
const raw = state.interior.map((m, f) =>
|
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
|
|
|
|
(state.hidden[f] || !m.contour ? 0 : m.contrast));
|
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
|
|
|
|
const on = o.teethOn, off = o.teethOn * 0.7;
|
2026-09-24 15:12:09 -04:00
|
|
|
|
const shown = new Array(N).fill(false);
|
|
|
|
|
|
let live = false, since = 0;
|
|
|
|
|
|
for (let f = 0; f < N; f++) {
|
|
|
|
|
|
const want = live ? raw[f] > off : raw[f] > on;
|
|
|
|
|
|
if (want !== live && since >= o.teethDwell) { live = want; since = 0; }
|
|
|
|
|
|
else since++;
|
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
|
|
|
|
shown[f] = live && !state.hidden[f] && !!state.interior[f].contour;
|
2026-09-24 15:12:09 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// Into raster space through the same chain the lips take, including the
|
|
|
|
|
|
// isotropic aspect conversion - a contour in MediaPipe's normalised space is
|
|
|
|
|
|
// in the same stretched coordinates the landmarks are.
|
|
|
|
|
|
const toRaster = (pts, f) => {
|
|
|
|
|
|
const tf = state.stab.transforms[f];
|
|
|
|
|
|
return pts.map((p) => state.xform(applySim(tf, { x: p.x * state.aspect, y: p.y })));
|
|
|
|
|
|
};
|
|
|
|
|
|
const rast = state.interior.map((m, f) => (m.contour ? toRaster(m.contour, f) : null));
|
|
|
|
|
|
|
|
|
|
|
|
// Radial sampling makes vertex k mean the same direction on every frame, so
|
|
|
|
|
|
// smoothing across time is well defined and cannot reorder anything.
|
|
|
|
|
|
const sm = rast.map((pts, f) => {
|
|
|
|
|
|
if (!pts || !shown[f]) return pts;
|
|
|
|
|
|
const acc = pts.map(() => ({ x: 0, y: 0 }));
|
|
|
|
|
|
let c = 0;
|
|
|
|
|
|
for (let j = f - o.teethSmooth; j <= f + o.teethSmooth; j++) {
|
2026-09-24 15:12:09 -04:00
|
|
|
|
const k = Math.min(N - 1, Math.max(0, j));
|
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
|
|
|
|
if (!shown[k] || !rast[k] || rast[k].length !== pts.length) continue;
|
|
|
|
|
|
for (let v = 0; v < pts.length; v++) { acc[v].x += rast[k][v].x; acc[v].y += rast[k][v].y; }
|
|
|
|
|
|
c++;
|
2026-09-24 15:12:09 -04:00
|
|
|
|
}
|
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
|
|
|
|
return c ? acc.map((p) => ({ x: p.x / c, y: p.y / c })) : pts;
|
2026-09-24 15:12:09 -04:00
|
|
|
|
});
|
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
|
|
|
|
|
|
|
|
|
|
return shown.map((show, f) => ({ show, pts: sm[f] }));
|
2026-09-24 15:12:09 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
/* ---------- render ---------- */
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
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
|
|
|
|
// The plate layer has several representations because its job changes: a flat
|
|
|
|
|
|
// shape to judge the mouth against, or a registered photograph to draw over.
|
|
|
|
|
|
// Only the latter is any use as reference art, and the generated oval is only a
|
|
|
|
|
|
// stand-in until a drawing exists.
|
|
|
|
|
|
function renderFrame(f, mode = plateMode()) {
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const r = new IndexedRaster(RW, RH);
|
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
|
|
|
|
const pf = heldFrame(keptSorted(), f); // the plate frame on screen
|
|
|
|
|
|
|
|
|
|
|
|
if (mode === 'posterize' && state.images[pf]) {
|
|
|
|
|
|
posterizeInto(r, state.images[pf], state.stab.transforms[pf], state.xform,
|
|
|
|
|
|
PALETTE.map((p) => p.hex));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
r.clear(IDX.bg);
|
|
|
|
|
|
if (mode === 'oval' || mode === 'oval+photo') r.fillPoly(state.plates[pf], IDX.base);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 15:34:08 -04:00
|
|
|
|
const mf = leadIndex(f); // performance frame, possibly ahead
|
|
|
|
|
|
r.fillPoly(state.outer[mf], IDX.dark); // mouth keeps every frame
|
|
|
|
|
|
if (!state.hidden[mf]) {
|
|
|
|
|
|
r.fillPoly(state.inner[mf], IDX.mouth);
|
|
|
|
|
|
const te = state.teeth[mf];
|
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
|
|
|
|
if (te.show && te.pts && te.pts.length >= 3) r.fillPoly(te.pts, IDX.teeth);
|
2026-09-24 15:12:09 -04:00
|
|
|
|
}
|
2026-09-24 14:38:07 -04:00
|
|
|
|
return r;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
const plateMode = () => el('plateMode').value;
|
|
|
|
|
|
|
|
|
|
|
|
// Photo modes composite under the indexed layer, so the flat shapes stay exactly
|
|
|
|
|
|
// as they render while the reference sits behind them.
|
|
|
|
|
|
function compositeRender(canvas, f, zoom) {
|
|
|
|
|
|
const mode = plateMode();
|
|
|
|
|
|
const pf = heldFrame(keptSorted(), f);
|
|
|
|
|
|
const img = state.images[pf];
|
|
|
|
|
|
const showPhoto = img && (mode === 'photo' || mode === 'photo-dim' || mode === 'oval+photo');
|
|
|
|
|
|
|
|
|
|
|
|
canvas.width = RW * zoom; canvas.height = RH * zoom;
|
|
|
|
|
|
const g = canvas.getContext('2d');
|
|
|
|
|
|
g.fillStyle = PALETTE[IDX.bg].hex;
|
|
|
|
|
|
g.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
|
|
|
|
|
|
if (showPhoto) {
|
|
|
|
|
|
drawRegistered(g, img, state.stab.transforms[pf], state.xform, zoom,
|
|
|
|
|
|
mode === 'photo-dim' ? 0.34 : 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const r = renderFrame(f, mode === 'oval+photo' ? 'oval' : (showPhoto ? 'off' : mode));
|
|
|
|
|
|
const img2 = r.toImageData(PALETTE.map((p) => p.hex), zoom);
|
|
|
|
|
|
if (showPhoto) {
|
|
|
|
|
|
// Keep the photo visible wherever the indexed layer is background.
|
|
|
|
|
|
const bg = PALETTE[IDX.bg].hex.replace('#', '');
|
|
|
|
|
|
const br = parseInt(bg.slice(0, 2), 16), bgn = parseInt(bg.slice(2, 4), 16), bb = parseInt(bg.slice(4, 6), 16);
|
|
|
|
|
|
const d = img2.data;
|
|
|
|
|
|
for (let i = 0; i < d.length; i += 4) {
|
|
|
|
|
|
if (d[i] === br && d[i + 1] === bgn && d[i + 2] === bb) d[i + 3] = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
const tmp = document.createElement('canvas');
|
|
|
|
|
|
tmp.width = img2.width; tmp.height = img2.height;
|
|
|
|
|
|
tmp.getContext('2d').putImageData(img2, 0, 0);
|
|
|
|
|
|
g.drawImage(tmp, 0, 0);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
g.putImageData(img2, 0, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const keptSorted = () => [...state.keep].sort((a, b) => a - b);
|
|
|
|
|
|
|
2026-09-24 15:34:08 -04:00
|
|
|
|
// Performance tracks can lead the clock.
|
|
|
|
|
|
//
|
|
|
|
|
|
// A centred moving average has no phase lag, so smoothing does not literally
|
|
|
|
|
|
// delay anything - but it blurs onsets, and the visually salient moment of a
|
|
|
|
|
|
// mouth opening moves later even though the mean does not. Animators also draw
|
|
|
|
|
|
// mouth shapes one or two frames ahead of the sound as a matter of course, so
|
|
|
|
|
|
// this is the normal control rather than a correction.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Positive lead = the mouth arrives earlier. Only performance parts shift; the
|
|
|
|
|
|
// head stays with the audio, because it is the mouth that should anticipate.
|
|
|
|
|
|
function leadIndex(f) {
|
2026-09-24 15:38:02 -04:00
|
|
|
|
// Reads a cached scalar, not opts(): this runs once per strip thumbnail, and
|
|
|
|
|
|
// calling opts() here meant ~14 DOM reads x 74 frames on every redraw.
|
|
|
|
|
|
return shiftIndex(f, state.lead, state.dense.length);
|
2026-09-24 15:34:08 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
|
function blit(canvas, raster, zoom) {
|
|
|
|
|
|
canvas.width = RW * zoom; canvas.height = RH * zoom;
|
|
|
|
|
|
canvas.getContext('2d').putImageData(raster.toImageData(PALETTE.map((p) => p.hex), zoom), 0, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function drawAll() {
|
|
|
|
|
|
drawPanes();
|
|
|
|
|
|
drawStrip();
|
|
|
|
|
|
drawWorksheet();
|
|
|
|
|
|
drawReadout();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function drawReadout() {
|
|
|
|
|
|
const kept = keptSorted();
|
|
|
|
|
|
const runs = kept.map((k, i) => (i + 1 < kept.length ? kept[i + 1] : state.dense.length) - k);
|
2026-09-24 15:38:02 -04:00
|
|
|
|
const lead = state.lead;
|
2026-09-24 15:12:09 -04:00
|
|
|
|
const teethFrames = state.teeth ? state.teeth.filter((t) => t.show).length : 0;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
el('readout').textContent =
|
|
|
|
|
|
`${state.dense.length} frames → ${kept.length} drawings · ` +
|
2026-09-24 15:12:09 -04:00
|
|
|
|
`teeth on ${teethFrames}f · ` +
|
2026-09-24 15:34:08 -04:00
|
|
|
|
(lead ? `mouth leads ${lead}f (${(lead / state.fps * 1000).toFixed(0)}ms) · ` : '') +
|
2026-09-24 14:51:15 -04:00
|
|
|
|
`holds ${Math.min(...runs)}–${Math.max(...runs)} frames · ` +
|
|
|
|
|
|
`neutral f${state.neutral} · residual ` +
|
|
|
|
|
|
`${(state.stab.residual.reduce((a, b) => a + b, 0) / state.dense.length).toFixed(4)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function drawPanes() {
|
|
|
|
|
|
const f = state.frame, kept = keptSorted();
|
|
|
|
|
|
const pf = heldFrame(kept, f);
|
2026-09-24 15:38:02 -04:00
|
|
|
|
// The mouth frame is always shown, not only when shifted, so the number can be
|
|
|
|
|
|
// watched diverging from f rather than taken on trust.
|
|
|
|
|
|
const lead = state.lead;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el('framelabel').textContent =
|
2026-09-24 15:38:02 -04:00
|
|
|
|
`f ${f} / ${state.dense.length - 1} · ${(f / state.fps).toFixed(2)}s · ` +
|
|
|
|
|
|
`plate f${pf} · mouth f${leadIndex(f)}` +
|
|
|
|
|
|
(lead ? ` (${lead > 0 ? '+' : ''}${lead} = ${(lead / state.fps * 1000).toFixed(0)}ms)` : '') +
|
2026-09-24 14:51:15 -04:00
|
|
|
|
(state.keep.has(f) ? ' · KEPT' : ' · held');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
|
|
|
|
|
const c1 = el('cv-source'), g1 = c1.getContext('2d');
|
|
|
|
|
|
c1.width = RW * ZOOM; c1.height = RH * ZOOM;
|
|
|
|
|
|
g1.fillStyle = '#000'; g1.fillRect(0, 0, c1.width, c1.height);
|
|
|
|
|
|
const im = state.images[f];
|
|
|
|
|
|
if (im) {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const b = state.faceBox[f];
|
|
|
|
|
|
const sx = b.x0 * im.naturalWidth, sy = b.y0 * im.naturalHeight;
|
|
|
|
|
|
const sw = (b.x1 - b.x0) * im.naturalWidth, sh = (b.y1 - b.y0) * im.naturalHeight;
|
|
|
|
|
|
const s = Math.min(c1.width / sw, c1.height / sh);
|
|
|
|
|
|
const dw = sw * s, dh = sh * s, dx = (c1.width - dw) / 2, dy = (c1.height - dh) / 2;
|
|
|
|
|
|
g1.drawImage(im, sx, sy, sw, sh, dx, dy, dw, dh);
|
|
|
|
|
|
const map = (p) => ({ x: dx + (p.x * im.naturalWidth - sx) * s, y: dy + (p.y * im.naturalHeight - sy) * s });
|
|
|
|
|
|
strokePts(g1, LIPS_OUTER.map((i) => map(state.dense[f][i])), '#4ade80');
|
|
|
|
|
|
strokePts(g1, LIPS_INNER.map((i) => map(state.dense[f][i])), '#f87171');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
} else {
|
|
|
|
|
|
g1.fillStyle = '#555'; g1.font = '13px system-ui';
|
|
|
|
|
|
g1.fillText('synthetic — no source frames', 14, 24);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const sc = (p) => ({ x: p.x * c1.width, y: p.y * c1.height });
|
|
|
|
|
|
strokePts(g1, LIPS_OUTER.map((i) => sc(state.dense[f][i])), '#4ade80');
|
|
|
|
|
|
strokePts(g1, LIPS_INNER.map((i) => sc(state.dense[f][i])), '#f87171');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const c2 = el('cv-stab'), g2 = c2.getContext('2d');
|
|
|
|
|
|
c2.width = RW * ZOOM; c2.height = RH * ZOOM;
|
|
|
|
|
|
g2.fillStyle = '#0d0f16'; g2.fillRect(0, 0, c2.width, c2.height);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
g2.strokeStyle = '#2a2f3e'; g2.lineWidth = 1; g2.beginPath();
|
2026-09-24 14:38:07 -04:00
|
|
|
|
g2.moveTo(c2.width / 2, 0); g2.lineTo(c2.width / 2, c2.height);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
g2.moveTo(0, c2.height / 2); g2.lineTo(c2.width, c2.height / 2); g2.stroke();
|
|
|
|
|
|
const z = (pts) => pts.map((p) => ({ x: p.x * ZOOM, y: p.y * ZOOM }));
|
2026-09-24 15:34:08 -04:00
|
|
|
|
const mf = leadIndex(f);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
strokePts(g2, z(state.plates[pf]), '#3b4a63');
|
2026-09-24 15:38:02 -04:00
|
|
|
|
// With a lead set, the unshifted contour is drawn as a ghost so the offset is
|
|
|
|
|
|
// something you can see rather than something you have to trust.
|
|
|
|
|
|
if (mf !== f) strokePts(g2, z(state.outer[f]), '#2f6b46');
|
2026-09-24 15:34:08 -04:00
|
|
|
|
strokePts(g2, z(state.outer[mf]), '#4ade80');
|
|
|
|
|
|
if (!state.hidden[mf]) strokePts(g2, z(state.inner[mf]), '#f87171');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
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
|
|
|
|
compositeRender(el('cv-render'), f, ZOOM);
|
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
|
|
|
|
drawInteriorDebug(f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// What the teeth measurement actually saw: sampled region, pixels above
|
|
|
|
|
|
// threshold in green, the resolved line in amber. Recomputed for the current
|
|
|
|
|
|
// frame only, so it costs nothing to keep on screen.
|
2026-09-24 15:34:08 -04:00
|
|
|
|
function drawInteriorDebug(fRaw) {
|
|
|
|
|
|
const f = leadIndex(fRaw);
|
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
|
|
|
|
const host = el('cv-teeth');
|
|
|
|
|
|
const img = state.images[f];
|
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
|
|
|
|
if (!img || state.hidden[f]) {
|
|
|
|
|
|
host.innerHTML = '';
|
|
|
|
|
|
el('teethinfo').textContent = state.images.length ? 'mouth closed' : 'no source frames';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
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
|
|
|
|
const o = opts();
|
|
|
|
|
|
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
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 m = extractTeeth(img, LIPS_INNER.map((k) => state.dense[f][k]), ctx, o, true);
|
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
|
|
|
|
host.innerHTML = '';
|
|
|
|
|
|
if (m.debug) {
|
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
|
|
|
|
m.debug.style.width = '170px';
|
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
|
|
|
|
m.debug.style.imageRendering = 'pixelated';
|
|
|
|
|
|
host.append(m.debug);
|
|
|
|
|
|
}
|
|
|
|
|
|
const te = state.teeth[f];
|
|
|
|
|
|
el('teethinfo').textContent =
|
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
|
|
|
|
`contrast ${m.contrast.toFixed(3)} / gate ${o.teethOn.toFixed(2)} · ` +
|
|
|
|
|
|
`area ${m.area}px · ${te.show ? 'SHOWN' : 'hidden'}`;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function strokePts(g, pts, color, lw = 1) {
|
|
|
|
|
|
g.strokeStyle = color; g.lineWidth = lw;
|
|
|
|
|
|
g.beginPath();
|
|
|
|
|
|
pts.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y)));
|
|
|
|
|
|
g.closePath(); g.stroke();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
/* ---------- the frame strip: this is the editing surface ---------- */
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function drawStrip() {
|
|
|
|
|
|
const host = el('strip');
|
|
|
|
|
|
host.innerHTML = '';
|
|
|
|
|
|
const N = state.dense.length;
|
|
|
|
|
|
for (let f = 0; f < N; f++) {
|
|
|
|
|
|
const cell = document.createElement('div');
|
|
|
|
|
|
cell.className = 'fr' + (state.keep.has(f) ? ' keep' : ' drop') + (f === state.frame ? ' cur' : '');
|
|
|
|
|
|
cell.dataset.f = f;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const cv = document.createElement('canvas');
|
|
|
|
|
|
const im = state.images[f];
|
|
|
|
|
|
cv.width = THUMB; cv.height = THUMB;
|
|
|
|
|
|
const g = cv.getContext('2d');
|
|
|
|
|
|
g.fillStyle = '#0d0f16'; g.fillRect(0, 0, THUMB, THUMB);
|
|
|
|
|
|
if (im) {
|
|
|
|
|
|
const b = state.faceBox[f];
|
|
|
|
|
|
const sx = b.x0 * im.naturalWidth, sy = b.y0 * im.naturalHeight;
|
|
|
|
|
|
const sw = (b.x1 - b.x0) * im.naturalWidth, sh = (b.y1 - b.y0) * im.naturalHeight;
|
|
|
|
|
|
const s = Math.min(THUMB / sw, THUMB / sh);
|
|
|
|
|
|
g.drawImage(im, sx, sy, sw, sh, (THUMB - sw * s) / 2, (THUMB - sh * s) / 2, sw * s, sh * s);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const r = new IndexedRaster(RW, RH);
|
|
|
|
|
|
r.clear(IDX.bg); r.fillPoly(state.plates[f], IDX.base);
|
|
|
|
|
|
const tmp = document.createElement('canvas');
|
|
|
|
|
|
blit(tmp, r, 1);
|
|
|
|
|
|
g.drawImage(tmp, 0, 0, RW, RH, 0, 0, THUMB, THUMB * (RH / RW));
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
2026-09-24 14:51:15 -04:00
|
|
|
|
|
|
|
|
|
|
const tag = document.createElement('span');
|
|
|
|
|
|
tag.textContent = f;
|
|
|
|
|
|
cell.append(cv, tag);
|
|
|
|
|
|
cell.onclick = (ev) => {
|
2026-09-24 14:53:09 -04:00
|
|
|
|
seekTo(f);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
if (ev.shiftKey) toggle(f);
|
|
|
|
|
|
drawAll();
|
|
|
|
|
|
};
|
|
|
|
|
|
cell.ondblclick = () => { toggle(f); drawAll(); };
|
|
|
|
|
|
host.append(cell);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
function toggle(f) {
|
|
|
|
|
|
if (f === 0) return; // frame 0 always has a drawing
|
|
|
|
|
|
if (state.keep.has(f)) state.keep.delete(f); else state.keep.add(f);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------- worksheet: the frames a human must draw ---------- */
|
|
|
|
|
|
|
|
|
|
|
|
function drawWorksheet() {
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const host = el('sheet');
|
|
|
|
|
|
host.innerHTML = '';
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const kept = keptSorted();
|
|
|
|
|
|
kept.forEach((f, i) => {
|
|
|
|
|
|
const until = (i + 1 < kept.length ? kept[i + 1] : state.dense.length) - 1;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const cell = document.createElement('div');
|
|
|
|
|
|
cell.className = 'cell';
|
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
|
|
|
|
// Registered, not raw-cropped: the worksheet frame is in raster space, so a
|
|
|
|
|
|
// drawing traced from it is already aligned to the mouth.
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const cv = document.createElement('canvas');
|
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
|
|
|
|
compositeRender(cv, f, 1);
|
|
|
|
|
|
cv.style.width = '176px';
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const cap = document.createElement('span');
|
2026-09-24 14:51:15 -04:00
|
|
|
|
cap.textContent = `f${f}` + (until > f ? ` → ${until}` : '') + ` (${until - f + 1}f)`;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
cell.append(cv, cap);
|
2026-09-24 14:53:09 -04:00
|
|
|
|
cell.onclick = () => { seekTo(f); drawAll(); };
|
2026-09-24 14:38:07 -04:00
|
|
|
|
host.append(cell);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------- export ---------- */
|
|
|
|
|
|
|
|
|
|
|
|
function exportTake() {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const kept = keptSorted();
|
|
|
|
|
|
const N = state.dense.length;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const take = {
|
|
|
|
|
|
name: el('takename').value || 'line_01',
|
2026-09-24 14:53:09 -04:00
|
|
|
|
frames: N, width: RW, height: RH, exposure: 1, fps: state.fps,
|
2026-09-24 14:38:07 -04:00
|
|
|
|
palette: PALETTE,
|
|
|
|
|
|
slot: { x: RW / 2, y: RH / 2 },
|
|
|
|
|
|
parts: [
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// Sparse: one plate key per frame a human draws.
|
|
|
|
|
|
{ name: 'head', kind: 'plate', z: 0, interp: 'hold',
|
|
|
|
|
|
keys: kept.map((f, i) => ({ f: i, plate: i, src: f })) },
|
2026-09-24 15:34:08 -04:00
|
|
|
|
// Dense: the traced mouth keeps every frame. The lead is baked in here -
|
|
|
|
|
|
// key f carries the pose from source frame f+lead - so the renderer never
|
|
|
|
|
|
// needs to know about it.
|
2026-09-24 14:38:07 -04:00
|
|
|
|
{ name: 'mouth', kind: 'poly', z: 30, color: 'skin_dark', interp: 'hold',
|
2026-09-24 15:34:08 -04:00
|
|
|
|
keys: state.outer.map((_, f) => ({ f, src: leadIndex(f), pts: state.outer[leadIndex(f)] })) },
|
2026-09-24 14:38:07 -04:00
|
|
|
|
{ name: 'mouth_in', kind: 'poly', z: 31, color: 'mouth_dark', interp: 'hold', parent: 'mouth',
|
2026-09-24 15:34:08 -04:00
|
|
|
|
keys: state.inner.map((_, f) => {
|
|
|
|
|
|
const m = leadIndex(f);
|
|
|
|
|
|
return state.hidden[m] ? { f, hidden: true } : { f, src: m, pts: state.inner[m] };
|
|
|
|
|
|
}) },
|
2026-09-24 15:12:09 -04:00
|
|
|
|
{ name: 'teeth', kind: 'poly', z: 32, color: 'teeth', interp: 'hold', parent: 'mouth_in',
|
2026-09-24 15:34:08 -04:00
|
|
|
|
keys: state.teeth.map((_, f) => {
|
|
|
|
|
|
const m = leadIndex(f), te = state.teeth[m];
|
|
|
|
|
|
return te.show && te.pts ? { f, src: m, pts: te.pts } : { f, hidden: true };
|
|
|
|
|
|
}) },
|
2026-09-24 14:38:07 -04:00
|
|
|
|
],
|
|
|
|
|
|
};
|
2026-09-24 14:51:15 -04:00
|
|
|
|
const text = writeTake(take)
|
|
|
|
|
|
+ `\n# plate drawings needed: ${kept.length} of ${N} frames\n`
|
|
|
|
|
|
+ kept.map((f, i) => {
|
|
|
|
|
|
const until = (i + 1 < kept.length ? kept[i + 1] : N) - 1;
|
|
|
|
|
|
return `# plate ${i} = source frame ${f}, holds f${f}..${until}`;
|
|
|
|
|
|
}).join('\n') + '\n';
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
|
|
|
|
|
|
a.download = `${take.name}.take`;
|
|
|
|
|
|
a.click();
|
2026-09-24 14:51:15 -04:00
|
|
|
|
status(`exported — ${kept.length} plate drawings, ${N} mouth frames`, 'ok');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------- wiring ---------- */
|
|
|
|
|
|
|
|
|
|
|
|
async function runFrames() {
|
|
|
|
|
|
try {
|
2026-09-24 14:53:09 -04:00
|
|
|
|
const man = await loadManifest();
|
|
|
|
|
|
if (man) {
|
|
|
|
|
|
state.fps = man.fps;
|
|
|
|
|
|
el('framedir').value = man.dir || 'frames';
|
|
|
|
|
|
attachAudio(man.audio);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
attachAudio(null);
|
|
|
|
|
|
status('no manifest.json — assuming 12fps, no audio. Re-run extract.sh', 'warn');
|
|
|
|
|
|
}
|
2026-09-24 14:38:07 -04:00
|
|
|
|
const images = await loadFrameSequence();
|
|
|
|
|
|
if (!images.length) {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
status(`no frames in ${el('framedir').value}/ — run extract.sh first`, 'err');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const { dense, missing } = await detectAll(images);
|
2026-09-24 14:51:15 -04:00
|
|
|
|
state.images = images; state.dense = dense;
|
2026-09-24 15:03:12 -04:00
|
|
|
|
state.aspect = images[0].naturalWidth / images[0].naturalHeight;
|
2026-09-24 15:12:09 -04:00
|
|
|
|
status('measuring mouth interiors…');
|
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
|
|
|
|
state.interior = measureAll(images, dense, opts());
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el('scrub').max = dense.length - 1;
|
|
|
|
|
|
state.frame = 0;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
rebuild(true);
|
2026-09-24 14:53:09 -04:00
|
|
|
|
const dur = (dense.length / state.fps).toFixed(2);
|
2026-09-24 15:03:12 -04:00
|
|
|
|
status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` +
|
|
|
|
|
|
`${state.fps}fps · ${dur}s` +
|
2026-09-24 14:53:09 -04:00
|
|
|
|
(state.audio ? ' · audio loaded' : ' · no audio') +
|
|
|
|
|
|
(missing.length ? ` · no face on ${missing.length} (held previous)` : ''),
|
|
|
|
|
|
missing.length ? 'warn' : 'ok');
|
2026-09-24 14:51:15 -04:00
|
|
|
|
} catch (e) { status(e.message, 'err'); console.error(e); }
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function runSynthetic() {
|
|
|
|
|
|
state.images = [];
|
2026-09-24 14:53:09 -04:00
|
|
|
|
attachAudio(null);
|
|
|
|
|
|
state.fps = 12;
|
2026-09-24 15:03:12 -04:00
|
|
|
|
state.aspect = 1; // synthetic landmarks are generated square
|
2026-09-24 15:38:02 -04:00
|
|
|
|
state.lead = 0;
|
2026-09-24 15:12:09 -04:00
|
|
|
|
state.interior = null; // no pixels, so no teeth
|
2026-09-24 15:03:12 -04:00
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
|
state.dense = synthDense(72);
|
|
|
|
|
|
el('scrub').max = 71;
|
|
|
|
|
|
state.frame = 0;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
rebuild(true);
|
|
|
|
|
|
status('synthetic — exercises everything below detection', 'ok');
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
for (const id of ['verts', 'smoothWin', 'contourSmooth', 'apertureThresh', 'tol',
|
|
|
|
|
|
'teethOn', 'teethDwell', 'teethErode', 'tongueReject', 'blobGrow',
|
2026-09-24 15:34:08 -04:00
|
|
|
|
'topBias', 'teethVerts', 'teethSmooth', 'lead']) {
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el(id).addEventListener('input', () => {
|
2026-09-24 14:51:15 -04:00
|
|
|
|
el(id + 'v').textContent = id === 'apertureThresh' || id === 'tol'
|
2026-09-24 15:12:09 -04:00
|
|
|
|
? (+el(id).value / 1000).toFixed(3)
|
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
|
|
|
|
: ['teethOn', 'teethErode', 'tongueReject', 'topBias'].includes(id)
|
|
|
|
|
|
? (+el(id).value / 100).toFixed(2)
|
2026-09-24 15:38:02 -04:00
|
|
|
|
: id === 'lead' && +el(id).value > 0 ? `+${el(id).value}`
|
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
|
|
|
|
: el(id).value;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
if (id === 'tol') return; // tol only matters when you ask for a suggestion
|
|
|
|
|
|
rebuild(false);
|
2026-09-24 14:38:07 -04:00
|
|
|
|
});
|
|
|
|
|
|
el(id + 'v').textContent = el(id).value;
|
|
|
|
|
|
}
|
2026-09-24 14:51:15 -04:00
|
|
|
|
|
2026-09-24 14:53:09 -04:00
|
|
|
|
function seekTo(f) {
|
|
|
|
|
|
state.frame = f;
|
|
|
|
|
|
if (state.audio) state.audio.currentTime = f / state.fps;
|
|
|
|
|
|
el('scrub').value = f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
el('scrub').addEventListener('input', (e) => { seekTo(+e.target.value); drawAll(); });
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el('btn-frames').onclick = runFrames;
|
|
|
|
|
|
el('btn-synth').onclick = runSynthetic;
|
|
|
|
|
|
el('btn-export').onclick = exportTake;
|
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
|
|
|
|
el('plateMode').addEventListener('change', () => { if (state.dense) drawAll(); });
|
|
|
|
|
|
el('btn-saveframe').onclick = () => {
|
|
|
|
|
|
if (!state.dense) return;
|
|
|
|
|
|
const cv = document.createElement('canvas');
|
|
|
|
|
|
compositeRender(cv, state.frame, 4); // 1280x800: enough to draw on
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = cv.toDataURL('image/png');
|
|
|
|
|
|
a.download = `${el('takename').value || 'take'}_f${String(state.frame).padStart(4, '0')}.png`;
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
status(`saved registered frame f${state.frame} at 4x`, 'ok');
|
|
|
|
|
|
};
|
2026-09-24 14:51:15 -04:00
|
|
|
|
el('btn-keepall').onclick = () => { if (state.dense) { rebuild(true); } };
|
|
|
|
|
|
el('btn-suggest').onclick = () => {
|
|
|
|
|
|
if (!state.dense) return;
|
|
|
|
|
|
const kept = suggestPlateFrames(state.stab.rigid, opts().tol);
|
|
|
|
|
|
state.keep = new Set(kept);
|
|
|
|
|
|
drawAll();
|
|
|
|
|
|
status(`suggested ${kept.length} drawings at tolerance ${opts().tol.toFixed(3)} — now hand-correct`, 'ok');
|
|
|
|
|
|
};
|
2026-09-24 14:38:07 -04:00
|
|
|
|
el('btn-play').onclick = () => {
|
2026-09-24 14:53:09 -04:00
|
|
|
|
if (!state.dense) return;
|
2026-09-24 14:38:07 -04:00
|
|
|
|
state.playing = !state.playing;
|
|
|
|
|
|
el('btn-play').textContent = state.playing ? 'Stop' : 'Play';
|
2026-09-24 14:53:09 -04:00
|
|
|
|
if (state.playing) {
|
|
|
|
|
|
if (state.audio) {
|
|
|
|
|
|
state.audio.playbackRate = +el('speed').value;
|
|
|
|
|
|
// Restart from the top if we are sitting at the end.
|
|
|
|
|
|
if (state.frame >= state.dense.length - 1) state.frame = 0;
|
|
|
|
|
|
state.audio.currentTime = state.frame / state.fps;
|
|
|
|
|
|
state.audio.play().catch((e) => status('audio blocked: ' + e.message, 'warn'));
|
|
|
|
|
|
}
|
|
|
|
|
|
last = 0;
|
|
|
|
|
|
tick();
|
|
|
|
|
|
} else if (state.audio) {
|
|
|
|
|
|
state.audio.pause();
|
|
|
|
|
|
}
|
2026-09-24 14:38:07 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-24 14:53:09 -04:00
|
|
|
|
el('speed').addEventListener('change', () => {
|
|
|
|
|
|
// playbackRate retimes the clock, and the picture follows it for free.
|
|
|
|
|
|
if (state.audio) state.audio.playbackRate = +el('speed').value;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// Keyboard is the point: stepping and deleting 37 frames by mouse is miserable.
|
|
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
|
|
|
|
if (!state.dense || e.target.tagName === 'INPUT') return;
|
|
|
|
|
|
const N = state.dense.length;
|
2026-09-24 14:53:09 -04:00
|
|
|
|
if (e.key === 'ArrowRight') { seekTo(Math.min(N - 1, state.frame + 1)); drawAll(); }
|
|
|
|
|
|
else if (e.key === 'ArrowLeft') { seekTo(Math.max(0, state.frame - 1)); drawAll(); }
|
2026-09-24 14:51:15 -04:00
|
|
|
|
else if (e.key === 'Backspace' || e.key === 'Delete' || e.key === 'x') {
|
|
|
|
|
|
state.keep.delete(state.frame === 0 ? -1 : state.frame); drawAll();
|
2026-09-24 15:34:08 -04:00
|
|
|
|
} else if (e.key === '[' || e.key === ']') {
|
|
|
|
|
|
const n = el('lead');
|
|
|
|
|
|
n.value = Math.max(+n.min, Math.min(+n.max, +n.value + (e.key === ']' ? 1 : -1)));
|
2026-09-24 15:38:02 -04:00
|
|
|
|
state.lead = +n.value;
|
|
|
|
|
|
el('leadv').textContent = state.lead > 0 ? `+${state.lead}` : String(state.lead);
|
2026-09-24 15:34:08 -04:00
|
|
|
|
drawAll();
|
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
|
|
|
|
} else if (e.key === 'b') {
|
|
|
|
|
|
const sel = el('plateMode');
|
|
|
|
|
|
sel.selectedIndex = (sel.selectedIndex + 1) % sel.options.length;
|
|
|
|
|
|
drawAll();
|
2026-09-24 14:51:15 -04:00
|
|
|
|
} else if (e.key === 'k' || e.key === ' ') {
|
|
|
|
|
|
if (state.frame !== 0) state.keep.add(state.frame);
|
|
|
|
|
|
drawAll();
|
|
|
|
|
|
} else return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 14:38:07 -04:00
|
|
|
|
let last = 0;
|
|
|
|
|
|
function tick(ts = 0) {
|
|
|
|
|
|
if (!state.playing) return;
|
2026-09-24 14:53:09 -04:00
|
|
|
|
const N = state.dense.length;
|
|
|
|
|
|
let f;
|
|
|
|
|
|
if (state.audio) {
|
|
|
|
|
|
// Audio is the clock. Deriving the frame from currentTime rather than
|
|
|
|
|
|
// counting means a slow render loop drops frames instead of drifting out
|
|
|
|
|
|
// of sync, which is the behaviour you want when judging lip sync.
|
|
|
|
|
|
f = Math.floor(state.audio.currentTime * state.fps);
|
|
|
|
|
|
if (f >= N || state.audio.ended) {
|
|
|
|
|
|
state.audio.currentTime = 0;
|
|
|
|
|
|
state.audio.play().catch(() => {});
|
|
|
|
|
|
f = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const rate = state.fps * +el('speed').value;
|
|
|
|
|
|
if (ts - last < 1000 / rate) { requestAnimationFrame(tick); return; }
|
2026-09-24 14:38:07 -04:00
|
|
|
|
last = ts;
|
2026-09-24 14:53:09 -04:00
|
|
|
|
f = (state.frame + 1) % N;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (f !== state.frame) {
|
|
|
|
|
|
state.frame = f;
|
|
|
|
|
|
el('scrub').value = f;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
drawPanes(); drawStrip();
|
2026-09-24 14:38:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
requestAnimationFrame(tick);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
PALETTE.forEach((p) => {
|
|
|
|
|
|
const sw = document.createElement('label');
|
|
|
|
|
|
sw.className = 'sw';
|
|
|
|
|
|
const inp = document.createElement('input');
|
|
|
|
|
|
inp.type = 'color'; inp.value = p.hex;
|
2026-09-24 14:51:15 -04:00
|
|
|
|
inp.oninput = () => { p.hex = inp.value; if (state.dense) drawAll(); };
|
2026-09-24 14:38:07 -04:00
|
|
|
|
sw.append(inp, document.createTextNode(p.name));
|
|
|
|
|
|
el('palette').append(sw);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
// #synth / #frames autorun, so the tool can be driven headlessly for smoke tests
|
|
|
|
|
|
// and deep-linked. Detection needs WebGL; the synthetic path does not.
|
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
|
|
|
|
window.addEventListener('error', (e) => {
|
|
|
|
|
|
const s = document.getElementById('status');
|
|
|
|
|
|
if (s) { s.textContent = e.message; s.className = 'err'; }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-24 14:51:15 -04:00
|
|
|
|
if (location.hash === '#synth') runSynthetic();
|
|
|
|
|
|
else if (location.hash === '#frames') runFrames();
|
|
|
|
|
|
else status('ready — Load frames, then step with \u2190 \u2192 and delete with X');
|
|
|
|
|
|
|
|
|
|
|
|
window.__roto = state; // headless smoke test reads this
|