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>
This commit is contained in:
parent
941022b69f
commit
c6daf827d1
4 changed files with 164 additions and 33 deletions
51
js/app.js
51
js/app.js
|
|
@ -32,8 +32,9 @@ const state = {
|
|||
const el = (id) => document.getElementById(id);
|
||||
const opts = () => ({
|
||||
verts: +el('verts').value,
|
||||
teethOn: +el('teethOn').value / 100,
|
||||
teethOn: +el('teethOn').value / 100, // minimum Otsu class separation
|
||||
teethDwell: +el('teethDwell').value,
|
||||
teethErode: +el('teethErode').value / 100,
|
||||
smoothWin: +el('smoothWin').value,
|
||||
contourSmooth: +el('contourSmooth').value,
|
||||
apertureThresh: +el('apertureThresh').value / 1000,
|
||||
|
|
@ -123,10 +124,11 @@ async function detectAll(images) {
|
|||
|
||||
// Interior measurement is a function of pixels alone, so it runs once with
|
||||
// detection and the knobs re-resolve it instantly afterwards.
|
||||
function measureAll(images, dense) {
|
||||
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));
|
||||
measureInterior(images[i], LIPS_INNER.map((k) => lm[k]), ctx,
|
||||
{ minContrast: 0, erode: o.teethErode })); // gate applied later, in resolveTeeth
|
||||
}
|
||||
|
||||
/* ---------- build ---------- */
|
||||
|
|
@ -167,6 +169,10 @@ 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)) {
|
||||
state.interior = measureAll(state.images, state.dense, o);
|
||||
state.erodeUsed = o.teethErode;
|
||||
}
|
||||
state.teeth = resolveTeeth(o);
|
||||
|
||||
// Plate outline per frame, so a kept frame shows its own head shape.
|
||||
|
|
@ -203,8 +209,12 @@ function resolveTeeth(o) {
|
|||
const N = state.dense.length;
|
||||
if (!state.interior) return new Array(N).fill({ show: false, t: 0 });
|
||||
|
||||
const raw = state.interior.map((m, f) => (state.hidden[f] ? 0 : m.coverage));
|
||||
const on = o.teethOn, off = o.teethOn * 0.6;
|
||||
// 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));
|
||||
const on = o.teethOn, off = o.teethOn * 0.7;
|
||||
const shown = new Array(N).fill(false);
|
||||
let live = false, since = 0;
|
||||
for (let f = 0; f < N; f++) {
|
||||
|
|
@ -369,6 +379,30 @@ function drawPanes() {
|
|||
if (!state.hidden[f]) strokePts(g2, z(state.inner[f]), '#f87171');
|
||||
|
||||
compositeRender(el('cv-render'), f, ZOOM);
|
||||
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.
|
||||
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; }
|
||||
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);
|
||||
host.innerHTML = '';
|
||||
if (m.debug) {
|
||||
m.debug.style.width = '150px';
|
||||
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'}`;
|
||||
}
|
||||
|
||||
function strokePts(g, pts, color, lw = 1) {
|
||||
|
|
@ -513,7 +547,7 @@ async function runFrames() {
|
|||
state.images = images; state.dense = dense;
|
||||
state.aspect = images[0].naturalWidth / images[0].naturalHeight;
|
||||
status('measuring mouth interiors…');
|
||||
state.interior = measureAll(images, dense);
|
||||
state.interior = measureAll(images, dense, opts());
|
||||
el('scrub').max = dense.length - 1;
|
||||
state.frame = 0;
|
||||
rebuild(true);
|
||||
|
|
@ -540,11 +574,12 @@ function runSynthetic() {
|
|||
status('synthetic — exercises everything below detection', 'ok');
|
||||
}
|
||||
|
||||
for (const id of ['verts', 'smoothWin', 'contourSmooth', 'apertureThresh', 'tol', 'teethOn', 'teethDwell']) {
|
||||
for (const id of ['verts', 'smoothWin', 'contourSmooth', 'apertureThresh', 'tol', 'teethOn', 'teethDwell', 'teethErode']) {
|
||||
el(id).addEventListener('input', () => {
|
||||
el(id + 'v').textContent = id === 'apertureThresh' || id === 'tol'
|
||||
? (+el(id).value / 1000).toFixed(3)
|
||||
: id === 'teethOn' ? (+el(id).value / 100).toFixed(2) : el(id).value;
|
||||
: id === 'teethOn' || id === 'teethErode' ? (+el(id).value / 100).toFixed(2)
|
||||
: el(id).value;
|
||||
if (id === 'tol') return; // tol only matters when you ask for a suggestion
|
||||
rebuild(false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue