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:
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
build/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Turbo
|
||||
.turbo/
|
||||
|
||||
# Model files (large files)
|
||||
*.pt
|
||||
*.onnx
|
||||
*.pth
|
||||
*.weights
|
||||
|
||||
# Static uploads
|
||||
apps/server/static/uploads/
|
||||
apps/server/static/results/
|
||||
apps/server/static/temp/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# jc-video-web
|
||||
|
||||
视频模型检测平台 - 基于YOLO的实时视频检测系统
|
||||
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
jc-video-web/
|
||||
├── apps/
|
||||
│ ├── web/ # 前端应用 (Vue 3 + Vite)
|
||||
│ └── server/ # 后端服务 (FastAPI)
|
||||
├── packages/
|
||||
│ └── shared-types/ # 前后端共享类型定义
|
||||
├── models/ # AI模型文件
|
||||
│ ├── fire_detection/ # 火灾检测模型
|
||||
│ ├── helmet_detection/ # 安全帽检测模型
|
||||
│ ├── crowd_detection/ # 人群检测模型
|
||||
│ └── smoking_detection/# 抽烟检测模型
|
||||
├── scripts/ # 构建/开发脚本
|
||||
└── docker/ # Docker配置
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
- **框架**: Vue 3 + Composition API
|
||||
- **构建工具**: Vite 5
|
||||
- **UI组件库**: Element Plus
|
||||
- **状态管理**: Pinia
|
||||
- **路由**: Vue Router 4
|
||||
- **HTTP客户端**: Axios
|
||||
|
||||
### 后端
|
||||
- **框架**: FastAPI
|
||||
- **服务器**: Uvicorn
|
||||
- **AI推理**: Ultralytics (YOLO)
|
||||
- **图像处理**: OpenCV, Pillow
|
||||
- **实时通信**: WebSocket
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js >= 18
|
||||
- Python >= 3.9
|
||||
- pnpm >= 9.0
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 运行初始化脚本
|
||||
bash scripts/setup.sh
|
||||
```
|
||||
|
||||
或手动安装:
|
||||
|
||||
```bash
|
||||
# 安装根依赖
|
||||
pnpm install
|
||||
|
||||
# 安装前端依赖
|
||||
cd apps/web
|
||||
pnpm install
|
||||
cd ../..
|
||||
|
||||
# 创建 Python 虚拟环境并安装依赖
|
||||
cd apps/server
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cd ../..
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
# 同时启动前后端
|
||||
pnpm dev
|
||||
|
||||
# 只启动前端
|
||||
pnpm dev:web
|
||||
|
||||
# 只启动后端
|
||||
pnpm dev:server
|
||||
```
|
||||
|
||||
访问地址:
|
||||
- 前端: http://localhost:5173
|
||||
- 后端: http://localhost:8000
|
||||
- API文档: http://localhost:8000/docs
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 检测模型
|
||||
1. **火灾检测** - 基于YOLOv10的火焰和烟雾检测
|
||||
2. **安全帽检测** - 基于YOLOv8的工地安全检测
|
||||
3. **人群检测** - 基于YOLOv8的人群聚集检测
|
||||
4. **抽烟检测** - 基于YOLOv8的吸烟行为检测
|
||||
|
||||
### 输入方式
|
||||
- 图片上传检测
|
||||
- 摄像头实时检测
|
||||
|
||||
### 核心功能
|
||||
- 可拖拽布局配置
|
||||
- 实时WebSocket视频流
|
||||
- 检测结果可视化
|
||||
- 多模型切换
|
||||
- 置信度阈值调整
|
||||
|
||||
## 项目脚本
|
||||
|
||||
```bash
|
||||
pnpm dev # 启动开发服务器
|
||||
pnpm build # 构建生产版本
|
||||
pnpm build:web # 只构建前端
|
||||
pnpm test # 运行测试
|
||||
pnpm lint # 代码检查
|
||||
pnpm clean # 清理构建产物
|
||||
```
|
||||
|
||||
## 模型配置
|
||||
|
||||
模型文件存放在 `models/` 目录下,需要在 `apps/server/services/model_service.py` 中配置模型路径。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
0
apps/server/api/__init__.py
Normal file
0
apps/server/api/__init__.py
Normal file
67
apps/server/api/detection.py
Normal file
67
apps/server/api/detection.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
import base64
|
||||
import logging
|
||||
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)
|
||||
):
|
||||
from main import model_service
|
||||
from services.detection_service import DetectionService
|
||||
|
||||
detection_service = DetectionService(model_service)
|
||||
|
||||
try:
|
||||
contents = await file.read()
|
||||
nparr = np.frombuffer(contents, np.uint8)
|
||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||
|
||||
if frame is None:
|
||||
return ImageDetectionResult(
|
||||
success=False,
|
||||
message="无法读取图片",
|
||||
data={}
|
||||
)
|
||||
|
||||
result = await detection_service.detect_image(frame, model_id, confidence, iou)
|
||||
|
||||
if result['success']:
|
||||
annotated_frame = detection_service.draw_detections(frame, result['detections'])
|
||||
|
||||
import uuid
|
||||
result_filename = f"result_{uuid.uuid4().hex[:8]}.jpg"
|
||||
result_path = f"static/results/{result_filename}"
|
||||
cv2.imwrite(result_path, annotated_frame)
|
||||
|
||||
return ImageDetectionResult(
|
||||
success=True,
|
||||
message="检测完成",
|
||||
data={
|
||||
"detections": result['detections'],
|
||||
"image_url": f"/static/results/{result_filename}",
|
||||
"stats": result['stats']
|
||||
}
|
||||
)
|
||||
else:
|
||||
return ImageDetectionResult(
|
||||
success=False,
|
||||
message=result['message'],
|
||||
data={}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"图片检测失败: {e}")
|
||||
return ImageDetectionResult(
|
||||
success=False,
|
||||
message=f"检测失败: {str(e)}",
|
||||
data={}
|
||||
)
|
||||
28
apps/server/api/models.py
Normal file
28
apps/server/api/models.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter
|
||||
from models.schemas import ModelInfo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/models", response_model=list[ModelInfo])
|
||||
async def get_models():
|
||||
from main import model_service
|
||||
models = model_service.get_available_models()
|
||||
return models
|
||||
|
||||
@router.get("/models/{model_id}", response_model=ModelInfo)
|
||||
async def get_model(model_id: str):
|
||||
from main import model_service
|
||||
models = model_service.get_available_models()
|
||||
for model in models:
|
||||
if model['id'] == model_id:
|
||||
return model
|
||||
return None
|
||||
|
||||
@router.post("/models/{model_id}/load")
|
||||
async def load_model(model_id: str):
|
||||
from main import model_service
|
||||
model = await model_service.load_model(model_id)
|
||||
if model:
|
||||
return {"success": True, "message": f"模型加载成功: {model_id}"}
|
||||
else:
|
||||
return {"success": False, "message": f"模型加载失败: {model_id}"}
|
||||
126
apps/server/main.py
Normal file
126
apps/server/main.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from fastapi import FastAPI, UploadFile, File, WebSocket, WebSocketDisconnect
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import uvicorn
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from api import detection, models
|
||||
from services.model_service import ModelService
|
||||
from services.camera_service import CameraService
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
model_service = ModelService()
|
||||
camera_service = None
|
||||
|
||||
|
||||
def setup_signal_handlers():
|
||||
"""设置信号处理器,确保进程异常退出时能清理资源"""
|
||||
def signal_handler(signum, frame):
|
||||
sig_name = signal.Signals(signum).name
|
||||
logger.info(f"收到信号 {sig_name},正在清理资源...")
|
||||
|
||||
# 强制释放摄像头资源
|
||||
import subprocess
|
||||
try:
|
||||
# 查找并终止占用摄像头的Python进程(除了当前进程)
|
||||
current_pid = os.getpid()
|
||||
result = subprocess.run(
|
||||
['lsof', '+D', '/dev', '-a', '-c', 'python'],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.split('\n'):
|
||||
if '/dev/video' in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
pid = int(parts[1])
|
||||
if pid != current_pid:
|
||||
logger.info(f"终止占用摄像头的进程: {pid}")
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except (ValueError, ProcessLookupError):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"清理摄像头资源失败: {e}")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
# 注册信号处理器
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
if hasattr(signal, 'SIGQUIT'):
|
||||
signal.signal(signal.SIGQUIT, signal_handler)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global camera_service
|
||||
|
||||
camera_service = CameraService(model_service)
|
||||
yield
|
||||
|
||||
# 关闭时清理资源
|
||||
logger.info("正在关闭服务,清理资源...")
|
||||
if camera_service:
|
||||
await camera_service.stop()
|
||||
|
||||
app = FastAPI(
|
||||
title="视频模型检测平台",
|
||||
description="基于YOLO的实时视频检测平台",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
app.include_router(detection.router, prefix="/api")
|
||||
app.include_router(models.router, prefix="/api")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "视频模型检测平台 API", "version": "1.0.0"}
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "healthy"}
|
||||
|
||||
@app.websocket("/ws/camera")
|
||||
async def camera_websocket_endpoint(websocket: WebSocket):
|
||||
await camera_service.handle_connection(websocket)
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.makedirs("static/uploads", exist_ok=True)
|
||||
os.makedirs("static/results", exist_ok=True)
|
||||
os.makedirs("static/temp", exist_ok=True)
|
||||
|
||||
# 设置信号处理器
|
||||
setup_signal_handlers()
|
||||
|
||||
# 检测是否处于uvicorn重载模式的子进程中
|
||||
is_reload_worker = os.environ.get('UVICORN_RELOAD') == 'true'
|
||||
|
||||
if is_reload_worker:
|
||||
logger.info("检测到uvicorn重载子进程,跳过摄像头预清理")
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
reload_dirs=["./"],
|
||||
reload_includes=["*.py"]
|
||||
)
|
||||
0
apps/server/models/__init__.py
Normal file
0
apps/server/models/__init__.py
Normal file
39
apps/server/models/schemas.py
Normal file
39
apps/server/models/schemas.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
classes: List[str]
|
||||
labels: Dict[str, str]
|
||||
size: str
|
||||
type: str
|
||||
|
||||
class Detection(BaseModel):
|
||||
class_name: str
|
||||
label: str
|
||||
confidence: float
|
||||
bbox: List[int]
|
||||
|
||||
class DetectionStats(BaseModel):
|
||||
total_detections: int
|
||||
avg_confidence: float
|
||||
processing_time: float
|
||||
model_used: str
|
||||
|
||||
class ImageDetectionResult(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
data: Dict[str, Any]
|
||||
|
||||
class VideoDetectionRequest(BaseModel):
|
||||
model_id: str
|
||||
confidence: float = Field(default=0.5, ge=0.1, le=1.0)
|
||||
iou: float = Field(default=0.45, ge=0.1, le=0.9)
|
||||
|
||||
class DetectionConfig(BaseModel):
|
||||
model_id: str
|
||||
confidence: float = Field(default=0.5, ge=0.1, le=1.0)
|
||||
iou: float = Field(default=0.45, ge=0.1, le=0.9)
|
||||
357
apps/server/models/smoking_yolo_adapter.py
Normal file
357
apps/server/models/smoking_yolo_adapter.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
YOLO 格式的抽烟检测模型适配器
|
||||
将 PaddleDetection 模型包装为 YOLO 接口
|
||||
"""
|
||||
|
||||
import os
|
||||
import cv2
|
||||
import numpy as np
|
||||
import subprocess
|
||||
import tempfile
|
||||
import logging
|
||||
from typing import List, Dict, Union
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmokingDetectionYOLO:
|
||||
"""
|
||||
模拟 YOLO 接口的抽烟检测模型
|
||||
底层使用 PaddleDetection Docker 容器
|
||||
"""
|
||||
|
||||
def __init__(self, model_path=None):
|
||||
"""
|
||||
初始化模型
|
||||
|
||||
Args:
|
||||
model_path: 模型路径(可选,仅用于兼容性)
|
||||
"""
|
||||
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
|
||||
|
||||
# YOLO 兼容属性
|
||||
self.names = {0: 'cigarette'}
|
||||
self.model = self # 自我引用,保持与 YOLO 相同的接口
|
||||
|
||||
# 检查 Docker
|
||||
self._check_docker()
|
||||
|
||||
logger.info(f"抽烟检测模型初始化完成,Docker可用: {self.available}")
|
||||
|
||||
def _check_docker(self):
|
||||
"""检查 Docker 环境"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
if self.available:
|
||||
# 检查镜像
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", self.docker_image],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Docker 检查失败: {e}")
|
||||
self.available = False
|
||||
|
||||
def __call__(self, source, conf=0.1, iou=0.45, verbose=False, stream=False):
|
||||
"""
|
||||
模拟 YOLO 模型的调用接口
|
||||
|
||||
Args:
|
||||
source: 图片路径、OpenCV 图片、或图片列表
|
||||
conf: 置信度阈值
|
||||
iou: IoU 阈值(PaddleDetection 不支持,仅用于兼容)
|
||||
verbose: 是否输出详细信息
|
||||
stream: 是否流式输出(仅用于兼容)
|
||||
|
||||
Returns:
|
||||
YOLOResult 对象列表
|
||||
"""
|
||||
if not self.available:
|
||||
logger.error("Docker 不可用,无法运行检测")
|
||||
return [YOLOResult([])]
|
||||
|
||||
# 处理不同类型的输入
|
||||
if isinstance(source, str):
|
||||
# 图片路径
|
||||
image = cv2.imread(source)
|
||||
if image is None:
|
||||
logger.error(f"无法读取图片: {source}")
|
||||
return [YOLOResult([])]
|
||||
return self._detect_single(image, conf, verbose)
|
||||
|
||||
elif isinstance(source, np.ndarray):
|
||||
# OpenCV 图片
|
||||
return self._detect_single(source, conf, verbose)
|
||||
|
||||
elif isinstance(source, list):
|
||||
# 图片列表
|
||||
results = []
|
||||
for img in source:
|
||||
if isinstance(img, str):
|
||||
img = cv2.imread(img)
|
||||
if img is not None:
|
||||
results.extend(self._detect_single(img, conf, verbose))
|
||||
return results
|
||||
|
||||
else:
|
||||
logger.error(f"不支持的输入类型: {type(source)}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def _detect_single(self, image: np.ndarray, conf: float, verbose: bool) -> List['YOLOResult']:
|
||||
"""检测单张图片"""
|
||||
|
||||
try:
|
||||
# 创建临时文件
|
||||
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f:
|
||||
temp_input = f.name
|
||||
|
||||
# 保存输入图片
|
||||
cv2.imwrite(temp_input, image)
|
||||
|
||||
if verbose:
|
||||
logger.info(f"正在检测: {temp_input}")
|
||||
|
||||
# 构建 Docker 命令
|
||||
cmd = [
|
||||
"docker", "run", "--rm",
|
||||
"-v", f"{temp_input}:/workspace/input.jpg",
|
||||
self.docker_image,
|
||||
"python", "deploy/python/infer.py",
|
||||
f"--model_dir={self.model_dir}",
|
||||
"--image_file=/workspace/input.jpg",
|
||||
"--device=CPU",
|
||||
"--output_dir=/workspace",
|
||||
f"--threshold={conf}"
|
||||
]
|
||||
|
||||
# 执行检测
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if verbose:
|
||||
logger.info(f"检测完成,返回码: {result.returncode}")
|
||||
|
||||
# 解析结果
|
||||
detections = self._parse_output(result.stdout)
|
||||
|
||||
if verbose and detections:
|
||||
logger.info(f"检测到 {len(detections)} 个目标")
|
||||
|
||||
# 清理临时文件
|
||||
try:
|
||||
os.remove(temp_input)
|
||||
except:
|
||||
pass
|
||||
|
||||
return [YOLOResult(detections)]
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("检测超时")
|
||||
return [YOLOResult([])]
|
||||
except Exception as e:
|
||||
logger.error(f"检测失败: {e}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def _parse_output(self, output: str) -> List[Dict]:
|
||||
"""解析检测输出"""
|
||||
detections = []
|
||||
import re
|
||||
|
||||
# 使用正则表达式匹配检测行
|
||||
# 格式: class_id:0, confidence:0.8921, left_top:[268.66,231.64],right_bottom:[351.87,258.66]
|
||||
pattern = r'class_id:\d+,\s*confidence:([\d.]+),\s*left_top:\[([\d.]+),\s*([\d.]+)\],\s*right_bottom:\[([\d.]+),\s*([\d.]+)\]'
|
||||
|
||||
for line in output.split('\n'):
|
||||
match = re.search(pattern, line)
|
||||
if match:
|
||||
try:
|
||||
confidence = float(match.group(1))
|
||||
x1 = float(match.group(2))
|
||||
y1 = float(match.group(3))
|
||||
x2 = float(match.group(4))
|
||||
y2 = float(match.group(5))
|
||||
|
||||
detections.append({
|
||||
'bbox': [int(x1), int(y1), int(x2), int(y2)],
|
||||
'confidence': confidence,
|
||||
'class': 0,
|
||||
'name': 'cigarette'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"解析检测结果失败: {e}, line: {line}")
|
||||
continue
|
||||
|
||||
return detections
|
||||
|
||||
def predict(self, source, **kwargs):
|
||||
"""兼容 predict 方法"""
|
||||
return self.__call__(source, **kwargs)
|
||||
|
||||
|
||||
class YOLOResult:
|
||||
"""
|
||||
模拟 YOLO 检测结果对象
|
||||
提供与 ultralytics YOLO 结果相同的接口
|
||||
"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
self.names = {0: 'cigarette'}
|
||||
|
||||
# 创建 boxes 对象
|
||||
self.boxes = Boxes(detections)
|
||||
|
||||
# 其他 YOLO 结果属性
|
||||
self.probs = None
|
||||
self.keypoints = None
|
||||
self.obb = None
|
||||
self.speed = {'preprocess': 0, 'inference': 0, 'postprocess': 0}
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __getitem__(self, idx):
|
||||
"""支持索引访问"""
|
||||
if idx < len(self.detections):
|
||||
return YOLOResult([self.detections[idx]])
|
||||
return YOLOResult([])
|
||||
|
||||
def plot(self, **kwargs):
|
||||
"""绘制检测结果(兼容方法)"""
|
||||
return None
|
||||
|
||||
|
||||
class Boxes:
|
||||
"""
|
||||
模拟 YOLO boxes 对象
|
||||
提供 xyxy, conf, cls 等属性
|
||||
"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
|
||||
# 尝试使用 torch,如果没有则使用 numpy
|
||||
try:
|
||||
import torch
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = torch.tensor(xyxy_list, dtype=torch.float32)
|
||||
self.conf = torch.tensor(conf_list, dtype=torch.float32)
|
||||
self.cls = torch.tensor(cls_list, dtype=torch.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = torch.empty((0, 4))
|
||||
self.conf = torch.empty((0, 1))
|
||||
self.cls = torch.empty((0, 1), dtype=torch.int64)
|
||||
self.id = None
|
||||
|
||||
except ImportError:
|
||||
# 如果没有 torch,使用 numpy
|
||||
import numpy as np
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = np.array(xyxy_list, dtype=np.float32)
|
||||
self.conf = np.array(conf_list, dtype=np.float32)
|
||||
self.cls = np.array(cls_list, dtype=np.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = np.empty((0, 4), dtype=np.float32)
|
||||
self.conf = np.empty((0, 1), dtype=np.float32)
|
||||
self.cls = np.empty((0, 1), dtype=np.int64)
|
||||
self.id = None
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __iter__(self):
|
||||
"""使 Boxes 可迭代"""
|
||||
for i in range(len(self.detections)):
|
||||
yield Box(self, i)
|
||||
|
||||
def cpu(self):
|
||||
"""兼容方法"""
|
||||
return self
|
||||
|
||||
def numpy(self):
|
||||
"""转换为 numpy"""
|
||||
if hasattr(self.xyxy, 'numpy'):
|
||||
return type('Boxes', (), {
|
||||
'xyxy': self.xyxy.numpy(),
|
||||
'conf': self.conf.numpy(),
|
||||
'cls': self.cls.numpy(),
|
||||
'id': self.id
|
||||
})()
|
||||
return self
|
||||
|
||||
|
||||
class Box:
|
||||
"""
|
||||
模拟单个检测框对象
|
||||
"""
|
||||
|
||||
def __init__(self, boxes: Boxes, index: int):
|
||||
self._boxes = boxes
|
||||
self._index = index
|
||||
|
||||
@property
|
||||
def xyxy(self):
|
||||
"""返回 xyxy 坐标 (1, 4) 形状"""
|
||||
import torch
|
||||
import numpy as np
|
||||
coords = self._boxes.xyxy[self._index]
|
||||
if isinstance(coords, torch.Tensor):
|
||||
return coords.unsqueeze(0)
|
||||
else:
|
||||
return np.array([coords])
|
||||
|
||||
@property
|
||||
def conf(self):
|
||||
"""返回置信度 (1,) 形状 - 与 YOLO 兼容"""
|
||||
import torch
|
||||
import numpy as np
|
||||
conf_val = self._boxes.conf[self._index]
|
||||
# 返回 (1,) 形状,与 YOLO 一致
|
||||
if isinstance(conf_val, torch.Tensor):
|
||||
return conf_val.view(1)
|
||||
else:
|
||||
return np.array([conf_val])
|
||||
|
||||
@property
|
||||
def cls(self):
|
||||
"""返回类别 (1,) 形状 - 与 YOLO 兼容"""
|
||||
import torch
|
||||
import numpy as np
|
||||
cls_val = self._boxes.cls[self._index]
|
||||
# 返回 (1,) 形状,与 YOLO 一致
|
||||
if isinstance(cls_val, torch.Tensor):
|
||||
return cls_val.view(1)
|
||||
else:
|
||||
return np.array([cls_val])
|
||||
359
apps/server/models/smoking_yolo_adapter_fast.py
Normal file
359
apps/server/models/smoking_yolo_adapter_fast.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
YOLO 格式的抽烟检测模型适配器(快速版)
|
||||
使用 HTTP API 与常驻 Docker 容器通信
|
||||
"""
|
||||
|
||||
import os
|
||||
import cv2
|
||||
import numpy as np
|
||||
import subprocess
|
||||
import tempfile
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
import requests
|
||||
from typing import List, Dict, Optional
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmokingDetectionYOLO:
|
||||
"""
|
||||
模拟 YOLO 接口的抽烟检测模型(快速版)
|
||||
使用 HTTP API 与常驻 Docker 容器通信
|
||||
"""
|
||||
|
||||
_container_name = "smoking-detection-daemon"
|
||||
_initialized = False
|
||||
_server_url = "http://localhost:8080"
|
||||
|
||||
def __init__(self, model_path=None):
|
||||
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
|
||||
|
||||
# YOLO 兼容属性
|
||||
self.names = {0: 'cigarette'}
|
||||
self.model = self
|
||||
|
||||
# 检查 Docker 并启动常驻服务器
|
||||
self._check_docker()
|
||||
if self.available:
|
||||
self._start_daemon()
|
||||
|
||||
logger.info(f"抽烟检测模型快速版初始化完成,Docker可用: {self.available}")
|
||||
|
||||
def _check_docker(self):
|
||||
"""检查 Docker 环境"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
if self.available:
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", self.docker_image],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Docker 检查失败: {e}")
|
||||
self.available = False
|
||||
|
||||
def _start_daemon(self):
|
||||
"""启动常驻服务器"""
|
||||
try:
|
||||
# 检查容器是否已在运行
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-q", "-f", f"name={self._container_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.stdout.strip():
|
||||
logger.info(f"常驻服务器已在运行: {self._container_name}")
|
||||
SmokingDetectionYOLO._initialized = True
|
||||
return
|
||||
|
||||
# 检查容器是否存在但已停止
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-aq", "-f", f"name={self._container_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.stdout.strip():
|
||||
# 删除旧容器
|
||||
logger.info("删除旧容器")
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", self._container_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# 创建新容器并启动常驻服务器
|
||||
logger.info("启动常驻服务器...")
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d",
|
||||
"--name", self._container_name,
|
||||
"-p", "8080:8080",
|
||||
"-v", "/tmp:/workspace/input",
|
||||
"-v", "/Users/wwh/project/video-model/PaddlePaddle/PaddleDetection-release-2.9/smoking_server_daemon.py:/workspace/PaddleDetection/smoking_server_daemon.py",
|
||||
"-w", "/workspace/PaddleDetection",
|
||||
self.docker_image,
|
||||
"python", "smoking_server_daemon.py"
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# 等待服务器启动
|
||||
logger.info("等待服务器启动...")
|
||||
time.sleep(5)
|
||||
|
||||
SmokingDetectionYOLO._initialized = True
|
||||
logger.info("常驻服务器启动成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动常驻服务器失败: {e}")
|
||||
SmokingDetectionYOLO._initialized = False
|
||||
|
||||
def __call__(self, source, conf=0.1, iou=0.45, verbose=False, stream=False):
|
||||
"""模拟 YOLO 模型的调用接口"""
|
||||
if not self.available:
|
||||
logger.error("Docker 不可用,无法运行检测")
|
||||
return [YOLOResult([])]
|
||||
|
||||
# 处理不同类型的输入
|
||||
if isinstance(source, str):
|
||||
image = cv2.imread(source)
|
||||
if image is None:
|
||||
logger.error(f"无法读取图片: {source}")
|
||||
return [YOLOResult([])]
|
||||
return self._detect_single(image, conf, verbose)
|
||||
|
||||
elif isinstance(source, np.ndarray):
|
||||
return self._detect_single(source, conf, verbose)
|
||||
|
||||
elif isinstance(source, list):
|
||||
results = []
|
||||
for img in source:
|
||||
if isinstance(img, str):
|
||||
img = cv2.imread(img)
|
||||
if img is not None:
|
||||
results.extend(self._detect_single(img, conf, verbose))
|
||||
return results
|
||||
|
||||
else:
|
||||
logger.error(f"不支持的输入类型: {type(source)}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def _detect_single(self, image: np.ndarray, conf: float, verbose: bool) -> List['YOLOResult']:
|
||||
"""检测单张图片(使用 HTTP API)"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 创建临时文件
|
||||
input_filename = f"smoking_fast_{int(time.time()*1000)}.jpg"
|
||||
temp_input = f"/tmp/{input_filename}"
|
||||
|
||||
# 保存输入图片
|
||||
cv2.imwrite(temp_input, image)
|
||||
|
||||
if verbose:
|
||||
logger.info(f"正在检测: {temp_input}")
|
||||
|
||||
# 发送 HTTP 请求
|
||||
request = {
|
||||
'image_path': f'/workspace/input/{input_filename}',
|
||||
'threshold': conf
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{self._server_url}/detect",
|
||||
json=request,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if verbose:
|
||||
logger.info(f"检测完成,耗时: {elapsed:.2f}秒")
|
||||
|
||||
# 解析结果
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('success'):
|
||||
detections = data.get('detections', [])
|
||||
else:
|
||||
logger.error(f"检测失败: {data.get('error')}")
|
||||
detections = []
|
||||
else:
|
||||
logger.error(f"HTTP 错误: {response.status_code}")
|
||||
detections = []
|
||||
|
||||
# 清理临时文件
|
||||
try:
|
||||
os.remove(temp_input)
|
||||
except:
|
||||
pass
|
||||
|
||||
return [YOLOResult(detections)]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检测失败: {e}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def predict(self, source, **kwargs):
|
||||
"""兼容 predict 方法"""
|
||||
return self.__call__(source, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def stop_daemon(cls):
|
||||
"""停止常驻服务器"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "stop", cls._container_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
logger.info("常驻服务器已停止")
|
||||
except Exception as e:
|
||||
logger.error(f"停止常驻服务器失败: {e}")
|
||||
|
||||
|
||||
# YOLOResult, Boxes, Box 类(与之前相同)
|
||||
class YOLOResult:
|
||||
"""模拟 YOLO 检测结果对象"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
self.names = {0: 'cigarette'}
|
||||
self.boxes = Boxes(detections)
|
||||
self.probs = None
|
||||
self.keypoints = None
|
||||
self.obb = None
|
||||
self.speed = {'preprocess': 0, 'inference': 0, 'postprocess': 0}
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __getitem__(self, idx):
|
||||
if idx < len(self.detections):
|
||||
return YOLOResult([self.detections[idx]])
|
||||
return YOLOResult([])
|
||||
|
||||
def plot(self, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class Boxes:
|
||||
"""模拟 YOLO boxes 对象"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = torch.tensor(xyxy_list, dtype=torch.float32)
|
||||
self.conf = torch.tensor(conf_list, dtype=torch.float32)
|
||||
self.cls = torch.tensor(cls_list, dtype=torch.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = torch.empty((0, 4))
|
||||
self.conf = torch.empty((0, 1))
|
||||
self.cls = torch.empty((0, 1), dtype=torch.int64)
|
||||
self.id = None
|
||||
|
||||
except ImportError:
|
||||
import numpy as np
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = np.array(xyxy_list, dtype=np.float32)
|
||||
self.conf = np.array(conf_list, dtype=np.float32)
|
||||
self.cls = np.array(cls_list, dtype=np.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = np.empty((0, 4), dtype=np.float32)
|
||||
self.conf = np.empty((0, 1), dtype=np.float32)
|
||||
self.cls = np.empty((0, 1), dtype=np.int64)
|
||||
self.id = None
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __iter__(self):
|
||||
for i in range(len(self.detections)):
|
||||
yield Box(self, i)
|
||||
|
||||
def cpu(self):
|
||||
return self
|
||||
|
||||
def numpy(self):
|
||||
if hasattr(self.xyxy, 'numpy'):
|
||||
return type('Boxes', (), {
|
||||
'xyxy': self.xyxy.numpy(),
|
||||
'conf': self.conf.numpy(),
|
||||
'cls': self.cls.numpy(),
|
||||
'id': self.id
|
||||
})()
|
||||
return self
|
||||
|
||||
|
||||
class Box:
|
||||
"""模拟单个检测框对象"""
|
||||
|
||||
def __init__(self, boxes: Boxes, index: int):
|
||||
self._boxes = boxes
|
||||
self._index = index
|
||||
|
||||
@property
|
||||
def xyxy(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
coords = self._boxes.xyxy[self._index]
|
||||
if isinstance(coords, torch.Tensor):
|
||||
return coords.unsqueeze(0)
|
||||
else:
|
||||
return np.array([coords])
|
||||
|
||||
@property
|
||||
def conf(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
conf_val = self._boxes.conf[self._index]
|
||||
if isinstance(conf_val, torch.Tensor):
|
||||
return conf_val.view(1)
|
||||
else:
|
||||
return np.array([conf_val])
|
||||
|
||||
@property
|
||||
def cls(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
cls_val = self._boxes.cls[self._index]
|
||||
if isinstance(cls_val, torch.Tensor):
|
||||
return cls_val.view(1)
|
||||
else:
|
||||
return np.array([cls_val])
|
||||
399
apps/server/models/smoking_yolo_adapter_optimized.py
Normal file
399
apps/server/models/smoking_yolo_adapter_optimized.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
YOLO 格式的抽烟检测模型适配器(优化版)
|
||||
使用后台 Docker 容器,避免重复启动
|
||||
"""
|
||||
|
||||
import os
|
||||
import cv2
|
||||
import numpy as np
|
||||
import subprocess
|
||||
import tempfile
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmokingDetectionYOLO:
|
||||
"""
|
||||
模拟 YOLO 接口的抽烟检测模型(优化版)
|
||||
使用后台 Docker 容器,避免每次检测都启动新容器
|
||||
"""
|
||||
|
||||
_container_name = "smoking-detection-server"
|
||||
_container_started = False
|
||||
|
||||
def __init__(self, model_path=None):
|
||||
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
|
||||
|
||||
# YOLO 兼容属性
|
||||
self.names = {0: 'cigarette'}
|
||||
self.model = self
|
||||
|
||||
# 检查 Docker 并启动后台容器
|
||||
self._check_docker()
|
||||
if self.available:
|
||||
self._start_background_container()
|
||||
|
||||
logger.info(f"抽烟检测模型初始化完成,Docker可用: {self.available}")
|
||||
|
||||
def _check_docker(self):
|
||||
"""检查 Docker 环境"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
if self.available:
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", self.docker_image],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Docker 检查失败: {e}")
|
||||
self.available = False
|
||||
|
||||
def _start_background_container(self):
|
||||
"""启动后台容器"""
|
||||
try:
|
||||
# 检查容器是否已在运行
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-q", "-f", f"name={self._container_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.stdout.strip():
|
||||
logger.info(f"后台容器已在运行: {self._container_name}")
|
||||
SmokingDetectionYOLO._container_started = True
|
||||
return
|
||||
|
||||
# 检查容器是否存在但已停止
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-aq", "-f", f"name={self._container_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.stdout.strip():
|
||||
# 启动已存在的容器
|
||||
logger.info(f"启动已存在的容器: {self._container_name}")
|
||||
subprocess.run(
|
||||
["docker", "start", self._container_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
else:
|
||||
# 创建新容器
|
||||
logger.info(f"创建后台容器: {self._container_name}")
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d",
|
||||
"--name", self._container_name,
|
||||
"-v", "/tmp:/workspace/input",
|
||||
"-v", "/tmp:/workspace/output",
|
||||
self.docker_image,
|
||||
"tail", "-f", "/dev/null"
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
SmokingDetectionYOLO._container_started = True
|
||||
logger.info("后台容器启动成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动后台容器失败: {e}")
|
||||
SmokingDetectionYOLO._container_started = False
|
||||
|
||||
def __call__(self, source, conf=0.1, iou=0.45, verbose=False, stream=False):
|
||||
"""模拟 YOLO 模型的调用接口"""
|
||||
if not self.available:
|
||||
logger.error("Docker 不可用,无法运行检测")
|
||||
return [YOLOResult([])]
|
||||
|
||||
# 处理不同类型的输入
|
||||
if isinstance(source, str):
|
||||
image = cv2.imread(source)
|
||||
if image is None:
|
||||
logger.error(f"无法读取图片: {source}")
|
||||
return [YOLOResult([])]
|
||||
return self._detect_single(image, conf, verbose)
|
||||
|
||||
elif isinstance(source, np.ndarray):
|
||||
return self._detect_single(source, conf, verbose)
|
||||
|
||||
elif isinstance(source, list):
|
||||
results = []
|
||||
for img in source:
|
||||
if isinstance(img, str):
|
||||
img = cv2.imread(img)
|
||||
if img is not None:
|
||||
results.extend(self._detect_single(img, conf, verbose))
|
||||
return results
|
||||
|
||||
else:
|
||||
logger.error(f"不支持的输入类型: {type(source)}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def _detect_single(self, image: np.ndarray, conf: float, verbose: bool) -> List['YOLOResult']:
|
||||
"""检测单张图片(使用后台容器)"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 创建临时文件
|
||||
input_filename = f"smoking_input_{int(time.time()*1000)}.jpg"
|
||||
output_filename = f"smoking_output_{int(time.time()*1000)}.jpg"
|
||||
temp_input = f"/tmp/{input_filename}"
|
||||
temp_output = f"/tmp/{output_filename}"
|
||||
|
||||
# 保存输入图片
|
||||
cv2.imwrite(temp_input, image)
|
||||
|
||||
if verbose:
|
||||
logger.info(f"正在检测: {temp_input}")
|
||||
|
||||
# 使用后台容器执行检测
|
||||
if SmokingDetectionYOLO._container_started:
|
||||
# 使用 exec 在运行中的容器内执行
|
||||
cmd = [
|
||||
"docker", "exec",
|
||||
self._container_name,
|
||||
"python", "deploy/python/infer.py",
|
||||
f"--model_dir={self.model_dir}",
|
||||
f"--image_file=/workspace/input/{input_filename}",
|
||||
"--device=CPU",
|
||||
f"--output_dir=/workspace/output",
|
||||
f"--threshold={conf}"
|
||||
]
|
||||
else:
|
||||
# 回退到原来的方式
|
||||
cmd = [
|
||||
"docker", "run", "--rm",
|
||||
"-v", f"{temp_input}:/workspace/input.jpg",
|
||||
self.docker_image,
|
||||
"python", "deploy/python/infer.py",
|
||||
f"--model_dir={self.model_dir}",
|
||||
"--image_file=/workspace/input.jpg",
|
||||
"--device=CPU",
|
||||
"--output_dir=/workspace",
|
||||
f"--threshold={conf}"
|
||||
]
|
||||
|
||||
# 执行检测
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if verbose:
|
||||
logger.info(f"检测完成,耗时: {elapsed:.2f}秒")
|
||||
|
||||
# 解析结果
|
||||
detections = self._parse_output(result.stdout)
|
||||
|
||||
# 清理临时文件
|
||||
try:
|
||||
os.remove(temp_input)
|
||||
except:
|
||||
pass
|
||||
|
||||
return [YOLOResult(detections)]
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("检测超时")
|
||||
return [YOLOResult([])]
|
||||
except Exception as e:
|
||||
logger.error(f"检测失败: {e}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def _parse_output(self, output: str) -> List[Dict]:
|
||||
"""解析检测输出"""
|
||||
detections = []
|
||||
import re
|
||||
|
||||
pattern = r'class_id:\d+,\s*confidence:([\d.]+),\s*left_top:\[([\d.]+),\s*([\d.]+)\],\s*right_bottom:\[([\d.]+),\s*([\d.]+)\]'
|
||||
|
||||
for line in output.split('\n'):
|
||||
match = re.search(pattern, line)
|
||||
if match:
|
||||
try:
|
||||
confidence = float(match.group(1))
|
||||
x1 = float(match.group(2))
|
||||
y1 = float(match.group(3))
|
||||
x2 = float(match.group(4))
|
||||
y2 = float(match.group(5))
|
||||
|
||||
detections.append({
|
||||
'bbox': [int(x1), int(y1), int(x2), int(y2)],
|
||||
'confidence': confidence,
|
||||
'class': 0,
|
||||
'name': 'cigarette'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"解析检测结果失败: {e}, line: {line}")
|
||||
continue
|
||||
|
||||
return detections
|
||||
|
||||
def predict(self, source, **kwargs):
|
||||
"""兼容 predict 方法"""
|
||||
return self.__call__(source, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def stop_background_container(cls):
|
||||
"""停止后台容器"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "stop", cls._container_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
logger.info("后台容器已停止")
|
||||
except Exception as e:
|
||||
logger.error(f"停止后台容器失败: {e}")
|
||||
|
||||
|
||||
# 复制 YOLOResult, Boxes, Box 类(与原版相同)
|
||||
class YOLOResult:
|
||||
"""模拟 YOLO 检测结果对象"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
self.names = {0: 'cigarette'}
|
||||
self.boxes = Boxes(detections)
|
||||
self.probs = None
|
||||
self.keypoints = None
|
||||
self.obb = None
|
||||
self.speed = {'preprocess': 0, 'inference': 0, 'postprocess': 0}
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __getitem__(self, idx):
|
||||
if idx < len(self.detections):
|
||||
return YOLOResult([self.detections[idx]])
|
||||
return YOLOResult([])
|
||||
|
||||
def plot(self, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class Boxes:
|
||||
"""模拟 YOLO boxes 对象"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = torch.tensor(xyxy_list, dtype=torch.float32)
|
||||
self.conf = torch.tensor(conf_list, dtype=torch.float32)
|
||||
self.cls = torch.tensor(cls_list, dtype=torch.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = torch.empty((0, 4))
|
||||
self.conf = torch.empty((0, 1))
|
||||
self.cls = torch.empty((0, 1), dtype=torch.int64)
|
||||
self.id = None
|
||||
|
||||
except ImportError:
|
||||
import numpy as np
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = np.array(xyxy_list, dtype=np.float32)
|
||||
self.conf = np.array(conf_list, dtype=np.float32)
|
||||
self.cls = np.array(cls_list, dtype=np.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = np.empty((0, 4), dtype=np.float32)
|
||||
self.conf = np.empty((0, 1), dtype=np.float32)
|
||||
self.cls = np.empty((0, 1), dtype=np.int64)
|
||||
self.id = None
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __iter__(self):
|
||||
for i in range(len(self.detections)):
|
||||
yield Box(self, i)
|
||||
|
||||
def cpu(self):
|
||||
return self
|
||||
|
||||
def numpy(self):
|
||||
if hasattr(self.xyxy, 'numpy'):
|
||||
return type('Boxes', (), {
|
||||
'xyxy': self.xyxy.numpy(),
|
||||
'conf': self.conf.numpy(),
|
||||
'cls': self.cls.numpy(),
|
||||
'id': self.id
|
||||
})()
|
||||
return self
|
||||
|
||||
|
||||
class Box:
|
||||
"""模拟单个检测框对象"""
|
||||
|
||||
def __init__(self, boxes: Boxes, index: int):
|
||||
self._boxes = boxes
|
||||
self._index = index
|
||||
|
||||
@property
|
||||
def xyxy(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
coords = self._boxes.xyxy[self._index]
|
||||
if isinstance(coords, torch.Tensor):
|
||||
return coords.unsqueeze(0)
|
||||
else:
|
||||
return np.array([coords])
|
||||
|
||||
@property
|
||||
def conf(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
conf_val = self._boxes.conf[self._index]
|
||||
if isinstance(conf_val, torch.Tensor):
|
||||
return conf_val.view(1)
|
||||
else:
|
||||
return np.array([conf_val])
|
||||
|
||||
@property
|
||||
def cls(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
cls_val = self._boxes.cls[self._index]
|
||||
if isinstance(cls_val, torch.Tensor):
|
||||
return cls_val.view(1)
|
||||
else:
|
||||
return np.array([cls_val])
|
||||
379
apps/server/models/smoking_yolo_adapter_v2.py
Normal file
379
apps/server/models/smoking_yolo_adapter_v2.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
YOLO 格式的抽烟检测模型适配器(V2 - 常驻进程版)
|
||||
使用 Docker 容器内的常驻 Python 进程,避免每次检测都启动新进程
|
||||
"""
|
||||
|
||||
import os
|
||||
import cv2
|
||||
import numpy as np
|
||||
import subprocess
|
||||
import tempfile
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmokingDetectionYOLO:
|
||||
"""
|
||||
模拟 YOLO 接口的抽烟检测模型(V2 - 常驻进程版)
|
||||
使用 Docker 容器内的常驻 Python 进程
|
||||
"""
|
||||
|
||||
_container_name = "smoking-detection-v2"
|
||||
_process = None
|
||||
_initialized = False
|
||||
|
||||
def __init__(self, model_path=None):
|
||||
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
|
||||
|
||||
# YOLO 兼容属性
|
||||
self.names = {0: 'cigarette'}
|
||||
self.model = self
|
||||
|
||||
# 检查 Docker 并启动常驻进程
|
||||
self._check_docker()
|
||||
if self.available:
|
||||
self._start_server()
|
||||
|
||||
logger.info(f"抽烟检测模型 V2 初始化完成,Docker可用: {self.available}")
|
||||
|
||||
def _check_docker(self):
|
||||
"""检查 Docker 环境"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
if self.available:
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", self.docker_image],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Docker 检查失败: {e}")
|
||||
self.available = False
|
||||
|
||||
def _start_server(self):
|
||||
"""启动常驻服务器进程"""
|
||||
try:
|
||||
# 检查是否已有进程在运行
|
||||
if SmokingDetectionYOLO._process is not None:
|
||||
logger.info("常驻进程已在运行")
|
||||
return
|
||||
|
||||
# 检查容器是否已存在
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-aq", "-f", f"name={self._container_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.stdout.strip():
|
||||
# 删除旧容器
|
||||
logger.info("删除旧容器")
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", self._container_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# 启动新容器并运行服务器
|
||||
logger.info("启动常驻服务器...")
|
||||
|
||||
# 获取 smoking_server.py 的绝对路径
|
||||
server_script_path = "/Users/wwh/project/video-model/PaddlePaddle/PaddleDetection-release-2.9/smoking_server.py"
|
||||
|
||||
# 使用 Popen 保持进程运行,挂载 server 脚本
|
||||
SmokingDetectionYOLO._process = subprocess.Popen(
|
||||
[
|
||||
"docker", "run", "-i", "--rm",
|
||||
"--name", self._container_name,
|
||||
"-v", "/tmp:/workspace/input",
|
||||
"-v", f"{server_script_path}:/workspace/PaddleDetection/smoking_server.py",
|
||||
"-w", "/workspace/PaddleDetection",
|
||||
self.docker_image,
|
||||
"python", "smoking_server.py"
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
# 等待服务器启动(读取模型加载完成的消息)
|
||||
logger.info("等待服务器启动...")
|
||||
start_wait = time.time()
|
||||
while time.time() - start_wait < 30: # 最多等待30秒
|
||||
if SmokingDetectionYOLO._process.poll() is not None:
|
||||
# 进程已退出
|
||||
stderr = SmokingDetectionYOLO._process.stderr.read()
|
||||
logger.error(f"服务器启动失败: {stderr}")
|
||||
SmokingDetectionYOLO._process = None
|
||||
return
|
||||
|
||||
# 尝试读取 stderr 看是否加载完成
|
||||
import select
|
||||
if SmokingDetectionYOLO._process.stderr:
|
||||
ready, _, _ = select.select([SmokingDetectionYOLO._process.stderr], [], [], 0.5)
|
||||
if ready:
|
||||
line = SmokingDetectionYOLO._process.stderr.readline()
|
||||
if line:
|
||||
logger.info(f"Server: {line.strip()}")
|
||||
if "模型加载完成" in line:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
# 检查进程是否还在运行
|
||||
if SmokingDetectionYOLO._process.poll() is None:
|
||||
SmokingDetectionYOLO._initialized = True
|
||||
logger.info("常驻服务器启动成功")
|
||||
else:
|
||||
stderr = SmokingDetectionYOLO._process.stderr.read()
|
||||
logger.error(f"服务器启动失败: {stderr}")
|
||||
SmokingDetectionYOLO._process = None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动常驻服务器失败: {e}")
|
||||
SmokingDetectionYOLO._process = None
|
||||
|
||||
def __call__(self, source, conf=0.1, iou=0.45, verbose=False, stream=False):
|
||||
"""模拟 YOLO 模型的调用接口"""
|
||||
if not self.available:
|
||||
logger.error("Docker 不可用,无法运行检测")
|
||||
return [YOLOResult([])]
|
||||
|
||||
if not SmokingDetectionYOLO._initialized:
|
||||
logger.error("常驻服务器未初始化")
|
||||
return [YOLOResult([])]
|
||||
|
||||
# 处理不同类型的输入
|
||||
if isinstance(source, str):
|
||||
image = cv2.imread(source)
|
||||
if image is None:
|
||||
logger.error(f"无法读取图片: {source}")
|
||||
return [YOLOResult([])]
|
||||
return self._detect_single(image, conf, verbose)
|
||||
|
||||
elif isinstance(source, np.ndarray):
|
||||
return self._detect_single(source, conf, verbose)
|
||||
|
||||
elif isinstance(source, list):
|
||||
results = []
|
||||
for img in source:
|
||||
if isinstance(img, str):
|
||||
img = cv2.imread(img)
|
||||
if img is not None:
|
||||
results.extend(self._detect_single(img, conf, verbose))
|
||||
return results
|
||||
|
||||
else:
|
||||
logger.error(f"不支持的输入类型: {type(source)}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def _detect_single(self, image: np.ndarray, conf: float, verbose: bool) -> List['YOLOResult']:
|
||||
"""检测单张图片(使用常驻进程)"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 创建临时文件
|
||||
input_filename = f"smoking_v2_{int(time.time()*1000)}.jpg"
|
||||
temp_input = f"/tmp/{input_filename}"
|
||||
|
||||
# 保存输入图片
|
||||
cv2.imwrite(temp_input, image)
|
||||
|
||||
if verbose:
|
||||
logger.info(f"正在检测: {temp_input}")
|
||||
|
||||
# 发送请求到常驻进程
|
||||
request = {
|
||||
'image_path': f'/workspace/input/{input_filename}',
|
||||
'threshold': conf
|
||||
}
|
||||
|
||||
SmokingDetectionYOLO._process.stdin.write(json.dumps(request) + '\n')
|
||||
SmokingDetectionYOLO._process.stdin.flush()
|
||||
|
||||
# 读取响应
|
||||
response_line = SmokingDetectionYOLO._process.stdout.readline()
|
||||
response = json.loads(response_line)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if verbose:
|
||||
logger.info(f"检测完成,耗时: {elapsed:.2f}秒")
|
||||
|
||||
# 解析结果
|
||||
if response.get('success'):
|
||||
detections = response.get('detections', [])
|
||||
else:
|
||||
logger.error(f"检测失败: {response.get('error')}")
|
||||
detections = []
|
||||
|
||||
# 清理临时文件
|
||||
try:
|
||||
os.remove(temp_input)
|
||||
except:
|
||||
pass
|
||||
|
||||
return [YOLOResult(detections)]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"检测失败: {e}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def predict(self, source, **kwargs):
|
||||
"""兼容 predict 方法"""
|
||||
return self.__call__(source, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def stop_server(cls):
|
||||
"""停止常驻服务器"""
|
||||
if cls._process is not None:
|
||||
cls._process.terminate()
|
||||
cls._process.wait()
|
||||
cls._process = None
|
||||
cls._initialized = False
|
||||
logger.info("常驻服务器已停止")
|
||||
|
||||
|
||||
# YOLOResult, Boxes, Box 类(与之前相同)
|
||||
class YOLOResult:
|
||||
"""模拟 YOLO 检测结果对象"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
self.names = {0: 'cigarette'}
|
||||
self.boxes = Boxes(detections)
|
||||
self.probs = None
|
||||
self.keypoints = None
|
||||
self.obb = None
|
||||
self.speed = {'preprocess': 0, 'inference': 0, 'postprocess': 0}
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __getitem__(self, idx):
|
||||
if idx < len(self.detections):
|
||||
return YOLOResult([self.detections[idx]])
|
||||
return YOLOResult([])
|
||||
|
||||
def plot(self, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class Boxes:
|
||||
"""模拟 YOLO boxes 对象"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = torch.tensor(xyxy_list, dtype=torch.float32)
|
||||
self.conf = torch.tensor(conf_list, dtype=torch.float32)
|
||||
self.cls = torch.tensor(cls_list, dtype=torch.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = torch.empty((0, 4))
|
||||
self.conf = torch.empty((0, 1))
|
||||
self.cls = torch.empty((0, 1), dtype=torch.int64)
|
||||
self.id = None
|
||||
|
||||
except ImportError:
|
||||
import numpy as np
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = np.array(xyxy_list, dtype=np.float32)
|
||||
self.conf = np.array(conf_list, dtype=np.float32)
|
||||
self.cls = np.array(cls_list, dtype=np.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = np.empty((0, 4), dtype=np.float32)
|
||||
self.conf = np.empty((0, 1), dtype=np.float32)
|
||||
self.cls = np.empty((0, 1), dtype=np.int64)
|
||||
self.id = None
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __iter__(self):
|
||||
for i in range(len(self.detections)):
|
||||
yield Box(self, i)
|
||||
|
||||
def cpu(self):
|
||||
return self
|
||||
|
||||
def numpy(self):
|
||||
if hasattr(self.xyxy, 'numpy'):
|
||||
return type('Boxes', (), {
|
||||
'xyxy': self.xyxy.numpy(),
|
||||
'conf': self.conf.numpy(),
|
||||
'cls': self.cls.numpy(),
|
||||
'id': self.id
|
||||
})()
|
||||
return self
|
||||
|
||||
|
||||
class Box:
|
||||
"""模拟单个检测框对象"""
|
||||
|
||||
def __init__(self, boxes: Boxes, index: int):
|
||||
self._boxes = boxes
|
||||
self._index = index
|
||||
|
||||
@property
|
||||
def xyxy(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
coords = self._boxes.xyxy[self._index]
|
||||
if isinstance(coords, torch.Tensor):
|
||||
return coords.unsqueeze(0)
|
||||
else:
|
||||
return np.array([coords])
|
||||
|
||||
@property
|
||||
def conf(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
conf_val = self._boxes.conf[self._index]
|
||||
if isinstance(conf_val, torch.Tensor):
|
||||
return conf_val.view(1)
|
||||
else:
|
||||
return np.array([conf_val])
|
||||
|
||||
@property
|
||||
def cls(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
cls_val = self._boxes.cls[self._index]
|
||||
if isinstance(cls_val, torch.Tensor):
|
||||
return cls_val.view(1)
|
||||
else:
|
||||
return np.array([cls_val])
|
||||
377
apps/server/models/smoking_yolo_adapter_v2_simple.py
Normal file
377
apps/server/models/smoking_yolo_adapter_v2_simple.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
YOLO 格式的抽烟检测模型适配器(V2 简化版)
|
||||
使用 Docker exec 在后台容器中执行检测
|
||||
"""
|
||||
|
||||
import os
|
||||
import cv2
|
||||
import numpy as np
|
||||
import subprocess
|
||||
import tempfile
|
||||
import logging
|
||||
import time
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmokingDetectionYOLO:
|
||||
"""
|
||||
模拟 YOLO 接口的抽烟检测模型(V2 简化版)
|
||||
使用 Docker exec 在后台容器中执行检测
|
||||
"""
|
||||
|
||||
_container_name = "smoking-detection-server"
|
||||
_initialized = False
|
||||
|
||||
def __init__(self, model_path=None):
|
||||
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
|
||||
|
||||
# YOLO 兼容属性
|
||||
self.names = {0: 'cigarette'}
|
||||
self.model = self
|
||||
|
||||
# 检查 Docker 并启动后台容器
|
||||
self._check_docker()
|
||||
if self.available:
|
||||
self._start_background_container()
|
||||
|
||||
logger.info(f"抽烟检测模型 V2 简化版初始化完成,Docker可用: {self.available}")
|
||||
|
||||
def _check_docker(self):
|
||||
"""检查 Docker 环境"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
if self.available:
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", self.docker_image],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
self.available = result.returncode == 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Docker 检查失败: {e}")
|
||||
self.available = False
|
||||
|
||||
def _start_background_container(self):
|
||||
"""启动后台容器"""
|
||||
try:
|
||||
# 检查容器是否已在运行
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-q", "-f", f"name={self._container_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.stdout.strip():
|
||||
logger.info(f"后台容器已在运行: {self._container_name}")
|
||||
SmokingDetectionYOLO._initialized = True
|
||||
return
|
||||
|
||||
# 检查容器是否存在但已停止
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-aq", "-f", f"name={self._container_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.stdout.strip():
|
||||
# 启动已存在的容器
|
||||
logger.info(f"启动已存在的容器: {self._container_name}")
|
||||
subprocess.run(
|
||||
["docker", "start", self._container_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
else:
|
||||
# 创建新容器(保持运行)
|
||||
logger.info(f"创建后台容器: {self._container_name}")
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "run", "-d",
|
||||
"--name", self._container_name,
|
||||
"-v", "/tmp:/workspace/input",
|
||||
"-v", "/Users/wwh/project/video-model/PaddlePaddle/PaddleDetection-release-2.9/smoking_server.py:/workspace/PaddleDetection/smoking_server.py",
|
||||
"-w", "/workspace/PaddleDetection",
|
||||
self.docker_image,
|
||||
"tail", "-f", "/dev/null"
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
SmokingDetectionYOLO._initialized = True
|
||||
logger.info("后台容器启动成功")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"启动后台容器失败: {e}")
|
||||
SmokingDetectionYOLO._initialized = False
|
||||
|
||||
def __call__(self, source, conf=0.1, iou=0.45, verbose=False, stream=False):
|
||||
"""模拟 YOLO 模型的调用接口"""
|
||||
if not self.available:
|
||||
logger.error("Docker 不可用,无法运行检测")
|
||||
return [YOLOResult([])]
|
||||
|
||||
# 处理不同类型的输入
|
||||
if isinstance(source, str):
|
||||
image = cv2.imread(source)
|
||||
if image is None:
|
||||
logger.error(f"无法读取图片: {source}")
|
||||
return [YOLOResult([])]
|
||||
return self._detect_single(image, conf, verbose)
|
||||
|
||||
elif isinstance(source, np.ndarray):
|
||||
return self._detect_single(source, conf, verbose)
|
||||
|
||||
elif isinstance(source, list):
|
||||
results = []
|
||||
for img in source:
|
||||
if isinstance(img, str):
|
||||
img = cv2.imread(img)
|
||||
if img is not None:
|
||||
results.extend(self._detect_single(img, conf, verbose))
|
||||
return results
|
||||
|
||||
else:
|
||||
logger.error(f"不支持的输入类型: {type(source)}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def _detect_single(self, image: np.ndarray, conf: float, verbose: bool) -> List['YOLOResult']:
|
||||
"""检测单张图片(使用 docker exec)"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 创建临时文件
|
||||
input_filename = f"smoking_v2_{int(time.time()*1000)}.jpg"
|
||||
temp_input = f"/tmp/{input_filename}"
|
||||
|
||||
# 保存输入图片
|
||||
cv2.imwrite(temp_input, image)
|
||||
|
||||
if verbose:
|
||||
logger.info(f"正在检测: {temp_input}")
|
||||
|
||||
# 使用 docker exec 执行检测
|
||||
cmd = [
|
||||
"docker", "exec", "-i",
|
||||
self._container_name,
|
||||
"python", "smoking_server.py"
|
||||
]
|
||||
|
||||
# 发送请求
|
||||
request = {
|
||||
'image_path': f'/workspace/input/{input_filename}',
|
||||
'threshold': conf
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
input=json.dumps(request) + '\n',
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if verbose:
|
||||
logger.info(f"检测完成,耗时: {elapsed:.2f}秒")
|
||||
|
||||
# 解析结果
|
||||
try:
|
||||
# 找到最后一行 JSON 输出
|
||||
lines = result.stdout.strip().split('\n')
|
||||
json_line = None
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if line.startswith('{'):
|
||||
json_line = line
|
||||
break
|
||||
|
||||
if json_line:
|
||||
response = json.loads(json_line)
|
||||
if response.get('success'):
|
||||
detections = response.get('detections', [])
|
||||
else:
|
||||
logger.error(f"检测失败: {response.get('error')}")
|
||||
detections = []
|
||||
else:
|
||||
logger.error(f"无法解析输出: {result.stdout}")
|
||||
detections = []
|
||||
except Exception as e:
|
||||
logger.error(f"解析结果失败: {e}, stdout: {result.stdout}")
|
||||
detections = []
|
||||
|
||||
# 清理临时文件
|
||||
try:
|
||||
os.remove(temp_input)
|
||||
except:
|
||||
pass
|
||||
|
||||
return [YOLOResult(detections)]
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("检测超时")
|
||||
return [YOLOResult([])]
|
||||
except Exception as e:
|
||||
logger.error(f"检测失败: {e}")
|
||||
return [YOLOResult([])]
|
||||
|
||||
def predict(self, source, **kwargs):
|
||||
"""兼容 predict 方法"""
|
||||
return self.__call__(source, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def stop_server(cls):
|
||||
"""停止后台容器"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "stop", cls._container_name],
|
||||
capture_output=True,
|
||||
timeout=10
|
||||
)
|
||||
logger.info("后台容器已停止")
|
||||
except Exception as e:
|
||||
logger.error(f"停止后台容器失败: {e}")
|
||||
|
||||
|
||||
# YOLOResult, Boxes, Box 类(与之前相同)
|
||||
class YOLOResult:
|
||||
"""模拟 YOLO 检测结果对象"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
self.names = {0: 'cigarette'}
|
||||
self.boxes = Boxes(detections)
|
||||
self.probs = None
|
||||
self.keypoints = None
|
||||
self.obb = None
|
||||
self.speed = {'preprocess': 0, 'inference': 0, 'postprocess': 0}
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __getitem__(self, idx):
|
||||
if idx < len(self.detections):
|
||||
return YOLOResult([self.detections[idx]])
|
||||
return YOLOResult([])
|
||||
|
||||
def plot(self, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class Boxes:
|
||||
"""模拟 YOLO boxes 对象"""
|
||||
|
||||
def __init__(self, detections: List[Dict]):
|
||||
self.detections = detections
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = torch.tensor(xyxy_list, dtype=torch.float32)
|
||||
self.conf = torch.tensor(conf_list, dtype=torch.float32)
|
||||
self.cls = torch.tensor(cls_list, dtype=torch.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = torch.empty((0, 4))
|
||||
self.conf = torch.empty((0, 1))
|
||||
self.cls = torch.empty((0, 1), dtype=torch.int64)
|
||||
self.id = None
|
||||
|
||||
except ImportError:
|
||||
import numpy as np
|
||||
|
||||
if detections:
|
||||
xyxy_list = [[d['bbox'][0], d['bbox'][1], d['bbox'][2], d['bbox'][3]] for d in detections]
|
||||
conf_list = [[d['confidence']] for d in detections]
|
||||
cls_list = [[d['class']] for d in detections]
|
||||
|
||||
self.xyxy = np.array(xyxy_list, dtype=np.float32)
|
||||
self.conf = np.array(conf_list, dtype=np.float32)
|
||||
self.cls = np.array(cls_list, dtype=np.int64)
|
||||
self.id = None
|
||||
else:
|
||||
self.xyxy = np.empty((0, 4), dtype=np.float32)
|
||||
self.conf = np.empty((0, 1), dtype=np.float32)
|
||||
self.cls = np.empty((0, 1), dtype=np.int64)
|
||||
self.id = None
|
||||
|
||||
def __len__(self):
|
||||
return len(self.detections)
|
||||
|
||||
def __iter__(self):
|
||||
for i in range(len(self.detections)):
|
||||
yield Box(self, i)
|
||||
|
||||
def cpu(self):
|
||||
return self
|
||||
|
||||
def numpy(self):
|
||||
if hasattr(self.xyxy, 'numpy'):
|
||||
return type('Boxes', (), {
|
||||
'xyxy': self.xyxy.numpy(),
|
||||
'conf': self.conf.numpy(),
|
||||
'cls': self.cls.numpy(),
|
||||
'id': self.id
|
||||
})()
|
||||
return self
|
||||
|
||||
|
||||
class Box:
|
||||
"""模拟单个检测框对象"""
|
||||
|
||||
def __init__(self, boxes: Boxes, index: int):
|
||||
self._boxes = boxes
|
||||
self._index = index
|
||||
|
||||
@property
|
||||
def xyxy(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
coords = self._boxes.xyxy[self._index]
|
||||
if isinstance(coords, torch.Tensor):
|
||||
return coords.unsqueeze(0)
|
||||
else:
|
||||
return np.array([coords])
|
||||
|
||||
@property
|
||||
def conf(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
conf_val = self._boxes.conf[self._index]
|
||||
if isinstance(conf_val, torch.Tensor):
|
||||
return conf_val.view(1)
|
||||
else:
|
||||
return np.array([conf_val])
|
||||
|
||||
@property
|
||||
def cls(self):
|
||||
import torch
|
||||
import numpy as np
|
||||
cls_val = self._boxes.cls[self._index]
|
||||
if isinstance(cls_val, torch.Tensor):
|
||||
return cls_val.view(1)
|
||||
else:
|
||||
return np.array([cls_val])
|
||||
17
apps/server/package.json
Normal file
17
apps/server/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "视频模型检测平台后端服务",
|
||||
"scripts": {
|
||||
"dev": "python main.py",
|
||||
"start": "uvicorn main:app --host 0.0.0.0 --port 8000",
|
||||
"lint": "ruff check .",
|
||||
"test": "pytest tests/",
|
||||
"clean": "rm -rf __pycache__ .pytest_cache"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {},
|
||||
"engines": {
|
||||
"python": ">=3.9"
|
||||
}
|
||||
}
|
||||
12
apps/server/requirements.txt
Normal file
12
apps/server/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
python-multipart>=0.0.6
|
||||
pydantic>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
aiofiles>=23.2.0
|
||||
opencv-python>=4.8.0
|
||||
pillow>=10.0.0
|
||||
ultralytics>=8.0.0
|
||||
numpy>=1.24.0
|
||||
torch>=2.0.0
|
||||
websockets>=12.0.0
|
||||
0
apps/server/services/__init__.py
Normal file
0
apps/server/services/__init__.py
Normal file
351
apps/server/services/camera_service.py
Normal file
351
apps/server/services/camera_service.py
Normal 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("摄像头服务已停止")
|
||||
199
apps/server/services/detection_service.py
Normal file
199
apps/server/services/detection_service.py
Normal 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
|
||||
}
|
||||
115
apps/server/services/model_service.py
Normal file
115
apps/server/services/model_service.py
Normal 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
|
||||
147
apps/server/services/model_service_updated.py
Normal file
147
apps/server/services/model_service_updated.py
Normal 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
|
||||
274
apps/server/services/paddle_detection_service.py
Normal file
274
apps/server/services/paddle_detection_service.py
Normal 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)
|
||||
13
apps/web/index.html
Normal file
13
apps/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>视频模型检测平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1843
apps/web/package-lock.json
generated
Normal file
1843
apps/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
apps/web/package.json
Normal file
24
apps/web/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"clean": "rm -rf dist node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5",
|
||||
"axios": "^1.6.2",
|
||||
"element-plus": "^2.4.4",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"pinia": "^2.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
71
apps/web/src/App.vue
Normal file
71
apps/web/src/App.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<el-container style="height: 100vh">
|
||||
<el-header class="header">
|
||||
<div class="header-content">
|
||||
<h1 class="title">🎯 视频模型检测平台</h1>
|
||||
<div class="header-info">
|
||||
<el-tag type="success">运行中</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-container>
|
||||
<router-view />
|
||||
</el-container>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
background: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
20
apps/web/src/api/detection.js
Normal file
20
apps/web/src/api/detection.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
export const detectionApi = {
|
||||
getModels() {
|
||||
return api.get('/models')
|
||||
},
|
||||
|
||||
detectImage(formData) {
|
||||
return api.post('/detect/image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
20
apps/web/src/main.js
Normal file
20
apps/web/src/main.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
17
apps/web/src/router/index.js
Normal file
17
apps/web/src/router/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '@/views/Home.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Home
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
809
apps/web/src/views/Home.vue
Normal file
809
apps/web/src/views/Home.vue
Normal file
@@ -0,0 +1,809 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<div class="resizable-layout">
|
||||
<!-- 左侧配置面板 -->
|
||||
<div class="left-panel" :style="{ width: leftPanelWidth + 'px' }">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span>检测配置</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-position="top" class="config-form">
|
||||
<el-form-item label="选择模型">
|
||||
<el-select
|
||||
v-model="config.model"
|
||||
placeholder="选择检测模型"
|
||||
class="full-width"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:label="model.name"
|
||||
:value="model.id"
|
||||
>
|
||||
<span>{{ model.name }}</span>
|
||||
<span class="model-size">{{ model.size }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="置信度阈值">
|
||||
<el-slider
|
||||
v-model="config.confidence"
|
||||
:min="0.1"
|
||||
:max="1.0"
|
||||
:step="0.05"
|
||||
:format-tooltip="formatConfidence"
|
||||
/>
|
||||
<div class="slider-value">{{ config.confidence.toFixed(2) }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="IOU阈值">
|
||||
<el-slider
|
||||
v-model="config.iou"
|
||||
:min="0.1"
|
||||
:max="0.9"
|
||||
:step="0.05"
|
||||
:format-tooltip="formatIOU"
|
||||
/>
|
||||
<div class="slider-value">{{ config.iou.toFixed(2) }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form-item label="输入方式">
|
||||
<el-radio-group v-model="inputType" class="input-type-group">
|
||||
<el-radio-button label="image">
|
||||
<el-icon><Picture /></el-icon>
|
||||
<span>图片</span>
|
||||
</el-radio-button>
|
||||
<el-radio-button label="camera">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
<span>摄像头</span>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-upload
|
||||
v-if="inputType === 'image'"
|
||||
:action="uploadUrl"
|
||||
:on-success="handleUploadSuccess"
|
||||
:before-upload="beforeUpload"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
drag
|
||||
class="upload-area"
|
||||
>
|
||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||
<div class="upload-text">点击或拖拽图片到此处上传</div>
|
||||
</el-upload>
|
||||
|
||||
<div v-if="inputType === 'camera'" class="camera-section">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="toggleCamera"
|
||||
:loading="cameraStarting"
|
||||
class="full-width"
|
||||
>
|
||||
<el-icon v-if="!cameraConnected"><VideoPlay /></el-icon>
|
||||
<el-icon v-else><VideoPause /></el-icon>
|
||||
{{ cameraConnected ? '停止摄像头' : '启用摄像头' }}
|
||||
</el-button>
|
||||
|
||||
<div v-if="cameraConnected" class="camera-status">
|
||||
<el-tag type="success" effect="dark">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
摄像头已连接
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽调整条 -->
|
||||
<div
|
||||
class="resize-handle"
|
||||
@mousedown="startResize"
|
||||
:class="{ 'resizing': isResizing }"
|
||||
>
|
||||
<div class="resize-indicator"></div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧展示区域 -->
|
||||
<div class="right-panel" :style="{ width: `calc(100% - ${leftPanelWidth}px - 8px)` }">
|
||||
<!-- 图片对比区域 -->
|
||||
<el-row :gutter="20" class="image-row">
|
||||
<el-col :xs="24" :sm="24" :md="12">
|
||||
<el-card class="image-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Picture /></el-icon>
|
||||
<span>{{ inputType === 'camera' ? '摄像头画面' : '原始图片' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="image-container">
|
||||
<img
|
||||
v-if="inputType === 'camera' && originalCameraFrame"
|
||||
:src="'data:image/jpeg;base64,' + originalCameraFrame"
|
||||
class="display-image"
|
||||
alt="摄像头画面"
|
||||
/>
|
||||
<img
|
||||
v-if="inputType === 'image' && originalImage"
|
||||
:src="originalImage"
|
||||
class="display-image"
|
||||
alt="原始图片"
|
||||
/>
|
||||
<div v-if="(inputType === 'camera' && !originalCameraFrame) || (inputType === 'image' && !originalImage)" class="placeholder">
|
||||
<el-icon class="placeholder-icon"><Picture /></el-icon>
|
||||
<p>{{ inputType === 'camera' ? '等待摄像头...' : '等待图片...' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :sm="24" :md="12">
|
||||
<el-card class="image-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><View /></el-icon>
|
||||
<span>检测结果</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="image-container">
|
||||
<img
|
||||
v-if="inputType === 'camera' && resultCameraFrame"
|
||||
:src="'data:image/jpeg;base64,' + resultCameraFrame"
|
||||
class="display-image"
|
||||
alt="检测结果"
|
||||
/>
|
||||
<img
|
||||
v-if="inputType === 'image' && resultImage"
|
||||
:src="resultImage"
|
||||
class="display-image"
|
||||
alt="检测结果"
|
||||
/>
|
||||
<div v-if="(inputType === 'camera' && !resultCameraFrame) || (inputType === 'image' && !resultImage)" class="placeholder">
|
||||
<el-icon class="placeholder-icon"><Document /></el-icon>
|
||||
<p>{{ inputType === 'camera' ? '等待摄像头画面...' : '等待检测结果...' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<el-card v-if="stats" class="stats-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span>检测统计</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="4" border class="stats-descriptions">
|
||||
<el-descriptions-item label="检测数量">
|
||||
<el-tag size="large" type="primary">{{ stats.totalDetections }} 个</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平均置信度">
|
||||
<el-tag size="large" :type="getConfidenceType(stats.avgConfidence)">
|
||||
{{ stats.avgConfidence.toFixed(2) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理时间">
|
||||
<el-tag size="large" type="info">{{ stats.processingTime.toFixed(2) }}s</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="使用模型">
|
||||
<el-tag size="large" type="success">{{ modelName }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="stats.fps" label="FPS" :span="4">
|
||||
<el-tag size="large" type="warning">{{ stats.fps }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 检测详情 -->
|
||||
<el-card v-if="detections.length > 0" class="details-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><List /></el-icon>
|
||||
<span>检测详情</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="detections" border class="details-table">
|
||||
<el-table-column prop="label" label="类别" min-width="120" />
|
||||
<el-table-column prop="confidence" label="置信度" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getConfidenceType(scope.row.confidence)">
|
||||
{{ scope.row.confidence }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bbox" label="位置" min-width="200">
|
||||
<template #default="scope">
|
||||
<code class="bbox-code">[{{ scope.row.bbox.join(', ') }}]</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
UploadFilled,
|
||||
Picture,
|
||||
Document,
|
||||
Setting,
|
||||
VideoCamera,
|
||||
VideoPlay,
|
||||
VideoPause,
|
||||
CircleCheck,
|
||||
View,
|
||||
DataLine,
|
||||
List
|
||||
} from '@element-plus/icons-vue'
|
||||
import { detectionApi } from '@/api/detection'
|
||||
|
||||
const config = ref({
|
||||
model: 'fire_detection',
|
||||
confidence: 0.5,
|
||||
iou: 0.45
|
||||
})
|
||||
|
||||
// 可拖拽调整宽度相关
|
||||
const leftPanelWidth = ref(320)
|
||||
const isResizing = ref(false)
|
||||
const startX = ref(0)
|
||||
const startWidth = ref(0)
|
||||
|
||||
const startResize = (e) => {
|
||||
isResizing.value = true
|
||||
startX.value = e.clientX
|
||||
startWidth.value = leftPanelWidth.value
|
||||
|
||||
document.addEventListener('mousemove', handleResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
const handleResize = (e) => {
|
||||
if (!isResizing.value) return
|
||||
const delta = e.clientX - startX.value
|
||||
const newWidth = startWidth.value + delta
|
||||
// 限制最小和最大宽度
|
||||
leftPanelWidth.value = Math.max(280, Math.min(500, newWidth))
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
const inputType = ref('image')
|
||||
const models = ref([])
|
||||
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 cameraConnected = ref(false)
|
||||
const cameraStarting = ref(false)
|
||||
const originalCameraFrame = ref('') // 原始摄像头画面
|
||||
const resultCameraFrame = ref('') // 检测结果画面(标注后)
|
||||
const websocket = ref(null)
|
||||
const cameraVideo = ref(null)
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const response = await detectionApi.getModels()
|
||||
models.value = response.data
|
||||
if (models.value.length > 0) {
|
||||
config.value.model = models.value[0].id
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载模型列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const formatConfidence = (value) => {
|
||||
return `置信度: ${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
const formatIOU = (value) => {
|
||||
return `IOU: ${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件')
|
||||
return false
|
||||
}
|
||||
originalImage.value = URL.createObjectURL(file)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleUploadSuccess = (response) => {
|
||||
if (response.success) {
|
||||
resultImage.value = response.data.image_url
|
||||
detections.value = response.data.detections
|
||||
stats.value = response.data.stats
|
||||
ElMessage.success('检测完成')
|
||||
} else {
|
||||
ElMessage.error(response.message)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCamera = async () => {
|
||||
if (cameraConnected.value) {
|
||||
stopCamera()
|
||||
} else {
|
||||
await startCamera()
|
||||
}
|
||||
}
|
||||
|
||||
const startCamera = async () => {
|
||||
cameraStarting.value = true
|
||||
try {
|
||||
websocket.value = new WebSocket('ws://localhost:8000/ws/camera')
|
||||
|
||||
websocket.value.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
cameraConnected.value = true
|
||||
cameraStarting.value = false
|
||||
|
||||
websocket.value.send(JSON.stringify({
|
||||
action: 'start',
|
||||
config: {
|
||||
model_id: config.value.model,
|
||||
confidence: config.value.confidence,
|
||||
iou: config.value.iou
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
websocket.value.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
console.log('WebSocket message received:', message)
|
||||
|
||||
if (message.type === 'original_frame') {
|
||||
originalCameraFrame.value = message.frame // 更新原始摄像头画面
|
||||
} else if (message.type === 'annotated_frame') {
|
||||
resultCameraFrame.value = message.frame // 更新检测结果画面(标注后)
|
||||
} else if (message.type === 'detection') {
|
||||
console.log('Detection result:', message)
|
||||
detections.value = message.detections
|
||||
stats.value = message.stats
|
||||
} else if (message.type === 'error') {
|
||||
ElMessage.error(message.message)
|
||||
} else if (message.type === 'camera_started') {
|
||||
ElMessage.success(message.message)
|
||||
}
|
||||
}
|
||||
|
||||
websocket.value.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
ElMessage.error('摄像头连接失败')
|
||||
cameraStarting.value = false
|
||||
cameraConnected.value = false
|
||||
}
|
||||
|
||||
websocket.value.onclose = () => {
|
||||
console.log('WebSocket closed')
|
||||
cameraConnected.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('启动摄像头失败')
|
||||
cameraStarting.value = false
|
||||
cameraConnected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopCamera = () => {
|
||||
if (websocket.value) {
|
||||
websocket.value.send(JSON.stringify({ action: 'stop' }))
|
||||
websocket.value.close()
|
||||
websocket.value = null
|
||||
}
|
||||
cameraConnected.value = false
|
||||
originalCameraFrame.value = ''
|
||||
resultCameraFrame.value = ''
|
||||
ElMessage.success('摄像头已停止')
|
||||
}
|
||||
|
||||
const updateCameraConfig = () => {
|
||||
if (websocket.value && cameraConnected.value) {
|
||||
websocket.value.send(JSON.stringify({
|
||||
action: 'update_config',
|
||||
config: {
|
||||
model_id: config.value.model,
|
||||
confidence: config.value.confidence,
|
||||
iou: config.value.iou
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const getConfidenceType = (confidence) => {
|
||||
if (confidence >= 0.8) return 'success'
|
||||
if (confidence >= 0.6) return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
const modelName = computed(() => {
|
||||
const model = models.value.find(m => m.id === config.value.model)
|
||||
return model ? model.name : config.value.model
|
||||
})
|
||||
|
||||
loadModels()
|
||||
|
||||
onUnmounted(() => {
|
||||
if (websocket.value) {
|
||||
websocket.value.close()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 容器样式 */
|
||||
.home-container {
|
||||
padding: 20px;
|
||||
min-height: calc(100vh - 60px);
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* 可拖拽布局 */
|
||||
.resizable-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: calc(100vh - 100px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-panel .config-card {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 拖拽调整条 */
|
||||
.resize-handle {
|
||||
width: 8px;
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
.resize-handle.resizing {
|
||||
background: #409eff;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.resize-indicator {
|
||||
width: 3px;
|
||||
height: 40px;
|
||||
background: #c0c4cc;
|
||||
border-radius: 2px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.resize-handle:hover .resize-indicator,
|
||||
.resize-handle.resizing .resize-indicator {
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 卡片通用样式 */
|
||||
:deep(.el-card) {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
/* 卡片头部样式 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 配置卡片样式 */
|
||||
.config-card {
|
||||
height: auto;
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.config-card :deep(.el-card__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.config-form :deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.config-form :deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 模型选择器 */
|
||||
.model-size {
|
||||
float: right;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 滑块样式 */
|
||||
.slider-value {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
margin-top: 8px;
|
||||
padding: 6px 12px;
|
||||
background: #ecf5ff;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* 输入方式选择 */
|
||||
.input-type-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.input-type-group :deep(.el-radio-button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-type-group :deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 上传区域 */
|
||||
.upload-area {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.upload-area :deep(.el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-area :deep(.el-upload-dragger) {
|
||||
width: 100%;
|
||||
padding: 40px 20px;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-area :deep(.el-upload-dragger:hover) {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48px;
|
||||
color: #409eff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 摄像头区域 */
|
||||
.camera-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.camera-status {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.camera-status :deep(.el-tag) {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 图片展示区域 */
|
||||
.image-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.image-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
border-radius: 0 0 12px 12px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.display-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.display-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 64px;
|
||||
color: #dcdfe6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.placeholder p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-card :deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-descriptions :deep(.el-descriptions__cell) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-descriptions :deep(.el-tag) {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 详情卡片 */
|
||||
.details-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.details-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.details-table :deep(.el-table__header) {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.details-table :deep(.el-table__header th) {
|
||||
background: #f5f7fa;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.bbox-code {
|
||||
background: #f5f7fa;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 768px) {
|
||||
.home-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
max-height: none;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
aspect-ratio: 4 / 3;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.image-container {
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.input-type-group :deep(.el-radio-button__inner) {
|
||||
padding: 10px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
apps/web/vite.config.js
Normal file
29
apps/web/vite.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/static': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
31
docker/Dockerfile.server
Normal file
31
docker/Dockerfile.server
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libgl1-mesa-glx \
|
||||
libglib2.0-0 \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libxrender-dev \
|
||||
libgomp1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装 Python 依赖
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 创建静态文件目录
|
||||
RUN mkdir -p static/uploads static/results static/temp
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
31
docker/Dockerfile.web
Normal file
31
docker/Dockerfile.web
Normal file
@@ -0,0 +1,31 @@
|
||||
# 构建阶段
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 package.json
|
||||
COPY package.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建
|
||||
RUN npm run build
|
||||
|
||||
# 生产阶段
|
||||
FROM nginx:alpine
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制 nginx 配置
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
# 启动 nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
26
docker/docker-compose.yml
Normal file
26
docker/docker-compose.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ../apps/server
|
||||
dockerfile: ../../docker/Dockerfile.server
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ../models:/app/models:ro
|
||||
- ../apps/server/static:/app/static
|
||||
environment:
|
||||
- MODEL_PATH=/app/models
|
||||
- STATIC_PATH=/app/static
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../apps/web
|
||||
dockerfile: ../../docker/Dockerfile.web
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
40
docker/nginx.conf
Normal file
40
docker/nginx.conf
Normal file
@@ -0,0 +1,40 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 代理
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket 代理
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 静态文件代理
|
||||
location /static/ {
|
||||
proxy_pass http://backend:8000/static/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "jc-video-web",
|
||||
"version": "1.0.0",
|
||||
"description": "视频模型检测平台 - 基于YOLO的实时视频检测",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev:web": "turbo run dev --filter=web",
|
||||
"dev:server": "turbo run dev --filter=server",
|
||||
"build": "turbo run build",
|
||||
"build:web": "turbo run build --filter=web",
|
||||
"test": "turbo run test",
|
||||
"lint": "turbo run lint",
|
||||
"clean": "turbo run clean && rm -rf node_modules",
|
||||
"setup": "pnpm install && pnpm run setup:models",
|
||||
"setup:models": "bash scripts/setup-models.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
}
|
||||
15
packages/shared-types/package.json
Normal file
15
packages/shared-types/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@jc-video/shared-types",
|
||||
"version": "1.0.0",
|
||||
"description": "前后端共享类型定义",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
58
packages/shared-types/src/index.ts
Normal file
58
packages/shared-types/src/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// 检测相关类型
|
||||
|
||||
export interface Detection {
|
||||
class: string;
|
||||
label: string;
|
||||
confidence: number;
|
||||
bbox: [number, number, number, number];
|
||||
}
|
||||
|
||||
export interface DetectionStats {
|
||||
total_detections: number;
|
||||
avg_confidence: number;
|
||||
processing_time: number;
|
||||
model_used: string;
|
||||
fps?: number;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
classes: string[];
|
||||
labels: Record<string, string>;
|
||||
size: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DetectionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
detections: Detection[];
|
||||
image_url?: string;
|
||||
stats: DetectionStats;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetectionConfig {
|
||||
model_id: string;
|
||||
confidence: number;
|
||||
iou: number;
|
||||
}
|
||||
|
||||
// WebSocket 消息类型
|
||||
export interface WebSocketMessage {
|
||||
type: 'original_frame' | 'annotated_frame' | 'detection' | 'error' | 'camera_started' | 'camera_stopped' | 'config_updated';
|
||||
frame?: string;
|
||||
detections?: Detection[];
|
||||
stats?: DetectionStats;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CameraConfig {
|
||||
model_id: string;
|
||||
confidence: number;
|
||||
iou: number;
|
||||
camera_source?: number | string;
|
||||
}
|
||||
16
packages/shared-types/tsconfig.json
Normal file
16
packages/shared-types/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
21
scripts/dev.sh
Normal file
21
scripts/dev.sh
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 开发模式启动脚本
|
||||
|
||||
echo "🚀 启动开发服务器..."
|
||||
|
||||
# 使用 concurrently 同时启动前后端
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# 检查 concurrently
|
||||
if ! command -v concurrently &> /dev/null; then
|
||||
echo "📦 安装 concurrently..."
|
||||
pnpm add -D concurrently
|
||||
fi
|
||||
|
||||
# 启动前后端
|
||||
pnpm concurrently \
|
||||
--names "frontend,backend" \
|
||||
--prefix-colors "blue,green" \
|
||||
"cd apps/web && pnpm dev" \
|
||||
"cd apps/server && source venv/bin/activate && python main.py"
|
||||
83
scripts/setup.sh
Normal file
83
scripts/setup.sh
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 视频模型检测平台 - 初始化脚本
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 开始初始化 jc-video-web 项目..."
|
||||
|
||||
# 检查 pnpm
|
||||
if ! command -v pnpm &> /dev/null; then
|
||||
echo "📦 安装 pnpm..."
|
||||
npm install -g pnpm
|
||||
fi
|
||||
|
||||
# 安装根依赖
|
||||
echo "📦 安装根依赖..."
|
||||
cd "$(dirname "$0")/.."
|
||||
pnpm install
|
||||
|
||||
# 安装前端依赖
|
||||
echo "📦 安装前端依赖..."
|
||||
cd apps/web
|
||||
pnpm install
|
||||
cd ../..
|
||||
|
||||
# 检查 Python 虚拟环境
|
||||
if [ ! -d "apps/server/venv" ]; then
|
||||
echo "🐍 创建 Python 虚拟环境..."
|
||||
cd apps/server
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cd ../..
|
||||
else
|
||||
echo "✓ Python 虚拟环境已存在"
|
||||
fi
|
||||
|
||||
# 创建必要的目录
|
||||
echo "📁 创建必要的目录..."
|
||||
mkdir -p apps/server/static/{uploads,results,temp}
|
||||
mkdir -p models/{fire_detection,helmet_detection,crowd_detection,smoking_detection}
|
||||
|
||||
# 链接模型文件(如果不存在)
|
||||
echo "🔗 检查模型文件链接..."
|
||||
|
||||
# 火灾检测模型
|
||||
if [ ! -f "models/fire_detection/best.pt" ]; then
|
||||
if [ -f "../../fire_detection/models/best.pt" ]; then
|
||||
ln -sf "$(pwd)/../../fire_detection/models/best.pt" models/fire_detection/
|
||||
fi
|
||||
fi
|
||||
|
||||
# 安全帽检测模型
|
||||
if [ ! -f "models/helmet_detection/yolov8n.pt" ]; then
|
||||
if [ -f "../../yolov/yolov8n.pt" ]; then
|
||||
ln -sf "$(pwd)/../../yolov/yolov8n.pt" models/helmet_detection/
|
||||
fi
|
||||
fi
|
||||
|
||||
# 人群检测模型
|
||||
if [ ! -f "models/crowd_detection/yolov8l.pt" ]; then
|
||||
if [ -f "../../behavior_detection/Crowd-Gathering/models/yolov8l.pt" ]; then
|
||||
ln -sf "$(pwd)/../../behavior_detection/Crowd-Gathering/models/yolov8l.pt" models/crowd_detection/
|
||||
fi
|
||||
fi
|
||||
|
||||
# 抽烟检测模型
|
||||
if [ ! -f "models/smoking_detection/smoking_yolov8n.pt" ]; then
|
||||
if [ -f "../../behavior_detection/smoker-detection/models/smoking_yolov8n.pt" ]; then
|
||||
ln -sf "$(pwd)/../../behavior_detection/smoker-detection/models/smoking_yolov8n.pt" models/smoking_detection/
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ 初始化完成!"
|
||||
echo ""
|
||||
echo "📝 可用命令:"
|
||||
echo " pnpm dev - 同时启动前后端开发服务器"
|
||||
echo " pnpm dev:web - 只启动前端开发服务器"
|
||||
echo " pnpm dev:server - 只启动后端开发服务器"
|
||||
echo ""
|
||||
echo "🌐 访问地址:"
|
||||
echo " 前端: http://localhost:5173"
|
||||
echo " 后端: http://localhost:8000"
|
||||
23
turbo.json
Normal file
23
turbo.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalDependencies": ["**/.env.*local"],
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
},
|
||||
"test": {
|
||||
"outputs": []
|
||||
},
|
||||
"clean": {
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user