Init: 导入NaviGlassServer源码

This commit is contained in:
Kevin Wong
2025-12-31 15:42:30 +08:00
parent 5baf812ed3
commit 2b6dd49a59
233 changed files with 20236 additions and 178 deletions

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

883
static/main.js Normal file
View File

@@ -0,0 +1,883 @@
// static/main.js
// ================= 摄像头 + ASR =================
(() => {
const $camStatus = document.getElementById('camStatus');
const $asrStatus = document.getElementById('asrStatus');
const $partial = document.getElementById('partial');
const $finalList = document.getElementById('finalList');
const $btnClear = document.getElementById('btnClear');
const $btnRe = document.getElementById('btnReconnect');
const $fps = document.getElementById('fps');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// === 获取/创建聊天容器(关键补丁) ===
let chatContainer = document.getElementById('chatContainer');
function ensureChatContainer() {
// 已缓存且仍在文档中
if (chatContainer && document.body.contains(chatContainer)) return chatContainer;
// 重新获取,防热更新或 DOM 移动
chatContainer = document.getElementById('chatContainer');
if (!chatContainer) {
chatContainer = document.createElement('div');
chatContainer.id = 'chatContainer';
// 优先挂到 finalList 的父容器;否则挂到 partial 的父容器;再否则挂到 body 兜底
if ($finalList && $finalList.parentElement) {
// 隐藏原来的 finalList
$finalList.style.display = 'none';
// 将聊天容器挂载到 finals div 内
$finalList.parentElement.appendChild(chatContainer);
console.log('[chat] 创建并挂载 #chatContainer 到 finalList 区域');
} else if ($partial && $partial.parentElement) {
$partial.parentElement.appendChild(chatContainer);
console.log('[chat] 创建并挂载 #chatContainer 到 partial 区域');
} else {
document.body.appendChild(chatContainer);
console.warn('[chat] 未找到合适锚点,已挂到 <body>');
}
}
return chatContainer;
}
// === 注入聊天样式(左右两侧气泡 + 时间戳,增加权重)===
(function injectChatStyles() {
if (document.getElementById('chat-style-injected')) return;
const s = document.createElement('style');
s.id = 'chat-style-injected';
s.textContent = `
#chatContainer{
position: relative !important;
overflow-y: auto !important;
flex: 1 !important; /* 改为使用 flex: 1 占满剩余空间 */
min-height: 0 !important; /* 确保 flex 子元素能正确收缩 */
padding: 12px 12px 4px !important;
background: #0b1020 !important;
border: 1px solid #1d2438 !important;
border-radius: 10px !important;
margin-top: 12px !important;
}
/* 自定义滚动条样式 */
#chatContainer::-webkit-scrollbar {
width: 8px !important;
}
#chatContainer::-webkit-scrollbar-track {
background: #0d1420 !important;
border-radius: 4px !important;
}
#chatContainer::-webkit-scrollbar-thumb {
background: #2a3446 !important;
border-radius: 4px !important;
transition: background 0.2s !important;
}
#chatContainer::-webkit-scrollbar-thumb:hover {
background: #3a4556 !important;
}
/* Firefox 滚动条 */
#chatContainer {
scrollbar-width: thin !important;
scrollbar-color: #2a3446 #0d1420 !important;
}
.timestamp{
text-align:center !important;
font-size:12px !important;
color:#8a93a5 !important;
margin:10px 0 !important;
user-select:none !important;
}
.message{
display:flex !important;
gap:8px !important;
margin:6px 0 !important;
align-items:flex-end !important;
}
.message.ai{ justify-content:flex-start !important; }
.message.user{ justify-content:flex-end !important; }
.avatar{
width:28px !important; height:28px !important; border-radius:50% !important;
background:#232a3d !important; flex:0 0 28px !important;
display:flex !important; align-items:center !important; justify-content:center !important;
color:#9fb0c3 !important; font-size:12px !important; user-select:none !important;
border:1px solid #29314a !important;
}
.message.user .avatar{ display:none !important; }
.bubble{
max-width: 72% !important;
padding:10px 12px !important;
line-height:1.45 !important;
border-radius:14px !important;
word-break:break-word !important;
white-space:pre-wrap !important;
border:1px solid transparent !important;
box-shadow:0 2px 8px rgba(0,0,0,0.15) !important;
font-size:14px !important;
}
.message.ai .bubble{
background:#111a2e !important;
color:#e6edf3 !important;
border-color:#1e2740 !important;
border-top-left-radius:6px !important;
}
.message.user .bubble{
background:#2a6df4 !important;
color:#fff !important;
border-color:#2a6df4 !important;
border-top-right-radius:6px !important;
}
`;
document.head.appendChild(s);
})();
// 聊天消息管理
let lastTimestamp = 0;
const TIMESTAMP_INTERVAL = 5 * 60 * 1000; // 5分钟
function shouldShowTimestamp() {
const now = Date.now();
if (now - lastTimestamp > TIMESTAMP_INTERVAL) {
lastTimestamp = now;
return true;
}
return false;
}
function formatTime(timestamp = Date.now()) {
const date = new Date(timestamp);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
function addTimestamp() {
const container = ensureChatContainer();
const timestampDiv = document.createElement('div');
timestampDiv.className = 'timestamp';
timestampDiv.textContent = formatTime();
container.appendChild(timestampDiv);
}
function addMessage(text, isUser = false) {
// 时间戳
if (shouldShowTimestamp()) addTimestamp();
const container = ensureChatContainer();
// 行容器
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user' : 'ai'}`;
// 左侧头像AI
const avatar = document.createElement('div');
avatar.className = 'avatar';
avatar.textContent = isUser ? '' : 'AI';
// 气泡
const bubbleDiv = document.createElement('div');
bubbleDiv.className = 'bubble';
bubbleDiv.textContent = text;
if (isUser) {
// 右侧:气泡在右
messageDiv.appendChild(bubbleDiv);
} else {
// 左侧:头像 + 气泡
messageDiv.appendChild(avatar);
messageDiv.appendChild(bubbleDiv);
}
container.appendChild(messageDiv);
// 滚动到底部
container.scrollTop = container.scrollHeight;
}
// Day 20: 更新 badge 样式,支持 connecting 状态动画
function setBadge(el, status, text) {
el.textContent = text;
// status: 'ok', 'err', 'connecting'
if (status === true) status = 'ok';
if (status === false) status = 'err';
el.className = 'badge ' + (status || '');
}
function navLabelAndText(raw) {
// 去掉前缀 “[导航] ”
const t = raw.startsWith('[导航]') ? raw.substring(4).trim() : raw;
// 粗略判断:含“斑马线/绿灯/红灯/黄灯/过马路”归为斑马线导航,否则盲道导航
const crossHints = ['斑马线', '绿灯', '红灯', '黄灯', '过马路'];
const isCross = crossHints.some(k => t.includes(k));
const label = isCross ? '【斑马线导航】' : '【盲道导航】';
return { label, text: `${label} ${t}` };
}
// 改进的 fitCanvas: 支持移动端尺寸计算
function fitCanvas() {
const rect = canvas.getBoundingClientRect();
// 使用容器实际宽高,添加最小值保护
const w = Math.max(320, Math.floor(rect.width) || 320);
let h = Math.floor(rect.height) || 0;
// 如果容器高度太小或为0使用4:3比例回退
if (h < 100) {
h = Math.max(240, Math.floor(w * 3 / 4));
}
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
console.log('[Canvas] 尺寸调整:', w, 'x', h);
}
}
window.addEventListener('resize', fitCanvas);
// 延迟初始化,确保布局完成
setTimeout(fitCanvas, 100);
fitCanvas();
let wsCam, wsUI, frames = 0, fpsTimer = 0;
function drawBlob(buf) {
const blob = new Blob([buf], { type: 'image/jpeg' });
if ('createImageBitmap' in window) {
createImageBitmap(blob).then(bmp => {
fitCanvas();
ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height);
}).catch(() => { });
} else {
const img = new Image();
img.onload = () => { fitCanvas(); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(img.src); };
img.src = URL.createObjectURL(blob);
}
frames++;
const now = performance.now();
if (!fpsTimer) fpsTimer = now;
if (now - fpsTimer >= 1000) {
$fps.textContent = 'FPS: ' + frames;
frames = 0; fpsTimer = now;
}
}
function connectCamera() {
try { if (wsCam) wsCam.close(); } catch (e) { }
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
wsCam = new WebSocket(`${proto}://${location.host}/ws/viewer`);
setBadge($camStatus, 'connecting', '📷 连接中...');
wsCam.binaryType = 'arraybuffer';
wsCam.onopen = () => setBadge($camStatus, 'ok', '📷 已连接');
wsCam.onclose = () => setBadge($camStatus, 'err', '📷 已断开');
wsCam.onerror = () => setBadge($camStatus, 'err', '📷 连接错误');
wsCam.onmessage = (ev) => drawBlob(ev.data);
}
function connectASR() {
try { if (wsUI) wsUI.close(); } catch (e) { }
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
wsUI = new WebSocket(`${proto}://${location.host}/ws_ui`);
setBadge($asrStatus, 'connecting', '🎤 连接中...');
wsUI.onopen = () => setBadge($asrStatus, 'ok', '🎤 已连接');
wsUI.onclose = () => setBadge($asrStatus, 'err', '🎤 已断开');
wsUI.onerror = () => setBadge($asrStatus, 'err', '🎤 连接错误');
wsUI.onmessage = (ev) => {
const s = ev.data || '';
if (s.startsWith('INIT:')) {
try {
const data = JSON.parse(s.slice(5));
$partial.textContent = data.partial || '(等待音频…)';
// 初始化时加载历史消息(识别 [AI] 与 [导航]
if (data.finals && data.finals.length > 0) {
data.finals.forEach(text => {
if (text.startsWith('[AI]')) {
addMessage(text.substring(4).trim(), false);
} else if (text.startsWith('[导航]')) {
const { text: show } = navLabelAndText(text);
addMessage(show, false);
} else {
addMessage(text, true);
}
});
}
} catch (e) { }
return;
}
if (s.startsWith('PARTIAL:')) {
$partial.textContent = s.slice(8);
return;
}
if (s.startsWith('FINAL:')) {
const text = s.slice(6);
if (text.startsWith('[AI]')) {
addMessage(text.substring(4).trim(), false);
} else if (text.startsWith('[导航]')) {
const { text: show } = navLabelAndText(text);
addMessage(show, false); // 左侧 AI
} else {
addMessage(text, true); // 其它仍按右侧
}
$partial.textContent = '(等待音频…)';
return;
}
}
}
$btnClear.onclick = () => {
const container = ensureChatContainer();
// 清空聊天记录
const messages = container.querySelectorAll('.message, .timestamp');
messages.forEach(msg => msg.remove());
lastTimestamp = 0; // 重置时间戳计数
};
$btnRe.onclick = () => { connectCamera(); connectASR(); };
connectCamera();
connectASR();
})();
// ================= IMU 3D无虚线框、无滚动条、上下对齐、自适应 =================
import * as THREE from 'three';
import { GLTFLoader } from 'https://unpkg.com/three@0.155.0/examples/jsm/loaders/GLTFLoader.js';
// Day 20: IMU 浮窗折叠功能 - 修复:兼容模块延迟加载
// Day 23: 移动端优化 - 默认折叠
function initImuToggle() {
const imuFloat = document.getElementById('imuFloat');
const imuToggle = document.getElementById('imuToggle');
console.log('[IMU] 初始化折叠功能, imuFloat:', !!imuFloat, 'imuToggle:', !!imuToggle);
if (imuFloat && imuToggle) {
// 检测移动端 - 默认折叠
const isMobile = window.innerWidth < 1100;
if (isMobile) {
imuFloat.classList.add('collapsed');
imuFloat.classList.remove('expanded');
imuToggle.textContent = '+';
console.log('[IMU] 移动端检测,默认折叠');
} else {
imuFloat.classList.add('expanded');
}
imuToggle.onclick = function (e) {
e.preventDefault();
e.stopPropagation();
const isCollapsed = imuFloat.classList.toggle('collapsed');
imuFloat.classList.toggle('expanded', !isCollapsed);
this.textContent = isCollapsed ? '+' : '';
console.log('[IMU] 折叠状态:', isCollapsed);
};
console.log('[IMU] 折叠按钮事件已绑定');
}
}
// 确保 DOM 加载后执行(兼容模块延迟加载)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initImuToggle);
} else {
// DOM 已加载完成,直接执行
initImuToggle();
}
(() => {
const container = document.getElementById('imu_view'); // 左侧3D容器
const hud = document.getElementById('imu_hud'); // 右侧IMU容器
// 左右窗口统一半透明底色
if (container) container.style.background = 'rgba(0,0,0,0.2)';
if (hud) {
// 关键:右侧容器作为定位参考,同时禁止滚动、清理边框
Object.assign(hud.style, {
position: 'relative',
overflow: 'hidden',
border: 'none',
outline: 'none',
background: 'rgba(0,0,0,0.2)', // 右侧也给统一底色(整块),干净无额外面板底色
borderRadius: '10px'
});
}
// —— 彻底去掉“虚线框”和一切边框/阴影(含可能的外层壳)——
(function killFraming() {
const s = document.createElement('style');
s.textContent = `
#imu_view, #imu_hud, #data-panel, #imu_dock,
.imu-card, .imu-wrap, .panel, .card, .window {
border: none !important;
outline: none !important;
box-shadow: none !important;
background-image: none !important;
}
/* 兜底:清除任何内联 dashed/ dotted */
[style*="dashed"], [style*="dotted"] {
border-style: none !important;
outline: none !important;
}
`;
document.head.appendChild(s);
// 同时清理父级(最多向上两层)里的边框与滚动条,避免外层虚线框和滚动条
[container, hud].forEach(el => {
let p = el ? el.parentElement : null;
for (let i = 0; i < 2 && p; i++, p = p.parentElement) {
p.style.border = 'none';
p.style.outline = 'none';
p.style.boxShadow = 'none';
p.style.overflow = 'hidden';
p.style.backgroundImage = 'none';
}
});
})();
// 右侧:不再额外创建 dock 背板(直接用 hud 当整块背景)
// 数据面板只负责显示文字,不再自带背景与边框
// three.js 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1000);
// 画质相关
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.setClearColor(0x000000, 0); // 透明背景
// ——— 核心:左右窗口“上下齐+自适应等比” ———
let syncRaf = 0;
function syncHeights() {
if (!container || !hud) return;
const w = container.clientWidth || 300;
// 恢复合理高度设置
const padding = 20;
const contentH = (document.getElementById('data-panel')?.offsetHeight || 0) + padding;
let targetH = Math.max(180, contentH); // 最小高度 180px
hud.style.height = `${targetH}px`;
hud.style.maxHeight = 'none';
hud.style.overflow = 'hidden';
container.style.height = `${targetH}px`;
renderer.setSize(w, targetH);
camera.aspect = w / targetH;
camera.updateProjectionMatrix();
}
function requestSync() {
cancelAnimationFrame(syncRaf);
syncRaf = requestAnimationFrame(syncHeights);
}
// 初次与窗口变化时,同步左右高度
requestSync();
window.addEventListener('resize', requestSync);
// 数据变化时也同步(放在 updateDataPanel 内)
function updateDataPanel(roll, pitch, yaw, gx, gy, gz, ax, ay, az) {
document.getElementById('panel-roll').textContent = roll.toFixed(1) + '°';
document.getElementById('panel-pitch').textContent = pitch.toFixed(1) + '°';
document.getElementById('panel-yaw').textContent = yaw.toFixed(1) + '°';
document.getElementById('panel-gx').textContent = gx.toFixed(1);
document.getElementById('panel-gy').textContent = gy.toFixed(1);
document.getElementById('panel-gz').textContent = gz.toFixed(1);
document.getElementById('panel-ax').textContent = ax.toFixed(2);
document.getElementById('panel-ay').textContent = ay.toFixed(2);
document.getElementById('panel-az').textContent = az.toFixed(2);
requestSync(); // 数据刷新后同步高度
}
container.appendChild(renderer.domElement);
// ========== 场景 ==========
const group = new THREE.Group();
scene.add(group);
const axesHelper = new THREE.AxesHelper(4);
scene.add(axesHelper);
function createAxisLabel(text, position, color) {
const c = document.createElement('canvas');
const ctx = c.getContext('2d');
c.width = 128; c.height = 64;
ctx.fillStyle = color;
ctx.font = 'Bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 64, 32);
const tex = new THREE.CanvasTexture(c);
const mat = new THREE.SpriteMaterial({ map: tex });
const spr = new THREE.Sprite(mat);
spr.position.copy(position);
spr.scale.set(0.8, 0.4, 1);
return spr;
}
scene.add(createAxisLabel('X', new THREE.Vector3(4.5, 0, 0), '#ff0000'));
scene.add(createAxisLabel('Y', new THREE.Vector3(0, 4.5, 0), '#00ff00'));
scene.add(createAxisLabel('Z', new THREE.Vector3(0, 0, 4.5), '#0000ff'));
function createScale() {
const g = new THREE.Group();
for (let i = 1; i <= 4; i++) {
const geo = new THREE.SphereGeometry(0.05, 8, 6);
const mk = (c) => new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: c }));
const mx = mk(0xff4444); mx.position.set(i, 0, 0); g.add(mx);
const my = mk(0x44ff44); my.position.set(0, i, 0); g.add(my);
const mz = mk(0x4444ff); mz.position.set(0, 0, i); g.add(mz);
}
return g;
}
scene.add(createScale());
function createDirectionLabels() {
[
{ t: '前', p: new THREE.Vector3(0, 0, 5), c: '#00ffff' },
{ t: '后', p: new THREE.Vector3(0, 0, -5), c: '#00ffff' },
{ t: '左', p: new THREE.Vector3(-5, 0, 0), c: '#ffff00' },
{ t: '右', p: new THREE.Vector3(5, 0, 0), c: '#ffff00' },
{ t: '上', p: new THREE.Vector3(0, 5, 0), c: '#ff00ff' },
{ t: '下', p: new THREE.Vector3(0, -5, 0), c: '#ff00ff' },
].forEach(d => scene.add(createAxisLabel(d.t, d.p, d.c)));
}
createDirectionLabels();
camera.position.set(4, 4, 6);
camera.lookAt(0, 0, 0);
// ========== 右侧 IMU 数据展示 ==========
function createDataPanel() {
const panel = document.createElement('div');
panel.id = 'data-panel';
panel.style.cssText = `
position: relative;
background: transparent;
border: none;
padding: 10px;
width: 100%;
color: #e6edf3;
font-family: 'Consolas','Monaco',monospace;
font-size: 11px;
box-shadow: none;
overflow: hidden;
`;
panel.innerHTML = `
<div style="margin-bottom:8px;font-weight:bold;color:#61dafb;font-size:12px;border-bottom:1px solid #2a3446;padding-bottom:4px;">IMU 实时数据</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px;">
<div><div style="color:#9fb0c3;font-size:9px;">翻滚角 (Roll)</div>
<div id="panel-roll" style="color:#ff6b6b;font-size:14px;font-weight:bold;">0.0°</div></div>
<div><div style="color:#9fb0c3;font-size:9px;">俯仰角 (Pitch)</div>
<div id="panel-pitch" style="color:#4ecdc4;font-size:14px;font-weight:bold;">0.0°</div></div>
</div>
<div style="margin-bottom:8px;">
<div style="color:#9fb0c3;font-size:9px;">偏航角 (Yaw)</div>
<div id="panel-yaw" style="color:#45b7d1;font-size:14px;font-weight:bold;">0.0°</div>
</div>
<div style="border-top:1px solid #2a3446;padding-top:6px;margin-top:6px;">
<div style="color:#9fb0c3;font-size:9px;margin-bottom:4px;">角速度 (°/s)</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;">
<div><span style="color:#ff9999;font-size:9px;">gX:</span><span id="panel-gx" style="color:#ff9999;font-size:10px;">0.0</span></div>
<div><span style="color:#99ff99;font-size:9px;">gY:</span><span id="panel-gy" style="color:#99ff99;font-size:10px;">0.0</span></div>
<div><span style="color:#9999ff;font-size:9px;">gZ:</span><span id="panel-gz" style="color:#9999ff;font-size:10px;">0.0</span></div>
</div>
</div>
<div style="border-top:1px solid #2a3446;padding-top:6px;margin-top:6px;">
<div style="color:#9fb0c3;font-size:9px;margin-bottom:4px;">加速度 (m/s²)</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;">
<div><span style="color:#ff9999;font-size:9px;">aX:</span><span id="panel-ax" style="color:#ff9999;font-size:10px;">0.0</span></div>
<div><span style="color:#99ff99;font-size:9px;">aY:</span><span id="panel-ay" style="color:#99ff99;font-size:10px;">0.0</span></div>
<div><span style="color:#9999ff;font-size:9px;">aZ:</span><span id="panel-az" style="color:#9999ff;font-size:10px;">0.0</span></div>
</div>
</div>
`;
hud.appendChild(panel);
return panel;
}
const dataPanel = createDataPanel();
function updateDataPanel(roll, pitch, yaw, gx, gy, gz, ax, ay, az) {
document.getElementById('panel-roll').textContent = roll.toFixed(1) + '°';
document.getElementById('panel-pitch').textContent = pitch.toFixed(1) + '°';
document.getElementById('panel-yaw').textContent = yaw.toFixed(1) + '°';
document.getElementById('panel-gx').textContent = gx.toFixed(1);
document.getElementById('panel-gy').textContent = gy.toFixed(1);
document.getElementById('panel-gz').textContent = gz.toFixed(1);
document.getElementById('panel-ax').textContent = ax.toFixed(2);
document.getElementById('panel-ay').textContent = ay.toFixed(2);
document.getElementById('panel-az').textContent = az.toFixed(2);
}
// ========== 灯光 ==========
const ambientLight = new THREE.AmbientLight(0x404080, 0.3);
scene.add(ambientLight);
const mainLight = new THREE.DirectionalLight(0x00aaff, 1.2);
mainLight.position.set(5, 8, 5);
mainLight.castShadow = true;
mainLight.shadow.mapSize.width = 2048;
mainLight.shadow.mapSize.height = 2048;
mainLight.shadow.camera.near = 0.5;
mainLight.shadow.camera.far = 50;
scene.add(mainLight);
const fillLight = new THREE.DirectionalLight(0xff6633, 0.8);
fillLight.position.set(-5, 3, -3);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0x66ffff, 0.6);
rimLight.position.set(0, -5, 8);
scene.add(rimLight);
const pointLight1 = new THREE.PointLight(0x00ff88, 0.5, 20);
pointLight1.position.set(3, 2, 4);
scene.add(pointLight1);
const pointLight2 = new THREE.PointLight(0xff3388, 0.4, 15);
pointLight2.position.set(-3, -2, 2);
scene.add(pointLight2);
const spotLight = new THREE.SpotLight(0xffffff, 1.0, 30, Math.PI / 6, 0.3, 1);
spotLight.position.set(0, 10, 8);
spotLight.target.position.set(0, 0, 0);
spotLight.castShadow = true;
scene.add(spotLight);
scene.add(spotLight.target);
let lightTime = 0;
function updateLighting() {
lightTime += 0.01;
mainLight.intensity = 1.2 + Math.sin(lightTime * 2) * 0.2;
pointLight1.intensity = 0.5 + Math.sin(lightTime * 3) * 0.2;
pointLight2.intensity = 0.4 + Math.cos(lightTime * 2.5) * 0.2;
const hue = (Math.sin(lightTime * 0.5) + 1) * 0.3;
rimLight.color.setHSL(0.5 + hue, 1.0, 0.7);
}
// ========== 模型 ==========
let glassModel = null;
const loader = new GLTFLoader();
loader.load(
'/static/models/aiglass.glb',
(gltf) => {
glassModel = gltf.scene;
glassModel.scale.set(2, 2, 2);
glassModel.position.set(0, 0, 0);
glassModel.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
if (child.material) {
if (child.material.transparent || child.material.opacity < 1) {
child.material.envMapIntensity = 1.5;
child.material.roughness = 0.1;
child.material.metalness = 0.8;
}
}
}
});
group.add(glassModel);
},
undefined,
(error) => {
console.error('GLB加载失败:', error);
const fallbackCube = new THREE.Mesh(
new THREE.BoxGeometry(2, 2, 2),
new THREE.MeshStandardMaterial({ color: 0x00aaff, metalness: 0.7, roughness: 0.3, envMapIntensity: 1.0 })
);
fallbackCube.castShadow = true;
fallbackCube.receiveShadow = true;
group.add(fallbackCube);
}
);
// 渲染循环
(function animate() {
requestAnimationFrame(animate);
updateLighting();
renderer.render(scene, camera);
})();
// ===== IMU 数学与数据通道(原逻辑保持) =====
// 安装补偿
const MOUNT_RX = 0, MOUNT_RY = -90, MOUNT_RZ = 0;
const qMount = new THREE.Quaternion()
.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(MOUNT_RY)))
.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(MOUNT_RZ)))
.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(MOUNT_RX)));
const FOLLOW = 0.85;
const $ = id => document.getElementById(id);
const updateSlider = (idBase, v) => { const sl = $(`${idBase}_sl`), tv = $(`${idBase}_val`); if (sl) { const min = +sl.min, max = +sl.max; sl.value = Math.max(min, Math.min(max, v)); } if (tv) tv.textContent = (typeof v === 'number' ? v.toFixed(2) : '-'); };
let MED_N = Number($('medn').value);
$('medn').onchange = e => MED_N = Number(e.target.value);
let STILL_W = Number($('still_w').value);
$('still_w').onchange = e => STILL_W = Number(e.target.value);
let ANG_EMA = Number($('ang_ema').value);
$('ang_ema').onchange = e => ANG_EMA = Number(e.target.value);
let GRAV_BETA = Number($('grav_beta').value);
$('grav_beta').onchange = e => GRAV_BETA = Number(e.target.value);
let YAW_DB = Number($('yaw_db').value);
$('yaw_db').onchange = e => YAW_DB = Number(e.target.value);
let YAW_LEAK = Number($('yaw_leak').value);
$('yaw_leak').onchange = e => YAW_LEAK = Number(e.target.value);
let autoRezero = true;
$('auto_rezero').onchange = e => { autoRezero = e.target.checked; };
let autoBias = true;
$('auto_bias').onchange = e => { autoBias = e.target.checked; };
let useProj = true;
$('use_proj').onchange = e => { useProj = e.target.checked; };
let freezeStill = true;
$('freeze_still').onchange = e => { freezeStill = e.target.checked; };
const mkMed = () => ({ buf: [], push(v) { this.buf.push(v); if (this.buf.length > MED_N) this.buf.shift(); const arr = [...this.buf].sort((a, b) => a - b); const m = arr[Math.floor(arr.length / 2)]; return { median: m, valid: this.buf.length === MED_N }; } });
const fx = mkMed(), fy = mkMed(), fz = mkMed();
const gx = mkMed(), gy = mkMed(), gz = mkMed();
const rad2deg = r => r * 180 / Math.PI;
const wrap180 = a => { a %= 360; if (a >= 180) a -= 360; if (a < -180) a += 360; return a; };
let lastTS = 0;
let yaw = 0;
let ref = { roll: 0, pitch: 0, yaw: 0 };
let holdStart = 0, isStill = false;
let gLP = { x: 0, y: 0, z: 0 };
const G = 9.807, A_TOL = 0.08 * G;
let gOff = { x: 0, y: 0, z: 0 };
const BIAS_ALPHA = 0.002;
let Rf = 0, Pf = 0, Yf = 0;
document.getElementById('btn_zero').onclick = () => { ref = { roll: Rf, pitch: Pf, yaw: Yf }; };
document.getElementById('btn_reset').onclick = () => { ref = { roll: 0, pitch: 0, yaw: 0 }; yaw = 0; Rf = 0; Pf = 0; Yf = 0; };
document.getElementById('btn_bias_now').onclick = () => { gOff = { ...lastGy }; };
let lastGy = { x: 0, y: 0, z: 0 };
const imu_ws_state = document.getElementById('imu_ws_state');
function setImuBadge(ok, text) {
imu_ws_state.textContent = text;
imu_ws_state.className = 'badge ' + (ok ? 'ok' : 'err');
}
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws');
setImuBadge(false, 'connecting…');
ws.onopen = () => setImuBadge(true, 'connected');
ws.onclose = () => setImuBadge(false, 'disconnected');
ws.onerror = () => setImuBadge(false, 'error');
ws.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
const t = (typeof d.ts === 'number') ? d.ts : performance.now();
let dt = (!lastTS || (t - lastTS) <= 0 || (t - lastTS) > 300) ? 0.02 : (t - lastTS) / 1000;
lastTS = t;
let ax = Number(d?.accel?.x) || 0, ay = Number(d?.accel?.y) || 0, az = Number(d?.accel?.z) || 0;
let wx = Number(d?.gyro?.x) || 0, wy = Number(d?.gyro?.y) || 0, wz = Number(d?.gyro?.z) || 0;
const fxr = fx.push(ax), fyr = fy.push(ay), fzr = fz.push(az);
const gxr = gx.push(wx), gyr = gy.push(wy), gzr = gz.push(wz);
if (fxr.valid) { ax = fxr.median; ay = fyr.median; az = fzr.median; }
if (gxr.valid) { wx = gxr.median; wy = gyr.median; wz = gzr.median; }
lastGy = { x: wx, y: wy, z: wz };
gLP.x = GRAV_BETA * gLP.x + (1 - GRAV_BETA) * ax;
gLP.y = GRAV_BETA * gLP.y + (1 - GRAV_BETA) * ay;
gLP.z = GRAV_BETA * gLP.z + (1 - GRAV_BETA) * az;
const gmag = Math.hypot(gLP.x, gLP.y, gLP.z) || 1;
const gHat = { x: gLP.x / gmag, y: gLP.y / gmag, z: gLP.z / gmag };
const roll = rad2deg(Math.atan2(az, ay));
const pitch = rad2deg(Math.atan2(-ax, ay));
const aNorm = Math.hypot(ax, ay, az);
const wNorm = Math.hypot(wx, wy, wz);
const nearFlat = Math.abs(roll) < 2.0 && Math.abs(pitch) < 2.0;
const stillCond = (Math.abs(aNorm - G) < A_TOL) && (wNorm < STILL_W);
if (stillCond) {
if (!holdStart) holdStart = t;
if (!isStill && (t - holdStart) > 350) isStill = true;
if (autoBias) {
gOff.x = (1 - BIAS_ALPHA) * gOff.x + BIAS_ALPHA * wx;
gOff.y = (1 - BIAS_ALPHA) * gOff.y + BIAS_ALPHA * wy;
gOff.z = (1 - BIAS_ALPHA) * gOff.z + BIAS_ALPHA * wz;
}
} else { holdStart = 0; isStill = false; }
let yawdot = useProj
? ((wx - gOff.x) * gHat.x + (wy - gOff.y) * gHat.y + (wz - gOff.z) * gHat.z)
: (wy - gOff.y);
if (Math.abs(yawdot) < YAW_DB) yawdot = 0;
if (freezeStill && stillCond) yawdot = 0;
yaw = wrap180(yaw + yawdot * dt);
if (YAW_LEAK > 0 && nearFlat && stillCond && Math.abs(yaw) > 0) {
const step = YAW_LEAK * dt * Math.sign(-yaw);
if (Math.abs(yaw) <= Math.abs(step)) yaw = 0; else yaw += step;
}
const alpha = ANG_EMA;
Rf = alpha * roll + (1 - alpha) * Rf;
Pf = alpha * pitch + (1 - alpha) * Pf;
Yf = alpha * yaw + (1 - alpha) * Yf;
if (autoRezero && nearFlat && wNorm < STILL_W) {
if (!holdStart) holdStart = t;
if (!isStill && (t - holdStart) > 350) {
ref = { roll: Rf, pitch: Pf, yaw: Yf };
isStill = true;
}
}
const R = wrap180(Rf - ref.roll);
const P = wrap180(Pf - ref.pitch);
const Y = wrap180(Yf - ref.yaw);
const qBody = new THREE.Quaternion()
.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(Y)))
.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(P)))
.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(R)));
const q = qMount.clone().multiply(qBody);
if (FOLLOW >= 0.999) group.setRotationFromQuaternion(q);
else group.quaternion.slerp(q, FOLLOW);
updateSlider('roll', R);
updateSlider('pitch', P);
updateSlider('yaw', Y);
updateSlider('gx', wx); updateSlider('gy', wy); updateSlider('gz', wz);
updateSlider('ax', ax); updateSlider('ay', ay); updateSlider('az', az);
// 更新右侧数据
updateDataPanel(R, P, Y, wx, wy, wz, ax, ay, az);
} catch (e) { }
};
// 初次与窗口改变时,保持左右上下对齐
window.addEventListener('resize', resize);
resize();
})();

