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();
|
||||
Reference in New Issue
Block a user