diff --git a/README.md b/README.md
index 8c8f043..af8dca5 100644
--- a/README.md
+++ b/README.md
@@ -65,25 +65,33 @@ is hand-drawn head plates, which this tool does not yet do.
## Teeth
MediaPipe has no landmarks inside the lips: the inner ring bounds the cavity and
-everything within it is just pixels. So teeth come from the image — but tracing
-the bright blob would produce a new contour every frame with no vertex
-correspondence, which is precisely the boil the design exists to avoid.
+everything within it is just pixels. So teeth come from the image.
-So the extraction yields a **scalar, not a shape**. The teeth polygon is the inner
-lip ring clipped to a horizontal line, and only that line's height is measured
-(Otsu threshold within the cavity, scanned from the top). The silhouette is
-therefore always the mouth's own shape — stable by construction — and the only
-per-frame variable is one number, which smooths trivially. It is also how the
-shape gets drawn by hand: a band bounded by the lip.
+The hazard is vertex correspondence — a traced contour reorders between frames
+and boils. The way out for a blob specifically is **radial sampling**: march
+outward from the blob's centroid along N fixed directions and take the last pixel
+inside. Vertex *k* is then always "the extent in direction *k*", so correspondence
+holds by construction, the vertex count is fixed, and temporal smoothing is well
+defined with no reordering possible. It also produces a star-shaped reduction,
+which is what flat blocks of colour want.
-Presence uses hysteresis plus a minimum dwell, the same treatment plate selection
-gets, because a teeth block that blinks on and off for single frames is worse
-than one that is simply absent.
+The **teeth measurement** panel shows exactly what is sampled: region dimmed,
+kept pixels green, extracted contour amber. Tune against that, not the numbers.
-**Tongue** would work the same way — a shape filling the lower cavity, gated on a
-redness rather than a brightness statistic. Not implemented, because it is not
-visible in the test footage: the cavity reads as dark with a bright upper-teeth
-band and nothing else.
+| Knob | What it does |
+| --- | --- |
+| teeth contrast | Gate on the separation between the cavity's dark and bright class means. Otsu always returns *some* threshold, so this is what stops it inventing teeth in a dark mouth. |
+| cavity erode | Pulls the sampled region in from the lip edge — MediaPipe's inner lip landmarks sit slightly outside the real opening, and lips are bright. |
+| blob grow/erode | Resizes the found blob. An open pass always runs first to despeckle. |
+| tongue reject | Drops pixels red relative to their own brightness. Teeth are near-neutral; tongue is not. |
+| prefer upper | Biases component choice toward the top of the cavity. Area alone picks the tongue when the mouth is wide. |
+| teeth vertices | Radial sample count. |
+| teeth avg ±f | Temporal average over the contour. |
+| teeth dwell | Frames a presence change must persist before it takes effect. |
+
+**Tongue** as its own part would work the same way, gated on redness instead of
+brightness and biased low rather than high. Not implemented: it is not visible in
+the test footage, which reads as a dark cavity with a bright upper-teeth band.
## The plate is reference, not art
diff --git a/gl.html b/gl.html
deleted file mode 100644
index b2b042c..0000000
--- a/gl.html
+++ /dev/null
@@ -1,6 +0,0 @@
-
palette
diff --git a/js/app.js b/js/app.js
index b569ccf..e15e444 100644
--- a/js/app.js
+++ b/js/app.js
@@ -3,7 +3,8 @@ import { LIPS_OUTER, LIPS_INNER, FACE_OVAL } from './landmarks.js';
import { stabilize, toRasterRing, smoothContours, suggestPlateFrames, heldFrame } from './pipeline.js';
import { IndexedRaster } from './raster.js';
import { drawRegistered, posterizeInto } from './underlay.js';
-import { measureInterior, clipPolyAbove } from './interior.js';
+import { extractTeeth } from './interior.js';
+import { applySim } from './mathutil.js';
import { writeTake } from './take.js';
import { synthDense } from './synth.js';
@@ -29,12 +30,23 @@ const state = {
teeth: null, // resolved per-frame {show, t} after knobs
};
-const el = (id) => document.getElementById(id);
+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;
+};
const opts = () => ({
verts: +el('verts').value,
teethOn: +el('teethOn').value / 100, // minimum Otsu class separation
teethDwell: +el('teethDwell').value,
- teethErode: +el('teethErode').value / 100,
+ teethSmooth: +el('teethSmooth').value,
+ cavityErode: +el('teethErode').value / 100,
+ tongueReject: +el('tongueReject').value / 100,
+ blobGrow: +el('blobGrow').value,
+ topBias: +el('topBias').value / 100,
+ verts: +el('teethVerts').value,
smoothWin: +el('smoothWin').value,
contourSmooth: +el('contourSmooth').value,
apertureThresh: +el('apertureThresh').value / 1000,
@@ -127,10 +139,14 @@ async function detectAll(images) {
function measureAll(images, dense, o) {
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
return dense.map((lm, i) =>
- measureInterior(images[i], LIPS_INNER.map((k) => lm[k]), ctx,
- { minContrast: 0, erode: o.teethErode })); // gate applied later, in resolveTeeth
+ extractTeeth(images[i], LIPS_INNER.map((k) => lm[k]), ctx, o));
}
+// 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) =>
+ [o.cavityErode, o.tongueReject, o.blobGrow, o.topBias, o.verts].join('|');
+
/* ---------- build ---------- */
function makeXform(stab, neutral) {
@@ -169,9 +185,9 @@ function rebuild(resetKeep) {
const apMax = Math.max(...ap);
state.hidden = ap.map((v) => v / apMax < o.apertureThresh);
- if (state.images.length && (!state.interior || state.erodeUsed !== o.teethErode)) {
+ if (state.images.length && state.extractKey !== extractKey(o)) {
state.interior = measureAll(state.images, state.dense, o);
- state.erodeUsed = o.teethErode;
+ state.extractKey = extractKey(o);
}
state.teeth = resolveTeeth(o);
@@ -207,13 +223,10 @@ function faceBoxes() {
// needs only a weak one.
function resolveTeeth(o) {
const N = state.dense.length;
- if (!state.interior) return new Array(N).fill({ show: false, t: 0 });
+ if (!state.interior) return new Array(N).fill({ show: false, pts: null });
- // Gate on the separation between Otsu's two class means, not on how much of
- // the cavity is bright: coverage is high both when the mouth is full of teeth
- // and when the region is uniformly dark and Otsu has split noise.
const raw = state.interior.map((m, f) =>
- (state.hidden[f] || m.teethT <= 0 ? 0 : m.contrast));
+ (state.hidden[f] || !m.contour ? 0 : m.contrast));
const on = o.teethOn, off = o.teethOn * 0.7;
const shown = new Array(N).fill(false);
let live = false, since = 0;
@@ -221,22 +234,34 @@ function resolveTeeth(o) {
const want = live ? raw[f] > off : raw[f] > on;
if (want !== live && since >= o.teethDwell) { live = want; since = 0; }
else since++;
- shown[f] = live && !state.hidden[f];
+ shown[f] = live && !state.hidden[f] && !!state.interior[f].contour;
}
- // Smooth the line height only across frames where it is actually shown, so a
- // gap does not drag the band toward zero on either side of it.
- const t = state.interior.map((m) => m.teethT);
- const sm = t.map((_, f) => {
- let acc = 0, c = 0;
- for (let j = f - o.contourSmooth; j <= f + o.contourSmooth; j++) {
+ // 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++) {
const k = Math.min(N - 1, Math.max(0, j));
- if (!shown[k]) continue;
- acc += t[k]; c++;
+ 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++;
}
- return c ? acc / c : t[f];
+ return c ? acc.map((p) => ({ x: p.x / c, y: p.y / c })) : pts;
});
- return shown.map((show, f) => ({ show, t: sm[f] }));
+
+ return shown.map((show, f) => ({ show, pts: sm[f] }));
}
/* ---------- render ---------- */
@@ -261,14 +286,7 @@ function renderFrame(f, mode = plateMode()) {
if (!state.hidden[f]) {
r.fillPoly(state.inner[f], IDX.mouth);
const te = state.teeth[f];
- if (te.show) {
- // The band is the mouth's own silhouette clipped to a height, so the shape
- // can never disagree with the lips around it.
- const ys = state.inner[f].map((p) => p.y);
- const top = Math.min(...ys), bot = Math.max(...ys);
- const poly = clipPolyAbove(state.inner[f], top + te.t * (bot - top));
- if (poly.length >= 3) r.fillPoly(poly, IDX.teeth);
- }
+ if (te.show && te.pts && te.pts.length >= 3) r.fillPoly(te.pts, IDX.teeth);
}
return r;
}
@@ -388,21 +406,24 @@ function drawPanes() {
function drawInteriorDebug(f) {
const host = el('cv-teeth');
const img = state.images[f];
- if (!img || state.hidden[f]) { host.innerHTML = ''; el('teethinfo').textContent = state.images.length ? 'mouth closed' : 'no source frames'; return; }
+ if (!img || state.hidden[f]) {
+ host.innerHTML = '';
+ el('teethinfo').textContent = state.images.length ? 'mouth closed' : 'no source frames';
+ return;
+ }
const o = opts();
const ctx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
- const m = measureInterior(img, LIPS_INNER.map((k) => state.dense[f][k]), ctx,
- { minContrast: 0, erode: o.teethErode }, true);
+ const m = extractTeeth(img, LIPS_INNER.map((k) => state.dense[f][k]), ctx, o, true);
host.innerHTML = '';
if (m.debug) {
- m.debug.style.width = '150px';
+ m.debug.style.width = '170px';
m.debug.style.imageRendering = 'pixelated';
host.append(m.debug);
}
const te = state.teeth[f];
el('teethinfo').textContent =
- `contrast ${m.contrast.toFixed(3)} (gate ${o.teethOn.toFixed(2)}) · ` +
- `line ${m.teethT.toFixed(2)} · ${te.show ? 'SHOWN' : 'hidden'}`;
+ `contrast ${m.contrast.toFixed(3)} / gate ${o.teethOn.toFixed(2)} · ` +
+ `area ${m.area}px · ${te.show ? 'SHOWN' : 'hidden'}`;
}
function strokePts(g, pts, color, lw = 1) {
@@ -503,13 +524,8 @@ function exportTake() {
{ name: 'mouth_in', kind: 'poly', z: 31, color: 'mouth_dark', interp: 'hold', parent: 'mouth',
keys: state.inner.map((pts, f) => (state.hidden[f] ? { f, hidden: true } : { f, src: f, pts })) },
{ name: 'teeth', kind: 'poly', z: 32, color: 'teeth', interp: 'hold', parent: 'mouth_in',
- keys: state.inner.map((pts, f) => {
- const te = state.teeth[f];
- if (state.hidden[f] || !te.show) return { f, hidden: true };
- const ys = pts.map((p) => p.y);
- const top = Math.min(...ys), bot = Math.max(...ys);
- return { f, src: f, pts: clipPolyAbove(pts, top + te.t * (bot - top)) };
- }) },
+ keys: state.teeth.map((te, f) =>
+ (te.show && te.pts ? { f, src: f, pts: te.pts } : { f, hidden: true })) },
],
};
const text = writeTake(take)
@@ -574,11 +590,14 @@ function runSynthetic() {
status('synthetic — exercises everything below detection', 'ok');
}
-for (const id of ['verts', 'smoothWin', 'contourSmooth', 'apertureThresh', 'tol', 'teethOn', 'teethDwell', 'teethErode']) {
+for (const id of ['verts', 'smoothWin', 'contourSmooth', 'apertureThresh', 'tol',
+ 'teethOn', 'teethDwell', 'teethErode', 'tongueReject', 'blobGrow',
+ 'topBias', 'teethVerts', 'teethSmooth']) {
el(id).addEventListener('input', () => {
el(id + 'v').textContent = id === 'apertureThresh' || id === 'tol'
? (+el(id).value / 1000).toFixed(3)
- : id === 'teethOn' || id === 'teethErode' ? (+el(id).value / 100).toFixed(2)
+ : ['teethOn', 'teethErode', 'tongueReject', 'topBias'].includes(id)
+ ? (+el(id).value / 100).toFixed(2)
: el(id).value;
if (id === 'tol') return; // tol only matters when you ask for a suggestion
rebuild(false);
@@ -699,6 +718,11 @@ PALETTE.forEach((p) => {
// #synth / #frames autorun, so the tool can be driven headlessly for smoke tests
// and deep-linked. Detection needs WebGL; the synthetic path does not.
+window.addEventListener('error', (e) => {
+ const s = document.getElementById('status');
+ if (s) { s.textContent = e.message; s.className = 'err'; }
+});
+
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');
diff --git a/js/interior.js b/js/interior.js
index 46580e6..f3d7053 100644
--- a/js/interior.js
+++ b/js/interior.js
@@ -1,38 +1,37 @@
// Mouth interior from image content.
//
// MediaPipe has no landmarks inside the lips - the inner ring bounds the cavity
-// and everything within it is just pixels. So teeth have to come from the
-// picture, and the question is how to do that without reintroducing the boil
-// that per-frame detection causes.
+// and everything within it is just pixels. So teeth come from the picture.
//
-// The answer is to extract a SCALAR, not a shape. Tracing the bright blob would
-// give a new contour every frame with no vertex correspondence - exactly the
-// failure docs/roto-puppet.md warns about. Instead the teeth polygon is the
-// inner lip ring clipped to a horizontal line, and only that line's height is
-// measured. The silhouette is therefore always the mouth's own shape (stable by
-// construction) and the only thing that varies per frame is one number, which
-// smooths trivially. It is also how the shape is drawn by hand: a band bounded
-// by the lip.
+// The hazard is vertex correspondence. A traced contour reorders between frames
+// and boils, which is the failure docs/roto-puppet.md exists to avoid. The way
+// out for a blob specifically is RADIAL SAMPLING: march outward from the
+// centroid along N fixed directions and take the last pixel inside. Vertex k is
+// then always "the blob's extent in direction k" - correspondence holds by
+// construction, the vertex count is fixed, and the result smooths over time
+// without any reordering being possible. It also yields a star-shaped
+// reduction, which is what flat blocks of colour want anyway.
-// Otsu's threshold over a luminance histogram. Self-tuning, so exposure changes
-// between frames do not shift what counts as "bright".
export function otsuForTest(h, t) { return otsu(h, t); }
+// Otsu's threshold plus its two class means. The means matter as much as the
+// threshold: Otsu ALWAYS returns a split, including on a homogeneous region, so
+// their separation is the only thing that says the split means anything.
function otsu(hist, total) {
let sum = 0;
for (let i = 0; i < 256; i++) sum += i * hist[i];
- let sumB = 0, wB = 0, best = 0, bestVar = -1, bestMB = 0, bestMF = 0;
+ let sumB = 0, wB = 0, best = 0, bestVar = -1, bestDark = 0, bestBright = 0;
for (let t = 0; t < 256; t++) {
wB += hist[t];
if (!wB) continue;
const wF = total - wB;
if (!wF) break;
sumB += t * hist[t];
- const mB = sumB / wB, mF = (sum - sumB) / wF; // mB = dark class, mF = bright
- const between = wB * wF * (mB - mF) * (mB - mF);
- if (between > bestVar) { bestVar = between; best = t; bestMB = mB; bestMF = mF; }
+ const mDark = sumB / wB, mBright = (sum - sumB) / wF;
+ const between = wB * wF * (mDark - mBright) * (mDark - mBright);
+ if (between > bestVar) { bestVar = between; best = t; bestDark = mDark; bestBright = mBright; }
}
- return { thr: best, mDark: bestMB, mBright: bestMF };
+ return { thr: best, mDark: bestDark, mBright: bestBright };
}
const pointInPoly = (pts, x, y) => {
@@ -44,133 +43,183 @@ const pointInPoly = (pts, x, y) => {
return inside;
};
-// Shrink a ring toward its centroid.
-//
-// MediaPipe's inner lip landmarks sit slightly OUTSIDE the actual opening, so
-// sampling the ring as given includes lip pixels - which are bright, and sit
-// right at the cavity boundary where they do the most damage.
-function erode(pts, k) {
+// Shrink or grow a ring about its centroid. MediaPipe's inner lip landmarks sit
+// slightly OUTSIDE the real opening, so sampling the ring as given includes lip
+// pixels - bright, and right at the boundary where they do most damage.
+export function scaleRing(pts, k) {
let cx = 0, cy = 0;
for (const p of pts) { cx += p.x; cy += p.y; }
cx /= pts.length; cy /= pts.length;
- return pts.map((p) => ({ x: cx + (p.x - cx) * (1 - k), y: cy + (p.y - cy) * (1 - k) }));
+ return pts.map((p) => ({ x: cx + (p.x - cx) * k, y: cy + (p.y - cy) * k }));
}
-// Measure one frame: how far down the cavity the bright region reaches, as a
-// fraction of cavity height, plus the contrast that justified calling it bright.
-//
-// `wantDebug` returns the sampled crop with the classification drawn on it.
-// Tuning this blind is miserable; the numbers alone do not say whether the
-// region being measured is even the right region.
-export function measureInterior(img, innerNorm, ctx, opts = {}, wantDebug = false) {
- const minContrast = opts.minContrast ?? 0.14;
- const rowFrac = opts.rowFrac ?? 0.4;
- const inner = erode(innerNorm, opts.erode ?? 0.18);
+/* ---- binary morphology on the candidate mask ---- */
+
+function erodeMask(m, w, h) {
+ const o = new Uint8Array(m.length);
+ for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) {
+ const i = y * w + x;
+ o[i] = m[i] && m[i - 1] && m[i + 1] && m[i - w] && m[i + w] ? 1 : 0;
+ }
+ return o;
+}
+function dilateMask(m, w, h) {
+ const o = new Uint8Array(m.length);
+ for (let y = 1; y < h - 1; y++) for (let x = 1; x < w - 1; x++) {
+ const i = y * w + x;
+ o[i] = m[i] || m[i - 1] || m[i + 1] || m[i - w] || m[i + w] ? 1 : 0;
+ }
+ return o;
+}
+
+// Largest 4-connected component, scored with a bias toward the TOP of the
+// cavity: upper teeth hang from the lip, and the usual false positive is the
+// tongue sitting lower down. Area alone picks the tongue when the mouth is wide.
+function bestComponent(mask, w, h, topBias) {
+ const label = new Int32Array(mask.length).fill(-1);
+ const stack = [];
+ let best = null, id = 0;
+ for (let s = 0; s < mask.length; s++) {
+ if (!mask[s] || label[s] >= 0) continue;
+ stack.length = 0; stack.push(s);
+ label[s] = id;
+ const px = [];
+ let sumY = 0;
+ while (stack.length) {
+ const i = stack.pop();
+ px.push(i);
+ sumY += (i / w) | 0;
+ const x = i % w, y = (i / w) | 0;
+ if (x > 0 && mask[i - 1] && label[i - 1] < 0) { label[i - 1] = id; stack.push(i - 1); }
+ if (x < w - 1 && mask[i + 1] && label[i + 1] < 0) { label[i + 1] = id; stack.push(i + 1); }
+ if (y > 0 && mask[i - w] && label[i - w] < 0) { label[i - w] = id; stack.push(i - w); }
+ if (y < h - 1 && mask[i + w] && label[i + w] < 0) { label[i + w] = id; stack.push(i + w); }
+ }
+ const meanY = sumY / px.length / h; // 0 top, 1 bottom
+ const score = px.length * (1 - topBias * meanY);
+ if (!best || score > best.score) best = { score, px, area: px.length, meanY };
+ id++;
+ }
+ return best;
+}
+
+// Radial sampling from the centroid: N fixed directions, last pixel inside.
+function radialContour(mask, w, h, cx, cy, n) {
+ const pts = [];
+ const maxR = Math.hypot(w, h);
+ let prev = 1;
+ for (let k = 0; k < n; k++) {
+ const a = -(k / n) * Math.PI * 2; // slot 0 = +x, 5/20 = top
+ const dx = Math.cos(a), dy = Math.sin(a);
+ let hit = 0;
+ for (let r = 0.5; r < maxR; r += 0.5) {
+ const x = Math.round(cx + dx * r), y = Math.round(cy + dy * r);
+ if (x < 0 || y < 0 || x >= w || y >= h) break;
+ if (mask[y * w + x]) hit = r;
+ else if (hit > 0 && r > hit + 2) break; // tolerate a 2px gap, then stop
+ }
+ // A ray that escapes immediately would collapse the polygon; hold the last
+ // good radius so the shape stays closed rather than spiking to the centre.
+ if (hit <= 0) hit = prev * 0.6;
+ prev = hit;
+ pts.push({ x: cx + dx * hit, y: cy + dy * hit });
+ }
+ return pts;
+}
+
+/* ---- the extraction ---- */
+
+export function extractTeeth(img, innerNorm, ctx, o, wantDebug = false) {
+ const none = { contour: null, contrast: 0, area: 0, debug: null };
+ const ring = scaleRing(innerNorm, 1 - (o.cavityErode ?? 0.18));
- const none = { teethT: 0, contrast: 0, coverage: 0, debug: null };
let x0 = 1, y0 = 1, x1 = 0, y1 = 0;
- for (const p of inner) {
+ for (const p of ring) {
x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
}
const W = img.naturalWidth, H = img.naturalHeight;
const px0 = Math.max(0, Math.floor(x0 * W)), py0 = Math.max(0, Math.floor(y0 * H));
const pw = Math.min(W - px0, Math.ceil((x1 - x0) * W)), ph = Math.min(H - py0, Math.ceil((y1 - y0) * H));
- if (pw < 4 || ph < 4) return none;
+ if (pw < 5 || ph < 5) return none;
ctx.canvas.width = pw; ctx.canvas.height = ph;
ctx.drawImage(img, px0, py0, pw, ph, 0, 0, pw, ph);
- const img0 = ctx.getImageData(0, 0, pw, ph);
- const data = img0.data;
+ const src = ctx.getImageData(0, 0, pw, ph);
+ const d = src.data;
- const poly = inner.map((p) => ({ x: p.x * W - px0, y: p.y * H - py0 }));
+ const poly = ring.map((p) => ({ x: p.x * W - px0, y: p.y * H - py0 }));
const hist = new Uint32Array(256);
const lum = new Float32Array(pw * ph);
- const mask = new Uint8Array(pw * ph);
+ const red = new Float32Array(pw * ph);
+ const inReg = new Uint8Array(pw * ph);
let n = 0;
- for (let y = 0; y < ph; y++) {
- for (let x = 0; x < pw; x++) {
- if (!pointInPoly(poly, x + 0.5, y + 0.5)) continue;
- const o = (y * pw + x) * 4;
- const l = (0.299 * data[o] + 0.587 * data[o + 1] + 0.114 * data[o + 2]) | 0;
- const i = y * pw + x;
- lum[i] = l; mask[i] = 1; hist[l]++; n++;
- }
+ for (let y = 0; y < ph; y++) for (let x = 0; x < pw; x++) {
+ if (!pointInPoly(poly, x + 0.5, y + 0.5)) continue;
+ const i = y * pw + x, oo = i * 4;
+ const R = d[oo], G = d[oo + 1], B = d[oo + 2];
+ lum[i] = (0.299 * R + 0.587 * G + 0.114 * B) | 0;
+ // Tongue is red relative to its own brightness; teeth are near-neutral.
+ red[i] = (R - (G + B) / 2) / 255;
+ inReg[i] = 1; hist[lum[i]]++; n++;
}
- if (n < 16) return none;
+ if (n < 24) return none;
const { thr, mDark, mBright } = otsu(hist, n);
-
- // 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 separation between the two class means is what says whether the
- // split means anything, so it is the actual gate.
const contrast = (mBright - mDark) / 255;
- if (contrast < minContrast) {
- return { teethT: 0, contrast, coverage: 0, debug: wantDebug ? debugCanvas(img0, mask, lum, thr, pw, ph, -1) : null };
+
+ let mask = new Uint8Array(pw * ph);
+ for (let i = 0; i < mask.length; i++) {
+ mask[i] = inReg[i] && lum[i] > thr && red[i] < (o.tongueReject ?? 0.18) ? 1 : 0;
}
- // Scan from the top and STOP at the first row that fails. Teeth hang from the
- // upper lip, so what matters is the contiguous run, not whether some row near
- // the bottom happens to qualify - tracking the latter is what made the band
- // fill the whole mouth.
- let lastRow = -1, started = false, bright = 0;
- for (let y = 0; y < ph; y++) {
- let rowIn = 0, rowBright = 0;
- for (let x = 0; x < pw; x++) {
- const i = y * pw + x;
- if (!mask[i]) continue;
- rowIn++;
- if (lum[i] > thr) { rowBright++; bright++; }
- }
- if (rowIn < 2) continue;
- const ok = rowBright / rowIn > rowFrac;
- if (ok) { started = true; lastRow = y; }
- else if (started) break;
+ // Open once to despeckle, then apply the signed size adjustment.
+ mask = dilateMask(erodeMask(mask, pw, ph), pw, ph);
+ const grow = o.blobGrow | 0;
+ for (let k = 0; k < Math.abs(grow); k++) {
+ mask = grow < 0 ? erodeMask(mask, pw, ph) : dilateMask(mask, pw, ph);
}
+ const comp = bestComponent(mask, pw, ph, o.topBias ?? 0.6);
+ if (!comp || comp.area < (o.minArea ?? 12)) {
+ return { contour: null, contrast, area: comp ? comp.area : 0,
+ debug: wantDebug ? debugCanvas(src, inReg, mask, pw, ph, null) : null };
+ }
+
+ const only = new Uint8Array(mask.length);
+ let cx = 0, cy = 0;
+ for (const i of comp.px) { only[i] = 1; cx += i % pw; cy += (i / pw) | 0; }
+ cx /= comp.px.length; cy /= comp.px.length;
+
+ const local = radialContour(only, pw, ph, cx, cy, o.verts ?? 10);
+ const contour = local.map((p) => ({ x: (p.x + px0) / W, y: (p.y + py0) / H }));
+
return {
- teethT: lastRow < 0 ? 0 : (lastRow + 1) / ph,
- contrast,
- coverage: bright / n,
- debug: wantDebug ? debugCanvas(img0, mask, lum, thr, pw, ph, lastRow) : null,
+ contour, contrast, area: comp.area,
+ debug: wantDebug ? debugCanvas(src, inReg, only, pw, ph, local) : null,
};
}
-// The sampled crop with the classification painted on: sampled region tinted,
-// pixels above threshold in green, the resolved teeth line in amber.
-function debugCanvas(img0, mask, lum, thr, pw, ph, lastRow) {
+// Sampled region dimmed, kept pixels green, extracted contour in amber.
+function debugCanvas(src, inReg, mask, pw, ph, local) {
const c = document.createElement('canvas');
c.width = pw; c.height = ph;
const g = c.getContext('2d');
const out = new ImageData(pw, ph);
for (let i = 0; i < pw * ph; i++) {
const o = i * 4;
- const [r, gr, b] = [img0.data[o], img0.data[o + 1], img0.data[o + 2]];
- if (!mask[i]) { out.data[o] = r * 0.3; out.data[o + 1] = gr * 0.3; out.data[o + 2] = b * 0.3; }
- else if (lum[i] > thr) { out.data[o] = 60; out.data[o + 1] = 230; out.data[o + 2] = 120; }
+ const [r, gr, b] = [src.data[o], src.data[o + 1], src.data[o + 2]];
+ if (!inReg[i]) { out.data[o] = r * 0.25; out.data[o + 1] = gr * 0.25; out.data[o + 2] = b * 0.25; }
+ else if (mask[i]) { out.data[o] = 60; out.data[o + 1] = 230; out.data[o + 2] = 120; }
else { out.data[o] = r; out.data[o + 1] = gr; out.data[o + 2] = b; }
out.data[o + 3] = 255;
}
g.putImageData(out, 0, 0);
- if (lastRow >= 0) {
- g.fillStyle = '#fbbf24';
- g.fillRect(0, lastRow, pw, 1);
+ if (local && local.length) {
+ g.strokeStyle = '#fbbf24'; g.lineWidth = 1;
+ g.beginPath();
+ local.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y)));
+ g.closePath(); g.stroke();
}
return c;
}
-
-// Sutherland-Hodgman against the half-plane y <= limit.
-export function clipPolyAbove(pts, limit) {
- const out = [];
- for (let i = 0; i < pts.length; i++) {
- const a = pts[i], b = pts[(i + 1) % pts.length];
- const ain = a.y <= limit, bin = b.y <= limit;
- if (ain) out.push(a);
- if (ain !== bin) {
- const t = (limit - a.y) / (b.y - a.y);
- out.push({ x: a.x + t * (b.x - a.x), y: limit });
- }
- }
- return out;
-}
diff --git a/js/selftest.js b/js/selftest.js
index ddbeec9..06b01e0 100644
--- a/js/selftest.js
+++ b/js/selftest.js
@@ -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
diff --git a/probe.html b/probe.html
deleted file mode 100644
index 06b6493..0000000
--- a/probe.html
+++ /dev/null
@@ -1,79 +0,0 @@
-
probe…
-
running…
-