BIN
static/models/aiglass.glb Normal file

Binary file not shown.

195
static/vision.css Normal file
View File

@@ -0,0 +1,195 @@
/* 科技感配色方案 */
:root {
--tech-bg: #0a0e1b;
--tech-card: #111827;
--tech-border: #1e293b;
--tech-primary: #3b82f6;
--tech-secondary: #8b5cf6;
--tech-accent: #06b6d4;
--tech-success: #10b981;
--tech-warning: #f59e0b;
--tech-text: #e0e7ff;
--tech-muted: #94a3b8;
--glow-primary: 0 0 30px rgba(59, 130, 246, 0.5);
--glow-secondary: 0 0 30px rgba(139, 92, 246, 0.5);
}
/* 视觉识别画布容器 */
.vision-container {
position: relative;
background: var(--tech-bg);
border: 1px solid var(--tech-border);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6);
}
.vision-canvas {
width: 100%;
height: auto;
display: block;
}
/* 覆盖层 */
.vision-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* HUD样式 */
.hud-element {
position: absolute;
color: var(--tech-text);
font-family: 'Inter', 'Noto Sans SC', sans-serif;
text-shadow: 0 0 10px rgba(59, 130, 246, 0.8);
}
/* 状态指示器 */
.status-indicator {
position: absolute;
top: 20px;
left: 20px;
padding: 12px 24px;
background: rgba(17, 24, 39, 0.9);
border: 1px solid var(--tech-primary);
border-radius: 8px;
backdrop-filter: blur(10px);
box-shadow: var(--glow-primary);
}
.status-main {
font-size: 18px;
font-weight: 600;
color: var(--tech-primary);
margin-bottom: 4px;
}
.status-sub {
font-size: 12px;
color: var(--tech-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
/* 进度条 */
.progress-container {
position: absolute;
bottom: 40px;
left: 20px;
width: 300px;
}
.progress-item {
margin-bottom: 20px;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.progress-label-text {
font-size: 14px;
font-weight: 500;
}
.progress-label-sub {
font-size: 11px;
color: var(--tech-muted);
margin-left: 8px;
}
.progress-bar {
height: 8px;
background: rgba(30, 41, 59, 0.8);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--tech-primary), var(--tech-accent));
border-radius: 4px;
transition: width 0.3s ease;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.6);
}
/* 手部追踪样式 */
.hand-skeleton {
stroke: var(--tech-accent);
stroke-width: 2;
fill: none;
filter: drop-shadow(0 0 6px rgba(6, 182, 212, 0.8));
}
.hand-joint {
fill: var(--tech-accent);
filter: drop-shadow(0 0 8px rgba(6, 182, 212, 1));
}
/* 目标锁定样式 */
.target-lock {
stroke: var(--tech-success);
stroke-width: 3;
fill: none;
stroke-dasharray: 10 5;
animation: rotate 20s linear infinite;
filter: drop-shadow(0 0 10px rgba(16, 185, 129, 0.8));
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 闪烁动画 */
.flash-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, transparent 0%, rgba(139, 92, 246, 0.3) 100%);
animation: flash-pulse 1s ease-in-out;
}
@keyframes flash-pulse {
0%, 100% { opacity: 0; }
50% { opacity: 1; }
}
/* 数据显示面板 */
.data-panel {
position: absolute;
top: 20px;
right: 20px;
background: rgba(17, 24, 39, 0.9);
border: 1px solid var(--tech-secondary);
border-radius: 8px;
padding: 16px;
backdrop-filter: blur(10px);
min-width: 200px;
}
.data-item {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 14px;
}
.data-label {
color: var(--tech-muted);
}
.data-value {
color: var(--tech-text);
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
}

