Give features stable identity, eye pairs and per-feature presence
Step 8's data model, ahead of its controls. Nothing here is a UI. domain/params holds every knob's definition once — default, applicable area, value constraints and the areas a change would force to regenerate. flow/take's literal knob map becomes a view of it, so the take's defaults and the future parameter panel cannot drift apart. domain/feature adds subjects, features and groups as document data the renderer never reads. A feature ID is stable for the whole clip, across occlusion: a run of visible frames is not a new identity. An eye pair is an explicit group of one or two eyes of the same subject, so a profile view with one identified eye needs no invented partner. Settings resolve area -> subject -> group -> feature, and dropping an eye from a pair materialises its effective values first so playback does not jump. scene/problems now validates all of it. Presence becomes per-feature rather than per-subject. freeze's :absent predicate takes a track as well as a frame, so one occluded eye can be absent while its partner still has a value; a full-face miss still marks everything absent. A manifest may annotate known gaps as one-based inclusive intervals, which ingest expands into observation tracks before measurement. An unobserved eye then gets no vote in the iris pairing and cannot steer the shared gaze — gaze falls back to whichever eye is visible. Temporal filters still see a sample on every frame, held from the last observed one, because the numbers are a rectangular buffer; the state mask, not the buffer, is what says the frame has no value. js/app.js gets the same occlusion lesson: leading nulls from a face that starts occluded used to throw away the whole take, and the neutral frame could be chosen from a held duplicate pose. Parameter editing, scoped regeneration and a feature-level detector remain. Until one exists, footage without annotations falls back to the full-face mask rather than claiming occlusions it cannot see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
This commit is contained in:
parent
ccca93e233
commit
35ef150b48
18 changed files with 631 additions and 86 deletions
44
js/app.js
44
js/app.js
|
|
@ -51,6 +51,7 @@ const state = {
|
|||
lead: 0, // performance-track offset in frames
|
||||
exposure: 1, // 1 = on 1s, 2 = on 2s. Picture holds; audio does not.
|
||||
interior: null, // per-frame teeth measurement from image content
|
||||
detected: null, // per-frame: did MediaPipe really see a face here?
|
||||
teeth: null, // resolved per-frame {show, t} after knobs
|
||||
eyes: null, // resolved per-frame lid rings, shut flags, iris discs
|
||||
brows: null, // resolved per-frame brow rings after quantised raise
|
||||
|
|
@ -167,18 +168,29 @@ async function initLandmarker() {
|
|||
async function detectAll(images) {
|
||||
const lm = await initLandmarker();
|
||||
const cv = document.createElement('canvas');
|
||||
const dense = [], missing = [];
|
||||
const dense = [], missing = [], detected = [];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const im = images[i];
|
||||
cv.width = im.naturalWidth; cv.height = im.naturalHeight;
|
||||
cv.getContext('2d').drawImage(im, 0, 0);
|
||||
const out = lm.detect(cv);
|
||||
if (out.faceLandmarks && out.faceLandmarks.length) dense.push(out.faceLandmarks[0]);
|
||||
else { missing.push(i); dense.push(dense.length ? dense[dense.length - 1] : null); }
|
||||
if (out.faceLandmarks && out.faceLandmarks.length) {
|
||||
dense.push(out.faceLandmarks[0]); detected.push(true);
|
||||
} else {
|
||||
missing.push(i); detected.push(false);
|
||||
dense.push(dense.length ? dense[dense.length - 1] : null);
|
||||
}
|
||||
if (i % 4 === 0) status(`detecting… ${i + 1}/${images.length}`);
|
||||
}
|
||||
if (dense[0] === null) throw new Error('no face found in the first frame');
|
||||
return { dense, missing };
|
||||
// A gap mid-take holds the previous frame, but a gap at the TOP has no
|
||||
// previous to hold - a face that starts occluded or walks in late left
|
||||
// leading nulls, and this used to throw and discard the whole take. Back-fill
|
||||
// from the first real detection: the mirror of hold-previous, and the only
|
||||
// fill that is a real pose from this take rather than an invention.
|
||||
const firstReal = dense.findIndex((d) => d !== null);
|
||||
if (firstReal < 0) throw new Error('no face found in any frame — check framing and light');
|
||||
for (let i = 0; i < firstReal; i++) dense[i] = dense[firstReal];
|
||||
return { dense, missing, detected, firstReal };
|
||||
}
|
||||
|
||||
// Interior measurement is a function of pixels alone, so it runs once with
|
||||
|
|
@ -217,10 +229,19 @@ function rebuild(resetKeep) {
|
|||
|
||||
state.stab = stabilize(state.dense, o.smoothWin, state.aspect);
|
||||
|
||||
// The neutral drives calibration and the placeholder plate, so it has to be a
|
||||
// frame the camera actually saw: a back-filled or held frame is a duplicate
|
||||
// pose, and letting one win this contest would calibrate the whole take
|
||||
// against a landmark set that belongs to some other moment.
|
||||
const ap = state.stab.aperture;
|
||||
const real = state.detected;
|
||||
const shut = (a, b) => (b < 0 || ap[a] < ap[b] ? a : b);
|
||||
const head = Math.max(1, Math.floor(N / 4));
|
||||
let neutral = 0;
|
||||
for (let i = 0; i < head; i++) if (ap[i] < ap[neutral]) neutral = i;
|
||||
let neutral = -1;
|
||||
for (let i = 0; i < head; i++) if (!real || real[i]) neutral = shut(i, neutral);
|
||||
// Whole head of the take occluded: widen to any real frame rather than give up.
|
||||
if (neutral < 0) for (let i = 0; i < N; i++) if (!real || real[i]) neutral = shut(i, neutral);
|
||||
if (neutral < 0) neutral = 0;
|
||||
state.neutral = neutral;
|
||||
state.xform = makeXform(state.stab, neutral);
|
||||
|
||||
|
|
@ -1072,8 +1093,8 @@ async function runFrames() {
|
|||
status(`no frames in ${el('framedir').value}/ — run extract.sh first`, 'err');
|
||||
return;
|
||||
}
|
||||
const { dense, missing } = await detectAll(images);
|
||||
state.images = images; state.dense = dense;
|
||||
const { dense, missing, detected, firstReal } = await detectAll(images);
|
||||
state.images = images; state.dense = dense; state.detected = detected;
|
||||
state.aspect = images[0].naturalWidth / images[0].naturalHeight;
|
||||
status('measuring mouth interiors…');
|
||||
state.interior = measureAll(images, dense, opts());
|
||||
|
|
@ -1086,7 +1107,9 @@ async function runFrames() {
|
|||
status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` +
|
||||
`${state.fps}fps · ${dur}s` +
|
||||
(state.audio ? ' · audio loaded' : ' · no audio') +
|
||||
(missing.length ? ` · no face on ${missing.length} (held previous)` : ''),
|
||||
(missing.length ? ` · no face on ${missing.length}` +
|
||||
(firstReal ? ` (${firstReal} at the top back-filled from ${firstReal + 1}, rest held)`
|
||||
: ' (held previous)') : ''),
|
||||
missing.length ? 'warn' : 'ok');
|
||||
} catch (e) { status(e.message, 'err'); console.error(e); }
|
||||
}
|
||||
|
|
@ -1096,6 +1119,7 @@ function runSynthetic() {
|
|||
attachAudio(null);
|
||||
state.fps = 12;
|
||||
state.aspect = 1; // synthetic landmarks are generated square
|
||||
state.detected = null; // no detection ran, so every frame counts as real
|
||||
state.lead = 0;
|
||||
state.interior = null; // no pixels, so no teeth
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue