这是"抽人回答问题"HTML代码。它利用摄像头实时检测人脸,并随机抽取一张人脸截图显示在屏幕上。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>抽人回答问题 · 人脸捕获</title>
<!-- 使用 Tailwind 精简样式,并自定义一些动效 -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 人脸检测库 face-api.js 核心与模型 -->
<script defer src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js"></script>
<style>
/* 摄像头容器与画布叠加层 */
#video-wrapper {
position: relative;
width: 100%;
max-width: 640px;
margin: 0 auto;
aspect-ratio: 4 / 3;
background: #1e1e2f;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.6);
}
#video-element {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1); /* 镜像,更自然 */
}
#overlay-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* 让按钮可以穿透点击 */
transform: scaleX(-1); /* 与视频保持镜像一致 */
}
/* 截图展示区 */
#snapshot-container {
position: relative;
width: 100%;
max-width: 320px;
margin: 0 auto;
aspect-ratio: 1 / 1;
background: #2d2d44;
border-radius: 32px;
overflow: hidden;
box-shadow: 0 10px 30px -8px rgba(0, 0, 0, 0.5);
border: 2px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s ease;
}
#snapshot-container img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.glow-text {
text-shadow: 0 0 12px rgba(120, 180, 255, 0.5);
}
/* 按钮动效 */
.btn-primary {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
transition: transform 0.15s, box-shadow 0.2s;
box-shadow: 0 8px 20px -6px #6366f180;
}
.btn-primary:hover:not(:disabled) {
transform: scale(1.02);
box-shadow: 0 12px 28px -6px #6366f1b0;
}
.btn-primary:active:not(:disabled) {
transform: scale(0.96);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(0.4);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.15);
transition: 0.15s;
}
.btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.16);
}
.badge {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
padding: 4px 14px;
border-radius: 40px;
font-size: 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* 加载模型占位 */
.loader {
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #818cf8;
border-radius: 50%;
width: 36px;
height: 36px;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-#0f0f1a via-#1a1a2e to-#16213e text-white font-sans antialiased flex items-center justify-center p-4">
<main class="w-full max-w-3xl mx-auto flex flex-col items-center gap-6">
<!-- 标题 & 状态 -->
<div class="flex items-center justify-between w-full max-w-md px-2">
<h1 class="text-3xl font-bold tracking-tight flex items-center gap-2">
<span class="bg-gradient-to-r from-indigo-300 to-purple-300 bg-clip-text text-transparent">📸 抽人</span>
<span class="text-sm font-normal text-white/40">回答问题</span>
</h1>
<span id="status-badge" class="badge text-white/70 text-xs flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-green-400 inline-block animate-pulse" id="cam-indicator"></span>
等待启动
</span>
</div>
<!-- 摄像头 + 人脸框叠加 -->
<div id="video-wrapper">
<video id="video-element" autoplay muted playsinline></video>
<canvas id="overlay-canvas"></canvas>
<!-- 轻提示:检测到人脸数量 -->
<div class="absolute bottom-3 left-3 bg-black/50 backdrop-blur-sm px-4 py-1.5 rounded-full text-xs text-white/80 border border-white/10 flex items-center gap-2">
<span>👤 检测到</span>
<span id="face-count" class="font-bold text-indigo-300 min-w-20px text-center">0</span>
<span>张脸</span>
</div>
</div>
<!-- 操作栏 -->
<div class="flex flex-wrap items-center justify-center gap-3 w-full max-w-md mt-1">
<button id="capture-btn" class="btn-primary px-8 py-3 rounded-full font-medium text-white flex items-center gap-2 disabled:opacity-50" disabled>
<span>🎲</span> 抽人
</button>
<button id="reset-btn" class="btn-secondary px-6 py-3 rounded-full font-medium text-white/80 flex items-center gap-2 disabled:opacity-40" disabled>
<span>↺</span> 返回重抽
</button>
</div>
<!-- 截图展示 (抽中的人) -->
<div id="snapshot-container" class="relative">
<img id="snapshot-img" src="" alt="抽中人脸截图" class="w-full h-full object-cover opacity-0 transition-opacity duration-300">
<div id="placeholder-snapshot" class="absolute inset-0 flex flex-col items-center justify-center text-white/30 text-sm">
<span class="text-4xl mb-2">👤</span>
<span>等待抽取</span>
</div>
<!-- 若抽中,显示"中奖"角标 -->
<div id="winner-badge" class="absolute top-2 right-2 bg-indigo-500/80 backdrop-blur-sm text-white text-xs font-bold px-3 py-1 rounded-full border border-white/20 hidden">
⭐ 被抽中
</div>
</div>
<!-- 调试/状态信息(可选) -->
<p class="text-xs text-white/30 max-w-sm text-center mt-2 leading-relaxed">
<span id="info-text">模型加载中,请授权摄像头...</span>
</p>
</main>
<script>
(function() {
"use strict";
// ---------- DOM 元素 ----------
const video = document.getElementById('video-element');
const canvas = document.getElementById('overlay-canvas');
const ctx = canvas.getContext('2d');
const snapshotImg = document.getElementById('snapshot-img');
const placeholder = document.getElementById('placeholder-snapshot');
const winnerBadge = document.getElementById('winner-badge');
const faceCountSpan = document.getElementById('face-count');
const statusBadge = document.getElementById('status-badge');
const camIndicator = document.getElementById('cam-indicator');
const infoText = document.getElementById('info-text');
const captureBtn = document.getElementById('capture-btn');
const resetBtn = document.getElementById('reset-btn');
// ---------- 状态变量 ----------
let isModelLoaded = false;
let isCameraReady = false;
let isCapturing = false; // 正在抽取中 (锁定按钮)
let hasSnapshot = false; // 是否已有截图 (用于显示reset)
let currentDetections = \[\]; // 存储当前帧人脸检测结果
let animationFrameId = null;
let lastFaceCount = 0;
// ---------- 配置 ----------
const MODEL_URL = 'https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/models/'; // 轻量级模型
const DETECT_OPTIONS = new faceapi.SsdMobilenetv1Options({
minConfidence: 0.5, // 置信度阈值
maxResults: 10
});
// ---------- 辅助函数 ----------
function updateUIForReady() {
if (isModelLoaded && isCameraReady) {
captureBtn.disabled = false;
resetBtn.disabled = false;
statusBadge.innerHTML = `<span class="w-2 h-2 rounded-full bg-green-400 inline-block"></span> 就绪`;
infoText.textContent = '✅ 已就绪,点击"抽人"随机截取一张人脸';
} else {
captureBtn.disabled = true;
resetBtn.disabled = true;
}
}
// 更新检测到的人脸数量
function updateFaceCount(detections) {
const count = detections ? detections.length : 0;
faceCountSpan.textContent = count;
// 如果没有人脸,给个提示颜色
if (count === 0 && isCameraReady) {
faceCountSpan.className = 'font-bold text-rose-300 min-w-20px text-center';
} else {
faceCountSpan.className = 'font-bold text-indigo-300 min-w-20px text-center';
}
// 当人脸数量变化时,轻微更新状态 (但不干扰capture)
}
// 绘制人脸框 (镜像处理,与视频一致)
function drawDetections(detections) {
const videoWidth = video.videoWidth;
const videoHeight = video.videoHeight;
if (!videoWidth || !videoHeight) return;
// canvas 尺寸与视频比例同步
canvas.width = videoWidth;
canvas.height = videoHeight;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!detections || detections.length === 0) return;
// face-api 的检测框坐标是基于原始图像 (未镜像)
// 但 canvas 我们设置了 scaleX(-1) 所以绘制时需要镜像翻转
// 因为 canvas 整体镜像,所以我们直接绘制矩形即可,坐标会自动镜像
// 不过绘制文字时需要额外注意方向,但这里仅绘制框,无需文字
ctx.strokeStyle = '#60a5fa';
ctx.lineWidth = 2.5;
ctx.shadowColor = '#60a5fa60';
ctx.shadowBlur = 8;
detections.forEach(det => {
const box = det.detection.box;
const x = box.x;
const y = box.y;
const w = box.width;
const h = box.height;
// 由于 canvas 镜像,x 会自动翻转,直接绘制即可
ctx.strokeRect(x, y, w, h);
// 加一个小圆角装饰
ctx.fillStyle = '#60a5fa30';
ctx.fillRect(x, y-1, w, 2);
});
// 重置阴影
ctx.shadowBlur = 0;
}
// ---------- 核心: 随机抽取一张人脸截图 ----------
function captureRandomFace() {
if (!isModelLoaded || !isCameraReady) {
alert('请等待模型和摄像头就绪');
return;
}
if (isCapturing) return;
if (!currentDetections || currentDetections.length === 0) {
alert('未检测到人脸,请面对摄像头 😅');
return;
}
isCapturing = true;
captureBtn.disabled = true;
resetBtn.disabled = true;
// 隐藏之前的截图状态
snapshotImg.classList.add('opacity-0');
placeholder.classList.remove('hidden');
winnerBadge.classList.add('hidden');
// 随机选择一个检测结果
const randomIndex = Math.floor(Math.random() * currentDetections.length);
const selected = currentDetectionsrandomIndex;
const box = selected.detection.box;
// 从视频中截取人脸区域 (注意视频是镜像的,但坐标基于原始图像,canvas 也是镜像,一致)
const videoWidth = video.videoWidth;
const videoHeight = video.videoHeight;
if (!videoWidth || !videoHeight) {
isCapturing = false;
captureBtn.disabled = false;
resetBtn.disabled = false;
return;
}
// 扩充边距 10%,让截图更自然
const margin = 0.12;
let x = Math.max(0, box.x - box.width * margin);
let y = Math.max(0, box.y - box.height * margin);
let w = Math.min(videoWidth - x, box.width * (1 + margin * 2));
let h = Math.min(videoHeight - y, box.height * (1 + margin * 2));
// 确保至少 30x30
if (w < 30) w = 30;
if (h < 30) h = 30;
// 利用 canvas 截取 (由于视频是镜像的,直接截取)
const tempCanvas = document.createElement('canvas');
tempCanvas.width = w;
tempCanvas.height = h;
const tempCtx = tempCanvas.getContext('2d');
// 绘制视频帧的对应区域 (注意视频本身是镜像,直接draw即可,因为坐标已对应)
tempCtx.drawImage(video, x, y, w, h, 0, 0, w, h);
// 将截图显示到 snapshot-img
const dataUrl = tempCanvas.toDataURL('image/png');
snapshotImg.src = dataUrl;
// 显示截图,隐藏占位
snapshotImg.onload = () => {
snapshotImg.classList.remove('opacity-0');
placeholder.classList.add('hidden');
winnerBadge.classList.remove('hidden');
hasSnapshot = true;
isCapturing = false;
captureBtn.disabled = false;
resetBtn.disabled = false;
// 更新信息
infoText.textContent = `🎯 已抽中 #${randomIndex+1} 号脸,点击"返回重抽"可重新抽取`;
};
snapshotImg.onerror = () => {
// fallback
placeholder.classList.remove('hidden');
winnerBadge.classList.add('hidden');
isCapturing = false;
captureBtn.disabled = false;
resetBtn.disabled = false;
infoText.textContent = '⚠️ 截图失败,请重试';
};
}
// ---------- 重置/重新开始 ----------
function resetSnapshot() {
if (isCapturing) return;
// 清空截图,回到初始状态
snapshotImg.src = '';
snapshotImg.classList.add('opacity-0');
placeholder.classList.remove('hidden');
winnerBadge.classList.add('hidden');
hasSnapshot = false;
infoText.textContent = '🔄 已重置,点击"抽人"再次抽取';
// 按钮状态恢复 (但若模型未就绪则禁用)
updateUIForReady();
// 重新开始新一轮 (清除任何临时状态)
// 并且如果当前有人脸框,继续显示
}
// ---------- 视频帧循环 (检测人脸 + 绘制) ----------
async function detectLoop() {
if (!isCameraReady || !isModelLoaded) {
animationFrameId = requestAnimationFrame(detectLoop);
return;
}
try {
// 检测人脸
const detections = await faceapi.detectAllFaces(video, DETECT_OPTIONS);
currentDetections = detections || \[\];
// 更新计数
updateFaceCount(currentDetections);
// 绘制框
drawDetections(currentDetections);
} catch (err) {
// 偶尔出错忽略
console.warn('检测异常:', err);
}
animationFrameId = requestAnimationFrame(detectLoop);
}
// ---------- 初始化摄像头 ----------
async function initCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
video.srcObject = stream;
await video.play();
isCameraReady = true;
camIndicator.className = 'w-2 h-2 rounded-full bg-green-400 inline-block animate-pulse';
statusBadge.innerHTML = `<span class="w-2 h-2 rounded-full bg-green-400 inline-block"></span> 摄像头已开`;
infoText.textContent = '📷 摄像头已启动,加载模型中...';
// 设置canvas尺寸 (在第一次视频帧加载后设置)
video.addEventListener('loadedmetadata', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
});
// 更新UI
updateUIForReady();
// 开始检测循环
if (animationFrameId) cancelAnimationFrame(animationFrameId);
detectLoop();
} catch (err) {
console.error('摄像头启动失败:', err);
camIndicator.className = 'w-2 h-2 rounded-full bg-rose-400 inline-block';
statusBadge.innerHTML = `<span class="w-2 h-2 rounded-full bg-rose-400 inline-block"></span> 摄像头不可用`;
infoText.textContent = '❌ 无法访问摄像头,请检查权限或连接';
alert('无法访问摄像头,请允许浏览器访问摄像头后刷新页面。');
}
}
// ---------- 加载 face-api 模型 ----------
async function loadModels() {
try {
infoText.textContent = '⏳ 加载人脸检测模型...';
await faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL);
// 也可以加载 tinyFaceDetector 但 ssd 更准
isModelLoaded = true;
infoText.textContent = '🧠 模型加载完成,等待摄像头...';
updateUIForReady();
// 如果摄像头已就绪,更新状态
if (isCameraReady) {
statusBadge.innerHTML = `<span class="w-2 h-2 rounded-full bg-green-400 inline-block"></span> 完全就绪`;
infoText.textContent = '✅ 一切就绪,点击"抽人"开始!';
}
} catch (err) {
console.error('模型加载失败:', err);
infoText.textContent = '❌ 模型加载失败,请刷新重试';
alert('人脸检测模型加载失败,请检查网络后刷新。');
}
}
// ---------- 事件绑定 ----------
captureBtn.addEventListener('click', captureRandomFace);
resetBtn.addEventListener('click', resetSnapshot);
// ---------- 启动 ----------
async function init() {
// 先加载模型,再开摄像头 (并行也行,但顺序保证ui)
await loadModels();
await initCamera();
// 若模型加载失败,摄像头也可能已经开启,但按钮禁用
updateUIForReady();
}
// 页面关闭前释放资源
window.addEventListener('beforeunload', () => {
if (video.srcObject) {
const tracks = video.srcObject.getTracks();
tracks.forEach(track => track.stop());
}
if (animationFrameId) cancelAnimationFrame(animationFrameId);
});
// 启动所有
init();
})();
</script>
</body>
</html>
```
备注
核心功能与操作流程
您可以通过"抽人"按钮随机选择当前画面中的一张人脸,并查看其截图。整个流程清晰且反馈即时。
· 人脸检测与显示:页面会实时显示摄像头画面,并用蓝色框标记出检测到的所有人脸。左上角会同步显示当前检测到的人脸数量。
· 随机抽取与截图:点击 "抽人" 按钮,系统会从当前检测到的人脸中随机选取一个,并将其面部区域放大截图,显示在下方区域,同时会有一个"被抽中"的角标提示。
· 重置与重抽:点击 "返回重抽" 按钮,可以清除当前截图,恢复初始状态,以便您进行新一轮的抽取。
可以根据需要调整 DETECT_OPTIONS 中的 minConfidence(置信度阈值,0-1)来改变人脸检测的灵敏度。数值越高,检测越严格。