291
static/vision.js Normal file
View File

@@ -0,0 +1,291 @@
// 科技感视觉识别系统
class VisionSystem {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.overlay = document.createElement('div');
this.overlay.className = 'vision-overlay';
this.canvas.parentElement.appendChild(this.overlay);
// 状态
this.mode = 'SEGMENT';
this.fps = 0;
this.detectedObjects = [];
this.handData = null;
this.trackingData = null;
// 初始化UI元素
this.initUI();
// 连接WebSocket
this.connectVisionWS();
}
initUI() {
// 状态指示器
this.statusElement = this.createStatusIndicator();
this.overlay.appendChild(this.statusElement);
// 进度条
this.progressElement = this.createProgressBars();
this.overlay.appendChild(this.progressElement);
// 数据面板
this.dataPanel = this.createDataPanel();
this.overlay.appendChild(this.dataPanel);
}
createStatusIndicator() {
const status = document.createElement('div');
status.className = 'status-indicator';
status.innerHTML = `
<div class="status-main">系统就绪 <span class="status-sub">System Ready</span></div>
<div class="status-sub">等待目标 Waiting for Target</div>
`;
return status;
}
createProgressBars() {
const container = document.createElement('div');
container.className = 'progress-container';
container.innerHTML = `
<div class="progress-item">
<div class="progress-label">
<span class="progress-label-text">对齐度 <span class="progress-label-sub">Alignment</span></span>
<span class="progress-value">0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="align-progress" style="width: 0%"></div>
</div>
</div>
<div class="progress-item">
<div class="progress-label">
<span class="progress-label-text">距离匹配 <span class="progress-label-sub">Distance Match</span></span>
<span class="progress-value">0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="distance-progress" style="width: 0%"></div>
</div>
</div>
`;
return container;
}
createDataPanel() {
const panel = document.createElement('div');
panel.className = 'data-panel';
panel.innerHTML = `
<div class="data-item">
<span class="data-label">FPS</span>
<span class="data-value" id="fps-value">--</span>
</div>
<div class="data-item">
<span class="data-label">模式 Mode</span>
<span class="data-value" id="mode-value">检测</span>
</div>
<div class="data-item">
<span class="data-label">目标数 Objects</span>
<span class="data-value" id="objects-value">0</span>
</div>
<div class="data-item">
<span class="data-label">握持分 Grasp</span>
<span class="data-value" id="grasp-value">0.00</span>
</div>
`;
return panel;
}
connectVisionWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
this.ws = new WebSocket(`${proto}://${location.host}/ws/viewer`); // 改为 /ws/viewer
this.ws.onopen = () => {
console.log('[Vision] WebSocket connected');
// ... rest of the code
};
this.ws.onmessage = (event) => {
// 处理二进制图像数据
if (event.data instanceof Blob) {
// 创建图像URL并显示
const url = URL.createObjectURL(event.data);
const img = new Image();
img.onload = () => {
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
URL.revokeObjectURL(url);
};
img.src = url;
}
};
this.ws.onerror = () => {
console.error('Vision WebSocket error');
};
}
updateVisualization(data) {
// 更新状态
this.mode = data.mode || 'SEGMENT';
this.fps = data.fps || 0;
// 更新UI
this.updateStatus(data);
this.updateProgress(data);
this.updateDataPanel(data);
// 绘制可视化
if (data.frame) {
this.drawFrame(data.frame);
}
if (data.hand) {
this.drawHand(data.hand);
}
if (data.objects) {
this.drawObjects(data.objects);
}
if (data.tracking) {
this.drawTracking(data.tracking);
}
}
updateStatus(data) {
const statusMain = this.statusElement.querySelector('.status-main');
const statusSub = this.statusElement.querySelector('.status-sub:last-child');
switch(this.mode) {
case 'SEGMENT':
statusMain.innerHTML = '目标检测中 <span class="status-sub">Detecting</span>';
statusSub.textContent = data.message || '扫描环境 Scanning Environment';
break;
case 'FLASH':
statusMain.innerHTML = '锁定中 <span class="status-sub">Locking</span>';
statusSub.textContent = '准备追踪 Preparing to Track';
break;
case 'TRACK':
statusMain.innerHTML = '追踪中 <span class="status-sub">Tracking</span>';
statusSub.textContent = '保持对准 Maintain Alignment';
break;
}
}
updateProgress(data) {
if (data.alignScore !== undefined) {
const alignPercent = Math.round(data.alignScore * 100);
document.getElementById('align-progress').style.width = `${alignPercent}%`;
this.progressElement.querySelector('.progress-value').textContent = `${alignPercent}%`;
}
if (data.distanceScore !== undefined) {
const distPercent = Math.round(data.distanceScore * 100);
document.getElementById('distance-progress').style.width = `${distPercent}%`;
this.progressElement.querySelectorAll('.progress-value')[1].textContent = `${distPercent}%`;
}
}
updateDataPanel(data) {
document.getElementById('fps-value').textContent = Math.round(this.fps);
document.getElementById('mode-value').textContent = this.getModeText(this.mode);
document.getElementById('objects-value').textContent = data.objectCount || 0;
document.getElementById('grasp-value').textContent = (data.graspScore || 0).toFixed(2);
}
getModeText(mode) {
const modeMap = {
'SEGMENT': '检测 Detect',
'FLASH': '锁定 Lock',
'TRACK': '追踪 Track'
};
return modeMap[mode] || mode;
}
drawFrame(frameData) {
// 绘制基础图像
const img = new Image();
img.onload = () => {
this.canvas.width = img.width;
this.canvas.height = img.height;
this.ctx.drawImage(img, 0, 0);
};
img.src = 'data:image/jpeg;base64,' + frameData;
}
drawHand(handData) {
// 使用SVG绘制手部骨骼
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.pointerEvents = 'none';
// 绘制连接线
handData.connections.forEach(conn => {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', conn.start.x);
line.setAttribute('y1', conn.start.y);
line.setAttribute('x2', conn.end.x);
line.setAttribute('y2', conn.end.y);
line.setAttribute('class', 'hand-skeleton');
svg.appendChild(line);
});
// 绘制关节点
handData.landmarks.forEach(point => {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', point.x);
circle.setAttribute('cy', point.y);
circle.setAttribute('r', '3');
circle.setAttribute('class', 'hand-joint');
svg.appendChild(circle);
});
// 添加到覆盖层
const oldSvg = this.overlay.querySelector('svg');
if (oldSvg) oldSvg.remove();
this.overlay.appendChild(svg);
}
drawObjects(objects) {
// 绘制检测到的物体
objects.forEach((obj, index) => {
if (obj.isTarget) {
// 目标物体用特殊样式
this.drawTargetObject(obj);
} else {
// 其他物体用普通样式
this.drawNormalObject(obj);
}
});
}
drawTargetObject(obj) {
// 创建目标锁定效果
const target = document.createElement('div');
target.className = 'target-lock';
target.style.position = 'absolute';
target.style.left = `${obj.x}px`;
target.style.top = `${obj.y}px`;
target.style.width = `${obj.width}px`;
target.style.height = `${obj.height}px`;
// 添加锁定动画
const svg = `
<svg width="${obj.width}" height="${obj.height}" style="position: absolute; top: 0; left: 0;">
<rect x="2" y="2" width="${obj.width-4}" height="${obj.height-4}"
class="target-lock" rx="8" ry="8"/>
</svg>
`;
target.innerHTML = svg;
this.overlay.appendChild(target);
}
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
const visionSystem = new VisionSystem('vision-canvas');
});

