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