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();

90
frontend/index.html Normal file
View File

@@ -0,0 +1,90 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>烟火视频检测</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">YOLO11S / VIDEO DETECTION</p>
<h1>烟火视频检测</h1>
</div>
<span id="connectionStatus" class="status-pill status-idle">正在检查服务</span>
</header>
<section class="workspace">
<div class="stage-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">VIDEO VIEW</p>
<h2>检测画面</h2>
</div>
<span id="sourceLabel" class="muted">等待选择视频</span>
</div>
<div class="media-stage">
<video id="videoPreview" controls muted playsinline hidden></video>
<div id="emptyState" class="empty-state">
<div class="empty-icon">+</div>
<strong>选择本地视频开始检测</strong>
<span>视频仅在浏览器本地播放,发送的是抽取帧</span>
</div>
<canvas id="overlayCanvas" aria-hidden="true"></canvas>
<div id="loadingState" class="loading-state" hidden>正在分析视频帧...</div>
</div>
<div class="stage-footer">
<span id="lastUpdated">尚未检测</span>
<button id="runDetectionButton" class="button button-primary" type="button" disabled>开始连续检测</button>
</div>
</div>
<aside class="control-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">INPUT</p>
<h2>视频源</h2>
</div>
</div>
<div class="control-stack">
<label class="upload-control">
<span class="button button-secondary">选择视频</span>
<input id="videoInput" type="file" accept="video/*" />
<small>支持浏览器可播放的 MP4、WebM 等格式</small>
</label>
<label class="setting-row">
<span>检测间隔</span>
<select id="intervalSelect">
<option value="500">0.5 秒</option>
<option value="1000" selected>1 秒</option>
<option value="2000">2 秒</option>
</select>
</label>
<button id="clearButton" class="button button-quiet" type="button">清除视频</button>
</div>
<div class="divider"></div>
<div class="panel-heading compact-heading">
<div>
<p class="eyebrow">RESULTS</p>
<h2>检测摘要</h2>
</div>
</div>
<div class="metrics-grid">
<div class="metric-card"><span>烟雾</span><strong id="smokeCount">0</strong></div>
<div class="metric-card"><span>火焰</span><strong id="fireCount">0</strong></div>
<div class="metric-card"><span>最高置信度</span><strong id="maxConfidence">--</strong></div>
<div class="metric-card"><span>状态</span><strong id="riskStatus">待机</strong></div>
</div>
<div id="alertStatus" class="alert-status">机器人告警状态:检查中</div>
<div id="detectionList" class="detection-list">
<p class="muted">暂无检测结果</p>
</div>
</aside>
</section>
</main>
<script src="./app.js" type="module"></script>
</body>
</html>

75
frontend/styles.css Normal file
View File

@@ -0,0 +1,75 @@
:root {
color-scheme: dark;
--bg: #101417;
--surface: #171d21;
--surface-raised: #1e272c;
--line: #2c373d;
--text: #edf3f2;
--muted: #93a2a5;
--cyan: #55d5c2;
--cyan-deep: #183d3b;
--orange: #ffb454;
--red: #ff786b;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { width: min(1380px, calc(100% - 40px)); margin: 0 auto; padding: 28px 0 40px; }
.topbar, .panel-heading, .stage-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.topbar { border-bottom: 1px solid var(--line); padding-bottom: 24px; }
.eyebrow { margin: 0 0 7px; color: var(--cyan); font-size: 11px; font-weight: 800; letter-spacing: 0.12em; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(24px, 4vw, 38px); letter-spacing: 0; }
h2 { margin-bottom: 0; font-size: 17px; }
.status-pill { border: 1px solid var(--line); border-radius: 999px; padding: 8px 12px; color: var(--muted); font-size: 12px; white-space: nowrap; }
.status-ready { border-color: #286f67; background: var(--cyan-deep); color: var(--cyan); }
.status-alert { border-color: #81473f; background: #392321; color: var(--red); }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; margin-top: 22px; }
.stage-panel, .control-panel { border: 1px solid var(--line); background: var(--surface); }
.stage-panel { min-width: 0; padding: 20px; }
.control-panel { padding: 20px; }
.muted { color: var(--muted); font-size: 13px; }
.media-stage { position: relative; display: grid; place-items: center; min-height: min(66vh, 680px); margin: 20px 0 16px; overflow: hidden; background: #0a0d0e; border: 1px solid var(--line); }
.media-stage img, .media-stage video { display: block; width: 100%; height: 100%; max-height: min(66vh, 680px); object-fit: contain; }
.media-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.empty-state { display: grid; justify-items: center; gap: 8px; color: var(--muted); text-align: center; }
.empty-icon { display: grid; place-items: center; width: 44px; height: 44px; border: 1px solid var(--line); border-radius: 50%; color: var(--cyan); font-size: 26px; }
.loading-state { position: absolute; inset: auto 16px 16px auto; padding: 10px 12px; background: #0e1718e8; border: 1px solid #286f67; color: var(--cyan); font-size: 12px; }
.stage-footer { color: var(--muted); font-size: 12px; }
.control-stack { display: grid; gap: 10px; margin-top: 22px; }
.button { display: inline-flex; min-height: 42px; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: 6px; padding: 0 15px; font-weight: 700; }
.button:disabled { cursor: not-allowed; opacity: 0.45; }
.button-primary { background: var(--cyan); color: #0b1918; }
.button-secondary { border-color: #3b5658; background: var(--surface-raised); color: var(--text); }
.button-secondary:hover, .button-quiet:hover { border-color: var(--cyan); color: var(--cyan); }
.button-quiet { border-color: var(--line); background: transparent; color: var(--muted); }
.upload-control { display: grid; gap: 8px; }
.upload-control input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.upload-control small { color: var(--muted); font-size: 11px; }
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--muted); font-size: 13px; }
.setting-row select { min-height: 38px; border: 1px solid var(--line); border-radius: 6px; padding: 0 10px; background: var(--surface-raised); color: var(--text); }
.alert-status { margin-top: 14px; border: 1px solid var(--line); padding: 10px 12px; color: var(--muted); font-size: 12px; line-height: 1.5; }
.alert-triggered { border-color: #81473f; background: #392321; color: var(--red); }
.divider { height: 1px; margin: 24px 0; background: var(--line); }
.compact-heading { margin-bottom: 14px; }
.metrics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.metric-card { display: grid; gap: 8px; min-height: 78px; padding: 12px; border: 1px solid var(--line); background: var(--surface-raised); }
.metric-card span { color: var(--muted); font-size: 12px; }
.metric-card strong { font-size: 20px; }
.detection-list { display: grid; gap: 8px; margin-top: 14px; }
.detection-row { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
.detection-row strong { color: var(--orange); }
@media (max-width: 860px) {
.app-shell { width: min(100% - 24px, 680px); padding-top: 18px; }
.workspace { grid-template-columns: 1fr; }
.media-stage { min-height: 48vh; }
}
@media (max-width: 480px) {
.topbar { align-items: flex-start; flex-direction: column; }
.stage-panel, .control-panel { padding: 14px; }
.stage-footer { align-items: flex-end; flex-direction: column; }
.stage-footer .button { width: 100%; }
}