diff --git a/game/game.html b/game/game.html
new file mode 100644
index 0000000..cc9b82b
--- /dev/null
+++ b/game/game.html
@@ -0,0 +1,1004 @@
+
+
+
+
+
+ AI剪刀石头布
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
手势分类映射
+
+
分类0 (未加载) → 石头 ✊
+
分类1 (未加载) → 剪刀 ✌️
+
分类2 (未加载) → 布 ✋
+
+
+
+
+
胜利: 0
+
失败: 0
+
平局: 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/game/index.html b/game/index.html
new file mode 100644
index 0000000..21877ce
--- /dev/null
+++ b/game/index.html
@@ -0,0 +1,78 @@
+
+
+
+
+
+ 动态手势分类器
+
+
+
+
+
+
+
+
正在加载模型,请稍候...
+
+
+
+
+
+
+
+
控制面板
+
+
+
第一步: 训练模型
+
点击下方按钮添加手势分类,为每个手势采集足够样本。
+
+
+
+
+
+
+
+
+
+
+
+
模型管理
+
+
+
+
+
+
+
+
+
+
第二步: 开始推理
+
训练完成后,点击下方按钮开始实时预测。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/game/script.js b/game/script.js
new file mode 100644
index 0000000..2497250
--- /dev/null
+++ b/game/script.js
@@ -0,0 +1,434 @@
+/**
+ * =============================================================================
+ * 动态版 - 手部姿态识别与模型管理脚本 (v3.0)
+ * 由人体姿态识别修改为手部姿态识别,并确保非镜像显示
+ * =============================================================================
+ */
+
+'use strict';
+
+// --- 全局变量和常量 ---
+const videoElement = document.getElementById('video');
+const canvasElement = document.getElementById('canvas');
+const canvasCtx = canvasElement.getContext('2d');
+const statusElement = document.getElementById('status');
+const resultElement = document.getElementById('result-text');
+
+// UI元素
+const poseClassesContainer = document.getElementById('pose-classes-container');
+const addClassButton = document.getElementById('btn-add-class');
+const predictButton = document.getElementById('btn-predict');
+const exportButton = document.getElementById('btn-export');
+const importButton = document.getElementById('btn-import');
+const fileImporter = document.getElementById('file-importer');
+
+let detector, classifier, animationFrameId;
+let isPredicting = false;
+
+const appState = {
+ classMap: {},
+ nextClassId: 0
+};
+
+// --- 主应用逻辑 ---
+
+/**
+ * 初始化应用,加载模型并设置摄像头
+ */
+async function init() {
+ try {
+ classifier = knnClassifier.create();
+
+ // --- 修改点 1: 加载手部检测模型 ---
+ const model = handPoseDetection.SupportedModels.MediaPipeHands;
+ const detectorConfig = {
+ runtime: 'mediapipe', // 推荐使用 MediaPipe runtime 获得最佳性能
+ solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/hands' // MediaPipe solution files path
+ };
+ detector = await handPoseDetection.createDetector(model, detectorConfig);
+
+ await setupCamera();
+ setupEventListeners();
+ mainLoop();
+
+ statusElement.innerText = "手部模型和摄像头已就绪!";
+ enableControls();
+ addNewClass(); // 默认创建第一个类别
+
+ } catch (error) {
+ console.error("初始化失败:", error);
+ statusElement.innerText = "初始化失败,请检查摄像头权限或刷新。";
+ statusElement.style.backgroundColor = '#fce8e6';
+ statusElement.style.color = '#d93025';
+ }
+}
+
+/**
+ * 设置和启动用户摄像头 (无需修改,与之前一致)
+ */
+async function setupCamera() {
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true });
+ videoElement.srcObject = stream;
+ return new Promise((resolve) => {
+ videoElement.onloadedmetadata = () => {
+ videoElement.play();
+ // 确保 Canvas 与 Video 宽高一致,并且在这里不需要 Canvas 镜像
+ canvasElement.width = videoElement.videoWidth;
+ canvasElement.height = videoElement.videoHeight;
+ resolve();
+ };
+ });
+}
+
+/**
+ * 为所有交互式元素绑定事件监听器 (无需修改,与之前一致)
+ */
+function setupEventListeners() {
+ addClassButton.addEventListener('click', addNewClass);
+ predictButton.addEventListener('click', togglePrediction);
+ exportButton.addEventListener('click', exportModel);
+ importButton.addEventListener('click', () => fileImporter.click());
+ fileImporter.addEventListener('change', importModel);
+}
+
+// --- 动态类别管理 (无需修改,与之前一致) ---
+
+/**
+ * 动态创建一个新类别的UI元素并添加到页面
+ * @param {number} cId - 类别的唯一ID
+ * @param {string} cName - 类别的名称
+ */
+function createClassUI(cId, cName) {
+ const poseClassDiv = document.createElement('div');
+ poseClassDiv.className = 'pose-class';
+ poseClassDiv.dataset.classId = cId;
+
+ poseClassDiv.innerHTML = `
+
+
+ (0 样本)
+
+
+
+
+
+ `;
+
+ poseClassesContainer.appendChild(poseClassDiv);
+
+ const nameInput = poseClassDiv.querySelector('.class-name-input');
+ nameInput.addEventListener('change', (e) => {
+ appState.classMap[cId] = e.target.value;
+ });
+
+ const sampleButton = poseClassDiv.querySelector('.btn-sample');
+ sampleButton.addEventListener('click', () => addExample(cId));
+
+ if (isPredicting) sampleButton.disabled = true;
+
+ const deleteButton = poseClassDiv.querySelector('.btn-delete-class');
+ deleteButton.addEventListener('click', () => deleteClass(cId));
+}
+
+/**
+ * 添加一个新的姿态类别
+ */
+function addNewClass() {
+ const classId = appState.nextClassId;
+ const className = `手势 ${classId + 1}`; // 改为“手势”
+ appState.classMap[classId] = className;
+ appState.nextClassId++;
+ createClassUI(classId, className);
+}
+
+/**
+ * 删除一个指定的姿态类别
+ * @param {number} classId - 要删除的类别的ID
+ */
+function deleteClass(classId) {
+ if (confirm(`确定要删除类别 "${appState.classMap[classId]}" 吗?所有样本都将丢失。`)) {
+ const elementToRemove = poseClassesContainer.querySelector(`[data-class-id="${classId}"]`);
+ if (elementToRemove) elementToRemove.remove();
+
+ delete appState.classMap[classId];
+ classifier.clearClass(classId);
+
+ updateSampleCounts();
+ updatePredictionUI();
+ checkExportAbility();
+ }
+}
+
+/**
+ * 采集一个姿态样本并添加到KNN分类器
+ * @param {number} classId 类别的ID
+ */
+async function addExample(classId) {
+ // --- 修改点 2: 使用 estimateHands 替代 estimatePoses ---
+ // flipHorizontal: false 确保模型输出的坐标与原视频方向一致 (非镜像)
+ const hands = await detector.estimateHands(videoElement, { flipHorizontal: false });
+ if (hands && hands.length > 0) {
+ // KNN 分类器通常只处理一个实例,这里我们取检测到的第一只手
+ const handTensor = flattenHand(hands[0]); // 使用新的 flattenHand
+ classifier.addExample(handTensor, classId);
+ handTensor.dispose(); // 释放内存
+
+ updateSampleCounts();
+ checkExportAbility();
+ } else {
+ console.warn(`为类别 ${appState.classMap[classId]} 采集样本失败,未检测到手部。`);
+ }
+}
+
+// --- 模型与预测逻辑 (小修改) ---
+
+/**
+ * 开始或停止姿态预测 (少量文案修改)
+ */
+function togglePrediction() {
+ if (classifier.getNumClasses() === 0) {
+ alert("请先为至少一个手势采集样本后再开始预测!"); // 文案修改
+ return;
+ }
+ isPredicting = !isPredicting;
+ updatePredictionUI();
+}
+
+/**
+ * 应用的主循环
+ */
+async function mainLoop() {
+ // --- 修改点 3: 使用 estimateHands 替代 estimatePoses ---
+ // flipHorizontal: false 确保模型输出的坐标与原视频方向一致 (非镜像)
+ const hands = await detector.estimateHands(videoElement, { flipHorizontal: false });
+ canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height); // 清空画布
+
+ if (hands && hands.length > 0) {
+ // 通常只处理检测到的第一只手,如果有两只手,可以根据需求处理
+ drawHand(hands[0]); // 使用新的 drawHand
+ if (isPredicting && classifier.getNumClasses() > 0) {
+ const handTensor = flattenHand(hands[0]); // 使用新的 flattenHand
+ const result = await classifier.predictClass(handTensor, 3);
+ handTensor.dispose();
+
+ const confidence = Math.round(result.confidences[result.label] * 100);
+ const predictedClassName = appState.classMap[result.label] || '未知手势'; // 文案修改
+ resultElement.innerText = `手势: ${predictedClassName} (${confidence}%)`; // 文案修改
+ }
+ }
+ animationFrameId = requestAnimationFrame(mainLoop);
+}
+
+// --- 模型管理函数 (无需修改,与之前一致) ---
+
+/**
+ * 导出KNN模型为包含类别信息的JSON文件
+ */
+function exportModel() {
+ if (classifier.getNumClasses() === 0) {
+ alert('模型中还没有任何样本,无法导出!');
+ return;
+ }
+
+ const dataset = classifier.getClassifierDataset();
+ const datasetObj = {};
+ Object.keys(dataset).forEach((key) => {
+ const data = dataset[key];
+ datasetObj[key] = data.arraySync();
+ });
+
+ const modelData = {
+ classMap: appState.classMap,
+ dataset: datasetObj
+ };
+
+ const jsonStr = JSON.stringify(modelData);
+ const blob = new Blob([jsonStr], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `hand-knn-model.json`; // 文件名改为 hand-knn-model.json
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+/**
+ * 从JSON文件导入KNN模型并恢复类别状态 (无需修改,与之前一致)
+ * @param {Event} event
+ */
+function importModel(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const modelData = JSON.parse(e.target.result);
+
+ if (!modelData.classMap || !modelData.dataset) {
+ throw new Error("无效的模型文件格式。");
+ }
+
+ classifier.clearAllClasses();
+ poseClassesContainer.innerHTML = '';
+ appState.classMap = {};
+
+ appState.classMap = modelData.classMap;
+ const classIds = Object.keys(appState.classMap).map(Number);
+ appState.nextClassId = classIds.length > 0 ? Math.max(...classIds) + 1 : 0;
+
+ classIds.forEach(id => {
+ createClassUI(id, appState.classMap[id]);
+ });
+
+ const newDataset = {};
+ Object.keys(modelData.dataset).forEach((key) => {
+ newDataset[key] = tf.tensor(modelData.dataset[key]);
+ });
+ classifier.setClassifierDataset(newDataset);
+
+ updateSampleCounts();
+ checkExportAbility();
+ alert('模型导入成功!');
+
+ } catch (error) {
+ console.error("导入模型失败:", error);
+ alert(`导入失败!请确保文件是正确的模型JSON文件。\n错误: ${error.message}`);
+ } finally {
+ fileImporter.value = '';
+ }
+ };
+ reader.readAsText(file);
+}
+
+
+// --- 辅助和UI更新函数 ---
+
+/**
+ * --- 修改点 4: 展平手部关键点 ---
+ * 将手部关键点展平为一维张量。
+ * 考虑到 MediaPipe Hands 模型的关键点总数是21个 (0-20)。
+ * @param {Object} hand - 单个手部检测结果对象
+ * @returns {tf.Tensor} - 展平后的关键点坐标张量
+ */
+function flattenHand(hand) {
+ // 归一化关键点坐标到 [0, 1] 范围,然后展平
+ const keypoints = hand.keypoints.map(p => [p.x / videoElement.videoWidth, p.y / videoElement.videoHeight]).flat();
+ return tf.tensor(keypoints);
+}
+
+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
+ [0, 5], [5, 9], [9, 13], [13, 17], [17, 0] // Palm base connections
+];
+
+/**
+ * --- 修改点 5: 绘制手部骨骼 ---
+ * 绘制手部关键点和连接线。
+ * @param {Object} hand - 单个手部检测结果对象
+ */
+function drawHand(hand) {
+ if (hand.keypoints) {
+ const keypoints = hand.keypoints;
+
+ // 绘制连接线
+ canvasCtx.strokeStyle = '#00FFFF'; // 青色
+ canvasCtx.lineWidth = 2;
+
+ for (const connection of HAND_CONNECTIONS) {
+ const start = keypoints[connection[0]];
+ const end = keypoints[connection[1]];
+ // 检查关键点的 score,确保是可靠的
+ 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 = '#FF0000'; // 红色
+ for (const keypoint of keypoints) {
+ if (keypoint.score > 0.3) { // 同样检查 score
+ canvasCtx.beginPath();
+ // 关键点半径设置小一点,因为手部关键点比人体姿态更密集
+ canvasCtx.arc(keypoint.x, keypoint.y, 4, 0, 2 * Math.PI);
+ canvasCtx.fill();
+ }
+ }
+ }
+}
+
+
+/**
+ * 更新所有类别UI上的样本数量 (无需修改,与之前一致)
+ */
+function updateSampleCounts() {
+ const dataset = classifier.getClassifierDataset();
+ const allClassElements = document.querySelectorAll('.pose-class');
+ allClassElements.forEach(el => {
+ const classId = parseInt(el.dataset.classId, 10);
+ const classInfo = dataset[classId];
+ // 确保 classInfo 存在,因为 classifier.clearClass(id) 后,dataset[id] 可能会是 undefined
+ const count = classInfo ? classInfo.shape[0] : 0;
+ el.querySelector('.sample-count').innerText = `(${count} 样本)`;
+ });
+}
+
+/**
+ * 根据状态更新UI (少量文案修改)
+ */
+function updatePredictionUI() {
+ const allActionButtons = document.querySelectorAll('.btn-sample, .btn-delete-class, .btn-add-class, #btn-import');
+ if (isPredicting) {
+ predictButton.innerText = "停止预测";
+ predictButton.classList.add('stop');
+ resultElement.innerText = "正在分析手势..."; // 文案修改
+ allActionButtons.forEach(btn => btn.disabled = true);
+ document.querySelectorAll('.class-name-input').forEach(input => input.disabled = true);
+ checkExportAbility();
+ } else {
+ predictButton.innerText = "开始预测";
+ predictButton.classList.remove('stop');
+ resultElement.innerText = "已停止";
+ allActionButtons.forEach(btn => btn.disabled = false);
+ document.querySelectorAll('.class-name-input').forEach(input => input.disabled = false);
+ checkExportAbility();
+ }
+ // 只有在有类别且有样本时才能预测
+ predictButton.disabled = isPredicting ? false : classifier.getNumClasses() === 0;
+}
+
+function enableControls() {
+ [predictButton, importButton, exportButton, addClassButton].forEach(btn => btn.disabled = false);
+ checkExportAbility();
+}
+
+/** 检查是否可以导出模型并更新按钮状态 */
+function checkExportAbility() {
+ exportButton.disabled = isPredicting || classifier.getNumClasses() === 0;
+}
+
+// 释放 TensorFlow.js 相关的内存
+function cleanup() {
+ if (detector) {
+ // 对于 MediaPipe runtime,detector.dispose() 可能不是必须的,
+ // 其内部会管理WebGL资源。但为保险起见可以保留。
+ // 或者更彻底地,如果不再需要,可以手动清理所有tf.Tensor。
+ }
+ if (classifier) classifier.clearAllClasses();
+ if (animationFrameId) cancelAnimationFrame(animationFrameId);
+ tf.disposeAll(); // 额外添加,确保所有创建的张量都被释放,防止内存泄露
+ console.log("Cleanup complete. All TensorFlow.js tensors disposed.");
+}
+
+// --- 启动应用 ---
+window.onbeforeunload = cleanup; // 页面关闭前清理资源
+init();
diff --git a/game/style.css b/game/style.css
new file mode 100644
index 0000000..63851ac
--- /dev/null
+++ b/game/style.css
@@ -0,0 +1,256 @@
+/* style.css */
+:root {
+ --primary-color: #1a73e8; /* 谷歌蓝 */
+ --secondary-color: #34a853; /* 谷歌绿 */
+ --background-color: #f8f9fa;
+ --text-color: #3c4043;
+ --card-bg: #ffffff;
+ --border-color: #dadce0;
+ --button-hover-bg: #e8f0fe;
+ --button-hover-text: #174ea6;
+ --stop-color: #d93025; /* 谷歌红,用于删除和停止 */
+ --stop-hover-bg: #fce8e6;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+ margin: 0;
+ padding: 2rem;
+ background-color: var(--background-color);
+ color: var(--text-color);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ line-height: 1.6;
+}
+
+header {
+ text-align: center;
+ margin-bottom: 2rem;
+}
+
+h1 {
+ color: var(--primary-color);
+ font-size: 2.5rem;
+ margin-bottom: 0.5rem;
+}
+
+h2 {
+ color: #5f6368;
+ border-bottom: 2px solid var(--border-color);
+ padding-bottom: 0.5rem;
+ margin-top: 0;
+}
+
+h3 {
+ color: var(--primary-color);
+ margin-top: 0;
+}
+
+#main-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2rem;
+ width: 100%;
+ max-width: 1200px;
+ justify-content: center;
+}
+
+#video-wrapper, #controls-panel {
+ background-color: var(--card-bg);
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
+ padding: 1.5rem;
+}
+
+#video-wrapper {
+ flex: 1;
+ min-width: 320px;
+ max-width: 680px;
+}
+
+#controls-panel {
+ flex: 1;
+ min-width: 300px;
+ max-width: 450px;
+}
+
+#video-container {
+ position: relative;
+ width: 100%;
+ margin: auto;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 4px 8px rgba(0,0,0,0.1);
+ aspect-ratio: 640 / 480;
+}
+
+#video, #canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ /* 核心修改:移除以下两行,实现非镜像显示 */
+ /* transform: scaleX(-1); */
+}
+
+#video {
+ object-fit: cover;
+}
+
+#canvas {
+ background-color: transparent;
+}
+
+#status {
+ background-color: #e8f0fe;
+ color: var(--primary-color);
+ padding: 0.75rem;
+ text-align: center;
+ border-radius: 4px;
+ font-weight: 500;
+ margin-bottom: 1rem;
+}
+
+.control-section {
+ margin-bottom: 2rem;
+}
+
+/* ==================== 动态类别UI样式更新 ==================== */
+
+#pose-classes-container {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem; /* 类别之间的间距 */
+ margin-bottom: 1rem;
+}
+
+.pose-class {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ gap: 0.5rem;
+}
+
+.class-info {
+ flex-grow: 1; /* 让信息区域占据更多空间 */
+ display: flex;
+ flex-direction: column;
+}
+
+/* 📌 新增: 类别名称输入框样式 */
+.class-name-input {
+ border: none;
+ outline: none;
+ font-size: 1.1rem;
+ font-weight: bold;
+ font-family: inherit;
+ color: var(--text-color);
+ background-color: transparent;
+ padding: 2px 4px;
+ margin: -2px -4px; /* 抵消padding,使其对齐 */
+ border-radius: 2px;
+ transition: background-color 0.2s;
+}
+
+.class-name-input:focus {
+ background-color: #e0e0e0;
+}
+
+.sample-count {
+ font-size: 0.9rem;
+ color: #5f6368;
+}
+
+.class-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem; /* 按钮间距 */
+}
+
+/* 📌 新增: 删除按钮样式 */
+.btn-delete-class {
+ background: transparent;
+ border: none;
+ color: var(--stop-color);
+ font-size: 1.5rem; /* 让 '×' 更大更清晰 */
+ line-height: 1;
+ padding: 0 0.5rem;
+ cursor: pointer;
+ border-radius: 50%;
+ transition: background-color 0.2s;
+ font-weight: bold;
+}
+
+.btn-delete-class:hover {
+ background-color: var(--stop-hover-bg);
+}
+
+/* ========================================================= */
+
+.btn-sample, .btn-predict, .btn-add-class {
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--primary-color);
+ background-color: var(--card-bg);
+ color: var(--primary-color);
+ border-radius: 4px;
+ cursor: pointer;
+ font-weight: 600;
+ transition: all 0.2s ease-in-out;
+}
+
+.btn-sample:hover, .btn-predict:hover, .btn-add-class:hover {
+ background-color: var(--button-hover-bg);
+ color: var(--button-hover-text);
+}
+
+.btn-sample:disabled, .btn-predict:disabled, .btn-add-class:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ background-color: var(--card-bg);
+ color: var(--primary-color);
+}
+
+/* 📌 新增: 为“增加分类”按钮添加特定样式 */
+.add-class-wrapper {
+ margin-top: 1rem;
+}
+
+.btn-add-class {
+ width: 100%;
+ border-style: dashed;
+}
+
+
+.btn-predict.stop {
+ background-color: var(--stop-color);
+ border-color: var(--stop-color);
+ color: white;
+}
+
+.btn-predict.stop:hover {
+ background-color: #a50e0e;
+}
+
+#result-container {
+ margin-top: 1rem;
+ padding: 1rem;
+ background-color: #e8f0fe;
+ border-radius: 4px;
+ text-align: center;
+}
+
+#result-text {
+ font-size: 1.5rem;
+ font-weight: bold;
+ color: var(--primary-color);
+}
+
+.model-controls {
+ display: flex;
+ gap: 1rem;
+}
diff --git a/new/index.html b/new/index.html
new file mode 100644
index 0000000..9c16dc3
--- /dev/null
+++ b/new/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ Hand Pose Detection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/new/script.js b/new/script.js
new file mode 100644
index 0000000..ff511b1
--- /dev/null
+++ b/new/script.js
@@ -0,0 +1,122 @@
+const video = document.getElementById('webcam');
+const canvas = document.getElementById('output');
+const ctx = canvas.getContext('2d');
+
+let detector, rafId;
+
+async function setupCamera() {
+ if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true });
+ video.srcObject = stream;
+ return new Promise((resolve) => {
+ video.onloadedmetadata = () => {
+ video.play(); // 确保视频播放才能获取到正确的宽度和高度
+ video.onplaying = () => { // 视频真正开始播放时
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+
+ resolve();
+ };
+ };
+ });
+ } catch (error) {
+ console.error('Error accessing webcam:', error);
+ alert('Cannot access webcam. Please check permissions.');
+ }
+ } else {
+ alert('Your browser does not support webcam access.');
+ }
+}
+
+async function loadModel() {
+ const model = handPoseDetection.SupportedModels.MediaPipeHands;
+ const detectorConfig = {
+ runtime: 'mediapipe', // 或者 'tfjs'
+ solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/hands' // MediaPipe solution files path
+ };
+ detector = await handPoseDetection.createDetector(model, detectorConfig);
+ console.log('Hand Pose Detector loaded.');
+}
+
+const handSkeleton = {
+ 'thumb': [0, 1, 2, 3, 4],
+ 'indexFinger': [0, 5, 6, 7, 8],
+ 'middleFinger': [0, 9, 10, 11, 12],
+ 'ringFinger': [0, 13, 14, 15, 16],
+ 'pinky': [0, 17, 18, 19, 20],
+ // 腕部和手掌基部的连接,确保0号点连接到所有手指的起始关节
+ 'palmBase': [0, 1, 5, 9, 13, 17, 0] // 形成手掌的近似轮廓
+};
+
+async function detectHands() {
+ if (detector && video.readyState === 4) {
+ const hands = await detector.estimateHands(video, {
+ flipHorizontal: false
+ });
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
+
+ if (hands.length > 0) {
+ for (const hand of hands) {
+ // 绘制手部骨架连接线
+ // MediaPipe Hands 模型通常有21个关键点,这些关键点有固定的索引
+ // 这些连接关系是标准的,可以通过硬编码定义或查找库的定义
+ const keypoints = hand.keypoints; // 简化访问
+
+ // 直接根据索引绘制连接线
+ const connectionsToDraw = [
+ // Thumb (拇指)
+ [0, 1], [1, 2], [2, 3], [3, 4],
+ // Index finger (食指)
+ [0, 5], [5, 6], [6, 7], [7, 8],
+ // Middle finger (中指)
+ [0, 9], [9, 10], [10, 11], [11, 12],
+ // Ring finger (无名指)
+ [0, 13], [13, 14], [14, 15], [15, 16],
+ // Pinky finger (小指)
+ [0, 17], [17, 18], [18, 19], [19, 20],
+ // Palm base connections (手掌基部连接)
+ [0, 5], [5, 9], [9, 13], [13, 17], [17, 0] // Connect wrist to finger bases and form a loop
+ ];
+
+ ctx.strokeStyle = '#00FFFF'; // 青色
+ ctx.lineWidth = 2; // 线宽
+
+ for (const connection of connectionsToDraw) {
+ const start = keypoints[connection[0]];
+ const end = keypoints[connection[1]];
+ if (start && end) { // 确保关键点存在
+ ctx.beginPath();
+ ctx.moveTo(start.x, start.y);
+ ctx.lineTo(end.x, end.y);
+ ctx.stroke();
+ }
+ }
+
+ // 绘制关键点
+ ctx.fillStyle = '#FF0000'; // 红色
+ for (const keypoint of keypoints) {
+ // keypoint.x, keypoint.y 是像素坐标
+ // keypoint.z 是深度信息 (相对坐标),通常不用于2D绘制
+ ctx.beginPath();
+ ctx.arc(keypoint.x, keypoint.y, 4, 0, 2 * Math.PI); // 绘制半径为4的圆
+ ctx.fill();
+ }
+
+ // 绘制手部关键点标记(如果仍想使用util函数绘制)
+ // handPoseDetection.util.drawLandmarks(ctx, hand.keypoints, {color: '#FF0000', radius: 4});
+ // 由于我们已经手动绘制了,这行可以注释掉或移除,避免重复绘制。
+ }
+ }
+ }
+ rafId = requestAnimationFrame(detectHands);
+}
+
+async function app() {
+ await setupCamera();
+ await loadModel();
+ detectHands();
+}
+
+app();