feat: 新增人员徘徊/静止行为分析功能
本次提交实现了完整的人员行为分析系统,包括: 1. 新增基于位置和跟踪ID的两种行为检测算法 2. 新增徘徊检测服务与行为处理器模块 3. 前后端集成算法配置界面与告警展示 4. 支持图片和视频流场景下的行为分析 5. 新增算法配置接口与文档说明 具体改动: - 新增loitering_detection模型目录与算法实现 - 新增AlgorithmConfig组件实现可视化配置 - 扩展图片/视频检测接口支持算法参数传递 - 新增行为告警推送与前端展示页面 - 优化检测服务,集成行为分析逻辑 - 移除冗余日志输出,完善代码注释
This commit is contained in:
@@ -2,24 +2,50 @@ import cv2
|
||||
import numpy as np
|
||||
import base64
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, UploadFile, File, Form, Query
|
||||
from models.schemas import ImageDetectionResult
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/detect/image", response_model=ImageDetectionResult)
|
||||
async def detect_image(
|
||||
file: UploadFile = File(...),
|
||||
model_id: str = Query("fire_detection"),
|
||||
confidence: float = Query(0.5),
|
||||
iou: float = Query(0.45)
|
||||
iou: float = Query(0.45),
|
||||
algorithm_config: Optional[str] = Query(None, description="算法配置JSON字符串")
|
||||
):
|
||||
"""
|
||||
图片检测接口
|
||||
|
||||
Args:
|
||||
algorithm_config: 算法配置JSON,例如:
|
||||
{
|
||||
"enable_stationary_detection": true,
|
||||
"enable_loitering_detection": false,
|
||||
"stationary_threshold": 10.0,
|
||||
"position_tolerance": 50,
|
||||
"loitering_threshold": 300.0,
|
||||
"movement_threshold": 5.0
|
||||
}
|
||||
"""
|
||||
from main import model_service
|
||||
from services.detection_service import DetectionService
|
||||
|
||||
detection_service = DetectionService(model_service)
|
||||
|
||||
# 解析算法配置
|
||||
algo_config = None
|
||||
if algorithm_config:
|
||||
try:
|
||||
algo_config = json.loads(algorithm_config)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"算法配置解析失败: {e}")
|
||||
|
||||
try:
|
||||
contents = await file.read()
|
||||
nparr = np.frombuffer(contents, np.uint8)
|
||||
@@ -32,10 +58,14 @@ async def detect_image(
|
||||
data={}
|
||||
)
|
||||
|
||||
result = await detection_service.detect_image(frame, model_id, confidence, iou)
|
||||
result = await detection_service.detect_image(
|
||||
frame, model_id, confidence, iou, algorithm_config=algo_config
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
annotated_frame = detection_service.draw_detections(frame, result['detections'])
|
||||
annotated_frame = detection_service.draw_detections(
|
||||
frame, result['detections'], algorithm_config=algo_config
|
||||
)
|
||||
|
||||
# 将标注后的图片转换为 base64
|
||||
_, buffer = cv2.imencode('.jpg', annotated_frame)
|
||||
@@ -47,7 +77,9 @@ async def detect_image(
|
||||
data={
|
||||
"detections": result['detections'],
|
||||
"image_base64": img_base64,
|
||||
"stats": result['stats']
|
||||
"stats": result['stats'],
|
||||
"alerts": result.get('alerts', []),
|
||||
"behavior_stats": result.get('behavior_stats', {})
|
||||
}
|
||||
)
|
||||
else:
|
||||
@@ -64,3 +96,66 @@ async def detect_image(
|
||||
message=f"检测失败: {str(e)}",
|
||||
data={}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/algorithms/config")
|
||||
async def get_algorithm_config():
|
||||
"""获取算法配置选项"""
|
||||
return {
|
||||
"algorithms": [
|
||||
{
|
||||
"id": "stationary_detection",
|
||||
"name": "静止检测",
|
||||
"description": "检测人员在同一位置静止停留",
|
||||
"params": [
|
||||
{
|
||||
"name": "stationary_threshold",
|
||||
"label": "静止阈值",
|
||||
"type": "number",
|
||||
"default": 10.0,
|
||||
"min": 1.0,
|
||||
"max": 300.0,
|
||||
"unit": "秒",
|
||||
"description": "超过此时间视为静止"
|
||||
},
|
||||
{
|
||||
"name": "position_tolerance",
|
||||
"label": "位置容差",
|
||||
"type": "number",
|
||||
"default": 50,
|
||||
"min": 10,
|
||||
"max": 200,
|
||||
"unit": "像素",
|
||||
"description": "位置匹配容差范围"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "loitering_detection",
|
||||
"name": "徘徊检测",
|
||||
"description": "检测人员长时间停留(需要跟踪ID)",
|
||||
"params": [
|
||||
{
|
||||
"name": "loitering_threshold",
|
||||
"label": "徘徊阈值",
|
||||
"type": "number",
|
||||
"default": 300.0,
|
||||
"min": 60.0,
|
||||
"max": 1800.0,
|
||||
"unit": "秒",
|
||||
"description": "超过此时间视为徘徊"
|
||||
},
|
||||
{
|
||||
"name": "movement_threshold",
|
||||
"label": "移动阈值",
|
||||
"type": "number",
|
||||
"default": 5.0,
|
||||
"min": 1.0,
|
||||
"max": 50.0,
|
||||
"unit": "像素",
|
||||
"description": "小于此移动视为静止"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -249,11 +249,21 @@ class CameraService:
|
||||
|
||||
logger.info(f"发送检测结果: {len(result['detections'])} 个目标, {result['stats']}")
|
||||
|
||||
await websocket.send_json({
|
||||
detection_message = {
|
||||
'type': 'detection',
|
||||
'detections': result['detections'],
|
||||
'stats': result['stats']
|
||||
})
|
||||
}
|
||||
|
||||
# 包含行为告警信息
|
||||
if 'alerts' in result and result['alerts']:
|
||||
detection_message['alerts'] = result['alerts']
|
||||
logger.info(f"发送告警: {len(result['alerts'])} 个")
|
||||
|
||||
if 'behavior_stats' in result:
|
||||
detection_message['behavior_stats'] = result['behavior_stats']
|
||||
|
||||
await websocket.send_json(detection_message)
|
||||
|
||||
_, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
|
||||
import base64
|
||||
|
||||
@@ -7,6 +7,8 @@ import logging
|
||||
from typing import Dict, List, Optional
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .loitering_service import get_loitering_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DetectionService:
|
||||
@@ -18,64 +20,20 @@ class DetectionService:
|
||||
|
||||
os.makedirs(self.results_dir, exist_ok=True)
|
||||
os.makedirs(self.temp_dir, exist_ok=True)
|
||||
|
||||
def draw_detections(self, frame: np.ndarray, detections: List[Dict], fps: float = 0) -> np.ndarray:
|
||||
try:
|
||||
img_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
pil_img = Image.fromarray(img_rgb)
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 20)
|
||||
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
font_large = font
|
||||
|
||||
class_colors = {
|
||||
'Fire': (255, 0, 0),
|
||||
'Smoke': (128, 128, 128),
|
||||
'person': (0, 255, 0),
|
||||
'helmet': (255, 255, 0),
|
||||
'no_helmet': (255, 0, 255),
|
||||
'cigarette': (0, 165, 255) # 橙色,用于抽烟检测
|
||||
}
|
||||
|
||||
for det in detections:
|
||||
x1, y1, x2, y2 = det['bbox']
|
||||
class_name = det['class']
|
||||
conf = det['confidence']
|
||||
label = det['label']
|
||||
|
||||
color = class_colors.get(class_name, (0, 255, 0))
|
||||
|
||||
draw.rectangle([x1, y1, x2, y2], outline=color, width=3)
|
||||
|
||||
label_text = f"{label} {conf:.2f}"
|
||||
bbox = draw.textbbox((0, 0), label_text, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
draw.rectangle([x1, y1 - text_h - 4, x1 + text_w + 4, y1], fill=color)
|
||||
draw.text((x1 + 2, y1 - text_h - 2), label_text, fill=(255, 255, 255), font=font)
|
||||
|
||||
if fps > 0:
|
||||
fps_text = f"FPS: {fps:.1f} | Detections: {len(detections)}"
|
||||
draw.text((10, 10), fps_text, fill=(0, 255, 0), font=font)
|
||||
|
||||
return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||
except Exception as e:
|
||||
logger.error(f"绘制检测结果失败: {e}")
|
||||
return frame
|
||||
|
||||
# 初始化徘徊检测服务(懒加载,实际初始化在第一次使用时)
|
||||
self.loitering_service = get_loitering_service()
|
||||
|
||||
async def detect_image(
|
||||
self,
|
||||
self,
|
||||
image: np.ndarray,
|
||||
model_id: str,
|
||||
confidence: float = 0.5,
|
||||
iou: float = 0.45
|
||||
iou: float = 0.45,
|
||||
algorithm_config: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
model = await self.model_service.load_model(model_id)
|
||||
if not model:
|
||||
return {
|
||||
@@ -84,10 +42,10 @@ class DetectionService:
|
||||
'detections': [],
|
||||
'stats': None
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
results = model(image, conf=confidence, iou=iou, verbose=False)
|
||||
|
||||
|
||||
detections = []
|
||||
for result in results:
|
||||
boxes = result.boxes
|
||||
@@ -96,21 +54,21 @@ class DetectionService:
|
||||
conf = float(box.conf[0].cpu().numpy())
|
||||
cls = int(box.cls[0].cpu().numpy())
|
||||
class_name = result.names[cls]
|
||||
|
||||
|
||||
label_map = self.model_service.model_configs[model_id]['labels']
|
||||
label = label_map.get(class_name, class_name)
|
||||
|
||||
|
||||
detections.append({
|
||||
'class': class_name,
|
||||
'label': label,
|
||||
'confidence': round(conf, 3),
|
||||
'bbox': [int(x1), int(y1), int(x2), int(y2)]
|
||||
})
|
||||
|
||||
|
||||
processing_time = time.time() - start_time
|
||||
avg_confidence = sum(d['confidence'] for d in detections) / len(detections) if detections else 0
|
||||
|
||||
return {
|
||||
|
||||
result_data = {
|
||||
'success': True,
|
||||
'message': '检测完成',
|
||||
'detections': detections,
|
||||
@@ -121,6 +79,14 @@ class DetectionService:
|
||||
'model_used': model_id
|
||||
}
|
||||
}
|
||||
|
||||
# 如果启用了行为检测算法
|
||||
if algorithm_config and detections:
|
||||
result_data = self._apply_behavior_analysis(
|
||||
result_data, algorithm_config
|
||||
)
|
||||
|
||||
return result_data
|
||||
except Exception as e:
|
||||
logger.error(f"图片检测失败: {e}")
|
||||
return {
|
||||
@@ -186,9 +152,40 @@ class DetectionService:
|
||||
}
|
||||
}
|
||||
|
||||
# 如果是人员检测模型,进行行为分析
|
||||
logger.info(f"[DetectionService] 模型: {model_id}, 检测目标: {len(detections)}")
|
||||
if model_id == 'loitering_detection' and detections:
|
||||
logger.info("[DetectionService] 调用行为分析...")
|
||||
|
||||
# 确保服务已初始化
|
||||
if not self.loitering_service.is_initialized:
|
||||
logger.info("[DetectionService] 初始化徘徊检测服务...")
|
||||
self.loitering_service.initialize(
|
||||
# 检测阈值(用于判断是否静止/徘徊)
|
||||
stationary_threshold=10.0,
|
||||
position_tolerance=50,
|
||||
loitering_threshold=300.0,
|
||||
movement_threshold=5.0,
|
||||
# 告警阈值(用于触发告警,应该比检测阈值高)
|
||||
stationary_alert_threshold=30.0,
|
||||
loitering_alert_threshold=600.0,
|
||||
# 启用告警
|
||||
enable_stationary_alert=True,
|
||||
enable_loitering_alert=True
|
||||
)
|
||||
|
||||
behavior_result = self.loitering_service.process_detections(
|
||||
detections,
|
||||
use_tracking=False # 可以改为 True 如果使用跟踪
|
||||
)
|
||||
detections = behavior_result['detections']
|
||||
result_data['alerts'] = behavior_result['alerts']
|
||||
result_data['behavior_stats'] = behavior_result['stats']
|
||||
logger.info(f"[DetectionService] 行为分析完成: alerts={len(behavior_result['alerts'])}, stats={behavior_result['stats']}")
|
||||
|
||||
if draw:
|
||||
frame = self.draw_detections(frame, detections, fps)
|
||||
|
||||
|
||||
return frame, result_data
|
||||
except Exception as e:
|
||||
logger.error(f"帧检测失败: {e}")
|
||||
@@ -197,3 +194,139 @@ class DetectionService:
|
||||
'detections': [],
|
||||
'stats': None
|
||||
}
|
||||
|
||||
def _apply_behavior_analysis(
|
||||
self,
|
||||
result_data: Dict,
|
||||
algorithm_config: Dict
|
||||
) -> Dict:
|
||||
"""
|
||||
应用行为分析算法
|
||||
|
||||
Args:
|
||||
result_data: 检测结果
|
||||
algorithm_config: 算法配置
|
||||
{
|
||||
"enable_stationary_detection": true,
|
||||
"enable_loitering_detection": false,
|
||||
"stationary_threshold": 10.0,
|
||||
"position_tolerance": 50,
|
||||
...
|
||||
}
|
||||
|
||||
Returns:
|
||||
添加行为分析结果的检测结果
|
||||
"""
|
||||
detections = result_data['detections']
|
||||
|
||||
# 检查是否需要行为分析
|
||||
enable_stationary = algorithm_config.get('enable_stationary_detection', False)
|
||||
enable_loitering = algorithm_config.get('enable_loitering_detection', False)
|
||||
|
||||
if not enable_stationary and not enable_loitering:
|
||||
return result_data
|
||||
|
||||
try:
|
||||
# 使用前端传入的配置初始化服务
|
||||
self.loitering_service.initialize(
|
||||
stationary_threshold=algorithm_config.get('stationary_threshold', 10.0),
|
||||
position_tolerance=algorithm_config.get('position_tolerance', 50),
|
||||
loitering_threshold=algorithm_config.get('loitering_threshold', 300.0),
|
||||
movement_threshold=algorithm_config.get('movement_threshold', 5.0),
|
||||
enable_stationary_alert=enable_stationary,
|
||||
enable_loitering_alert=enable_loitering
|
||||
)
|
||||
|
||||
# 处理检测
|
||||
behavior_result = self.loitering_service.process_detections(
|
||||
detections,
|
||||
use_tracking=enable_loitering # 只有启用徘徊检测时才使用跟踪
|
||||
)
|
||||
|
||||
result_data['detections'] = behavior_result['detections']
|
||||
result_data['alerts'] = behavior_result['alerts']
|
||||
result_data['behavior_stats'] = behavior_result['stats']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"行为分析失败: {e}")
|
||||
result_data['behavior_error'] = str(e)
|
||||
|
||||
return result_data
|
||||
|
||||
def draw_detections(
|
||||
self,
|
||||
frame: np.ndarray,
|
||||
detections: List[Dict],
|
||||
fps: float = 0,
|
||||
algorithm_config: Optional[Dict] = None
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
绘制检测结果和行为告警
|
||||
|
||||
Args:
|
||||
frame: 图像帧
|
||||
detections: 检测结果列表(可能包含 stationary_info/loitering_info)
|
||||
fps: 帧率
|
||||
algorithm_config: 算法配置(已废弃,保留用于向后兼容)
|
||||
"""
|
||||
try:
|
||||
img_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
pil_img = Image.fromarray(img_rgb)
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 20)
|
||||
font_large = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
font_large = font
|
||||
|
||||
class_colors = {
|
||||
'Fire': (255, 0, 0),
|
||||
'Smoke': (128, 128, 128),
|
||||
'person': (0, 255, 0),
|
||||
'helmet': (255, 255, 0),
|
||||
'no_helmet': (255, 0, 255),
|
||||
'cigarette': (0, 165, 255)
|
||||
}
|
||||
|
||||
for det in detections:
|
||||
x1, y1, x2, y2 = det['bbox']
|
||||
class_name = det['class']
|
||||
conf = det['confidence']
|
||||
label = det['label']
|
||||
|
||||
# 根据是否有行为告警选择颜色
|
||||
color = class_colors.get(class_name, (0, 255, 0))
|
||||
|
||||
# 检查行为告警
|
||||
if algorithm_config:
|
||||
if 'stationary_info' in det:
|
||||
info = det['stationary_info']
|
||||
if info.get('is_stationary'):
|
||||
color = (0, 0, 255) # 红色警告
|
||||
label = f"静止{int(info['duration'])}s"
|
||||
|
||||
if 'loitering_info' in det:
|
||||
info = det['loitering_info']
|
||||
if info.get('is_loitering'):
|
||||
color = (255, 0, 0) # 蓝色警告
|
||||
label = f"徘徊{int(info['loitering_duration']//60)}min"
|
||||
|
||||
draw.rectangle([x1, y1, x2, y2], outline=color, width=3)
|
||||
|
||||
label_text = f"{label} {conf:.2f}"
|
||||
bbox = draw.textbbox((0, 0), label_text, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
draw.rectangle([x1, y1 - text_h - 4, x1 + text_w + 4, y1], fill=color)
|
||||
draw.text((x1 + 2, y1 - text_h - 2), label_text, fill=(255, 255, 255), font=font)
|
||||
|
||||
if fps > 0:
|
||||
fps_text = f"FPS: {fps:.1f} | Detections: {len(detections)}"
|
||||
draw.text((10, 10), fps_text, fill=(0, 255, 0), font=font)
|
||||
|
||||
return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||||
except Exception as e:
|
||||
logger.error(f"绘制检测结果失败: {e}")
|
||||
return frame
|
||||
|
||||
168
apps/server/services/loitering_service.py
Normal file
168
apps/server/services/loitering_service.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
徘徊检测服务
|
||||
集成行为检测算法到后端服务
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
import logging
|
||||
|
||||
# 添加算法模块路径
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'models', 'loitering_detection'))
|
||||
|
||||
from processors import BehaviorProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoiteringService:
|
||||
"""
|
||||
徘徊检测服务
|
||||
|
||||
为视频流检测提供行为分析功能:
|
||||
- 静止检测(基于位置,无需跟踪)
|
||||
- 徘徊检测(基于跟踪ID)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.processor = None
|
||||
self.is_initialized = False
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
stationary_threshold: float = 10.0,
|
||||
position_tolerance: int = 50,
|
||||
loitering_threshold: float = 300.0,
|
||||
movement_threshold: float = 5.0,
|
||||
enable_stationary_alert: bool = True,
|
||||
enable_loitering_alert: bool = True,
|
||||
stationary_alert_threshold: Optional[float] = None,
|
||||
loitering_alert_threshold: Optional[float] = None
|
||||
):
|
||||
"""
|
||||
初始化服务
|
||||
|
||||
Args:
|
||||
stationary_threshold: 静止检测阈值(秒)- 用于判断是否静止
|
||||
position_tolerance: 位置容差(像素)
|
||||
loitering_threshold: 徘徊检测阈值(秒)- 用于判断是否徘徊
|
||||
movement_threshold: 移动阈值(像素)
|
||||
enable_stationary_alert: 是否启用静止告警
|
||||
enable_loitering_alert: 是否启用徘徊告警
|
||||
stationary_alert_threshold: 静止告警阈值(秒)- 超过此时间产生告警,默认等于 stationary_threshold
|
||||
loitering_alert_threshold: 徘徊告警阈值(秒)- 超过此时间产生告警,默认等于 loitering_threshold
|
||||
"""
|
||||
try:
|
||||
self.processor = BehaviorProcessor(
|
||||
stationary_threshold=stationary_threshold,
|
||||
position_tolerance=position_tolerance,
|
||||
loitering_threshold=loitering_threshold,
|
||||
movement_threshold=movement_threshold,
|
||||
enable_stationary_alert=enable_stationary_alert,
|
||||
enable_loitering_alert=enable_loitering_alert,
|
||||
stationary_alert_threshold=stationary_alert_threshold if stationary_alert_threshold is not None else stationary_threshold,
|
||||
loitering_alert_threshold=loitering_alert_threshold if loitering_alert_threshold is not None else loitering_threshold
|
||||
)
|
||||
self.is_initialized = True
|
||||
logger.info(f"徘徊检测服务初始化成功: 静止阈值={stationary_threshold}s, 告警阈值={stationary_alert_threshold or stationary_threshold}s")
|
||||
except Exception as e:
|
||||
logger.error(f"徘徊检测服务初始化失败: {e}")
|
||||
self.is_initialized = False
|
||||
|
||||
def process_detections(
|
||||
self,
|
||||
detections: List[Dict],
|
||||
use_tracking: bool = False,
|
||||
track_id_key: str = 'track_id'
|
||||
) -> Dict:
|
||||
"""
|
||||
处理检测结果
|
||||
|
||||
Args:
|
||||
detections: YOLO检测结果列表
|
||||
use_tracking: 是否使用跟踪ID
|
||||
track_id_key: 跟踪ID字段名
|
||||
|
||||
Returns:
|
||||
{
|
||||
'detections': 添加行为信息的检测结果,
|
||||
'alerts': 触发的告警列表,
|
||||
'stats': 统计信息
|
||||
}
|
||||
"""
|
||||
if not self.is_initialized or not self.processor:
|
||||
return {
|
||||
'detections': detections,
|
||||
'alerts': [],
|
||||
'stats': {'error': '服务未初始化'}
|
||||
}
|
||||
|
||||
try:
|
||||
return self.processor.process(
|
||||
detections=detections,
|
||||
use_tracking=use_tracking,
|
||||
track_id_key=track_id_key
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"处理检测结果失败: {e}")
|
||||
return {
|
||||
'detections': detections,
|
||||
'alerts': [],
|
||||
'stats': {'error': str(e)}
|
||||
}
|
||||
|
||||
def get_stationary_persons(self) -> List[Dict]:
|
||||
"""获取所有静止人员"""
|
||||
if not self.is_initialized or not self.processor:
|
||||
return []
|
||||
return self.processor.get_stationary_persons()
|
||||
|
||||
def get_loitering_persons(self) -> List[Dict]:
|
||||
"""获取所有徘徊人员"""
|
||||
if not self.is_initialized or not self.processor:
|
||||
return []
|
||||
return self.processor.get_loitering_persons()
|
||||
|
||||
def reset(self):
|
||||
"""重置检测器"""
|
||||
if self.processor:
|
||||
self.processor.reset()
|
||||
logger.info("徘徊检测器已重置")
|
||||
|
||||
def get_config(self) -> Dict:
|
||||
"""获取当前配置"""
|
||||
if not self.is_initialized or not self.processor:
|
||||
return {'error': '服务未初始化'}
|
||||
return self.processor.get_config()
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""获取统计信息"""
|
||||
if not self.is_initialized or not self.processor:
|
||||
return {'error': '服务未初始化'}
|
||||
|
||||
stats = {
|
||||
'stationary_count': len(self.get_stationary_persons()),
|
||||
'loitering_count': len(self.get_loitering_persons()),
|
||||
'config': self.get_config()
|
||||
}
|
||||
return stats
|
||||
|
||||
|
||||
# 全局服务实例
|
||||
_loitering_service: Optional[LoiteringService] = None
|
||||
|
||||
|
||||
def get_loitering_service() -> LoiteringService:
|
||||
"""获取全局徘徊检测服务实例"""
|
||||
global _loitering_service
|
||||
if _loitering_service is None:
|
||||
_loitering_service = LoiteringService()
|
||||
return _loitering_service
|
||||
|
||||
|
||||
def initialize_loitering_service(**kwargs):
|
||||
"""初始化全局徘徊检测服务"""
|
||||
service = get_loitering_service()
|
||||
service.initialize(**kwargs)
|
||||
return service
|
||||
@@ -82,7 +82,6 @@ class ModelService:
|
||||
return None
|
||||
|
||||
if model_id in self.models:
|
||||
logger.info(f"模型已加载: {model_id}")
|
||||
return self.models[model_id]
|
||||
|
||||
config = self.model_configs[model_id]
|
||||
|
||||
@@ -9,12 +9,22 @@ export const detectionApi = {
|
||||
getModels() {
|
||||
return api.get('/models')
|
||||
},
|
||||
|
||||
detectImage(formData) {
|
||||
|
||||
getAlgorithmConfig() {
|
||||
return api.get('/algorithms/config')
|
||||
},
|
||||
|
||||
detectImage(formData, algorithmConfig = null) {
|
||||
const params = {}
|
||||
if (algorithmConfig) {
|
||||
params.algorithm_config = JSON.stringify(algorithmConfig)
|
||||
}
|
||||
|
||||
return api.post('/detect/image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
},
|
||||
params
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
326
apps/web/src/components/AlgorithmConfig.vue
Normal file
326
apps/web/src/components/AlgorithmConfig.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div v-if="showConfig" class="algorithm-config">
|
||||
<el-divider content-position="left">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span style="margin-left: 8px;">行为分析算法</span>
|
||||
</el-divider>
|
||||
|
||||
<div v-if="loading" class="loading-wrapper">
|
||||
<el-skeleton :rows="3" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="algorithms.length === 0" class="empty-config">
|
||||
<el-empty description="暂无可配置算法" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<div v-else class="algorithm-list">
|
||||
<div
|
||||
v-for="algo in algorithms"
|
||||
:key="algo.id"
|
||||
class="algorithm-item"
|
||||
>
|
||||
<div class="algorithm-header">
|
||||
<el-switch
|
||||
v-model="config[algo.id].enabled"
|
||||
@change="onConfigChange"
|
||||
:active-text="algo.name"
|
||||
/>
|
||||
<el-tooltip :content="algo.description" placement="top">
|
||||
<el-icon class="info-icon"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="config[algo.id].enabled" class="algorithm-params">
|
||||
<div
|
||||
v-for="param in algo.params"
|
||||
:key="param.name"
|
||||
class="param-item"
|
||||
>
|
||||
<div class="param-label">
|
||||
{{ param.label }}
|
||||
<el-tooltip :content="param.description" placement="top">
|
||||
<el-icon class="help-icon"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="param-control">
|
||||
<el-slider
|
||||
v-if="param.type === 'number'"
|
||||
v-model="config[algo.id].params[param.name]"
|
||||
:min="param.min"
|
||||
:max="param.max"
|
||||
:step="param.name.includes('threshold') ? 1 : 1"
|
||||
@change="onConfigChange"
|
||||
show-input
|
||||
:show-input-controls="false"
|
||||
input-size="small"
|
||||
/>
|
||||
</div>
|
||||
<div class="param-unit">{{ param.unit }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<el-button size="small" @click="resetConfig" :icon="RefreshRight">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" @click="applyConfig" :icon="Check">
|
||||
应用
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Cpu,
|
||||
InfoFilled,
|
||||
QuestionFilled,
|
||||
RefreshRight,
|
||||
Check
|
||||
} from '@element-plus/icons-vue'
|
||||
import { detectionApi } from '@/api/detection'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
modelId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 支持行为分析的模型列表
|
||||
const SUPPORTED_MODELS = [
|
||||
'loitering_detection', // 徘徊检测
|
||||
'crowd_detection', // 人群检测
|
||||
'person_detection' // 人员检测
|
||||
]
|
||||
|
||||
// 是否显示配置
|
||||
const showConfig = computed(() => {
|
||||
return SUPPORTED_MODELS.some(model => props.modelId.includes(model))
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const loading = ref(false)
|
||||
const algorithms = ref([])
|
||||
const config = reactive({})
|
||||
|
||||
// 获取算法配置
|
||||
const fetchAlgorithmConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await detectionApi.getAlgorithmConfig()
|
||||
algorithms.value = response.data.algorithms || []
|
||||
|
||||
// 初始化配置
|
||||
algorithms.value.forEach(algo => {
|
||||
if (!config[algo.id]) {
|
||||
config[algo.id] = {
|
||||
enabled: false,
|
||||
params: {}
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认参数
|
||||
algo.params.forEach(param => {
|
||||
if (config[algo.id].params[param.name] === undefined) {
|
||||
config[algo.id].params[param.name] = param.default
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取算法配置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成后端需要的配置格式
|
||||
const generateConfig = () => {
|
||||
const result = {}
|
||||
|
||||
algorithms.value.forEach(algo => {
|
||||
const algoConfig = config[algo.id]
|
||||
if (algoConfig && algoConfig.enabled) {
|
||||
// 根据算法类型设置启用标志
|
||||
if (algo.id === 'stationary_detection') {
|
||||
result.enable_stationary_detection = true
|
||||
} else if (algo.id === 'loitering_detection') {
|
||||
result.enable_loitering_detection = true
|
||||
}
|
||||
|
||||
// 添加参数
|
||||
Object.entries(algoConfig.params).forEach(([key, value]) => {
|
||||
result[key] = value
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 配置变化
|
||||
const onConfigChange = () => {
|
||||
const backendConfig = generateConfig()
|
||||
emit('update:modelValue', backendConfig)
|
||||
emit('change', backendConfig)
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
const resetConfig = () => {
|
||||
algorithms.value.forEach(algo => {
|
||||
config[algo.id] = {
|
||||
enabled: false,
|
||||
params: {}
|
||||
}
|
||||
|
||||
algo.params.forEach(param => {
|
||||
config[algo.id].params[param.name] = param.default
|
||||
})
|
||||
})
|
||||
|
||||
onConfigChange()
|
||||
ElMessage.success('配置已重置')
|
||||
}
|
||||
|
||||
// 应用配置
|
||||
const applyConfig = () => {
|
||||
onConfigChange()
|
||||
ElMessage.success('配置已应用')
|
||||
}
|
||||
|
||||
// 监听外部配置变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal && Object.keys(newVal).length > 0) {
|
||||
// 根据外部配置更新内部状态
|
||||
if (newVal.enable_stationary_detection) {
|
||||
config['stationary_detection'].enabled = true
|
||||
}
|
||||
if (newVal.enable_loitering_detection) {
|
||||
config['loitering_detection'].enabled = true
|
||||
}
|
||||
|
||||
// 更新参数
|
||||
Object.entries(newVal).forEach(([key, value]) => {
|
||||
algorithms.value.forEach(algo => {
|
||||
if (config[algo.id].params[key] !== undefined) {
|
||||
config[algo.id].params[key] = value
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
fetchAlgorithmConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.algorithm-config {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.empty-config {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.algorithm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.algorithm-item {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.algorithm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.algorithm-header :deep(.el-switch__label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #909399;
|
||||
cursor: help;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.algorithm-params {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #dcdfe6;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.param-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
color: #c0c4cc;
|
||||
cursor: help;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.param-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.param-control :deep(.el-slider) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.param-control :deep(.el-slider__input) {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.param-unit {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
}
|
||||
</style>
|
||||
@@ -93,6 +93,13 @@
|
||||
<div class="slider-value">{{ config.iou.toFixed(2) }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 算法配置(仅对人员检测模型显示) -->
|
||||
<AlgorithmConfig
|
||||
v-model="config.algorithmConfig"
|
||||
@change="onAlgorithmChange"
|
||||
:model-id="config.model"
|
||||
/>
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -225,6 +232,7 @@ import {
|
||||
QuestionFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { detectionApi } from '@/api/detection'
|
||||
import AlgorithmConfig from './AlgorithmConfig.vue'
|
||||
|
||||
const props = defineProps({
|
||||
models: {
|
||||
@@ -236,7 +244,8 @@ const props = defineProps({
|
||||
const config = ref({
|
||||
model: props.models.length > 0 ? props.models[0].id : 'fire_detection',
|
||||
confidence: 0.5,
|
||||
iou: 0.45
|
||||
iou: 0.45,
|
||||
algorithmConfig: {}
|
||||
})
|
||||
|
||||
// 可拖拽调整宽度相关
|
||||
@@ -271,7 +280,20 @@ const originalImage = ref('')
|
||||
const resultImage = ref('')
|
||||
const detections = ref([])
|
||||
const stats = ref(null)
|
||||
const uploadUrl = computed(() => `/api/detect/image?model_id=${config.value.model}&confidence=${config.value.confidence}&iou=${config.value.iou}`)
|
||||
const uploadUrl = computed(() => {
|
||||
const params = new URLSearchParams({
|
||||
model_id: config.value.model,
|
||||
confidence: config.value.confidence,
|
||||
iou: config.value.iou
|
||||
})
|
||||
|
||||
// 添加算法配置
|
||||
if (config.value.algorithmConfig && Object.keys(config.value.algorithmConfig).length > 0) {
|
||||
params.append('algorithm_config', JSON.stringify(config.value.algorithmConfig))
|
||||
}
|
||||
|
||||
return `/api/detect/image?${params.toString()}`
|
||||
})
|
||||
|
||||
const formatConfidence = (value) => {
|
||||
return `置信度: ${value.toFixed(2)}`
|
||||
@@ -303,6 +325,22 @@ const handleUploadSuccess = (response) => {
|
||||
}
|
||||
detections.value = response.data.detections || []
|
||||
stats.value = response.data.stats
|
||||
|
||||
// 处理告警信息
|
||||
if (response.data.alerts && response.data.alerts.length > 0) {
|
||||
alerts.value = response.data.alerts
|
||||
console.log('收到告警:', response.data.alerts)
|
||||
|
||||
// 显示告警通知
|
||||
response.data.alerts.forEach(alert => {
|
||||
ElMessage({
|
||||
message: `行为告警: ${alert.type} - ${alert.message}`,
|
||||
type: 'warning',
|
||||
duration: 3000
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
ElMessage.success('检测完成')
|
||||
} else {
|
||||
ElMessage.error(response.message)
|
||||
@@ -320,6 +358,10 @@ const modelName = computed(() => {
|
||||
const model = props.models.find(m => m.id === config.value.model)
|
||||
return model ? model.name : config.value.model
|
||||
})
|
||||
|
||||
const onAlgorithmChange = (algoConfig) => {
|
||||
config.value.algorithmConfig = algoConfig
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -624,6 +666,78 @@ const modelName = computed(() => {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 告警卡片 */
|
||||
.alerts-card {
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid #f56c6c;
|
||||
}
|
||||
|
||||
.alerts-card .card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-count {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.alerts-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
background: #fef0f0;
|
||||
border-left: 4px solid #f56c6c;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.alert-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
font-size: 14px;
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-duration {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
background: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-bbox {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 768px) {
|
||||
.image-detection-container {
|
||||
|
||||
@@ -95,6 +95,13 @@
|
||||
/>
|
||||
<div class="slider-value">{{ config.iou.toFixed(2) }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 算法配置(仅对人员检测模型显示) -->
|
||||
<AlgorithmConfig
|
||||
v-model="config.algorithmConfig"
|
||||
@change="onAlgorithmChange"
|
||||
:model-id="config.model"
|
||||
/>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -186,6 +193,40 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 行为告警 -->
|
||||
<el-card v-if="alerts && alerts.length > 0" class="alerts-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-icon class="header-icon"><Warning /></el-icon>
|
||||
<span>行为告警</span>
|
||||
<el-tag size="small" type="danger" class="alert-count">{{ alerts.length }} 条</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="alerts-container">
|
||||
<div
|
||||
v-for="(alert, index) in alerts"
|
||||
:key="index"
|
||||
class="alert-item"
|
||||
>
|
||||
<div class="alert-header">
|
||||
<el-tag :type="alert.type === 'stationary' ? 'warning' : 'danger'" size="small">
|
||||
{{ alert.type === 'stationary' ? '静止' : '徘徊' }}
|
||||
</el-tag>
|
||||
<span class="alert-time">{{ new Date(alert.timestamp * 1000).toLocaleTimeString('zh-CN') }}</span>
|
||||
</div>
|
||||
<div class="alert-detail">
|
||||
<span class="alert-message">{{ alert.message }}</span>
|
||||
<span v-if="alert.duration" class="alert-duration">持续: {{ alert.duration.toFixed(1) }}s</span>
|
||||
</div>
|
||||
<div v-if="alert.bbox" class="alert-bbox">
|
||||
<code class="bbox-code">[{{ alert.bbox.join(', ') }}]</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 检测详情 -->
|
||||
<el-card v-if="detections.length > 0" class="details-card" shadow="hover">
|
||||
<template #header>
|
||||
@@ -273,6 +314,7 @@ import {
|
||||
Timer,
|
||||
Delete
|
||||
} from '@element-plus/icons-vue'
|
||||
import AlgorithmConfig from './AlgorithmConfig.vue'
|
||||
|
||||
const props = defineProps({
|
||||
models: {
|
||||
@@ -284,7 +326,8 @@ const props = defineProps({
|
||||
const config = ref({
|
||||
model: props.models.length > 0 ? props.models[0].id : 'fire_detection',
|
||||
confidence: 0.5,
|
||||
iou: 0.45
|
||||
iou: 0.45,
|
||||
algorithmConfig: {}
|
||||
})
|
||||
|
||||
// 可拖拽调整宽度相关
|
||||
@@ -321,6 +364,7 @@ const originalCameraFrame = ref('')
|
||||
const resultCameraFrame = ref('')
|
||||
const detections = ref([])
|
||||
const stats = ref(null)
|
||||
const alerts = ref([])
|
||||
const websocket = ref(null)
|
||||
|
||||
// 检测日志
|
||||
@@ -387,14 +431,21 @@ const startCamera = async () => {
|
||||
cameraConnected.value = true
|
||||
cameraStarting.value = false
|
||||
|
||||
websocket.value.send(JSON.stringify({
|
||||
const startConfig = {
|
||||
action: 'start',
|
||||
config: {
|
||||
model_id: config.value.model,
|
||||
confidence: config.value.confidence,
|
||||
iou: config.value.iou
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 添加算法配置
|
||||
if (config.value.algorithmConfig && Object.keys(config.value.algorithmConfig).length > 0) {
|
||||
startConfig.config.algorithm_config = config.value.algorithmConfig
|
||||
}
|
||||
|
||||
websocket.value.send(JSON.stringify(startConfig))
|
||||
}
|
||||
|
||||
websocket.value.onmessage = (event) => {
|
||||
@@ -450,14 +501,29 @@ const stopCamera = () => {
|
||||
|
||||
const updateCameraConfig = () => {
|
||||
if (websocket.value && cameraConnected.value) {
|
||||
websocket.value.send(JSON.stringify({
|
||||
const wsConfig = {
|
||||
action: 'update_config',
|
||||
config: {
|
||||
model_id: config.value.model,
|
||||
confidence: config.value.confidence,
|
||||
iou: config.value.iou
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// 添加算法配置
|
||||
if (config.value.algorithmConfig && Object.keys(config.value.algorithmConfig).length > 0) {
|
||||
wsConfig.config.algorithm_config = config.value.algorithmConfig
|
||||
}
|
||||
|
||||
websocket.value.send(JSON.stringify(wsConfig))
|
||||
}
|
||||
}
|
||||
|
||||
const onAlgorithmChange = (algoConfig) => {
|
||||
config.value.algorithmConfig = algoConfig
|
||||
// 如果摄像头已连接,实时更新配置
|
||||
if (websocket.value && cameraConnected.value) {
|
||||
updateCameraConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,6 +913,78 @@ onUnmounted(() => {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 告警卡片 */
|
||||
.alerts-card {
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid #f56c6c;
|
||||
}
|
||||
|
||||
.alerts-card .card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-count {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.alerts-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
background: #fef0f0;
|
||||
border-left: 4px solid #f56c6c;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.alert-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
font-size: 14px;
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-duration {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
background: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-bbox {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
background: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 768px) {
|
||||
.video-detection-container {
|
||||
|
||||
Reference in New Issue
Block a user