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>
This commit is contained in:
Your Name 2026-09-24 15:24:52 -04:00
parent c6daf827d1
commit 9a11eeabc1
7 changed files with 272 additions and 275 deletions

View file

@ -12,7 +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, otsuForTest } from './interior.js';
import { otsuForTest, scaleRing } from './interior.js';
import { synthDense } from './synth.js';
const results = [];
@ -182,25 +182,17 @@ 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.
// scaleRing is what pulls the sampled region in from MediaPipe's inner lip
// landmarks, which sit slightly outside the real opening.
{
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);
const small = scaleRing(ring, 0.5);
const w = Math.max(...small.map((p) => p.x)) - Math.min(...small.map((p) => p.x));
ok('scaleRing(0.5) halves the extent', Math.abs(w - 5) < 1e-9, `width ${w}`);
const same = scaleRing(ring, 1);
ok('scaleRing(1) is identity', same.every((p, i) => Math.abs(p.x - ring[i].x) < 1e-9));
let cx = 0; for (const p of small) cx += p.x;
ok('scaleRing keeps the centroid', Math.abs(cx / 4 - 5) < 1e-9);
}
// Otsu on a uniform region must report near-zero class separation. It will