Compare commits

..
19 Commits
Author SHA1 Message Date
cacstj a37b110a7f 预警列表添加”全部”分类,预警消息弹窗5秒后自动消失 2026-06-16 17:27:18 +08:00
cacstj 7396889f32 预警图片检测触发修复 2026-06-16 16:58:24 +08:00
wuzhuorong 0158b41712 docs: 总结项目现状与功能梳理 2026-06-16 14:58:49 +08:00
wuzhuorong 1409734c8a feat(server): 新增 LLM 集成配置项与 pydantic-settings 嵌套模型修复 2026-06-16 11:48:24 +08:00
wuzhuorong 86ac65c6d8 chore(): 更新依赖 2026-06-16 11:45:50 +08:00
wuzhuorong 66c0eb27c8 feat(server): 完善 LLM 集成基础设施与规则配置 2026-06-16 11:41:07 +08:00
wuzhuorong fe2b569046 feat(web): 前端集成 LLM 预警推送与二次判断结果展示 2026-06-16 11:26:53 +08:00
wuzhuorong b097983f1b feat(web): 新增摄像头管理与规则配置页面 2026-06-16 11:25:59 +08:00
wuzhuorong f5b1fe3a0a fix(server): 视频检测接口补充 LLM 管道结果传递 2026-06-16 11:13:37 +08:00
wuzhuorong 36dc83b36f feat(server): 新增 LLM 状态/成本 API 与规则配置 API 2026-06-16 11:00:58 +08:00
wuzhuorong dce7014774 feat(server): DetectionService 集成 LLM 二次判断完整管道 2026-06-16 10:59:16 +08:00
wuzhuorong 7e59455361 feat(server): 新增 LLM 成本追踪与熔断降级机制 2026-06-16 10:58:01 +08:00
wuzhuorong 01e6feb46e feat(server): 新增 LLM 视觉分析服务与 YOLO/LLM 结果融合策略 2026-06-16 10:40:31 +08:00
wuzhuorong 0eb30a875b feat(event): 新增事件多帧累积与 LLM 触发决策模块 2026-06-16 10:29:35 +08:00
wuzhuorong a13d0c46af feat(event): 新增 LLM 事件类型映射扩展 2026-06-16 10:27:52 +08:00
wuzhuorong 435fe3073f feat(event): 新增 LLM 触发决策器 LLMTrigger 2026-06-16 10:20:10 +08:00
wuzhuorong ab599486ef feat(event): 新增多帧累积分析器 MultiFrameAccumulator 2026-06-16 10:16:10 +08:00
wuzhuorong eb75045d7c Merge remote-tracking branch 'origin/master' 2026-06-15 17:38:48 +08:00
wuzhuorong bfd68026ca perf(web):优化图片检测切换到视频检测的模型切换问题 2026-06-15 17:38:03 +08:00
27 changed files with 5385 additions and 109 deletions
+63
View File
@@ -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
+37 -3
View File
@@ -14,6 +14,36 @@ router = APIRouter()
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)
async def detect_image(
file: UploadFile = File(...),
@@ -42,7 +72,7 @@ async def detect_image(
from main import model_service
from services.detection_service import DetectionService
detection_service = DetectionService(model_service)
detection_service = get_detection_service(model_service)
# 解析算法配置
algo_config = None
@@ -100,7 +130,8 @@ async def detect_image(
"detections": result['detections'],
"image_base64": img_base64,
"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', {})
}
)
@@ -309,7 +340,10 @@ async def detect_video(
'frame_index': frame_index,
'timestamp': round(frame_index / fps, 2) if fps > 0 else 0,
'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 张,防止响应过大)
+186
View File
@@ -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"]
+281
View File
@@ -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"]
+6 -4
View File
@@ -1,17 +1,19 @@
# 打架检测预警规则 (MVP-1)
# [测试模式] min_confidence 已调低到 0.3 用于触发测试
# [生产模式] 推荐: high=0.55, critical=0.75
rules:
- name: fight_high
event_type: fight
enabled: true
min_confidence: 0.55
min_confidence: 0.3
severity: high
min_bbox_area: 800
min_bbox_area: 100
description: 检测到打架/暴力行为,触发高级预警
- name: fight_critical_continuous
event_type: fight
enabled: true
min_confidence: 0.75
min_confidence: 0.5
severity: critical
min_bbox_area: 1200
min_bbox_area: 100
description: 检测到高置信度打架行为,立即触发最高级别预警
+111 -9
View File
@@ -34,6 +34,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
SERVER_DIR: Path = Path(__file__).resolve().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):
"""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="监听地址")
port: int = Field(default=8000, description="监听端口")
@@ -56,7 +59,7 @@ class APISettings(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_iou: float = Field(default=0.45, ge=0.0, le=1.0)
@@ -67,7 +70,7 @@ class DetectionSettings(BaseSettings):
class ActionDetectionSettings(BaseSettings):
"""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")
timeout: int = Field(default=30, ge=1)
@@ -76,7 +79,7 @@ class ActionDetectionSettings(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) 在窗口内只产生一条
dedup_window_seconds: float = Field(default=30.0, ge=0.0)
@@ -89,7 +92,7 @@ class EventEngineSettings(BaseSettings):
class RTSPSettings(BaseSettings):
"""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="最大同时接入流数量")
buffer_capacity: int = Field(default=300, ge=1, description="每路流帧缓冲区容量")
@@ -105,7 +108,7 @@ class RTSPSettings(BaseSettings):
class MQTTSettings(BaseSettings):
"""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 发布")
broker_host: str = Field(default="localhost", description="MQTT broker 主机")
@@ -126,7 +129,7 @@ class MQTTSettings(BaseSettings):
class TrackingSettings(BaseSettings):
"""目标跟踪 (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="是否启用目标跟踪")
track_thresh: float = Field(default=0.5, ge=0.0, le=1.0, description="跟踪置信度阈值")
@@ -139,7 +142,7 @@ class TrackingSettings(BaseSettings):
class AggregatorSettings(BaseSettings):
"""事件聚合器扩展配置 (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="是否启用空间邻近合并")
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):
"""日志配置。"""
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")
json_format: bool = Field(default=False)
@@ -217,6 +311,10 @@ class Settings(BaseSettings):
mqtt: MQTTSettings = Field(default_factory=MQTTSettings)
tracking: TrackingSettings = Field(default_factory=TrackingSettings)
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)
paths: PathSettings = Field(default_factory=PathSettings)
@@ -243,6 +341,10 @@ __all__ = [
"MQTTSettings",
"TrackingSettings",
"AggregatorSettings",
"LLMSettings",
"LLMTriggerSettings",
"FusionSettings",
"LLMCostSettings",
"LoggingSettings",
"PathSettings",
"get_settings",
+28
View File
@@ -11,8 +11,13 @@ import logging
from api import detection, models
from api.alerts import get_broadcaster
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.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)
logger = logging.getLogger(__name__)
@@ -74,6 +79,29 @@ async def lifespan(app: FastAPI):
app.include_router(rtsp_router, prefix="/api")
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
# 关闭时清理资源
+1
View File
@@ -43,6 +43,7 @@ psutil==6.1.1
# 网络相关
httpx==0.28.1
certifi==2026.5.20
paho-mqtt==2.1.0
# 开发工具
ipython==9.1.0
+1 -74
View File
@@ -48,8 +48,7 @@ class DetectionService:
model_id: str,
confidence: float = 0.5,
iou: float = 0.45,
algorithm_config: Optional[Dict] = None,
region_polygon: Optional[List[List[int]]] = None
algorithm_config: Optional[Dict] = None
) -> Dict:
start_time = time.time()
@@ -63,39 +62,6 @@ class DetectionService:
}
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)
detections = []
@@ -236,44 +202,6 @@ class DetectionService:
'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)
detections = []
@@ -651,7 +579,6 @@ class DetectionService:
'helmet': (255, 255, 0),
'no_helmet': (255, 0, 255),
'cigarette': (0, 165, 255),
'illegal_parking': (0, 0, 255),
# 兼容旧模型类别
'violence': (0, 0, 255),
'fight': (0, 0, 255),
+16 -4
View File
@@ -1,14 +1,26 @@
"""事件引擎子包 (MVP-1 / P1-P2 / P5)。
"""事件引擎子包 (MVP-1 / P1-P2 / P5 + MVP-3 / D26-D28)。
模块结构::
decision_engine.py 决策引擎 (置信度评估 + 事件类型映射)
rule_engine.py 规则引擎 (YAML 驱动)
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 .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,
"flame": EventType.FIRE,
"火焰": EventType.FIRE,
"smoke": EventType.SMOKE,
"烟雾": EventType.SMOKE,
# 抽烟
"smoking": EventType.SMOKING,
"cigarette": EventType.SMOKING,
# 打架
"fight": EventType.FIGHT,
"fighting": EventType.FIGHT,
"violence": EventType.FIGHT, # YOLO打架模型 (fight_detection) 正样本
# 行为
"loitering": EventType.LOITERING,
"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"]
+244
View File
@@ -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",
]
+555
View File
@@ -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 tokenprompt 模板 ~ 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",
]
+375
View File
@@ -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"]
+94
View File
@@ -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')
}
}
+40 -8
View File
@@ -5,7 +5,7 @@
<DetectionConfig
:models="models"
:default-type="'image'"
:default-model="config.model"
:default-model="selectedModel || (models.length > 0 ? models[0].id : '')"
@type-change="onTypeChange"
@config-change="onConfigChange"
/>
@@ -346,18 +346,25 @@ import {
} from '@element-plus/icons-vue'
import { detectionApi } from '@/api/detection'
import DetectionConfig from './DetectionConfig.vue'
import { useAlertStore } from '@/stores/alertStore'
const alertStore = useAlertStore()
const props = defineProps({
models: {
type: Array,
default: () => []
},
selectedModel: {
type: String,
default: ''
}
})
const emit = defineEmits(['type-change'])
const emit = defineEmits(['type-change', 'model-change'])
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,
iou: 0.45,
composite: false,
@@ -369,7 +376,12 @@ const onTypeChange = (type) => {
}
const onConfigChange = (newConfig) => {
const oldModel = config.value.model
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) {
alerts.value = response.data.alerts
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({
message: `行为告警: ${alert.type} - ${alert.message}`,
message: `预警: ${alertType} - ${alertMessage}`,
type: 'warning',
duration: 3000
})
@@ -778,6 +795,23 @@ const handleFightVideoSuccess = (response) => {
const detCount = response.data.stats?.total_detections || 0
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) {
console.warn('视频编码不兼容浏览器,将以关键帧形式展示')
@@ -1088,8 +1122,6 @@ onUnmounted(() => {
.image-container {
width: 100%;
aspect-ratio: 16 / 9;
min-height: 400px;
max-height: 600px;
display: flex;
align-items: center;
@@ -1101,8 +1133,8 @@ onUnmounted(() => {
}
.display-image {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 600px;
object-fit: contain;
background: #000;
}
+13 -4
View File
@@ -5,7 +5,7 @@
<DetectionConfig
:models="models"
:default-type="'video'"
:default-model="config.model"
:default-model="selectedModel || (models.length > 0 ? models[0].id : '')"
@type-change="onTypeChange"
@config-change="onConfigChange"
/>
@@ -223,13 +223,17 @@ const props = defineProps({
models: {
type: Array,
default: () => []
},
selectedModel: {
type: String,
default: ''
}
})
const emit = defineEmits(['type-change'])
const emit = defineEmits(['type-change', 'model-change'])
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,
iou: 0.45,
composite: false,
@@ -241,8 +245,13 @@ const onTypeChange = (type) => {
}
const onConfigChange = (newConfig) => {
const oldModel = config.value.model
config.value = { ...newConfig }
// 模型变化时通知父组件
if (newConfig.model && newConfig.model !== oldModel) {
emit('model-change', newConfig.model)
}
// 如果摄像头已连接,实时更新配置
if (websocket.value && cameraConnected.value) {
updateCameraConfig()
+5 -1
View File
@@ -99,7 +99,9 @@ import {
WarningFilled,
Bell,
Fold,
Expand
Expand,
Connection,
SetUp
} from '@element-plus/icons-vue'
import { useAlertStore } from '@/stores/alertStore'
import { connectAlertSocket, disconnectAlertSocket } from '@/services/mqtt.client'
@@ -111,6 +113,8 @@ const alertStore = useAlertStore()
const menuItems = [
{ path: '/', title: '模型检测', icon: VideoCameraFilled },
{ path: '/cameras', title: '摄像头管理', icon: Connection },
{ path: '/rules', title: '规则配置', icon: SetUp },
{ path: '/alerts', title: '预警列表', icon: WarningFilled }
]
+15 -1
View File
@@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.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'
const routes = [
@@ -14,6 +16,18 @@ const routes = [
component: Home,
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',
name: 'AlertList',
@@ -29,4 +43,4 @@ const router = createRouter({
routes
})
export default router
export default router
+19 -1
View File
@@ -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) {
const severityLabels = {
critical: '严重',
@@ -94,6 +105,8 @@ export const useAlertStore = defineStore('alerts', () => {
}
const label = severityLabels[alert.severity] || alert.severity
const typeName = alert.event_type || '未知事件'
const offset = getAvailableOffset()
activeNotifyOffsets.add(offset)
ElNotification({
title: `[${label}] ${typeName}`,
@@ -103,10 +116,15 @@ export const useAlertStore = defineStore('alerts', () => {
type: alert.severity === 'critical' || alert.severity === 'high'
? 'warning'
: 'info',
duration: alert.severity === 'critical' ? 0 : 4500,
duration: 5000,
offset,
showClose: true,
dangerouslyUseHTMLString: false,
onClick: () => {
markAsRead(alert.alert_id)
},
onClose: () => {
activeNotifyOffsets.delete(offset)
}
})
}
+51
View File
@@ -29,6 +29,7 @@
class="filter-control"
@change="applyFilter"
>
<el-option label="全部" value="" />
<el-option label="严重" value="critical" />
<el-option label="高危" value="high" />
<el-option label="中等" value="medium" />
@@ -44,6 +45,7 @@
class="filter-control"
@change="applyFilter"
>
<el-option label="全部" value="" />
<el-option label="火灾" value="fire" />
<el-option label="烟雾" value="smoke" />
<el-option label="抽烟" value="smoking" />
@@ -156,6 +158,28 @@
<span v-if="det.track_id" class="track-id">#{{ det.track_id }}</span>
</span>
</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 class="alert-actions">
@@ -534,6 +558,33 @@ onMounted(() => {
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 {
display: flex;
align-items: center;
+683
View File
@@ -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>
+13
View File
@@ -4,14 +4,18 @@
<ImageDetection
v-if="activeTab === 'image'"
:models="models"
:selected-model="selectedModel"
@type-change="handleTypeChange"
@model-change="handleModelChange"
/>
<!-- 视频检测模块 -->
<VideoDetection
v-if="activeTab === 'video'"
:models="models"
:selected-model="selectedModel"
@type-change="handleTypeChange"
@model-change="handleModelChange"
/>
</div>
</template>
@@ -26,15 +30,24 @@ import VideoDetection from '@/components/VideoDetection.vue'
const activeTab = ref('image')
const models = ref([])
const selectedModel = ref('')
const handleTypeChange = (type) => {
activeTab.value = type
}
const handleModelChange = (modelId) => {
selectedModel.value = modelId
}
const loadModels = async () => {
try {
const response = await detectionApi.getModels()
models.value = response.data
// 初始化默认选中第一个模型
if (models.value.length > 0 && !selectedModel.value) {
selectedModel.value = models.value[0].id
}
} catch (error) {
ElMessage.error('加载模型列表失败')
}
+903
View File
@@ -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>
+821
View File
@@ -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-2RTSP/MQTT/实时推送)+ MVP-3LLM 二次判断)
---
## 1. 项目概述
本项目是一个全栈视频智能检测平台,集成了多种 AI 检测模型(YOLO 系列 + PaddlePaddle 系列),覆盖火灾、安全帽、人群、抽烟、徘徊、车辆违停、打架斗殴等场景。系统在原始检测能力基础上,构建了完整的事件判断管道、RTSP 视频流接入、MQTT 预警发布、WebSocket 实时推送、以及大模型二次判断等企业级能力。
### 1.1 技术栈
| 层级 | 技术栈 | 说明 |
|------|--------|------|
| **前端** | Vue 3 + Vite 5 + Element Plus + Pinia | 暗色主题 UI4 个核心页面 |
| **后端** | 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 | 消息查询与统计分析页面未实现 |