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>
134 lines
6.5 KiB
JavaScript
134 lines
6.5 KiB
JavaScript
// Synthetic landmark frames, shaped exactly like FaceLandmarker output.
|
|
//
|
|
// Exists so the whole chain downstream of detection - Procrustes, smoothing,
|
|
// stabilisation, key selection, rasterising, take writing - can be exercised and
|
|
// verified without a video file. A synthetic face is also the only way to test
|
|
// stabilisation against a KNOWN head motion, since real footage gives no ground
|
|
// truth to compare against.
|
|
import { LIPS_OUTER, LIPS_INNER, FACE_OVAL, RIGID,
|
|
EYE_R_RING, EYE_L_RING, IRIS_A, IRIS_B,
|
|
BROW_A_RING, BROW_B_RING } from './landmarks.js';
|
|
|
|
const NUM = 478;
|
|
|
|
// `swapIris` places the two iris blocks on the opposite eyes. It exists so the
|
|
// pairing resolver can be tested against a track it actually disagrees with:
|
|
// a resolver checked only against the convention it was written for is checking
|
|
// nothing at all.
|
|
export function synthDense(nFrames = 72, { swapIris = false } = {}) {
|
|
const frames = [];
|
|
for (let t = 0; t < nFrames; t++) {
|
|
const pts = new Array(NUM);
|
|
for (let i = 0; i < NUM; i++) pts[i] = { x: 0.5, y: 0.5, z: 0 };
|
|
|
|
// Known head motion: drift, sway, roll and a slow scale change, plus a
|
|
// little per-frame jitter so transform smoothing has something to remove.
|
|
const ph = t / nFrames;
|
|
const hx = 0.5 + 0.045 * Math.sin(ph * Math.PI * 2) + (Math.random() - 0.5) * 0.002;
|
|
const hy = 0.5 + 0.02 * Math.cos(ph * Math.PI * 3) + (Math.random() - 0.5) * 0.002;
|
|
const roll = 0.18 * Math.sin(ph * Math.PI * 2.5);
|
|
const scale = 1 + 0.06 * Math.sin(ph * Math.PI * 1.5);
|
|
const cr = Math.cos(roll), sr = Math.sin(roll);
|
|
const place = (i, lx, ly) => {
|
|
const sx = lx * scale, sy = ly * scale;
|
|
pts[i] = { x: hx + cr * sx - sr * sy, y: hy + sr * sx + cr * sy, z: 0 };
|
|
};
|
|
|
|
// Mouth opens in four sustained beats with holds between, so key selection
|
|
// has genuine extremes and genuine plateaux to find.
|
|
const beat = Math.floor(t / 9) % 4;
|
|
const target = [0.004, 0.05, 0.022, 0.0];
|
|
const openAmt = target[beat];
|
|
const wide = 0.10 + (beat === 1 ? 0.012 : beat === 3 ? -0.008 : 0);
|
|
|
|
place(RIGID[4], 0.000, -0.050); place(RIGID[5], 0.000, -0.020);
|
|
place(RIGID[6], 0.000, 0.012);
|
|
|
|
// Eyes. The corners (RIGID[0..3]) are placed BY the lid rings rather than
|
|
// separately, because they are slots 0 and 8 of those rings: writing them
|
|
// twice is how the mouth grew a bowtie, and a corner that disagrees with
|
|
// its own ring would make the eye self-intersect at some vertex budgets
|
|
// and not others.
|
|
//
|
|
// A blink is ONE frame, which is the honest hard case: at 12fps that is
|
|
// what a real blink costs, and it is exactly the length that reads as a
|
|
// dropped frame rather than as a blink unless `hold` extends it.
|
|
const blink = t > 5 && t % 19 === 0;
|
|
const openness = blink ? 0.05 : 1;
|
|
|
|
// Gaze holds and then jumps, the way gaze actually behaves, with a little
|
|
// jitter on top so quantisation has noise to remove and the dwell has
|
|
// something to suppress.
|
|
const LOOK = [[0, 0], [0.16, 0.0], [-0.16, 0.05], [0.0, -0.09]];
|
|
const [gx, gy] = LOOK[Math.floor(t / 11) % LOOK.length];
|
|
const jit = () => (Math.random() - 0.5) * 0.012;
|
|
|
|
// Half the corner separation, and the lid half-height at full open.
|
|
const EYE_RX = 0.0235, EYE_RY = 0.011, EYE_Y = -0.044;
|
|
const eye = (ring, cx, dir, iris) => {
|
|
const n = ring.length;
|
|
for (let k = 0; k < n; k++) {
|
|
// dir flips the traversal so each ring runs the direction its real
|
|
// table does: slot 0 outer corner, 4 upper lid, 8 inner, 12 lower.
|
|
const a = dir > 0 ? Math.PI + (k / n) * Math.PI * 2 : -(k / n) * Math.PI * 2;
|
|
place(ring[k], cx + EYE_RX * Math.cos(a),
|
|
EYE_Y + EYE_RY * openness * Math.sin(a));
|
|
}
|
|
// Iris: centre first, then four ring points, as the refined mesh emits.
|
|
const ix = cx + (gx + jit()) * EYE_RX * 2, iy = EYE_Y + (gy + jit()) * EYE_RX * 2;
|
|
place(iris[0], ix, iy);
|
|
for (let k = 1; k < iris.length; k++) {
|
|
const a = ((k - 1) / (iris.length - 1)) * Math.PI * 2;
|
|
place(iris[k], ix + 0.008 * Math.cos(a), iy + 0.008 * Math.sin(a));
|
|
}
|
|
};
|
|
eye(EYE_R_RING, -0.0515, 1, swapIris ? IRIS_B : IRIS_A);
|
|
eye(EYE_L_RING, 0.0515, -1, swapIris ? IRIS_A : IRIS_B);
|
|
|
|
// Brows, held in four sustained poses so raise quantisation has genuine
|
|
// plateaux to find: rest, surprise (both ends up), worry (inner up only),
|
|
// anger (inner down). Commanded in eye widths above the eye centre so the
|
|
// measurement can be checked against a number rather than an eyeball.
|
|
const BROW = [[0.30, 0.30], [0.46, 0.46], [0.30, 0.44], [0.30, 0.18]];
|
|
const [bOut, bIn] = BROW[Math.floor(t / 13) % BROW.length];
|
|
const EYE_W = EYE_RX * 2, HALF = 0.006; // ring half-thickness
|
|
const brow = (ring, cx, outerSign) => {
|
|
// Slots 0-4 are one edge outer->inner, 5-9 the other inner->outer, so the
|
|
// ends land on {0,9} and {4,5} exactly as the table promises.
|
|
const n = ring.length, half = n / 2;
|
|
for (let k = 0; k < n; k++) {
|
|
const along = k < half ? k / (half - 1) : (n - 1 - k) / (half - 1);
|
|
const rise = bOut + (bIn - bOut) * along;
|
|
place(ring[k], cx + outerSign * (EYE_RX - along * EYE_W) * 1.05,
|
|
EYE_Y - rise * EYE_W + (k < half ? -HALF : HALF));
|
|
}
|
|
};
|
|
brow(BROW_A_RING, -0.0515, -1);
|
|
brow(BROW_B_RING, 0.0515, 1);
|
|
|
|
// Lip rings as ellipse arcs, traversed so ring ORDER matches the tables:
|
|
// slot 0 = right corner, 5 = top centre, 10 = left corner, 15 = bottom
|
|
// centre, with y growing downward. Getting this convention wrong swaps two
|
|
// opposite vertices and the ring self-intersects into a bowtie - see the
|
|
// ring-simplicity assertion in selftest.
|
|
const ring = (table, rx, ry, cy) => {
|
|
const n = table.length;
|
|
for (let k = 0; k < n; k++) {
|
|
const a = -(k / n) * Math.PI * 2;
|
|
place(table[k], rx * Math.cos(a), cy + ry * Math.sin(a));
|
|
}
|
|
};
|
|
ring(LIPS_OUTER, wide / 2, 0.012 + openAmt * 0.6, 0.075);
|
|
// APERTURE (13, 14) are slots 5 and 15 of the inner ring, so the ring itself
|
|
// places them at the vertical extremes. Writing them again afterwards is what
|
|
// produced the bowtie; the aperture is simply the inner ring's height.
|
|
ring(LIPS_INNER, wide / 2.6, 0.001 + openAmt, 0.075);
|
|
|
|
for (let k = 0; k < FACE_OVAL.length; k++) {
|
|
const a = -Math.PI / 2 + (k / FACE_OVAL.length) * Math.PI * 2;
|
|
place(FACE_OVAL[k], 0.105 * Math.cos(a), 0.145 * Math.sin(a) + 0.01);
|
|
}
|
|
frames.push(pts);
|
|
}
|
|
return frames;
|
|
}
|