roto: video -> take file builder with interactive tuning
Analysis half of the pipeline in docs/roto-puppet.md. Stabilises a face out of a clip via a similarity fit on rigid landmarks, reduces the lip contour to a fixed vertex budget, selects sparse keys on velocity minima, and previews the result as flat indexed fills so timing can be judged without an Animator Pro render. - landmarks.js ordered lip/oval rings; slot position is vertex identity - mathutil.js closed-form 2D similarity, Procrustes mean, transform smoothing - pipeline.js stabilise -> subsample -> key-select - raster.js indexed scanline fill, no antialiasing - take.js take-file writer - selftest.js 29 assertions, incl. ring simplicity at every vertex budget Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
7a6bdea157
13 changed files with 1277 additions and 0 deletions
69
js/synth.js
Normal file
69
js/synth.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// 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_INNER } from './landmarks.js';
|
||||
|
||||
const NUM = 478;
|
||||
|
||||
export function synthDense(nFrames = 72) {
|
||||
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[0], -0.075, -0.045); place(RIGID[1], -0.028, -0.043);
|
||||
place(RIGID[2], 0.028, -0.043); place(RIGID[3], 0.075, -0.045);
|
||||
place(RIGID[4], 0.000, -0.050); place(RIGID[5], 0.000, -0.020);
|
||||
place(RIGID[6], 0.000, 0.012);
|
||||
place(EYE_INNER[0], -0.028, -0.043); place(EYE_INNER[1], 0.028, -0.043);
|
||||
|
||||
// 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue