Init: 导入NaviGlassServer源码
This commit is contained in:
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
883
static/main.js
Normal file
883
static/main.js
Normal 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
BIN
static/models/aiglass.glb
Normal file
Binary file not shown.
195
static/vision.css
Normal file
195
static/vision.css
Normal 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
291
static/vision.js
Normal 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
443
static/vision_renderer.js
Normal 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
546
static/visualizer.js
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user