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