From 8fb58c75fefd3a8d1157992d0de31ce9b922a442 Mon Sep 17 00:00:00 2001 From: wwh <496479012@qq.com> Date: Mon, 18 May 2026 10:54:10 +0800 Subject: [PATCH] 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 --- .gitignore | 63 + README.md | 135 ++ apps/server/api/__init__.py | 0 apps/server/api/detection.py | 67 + apps/server/api/models.py | 28 + apps/server/main.py | 126 ++ apps/server/models/__init__.py | 0 apps/server/models/schemas.py | 39 + apps/server/models/smoking_yolo_adapter.py | 357 ++++ .../models/smoking_yolo_adapter_fast.py | 359 ++++ .../models/smoking_yolo_adapter_optimized.py | 399 ++++ apps/server/models/smoking_yolo_adapter_v2.py | 379 ++++ .../models/smoking_yolo_adapter_v2_simple.py | 377 ++++ apps/server/package.json | 17 + apps/server/requirements.txt | 12 + apps/server/services/__init__.py | 0 apps/server/services/camera_service.py | 351 ++++ apps/server/services/detection_service.py | 199 ++ apps/server/services/model_service.py | 115 + apps/server/services/model_service_updated.py | 147 ++ .../services/paddle_detection_service.py | 274 +++ apps/web/index.html | 13 + apps/web/package-lock.json | 1843 +++++++++++++++++ apps/web/package.json | 24 + apps/web/src/App.vue | 71 + apps/web/src/api/detection.js | 20 + apps/web/src/main.js | 20 + apps/web/src/router/index.js | 17 + apps/web/src/views/Home.vue | 809 ++++++++ apps/web/vite.config.js | 29 + docker/Dockerfile.server | 31 + docker/Dockerfile.web | 31 + docker/docker-compose.yml | 26 + docker/nginx.conf | 40 + package.json | 26 + packages/shared-types/package.json | 15 + packages/shared-types/src/index.ts | 58 + packages/shared-types/tsconfig.json | 16 + pnpm-workspace.yaml | 3 + scripts/dev.sh | 21 + scripts/setup.sh | 83 + turbo.json | 23 + 42 files changed, 6663 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/server/api/__init__.py create mode 100644 apps/server/api/detection.py create mode 100644 apps/server/api/models.py create mode 100644 apps/server/main.py create mode 100644 apps/server/models/__init__.py create mode 100644 apps/server/models/schemas.py create mode 100644 apps/server/models/smoking_yolo_adapter.py create mode 100644 apps/server/models/smoking_yolo_adapter_fast.py create mode 100644 apps/server/models/smoking_yolo_adapter_optimized.py create mode 100644 apps/server/models/smoking_yolo_adapter_v2.py create mode 100644 apps/server/models/smoking_yolo_adapter_v2_simple.py create mode 100644 apps/server/package.json create mode 100644 apps/server/requirements.txt create mode 100644 apps/server/services/__init__.py create mode 100644 apps/server/services/camera_service.py create mode 100644 apps/server/services/detection_service.py create mode 100644 apps/server/services/model_service.py create mode 100644 apps/server/services/model_service_updated.py create mode 100644 apps/server/services/paddle_detection_service.py create mode 100644 apps/web/index.html create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.vue create mode 100644 apps/web/src/api/detection.js create mode 100644 apps/web/src/main.js create mode 100644 apps/web/src/router/index.js create mode 100644 apps/web/src/views/Home.vue create mode 100644 apps/web/vite.config.js create mode 100644 docker/Dockerfile.server create mode 100644 docker/Dockerfile.web create mode 100644 docker/docker-compose.yml create mode 100644 docker/nginx.conf create mode 100644 package.json create mode 100644 packages/shared-types/package.json create mode 100644 packages/shared-types/src/index.ts create mode 100644 packages/shared-types/tsconfig.json create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/dev.sh create mode 100644 scripts/setup.sh create mode 100644 turbo.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fa3e98 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb91b89 --- /dev/null +++ b/README.md @@ -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) diff --git a/apps/server/api/__init__.py b/apps/server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/server/api/detection.py b/apps/server/api/detection.py new file mode 100644 index 0000000..95d2c17 --- /dev/null +++ b/apps/server/api/detection.py @@ -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={} + ) diff --git a/apps/server/api/models.py b/apps/server/api/models.py new file mode 100644 index 0000000..7d72329 --- /dev/null +++ b/apps/server/api/models.py @@ -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}"} diff --git a/apps/server/main.py b/apps/server/main.py new file mode 100644 index 0000000..ee6bd46 --- /dev/null +++ b/apps/server/main.py @@ -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"] + ) diff --git a/apps/server/models/__init__.py b/apps/server/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/server/models/schemas.py b/apps/server/models/schemas.py new file mode 100644 index 0000000..1c2cbdf --- /dev/null +++ b/apps/server/models/schemas.py @@ -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) diff --git a/apps/server/models/smoking_yolo_adapter.py b/apps/server/models/smoking_yolo_adapter.py new file mode 100644 index 0000000..07969d6 --- /dev/null +++ b/apps/server/models/smoking_yolo_adapter.py @@ -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]) diff --git a/apps/server/models/smoking_yolo_adapter_fast.py b/apps/server/models/smoking_yolo_adapter_fast.py new file mode 100644 index 0000000..b59e94e --- /dev/null +++ b/apps/server/models/smoking_yolo_adapter_fast.py @@ -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]) diff --git a/apps/server/models/smoking_yolo_adapter_optimized.py b/apps/server/models/smoking_yolo_adapter_optimized.py new file mode 100644 index 0000000..1107e71 --- /dev/null +++ b/apps/server/models/smoking_yolo_adapter_optimized.py @@ -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]) diff --git a/apps/server/models/smoking_yolo_adapter_v2.py b/apps/server/models/smoking_yolo_adapter_v2.py new file mode 100644 index 0000000..0af6117 --- /dev/null +++ b/apps/server/models/smoking_yolo_adapter_v2.py @@ -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]) diff --git a/apps/server/models/smoking_yolo_adapter_v2_simple.py b/apps/server/models/smoking_yolo_adapter_v2_simple.py new file mode 100644 index 0000000..62cb46c --- /dev/null +++ b/apps/server/models/smoking_yolo_adapter_v2_simple.py @@ -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]) diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..19084bd --- /dev/null +++ b/apps/server/package.json @@ -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" + } +} diff --git a/apps/server/requirements.txt b/apps/server/requirements.txt new file mode 100644 index 0000000..feaf4bc --- /dev/null +++ b/apps/server/requirements.txt @@ -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 diff --git a/apps/server/services/__init__.py b/apps/server/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/server/services/camera_service.py b/apps/server/services/camera_service.py new file mode 100644 index 0000000..5ddaecf --- /dev/null +++ b/apps/server/services/camera_service.py @@ -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("摄像头服务已停止") diff --git a/apps/server/services/detection_service.py b/apps/server/services/detection_service.py new file mode 100644 index 0000000..9aa0b9f --- /dev/null +++ b/apps/server/services/detection_service.py @@ -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 + } diff --git a/apps/server/services/model_service.py b/apps/server/services/model_service.py new file mode 100644 index 0000000..0657600 --- /dev/null +++ b/apps/server/services/model_service.py @@ -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 diff --git a/apps/server/services/model_service_updated.py b/apps/server/services/model_service_updated.py new file mode 100644 index 0000000..93e4f58 --- /dev/null +++ b/apps/server/services/model_service_updated.py @@ -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 diff --git a/apps/server/services/paddle_detection_service.py b/apps/server/services/paddle_detection_service.py new file mode 100644 index 0000000..962ef81 --- /dev/null +++ b/apps/server/services/paddle_detection_service.py @@ -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) diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..f6c33d9 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + 视频模型检测平台 + + +
+ + + diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 0000000..0bf10e6 --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,1843 @@ +{ + "name": "video-detection-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "video-detection-frontend", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.6.2", + "element-plus": "^2.4.4", + "pinia": "^2.1.7", + "vue": "^3.3.4", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.5.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.0.tgz", + "integrity": "sha512-POgH+TtoreaEKWqYYAVQyE6i8rQMEFqAEublyF29dBA5yASWPLKY6EzfeqBTr2Uv26mPss4vSrMrNPyaK7LX5w==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.8" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.9.tgz", + "integrity": "sha512-S3BiWYaLSzHxTpln665ELSrMR9UYmrIDUmhik7nVZxmJjTKL2/a+ew1hvGxksKelivm0ujjWfG1fYOiU/2e8rA==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..c369207 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue new file mode 100644 index 0000000..3cf5561 --- /dev/null +++ b/apps/web/src/App.vue @@ -0,0 +1,71 @@ + + + + + + + diff --git a/apps/web/src/api/detection.js b/apps/web/src/api/detection.js new file mode 100644 index 0000000..cb36f76 --- /dev/null +++ b/apps/web/src/api/detection.js @@ -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' + } + }) + } +} diff --git a/apps/web/src/main.js b/apps/web/src/main.js new file mode 100644 index 0000000..6094d85 --- /dev/null +++ b/apps/web/src/main.js @@ -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') diff --git a/apps/web/src/router/index.js b/apps/web/src/router/index.js new file mode 100644 index 0000000..d94af19 --- /dev/null +++ b/apps/web/src/router/index.js @@ -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 diff --git a/apps/web/src/views/Home.vue b/apps/web/src/views/Home.vue new file mode 100644 index 0000000..aee4c5b --- /dev/null +++ b/apps/web/src/views/Home.vue @@ -0,0 +1,809 @@ + + + + + diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js new file mode 100644 index 0000000..3c0980e --- /dev/null +++ b/apps/web/vite.config.js @@ -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 + } + } + } +}) diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server new file mode 100644 index 0000000..66610f2 --- /dev/null +++ b/docker/Dockerfile.server @@ -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"] diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..256ab88 --- /dev/null +++ b/docker/Dockerfile.web @@ -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;"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..5d681ac --- /dev/null +++ b/docker/docker-compose.yml @@ -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 diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..708d792 --- /dev/null +++ b/docker/nginx.conf @@ -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; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f3ff596 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 0000000..964879f --- /dev/null +++ b/packages/shared-types/package.json @@ -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" + } +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts new file mode 100644 index 0000000..72b95eb --- /dev/null +++ b/packages/shared-types/src/index.ts @@ -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; + 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; +} diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json new file mode 100644 index 0000000..8cdcc06 --- /dev/null +++ b/packages/shared-types/tsconfig.json @@ -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"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100644 index 0000000..7af7d3b --- /dev/null +++ b/scripts/dev.sh @@ -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" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 0000000..267b52a --- /dev/null +++ b/scripts/setup.sh @@ -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" diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..da71c3e --- /dev/null +++ b/turbo.json @@ -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 + } + } +}