From 193fe940535f22efa57c46df8773769c250c451a Mon Sep 17 00:00:00 2001 From: 51hhh Date: Tue, 19 Aug 2025 15:32:42 +0800 Subject: [PATCH] =?UTF-8?q?[CF]=E5=AE=8C=E6=88=90game?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/game.html | 1004 +++++++++++++++++++++++++++++++++++++++++++++++ game/index.html | 78 ++++ game/script.js | 434 ++++++++++++++++++++ game/style.css | 256 ++++++++++++ new/index.html | 38 ++ new/script.js | 122 ++++++ 6 files changed, 1932 insertions(+) create mode 100644 game/game.html create mode 100644 game/index.html create mode 100644 game/script.js create mode 100644 game/style.css create mode 100644 new/index.html create mode 100644 new/script.js 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
+
+ + +
+
AI
+
+
+ + +
+
玩家
+
+ +
+ + +
+ +
+
+ + +
+ + +
+ + +
+
3
+
+ + +
+
WIN
+ +
+ + + + 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 @@ + + + + + + 动态手势分类器 + + + +
+

动态手势分类器

+

一个使用 TensorFlow.js 和 MediaPipe Hands 实现的实时手势训练与推理工具

+
+ +
+
+
正在加载模型,请稍候...
+
+ + +
+
+ +
+

控制面板

+ +
+

第一步: 训练模型

+

点击下方按钮添加手势分类,为每个手势采集足够样本。

+ +
+ +
+ +
+ +
+
+ +
+

模型管理

+
+ + + +
+
+ + +
+

第二步: 开始推理

+

训练完成后,点击下方按钮开始实时预测。

+ +
+ 预测结果: +
尚未开始
+
+
+
+
+ + + + + + + + + + + + + + + + + + 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();