Initial commit: Video detection platform with YOLO models

Features:
- Fire detection (YOLOv10)
- Helmet detection (YOLOv8)
- Crowd detection (YOLOv8)
- Smoking detection (YOLOv8)
- Loitering detection (YOLOv8)

Tech Stack:
- Frontend: Vue 3 + Vite + Element Plus
- Backend: FastAPI + WebSocket
- Monorepo: pnpm workspace + Turbo
- Docker support included
This commit is contained in:
wwh
2026-05-18 10:54:10 +08:00
commit 8fb58c75fe
42 changed files with 6663 additions and 0 deletions

View File

View File

@@ -0,0 +1,351 @@
import cv2
import json
import logging
import asyncio
import os
import signal
import subprocess
import platform
from fastapi import WebSocket, WebSocketDisconnect
from typing import Dict, Optional
import numpy as np
logger = logging.getLogger(__name__)
class CameraService:
def __init__(self, model_service):
self.model_service = model_service
self.active_connections: Dict[str, WebSocket] = {}
self.camera_captures: Dict[str, cv2.VideoCapture] = {}
self.running = False
self.camera_configs: Dict[str, Dict] = {}
self._locks: Dict[str, asyncio.Lock] = {}
self._stop_events: Dict[str, asyncio.Event] = {}
@staticmethod
def force_release_cameras():
"""强制释放被占用的摄像头资源
在以下场景调用:
1. 服务启动前 - 清理之前异常退出残留的占用
2. 服务关闭时 - 确保资源被释放
3. 信号处理时 - 异常退出前的清理
"""
logger.info("强制释放摄像头资源...")
# 1. 尝试释放当前进程中的摄像头
try:
# 在macOS上摄像头设备通常是 /dev/video* 或 AVFoundation 设备
# 尝试打开并立即释放来清理状态
for i in range(10): # 检查前10个可能的摄像头索引
try:
cap = cv2.VideoCapture(i)
if cap.isOpened():
cap.release()
logger.info(f"已释放摄像头索引 {i}")
except Exception:
pass
except Exception as e:
logger.error(f"释放当前进程摄像头失败: {e}")
# 2. 终止占用摄像头的其他Python进程
current_pid = os.getpid()
system = platform.system()
try:
if system == "Darwin": # macOS
# 使用 lsof 查找占用摄像头的进程
result = subprocess.run(
['lsof', '-c', 'python'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
pids_to_kill = set()
for line in result.stdout.split('\n'):
# 查找包含摄像头设备 (/dev/video* 或 V4L 相关) 的行
if any(x in line for x in ['/dev/video', 'Camera', 'V4L']):
parts = line.split()
if len(parts) >= 2:
try:
pid = int(parts[1])
if pid != current_pid:
pids_to_kill.add(pid)
except (ValueError, IndexError):
pass
# 终止找到的进程
for pid in pids_to_kill:
try:
logger.info(f"终止占用摄像头的进程: {pid}")
os.kill(pid, signal.SIGTERM)
# 给进程一点时间优雅退出
import time
time.sleep(0.5)
# 检查是否还在运行,如果是则强制终止
try:
os.kill(pid, 0) # 检查进程是否存在
logger.warning(f"进程 {pid} 未响应 SIGTERM使用 SIGKILL")
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass # 进程已退出
except ProcessLookupError:
pass # 进程已不存在
except Exception as e:
logger.error(f"终止进程 {pid} 失败: {e}")
elif system == "Linux":
# Linux 系统使用 fuser 或 lsof
for device in ['/dev/video0', '/dev/video1', '/dev/video2']:
try:
result = subprocess.run(
['fuser', '-k', device],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
logger.info(f"已释放设备 {device}")
except Exception:
pass
except Exception as e:
logger.error(f"终止占用进程失败: {e}")
# 3. 给系统一点时间完成资源释放
import time
time.sleep(0.5)
logger.info("摄像头资源释放完成")
async def handle_connection(self, websocket: WebSocket):
await websocket.accept()
connection_id = id(websocket)
self.active_connections[connection_id] = websocket
logger.info(f"新连接: {connection_id}")
try:
while True:
try:
data = await websocket.receive_text()
message = json.loads(data)
if message.get('action') == 'start':
await self.start_camera(connection_id, websocket, message.get('config'))
elif message.get('action') == 'stop':
await self.stop_camera(connection_id)
elif message.get('action') == 'update_config':
await self.update_config(connection_id, message.get('config', {}))
except WebSocketDisconnect:
logger.info(f"WebSocket连接断开: {connection_id}")
break
except WebSocketDisconnect:
logger.info(f"连接断开: {connection_id}")
except Exception as e:
logger.error(f"连接错误: {connection_id}, {e}")
finally:
await self.stop_camera(connection_id)
if connection_id in self.active_connections:
del self.active_connections[connection_id]
logger.info(f"连接清理完成: {connection_id}")
async def start_camera(self, connection_id: str, websocket: WebSocket, config: Dict = None):
try:
camera_source = 0 # 默认本地摄像头
if not config:
config = {
'model_id': 'fire_detection',
'confidence': 0.5,
'iou': 0.45,
'camera_source': 0 # 默认本地摄像头
}
# 支持多种摄像头源
if 'camera_source' in config:
camera_source = config['camera_source']
# 如果该连接已有摄像头在运行,先停止它
if connection_id in self.camera_captures:
await self.stop_camera(connection_id)
# 初始化锁和停止事件
self._locks[connection_id] = asyncio.Lock()
self._stop_events[connection_id] = asyncio.Event()
# 尝试打开摄像头
cap = cv2.VideoCapture(camera_source)
if not cap.isOpened():
await websocket.send_json({
'type': 'error',
'message': f'无法打开摄像头源: {camera_source}'
})
return
# 只对本地摄像头设置分辨率
if isinstance(camera_source, int):
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
self.camera_captures[connection_id] = cap
self.camera_configs[connection_id] = config
await websocket.send_json({
'type': 'camera_started',
'message': f'摄像头已启动: {camera_source}'
})
await self.process_frames(connection_id, websocket, cap)
except Exception as e:
logger.error(f"启动摄像头失败: {e}")
await websocket.send_json({
'type': 'error',
'message': f'启动摄像头失败: {str(e)}'
})
async def process_frames(self, connection_id: str, websocket: WebSocket, cap: cv2.VideoCapture):
from .detection_service import DetectionService
detection_service = DetectionService(self.model_service)
try:
frame_count = 0
stop_event = self._stop_events.get(connection_id)
while connection_id in self.active_connections:
# 检查是否收到停止信号
if stop_event and stop_event.is_set():
logger.info(f"收到停止信号,结束帧处理: {connection_id}")
break
ret, frame = cap.read()
if not ret:
await asyncio.sleep(0.1)
continue
config = self.camera_configs.get(connection_id, {
'model_id': 'fire_detection',
'confidence': 0.5,
'iou': 0.45
})
model_id = config.get('model_id', 'fire_detection')
confidence = config.get('confidence', 0.5)
iou = config.get('iou', 0.45)
draw = True
processed_frame, result = await detection_service.detect_frame(
frame,
model_id=model_id,
confidence=confidence,
iou=iou,
draw=draw
)
if result['success']:
frame_count += 1
logger.info(f"发送检测结果: {len(result['detections'])} 个目标, {result['stats']}")
await websocket.send_json({
'type': 'detection',
'detections': result['detections'],
'stats': result['stats']
})
_, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
import base64
original_frame_data = base64.b64encode(buffer).decode('utf-8')
_, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
frame_data = base64.b64encode(buffer).decode('utf-8')
try:
await websocket.send_json({
'type': 'original_frame',
'frame': original_frame_data
})
await websocket.send_json({
'type': 'annotated_frame',
'frame': frame_data
})
except Exception as e:
logger.error(f"发送帧数据失败: {e}")
break
await asyncio.sleep(0.03)
except Exception as e:
logger.error(f"处理帧错误: {e}")
finally:
# 只在摄像头仍存在于字典中时才释放(避免重复释放)
if connection_id in self.camera_captures:
cap.release()
del self.camera_captures[connection_id]
logger.info(f"帧处理结束,摄像头已释放: {connection_id}")
async def stop_camera(self, connection_id: str):
# 使用锁确保同一时间只有一个协程在操作该连接的摄像头资源
lock = self._locks.get(connection_id)
if lock:
async with lock:
await self._do_stop_camera(connection_id)
else:
await self._do_stop_camera(connection_id)
async def _do_stop_camera(self, connection_id: str):
"""实际执行停止摄像头的操作(内部方法,应在获取锁后调用)"""
# 设置停止事件,通知帧处理循环退出
if connection_id in self._stop_events:
self._stop_events[connection_id].set()
if connection_id in self.camera_captures:
cap = self.camera_captures[connection_id]
cap.release()
del self.camera_captures[connection_id]
if connection_id in self.active_connections:
try:
await self.active_connections[connection_id].send_json({
'type': 'camera_stopped',
'message': '摄像头已停止'
})
except:
pass
logger.info(f"摄像头已停止: {connection_id}")
# 清理锁和事件
if connection_id in self._locks:
del self._locks[connection_id]
if connection_id in self._stop_events:
del self._stop_events[connection_id]
async def update_config(self, connection_id: str, config: Dict):
if connection_id in self.camera_configs:
self.camera_configs[connection_id].update(config)
model_id = self.camera_configs[connection_id].get('model_id', 'fire_detection')
confidence = self.camera_configs[connection_id].get('confidence', 0.5)
iou = self.camera_configs[connection_id].get('iou', 0.45)
logger.info(f"配置更新: model_id={model_id}, confidence={confidence}, iou={iou}")
if connection_id in self.active_connections:
try:
await self.active_connections[connection_id].send_json({
'type': 'config_updated',
'message': '配置已更新'
})
except:
pass
async def stop(self):
for connection_id in list(self.camera_captures.keys()):
await self.stop_camera(connection_id)
self.running = False
logger.info("摄像头服务已停止")

View File

@@ -0,0 +1,199 @@
import os
import cv2
import numpy as np
import time
import uuid
import logging
from typing import Dict, List, Optional
from PIL import Image, ImageDraw, ImageFont
logger = logging.getLogger(__name__)
class DetectionService:
def __init__(self, model_service):
self.model_service = model_service
self.base_dir = os.path.dirname(os.path.dirname(__file__))
self.results_dir = os.path.join(self.base_dir, "static", "results")
self.temp_dir = os.path.join(self.base_dir, "static", "temp")
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
async def detect_image(
self,
image: np.ndarray,
model_id: str,
confidence: float = 0.5,
iou: float = 0.45
) -> Dict:
start_time = time.time()
model = await self.model_service.load_model(model_id)
if not model:
return {
'success': False,
'message': f'模型加载失败: {model_id}',
'detections': [],
'stats': None
}
try:
results = model(image, conf=confidence, iou=iou, verbose=False)
detections = []
for result in results:
boxes = result.boxes
for box in boxes:
x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
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 {
'success': True,
'message': '检测完成',
'detections': detections,
'stats': {
'total_detections': len(detections),
'avg_confidence': round(avg_confidence, 3),
'processing_time': round(processing_time, 3),
'model_used': model_id
}
}
except Exception as e:
logger.error(f"图片检测失败: {e}")
return {
'success': False,
'message': f'检测失败: {str(e)}',
'detections': [],
'stats': None
}
async def detect_frame(
self,
frame: np.ndarray,
model_id: str,
confidence: float = 0.5,
iou: float = 0.45,
draw: bool = True
) -> tuple:
start_time = time.time()
model = await self.model_service.load_model(model_id)
if not model:
return frame, {
'success': False,
'detections': [],
'stats': None
}
try:
results = model(frame, conf=confidence, iou=iou, verbose=False)
detections = []
for result in results:
boxes = result.boxes
for box in boxes:
x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
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
fps = 1.0 / processing_time if processing_time > 0 else 0
avg_confidence = sum(d['confidence'] for d in detections) / len(detections) if detections else 0
result_data = {
'success': True,
'detections': detections,
'stats': {
'total_detections': len(detections),
'avg_confidence': round(avg_confidence, 3),
'processing_time': round(processing_time, 3),
'fps': round(fps, 2),
'model_used': model_id
}
}
if draw:
frame = self.draw_detections(frame, detections, fps)
return frame, result_data
except Exception as e:
logger.error(f"帧检测失败: {e}")
return frame, {
'success': False,
'detections': [],
'stats': None
}

View File

@@ -0,0 +1,115 @@
import os
import logging
from ultralytics import YOLO
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
class ModelService:
def __init__(self):
self.models: Dict[str, YOLO] = {}
# 基础路径:从 apps/server/services/model_service.py 到 jc-video-web 根目录
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
self.model_configs = {
'fire_detection': {
'path': os.path.join(base_dir, 'models', 'fire_detection', 'best.pt'),
'type': 'yolov10',
'classes': ['Fire', 'Smoke'],
'labels': {'Fire': '火焰', 'Smoke': '烟雾'},
'size': '61MB',
'description': '基于YOLOv10的火灾烟雾检测模型',
'name': '火灾检测'
},
'helmet_detection': {
'path': os.path.join(base_dir, 'models', 'helmet_detection', 'yolov8n.pt'),
'type': 'yolov8',
'classes': ['person', 'helmet'],
'labels': {'person': '人员', 'helmet': '安全帽'},
'size': '6MB',
'description': '基于YOLOv8的安全帽检测模型',
'name': '安全帽检测'
},
'crowd_detection': {
'path': os.path.join(base_dir, 'models', 'crowd_detection', 'yolov8l.pt'),
'type': 'yolov8',
'classes': ['person'],
'labels': {'person': '人员'},
'size': '100MB',
'description': '基于YOLOv8的人群聚集检测模型',
'name': '人群检测'
},
'smoking_detection': {
'path': os.path.join(base_dir, 'models', 'smoking_detection', 'smoking_yolov8n.pt'),
'type': 'yolov8',
'classes': ['cigarette', 'smoke'],
'labels': {'cigarette': '香烟', 'smoke': '烟雾'},
'size': '6MB',
'description': '基于YOLOv8的抽烟检测模型',
'name': '抽烟检测'
},
'loitering_detection': {
'path': os.path.join(base_dir, 'models', 'loitering_detection', 'yolov8n.pt'),
'type': 'yolov8',
'classes': ['person'],
'labels': {'person': '人员'},
'size': '6MB',
'description': '基于YOLOv8的徘徊检测模型',
'name': '徘徊检测'
}
}
def get_available_models(self) -> List[Dict]:
available_models = []
for model_id, config in self.model_configs.items():
if os.path.exists(config['path']):
available_models.append({
'id': model_id,
'name': config['name'],
'description': config['description'],
'classes': config['classes'],
'labels': config['labels'],
'size': config['size'],
'type': config['type']
})
else:
logger.warning(f"模型文件不存在: {config['path']}")
return available_models
async def load_model(self, model_id: str) -> Optional[YOLO]:
if model_id not in self.model_configs:
logger.error(f"未知模型ID: {model_id}")
return None
if model_id in self.models:
logger.info(f"模型已加载: {model_id}")
return self.models[model_id]
config = self.model_configs[model_id]
# 处理 YOLO 模型
model_path = config['path']
if not os.path.exists(model_path):
logger.error(f"模型文件不存在: {model_path}")
return None
try:
logger.info(f"正在加载模型: {model_id} from {model_path}")
model = YOLO(model_path)
self.models[model_id] = model
logger.info(f"模型加载成功: {model_id}")
return model
except Exception as e:
logger.error(f"模型加载失败: {model_id}, 错误: {e}")
return None
def get_model(self, model_id: str) -> Optional[YOLO]:
return self.models.get(model_id)
async def unload_model(self, model_id: str) -> bool:
if model_id in self.models:
del self.models[model_id]
logger.info(f"模型已卸载: {model_id}")
return True
return False

View File

@@ -0,0 +1,147 @@
import os
import logging
from ultralytics import YOLO
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
class ModelService:
def __init__(self):
self.models: Dict[str, YOLO] = {}
self.model_configs = {
'fire_detection': {
'path': os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'fire_detection', 'models', 'best.pt'),
'type': 'yolov10',
'classes': ['Fire', 'Smoke'],
'labels': {'Fire': '火焰', 'Smoke': '烟雾'},
'size': '61MB',
'description': '基于YOLOv10的火灾烟雾检测模型',
'name': '火灾检测'
},
'helmet_detection': {
'path': os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'yolov', 'yolov8n.pt'),
'type': 'yolov8',
'classes': ['person', 'helmet'],
'labels': {'person': '人员', 'helmet': '安全帽'},
'size': '6MB',
'description': '基于YOLOv8的安全帽检测模型',
'name': '安全帽检测'
},
'crowd_detection': {
'path': os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'behavior_detection', 'Crowd-Gathering', 'models', 'yolov8l.pt'),
'type': 'yolov8',
'classes': ['person'],
'labels': {'person': '人员'},
'size': '100MB',
'description': '基于YOLOv8的人群聚集检测模型',
'name': '人群检测'
},
'smoking_detection': {
'path': 'PADDLE_DETECTION', # 特殊标记,表示使用 PaddleDetection
'type': 'paddle',
'classes': ['cigarette'],
'labels': {'cigarette': '香烟'},
'size': '27MB',
'description': '基于PP-YOLOE的抽烟检测模型',
'name': '抽烟检测',
'docker_image': 'smoking-detection:test',
'model_dir': 'output_inference/ppyoloe_crn_s_80e_smoking_visdrone'
}
}
def get_available_models(self) -> List[Dict]:
available_models = []
for model_id, config in self.model_configs.items():
# 对于 PaddleDetection 模型,不需要检查本地文件
if config.get('type') == 'paddle':
# 检查 Docker 是否可用
import subprocess
try:
result = subprocess.run(
["docker", "image", "inspect", config['docker_image']],
capture_output=True,
timeout=5
)
if result.returncode == 0:
available_models.append({
'id': model_id,
'name': config['name'],
'description': config['description'],
'classes': config['classes'],
'labels': config['labels'],
'size': config['size'],
'type': config['type']
})
else:
logger.warning(f"PaddleDetection Docker 镜像不可用: {config['docker_image']}")
except Exception as e:
logger.warning(f"检查 PaddleDetection Docker 镜像失败: {e}")
elif os.path.exists(config['path']):
available_models.append({
'id': model_id,
'name': config['name'],
'description': config['description'],
'classes': config['classes'],
'labels': config['labels'],
'size': config['size'],
'type': config['type']
})
else:
logger.warning(f"模型文件不存在: {config['path']}")
return available_models
async def load_model(self, model_id: str):
if model_id not in self.model_configs:
logger.error(f"未知模型ID: {model_id}")
return None
# 如果已经加载,直接返回
if model_id in self.models:
logger.info(f"模型已加载: {model_id}")
return self.models[model_id]
config = self.model_configs[model_id]
# 处理 PaddleDetection 模型
if config.get('type') == 'paddle':
try:
from .paddle_detection_service import SmokingDetectionModel
logger.info(f"正在加载 PaddleDetection 抽烟检测模型...")
model = SmokingDetectionModel()
self.models[model_id] = model
logger.info(f"PaddleDetection 模型加载成功: {model_id}")
return model
except Exception as e:
logger.error(f"PaddleDetection 模型加载失败: {e}")
return None
# 处理 YOLO 模型
model_path = config['path']
if not os.path.exists(model_path):
logger.error(f"模型文件不存在: {model_path}")
return None
try:
logger.info(f"正在加载模型: {model_id} from {model_path}")
model = YOLO(model_path)
self.models[model_id] = model
logger.info(f"模型加载成功: {model_id}")
return model
except Exception as e:
logger.error(f"模型加载失败: {model_id}, 错误: {e}")
return None
def get_model(self, model_id: str):
return self.models.get(model_id)
async def unload_model(self, model_id: str) -> bool:
if model_id in self.models:
del self.models[model_id]
logger.info(f"模型已卸载: {model_id}")
return True
return False

