2025-08-26 09:47:04 +08:00

721 lines
34 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');
const WEBCAM_STATUS_DISPLAY = document.getElementById('webcam-status-display'); // !!! ADDED !!!
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', '串口未连接。点击 "连接串口" 开始。');
}
}
// !!! MODIFIED: Adjusted updateWebcamUI to use the new WEBCAM_STATUS_DISPLAY !!!
function updateWebcamUI(isRunning) {
START_WEBCAM_BTN.disabled = isRunning;
STOP_WEBCAM_BTN.disabled = !isRunning;
if (isRunning) {
showStatus(WEBCAM_STATUS_DISPLAY, 'info', '摄像头已启动,等待模型预测...');
PREDICTION_OUTPUT.classList.remove('idle', 'error'); // !!! ADDED !!!
} else {
showStatus(WEBCAM_STATUS_DISPLAY, 'info', '摄像头未启动');
PREDICTION_OUTPUT.classList.add('idle'); // !!! ADDED !!!
PREDICTION_OUTPUT.textContent = '等待识别...'; // !!! ADDED !!!
}
}
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 模型...');
showStatus(WEBCAM_STATUS_DISPLAY, 'info', '系统初始化中...'); // !!! ADDED !!!
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 });
knnClassifier = window.knnClassifier.create();
showStatus(MODEL_STATUS, 'success', '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) {
showStatus(MODEL_STATUS, 'error', `模型加载失败: ${error.message}`);
showStatus(WEBCAM_STATUS_DISPLAY, 'error', '模型加载失败'); // !!! ADDED !!!
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' 两部分文件。
* 支持从 URL 或用户选择的文件加载。
* @param {string} [jsonUrl] - 可选KNN 模型 .json 文件的 URL。
* @param {string} [binUrl] - 可选KNN 模型 .bin 文件的 URL。
*/
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');
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;
}
// 执行加载 KNN 分类器的核心逻辑
if (modelData && binData) { // 仅当同时有 modelData 和 binData 时才尝试加载
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;
// 检查 binData 是否足够大以包含所需的数据
if (startFloat32ElementIndex + numFloat32Elements > binData.length) {
throw new Error(`模型数据错误: 类别 ${label} 的数据超出 .bin 文件范围。`);
}
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); // 及时释放 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}`);
}
showStatus(MODEL_STATUS, 'success', `KNN 模型 "${modelName}" 加载成功!类别: ${classNames.join(', ')}`);
updateModelUI(true); // 模型已加载,可以启动摄像头
} 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}`);
showStatus(WEBCAM_STATUS_DISPLAY, 'error', '模型加载失败'); // !!! ADDED !!!
console.error('加载 KNN 模型总失败:', error);
updateModelUI(false);
// 重新抛出错误,以便 initModel 可以捕获 CDN 加载失败的情况
throw error;
}
}
/**
* 辅助函数处理旧的单文件JSON模型格式 dataset 字段直接包含数据而不是偏移量)
* @param {object} modelData - 已解析的 JSON 模型数据
* @returns {Promise<void>}
*/
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));
tf.dispose(tensor); // 及时释放 Tensor 内存
});
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}`);
showStatus(WEBCAM_STATUS_DISPLAY, 'error', '模型加载失败'); // !!! ADDED !!!
console.error('加载单文件JSON模型失败:', error);
updateModelUI(false);
throw error; // 重新抛出错误
}
}
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(WEBCAM_STATUS_DISPLAY, 'success', `摄像头已运行,识别中...`); // !!! MODIFIED !!!
PREDICTION_OUTPUT.classList.remove('idle', 'error'); // !!! ADDED !!!
};
} catch (error) {
showStatus(MODEL_STATUS, 'error', `无法访问摄像头: ${error.message}`);
showStatus(WEBCAM_STATUS_DISPLAY, 'error', '无法启动摄像头'); // !!! MODIFIED !!!
console.error('启动摄像头失败:', error);
updateWebcamUI(false);
}
}
function stopWebcam() {
if (webcamStream) {
webcamStream.getTracks().forEach(track => track.stop());
webcamStream = null;
}
isPredicting = false;
VIDEO.srcObject = null;
updateWebcamUI(false); // !!! MODIFIED !!!
showStatus(WEBCAM_STATUS_DISPLAY, 'info', '摄像头已停止'); // !!! MODIFIED !!!
// ===================================
// 停止摄像头时,清除任何待确认的命令,并发送“停止”或“复位”命令
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 分类器未就绪或无数据。';
PREDICTION_OUTPUT.classList.add('error'); // !!! ADDED !!!
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;
if (predictedClassIndex !== -1 && maxConfidence > confidenceThreshold) {
const className = classNames[predictedClassIndex] || `Class ${predictedClassIndex + 1}`;
const percentage = (maxConfidence * 100).toFixed(1);
PREDICTION_OUTPUT.textContent = `识别为: ${className} (${percentage}%)`;
PREDICTION_OUTPUT.classList.remove('idle', 'error'); // !!! MODIFIED !!!
if (predictedClassIndex === 0) {
commandCandidate = '1';
} else if (predictedClassIndex === 1) {
commandCandidate = '2';
} else {
commandCandidate = '0';
}
} else {
PREDICTION_OUTPUT.textContent = `未知或不确定... (最高置信度: ${(maxConfidence * 100).toFixed(1)}%)`;
PREDICTION_OUTPUT.classList.add('idle'); // !!! MODIFIED !!!
commandCandidate = '0';
}
} else {
PREDICTION_OUTPUT.textContent = '无法识别。';
PREDICTION_OUTPUT.classList.add('error'); // !!! ADDED !!!
commandCandidate = '0';
}
}
} catch (error) {
console.error('预测错误:', error);
PREDICTION_OUTPUT.textContent = `预测错误: ${error.message}`;
PREDICTION_OUTPUT.classList.add('error'); // !!! ADDED !!!
commandCandidate = '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(null, null)); // 手动加载时,不传 CDN URL
START_WEBCAM_BTN.addEventListener('click', startWebcam);
STOP_WEBCAM_BTN.addEventListener('click', stopWebcam);
// ===================================
// Initialization
// ===================================
document.addEventListener('DOMContentLoaded', () => {
checkWebSerialCompatibility(); // 检查 Web Serial API 兼容性
initModel(); // 加载 MobileNet 和 KNN 分类器实例,并尝试自动加载 KNN 模型
});