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 @@ -
?
diff --git a/index.html b/index.html index 5aa1f76..b6c44e3 100644 --- a/index.html +++ b/index.html @@ -123,18 +123,27 @@ - + + + + + +
contour avg 0 = off, 1 = ±1 frame. Removes per-frame landmark jitter. Push past 2 and it starts eating articulation.
anchor avg smooths the head transform only — never the contour.
- teeth contrast how far apart the dark and bright halves of the - cavity must be before the split means anything — Otsu always returns - some threshold, so this is what stops it inventing teeth in a dark - mouth. erode shrinks the sampled region inward, away from the lip - edge. dwell is how many frames a change must persist.
+ teeth contrast gates on how far apart the cavity's dark and bright + halves are — 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; + blob grow/erode resizes the found blob itself. + tongue reject drops pixels that are red relative to their own + brightness; prefer upper biases component choice toward the top of + the cavity, where teeth are and the tongue is not. + dwell is how many frames a presence change must persist.
suggest tolerance only affects the Suggest button: max head movement allowed before a new drawing is required.
@@ -143,7 +152,7 @@

teeth measurement

-
green = above threshold · amber = resolved line
+
green = kept pixels · amber = extracted contour

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…
-