Compare commits

...

22 Commits

Author SHA1 Message Date
18cfc9b16a feat(server):补充RESTful 接口 2026-06-12 15:00:24 +08:00
8b914aae8a refactor(web): 重构App.vue为纯路由出口 2026-06-12 14:27:37 +08:00
4e0f724661 feat(server):补充新增RTSP配置类;集成 RTSP StreamManager 初始化与路由注册 2026-06-12 14:26:15 +08:00
c88dcfff17 chore(web): 新增前端测试依赖与构建配置 2026-06-12 14:23:52 +08:00
2c135d5ebe fix(web): 修复页面切换导致检测过程中断的问题 2026-06-12 14:10:04 +08:00
8c2ea57119 feat(web): 新增前端预警列表页面与服务层。实现前端预警列表展示页、WebSocket 订阅服务和全局预警状态管理。 2026-06-12 14:09:24 +08:00
535fa89e64 feat(server): 新增预警 WebSocket 接口。实现后端 WebSocket 接口,支持前端实时订阅预警消息推送。 2026-06-12 14:06:10 +08:00
21829bcbae feat(server): 新增事件聚合引擎。实现时间窗口去重、空间邻近合并和置信度加权融合的事件聚合能力,减少重复预警。 2026-06-12 14:05:22 +08:00
bf12a29acd feat(server): 新增ByteTrack目标跟踪服务 2026-06-12 13:58:15 +08:00
2fcaf57478 feat(server): 新增 MQTT 预警消息发布服务 2026-06-12 13:57:41 +08:00
40fd3089a7 feat(server): 新增RTSP多路视频流接入服务。- 实现基于Ring Buffer的帧缓冲区(frame_buffer),支持线程安全读写
- 实现RTSP流接入服务(rtsp_service),支持单路流连接/解码/帧采集
- 实现多路流调度管理器(stream_manager),统一管理多路RTSP流启停与状态监控
2026-06-12 13:56:35 +08:00
279bffbcde feat:集成事件决策/规则/聚合管道 2026-06-11 17:27:57 +08:00
51279c00ab chore:新增火灾/抽烟/徘徊/车辆/打架五类预警规则yaml 2026-06-11 17:26:38 +08:00
4b051f16be feat:新增事件决策/规则/聚合三段管道引擎 2026-06-11 17:25:11 +08:00
24c16de9a1 feat:新增 DetectionAdapter 统一 4 种检测服务输出 2026-06-11 17:23:40 +08:00
05d4a5edf6 feat:新增统一事件数据契约,为所有检测服务统一输出格式 2026-06-11 17:22:32 +08:00
cb2a7dcca3 feat:新增基于 pydantic-settings 的统一配置中心 2026-06-11 17:20:55 +08:00
993586fdee chore:引入 pydantic-settings 与 pyyaml 依赖 2026-06-11 17:15:48 +08:00
360f98fe7a feat(模型):补充YOLOv8的打架斗殴模型PT 2026-06-10 15:12:32 +08:00
9be93340a7 补充:火灾检测模型由基于YOLOv10的火灾烟雾检测模型改为复合模型[基于YOLOv8的火灾检测模型(单火焰检测)+YOLOv10-M,专用火灾烟雾模型] 2026-06-10 15:05:48 +08:00
ca4f977ff0 补充:火灾检测模型由基于YOLOv10的火灾烟雾检测模型改为复合模型[基于YOLOv8的火灾检测模型(单火焰检测)+YOLOv10-M,专用火灾烟雾模型] 2026-06-10 14:53:58 +08:00
0e011dacfd 火灾检测模型由基于YOLOv10的火灾烟雾检测模型改为复合模型[基于YOLOv8的火灾检测模型(单火焰检测)+YOLOv10-M,专用火灾烟雾模型] 2026-06-10 14:53:57 +08:00
42 changed files with 7101 additions and 94 deletions

261
apps/server/api/alerts.py Normal file
View File

