[MF]统一UI
This commit is contained in:
parent
976e46387f
commit
59ba028e01
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"liveServer.settings.port": 5501
|
||||
}
|
BIN
game/分类器.zip
Normal file
BIN
game/分类器.zip
Normal file
Binary file not shown.
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Web Serial KNN Classifier</title>
|
||||
<title>Goood Space - Web Serial 实时分类器</title>
|
||||
<!-- TensorFlow.js 核心库 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"></script>
|
||||
<!-- MobileNet 模型 -->
|
||||
@ -11,41 +11,439 @@
|
||||
<!-- KNN 分类器 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@latest"></script>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 20px; text-align: center; background-color: #f0f0f0; }
|
||||
h1 { color: #333; }
|
||||
.container { max-width: 800px; margin: 20px auto; padding: 20px; background-color: white; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); }
|
||||
video { width: 100%; max-width: 640px; border: 1px solid #ccc; background-color: black; margin-top: 15px; border-radius: 4px;}
|
||||
button { padding: 10px 20px; font-size: 16px; margin: 5px; cursor: pointer; border: none; border-radius: 4px; transition: background-color 0.3s; }
|
||||
button:hover:not(:disabled) { background-color: #007bff; color: white; }
|
||||
button:disabled { background-color: #ccc; cursor: not-allowed; }
|
||||
.status-message { margin-top: 15px; padding: 10px; border-radius: 4px; }
|
||||
.status-info { background-color: #e0f7fa; color: #007bff; }
|
||||
.status-success { background-color: #e8f5e9; color: #4caf50; }
|
||||
.status-error { background-color: #ffebee; color: #f44336; }
|
||||
#prediction { font-size: 1.2em; font-weight: bold; margin-top: 20px; color: #333; }
|
||||
#serialStatus { margin-top: 10px; }
|
||||
/* Goood Space 统一 UI 风格 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #2d1b69 100%);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
overflow-x: hidden;
|
||||
position: relative; /* For pseudo-elements positioning */
|
||||
}
|
||||
|
||||
/* Background Animation */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
background: radial-gradient(circle, rgba(255, 0, 255, 0.15) 0%, transparent 70%); /* Purple/Pink orb */
|
||||
animation: rotate 35s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
top: -25%;
|
||||
left: -25%;
|
||||
background: radial-gradient(circle, rgba(0, 212, 255, 0.08) 0%, transparent 60%); /* Blue orb */
|
||||
animation: rotate-reverse 40s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes rotate-reverse {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
animation: fadeInDown 0.8s ease-out;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 3.5rem;
|
||||
background: linear-gradient(90deg, #ff00ff, #00d4ff, #00ff88); /* Purple to Green palette */
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 0 60px rgba(255, 0, 255, 0.5);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.5rem;
|
||||
color: #a8c0ff;
|
||||
text-shadow: 0 0 20px rgba(168, 192, 255, 0.5);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Main Container */
|
||||
.main-container {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
flex-wrap: wrap; /* Allow wrapping on smaller screens */
|
||||
justify-content: center;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
/* For this single-page layout, we can center the entire content */
|
||||
align-items: flex-start; /* Align prediction results to the top */
|
||||
}
|
||||
|
||||
/* Card (Panel) Styles - Unified Look */
|
||||
.card { /* Using 'card' as a generic panel style */
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 0 30px rgba(255, 0, 255, 0.3); /* Accent glow */
|
||||
color: #e0f2f7;
|
||||
min-width: 400px;
|
||||
flex: 1; /* Allow to grow */
|
||||
}
|
||||
|
||||
.card.wide { /* For the camera and prediction section */
|
||||
flex-basis: 100%; /* Take full width */
|
||||
max-width: 900px; /* Adjust maximum width for the combined video + prediction */
|
||||
order: 1; /* Position it below the two control columns */
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
color: #ff00ff; /* Accent color for card titles */
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid rgba(255, 0, 255, 0.3);
|
||||
padding-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Button Group Structure */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 1rem; /* Spacing between buttons */
|
||||
margin: 1.5rem 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* General Button Styles */
|
||||
button {
|
||||
padding: 1rem 2rem;
|
||||
border: none;
|
||||
border-radius: 50px; /* Pill shape */
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
flex: 1; /* Allow buttons within a group to share space */
|
||||
min-width: 120px; /* Minimum width for buttons */
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: scale(1.03); /* Slight scale on hover */
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #444;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Specific Button Colors */
|
||||
#connectSerialBtn {
|
||||
background: linear-gradient(90deg, #2196F3, #21CBF3); /* Goood Space Blue */
|
||||
color: white;
|
||||
}
|
||||
#connectSerialBtn:hover:not(:disabled) {
|
||||
background: linear-gradient(90deg, #21CBF3, #667eea);
|
||||
box-shadow: 0 6px 20px rgba(33, 150, 243, 0.4);
|
||||
}
|
||||
|
||||
#disconnectSerialBtn {
|
||||
background: linear-gradient(90deg, #f44336, #e91e63); /* Goood Space Red */
|
||||
color: white;
|
||||
}
|
||||
#disconnectSerialBtn:hover:not(:disabled) {
|
||||
background: linear-gradient(90deg, #e91e63, #c0392b);
|
||||
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
#loadModelBtn {
|
||||
background: linear-gradient(90deg, #FFC107, #FFEB3B); /* Goood Space Yellow/Orange */
|
||||
color: #333; /* Dark text for contrast on light background */
|
||||
}
|
||||
#loadModelBtn:hover:not(:disabled) {
|
||||
background: linear-gradient(90deg, #FFEB3B, #f1c40f);
|
||||
box-shadow: 0 6px 20px rgba(255, 193, 7, 0.4);
|
||||
}
|
||||
|
||||
#startWebcamBtn {
|
||||
background: linear-gradient(90deg, #00ff88, #2ecc71); /* Goood Space Green */
|
||||
color: white;
|
||||
}
|
||||
#startWebcamBtn:hover:not(:disabled) {
|
||||
background: linear-gradient(90deg, #2ecc71, #27ae60);
|
||||
box-shadow: 0 6px 20px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
#stopWebcamBtn {
|
||||
background: linear-gradient(90deg, #ff4444, #e74c3c); /* Matches Red */
|
||||
color: white;
|
||||
}
|
||||
#stopWebcamBtn:hover:not(:disabled) {
|
||||
background: linear-gradient(90deg, #e74c3c, #c0392b);
|
||||
box-shadow: 0 6px 20px rgba(255, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
/* Status Messages - Standardized from KNN classifier */
|
||||
.status-message {
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
margin: 1.5rem 0;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background: rgba(0, 212, 255, 0.15); /* Blue translucent */
|
||||
color: #00d4ff;
|
||||
border-color: #00d4ff;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: rgba(0, 255, 136, 0.15); /* Green translucent */
|
||||
color: #00ff88;
|
||||
border-color: #00ff88;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: rgba(255, 68, 68, 0.15); /* Red translucent */
|
||||
color: #ff4444;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background: rgba(255, 170, 0, 0.15); /* Orange translucent */
|
||||
color: #ffaa00;
|
||||
border-color: #ffaa00;
|
||||
}
|
||||
|
||||
/* Video Output and Prediction Display */
|
||||
#webcam-container { /* Added wrapper for video and status */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 640px; /* Standard video width */
|
||||
margin: 2rem auto; /* Center it */
|
||||
border-radius: 15px;
|
||||
overflow: hidden; /* Ensure video respects border-radius */
|
||||
background: #000;
|
||||
box-shadow: 0 0 30px rgba(255, 0, 255, 0.3); /* Matching panel glow */
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto; /* Maintain aspect ratio */
|
||||
display: block; /* Remove extra space below video */
|
||||
border: none; /* Already handled by container border/shadow */
|
||||
border-radius: 15px;
|
||||
background-color: transparent; /* Container handles it now */
|
||||
}
|
||||
|
||||
#webcam-status-display { /* A new combined status bar for webcam section */
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: #00ff88; /* Default green for live status */
|
||||
border-bottom-left-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
z-index: 3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#prediction {
|
||||
font-size: 2rem; /* Larger, more prominent */
|
||||
font-weight: bold;
|
||||
margin-top: 2rem; /* More space */
|
||||
color: #00ff88; /* Green for detected result */
|
||||
text-shadow: 0 0 20px rgba(0, 255, 136, 0.6);
|
||||
padding: 1rem;
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-radius: 10px;
|
||||
min-height: 80px; /* Ensure consistent height */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #00ff88;
|
||||
transition: all 0.3s ease-out; /* Smooth transitions */
|
||||
}
|
||||
|
||||
#prediction.idle { /* Style for 'Waiting for prediction...' */
|
||||
font-size: 1.5rem;
|
||||
color: #a8c0ff;
|
||||
text-shadow: none;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#prediction.error { /* Style for error state */
|
||||
color: #ff4444;
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
border-color: #ff4444;
|
||||
text-shadow: 0 0 15px rgba(255, 68, 68, 0.6);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
margin: 2.5rem auto; /* More visual separation */
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1200px) {
|
||||
.main-container {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-width: unset;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card.wide {
|
||||
max-width: 100%; /* Take full width on smaller screens */
|
||||
}
|
||||
|
||||
#webcam-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.8rem 1.2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
#prediction {
|
||||
font-size: 1.5rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 2rem auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📦 Web Serial 实时分类器</h1>
|
||||
<div class="header">
|
||||
<h1 class="brand-title">Goood Space</h1>
|
||||
<p class="subtitle">Web Serial 实时分类器</p>
|
||||
</div>
|
||||
|
||||
<div id="serialStatus" class="status-message status-info">正在检查 Web Serial API 兼容性...</div>
|
||||
<button id="connectSerialBtn" disabled>连接串口</button>
|
||||
<button id="disconnectSerialBtn" disabled>断开串口</button>
|
||||
<div class="main-container">
|
||||
<!-- 串口控制卡片 -->
|
||||
<div class="card">
|
||||
<h2>🔌 串口连接</h2>
|
||||
<div id="serialStatus" class="status-message status-info">正在检查 Web Serial API 兼容性...</div>
|
||||
<div class="button-group">
|
||||
<button id="connectSerialBtn" disabled>连接串口</button>
|
||||
<button id="disconnectSerialBtn" disabled>断开串口</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 20px 0;">
|
||||
<!-- 模型加载卡片 -->
|
||||
<div class="card">
|
||||
<h2>🧠 模型管理</h2>
|
||||
<div id="modelStatus" class="status-message status-info">正在加载 MobileNet 模型...</div>
|
||||
<div class="button-group">
|
||||
<button id="loadModelBtn">加载模型文件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modelStatus" class="status-message status-info">正在加载 MobileNet 和 KNN 模型...</div>
|
||||
<button id="loadModelBtn">加载模型文件</button>
|
||||
|
||||
<hr style="margin: 20px 0;">
|
||||
<!-- 摄像头与预测结果卡片 (宽屏显示) -->
|
||||
<div class="card wide">
|
||||
<h2>📹 实时预测</h2>
|
||||
<div class="button-group">
|
||||
<button id="startWebcamBtn" disabled>启动摄像头</button>
|
||||
<button id="stopWebcamBtn" disabled>停止摄像头</button>
|
||||
</div>
|
||||
<div id="webcam-container">
|
||||
<video id="webcam" autoplay playsinline muted></video>
|
||||
<div id="webcam-status-display" class="status-message status-info">摄像头未启动</div>
|
||||
</div>
|
||||
|
||||
<button id="startWebcamBtn" disabled>启动摄像头</button>
|
||||
<button id="stopWebcamBtn" disabled>停止摄像头</button>
|
||||
<video id="webcam" autoplay playsinline muted></video>
|
||||
|
||||
<div id="prediction">等待识别...</div>
|
||||
<!-- Prediction output moved here -->
|
||||
<div id="prediction" class="idle">等待识别...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
|
@ -9,6 +9,7 @@ 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 分类器实例
|
||||
@ -55,9 +56,18 @@ function updateSerialUI(isConnected) {
|
||||
}
|
||||
}
|
||||
|
||||
// !!! 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) {
|
||||
@ -70,6 +80,7 @@ function updateModelUI(isLoaded) {
|
||||
// ===================================
|
||||
async function initModel() {
|
||||
showStatus(MODEL_STATUS, 'info', '正在加载 MobileNet 模型...');
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'info', '系统初始化中...'); // !!! ADDED !!!
|
||||
try {
|
||||
// 确保 window.mobilenet 和 window.knnClassifier 库已加载
|
||||
if (!window.mobilenet || !window.knnClassifier) {
|
||||
@ -108,6 +119,7 @@ async function initModel() {
|
||||
|
||||
} catch (error) {
|
||||
showStatus(MODEL_STATUS, 'error', `模型加载失败: ${error.message}`);
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'error', '模型加载失败'); // !!! ADDED !!!
|
||||
console.error('MobileNet/KNN加载失败:', error);
|
||||
}
|
||||
}
|
||||
@ -328,6 +340,7 @@ async function loadKNNModel(jsonUrl = null, binUrl = null) {
|
||||
|
||||
} catch (error) {
|
||||
showStatus(MODEL_STATUS, 'error', `加载 KNN 模型失败: ${error.message}`);
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'error', '模型加载失败'); // !!! ADDED !!!
|
||||
console.error('加载 KNN 模型总失败:', error);
|
||||
updateModelUI(false);
|
||||
// 重新抛出错误,以便 initModel 可以捕获 CDN 加载失败的情况
|
||||
@ -368,6 +381,7 @@ async function loadSingleJsonModel(modelData) {
|
||||
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; // 重新抛出错误
|
||||
@ -401,11 +415,13 @@ async function startWebcam() {
|
||||
// ===================================
|
||||
isPredicting = true;
|
||||
predictLoop();
|
||||
showStatus(MODEL_STATUS, 'info', '摄像头已启动,开始实时预测...');
|
||||
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);
|
||||
}
|
||||
@ -418,9 +434,8 @@ function stopWebcam() {
|
||||
}
|
||||
isPredicting = false;
|
||||
VIDEO.srcObject = null;
|
||||
updateWebcamUI(false);
|
||||
PREDICTION_OUTPUT.textContent = '等待识别...';
|
||||
showStatus(MODEL_STATUS, 'info', '摄像头已停止。');
|
||||
updateWebcamUI(false); // !!! MODIFIED !!!
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'info', '摄像头已停止'); // !!! MODIFIED !!!
|
||||
|
||||
// ===================================
|
||||
// 停止摄像头时,清除任何待确认的命令,并发送“停止”或“复位”命令
|
||||
@ -455,10 +470,11 @@ async function predictLoop() {
|
||||
if (!knnClassifier || knnClassifier.getNumClasses() === 0) {
|
||||
features.dispose();
|
||||
PREDICTION_OUTPUT.textContent = 'KNN 分类器未就绪或无数据。';
|
||||
commandCandidate = '0'; // 使用默认命令
|
||||
PREDICTION_OUTPUT.classList.add('error'); // !!! ADDED !!!
|
||||
commandCandidate = '0';
|
||||
} else {
|
||||
const prediction = await knnClassifier.predictClass(features, k);
|
||||
features.dispose(); // 及时释放 Tensor 内存
|
||||
features.dispose();
|
||||
|
||||
if (prediction && prediction.confidences) {
|
||||
let maxConfidence = 0;
|
||||
@ -473,33 +489,36 @@ async function predictLoop() {
|
||||
}
|
||||
});
|
||||
|
||||
const confidenceThreshold = 0.75; // 75%置信度
|
||||
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'; // 未匹配到特定类别,或默认复位
|
||||
commandCandidate = '0';
|
||||
}
|
||||
} else {
|
||||
PREDICTION_OUTPUT.textContent = `未知或不确定... (最高置信度: ${(maxConfidence * 100).toFixed(1)}%)`;
|
||||
commandCandidate = '0'; // 不确定也发送'0'回退
|
||||
PREDICTION_OUTPUT.classList.add('idle'); // !!! MODIFIED !!!
|
||||
commandCandidate = '0';
|
||||
}
|
||||
} else {
|
||||
PREDICTION_OUTPUT.textContent = '无法识别。';
|
||||
commandCandidate = '0'; // 无法识别也发送 '0' 回退
|
||||
PREDICTION_OUTPUT.classList.add('error'); // !!! ADDED !!!
|
||||
commandCandidate = '0';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('预测错误:', error);
|
||||
PREDICTION_OUTPUT.textContent = `预测错误: ${error.message}`;
|
||||
commandCandidate = '0'; // 错误时也发送'0'
|
||||
PREDICTION_OUTPUT.classList.add('error'); // !!! ADDED !!!
|
||||
commandCandidate = '0';
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
|
407
game/橘子/index.html
Normal file
407
game/橘子/index.html
Normal file
@ -0,0 +1,407 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Goood Space - 实时分类器</title>
|
||||
<!-- TensorFlow.js 核心库 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest"></script>
|
||||
<!-- MobileNet 模型 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/mobilenet@latest"></script>
|
||||
<!-- KNN 分类器 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@latest"></script>
|
||||
<style>
|
||||
/* Goood Space 统一 UI 风格 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #2d1b69 100%);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
overflow-x: hidden;
|
||||
position: relative; /* For pseudo-elements positioning */
|
||||
}
|
||||
|
||||
/* Background Animation */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
background: radial-gradient(circle, rgba(255, 0, 255, 0.15) 0%, transparent 70%); /* Purple/Pink orb */
|
||||
animation: rotate 35s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
top: -25%;
|
||||
left: -25%;
|
||||
background: radial-gradient(circle, rgba(0, 212, 255, 0.08) 0%, transparent 60%); /* Blue orb */
|
||||
animation: rotate-reverse 40s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes rotate-reverse {
|
||||
from { transform: rotate(360deg); }
|
||||
to { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
animation: fadeInDown 0.8s ease-out;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 3.5rem;
|
||||
background: linear-gradient(90deg, #ff00ff, #00d4ff, #00ff88); /* Purple to Green palette */
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 0 60px rgba(255, 0, 255, 0.5);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.5rem;
|
||||
color: #a8c0ff;
|
||||
text-shadow: 0 0 20px rgba(168, 192, 255, 0.5);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Main Container - Adjusted for two-column layout */
|
||||
.main-container {
|
||||
display: flex;
|
||||
gap: 3rem; /* Space between the two main cards */
|
||||
flex-wrap: wrap; /* Allows wrapping on smaller screens */
|
||||
justify-content: center;
|
||||
max-width: 1200px; /* Adjust max-width for two columns */
|
||||
width: 100%;
|
||||
align-items: flex-start; /* Align contents to the top */
|
||||
}
|
||||
|
||||
/* Card (Panel) Styles - Unified Look */
|
||||
.card {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 0 30px rgba(255, 0, 255, 0.3); /* Accent glow */
|
||||
color: #e0f2f7;
|
||||
flex: 1; /* Both cards take equal space */
|
||||
min-width: 450px; /* Minimum width before wrapping */
|
||||
max-width: calc(50% - 1.5rem); /* Roughly half width minus gap */
|
||||
}
|
||||
/* Removed .card.wide as both will share width */
|
||||
|
||||
.card h2 {
|
||||
color: #ff00ff; /* Accent color for card titles */
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid rgba(255, 0, 255, 0.3);
|
||||
padding-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Button Group Structure */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 1rem; /* Spacing between buttons */
|
||||
margin: 1.5rem 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* General Button Styles */
|
||||
button {
|
||||
padding: 1rem 2rem;
|
||||
border: none;
|
||||
border-radius: 50px; /* Pill shape */
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
flex: 1; /* Allow buttons within a group to share space */
|
||||
min-width: 120px; /* Minimum width for buttons */
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: scale(1.03); /* Slight scale on hover */
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #444;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Specific Button Colors */
|
||||
/* Removed #connectSerialBtn and #disconnectSerialBtn styles */
|
||||
|
||||
#loadModelBtn {
|
||||
background: linear-gradient(90deg, #FFC107, #FFEB3B); /* Goood Space Yellow/Orange */
|
||||
color: #333; /* Dark text for contrast on light background */
|
||||
}
|
||||
#loadModelBtn:hover:not(:disabled) {
|
||||
background: linear-gradient(90deg, #FFEB3B, #f1c40f);
|
||||
box-shadow: 0 6px 20px rgba(255, 193, 7, 0.4);
|
||||
}
|
||||
|
||||
#startWebcamBtn {
|
||||
background: linear-gradient(90deg, #00ff88, #2ecc71); /* Goood Space Green */
|
||||
color: white;
|
||||
}
|
||||
#startWebcamBtn:hover:not(:disabled) {
|
||||
background: linear-gradient(90deg, #2ecc71, #27ae60);
|
||||
box-shadow: 0 6px 20px rgba(0, 255, 136, 0.4);
|
||||
}
|
||||
|
||||
#stopWebcamBtn {
|
||||
background: linear-gradient(90deg, #ff4444, #e74c3c); /* Matches Red */
|
||||
color: white;
|
||||
}
|
||||
#stopWebcamBtn:hover:not(:disabled) {
|
||||
background: linear-gradient(90deg, #e74c3c, #c0392b);
|
||||
box-shadow: 0 6px 20px rgba(255, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
/* Status Messages - Standardized from KNN classifier */
|
||||
.status-message {
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
margin: 1.5rem 0;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
background: rgba(0, 212, 255, 0.15); /* Blue translucent */
|
||||
color: #00d4ff;
|
||||
border-color: #00d4ff;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: rgba(0, 255, 136, 0.15); /* Green translucent */
|
||||
color: #00ff88;
|
||||
border-color: #00ff88;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: rgba(255, 68, 68, 0.15); /* Red translucent */
|
||||
color: #ff4444;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background: rgba(255, 170, 0, 0.15); /* Orange translucent */
|
||||
color: #ffaa00;
|
||||
border-color: #ffaa00;
|
||||
}
|
||||
|
||||
/* Video Output and Prediction Display */
|
||||
#webcam-container { /* Added wrapper for video and status */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 640px; /* Standard video width, will adjust with flex */
|
||||
margin: 2rem auto; /* Center it, will adjust with flex */
|
||||
border-radius: 15px;
|
||||
overflow: hidden; /* Ensure video respects border-radius */
|
||||
background: #000;
|
||||
box-shadow: 0 0 30px rgba(255, 0, 255, 0.3); /* Matching panel glow */
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto; /* Maintain aspect ratio */
|
||||
display: block; /* Remove extra space below video */
|
||||
border: none; /* Already handled by container border/shadow */
|
||||
border-radius: 15px;
|
||||
background-color: transparent; /* Container handles it now */
|
||||
}
|
||||
|
||||
#webcam-status-display { /* A new combined status bar for webcam section */
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: #00ff88; /* Default green for live status */
|
||||
border-bottom-left-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
z-index: 3;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#prediction {
|
||||
font-size: 2rem; /* Larger, more prominent */
|
||||
font-weight: bold;
|
||||
margin-top: 2rem; /* More space */
|
||||
color: #00ff88; /* Green for detected result */
|
||||
text-shadow: 0 0 20px rgba(0, 255, 136, 0.6);
|
||||
padding: 1rem;
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-radius: 10px;
|
||||
min-height: 80px; /* Ensure consistent height */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #00ff88;
|
||||
transition: all 0.3s ease-out; /* Smooth transitions */
|
||||
}
|
||||
|
||||
#prediction.idle { /* Style for 'Waiting for prediction...' */
|
||||
font-size: 1.5rem;
|
||||
color: #a8c0ff;
|
||||
text-shadow: none;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#prediction.error { /* Style for error state */
|
||||
color: #ff4444;
|
||||
background: rgba(255, 68, 68, 0.1);
|
||||
border-color: #ff4444;
|
||||
text-shadow: 0 0 15px rgba(255, 68, 68, 0.6);
|
||||
}
|
||||
|
||||
/* Removed hr as it was for separating serial part */
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1200px) {
|
||||
.main-container {
|
||||
flex-direction: column; /* Stack vertically on smaller screens */
|
||||
align-items: center;
|
||||
gap: 2rem; /* Reduced gap when stacked */
|
||||
}
|
||||
|
||||
.card {
|
||||
min-width: unset;
|
||||
max-width: 100%; /* Take full width */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#webcam-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.8rem 1.2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
#prediction {
|
||||
font-size: 1.5rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1 class="brand-title">Goood Space</h1>
|
||||
<p class="subtitle">实时分类器</p>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 模型管理卡片 -->
|
||||
<div class="card">
|
||||
<h2>🧠 模型管理</h2>
|
||||
<div id="modelStatus" class="status-message status-info">正在加载 MobileNet 模型...</div>
|
||||
<div class="button-group">
|
||||
<button id="loadModelBtn">加载模型文件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时预测卡片 -->
|
||||
<div class="card"> <!-- Removed .wide class -->
|
||||
<h2>📹 实时预测</h2>
|
||||
<div class="button-group">
|
||||
<button id="startWebcamBtn" disabled>启动摄像头</button>
|
||||
<button id="stopWebcamBtn" disabled>停止摄像头</button>
|
||||
</div>
|
||||
<div id="webcam-container">
|
||||
<video id="webcam" autoplay playsinline muted></video>
|
||||
<div id="webcam-status-display" class="status-message status-info">摄像头未启动</div>
|
||||
</div>
|
||||
|
||||
<!-- Prediction output moved here -->
|
||||
<div id="prediction" class="idle">等待识别...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
512
game/橘子/script.js
Normal file
512
game/橘子/script.js
Normal file
@ -0,0 +1,512 @@
|
||||
// script.js
|
||||
|
||||
const VIDEO = document.getElementById('webcam');
|
||||
// const CONNECT_SERIAL_BTN = document.getElementById('connectSerialBtn'); // REMOVED
|
||||
// const DISCONNECT_SERIAL_BTN = document.getElementById('disconnectSerialBtn'); // REMOVED
|
||||
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'); // REMOVED
|
||||
const PREDICTION_OUTPUT = document.getElementById('prediction');
|
||||
const WEBCAM_STATUS_DISPLAY = document.getElementById('webcam-status-display');
|
||||
|
||||
let mobilenet;
|
||||
let knnClassifier;
|
||||
let classNames = [];
|
||||
let webcamStream = null;
|
||||
let isPredicting = false;
|
||||
|
||||
// REMOVED: Web Serial API variables
|
||||
// let serialPort = null;
|
||||
// let serialWriter = null;
|
||||
// const SERIAL_BAUD_RATE = 9600;
|
||||
// const SERIAL_SEND_MIN_INTERVAL = 500;
|
||||
// let lastSerialCommand = '';
|
||||
// let lastSerialSendTime = 0;
|
||||
|
||||
// REMOVED: Serial connection state variables
|
||||
// let isSerialConnectedState = false;
|
||||
// let lastSentClassCommand = null;
|
||||
|
||||
// REMOVED: Confirmation sending logic variables
|
||||
// let pendingCommandToSend = null;
|
||||
// let pendingCommandTimerId = null;
|
||||
// const CONFIRMATION_DELAY_MS = 100;
|
||||
|
||||
|
||||
// ===================================
|
||||
// Helper Functions (UI Status)
|
||||
// ===================================
|
||||
function showStatus(element, type, message) {
|
||||
element.className = `status-message status-${type}`;
|
||||
element.textContent = message;
|
||||
}
|
||||
|
||||
// REMOVED: updateSerialUI function
|
||||
/*
|
||||
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;
|
||||
if (isRunning) {
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'info', '摄像头已启动,等待模型预测...');
|
||||
PREDICTION_OUTPUT.classList.remove('idle', 'error');
|
||||
} else {
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'info', '摄像头未启动');
|
||||
PREDICTION_OUTPUT.classList.add('idle');
|
||||
PREDICTION_OUTPUT.textContent = '等待识别...';
|
||||
}
|
||||
}
|
||||
|
||||
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', '系统初始化中...');
|
||||
try {
|
||||
if (!window.tf || !window.mobilenet || !window.knnClassifier) { // Added tf check
|
||||
showStatus(MODEL_STATUS, 'error', 'TensorFlow.js 核心库或模型库未加载。请检查 HTML 引入。');
|
||||
console.error('TensorFlow.js 核心库或模型库未加载。');
|
||||
return;
|
||||
}
|
||||
|
||||
mobilenet = await window.mobilenet.load({ version: 2, alpha: 1.0 });
|
||||
knnClassifier = window.knnClassifier.create();
|
||||
showStatus(MODEL_STATUS, 'success', 'MobileNet 模型和 KNN 分类器已加载。');
|
||||
updateModelUI(false);
|
||||
|
||||
const cdnModelBaseUrl = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/models/';
|
||||
const cdnModelJsonFileName = 'knn-model-juzi.json';
|
||||
const cdnModelBinFileName = 'knn-model-juzi.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 模型自动加载成功。');
|
||||
} catch (cdnError) {
|
||||
showStatus(MODEL_STATUS, 'warning', `从 CDN 加载 KNN 模型失败: ${cdnError.message}。您可以尝试手动加载。`);
|
||||
console.warn('CDN KNN 模型加载失败:', cdnError);
|
||||
updateModelUI(false);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showStatus(MODEL_STATUS, 'error', `模型加载失败: ${error.message}`);
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'error', '模型加载失败');
|
||||
console.error('MobileNet/KNN加载失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// loadSingleJsonModel 保持不变
|
||||
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);
|
||||
});
|
||||
|
||||
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', '模型加载失败');
|
||||
console.error('加载单文件JSON模型失败:', error);
|
||||
updateModelUI(false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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) {
|
||||
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);
|
||||
|
||||
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();
|
||||
} catch (error) {
|
||||
showStatus(MODEL_STATUS, 'error', `读取 .bin 文件失败: ${error.message}`);
|
||||
console.error('读取 .bin 失败:', error);
|
||||
updateModelUI(false);
|
||||
return reject(error);
|
||||
}
|
||||
};
|
||||
inputBin.click();
|
||||
};
|
||||
inputJson.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (!modelData) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (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;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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', `KNN 模型 "${modelName}" 加载成功!类别: ${classNames.join(', ')}。`);
|
||||
updateModelUI(true);
|
||||
|
||||
} else if (modelData && !binData && !jsonUrl) {
|
||||
showStatus(MODEL_STATUS, 'error', '未知模型加载状态:仅有 JSON 数据,没有 BIN 数据。');
|
||||
updateModelUI(false);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showStatus(MODEL_STATUS, 'error', `加载 KNN 模型失败: ${error.message}`);
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'error', '模型加载失败');
|
||||
console.error('加载 KNN 模型总失败:', 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 = () => {
|
||||
// REMOVED: Serial related state resets
|
||||
isPredicting = true;
|
||||
predictLoop();
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'success', `摄像头已运行,识别中...`);
|
||||
PREDICTION_OUTPUT.classList.remove('idle', 'error');
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
showStatus(MODEL_STATUS, 'error', `无法访问摄像头: ${error.message}`);
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'error', '无法启动摄像头');
|
||||
console.error('启动摄像头失败:', error);
|
||||
updateWebcamUI(false);
|
||||
}
|
||||
}
|
||||
|
||||
function stopWebcam() {
|
||||
if (webcamStream) {
|
||||
webcamStream.getTracks().forEach(track => track.stop());
|
||||
webcamStream = null;
|
||||
}
|
||||
isPredicting = false;
|
||||
VIDEO.srcObject = null;
|
||||
updateWebcamUI(false);
|
||||
showStatus(WEBCAM_STATUS_DISPLAY, 'info', '摄像头已停止');
|
||||
|
||||
// REMOVED: Serial related state resets
|
||||
// If there were any non-serial resource cleanup here, it would be moved.
|
||||
}
|
||||
|
||||
// REMOVED: Serial confirmation logic variables and functions
|
||||
// predictLoop will be simplified to just predict and update UI.
|
||||
let currentDetectedClassLabel = '等待识别...'; // For displaying the current prediction.
|
||||
|
||||
async function predictLoop() {
|
||||
if (!isPredicting) return;
|
||||
|
||||
if (VIDEO.readyState === 4 && VIDEO.videoWidth > 0 && VIDEO.videoHeight > 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');
|
||||
currentDetectedClassLabel = '模型未就绪';
|
||||
} 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');
|
||||
currentDetectedClassLabel = className;
|
||||
|
||||
// Original logic had commandCandidate = '1', '2', '0' etc.
|
||||
// Since serial is removed, this part is now purely for UI display.
|
||||
} else {
|
||||
PREDICTION_OUTPUT.textContent = `未知或不确定... (最高置信度: ${(maxConfidence * 100).toFixed(1)}%)`;
|
||||
PREDICTION_OUTPUT.classList.add('idle');
|
||||
currentDetectedClassLabel = '未知或不确定';
|
||||
}
|
||||
} else {
|
||||
PREDICTION_OUTPUT.textContent = '无法识别。';
|
||||
PREDICTION_OUTPUT.classList.add('error');
|
||||
currentDetectedClassLabel = '无法识别';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('预测错误:', error);
|
||||
PREDICTION_OUTPUT.textContent = `预测错误: ${error.message}`;
|
||||
PREDICTION_OUTPUT.classList.add('error');
|
||||
currentDetectedClassLabel = `错误: ${error.message}`;
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(predictLoop);
|
||||
}
|
||||
|
||||
|
||||
// REMOVED: Web Serial API Logic and Event Listeners
|
||||
/*
|
||||
async function checkWebSerialCompatibility() { ... }
|
||||
async function connectSerial() { ... }
|
||||
async function disconnectSerial() { ... }
|
||||
async function sendToSerialPort(command) { ... }
|
||||
CONNECT_SERIAL_BTN.addEventListener('click', connectSerial);
|
||||
DISCONNECT_SERIAL_BTN.addEventListener('click', disconnectSerial);
|
||||
*/
|
||||
|
||||
// ===================================
|
||||
// Event Listeners (Simplified)
|
||||
// ===================================
|
||||
LOAD_MODEL_BTN.addEventListener('click', () => loadKNNModel(null, null));
|
||||
START_WEBCAM_BTN.addEventListener('click', startWebcam);
|
||||
STOP_WEBCAM_BTN.addEventListener('click', stopWebcam);
|
||||
|
||||
// ===================================
|
||||
// Initialization (Simplified)
|
||||
// ===================================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// REMOVED: checkWebSerialCompatibility();
|
||||
initModel();
|
||||
});
|
||||
|
||||
// Added cleanup for TensorFlow.js on window close/reload
|
||||
window.onbeforeunload = () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
if (mobilenet) {
|
||||
// mobilenet.dispose(); // MobileNet is part of TF.js, tf.disposeAll() handles it
|
||||
}
|
||||
if (knnClassifier) {
|
||||
knnClassifier.clearAllClasses();
|
||||
}
|
||||
tf.disposeAll();
|
||||
console.log('Resources cleaned up (tf.disposeAll()).');
|
||||
};
|
1465
game/石头剪刀布/game.html
1465
game/石头剪刀布/game.html
File diff suppressed because it is too large
Load Diff
1248
game/钢琴/index.html
1248
game/钢琴/index.html
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user