YOLO11S / VIDEO DETECTION
-烟火视频检测
-VIDEO VIEW
-diff --git a/README.md b/README.md index c565f3e..e7c4ce1 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ uv sync uv run uvicorn backend.main:app --host 127.0.0.1 --port 8000 ``` -Open `http://127.0.0.1:8000`, select a local video, and start continuous detection. The browser plays the video locally and sends sequential JPEG frames to `POST /api/detect`; requests do not overlap. +Open `http://127.0.0.1:8000` to use the fire prevention management platform. It provides a system overview, local-video inspection, alert event handling, a risk register, robot channel status, and effective model settings. The browser plays selected videos locally and sends sequential JPEG frames to `POST /api/detect`; requests do not overlap. + +Management endpoints include `GET /api/dashboard`, `GET /api/events`, and `PATCH /api/events/{event_id}`. Alert events can be marked as pending, acknowledged, or resolved. The current implementation retains the most recent 500 events in process memory, so events are cleared when the API service restarts. The local `.env` file contains optional robot settings. Set `WECHAT_WEBHOOK_URL` for an Enterprise WeChat group robot, `FEISHU_WEBHOOK_URL` for a Feishu custom group robot, or both. If Feishu signature verification is enabled, also set `FEISHU_SECRET`. Alerts require three consecutive positive frames by default and use separate 60-second cooldowns for fire and smoke. `ALERT_CONFIRM_FRAMES` and `ALERT_COOLDOWN_SECONDS` override these settings. Without a webhook, video detection still works and the UI reports that notifications are disabled. diff --git a/backend/events.py b/backend/events.py new file mode 100644 index 0000000..b1df80b --- /dev/null +++ b/backend/events.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import threading +import uuid +from collections import deque +from datetime import datetime +from typing import Any + +EVENT_STATUSES = {"pending", "acknowledged", "resolved"} + + +class EventStore: + def __init__(self, max_events: int = 500) -> None: + self._events: deque[dict[str, Any]] = deque(maxlen=max_events) + self._lock = threading.Lock() + + def create( + self, + session_id: str, + classes: list[str], + detections: list[dict[str, Any]], + notification_channels: dict[str, bool], + ) -> dict[str, Any]: + now = datetime.now().astimezone() + confidences = [ + float(detection.get("confidence", 0.0)) + for detection in detections + if detection.get("class") in classes + ] + event = { + "id": uuid.uuid4().hex[:12], + "created_at": now.isoformat(timespec="seconds"), + "session_id": session_id, + "classes": classes, + "max_confidence": round(max(confidences, default=0.0), 6), + "detection_count": len(detections), + "status": "pending", + "notification_channels": notification_channels, + "handled_at": None, + } + with self._lock: + self._events.appendleft(event) + return dict(event) + + def list( + self, + limit: int = 100, + status: str | None = None, + ) -> list[dict[str, Any]]: + with self._lock: + events = [dict(event) for event in self._events] + if status: + events = [event for event in events if event["status"] == status] + return events[: max(1, min(limit, 500))] + + def update(self, event_id: str, status: str) -> dict[str, Any] | None: + if status not in EVENT_STATUSES: + raise ValueError(f"Unsupported event status: {status}") + with self._lock: + for event in self._events: + if event["id"] != event_id: + continue + event["status"] = status + event["handled_at"] = ( + None + if status == "pending" + else datetime.now().astimezone().isoformat( + timespec="seconds" + ) + ) + return dict(event) + return None + + def summary(self) -> dict[str, int]: + today = datetime.now().astimezone().date() + with self._lock: + events = [dict(event) for event in self._events] + today_events = [ + event + for event in events + if datetime.fromisoformat(event["created_at"]).date() == today + ] + return { + "total": len(events), + "today": len(today_events), + "pending": sum( + event["status"] == "pending" for event in events + ), + "acknowledged": sum( + event["status"] == "acknowledged" for event in events + ), + "resolved": sum( + event["status"] == "resolved" for event in events + ), + "fire": sum("fire" in event["classes"] for event in events), + "smoke": sum("smoke" in event["classes"] for event in events), + } diff --git a/backend/main.py b/backend/main.py index ea95a15..385fe39 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,9 +12,11 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from PIL import Image, UnidentifiedImageError from dotenv import load_dotenv +from pydantic import BaseModel from ultralytics import YOLO from .alerting import AlertManager +from .events import EVENT_STATUSES, EventStore PROJECT_ROOT = Path(__file__).resolve().parents[1] FRONTEND_DIR = PROJECT_ROOT / "frontend" @@ -32,6 +34,11 @@ ALERT_MANAGER = AlertManager( confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")), cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")), ) +EVENT_STORE = EventStore() + + +class EventStatusUpdate(BaseModel): + status: str app = FastAPI(title="Smoke Fire Detector API", version="0.1.0") app.add_middleware( @@ -132,6 +139,16 @@ async def detect( "notification_channels": ALERT_MANAGER.channels, } ) + if result["alert"]["triggered"]: + result["event"] = EVENT_STORE.create( + session_id=session_id or "single-image", + classes=result["alert"]["classes"], + detections=result["detections"], + notification_channels=result["alert"].get( + "notification_channels", + ALERT_MANAGER.channels, + ), + ) return result @@ -141,6 +158,49 @@ def reset_detection_session(session_id: str) -> dict[str, str]: return {"status": "reset"} +@app.get("/api/events") +def list_events( + limit: int = 100, + status: str | None = None, +) -> dict[str, Any]: + if status and status not in EVENT_STATUSES: + raise HTTPException(status_code=400, detail="Invalid event status") + return { + "events": EVENT_STORE.list(limit=limit, status=status), + "summary": EVENT_STORE.summary(), + } + + +@app.patch("/api/events/{event_id}") +def update_event( + event_id: str, + update: EventStatusUpdate, +) -> dict[str, Any]: + try: + event = EVENT_STORE.update(event_id, update.status) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + return event + + +@app.get("/api/dashboard") +def dashboard() -> dict[str, Any]: + return { + "summary": EVENT_STORE.summary(), + "recent_events": EVENT_STORE.list(limit=6), + "system": health(), + "detection": { + "confidence": CONFIDENCE, + "iou": IOU, + "image_size": IMAGE_SIZE, + "confirm_frames": ALERT_MANAGER.confirm_frames, + "cooldown_seconds": ALERT_MANAGER.cooldown_seconds, + }, + } + + @app.get("/") def frontend() -> FileResponse: return FileResponse(FRONTEND_DIR / "index.html") diff --git a/frontend/app.js b/frontend/app.js index 2483f28..4e802d2 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,24 +1,38 @@ 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 VIEW_LABELS = { + overview: "系统总览", + inspection: "视频巡检", + events: "告警中心", + risks: "风险台账", + channels: "通知通道", + settings: "系统设置", }; +const STATUS_LABELS = { + pending: "待处置", + acknowledged: "已确认", + resolved: "已解决", +}; + +const elements = Object.fromEntries( + [ + "ackEventsCount", "alertStatus", "allEventsCount", "channelCount", + "channelSummary", "clearButton", "confirmFramesValue", "cooldownValue", + "currentClock", "detectionList", "emptyState", "eventTableBody", + "eventTableMeta", "feishuChannelBadge", "fireCount", "globalStatus", + "inspectionStatus", "intervalSelect", "lastUpdated", "loadingState", + "maxConfidence", "menuButton", "modelStatusDetail", "modelStatusValue", + "notificationDot", "overlayCanvas", "overviewMessage", "pendingEventCount", + "pendingEventsCount", "pendingNavCount", "readyApi", "readyFeishu", + "readyWechat", "readyWeights", "recentEventList", "refreshEventsButton", + "resolvedEventsCount", "riskChannelValue", "riskConfidenceValue", + "riskPendingValue", "riskStatus", "runDetectionButton", "settingConfidence", + "settingConfirmFrames", "settingCooldown", "settingImageSize", "settingIou", + "settingWeights", "sidebar", "sidebarStatus", "sidebarStatusDot", "smokeCount", + "sourceLabel", "todayEventCount", "toastRegion", "trendChart", "videoClock", + "videoInput", "videoMeta", "videoPreview", "viewBreadcrumb", "viewTitle", + "wechatChannelBadge", + ].map((id) => [id, document.querySelector(`#${id}`)]) +); const frameCanvas = document.createElement("canvas"); let videoUrl = null; @@ -26,10 +40,40 @@ let sessionId = null; let detectionActive = false; let requestInFlight = false; let lastDetectionAt = 0; +let dashboardData = null; +let eventData = []; +let eventFilter = ""; -function setConnectionStatus(label, state = "idle") { - elements.connectionStatus.textContent = label; - elements.connectionStatus.className = `status-pill status-${state}`; +function showToast(message, type = "info") { + const toast = document.createElement("div"); + toast.className = `toast ${type}`; + toast.textContent = message; + elements.toastRegion.appendChild(toast); + setTimeout(() => toast.remove(), 3600); +} + +function switchView(viewName) { + if (!VIEW_LABELS[viewName]) return; + document.querySelectorAll("[data-view-panel]").forEach((panel) => { + panel.classList.toggle("is-active", panel.dataset.viewPanel === viewName); + }); + document.querySelectorAll("[data-view]").forEach((button) => { + button.classList.toggle("is-active", button.dataset.view === viewName); + }); + elements.viewTitle.textContent = VIEW_LABELS[viewName]; + elements.viewBreadcrumb.textContent = VIEW_LABELS[viewName]; + elements.sidebar.classList.remove("is-open"); + if (viewName === "events") loadEvents(); + if (["overview", "risks", "channels", "settings"].includes(viewName)) loadDashboard(); +} + +function setServiceStatus(online) { + elements.globalStatus.textContent = online ? "服务运行正常" : "服务连接异常"; + elements.globalStatus.className = `status-chip ${online ? "status-online" : "status-offline"}`; + elements.sidebarStatus.textContent = online ? "服务在线" : "服务离线"; + elements.sidebarStatusDot.className = `status-dot ${online ? "is-online" : "is-offline"}`; + elements.readyApi.textContent = online ? "正常" : "异常"; + elements.readyApi.className = online ? "ready" : "not-ready"; } function enabledChannelNames(channels = {}) { @@ -39,12 +83,189 @@ function enabledChannelNames(channels = {}) { return names; } -function clearResults() { +function updateClock() { + elements.currentClock.textContent = new Date().toLocaleString("zh-CN", { + hour12: false, + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function formatDate(value) { + if (!value) return "--"; + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} + +function formatTime(seconds) { + if (!Number.isFinite(seconds)) return "00:00"; + const minutes = Math.floor(seconds / 60); + const remaining = Math.floor(seconds % 60); + return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`; +} + +function updateDashboard(data) { + dashboardData = data; + const { summary, system, detection } = data; + const channels = system.alert_channels || {}; + const channelNames = enabledChannelNames(channels); + elements.todayEventCount.textContent = String(summary.today); + elements.pendingEventCount.textContent = String(summary.pending); + elements.pendingNavCount.textContent = String(summary.pending); + elements.notificationDot.hidden = summary.pending === 0; + elements.modelStatusValue.textContent = system.weights_available ? "运行正常" : "权重缺失"; + elements.modelStatusDetail.textContent = system.weights_available ? "模型文件已就绪" : "请检查 YOLO_WEIGHTS"; + elements.modelStatusValue.className = "text-value"; + elements.channelCount.textContent = String(channelNames.length); + elements.channelSummary.textContent = channelNames.length ? channelNames.join(" + ") : "尚未启用机器人"; + elements.overviewMessage.textContent = summary.pending + ? `当前有 ${summary.pending} 条告警事件等待处置,请尽快进入告警中心确认。` + : "当前无待处置事件,模型与视频巡检服务保持监测状态。"; + elements.readyWeights.textContent = system.weights_available ? "已就绪" : "缺失"; + elements.readyWeights.className = system.weights_available ? "ready" : "not-ready"; + setChannelReady(elements.readyWechat, channels.wechat); + setChannelReady(elements.readyFeishu, channels.feishu); + setChannelBadge(elements.wechatChannelBadge, channels.wechat); + setChannelBadge(elements.feishuChannelBadge, channels.feishu); + elements.riskPendingValue.textContent = String(summary.pending); + elements.riskChannelValue.textContent = `${channelNames.length} / 2`; + elements.riskConfidenceValue.textContent = `${Math.round(detection.confidence * 100)}%`; + elements.confirmFramesValue.textContent = `${detection.confirm_frames} 帧`; + elements.cooldownValue.textContent = `${detection.cooldown_seconds} 秒`; + elements.settingWeights.textContent = system.weights; + elements.settingImageSize.textContent = `${detection.image_size} px`; + elements.settingConfidence.textContent = detection.confidence.toFixed(2); + elements.settingIou.textContent = detection.iou.toFixed(2); + elements.settingConfirmFrames.textContent = `${detection.confirm_frames} 帧`; + elements.settingCooldown.textContent = `${detection.cooldown_seconds} 秒`; + renderRecentEvents(data.recent_events || []); + renderTrend(eventData.length ? eventData : data.recent_events || []); +} + +function setChannelReady(element, enabled) { + element.textContent = enabled ? "已启用" : "未配置"; + element.className = enabled ? "ready" : "optional"; +} + +function setChannelBadge(element, enabled) { + element.textContent = enabled ? "已启用" : "未配置"; + element.className = `channel-badge ${enabled ? "enabled" : ""}`; +} + +async function loadDashboard() { + try { + const response = await fetch("/api/dashboard"); + if (!response.ok) throw new Error(`API ${response.status}`); + const data = await response.json(); + setServiceStatus(true); + updateDashboard(data); + } catch (error) { + setServiceStatus(false); + showToast(`无法读取管理数据:${error.message}`, "error"); + } +} + +async function loadEvents() { + try { + const query = eventFilter ? `?status=${eventFilter}` : ""; + const response = await fetch(`/api/events${query}`); + if (!response.ok) throw new Error(`API ${response.status}`); + const result = await response.json(); + eventData = result.events || []; + renderEventTable(eventData); + updateEventSummary(result.summary); + renderTrend(eventData); + setServiceStatus(true); + } catch (error) { + setServiceStatus(false); + showToast(`无法读取告警事件:${error.message}`, "error"); + } +} + +function updateEventSummary(summary) { + elements.allEventsCount.textContent = String(summary.total); + elements.pendingEventsCount.textContent = String(summary.pending); + elements.ackEventsCount.textContent = String(summary.acknowledged); + elements.resolvedEventsCount.textContent = String(summary.resolved); + elements.pendingNavCount.textContent = String(summary.pending); + elements.notificationDot.hidden = summary.pending === 0; +} + +function renderRecentEvents(events) { + if (!events.length) { + elements.recentEventList.innerHTML = "
暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。
"; + return; + } + elements.recentEventList.innerHTML = events.map((event) => { + const primary = event.classes.includes("fire") ? "fire" : "smoke"; + const label = event.classes.map(classLabel).join("、"); + return `暂无检测结果
'; + elements.detectionList.innerHTML = '暂无检测结果
'; const context = elements.overlayCanvas.getContext("2d"); context.clearRect(0, 0, elements.overlayCanvas.width, elements.overlayCanvas.height); } @@ -59,7 +280,7 @@ function drawDetections(detections = []) { 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"; + 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); @@ -69,7 +290,7 @@ function drawDetections(detections = []) { }); } -function updateResults(result) { +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; @@ -77,35 +298,26 @@ function updateResults(result) { elements.smokeCount.textContent = String(smoke); elements.fireCount.textContent = String(fire); elements.maxConfidence.textContent = max ? `${Math.round(max * 100)}%` : "--"; - elements.riskStatus.textContent = fire ? "火焰告警" : smoke ? "烟雾告警" : "正常"; + elements.riskStatus.textContent = fire ? "高风险" : smoke ? "需关注" : "正常"; elements.detectionList.innerHTML = detections.length - ? detections.map((item) => `未发现目标
'; + ? detections.map((item) => `未发现烟雾或火焰目标
'; const alert = result.alert || {}; + const channelNames = enabledChannelNames(alert.notification_channels); 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)`; + const labels = alert.classes.map(classLabel).join("、"); + elements.alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`; elements.alertStatus.className = "alert-status alert-triggered"; + showToast(`检测到${labels},已生成告警事件`, "error"); + loadDashboard(); } 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.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${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")}`; + elements.lastUpdated.textContent = `推理 ${result.inference_ms ?? "--"} ms`; } function captureFrame() { @@ -117,13 +329,7 @@ function captureFrame() { } async function detectCurrentFrame(timestamp) { - if ( - !detectionActive - || requestInFlight - || !sessionId - || elements.videoPreview.paused - || elements.videoPreview.ended - ) return; + if (!detectionActive || requestInFlight || !sessionId || elements.videoPreview.paused || elements.videoPreview.ended) return; const interval = Number(elements.intervalSelect.value); if (timestamp - lastDetectionAt < interval) return; lastDetectionAt = timestamp; @@ -136,11 +342,14 @@ async function detectCurrentFrame(timestamp) { 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"); + updateDetectionResults(await response.json()); + elements.inspectionStatus.textContent = "检测运行中"; + elements.inspectionStatus.className = "status-chip status-online"; + setServiceStatus(true); } catch (error) { - setConnectionStatus("检测服务异常", "alert"); - elements.detectionList.innerHTML = `${error.message}
`; + elements.inspectionStatus.textContent = "检测异常"; + elements.inspectionStatus.className = "status-chip status-offline"; + showToast(`视频检测失败:${error.message}`, "error"); } finally { requestInFlight = false; elements.loadingState.hidden = true; @@ -160,42 +369,50 @@ async function startDetection() { elements.intervalSelect.disabled = true; try { await elements.videoPreview.play(); + requestAnimationFrame(scheduleDetection); } catch { - setConnectionStatus("请允许视频播放", "alert"); + stopDetection(); + showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error"); } - requestAnimationFrame(scheduleDetection); } function stopDetection() { detectionActive = false; elements.runDetectionButton.textContent = "开始连续检测"; elements.intervalSelect.disabled = false; + elements.inspectionStatus.textContent = elements.videoPreview.src ? "检测已暂停" : "等待视频"; + elements.inspectionStatus.className = "status-chip status-idle"; } async function resetSession() { - const previousSession = sessionId; + const previous = 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 = "无法读取机器人告警状态"; - } + if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {}); } +document.querySelectorAll("[data-view], [data-view-jump]").forEach((control) => { + control.addEventListener("click", () => switchView(control.dataset.view || control.dataset.viewJump)); +}); +document.querySelectorAll("[data-event-filter]").forEach((button) => { + button.addEventListener("click", () => { + eventFilter = button.dataset.eventFilter; + document.querySelectorAll("[data-event-filter]").forEach((item) => item.classList.toggle("is-active", item === button)); + loadEvents(); + }); +}); +elements.eventTableBody.addEventListener("click", (event) => { + const button = event.target.closest("[data-event-id]"); + if (button) updateEventStatus(button.dataset.eventId, button.dataset.eventStatus); +}); +elements.menuButton.addEventListener("click", () => elements.sidebar.classList.toggle("is-open")); +elements.refreshEventsButton.addEventListener("click", loadEvents); +elements.runDetectionButton.addEventListener("click", () => detectionActive ? stopDetection() : startDetection()); +elements.videoPreview.addEventListener("ended", stopDetection); +elements.videoPreview.addEventListener("timeupdate", () => { + elements.videoClock.textContent = `${formatTime(elements.videoPreview.currentTime)} / ${formatTime(elements.videoPreview.duration)}`; +}); +elements.videoPreview.addEventListener("seeked", async () => { drawDetections([]); await resetSession(); }); +elements.videoPreview.addEventListener("resize", () => drawDetections([])); elements.videoInput.addEventListener("change", async () => { const [file] = elements.videoInput.files; if (!file) return; @@ -207,22 +424,13 @@ elements.videoInput.addEventListener("change", async () => { elements.videoPreview.hidden = false; elements.emptyState.hidden = true; elements.sourceLabel.textContent = file.name; + elements.videoMeta.textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`; elements.runDetectionButton.disabled = false; + elements.inspectionStatus.textContent = "视频已就绪"; + elements.inspectionStatus.className = "status-chip status-online"; await resetSession(); - clearResults(); + clearDetectionResults(); }); - -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(); @@ -232,12 +440,15 @@ elements.clearButton.addEventListener("click", async () => { if (videoUrl) URL.revokeObjectURL(videoUrl); videoUrl = null; elements.videoInput.value = ""; - elements.sourceLabel.textContent = "等待选择视频"; + elements.sourceLabel.textContent = "未选择视频"; + elements.videoMeta.textContent = "支持 MP4、WebM 等浏览器可播放格式"; elements.emptyState.hidden = false; elements.runDetectionButton.disabled = true; await resetSession(); - clearResults(); + clearDetectionResults(); }); +updateClock(); +setInterval(updateClock, 1000); resetSession(); -checkHealth(); +Promise.all([loadDashboard(), loadEvents()]); diff --git a/frontend/index.html b/frontend/index.html index f8ba920..50d9fbb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,88 +3,177 @@ -YOLO11S / VIDEO DETECTION
-VIDEO VIEW
-