Created
August 31, 2025 22:47
-
-
Save willwade/e577345834482012df8d766108ffa0a3 to your computer and use it in GitHub Desktop.
full mediapipe face mesh test in js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>MediaPipe FaceLandmarker Test</title> | |
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/vision_bundle.js" crossorigin="anonymous"></script> | |
<script> | |
// Wait for MediaPipe to load and expose globals | |
window.addEventListener('load', () => { | |
console.log('Available MediaPipe objects:', Object.keys(window).filter(k => k.toLowerCase().includes('mediapipe') || k.toLowerCase().includes('face') || k.toLowerCase().includes('fileset'))); | |
}); | |
</script> | |
<style> | |
body { | |
font-family: Arial, sans-serif; | |
max-width: 1200px; | |
margin: 0 auto; | |
padding: 20px; | |
} | |
.container { | |
display: flex; | |
gap: 20px; | |
} | |
.video-section { | |
flex: 1; | |
} | |
.debug-section { | |
flex: 1; | |
background: #f5f5f5; | |
padding: 15px; | |
border-radius: 8px; | |
max-height: 600px; | |
overflow-y: auto; | |
} | |
video { | |
width: 100%; | |
max-width: 480px; | |
border: 2px solid #ddd; | |
border-radius: 8px; | |
} | |
.debug-output { | |
font-family: monospace; | |
font-size: 12px; | |
white-space: pre-wrap; | |
background: white; | |
padding: 10px; | |
border-radius: 4px; | |
margin-top: 10px; | |
max-height: 400px; | |
overflow-y: auto; | |
} | |
.controls { | |
margin: 15px 0; | |
} | |
button { | |
padding: 10px 15px; | |
margin: 5px; | |
border: none; | |
border-radius: 4px; | |
background: #007bff; | |
color: white; | |
cursor: pointer; | |
} | |
button:hover { | |
background: #0056b3; | |
} | |
button:disabled { | |
background: #ccc; | |
cursor: not-allowed; | |
} | |
.status { | |
padding: 10px; | |
margin: 10px 0; | |
border-radius: 4px; | |
font-weight: bold; | |
} | |
.status.success { background: #d4edda; color: #155724; } | |
.status.error { background: #f8d7da; color: #721c24; } | |
.status.info { background: #d1ecf1; color: #0c5460; } | |
</style> | |
</head> | |
<body> | |
<h1>MediaPipe FaceLandmarker API Test</h1> | |
<div class="container"> | |
<div class="video-section"> | |
<video id="video" autoplay muted playsinline></video> | |
<div class="controls"> | |
<button id="startBtn">Start Camera & FaceLandmarker</button> | |
<button id="stopBtn" disabled>Stop</button> | |
<button id="clearBtn">Clear Debug Output</button> | |
</div> | |
<div id="status" class="status info">Ready to start</div> | |
</div> | |
<div class="debug-section"> | |
<h3>Debug Output</h3> | |
<div>This will show exactly what MediaPipe returns:</div> | |
<div id="debugOutput" class="debug-output">Waiting for data...</div> | |
</div> | |
</div> | |
<script> | |
let faceLandmarker = null; | |
let video = null; | |
let stream = null; | |
let animationId = null; | |
let frameCount = 0; | |
let debugLogged = { | |
fullResult: false, | |
blendshapes: false, | |
matrix: false | |
}; | |
const statusEl = document.getElementById('status'); | |
const debugEl = document.getElementById('debugOutput'); | |
const startBtn = document.getElementById('startBtn'); | |
const stopBtn = document.getElementById('stopBtn'); | |
const clearBtn = document.getElementById('clearBtn'); | |
function updateStatus(message, type = 'info') { | |
statusEl.textContent = message; | |
statusEl.className = `status ${type}`; | |
} | |
function addDebugOutput(message) { | |
const timestamp = new Date().toLocaleTimeString(); | |
debugEl.textContent += `[${timestamp}] ${message}\n`; | |
debugEl.scrollTop = debugEl.scrollHeight; | |
} | |
async function initializeFaceLandmarker() { | |
try { | |
updateStatus('Initializing MediaPipe...', 'info'); | |
// Wait a bit for MediaPipe to fully load | |
await new Promise(resolve => setTimeout(resolve, 1000)); | |
// Check what's available in the global scope | |
const availableKeys = Object.keys(window).filter(k => | |
k.toLowerCase().includes('mediapipe') || | |
k.toLowerCase().includes('face') || | |
k.toLowerCase().includes('fileset') || | |
k.includes('FilesetResolver') || | |
k.includes('FaceLandmarker') | |
); | |
addDebugOutput(`Available globals: ${availableKeys.join(', ')}`); | |
addDebugOutput(`All window keys containing 'F': ${Object.keys(window).filter(k => k.includes('F')).slice(0, 10).join(', ')}`); | |
// Try to find the correct classes | |
const { FilesetResolver, FaceLandmarker } = await import('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/vision_bundle.js'); | |
const vision = await FilesetResolver.forVisionTasks( | |
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm" | |
); | |
faceLandmarker = await FaceLandmarker.createFromOptions(vision, { | |
baseOptions: { | |
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task", | |
delegate: "GPU" | |
}, | |
runningMode: "VIDEO", | |
numFaces: 1, | |
outputFaceBlendshapes: true, | |
outputFacialTransformationMatrices: true | |
}); | |
addDebugOutput('✅ FaceLandmarker initialized successfully'); | |
return true; | |
} catch (error) { | |
addDebugOutput(`❌ FaceLandmarker initialization failed: ${error.message}`); | |
addDebugOutput(`Error stack: ${error.stack}`); | |
updateStatus(`Initialization failed: ${error.message}`, 'error'); | |
// Try fallback approach | |
try { | |
addDebugOutput('Trying fallback approach...'); | |
// This might work if the script loaded correctly | |
if (typeof FilesetResolver !== 'undefined' && typeof FaceLandmarker !== 'undefined') { | |
const vision = await FilesetResolver.forVisionTasks( | |
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm" | |
); | |
faceLandmarker = await FaceLandmarker.createFromOptions(vision, { | |
baseOptions: { | |
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task", | |
delegate: "GPU" | |
}, | |
runningMode: "VIDEO", | |
numFaces: 1, | |
outputFaceBlendshapes: true, | |
outputFacialTransformationMatrices: true | |
}); | |
addDebugOutput('✅ FaceLandmarker initialized with fallback'); | |
return true; | |
} | |
} catch (fallbackError) { | |
addDebugOutput(`❌ Fallback also failed: ${fallbackError.message}`); | |
} | |
return false; | |
} | |
} | |
async function startCamera() { | |
try { | |
updateStatus('Starting camera...', 'info'); | |
stream = await navigator.mediaDevices.getUserMedia({ | |
video: { width: 640, height: 480 } | |
}); | |
video = document.getElementById('video'); | |
video.srcObject = stream; | |
await new Promise((resolve) => { | |
video.onloadedmetadata = resolve; | |
}); | |
addDebugOutput('✅ Camera started successfully'); | |
return true; | |
} catch (error) { | |
addDebugOutput(`❌ Camera start failed: ${error.message}`); | |
updateStatus(`Camera failed: ${error.message}`, 'error'); | |
return false; | |
} | |
} | |
function processFrame() { | |
if (!faceLandmarker || !video) return; | |
frameCount++; | |
const timestamp = performance.now(); | |
try { | |
const result = faceLandmarker.detectForVideo(video, timestamp); | |
// Log basic result structure once | |
if (!debugLogged.fullResult) { | |
addDebugOutput(`MediaPipe Detection - Landmarks: ${result.faceLandmarks?.length || 0}, Blendshapes: ${result.faceBlendshapes?.length || 0}, Matrices: ${result.facialTransformationMatrixes?.length || 0}`); | |
// Debug: Check all properties of the result object | |
addDebugOutput(`Result object properties: ${Object.keys(result).join(', ')}`); | |
// Check for alternative matrix property names | |
const matrixProps = Object.keys(result).filter(key => | |
key.toLowerCase().includes('matrix') || | |
key.toLowerCase().includes('transform') | |
); | |
if (matrixProps.length > 0) { | |
addDebugOutput(`Matrix-related properties found: ${matrixProps.join(', ')}`); | |
} | |
if (result.facialTransformationMatrixes && result.facialTransformationMatrixes[0]) { | |
addDebugOutput(`Matrix available - Type: ${typeof result.facialTransformationMatrixes[0]}, Length: ${result.facialTransformationMatrixes[0].length}`); | |
} | |
debugLogged.fullResult = true; | |
} | |
// Log available blendshapes once | |
if (!debugLogged.blendshapes && result.faceBlendshapes && result.faceBlendshapes[0]) { | |
const categories = result.faceBlendshapes[0].categories || []; | |
const blendshapeNames = categories.map(c => c.categoryName); | |
addDebugOutput(`Available blendshapes (${blendshapeNames.length}): ${blendshapeNames.join(', ')}`); | |
debugLogged.blendshapes = true; | |
} | |
// Show live values and head pose every 30 frames | |
if (frameCount % 30 === 0) { | |
let output = ''; | |
// Blendshape values | |
if (result.faceBlendshapes && result.faceBlendshapes[0]) { | |
const categories = result.faceBlendshapes[0].categories || []; | |
const eyeBlinkLeft = categories.find(c => c.categoryName === 'eyeBlinkLeft')?.score || 0; | |
const eyeBlinkRight = categories.find(c => c.categoryName === 'eyeBlinkRight')?.score || 0; | |
const jawOpen = categories.find(c => c.categoryName === 'jawOpen')?.score || 0; | |
const browInnerUp = categories.find(c => c.categoryName === 'browInnerUp')?.score || 0; | |
const cheekPuff = categories.find(c => c.categoryName === 'cheekPuff')?.score || 0; | |
const cheekSquintLeft = categories.find(c => c.categoryName === 'cheekSquintLeft')?.score || 0; | |
const cheekSquintRight = categories.find(c => c.categoryName === 'cheekSquintRight')?.score || 0; | |
output += `Blendshapes - Blink L: ${eyeBlinkLeft.toFixed(3)}, Blink R: ${eyeBlinkRight.toFixed(3)}, Jaw: ${jawOpen.toFixed(3)}, Brow: ${browInnerUp.toFixed(3)}`; | |
output += ` | Cheek - Puff: ${cheekPuff.toFixed(3)}, SquintL: ${cheekSquintLeft.toFixed(3)}, SquintR: ${cheekSquintRight.toFixed(3)}`; | |
} | |
// Head pose calculation using landmarks (improved method based on research) | |
if (result.faceLandmarks && result.faceLandmarks[0]) { | |
const landmarks = result.faceLandmarks[0]; | |
// MediaPipe 468 face landmarks - key points: | |
const leftEyeOuter = landmarks[33]; // Left eye outer corner | |
const rightEyeOuter = landmarks[263]; // Right eye outer corner | |
const noseTip = landmarks[1]; // Nose tip | |
const noseBottom = landmarks[2]; // Nose bottom | |
const forehead = landmarks[10]; // Forehead center | |
const chin = landmarks[152]; // Chin bottom | |
const leftFace = landmarks[234]; // Left face edge | |
const rightFace = landmarks[454]; // Right face edge | |
// Helper function for angle calculation | |
const radians = (x1, y1, x2, y2) => Math.atan2(y2 - y1, x2 - x1); | |
// ROLL: Face lean left/right (eye line angle) | |
const roll = radians(leftEyeOuter.x, leftEyeOuter.y, rightEyeOuter.x, rightEyeOuter.y) * 180 / Math.PI; | |
// YAW: Face turn left/right (nose position relative to face center) | |
const faceCenter = (leftFace.x + rightFace.x) / 2; | |
const faceWidth = Math.abs(rightFace.x - leftFace.x); | |
const noseOffset = (noseTip.x - faceCenter) / faceWidth; | |
const yaw = noseOffset * 90; // Convert to degrees | |
// PITCH: Face up/down (adjusted baseline) | |
const faceHeight = Math.abs(forehead.y - chin.y); | |
const eyeLevel = (leftEyeOuter.y + rightEyeOuter.y) / 2; | |
const noseToEyeRatio = (noseTip.y - eyeLevel) / faceHeight; | |
// Adjust baseline - from measurements, neutral appears to be around 0.22 | |
const neutralBaseline = 0.22; | |
const pitch = (noseToEyeRatio - neutralBaseline) * 200; // Convert to degrees with scaling | |
output += ` | Head Pose - Roll: ${roll.toFixed(1)}°, Pitch: ${pitch.toFixed(1)}°, Yaw: ${yaw.toFixed(1)}°`; | |
// Gesture detection with refined thresholds | |
const rollThreshold = 12; | |
const pitchThreshold = 15; | |
const yawThreshold = 0.15; // Using normalized ratio for yaw | |
let gestures = []; | |
// Flip directions to match user's perspective (mirror image) | |
if (roll > rollThreshold) gestures.push('TILT_LEFT'); | |
if (roll < -rollThreshold) gestures.push('TILT_RIGHT'); | |
if (pitch > pitchThreshold) gestures.push('HEAD_DOWN'); | |
if (pitch < -pitchThreshold) gestures.push('HEAD_UP'); | |
if (noseOffset > yawThreshold) gestures.push('HEAD_LEFT'); | |
if (noseOffset < -yawThreshold) gestures.push('HEAD_RIGHT'); | |
if (gestures.length > 0) { | |
output += ` | GESTURES: ${gestures.join(', ')}`; | |
} | |
// Debug key measurements occasionally | |
if (frameCount % 120 === 0) { | |
addDebugOutput(`Measurements - noseOffset: ${noseOffset.toFixed(3)}, faceHeight: ${faceHeight.toFixed(3)}, noseToEyeRatio: ${noseToEyeRatio.toFixed(3)}`); | |
} | |
} else if (result.facialTransformationMatrixes && result.facialTransformationMatrixes[0]) { | |
// Fallback to matrix method if available | |
const matrix = result.facialTransformationMatrixes[0]; | |
// ... matrix calculation code would go here | |
output += ' | Head Pose - Using matrix data'; | |
} else { | |
output += ' | Head Pose - No data available'; | |
} | |
if (output) { | |
addDebugOutput(output); | |
} | |
} | |
updateStatus(`Processing frames... (${frameCount})`, 'success'); | |
} catch (error) { | |
addDebugOutput(`❌ Frame processing error: ${error.message}`); | |
} | |
animationId = requestAnimationFrame(processFrame); | |
} | |
async function start() { | |
startBtn.disabled = true; | |
const faceLandmarkerReady = await initializeFaceLandmarker(); | |
if (!faceLandmarkerReady) { | |
startBtn.disabled = false; | |
return; | |
} | |
const cameraReady = await startCamera(); | |
if (!cameraReady) { | |
startBtn.disabled = false; | |
return; | |
} | |
updateStatus('Running - try facial expressions!', 'success'); | |
stopBtn.disabled = false; | |
processFrame(); | |
} | |
function stop() { | |
if (animationId) { | |
cancelAnimationFrame(animationId); | |
animationId = null; | |
} | |
if (stream) { | |
stream.getTracks().forEach(track => track.stop()); | |
stream = null; | |
} | |
if (video) { | |
video.srcObject = null; | |
} | |
startBtn.disabled = false; | |
stopBtn.disabled = true; | |
updateStatus('Stopped', 'info'); | |
addDebugOutput('🛑 Stopped'); | |
} | |
function clearDebug() { | |
debugEl.textContent = 'Debug output cleared...\n'; | |
debugLogged = { | |
fullResult: false, | |
blendshapes: false, | |
matrix: false | |
}; | |
frameCount = 0; | |
} | |
// Event listeners | |
startBtn.addEventListener('click', start); | |
stopBtn.addEventListener('click', stop); | |
clearBtn.addEventListener('click', clearDebug); | |
// Make objects available in console for debugging | |
window.faceLandmarkerTest = { | |
faceLandmarker, | |
video, | |
debugLogged, | |
frameCount | |
}; | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment