1005 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI剪刀石头布</title>
<style>
/* 保持大部分样式不变 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.game-container {
flex: 1;
display: flex;
flex-direction: column;
}
.npc-area {
flex: 1;
background: rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-bottom: 3px solid rgba(255, 255, 255, 0.3);
}
.player-area {
flex: 1;
background: rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.area-title {
color: white;
font-size: 24px;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.choice-display {
width: 150px;
height: 150px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 80px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
transition: transform 0.3s;
}
.countdown-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.countdown-number {
font-size: 200px;
color: white;
animation: pulse 1s ease-in-out;
}
@keyframes pulse {
0% { transform: scale(0.5); opacity: 0; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(1); opacity: 0.8; }
}
.result-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1001;
}
.result-text {
font-size: 100px;
font-weight: bold;
margin-bottom: 30px;
text-transform: uppercase;
animation: slideIn 0.5s ease-out;
}
.result-text.win {
color: #4CAF50;
text-shadow: 0 0 20px #4CAF50;
}
.result-text.lose {
color: #f44336;
text-shadow: 0 0 20px #f44336;
}
.result-text.draw {
color: #FFC107;
text-shadow: 0 0 20px #FFC107;
}
@keyframes slideIn {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.start-button {
padding: 15px 40px;
font-size: 24px;
background: linear-gradient(45deg, #2196F3, #21CBF3);
color: white;
border: none;
border-radius: 50px;
cursor: pointer;
box-shadow: 0 4px 15px rgba(33, 150, 243, 0.3);
transition: all 0.3s;
}
.start-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(33, 150, 243, 0.4);
}
.start-button:active {
transform: translateY(0);
}
.start-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.controls {
position: absolute;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 20px;
}
/* 移除手动选择按钮,游戏不再需要 */
.user-choice-buttons {
display: none;
}
.choice-btn {
display: none; /* 确保按钮不显示 */
}
.score-board {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.2);
padding: 15px 25px;
border-radius: 10px;
color: white;
font-size: 18px;
z-index: 100; /* 确保在视频上方 */
}
.score-item {
margin: 5px 0;
}
#video-feed {
position: fixed;
top: 20px;
left: 20px;
width: 320px;
height: 240px;
border: 3px solid white;
border-radius: 10px;
background: black;
z-index: 100;
display: block;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5);
}
#video-feed video {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 7px;
}
#video-feed canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 7px;
}
.gesture-status {
position: absolute;
bottom: -40px;
left: 0;
right: 0;
text-align: center;
color: white;
font-size: 12px;
background: rgba(0, 0, 0, 0.9);
padding: 8px 10px;
border-radius: 0 0 7px 7px;
height: 35px;
line-height: 18px;
font-weight: bold;
border-top: 2px solid #00ff00;
}
.gesture-indicator {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 30px;
display: none;
z-index: 10; /* 确保在 Canvas 骨骼之上 */
}
.mapping-info {
position: absolute;
bottom: 50px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 15px;
border-radius: 8px;
font-size: 12px;
line-height: 18px;
z-index: 101;
}
.mapping-info h4 {
margin: 0 0 5px 0;
color: #00ff00;
}
.mapping-item {
color: #fff;
}
.current-class-display {
position: fixed;
top: 20px;
left: 50%; /* 居中显示 */
transform: translateX(-50%); /* 居中显示 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
font-size: 16px;
font-weight: bold;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
display: none;
text-align: center;
min-width: 180px;
z-index: 102;
border: 2px solid white;
}
.class-label {
font-size: 24px;
color: #ffff00;
text-shadow: 0 0 10px rgba(255, 255, 0, 0.5);
margin-bottom: 5px;
font-weight: bold;
}
.class-detail {
font-size: 14px;
color: white;
margin-top: 5px;
}
/* 导入模型按钮样式 */
.import-model-controls {
position: absolute;
bottom: 80px; /* 调整位置避免覆盖其他元素 */
left: 10%;
top: 40%;
transform: translateX(-60%);
z-index: 100; /* 确保在其他元素之上 */
}
.import-button {
padding: 10px 20px;
font-size: 16px;
background: #FFC107; /* 橙色按钮 */
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
transition: background 0.3s, transform 0.2s;
}
.import-button:hover {
background: #FFA000;
transform: translateY(-2px);
}
.import-button:active {
transform: translateY(0);
}
.import-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
<!-- 引入 TensorFlow.js 核心库 -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script>
<!-- KNN 分类器 -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@1.2.2/dist/knn-classifier.min.js"></script>
<!-- 重点MediaPipe Hands 解决方案文件 -->
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands"></script>
<!-- TensorFlow Models - Hand Pose Detection 库 -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/hand-pose-detection"></script>
<!-- !!! 移除 modelData.js 的引入,因为现在通过文件导入 !!! -->
<!-- <script src="modelData.js"></script> -->
</head>
<body>
<div class="game-container">
<!-- 视频流显示 -->
<div id="video-feed">
<video id="video" autoplay muted playsinline></video>
<canvas id="canvas"></canvas>
<div class="gesture-indicator" id="gestureIndicator"></div>
<div class="gesture-status" id="gestureStatus">等待摄像头启动...</div>
</div>
<!-- 当前识别类别显示 -->
<div class="current-class-display" id="currentClassDisplay">
<div class="class-label" id="classLabel">-</div>
<div class="class-detail" id="classDetail">等待识别...</div>
</div>
<!-- 映射关系说明 -->
<div class="mapping-info" id="mappingInfo">
<h4>手势分类映射</h4>
<!-- JavaScript 会在此处更新实际的类别名称 -->
<div class="mapping-item" id="mapping0">分类0 (未加载) → 石头 ✊</div>
<div class="mapping-item" id="mapping1">分类1 (未加载) → 剪刀 ✌️</div>
<div class="mapping-item" id="mapping2">分类2 (未加载) → 布 ✋</div>
</div>
<!-- 计分板 -->
<div class="score-board">
<div class="score-item">胜利: <span id="winCount">0</span></div>
<div class="score-item">失败: <span id="loseCount">0</span></div>
<div class="score-item">平局: <span id="drawCount">0</span></div>
</div>
<!-- NPC区域 -->
<div class="npc-area">
<div class="area-title">AI</div>
<div class="choice-display" id="npcChoice"></div>
</div>
<!-- 玩家区域 -->
<div class="player-area">
<div class="area-title">玩家</div>
<div class="choice-display" id="playerChoice"></div>
<!-- 移除手动选择按钮,完全依靠手势识别 -->
</div>
<!-- 控制按钮 -->
<div class="controls">
<button class="start-button" id="startBtn" onclick="startGame()" disabled>开始游戏</button>
</div>
</div>
<!-- 导入模型控件 -->
<div class="import-model-controls">
<button class="import-button" id="btnImportModel">导入手势模型</button>
<input type="file" id="fileImporter" accept=".json" style="display: none;">
</div>
<!-- 倒计时遮罩 -->
<div class="countdown-overlay" id="countdownOverlay">
<div class="countdown-number" id="countdownNumber">3</div>
</div>
<!-- 结果遮罩 -->
<div class="result-overlay" id="resultOverlay">
<div class="result-text" id="resultText">WIN</div>
<button class="start-button" onclick="resetGame()">再来一局</button>
</div>
<script>
// 游戏状态变量
let userChoice = null;
let npcChoice = null;
let isGameActive = false; // 游戏是否处于进行中状态
let winCount = 0;
let loseCount = 0;
let drawCount = 0;
// 游戏选择映射 ID -> 表情符号)
const choices = {
1: '✊', // 石头 - 对应分类0
2: '✌️', // 剪刀 - 对应分类1
3: '✋' // 布 - 对应分类2
};
const choiceNames = {
1: '石头',
2: '剪刀',
3: '布'
};
// 手势识别相关变量
let detector = null; // MediaPipe Hands 检测器
let classifier = null; // KNN 分类器
let videoElement = null;
let canvasElement = null;
let canvasCtx = null;
let isHandDetectionReady = false; // 手势识别系统是否完全就绪 (指摄像头和检测器准备好)
let isModelLoaded = false; // 新增:是否已成功导入模型
let detectionLoopFrameId = null; // requestAnimationFrame 的 ID
let currentDetectedGesture = null; // 实时检测到的手势 (1:石头, 2:剪刀, 3:布)
let lockedGesture = null; // 倒计时结束后锁定的手势,作为玩家的选择
// 手势分类 ID 到游戏选择 ID 的映射
// 假设您的模型类别0是石头1是剪刀2是布
const gestureClassToGameChoice = {
'0': 1, // 对应石头
'1': 2, // 对应剪刀
'2': 3 // 对应布
};
// 存储实际导入模型中的分类名称,例如 {"0": "拳头", "1": "V字", "2": "手掌"}
let importedClassNames = {};
// DOM 元素引用
const startBtn = document.getElementById('startBtn');
const btnImportModel = document.getElementById('btnImportModel');
const fileImporter = document.getElementById('fileImporter');
// --- 游戏逻辑函数 ---
function startGame() {
if (!isHandDetectionReady) {
alert('手势识别系统未加载或未准备就绪,请稍后重试。');
return;
}
if (!isModelLoaded) { // 新增检查
alert('手势模型数据未导入!请点击下方的“导入手势模型”按钮。');
return;
}
// `classifier` 在 `initHandDetection` 中被 `knnClassifier.create()` 初始化,
// 确保它不为 null。之后检查 `getNumClasses()` 确认是否有训练数据。
if (!classifier || classifier.getNumClasses() === 0) {
alert('手势模型数据为空或不完整,无法开始游戏。请导入包含样本的模型。');
return;
}
userChoice = null;
npcChoice = null;
lockedGesture = null; // 重置锁定的手势
isGameActive = true; // 游戏状态设为活跃
startBtn.disabled = true; // 游戏开始时禁用开始按钮
btnImportModel.disabled = true; // 游戏开始时禁用导入按钮
document.getElementById('npcChoice').textContent = '❓';
document.getElementById('playerChoice').textContent = '❓';
document.getElementById('playerChoice').style.color = ''; // 恢复玩家选择图标颜色
// 显示倒计时遮罩
const countdownOverlay = document.getElementById('countdownOverlay');
const countdownNumber = document.getElementById('countdownNumber');
countdownOverlay.style.display = 'flex';
let count = 3;
countdownNumber.textContent = count;
const countdownInterval = setInterval(() => {
count--;
// 在倒计时到1时锁定手势给用户反应时间
if (count === 1) { // 倒计时到1秒时进行锁定
// 确保在倒计时结束前锁定玩家手势,避免结束瞬间手势变化
if (currentDetectedGesture) {
lockedGesture = currentDetectedGesture;
console.log(`倒计时1秒锁定玩家手势: ${choiceNames[lockedGesture]} (${choices[lockedGesture]})`);
// 在锁定后立即更新玩家显示,带上“已锁定”标记
document.getElementById('playerChoice').textContent = choices[lockedGesture] + ' ✓';
document.getElementById('playerChoice').style.color = '#00ff00'; // 绿色表示已锁定
} else {
// 如果在锁定时间点仍未检测到手势,则保持 null
console.log('倒计时1秒未检测到手势无法锁定。');
document.getElementById('playerChoice').textContent = '❓ (未识别)';
document.getElementById('playerChoice').style.color = '#ff8c00'; // 橙色表示未识别
}
}
if (count > 0) {
countdownNumber.textContent = count;
countdownNumber.style.animation = 'none'; // 重置动画以便再次触发
// 强制回流,确保动画能够再次播放
void countdownNumber.offsetWidth;
countdownNumber.style.animation = 'pulse 1s ease-in-out';
} else {
// 倒计时结束
clearInterval(countdownInterval);
countdownOverlay.style.display = 'none';
endGame();
}
}, 1000);
}
function endGame() {
isGameActive = false;
// 玩家手势最终确定:优先使用锁定的手势
if (lockedGesture) {
userChoice = lockedGesture;
} else if (currentDetectedGesture) {
// 如果在1秒时没有锁定到但倒计时结束后有也用当前的
userChoice = currentDetectedGesture;
} else {
// 极端情况:全程未识别到手势,则 userChoice 保持 null由 checkResult 处理
userChoice = null;
console.warn('全程未识别到玩家手势!');
}
// 更新玩家最终显示(去除“已锁定”标记)
if (userChoice) {
document.getElementById('playerChoice').textContent = choices[userChoice];
document.getElementById('playerChoice').style.color = ''; // 恢复默认颜色
} else {
document.getElementById('playerChoice').textContent = '❓';
document.getElementById('playerChoice').style.color = '#ff4500'; // 红色警告
}
// NPC 随机选择
npcChoice = Math.floor(Math.random() * 3) + 1;
document.getElementById('npcChoice').textContent = choices[npcChoice];
// 判断胜负
setTimeout(() => {
checkResult();
}, 500); // 稍作延迟让玩家和NPC选择显示出来
}
function checkResult() {
const resultOverlay = document.getElementById('resultOverlay');
const resultText = document.getElementById('resultText');
if (userChoice === null) {
resultText.textContent = '未识别到手势,本局无效';
resultText.className = 'result-text draw'; // 用draw的样式表示未分胜负
// 不计入任何胜负平统计,因为这是无效局,也可以根据需求决定是否计入
} else if (userChoice === npcChoice) {
resultText.textContent = '平局';
resultText.className = 'result-text draw';
drawCount++;
} else if (
(userChoice === 1 && npcChoice === 2) || // 石头胜剪刀
(userChoice === 2 && npcChoice === 3) || // 剪刀胜布
(userChoice === 3 && npcChoice === 1) // 布胜石头
) {
resultText.textContent = 'WIN';
resultText.className = 'result-text win';
winCount++;
} else {
resultText.textContent = 'LOSE';
resultText.className = 'result-text lose';
loseCount++;
}
updateScore(); // 更新计分板
resultOverlay.style.display = 'flex'; // 显示结果遮罩
}
function updateScore() {
document.getElementById('winCount').textContent = winCount;
document.getElementById('loseCount').textContent = loseCount;
document.getElementById('drawCount').textContent = drawCount;
}
function resetGame() {
document.getElementById('resultOverlay').style.display = 'none';
startBtn.disabled = !isModelLoaded; // 只有在模型加载后才能开始游戏
btnImportModel.disabled = isModelLoaded; // 模型加载后禁用导入按钮
document.getElementById('npcChoice').textContent = '❓';
document.getElementById('playerChoice').textContent = '❓';
document.getElementById('playerChoice').style.color = ''; // 重置颜色
userChoice = null;
npcChoice = null;
lockedGesture = null; // 重置锁定的手势
}
// --- 手势识别初始化与主循环 ---
async function initHandDetection() {
try {
console.log('初始化手势识别...');
document.getElementById('gestureStatus').textContent = '正在初始化摄像头和检测器...';
videoElement = document.getElementById('video');
canvasElement = document.getElementById('canvas');
canvasCtx = canvasElement.getContext('2d');
// 1. 设置摄像头
await setupCamera();
// 2. 创建KNN分类器
console.log('创建KNN分类器...');
classifier = knnClassifier.create(); // Classifier在这里被正确初始化所以不会是null
// 3. 加载手部检测模型
console.log('加载手部姿态检测器...');
document.getElementById('gestureStatus').textContent = '加载手部姿态检测器中...';
const model = handPoseDetection.SupportedModels.MediaPipeHands;
const detectorConfig = {
runtime: 'mediapipe', // 推荐使用 MediaPipe runtime
solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/hands',
modelType: 'full' // 可以指定模型类型,如 'full', 'lite', 'heavy'
};
detector = await handPoseDetection.createDetector(model, detectorConfig);
isHandDetectionReady = true; // 摄像头和检测器已就绪
console.log('手部检测器和摄像头已就绪。');
document.getElementById('gestureStatus').textContent = '手部检测器已就绪。请导入手势模型。';
// 启用导入模型按钮,禁用开始游戏按钮直到模型导入
startBtn.disabled = true;
btnImportModel.disabled = false;
// 4. 开始持续检测循环 (现在仅检测和绘制骨骼,不预测,直到模型导入)
startDetectionLoop();
} catch (error) {
console.error('手势识别初始化失败:', error);
document.getElementById('gestureStatus').textContent = `初始化失败: ${error.message}`;
alert(`手势识别初始化失败: ${error.message}\n请检查摄像头权限、网络连接或刷新页面。`);
}
}
async function setupCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: 'user' // 优先使用前置摄像头
}
});
videoElement.srcObject = stream;
return new Promise((resolve, reject) => {
videoElement.onloadedmetadata = () => {
videoElement.play().then(() => {
canvasElement.width = videoElement.videoWidth;
canvasElement.height = videoElement.videoHeight;
console.log(`摄像头启动成功,渲染分辨率: ${videoElement.videoWidth}x${videoElement.videoHeight}`);
resolve();
}).catch(reject); // 播放失败也拒绝 Promise
};
// 添加一个超时,防止 metadata 不加载或 play() 挂起
setTimeout(() => reject(new Error('摄像头元数据加载或播放超时')), 10000);
});
} catch (error) {
console.error('摄像头设置失败:', error);
if (error.name === 'NotAllowedError') {
throw new Error('用户拒绝了摄像头权限。');
} else if (error.name === 'NotFoundError') {
throw new Error('未找到摄像头设备。');
} else {
throw error;
}
}
}
// 修改 loadModel 函数,从文件事件中读取并加载模型
async function loadModelFromFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const loadedModelData = JSON.parse(e.target.result);
if (!loadedModelData || !loadedModelData.dataset || !loadedModelData.classMap) {
throw new Error('模型数据结构不正确 (缺少 classMap 或 dataset)。');
}
// 清除分类器之前可能存在的任何数据
classifier.clearAllClasses();
const dataset = {};
let totalExamples = 0;
// 恢复分类器数据集
for (const classId in loadedModelData.dataset) {
const classData = loadedModelData.dataset[classId];
if (classData && classData.length > 0) {
// 确保所有样本的特征长度一致,并且是数字数组
if (classData.some(sample => !Array.isArray(sample) || sample.some(val => typeof val !== 'number'))) {
throw new Error(`类别 ${classId} 包含无效的样本数据 (不是数字数组)。`);
}
const tensors = classData.map(data => tf.tensor1d(data));
// tf.stack 会将一组张量沿新轴堆叠起来
// 如果样本特征都是 1D 张量stack 后会变成 2D 张量 [numSamples, featureLength]
const stacked = tf.stack(tensors);
dataset[classId] = stacked;
totalExamples += classData.length;
// 及时清理临时张量
tensors.forEach(t => t.dispose());
} else {
console.warn(`类别 ${classId} 没有样本数据。`);
}
}
classifier.setClassifierDataset(dataset);
importedClassNames = loadedModelData.classMap; // 更新类别名称映射
console.log(`模型加载成功!共加载 ${totalExamples} 个样本。`);
console.log('类别映射 (导入):', importedClassNames);
// 更新页面上的映射显示
if (importedClassNames) {
document.getElementById('mapping0').innerHTML =
`<strong style="color: #ffff00;">分类0</strong> (${importedClassNames['0'] || '未定义'}) → 石头 ✊`;
document.getElementById('mapping1').innerHTML =
`<strong style="color: #ffff00;">分类1</strong> (${importedClassNames['1'] || '未定义'}) → 剪刀 ✌️`;
document.getElementById('mapping2').innerHTML =
`<strong style="color: #ffff00;">分类2</strong> (${importedClassNames['2'] || '未定义'}) → 布 ✋`;
}
document.getElementById('gestureStatus').textContent = '手势模型导入成功!可以开始游戏了。';
isModelLoaded = true; // 设置模型已加载状态
startBtn.disabled = false; // 启用开始游戏按钮
btnImportModel.disabled = true; // 导入按钮禁用
resolve();
} catch (error) {
console.error('模型加载失败:', error);
reject(new Error(`模型导入失败: ${error.message}`));
} finally {
fileImporter.value = ''; // 清空文件输入
}
};
reader.onerror = (error) => {
console.error('文件读取失败:', error);
reject(new Error('文件读取失败。'));
};
reader.readAsText(file);
});
}
// 文件选择事件处理器
async function handleModelImport(event) {
const file = event.target.files[0];
if (!file) return;
document.getElementById('gestureStatus').textContent = '正在导入模型...';
startBtn.disabled = true; // 导入中禁用开始按钮
btnImportModel.disabled = true; // 导入中禁用导入按钮
try {
await loadModelFromFile(file);
} catch (error) {
alert(error.message);
document.getElementById('gestureStatus').textContent = `导入失败: ${error.message}`;
startBtn.disabled = true; // 失败后仍保持禁用
btnImportModel.disabled = false; // 失败后可再次导入
}
}
// 提取手部特征 (与您原始项目的 flattenHand 保持一致的归一化)
function extractHandFeatures(hand) {
// MediaPipe Hands 模型的 x, y 坐标已经是像素坐标
// 所以这里直接除以视频宽高进行归一化
const keypoints = hand.keypoints.map(p => {
return [p.x / videoElement.videoWidth, p.y / videoElement.videoHeight];
}).flat();
return tf.tensor1d(keypoints); // 返回 1D 张量
}
// 持续检测和绘制循环
function startDetectionLoop() {
if (!isHandDetectionReady) return;
async function detectAndDraw() {
try {
// 检测手部,无需镜像翻转
const hands = await detector.estimateHands(videoElement, { flipHorizontal: false });
canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height); // 清空画布
if (hands && hands.length > 0) {
drawHands(hands); // 绘制手部骨骼
// 只有当有模型且游戏不处于倒计时锁定阶段时才进行预测
if (isModelLoaded && (!isGameActive || lockedGesture === null)) {
const features = extractHandFeatures(hands[0]); // 获取第一只手的特征
let prediction;
// 确保分类器有数据
if (classifier.getNumClasses() > 0) {
prediction = await classifier.predictClass(features);
features.dispose(); // 释放张量内存
const predictedClassId = prediction.label; // 例如 '0', '1', '2'
const confidence = (prediction.confidences[predictedClassId] * 100).toFixed(0);
// 将分类ID映射到游戏选择ID (1, 2, 3)
const gameChoice = gestureClassToGameChoice[predictedClassId];
// 设一个置信度阈值,避免误识别 (例如 70%)
if (gameChoice && confidence > 70) {
currentDetectedGesture = gameChoice;
const gestureName = choiceNames[gameChoice];
document.getElementById('gestureStatus').textContent =
`识别: ${importedClassNames[predictedClassId] || `未知类别${predictedClassId}`} (${gestureName}) 置信度: ${confidence}%`;
document.getElementById('gestureStatus').style.color = '#00ff00';
document.getElementById('gestureIndicator').textContent = choices[gameChoice];
document.getElementById('gestureIndicator').style.display = 'block';
// 更新顶部中间的当前类别显示卡片
const classDisplay = document.getElementById('currentClassDisplay');
if (classDisplay) {
classDisplay.style.display = 'block';
const classLabel = document.getElementById('classLabel');
const classDetail = document.getElementById('classDetail');
if (classLabel) classLabel.textContent = importedClassNames[predictedClassId] || `分类 ${predictedClassId}`;
if (classDetail) classDetail.innerHTML = `<strong>分类 ${predictedClassId}</strong> → ${gestureName} ${choices[gameChoice]}`;
// 动态改变背景颜色
const colors = {
'0': 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', // 石头 (示例颜色)
'1': 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', // 剪刀 (示例颜色)
'2': 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' // 布 (示例颜色)
};
classDisplay.style.background = colors[predictedClassId] || colors['0'];
}
} else {
currentDetectedGesture = null;
document.getElementById('gestureStatus').textContent = `识别不足 (置信度: ${confidence}%)`;
document.getElementById('gestureStatus').style.color = '#ffaa00'; // 橙色警告
document.getElementById('gestureIndicator').textContent = '❓';
document.getElementById('gestureIndicator').style.display = 'block'; // 保持显示,但显示问号
document.getElementById('currentClassDisplay').style.display = 'none'; // 隐藏卡片
}
} else {
features.dispose(); // 确保张量被释放
currentDetectedGesture = null;
document.getElementById('gestureStatus').textContent = '分类器未加载数据';
document.getElementById('gestureStatus').style.color = '#ff4500'; // 红色警告
document.getElementById('gestureIndicator').textContent = '❓';
document.getElementById('currentClassDisplay').style.display = 'none';
}
}
} else {
currentDetectedGesture = null;
document.getElementById('gestureStatus').textContent = '请将手放入画面';
document.getElementById('gestureStatus').style.color = '#cccccc';
document.getElementById('gestureIndicator').textContent = '❓';
document.getElementById('gestureIndicator').style.display = 'none';
document.getElementById('currentClassDisplay').style.display = 'none';
}
} catch (error) {
console.error('手势检测或预测出错:', error);
document.getElementById('gestureStatus').textContent = `检测错误: ${error.message}`;
document.getElementById('gestureStatus').style.color = '#ff4500';
}
detectionLoopFrameId = requestAnimationFrame(detectAndDraw); // 继续循环
}
detectAndDraw(); // 启动循环
}
// 绘制手部骨骼 (与您之前提供的 drawHand 函数 logic 相同)
const HAND_CONNECTIONS = [
[0, 1], [1, 2], [2, 3], [3, 4], // Thumb
[0, 5], [5, 6], [6, 7], [7, 8], // Index finger
[0, 9], [9, 10], [10, 11], [11, 12], // Middle finger
[0, 13], [13, 14], [14, 15], [15, 16], // Ring finger
[0, 17], [17, 18], [18, 19], [19, 20], // Pinky finger
// 掌心连接 (连接腕部到手指基部,形成手掌轮廓)
// 这些连接在 MediaPipe util.drawConnectors 中通常是默认包含的
// 这里手动添加以确保完整性
[0, 5], [5, 9], [9, 13], [13, 17] // 可选:[17, 0] 闭合手腕到小指的连接
];
function drawHands(hands) {
for (const hand of hands) {
if (!hand || !hand.keypoints) continue;
const keypoints = hand.keypoints;
// 绘制连接线
canvasCtx.strokeStyle = '#00ff00'; // 绿色骨骼线
canvasCtx.lineWidth = 3;
canvasCtx.shadowColor = '#00ff00';
canvasCtx.shadowBlur = 5; // 添加发光效果
for (const [startIdx, endIdx] of HAND_CONNECTIONS) {
const start = keypoints[startIdx];
const end = keypoints[endIdx];
// 只有当连接的两个关键点都存在
if (start && end) {
canvasCtx.beginPath();
canvasCtx.moveTo(start.x, start.y);
canvasCtx.lineTo(end.x, end.y);
canvasCtx.stroke();
}
}
// 绘制关键点
canvasCtx.fillStyle = '#ff0066'; // 粉红色关键点
canvasCtx.shadowColor = '#ff0066';
canvasCtx.shadowBlur = 10; // 关键点发光更强
for (const keypoint of keypoints) {
if (keypoint) { // 同样只绘制置信度高的关键点
canvasCtx.beginPath();
canvasCtx.arc(keypoint.x, keypoint.y, 6, 0, 2 * Math.PI); // 绘制半径为6的圆
canvasCtx.fill();
}
}
}
}
// --- 应用启动 ---
window.addEventListener('DOMContentLoaded', () => {
console.log('DOM content loaded. Initializing hand detection...');
// 初始时禁用开始按钮,直到模型导入
startBtn.disabled = true;
btnImportModel.disabled = false; // 初始时启用导入按钮
initHandDetection().catch(error => {
console.error('App initialization failed:', error);
// 错误信息已在 initHandDetection 内部处理并alert
});
// 绑定导入模型事件
fileImporter.addEventListener('change', handleModelImport);
btnImportModel.addEventListener('click', () => {
fileImporter.click(); // 点击按钮触发文件输入
});
});
// 页面关闭时清理资源 (可选,但推荐)
window.onbeforeunload = () => {
if (detectionLoopFrameId) {
cancelAnimationFrame(detectionLoopFrameId);
}
if (detector) {
// MediaPipe runtime 通常会管理其自己的 WebGL 资源,
// 明确调用 dispose() 可能会导致后续异常,如果 MediaPipe 内部未预期再次使用。
// 如果遇到问题,可以注释掉这行。
// detector.dispose();
}
if (classifier) {
classifier.clearAllClasses();
}
tf.disposeAll(); // 释放所有TensorFlow.js张量防止内存泄露
console.log('Resources cleaned up.');
};
</script>
</body>
</html>