Brows: traced ring, quantised raise
A brow at 320x200 is fourteen pixels wide and three tall. Its shape carries almost nothing at that size; its height above the eye carries the expression, and a brow raise is the most legible beat on a face. So the ring is traced and the height is quantised - the split the eyes already got, where the lid is a traced feature and the iris a quantised primitive. The decomposition is the point. The traced ring already contains the real height, so adding a quantised raise on top would move the brow twice. The height is measured OUT of the ring, quantised, and put back, so the shape that renders is his at a height that snaps between a few levels and holds. Measured at both ends rather than as one number, because raise and tilt are different expressions out of one mechanism: both ends up is surprise, inner up alone is worry, inner down is anger. They share a dwell - the gaze quantiser, renamed quantizeSnap now that it has two callers - so the brow hits its pose in one frame instead of crawling into it with one end arriving before the other. Measured against the eye's corner midpoint, never its lid. Same trap the gaze origin has and worth avoiding twice: brows and lids move together constantly, so a brow that jumped on every blink would read as a tic. Rest pose from the take median rather than the neutral frame, for the reason gaze learned the hard way - that frame is picked by minimum mouth aperture and says nothing about the brows. Two correspondences resolved from geometry, not declared: which ring is which brow, and which end is the outer one. The second matters more - backwards, the tilt mirrors and worry renders as its own opposite, which reads as a directed performance choice rather than a bug and would never be questioned. Which EDGE is upper is deliberately left unresolved: it traverses the same ring the other way, an even-odd fill has no winding, and both ends still land on fixed slots. Also fixes a bug from the exposure work: the live render applied exposure to the plate and the mouth but not to the eyes, so on 2s the preview and the export disagreed. A preview that disagrees with the export is the one bug this tool cannot afford. perfIndex now exists as a named thing so the two paths cannot drift apart again. 91 -> 105 assertions. Ground truth on all four synthetic brow poses, tilt separating worry from anger by sign, a blink not faking a raise, and a shared dwell never emitting a half-raised brow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2e4406d579
commit
03df81fa0b
8 changed files with 419 additions and 26 deletions
110
js/selftest.js
110
js/selftest.js
|
|
@ -9,10 +9,12 @@
|
|||
|
||||
import { LIPS_OUTER, LIPS_INNER, FACE_OVAL, RIGID, subsampleSlots, subsampleRing,
|
||||
EYE_R_RING, EYE_L_RING, EYE_R_CORNERS, EYE_L_CORNERS,
|
||||
EYE_R_LIDS, EYE_L_LIDS } from './landmarks.js';
|
||||
EYE_R_LIDS, EYE_L_LIDS, BROW_A_RING, BROW_B_RING,
|
||||
BROW_END_0, BROW_END_1 } from './landmarks.js';
|
||||
import { fitSimilarity, applySim, procrustesMean, smoothTransforms, offsetRing } from './mathutil.js';
|
||||
import { stabilize, toRasterRing, smoothContours, selectKeys, activeKey, shiftIndex,
|
||||
exposeIndex, eyeSignals, pairIrises, gazeOrigin, quantizeGaze, resolveBlink } from './pipeline.js';
|
||||
exposeIndex, eyeSignals, pairIrises, gazeOrigin, quantizeSnap, resolveBlink,
|
||||
browSignals, pairBrows } from './pipeline.js';
|
||||
import { IndexedRaster, hexToRgb } from './raster.js';
|
||||
import { writeTake } from './take.js';
|
||||
import { otsuForTest, scaleRing } from './interior.js';
|
||||
|
|
@ -479,19 +481,111 @@ export function run() {
|
|||
const px = sig.gazeRaw.map((g) => ({ x: (g.x - org.x) * 30, y: (g.y - org.y) * 30 }));
|
||||
const cells = (a) => new Set(a.map((g) => `${g.x},${g.y}`)).size;
|
||||
ok('quantisation collapses drift into a few fixations',
|
||||
cells(quantizeGaze(px, 2, 2)) <= 6 && cells(px) > 40,
|
||||
`${cells(px)} raw -> ${cells(quantizeGaze(px, 2, 2))} cells`);
|
||||
cells(quantizeSnap(px, 2, 2)) <= 6 && cells(px) > 40,
|
||||
`${cells(px)} raw -> ${cells(quantizeSnap(px, 2, 2))} cells`);
|
||||
ok('gaze step 0 leaves the track untouched',
|
||||
quantizeGaze(px, 0, 2).every((g, i) => g.x === px[i].x && g.y === px[i].y));
|
||||
quantizeSnap(px, 0, 2).every((g, i) => g.x === px[i].x && g.y === px[i].y));
|
||||
ok('quantised values land on the grid',
|
||||
quantizeGaze(px, 2, 0).every((g) => Math.abs(g.x % 2) < 1e-9 && Math.abs(g.y % 2) < 1e-9));
|
||||
quantizeSnap(px, 2, 0).every((g) => Math.abs(g.x % 2) < 1e-9 && Math.abs(g.y % 2) < 1e-9));
|
||||
// A one-frame excursion is noise; the dwell must swallow it.
|
||||
{
|
||||
const spike = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 4, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }];
|
||||
ok('the dwell suppresses a one-frame gaze spike',
|
||||
quantizeGaze(spike, 2, 1).every((g) => g.x === 0));
|
||||
quantizeSnap(spike, 2, 1).every((g) => g.x === 0));
|
||||
ok('a sustained move still gets through',
|
||||
quantizeGaze([...spike, { x: 4, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 0 }], 2, 1).pop().x === 4);
|
||||
quantizeSnap([...spike, { x: 4, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 0 }], 2, 1).pop().x === 4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- brows ---- */
|
||||
|
||||
ok('brow rings have 10 distinct ids each',
|
||||
new Set(BROW_A_RING).size === 10 && new Set(BROW_B_RING).size === 10);
|
||||
ok('the brow rings share no landmark with each other, RIGID, or the lids',
|
||||
!BROW_A_RING.some((i) => BROW_B_RING.includes(i)) &&
|
||||
![...BROW_A_RING, ...BROW_B_RING].some((i) =>
|
||||
RIGID.includes(i) || EYE_R_RING.includes(i) || EYE_L_RING.includes(i)),
|
||||
'a brow in RIGID would bleed expression into the stabilisation');
|
||||
|
||||
{
|
||||
let bad = null;
|
||||
for (const [label, table] of [['A', BROW_A_RING], ['B', BROW_B_RING]]) {
|
||||
for (let n = 4; n <= 10 && !bad; n += 2) {
|
||||
const slots = subsampleSlots(table.length, n);
|
||||
for (let f = 0; f < dense.length; f++) {
|
||||
if (ringSelfIntersections(slots.map((sl) => dense[f][table[sl]])).length) {
|
||||
bad = `${label} verts=${n} frame=${f}`; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok('brow rings are simple at every vertex budget', !bad, bad || '');
|
||||
}
|
||||
|
||||
{
|
||||
const stB = stabilize(dense, 2);
|
||||
const pr = pairBrows(stB);
|
||||
ok('brow-to-eye pairing is resolved from geometry',
|
||||
pr.right === 'browA' && pr.left === 'browB', JSON.stringify(pr));
|
||||
// Getting this backwards mirrors the tilt, so inner-up "worried" renders as
|
||||
// outer-up. That is a different expression, not a broken one, which is
|
||||
// exactly why it needs an assertion rather than an eyeball.
|
||||
ok('the outer end of the brow ring is resolved from geometry', pr.outerAtSlot0 === true);
|
||||
|
||||
// Both ends land on fixed slots whichever edge of the brow is on top, which
|
||||
// is what lets the upper/lower ambiguity go unresolved without consequence.
|
||||
ok('brow end slots are disjoint and cover both ends',
|
||||
!BROW_END_0.some((i) => BROW_END_1.includes(i)) &&
|
||||
BROW_END_0.length === 2 && BROW_END_1.length === 2);
|
||||
|
||||
// Ground truth: synth commands rest, surprise, worry and anger as heights
|
||||
// above the eye centre in eye widths, holding each for thirteen frames.
|
||||
const b = browSignals(stB);
|
||||
const at = (f) => [b.R[f].x, b.R[f].y];
|
||||
const near = (v, want) => Math.abs(v - want) < 0.02;
|
||||
ok('brow raise recovers the commanded rest pose', near(at(0)[0], 0.30) && near(at(0)[1], 0.30),
|
||||
at(0).map((v) => v.toFixed(3)).join(', '));
|
||||
ok('brow raise recovers surprise - both ends up',
|
||||
near(at(14)[0], 0.46) && near(at(14)[1], 0.46), at(14).map((v) => v.toFixed(3)).join(', '));
|
||||
ok('brow raise recovers worry - inner end only',
|
||||
near(at(27)[0], 0.30) && near(at(27)[1], 0.44), at(27).map((v) => v.toFixed(3)).join(', '));
|
||||
ok('brow raise recovers anger - inner end down',
|
||||
near(at(40)[0], 0.30) && near(at(40)[1], 0.18), at(40).map((v) => v.toFixed(3)).join(', '));
|
||||
|
||||
// Tilt must be a signed quantity that separates worry from anger. If the
|
||||
// outer/inner resolution were mirrored these two would swap.
|
||||
ok('tilt separates worry from anger by sign',
|
||||
(at(27)[1] - at(27)[0]) > 0.08 && (at(40)[1] - at(40)[0]) < -0.08,
|
||||
`worry ${(at(27)[1] - at(27)[0]).toFixed(3)}, anger ${(at(40)[1] - at(40)[0]).toFixed(3)}`);
|
||||
|
||||
// A blink must not read as a brow raise: the raise is measured against the
|
||||
// eye's rigid corners, not its lid, which is the same trap the gaze origin
|
||||
// has and worth avoiding twice.
|
||||
const dR = Math.abs(b.R[19].x - b.R[18].x);
|
||||
ok('a blink does not fake a brow raise', dR < 0.01, `f18 -> f19 delta ${dR.toFixed(4)}`);
|
||||
|
||||
// Quantisation: four sustained poses must come back as a handful of levels.
|
||||
const rest = gazeOrigin(b.R, 'median');
|
||||
const px = b.R.map((g) => ({ x: (g.x - rest.x) * 60, y: (g.y - rest.y) * 60 }));
|
||||
const cells = (a) => new Set(a.map((g) => `${g.x},${g.y}`)).size;
|
||||
ok('brow quantisation collapses drift into a few poses',
|
||||
cells(quantizeSnap(px, 2, 2)) <= 6 && cells(px) > 20,
|
||||
`${cells(px)} raw -> ${cells(quantizeSnap(px, 2, 2))} poses`);
|
||||
// The dwell is SHARED across both channels, and that is the whole reason
|
||||
// brows reuse the gaze quantiser rather than running two independent ones.
|
||||
// Here the outer end moves one frame before the inner: with a shared dwell
|
||||
// the half-raised pose (2,0) is transient and never commits, so the brow
|
||||
// snaps once. Two independent dwells would emit it and the brow would crawl
|
||||
// into position over two frames instead of hitting it.
|
||||
{
|
||||
const staggered = [
|
||||
{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 2, y: 0 },
|
||||
{ x: 2, y: 2 }, { x: 2, y: 2 }, { x: 2, y: 2 },
|
||||
];
|
||||
const out = quantizeSnap(staggered, 2, 1);
|
||||
ok('a shared dwell never emits a half-raised brow',
|
||||
!out.some((g) => g.x === 2 && g.y === 0),
|
||||
out.map((g) => `${g.x},${g.y}`).join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue