mobileNet/game/橘子/index.html

532 lines
19 KiB
HTML
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.

<!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>
<!-- !!!!!! 核心劫持代码:确保在任何 TF.js 库之前加载 !!!!!! -->
<script>
(function() {
// 定义你的镜像服务器的公共前缀,用于存放 MobileNet 模型文件
// 根据你提供的最新路径进行更新。
// 确保这些文件就命名为 model.json, group1-shardXof4.bin并直接在此目录下
const MOBILENET_MIRROR_BASE_URL = 'https://goood-space-assets.oss-cn-beijing.aliyuncs.com/public/fetch/mobilenet/';
// 定义需要被劫持的原始 URL 的域名模式
// 我们观察到最终请求来自 storage.googleapis.com
const INTERCEPT_DOMAINS = [
'https://storage.googleapis.com/tfjs-models/', // tfjs 官方模型常用的 CDN
'https://storage.googleapis.com/', // 更宽泛的匹配 Google Storage
'https://tfhub.dev/', // 如果 MobileNet 也会通过 tfhub.dev 间接加载
];
// 备份原始的 fetch 函数
const originalFetch = window.fetch;
window.fetch = function(input, init) {
let url = input;
if (input instanceof Request) {
url = input.url;
}
let newUrl = url;
let isIntercepted = false;
// 检查 URL 是否以我们关注的域名开头
for (const domain of INTERCEPT_DOMAINS) {
if (url.startsWith(domain)) {
// 尝试从 URL 中提取文件名 (不包含查询参数)
// 匹配 model.json 或 group1-shardXofY.bin
const fileNameMatch = url.match(/(model\.json|group1-shard\dof\d\.bin)/);
if (fileNameMatch) {
const fileName = fileNameMatch[0]; // 获取匹配到的文件名
newUrl = MOBILENET_MIRROR_BASE_URL + fileName; // 拼接新的镜像 URL
isIntercepted = true;
break; // 找到匹配的域名和文件,停止循环
}
}
}
if (isIntercepted) {
console.warn(`[TFJS Fetch Intercepted] Original: ${url}`);
console.warn(`[TFJS Fetch Intercepted] Redirecting to: ${newUrl}`);
// 如果 input 是 Request 对象,需要创建新的 Request 对象来修改 URL
if (input instanceof Request) {
try {
input = new Request(newUrl, {
method: input.method,
headers: input.headers,
body: input.body,
referrer: input.referrer,
referrerPolicy: input.referrerPolicy,
mode: 'cors', // 总是使用 CORS 模式
credentials: input.credentials,
cache: 'default',
redirect: 'follow',
integrity: undefined, // 移除 integrity 属性以避免校验失败
signal: input.signal,
});
} catch (e) {
console.error(`[TFJS Fetch Intercepted Error] Failed to create new Request object: ${e.message}. Falling back to URL string.`, input);
// 如果创建 Request 对象失败,回退到直接使用 URL 字符串
input = newUrl;
}
} else {
// 如果 input 是 URL 字符串,直接替换
input = newUrl;
}
}
return originalFetch(input, init).catch(error => {
console.error(`[TFJS Fetch Intercepted Error] Failed to load ${url} (redirected to ${newUrl || url || input}):`, error);
throw error;
});
};
// -------------------- 劫持 XMLHttpRequest API (备用安全网) --------------------
const originalXHR = window.XMLHttpRequest;
window.XMLHttpRequest = function() {
const xhr = new originalXHR();
const originalOpen = xhr.open;
xhr.open = function(method, url, async = true, user = null, password = null) {
let newUrl = url;
let isIntercepted = false;
for (const domain of INTERCEPT_DOMAINS) {
if (url.startsWith(domain)) {
const fileNameMatch = url.match(/(model\.json|group1-shard\dof\d\.bin)/);
if (fileNameMatch) {
const fileName = fileNameMatch[0];
newUrl = MOBILENET_MIRROR_BASE_URL + fileName;
isIntercepted = true;
break;
}
}
}
if (isIntercepted) {
console.warn(`[TFJS XHR Intercepted] Original: ${url}`);
console.warn(`[TFJS XHR Intercepted] Redirecting to: ${newUrl}`);
url = newUrl; // 修改传入 open 的 URL
}
// 调用原始的 open 方法
return originalOpen.apply(this, arguments);
};
// 将原始 XMLHttpRequest 的所有静态属性和方法复制到劫持后的 XMLHttpRequest 构造函数
// 这样像 XHR.UNSENT 等常量仍然可用
for (const key in originalXHR) {
if (typeof originalXHR[key] !== 'function' && originalXHR.hasOwnProperty(key)) {
window.XMLHttpRequest[key] = originalXHR[key];
}
}
return xhr;
};
})();
</script>
<!-- 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>