View File

@@ -0,0 +1,274 @@
"""
PaddleDetection 抽烟检测服务适配器
通过 Docker 调用 Paddle 模型
"""
import os
import cv2
import numpy as np
import subprocess
import tempfile
import logging
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.docker_image = "smoking-detection:test"
self.model_dir = "output_inference/ppyoloe_crn_s_80e_smoking_visdrone"
self.threshold = 0.1 # 抽烟检测需要较低的阈值
# 检查 Docker 和镜像
self._check_docker()
def _check_docker(self):
"""检查 Docker 环境"""
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
logger.error("Docker 未运行")
self.available = False
return
# 检查镜像
result = subprocess.run(
["docker", "image", "inspect", self.docker_image],
capture_output=True,
text=True,
timeout=5
)
self.available = result.returncode == 0
if self.available:
logger.info(f"PaddleDetection 服务已就绪: {self.docker_image}")
else:
logger.error(f"Docker 镜像不存在: {self.docker_image}")
except Exception as e:
logger.error(f"Docker 检查失败: {e}")
self.available = False
def detect_image(self, image: np.ndarray) -> Dict:
"""
检测图片中的抽烟行为
Args:
image: OpenCV 图片 (BGR格式)
Returns:
检测结果字典
"""
if not self.available:
return {
'success': False,
'message': 'PaddleDetection 服务不可用',
'detections': [],
'stats': None
}
try:
# 创建临时文件
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f:
temp_input = f.name
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f:
temp_output = f.name
# 保存输入图片
cv2.imwrite(temp_input, image)
# 构建 Docker 命令
cmd = [
"docker", "run", "--rm",
"-v", f"{temp_input}:/workspace/input.jpg",
"-v", f"{os.path.dirname(temp_output)}:/workspace/output",
self.docker_image,
"python", "deploy/python/infer.py",
f"--model_dir={self.model_dir}",
"--image_file=/workspace/input.jpg",
"--device=CPU",
"--output_dir=/workspace/output",
f"--threshold={self.threshold}"
]
# 执行检测
logger.info(f"执行抽烟检测: {temp_input}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60
)
# 解析结果
detections = self._parse_detection_output(result.stdout)
# 读取输出图片
output_image = None
output_path = temp_output.replace('.jpg', '') + '_result.jpg'
if os.path.exists(output_path):
output_image = cv2.imread(output_path)
# 清理临时文件
self._cleanup_temp_files([temp_input, temp_output, output_path])
return {
'success': True,
'message': '检测完成',
'detections': detections,
'output_image': output_image,
'stats': {
'total_detections': len(detections),
'model_used': 'ppyoloe_crn_s_80e_smoking_visdrone',
'threshold': self.threshold
}
}
except subprocess.TimeoutExpired:
logger.error("检测超时")
return {
'success': False,
'message': '检测超时',
'detections': [],
'stats': None
}
except Exception as e:
logger.error(f"检测失败: {e}")
return {
'success': False,
'message': f'检测失败: {str(e)}',
'detections': [],
'stats': None
}
def _parse_detection_output(self, output: str) -> List[Dict]:
"""解析检测输出"""
detections = []
# 查找检测结果行
for line in output.split('\n'):
if 'class_id:' in line and 'confidence:' in line:
try:
# 解析: class_id:0, confidence:0.8921, left_top:[268.66,231.64],right_bottom:[351.87,258.66]
parts = line.split(',')
# 提取置信度
conf_part = [p for p in parts if 'confidence:' in p][0]
confidence = float(conf_part.split(':')[1])
# 提取坐标
left_top_part = [p for p in parts if 'left_top:' in p][0]
right_bottom_part = [p for p in parts if 'right_bottom:' in p][0]
# 解析坐标
left_top = eval(left_top_part.split(':')[1])
right_bottom = eval(right_bottom_part.split(':')[1])
x1, y1 = left_top
x2, y2 = right_bottom
detections.append({
'class': 'cigarette',
'label': '香烟',
'confidence': round(confidence, 3),
'bbox': [int(x1), int(y1), int(x2), int(y2)]
})
except Exception as e:
logger.warning(f"解析检测结果失败: {e}")
continue
return detections
def _cleanup_temp_files(self, files: List[str]):
"""清理临时文件"""
for f in files:
try:
if os.path.exists(f):
os.remove(f)
except Exception as e:
logger.warning(f"清理临时文件失败: {f}, {e}")
# 兼容性包装,保持与 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)
# 创建模拟的 YOLO 结果对象
return [PaddleDetectionResult(result, self.names)]
class PaddleDetectionResult:
"""模拟 YOLO 检测结果对象"""
def __init__(self, detection_result: Dict, names: Dict):
self.detection_result = detection_result
self.names = names
# 创建模拟的 boxes 对象
self.boxes = self._create_boxes()
def _create_boxes(self):
"""创建模拟的 boxes 对象"""
detections = self.detection_result.get('detections', [])
if not detections:
return MockBoxes([])
# 转换为 YOLO 格式
xyxy = []
conf = []
cls = []
for det in detections:
xyxy.append(det['bbox'])
conf.append(det['confidence'])
cls.append(0) # cigarette 类别
return MockBoxes(xyxy, conf, cls)
class MockBoxes:
"""模拟 YOLO boxes 对象"""
def __init__(self, xyxy_list, conf_list=None, cls_list=None):
import torch
if xyxy_list:
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 = torch.empty((0, 4))
self.conf = torch.empty((0, 1))
self.cls = torch.empty((0, 1), dtype=torch.int64)