face.html 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. <!doctype html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8" />
  5. <title>前置摄像头 人脸活体检测 + 录制上传示例</title>
  6. <style>
  7. video, canvas { max-width: 360px; border: 1px solid #ccc; display:block; }
  8. #status { margin-top:8px; }
  9. </style>
  10. <!-- MediaPipe Face Mesh (browser) -->
  11. <script src="https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4/face_mesh.js"></script>
  12. <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils@0.4/camera_utils.js"></script>
  13. <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils@0.4/drawing_utils.js"></script>
  14. </head>
  15. <body>
  16. <h3>活体检测示例(张嘴 / 眨眼 / 转头)</h3>
  17. <video id="inputVideo" autoplay playsinline muted></video>
  18. <canvas id="overlay"></canvas>
  19. <div id="status">等待摄像头权限...</div>
  20. <button id="startTest">开始动作检测并录制通过视频</button>
  21. <script>
  22. const video = document.getElementById('inputVideo');
  23. const canvas = document.getElementById('overlay');
  24. const ctx = canvas.getContext('2d');
  25. const status = document.getElementById('status');
  26. const startBtn = document.getElementById('startTest');
  27. // 状态机:按序进行检测(你也可以并行检测)
  28. const ACTIONS = ['blink', 'open_mouth', 'turn_right']; // 示例序列
  29. let currentActionIndex = 0;
  30. let actionPassed = { blink:false, open_mouth:false, turn_right:false };
  31. // MediaRecorder 相关
  32. let mediaRecorder, recordedBlobs = [];
  33. // 启动摄像头
  34. async function startCamera() {
  35. try {
  36. const stream = await navigator.mediaDevices.getUserMedia({
  37. video: { facingMode: 'user', width: 640, height: 480 }, audio: true
  38. });
  39. video.srcObject = stream;
  40. await video.play();
  41. canvas.width = video.videoWidth;
  42. canvas.height = video.videoHeight;
  43. status.textContent = '摄像头已开启,准备就绪。点击 "开始动作检测并录制通过视频"。';
  44. return stream;
  45. } catch (e) {
  46. status.textContent = '无法打开摄像头: ' + e.message;
  47. throw e;
  48. }
  49. }
  50. // EAR - Eye Aspect Ratio 计算(基于 MediaPipe 的关键点索引)
  51. // MediaPipe 面部关键点参考: https://github.com/tensorflow/tfjs-models/tree/master/face-landmarks-detection (但我们用通用索引)
  52. // 这里用了左右眼的几个点的索引(MediaPipe 有 468 点):
  53. const leftEyeIndices = { // 以常见的点索引为例,可能需要根据版本微调
  54. outer: 33, inner: 133,
  55. top1: 159, top2: 145,
  56. bottom1: 23, bottom2: 27
  57. };
  58. const rightEyeIndices = {
  59. outer: 362, inner: 263,
  60. top1: 386, top2: 374,
  61. bottom1: 253, bottom2: 257
  62. };
  63. const mouthIndices = {
  64. top: 13, bottom: 14, left: 78, right: 308
  65. };
  66. function dist(a, b) {
  67. return Math.hypot(a.x - b.x, a.y - b.y);
  68. }
  69. function computeEAR(landmarks, eyeIdx) {
  70. // EAR ~ (||p2-p6|| + ||p3-p5||) / (2 * ||p1-p4||)
  71. const p1 = landmarks[eyeIdx.outer];
  72. const p4 = landmarks[eyeIdx.inner];
  73. // vertical pairs
  74. const p2 = landmarks[eyeIdx.top1];
  75. const p3 = landmarks[eyeIdx.top2];
  76. const p5 = landmarks[eyeIdx.bottom1];
  77. const p6 = landmarks[eyeIdx.bottom2];
  78. const vert = (dist(p2,p5) + dist(p3,p6)) / 2;
  79. const horiz = dist(p1,p4);
  80. if (horiz === 0) return 0;
  81. return vert / horiz;
  82. }
  83. function computeMAR(landmarks) {
  84. // 简单 MAR: 竖直嘴唇距离 / 眼距归一化
  85. const top = landmarks[mouthIndices.top];
  86. const bottom = landmarks[mouthIndices.bottom];
  87. const left = landmarks[mouthIndices.left];
  88. const right = landmarks[mouthIndices.right];
  89. const mouthOpen = dist(top, bottom);
  90. const eyeDist = dist(landmarks[leftEyeIndices.outer], landmarks[rightEyeIndices.outer]);
  91. if (eyeDist === 0) return 0;
  92. return mouthOpen / eyeDist;
  93. }
  94. function estimateYaw(landmarks) {
  95. // 简单估算 yaw(左右转): 比较鼻子与两眼之间的相对位置
  96. // 鼻尖索引在 MediaPipe 常见是 1 或 4 等,这里用 1(可能需微调)
  97. const nose = landmarks[1] || landmarks[4] || landmarks[0];
  98. const leftEye = landmarks[leftEyeIndices.outer];
  99. const rightEye = landmarks[rightEyeIndices.outer];
  100. // 若鼻子更靠左 => 向右转(相机视角)。用归一化比例作为估计值
  101. const midEyeX = (leftEye.x + rightEye.x) / 2;
  102. const dx = (nose.x - midEyeX); // 负 = nose 左偏, 正 = 右偏
  103. // 将 dx 归一化到 -1..1
  104. const w = canvas.width || 1;
  105. return dx / w * 2;
  106. }
  107. // 初始化 MediaPipe FaceMesh
  108. const faceMesh = new FaceMesh({
  109. locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4/${file}`
  110. });
  111. faceMesh.setOptions({
  112. maxNumFaces: 1,
  113. refineLandmarks: true,
  114. minDetectionConfidence: 0.5,
  115. minTrackingConfidence: 0.5
  116. });
  117. faceMesh.onResults(onResults);
  118. // Camera helper from MediaPipe(自动把 video 帧送入 faceMesh)
  119. let mpCamera;
  120. async function initFaceMesh() {
  121. mpCamera = new Camera(video, {
  122. onFrame: async () => {
  123. await faceMesh.send({image: video});
  124. },
  125. width: 640,
  126. height: 480
  127. });
  128. mpCamera.start();
  129. }
  130. // 处理每帧结果
  131. let blinkCooldown = 0;
  132. let mouthCooldown = 0;
  133. let turnCooldown = 0;
  134. function onResults(results) {
  135. ctx.clearRect(0,0,canvas.width,canvas.height);
  136. if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) {
  137. status.textContent = '未检测到人脸,请正对摄像头。';
  138. return;
  139. }
  140. const landmarks = results.multiFaceLandmarks[0].map(p => ({x:p.x * canvas.width, y:p.y * canvas.height, z: p.z}));
  141. // 可视化关键点(可选)
  142. drawConnectors(ctx, results.multiFaceLandmarks[0], FACEMESH_TESSELATION, {color: '#C0C0C0', lineWidth: 1});
  143. drawConnectors(ctx, results.multiFaceLandmarks[0], FACEMESH_RIGHT_EYE, {color: '#FF3030'});
  144. drawConnectors(ctx, results.multiFaceLandmarks[0], FACEMESH_LEFT_EYE, {color: '#30FF30'});
  145. // 计算指标
  146. const leftEAR = computeEAR(landmarks, leftEyeIndices);
  147. const rightEAR = computeEAR(landmarks, rightEyeIndices);
  148. const ear = (leftEAR + rightEAR) / 2;
  149. const mar = computeMAR(landmarks);
  150. const yaw = estimateYaw(landmarks);
  151. // 简单阈值(需按实际设备/光线/摄像头微调)
  152. const BLINK_EAR_THRESHOLD = 0.18; // 小于视为眨眼
  153. const MAR_THRESHOLD = 0.15; // 大于视为张嘴
  154. const YAW_RIGHT_THRESHOLD = 0.03; // 右转阈值(正/负方向看估算)
  155. // 事件检测并防抖(cooldown)
  156. const now = Date.now();
  157. // 眨眼检测:EAR 瞬时低于阈值且未处于 cooldown
  158. if (ear < BLINK_EAR_THRESHOLD && (now - blinkCooldown) > 800) {
  159. actionPassed.blink = true;
  160. blinkCooldown = now;
  161. status.textContent = '检测到眨眼 ✅';
  162. }
  163. // 张嘴检测
  164. if (mar > MAR_THRESHOLD && (now - mouthCooldown) > 1200) {
  165. actionPassed.open_mouth = true;
  166. mouthCooldown = now;
  167. status.textContent = '检测到张嘴 ✅';
  168. }
  169. // 转头检测(示例检测向右转)
  170. if (yaw > YAW_RIGHT_THRESHOLD && (now - turnCooldown) > 1200) {
  171. actionPassed.turn_right = true;
  172. turnCooldown = now;
  173. status.textContent = '检测到向右转头 ✅';
  174. }
  175. // 更新动作进度(按 ACTIONS 顺序)
  176. while (currentActionIndex < ACTIONS.length && actionPassed[ACTIONS[currentActionIndex]]) {
  177. currentActionIndex++;
  178. }
  179. if (currentActionIndex >= ACTIONS.length) {
  180. status.textContent = '动作序列全部完成,准备上传视频...';
  181. finishAndUpload();
  182. } else {
  183. status.textContent = `请进行动作:${ACTIONS[currentActionIndex]} (已完成 ${currentActionIndex}/${ACTIONS.length})`;
  184. }
  185. }
  186. // 录制并上传:开始录制视频并在动作完成后上传(也可以先录制,再在完成后上传)
  187. async function startRecording(stream) {
  188. recordedBlobs = [];
  189. let options = { mimeType: 'video/webm;codecs=vp9,opus' };
  190. try {
  191. mediaRecorder = new MediaRecorder(stream, options);
  192. } catch (e) {
  193. options = { mimeType: 'video/webm;codecs=vp8,opus' };
  194. mediaRecorder = new MediaRecorder(stream, options);
  195. }
  196. mediaRecorder.ondataavailable = (e) => {
  197. if (e.data && e.data.size > 0) recordedBlobs.push(e.data);
  198. };
  199. mediaRecorder.start(100); // 每 100ms 收集
  200. status.textContent = '已开始录制...';
  201. }
  202. // 停止并上传
  203. async function finishAndUpload() {
  204. // 防止重复触发
  205. if (!mediaRecorder) {
  206. status.textContent = '未找到录制器,直接开始并在1.5s后上传短片。';
  207. // 快速录一段短片(1.5s)作为上传
  208. const t0 = Date.now();
  209. const s = video.srcObject;
  210. await startRecording(s);
  211. await new Promise(r => setTimeout(r, 1500));
  212. mediaRecorder.stop();
  213. } else {
  214. mediaRecorder.stop();
  215. }
  216. // 等待 dataavailable 收集完成(用短延时)
  217. await new Promise(r => setTimeout(r, 500));
  218. const blob = new Blob(recordedBlobs, { type: 'video/webm' });
  219. const form = new FormData();
  220. form.append('file', blob, 'liveness.webm');
  221. form.append('meta', JSON.stringify({ actions: ACTIONS, timestamp: Date.now() }));
  222. status.textContent = '上传中...';
  223. // 上传(替换为你的上传 URL)
  224. try {
  225. const resp = await fetch('/upload_liveness', {
  226. method: 'POST',
  227. body: form
  228. });
  229. if (resp.ok) {
  230. status.textContent = '上传成功 ✅';
  231. } else {
  232. status.textContent = '上传失败,服务器返回 ' + resp.status;
  233. }
  234. } catch (e) {
  235. status.textContent = '上传出错:' + e.message;
  236. }
  237. }
  238. // UI 按钮:开始检测并录制
  239. startBtn.addEventListener('click', async () => {
  240. startBtn.disabled = true;
  241. currentActionIndex = 0;
  242. actionPassed = { blink:false, open_mouth:false, turn_right:false };
  243. try {
  244. const stream = await startCamera();
  245. await initFaceMesh();
  246. // 开始录制整个摄像头流(也可选择在动作完成后再录制)
  247. await startRecording(stream);
  248. } catch (e) {
  249. console.error(e);
  250. }
  251. });
  252. // 页面卸载时停止摄像头
  253. window.addEventListener('beforeunload', () => {
  254. if (video && video.srcObject) {
  255. video.srcObject.getTracks().forEach(t => t.stop());
  256. }
  257. if (mpCamera && mpCamera.stop) mpCamera.stop();
  258. });
  259. </script>
  260. </body>
  261. </html>