Compare commits
19 Commits
9be93340a7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 18cfc9b16a | |||
| 8b914aae8a | |||
| 4e0f724661 | |||
| c88dcfff17 | |||
| 2c135d5ebe | |||
| 8c2ea57119 | |||
| 535fa89e64 | |||
| 21829bcbae | |||
| bf12a29acd | |||
| 2fcaf57478 | |||
| 40fd3089a7 | |||
| 279bffbcde | |||
| 51279c00ab | |||
| 4b051f16be | |||
| 24c16de9a1 | |||
| 05d4a5edf6 | |||
| cb2a7dcca3 | |||
| 993586fdee | |||
| 360f98fe7a |
261
apps/server/api/alerts.py
Normal file
261
apps/server/api/alerts.py
Normal 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"]
|
||||||
200
apps/server/api/rtsp.py
Normal file
200
apps/server/api/rtsp.py
Normal 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"]
|
||||||
17
apps/server/config/rules/fight.yaml
Normal file
17
apps/server/config/rules/fight.yaml
Normal 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: 检测到高置信度打架行为,立即触发最高级别预警
|
||||||
18
apps/server/config/rules/fire.yaml
Normal file
18
apps/server/config/rules/fire.yaml
Normal 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: 检测到烟雾,可能伴随火情,触发高级预警
|
||||||
15
apps/server/config/rules/loitering.yaml
Normal file
15
apps/server/config/rules/loitering.yaml
Normal 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: 检测到人员长时间静止
|
||||||
9
apps/server/config/rules/smoking.yaml
Normal file
9
apps/server/config/rules/smoking.yaml
Normal 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: 检测到抽烟行为
|
||||||
9
apps/server/config/rules/vehicle.yaml
Normal file
9
apps/server/config/rules/vehicle.yaml
Normal 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: 检测到车辆违停行为
|
||||||
10
apps/server/core/__init__.py
Normal file
10
apps/server/core/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""核心基础模块 (配置、日志、异常等)。"""
|
||||||
|
|
||||||
|
from .settings import (
|
||||||
|
Settings,
|
||||||
|
get_settings,
|
||||||
|
SERVER_DIR,
|
||||||
|
PROJECT_ROOT,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["Settings", "get_settings", "SERVER_DIR", "PROJECT_ROOT"]
|
||||||
251
apps/server/core/settings.py
Normal file
251
apps/server/core/settings.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -9,6 +9,8 @@ import sys
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from api import detection, models
|
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.model_service import ModelService
|
||||||
from services.camera_service import CameraService
|
from services.camera_service import CameraService
|
||||||
|
|
||||||
@@ -64,12 +66,21 @@ async def lifespan(app: FastAPI):
|
|||||||
global camera_service
|
global camera_service
|
||||||
|
|
||||||
camera_service = CameraService(model_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
|
yield
|
||||||
|
|
||||||
# 关闭时清理资源
|
# 关闭时清理资源
|
||||||
logger.info("正在关闭服务,清理资源...")
|
logger.info("正在关闭服务,清理资源...")
|
||||||
if camera_service:
|
if camera_service:
|
||||||
await camera_service.stop()
|
await camera_service.stop()
|
||||||
|
await rtsp_manager.stop_all()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="视频模型检测平台",
|
title="视频模型检测平台",
|
||||||
|
|||||||
226
apps/server/models/event_schemas.py
Normal file
226
apps/server/models/event_schemas.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -3,6 +3,8 @@ fastapi==0.136.1
|
|||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
pydantic==2.10.6
|
pydantic==2.10.6
|
||||||
|
pydantic-settings==2.7.1
|
||||||
|
pyyaml==6.0.2
|
||||||
python-dotenv==1.1.0
|
python-dotenv==1.1.0
|
||||||
aiofiles==25.1.0
|
aiofiles==25.1.0
|
||||||
websockets==14.1
|
websockets==14.1
|
||||||
@@ -26,7 +28,7 @@ paddle2onnx==2.1.0
|
|||||||
# 数据处理
|
# 数据处理
|
||||||
pandas==2.3.3
|
pandas==2.3.3
|
||||||
scipy==1.15.2
|
scipy==1.15.2
|
||||||
scikit-image==0.26.2
|
scikit-image==0.26.0
|
||||||
|
|
||||||
# 图像和几何处理
|
# 图像和几何处理
|
||||||
imageio==2.37.3
|
imageio==2.37.3
|
||||||
@@ -35,7 +37,7 @@ shapely==2.1.0
|
|||||||
|
|
||||||
# 其他工具
|
# 其他工具
|
||||||
click==8.4.0
|
click==8.4.0
|
||||||
tqdm==4.69.2
|
tqdm==4.68.1
|
||||||
psutil==6.1.1
|
psutil==6.1.1
|
||||||
|
|
||||||
# 网络相关
|
# 网络相关
|
||||||
@@ -45,6 +47,8 @@ certifi==2026.5.20
|
|||||||
# 开发工具
|
# 开发工具
|
||||||
ipython==9.1.0
|
ipython==9.1.0
|
||||||
jedi==0.19.2
|
jedi==0.19.2
|
||||||
|
pytest==9.0.3
|
||||||
|
pytest-cov==7.0.0
|
||||||
|
|
||||||
# 特殊注意事项:
|
# 特殊注意事项:
|
||||||
# 1. imgaug==0.4.0 需要手动修复 numpy 2.0 兼容性问题:
|
# 1. imgaug==0.4.0 需要手动修复 numpy 2.0 兼容性问题:
|
||||||
|
|||||||
9
apps/server/services/adapters/__init__.py
Normal file
9
apps/server/services/adapters/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""检测结果适配器子包。
|
||||||
|
|
||||||
|
将各检测服务 (YOLO / Paddle / Action Docker 等) 的原始输出
|
||||||
|
适配为 ``models.event_schemas.DetectionResult``。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .detection_adapter import DetectionAdapter
|
||||||
|
|
||||||
|
__all__ = ["DetectionAdapter"]
|
||||||
212
apps/server/services/adapters/detection_adapter.py
Normal file
212
apps/server/services/adapters/detection_adapter.py
Normal 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"]
|
||||||
181
apps/server/services/alert_publisher.py
Normal file
181
apps/server/services/alert_publisher.py
Normal 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"]
|
||||||
@@ -10,6 +10,10 @@ from PIL import Image, ImageDraw, ImageFont
|
|||||||
import torch
|
import torch
|
||||||
|
|
||||||
from .loitering_service import get_loitering_service
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +30,18 @@ class DetectionService:
|
|||||||
# 初始化徘徊检测服务(懒加载,实际初始化在第一次使用时)
|
# 初始化徘徊检测服务(懒加载,实际初始化在第一次使用时)
|
||||||
self.loitering_service = get_loitering_service()
|
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(
|
async def detect_image(
|
||||||
self,
|
self,
|
||||||
image: np.ndarray,
|
image: np.ndarray,
|
||||||
@@ -133,6 +149,9 @@ class DetectionService:
|
|||||||
result_data, algorithm_config
|
result_data, algorithm_config
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 事件管道 (MVP-1): 决策 → 规则 → 聚合
|
||||||
|
result_data = self._apply_event_pipeline(result_data, model_id)
|
||||||
|
|
||||||
return result_data
|
return result_data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"图片检测失败: {e}")
|
logger.error(f"图片检测失败: {e}")
|
||||||
@@ -171,6 +190,7 @@ class DetectionService:
|
|||||||
if draw:
|
if draw:
|
||||||
frame = self.draw_detections(frame, detections, fps)
|
frame = self.draw_detections(frame, detections, fps)
|
||||||
|
|
||||||
|
# detect_fire_composite 已自带事件管道,此处无需再次调用
|
||||||
return frame, result_data
|
return frame, result_data
|
||||||
|
|
||||||
# 普通单模型检测
|
# 普通单模型检测
|
||||||
@@ -294,6 +314,9 @@ class DetectionService:
|
|||||||
if draw:
|
if draw:
|
||||||
frame = self.draw_detections(frame, detections, fps)
|
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
|
return frame, result_data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"帧检测失败: {e}")
|
logger.error(f"帧检测失败: {e}")
|
||||||
@@ -385,7 +408,7 @@ class DetectionService:
|
|||||||
processing_time = time.time() - start_time
|
processing_time = time.time() - start_time
|
||||||
avg_confidence = sum(d['confidence'] for d in all_detections) / len(all_detections) if all_detections else 0
|
avg_confidence = sum(d['confidence'] for d in all_detections) / len(all_detections) if all_detections else 0
|
||||||
|
|
||||||
return {
|
result_data = {
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': '复合火灾检测完成',
|
'message': '复合火灾检测完成',
|
||||||
'detections': all_detections,
|
'detections': all_detections,
|
||||||
@@ -401,6 +424,14 @@ class DetectionService:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 事件管道 (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:
|
except Exception as e:
|
||||||
logger.error(f"复合火灾检测失败: {e}")
|
logger.error(f"复合火灾检测失败: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
@@ -470,6 +501,49 @@ class DetectionService:
|
|||||||
|
|
||||||
return result_data
|
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(
|
def draw_detections(
|
||||||
self,
|
self,
|
||||||
frame: np.ndarray,
|
frame: np.ndarray,
|
||||||
|
|||||||
14
apps/server/services/event/__init__.py
Normal file
14
apps/server/services/event/__init__.py
Normal 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"]
|
||||||
247
apps/server/services/event/aggregator.py
Normal file
247
apps/server/services/event/aggregator.py
Normal 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"]
|
||||||
163
apps/server/services/event/decision_engine.py
Normal file
163
apps/server/services/event/decision_engine.py
Normal 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"]
|
||||||
206
apps/server/services/event/rule_engine.py
Normal file
206
apps/server/services/event/rule_engine.py
Normal 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"]
|
||||||
254
apps/server/services/frame_buffer.py
Normal file
254
apps/server/services/frame_buffer.py
Normal 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"]
|
||||||
334
apps/server/services/mqtt_service.py
Normal file
334
apps/server/services/mqtt_service.py
Normal 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"]
|
||||||
396
apps/server/services/rtsp_service.py
Normal file
396
apps/server/services/rtsp_service.py
Normal 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",
|
||||||
|
]
|
||||||
407
apps/server/services/stream_manager.py
Normal file
407
apps/server/services/stream_manager.py
Normal 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"]
|
||||||
385
apps/server/services/tracking_service.py
Normal file
385
apps/server/services/tracking_service.py
Normal 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",
|
||||||
|
]
|
||||||
1673
apps/web/package-lock.json
generated
1673
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"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",
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
"clean": "rm -rf dist node_modules"
|
"clean": "rm -rf dist node_modules"
|
||||||
},
|
},
|
||||||
@@ -19,6 +21,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.5.0",
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<el-container style="height: 100vh">
|
<router-view />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
</script>
|
</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>
|
<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');
|
@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 {
|
body {
|
||||||
font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
background: #0F172A;
|
background: #020617;
|
||||||
color: #F8FAFC;
|
color: #F8FAFC;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-main {
|
.el-main {
|
||||||
background: #0F172A;
|
background: #020617;
|
||||||
padding: 20px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -97,9 +49,28 @@ body {
|
|||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #334155;
|
background: #334155;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
transition: background 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #475569;
|
background: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全局 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>
|
</style>
|
||||||
93
apps/web/src/components/AlertNotification.vue
Normal file
93
apps/web/src/components/AlertNotification.vue
Normal 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>
|
||||||
299
apps/web/src/layouts/MainLayout.vue
Normal file
299
apps/web/src/layouts/MainLayout.vue
Normal 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>
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import Home from '@/views/Home.vue'
|
import Home from '@/views/Home.vue'
|
||||||
|
import AlertList from '@/views/AlertList.vue'
|
||||||
|
import Layout from '@/layouts/MainLayout.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Home',
|
component: Layout,
|
||||||
component: Home
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'Home',
|
||||||
|
component: Home,
|
||||||
|
meta: { title: '模型检测', icon: 'VideoCamera', keepAlive: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/alerts',
|
||||||
|
name: 'AlertList',
|
||||||
|
component: AlertList,
|
||||||
|
meta: { title: '预警列表', icon: 'WarningFilled', keepAlive: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
158
apps/web/src/services/mqtt.client.js
Normal file
158
apps/web/src/services/mqtt.client.js
Normal 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)
|
||||||
|
}
|
||||||
132
apps/web/src/stores/alertStore.js
Normal file
132
apps/web/src/stores/alertStore.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
546
apps/web/src/views/AlertList.vue
Normal file
546
apps/web/src/views/AlertList.vue
Normal 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
BIN
models/fight_detection/yolov8n.pt
LFS
Normal file
Binary file not shown.
Reference in New Issue
Block a user