Compare commits
19
Commits
bf7fb83336
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a37b110a7f | ||
|
|
7396889f32 | ||
|
|
0158b41712 | ||
|
|
1409734c8a | ||
|
|
86ac65c6d8 | ||
|
|
66c0eb27c8 | ||
|
|
fe2b569046 | ||
|
|
b097983f1b | ||
|
|
f5b1fe3a0a | ||
|
|
36dc83b36f | ||
|
|
dce7014774 | ||
|
|
7e59455361 | ||
|
|
01e6feb46e | ||
|
|
0eb30a875b | ||
|
|
a13d0c46af | ||
|
|
435fe3073f | ||
|
|
ab599486ef | ||
|
|
eb75045d7c | ||
|
|
bfd68026ca |
@@ -0,0 +1,63 @@
|
|||||||
|
# ============================================
|
||||||
|
# JC Video Recognize - 服务端配置模板
|
||||||
|
# ============================================
|
||||||
|
# 使用方法: 复制本文件为 .env 并填入真实配置
|
||||||
|
# cp .env.example .env
|
||||||
|
# ⚠️ 严禁将真实 API Key / 密码 提交到 Git!
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# --- 服务器 ---
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8000
|
||||||
|
|
||||||
|
# --- 检测 ---
|
||||||
|
DETECTION_DEFAULT_CONFIDENCE=0.5
|
||||||
|
DETECTION_MIN_CONFIDENCE=0.3
|
||||||
|
|
||||||
|
# --- LLM 二次判断 (MVP-3) ---
|
||||||
|
# 支持的 provider: openai (兼容协议) / mock (离线测试)
|
||||||
|
# 火山方舟接入示例:
|
||||||
|
# LLM_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
# LLM_MODEL=<你在火山方舟控制台开通的 Endpoint ID>
|
||||||
|
LLM_ENABLED=false
|
||||||
|
LLM_PROVIDER=mock
|
||||||
|
LLM_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
LLM_API_KEY=your-api-key-here
|
||||||
|
LLM_MODEL=doubao-vision-pro-32k
|
||||||
|
LLM_TIMEOUT=20.0
|
||||||
|
LLM_MAX_RETRIES=2
|
||||||
|
LLM_MAX_TOKENS=512
|
||||||
|
LLM_TEMPERATURE=0.0
|
||||||
|
LLM_MAX_CONCURRENCY=2
|
||||||
|
LLM_IMAGE_MAX_SIDE=768
|
||||||
|
|
||||||
|
# --- LLM 触发器 (D26-D28) ---
|
||||||
|
# [生产模式] 推荐: HITS=3, CONFIDENCE=0.55, COOLDOWN=20.0
|
||||||
|
# [测试模式] 调低阈值: HITS=1, CONFIDENCE=0.1, COOLDOWN=2.0
|
||||||
|
LLM_TRIGGER_ENABLED=true
|
||||||
|
LLM_TRIGGER_WINDOW_SECONDS=3.0
|
||||||
|
LLM_TRIGGER_MIN_CONSECUTIVE_HITS=3
|
||||||
|
LLM_TRIGGER_MIN_AVG_CONFIDENCE=0.55
|
||||||
|
LLM_TRIGGER_COOLDOWN_SECONDS=20.0
|
||||||
|
|
||||||
|
# --- 融合策略 (D30) ---
|
||||||
|
# strategy: weighted / conservative / llm_priority
|
||||||
|
FUSION_STRATEGY=weighted
|
||||||
|
FUSION_YOLO_WEIGHT=0.4
|
||||||
|
FUSION_LLM_WEIGHT=0.6
|
||||||
|
FUSION_SUPPRESS_ON_LLM_NEGATIVE=true
|
||||||
|
FUSION_FALLBACK_TO_YOLO=true
|
||||||
|
|
||||||
|
# --- 成本追踪 / 降级 (D34) ---
|
||||||
|
# daily_budget_usd=0.0 表示不限制预算
|
||||||
|
LLM_COST_DAILY_BUDGET_USD=0.0
|
||||||
|
LLM_COST_ERROR_RATE_THRESHOLD=0.5
|
||||||
|
LLM_COST_COOLDOWN_SECONDS=60.0
|
||||||
|
|
||||||
|
# --- 事件引擎 ---
|
||||||
|
EVENT_DEDUP_WINDOW_SECONDS=30.0
|
||||||
|
EVENT_RULES_DIR=config/rules
|
||||||
|
EVENT_MAX_ACTIVE_EVENTS=1000
|
||||||
|
|
||||||
|
# --- MQTT (可选) ---
|
||||||
|
MQTT_ENABLED=false
|
||||||
@@ -14,6 +14,36 @@ router = APIRouter()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DetectionService 单例 (MVP-3 / D33)
|
||||||
|
#
|
||||||
|
# 之前每次请求都 new 一个 DetectionService,会导致 rule_engine / aggregator 状态
|
||||||
|
# 不共享,规则配置 API 修改后无法同步生效。改造为按 model_service 缓存的单例。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_detection_service = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_detection_service(model_service=None):
|
||||||
|
"""获取共享的 DetectionService 单例。
|
||||||
|
|
||||||
|
若尚未初始化且未传入 ``model_service``,将抛出 RuntimeError。
|
||||||
|
"""
|
||||||
|
|
||||||
|
global _detection_service
|
||||||
|
if _detection_service is None:
|
||||||
|
if model_service is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DetectionService 尚未初始化,请先在 main.lifespan 中调用 "
|
||||||
|
"get_detection_service(model_service)"
|
||||||
|
)
|
||||||
|
from services.detection_service import DetectionService
|
||||||
|
_detection_service = DetectionService(model_service)
|
||||||
|
logger.info("DetectionService 单例已初始化")
|
||||||
|
return _detection_service
|
||||||
|
|
||||||
|
|
||||||
@router.post("/detect/image", response_model=ImageDetectionResult)
|
@router.post("/detect/image", response_model=ImageDetectionResult)
|
||||||
async def detect_image(
|
async def detect_image(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -42,7 +72,7 @@ async def detect_image(
|
|||||||
from main import model_service
|
from main import model_service
|
||||||
from services.detection_service import DetectionService
|
from services.detection_service import DetectionService
|
||||||
|
|
||||||
detection_service = DetectionService(model_service)
|
detection_service = get_detection_service(model_service)
|
||||||
|
|
||||||
# 解析算法配置
|
# 解析算法配置
|
||||||
algo_config = None
|
algo_config = None
|
||||||
@@ -100,7 +130,8 @@ async def detect_image(
|
|||||||
"detections": result['detections'],
|
"detections": result['detections'],
|
||||||
"image_base64": img_base64,
|
"image_base64": img_base64,
|
||||||
"stats": result['stats'],
|
"stats": result['stats'],
|
||||||
"alerts": result.get('alerts', []),
|
"alerts": result.get('alerts', []) or result.get('alert_events', []),
|
||||||
|
"alert_events": result.get('alert_events', []),
|
||||||
"behavior_stats": result.get('behavior_stats', {})
|
"behavior_stats": result.get('behavior_stats', {})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -309,7 +340,10 @@ async def detect_video(
|
|||||||
'frame_index': frame_index,
|
'frame_index': frame_index,
|
||||||
'timestamp': round(frame_index / fps, 2) if fps > 0 else 0,
|
'timestamp': round(frame_index / fps, 2) if fps > 0 else 0,
|
||||||
'detections': result_data['detections'],
|
'detections': result_data['detections'],
|
||||||
'detection_count': len(result_data['detections'])
|
'detection_count': len(result_data['detections']),
|
||||||
|
# MVP-3: 携带 LLM 管道结果供前端展示
|
||||||
|
'llm_results': result_data.get('llm_results', []),
|
||||||
|
'alert_events': result_data.get('alert_events', []),
|
||||||
})
|
})
|
||||||
|
|
||||||
# 提取关键帧截图(最多保留 20 张,防止响应过大)
|
# 提取关键帧截图(最多保留 20 张,防止响应过大)
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""LLM 状态/成本/控制 API (MVP-3 / D34)
|
||||||
|
|
||||||
|
提供给前端的 RESTful 接口:
|
||||||
|
|
||||||
|
- ``GET /llm/status`` 返回 LLM 是否启用、provider、熔断状态、降级原因
|
||||||
|
- ``GET /llm/cost`` 返回今日费用 / 预算 / token 用量
|
||||||
|
- ``GET /llm/cost/history`` 返回最近 N 天的日级统计
|
||||||
|
- ``GET /llm/cost/records`` 返回最近 N 次调用明细
|
||||||
|
- ``POST /llm/disable`` 手动禁用 LLM 调用 (运维)
|
||||||
|
- ``POST /llm/enable`` 手动重新启用
|
||||||
|
- ``POST /llm/reset`` 重置成本统计 + 熔断状态
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
|
||||||
|
- ``llm_service`` 与 ``cost_tracker`` 都通过 ``main.py`` 在 lifespan 中注入,
|
||||||
|
此处仅暴露读取接口
|
||||||
|
- 前端的"规则配置 / LLM 状态面板"通过本接口拿到实时数据
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from core.settings import get_settings
|
||||||
|
from services.llm_analysis_service import LLMAnalysisService
|
||||||
|
from services.llm_cost_tracker import LLMCostTracker
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/llm", tags=["LLM 二次判断"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 全局注入 (由 main.py 调用)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_llm_service: Optional[LLMAnalysisService] = None
|
||||||
|
_cost_tracker: Optional[LLMCostTracker] = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_llm_api(
|
||||||
|
llm_service: Optional[LLMAnalysisService],
|
||||||
|
cost_tracker: Optional[LLMCostTracker],
|
||||||
|
) -> None:
|
||||||
|
"""由 main.py 在启动阶段注入服务实例。"""
|
||||||
|
|
||||||
|
global _llm_service, _cost_tracker
|
||||||
|
_llm_service = llm_service
|
||||||
|
_cost_tracker = cost_tracker
|
||||||
|
logger.info(
|
||||||
|
"LLM API 已初始化: service=%s tracker=%s",
|
||||||
|
bool(llm_service),
|
||||||
|
bool(cost_tracker),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_tracker() -> LLMCostTracker:
|
||||||
|
if _cost_tracker is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="LLMCostTracker 未初始化"
|
||||||
|
)
|
||||||
|
return _cost_tracker
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 响应模型
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LLMStatusResponse(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
api_base: Optional[str] = None
|
||||||
|
triggers: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
fusion: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
cost: Dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API 路由
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=LLMStatusResponse)
|
||||||
|
async def get_llm_status() -> LLMStatusResponse:
|
||||||
|
"""获取 LLM 当前运行状态 (供前端 LLM 状态面板)。"""
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
llm_cfg = settings.llm
|
||||||
|
trigger_cfg = settings.llm_trigger
|
||||||
|
fusion_cfg = settings.fusion
|
||||||
|
|
||||||
|
cost_data: Dict[str, Any]
|
||||||
|
if _cost_tracker is not None:
|
||||||
|
cost_data = _cost_tracker.stats()
|
||||||
|
else:
|
||||||
|
cost_data = {"enabled": llm_cfg.enabled, "circuit_state": "n/a"}
|
||||||
|
|
||||||
|
provider_name = llm_cfg.provider
|
||||||
|
if _llm_service is not None:
|
||||||
|
provider_name = getattr(_llm_service.provider, "name", provider_name)
|
||||||
|
|
||||||
|
return LLMStatusResponse(
|
||||||
|
enabled=llm_cfg.enabled,
|
||||||
|
provider=provider_name,
|
||||||
|
model=llm_cfg.model,
|
||||||
|
api_base=llm_cfg.api_base,
|
||||||
|
triggers={
|
||||||
|
"enabled": trigger_cfg.enabled,
|
||||||
|
"window_seconds": trigger_cfg.window_seconds,
|
||||||
|
"min_consecutive_hits": trigger_cfg.min_consecutive_hits,
|
||||||
|
"min_avg_confidence": trigger_cfg.min_avg_confidence,
|
||||||
|
"cooldown_seconds": trigger_cfg.cooldown_seconds,
|
||||||
|
"severity_bypass": trigger_cfg.severity_bypass,
|
||||||
|
},
|
||||||
|
fusion={
|
||||||
|
"strategy": fusion_cfg.strategy,
|
||||||
|
"yolo_weight": fusion_cfg.yolo_weight,
|
||||||
|
"llm_weight": fusion_cfg.llm_weight,
|
||||||
|
"suppress_on_llm_negative": fusion_cfg.suppress_on_llm_negative,
|
||||||
|
"fallback_to_yolo": fusion_cfg.fallback_to_yolo,
|
||||||
|
},
|
||||||
|
cost=cost_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cost")
|
||||||
|
async def get_llm_cost() -> Dict[str, Any]:
|
||||||
|
"""获取今日 LLM 成本概览。"""
|
||||||
|
|
||||||
|
return _require_tracker().stats()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cost/history")
|
||||||
|
async def get_llm_cost_history(
|
||||||
|
days: int = Query(default=7, ge=1, le=30),
|
||||||
|
) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
"""最近 N 天的日级成本/调用统计。"""
|
||||||
|
|
||||||
|
summary = _require_tracker().daily_summary()
|
||||||
|
return {"days": summary[-days:]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cost/records")
|
||||||
|
async def get_llm_cost_records(
|
||||||
|
limit: int = Query(default=20, ge=1, le=200),
|
||||||
|
) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
"""最近 N 次 LLM 调用明细。"""
|
||||||
|
|
||||||
|
return {"records": _require_tracker().recent_records(limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disable")
|
||||||
|
async def disable_llm() -> Dict[str, Any]:
|
||||||
|
"""手动禁用 LLM 调用。"""
|
||||||
|
|
||||||
|
tracker = _require_tracker()
|
||||||
|
tracker.disable()
|
||||||
|
return {"success": True, "enabled": tracker.is_enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enable")
|
||||||
|
async def enable_llm() -> Dict[str, Any]:
|
||||||
|
"""手动重新启用 LLM 调用 (重置熔断)。"""
|
||||||
|
|
||||||
|
tracker = _require_tracker()
|
||||||
|
tracker.enable()
|
||||||
|
return {"success": True, "enabled": tracker.is_enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset")
|
||||||
|
async def reset_llm() -> Dict[str, Any]:
|
||||||
|
"""重置成本统计与熔断状态。"""
|
||||||
|
|
||||||
|
tracker = _require_tracker()
|
||||||
|
tracker.reset()
|
||||||
|
return {"success": True, "stats": tracker.stats()}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router", "init_llm_api"]
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""规则配置 API (MVP-3 / D33 配套后端)
|
||||||
|
|
||||||
|
提供 YAML 规则文件的增删改查接口,供前端 ``RuleConfiguration.vue`` 调用。
|
||||||
|
|
||||||
|
接口列表:
|
||||||
|
|
||||||
|
- ``GET /rules`` 列出所有规则 (按文件分组)
|
||||||
|
- ``GET /rules/{name}`` 获取单条规则详情
|
||||||
|
- ``POST /rules`` 新增规则
|
||||||
|
- ``PUT /rules/{name}`` 更新规则
|
||||||
|
- ``DELETE /rules/{name}`` 删除规则
|
||||||
|
- ``POST /rules/reload`` 热重载规则目录
|
||||||
|
- ``GET /rules/_stats`` 规则引擎运行时状态
|
||||||
|
|
||||||
|
规则文件落盘策略:
|
||||||
|
|
||||||
|
- 默认所有自定义规则写入 ``config/rules/custom.yaml``
|
||||||
|
- 不修改 MVP-1 内置的事件类型规则文件 (fire.yaml / smoking.yaml 等) 的"内置规则",
|
||||||
|
但可以通过本接口在其上追加新规则或编辑已有规则
|
||||||
|
- 安全保护: 规则名作为唯一键,禁止同名覆盖;删除时仅允许删除 "custom.yaml" 内的规则
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
|
||||||
|
- 规则编辑后会刷新 ``DetectionService.rule_engine`` 实例,确保实时生效
|
||||||
|
- 写入 YAML 时使用 ``yaml.safe_dump``,保留 schema 兼容性
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from core.settings import get_settings
|
||||||
|
from models.event_schemas import EventType, SeverityLevel
|
||||||
|
from services.event.rule_engine import AlertRule, AlertRuleEngine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rules", tags=["规则配置"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 全局引擎注入
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_rule_engine_provider = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_rules_api(rule_engine_provider) -> None:
|
||||||
|
"""注入规则引擎获取函数。
|
||||||
|
|
||||||
|
使用 callable 而非实例,便于多个模块 (DetectionService 等) 共享同一引擎,
|
||||||
|
并在调用时拿到最新引用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
global _rule_engine_provider
|
||||||
|
_rule_engine_provider = rule_engine_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _get_engine() -> AlertRuleEngine:
|
||||||
|
if _rule_engine_provider is None:
|
||||||
|
raise HTTPException(status_code=503, detail="规则引擎未初始化")
|
||||||
|
engine = _rule_engine_provider()
|
||||||
|
if engine is None:
|
||||||
|
raise HTTPException(status_code=503, detail="规则引擎未就绪")
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _custom_rules_path() -> Path:
|
||||||
|
settings = get_settings()
|
||||||
|
rules_dir = settings.paths.server_dir / settings.event_engine.rules_dir
|
||||||
|
rules_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return rules_dir / "custom.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 请求 / 响应模型
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RulePayload(BaseModel):
|
||||||
|
"""规则的可序列化表示。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=64)
|
||||||
|
event_type: EventType
|
||||||
|
enabled: bool = True
|
||||||
|
min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
severity: Optional[SeverityLevel] = None
|
||||||
|
allowed_sources: Optional[List[str]] = None
|
||||||
|
required_labels: Optional[List[str]] = None
|
||||||
|
min_bbox_area: int = Field(default=0, ge=0)
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_rule(cls, rule: AlertRule) -> "RulePayload":
|
||||||
|
return cls(
|
||||||
|
name=rule.name,
|
||||||
|
event_type=rule.event_type,
|
||||||
|
enabled=rule.enabled,
|
||||||
|
min_confidence=rule.min_confidence,
|
||||||
|
severity=rule.severity,
|
||||||
|
allowed_sources=rule.allowed_sources,
|
||||||
|
required_labels=rule.required_labels,
|
||||||
|
min_bbox_area=rule.min_bbox_area,
|
||||||
|
description=rule.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_yaml_dict(self) -> Dict[str, Any]:
|
||||||
|
data: Dict[str, Any] = {
|
||||||
|
"name": self.name,
|
||||||
|
"event_type": self.event_type.value,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"min_confidence": self.min_confidence,
|
||||||
|
"min_bbox_area": self.min_bbox_area,
|
||||||
|
"description": self.description,
|
||||||
|
}
|
||||||
|
if self.severity is not None:
|
||||||
|
data["severity"] = self.severity.value
|
||||||
|
if self.allowed_sources:
|
||||||
|
data["allowed_sources"] = list(self.allowed_sources)
|
||||||
|
if self.required_labels:
|
||||||
|
data["required_labels"] = list(self.required_labels)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# YAML 持久化
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _load_custom_rules() -> List[Dict[str, Any]]:
|
||||||
|
path = _custom_rules_path()
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
with path.open("r", encoding="utf-8") as fp:
|
||||||
|
data = yaml.safe_load(fp) or {}
|
||||||
|
if isinstance(data, dict) and "rules" in data:
|
||||||
|
return list(data["rules"])
|
||||||
|
if isinstance(data, list):
|
||||||
|
return list(data)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_custom_rules(rules: List[Dict[str, Any]]) -> None:
|
||||||
|
path = _custom_rules_path()
|
||||||
|
payload = {
|
||||||
|
"# generated by /api/rules MVP-3 D33": None,
|
||||||
|
"rules": rules,
|
||||||
|
}
|
||||||
|
payload.pop("# generated by /api/rules MVP-3 D33") # 仅作注释占位
|
||||||
|
with path.open("w", encoding="utf-8") as fp:
|
||||||
|
fp.write("# 由前端规则配置页面写入,请勿手工同时编辑\n")
|
||||||
|
yaml.safe_dump(
|
||||||
|
{"rules": rules},
|
||||||
|
fp,
|
||||||
|
allow_unicode=True,
|
||||||
|
sort_keys=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_engine() -> AlertRuleEngine:
|
||||||
|
"""重新从规则目录加载,并替换全局引擎的规则集合。"""
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
rules_dir = settings.paths.server_dir / settings.event_engine.rules_dir
|
||||||
|
new_engine = AlertRuleEngine.from_directory(rules_dir)
|
||||||
|
|
||||||
|
engine = _get_engine()
|
||||||
|
engine.rules = new_engine.rules
|
||||||
|
engine._stats = new_engine._stats # noqa: SLF001
|
||||||
|
logger.info("规则引擎已热重载: 共 %d 条", len(engine.rules))
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_rules() -> Dict[str, Any]:
|
||||||
|
"""列出所有规则 (含来源文件)。"""
|
||||||
|
|
||||||
|
engine = _get_engine()
|
||||||
|
return {
|
||||||
|
"rules": [RulePayload.from_rule(r).model_dump(mode="json") for r in engine.rules],
|
||||||
|
"stats": engine.stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/_stats")
|
||||||
|
async def rule_stats() -> Dict[str, Any]:
|
||||||
|
"""规则引擎运行状态。"""
|
||||||
|
|
||||||
|
engine = _get_engine()
|
||||||
|
return engine.stats
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{name}")
|
||||||
|
async def get_rule(name: str) -> RulePayload:
|
||||||
|
"""获取单条规则。"""
|
||||||
|
|
||||||
|
engine = _get_engine()
|
||||||
|
for rule in engine.rules:
|
||||||
|
if rule.name == name:
|
||||||
|
return RulePayload.from_rule(rule)
|
||||||
|
raise HTTPException(status_code=404, detail=f"规则 {name} 不存在")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_rule(payload: RulePayload) -> Dict[str, Any]:
|
||||||
|
"""新增规则 (写入 custom.yaml)。"""
|
||||||
|
|
||||||
|
engine = _get_engine()
|
||||||
|
if any(r.name == payload.name for r in engine.rules):
|
||||||
|
raise HTTPException(status_code=409, detail=f"规则 {payload.name} 已存在")
|
||||||
|
|
||||||
|
rules = _load_custom_rules()
|
||||||
|
rules.append(payload.to_yaml_dict())
|
||||||
|
_save_custom_rules(rules)
|
||||||
|
|
||||||
|
_reload_engine()
|
||||||
|
return {"success": True, "rule": payload.model_dump(mode="json")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{name}")
|
||||||
|
async def update_rule(name: str, payload: RulePayload) -> Dict[str, Any]:
|
||||||
|
"""更新规则 (仅支持 custom.yaml 中的规则)。"""
|
||||||
|
|
||||||
|
if payload.name != name:
|
||||||
|
raise HTTPException(status_code=400, detail="路径与请求体中的规则名不一致")
|
||||||
|
|
||||||
|
rules = _load_custom_rules()
|
||||||
|
for idx, item in enumerate(rules):
|
||||||
|
if item.get("name") == name:
|
||||||
|
rules[idx] = payload.to_yaml_dict()
|
||||||
|
_save_custom_rules(rules)
|
||||||
|
_reload_engine()
|
||||||
|
return {"success": True, "rule": payload.model_dump(mode="json")}
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"规则 {name} 不在 custom.yaml 中,无法编辑 (内置规则只读)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{name}")
|
||||||
|
async def delete_rule(name: str) -> Dict[str, Any]:
|
||||||
|
"""删除规则 (仅 custom.yaml)。"""
|
||||||
|
|
||||||
|
rules = _load_custom_rules()
|
||||||
|
new_rules = [r for r in rules if r.get("name") != name]
|
||||||
|
if len(new_rules) == len(rules):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"规则 {name} 不在 custom.yaml 中,无法删除 (内置规则只读)",
|
||||||
|
)
|
||||||
|
|
||||||
|
_save_custom_rules(new_rules)
|
||||||
|
_reload_engine()
|
||||||
|
return {"success": True, "deleted": name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reload")
|
||||||
|
async def reload_rules() -> Dict[str, Any]:
|
||||||
|
"""热重载规则目录 (供运维直接编辑 YAML 后调用)。"""
|
||||||
|
|
||||||
|
engine = _reload_engine()
|
||||||
|
return {"success": True, "stats": engine.stats}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router", "init_rules_api"]
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
# 打架检测预警规则 (MVP-1)
|
# 打架检测预警规则 (MVP-1)
|
||||||
|
# [测试模式] min_confidence 已调低到 0.3 用于触发测试
|
||||||
|
# [生产模式] 推荐: high=0.55, critical=0.75
|
||||||
rules:
|
rules:
|
||||||
- name: fight_high
|
- name: fight_high
|
||||||
event_type: fight
|
event_type: fight
|
||||||
enabled: true
|
enabled: true
|
||||||
min_confidence: 0.55
|
min_confidence: 0.3
|
||||||
severity: high
|
severity: high
|
||||||
min_bbox_area: 800
|
min_bbox_area: 100
|
||||||
description: 检测到打架/暴力行为,触发高级预警
|
description: 检测到打架/暴力行为,触发高级预警
|
||||||
|
|
||||||
- name: fight_critical_continuous
|
- name: fight_critical_continuous
|
||||||
event_type: fight
|
event_type: fight
|
||||||
enabled: true
|
enabled: true
|
||||||
min_confidence: 0.75
|
min_confidence: 0.5
|
||||||
severity: critical
|
severity: critical
|
||||||
min_bbox_area: 1200
|
min_bbox_area: 100
|
||||||
description: 检测到高置信度打架行为,立即触发最高级别预警
|
description: 检测到高置信度打架行为,立即触发最高级别预警
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
SERVER_DIR: Path = Path(__file__).resolve().parent.parent
|
SERVER_DIR: Path = Path(__file__).resolve().parent.parent
|
||||||
PROJECT_ROOT: Path = SERVER_DIR.parent.parent
|
PROJECT_ROOT: Path = SERVER_DIR.parent.parent
|
||||||
|
|
||||||
|
# 共享 .env 文件路径 (所有嵌套子配置共用)
|
||||||
|
_ENV_FILE = str(SERVER_DIR / ".env")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 子配置
|
# 子配置
|
||||||
@@ -43,7 +46,7 @@ PROJECT_ROOT: Path = SERVER_DIR.parent.parent
|
|||||||
class APISettings(BaseSettings):
|
class APISettings(BaseSettings):
|
||||||
"""API 与服务器相关配置。"""
|
"""API 与服务器相关配置。"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="API_", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="API_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
host: str = Field(default="0.0.0.0", description="监听地址")
|
host: str = Field(default="0.0.0.0", description="监听地址")
|
||||||
port: int = Field(default=8000, description="监听端口")
|
port: int = Field(default=8000, description="监听端口")
|
||||||
@@ -56,7 +59,7 @@ class APISettings(BaseSettings):
|
|||||||
class DetectionSettings(BaseSettings):
|
class DetectionSettings(BaseSettings):
|
||||||
"""检测相关全局默认值。"""
|
"""检测相关全局默认值。"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="DETECTION_", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="DETECTION_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
default_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
|
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)
|
default_iou: float = Field(default=0.45, ge=0.0, le=1.0)
|
||||||
@@ -67,7 +70,7 @@ class DetectionSettings(BaseSettings):
|
|||||||
class ActionDetectionSettings(BaseSettings):
|
class ActionDetectionSettings(BaseSettings):
|
||||||
"""ppTSM 行为识别 (Docker) 服务配置。"""
|
"""ppTSM 行为识别 (Docker) 服务配置。"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="ACTION_DETECTION_", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="ACTION_DETECTION_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
api_url: str = Field(default="http://localhost:8081")
|
api_url: str = Field(default="http://localhost:8081")
|
||||||
timeout: int = Field(default=30, ge=1)
|
timeout: int = Field(default=30, ge=1)
|
||||||
@@ -76,7 +79,7 @@ class ActionDetectionSettings(BaseSettings):
|
|||||||
class EventEngineSettings(BaseSettings):
|
class EventEngineSettings(BaseSettings):
|
||||||
"""事件决策 + 聚合 + 规则引擎配置。"""
|
"""事件决策 + 聚合 + 规则引擎配置。"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="EVENT_", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="EVENT_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
# 时间窗口去重 (秒),同一 (source_id, event_type, track_id) 在窗口内只产生一条
|
# 时间窗口去重 (秒),同一 (source_id, event_type, track_id) 在窗口内只产生一条
|
||||||
dedup_window_seconds: float = Field(default=30.0, ge=0.0)
|
dedup_window_seconds: float = Field(default=30.0, ge=0.0)
|
||||||
@@ -89,7 +92,7 @@ class EventEngineSettings(BaseSettings):
|
|||||||
class RTSPSettings(BaseSettings):
|
class RTSPSettings(BaseSettings):
|
||||||
"""RTSP 流接入相关配置 (MVP-2)。"""
|
"""RTSP 流接入相关配置 (MVP-2)。"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="RTSP_", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="RTSP_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
max_streams: int = Field(default=16, ge=1, description="最大同时接入流数量")
|
max_streams: int = Field(default=16, ge=1, description="最大同时接入流数量")
|
||||||
buffer_capacity: int = Field(default=300, ge=1, description="每路流帧缓冲区容量")
|
buffer_capacity: int = Field(default=300, ge=1, description="每路流帧缓冲区容量")
|
||||||
@@ -105,7 +108,7 @@ class RTSPSettings(BaseSettings):
|
|||||||
class MQTTSettings(BaseSettings):
|
class MQTTSettings(BaseSettings):
|
||||||
"""MQTT 预警发布相关配置 (MVP-2 / D16-D18)。"""
|
"""MQTT 预警发布相关配置 (MVP-2 / D16-D18)。"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="MQTT_", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="MQTT_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
enabled: bool = Field(default=False, description="是否启用 MQTT 发布")
|
enabled: bool = Field(default=False, description="是否启用 MQTT 发布")
|
||||||
broker_host: str = Field(default="localhost", description="MQTT broker 主机")
|
broker_host: str = Field(default="localhost", description="MQTT broker 主机")
|
||||||
@@ -126,7 +129,7 @@ class MQTTSettings(BaseSettings):
|
|||||||
class TrackingSettings(BaseSettings):
|
class TrackingSettings(BaseSettings):
|
||||||
"""目标跟踪 (ByteTrack) 配置 (MVP-2 / D19)。"""
|
"""目标跟踪 (ByteTrack) 配置 (MVP-2 / D19)。"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="TRACKING_", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="TRACKING_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
enabled: bool = Field(default=True, description="是否启用目标跟踪")
|
enabled: bool = Field(default=True, description="是否启用目标跟踪")
|
||||||
track_thresh: float = Field(default=0.5, ge=0.0, le=1.0, description="跟踪置信度阈值")
|
track_thresh: float = Field(default=0.5, ge=0.0, le=1.0, description="跟踪置信度阈值")
|
||||||
@@ -139,7 +142,7 @@ class TrackingSettings(BaseSettings):
|
|||||||
class AggregatorSettings(BaseSettings):
|
class AggregatorSettings(BaseSettings):
|
||||||
"""事件聚合器扩展配置 (MVP-2 / D20)。"""
|
"""事件聚合器扩展配置 (MVP-2 / D20)。"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="AGGREGATOR_", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="AGGREGATOR_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
enable_spatial_merge: bool = Field(default=True, description="是否启用空间邻近合并")
|
enable_spatial_merge: bool = Field(default=True, description="是否启用空间邻近合并")
|
||||||
spatial_iou_threshold: float = Field(
|
spatial_iou_threshold: float = Field(
|
||||||
@@ -154,10 +157,101 @@ class AggregatorSettings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMSettings(BaseSettings):
|
||||||
|
"""LLM 二次判断服务配置 (MVP-3 / D29)。
|
||||||
|
|
||||||
|
支持 OpenAI 兼容协议 (GPT-4V / Qwen-VL / GLM-4V 等),
|
||||||
|
通过 ``provider=mock`` 在无 API Key 环境下保持离线可用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="LLM_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
|
enabled: bool = Field(default=False, description="是否启用 LLM 二次判断")
|
||||||
|
provider: str = Field(
|
||||||
|
default="mock",
|
||||||
|
description="LLM provider: openai / qwen / glm / mock",
|
||||||
|
)
|
||||||
|
api_base: Optional[str] = Field(default=None, description="API base URL (OpenAI 兼容)")
|
||||||
|
api_key: Optional[str] = Field(default=None, description="API Key")
|
||||||
|
model: str = Field(default="gpt-4o", description="LLM 模型名称")
|
||||||
|
timeout: float = Field(default=15.0, ge=1.0, description="单次调用超时(秒)")
|
||||||
|
max_retries: int = Field(default=2, ge=0, description="失败重试次数")
|
||||||
|
max_tokens: int = Field(default=512, ge=64, description="最大返回 token 数")
|
||||||
|
temperature: float = Field(default=0.0, ge=0.0, le=2.0, description="采样温度")
|
||||||
|
max_concurrency: int = Field(default=2, ge=1, description="最大并发调用数")
|
||||||
|
image_max_side: int = Field(
|
||||||
|
default=768, ge=128, description="图像编码前最大边长 (节省 token)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMTriggerSettings(BaseSettings):
|
||||||
|
"""LLM 触发器配置 (MVP-3 / D26-D28)。"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="LLM_TRIGGER_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
|
enabled: bool = Field(default=False, description="是否启用 LLM 触发")
|
||||||
|
window_seconds: float = Field(
|
||||||
|
default=3.0, ge=0.5, description="多帧累积时间窗口(秒)"
|
||||||
|
)
|
||||||
|
min_consecutive_hits: int = Field(
|
||||||
|
default=3, ge=1, description="触发所需的最小连续命中帧数"
|
||||||
|
)
|
||||||
|
min_avg_confidence: float = Field(
|
||||||
|
default=0.55, ge=0.0, le=1.0, description="累积平均置信度阈值"
|
||||||
|
)
|
||||||
|
cooldown_seconds: float = Field(
|
||||||
|
default=20.0, ge=0.0, description="同目标 LLM 冷却时间(秒)"
|
||||||
|
)
|
||||||
|
max_track_capacity: int = Field(
|
||||||
|
default=2000, ge=1, description="累积器最大跟踪条目"
|
||||||
|
)
|
||||||
|
severity_bypass: List[str] = Field(
|
||||||
|
default_factory=lambda: ["critical"],
|
||||||
|
description="无需累积、立即触发的严重性级别",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionSettings(BaseSettings):
|
||||||
|
"""LLM/YOLO 结果融合配置 (MVP-3 / D30)。"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="FUSION_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
|
strategy: str = Field(
|
||||||
|
default="weighted",
|
||||||
|
description="融合策略: weighted / conservative / llm_priority",
|
||||||
|
)
|
||||||
|
yolo_weight: float = Field(default=0.4, ge=0.0, le=1.0)
|
||||||
|
llm_weight: float = Field(default=0.6, ge=0.0, le=1.0)
|
||||||
|
# conservative 策略下,LLM 判定为否时直接抑制预警
|
||||||
|
suppress_on_llm_negative: bool = Field(default=True)
|
||||||
|
# LLM 不可用时是否回退到 YOLO 结果
|
||||||
|
fallback_to_yolo: bool = Field(default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LLMCostSettings(BaseSettings):
|
||||||
|
"""LLM 成本追踪 + 降级策略配置 (MVP-3 / D34)。"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="LLM_COST_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
|
daily_budget_usd: float = Field(
|
||||||
|
default=0.0, ge=0.0, description="日预算 (USD),0=不限"
|
||||||
|
)
|
||||||
|
history_days: int = Field(default=7, ge=1, description="保留多少天的日级统计")
|
||||||
|
recent_window: int = Field(
|
||||||
|
default=20, ge=3, description="错误率统计窗口 (最近 N 次调用)"
|
||||||
|
)
|
||||||
|
error_rate_threshold: float = Field(
|
||||||
|
default=0.5, gt=0.0, le=1.0, description="触发熔断的错误率阈值"
|
||||||
|
)
|
||||||
|
cooldown_seconds: float = Field(
|
||||||
|
default=60.0, ge=0.0, description="熔断冷却时间 (秒)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LoggingSettings(BaseSettings):
|
class LoggingSettings(BaseSettings):
|
||||||
"""日志配置。"""
|
"""日志配置。"""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_prefix="LOG_", extra="ignore")
|
model_config = SettingsConfigDict(env_prefix="LOG_", env_file=_ENV_FILE, extra="ignore")
|
||||||
|
|
||||||
level: str = Field(default="INFO")
|
level: str = Field(default="INFO")
|
||||||
json_format: bool = Field(default=False)
|
json_format: bool = Field(default=False)
|
||||||
@@ -217,6 +311,10 @@ class Settings(BaseSettings):
|
|||||||
mqtt: MQTTSettings = Field(default_factory=MQTTSettings)
|
mqtt: MQTTSettings = Field(default_factory=MQTTSettings)
|
||||||
tracking: TrackingSettings = Field(default_factory=TrackingSettings)
|
tracking: TrackingSettings = Field(default_factory=TrackingSettings)
|
||||||
aggregator: AggregatorSettings = Field(default_factory=AggregatorSettings)
|
aggregator: AggregatorSettings = Field(default_factory=AggregatorSettings)
|
||||||
|
llm: LLMSettings = Field(default_factory=LLMSettings)
|
||||||
|
llm_trigger: LLMTriggerSettings = Field(default_factory=LLMTriggerSettings)
|
||||||
|
fusion: FusionSettings = Field(default_factory=FusionSettings)
|
||||||
|
llm_cost: LLMCostSettings = Field(default_factory=LLMCostSettings)
|
||||||
logging: LoggingSettings = Field(default_factory=LoggingSettings)
|
logging: LoggingSettings = Field(default_factory=LoggingSettings)
|
||||||
paths: PathSettings = Field(default_factory=PathSettings)
|
paths: PathSettings = Field(default_factory=PathSettings)
|
||||||
|
|
||||||
@@ -243,6 +341,10 @@ __all__ = [
|
|||||||
"MQTTSettings",
|
"MQTTSettings",
|
||||||
"TrackingSettings",
|
"TrackingSettings",
|
||||||
"AggregatorSettings",
|
"AggregatorSettings",
|
||||||
|
"LLMSettings",
|
||||||
|
"LLMTriggerSettings",
|
||||||
|
"FusionSettings",
|
||||||
|
"LLMCostSettings",
|
||||||
"LoggingSettings",
|
"LoggingSettings",
|
||||||
"PathSettings",
|
"PathSettings",
|
||||||
"get_settings",
|
"get_settings",
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ import logging
|
|||||||
from api import detection, models
|
from api import detection, models
|
||||||
from api.alerts import get_broadcaster
|
from api.alerts import get_broadcaster
|
||||||
from api.rtsp import init_stream_manager
|
from api.rtsp import init_stream_manager
|
||||||
|
from api.llm import init_llm_api, router as llm_router
|
||||||
|
from api.rules import init_rules_api, router as rules_router
|
||||||
|
from core.settings import get_settings
|
||||||
from services.model_service import ModelService
|
from services.model_service import ModelService
|
||||||
from services.camera_service import CameraService
|
from services.camera_service import CameraService
|
||||||
|
from services.llm_analysis_service import create_llm_service_from_settings
|
||||||
|
from services.llm_cost_tracker import init_global_tracker
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -74,6 +79,29 @@ async def lifespan(app: FastAPI):
|
|||||||
app.include_router(rtsp_router, prefix="/api")
|
app.include_router(rtsp_router, prefix="/api")
|
||||||
app.include_router(alerts_router, prefix="") # WebSocket 路径已带 /ws 前缀
|
app.include_router(alerts_router, prefix="") # WebSocket 路径已带 /ws 前缀
|
||||||
|
|
||||||
|
# 初始化 LLM 集成 (MVP-3)
|
||||||
|
settings = get_settings()
|
||||||
|
llm_service = create_llm_service_from_settings(settings)
|
||||||
|
cost_tracker = init_global_tracker(
|
||||||
|
daily_budget_usd=settings.llm_cost.daily_budget_usd,
|
||||||
|
history_days=settings.llm_cost.history_days,
|
||||||
|
recent_window=settings.llm_cost.recent_window,
|
||||||
|
error_rate_threshold=settings.llm_cost.error_rate_threshold,
|
||||||
|
cooldown_seconds=settings.llm_cost.cooldown_seconds,
|
||||||
|
)
|
||||||
|
init_llm_api(llm_service, cost_tracker)
|
||||||
|
app.include_router(llm_router, prefix="/api")
|
||||||
|
logger.info(
|
||||||
|
"LLM 集成已加载: enabled=%s provider=%s",
|
||||||
|
settings.llm.enabled,
|
||||||
|
settings.llm.provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 初始化规则配置 API (MVP-3 / D33),与 DetectionService 共享同一规则引擎
|
||||||
|
detection_service = detection.get_detection_service(model_service)
|
||||||
|
init_rules_api(lambda: detection_service.rule_engine)
|
||||||
|
app.include_router(rules_router, prefix="/api")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# 关闭时清理资源
|
# 关闭时清理资源
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ psutil==6.1.1
|
|||||||
# 网络相关
|
# 网络相关
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
certifi==2026.5.20
|
certifi==2026.5.20
|
||||||
|
paho-mqtt==2.1.0
|
||||||
|
|
||||||
# 开发工具
|
# 开发工具
|
||||||
ipython==9.1.0
|
ipython==9.1.0
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ class DetectionService:
|
|||||||
model_id: str,
|
model_id: str,
|
||||||
confidence: float = 0.5,
|
confidence: float = 0.5,
|
||||||
iou: float = 0.45,
|
iou: float = 0.45,
|
||||||
algorithm_config: Optional[Dict] = None,
|
algorithm_config: Optional[Dict] = None
|
||||||
region_polygon: Optional[List[List[int]]] = None
|
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
@@ -63,39 +62,6 @@ class DetectionService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 违停检测特殊处理:调用专门的违停检测方法
|
|
||||||
if model_id == 'illegal_parking_detection' and hasattr(model, 'detect_illegal_parking'):
|
|
||||||
# 如果提供了禁停区域,单张图片模式下即时判定(时间阈值设为0)
|
|
||||||
parking_time = 0 if region_polygon else None
|
|
||||||
parking_result = model.detect_illegal_parking(
|
|
||||||
image, conf=confidence, illegal_parking_time=parking_time, region_polygon=region_polygon
|
|
||||||
)
|
|
||||||
detections = []
|
|
||||||
for vehicle in parking_result.get('illegal_parking', []):
|
|
||||||
detections.append({
|
|
||||||
'class': 'illegal_parking',
|
|
||||||
'label': '违停车辆',
|
|
||||||
'confidence': 1.0,
|
|
||||||
'bbox': vehicle['bbox'],
|
|
||||||
'track_id': vehicle.get('track_id'),
|
|
||||||
'parking_duration': vehicle.get('parking_duration', 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
processing_time = time.time() - start_time
|
|
||||||
result_data = {
|
|
||||||
'success': parking_result['success'],
|
|
||||||
'message': parking_result.get('message', '违停检测完成'),
|
|
||||||
'detections': detections,
|
|
||||||
'stats': {
|
|
||||||
**parking_result.get('stats', {}),
|
|
||||||
'total_detections': len(detections),
|
|
||||||
'processing_time': round(processing_time, 3),
|
|
||||||
'model_used': model_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result_data = self._apply_event_pipeline(result_data, model_id)
|
|
||||||
return result_data
|
|
||||||
|
|
||||||
results = model(image, conf=confidence, iou=iou, verbose=False)
|
results = model(image, conf=confidence, iou=iou, verbose=False)
|
||||||
|
|
||||||
detections = []
|
detections = []
|
||||||
@@ -236,44 +202,6 @@ class DetectionService:
|
|||||||
'stats': None
|
'stats': None
|
||||||
}
|
}
|
||||||
|
|
||||||
# 违停检测特殊处理:调用专门的违停检测方法
|
|
||||||
if model_id == 'illegal_parking_detection' and hasattr(model, 'detect_illegal_parking'):
|
|
||||||
parking_result = model.detect_illegal_parking(
|
|
||||||
frame, conf=confidence
|
|
||||||
)
|
|
||||||
detections = []
|
|
||||||
for vehicle in parking_result.get('illegal_parking', []):
|
|
||||||
detections.append({
|
|
||||||
'class': 'illegal_parking',
|
|
||||||
'label': '违停车辆',
|
|
||||||
'confidence': 1.0,
|
|
||||||
'bbox': vehicle['bbox'],
|
|
||||||
'track_id': vehicle.get('track_id'),
|
|
||||||
'parking_duration': vehicle.get('parking_duration', 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
processing_time = time.time() - start_time
|
|
||||||
fps = 1.0 / processing_time if processing_time > 0 else 0
|
|
||||||
|
|
||||||
result_data = {
|
|
||||||
'success': parking_result['success'],
|
|
||||||
'message': parking_result.get('message', '违停检测完成'),
|
|
||||||
'detections': detections,
|
|
||||||
'stats': {
|
|
||||||
**parking_result.get('stats', {}),
|
|
||||||
'total_detections': len(detections),
|
|
||||||
'fps': round(fps, 2),
|
|
||||||
'processing_time': round(processing_time, 3),
|
|
||||||
'model_used': model_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result_data = self._apply_event_pipeline(result_data, model_id)
|
|
||||||
|
|
||||||
if draw:
|
|
||||||
frame = self.draw_detections(frame, detections, fps)
|
|
||||||
|
|
||||||
return frame, result_data
|
|
||||||
|
|
||||||
results = model(frame, conf=confidence, iou=iou, verbose=False)
|
results = model(frame, conf=confidence, iou=iou, verbose=False)
|
||||||
|
|
||||||
detections = []
|
detections = []
|
||||||
@@ -651,7 +579,6 @@ class DetectionService:
|
|||||||
'helmet': (255, 255, 0),
|
'helmet': (255, 255, 0),
|
||||||
'no_helmet': (255, 0, 255),
|
'no_helmet': (255, 0, 255),
|
||||||
'cigarette': (0, 165, 255),
|
'cigarette': (0, 165, 255),
|
||||||
'illegal_parking': (0, 0, 255),
|
|
||||||
# 兼容旧模型类别
|
# 兼容旧模型类别
|
||||||
'violence': (0, 0, 255),
|
'violence': (0, 0, 255),
|
||||||
'fight': (0, 0, 255),
|
'fight': (0, 0, 255),
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
"""事件引擎子包 (MVP-1 / P1-P2 / P5)。
|
"""事件引擎子包 (MVP-1 / P1-P2 / P5 + MVP-3 / D26-D28)。
|
||||||
|
|
||||||
模块结构::
|
模块结构::
|
||||||
|
|
||||||
decision_engine.py 决策引擎 (置信度评估 + 事件类型映射)
|
decision_engine.py 决策引擎 (置信度评估 + 事件类型映射)
|
||||||
rule_engine.py 规则引擎 (YAML 驱动)
|
rule_engine.py 规则引擎 (YAML 驱动)
|
||||||
aggregator.py 事件聚合器 (时间窗口去重)
|
aggregator.py 事件聚合器 (时间窗口去重)
|
||||||
|
frame_accumulator.py 多帧累积分析器 (MVP-3)
|
||||||
|
llm_trigger.py LLM 触发决策器 (MVP-3)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .decision_engine import EventDecisionEngine
|
|
||||||
from .rule_engine import AlertRuleEngine
|
|
||||||
from .aggregator import EventAggregator
|
from .aggregator import EventAggregator
|
||||||
|
from .decision_engine import EventDecisionEngine
|
||||||
|
from .frame_accumulator import AccumulationEntry, MultiFrameAccumulator
|
||||||
|
from .llm_trigger import LLMTrigger, TriggerDecision
|
||||||
|
from .rule_engine import AlertRuleEngine
|
||||||
|
|
||||||
__all__ = ["EventDecisionEngine", "AlertRuleEngine", "EventAggregator"]
|
__all__ = [
|
||||||
|
"EventDecisionEngine",
|
||||||
|
"AlertRuleEngine",
|
||||||
|
"EventAggregator",
|
||||||
|
"MultiFrameAccumulator",
|
||||||
|
"AccumulationEntry",
|
||||||
|
"LLMTrigger",
|
||||||
|
"TriggerDecision",
|
||||||
|
]
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ DEFAULT_CLASS_TO_EVENT: Dict[str, EventType] = {
|
|||||||
# 火灾
|
# 火灾
|
||||||
"fire": EventType.FIRE,
|
"fire": EventType.FIRE,
|
||||||
"flame": EventType.FIRE,
|
"flame": EventType.FIRE,
|
||||||
|
"火焰": EventType.FIRE,
|
||||||
"smoke": EventType.SMOKE,
|
"smoke": EventType.SMOKE,
|
||||||
|
"烟雾": EventType.SMOKE,
|
||||||
# 抽烟
|
# 抽烟
|
||||||
"smoking": EventType.SMOKING,
|
"smoking": EventType.SMOKING,
|
||||||
"cigarette": EventType.SMOKING,
|
"cigarette": EventType.SMOKING,
|
||||||
# 打架
|
# 打架
|
||||||
"fight": EventType.FIGHT,
|
"fight": EventType.FIGHT,
|
||||||
"fighting": EventType.FIGHT,
|
"fighting": EventType.FIGHT,
|
||||||
|
"violence": EventType.FIGHT, # YOLO打架模型 (fight_detection) 正样本
|
||||||
# 行为
|
# 行为
|
||||||
"loitering": EventType.LOITERING,
|
"loitering": EventType.LOITERING,
|
||||||
"stationary": EventType.STATIONARY,
|
"stationary": EventType.STATIONARY,
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""多帧累积分析器 (MVP-3 / D26-D27)
|
||||||
|
|
||||||
|
针对同一目标在时间窗口内的连续帧候选事件进行累积,
|
||||||
|
为 ``LLMTrigger`` 提供"是否值得调用 LLM 二次判断"的决策依据。
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
|
||||||
|
1. 以 ``(source_id, event_type, target_identity)`` 作为唯一累积键,
|
||||||
|
``target_identity`` 优先采用 ``track_id``,缺失时回退到 bbox 网格哈希
|
||||||
|
(与 ``EventAggregator`` 一致,确保两个模块对"同一目标"的认定口径相同)。
|
||||||
|
2. 累积条目记录连续命中帧数、累积平均置信度、首末时间戳、最大严重性。
|
||||||
|
3. 按时间窗口自动淘汰过期条目,并使用 OrderedDict 实现 LRU 容量保护,
|
||||||
|
避免多路摄像头长时间运行导致内存无限增长。
|
||||||
|
4. 当某次新候选事件与已有键的 ``last_seen`` 间隔超出窗口时,
|
||||||
|
认为序列中断,自动重置 ``consecutive_hits`` 计数。
|
||||||
|
|
||||||
|
Thread-safety: 当前实现为非线程安全,调用方应在单事件循环中按序使用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional, Tuple, TypeAlias
|
||||||
|
|
||||||
|
from models.event_schemas import (
|
||||||
|
BBox,
|
||||||
|
CandidateEvent,
|
||||||
|
EventType,
|
||||||
|
SeverityLevel,
|
||||||
|
UnifiedDetection,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_AccKey: TypeAlias = Tuple[Optional[str], str, str]
|
||||||
|
|
||||||
|
_SEVERITY_ORDER: Tuple[str, ...] = (
|
||||||
|
SeverityLevel.INFO.value,
|
||||||
|
SeverityLevel.LOW.value,
|
||||||
|
SeverityLevel.MEDIUM.value,
|
||||||
|
SeverityLevel.HIGH.value,
|
||||||
|
SeverityLevel.CRITICAL.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 累积条目
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccumulationEntry:
|
||||||
|
"""单个目标在窗口内的累积统计。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key: (source_id, event_type, target_identity)
|
||||||
|
event_type: 事件类型
|
||||||
|
source_id: 摄像头/视频流标识
|
||||||
|
first_seen: 首次出现时间戳
|
||||||
|
last_seen: 最近一次出现时间戳
|
||||||
|
total_hits: 窗口内累计命中次数 (含被时间窗口截断重置前的不计)
|
||||||
|
consecutive_hits: 当前连续命中帧数
|
||||||
|
confidence_sum: 置信度累积和 (用于计算平均值)
|
||||||
|
max_confidence: 窗口内最高置信度
|
||||||
|
max_severity: 窗口内最高严重性
|
||||||
|
last_event: 最近一次的候选事件 (用于 LLM 抽帧)
|
||||||
|
last_bbox: 最近一次目标 bbox
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: _AccKey
|
||||||
|
event_type: EventType
|
||||||
|
source_id: Optional[str]
|
||||||
|
first_seen: float
|
||||||
|
last_seen: float
|
||||||
|
total_hits: int = 1
|
||||||
|
consecutive_hits: int = 1
|
||||||
|
confidence_sum: float = 0.0
|
||||||
|
max_confidence: float = 0.0
|
||||||
|
max_severity: SeverityLevel = SeverityLevel.INFO
|
||||||
|
last_event: Optional[CandidateEvent] = None
|
||||||
|
last_bbox: Optional[BBox] = None
|
||||||
|
metadata: Dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def avg_confidence(self) -> float:
|
||||||
|
"""窗口内平均置信度 (按累积命中数平均)。"""
|
||||||
|
|
||||||
|
if self.total_hits <= 0:
|
||||||
|
return 0.0
|
||||||
|
return self.confidence_sum / self.total_hits
|
||||||
|
|
||||||
|
@property
|
||||||
|
def duration(self) -> float:
|
||||||
|
"""累积持续时间 (秒)。"""
|
||||||
|
|
||||||
|
return max(0.0, self.last_seen - self.first_seen)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, object]:
|
||||||
|
return {
|
||||||
|
"source_id": self.source_id,
|
||||||
|
"event_type": self.event_type.value,
|
||||||
|
"target_identity": self.key[2],
|
||||||
|
"total_hits": self.total_hits,
|
||||||
|
"consecutive_hits": self.consecutive_hits,
|
||||||
|
"avg_confidence": round(self.avg_confidence, 4),
|
||||||
|
"max_confidence": round(self.max_confidence, 4),
|
||||||
|
"max_severity": self.max_severity.value,
|
||||||
|
"first_seen": self.first_seen,
|
||||||
|
"last_seen": self.last_seen,
|
||||||
|
"duration": round(self.duration, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MultiFrameAccumulator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class MultiFrameAccumulator:
|
||||||
|
"""多帧候选事件累积器。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_seconds: 累积时间窗口,超过此值的条目会被淘汰;
|
||||||
|
同一 key 两次命中间隔超出窗口时,连续帧计数会被重置
|
||||||
|
max_capacity: 最大跟踪条目数,超出时按 LRU 淘汰
|
||||||
|
grid_size: 缺失 track_id 时用于构造目标 hash 的网格尺寸 (像素)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
window_seconds: float = 3.0,
|
||||||
|
max_capacity: int = 2000,
|
||||||
|
grid_size: int = 50,
|
||||||
|
) -> None:
|
||||||
|
if window_seconds <= 0:
|
||||||
|
raise ValueError("window_seconds 必须 > 0")
|
||||||
|
if max_capacity < 1:
|
||||||
|
raise ValueError("max_capacity 必须 >= 1")
|
||||||
|
if grid_size < 1:
|
||||||
|
raise ValueError("grid_size 必须 >= 1")
|
||||||
|
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self.max_capacity = max_capacity
|
||||||
|
self.grid_size = grid_size
|
||||||
|
|
||||||
|
self._entries: "OrderedDict[_AccKey, AccumulationEntry]" = OrderedDict()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 主入口
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def accumulate(
|
||||||
|
self,
|
||||||
|
events: List[CandidateEvent],
|
||||||
|
now: Optional[float] = None,
|
||||||
|
) -> List[AccumulationEntry]:
|
||||||
|
"""累积一批候选事件,返回受影响的累积条目快照列表。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
events: 当前帧的候选事件
|
||||||
|
now: 当前时间戳 (供测试注入),默认 ``time.time()``
|
||||||
|
"""
|
||||||
|
|
||||||
|
if now is None:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
self._evict_expired(now)
|
||||||
|
|
||||||
|
affected: List[AccumulationEntry] = []
|
||||||
|
for event in events:
|
||||||
|
entry = self._update_one(event, now)
|
||||||
|
if entry is not None:
|
||||||
|
affected.append(entry)
|
||||||
|
|
||||||
|
# LRU 容量保护
|
||||||
|
while len(self._entries) > self.max_capacity:
|
||||||
|
dropped_key, _ = self._entries.popitem(last=False)
|
||||||
|
logger.debug("MultiFrameAccumulator LRU 淘汰: %s", dropped_key)
|
||||||
|
|
||||||
|
return affected
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 单事件累积
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _update_one(
|
||||||
|
self,
|
||||||
|
event: CandidateEvent,
|
||||||
|
now: float,
|
||||||
|
) -> Optional[AccumulationEntry]:
|
||||||
|
key = self._make_key(event)
|
||||||
|
existing = self._entries.get(key)
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
entry = AccumulationEntry(
|
||||||
|
key=key,
|
||||||
|
event_type=event.event_type,
|
||||||
|
source_id=event.source_id,
|
||||||
|
first_seen=now,
|
||||||
|
last_seen=now,
|
||||||
|
total_hits=1,
|
||||||
|
consecutive_hits=1,
|
||||||
|
confidence_sum=event.confidence,
|
||||||
|
max_confidence=event.confidence,
|
||||||
|
max_severity=event.severity,
|
||||||
|
last_event=event,
|
||||||
|
last_bbox=event.detection.bbox,
|
||||||
|
)
|
||||||
|
self._entries[key] = entry
|
||||||
|
return entry
|
||||||
|
|
||||||
|
# 序列中断检测:超出窗口则重置连续计数与平均累积
|
||||||
|
if now - existing.last_seen > self.window_seconds:
|
||||||
|
existing.first_seen = now
|
||||||
|
existing.total_hits = 0
|
||||||
|
existing.consecutive_hits = 0
|
||||||
|
existing.confidence_sum = 0.0
|
||||||
|
|
||||||
|
existing.last_seen = now
|
||||||
|
existing.total_hits += 1
|
||||||
|
existing.consecutive_hits += 1
|
||||||
|
existing.confidence_sum += event.confidence
|
||||||
|
existing.max_confidence = max(existing.max_confidence, event.confidence)
|
||||||
|
existing.max_severity = self._max_severity(
|
||||||
|
existing.max_severity, event.severity
|
||||||
|
)
|
||||||
|
existing.last_event = event
|
||||||
|
existing.last_bbox = event.detection.bbox
|
||||||
|
|
||||||
|
# 移到队尾保持 LRU
|
||||||
|
self._entries.move_to_end(key)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 工具
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_key(self, event: CandidateEvent) -> _AccKey:
|
||||||
|
return (
|
||||||
|
event.source_id,
|
||||||
|
event.event_type.value,
|
||||||
|
self._target_identity(event.detection),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _target_identity(self, det: UnifiedDetection) -> str:
|
||||||
|
"""构造目标稳定标识:优先 track_id,否则 bbox 网格哈希。"""
|
||||||
|
|
||||||
|
if det.track_id is not None:
|
||||||
|
return f"t{det.track_id}"
|
||||||
|
cx, cy = det.bbox.center
|
||||||
|
gx = int(cx) // self.grid_size
|
||||||
|
gy = int(cy) // self.grid_size
|
||||||
|
return f"g{gx}_{gy}_{det.class_name}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _max_severity(a: SeverityLevel, b: SeverityLevel) -> SeverityLevel:
|
||||||
|
try:
|
||||||
|
ai = _SEVERITY_ORDER.index(a.value)
|
||||||
|
bi = _SEVERITY_ORDER.index(b.value)
|
||||||
|
except ValueError:
|
||||||
|
return a
|
||||||
|
return a if ai >= bi else b
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 淘汰 / 自省
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _evict_expired(self, now: float) -> None:
|
||||||
|
expired = [
|
||||||
|
key
|
||||||
|
for key, entry in self._entries.items()
|
||||||
|
if now - entry.last_seen > self.window_seconds
|
||||||
|
]
|
||||||
|
for key in expired:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
|
||||||
|
def get(self, event: CandidateEvent) -> Optional[AccumulationEntry]:
|
||||||
|
return self._entries.get(self._make_key(event))
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._entries.clear()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_count(self) -> int:
|
||||||
|
return len(self._entries)
|
||||||
|
|
||||||
|
def snapshot(self) -> List[Dict[str, object]]:
|
||||||
|
return [entry.to_dict() for entry in self._entries.values()]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["MultiFrameAccumulator", "AccumulationEntry"]
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""LLM 触发决策器 (MVP-3 / D28)
|
||||||
|
|
||||||
|
基于 ``MultiFrameAccumulator`` 的累积统计,决定哪些目标值得调用 LLM 二次判断。
|
||||||
|
|
||||||
|
触发策略 (任一满足即触发):
|
||||||
|
|
||||||
|
1. ``severity_bypass``: 累积条目最大严重性命中白名单 (默认 critical) 立即触发,
|
||||||
|
无需累积窗口
|
||||||
|
2. ``连续命中帧数 >= min_consecutive_hits`` 且 ``avg_confidence >= min_avg_confidence``
|
||||||
|
|
||||||
|
冷却机制:
|
||||||
|
|
||||||
|
- 触发后记录该目标最近一次的触发时间,``cooldown_seconds`` 内不再重复触发,
|
||||||
|
避免对同一可疑目标短时间多次调用 LLM 造成成本浪费。
|
||||||
|
|
||||||
|
可观测性:
|
||||||
|
|
||||||
|
- ``stats`` 暴露 evaluated / triggered / cooled / bypassed 计数,便于监控
|
||||||
|
|
||||||
|
线程安全: 与 ``MultiFrameAccumulator`` 一致,单事件循环串行使用即可。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, List, Optional, Tuple, TypeAlias
|
||||||
|
|
||||||
|
from models.event_schemas import CandidateEvent, SeverityLevel
|
||||||
|
|
||||||
|
from .frame_accumulator import AccumulationEntry, MultiFrameAccumulator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_TriggerKey: TypeAlias = Tuple[Optional[str], str, str]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 触发结果
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TriggerDecision:
|
||||||
|
"""LLM 触发决策结果。"""
|
||||||
|
|
||||||
|
candidate: CandidateEvent
|
||||||
|
entry: AccumulationEntry
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, object]:
|
||||||
|
return {
|
||||||
|
"event_type": self.candidate.event_type.value,
|
||||||
|
"source_id": self.candidate.source_id,
|
||||||
|
"confidence": round(self.candidate.confidence, 4),
|
||||||
|
"consecutive_hits": self.entry.consecutive_hits,
|
||||||
|
"avg_confidence": round(self.entry.avg_confidence, 4),
|
||||||
|
"max_severity": self.entry.max_severity.value,
|
||||||
|
"duration": round(self.entry.duration, 3),
|
||||||
|
"reason": self.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLMTrigger
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LLMTrigger:
|
||||||
|
"""LLM 触发器。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accumulator: 多帧累积分析器 (由调用方共享,便于状态一致)
|
||||||
|
min_consecutive_hits: 触发所需的最小连续命中帧数
|
||||||
|
min_avg_confidence: 累积平均置信度下限
|
||||||
|
cooldown_seconds: 同目标 LLM 冷却时间 (秒),0 表示不冷却
|
||||||
|
severity_bypass: 立即触发的严重性级别集合
|
||||||
|
max_cooldown_entries: 冷却记录最大容量 (LRU 淘汰)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
accumulator: MultiFrameAccumulator,
|
||||||
|
min_consecutive_hits: int = 3,
|
||||||
|
min_avg_confidence: float = 0.55,
|
||||||
|
cooldown_seconds: float = 20.0,
|
||||||
|
severity_bypass: Optional[List[str]] = None,
|
||||||
|
max_cooldown_entries: int = 5000,
|
||||||
|
) -> None:
|
||||||
|
if min_consecutive_hits < 1:
|
||||||
|
raise ValueError("min_consecutive_hits 必须 >= 1")
|
||||||
|
if not 0.0 <= min_avg_confidence <= 1.0:
|
||||||
|
raise ValueError("min_avg_confidence 必须在 [0, 1]")
|
||||||
|
if cooldown_seconds < 0:
|
||||||
|
raise ValueError("cooldown_seconds 必须 >= 0")
|
||||||
|
if max_cooldown_entries < 1:
|
||||||
|
raise ValueError("max_cooldown_entries 必须 >= 1")
|
||||||
|
|
||||||
|
self.accumulator = accumulator
|
||||||
|
self.min_consecutive_hits = min_consecutive_hits
|
||||||
|
self.min_avg_confidence = min_avg_confidence
|
||||||
|
self.cooldown_seconds = cooldown_seconds
|
||||||
|
self.severity_bypass = {
|
||||||
|
SeverityLevel(s) for s in (severity_bypass or [])
|
||||||
|
} if severity_bypass else set()
|
||||||
|
self.max_cooldown_entries = max_cooldown_entries
|
||||||
|
|
||||||
|
self._cooldowns: "OrderedDict[_TriggerKey, float]" = OrderedDict()
|
||||||
|
|
||||||
|
# 统计
|
||||||
|
self._evaluated = 0
|
||||||
|
self._triggered = 0
|
||||||
|
self._cooled = 0
|
||||||
|
self._bypassed = 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 主入口
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
candidates: List[CandidateEvent],
|
||||||
|
now: Optional[float] = None,
|
||||||
|
) -> List[TriggerDecision]:
|
||||||
|
"""评估候选事件,返回需要触发 LLM 复审的决策列表。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
candidates: 当前批次候选事件 (通常来自规则引擎之前的决策结果)
|
||||||
|
now: 当前时间戳 (供测试注入)
|
||||||
|
"""
|
||||||
|
|
||||||
|
if now is None:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# 1. 先把候选事件喂给累积器 (统一时间戳,确保统计与触发判定基于相同 now)
|
||||||
|
self.accumulator.accumulate(candidates, now=now)
|
||||||
|
|
||||||
|
decisions: List[TriggerDecision] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
self._evaluated += 1
|
||||||
|
entry = self.accumulator.get(candidate)
|
||||||
|
if entry is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
decision = self._make_decision(candidate, entry, now)
|
||||||
|
if decision is not None:
|
||||||
|
decisions.append(decision)
|
||||||
|
|
||||||
|
# 维护冷却表容量
|
||||||
|
self._evict_cooldowns(now)
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 内部
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_decision(
|
||||||
|
self,
|
||||||
|
candidate: CandidateEvent,
|
||||||
|
entry: AccumulationEntry,
|
||||||
|
now: float,
|
||||||
|
) -> Optional[TriggerDecision]:
|
||||||
|
cooldown_key: _TriggerKey = entry.key
|
||||||
|
|
||||||
|
# 冷却检查
|
||||||
|
last_fire = self._cooldowns.get(cooldown_key)
|
||||||
|
if last_fire is not None and (now - last_fire) < self.cooldown_seconds:
|
||||||
|
self._cooled += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 严重性快速通道
|
||||||
|
if entry.max_severity in self.severity_bypass:
|
||||||
|
self._bypassed += 1
|
||||||
|
self._mark_cooldown(cooldown_key, now)
|
||||||
|
return TriggerDecision(
|
||||||
|
candidate=candidate,
|
||||||
|
entry=entry,
|
||||||
|
reason=f"severity_bypass:{entry.max_severity.value}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 多帧累积阈值
|
||||||
|
if (
|
||||||
|
entry.consecutive_hits >= self.min_consecutive_hits
|
||||||
|
and entry.avg_confidence >= self.min_avg_confidence
|
||||||
|
):
|
||||||
|
self._triggered += 1
|
||||||
|
self._mark_cooldown(cooldown_key, now)
|
||||||
|
return TriggerDecision(
|
||||||
|
candidate=candidate,
|
||||||
|
entry=entry,
|
||||||
|
reason=(
|
||||||
|
f"hits={entry.consecutive_hits}>={self.min_consecutive_hits},"
|
||||||
|
f"avg_conf={entry.avg_confidence:.3f}>="
|
||||||
|
f"{self.min_avg_confidence:.2f}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _mark_cooldown(self, key: _TriggerKey, now: float) -> None:
|
||||||
|
self._cooldowns[key] = now
|
||||||
|
self._cooldowns.move_to_end(key)
|
||||||
|
while len(self._cooldowns) > self.max_cooldown_entries:
|
||||||
|
self._cooldowns.popitem(last=False)
|
||||||
|
|
||||||
|
def _evict_cooldowns(self, now: float) -> None:
|
||||||
|
if self.cooldown_seconds <= 0:
|
||||||
|
self._cooldowns.clear()
|
||||||
|
return
|
||||||
|
expired = [
|
||||||
|
key
|
||||||
|
for key, ts in self._cooldowns.items()
|
||||||
|
if (now - ts) >= self.cooldown_seconds
|
||||||
|
]
|
||||||
|
for key in expired:
|
||||||
|
self._cooldowns.pop(key, None)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 自省
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> Dict[str, int]:
|
||||||
|
return {
|
||||||
|
"evaluated": self._evaluated,
|
||||||
|
"triggered": self._triggered,
|
||||||
|
"cooled": self._cooled,
|
||||||
|
"bypassed": self._bypassed,
|
||||||
|
"active_cooldowns": len(self._cooldowns),
|
||||||
|
"active_accumulations": self.accumulator.active_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._cooldowns.clear()
|
||||||
|
self._evaluated = 0
|
||||||
|
self._triggered = 0
|
||||||
|
self._cooled = 0
|
||||||
|
self._bypassed = 0
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["LLMTrigger", "TriggerDecision"]
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
"""LLM 分析服务 (MVP-3 / D29)
|
||||||
|
|
||||||
|
调用多模态大模型 (GPT-4V / Qwen-VL / GLM-4V) 对可疑事件进行二次判断。
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
|
||||||
|
1. **provider 抽象**: ``BaseLLMProvider`` 定义统一接口,
|
||||||
|
``OpenAICompatibleProvider`` 覆盖所有 OpenAI 协议兼容的 vendor,
|
||||||
|
``MockLLMProvider`` 提供完全离线的可预测行为,便于测试与本地开发。
|
||||||
|
2. **并发控制**: 使用 ``asyncio.Semaphore`` 限制同时进行的 LLM 调用数量,
|
||||||
|
避免大量并发请求拖垮 API 配额。
|
||||||
|
3. **降级策略**: 上层调用方可通过 ``analyze_with_fallback`` 在 LLM 失败时
|
||||||
|
返回 ``confirmed=None`` 的"未确认"结果,由 ``ResultFusion`` 决定如何处理。
|
||||||
|
4. **图像编码**: 自动按最大边长缩放后编码为 base64 JPEG,控制 token 成本。
|
||||||
|
5. **结构化输出**: prompt 强制 LLM 输出 JSON,解析失败时降级为
|
||||||
|
``confirmed=None`` 而不是抛异常。
|
||||||
|
|
||||||
|
依赖说明:
|
||||||
|
|
||||||
|
- HTTP 客户端使用 ``httpx`` (已在 requirements 中)
|
||||||
|
- 图像编码使用 ``opencv-python`` 已存在的 cv2.imencode
|
||||||
|
|
||||||
|
无需新增 Python 依赖即可运行 (mock provider),仅当 ``provider != mock`` 时
|
||||||
|
才会真正发起 HTTP 请求。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from models.event_schemas import CandidateEvent, EventType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 数据结构
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMAnalysisRequest:
|
||||||
|
"""LLM 分析请求。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
candidate: 触发分析的候选事件 (含事件类型 / bbox)
|
||||||
|
frames: 关键帧序列 (BGR ``np.ndarray``),
|
||||||
|
通常取累积窗口内的 1-3 张代表帧
|
||||||
|
prompt_extra: 业务侧附加提示词 (可选)
|
||||||
|
request_id: 请求标识 (用于日志关联)
|
||||||
|
"""
|
||||||
|
|
||||||
|
candidate: CandidateEvent
|
||||||
|
frames: Sequence[np.ndarray]
|
||||||
|
prompt_extra: Optional[str] = None
|
||||||
|
request_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMAnalysisResult:
|
||||||
|
"""LLM 分析结果。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
confirmed: True=LLM 确认事件成立,False=否决,None=未知/调用失败
|
||||||
|
confidence: LLM 给出的置信度 [0, 1]
|
||||||
|
reasoning: LLM 的简短推理说明
|
||||||
|
provider: 实际调用的 provider 名称
|
||||||
|
model: 实际调用的模型名
|
||||||
|
latency_ms: 调用耗时 (毫秒)
|
||||||
|
error: 失败时的错误信息
|
||||||
|
raw: 原始返回 (调试用)
|
||||||
|
"""
|
||||||
|
|
||||||
|
confirmed: Optional[bool]
|
||||||
|
confidence: float = 0.0
|
||||||
|
reasoning: str = ""
|
||||||
|
provider: str = ""
|
||||||
|
model: str = ""
|
||||||
|
latency_ms: float = 0.0
|
||||||
|
error: Optional[str] = None
|
||||||
|
raw: Optional[str] = None
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider 抽象
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class BaseLLMProvider:
|
||||||
|
"""LLM provider 抽象。"""
|
||||||
|
|
||||||
|
name: str = "base"
|
||||||
|
|
||||||
|
async def analyze(self, request: LLMAnalysisRequest) -> LLMAnalysisResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class MockLLMProvider(BaseLLMProvider):
|
||||||
|
"""离线 Mock provider。
|
||||||
|
|
||||||
|
根据候选事件置信度生成可预测的判定结果,便于测试与无 API Key 环境。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- confidence >= 0.7 -> confirmed=True
|
||||||
|
- confidence < 0.3 -> confirmed=False
|
||||||
|
- 其余 -> confirmed=None
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "mock"
|
||||||
|
|
||||||
|
def __init__(self, model: str = "mock-vlm") -> None:
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
async def analyze(self, request: LLMAnalysisRequest) -> LLMAnalysisResult:
|
||||||
|
start = time.time()
|
||||||
|
await asyncio.sleep(0) # 模拟异步
|
||||||
|
conf = float(request.candidate.confidence)
|
||||||
|
if conf >= 0.7:
|
||||||
|
confirmed: Optional[bool] = True
|
||||||
|
elif conf < 0.3:
|
||||||
|
confirmed = False
|
||||||
|
else:
|
||||||
|
confirmed = None
|
||||||
|
|
||||||
|
reasoning = (
|
||||||
|
f"mock provider: candidate confidence={conf:.3f}, "
|
||||||
|
f"event_type={request.candidate.event_type.value}"
|
||||||
|
)
|
||||||
|
return LLMAnalysisResult(
|
||||||
|
confirmed=confirmed,
|
||||||
|
confidence=conf,
|
||||||
|
reasoning=reasoning,
|
||||||
|
provider=self.name,
|
||||||
|
model=self.model,
|
||||||
|
latency_ms=(time.time() - start) * 1000.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICompatibleProvider(BaseLLMProvider):
|
||||||
|
"""OpenAI 协议兼容 provider (覆盖 GPT-4V / Qwen-VL / GLM-4V 等)。"""
|
||||||
|
|
||||||
|
name = "openai"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_base: str,
|
||||||
|
api_key: str,
|
||||||
|
model: str,
|
||||||
|
timeout: float = 15.0,
|
||||||
|
max_retries: int = 2,
|
||||||
|
max_tokens: int = 512,
|
||||||
|
temperature: float = 0.0,
|
||||||
|
provider_name: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
if not api_base:
|
||||||
|
raise ValueError("api_base 不能为空")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("api_key 不能为空")
|
||||||
|
self.api_base = api_base.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.max_tokens = max_tokens
|
||||||
|
self.temperature = temperature
|
||||||
|
if provider_name:
|
||||||
|
self.name = provider_name
|
||||||
|
|
||||||
|
async def analyze(self, request: LLMAnalysisRequest) -> LLMAnalysisResult:
|
||||||
|
try:
|
||||||
|
import httpx # noqa: WPS433
|
||||||
|
except ImportError as exc: # pragma: no cover - 依赖在 requirements 已声明
|
||||||
|
return LLMAnalysisResult(
|
||||||
|
confirmed=None,
|
||||||
|
provider=self.name,
|
||||||
|
model=self.model,
|
||||||
|
error=f"httpx 未安装: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
url = f"{self.api_base}/chat/completions"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": _build_messages(request),
|
||||||
|
"max_tokens": self.max_tokens,
|
||||||
|
"temperature": self.temperature,
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt = 0
|
||||||
|
last_error: Optional[str] = None
|
||||||
|
start = time.time()
|
||||||
|
while attempt <= self.max_retries:
|
||||||
|
attempt += 1
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
resp = await client.post(url, headers=headers, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
content = (
|
||||||
|
data.get("choices", [{}])[0]
|
||||||
|
.get("message", {})
|
||||||
|
.get("content", "")
|
||||||
|
)
|
||||||
|
parsed = _parse_llm_json(content)
|
||||||
|
return LLMAnalysisResult(
|
||||||
|
confirmed=parsed.get("confirmed"),
|
||||||
|
confidence=float(parsed.get("confidence", 0.0) or 0.0),
|
||||||
|
reasoning=str(parsed.get("reasoning", "")),
|
||||||
|
provider=self.name,
|
||||||
|
model=self.model,
|
||||||
|
latency_ms=(time.time() - start) * 1000.0,
|
||||||
|
raw=content,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
logger.warning(
|
||||||
|
"LLM 调用失败 attempt=%d/%d err=%s",
|
||||||
|
attempt,
|
||||||
|
self.max_retries + 1,
|
||||||
|
last_error,
|
||||||
|
)
|
||||||
|
if attempt > self.max_retries:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(min(2 ** (attempt - 1), 5))
|
||||||
|
|
||||||
|
return LLMAnalysisResult(
|
||||||
|
confirmed=None,
|
||||||
|
provider=self.name,
|
||||||
|
model=self.model,
|
||||||
|
latency_ms=(time.time() - start) * 1000.0,
|
||||||
|
error=last_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LLMAnalysisService:
|
||||||
|
"""LLM 二次判断服务。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: LLM provider 实例 (Mock / OpenAI 兼容)
|
||||||
|
max_concurrency: 同时进行的最大调用数
|
||||||
|
image_max_side: 编码前图像最大边长 (像素)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: BaseLLMProvider,
|
||||||
|
max_concurrency: int = 2,
|
||||||
|
image_max_side: int = 768,
|
||||||
|
) -> None:
|
||||||
|
if max_concurrency < 1:
|
||||||
|
raise ValueError("max_concurrency 必须 >= 1")
|
||||||
|
if image_max_side < 64:
|
||||||
|
raise ValueError("image_max_side 必须 >= 64")
|
||||||
|
self.provider = provider
|
||||||
|
self._semaphore = asyncio.Semaphore(max_concurrency)
|
||||||
|
self.image_max_side = image_max_side
|
||||||
|
|
||||||
|
# 统计
|
||||||
|
self._call_count = 0
|
||||||
|
self._success_count = 0
|
||||||
|
self._failure_count = 0
|
||||||
|
self._total_latency_ms = 0.0
|
||||||
|
|
||||||
|
async def analyze(self, request: LLMAnalysisRequest) -> LLMAnalysisResult:
|
||||||
|
"""调用 LLM 分析单个事件 (并发受信号量限制)。"""
|
||||||
|
|
||||||
|
# 图像缩放预处理 (mock provider 也走一遍,保持行为一致)
|
||||||
|
prepared_frames = [
|
||||||
|
_resize_frame(f, self.image_max_side) for f in request.frames
|
||||||
|
]
|
||||||
|
if any(p is not o for p, o in zip(prepared_frames, request.frames)):
|
||||||
|
request = LLMAnalysisRequest(
|
||||||
|
candidate=request.candidate,
|
||||||
|
frames=prepared_frames,
|
||||||
|
prompt_extra=request.prompt_extra,
|
||||||
|
request_id=request.request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self._semaphore:
|
||||||
|
self._call_count += 1
|
||||||
|
try:
|
||||||
|
result = await self.provider.analyze(request)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("LLM provider 异常: %s", exc)
|
||||||
|
result = LLMAnalysisResult(
|
||||||
|
confirmed=None,
|
||||||
|
provider=getattr(self.provider, "name", "unknown"),
|
||||||
|
model=getattr(self.provider, "model", ""),
|
||||||
|
error=f"{type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.error or result.confirmed is None:
|
||||||
|
self._failure_count += 1
|
||||||
|
else:
|
||||||
|
self._success_count += 1
|
||||||
|
self._total_latency_ms += result.latency_ms
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def analyze_with_fallback(
|
||||||
|
self,
|
||||||
|
request: LLMAnalysisRequest,
|
||||||
|
) -> LLMAnalysisResult:
|
||||||
|
"""带降级的分析: 任意异常都不会向上抛出。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self.analyze(request)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error("LLM 分析降级: %s", exc)
|
||||||
|
return LLMAnalysisResult(
|
||||||
|
confirmed=None,
|
||||||
|
provider=getattr(self.provider, "name", "unknown"),
|
||||||
|
model=getattr(self.provider, "model", ""),
|
||||||
|
error=f"{type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> Dict[str, Any]:
|
||||||
|
avg_latency = (
|
||||||
|
self._total_latency_ms / self._call_count
|
||||||
|
if self._call_count > 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"provider": getattr(self.provider, "name", "unknown"),
|
||||||
|
"model": getattr(self.provider, "model", ""),
|
||||||
|
"call_count": self._call_count,
|
||||||
|
"success_count": self._success_count,
|
||||||
|
"failure_count": self._failure_count,
|
||||||
|
"avg_latency_ms": round(avg_latency, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Factory
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def create_llm_service_from_settings(settings: Any) -> Optional[LLMAnalysisService]:
|
||||||
|
"""根据 ``Settings.llm`` 配置构造 LLMAnalysisService。
|
||||||
|
|
||||||
|
返回 None 表示未启用,调用方应跳过 LLM 分析。
|
||||||
|
"""
|
||||||
|
|
||||||
|
llm_cfg = getattr(settings, "llm", None)
|
||||||
|
if llm_cfg is None or not getattr(llm_cfg, "enabled", False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
provider_name = (llm_cfg.provider or "mock").lower()
|
||||||
|
if provider_name == "mock":
|
||||||
|
provider: BaseLLMProvider = MockLLMProvider(model=llm_cfg.model or "mock-vlm")
|
||||||
|
else:
|
||||||
|
api_base = llm_cfg.api_base
|
||||||
|
api_key = llm_cfg.api_key
|
||||||
|
if not api_base or not api_key:
|
||||||
|
logger.warning(
|
||||||
|
"LLM provider=%s 缺少 api_base/api_key,回退到 mock", provider_name
|
||||||
|
)
|
||||||
|
provider = MockLLMProvider(model=llm_cfg.model or "mock-vlm")
|
||||||
|
else:
|
||||||
|
provider = OpenAICompatibleProvider(
|
||||||
|
api_base=api_base,
|
||||||
|
api_key=api_key,
|
||||||
|
model=llm_cfg.model,
|
||||||
|
timeout=llm_cfg.timeout,
|
||||||
|
max_retries=llm_cfg.max_retries,
|
||||||
|
max_tokens=llm_cfg.max_tokens,
|
||||||
|
temperature=llm_cfg.temperature,
|
||||||
|
provider_name=provider_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return LLMAnalysisService(
|
||||||
|
provider=provider,
|
||||||
|
max_concurrency=llm_cfg.max_concurrency,
|
||||||
|
image_max_side=llm_cfg.image_max_side,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_EVENT_TYPE_DESCRIPTIONS: Dict[EventType, str] = {
|
||||||
|
EventType.FIRE: "fire / open flame",
|
||||||
|
EventType.SMOKE: "smoke / haze",
|
||||||
|
EventType.SMOKING: "a person smoking a cigarette",
|
||||||
|
EventType.FIGHT: "a fight or physical altercation",
|
||||||
|
EventType.LOITERING: "a person loitering / lingering suspiciously",
|
||||||
|
EventType.STATIONARY: "a stationary person (possibly fallen or unconscious)",
|
||||||
|
EventType.INTRUSION: "an intrusion into a restricted area",
|
||||||
|
EventType.ILLEGAL_PARKING: "a vehicle illegally parked",
|
||||||
|
EventType.VEHICLE: "a vehicle of interest",
|
||||||
|
EventType.PERSON: "a person of interest",
|
||||||
|
EventType.UNKNOWN: "an unspecified suspicious event",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages(request: LLMAnalysisRequest) -> List[Dict[str, Any]]:
|
||||||
|
"""构造 OpenAI ChatCompletion messages。"""
|
||||||
|
|
||||||
|
candidate = request.candidate
|
||||||
|
event_desc = _EVENT_TYPE_DESCRIPTIONS.get(candidate.event_type, "an event")
|
||||||
|
bbox = candidate.detection.bbox.to_list()
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"You are a strict video surveillance auditor. "
|
||||||
|
"Given one or more frames and a candidate event reported by an AI detector, "
|
||||||
|
"decide whether the event is truly present. "
|
||||||
|
"Respond with a single JSON object: "
|
||||||
|
'{"confirmed": true|false, "confidence": 0.0-1.0, "reasoning": "..."} '
|
||||||
|
"and nothing else."
|
||||||
|
)
|
||||||
|
|
||||||
|
user_text_parts: List[str] = [
|
||||||
|
f"Candidate event: {event_desc}.",
|
||||||
|
f"Detector confidence: {candidate.confidence:.3f}.",
|
||||||
|
f"Bounding box (x1,y1,x2,y2): {bbox}.",
|
||||||
|
]
|
||||||
|
if request.prompt_extra:
|
||||||
|
user_text_parts.append(request.prompt_extra)
|
||||||
|
user_text_parts.append(
|
||||||
|
"Please answer strictly in JSON: "
|
||||||
|
'{"confirmed": <bool>, "confidence": <float>, "reasoning": <str>}'
|
||||||
|
)
|
||||||
|
|
||||||
|
content: List[Dict[str, Any]] = [
|
||||||
|
{"type": "text", "text": "\n".join(user_text_parts)},
|
||||||
|
]
|
||||||
|
for frame in request.frames:
|
||||||
|
b64 = _encode_frame_base64(frame)
|
||||||
|
if b64:
|
||||||
|
content.append(
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": content},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_frame_base64(frame: np.ndarray) -> Optional[str]:
|
||||||
|
"""将 BGR 帧编码为 base64 JPEG 字符串。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cv2 # noqa: WPS433
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
return None
|
||||||
|
if frame is None or frame.size == 0:
|
||||||
|
return None
|
||||||
|
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
return base64.b64encode(buf.tobytes()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _resize_frame(frame: np.ndarray, max_side: int) -> np.ndarray:
|
||||||
|
"""按最大边长等比缩放 (节省 token)。"""
|
||||||
|
|
||||||
|
if frame is None or frame.size == 0:
|
||||||
|
return frame
|
||||||
|
try:
|
||||||
|
import cv2 # noqa: WPS433
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
return frame
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
longest = max(h, w)
|
||||||
|
if longest <= max_side:
|
||||||
|
return frame
|
||||||
|
scale = max_side / float(longest)
|
||||||
|
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
|
||||||
|
return cv2.resize(frame, new_size, interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
|
|
||||||
|
_JSON_PATTERN = re.compile(r"\{.*\}", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_llm_json(content: str) -> Dict[str, Any]:
|
||||||
|
"""从 LLM 文本输出中尽力解析 JSON。"""
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
match = _JSON_PATTERN.search(content)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return json.loads(match.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.debug("LLM JSON 解析失败: %s", content[:200])
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LLMAnalysisService",
|
||||||
|
"LLMAnalysisRequest",
|
||||||
|
"LLMAnalysisResult",
|
||||||
|
"BaseLLMProvider",
|
||||||
|
"MockLLMProvider",
|
||||||
|
"OpenAICompatibleProvider",
|
||||||
|
"create_llm_service_from_settings",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
"""LLM 成本追踪器 + 增强降级策略 (MVP-3 / D34)
|
||||||
|
|
||||||
|
职责
|
||||||
|
----
|
||||||
|
|
||||||
|
1. 记录每次 LLM 调用的 token / 费用 / 延迟,提供按时段聚合的统计
|
||||||
|
2. 实现"硬熔断"降级策略: 当达到日预算 / 错误率阈值时自动禁用 LLM
|
||||||
|
3. 提供成本仪表盘所需的导出接口(供 ``api/llm.py`` 暴露给前端)
|
||||||
|
|
||||||
|
设计要点
|
||||||
|
--------
|
||||||
|
|
||||||
|
* **职责分离**:成本追踪本身只负责"记账 + 状态机",不直接决定能否调用 LLM;
|
||||||
|
调用方 (例如检测管道) 在每次调用前调用 ``can_call()``,根据结果决定降级
|
||||||
|
* **滑动窗口**:使用按 UTC 日历日的桶 (``deque``) 保存最近 N 天的统计,
|
||||||
|
避免长时间运行内存无限增长
|
||||||
|
* **熔断**:达到错误率阈值 (默认连续 ``error_threshold`` 次调用失败率 >50%)
|
||||||
|
自动进入 ``CIRCUIT_OPEN`` 状态,``cooldown_seconds`` 后自动半开尝试恢复
|
||||||
|
* **Token 估算**:未拿到真实 usage 时按"图像 + prompt 长度"做粗估,
|
||||||
|
保证账单不会因为 provider 不返回 usage 而归零
|
||||||
|
|
||||||
|
接入方式
|
||||||
|
--------
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
tracker = LLMCostTracker(daily_budget_usd=5.0)
|
||||||
|
if not tracker.can_call():
|
||||||
|
return None # 降级处理
|
||||||
|
result = await llm_service.analyze_with_fallback(req)
|
||||||
|
tracker.record(result, request=req)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Deque, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 常量 / 数据结构
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# 不同 provider 的近似定价 (USD per 1M tokens),仅供估算
|
||||||
|
DEFAULT_PRICING: Dict[str, Dict[str, float]] = {
|
||||||
|
# OpenAI GPT-4o
|
||||||
|
"openai": {"prompt": 5.0, "completion": 15.0, "image": 0.005},
|
||||||
|
# 豆包 / Qwen / GLM 国内 VLM 大致价位(仅估算)
|
||||||
|
"qwen": {"prompt": 0.8, "completion": 2.0, "image": 0.001},
|
||||||
|
"glm": {"prompt": 1.0, "completion": 3.0, "image": 0.001},
|
||||||
|
"doubao": {"prompt": 0.8, "completion": 2.0, "image": 0.001},
|
||||||
|
# mock provider 不计费
|
||||||
|
"mock": {"prompt": 0.0, "completion": 0.0, "image": 0.0},
|
||||||
|
# 兜底
|
||||||
|
"default": {"prompt": 1.0, "completion": 3.0, "image": 0.002},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitState:
|
||||||
|
CLOSED = "closed" # 正常
|
||||||
|
OPEN = "open" # 熔断
|
||||||
|
HALF_OPEN = "half_open" # 试探恢复
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CallRecord:
|
||||||
|
"""单次 LLM 调用记录。"""
|
||||||
|
|
||||||
|
timestamp: float
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
success: bool
|
||||||
|
confirmed: Optional[bool]
|
||||||
|
latency_ms: float
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
image_count: int
|
||||||
|
cost_usd: float
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _DailyBucket:
|
||||||
|
"""按 UTC 日历日聚合的桶。"""
|
||||||
|
|
||||||
|
date: str
|
||||||
|
call_count: int = 0
|
||||||
|
success_count: int = 0
|
||||||
|
failure_count: int = 0
|
||||||
|
total_prompt_tokens: int = 0
|
||||||
|
total_completion_tokens: int = 0
|
||||||
|
total_image_count: int = 0
|
||||||
|
total_cost_usd: float = 0.0
|
||||||
|
total_latency_ms: float = 0.0
|
||||||
|
|
||||||
|
def add(self, record: CallRecord) -> None:
|
||||||
|
self.call_count += 1
|
||||||
|
if record.success:
|
||||||
|
self.success_count += 1
|
||||||
|
else:
|
||||||
|
self.failure_count += 1
|
||||||
|
self.total_prompt_tokens += record.prompt_tokens
|
||||||
|
self.total_completion_tokens += record.completion_tokens
|
||||||
|
self.total_image_count += record.image_count
|
||||||
|
self.total_cost_usd += record.cost_usd
|
||||||
|
self.total_latency_ms += record.latency_ms
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"date": self.date,
|
||||||
|
"call_count": self.call_count,
|
||||||
|
"success_count": self.success_count,
|
||||||
|
"failure_count": self.failure_count,
|
||||||
|
"prompt_tokens": self.total_prompt_tokens,
|
||||||
|
"completion_tokens": self.total_completion_tokens,
|
||||||
|
"image_count": self.total_image_count,
|
||||||
|
"cost_usd": round(self.total_cost_usd, 6),
|
||||||
|
"avg_latency_ms": round(
|
||||||
|
self.total_latency_ms / self.call_count, 2
|
||||||
|
)
|
||||||
|
if self.call_count
|
||||||
|
else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLMCostTracker
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LLMCostTracker:
|
||||||
|
"""LLM 成本追踪器 + 熔断降级。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
daily_budget_usd: 日预算,0 表示不限
|
||||||
|
history_days: 保留多少天的日级统计
|
||||||
|
recent_window: 用于错误率计算的最近调用窗口
|
||||||
|
error_rate_threshold: 触发熔断的错误率阈值 [0, 1]
|
||||||
|
cooldown_seconds: 熔断进入半开状态的冷却时间
|
||||||
|
pricing_overrides: 自定义价格表 (覆盖默认)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
daily_budget_usd: float = 0.0,
|
||||||
|
history_days: int = 7,
|
||||||
|
recent_window: int = 20,
|
||||||
|
error_rate_threshold: float = 0.5,
|
||||||
|
cooldown_seconds: float = 60.0,
|
||||||
|
pricing_overrides: Optional[Dict[str, Dict[str, float]]] = None,
|
||||||
|
) -> None:
|
||||||
|
if daily_budget_usd < 0:
|
||||||
|
raise ValueError("daily_budget_usd 必须 >= 0")
|
||||||
|
if history_days < 1:
|
||||||
|
raise ValueError("history_days 必须 >= 1")
|
||||||
|
if recent_window < 1:
|
||||||
|
raise ValueError("recent_window 必须 >= 1")
|
||||||
|
if not 0.0 < error_rate_threshold <= 1.0:
|
||||||
|
raise ValueError("error_rate_threshold 必须在 (0, 1]")
|
||||||
|
if cooldown_seconds < 0:
|
||||||
|
raise ValueError("cooldown_seconds 必须 >= 0")
|
||||||
|
|
||||||
|
self.daily_budget_usd = daily_budget_usd
|
||||||
|
self.history_days = history_days
|
||||||
|
self.recent_window = recent_window
|
||||||
|
self.error_rate_threshold = error_rate_threshold
|
||||||
|
self.cooldown_seconds = cooldown_seconds
|
||||||
|
|
||||||
|
# 合并定价
|
||||||
|
self._pricing: Dict[str, Dict[str, float]] = {
|
||||||
|
k: dict(v) for k, v in DEFAULT_PRICING.items()
|
||||||
|
}
|
||||||
|
if pricing_overrides:
|
||||||
|
for provider, table in pricing_overrides.items():
|
||||||
|
self._pricing.setdefault(provider, {})
|
||||||
|
self._pricing[provider].update(table)
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
self._buckets: Deque[_DailyBucket] = deque(maxlen=history_days)
|
||||||
|
self._recent: Deque[bool] = deque(maxlen=recent_window)
|
||||||
|
self._records: Deque[CallRecord] = deque(maxlen=200)
|
||||||
|
self._circuit_state = CircuitState.CLOSED
|
||||||
|
self._circuit_opened_at: Optional[float] = None
|
||||||
|
self._manually_disabled = False
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 准入检查
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def can_call(self, now: Optional[float] = None) -> bool:
|
||||||
|
"""同步判断当前是否允许调用 LLM。
|
||||||
|
|
||||||
|
触发拒绝的条件按优先级:
|
||||||
|
1. 手动禁用
|
||||||
|
2. 日预算超限
|
||||||
|
3. 熔断器处于 OPEN 且未到冷却结束
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._manually_disabled:
|
||||||
|
return False
|
||||||
|
if self._budget_exhausted():
|
||||||
|
return False
|
||||||
|
|
||||||
|
ts = now if now is not None else time.time()
|
||||||
|
if self._circuit_state == CircuitState.OPEN:
|
||||||
|
if (
|
||||||
|
self._circuit_opened_at is not None
|
||||||
|
and (ts - self._circuit_opened_at) >= self.cooldown_seconds
|
||||||
|
):
|
||||||
|
# 进入半开,允许一次试探
|
||||||
|
self._circuit_state = CircuitState.HALF_OPEN
|
||||||
|
logger.info("LLMCostTracker 熔断器进入 HALF_OPEN")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def reason_for_block(self, now: Optional[float] = None) -> Optional[str]:
|
||||||
|
"""返回当前不可调用的原因 (调试用),None 表示可以调用。"""
|
||||||
|
|
||||||
|
if self._manually_disabled:
|
||||||
|
return "manually_disabled"
|
||||||
|
if self._budget_exhausted():
|
||||||
|
return f"daily_budget_exceeded({self._today_cost():.4f}/{self.daily_budget_usd})"
|
||||||
|
ts = now if now is not None else time.time()
|
||||||
|
if self._circuit_state == CircuitState.OPEN:
|
||||||
|
if (
|
||||||
|
self._circuit_opened_at is not None
|
||||||
|
and (ts - self._circuit_opened_at) < self.cooldown_seconds
|
||||||
|
):
|
||||||
|
return "circuit_open"
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 记录
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def record(
|
||||||
|
self,
|
||||||
|
result: Any,
|
||||||
|
prompt_tokens: Optional[int] = None,
|
||||||
|
completion_tokens: Optional[int] = None,
|
||||||
|
image_count: int = 1,
|
||||||
|
provider_override: Optional[str] = None,
|
||||||
|
) -> CallRecord:
|
||||||
|
"""记录一次 LLM 调用。
|
||||||
|
|
||||||
|
``result`` 可以是 ``LLMAnalysisResult`` 实例或鸭子类型 (含 confirmed
|
||||||
|
/ provider / model / latency_ms / error / metadata)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
provider = provider_override or getattr(result, "provider", "default")
|
||||||
|
model = getattr(result, "model", "")
|
||||||
|
success = getattr(result, "error", None) is None and getattr(
|
||||||
|
result, "confirmed", None
|
||||||
|
) is not None
|
||||||
|
latency_ms = float(getattr(result, "latency_ms", 0.0) or 0.0)
|
||||||
|
|
||||||
|
usage = self._extract_usage(result)
|
||||||
|
prompt_tokens = (
|
||||||
|
int(prompt_tokens)
|
||||||
|
if prompt_tokens is not None
|
||||||
|
else int(usage.get("prompt_tokens") or 0)
|
||||||
|
)
|
||||||
|
completion_tokens = (
|
||||||
|
int(completion_tokens)
|
||||||
|
if completion_tokens is not None
|
||||||
|
else int(usage.get("completion_tokens") or 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# token 估算兜底
|
||||||
|
if prompt_tokens == 0 and completion_tokens == 0 and provider != "mock":
|
||||||
|
prompt_tokens = self._estimate_prompt_tokens(image_count)
|
||||||
|
completion_tokens = self._estimate_completion_tokens(result)
|
||||||
|
|
||||||
|
cost = self._calc_cost(provider, prompt_tokens, completion_tokens, image_count)
|
||||||
|
|
||||||
|
record = CallRecord(
|
||||||
|
timestamp=time.time(),
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
success=success,
|
||||||
|
confirmed=getattr(result, "confirmed", None),
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
image_count=image_count,
|
||||||
|
cost_usd=cost,
|
||||||
|
error=getattr(result, "error", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._append_record(record)
|
||||||
|
self._update_circuit(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 控制
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def disable(self) -> None:
|
||||||
|
"""手动禁用 LLM 调用 (运维场景)。"""
|
||||||
|
|
||||||
|
self._manually_disabled = True
|
||||||
|
logger.warning("LLMCostTracker 已被手动禁用")
|
||||||
|
|
||||||
|
def enable(self) -> None:
|
||||||
|
"""手动重新启用。"""
|
||||||
|
|
||||||
|
self._manually_disabled = False
|
||||||
|
self._circuit_state = CircuitState.CLOSED
|
||||||
|
self._circuit_opened_at = None
|
||||||
|
logger.info("LLMCostTracker 已重新启用")
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""清空全部统计 (谨慎使用)。"""
|
||||||
|
|
||||||
|
self._buckets.clear()
|
||||||
|
self._recent.clear()
|
||||||
|
self._records.clear()
|
||||||
|
self._circuit_state = CircuitState.CLOSED
|
||||||
|
self._circuit_opened_at = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 导出
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
return not self._manually_disabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def circuit_state(self) -> str:
|
||||||
|
return self._circuit_state
|
||||||
|
|
||||||
|
def daily_summary(self) -> List[Dict[str, Any]]:
|
||||||
|
return [bucket.to_dict() for bucket in self._buckets]
|
||||||
|
|
||||||
|
def today_summary(self) -> Dict[str, Any]:
|
||||||
|
bucket = self._today_bucket(create=False)
|
||||||
|
if bucket is None:
|
||||||
|
return {
|
||||||
|
"date": _today_str(),
|
||||||
|
"call_count": 0,
|
||||||
|
"cost_usd": 0.0,
|
||||||
|
"budget_usd": self.daily_budget_usd,
|
||||||
|
"budget_used_ratio": 0.0,
|
||||||
|
}
|
||||||
|
data = bucket.to_dict()
|
||||||
|
data["budget_usd"] = self.daily_budget_usd
|
||||||
|
data["budget_used_ratio"] = (
|
||||||
|
data["cost_usd"] / self.daily_budget_usd
|
||||||
|
if self.daily_budget_usd > 0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def recent_records(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
records = list(self._records)[-limit:]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"timestamp": r.timestamp,
|
||||||
|
"provider": r.provider,
|
||||||
|
"model": r.model,
|
||||||
|
"success": r.success,
|
||||||
|
"confirmed": r.confirmed,
|
||||||
|
"latency_ms": round(r.latency_ms, 2),
|
||||||
|
"prompt_tokens": r.prompt_tokens,
|
||||||
|
"completion_tokens": r.completion_tokens,
|
||||||
|
"image_count": r.image_count,
|
||||||
|
"cost_usd": round(r.cost_usd, 6),
|
||||||
|
"error": r.error,
|
||||||
|
}
|
||||||
|
for r in reversed(records)
|
||||||
|
]
|
||||||
|
|
||||||
|
def stats(self) -> Dict[str, Any]:
|
||||||
|
recent_total = len(self._recent)
|
||||||
|
recent_failed = sum(1 for ok in self._recent if not ok)
|
||||||
|
recent_error_rate = (
|
||||||
|
recent_failed / recent_total if recent_total > 0 else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": self.is_enabled,
|
||||||
|
"circuit_state": self._circuit_state,
|
||||||
|
"circuit_opened_at": self._circuit_opened_at,
|
||||||
|
"block_reason": self.reason_for_block(),
|
||||||
|
"today": self.today_summary(),
|
||||||
|
"history_days": self.history_days,
|
||||||
|
"recent_window": self.recent_window,
|
||||||
|
"recent_error_rate": round(recent_error_rate, 4),
|
||||||
|
"error_rate_threshold": self.error_rate_threshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 内部
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _append_record(self, record: CallRecord) -> None:
|
||||||
|
bucket = self._today_bucket(create=True)
|
||||||
|
assert bucket is not None
|
||||||
|
bucket.add(record)
|
||||||
|
self._recent.append(record.success)
|
||||||
|
self._records.append(record)
|
||||||
|
|
||||||
|
def _today_bucket(self, *, create: bool) -> Optional[_DailyBucket]:
|
||||||
|
today = _today_str()
|
||||||
|
if self._buckets and self._buckets[-1].date == today:
|
||||||
|
return self._buckets[-1]
|
||||||
|
if not create:
|
||||||
|
for b in self._buckets:
|
||||||
|
if b.date == today:
|
||||||
|
return b
|
||||||
|
return None
|
||||||
|
bucket = _DailyBucket(date=today)
|
||||||
|
self._buckets.append(bucket)
|
||||||
|
return bucket
|
||||||
|
|
||||||
|
def _today_cost(self) -> float:
|
||||||
|
bucket = self._today_bucket(create=False)
|
||||||
|
return bucket.total_cost_usd if bucket else 0.0
|
||||||
|
|
||||||
|
def _budget_exhausted(self) -> bool:
|
||||||
|
if self.daily_budget_usd <= 0:
|
||||||
|
return False
|
||||||
|
return self._today_cost() >= self.daily_budget_usd
|
||||||
|
|
||||||
|
def _update_circuit(self, record: CallRecord) -> None:
|
||||||
|
# HALF_OPEN: 一次结果决定回到 CLOSED 还是 OPEN
|
||||||
|
if self._circuit_state == CircuitState.HALF_OPEN:
|
||||||
|
if record.success:
|
||||||
|
self._circuit_state = CircuitState.CLOSED
|
||||||
|
self._circuit_opened_at = None
|
||||||
|
logger.info("LLMCostTracker 熔断器恢复 CLOSED")
|
||||||
|
else:
|
||||||
|
self._circuit_state = CircuitState.OPEN
|
||||||
|
self._circuit_opened_at = time.time()
|
||||||
|
logger.warning("LLMCostTracker 半开试探失败,回到 OPEN")
|
||||||
|
return
|
||||||
|
|
||||||
|
# CLOSED: 检查最近窗口的错误率
|
||||||
|
if (
|
||||||
|
self._circuit_state == CircuitState.CLOSED
|
||||||
|
and len(self._recent) >= max(3, self.recent_window // 2)
|
||||||
|
):
|
||||||
|
failures = sum(1 for ok in self._recent if not ok)
|
||||||
|
error_rate = failures / len(self._recent)
|
||||||
|
if error_rate >= self.error_rate_threshold:
|
||||||
|
self._circuit_state = CircuitState.OPEN
|
||||||
|
self._circuit_opened_at = time.time()
|
||||||
|
logger.warning(
|
||||||
|
"LLMCostTracker 触发熔断: 最近 %d 次调用错误率 %.2f >= %.2f",
|
||||||
|
len(self._recent),
|
||||||
|
error_rate,
|
||||||
|
self.error_rate_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calc_cost(
|
||||||
|
self,
|
||||||
|
provider: str,
|
||||||
|
prompt_tokens: int,
|
||||||
|
completion_tokens: int,
|
||||||
|
image_count: int,
|
||||||
|
) -> float:
|
||||||
|
rates = self._pricing.get(provider) or self._pricing["default"]
|
||||||
|
cost = (
|
||||||
|
prompt_tokens / 1_000_000.0 * rates.get("prompt", 0.0)
|
||||||
|
+ completion_tokens / 1_000_000.0 * rates.get("completion", 0.0)
|
||||||
|
+ image_count * rates.get("image", 0.0)
|
||||||
|
)
|
||||||
|
return max(0.0, cost)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estimate_prompt_tokens(image_count: int) -> int:
|
||||||
|
# 经验值:每张图 ~ 200 token,prompt 模板 ~ 120 token
|
||||||
|
return 120 + 200 * max(0, image_count)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estimate_completion_tokens(result: Any) -> int:
|
||||||
|
reasoning = getattr(result, "reasoning", "") or ""
|
||||||
|
# 粗略 1.3 字符 = 1 token
|
||||||
|
return max(16, int(len(reasoning) / 1.3))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_usage(result: Any) -> Dict[str, Any]:
|
||||||
|
"""从结果对象上尽力获取 usage (provider 可能放 metadata 里)。"""
|
||||||
|
|
||||||
|
usage = getattr(result, "usage", None)
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
return usage
|
||||||
|
metadata = getattr(result, "metadata", None)
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
md_usage = metadata.get("usage")
|
||||||
|
if isinstance(md_usage, dict):
|
||||||
|
return md_usage
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 全局单例
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_global_tracker: Optional[LLMCostTracker] = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_global_tracker(
|
||||||
|
daily_budget_usd: float = 0.0,
|
||||||
|
history_days: int = 7,
|
||||||
|
recent_window: int = 20,
|
||||||
|
error_rate_threshold: float = 0.5,
|
||||||
|
cooldown_seconds: float = 60.0,
|
||||||
|
) -> LLMCostTracker:
|
||||||
|
"""初始化并返回全局成本追踪器。"""
|
||||||
|
|
||||||
|
global _global_tracker
|
||||||
|
_global_tracker = LLMCostTracker(
|
||||||
|
daily_budget_usd=daily_budget_usd,
|
||||||
|
history_days=history_days,
|
||||||
|
recent_window=recent_window,
|
||||||
|
error_rate_threshold=error_rate_threshold,
|
||||||
|
cooldown_seconds=cooldown_seconds,
|
||||||
|
)
|
||||||
|
return _global_tracker
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_tracker() -> Optional[LLMCostTracker]:
|
||||||
|
return _global_tracker
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 工具
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _today_str() -> str:
|
||||||
|
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LLMCostTracker",
|
||||||
|
"CallRecord",
|
||||||
|
"CircuitState",
|
||||||
|
"DEFAULT_PRICING",
|
||||||
|
"init_global_tracker",
|
||||||
|
"get_global_tracker",
|
||||||
|
]
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""结果融合器 (MVP-3 / D30)
|
||||||
|
|
||||||
|
将 YOLO 候选事件 (含规则引擎产出的 ``AlertEvent``) 与 LLM 分析结果
|
||||||
|
按指定策略融合,产出最终对外发出的 ``AlertEvent``。
|
||||||
|
|
||||||
|
策略说明
|
||||||
|
--------
|
||||||
|
|
||||||
|
1. ``weighted`` (加权平均):
|
||||||
|
final_conf = yolo_weight * yolo_conf + llm_weight * llm_conf
|
||||||
|
- LLM 明确否决时 (confirmed=False) 同样适用,但置信度向 0 收敛
|
||||||
|
- LLM 不确定 (confirmed=None) 时,若 ``fallback_to_yolo`` 为 True
|
||||||
|
直接保留 YOLO 结果
|
||||||
|
|
||||||
|
2. ``conservative`` (保守):
|
||||||
|
- LLM 否决 + ``suppress_on_llm_negative`` -> 抑制预警 (返回 None)
|
||||||
|
- LLM 确认 -> 取 ``min(yolo_conf, llm_conf)`` 与原值的最小值
|
||||||
|
- LLM 不确定 -> 抑制 (除非 ``fallback_to_yolo``)
|
||||||
|
|
||||||
|
3. ``llm_priority`` (LLM 优先):
|
||||||
|
- LLM 确认 -> 直接使用 LLM 置信度
|
||||||
|
- LLM 否决 -> 抑制
|
||||||
|
- LLM 不确定 -> 回退到 YOLO 置信度 (若 ``fallback_to_yolo``)
|
||||||
|
|
||||||
|
融合后会在 ``AlertEvent.metadata`` 中写入 ``llm`` 子对象,便于审计。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from models.event_schemas import AlertEvent, SeverityLevel
|
||||||
|
from services.llm_analysis_service import LLMAnalysisResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_STRATEGIES = ("weighted", "conservative", "llm_priority")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 融合输出
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FusionOutcome:
|
||||||
|
"""融合结果。"""
|
||||||
|
|
||||||
|
alert: Optional[AlertEvent]
|
||||||
|
suppressed: bool = False
|
||||||
|
reason: str = ""
|
||||||
|
final_confidence: float = 0.0
|
||||||
|
yolo_confidence: float = 0.0
|
||||||
|
llm_confidence: float = 0.0
|
||||||
|
strategy: str = ""
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ResultFusion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ResultFusion:
|
||||||
|
"""YOLO + LLM 结果融合器。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
strategy: 融合策略,见模块 docstring
|
||||||
|
yolo_weight: weighted 策略下 YOLO 权重
|
||||||
|
llm_weight: weighted 策略下 LLM 权重
|
||||||
|
suppress_on_llm_negative: LLM 明确否决时是否抑制预警
|
||||||
|
fallback_to_yolo: LLM 未确认 / 不可用时是否回退到 YOLO 结果
|
||||||
|
promote_severity_on_high_confidence: 高融合置信度是否提升严重性
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
strategy: str = "weighted",
|
||||||
|
yolo_weight: float = 0.4,
|
||||||
|
llm_weight: float = 0.6,
|
||||||
|
suppress_on_llm_negative: bool = True,
|
||||||
|
fallback_to_yolo: bool = True,
|
||||||
|
promote_severity_on_high_confidence: bool = False,
|
||||||
|
high_confidence_threshold: float = 0.85,
|
||||||
|
) -> None:
|
||||||
|
if strategy not in SUPPORTED_STRATEGIES:
|
||||||
|
raise ValueError(
|
||||||
|
f"不支持的融合策略: {strategy}, 支持: {SUPPORTED_STRATEGIES}"
|
||||||
|
)
|
||||||
|
if not 0.0 <= yolo_weight <= 1.0:
|
||||||
|
raise ValueError("yolo_weight 必须在 [0, 1]")
|
||||||
|
if not 0.0 <= llm_weight <= 1.0:
|
||||||
|
raise ValueError("llm_weight 必须在 [0, 1]")
|
||||||
|
total = yolo_weight + llm_weight
|
||||||
|
if total <= 0:
|
||||||
|
raise ValueError("yolo_weight + llm_weight 必须 > 0")
|
||||||
|
|
||||||
|
self.strategy = strategy
|
||||||
|
self.yolo_weight = yolo_weight / total
|
||||||
|
self.llm_weight = llm_weight / total
|
||||||
|
self.suppress_on_llm_negative = suppress_on_llm_negative
|
||||||
|
self.fallback_to_yolo = fallback_to_yolo
|
||||||
|
self.promote_severity_on_high_confidence = promote_severity_on_high_confidence
|
||||||
|
self.high_confidence_threshold = high_confidence_threshold
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 主入口
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fuse(
|
||||||
|
self,
|
||||||
|
alert: AlertEvent,
|
||||||
|
llm_result: Optional[LLMAnalysisResult],
|
||||||
|
) -> FusionOutcome:
|
||||||
|
"""融合单条预警事件与 LLM 结果。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
alert: 规则引擎/聚合器产出的预警事件 (尚未发布)
|
||||||
|
llm_result: LLM 分析结果,None 表示未触发 LLM
|
||||||
|
"""
|
||||||
|
|
||||||
|
yolo_conf = float(alert.confidence)
|
||||||
|
|
||||||
|
# 未调用 LLM 或 LLM 不可用
|
||||||
|
if llm_result is None:
|
||||||
|
return self._fallback_outcome(
|
||||||
|
alert,
|
||||||
|
yolo_conf=yolo_conf,
|
||||||
|
reason="llm_skipped",
|
||||||
|
)
|
||||||
|
|
||||||
|
if llm_result.error or llm_result.confirmed is None:
|
||||||
|
return self._fallback_outcome(
|
||||||
|
alert,
|
||||||
|
yolo_conf=yolo_conf,
|
||||||
|
llm_conf=llm_result.confidence,
|
||||||
|
reason=f"llm_unavailable:{llm_result.error or 'unknown'}",
|
||||||
|
llm_metadata=self._llm_metadata(llm_result),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 至此 LLM 给出明确判定
|
||||||
|
if self.strategy == "weighted":
|
||||||
|
outcome = self._fuse_weighted(alert, yolo_conf, llm_result)
|
||||||
|
elif self.strategy == "conservative":
|
||||||
|
outcome = self._fuse_conservative(alert, yolo_conf, llm_result)
|
||||||
|
else: # llm_priority
|
||||||
|
outcome = self._fuse_llm_priority(alert, yolo_conf, llm_result)
|
||||||
|
|
||||||
|
if outcome.alert is not None:
|
||||||
|
self._apply_metadata(outcome.alert, llm_result, outcome)
|
||||||
|
if self.promote_severity_on_high_confidence:
|
||||||
|
self._maybe_promote_severity(outcome.alert, outcome.final_confidence)
|
||||||
|
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 策略实现
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fuse_weighted(
|
||||||
|
self,
|
||||||
|
alert: AlertEvent,
|
||||||
|
yolo_conf: float,
|
||||||
|
llm_result: LLMAnalysisResult,
|
||||||
|
) -> FusionOutcome:
|
||||||
|
# 否决降权: confirmed=False 时 LLM 置信度按 1-conf 反转
|
||||||
|
llm_conf = (
|
||||||
|
float(llm_result.confidence)
|
||||||
|
if llm_result.confirmed
|
||||||
|
else max(0.0, 1.0 - float(llm_result.confidence))
|
||||||
|
)
|
||||||
|
# confirmed=False 直接将 LLM 端贡献当作"反对票"
|
||||||
|
if not llm_result.confirmed:
|
||||||
|
# 反转后 llm 端置信度越高代表越反对,因此对最终值取 (yolo*w_y) - llm*w_l
|
||||||
|
final = self.yolo_weight * yolo_conf - self.llm_weight * llm_conf
|
||||||
|
final = max(0.0, min(1.0, final))
|
||||||
|
else:
|
||||||
|
final = self.yolo_weight * yolo_conf + self.llm_weight * llm_conf
|
||||||
|
final = max(0.0, min(1.0, final))
|
||||||
|
|
||||||
|
# 反对足够强且策略允许时抑制
|
||||||
|
if (
|
||||||
|
not llm_result.confirmed
|
||||||
|
and self.suppress_on_llm_negative
|
||||||
|
and final < 0.2
|
||||||
|
):
|
||||||
|
return FusionOutcome(
|
||||||
|
alert=None,
|
||||||
|
suppressed=True,
|
||||||
|
reason="weighted_suppressed_by_llm_negative",
|
||||||
|
final_confidence=final,
|
||||||
|
yolo_confidence=yolo_conf,
|
||||||
|
llm_confidence=float(llm_result.confidence),
|
||||||
|
strategy=self.strategy,
|
||||||
|
metadata=self._llm_metadata(llm_result),
|
||||||
|
)
|
||||||
|
|
||||||
|
alert.confidence = round(final, 4)
|
||||||
|
return FusionOutcome(
|
||||||
|
alert=alert,
|
||||||
|
suppressed=False,
|
||||||
|
reason="weighted",
|
||||||
|
final_confidence=final,
|
||||||
|
yolo_confidence=yolo_conf,
|
||||||
|
llm_confidence=float(llm_result.confidence),
|
||||||
|
strategy=self.strategy,
|
||||||
|
metadata=self._llm_metadata(llm_result),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fuse_conservative(
|
||||||
|
self,
|
||||||
|
alert: AlertEvent,
|
||||||
|
yolo_conf: float,
|
||||||
|
llm_result: LLMAnalysisResult,
|
||||||
|
) -> FusionOutcome:
|
||||||
|
if not llm_result.confirmed:
|
||||||
|
if self.suppress_on_llm_negative:
|
||||||
|
return FusionOutcome(
|
||||||
|
alert=None,
|
||||||
|
suppressed=True,
|
||||||
|
reason="conservative_llm_negative",
|
||||||
|
final_confidence=0.0,
|
||||||
|
yolo_confidence=yolo_conf,
|
||||||
|
llm_confidence=float(llm_result.confidence),
|
||||||
|
strategy=self.strategy,
|
||||||
|
metadata=self._llm_metadata(llm_result),
|
||||||
|
)
|
||||||
|
# 不抑制时也大幅降权
|
||||||
|
final = min(yolo_conf, 1.0 - float(llm_result.confidence))
|
||||||
|
else:
|
||||||
|
# 双方都确认: 取较小者,体现保守
|
||||||
|
final = min(yolo_conf, float(llm_result.confidence))
|
||||||
|
|
||||||
|
final = max(0.0, min(1.0, final))
|
||||||
|
alert.confidence = round(final, 4)
|
||||||
|
return FusionOutcome(
|
||||||
|
alert=alert,
|
||||||
|
suppressed=False,
|
||||||
|
reason="conservative",
|
||||||
|
final_confidence=final,
|
||||||
|
yolo_confidence=yolo_conf,
|
||||||
|
llm_confidence=float(llm_result.confidence),
|
||||||
|
strategy=self.strategy,
|
||||||
|
metadata=self._llm_metadata(llm_result),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fuse_llm_priority(
|
||||||
|
self,
|
||||||
|
alert: AlertEvent,
|
||||||
|
yolo_conf: float,
|
||||||
|
llm_result: LLMAnalysisResult,
|
||||||
|
) -> FusionOutcome:
|
||||||
|
if not llm_result.confirmed:
|
||||||
|
if self.suppress_on_llm_negative:
|
||||||
|
return FusionOutcome(
|
||||||
|
alert=None,
|
||||||
|
suppressed=True,
|
||||||
|
reason="llm_priority_negative",
|
||||||
|
final_confidence=0.0,
|
||||||
|
yolo_confidence=yolo_conf,
|
||||||
|
llm_confidence=float(llm_result.confidence),
|
||||||
|
strategy=self.strategy,
|
||||||
|
metadata=self._llm_metadata(llm_result),
|
||||||
|
)
|
||||||
|
final = max(0.0, 1.0 - float(llm_result.confidence))
|
||||||
|
else:
|
||||||
|
final = float(llm_result.confidence)
|
||||||
|
|
||||||
|
final = max(0.0, min(1.0, final))
|
||||||
|
alert.confidence = round(final, 4)
|
||||||
|
return FusionOutcome(
|
||||||
|
alert=alert,
|
||||||
|
suppressed=False,
|
||||||
|
reason="llm_priority",
|
||||||
|
final_confidence=final,
|
||||||
|
yolo_confidence=yolo_conf,
|
||||||
|
llm_confidence=float(llm_result.confidence),
|
||||||
|
strategy=self.strategy,
|
||||||
|
metadata=self._llm_metadata(llm_result),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 降级
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fallback_outcome(
|
||||||
|
self,
|
||||||
|
alert: AlertEvent,
|
||||||
|
yolo_conf: float,
|
||||||
|
llm_conf: float = 0.0,
|
||||||
|
reason: str = "fallback",
|
||||||
|
llm_metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> FusionOutcome:
|
||||||
|
if not self.fallback_to_yolo:
|
||||||
|
return FusionOutcome(
|
||||||
|
alert=None,
|
||||||
|
suppressed=True,
|
||||||
|
reason=f"{reason}:fallback_disabled",
|
||||||
|
final_confidence=0.0,
|
||||||
|
yolo_confidence=yolo_conf,
|
||||||
|
llm_confidence=llm_conf,
|
||||||
|
strategy=self.strategy,
|
||||||
|
metadata=llm_metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
alert.metadata.setdefault("llm", {})
|
||||||
|
if llm_metadata:
|
||||||
|
alert.metadata["llm"].update(llm_metadata)
|
||||||
|
alert.metadata["llm"]["fusion_reason"] = reason
|
||||||
|
return FusionOutcome(
|
||||||
|
alert=alert,
|
||||||
|
suppressed=False,
|
||||||
|
reason=reason,
|
||||||
|
final_confidence=yolo_conf,
|
||||||
|
yolo_confidence=yolo_conf,
|
||||||
|
llm_confidence=llm_conf,
|
||||||
|
strategy=self.strategy,
|
||||||
|
metadata=llm_metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 元数据 / 严重性
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _llm_metadata(llm_result: LLMAnalysisResult) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"provider": llm_result.provider,
|
||||||
|
"model": llm_result.model,
|
||||||
|
"confirmed": llm_result.confirmed,
|
||||||
|
"confidence": round(float(llm_result.confidence), 4),
|
||||||
|
"reasoning": llm_result.reasoning,
|
||||||
|
"latency_ms": round(llm_result.latency_ms, 2),
|
||||||
|
"error": llm_result.error,
|
||||||
|
"evaluated_at": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_metadata(
|
||||||
|
self,
|
||||||
|
alert: AlertEvent,
|
||||||
|
llm_result: LLMAnalysisResult,
|
||||||
|
outcome: FusionOutcome,
|
||||||
|
) -> None:
|
||||||
|
alert.metadata.setdefault("llm", {})
|
||||||
|
alert.metadata["llm"].update(self._llm_metadata(llm_result))
|
||||||
|
alert.metadata["llm"]["fusion_strategy"] = self.strategy
|
||||||
|
alert.metadata["llm"]["fusion_reason"] = outcome.reason
|
||||||
|
alert.metadata["llm"]["final_confidence"] = round(
|
||||||
|
outcome.final_confidence, 4
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _maybe_promote_severity(alert: AlertEvent, final_confidence: float) -> None:
|
||||||
|
order = [
|
||||||
|
SeverityLevel.INFO,
|
||||||
|
SeverityLevel.LOW,
|
||||||
|
SeverityLevel.MEDIUM,
|
||||||
|
SeverityLevel.HIGH,
|
||||||
|
SeverityLevel.CRITICAL,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
idx = order.index(alert.severity)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
if final_confidence >= 0.95 and idx < len(order) - 1:
|
||||||
|
alert.severity = order[idx + 1]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ResultFusion", "FusionOutcome", "SUPPORTED_STRATEGIES"]
|
||||||
|
|
||||||
|
|
||||||
@@ -68,3 +68,97 @@ export const detectionApi = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MVP-2 / MVP-3 扩展 API: 摄像头管理 / 规则配置 / LLM 状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 摄像头 (RTSP 流) 管理 API (MVP-2 / D11-D14)
|
||||||
|
*/
|
||||||
|
export const cameraApi = {
|
||||||
|
list() {
|
||||||
|
return api.get('/rtsp/streams')
|
||||||
|
},
|
||||||
|
get(streamId) {
|
||||||
|
return api.get(`/rtsp/streams/${encodeURIComponent(streamId)}`)
|
||||||
|
},
|
||||||
|
add(payload) {
|
||||||
|
return api.post('/rtsp/streams', payload)
|
||||||
|
},
|
||||||
|
remove(streamId) {
|
||||||
|
return api.delete(`/rtsp/streams/${encodeURIComponent(streamId)}`)
|
||||||
|
},
|
||||||
|
start(streamId) {
|
||||||
|
return api.post(`/rtsp/streams/${encodeURIComponent(streamId)}/start`)
|
||||||
|
},
|
||||||
|
stop(streamId) {
|
||||||
|
return api.post(`/rtsp/streams/${encodeURIComponent(streamId)}/stop`)
|
||||||
|
},
|
||||||
|
updateConfig(streamId, payload) {
|
||||||
|
return api.put(`/rtsp/streams/${encodeURIComponent(streamId)}/config`, payload)
|
||||||
|
},
|
||||||
|
startAll() {
|
||||||
|
return api.post('/rtsp/start-all')
|
||||||
|
},
|
||||||
|
stopAll() {
|
||||||
|
return api.post('/rtsp/stop-all')
|
||||||
|
},
|
||||||
|
health() {
|
||||||
|
return api.get('/rtsp/health')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规则配置 API (MVP-3 / D33)
|
||||||
|
*/
|
||||||
|
export const ruleApi = {
|
||||||
|
list() {
|
||||||
|
return api.get('/rules')
|
||||||
|
},
|
||||||
|
get(name) {
|
||||||
|
return api.get(`/rules/${encodeURIComponent(name)}`)
|
||||||
|
},
|
||||||
|
create(payload) {
|
||||||
|
return api.post('/rules', payload)
|
||||||
|
},
|
||||||
|
update(name, payload) {
|
||||||
|
return api.put(`/rules/${encodeURIComponent(name)}`, payload)
|
||||||
|
},
|
||||||
|
remove(name) {
|
||||||
|
return api.delete(`/rules/${encodeURIComponent(name)}`)
|
||||||
|
},
|
||||||
|
reload() {
|
||||||
|
return api.post('/rules/reload')
|
||||||
|
},
|
||||||
|
stats() {
|
||||||
|
return api.get('/rules/_stats')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM 状态 / 成本 / 控制 API (MVP-3 / D29-D34)
|
||||||
|
*/
|
||||||
|
export const llmApi = {
|
||||||
|
status() {
|
||||||
|
return api.get('/llm/status')
|
||||||
|
},
|
||||||
|
cost() {
|
||||||
|
return api.get('/llm/cost')
|
||||||
|
},
|
||||||
|
costHistory(days = 7) {
|
||||||
|
return api.get('/llm/cost/history', { params: { days } })
|
||||||
|
},
|
||||||
|
costRecords(limit = 20) {
|
||||||
|
return api.get('/llm/cost/records', { params: { limit } })
|
||||||
|
},
|
||||||
|
enable() {
|
||||||
|
return api.post('/llm/enable')
|
||||||
|
},
|
||||||
|
disable() {
|
||||||
|
return api.post('/llm/disable')
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
return api.post('/llm/reset')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<DetectionConfig
|
<DetectionConfig
|
||||||
:models="models"
|
:models="models"
|
||||||
:default-type="'image'"
|
:default-type="'image'"
|
||||||
:default-model="config.model"
|
:default-model="selectedModel || (models.length > 0 ? models[0].id : '')"
|
||||||
@type-change="onTypeChange"
|
@type-change="onTypeChange"
|
||||||
@config-change="onConfigChange"
|
@config-change="onConfigChange"
|
||||||
/>
|
/>
|
||||||
@@ -346,18 +346,25 @@ import {
|
|||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { detectionApi } from '@/api/detection'
|
import { detectionApi } from '@/api/detection'
|
||||||
import DetectionConfig from './DetectionConfig.vue'
|
import DetectionConfig from './DetectionConfig.vue'
|
||||||
|
import { useAlertStore } from '@/stores/alertStore'
|
||||||
|
|
||||||
|
const alertStore = useAlertStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
models: {
|
models: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
selectedModel: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['type-change'])
|
const emit = defineEmits(['type-change', 'model-change'])
|
||||||
|
|
||||||
const config = ref({
|
const config = ref({
|
||||||
model: props.models.length > 0 ? props.models[0].id : 'fire_detection',
|
model: props.selectedModel || (props.models.length > 0 ? props.models[0].id : 'fire_detection'),
|
||||||
confidence: 0.5,
|
confidence: 0.5,
|
||||||
iou: 0.45,
|
iou: 0.45,
|
||||||
composite: false,
|
composite: false,
|
||||||
@@ -369,7 +376,12 @@ const onTypeChange = (type) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onConfigChange = (newConfig) => {
|
const onConfigChange = (newConfig) => {
|
||||||
|
const oldModel = config.value.model
|
||||||
config.value = { ...newConfig }
|
config.value = { ...newConfig }
|
||||||
|
// 模型变化时通知父组件
|
||||||
|
if (newConfig.model && newConfig.model !== oldModel) {
|
||||||
|
emit('model-change', newConfig.model)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 可拖拽调整宽度相关
|
// 可拖拽调整宽度相关
|
||||||
@@ -571,8 +583,13 @@ const handleUploadSuccess = (response) => {
|
|||||||
if (response.data.alerts && response.data.alerts.length > 0) {
|
if (response.data.alerts && response.data.alerts.length > 0) {
|
||||||
alerts.value = response.data.alerts
|
alerts.value = response.data.alerts
|
||||||
response.data.alerts.forEach(alert => {
|
response.data.alerts.forEach(alert => {
|
||||||
|
// 同步到全局预警中心,供 AlertList 展示
|
||||||
|
alertStore.addAlert(alert)
|
||||||
|
// 兼容两种字段名: 旧格式 {type, message} / 新格式 {event_type, metadata.description}
|
||||||
|
const alertType = alert.type || alert.event_type
|
||||||
|
const alertMessage = alert.message || (alert.metadata && alert.metadata.description) || ''
|
||||||
ElMessage({
|
ElMessage({
|
||||||
message: `行为告警: ${alert.type} - ${alert.message}`,
|
message: `预警: ${alertType} - ${alertMessage}`,
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
duration: 3000
|
duration: 3000
|
||||||
})
|
})
|
||||||
@@ -778,6 +795,23 @@ const handleFightVideoSuccess = (response) => {
|
|||||||
const detCount = response.data.stats?.total_detections || 0
|
const detCount = response.data.stats?.total_detections || 0
|
||||||
ElMessage.success(`检测完成:${detCount} 个目标,已提取 ${kfCount} 张关键帧`)
|
ElMessage.success(`检测完成:${detCount} 个目标,已提取 ${kfCount} 张关键帧`)
|
||||||
|
|
||||||
|
// MVP-3: 将 LLM 管道产出的预警推送到 AlertList
|
||||||
|
const frameResults = response.data.frame_results || []
|
||||||
|
let llmAlertCount = 0
|
||||||
|
for (const fr of frameResults) {
|
||||||
|
const alerts = fr.alert_events || []
|
||||||
|
for (const alert of alerts) {
|
||||||
|
alertStore.addAlert({
|
||||||
|
...alert,
|
||||||
|
llm_results: fr.llm_results || [],
|
||||||
|
})
|
||||||
|
llmAlertCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (llmAlertCount > 0) {
|
||||||
|
ElMessage.success(`LLM 二次判断完成,已生成 ${llmAlertCount} 条预警记录`)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果后端提示编码不兼容,自动标记视频错误
|
// 如果后端提示编码不兼容,自动标记视频错误
|
||||||
if (response.data.video_codec_warning) {
|
if (response.data.video_codec_warning) {
|
||||||
console.warn('视频编码不兼容浏览器,将以关键帧形式展示')
|
console.warn('视频编码不兼容浏览器,将以关键帧形式展示')
|
||||||
@@ -1088,8 +1122,6 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.image-container {
|
.image-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
min-height: 400px;
|
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1101,8 +1133,8 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.display-image {
|
.display-image {
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
height: 100%;
|
max-height: 600px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<DetectionConfig
|
<DetectionConfig
|
||||||
:models="models"
|
:models="models"
|
||||||
:default-type="'video'"
|
:default-type="'video'"
|
||||||
:default-model="config.model"
|
:default-model="selectedModel || (models.length > 0 ? models[0].id : '')"
|
||||||
@type-change="onTypeChange"
|
@type-change="onTypeChange"
|
||||||
@config-change="onConfigChange"
|
@config-change="onConfigChange"
|
||||||
/>
|
/>
|
||||||
@@ -223,13 +223,17 @@ const props = defineProps({
|
|||||||
models: {
|
models: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
selectedModel: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['type-change'])
|
const emit = defineEmits(['type-change', 'model-change'])
|
||||||
|
|
||||||
const config = ref({
|
const config = ref({
|
||||||
model: props.models.length > 0 ? props.models[0].id : 'fire_detection',
|
model: props.selectedModel || (props.models.length > 0 ? props.models[0].id : 'fire_detection'),
|
||||||
confidence: 0.5,
|
confidence: 0.5,
|
||||||
iou: 0.45,
|
iou: 0.45,
|
||||||
composite: false,
|
composite: false,
|
||||||
@@ -241,7 +245,12 @@ const onTypeChange = (type) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onConfigChange = (newConfig) => {
|
const onConfigChange = (newConfig) => {
|
||||||
|
const oldModel = config.value.model
|
||||||
config.value = { ...newConfig }
|
config.value = { ...newConfig }
|
||||||
|
// 模型变化时通知父组件
|
||||||
|
if (newConfig.model && newConfig.model !== oldModel) {
|
||||||
|
emit('model-change', newConfig.model)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果摄像头已连接,实时更新配置
|
// 如果摄像头已连接,实时更新配置
|
||||||
if (websocket.value && cameraConnected.value) {
|
if (websocket.value && cameraConnected.value) {
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ import {
|
|||||||
WarningFilled,
|
WarningFilled,
|
||||||
Bell,
|
Bell,
|
||||||
Fold,
|
Fold,
|
||||||
Expand
|
Expand,
|
||||||
|
Connection,
|
||||||
|
SetUp
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { useAlertStore } from '@/stores/alertStore'
|
import { useAlertStore } from '@/stores/alertStore'
|
||||||
import { connectAlertSocket, disconnectAlertSocket } from '@/services/mqtt.client'
|
import { connectAlertSocket, disconnectAlertSocket } from '@/services/mqtt.client'
|
||||||
@@ -111,6 +113,8 @@ const alertStore = useAlertStore()
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ path: '/', title: '模型检测', icon: VideoCameraFilled },
|
{ path: '/', title: '模型检测', icon: VideoCameraFilled },
|
||||||
|
{ path: '/cameras', title: '摄像头管理', icon: Connection },
|
||||||
|
{ path: '/rules', title: '规则配置', icon: SetUp },
|
||||||
{ path: '/alerts', title: '预警列表', icon: WarningFilled }
|
{ path: '/alerts', title: '预警列表', icon: WarningFilled }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
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 AlertList from '@/views/AlertList.vue'
|
||||||
|
import CameraManagement from '@/views/CameraManagement.vue'
|
||||||
|
import RuleConfiguration from '@/views/RuleConfiguration.vue'
|
||||||
import Layout from '@/layouts/MainLayout.vue'
|
import Layout from '@/layouts/MainLayout.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -14,6 +16,18 @@ const routes = [
|
|||||||
component: Home,
|
component: Home,
|
||||||
meta: { title: '模型检测', icon: 'VideoCamera', keepAlive: true }
|
meta: { title: '模型检测', icon: 'VideoCamera', keepAlive: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/cameras',
|
||||||
|
name: 'CameraManagement',
|
||||||
|
component: CameraManagement,
|
||||||
|
meta: { title: '摄像头管理', icon: 'Connection', keepAlive: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/rules',
|
||||||
|
name: 'RuleConfiguration',
|
||||||
|
component: RuleConfiguration,
|
||||||
|
meta: { title: '规则配置', icon: 'SetUp', keepAlive: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/alerts',
|
path: '/alerts',
|
||||||
name: 'AlertList',
|
name: 'AlertList',
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ export const useAlertStore = defineStore('alerts', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 桌面通知 ----
|
// ---- 桌面通知 ----
|
||||||
|
const activeNotifyOffsets = new Set()
|
||||||
|
const NOTIFY_STEP = 80
|
||||||
|
|
||||||
|
function getAvailableOffset() {
|
||||||
|
let offset = 0
|
||||||
|
while (activeNotifyOffsets.has(offset)) {
|
||||||
|
offset += NOTIFY_STEP
|
||||||
|
}
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
function showDesktopNotification(alert) {
|
function showDesktopNotification(alert) {
|
||||||
const severityLabels = {
|
const severityLabels = {
|
||||||
critical: '严重',
|
critical: '严重',
|
||||||
@@ -94,6 +105,8 @@ export const useAlertStore = defineStore('alerts', () => {
|
|||||||
}
|
}
|
||||||
const label = severityLabels[alert.severity] || alert.severity
|
const label = severityLabels[alert.severity] || alert.severity
|
||||||
const typeName = alert.event_type || '未知事件'
|
const typeName = alert.event_type || '未知事件'
|
||||||
|
const offset = getAvailableOffset()
|
||||||
|
activeNotifyOffsets.add(offset)
|
||||||
|
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: `[${label}] ${typeName}`,
|
title: `[${label}] ${typeName}`,
|
||||||
@@ -103,10 +116,15 @@ export const useAlertStore = defineStore('alerts', () => {
|
|||||||
type: alert.severity === 'critical' || alert.severity === 'high'
|
type: alert.severity === 'critical' || alert.severity === 'high'
|
||||||
? 'warning'
|
? 'warning'
|
||||||
: 'info',
|
: 'info',
|
||||||
duration: alert.severity === 'critical' ? 0 : 4500,
|
duration: 5000,
|
||||||
|
offset,
|
||||||
|
showClose: true,
|
||||||
dangerouslyUseHTMLString: false,
|
dangerouslyUseHTMLString: false,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
markAsRead(alert.alert_id)
|
markAsRead(alert.alert_id)
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
activeNotifyOffsets.delete(offset)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
class="filter-control"
|
class="filter-control"
|
||||||
@change="applyFilter"
|
@change="applyFilter"
|
||||||
>
|
>
|
||||||
|
<el-option label="全部" value="" />
|
||||||
<el-option label="严重" value="critical" />
|
<el-option label="严重" value="critical" />
|
||||||
<el-option label="高危" value="high" />
|
<el-option label="高危" value="high" />
|
||||||
<el-option label="中等" value="medium" />
|
<el-option label="中等" value="medium" />
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
class="filter-control"
|
class="filter-control"
|
||||||
@change="applyFilter"
|
@change="applyFilter"
|
||||||
>
|
>
|
||||||
|
<el-option label="全部" value="" />
|
||||||
<el-option label="火灾" value="fire" />
|
<el-option label="火灾" value="fire" />
|
||||||
<el-option label="烟雾" value="smoke" />
|
<el-option label="烟雾" value="smoke" />
|
||||||
<el-option label="抽烟" value="smoking" />
|
<el-option label="抽烟" value="smoking" />
|
||||||
@@ -156,6 +158,28 @@
|
|||||||
<span v-if="det.track_id" class="track-id">#{{ det.track_id }}</span>
|
<span v-if="det.track_id" class="track-id">#{{ det.track_id }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MVP-3: LLM 二次判断结果 -->
|
||||||
|
<div v-if="alert.llm_results && alert.llm_results.length > 0" class="llm-result">
|
||||||
|
<el-tag
|
||||||
|
:type="alert.llm_results[0].llm_confirmed ? 'success' : 'danger'"
|
||||||
|
effect="light"
|
||||||
|
round
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ alert.llm_results[0].llm_confirmed ? 'LLM确认' : 'LLM否决' }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="llm-confidence">
|
||||||
|
LLM置信度: {{ ((alert.llm_results[0].llm_confidence || 0) * 100).toFixed(0) }}%
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="alert.llm_results[0].llm_reasoning"
|
||||||
|
class="llm-reasoning"
|
||||||
|
:title="alert.llm_results[0].llm_reasoning"
|
||||||
|
>
|
||||||
|
{{ alert.llm_results[0].llm_reasoning.slice(0, 50) }}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert-actions">
|
<div class="alert-actions">
|
||||||
@@ -534,6 +558,33 @@ onMounted(() => {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* MVP-3: LLM 二次判断结果样式 */
|
||||||
|
.llm-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(99, 102, 241, 0.06);
|
||||||
|
border-left: 3px solid #818CF8;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-confidence {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #A5B4FC;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-reasoning {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9CA3AF;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
.alert-actions {
|
.alert-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,683 @@
|
|||||||
|
<template>
|
||||||
|
<div class="camera-mgmt-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-input
|
||||||
|
v-model="filter.keyword"
|
||||||
|
placeholder="按 ID/URL 搜索"
|
||||||
|
size="small"
|
||||||
|
clearable
|
||||||
|
class="filter-control"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="filter.status"
|
||||||
|
placeholder="状态"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="filter-control"
|
||||||
|
>
|
||||||
|
<el-option label="已连接" value="connected" />
|
||||||
|
<el-option label="重连中" value="reconnecting" />
|
||||||
|
<el-option label="错误" value="error" />
|
||||||
|
<el-option label="未启动" value="idle" />
|
||||||
|
<el-option label="已停止" value="stopped" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-button size="small" @click="loadStreams">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="success" plain @click="handleStartAll">
|
||||||
|
<el-icon><VideoPlay /></el-icon>
|
||||||
|
全部启动
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="warning" plain @click="handleStopAll">
|
||||||
|
<el-icon><VideoPause /></el-icon>
|
||||||
|
全部停止
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openAddDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加摄像头
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 摄像头列表 -->
|
||||||
|
<div class="camera-list">
|
||||||
|
<el-empty
|
||||||
|
v-if="filteredStreams.length === 0"
|
||||||
|
description="尚未配置摄像头,点击右上角添加"
|
||||||
|
class="empty-state"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="stream in filteredStreams"
|
||||||
|
:key="stream.stream_id"
|
||||||
|
class="camera-card"
|
||||||
|
:class="`status-${stream.status}`"
|
||||||
|
>
|
||||||
|
<div class="status-bar"></div>
|
||||||
|
|
||||||
|
<div class="camera-body">
|
||||||
|
<div class="camera-head">
|
||||||
|
<div class="camera-title">
|
||||||
|
<el-tag :type="statusTagType(stream.status)" size="small" effect="dark" round>
|
||||||
|
{{ statusLabel(stream.status) }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="camera-id">{{ stream.stream_id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="camera-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="!isRunning(stream)"
|
||||||
|
text
|
||||||
|
type="success"
|
||||||
|
size="small"
|
||||||
|
@click="handleStart(stream)"
|
||||||
|
>
|
||||||
|
<el-icon><VideoPlay /></el-icon>
|
||||||
|
启动
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
text
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
@click="handleStop(stream)"
|
||||||
|
>
|
||||||
|
<el-icon><VideoPause /></el-icon>
|
||||||
|
停止
|
||||||
|
</el-button>
|
||||||
|
<el-button text size="small" @click="openEditDialog(stream)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
配置
|
||||||
|
</el-button>
|
||||||
|
<el-popconfirm
|
||||||
|
title="确认移除该摄像头?"
|
||||||
|
confirm-button-text="移除"
|
||||||
|
cancel-button-text="取消"
|
||||||
|
@confirm="handleRemove(stream)"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<el-button text type="danger" size="small">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
移除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="camera-url">{{ stream.rtsp_url }}</div>
|
||||||
|
|
||||||
|
<div class="camera-meta">
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><Cpu /></el-icon>
|
||||||
|
<span>{{ stream.config?.model_id || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><DataLine /></el-icon>
|
||||||
|
<span>置信度 {{ formatNumber(stream.config?.confidence) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><PictureFilled /></el-icon>
|
||||||
|
<span>{{ stream.frame_count || 0 }} 帧</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><Timer /></el-icon>
|
||||||
|
<span>{{ formatLastFrame(stream.last_frame_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="stream.error_message" class="meta-item meta-error">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
<span>{{ stream.error_message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加 / 编辑摄像头弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="editingStreamId ? '配置摄像头' : '添加摄像头'"
|
||||||
|
width="520px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
class="camera-dialog"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:rules="formRules"
|
||||||
|
label-position="top"
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
<el-form-item label="摄像头 ID" prop="stream_id">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.stream_id"
|
||||||
|
placeholder="例如 cam-01"
|
||||||
|
:disabled="!!editingStreamId"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="RTSP URL" prop="rtsp_url">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.rtsp_url"
|
||||||
|
placeholder="rtsp://user:pass@host:554/stream"
|
||||||
|
:disabled="!!editingStreamId"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="检测模型" prop="model_id">
|
||||||
|
<el-select v-model="formState.model_id" placeholder="选择检测模型" style="width: 100%">
|
||||||
|
<el-option label="火灾检测" value="fire_detection" />
|
||||||
|
<el-option label="烟雾检测" value="smoke_detection" />
|
||||||
|
<el-option label="抽烟检测" value="smoking_detection" />
|
||||||
|
<el-option label="打架斗殴" value="fight_detection" />
|
||||||
|
<el-option label="徘徊检测" value="loitering_detection" />
|
||||||
|
<el-option label="违章停车" value="illegal_parking_detection" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="置信度阈值">
|
||||||
|
<el-slider
|
||||||
|
v-model="formState.confidence"
|
||||||
|
:min="0.1"
|
||||||
|
:max="0.95"
|
||||||
|
:step="0.05"
|
||||||
|
show-stops
|
||||||
|
:format-tooltip="(v) => v.toFixed(2)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="IOU 阈值">
|
||||||
|
<el-slider
|
||||||
|
v-model="formState.iou"
|
||||||
|
:min="0.2"
|
||||||
|
:max="0.9"
|
||||||
|
:step="0.05"
|
||||||
|
show-stops
|
||||||
|
:format-tooltip="(v) => v.toFixed(2)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="帧采样间隔">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formState.frame_skip"
|
||||||
|
:min="0"
|
||||||
|
:max="20"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">0 = 每帧检测,N = 每 N+1 帧检测一次</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ editingStreamId ? '保存' : '添加' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Refresh,
|
||||||
|
Plus,
|
||||||
|
VideoPlay,
|
||||||
|
VideoPause,
|
||||||
|
Edit,
|
||||||
|
Delete,
|
||||||
|
Cpu,
|
||||||
|
DataLine,
|
||||||
|
PictureFilled,
|
||||||
|
Timer,
|
||||||
|
WarningFilled,
|
||||||
|
CircleCheckFilled,
|
||||||
|
Connection,
|
||||||
|
Bell
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { cameraApi } from '@/api/detection'
|
||||||
|
|
||||||
|
const streams = ref([])
|
||||||
|
const filter = reactive({ keyword: '', status: '' })
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const editingStreamId = ref('')
|
||||||
|
const formRef = ref(null)
|
||||||
|
const formState = reactive(makeEmptyForm())
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
stream_id: [{ required: true, message: '请输入摄像头 ID', trigger: 'blur' }],
|
||||||
|
rtsp_url: [{ required: true, message: '请输入 RTSP 地址', trigger: 'blur' }],
|
||||||
|
model_id: [{ required: true, message: '请选择检测模型', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmptyForm() {
|
||||||
|
return {
|
||||||
|
stream_id: '',
|
||||||
|
rtsp_url: '',
|
||||||
|
model_id: 'fire_detection',
|
||||||
|
confidence: 0.5,
|
||||||
|
iou: 0.45,
|
||||||
|
frame_skip: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = computed(() => {
|
||||||
|
const total = streams.value.length
|
||||||
|
const active = streams.value.filter((s) => s.status === 'connected').length
|
||||||
|
const errored = streams.value.filter((s) => s.status === 'error').length
|
||||||
|
const reconnecting = streams.value.filter((s) => s.status === 'reconnecting').length
|
||||||
|
return [
|
||||||
|
{ key: 'total', label: '摄像头总数', value: total, icon: Connection, tone: 'neutral' },
|
||||||
|
{ key: 'active', label: '运行中', value: active, icon: CircleCheckFilled, tone: 'success' },
|
||||||
|
{ key: 'reconnecting', label: '重连中', value: reconnecting, icon: Bell, tone: 'warning' },
|
||||||
|
{ key: 'error', label: '故障', value: errored, icon: WarningFilled, tone: 'danger' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredStreams = computed(() => {
|
||||||
|
return streams.value.filter((s) => {
|
||||||
|
if (filter.status && s.status !== filter.status) return false
|
||||||
|
if (filter.keyword) {
|
||||||
|
const kw = filter.keyword.toLowerCase()
|
||||||
|
const text = `${s.stream_id} ${s.rtsp_url || ''}`.toLowerCase()
|
||||||
|
if (!text.includes(kw)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function isRunning(stream) {
|
||||||
|
return ['connecting', 'connected', 'reconnecting'].includes(stream.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
const map = {
|
||||||
|
idle: '未启动',
|
||||||
|
connecting: '连接中',
|
||||||
|
connected: '已连接',
|
||||||
|
reconnecting: '重连中',
|
||||||
|
stopped: '已停止',
|
||||||
|
error: '错误'
|
||||||
|
}
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTagType(status) {
|
||||||
|
const map = {
|
||||||
|
connected: 'success',
|
||||||
|
connecting: 'warning',
|
||||||
|
reconnecting: 'warning',
|
||||||
|
error: 'danger',
|
||||||
|
idle: 'info',
|
||||||
|
stopped: 'info'
|
||||||
|
}
|
||||||
|
return map[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(v) {
|
||||||
|
if (v === undefined || v === null) return '-'
|
||||||
|
return Number(v).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastFrame(ts) {
|
||||||
|
if (!ts) return '无数据'
|
||||||
|
const date = new Date(ts * 1000)
|
||||||
|
const diff = (Date.now() - date.getTime()) / 1000
|
||||||
|
if (diff < 60) return `${Math.floor(diff)} 秒前`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||||
|
return date.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStreams() {
|
||||||
|
try {
|
||||||
|
const { data } = await cameraApi.list()
|
||||||
|
streams.value = data?.streams || []
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`加载摄像头列表失败: ${err.message || err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
editingStreamId.value = ''
|
||||||
|
Object.assign(formState, makeEmptyForm())
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(stream) {
|
||||||
|
editingStreamId.value = stream.stream_id
|
||||||
|
Object.assign(formState, {
|
||||||
|
stream_id: stream.stream_id,
|
||||||
|
rtsp_url: stream.rtsp_url,
|
||||||
|
model_id: stream.config?.model_id || 'fire_detection',
|
||||||
|
confidence: Number(stream.config?.confidence ?? 0.5),
|
||||||
|
iou: Number(stream.config?.iou ?? 0.45),
|
||||||
|
frame_skip: Number(stream.config?.frame_skip ?? 0)
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (editingStreamId.value) {
|
||||||
|
await cameraApi.updateConfig(editingStreamId.value, {
|
||||||
|
model_id: formState.model_id,
|
||||||
|
confidence: formState.confidence,
|
||||||
|
iou: formState.iou,
|
||||||
|
frame_skip: formState.frame_skip
|
||||||
|
})
|
||||||
|
ElMessage.success('已保存配置')
|
||||||
|
} else {
|
||||||
|
await cameraApi.add({
|
||||||
|
stream_id: formState.stream_id,
|
||||||
|
rtsp_url: formState.rtsp_url,
|
||||||
|
model_id: formState.model_id,
|
||||||
|
confidence: formState.confidence,
|
||||||
|
iou: formState.iou,
|
||||||
|
frame_skip: formState.frame_skip
|
||||||
|
})
|
||||||
|
ElMessage.success('摄像头已添加')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.detail || err.message || String(err)
|
||||||
|
ElMessage.error(`操作失败: ${msg}`)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStart(stream) {
|
||||||
|
try {
|
||||||
|
await cameraApi.start(stream.stream_id)
|
||||||
|
ElMessage.success(`已启动 ${stream.stream_id}`)
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`启动失败: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop(stream) {
|
||||||
|
try {
|
||||||
|
await cameraApi.stop(stream.stream_id)
|
||||||
|
ElMessage.success(`已停止 ${stream.stream_id}`)
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`停止失败: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(stream) {
|
||||||
|
try {
|
||||||
|
await cameraApi.remove(stream.stream_id)
|
||||||
|
ElMessage.success(`已移除 ${stream.stream_id}`)
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`移除失败: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartAll() {
|
||||||
|
try {
|
||||||
|
await cameraApi.startAll()
|
||||||
|
ElMessage.success('已启动全部摄像头')
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStopAll() {
|
||||||
|
try {
|
||||||
|
await cameraApi.stopAll()
|
||||||
|
ElMessage.success('已停止全部摄像头')
|
||||||
|
await loadStreams()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadStreams()
|
||||||
|
// 每 5 秒刷新一次状态
|
||||||
|
pollTimer = setInterval(loadStreams, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.camera-mgmt-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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--success .stat-icon { background: rgba(34, 197, 94, 0.12); color: #22C55E; }
|
||||||
|
.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-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: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-card {
|
||||||
|
display: flex;
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-card:hover {
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
width: 4px;
|
||||||
|
background: #64748B;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected .status-bar { background: #22C55E; }
|
||||||
|
.status-connecting .status-bar { background: #F59E0B; }
|
||||||
|
.status-reconnecting .status-bar { background: #F59E0B; }
|
||||||
|
.status-error .status-bar { background: #EF4444; }
|
||||||
|
.status-idle .status-bar { background: #94A3B8; }
|
||||||
|
.status-stopped .status-bar { background: #64748B; }
|
||||||
|
|
||||||
|
.camera-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-id {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item .el-icon { font-size: 14px; }
|
||||||
|
|
||||||
|
.meta-error {
|
||||||
|
color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.camera-dialog .el-dialog) {
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.camera-dialog .el-dialog__title) { color: #F8FAFC; }
|
||||||
|
:deep(.camera-dialog .el-form-item__label) { color: #CBD5E1; }
|
||||||
|
</style>
|
||||||
@@ -4,14 +4,18 @@
|
|||||||
<ImageDetection
|
<ImageDetection
|
||||||
v-if="activeTab === 'image'"
|
v-if="activeTab === 'image'"
|
||||||
:models="models"
|
:models="models"
|
||||||
|
:selected-model="selectedModel"
|
||||||
@type-change="handleTypeChange"
|
@type-change="handleTypeChange"
|
||||||
|
@model-change="handleModelChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 视频检测模块 -->
|
<!-- 视频检测模块 -->
|
||||||
<VideoDetection
|
<VideoDetection
|
||||||
v-if="activeTab === 'video'"
|
v-if="activeTab === 'video'"
|
||||||
:models="models"
|
:models="models"
|
||||||
|
:selected-model="selectedModel"
|
||||||
@type-change="handleTypeChange"
|
@type-change="handleTypeChange"
|
||||||
|
@model-change="handleModelChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -26,15 +30,24 @@ import VideoDetection from '@/components/VideoDetection.vue'
|
|||||||
|
|
||||||
const activeTab = ref('image')
|
const activeTab = ref('image')
|
||||||
const models = ref([])
|
const models = ref([])
|
||||||
|
const selectedModel = ref('')
|
||||||
|
|
||||||
const handleTypeChange = (type) => {
|
const handleTypeChange = (type) => {
|
||||||
activeTab.value = type
|
activeTab.value = type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleModelChange = (modelId) => {
|
||||||
|
selectedModel.value = modelId
|
||||||
|
}
|
||||||
|
|
||||||
const loadModels = async () => {
|
const loadModels = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await detectionApi.getModels()
|
const response = await detectionApi.getModels()
|
||||||
models.value = response.data
|
models.value = response.data
|
||||||
|
// 初始化默认选中第一个模型
|
||||||
|
if (models.value.length > 0 && !selectedModel.value) {
|
||||||
|
selectedModel.value = models.value[0].id
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('加载模型列表失败')
|
ElMessage.error('加载模型列表失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,903 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rule-config-page">
|
||||||
|
<!-- LLM 状态面板 (MVP-3 / D34) -->
|
||||||
|
<section class="llm-panel" v-if="llmStatus">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-title">
|
||||||
|
<el-icon :size="18"><Cpu /></el-icon>
|
||||||
|
<span>LLM 二次判断</span>
|
||||||
|
<el-tag
|
||||||
|
:type="llmStatus.enabled ? 'success' : 'info'"
|
||||||
|
effect="dark"
|
||||||
|
round
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ llmStatus.enabled ? '已启用' : '未启用' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-if="llmCircuitState"
|
||||||
|
:type="circuitTagType"
|
||||||
|
effect="plain"
|
||||||
|
round
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
熔断: {{ circuitLabel }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<el-button size="small" @click="loadLLMStatus">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="llmTrackerEnabled"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
@click="handleLLMDisable"
|
||||||
|
>
|
||||||
|
<el-icon><VideoPause /></el-icon>
|
||||||
|
紧急停用
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
@click="handleLLMEnable"
|
||||||
|
>
|
||||||
|
<el-icon><VideoPlay /></el-icon>
|
||||||
|
重新启用
|
||||||
|
</el-button>
|
||||||
|
<el-popconfirm title="确认重置成本统计与熔断?" @confirm="handleLLMReset">
|
||||||
|
<template #reference>
|
||||||
|
<el-button size="small" type="danger" plain>
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
重置统计
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="llm-grid">
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">Provider</div>
|
||||||
|
<div class="llm-value">{{ llmStatus.provider }}</div>
|
||||||
|
<div class="llm-sub">{{ llmStatus.model || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">融合策略</div>
|
||||||
|
<div class="llm-value">{{ fusionLabel }}</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
YOLO {{ formatRatio(llmStatus.fusion?.yolo_weight) }} /
|
||||||
|
LLM {{ formatRatio(llmStatus.fusion?.llm_weight) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">触发阈值</div>
|
||||||
|
<div class="llm-value">
|
||||||
|
{{ llmStatus.triggers?.min_consecutive_hits }} 连续帧
|
||||||
|
</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
avg≥{{ formatRatio(llmStatus.triggers?.min_avg_confidence) }} ·
|
||||||
|
冷却{{ llmStatus.triggers?.cooldown_seconds }}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">今日调用</div>
|
||||||
|
<div class="llm-value">{{ todayCost?.call_count ?? 0 }} 次</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
成功 {{ todayCost?.success_count ?? 0 }} /
|
||||||
|
失败 {{ todayCost?.failure_count ?? 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">今日费用</div>
|
||||||
|
<div class="llm-value">${{ formatCost(todayCost?.cost_usd) }}</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
预算
|
||||||
|
{{ todayCost?.budget_usd ? `$${formatCost(todayCost.budget_usd)}` : '∞' }}
|
||||||
|
<span v-if="todayCost?.budget_usd > 0" class="budget-bar-wrap">
|
||||||
|
<span
|
||||||
|
class="budget-bar"
|
||||||
|
:class="{ 'is-danger': (todayCost?.budget_used_ratio ?? 0) >= 0.8 }"
|
||||||
|
:style="{ width: `${Math.min(100, (todayCost?.budget_used_ratio ?? 0) * 100)}%` }"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="llm-card">
|
||||||
|
<div class="llm-label">最近错误率</div>
|
||||||
|
<div class="llm-value">
|
||||||
|
{{ formatRatio(llmStatus.cost?.recent_error_rate) }}
|
||||||
|
</div>
|
||||||
|
<div class="llm-sub">
|
||||||
|
阈值 {{ formatRatio(llmStatus.cost?.error_rate_threshold) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="llmStatus.cost?.block_reason" class="llm-warning">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
当前不会调用 LLM,原因:{{ llmStatus.cost.block_reason }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<el-input
|
||||||
|
v-model="filter.keyword"
|
||||||
|
placeholder="按规则名/描述搜索"
|
||||||
|
size="small"
|
||||||
|
clearable
|
||||||
|
class="filter-control"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-select
|
||||||
|
v-model="filter.event_type"
|
||||||
|
placeholder="事件类型"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="filter-control"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in eventTypeOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-button size="small" @click="loadRules">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="warning" plain @click="handleReload">
|
||||||
|
<el-icon><MagicStick /></el-icon>
|
||||||
|
热重载
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="openCreateDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新增规则
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 规则列表 -->
|
||||||
|
<div class="rule-list">
|
||||||
|
<el-empty
|
||||||
|
v-if="filteredRules.length === 0"
|
||||||
|
description="暂无规则"
|
||||||
|
class="empty-state"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="rule in filteredRules"
|
||||||
|
:key="rule.name"
|
||||||
|
class="rule-card"
|
||||||
|
:class="{ 'is-disabled': !rule.enabled }"
|
||||||
|
>
|
||||||
|
<div class="rule-bar" :class="`severity-${rule.severity || 'info'}`"></div>
|
||||||
|
<div class="rule-body">
|
||||||
|
<div class="rule-head">
|
||||||
|
<div class="rule-title">
|
||||||
|
<el-tag
|
||||||
|
:type="severityTagType(rule.severity)"
|
||||||
|
size="small"
|
||||||
|
effect="dark"
|
||||||
|
round
|
||||||
|
>
|
||||||
|
{{ severityLabel(rule.severity) }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="rule-name">{{ rule.name }}</span>
|
||||||
|
<el-tag size="small" effect="plain">
|
||||||
|
{{ eventTypeLabel(rule.event_type) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-switch
|
||||||
|
:model-value="rule.enabled"
|
||||||
|
size="small"
|
||||||
|
@change="(v) => handleToggle(rule, v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="rule-actions">
|
||||||
|
<el-button text size="small" @click="openEditDialog(rule)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-popconfirm
|
||||||
|
title="确认删除该规则?(仅 custom.yaml 中的规则可删除)"
|
||||||
|
@confirm="handleDelete(rule)"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<el-button text type="danger" size="small">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rule.description" class="rule-desc">{{ rule.description }}</div>
|
||||||
|
|
||||||
|
<div class="rule-meta">
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><DataLine /></el-icon>
|
||||||
|
<span>min_conf ≥ {{ formatRatio(rule.min_confidence) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<el-icon><Crop /></el-icon>
|
||||||
|
<span>min_area ≥ {{ rule.min_bbox_area || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="rule.allowed_sources?.length" class="meta-item">
|
||||||
|
<el-icon><VideoCamera /></el-icon>
|
||||||
|
<span>限定: {{ rule.allowed_sources.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="rule.required_labels?.length" class="meta-item">
|
||||||
|
<el-icon><Collection /></el-icon>
|
||||||
|
<span>标签: {{ rule.required_labels.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑/新增规则弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="editingName ? '编辑规则' : '新增规则'"
|
||||||
|
width="560px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
class="rule-dialog"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:rules="formRules"
|
||||||
|
label-position="top"
|
||||||
|
>
|
||||||
|
<el-form-item label="规则名称" prop="name">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.name"
|
||||||
|
placeholder="例如 fire_high_priority"
|
||||||
|
:disabled="!!editingName"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="事件类型" prop="event_type">
|
||||||
|
<el-select v-model="formState.event_type" placeholder="选择事件类型" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in eventTypeOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="严重级别">
|
||||||
|
<el-select v-model="formState.severity" placeholder="使用默认" clearable style="width: 100%">
|
||||||
|
<el-option label="信息" value="info" />
|
||||||
|
<el-option label="低危" value="low" />
|
||||||
|
<el-option label="中等" value="medium" />
|
||||||
|
<el-option label="高危" value="high" />
|
||||||
|
<el-option label="严重" value="critical" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最低置信度">
|
||||||
|
<el-slider
|
||||||
|
v-model="formState.min_confidence"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.05"
|
||||||
|
:format-tooltip="(v) => Number(v).toFixed(2)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最小边界框面积">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formState.min_bbox_area"
|
||||||
|
:min="0"
|
||||||
|
:step="100"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="form-hint">单位:像素²,0 表示不限</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="限定摄像头 ID(可选)">
|
||||||
|
<el-select
|
||||||
|
v-model="formState.allowed_sources"
|
||||||
|
multiple
|
||||||
|
allow-create
|
||||||
|
filterable
|
||||||
|
default-first-option
|
||||||
|
placeholder="留空表示所有摄像头"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in cameraOptions"
|
||||||
|
:key="opt"
|
||||||
|
:label="opt"
|
||||||
|
:value="opt"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="必备标签(可选)">
|
||||||
|
<el-select
|
||||||
|
v-model="formState.required_labels"
|
||||||
|
multiple
|
||||||
|
allow-create
|
||||||
|
filterable
|
||||||
|
default-first-option
|
||||||
|
placeholder="检测项 class_name 或 label 必须包含其中之一"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input
|
||||||
|
v-model="formState.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="说明该规则的业务用途"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="formState.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ editingName ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Refresh,
|
||||||
|
Plus,
|
||||||
|
Edit,
|
||||||
|
Delete,
|
||||||
|
Cpu,
|
||||||
|
DataLine,
|
||||||
|
Crop,
|
||||||
|
Collection,
|
||||||
|
VideoCamera,
|
||||||
|
VideoPlay,
|
||||||
|
VideoPause,
|
||||||
|
WarningFilled,
|
||||||
|
MagicStick
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { ruleApi, llmApi, cameraApi } from '@/api/detection'
|
||||||
|
|
||||||
|
const eventTypeOptions = [
|
||||||
|
{ label: '火灾', value: 'fire' },
|
||||||
|
{ label: '烟雾', value: 'smoke' },
|
||||||
|
{ label: '抽烟', value: 'smoking' },
|
||||||
|
{ label: '打架', value: 'fight' },
|
||||||
|
{ label: '徘徊', value: 'loitering' },
|
||||||
|
{ label: '滞留', value: 'stationary' },
|
||||||
|
{ label: '入侵', value: 'intrusion' },
|
||||||
|
{ label: '违章停车', value: 'illegal_parking' },
|
||||||
|
{ label: '车辆', value: 'vehicle' },
|
||||||
|
{ label: '人员', value: 'person' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const rules = ref([])
|
||||||
|
const llmStatus = ref(null)
|
||||||
|
const cameraOptions = ref([])
|
||||||
|
const filter = reactive({ keyword: '', event_type: '' })
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const editingName = ref('')
|
||||||
|
const formRef = ref(null)
|
||||||
|
const formState = reactive(makeEmptyForm())
|
||||||
|
let pollTimer = null
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入规则名称', trigger: 'blur' },
|
||||||
|
{ pattern: /^[A-Za-z0-9_\-]+$/, message: '仅允许字母、数字、下划线和短横线', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
event_type: [{ required: true, message: '请选择事件类型', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmptyForm() {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
event_type: 'fire',
|
||||||
|
enabled: true,
|
||||||
|
min_confidence: 0.5,
|
||||||
|
severity: '',
|
||||||
|
allowed_sources: [],
|
||||||
|
required_labels: [],
|
||||||
|
min_bbox_area: 0,
|
||||||
|
description: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRules = computed(() => {
|
||||||
|
return rules.value.filter((r) => {
|
||||||
|
if (filter.event_type && r.event_type !== filter.event_type) return false
|
||||||
|
if (filter.keyword) {
|
||||||
|
const kw = filter.keyword.toLowerCase()
|
||||||
|
const text = `${r.name} ${r.description || ''}`.toLowerCase()
|
||||||
|
if (!text.includes(kw)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const todayCost = computed(() => llmStatus.value?.cost?.today)
|
||||||
|
const llmCircuitState = computed(() => llmStatus.value?.cost?.circuit_state)
|
||||||
|
const llmTrackerEnabled = computed(() => llmStatus.value?.cost?.enabled !== false)
|
||||||
|
|
||||||
|
const circuitLabel = computed(() => {
|
||||||
|
const map = { closed: '正常', open: '熔断', half_open: '半开试探' }
|
||||||
|
return map[llmCircuitState.value] || llmCircuitState.value || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
const circuitTagType = computed(() => {
|
||||||
|
if (llmCircuitState.value === 'open') return 'danger'
|
||||||
|
if (llmCircuitState.value === 'half_open') return 'warning'
|
||||||
|
return 'success'
|
||||||
|
})
|
||||||
|
|
||||||
|
const fusionLabel = computed(() => {
|
||||||
|
const map = {
|
||||||
|
weighted: '加权融合',
|
||||||
|
conservative: '保守策略',
|
||||||
|
llm_priority: 'LLM 优先'
|
||||||
|
}
|
||||||
|
return map[llmStatus.value?.fusion?.strategy] || llmStatus.value?.fusion?.strategy || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
function severityTagType(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 opt = eventTypeOptions.find((o) => o.value === type)
|
||||||
|
return opt ? opt.label : type
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRatio(v) {
|
||||||
|
if (v === undefined || v === null) return '-'
|
||||||
|
return Number(v).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCost(v) {
|
||||||
|
if (v === undefined || v === null) return '0.0000'
|
||||||
|
return Number(v).toFixed(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRules() {
|
||||||
|
try {
|
||||||
|
const { data } = await ruleApi.list()
|
||||||
|
rules.value = data?.rules || []
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`加载规则失败: ${err.message || err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLLMStatus() {
|
||||||
|
try {
|
||||||
|
const { data } = await llmApi.status()
|
||||||
|
llmStatus.value = data
|
||||||
|
} catch (err) {
|
||||||
|
// LLM 未启用时 cost 接口可能 503,状态接口仍可用 - 失败时静默
|
||||||
|
console.warn('加载 LLM 状态失败:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCameras() {
|
||||||
|
try {
|
||||||
|
const { data } = await cameraApi.list()
|
||||||
|
cameraOptions.value = (data?.streams || []).map((s) => s.stream_id)
|
||||||
|
} catch (err) {
|
||||||
|
// 摄像头列表加载失败不阻塞规则页
|
||||||
|
cameraOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
editingName.value = ''
|
||||||
|
Object.assign(formState, makeEmptyForm())
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(rule) {
|
||||||
|
editingName.value = rule.name
|
||||||
|
Object.assign(formState, {
|
||||||
|
name: rule.name,
|
||||||
|
event_type: rule.event_type,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
min_confidence: Number(rule.min_confidence ?? 0),
|
||||||
|
severity: rule.severity || '',
|
||||||
|
allowed_sources: rule.allowed_sources || [],
|
||||||
|
required_labels: rule.required_labels || [],
|
||||||
|
min_bbox_area: Number(rule.min_bbox_area ?? 0),
|
||||||
|
description: rule.description || ''
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitting.value = true
|
||||||
|
const payload = {
|
||||||
|
name: formState.name,
|
||||||
|
event_type: formState.event_type,
|
||||||
|
enabled: formState.enabled,
|
||||||
|
min_confidence: formState.min_confidence,
|
||||||
|
severity: formState.severity || null,
|
||||||
|
allowed_sources: formState.allowed_sources?.length ? formState.allowed_sources : null,
|
||||||
|
required_labels: formState.required_labels?.length ? formState.required_labels : null,
|
||||||
|
min_bbox_area: formState.min_bbox_area || 0,
|
||||||
|
description: formState.description || ''
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingName.value) {
|
||||||
|
await ruleApi.update(editingName.value, payload)
|
||||||
|
ElMessage.success('规则已保存')
|
||||||
|
} else {
|
||||||
|
await ruleApi.create(payload)
|
||||||
|
ElMessage.success('规则已创建')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadRules()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.detail || err.message || String(err)
|
||||||
|
ElMessage.error(`操作失败: ${msg}`)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggle(rule, enabled) {
|
||||||
|
try {
|
||||||
|
await ruleApi.update(rule.name, { ...rule, enabled })
|
||||||
|
ElMessage.success(`已${enabled ? '启用' : '停用'} ${rule.name}`)
|
||||||
|
await loadRules()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.detail || err.message
|
||||||
|
ElMessage.error(`切换失败: ${msg}`)
|
||||||
|
await loadRules()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(rule) {
|
||||||
|
try {
|
||||||
|
await ruleApi.remove(rule.name)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await loadRules()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`删除失败: ${err.response?.data?.detail || err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReload() {
|
||||||
|
try {
|
||||||
|
await ruleApi.reload()
|
||||||
|
ElMessage.success('规则已热重载')
|
||||||
|
await loadRules()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`重载失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLLMEnable() {
|
||||||
|
try {
|
||||||
|
await llmApi.enable()
|
||||||
|
ElMessage.success('LLM 已重新启用')
|
||||||
|
await loadLLMStatus()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLLMDisable() {
|
||||||
|
try {
|
||||||
|
await llmApi.disable()
|
||||||
|
ElMessage.success('LLM 已停用')
|
||||||
|
await loadLLMStatus()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLLMReset() {
|
||||||
|
try {
|
||||||
|
await llmApi.reset()
|
||||||
|
ElMessage.success('成本统计已重置')
|
||||||
|
await loadLLMStatus()
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`操作失败: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRules()
|
||||||
|
loadLLMStatus()
|
||||||
|
loadCameras()
|
||||||
|
pollTimer = setInterval(loadLLMStatus, 10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rule-config-page {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #020617;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- LLM 状态面板 ---- */
|
||||||
|
.llm-panel {
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #131E33;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748B;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar-wrap {
|
||||||
|
display: inline-block;
|
||||||
|
width: 80px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: #22C55E;
|
||||||
|
transition: width 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar.is-danger {
|
||||||
|
background: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-warning {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #FCA5A5;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 工具栏 ---- */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-control {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 规则列表 ---- */
|
||||||
|
.rule-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card {
|
||||||
|
display: flex;
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card:hover {
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card.is-disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-bar {
|
||||||
|
width: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-bar.severity-critical { background: #EF4444; }
|
||||||
|
.rule-bar.severity-high { background: #F59E0B; }
|
||||||
|
.rule-bar.severity-medium { background: #3B82F6; }
|
||||||
|
.rule-bar.severity-low { background: #94A3B8; }
|
||||||
|
.rule-bar.severity-info { background: #22C55E; }
|
||||||
|
|
||||||
|
.rule-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F8FAFC;
|
||||||
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94A3B8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94A3B8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item .el-icon { font-size: 14px; }
|
||||||
|
|
||||||
|
.empty-state { padding: 60px 0; }
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748B;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rule-dialog .el-dialog) {
|
||||||
|
background: #0F172A;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.rule-dialog .el-dialog__title) { color: #F8FAFC; }
|
||||||
|
:deep(.rule-dialog .el-form-item__label) { color: #CBD5E1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,821 @@
|
|||||||
|
# jc-video-recognize 项目现状与功能梳理
|
||||||
|
|
||||||
|
> 基于 `event-judgment-algorithm-architecture.md` 和 `project-integration-plan.md` 两份架构设计文档,
|
||||||
|
> 结合三个 MVP 迭代的实际代码实现,对项目当前已实现功能的全面梳理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档信息
|
||||||
|
|
||||||
|
- **项目名称**: jc-video-recognize 视频模型检测平台
|
||||||
|
- **文档版本**: v2.0
|
||||||
|
- **更新日期**: 2026-06-16
|
||||||
|
- **覆盖范围**: MVP-1(事件判断管道)+ MVP-2(RTSP/MQTT/实时推送)+ MVP-3(LLM 二次判断)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
本项目是一个全栈视频智能检测平台,集成了多种 AI 检测模型(YOLO 系列 + PaddlePaddle 系列),覆盖火灾、安全帽、人群、抽烟、徘徊、车辆违停、打架斗殴等场景。系统在原始检测能力基础上,构建了完整的事件判断管道、RTSP 视频流接入、MQTT 预警发布、WebSocket 实时推送、以及大模型二次判断等企业级能力。
|
||||||
|
|
||||||
|
### 1.1 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术栈 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| **前端** | Vue 3 + Vite 5 + Element Plus + Pinia | 暗色主题 UI,4 个核心页面 |
|
||||||
|
| **后端** | FastAPI + Uvicorn | REST API + WebSocket |
|
||||||
|
| **AI 推理** | YOLOv8/v10 (Ultralytics) + PaddlePaddle 3.0 | 多模型检测服务 |
|
||||||
|
| **视频流** | OpenCV + RTSP | 多路摄像头实时接入 |
|
||||||
|
| **消息系统** | MQTT (paho-mqtt) | 预警事件发布 |
|
||||||
|
| **大模型** | OpenAI 兼容协议 (GPT-4V/Qwen-VL/GLM-4V) | 二次判断与结果融合 |
|
||||||
|
| **目标跟踪** | ByteTrack (纯 Python) | 稳定跟踪 ID 分配 |
|
||||||
|
| **构建部署** | pnpm + Turborepo + Docker + Nginx | Monorepo 管理 |
|
||||||
|
|
||||||
|
### 1.2 整体架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 前端 (Vue 3) │
|
||||||
|
│ ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||||
|
│ │ 模型检测 │ │ 摄像头管理 │ │ 规则配置 │ │ 预警列表 │ │
|
||||||
|
│ │ Home.vue │ │ CameraMgmt │ │ RuleConfig│ │ AlertList │ │
|
||||||
|
│ └────┬─────┘ └──────┬───────┘ └────┬─────┘ └──────┬───────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └───────────────┴──────────────┴───────────────┘ │
|
||||||
|
│ │ HTTP/REST + WebSocket │
|
||||||
|
└───────────────────────┼─────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────────────┼─────────────────────────────────────────────────────┐
|
||||||
|
│ ▼ │
|
||||||
|
│ 后端 (FastAPI) │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ API 层 │ │
|
||||||
|
│ │ /api/detect/* /api/models/* /api/rtsp/* /api/rules/* /api/llm/*│ │
|
||||||
|
│ │ /ws/camera /ws/alerts │ │
|
||||||
|
│ └──────────────────────────┬───────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────────▼───────────────────────────────────────────┐ │
|
||||||
|
│ │ 服务层 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
|
||||||
|
│ │ │ DetectionSvc │ │ RTSPService │ │ StreamMgr │ │ MQTTService│ │ │
|
||||||
|
│ │ │ (检测核心) │ │ (流接入) │ │ (多路调度) │ │ (消息发布) │ │ │
|
||||||
|
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬─────┘ │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ ┌──────▼────────────────▼────────────────▼────────────────▼─────┐ │ │
|
||||||
|
│ │ │ 事件判断管道 │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ DecisionEngine → AlertRuleEngine → EventAggregator │ │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ │ ▼ ▼ │ │ │
|
||||||
|
│ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │
|
||||||
|
│ │ │ │ LLM 二次判断管道 │ │ │ │
|
||||||
|
│ │ │ │ FrameAccumulator → LLMTrigger → LLMAnalysisService │ │ │ │
|
||||||
|
│ │ │ │ ResultFusion + LLMCostTracker (熔断降级) │ │ │ │
|
||||||
|
│ │ │ └──────────────────────────────────────────────────────┘ │ │ │
|
||||||
|
│ │ └───────────────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │
|
||||||
|
│ │ │ ByteTracker │ │ AlertPublisher│ │ DetectionAdapter │ │ │
|
||||||
|
│ │ │ (目标跟踪) │ │ (MQTT发布) │ │ (结果格式统一) │ │ │
|
||||||
|
│ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 推理引擎层 │ │
|
||||||
|
│ │ ┌─────────┐ ┌──────────────┐ ┌────────────┐ │ │
|
||||||
|
│ │ │ YOLO │ │ PaddlePaddle │ │ Docker API │ │ │
|
||||||
|
│ │ │(Ultralytics) │(PP-YOLOE) │ │ (ppTSM) │ │ │
|
||||||
|
│ │ └─────────┘ └──────────────┘ └────────────┘ │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已实现功能清单
|
||||||
|
|
||||||
|
### 2.1 AI 检测能力(原始功能)
|
||||||
|
|
||||||
|
| 模型 ID | 名称 | 框架 | 检测类别 | 图片检测 | 视频检测 |
|
||||||
|
|---------|------|------|----------|---------|---------|
|
||||||
|
| `fire_detection` | 火灾检测 | YOLOv10 | 火焰、烟雾 | ✅ | ❌ |
|
||||||
|
| `helmet_detection` | 安全帽检测 | YOLOv8 | 人员、安全帽 | ✅ | ❌ |
|
||||||
|
| `crowd_detection` | 人群检测 | YOLOv8 | 人员 | ✅ | ❌ |
|
||||||
|
| `smoking_detection` | 抽烟检测 | YOLOv8 | 香烟、烟雾 | ✅ | ❌ |
|
||||||
|
| `smoking_detection_paddle` | 抽烟检测 (Paddle) | PP-YOLOE-s | 香烟 | ✅ | ❌ |
|
||||||
|
| `loitering_detection` | 徘徊检测 | YOLOv8 | 人员(含行为分析) | ✅ | ❌ |
|
||||||
|
| `vehicle_detection` | 车辆检测 | PP-YOLOE-l | 车辆 | ✅ | ❌ |
|
||||||
|
| `illegal_parking_detection` | 违停检测 | PP-YOLOE-l | 车辆(含违停判断) | ✅ | ❌ |
|
||||||
|
| `fight_detection` | 打架斗殴检测 | YOLOv8 | 暴力行为、正常 | ✅ | ✅ |
|
||||||
|
| `action_detection` | 打架检测 (Docker) | ppTSM | 打架、正常 | ✅ | ❌ |
|
||||||
|
|
||||||
|
**行为分析算法**(集成在 `loitering_detection` 下):
|
||||||
|
|
||||||
|
| 算法 | 说明 | 参数 |
|
||||||
|
|------|------|------|
|
||||||
|
| 静止检测 | 基于位置匹配,检测人员静止停留 | 静止阈值(秒)、位置容差(像素) |
|
||||||
|
| 徘徊检测 | 基于跟踪ID,检测人员长时间停留 | 徘徊阈值(秒)、移动阈值(像素) |
|
||||||
|
|
||||||
|
**检测适配器**:通过 `DetectionAdapter` 将 YOLO、PaddlePaddle、Docker 三种不同输出格式统一为 `UnifiedDetection` / `DetectionResult`,为后续事件管道提供一致的数据入口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 事件判断管道(MVP-1 已实现)
|
||||||
|
|
||||||
|
对应设计文档 `event-judgment-algorithm-architecture.md` 中的「决策层」。
|
||||||
|
|
||||||
|
#### 2.2.1 事件决策引擎 (`EventDecisionEngine`)
|
||||||
|
|
||||||
|
**文件**: `services/event/decision_engine.py`
|
||||||
|
|
||||||
|
- 根据置信度阈值过滤检测结果
|
||||||
|
- 将底层 class_name 映射为统一 `EventType` 枚举(fire/smoke/smoking/fight/loitering/illegal_parking 等)
|
||||||
|
- 生成 `CandidateEvent` 候选事件,附带严重性级别、来源信息、检测详情
|
||||||
|
|
||||||
|
#### 2.2.2 预警规则引擎 (`AlertRuleEngine`)
|
||||||
|
|
||||||
|
**文件**: `services/event/rule_engine.py`
|
||||||
|
|
||||||
|
- 从 YAML 配置目录加载规则(`config/rules/*.yaml`)
|
||||||
|
- 支持规则类型:置信度阈值、事件类型、最小边界框面积、限定摄像头来源、必备标签
|
||||||
|
- 每条规则可配置严重性级别(info/low/medium/high/critical)
|
||||||
|
- 支持热重载(无需重启服务即可更新规则)
|
||||||
|
- 已内置规则文件:`fire.yaml`、`smoking.yaml`、`loitering.yaml`、`fight.yaml`、`vehicle.yaml`
|
||||||
|
|
||||||
|
#### 2.2.3 事件聚合器 (`EventAggregator`)
|
||||||
|
|
||||||
|
**文件**: `services/event/aggregator.py`
|
||||||
|
|
||||||
|
- 基于时间窗口去重:同一 (source_id, event_type, track_id) 在窗口内只产生一条预警
|
||||||
|
- 空间邻近合并:IOU 超过阈值的同类事件自动合并
|
||||||
|
- 置信度加权融合:历史置信度按衰减因子递减,新检测加权更新
|
||||||
|
- 输出 `AlertEvent`,包含首次/末次出现时间、触发次数、关联检测列表
|
||||||
|
|
||||||
|
#### 2.2.4 统一事件数据契约 (`event_schemas.py`)
|
||||||
|
|
||||||
|
**文件**: `models/event_schemas.py`
|
||||||
|
|
||||||
|
定义了完整的事件数据模型体系:
|
||||||
|
|
||||||
|
| 模型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `EventType` | 统一事件类型枚举(11 种) |
|
||||||
|
| `SeverityLevel` | 严重性级别枚举(5 级) |
|
||||||
|
| `DetectionSource` | 检测来源枚举(YOLO/Paddle/Docker/Behavior/Composite) |
|
||||||
|
| `BBox` | 边界框 (xyxy) |
|
||||||
|
| `UnifiedDetection` | 统一检测结果(含 track_id) |
|
||||||
|
| `DetectionResult` | 单帧检测结果集合 |
|
||||||
|
| `CandidateEvent` | 候选事件(决策引擎输出) |
|
||||||
|
| `AlertEvent` | 预警事件(聚合器输出,最终发布格式) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 RTSP 视频流接入(MVP-2 已实现)
|
||||||
|
|
||||||
|
对应设计文档 `project-integration-plan.md` 中的「视频流接入方案」。
|
||||||
|
|
||||||
|
#### 2.3.1 RTSP 流接入服务 (`RTSPService`)
|
||||||
|
|
||||||
|
**文件**: `services/rtsp_service.py`
|
||||||
|
|
||||||
|
- 基于 OpenCV VideoCapture 的 RTSP 接入,兼容主流 IP 摄像头
|
||||||
|
- 后台线程解码帧,避免阻塞事件循环
|
||||||
|
- 自动重连:断线后按指数退避策略重试(可配置最大次数、间隔、退避因子)
|
||||||
|
- 帧回调机制:每解码一帧触发回调,由 StreamManager 分发到检测管道
|
||||||
|
- 优雅关闭:stop() 等待解码线程退出,释放资源
|
||||||
|
- 流状态管理:idle → connecting → connected → reconnecting → stopped → error
|
||||||
|
|
||||||
|
#### 2.3.2 多路流调度管理器 (`StreamManager`)
|
||||||
|
|
||||||
|
**文件**: `services/stream_manager.py`
|
||||||
|
|
||||||
|
- 统一管理多路 RTSPService 实例(最大 16 路,可配置)
|
||||||
|
- 每路流对应一个 `FrameBuffer`(环形缓冲区),解耦解码与检测
|
||||||
|
- 检测调度:轮询帧缓冲区,按流配置的模型和参数执行检测
|
||||||
|
- 状态监控:汇总所有流状态,提供健康检查接口
|
||||||
|
- RESTful API 管理:添加/移除/启动/停止/配置更新
|
||||||
|
|
||||||
|
#### 2.3.3 帧缓冲区 (`FrameBuffer`)
|
||||||
|
|
||||||
|
**文件**: `services/frame_buffer.py`
|
||||||
|
|
||||||
|
- 环形缓冲区设计,防止内存溢出
|
||||||
|
- 支持丢帧策略(最新帧优先 / 均匀采样)
|
||||||
|
- 线程安全:解码线程写入、检测线程读取
|
||||||
|
|
||||||
|
#### 2.3.4 RTSP 管理 API
|
||||||
|
|
||||||
|
**文件**: `api/rtsp.py`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/rtsp/streams` | 获取所有流状态列表 |
|
||||||
|
| `GET` | `/api/rtsp/streams/{stream_id}` | 获取单路流详情 |
|
||||||
|
| `POST` | `/api/rtsp/streams` | 添加新摄像头流 |
|
||||||
|
| `DELETE` | `/api/rtsp/streams/{stream_id}` | 移除摄像头流 |
|
||||||
|
| `POST` | `/api/rtsp/streams/{stream_id}/start` | 启动指定流 |
|
||||||
|
| `POST` | `/api/rtsp/streams/{stream_id}/stop` | 停止指定流 |
|
||||||
|
| `PUT` | `/api/rtsp/streams/{stream_id}/config` | 更新流检测配置 |
|
||||||
|
| `POST` | `/api/rtsp/start-all` | 启动全部流 |
|
||||||
|
| `POST` | `/api/rtsp/stop-all` | 停止全部流 |
|
||||||
|
| `GET` | `/api/rtsp/health` | 健康检查 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 MQTT 预警消息系统(MVP-2 已实现)
|
||||||
|
|
||||||
|
对应设计文档 `project-integration-plan.md` 中的「MQTT预警事件消息系统」。
|
||||||
|
|
||||||
|
#### 2.4.1 MQTT 客户端服务 (`MQTTService`)
|
||||||
|
|
||||||
|
**文件**: `services/mqtt_service.py`
|
||||||
|
|
||||||
|
- 基于 paho-mqtt 的 MQTT 客户端封装
|
||||||
|
- 支持用户名/密码认证
|
||||||
|
- 自动重连:按指数退避策略重试(可配置最小/最大延迟)
|
||||||
|
- QoS 等级可配置(默认 QoS 1,确保至少送达一次)
|
||||||
|
- 消息保留(retain)可配置
|
||||||
|
|
||||||
|
#### 2.4.2 预警发布器 (`AlertPublisher`)
|
||||||
|
|
||||||
|
**文件**: `services/alert_publisher.py`
|
||||||
|
|
||||||
|
- 将 `AlertEvent` 格式化为标准 JSON 消息
|
||||||
|
- 主题命名规则:`{prefix}/{event_type}/{source_id}`(单流单类型)、`{prefix}/all`(全量订阅)
|
||||||
|
- 同时触发 WebSocket 广播(通过 AlertBroadcaster)
|
||||||
|
- 消息包含:alert_id、event_type、severity、confidence、source_id、rule_name、detections 等
|
||||||
|
|
||||||
|
#### 2.4.3 MQTT 配置
|
||||||
|
|
||||||
|
通过环境变量或 `.env` 文件配置:
|
||||||
|
|
||||||
|
| 配置项 | 环境变量 | 默认值 | 说明 |
|
||||||
|
|--------|----------|--------|------|
|
||||||
|
| 启用 | `MQTT_ENABLED` | `false` | 是否启用 MQTT |
|
||||||
|
| Broker 地址 | `MQTT_BROKER_HOST` | `localhost` | MQTT 服务器 |
|
||||||
|
| Broker 端口 | `MQTT_BROKER_PORT` | `1883` | 端口 |
|
||||||
|
| 客户端 ID | `MQTT_CLIENT_ID` | `jc-video-recognize` | 客户端标识 |
|
||||||
|
| QoS | `MQTT_QOS` | `1` | 消息质量等级 |
|
||||||
|
| 主题前缀 | `MQTT_ALERT_TOPIC_PREFIX` | `video/alerts` | 主题前缀 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 目标跟踪服务(MVP-2 已实现)
|
||||||
|
|
||||||
|
对应设计文档 `event-judgment-algorithm-architecture.md` 中的「目标轨迹关联」。
|
||||||
|
|
||||||
|
#### 2.5.1 ByteTracker (`tracking_service.py`)
|
||||||
|
|
||||||
|
**文件**: `services/tracking_service.py`
|
||||||
|
|
||||||
|
- 基于 ByteTrack 论文的简化跟踪器,纯 Python 实现
|
||||||
|
- 按置信度将检测分为 high/low 两组
|
||||||
|
- 先用 high 检测与现有 tracks 进行 IOU 匹配
|
||||||
|
- 未匹配的 tracks 再与 low 检测匹配(拯救低置信度真实目标)
|
||||||
|
- 未匹配的 high 检测创建新 track
|
||||||
|
- 超过 `max_lost_frames` 的 tracks 移除
|
||||||
|
- 为每个检测目标分配稳定的 `track_id`,支持目标轨迹关联
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 WebSocket 实时推送(MVP-2 已实现)
|
||||||
|
|
||||||
|
对应设计文档 `project-integration-plan.md` 中的「实时通信」。
|
||||||
|
|
||||||
|
#### 2.6.1 预警 WebSocket (`/ws/alerts`)
|
||||||
|
|
||||||
|
**文件**: `api/alerts.py`
|
||||||
|
|
||||||
|
- `AlertBroadcaster` 维护 WebSocket 连接池
|
||||||
|
- 支持按事件类型、source_id 过滤订阅
|
||||||
|
- 心跳机制:前端每 30 秒发送 ping,后端回复 pong
|
||||||
|
- 预警事件通过 MQTT 发布的同时广播到所有 WebSocket 订阅者
|
||||||
|
- 消息格式:`{"type": "alert", "data": {...}}`
|
||||||
|
|
||||||
|
#### 2.6.2 摄像头 WebSocket (`/ws/camera`)
|
||||||
|
|
||||||
|
**文件**: `main.py` → `CameraService`
|
||||||
|
|
||||||
|
- 实时视频流传输(原始帧 + 标注帧双路)
|
||||||
|
- 支持动态切换模型、调整置信度/IOU 阈值
|
||||||
|
- 自动清理摄像头资源
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.7 LLM 大模型二次判断(MVP-3 已实现)
|
||||||
|
|
||||||
|
对应设计文档 `event-judgment-algorithm-architecture.md` 中的「AI增强层」和 `project-integration-plan.md` 中的「大模型二次判断架构」。
|
||||||
|
|
||||||
|
#### 2.7.1 多帧累积器 (`MultiFrameAccumulator`)
|
||||||
|
|
||||||
|
**文件**: `services/event/frame_accumulator.py`
|
||||||
|
|
||||||
|
- 累积同一目标在时间窗口内的候选事件
|
||||||
|
- 统计连续命中帧数、平均置信度、首次/末次出现时间
|
||||||
|
- LRU 淘汰机制:超出最大容量时淘汰最久未更新的条目
|
||||||
|
- 过期淘汰:超出时间窗口的条目自动清理
|
||||||
|
- 为 LLM 触发器提供累积统计数据
|
||||||
|
|
||||||
|
#### 2.7.2 LLM 触发器 (`LLMTrigger`)
|
||||||
|
|
||||||
|
**文件**: `services/event/llm_trigger.py`
|
||||||
|
|
||||||
|
- 基于累积统计决定是否触发 LLM 二次判断
|
||||||
|
- 触发条件:连续命中帧数 ≥ `min_consecutive_hits` 且平均置信度 ≥ `min_avg_confidence`
|
||||||
|
- 严重性旁路:`critical` 级别事件无需累积,立即触发 LLM
|
||||||
|
- 冷却机制:同一目标在冷却时间内不重复触发
|
||||||
|
- 输出 `TriggerDecision`,包含是否触发、累积帧列表、触发原因
|
||||||
|
|
||||||
|
#### 2.7.3 LLM 分析服务 (`LLMAnalysisService`)
|
||||||
|
|
||||||
|
**文件**: `services/llm_analysis_service.py`
|
||||||
|
|
||||||
|
- **Provider 抽象**:`BaseLLMProvider` 抽象基类,支持多种 LLM 后端
|
||||||
|
- `MockLLMProvider`:离线测试用,返回模拟结果
|
||||||
|
- `OpenAICompatibleProvider`:支持 OpenAI 兼容协议(GPT-4V/Qwen-VL/GLM-4V 等)
|
||||||
|
- 并发控制:`asyncio.Semaphore` 限制同时进行的 LLM 调用数
|
||||||
|
- 图像预处理:自动缩放到 `image_max_side` 以节省 token
|
||||||
|
- 结构化输出解析:从 LLM 响应中提取 confirmed/confidence/reasoning
|
||||||
|
- 调用统计:记录调用次数、成功/失败数、总延迟
|
||||||
|
|
||||||
|
#### 2.7.4 结果融合器 (`ResultFusion`)
|
||||||
|
|
||||||
|
**文件**: `services/result_fusion.py`
|
||||||
|
|
||||||
|
三种融合策略:
|
||||||
|
|
||||||
|
| 策略 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `weighted` | 加权融合:YOLO 权重 × YOLO 置信度 + LLM 权重 × LLM 置信度 |
|
||||||
|
| `conservative` | 保守策略:LLM 明确否决时直接抑制预警 |
|
||||||
|
| `llm_priority` | LLM 优先:LLM 给出明确判定时以 LLM 为准 |
|
||||||
|
|
||||||
|
关键行为:
|
||||||
|
- LLM 不可用/未触发时,可配置是否回退到 YOLO 结果(`fallback_to_yolo`)
|
||||||
|
- LLM 明确否决时,可配置是否抑制预警(`suppress_on_llm_negative`)
|
||||||
|
- 高融合置信度时可自动提升严重性级别(`promote_severity_on_high_confidence`)
|
||||||
|
|
||||||
|
#### 2.7.5 LLM 成本追踪与熔断降级 (`LLMCostTracker`)
|
||||||
|
|
||||||
|
**文件**: `services/llm_cost_tracker.py`
|
||||||
|
|
||||||
|
- **成本追踪**:记录每次 LLM 调用的 token 用量、费用、延迟
|
||||||
|
- **日预算控制**:超过日预算自动拒绝调用
|
||||||
|
- **熔断降级**:
|
||||||
|
- 状态机:CLOSED → OPEN → HALF_OPEN → CLOSED
|
||||||
|
- 触发条件:最近 N 次调用错误率超过阈值
|
||||||
|
- 冷却恢复:OPEN 状态持续 `cooldown_seconds` 后进入 HALF_OPEN,允许一次试探
|
||||||
|
- 手动控制:支持紧急停用 / 重新启用 / 重置统计
|
||||||
|
- **定价表**:内置主流模型定价,支持自定义覆盖
|
||||||
|
- **统计接口**:提供日级统计、历史趋势、最近调用记录
|
||||||
|
|
||||||
|
#### 2.7.6 LLM 管理 API
|
||||||
|
|
||||||
|
**文件**: `api/llm.py`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/llm/status` | 获取 LLM 运行状态(provider/模型/触发配置/融合配置/成本) |
|
||||||
|
| `GET` | `/api/llm/cost` | 获取当前成本统计 |
|
||||||
|
| `GET` | `/api/llm/cost/history` | 获取历史成本趋势(按天) |
|
||||||
|
| `GET` | `/api/llm/cost/records` | 获取最近调用记录 |
|
||||||
|
| `POST` | `/api/llm/disable` | 紧急停用 LLM |
|
||||||
|
| `POST` | `/api/llm/enable` | 重新启用 LLM |
|
||||||
|
| `POST` | `/api/llm/reset` | 重置成本统计与熔断状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.8 规则配置管理(MVP-3 已实现)
|
||||||
|
|
||||||
|
#### 2.8.1 规则管理 API
|
||||||
|
|
||||||
|
**文件**: `api/rules.py`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/rules` | 获取所有规则列表 |
|
||||||
|
| `GET` | `/api/rules/{name}` | 获取单条规则详情 |
|
||||||
|
| `POST` | `/api/rules` | 新增规则(写入 custom.yaml) |
|
||||||
|
| `PUT` | `/api/rules/{name}` | 更新规则 |
|
||||||
|
| `DELETE` | `/api/rules/{name}` | 删除规则 |
|
||||||
|
| `POST` | `/api/rules/reload` | 热重载规则(从 YAML 重新加载) |
|
||||||
|
| `GET` | `/api/rules/_stats` | 获取规则统计信息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.9 统一配置管理(MVP-1/2/3 已实现)
|
||||||
|
|
||||||
|
**文件**: `core/settings.py`
|
||||||
|
|
||||||
|
基于 `pydantic-settings` 的多环境配置管理,支持环境变量、`.env` 文件、默认值三级加载。
|
||||||
|
|
||||||
|
| 子配置 | 环境变量前缀 | 覆盖范围 |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `APISettings` | `API_` | 服务端口、CORS |
|
||||||
|
| `DetectionSettings` | `DETECTION_` | 默认置信度、IOU、最低置信度 |
|
||||||
|
| `ActionDetectionSettings` | `ACTION_DETECTION_` | Docker 行为识别服务 |
|
||||||
|
| `EventEngineSettings` | `EVENT_` | 去重窗口、规则目录、最大事件数 |
|
||||||
|
| `RTSPSettings` | `RTSP_` | 最大流数、缓冲区、重连策略 |
|
||||||
|
| `MQTTSettings` | `MQTT_` | Broker 配置、QoS、主题 |
|
||||||
|
| `TrackingSettings` | `TRACKING_` | ByteTrack 参数 |
|
||||||
|
| `AggregatorSettings` | `AGGREGATOR_` | 空间合并、置信度融合 |
|
||||||
|
| `LLMSettings` | `LLM_` | Provider、模型、API Key、并发 |
|
||||||
|
| `LLMTriggerSettings` | `LLM_TRIGGER_` | 触发阈值、冷却、严重性旁路 |
|
||||||
|
| `FusionSettings` | `FUSION_` | 融合策略、权重、回退 |
|
||||||
|
| `LLMCostSettings` | `LLM_COST_` | 日预算、熔断阈值、冷却 |
|
||||||
|
| `LoggingSettings` | `LOG_` | 日志级别、格式 |
|
||||||
|
| `PathSettings` | `PATH_` | 静态资源、模型路径 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 前端功能清单
|
||||||
|
|
||||||
|
### 3.1 页面结构
|
||||||
|
|
||||||
|
| 页面 | 路径 | 组件 | 功能 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 模型检测 | `/` | `Home.vue` + `ImageDetection.vue` + `VideoDetection.vue` | 图片/视频/摄像头实时检测 |
|
||||||
|
| 摄像头管理 | `/cameras` | `CameraManagement.vue` | RTSP 流增删改查、启停控制 |
|
||||||
|
| 规则配置 | `/rules` | `RuleConfiguration.vue` | 规则 CRUD + LLM 状态面板 |
|
||||||
|
| 预警列表 | `/alerts` | `AlertList.vue` | 实时预警展示、过滤、LLM 结果展示 |
|
||||||
|
|
||||||
|
### 3.2 全局功能
|
||||||
|
|
||||||
|
- **暗色主题 UI**:统一深色设计风格
|
||||||
|
- **可折叠侧边栏**:4 个导航菜单项
|
||||||
|
- **实时连接状态**:显示 WebSocket 连接状态(已连接/连接中/未连接)
|
||||||
|
- **预警铃铛**:顶部显示未读预警数量,点击跳转预警列表
|
||||||
|
- **全局预警弹窗**:`AlertNotification.vue` 收到新预警时弹出桌面通知
|
||||||
|
- **WebSocket 自动重连**:指数退避策略,最多重试 10 次
|
||||||
|
- **心跳保活**:每 30 秒发送 ping
|
||||||
|
|
||||||
|
### 3.3 摄像头管理页面功能
|
||||||
|
|
||||||
|
- 统计卡片:摄像头总数、运行中、重连中、故障
|
||||||
|
- 搜索过滤:按 ID/URL 搜索、按状态过滤
|
||||||
|
- 摄像头操作:启动/停止/配置/移除
|
||||||
|
- 批量操作:全部启动/全部停止
|
||||||
|
- 添加/编辑弹窗:配置 RTSP URL、检测模型、置信度/IOU 阈值、帧采样间隔
|
||||||
|
- 自动刷新:每 5 秒轮询状态
|
||||||
|
|
||||||
|
### 3.4 规则配置页面功能
|
||||||
|
|
||||||
|
- **LLM 状态面板**:
|
||||||
|
- Provider/模型/融合策略/触发阈值展示
|
||||||
|
- 今日调用次数/成功/失败/费用/预算使用率
|
||||||
|
- 最近错误率/熔断状态
|
||||||
|
- 紧急停用/重新启用/重置统计操作
|
||||||
|
- **规则列表**:
|
||||||
|
- 按规则名/描述搜索、按事件类型过滤
|
||||||
|
- 规则卡片:严重级别标签、事件类型、启停开关、编辑/删除
|
||||||
|
- 规则元数据:最低置信度、最小边界框面积、限定摄像头、必备标签
|
||||||
|
- **新增/编辑规则弹窗**:
|
||||||
|
- 规则名称、事件类型、严重级别
|
||||||
|
- 最低置信度滑块、最小边界框面积
|
||||||
|
- 限定摄像头 ID(多选)、必备标签(多选)
|
||||||
|
- 描述、启用开关
|
||||||
|
|
||||||
|
### 3.5 预警列表页面功能
|
||||||
|
|
||||||
|
- 统计卡片:预警总数、未读、严重预警、订阅状态
|
||||||
|
- 过滤工具栏:按严重级别/事件类型/摄像头 ID 过滤
|
||||||
|
- 预警卡片:
|
||||||
|
- 严重性色条(红/橙/蓝/灰/绿)
|
||||||
|
- 未读标记(绿色圆点)
|
||||||
|
- 事件类型、置信度、触发次数、规则名称
|
||||||
|
- 检测标签(含 track_id)
|
||||||
|
- **LLM 二次判断结果**:LLM 确认/否决标签、LLM 置信度、推理摘要
|
||||||
|
- 操作:全部标为已读、清空记录、单条删除
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 完整 API 接口一览
|
||||||
|
|
||||||
|
### 4.1 检测接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `POST` | `/api/detect/image` | 图片检测(支持所有模型) |
|
||||||
|
| `POST` | `/api/detect/video` | 视频检测(仅 YOLO 模型,主要面向打架斗殴) |
|
||||||
|
| `GET` | `/api/algorithms/config` | 获取算法配置选项 |
|
||||||
|
|
||||||
|
### 4.2 模型管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/models` | 获取可用模型列表 |
|
||||||
|
| `GET` | `/api/models/{model_id}` | 获取单个模型信息 |
|
||||||
|
| `POST` | `/api/models/{model_id}/load` | 加载指定模型 |
|
||||||
|
|
||||||
|
### 4.3 RTSP 流管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/rtsp/streams` | 获取所有流状态 |
|
||||||
|
| `GET` | `/api/rtsp/streams/{stream_id}` | 获取单路流详情 |
|
||||||
|
| `POST` | `/api/rtsp/streams` | 添加新摄像头流 |
|
||||||
|
| `DELETE` | `/api/rtsp/streams/{stream_id}` | 移除摄像头流 |
|
||||||
|
| `POST` | `/api/rtsp/streams/{stream_id}/start` | 启动指定流 |
|
||||||
|
| `POST` | `/api/rtsp/streams/{stream_id}/stop` | 停止指定流 |
|
||||||
|
| `PUT` | `/api/rtsp/streams/{stream_id}/config` | 更新流检测配置 |
|
||||||
|
| `POST` | `/api/rtsp/start-all` | 启动全部流 |
|
||||||
|
| `POST` | `/api/rtsp/stop-all` | 停止全部流 |
|
||||||
|
| `GET` | `/api/rtsp/health` | 健康检查 |
|
||||||
|
|
||||||
|
### 4.4 规则配置
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/rules` | 获取所有规则 |
|
||||||
|
| `GET` | `/api/rules/{name}` | 获取单条规则 |
|
||||||
|
| `POST` | `/api/rules` | 新增规则 |
|
||||||
|
| `PUT` | `/api/rules/{name}` | 更新规则 |
|
||||||
|
| `DELETE` | `/api/rules/{name}` | 删除规则 |
|
||||||
|
| `POST` | `/api/rules/reload` | 热重载规则 |
|
||||||
|
| `GET` | `/api/rules/_stats` | 规则统计 |
|
||||||
|
|
||||||
|
### 4.5 LLM 管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/llm/status` | LLM 运行状态 |
|
||||||
|
| `GET` | `/api/llm/cost` | 当前成本统计 |
|
||||||
|
| `GET` | `/api/llm/cost/history` | 历史成本趋势 |
|
||||||
|
| `GET` | `/api/llm/cost/records` | 最近调用记录 |
|
||||||
|
| `POST` | `/api/llm/disable` | 紧急停用 LLM |
|
||||||
|
| `POST` | `/api/llm/enable` | 重新启用 LLM |
|
||||||
|
| `POST` | `/api/llm/reset` | 重置统计与熔断 |
|
||||||
|
|
||||||
|
### 4.6 WebSocket
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `/ws/camera` | 摄像头实时视频流 |
|
||||||
|
| `/ws/alerts` | 预警事件实时推送(支持过滤订阅) |
|
||||||
|
|
||||||
|
### 4.7 系统
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/` | 服务信息 |
|
||||||
|
| `GET` | `/api/health` | 健康检查 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据流全景
|
||||||
|
|
||||||
|
### 5.1 实时检测管道(RTSP → 预警)
|
||||||
|
|
||||||
|
```
|
||||||
|
RTSP 摄像头
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
RTSPService (解码线程)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
FrameBuffer (环形缓冲区)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
StreamManager (检测调度)
|
||||||
|
│
|
||||||
|
├──▶ ModelService (YOLO/Paddle 推理)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ DetectionAdapter (格式统一)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ByteTracker (目标跟踪, 分配 track_id)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DetectionResult (统一检测结果)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EventDecisionEngine (置信度过滤 + 类型映射)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
CandidateEvent[]
|
||||||
|
│
|
||||||
|
├──▶ AlertRuleEngine (规则匹配)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ AlertEvent[] (规则过滤后)
|
||||||
|
│
|
||||||
|
├──▶ MultiFrameAccumulator (多帧累积)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ LLMTrigger (触发判定)
|
||||||
|
│ │
|
||||||
|
│ ▼ (触发时)
|
||||||
|
│ LLMAnalysisService (大模型分析)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ResultFusion (YOLO + LLM 融合)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ LLMCostTracker (成本追踪 + 熔断)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
EventAggregator (去重 + 空间合并 + 置信度融合)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AlertEvent (最终预警)
|
||||||
|
│
|
||||||
|
├──▶ AlertPublisher → MQTT (外部系统)
|
||||||
|
├──▶ AlertBroadcaster → WebSocket (前端实时推送)
|
||||||
|
└──▶ 前端 AlertList / 桌面通知
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 图片/视频检测管道(上传 → 结果)
|
||||||
|
|
||||||
|
```
|
||||||
|
用户上传图片/视频
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/detect/image 或 /api/detect/video
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
DetectionService
|
||||||
|
│
|
||||||
|
├──▶ ModelService (推理)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ 检测结果 + 标注图/视频
|
||||||
|
│
|
||||||
|
├──▶ 事件管道 (同上,但仅图片检测触发)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
返回检测结果 (含标注图/视频 URL/关键帧)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 测试覆盖
|
||||||
|
|
||||||
|
项目包含完整的单元测试和集成测试:
|
||||||
|
|
||||||
|
### 6.1 单元测试
|
||||||
|
|
||||||
|
| 测试文件 | 覆盖模块 |
|
||||||
|
|----------|---------|
|
||||||
|
| `test_decision_engine.py` | 事件决策引擎 |
|
||||||
|
| `test_rule_engine.py` | 预警规则引擎 |
|
||||||
|
| `test_aggregator.py` / `test_aggregator_v2.py` | 事件聚合器 |
|
||||||
|
| `test_event_schemas.py` | 事件数据模型 |
|
||||||
|
| `test_detection_adapter.py` | 检测适配器 |
|
||||||
|
| `test_frame_accumulator.py` | 多帧累积器 |
|
||||||
|
| `test_llm_trigger.py` | LLM 触发器 |
|
||||||
|
| `test_llm_analysis_service.py` | LLM 分析服务 |
|
||||||
|
| `test_llm_cost_tracker.py` | LLM 成本追踪 |
|
||||||
|
| `test_result_fusion.py` | 结果融合器 |
|
||||||
|
| `test_rtsp_service.py` | RTSP 流服务 |
|
||||||
|
| `test_stream_manager.py` | 多路流管理器 |
|
||||||
|
| `test_tracking_service.py` | 目标跟踪 |
|
||||||
|
| `test_mqtt_service.py` | MQTT 服务 |
|
||||||
|
| `test_alert_publisher.py` | 预警发布器 |
|
||||||
|
| `test_frame_buffer.py` | 帧缓冲区 |
|
||||||
|
| `test_settings.py` | 配置管理 |
|
||||||
|
|
||||||
|
### 6.2 集成测试
|
||||||
|
|
||||||
|
| 测试文件 | 覆盖场景 |
|
||||||
|
|----------|---------|
|
||||||
|
| `test_detect_image_integration.py` | 图片检测端到端 |
|
||||||
|
| `test_detection_service_pipeline.py` | 检测服务管道 |
|
||||||
|
| `test_event_pipeline.py` | 事件管道端到端 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 设计文档对照:已实现 vs 待实现
|
||||||
|
|
||||||
|
### 7.1 `event-judgment-algorithm-architecture.md` 对照
|
||||||
|
|
||||||
|
| 设计模块 | 设计内容 | 实现状态 | 说明 |
|
||||||
|
|----------|---------|---------|------|
|
||||||
|
| 事件决策引擎 | 置信度评估、场景识别、初步筛选 | ✅ 已实现 | 置信度过滤 + 类型映射,场景识别暂未实现 |
|
||||||
|
| 预警规则引擎 | 规则匹配、时间窗口、区域规则 | ✅ 已实现 | 置信度/面积/来源/标签规则已实现,时间窗口规则暂未实现 |
|
||||||
|
| 事件聚合器 | 去重合并、时间窗口、事件关联 | ✅ 已实现 | 时间窗口去重 + 空间合并 + 置信度融合 |
|
||||||
|
| 大模型触发器 | 智能决策、优先级排序、并发控制 | ✅ 已实现 | 多帧累积 + 冷却 + 严重性旁路 |
|
||||||
|
| LLM 视觉分析 | 图像理解、场景分析、推理验证 | ✅ 已实现 | OpenAI 兼容协议 + Mock Provider |
|
||||||
|
| 结果融合 | 置信融合、决策输出 | ✅ 已实现 | weighted/conservative/llm_priority 三策略 |
|
||||||
|
| 严重性评估器 | 风险等级、优先级计算 | ⚠️ 部分实现 | 规则引擎指定严重性,动态评估暂未实现 |
|
||||||
|
| 事件格式化 | 标准格式、元数据填充 | ✅ 已实现 | AlertEvent 统一格式 |
|
||||||
|
| MQTT 发布 | 消息发布、QoS 管理 | ✅ 已实现 | AlertPublisher + MQTTService |
|
||||||
|
|
||||||
|
### 7.2 `project-integration-plan.md` 对照
|
||||||
|
|
||||||
|
| 设计模块 | 设计内容 | 实现状态 | 说明 |
|
||||||
|
|----------|---------|---------|------|
|
||||||
|
| RTSP 流接入 | 解码、缓冲、重连 | ✅ 已实现 | RTSPService + FrameBuffer + 指数退避重连 |
|
||||||
|
| 多路流管理 | 调度、状态监控 | ✅ 已实现 | StreamManager + REST API |
|
||||||
|
| MQTT 预警系统 | 消息发布、QoS、主题设计 | ✅ 已实现 | MQTTService + AlertPublisher |
|
||||||
|
| AI 模型扩展 | 车辆/打架检测 | ✅ 已实现 | fight_detection + vehicle_detection_paddle |
|
||||||
|
| 大模型二次判断 | 多模型策略、Prompt 设计 | ✅ 已实现 | Provider 抽象 + 结构化输出解析 |
|
||||||
|
| 触发条件 | 置信度区间、去重窗口、并发限制 | ✅ 已实现 | LLMTrigger + MultiFrameAccumulator |
|
||||||
|
| 成本控制 | 日预算、熔断降级 | ✅ 已实现 | LLMCostTracker (CLOSED/OPEN/HALF_OPEN) |
|
||||||
|
| WebSocket 实时推送 | 预警订阅、过滤 | ✅ 已实现 | AlertBroadcaster + 前端 WebSocket 客户端 |
|
||||||
|
| 消息持久化 | 存储与消费确认 | ❌ 未实现 | 当前仅内存存储,未做持久化 |
|
||||||
|
| 人脸识别/属性分析 | InsightFace / PaddleFace | ❌ 未实现 | — |
|
||||||
|
| 越界/入侵检测 | 虚拟围栏算法 | ❌ 未实现 | — |
|
||||||
|
| 异常行为检测 | 自定义行为分析 | ⚠️ 部分实现 | 已有静止/徘徊检测,通用异常行为未实现 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 项目文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
jc-video-recognize/
|
||||||
|
├── apps/
|
||||||
|
│ ├── web/ # 前端 (Vue 3 + Vite 5)
|
||||||
|
│ │ └── src/
|
||||||
|
│ │ ├── api/detection.js # API 请求封装 (检测/摄像头/规则/LLM)
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── AlertNotification.vue # 全局预警弹窗
|
||||||
|
│ │ │ ├── AlgorithmConfig.vue # 行为分析算法配置
|
||||||
|
│ │ │ ├── DetectionConfig.vue # 检测参数配置
|
||||||
|
│ │ │ ├── ImageDetection.vue # 图片检测模块
|
||||||
|
│ │ │ └── VideoDetection.vue # 视频/摄像头检测模块
|
||||||
|
│ │ ├── layouts/MainLayout.vue # 主布局 (侧边栏+顶栏+预警铃铛)
|
||||||
|
│ │ ├── router/index.js # 路由 (4 个页面)
|
||||||
|
│ │ ├── services/mqtt.client.js # WebSocket 预警客户端
|
||||||
|
│ │ ├── stores/alertStore.js # Pinia 预警状态管理
|
||||||
|
│ │ └── views/
|
||||||
|
│ │ ├── Home.vue # 模型检测首页
|
||||||
|
│ │ ├── CameraManagement.vue # 摄像头管理
|
||||||
|
│ │ ├── RuleConfiguration.vue # 规则配置 + LLM 面板
|
||||||
|
│ │ └── AlertList.vue # 预警列表
|
||||||
|
│ └── server/ # 后端 (FastAPI)
|
||||||
|
│ ├── main.py # 服务入口 + 生命周期管理
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── detection.py # 检测 API
|
||||||
|
│ │ ├── models.py # 模型管理 API
|
||||||
|
│ │ ├── rtsp.py # RTSP 流管理 API
|
||||||
|
│ │ ├── alerts.py # 预警 WebSocket API
|
||||||
|
│ │ ├── rules.py # 规则配置 API
|
||||||
|
│ │ └── llm.py # LLM 管理 API
|
||||||
|
│ ├── config/rules/ # 规则 YAML 配置
|
||||||
|
│ │ ├── fire.yaml
|
||||||
|
│ │ ├── smoking.yaml
|
||||||
|
│ │ ├── loitering.yaml
|
||||||
|
│ │ ├── fight.yaml
|
||||||
|
│ │ └── vehicle.yaml
|
||||||
|
│ ├── core/settings.py # 统一配置管理 (14 个子配置)
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── event_schemas.py # 统一事件数据契约
|
||||||
|
│ │ └── schemas.py # 检测请求/响应模型
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── detection_service.py # 检测核心 (管道编排)
|
||||||
|
│ │ ├── model_service.py # 模型加载与管理
|
||||||
|
│ │ ├── rtsp_service.py # RTSP 流接入
|
||||||
|
│ │ ├── stream_manager.py # 多路流调度
|
||||||
|
│ │ ├── frame_buffer.py # 帧缓冲区
|
||||||
|
│ │ ├── mqtt_service.py # MQTT 客户端
|
||||||
|
│ │ ├── alert_publisher.py # 预警发布器
|
||||||
|
│ │ ├── tracking_service.py # ByteTrack 目标跟踪
|
||||||
|
│ │ ├── llm_analysis_service.py # LLM 分析服务
|
||||||
|
│ │ ├── llm_cost_tracker.py # LLM 成本追踪 + 熔断
|
||||||
|
│ │ ├── result_fusion.py # 结果融合器
|
||||||
|
│ │ ├── loitering_service.py # 徘徊检测服务
|
||||||
|
│ │ ├── camera_service.py # 摄像头 WebSocket 服务
|
||||||
|
│ │ ├── action_detection_service.py # Docker 行为识别适配器
|
||||||
|
│ │ ├── paddle_detection_service.py # PaddlePaddle 抽烟检测适配器
|
||||||
|
│ │ ├── vehicle_detection_service.py # 车辆/违停检测适配器
|
||||||
|
│ │ ├── adapters/detection_adapter.py # 检测结果格式统一适配器
|
||||||
|
│ │ └── event/
|
||||||
|
│ │ ├── decision_engine.py # 事件决策引擎
|
||||||
|
│ │ ├── rule_engine.py # 预警规则引擎
|
||||||
|
│ │ ├── aggregator.py # 事件聚合器
|
||||||
|
│ │ ├── frame_accumulator.py # 多帧累积器
|
||||||
|
│ │ └── llm_trigger.py # LLM 触发器
|
||||||
|
│ └── tests/ # 测试 (17 单元 + 3 集成)
|
||||||
|
├── models/ # AI 模型文件
|
||||||
|
│ ├── fire_detection/ # YOLOv10 火灾检测
|
||||||
|
│ ├── helmet_detection/ # YOLOv8 安全帽检测
|
||||||
|
│ ├── crowd_detection/ # YOLOv8 人群检测
|
||||||
|
│ ├── smoking_detection/ # YOLOv8 抽烟检测
|
||||||
|
│ ├── smoking_detection_paddle/ # PaddlePaddle 抽烟检测
|
||||||
|
│ ├── loitering_detection/ # YOLOv8 徒步检测
|
||||||
|
│ ├── fight_detection/ # YOLOv8 打架斗殴检测
|
||||||
|
│ └── vehicle_detection_paddle/ # PaddlePaddle 车辆检测
|
||||||
|
├── docs/ # 项目文档
|
||||||
|
└── docker/ # Docker 部署配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 待完善功能
|
||||||
|
|
||||||
|
基于两份设计文档的对照,以下功能尚未实现:
|
||||||
|
|
||||||
|
| 优先级 | 功能 | 来源文档 | 说明 |
|
||||||
|
|--------|------|---------|------|
|
||||||
|
| 高 | 预警事件持久化存储 | project-integration-plan | 当前仅内存存储,重启后丢失 |
|
||||||
|
| 高 | 时间窗口规则 | event-judgment-algorithm | 规则引擎暂不支持工作时间/非工作时间过滤 |
|
||||||
|
| 中 | 场景识别 | event-judgment-algorithm | 决策引擎暂未实现室内/室外/禁区场景分类 |
|
||||||
|
| 中 | 动态严重性评估 | event-judgment-algorithm | 当前严重性由规则静态指定,未实现动态计算 |
|
||||||
|
| 中 | 越界/入侵检测 | project-integration-plan | 虚拟围栏算法未实现 |
|
||||||
|
| 中 | 人脸识别/属性分析 | project-integration-plan | InsightFace / PaddleFace 未集成 |
|
||||||
|
| 低 | 区域规则 | event-judgment-algorithm | 规则引擎暂不支持区域(禁区/普通区域)过滤 |
|
||||||
|
| 低 | 组合规则 | event-judgment-algorithm | 多条件组合规则未实现 |
|
||||||
|
| 低 | 消息消费确认机制 | project-integration-plan | MQTT 消息持久化与消费确认未实现 |
|
||||||
|
| 低 | 预警消息管理后台 | project-integration-plan | 消息查询与统计分析页面未实现 |
|
||||||
Reference in New Issue
Block a user