1005 lines
42 KiB
HTML
1005 lines
42 KiB
HTML
<!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 && start.score > 0.3 && end.score > 0.3) {
|
||
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.score > 0.3) { // 同样只绘制置信度高的关键点
|
||
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>
|