/**
* =============================================================================
* 动态版 - 手部姿态识别与模型管理脚本 (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();