[MF]修改从CDN自动加载模型

This commit is contained in:
51hhh 2025-08-25 14:16:06 +08:00
parent 9a3e94d318
commit 976e46387f
7 changed files with 739 additions and 406 deletions

View File

@ -71,10 +71,41 @@ function updateModelUI(isLoaded) {
async function initModel() { async function initModel() {
showStatus(MODEL_STATUS, 'info', '正在加载 MobileNet 模型...'); showStatus(MODEL_STATUS, 'info', '正在加载 MobileNet 模型...');
try { try {
// 确保 window.mobilenet 和 window.knnClassifier 库已加载
if (!window.mobilenet || !window.knnClassifier) {
showStatus(MODEL_STATUS, 'error', 'TensorFlow.js 库或 KNN 分类器库未加载。请检查 HTML 引入。');
console.error('TensorFlow.js 库或 KNN 分类器库未加载。');
return;
}
mobilenet = await window.mobilenet.load({ version: 2, alpha: 1.0 }); mobilenet = await window.mobilenet.load({ version: 2, alpha: 1.0 });
knnClassifier = window.knnClassifier.create(); knnClassifier = window.knnClassifier.create();
showStatus(MODEL_STATUS, 'success', 'MobileNet 模型和 KNN 分类器已加载。请加载模型文件。'); showStatus(MODEL_STATUS, 'success', 'MobileNet 模型和 KNN 分类器已加载。');
updateModelUI(false); // 此时 MobileNet 已加载,但 KNN 分类器本身还需加载数据 updateModelUI(false); // MobileNet 准备好但KNN数据还未加载
// ===== 新增:尝试自动从 CDN 加载 KNN 模型 =====
// 请替换为你的实际 CDN 模型路径
const cdnModelBaseUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/'; // 例如:'https://example.com/models/'
const cdnModelJsonFileName = 'knn-model.json'; // 你的模型json文件名
const cdnModelBinFileName = 'knn-model.bin'; // 你的模型bin文件名
const cdnJsonUrl = `${cdnModelBaseUrl}${cdnModelJsonFileName}`;
const cdnBinUrl = `${cdnModelBaseUrl}${cdnModelBinFileName}`;
console.log(`尝试从 CDN 加载模型: ${cdnJsonUrl}, ${cdnBinUrl}`);
showStatus(MODEL_STATUS, 'info', '正在尝试从 CDN 自动加载 KNN 模型...');
try {
await loadKNNModel(cdnJsonUrl, cdnBinUrl);
console.log('CDN 模型自动加载成功。');
// 如果成功loadKNNModel 会更新状态
} catch (cdnError) {
showStatus(MODEL_STATUS, 'warning', `从 CDN 加载 KNN 模型失败: ${cdnError.message}。您可以尝试手动加载。`);
console.warn('CDN KNN 模型加载失败:', cdnError);
updateModelUI(false); // 即使 CDN 失败,用户仍然可以通过按钮加载
}
// ==============================================
} catch (error) { } catch (error) {
showStatus(MODEL_STATUS, 'error', `模型加载失败: ${error.message}`); showStatus(MODEL_STATUS, 'error', `模型加载失败: ${error.message}`);
console.error('MobileNet/KNN加载失败:', error); console.error('MobileNet/KNN加载失败:', error);
@ -96,154 +127,218 @@ async function getFeatures(img) {
/** /**
* 加载 KNN 模型文件支持 '.json' '.bin' 两部分文件 * 加载 KNN 模型文件支持 '.json' '.bin' 两部分文件
* 用户需要依次选择对应的 .json .bin 文件 * 支持从 URL 或用户选择的文件加载
* @param {string} [jsonUrl] - 可选KNN 模型 .json 文件的 URL
* @param {string} [binUrl] - 可选KNN 模型 .bin 文件的 URL
*/ */
async function loadKNNModel() { async function loadKNNModel(jsonUrl = null, binUrl = null) {
const inputJson = document.createElement('input'); if (!knnClassifier) {
inputJson.type = 'file'; showStatus(MODEL_STATUS, 'error', 'KNN 分类器未初始化。请先加载 MobileNet 模型。');
inputJson.accept = '.json'; return;
inputJson.multiple = false; }
showStatus(MODEL_STATUS, 'info', '请先选择 KNN 模型配置文件 (.json)...'); let modelData = null;
let binData = null;
let modelName = '未知模型';
inputJson.onchange = async (e) => { try {
const jsonFile = e.target.files[0]; if (jsonUrl && binUrl) {
if (!jsonFile) { // 从 CDN URL 加载
showStatus(MODEL_STATUS, 'info', '未选择 .json 文件。'); showStatus(MODEL_STATUS, 'info', `正在从 CDN 加载模型配置文件 (${jsonUrl})...`);
updateModelUI(false); const jsonResponse = await fetch(jsonUrl);
if (!jsonResponse.ok) {
throw new Error(`无法从 ${jsonUrl} 加载.json文件: ${jsonResponse.statusText}`);
}
modelData = await jsonResponse.json();
modelName = jsonUrl.split('/').pop();
showStatus(MODEL_STATUS, 'info', `正在从 CDN 加载模型权重 (${binUrl})...`);
const binResponse = await fetch(binUrl);
if (!binResponse.ok) {
throw new Error(`无法从 ${binUrl} 加载.bin文件: ${binResponse.statusText}`);
}
const arrayBuffer = await binResponse.arrayBuffer();
binData = new Float32Array(arrayBuffer);
// 验证 bin 文件名是否匹配(如果 json 中有定义)
if (modelData.dataFile && !binUrl.endsWith(modelData.dataFile)) {
console.warn(`CDN 加载警告:.bin URL (${binUrl}) 与 .json 中定义的 dataFile (${modelData.dataFile}) 不匹配。继续加载。`);
}
} else {
// 从用户本地文件加载 (原逻辑不变)
const inputJson = document.createElement('input');
inputJson.type = 'file';
inputJson.accept = '.json';
inputJson.multiple = false;
showStatus(MODEL_STATUS, 'info', '请先选择 KNN 模型配置文件 (.json)...');
await new Promise((resolve, reject) => {
inputJson.onchange = async (e) => {
const jsonFile = e.target.files[0];
if (!jsonFile) {
showStatus(MODEL_STATUS, 'info', '未选择 .json 文件。');
updateModelUI(false);
return reject(new Error('No JSON file selected.'));
}
showStatus(MODEL_STATUS, 'info', `正在解析 ${jsonFile.name}...`);
modelName = jsonFile.name;
try {
const reader = new FileReader();
const jsonText = await new Promise((res, rej) => {
reader.onload = () => res(reader.result);
reader.onerror = () => rej(reader.error);
reader.readAsText(jsonFile);
});
modelData = JSON.parse(jsonText);
if (!modelData.dataFile) {
console.warn('模型JSON文件不包含 "dataFile" 字段尝试以旧的单文件JSON格式加载。');
// 对于旧的单文件模型,直接加载并结束
await loadSingleJsonModel(modelData);
return resolve(); // 成功加载旧模型,返回
}
} catch (error) {
showStatus(MODEL_STATUS, 'error', `解析 .json 文件失败: ${error.message}`);
console.error('解析 .json 失败:', error);
updateModelUI(false);
return reject(error);
}
const inputBin = document.createElement('input');
inputBin.type = 'file';
inputBin.accept = '.bin';
inputBin.multiple = false;
showStatus(MODEL_STATUS, 'info', `已加载 .json 文件。请选择对应的权重文件 "${modelData.dataFile}" (.bin)...`);
inputBin.onchange = async (eBin) => {
const binFile = eBin.target.files[0];
if (!binFile) {
showStatus(MODEL_STATUS, 'info', '未选择 .bin 文件。');
updateModelUI(false);
return reject(new Error('No BIN file selected.'));
}
if (binFile.name !== modelData.dataFile) {
showStatus(MODEL_STATUS, 'error', `选择的 .bin 文件名 "${binFile.name}" 与 .json 中定义的 "${modelData.dataFile}" 不匹配!请选择正确的文件。`);
updateModelUI(false);
return reject(new Error('BIN file name mismatch.'));
}
showStatus(MODEL_STATUS, 'info', `正在读取 ${binFile.name} (二进制权重文件)...`);
try {
const reader = new FileReader();
const arrayBuffer = await new Promise((res, rej) => {
reader.onload = () => res(reader.result);
reader.onerror = () => rej(reader.error);
reader.readAsArrayBuffer(binFile);
});
binData = new Float32Array(arrayBuffer);
resolve(); // 成功获取到 binData解析流程继续
} catch (error) {
showStatus(MODEL_STATUS, 'error', `读取 .bin 文件失败: ${error.message}`);
console.error('读取 .bin 失败:', error);
updateModelUI(false);
return reject(error);
}
};
inputBin.click();
};
inputJson.click();
}); // 结束 Promise 包装的回调
}
// 如果 modelData 为 null (意味着旧的单文件JSON模型已在上面被处理并返回),则停止后续处理
if (!modelData) {
return; return;
} }
showStatus(MODEL_STATUS, 'info', `正在解析 ${jsonFile.name}...`); // 执行加载 KNN 分类器的核心逻辑
if (modelData && binData) { // 仅当同时有 modelData 和 binData 时才尝试加载
knnClassifier.clearAllClasses();
let modelData; Object.keys(modelData.dataset).forEach(label => {
try { const classDataMeta = modelData.dataset[label];
const reader = new FileReader(); const startFloat32ElementIndex = classDataMeta.start;
const jsonText = await new Promise((resolve, reject) => { const numFloat32Elements = classDataMeta.length;
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsText(jsonFile);
});
modelData = JSON.parse(jsonText);
if (!modelData.dataFile) { const featureDim = modelData.featureDim || 1280;
console.warn('模型JSON文件不包含 "dataFile" 字段尝试以旧的单文件JSON格式加载。');
return loadSingleJsonModel(modelData);
}
} catch (error) { // 检查 binData 是否足够大以包含所需的数据
showStatus(MODEL_STATUS, 'error', `解析 .json 文件失败: ${error.message}`); if (startFloat32ElementIndex + numFloat32Elements > binData.length) {
console.error('解析 .json 失败:', error); throw new Error(`模型数据错误: 类别 ${label} 的数据超出 .bin 文件范围。`);
updateModelUI(false);
return;
}
const inputBin = document.createElement('input');
inputBin.type = 'file';
inputBin.accept = '.bin';
inputBin.multiple = false;
showStatus(MODEL_STATUS, 'info', `已加载 .json 文件。请选择对应的权重文件 "${modelData.dataFile}" (.bin)...`);
inputBin.onchange = async (eBin) => {
const binFile = eBin.target.files[0];
if (!binFile) {
showStatus(MODEL_STATUS, 'info', '未选择 .bin 文件。');
updateModelUI(false);
return;
}
if (binFile.name !== modelData.dataFile) {
showStatus(MODEL_STATUS, 'error', `选择的 .bin 文件名 "${binFile.name}" 与 .json 中定义的 "${modelData.dataFile}" 不匹配!请选择正确的文件。`);
updateModelUI(false);
return;
}
showStatus(MODEL_STATUS, 'info', `正在读取 ${binFile.name} (二进制权重文件)...`);
let binData;
try {
const reader = new FileReader();
const arrayBuffer = await new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(binFile);
});
binData = new Float32Array(arrayBuffer);
} catch (error) {
showStatus(MODEL_STATUS, 'error', `读取 .bin 文件失败: ${error.message}`);
console.error('读取 .bin 失败:', error);
updateModelUI(false);
return;
}
try {
knnClassifier.clearAllClasses();
Object.keys(modelData.dataset).forEach(label => {
const classDataMeta = modelData.dataset[label];
const startFloat32ElementIndex = classDataMeta.start;
const numFloat32Elements = classDataMeta.length;
const featureDim = modelData.featureDim || 1280;
const classFeatures = binData.subarray(startFloat32ElementIndex, startFloat32ElementIndex + numFloat32Elements);
if (classFeatures.length === 0) {
console.warn(`类别 ${label} 没有找到特征数据,跳过。`);
return;
}
if (classFeatures.length % featureDim !== 0) {
const actualSamples = classFeatures.length / featureDim;
console.error(
`--- 类别: ${label} ---`,
`起始 Float32 元素索引: ${startFloat32ElementIndex}`,
`该类别 Float32 元素数量: ${numFloat32Elements}`,
`ERROR: 特征数据长度 (${classFeatures.length} 个 Float32 元素) 与特征维度 (${featureDim}) 不匹配!` +
`实际样本数计算为 ${actualSamples} (预期为整数)。`,
`请检查您的模型导出逻辑和训练数据的完整性。`
);
throw new Error("模型数据完整性错误:特征数据长度与维度不匹配。");
}
const numSamples = classFeatures.length / featureDim;
for (let i = 0; i < numSamples; i++) {
const startIndex = i * featureDim;
const endIndex = (i + 1) * featureDim;
const sampleFeatures = classFeatures.subarray(startIndex, endIndex);
const sampleTensor = tf.tensor(sampleFeatures, [1, featureDim]);
knnClassifier.addExample(sampleTensor, parseInt(label));
tf.dispose(sampleTensor);
}
});
if (modelData.classList && Array.isArray(modelData.classList)) {
classNames = modelData.classList.map(c => c.name);
} else {
console.warn('模型JSON中未找到 classList 字段或格式不正确,使用默认类别名称。');
classNames = Object.keys(modelData.dataset).map(key => `Class ${parseInt(key) + 1}`);
} }
showStatus(MODEL_STATUS, 'success', `模型 "${jsonFile.name}" 及权重加载成功!类别: ${classNames.join(', ')}`); const classFeatures = binData.subarray(startFloat32ElementIndex, startFloat32ElementIndex + numFloat32Elements);
updateModelUI(true);
} catch (error) { if (classFeatures.length === 0) {
showStatus(MODEL_STATUS, 'error', `处理模型数据失败: ${error.message}`); console.warn(`类别 ${label} 没有找到特征数据,跳过。`);
console.error('处理模型数据失败:', error); return;
updateModelUI(false); }
if (classFeatures.length % featureDim !== 0) {
const actualSamples = classFeatures.length / featureDim;
console.error(
`--- 类别: ${label} ---`,
`起始 Float32 元素索引: ${startFloat32ElementIndex}`,
`该类别 Float32 元素数量: ${numFloat32Elements}`,
`ERROR: 特征数据长度 (${classFeatures.length} 个 Float32 元素) 与特征维度 (${featureDim}) 不匹配!` +
`实际样本数计算为 ${actualSamples} (预期为整数)。`,
`请检查您的模型导出逻辑和训练数据的完整性。`
);
throw new Error("模型数据完整性错误:特征数据长度与维度不匹配。");
}
const numSamples = classFeatures.length / featureDim;
for (let i = 0; i < numSamples; i++) {
const startIndex = i * featureDim;
const endIndex = (i + 1) * featureDim;
const sampleFeatures = classFeatures.subarray(startIndex, endIndex);
const sampleTensor = tf.tensor(sampleFeatures, [1, featureDim]);
knnClassifier.addExample(sampleTensor, parseInt(label));
tf.dispose(sampleTensor); // 及时释放 Tensor 内存
}
});
if (modelData.classList && Array.isArray(modelData.classList)) {
classNames = modelData.classList.map(c => c.name);
} else {
console.warn('模型JSON中未找到 classList 字段或格式不正确,使用默认类别名称。');
// 如果没有 classList尝试从 dataset 的键值来生成
classNames = Object.keys(modelData.dataset).map(key => `Class ${parseInt(key) + 1}`);
} }
};
inputBin.click(); showStatus(MODEL_STATUS, 'success', `KNN 模型 "${modelName}" 加载成功!类别: ${classNames.join(', ')}`);
}; updateModelUI(true); // 模型已加载,可以启动摄像头
inputJson.click();
} else if (modelData && !binData && !jsonUrl) {
// 如果只有 modelData 且不是从 CDN 加载,说明可能是单文件旧格式,但之前的逻辑没成功处理
// 应该是由 loadSingleJsonModel 捕获,这里作为 fallback
showStatus(MODEL_STATUS, 'error', '未知模型加载状态:仅有 JSON 数据,没有 BIN 数据。');
updateModelUI(false);
}
} catch (error) {
showStatus(MODEL_STATUS, 'error', `加载 KNN 模型失败: ${error.message}`);
console.error('加载 KNN 模型总失败:', error);
updateModelUI(false);
// 重新抛出错误,以便 initModel 可以捕获 CDN 加载失败的情况
throw error;
}
} }
/** /**
* 辅助函数处理旧的单文件JSON模型格式 dataset 字段直接包含数据而不是偏移量 * 辅助函数处理旧的单文件JSON模型格式 dataset 字段直接包含数据而不是偏移量
* @param {object} modelData - 已解析的 JSON 模型数据 * @param {object} modelData - 已解析的 JSON 模型数据
* @returns {Promise<void>}
*/ */
async function loadSingleJsonModel(modelData) { async function loadSingleJsonModel(modelData) {
try { try {
@ -257,6 +352,7 @@ async function loadSingleJsonModel(modelData) {
const numSamples = data.length / featureDim; const numSamples = data.length / featureDim;
const tensor = tf.tensor(data, [numSamples, featureDim]); const tensor = tf.tensor(data, [numSamples, featureDim]);
knnClassifier.addExample(tensor, parseInt(key)); knnClassifier.addExample(tensor, parseInt(key));
tf.dispose(tensor); // 及时释放 Tensor 内存
}); });
if (modelData.classList && Array.isArray(modelData.classList)) { if (modelData.classList && Array.isArray(modelData.classList)) {
@ -274,6 +370,7 @@ async function loadSingleJsonModel(modelData) {
showStatus(MODEL_STATUS, 'error', `加载单文件JSON模型失败: ${error.message}`); showStatus(MODEL_STATUS, 'error', `加载单文件JSON模型失败: ${error.message}`);
console.error('加载单文件JSON模型失败:', error); console.error('加载单文件JSON模型失败:', error);
updateModelUI(false); updateModelUI(false);
throw error; // 重新抛出错误
} }
} }
@ -361,7 +458,7 @@ async function predictLoop() {
commandCandidate = '0'; // 使用默认命令 commandCandidate = '0'; // 使用默认命令
} else { } else {
const prediction = await knnClassifier.predictClass(features, k); const prediction = await knnClassifier.predictClass(features, k);
features.dispose(); features.dispose(); // 及时释放 Tensor 内存
if (prediction && prediction.confidences) { if (prediction && prediction.confidences) {
let maxConfidence = 0; let maxConfidence = 0;
@ -591,7 +688,7 @@ async function sendToSerialPort(command) {
// =================================== // ===================================
CONNECT_SERIAL_BTN.addEventListener('click', connectSerial); CONNECT_SERIAL_BTN.addEventListener('click', connectSerial);
DISCONNECT_SERIAL_BTN.addEventListener('click', disconnectSerial); DISCONNECT_SERIAL_BTN.addEventListener('click', disconnectSerial);
LOAD_MODEL_BTN.addEventListener('click', loadKNNModel); LOAD_MODEL_BTN.addEventListener('click', () => loadKNNModel(null, null)); // 手动加载时,不传 CDN URL
START_WEBCAM_BTN.addEventListener('click', startWebcam); START_WEBCAM_BTN.addEventListener('click', startWebcam);
STOP_WEBCAM_BTN.addEventListener('click', stopWebcam); STOP_WEBCAM_BTN.addEventListener('click', stopWebcam);
@ -600,5 +697,5 @@ STOP_WEBCAM_BTN.addEventListener('click', stopWebcam);
// =================================== // ===================================
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
checkWebSerialCompatibility(); // 检查 Web Serial API 兼容性 checkWebSerialCompatibility(); // 检查 Web Serial API 兼容性
initModel(); // 加载 MobileNet 和 KNN 分类器实例 initModel(); // 加载 MobileNet 和 KNN 分类器实例,并尝试自动加载 KNN 模型
}); });

View File

@ -464,7 +464,7 @@
'1': 2, // 对应剪刀 '1': 2, // 对应剪刀
'2': 3 // 对应布 '2': 3 // 对应布
}; };
// 存储实际导入模型中的分类名称,例如 {"0": "拳头", "1": "V字", "2": "手掌"} // 导入模型后,存储实际导入模型中的分类名称,例如 {"0": "拳头", "1": "V字", "2": "手掌"}
let importedClassNames = {}; let importedClassNames = {};
// DOM 元素引用 // DOM 元素引用
@ -660,7 +660,6 @@
isHandDetectionReady = true; // 摄像头和检测器已就绪 isHandDetectionReady = true; // 摄像头和检测器已就绪
console.log('手部检测器和摄像头已就绪。'); console.log('手部检测器和摄像头已就绪。');
document.getElementById('gestureStatus').textContent = '手部检测器已就绪。请导入手势模型。';
// 启用导入模型按钮,禁用开始游戏按钮直到模型导入 // 启用导入模型按钮,禁用开始游戏按钮直到模型导入
startBtn.disabled = true; startBtn.disabled = true;
@ -670,6 +669,28 @@
// 4. 开始持续检测循环 (现在仅检测和绘制骨骼,不预测,直到模型导入) // 4. 开始持续检测循环 (现在仅检测和绘制骨骼,不预测,直到模型导入)
startDetectionLoop(); startDetectionLoop();
// --- 新增:尝试自动从 CDN 加载 KNN 模型数据 ---
// !!! 请替换为你的实际 CDN 模型 URL !!!
const cdnModelJsonUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/hand-knn-model-2.json';
console.log(`尝试从 CDN 自动加载 KNN 模型数据: ${cdnModelJsonUrl}`);
document.getElementById('gestureStatus').textContent = '正在尝试从 CDN 加载手势识别模型...';
try {
await loadKNNModelData(null, cdnModelJsonUrl); // 传入 CDN URL, file 为 null
document.getElementById('gestureStatus').textContent = 'CDN 手势识别模型加载成功!可以开始游戏了。';
isModelLoaded = true; // 标记模型已加载
startBtn.disabled = false; // 启用开始游戏按钮
btnImportModel.disabled = true; // 自动加载成功后,禁用手动导入按钮
} catch (cdnError) {
console.warn('CDN KNN 模型数据自动加载失败:', cdnError);
document.getElementById('gestureStatus').textContent = `CDN 模型加载失败: ${cdnError.message}。请手动导入模型。`;
isModelLoaded = false; // 标记模型未加载
startBtn.disabled = true; // 模型未加载,开始按钮仍禁用
btnImportModel.disabled = false; // 允许手动导入
}
// --- 结束 CDN 自动加载 ---
} catch (error) { } catch (error) {
console.error('手势识别初始化失败:', error); console.error('手势识别初始化失败:', error);
document.getElementById('gestureStatus').textContent = `初始化失败: ${error.message}`; document.getElementById('gestureStatus').textContent = `初始化失败: ${error.message}`;
@ -712,98 +733,119 @@
} }
} }
// 修改 loadModel 函数,从文件事件中读取并加载模型 /**
async function loadModelFromFile(file) { * 加载 KNN 模型数据,支持从文件或 CDN URL 加载。
return new Promise((resolve, reject) => { * @param {File} [file] - 可选,用户选择的 KNN 模型 JSON 文件。
const reader = new FileReader(); * @param {string} [cdnUrl] - 可选KNN 模型 JSON 文件的 CDN URL。
reader.onload = async (e) => { * @returns {Promise<void>}
try { */
const loadedModelData = JSON.parse(e.target.result); async function loadKNNModelData(file = null, cdnUrl = null) {
document.getElementById('gestureStatus').textContent = '正在加载模型数据...';
startBtn.disabled = true; // 加载中禁用开始按钮
btnImportModel.disabled = true; // 加载中禁用导入按钮
if (!loadedModelData || !loadedModelData.dataset || !loadedModelData.classMap) { try {
throw new Error('模型数据结构不正确 (缺少 classMap 或 dataset)。'); let loadedModelData;
}
// 清除分类器之前可能存在的任何数据 if (file) {
classifier.clearAllClasses(); const reader = new FileReader();
const fileReadPromise = new Promise((resolve, reject) => {
const dataset = {}; reader.onload = e => resolve(JSON.parse(e.target.result));
let totalExamples = 0; reader.onerror = error => reject(new Error('文件读取失败。'));
reader.readAsText(file);
// 恢复分类器数据集 });
for (const classId in loadedModelData.dataset) { loadedModelData = await fileReadPromise;
const classData = loadedModelData.dataset[classId]; } else if (cdnUrl) {
if (classData && classData.length > 0) { const response = await fetch(cdnUrl);
// 确保所有样本的特征长度一致,并且是数字数组 if (!response.ok) {
if (classData.some(sample => !Array.isArray(sample) || sample.some(val => typeof val !== 'number'))) { throw new Error(`无法从 ${cdnUrl} 加载模型数据: ${response.statusText}`);
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 = ''; // 清空文件输入
} }
}; loadedModelData = await response.json();
reader.onerror = (error) => { } else {
console.error('文件读取失败:', error); throw new Error('未提供模型文件或 CDN URL。');
reject(new Error('文件读取失败。')); }
};
reader.readAsText(file); 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));
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; // 导入按钮禁用
// 如果模型加载成功,且之前是因为没有模型而被禁用,现在应该解锁开始按钮
if(isHandDetectionReady && isModelLoaded) {
startBtn.disabled = false;
}
} catch (error) {
console.error('模型加载失败:', error);
document.getElementById('gestureStatus').textContent = `模型加载失败: ${error.message}`;
isModelLoaded = false;
startBtn.disabled = true; // 失败后保持禁用
btnImportModel.disabled = false; // 失败后可再次导入
throw error; // 重新抛出错误以便调用者如initHandDetection能捕获
} finally {
fileImporter.value = ''; // 清空文件输入
}
} }
// 文件选择事件处理器
// 文件选择事件处理器 (现在它调用通用加载函数)
async function handleModelImport(event) { async function handleModelImport(event) {
const file = event.target.files[0]; const file = event.target.files[0];
if (!file) return; if (!file) {
document.getElementById('gestureStatus').textContent = '未选择文件。';
return;
}
document.getElementById('gestureStatus').textContent = '正在导入模型...'; document.getElementById('gestureStatus').textContent = '正在从本地文件导入模型...';
startBtn.disabled = true; // 导入中禁用开始按钮 startBtn.disabled = true; // 导入中禁用开始按钮
btnImportModel.disabled = true; // 导入中禁用导入按钮 btnImportModel.disabled = true; // 导入中禁用导入按钮
try { try {
await loadModelFromFile(file); await loadKNNModelData(file, null); // 传入文件对象cdnUrl 为 null
} catch (error) { } catch (error) {
alert(error.message); // 错误信息已在 loadKNNModelData 内部处理并设置状态
document.getElementById('gestureStatus').textContent = `导入失败: ${error.message}`; alert(error.message); // 弹出错误提示
startBtn.disabled = true; // 失败后仍保持禁用
btnImportModel.disabled = false; // 失败后可再次导入
} }
} }
@ -849,11 +891,12 @@
const gameChoice = gestureClassToGameChoice[predictedClassId]; const gameChoice = gestureClassToGameChoice[predictedClassId];
// 设一个置信度阈值,避免误识别 (例如 70%) // 设一个置信度阈值,避免误识别 (例如 70%)
if (gameChoice && confidence > 70) { const MIN_PREDICT_CONFIDENCE = 70; // 调整为更易读的常量
if (gameChoice && confidence >= MIN_PREDICT_CONFIDENCE) {
currentDetectedGesture = gameChoice; currentDetectedGesture = gameChoice;
const gestureName = choiceNames[gameChoice]; const gestureName = choiceNames[gameChoice];
document.getElementById('gestureStatus').textContent = document.getElementById('gestureStatus').textContent =
`识别: ${importedClassNames[predictedClassId] || `未知类别${predictedClassId}`} (${gestureName}) 置信度: ${confidence}%`; `识别: ${importedClassNames[predictedClassId] || `类别${predictedClassId}`} (${gestureName}) 置信度: ${confidence}%`;
document.getElementById('gestureStatus').style.color = '#00ff00'; document.getElementById('gestureStatus').style.color = '#00ff00';
document.getElementById('gestureIndicator').textContent = choices[gameChoice]; document.getElementById('gestureIndicator').textContent = choices[gameChoice];
@ -878,8 +921,14 @@
} else { } else {
currentDetectedGesture = null; currentDetectedGesture = null;
document.getElementById('gestureStatus').textContent = `识别不足 (置信度: ${confidence}%)`; if (isGameActive && lockedGesture !== null) {
document.getElementById('gestureStatus').style.color = '#ffaa00'; // 橙色警告 // 如果在游戏倒计时且手势已锁定,则不更新状态文本防止抖动
document.getElementById('gestureStatus').textContent = `已锁定手势: ${choiceNames[lockedGesture]} (${confidence}%)`;
document.getElementById('gestureStatus').style.color = '#00ff00';
} else {
document.getElementById('gestureStatus').textContent = `识别不足 (置信度: ${confidence}%)`;
document.getElementById('gestureStatus').style.color = '#ffaa00'; // 橙色警告
}
document.getElementById('gestureIndicator').textContent = '❓'; document.getElementById('gestureIndicator').textContent = '❓';
document.getElementById('gestureIndicator').style.display = 'block'; // 保持显示,但显示问号 document.getElementById('gestureIndicator').style.display = 'block'; // 保持显示,但显示问号
document.getElementById('currentClassDisplay').style.display = 'none'; // 隐藏卡片 document.getElementById('currentClassDisplay').style.display = 'none'; // 隐藏卡片

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -197,6 +197,9 @@
margin-bottom: 20px; margin-bottom: 20px;
text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.5); text-shadow: 3px 3px 6px rgba(0, 0, 0, 0.5);
} }
.overlay p {
font-size: 0.8em; /* 调整字体大小 */
}
.overlay button { .overlay button {
margin-top: 30px; margin-top: 30px;
font-size: 1.5em; font-size: 1.5em;
@ -291,11 +294,11 @@
<div class="instructions"> <div class="instructions">
<h3>姿态控制说明</h3> <h3>姿态控制说明</h3>
<ul> <ul>
<li><strong>向上:</strong> <span>双手举过头顶</span></li> <li><strong>向上:</strong> <span>双手举过头顶 (分类0)</span></li>
<li><strong>向下:</strong> <span>双手放在身体两侧或下垂</span></li> <li><strong>向下:</strong> <span>双手放在身体两侧或下垂 (分类1)</span></li>
<li><strong>向左:</strong> <span>左手平举</span></li> <li><strong>向左:</strong> <span>左手平举 (分类2)</span></li>
<li><strong>向右:</strong> <span>右手平举</span></li> <li><strong>向右:</strong> <span>右手平举 (分类3)</span></li>
<li><strong>静止:</strong> <span>保持站立姿势 (无特定动作)</span></li> <li><strong>静止:</strong> <span>无特定动作 (游戏开始前/结束后姿态)</span></li>
</ul> </ul>
<p style="font-size:0.8em;text-align:center;margin-top:10px;color:#ccc;">(确保在摄像头画面中能清晰识别全身)</p> <p style="font-size:0.8em;text-align:center;margin-top:10px;color:#ccc;">(确保在摄像头画面中能清晰识别全身)</p>
</div> </div>
@ -356,7 +359,13 @@
'3': 'RIGHT' // 类别3 -> 向右 '3': 'RIGHT' // 类别3 -> 向右
}; };
// 用于显示给用户的类别名称加载模型时从JSON中读取 // 用于显示给用户的类别名称加载模型时从JSON中读取
let importedClassNames = {}; // 确保你的训练模型有这四个类别
let importedClassNames = {
'0': '举手',
'1': '下蹲',
'2': '左抬手',
'3': '右抬手'
};
let snakeDirection = 'RIGHT'; // 贪吃蛇当前方向 let snakeDirection = 'RIGHT'; // 贪吃蛇当前方向
// ========================================================== // ==========================================================
@ -380,7 +389,7 @@
document.addEventListener('DOMContentLoaded', initApp); document.addEventListener('DOMContentLoaded', initApp);
async function initApp() { async function initApp() {
updateGameStatus('loading'); updateGameStatus('initial'); // 初始状态设为 'initial'
statusDisplay.textContent = '正在加载 MoveNet 模型和摄像头...'; statusDisplay.textContent = '正在加载 MoveNet 模型和摄像头...';
startBtn.disabled = true; startBtn.disabled = true;
importModelBtn.disabled = true; importModelBtn.disabled = true;
@ -397,8 +406,8 @@
await setupCamera(); await setupCamera();
isPoseDetectionReady = true; isPoseDetectionReady = true;
statusDisplay.textContent = 'MoveNet 和摄像头已就绪。请导入姿态模型。'; statusDisplay.textContent = 'MoveNet 和摄像头已就绪。'; // 状态更新
importModelBtn.disabled = false; // 启用导入按钮 importModelBtn.disabled = false; // 启用导入按钮 (因为CDN加载可能失败需要手动导入)
// 启动姿态检测循环(只进行检测和绘制,不预测,直到模型导入) // 启动姿态检测循环(只进行检测和绘制,不预测,直到模型导入)
startPoseDetectionLoop(); startPoseDetectionLoop();
@ -412,20 +421,50 @@
// 键盘快捷键 // 键盘快捷键
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
// 确保只有当游戏处于“准备好”或“游戏结束”状态才能启动或重新开始
if (gameStatus === 'ready' || gameStatus === 'gameOver') { if (gameStatus === 'ready' || gameStatus === 'gameOver') {
startBtn.click(); // 模拟点击开始按钮 // 如果是游戏结束状态且按钮可用,点击 restartBtn
} else if (gameStatus === 'playing') { if (gameStatus === 'gameOver' && !restartBtn.disabled) {
// 可选:添加暂停功能 restartBtn.click();
} else if (gameStatus === 'ready' && !startBtn.disabled) {
// 如果是准备就绪状态且按钮可用,点击 startBtn
startBtn.click();
}
} }
} }
}); });
updateGameUI(); // --- 新增:尝试自动从 CDN 加载 KNN 模型数据 ---
// !!! 请替换为你的实际 CDN 模型 URL !!!
const cdnModelJsonUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/pose-knn-model.json';
console.log(`尝试从 CDN 自动加载 KNN 模型数据: ${cdnModelJsonUrl}`);
statusDisplay.textContent = '正在尝试从 CDN 加载姿态识别模型...';
try {
await loadKNNModelData(null, cdnModelJsonUrl); // 传入 CDN URL, file 为 null
statusDisplay.textContent = 'CDN 姿态识别模型加载成功!可以开始游戏了。';
isModelLoaded = true; // 标记模型已加载
startBtn.disabled = false; // 启用开始游戏按钮
importModelBtn.disabled = true; // 自动加载成功后,禁用手动导入按钮
updateGameStatus('ready'); // 更新游戏状态
} catch (cdnError) {
console.warn('CDN KNN 模型数据自动加载失败:', cdnError);
statusDisplay.textContent = `CDN 模型加载失败: ${cdnError.message}。请手动导入模型。`;
isModelLoaded = false; // 标记模型未加载
startBtn.disabled = true; // 模型未加载,开始按钮仍禁用
importModelBtn.disabled = false; // 允许手动导入
updateGameStatus('initial'); // 保持初始状态
}
// --- 结束 CDN 自动加载 ---
updateGameUI(); // 确保 UI 在初始化结束时更新一次
} catch (error) { } catch (error) {
console.error("应用初始化失败:", error); console.error("应用初始化失败:", error);
statusDisplay.textContent = `初始化失败: ${error.message}`; statusDisplay.textContent = `初始化失败: ${error.message}`;
alert(`应用初始化失败: ${error.message}\n请检查摄像头权限或网络连接。`); alert(`应用初始化失败: ${error.message}\n请检查摄像头权限、网络连接或刷新页面。`);
updateGameStatus('initial');
} }
} }
@ -477,18 +516,20 @@
if (poses && poses.length > 0) { if (poses && poses.length > 0) {
drawPose(poses[0]); // 绘制检测到的姿态 drawPose(poses[0]); // 绘制检测到的姿态
if (isModelLoaded && gameStatus === 'playing') { // 仅在游戏进行中时才预测并控制贪吃蛇 // 仅在游戏进行中且模型已加载时才预测并控制贪吃蛇
if (isModelLoaded && gameStatus === 'playing') {
const poseTensor = flattenPose(poses[0]); const poseTensor = flattenPose(poses[0]);
if (classifier.getNumClasses() > 0) { if (classifier.getNumClasses() > 0) {
const prediction = await classifier.predictClass(poseTensor); const prediction = await classifier.predictClass(poseTensor);
poseTensor.dispose(); // 释放张量内存 poseTensor.dispose(); // 释放张量内存
const predictedClassId = prediction.label; const predictedClassId = prediction.label;
const confidence = prediction.confidences[predictedClassId]; const confidence = prediction.confidences[predictedClassId] || 0; // 确保有默认值
currentConfidence = (confidence * 100).toFixed(1); currentConfidence = (confidence * 100).toFixed(1);
// 设定一个置信度阈值,例如 70% // 设定一个置信度阈值,例如 70%
if (confidence > 0.70) { const MIN_PREDICT_CONFIDENCE = 70;
if (confidence * 100 > MIN_PREDICT_CONFIDENCE) {
currentDetectedClassId = predictedClassId; currentDetectedClassId = predictedClassId;
const gameDirection = gestureClassToGameDirection[predictedClassId]; const gameDirection = gestureClassToGameDirection[predictedClassId];
if (gameDirection) { if (gameDirection) {
@ -509,11 +550,7 @@
controlCommandDisplay.textContent = '静止 (模型无数据)'; controlCommandDisplay.textContent = '静止 (模型无数据)';
} }
} else if (!isModelLoaded) { } else { // 如果游戏未进行或模型未加载,则不进行预测控制
currentDetectedClassId = null;
currentConfidence = 0;
controlCommandDisplay.textContent = '静止 (等待模型)';
} else if (gameStatus !== 'playing') {
currentDetectedClassId = null; currentDetectedClassId = null;
currentConfidence = 0; currentConfidence = 0;
controlCommandDisplay.textContent = '静止'; controlCommandDisplay.textContent = '静止';
@ -575,87 +612,131 @@
} }
// ========================================================== // ==========================================================
// 模型导入导出 // 模型导入导出 (改造为支持文件和 CDN URL)
// ========================================================== // ==========================================================
async function handleModelImport(event) { /**
const file = event.target.files[0]; * 加载 KNN 模型数据,支持从文件或 CDN URL 加载。
if (!file) return; * @param {File} [file] - 可选,用户选择的 KNN 模型 JSON 文件。
* @param {string} [cdnUrl] - 可选KNN 模型 JSON 文件的 CDN URL。
* @returns {Promise<void>}
*/
async function loadKNNModelData(file = null, cdnUrl = null) {
updateGameStatus('loading'); statusDisplay.textContent = '正在加载模型数据...';
statusDisplay.textContent = '正在导入模型...'; startBtn.disabled = true; // 加载中禁用开始按钮
startBtn.disabled = true; importModelBtn.disabled = true; // 加载中禁用导入按钮
importModelBtn.disabled = true;
try { try {
await loadModelFromFile(file); let loadedModelData;
statusDisplay.textContent = '姿态模型导入成功!';
isModelLoaded = true; if (file) {
startBtn.disabled = false; // 启用开始按钮 const reader = new FileReader();
importModelBtn.disabled = true; // 禁用导入按钮 const fileReadPromise = new Promise((resolve, reject) => {
reader.onload = e => resolve(JSON.parse(e.target.result));
reader.onerror = error => reject(new Error('文件读取失败。'));
reader.readAsText(file);
});
loadedModelData = await fileReadPromise;
} else if (cdnUrl) {
const response = await fetch(cdnUrl);
if (!response.ok) {
throw new Error(`无法从 ${cdnUrl} 加载模型数据: ${response.statusText}`);
}
loadedModelData = await response.json();
} else {
throw new Error('未提供模型文件或 CDN URL。');
}
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));
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);
// 更新页面上的映射显示,如果需要的话。
// 确保 instructions 中的分类名称与 importedClassNames 保持一致
const instructionListItems = document.querySelectorAll('.instructions li');
instructionListItems.forEach((item, index) => {
const classId = String(index); // 假定指令顺序与分类ID一致
const classNameFromModel = importedClassNames[classId] || '未定义';
let controlDesc = '';
switch(classId) {
case '0': controlDesc = '双手举过头顶'; break;
case '1': controlDesc = '双手放在身体两侧或下垂'; break;
case '2': controlDesc = '左手平举'; break;
case '3': controlDesc = '右手平举'; break;
default: controlDesc = '未知动作';
}
item.querySelector('span').innerHTML = `${controlDesc} (<strong>分类${classId}</strong>: ${classNameFromModel})`;
// 如果你的 instructions 区域已经有预设的文字,并且你只希望更新 Classification ID
// 可以根据 HTML 结构微调这里的更新逻辑。
});
statusDisplay.textContent = '姿态模型导入成功!可以开始游戏了。';
isModelLoaded = true; // 设置模型已加载状态
startBtn.disabled = false; // 启用开始游戏按钮
importModelBtn.disabled = true; // 导入按钮禁用
updateGameStatus('ready'); updateGameStatus('ready');
} catch (error) { } catch (error) {
console.error('模型导入失败:', error); console.error('模型加载失败:', error);
statusDisplay.textContent = `模型导入失败: ${error.message}`; statusDisplay.textContent = `模型加载失败: ${error.message}`;
alert(`模型导入失败: ${error.message}\n请确保文件是正确的模型JSON文件。`);
startBtn.disabled = true; // 导入失败则不能开始
importModelBtn.disabled = false; // 可以再试一次导入
isModelLoaded = false; isModelLoaded = false;
startBtn.disabled = true; // 失败后保持禁用
importModelBtn.disabled = false; // 失败后可再次导入
updateGameStatus('initial'); updateGameStatus('initial');
throw error; // 重新抛出错误,以便调用者能捕获
} finally { } finally {
fileImporter.value = ''; // 清空文件输入 fileImporter.value = ''; // 清空文件输入
} }
} }
async function loadModelFromFile(file) {
// 这段逻辑与您之前在 script.js 中 `importModel` 的核心逻辑一致
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)。'); async function handleModelImport(event) {
} const file = event.target.files[0];
if (!file) {
statusDisplay.textContent = '未选择文件。';
return;
}
classifier.clearAllClasses(); statusDisplay.textContent = '正在从本地文件导入模型...';
startBtn.disabled = true; // 导入中禁用开始按钮
importModelBtn.disabled = true; // 导入中禁用导入按钮
const dataset = {}; try {
let totalExamples = 0; await loadKNNModelData(file, null); // 传入文件对象cdnUrl 为 null
} catch (error) {
for (const classId in loadedModelData.dataset) { // 错误信息已在 loadKNNModelData 内部处理并设置状态
const classData = loadedModelData.dataset[classId]; alert(error.message); // 弹出错误提示
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));
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);
resolve();
} catch (error) {
reject(error);
}
};
reader.onerror = (error) => {
reject(new Error('文件读取失败。'));
};
reader.readAsText(file);
});
} }
// =================================== // ===================================
@ -817,6 +898,7 @@
else if (newDirection === 'UP' && snakeDirection !== 'DOWN') snakeDirection = 'UP'; else if (newDirection === 'UP' && snakeDirection !== 'DOWN') snakeDirection = 'UP';
else if (newDirection === 'RIGHT' && snakeDirection !== 'LEFT') snakeDirection = 'RIGHT'; else if (newDirection === 'RIGHT' && snakeDirection !== 'LEFT') snakeDirection = 'RIGHT';
else if (newDirection === 'DOWN' && snakeDirection !== 'UP') snakeDirection = 'DOWN'; else if (newDirection === 'DOWN' && snakeDirection !== 'UP') snakeDirection = 'DOWN';
// console.log("Snake direction changed to:", snakeDirection); // 调试用
} }
function endGame() { function endGame() {
@ -836,7 +918,7 @@
function updateGameStatus(status) { function updateGameStatus(status) {
gameStatus = status; gameStatus = status;
console.log("Game status updated to:", gameStatus); // console.log("Game status updated to:", gameStatus); // 调试用
} }
function updateScoreDisplay() { function updateScoreDisplay() {
@ -847,7 +929,7 @@
// 更新姿态识别显示 // 更新姿态识别显示
if (currentDetectedClassId !== null) { if (currentDetectedClassId !== null) {
const className = importedClassNames[currentDetectedClassId] || `未知类别 ${currentDetectedClassId}`; const className = importedClassNames[currentDetectedClassId] || `未知类别 ${currentDetectedClassId}`;
currentGestureDisplay.textContent = `${className} (${currentConfidence}%)`; currentGestureDisplay.textContent = `${className}`;
gestureConfidenceDisplay.textContent = `置信度: ${currentConfidence}%`; gestureConfidenceDisplay.textContent = `置信度: ${currentConfidence}%`;
gestureConfidenceDisplay.style.color = currentConfidence > 70 ? '#00ff00' : '#ffaa00'; gestureConfidenceDisplay.style.color = currentConfidence > 70 ? '#00ff00' : '#ffaa00';
} else { } else {
@ -855,19 +937,70 @@
gestureConfidenceDisplay.textContent = ''; gestureConfidenceDisplay.textContent = '';
} }
// 更新状态信息 // 根据游戏状态更新按钮和文本
if (gameStatus === 'initial') { if (gameStatus === 'initial') {
statusDisplay.textContent = '等待模型导入...'; statusDisplay.textContent = '等待模型导入...';
startBtn.disabled = true;
importModelBtn.disabled = false;
restartBtn.disabled = true;
} else if (gameStatus === 'loading') { } else if (gameStatus === 'loading') {
// 状态文本已经在 initApp 或 handleModelImport 中设置 // 状态文本已经在加载函数中设置
startBtn.disabled = true;
importModelBtn.disabled = true;
restartBtn.disabled = true;
} else if (gameStatus === 'ready') { } else if (gameStatus === 'ready') {
statusDisplay.textContent = '模型已加载点击“开始游戏”或按“Enter”键。'; statusDisplay.textContent = '模型已加载点击“开始游戏”或按“Enter”键。';
startBtn.disabled = false;
importModelBtn.disabled = true;
restartBtn.disabled = true; // 只有在gameOver后才启用重新开始
} else if (gameStatus === 'playing') { } else if (gameStatus === 'playing') {
statusDisplay.textContent = '游戏进行中...'; statusDisplay.textContent = '游戏进行中...';
startBtn.disabled = true;
importModelBtn.disabled = true;
restartBtn.disabled = true;
} else if (gameStatus === 'gameOver') { } else if (gameStatus === 'gameOver') {
statusDisplay.textContent = '游戏结束点击“重新开始”或按“Enter”键。'; statusDisplay.textContent = '游戏结束点击“重新开始”或按“Enter”键。';
startBtn.disabled = true;
importModelBtn.disabled = true;
restartBtn.disabled = false; // 游戏结束时启用重新开始
} }
} }
// --- 应用启动 ---
window.addEventListener('DOMContentLoaded', () => {
console.log('DOM content loaded. Initializing hand detection...');
initApp().catch(error => {
console.error('App initialization failed:', error);
// 错误信息已在 initApp 内部处理并alert
});
// 绑定导入模型事件
fileImporter.addEventListener('change', handleModelImport);
importModelBtn.addEventListener('click', () => {
fileImporter.click(); // 点击按钮触发文件输入
});
});
// 页面关闭时清理资源 (可选,但推荐)
window.onbeforeunload = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
if (gameLoopId) {
clearInterval(gameLoopId);
}
if (detector) {
// MediaPipe runtime 通常会管理其自己的 WebGL 资源,
// 明确调用 dispose() 可能会导致后续异常,如果 MediaPipe 内部未预期再次使用。
// 如果遇到问题,可以注释掉这行。
// detector.dispose();
}
if (classifier) {
classifier.clearAllClasses();
}
tf.disposeAll(); // 释放所有TensorFlow.js张量防止内存泄露
console.log('Resources cleaned up.');
};
</script> </script>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

View File

@ -334,9 +334,7 @@
'5': { name: 'A2', audio: new Audio('sounds/A2.mp3') }, '5': { name: 'A2', audio: new Audio('sounds/A2.mp3') },
'6': { name: 'B2', audio: new Audio('sounds/B2.mp3') }, '6': { name: 'B2', audio: new Audio('sounds/B2.mp3') },
'7': { name: '高音C (C3)', audio: new Audio('sounds/C3.mp3') }, '7': { name: '高音C (C3)', audio: new Audio('sounds/C3.mp3') },
'8': { name: '空', audio: new Audio('sounds/rest.mp3') } '8': { name: '空', audio: new Audio('sounds/rest.mp3') } // 如果需要休息/不触发音符的类别
// 您需要在 index.html 中训练 8 个不同的手势分别对应这些类别ID (0-7)。
// 如果不训练足够多的手势KNN 分类器将无法预测这些类别。
}; };
// 可配置项 // 可配置项
@ -378,7 +376,7 @@
await setupCamera(); await setupCamera();
isHandDetectionReady = true; isHandDetectionReady = true;
updateGlobalStatus('手部检测器和摄像头已就绪。请导入您的手势模型。', 'ready'); updateGlobalStatus('手部检测器和摄像头已就绪。');
lockControls(false); lockControls(false);
startStopBtn.disabled = true; startStopBtn.disabled = true;
@ -387,7 +385,7 @@
// 绑定按钮事件 // 绑定按钮事件
importModelBtn.addEventListener('click', () => fileImporter.click()); importModelBtn.addEventListener('click', () => fileImporter.click());
fileImporter.addEventListener('change', handleModelImport); fileImporter.addEventListener('change', (event) => loadKNNModelData(event.target.files[0])); // 改为通用加载函数
startStopBtn.addEventListener('click', togglePlaying); startStopBtn.addEventListener('click', togglePlaying);
// 预加载所有音频 // 预加载所有音频
@ -396,6 +394,30 @@
// 初始更新映射UI显示所有8个音符的映射 // 初始更新映射UI显示所有8个音符的映射
updateGestureMappingUI(); updateGestureMappingUI();
// --- 新增:尝试自动从 CDN 加载 KNN 模型数据 ---
// !!! 请替换为你的实际 CDN 模型 URL !!!
const cdnJsonUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/hand-knn-model.json';
// 如果你的 KNN 数据是分 bin 而不是直接包含在 json你需要类似上次 script.js 那样处理两个文件
// 但通常 KNN Classifier 的 export/import 是一个单一 JSON 文件
console.log(`尝试从 CDN 自动加载 KNN 模型数据: ${cdnJsonUrl}`);
updateGlobalStatus('正在尝试从 CDN 加载手势识别模型...', 'loading');
try {
await loadKNNModelData(null, cdnJsonUrl); // 传递 CDN URL不传文件
updateGlobalStatus('CDN 手势识别模型加载成功!', 'success');
isModelLoaded = true;
importModelBtn.disabled = true; // 自动加载成功后禁用手动导入
startStopBtn.disabled = false;
} catch (cdnError) {
console.warn('CDN KNN 模型数据自动加载失败:', cdnError);
updateGlobalStatus(`CDN 模型加载失败: ${cdnError.message}。请手动导入模型。`, 'warning');
isModelLoaded = false;
importModelBtn.disabled = false; // CDN 失败,允许手动导入
startStopBtn.disabled = true;
}
// --- 结束 CDN 自动加载 ---
} catch (error) { } catch (error) {
console.error("应用初始化失败:", error); console.error("应用初始化失败:", error);
updateGlobalStatus(`初始化失败: ${error.message}`, 'error'); updateGlobalStatus(`初始化失败: ${error.message}`, 'error');
@ -571,91 +593,103 @@
} }
// ========================================================== // ==========================================================
// 模型导入 // 模型导入 - 改造为支持文件和 URL 两种方式
// ========================================================== // ==========================================================
async function handleModelImport(event) { /**
const file = event.target.files[0]; * 加载 KNN 模型数据,支持从文件或 CDN URL 加载。
if (!file) return; * @param {File} [file] - 可选,用户选择的 KNN 模型 JSON 文件。
* @param {string} [cdnUrl] - 可选KNN 模型 JSON 文件的 CDN URL。
updateGlobalStatus('正在导入模型...', 'loading'); * @returns {Promise<void>}
*/
async function loadKNNModelData(file = null, cdnUrl = null) {
updateGlobalStatus('正在加载模型...', 'loading');
lockControls(true); lockControls(true);
try { try {
await loadModelFromFile(file); let loadedModelData;
updateGlobalStatus('手势模型导入成功!', 'success');
if (file) {
const reader = new FileReader();
const fileReadPromise = new Promise((resolve, reject) => {
reader.onload = e => resolve(JSON.parse(e.target.result));
reader.onerror = error => reject(new Error('文件读取失败。'));
reader.readAsText(file);
});
loadedModelData = await fileReadPromise;
} else if (cdnUrl) {
const response = await fetch(cdnUrl);
if (!response.ok) {
throw new Error(`无法从 ${cdnUrl} 加载模型数据: ${response.statusText}`);
}
loadedModelData = await response.json();
} else {
throw new Error('未提供模型文件或 CDN URL。');
}
// 确保模型包含 classMap 和 dataset
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));
const stacked = tf.stack(tensors);
dataset[classId] = stacked;
totalExamples += classData.length;
tensors.forEach(t => t.dispose());
} else {
console.warn(`类别 ${classId} 没有样本数据。`);
}
}
classifier.setClassifierDataset(dataset);
// 可以在这里根据 loadedModelData.classMap 动态更新 gestureClassToAudioMap 中的 name 字段
// 以便 UI 上的音符名称直接从训练模型中获取。
// 例如:
// loadedModelData.classMap.forEach(item => {
// if (gestureClassToAudioMap[item.classId]) {
// gestureClassToAudioMap[item.classId].name = item.className;
// }
// });
updateGestureMappingUI(); // 再次调用以确保UI更新
console.log(`模型加载成功!共加载 ${totalExamples} 个样本。`);
console.log('导入类别映射:', loadedModelData.classMap);
isModelLoaded = true; isModelLoaded = true;
lockControls(false); lockControls(false);
importModelBtn.disabled = true; importModelBtn.disabled = true; // 成功加载后应禁用手动导入,除非你想支持重新导入
startStopBtn.disabled = false; startStopBtn.disabled = false;
updateGlobalStatus('手势模型加载成功!', 'success');
} catch (error) { } catch (error) {
console.error('模型导入失败:', error); console.error('模型加载失败:', error);
updateGlobalStatus(`模型导入失败: ${error.message}`, 'error'); updateGlobalStatus(`模型加载失败: ${error.message}`, 'error');
alert(`模型导入失败: ${error.message}\n请确保文件是正确的模型JSON文件。`); alert(`模型加载失败: ${error.message}\n请确保文件是正确的模型JSON文件或 CDN URL 可访问。`);
isModelLoaded = false; isModelLoaded = false;
lockControls(false); lockControls(false);
importModelBtn.disabled = false; // 失败后允许手动导入
startStopBtn.disabled = true; startStopBtn.disabled = true;
importModelBtn.disabled = false; throw error; // 重新抛出错误以便调用者如initApp能捕获
} finally { } finally {
fileImporter.value = ''; fileImporter.value = ''; // 清除文件选择器的值,以便可以再次选择相同文件
} }
} }
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);
// 确保模型包含 classMap 和 dataset
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));
const stacked = tf.stack(tensors);
dataset[classId] = stacked;
totalExamples += classData.length;
tensors.forEach(t => t.dispose());
} else {
console.warn(`类别 ${classId} 没有样本数据。`);
}
}
classifier.setClassifierDataset(dataset);
// 您可以在这里遍历 loadedModelData.classMap 来更新 `gestureClassToAudioMap` 中的 `name` 字段
// 以便 UI 上的音符名称直接从训练模型中获取。
// 当前代码是直接使用 `gestureClassToAudioMap` 中预设的音符名称。
updateGestureMappingUI(); // 再次调用以确保UI更新
console.log(`模型加载成功!共加载 ${totalExamples} 个样本。`);
console.log('导入类别映射:', loadedModelData.classMap);
resolve();
} catch (error) {
reject(error);
}
};
reader.onerror = (error) => {
reject(new Error('文件读取失败。'));
};
reader.readAsText(file);
});
}
// ========================================================== // ==========================================================
// 音频播放逻辑 // 音频播放逻辑
// ========================================================== // ==========================================================
@ -705,18 +739,24 @@
function updateGlobalStatus(message, type = 'info') { function updateGlobalStatus(message, type = 'info') {
globalStatusDisplay.textContent = message; globalStatusDisplay.textContent = message;
// 重置颜色以便不同类型的状态信息有不同的视觉反馈
globalStatusDisplay.style.color = '#bdc3c7'; // 默认颜色
if (type === 'error') { if (type === 'error') {
globalStatusDisplay.style.color = '#e74c3c'; /* 红色 */ globalStatusDisplay.style.color = '#e74c3c'; /* 红色 */
} else if (type === 'success') { } else if (type === 'success') {
globalStatusDisplay.style.color = '#2ecc71'; /* 绿色 */ globalStatusDisplay.style.color = '#2ecc71'; /* 绿色 */
} else { } else if (type === 'warning') {
globalStatusDisplay.style.color = '#bdc3c7'; /* 默认灰色 */ globalStatusDisplay.style.color = '#f1c40f'; /* 黄色 */
} }
// loading 状态可以考虑添加一个动画或特定的颜色
} }
function lockControls(lock) { function lockControls(lock) {
importModelBtn.disabled = lock; // 在某些状态下,即使不处于锁定状态,按钮也可能被特定逻辑禁用
startStopBtn.disabled = lock; // 所以这里只处理整体的禁用/启用
importModelBtn.disabled = lock || isModelLoaded; // 如果模型已加载,手动导入按钮通常也应禁用
startStopBtn.disabled = lock || !isModelLoaded; // 必须加载模型才能开始
} }
function togglePlaying() { function togglePlaying() {
@ -728,22 +768,23 @@
alert('已导入的模型中没有训练数据,请导入一个有效的模型文件。'); alert('已导入的模型中没有训练数据,请导入一个有效的模型文件。');
return; return;
} }
if (classifier.getNumClasses() < Object.keys(gestureClassToAudioMap).length) { // 这里的警告信息如果是在自动加载时已经给出,可以考虑不重复。
alert(`警告:导入的模型只包含 ${classifier.getNumClasses()} 个类别,但需要 ${Object.keys(gestureClassToAudioMap).length} 个音符手势。请确保导入完整的模型!`); // 考虑到用户可能会忽略,此处保留。
// 允许继续,但用户会发现部分音符无法弹奏 if (classifier.getNumClasses() < Object.keys(gestureClassToAudioMap).length && classifier.getNumClasses() > 0) {
alert(`警告:导入的模型只包含 ${classifier.getNumClasses()} 个类别,但需要 ${Object.keys(gestureClassToAudioMap).length} 个音符手势。请确保导入完整的模型!若要继续,点击确定。`);
} }
isPlaying = !isPlaying; isPlaying = !isPlaying;
if (isPlaying) { if (isPlaying) {
startStopBtn.textContent = '停止演奏'; startStopBtn.textContent = '停止演奏';
startStopBtn.classList.add('playing'); startStopBtn.classList.add('playing');
importModelBtn.disabled = true; importModelBtn.disabled = true; // 演奏时不能切换模型
updateGlobalStatus('开始演奏,请摆出您的钢琴手势!', 'info'); // 文本修改 updateGlobalStatus('开始演奏,请摆出您的钢琴手势!', 'info'); // 文本修改
currentPlayingActionDisplay.textContent = '无'; currentPlayingActionDisplay.textContent = '无';
} else { } else {
startStopBtn.textContent = '开始演奏'; startStopBtn.textContent = '开始演奏';
startStopBtn.classList.remove('playing'); startStopBtn.classList.remove('playing');
importModelBtn.disabled = false; importModelBtn.disabled = false; // 停止演奏后允许重新导入
updateGlobalStatus('已停止演奏,等待您开始。', 'ready'); updateGlobalStatus('已停止演奏,等待您开始。', 'ready');
currentPlayingActionDisplay.textContent = '无'; currentPlayingActionDisplay.textContent = '无';
currentPlayingActionId = null; // 停止演奏时重置当前播放音符ID currentPlayingActionId = null; // 停止演奏时重置当前播放音符ID
@ -767,9 +808,19 @@
const noteName = orderedNoteNames[i]; const noteName = orderedNoteNames[i];
const listItem = document.createElement('li'); const listItem = document.createElement('li');
listItem.innerHTML = `<strong>ID ${classId}:</strong> <span class="action-name">${noteName}</span> → 音段 ${i + 1}`; // 从 gestureClassToAudioMap 获取的 name 可以更准确地显示
const mappedNoteName = gestureClassToAudioMap[classId] ? gestureClassToAudioMap[classId].name : noteName;
listItem.innerHTML = `<strong>ID ${classId}:</strong> <span class="action-name">${mappedNoteName}</span> → 音段 ${i + 1}`;
gestureMappingList.appendChild(listItem); gestureMappingList.appendChild(listItem);
} }
// 如果 classifier 中有额外或不同的类别,也可以在这里显示
// for (const classId of classifier.getClassNames()) {
// if (!gestureClassToAudioMap[classId]) {
// const listItem = document.createElement('li');
// listItem.innerHTML = `<strong>ID ${classId}:</strong> <span class="action-name">未知/自定义手势</span>`;
// gestureMappingList.appendChild(listItem);
// }
// }
} }
// --- 应用启动和清理 --- // --- 应用启动和清理 ---