diff --git a/apps/server/main.py b/apps/server/main.py
index ee6bd46..574fd1d 100644
--- a/apps/server/main.py
+++ b/apps/server/main.py
@@ -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")
diff --git a/apps/server/services/action_detection_service.py b/apps/server/services/action_detection_service.py
new file mode 100644
index 0000000..424811f
--- /dev/null
+++ b/apps/server/services/action_detection_service.py
@@ -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
diff --git a/apps/server/services/model_service.py b/apps/server/services/model_service.py
index cc05f34..2189f9e 100644
--- a/apps/server/services/model_service.py
+++ b/apps/server/services/model_service.py
@@ -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:
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index c11ce23..1815d99 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -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",
diff --git a/apps/web/src/api/detection.js b/apps/web/src/api/detection.js
index b935bfb..7653011 100644
--- a/apps/web/src/api/detection.js
+++ b/apps/web/src/api/detection.js
@@ -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'
+ }
+ })
}
}
diff --git a/apps/web/src/components/ImageDetection.vue b/apps/web/src/components/ImageDetection.vue
index 2338c62..3bab4c3 100644
--- a/apps/web/src/components/ImageDetection.vue
+++ b/apps/web/src/components/ImageDetection.vue
@@ -36,25 +36,51 @@
正在进行打架检测...
+请稍候,视频检测可能需要几秒钟
+请上传视频进行检测
+支持 MP4、AVI 等格式
+请上传图片进行检测
@@ -97,8 +123,21 @@ + +{{ formattedJson }}
+