Files
jc-video-recognize/apps/server/services/paddle_detection_service.py
wwh e97bd503ec feat: 新增PaddlePaddle检测支持,重构项目架构
1. 新增concurrently依赖用于并行启动服务
2. 新增服务器启动脚本统一管理环境变量和虚拟环境
3. 新增PaddlePaddle推理引擎和配套工具代码
4. 新增抽烟检测Paddle模型支持,完善模型管理
5. 重构开发启动脚本,优化开发体验
6. 更新.gitignore排除不必要的外部目录和缓存
7. 完善文档说明,新增PaddlePaddle部署指南
2026-05-21 10:39:26 +08:00

404 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
PaddleDetection 抽烟检测服务适配器
使用本地 PaddlePaddle 环境直接调用模型(无需 Docker
"""
# 禁用 PIR API 以支持旧版模型格式(必须在任何导入之前设置)
import os
os.environ['FLAGS_enable_pir_api'] = '0'
import cv2
import numpy as np
import logging
import threading
import time
import sys
from typing import Dict, List, Optional
from pathlib import Path
logger = logging.getLogger(__name__)
class PaddleDetectionService:
"""PaddleDetection 服务适配器(本地模式)"""
def __init__(self):
self.model_name = "smoking_detection"
self.threshold = 0.1
self._lock = threading.Lock()
# 本地环境配置
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
self.paddle_dir = os.path.join(project_root, "third-party", "paddle-inference")
self.model_dir = os.path.join(project_root, "models", "smoking_detection_paddle")
# 检测器实例(延迟加载)
self._detector = None
self._detector_initialized = False
self.available = True
logger.info(f"本地 PaddlePaddle 模式已启用")
logger.info(f"模型目录: {self.model_dir}")
logger.info(f"使用服务器虚拟环境中的 PaddlePaddle")
logger.info(f"PaddlePaddle 目录: {self.paddle_dir}")
# 禁用 PIR API 以支持旧版模型格式(必须在初始化前设置)
os.environ['FLAGS_enable_pir_api'] = '0'
# 检测系统架构
import platform
self.platform_info = platform.uname()
self.is_apple_silicon = self.platform_info.machine in ('arm64', 'aarch64') and self.platform_info.system == 'Darwin'
if self.is_apple_silicon:
logger.info("✅ 检测到 Apple Silicon (ARM64) 架构")
logger.info("✅ 使用本地 PaddlePaddle 环境获得最佳性能")
logger.info("✅ 相比 Docker 方式性能提升 5-10 倍")
try:
self._initialize_environment()
except Exception as e:
logger.error(f"初始化环境失败: {e}")
self.available = False
def _initialize_environment(self):
"""初始化本地 PaddlePaddle 环境"""
try:
# 添加 PaddleDetection 部署路径
paddle_detection_path = self.paddle_dir
if paddle_detection_path not in sys.path:
sys.path.insert(0, paddle_detection_path)
logger.info(f"✅ 添加 PaddleDetection 路径: {paddle_detection_path}")
# 检查模型目录是否存在
if not os.path.exists(self.model_dir):
raise Exception(f"模型目录不存在: {self.model_dir}")
# 检查必要文件
required_files = ['model.pdmodel', 'model.pdiparams', 'infer_cfg.yml']
for file in required_files:
file_path = os.path.join(self.model_dir, file)
if not os.path.exists(file_path):
raise Exception(f"模型文件不存在: {file}")
logger.info("✅ 环境检查通过")
# 预加载检测器(可选,用于首次检测预热)
try:
self._get_detector()
logger.info("✅ 检测器预加载成功")
except Exception as e:
logger.warning(f"检测器预加载失败,将在首次使用时初始化: {e}")
except Exception as e:
logger.error(f"环境初始化失败: {e}")
raise
def _get_detector(self):
"""获取检测器实例(单例模式)"""
if self._detector is None or not self._detector_initialized:
try:
# 设置环境变量以支持旧版模型格式
os.environ['FLAGS_enable_pir_api'] = '0'
# 添加 PaddleDetection 路径(直接使用 self.paddle_dir
if self.paddle_dir not in sys.path:
sys.path.insert(0, self.paddle_dir)
logger.info(f"添加 PaddleDetection 路径: {self.paddle_dir}")
# 导入 PaddleDetection 模块
from infer import Detector, PredictConfig
# 创建检测器
self._detector = Detector(
model_dir=self.model_dir,
device='CPU',
run_mode='paddle',
batch_size=1,
output_dir='output',
threshold=self.threshold
)
self._detector_initialized = True
logger.info("✅ PaddlePaddle 检测器初始化成功")
except Exception as e:
logger.error(f"检测器初始化失败: {e}")
raise
return self._detector
def detect_image(self, image: np.ndarray, threshold: float = None) -> Dict:
"""
检测图片中的抽烟行为(本地模式)
Args:
image: OpenCV 图片 (BGR格式)
threshold: 置信度阈值,如果为 None 则使用默认值
Returns:
检测结果字典
"""
if threshold is None:
threshold = self.threshold
if not self.available:
return {
'success': False,
'message': 'PaddleDetection 服务不可用',
'detections': [],
'stats': None
}
try:
with self._lock:
start_time = time.time()
# 确保检测器已初始化
detector = self._get_detector()
# 准备输入图片
if not isinstance(image, np.ndarray):
raise Exception(f"不支持的图片类型: {type(image)}")
if len(image.shape) == 2: # 灰度图转 BGR
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
elif image.shape[2] == 4: # RGBA 转 BGR
image = cv2.cvtColor(image, cv2.COLOR_RGBA2BGR)
# 执行推理
inference_start = time.time()
# 使用 PaddleDetection API 进行推理
results = detector.predict_image(
[image],
visual=False,
save_results=False
)
inference_time = time.time() - inference_start
logger.info(f"推理耗时: {inference_time:.3f}s")
# 解析检测结果
detections = self._parse_detection_results(results, threshold)
total_time = time.time() - start_time
logger.info(f"检测总耗时: {total_time:.3f}s")
return {
'success': True,
'message': '检测完成',
'detections': detections,
'stats': {
'total_detections': len(detections),
'model_used': 'ppyoloe_crn_s_80e_smoking_visdrone',
'threshold': threshold,
'processing_time': round(total_time, 3),
'inference_time': round(inference_time, 3)
}
}
except Exception as e:
import traceback
logger.error(f"检测失败: {e}")
logger.error(f"错误堆栈: {traceback.format_exc()}")
# 重置检测器状态以允许重试
self._detector_initialized = False
return {
'success': False,
'message': f'检测失败: {e}',
'detections': [],
'stats': None
}
def _parse_detection_results(self, results: Dict, threshold: float) -> List[Dict]:
"""解析 PaddleDetection 返回的检测结果"""
detections = []
try:
if results and 'boxes' in results:
boxes = results['boxes']
if boxes is not None and len(boxes) > 0:
for box in boxes:
# 解析检测结果格式: [class_id, score, x1, y1, x2, y2]
if len(box) >= 6:
class_id = int(box[0])
confidence = float(box[1])
x1, y1, x2, y2 = float(box[2]), float(box[3]), float(box[4]), float(box[5])
# 过滤低置信度检测
if confidence >= threshold:
detections.append({
'class': 'cigarette',
'label': '香烟',
'confidence': round(confidence, 3),
'bbox': [int(x1), int(y1), int(x2), int(y2)]
})
except Exception as e:
logger.error(f"解析检测结果失败: {e}")
import traceback
logger.error(traceback.format_exc())
return detections
def get_performance_info(self) -> Dict:
"""获取性能信息"""
return {
'mode': 'local',
'environment': 'PaddlePaddle',
'model_dir': self.model_dir,
'apple_silicon': self.is_apple_silicon,
'detector_loaded': self._detector_initialized,
'available': self.available
}
# 兼容性包装,保持与 YOLO 模型相同的接口
class SmokingDetectionModel:
"""抽烟检测模型包装器,兼容 YOLO 接口"""
def __init__(self):
self.service = PaddleDetectionService()
self.names = {0: 'cigarette'}
def __call__(self, image, conf=0.1, iou=0.45, verbose=False):
"""
模拟 YOLO 模型的调用接口
Args:
image: OpenCV 图片
conf: 置信度阈值
iou: IoU 阈值
verbose: 是否输出详细信息
Returns:
模拟 YOLO 结果的对象
"""
result = self.service.detect_image(image, threshold=conf)
return [PaddleDetectionResult(result, self.names)]
class PaddleDetectionResult:
"""模拟 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:
xyxy.append(det['bbox'])
conf.append(det['confidence'])
cls.append(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