@@ -0,0 +1,261 @@
"""预警 WebSocket 接口 (MVP-2 / D23-D24)
提供 ``/ws/alerts`` 接口,供前端订阅实时预警事件。
工作原理:
1. 前端通过 WebSocket 连接 ``/ws/alerts``
2. 后端维护连接池,预警事件通过 ``AlertBroadcaster`` 广播到所有订阅者
3. 支持按事件类型、source_id 过滤订阅
4. AlertPublisher 在发布到 MQTT 的同时也调用本接口的广播器
消息格式 (与 AlertPublisher 保持一致)::
{
"type": "alert",
"data": {
"alert_id": "...",
"event_type": "fire",
...
}
}
订阅消息::
{"action": "subscribe", "filter": {"event_types": ["fire"], "source_ids": ["cam-01"]}}
{"action": "unsubscribe"}
{"action": "ping"}
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional, Set
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# AlertBroadcaster
# ---------------------------------------------------------------------------
class AlertBroadcaster:
"""预警事件广播器 (单例)。
保存所有活跃 WebSocket 连接,提供广播 + 按过滤条件分发能力。
"""
def __init__(self) -> None:
self._connections: Dict[int, "_Subscriber"] = {}
self._lock = asyncio.Lock()
async def add_connection(self, ws: WebSocket) -> "_Subscriber":
"""添加 WebSocket 连接。"""
async with self._lock:
sub = _Subscriber(ws=ws)
self._connections[id(ws)] = sub
logger.info("AlertBroadcaster 新增订阅者: %d (当前 %d)", id(ws), len(self._connections))
return sub
async def remove_connection(self, ws: WebSocket) -> None:
"""移除 WebSocket 连接。"""
async with self._lock:
self._connections.pop(id(ws), None)
logger.info(
"AlertBroadcaster 移除订阅者: %d (剩余 %d)",
id(ws),
len(self._connections),
)
async def broadcast(self, alert: Dict[str, Any]) -> int:
"""广播预警事件到所有匹配的订阅者。
Returns:
实际投递的订阅者数量
"""
message = {"type": "alert", "data": alert}
delivered = 0
async with self._lock:
subs = list(self._connections.values())
# 在锁外发送,避免阻塞其他连接管理
for sub in subs:
if not sub.matches(alert):
continue
try:
await sub.ws.send_json(message)
delivered += 1
except Exception as e: # noqa: BLE001
logger.debug("广播失败 (订阅者 %d): %s", id(sub.ws), e)
return delivered
@property
def connection_count(self) -> int:
return len(self._connections)
def get_stats(self) -> Dict[str, Any]:
return {
"total_connections": len(self._connections),
"subscribers": [
{
"id": sid,
"event_types": list(s.event_types) if s.event_types else "all",
"source_ids": list(s.source_ids) if s.source_ids else "all",
}
for sid, s in self._connections.items()
],
}
# ---------------------------------------------------------------------------
# 订阅者
# ---------------------------------------------------------------------------
class _Subscriber:
"""单个 WebSocket 订阅者上下文。"""
__slots__ = ("ws", "event_types", "source_ids")
def __init__(
self,
ws: WebSocket,
event_types: Optional[Set[str]] = None,
source_ids: Optional[Set[str]] = None,
) -> None:
self.ws = ws
self.event_types: Set[str] = event_types or set()
self.source_ids: Set[str] = source_ids or set()
def update_filter(
self,
event_types: Optional[List[str]] = None,
source_ids: Optional[List[str]] = None,
) -> None:
self.event_types = set(event_types or [])
self.source_ids = set(source_ids or [])
def matches(self, alert: Dict[str, Any]) -> bool:
"""判断预警事件是否匹配订阅过滤条件。"""
if self.event_types:
event_type = alert.get("event_type")
if event_type not in self.event_types:
return False
if self.source_ids:
source_id = alert.get("source_id")
if source_id not in self.source_ids:
return False
return True
# ---------------------------------------------------------------------------
# 全局广播器实例
# ---------------------------------------------------------------------------
_broadcaster: Optional[AlertBroadcaster] = None
def get_broadcaster() -> AlertBroadcaster:
"""获取全局 AlertBroadcaster 单例。"""
global _broadcaster
if _broadcaster is None:
_broadcaster = AlertBroadcaster()
return _broadcaster
# ---------------------------------------------------------------------------
# WebSocket 路由
# ---------------------------------------------------------------------------
@router.websocket("/ws/alerts")
async def alerts_websocket(websocket: WebSocket):
"""前端订阅预警事件的 WebSocket 接口。"""
await websocket.accept()
broadcaster = get_broadcaster()
subscriber = await broadcaster.add_connection(websocket)
try:
# 发送欢迎消息
await websocket.send_json(
{
"type": "welcome",
"data": {
"message": "已连接预警频道",
"subscriber_id": id(websocket),
},
}
)
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
except json.JSONDecodeError:
await websocket.send_json(
{"type": "error", "data": {"message": "无效的 JSON"}}
)
continue
action = message.get("action")
if action == "subscribe":
filter_cfg = message.get("filter") or {}
subscriber.update_filter(
event_types=filter_cfg.get("event_types"),
source_ids=filter_cfg.get("source_ids"),
)
await websocket.send_json(
{
"type": "subscribed",
"data": {
"event_types": list(subscriber.event_types) or "all",
"source_ids": list(subscriber.source_ids) or "all",
},
}
)
elif action == "unsubscribe":
subscriber.update_filter()
await websocket.send_json(
{"type": "unsubscribed", "data": {}}
)
elif action == "ping":
await websocket.send_json({"type": "pong", "data": {}})
else:
await websocket.send_json(
{
"type": "error",
"data": {"message": f"未知 action: {action}"},
}
)
except WebSocketDisconnect:
logger.info("预警订阅者断开连接")
except Exception as e: # noqa: BLE001
logger.error("预警 WebSocket 异常: %s", e)
finally:
await broadcaster.remove_connection(websocket)
@router.get("/alerts/subscribers")
async def get_subscribers():
"""获取当前预警订阅者状态 (调试用)。"""
return get_broadcaster().get_stats()
__all__ = ["router", "AlertBroadcaster", "get_broadcaster"]

View File

@@ -20,7 +20,8 @@ async def detect_image(
model_id: str = Query("fire_detection"),
confidence: float = Query(0.5),
iou: float = Query(0.45),
algorithm_config: Optional[str] = Query(None, description="算法配置JSON字符串")
algorithm_config: Optional[str] = Query(None, description="算法配置JSON字符串"),
composite: bool = Query(False, description="是否启用复合检测(火灾检测时同时检测火焰和烟雾)")
):
"""
图片检测接口
@@ -61,9 +62,15 @@ async def detect_image(
data={}
)
result = await detection_service.detect_image(
frame, model_id, confidence, iou, algorithm_config=algo_config
)
# 判断是否启用复合火灾检测
if composite and model_id == 'fire_detection':
result = await detection_service.detect_fire_composite(
frame, confidence, iou
)
else:
result = await detection_service.detect_image(
frame, model_id, confidence, iou, algorithm_config=algo_config
)
if result['success']:
annotated_frame = detection_service.draw_detections(

200
apps/server/api/rtsp.py Normal file
View File

@@ -0,0 +1,200 @@
"""RTSP 流管理 API (MVP-2 / D13-D14)
提供 RTSP 流的增删改查、启停控制、状态监控等 RESTful 接口。
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from core.settings import get_settings
from services.stream_manager import StreamManager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/rtsp", tags=["RTSP 流管理"])
# 全局 StreamManager 实例 (由 main.py 初始化)
_stream_manager: Optional[StreamManager] = None
def init_stream_manager(model_service: Any = None) -> StreamManager:
"""初始化全局 StreamManager 实例。"""
global _stream_manager
settings = get_settings()
_stream_manager = StreamManager(
model_service=model_service,
buffer_capacity=settings.rtsp.buffer_capacity,
max_streams=settings.rtsp.max_streams,
detect_interval=settings.rtsp.detect_interval,
)
return _stream_manager
def get_stream_manager() -> StreamManager:
"""获取全局 StreamManager 实例。"""
if _stream_manager is None:
raise RuntimeError("StreamManager 未初始化,请先调用 init_stream_manager()")
return _stream_manager
# ---------------------------------------------------------------------------
# 请求/响应模型
# ---------------------------------------------------------------------------
class AddStreamRequest(BaseModel):
"""添加 RTSP 流请求。"""
stream_id: str = Field(..., min_length=1, max_length=64, description="流标识")
rtsp_url: str = Field(..., description="RTSP 流地址")
model_id: str = Field(default="fire_detection", description="检测模型ID")
confidence: float = Field(default=0.5, ge=0.0, le=1.0, description="置信度阈值")
iou: float = Field(default=0.45, ge=0.0, le=1.0, description="IOU阈值")
frame_skip: int = Field(default=0, ge=0, description="帧采样间隔")
class UpdateStreamConfigRequest(BaseModel):
"""更新流配置请求。"""
model_id: Optional[str] = None
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)
iou: Optional[float] = Field(default=None, ge=0.0, le=1.0)
frame_skip: Optional[int] = Field(default=None, ge=0)
class StreamOperationResponse(BaseModel):
"""通用操作响应。"""
success: bool
message: str
# ---------------------------------------------------------------------------
# API 路由
# ---------------------------------------------------------------------------
@router.post("/streams", response_model=StreamOperationResponse)
async def add_stream(req: AddStreamRequest):
"""添加一路 RTSP 流。"""
from services.rtsp_service import StreamConfig
config = StreamConfig(
stream_id=req.stream_id,
rtsp_url=req.rtsp_url,
model_id=req.model_id,
confidence=req.confidence,
iou=req.iou,
frame_skip=req.frame_skip,
)
manager = get_stream_manager()
result = await manager.add_stream(req.stream_id, req.rtsp_url, config=config)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["message"])
return StreamOperationResponse(**result)
@router.delete("/streams/{stream_id}", response_model=StreamOperationResponse)
async def remove_stream(stream_id: str):
"""移除一路 RTSP 流。"""
manager = get_stream_manager()
result = await manager.remove_stream(stream_id)
if not result["success"]:
raise HTTPException(status_code=404, detail=result["message"])
return StreamOperationResponse(**result)
@router.post("/streams/{stream_id}/start", response_model=StreamOperationResponse)
async def start_stream(stream_id: str):
"""启动一路 RTSP 流。"""
manager = get_stream_manager()
result = await manager.start_stream(stream_id)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["message"])
return StreamOperationResponse(**result)
@router.post("/streams/{stream_id}/stop", response_model=StreamOperationResponse)
async def stop_stream(stream_id: str):
"""停止单路 RTSP 流。"""
manager = get_stream_manager()
result = await manager.stop_stream(stream_id)
if not result["success"]:
raise HTTPException(status_code=404, detail=result["message"])
return StreamOperationResponse(**result)
@router.put("/streams/{stream_id}/config", response_model=StreamOperationResponse)
async def update_stream_config(stream_id: str, req: UpdateStreamConfigRequest):
"""更新流的检测配置。"""
manager = get_stream_manager()
result = await manager.update_stream_config(
stream_id,
model_id=req.model_id,
confidence=req.confidence,
iou=req.iou,
frame_skip=req.frame_skip,
)
if not result["success"]:
raise HTTPException(status_code=404, detail=result["message"])
return StreamOperationResponse(**result)
@router.get("/streams/{stream_id}")
async def get_stream_info(stream_id: str):
"""获取单路流状态信息。"""
manager = get_stream_manager()
info = manager.get_stream_info(stream_id)
if info is None:
raise HTTPException(status_code=404, detail=f"{stream_id} 不存在")
return info
@router.get("/streams")
async def list_streams():
"""获取所有流状态信息。"""
manager = get_stream_manager()
return {"streams": manager.get_all_streams_info()}
@router.get("/health")
async def rtsp_health():
"""RTSP 管理器健康检查。"""
manager = get_stream_manager()
return manager.get_health()
@router.post("/start-all", response_model=StreamOperationResponse)
async def start_all_streams():
"""启动所有已添加的流。"""
manager = get_stream_manager()
await manager.start_all()
return StreamOperationResponse(success=True, message="所有流已启动")
@router.post("/stop-all", response_model=StreamOperationResponse)
async def stop_all_streams():
"""停止所有流。"""
manager = get_stream_manager()
await manager.stop_all()
return StreamOperationResponse(success=True, message="所有流已停止")
__all__ = ["router", "init_stream_manager", "get_stream_manager"]

View File

@@ -0,0 +1,17 @@
# 打架检测预警规则 (MVP-1)
rules:
- name: fight_high
event_type: fight
enabled: true
min_confidence: 0.55
severity: high
min_bbox_area: 800
description: 检测到打架/暴力行为,触发高级预警
- name: fight_critical_continuous
event_type: fight
enabled: true
min_confidence: 0.75
severity: critical
min_bbox_area: 1200
description: 检测到高置信度打架行为,立即触发最高级别预警

View File

@@ -0,0 +1,18 @@
# 火灾预警规则 (MVP-1)
# 命中后会被规则引擎升级为 AlertEvent
rules:
- name: fire_critical
event_type: fire
enabled: true
min_confidence: 0.6
severity: critical
min_bbox_area: 400
description: 检测到火焰,立即触发最高级别预警
- name: smoke_high
event_type: smoke
enabled: true
min_confidence: 0.55
severity: high
min_bbox_area: 600
description: 检测到烟雾,可能伴随火情,触发高级预警

View File

@@ -0,0 +1,15 @@
# 徘徊/静止行为预警规则 (MVP-1)
rules:
- name: loitering_medium
event_type: loitering
enabled: true
min_confidence: 0.5
severity: medium
description: 检测到人员徘徊行为
- name: stationary_low
event_type: stationary
enabled: true
min_confidence: 0.5
severity: low
description: 检测到人员长时间静止

View File

@@ -0,0 +1,9 @@
# 抽烟检测预警规则 (MVP-1)
rules:
- name: smoking_medium
event_type: smoking
enabled: true
min_confidence: 0.5
severity: medium
min_bbox_area: 200
description: 检测到抽烟行为

View File

@@ -0,0 +1,9 @@
# 车辆违停检测预警规则 (MVP-1)
rules:
- name: illegal_parking_medium
event_type: illegal_parking
enabled: true
min_confidence: 0.5
severity: medium
min_bbox_area: 1200
description: 检测到车辆违停行为

View File

@@ -0,0 +1,10 @@
"""核心基础模块 (配置、日志、异常等)。"""
from .settings import (
Settings,
get_settings,
SERVER_DIR,
PROJECT_ROOT,
)
__all__ = ["Settings", "get_settings", "SERVER_DIR", "PROJECT_ROOT"]

View File

@@ -0,0 +1,251 @@
"""统一应用配置 (MVP-1 / 新增)
基于 pydantic-settings 的多环境配置管理,集中收敛原先散落在
``main.py``、各 service 模块中的环境变量、路径、阈值等配置。
加载顺序 (优先级从高到低)::
1. 显式构造参数
2. 环境变量 (大小写不敏感)
3. ``.env`` 文件 (位于 apps/server/.env)
4. 默认值
使用方式::
from core.settings import get_settings
settings = get_settings()
print(settings.api.port)
print(settings.detection.default_confidence)
"""
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
from typing import List, Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
# 项目根 (apps/server)
SERVER_DIR: Path = Path(__file__).resolve().parent.parent
PROJECT_ROOT: Path = SERVER_DIR.parent.parent
# ---------------------------------------------------------------------------
# 子配置
# ---------------------------------------------------------------------------
class APISettings(BaseSettings):
"""API 与服务器相关配置。"""
model_config = SettingsConfigDict(env_prefix="API_", extra="ignore")
host: str = Field(default="0.0.0.0", description="监听地址")
port: int = Field(default=8000, description="监听端口")
reload: bool = Field(default=True, description="开发模式自动重载")
cors_origins: List[str] = Field(
default_factory=lambda: ["*"], description="允许的跨域来源"
)
class DetectionSettings(BaseSettings):
"""检测相关全局默认值。"""
model_config = SettingsConfigDict(env_prefix="DETECTION_", extra="ignore")
default_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
default_iou: float = Field(default=0.45, ge=0.0, le=1.0)
# 决策引擎过滤的最低置信度
min_confidence: float = Field(default=0.3, ge=0.0, le=1.0)
class ActionDetectionSettings(BaseSettings):
"""ppTSM 行为识别 (Docker) 服务配置。"""
model_config = SettingsConfigDict(env_prefix="ACTION_DETECTION_", extra="ignore")
api_url: str = Field(default="http://localhost:8081")
timeout: int = Field(default=30, ge=1)
class EventEngineSettings(BaseSettings):
"""事件决策 + 聚合 + 规则引擎配置。"""
model_config = SettingsConfigDict(env_prefix="EVENT_", extra="ignore")
# 时间窗口去重 (秒),同一 (source_id, event_type, track_id) 在窗口内只产生一条
dedup_window_seconds: float = Field(default=30.0, ge=0.0)
# 规则 YAML 目录 (相对 server 根)
rules_dir: str = Field(default="config/rules")
# 事件聚合最大活跃事件数 (超过将按 LRU 淘汰)
max_active_events: int = Field(default=1000, ge=1)
class RTSPSettings(BaseSettings):
"""RTSP 流接入相关配置 (MVP-2)。"""
model_config = SettingsConfigDict(env_prefix="RTSP_", extra="ignore")
max_streams: int = Field(default=16, ge=1, description="最大同时接入流数量")
buffer_capacity: int = Field(default=300, ge=1, description="每路流帧缓冲区容量")
reconnect_attempts: int = Field(default=10, ge=0, description="最大重连次数0=无限")
reconnect_interval_base: float = Field(default=2.0, ge=0.5, description="首次重连间隔(秒)")
reconnect_interval_max: float = Field(default=60.0, ge=1.0, description="最大重连间隔(秒)")
reconnect_backoff_factor: float = Field(default=2.0, ge=1.0, description="退避因子")
frame_skip: int = Field(default=0, ge=0, description="帧采样间隔0=每帧都取")
read_timeout: float = Field(default=5.0, ge=1.0, description="单帧读取超时(秒)")
detect_interval: float = Field(default=0.0, ge=0.0, description="检测轮询间隔(秒)0=每帧检测")
class MQTTSettings(BaseSettings):
"""MQTT 预警发布相关配置 (MVP-2 / D16-D18)。"""
model_config = SettingsConfigDict(env_prefix="MQTT_", extra="ignore")
enabled: bool = Field(default=False, description="是否启用 MQTT 发布")
broker_host: str = Field(default="localhost", description="MQTT broker 主机")
broker_port: int = Field(default=1883, ge=1, le=65535, description="MQTT broker 端口")
client_id: str = Field(default="jc-video-recognize", description="MQTT 客户端ID")
username: Optional[str] = Field(default=None, description="MQTT 用户名")
password: Optional[str] = Field(default=None, description="MQTT 密码")
keepalive: int = Field(default=60, ge=10, description="心跳间隔(秒)")
qos: int = Field(default=1, ge=0, le=2, description="QoS 等级")
retain: bool = Field(default=False, description="是否保留消息")
# 主题模板
alert_topic_prefix: str = Field(default="video/alerts", description="预警主题前缀")
# 重连
reconnect_min_delay: float = Field(default=1.0, ge=0.1, description="最小重连间隔(秒)")
reconnect_max_delay: float = Field(default=60.0, ge=1.0, description="最大重连间隔(秒)")
class TrackingSettings(BaseSettings):
"""目标跟踪 (ByteTrack) 配置 (MVP-2 / D19)。"""
model_config = SettingsConfigDict(env_prefix="TRACKING_", extra="ignore")
enabled: bool = Field(default=True, description="是否启用目标跟踪")
track_thresh: float = Field(default=0.5, ge=0.0, le=1.0, description="跟踪置信度阈值")
high_thresh: float = Field(default=0.6, ge=0.0, le=1.0, description="高置信度阈值")
match_thresh: float = Field(default=0.8, ge=0.0, le=1.0, description="IOU 匹配阈值")
max_lost_frames: int = Field(default=30, ge=1, description="目标丢失最大帧数")
min_box_area: float = Field(default=10.0, ge=0.0, description="最小框面积")
class AggregatorSettings(BaseSettings):
"""事件聚合器扩展配置 (MVP-2 / D20)。"""
model_config = SettingsConfigDict(env_prefix="AGGREGATOR_", extra="ignore")
enable_spatial_merge: bool = Field(default=True, description="是否启用空间邻近合并")
spatial_iou_threshold: float = Field(
default=0.3, ge=0.0, le=1.0, description="空间合并 IOU 阈值"
)
confidence_fusion_strategy: str = Field(
default="weighted",
description="置信度融合策略: weighted/max/avg",
)
fusion_decay_factor: float = Field(
default=0.9, ge=0.0, le=1.0, description="历史置信度衰减因子"
)
class LoggingSettings(BaseSettings):
"""日志配置。"""
model_config = SettingsConfigDict(env_prefix="LOG_", extra="ignore")
level: str = Field(default="INFO")
json_format: bool = Field(default=False)
class PathSettings(BaseSettings):
"""关键路径 (静态资源 / 外部模型) 配置。"""
model_config = SettingsConfigDict(env_prefix="PATH_", extra="ignore")
server_dir: Path = SERVER_DIR
project_root: Path = PROJECT_ROOT
static_dir: Path = SERVER_DIR / "static"
results_dir: Path = SERVER_DIR / "static" / "results"
temp_dir: Path = SERVER_DIR / "static" / "temp"
uploads_dir: Path = SERVER_DIR / "static" / "uploads"
external_paddle: Path = (
PROJECT_ROOT / "external" / "video-recognition-system" / "PaddlePaddle"
)
def ensure(self) -> None:
"""确保所有需要的目录存在 (启动时调用)。"""
for p in (self.static_dir, self.results_dir, self.temp_dir, self.uploads_dir):
p.mkdir(parents=True, exist_ok=True)
# ---------------------------------------------------------------------------
# 总配置
# ---------------------------------------------------------------------------
class Settings(BaseSettings):
"""应用总配置。
子配置统一在此聚合,便于以 ``settings.api.port`` 这种命名空间方式访问。
"""
model_config = SettingsConfigDict(
env_file=str(SERVER_DIR / ".env"),
env_file_encoding="utf-8",
env_nested_delimiter="__",
case_sensitive=False,
extra="ignore",
)
env: str = Field(default="development", description="运行环境")
debug: bool = Field(default=True)
api: APISettings = Field(default_factory=APISettings)
detection: DetectionSettings = Field(default_factory=DetectionSettings)
action_detection: ActionDetectionSettings = Field(
default_factory=ActionDetectionSettings
)
event_engine: EventEngineSettings = Field(default_factory=EventEngineSettings)
rtsp: RTSPSettings = Field(default_factory=RTSPSettings)
mqtt: MQTTSettings = Field(default_factory=MQTTSettings)
tracking: TrackingSettings = Field(default_factory=TrackingSettings)
aggregator: AggregatorSettings = Field(default_factory=AggregatorSettings)
logging: LoggingSettings = Field(default_factory=LoggingSettings)
paths: PathSettings = Field(default_factory=PathSettings)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""获取全局 Settings 单例 (带缓存)。
测试场景下可调用 ``get_settings.cache_clear()`` 重新加载。
"""
settings = Settings()
settings.paths.ensure()
return settings
__all__ = [
"Settings",
"APISettings",
"DetectionSettings",
"ActionDetectionSettings",
"EventEngineSettings",
"RTSPSettings",
"MQTTSettings",
"TrackingSettings",
"AggregatorSettings",
"LoggingSettings",
"PathSettings",
"get_settings",
"SERVER_DIR",
"PROJECT_ROOT",
]

View File

@@ -9,6 +9,8 @@ import sys
import logging
from api import detection, models
from api.alerts import get_broadcaster
from api.rtsp import init_stream_manager
from services.model_service import ModelService
from services.camera_service import CameraService
@@ -64,12 +66,21 @@ async def lifespan(app: FastAPI):
global camera_service
camera_service = CameraService(model_service)
# 初始化 RTSP StreamManager (MVP-2)
rtsp_manager = init_stream_manager(model_service)
from api.rtsp import router as rtsp_router
from api.alerts import router as alerts_router
app.include_router(rtsp_router, prefix="/api")
app.include_router(alerts_router, prefix="") # WebSocket 路径已带 /ws 前缀
yield
# 关闭时清理资源
logger.info("正在关闭服务,清理资源...")
if camera_service:
await camera_service.stop()
await rtsp_manager.stop_all()
app = FastAPI(
title="视频模型检测平台",

View File

@@ -0,0 +1,226 @@
"""统一事件数据契约 (MVP-1 / P0)
为后续事件决策引擎、规则引擎、聚合器、LLM 复审等模块提供一致的数据模型。
所有检测服务 (YOLO / PaddleDetection / ActionDetection 等) 的输出,
都应通过 ``services/adapters/detection_adapter.py`` 适配为本文件定义的
``UnifiedDetection`` / ``DetectionResult``。
字段命名遵循 Google Python Style Guide所有字段使用 snake_case。
"""
from __future__ import annotations
import time
import uuid
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ---------------------------------------------------------------------------
# 枚举
# ---------------------------------------------------------------------------
class EventType(str, Enum):
"""统一事件类型枚举。
与各服务底层 class_name 解耦,事件引擎只面向本枚举决策。
"""
FIRE = "fire"
SMOKE = "smoke"
SMOKING = "smoking"
FIGHT = "fight"
LOITERING = "loitering"
STATIONARY = "stationary"
INTRUSION = "intrusion"
ILLEGAL_PARKING = "illegal_parking"
VEHICLE = "vehicle"
PERSON = "person"
UNKNOWN = "unknown"
class SeverityLevel(str, Enum):
"""事件严重性级别 (供规则引擎 / 聚合器使用)。"""
INFO = "info"
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class DetectionSource(str, Enum):
"""检测来源 (用于适配器溯源 & 调度策略)。"""
YOLO = "yolo"
PADDLE = "paddle"
ACTION_DOCKER = "action_docker"
BEHAVIOR = "behavior" # 由徘徊/静止等行为分析模块产出
COMPOSITE = "composite" # 复合检测 (例如火灾双模型)
# ---------------------------------------------------------------------------
# 基础结构
# ---------------------------------------------------------------------------
class BBox(BaseModel):
"""边界框 (xyxy)。"""
model_config = ConfigDict(frozen=True)
x1: int
y1: int
x2: int
y2: int
@field_validator("x2")
@classmethod
def _check_x2(cls, v: int, info) -> int:
x1 = info.data.get("x1", 0)
if v < x1:
raise ValueError(f"bbox.x2({v}) 必须 >= x1({x1})")
return v
@field_validator("y2")
@classmethod
def _check_y2(cls, v: int, info) -> int:
y1 = info.data.get("y1", 0)
if v < y1:
raise ValueError(f"bbox.y2({v}) 必须 >= y1({y1})")
return v
@property
def width(self) -> int:
return self.x2 - self.x1
@property
def height(self) -> int:
return self.y2 - self.y1
@property
def area(self) -> int:
return self.width * self.height
@property
def center(self) -> Tuple[float, float]:
return (self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0
def to_list(self) -> List[int]:
return [self.x1, self.y1, self.x2, self.y2]
# ---------------------------------------------------------------------------
# 统一检测结果
# ---------------------------------------------------------------------------
class UnifiedDetection(BaseModel):
"""统一检测对象 (单个目标)。
适配器将各引擎原始输出转换为本结构,作为事件决策引擎的唯一输入。
"""
detection_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
track_id: Optional[int] = None
class_name: str
label: str
confidence: float = Field(ge=0.0, le=1.0)
bbox: BBox
source: DetectionSource = DetectionSource.YOLO
model_id: Optional[str] = None
timestamp: float = Field(default_factory=time.time)
extra: Dict[str, Any] = Field(default_factory=dict)
class DetectionStats(BaseModel):
"""检测统计信息 (与现有 API 字段保持兼容)。"""
total_detections: int = 0
avg_confidence: float = 0.0
processing_time: float = 0.0
model_used: Optional[str] = None
fps: Optional[float] = None
class DetectionResult(BaseModel):
"""统一检测结果 (单帧 / 单图)。"""
success: bool = True
message: str = ""
source: DetectionSource = DetectionSource.YOLO
model_id: Optional[str] = None
frame_id: Optional[str] = Field(default_factory=lambda: uuid.uuid4().hex)
timestamp: float = Field(default_factory=time.time)
detections: List[UnifiedDetection] = Field(default_factory=list)
stats: DetectionStats = Field(default_factory=DetectionStats)
def to_legacy_dict(self) -> Dict[str, Any]:
"""转换为旧接口字段格式 (供 API 层向后兼容输出)。"""
return {
"success": self.success,
"message": self.message,
"detections": [
{
"class": d.class_name,
"label": d.label,
"confidence": round(d.confidence, 3),
"bbox": d.bbox.to_list(),
**({"track_id": d.track_id} if d.track_id is not None else {}),
}
for d in self.detections
],
"stats": self.stats.model_dump(exclude_none=True),
}
# ---------------------------------------------------------------------------
# 候选事件 (决策引擎产出)
# ---------------------------------------------------------------------------
class CandidateEvent(BaseModel):
"""候选事件 (供规则引擎与聚合器进一步处理)。"""
event_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
event_type: EventType
severity: SeverityLevel = SeverityLevel.INFO
confidence: float = Field(ge=0.0, le=1.0)
detection: UnifiedDetection
source_id: Optional[str] = None # 摄像头/视频流标识
timestamp: float = Field(default_factory=time.time)
triggered_rules: List[str] = Field(default_factory=list)
metadata: Dict[str, Any] = Field(default_factory=dict)
class AlertEvent(BaseModel):
"""最终预警事件 (聚合 + 规则筛选后产出)。"""
alert_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
event_type: EventType
severity: SeverityLevel
confidence: float = Field(ge=0.0, le=1.0)
source_id: Optional[str] = None
detections: List[UnifiedDetection] = Field(default_factory=list)
rule_name: Optional[str] = None
first_seen: float = Field(default_factory=time.time)
last_seen: float = Field(default_factory=time.time)
occurrence_count: int = 1
metadata: Dict[str, Any] = Field(default_factory=dict)
__all__ = [
"EventType",
"SeverityLevel",
"DetectionSource",
"BBox",
"UnifiedDetection",
"DetectionStats",
"DetectionResult",
"CandidateEvent",
"AlertEvent",
]

View File

@@ -3,6 +3,8 @@ fastapi==0.136.1
uvicorn[standard]==0.34.0
python-multipart==0.0.20
pydantic==2.10.6
pydantic-settings==2.7.1
pyyaml==6.0.2
python-dotenv==1.1.0
aiofiles==25.1.0
websockets==14.1
@@ -26,7 +28,7 @@ paddle2onnx==2.1.0
# 数据处理
pandas==2.3.3
scipy==1.15.2
scikit-image==0.26.2
scikit-image==0.26.0
# 图像和几何处理
imageio==2.37.3
@@ -35,7 +37,7 @@ shapely==2.1.0
# 其他工具
click==8.4.0
tqdm==4.69.2
tqdm==4.68.1
psutil==6.1.1
# 网络相关
@@ -45,6 +47,8 @@ certifi==2026.5.20
# 开发工具
ipython==9.1.0
jedi==0.19.2
pytest==9.0.3
pytest-cov==7.0.0
# 特殊注意事项:
# 1. imgaug==0.4.0 需要手动修复 numpy 2.0 兼容性问题:

View File

@@ -0,0 +1,9 @@
"""检测结果适配器子包。
将各检测服务 (YOLO / Paddle / Action Docker 等) 的原始输出
适配为 ``models.event_schemas.DetectionResult``。
"""
from .detection_adapter import DetectionAdapter
__all__ = ["DetectionAdapter"]

View File

@@ -0,0 +1,212 @@
"""检测结果适配器 (MVP-1 / P0)
负责将各检测服务的原始 dict 输出,转换为统一的
``DetectionResult`` 数据契约。
输入示例 (旧格式)::
{
"success": True,
"message": "检测完成",
"detections": [
{"class": "fire", "label": "火焰",
"confidence": 0.87, "bbox": [10, 20, 100, 200],
"track_id": 5 # 可选
},
...
],
"stats": {...}
}
输出: ``DetectionResult``
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from models.event_schemas import (
BBox,
DetectionResult,
DetectionSource,
DetectionStats,
UnifiedDetection,
)
logger = logging.getLogger(__name__)
class DetectionAdapter:
"""检测结果适配器。
覆盖以下 4 种来源:
1. YOLO 系列 (``DetectionService.detect_image`` / ``detect_frame``)
2. PaddleDetection 抽烟 (``PaddleDetectionService``)
3. 车辆检测 (``VehicleDetectionService``,含 track_id)
4. ppTSM 行为识别 Docker (``ActionDetectionService``)
"""
@staticmethod
def from_yolo(
raw: Dict[str, Any],
model_id: Optional[str] = None,
source: DetectionSource = DetectionSource.YOLO,
) -> DetectionResult:
"""适配 YOLO / 复合检测产出的 dict。"""
return DetectionAdapter._from_generic(raw, source=source, model_id=model_id)
@staticmethod
def from_paddle(
raw: Dict[str, Any], model_id: Optional[str] = "smoking_detection"
) -> DetectionResult:
"""适配 PaddleDetection (抽烟) 输出。"""
return DetectionAdapter._from_generic(
raw, source=DetectionSource.PADDLE, model_id=model_id
)
@staticmethod
def from_vehicle(
raw: Dict[str, Any], model_id: Optional[str] = "vehicle_detection"
) -> DetectionResult:
"""适配车辆检测 (含 track_id) 输出。"""
return DetectionAdapter._from_generic(
raw, source=DetectionSource.PADDLE, model_id=model_id
)
@staticmethod
def from_action_docker(
raw: Dict[str, Any], model_id: Optional[str] = "ppTSM_fight"
) -> DetectionResult:
"""适配 Docker ppTSM 行为识别输出。
其原始检测使用 ``class_id`` 字段而非 ``class``,此处统一归一化。
"""
normalized = dict(raw)
normalized_dets: List[Dict[str, Any]] = []
for det in raw.get("detections", []) or []:
d = dict(det)
if "class" not in d:
cls_id = d.get("class_id", 0)
d["class"] = "fight" if int(cls_id) == 0 else "normal"
if "label" not in d:
d["label"] = "打架" if d["class"] == "fight" else "正常"
normalized_dets.append(d)
normalized["detections"] = normalized_dets
return DetectionAdapter._from_generic(
normalized, source=DetectionSource.ACTION_DOCKER, model_id=model_id
)
# ------------------------------------------------------------------
# 内部
# ------------------------------------------------------------------
@staticmethod
def _from_generic(
raw: Dict[str, Any],
source: DetectionSource,
model_id: Optional[str],
) -> DetectionResult:
"""通用归一化逻辑。"""
if not isinstance(raw, dict):
raise TypeError(f"raw 必须为 dict收到 {type(raw)}")
success = bool(raw.get("success", True))
message = str(raw.get("message", ""))
raw_dets = raw.get("detections") or []
unified_dets: List[UnifiedDetection] = []
for det in raw_dets:
try:
unified_dets.append(
DetectionAdapter._build_detection(det, source, model_id)
)
except Exception as exc: # noqa: BLE001 - 单条失败不阻塞整体
logger.warning("适配检测项失败,已跳过: %s, raw=%s", exc, det)
stats = DetectionAdapter._build_stats(raw.get("stats"), unified_dets, model_id)
return DetectionResult(
success=success,
message=message,
source=source,
model_id=model_id,
detections=unified_dets,
stats=stats,
)
@staticmethod
def _build_detection(
det: Dict[str, Any],
source: DetectionSource,
model_id: Optional[str],
) -> UnifiedDetection:
"""构建单个 UnifiedDetection。"""
bbox_raw = det.get("bbox") or det.get("box")
if not bbox_raw or len(bbox_raw) < 4:
raise ValueError(f"缺少有效 bbox: {det}")
x1, y1, x2, y2 = (int(v) for v in bbox_raw[:4])
# 确保 x2 >= x1, y2 >= y1
if x2 < x1:
x1, x2 = x2, x1
if y2 < y1:
y1, y2 = y2, y1
class_name = det.get("class") or det.get("class_name") or "unknown"
label = det.get("label", class_name)
confidence = float(det.get("confidence", det.get("score", 0.0)))
confidence = max(0.0, min(1.0, confidence))
track_id = det.get("track_id")
if track_id is not None:
track_id = int(track_id)
extra: Dict[str, Any] = {}
for key in ("center", "plate_number", "is_illegal_parking", "trajectory"):
if key in det:
extra[key] = det[key]
return UnifiedDetection(
track_id=track_id,
class_name=str(class_name),
label=str(label),
confidence=confidence,
bbox=BBox(x1=x1, y1=y1, x2=x2, y2=y2),
source=source,
model_id=model_id,
extra=extra,
)
@staticmethod
def _build_stats(
raw_stats: Optional[Dict[str, Any]],
detections: List[UnifiedDetection],
model_id: Optional[str],
) -> DetectionStats:
"""构建 DetectionStats若原始 stats 缺字段则按 detections 重算。"""
if not detections:
avg_conf = 0.0
else:
avg_conf = sum(d.confidence for d in detections) / len(detections)
raw_stats = raw_stats or {}
return DetectionStats(
total_detections=int(raw_stats.get("total_detections", len(detections))),
avg_confidence=float(raw_stats.get("avg_confidence", round(avg_conf, 3))),
processing_time=float(raw_stats.get("processing_time", 0.0)),
model_used=raw_stats.get("model_used", model_id),
fps=raw_stats.get("fps"),
)
__all__ = ["DetectionAdapter"]

View File

@@ -0,0 +1,181 @@
"""预警发布器 (MVP-2 / D18)
将统一 ``AlertEvent`` 格式化为前端/外部系统可消费的 JSON 消息,
并通过 ``MQTTService`` 发布到对应主题。
主题命名规则::
{prefix}/{event_type}/{source_id} # 单流单类型
{prefix}/all # 全量订阅
{prefix}/{event_type} # 按事件类型订阅
消息格式 (与前端约定)::
{
"alert_id": "...",
"event_type": "fire",
"severity": "critical",
"confidence": 0.92,
"source_id": "cam-01",
"rule_name": "fire_critical",
"first_seen": 1781233417.12,
"last_seen": 1781233418.45,
"occurrence_count": 3,
"detections": [{...}],
"metadata": {...}
}
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, Iterable, List, Optional
from models.event_schemas import AlertEvent
from .mqtt_service import MQTTService
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# AlertPublisher
# ---------------------------------------------------------------------------
class AlertPublisher:
"""预警事件发布器。
Args:
mqtt_service: MQTT 服务实例
topic_prefix: 主题前缀
broadcast_all: 是否同时发布到 ``{prefix}/all`` 总订阅主题
qos: 发布 QoS 等级
retain: 是否保留最后一条预警 (用于新订阅者立即获取最近状态)
"""
def __init__(
self,
mqtt_service: MQTTService,
topic_prefix: str = "video/alerts",
broadcast_all: bool = True,
qos: int = 1,
retain: bool = False,
) -> None:
self._mqtt = mqtt_service
self.topic_prefix = topic_prefix.rstrip("/")
self.broadcast_all = broadcast_all
self.qos = qos
self.retain = retain
self._published_count = 0
self._failed_count = 0
# ------------------------------------------------------------------
# 发布
# ------------------------------------------------------------------
async def publish_alert(self, alert: AlertEvent) -> bool:
"""发布单条预警事件。
Returns:
True 表示至少有一条主题发布成功
"""
payload = self.format_alert(alert)
topics = self._build_topics(alert)
any_success = False
for topic in topics:
ok = await self._mqtt.publish(
topic, payload, qos=self.qos, retain=self.retain
)
if ok:
any_success = True
self._published_count += 1
logger.debug("AlertPublisher 已发布 topic=%s alert_id=%s", topic, alert.alert_id)
else:
self._failed_count += 1
logger.warning(
"AlertPublisher 发布失败 topic=%s alert_id=%s", topic, alert.alert_id
)
return any_success
async def publish_alerts(self, alerts: Iterable[AlertEvent]) -> int:
"""批量发布预警事件,返回成功条数。"""
success = 0
for alert in alerts:
if await self.publish_alert(alert):
success += 1
return success
# ------------------------------------------------------------------
# 格式化
# ------------------------------------------------------------------
@staticmethod
def format_alert(alert: AlertEvent) -> Dict[str, Any]:
"""将 AlertEvent 格式化为 dict (JSON 友好)。"""
return {
"alert_id": alert.alert_id,
"event_type": alert.event_type.value,
"severity": alert.severity.value,
"confidence": round(alert.confidence, 4),
"source_id": alert.source_id,
"rule_name": alert.rule_name,
"first_seen": alert.first_seen,
"last_seen": alert.last_seen,
"occurrence_count": alert.occurrence_count,
"detections": [
{
"detection_id": d.detection_id,
"track_id": d.track_id,
"class_name": d.class_name,
"label": d.label,
"confidence": round(d.confidence, 4),
"bbox": d.bbox.to_list(),
"source": d.source.value,
"model_id": d.model_id,
"timestamp": d.timestamp,
}
for d in alert.detections
],
"metadata": alert.metadata,
"published_at": time.time(),
}
# ------------------------------------------------------------------
# 主题构建
# ------------------------------------------------------------------
def _build_topics(self, alert: AlertEvent) -> List[str]:
topics: List[str] = []
event_type = alert.event_type.value
source_id = alert.source_id or "unknown"
# 细粒度主题
topics.append(f"{self.topic_prefix}/{event_type}/{source_id}")
# 事件类型订阅
topics.append(f"{self.topic_prefix}/{event_type}")
# 总广播
if self.broadcast_all:
topics.append(f"{self.topic_prefix}/all")
return topics
# ------------------------------------------------------------------
# 状态
# ------------------------------------------------------------------
@property
def stats(self) -> Dict[str, Any]:
return {
"topic_prefix": self.topic_prefix,
"published_count": self._published_count,
"failed_count": self._failed_count,
"mqtt_connected": self._mqtt.is_connected,
}
__all__ = ["AlertPublisher"]

View File

@@ -235,13 +235,15 @@ class CameraService:
confidence = config.get('confidence', 0.5)
iou = config.get('iou', 0.45)
draw = True
composite = config.get('composite', False)
processed_frame, result = await detection_service.detect_frame(
frame,
model_id=model_id,
confidence=confidence,
iou=iou,
draw=draw
draw=draw,
composite=composite
)
if result['success']:

View File

@@ -4,11 +4,16 @@ import numpy as np
import time
import uuid
import logging
import torch
from typing import Dict, List, Optional
from PIL import Image, ImageDraw, ImageFont
import torch
from .loitering_service import get_loitering_service
from .adapters import DetectionAdapter
from .event import AlertRuleEngine, EventAggregator, EventDecisionEngine
from core.settings import get_settings
from models.event_schemas import DetectionSource
logger = logging.getLogger(__name__)
@@ -18,12 +23,24 @@ class DetectionService:
self.base_dir = os.path.dirname(os.path.dirname(__file__))
self.results_dir = os.path.join(self.base_dir, "static", "results")
self.temp_dir = os.path.join(self.base_dir, "static", "temp")
os.makedirs(self.results_dir, exist_ok=True)
os.makedirs(self.temp_dir, exist_ok=True)
# 初始化徘徊检测服务(懒加载,实际初始化在第一次使用时)
self.loitering_service = get_loitering_service()
# 事件管道 (MVP-1 / P1-P2-P5)
settings = get_settings()
self.decision_engine = EventDecisionEngine(
min_confidence=settings.detection.min_confidence,
)
rules_dir = os.path.join(self.base_dir, settings.event_engine.rules_dir)
self.rule_engine = AlertRuleEngine.from_directory(rules_dir)
self.event_aggregator = EventAggregator(
dedup_window_seconds=settings.event_engine.dedup_window_seconds,
max_active_events=settings.event_engine.max_active_events,
)
async def detect_image(
self,
@@ -132,6 +149,9 @@ class DetectionService:
result_data, algorithm_config
)
# 事件管道 (MVP-1): 决策 → 规则 → 聚合
result_data = self._apply_event_pipeline(result_data, model_id)
return result_data
except Exception as e:
logger.error(f"图片检测失败: {e}")
@@ -148,19 +168,40 @@ class DetectionService:
model_id: str,
confidence: float = 0.5,
iou: float = 0.45,
draw: bool = True
draw: bool = True,
composite: bool = False
) -> tuple:
start_time = time.time()
model = await self.model_service.load_model(model_id)
if not model:
return frame, {
'success': False,
'detections': [],
'stats': None
}
try:
# 如果是火灾检测模型且启用了复合检测
if composite and model_id == 'fire_detection':
result_data = await self.detect_fire_composite(frame, confidence=confidence, iou=iou)
if result_data['success']:
detections = result_data['detections']
processing_time = time.time() - start_time
fps = 1.0 / processing_time if processing_time > 0 else 0
# 更新 stats 中的 fps 和处理时间
result_data['stats']['fps'] = round(fps, 2)
result_data['stats']['processing_time'] = round(processing_time, 3)
if draw:
frame = self.draw_detections(frame, detections, fps)
# detect_fire_composite 已自带事件管道,此处无需再次调用
return frame, result_data
# 普通单模型检测
model = await self.model_service.load_model(model_id)
if not model:
return frame, {
'success': False,
'detections': [],
'stats': None
}
results = model(frame, conf=confidence, iou=iou, verbose=False)
detections = []
@@ -273,6 +314,9 @@ class DetectionService:
if draw:
frame = self.draw_detections(frame, detections, fps)
# 事件管道 (MVP-1): 决策 → 规则 → 聚合
result_data = self._apply_event_pipeline(result_data, model_id=model_id)
return frame, result_data
except Exception as e:
logger.error(f"帧检测失败: {e}")
@@ -282,6 +326,123 @@ class DetectionService:
'stats': None
}
async def detect_fire_composite(
self,
image: np.ndarray,
confidence: float = 0.1,
iou: float = 0.45
) -> Dict:
"""
复合火灾检测:同时检测火焰和烟雾
"""
start_time = time.time()
try:
# 1. 检测火焰
fire_model = await self.model_service.load_model('fire_detection')
fire_results = fire_model(image, conf=confidence, iou=iou, verbose=False)
fire_detections = []
for result in fire_results:
for box in result.boxes:
try:
xyxy_values = box.xyxy.squeeze().tolist()
if len(xyxy_values) >= 4:
x1, y1, x2, y2 = float(xyxy_values[0]), float(xyxy_values[1]), float(xyxy_values[2]), float(xyxy_values[3])
else:
continue
conf = float(box.conf[0]) if hasattr(box.conf, '__getitem__') else float(box.conf)
cls = int(box.cls[0]) if hasattr(box.cls, '__getitem__') else int(box.cls)
class_name = result.names[cls]
if class_name == 'Fire':
fire_detections.append({
'class': 'Fire',
'label': '火焰',
'confidence': round(conf, 3),
'bbox': [int(x1), int(y1), int(x2), int(y2)],
'type': 'fire'
})
except Exception as e:
logger.error(f"火焰检测解析失败: {e}")
continue
# 2. 检测烟雾使用YOLOv10-M专用火灾烟雾模型只保留 Smoke 类别)
smoke_model = await self.model_service.load_model('smoke_detection')
smoke_results = smoke_model(image, conf=confidence, iou=iou, verbose=False)
smoke_detections = []
for result in smoke_results:
for box in result.boxes:
try:
xyxy_values = box.xyxy.squeeze().tolist()
if len(xyxy_values) >= 4:
x1, y1, x2, y2 = float(xyxy_values[0]), float(xyxy_values[1]), float(xyxy_values[2]), float(xyxy_values[3])
else:
continue
conf = float(box.conf[0]) if hasattr(box.conf, '__getitem__') else float(box.conf)
cls = int(box.cls[0]) if hasattr(box.cls, '__getitem__') else int(box.cls)
class_name = result.names[cls]
# 只保留 Smoke 类别(注意模型输出为大写 S
if class_name == 'Smoke':
smoke_detections.append({
'class': 'Smoke',
'label': '烟雾',
'confidence': round(conf, 3),
'bbox': [int(x1), int(y1), int(x2), int(y2)],
'type': 'smoke'
})
except Exception as e:
logger.error(f"烟雾检测解析失败: {e}")
continue
# 3. 合并所有检测
all_detections = fire_detections + smoke_detections
# 4. 判定是否疑似火灾(火焰或烟雾任一检测到即判定为是)
suspected_fire = len(fire_detections) > 0 or len(smoke_detections) > 0
processing_time = time.time() - start_time
avg_confidence = sum(d['confidence'] for d in all_detections) / len(all_detections) if all_detections else 0
result_data = {
'success': True,
'message': '复合火灾检测完成',
'detections': all_detections,
'stats': {
'total_detections': len(all_detections),
'fire_count': len(fire_detections),
'smoke_count': len(smoke_detections),
'avg_confidence': round(avg_confidence, 3),
'suspected_fire': suspected_fire,
'suspected_fire_label': '' if suspected_fire else '',
'processing_time': round(processing_time, 3),
'model_used': 'fire_composite'
}
}
# 事件管道 (MVP-1): 复合火灾检测产出 candidate/alert 事件
result_data = self._apply_event_pipeline(
result_data,
model_id='fire_composite',
source=DetectionSource.COMPOSITE,
)
return result_data
except Exception as e:
logger.error(f"复合火灾检测失败: {e}")
import traceback
logger.error(f"错误堆栈: {traceback.format_exc()}")
return {
'success': False,
'message': f'复合火灾检测失败: {str(e)}',
'detections': [],
'stats': None
}
def _apply_behavior_analysis(
self,
result_data: Dict,
@@ -340,6 +501,49 @@ class DetectionService:
return result_data
def _apply_event_pipeline(
self,
result_data: Dict,
model_id: Optional[str] = None,
source_id: Optional[str] = None,
source: DetectionSource = DetectionSource.YOLO,
) -> Dict:
"""对检测结果执行 决策 → 规则 → 聚合 三段管道。
在 ``result_data`` 中追加两个字段:
- ``candidate_events``: List[dict] 决策引擎产出的候选事件
- ``alert_events``: List[dict] 规则命中后经聚合的预警事件
"""
if not result_data.get('success') or not result_data.get('detections'):
result_data['candidate_events'] = []
result_data['alert_events'] = []
return result_data
try:
unified_result = DetectionAdapter.from_yolo(
result_data, model_id=model_id, source=source
)
candidates = self.decision_engine.decide(unified_result, source_id=source_id)
alerts = self.rule_engine.evaluate(candidates)
emitted = self.event_aggregator.aggregate(alerts)
result_data['candidate_events'] = [
event.model_dump(mode='json') for event in candidates
]
result_data['alert_events'] = [
event.model_dump(mode='json') for event in emitted
]
except Exception as e: # noqa: BLE001
logger.error(f"事件管道执行失败: {e}")
result_data['candidate_events'] = []
result_data['alert_events'] = []
result_data['event_pipeline_error'] = str(e)
return result_data
def draw_detections(
self,
frame: np.ndarray,

View File

@@ -0,0 +1,14 @@
"""事件引擎子包 (MVP-1 / P1-P2 / P5)。
模块结构::
decision_engine.py 决策引擎 (置信度评估 + 事件类型映射)
rule_engine.py 规则引擎 (YAML 驱动)
aggregator.py 事件聚合器 (时间窗口去重)
"""
from .decision_engine import EventDecisionEngine
from .rule_engine import AlertRuleEngine
from .aggregator import EventAggregator
__all__ = ["EventDecisionEngine", "AlertRuleEngine", "EventAggregator"]

View File

@@ -0,0 +1,247 @@
"""事件聚合器 (MVP-1 / P5 + MVP-2 / D20 增强版)
MVP-1 能力:
- 时间窗口去重: 对同一 (source_id, event_type, track_id_or_bbox_hash)
在配置窗口内只保留一条预警事件
MVP-2 / D20 新增能力:
- 空间邻近合并: 同一 (source_id, event_type) 且 bbox IOU 高的事件视为同一目标
- 置信度加权融合: 多次命中时按时间衰减加权融合置信度
- 融合策略可选: weighted / max / avg
"""
from __future__ import annotations
import logging
import time
from collections import OrderedDict
from typing import Dict, List, Optional, Tuple, TypeAlias
from models.event_schemas import AlertEvent, BBox, UnifiedDetection
logger = logging.getLogger(__name__)
_AggKey: TypeAlias = Tuple[Optional[str], str, str]
# ---------------------------------------------------------------------------
# IOU 工具
# ---------------------------------------------------------------------------
def _bbox_iou(b1: BBox, b2: BBox) -> float:
inter_x1 = max(b1.x1, b2.x1)
inter_y1 = max(b1.y1, b2.y1)
inter_x2 = min(b1.x2, b2.x2)
inter_y2 = min(b1.y2, b2.y2)
inter_w = max(0, inter_x2 - inter_x1)
inter_h = max(0, inter_y2 - inter_y1)
inter = inter_w * inter_h
if inter == 0:
return 0.0
union = b1.area + b2.area - inter
return inter / union if union > 0 else 0.0
# ---------------------------------------------------------------------------
# EventAggregator
# ---------------------------------------------------------------------------
class EventAggregator:
"""基于时间窗口 + 空间邻近的预警去重 / 融合器。
Args:
dedup_window_seconds: 去重窗口 (秒),同 key 在窗口内不会重复产出
max_active_events: 内存中最大活跃事件数,超过时按 LRU 淘汰
enable_spatial_merge: 是否启用空间邻近合并 (MVP-2)
spatial_iou_threshold: 空间合并 IOU 阈值
fusion_strategy: 置信度融合策略: ``weighted`` / ``max`` / ``avg``
fusion_decay_factor: 历史置信度衰减因子 (越小越偏向新数据)
"""
SUPPORTED_FUSION_STRATEGIES = ("weighted", "max", "avg")
def __init__(
self,
dedup_window_seconds: float = 30.0,
max_active_events: int = 1000,
enable_spatial_merge: bool = False,
spatial_iou_threshold: float = 0.3,
fusion_strategy: str = "max",
fusion_decay_factor: float = 0.9,
) -> None:
self.dedup_window_seconds = max(0.0, dedup_window_seconds)
self.max_active_events = max(1, max_active_events)
self.enable_spatial_merge = enable_spatial_merge
self.spatial_iou_threshold = max(0.0, min(1.0, spatial_iou_threshold))
if fusion_strategy not in self.SUPPORTED_FUSION_STRATEGIES:
raise ValueError(
f"不支持的融合策略: {fusion_strategy}, "
f"支持: {self.SUPPORTED_FUSION_STRATEGIES}"
)
self.fusion_strategy = fusion_strategy
self.fusion_decay_factor = max(0.0, min(1.0, fusion_decay_factor))
# 按插入顺序保存以便 LRU 淘汰
self._active: "OrderedDict[_AggKey, AlertEvent]" = OrderedDict()
# ------------------------------------------------------------------
# 主入口
# ------------------------------------------------------------------
def aggregate(self, alerts: List[AlertEvent]) -> List[AlertEvent]:
"""聚合一批预警事件,返回去重 / 融合后真正应当对外发出的事件。"""
now = time.time()
self._evict_expired(now)
emitted: List[AlertEvent] = []
for alert in alerts:
# 1. 优先按 key (track_id / bbox 网格) 精确匹配
key = self._make_key(alert)
existing = self._active.get(key)
# 2. 空间邻近合并: 若 key 未命中,尝试 IOU 匹配
if existing is None and self.enable_spatial_merge:
spatial_key = self._find_spatial_match(alert)
if spatial_key is not None:
existing = self._active.get(spatial_key)
key = spatial_key # 复用旧 key
if existing is None:
self._active[key] = alert
self._active.move_to_end(key)
emitted.append(alert)
if len(self._active) > self.max_active_events:
dropped_key, _ = self._active.popitem(last=False)
logger.debug("聚合器 LRU 淘汰事件: %s", dropped_key)
else:
# 窗口内重复:融合统计
self._fuse(existing, alert, now)
self._active.move_to_end(key)
return emitted
# ------------------------------------------------------------------
# 融合
# ------------------------------------------------------------------
def _fuse(self, existing: AlertEvent, new: AlertEvent, now: float) -> None:
"""将新事件融合到已有事件。"""
existing.last_seen = now
existing.occurrence_count += 1
# 置信度融合
if self.fusion_strategy == "max":
existing.confidence = max(existing.confidence, new.confidence)
elif self.fusion_strategy == "avg":
n = existing.occurrence_count
existing.confidence = (
existing.confidence * (n - 1) + new.confidence
) / n
else: # weighted
decay = self.fusion_decay_factor
existing.confidence = (
existing.confidence * decay + new.confidence * (1 - decay)
)
# 取整到 4 位避免浮点漂移
existing.confidence = round(
max(0.0, min(1.0, existing.confidence)), 4
)
# 严重性向上提升 (新事件更严重时)
severity_order = ["info", "low", "medium", "high", "critical"]
try:
if severity_order.index(new.severity.value) > severity_order.index(
existing.severity.value
):
existing.severity = new.severity
except ValueError:
pass
# 更新最新的检测目标 (用于 LLM 触发器拿到最新 bbox)
if new.detections:
existing.detections = new.detections
# ------------------------------------------------------------------
# 空间匹配
# ------------------------------------------------------------------
def _find_spatial_match(self, alert: AlertEvent) -> Optional[_AggKey]:
"""在活跃事件中寻找空间邻近的同类型事件。"""
if not alert.detections:
return None
target_bbox = alert.detections[0].bbox
best_key: Optional[_AggKey] = None
best_iou = 0.0
for key, existing in self._active.items():
# 必须同 source + 同事件类型
if key[0] != alert.source_id:
continue
if key[1] != alert.event_type.value:
continue
if not existing.detections:
continue
iou = _bbox_iou(target_bbox, existing.detections[0].bbox)
if iou >= self.spatial_iou_threshold and iou > best_iou:
best_iou = iou
best_key = key
return best_key
# ------------------------------------------------------------------
# Key 构造
# ------------------------------------------------------------------
@staticmethod
def _make_key(alert: AlertEvent) -> _AggKey:
if alert.detections:
target_id = EventAggregator._target_identity(alert.detections[0])
else:
target_id = "no_target"
return (alert.source_id, alert.event_type.value, target_id)
@staticmethod
def _target_identity(det: UnifiedDetection) -> str:
"""构造目标稳定标识:优先 track_id否则用 bbox 网格哈希。"""
if det.track_id is not None:
return f"t{det.track_id}"
cx, cy = det.bbox.center
return f"g{int(cx) // 50}_{int(cy) // 50}_{det.class_name}"
# ------------------------------------------------------------------
# 过期淘汰
# ------------------------------------------------------------------
def _evict_expired(self, now: float) -> None:
if self.dedup_window_seconds <= 0:
self._active.clear()
return
expired_keys = [
key
for key, alert in self._active.items()
if now - alert.first_seen > self.dedup_window_seconds
]
for key in expired_keys:
self._active.pop(key, None)
# ------------------------------------------------------------------
# 自省
# ------------------------------------------------------------------
@property
def active_count(self) -> int:
return len(self._active)
def snapshot(self) -> Dict[str, AlertEvent]:
return {f"{k[0]}|{k[1]}|{k[2]}": v for k, v in self._active.items()}
__all__ = ["EventAggregator"]

View File

@@ -0,0 +1,163 @@
"""事件决策引擎 (MVP-1 / P1)
职责:
1. 对统一 ``DetectionResult`` 中的每个检测应用置信度过滤
2. 将底层 ``class_name`` 映射到统一 ``EventType``
3. 产出 ``CandidateEvent`` 列表,供规则引擎与聚合器继续处理
设计原则 (MVP 简化版):
- 不做温度缩放 / 校准 (后续 MVP-3 再迭代)
- 不做场景分类 (后续按需引入)
- 类型映射规则可外部覆盖,避免硬编码
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
from models.event_schemas import (
CandidateEvent,
DetectionResult,
EventType,
SeverityLevel,
UnifiedDetection,
)
logger = logging.getLogger(__name__)
# 默认的 class_name -> EventType 映射 (覆盖当前已有模型)
DEFAULT_CLASS_TO_EVENT: Dict[str, EventType] = {
# 火灾
"fire": EventType.FIRE,
"flame": EventType.FIRE,
"smoke": EventType.SMOKE,
# 抽烟
"smoking": EventType.SMOKING,
"cigarette": EventType.SMOKING,
# 打架
"fight": EventType.FIGHT,
"fighting": EventType.FIGHT,
# 行为
"loitering": EventType.LOITERING,
"stationary": EventType.STATIONARY,
"intrusion": EventType.INTRUSION,
# 车辆
"vehicle": EventType.VEHICLE,
"car": EventType.VEHICLE,
"truck": EventType.VEHICLE,
"bus": EventType.VEHICLE,
"illegal_parking": EventType.ILLEGAL_PARKING,
# 人员
"person": EventType.PERSON,
}
# 事件类型 -> 默认严重性 (规则引擎可覆盖)
DEFAULT_SEVERITY: Dict[EventType, SeverityLevel] = {
EventType.FIRE: SeverityLevel.CRITICAL,
EventType.SMOKE: SeverityLevel.HIGH,
EventType.SMOKING: SeverityLevel.MEDIUM,
EventType.FIGHT: SeverityLevel.HIGH,
EventType.LOITERING: SeverityLevel.MEDIUM,
EventType.STATIONARY: SeverityLevel.LOW,
EventType.INTRUSION: SeverityLevel.HIGH,
EventType.ILLEGAL_PARKING: SeverityLevel.MEDIUM,
EventType.VEHICLE: SeverityLevel.INFO,
EventType.PERSON: SeverityLevel.INFO,
EventType.UNKNOWN: SeverityLevel.INFO,
}
class EventDecisionEngine:
"""事件决策引擎 (简化版)。
Args:
min_confidence: 全局最低置信度阈值,低于此值的检测会被丢弃
class_to_event: 自定义类别映射,会与默认映射合并 (覆盖)
ignore_event_types: 不希望产出的事件类型集合 (例如 PERSON 太频繁)
"""
def __init__(
self,
min_confidence: float = 0.5,
class_to_event: Optional[Dict[str, EventType]] = None,
ignore_event_types: Optional[List[EventType]] = None,
) -> None:
self.min_confidence = max(0.0, min(1.0, min_confidence))
self.class_to_event: Dict[str, EventType] = dict(DEFAULT_CLASS_TO_EVENT)
if class_to_event:
self.class_to_event.update(class_to_event)
self.ignore_event_types = set(ignore_event_types or [])
# ------------------------------------------------------------------
# 主入口
# ------------------------------------------------------------------
def decide(
self,
result: DetectionResult,
source_id: Optional[str] = None,
) -> List[CandidateEvent]:
"""根据检测结果产出候选事件列表。
Args:
result: 统一检测结果
source_id: 摄像头/视频流标识,用于后续聚合
"""
if not result.success or not result.detections:
return []
events: List[CandidateEvent] = []
for det in result.detections:
event = self._build_candidate(det, source_id)
if event is not None:
events.append(event)
if events:
logger.debug(
"DecisionEngine 产出 %d 条候选事件 (source_id=%s, model=%s)",
len(events),
source_id,
result.model_id,
)
return events
# ------------------------------------------------------------------
# 内部
# ------------------------------------------------------------------
def _build_candidate(
self,
det: UnifiedDetection,
source_id: Optional[str],
) -> Optional[CandidateEvent]:
if det.confidence < self.min_confidence:
return None
event_type = self.map_event_type(det.class_name)
if event_type in self.ignore_event_types:
return None
severity = DEFAULT_SEVERITY.get(event_type, SeverityLevel.INFO)
return CandidateEvent(
event_type=event_type,
severity=severity,
confidence=det.confidence,
detection=det,
source_id=source_id,
)
def map_event_type(self, class_name: str) -> EventType:
"""将 class_name 映射为 EventType (大小写不敏感)。"""
if not class_name:
return EventType.UNKNOWN
return self.class_to_event.get(class_name.lower().strip(), EventType.UNKNOWN)
__all__ = ["EventDecisionEngine", "DEFAULT_CLASS_TO_EVENT", "DEFAULT_SEVERITY"]

View File

@@ -0,0 +1,206 @@
"""预警规则引擎 (MVP-1 / P2)
YAML 驱动的预警规则引擎,对 ``CandidateEvent`` 列表应用规则,
满足条件的事件会被升级为 ``AlertEvent``。
规则 YAML 示例 (config/rules/fire.yaml)::
name: fire_critical
event_type: fire
enabled: true
min_confidence: 0.6
severity: critical # 覆盖默认严重性 (可选)
description: 检测到火焰,立即触发预警
规则条件支持:
- ``min_confidence``: 置信度阈值
- ``allowed_sources``: 允许的来源 (source_id 白名单None 表示不限制)
- ``required_labels``: 检测项 label 必须包含其中之一
- ``min_bbox_area``: 边界框最小面积
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
from models.event_schemas import (
AlertEvent,
CandidateEvent,
EventType,
SeverityLevel,
)
logger = logging.getLogger(__name__)
@dataclass
class AlertRule:
"""单条预警规则。"""
name: str
event_type: EventType
enabled: bool = True
min_confidence: float = 0.0
severity: Optional[SeverityLevel] = None
allowed_sources: Optional[List[str]] = None
required_labels: Optional[List[str]] = None
min_bbox_area: int = 0
description: str = ""
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AlertRule":
return cls(
name=str(data["name"]),
event_type=EventType(data["event_type"]),
enabled=bool(data.get("enabled", True)),
min_confidence=float(data.get("min_confidence", 0.0)),
severity=SeverityLevel(data["severity"]) if data.get("severity") else None,
allowed_sources=data.get("allowed_sources"),
required_labels=data.get("required_labels"),
min_bbox_area=int(data.get("min_bbox_area", 0)),
description=str(data.get("description", "")),
)
def matches(self, event: CandidateEvent) -> bool:
"""判断候选事件是否命中规则。"""
if not self.enabled:
return False
if event.event_type != self.event_type:
return False
if event.confidence < self.min_confidence:
return False
if self.allowed_sources and event.source_id not in self.allowed_sources:
return False
if self.required_labels:
labels = {event.detection.class_name, event.detection.label}
if not any(lbl in labels for lbl in self.required_labels):
return False
if self.min_bbox_area > 0 and event.detection.bbox.area < self.min_bbox_area:
return False
return True
@dataclass
class _RuleStats:
loaded: int = 0
enabled: int = 0
files: List[str] = field(default_factory=list)
class AlertRuleEngine:
"""预警规则引擎。
支持从单个 YAML 文件或目录批量加载规则。
"""
def __init__(self, rules: Optional[List[AlertRule]] = None) -> None:
self.rules: List[AlertRule] = list(rules or [])
self._stats = _RuleStats(
loaded=len(self.rules),
enabled=sum(1 for r in self.rules if r.enabled),
)
# ------------------------------------------------------------------
# 加载
# ------------------------------------------------------------------
@classmethod
def from_directory(cls, rules_dir: str | Path) -> "AlertRuleEngine":
"""从目录加载所有 ``*.yaml`` / ``*.yml`` 规则文件。"""
engine = cls()
engine.load_directory(rules_dir)
return engine
def load_directory(self, rules_dir: str | Path) -> int:
path = Path(rules_dir)
if not path.exists():
logger.warning("规则目录不存在: %s", path)
return 0
count = 0
for file in sorted(path.glob("*.y*ml")):
try:
count += self.load_file(file)
except Exception as exc: # noqa: BLE001
logger.error("加载规则文件失败 %s: %s", file, exc)
logger.info("规则引擎加载完成: 共 %d 条规则 (来自 %s)", count, path)
return count
def load_file(self, rule_file: str | Path) -> int:
path = Path(rule_file)
with path.open("r", encoding="utf-8") as fp:
data = yaml.safe_load(fp)
if data is None:
return 0
# 单条规则 dict 或 列表 或 {"rules": [...]} 三种格式都支持
if isinstance(data, dict) and "rules" in data:
rule_items = data["rules"]
elif isinstance(data, list):
rule_items = data
else:
rule_items = [data]
added = 0
for item in rule_items:
rule = AlertRule.from_dict(item)
self.rules.append(rule)
self._stats.loaded += 1
if rule.enabled:
self._stats.enabled += 1
added += 1
self._stats.files.append(str(path))
return added
# ------------------------------------------------------------------
# 评估
# ------------------------------------------------------------------
def evaluate(self, events: List[CandidateEvent]) -> List[AlertEvent]:
"""对一批候选事件执行规则评估,返回触发的预警事件。"""
alerts: List[AlertEvent] = []
for event in events:
for rule in self.rules:
if rule.matches(event):
alerts.append(self._build_alert(event, rule))
event.triggered_rules.append(rule.name)
break # 命中一条即可,避免重复
return alerts
@staticmethod
def _build_alert(event: CandidateEvent, rule: AlertRule) -> AlertEvent:
severity = rule.severity or event.severity
return AlertEvent(
event_type=event.event_type,
severity=severity,
confidence=event.confidence,
source_id=event.source_id,
detections=[event.detection],
rule_name=rule.name,
metadata={"description": rule.description},
)
# ------------------------------------------------------------------
# 自省
# ------------------------------------------------------------------
@property
def stats(self) -> Dict[str, Any]:
return {
"loaded": self._stats.loaded,
"enabled": self._stats.enabled,
"files": list(self._stats.files),
}
__all__ = ["AlertRule", "AlertRuleEngine"]

View File

@@ -0,0 +1,254 @@
"""帧缓冲区 (MVP-2 / D15)
基于 Ring Buffer 的帧缓冲,配合丢帧策略,避免多路 RTSP 流场景下
内存无限增长。
核心设计:
1. 固定容量的环形缓冲区,写满后自动覆盖最旧帧
2. 支持按策略丢帧: 最新帧优先 (实时性) / 均匀采样 (覆盖率)
3. 线程安全: 使用 asyncio.Lock 保护并发读写
4. 帧元数据: 每帧附带 stream_id / timestamp / frame_index
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import deque
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger(__name__)
class DropPolicy(str, Enum):
"""丢帧策略。"""
LATEST = "latest" # 保留最新帧,覆盖最旧帧 (默认,适合实时检测)
SAMPLE = "sample" # 均匀采样保留,丢弃中间帧 (适合回溯分析)
@dataclass
class FrameMeta:
"""帧元数据。"""
stream_id: str
frame_index: int
timestamp: float
width: int = 0
height: int = 0
@dataclass
class FrameItem:
"""缓冲区中的帧条目。"""
frame: np.ndarray
meta: FrameMeta
class FrameBuffer:
"""环形帧缓冲区。
Args:
capacity: 缓冲区最大帧数
drop_policy: 丢帧策略
max_memory_mb: 内存上限 (MB)超过时强制丢帧0 表示不限制
"""
def __init__(
self,
capacity: int = 300,
drop_policy: DropPolicy = DropPolicy.LATEST,
max_memory_mb: float = 0,
) -> None:
self.capacity = max(1, capacity)
self.drop_policy = drop_policy
self.max_memory_mb = max(0.0, max_memory_mb)
self._buffer: deque[FrameItem] = deque(maxlen=self.capacity)
self._lock = asyncio.Lock()
self._total_written: int = 0
self._total_dropped: int = 0
# ------------------------------------------------------------------
# 写入
# ------------------------------------------------------------------
async def write(
self,
frame: np.ndarray,
stream_id: str,
frame_index: int,
timestamp: Optional[float] = None,
) -> None:
"""写入一帧到缓冲区。
当缓冲区已满时,根据 ``drop_policy`` 决定丢弃策略。
"""
meta = FrameMeta(
stream_id=stream_id,
frame_index=frame_index,
timestamp=timestamp or time.time(),
width=frame.shape[1] if frame.ndim >= 2 else 0,
height=frame.shape[0] if frame.ndim >= 2 else 0,
)
item = FrameItem(frame=frame, meta=meta)
async with self._lock:
self._total_written += 1
if len(self._buffer) >= self.capacity:
self._apply_drop_policy(item)
else:
self._buffer.append(item)
# 内存上限检查
if self.max_memory_mb > 0:
self._enforce_memory_limit()
# ------------------------------------------------------------------
# 读取
# ------------------------------------------------------------------
async def read_latest(self) -> Optional[FrameItem]:
"""读取最新一帧 (不消费)。"""
async with self._lock:
if not self._buffer:
return None
return self._buffer[-1]
async def read_oldest(self) -> Optional[FrameItem]:
"""读取最旧一帧 (不消费)。"""
async with self._lock:
if not self._buffer:
return None
return self._buffer[0]
async def read_all(self) -> List[FrameItem]:
"""读取缓冲区所有帧 (快照,不消费)。"""
async with self._lock:
return list(self._buffer)
async def read_range(
self,
start_index: int = 0,
count: Optional[int] = None,
) -> List[FrameItem]:
"""读取指定范围的帧 (快照)。
Args:
start_index: 从缓冲区开头的偏移量
count: 读取帧数None 表示到末尾
"""
async with self._lock:
items = list(self._buffer)
if start_index >= len(items):
return []
end = len(items) if count is None else start_index + count
return items[start_index:end]
async def pop_latest(self) -> Optional[FrameItem]:
"""弹出最新一帧 (消费)。"""
async with self._lock:
if not self._buffer:
return None
return self._buffer.pop()
async def pop_oldest(self) -> Optional[FrameItem]:
"""弹出最旧一帧 (消费)。"""
async with self._lock:
if not self._buffer:
return None
return self._buffer.popleft()
# ------------------------------------------------------------------
# 状态
# ------------------------------------------------------------------
async def clear(self) -> None:
"""清空缓冲区。"""
async with self._lock:
self._buffer.clear()
@property
def size(self) -> int:
"""当前缓冲区帧数。"""
return len(self._buffer)
@property
def stats(self) -> Dict[str, Any]:
"""缓冲区统计信息。"""
return {
"size": len(self._buffer),
"capacity": self.capacity,
"total_written": self._total_written,
"total_dropped": self._total_dropped,
"drop_policy": self.drop_policy.value,
"usage_percent": round(len(self._buffer) / self.capacity * 100, 1),
}
def estimate_memory_mb(self) -> float:
"""估算当前缓冲区占用内存 (MB)。"""
if not self._buffer:
return 0.0
# 取第一帧估算单帧大小
sample = self._buffer[0].frame
frame_bytes = sample.nbytes if isinstance(sample, np.ndarray) else 0
return len(self._buffer) * frame_bytes / (1024 * 1024)
# ------------------------------------------------------------------
# 内部
# ------------------------------------------------------------------
def _apply_drop_policy(self, new_item: FrameItem) -> None:
"""缓冲区满时应用丢帧策略。"""
if self.drop_policy == DropPolicy.LATEST:
# 覆盖最旧帧 (deque maxlen 自动处理)
self._total_dropped += 1
self._buffer.append(new_item)
elif self.drop_policy == DropPolicy.SAMPLE:
# 均匀采样: 丢弃偶数位置的帧,腾出空间
sampled = deque(maxlen=self.capacity)
step = 2
for i, item in enumerate(self._buffer):
if i % step != 0:
self._total_dropped += 1
else:
sampled.append(item)
sampled.append(new_item)
self._buffer = sampled
def _enforce_memory_limit(self) -> None:
"""强制执行内存上限,超出时丢弃最旧帧。"""
while self.max_memory_mb > 0 and self._buffer:
current_mb = self.estimate_memory_mb()
if current_mb <= self.max_memory_mb:
break
self._buffer.popleft()
self._total_dropped += 1
logger.debug(
"FrameBuffer 内存超限 (%.1f > %.1f MB),丢弃最旧帧",
current_mb,
self.max_memory_mb,
)
__all__ = ["FrameBuffer", "FrameItem", "FrameMeta", "DropPolicy"]

View File

@@ -40,12 +40,21 @@ class ModelService:
self.model_configs = {
'fire_detection': {
'path': os.path.join(base_dir, 'models', 'fire_detection', 'best.pt'),
'type': 'yolov8',
'classes': ['Fire'],
'labels': {'Fire': '火焰'},
'size': '22MB',
'description': '基于YOLOv8的火焰检测模型',
'name': '火焰检测'
},
'smoke_detection': {
'path': os.path.join(base_dir, 'models', 'fire_detection', 'yolov10_fire_smoke_best.pt'),
'type': 'yolov10',
'classes': ['Fire', 'Smoke'],
'labels': {'Fire': '火焰', 'Smoke': '烟雾'},
'size': '61MB',
'description': '基于YOLOv10的火灾烟雾检测模型',
'name': '火灾检测'
'size': '33MB',
'description': '基于YOLOv10-M的火灾烟雾检测模型来自GitHub开源项目123K+图片训练)',
'name': '烟雾检测 (YOLOv10-M)'
},
'helmet_detection': {
'path': os.path.join(base_dir, 'models', 'helmet_detection', 'yolov8n.pt'),

View File

@@ -0,0 +1,334 @@
"""MQTT 预警发布服务 (MVP-2 / D16-D17)
封装 paho-mqtt 客户端,提供:
1. 异步友好的连接 / 断开接口
2. QoS 0/1/2 支持
3. 自动重连 (指数退避)
4. 发布失败队列重试
5. 状态监控与统计
设计原则:
- paho-mqtt 的回调运行在内部线程,对外暴露 async 接口
- 发布操作非阻塞,失败时入队由后台 Worker 重试
- 与 AlertPublisher 解耦,本类只负责传输层
"""
from __future__ import annotations
import asyncio
import json
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional
try: # noqa: SIM105
import paho.mqtt.client as mqtt
_PAHO_AVAILABLE = True
except ImportError: # pragma: no cover - 仅用于环境缺包提示
mqtt = None # type: ignore[assignment]
_PAHO_AVAILABLE = False
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 数据模型
# ---------------------------------------------------------------------------
@dataclass
class MQTTConfig:
"""MQTT 客户端配置。"""
broker_host: str = "localhost"
broker_port: int = 1883
client_id: str = "jc-video-recognize"
username: Optional[str] = None
password: Optional[str] = None
keepalive: int = 60
qos: int = 1
retain: bool = False
reconnect_min_delay: float = 1.0
reconnect_max_delay: float = 60.0
# TLS 暂不在 MVP 范围
use_tls: bool = False
@dataclass
class MQTTStats:
"""MQTT 服务统计。"""
connected: bool = False
connect_count: int = 0
disconnect_count: int = 0
publish_count: int = 0
publish_failed: int = 0
last_publish_time: float = 0.0
last_error: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"connected": self.connected,
"connect_count": self.connect_count,
"disconnect_count": self.disconnect_count,
"publish_count": self.publish_count,
"publish_failed": self.publish_failed,
"last_publish_time": self.last_publish_time,
"last_error": self.last_error,
}
# ---------------------------------------------------------------------------
# MQTTService
# ---------------------------------------------------------------------------
class MQTTService:
"""MQTT 客户端封装。
Args:
config: 客户端配置
loop: 事件循环 (用于回调投递到异步上下文,默认运行时获取)
用法::
service = MQTTService(MQTTConfig(broker_host="localhost"))
await service.connect()
await service.publish("video/alerts/cam-01", {"event": "fire"})
await service.disconnect()
"""
def __init__(
self,
config: Optional[MQTTConfig] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> None:
if not _PAHO_AVAILABLE:
raise RuntimeError(
"paho-mqtt 未安装,请执行: pip install paho-mqtt"
)
self.config = config or MQTTConfig()
self._loop = loop
self._stats = MQTTStats()
self._client: Optional["mqtt.Client"] = None
self._connected_event = threading.Event()
self._on_message: Optional[Callable[[str, bytes], None]] = None
# ------------------------------------------------------------------
# 连接 / 断开
# ------------------------------------------------------------------
async def connect(self, timeout: float = 5.0) -> bool:
"""连接到 MQTT broker。
Returns:
True 表示连接成功
"""
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._loop = None
self._client = mqtt.Client(
client_id=self.config.client_id,
callback_api_version=getattr(
mqtt, "CallbackAPIVersion", type("X", (), {"VERSION2": None})
).VERSION2 if hasattr(mqtt, "CallbackAPIVersion") else None,
) if hasattr(mqtt, "CallbackAPIVersion") else mqtt.Client(
client_id=self.config.client_id
)
if self.config.username:
self._client.username_pw_set(
self.config.username, self.config.password
)
# 自动重连
self._client.reconnect_delay_set(
min_delay=int(self.config.reconnect_min_delay),
max_delay=int(self.config.reconnect_max_delay),
)
self._client.on_connect = self._on_connect_cb
self._client.on_disconnect = self._on_disconnect_cb
self._client.on_publish = self._on_publish_cb
self._client.on_message = self._on_message_cb
self._connected_event.clear()
try:
self._client.connect_async(
self.config.broker_host,
self.config.broker_port,
keepalive=self.config.keepalive,
)
self._client.loop_start()
# 等待连接成功
connected = await asyncio.get_event_loop().run_in_executor(
None, self._connected_event.wait, timeout
)
if not connected:
self._stats.last_error = f"连接超时 ({timeout}s)"
logger.warning(
"MQTT 连接超时: %s:%d",
self.config.broker_host,
self.config.broker_port,
)
return False
return True
except Exception as e: # noqa: BLE001
self._stats.last_error = f"连接异常: {e}"
logger.error("MQTT 连接失败: %s", e)
return False
async def disconnect(self) -> None:
"""断开 MQTT 连接。"""
if self._client is None:
return
try:
self._client.loop_stop()
self._client.disconnect()
except Exception as e: # noqa: BLE001
logger.debug("MQTT 断开异常: %s", e)
finally:
self._stats.connected = False
self._connected_event.clear()
self._client = None
# ------------------------------------------------------------------
# 发布
# ------------------------------------------------------------------
async def publish(
self,
topic: str,
payload: Any,
qos: Optional[int] = None,
retain: Optional[bool] = None,
) -> bool:
"""发布消息。
Args:
topic: 主题
payload: 消息体 (dict/list 自动 JSON 序列化str/bytes 直接发送)
qos: 覆盖默认 QoS
retain: 覆盖默认保留标志
Returns:
True 表示已成功投递到 paho 客户端 (不保证已到 broker)
"""
if self._client is None or not self._stats.connected:
self._stats.publish_failed += 1
self._stats.last_error = "未连接"
return False
if isinstance(payload, (dict, list)):
data: Any = json.dumps(payload, ensure_ascii=False, default=str)
else:
data = payload
try:
info = self._client.publish(
topic,
payload=data,
qos=qos if qos is not None else self.config.qos,
retain=retain if retain is not None else self.config.retain,
)
# 检查返回码
if info.rc != 0:
self._stats.publish_failed += 1
self._stats.last_error = f"发布失败 rc={info.rc}"
return False
self._stats.publish_count += 1
self._stats.last_publish_time = time.time()
return True
except Exception as e: # noqa: BLE001
self._stats.publish_failed += 1
self._stats.last_error = f"发布异常: {e}"
logger.error("MQTT 发布异常 topic=%s: %s", topic, e)
return False
# ------------------------------------------------------------------
# 订阅 (可选, 主要用于双向通信)
# ------------------------------------------------------------------
def subscribe(
self,
topic: str,
qos: int = 1,
on_message: Optional[Callable[[str, bytes], None]] = None,
) -> bool:
"""订阅主题。"""
if self._client is None:
return False
if on_message:
self._on_message = on_message
try:
result, _ = self._client.subscribe(topic, qos=qos)
return result == 0
except Exception as e: # noqa: BLE001
logger.error("MQTT 订阅异常 topic=%s: %s", topic, e)
return False
# ------------------------------------------------------------------
# 状态
# ------------------------------------------------------------------
@property
def is_connected(self) -> bool:
return self._stats.connected
@property
def stats(self) -> Dict[str, Any]:
return self._stats.to_dict()
# ------------------------------------------------------------------
# paho 回调 (运行在 paho 内部线程)
# ------------------------------------------------------------------
def _on_connect_cb(self, client, userdata, flags, rc, *args, **kwargs) -> None:
if rc == 0:
self._stats.connected = True
self._stats.connect_count += 1
self._connected_event.set()
logger.info(
"MQTT 已连接: %s:%d",
self.config.broker_host,
self.config.broker_port,
)
else:
self._stats.connected = False
self._stats.last_error = f"连接失败 rc={rc}"
logger.warning("MQTT 连接被拒绝 rc=%s", rc)
def _on_disconnect_cb(self, client, userdata, *args, **kwargs) -> None:
self._stats.connected = False
self._stats.disconnect_count += 1
self._connected_event.clear()
rc = args[0] if args else 0
logger.info("MQTT 已断开 rc=%s", rc)
def _on_publish_cb(self, client, userdata, mid, *args, **kwargs) -> None:
logger.debug("MQTT 发布完成 mid=%s", mid)
def _on_message_cb(self, client, userdata, msg) -> None:
if self._on_message:
try:
self._on_message(msg.topic, msg.payload)
except Exception as e: # noqa: BLE001
logger.error("MQTT 消息回调异常: %s", e)
__all__ = ["MQTTService", "MQTTConfig", "MQTTStats"]

View File

@@ -0,0 +1,396 @@
"""RTSP 流接入服务 (MVP-2 / D11-D12)
负责单路 RTSP 流的连接、解码、自动重连和帧产出。
核心设计:
1. 基于 OpenCV VideoCapture 的 RTSP 接入,兼容主流 IP 摄像头
2. 后台线程解码帧,避免阻塞事件循环
3. 自动重连: 断线后按指数退避策略重试
4. 帧回调: 每解码一帧触发回调,由 StreamManager 分发到检测管道
5. 优雅关闭: stop() 等待解码线程退出,释放资源
使用方式::
service = RTSPService(
stream_id="cam-01",
rtsp_url="rtsp://admin:pass@192.168.1.100:554/stream",
on_frame=handle_frame,
)
await service.start()
...
await service.stop()
"""
from __future__ import annotations
import asyncio
import enum
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Coroutine, Dict, Optional
import cv2
import numpy as np
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 数据模型
# ---------------------------------------------------------------------------
class StreamStatus(str, enum.Enum):
"""流状态。"""
IDLE = "idle" # 未启动
CONNECTING = "connecting" # 连接中
CONNECTED = "connected" # 已连接,正在解码
RECONNECTING = "reconnecting" # 断线重连中
STOPPED = "stopped" # 已停止
ERROR = "error" # 不可恢复错误
@dataclass
class StreamConfig:
"""单路 RTSP 流配置。"""
stream_id: str
rtsp_url: str
# 解码参数
reconnect_attempts: int = 10 # 最大重连次数0 = 无限
reconnect_interval_base: float = 2.0 # 首次重连间隔 (秒)
reconnect_interval_max: float = 60.0 # 最大重连间隔 (秒)
reconnect_backoff_factor: float = 2.0 # 退避因子
# 帧采样
frame_skip: int = 0 # 每隔 N 帧取 1 帧0 = 每帧都取
# OpenCV 参数
buffer_size: int = 1 # FFmpeg 缓冲区大小 (越小延迟越低)
# 超时
read_timeout: float = 5.0 # 单帧读取超时 (秒)
# 检测配置
model_id: str = "fire_detection"
confidence: float = 0.5
iou: float = 0.45
@dataclass
class StreamInfo:
"""流运行时信息。"""
stream_id: str
status: StreamStatus = StreamStatus.IDLE
rtsp_url: str = ""
# 统计
frames_decoded: int = 0
frames_dropped: int = 0
reconnect_count: int = 0
last_frame_time: float = 0.0
fps: float = 0.0
# 时间
connected_at: float = 0.0
error_message: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"stream_id": self.stream_id,
"status": self.status.value,
"rtsp_url": self._mask_url(self.rtsp_url),
"frames_decoded": self.frames_decoded,
"frames_dropped": self.frames_dropped,
"reconnect_count": self.reconnect_count,
"fps": round(self.fps, 2),
"connected_at": self.connected_at,
"error_message": self.error_message,
}
@staticmethod
def _mask_url(url: str) -> str:
"""遮蔽 RTSP URL 中的密码。"""
if "@" not in url:
return url
try:
prefix, rest = url.split("://", 1)
creds_host = rest.split("@", 1)
if len(creds_host) == 2:
creds, host_path = creds_host
if ":" in creds:
user, _ = creds.split(":", 1)
return f"{prefix}://{user}:****@{host_path}"
except Exception:
pass
return url
# ---------------------------------------------------------------------------
# 帧回调类型
# ---------------------------------------------------------------------------
# on_frame(stream_id, frame, frame_index, timestamp) -> None
FrameCallback = Callable[[str, np.ndarray, int, float], Coroutine[Any, Any, None]]
# ---------------------------------------------------------------------------
# RTSPService
# ---------------------------------------------------------------------------
class RTSPService:
"""单路 RTSP 流接入服务。
在后台线程中执行 OpenCV 解码循环,通过 asyncio 事件循环
将帧投递到异步回调,不阻塞主事件循环。
"""
def __init__(
self,
stream_id: str,
rtsp_url: str,
on_frame: Optional[FrameCallback] = None,
config: Optional[StreamConfig] = None,
loop: Optional[asyncio.AbstractEventLoop] = None,
) -> None:
self.config = config or StreamConfig(
stream_id=stream_id, rtsp_url=rtsp_url
)
self.config.stream_id = stream_id
self.config.rtsp_url = rtsp_url
self._on_frame = on_frame
self._loop = loop
self._info = StreamInfo(
stream_id=stream_id,
rtsp_url=rtsp_url,
)
self._cap: Optional[cv2.VideoCapture] = None
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._frame_index: int = 0
# ------------------------------------------------------------------
# 生命周期
# ------------------------------------------------------------------
async def start(self) -> None:
"""启动 RTSP 流解码。"""
if self._info.status in (StreamStatus.CONNECTED, StreamStatus.CONNECTING):
logger.warning("RTSP 流 %s 已在运行中", self.config.stream_id)
return
if self._loop is None:
self._loop = asyncio.get_running_loop()
self._stop_event.clear()
self._frame_index = 0
self._info.status = StreamStatus.CONNECTING
self._thread = threading.Thread(
target=self._decode_loop,
name=f"rtsp-{self.config.stream_id}",
daemon=True,
)
self._thread.start()
logger.info("RTSP 流 %s 解码线程已启动", self.config.stream_id)
async def stop(self) -> None:
"""停止 RTSP 流解码,释放资源。"""
if self._info.status == StreamStatus.STOPPED:
return
self._stop_event.set()
self._info.status = StreamStatus.STOPPED
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5.0)
self._release_capture()
logger.info(
"RTSP 流 %s 已停止, 共解码 %d",
self.config.stream_id,
self._info.frames_decoded,
)
# ------------------------------------------------------------------
# 状态
# ------------------------------------------------------------------
@property
def info(self) -> StreamInfo:
return self._info
@property
def status(self) -> StreamStatus:
return self._info.status
@property
def is_running(self) -> bool:
return self._info.status in (
StreamStatus.CONNECTED,
StreamStatus.CONNECTING,
StreamStatus.RECONNECTING,
)
# ------------------------------------------------------------------
# 解码循环 (后台线程)
# ------------------------------------------------------------------
def _decode_loop(self) -> None:
"""后台线程: RTSP 解码 + 自动重连。"""
attempt = 0
while not self._stop_event.is_set():
# 尝试连接
connected = self._connect()
if not connected:
if self._stop_event.is_set():
break
attempt += 1
if (
self.config.reconnect_attempts > 0
and attempt > self.config.reconnect_attempts
):
self._info.status = StreamStatus.ERROR
self._info.error_message = (
f"超过最大重连次数 ({self.config.reconnect_attempts})"
)
logger.error(
"RTSP 流 %s %s",
self.config.stream_id,
self._info.error_message,
)
break
# 指数退避
interval = min(
self.config.reconnect_interval_base
* (self.config.reconnect_backoff_factor ** (attempt - 1)),
self.config.reconnect_interval_max,
)
self._info.status = StreamStatus.RECONNECTING
self._info.reconnect_count += 1
logger.warning(
"RTSP 流 %s 连接失败,第 %d 次重连,等待 %.1fs",
self.config.stream_id,
attempt,
interval,
)
self._stop_event.wait(timeout=interval)
continue
# 连接成功,重置计数
attempt = 0
self._info.status = StreamStatus.CONNECTED
self._info.connected_at = time.time()
logger.info("RTSP 流 %s 已连接: %s", self.config.stream_id, self.config.rtsp_url)
# 解码帧
self._read_frames()
# 如果 read_frames 退出且未被停止,说明断线了
if not self._stop_event.is_set():
self._release_capture()
self._info.status = StreamStatus.RECONNECTING
logger.warning("RTSP 流 %s 断线,准备重连", self.config.stream_id)
def _connect(self) -> bool:
"""尝试连接 RTSP 流。"""
try:
cap = cv2.VideoCapture(self.config.rtsp_url, cv2.CAP_FFMPEG)
# 降低缓冲以减少延迟
cap.set(cv2.CAP_PROP_BUFFERSIZE, self.config.buffer_size)
if not cap.isOpened():
return False
# 验证: 尝试读取一帧
ret, _ = cap.read()
if not ret:
cap.release()
return False
self._cap = cap
return True
except Exception as e:
logger.debug("RTSP 流 %s 连接异常: %s", self.config.stream_id, e)
return False
def _read_frames(self) -> None:
"""持续读取帧直到断线或停止信号。"""
if self._cap is None:
return
fps_counter_start = time.time()
fps_frame_count = 0
while not self._stop_event.is_set():
try:
ret, frame = self._cap.read()
except Exception as e:
logger.warning("RTSP 流 %s 读取异常: %s", self.config.stream_id, e)
break
if not ret or frame is None:
logger.warning("RTSP 流 %s 读取帧失败,可能断线", self.config.stream_id)
break
self._frame_index += 1
self._info.frames_decoded += 1
self._info.last_frame_time = time.time()
# 帧采样
if self.config.frame_skip > 0 and self._frame_index % (self.config.frame_skip + 1) != 1:
self._info.frames_dropped += 1
continue
# FPS 统计
fps_frame_count += 1
elapsed = time.time() - fps_counter_start
if elapsed >= 1.0:
self._info.fps = fps_frame_count / elapsed
fps_frame_count = 0
fps_counter_start = time.time()
# 通过事件循环投递帧到异步回调
if self._on_frame and self._loop and not self._loop.is_closed():
try:
asyncio.run_coroutine_threadsafe(
self._on_frame(
self.config.stream_id,
frame,
self._frame_index,
self._info.last_frame_time,
),
self._loop,
)
except RuntimeError as e:
logger.debug("投递帧回调失败 (事件循环可能已关闭): %s", e)
break
def _release_capture(self) -> None:
"""释放 VideoCapture 资源。"""
if self._cap is not None:
try:
self._cap.release()
except Exception:
pass
self._cap = None
__all__ = [
"RTSPService",
"StreamConfig",
"StreamInfo",
"StreamStatus",
"FrameCallback",
]

View File

@@ -0,0 +1,407 @@
"""多路流调度管理器 (MVP-2 / D13-D14)
负责管理多路 RTSP 流的生命周期、帧缓冲、状态监控和检测调度。
核心设计:
1. 统一管理多路 RTSPService 实例
2. 每路流对应一个 FrameBuffer解耦解码与检测
3. 检测调度: 轮询 / 事件驱动,按流优先级分配检测资源
4. 状态监控: 汇总所有流状态,提供健康检查接口
5. 优雅关闭: 按序停止所有流,等待资源释放
使用方式::
manager = StreamManager(model_service=model_service)
await manager.add_stream("cam-01", "rtsp://admin:pass@192.168.1.100:554/stream")
await manager.start_stream("cam-01")
...
info = manager.get_stream_info("cam-01")
...
await manager.stop_all()
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Callable, Coroutine, Dict, List, Optional
import numpy as np
from .frame_buffer import DropPolicy, FrameBuffer
from .rtsp_service import FrameCallback, RTSPService, StreamConfig, StreamStatus
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 流条目
# ---------------------------------------------------------------------------
class _StreamEntry:
"""管理器内部: 单路流的完整上下文。"""
__slots__ = ("service", "buffer", "config", "detect_task")
def __init__(
self,
service: RTSPService,
buffer: FrameBuffer,
config: StreamConfig,
) -> None:
self.service = service
self.buffer = buffer
self.config = config
self.detect_task: Optional[asyncio.Task] = None
# ---------------------------------------------------------------------------
# StreamManager
# ---------------------------------------------------------------------------
class StreamManager:
"""多路 RTSP 流调度管理器。
Args:
model_service: 模型服务实例,用于创建 DetectionService
buffer_capacity: 每路流帧缓冲区容量
buffer_drop_policy: 帧缓冲区丢帧策略
max_streams: 最大流数量
detect_interval: 检测轮询间隔 (秒)0 = 每帧检测
"""
def __init__(
self,
model_service: Any = None,
buffer_capacity: int = 300,
buffer_drop_policy: DropPolicy = DropPolicy.LATEST,
max_streams: int = 16,
detect_interval: float = 0.0,
) -> None:
self._model_service = model_service
self._buffer_capacity = buffer_capacity
self._buffer_drop_policy = buffer_drop_policy
self._max_streams = max(1, max_streams)
self._detect_interval = detect_interval
self._streams: Dict[str, _StreamEntry] = {}
self._lock = asyncio.Lock()
self._running = False
# 帧回调: 写入缓冲区 + 触发检测
self._on_detect: Optional[
Callable[[str, np.ndarray, int, float], Coroutine[Any, Any, None]]
] = None
# ------------------------------------------------------------------
# 流管理
# ------------------------------------------------------------------
async def add_stream(
self,
stream_id: str,
rtsp_url: str,
config: Optional[StreamConfig] = None,
) -> Dict[str, Any]:
"""添加一路 RTSP 流 (不立即启动)。
Returns:
操作结果 {"success": bool, "message": str}
"""
async with self._lock:
if stream_id in self._streams:
return {"success": False, "message": f"{stream_id} 已存在"}
if len(self._streams) >= self._max_streams:
return {
"success": False,
"message": f"已达最大流数量 ({self._max_streams})",
}
stream_config = config or StreamConfig(
stream_id=stream_id, rtsp_url=rtsp_url
)
stream_config.stream_id = stream_id
stream_config.rtsp_url = rtsp_url
buffer = FrameBuffer(
capacity=self._buffer_capacity,
drop_policy=self._buffer_drop_policy,
)
service = RTSPService(
stream_id=stream_id,
rtsp_url=rtsp_url,
on_frame=self._handle_frame,
config=stream_config,
)
self._streams[stream_id] = _StreamEntry(
service=service,
buffer=buffer,
config=stream_config,
)
logger.info("已添加 RTSP 流: %s (%s)", stream_id, rtsp_url)
return {"success": True, "message": f"{stream_id} 已添加"}
async def remove_stream(self, stream_id: str) -> Dict[str, Any]:
"""移除一路 RTSP 流 (先停止再移除)。"""
async with self._lock:
entry = self._streams.get(stream_id)
if entry is None:
return {"success": False, "message": f"{stream_id} 不存在"}
# 停止流
await entry.service.stop()
if entry.detect_task and not entry.detect_task.done():
entry.detect_task.cancel()
try:
await entry.detect_task
except asyncio.CancelledError:
pass
# 清空缓冲区
await entry.buffer.clear()
del self._streams[stream_id]
logger.info("已移除 RTSP 流: %s", stream_id)
return {"success": True, "message": f"{stream_id} 已移除"}
async def start_stream(self, stream_id: str) -> Dict[str, Any]:
"""启动一路 RTSP 流。"""
entry = self._streams.get(stream_id)
if entry is None:
return {"success": False, "message": f"{stream_id} 不存在"}
if entry.service.is_running:
return {"success": False, "message": f"{stream_id} 已在运行中"}
await entry.service.start()
# 启动检测轮询任务
if self._on_detect:
entry.detect_task = asyncio.create_task(
self._detect_loop(stream_id),
name=f"detect-{stream_id}",
)
logger.info("已启动 RTSP 流: %s", stream_id)
return {"success": True, "message": f"{stream_id} 已启动"}
async def stop_stream(self, stream_id: str) -> Dict[str, Any]:
"""停止单路 RTSP 流 (不移除)。"""
entry = self._streams.get(stream_id)
if entry is None:
return {"success": False, "message": f"{stream_id} 不存在"}
await entry.service.stop()
if entry.detect_task and not entry.detect_task.done():
entry.detect_task.cancel()
try:
await entry.detect_task
except asyncio.CancelledError:
pass
entry.detect_task = None
logger.info("已停止 RTSP 流: %s", stream_id)
return {"success": True, "message": f"{stream_id} 已停止"}
async def start_all(self) -> None:
"""启动所有已添加的流。"""
self._running = True
for stream_id in list(self._streams.keys()):
await self.start_stream(stream_id)
async def stop_all(self) -> None:
"""停止所有流。"""
self._running = False
for stream_id in list(self._streams.keys()):
await self.stop_stream(stream_id)
logger.info("所有 RTSP 流已停止")
# ------------------------------------------------------------------
# 检测调度
# ------------------------------------------------------------------
def set_detect_callback(
self,
callback: Callable[[str, np.ndarray, int, float], Coroutine[Any, Any, None]],
) -> None:
"""设置检测回调函数。
回调签名: ``callback(stream_id, frame, frame_index, timestamp)``
"""
self._on_detect = callback
async def _detect_loop(self, stream_id: str) -> None:
"""检测轮询循环: 从缓冲区取最新帧进行检测。"""
entry = self._streams.get(stream_id)
if entry is None:
return
while entry.service.is_running:
try:
item = await entry.buffer.read_latest()
if item is not None and self._on_detect:
await self._on_detect(
stream_id,
item.frame,
item.meta.frame_index,
item.meta.timestamp,
)
if self._detect_interval > 0:
await asyncio.sleep(self._detect_interval)
else:
await asyncio.sleep(0.03) # ~30fps
except asyncio.CancelledError:
break
except Exception as e:
logger.error("检测循环异常 (stream=%s): %s", stream_id, e)
await asyncio.sleep(1.0)
# ------------------------------------------------------------------
# 帧回调
# ------------------------------------------------------------------
async def _handle_frame(
self,
stream_id: str,
frame: np.ndarray,
frame_index: int,
timestamp: float,
) -> None:
"""RTSPService 帧回调: 写入对应流的缓冲区。"""
entry = self._streams.get(stream_id)
if entry is None:
return
await entry.buffer.write(
frame=frame,
stream_id=stream_id,
frame_index=frame_index,
timestamp=timestamp,
)
# ------------------------------------------------------------------
# 状态查询
# ------------------------------------------------------------------
def get_stream_info(self, stream_id: str) -> Optional[Dict[str, Any]]:
"""获取单路流状态信息。"""
entry = self._streams.get(stream_id)
if entry is None:
return None
info = entry.service.info.to_dict()
info["buffer"] = entry.buffer.stats
info["config"] = {
"model_id": entry.config.model_id,
"confidence": entry.config.confidence,
"iou": entry.config.iou,
"frame_skip": entry.config.frame_skip,
}
return info
def get_all_streams_info(self) -> List[Dict[str, Any]]:
"""获取所有流状态信息。"""
return [
info
for sid in self._streams
if (info := self.get_stream_info(sid)) is not None
]
@property
def stream_count(self) -> int:
return len(self._streams)
@property
def active_stream_count(self) -> int:
return sum(
1
for e in self._streams.values()
if e.service.status == StreamStatus.CONNECTED
)
def get_health(self) -> Dict[str, Any]:
"""获取管理器健康状态。"""
total = len(self._streams)
active = self.active_stream_count
reconnecting = sum(
1
for e in self._streams.values()
if e.service.status == StreamStatus.RECONNECTING
)
errored = sum(
1
for e in self._streams.values()
if e.service.status == StreamStatus.ERROR
)
return {
"total_streams": total,
"active_streams": active,
"reconnecting_streams": reconnecting,
"error_streams": errored,
"max_streams": self._max_streams,
"healthy": errored == 0,
}
# ------------------------------------------------------------------
# 流配置更新
# ------------------------------------------------------------------
async def update_stream_config(
self,
stream_id: str,
model_id: Optional[str] = None,
confidence: Optional[float] = None,
iou: Optional[float] = None,
frame_skip: Optional[int] = None,
) -> Dict[str, Any]:
"""更新流的检测配置 (运行时热更新)。"""
entry = self._streams.get(stream_id)
if entry is None:
return {"success": False, "message": f"{stream_id} 不存在"}
if model_id is not None:
entry.config.model_id = model_id
if confidence is not None:
entry.config.confidence = confidence
if iou is not None:
entry.config.iou = iou
if frame_skip is not None:
entry.config.frame_skip = frame_skip
logger.info(
"%s 配置已更新: model=%s, conf=%.2f, iou=%.2f, skip=%d",
stream_id,
entry.config.model_id,
entry.config.confidence,
entry.config.iou,
entry.config.frame_skip,
)
return {"success": True, "message": f"{stream_id} 配置已更新"}
__all__ = ["StreamManager"]

View File

@@ -0,0 +1,385 @@
"""目标跟踪服务 (MVP-2 / D19)
基于 ByteTrack 思想的简化跟踪器,纯 Python 实现,无需额外依赖。
核心算法 (ByteTrack 论文要点):
1. 按置信度将检测分为 high (>= high_thresh) / low (< high_thresh)
2. 先用 high 与现有 tracks 进行 IOU 匹配 (匈牙利算法 / 贪心)
3. 未匹配的 tracks 再与 low 检测匹配 (拯救低置信度真实目标)
4. 仍未匹配的 high 检测创建新 track
5. 未匹配的 tracks 进入 Lost 状态,超过 max_lost_frames 移除
简化点 (相对原版):
- 不使用 Kalman Filter只用上一帧 bbox 与当前检测 IOU 匹配
- 匹配算法用贪心 (按 IOU 降序) 替代匈牙利
- 不支持外观特征 (ReID)
集成方式::
tracker = ByteTracker()
for frame_detections in stream:
tracked = tracker.update(frame_detections)
# tracked 中每个 UnifiedDetection 会被填充 track_id
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Tuple
from models.event_schemas import BBox, UnifiedDetection
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Track 状态
# ---------------------------------------------------------------------------
class TrackState(str, Enum):
"""跟踪状态。"""
NEW = "new"
TRACKED = "tracked"
LOST = "lost"
REMOVED = "removed"
@dataclass
class Track:
"""单个目标跟踪。"""
track_id: int
bbox: BBox
confidence: float
class_name: str
state: TrackState = TrackState.NEW
age: int = 0 # 总存活帧数
lost_frames: int = 0 # 连续丢失帧数
last_update_frame: int = 0
# ---------------------------------------------------------------------------
# 工具函数: IOU
# ---------------------------------------------------------------------------
def compute_iou(b1: BBox, b2: BBox) -> float:
"""计算两个 BBox 的 IOU。"""
inter_x1 = max(b1.x1, b2.x1)
inter_y1 = max(b1.y1, b2.y1)
inter_x2 = min(b1.x2, b2.x2)
inter_y2 = min(b1.y2, b2.y2)
inter_w = max(0, inter_x2 - inter_x1)
inter_h = max(0, inter_y2 - inter_y1)
inter = inter_w * inter_h
if inter == 0:
return 0.0
area1 = b1.area
area2 = b2.area
union = area1 + area2 - inter
return inter / union if union > 0 else 0.0
def greedy_match(
cost_matrix: List[List[float]],
threshold: float,
) -> List[Tuple[int, int]]:
"""贪心匹配: 按 IOU 降序选择不冲突的最佳匹配。
Args:
cost_matrix: cost_matrix[i][j] = IOU 越大越好
threshold: 低于该阈值不匹配
Returns:
匹配对列表 [(row_idx, col_idx), ...]
"""
matches: List[Tuple[int, int]] = []
if not cost_matrix or not cost_matrix[0]:
return matches
candidates: List[Tuple[float, int, int]] = []
for i, row in enumerate(cost_matrix):
for j, score in enumerate(row):
if score >= threshold:
candidates.append((score, i, j))
candidates.sort(reverse=True)
used_rows: set = set()
used_cols: set = set()
for score, i, j in candidates:
if i in used_rows or j in used_cols:
continue
matches.append((i, j))
used_rows.add(i)
used_cols.add(j)
return matches
# ---------------------------------------------------------------------------
# ByteTracker
# ---------------------------------------------------------------------------
class ByteTracker:
"""简化版 ByteTrack 跟踪器。
Args:
track_thresh: 跟踪基础置信度阈值,低于此值不参与跟踪
high_thresh: 高置信度阈值,分桶用
match_thresh: IOU 匹配阈值
max_lost_frames: 跟踪丢失最大帧数,超过移除
min_box_area: 最小框面积,低于此值忽略
"""
def __init__(
self,
track_thresh: float = 0.5,
high_thresh: float = 0.6,
match_thresh: float = 0.8,
max_lost_frames: int = 30,
min_box_area: float = 10.0,
) -> None:
self.track_thresh = track_thresh
self.high_thresh = high_thresh
self.match_thresh = match_thresh
self.max_lost_frames = max_lost_frames
self.min_box_area = min_box_area
self._tracks: Dict[int, Track] = {}
self._next_id: int = 1
self._frame_count: int = 0
# ------------------------------------------------------------------
# 主入口
# ------------------------------------------------------------------
def update(
self,
detections: List[UnifiedDetection],
) -> List[UnifiedDetection]:
"""对一帧检测结果执行跟踪更新,填充 track_id。
Returns:
带 track_id 的检测结果 (顺序保持不变)
"""
self._frame_count += 1
# 1. 过滤无效检测 (面积太小)
valid: List[Tuple[int, UnifiedDetection]] = []
for idx, det in enumerate(detections):
if det.bbox.area < self.min_box_area:
continue
valid.append((idx, det))
# 2. 按置信度分桶
high_dets: List[Tuple[int, UnifiedDetection]] = []
low_dets: List[Tuple[int, UnifiedDetection]] = []
for idx, det in valid:
if det.confidence >= self.high_thresh:
high_dets.append((idx, det))
elif det.confidence >= self.track_thresh:
low_dets.append((idx, det))
# < track_thresh 直接忽略
# 3. 获取当前 tracked + lost 的 tracks 列表
active_tracks: List[Track] = [
t for t in self._tracks.values()
if t.state in (TrackState.TRACKED, TrackState.LOST, TrackState.NEW)
]
# 4. 第一轮: 与 high 检测匹配
matches_high, unmatched_tracks, unmatched_high = self._match(
active_tracks, [d for _, d in high_dets], self.match_thresh
)
for track_idx, det_idx in matches_high:
track = active_tracks[track_idx]
orig_idx, det = high_dets[det_idx]
self._update_track(track, det)
det.track_id = track.track_id
# 5. 第二轮: 未匹配 tracks 与 low 检测匹配
remaining_tracks = [active_tracks[i] for i in unmatched_tracks]
matches_low, still_unmatched, unmatched_low = self._match(
remaining_tracks, [d for _, d in low_dets], 0.5 # 低置信度用更宽松阈值
)
for track_idx, det_idx in matches_low:
track = remaining_tracks[track_idx]
orig_idx, det = low_dets[det_idx]
self._update_track(track, det)
det.track_id = track.track_id
# 6. 未匹配的 high 检测创建新 track
for det_idx in unmatched_high:
orig_idx, det = high_dets[det_idx]
new_track = self._create_track(det)
det.track_id = new_track.track_id
# 7. 未匹配的 tracks 增加 lost_frames
unmatched_original_tracks = [remaining_tracks[i] for i in still_unmatched]
for track in unmatched_original_tracks:
track.lost_frames += 1
track.state = TrackState.LOST
if track.lost_frames > self.max_lost_frames:
track.state = TrackState.REMOVED
# 8. 清理已移除的 tracks
self._tracks = {
tid: t for tid, t in self._tracks.items()
if t.state != TrackState.REMOVED
}
return detections
# ------------------------------------------------------------------
# 内部
# ------------------------------------------------------------------
def _match(
self,
tracks: List[Track],
detections: List[UnifiedDetection],
threshold: float,
) -> Tuple[List[Tuple[int, int]], List[int], List[int]]:
"""计算 tracks 与 detections 的 IOU 匹配。
Returns:
(matches, unmatched_track_indices, unmatched_det_indices)
"""
if not tracks or not detections:
return [], list(range(len(tracks))), list(range(len(detections)))
# 构建 IOU 矩阵
iou_matrix: List[List[float]] = []
for t in tracks:
row = [compute_iou(t.bbox, d.bbox) for d in detections]
iou_matrix.append(row)
matches = greedy_match(iou_matrix, threshold)
matched_tracks = {i for i, _ in matches}
matched_dets = {j for _, j in matches}
unmatched_tracks = [i for i in range(len(tracks)) if i not in matched_tracks]
unmatched_dets = [j for j in range(len(detections)) if j not in matched_dets]
return matches, unmatched_tracks, unmatched_dets
def _create_track(self, det: UnifiedDetection) -> Track:
track_id = self._next_id
self._next_id += 1
track = Track(
track_id=track_id,
bbox=det.bbox,
confidence=det.confidence,
class_name=det.class_name,
state=TrackState.TRACKED,
age=1,
lost_frames=0,
last_update_frame=self._frame_count,
)
self._tracks[track_id] = track
return track
def _update_track(self, track: Track, det: UnifiedDetection) -> None:
track.bbox = det.bbox
track.confidence = det.confidence
track.age += 1
track.lost_frames = 0
track.state = TrackState.TRACKED
track.last_update_frame = self._frame_count
# ------------------------------------------------------------------
# 状态
# ------------------------------------------------------------------
@property
def active_tracks(self) -> List[Track]:
return [t for t in self._tracks.values() if t.state == TrackState.TRACKED]
@property
def lost_tracks(self) -> List[Track]:
return [t for t in self._tracks.values() if t.state == TrackState.LOST]
def reset(self) -> None:
self._tracks.clear()
self._next_id = 1
self._frame_count = 0
# ---------------------------------------------------------------------------
# TrackingService: 多流跟踪器管理
# ---------------------------------------------------------------------------
class TrackingService:
"""多流目标跟踪服务。
为每个 source_id (摄像头/视频流) 维护独立的 ByteTracker 实例。
"""
def __init__(
self,
track_thresh: float = 0.5,
high_thresh: float = 0.6,
match_thresh: float = 0.8,
max_lost_frames: int = 30,
min_box_area: float = 10.0,
) -> None:
self.track_thresh = track_thresh
self.high_thresh = high_thresh
self.match_thresh = match_thresh
self.max_lost_frames = max_lost_frames
self.min_box_area = min_box_area
self._trackers: Dict[str, ByteTracker] = {}
def get_or_create(self, source_id: str) -> ByteTracker:
if source_id not in self._trackers:
self._trackers[source_id] = ByteTracker(
track_thresh=self.track_thresh,
high_thresh=self.high_thresh,
match_thresh=self.match_thresh,
max_lost_frames=self.max_lost_frames,
min_box_area=self.min_box_area,
)
return self._trackers[source_id]
def update(
self,
source_id: str,
detections: List[UnifiedDetection],
) -> List[UnifiedDetection]:
tracker = self.get_or_create(source_id)
return tracker.update(detections)
def reset(self, source_id: Optional[str] = None) -> None:
if source_id is None:
self._trackers.clear()
elif source_id in self._trackers:
self._trackers[source_id].reset()
def remove(self, source_id: str) -> None:
self._trackers.pop(source_id, None)
@property
def source_count(self) -> int:
return len(self._trackers)
__all__ = [
"ByteTracker",
"TrackingService",
"Track",
"TrackState",
"compute_iou",
"greedy_match",
]

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"clean": "rm -rf dist node_modules"
},
@@ -19,6 +21,9 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"vite": "^5.0.0"
"vite": "^5.0.0",
"vitest": "^3.1.0",
"@vue/test-utils": "^2.4.0",
"jsdom": "^26.0.0"
}
}

View File

@@ -1,60 +1,12 @@
<template>
<div id="app">
<el-container style="height: 100vh">
<el-header class="header">
<div class="header-content">
<h1 class="title">🎯 视频模型检测平台</h1>
<div class="header-info">
<el-tag type="success">运行中</el-tag>
</div>
</div>
</el-header>
<el-container>
<router-view />
</el-container>
</el-container>
<router-view />
</div>
</template>
<script setup>
</script>
<style scoped>
.header {
background: linear-gradient(135deg, #0F172A 0%, #1E293B 100%);
color: #F8FAFC;
padding: 0 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 60px;
}
.title {
font-size: 24px;
margin: 0;
font-weight: 600;
font-family: 'Fira Code', monospace;
letter-spacing: -0.5px;
background: linear-gradient(135deg, #22C55E 0%, #16A34A 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-info {
display: flex;
gap: 12px;
align-items: center;
}
</style>
<style>
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
@@ -66,18 +18,18 @@
body {
font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: #0F172A;
background: #020617;
color: #F8FAFC;
}
#app {
width: 100%;
height: 100%;
height: 100vh;
}
.el-main {
background: #0F172A;
padding: 20px;
background: #020617;
padding: 0;
}
::selection {
@@ -97,9 +49,28 @@ body {
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 5px;
transition: background 200ms ease;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
</style>
/* 全局 cursor pointer */
button,
[role="button"],
.el-button,
.el-menu-item,
.el-tag,
.el-pagination li {
cursor: pointer;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<!-- 此组件为纯逻辑组件不渲染 DOM -->
<!-- 预警桌面通知由 alertStore.addAlert() 内部的 ElNotification 处理 -->
<div class="alert-notification"></div>
</template>
<script setup>
/**
* 预警通知弹窗组件 (MVP-2 / D24)
*
* 功能:
* 1. 实时 ElNotification 弹窗 (由 alertStore.showDesktopNotification 触发)
* 2. 顶部横幅展示最近 N 条高优预警 (列表滚动)
* 3. 点击横幅跳转到预警列表
*
* 使用方式: 在 MainLayout.vue 中引入一次即可Store 自动管理弹窗。
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAlertStore } from '@/stores/alertStore'
const router = useRouter()
const alertStore = useAlertStore()
const topAlertVisible = ref(false)
const topAlertTimer = ref(null)
// 最近的活跃预警 (仅 critical/high)
const recentCritical = computed(() => alertStore.activeAlerts.slice(0, 5))
// 监听新预警 -> 显示顶部横幅
onMounted(() => {
// 如果 store 有未读的 critical 预警,显示横幅
showTopBanner()
})
// 每次 activeAlerts 变化时检查是否需要显示横幅
watchCriticalAlerts()
import { watch } from 'vue'
function watchCriticalAlerts() {
watch(
() => alertStore.unreadCount,
(newCount, oldCount) => {
if (newCount > oldCount && alertStore.activeAlerts.length > 0) {
showTopBanner()
}
}
)
}
function showTopBanner() {
if (recentCritical.value.length === 0) return
topAlertVisible.value = true
clearTopAlertTimer()
// 5 秒后自动隐藏
topAlertTimer.value = setTimeout(() => {
topAlertVisible.value = false
}, 5000)
}
function clearTopAlertTimer() {
if (topAlertTimer.value) {
clearTimeout(topAlertTimer.value)
topAlertTimer.value = null
}
}
function goToAlerts() {
topAlertVisible.value = false
alertStore.markAllAsRead()
router.push('/alerts')
}
onUnmounted(() => {
clearTopAlertTimer()
})
</script>
<style scoped>
/* 顶部横幅定位在 MainLayout 的 header 下方 */
.alert-notification {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
width: auto;
max-width: 600px;
}
</style>

View File

@@ -117,6 +117,36 @@
<div class="slider-value">{{ config.iou.toFixed(2) }}</div>
</el-form-item>
<!-- 复合检测开关仅对火灾检测模型显示 -->
<el-form-item v-if="isFireDetectionModel">
<template #label>
<span>复合火灾检测</span>
<el-tooltip placement="top" :show-after="200">
<template #content>
<div style="max-width: 300px; line-height: 1.6;">
<p><strong>复合火灾检测是什么</strong></p>
<p>同时检测火焰和烟雾提高火灾识别准确率</p>
<p style="margin: 8px 0;"><strong>检测内容</strong></p>
<ul style="margin: 8px 0; padding-left: 16px;">
<li>火焰检测识别明火区域</li>
<li>烟雾检测识别烟雾区域</li>
<li>综合判断根据两者结果评估火灾等级</li>
</ul>
<p><strong>建议火灾检测场景建议开启</strong></p>
</div>
</template>
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-switch
v-model="config.composite"
active-text="开启"
inactive-text="关闭"
:active-value="true"
:inactive-value="false"
/>
</el-form-item>
<!-- 算法配置仅对人员检测模型显示 -->
<AlgorithmConfig
v-model="config.algorithmConfig"
@@ -129,7 +159,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
import {
Setting,
Picture,
@@ -167,9 +197,15 @@ const config = ref({
model: props.defaultModel || (props.models.length > 0 ? props.models[0].id : ''),
confidence: 0.5,
iou: 0.45,
composite: false,
algorithmConfig: {}
})
// 判断当前是否是火灾检测模型
const isFireDetectionModel = computed(() => {
return config.value.model === 'fire_detection'
})
const formatConfidence = (value) => {
return `置信度: ${value.toFixed(2)}`
}
@@ -191,9 +227,16 @@ watch(configType, (newType) => {
})
// 监听配置变化
watch(() => [config.value.model, config.value.confidence, config.value.iou], () => {
watch(() => [config.value.model, config.value.confidence, config.value.iou, config.value.composite], () => {
emit('config-change', config.value)
}, { deep: true })
// 监听模型变化,如果不是火灾模型,关闭复合检测
watch(() => config.value.model, (newModel) => {
if (newModel !== 'fire_detection') {
config.value.composite = false
}
})
</script>
<style scoped>

View File

@@ -112,6 +112,21 @@
<div class="stat-label">检测数量</div>
<el-tag size="large" type="primary">{{ stats.total_detections }} </el-tag>
</div>
<!-- 复合检测统计 -->
<div v-if="stats.fire_count !== undefined" class="stat-item">
<div class="stat-label">火焰数量</div>
<el-tag size="large" type="danger">{{ stats.fire_count }} </el-tag>
</div>
<div v-if="stats.smoke_count !== undefined" class="stat-item">
<div class="stat-label">烟雾数量</div>
<el-tag size="large" type="warning">{{ stats.smoke_count }} </el-tag>
</div>
<div v-if="stats.suspected_fire !== undefined" class="stat-item">
<div class="stat-label">疑似火灾</div>
<el-tag size="large" :type="stats.suspected_fire ? 'danger' : 'success'">
{{ stats.suspected_fire_label || (stats.suspected_fire ? '是' : '否') }}
</el-tag>
</div>
<div class="stat-item">
<div class="stat-label">平均置信度</div>
<el-tag size="large" :type="getConfidenceType(stats.avg_confidence)">
@@ -310,6 +325,7 @@ const config = ref({
model: props.models.length > 0 ? props.models[0].id : 'fire_detection',
confidence: 0.5,
iou: 0.45,
composite: false,
algorithmConfig: {}
})
@@ -392,6 +408,11 @@ const uploadUrl = computed(() => {
iou: config.value.iou
})
// 添加复合检测参数(火灾检测模型时)
if (config.value.composite && config.value.model === 'fire_detection') {
params.append('composite', 'true')
}
if (config.value.algorithmConfig && Object.keys(config.value.algorithmConfig).length > 0) {
params.append('algorithm_config', JSON.stringify(config.value.algorithmConfig))
}

View File

@@ -232,6 +232,7 @@ const config = ref({
model: props.models.length > 0 ? props.models[0].id : 'fire_detection',
confidence: 0.5,
iou: 0.45,
composite: false,
algorithmConfig: {}
})
@@ -346,7 +347,8 @@ const startCamera = async () => {
config: {
model_id: config.value.model,
confidence: config.value.confidence,
iou: config.value.iou
iou: config.value.iou,
composite: config.value.composite
}
}
@@ -416,7 +418,8 @@ const updateCameraConfig = () => {
config: {
model_id: config.value.model,
confidence: config.value.confidence,
iou: config.value.iou
iou: config.value.iou,
composite: config.value.composite
}
}

View File

@@ -0,0 +1,299 @@
<template>
<el-container class="layout-root">
<!-- 侧边栏 -->
<el-aside class="layout-aside" :width="collapsed ? '64px' : '220px'">
<div class="logo-area" @click="$router.push('/')">
<div class="logo-icon">
<el-icon :size="22"><VideoCameraFilled /></el-icon>
</div>
<span v-if="!collapsed" class="logo-text">视频检测</span>
</div>
<el-menu
:default-active="activeRoute"
:collapse="collapsed"
:collapse-transition="false"
background-color="#0F172A"
text-color="#94A3B8"
active-text-color="#22C55E"
router
>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
>
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<!-- 顶部 -->
<el-header class="layout-header">
<div class="header-left">
<el-button
text
class="collapse-btn"
@click="collapsed = !collapsed"
:aria-label="collapsed ? '展开侧边栏' : '收起侧边栏'"
>
<el-icon :size="20">
<Fold v-if="!collapsed" />
<Expand v-else />
</el-icon>
</el-button>
<h1 class="title">{{ currentTitle }}</h1>
</div>
<div class="header-right">
<!-- 实时连接状态 -->
<div class="connection-badge" :class="alertStore.statusClass">
<span class="dot"></span>
<span class="label">{{ alertStore.statusLabel }}</span>
</div>
<!-- 预警铃铛 -->
<el-badge
:value="alertStore.unreadCount"
:hidden="alertStore.unreadCount === 0"
:max="99"
class="bell-badge"
>
<el-button
text
class="bell-btn"
@click="$router.push('/alerts')"
aria-label="查看预警列表"
>
<el-icon :size="20"><Bell /></el-icon>
</el-button>
</el-badge>
<el-tag type="success" effect="dark" round size="small">运行中</el-tag>
</div>
</el-header>
<!-- 路由出口 -->
<el-main class="layout-main">
<router-view v-slot="{ Component, route: viewRoute }">
<keep-alive>
<component :is="Component" v-if="viewRoute.meta.keepAlive" :key="viewRoute.name" />
</keep-alive>
<component :is="Component" v-if="!viewRoute.meta.keepAlive" :key="viewRoute.name" />
</router-view>
</el-main>
</el-container>
<!-- 全局预警弹窗 -->
<AlertNotification />
</el-container>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
VideoCameraFilled,
WarningFilled,
Bell,
Fold,
Expand
} from '@element-plus/icons-vue'
import { useAlertStore } from '@/stores/alertStore'
import { connectAlertSocket, disconnectAlertSocket } from '@/services/mqtt.client'
import AlertNotification from '@/components/AlertNotification.vue'
const collapsed = ref(false)
const route = useRoute()
const alertStore = useAlertStore()
const menuItems = [
{ path: '/', title: '模型检测', icon: VideoCameraFilled },
{ path: '/alerts', title: '预警列表', icon: WarningFilled }
]
const activeRoute = computed(() => route.path)
const currentTitle = computed(() => {
const item = menuItems.find((m) => m.path === route.path)
return item ? item.title : '视频模型检测平台'
})
onMounted(() => {
connectAlertSocket()
})
onUnmounted(() => {
disconnectAlertSocket()
})
</script>
<style scoped>
.layout-root {
height: 100vh;
background: #020617;
}
.layout-aside {
background: #0F172A;
border-right: 1px solid rgba(255, 255, 255, 0.06);
transition: width 200ms ease;
overflow: hidden;
}
.logo-area {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 16px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.logo-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, #22C55E 0%, #16A34A 100%);
color: #0F172A;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.logo-text {
font-size: 16px;
font-weight: 600;
color: #F8FAFC;
letter-spacing: -0.3px;
}
:deep(.el-menu) {
border-right: none;
}
:deep(.el-menu-item) {
height: 48px;
margin: 4px 8px;
border-radius: 8px;
transition: background 150ms ease, color 150ms ease;
}
:deep(.el-menu-item:hover) {
background: rgba(34, 197, 94, 0.08) !important;
color: #F8FAFC !important;
}
:deep(.el-menu-item.is-active) {
background: rgba(34, 197, 94, 0.15) !important;
}
.layout-header {
background: #0F172A;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
color: #94A3B8;
cursor: pointer;
}
.collapse-btn:hover {
color: #22C55E;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #F8FAFC;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.connection-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.connection-badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #64748B;
}
.connection-badge.connected .dot {
background: #22C55E;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
}
.connection-badge.connected .label {
color: #22C55E;
}
.connection-badge.connecting .dot {
background: #F59E0B;
animation: pulse 1.2s ease-in-out infinite;
}
.connection-badge.connecting .label {
color: #F59E0B;
}
.connection-badge.disconnected .dot {
background: #EF4444;
}
.connection-badge.disconnected .label {
color: #EF4444;
}
.bell-badge {
cursor: pointer;
}
.bell-btn {
color: #94A3B8;
cursor: pointer;
}
.bell-btn:hover {
color: #22C55E;
}
.layout-main {
background: #020617;
padding: 0;
overflow: auto;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>

View File

@@ -1,11 +1,26 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import AlertList from '@/views/AlertList.vue'
import Layout from '@/layouts/MainLayout.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
component: Layout,
children: [
{
path: '',
name: 'Home',
component: Home,
meta: { title: '模型检测', icon: 'VideoCamera', keepAlive: true }
},
{
path: '/alerts',
name: 'AlertList',
component: AlertList,
meta: { title: '预警列表', icon: 'WarningFilled', keepAlive: true }
}
]
}
]
@@ -14,4 +29,4 @@ const router = createRouter({
routes
})
export default router
export default router

View File

@@ -0,0 +1,158 @@
/**
* 预警 WebSocket 客户端 (MVP-2 / D23)
*
* 连接后端 /ws/alerts接收实时预警事件并推送到 Pinia Store。
*
* 提供:
* - 自动重连 (指数退避,最多重试 10 次)
* - 心跳 (每 30 秒 ping)
* - 过滤订阅 (按事件类型 / source_id)
* - 连接状态同步到 alertStore
*
* 注意: 当前架构中前端不直连 MQTT Broker而是通过后端 WebSocket
* 代理接收预警MQTT 仅用于后端与其他系统(如移动端)的互通。
*/
import { useAlertStore } from '@/stores/alertStore'
// ---- 配置 ----
const WS_BASE = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/ws/alerts`
const PING_INTERVAL = 30000 // 心跳间隔 (ms)
const RECONNECT_BASE = 2000 // 首次重连间隔 (ms)
const RECONNECT_MAX = 30000 // 最大重连间隔 (ms)
const MAX_RECONNECT_RETRIES = 10 // 最大重连次数
// ---- 内部状态 ----
let socket = null
let pingTimer = null
let reconnectTimer = null
let reconnectAttempts = 0
let filterConfig = null
/**
* 连接预警 WebSocket
* @param {Object} [filter] - 订阅过滤 { event_types?: string[], source_ids?: string[] }
*/
export function connectAlertSocket(filter) {
const store = useAlertStore()
// 避免重复连接
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
if (filter) {
sendSubscribe(filter)
}
return
}
filterConfig = filter || null
socket = new WebSocket(WS_BASE)
socket.onopen = () => {
store.setConnectionStatus('connected')
reconnectAttempts = 0
startPing()
// 发送订阅
if (filterConfig) {
sendSubscribe(filterConfig)
}
}
socket.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
if (message.type === 'alert') {
store.addAlert(message.data)
} else if (message.type === 'welcome') {
// 连接成功确认, 暂不处理
} else if (message.type === 'subscribed') {
// 订阅成功确认, 暂不处理
} else if (message.type === 'pong') {
// 心跳响应, 暂不处理
}
} catch (e) {
console.warn('[AlertWS] 消息解析失败:', e)
}
}
socket.onclose = (event) => {
store.setConnectionStatus('disconnected')
stopPing()
scheduleReconnect()
}
socket.onerror = () => {
// onclose 会自动触发, 不重复处理
}
}
/**
* 断开预警 WebSocket
*/
export function disconnectAlertSocket() {
stopPing()
clearTimeout(reconnectTimer)
reconnectAttempts = 0
if (socket) {
socket.onclose = null
socket.onerror = null
socket.close()
socket = null
}
useAlertStore().setConnectionStatus('disconnected')
}
/**
* 更新订阅过滤
*/
export function subscribeAlert(filter) {
filterConfig = filter || null
sendSubscribe(filter)
}
function sendSubscribe(filter) {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({
action: 'subscribe',
filter: filter || {}
}))
}
/**
* 心跳: 每 30s 发送 ping
*/
function startPing() {
stopPing()
pingTimer = setInterval(() => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ action: 'ping' }))
}
}, PING_INTERVAL)
}
function stopPing() {
if (pingTimer) {
clearInterval(pingTimer)
pingTimer = null
}
}
function scheduleReconnect() {
if (reconnectAttempts >= MAX_RECONNECT_RETRIES) return
const delay = Math.min(
RECONNECT_BASE * Math.pow(2, reconnectAttempts),
RECONNECT_MAX
)
useAlertStore().setConnectionStatus('connecting')
reconnectAttempts++
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
connectAlertSocket(filterConfig)
}, delay)
}

View File

@@ -0,0 +1,132 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ElNotification } from 'element-plus'
/**
* 预警状态管理 Store
*
* 管理实时预警集合、未读计数、连接状态。
* 预警数据来源: WebSocket (/ws/alerts) + 历史查询 (GET /api/alerts/history)
*/
export const useAlertStore = defineStore('alerts', () => {
// ---- 状态 ----
const alerts = ref([]) // 全部已知预警 (最新在前)
const unreadIds = ref(new Set()) // 未读 ID 集合
const connectionStatus = ref('disconnected') // connected / connecting / disconnected
const loading = ref(false)
const error = ref(null)
// ---- 计算 ----
const unreadCount = computed(() => unreadIds.value.size)
const statusLabel = computed(() => {
const map = {
connected: '已连接',
connecting: '连接中',
disconnected: '未连接'
}
return map[connectionStatus.value]
})
const statusClass = computed(() => connectionStatus.value)
const activeAlerts = computed(() =>
alerts.value.filter((a) => a.severity === 'critical' || a.severity === 'high')
)
// ---- 行为 ----
function setConnectionStatus(status) {
connectionStatus.value = status
}
/**
* 添加实时预警 (来自 WebSocket)
* - 重复的 alert_id 会被忽略
* - 新预警自动标记为未读
* - 根据严重级别弹出桌面通知
*/
function addAlert(alertData) {
if (!alertData || !alertData.alert_id) return
// 去重
if (alerts.value.some((a) => a.alert_id === alertData.alert_id)) return
alerts.value.unshift(alertData)
unreadIds.value.add(alertData.alert_id)
showDesktopNotification(alertData)
}
/**
* 批量加载历史预警 (来自 REST API)
*/
function setAlerts(list) {
alerts.value = list || []
// 历史预警不计入未读
}
function markAsRead(alertId) {
unreadIds.value.delete(alertId)
}
function markAllAsRead() {
unreadIds.value.clear()
}
function clearAlerts() {
alerts.value = []
unreadIds.value.clear()
}
function removeAlert(alertId) {
alerts.value = alerts.value.filter((a) => a.alert_id !== alertId)
unreadIds.value.delete(alertId)
}
// ---- 桌面通知 ----
function showDesktopNotification(alert) {
const severityLabels = {
critical: '严重',
high: '高危',
medium: '中等',
low: '低危',
info: '信息'
}
const label = severityLabels[alert.severity] || alert.severity
const typeName = alert.event_type || '未知事件'
ElNotification({
title: `[${label}] ${typeName}`,
message: alert.source_id
? `来源: ${alert.source_id} | 置信度: ${(alert.confidence * 100).toFixed(0)}%`
: `置信度: ${(alert.confidence * 100).toFixed(0)}%`,
type: alert.severity === 'critical' || alert.severity === 'high'
? 'warning'
: 'info',
duration: alert.severity === 'critical' ? 0 : 4500,
dangerouslyUseHTMLString: false,
onClick: () => {
markAsRead(alert.alert_id)
}
})
}
return {
alerts,
unreadIds,
connectionStatus,
loading,
error,
unreadCount,
statusLabel,
statusClass,
activeAlerts,
setConnectionStatus,
addAlert,
setAlerts,
markAsRead,
markAllAsRead,
clearAlerts,
removeAlert
}
})

View File

@@ -0,0 +1,546 @@
<template>
<div class="alert-list-page">
<!-- 顶部统计卡片 -->
<div class="stat-grid">
<div
v-for="stat in stats"
:key="stat.key"
class="stat-card"
:class="`stat-card--${stat.tone}`"
>
<div class="stat-icon">
<el-icon :size="20"><component :is="stat.icon" /></el-icon>
</div>
<div class="stat-content">
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-value">{{ stat.value }}</div>
</div>
</div>
</div>
<!-- 过滤工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-select
v-model="filter.severity"
placeholder="严重级别"
clearable
size="small"
class="filter-control"
@change="applyFilter"
>
<el-option label="严重" value="critical" />
<el-option label="高危" value="high" />
<el-option label="中等" value="medium" />
<el-option label="低危" value="low" />
<el-option label="信息" value="info" />
</el-select>
<el-select
v-model="filter.event_type"
placeholder="事件类型"
clearable
size="small"
class="filter-control"
@change="applyFilter"
>
<el-option label="火灾" value="fire" />
<el-option label="烟雾" value="smoke" />
<el-option label="抽烟" value="smoking" />
<el-option label="打架" value="fight" />
<el-option label="徘徊" value="loitering" />
<el-option label="违停" value="illegal_parking" />
<el-option label="入侵" value="intrusion" />
</el-select>
<el-input
v-model="filter.source_id"
placeholder="按摄像头ID搜索"
size="small"
clearable
class="filter-control"
@input="applyFilter"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="toolbar-right">
<el-button
size="small"
@click="handleMarkAllRead"
:disabled="alertStore.unreadCount === 0"
>
<el-icon><Check /></el-icon>
全部标为已读
</el-button>
<el-button
size="small"
type="danger"
plain
@click="handleClear"
:disabled="alertStore.alerts.length === 0"
>
<el-icon><Delete /></el-icon>
清空记录
</el-button>
</div>
</div>
<!-- 预警列表 -->
<div class="alert-list">
<el-empty
v-if="filteredAlerts.length === 0"
description="暂无预警记录"
class="empty-state"
/>
<div
v-for="alert in filteredAlerts"
:key="alert.alert_id"
class="alert-card"
:class="[
`severity-${alert.severity}`,
{ 'is-unread': alertStore.unreadIds.has(alert.alert_id) }
]"
@click="alertStore.markAsRead(alert.alert_id)"
>
<!-- 严重性条 -->
<div class="severity-bar"></div>
<div class="alert-body">
<div class="alert-head">
<div class="alert-title">
<el-tag
:type="severityType(alert.severity)"
effect="dark"
round
size="small"
>
{{ severityLabel(alert.severity) }}
</el-tag>
<span class="alert-event-type">{{ eventTypeLabel(alert.event_type) }}</span>
<span v-if="alertStore.unreadIds.has(alert.alert_id)" class="unread-dot"></span>
</div>
<div class="alert-time">{{ formatTime(alert.first_seen) }}</div>
</div>
<div class="alert-meta">
<div class="meta-item">
<el-icon><VideoCamera /></el-icon>
<span>{{ alert.source_id || '未知摄像头' }}</span>
</div>
<div class="meta-item">
<el-icon><DataLine /></el-icon>
<span>置信度 {{ (alert.confidence * 100).toFixed(1) }}%</span>
</div>
<div class="meta-item">
<el-icon><Refresh /></el-icon>
<span>触发 {{ alert.occurrence_count }} </span>
</div>
<div v-if="alert.rule_name" class="meta-item">
<el-icon><Setting /></el-icon>
<span>{{ alert.rule_name }}</span>
</div>
</div>
<div v-if="alert.detections && alert.detections.length > 0" class="alert-detections">
<span
v-for="(det, idx) in alert.detections.slice(0, 3)"
:key="det.detection_id || idx"
class="detection-chip"
>
{{ det.label || det.class_name }}
<span v-if="det.track_id" class="track-id">#{{ det.track_id }}</span>
</span>
</div>
</div>
<div class="alert-actions">
<el-button
text
type="danger"
size="small"
@click.stop="alertStore.removeAlert(alert.alert_id)"
aria-label="删除预警"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive } from 'vue'
import {
WarningFilled,
CircleCheckFilled,
Bell,
TrendCharts,
VideoCamera,
DataLine,
Refresh,
Setting,
Search,
Check,
Delete,
Close
} from '@element-plus/icons-vue'
import { useAlertStore } from '@/stores/alertStore'
const alertStore = useAlertStore()
const filter = reactive({
severity: '',
event_type: '',
source_id: ''
})
const filteredAlerts = computed(() => {
return alertStore.alerts.filter((a) => {
if (filter.severity && a.severity !== filter.severity) return false
if (filter.event_type && a.event_type !== filter.event_type) return false
if (filter.source_id && !(a.source_id || '').includes(filter.source_id)) return false
return true
})
})
const stats = computed(() => [
{
key: 'total',
label: '预警总数',
value: alertStore.alerts.length,
icon: Bell,
tone: 'neutral'
},
{
key: 'unread',
label: '未读',
value: alertStore.unreadCount,
icon: WarningFilled,
tone: 'warning'
},
{
key: 'critical',
label: '严重预警',
value: alertStore.alerts.filter((a) => a.severity === 'critical').length,
icon: TrendCharts,
tone: 'danger'
},
{
key: 'connected',
label: '订阅状态',
value: alertStore.statusLabel,
icon: CircleCheckFilled,
tone: alertStore.connectionStatus === 'connected' ? 'success' : 'neutral'
}
])
function severityType(severity) {
const map = {
critical: 'danger',
high: 'warning',
medium: 'primary',
low: 'info',
info: 'success'
}
return map[severity] || 'info'
}
function severityLabel(severity) {
const map = {
critical: '严重',
high: '高危',
medium: '中等',
low: '低危',
info: '信息'
}
return map[severity] || severity
}
function eventTypeLabel(type) {
const map = {
fire: '火灾',
smoke: '烟雾',
smoking: '抽烟',
fight: '打架斗殴',
loitering: '徘徊',
stationary: '滞留',
intrusion: '入侵',
illegal_parking: '违章停车',
vehicle: '车辆',
person: '人员',
unknown: '未知'
}
return map[type] || type
}
function formatTime(timestamp) {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
const now = Date.now()
const diff = (now - date.getTime()) / 1000
if (diff < 60) return `${Math.floor(diff)} 秒前`
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
return date.toLocaleString('zh-CN', { hour12: false })
}
function applyFilter() {
// 仅用作触发计算属性更新, 无需额外逻辑
}
function handleMarkAllRead() {
alertStore.markAllAsRead()
}
function handleClear() {
alertStore.clearAlerts()
}
onMounted(() => {
// 进入页面自动标记当前可见预警为已读,但保留未读计数标记给新预警
// 这里只清除红点,但不清除数据
})
</script>
<style scoped>
.alert-list-page {
padding: 24px;
min-height: 100%;
background: #020617;
}
/* ---- 统计卡片 ---- */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px;
border-radius: 12px;
background: #0F172A;
border: 1px solid rgba(255, 255, 255, 0.06);
transition: border-color 200ms ease, transform 200ms ease;
}
.stat-card:hover {
border-color: rgba(34, 197, 94, 0.3);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(148, 163, 184, 0.1);
color: #94A3B8;
flex-shrink: 0;
}
.stat-card--warning .stat-icon {
background: rgba(245, 158, 11, 0.12);
color: #F59E0B;
}
.stat-card--danger .stat-icon {
background: rgba(239, 68, 68, 0.12);
color: #EF4444;
}
.stat-card--success .stat-icon {
background: rgba(34, 197, 94, 0.12);
color: #22C55E;
}
.stat-label {
font-size: 12px;
color: #94A3B8;
margin-bottom: 4px;
}
.stat-value {
font-size: 22px;
font-weight: 600;
color: #F8FAFC;
font-family: 'Fira Code', monospace;
}
/* ---- 工具栏 ---- */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 12px 16px;
margin-bottom: 16px;
background: #0F172A;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
flex-wrap: wrap;
}
.toolbar-left {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.toolbar-right {
display: flex;
gap: 8px;
align-items: center;
}
.filter-control {
width: 180px;
}
/* ---- 预警卡片 ---- */
.alert-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.alert-card {
display: flex;
background: #0F172A;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: border-color 200ms ease, background 200ms ease;
}
.alert-card:hover {
border-color: rgba(34, 197, 94, 0.3);
background: #131E33;
}
.alert-card.is-unread {
border-color: rgba(34, 197, 94, 0.2);
}
.severity-bar {
width: 4px;
background: #64748B;
flex-shrink: 0;
}
.severity-critical .severity-bar { background: #EF4444; }
.severity-high .severity-bar { background: #F59E0B; }
.severity-medium .severity-bar { background: #3B82F6; }
.severity-low .severity-bar { background: #94A3B8; }
.severity-info .severity-bar { background: #22C55E; }
.alert-body {
flex: 1;
padding: 14px 16px;
min-width: 0;
}
.alert-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 12px;
flex-wrap: wrap;
}
.alert-title {
display: flex;
align-items: center;
gap: 10px;
}
.alert-event-type {
font-size: 15px;
font-weight: 600;
color: #F8FAFC;
}
.unread-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22C55E;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
}
.alert-time {
font-size: 12px;
color: #64748B;
font-family: 'Fira Code', monospace;
}
.alert-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 8px;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #94A3B8;
}
.meta-item .el-icon {
font-size: 14px;
}
.alert-detections {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.detection-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.16);
border-radius: 999px;
font-size: 12px;
color: #86EFAC;
}
.track-id {
font-family: 'Fira Code', monospace;
font-size: 11px;
color: #6EE7B7;
opacity: 0.8;
}
.alert-actions {
display: flex;
align-items: center;
padding: 0 10px;
}
.empty-state {
padding: 60px 0;
}
</style>

BIN
models/fight_detection/yolov8n.pt LFS Normal file

Binary file not shown.

Binary file not shown.