Files
yolo/frontend/app.js

244 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const API_ENDPOINT = "/api/detect";
const elements = {
alertStatus: document.querySelector("#alertStatus"),
clearButton: document.querySelector("#clearButton"),
connectionStatus: document.querySelector("#connectionStatus"),
detectionList: document.querySelector("#detectionList"),
emptyState: document.querySelector("#emptyState"),
fireCount: document.querySelector("#fireCount"),
intervalSelect: document.querySelector("#intervalSelect"),
lastUpdated: document.querySelector("#lastUpdated"),
loadingState: document.querySelector("#loadingState"),
maxConfidence: document.querySelector("#maxConfidence"),
overlayCanvas: document.querySelector("#overlayCanvas"),
riskStatus: document.querySelector("#riskStatus"),
runDetectionButton: document.querySelector("#runDetectionButton"),
smokeCount: document.querySelector("#smokeCount"),
sourceLabel: document.querySelector("#sourceLabel"),
videoInput: document.querySelector("#videoInput"),
videoPreview: document.querySelector("#videoPreview"),
};
const frameCanvas = document.createElement("canvas");
let videoUrl = null;
let sessionId = null;
let detectionActive = false;
let requestInFlight = false;
let lastDetectionAt = 0;
function setConnectionStatus(label, state = "idle") {
elements.connectionStatus.textContent = label;
elements.connectionStatus.className = `status-pill status-${state}`;
}
function enabledChannelNames(channels = {}) {
const names = [];
if (channels.wechat) names.push("企业微信");
if (channels.feishu) names.push("飞书");
return names;
}
function clearResults() {
elements.smokeCount.textContent = "0";
elements.fireCount.textContent = "0";
elements.maxConfidence.textContent = "--";
elements.riskStatus.textContent = "待机";
elements.detectionList.innerHTML = '<p class="muted">暂无检测结果</p>';
const context = elements.overlayCanvas.getContext("2d");
context.clearRect(0, 0, elements.overlayCanvas.width, elements.overlayCanvas.height);
}
function drawDetections(detections = []) {
const video = elements.videoPreview;
if (!video.videoWidth || !video.videoHeight) return;
const canvas = elements.overlayCanvas;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
detections.forEach((detection) => {
const [x1, y1, x2, y2] = detection.box || [];
const color = detection.class === "fire" ? "#ff786b" : "#55d5c2";
context.strokeStyle = color;
context.lineWidth = Math.max(2, canvas.width / 320);
context.strokeRect(x1, y1, x2 - x1, y2 - y1);
context.fillStyle = color;
context.font = `${Math.max(13, canvas.width / 60)}px sans-serif`;
context.fillText(`${detection.class} ${Math.round(detection.confidence * 100)}%`, x1 + 4, Math.max(18, y1 - 6));
});
}
function updateResults(result) {
const detections = result.detections || [];
const smoke = detections.filter((item) => item.class === "smoke").length;
const fire = detections.filter((item) => item.class === "fire").length;
const max = detections.reduce((highest, item) => Math.max(highest, item.confidence || 0), 0);
elements.smokeCount.textContent = String(smoke);
elements.fireCount.textContent = String(fire);
elements.maxConfidence.textContent = max ? `${Math.round(max * 100)}%` : "--";
elements.riskStatus.textContent = fire ? "火焰告警" : smoke ? "烟雾告警" : "正常";
elements.detectionList.innerHTML = detections.length
? detections.map((item) => `<div class="detection-row"><span>${item.class === "fire" ? "火焰" : "烟雾"}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
: '<p class="muted">未发现目标</p>';
const alert = result.alert || {};
if (alert.triggered) {
const labels = alert.classes.map((name) => name === "fire" ? "火焰" : "烟雾").join("、");
const channelNames = enabledChannelNames(alert.notification_channels);
elements.alertStatus.textContent = channelNames.length
? `已触发 ${channelNames.join(" + ")} 告警:${labels}`
: `已满足告警条件:${labels}(未配置机器人 Webhook`;
elements.alertStatus.className = "alert-status alert-triggered";
} else {
const fireFrames = alert.consecutive?.fire || 0;
const smokeFrames = alert.consecutive?.smoke || 0;
const channelNames = enabledChannelNames(alert.notification_channels);
elements.alertStatus.textContent = channelNames.length
? `${channelNames.join(" + ")} 告警已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`
: "机器人告警未配置,检测功能正常";
elements.alertStatus.className = "alert-status";
}
drawDetections(detections);
elements.lastUpdated.textContent = `视频 ${formatTime(elements.videoPreview.currentTime)} · 推理 ${result.inference_ms ?? "--"} ms`;
}
function formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remaining = Math.floor(seconds % 60);
return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`;
}
function captureFrame() {
const video = elements.videoPreview;
frameCanvas.width = video.videoWidth;
frameCanvas.height = video.videoHeight;
frameCanvas.getContext("2d").drawImage(video, 0, 0);
return new Promise((resolve) => frameCanvas.toBlob(resolve, "image/jpeg", 0.88));
}
async function detectCurrentFrame(timestamp) {
if (
!detectionActive
|| requestInFlight
|| !sessionId
|| elements.videoPreview.paused
|| elements.videoPreview.ended
) return;
const interval = Number(elements.intervalSelect.value);
if (timestamp - lastDetectionAt < interval) return;
lastDetectionAt = timestamp;
requestInFlight = true;
elements.loadingState.hidden = false;
try {
const frame = await captureFrame();
if (!frame) throw new Error("无法截取视频帧");
const form = new FormData();
form.append("file", frame, "video-frame.jpg");
const response = await fetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form });
if (!response.ok) throw new Error(`API ${response.status}`);
updateResults(await response.json());
setConnectionStatus("检测服务已连接", "ready");
} catch (error) {
setConnectionStatus("检测服务异常", "alert");
elements.detectionList.innerHTML = `<p class="muted">${error.message}</p>`;
} finally {
requestInFlight = false;
elements.loadingState.hidden = true;
}
}
function scheduleDetection(timestamp) {
detectCurrentFrame(timestamp);
if (detectionActive) requestAnimationFrame(scheduleDetection);
}
async function startDetection() {
if (!elements.videoPreview.src) return;
detectionActive = true;
lastDetectionAt = -Infinity;
elements.runDetectionButton.textContent = "停止检测";
elements.intervalSelect.disabled = true;
try {
await elements.videoPreview.play();
} catch {
setConnectionStatus("请允许视频播放", "alert");
}
requestAnimationFrame(scheduleDetection);
}
function stopDetection() {
detectionActive = false;
elements.runDetectionButton.textContent = "开始连续检测";
elements.intervalSelect.disabled = false;
}
async function resetSession() {
const previousSession = sessionId;
sessionId = crypto.randomUUID();
if (previousSession) {
fetch(`/api/sessions/${encodeURIComponent(previousSession)}`, { method: "DELETE" }).catch(() => {});
}
}
async function checkHealth() {
try {
const response = await fetch("/api/health");
if (!response.ok) throw new Error();
const health = await response.json();
setConnectionStatus("检测服务已连接", "ready");
const channelNames = enabledChannelNames(health.alert_channels);
elements.alertStatus.textContent = channelNames.length
? `${channelNames.join(" + ")} 告警已启用`
: "机器人告警未配置,检测功能正常";
} catch {
setConnectionStatus("检测服务未连接", "alert");
elements.alertStatus.textContent = "无法读取机器人告警状态";
}
}
elements.videoInput.addEventListener("change", async () => {
const [file] = elements.videoInput.files;
if (!file) return;
stopDetection();
elements.videoPreview.pause();
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = URL.createObjectURL(file);
elements.videoPreview.src = videoUrl;
elements.videoPreview.hidden = false;
elements.emptyState.hidden = true;
elements.sourceLabel.textContent = file.name;
elements.runDetectionButton.disabled = false;
await resetSession();
clearResults();
});
elements.runDetectionButton.addEventListener("click", () => {
if (detectionActive) stopDetection(); else startDetection();
});
elements.videoPreview.addEventListener("ended", stopDetection);
elements.videoPreview.addEventListener("seeked", async () => {
drawDetections([]);
await resetSession();
});
elements.videoPreview.addEventListener("resize", () => drawDetections([]));
elements.clearButton.addEventListener("click", async () => {
stopDetection();
elements.videoPreview.pause();
elements.videoPreview.removeAttribute("src");
elements.videoPreview.load();
elements.videoPreview.hidden = true;
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = null;
elements.videoInput.value = "";
elements.sourceLabel.textContent = "等待选择视频";
elements.emptyState.hidden = false;
elements.runDetectionButton.disabled = true;
await resetSession();
clearResults();
});
resetSession();
checkHealth();