index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>I:CAN:READ:YOUR:FACE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800;900&display=swap" rel="stylesheet">
<style>
:root{
--ink:#000; --mute:#8a8a8a; --line:#e6e6e6; --bg:#fff; --panel:320px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:var(--bg);color:var(--ink);font-family:Inter,"Helvetica Neue",Arial,sans-serif;overflow:hidden}
#stage{position:fixed;left:0;top:0;right:var(--panel);bottom:0;overflow:hidden;background:#fff}
#video{position:absolute;transform:scaleX(-1);object-fit:fill;opacity:1;transition:opacity .3s}
#video.off{opacity:0}
#gl,#ov{position:absolute;left:0;top:0;pointer-events:none}
#tags{position:absolute;inset:0;pointer-events:none}
.tag{position:absolute;left:0;top:0;background:#fff;padding:5px 9px 6px;white-space:nowrap;will-change:transform;border:1.5px solid #000}
.tag .k{display:block;font-size:9px;font-weight:600;letter-spacing:.12em;color:var(--mute);text-transform:uppercase;line-height:1.2}
.tag .v{display:block;font-size:15px;font-weight:900;letter-spacing:.02em;text-transform:uppercase;line-height:1.15}
#panel{position:fixed;top:0;right:0;bottom:0;width:var(--panel);border-left:1px solid var(--line);background:#fff;overflow-y:auto;padding:22px 22px 40px}
h1{font-size:19px;font-weight:900;letter-spacing:.01em}
.sub{font-size:11px;color:var(--mute);margin-top:4px;line-height:1.45}
.sec{margin-top:26px}
.sec>h2{font-size:10px;font-weight:600;letter-spacing:.14em;color:var(--mute);text-transform:uppercase;margin-bottom:10px}
.row{display:flex;align-items:center;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--line);font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;user-select:none}
.row small{font-weight:400;color:var(--mute);letter-spacing:.02em;text-transform:none;margin-left:6px}
.sw{width:30px;height:16px;border:1.5px solid #000;border-radius:16px;position:relative;flex:none;transition:background .15s}
.sw::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:#000;transition:transform .15s,background .15s}
.row.on .sw{background:#000}
.row.on .sw::after{transform:translateX(14px);background:#fff}
.seg{display:flex;gap:0;margin:8px 0 2px}
.seg button{flex:1;font:inherit;font-size:10px;font-weight:800;letter-spacing:.08em;padding:6px 0;border:1.5px solid #000;background:#fff;cursor:pointer}
.seg button+button{border-left:none}
.seg button.on{background:#000;color:#fff}
.bars{display:grid;grid-template-columns:auto 1fr 34px;gap:3px 8px;align-items:center;font-size:10px}
.bars .n{color:#333;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:130px}
.bars .b{height:6px;background:#f0f0f0;position:relative}
.bars .b i{position:absolute;left:0;top:0;bottom:0;background:#000}
.bars .p{text-align:right;font-variant-numeric:tabular-nums;color:#333}
.bars .n.hi{font-weight:800;color:#000}
.formula{font-size:10px;color:#555;line-height:1.55;margin-top:10px;font-family:ui-monospace,Menlo,monospace}
.formula b{color:#000}
.status{font-size:10px;color:var(--mute);margin-top:6px;letter-spacing:.04em;font-variant-numeric:tabular-nums}
.note{font-size:10px;color:var(--mute);line-height:1.5;margin-top:28px;border-top:1px solid var(--line);padding-top:12px}
.hidden{display:none!important}
#start{position:fixed;inset:0;right:var(--panel);display:flex;flex-direction:column;align-items:center;justify-content:center;background:#fff;z-index:5;text-align:center;padding:24px}
#start h2{font-size:clamp(36px,7vw,64px);font-weight:900;letter-spacing:-.01em;line-height:1}
#start p{font-size:12px;color:var(--mute);margin:14px 0 26px;max-width:360px;line-height:1.5}
#go{font:inherit;font-size:13px;font-weight:900;letter-spacing:.12em;padding:13px 26px;background:#000;color:#fff;border:none;cursor:pointer}
#go:disabled{opacity:.4;cursor:default}
#err{font-size:11px;color:#000;margin-top:16px;max-width:360px}
@media (max-width:760px){
:root{--panel:0px}
#panel{width:100%;top:auto;height:44%;border-left:none;border-top:1px solid var(--line)}
#stage{bottom:44%}
#start{bottom:44%}
}
</style>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js" } }
</script>
</head>
<body>
<div id="stage">
<video id="video" playsinline muted autoplay></video>
<canvas id="gl"></canvas>
<canvas id="ov"></canvas>
<div id="tags"></div>
</div>
<div id="start">
<h2>I:CAN:<br>READ:<br>YOUR:<br>FACE</h2>
<p>Was eine Maschine in ein Gesicht hineinliest. Alles läuft lokal im Browser, kein Bild verlässt dieses Gerät.</p>
<button id="go">KAMERA STARTEN</button>
<div id="err"></div>
</div>
<aside id="panel">
<h1>I:CAN:READ:YOUR:FACE</h1>
<div class="sub">MediaPipe Face Landmarker + Human (GEAR). Module einzeln zuschaltbar.</div>
<div class="status" id="fps">—</div>
<div class="sec">
<h2>Geometrie · MediaPipe</h2>
<div class="row on" data-mod="video">Kamerabild<span class="sw"></span></div>
<div class="row on" data-mod="mesh">3D-Mesh <small>478 Landmarks</small><span class="sw"></span></div>
<div class="seg" id="meshStyle">
<button data-v="points">PUNKTE</button><button data-v="wire" class="on">NETZ</button><button data-v="contour">KONTUR</button>
</div>
<div class="row" data-mod="pose">Kopfpose<span class="sw"></span></div>
<div class="row" data-mod="gaze">Iris & Blick<span class="sw"></span></div>
<div class="row" data-mod="blend">Blendshapes <small>52 Werte</small><span class="sw"></span></div>
</div>
<div class="sec">
<h2>Emotion · aus Blendshapes</h2>
<div class="row on" data-mod="emotion">Emotion <small>transparent</small><span class="sw"></span></div>
</div>
<div class="sec">
<h2>Klassifikation · Human / GEAR</h2>
<div class="row" data-mod="age">Alter<span class="sw"></span></div>
<div class="row" data-mod="gender">Geschlecht<span class="sw"></span></div>
<div class="row" data-mod="race">Ethnie<span class="sw"></span></div>
<div class="status" id="hstat">Modell lädt erst beim Einschalten.</div>
</div>
<div class="sec hidden" id="emoSec">
<h2>Emotion · Rechenweg</h2>
<div class="bars" id="emoBars"></div>
<div class="formula" id="emoFormula"></div>
</div>
<div class="sec hidden" id="gearSec">
<h2>GEAR · Rohwerte</h2>
<div class="bars" id="gearBars"></div>
</div>
<div class="sec hidden" id="blendSec">
<h2>Blendshapes</h2>
<div class="bars" id="blendBars"></div>
</div>
<div class="note">Alle Labels sind statistische Vermutungen. Die Kategorien von GEAR („white", „black", „asian", „indian", „other") und die binäre Geschlechtsausgabe stammen aus dem Trainingsdatensatz des Modells, nicht aus der Person vor der Kamera.</div>
</aside>
<script type="module">
import * as THREE from 'three';
import { FaceLandmarker, FilesetResolver } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.1.1/vision_bundle.mjs';
const HUMAN_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/human@3.3.6/dist/human.esm.js';
const HUMAN_BASE = 'https://cdn.jsdelivr.net/npm/@vladmandic/human@3.3.6/models/';
// GEAR liegt nicht auf jsDelivr (Paket zu groß → 403). Reihenfolge: lokal, dann GitHub raw.
const GEAR_URLS = [
'./models/gear.json',
'https://raw.githubusercontent.com/vladmandic/human-models/main/models/gear.json',
];
async function findGear(){
for (const u of GEAR_URLS){
try { const r = await fetch(u, { cache: 'no-store' }); if (r.ok){ await r.json(); return u; } } catch {}
}
throw new Error('gear.json nicht gefunden (./models/ oder GitHub)');
}
const MP_WASM = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.1.1/wasm';
const MP_MODEL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task';
const MAX_FACES = 2;
/* ---------- state ---------- */
const mods = { video:true, mesh:true, pose:false, gaze:false, blend:false, emotion:true, age:false, gender:false, race:false };
let meshStyle = 'wire';
const $ = s => document.querySelector(s);
const video = $('#video'), stage = $('#stage'), glCanvas = $('#gl'), ov = $('#ov'), ctx = ov.getContext('2d'), tagsEl = $('#tags');
/* ---------- panel ---------- */
document.querySelectorAll('.row[data-mod]').forEach(r => {
r.addEventListener('click', () => {
const k = r.dataset.mod; mods[k] = !mods[k]; r.classList.toggle('on', mods[k]);
onModsChanged();
});
});
document.querySelectorAll('#meshStyle button').forEach(b => b.addEventListener('click', () => {
meshStyle = b.dataset.v;
document.querySelectorAll('#meshStyle button').forEach(x => x.classList.toggle('on', x === b));
}));
function onModsChanged(){
video.classList.toggle('off', !mods.video);
$('#meshStyle').classList.toggle('hidden', !mods.mesh);
$('#emoSec').classList.toggle('hidden', !mods.emotion);
$('#blendSec').classList.toggle('hidden', !mods.blend);
const gearOn = mods.age || mods.gender || mods.race;
$('#gearSec').classList.toggle('hidden', !gearOn);
if (gearOn) ensureHuman();
}
onModsChanged();
/* ---------- three.js ---------- */
const renderer = new THREE.WebGLRenderer({ canvas: glCanvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scene = new THREE.Scene();
let cam = new THREE.OrthographicCamera(0, 1, 0, 1, -5000, 5000);
const matPts = new THREE.PointsMaterial({ color: 0x000000, size: 2.2, sizeAttenuation: false, transparent: true, opacity: .85 });
const matWire = new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: .35 });
const matCont = new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: .9 });
const pairsToIndex = conns => { const a = []; conns.forEach(c => a.push(c.start, c.end)); return a; };
const faceObjs = [];
function buildFaceObjs(){
const tess = pairsToIndex(FaceLandmarker.FACE_LANDMARKS_TESSELATION);
const cont = pairsToIndex([...FaceLandmarker.FACE_LANDMARKS_CONTOURS,
...FaceLandmarker.FACE_LANDMARKS_LEFT_IRIS, ...FaceLandmarker.FACE_LANDMARKS_RIGHT_IRIS]);
for (let i = 0; i < MAX_FACES; i++){
const pos = new THREE.BufferAttribute(new Float32Array(478 * 3), 3);
pos.setUsage(THREE.DynamicDrawUsage);
const gP = new THREE.BufferGeometry(); gP.setAttribute('position', pos);
const gW = new THREE.BufferGeometry(); gW.setAttribute('position', pos); gW.setIndex(tess);
const gC = new THREE.BufferGeometry(); gC.setAttribute('position', pos); gC.setIndex(cont);
const o = { pos, points: new THREE.Points(gP, matPts), wire: new THREE.LineSegments(gW, matWire), contour: new THREE.LineSegments(gC, matCont) };
[o.points, o.wire, o.contour].forEach(m => { m.frustumCulled = false; m.visible = false; scene.add(m); });
faceObjs.push(o);
}
}
/* ---------- layout ---------- */
let rect = { x:0, y:0, w:1, h:1 }, SW = 1, SH = 1;
function layout(){
SW = stage.clientWidth; SH = stage.clientHeight;
const vw = video.videoWidth || 16, vh = video.videoHeight || 9;
const s = Math.min(SW / vw, SH / vh);
rect = { w: vw * s, h: vh * s }; rect.x = (SW - rect.w) / 2; rect.y = (SH - rect.h) / 2;
Object.assign(video.style, { left: rect.x + 'px', top: rect.y + 'px', width: rect.w + 'px', height: rect.h + 'px' });
renderer.setSize(SW, SH);
cam = new THREE.OrthographicCamera(0, SW, 0, SH, -5000, 5000);
ov.width = SW * devicePixelRatio; ov.height = SH * devicePixelRatio;
ov.style.width = SW + 'px'; ov.style.height = SH + 'px';
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}
addEventListener('resize', layout);
const sx = lm => rect.x + (1 - lm.x) * rect.w; // mirrored
const sy = lm => rect.y + lm.y * rect.h;
/* ---------- emotion (transparent heuristic) ---------- */
const EMO = [
{ k:'happy', terms:[['mouthSmile',.7],['cheekSquint',.3]], gain:1.3 },
{ k:'sad', terms:[['mouthFrown',.5],['browInnerUp',.3],['mouthShrugLower',.2]], gain:1.8 },
{ k:'angry', terms:[['browDown',.6],['noseSneer',.2],['mouthPress',.2]], gain:1.8 },
{ k:'surprised', terms:[['browOuterUp',.35],['eyeWide',.35],['jawOpen',.3]], gain:1.5 },
{ k:'disgusted', terms:[['noseSneer',.6],['mouthUpperUp',.4]], gain:2.2 },
{ k:'fearful', terms:[['eyeWide',.4],['browInnerUp',.3],['mouthStretch',.3]], gain:1.8 },
];
const LR = new Set(['mouthSmile','cheekSquint','mouthFrown','browDown','noseSneer','mouthPress','browOuterUp','eyeWide','mouthUpperUp','mouthStretch']);
function bsVal(bs, name){
if (LR.has(name)) return ((bs[name + 'Left'] || 0) + (bs[name + 'Right'] || 0)) / 2;
return bs[name] || 0;
}
function readEmotion(bs){
const out = EMO.map(e => {
const parts = e.terms.map(([n, w]) => ({ n, w, v: bsVal(bs, n) }));
const raw = parts.reduce((a, p) => a + p.v * p.w, 0);
return { k: e.k, s: Math.min(1, raw * e.gain), parts, gain: e.gain };
});
const top = out.reduce((a, b) => b.s > a.s ? b : a);
const neutral = Math.max(0, 1 - top.s * 1.6);
out.push({ k:'neutral', s: neutral, parts: [], gain: 1 });
const winner = top.s < .28 ? out[out.length - 1] : top;
return { all: out, winner };
}
/* ---------- head pose ---------- */
const _m = new THREE.Matrix4(), _e = new THREE.Euler();
function headPose(mat){
_m.fromArray(mat.data); _e.setFromRotationMatrix(_m, 'YXZ');
const d = r => Math.round(THREE.MathUtils.radToDeg(r));
return { yaw: -d(_e.y), pitch: d(_e.x), roll: -d(_e.z) };
}
function gazeLabel(bs){
const h = (bsVal(bs,'eyeLookOutLeft') + bsVal(bs,'eyeLookInRight')) - (bsVal(bs,'eyeLookInLeft') + bsVal(bs,'eyeLookOutRight'));
const v = (bsVal(bs,'eyeLookUpLeft') + bsVal(bs,'eyeLookUpRight')) - (bsVal(bs,'eyeLookDownLeft') + bsVal(bs,'eyeLookDownRight'));
const H = h > .35 ? 'LINKS' : h < -.35 ? 'RECHTS' : '';
const V = v > .25 ? 'OBEN' : v < -.35 ? 'UNTEN' : '';
return [V, H].filter(Boolean).join(' ') || 'GERADEAUS';
}
/* ---------- tags ---------- */
const tagPools = Array.from({ length: MAX_FACES }, () => ({}));
const smooth = Array.from({ length: MAX_FACES }, () => ({}));
function getTag(fi, id){
let t = tagPools[fi][id];
if (!t){
t = document.createElement('div'); t.className = 'tag';
t.innerHTML = '<span class="k"></span><span class="v"></span>';
tagsEl.appendChild(t); tagPools[fi][id] = t;
t._k = t.firstChild; t._v = t.lastChild; t._x = null; t._y = null;
}
return t;
}
function hideTagsExcept(fi, used){
for (const id in tagPools[fi]) if (!used.has(id)) tagPools[fi][id].style.display = 'none';
}
/* ---------- human / GEAR ---------- */
let human = null, humanLoading = false, humanFaces = [], humanMs = 0;
async function ensureHuman(){
if (human || humanLoading) return;
humanLoading = true; $('#hstat').textContent = 'Lade Human + GEAR …';
try{
const gearUrl = await findGear();
const mod = await import(HUMAN_URL);
const Human = mod.Human || mod.default;
human = new Human({
backend: 'webgl', modelBasePath: HUMAN_BASE, warmup: 'none', cacheSensitivity: 0,
filter: { enabled: false },
face: {
enabled: true,
detector: { rotation: false, maxDetected: MAX_FACES, minConfidence: .3, skipFrames: 0, skipTime: 0 },
mesh: { enabled: true }, iris: { enabled: false }, emotion: { enabled: false },
description: { enabled: false }, antispoof: { enabled: false }, liveness: { enabled: false },
gear: { enabled: true, modelPath: gearUrl, skipFrames: 0, skipTime: 0, minConfidence: .01 },
},
body: { enabled: false }, hand: { enabled: false }, object: { enabled: false },
gesture: { enabled: false }, segmentation: { enabled: false },
});
await human.load();
if (!human.models?.models?.gear && !Object.keys(human.models || {}).length) console.warn('Human models', human.models);
$('#hstat').textContent = 'Bereit · ' + (gearUrl.startsWith('./') ? 'GEAR lokal' : 'GEAR von GitHub');
humanLoop();
}catch(e){
console.error(e); $('#hstat').textContent = 'Fehler beim Laden: ' + e.message; human = null;
}
humanLoading = false;
}
async function humanLoop(){
if (human && (mods.age || mods.gender || mods.race) && video.readyState >= 2){
const t0 = performance.now();
try{
const res = await human.detect(video);
humanMs = performance.now() - t0;
humanFaces = (res.face || []).map(f => ({
cx: (f.box[0] + f.box[2] / 2) / video.videoWidth,
cy: (f.box[1] + f.box[3] / 2) / video.videoHeight,
age: f.age, gender: f.gender, genderScore: f.genderScore, race: f.race || []
}));
$('#hstat').textContent = `GEAR läuft · ${humanMs.toFixed(0)} ms / Analyse · ${(1000 / Math.max(humanMs + 60, 1)).toFixed(1)} Hz`;
}catch(e){ console.warn(e); }
} else if (!(mods.age || mods.gender || mods.race)) humanFaces = [];
setTimeout(humanLoop, 60);
}
function matchHuman(center){
let best = null, bd = .15;
for (const h of humanFaces){
const d = Math.hypot(h.cx - center.x, h.cy - center.y);
if (d < bd){ bd = d; best = h; }
}
return best;
}
/* ---------- panel bars ---------- */
function bars(el, rows, hiKey){
if (el._n !== rows.length){
el.innerHTML = rows.map(() => '<span class="n"></span><span class="b"><i></i></span><span class="p"></span>').join('');
el._n = rows.length; el._c = el.children;
}
rows.forEach((r, i) => {
const n = el._c[i * 3], b = el._c[i * 3 + 1].firstChild, p = el._c[i * 3 + 2];
if (n.textContent !== r.label) n.textContent = r.label;
n.classList.toggle('hi', r.label === hiKey);
b.style.width = (Math.max(0, Math.min(1, r.v)) * 100).toFixed(1) + '%';
p.textContent = r.txt ?? (r.v * 100).toFixed(0);
});
}
/* ---------- main loop ---------- */
let landmarker, lastVT = -1, frames = 0, fpsT = performance.now(), fps = 0, panelTick = 0;
function frame(){
requestAnimationFrame(frame);
if (video.readyState < 2) return;
if (video.currentTime === lastVT) return;
lastVT = video.currentTime;
const res = landmarker.detectForVideo(video, performance.now());
frames++;
const now = performance.now();
if (now - fpsT > 500){ fps = frames * 1000 / (now - fpsT); frames = 0; fpsT = now;
$('#fps').textContent = `MEDIAPIPE ${fps.toFixed(0)} FPS · ${res.faceLandmarks.length} GESICHT${res.faceLandmarks.length === 1 ? '' : 'ER'}`; }
ctx.clearRect(0, 0, SW, SH);
const faces = res.faceLandmarks;
const doPanel = (panelTick++ % 4) === 0;
for (let fi = 0; fi < MAX_FACES; fi++){
const o = faceObjs[fi], lms = faces[fi];
const used = new Set();
if (!lms){
o.points.visible = o.wire.visible = o.contour.visible = false;
hideTagsExcept(fi, used); continue;
}
/* mesh */
const a = o.pos.array;
for (let i = 0; i < lms.length; i++){ a[i*3] = sx(lms[i]); a[i*3+1] = sy(lms[i]); a[i*3+2] = -lms[i].z * rect.w; }
o.pos.needsUpdate = true;
o.points.visible = mods.mesh && meshStyle === 'points';
o.wire.visible = mods.mesh && meshStyle === 'wire';
o.contour.visible = mods.mesh && meshStyle === 'contour';
/* face bbox on screen */
let minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9, cxn = 0, cyn = 0;
for (const p of lms){ const x = sx(p), y = sy(p); if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; cxn += p.x; cyn += p.y; }
cxn /= lms.length; cyn /= lms.length;
const placeRight = maxX + 200 < SW;
const colX = placeRight ? maxX + 34 : minX - 34;
const bsArr = res.faceBlendshapes?.[fi]?.categories || [];
const bs = {}; bsArr.forEach(c => bs[c.categoryName] = c.score);
const tags = []; // {id, key, val, anchor}
if (mods.emotion && bsArr.length){
const em = readEmotion(bs);
tags.push({ id:'emo', key:'Emotion · Blendshapes', val: `${em.winner.k} ${(em.winner.s*100).toFixed(0)}%`, anchor: 13 });
if (fi === 0 && doPanel){
bars($('#emoBars'), em.all.map(e => ({ label: e.k, v: e.s })), em.winner.k);
const w = EMO.find(e => e.k === em.winner.k);
$('#emoFormula').innerHTML = w
? `<b>${w.k}</b> = ${w.gain} × (` + em.winner.parts.map(p => `${p.w}·${p.n}<b>[${p.v.toFixed(2)}]</b>`).join(' + ') + ')'
: `<b>neutral</b> = 1 − 1.6 × stärkste Emotion<br>(alle Werte unter Schwelle 0.28)`;
}
}
if (mods.pose && res.facialTransformationMatrixes?.[fi]){
const p = headPose(res.facialTransformationMatrixes[fi]);
tags.push({ id:'pose', key:'Kopfpose · Yaw / Pitch / Roll', val: `${p.yaw}° ${p.pitch}° ${p.roll}°`, anchor: 1 });
}
if (mods.gaze && bsArr.length){
tags.push({ id:'gaze', key:'Blick', val: gazeLabel(bs), anchor: 468 });
ctx.strokeStyle = '#000'; ctx.lineWidth = 1.5;
for (const c of [468, 473]){
if (!lms[c]) continue;
const r = Math.hypot(sx(lms[c+1]) - sx(lms[c]), sy(lms[c+1]) - sy(lms[c]));
ctx.beginPath(); ctx.arc(sx(lms[c]), sy(lms[c]), r, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(sx(lms[c]), sy(lms[c]), 1.5, 0, Math.PI * 2); ctx.fillStyle = '#000'; ctx.fill();
}
}
const hm = (mods.age || mods.gender || mods.race) ? matchHuman({ x: cxn, y: cyn }) : null;
if (hm){
const s = smooth[fi];
if (mods.age && hm.age){
s.age = s.age == null ? hm.age : s.age * .85 + hm.age * .15;
tags.push({ id:'age', key:'Alter · GEAR', val: `~${Math.round(s.age)} J.`, anchor: 10 });
}
if (mods.gender && hm.gender){
tags.push({ id:'gender', key:'Geschlecht · GEAR', val: `${hm.gender} ${Math.round((hm.genderScore||0)*100)}%`, anchor: 168 });
}
if (mods.race && hm.race.length){
const r = hm.race[0];
tags.push({ id:'race', key:'Ethnie · GEAR', val: `„${r.race}" ${Math.round(r.score*100)}%`, anchor: 50 });
}
if (fi === 0 && doPanel){
const rows = [];
if (mods.age && hm.age) rows.push({ label:'age', v: Math.min(1, hm.age / 90), txt: hm.age.toFixed(0) });
if (mods.gender && hm.gender) rows.push({ label: hm.gender, v: hm.genderScore || 0 });
if (mods.race) ['white','black','asian','indian','other'].forEach(k => {
const f = hm.race.find(x => x.race === k); rows.push({ label: k, v: f ? f.score : 0 });
});
bars($('#gearBars'), rows, mods.race && hm.race[0] ? hm.race[0].race : '');
}
} else if ((mods.age || mods.gender || mods.race) && fi === 0){
tags.push({ id:'wait', key:'GEAR', val: human ? 'SUCHT…' : 'LÄDT…', anchor: 10 });
}
if (mods.blend && fi === 0 && doPanel && bsArr.length){
bars($('#blendBars'), bsArr.filter(c => c.categoryName !== '_neutral').map(c => ({ label: c.categoryName, v: c.score })), '');
}
/* place tags: column beside face, leader line to evidence landmark */
const gap = 50, totalH = tags.length * gap;
let y0 = (minY + maxY) / 2 - totalH / 2 + 8;
tags.forEach((t, i) => {
used.add(t.id);
const el = getTag(fi, t.id);
el.style.display = 'block';
if (el._k.textContent !== t.key) el._k.textContent = t.key;
if (el._v.textContent !== t.val) el._v.textContent = t.val;
const w = el.offsetWidth, h = el.offsetHeight;
const tx = placeRight ? colX : colX - w, ty = y0 + i * gap;
el._x = el._x == null ? tx : el._x + (tx - el._x) * .25;
el._y = el._y == null ? ty : el._y + (ty - el._y) * .25;
el.style.transform = `translate(${el._x.toFixed(1)}px,${el._y.toFixed(1)}px)`;
const an = lms[t.anchor] || lms[1];
const ax = sx(an), ay = sy(an);
const ex = placeRight ? el._x : el._x + w, ey = el._y + h / 2;
ctx.strokeStyle = '#000'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(ex, ey); ctx.stroke();
ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(ax, ay, 2.5, 0, Math.PI * 2); ctx.fill();
});
hideTagsExcept(fi, used);
}
renderer.render(scene, cam);
}
/* ---------- start ---------- */
$('#go').addEventListener('click', async () => {
const btn = $('#go'); btn.disabled = true; btn.textContent = 'LÄDT …'; $('#err').textContent = '';
try{
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, audio: false });
video.srcObject = stream;
await new Promise(r => video.readyState >= 1 ? r() : video.onloadedmetadata = r);
await video.play();
const fileset = await FilesetResolver.forVisionTasks(MP_WASM);
const opts = d => ({
baseOptions: { modelAssetPath: MP_MODEL, delegate: d }, runningMode: 'VIDEO', numFaces: MAX_FACES,
outputFaceBlendshapes: true, outputFacialTransformationMatrixes: true,
});
try { landmarker = await FaceLandmarker.createFromOptions(fileset, opts('GPU')); }
catch { landmarker = await FaceLandmarker.createFromOptions(fileset, opts('CPU')); }
buildFaceObjs(); layout(); onModsChanged();
$('#start').style.display = 'none';
requestAnimationFrame(frame);
}catch(e){
console.error(e); btn.disabled = false; btn.textContent = 'KAMERA STARTEN';
$('#err').textContent = 'Kamera oder Modell nicht verfügbar: ' + e.message;
}
});
</script>
</body>
</html>