443
static/vision_renderer.js Normal file
View File

@@ -0,0 +1,443 @@
// vision_renderer.js - 前端可视化渲染器
class VisionRenderer {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.ws = null;
this.currentData = null;
// UI配色方案
this.colors = {
primaryBlue: '#00C8FF',
secondaryPurple: '#9664FF',
accentCyan: '#00FFFF',
white: '#FFFFFF',
lightGray: '#C8C8C8',
darkBg: 'rgba(40, 40, 40, 0.8)',
success: '#7FFF00',
warning: '#FFA500',
error: '#FF7272',
glassBg: 'rgba(20, 20, 20, 0.3)',
};
// 动画状态
this.animations = {
flashAlpha: 0,
messageAlpha: 1,
progressAnimations: {}
};
this.setupCanvas();
this.connect();
this.startRenderLoop();
}
setupCanvas() {
// 设置画布大小
const resizeCanvas = () => {
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width;
this.canvas.height = rect.height;
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
this.ws = new WebSocket(`${proto}://${location.host}/ws/vision_data`);
this.ws.onopen = () => {
console.log('[VisionRenderer] Connected');
this.updateConnectionStatus(true);
};
this.ws.onclose = () => {
console.log('[VisionRenderer] Disconnected');
this.updateConnectionStatus(false);
// 自动重连
setTimeout(() => this.connect(), 2000);
};
this.ws.onmessage = (event) => {
try {
this.currentData = JSON.parse(event.data);
} catch (e) {
console.error('[VisionRenderer] Parse error:', e);
}
};
}
updateConnectionStatus(connected) {
const badge = document.getElementById('visionStatus');
if (badge) {
badge.textContent = connected ? 'Vision: connected' : 'Vision: disconnected';
badge.className = 'badge ' + (connected ? 'ok' : 'err');
}
}
startRenderLoop() {
const render = () => {
this.clearCanvas();
if (this.currentData) {
this.renderFrame(this.currentData);
}
requestAnimationFrame(render);
};
render();
}
clearCanvas() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
renderFrame(data) {
const ctx = this.ctx;
const W = this.canvas.width;
const H = this.canvas.height;
// 渲染手部骨骼
if (data.hand_detected && data.hand_landmarks) {
this.drawHandSkeleton(data.hand_landmarks);
// 手部边界框
if (data.hand_box) {
this.drawBox(data.hand_box, this.colors.accentCyan, 1);
}
// 握持评分
this.drawTextWithBg(
`握持评分 Grasp Score: ${data.grasp_score.toFixed(2)}`,
10, 60, 18, this.colors.accentCyan
);
}
// 渲染检测到的物体
if (data.mode === 'SEGMENT' && data.objects) {
data.objects.forEach((obj, index) => {
const isSelected = index === data.selected_object_index;
const color = isSelected ? this.colors.success : this.colors.primaryBlue;
// 绘制轮廓
if (obj.contour) {
this.drawContour(obj.contour, color, isSelected ? 3 : 2);
}
// 选中物体的标记
if (isSelected && obj.center) {
this.drawTargetMarker(obj.center.x, obj.center.y);
}
});
// 倒计时
if (data.countdown !== null) {
this.drawCountdown(data.countdown);
}
}
// 闪烁动画
if (data.mode === 'FLASH' && data.flash_progress !== null) {
this.renderFlashAnimation(data.flash_progress);
}
// 追踪模式
if (data.mode === 'TRACK') {
// 追踪多边形
if (data.tracking_polygon) {
this.drawPolygon(data.tracking_polygon, this.colors.success, 2);
}
// 中心点
if (data.tracking_center) {
this.drawCircle(data.tracking_center.x, data.tracking_center.y, 6, this.colors.success);
}
// 对齐箭头
if (data.hand_center && data.tracking_center) {
this.drawMeasureArrow(
data.hand_center,
data.tracking_center
);
}
// 面积比和引导
if (data.area_ratio !== null) {
this.drawAreaRatio(data.area_ratio, data.guidance);
}
}
// 进度条
this.drawTechProgressBars(data.align_score, data.range_score);
// FPS
this.drawFPS(data.fps);
// 状态消息
if (data.status_message) {
this.drawStatusMessage(data.status_message);
}
}
drawHandSkeleton(landmarks) {
const ctx = this.ctx;
const color = this.colors.secondaryPurple;
// MediaPipe手部连接
const connections = [
[0, 1], [1, 2], [2, 3], [3, 4], // 拇指
[0, 5], [5, 6], [6, 7], [7, 8], // 食指
[0, 9], [9, 10], [10, 11], [11, 12], // 中指
[0, 13], [13, 14], [14, 15], [15, 16], // 无名指
[0, 17], [17, 18], [18, 19], [19, 20], // 小指
[5, 9], [9, 13], [13, 17] // 掌心
];
// 绘制连接线
ctx.strokeStyle = color;
ctx.lineWidth = 2;
connections.forEach(([i, j]) => {
if (landmarks[i] && landmarks[j]) {
ctx.beginPath();
ctx.moveTo(landmarks[i].x, landmarks[i].y);
ctx.lineTo(landmarks[j].x, landmarks[j].y);
ctx.stroke();
}
});
// 绘制关键点
landmarks.forEach(point => {
this.drawCircle(point.x, point.y, 3, color, true);
});
}
drawTextWithBg(text, x, y, fontSize = 18, color = this.colors.white, bgColor = this.colors.glassBg) {
const ctx = this.ctx;
const padding = 10;
ctx.font = `${fontSize}px Arial, "Microsoft YaHei"`;
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
const textHeight = fontSize;
// 绘制背景
ctx.fillStyle = bgColor;
ctx.fillRect(x - padding, y - textHeight - padding,
textWidth + 2 * padding, textHeight + 2 * padding);
// 绘制边框
ctx.strokeStyle = this.colors.primaryBlue;
ctx.lineWidth = 1;
ctx.strokeRect(x - padding, y - textHeight - padding,
textWidth + 2 * padding, textHeight + 2 * padding);
// 绘制文字
ctx.fillStyle = color;
ctx.fillText(text, x, y);
}
drawCountdown(seconds) {
const text = `检测到物体 Object detected, ${seconds.toFixed(1)}s`;
const x = 10;
const y = 100;
this.drawTextWithBg(text, x, y, 22, this.colors.warning);
}
renderFlashAnimation(progress) {
const ctx = this.ctx;
const W = this.canvas.width;
const H = this.canvas.height;
// 计算闪烁透明度
const cycleProgress = progress * 2;
const alpha = 0.3 + 0.3 * Math.sin(cycleProgress * Math.PI);
// 全屏闪烁效果
ctx.fillStyle = this.colors.accentCyan + Math.floor(alpha * 255).toString(16).padStart(2, '0');
ctx.fillRect(0, 0, W, H);
// 锁定文字
this.drawTextWithBg('正在锁定目标... Locking target...',
W/2 - 150, H/2, 24, this.colors.accentCyan);
}
drawTechProgressBars(alignScore, rangeScore) {
const W = this.canvas.width;
const H = this.canvas.height;
const barW = W * 0.3;
const barH = 8;
const gap = 20;
const x0 = 20;
const y0 = H - 2 * barH - gap - 60;
// 对齐进度条
this.drawProgressBar(x0, y0, barW, barH, alignScore,
'对齐 Align', this.colors.primaryBlue);
// 距离进度条
this.drawProgressBar(x0, y0 + barH + gap, barW, barH, rangeScore,
'距离(≈1) Distance(≈1)', this.colors.accentCyan);
}
drawProgressBar(x, y, width, height, value, label, color) {
const ctx = this.ctx;
// 背景
ctx.fillStyle = this.colors.darkBg;
ctx.fillRect(x, y, width, height);
// 边框
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
// 填充(渐变)
const fillWidth = width * Math.max(0, Math.min(1, value));
if (fillWidth > 0) {
const gradient = ctx.createLinearGradient(x, y, x + fillWidth, y);
gradient.addColorStop(0, this.colors.secondaryPurple);
gradient.addColorStop(1, color);
ctx.fillStyle = gradient;
ctx.fillRect(x, y, fillWidth, height);
}
// 标签
this.drawTextWithBg(label, x, y - 10, 14, color);
}
drawCircle(x, y, radius, color, fill = true) {
const ctx = this.ctx;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
if (fill) {
ctx.fillStyle = color;
ctx.fill();
} else {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.stroke();
}
}
drawBox(box, color, lineWidth = 2) {
const ctx = this.ctx;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.strokeRect(box.x, box.y, box.width, box.height);
}
drawContour(points, color, lineWidth = 2) {
if (!points || points.length < 3) return;
const ctx = this.ctx;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.closePath();
ctx.stroke();
}
drawPolygon(points, color, lineWidth = 2) {
this.drawContour(points, color, lineWidth);
}
drawTargetMarker(x, y) {
// 双圆圈标记
this.drawCircle(x, y, 8, this.colors.success, false);
this.drawCircle(x, y, 12, this.colors.success, false);
this.drawTextWithBg('目标 Target', x + 15, y - 5, 16, this.colors.success);
}
drawMeasureArrow(p1, p2) {
const ctx = this.ctx;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 绘制线
ctx.strokeStyle = this.colors.white;
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
ctx.setLineDash([]);
// 绘制箭头
const angle = Math.atan2(dy, dx);
const arrowLength = 15;
const arrowAngle = Math.PI / 6;
ctx.beginPath();
ctx.moveTo(p2.x, p2.y);
ctx.lineTo(
p2.x - arrowLength * Math.cos(angle - arrowAngle),
p2.y - arrowLength * Math.sin(angle - arrowAngle)
);
ctx.moveTo(p2.x, p2.y);
ctx.lineTo(
p2.x - arrowLength * Math.cos(angle + arrowAngle),
p2.y - arrowLength * Math.sin(angle + arrowAngle)
);
ctx.stroke();
// 显示距离
const midX = (p1.x + p2.x) / 2;
const midY = (p1.y + p2.y) / 2;
ctx.fillStyle = this.colors.white;
ctx.font = '14px Arial';
ctx.fillText(`${distance.toFixed(0)}px`, midX + 10, midY - 10);
}
drawAreaRatio(ratio, guidance) {
const y = 120;
const text = `面积比 Area Ratio: ${ratio.toFixed(2)}`;
this.drawTextWithBg(text, 10, y, 18, this.colors.lightGray);
if (guidance) {
const guidanceText = {
'forward': '向前靠近 Move Forward',
'backward': '后退 Move Back',
'maintain': '保持 Maintain'
};
const guidanceColor = guidance === 'maintain' ? this.colors.success : this.colors.warning;
this.drawTextWithBg(guidanceText[guidance] || guidance,
10, y + 40, 20, guidanceColor);
}
}
drawFPS(fps) {
const W = this.canvas.width;
const text = `FPS: ${fps.toFixed(1)}`;
this.drawTextWithBg(text, W - 120, 30, 16, this.colors.accentCyan);
}
drawStatusMessage(message) {
const W = this.canvas.width;
const H = this.canvas.height;
// 根据消息类型选择颜色
let color = this.colors.white;
if (message.includes('追踪丢失') || message.includes('lost')) {
color = this.colors.error;
} else if (message.includes('刷新') || message.includes('refreshed')) {
color = this.colors.success;
}
this.drawTextWithBg(message, W/2 - 200, H - 50, 20, color);
}
}
// 初始化渲染器
document.addEventListener('DOMContentLoaded', () => {
window.visionRenderer = new VisionRenderer('canvas');
});

