605 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// script.js
const VIDEO = document.getElementById('webcam');
const CONNECT_SERIAL_BTN = document.getElementById('connectSerialBtn');
const DISCONNECT_SERIAL_BTN = document.getElementById('disconnectSerialBtn');
const LOAD_MODEL_BTN = document.getElementById('loadModelBtn');
const START_WEBCAM_BTN = document.getElementById('startWebcamBtn');
const STOP_WEBCAM_BTN = document.getElementById('stopWebcamBtn');
const MODEL_STATUS = document.getElementById('modelStatus');
const SERIAL_STATUS = document.getElementById('serialStatus');
const PREDICTION_OUTPUT = document.getElementById('prediction');
let mobilenet; // 这个变量将存储加载后的 MobileNet 模型实例
let knnClassifier; // 这个变量将存储 KNN 分类器实例
let classNames = []; // 类别名称将从加载的模型中获取
let webcamStream = null;
let isPredicting = false;
// Web Serial API 变量
let serialPort = null;
let serialWriter = null;
const SERIAL_BAUD_RATE = 9600;
const SERIAL_SEND_MIN_INTERVAL = 500; // 最短发送间隔500ms用于内部节流
let lastSerialCommand = ''; // 用于sendToSerialPort内部的节流避免重复命令+短间隔)
let lastSerialSendTime = 0;
// ===================================
// 新增变量:追踪串口连接状态,并记录上一次发送到串口的类别命令
let isSerialConnectedState = false; // 跟踪串口的逻辑连接状态
let lastSentClassCommand = null; // 上一次"成功通过确认机制并实际发送"的命令
// ===================================
// ===================================
// 新增变量:用于实现“确认式”发送逻辑
let pendingCommandToSend = null; // 正在等待确认的命令
let pendingCommandTimerId = null; // 确认定时器ID
const CONFIRMATION_DELAY_MS = 100; // 等待 100 毫秒确认 class 是否稳定
// ===================================
// ===================================
// Helper Functions (UI Status)
// ===================================
function showStatus(element, type, message) {
element.className = `status-message status-${type}`;
element.textContent = message;
}
function updateSerialUI(isConnected) {
CONNECT_SERIAL_BTN.disabled = isConnected;
DISCONNECT_SERIAL_BTN.disabled = !isConnected;
isSerialConnectedState = isConnected; // 更新我们维护的串口连接状态
if (!isConnected) {
showStatus(SERIAL_STATUS, 'info', '串口未连接。点击 "连接串口" 开始。');
}
}
function updateWebcamUI(isRunning) {
START_WEBCAM_BTN.disabled = isRunning;
STOP_WEBCAM_BTN.disabled = !isRunning;
}
function updateModelUI(isLoaded) {
LOAD_MODEL_BTN.disabled = false; // 总是允许重新加载模型
START_WEBCAM_BTN.disabled = !isLoaded; // 模型加载后才能启动摄像头
}
// ===================================
// Core Logic: Model & Webcam
// ===================================
async function initModel() {
showStatus(MODEL_STATUS, 'info', '正在加载 MobileNet 模型...');
try {
mobilenet = await window.mobilenet.load({ version: 2, alpha: 1.0 });
knnClassifier = window.knnClassifier.create();
showStatus(MODEL_STATUS, 'success', 'MobileNet 模型和 KNN 分类器已加载。请加载模型文件。');
updateModelUI(false); // 此时 MobileNet 已加载,但 KNN 分类器本身还需加载数据
} catch (error) {
showStatus(MODEL_STATUS, 'error', `模型加载失败: ${error.message}`);
console.error('MobileNet/KNN加载失败:', error);
}
}
// 提取 MobileNet 特征
async function getFeatures(img) {
if (!mobilenet) {
throw new Error("MobileNet model is not loaded.");
}
return tf.tidy(() => {
const embeddings = mobilenet.infer(img, true);
const norm = tf.norm(embeddings);
const normalized = tf.div(embeddings, norm);
return normalized;
});
}
/**
* 加载 KNN 模型文件,支持 '.json' 和 '.bin' 两部分文件。
* 用户需要依次选择对应的 .json 和 .bin 文件。
*/
async function loadKNNModel() {
const inputJson = document.createElement('input');
inputJson.type = 'file';
inputJson.accept = '.json';
inputJson.multiple = false;
showStatus(MODEL_STATUS, 'info', '请先选择 KNN 模型配置文件 (.json)...');
inputJson.onchange = async (e) => {
const jsonFile = e.target.files[0];
if (!jsonFile) {
showStatus(MODEL_STATUS, 'info', '未选择 .json 文件。');
updateModelUI(false);
return;
}
showStatus(MODEL_STATUS, 'info', `正在解析 ${jsonFile.name}...`);
let modelData;
try {
const reader = new FileReader();
const jsonText = await new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsText(jsonFile);
});
modelData = JSON.parse(jsonText);
if (!modelData.dataFile) {
console.warn('模型JSON文件不包含 "dataFile" 字段尝试以旧的单文件JSON格式加载。');
return loadSingleJsonModel(modelData);
}
} catch (error) {
showStatus(MODEL_STATUS, 'error', `解析 .json 文件失败: ${error.message}`);
console.error('解析 .json 失败:', error);
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(', ')}`);
updateModelUI(true);
} catch (error) {
showStatus(MODEL_STATUS, 'error', `处理模型数据失败: ${error.message}`);
console.error('处理模型数据失败:', error);
updateModelUI(false);
}
};
inputBin.click();
};
inputJson.click();
}
/**
* 辅助函数处理旧的单文件JSON模型格式 dataset 字段直接包含数据而不是偏移量)
* @param {object} modelData - 已解析的 JSON 模型数据
*/
async function loadSingleJsonModel(modelData) {
try {
knnClassifier.clearAllClasses();
Object.keys(modelData.dataset).forEach(key => {
const data = modelData.dataset[key];
const featureDim = modelData.featureDim || 1280;
if (data.length % featureDim !== 0) {
throw new Error(`类别 ${key} 的特征数据长度 ${data.length} 与特征维度 ${featureDim} 不匹配!`);
}
const numSamples = data.length / featureDim;
const tensor = tf.tensor(data, [numSamples, featureDim]);
knnClassifier.addExample(tensor, parseInt(key));
});
if (modelData.classList && Array.isArray(modelData.classList)) {
classNames = modelData.classList.map(c => c.name);
} else if (modelData.classNames && Array.isArray(modelData.classNames)) {
classNames = modelData.classNames;
} else {
console.warn('模型JSON中未找到 classList/classNames 字段,使用默认类别名称。');
classNames = Object.keys(modelData.dataset).map(key => `Class ${parseInt(key) + 1}`);
}
showStatus(MODEL_STATUS, 'success', `模型 (单文件JSON格式) 加载成功!类别: ${classNames.join(', ')}`);
updateModelUI(true);
} catch (error) {
showStatus(MODEL_STATUS, 'error', `加载单文件JSON模型失败: ${error.message}`);
console.error('加载单文件JSON模型失败:', error);
updateModelUI(false);
}
}
async function startWebcam() {
if (webcamStream) return;
if (!knnClassifier || knnClassifier.getNumClasses() === 0) {
showStatus(MODEL_STATUS, 'error', '请先加载训练好的模型!');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false });
VIDEO.srcObject = stream;
webcamStream = stream;
updateWebcamUI(true);
VIDEO.onloadeddata = () => {
// ===================================
// 启动摄像头时,重置所有发送状态,确保第一次识别结果可发送
if (pendingCommandTimerId) {
clearTimeout(pendingCommandTimerId);
pendingCommandTimerId = null;
}
pendingCommandToSend = null;
lastSentClassCommand = null;
// ===================================
isPredicting = true;
predictLoop();
showStatus(MODEL_STATUS, 'info', '摄像头已启动,开始实时预测...');
};
} catch (error) {
showStatus(MODEL_STATUS, 'error', `无法访问摄像头: ${error.message}`);
console.error('启动摄像头失败:', error);
updateWebcamUI(false);
}
}
function stopWebcam() {
if (webcamStream) {
webcamStream.getTracks().forEach(track => track.stop());
webcamStream = null;
}
isPredicting = false;
VIDEO.srcObject = null;
updateWebcamUI(false);
PREDICTION_OUTPUT.textContent = '等待识别...';
showStatus(MODEL_STATUS, 'info', '摄像头已停止。');
// ===================================
// 停止摄像头时,清除任何待确认的命令,并发送“停止”或“复位”命令
if (pendingCommandTimerId) {
clearTimeout(pendingCommandTimerId);
pendingCommandTimerId = null;
}
pendingCommandToSend = null;
// 假设 '0' 是停止/复位命令,并且只有当上次发送的不是 '0' 时才发送
if (lastSentClassCommand !== '0') {
sendToSerialPort('0');
}
lastSentClassCommand = null; // 重置,确保下次启动摄像头时能发送初始状态
// ===================================
}
// ============== 重要的修改区域 start ==============
// 定义一个全局变量,用于存储当前帧识别到的命令,供 setTimeout 回调使用
let currentCommandInFrame = '0'; // 初始值默认是 '0' (复位/无动作)
async function predictLoop() {
if (!isPredicting) return;
if (VIDEO.readyState === 4 && VIDEO.videoWidth > 0 && VIDEO.videoHeight > 0) {
let commandCandidate = '0'; // 每次循环开始时,将当前帧的候选命令初始化为'0'
try {
const features = await getFeatures(VIDEO);
const k = 3;
if (!knnClassifier || knnClassifier.getNumClasses() === 0) {
features.dispose();
PREDICTION_OUTPUT.textContent = 'KNN 分类器未就绪或无数据。';
commandCandidate = '0'; // 使用默认命令
} else {
const prediction = await knnClassifier.predictClass(features, k);
features.dispose();
if (prediction && prediction.confidences) {
let maxConfidence = 0;
let predictedClassIndex = -1;
const confidencesArray = Object.entries(prediction.confidences).map(([key, value]) => ({ index: parseInt(key), confidence: value }));
confidencesArray.forEach(({ index, confidence }) => {
if (confidence > maxConfidence) {
maxConfidence = confidence;
predictedClassIndex = index;
}
});
const confidenceThreshold = 0.75; // 75%置信度
if (predictedClassIndex !== -1 && maxConfidence > confidenceThreshold) {
const className = classNames[predictedClassIndex] || `Class ${predictedClassIndex + 1}`;
const percentage = (maxConfidence * 100).toFixed(1);
PREDICTION_OUTPUT.textContent = `识别为: ${className} (${percentage}%)`;
// 根据类别设置本帧的候选命令
if (predictedClassIndex === 0) {
commandCandidate = '1';
} else if (predictedClassIndex === 1) {
commandCandidate = '2';
} else {
commandCandidate = '0'; // 未匹配到特定类别,或默认复位
}
} else {
PREDICTION_OUTPUT.textContent = `未知或不确定... (最高置信度: ${(maxConfidence * 100).toFixed(1)}%)`;
commandCandidate = '0'; // 不确定也发送'0'回退
}
} else {
PREDICTION_OUTPUT.textContent = '无法识别。';
commandCandidate = '0'; // 无法识别也发送 '0' 回退
}
}
} catch (error) {
console.error('预测错误:', error);
PREDICTION_OUTPUT.textContent = `预测错误: ${error.message}`;
commandCandidate = '0'; // 错误时也发送'0'
}
// =========================================================
// 核心逻辑:确认式发送机制 (Persistence / Confirmation)
// =========================================================
// 更新全局变量 currentCommandInFrame指示当前帧的预测结果
currentCommandInFrame = commandCandidate;
// 只有当当前帧的候选命令与上一次实际发送的命令不同时,才进入确认流程
if (currentCommandInFrame !== lastSentClassCommand) {
// 如果目前没有待确认的命令,或者待确认的命令与当前不同
if (pendingCommandToSend === null || pendingCommandToSend !== currentCommandInFrame) {
if (pendingCommandTimerId) {
clearTimeout(pendingCommandTimerId); // 清除旧的等待定时器
pendingCommandTimerId = null;
// console.log(`[${new Date().toLocaleTimeString()}] 命令在确认前再次变化,取消旧的确认定时器.`);
}
pendingCommandToSend = currentCommandInFrame; // 设置新的待确认命令
// 设置一个定时器,在 CONFIRMATION_DELAY_MS 后进行确认
pendingCommandTimerId = setTimeout(async () => {
// 当定时器触发时,再次检查全局的 currentCommandInFrame
// 如果它仍然与我们开始等待的命令相同,则确认并发送
if (currentCommandInFrame === pendingCommandToSend) {
console.log(`[${new Date().toLocaleTimeString()}] 确认命令 "${pendingCommandToSend}" 稳定,正在发送。`);
await sendToSerialPort(pendingCommandToSend);
lastSentClassCommand = pendingCommandToSend; // 更新上一次实际发送的命令
} else {
console.log(`[${new Date().toLocaleTimeString()}] 命令在确认期内再次变化 (${pendingCommandToSend} -> ${currentCommandInFrame}),不发送。`);
}
pendingCommandToSend = null; // 清理待确认状态
pendingCommandTimerId = null; // 清理定时器ID
}, CONFIRMATION_DELAY_MS);
// console.log(`[${new Date().toLocaleTimeString()}] 检测到新候选命令 "${currentCommandInFrame}",启动 ${CONFIRMATION_DELAY_MS}ms 确认计时器。`);
}
// 如果 currentCommandInFrame 1. 仍然与 pendingCommandToSend 相同
// 并且 2. 不同于 lastSentClassCommand
// 说明它处在“正在等待确认”的状态,不需要做任何事情,让当前定时器继续运行
} else { // 当前帧的命令与上次实际发送的命令相同
// 如果已经发送了相同的命令,或者回到了已发送的命令,那么清除待确认定时器
if (pendingCommandTimerId) {
clearTimeout(pendingCommandTimerId);
pendingCommandTimerId = null;
pendingCommandToSend = null; // 清理待确认状态
// console.log(`[${new Date().toLocaleTimeString()}] 当前命令 "${currentCommandInFrame}" 与上次发送命令相同,或回摆,取消所有待确认发送。`);
}
}
// =========================================================
}
requestAnimationFrame(predictLoop); // 继续下一帧预测
}
// ============== 重要的修改区域 end ==============
// ===================================
// Web Serial API Logic
// ===================================
async function checkWebSerialCompatibility() {
if ('serial' in navigator) {
showStatus(SERIAL_STATUS, 'info', 'Web Serial API 可用!');
CONNECT_SERIAL_BTN.disabled = false;
} else {
showStatus(SERIAL_STATUS, 'error', 'Web Serial API 在此浏览器中不可用。请使用最新版 Chrome');
CONNECT_SERIAL_BTN.disabled = true;
}
}
async function connectSerial() {
showStatus(SERIAL_STATUS, 'info', '正在请求连接串口...');
try {
serialPort = await navigator.serial.requestPort();
serialPort.addEventListener('disconnect', () => {
const disconnectTime = new Date().toLocaleString();
console.warn(`[${disconnectTime}] ❗️❗️❗️ 串口已断开连接事件触发! `); // 强调日志
showStatus(SERIAL_STATUS, 'error', '串口连接已丢失!');
disconnectSerial();
});
await serialPort.open({ baudRate: SERIAL_BAUD_RATE });
console.log('串口已成功打开。尝试获取写入器...');
serialWriter = serialPort.writable.getWriter();
console.log('写入器已获取。');
console.log('串口已连接并打开。');
showStatus(SERIAL_STATUS, 'success', `串口已连接 (Baud: ${SERIAL_BAUD_RATE})`);
updateSerialUI(true); // 更新状态为已连接
} catch (error) {
console.error('连接串口失败:', error);
if (error.name === 'NotFoundError') {
showStatus(SERIAL_STATUS, 'warning', '连接串口请求已取消或未选择端口。请选择一个设备。');
} else if (error.name === 'SecurityError') {
showStatus(SERIAL_STATUS, 'error', `连接串口失败: ${error.message}。请确保您在安全上下文 (HTTPS 或 localhost) 中运行。`);
} else {
showStatus(SERIAL_STATUS, 'error', `连接串口失败: ${error.message}`);
}
updateSerialUI(false); // 更新状态为未连接
}
}
async function disconnectSerial() {
console.trace(`[${new Date().toLocaleString()}] disconnectSerial() called`);
if (!isSerialConnectedState && !serialPort && !serialWriter) {
console.log('Already in disconnected state, skipping further cleanup.');
return;
}
// ===================================
// 断开连接时,清除任何待确认的定时器和状态
if (pendingCommandTimerId) {
clearTimeout(pendingCommandTimerId);
pendingCommandTimerId = null;
}
pendingCommandToSend = null;
// ===================================
try {
if (serialWriter) {
await serialWriter.close();
serialWriter = null;
}
if (serialPort && serialPort.readable) {
const reader = serialPort.readable.getReader();
if (reader) {
await reader.cancel();
reader.releaseLock();
}
}
if (serialPort && serialPort.isOpen) {
await serialPort.close();
}
serialPort = null;
console.log('串口已断开。');
showStatus(SERIAL_STATUS, 'info', '串口已断开。');
} catch (error) {
console.error('断开串口失败或串口已处于非连接状态:', error);
showStatus(SERIAL_STATUS, 'error', `断开串口失败: ${error.message}`);
} finally {
updateSerialUI(false); // 无论如何都确保UI和状态更新为断开
lastSentClassCommand = null; // 断开连接时,重置上一次发送的命令,确保重连后第一次变化能发送
}
}
/**
* 低级串口发送函数。包含内部的频繁发送节流。
* @param {string} command - 要发送的命令字符串。
*/
async function sendToSerialPort(command) {
if (!isSerialConnectedState) {
// console.warn('串口已断开(逻辑状态),无法发送命令。');
return;
}
if (!serialWriter) {
console.warn(`[${new Date().toLocaleString()}] 串口连接状态为 true但 serialWriter 不可用。尝试重置串口连接。`);
disconnectSerial();
return;
}
// 避免频繁发送相同的命令 (sendToSerialPort 内部的节流)
const currentTime = new Date().getTime();
if (command === lastSerialCommand && (currentTime - lastSerialSendTime < SERIAL_SEND_MIN_INTERVAL)) {
return; // 命令相同且发送间隔太短,不发送
}
try {
const encoder = new TextEncoder();
const data = encoder.encode(command + '\n');
await serialWriter.write(data);
console.log(`[${new Date().toLocaleTimeString()}] 实际发送串口命令: ${command}`);
lastSerialCommand = command; // 更新内部节流用的命令
lastSerialSendTime = currentTime;
} catch (error) {
console.error(`[${new Date().toLocaleTimeString()}] 实际发送串口命令失败: ${error.message}`);
showStatus(SERIAL_STATUS, 'error', `发送串口命令失败: ${error.message},正在断开串口。`);
disconnectSerial();
}
}
// ===================================
// Event Listeners
// ===================================
CONNECT_SERIAL_BTN.addEventListener('click', connectSerial);
DISCONNECT_SERIAL_BTN.addEventListener('click', disconnectSerial);
LOAD_MODEL_BTN.addEventListener('click', loadKNNModel);
START_WEBCAM_BTN.addEventListener('click', startWebcam);
STOP_WEBCAM_BTN.addEventListener('click', stopWebcam);
// ===================================
// Initialization
// ===================================
document.addEventListener('DOMContentLoaded', () => {
checkWebSerialCompatibility(); // 检查 Web Serial API 兼容性
initModel(); // 加载 MobileNet 和 KNN 分类器实例
});