feat: add video detection and robot alerts

This commit is contained in:
2026-08-12 17:15:11 +08:00
parent 67d5b40736
commit bb395e3d9a
22 changed files with 1623 additions and 319 deletions

243
frontend/app.js Normal file
View File

@@ -0,0 +1,243 @@
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();