feat: SQLite persistence, non-blocking inference, modular frontend
Backend:
- Persist alert events and robot delivery counters in SQLite (data/app.db,
YOLO_DB_PATH override); EventStore keeps its interface, DeliveryStatsStore
uses read/write-through UPSERT; in-memory fallback remains for standalone
AlertManager use
- Serve /api/detect from a sync endpoint and serialize YOLO inference with a
lock, so concurrent requests no longer block the event loop
- Unify single-image and session detection: requests without session_id
share the fixed single-image session and run full frame confirmation
- Store UTC Z-suffixed timestamps for sortable string comparison; close
executor and database on shutdown via FastAPI lifespan
Frontend:
- Split app.js into ES modules (main.js + modules/{dom,api,ui,charts,store}
+ views/{dashboard,inspection,events,robots,robotCards}), no build step
- Escape all server data interpolated into innerHTML; guard missing
event.classes; replace lazy element ID list with memoized qs()
- Remove hardcoded fake stats (trend badge, device donut segment)
Docs:
- Rewrite README: accurate weight policy (only trained best.pt committed,
no Git LFS), SQLite persistence, single-worker note, data asset inventory
- Align models/pretrained/README.md with actual files; add YOLO_DB_PATH to
.env.example; add persistence unit tests; ruff clean
This commit is contained in:
196
frontend/views/inspection.js
Normal file
196
frontend/views/inspection.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import { qs, classLabel, formatTime, enabledChannelNames } from "../modules/dom.js";
|
||||
import { apiFetch } from "../modules/api.js";
|
||||
import { showToast, setServiceStatus } from "../modules/ui.js";
|
||||
import { loadDashboard } from "./dashboard.js";
|
||||
|
||||
const API_ENDPOINT = "/api/detect";
|
||||
const frameCanvas = document.createElement("canvas");
|
||||
let videoUrl = null;
|
||||
let sessionId = null;
|
||||
let detectionActive = false;
|
||||
let requestInFlight = false;
|
||||
let lastDetectionAt = 0;
|
||||
|
||||
function setInspectionStatus(text, className) {
|
||||
const status = qs("#inspectionStatus");
|
||||
status.textContent = text;
|
||||
status.className = className;
|
||||
}
|
||||
|
||||
function clearDetectionResults() {
|
||||
qs("#smokeCount").textContent = "0";
|
||||
qs("#fireCount").textContent = "0";
|
||||
qs("#maxConfidence").textContent = "--";
|
||||
qs("#riskStatus").textContent = "待机";
|
||||
qs("#detectionList").innerHTML = '<p class="empty-copy">暂无检测结果</p>';
|
||||
const context = qs("#overlayCanvas").getContext("2d");
|
||||
context.clearRect(0, 0, qs("#overlayCanvas").width, qs("#overlayCanvas").height);
|
||||
}
|
||||
|
||||
function drawDetections(detections = []) {
|
||||
const video = qs("#videoPreview");
|
||||
if (!video.videoWidth || !video.videoHeight) return;
|
||||
const canvas = qs("#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" ? "#ff665e" : "#41d6c3";
|
||||
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 updateDetectionResults(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);
|
||||
qs("#smokeCount").textContent = String(smoke);
|
||||
qs("#fireCount").textContent = String(fire);
|
||||
qs("#maxConfidence").textContent = max ? `${Math.round(max * 100)}%` : "--";
|
||||
qs("#riskStatus").textContent = fire ? "高风险" : smoke ? "需关注" : "正常";
|
||||
qs("#detectionList").innerHTML = detections.length
|
||||
? detections.map((item) => `<div class="detection-row"><span>${classLabel(item.class)}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
|
||||
: '<p class="empty-copy">未发现烟雾或火焰目标</p>';
|
||||
const alert = result.alert || {};
|
||||
const channelNames = enabledChannelNames(alert.notification_channels);
|
||||
if (alert.triggered) {
|
||||
const labels = alert.classes.map(classLabel).join("、");
|
||||
const alertStatus = qs("#alertStatus");
|
||||
alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`;
|
||||
alertStatus.className = "alert-status alert-triggered";
|
||||
showToast(`检测到${labels},已生成告警事件`, "error");
|
||||
loadDashboard();
|
||||
} else {
|
||||
const fireFrames = alert.consecutive?.fire || 0;
|
||||
const smokeFrames = alert.consecutive?.smoke || 0;
|
||||
const alertStatus = qs("#alertStatus");
|
||||
alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`;
|
||||
alertStatus.className = "alert-status";
|
||||
}
|
||||
drawDetections(detections);
|
||||
qs("#lastUpdated").textContent = `推理 ${result.inference_ms ?? "--"} ms`;
|
||||
}
|
||||
|
||||
function captureFrame() {
|
||||
const video = qs("#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) {
|
||||
const video = qs("#videoPreview");
|
||||
if (!detectionActive || requestInFlight || !sessionId || video.paused || video.ended) return;
|
||||
const interval = Number(qs("#intervalSelect").value);
|
||||
if (timestamp - lastDetectionAt < interval) return;
|
||||
lastDetectionAt = timestamp;
|
||||
requestInFlight = true;
|
||||
qs("#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 result = await apiFetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form });
|
||||
updateDetectionResults(result);
|
||||
setInspectionStatus("检测运行中", "status-chip status-online");
|
||||
setServiceStatus(true);
|
||||
} catch (error) {
|
||||
setInspectionStatus("检测异常", "status-chip status-offline");
|
||||
showToast(`视频检测失败:${error.message}`, "error");
|
||||
} finally {
|
||||
requestInFlight = false;
|
||||
qs("#loadingState").hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDetection(timestamp) {
|
||||
detectCurrentFrame(timestamp);
|
||||
if (detectionActive) requestAnimationFrame(scheduleDetection);
|
||||
}
|
||||
|
||||
async function startDetection() {
|
||||
const video = qs("#videoPreview");
|
||||
if (!video.src) return;
|
||||
detectionActive = true;
|
||||
lastDetectionAt = -Infinity;
|
||||
qs("#runDetectionButton").textContent = "停止检测";
|
||||
qs("#intervalSelect").disabled = true;
|
||||
try {
|
||||
await video.play();
|
||||
requestAnimationFrame(scheduleDetection);
|
||||
} catch {
|
||||
stopDetection();
|
||||
showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function stopDetection() {
|
||||
detectionActive = false;
|
||||
qs("#runDetectionButton").textContent = "开始连续检测";
|
||||
qs("#intervalSelect").disabled = false;
|
||||
const video = qs("#videoPreview");
|
||||
setInspectionStatus(video.src ? "检测已暂停" : "等待视频", "status-chip status-idle");
|
||||
}
|
||||
|
||||
export async function resetSession() {
|
||||
const previous = sessionId;
|
||||
sessionId = crypto.randomUUID();
|
||||
if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {});
|
||||
}
|
||||
|
||||
export function initInspection() {
|
||||
const video = qs("#videoPreview");
|
||||
qs("#runDetectionButton").addEventListener("click", () => detectionActive ? stopDetection() : startDetection());
|
||||
video.addEventListener("ended", stopDetection);
|
||||
video.addEventListener("timeupdate", () => {
|
||||
qs("#videoClock").textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
|
||||
});
|
||||
video.addEventListener("seeked", async () => {
|
||||
drawDetections([]);
|
||||
await resetSession();
|
||||
});
|
||||
video.addEventListener("resize", () => drawDetections([]));
|
||||
qs("#videoInput").addEventListener("change", async () => {
|
||||
const [file] = qs("#videoInput").files;
|
||||
if (!file) return;
|
||||
stopDetection();
|
||||
video.pause();
|
||||
if (videoUrl) URL.revokeObjectURL(videoUrl);
|
||||
videoUrl = URL.createObjectURL(file);
|
||||
video.src = videoUrl;
|
||||
video.hidden = false;
|
||||
qs("#emptyState").hidden = true;
|
||||
qs("#sourceLabel").textContent = file.name;
|
||||
qs("#videoMeta").textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`;
|
||||
qs("#runDetectionButton").disabled = false;
|
||||
setInspectionStatus("视频已就绪", "status-chip status-online");
|
||||
await resetSession();
|
||||
clearDetectionResults();
|
||||
});
|
||||
qs("#clearButton").addEventListener("click", async () => {
|
||||
stopDetection();
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.hidden = true;
|
||||
if (videoUrl) URL.revokeObjectURL(videoUrl);
|
||||
videoUrl = null;
|
||||
qs("#videoInput").value = "";
|
||||
qs("#sourceLabel").textContent = "未选择视频";
|
||||
qs("#videoMeta").textContent = "支持 MP4、WebM 等浏览器可播放格式";
|
||||
qs("#emptyState").hidden = false;
|
||||
qs("#runDetectionButton").disabled = true;
|
||||
await resetSession();
|
||||
clearDetectionResults();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user