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

452 lines
14 KiB
HTML

<!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 - Web Serial 实时分类器</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 */
.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="header">
<h1 class="brand-title">Goood Space</h1>
<p class="subtitle">Web Serial 实时分类器</p>
</div>
<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>
<!-- 模型加载卡片 -->
<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 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>
<!-- Prediction output moved here -->
<div id="prediction" class="idle">等待识别...</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>