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:
parent
7a6bdea157
commit
a082208ad5
7 changed files with 489 additions and 265 deletions
6
gl.html
Normal file
6
gl.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<!doctype html><html><body><pre id=o>?</pre><script>
|
||||||
|
const c=document.createElement('canvas');
|
||||||
|
const g=c.getContext('webgl2')||c.getContext('webgl');
|
||||||
|
document.getElementById('o').textContent = g ? 'WEBGL OK '+g.getParameter(g.VERSION) : 'WEBGL UNAVAILABLE';
|
||||||
|
document.title = g ? 'GLOK' : 'GLNO';
|
||||||
|
</script></body></html>
|
||||||
112
index.html
112
index.html
|
|
@ -5,38 +5,46 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>roto — take builder</title>
|
<title>roto — take builder</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root { --bg:#0b0d13; --panel:#12151f; --line:#232836; --fg:#e8eaf0;
|
||||||
--bg: #0b0d13; --panel: #12151f; --line: #232836; --fg: #e8eaf0;
|
--dim:#8891a5; --accent:#fbbf24; --ok:#4ade80; --err:#f87171; }
|
||||||
--dim: #8891a5; --accent: #fbbf24; --ok: #4ade80; --err: #f87171;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin:0; background:var(--bg); color:var(--fg);
|
body { margin:0; background:var(--bg); color:var(--fg);
|
||||||
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; }
|
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; }
|
||||||
header { padding: 14px 18px; border-bottom: 1px solid var(--line);
|
header { padding:12px 18px; border-bottom:1px solid var(--line);
|
||||||
display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; }
|
display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||||
h1 { font-size: 14px; margin: 0; letter-spacing: .04em; }
|
h1 { font-size:14px; margin:0 8px 0 0; letter-spacing:.04em; }
|
||||||
h1 span { color:var(--dim); font-weight:400; }
|
h1 span { color:var(--dim); font-weight:400; }
|
||||||
main { padding: 18px; display: flex; flex-direction: column; gap: 18px; }
|
main { padding:16px 18px 40px; display:flex; flex-direction:column; gap:16px; }
|
||||||
.row { display: flex; gap: 18px; flex-wrap: wrap; }
|
.row { display:flex; gap:16px; flex-wrap:wrap; }
|
||||||
.panel { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:12px; }
|
.panel { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:12px; }
|
||||||
.panel h2 { font-size:11px; text-transform:uppercase; letter-spacing:.08em;
|
.panel h2 { font-size:11px; text-transform:uppercase; letter-spacing:.08em;
|
||||||
color:var(--dim); margin:0 0 8px; font-weight:500; }
|
color:var(--dim); margin:0 0 8px; font-weight:500; }
|
||||||
canvas { display:block; image-rendering:pixelated; max-width:100%; border-radius:2px; }
|
canvas { display:block; image-rendering:pixelated; max-width:100%; border-radius:2px; }
|
||||||
#cv-timeline { width: 100%; height: 90px; }
|
button,input[type=text],select { font:inherit; background:#1c2130; color:var(--fg);
|
||||||
button, input[type=text] {
|
border:1px solid var(--line); border-radius:3px; padding:5px 10px; cursor:pointer; }
|
||||||
font: inherit; background: #1c2130; color: var(--fg);
|
|
||||||
border: 1px solid var(--line); border-radius: 3px; padding: 5px 11px; cursor: pointer; }
|
|
||||||
button:hover { border-color:var(--accent); }
|
button:hover { border-color:var(--accent); }
|
||||||
input[type=text] { cursor:text; }
|
input[type=text] { cursor:text; }
|
||||||
label.ctl { display: grid; grid-template-columns: 116px 1fr 44px;
|
label.ctl { display:grid; grid-template-columns:132px 1fr 46px; gap:10px;
|
||||||
gap: 10px; align-items: center; margin-bottom: 6px; }
|
align-items:center; margin-bottom:6px; }
|
||||||
label.ctl span:first-child { color:var(--dim); }
|
label.ctl span:first-child { color:var(--dim); }
|
||||||
label.ctl output { text-align:right; color:var(--accent); }
|
label.ctl output { text-align:right; color:var(--accent); }
|
||||||
input[type=range] { width:100%; accent-color:var(--accent); }
|
input[type=range] { width:100%; accent-color:var(--accent); }
|
||||||
#status { color: var(--dim); }
|
#status { color:var(--dim); } #status.ok{color:var(--ok)} #status.err{color:var(--err)} #status.warn{color:var(--accent)}
|
||||||
#status.ok { color: var(--ok); } #status.err { color: var(--err); } #status.warn { color: var(--accent); }
|
|
||||||
#readout { color:var(--dim); font-size:12px; }
|
#readout { color:var(--dim); font-size:12px; }
|
||||||
#sheet { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
||||||
|
/* the frame strip is the editing surface */
|
||||||
|
#strip { display:flex; flex-wrap:wrap; gap:4px; }
|
||||||
|
.fr { position:relative; cursor:pointer; border:2px solid transparent; border-radius:3px; line-height:0; }
|
||||||
|
.fr canvas { border-radius:1px; }
|
||||||
|
.fr span { position:absolute; left:2px; bottom:2px; font-size:10px; line-height:1.2;
|
||||||
|
padding:0 3px; border-radius:2px; background:#000a; color:#fff; }
|
||||||
|
.fr.keep { border-color:var(--ok); }
|
||||||
|
.fr.drop { border-color:#2a2f3e; }
|
||||||
|
.fr.drop canvas { opacity:.26; filter:grayscale(1); }
|
||||||
|
.fr.cur { border-color:var(--accent); }
|
||||||
|
.fr.keep::after { content:'●'; position:absolute; right:3px; top:1px; color:var(--ok); font-size:10px; }
|
||||||
|
|
||||||
|
#sheet { display:flex; flex-wrap:wrap; gap:10px; }
|
||||||
#sheet .cell { display:flex; flex-direction:column; gap:3px; cursor:pointer; }
|
#sheet .cell { display:flex; flex-direction:column; gap:3px; cursor:pointer; }
|
||||||
#sheet .cell span { color:var(--dim); font-size:11px; }
|
#sheet .cell span { color:var(--dim); font-size:11px; }
|
||||||
#sheet .cell:hover span { color:var(--accent); }
|
#sheet .cell:hover span { color:var(--accent); }
|
||||||
|
|
@ -44,18 +52,21 @@
|
||||||
.sw { display:flex; align-items:center; gap:5px; color:var(--dim); font-size:11px; }
|
.sw { display:flex; align-items:center; gap:5px; color:var(--dim); font-size:11px; }
|
||||||
.sw input { width:26px; height:20px; padding:0; border:1px solid var(--line); background:none; }
|
.sw input { width:26px; height:20px; padding:0; border:1px solid var(--line); background:none; }
|
||||||
.legend { color:var(--dim); font-size:11px; margin-top:6px; }
|
.legend { color:var(--dim); font-size:11px; margin-top:6px; }
|
||||||
.legend b { font-weight: 400; }
|
kbd { background:#1c2130; border:1px solid var(--line); border-radius:2px;
|
||||||
.k { color: var(--accent); } .s { color: var(--err); } .c { color: #5b6478; }
|
padding:0 4px; color:var(--fg); font-size:11px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>roto <span>— video → take builder</span></h1>
|
<h1>roto <span>— frame removal + take builder</span></h1>
|
||||||
<button id="btn-synth">Synthetic take</button>
|
<button id="btn-synth">Synthetic</button>
|
||||||
<input type="text" id="framedir" value="frames" size="8" title="frame directory">
|
<input type="text" id="framedir" value="frames" size="7" title="frame directory">
|
||||||
<button id="btn-frames">Load frames</button>
|
<button id="btn-frames">Load frames</button>
|
||||||
<button id="btn-play">Play</button>
|
<button id="btn-play">Play</button>
|
||||||
<input type="text" id="takename" value="line_01" size="10" title="take name">
|
<select id="playfps" title="playback fps"><option>6</option><option selected>12</option><option>24</option></select>
|
||||||
|
<button id="btn-keepall">Keep all</button>
|
||||||
|
<button id="btn-suggest">Suggest</button>
|
||||||
|
<input type="text" id="takename" value="line_01" size="9" title="take name">
|
||||||
<button id="btn-export">Export .take</button>
|
<button id="btn-export">Export .take</button>
|
||||||
<span id="status"></span>
|
<span id="status"></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -70,7 +81,7 @@
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>stabilised (head-local)</h2>
|
<h2>stabilised (head-local)</h2>
|
||||||
<canvas id="cv-stab"></canvas>
|
<canvas id="cv-stab"></canvas>
|
||||||
<div class="legend">live contour · <b class="k">—</b> active key pose · should sit still except the mouth</div>
|
<div class="legend">should sit still except the mouth · grey = held plate outline</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>flat render — 320×200 indexed</h2>
|
<h2>flat render — 320×200 indexed</h2>
|
||||||
|
|
@ -79,19 +90,35 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="panel">
|
||||||
<div class="panel" style="flex:1 1 380px">
|
<h2>frames — green kept (gets its own drawing) · dim held from the last kept frame</h2>
|
||||||
<h2>knobs</h2>
|
<div id="strip"></div>
|
||||||
<label class="ctl"><span>vertices</span><input type="range" id="verts" min="4" max="16" step="2" value="8"><output id="vertsv"></output></label>
|
<input type="range" id="scrub" min="0" max="0" value="0" style="width:100%;margin-top:10px">
|
||||||
<label class="ctl"><span>min hold</span><input type="range" id="minHold" min="1" max="8" value="2"><output id="minHoldv"></output></label>
|
<div class="legend">
|
||||||
<label class="ctl"><span>change gate</span><input type="range" id="distThresh" min="0" max="40" value="6"><output id="distThreshv"></output></label>
|
<kbd>←</kbd> <kbd>→</kbd> step · <kbd>X</kbd> delete · <kbd>K</kbd> keep ·
|
||||||
<label class="ctl"><span>vel smoothing</span><input type="range" id="velSmooth" min="1" max="11" step="2" value="3"><output id="velSmoothv"></output></label>
|
click to select · double-click or shift-click to toggle ·
|
||||||
<label class="ctl"><span>anchor smoothing</span><input type="range" id="smoothWin" min="1" max="21" step="2" value="5"><output id="smoothWinv"></output></label>
|
the mouth keeps <b>every</b> frame regardless
|
||||||
<label class="ctl"><span>exposure</span><input type="range" id="exposure" min="1" max="4" value="2"><output id="exposurev"></output></label>
|
</div>
|
||||||
<label class="ctl"><span>closed-mouth cut</span><input type="range" id="apertureThresh" min="0" max="400" value="120"><output id="apertureThreshv"></output></label>
|
|
||||||
<div class="legend" id="readout"></div>
|
<div class="legend" id="readout"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel" style="flex:1 1 300px">
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="panel" style="flex:1 1 400px">
|
||||||
|
<h2>knobs</h2>
|
||||||
|
<label class="ctl"><span>vertices</span><input type="range" id="verts" min="4" max="16" step="2" value="8"><output id="vertsv"></output></label>
|
||||||
|
<label class="ctl"><span>contour avg ±f</span><input type="range" id="contourSmooth" min="0" max="4" value="1"><output id="contourSmoothv"></output></label>
|
||||||
|
<label class="ctl"><span>anchor avg ±f</span><input type="range" id="smoothWin" min="0" max="8" value="2"><output id="smoothWinv"></output></label>
|
||||||
|
<label class="ctl"><span>closed-mouth cut</span><input type="range" id="apertureThresh" min="0" max="400" value="120"><output id="apertureThreshv"></output></label>
|
||||||
|
<label class="ctl"><span>suggest tolerance</span><input type="range" id="tol" min="2" max="60" value="14"><output id="tolv"></output></label>
|
||||||
|
<div class="legend">
|
||||||
|
<b>contour avg</b> 0 = off, 1 = ±1 frame. Removes per-frame landmark
|
||||||
|
jitter. Push past 2 and it starts eating articulation.<br>
|
||||||
|
<b>anchor avg</b> smooths the head transform only — never the contour.<br>
|
||||||
|
<b>suggest tolerance</b> only affects the Suggest button: max head movement
|
||||||
|
allowed before a new drawing is required.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" style="flex:1 1 280px">
|
||||||
<h2>palette</h2>
|
<h2>palette</h2>
|
||||||
<div id="palette"></div>
|
<div id="palette"></div>
|
||||||
<div class="legend" style="margin-top:12px">
|
<div class="legend" style="margin-top:12px">
|
||||||
|
|
@ -102,18 +129,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>timeline</h2>
|
<h2>drawings needed — one per kept frame, with the range it holds</h2>
|
||||||
<canvas id="cv-timeline"></canvas>
|
|
||||||
<input type="range" id="scrub" min="0" max="0" value="0" style="width:100%;margin-top:8px">
|
|
||||||
<div class="legend">
|
|
||||||
velocity curve · <b class="c">|</b> candidate minima ·
|
|
||||||
<b class="k">|</b> key as rendered (<code>f</code>) ·
|
|
||||||
<b class="s">|</b> true extreme (<code>src</code>) — a large gap means min-hold and exposure are fighting
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<h2>selected poses — the vocabulary the take actually contains</h2>
|
|
||||||
<div id="sheet"></div>
|
<div id="sheet"></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
428
js/app.js
428
js/app.js
|
|
@ -1,11 +1,11 @@
|
||||||
import { FaceLandmarker, FilesetResolver } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/vision_bundle.mjs';
|
import { FaceLandmarker, FilesetResolver } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/vision_bundle.mjs';
|
||||||
import { LIPS_OUTER, LIPS_INNER, FACE_OVAL } from './landmarks.js';
|
import { LIPS_OUTER, LIPS_INNER, FACE_OVAL } from './landmarks.js';
|
||||||
import { stabilize, toRasterRing, selectKeys, activeKey } from './pipeline.js';
|
import { stabilize, toRasterRing, smoothContours, suggestPlateFrames, heldFrame } from './pipeline.js';
|
||||||
import { IndexedRaster } from './raster.js';
|
import { IndexedRaster } from './raster.js';
|
||||||
import { writeTake } from './take.js';
|
import { writeTake } from './take.js';
|
||||||
import { synthDense } from './synth.js';
|
import { synthDense } from './synth.js';
|
||||||
|
|
||||||
const RW = 320, RH = 200, ZOOM = 2;
|
const RW = 320, RH = 200, ZOOM = 2, THUMB = 92;
|
||||||
|
|
||||||
const PALETTE = [
|
const PALETTE = [
|
||||||
{ name: 'bg', hex: '#12141c' },
|
{ name: 'bg', hex: '#12141c' },
|
||||||
|
|
@ -18,44 +18,37 @@ const IDX = { bg: 0, base: 1, dark: 2, mouth: 3, lite: 4 };
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
dense: null, images: [], stab: null, xform: null,
|
dense: null, images: [], stab: null, xform: null,
|
||||||
plate: null, neutral: 0, keysOuter: null, keysInner: null,
|
outer: null, inner: null, plates: null, hidden: null,
|
||||||
frame: 0, playing: false, source: 'none',
|
keep: new Set(), // frames that get their own plate drawing
|
||||||
|
frame: 0, playing: false, faceBox: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = (id) => document.getElementById(id);
|
const el = (id) => document.getElementById(id);
|
||||||
const opts = () => ({
|
const opts = () => ({
|
||||||
verts: +el('verts').value,
|
verts: +el('verts').value,
|
||||||
minHold: +el('minHold').value,
|
|
||||||
distThresh: +el('distThresh').value / 10,
|
|
||||||
velSmooth: +el('velSmooth').value,
|
|
||||||
smoothWin: +el('smoothWin').value,
|
smoothWin: +el('smoothWin').value,
|
||||||
exposure: +el('exposure').value,
|
contourSmooth: +el('contourSmooth').value,
|
||||||
apertureThresh: +el('apertureThresh').value / 1000,
|
apertureThresh: +el('apertureThresh').value / 1000,
|
||||||
|
tol: +el('tol').value / 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
function status(msg, kind = '') {
|
function status(msg, kind = '') {
|
||||||
const s = el('status');
|
el('status').textContent = msg;
|
||||||
s.textContent = msg;
|
el('status').className = kind;
|
||||||
s.className = kind;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- frame loading ---------- */
|
/* ---------- loading ---------- */
|
||||||
|
|
||||||
function loadImage(src) {
|
const loadImage = (src) => new Promise((r) => {
|
||||||
return new Promise((res) => {
|
|
||||||
const im = new Image();
|
const im = new Image();
|
||||||
im.onload = () => res(im);
|
im.onload = () => r(im); im.onerror = () => r(null); im.src = src;
|
||||||
im.onerror = () => res(null);
|
|
||||||
im.src = src;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFrameSequence() {
|
async function loadFrameSequence() {
|
||||||
const dir = el('framedir').value.replace(/\/$/, '');
|
const dir = el('framedir').value.replace(/\/$/, '');
|
||||||
const imgs = [];
|
const imgs = [];
|
||||||
for (let i = 1; i <= 900; i++) {
|
for (let i = 1; i <= 900; i++) {
|
||||||
const name = `${dir}/${String(i).padStart(4, '0')}.png`;
|
const im = await loadImage(`${dir}/${String(i).padStart(4, '0')}.png`);
|
||||||
const im = await loadImage(name);
|
|
||||||
if (!im) break;
|
if (!im) break;
|
||||||
imgs.push(im);
|
imgs.push(im);
|
||||||
if (i % 10 === 0) status(`loading frames… ${i}`);
|
if (i % 10 === 0) status(`loading frames… ${i}`);
|
||||||
|
|
@ -63,184 +56,188 @@ async function loadFrameSequence() {
|
||||||
return imgs;
|
return imgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- detection ---------- */
|
|
||||||
|
|
||||||
let landmarker = null;
|
let landmarker = null;
|
||||||
|
|
||||||
async function initLandmarker() {
|
async function initLandmarker() {
|
||||||
if (landmarker) return landmarker;
|
if (landmarker) return landmarker;
|
||||||
status('loading MediaPipe wasm…');
|
status('loading MediaPipe wasm…');
|
||||||
const fileset = await FilesetResolver.forVisionTasks(
|
const fileset = await FilesetResolver.forVisionTasks(
|
||||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/wasm');
|
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/wasm');
|
||||||
|
// GPU is faster but unavailable in some contexts; fall back rather than fail.
|
||||||
|
for (const delegate of ['GPU', 'CPU']) {
|
||||||
|
try {
|
||||||
landmarker = await FaceLandmarker.createFromOptions(fileset, {
|
landmarker = await FaceLandmarker.createFromOptions(fileset, {
|
||||||
baseOptions: { modelAssetPath: './face_landmarker.task', delegate: 'GPU' },
|
baseOptions: { modelAssetPath: './face_landmarker.task', delegate },
|
||||||
runningMode: 'IMAGE',
|
runningMode: 'IMAGE', numFaces: 1,
|
||||||
numFaces: 1,
|
|
||||||
});
|
});
|
||||||
|
status(`landmarker ready (${delegate})`);
|
||||||
return landmarker;
|
return landmarker;
|
||||||
|
} catch (e) {
|
||||||
|
if (delegate === 'CPU') throw e;
|
||||||
|
console.warn('GPU delegate failed, falling back to CPU:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectAll(images) {
|
async function detectAll(images) {
|
||||||
const lm = await initLandmarker();
|
const lm = await initLandmarker();
|
||||||
const cv = document.createElement('canvas');
|
const cv = document.createElement('canvas');
|
||||||
const dense = [];
|
const dense = [], missing = [];
|
||||||
const missing = [];
|
|
||||||
for (let i = 0; i < images.length; i++) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
const im = images[i];
|
const im = images[i];
|
||||||
cv.width = im.naturalWidth; cv.height = im.naturalHeight;
|
cv.width = im.naturalWidth; cv.height = im.naturalHeight;
|
||||||
cv.getContext('2d').drawImage(im, 0, 0);
|
cv.getContext('2d').drawImage(im, 0, 0);
|
||||||
const out = lm.detect(cv);
|
const out = lm.detect(cv);
|
||||||
if (out.faceLandmarks && out.faceLandmarks.length) {
|
if (out.faceLandmarks && out.faceLandmarks.length) dense.push(out.faceLandmarks[0]);
|
||||||
dense.push(out.faceLandmarks[0]);
|
else { missing.push(i); dense.push(dense.length ? dense[dense.length - 1] : null); }
|
||||||
} else {
|
if (i % 4 === 0) status(`detecting… ${i + 1}/${images.length}`);
|
||||||
// Hold the previous frame rather than dropping it, so frame indices stay
|
|
||||||
// aligned with the source sequence. A gap is reported, not hidden.
|
|
||||||
missing.push(i);
|
|
||||||
dense.push(dense.length ? dense[dense.length - 1] : null);
|
|
||||||
}
|
|
||||||
if (i % 5 === 0) status(`detecting… ${i + 1}/${images.length}`);
|
|
||||||
}
|
}
|
||||||
if (dense[0] === null) throw new Error('no face found in the first frame');
|
if (dense[0] === null) throw new Error('no face found in the first frame');
|
||||||
return { dense, missing };
|
return { dense, missing };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- calibration ---------- */
|
/* ---------- build ---------- */
|
||||||
|
|
||||||
// v1 calibration: fit the reference face oval's bounding box to a target box on
|
function makeXform(stab, neutral) {
|
||||||
// the character raster. Identity retargeting - it makes any clip frame sensibly,
|
const oval = stab.oval[neutral];
|
||||||
// but a real project replaces this with a measured performer->character map.
|
|
||||||
function makeXform(stab) {
|
|
||||||
const oval = stab.oval[state.neutral];
|
|
||||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||||
for (const p of oval) {
|
for (const p of oval) {
|
||||||
x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
|
x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y);
|
||||||
x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
|
x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y);
|
||||||
}
|
}
|
||||||
const targetH = RH * 0.80;
|
const s = (RH * 0.80) / (y1 - y0);
|
||||||
const s = targetH / (y1 - y0);
|
|
||||||
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
|
||||||
return (p) => ({ x: (p.x - cx) * s + RW / 2, y: (p.y - cy) * s + RH / 2 });
|
return (p) => ({ x: (p.x - cx) * s + RW / 2, y: (p.y - cy) * s + RH / 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- build ---------- */
|
function rebuild(resetKeep) {
|
||||||
|
|
||||||
function rebuild() {
|
|
||||||
if (!state.dense) return;
|
if (!state.dense) return;
|
||||||
const o = opts();
|
const o = opts();
|
||||||
|
const N = state.dense.length;
|
||||||
|
|
||||||
state.stab = stabilize(state.dense, o.smoothWin);
|
state.stab = stabilize(state.dense, o.smoothWin);
|
||||||
|
|
||||||
// Neutral frame = most closed mouth in the first quarter of the shot, which is
|
|
||||||
// where the performer is asked to hold a neutral closed mouth.
|
|
||||||
const ap = state.stab.aperture;
|
const ap = state.stab.aperture;
|
||||||
const head = Math.max(1, Math.floor(ap.length / 4));
|
const head = Math.max(1, Math.floor(N / 4));
|
||||||
let best = 0;
|
let neutral = 0;
|
||||||
for (let i = 0; i < head; i++) if (ap[i] < ap[best]) best = i;
|
for (let i = 0; i < head; i++) if (ap[i] < ap[neutral]) neutral = i;
|
||||||
state.neutral = best;
|
state.neutral = neutral;
|
||||||
|
state.xform = makeXform(state.stab, neutral);
|
||||||
|
|
||||||
state.xform = makeXform(state.stab);
|
// Mouth is traced, so it costs nothing: a key on EVERY frame. Only the plate,
|
||||||
state.plate = state.stab.oval[state.neutral].map(state.xform);
|
// which a human draws, gets decimated.
|
||||||
|
state.outer = smoothContours(
|
||||||
const outer = state.stab.outer.map((r) => toRasterRing(r, LIPS_OUTER, o.verts, state.xform));
|
state.stab.outer.map((r) => toRasterRing(r, LIPS_OUTER, o.verts, state.xform)), o.contourSmooth);
|
||||||
const inner = state.stab.inner.map((r) => toRasterRing(r, LIPS_INNER, o.verts, state.xform));
|
state.inner = smoothContours(
|
||||||
|
state.stab.inner.map((r) => toRasterRing(r, LIPS_INNER, o.verts, state.xform)), o.contourSmooth);
|
||||||
state.shapesOuter = outer;
|
|
||||||
state.shapesInner = inner;
|
|
||||||
state.keysOuter = selectKeys(outer, o);
|
|
||||||
// The interior is a child of the lip silhouette: it keys on exactly its
|
|
||||||
// parent's frames, never independently, or it swims.
|
|
||||||
state.keysInner = {
|
|
||||||
...state.keysOuter,
|
|
||||||
keys: state.keysOuter.keys.map((k) => ({ ...k })),
|
|
||||||
};
|
|
||||||
|
|
||||||
const apMax = Math.max(...ap);
|
const apMax = Math.max(...ap);
|
||||||
state.hidden = state.keysOuter.keys.map((k) => ap[k.src] / apMax < o.apertureThresh);
|
state.hidden = ap.map((v) => v / apMax < o.apertureThresh);
|
||||||
|
|
||||||
el('readout').textContent =
|
// Plate outline per frame, so a kept frame shows its own head shape.
|
||||||
`${state.dense.length} frames · ${state.keysOuter.candidates.length} candidates → ` +
|
state.plates = state.stab.oval.map((r) => r.map(state.xform));
|
||||||
`${state.keysOuter.keys.length} keys · ${o.verts} verts · neutral f${state.neutral} · ` +
|
|
||||||
`residual ${(state.stab.residual.reduce((a, b) => a + b, 0) / state.dense.length).toFixed(4)}`;
|
|
||||||
|
|
||||||
drawTimeline();
|
if (resetKeep || !state.keep.size) {
|
||||||
drawContactSheet();
|
state.keep = new Set(Array.from({ length: N }, (_, i) => i));
|
||||||
draw();
|
}
|
||||||
|
// Frame 0 must always be kept: something has to be on screen at the start.
|
||||||
|
state.keep.add(0);
|
||||||
|
|
||||||
|
state.faceBox = faceBoxes();
|
||||||
|
drawAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- drawing ---------- */
|
// Face bounding box per frame in image space, for legible strip thumbnails.
|
||||||
|
function faceBoxes() {
|
||||||
|
return state.dense.map((lm) => {
|
||||||
|
let x0 = 1, y0 = 1, x1 = 0, y1 = 0;
|
||||||
|
for (const i of FACE_OVAL) {
|
||||||
|
x0 = Math.min(x0, lm[i].x); y0 = Math.min(y0, lm[i].y);
|
||||||
|
x1 = Math.max(x1, lm[i].x); y1 = Math.max(y1, lm[i].y);
|
||||||
|
}
|
||||||
|
const mx = (x1 - x0) * 0.18, my = (y1 - y0) * 0.14;
|
||||||
|
return { x0: x0 - mx, y0: y0 - my, x1: x1 + mx, y1: y1 + my };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- render ---------- */
|
||||||
|
|
||||||
function renderFrame(f) {
|
function renderFrame(f) {
|
||||||
const r = new IndexedRaster(RW, RH);
|
const r = new IndexedRaster(RW, RH);
|
||||||
r.clear(IDX.bg);
|
r.clear(IDX.bg);
|
||||||
if (state.plate) r.fillPoly(state.plate, IDX.base);
|
const pf = heldFrame(keptSorted(), f); // the plate that is on screen
|
||||||
|
r.fillPoly(state.plates[pf], IDX.base);
|
||||||
const ko = activeKey(state.keysOuter.keys, f);
|
r.fillPoly(state.outer[f], IDX.dark); // mouth at full rate
|
||||||
const ki = activeKey(state.keysInner.keys, f);
|
if (!state.hidden[f]) r.fillPoly(state.inner[f], IDX.mouth);
|
||||||
if (ko) r.fillPoly(state.shapesOuter[ko.src], IDX.dark);
|
|
||||||
if (ki) {
|
|
||||||
const slot = state.keysOuter.keys.indexOf(ki);
|
|
||||||
if (!state.hidden[slot]) r.fillPoly(state.shapesInner[ki.src], IDX.mouth);
|
|
||||||
}
|
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keptSorted = () => [...state.keep].sort((a, b) => a - b);
|
||||||
|
|
||||||
function blit(canvas, raster, zoom) {
|
function blit(canvas, raster, zoom) {
|
||||||
canvas.width = RW * zoom; canvas.height = RH * zoom;
|
canvas.width = RW * zoom; canvas.height = RH * zoom;
|
||||||
canvas.getContext('2d').putImageData(raster.toImageData(PALETTE.map((p) => p.hex), zoom), 0, 0);
|
canvas.getContext('2d').putImageData(raster.toImageData(PALETTE.map((p) => p.hex), zoom), 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw() {
|
function drawAll() {
|
||||||
const f = state.frame;
|
drawPanes();
|
||||||
el('framelabel').textContent =
|
drawStrip();
|
||||||
`f ${String(f).padStart(3)} / ${state.dense.length - 1} ` +
|
drawWorksheet();
|
||||||
`key ${activeKey(state.keysOuter.keys, f).f}`;
|
drawReadout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawReadout() {
|
||||||
|
const kept = keptSorted();
|
||||||
|
const runs = kept.map((k, i) => (i + 1 < kept.length ? kept[i + 1] : state.dense.length) - k);
|
||||||
|
el('readout').textContent =
|
||||||
|
`${state.dense.length} frames → ${kept.length} drawings · ` +
|
||||||
|
`holds ${Math.min(...runs)}–${Math.max(...runs)} frames · ` +
|
||||||
|
`neutral f${state.neutral} · residual ` +
|
||||||
|
`${(state.stab.residual.reduce((a, b) => a + b, 0) / state.dense.length).toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPanes() {
|
||||||
|
const f = state.frame, kept = keptSorted();
|
||||||
|
const pf = heldFrame(kept, f);
|
||||||
|
el('framelabel').textContent =
|
||||||
|
`f ${f} / ${state.dense.length - 1} · plate from f${pf}` +
|
||||||
|
(state.keep.has(f) ? ' · KEPT' : ' · held');
|
||||||
|
|
||||||
// pane 1: source with the raw contour overlaid
|
|
||||||
const c1 = el('cv-source'), g1 = c1.getContext('2d');
|
const c1 = el('cv-source'), g1 = c1.getContext('2d');
|
||||||
c1.width = RW * ZOOM; c1.height = RH * ZOOM;
|
c1.width = RW * ZOOM; c1.height = RH * ZOOM;
|
||||||
g1.fillStyle = '#000'; g1.fillRect(0, 0, c1.width, c1.height);
|
g1.fillStyle = '#000'; g1.fillRect(0, 0, c1.width, c1.height);
|
||||||
const im = state.images[f];
|
const im = state.images[f];
|
||||||
if (im) {
|
if (im) {
|
||||||
const s = Math.min(c1.width / im.naturalWidth, c1.height / im.naturalHeight);
|
const b = state.faceBox[f];
|
||||||
const w = im.naturalWidth * s, h = im.naturalHeight * s;
|
const sx = b.x0 * im.naturalWidth, sy = b.y0 * im.naturalHeight;
|
||||||
g1.drawImage(im, (c1.width - w) / 2, (c1.height - h) / 2, w, h);
|
const sw = (b.x1 - b.x0) * im.naturalWidth, sh = (b.y1 - b.y0) * im.naturalHeight;
|
||||||
g1.save();
|
const s = Math.min(c1.width / sw, c1.height / sh);
|
||||||
g1.translate((c1.width - w) / 2, (c1.height - h) / 2);
|
const dw = sw * s, dh = sh * s, dx = (c1.width - dw) / 2, dy = (c1.height - dh) / 2;
|
||||||
strokeRing(g1, LIPS_OUTER.map((i) => state.dense[f][i]), w, h, '#4ade80');
|
g1.drawImage(im, sx, sy, sw, sh, dx, dy, dw, dh);
|
||||||
strokeRing(g1, LIPS_INNER.map((i) => state.dense[f][i]), w, h, '#f87171');
|
const map = (p) => ({ x: dx + (p.x * im.naturalWidth - sx) * s, y: dy + (p.y * im.naturalHeight - sy) * s });
|
||||||
g1.restore();
|
strokePts(g1, LIPS_OUTER.map((i) => map(state.dense[f][i])), '#4ade80');
|
||||||
|
strokePts(g1, LIPS_INNER.map((i) => map(state.dense[f][i])), '#f87171');
|
||||||
} else {
|
} else {
|
||||||
g1.fillStyle = '#555'; g1.font = '13px system-ui';
|
g1.fillStyle = '#555'; g1.font = '13px system-ui';
|
||||||
g1.fillText('synthetic — no source frames', 14, 24);
|
g1.fillText('synthetic — no source frames', 14, 24);
|
||||||
strokeRing(g1, LIPS_OUTER.map((i) => state.dense[f][i]), c1.width, c1.height, '#4ade80');
|
const sc = (p) => ({ x: p.x * c1.width, y: p.y * c1.height });
|
||||||
strokeRing(g1, LIPS_INNER.map((i) => state.dense[f][i]), c1.width, c1.height, '#f87171');
|
strokePts(g1, LIPS_OUTER.map((i) => sc(state.dense[f][i])), '#4ade80');
|
||||||
|
strokePts(g1, LIPS_INNER.map((i) => sc(state.dense[f][i])), '#f87171');
|
||||||
}
|
}
|
||||||
|
|
||||||
// pane 2: stabilised contour against a fixed reference cross.
|
|
||||||
// If stabilisation works, this contour stays put except for mouth motion.
|
|
||||||
const c2 = el('cv-stab'), g2 = c2.getContext('2d');
|
const c2 = el('cv-stab'), g2 = c2.getContext('2d');
|
||||||
c2.width = RW * ZOOM; c2.height = RH * ZOOM;
|
c2.width = RW * ZOOM; c2.height = RH * ZOOM;
|
||||||
g2.fillStyle = '#0d0f16'; g2.fillRect(0, 0, c2.width, c2.height);
|
g2.fillStyle = '#0d0f16'; g2.fillRect(0, 0, c2.width, c2.height);
|
||||||
g2.strokeStyle = '#2a2f3e'; g2.lineWidth = 1;
|
g2.strokeStyle = '#2a2f3e'; g2.lineWidth = 1; g2.beginPath();
|
||||||
g2.beginPath();
|
|
||||||
g2.moveTo(c2.width / 2, 0); g2.lineTo(c2.width / 2, c2.height);
|
g2.moveTo(c2.width / 2, 0); g2.lineTo(c2.width / 2, c2.height);
|
||||||
g2.moveTo(0, c2.height / 2); g2.lineTo(c2.width, c2.height / 2);
|
g2.moveTo(0, c2.height / 2); g2.lineTo(c2.width, c2.height / 2); g2.stroke();
|
||||||
g2.stroke();
|
const z = (pts) => pts.map((p) => ({ x: p.x * ZOOM, y: p.y * ZOOM }));
|
||||||
const pl = (pts) => pts.map((p) => { const q = state.xform(p); return { x: q.x * ZOOM, y: q.y * ZOOM }; });
|
strokePts(g2, z(state.plates[pf]), '#3b4a63');
|
||||||
strokePts(g2, pl(state.stab.oval[f]), '#3b4a63');
|
strokePts(g2, z(state.outer[f]), '#4ade80');
|
||||||
strokePts(g2, pl(state.stab.outer[f]), '#4ade80');
|
if (!state.hidden[f]) strokePts(g2, z(state.inner[f]), '#f87171');
|
||||||
strokePts(g2, pl(state.stab.inner[f]), '#f87171');
|
|
||||||
const ko = activeKey(state.keysOuter.keys, f);
|
|
||||||
strokePts(g2, state.shapesOuter[ko.src].map((p) => ({ x: p.x * ZOOM, y: p.y * ZOOM })), '#fbbf24', 2);
|
|
||||||
|
|
||||||
// pane 3: the flat indexed render
|
|
||||||
blit(el('cv-render'), renderFrame(f), ZOOM);
|
blit(el('cv-render'), renderFrame(f), ZOOM);
|
||||||
}
|
}
|
||||||
|
|
||||||
function strokeRing(g, pts, w, h, color) {
|
|
||||||
strokePts(g, pts.map((p) => ({ x: p.x * w, y: p.y * h })), color);
|
|
||||||
}
|
|
||||||
|
|
||||||
function strokePts(g, pts, color, lw = 1) {
|
function strokePts(g, pts, color, lw = 1) {
|
||||||
g.strokeStyle = color; g.lineWidth = lw;
|
g.strokeStyle = color; g.lineWidth = lw;
|
||||||
g.beginPath();
|
g.beginPath();
|
||||||
|
|
@ -248,51 +245,80 @@ function strokePts(g, pts, color, lw = 1) {
|
||||||
g.closePath(); g.stroke();
|
g.closePath(); g.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawTimeline() {
|
/* ---------- the frame strip: this is the editing surface ---------- */
|
||||||
const c = el('cv-timeline'), g = c.getContext('2d');
|
|
||||||
|
function drawStrip() {
|
||||||
|
const host = el('strip');
|
||||||
|
host.innerHTML = '';
|
||||||
const N = state.dense.length;
|
const N = state.dense.length;
|
||||||
const W = Math.max(640, N * 8), H = 90;
|
for (let f = 0; f < N; f++) {
|
||||||
c.width = W; c.height = H;
|
const cell = document.createElement('div');
|
||||||
const px = W / N;
|
cell.className = 'fr' + (state.keep.has(f) ? ' keep' : ' drop') + (f === state.frame ? ' cur' : '');
|
||||||
g.fillStyle = '#0d0f16'; g.fillRect(0, 0, W, H);
|
cell.dataset.f = f;
|
||||||
|
|
||||||
const vel = state.keysOuter.velocity;
|
const cv = document.createElement('canvas');
|
||||||
const vmax = Math.max(...vel) || 1;
|
const im = state.images[f];
|
||||||
g.strokeStyle = '#3b4a63'; g.lineWidth = 1; g.beginPath();
|
cv.width = THUMB; cv.height = THUMB;
|
||||||
vel.forEach((v, i) => {
|
const g = cv.getContext('2d');
|
||||||
const x = i * px + px / 2, y = H - 18 - (v / vmax) * (H - 34);
|
g.fillStyle = '#0d0f16'; g.fillRect(0, 0, THUMB, THUMB);
|
||||||
i ? g.lineTo(x, y) : g.moveTo(x, y);
|
if (im) {
|
||||||
});
|
const b = state.faceBox[f];
|
||||||
g.stroke();
|
const sx = b.x0 * im.naturalWidth, sy = b.y0 * im.naturalHeight;
|
||||||
|
const sw = (b.x1 - b.x0) * im.naturalWidth, sh = (b.y1 - b.y0) * im.naturalHeight;
|
||||||
g.fillStyle = '#5b6478';
|
const s = Math.min(THUMB / sw, THUMB / sh);
|
||||||
for (const t of state.keysOuter.candidates) g.fillRect(t * px + px / 2 - 0.5, H - 18, 1, 6);
|
g.drawImage(im, sx, sy, sw, sh, (THUMB - sw * s) / 2, (THUMB - sh * s) / 2, sw * s, sh * s);
|
||||||
|
} else {
|
||||||
for (const k of state.keysOuter.keys) {
|
const r = new IndexedRaster(RW, RH);
|
||||||
g.fillStyle = '#fbbf24';
|
r.clear(IDX.bg); r.fillPoly(state.plates[f], IDX.base);
|
||||||
g.fillRect(k.f * px + px / 2 - 1, 6, 2, H - 24); // f: what renders
|
const tmp = document.createElement('canvas');
|
||||||
if (k.src !== k.f) {
|
blit(tmp, r, 1);
|
||||||
g.fillStyle = '#f87171';
|
g.drawImage(tmp, 0, 0, RW, RH, 0, 0, THUMB, THUMB * (RH / RW));
|
||||||
g.fillRect(k.src * px + px / 2 - 0.5, 6, 1, 10); // src: the true extreme
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g.fillStyle = '#e8eaf0';
|
|
||||||
g.fillRect(state.frame * px + px / 2 - 0.5, 0, 1, H);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawContactSheet() {
|
const tag = document.createElement('span');
|
||||||
|
tag.textContent = f;
|
||||||
|
cell.append(cv, tag);
|
||||||
|
cell.onclick = (ev) => {
|
||||||
|
state.frame = f;
|
||||||
|
if (ev.shiftKey) toggle(f);
|
||||||
|
drawAll();
|
||||||
|
};
|
||||||
|
cell.ondblclick = () => { toggle(f); drawAll(); };
|
||||||
|
host.append(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(f) {
|
||||||
|
if (f === 0) return; // frame 0 always has a drawing
|
||||||
|
if (state.keep.has(f)) state.keep.delete(f); else state.keep.add(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- worksheet: the frames a human must draw ---------- */
|
||||||
|
|
||||||
|
function drawWorksheet() {
|
||||||
const host = el('sheet');
|
const host = el('sheet');
|
||||||
host.innerHTML = '';
|
host.innerHTML = '';
|
||||||
state.keysOuter.keys.forEach((k, i) => {
|
const kept = keptSorted();
|
||||||
|
kept.forEach((f, i) => {
|
||||||
|
const until = (i + 1 < kept.length ? kept[i + 1] : state.dense.length) - 1;
|
||||||
const cell = document.createElement('div');
|
const cell = document.createElement('div');
|
||||||
cell.className = 'cell';
|
cell.className = 'cell';
|
||||||
const cv = document.createElement('canvas');
|
const cv = document.createElement('canvas');
|
||||||
blit(cv, renderFrame(k.f), 1);
|
const im = state.images[f];
|
||||||
cv.style.width = '104px';
|
if (im) {
|
||||||
|
const b = state.faceBox[f];
|
||||||
|
const sx = b.x0 * im.naturalWidth, sy = b.y0 * im.naturalHeight;
|
||||||
|
const sw = (b.x1 - b.x0) * im.naturalWidth, sh = (b.y1 - b.y0) * im.naturalHeight;
|
||||||
|
cv.width = 150; cv.height = Math.round(150 * (sh / sw));
|
||||||
|
cv.getContext('2d').drawImage(im, sx, sy, sw, sh, 0, 0, cv.width, cv.height);
|
||||||
|
} else {
|
||||||
|
blit(cv, renderFrame(f), 1);
|
||||||
|
cv.style.width = '150px';
|
||||||
|
}
|
||||||
const cap = document.createElement('span');
|
const cap = document.createElement('span');
|
||||||
cap.textContent = `f${k.f}${k.src !== k.f ? ` ←${k.src}` : ''}${state.hidden[i] ? ' ·closed' : ''}`;
|
cap.textContent = `f${f}` + (until > f ? ` → ${until}` : '') + ` (${until - f + 1}f)`;
|
||||||
cell.append(cv, cap);
|
cell.append(cv, cap);
|
||||||
cell.onclick = () => { state.frame = k.f; draw(); drawTimeline(); };
|
cell.onclick = () => { state.frame = f; drawAll(); };
|
||||||
host.append(cell);
|
host.append(cell);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -300,92 +326,118 @@ function drawContactSheet() {
|
||||||
/* ---------- export ---------- */
|
/* ---------- export ---------- */
|
||||||
|
|
||||||
function exportTake() {
|
function exportTake() {
|
||||||
const keys = state.keysOuter.keys;
|
const kept = keptSorted();
|
||||||
|
const N = state.dense.length;
|
||||||
const take = {
|
const take = {
|
||||||
name: el('takename').value || 'line_01',
|
name: el('takename').value || 'line_01',
|
||||||
frames: state.dense.length, width: RW, height: RH,
|
frames: N, width: RW, height: RH, exposure: 1,
|
||||||
exposure: opts().exposure,
|
|
||||||
palette: PALETTE,
|
palette: PALETTE,
|
||||||
slot: { x: RW / 2, y: RH / 2 },
|
slot: { x: RW / 2, y: RH / 2 },
|
||||||
parts: [
|
parts: [
|
||||||
{ name: 'head', kind: 'plate', z: 0, interp: 'hold', keys: [{ f: 0, plate: 0 }] },
|
// Sparse: one plate key per frame a human draws.
|
||||||
|
{ name: 'head', kind: 'plate', z: 0, interp: 'hold',
|
||||||
|
keys: kept.map((f, i) => ({ f: i, plate: i, src: f })) },
|
||||||
|
// Dense: the traced mouth keeps every frame.
|
||||||
{ name: 'mouth', kind: 'poly', z: 30, color: 'skin_dark', interp: 'hold',
|
{ name: 'mouth', kind: 'poly', z: 30, color: 'skin_dark', interp: 'hold',
|
||||||
keys: keys.map((k) => ({ f: k.f, src: k.src, pts: state.shapesOuter[k.src] })) },
|
keys: state.outer.map((pts, f) => ({ f, src: f, pts })) },
|
||||||
{ name: 'mouth_in', kind: 'poly', z: 31, color: 'mouth_dark', interp: 'hold', parent: 'mouth',
|
{ name: 'mouth_in', kind: 'poly', z: 31, color: 'mouth_dark', interp: 'hold', parent: 'mouth',
|
||||||
keys: keys.map((k, i) => state.hidden[i]
|
keys: state.inner.map((pts, f) => (state.hidden[f] ? { f, hidden: true } : { f, src: f, pts })) },
|
||||||
? { f: k.f, hidden: true }
|
|
||||||
: { f: k.f, src: k.src, pts: state.shapesInner[k.src] }) },
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const text = writeTake(take);
|
const text = writeTake(take)
|
||||||
|
+ `\n# plate drawings needed: ${kept.length} of ${N} frames\n`
|
||||||
|
+ kept.map((f, i) => {
|
||||||
|
const until = (i + 1 < kept.length ? kept[i + 1] : N) - 1;
|
||||||
|
return `# plate ${i} = source frame ${f}, holds f${f}..${until}`;
|
||||||
|
}).join('\n') + '\n';
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
|
a.href = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
|
||||||
a.download = `${take.name}.take`;
|
a.download = `${take.name}.take`;
|
||||||
a.click();
|
a.click();
|
||||||
status(`exported ${take.name}.take — ${keys.length} keys`, 'ok');
|
status(`exported — ${kept.length} plate drawings, ${N} mouth frames`, 'ok');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- wiring ---------- */
|
/* ---------- wiring ---------- */
|
||||||
|
|
||||||
async function runFrames() {
|
async function runFrames() {
|
||||||
try {
|
try {
|
||||||
status('loading frames…');
|
|
||||||
const images = await loadFrameSequence();
|
const images = await loadFrameSequence();
|
||||||
if (!images.length) {
|
if (!images.length) {
|
||||||
status(`no frames found in ${el('framedir').value}/ — run extract.sh first`, 'err');
|
status(`no frames in ${el('framedir').value}/ — run extract.sh first`, 'err');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { dense, missing } = await detectAll(images);
|
const { dense, missing } = await detectAll(images);
|
||||||
state.images = images; state.dense = dense; state.source = 'video';
|
state.images = images; state.dense = dense;
|
||||||
el('scrub').max = dense.length - 1;
|
el('scrub').max = dense.length - 1;
|
||||||
state.frame = 0;
|
state.frame = 0;
|
||||||
rebuild();
|
rebuild(true);
|
||||||
status(missing.length
|
status(missing.length
|
||||||
? `${images.length} frames · no face on ${missing.length} (held previous)`
|
? `${images.length} frames · no face on ${missing.length} (held previous)`
|
||||||
: `${images.length} frames detected`, missing.length ? 'warn' : 'ok');
|
: `${images.length} frames detected`, missing.length ? 'warn' : 'ok');
|
||||||
} catch (e) {
|
} catch (e) { status(e.message, 'err'); console.error(e); }
|
||||||
status(`${e.message}`, 'err');
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function runSynthetic() {
|
function runSynthetic() {
|
||||||
state.images = [];
|
state.images = [];
|
||||||
state.dense = synthDense(72);
|
state.dense = synthDense(72);
|
||||||
state.source = 'synthetic';
|
|
||||||
el('scrub').max = 71;
|
el('scrub').max = 71;
|
||||||
state.frame = 0;
|
state.frame = 0;
|
||||||
rebuild();
|
rebuild(true);
|
||||||
status('synthetic take — no video needed; exercises the whole chain below detection', 'ok');
|
status('synthetic — exercises everything below detection', 'ok');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const id of ['verts','minHold','distThresh','velSmooth','smoothWin','exposure','apertureThresh']) {
|
for (const id of ['verts', 'smoothWin', 'contourSmooth', 'apertureThresh', 'tol']) {
|
||||||
el(id).addEventListener('input', () => {
|
el(id).addEventListener('input', () => {
|
||||||
el(id + 'v').textContent = id === 'distThresh' ? (+el(id).value / 10).toFixed(1)
|
el(id + 'v').textContent = id === 'apertureThresh' || id === 'tol'
|
||||||
: id === 'apertureThresh' ? (+el(id).value / 1000).toFixed(3)
|
? (+el(id).value / 1000).toFixed(3) : el(id).value;
|
||||||
: el(id).value;
|
if (id === 'tol') return; // tol only matters when you ask for a suggestion
|
||||||
rebuild();
|
rebuild(false);
|
||||||
});
|
});
|
||||||
el(id + 'v').textContent = el(id).value;
|
el(id + 'v').textContent = el(id).value;
|
||||||
}
|
}
|
||||||
el('scrub').addEventListener('input', (e) => { state.frame = +e.target.value; draw(); drawTimeline(); });
|
|
||||||
|
el('scrub').addEventListener('input', (e) => { state.frame = +e.target.value; drawAll(); });
|
||||||
el('btn-frames').onclick = runFrames;
|
el('btn-frames').onclick = runFrames;
|
||||||
el('btn-synth').onclick = runSynthetic;
|
el('btn-synth').onclick = runSynthetic;
|
||||||
el('btn-export').onclick = exportTake;
|
el('btn-export').onclick = exportTake;
|
||||||
|
el('btn-keepall').onclick = () => { if (state.dense) { rebuild(true); } };
|
||||||
|
el('btn-suggest').onclick = () => {
|
||||||
|
if (!state.dense) return;
|
||||||
|
const kept = suggestPlateFrames(state.stab.rigid, opts().tol);
|
||||||
|
state.keep = new Set(kept);
|
||||||
|
drawAll();
|
||||||
|
status(`suggested ${kept.length} drawings at tolerance ${opts().tol.toFixed(3)} — now hand-correct`, 'ok');
|
||||||
|
};
|
||||||
el('btn-play').onclick = () => {
|
el('btn-play').onclick = () => {
|
||||||
state.playing = !state.playing;
|
state.playing = !state.playing;
|
||||||
el('btn-play').textContent = state.playing ? 'Stop' : 'Play';
|
el('btn-play').textContent = state.playing ? 'Stop' : 'Play';
|
||||||
if (state.playing) tick();
|
if (state.playing) tick();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keyboard is the point: stepping and deleting 37 frames by mouse is miserable.
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (!state.dense || e.target.tagName === 'INPUT') return;
|
||||||
|
const N = state.dense.length;
|
||||||
|
if (e.key === 'ArrowRight') { state.frame = Math.min(N - 1, state.frame + 1); drawAll(); }
|
||||||
|
else if (e.key === 'ArrowLeft') { state.frame = Math.max(0, state.frame - 1); drawAll(); }
|
||||||
|
else if (e.key === 'Backspace' || e.key === 'Delete' || e.key === 'x') {
|
||||||
|
state.keep.delete(state.frame === 0 ? -1 : state.frame); drawAll();
|
||||||
|
} else if (e.key === 'k' || e.key === ' ') {
|
||||||
|
if (state.frame !== 0) state.keep.add(state.frame);
|
||||||
|
drawAll();
|
||||||
|
} else return;
|
||||||
|
el('scrub').value = state.frame;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
let last = 0;
|
let last = 0;
|
||||||
function tick(ts = 0) {
|
function tick(ts = 0) {
|
||||||
if (!state.playing) return;
|
if (!state.playing) return;
|
||||||
if (ts - last > 1000 / 24) {
|
if (ts - last > 1000 / +el('playfps').value) {
|
||||||
last = ts;
|
last = ts;
|
||||||
state.frame = (state.frame + 1) % state.dense.length;
|
state.frame = (state.frame + 1) % state.dense.length;
|
||||||
el('scrub').value = state.frame;
|
el('scrub').value = state.frame;
|
||||||
draw(); drawTimeline();
|
drawPanes(); drawStrip();
|
||||||
}
|
}
|
||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|
@ -395,9 +447,15 @@ PALETTE.forEach((p) => {
|
||||||
sw.className = 'sw';
|
sw.className = 'sw';
|
||||||
const inp = document.createElement('input');
|
const inp = document.createElement('input');
|
||||||
inp.type = 'color'; inp.value = p.hex;
|
inp.type = 'color'; inp.value = p.hex;
|
||||||
inp.oninput = () => { p.hex = inp.value; if (state.dense) { draw(); drawContactSheet(); } };
|
inp.oninput = () => { p.hex = inp.value; if (state.dense) drawAll(); };
|
||||||
sw.append(inp, document.createTextNode(p.name));
|
sw.append(inp, document.createTextNode(p.name));
|
||||||
el('palette').append(sw);
|
el('palette').append(sw);
|
||||||
});
|
});
|
||||||
|
|
||||||
status('ready — "Synthetic take" works with no video; "Load frames" reads ./frames/');
|
// #synth / #frames autorun, so the tool can be driven headlessly for smoke tests
|
||||||
|
// and deep-linked. Detection needs WebGL; the synthetic path does not.
|
||||||
|
if (location.hash === '#synth') runSynthetic();
|
||||||
|
else if (location.hash === '#frames') runFrames();
|
||||||
|
else status('ready — Load frames, then step with \u2190 \u2192 and delete with X');
|
||||||
|
|
||||||
|
window.__roto = state; // headless smoke test reads this
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,12 @@ export function procrustesMean(framesRigid, iters = 3) {
|
||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|
||||||
function movingAverage(vals, win) {
|
// `radius` is in frames either side: 0 is off, 1 averages over 3 frames, 2 over
|
||||||
if (win <= 1) return vals.slice();
|
// 5. Expressed as a radius rather than a window so that "off" is 0 and every
|
||||||
const half = Math.floor(win / 2), out = new Array(vals.length);
|
// 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++) {
|
for (let i = 0; i < vals.length; i++) {
|
||||||
let acc = 0, cnt = 0;
|
let acc = 0, cnt = 0;
|
||||||
for (let j = i - half; j <= i + half; j++) {
|
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
|
// where the low-pass belongs; smoothing the contour would destroy the
|
||||||
// performance, which is the entire asset.
|
// performance, which is the entire asset.
|
||||||
// Angles are smoothed as (cos, sin) so wrapping cannot produce a spike.
|
// Angles are smoothed as (cos, sin) so wrapping cannot produce a spike.
|
||||||
export function smoothTransforms(tfs, win) {
|
export function smoothTransforms(tfs, radius) {
|
||||||
const c = movingAverage(tfs.map((t) => Math.cos(t.theta)), win);
|
const c = movingAverage(tfs.map((t) => Math.cos(t.theta)), radius);
|
||||||
const sn = movingAverage(tfs.map((t) => Math.sin(t.theta)), win);
|
const sn = movingAverage(tfs.map((t) => Math.sin(t.theta)), radius);
|
||||||
const s = movingAverage(tfs.map((t) => t.s), win);
|
const s = movingAverage(tfs.map((t) => t.s), radius);
|
||||||
const tx = movingAverage(tfs.map((t) => t.tx), win);
|
const tx = movingAverage(tfs.map((t) => t.tx), radius);
|
||||||
const ty = movingAverage(tfs.map((t) => t.ty), win);
|
const ty = movingAverage(tfs.map((t) => t.ty), radius);
|
||||||
return tfs.map((_, i) => ({
|
return tfs.map((_, i) => ({
|
||||||
theta: Math.atan2(sn[i], c[i]), s: s[i], tx: tx[i], ty: ty[i],
|
theta: Math.atan2(sn[i], c[i]), s: s[i], tx: tx[i], ty: ty[i],
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,19 @@ const pick = (lm, idx) => idx.map((i) => ({ x: lm[i].x, y: lm[i].y }));
|
||||||
// Stage 1-3: fit the rigid transform per frame, smooth its parameters, then map
|
// Stage 1-3: fit the rigid transform per frame, smooth its parameters, then map
|
||||||
// every contour through it into the reference frame. The result is head-local:
|
// every contour through it into the reference frame. The result is head-local:
|
||||||
// translation, roll and depth-scale of the head are gone.
|
// translation, roll and depth-scale of the head are gone.
|
||||||
export function stabilize(dense, smoothWin) {
|
export function stabilize(dense, smoothRadius) {
|
||||||
const rigid = dense.map((f) => pick(f, RIGID));
|
const rigid = dense.map((f) => pick(f, RIGID));
|
||||||
const ref = procrustesMean(rigid);
|
const ref = procrustesMean(rigid);
|
||||||
const raw = rigid.map((r) => fitSimilarity(r, ref));
|
const raw = rigid.map((r) => fitSimilarity(r, ref));
|
||||||
const tfs = smoothTransforms(raw, smoothWin);
|
const tfs = smoothTransforms(raw, smoothRadius);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ref,
|
ref,
|
||||||
transforms: tfs,
|
transforms: tfs,
|
||||||
|
// Rigid landmarks in IMAGE space: the head-pose signal. Frame removal is
|
||||||
|
// decided from head motion, not from the mouth, so this has to survive the
|
||||||
|
// fit rather than being consumed by it.
|
||||||
|
rigid,
|
||||||
// Residual rises with out-of-plane rotation, which no 2D similarity can
|
// Residual rises with out-of-plane rotation, which no 2D similarity can
|
||||||
// remove. High values mean this section wants a different head plate.
|
// remove. High values mean this section wants a different head plate.
|
||||||
residual: tfs.map((tf, i) => fitResidual(tf, rigid[i], ref)),
|
residual: tfs.map((tf, i) => fitResidual(tf, rigid[i], ref)),
|
||||||
|
|
@ -106,3 +110,61 @@ export function activeKey(keys, f) {
|
||||||
for (const k of keys) { if (k.f <= f) hit = k; else break; }
|
for (const k of keys) { if (k.f <= f) hit = k; else break; }
|
||||||
return hit;
|
return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Temporal smoothing of a contour, per vertex, across time.
|
||||||
|
//
|
||||||
|
// docs/roto-puppet.md says to smooth the transform and never the contour. That
|
||||||
|
// was correct while keys were sparse: sampling at velocity minima rejected
|
||||||
|
// per-frame detector noise for free. With a key on every frame the noise is
|
||||||
|
// visible as a shimmer along the lip edge, so a bounded exception applies -
|
||||||
|
// the window must stay SHORTER than the shortest articulation worth keeping.
|
||||||
|
// At 12fps, mouth movement spans 3-6 frames and detector noise is per-frame, so
|
||||||
|
// a radius of 1 separates them and a radius of 3 would start eating speech.
|
||||||
|
//
|
||||||
|
// `radius` in frames either side: 0 off, 1 = 3-frame average, 2 = 5-frame.
|
||||||
|
export function smoothContours(rings, radius) {
|
||||||
|
if (radius <= 0) return rings;
|
||||||
|
const half = Math.floor(radius), N = rings.length, V = rings[0].length;
|
||||||
|
const out = [];
|
||||||
|
for (let t = 0; t < N; t++) {
|
||||||
|
const frame = [];
|
||||||
|
for (let v = 0; v < V; v++) {
|
||||||
|
let sx = 0, sy = 0, c = 0;
|
||||||
|
for (let j = t - half; j <= t + half; j++) {
|
||||||
|
const k = Math.min(N - 1, Math.max(0, j));
|
||||||
|
sx += rings[k][v].x; sy += rings[k][v].y; c++;
|
||||||
|
}
|
||||||
|
frame.push({ x: sx / c, y: sy / c });
|
||||||
|
}
|
||||||
|
out.push(frame);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which frames need their own PLATE drawing.
|
||||||
|
//
|
||||||
|
// This is frame removal, not keyframe extraction: every frame is a candidate and
|
||||||
|
// the question is which can be dropped. Walk forward holding the current drawing
|
||||||
|
// until the head has moved further than `tol` from it, then a new drawing is
|
||||||
|
// required. The cost being managed is an artist drawing a head, which is why the
|
||||||
|
// signal is head pose and not the mouth - the mouth is traced and free.
|
||||||
|
export function suggestPlateFrames(rigid, tol) {
|
||||||
|
const dist = (a, b) => {
|
||||||
|
let m = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) m = Math.max(m, Math.hypot(a[i].x - b[i].x, a[i].y - b[i].y));
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
const keep = [0];
|
||||||
|
let anchor = 0;
|
||||||
|
for (let f = 1; f < rigid.length; f++) {
|
||||||
|
if (dist(rigid[f], rigid[anchor]) > tol) { keep.push(f); anchor = f; }
|
||||||
|
}
|
||||||
|
return keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearest kept frame at or before f - the plate that is on screen.
|
||||||
|
export function heldFrame(kept, f) {
|
||||||
|
let hit = kept[0];
|
||||||
|
for (const k of kept) { if (k <= f) hit = k; else break; }
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ export function run() {
|
||||||
`s=${got.s.toFixed(6)} th=${got.theta.toFixed(6)}`);
|
`s=${got.s.toFixed(6)} th=${got.theta.toFixed(6)}`);
|
||||||
|
|
||||||
// stabilisation: head motion out, mouth motion kept
|
// stabilisation: head motion out, mouth motion kept
|
||||||
const stab = stabilize(dense, 1);
|
const stab = stabilize(dense, 0);
|
||||||
const rawSpread = spreadX(dense, 133);
|
const rawSpread = spreadX(dense, 133);
|
||||||
const stabSpread = (() => {
|
const stabSpread = (() => {
|
||||||
const xs = stab.eyes.map((e) => e[0].x);
|
const xs = stab.eyes.map((e) => e[0].x);
|
||||||
|
|
|
||||||
79
probe.html
Normal file
79
probe.html
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<!doctype html><html><head><meta charset="utf-8"><title>probe…</title></head>
|
||||||
|
<body><pre id="o">running…</pre>
|
||||||
|
<script type="module">
|
||||||
|
import { FaceLandmarker, FilesetResolver } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/vision_bundle.mjs';
|
||||||
|
import { LIPS_OUTER, LIPS_INNER, subsampleSlots } from './js/landmarks.js';
|
||||||
|
import { stabilize, toRasterRing, selectKeys } from './js/pipeline.js';
|
||||||
|
|
||||||
|
const log = [];
|
||||||
|
const say = (s) => { log.push(s); document.getElementById('o').textContent = log.join('\n'); };
|
||||||
|
|
||||||
|
function crosses(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;}
|
||||||
|
function selfInts(pts){const n=pts.length,h=[];for(let i=0;i<n;i++)for(let j=i+1;j<n;j++){
|
||||||
|
if((j+1)%n===i||(i+1)%n===j)continue;
|
||||||
|
if(crosses(pts[i],pts[(i+1)%n],pts[j],pts[(j+1)%n]))h.push([i,j]);}return h;}
|
||||||
|
|
||||||
|
const loadImg = (src) => new Promise(r => { const i=new Image(); i.onload=()=>r(i); i.onerror=()=>r(null); i.src=src; });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imgs=[];
|
||||||
|
for(let i=1;i<=900;i++){const im=await loadImg(`frames/${String(i).padStart(4,'0')}.png`); if(!im)break; imgs.push(im);}
|
||||||
|
say(`frames loaded: ${imgs.length} @ ${imgs[0].naturalWidth}x${imgs[0].naturalHeight}`);
|
||||||
|
|
||||||
|
const fs = await FilesetResolver.forVisionTasks('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/wasm');
|
||||||
|
const lm = await FaceLandmarker.createFromOptions(fs, {
|
||||||
|
baseOptions:{ modelAssetPath:'./face_landmarker.task', delegate:'CPU' },
|
||||||
|
runningMode:'IMAGE', numFaces:1 });
|
||||||
|
say('landmarker ready (CPU delegate)');
|
||||||
|
|
||||||
|
const cv=document.createElement('canvas'); const dense=[]; let miss=0;
|
||||||
|
for(const im of imgs){
|
||||||
|
cv.width=im.naturalWidth; cv.height=im.naturalHeight;
|
||||||
|
cv.getContext('2d').drawImage(im,0,0);
|
||||||
|
const out=lm.detect(cv);
|
||||||
|
if(out.faceLandmarks?.length) dense.push(out.faceLandmarks[0]);
|
||||||
|
else { miss++; if(dense.length) dense.push(dense[dense.length-1]); }
|
||||||
|
}
|
||||||
|
say(`detected: ${dense.length}/${imgs.length} (no face on ${miss})`);
|
||||||
|
if(!dense.length) throw new Error('no face detected in any frame');
|
||||||
|
|
||||||
|
// THE key check: are LIPS_OUTER / LIPS_INNER correct traversals of real data?
|
||||||
|
for(const [name,tab] of [['LIPS_OUTER',LIPS_OUTER],['LIPS_INNER',LIPS_INNER]]){
|
||||||
|
let bad=0, first=null;
|
||||||
|
for(let n=4;n<=16;n+=2){
|
||||||
|
const slots=subsampleSlots(tab.length,n);
|
||||||
|
for(let f=0;f<dense.length;f++){
|
||||||
|
const h=selfInts(slots.map(s=>dense[f][tab[s]]));
|
||||||
|
if(h.length){bad++; first=first||`verts=${n} f=${f} edges ${JSON.stringify(h[0])}`;}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
say(`${name}: ${bad===0?'SIMPLE at every budget/frame':`SELF-INTERSECTS ${bad}x first ${first}`}`);
|
||||||
|
// full 20-ring too
|
||||||
|
let bad20=0;
|
||||||
|
for(let f=0;f<dense.length;f++) if(selfInts(tab.map(i=>dense[f][i])).length) bad20++;
|
||||||
|
say(` full 20-point ring: ${bad20===0?'simple on all frames':`self-intersects on ${bad20} frames`}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const st = stabilize(dense, 5);
|
||||||
|
const res = st.residual;
|
||||||
|
const mean = res.reduce((a,b)=>a+b,0)/res.length;
|
||||||
|
say(`residual mean ${mean.toFixed(5)} max ${Math.max(...res).toFixed(5)} (high = out-of-plane rotation)`);
|
||||||
|
const eyeX = st.eyes.map(e=>e[0].x);
|
||||||
|
const rawX = dense.map(f=>f[133].x);
|
||||||
|
say(`eye-inner x spread: raw ${(Math.max(...rawX)-Math.min(...rawX)).toFixed(4)} -> stabilised ${(Math.max(...eyeX)-Math.min(...eyeX)).toFixed(4)}`);
|
||||||
|
const ap=st.aperture;
|
||||||
|
say(`aperture min ${Math.min(...ap).toFixed(4)} max ${Math.max(...ap).toFixed(4)} range ${(Math.max(...ap)-Math.min(...ap)).toFixed(4)}`);
|
||||||
|
let nf=0; ap.forEach((v,i)=>{ if(v===Math.min(...ap)) nf=i; });
|
||||||
|
say(`most-closed frame: ${nf}`);
|
||||||
|
|
||||||
|
const xf=p=>({x:p.x*320,y:p.y*200});
|
||||||
|
const shapes=st.outer.map(r=>toRasterRing(r,LIPS_OUTER,8,xf));
|
||||||
|
for(const [mh,dt] of [[1,0.6],[2,0.6],[2,1.5],[2,3.0],[3,1.5]]){
|
||||||
|
const k=selectKeys(shapes,{minHold:mh,distThresh:dt,velSmooth:3,exposure:1});
|
||||||
|
say(`minHold=${mh} gate=${dt}: ${k.candidates.length} cand -> ${k.keys.length} keys [${k.keys.map(x=>x.f).join(' ')}]`);
|
||||||
|
}
|
||||||
|
document.title='PROBE OK';
|
||||||
|
} catch(e){ say('ERROR: '+e.message+'\n'+e.stack); document.title='PROBE FAIL'; }
|
||||||
|
</script></body></html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue