|
|
@@ -0,0 +1,285 @@
|
|
|
+<!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>
|