[MF]修改从CDN自动加载模型
This commit is contained in:
parent
9a3e94d318
commit
976e46387f
@ -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,9 +127,46 @@ 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) {
|
||||||
|
if (!knnClassifier) {
|
||||||
|
showStatus(MODEL_STATUS, 'error', 'KNN 分类器未初始化。请先加载 MobileNet 模型。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let modelData = null;
|
||||||
|
let binData = null;
|
||||||
|
let modelName = '未知模型';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (jsonUrl && binUrl) {
|
||||||
|
// 从 CDN URL 加载
|
||||||
|
showStatus(MODEL_STATUS, 'info', `正在从 CDN 加载模型配置文件 (${jsonUrl})...`);
|
||||||
|
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');
|
const inputJson = document.createElement('input');
|
||||||
inputJson.type = 'file';
|
inputJson.type = 'file';
|
||||||
inputJson.accept = '.json';
|
inputJson.accept = '.json';
|
||||||
@ -106,36 +174,39 @@ async function loadKNNModel() {
|
|||||||
|
|
||||||
showStatus(MODEL_STATUS, 'info', '请先选择 KNN 模型配置文件 (.json)...');
|
showStatus(MODEL_STATUS, 'info', '请先选择 KNN 模型配置文件 (.json)...');
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
inputJson.onchange = async (e) => {
|
inputJson.onchange = async (e) => {
|
||||||
const jsonFile = e.target.files[0];
|
const jsonFile = e.target.files[0];
|
||||||
if (!jsonFile) {
|
if (!jsonFile) {
|
||||||
showStatus(MODEL_STATUS, 'info', '未选择 .json 文件。');
|
showStatus(MODEL_STATUS, 'info', '未选择 .json 文件。');
|
||||||
updateModelUI(false);
|
updateModelUI(false);
|
||||||
return;
|
return reject(new Error('No JSON file selected.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
showStatus(MODEL_STATUS, 'info', `正在解析 ${jsonFile.name}...`);
|
showStatus(MODEL_STATUS, 'info', `正在解析 ${jsonFile.name}...`);
|
||||||
|
modelName = jsonFile.name;
|
||||||
|
|
||||||
let modelData;
|
|
||||||
try {
|
try {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
const jsonText = await new Promise((resolve, reject) => {
|
const jsonText = await new Promise((res, rej) => {
|
||||||
reader.onload = () => resolve(reader.result);
|
reader.onload = () => res(reader.result);
|
||||||
reader.onerror = () => reject(reader.error);
|
reader.onerror = () => rej(reader.error);
|
||||||
reader.readAsText(jsonFile);
|
reader.readAsText(jsonFile);
|
||||||
});
|
});
|
||||||
modelData = JSON.parse(jsonText);
|
modelData = JSON.parse(jsonText);
|
||||||
|
|
||||||
if (!modelData.dataFile) {
|
if (!modelData.dataFile) {
|
||||||
console.warn('模型JSON文件不包含 "dataFile" 字段,尝试以旧的单文件JSON格式加载。');
|
console.warn('模型JSON文件不包含 "dataFile" 字段,尝试以旧的单文件JSON格式加载。');
|
||||||
return loadSingleJsonModel(modelData);
|
// 对于旧的单文件模型,直接加载并结束
|
||||||
|
await loadSingleJsonModel(modelData);
|
||||||
|
return resolve(); // 成功加载旧模型,返回
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
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);
|
||||||
return;
|
return reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputBin = document.createElement('input');
|
const inputBin = document.createElement('input');
|
||||||
@ -150,33 +221,45 @@ async function loadKNNModel() {
|
|||||||
if (!binFile) {
|
if (!binFile) {
|
||||||
showStatus(MODEL_STATUS, 'info', '未选择 .bin 文件。');
|
showStatus(MODEL_STATUS, 'info', '未选择 .bin 文件。');
|
||||||
updateModelUI(false);
|
updateModelUI(false);
|
||||||
return;
|
return reject(new Error('No BIN file selected.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (binFile.name !== modelData.dataFile) {
|
if (binFile.name !== modelData.dataFile) {
|
||||||
showStatus(MODEL_STATUS, 'error', `选择的 .bin 文件名 "${binFile.name}" 与 .json 中定义的 "${modelData.dataFile}" 不匹配!请选择正确的文件。`);
|
showStatus(MODEL_STATUS, 'error', `选择的 .bin 文件名 "${binFile.name}" 与 .json 中定义的 "${modelData.dataFile}" 不匹配!请选择正确的文件。`);
|
||||||
updateModelUI(false);
|
updateModelUI(false);
|
||||||
return;
|
return reject(new Error('BIN file name mismatch.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
showStatus(MODEL_STATUS, 'info', `正在读取 ${binFile.name} (二进制权重文件)...`);
|
showStatus(MODEL_STATUS, 'info', `正在读取 ${binFile.name} (二进制权重文件)...`);
|
||||||
let binData;
|
|
||||||
try {
|
try {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
const arrayBuffer = await new Promise((resolve, reject) => {
|
const arrayBuffer = await new Promise((res, rej) => {
|
||||||
reader.onload = () => resolve(reader.result);
|
reader.onload = () => res(reader.result);
|
||||||
reader.onerror = () => reject(reader.error);
|
reader.onerror = () => rej(reader.error);
|
||||||
reader.readAsArrayBuffer(binFile);
|
reader.readAsArrayBuffer(binFile);
|
||||||
});
|
});
|
||||||
binData = new Float32Array(arrayBuffer);
|
binData = new Float32Array(arrayBuffer);
|
||||||
|
resolve(); // 成功获取到 binData,解析流程继续
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showStatus(MODEL_STATUS, 'error', `读取 .bin 文件失败: ${error.message}`);
|
showStatus(MODEL_STATUS, 'error', `读取 .bin 文件失败: ${error.message}`);
|
||||||
console.error('读取 .bin 失败:', error);
|
console.error('读取 .bin 失败:', error);
|
||||||
updateModelUI(false);
|
updateModelUI(false);
|
||||||
|
return reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
inputBin.click();
|
||||||
|
};
|
||||||
|
inputJson.click();
|
||||||
|
}); // 结束 Promise 包装的回调
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 modelData 为 null (意味着旧的单文件JSON模型已在上面被处理并返回),则停止后续处理
|
||||||
|
if (!modelData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 执行加载 KNN 分类器的核心逻辑
|
||||||
|
if (modelData && binData) { // 仅当同时有 modelData 和 binData 时才尝试加载
|
||||||
knnClassifier.clearAllClasses();
|
knnClassifier.clearAllClasses();
|
||||||
|
|
||||||
Object.keys(modelData.dataset).forEach(label => {
|
Object.keys(modelData.dataset).forEach(label => {
|
||||||
@ -186,6 +269,11 @@ async function loadKNNModel() {
|
|||||||
|
|
||||||
const featureDim = modelData.featureDim || 1280;
|
const featureDim = modelData.featureDim || 1280;
|
||||||
|
|
||||||
|
// 检查 binData 是否足够大以包含所需的数据
|
||||||
|
if (startFloat32ElementIndex + numFloat32Elements > binData.length) {
|
||||||
|
throw new Error(`模型数据错误: 类别 ${label} 的数据超出 .bin 文件范围。`);
|
||||||
|
}
|
||||||
|
|
||||||
const classFeatures = binData.subarray(startFloat32ElementIndex, startFloat32ElementIndex + numFloat32Elements);
|
const classFeatures = binData.subarray(startFloat32ElementIndex, startFloat32ElementIndex + numFloat32Elements);
|
||||||
|
|
||||||
if (classFeatures.length === 0) {
|
if (classFeatures.length === 0) {
|
||||||
@ -216,7 +304,7 @@ async function loadKNNModel() {
|
|||||||
const sampleTensor = tf.tensor(sampleFeatures, [1, featureDim]);
|
const sampleTensor = tf.tensor(sampleFeatures, [1, featureDim]);
|
||||||
|
|
||||||
knnClassifier.addExample(sampleTensor, parseInt(label));
|
knnClassifier.addExample(sampleTensor, parseInt(label));
|
||||||
tf.dispose(sampleTensor);
|
tf.dispose(sampleTensor); // 及时释放 Tensor 内存
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -224,26 +312,33 @@ async function loadKNNModel() {
|
|||||||
classNames = modelData.classList.map(c => c.name);
|
classNames = modelData.classList.map(c => c.name);
|
||||||
} else {
|
} else {
|
||||||
console.warn('模型JSON中未找到 classList 字段或格式不正确,使用默认类别名称。');
|
console.warn('模型JSON中未找到 classList 字段或格式不正确,使用默认类别名称。');
|
||||||
|
// 如果没有 classList,尝试从 dataset 的键值来生成
|
||||||
classNames = Object.keys(modelData.dataset).map(key => `Class ${parseInt(key) + 1}`);
|
classNames = Object.keys(modelData.dataset).map(key => `Class ${parseInt(key) + 1}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
showStatus(MODEL_STATUS, 'success', `模型 "${jsonFile.name}" 及权重加载成功!类别: ${classNames.join(', ')}。`);
|
showStatus(MODEL_STATUS, 'success', `KNN 模型 "${modelName}" 加载成功!类别: ${classNames.join(', ')}。`);
|
||||||
updateModelUI(true);
|
updateModelUI(true); // 模型已加载,可以启动摄像头
|
||||||
|
|
||||||
} catch (error) {
|
} else if (modelData && !binData && !jsonUrl) {
|
||||||
showStatus(MODEL_STATUS, 'error', `处理模型数据失败: ${error.message}`);
|
// 如果只有 modelData 且不是从 CDN 加载,说明可能是单文件旧格式,但之前的逻辑没成功处理
|
||||||
console.error('处理模型数据失败:', error);
|
// 应该是由 loadSingleJsonModel 捕获,这里作为 fallback
|
||||||
|
showStatus(MODEL_STATUS, 'error', '未知模型加载状态:仅有 JSON 数据,没有 BIN 数据。');
|
||||||
updateModelUI(false);
|
updateModelUI(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
inputBin.click();
|
} catch (error) {
|
||||||
};
|
showStatus(MODEL_STATUS, 'error', `加载 KNN 模型失败: ${error.message}`);
|
||||||
inputJson.click();
|
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 模型
|
||||||
});
|
});
|
||||||
|
@ -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,40 +733,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 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>}
|
||||||
|
*/
|
||||||
|
async function loadKNNModelData(file = null, cdnUrl = null) {
|
||||||
|
document.getElementById('gestureStatus').textContent = '正在加载模型数据...';
|
||||||
|
startBtn.disabled = true; // 加载中禁用开始按钮
|
||||||
|
btnImportModel.disabled = true; // 加载中禁用导入按钮
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const loadedModelData = JSON.parse(e.target.result);
|
let loadedModelData;
|
||||||
|
|
||||||
|
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。');
|
||||||
|
}
|
||||||
|
|
||||||
if (!loadedModelData || !loadedModelData.dataset || !loadedModelData.classMap) {
|
if (!loadedModelData || !loadedModelData.dataset || !loadedModelData.classMap) {
|
||||||
throw new Error('模型数据结构不正确 (缺少 classMap 或 dataset)。');
|
throw new Error('模型数据结构不正确 (缺少 classMap 或 dataset)。');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除分类器之前可能存在的任何数据
|
|
||||||
classifier.clearAllClasses();
|
classifier.clearAllClasses();
|
||||||
|
|
||||||
const dataset = {};
|
const dataset = {};
|
||||||
let totalExamples = 0;
|
let totalExamples = 0;
|
||||||
|
|
||||||
// 恢复分类器数据集
|
|
||||||
for (const classId in loadedModelData.dataset) {
|
for (const classId in loadedModelData.dataset) {
|
||||||
const classData = loadedModelData.dataset[classId];
|
const classData = loadedModelData.dataset[classId];
|
||||||
if (classData && classData.length > 0) {
|
if (classData && classData.length > 0) {
|
||||||
// 确保所有样本的特征长度一致,并且是数字数组
|
|
||||||
if (classData.some(sample => !Array.isArray(sample) || sample.some(val => typeof val !== 'number'))) {
|
if (classData.some(sample => !Array.isArray(sample) || sample.some(val => typeof val !== 'number'))) {
|
||||||
throw new Error(`类别 ${classId} 包含无效的样本数据 (不是数字数组)。`);
|
throw new Error(`类别 ${classId} 包含无效的样本数据 (不是数字数组)。`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tensors = classData.map(data => tf.tensor1d(data));
|
const tensors = classData.map(data => tf.tensor1d(data));
|
||||||
// tf.stack 会将一组张量沿新轴堆叠起来
|
|
||||||
// 如果样本特征都是 1D 张量,stack 后会变成 2D 张量 [numSamples, featureLength]
|
|
||||||
const stacked = tf.stack(tensors);
|
const stacked = tf.stack(tensors);
|
||||||
dataset[classId] = stacked;
|
dataset[classId] = stacked;
|
||||||
totalExamples += classData.length;
|
totalExamples += classData.length;
|
||||||
// 及时清理临时张量
|
|
||||||
tensors.forEach(t => t.dispose());
|
tensors.forEach(t => t.dispose());
|
||||||
} else {
|
} else {
|
||||||
console.warn(`类别 ${classId} 没有样本数据。`);
|
console.warn(`类别 ${classId} 没有样本数据。`);
|
||||||
@ -771,39 +810,42 @@
|
|||||||
isModelLoaded = true; // 设置模型已加载状态
|
isModelLoaded = true; // 设置模型已加载状态
|
||||||
startBtn.disabled = false; // 启用开始游戏按钮
|
startBtn.disabled = false; // 启用开始游戏按钮
|
||||||
btnImportModel.disabled = true; // 导入按钮禁用
|
btnImportModel.disabled = true; // 导入按钮禁用
|
||||||
resolve();
|
|
||||||
|
// 如果模型加载成功,且之前是因为没有模型而被禁用,现在应该解锁开始按钮
|
||||||
|
if(isHandDetectionReady && isModelLoaded) {
|
||||||
|
startBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('模型加载失败:', error);
|
console.error('模型加载失败:', error);
|
||||||
reject(new Error(`模型导入失败: ${error.message}`));
|
document.getElementById('gestureStatus').textContent = `模型加载失败: ${error.message}`;
|
||||||
|
isModelLoaded = false;
|
||||||
|
startBtn.disabled = true; // 失败后保持禁用
|
||||||
|
btnImportModel.disabled = false; // 失败后可再次导入
|
||||||
|
throw error; // 重新抛出错误,以便调用者(如initHandDetection)能捕获
|
||||||
} finally {
|
} finally {
|
||||||
fileImporter.value = ''; // 清空文件输入
|
fileImporter.value = ''; // 清空文件输入
|
||||||
}
|
}
|
||||||
};
|
|
||||||
reader.onerror = (error) => {
|
|
||||||
console.error('文件读取失败:', error);
|
|
||||||
reject(new Error('文件读取失败。'));
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件选择事件处理器
|
|
||||||
|
// 文件选择事件处理器 (现在它调用通用加载函数)
|
||||||
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;
|
||||||
|
if (isGameActive && lockedGesture !== null) {
|
||||||
|
// 如果在游戏倒计时且手势已锁定,则不更新状态文本防止抖动
|
||||||
|
document.getElementById('gestureStatus').textContent = `已锁定手势: ${choiceNames[lockedGesture]} (${confidence}%)`;
|
||||||
|
document.getElementById('gestureStatus').style.color = '#00ff00';
|
||||||
|
} else {
|
||||||
document.getElementById('gestureStatus').textContent = `识别不足 (置信度: ${confidence}%)`;
|
document.getElementById('gestureStatus').textContent = `识别不足 (置信度: ${confidence}%)`;
|
||||||
document.getElementById('gestureStatus').style.color = '#ffaa00'; // 橙色警告
|
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'; // 隐藏卡片
|
||||||
|
1
game/石头剪刀布/hand-knn-model-2.json
Normal file
1
game/石头剪刀布/hand-knn-model-2.json
Normal file
File diff suppressed because one or more lines are too long
1
game/贪吃蛇/pose-knn-model.json
Normal file
1
game/贪吃蛇/pose-knn-model.json
Normal file
File diff suppressed because one or more lines are too long
@ -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,45 +612,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================
|
// ==========================================================
|
||||||
// 模型导入导出
|
// 模型导入导出 (改造为支持文件和 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;
|
|
||||||
startBtn.disabled = false; // 启用开始按钮
|
|
||||||
importModelBtn.disabled = true; // 禁用导入按钮
|
|
||||||
updateGameStatus('ready');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('模型导入失败:', error);
|
|
||||||
statusDisplay.textContent = `模型导入失败: ${error.message}`;
|
|
||||||
alert(`模型导入失败: ${error.message}\n请确保文件是正确的模型JSON文件。`);
|
|
||||||
startBtn.disabled = true; // 导入失败则不能开始
|
|
||||||
importModelBtn.disabled = false; // 可以再试一次导入
|
|
||||||
isModelLoaded = false;
|
|
||||||
updateGameStatus('initial');
|
|
||||||
} finally {
|
|
||||||
fileImporter.value = ''; // 清空文件输入
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModelFromFile(file) {
|
if (file) {
|
||||||
// 这段逻辑与您之前在 script.js 中 `importModel` 的核心逻辑一致
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async (e) => {
|
const fileReadPromise = new Promise((resolve, reject) => {
|
||||||
try {
|
reader.onload = e => resolve(JSON.parse(e.target.result));
|
||||||
const loadedModelData = 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) {
|
if (!loadedModelData || !loadedModelData.dataset || !loadedModelData.classMap) {
|
||||||
throw new Error('模型数据结构不正确 (缺少 classMap 或 dataset)。');
|
throw new Error('模型数据结构不正确 (缺少 classMap 或 dataset)。');
|
||||||
@ -645,17 +678,65 @@
|
|||||||
|
|
||||||
console.log(`模型加载成功!共加载 ${totalExamples} 个样本。`);
|
console.log(`模型加载成功!共加载 ${totalExamples} 个样本。`);
|
||||||
console.log('类别映射 (导入):', importedClassNames);
|
console.log('类别映射 (导入):', importedClassNames);
|
||||||
resolve();
|
|
||||||
|
// 更新页面上的映射显示,如果需要的话。
|
||||||
|
// 确保 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');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
console.error('模型加载失败:', error);
|
||||||
|
statusDisplay.textContent = `模型加载失败: ${error.message}`;
|
||||||
|
isModelLoaded = false;
|
||||||
|
startBtn.disabled = true; // 失败后保持禁用
|
||||||
|
importModelBtn.disabled = false; // 失败后可再次导入
|
||||||
|
updateGameStatus('initial');
|
||||||
|
throw error; // 重新抛出错误,以便调用者能捕获
|
||||||
|
} finally {
|
||||||
|
fileImporter.value = ''; // 清空文件输入
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 文件选择事件处理器 (现在它调用通用加载函数)
|
||||||
|
async function handleModelImport(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) {
|
||||||
|
statusDisplay.textContent = '未选择文件。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusDisplay.textContent = '正在从本地文件导入模型...';
|
||||||
|
startBtn.disabled = true; // 导入中禁用开始按钮
|
||||||
|
importModelBtn.disabled = true; // 导入中禁用导入按钮
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadKNNModelData(file, null); // 传入文件对象,cdnUrl 为 null
|
||||||
|
} catch (error) {
|
||||||
|
// 错误信息已在 loadKNNModelData 内部处理并设置状态
|
||||||
|
alert(error.message); // 弹出错误提示
|
||||||
}
|
}
|
||||||
};
|
|
||||||
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>
|
||||||
|
1
game/钢琴/hand-knn-model.json
Normal file
1
game/钢琴/hand-knn-model.json
Normal file
File diff suppressed because one or more lines are too long
@ -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,42 +593,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================
|
// ==========================================================
|
||||||
// 模型导入
|
// 模型导入 - 改造为支持文件和 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');
|
|
||||||
isModelLoaded = true;
|
|
||||||
lockControls(false);
|
|
||||||
importModelBtn.disabled = true;
|
|
||||||
startStopBtn.disabled = false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('模型导入失败:', error);
|
|
||||||
updateGlobalStatus(`模型导入失败: ${error.message}`, 'error');
|
|
||||||
alert(`模型导入失败: ${error.message}\n请确保文件是正确的模型JSON文件。`);
|
|
||||||
isModelLoaded = false;
|
|
||||||
lockControls(false);
|
|
||||||
startStopBtn.disabled = true;
|
|
||||||
importModelBtn.disabled = false;
|
|
||||||
} finally {
|
|
||||||
fileImporter.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadModelFromFile(file) {
|
if (file) {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async (e) => {
|
const fileReadPromise = new Promise((resolve, reject) => {
|
||||||
try {
|
reader.onload = e => resolve(JSON.parse(e.target.result));
|
||||||
const loadedModelData = 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
|
// 确保模型包含 classMap 和 dataset
|
||||||
if (!loadedModelData || !loadedModelData.dataset || !loadedModelData.classMap) {
|
if (!loadedModelData || !loadedModelData.dataset || !loadedModelData.classMap) {
|
||||||
@ -621,6 +640,7 @@
|
|||||||
for (const classId in loadedModelData.dataset) {
|
for (const classId in loadedModelData.dataset) {
|
||||||
const classData = loadedModelData.dataset[classId];
|
const classData = loadedModelData.dataset[classId];
|
||||||
if (classData && classData.length > 0) {
|
if (classData && classData.length > 0) {
|
||||||
|
// 验证数据格式
|
||||||
if (classData.some(sample => !Array.isArray(sample) || sample.some(val => typeof val !== 'number'))) {
|
if (classData.some(sample => !Array.isArray(sample) || sample.some(val => typeof val !== 'number'))) {
|
||||||
throw new Error(`类别 ${classId} 包含无效的样本数据 (不是数字数组)。`);
|
throw new Error(`类别 ${classId} 包含无效的样本数据 (不是数字数组)。`);
|
||||||
}
|
}
|
||||||
@ -635,25 +655,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
classifier.setClassifierDataset(dataset);
|
classifier.setClassifierDataset(dataset);
|
||||||
// 您可以在这里遍历 loadedModelData.classMap 来更新 `gestureClassToAudioMap` 中的 `name` 字段
|
|
||||||
|
// 可以在这里根据 loadedModelData.classMap 动态更新 gestureClassToAudioMap 中的 name 字段
|
||||||
// 以便 UI 上的音符名称直接从训练模型中获取。
|
// 以便 UI 上的音符名称直接从训练模型中获取。
|
||||||
// 当前代码是直接使用 `gestureClassToAudioMap` 中预设的音符名称。
|
// 例如:
|
||||||
|
// loadedModelData.classMap.forEach(item => {
|
||||||
|
// if (gestureClassToAudioMap[item.classId]) {
|
||||||
|
// gestureClassToAudioMap[item.classId].name = item.className;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
updateGestureMappingUI(); // 再次调用以确保UI更新
|
updateGestureMappingUI(); // 再次调用以确保UI更新
|
||||||
|
|
||||||
console.log(`模型加载成功!共加载 ${totalExamples} 个样本。`);
|
console.log(`模型加载成功!共加载 ${totalExamples} 个样本。`);
|
||||||
console.log('导入类别映射:', loadedModelData.classMap);
|
console.log('导入类别映射:', loadedModelData.classMap);
|
||||||
resolve();
|
|
||||||
|
isModelLoaded = true;
|
||||||
|
lockControls(false);
|
||||||
|
importModelBtn.disabled = true; // 成功加载后应禁用手动导入,除非你想支持重新导入
|
||||||
|
startStopBtn.disabled = false;
|
||||||
|
updateGlobalStatus('手势模型加载成功!', 'success');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
console.error('模型加载失败:', error);
|
||||||
|
updateGlobalStatus(`模型加载失败: ${error.message}`, 'error');
|
||||||
|
alert(`模型加载失败: ${error.message}\n请确保文件是正确的模型JSON文件或 CDN URL 可访问。`);
|
||||||
|
isModelLoaded = false;
|
||||||
|
lockControls(false);
|
||||||
|
importModelBtn.disabled = false; // 失败后允许手动导入
|
||||||
|
startStopBtn.disabled = true;
|
||||||
|
throw error; // 重新抛出错误,以便调用者(如initApp)能捕获
|
||||||
|
} finally {
|
||||||
|
fileImporter.value = ''; // 清除文件选择器的值,以便可以再次选择相同文件
|
||||||
}
|
}
|
||||||
};
|
|
||||||
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);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 应用启动和清理 ---
|
// --- 应用启动和清理 ---
|
||||||
|
Loading…
x
Reference in New Issue
Block a user