546
static/visualizer.js Normal file
View File

@@ -0,0 +1,546 @@
// static/visualizer.js
class TechVisualizer {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.ws = null;
this.data = {};
// 科技感配色方案
this.colors = {
primary: '#00D9FF', // 青蓝色
secondary: '#FF00FF', // 品红/紫色
accent: '#00FF88', // 青绿色
warning: '#FFAA00', // 橙色
background: '#000000', // 黑色
surface: '#0A0A0A', // 深灰
text: '#FFFFFF', // 白色
textMuted: '#888888', // 灰色
grid: '#1A1A1A', // 网格色
glow: '#00D9FF55' // 发光效果
};
// 字体设置
this.fonts = {
title: 'bold 24px "Orbitron", "Microsoft YaHei", sans-serif',
subtitle: 'bold 18px "Rajdhani", "Microsoft YaHei", sans-serif',
body: '16px "Roboto", "Microsoft YaHei", sans-serif',
small: '12px "Roboto", "Microsoft YaHei", sans-serif',
tiny: '10px "Roboto", sans-serif'
};
this.setupCanvas();
this.connectWebSocket();
}
setupCanvas() {
// 设置画布大小
const resizeCanvas = () => {
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = rect.width * window.devicePixelRatio;
this.canvas.height = rect.height * window.devicePixelRatio;
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
}
connectWebSocket() {
const wsUrl = `ws://${window.location.host}/ws/visualizer`;
this.ws = new WebSocket(wsUrl);
this.ws.onmessage = (event) => {
try {
this.data = JSON.parse(event.data);
this.render();
} catch (e) {
console.error('Failed to parse visualization data:', e);
}
};
this.ws.onclose = () => {
setTimeout(() => this.connectWebSocket(), 1000);
};
}
render() {
const ctx = this.ctx;
const width = this.canvas.width / window.devicePixelRatio;
const height = this.canvas.height / window.devicePixelRatio;
// 清空画布
ctx.fillStyle = this.colors.background;
ctx.fillRect(0, 0, width, height);
// 绘制网格背景
this.drawGrid(width, height);
// 绘制HUD边框
this.drawHUD(width, height);
// 根据模式绘制内容
if (this.data.mode === 'SEGMENT') {
this.drawSegmentMode(width, height);
} else if (this.data.mode === 'FLASH') {
this.drawFlashMode(width, height);
} else if (this.data.mode === 'TRACK') {
this.drawTrackMode(width, height);
}
// 绘制手部骨骼
if (this.data.hand) {
this.drawHand(this.data.hand, width, height);
}
// 绘制FPS和状态信息
this.drawStats(width, height);
}
drawGrid(width, height) {
const ctx = this.ctx;
ctx.strokeStyle = this.colors.grid;
ctx.lineWidth = 0.5;
const gridSize = 50;
for (let x = 0; x < width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y < height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
drawHUD(width, height) {
const ctx = this.ctx;
const margin = 20;
// 四角装饰
ctx.strokeStyle = this.colors.primary;
ctx.lineWidth = 2;
const cornerSize = 40;
// 左上角
ctx.beginPath();
ctx.moveTo(margin, margin + cornerSize);
ctx.lineTo(margin, margin);
ctx.lineTo(margin + cornerSize, margin);
ctx.stroke();
// 右上角
ctx.beginPath();
ctx.moveTo(width - margin - cornerSize, margin);
ctx.lineTo(width - margin, margin);
ctx.lineTo(width - margin, margin + cornerSize);
ctx.stroke();
// 左下角
ctx.beginPath();
ctx.moveTo(margin, height - margin - cornerSize);
ctx.lineTo(margin, height - margin);
ctx.lineTo(margin + cornerSize, height - margin);
ctx.stroke();
// 右下角
ctx.beginPath();
ctx.moveTo(width - margin - cornerSize, height - margin);
ctx.lineTo(width - margin, height - margin);
ctx.lineTo(width - margin, height - margin - cornerSize);
ctx.stroke();
}
drawSegmentMode(width, height) {
const ctx = this.ctx;
// 绘制检测到的物体
if (this.data.segments) {
this.data.segments.forEach((seg, index) => {
if (seg.contour && seg.contour.length > 0) {
// 绘制轮廓
ctx.beginPath();
ctx.strokeStyle = seg.is_target ? this.colors.primary : this.colors.secondary;
ctx.lineWidth = seg.is_target ? 3 : 2;
// 添加发光效果
if (seg.is_target) {
ctx.shadowColor = this.colors.primary;
ctx.shadowBlur = 10;
}
const points = this.scalePoints(seg.contour, width, height);
ctx.moveTo(points[0][0], points[0][1]);
points.forEach(p => ctx.lineTo(p[0], p[1]));
ctx.closePath();
ctx.stroke();
ctx.shadowBlur = 0;
// 如果是目标,绘制中心标记
if (seg.is_target) {
const center = this.getContourCenter(points);
this.drawTargetMarker(center[0], center[1]);
// 绘制面积信息
ctx.font = this.fonts.small;
ctx.fillStyle = this.colors.primary;
ctx.fillText(`Area: ${seg.area}`, center[0] + 20, center[1] - 20);
}
}
});
}
// 绘制状态文字
if (this.data.auto_lock && this.data.auto_lock.active) {
this.drawStatusText(
`目标锁定中 Locking Target`,
`${this.data.auto_lock.remaining.toFixed(1)}s`,
width / 2,
100,
this.colors.warning
);
} else {
this.drawStatusText(
'扫描中 Scanning',
'等待检测目标 Waiting for target',
width / 2,
100,
this.colors.primary
);
}
}
drawFlashMode(width, height) {
const ctx = this.ctx;
if (this.data.flash && this.data.flash.mask_contour) {
const progress = this.data.flash.progress || 0;
const alpha = 0.3 + 0.4 * (0.5 * (1 + Math.sin(progress * 2 * Math.PI - Math.PI/2)));
// 绘制闪烁轮廓
ctx.beginPath();
ctx.strokeStyle = this.colors.accent;
ctx.lineWidth = 4;
ctx.globalAlpha = alpha;
const points = this.scalePoints(this.data.flash.mask_contour, width, height);
ctx.moveTo(points[0][0], points[0][1]);
points.forEach(p => ctx.lineTo(p[0], p[1]));
ctx.closePath();
// 填充
ctx.fillStyle = this.colors.accent + '33';
ctx.fill();
ctx.stroke();
ctx.globalAlpha = 1;
// 绘制锁定动画
const center = this.getContourCenter(points);
this.drawLockAnimation(center[0], center[1], progress);
}
this.drawStatusText(
'正在锁定目标 Locking Target',
'准备追踪 Preparing to track',
width / 2,
100,
this.colors.accent
);
}
drawTrackMode(width, height) {
const ctx = this.ctx;
const tracking = this.data.tracking;
if (!tracking) return;
// 绘制追踪多边形
if (tracking.polygon && tracking.polygon.length > 0) {
ctx.beginPath();
ctx.strokeStyle = this.colors.accent;
ctx.lineWidth = 3;
ctx.shadowColor = this.colors.accent;
ctx.shadowBlur = 15;
const points = this.scalePoints(tracking.polygon, width, height);
ctx.moveTo(points[0][0], points[0][1]);
points.forEach(p => ctx.lineTo(p[0], p[1]));
ctx.closePath();
ctx.stroke();
ctx.shadowBlur = 0;
// 绘制中心点
if (tracking.center) {
const center = this.scalePoint(tracking.center, width, height);
ctx.fillStyle = this.colors.accent;
ctx.beginPath();
ctx.arc(center[0], center[1], 6, 0, Math.PI * 2);
ctx.fill();
}
}
// 绘制进度条
this.drawProgressBars(tracking, width, height);
// 绘制引导文字
if (tracking.guidance) {
const guidanceText = {
'向前靠近': 'Move Closer',
'后退': 'Move Back',
'保持': 'Hold Position'
};
this.drawStatusText(
tracking.guidance,
guidanceText[tracking.guidance] || '',
width / 2,
height - 100,
this.colors.warning
);
}
// 如果触发了重新锁定
if (tracking.relock_triggered) {
this.drawStatusText(
'已根据周边检测刷新追踪',
'Tracking refreshed by peripheral detection',
width / 2,
170,
this.colors.accent
);
}
}
drawHand(handData, width, height) {
const ctx = this.ctx;
if (!handData.landmarks) return;
// 缩放坐标
const landmarks = handData.landmarks.map(p =>
this.scalePoint([p[0], p[1]], width, height)
);
// 绘制手部连接线
ctx.strokeStyle = this.colors.secondary;
ctx.lineWidth = 2;
ctx.globalAlpha = 0.8;
// MediaPipe手部连接定义
const connections = [
[0, 1], [1, 2], [2, 3], [3, 4], // 拇指
[0, 5], [5, 6], [6, 7], [7, 8], // 食指
[5, 9], [9, 10], [10, 11], [11, 12], // 中指
[9, 13], [13, 14], [14, 15], [15, 16], // 无名指
[13, 17], [17, 18], [18, 19], [19, 20], // 小指
[0, 17] // 手腕连接
];
connections.forEach(([start, end]) => {
ctx.beginPath();
ctx.moveTo(landmarks[start][0], landmarks[start][1]);
ctx.lineTo(landmarks[end][0], landmarks[end][1]);
ctx.stroke();
});
// 绘制关键点
landmarks.forEach((point, i) => {
ctx.fillStyle = this.colors.secondary;
ctx.beginPath();
ctx.arc(point[0], point[1], 3, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
// 绘制握持评分
if (handData.grasp_score !== undefined) {
ctx.font = this.fonts.body;
ctx.fillStyle = this.colors.text;
ctx.fillText(
`握持评分 Grasp Score: ${handData.grasp_score.toFixed(2)}`,
20,
80
);
}
}
drawProgressBars(tracking, width, height) {
const ctx = this.ctx;
const barWidth = width * 0.25;
const barHeight = 12;
const x = 20;
const y = height - 80;
// 对齐进度条
this.drawProgressBar(
x, y - 30,
barWidth, barHeight,
tracking.align_score || 0,
'对齐 Alignment',
this.colors.primary
);
// 距离进度条
this.drawProgressBar(
x, y,
barWidth, barHeight,
tracking.range_score || 0,
`距离 Distance (≈1)`,
this.colors.secondary
);
// 显示比率
if (tracking.ratio !== null && tracking.ratio !== undefined) {
ctx.font = this.fonts.small;
ctx.fillStyle = this.colors.text;
ctx.fillText(
`面积比 Ratio: ${tracking.ratio.toFixed(2)}`,
x + barWidth + 20,
y + 8
);
}
}
drawProgressBar(x, y, width, height, value, label, color) {
const ctx = this.ctx;
// 背景
ctx.fillStyle = this.colors.surface;
ctx.fillRect(x, y, width, height);
// 边框
ctx.strokeStyle = color + '44';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
// 填充
const fillWidth = width * Math.max(0, Math.min(1, value));
const gradient = ctx.createLinearGradient(x, y, x + fillWidth, y);
gradient.addColorStop(0, color + 'AA');
gradient.addColorStop(1, color);
ctx.fillStyle = gradient;
ctx.fillRect(x, y, fillWidth, height);
// 标签
ctx.font = this.fonts.small;
ctx.fillStyle = this.colors.textMuted;
ctx.fillText(label, x, y - 5);
}
drawStats(width, height) {
const ctx = this.ctx;
// FPS显示
ctx.font = this.fonts.body;
ctx.fillStyle = this.colors.accent;
ctx.fillText(`FPS: ${(this.data.fps || 0).toFixed(1)}`, 20, 40);
// 模式显示
const modeText = {
'SEGMENT': '分割模式 Segmentation',
'FLASH': '锁定模式 Locking',
'TRACK': '追踪模式 Tracking'
};
ctx.fillStyle = this.colors.text;
ctx.fillText(modeText[this.data.mode] || this.data.mode, width - 200, 40);
}
// 辅助函数
scalePoint(point, width, height) {
if (!this.data.frame_size) return [0, 0];
return [
point[0] * width / this.data.frame_size.width,
point[1] * height / this.data.frame_size.height
];
}
scalePoints(points, width, height) {
return points.map(p => this.scalePoint(p, width, height));
}
getContourCenter(points) {
const sum = points.reduce((acc, p) => [acc[0] + p[0], acc[1] + p[1]], [0, 0]);
return [sum[0] / points.length, sum[1] / points.length];
}
drawTargetMarker(x, y) {
const ctx = this.ctx;
ctx.strokeStyle = this.colors.primary;
ctx.lineWidth = 2;
// 十字准星
const size = 20;
ctx.beginPath();
ctx.moveTo(x - size, y);
ctx.lineTo(x - size/2, y);
ctx.moveTo(x + size/2, y);
ctx.lineTo(x + size, y);
ctx.moveTo(x, y - size);
ctx.lineTo(x, y - size/2);
ctx.moveTo(x, y + size/2);
ctx.lineTo(x, y + size);
ctx.stroke();
// 圆圈
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.stroke();
}
drawLockAnimation(x, y, progress) {
const ctx = this.ctx;
const radius = 30 + 10 * Math.sin(progress * Math.PI * 2);
ctx.strokeStyle = this.colors.accent;
ctx.lineWidth = 3;
ctx.globalAlpha = 0.8;
// 旋转的锁定环
ctx.save();
ctx.translate(x, y);
ctx.rotate(progress * Math.PI * 2);
// 绘制4个弧形
for (let i = 0; i < 4; i++) {
ctx.beginPath();
ctx.arc(0, 0, radius, i * Math.PI/2 + 0.1, i * Math.PI/2 + Math.PI/2 - 0.1);
ctx.stroke();
}
ctx.restore();
ctx.globalAlpha = 1;
}
drawStatusText(mainText, subText, x, y, color) {
const ctx = this.ctx;
// 主文字(中文)
ctx.font = this.fonts.subtitle;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.fillText(mainText, x, y);
// 副文字(英文)
if (subText) {
ctx.font = this.fonts.small;
ctx.fillStyle = this.colors.textMuted;
ctx.fillText(subText, x, y + 20);
}
ctx.textAlign = 'left';
}
}
// 初始化
window.addEventListener('DOMContentLoaded', () => {
window.visualizer = new TechVisualizer('tech-canvas');
});