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
163
js/selftest.js
Normal file
163
js/selftest.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// Assertions over the stages below detection. Runs in the browser so the exact
|
||||
// module graph the tool uses is what gets tested.
|
||||
//
|
||||
// The ring-simplicity check exists because "fixed topology" is load-bearing in
|
||||
// docs/roto-puppet.md: because hold parts CUT between poses rather than
|
||||
// interpolating, a ring whose vertex order is wrong self-intersects and renders
|
||||
// as blocks meeting at corners. It is invisible at some vertex counts and obvious
|
||||
// at others, so it needs an assertion rather than an eyeball.
|
||||
|
||||
import { LIPS_OUTER, LIPS_INNER, FACE_OVAL, RIGID, subsampleSlots, subsampleRing } from './landmarks.js';
|
||||
import { fitSimilarity, applySim, procrustesMean, smoothTransforms } from './mathutil.js';
|
||||
import { stabilize, toRasterRing, selectKeys, activeKey } from './pipeline.js';
|
||||
import { IndexedRaster, hexToRgb } from './raster.js';
|
||||
import { writeTake } from './take.js';
|
||||
import { synthDense } from './synth.js';
|
||||
|
||||
const results = [];
|
||||
const ok = (name, cond, detail = '') => results.push({ name, pass: !!cond, detail });
|
||||
|
||||
/* ---- geometry helpers ---- */
|
||||
|
||||
function segmentsCross(a, b, c, d) {
|
||||
const o = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));
|
||||
const o1 = o(a, b, c), o2 = o(a, b, d), o3 = o(c, d, a), o4 = o(c, d, b);
|
||||
return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0;
|
||||
}
|
||||
|
||||
// A closed ring is simple if no pair of non-adjacent edges crosses.
|
||||
function ringSelfIntersections(pts) {
|
||||
const n = pts.length, hits = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
if (i === j || (j + 1) % n === i || (i + 1) % n === j) continue;
|
||||
if (segmentsCross(pts[i], pts[(i + 1) % n], pts[j], pts[(j + 1) % n])) hits.push([i, j]);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
const spreadX = (frames, slot) => {
|
||||
const xs = frames.map((f) => f[slot].x);
|
||||
return Math.max(...xs) - Math.min(...xs);
|
||||
};
|
||||
|
||||
/* ---- the tests ---- */
|
||||
|
||||
export function run() {
|
||||
results.length = 0;
|
||||
|
||||
// tables
|
||||
ok('LIPS_OUTER has 20 distinct ids', new Set(LIPS_OUTER).size === 20);
|
||||
ok('LIPS_INNER has 20 distinct ids', new Set(LIPS_INNER).size === 20);
|
||||
ok('FACE_OVAL has 36 distinct ids', new Set(FACE_OVAL).size === 36);
|
||||
ok('RIGID excludes every lip vertex',
|
||||
!RIGID.some((i) => LIPS_OUTER.includes(i) || LIPS_INNER.includes(i)),
|
||||
'a moving feature in the rigid set bleeds performance into stabilisation');
|
||||
|
||||
// subsampling preserves order and count at every budget
|
||||
for (let n = 4; n <= 16; n += 2) {
|
||||
const s = subsampleSlots(20, n);
|
||||
const mono = s.every((v, i) => i === 0 || v > s[i - 1]);
|
||||
ok(`subsampleSlots(20,${n}) is strictly increasing, n=${n}`, mono && s.length === n, s.join(','));
|
||||
}
|
||||
ok('subsampleRing agrees with subsampleSlots',
|
||||
subsampleRing(LIPS_OUTER, 8).join(',') === subsampleSlots(20, 8).map((s) => LIPS_OUTER[s]).join(','));
|
||||
|
||||
const dense = synthDense(72);
|
||||
|
||||
// rings must be simple at EVERY vertex budget, on every frame
|
||||
for (const [label, table] of [['outer', LIPS_OUTER], ['inner', LIPS_INNER]]) {
|
||||
let worst = null;
|
||||
for (let n = 4; n <= 16 && !worst; n += 2) {
|
||||
const slots = subsampleSlots(table.length, n);
|
||||
for (let f = 0; f < dense.length; f++) {
|
||||
const pts = slots.map((s) => dense[f][table[s]]);
|
||||
const hits = ringSelfIntersections(pts);
|
||||
if (hits.length) { worst = `verts=${n} frame=${f} edges ${JSON.stringify(hits[0])}`; break; }
|
||||
}
|
||||
}
|
||||
ok(`${label} ring is simple at every vertex budget`, !worst, worst || '');
|
||||
}
|
||||
|
||||
// similarity fit recovers a known transform
|
||||
const src = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 2, y: 3 }];
|
||||
const truth = { s: 1.7, theta: 0.6, tx: 4, ty: -2 };
|
||||
const dst = src.map((p) => applySim(truth, p));
|
||||
const got = fitSimilarity(src, dst);
|
||||
ok('fitSimilarity recovers a known transform',
|
||||
Math.abs(got.s - truth.s) < 1e-9 && Math.abs(got.theta - truth.theta) < 1e-9 &&
|
||||
Math.abs(got.tx - truth.tx) < 1e-9 && Math.abs(got.ty - truth.ty) < 1e-9,
|
||||
`s=${got.s.toFixed(6)} th=${got.theta.toFixed(6)}`);
|
||||
|
||||
// stabilisation: head motion out, mouth motion kept
|
||||
const stab = stabilize(dense, 1);
|
||||
const rawSpread = spreadX(dense, 133);
|
||||
const stabSpread = (() => {
|
||||
const xs = stab.eyes.map((e) => e[0].x);
|
||||
return Math.max(...xs) - Math.min(...xs);
|
||||
})();
|
||||
ok('stabilisation removes >90% of head translation',
|
||||
stabSpread < rawSpread * 0.1, `raw ${rawSpread.toFixed(4)} -> ${stabSpread.toFixed(4)}`);
|
||||
const apRange = Math.max(...stab.aperture) - Math.min(...stab.aperture);
|
||||
ok('stabilisation preserves mouth motion', apRange > 0.05, `aperture range ${apRange.toFixed(4)}`);
|
||||
|
||||
// key selection
|
||||
const xf = (p) => ({ x: p.x * 320, y: p.y * 200 });
|
||||
const shapes = stab.outer.map((r) => toRasterRing(r, LIPS_OUTER, 8, xf));
|
||||
const sel = selectKeys(shapes, { minHold: 2, distThresh: 0.6, velSmooth: 3, exposure: 2 });
|
||||
ok('keys are strictly increasing in f', sel.keys.every((k, i) => i === 0 || k.f > sel.keys[i - 1].f));
|
||||
ok('keys respect the minimum hold',
|
||||
sel.keys.every((k, i) => i === 0 || k.src - sel.keys[i - 1].src >= 2));
|
||||
ok('keys land on the exposure grid', sel.keys.every((k) => k.f % 2 === 0));
|
||||
ok('selection reduces candidates', sel.keys.length < sel.candidates.length,
|
||||
`${sel.candidates.length} candidates -> ${sel.keys.length} keys`);
|
||||
ok('first key is frame 0', sel.keys[0].f === 0);
|
||||
ok('activeKey holds between keys',
|
||||
activeKey(sel.keys, sel.keys[1].f - 1).f === sel.keys[0].f);
|
||||
|
||||
// rasteriser: indexed, hard-edged, no blending
|
||||
const r = new IndexedRaster(64, 48);
|
||||
r.clear(0);
|
||||
r.fillPoly([{ x: 8, y: 8 }, { x: 56, y: 8 }, { x: 56, y: 40 }, { x: 8, y: 40 }], 2);
|
||||
const present = new Set(r.buf);
|
||||
ok('raster contains only written indices', present.size === 2 && present.has(0) && present.has(2),
|
||||
`indices ${[...present].join(',')}`);
|
||||
let count = 0;
|
||||
for (const v of r.buf) if (v === 2) count++;
|
||||
ok('axis-aligned rect fills the exact pixel count', count === 48 * 32, `${count} vs ${48 * 32}`);
|
||||
const pal = ['#000000', '#ffffff', '#ff8800'];
|
||||
const img = r.toImageData(pal, 2);
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
seen.add(`${img.data[i]},${img.data[i + 1]},${img.data[i + 2]}`);
|
||||
}
|
||||
const allowed = new Set(pal.map((h) => hexToRgb(h).join(',')));
|
||||
ok('palette expansion introduces no intermediate colours',
|
||||
[...seen].every((c) => allowed.has(c)), `${seen.size} distinct colours`);
|
||||
|
||||
// take writer round-trip
|
||||
const take = {
|
||||
name: 'test', frames: 72, width: 320, height: 200, exposure: 2,
|
||||
palette: [{ name: 'bg' }, { name: 'skin' }],
|
||||
slot: { x: 160, y: 100 },
|
||||
parts: [
|
||||
{ name: 'head', kind: 'plate', z: 0, interp: 'hold', keys: [{ f: 0, plate: 0 }] },
|
||||
{ name: 'mouth', kind: 'poly', z: 30, color: 'skin', interp: 'hold',
|
||||
keys: sel.keys.map((k) => ({ f: k.f, src: k.src, pts: shapes[k.src] })) },
|
||||
],
|
||||
};
|
||||
const text = writeTake(take);
|
||||
const keyLines = text.split('\n').filter((l) => l.startsWith('key') && l.includes('n='));
|
||||
ok('every key line declares n= matching its point count',
|
||||
keyLines.every((l) => {
|
||||
const n = +l.match(/n=(\d+)/)[1];
|
||||
const pts = l.split(/n=\d+\s+/)[1].trim().split(/\s+/);
|
||||
return pts.length === n;
|
||||
}), `${keyLines.length} key lines`);
|
||||
ok('take declares a plate and a part table',
|
||||
/^plate\s+0/m.test(text) && /^part\s+mouth/m.test(text));
|
||||
ok('coordinates are integers', !/-?\d+\.\d/.test(text.split('\n').filter((l) => l.startsWith('key')).join('')));
|
||||
|
||||
return results;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue