Teeth from mouth-interior image content

MediaPipe has no landmarks inside the lips, so teeth have to come from pixels.
Tracing the bright blob would give a new contour every frame with no vertex
correspondence - the exact boil docs/roto-puppet.md warns about.

So the measurement yields a scalar, not a shape: Otsu within the cavity, scanned
from the top for where the bright run stops, giving one line height per frame.
The teeth polygon is the inner lip ring clipped to that line, so the silhouette
is always the mouth's own shape and cannot disagree with the lips around it,
and the only per-frame variable is a single number that smooths trivially.

Presence gets hysteresis and minimum dwell, as plate selection does: a teeth
block blinking on and off for single frames is worse than one simply absent.

Tongue is not implemented. The same scalar approach would apply, gated on
redness rather than brightness, but it is not visible in the test footage - the
cavity reads dark with a bright upper-teeth band and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-24 15:12:09 -04:00
parent 29b0bd6690
commit 941022b69f
5 changed files with 242 additions and 5 deletions

View file

@ -12,6 +12,7 @@ import { fitSimilarity, applySim, procrustesMean, smoothTransforms } from './mat
import { stabilize, toRasterRing, selectKeys, activeKey } from './pipeline.js';
import { IndexedRaster, hexToRgb } from './raster.js';
import { writeTake } from './take.js';
import { clipPolyAbove } from './interior.js';
import { synthDense } from './synth.js';
const results = [];
@ -181,6 +182,27 @@ export function run() {
ok('palette expansion introduces no intermediate colours',
[...seen].every((c) => allowed.has(c)), `${seen.size} distinct colours`);
// teeth band: the inner ring clipped to a height. The point of doing it this
// way is that the silhouette is the mouth's own shape, so it can never
// disagree with the lips - assert that rather than the pixel measurement.
{
const ring = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }];
const half = clipPolyAbove(ring, 5);
ok('clip at mid height halves the box',
half.length === 4 && Math.max(...half.map((p) => p.y)) === 5,
`${half.length} pts, maxY ${Math.max(...half.map((p) => p.y))}`);
ok('clip above everything keeps the ring', clipPolyAbove(ring, 99).length === 4);
ok('clip below everything empties it', clipPolyAbove(ring, -1).length === 0);
const w = clipPolyAbove(ring, 5);
ok('clipped band keeps the ring width',
Math.min(...w.map((p) => p.x)) === 0 && Math.max(...w.map((p) => p.x)) === 10);
// a non-convex ring must not gain or lose x-extent from clipping
const tri = [{ x: 0, y: 0 }, { x: 8, y: 2 }, { x: 4, y: 9 }];
const ct = clipPolyAbove(tri, 4);
ok('clip of a non-convex-ish ring stays within its x range',
Math.min(...ct.map((p) => p.x)) >= 0 && Math.max(...ct.map((p) => p.x)) <= 8);
}
// take writer round-trip
const take = {
name: 'test', frames: 72, width: 320, height: 200, exposure: 2,