feat: add video detection and robot alerts
This commit is contained in:
243
frontend/app.js
Normal file
243
frontend/app.js
Normal 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
90
frontend/index.html
Normal 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
75
frontend/styles.css
Normal 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%; }
|
||||
}
|
||||
Reference in New Issue
Block a user