<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>人脸居中引导系统</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body, html {
width: 100%; height: 100%;
overflow: hidden; background: #000;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
.video-wrapper { position: relative; width: 100%; height: 100%; }
video {
width: 100%; height: 100%;
object-fit: cover;
transform: scaleX(-1);
}
.guide-layer {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
display: flex; flex-direction: column;
justify-content: center; align-items: center;
pointer-events: none; z-index: 10;
}
.face-oval {
width: 220px;
height: 320px;
border: 3px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5),
0 0 20px rgba(255, 255, 255, 0.2);
transition: border-color 0.3s, box-shadow 0.3s;
}
.face-oval.warning {
border-color: #FFD700;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.3),
0 0 20px rgba(255, 215, 0, 0.6);
}
.face-oval.success {
border-color: #00FF00;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.2),
0 0 25px rgba(0, 255, 0, 0.8);
}
.hint-text {
margin-top: 30px; color: #fff; font-size: 18px; font-weight: 500;
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
background: rgba(0,0,0,0.5); padding: 10px 24px; border-radius: 20px;
transition: color 0.3s;
}
/* 调试信息 */
.debug-info {
position: absolute; top: 10px; left: 10px;
color: #0f0; font-size: 12px; font-family: monospace;
background: rgba(0,0,0,0.7); padding: 8px; border-radius: 6px;
z-index: 30; pointer-events: none; line-height: 1.6;
}
.switch-btn {
pointer-events: auto; position: absolute; bottom: 40px; right: 20px;
width: 50px; height: 50px; border-radius: 50%;
border: 1px solid rgba(255,255,255,0.5); background: rgba(0,0,0,0.5);
color: #fff; font-size: 24px; display: flex;
justify-content: center; align-items: center; cursor: pointer; z-index: 20;
}
.switch-btn:active { background: rgba(255,255,255,0.3); }
</style>
</head>
<body>
<div class="video-wrapper">
<video id="camVideo" autoplay muted playsinline></video>
<div class="guide-layer">
<div class="face-oval" id="guideBox"></div>
<p class="hint-text" id="hintText">正在初始化...</p>
</div>
<div class="debug-info" id="debugInfo">调试信息加载中...</div>
<div class="switch-btn" id="switchBtn">🔄</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/face_detection@0.4.1646425229/face_detection.js" crossorigin="anonymous"></script>
<script>
const video = document.getElementById('camVideo');
const hint = document.getElementById('hintText');
const switchBtn = document.getElementById('switchBtn');
const guideBox = document.getElementById('guideBox');
const debugEl = document.getElementById('debugInfo');
let currentStream = null;
let isFrontCamera = true;
let faceDetection = null;
let isProcessing = false; // 防止帧堆积
// ========== 调试面板 ==========
function debug(msg) {
debugEl.innerText = msg;
}
// ========== 1. 初始化 MediaPipe ==========
function initMediaPipe() {
return new Promise((resolve, reject) => {
try {
faceDetection = new FaceDetection({
locateFile: (file) => {
return `https://cdn.jsdelivr.net/npm/@mediapipe/face_detection@0.4.1646425229/${file}\`;
}
});
faceDetection.setOptions({
model: 'short',
minDetectionConfidence: 0.5
});
faceDetection.onResults(onFaceResults);
debug("MediaPipe 初始化成功");
resolve();
} catch(e) {
debug("MediaPipe 初始化失败: " + e.message);
reject(e);
}
});
}
// ========== 2. 启动摄像头 ==========
async function startCamera(facingMode) {
if (currentStream) {
currentStream.getTracks().forEach(t => t.stop());
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: facingMode, width: { ideal: 640 }, height: { ideal: 480 } },
audio: false
});
currentStream = stream;
video.srcObject = stream;
isFrontCamera = (facingMode === 'user');
video.style.transform = isFrontCamera ? 'scaleX(-1)' : 'scaleX(1)';
hint.innerText = "请将面部移入框内";
hint.style.color = "#fff";
guideBox.className = 'face-oval';
debug("摄像头已启动: " + facingMode);
} catch(e) {
hint.innerText = "摄像头启动失败";
debug("摄像头错误: " + e.message);
}
}
// ========== 3. 核心:人脸结果处理 ==========
function onFaceResults(results) {
isProcessing = false;
// 调试:显示原始数据
const detCount = results.detections ? results.detections.length : 0;
let debugMsg = `检测到人脸: ${detCount} 个\n`;
if (detCount === 0) {
hint.innerText = "请将面部移入框内";
hint.style.color = "#fff";
guideBox.className = 'face-oval';
debug(debugMsg);
return;
}
// 取第一个人脸
const detection = results.detections0;
// boundingBox: { xCenter, yCenter, width, height } (归一化 0~1)
const box = detection.boundingBox;
// 人脸中心点(归一化坐标)
let faceCenterNormX = box.xCenter;
let faceCenterNormY = box.yCenter;
debugMsg += `人脸中心(归一化): x={faceCenterNormX.toFixed(3)}, y={faceCenterNormY.toFixed(3)}\n`;
debugMsg += `框大小(归一化): w={box.width.toFixed(3)}, h={box.height.toFixed(3)}\n`;
// 屏幕中心 = 目标位置(归一化 0.5, 0.5)
const targetX = 0.5;
const targetY = 0.5;
// 前置摄像头镜像:MediaPipe 返回的是原始图像坐标
// 原始图像中人脸在左边 -> 归一化 x 小
// 但用户看到的是镜像画面,所以视觉上人脸在右边
// 因此需要翻转 X 坐标来匹配用户的视觉
if (isFrontCamera) {
faceCenterNormX = 1.0 - faceCenterNormX;
}
debugMsg += `镜像后中心: x={faceCenterNormX.toFixed(3)}, y={faceCenterNormY.toFixed(3)}\n`;
// 计算偏移(归一化)
const diffX = faceCenterNormX - targetX;
const diffY = faceCenterNormY - targetY;
// 容差(归一化坐标,0.08 大约对应屏幕宽度的 8%)
const threshold = 0.08;
const isAlignedX = Math.abs(diffX) < threshold;
const isAlignedY = Math.abs(diffY) < threshold;
debugMsg += `偏移: dx={diffX.toFixed(3)}, dy={diffY.toFixed(3)}\n`;
debugMsg += `对齐: X={isAlignedX}, Y={isAlignedY}`;
debug(debugMsg);
if (isAlignedX && isAlignedY) {
hint.innerText = "✓ 识别成功";
hint.style.color = "#00FF00";
guideBox.className = 'face-oval success';
} else {
guideBox.className = 'face-oval warning';
let direction = "";
// 注意:这里的 diffX/diffY 已经是镜像后的值
// diffY < 0 表示人脸在框上方 -> 提示向上
// diffY > 0 表示人脸在框下方 -> 提示向下
// diffX < 0 表示人脸在框左边 -> 提示向左
// diffX > 0 表示人脸在框右边 -> 提示向右
if (!isAlignedY) {
direction += diffY < 0 ? "上" : "下";
}
if (!isAlignedX) {
direction += diffX < 0 ? "左" : "右";
}
hint.innerText = `请向${direction}移动`;
hint.style.color = "#FFD700";
}
}
// ========== 4. 渲染循环(带节流) ==========
async function renderLoop() {
if (!isProcessing && video.readyState >= 2 && faceDetection) {
isProcessing = true;
try {
await faceDetection.send({ image: video });
} catch(e) {
isProcessing = false;
console.warn("检测帧出错:", e);
}
}
requestAnimationFrame(renderLoop);
}
// ========== 5. 切换摄像头 ==========
switchBtn.addEventListener('click', () => {
isFrontCamera = !isFrontCamera;
hint.innerText = "切换中...";
startCamera(isFrontCamera ? 'user' : 'environment');
});
// ========== 6. 启动 ==========
window.addEventListener('DOMContentLoaded', async () => {
try {
await initMediaPipe();
await startCamera('user');
renderLoop();
} catch(e) {
hint.innerText = "初始化失败,请刷新重试";
}
});
</script>
</body>
</html>