[CF]完成game
This commit is contained in:
parent
37dc1c5a76
commit
193fe94053
1004
game/game.html
Normal file
1004
game/game.html
Normal file
File diff suppressed because it is too large
Load Diff
78
game/index.html
Normal file
78
game/index.html
Normal file
@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>动态手势分类器</title> <!-- 标题改为手势 -->
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>动态手势分类器</h1> <!-- 标题改为手势 -->
|
||||
<p>一个使用 TensorFlow.js 和 MediaPipe Hands 实现的实时手势训练与推理工具</p>
|
||||
</header>
|
||||
|
||||
<main id="main-container">
|
||||
<div id="video-wrapper">
|
||||
<div id="status">正在加载模型,请稍候...</div>
|
||||
<div id="video-container">
|
||||
<video id="video" width="640" height="480" autoplay muted playsinline></video>
|
||||
<canvas id="canvas" width="640" height="480"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="controls-panel">
|
||||
<h2>控制面板</h2>
|
||||
|
||||
<div class="control-section" id="training-section">
|
||||
<h3>第一步: 训练模型</h3>
|
||||
<p>点击下方按钮添加手势分类,为每个手势采集足够样本。</p>
|
||||
|
||||
<div id="pose-classes-container">
|
||||
<!-- JavaScript 将在此处动态生成类别UI -->
|
||||
</div>
|
||||
|
||||
<div class="add-class-wrapper">
|
||||
<button id="btn-add-class" class="btn-add-class" disabled>+ 增加分类</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-section">
|
||||
<h3>模型管理</h3>
|
||||
<div class="model-controls">
|
||||
<button id="btn-export" class="btn-sample" disabled>导出模型</button>
|
||||
<button id="btn-import" class="btn-sample" disabled>导入模型</button>
|
||||
<input type="file" id="file-importer" accept=".json" style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="control-section" id="inference-section">
|
||||
<h3>第二步: 开始推理</h3>
|
||||
<p>训练完成后,点击下方按钮开始实时预测。</p>
|
||||
<button id="btn-predict" class="btn-predict" disabled>开始预测</button>
|
||||
<div id="result-container">
|
||||
<strong>预测结果:</strong>
|
||||
<div id="result-text">尚未开始</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 引入所有依赖库 -->
|
||||
<!-- TensorFlow.js 核心库 (包含了tfjs-core, tfjs-converter, tfjs-backend-webgl) -->
|
||||
<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>
|
||||
|
||||
<!-- 引入我们自己的逻辑脚本 -->
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
434
game/script.js
Normal file
434
game/script.js
Normal file
@ -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 = `
|
||||
<div class="class-info">
|
||||
<input type="text" class="class-name-input" value="${cName}" data-class-id="${cId}">
|
||||
<span class="sample-count">(0 样本)</span>
|
||||
</div>
|
||||
<div class="class-actions">
|
||||
<button class="btn-sample" data-class-id="${cId}">采集样本</button>
|
||||
<button class="btn-delete-class" title="删除类别" data-class-id="${cId}">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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();
|
256
game/style.css
Normal file
256
game/style.css
Normal file
@ -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;
|
||||
}
|
38
new/index.html
Normal file
38
new/index.html
Normal file
@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hand Pose Detection</title>
|
||||
<style>
|
||||
body { margin: 0; overflow: hidden; display: flex; justify-content: center; align-items: center; min-height: 100vh; background-color: #f0f0f0; }
|
||||
#container { position: relative; width: auto; height: auto; max-width: 100%; max-height: 100vh; }
|
||||
video, canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
display: block;
|
||||
}
|
||||
canvas { z-index: 10; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<video id="webcam" autoplay playsinline muted></video>
|
||||
<canvas id="output"></canvas>
|
||||
</div>
|
||||
<!-- 引入 TensorFlow.js 核心库 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-converter"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl"></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>
|
||||
|
||||
<script src="script.js"></script> <!-- 你的主 JavaScript 文件 -->
|
||||
</body>
|
||||
</html>
|
122
new/script.js
Normal file
122
new/script.js
Normal file
@ -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();
|
Loading…
x
Reference in New Issue
Block a user