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:
Olive Vaughn 2026-09-27 22:44:36 -04:00
parent ccca93e233
commit 35ef150b48
18 changed files with 631 additions and 86 deletions

View file

@ -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