人脸偏移检测

<!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>