Frame removal for plates; mouth keeps every frame

Sparseness was being applied for two different reasons at once. Aesthetic
sparseness is set by the extraction rate; labour sparseness only binds on the
plate, because a human draws each one. The mouth is traced and therefore free,
and in limited animation lip sync is routinely the densest element - on 1s
while heads hold on 2s and 3s.

So: the mouth gets a key on every frame, and the frame strip is now the
editing surface for deciding which frames need their own plate drawing. All
frames start kept; delete the ones you don't want.

- strip of face-cropped thumbnails, keep/drop per frame, keyboard driven
- worksheet panel lists the drawings needed and the range each one holds
- Suggest runs error-tolerance decimation on head pose as a starting point
- export writes sparse plate keys + dense mouth keys, with a hold manifest
- smoothContours: bounded exception to "never smooth the contour", which held
  only while keys were sparse enough to reject detector noise by sampling
- averages are now a RADIUS in frames: 0 is off, 1 is +-1
- GPU delegate falls back to CPU instead of failing
- #synth / #frames autorun for headless smoke tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-24 14:51:15 -04:00
parent 7a6bdea157
commit a082208ad5
7 changed files with 489 additions and 265 deletions

View file

@ -74,9 +74,12 @@ export function procrustesMean(framesRigid, iters = 3) {
return ref;
}
function movingAverage(vals, win) {
if (win <= 1) return vals.slice();
const half = Math.floor(win / 2), out = new Array(vals.length);
// `radius` is in frames either side: 0 is off, 1 averages over 3 frames, 2 over
// 5. Expressed as a radius rather than a window so that "off" is 0 and every
// value is symmetric - an even window would be lopsided in time.
function movingAverage(vals, radius) {
if (radius <= 0) return vals.slice();
const half = Math.floor(radius), out = new Array(vals.length);
for (let i = 0; i < vals.length; i++) {
let acc = 0, cnt = 0;
for (let j = i - half; j <= i + half; j++) {
@ -95,12 +98,12 @@ export { movingAverage };
// where the low-pass belongs; smoothing the contour would destroy the
// performance, which is the entire asset.
// Angles are smoothed as (cos, sin) so wrapping cannot produce a spike.
export function smoothTransforms(tfs, win) {
const c = movingAverage(tfs.map((t) => Math.cos(t.theta)), win);
const sn = movingAverage(tfs.map((t) => Math.sin(t.theta)), win);
const s = movingAverage(tfs.map((t) => t.s), win);
const tx = movingAverage(tfs.map((t) => t.tx), win);
const ty = movingAverage(tfs.map((t) => t.ty), win);
export function smoothTransforms(tfs, radius) {
const c = movingAverage(tfs.map((t) => Math.cos(t.theta)), radius);
const sn = movingAverage(tfs.map((t) => Math.sin(t.theta)), radius);
const s = movingAverage(tfs.map((t) => t.s), radius);
const tx = movingAverage(tfs.map((t) => t.tx), radius);
const ty = movingAverage(tfs.map((t) => t.ty), radius);
return tfs.map((_, i) => ({
theta: Math.atan2(sn[i], c[i]), s: s[i], tx: tx[i], ty: ty[i],
}));