打架斗殴模型集成
This commit is contained in:
@@ -87,6 +87,14 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
docker_output_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"external", "video-recognition-system", "PaddlePaddle", "PaddleDetection", "output"
|
||||
)
|
||||
os.makedirs(docker_output_dir, exist_ok=True)
|
||||
app.mount("/docker-output", StaticFiles(directory=docker_output_dir), name="docker-output")
|
||||
|
||||
app.include_router(detection.router, prefix="/api")
|
||||
app.include_router(models.router, prefix="/api")
|
||||
|
||||
|
||||
265
apps/server/services/action_detection_service.py
Normal file
265
apps/server/services/action_detection_service.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
PaddleVideo 行为识别服务适配器(Docker API 调用方式)
|
||||
通过 HTTP 请求调用运行在 Docker 中的 ppTSM 行为识别模型
|
||||
"""
|
||||
|
||||
import os
|
||||
import cv2
|
||||
import numpy as np
|
||||
import logging
|
||||
import httpx
|
||||
import base64
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 获取外部项目路径
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
EXTERNAL_PADDLE_PATH = os.path.join(BASE_DIR, 'external', 'video-recognition-system', 'PaddlePaddle')
|
||||
|
||||
|
||||
class ActionDetectionService:
|
||||
"""行为识别服务(调用外部 Docker 服务)"""
|
||||
|
||||
def __init__(self):
|
||||
# 从环境变量获取 Docker 服务地址
|
||||
self.api_base_url = os.environ.get(
|
||||
'ACTION_DETECTION_API_URL',
|
||||
'http://localhost:8081' # 统一使用 8081 端口
|
||||
)
|
||||
self.timeout = int(os.environ.get('ACTION_DETECTION_TIMEOUT', '30'))
|
||||
|
||||
# 类别定义(根据你的 ppTSM 模型)
|
||||
self.classes = ['fight', 'normal']
|
||||
self.labels = {0: 'fight', 1: 'normal'}
|
||||
|
||||
# 外部项目路径
|
||||
self.external_paddle_path = EXTERNAL_PADDLE_PATH
|
||||
self.model_path = os.path.join(self.external_paddle_path, 'PaddleVideo', 'inference', 'ppTSM')
|
||||
|
||||
# 健康检查
|
||||
self.available = self._check_service_health()
|
||||
|
||||
logger.info(f"行为识别服务初始化完成")
|
||||
logger.info(f"API地址: {self.api_base_url}")
|
||||
logger.info(f"外部项目路径: {self.external_paddle_path}")
|
||||
logger.info(f"服务可用: {self.available}")
|
||||
|
||||
def _check_service_health(self) -> bool:
|
||||
"""检查 Docker 服务是否可用"""
|
||||
try:
|
||||
with httpx.Client(timeout=5) as client:
|
||||
response = client.get(f"{self.api_base_url}/health")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning(f"服务健康检查失败: {e}")
|
||||
return False
|
||||
|
||||
def detect_image(self, image: np.ndarray, threshold: float = 0.5) -> Dict:
|
||||
"""
|
||||
调用 Docker 服务进行行为识别
|
||||
|
||||
Args:
|
||||
image: OpenCV 图片 (BGR格式)
|
||||
threshold: 置信度阈值
|
||||
|
||||
Returns:
|
||||
检测结果字典
|
||||
"""
|
||||
if not self.available:
|
||||
return {
|
||||
'success': False,
|
||||
'message': '行为识别服务不可用',
|
||||
'detections': [],
|
||||
'stats': None
|
||||
}
|
||||
|
||||
try:
|
||||
# 将图片转换为 base64
|
||||
_, img_encoded = cv2.imencode('.jpg', image)
|
||||
img_base64 = base64.b64encode(img_encoded).decode('utf-8')
|
||||
|
||||
# 构建请求数据
|
||||
payload = {
|
||||
'image': img_base64,
|
||||
'threshold': threshold
|
||||
}
|
||||
|
||||
# 调用 Docker 服务
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
response = client.post(
|
||||
f"{self.api_base_url}/api/detect",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
'success': False,
|
||||
'message': f"API调用失败: {response.status_code}",
|
||||
'detections': [],
|
||||
'stats': None
|
||||
}
|
||||
|
||||
# 解析响应
|
||||
result = response.json()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '检测完成',
|
||||
'detections': result.get('detections', []),
|
||||
'stats': result.get('stats', None)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检测失败: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'message': f'检测失败: {e}',
|
||||
'detections': [],
|
||||
'stats': None
|
||||
}
|
||||
|
||||
|
||||
class ActionDetectionModel:
|
||||
"""行为识别模型包装器,兼容 YOLO 接口"""
|
||||
|
||||
def __init__(self):
|
||||
self.service = ActionDetectionService()
|
||||
self.names = {0: 'fight', 1: 'normal'}
|
||||
|
||||
def __call__(self, image, conf=0.5, iou=0.45, verbose=False):
|
||||
"""
|
||||
模拟 YOLO 模型的调用接口
|
||||
|
||||
Args:
|
||||
image: OpenCV 图片
|
||||
conf: 置信度阈值
|
||||
iou: IoU 阈值
|
||||
verbose: 是否输出详细信息
|
||||
|
||||
Returns:
|
||||
模拟 YOLO 结果的对象
|
||||
"""
|
||||
result = self.service.detect_image(image, threshold=conf)
|
||||
return [ActionDetectionResult(result, self.names)]
|
||||
|
||||
|
||||
class ActionDetectionResult:
|
||||
"""模拟 YOLO 检测结果对象"""
|
||||
|
||||
def __init__(self, detection_result: Dict, names: Dict):
|
||||
self.detection_result = detection_result
|
||||
self.names = names
|
||||
self.boxes = self._create_boxes()
|
||||
|
||||
def _create_boxes(self):
|
||||
"""创建模拟的 boxes 对象"""
|
||||
detections = self.detection_result.get('detections', [])
|
||||
|
||||
if not detections:
|
||||
return MockBoxes([])
|
||||
|
||||
xyxy = []
|
||||
conf = []
|
||||
cls = []
|
||||
|
||||
for det in detections:
|
||||
if 'bbox' in det:
|
||||
xyxy.append(det['bbox'])
|
||||
conf.append(det.get('confidence', 0.0))
|
||||
cls.append(det.get('class_id', 0))
|
||||
|
||||
return MockBoxes(xyxy, conf, cls)
|
||||
|
||||
|
||||
class MockBoxes:
|
||||
"""模拟 YOLO boxes 对象"""
|
||||
|
||||
def __init__(self, xyxy_list, conf_list=None, cls_list=None):
|
||||
try:
|
||||
import torch
|
||||
use_torch = True
|
||||
except ImportError:
|
||||
use_torch = False
|
||||
|
||||
if xyxy_list and len(xyxy_list) > 0:
|
||||
if use_torch:
|
||||
self.xyxy = torch.tensor(xyxy_list, dtype=torch.float32)
|
||||
self.conf = torch.tensor(conf_list, dtype=torch.float32).reshape(-1, 1)
|
||||
self.cls = torch.tensor(cls_list, dtype=torch.int64).reshape(-1, 1)
|
||||
else:
|
||||
self.xyxy = np.array(xyxy_list, dtype=np.float32)
|
||||
self.conf = np.array(conf_list, dtype=np.float32).reshape(-1, 1)
|
||||
self.cls = np.array(cls_list, dtype=np.int64).reshape(-1, 1)
|
||||
else:
|
||||
if use_torch:
|
||||
self.xyxy = torch.empty((0, 4), dtype=torch.float32)
|
||||
self.conf = torch.empty((0, 1), dtype=torch.float32)
|
||||
self.cls = torch.empty((0, 1), dtype=torch.int64)
|
||||
else:
|
||||
self.xyxy = np.array([]).reshape(0, 4)
|
||||
self.conf = np.array([]).reshape(0, 1)
|
||||
self.cls = np.array([]).reshape(0, 1)
|
||||
|
||||
self._use_torch = use_torch
|
||||
|
||||
def __iter__(self):
|
||||
for i in range(len(self.xyxy)):
|
||||
yield MockBox(
|
||||
self.xyxy[i],
|
||||
self.conf[i][0] if len(self.conf) > i else 0.0,
|
||||
self.cls[i][0] if len(self.cls) > i else 0
|
||||
)
|
||||
|
||||
def __len__(self):
|
||||
return len(self.xyxy)
|
||||
|
||||
def cpu(self):
|
||||
return self
|
||||
|
||||
def numpy(self):
|
||||
if self._use_torch:
|
||||
if len(self.xyxy) > 0:
|
||||
return (
|
||||
self.xyxy.numpy(),
|
||||
self.conf.numpy(),
|
||||
self.cls.numpy()
|
||||
)
|
||||
else:
|
||||
return (
|
||||
np.array([]).reshape(0, 4),
|
||||
np.array([]).reshape(0, 1),
|
||||
np.array([], dtype=np.int64).reshape(0, 1)
|
||||
)
|
||||
else:
|
||||
return (
|
||||
self.xyxy,
|
||||
self.conf,
|
||||
self.cls
|
||||
)
|
||||
|
||||
|
||||
class MockBox:
|
||||
"""模拟单个 YOLO box 对象"""
|
||||
|
||||
def __init__(self, xyxy, conf, cls):
|
||||
try:
|
||||
import torch
|
||||
use_torch = True
|
||||
except ImportError:
|
||||
use_torch = False
|
||||
|
||||
if use_torch:
|
||||
if isinstance(xyxy, torch.Tensor):
|
||||
self.xyxy = xyxy
|
||||
else:
|
||||
self.xyxy = torch.tensor(xyxy, dtype=torch.float32)
|
||||
else:
|
||||
if isinstance(xyxy, np.ndarray):
|
||||
self.xyxy = xyxy
|
||||
else:
|
||||
self.xyxy = np.array(xyxy, dtype=np.float32)
|
||||
|
||||
self.conf = conf
|
||||
self.cls = cls
|
||||
@@ -109,6 +109,15 @@ class ModelService:
|
||||
'size': '200MB',
|
||||
'description': '基于PaddlePaddle PP-YOLOE-l的违停检测模型,支持车牌识别',
|
||||
'name': '违停检测 (Paddle)'
|
||||
},
|
||||
'action_detection': {
|
||||
'path': 'docker_api',
|
||||
'type': 'docker_api',
|
||||
'classes': ['fight', 'normal'],
|
||||
'labels': {'fight': '打架', 'normal': '正常'},
|
||||
'size': 'Docker',
|
||||
'description': '基于PaddleVideo ppTSM的打架检测模型(通过Docker API调用)',
|
||||
'name': '打架检测 (Docker)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +126,12 @@ class ModelService:
|
||||
for model_id, config in self.model_configs.items():
|
||||
model_path = config['path']
|
||||
|
||||
# 检查模型是否存在(Paddle模型检查目录,YOLO模型检查文件)
|
||||
# 检查模型是否存在
|
||||
model_exists = False
|
||||
if config['type'] == 'paddle':
|
||||
if config['type'] == 'docker_api':
|
||||
# Docker API 类型的模型不需要检查文件,总是可用
|
||||
model_exists = True
|
||||
elif config['type'] == 'paddle':
|
||||
model_dir = os.path.dirname(model_path)
|
||||
required_files = ['model.pdmodel', 'model.pdiparams', 'infer_cfg.yml']
|
||||
model_exists = all(
|
||||
@@ -153,6 +165,24 @@ class ModelService:
|
||||
|
||||
config = self.model_configs[model_id]
|
||||
|
||||
# 处理 Docker API 模型
|
||||
if config['type'] == 'docker_api':
|
||||
try:
|
||||
if model_id == 'action_detection':
|
||||
from .action_detection_service import ActionDetectionModel
|
||||
logger.info(f"正在加载 Docker API 行为识别服务: {model_id}")
|
||||
model = ActionDetectionModel()
|
||||
else:
|
||||
logger.error(f"未知的 Docker API 模型类型: {model_id}")
|
||||
return None
|
||||
|
||||
self.models[model_id] = model
|
||||
logger.info(f"Docker API 服务加载成功: {model_id}")
|
||||
return model
|
||||
except Exception as e:
|
||||
logger.error(f"Docker API 服务加载失败: {model_id}, 错误: {e}")
|
||||
return None
|
||||
|
||||
# 处理 PaddleDetection 模型
|
||||
if config['type'] == 'paddle':
|
||||
try:
|
||||
|
||||
48
apps/web/package-lock.json
generated
48
apps/web/package-lock.json
generated
@@ -609,9 +609,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -626,9 +623,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -643,9 +637,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -660,9 +651,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -677,9 +665,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -694,9 +679,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -711,9 +693,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -728,9 +707,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -745,9 +721,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -762,9 +735,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -779,9 +749,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -796,9 +763,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -813,9 +777,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -924,6 +885,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
@@ -1498,13 +1460,15 @@
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -1717,6 +1681,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -1776,6 +1741,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
|
||||
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.34",
|
||||
"@vue/compiler-sfc": "3.5.34",
|
||||
|
||||
@@ -5,6 +5,11 @@ const api = axios.create({
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
const dockerApi = axios.create({
|
||||
baseURL: '/docker-api',
|
||||
timeout: 120000
|
||||
})
|
||||
|
||||
export const detectionApi = {
|
||||
getModels() {
|
||||
return api.get('/models')
|
||||
@@ -26,5 +31,13 @@ export const detectionApi = {
|
||||
},
|
||||
params
|
||||
})
|
||||
},
|
||||
|
||||
detectVideo(formData) {
|
||||
return dockerApi.post('/api/fight_detect', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,25 +36,51 @@
|
||||
<el-upload
|
||||
:action="uploadUrl"
|
||||
:on-success="handleUploadSuccess"
|
||||
:on-error="handleUploadError"
|
||||
:before-upload="beforeUpload"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:accept="isActionDetection ? 'video/*' : 'image/*'"
|
||||
:disabled="isDetecting"
|
||||
class="header-upload"
|
||||
>
|
||||
<el-button type="primary" size="small">
|
||||
<el-button type="primary" size="small" :loading="isDetecting">
|
||||
<el-icon><UploadFilled /></el-icon>
|
||||
<span>{{ resultImage ? '上传新图片' : '上传图片' }}</span>
|
||||
<span>{{ resultImage || resultVideo ? '上传新文件' : (isActionDetection ? '上传视频' : '上传图片') }}</span>
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
<div class="image-container">
|
||||
<!-- 图片显示 -->
|
||||
<img
|
||||
v-if="resultImage"
|
||||
v-if="resultImage && !isActionDetection"
|
||||
:src="resultImage"
|
||||
class="display-image"
|
||||
alt="检测结果"
|
||||
/>
|
||||
<!-- 视频显示 -->
|
||||
<div v-else-if="isActionDetection" class="video-result-container">
|
||||
<video
|
||||
v-if="resultVideo"
|
||||
:src="resultVideo"
|
||||
class="display-video"
|
||||
controls
|
||||
alt="检测结果视频"
|
||||
/>
|
||||
<div v-else-if="isDetecting" class="detection-loading">
|
||||
<div class="loading-spinner">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
</div>
|
||||
<p class="loading-text">正在进行打架检测...</p>
|
||||
<p class="loading-hint">请稍候,视频检测可能需要几秒钟</p>
|
||||
</div>
|
||||
<div v-else class="empty-placeholder">
|
||||
<el-icon class="empty-icon"><VideoCamera /></el-icon>
|
||||
<p class="empty-text">请上传视频进行检测</p>
|
||||
<p class="empty-hint">支持 MP4、AVI 等格式</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 默认空状态 -->
|
||||
<div v-else class="empty-placeholder">
|
||||
<el-icon class="empty-icon"><Picture /></el-icon>
|
||||
<p class="empty-text">请上传图片进行检测</p>
|
||||
@@ -97,8 +123,21 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Docker 检测结果 JSON 显示 -->
|
||||
<el-card v-if="dockerResult && isActionDetection" class="json-result-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Files /></el-icon>
|
||||
<span>检测结果 (JSON)</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="json-content">
|
||||
<pre class="json-pre">{{ formattedJson }}</pre>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 检测详情 -->
|
||||
<el-card v-if="detections.length > 0" class="details-card" shadow="hover">
|
||||
<el-card v-if="detections.length > 0 && !isActionDetection" class="details-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><List /></el-icon>
|
||||
@@ -121,6 +160,40 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 打架检测结果卡片 -->
|
||||
<el-card v-if="actionResult && isActionDetection" class="action-result-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Warning /></el-icon>
|
||||
<span>打架检测结果</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="action-result-content">
|
||||
<div class="result-item">
|
||||
<span class="result-label">视频名称</span>
|
||||
<span class="result-value">{{ actionResult.video_name }}</span>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span class="result-label">检测结果</span>
|
||||
<el-tag :type="actionResult.result === 'fight' ? 'danger' : 'success'" size="large">
|
||||
{{ actionResult.result_text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span class="result-label">置信度</span>
|
||||
<el-tag :type="getConfidenceType(actionResult.confidence / 100)" size="large">
|
||||
{{ actionResult.confidence_text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="result-item">
|
||||
<span class="result-label">置信度阈值</span>
|
||||
<el-tag type="info" size="large">
|
||||
{{ actionResult.confidence_threshold?.toFixed(2) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -131,9 +204,13 @@ import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
UploadFilled,
|
||||
Picture,
|
||||
VideoCamera,
|
||||
View,
|
||||
DataLine,
|
||||
List
|
||||
List,
|
||||
Files,
|
||||
Warning,
|
||||
Loading
|
||||
} from '@element-plus/icons-vue'
|
||||
import { detectionApi } from '@/api/detection'
|
||||
import DetectionConfig from './DetectionConfig.vue'
|
||||
@@ -192,16 +269,29 @@ const stopResize = () => {
|
||||
|
||||
const originalImage = ref('')
|
||||
const resultImage = ref('')
|
||||
const uploadedVideoUrl = ref('')
|
||||
const resultVideo = ref('')
|
||||
const detections = ref([])
|
||||
const stats = ref(null)
|
||||
const alerts = ref([])
|
||||
const isDetecting = ref(false)
|
||||
const dockerResult = ref(null)
|
||||
const actionResult = ref(null)
|
||||
|
||||
const isActionDetection = computed(() => {
|
||||
return config.value.model === 'action_detection'
|
||||
})
|
||||
|
||||
const uploadUrl = computed(() => {
|
||||
if (isActionDetection.value) {
|
||||
return null
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -209,35 +299,61 @@ const uploadUrl = computed(() => {
|
||||
return `/api/detect/image?${params.toString()}`
|
||||
})
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const formattedJson = computed(() => {
|
||||
if (!dockerResult.value) return ''
|
||||
return JSON.stringify(dockerResult.value, null, 2)
|
||||
})
|
||||
|
||||
const beforeUpload = async (file) => {
|
||||
isDetecting.value = true
|
||||
|
||||
if (isActionDetection.value) {
|
||||
const isVideo = file.type.startsWith('video/')
|
||||
if (!isVideo) {
|
||||
ElMessage.error('只能上传视频文件')
|
||||
isDetecting.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('video', file)
|
||||
formData.append('confidence', config.value.confidence)
|
||||
|
||||
try {
|
||||
const response = await detectionApi.detectVideo(formData)
|
||||
handleActionDetectionSuccess(response.data)
|
||||
} catch (error) {
|
||||
handleUploadError(error)
|
||||
}
|
||||
|
||||
return false
|
||||
} else {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件')
|
||||
isDetecting.value = false
|
||||
return false
|
||||
}
|
||||
originalImage.value = URL.createObjectURL(file)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadSuccess = (response) => {
|
||||
isDetecting.value = false
|
||||
console.log('Upload success response:', response)
|
||||
|
||||
if (response.success) {
|
||||
// 使用 base64 图片数据
|
||||
if (response.data.image_base64) {
|
||||
resultImage.value = `data:image/jpeg;base64,${response.data.image_base64}`
|
||||
console.log('Result image set, length:', response.data.image_base64.length)
|
||||
} else {
|
||||
console.error('No image_base64 in response:', response.data)
|
||||
}
|
||||
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}`,
|
||||
@@ -253,6 +369,38 @@ const handleUploadSuccess = (response) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionDetectionSuccess = (response) => {
|
||||
isDetecting.value = false
|
||||
console.log('Action detection response:', response)
|
||||
|
||||
if (response.code === 200) {
|
||||
dockerResult.value = response
|
||||
actionResult.value = response.data
|
||||
|
||||
if (response.data.output_video) {
|
||||
const videoPath = response.data.output_video.replace(/^output\//, '')
|
||||
resultVideo.value = `/docker-output/${videoPath}`
|
||||
}
|
||||
|
||||
ElMessage.success(response.message)
|
||||
} else {
|
||||
ElMessage.error(response.message || '检测失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadError = (error) => {
|
||||
isDetecting.value = false
|
||||
console.error('Upload error:', error)
|
||||
|
||||
if (error.response) {
|
||||
ElMessage.error(`上传失败: ${error.response.data.message || error.response.status}`)
|
||||
} else if (error.request) {
|
||||
ElMessage.error('请求超时或服务器未响应,请检查Docker服务是否正常运行')
|
||||
} else {
|
||||
ElMessage.error(`上传失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const getConfidenceType = (confidence) => {
|
||||
if (!confidence && confidence !== 0) return 'info'
|
||||
if (confidence >= 0.8) return 'success'
|
||||
@@ -285,7 +433,6 @@ const modelName = computed(() => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 拖拽调整条 */
|
||||
.resize-handle {
|
||||
width: 8px;
|
||||
flex-shrink: 0;
|
||||
@@ -326,7 +473,6 @@ const modelName = computed(() => {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 卡片通用样式 */
|
||||
:deep(.el-card) {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -348,7 +494,6 @@ const modelName = computed(() => {
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
/* 卡片头部样式 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -374,7 +519,6 @@ const modelName = computed(() => {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 配置卡片样式 */
|
||||
.config-card {
|
||||
height: auto;
|
||||
max-height: calc(100vh - 100px);
|
||||
@@ -401,14 +545,12 @@ const modelName = computed(() => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 模型选择器 */
|
||||
.model-size {
|
||||
float: right;
|
||||
color: #64748B;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 滑块样式 */
|
||||
.slider-value {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
@@ -424,7 +566,6 @@ const modelName = computed(() => {
|
||||
font-family: 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* 空状态占位区域 */
|
||||
.empty-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -444,8 +585,53 @@ const modelName = computed(() => {
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
.detection-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
font-size: 48px;
|
||||
color: #22C55E;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: #F8FAFC;
|
||||
font-weight: 500;
|
||||
margin: 0 0 8px 0;
|
||||
font-family: 'Fira Sans', sans-serif;
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
font-size: 13px;
|
||||
color: #64748B;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
@@ -454,7 +640,6 @@ const modelName = computed(() => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 图片展示区域 */
|
||||
.image-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -488,6 +673,21 @@ const modelName = computed(() => {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-result-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.display-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
color: #64748B;
|
||||
@@ -505,7 +705,6 @@ const modelName = computed(() => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-card {
|
||||
margin-bottom: 20px;
|
||||
height: calc(100% - 20px);
|
||||
@@ -553,7 +752,6 @@ const modelName = computed(() => {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 详情卡片 */
|
||||
.details-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -627,7 +825,6 @@ const modelName = computed(() => {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 帮助图标样式 */
|
||||
.help-icon {
|
||||
margin-left: 6px;
|
||||
font-size: 14px;
|
||||
@@ -640,7 +837,6 @@ const modelName = computed(() => {
|
||||
color: #22C55E;
|
||||
}
|
||||
|
||||
/* 告警卡片 */
|
||||
.alerts-card {
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid #EF4444;
|
||||
@@ -722,7 +918,70 @@ const modelName = computed(() => {
|
||||
font-family: 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
.json-result-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.json-result-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.json-content {
|
||||
background: #0F172A;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.json-pre {
|
||||
color: #22C55E;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.action-result-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.action-result-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.action-result-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 13px;
|
||||
color: #94A3B8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 16px;
|
||||
color: #F8FAFC;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.image-detection-container {
|
||||
flex-direction: column;
|
||||
@@ -751,14 +1010,8 @@ const modelName = computed(() => {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.stats-descriptions :deep(.el-descriptions__body) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats-descriptions :deep(.el-descriptions__cell) {
|
||||
padding: 12px;
|
||||
.action-result-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,19 +10,29 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/static': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true
|
||||
},
|
||||
'/docker-api': {
|
||||
target: 'http://localhost:8081',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/docker-api/, '')
|
||||
},
|
||||
'/docker-output': {
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user