yuzou1121's picture
Publish com.orulink.vision_debug_lab 1.0.0
44628ce verified
Raw
History Blame Contribute Delete
10.2 kB
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 (_) { /* status polling reports connection errors */ }
}
$("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);