| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- <!doctype html>
- <html>
- <head>
- <meta charset="utf-8" />
- <title>前置摄像头 人脸活体检测 + 录制上传示例</title>
- <style>
- video, canvas { max-width: 360px; border: 1px solid #ccc; display:block; }
- #status { margin-top:8px; }
- </style>
- <!-- MediaPipe Face Mesh (browser) -->
- <script src="https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4/face_mesh.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils@0.4/camera_utils.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils@0.4/drawing_utils.js"></script>
- </head>
- <body>
- <h3>活体检测示例(张嘴 / 眨眼 / 转头)</h3>
- <video id="inputVideo" autoplay playsinline muted></video>
- <canvas id="overlay"></canvas>
- <div id="status">等待摄像头权限...</div>
- <button id="startTest">开始动作检测并录制通过视频</button>
- <script>
- const video = document.getElementById('inputVideo');
- const canvas = document.getElementById('overlay');
- const ctx = canvas.getContext('2d');
- const status = document.getElementById('status');
- const startBtn = document.getElementById('startTest');
- // 状态机:按序进行检测(你也可以并行检测)
- const ACTIONS = ['blink', 'open_mouth', 'turn_right']; // 示例序列
- let currentActionIndex = 0;
- let actionPassed = { blink:false, open_mouth:false, turn_right:false };
- // MediaRecorder 相关
- let mediaRecorder, recordedBlobs = [];
- // 启动摄像头
- async function startCamera() {
- try {
- const stream = await navigator.mediaDevices.getUserMedia({
- video: { facingMode: 'user', width: 640, height: 480 }, audio: true
- });
- video.srcObject = stream;
- await video.play();
- canvas.width = video.videoWidth;
- canvas.height = video.videoHeight;
- status.textContent = '摄像头已开启,准备就绪。点击 "开始动作检测并录制通过视频"。';
- return stream;
- } catch (e) {
- status.textContent = '无法打开摄像头: ' + e.message;
- throw e;
- }
- }
- // EAR - Eye Aspect Ratio 计算(基于 MediaPipe 的关键点索引)
- // MediaPipe 面部关键点参考: https://github.com/tensorflow/tfjs-models/tree/master/face-landmarks-detection (但我们用通用索引)
- // 这里用了左右眼的几个点的索引(MediaPipe 有 468 点):
- const leftEyeIndices = { // 以常见的点索引为例,可能需要根据版本微调
- outer: 33, inner: 133,
- top1: 159, top2: 145,
- bottom1: 23, bottom2: 27
- };
- const rightEyeIndices = {
- outer: 362, inner: 263,
- top1: 386, top2: 374,
- bottom1: 253, bottom2: 257
- };
- const mouthIndices = {
- top: 13, bottom: 14, left: 78, right: 308
- };
- function dist(a, b) {
- return Math.hypot(a.x - b.x, a.y - b.y);
- }
- function computeEAR(landmarks, eyeIdx) {
- // EAR ~ (||p2-p6|| + ||p3-p5||) / (2 * ||p1-p4||)
- const p1 = landmarks[eyeIdx.outer];
- const p4 = landmarks[eyeIdx.inner];
- // vertical pairs
- const p2 = landmarks[eyeIdx.top1];
- const p3 = landmarks[eyeIdx.top2];
- const p5 = landmarks[eyeIdx.bottom1];
- const p6 = landmarks[eyeIdx.bottom2];
- const vert = (dist(p2,p5) + dist(p3,p6)) / 2;
- const horiz = dist(p1,p4);
- if (horiz === 0) return 0;
- return vert / horiz;
- }
- function computeMAR(landmarks) {
- // 简单 MAR: 竖直嘴唇距离 / 眼距归一化
- const top = landmarks[mouthIndices.top];
- const bottom = landmarks[mouthIndices.bottom];
- const left = landmarks[mouthIndices.left];
- const right = landmarks[mouthIndices.right];
- const mouthOpen = dist(top, bottom);
- const eyeDist = dist(landmarks[leftEyeIndices.outer], landmarks[rightEyeIndices.outer]);
- if (eyeDist === 0) return 0;
- return mouthOpen / eyeDist;
- }
- function estimateYaw(landmarks) {
- // 简单估算 yaw(左右转): 比较鼻子与两眼之间的相对位置
- // 鼻尖索引在 MediaPipe 常见是 1 或 4 等,这里用 1(可能需微调)
- const nose = landmarks[1] || landmarks[4] || landmarks[0];
- const leftEye = landmarks[leftEyeIndices.outer];
- const rightEye = landmarks[rightEyeIndices.outer];
- // 若鼻子更靠左 => 向右转(相机视角)。用归一化比例作为估计值
- const midEyeX = (leftEye.x + rightEye.x) / 2;
- const dx = (nose.x - midEyeX); // 负 = nose 左偏, 正 = 右偏
- // 将 dx 归一化到 -1..1
- const w = canvas.width || 1;
- return dx / w * 2;
- }
- // 初始化 MediaPipe FaceMesh
- const faceMesh = new FaceMesh({
- locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4/${file}`
- });
- faceMesh.setOptions({
- maxNumFaces: 1,
- refineLandmarks: true,
- minDetectionConfidence: 0.5,
- minTrackingConfidence: 0.5
- });
- faceMesh.onResults(onResults);
- // Camera helper from MediaPipe(自动把 video 帧送入 faceMesh)
- let mpCamera;
- async function initFaceMesh() {
- mpCamera = new Camera(video, {
- onFrame: async () => {
- await faceMesh.send({image: video});
- },
- width: 640,
- height: 480
- });
- mpCamera.start();
- }
- // 处理每帧结果
- let blinkCooldown = 0;
- let mouthCooldown = 0;
- let turnCooldown = 0;
- function onResults(results) {
- ctx.clearRect(0,0,canvas.width,canvas.height);
- if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) {
- status.textContent = '未检测到人脸,请正对摄像头。';
- return;
- }
- const landmarks = results.multiFaceLandmarks[0].map(p => ({x:p.x * canvas.width, y:p.y * canvas.height, z: p.z}));
- // 可视化关键点(可选)
- drawConnectors(ctx, results.multiFaceLandmarks[0], FACEMESH_TESSELATION, {color: '#C0C0C0', lineWidth: 1});
- drawConnectors(ctx, results.multiFaceLandmarks[0], FACEMESH_RIGHT_EYE, {color: '#FF3030'});
- drawConnectors(ctx, results.multiFaceLandmarks[0], FACEMESH_LEFT_EYE, {color: '#30FF30'});
- // 计算指标
- const leftEAR = computeEAR(landmarks, leftEyeIndices);
- const rightEAR = computeEAR(landmarks, rightEyeIndices);
- const ear = (leftEAR + rightEAR) / 2;
- const mar = computeMAR(landmarks);
- const yaw = estimateYaw(landmarks);
- // 简单阈值(需按实际设备/光线/摄像头微调)
- const BLINK_EAR_THRESHOLD = 0.18; // 小于视为眨眼
- const MAR_THRESHOLD = 0.15; // 大于视为张嘴
- const YAW_RIGHT_THRESHOLD = 0.03; // 右转阈值(正/负方向看估算)
- // 事件检测并防抖(cooldown)
- const now = Date.now();
- // 眨眼检测:EAR 瞬时低于阈值且未处于 cooldown
- if (ear < BLINK_EAR_THRESHOLD && (now - blinkCooldown) > 800) {
- actionPassed.blink = true;
- blinkCooldown = now;
- status.textContent = '检测到眨眼 ✅';
- }
- // 张嘴检测
- if (mar > MAR_THRESHOLD && (now - mouthCooldown) > 1200) {
- actionPassed.open_mouth = true;
- mouthCooldown = now;
- status.textContent = '检测到张嘴 ✅';
- }
- // 转头检测(示例检测向右转)
- if (yaw > YAW_RIGHT_THRESHOLD && (now - turnCooldown) > 1200) {
- actionPassed.turn_right = true;
- turnCooldown = now;
- status.textContent = '检测到向右转头 ✅';
- }
- // 更新动作进度(按 ACTIONS 顺序)
- while (currentActionIndex < ACTIONS.length && actionPassed[ACTIONS[currentActionIndex]]) {
- currentActionIndex++;
- }
- if (currentActionIndex >= ACTIONS.length) {
- status.textContent = '动作序列全部完成,准备上传视频...';
- finishAndUpload();
- } else {
- status.textContent = `请进行动作:${ACTIONS[currentActionIndex]} (已完成 ${currentActionIndex}/${ACTIONS.length})`;
- }
- }
- // 录制并上传:开始录制视频并在动作完成后上传(也可以先录制,再在完成后上传)
- async function startRecording(stream) {
- recordedBlobs = [];
- let options = { mimeType: 'video/webm;codecs=vp9,opus' };
- try {
- mediaRecorder = new MediaRecorder(stream, options);
- } catch (e) {
- options = { mimeType: 'video/webm;codecs=vp8,opus' };
- mediaRecorder = new MediaRecorder(stream, options);
- }
- mediaRecorder.ondataavailable = (e) => {
- if (e.data && e.data.size > 0) recordedBlobs.push(e.data);
- };
- mediaRecorder.start(100); // 每 100ms 收集
- status.textContent = '已开始录制...';
- }
- // 停止并上传
- async function finishAndUpload() {
- // 防止重复触发
- if (!mediaRecorder) {
- status.textContent = '未找到录制器,直接开始并在1.5s后上传短片。';
- // 快速录一段短片(1.5s)作为上传
- const t0 = Date.now();
- const s = video.srcObject;
- await startRecording(s);
- await new Promise(r => setTimeout(r, 1500));
- mediaRecorder.stop();
- } else {
- mediaRecorder.stop();
- }
- // 等待 dataavailable 收集完成(用短延时)
- await new Promise(r => setTimeout(r, 500));
- const blob = new Blob(recordedBlobs, { type: 'video/webm' });
- const form = new FormData();
- form.append('file', blob, 'liveness.webm');
- form.append('meta', JSON.stringify({ actions: ACTIONS, timestamp: Date.now() }));
- status.textContent = '上传中...';
- // 上传(替换为你的上传 URL)
- try {
- const resp = await fetch('/upload_liveness', {
- method: 'POST',
- body: form
- });
- if (resp.ok) {
- status.textContent = '上传成功 ✅';
- } else {
- status.textContent = '上传失败,服务器返回 ' + resp.status;
- }
- } catch (e) {
- status.textContent = '上传出错:' + e.message;
- }
- }
- // UI 按钮:开始检测并录制
- startBtn.addEventListener('click', async () => {
- startBtn.disabled = true;
- currentActionIndex = 0;
- actionPassed = { blink:false, open_mouth:false, turn_right:false };
- try {
- const stream = await startCamera();
- await initFaceMesh();
- // 开始录制整个摄像头流(也可选择在动作完成后再录制)
- await startRecording(stream);
- } catch (e) {
- console.error(e);
- }
- });
- // 页面卸载时停止摄像头
- window.addEventListener('beforeunload', () => {
- if (video && video.srcObject) {
- video.srcObject.getTracks().forEach(t => t.stop());
- }
- if (mpCamera && mpCamera.stop) mpCamera.stop();
- });
- </script>
- </body>
- </html>
|