|
|
<!doctype html>
|
|
|
<html lang="en">
|
|
|
<head>
|
|
|
<meta charset="utf-8" />
|
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
|
<title>MediaPipe Hands + FaceMesh (Bigger + Better)</title>
|
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils@0.3.1675466862/camera_utils.js" crossorigin="anonymous"></script>
|
|
|
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands@0.4.1675469240/hands.js" crossorigin="anonymous"></script>
|
|
|
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4.1633559619/face_mesh.js" crossorigin="anonymous"></script>
|
|
|
|
|
|
<style>
|
|
|
:root { color-scheme: dark; }
|
|
|
html, body { margin: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
|
|
|
#video { position: absolute; left: 0; top: 0; width: 2px; height: 2px; opacity: 0; pointer-events: none; z-index: -1; }
|
|
|
#canvas { position: fixed; inset: 0; width: 100vw; height: 100vh; display: block; background: #000; }
|
|
|
|
|
|
#hud{
|
|
|
position:fixed; left:14px; top:14px; z-index:10; display:none;
|
|
|
padding:10px 12px; border-radius:10px;
|
|
|
background:rgba(18,18,18,0.72); border:1px solid rgba(255,255,255,0.10);
|
|
|
backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px);
|
|
|
box-shadow:0 10px 24px rgba(0,0,0,0.45);
|
|
|
font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
|
line-height:1.15; user-select:none; min-width: 190px;
|
|
|
}
|
|
|
.row{ display:flex; justify-content:space-between; gap:10px; }
|
|
|
.k{ color:rgba(255,255,255,0.65); font-size:11px; letter-spacing:0.12em; text-transform:uppercase; }
|
|
|
.v{ font-size:18px; font-weight:800; }
|
|
|
|
|
|
#start{
|
|
|
position:fixed; inset:0; margin:auto; z-index:20;
|
|
|
width:min(380px, calc(100vw - 36px)); height:56px;
|
|
|
border:0; border-radius:999px; cursor:pointer;
|
|
|
font-weight:800; letter-spacing:0.08em; text-transform:uppercase;
|
|
|
color:#001114;
|
|
|
background:linear-gradient(135deg, #00ffff 0%, #00d4ff 45%, #00ff9a 100%);
|
|
|
box-shadow:0 0 0 1px rgba(0,255,255,0.18), 0 18px 44px rgba(0,255,255,0.18);
|
|
|
}
|
|
|
</style>
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
<video id="video" playsinline muted></video>
|
|
|
<canvas id="canvas"></canvas>
|
|
|
|
|
|
<div id="hud">
|
|
|
<div class="row"><div class="k">Render</div><div id="fpsR" class="v">0</div></div>
|
|
|
<div class="row"><div class="k">Hands</div><div id="fpsH" class="v">0</div></div>
|
|
|
<div class="row"><div class="k">Face</div><div id="fpsF" class="v">0</div></div>
|
|
|
<div class="row"><div class="k">Face</div><div id="faceOn" class="v" style="font-size:14px;">OFF</div></div>
|
|
|
</div>
|
|
|
|
|
|
<button id="start">Start</button>
|
|
|
|
|
|
<script>
|
|
|
const HANDS_BASE = "https://cdn.jsdelivr.net/npm/@mediapipe/hands@0.4.1675469240/";
|
|
|
const FACE_BASE = "https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4.1633559619/";
|
|
|
|
|
|
|
|
|
const CAM_W = 320, CAM_H = 240;
|
|
|
|
|
|
|
|
|
|
|
|
const HAND_SCALE = 1.35;
|
|
|
const FACE_SCALE = 1.25;
|
|
|
|
|
|
|
|
|
const HAND_LINE_W = 3.5;
|
|
|
const HAND_POINT = 4;
|
|
|
const FACE_POINT = 1.4;
|
|
|
|
|
|
|
|
|
const HANDS_HZ = 24;
|
|
|
const FACE_HZ = 10;
|
|
|
|
|
|
|
|
|
const MAX_HANDS = 2;
|
|
|
const HANDS_COMPLEXITY = 0;
|
|
|
const FACE_REFINE = false;
|
|
|
|
|
|
const videoEl = document.getElementById('video');
|
|
|
const canvasEl = document.getElementById('canvas');
|
|
|
const ctx = canvasEl.getContext('2d', { alpha: false, desynchronized: true });
|
|
|
|
|
|
function resizeCanvas() {
|
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
|
|
canvasEl.width = Math.floor(window.innerWidth * dpr);
|
|
|
canvasEl.height = Math.floor(window.innerHeight * dpr);
|
|
|
canvasEl.style.width = window.innerWidth + 'px';
|
|
|
canvasEl.style.height = window.innerHeight + 'px';
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
clearBlack();
|
|
|
}
|
|
|
function clearBlack() {
|
|
|
ctx.fillStyle = '#000000';
|
|
|
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
|
|
|
}
|
|
|
window.addEventListener('resize', resizeCanvas, { passive: true });
|
|
|
resizeCanvas();
|
|
|
|
|
|
|
|
|
function getViewRect() {
|
|
|
const cw = window.innerWidth, ch = window.innerHeight;
|
|
|
const camAR = CAM_W / CAM_H;
|
|
|
const canvasAR = cw / ch;
|
|
|
let vw, vh, vx, vy;
|
|
|
if (canvasAR > camAR) { vh = ch; vw = vh * camAR; vx = (cw - vw) * 0.5; vy = 0; }
|
|
|
else { vw = cw; vh = vw / camAR; vx = 0; vy = (ch - vh) * 0.5; }
|
|
|
return { x: vx, y: vy, w: vw, h: vh };
|
|
|
}
|
|
|
function mapLM(lm, view) {
|
|
|
return { x: view.x + lm.x * view.w, y: view.y + lm.y * view.h };
|
|
|
}
|
|
|
|
|
|
|
|
|
function centroidPx(landmarks, view) {
|
|
|
let sx = 0, sy = 0;
|
|
|
const n = landmarks.length;
|
|
|
for (let i = 0; i < n; i++) {
|
|
|
const p = mapLM(landmarks[i], view);
|
|
|
sx += p.x; sy += p.y;
|
|
|
}
|
|
|
return { x: sx / n, y: sy / n };
|
|
|
}
|
|
|
|
|
|
function drawPointsScaled(landmarks, color, sizePx, view, scale) {
|
|
|
if (!landmarks || !landmarks.length) return;
|
|
|
const c = centroidPx(landmarks, view);
|
|
|
ctx.fillStyle = color;
|
|
|
ctx.beginPath();
|
|
|
for (let i = 0, n = landmarks.length; i < n; i++) {
|
|
|
const p = mapLM(landmarks[i], view);
|
|
|
const x = c.x + (p.x - c.x) * scale;
|
|
|
const y = c.y + (p.y - c.y) * scale;
|
|
|
ctx.rect(x, y, sizePx, sizePx);
|
|
|
}
|
|
|
ctx.fill();
|
|
|
}
|
|
|
|
|
|
function drawEdgesScaled(landmarks, edges, color, lineWidth, view, scale) {
|
|
|
if (!landmarks || !landmarks.length) return;
|
|
|
const c = centroidPx(landmarks, view);
|
|
|
ctx.strokeStyle = color;
|
|
|
ctx.lineWidth = lineWidth;
|
|
|
ctx.beginPath();
|
|
|
for (let i = 0; i < edges.length; i++) {
|
|
|
const a = edges[i][0], b = edges[i][1];
|
|
|
const p0 = mapLM(landmarks[a], view);
|
|
|
const p1 = mapLM(landmarks[b], view);
|
|
|
const x0 = c.x + (p0.x - c.x) * scale;
|
|
|
const y0 = c.y + (p0.y - c.y) * scale;
|
|
|
const x1 = c.x + (p1.x - c.x) * scale;
|
|
|
const y1 = c.y + (p1.y - c.y) * scale;
|
|
|
ctx.moveTo(x0, y0);
|
|
|
ctx.lineTo(x1, y1);
|
|
|
}
|
|
|
ctx.stroke();
|
|
|
}
|
|
|
|
|
|
|
|
|
const HAND_EDGES = [
|
|
|
[0,1],[1,2],[2,3],[3,4],
|
|
|
[0,5],[5,6],[6,7],[7,8],
|
|
|
[0,9],[9,10],[10,11],[11,12],
|
|
|
[0,13],[13,14],[14,15],[15,16],
|
|
|
[0,17],[17,18],[18,19],[19,20],
|
|
|
[5,9],[9,13],[13,17]
|
|
|
];
|
|
|
|
|
|
|
|
|
const EMA_HANDS = 0.65;
|
|
|
const EMA_FACE = 0.45;
|
|
|
|
|
|
function cloneLms(lms) {
|
|
|
if (!lms) return null;
|
|
|
const out = new Array(lms.length);
|
|
|
for (let i = 0; i < lms.length; i++) out[i] = { x: lms[i].x, y: lms[i].y, z: lms[i].z };
|
|
|
return out;
|
|
|
}
|
|
|
function emaUpdate(prev, next, alpha) {
|
|
|
if (!next) return null;
|
|
|
if (!prev || prev.length !== next.length) return cloneLms(next);
|
|
|
const out = new Array(next.length);
|
|
|
for (let i = 0; i < next.length; i++) {
|
|
|
const p = prev[i], n = next[i];
|
|
|
out[i] = { x: p.x + (n.x - p.x) * alpha, y: p.y + (n.y - p.y) * alpha, z: 0 };
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
function lerpFrame(a, b, t) {
|
|
|
if (!a) return null;
|
|
|
if (!b || a.length !== b.length) return a;
|
|
|
const out = new Array(a.length);
|
|
|
for (let i = 0; i < a.length; i++) {
|
|
|
out[i] = { x: a[i].x + (b[i].x - a[i].x) * t, y: a[i].y + (b[i].y - a[i].y) * t, z: 0 };
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
let prevLH=null, currLH=null, prevRH=null, currRH=null;
|
|
|
let prevFace=null, currFace=null;
|
|
|
|
|
|
let prevHandsTs=0, lastHandsTs=0;
|
|
|
let prevFaceTs=0, lastFaceTs=0;
|
|
|
|
|
|
|
|
|
const hudEl = document.getElementById('hud');
|
|
|
const fpsREl = document.getElementById('fpsR');
|
|
|
const fpsHEl = document.getElementById('fpsH');
|
|
|
const fpsFEl = document.getElementById('fpsF');
|
|
|
const faceOnEl = document.getElementById('faceOn');
|
|
|
|
|
|
let rFrames = 0, rLastT = performance.now();
|
|
|
function tickRenderFps() {
|
|
|
rFrames++;
|
|
|
const now = performance.now();
|
|
|
const dt = now - rLastT;
|
|
|
if (dt >= 500) {
|
|
|
fpsREl.textContent = String(Math.round((rFrames * 1000) / dt));
|
|
|
rLastT = now; rFrames = 0;
|
|
|
}
|
|
|
}
|
|
|
let hFrames = 0, hLastT = performance.now();
|
|
|
function tickHandsFps() {
|
|
|
hFrames++;
|
|
|
const now = performance.now();
|
|
|
const dt = now - hLastT;
|
|
|
if (dt >= 900) {
|
|
|
fpsHEl.textContent = String(Math.round((hFrames * 1000) / dt));
|
|
|
hLastT = now; hFrames = 0;
|
|
|
}
|
|
|
}
|
|
|
let fFrames = 0, fLastT = performance.now();
|
|
|
function tickFaceFps() {
|
|
|
fFrames++;
|
|
|
const now = performance.now();
|
|
|
const dt = now - fLastT;
|
|
|
if (dt >= 1100) {
|
|
|
fpsFEl.textContent = String(Math.round((fFrames * 1000) / dt));
|
|
|
fLastT = now; fFrames = 0;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function renderLoop() {
|
|
|
clearBlack();
|
|
|
const view = getViewRect();
|
|
|
const now = performance.now();
|
|
|
|
|
|
const hdt = Math.max(1, lastHandsTs - prevHandsTs);
|
|
|
const ht = Math.min(1, Math.max(0, (now - lastHandsTs) / hdt));
|
|
|
const drawLH = lerpFrame(prevLH, currLH, ht);
|
|
|
const drawRH = lerpFrame(prevRH, currRH, ht);
|
|
|
|
|
|
const fdt = Math.max(1, lastFaceTs - prevFaceTs);
|
|
|
const ft = Math.min(1, Math.max(0, (now - lastFaceTs) / fdt));
|
|
|
const drawFace = lerpFrame(prevFace, currFace, ft);
|
|
|
|
|
|
if (drawLH) {
|
|
|
drawEdgesScaled(drawLH, HAND_EDGES, '#00ff00', HAND_LINE_W, view, HAND_SCALE);
|
|
|
drawPointsScaled(drawLH, '#00ff00', HAND_POINT, view, HAND_SCALE);
|
|
|
}
|
|
|
if (drawRH) {
|
|
|
drawEdgesScaled(drawRH, HAND_EDGES, '#ffff00', HAND_LINE_W, view, HAND_SCALE);
|
|
|
drawPointsScaled(drawRH, '#ffff00', HAND_POINT, view, HAND_SCALE);
|
|
|
}
|
|
|
|
|
|
if (drawFace) {
|
|
|
drawPointsScaled(drawFace, '#ff00ff', FACE_POINT, view, FACE_SCALE);
|
|
|
}
|
|
|
|
|
|
tickRenderFps();
|
|
|
requestAnimationFrame(renderLoop);
|
|
|
}
|
|
|
|
|
|
|
|
|
const hands = new Hands({ locateFile: (f) => HANDS_BASE + f });
|
|
|
hands.setOptions({
|
|
|
maxNumHands: MAX_HANDS,
|
|
|
modelComplexity: HANDS_COMPLEXITY,
|
|
|
minDetectionConfidence: 0.5,
|
|
|
minTrackingConfidence: 0.5,
|
|
|
});
|
|
|
hands.onResults((res) => {
|
|
|
prevHandsTs = lastHandsTs;
|
|
|
lastHandsTs = performance.now();
|
|
|
tickHandsFps();
|
|
|
|
|
|
let left = null, right = null;
|
|
|
const lms = res.multiHandLandmarks || [];
|
|
|
const hd = res.multiHandedness || [];
|
|
|
for (let i = 0; i < lms.length; i++) {
|
|
|
const label = hd[i]?.label;
|
|
|
if (label === 'Left') left = lms[i];
|
|
|
else if (label === 'Right') right = lms[i];
|
|
|
}
|
|
|
|
|
|
prevLH = currLH; currLH = emaUpdate(currLH, left, EMA_HANDS);
|
|
|
prevRH = currRH; currRH = emaUpdate(currRH, right, EMA_HANDS);
|
|
|
});
|
|
|
|
|
|
const faceMesh = new FaceMesh({ locateFile: (f) => FACE_BASE + f });
|
|
|
faceMesh.setOptions({
|
|
|
maxNumFaces: 1,
|
|
|
refineLandmarks: FACE_REFINE,
|
|
|
minDetectionConfidence: 0.5,
|
|
|
minTrackingConfidence: 0.5,
|
|
|
});
|
|
|
faceMesh.onResults((res) => {
|
|
|
prevFaceTs = lastFaceTs;
|
|
|
lastFaceTs = performance.now();
|
|
|
tickFaceFps();
|
|
|
|
|
|
const face = res.multiFaceLandmarks?.[0] || null;
|
|
|
prevFace = currFace;
|
|
|
currFace = emaUpdate(currFace, face, EMA_FACE);
|
|
|
|
|
|
faceOnEl.textContent = face ? "ON" : "OFF";
|
|
|
faceOnEl.style.color = face ? "#00ff80" : "#ff3b3b";
|
|
|
});
|
|
|
|
|
|
|
|
|
let busy = false;
|
|
|
let lastHandsRun = 0, lastFaceRun = 0;
|
|
|
let preferFaceNext = true;
|
|
|
|
|
|
function due(now, lastT, hz) { return (now - lastT) >= (1000 / hz); }
|
|
|
|
|
|
async function runScheduled(now) {
|
|
|
if (busy) return;
|
|
|
|
|
|
const handsDue = due(now, lastHandsRun, HANDS_HZ);
|
|
|
const faceDue = due(now, lastFaceRun, FACE_HZ);
|
|
|
|
|
|
let run = null;
|
|
|
if (handsDue && !faceDue) run = 'hands';
|
|
|
else if (!handsDue && faceDue) run = 'face';
|
|
|
else if (handsDue && faceDue) run = (preferFaceNext ? 'face' : 'hands');
|
|
|
else return;
|
|
|
|
|
|
busy = true;
|
|
|
try {
|
|
|
if (run === 'hands') {
|
|
|
lastHandsRun = now;
|
|
|
preferFaceNext = true;
|
|
|
await hands.send({ image: videoEl });
|
|
|
} else {
|
|
|
lastFaceRun = now;
|
|
|
preferFaceNext = false;
|
|
|
await faceMesh.send({ image: videoEl });
|
|
|
}
|
|
|
} finally {
|
|
|
busy = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const camera = new Camera(videoEl, {
|
|
|
width: CAM_W,
|
|
|
height: CAM_H,
|
|
|
onFrame: async () => { await runScheduled(performance.now()); }
|
|
|
});
|
|
|
|
|
|
document.getElementById('start').addEventListener('click', async () => {
|
|
|
document.getElementById('start').style.display = 'none';
|
|
|
hudEl.style.display = 'block';
|
|
|
clearBlack();
|
|
|
|
|
|
const t = performance.now();
|
|
|
prevHandsTs = lastHandsTs = t;
|
|
|
prevFaceTs = lastFaceTs = t;
|
|
|
|
|
|
lastHandsRun = t - 1000;
|
|
|
lastFaceRun = t - 1000;
|
|
|
|
|
|
await camera.start();
|
|
|
requestAnimationFrame(renderLoop);
|
|
|
});
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|
|
|
|