| import { buildPreviewWebSocketUrl, parseVisionPacket } from "./preview-packet.mjs"; |
| import { deadZoneRect, scaleFace, targetPoint } from "./overlay-geometry.mjs"; |
|
|
| const $ = (id) => document.getElementById(id); |
| const canvas = $("visionCanvas"); |
| const context = canvas.getContext("2d"); |
| const state = { |
| socket: null, |
| lastEvent: 0, |
| recording: false, |
| pendingFrame: null, |
| drawingFrame: false, |
| }; |
|
|
| function fmt(value, suffix = "") { |
| return value === null || value === undefined ? "—" : `${value}${suffix}`; |
| } |
|
|
| function toast(message, tone = "ok") { |
| const node = $("toast"); |
| node.textContent = message; |
| node.dataset.tone = tone; |
| node.classList.add("visible"); |
| window.clearTimeout(toast.timer); |
| toast.timer = window.setTimeout(() => node.classList.remove("visible"), 3200); |
| } |
|
|
| async function request(path, options = {}) { |
| const response = await fetch(path, { |
| ...options, |
| headers: { "Content-Type": "application/json", ...(options.headers || {}) }, |
| }); |
| const payload = await response.json(); |
| if (!response.ok) throw new Error(payload.message || payload.detail || "操作失败"); |
| return payload; |
| } |
|
|
| function setSignal(node, tone) { |
| node.dataset.tone = tone; |
| } |
|
|
| function renderStatus(snapshot) { |
| const { connection, vision, session, findings } = snapshot; |
| $("connectionState").textContent = connection.online ? "机器人已连接" : "机器人未连接"; |
| setSignal($("connectionLamp"), connection.online ? "ok" : "error"); |
| $("backendValue").textContent = (vision.backend || "—").toUpperCase(); |
| $("backendDetail").textContent = `${vision.health || "unknown"} · code ${vision.status_code ?? "—"}`; |
| $("himaxValue").textContent = vision.himax_connected ? "CONNECTED" : "DISCONNECTED"; |
| $("himaxDetail").textContent = vision.initialized ? "初始化完成" : "未初始化"; |
| const model = vision.model; |
| $("modelValue").textContent = model?.name || "NO MODEL"; |
| $("modelDetail").textContent = model ? `槽位 ${model.id} · ${model.task}` : "模型元数据不可用"; |
| const capabilities = vision.capabilities || {}; |
| const enabled = Object.entries(capabilities).filter(([, value]) => value).map(([key]) => key); |
| $("capabilityValue").textContent = `${enabled.length} / ${Object.keys(capabilities).length || 0}`; |
| $("capabilityDetail").textContent = enabled.join(" · ") || "无可用能力"; |
| $("findings").replaceChildren(...findings.map((finding) => { |
| const item = document.createElement("p"); |
| item.dataset.tone = finding.severity; |
| const severity = document.createElement("span"); |
| severity.textContent = finding.severity.toUpperCase(); |
| item.append(severity, document.createTextNode(finding.message)); |
| return item; |
| })); |
| $("fpsMetric").textContent = fmt(session.fps); |
| $("gapMetric").textContent = fmt(session.gap_p95_ms, " ms"); |
| $("ageMetric").textContent = fmt(session.age_p95_ms, " ms"); |
| $("inferenceMetric").textContent = `${fmt(session.inference_avg_ms)} / ${fmt(session.inference_p95_ms)} ms`; |
| $("applicationMetric").textContent = fmt(session.application_p95_ms, " ms"); |
| $("missingMetric").textContent = fmt(session.missing_sequences); |
| $("jpegMetric").textContent = session.jpeg_avg_bytes ? `${(session.jpeg_avg_bytes / 1024).toFixed(1)} KB` : "—"; |
| $("streamState").textContent = session.running ? "LIVE" : "STANDBY"; |
| setSignal($("streamLamp"), session.running ? "ok" : "idle"); |
| state.recording = Boolean(session.recording?.active); |
| renderRecording(); |
| } |
|
|
| function renderRecording() { |
| $("recordingButton").classList.toggle("active", state.recording); |
| $("recordingButton").lastChild.textContent = state.recording ? "停止并保存录制" : "开始录制 JPEG + JSONL"; |
| $("recordingState").textContent = state.recording ? "● RECORDING" : "NOT RECORDING"; |
| $("recordingState").classList.toggle("active", state.recording); |
| } |
|
|
| async function refreshStatus() { |
| try { renderStatus(await request("/api/status")); } catch (error) { toast(error.message, "error"); } |
| } |
|
|
| function drawOverlay(metadata) { |
| const width = canvas.width; |
| const height = canvas.height; |
| context.save(); |
| context.lineWidth = 3; |
| context.font = "22px Cascadia Code, monospace"; |
| for (const face of metadata.faces || []) { |
| const box = scaleFace(face, metadata.width, metadata.height, width, height); |
| context.strokeStyle = face.target ? "#ffb547" : "#31d4bd"; |
| context.strokeRect(box.x, box.y, box.width, box.height); |
| context.fillStyle = context.strokeStyle; |
| context.fillRect(box.x, Math.max(0, box.y - 28), 154, 28); |
| context.fillStyle = "#07100f"; |
| context.fillText(`${face.target ? "TARGET" : "FACE"} ${face.score}`, box.x + 7, Math.max(21, box.y - 7)); |
| } |
| const zone = deadZoneRect(width, height); |
| context.setLineDash([10, 8]); |
| context.lineWidth = 2; |
| context.strokeStyle = "rgba(255,181,71,.75)"; |
| context.strokeRect(zone.x, zone.y, zone.width, zone.height); |
| context.setLineDash([]); |
| context.strokeStyle = "rgba(255,255,255,.42)"; |
| context.beginPath(); context.moveTo(width / 2 - 16, height / 2); context.lineTo(width / 2 + 16, height / 2); context.moveTo(width / 2, height / 2 - 16); context.lineTo(width / 2, height / 2 + 16); context.stroke(); |
| const target = targetPoint(metadata, width, height); |
| if (target) { |
| context.strokeStyle = "#ffb547"; |
| context.beginPath(); context.moveTo(width / 2, height / 2); context.lineTo(target.x, target.y); context.stroke(); |
| context.beginPath(); context.arc(target.x, target.y, 7, 0, Math.PI * 2); context.fillStyle = "#ffb547"; context.fill(); |
| } |
| context.restore(); |
| } |
|
|
| async function drawPacket(buffer) { |
| const { metadata, jpeg } = parseVisionPacket(buffer); |
| const image = await createImageBitmap(new Blob([jpeg], { type: "image/jpeg" })); |
| canvas.width = metadata.width * 2; |
| canvas.height = metadata.height * 2; |
| context.drawImage(image, 0, 0, canvas.width, canvas.height); |
| image.close(); |
| drawOverlay(metadata); |
| $("emptyState").hidden = true; |
| $("frameSequence").textContent = `SEQ ${metadata.sequence}`; |
| $("frameSize").textContent = `JPEG ${(metadata.jpeg_bytes / 1024).toFixed(1)} KB`; |
| $("browserMetric").textContent = `${Math.max(0, Date.now() - metadata.received_at * 1000).toFixed(1)} ms`; |
| const telemetry = metadata.telemetry || {}; |
| $("errorX").textContent = fmt(telemetry.error_x_percent, "%"); |
| $("errorY").textContent = fmt(telemetry.error_y_percent, "%"); |
| $("panVelocity").textContent = fmt(telemetry.pan_velocity_deg_s, "°/s"); |
| $("tiltVelocity").textContent = fmt(telemetry.tilt_velocity_deg_s, "°/s"); |
| } |
|
|
| async function renderLatestFrame() { |
| if (state.drawingFrame) return; |
| state.drawingFrame = true; |
| try { |
| while (state.pendingFrame) { |
| const buffer = state.pendingFrame; |
| state.pendingFrame = null; |
| await drawPacket(buffer); |
| } |
| } catch (error) { |
| toast(error.message, "error"); |
| } finally { |
| state.drawingFrame = false; |
| } |
| } |
|
|
| function connectPreviewSocket() { |
| if (state.socket && state.socket.readyState < 2) return; |
| const socket = new WebSocket(buildPreviewWebSocketUrl()); |
| socket.binaryType = "arraybuffer"; |
| socket.onmessage = (event) => { |
| if (event.data instanceof ArrayBuffer) { |
| state.pendingFrame = event.data; |
| renderLatestFrame(); |
| } |
| }; |
| socket.onclose = () => { state.socket = null; }; |
| state.socket = socket; |
| } |
|
|
| async function startPreview() { |
| const [width, height] = $("resolution").value.split("x").map(Number); |
| try { |
| connectPreviewSocket(); |
| await request("/api/preview/start", { method: "POST", body: JSON.stringify({ width, height, frame_stride: Number($("stride").value), stop_policy: "hold" }) }); |
| toast("端侧人脸预览已启动"); |
| await refreshStatus(); |
| } catch (error) { toast(error.message, "error"); } |
| } |
|
|
| async function stopPreview(policy) { |
| try { |
| await request("/api/preview/stop", { method: "POST", body: JSON.stringify({ policy }) }); |
| toast(policy === "hold" ? "预览已停止,机器人保持当前位置" : "预览已停止,机器人回到中心"); |
| await refreshStatus(); |
| } catch (error) { toast(error.message, "error"); } |
| } |
|
|
| async function toggleRecording() { |
| try { |
| const path = state.recording ? "/api/recording/stop" : "/api/recording/start"; |
| const result = await request(path, { method: "POST" }); |
| state.recording = !state.recording; |
| renderRecording(); |
| toast(state.recording ? "数据集录制已开始" : `录制已保存:${result.relative_path}`); |
| } catch (error) { toast(error.message, "error"); } |
| } |
|
|
| async function exportReport() { |
| try { |
| const result = await request("/api/diagnostics/export", { method: "POST" }); |
| const link = document.createElement("a"); link.href = result.artifact_url; link.download = "vision-diagnostic-report.json"; link.click(); |
| toast("诊断报告已导出"); |
| } catch (error) { toast(error.message, "error"); } |
| } |
|
|
| async function pollEvents() { |
| try { |
| const payload = await request(`/api/events?after=${state.lastEvent}`); |
| if (payload.events.length) { |
| const list = $("eventList"); |
| if (list.querySelector(".muted")) list.replaceChildren(); |
| for (const event of payload.events) { |
| state.lastEvent = Math.max(state.lastEvent, event.id); |
| const item = document.createElement("li"); item.dataset.tone = event.tone; |
| const timestamp = document.createElement("time"); |
| timestamp.textContent = new Date(event.timestamp * 1000).toLocaleTimeString(); |
| const message = document.createElement("span"); |
| message.textContent = event.message; |
| item.append(timestamp, message); |
| list.prepend(item); |
| } |
| } |
| } catch (_) { } |
| } |
|
|
| $("startPreview").addEventListener("click", startPreview); |
| $("stopHold").addEventListener("click", () => stopPreview("hold")); |
| $("stopRecenter").addEventListener("click", () => stopPreview("recenter")); |
| $("recordingButton").addEventListener("click", toggleRecording); |
| $("exportReport").addEventListener("click", exportReport); |
| $("refreshStatus").addEventListener("click", refreshStatus); |
| connectPreviewSocket(); |
| refreshStatus(); |
| pollEvents(); |
| window.setInterval(refreshStatus, 2000); |
| window.setInterval(pollEvents, 1000); |
|
|