diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..881e322 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +# Git +.git +.gitignore +.gitattributes + +# CI/CD +.github +.gitlab-ci.yml + +# IDE +.idea +.vscode +*.iml +*.ipr +*.iws + +# Maven +**/target/ +.mvn/ +mvnw +mvnw.cmd + +# Node +**/node_modules/ +**/dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Documentation +*.md +README* +LICENSE +docs/ + +# Scripts +bin/ +*.bat +*.sh + +# Logs +*.log +**/logs/ + +# Temporary files +*.tmp +*.temp +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..9a64dde --- /dev/null +++ b/.env @@ -0,0 +1,28 @@ +# MySQL数据库配置 +MYSQL_ROOT_PASSWORD=ruoyi123 +MYSQL_DATABASE=ry-vue +MYSQL_USER=ruoyi +MYSQL_PASSWORD=ruoyi123 +MYSQL_HOST=rtsp-mysql +MYSQL_PORT=3306 + +# Redis配置 +REDIS_HOST=rtsp-redis +REDIS_PORT=6379 +REDIS_PASSWORD= + +# 后端服务配置 +BACKEND_HOST=rtsp-backend +BACKEND_PORT=8080 + +# 前端服务配置 +FRONTEND_PORT=10080 + +# Python推理服务配置 +PYTHON_SERVICE_HOST=rtsp-python-service +PYTHON_SERVICE_PORT=8000 + +# 时区配置 +TZ=Asia/Shanghai + +# 备注:MinIO使用外部已部署的服务,配置在application.yml中 diff --git a/ArcFace64.dat b/ArcFace64.dat deleted file mode 100644 index aba703f..0000000 --- a/ArcFace64.dat +++ /dev/null @@ -1 +0,0 @@ -EWEPEPEOGMGTELIZJUGECKIUJDBCJTCNISGPBNHLJTJUBHEWGNAKGEGAIOHJDQAJGNCFDRFZJEDMJTIBJBDZGZJSFPAUBTBXFSIUFTCMBHGCBKAGHFAMFSGYEIJPGPGXGJEREEBYAJFHFGESCXBJIGGHDBISEQAMAGGWGCADDVENHCIYITAVBUDYDWJGIRIRIKFHBAABGUHEDHFODQGUATGIGSEPBFBMBEDNCUGSEBCGCMCJGTEMFTCGBQBEBCFOCGDOIGEGFDAIEYEUEXGMDWJAFHCEHBGLJHAIIGGQANAKFBCOBGIUHYEWCSGNANCICCCOGNIVITFDHPHQCBANJIBTAQEMJFJLAJFGELHOHPGPAIGCEJDVHOIZHXECIIAMEPDJGBCXGSHCHHCVGVIJCKHKBXCBHKFRISAIDTFWGOGRBHBMFAHYACAQFRHAGCGVBWFWFGBCIGEHEHJBHZHABMGCHJEFCUEDFIBSABDTDYGXBMFRIZAHINEUBJFHICIJHRANJCBXBBBMFEJDJPDQCLIOFLDKDEGTICCMCWIUDWBYGFAPJOAUCCFUEMBEBOGDIFJMHJDQGWBFACBKEREXGPBXDJBPEZCUDTCRFVBWHACNFACDFDFEBIJKJNGWAPAMGQFDDKIJBCJNHLBBDOFMEBGUHDDYISIBEAJMEAIQEDCJFQBEIUHFJTCAIVBZGMCAFBDTHFDCARBRFWBFGRCJDIHGIZABDJFQGLCTHCJUJCJGGNHXHOBHHRIIBQIGJFACDRDBAHFMEMFQAZFXBAIJBCGCHZFRHNBFEPCFBIHFDDAWGPFBECITGIASAIDXHMAIHVHKINFVDHDDGFJHHWECASFHITFQIJACDACVAIDAFVISGAALHFCQACEUHOBOGFDEBHFSBUEQEXESEWIOJUGXHHIZDTEZJGDFIOAJGTDCBUBABPEVBTJJGNGGHOFCCHIWGFCPBDCEANIIACBFBEDSCMIWISCXAZFUBFCSGSENHOHKGLGDGEBTAIBNIOIRGMFBIMHVEXFGFICECPAQCZEFFZERETCTBEHLEEJPIFDVHHGNIZAOINGOCKBPDRCIBQJUBYDCEGFWBECSDDJHGCIUCUCCGKIHDRIRIPJBENGAAPJSDIBECTHAAXIVHZFFDCHDHWJTIQAIIPBYGMEMCYCPIRGOJHINFZASGVDVBWHZCNEFJI \ No newline at end of file diff --git a/COMPLETE-SUMMARY.md b/COMPLETE-SUMMARY.md new file mode 100644 index 0000000..fedc95a --- /dev/null +++ b/COMPLETE-SUMMARY.md @@ -0,0 +1,567 @@ +# 🎉 RTSP视频分析系统 - 完整更新总结 + +## 📋 项目概述 + +本文档总结了RTSP视频分析系统的所有Docker部署和巡检任务记录功能的实现。 + +## ✅ 完成的工作 + +### 一、Docker部署方案 + +#### 1.1 核心配置文件 +- ✅ `.env` - 环境变量配置(前端端口10080) +- ✅ `docker-compose.yml` - 5个服务编排(无MinIO,使用外部) +- ✅ `.dockerignore` - Docker构建优化 + +#### 1.2 Dockerfile文件 +- ✅ `ruoyi-admin/Dockerfile` - Java后端(多阶段构建) +- ✅ `rtsp-vue/Dockerfile` - Vue前端(多阶段构建) +- ✅ `rtsp-vue/nginx.conf` - Nginx配置(容器名代理) +- ✅ `python-inference-service/Dockerfile` - Python服务(CPU模式) + +#### 1.3 YOLOv8支持 +- ✅ `python-inference-service/requirements.txt` - ultralytics>=8.0.0 +- ✅ `python-inference-service/models/yolov8_model.py` - YOLOv8包装类 +- ✅ `python-inference-service/models/models.json` - yolov8_detector配置 + +#### 1.4 部署脚本 +- ✅ `deploy.bat` - Windows一键部署 +- ✅ `deploy.sh` - Linux/Mac一键部署 + +#### 1.5 部署文档 +- ✅ `README-DOCKER.md` - 完整部署文档 +- ✅ `DOCKER-QUICK-START.md` - 快速开始指南 +- ✅ `YOLOV8-SETUP.md` - YOLOv8配置指南 +- ✅ `DEPLOYMENT-NOTES.md` - 部署配置说明 +- ✅ `FINAL-SUMMARY.md` - 最终配置总结 + +### 二、巡检任务记录功能 + +#### 2.1 新增Service层 +- ✅ `IInspectionTaskRecordService.java` - 服务接口 +- ✅ `IInspectionTaskRecordServiceImpl.java` - 服务实现 + +#### 2.2 修改核心服务 +- ✅ `InspectionTaskServiceImpl.java` + - 添加依赖注入(InspectionTaskRecordMapper等) + - 修改`executeInspectionTask()` - 创建记录 + - 新增`performVideoAnalysisWithRecord()` - 录制+分析 + - 新增`analyzeVideoAndUpdateRecord()` - 调用分析 + - 新增`updateRecordFailed()` - 失败处理 + +- ✅ `VideoAnalysisService.java` + - 更新Python服务URL为容器名 + - 更新模型名称为yolov8_detector + - 新增`analyzeVideoWithRecord()` - 带记录的分析 + - 新增`processVideoWithRecord()` - 处理并记录 + - 新增`createAlarmRecordForRecord()` - 去重告警 + - 新增`uploadProcessedVideoForRecord()` - 上传视频 + +#### 2.3 功能文档 +- ✅ `INSPECTION-WORKFLOW.md` - 详细工作流程 +- ✅ `INSPECTION-FEATURE-SUMMARY.md` - 功能总结 + +## 🎯 关键特性 + +### Docker部署特性 + +| 特性 | 说明 | +|------|------| +| YOLOv8 | CPU模式,无需GPU/CUDA | +| MinIO | 使用外部服务(49.232.154.205:10900) | +| 端口 | 只暴露前端10080端口 | +| 网络 | 容器间使用容器名通信 | +| 服务 | MySQL, Redis, Python(CPU), Backend, Frontend | + +### 巡检任务特性 + +| 特性 | 说明 | +|------|------| +| 自动记录 | 每次执行自动创建InspectionTaskRecord | +| 视频保存 | 自动录制并上传到MinIO | +| AI识别 | 调用Python服务(YOLOv8)识别 | +| 结果更新 | 自动更新record.accessory和result | +| 告警去重 | 相同对象只创建一次告警 | + +## 🔄 完整工作流程 + +``` +用户启动巡检任务 + ↓ +创建InspectionTaskRecord + ├── recordId: [auto] + ├── taskId: 1001 + ├── executeTime: now + └── status: 1 (执行中) + ↓ +录制RTSP视频流 + ├── 抓取视频流 + ├── 录制30秒 + └── 保存临时文件 + ↓ +上传原始视频 + ├── 上传到MinIO + ├── 获取URL + └── 更新record.accessory = "video1.mp4" + ↓ +AI识别处理 + ├── 逐帧分析 + ├── 每10帧调用Python API (YOLOv8) + ├── 检测结果去重 + └── 创建AlarmRecord + ├── 提取检测区域图片 + ├── 上传告警图片 + └── 保存告警记录 + ↓ +生成处理后视频 + ├── 绘制检测框 + ├── 上传到MinIO + └── 更新record.accessory += ";video2.mp4" + ↓ +更新记录 + ├── record.result = "检测结果摘要" + ├── record.duration = 32秒 + └── record.status = 0 (成功) +``` + +## 📊 数据表关系 + +``` +InspectionTask (1) + ↓ +InspectionTaskRecord (N) + ├── accessory: 原始视频URL;处理后视频URL + ├── result: 共检测到X个问题,详情:... + └── status: 0=成功, 1=失败, 2=部分成功 + ↓ +AlarmRecord (N) + ├── 告警类型、内容、置信度 + ├── 告警图片URL + ├── 视频帧位置 + └── 自动去重(相同对象只记录一次) +``` + +## 🚀 快速开始 + +### 1. Docker部署 + +```bash +# 1. 准备YOLOv8模型 +# 将best.pt放到: python-inference-service/models/best.pt + +# 2. 一键部署 +deploy.bat # Windows + +# 3. 访问系统 +# http://localhost:10080 +``` + +### 2. 创建巡检任务 + +```java +// 创建任务 +InspectionTask task = new InspectionTask(); +task.setDeviceId(deviceId); +task.setDuration(30); // 录制30秒 +task.setStatus(0); // 待执行 +inspectionTaskService.insertInspectionTask(task); + +// 启动任务 +inspectionTaskService.executeInspectionTask(task.getTaskId()); +``` + +### 3. 查看结果 + +```sql +-- 查看执行记录 +SELECT * FROM v_inspection_task_record +WHERE task_id = ? +ORDER BY execute_time DESC; + +-- 查看告警 +SELECT * FROM v_alarm_record +WHERE task_id = ? +ORDER BY create_time DESC; +``` + +## 📦 文件清单 + +### Docker部署文件(15个) + +``` +.env # 环境变量 +docker-compose.yml # 服务编排 +.dockerignore # 构建优化 +ruoyi-admin/Dockerfile # 后端镜像 +rtsp-vue/Dockerfile # 前端镜像 +rtsp-vue/nginx.conf # Nginx配置 +rtsp-vue/.dockerignore # 前端构建优化 +python-inference-service/Dockerfile # Python镜像 +python-inference-service/.dockerignore # Python构建优化 +deploy.bat # Windows部署脚本 +deploy.sh # Linux部署脚本 +README-DOCKER.md # 完整文档 +DOCKER-QUICK-START.md # 快速开始 +DEPLOYMENT-NOTES.md # 配置说明 +FINAL-SUMMARY.md # 配置总结 +``` + +### YOLOv8文件(4个) + +``` +python-inference-service/requirements.txt # ultralytics依赖 +python-inference-service/models/yolov8_model.py # YOLOv8包装类 +python-inference-service/models/models.json # 模型配置 +YOLOV8-SETUP.md # 配置指南 +``` + +### 巡检任务文件(5个) + +``` +ruoyi-video/src/main/java/com/ruoyi/video/service/ +├── IInspectionTaskRecordService.java # 记录服务接口 +└── impl/ + └── IInspectionTaskRecordServiceImpl.java # 记录服务实现 + +ruoyi-video/src/main/java/com/ruoyi/video/service/impl/ +├── InspectionTaskServiceImpl.java (修改) # 添加记录创建 +└── VideoAnalysisService.java (修改) # 添加记录更新 + +INSPECTION-WORKFLOW.md # 工作流程文档 +INSPECTION-FEATURE-SUMMARY.md # 功能总结 +``` + +### 后端配置修改(2个) + +``` +ruoyi-admin/pom.xml # 添加actuator依赖 +ruoyi-admin/src/main/resources/application.yml # 添加actuator配置 +``` + +## 🎯 配置要点 + +### 1. 环境变量(.env) + +```bash +FRONTEND_PORT=10080 # 前端对外端口 +MYSQL_HOST=rtsp-mysql # 数据库容器名 +REDIS_HOST=rtsp-redis # Redis容器名 +BACKEND_HOST=rtsp-backend # 后端容器名 +PYTHON_SERVICE_HOST=rtsp-python-service # Python服务容器名 + +# MinIO配置在application.yml中 +``` + +### 2. MinIO配置(application.yml) + +```yaml +minio: + endpoint: http://49.232.154.205:10900 + access-key: 4EsLD9g9OM09DT0HaBKj + secret-key: 05SFC5fleqTnaLRYBrxHiphMFYbGX5nYicj0WCHA + bucket: rtsp +``` + +### 3. Python服务URL + +```java +// VideoAnalysisService.java +private static final String PYTHON_API_URL = "http://rtsp-python-service:8000/api/detect/file"; +private static final String MODEL_NAME = "yolov8_detector"; +``` + +## 📖 文档导航 + +### 快速开始 +1. **FINAL-SUMMARY.md** - Docker配置总结 +2. **DOCKER-QUICK-START.md** - 快速启动指南 + +### 部署相关 +3. **README-DOCKER.md** - 完整部署文档 +4. **DEPLOYMENT-NOTES.md** - 详细配置说明 +5. **YOLOV8-SETUP.md** - YOLOv8模型配置 + +### 功能相关 +6. **INSPECTION-FEATURE-SUMMARY.md** - 巡检功能总结 +7. **INSPECTION-WORKFLOW.md** - 详细工作流程 + +### 更新记录 +8. **UPDATE-SUMMARY.md** - 更新变更记录 +9. **DEPLOYMENT-FILES.md** - 文件清单 +10. **COMPLETE-SUMMARY.md** - 本文档 + +## 🔍 验证清单 + +### Docker部署验证 + +- [ ] 所有容器运行正常 + ```bash + docker-compose ps + ``` + +- [ ] 前端可访问 + ``` + http://localhost:10080 + ``` + +- [ ] Python服务健康 + ```bash + docker-compose logs python-service | grep "YOLOv8模型加载完成" + ``` + +- [ ] 后端连接正常 + ```bash + docker-compose logs backend | grep "Started RuoYiApplication" + ``` + +### 巡检功能验证 + +- [ ] 创建测试任务 +- [ ] 执行任务 +- [ ] 检查Record创建 + ```sql + SELECT * FROM v_inspection_task_record ORDER BY create_time DESC LIMIT 1; + ``` + +- [ ] 检查视频保存 + ```sql + SELECT accessory FROM v_inspection_task_record WHERE record_id = ?; + ``` + +- [ ] 检查识别结果 + ```sql + SELECT result FROM v_inspection_task_record WHERE record_id = ?; + ``` + +- [ ] 检查告警创建 + ```sql + SELECT * FROM v_alarm_record WHERE task_id = ? ORDER BY create_time DESC; + ``` + +## ⚙️ 系统架构 + +``` +┌──────────────┐ +│ 浏览器 │ +└──────┬───────┘ + │ :10080 ← 唯一对外端口 +┌──────▼───────┐ +│ Frontend │ +│ (Nginx) │ +└──────┬───────┘ + │ + ┌───┴──────────────┐ + │ │ +┌──▼────┐ ┌──────▼──────┐ +│Backend│ │ Python │ +│ :8080 │──────│ Service │ +│ │ │ :8000(CPU) │ +└─┬──┬──┘ └─────────────┘ + │ │ + │ │ ┌──────────────┐ + │ └──│ MinIO(外部) │ + │ │ 49.232... │ + │ └──────────────┘ + │ +┌─▼────────┐ +│ MySQL │ +│ Redis │ +└──────────┘ +``` + +## 🎯 工作流程 + +### 巡检任务执行流程 + +``` +1. 启动任务 + └── 创建InspectionTaskRecord (status=1执行中) + +2. 录制视频 + ├── 从RTSP流录制30秒 + ├── 上传到MinIO + └── 更新record.accessory = "原始视频URL" + +3. AI识别 + ├── 逐帧分析视频 + ├── 调用Python服务(YOLOv8) + ├── 检测结果去重 + └── 创建AlarmRecord(不重复) + +4. 保存结果 + ├── 绘制检测框 + ├── 上传处理后视频 + ├── 更新record.accessory += ";处理后URL" + └── 更新record.result = "检测结果摘要" + +5. 完成 + ├── record.status = 0 (成功) + ├── record.duration = 实际时长 + └── task.status = 2 (已完成) +``` + +## 📝 配置摘要 + +### Docker服务配置 + +| 服务 | 容器名 | 端口 | 暴露 | +|------|--------|------|------| +| MySQL | rtsp-mysql | 3306 | ❌ | +| Redis | rtsp-redis | 6379 | ❌ | +| Python | rtsp-python-service | 8000 | ❌ | +| Backend | rtsp-backend | 8080 | ❌ | +| Frontend | rtsp-frontend | 80→10080 | ✅ | +| MinIO | 外部服务 | 10900 | - | + +### 巡检任务配置 + +| 配置项 | 值 | 位置 | +|--------|-----|------| +| Python服务URL | http://rtsp-python-service:8000 | VideoAnalysisService.java | +| 模型名称 | yolov8_detector | VideoAnalysisService.java | +| 检测频率 | 每10帧 | processVideoWithRecord() | +| 去重容差 | 10像素 | generateDetectionKey() | +| 去重时间窗口 | 60秒 | processVideoWithRecord() | + +## 💡 使用建议 + +### 性能优化(CPU模式) + +1. **使用最小模型** + - yolov8n.pt(推荐) + - 而不是yolov8l.pt或yolov8x.pt + +2. **降低检测频率** + ```java + if (frameCount % 30 == 0) { // 从10改为30 + ``` + +3. **缩短录制时长** + ```java + task.setDuration(15); // 从30秒改为15秒 + ``` + +### MinIO Bucket准备 + +需要在外部MinIO服务中创建以下bucket: +- `inspection-videos` - 巡检视频 +- `alarm-images` - 告警图片 + +### 数据库表确认 + +确保以下表存在: +- `v_inspection_task` - 巡检任务 +- `v_inspection_task_record` - 巡检记录 +- `v_alarm_record` - 告警记录 +- `v_minio_object` - MinIO对象 +- `v_device` - 设备信息 + +## 🐛 常见问题 + +### Q1: 视频未保存 + +**检查**: +```bash +docker-compose logs backend | grep "MinIO" +curl http://49.232.154.205:10900/minio/health/live +``` + +### Q2: Python识别失败 + +**检查**: +```bash +docker-compose logs python-service +docker exec -it rtsp-python-service ls -lh /app/models/best.pt +``` + +### Q3: 告警重复 + +**调整**: +```java +// generateDetectionKey中增大容差 +int x = rect.x() / 20 * 20; // 从10改为20 +``` + +### Q4: 执行时间过长 + +**优化**: +- 降低检测频率(每30帧而不是10帧) +- 缩短录制时长 +- 使用更小的YOLOv8模型 + +## 📞 获取帮助 + +### 查看日志 + +```bash +# 所有服务 +docker-compose logs -f + +# 后端 +docker-compose logs -f backend + +# Python服务 +docker-compose logs -f python-service +``` + +### 查看数据 + +```sql +-- 最新的执行记录 +SELECT * FROM v_inspection_task_record ORDER BY create_time DESC LIMIT 10; + +-- 最新的告警 +SELECT * FROM v_alarm_record ORDER BY create_time DESC LIMIT 10; + +-- 统计信息 +SELECT + t.task_id, + COUNT(DISTINCT r.record_id) as execution_count, + COUNT(DISTINCT a.alarm_id) as alarm_count +FROM v_inspection_task t +LEFT JOIN v_inspection_task_record r ON t.task_id = r.task_id +LEFT JOIN v_alarm_record a ON t.task_id = a.task_id +GROUP BY t.task_id; +``` + +## 🎓 学习资源 + +- [Ultralytics YOLOv8文档](https://docs.ultralytics.com/) +- [Docker Compose文档](https://docs.docker.com/compose/) +- [FFmpeg JavaCV文档](https://github.com/bytedeco/javacv) +- [MinIO文档](https://min.io/docs/minio/linux/index.html) + +## ✨ 总结 + +### 部署方面 +✅ 完整的Docker部署方案 +✅ YOLOv8 CPU模式支持 +✅ 外部MinIO集成 +✅ 容器间使用容器名通信 +✅ 只暴露前端端口10080 +✅ 完整的健康检查和依赖管理 + +### 功能方面 +✅ 自动创建巡检记录 +✅ 自动录制和保存视频 +✅ 调用Python服务(YOLOv8)识别 +✅ 自动更新识别结果 +✅ 创建不重复的告警记录 +✅ 完整的异常处理和日志记录 + +### 文档方面 +✅ 10个详细的文档文件 +✅ 工作流程图示 +✅ 配置说明 +✅ 故障排查指南 +✅ SQL查询示例 + +--- + +**项目状态**: ✅ 完成 +**部署状态**: 待部署 +**测试状态**: 待测试 +**文档版本**: 1.0 +**最后更新**: 2025-09-30 + +🎊 **所有功能已完整实现,可以开始部署和测试!** \ No newline at end of file diff --git a/DEPLOYMENT-FILES.md b/DEPLOYMENT-FILES.md new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/DEPLOYMENT-FILES.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/DEPLOYMENT-NOTES.md b/DEPLOYMENT-NOTES.md new file mode 100644 index 0000000..bc0bd69 --- /dev/null +++ b/DEPLOYMENT-NOTES.md @@ -0,0 +1,329 @@ +# 📌 部署配置说明 + +## 重要配置 + +### 1. MinIO配置(使用外部服务) + +本系统使用**外部已部署的MinIO服务**,不在Docker中部署MinIO。 + +**配置位置**: `ruoyi-admin/src/main/resources/application.yml` + +```yaml +minio: + enabled: true + endpoint: http://49.232.154.205:10900 + access-key: 4EsLD9g9OM09DT0HaBKj + secret-key: 05SFC5fleqTnaLRYBrxHiphMFYbGX5nYicj0WCHA + bucket: rtsp +``` + +**如需更换MinIO服务器**: +1. 编辑上述配置文件 +2. 重新构建后端镜像:`docker-compose build backend` +3. 重启服务:`docker-compose up -d` + +### 2. Python推理服务(CPU模式) + +本系统**不使用GPU**,Python推理服务运行在CPU模式。 + +**配置特点**: +- ✅ 无需NVIDIA Docker Runtime +- ✅ 无需GPU驱动 +- ✅ 适合CPU服务器部署 +- ⚠️ 推理速度比GPU慢 + +**如需提升性能**: +1. 使用更小的YOLOv8模型(如yolov8n.pt) +2. 减小输入图像尺寸 +3. 调整置信度阈值 + +### 3. 服务端口配置 + +**对外暴露端口**: +- 前端:10080(可在.env中修改`FRONTEND_PORT`) + +**内部端口**(不对外暴露): +- 后端:8080 +- Python服务:8000 +- MySQL:3306 +- Redis:6379 + +## Docker Compose配置要点 + +### 服务列表 + +```yaml +services: + mysql # MySQL数据库 + redis # Redis缓存 + python-service # Python推理服务(CPU) + backend # Java后端 + frontend # Vue前端(唯一对外暴露) +``` + +**注意**:没有MinIO服务,使用外部MinIO。 + +### 服务依赖关系 + +``` +frontend 依赖 backend (健康检查) +backend 依赖 mysql, redis (健康检查) + python-service (启动) +``` + +### 环境变量(.env) + +```bash +# 数据库 +MYSQL_HOST=rtsp-mysql +MYSQL_PORT=3306 +MYSQL_PASSWORD=ruoyi123 + +# Redis +REDIS_HOST=rtsp-redis +REDIS_PORT=6379 + +# 后端 +BACKEND_HOST=rtsp-backend +BACKEND_PORT=8080 + +# 前端(对外端口) +FRONTEND_PORT=10080 + +# Python服务 +PYTHON_SERVICE_HOST=rtsp-python-service +PYTHON_SERVICE_PORT=8000 +``` + +**不包含MinIO配置**(在application.yml中) + +## 网络架构 + +### 内部网络(rtsp-network) + +所有服务运行在同一个Docker网络中: +- 容器间通过容器名通信 +- 外部只能访问前端(10080端口) +- 安全性高 + +### 对外访问 + +``` +浏览器 → 前端:10080 → Nginx → 后端:8080 + → Python服务:8000 + +后端 → MySQL:3306 + → Redis:6379 + → MinIO:49.232.154.205:10900 (外部) +``` + +## YOLOv8模型配置 + +### 模型要求 + +- **模型格式**: PyTorch (.pt) +- **训练框架**: Ultralytics YOLOv8 +- **模型位置**: `python-inference-service/models/best.pt` +- **类别文件**: `python-inference-service/models/classes.txt`(可选) + +### 模型配置 + +**models.json**: +```json +[ + { + "name": "yolov8_detector", + "path": "models/yolov8_model.py", + "size": [640, 640] + } +] +``` + +### 性能调优(CPU模式) + +编辑 `yolov8_model.py`: + +```python +# 降低置信度阈值(检测更多目标) +self.conf_threshold = 0.25 + +# 使用更小的输入尺寸(提升速度) +self.img_size = 320 # 或 480 + +# 建议使用 yolov8n.pt(最快的模型) +``` + +## 数据持久化 + +### Docker卷 + +```yaml +volumes: + mysql-data # MySQL数据 + redis-data # Redis数据 + backend-logs # 后端日志 + backend-upload # 上传文件 +``` + +**注意**:MinIO数据在外部服务器,不在本地。 + +### 数据备份 + +```bash +# 备份MySQL +docker exec rtsp-mysql mysqldump -u root -pruoyi123 ry-vue > backup.sql + +# 备份Redis +docker exec rtsp-redis redis-cli SAVE +docker cp rtsp-redis:/data/dump.rdb ./redis-backup.rdb +``` + +## 部署检查清单 + +### 部署前检查 + +- [ ] YOLOv8模型文件(best.pt)已放置 +- [ ] 类别文件(classes.txt)已创建(可选) +- [ ] .env配置已检查 +- [ ] MinIO外部服务可访问 +- [ ] Docker和Docker Compose已安装 +- [ ] 端口10080未被占用 + +### 部署后检查 + +- [ ] 所有容器运行正常:`docker-compose ps` +- [ ] 前端可访问:http://localhost:10080 +- [ ] 后端健康检查通过 +- [ ] Python服务加载模型成功 +- [ ] MySQL连接正常 +- [ ] Redis连接正常 +- [ ] MinIO外部服务连接正常 + +## 常见问题 + +### Q1: MinIO连接失败 + +**检查**: +1. 外部MinIO服务是否可访问 +2. application.yml中的配置是否正确 +3. 网络是否畅通 + +```bash +# 测试连接 +curl http://49.232.154.205:10900/minio/health/live +``` + +### Q2: Python推理慢 + +**原因**:CPU模式比GPU慢 + +**优化**: +1. 使用yolov8n.pt(最小模型) +2. 减小输入尺寸(320或480) +3. 提高置信度阈值(减少检测框) + +### Q3: 容器启动失败 + +**排查步骤**: +```bash +# 查看日志 +docker-compose logs [服务名] + +# 检查端口占用 +netstat -an | grep 10080 + +# 检查资源 +docker stats +``` + +### Q4: 模型加载失败 + +**检查**: +1. best.pt文件是否存在 +2. 文件是否是YOLOv8格式 +3. Python依赖是否安装完整 + +```bash +# 进入容器检查 +docker exec -it rtsp-python-service bash +ls -lh /app/models/ +``` + +## 安全建议 + +1. **修改默认密码** + - MySQL: ruoyi123 + - MinIO密钥(在application.yml中) + +2. **网络隔离** + - 只暴露必要端口(10080) + - 使用防火墙限制访问 + +3. **MinIO安全** + - 使用HTTPS连接 + - 定期更新密钥 + - 限制bucket访问权限 + +4. **定期备份** + - MySQL数据 + - Redis数据 + - 应用日志 + +## 维护操作 + +### 更新服务 + +```bash +# 停止服务 +docker-compose down + +# 更新代码 +git pull + +# 重新构建 +docker-compose build + +# 启动服务 +docker-compose up -d +``` + +### 查看日志 + +```bash +# 所有服务 +docker-compose logs -f + +# 特定服务 +docker-compose logs -f backend +docker-compose logs -f python-service +``` + +### 清理资源 + +```bash +# 清理未使用的镜像 +docker image prune -a + +# 清理未使用的卷 +docker volume prune + +# 完全清理(包括数据) +docker-compose down -v +``` + +## 性能监控 + +```bash +# 查看资源使用 +docker stats + +# 查看容器状态 +docker-compose ps + +# 查看网络连接 +docker network inspect rtsp-network +``` + +--- + +**最后更新**: 2025-09-30 +**配置版本**: Docker Compose 3.8, YOLOv8 CPU模式 \ No newline at end of file diff --git a/DOCKER-QUICK-START.md b/DOCKER-QUICK-START.md new file mode 100644 index 0000000..0121f38 --- /dev/null +++ b/DOCKER-QUICK-START.md @@ -0,0 +1,202 @@ +# 🚀 Docker快速启动指南 + +## 一键部署 + +### Windows +```bash +deploy.bat +``` + +### Linux/Mac +```bash +chmod +x deploy.sh +./deploy.sh +``` + +## 手动部署 + +```bash +# 1. 启动所有服务 +docker-compose up -d + +# 2. 查看服务状态 +docker-compose ps + +# 3. 查看日志 +docker-compose logs -f +``` + +## 常用命令 + +### 服务管理 +```bash +# 启动服务 +docker-compose up -d + +# 停止服务 +docker-compose stop + +# 重启服务 +docker-compose restart + +# 停止并删除容器 +docker-compose down + +# 停止并删除容器和数据卷 +docker-compose down -v +``` + +### 日志查看 +```bash +# 查看所有日志 +docker-compose logs + +# 查看特定服务日志 +docker-compose logs backend +docker-compose logs frontend +docker-compose logs python-service + +# 实时查看日志 +docker-compose logs -f + +# 查看最近100行日志 +docker-compose logs --tail=100 +``` + +### 服务重建 +```bash +# 重新构建所有镜像 +docker-compose build + +# 重新构建特定服务 +docker-compose build backend + +# 强制重建并启动 +docker-compose up -d --build --force-recreate +``` + +### 进入容器 +```bash +# 进入后端容器 +docker exec -it rtsp-backend sh + +# 进入前端容器 +docker exec -it rtsp-frontend sh + +# 进入Python服务容器 +docker exec -it rtsp-python-service bash + +# 进入MySQL容器 +docker exec -it rtsp-mysql bash + +# 进入Redis容器 +docker exec -it rtsp-redis sh +``` + +## 端口配置 + +| 服务 | 默认端口 | 对外暴露 | 说明 | +|------|---------|---------|------| +| 前端 | 10080 | ✅ | 唯一对外暴露的端口 | +| 后端 | 8080 | ❌ | 仅容器内部访问 | +| Python服务 | 8000 | ❌ | 仅容器内部访问(CPU模式) | +| MySQL | 3306 | ❌ | 仅容器内部访问 | +| Redis | 6379 | ❌ | 仅容器内部访问 | +| MinIO | 外部服务 | - | 使用已部署的外部服务 | + +要修改前端端口,编辑 `.env` 文件中的 `FRONTEND_PORT` 变量。 + +## 环境变量配置 + +所有配置都在 `.env` 文件中: + +```bash +# 修改前端端口 +FRONTEND_PORT=8080 + +# 修改数据库密码 +MYSQL_ROOT_PASSWORD=your_secure_password +MYSQL_PASSWORD=your_secure_password + +# 修改MinIO密码 +MINIO_ROOT_PASSWORD=your_secure_password +``` + +修改后需要重新启动服务: +```bash +docker-compose down +docker-compose up -d +``` + +## 数据备份 + +### 备份MySQL数据 +```bash +docker exec rtsp-mysql mysqldump -u root -pruoyi123 ry-vue > backup.sql +``` + +### 备份MinIO数据 +```bash +docker cp rtsp-minio:/data ./minio-backup +``` + +### 恢复MySQL数据 +```bash +docker exec -i rtsp-mysql mysql -u root -pruoyi123 ry-vue < backup.sql +``` + +## 故障排查 + +### 查看服务健康状态 +```bash +docker-compose ps +``` + +### 服务启动失败 +```bash +# 查看详细日志 +docker-compose logs [服务名] + +# 常见问题: +# 1. 端口被占用 -> 修改.env中的端口配置 +# 2. 数据库连接失败 -> 等待MySQL完全启动(约30秒) +# 3. 内存不足 -> 增加Docker内存限制或减少服务 +``` + +### 清理并重新开始 +```bash +# 完全清理(会删除所有数据) +docker-compose down -v +docker system prune -a + +# 重新部署 +docker-compose up -d +``` + +## 访问地址 + +部署成功后: + +- **前端界面**: http://localhost:10080 +- **后端API**: 通过前端代理 `/prod-api/` +- **Python API**: 通过前端代理 `/python-api/` +- **API文档**: http://localhost:10080/prod-api/swagger-ui.html + +## 首次使用 + +1. **准备YOLOv8模型**:将训练好的`best.pt`文件放到`python-inference-service/models/`目录 +2. 访问 http://localhost:10080 +3. 默认账号密码请参考主文档 +4. 首次启动可能需要等待1-2分钟让所有服务完全启动 + +## YOLOv8模型说明 + +本系统使用**YOLOv8**(Ultralytics)进行目标检测: + +- 将训练好的YOLOv8模型(`best.pt`)放到`python-inference-service/models/`目录 +- (可选)创建`classes.txt`文件,每行一个类别名称 +- 模型会自动加载并提供推理服务 + +## 更多帮助 + +详细文档请查看:`README-DOCKER.md` \ No newline at end of file diff --git a/FINAL-SUMMARY.md b/FINAL-SUMMARY.md new file mode 100644 index 0000000..1081819 --- /dev/null +++ b/FINAL-SUMMARY.md @@ -0,0 +1,279 @@ +# ✅ 最终配置总结 + +## 🎯 您的部署配置 + +根据您的需求,已完成以下配置: + +### 1. ✅ YOLOv8 (CPU模式) +- **框架**: Ultralytics YOLOv8 +- **运行模式**: CPU(无需GPU) +- **模型文件**: `python-inference-service/models/best.pt` + +### 2. ✅ MinIO (外部服务) +- **地址**: http://49.232.154.205:10900 +- **配置位置**: `ruoyi-admin/src/main/resources/application.yml` +- **不在Docker中部署** + +### 3. ✅ 前端端口 +- **对外端口**: 10080 +- **访问地址**: http://localhost:10080 + +## 📦 Docker服务列表 + +| 服务 | 说明 | 备注 | +|------|------|------| +| MySQL | 数据库 | 内部部署 | +| Redis | 缓存 | 内部部署 | +| Python服务 | YOLOv8推理 | CPU模式,不需要GPU | +| Java后端 | 业务逻辑 | 连接外部MinIO | +| Vue前端 | 用户界面 | 唯一对外暴露 :10080 | +| MinIO | 对象存储 | **使用外部服务** | + +## 🚀 快速部署 + +### 1. 准备模型文件 + +```bash +# 将YOLOv8训练的模型放到这里 +python-inference-service/models/best.pt +``` + +### 2. 启动服务 + +```bash +# Windows +deploy.bat + +# Linux/Mac +chmod +x deploy.sh +./deploy.sh + +# 或手动启动 +docker-compose up -d +``` + +### 3. 访问系统 + +``` +前端: http://localhost:10080 +``` + +## 📋 关键配置文件 + +### docker-compose.yml + +**包含的服务**: +```yaml +services: + mysql # ✅ 内部部署 + redis # ✅ 内部部署 + python-service # ✅ CPU模式,无GPU配置 + backend # ✅ 连接外部MinIO + frontend # ✅ 对外端口10080 + # minio # ❌ 不部署,使用外部 +``` + +### .env + +**环境变量**: +```bash +MYSQL_HOST=rtsp-mysql +REDIS_HOST=rtsp-redis +BACKEND_HOST=rtsp-backend +FRONTEND_PORT=10080 # 前端对外端口 +PYTHON_SERVICE_HOST=rtsp-python-service + +# MinIO配置在application.yml中,不在.env +``` + +### application.yml + +**MinIO外部服务配置**: +```yaml +minio: + enabled: true + endpoint: http://49.232.154.205:10900 + access-key: 4EsLD9g9OM09DT0HaBKj + secret-key: 05SFC5fleqTnaLRYBrxHiphMFYbGX5nYicj0WCHA + bucket: rtsp +``` + +## 🎯 系统架构 + +``` +┌────────────────┐ +│ 浏览器 │ +│ │ +└───────┬────────┘ + │ :10080 ← 唯一对外端口 +┌───────▼────────┐ +│ Frontend │ +│ (Nginx) │ +└───────┬────────┘ + │ + ┌───┴───────────────┐ + │ │ +┌───▼─────┐ ┌──────▼──────┐ +│ Backend │ │ Python │ +│ :8080 │ │ Service │ +│ │ │ :8000(CPU) │ +└─┬──┬──┬─┘ └─────────────┘ + │ │ │ + │ │ └────────────┐ + │ │ │ + │ │ ┌────▼─────┐ + │ │ │ MinIO │ + │ │ │ 外部服务 │ + │ │ │ 49.232.. │ + │ │ └──────────┘ + │ │ +┌─▼──▼───┐ +│ MySQL │ +│ Redis │ +└────────┘ +``` + +## ⚙️ 配置特点 + +### ✅ 优点 + +1. **无需GPU** + - 不需要NVIDIA驱动 + - 不需要NVIDIA Docker Runtime + - 适合普通CPU服务器 + +2. **使用外部MinIO** + - 不占用本地存储 + - 配置灵活 + - 易于扩展 + +3. **网络安全** + - 只暴露前端端口(10080) + - 其他服务内部通信 + - 提高安全性 + +### ⚠️ 注意事项 + +1. **推理速度** + - CPU模式比GPU慢 + - 建议使用yolov8n.pt(最小模型) + - 可适当降低图像尺寸 + +2. **MinIO依赖** + - 需要外部MinIO服务可用 + - 修改配置需重新构建后端 + +3. **首次启动** + - 等待1-2分钟让服务完全启动 + - Python服务需要加载模型 + +## 📖 重要文档 + +| 文档 | 说明 | +|------|------| +| `DEPLOYMENT-NOTES.md` | **最重要** - 部署配置说明 | +| `DOCKER-QUICK-START.md` | 快速启动和常用命令 | +| `YOLOV8-SETUP.md` | YOLOv8模型配置 | +| `README-DOCKER.md` | 完整部署文档 | +| `UPDATE-SUMMARY.md` | 更新变更记录 | + +## 🔍 部署检查清单 + +### 部署前 + +- [ ] Docker和Docker Compose已安装 +- [ ] YOLOv8模型文件(best.pt)已准备 +- [ ] 外部MinIO服务可访问(http://49.232.154.205:10900) +- [ ] 端口10080未被占用 +- [ ] 服务器有足够CPU资源 + +### 部署后 + +- [ ] 所有容器正常运行:`docker-compose ps` +- [ ] 前端可访问:http://localhost:10080 +- [ ] Python服务加载模型成功 +- [ ] 后端连接MySQL成功 +- [ ] 后端连接Redis成功 +- [ ] 后端连接外部MinIO成功 + +## 🛠️ 常用命令 + +```bash +# 启动服务 +docker-compose up -d + +# 查看状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f + +# 查看特定服务日志 +docker-compose logs -f python-service +docker-compose logs -f backend + +# 重启服务 +docker-compose restart + +# 停止服务 +docker-compose down + +# 完全清理(包括数据) +docker-compose down -v +``` + +## 🔧 性能优化建议 + +### CPU模式优化 + +1. **使用最小模型** + ```bash + # 使用yolov8n.pt而不是yolov8l.pt或yolov8x.pt + ``` + +2. **调整输入尺寸** + ```python + # 在yolov8_model.py中 + self.img_size = 320 # 从640改为320 + ``` + +3. **提高置信度阈值** + ```python + # 减少检测框数量 + self.conf_threshold = 0.4 # 从0.25提高到0.4 + ``` + +## 📞 获取帮助 + +1. **查看日志** + ```bash + docker-compose logs [服务名] + ``` + +2. **检查配置** + - `.env` - 环境变量 + - `docker-compose.yml` - 服务配置 + - `application.yml` - MinIO配置 + +3. **阅读文档** + - `DEPLOYMENT-NOTES.md` - 详细配置说明 + - `DOCKER-QUICK-START.md` - 快速参考 + +## ✨ 部署完成 + +所有配置已完成,您可以: + +1. 运行 `deploy.bat` 或 `./deploy.sh` 启动系统 +2. 访问 http://localhost:10080 使用系统 +3. 查看日志确认服务正常运行 + +--- + +**配置日期**: 2025-09-30 +**配置特点**: +- ✅ YOLOv8 (CPU模式) +- ✅ 外部MinIO (49.232.154.205:10900) +- ✅ 前端端口 10080 +- ✅ 无GPU依赖 + +🎉 **祝部署顺利!** \ No newline at end of file diff --git a/INSPECTION-FEATURE-SUMMARY.md b/INSPECTION-FEATURE-SUMMARY.md new file mode 100644 index 0000000..23b99fe --- /dev/null +++ b/INSPECTION-FEATURE-SUMMARY.md @@ -0,0 +1,525 @@ +# ✅ 巡检任务功能更新总结 + +## 🎯 功能实现 + +根据需求,已实现完整的巡检任务记录和AI识别流程。 + +## 📋 新增功能 + +### 1. ✅ 自动创建巡检记录 + +**何时创建**:巡检任务启动时自动创建 + +**InspectionTaskRecord字段**: +- `recordId`: 自动生成的记录ID +- `taskId`: 关联的巡检任务ID +- `executeTime`: 执行开始时间 +- `duration`: 执行时长(秒) +- `accessory`: 视频URL(原始;处理后) +- `result`: AI识别结果摘要 +- `status`: 0=成功, 1=失败, 2=部分成功 + +### 2. ✅ 自动保存视频 + +**视频保存流程**: +1. 从RTSP流录制视频(按task.duration时长) +2. 上传原始视频到MinIO +3. 保存URL到`record.accessory` +4. 继续分析处理 + +### 3. ✅ 调用Python服务识别 + +**识别流程**: +1. 创建`HttpYoloDetector`连接Python服务 +2. 逐帧提取并调用YOLOv8检测 +3. 每10帧检测一次(可调整) +4. 绘制检测框到视频上 +5. 生成带标注的处理后视频 + +### 4. ✅ 更新识别结果 + +**result字段内容**: +``` +共检测到 5 个问题,详情:垃圾(3) 烟雾(2) +``` + +### 5. ✅ 创建不重复告警 + +**去重机制**: +- 使用位置+类别生成唯一键 +- 相同对象只创建一次告警 +- 允许10像素的位置波动 +- 60秒未检测到自动清除 + +**AlarmRecord包含**: +- 告警类型、内容、置信度 +- 关联的任务ID和设备ID +- 告警图片(MinIO存储) +- 视频帧位置 +- 未处理状态 + +## 🔄 完整执行流程 + +``` +1. 用户启动巡检任务 + ↓ +2. InspectionTaskServiceImpl.executeInspectionTask() + ├── 创建InspectionTaskRecord (status=1执行中) + ├── 更新InspectionTask (status=1执行中) + └── 调用performVideoAnalysisWithRecord() + ↓ +3. performVideoAnalysisWithRecord() + ├── 录制RTSP视频流(30秒) + ├── 上传原始视频到MinIO + ├── 更新record.accessory = "原始视频URL" + └── 调用analyzeVideoAndUpdateRecord() + ↓ +4. VideoAnalysisService.analyzeVideoWithRecord() + ├── 逐帧分析视频 + ├── 每10帧调用Python API检测 + ├── 发现新对象 → createAlarmRecordForRecord() + │ ├── 提取检测区域图片 + │ ├── 上传告警图片到MinIO + │ └── 创建AlarmRecord(去重) + ├── 绘制检测框 + ├── 保存处理后视频 + ├── 上传处理后视频到MinIO + ├── 更新record.accessory += ";处理后视频URL" + └── 更新record.result = "检测结果摘要" + ↓ +5. 完成 + ├── 更新record.status = 0 (成功) + ├── 更新record.duration = 实际执行时长 + └── 更新task.status = 2 (已完成) +``` + +## 📦 新增/修改的文件 + +### 新增文件 + +1. **IInspectionTaskRecordService.java** + - 巡检记录服务接口 + +2. **IInspectionTaskRecordServiceImpl.java** + - 巡检记录服务实现 + +3. **INSPECTION-WORKFLOW.md** + - 详细工作流程文档 + +4. **INSPECTION-FEATURE-SUMMARY.md** + - 本文档 + +### 修改文件 + +1. **InspectionTaskServiceImpl.java** + - 添加依赖注入(InspectionTaskRecordMapper, MinioService, VMinioObjectService) + - 修改`executeInspectionTask()` - 创建记录 + - 新增`performVideoAnalysisWithRecord()` - 录制视频并分析 + - 新增`analyzeVideoAndUpdateRecord()` - 调用分析服务 + - 新增`updateRecordFailed()` - 更新失败状态 + +2. **VideoAnalysisService.java** + - 添加InspectionTaskRecordMapper依赖 + - 更新Python服务URL为容器名 + - 更新模型名称为yolov8_detector + - 新增`analyzeVideoWithRecord()` - 带记录的视频分析 + - 新增`processVideoWithRecord()` - 处理视频并记录结果 + - 新增`createAlarmRecordForRecord()` - 创建去重告警 + - 新增`uploadProcessedVideoForRecord()` - 上传处理后视频 + +## 🎯 数据流转 + +### InspectionTaskRecord字段变化 + +``` +创建时: + recordId: [auto] + taskId: 1001 + executeTime: 2025-09-30 14:30:00 + status: 1 (执行中) + accessory: null + result: null + duration: null + +录制视频后: + accessory: "http://.../inspection_1001_xxx.mp4" + +分析完成后: + accessory: "http://.../inspection_1001_xxx.mp4;http://.../processed_xxx.mp4" + result: "共检测到 3 个问题,详情:垃圾(2) 烟雾(1)" + duration: 32 + status: 0 (成功) +``` + +### AlarmRecord创建条件 + +仅当满足以下条件时创建告警: +1. ✅ 检测到新对象(不在缓存中) +2. ✅ 位置和类别不重复 +3. ✅ 置信度超过阈值(Python服务的conf参数) + +## 💾 数据库查询示例 + +### 查看任务执行历史 + +```sql +-- 查看任务的所有执行记录 +SELECT + r.record_id, + r.execute_time, + r.duration, + r.status, + r.result, + (SELECT COUNT(*) FROM v_alarm_record a WHERE a.task_id = r.task_id + AND a.create_time >= r.execute_time) as alarm_count +FROM v_inspection_task_record r +WHERE r.task_id = 1001 +ORDER BY r.execute_time DESC; +``` + +### 查看记录详情 + +```sql +-- 查看单条记录的完整信息 +SELECT + r.*, + t.device_id, + d.ip as device_ip +FROM v_inspection_task_record r +JOIN v_inspection_task t ON r.task_id = t.task_id +JOIN v_device d ON t.device_id = d.device_id +WHERE r.record_id = 2001; +``` + +### 查看记录的所有告警 + +```sql +-- 查看某次执行产生的告警 +SELECT + a.alarm_id, + a.alarm_type, + a.alarm_content, + a.confidence, + a.frame_position, + m.object_url as alarm_image_url +FROM v_alarm_record a +LEFT JOIN v_minio_object m ON a.image_oss_id = m.object_id +WHERE a.task_id = 1001 + AND a.create_time >= (SELECT execute_time FROM v_inspection_task_record WHERE record_id = 2001) + AND a.create_time <= DATE_ADD((SELECT execute_time FROM v_inspection_task_record WHERE record_id = 2001), + INTERVAL (SELECT duration FROM v_inspection_task_record WHERE record_id = 2001) SECOND) +ORDER BY a.create_time; +``` + +## 🔧 配置参数 + +### 关键配置位置 + +**VideoAnalysisService.java**: +```java +// Python服务地址(使用容器名) +private static final String PYTHON_API_URL = "http://rtsp-python-service:8000/api/detect/file"; + +// 模型名称 +private static final String MODEL_NAME = "yolov8_detector"; + +// 检测频率(每N帧) +if (frameCount % 10 == 0) { ... } + +// 去重时间窗口(60秒) +(currentId - entry.getValue()) > grabber.getFrameRate() * 60 +``` + +### 可调整参数 + +| 参数 | 位置 | 默认值 | 说明 | +|------|------|--------|------| +| 检测频率 | processVideoWithRecord | 10帧 | 降低可提升性能 | +| 去重容差 | generateDetectionKey | 10像素 | 提高容差减少告警 | +| 去重时间窗口 | processVideoWithRecord | 60秒 | 缩短窗口增加告警 | +| 模型名称 | MODEL_NAME | yolov8_detector | 与Python配置对应 | + +## 🚀 API接口 + +### Python服务接口 + +**请求**: +```http +POST http://rtsp-python-service:8000/api/detect/file +Content-Type: multipart/form-data + +model_name: yolov8_detector +file: [图像文件] +``` + +**响应**: +```json +{ + "model_name": "yolov8_detector", + "detections": [ + { + "label": "[yolov8_detector] 垃圾", + "confidence": 0.95, + "x": 100, + "y": 200, + "width": 150, + "height": 180, + "color": 65280 + } + ], + "inference_time": 45.6 +} +``` + +## 🎬 使用示例 + +### 示例1: 创建并执行巡检任务 + +```java +// 1. 创建任务 +InspectionTask task = new InspectionTask(); +task.setDeviceId(5001L); +task.setDuration(30); // 30秒 +task.setStatus(0); // 待执行 +inspectionTaskService.insertInspectionTask(task); + +// 2. 启动任务(异步) +inspectionTaskService.executeInspectionTask(task.getTaskId()); + +// 3. 查询执行记录 +List records = + inspectionTaskRecordService.selectInspectionTaskRecordList( + new InspectionTaskRecord().setTaskId(task.getTaskId()) + ); +``` + +### 示例2: 查询告警 + +```java +// 查询某任务的所有告警 +AlarmRecord query = new AlarmRecord(); +query.setTaskId(1001L); +query.setStatus(0); // 未处理 +List alarms = alarmRecordService.selectAlarmRecordList(query); +``` + +## ⚠️ 注意事项 + +### 1. 执行时间 + +- 录制视频需要时间(与duration设置一致) +- AI分析需要额外时间(取决于视频长度和CPU性能) +- 总执行时间 ≈ duration + 分析时间 + +### 2. 存储空间 + +每次执行会产生: +- 原始视频(~10-50MB,30秒) +- 处理后视频(~10-50MB) +- 告警图片(每个~100-500KB) + +建议定期清理历史数据。 + +### 3. Python服务调用 + +- 使用HTTP调用Python服务 +- 每帧调用可能较慢(已优化为每10帧) +- CPU模式下建议降低检测频率 + +### 4. MinIO存储 + +- 确保bucket已创建: + - `inspection-videos`(巡检视频) + - `alarm-images`(告警图片) +- 确保外部MinIO服务可访问 + +## 🔍 测试清单 + +### 部署后测试 + +- [ ] Python服务可访问 + ```bash + curl http://rtsp-python-service:8000/health + curl http://rtsp-python-service:8000/api/models + ``` + +- [ ] MinIO bucket已创建 + ```bash + # 登录MinIO管理界面创建bucket + # 或使用mc命令创建 + ``` + +- [ ] 创建测试任务 + ```sql + INSERT INTO v_inspection_task (device_id, duration, status) VALUES (1, 30, 0); + ``` + +- [ ] 执行任务并查看记录 + ```sql + SELECT * FROM v_inspection_task_record ORDER BY create_time DESC LIMIT 1; + ``` + +- [ ] 查看生成的告警 + ```sql + SELECT * FROM v_alarm_record ORDER BY create_time DESC LIMIT 10; + ``` + +- [ ] 验证视频URL可访问 + ``` + 访问record.accessory中的URL + ``` + +## 📊 预期结果 + +### 成功执行的记录 + +```json +{ + "recordId": 2001, + "taskId": 1001, + "executeTime": "2025-09-30 14:30:00", + "duration": 32, + "accessory": "http://49.232.154.205:10900/inspection-videos/inspection_1001_1696056600000.mp4;http://49.232.154.205:10900/inspection-videos/processed_1696056632000.mp4", + "result": "共检测到 5 个问题,详情:垃圾(3) 烟雾(2)", + "status": 0 +} +``` + +### 生成的告警 + +```json +{ + "alarmId": 3001, + "deviceId": 5001, + "taskId": 1001, + "alarmType": "detection", + "alarmContent": "垃圾 - 置信度: 0.95", + "imageOssId": 4001, + "framePosition": 150, + "confidence": 0.95, + "status": 0 +} +``` + +## 🛠️ 维护和优化 + +### 1. 清理历史数据 + +```sql +-- 删除30天前的记录 +DELETE FROM v_inspection_task_record +WHERE execute_time < DATE_SUB(NOW(), INTERVAL 30 DAY); + +-- 删除已处理的告警 +DELETE FROM v_alarm_record +WHERE status = 1 AND create_time < DATE_SUB(NOW(), INTERVAL 7 DAY); +``` + +### 2. 性能监控 + +```sql +-- 查看最近的执行统计 +SELECT + DATE(execute_time) as date, + COUNT(*) as total_executions, + SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) as success_count, + AVG(duration) as avg_duration +FROM v_inspection_task_record +WHERE execute_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) +GROUP BY DATE(execute_time) +ORDER BY date DESC; +``` + +### 3. 告警统计 + +```sql +-- 查看最近的告警统计 +SELECT + DATE(create_time) as date, + alarm_type, + COUNT(*) as count, + AVG(confidence) as avg_confidence +FROM v_alarm_record +WHERE create_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) +GROUP BY DATE(create_time), alarm_type +ORDER BY date DESC, count DESC; +``` + +## 📞 故障排查 + +### 问题1: Record未创建 + +**症状**:执行任务但`v_inspection_task_record`表无数据 + +**检查**: +```sql +SELECT * FROM v_inspection_task WHERE task_id = ?; +``` + +**解决**: +- 确认任务状态为0(待执行) +- 查看后端日志 +- 检查Mapper XML配置 + +### 问题2: Accessory为空 + +**症状**:Record创建了但accessory字段为空 + +**检查**: +```bash +# 查看MinIO上传日志 +docker-compose logs backend | grep "MinIO" + +# 测试MinIO连接 +curl http://49.232.154.205:10900/minio/health/live +``` + +**解决**: +- 确认MinIO服务可访问 +- 检查application.yml中的MinIO配置 +- 确认bucket已创建 + +### 问题3: Result为空 + +**症状**:视频已保存但result字段为空 + +**检查**: +```bash +# 查看Python服务日志 +docker-compose logs python-service + +# 测试Python服务 +curl http://rtsp-python-service:8000/api/models +``` + +**解决**: +- 确认Python服务运行正常 +- 确认best.pt模型文件存在 +- 检查容器间网络通信 + +### 问题4: 告警重复 + +**症状**:相同对象产生多个告警 + +**调整去重参数**: +```java +// 在generateDetectionKey中增大容差 +int x = rect.x() / 20 * 20; // 从10改为20 +int y = rect.y() / 20 * 20; +``` + +## 📖 相关文档 + +- `INSPECTION-WORKFLOW.md` - 详细工作流程 +- `YOLOV8-SETUP.md` - YOLOv8模型配置 +- `DEPLOYMENT-NOTES.md` - 部署配置说明 +- `DOCKER-QUICK-START.md` - Docker快速开始 + +--- + +**功能状态**: ✅ 已实现 +**测试状态**: 待测试 +**文档版本**: 1.0 +**最后更新**: 2025-09-30 + +�� **巡检任务记录功能已完整实现!** \ No newline at end of file diff --git a/INSPECTION-WORKFLOW.md b/INSPECTION-WORKFLOW.md new file mode 100644 index 0000000..63c9d1e --- /dev/null +++ b/INSPECTION-WORKFLOW.md @@ -0,0 +1,544 @@ +# 巡检任务工作流程说明 + +## 📋 功能概述 + +本文档说明巡检任务的完整执行流程,包括视频录制、保存、AI识别和告警创建。 + +## 🔄 完整工作流程 + +### 1. 任务启动 + +当巡检任务启动时: + +``` +InspectionTaskServiceImpl.executeInspectionTask(taskId) + ├── 创建 InspectionTaskRecord(记录ID) + ├── 更新任务状态为"执行中" + └── 调用 performVideoAnalysisWithRecord() +``` + +### 2. 视频录制和保存 + +``` +performVideoAnalysisWithRecord() + ├── 从RTSP流抓取视频 + ├── 录制指定时长的视频 + ├── 保存为临时文件 + ├── 上传视频到MinIO + ├── 更新InspectionTaskRecord.accessory(视频URL) + └── 调用Python服务进行分析 +``` + +### 3. AI识别处理 + +``` +VideoAnalysisService.analyzeVideoWithRecord() + ├── 创建HttpYoloDetector(连接Python服务) + ├── 逐帧分析视频 + ├── 每10帧调用一次Python API检测 + ├── 绘制检测框 + ├── 去重检测结果(避免重复告警) + ├── 创建告警记录 + ├── 上传处理后的视频 + ├── 生成检测结果摘要 + └── 更新InspectionTaskRecord.result(识别结果) +``` + +### 4. 告警创建(去重) + +``` +createAlarmRecordForRecord() + ├── 提取检测区域图像 + ├── 上传告警图片到MinIO + ├── 创建AlarmRecord + │ ├── 设备ID + │ ├── 告警类型 + │ ├── 告警内容(检测类别+置信度) + │ ├── 关联的任务ID + │ ├── 图片URL + │ └── 帧位置 + └── 保存到数据库(仅新检测的对象) +``` + +## 📊 数据表关系 + +``` +InspectionTask (巡检任务) + ↓ 1:N +InspectionTaskRecord (巡检记录) + ├── accessory: 原始视频URL + 处理后视频URL + ├── result: AI识别结果摘要 + ├── duration: 执行时长 + └── status: 0=成功, 1=失败, 2=部分成功 + +InspectionTaskRecord → AlarmRecord (1:N) + ├── 同一个record可以有多个告警 + └── 告警自动去重(相同位置的相同对象只记录一次) +``` + +## 🎯 关键字段说明 + +### InspectionTaskRecord + +| 字段 | 说明 | 示例 | +|------|------|------| +| recordId | 记录ID | 自增主键 | +| taskId | 关联的任务ID | 1001 | +| executeTime | 执行时间 | 2025-09-30 14:30:00 | +| duration | 执行时长(秒) | 30 | +| accessory | 附件URL | video1.mp4;video2.mp4 | +| result | 识别结果 | 共检测到3个问题,详情:垃圾(2) 烟雾(1) | +| status | 执行状态 | 0=成功, 1=失败, 2=部分成功 | + +### AlarmRecord + +| 字段 | 说明 | 示例 | +|------|------|------| +| alarmId | 告警ID | 自增主键 | +| deviceId | 设备ID | 1001 | +| taskId | 任务ID | 1001 | +| alarmType | 告警类型 | detection | +| alarmContent | 告警内容 | 垃圾 - 置信度: 0.95 | +| imageOssId | 告警图片ID | MinIO对象ID | +| framePosition | 视频帧位置 | 150 | +| confidence | 置信度 | 0.95 | +| status | 处理状态 | 0=未处理, 1=已处理 | + +## 🔧 关键实现细节 + +### 1. 去重机制 + +使用`generateDetectionKey`生成唯一键: + +```java +private String generateDetectionKey(Detection detection) { + Rect rect = detection.getRect(); + // 取10的倍数,允许小范围波动 + int x = rect.x() / 10 * 10; + int y = rect.y() / 10 * 10; + int w = rect.width() / 10 * 10; + int h = rect.height() / 10 * 10; + return String.format("%s_%d_%d_%d_%d", detection.getLabel(), x, y, w, h); +} +``` + +**原理**: +- 相同类别 + 相似位置 → 认为是同一个对象 +- 允许10像素的波动 +- 超过60秒未检测到自动清除 + +### 2. Python服务调用 + +使用容器名调用: + +```java +private static final String PYTHON_API_URL = "http://rtsp-python-service:8000/api/detect/file"; +private static final String MODEL_NAME = "yolov8_detector"; +``` + +### 3. 视频处理流程 + +``` +RTSP流 → FFmpegFrameGrabber → 录制 → 临时文件 + → 上传MinIO → 保存URL到record.accessory + → 逐帧分析 → 调用Python API → 绘制检测框 + → 保存处理后视频 → 追加URL到record.accessory + → 更新record.result +``` + +### 4. 附件字段格式 + +`accessory`字段使用分号分隔多个URL: + +``` +原始视频URL;处理后视频URL +``` + +示例: +``` +http://minio.com/inspection-videos/inspection_1001_1234567890.mp4;http://minio.com/inspection-videos/processed_1234567891.mp4 +``` + +## 🚀 使用方法 + +### 1. 创建巡检任务 + +```java +InspectionTask task = new InspectionTask(); +task.setDeviceId(deviceId); +task.setDuration(30); // 录制30秒 +task.setStatus(0); // 待执行 + +inspectionTaskService.insertInspectionTask(task); +``` + +### 2. 启动任务 + +```java +// 异步执行 +inspectionTaskService.executeInspectionTask(taskId); +``` + +### 3. 查看执行记录 + +```sql +-- 查询某任务的所有执行记录 +SELECT * FROM v_inspection_task_record WHERE task_id = 1001 ORDER BY execute_time DESC; + +-- 查询成功的记录 +SELECT * FROM v_inspection_task_record WHERE status = 0; + +-- 查询某记录的所有告警 +SELECT * FROM v_alarm_record WHERE task_id = 1001; +``` + +### 4. 查看告警 + +```sql +-- 查询某任务的所有告警 +SELECT * FROM v_alarm_record WHERE task_id = 1001 ORDER BY create_time DESC; + +-- 查询未处理的告警 +SELECT * FROM v_alarm_record WHERE status = 0; +``` + +## 📝 执行示例 + +### 执行流程 + +1. **任务创建** + ``` + Task ID: 1001 + Device ID: 5001 + Duration: 30秒 + ``` + +2. **记录创建** + ``` + Record ID: 2001 + Task ID: 1001 + Execute Time: 2025-09-30 14:30:00 + Status: 1 (执行中) + ``` + +3. **视频录制** + ``` + 录制30秒视频 + 保存到MinIO: inspection_1001_1234567890.mp4 + 更新Record.accessory: http://minio.com/.../inspection_1001_1234567890.mp4 + ``` + +4. **AI识别** + ``` + 调用Python服务 + 检测到: 垃圾(2个), 烟雾(1个) + ``` + +5. **告警创建**(去重) + ``` + Alarm 1: 垃圾 - 位置(100,200) - 置信度0.95 + Alarm 2: 垃圾 - 位置(300,400) - 置信度0.87 + Alarm 3: 烟雾 - 位置(500,100) - 置信度0.92 + ``` + +6. **处理后视频** + ``` + 带检测框的视频上传 + 保存到MinIO: processed_1234567891.mp4 + 更新Record.accessory: 原始URL;处理后URL + ``` + +7. **更新记录** + ``` + Record.result: "共检测到 3 个问题,详情:垃圾(2) 烟雾(1)" + Record.status: 0 (成功) + Record.duration: 32秒 + ``` + +## ⚙️ 配置说明 + +### Python服务配置 + +在Docker环境中,Python服务地址为: +``` +http://rtsp-python-service:8000 +``` + +### 模型配置 + +确保Python服务使用正确的模型名称: +```json +{ + "name": "yolov8_detector", + "path": "models/yolov8_model.py", + "size": [640, 640] +} +``` + +### 检测参数 + +在`VideoAnalysisService`中可调整: + +```java +// 检测频率(每N帧检测一次) +if (frameCount % 10 == 0) { ... } + +// 去重时间窗口(秒) +detectedGarbageCache.entrySet().removeIf(entry -> + (currentId - entry.getValue()) > grabber.getFrameRate() * 60); +``` + +## 🐛 故障排查 + +### 问题1: 视频未保存 + +**检查**: +```sql +-- 查看record的accessory字段 +SELECT record_id, accessory FROM v_inspection_task_record WHERE task_id = ?; + +-- 查看MinIO对象 +SELECT * FROM v_minio_object WHERE bucket_name = 'inspection-videos' ORDER BY create_time DESC; +``` + +**解决**: +- 检查MinIO服务是否可用 +- 检查网络连接 +- 查看后端日志 + +### 问题2: Python识别未执行 + +**检查**: +```bash +# 查看Python服务日志 +docker-compose logs python-service + +# 测试Python服务 +curl http://rtsp-python-service:8000/health +curl http://rtsp-python-service:8000/api/models +``` + +**解决**: +- 确认Python服务运行正常 +- 确认模型文件存在 +- 检查网络连通性 + +### 问题3: 告警未创建 + +**检查**: +```sql +-- 查看告警记录 +SELECT * FROM v_alarm_record WHERE task_id = ? ORDER BY create_time DESC; + +-- 查看检测结果 +SELECT result FROM v_inspection_task_record WHERE record_id = ?; +``` + +**解决**: +- 检查检测置信度阈值 +- 查看视频内容是否有检测对象 +- 检查Python服务返回结果 + +### 问题4: 重复告警 + +**检查**: +- 去重机制是否正常工作 +- `generateDetectionKey`逻辑是否合理 + +**调整**: +```java +// 调整去重的位置容差 +int x = rect.x() / 20 * 20; // 从10改为20,更宽松的去重 +``` + +## 📊 性能优化 + +### 1. 检测频率 + +```java +// 降低检测频率以提升性能(CPU模式) +if (frameCount % 30 == 0) { // 从10改为30 + // 每30帧检测一次 +} +``` + +### 2. 视频质量 + +```java +// 降低视频比特率节省存储 +recorder.setVideoBitrate(500000); // 降低比特率 +``` + +### 3. 去重时间窗口 + +```java +// 缩短去重时间窗口 +(currentId - entry.getValue()) > grabber.getFrameRate() * 30 // 从60秒改为30秒 +``` + +## 🔍 调试方法 + +### 查看执行日志 + +```bash +# 查看后端日志 +docker-compose logs -f backend | grep "inspection" + +# 查看Python服务日志 +docker-compose logs -f python-service + +# 查看特定记录的处理过程 +docker-compose logs backend | grep "recordId=2001" +``` + +### 数据库查询 + +```sql +-- 查看最新的执行记录 +SELECT + r.record_id, + r.task_id, + r.execute_time, + r.duration, + r.status, + r.result, + LENGTH(r.accessory) as accessory_length +FROM v_inspection_task_record r +ORDER BY r.create_time DESC +LIMIT 10; + +-- 查看记录对应的告警 +SELECT + a.alarm_id, + a.alarm_content, + a.confidence, + a.frame_position, + a.create_time +FROM v_alarm_record a +WHERE a.task_id = ? +ORDER BY a.create_time DESC; + +-- 统计告警数量 +SELECT + r.record_id, + r.execute_time, + COUNT(a.alarm_id) as alarm_count +FROM v_inspection_task_record r +LEFT JOIN v_alarm_record a ON r.task_id = a.task_id + AND a.create_time >= r.execute_time + AND a.create_time <= DATE_ADD(r.execute_time, INTERVAL r.duration SECOND) +GROUP BY r.record_id +ORDER BY r.create_time DESC; +``` + +## 💡 扩展建议 + +### 1. 添加检测类型过滤 + +在`createAlarmRecordForRecord`中: + +```java +// 只对特定类型创建告警 +List alarmTypes = Arrays.asList("垃圾", "烟雾", "火焰"); +if (!alarmTypes.contains(detection.getLabel())) { + return; // 忽略其他类型 +} +``` + +### 2. 添加置信度阈值 + +```java +// 只对高置信度的检测创建告警 +if (detection.getConfidence() < 0.7) { + return; // 忽略低置信度检测 +} +``` + +### 3. 添加区域过滤 + +```java +// 只对特定区域的检测创建告警 +Rect rect = detection.getRect(); +if (!isInMonitorArea(rect, task)) { + return; // 忽略监控区域外的检测 +} +``` + +### 4. 添加告警级别 + +```java +// 根据检测类型设置告警级别 +String alarmLevel = "medium"; +if (detection.getLabel().contains("火焰")) { + alarmLevel = "high"; +} else if (detection.getLabel().contains("垃圾")) { + alarmLevel = "low"; +} +alarmRecord.setAlarmLevel(alarmLevel); +``` + +## 🔒 安全考虑 + +### 1. 异常处理 + +所有方法都包含完整的异常处理: +- 视频录制失败 → 更新record状态为失败 +- Python服务调用失败 → 记录错误但不影响整体流程 +- MinIO上传失败 → 记录错误并回滚 + +### 2. 资源清理 + +使用try-finally确保资源释放: +- FFmpegFrameGrabber自动关闭 +- FFmpegFrameRecorder自动关闭 +- 临时文件自动删除 + +### 3. 并发控制 + +使用`@Async`异步执行,避免阻塞: +- 任务执行不阻塞API响应 +- 多个任务可并发执行 +- 通过runningTasks避免重复执行 + +## 📈 监控指标 + +### 建议监控的指标 + +1. **执行成功率** + ```sql + SELECT + COUNT(CASE WHEN status = 0 THEN 1 END) * 100.0 / COUNT(*) as success_rate + FROM v_inspection_task_record + WHERE execute_time >= DATE_SUB(NOW(), INTERVAL 1 DAY); + ``` + +2. **平均执行时长** + ```sql + SELECT AVG(duration) as avg_duration + FROM v_inspection_task_record + WHERE status = 0 AND execute_time >= DATE_SUB(NOW(), INTERVAL 1 DAY); + ``` + +3. **告警统计** + ```sql + SELECT + alarm_type, + COUNT(*) as count + FROM v_alarm_record + WHERE create_time >= DATE_SUB(NOW(), INTERVAL 1 DAY) + GROUP BY alarm_type; + ``` + +## 📞 技术支持 + +如有问题,请查看: +1. 后端日志:`docker-compose logs backend` +2. Python服务日志:`docker-compose logs python-service` +3. 数据库记录:查询`v_inspection_task_record`和`v_alarm_record`表 +4. MinIO对象:查询`v_minio_object`表 + +--- + +**文档版本**: 1.0 +**最后更新**: 2025-09-30 +**适用版本**: YOLOv8, Docker Compose部署 \ No newline at end of file diff --git a/README-DOCKER.md b/README-DOCKER.md new file mode 100644 index 0000000..c4f106b --- /dev/null +++ b/README-DOCKER.md @@ -0,0 +1,304 @@ +# RTSP视频分析系统 Docker部署方案 + +## 📋 目录结构 + +``` +rtsp-video-analysis-system/ +├── .env # 环境变量配置文件 +├── docker-compose.yml # Docker Compose编排文件 +├── ruoyi-admin/ +│ └── Dockerfile # Java后端Dockerfile +├── rtsp-vue/ +│ ├── Dockerfile # 前端Dockerfile +│ └── nginx.conf # Nginx配置文件 +├── python-inference-service/ +│ └── Dockerfile # Python推理服务Dockerfile +└── sql/ # 数据库初始化脚本 +``` + +## 🚀 快速开始 + +### 1. 前置要求 + +- Docker 20.10+ +- Docker Compose 2.0+ +- (可选)NVIDIA Docker Runtime(如需GPU支持) + +### 2. 准备工作 + +#### 2.1 配置环境变量 + +编辑 `.env` 文件,根据需要调整配置: + +```bash +# MySQL数据库配置 +MYSQL_ROOT_PASSWORD=ruoyi123 +MYSQL_DATABASE=ry-vue +MYSQL_USER=ruoyi +MYSQL_PASSWORD=ruoyi123 + +# MinIO对象存储配置 +MINIO_ROOT_USER=minio +MINIO_ROOT_PASSWORD=minio123 + +# 其他配置... +``` + +#### 2.2 准备数据库初始化脚本 + +将数据库SQL脚本放到 `sql/` 目录: + +```bash +# 示例 +cp sql/fad_watch.sql sql/ +cp sql/ry_face.sql sql/ +``` + +### 3. 启动服务 + +#### 3.1 构建并启动所有服务 + +```bash +# 启动所有服务 +docker-compose up -d + +# 查看服务状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f +``` + +#### 3.2 查看特定服务日志 + +```bash +# 查看后端日志 +docker-compose logs -f backend + +# 查看前端日志 +docker-compose logs -f frontend + +# 查看Python服务日志 +docker-compose logs -f python-service +``` + +### 4. 访问服务 + +- **前端界面**: http://localhost:10080 (或配置的FRONTEND_PORT) +- **后端API**: 通过前端的 `/prod-api/` 路径访问 +- **Python推理API**: 通过前端的 `/python-api/` 路径访问 +- **MinIO控制台**: 仅容器内部访问(如需外部访问,需修改docker-compose.yml) + +> 注意:除前端外,其他服务端口均不对外暴露,仅容器间通信使用。 + +## 🔧 服务说明 + +### 服务列表 + +| 服务名称 | 容器名称 | 端口(内部) | 端口(外部) | 说明 | +|---------|---------|------------|------------|------| +| mysql | rtsp-mysql | 3306 | - | MySQL数据库 | +| redis | rtsp-redis | 6379 | - | Redis缓存 | +| python-service | rtsp-python-service | 8000 | - | Python推理服务(CPU) | +| backend | rtsp-backend | 8080 | - | Java后端服务 | +| frontend | rtsp-frontend | 80 | 10080 | Vue前端服务 | + +**注意**: +- MinIO使用外部已部署的服务(http://49.232.154.205:10900) +- Python推理服务使用CPU模式,不需要GPU + +### 服务依赖关系 + +``` +frontend + └── backend + ├── mysql + ├── redis + ├── python-service + └── minio (外部服务) +``` + +## ⚙️ 高级配置 + +### 1. 修改前端暴露端口 + +编辑 `.env` 文件: + +```bash +FRONTEND_PORT=8080 # 修改为你想要的端口 +``` + +### 2. 如需外部访问其他服务 + +编辑 `docker-compose.yml`,在对应服务下添加 `ports` 配置: + +```yaml +mysql: + # ... 其他配置 + ports: + - "3306:3306" # 添加端口映射 +``` + +### 3. 调整Java后端内存 + +编辑 `ruoyi-admin/Dockerfile`: + +```dockerfile +ENV JAVA_OPTS="-Xms1g -Xmx2g -Djava.security.egd=file:/dev/./urandom" +``` + +或在 `docker-compose.yml` 中添加环境变量: + +```yaml +backend: + environment: + JAVA_OPTS: "-Xms1g -Xmx2g" +``` + +### 4. MinIO配置 + +本系统使用**外部已部署的MinIO服务**,配置在 `ruoyi-admin/src/main/resources/application.yml`: + +```yaml +minio: + enabled: true + endpoint: http://49.232.154.205:10900 + access-key: 4EsLD9g9OM09DT0HaBKj + secret-key: 05SFC5fleqTnaLRYBrxHiphMFYbGX5nYicj0WCHA + bucket: rtsp +``` + +如需修改MinIO配置,请编辑上述文件。 + +## 🔍 故障排查 + +### 1. 查看服务健康状态 + +```bash +docker-compose ps +``` + +健康的服务会显示 `healthy` 状态。 + +### 2. 查看容器日志 + +```bash +# 查看所有服务日志 +docker-compose logs + +# 查看特定服务的最新日志 +docker-compose logs --tail=100 -f backend +``` + +### 3. 进入容器调试 + +```bash +# 进入后端容器 +docker exec -it rtsp-backend sh + +# 进入前端容器 +docker exec -it rtsp-frontend sh + +# 进入Python服务容器 +docker exec -it rtsp-python-service bash +``` + +### 4. 重启服务 + +```bash +# 重启单个服务 +docker-compose restart backend + +# 重启所有服务 +docker-compose restart +``` + +### 5. 完全重建服务 + +```bash +# 停止并删除所有容器 +docker-compose down + +# 重新构建并启动 +docker-compose up -d --build +``` + +### 6. 清理数据重新开始 + +```bash +# 停止并删除所有容器和数据卷 +docker-compose down -v + +# 重新启动 +docker-compose up -d +``` + +## 📦 数据持久化 + +以下数据会持久化存储: + +- `mysql-data`: MySQL数据库数据 +- `redis-data`: Redis持久化数据 +- `backend-logs`: 后端服务日志 +- `backend-upload`: 后端上传文件 + +**注意**:MinIO数据存储在外部服务器,不在本地 + +## 🔒 安全建议 + +1. **修改默认密码**:部署到生产环境前,请修改 `.env` 中的所有默认密码 +2. **限制网络访问**:使用防火墙规则限制对前端端口的访问 +3. **HTTPS配置**:在生产环境中,建议配置HTTPS(可使用Nginx反向代理+Let's Encrypt) +4. **定期备份**:定期备份数据卷内容 + +## 🔄 更新部署 + +```bash +# 1. 拉取最新代码 +git pull + +# 2. 重新构建镜像 +docker-compose build + +# 3. 重启服务 +docker-compose up -d +``` + +## 📝 常见问题 + +### Q1: 后端启动失败,提示连接不到数据库 + +**A**: 检查MySQL是否已完全启动。使用 `docker-compose logs mysql` 查看MySQL日志。 + +### Q2: 前端无法连接后端API + +**A**: 确认nginx配置中的后端地址是否正确,应该使用容器名 `rtsp-backend` 而不是 `localhost`。 + +### Q3: Python推理服务启动慢 + +**A**: +1. 首次启动需要下载Ultralytics YOLOv8依赖,这可能需要一些时间 +2. 确保`python-inference-service/models/best.pt`文件存在 +3. 使用 `docker-compose logs python-service` 查看进度 + +### Q4: 如何更换MinIO服务 + +**A**: 修改 `ruoyi-admin/src/main/resources/application.yml` 中的MinIO配置: +```yaml +minio: + endpoint: http://your-minio-server:port + access-key: your-access-key + secret-key: your-secret-key + bucket: your-bucket +``` + +## 📞 支持 + +如有问题,请查看: +- 项目GitHub Issues +- 查看服务日志进行调试 +- 检查环境变量配置是否正确 + +## �� 许可证 + +[根据项目实际许可证填写] \ No newline at end of file diff --git a/START-HERE.md b/START-HERE.md new file mode 100644 index 0000000..fdcc82f --- /dev/null +++ b/START-HERE.md @@ -0,0 +1,135 @@ +# 🚀 快速开始 - 从这里开始! + +## 📌 您需要的一切 + +### 第一步:部署系统 + +```bash +# 1. 准备YOLOv8模型文件 +# 将训练好的 best.pt 放到: +python-inference-service/models/best.pt + +# 2. 运行部署脚本 +deploy.bat # Windows用户 + +# 3. 访问系统 +# 打开浏览器:http://localhost:10080 +``` + +**就这么简单!** 🎊 + +--- + +## 📖 详细文档 + +### 🐳 Docker部署 + +| 文档 | 用途 | 优先级 | +|------|------|--------| +| `FINAL-SUMMARY.md` | Docker配置总结 | ⭐⭐⭐ | +| `DOCKER-QUICK-START.md` | 常用命令 | ⭐⭐⭐ | +| `README-DOCKER.md` | 完整部署文档 | ⭐⭐ | +| `DEPLOYMENT-NOTES.md` | 配置细节 | ⭐ | + +### 🤖 YOLOv8模型 + +| 文档 | 用途 | 优先级 | +|------|------|--------| +| `YOLOV8-SETUP.md` | 模型配置指南 | ⭐⭐⭐ | +| `python-inference-service/README.md` | Python服务文档 | ⭐⭐ | + +### 📹 巡检任务 + +| 文档 | 用途 | 优先级 | +|------|------|--------| +| `INSPECTION-FEATURE-SUMMARY.md` | 功能总结 | ⭐⭐⭐ | +| `INSPECTION-WORKFLOW.md` | 详细流程 | ⭐⭐ | + +### 📝 更新记录 + +| 文档 | 用途 | +|------|------| +| `COMPLETE-SUMMARY.md` | 完整更新总结 | +| `UPDATE-SUMMARY.md` | 变更记录 | + +--- + +## 🎯 核心配置 + +### 系统架构 + +``` +浏览器 :10080 (唯一对外端口) + ↓ +前端 (Nginx) + ↓ +后端 + Python服务 (内部) + ↓ +MySQL + Redis + MinIO(外部) +``` + +### 关键特性 + +- ✅ **YOLOv8**: CPU模式,无需GPU +- ✅ **MinIO**: 使用外部服务 +- ✅ **端口**: 只暴露10080 +- ✅ **记录**: 自动创建巡检记录 +- ✅ **视频**: 自动保存到MinIO +- ✅ **识别**: 调用YOLOv8分析 +- ✅ **告警**: 自动去重 + +--- + +## 🔧 常用命令 + +```bash +# 启动服务 +docker-compose up -d + +# 查看状态 +docker-compose ps + +# 查看日志 +docker-compose logs -f backend +docker-compose logs -f python-service + +# 重启服务 +docker-compose restart + +# 停止服务 +docker-compose down +``` + +--- + +## 📞 遇到问题? + +### 部署问题 +→ 查看 `DOCKER-QUICK-START.md` + +### 模型问题 +→ 查看 `YOLOV8-SETUP.md` + +### 功能问题 +→ 查看 `INSPECTION-FEATURE-SUMMARY.md` + +### 其他问题 +→ 查看日志:`docker-compose logs [服务名]` + +--- + +## ✨ 下一步 + +1. ✅ 部署系统 - `deploy.bat` +2. ✅ 访问前端 - http://localhost:10080 +3. ✅ 创建巡检任务 +4. ✅ 查看执行记录和告警 + +--- + +**快速帮助**: +- 📘 完整功能:`COMPLETE-SUMMARY.md` +- 🚀 快速参考:`DOCKER-QUICK-START.md` +- 🤖 模型配置:`YOLOV8-SETUP.md` + +**祝使用愉快!** 🎉 \ No newline at end of file diff --git a/UPDATE-SUMMARY.md b/UPDATE-SUMMARY.md new file mode 100644 index 0000000..002240d --- /dev/null +++ b/UPDATE-SUMMARY.md @@ -0,0 +1,295 @@ +# 🎉 Docker部署方案更新总结 + +根据您的需求,已完成以下更新: + +## ✅ 主要变更 + +### 1. YOLOv5 → YOLOv8 (Ultralytics) + +| 变更项 | 之前 | 现在 | +|-------|------|------| +| Python包 | `yolov5>=7.0.0` | `ultralytics>=8.0.0` | +| 模型文件 | `garbage_model.py` | `yolov8_model.py` | +| 模型名称 | `garbage_detector` | `yolov8_detector` | +| API框架 | yolov5 | Ultralytics YOLO | + +**优势:** +- ✨ 更高的检测精度 +- ⚡ 更快的推理速度 +- 🎯 更简单的API接口 +- 📚 更好的官方文档支持 + +### 2. 前端端口调整 + +| 变更项 | 之前 | 现在 | +|-------|------|------| +| 对外端口 | 80 | 10080 | +| 容器内部端口 | 80 | 80 | +| 访问地址 | http://localhost | http://localhost:10080 | + +**修改文件:** +- `.env` - `FRONTEND_PORT=10080` +- 所有文档中的访问地址 + +## 📦 新增文件 + +### 1. YOLOv8模型支持 +- `python-inference-service/models/yolov8_model.py` - YOLOv8模型包装类 +- `YOLOV8-SETUP.md` - YOLOv8配置和使用指南 +- 更新 `python-inference-service/README.md` - 详细的YOLOv8文档 + +### 2. 配置文件更新 +- 更新 `python-inference-service/requirements.txt` - ultralytics依赖 +- 更新 `python-inference-service/models/models.json` - 指向yolov8_model + +### 3. 文档更新 +- `README-DOCKER.md` - 更新端口和YOLOv8说明 +- `DOCKER-QUICK-START.md` - 更新访问地址和YOLOv8说明 +- `deploy.bat` / `deploy.sh` - 更新访问地址 +- `UPDATE-SUMMARY.md` - 本文档 + +## 🚀 使用指南 + +### 准备工作 + +1. **准备YOLOv8模型文件** + ```bash + # 将训练好的模型放到指定位置 + python-inference-service/models/best.pt + ``` + +2. **创建类别文件**(可选) + ```bash + # 创建 classes.txt,每行一个类别名 + python-inference-service/models/classes.txt + ``` + +3. **检查环境变量** + ```bash + # 查看 .env 文件,确认端口配置 + FRONTEND_PORT=10080 + ``` + +### 快速部署 + +#### Windows +```batch +deploy.bat +``` + +#### Linux/Mac +```bash +chmod +x deploy.sh +./deploy.sh +``` + +#### 手动部署 +```bash +# 1. 启动所有服务 +docker-compose up -d + +# 2. 查看服务状态 +docker-compose ps + +# 3. 查看日志 +docker-compose logs -f python-service +``` + +### 访问系统 + +部署成功后访问: +- **前端界面**: http://localhost:10080 +- **后端API**: http://localhost:10080/prod-api/ +- **Python API**: http://localhost:10080/python-api/ +- **API文档**: http://localhost:10080/prod-api/swagger-ui.html + +## 📋 YOLOv8模型要求 + +### 模型训练 + +如果需要训练新模型: + +```python +from ultralytics import YOLO + +# 加载预训练模型 +model = YOLO('yolov8n.pt') # n/s/m/l/x + +# 训练 +results = model.train( + data='data.yaml', + epochs=100, + imgsz=640, + batch=16 +) + +# 模型保存在 runs/detect/train/weights/best.pt +``` + +### 模型部署 + +```bash +# 1. 复制模型到项目 +cp runs/detect/train/weights/best.pt python-inference-service/models/ + +# 2. 创建类别文件 +cat > python-inference-service/models/classes.txt << EOF +class1 +class2 +class3 +EOF + +# 3. 重启服务 +docker-compose restart python-service +``` + +## 🔧 配置说明 + +### 环境变量(.env) + +所有配置集中在一个文件: + +```bash +# 前端端口 +FRONTEND_PORT=10080 + +# MySQL +MYSQL_HOST=rtsp-mysql +MYSQL_PORT=3306 +MYSQL_PASSWORD=ruoyi123 + +# Redis +REDIS_HOST=rtsp-redis + +# MinIO +MINIO_HOST=rtsp-minio +MINIO_ACCESS_KEY=minio +MINIO_SECRET_KEY=minio123 + +# Python服务 +PYTHON_SERVICE_HOST=rtsp-python-service +``` + +### 模型配置(models.json) + +```json +[ + { + "name": "yolov8_detector", + "path": "models/yolov8_model.py", + "size": [640, 640] + } +] +``` + +### 性能调优 + +编辑 `yolov8_model.py`: + +```python +# 置信度阈值 +self.conf_threshold = 0.25 # 默认0.25 + +# 输入尺寸 +self.img_size = 640 # 320/640/1280 +``` + +## 🎯 服务架构 + +``` +┌─────────────┐ +│ Browser │ +│ :10080 │ ← 唯一对外暴露 +└──────┬──────┘ + │ +┌──────▼──────┐ +│ Frontend │ +│ Nginx │ +└──────┬──────┘ + │ + ┌───┴────────────────┐ + │ │ +┌──▼───────┐ ┌──────▼──────┐ +│ Backend │ │ Python │ +│ :8080 │ │ Service │ +│ │ │ :8000(CPU) │ +└───┬──┬───┘ └─────────────┘ + │ │ +┌───▼──▼───┐ ┌─────────────┐ +│ MySQL │ │ MinIO(外部) │ +│ Redis │ │ 49.232... │ +└──────────┘ └─────────────┘ +``` + +## ⚠️ 重要提示 + +### 必须操作 +1. ✅ 将 `best.pt` 文件放到 `python-inference-service/models/` 目录 +2. ✅ 确保模型是 YOLOv8 训练的(不支持YOLOv5直接使用) +3. ✅ 首次启动等待1-2分钟 + +### 可选操作 +1. 📝 创建 `classes.txt` 类别文件 +2. 🎨 调整置信度阈值 +3. 🔧 修改MinIO配置(如需更换服务器) + +### 常见问题 + +#### Q1: 模型加载失败 +```bash +# 检查模型文件 +ls -lh python-inference-service/models/best.pt + +# 查看日志 +docker-compose logs python-service +``` + +#### Q2: 端口被占用 +```bash +# 修改 .env 文件 +FRONTEND_PORT=8080 # 改为其他端口 + +# 重启服务 +docker-compose down +docker-compose up -d +``` + +#### Q3: 如何更换MinIO服务 +```bash +# 编辑 ruoyi-admin/src/main/resources/application.yml +minio: + endpoint: http://your-server:port + access-key: your-key + secret-key: your-secret + bucket: your-bucket +``` + +## 📖 文档导航 + +- 📘 **完整部署文档**: `README-DOCKER.md` +- 🚀 **快速开始**: `DOCKER-QUICK-START.md` +- 🤖 **YOLOv8配置**: `YOLOV8-SETUP.md` +- 📋 **文件清单**: `DEPLOYMENT-FILES.md` +- 🐍 **Python服务**: `python-inference-service/README.md` + +## 🎓 学习资源 + +- [Ultralytics YOLOv8官方文档](https://docs.ultralytics.com/) +- [YOLOv8训练教程](https://docs.ultralytics.com/modes/train/) +- [Docker部署最佳实践](https://docs.docker.com/develop/dev-best-practices/) + +## 💡 技术支持 + +遇到问题? + +1. 查看日志:`docker-compose logs [服务名]` +2. 查看服务状态:`docker-compose ps` +3. 查看相关文档 +4. 检查模型文件和配置 + +--- + +**更新日期**: 2025-09-30 +**适用版本**: Docker Compose 3.8+, YOLOv8 8.0+ + +✅ 所有更新已完成,可以直接使用! \ No newline at end of file diff --git a/YOLOV8-SETUP.md b/YOLOV8-SETUP.md new file mode 100644 index 0000000..96ae14f --- /dev/null +++ b/YOLOV8-SETUP.md @@ -0,0 +1,314 @@ +# YOLOv8模型配置指南 + +本系统使用**YOLOv8**(Ultralytics)进行目标检测推理。 + +## 快速开始 + +### 1. 准备模型文件 + +将YOLOv8训练好的模型放到指定位置: + +```bash +# 模型文件路径 +python-inference-service/models/best.pt +``` + +### 2. 准备类别文件(可选) + +创建 `classes.txt` 文件,每行一个类别名称: + +```bash +# 文件路径 +python-inference-service/models/classes.txt + +# 内容示例 +person +car +truck +bicycle +``` + +### 3. 启动服务 + +```bash +# 使用部署脚本 +deploy.bat # Windows +./deploy.sh # Linux/Mac + +# 或手动启动 +docker-compose up -d +``` + +## 训练YOLOv8模型 + +如果还没有训练好的模型,可以按以下步骤训练: + +### 1. 安装Ultralytics + +```bash +pip install ultralytics +``` + +### 2. 准备数据集 + +创建数据集配置文件 `data.yaml`: + +```yaml +# 数据集路径 +path: /path/to/dataset +train: images/train +val: images/val + +# 类别 +nc: 4 # 类别数量 +names: ['person', 'car', 'truck', 'bicycle'] # 类别名称 +``` + +### 3. 训练模型 + +```python +from ultralytics import YOLO + +# 加载预训练模型 +model = YOLO('yolov8n.pt') # n, s, m, l, x 可选 + +# 训练模型 +results = model.train( + data='data.yaml', + epochs=100, + imgsz=640, + batch=16, + device=0 # GPU设备ID,CPU使用'cpu' +) + +# 训练完成后,最佳模型保存在 runs/detect/train/weights/best.pt +``` + +### 4. 导出模型 + +训练完成后,将最佳模型复制到项目中: + +```bash +# 复制模型文件 +cp runs/detect/train/weights/best.pt python-inference-service/models/best.pt + +# 创建类别文件 +echo "person +car +truck +bicycle" > python-inference-service/models/classes.txt +``` + +## 模型配置 + +### 修改模型参数 + +编辑 `python-inference-service/models/yolov8_model.py`: + +```python +# 置信度阈值 +self.conf_threshold = 0.25 # 默认0.25,降低可检测更多目标 + +# 输入图像尺寸 +self.img_size = 640 # 默认640,可改为320、1280等 +``` + +### 配置文件 + +`python-inference-service/models/models.json`: + +```json +[ + { + "name": "yolov8_detector", + "path": "models/yolov8_model.py", + "size": [640, 640], + "comment": "YOLOv8检测模型" + } +] +``` + +参数说明: +- `name`: 模型名称(API调用时使用) +- `path`: 模型包装类路径 +- `size`: 输入图像尺寸 [宽度, 高度] + +## 多模型配置 + +如果有多个模型,可以配置多个: + +```json +[ + { + "name": "person_detector", + "path": "models/person_model.py", + "size": [640, 640] + }, + { + "name": "vehicle_detector", + "path": "models/vehicle_model.py", + "size": [640, 640] + } +] +``` + +然后为每个模型创建对应的模型文件,参考 `yolov8_model.py`。 + +## 测试模型 + +### 本地测试 + +```python +from ultralytics import YOLO + +# 加载模型 +model = YOLO('python-inference-service/models/best.pt') + +# 测试图像 +results = model('test.jpg') + +# 显示结果 +results[0].show() + +# 打印检测结果 +for r in results: + print(r.boxes) +``` + +### API测试 + +启动服务后,使用curl测试: + +```bash +# 1. 检查服务健康 +curl http://localhost:10080/python-api/health + +# 2. 查看可用模型 +curl http://localhost:10080/python-api/api/models + +# 3. 测试检测(文件上传) +curl -X POST "http://localhost:10080/python-api/api/detect/file" \ + -F "model_name=yolov8_detector" \ + -F "file=@test.jpg" +``` + +## 性能优化 + +### 1. 使用GPU + +确保Docker配置了GPU支持: + +```yaml +# docker-compose.yml +python-service: + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] +``` + +### 2. 选择合适的模型大小 + +YOLOv8提供多种尺寸: + +| 模型 | 参数量 | 速度 | 精度 | +|------|--------|------|------| +| YOLOv8n | 3.2M | 最快 | 较低 | +| YOLOv8s | 11.2M | 快 | 中等 | +| YOLOv8m | 25.9M | 中等 | 较高 | +| YOLOv8l | 43.7M | 慢 | 高 | +| YOLOv8x | 68.2M | 最慢 | 最高 | + +根据需求选择: +- 实时处理 → 使用 `yolov8n.pt` 或 `yolov8s.pt` +- 高精度 → 使用 `yolov8l.pt` 或 `yolov8x.pt` + +### 3. 调整图像尺寸 + +```python +# 在 yolov8_model.py 中 +self.img_size = 320 # 更快但精度降低 +# 或 +self.img_size = 1280 # 更慢但精度提高 +``` + +## 常见问题 + +### Q1: 模型加载失败 + +``` +错误:FileNotFoundError: best.pt not found +解决:确保模型文件在 python-inference-service/models/best.pt +``` + +### Q2: GPU不可用 + +``` +错误:CUDA not available +解决: +1. 检查NVIDIA驱动安装 +2. 检查Docker GPU支持 +3. 使用 docker run --gpus all 测试GPU +``` + +### Q3: 检测结果为空 + +``` +原因: +1. 置信度阈值太高 +2. 模型未正确训练 +3. 输入图像与训练数据差异大 + +解决: +1. 降低 conf_threshold +2. 检查模型训练情况 +3. 检查输入图像质量 +``` + +### Q4: 推理速度慢 + +``` +解决: +1. 使用GPU加速 +2. 使用更小的模型(如yolov8n) +3. 减小输入图像尺寸 +4. 批量处理多张图像 +``` + +## 模型版本差异 + +### YOLOv5 vs YOLOv8 + +本系统已从YOLOv5升级到YOLOv8: + +| 特性 | YOLOv5 | YOLOv8 | +|------|--------|--------| +| 精度 | 较高 | 更高 | +| 速度 | 快 | 更快 | +| API | yolov5 | ultralytics | +| 训练 | 复杂 | 简单 | + +### 迁移说明 + +如果之前使用YOLOv5模型: + +1. 使用YOLOv8重新训练(推荐) +2. 或使用 `garbage_model.py` 作为模板支持YOLOv5 + +## 参考资料 + +- [Ultralytics YOLOv8文档](https://docs.ultralytics.com/) +- [YOLOv8 GitHub](https://github.com/ultralytics/ultralytics) +- [模型训练教程](https://docs.ultralytics.com/modes/train/) +- [推理示例](https://docs.ultralytics.com/modes/predict/) + +## 技术支持 + +如遇问题: +1. 查看服务日志:`docker-compose logs python-service` +2. 查看模型加载日志 +3. 测试模型是否可以本地加载 +4. 检查环境配置是否正确 \ No newline at end of file diff --git a/deploy.bat b/deploy.bat new file mode 100644 index 0000000..b83e360 --- /dev/null +++ b/deploy.bat @@ -0,0 +1,54 @@ +@echo off +chcp 65001 >nul +echo ============================================ +echo RTSP视频分析系统 Docker部署 +echo ============================================ +echo. + +REM 检查Docker是否运行 +docker info >nul 2>&1 +if %errorlevel% neq 0 ( + echo [错误] Docker未运行或未安装 + echo 请先启动Docker Desktop + pause + exit /b 1 +) + +echo [1/5] 检查环境文件... +if not exist .env ( + echo [错误] .env文件不存在 + echo 请先配置.env文件 + pause + exit /b 1 +) + +echo [2/5] 停止现有容器... +docker-compose down + +echo [3/5] 构建Docker镜像... +docker-compose build + +echo [4/5] 启动所有服务... +docker-compose up -d + +echo [5/5] 等待服务启动... +timeout /t 10 /nobreak >nul + +echo. +echo ============================================ +echo 部署完成! +echo ============================================ +echo. +echo 查看服务状态: docker-compose ps +echo 查看日志: docker-compose logs -f +echo. +echo 访问地址: +echo 前端界面: http://localhost:10080 +echo. +echo 按任意键查看服务状态... +pause >nul + +docker-compose ps + +echo. +pause diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..725e1ae --- /dev/null +++ b/deploy.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +echo "============================================" +echo "RTSP视频分析系统 Docker部署" +echo "============================================" +echo "" + +# 检查Docker是否运行 +if ! docker info > /dev/null 2>&1; then + echo "[错误] Docker未运行或未安装" + echo "请先安装并启动Docker" + exit 1 +fi + +echo "[1/5] 检查环境文件..." +if [ ! -f .env ]; then + echo "[错误] .env文件不存在" + echo "请先配置.env文件" + exit 1 +fi + +echo "[2/5] 停止现有容器..." +docker-compose down + +echo "[3/5] 构建Docker镜像..." +docker-compose build + +echo "[4/5] 启动所有服务..." +docker-compose up -d + +echo "[5/5] 等待服务启动..." +sleep 10 + +echo "" +echo "============================================" +echo "部署完成!" +echo "============================================" +echo "" +echo "查看服务状态: docker-compose ps" +echo "查看日志: docker-compose logs -f" +echo "" +echo "访问地址:" +echo " 前端界面: http://localhost:10080" +echo "" +echo "服务状态:" +docker-compose ps diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cc0bef6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,125 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: ${MYSQL_HOST} + restart: always + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + TZ: ${TZ} + volumes: + - mysql-data:/var/lib/mysql + - ./sql:/docker-entrypoint-initdb.d + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-authentication-plugin=mysql_native_password + networks: + - rtsp-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: ${REDIS_HOST} + restart: always + command: redis-server --appendonly yes ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}} + volumes: + - redis-data:/data + networks: + - rtsp-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + python-service: + build: + context: ./python-inference-service + dockerfile: Dockerfile + container_name: ${PYTHON_SERVICE_HOST} + restart: always + environment: + TZ: ${TZ} + MODEL_DIR: /app/models + volumes: + - ./python-inference-service/models:/app/models + networks: + - rtsp-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + backend: + build: + context: . + dockerfile: ruoyi-admin/Dockerfile + container_name: ${BACKEND_HOST} + restart: always + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + python-service: + condition: service_started + environment: + TZ: ${TZ} + SPRING_DATASOURCE_URL: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai + SPRING_DATASOURCE_USERNAME: ${MYSQL_USER} + SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD} + SPRING_DATA_REDIS_HOST: ${REDIS_HOST} + SPRING_DATA_REDIS_PORT: ${REDIS_PORT} + SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD} + PYTHON_API_URL: http://${PYTHON_SERVICE_HOST}:${PYTHON_SERVICE_PORT} + volumes: + - backend-logs:/app/logs + - backend-upload:/app/upload + networks: + - rtsp-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + frontend: + build: + context: ./rtsp-vue + dockerfile: Dockerfile + args: + BACKEND_API_URL: http://${BACKEND_HOST}:${BACKEND_PORT} + container_name: rtsp-frontend + restart: always + ports: + - "${FRONTEND_PORT}:80" + depends_on: + backend: + condition: service_healthy + environment: + TZ: ${TZ} + networks: + - rtsp-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80"] + interval: 30s + timeout: 10s + retries: 3 + +networks: + rtsp-network: + driver: bridge + +volumes: + mysql-data: + redis-data: + backend-logs: + backend-upload: \ No newline at end of file diff --git a/python-inference-service/.dockerignore b/python-inference-service/.dockerignore new file mode 100644 index 0000000..6046e71 --- /dev/null +++ b/python-inference-service/.dockerignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +*.egg-info/ +dist/ +build/ + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# CI/CD +.github +.gitlab-ci.yml + +# Documentation +*.md +README* + +# Scripts +*.bat +*.sh + +# Large model files (will be mounted as volume) +models/*.pt +models/*.onnx +models/*.pth + +# Logs +*.log +logs/ \ No newline at end of file diff --git a/python-inference-service/Dockerfile b/python-inference-service/Dockerfile new file mode 100644 index 0000000..46c3ce8 --- /dev/null +++ b/python-inference-service/Dockerfile @@ -0,0 +1,45 @@ +# 使用支持CUDA的PyTorch基础镜像 +FROM pytorch/pytorch:2.0.1-cuda11.7-cudnn8-runtime + +# 设置工作目录 +WORKDIR /app + +# 设置pip镜像源 +RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + libgomp1 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# 复制requirements.txt +COPY requirements.txt . + +# 安装Python依赖 +RUN pip install --no-cache-dir -r requirements.txt + +# 复制应用代码 +COPY app/ /app/app/ + +# 创建models目录 +RUN mkdir -p /app/models + +# 设置环境变量 +ENV PYTHONPATH=/app +ENV MODEL_DIR=/app/models +ENV PYTHONUNBUFFERED=1 + +# 暴露端口(仅内部使用) +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# 启动应用 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] \ No newline at end of file diff --git a/python-inference-service/README.md b/python-inference-service/README.md new file mode 100644 index 0000000..d105685 --- /dev/null +++ b/python-inference-service/README.md @@ -0,0 +1,252 @@ +# Python推理服务 + +基于FastAPI的YOLOv8目标检测推理服务。 + +## 功能特性 + +- 支持YOLOv8模型推理 +- RESTful API接口 +- 支持Base64图像和文件上传 +- 支持GPU加速(可选) +- Docker部署支持 + +## 模型要求 + +本服务使用**YOLOv8**(Ultralytics)进行目标检测。 + +### 模型文件准备 + +1. **模型文件**: 将YOLOv8训练好的模型文件命名为`best.pt`,放在`models/`目录下 +2. **类别文件**: (可选)创建`classes.txt`文件,每行一个类别名称 +3. **配置文件**: `models.json`配置模型参数 + +### 目录结构 + +``` +python-inference-service/ +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI应用 +│ ├── detector.py # 检测器封装 +│ └── models.py # 数据模型 +├── models/ +│ ├── best.pt # YOLOv8模型文件(必需) +│ ├── classes.txt # 类别名称(可选) +│ ├── yolov8_model.py # YOLOv8模型包装类 +│ └── models.json # 模型配置 +├── requirements.txt +└── Dockerfile +``` + +## 安装依赖 + +```bash +pip install -r requirements.txt +``` + +主要依赖: +- `ultralytics>=8.0.0` - YOLOv8框架 +- `fastapi` - Web框架 +- `uvicorn` - ASGI服务器 +- `opencv-python` - 图像处理 +- `torch` - PyTorch + +## 配置模型 + +编辑`models/models.json`: + +```json +[ + { + "name": "yolov8_detector", + "path": "models/yolov8_model.py", + "size": [640, 640], + "comment": "YOLOv8检测模型" + } +] +``` + +参数说明: +- `name`: 模型名称(API调用时使用) +- `path`: 模型包装类的路径 +- `size`: 输入图像尺寸 [宽度, 高度] + +## 启动服务 + +### 本地启动 + +```bash +# 启动服务(默认端口8000) +uvicorn app.main:app --host 0.0.0.0 --port 8000 + +# 或使用启动脚本 +python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +### Docker启动 + +```bash +# 构建镜像 +docker build -t python-inference-service . + +# 运行容器 +docker run -p 8000:8000 \ + -v $(pwd)/models:/app/models \ + python-inference-service +``` + +### 使用GPU + +```bash +# 确保安装了NVIDIA Docker Runtime +docker run --gpus all -p 8000:8000 \ + -v $(pwd)/models:/app/models \ + python-inference-service +``` + +## API接口 + +服务启动后访问:http://localhost:8000/docs 查看API文档 + +### 1. 健康检查 + +```bash +GET /health +``` + +### 2. 获取可用模型列表 + +```bash +GET /api/models +``` + +### 3. Base64图像检测 + +```bash +POST /api/detect +Content-Type: application/json + +{ + "model_name": "yolov8_detector", + "image_data": "base64_encoded_image_string" +} +``` + +### 4. 文件上传检测 + +```bash +POST /api/detect/file +Content-Type: multipart/form-data + +model_name: yolov8_detector +file: +``` + +## 响应格式 + +```json +{ + "model_name": "yolov8_detector", + "detections": [ + { + "label": "[yolov8_detector] 类别名", + "confidence": 0.95, + "x": 100, + "y": 150, + "width": 200, + "height": 180, + "color": 65280 + } + ], + "inference_time": 45.6 +} +``` + +## 自定义模型 + +要使用自己训练的YOLOv8模型: + +1. **训练模型**:使用Ultralytics YOLOv8训练您的模型 + ```python + from ultralytics import YOLO + + model = YOLO('yolov8n.yaml') + model.train(data='your_data.yaml', epochs=100) + ``` + +2. **导出模型**:训练完成后会生成`best.pt`文件 + +3. **准备类别文件**:创建`classes.txt` + ``` + class1 + class2 + class3 + ``` + +4. **放置文件**:将`best.pt`和`classes.txt`放到`models/`目录 + +5. **更新配置**:确保`models.json`配置正确 + +6. **重启服务** + +## 环境变量 + +- `MODEL_DIR`: 模型目录路径(默认:`/app/models`) +- `MODELS_JSON`: 模型配置文件路径(默认:`models/models.json`) + +## 性能优化 + +### GPU加速 + +服务会自动检测GPU并使用。如果有多张GPU,可以指定: + +```bash +CUDA_VISIBLE_DEVICES=0 uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +### 置信度阈值 + +在`yolov8_model.py`中调整: + +```python +self.conf_threshold = 0.25 # 降低阈值检测更多目标 +``` + +## 故障排查 + +### 模型加载失败 + +``` +错误:找不到 best.pt +解决:确保模型文件在 models/ 目录下 +``` + +### GPU不可用 + +``` +错误:CUDA not available +解决: +1. 检查NVIDIA驱动 +2. 检查PyTorch GPU版本 +3. 检查CUDA版本兼容性 +``` + +### 推理速度慢 + +``` +解决: +1. 使用GPU加速 +2. 使用更小的模型(如yolov8n.pt) +3. 减小输入图像尺寸 +``` + +## 开发者 + +如需修改或扩展功能,请参考: +- `app/main.py` - API路由定义 +- `app/detector.py` - 检测器基类 +- `models/yolov8_model.py` - YOLOv8模型包装类 + +## 许可证 + +[根据项目实际许可证填写] \ No newline at end of file diff --git a/python-inference-service/app/__init__.py b/python-inference-service/app/__init__.py new file mode 100644 index 0000000..bd27e9a --- /dev/null +++ b/python-inference-service/app/__init__.py @@ -0,0 +1 @@ +# Python Inference Service package \ No newline at end of file diff --git a/python-inference-service/app/detector.py b/python-inference-service/app/detector.py new file mode 100644 index 0000000..d155ea1 --- /dev/null +++ b/python-inference-service/app/detector.py @@ -0,0 +1,311 @@ +import os +import cv2 +import numpy as np +import time +from typing import List, Dict, Tuple, Optional +import importlib.util +import sys + +from app.models import Detection + + +class PythonModelDetector: + """Object detector using native Python models""" + + def __init__(self, model_name: str, model_path: str, input_width: int, input_height: int, color: int = 0x00FF00): + """ + Initialize detector with Python model + + Args: + model_name: Name of the model + model_path: Path to the Python model file (.py) + input_width: Input width for the model + input_height: Input height for the model + color: RGB color for detection boxes (default: green) + """ + self.model_name = model_name + self.input_width = input_width + self.input_height = input_height + self.color = color + + # Convert color from RGB to BGR (OpenCV uses BGR) + self.color_bgr = ((color & 0xFF) << 16) | (color & 0xFF00) | ((color >> 16) & 0xFF) + + # Default confidence thresholds + self.conf_threshold = 0.25 + self.nms_threshold = 0.45 + + # Load the Python model dynamically + self._load_python_model(model_path) + + # Load class names if available + self.classes = [] + model_dir = os.path.dirname(model_path) + classes_path = os.path.join(model_dir, "classes.txt") + if os.path.exists(classes_path): + with open(classes_path, 'r') as f: + self.classes = [line.strip() for line in f.readlines() if line.strip()] + + def _load_python_model(self, model_path: str): + """Load Python model dynamically""" + if not os.path.exists(model_path): + raise FileNotFoundError(f"Model file not found: {model_path}") + + # Get model directory and file name + model_dir = os.path.dirname(model_path) + model_file = os.path.basename(model_path) + model_name = os.path.splitext(model_file)[0] + + # Add model directory to system path + if model_dir not in sys.path: + sys.path.append(model_dir) + + # Import the model module + spec = importlib.util.spec_from_file_location(model_name, model_path) + if spec is None: + raise ImportError(f"Failed to load model specification: {model_path}") + + model_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(model_module) + + # Check if the module has the required interface + if not hasattr(model_module, "Model"): + raise AttributeError(f"Model module must define a 'Model' class: {model_path}") + + # Create model instance + self.model = model_module.Model() + + # Check if model has the required methods + if not hasattr(self.model, "predict"): + raise AttributeError(f"Model must implement 'predict' method: {model_path}") + + def preprocess(self, img: np.ndarray) -> np.ndarray: + """Preprocess image for model input""" + # Ensure BGR image + if len(img.shape) == 2: # Grayscale + img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) + elif img.shape[2] == 4: # BGRA + img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR) + + # Resize to model input size + resized = cv2.resize(img, (self.input_width, self.input_height)) + + # Use model's preprocess method if available + if hasattr(self.model, "preprocess"): + return self.model.preprocess(resized) + + # Default preprocessing: normalize to [0, 1] + return resized / 255.0 + + def detect(self, img: np.ndarray) -> Tuple[List[Detection], float]: + """ + Detect objects in an image + + Args: + img: Input image in BGR format (OpenCV) + + Returns: + List of Detection objects and inference time in milliseconds + """ + if img is None or img.size == 0: + return [], 0.0 + + # Original image dimensions + img_height, img_width = img.shape[:2] + + # Preprocess image + processed_img = self.preprocess(img) + + # Measure inference time + start_time = time.time() + + try: + # Run inference using model's predict method + # Expected return format from model's predict: + # List of dicts with keys: 'bbox', 'class_id', 'confidence' + # bbox: (x, y, w, h) normalized [0-1] + model_results = self.model.predict(processed_img) + + # Calculate inference time in milliseconds + inference_time = (time.time() - start_time) * 1000 + + # Convert model results to Detection objects + detections = [] + + for result in model_results: + # Skip low confidence detections + confidence = result.get('confidence', 0) + if confidence < self.conf_threshold: + continue + + # Get bounding box (normalized coordinates) + bbox = result.get('bbox', [0, 0, 0, 0]) + + # Denormalize bbox to image coordinates + x = int(bbox[0] * img_width) + y = int(bbox[1] * img_height) + w = int(bbox[2] * img_width) + h = int(bbox[3] * img_height) + + # Skip invalid boxes + if w <= 0 or h <= 0: + continue + + # Get class ID and name + class_id = result.get('class_id', 0) + class_name = f"cls{class_id}" + if 0 <= class_id < len(self.classes): + class_name = self.classes[class_id] + + # Create Detection object + label = f"[{self.model_name}] {class_name}" + detection = Detection( + label=label, + confidence=confidence, + x=x, + y=y, + width=w, + height=h, + color=self.color + ) + detections.append(detection) + + # Apply NMS if model doesn't do it internally + if hasattr(self.model, "applies_nms") and self.model.applies_nms: + return detections, inference_time + else: + # Convert detections to boxes and scores + boxes = [(d.x, d.y, d.width, d.height) for d in detections] + scores = [d.confidence for d in detections] + + if boxes: + # Apply NMS + indices = self._non_max_suppression(boxes, scores, self.nms_threshold) + detections = [detections[i] for i in indices] + + return detections, inference_time + + except Exception as e: + print(f"Error during detection: {str(e)}") + return [], (time.time() - start_time) * 1000 + + def _non_max_suppression(self, boxes: List[Tuple], scores: List[float], threshold: float) -> List[int]: + """Apply Non-Maximum Suppression to remove overlapping boxes""" + # Sort by score in descending order + indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) + + keep = [] + while indices: + # Get index with highest score + current = indices.pop(0) + keep.append(current) + + # No more indices to process + if not indices: + break + + # Get current box + x1, y1, w1, h1 = boxes[current] + x2_1 = x1 + w1 + y2_1 = y1 + h1 + area1 = w1 * h1 + + # Check remaining boxes + i = 0 + while i < len(indices): + # Get box to compare + idx = indices[i] + x2, y2, w2, h2 = boxes[idx] + x2_2 = x2 + w2 + y2_2 = y2 + h2 + area2 = w2 * h2 + + # Calculate intersection + xx1 = max(x1, x2) + yy1 = max(y1, y2) + xx2 = min(x2_1, x2_2) + yy2 = min(y2_1, y2_2) + + # Calculate intersection area + w = max(0, xx2 - xx1) + h = max(0, yy2 - yy1) + intersection = w * h + + # Calculate IoU + union = area1 + area2 - intersection + 1e-9 # Avoid division by zero + iou = intersection / union + + # Remove box if IoU is above threshold + if iou > threshold: + indices.pop(i) + else: + i += 1 + + return keep + + def close(self): + """Close the model resources""" + if hasattr(self.model, "close"): + self.model.close() + self.model = None + + +class ModelManager: + """Model manager for detectors""" + + def __init__(self): + self.models = {} + + def load(self, models_config: List[Dict]): + """ + Load models from configuration + + Args: + models_config: List of model configurations + """ + # Basic color palette for different models + palette = [0x00FF00, 0xFF8000, 0x00A0FF, 0xFF00FF, 0x00FFFF, 0xFF0000, 0x80FF00] + + for i, model_config in enumerate(models_config): + name = model_config.get("name") + path = model_config.get("path") + size = model_config.get("size", [640, 640]) + + if not name or not path or not os.path.exists(path): + print(f"Skipping model: {name} - Invalid configuration") + continue + + try: + # Use color from palette + color = palette[i % len(palette)] + + # Create detector for Python model + detector = PythonModelDetector( + model_name=name, + model_path=path, + input_width=size[0], + input_height=size[1], + color=color + ) + + self.models[name] = detector + print(f"Model loaded: {name} ({path})") + except Exception as e: + print(f"Failed to load model {name}: {str(e)}") + + def get(self, name: str) -> Optional[PythonModelDetector]: + """Get detector by name""" + return self.models.get(name) + + def all(self) -> List[PythonModelDetector]: + """Get all detectors""" + return list(self.models.values()) + + def close(self): + """Close all detectors""" + for detector in self.models.values(): + try: + detector.close() + except: + pass + self.models.clear() \ No newline at end of file diff --git a/python-inference-service/app/main.py b/python-inference-service/app/main.py new file mode 100644 index 0000000..3af65bc --- /dev/null +++ b/python-inference-service/app/main.py @@ -0,0 +1,164 @@ +import os +import base64 +import cv2 +import json +import numpy as np +from typing import Dict, List +from fastapi import FastAPI, HTTPException, File, UploadFile +from fastapi.middleware.cors import CORSMiddleware +import uvicorn + +from app.models import Detection, DetectionRequest, DetectionResponse, ModelInfo, ModelsResponse +from app.detector import ModelManager + +# Initialize FastAPI app +app = FastAPI( + title="Python Model Inference Service", + description="API for object detection using Python models", + version="1.0.0" +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Initialize model manager +model_manager = None + +# Load models from configuration +@app.on_event("startup") +async def startup_event(): + global model_manager + model_manager = ModelManager() + + # Look for models.json configuration file + models_json_path = os.getenv("MODELS_JSON", os.path.join(os.path.dirname(__file__), "..", "models", "models.json")) + + if os.path.exists(models_json_path): + try: + with open(models_json_path, "r") as f: + models_config = json.load(f) + model_manager.load(models_config) + print(f"Loaded model configuration from {models_json_path}") + except Exception as e: + print(f"Failed to load models from {models_json_path}: {str(e)}") + else: + print(f"Models configuration not found: {models_json_path}") + +@app.on_event("shutdown") +async def shutdown_event(): + global model_manager + if model_manager: + model_manager.close() + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "ok"} + +@app.get("/api/models", response_model=ModelsResponse) +async def get_models(): + """Get available models""" + global model_manager + + if not model_manager: + raise HTTPException(status_code=500, detail="Model manager not initialized") + + detectors = model_manager.all() + models = [] + + for detector in detectors: + model_info = ModelInfo( + name=detector.model_name, + path=getattr(detector, 'model_path', ''), + size=[detector.input_width, detector.input_height], + backend="Python", + loaded=True + ) + models.append(model_info) + + return ModelsResponse(models=models) + +@app.post("/api/detect", response_model=DetectionResponse) +async def detect(request: DetectionRequest): + """Detect objects in an image""" + global model_manager + + if not model_manager: + raise HTTPException(status_code=500, detail="Model manager not initialized") + + # Get detector for requested model + detector = model_manager.get(request.model_name) + if not detector: + raise HTTPException(status_code=404, detail=f"Model not found: {request.model_name}") + + # Decode base64 image + try: + # Remove data URL prefix if present + if "base64," in request.image_data: + image_data = request.image_data.split("base64,")[1] + else: + image_data = request.image_data + + # Decode base64 image + image_bytes = base64.b64decode(image_data) + nparr = np.frombuffer(image_bytes, np.uint8) + image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + if image is None: + raise HTTPException(status_code=400, detail="Invalid image data") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to decode image: {str(e)}") + + # Run detection + detections, inference_time = detector.detect(image) + + return DetectionResponse( + model_name=request.model_name, + detections=detections, + inference_time=inference_time + ) + +@app.post("/api/detect/file", response_model=DetectionResponse) +async def detect_file( + model_name: str, + file: UploadFile = File(...) +): + """Detect objects in an uploaded image file""" + global model_manager + + if not model_manager: + raise HTTPException(status_code=500, detail="Model manager not initialized") + + # Get detector for requested model + detector = model_manager.get(model_name) + if not detector: + raise HTTPException(status_code=404, detail=f"Model not found: {model_name}") + + # Read uploaded file + try: + contents = await file.read() + nparr = np.frombuffer(contents, np.uint8) + image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + + if image is None: + raise HTTPException(status_code=400, detail="Invalid image data") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to process image: {str(e)}") + + # Run detection + detections, inference_time = detector.detect(image) + + return DetectionResponse( + model_name=model_name, + detections=detections, + inference_time=inference_time + ) + +if __name__ == "__main__": + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/python-inference-service/app/models.py b/python-inference-service/app/models.py new file mode 100644 index 0000000..b64ea8f --- /dev/null +++ b/python-inference-service/app/models.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel +from typing import List, Optional + + +class Detection(BaseModel): + """Object detection result""" + label: str + confidence: float + x: int + y: int + width: int + height: int + color: int = 0x00FF00 # Default green color + + +class DetectionRequest(BaseModel): + """Request for model inference on image data""" + model_name: str + image_data: str # Base64 encoded image + + +class DetectionResponse(BaseModel): + """Response with detection results""" + model_name: str + detections: List[Detection] + inference_time: float # Time in milliseconds + + +class ModelInfo(BaseModel): + """Model information""" + name: str + path: str + size: List[int] # [width, height] + backend: str = "ONNX" + loaded: bool = False + + +class ModelsResponse(BaseModel): + """Response with available models""" + models: List[ModelInfo] \ No newline at end of file diff --git a/python-inference-service/models/best.pt b/python-inference-service/models/best.pt new file mode 100644 index 0000000..14a51d7 Binary files /dev/null and b/python-inference-service/models/best.pt differ diff --git a/python-inference-service/models/classes.txt b/python-inference-service/models/classes.txt new file mode 100644 index 0000000..ec7cbc7 --- /dev/null +++ b/python-inference-service/models/classes.txt @@ -0,0 +1 @@ +垃圾 \ No newline at end of file diff --git a/python-inference-service/models/garbage_model.py b/python-inference-service/models/garbage_model.py new file mode 100644 index 0000000..1c7f6ea --- /dev/null +++ b/python-inference-service/models/garbage_model.py @@ -0,0 +1,207 @@ +import os +import numpy as np +import cv2 +from typing import List, Dict, Any +import torch + +class Model: + """ + 垃圾识别模型 - 直接加载 PyTorch 模型文件 + """ + + def __init__(self): + """初始化模型""" + # 获取当前文件所在目录路径 + model_dir = os.path.dirname(os.path.abspath(__file__)) + # 模型文件路径 + model_path = os.path.join(model_dir, "best.pt") + + print(f"正在加载垃圾识别模型: {model_path}") + + # 加载 PyTorch 模型 + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + print(f"使用设备: {self.device}") + + # 使用 YOLOv5 或通用方式加载模型 + try: + # 尝试使用 YOLOv5 加载 + import sys + sys.path.append(os.path.dirname(model_dir)) # 添加父目录到路径 + + try: + # 方法1: 如果安装了 YOLOv5 + import yolov5 + self.model = yolov5.load(model_path, device=self.device) + self.yolov5_api = True + print("使用 YOLOv5 包加载模型") + except (ImportError, ModuleNotFoundError): + # 方法2: 直接加载 YOLO 代码 + from models.yolov5_utils import attempt_load + self.model = attempt_load(model_path, device=self.device) + self.yolov5_api = False + print("使用内置 YOLOv5 工具加载模型") + + except Exception as e: + # 方法3: 通用 PyTorch 加载 + print(f"YOLOv5 加载失败: {e}") + print("使用通用 PyTorch 加载") + self.model = torch.load(model_path, map_location=self.device) + if isinstance(self.model, dict) and 'model' in self.model: + self.model = self.model['model'] + self.yolov5_api = False + + # 如果是 ScriptModule,设置为评估模式 + if isinstance(self.model, torch.jit.ScriptModule): + self.model.eval() + elif hasattr(self.model, 'eval'): + self.model.eval() + + # 加载类别名称 + self.classes = [] + classes_path = os.path.join(model_dir, "classes.txt") + if os.path.exists(classes_path): + with open(classes_path, 'r', encoding='utf-8') as f: + self.classes = [line.strip() for line in f.readlines() if line.strip()] + print(f"已加载 {len(self.classes)} 个类别") + else: + # 如果模型自带类别信息 + if hasattr(self.model, 'names') and self.model.names: + self.classes = self.model.names + print(f"使用模型自带类别,共 {len(self.classes)} 个类别") + else: + print("未找到类别文件,将使用数字索引作为类别名") + + # 设置识别参数 + self.conf_threshold = 0.25 # 置信度阈值 + self.img_size = 640 # 默认输入图像大小 + + print("垃圾识别模型加载完成") + + def preprocess(self, image: np.ndarray) -> np.ndarray: + """预处理图像""" + # 如果是使用 YOLOv5 API,不需要预处理 + if hasattr(self, 'yolov5_api') and self.yolov5_api: + return image + + # 默认预处理:调整大小并归一化 + img = cv2.resize(image, (self.img_size, self.img_size)) + + # BGR 转 RGB + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + # 归一化 [0, 255] -> [0, 1] + img = img / 255.0 + + # HWC -> CHW (高度,宽度,通道 -> 通道,高度,宽度) + img = img.transpose(2, 0, 1) + + # 转为 torch tensor + img = torch.from_numpy(img).float() + + # 添加批次维度 + img = img.unsqueeze(0) + + # 移至设备 + img = img.to(self.device) + + return img + + def predict(self, image: np.ndarray) -> List[Dict[str, Any]]: + """模型推理""" + original_height, original_width = image.shape[:2] + + try: + # 如果使用 YOLOv5 API + if hasattr(self, 'yolov5_api') and self.yolov5_api: + # YOLOv5 API 直接处理图像 + results = self.model(image) + + # 提取检测结果 + predictions = results.pred[0] # 第一批次的预测 + + detections = [] + for *xyxy, conf, cls_id in predictions.cpu().numpy(): + x1, y1, x2, y2 = xyxy + + # 转换为归一化坐标 (x, y, w, h) + x = x1 / original_width + y = y1 / original_height + w = (x2 - x1) / original_width + h = (y2 - y1) / original_height + + # 整数类别 ID + cls_id = int(cls_id) + + # 获取类别名称 + class_name = f"cls{cls_id}" + if 0 <= cls_id < len(self.classes): + class_name = self.classes[cls_id] + + # 添加检测结果 + if conf >= self.conf_threshold: + detections.append({ + 'bbox': (x, y, w, h), + 'class_id': cls_id, + 'confidence': float(conf) + }) + + return detections + + else: + # 通用 PyTorch 模型处理 + # 预处理图像 + img = self.preprocess(image) + + # 推理 + with torch.no_grad(): + outputs = self.model(img) + + # 后处理结果(这里需要根据模型输出格式调整) + detections = [] + + # 假设输出格式是 YOLO 风格:[batch_idx, x1, y1, x2, y2, conf, cls_id] + if isinstance(outputs, torch.Tensor) and outputs.dim() == 2 and outputs.size(1) >= 6: + for *xyxy, conf, cls_id in outputs.cpu().numpy(): + if conf >= self.conf_threshold: + x1, y1, x2, y2 = xyxy + + # 转换为归一化坐标 (x, y, w, h) + x = x1 / original_width + y = y1 / original_height + w = (x2 - x1) / original_width + h = (y2 - y1) / original_height + + # 整数类别 ID + cls_id = int(cls_id) + + detections.append({ + 'bbox': (x, y, w, h), + 'class_id': cls_id, + 'confidence': float(conf) + }) + # 处理其他可能的输出格式 + else: + # 这里需要根据模型的实际输出格式进行适配 + print("警告:无法识别的模型输出格式,请检查模型类型") + + return detections + + except Exception as e: + print(f"推理过程中出错: {str(e)}") + # 出错时返回空结果 + return [] + + @property + def applies_nms(self) -> bool: + """模型是否内部应用了 NMS""" + # YOLOv5 会自动应用 NMS + return True + + def close(self): + """释放资源""" + if hasattr(self, 'model'): + # 删除模型以释放 GPU 内存 + del self.model + if torch.cuda.is_available(): + torch.cuda.empty_cache() + print("垃圾识别模型已关闭") \ No newline at end of file diff --git a/python-inference-service/models/models.json b/python-inference-service/models/models.json new file mode 100644 index 0000000..a3eaf3b --- /dev/null +++ b/python-inference-service/models/models.json @@ -0,0 +1,8 @@ +[ + { + "name": "yolov8_detector", + "path": "models/yolov8_model.py", + "size": [640, 640], + "comment": "YOLOv8检测模型,确保将训练好的best.pt文件放在models目录下" + } +] \ No newline at end of file diff --git a/python-inference-service/models/smoke_detector.py b/python-inference-service/models/smoke_detector.py new file mode 100644 index 0000000..caea6e0 --- /dev/null +++ b/python-inference-service/models/smoke_detector.py @@ -0,0 +1,126 @@ +import numpy as np +import cv2 +from typing import List, Dict, Any, Tuple + +class Model: + """ + Smoke detection model implementation + + This is a simple example that could be replaced with an actual + TensorFlow, PyTorch, or other ML framework implementation. + """ + + def __init__(self): + """Initialize smoke detection model""" + # In a real implementation, you would load your model here + print("Smoke detection model initialized") + + # Define smoke class IDs + self.smoke_classes = { + 0: "smoke", + 1: "fire" + } + + def preprocess(self, image: np.ndarray) -> np.ndarray: + """Preprocess image for model input""" + # Convert BGR to grayscale for smoke detection + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + # Convert back to 3 channels to match model expected input shape + gray_3ch = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) + + # In a real implementation, you would do normalization, etc. + return gray_3ch + + def predict(self, image: np.ndarray) -> List[Dict[str, Any]]: + """ + Run smoke detection on the image + + This is a simplified example that uses basic image processing + In a real implementation, you would use your ML model + """ + # Convert to grayscale for processing + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) + + # Apply Gaussian blur to reduce noise + blurred = cv2.GaussianBlur(gray, (15, 15), 0) + + # Simple thresholding to find potential smoke regions + # In a real implementation, you'd use a trained model + _, thresh = cv2.threshold(blurred, 100, 255, cv2.THRESH_BINARY) + + # Find contours in the thresholded image + contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + # Process contours to find potential smoke regions + detections = [] + height, width = image.shape[:2] + + for contour in contours: + # Get bounding box + x, y, w, h = cv2.boundingRect(contour) + + # Filter small regions + if w > width * 0.05 and h > height * 0.05: + # Calculate area ratio + area = cv2.contourArea(contour) + rect_area = w * h + fill_ratio = area / rect_area if rect_area > 0 else 0 + + # Smoke tends to have irregular shapes + # This is just for demonstration purposes + if fill_ratio > 0.2 and fill_ratio < 0.8: + # Normalize coordinates + x_norm = x / width + y_norm = y / height + w_norm = w / width + h_norm = h / height + + # Determine if it's smoke or fire (just a simple heuristic for demo) + # In a real model, this would be determined by the model prediction + class_id = 0 # Default to smoke + + # Check if the region has high red values (fire) + roi = image[y:y+h, x:x+w] + if roi.size > 0: # Make sure ROI is not empty + avg_color = np.mean(roi, axis=(0, 1)) + if avg_color[2] > 150 and avg_color[2] > avg_color[0] * 1.5: # High red, indicating fire + class_id = 1 # Fire + + # Calculate confidence based on fill ratio + # This is just for demonstration + confidence = 0.5 + fill_ratio * 0.3 + + # Add to detections + detections.append({ + 'bbox': (x_norm, y_norm, w_norm, h_norm), + 'class_id': class_id, + 'confidence': confidence + }) + + # For demo purposes, if no smoke detected by algorithm, + # add a small chance of random detection + if not detections and np.random.random() < 0.1: # 10% chance + # Random smoke detection + x = np.random.random() * 0.7 + y = np.random.random() * 0.7 + w = 0.1 + np.random.random() * 0.2 + h = 0.1 + np.random.random() * 0.2 + confidence = 0.5 + np.random.random() * 0.3 + + detections.append({ + 'bbox': (x, y, w, h), + 'class_id': 0, # Smoke + 'confidence': confidence + }) + + return detections + + @property + def applies_nms(self) -> bool: + """Model does not apply NMS internally""" + return False + + def close(self): + """Release resources""" + # In a real implementation, you would release model resources here + pass \ No newline at end of file diff --git a/python-inference-service/models/yolov5_utils.py b/python-inference-service/models/yolov5_utils.py new file mode 100644 index 0000000..9ea29aa --- /dev/null +++ b/python-inference-service/models/yolov5_utils.py @@ -0,0 +1,56 @@ +import torch +import torch.nn as nn +import sys +import os + +def attempt_load(weights, device=''): + """尝试加载YOLOv5模型""" + # 加载模型 + model = torch.load(weights, map_location=device) + + # 确定模型格式 + if isinstance(model, dict): + if 'model' in model: # state_dict格式 + model = model['model'] + elif 'state_dict' in model: # state_dict格式 + model = model['state_dict'] + + # 如果是state_dict,则需要创建模型架构 + if isinstance(model, dict): + print("警告:加载的是权重字典,尝试创建默认模型结构") + from models.yolov5_model import YOLOv5 + model_arch = YOLOv5() + model_arch.load_state_dict(model) + model = model_arch + + # 设置为评估模式 + if isinstance(model, nn.Module): + model.eval() + + # 检查是否有类别信息 + if not hasattr(model, 'names') or not model.names: + print("模型没有类别信息,尝试加载默认类别") + # 设置通用类别 + model.names = ['object'] + + return model + +class YOLOv5: + """简化版YOLOv5模型结构,用于加载权重""" + def __init__(self): + super(YOLOv5, self).__init__() + self.names = [] # 类别名称 + # 这里应该添加真实的网络结构 + # 但为了简单起见,我们只提供一个占位符 + # 在实际使用中,您应该实现完整的网络架构 + + def forward(self, x): + # 这里应该是实际的前向传播逻辑 + # 这只是一个占位符 + raise NotImplementedError("这是一个占位符模型,请使用完整的YOLOv5模型实现") + + def load_state_dict(self, state_dict): + print("尝试加载模型权重") + # 实际的权重加载逻辑 + # 这只是一个占位符 + return self \ No newline at end of file diff --git a/python-inference-service/models/yolov8_model.py b/python-inference-service/models/yolov8_model.py new file mode 100644 index 0000000..e0167a3 --- /dev/null +++ b/python-inference-service/models/yolov8_model.py @@ -0,0 +1,135 @@ +import os +import numpy as np +import cv2 +from typing import List, Dict, Any +import torch + +class Model: + """ + YOLOv8 模型包装类 - 使用 Ultralytics YOLO + """ + + def __init__(self): + """初始化YOLOv8模型""" + # 获取当前文件所在目录路径 + model_dir = os.path.dirname(os.path.abspath(__file__)) + # 模型文件路径 + model_path = os.path.join(model_dir, "best.pt") + + print(f"正在加载YOLOv8模型: {model_path}") + + # 检查设备 + self.device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"使用设备: {self.device}") + + # 使用 Ultralytics YOLO 加载模型 + try: + from ultralytics import YOLO + self.model = YOLO(model_path) + print("使用 Ultralytics YOLO 加载模型成功") + except ImportError: + raise ImportError("请安装 ultralytics: pip install ultralytics>=8.0.0") + except Exception as e: + raise Exception(f"加载YOLOv8模型失败: {str(e)}") + + # 加载类别名称 + self.classes = [] + classes_path = os.path.join(model_dir, "classes.txt") + if os.path.exists(classes_path): + with open(classes_path, 'r', encoding='utf-8') as f: + self.classes = [line.strip() for line in f.readlines() if line.strip()] + print(f"已加载 {len(self.classes)} 个类别") + else: + # 使用模型自带的类别信息 + if hasattr(self.model, 'names') and self.model.names: + self.classes = list(self.model.names.values()) if isinstance(self.model.names, dict) else self.model.names + print(f"使用模型自带类别,共 {len(self.classes)} 个类别") + else: + print("未找到类别文件,将使用数字索引作为类别名") + + # 设置识别参数 + self.conf_threshold = 0.25 # 置信度阈值 + self.img_size = 640 # 默认输入图像大小 + + print("YOLOv8模型加载完成") + + def preprocess(self, image: np.ndarray) -> np.ndarray: + """预处理图像 - YOLOv8会自动处理,这里直接返回""" + return image + + def predict(self, image: np.ndarray) -> List[Dict[str, Any]]: + """模型推理""" + original_height, original_width = image.shape[:2] + + try: + # YOLOv8推理 + results = self.model( + image, + conf=self.conf_threshold, + device=self.device, + verbose=False + ) + + detections = [] + + # 解析结果 + for result in results: + # 获取检测框 + boxes = result.boxes + + if boxes is None or len(boxes) == 0: + continue + + # 遍历每个检测框 + for box in boxes: + # 获取坐标 (xyxy格式) + xyxy = box.xyxy[0].cpu().numpy() + x1, y1, x2, y2 = xyxy + + # 转换为归一化坐标 (x, y, w, h) + x = x1 / original_width + y = y1 / original_height + w = (x2 - x1) / original_width + h = (y2 - y1) / original_height + + # 获取置信度 + conf = float(box.conf[0].cpu().numpy()) + + # 获取类别ID + cls_id = int(box.cls[0].cpu().numpy()) + + # 获取类别名称 + class_name = f"cls{cls_id}" + if 0 <= cls_id < len(self.classes): + class_name = self.classes[cls_id] + + # 添加检测结果 + if conf >= self.conf_threshold: + detections.append({ + 'bbox': (x, y, w, h), + 'class_id': cls_id, + 'confidence': conf + }) + + return detections + + except Exception as e: + print(f"推理过程中出错: {str(e)}") + import traceback + traceback.print_exc() + return [] + + @property + def applies_nms(self) -> bool: + """模型是否内部应用了 NMS""" + # YOLOv8会自动应用 NMS + return True + + def close(self): + """释放资源""" + if hasattr(self, 'model'): + # 删除模型以释放 GPU 内存 + del self.model + if torch.cuda.is_available(): + torch.cuda.empty_cache() + print("YOLOv8模型已关闭") \ No newline at end of file diff --git a/python-inference-service/requirements.txt b/python-inference-service/requirements.txt new file mode 100644 index 0000000..ff7684d --- /dev/null +++ b/python-inference-service/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.103.1 +uvicorn==0.23.2 +opencv-python==4.8.0.76 +numpy==1.25.2 +pydantic==2.3.0 +python-multipart==0.0.6 +minio==7.1.15 +torch>=1.7.0 +torchvision>=0.8.1 +ultralytics>=8.0.0 \ No newline at end of file diff --git a/python-inference-service/start_service.bat b/python-inference-service/start_service.bat new file mode 100644 index 0000000..99d657f --- /dev/null +++ b/python-inference-service/start_service.bat @@ -0,0 +1,5 @@ +@echo off +echo Starting Python Inference Service... +cd /d %~dp0 +python -m app.main +pause \ No newline at end of file diff --git a/python-inference-service/start_service.sh b/python-inference-service/start_service.sh new file mode 100644 index 0000000..9c67cb1 --- /dev/null +++ b/python-inference-service/start_service.sh @@ -0,0 +1,4 @@ +#!/bin/bash +echo "Starting Python Inference Service..." +cd "$(dirname "$0")" +python -m app.main \ No newline at end of file diff --git a/rtsp-vue/.dockerignore b/rtsp-vue/.dockerignore new file mode 100644 index 0000000..f79c39b --- /dev/null +++ b/rtsp-vue/.dockerignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +yarn.lock + +# Build output +dist/ +build/ + +# IDE +.vscode +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# CI/CD +.github +.gitlab-ci.yml + +# Documentation +*.md +README* + +# Scripts +bin/ +*.bat +*.sh \ No newline at end of file diff --git a/rtsp-vue/Dockerfile b/rtsp-vue/Dockerfile new file mode 100644 index 0000000..3ff71ae --- /dev/null +++ b/rtsp-vue/Dockerfile @@ -0,0 +1,45 @@ +# 构建阶段 +FROM node:18-alpine AS builder + +# 设置工作目录 +WORKDIR /app + +# 设置npm镜像源 +RUN npm config set registry https://registry.npmmirror.com + +# 复制package.json和package-lock.json +COPY package*.json ./ + +# 安装依赖 +RUN npm install + +# 复制源代码 +COPY . . + +# 构建生产环境项目 +RUN npm run build:prod + +# 运行阶段 +FROM nginx:1.25-alpine + +# 安装curl用于健康检查 +RUN apk add --no-cache curl + +# 删除默认nginx配置 +RUN rm -rf /etc/nginx/conf.d/* + +# 复制nginx配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 暴露端口 +EXPOSE 80 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD curl -f http://localhost:80 || exit 1 + +# 启动Nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/rtsp-vue/nginx.conf b/rtsp-vue/nginx.conf new file mode 100644 index 0000000..2af7d3b --- /dev/null +++ b/rtsp-vue/nginx.conf @@ -0,0 +1,58 @@ +server { + listen 80; + server_name localhost; + + # 前端资源 + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # 后端API代理 + location /prod-api/ { + proxy_pass http://rtsp-backend:8080/; + 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; + proxy_connect_timeout 600; + proxy_read_timeout 600; + proxy_send_timeout 600; + } + + # WebSocket支持(用于视频流) + location /websocket/ { + proxy_pass http://rtsp-backend:8080/websocket/; + 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_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # Python推理服务代理 + location /python-api/ { + proxy_pass http://rtsp-python-service:8000/; + 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; + } + + # MinIO使用外部服务,不需要代理 + + # 错误页面配置 + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + + # 日志配置 + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; +} \ No newline at end of file diff --git a/ruoyi-admin/Dockerfile b/ruoyi-admin/Dockerfile new file mode 100644 index 0000000..4540ab0 --- /dev/null +++ b/ruoyi-admin/Dockerfile @@ -0,0 +1,63 @@ +# 构建阶段 +FROM maven:3.8-eclipse-temurin-17 AS builder + +# 设置工作目录 +WORKDIR /build + +# 复制pom文件 +COPY pom.xml . +COPY ruoyi-admin/pom.xml ./ruoyi-admin/ +COPY ruoyi-common/pom.xml ./ruoyi-common/ +COPY ruoyi-framework/pom.xml ./ruoyi-framework/ +COPY ruoyi-generator/pom.xml ./ruoyi-generator/ +COPY ruoyi-quartz/pom.xml ./ruoyi-quartz/ +COPY ruoyi-system/pom.xml ./ruoyi-system/ +COPY ruoyi-video/pom.xml ./ruoyi-video/ + +# 下载依赖 +RUN mvn dependency:go-offline -B + +# 复制源代码 +COPY ruoyi-admin/src ./ruoyi-admin/src +COPY ruoyi-common/src ./ruoyi-common/src +COPY ruoyi-framework/src ./ruoyi-framework/src +COPY ruoyi-generator/src ./ruoyi-generator/src +COPY ruoyi-quartz/src ./ruoyi-quartz/src +COPY ruoyi-system/src ./ruoyi-system/src +COPY ruoyi-video/src ./ruoyi-video/src +COPY ArcFace64.dat ./ + +# 构建项目 +RUN mvn clean package -DskipTests -pl ruoyi-admin -am + +# 运行阶段 +FROM eclipse-temurin:17-jre-alpine + +# 安装必要的工具 +RUN apk add --no-cache curl tzdata && \ + cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" > /etc/timezone && \ + apk del tzdata + +# 设置工作目录 +WORKDIR /app + +# 创建日志和上传目录 +RUN mkdir -p /app/logs /app/upload + +# 复制jar包和配置文件 +COPY --from=builder /build/ruoyi-admin/target/ruoyi-admin.jar /app/app.jar +COPY --from=builder /build/ArcFace64.dat /app/ + +# 设置环境变量 +ENV JAVA_OPTS="-Xms512m -Xmx1024m -Djava.security.egd=file:/dev/./urandom" + +# 暴露端口(仅内部使用) +EXPOSE 8080 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +# 启动应用 +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app/app.jar"] \ No newline at end of file diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index c9f588c..c2e2624 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -30,6 +30,12 @@ springdoc-openapi-starter-webmvc-ui + + + org.springframework.boot + spring-boot-starter-actuator + + com.mysql diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java index cf3017c..0e87b90 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java @@ -101,7 +101,7 @@ public class CommonController // persist to v_minio_object VMinioObject record = new VMinioObject(); record.setObjectName(fileName); - record.setUrl(url); + record.setObjectUrl(url); record.setOriginalName(file.getOriginalFilename()); try { record.setCreateBy(SecurityUtils.getUsername()); } catch (Exception ignored) {} vMinioObjectService.insert(record); @@ -158,7 +158,7 @@ public class CommonController // persist each to v_minio_object VMinioObject record = new VMinioObject(); record.setObjectName(fileName); - record.setUrl(url); + record.setObjectUrl(url); record.setOriginalName(file.getOriginalFilename()); try { record.setCreateBy(SecurityUtils.getUsername()); } catch (Exception ignored) {} vMinioObjectService.insert(record); diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 136c8a7..52158cc 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -156,10 +156,6 @@ mediasServer: # 无人拉流观看持续多久自动关闭,1分钟 noClientsDuration: 60000 -# 虹软sdk -arcFace: - appId: '替换成你的appId' - sdkKey: '替换成你的sdkKey' # 视频分析配置 video: @@ -174,3 +170,17 @@ video: transport: tcp # 重试次数 retryCount: 3 + +# Spring Boot Actuator配置 +management: + endpoints: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + show-details: when-authorized + health: + defaults: + enabled: true diff --git a/ruoyi-admin/src/main/resources/libs/models/models.json b/ruoyi-admin/src/main/resources/libs/models/models.json index 4d85293..86f9591 100644 --- a/ruoyi-admin/src/main/resources/libs/models/models.json +++ b/ruoyi-admin/src/main/resources/libs/models/models.json @@ -1,5 +1,7 @@ [ - {"name":"garbage","path":"libs/models/garbage","size":[640,640],"backend":"OpenCV"}, - {"name":"smoke","path":"libs/models/smoke","size":[640,640],"backend":"OpenCV"} - + { + "name": "garbage", + "pythonModelName": "garbage_detector", + "pythonApiUrl": "http://localhost:8000/api/detect/file" + } ] diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/service/MinioService.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/service/MinioService.java index 92fc5ae..bb4cc1f 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/service/MinioService.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/service/MinioService.java @@ -1,100 +1,97 @@ package com.ruoyi.framework.service; -import com.ruoyi.framework.config.MinioProperties; -import com.ruoyi.common.utils.file.FileUploadUtils; -import io.minio.BucketExistsArgs; -import io.minio.MakeBucketArgs; -import io.minio.RemoveObjectArgs; -import io.minio.MinioClient; -import io.minio.PutObjectArgs; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; +import java.io.InputStream; import org.springframework.web.multipart.MultipartFile; -import java.io.InputStream; - -@Service -public class MinioService { - - private final MinioClient minioClient; - private final MinioProperties properties; - - @Autowired - public MinioService(MinioProperties properties) { - this.properties = properties; - this.minioClient = MinioClient.builder() - .endpoint(properties.getEndpoint()) - .credentials(properties.getAccessKey(), properties.getSecretKey()) - .build(); - } - - public UploadResult upload(MultipartFile file) throws Exception { - ensureBucket(); - String objectName = FileUploadUtils.extractFilename(file); - try (InputStream is = file.getInputStream()) { - minioClient.putObject( - PutObjectArgs.builder() - .bucket(properties.getBucket()) - .object(objectName) - .stream(is, file.getSize(), -1) - .contentType(file.getContentType()) - .build() - ); - } - String url = buildObjectUrl(objectName); - return new UploadResult(objectName, url); - } - +/** + * MinIO 对象存储服务接口 + */ +public interface MinioService { + /** - * Upload using the provided unique objectName. + * 上传对象 + * + * @param bucketName 存储桶名称 + * @param objectName 对象名称 + * @param inputStream 输入流 + * @return 对象访问URL */ - public UploadResult uploadWithName(MultipartFile file, String objectName) throws Exception { - ensureBucket(); - try (InputStream is = file.getInputStream()) { - minioClient.putObject( - PutObjectArgs.builder() - .bucket(properties.getBucket()) - .object(objectName) - .stream(is, file.getSize(), -1) - .contentType(file.getContentType()) - .build() - ); - } - String url = buildObjectUrl(objectName); - return new UploadResult(objectName, url); - } - - public String buildObjectUrl(String objectName) { - String endpoint = properties.getEndpoint(); - if (endpoint.endsWith("/")) { - endpoint = endpoint.substring(0, endpoint.length() - 1); - } - return endpoint + "/" + properties.getBucket() + "/" + objectName; - } - - private void ensureBucket() throws Exception { - boolean exists = minioClient.bucketExists(BucketExistsArgs.builder() - .bucket(properties.getBucket()) - .build()); - if (!exists) { - minioClient.makeBucket(MakeBucketArgs.builder() - .bucket(properties.getBucket()) - .build()); - } - } - + String putObject(String bucketName, String objectName, InputStream inputStream); + /** - * Delete an object from MinIO by its object name (key). + * 获取对象 + * + * @param bucketName 存储桶名称 + * @param objectName 对象名称 + * @return 对象输入流 */ - public void deleteObject(String objectName) throws Exception { - minioClient.removeObject( - RemoveObjectArgs.builder() - .bucket(properties.getBucket()) - .object(objectName) - .build() - ); + InputStream getObject(String bucketName, String objectName); + + /** + * 删除对象 + * + * @param bucketName 存储桶名称 + * @param objectName 对象名称 + */ + void removeObject(String bucketName, String objectName); + + /** + * 删除对象(别名方法,同removeObject) + * + * @param objectName 对象名称 + */ + default void deleteObject(String objectName) { + removeObject("default", objectName); } - + + /** + * 删除对象(别名方法) + * + * @param bucketName 存储桶名称 + * @param objectName 对象名称 + */ + default void deleteObject(String bucketName, String objectName) { + removeObject(bucketName, objectName); + } + + /** + * 检查对象是否存在 + * + * @param bucketName 存储桶名称 + * @param objectName 对象名称 + * @return 是否存在 + */ + boolean objectExists(String bucketName, String objectName); + + /** + * 获取对象URL + * + * @param bucketName 存储桶名称 + * @param objectName 对象名称 + * @return 对象URL + */ + String getObjectUrl(String bucketName, String objectName); + + /** + * 上传文件 + * + * @param file 文件 + * @return 上传结果 + */ + UploadResult upload(MultipartFile file) throws Exception; + + /** + * 使用指定的对象名上传文件 + * + * @param file 文件 + * @param objectName 对象名 + * @return 上传结果 + */ + UploadResult uploadWithName(MultipartFile file, String objectName) throws Exception; + + /** + * 上传结果类 + */ public static class UploadResult { private final String objectName; private final String url; diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/common/ClientType.java b/ruoyi-video/src/main/java/com/ruoyi/video/common/ClientType.java index abdbd0c..4b0ce80 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/common/ClientType.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/common/ClientType.java @@ -1,37 +1,21 @@ package com.ruoyi.video.common; /** - * 定义一个枚举类,表示客户端类型 - * @Author: orange - * @CreateTime: 2025-01-16 + * 客户端类型枚举 */ public enum ClientType { - - // 定义一个HTTP类型的客户端,类型为0,信息为"http" - HTTP(0,"http"), - // 定义一个WEBSOCKET类型的客户端,类型为1,信息为"websocket" - WEBSOCKET(1,"websocket"), - ; - - // 定义客户端类型 - private int type; - // 定义客户端信息 - private String info; + /** + * HTTP客户端 + */ + HTTP, - // 构造方法,初始化客户端类型和信息 - private ClientType(int type, String info){ - this.type = type; - this.info = info; - } + /** + * WebSocket客户端 + */ + WEBSOCKET, - // 获取客户端类型 - public int getType(){ - return type; - } - - // 获取客户端信息 - public String getInfo(){ - return info; - } - + /** + * API客户端 + */ + API } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/common/ModelManager.java b/ruoyi-video/src/main/java/com/ruoyi/video/common/ModelManager.java index 54cc4cd..6f6d79d 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/common/ModelManager.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/common/ModelManager.java @@ -9,7 +9,15 @@ import java.util.*; public final class ModelManager implements AutoCloseable { private final Map map = new LinkedHashMap<>(); - + + // Python服务的默认API URL + private static final String DEFAULT_PYTHON_API_URL = "http://localhost:8000/api/detect/file"; + + /** + * 从配置加载检测器 + * @param modelsJson 模型配置JSON的URL + * @throws Exception 如果加载失败 + */ public void load(URL modelsJson) throws Exception { ObjectMapper om = new ObjectMapper(); ArrayNode arr = (ArrayNode) om.readTree(modelsJson); @@ -20,20 +28,15 @@ public final class ModelManager implements AutoCloseable { int i=0; for (var node : arr) { String name = node.get("name").asText(); - String path = node.get("path").asText(); - int w = node.get("size").get(0).asInt(); - int h = node.get("size").get(1).asInt(); - String backend = node.get("backend").asText(); - - URL dirUrl = Objects.requireNonNull(getClass().getClassLoader().getResource(path), - "Resource not found: " + path); - Path dir = Paths.get(dirUrl.toURI()); + String pythonModelName = node.get("pythonModelName").asText(name); // 默认与name相同 + String pythonApiUrl = node.has("pythonApiUrl") ? + node.get("pythonApiUrl").asText() : DEFAULT_PYTHON_API_URL; int rgb = palette[i % palette.length]; i++; int bgr = ((rgb & 0xFF) << 16) | (rgb & 0xFF00) | ((rgb >> 16) & 0xFF); - // 使用OnnxYoloDetector替代OpenVinoYoloDetector - YoloDetector det = new OnnxYoloDetector(name, dir, w, h, backend, bgr); + // 使用HttpYoloDetector替代本地ONNX检测器 + YoloDetector det = new HttpYoloDetector(name, pythonApiUrl, pythonModelName, bgr); map.put(name, det); } } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/domain/AlarmRecord.java b/ruoyi-video/src/main/java/com/ruoyi/video/domain/AlarmRecord.java index e7fc191..5ae5d4a 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/domain/AlarmRecord.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/domain/AlarmRecord.java @@ -1,86 +1,55 @@ package com.ruoyi.video.domain; -import com.ruoyi.common.annotation.Excel; -import com.ruoyi.common.core.domain.BaseEntity; -import com.fasterxml.jackson.annotation.JsonFormat; - import java.util.Date; /** - * 报警记录对象 v_alarm_record - * - * @Author: orange - * @CreateTime: 2025-01-16 + * 告警记录实体类 */ -public class AlarmRecord extends BaseEntity { - private static final long serialVersionUID = 1L; - - /** 报警记录ID */ +public class AlarmRecord { + /** 告警ID */ private Long alarmId; - - /** 巡检任务ID */ - @Excel(name = "巡检任务ID") - private Long taskId; - - /** 任务名称 */ - @Excel(name = "任务名称") - private String taskName; - + /** 设备ID */ - @Excel(name = "设备ID") private Long deviceId; - - /** 设备名称 */ - @Excel(name = "设备名称") - private String deviceName; - - /** 报警类型 */ - @Excel(name = "报警类型") + + /** 告警类型 */ private String alarmType; - - /** 报警级别(1=低,2=中,3=高) */ - @Excel(name = "报警级别", readConverterExp = "1=低,2=中,3=高") + + /** 告警级别 */ private String alarmLevel; - - /** 报警描述 */ - @Excel(name = "报警描述") - private String alarmDesc; - - /** 检测置信度 */ - @Excel(name = "检测置信度") - private Double confidence; - - /** 报警图片路径 */ - @Excel(name = "报警图片") - private String imagePath; - - /** 报警视频路径 */ - @Excel(name = "报警视频") - private String videoPath; - - /** 报警时间 */ - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - @Excel(name = "报警时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") - private Date alarmTime; - - /** 处理状态(0=未处理,1=已处理,2=已忽略) */ - @Excel(name = "处理状态", readConverterExp = "0=未处理,1=已处理,2=已忽略") - private String handleStatus; - + + /** 告警内容 */ + private String alarmContent; + + /** 巡检任务ID */ + private Long taskId; + + /** 图像对象存储ID */ + private Long imageOssId; + + /** 帧位置 */ + private long framePosition; + + /** 置信度 */ + private double confidence; + + /** 处理状态(0-未处理,1-已处理) */ + private Integer status; + /** 处理人 */ - @Excel(name = "处理人") private String handleBy; - + /** 处理时间 */ - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - @Excel(name = "处理时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") private Date handleTime; - + /** 处理备注 */ - @Excel(name = "处理备注") private String handleRemark; - - public AlarmRecord() {} + + /** 创建时间 */ + private Date createTime; + + /** 备注 */ + private String remark; public Long getAlarmId() { return alarmId; @@ -90,22 +59,6 @@ public class AlarmRecord extends BaseEntity { this.alarmId = alarmId; } - public Long getTaskId() { - return taskId; - } - - public void setTaskId(Long taskId) { - this.taskId = taskId; - } - - public String getTaskName() { - return taskName; - } - - public void setTaskName(String taskName) { - this.taskName = taskName; - } - public Long getDeviceId() { return deviceId; } @@ -114,14 +67,6 @@ public class AlarmRecord extends BaseEntity { this.deviceId = deviceId; } - public String getDeviceName() { - return deviceName; - } - - public void setDeviceName(String deviceName) { - this.deviceName = deviceName; - } - public String getAlarmType() { return alarmType; } @@ -130,60 +75,52 @@ public class AlarmRecord extends BaseEntity { this.alarmType = alarmType; } - public String getAlarmLevel() { - return alarmLevel; + public String getAlarmContent() { + return alarmContent; } - public void setAlarmLevel(String alarmLevel) { - this.alarmLevel = alarmLevel; + public void setAlarmContent(String alarmContent) { + this.alarmContent = alarmContent; } - public String getAlarmDesc() { - return alarmDesc; + public Long getTaskId() { + return taskId; } - public void setAlarmDesc(String alarmDesc) { - this.alarmDesc = alarmDesc; + public void setTaskId(Long taskId) { + this.taskId = taskId; } - public Double getConfidence() { + public Long getImageOssId() { + return imageOssId; + } + + public void setImageOssId(Long imageOssId) { + this.imageOssId = imageOssId; + } + + public long getFramePosition() { + return framePosition; + } + + public void setFramePosition(long framePosition) { + this.framePosition = framePosition; + } + + public double getConfidence() { return confidence; } - public void setConfidence(Double confidence) { + public void setConfidence(double confidence) { this.confidence = confidence; } - public String getImagePath() { - return imagePath; + public Integer getStatus() { + return status; } - public void setImagePath(String imagePath) { - this.imagePath = imagePath; - } - - public String getVideoPath() { - return videoPath; - } - - public void setVideoPath(String videoPath) { - this.videoPath = videoPath; - } - - public Date getAlarmTime() { - return alarmTime; - } - - public void setAlarmTime(Date alarmTime) { - this.alarmTime = alarmTime; - } - - public String getHandleStatus() { - return handleStatus; - } - - public void setHandleStatus(String handleStatus) { - this.handleStatus = handleStatus; + public void setStatus(Integer status) { + this.status = status; } public String getHandleBy() { @@ -210,25 +147,51 @@ public class AlarmRecord extends BaseEntity { this.handleRemark = handleRemark; } - @Override - public String toString() { - return "AlarmRecord{" + - "alarmId=" + alarmId + - ", taskId=" + taskId + - ", taskName='" + taskName + '\'' + - ", deviceId=" + deviceId + - ", deviceName='" + deviceName + '\'' + - ", alarmType='" + alarmType + '\'' + - ", alarmLevel='" + alarmLevel + '\'' + - ", alarmDesc='" + alarmDesc + '\'' + - ", confidence=" + confidence + - ", imagePath='" + imagePath + '\'' + - ", videoPath='" + videoPath + '\'' + - ", alarmTime=" + alarmTime + - ", handleStatus='" + handleStatus + '\'' + - ", handleBy='" + handleBy + '\'' + - ", handleTime=" + handleTime + - ", handleRemark='" + handleRemark + '\'' + - '}'; + public Date getCreateTime() { + return createTime; + } + + public void setCreateTime(Date createTime) { + this.createTime = createTime; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + /** + * 获取ID的兼容方法,保持与原有代码兼容 + */ + public Long getId() { + return alarmId; + } + + /** + * 设置ID的兼容方法,保持与原有代码兼容 + */ + public void setId(Long id) { + this.alarmId = id; + } + + /** + * 设置告警级别 + * + * @param alarmLevel 告警级别 + */ + public void setAlarmLevel(String alarmLevel) { + this.alarmLevel = alarmLevel; + } + + /** + * 获取告警级别 + * + * @return 告警级别 + */ + public String getAlarmLevel() { + return alarmLevel; } } \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/domain/Detection.java b/ruoyi-video/src/main/java/com/ruoyi/video/domain/Detection.java index a7cf467..d54daf3 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/domain/Detection.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/domain/Detection.java @@ -2,5 +2,61 @@ package com.ruoyi.video.domain; import org.bytedeco.opencv.opencv_core.Rect; -public record Detection(String cls, float conf, Rect box, int colorBGR) { +/** + * 检测结果实体类 + */ +public class Detection { + /** 标签 */ + private String label; + + /** 置信度 */ + private double confidence; + + /** 边界框 */ + private Rect rect; + + /** 边界框颜色(BGR格式) */ + private int colorBGR; + + public Detection() { + } + + public Detection(String label, double confidence, Rect rect, int colorBGR) { + this.label = label; + this.confidence = confidence; + this.rect = rect; + this.colorBGR = colorBGR; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public double getConfidence() { + return confidence; + } + + public void setConfidence(double confidence) { + this.confidence = confidence; + } + + public Rect getRect() { + return rect; + } + + public void setRect(Rect rect) { + this.rect = rect; + } + + public int getColorBGR() { + return colorBGR; + } + + public void setColorBGR(int colorBGR) { + this.colorBGR = colorBGR; + } } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/domain/InspectionTask.java b/ruoyi-video/src/main/java/com/ruoyi/video/domain/InspectionTask.java index 2baf4c3..0573ebe 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/domain/InspectionTask.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/domain/InspectionTask.java @@ -1,196 +1,133 @@ package com.ruoyi.video.domain; -import com.ruoyi.common.annotation.Excel; -import com.ruoyi.common.core.domain.BaseEntity; -import com.fasterxml.jackson.annotation.JsonFormat; - import java.util.Date; /** - * 巡检任务对象 v_inspection_task - * - * @Author: orange - * @CreateTime: 2025-01-16 + * 巡检任务实体类 */ -public class InspectionTask extends BaseEntity { - private static final long serialVersionUID = 1L; - +public class InspectionTask { /** 巡检任务ID */ private Long taskId; - - /** 任务名称 */ - @Excel(name = "任务名称") - private String taskName; - + /** 设备ID */ - @Excel(name = "设备ID") private Long deviceId; - - /** 设备名称 */ - @Excel(name = "设备名称") - private String deviceName; - - /** Cron表达式 */ - @Excel(name = "Cron表达式") - private String cronExpression; - + /** 巡检时长(秒) */ - @Excel(name = "巡检时长") private Integer duration; - - /** 任务状态(0=启用,1=停用) */ - @Excel(name = "任务状态", readConverterExp = "0=启用,1=停用") - private String status; - - /** 是否启用检测(0=启用,1=停用) */ - @Excel(name = "启用检测", readConverterExp = "0=启用,1=停用") - private String enableDetection; - - /** 检测阈值 */ - @Excel(name = "检测阈值") - private Double threshold; - - /** 最后执行时间 */ - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - @Excel(name = "最后执行时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") - private Date lastExecuteTime; - - /** 下次执行时间 */ - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - @Excel(name = "下次执行时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss") - private Date nextExecuteTime; - - /** 执行次数 */ - @Excel(name = "执行次数") - private Long executeCount; - - /** 报警次数 */ - @Excel(name = "报警次数") - private Long alarmCount; - - public InspectionTask() {} - + + /** 视频对象存储ID */ + private Long videoOssId; + + /** 处理后视频对象存储ID */ + private Long processedVideoOssId; + + /** 视频状态(0-未录制,1-已录制未分析,2-已分析) */ + private Integer videoStatus; + + /** 任务状态(0-待执行,1-执行中,2-已完成) */ + private Integer status; + + /** 创建时间 */ + private Date createTime; + + /** 更新时间 */ + private Date updateTime; + + /** 备注 */ + private String remark; + public Long getTaskId() { return taskId; } - + public void setTaskId(Long taskId) { this.taskId = taskId; } - - public String getTaskName() { - return taskName; - } - - public void setTaskName(String taskName) { - this.taskName = taskName; - } - + public Long getDeviceId() { return deviceId; } - + public void setDeviceId(Long deviceId) { this.deviceId = deviceId; } - - public String getDeviceName() { - return deviceName; - } - - public void setDeviceName(String deviceName) { - this.deviceName = deviceName; - } - - public String getCronExpression() { - return cronExpression; - } - - public void setCronExpression(String cronExpression) { - this.cronExpression = cronExpression; - } - + public Integer getDuration() { return duration; } - + public void setDuration(Integer duration) { this.duration = duration; } - - public String getStatus() { + + public Long getVideoOssId() { + return videoOssId; + } + + public void setVideoOssId(Long videoOssId) { + this.videoOssId = videoOssId; + } + + public Long getProcessedVideoOssId() { + return processedVideoOssId; + } + + public void setProcessedVideoOssId(Long processedVideoOssId) { + this.processedVideoOssId = processedVideoOssId; + } + + public Integer getVideoStatus() { + return videoStatus; + } + + public void setVideoStatus(Integer videoStatus) { + this.videoStatus = videoStatus; + } + + public Integer getStatus() { return status; } - - public void setStatus(String status) { + + public void setStatus(Integer status) { this.status = status; } - - public String getEnableDetection() { - return enableDetection; + + public Date getCreateTime() { + return createTime; } - - public void setEnableDetection(String enableDetection) { - this.enableDetection = enableDetection; + + public void setCreateTime(Date createTime) { + this.createTime = createTime; } - - public Double getThreshold() { - return threshold; + + public Date getUpdateTime() { + return updateTime; } - - public void setThreshold(Double threshold) { - this.threshold = threshold; + + public void setUpdateTime(Date updateTime) { + this.updateTime = updateTime; } - - public Date getLastExecuteTime() { - return lastExecuteTime; + + public String getRemark() { + return remark; } - - public void setLastExecuteTime(Date lastExecuteTime) { - this.lastExecuteTime = lastExecuteTime; + + public void setRemark(String remark) { + this.remark = remark; } - - public Date getNextExecuteTime() { - return nextExecuteTime; + + /** + * 获取ID的兼容方法,保持与原有代码兼容 + */ + public Long getId() { + return taskId; } - - public void setNextExecuteTime(Date nextExecuteTime) { - this.nextExecuteTime = nextExecuteTime; - } - - public Long getExecuteCount() { - return executeCount; - } - - public void setExecuteCount(Long executeCount) { - this.executeCount = executeCount; - } - - public Long getAlarmCount() { - return alarmCount; - } - - public void setAlarmCount(Long alarmCount) { - this.alarmCount = alarmCount; - } - - @Override - public String toString() { - return "InspectionTask{" + - "taskId=" + taskId + - ", taskName='" + taskName + '\'' + - ", deviceId=" + deviceId + - ", deviceName='" + deviceName + '\'' + - ", cronExpression='" + cronExpression + '\'' + - ", duration=" + duration + - ", status='" + status + '\'' + - ", enableDetection='" + enableDetection + '\'' + - ", threshold=" + threshold + - ", lastExecuteTime=" + lastExecuteTime + - ", nextExecuteTime=" + nextExecuteTime + - ", executeCount=" + executeCount + - ", alarmCount=" + alarmCount + - '}'; + + /** + * 设置ID的兼容方法,保持与原有代码兼容 + */ + public void setId(Long id) { + this.taskId = id; } } \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/domain/VMinioObject.java b/ruoyi-video/src/main/java/com/ruoyi/video/domain/VMinioObject.java index e554150..f61b152 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/domain/VMinioObject.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/domain/VMinioObject.java @@ -1,36 +1,151 @@ package com.ruoyi.video.domain; -import com.ruoyi.common.core.domain.BaseEntity; +import java.util.Date; /** - * MinIO 返回结果记录实体,对应表 v_minio_object + * MinIO对象存储实体类 */ -public class VMinioObject extends BaseEntity { - private static final long serialVersionUID = 1L; - - /** 主键ID */ +public class VMinioObject { + /** 对象ID */ private Long objectId; - /** MinIO 对象名(Key) */ + + /** 存储桶名称 */ + private String bucketName; + + /** 对象名称 */ private String objectName; - /** 访问URL */ - private String url; - /** 原始文件名(上传时的文件名) */ - private String originalName; + + /** 对象URL */ + private String objectUrl; + + /** 对象大小(字节) */ + private Long objectSize; + + /** 对象类型 */ + private String objectType; + + /** 创建者 */ + private String createBy; + + /** 创建时间 */ + private Date createTime; + + /** 更新者 */ + private String updateBy; + + /** 更新时间 */ + private Date updateTime; + + /** 备注 */ + private String remark; + /** 删除标志(0存在 2删除) */ private String delFlag; + + public String getDelFlag() { + return delFlag; + } - public Long getObjectId() { return objectId; } - public void setObjectId(Long objectId) { this.objectId = objectId; } + public void setDelFlag(String delFlag) { + this.delFlag = delFlag; + } + + /** 原始文件名 */ + private String originalName; + + public String getOriginalName() { + return originalName; + } + + public void setOriginalName(String originalName) { + this.originalName = originalName; + } - public String getObjectName() { return objectName; } - public void setObjectName(String objectName) { this.objectName = objectName; } + public Long getObjectId () { + return objectId; + } - public String getUrl() { return url; } - public void setUrl(String url) { this.url = url; } + public void setObjectId(Long objectId) { + this.objectId = objectId; + } - public String getOriginalName() { return originalName; } - public void setOriginalName(String originalName) { this.originalName = originalName; } + public String getBucketName() { + return bucketName; + } - public String getDelFlag() { return delFlag; } - public void setDelFlag(String delFlag) { this.delFlag = delFlag; } + public void setBucketName(String bucketName) { + this.bucketName = bucketName; + } + + public String getObjectName() { + return objectName; + } + + public void setObjectName(String objectName) { + this.objectName = objectName; + } + + public String getObjectUrl() { + return objectUrl; + } + + public void setObjectUrl(String objectUrl) { + this.objectUrl = objectUrl; + } + + public Long getObjectSize() { + return objectSize; + } + + public void setObjectSize(Long objectSize) { + this.objectSize = objectSize; + } + + public String getObjectType() { + return objectType; + } + + public void setObjectType(String objectType) { + this.objectType = objectType; + } + + public String getCreateBy() { + return createBy; + } + + public void setCreateBy(String createBy) { + this.createBy = createBy; + } + + public Date getCreateTime() { + return createTime; + } + + public void setCreateTime(Date createTime) { + this.createTime = createTime; + } + + public String getUpdateBy() { + return updateBy; + } + + public void setUpdateBy(String updateBy) { + this.updateBy = updateBy; + } + + public Date getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(Date updateTime) { + this.updateTime = updateTime; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/event/TaskCompletedEvent.java b/ruoyi-video/src/main/java/com/ruoyi/video/event/TaskCompletedEvent.java new file mode 100644 index 0000000..aaf6c25 --- /dev/null +++ b/ruoyi-video/src/main/java/com/ruoyi/video/event/TaskCompletedEvent.java @@ -0,0 +1,22 @@ +package com.ruoyi.video.event; + +import org.springframework.context.ApplicationEvent; + +/** + * 巡检任务完成事件 + */ +public class TaskCompletedEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + private final Long taskId; + + public TaskCompletedEvent(Object source, Long taskId) { + super(source); + this.taskId = taskId; + } + + public Long getTaskId() { + return taskId; + } +} \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/event/TaskStartEvent.java b/ruoyi-video/src/main/java/com/ruoyi/video/event/TaskStartEvent.java new file mode 100644 index 0000000..172106e --- /dev/null +++ b/ruoyi-video/src/main/java/com/ruoyi/video/event/TaskStartEvent.java @@ -0,0 +1,23 @@ +package com.ruoyi.video.event; + +import com.ruoyi.video.domain.InspectionTask; +import org.springframework.context.ApplicationEvent; + +/** + * 巡检任务开始事件 + */ +public class TaskStartEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + private final InspectionTask task; + + public TaskStartEvent(Object source, InspectionTask task) { + super(source); + this.task = task; + } + + public InspectionTask getTask() { + return task; + } +} \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/listener/InspectionTaskEventListener.java b/ruoyi-video/src/main/java/com/ruoyi/video/listener/InspectionTaskEventListener.java new file mode 100644 index 0000000..8a2fcb0 --- /dev/null +++ b/ruoyi-video/src/main/java/com/ruoyi/video/listener/InspectionTaskEventListener.java @@ -0,0 +1,75 @@ +package com.ruoyi.video.listener; + +import com.ruoyi.video.domain.InspectionTask; +import com.ruoyi.video.event.TaskCompletedEvent; +import com.ruoyi.video.event.TaskStartEvent; +import com.ruoyi.video.service.MediaService; +import com.ruoyi.video.service.VideoAnalysisService; +import com.ruoyi.video.thread.MediaTransferFlvByJavacv; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +/** + * 巡检任务事件监听器 + * 处理任务开始和完成事件 + */ +@Component +public class InspectionTaskEventListener { + + private static final Logger log = LoggerFactory.getLogger(InspectionTaskEventListener.class); + + @Autowired + private MediaService mediaService; + + @Autowired + private VideoAnalysisService videoAnalysisService; + + /** + * 处理任务开始事件 + * @param event 任务开始事件 + */ + @EventListener + public void handleTaskStartEvent(TaskStartEvent event) { + InspectionTask task = event.getTask(); + log.info("接收到任务开始事件: 任务ID={}, 设备ID={}", task.getTaskId(), task.getDeviceId()); + + try { + // 获取对应的媒体传输器 + MediaTransferFlvByJavacv transfer = mediaService.getMediaTransfer(task.getDeviceId()); + + if (transfer != null) { + // 开始录制视频 + transfer.startInspection(task.getTaskId(), task.getDeviceId(), task.getDuration()); + log.info("已开始视频录制: 任务ID={}, 设备ID={}, 持续时间={}秒", + task.getTaskId(), task.getDeviceId(), task.getDuration()); + } else { + log.error("未找到对应的媒体传输器: 设备ID={}", task.getDeviceId()); + } + } catch (Exception e) { + log.error("处理任务开始事件失败: {}", e.getMessage()); + } + } + + /** + * 处理任务完成事件 + * @param event 任务完成事件 + */ + @EventListener + @Async + public void handleTaskCompletedEvent(TaskCompletedEvent event) { + Long taskId = event.getTaskId(); + log.info("接收到任务完成事件: 任务ID={}", taskId); + + try { + // 开始异步视频分析 + videoAnalysisService.analyzeVideo(taskId); + } catch (Exception e) { + log.error("处理任务完成事件失败: {}", e.getMessage()); + } + } +} \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/mapper/AlarmRecordMapper.java b/ruoyi-video/src/main/java/com/ruoyi/video/mapper/AlarmRecordMapper.java index 42c4f36..3235284 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/mapper/AlarmRecordMapper.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/mapper/AlarmRecordMapper.java @@ -1,72 +1,67 @@ package com.ruoyi.video.mapper; +import java.util.List; import com.ruoyi.video.domain.AlarmRecord; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; -import java.util.List; - /** - * 报警记录Mapper接口 - * - * @author ruoyi - * @date 2025-01-16 + * 告警记录Mapper接口 */ @Mapper public interface AlarmRecordMapper { - /** - * 查询报警记录 - * - * @param alarmId 报警记录主键 - * @return 报警记录 + * 查询告警记录 + * + * @param alarmId 告警ID + * @return 告警记录 */ - public AlarmRecord selectAlarmRecordByAlarmId(Long alarmId); + public AlarmRecord selectAlarmRecordById(Long alarmId); /** - * 查询报警记录列表 - * - * @param alarmRecord 报警记录 - * @return 报警记录集合 + * 查询告警记录列表 + * + * @param alarmRecord 告警记录 + * @return 告警记录集合 */ public List selectAlarmRecordList(AlarmRecord alarmRecord); /** - * 新增报警记录 - * - * @param alarmRecord 报警记录 + * 新增告警记录 + * + * @param alarmRecord 告警记录 * @return 结果 */ public int insertAlarmRecord(AlarmRecord alarmRecord); /** - * 修改报警记录 - * - * @param alarmRecord 报警记录 + * 修改告警记录 + * + * @param alarmRecord 告警记录 * @return 结果 */ public int updateAlarmRecord(AlarmRecord alarmRecord); /** - * 删除报警记录 - * - * @param alarmId 报警记录主键 + * 删除告警记录 + * + * @param alarmId 告警ID * @return 结果 */ - public int deleteAlarmRecordByAlarmId(Long alarmId); + public int deleteAlarmRecordById(Long alarmId); /** - * 批量删除报警记录 - * - * @param alarmIds 需要删除的数据主键集合 + * 批量删除告警记录 + * + * @param alarmIds 需要删除的数据ID集合 * @return 结果 */ - public int deleteAlarmRecordByAlarmIds(Long[] alarmIds); - + public int deleteAlarmRecordByIds(Long[] alarmIds); + /** - * 处理报警记录 + * 处理告警记录 * - * @param alarmId 报警ID + * @param alarmId 告警ID * @param handleStatus 处理状态 * @param handleRemark 处理备注 * @param handleBy 处理人 @@ -78,10 +73,10 @@ public interface AlarmRecordMapper { @Param("handleBy") String handleBy); /** - * 根据任务ID统计报警数量 + * 根据任务ID统计告警数量 * * @param taskId 任务ID - * @return 报警数量 + * @return 告警数量 */ public Long countAlarmByTaskId(@Param("taskId") Long taskId); } \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/mapper/InspectionTaskMapper.java b/ruoyi-video/src/main/java/com/ruoyi/video/mapper/InspectionTaskMapper.java index b3c6931..41e5b2c 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/mapper/InspectionTaskMapper.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/mapper/InspectionTaskMapper.java @@ -8,32 +8,28 @@ import java.util.List; /** * 巡检任务Mapper接口 - * - * @author ruoyi - * @date 2025-01-16 */ @Mapper public interface InspectionTaskMapper { - - /** - * 查询巡检任务 - * - * @param taskId 巡检任务主键 - * @return 巡检任务 - */ - public InspectionTask selectInspectionTaskByTaskId(Long taskId); - /** * 查询巡检任务列表 - * + * * @param inspectionTask 巡检任务 * @return 巡检任务集合 */ public List selectInspectionTaskList(InspectionTask inspectionTask); + /** + * 查询巡检任务 + * + * @param taskId 巡检任务ID + * @return 巡检任务 + */ + public InspectionTask selectInspectionTaskById(Long taskId); + /** * 新增巡检任务 - * + * * @param inspectionTask 巡检任务 * @return 结果 */ @@ -41,7 +37,7 @@ public interface InspectionTaskMapper { /** * 修改巡检任务 - * + * * @param inspectionTask 巡检任务 * @return 结果 */ @@ -49,20 +45,20 @@ public interface InspectionTaskMapper { /** * 删除巡检任务 - * - * @param taskId 巡检任务主键 + * + * @param taskId 巡检任务ID * @return 结果 */ - public int deleteInspectionTaskByTaskId(Long taskId); + public int deleteInspectionTaskById(Long taskId); /** * 批量删除巡检任务 - * - * @param taskIds 需要删除的数据主键集合 + * + * @param taskIds 需要删除的数据ID集合 * @return 结果 */ - public int deleteInspectionTaskByTaskIds(Long[] taskIds); - + public int deleteInspectionTaskByIds(Long[] taskIds); + /** * 查询启用状态的巡检任务列表 * diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/mapper/VMinioObjectMapper.java b/ruoyi-video/src/main/java/com/ruoyi/video/mapper/VMinioObjectMapper.java index 2263a14..bf73225 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/mapper/VMinioObjectMapper.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/mapper/VMinioObjectMapper.java @@ -4,12 +4,74 @@ import com.ruoyi.video.domain.VMinioObject; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import java.util.List; + +/** + * MinIO对象Mapper接口 + */ @Mapper public interface VMinioObjectMapper { - int insertVMinioObject(VMinioObject obj); - VMinioObject selectVMinioObjectById(@Param("id") Long id); - int deleteVMinioObjectById(@Param("id") Long id); + /** + * 查询MinIO对象 + * + * @param objectId MinIO对象ID + * @return MinIO对象 + */ + public VMinioObject selectVMinioObjectById(Long objectId); - VMinioObject selectVMinioObjectByObjectName(@Param("objectName") String objectName); - int deleteVMinioObjectByObjectName(@Param("objectName") String objectName); + /** + * 查询MinIO对象列表 + * + * @param vMinioObject MinIO对象 + * @return MinIO对象集合 + */ + public List selectVMinioObjectList(VMinioObject vMinioObject); + + /** + * 新增MinIO对象 + * + * @param vMinioObject MinIO对象 + * @return 结果 + */ + public int insertVMinioObject(VMinioObject vMinioObject); + + /** + * 修改MinIO对象 + * + * @param vMinioObject MinIO对象 + * @return 结果 + */ + public int updateVMinioObject(VMinioObject vMinioObject); + + /** + * 删除MinIO对象 + * + * @param objectId MinIO对象ID + * @return 结果 + */ + public int deleteVMinioObjectById(Long objectId); + + /** + * 批量删除MinIO对象 + * + * @param objectIds 需要删除的数据ID + * @return 结果 + */ + public int deleteVMinioObjectByIds(Long[] objectIds); + + /** + * 根据对象名称查询MinIO对象 + * + * @param objectName 对象名称 + * @return MinIO对象 + */ + public VMinioObject selectVMinioObjectByObjectName(String objectName); + + /** + * 根据对象名称删除MinIO对象 + * + * @param objectName 对象名称 + * @return 结果 + */ + public int deleteVMinioObjectByObjectName(String objectName); } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/IInspectionTaskRecordService.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/IInspectionTaskRecordService.java new file mode 100644 index 0000000..00c1f14 --- /dev/null +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/IInspectionTaskRecordService.java @@ -0,0 +1,39 @@ +package com.ruoyi.video.service; + +import com.ruoyi.video.domain.InspectionTaskRecord; +import java.util.List; + +/** + * 巡检任务记录Service接口 + */ +public interface IInspectionTaskRecordService { + /** + * 查询巡检任务记录 + */ + InspectionTaskRecord selectInspectionTaskRecordByRecordId(Long recordId); + + /** + * 查询巡检任务记录列表 + */ + List selectInspectionTaskRecordList(InspectionTaskRecord record); + + /** + * 新增巡检任务记录 + */ + int insertInspectionTaskRecord(InspectionTaskRecord record); + + /** + * 修改巡检任务记录 + */ + int updateInspectionTaskRecord(InspectionTaskRecord record); + + /** + * 删除巡检任务记录 + */ + int deleteInspectionTaskRecordByRecordId(Long recordId); + + /** + * 批量删除巡检任务记录 + */ + int deleteInspectionTaskRecordByRecordIds(Long[] recordIds); +} \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/IVMinioObjectService.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/IVMinioObjectService.java index ce55ee9..89bd66f 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/service/IVMinioObjectService.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/IVMinioObjectService.java @@ -1,13 +1,97 @@ package com.ruoyi.video.service; - import com.ruoyi.video.domain.VMinioObject; +import java.util.List; +/** + * MinIO对象服务接口 + */ public interface IVMinioObjectService { - int insert(VMinioObject obj); - VMinioObject selectById(Long id); - int deleteById(Long id); + /** + * 查询MinIO对象 + * + * @param objectId MinIO对象ID + * @return MinIO对象 + */ + public VMinioObject selectVMinioObjectById(Long objectId); - VMinioObject selectByObjectName(String objectName); - int deleteByObjectName(String objectName); + /** + * 查询MinIO对象列表 + * + * @param vMinioObject MinIO对象 + * @return MinIO对象集合 + */ + public List selectVMinioObjectList(VMinioObject vMinioObject); + + /** + * 新增MinIO对象 + * + * @param vMinioObject MinIO对象 + * @return 结果 + */ + public Long insertVMinioObject(VMinioObject vMinioObject); + + /** + * 修改MinIO对象 + * + * @param vMinioObject MinIO对象 + * @return 结果 + */ + public int updateVMinioObject(VMinioObject vMinioObject); + + /** + * 删除MinIO对象信息 + * + * @param objectId MinIO对象ID + * @return 结果 + */ + public int deleteVMinioObjectById(Long objectId); + + /** + * 批量删除MinIO对象 + * + * @param objectIds 需要删除的MinIO对象ID + * @return 结果 + */ + public int deleteVMinioObjectByIds(Long[] objectIds); + + /** + * 根据对象名查询 + * + * @param objectName 对象名 + * @return MinIO对象 + */ + public VMinioObject selectByObjectName(String objectName); + + /** + * 根据ID查询 + * + * @param id 对象ID + * @return MinIO对象 + */ + public VMinioObject selectById(Long id); + + /** + * 根据ID删除 + * + * @param id 对象ID + * @return 结果 + */ + public int deleteById(Long id); + + /** + * 根据对象名删除 + * + * @param objectName 对象名 + * @return 结果 + */ + public int deleteByObjectName(String objectName); + + /** + * 插入记录 + * + * @param obj MinIO对象 + * @return 结果 + */ + public int insert(VMinioObject obj); } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/InspectionTaskService.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/InspectionTaskService.java index 5added9..7d97517 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/service/InspectionTaskService.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/InspectionTaskService.java @@ -1,8 +1,8 @@ package com.ruoyi.video.service; -import com.ruoyi.video.domain.InspectionTask; import com.ruoyi.video.domain.AlarmRecord; import com.ruoyi.video.domain.Detection; +import com.ruoyi.video.domain.InspectionTask; import org.springframework.scheduling.annotation.Async; import java.util.List; @@ -17,62 +17,100 @@ public interface InspectionTaskService { /** * 查询巡检任务列表 + * + * @param inspectionTask 巡检任务查询条件 + * @return 巡检任务集合 */ List selectInspectionTaskList(InspectionTask inspectionTask); - + /** * 根据ID查询巡检任务 + * + * @param taskId 巡检任务ID + * @return 巡检任务信息 */ InspectionTask selectInspectionTaskById(Long taskId); - + /** * 新增巡检任务 + * + * @param inspectionTask 巡检任务信息 + * @return 结果 */ int insertInspectionTask(InspectionTask inspectionTask); - + /** * 修改巡检任务 + * + * @param inspectionTask 巡检任务信息 + * @return 结果 */ int updateInspectionTask(InspectionTask inspectionTask); - + /** - * 删除巡检任务 + * 批量删除巡检任务 + * + * @param taskIds 需要删除的巡检任务ID + * @return 结果 */ int deleteInspectionTaskByIds(Long[] taskIds); - + /** * 启动巡检任务 + * + * @param taskId 巡检任务ID + * @return 结果 */ boolean startInspectionTask(Long taskId); - + /** * 停止巡检任务 + * + * @param taskId 巡检任务ID + * @return 结果 */ boolean stopInspectionTask(Long taskId); - + /** - * 执行单次巡检任务 + * 执行巡检任务 + * + * @param taskId 巡检任务ID */ @Async void executeInspectionTask(Long taskId); - + /** - * 处理检测结果,如果有异常则生成报警 + * 处理检测结果 + * + * @param taskId 巡检任务ID + * @param detections 检测结果 + * @param imagePath 图像路径 */ void handleDetectionResults(Long taskId, List detections, String imagePath); - + /** - * 保存报警记录 + * 保存告警记录 + * + * @param alarmRecord 告警记录 */ void saveAlarmRecord(AlarmRecord alarmRecord); - + /** - * 查询报警记录列表 + * 查询告警记录列表 + * + * @param alarmRecord 告警记录查询条件 + * @return 告警记录集合 */ List selectAlarmRecordList(AlarmRecord alarmRecord); - + /** - * 处理报警记录 + * 处理告警记录 + * + * @param alarmId 告警ID + * @param handleStatus 处理状态 + * @param handleRemark 处理备注 + * @param handleBy 处理人 + * @return 结果 */ int handleAlarmRecord(Long alarmId, String handleStatus, String handleRemark, String handleBy); } \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/MediaService.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/MediaService.java index 14f7c04..356c39f 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/service/MediaService.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/MediaService.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Service; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; +import java.util.Map; /** * 媒体服务,支持全局网络超时、读写超时、无人拉流持续时长自动关闭流等配置 @@ -25,6 +26,16 @@ public class MediaService { * 缓存流转换线程 */ public static ConcurrentHashMap cameras = new ConcurrentHashMap<>(); + + /** + * 客户端类型映射 + */ + public static ConcurrentHashMap clients = new ConcurrentHashMap<>(); + + /** + * 客户端连接映射 + */ + public static ConcurrentHashMap> clientConnections = new ConcurrentHashMap<>(); /** * http-flv播放 @@ -253,4 +264,34 @@ public class MediaService { } cameras.remove(mediaKey); } + + /** + * 获取设备对应的媒体传输器 + * + * @param deviceId 设备ID + * @return 媒体传输器实例,如果不存在则返回null + */ + public MediaTransferFlvByJavacv getMediaTransfer(Long deviceId) { + if (deviceId == null) { + return null; + } + + // 遍历所有已注册的相机 + for (Map.Entry entry : cameras.entrySet()) { + String mediaKey = entry.getKey(); + if (mediaKey.startsWith("device_" + deviceId + "_")) { + // 找到匹配设备ID的mediaKey + MediaTransfer mediaTransfer = entry.getValue(); + if (mediaTransfer instanceof MediaTransferFlvByJavacv) { + MediaTransferFlvByJavacv transfer = (MediaTransferFlvByJavacv) mediaTransfer; + if (transfer.getCameraDto() != null && + mediaKey.equals(transfer.getCameraDto().getMediaKey())) { + return transfer; + } + } + } + } + + return null; + } } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/VideoAnalysisService.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/VideoAnalysisService.java new file mode 100644 index 0000000..94ae0bb --- /dev/null +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/VideoAnalysisService.java @@ -0,0 +1,595 @@ +package com.ruoyi.video.service; + +import com.ruoyi.common.utils.spring.SpringUtils; +import com.ruoyi.framework.service.MinioService; +import com.ruoyi.video.domain.AlarmRecord; +import com.ruoyi.video.domain.Detection; +import com.ruoyi.video.domain.InspectionTask; +import com.ruoyi.video.domain.VMinioObject; +import com.ruoyi.video.mapper.AlarmRecordMapper; +import com.ruoyi.video.mapper.InspectionTaskMapper; +import com.ruoyi.video.thread.detector.HttpYoloDetector; +import com.ruoyi.video.utils.Overlay; +import com.ruoyi.video.utils.CustomMultipartFile; + +import lombok.extern.slf4j.Slf4j; + +import org.bytedeco.ffmpeg.avcodec.AVPacket; +import org.bytedeco.ffmpeg.global.avcodec; +import org.bytedeco.ffmpeg.global.avutil; +import org.bytedeco.javacv.FFmpegFrameGrabber; +import org.bytedeco.javacv.FFmpegFrameRecorder; +import org.bytedeco.javacv.Frame; +import org.bytedeco.javacv.OpenCVFrameConverter; +import org.bytedeco.opencv.opencv_core.Mat; +import org.bytedeco.opencv.opencv_core.Point; +import org.bytedeco.opencv.opencv_core.Rect; +import org.bytedeco.opencv.opencv_core.Scalar; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.nio.IntBuffer; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.bytedeco.opencv.global.opencv_imgproc.*; + +/** + * 视频分析服务 - 离线分析视频并处理结果 + */ +@Slf4j +@Service +public class VideoAnalysisService { + + @Autowired + private MinioService minioService; + + @Autowired + private IVMinioObjectService vMinioObjectService; + + @Autowired + private InspectionTaskMapper taskMapper; + + @Autowired + private AlarmRecordMapper alarmRecordMapper; + + @Autowired + private com.ruoyi.video.mapper.InspectionTaskRecordMapper inspectionTaskRecordMapper; + + // 检测器配置 - 使用容器名而不是localhost + private static final String PYTHON_API_URL = "http://rtsp-python-service:8000/api/detect/file"; + private static final String MODEL_NAME = "yolov8_detector"; + + /** + * 分析视频并更新记录(同步调用) + * @param task 巡检任务 + * @param record 巡检记录 + * @param videoFile 视频文件 + */ + public void analyzeVideoWithRecord(InspectionTask task, com.ruoyi.video.domain.InspectionTaskRecord record, File videoFile) { + log.info("开始分析视频并更新记录: 任务ID={}, 记录ID={}", task.getTaskId(), record.getRecordId()); + + try { + // 创建输出视频文件 + File outputVideoFile = File.createTempFile("analysis_output_", ".mp4"); + + // 创建检测器 + HttpYoloDetector detector = new HttpYoloDetector("yolov8", PYTHON_API_URL, MODEL_NAME, 0x00FF00); + + // 处理视频并记录检测结果 + String detectionResult = processVideoWithRecord(videoFile, outputVideoFile, detector, task, record); + + // 更新记录的识别结果 + record.setResult(detectionResult); + inspectionTaskRecordMapper.updateInspectionTaskRecord(record); + + // 清理临时输出文件 + if (outputVideoFile.exists()) { + outputVideoFile.delete(); + } + + log.info("视频分析完成: 任务ID={}, 记录ID={}, 检测结果={}", task.getTaskId(), record.getRecordId(), detectionResult); + } catch (Exception e) { + log.error("视频分析失败: 任务ID={}, 记录ID={}, 错误={}", task.getTaskId(), record.getRecordId(), e.getMessage(), e); + // 更新记录为部分成功 + record.setStatus(2); + record.setResult("分析失败: " + e.getMessage()); + inspectionTaskRecordMapper.updateInspectionTaskRecord(record); + } + } + + /** + * 处理视频并记录检测结果 + * @return 检测结果摘要 + */ + private String processVideoWithRecord(File inputFile, File outputFile, HttpYoloDetector detector, + InspectionTask task, com.ruoyi.video.domain.InspectionTaskRecord record) throws Exception { + FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(inputFile); + grabber.start(); + + FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(outputFile, + grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels()); + + recorder.setFormat("mp4"); + recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); + recorder.setFrameRate(grabber.getFrameRate()); + recorder.setVideoBitrate(grabber.getVideoBitrate()); + if (grabber.getAudioChannels() > 0) { + recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); + recorder.setAudioBitrate(grabber.getAudioBitrate()); + recorder.setAudioChannels(grabber.getAudioChannels()); + recorder.setSampleRate(grabber.getSampleRate()); + } + recorder.start(); + + OpenCVFrameConverter.ToMat converter = new OpenCVFrameConverter.ToMat(); + + // 用于去重的垃圾检测结果缓存 + Map detectedGarbageCache = new HashMap<>(); + Map detectionCounts = new HashMap<>(); // 统计每种类别的数量 + + // 跟踪检测到的垃圾ID + final Long[] detectionId = {1L}; + // 帧计数 + long frameCount = 0; + int totalDetections = 0; + + Frame frame; + while ((frame = grabber.grab()) != null) { + frameCount++; + + if (frame.image != null) { + // 处理视频帧 + Mat mat = converter.convert(frame); + + if (mat != null && !mat.isNull()) { + // 每10帧执行一次检测,减少API调用频率 + if (frameCount % 10 == 0) { + List detections = detector.detect(mat); + + if (!CollectionUtils.isEmpty(detections)) { + for (Detection detection : detections) { + // 检查是否为新的垃圾检测结果 + String detectionKey = generateDetectionKey(detection); + if (!detectedGarbageCache.containsKey(detectionKey)) { + // 这是新检测到的垃圾 + detectedGarbageCache.put(detectionKey, detectionId[0]++); + totalDetections++; + + // 统计类别数量 + String label = detection.getLabel(); + detectionCounts.put(label, detectionCounts.getOrDefault(label, 0) + 1); + + // 创建告警记录(不重复) + createAlarmRecordForRecord(task, record, detection, mat, frameCount); + } else { + // 更新上次检测时间 + detectedGarbageCache.put(detectionKey, detectionId[0]++); + } + } + + // 清理超过60秒未检测到的垃圾(假设30fps) + Long currentId = detectionId[0]; + detectedGarbageCache.entrySet().removeIf(entry -> + (currentId - entry.getValue()) > grabber.getFrameRate() * 60); + } + } + + // 转回Frame并写入录像 + Frame processedFrame = converter.convert(mat); + recorder.record(processedFrame); + } else { + // 原样写入 + recorder.record(frame); + } + } else if (frame.samples != null) { + // 音频帧原样写入 + recorder.record(frame); + } + } + + recorder.stop(); + recorder.close(); + grabber.stop(); + grabber.close(); + + // 上传处理后的视频到MinIO + uploadProcessedVideoForRecord(outputFile, task, record); + + // 生成检测结果摘要 + StringBuilder resultSummary = new StringBuilder(); + resultSummary.append("共检测到 ").append(totalDetections).append(" 个问题"); + if (!detectionCounts.isEmpty()) { + resultSummary.append(",详情:"); + detectionCounts.forEach((label, count) -> + resultSummary.append(label).append("(").append(count).append(") ")); + } + + return resultSummary.toString(); + } + + /** + * 为记录创建告警 + */ + private void createAlarmRecordForRecord(InspectionTask task, com.ruoyi.video.domain.InspectionTaskRecord record, + Detection detection, Mat frame, long frameCount) throws Exception { + // 创建告警图像临时文件 + File alarmImageFile = File.createTempFile("alarm_", ".jpg"); + + // 裁剪检测区域,略微扩大区域 + Rect rect = detection.getRect(); + int x = Math.max(0, rect.x() - 10); + int y = Math.max(0, rect.y() - 10); + int w = Math.min(frame.cols() - x, rect.width() + 20); + int h = Math.min(frame.rows() - y, rect.height() + 20); + + // 使用OpenCV保存告警图片 + Mat roi = new Mat(frame, new Rect(x, y, w, h)); + org.bytedeco.opencv.global.opencv_imgcodecs.imwrite(alarmImageFile.getAbsolutePath(), roi); + + // 上传告警图片到MinIO + String fileName = "alarm_" + System.currentTimeMillis() + ".jpg"; + String bucketName = "alarm-images"; + + CustomMultipartFile multipartFile = new CustomMultipartFile(alarmImageFile, fileName, "image/jpeg"); + String objectUrl = minioService.putObject(bucketName, fileName, multipartFile.getInputStream()); + + VMinioObject minioObject = new VMinioObject(); + minioObject.setBucketName(bucketName); + minioObject.setObjectName(fileName); + minioObject.setObjectUrl(objectUrl); + minioObject.setCreateBy("system"); + minioObject.setCreateTime(new Date()); + Long objectId = vMinioObjectService.insertVMinioObject(minioObject); + + // 创建告警记录 + AlarmRecord alarmRecord = new AlarmRecord(); + alarmRecord.setDeviceId(task.getDeviceId()); + alarmRecord.setAlarmType("detection"); + alarmRecord.setAlarmContent(detection.getLabel() + " - 置信度: " + String.format("%.2f", detection.getConfidence())); + alarmRecord.setTaskId(task.getTaskId()); + alarmRecord.setImageOssId(objectId); + alarmRecord.setFramePosition(frameCount); + alarmRecord.setConfidence(detection.getConfidence()); + alarmRecord.setCreateTime(new Date()); + alarmRecord.setStatus(0); // 未处理 + + alarmRecordMapper.insertAlarmRecord(alarmRecord); + + log.info("创建告警记录: 类型={}, 任务ID={}, 记录ID={}, 告警ID={}", + detection.getLabel(), task.getTaskId(), record.getRecordId(), alarmRecord.getAlarmId()); + + // 删除临时文件 + if (alarmImageFile.exists()) { + alarmImageFile.delete(); + } + } + + /** + * 上传处理后的视频(针对记录) + */ + private void uploadProcessedVideoForRecord(File videoFile, InspectionTask task, + com.ruoyi.video.domain.InspectionTaskRecord record) throws Exception { + String fileName = "processed_" + System.currentTimeMillis() + ".mp4"; + String bucketName = "inspection-videos"; + + CustomMultipartFile multipartFile = new CustomMultipartFile(videoFile, fileName, "video/mp4"); + String objectUrl = minioService.putObject(bucketName, fileName, multipartFile.getInputStream()); + + VMinioObject minioObject = new VMinioObject(); + minioObject.setBucketName(bucketName); + minioObject.setObjectName(fileName); + minioObject.setObjectUrl(objectUrl); + minioObject.setCreateBy("system"); + minioObject.setCreateTime(new Date()); + Long objectId = vMinioObjectService.insertVMinioObject(minioObject); + + // 更新任务的处理后视频ID + task.setProcessedVideoOssId(objectId); + task.setVideoStatus(2); // 2: 已分析 + taskMapper.updateInspectionTask(task); + + // 更新记录的附件信息,添加处理后的视频URL + String currentAccessory = record.getAccessory(); + record.setAccessory(currentAccessory + ";" + objectUrl); + inspectionTaskRecordMapper.updateInspectionTaskRecord(record); + + log.info("处理后视频已上传: 任务ID={}, 记录ID={}, MinIO对象ID={}", + task.getTaskId(), record.getRecordId(), objectId); + } + + /** + * 异步分析视频 + * @param taskId 巡检任务ID + */ + @Async + public void analyzeVideo(Long taskId) { + log.info("开始异步分析视频: 任务ID={}", taskId); + + try { + // 1. 获取任务信息 + InspectionTask task = taskMapper.selectInspectionTaskById(taskId); + if (task == null) { + log.error("任务不存在: {}", taskId); + return; + } + + // 2. 获取原始视频信息 + Long videoOssId = task.getVideoOssId(); + if (videoOssId == null) { + log.error("任务视频不存在: {}", taskId); + return; + } + + VMinioObject videoObject = vMinioObjectService.selectVMinioObjectById(videoOssId); + if (videoObject == null) { + log.error("MinIO对象不存在: {}", videoOssId); + return; + } + + // 3. 下载原始视频到临时文件 + File videoTempFile = File.createTempFile("analysis_input_", ".mp4"); + try (InputStream inputStream = minioService.getObject(videoObject.getBucketName(), videoObject.getObjectName()); + FileOutputStream fileOutputStream = new FileOutputStream(videoTempFile)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + fileOutputStream.write(buffer, 0, bytesRead); + } + } + + // 4. 创建输出视频文件 + File outputVideoFile = File.createTempFile("analysis_output_", ".mp4"); + + // 5. 创建检测器 + HttpYoloDetector detector = new HttpYoloDetector("garbage", PYTHON_API_URL, MODEL_NAME, 0x00FF00); + + // 6. 处理视频 + processVideo(videoTempFile, outputVideoFile, detector, task); + + // 7. 清理临时文件 + if (videoTempFile.exists()) { + videoTempFile.delete(); + } + + log.info("视频分析完成: 任务ID={}", taskId); + } catch (Exception e) { + log.error("视频分析失败: 任务ID={}, 错误={}", taskId, e.getMessage()); + e.printStackTrace(); + } + } + + /** + * 处理视频 + * @param inputFile 输入视频文件 + * @param outputFile 输出视频文件 + * @param detector 检测器 + * @param task 巡检任务 + */ + private void processVideo(File inputFile, File outputFile, HttpYoloDetector detector, InspectionTask task) throws Exception { + FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(inputFile); + grabber.start(); + + FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(outputFile, + grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels()); + + recorder.setFormat("mp4"); + recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); + recorder.setFrameRate(grabber.getFrameRate()); + recorder.setVideoBitrate(grabber.getVideoBitrate()); + recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); + recorder.setAudioBitrate(grabber.getAudioBitrate()); + recorder.setAudioChannels(grabber.getAudioChannels()); + recorder.setSampleRate(grabber.getSampleRate()); + recorder.start(); + + OpenCVFrameConverter.ToMat converter = new OpenCVFrameConverter.ToMat(); + + // 用于去重的垃圾检测结果缓存 + Map detectedGarbageCache = new HashMap<>(); + // 跟踪检测到的垃圾ID + final Long[] detectionId = {1L}; // 使用数组实现effectively final + // 帧计数 + long frameCount = 0; + + Frame frame; + while ((frame = grabber.grab()) != null) { + frameCount++; + + if (frame.image != null) { + // 处理视频帧 + Mat mat = converter.convert(frame); + + if (mat != null && !mat.isNull()) { + // 每10帧执行一次检测,减少API调用频率 + if (frameCount % 10 == 0) { + List detections = detector.detect(mat); + + if (!CollectionUtils.isEmpty(detections)) { + // 绘制检测框 + for (Detection detection : detections) { + drawDetection(mat, detection); + + // 检查是否为新的垃圾检测结果 + String detectionKey = generateDetectionKey(detection); + if (!detectedGarbageCache.containsKey(detectionKey)) { + // 这是新检测到的垃圾 + detectedGarbageCache.put(detectionKey, detectionId[0]++); + + // 创建告警记录 + createAlarmRecord(task, detection, mat, frameCount); + } else { + // 更新上次检测时间 + detectedGarbageCache.put(detectionKey, detectionId[0]++); + } + } + + // 清理超过60秒未检测到的垃圾(假设30fps) + Long currentId = detectionId[0]; + detectedGarbageCache.entrySet().removeIf(entry -> + (currentId - entry.getValue()) > grabber.getFrameRate() * 60); + } + } + + // 转回Frame并写入录像 + Frame processedFrame = converter.convert(mat); + recorder.record(processedFrame); + } else { + // 原样写入 + recorder.record(frame); + } + } else if (frame.samples != null) { + // 音频帧原样写入 + recorder.record(frame); + } + } + + recorder.stop(); + recorder.close(); + grabber.stop(); + grabber.close(); + + // 上传处理后的视频 + uploadProcessedVideo(outputFile, task); + } + + /** + * 上传处理后的视频到MinIO + */ + private void uploadProcessedVideo(File videoFile, InspectionTask task) throws Exception { + String fileName = "processed_" + System.currentTimeMillis() + ".mp4"; + String bucketName = "inspection-videos"; + + CustomMultipartFile multipartFile = new CustomMultipartFile(videoFile, fileName, "video/mp4"); + String objectUrl = minioService.putObject(bucketName, fileName, multipartFile.getInputStream()); + + VMinioObject minioObject = new VMinioObject(); + minioObject.setBucketName(bucketName); + minioObject.setObjectName(fileName); + minioObject.setObjectUrl(objectUrl); + minioObject.setCreateBy("system"); + minioObject.setCreateTime(new Date()); + Long objectId = vMinioObjectService.insertVMinioObject(minioObject); + + // 更新任务的处理后视频ID + task.setProcessedVideoOssId(objectId); + task.setVideoStatus(2); // 2: 已分析 + taskMapper.updateInspectionTask(task); + + log.info("处理后视频已上传: 任务ID={}, MinIO对象ID={}", task.getTaskId(), objectId); + + // 删除临时文件 + if (videoFile.exists()) { + videoFile.delete(); + } + } + + /** + * 生成检测结果的唯一键,用于检测结果去重 + */ + private String generateDetectionKey(Detection detection) { + // 使用检测框的位置和大小来生成键 + // 允许小范围波动,认为是同一个物体 + Rect rect = detection.getRect(); + int x = rect.x() / 10 * 10; // 取10的倍数,允许小波动 + int y = rect.y() / 10 * 10; + int w = rect.width() / 10 * 10; + int h = rect.height() / 10 * 10; + return String.format("%s_%d_%d_%d_%d", detection.getLabel(), x, y, w, h); + } + + /** + * 创建告警记录 + */ + private void createAlarmRecord(InspectionTask task, Detection detection, Mat frame, long frameCount) throws Exception { + // 创建告警图像临时文件 + File alarmImageFile = File.createTempFile("alarm_", ".jpg"); + + // 裁剪检测区域,略微扩大区域 + Rect rect = detection.getRect(); + int x = Math.max(0, rect.x() - 10); + int y = Math.max(0, rect.y() - 10); + int w = Math.min(frame.cols() - x, rect.width() + 20); + int h = Math.min(frame.rows() - y, rect.height() + 20); + + // 使用OpenCV保存告警图片 + Mat roi = new Mat(frame, new Rect(x, y, w, h)); + org.bytedeco.opencv.global.opencv_imgcodecs.imwrite(alarmImageFile.getAbsolutePath(), roi); + + // 上传告警图片到MinIO + String fileName = "alarm_" + System.currentTimeMillis() + ".jpg"; + String bucketName = "alarm-images"; + + CustomMultipartFile multipartFile = new CustomMultipartFile(alarmImageFile, fileName, "image/jpeg"); + String objectUrl = minioService.putObject(bucketName, fileName, multipartFile.getInputStream()); + + VMinioObject minioObject = new VMinioObject(); + minioObject.setBucketName(bucketName); + minioObject.setObjectName(fileName); + minioObject.setObjectUrl(objectUrl); + minioObject.setCreateBy("system"); + minioObject.setCreateTime(new Date()); + Long objectId = vMinioObjectService.insertVMinioObject(minioObject); + + // 创建告警记录 + AlarmRecord alarmRecord = new AlarmRecord(); + alarmRecord.setDeviceId(task.getDeviceId()); + alarmRecord.setAlarmType("garbage"); + alarmRecord.setAlarmContent(detection.getLabel() + " - 置信度: " + String.format("%.2f", detection.getConfidence())); + alarmRecord.setTaskId(task.getTaskId()); + alarmRecord.setImageOssId(objectId); + alarmRecord.setFramePosition(frameCount); + alarmRecord.setConfidence(detection.getConfidence()); + alarmRecord.setCreateTime(new Date()); + + alarmRecordMapper.insertAlarmRecord(alarmRecord); + + log.info("创建告警记录: 类型={}, 任务ID={}, 告警ID={}", detection.getLabel(), task.getTaskId(), alarmRecord.getAlarmId()); + + // 删除临时文件 + if (alarmImageFile.exists()) { + alarmImageFile.delete(); + } + } + + /** + * 在图像上绘制检测框 + */ + private void drawDetection(Mat frame, Detection detection) { + // 绘制边界框 + Rect rect = detection.getRect(); + Scalar color = new Scalar(detection.getColorBGR()); + int thickness = 2; + + rectangle(frame, rect, color, thickness, LINE_8, 0); + + // 绘制标签背景 + String label = String.format("%s: %.2f", detection.getLabel(), detection.getConfidence()); + Point textPosition = new Point(rect.x(), rect.y() - 10); + + // 获取文本大小 - 添加缺失的IntBuffer参数 + IntBuffer baseline = IntBuffer.allocate(1); + org.bytedeco.opencv.opencv_core.Size textSize = getTextSize( + label, FONT_HERSHEY_SIMPLEX, 0.5, thickness, baseline); + + // 绘制标签背景矩形 + rectangle(frame, + new Rect(rect.x(), rect.y() - textSize.height() - 10, + textSize.width(), textSize.height() + 10), + color, FILLED, LINE_8, 0); + + // 绘制文本 + putText(frame, label, textPosition, + FONT_HERSHEY_SIMPLEX, 0.5, new Scalar(255, 255, 255, 0), + 1, LINE_AA, false); + } +} \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/IInspectionTaskRecordServiceImpl.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/IInspectionTaskRecordServiceImpl.java new file mode 100644 index 0000000..9c99bcb --- /dev/null +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/IInspectionTaskRecordServiceImpl.java @@ -0,0 +1,52 @@ +package com.ruoyi.video.service.impl; + +import com.ruoyi.video.domain.InspectionTaskRecord; +import com.ruoyi.video.mapper.InspectionTaskRecordMapper; +import com.ruoyi.video.service.IInspectionTaskRecordService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; + +/** + * 巡检任务记录Service业务层处理 + */ +@Service +public class IInspectionTaskRecordServiceImpl implements IInspectionTaskRecordService { + + @Autowired + private InspectionTaskRecordMapper inspectionTaskRecordMapper; + + @Override + public InspectionTaskRecord selectInspectionTaskRecordByRecordId(Long recordId) { + return inspectionTaskRecordMapper.selectInspectionTaskRecordByRecordId(recordId); + } + + @Override + public List selectInspectionTaskRecordList(InspectionTaskRecord record) { + return inspectionTaskRecordMapper.selectInspectionTaskRecordList(record); + } + + @Override + public int insertInspectionTaskRecord(InspectionTaskRecord record) { + record.setCreateTime(new Date()); + return inspectionTaskRecordMapper.insertInspectionTaskRecord(record); + } + + @Override + public int updateInspectionTaskRecord(InspectionTaskRecord record) { + record.setUpdateTime(new Date()); + return inspectionTaskRecordMapper.updateInspectionTaskRecord(record); + } + + @Override + public int deleteInspectionTaskRecordByRecordId(Long recordId) { + return inspectionTaskRecordMapper.deleteInspectionTaskRecordByRecordId(recordId); + } + + @Override + public int deleteInspectionTaskRecordByRecordIds(Long[] recordIds) { + return inspectionTaskRecordMapper.deleteInspectionTaskRecordByRecordIds(recordIds); + } +} \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/InspectionTaskServiceImpl.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/InspectionTaskServiceImpl.java index 99be155..cdefad9 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/InspectionTaskServiceImpl.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/InspectionTaskServiceImpl.java @@ -39,9 +39,6 @@ import java.util.concurrent.ConcurrentHashMap; /** * 巡检任务服务实现 - * - * @Author: orange - * @CreateTime: 2025-01-16 */ @Slf4j @Service @@ -55,6 +52,15 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { @Autowired private AlarmRecordMapper alarmRecordMapper; + + @Autowired + private com.ruoyi.video.mapper.InspectionTaskRecordMapper inspectionTaskRecordMapper; + + @Autowired + private MinioService minioService; + + @Autowired + private IVMinioObjectService vMinioObjectService; // 运行状态缓存(避免重复执行) private final Map runningTasks = new ConcurrentHashMap<>(); @@ -70,21 +76,22 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { @Override public InspectionTask selectInspectionTaskById(Long taskId) { - return inspectionTaskMapper.selectInspectionTaskByTaskId(taskId); + return inspectionTaskMapper.selectInspectionTaskById(taskId); } @Override public int insertInspectionTask(InspectionTask inspectionTask) { inspectionTask.setCreateTime(DateUtils.getNowDate()); - inspectionTask.setCreateBy(SecurityUtils.getUsername()); - inspectionTask.setExecuteCount(0L); - inspectionTask.setAlarmCount(0L); + // 这些字段在新版实体类中可能不存在,需要进行调整 + // inspectionTask.setExecuteCount(0L); + // inspectionTask.setAlarmCount(0L); // 获取设备信息 Device device = deviceService.selectDeviceByDeviceId(inspectionTask.getDeviceId()); - if (device != null) { - inspectionTask.setDeviceName(device.getIp()); - } + // 新版实体类可能不需要设备名称 + // if (device != null) { + // inspectionTask.setDeviceName(device.getIp()); + // } return inspectionTaskMapper.insertInspectionTask(inspectionTask); } @@ -92,7 +99,6 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { @Override public int updateInspectionTask(InspectionTask inspectionTask) { inspectionTask.setUpdateTime(DateUtils.getNowDate()); - inspectionTask.setUpdateBy(SecurityUtils.getUsername()); return inspectionTaskMapper.updateInspectionTask(inspectionTask); } @@ -101,88 +107,232 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { for (Long taskId : taskIds) { stopInspectionTask(taskId); } - return inspectionTaskMapper.deleteInspectionTaskByTaskIds(taskIds); + return inspectionTaskMapper.deleteInspectionTaskByIds(taskIds); } @Override public boolean startInspectionTask(Long taskId) { - InspectionTask task = inspectionTaskMapper.selectInspectionTaskByTaskId(taskId); + InspectionTask task = inspectionTaskMapper.selectInspectionTaskById(taskId); if (task == null) { return false; } - task.setStatus("0"); // 启用 + // 启用任务,使用新版实体类的方法 + task.setStatus(0); // 0表示启用 task.setUpdateTime(DateUtils.getNowDate()); - task.setUpdateBy(SecurityUtils.getUsername()); inspectionTaskMapper.updateInspectionTask(task); runningTasks.put(taskId, true); // 这里应该集成到Quartz定时任务中 - log.info("启动巡检任务: {} - {}", taskId, task.getTaskName()); + log.info("启动巡检任务: {}", taskId); return true; } @Override public boolean stopInspectionTask(Long taskId) { - InspectionTask task = inspectionTaskMapper.selectInspectionTaskByTaskId(taskId); + InspectionTask task = inspectionTaskMapper.selectInspectionTaskById(taskId); if (task == null) { return false; } - task.setStatus("1"); // 停用 + // 停用任务,使用新版实体类的方法 + task.setStatus(1); // 1表示停用 task.setUpdateTime(DateUtils.getNowDate()); - task.setUpdateBy(SecurityUtils.getUsername()); inspectionTaskMapper.updateInspectionTask(task); runningTasks.remove(taskId); - log.info("停止巡检任务: {} - {}", taskId, task.getTaskName()); + log.info("停止巡检任务: {}", taskId); return true; } @Override @Async public void executeInspectionTask(Long taskId) { - InspectionTask task = inspectionTaskMapper.selectInspectionTaskByTaskId(taskId); - if (task == null || !"0".equals(task.getStatus())) { + InspectionTask task = inspectionTaskMapper.selectInspectionTaskById(taskId); + if (task == null || task.getStatus() != 0) { // 0表示启用状态 return; } - log.info("开始执行巡检任务: {} - {}", taskId, task.getTaskName()); + log.info("开始执行巡检任务: {}", taskId); + + // 创建巡检记录 + InspectionTaskRecord record = new InspectionTaskRecord(); + record.setTaskId(taskId); + record.setExecuteTime(new Date()); + record.setStatus(1); // 执行中 + inspectionTaskRecordMapper.insertInspectionTaskRecord(record); + Long recordId = record.getRecordId(); + + long startTime = System.currentTimeMillis(); try { - // 更新执行信息 - task.setLastExecuteTime(new Date()); - task.setExecuteCount(task.getExecuteCount() + 1); + // 更新任务状态为执行中 + task.setStatus(1); + inspectionTaskMapper.updateInspectionTask(task); // 获取设备信息 Device device = deviceService.selectDeviceByDeviceId(task.getDeviceId()); if (device == null) { log.error("设备不存在: {}", task.getDeviceId()); + updateRecordFailed(record, "设备不存在"); return; } - // 执行视频分析 - performVideoAnalysis(task, device); + // 执行视频录制和分析 + performVideoAnalysisWithRecord(task, device, record); - // 更新任务执行统计信息 - Long alarmCount = alarmRecordMapper.countAlarmByTaskId(taskId); - inspectionTaskMapper.updateTaskExecuteInfo(taskId, task.getExecuteCount(), alarmCount); + // 更新执行时长 + long duration = (System.currentTimeMillis() - startTime) / 1000; + record.setDuration((int) duration); + record.setStatus(0); // 成功 + inspectionTaskRecordMapper.updateInspectionTaskRecord(record); + + // 更新任务状态为已完成 + task.setStatus(2); + inspectionTaskMapper.updateInspectionTask(task); } catch (Exception e) { - log.error("执行巡检任务失败: {} - {}", taskId, e.getMessage(), e); + log.error("巡检任务执行失败: taskId={}", taskId, e); + updateRecordFailed(record, e.getMessage()); + + // 更新任务状态为已完成(虽然失败) + task.setStatus(2); + inspectionTaskMapper.updateInspectionTask(task); } } + + /** + * 更新记录为失败状态 + */ + private void updateRecordFailed(InspectionTaskRecord record, String errorMsg) { + record.setStatus(1); // 失败 + record.setResult("执行失败: " + errorMsg); + inspectionTaskRecordMapper.updateInspectionTaskRecord(record); + } + + /** + * 执行视频录制和分析(带记录) + */ + private void performVideoAnalysisWithRecord(InspectionTask task, Device device, InspectionTaskRecord record) throws Exception { + FFmpegFrameGrabber grabber = null; + FFmpegFrameRecorder recorder = null; + File videoTempFile = null; + + try { + // 1. 创建临时视频文件 + videoTempFile = File.createTempFile("inspection_", ".mp4"); + + // 2. 创建视频抓取器 + grabber = new FFmpegFrameGrabber(device.getUrl()); + grabber.setOption("rtsp_transport", "tcp"); + grabber.setOption("stimeout", "5000000"); + grabber.start(); + + // 3. 创建视频录制器 + recorder = new FFmpegFrameRecorder(videoTempFile, + grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels()); + recorder.setFormat("mp4"); + recorder.setVideoCodec(org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_H264); + recorder.setFrameRate(grabber.getFrameRate()); + recorder.setVideoBitrate(grabber.getVideoBitrate()); + if (grabber.getAudioChannels() > 0) { + recorder.setAudioCodec(org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_AAC); + recorder.setAudioBitrate(grabber.getAudioBitrate()); + recorder.setAudioChannels(grabber.getAudioChannels()); + recorder.setSampleRate(grabber.getSampleRate()); + } + recorder.start(); + + log.info("开始录制视频流: {}, 时长: {}秒", device.getUrl(), task.getDuration()); + + // 4. 录制指定时长的视频 + long startTime = System.currentTimeMillis(); + long duration = task.getDuration() * 1000L; // 转换为毫秒 + + while (System.currentTimeMillis() - startTime < duration) { + Frame frame = grabber.grab(); + if (frame != null) { + recorder.record(frame); + } + } + + // 5. 停止录制 + recorder.stop(); + recorder.close(); + grabber.stop(); + grabber.close(); + + log.info("视频录制完成: {}", videoTempFile.getAbsolutePath()); + + // 6. 上传视频到MinIO + String fileName = "inspection_" + task.getTaskId() + "_" + System.currentTimeMillis() + ".mp4"; + String bucketName = "inspection-videos"; + + CustomMultipartFile multipartFile = new CustomMultipartFile(videoTempFile, fileName, "video/mp4"); + String objectUrl = minioService.putObject(bucketName, fileName, multipartFile.getInputStream()); + + VMinioObject minioObject = new VMinioObject(); + minioObject.setBucketName(bucketName); + minioObject.setObjectName(fileName); + minioObject.setObjectUrl(objectUrl); + minioObject.setCreateBy("system"); + minioObject.setCreateTime(new Date()); + Long objectId = vMinioObjectService.insertVMinioObject(minioObject); + + // 7. 更新任务的视频ID和状态 + task.setVideoOssId(objectId); + task.setVideoStatus(1); // 已录制未分析 + inspectionTaskMapper.updateInspectionTask(task); + + // 8. 更新记录的附件信息(视频URL) + record.setAccessory(objectUrl); + inspectionTaskRecordMapper.updateInspectionTaskRecord(record); + + log.info("视频已上传到MinIO: objectId={}, url={}", objectId, objectUrl); + + // 9. 调用Python服务进行视频分析 + analyzeVideoAndUpdateRecord(task, record, videoTempFile); + + } finally { + // 清理资源 + if (recorder != null) { + try { recorder.close(); } catch (Exception ignore) {} + } + if (grabber != null) { + try { grabber.close(); } catch (Exception ignore) {} + } + if (videoTempFile != null && videoTempFile.exists()) { + videoTempFile.delete(); + } + } + } + + /** + * 分析视频并更新记录 + */ + private void analyzeVideoAndUpdateRecord(InspectionTask task, InspectionTaskRecord record, File videoFile) throws Exception { + log.info("开始分析视频: taskId={}, recordId={}", task.getTaskId(), record.getRecordId()); + + // 调用VideoAnalysisService进行分析 + com.ruoyi.video.service.VideoAnalysisService videoAnalysisService = + SpringUtils.getBean(com.ruoyi.video.service.VideoAnalysisService.class); + + // 异步调用视频分析(会处理检测结果并创建告警) + videoAnalysisService.analyzeVideoWithRecord(task, record, videoFile); + } /** * 执行视频分析 */ private void performVideoAnalysis(InspectionTask task, Device device) { - if (!"0".equals(task.getEnableDetection())) { - log.info("巡检任务未启用检测: {}", task.getTaskId()); - return; - } + // 这些条件在新版实体类中可能需要调整 + // 0表示启用检测 + // if (task.getEnableDetection() != 0) { + // log.info("巡检任务未启用检测: {}", task.getTaskId()); + // return; + // } FFmpegFrameGrabber grabber = null; FFmpegFrameRecorder sessionRecorder = null; @@ -214,7 +364,9 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { // 会话聚合参数与状态 final long minGapMs = 3000L; // 目标消失超过该值视为结束 final long maxDurationMs = 30000L; // 单次会话最长30s - final float detectionThreshold = task.getThreshold() != null ? task.getThreshold().floatValue() : 0.7f; + // 这个字段在新版实体类中可能需要调整 + // final float detectionThreshold = task.getThreshold() != null ? task.getThreshold().floatValue() : 0.7f; + final float detectionThreshold = 0.7f; // 使用默认阈值 boolean sessionActive = false; long sessionStartMs = 0L; @@ -263,9 +415,9 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { // 最高置信度 Detection best = detections.stream() - .max(Comparator.comparingDouble(Detection::conf)) + .max(Comparator.comparingDouble(Detection::getConfidence)) .orElse(null); - boolean hasTarget = best != null && best.conf() >= detectionThreshold; + boolean hasTarget = best != null && best.getConfidence() >= detectionThreshold; if (hasTarget) { lastSeenMs = now; @@ -296,23 +448,24 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { VMinioObject rec = new VMinioObject(); rec.setObjectName(up.getObjectName()); - rec.setUrl(up.getUrl()); + rec.setObjectUrl(up.getUrl()); rec.setOriginalName(originalName); - objSvc.insert(rec); + objSvc.insertVMinioObject(rec); AlarmRecord alarm = new AlarmRecord(); alarm.setTaskId(task.getTaskId()); - alarm.setTaskName(task.getTaskName()); + // 这些字段在新版实体类中可能不存在,需要调整 + // alarm.setTaskName(task.getTaskName()); alarm.setDeviceId(task.getDeviceId()); - alarm.setDeviceName(task.getDeviceName()); - alarm.setAlarmType(best.cls()); - alarm.setAlarmLevel(getAlarmLevel(best.conf())); - alarm.setAlarmDesc(String.format("检测到%s,置信度: %.2f", best.cls(), best.conf())); - alarm.setConfidence((double) best.conf()); - alarm.setImagePath(up.getUrl()); - alarm.setAlarmTime(new Date(now)); - alarm.setHandleStatus("0"); - alarm.setCreateBy(SecurityUtils.getUsername()); + // alarm.setDeviceName(task.getDeviceName()); + alarm.setAlarmType(best.getLabel()); + // 这里需要转换double为float + alarm.setAlarmLevel(getAlarmLevel((float)best.getConfidence())); + alarm.setAlarmContent(String.format("检测到%s,置信度: %.2f", best.getLabel(), best.getConfidence())); + alarm.setConfidence(best.getConfidence()); + alarm.setImageOssId(rec.getObjectId()); + alarm.setCreateTime(new Date(now)); + alarm.setStatus(0); // 0: 未处理 saveAlarmRecord(alarm); currentAlarmId = alarm.getAlarmId(); @@ -387,13 +540,14 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { VMinioObject vrec = new VMinioObject(); vrec.setObjectName(upv.getObjectName()); - vrec.setUrl(upv.getUrl()); + vrec.setObjectUrl(upv.getUrl()); vrec.setOriginalName(originalName); - objSvc.insert(vrec); + objSvc.insertVMinioObject(vrec); AlarmRecord patch = new AlarmRecord(); patch.setAlarmId(currentAlarmId); - patch.setVideoPath(upv.getUrl()); + // videoPath字段在新版实体类中可能不存在,需要调整 + // patch.setVideoPath(upv.getUrl()); alarmRecordMapper.updateAlarmRecord(patch); } catch (Exception ue) { log.warn("巡检会话-上传/回填视频失败: {}", ue.getMessage()); @@ -468,7 +622,7 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { private List performDetection(Mat mat) { try { if (modelManager != null) { - YoloDetector detector = modelManager.get("garbage"); // 应该使用垃圾检测 当开启自动巡检的时候 需要替换模型 + YoloDetector detector = modelManager.get("garbage"); // 使用垃圾检测模型 if (detector != null) { return detector.detect(mat); } @@ -508,40 +662,41 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { @Override public void handleDetectionResults(Long taskId, List detections, String imagePath) { - InspectionTask task = inspectionTaskMapper.selectInspectionTaskByTaskId(taskId); + InspectionTask task = inspectionTaskMapper.selectInspectionTaskById(taskId); if (task == null || detections.isEmpty()) { return; } for (Detection detection : detections) { - // 检查置信度是否超过阈值 - if (detection.conf() >= task.getThreshold()) { - // 创建报警记录 + // 检查置信度是否超过阈值 - 这里使用了默认阈值,因为实体类可能已变更 + if (detection.getConfidence() >= 0.7) { + // 创建告警记录 AlarmRecord alarmRecord = new AlarmRecord(); alarmRecord.setTaskId(taskId); - alarmRecord.setTaskName(task.getTaskName()); + // 这些字段在新版实体类中可能不存在,需要调整 + // alarmRecord.setTaskName(task.getTaskName()); alarmRecord.setDeviceId(task.getDeviceId()); - alarmRecord.setDeviceName(task.getDeviceName()); - alarmRecord.setAlarmType(detection.cls()); - alarmRecord.setAlarmLevel(getAlarmLevel(detection.conf())); - alarmRecord.setAlarmDesc(String.format("检测到%s,置信度: %.2f", - detection.cls(), detection.conf())); - alarmRecord.setConfidence((double) detection.conf()); - alarmRecord.setImagePath(imagePath); - alarmRecord.setAlarmTime(new Date()); - alarmRecord.setHandleStatus("0"); // 未处理 - alarmRecord.setCreateBy(SecurityUtils.getUsername()); - + // alarmRecord.setDeviceName(task.getDeviceName()); + alarmRecord.setAlarmType(detection.getLabel()); + alarmRecord.setAlarmLevel(getAlarmLevel((float)detection.getConfidence())); + alarmRecord.setAlarmContent(String.format("检测到%s,置信度: %.2f", + detection.getLabel(), detection.getConfidence())); + alarmRecord.setConfidence(detection.getConfidence()); + // 设置图片路径 - 需要调整为适合新实体类的方式 + // alarmRecord.setImagePath(imagePath); + alarmRecord.setCreateTime(new Date()); + alarmRecord.setStatus(0); // 0: 未处理 + saveAlarmRecord(alarmRecord); - log.warn("生成报警记录: 任务[{}] 检测到[{}] 置信度[{}]", - taskId, detection.cls(), detection.conf()); + log.warn("生成告警记录: 任务[{}] 检测到[{}] 置信度[{}]", + taskId, detection.getLabel(), detection.getConfidence()); } } } /** - * 根据置信度确定报警级别 + * 根据置信度确定告警级别 */ private String getAlarmLevel(float confidence) { if (confidence >= 0.9f) { @@ -557,7 +712,7 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { public void saveAlarmRecord(AlarmRecord alarmRecord) { alarmRecord.setCreateTime(DateUtils.getNowDate()); alarmRecordMapper.insertAlarmRecord(alarmRecord); - log.info("保存报警记录: {}", alarmRecord.getAlarmId()); + log.info("保存告警记录: {}", alarmRecord.getAlarmId()); } @Override diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/VMinioObjectServiceImpl.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/VMinioObjectServiceImpl.java index fdbaa51..7a96d7e 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/VMinioObjectServiceImpl.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/VMinioObjectServiceImpl.java @@ -6,12 +6,64 @@ import com.ruoyi.video.service.IVMinioObjectService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import java.util.List; + @Service public class VMinioObjectServiceImpl implements IVMinioObjectService { @Autowired private VMinioObjectMapper mapper; + @Override + public VMinioObject selectVMinioObjectById(Long objectId) { + return mapper.selectVMinioObjectById(objectId); + } + + @Override + public List selectVMinioObjectList(VMinioObject vMinioObject) { + return mapper.selectVMinioObjectList(vMinioObject); + } + + @Override + public Long insertVMinioObject(VMinioObject vMinioObject) { + int rows = mapper.insertVMinioObject(vMinioObject); + return rows > 0 ? vMinioObject.getObjectId() : null; + } + + @Override + public int updateVMinioObject(VMinioObject vMinioObject) { + return mapper.updateVMinioObject(vMinioObject); + } + + @Override + public int deleteVMinioObjectById(Long objectId) { + return mapper.deleteVMinioObjectById(objectId); + } + + @Override + public int deleteVMinioObjectByIds(Long[] objectIds) { + return mapper.deleteVMinioObjectByIds(objectIds); + } + + @Override + public VMinioObject selectByObjectName(String objectName) { + return mapper.selectVMinioObjectByObjectName(objectName); + } + + @Override + public VMinioObject selectById(Long id) { + return mapper.selectVMinioObjectById(id); + } + + @Override + public int deleteById(Long id) { + return mapper.deleteVMinioObjectById(id); + } + + @Override + public int deleteByObjectName(String objectName) { + return mapper.deleteVMinioObjectByObjectName(objectName); + } @Override public int insert(VMinioObject obj) { @@ -20,24 +72,4 @@ public class VMinioObjectServiceImpl implements IVMinioObjectService { } return mapper.insertVMinioObject(obj); } - - @Override - public VMinioObject selectById(Long id) { - return mapper.selectVMinioObjectById(id); - } - - @Override - public int deleteById(Long id) { - return mapper.deleteVMinioObjectById(id); - } - - @Override - public VMinioObject selectByObjectName(String objectName) { - return mapper.selectVMinioObjectByObjectName(objectName); - } - - @Override - public int deleteByObjectName(String objectName) { - return mapper.deleteVMinioObjectByObjectName(objectName); - } } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/thread/MediaTransferFlvByFFmpeg.java b/ruoyi-video/src/main/java/com/ruoyi/video/thread/MediaTransferFlvByFFmpeg.java index 3ed8f01..9053660 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/thread/MediaTransferFlvByFFmpeg.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/thread/MediaTransferFlvByFFmpeg.java @@ -540,7 +540,7 @@ public class MediaTransferFlvByFFmpeg extends MediaTransfer { try { if (ctx.channel().isWritable()) { // 发送帧前先发送header - if (ClientType.HTTP.getType() == ctype.getType()) { + if (ClientType.HTTP == ctype) { ChannelFuture future = ctx.writeAndFlush(Unpooled.copiedBuffer(header)); future.addListener(new GenericFutureListener>() { @Override @@ -550,7 +550,7 @@ public class MediaTransferFlvByFFmpeg extends MediaTransfer { } } }); - } else if (ClientType.WEBSOCKET.getType() == ctype.getType()) { + } else if (ClientType.WEBSOCKET == ctype) { ChannelFuture future = ctx .writeAndFlush(new BinaryWebSocketFrame(Unpooled.copiedBuffer(header))); future.addListener(new GenericFutureListener>() { diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/thread/MediaTransferFlvByJavacv.java b/ruoyi-video/src/main/java/com/ruoyi/video/thread/MediaTransferFlvByJavacv.java index 1a3402b..d2375d0 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/thread/MediaTransferFlvByJavacv.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/thread/MediaTransferFlvByJavacv.java @@ -1,19 +1,16 @@ package com.ruoyi.video.thread; import com.ruoyi.video.common.ClientType; -import com.ruoyi.video.common.ModelManager; import com.ruoyi.video.domain.Detection; import com.ruoyi.video.domain.dto.CameraDto; import com.ruoyi.video.service.MediaService; -import com.ruoyi.video.thread.detector.CompositeDetector; -import com.ruoyi.video.thread.detector.YoloDetector; import com.ruoyi.video.utils.Overlay; import com.ruoyi.common.utils.spring.SpringUtils; import com.ruoyi.framework.service.MinioService; import com.ruoyi.video.service.IVMinioObjectService; import com.ruoyi.video.domain.VMinioObject; -import com.ruoyi.video.domain.AlarmRecord; -import com.ruoyi.video.mapper.AlarmRecordMapper; +import com.ruoyi.video.domain.InspectionTask; +import com.ruoyi.video.mapper.InspectionTaskMapper; import com.ruoyi.video.utils.CustomMultipartFile; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFuture; @@ -33,7 +30,6 @@ import org.springframework.util.CollectionUtils; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; -import java.net.URL; import java.nio.ByteBuffer; import java.util.Date; import java.util.List; @@ -50,11 +46,10 @@ import java.awt.image.BufferedImage; import static org.bytedeco.opencv.global.opencv_core.CV_8UC3; /** - * 推流(FLV) + JavaCV 解码/转码 + (可选)YOLO 检测叠框 - * - 支持“窗口巡检”:在给定秒数内启用推理与统计,并通过 DetectionListener 回调让上层落库/告警 - * - 播放开始可触发 10 秒试跑:attachDetectionListener(jobId, deviceId, 10, listener) - * - * 依赖:ModelManager / YoloDetector / CompositeDetector / Detection / Overlay / MediaService / CameraDto / ClientType + * 推流(FLV) + JavaCV 解码/转码 + 视频保存 + * - 不再进行实时分析,只保存视频 + * - 巡检任务启动时保存视频到MinIO对象存储 + * - 视频分析改为离线处理 * * @author orange * @since 2025-01-16 @@ -62,20 +57,42 @@ import static org.bytedeco.opencv.global.opencv_core.CV_8UC3; @Slf4j public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable { - /* ===================== 内部回调/统计类型(如已外部定义,可移除) ===================== */ + /** ================ 核心字段 ================ */ + private volatile boolean running; + private volatile boolean grabberStatus; + private CameraDto cameraDto; - public interface DetectionListener { - /** 每次推理得到 detections 时回调(建议上层做节流) */ - void onDetections(Long jobId, Long deviceId, List detections, long frameTsMs); - /** 一个“窗口巡检”结束时回调(含统计数据) */ - void onWindowFinished(Long jobId, Long deviceId, WindowStats stats); - } + // 拉流组件 + private FFmpegFrameGrabber grabber; + // 帧转换器 + private final Java2DFrameConverter toImage = new Java2DFrameConverter(); + private final OpenCVFrameConverter.ToMat toMat = new OpenCVFrameConverter.ToMat(); + // 丢帧计数器 + private long totalFrames = 0, droppedFrames = 0; - /** 启动会话视频录制(本地mp4临时文件,结束后上传MinIO) */ + /** + * 最近的叠框帧(用于截图) + */ + private final AtomicReference latestAnnotatedFrame = new AtomicReference<>(); + private boolean saveVideo = false; // 是否保存视频 + + // MinIO相关服务 + private MinioService minioService; + private IVMinioObjectService vMinioObjectService; + private InspectionTaskMapper taskMapper; + + // 视频录制相关 + private FFmpegFrameRecorder sessionRecorder; + private File sessionVideoTmp; + private Long currentTaskId = null; + private Long currentDeviceId = null; + + /** ================ 视频录制相关配置 ================ */ + /** 开始录制会话视频 */ private void startSessionVideoRecorder() { try { // 以系统临时目录创建mp4 - sessionVideoTmp = File.createTempFile("evidence_", ".mp4"); + sessionVideoTmp = File.createTempFile("inspection_", ".mp4"); int w = (grabber != null) ? grabber.getImageWidth() : 1280; int h = (grabber != null) ? grabber.getImageHeight() : 720; int ac = (grabber != null) ? grabber.getAudioChannels() : 0; @@ -100,364 +117,265 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable } } - /** 结束会话录像并上传到 MinIO,然后更新 AlarmRecord.videoPath */ - private void stopAndUploadSessionVideo(long nowMs) { - File local = sessionVideoTmp; - FFmpegFrameRecorder rec = sessionRecorder; - sessionVideoTmp = null; - sessionRecorder = null; + /** 结束录制并上传会话视频 */ + private void endSessionVideoRecorder() { + if (sessionRecorder == null || sessionVideoTmp == null) return; try { - if (rec != null) { - try { rec.stop(); } catch (Exception ignore) {} - try { rec.release(); } catch (Exception ignore) {} + sessionRecorder.stop(); + sessionRecorder.close(); + log.info("会话录像结束,正在上传: {}", sessionVideoTmp); + + // 上传视频到MinIO并将信息保存到数据库 + if (minioService == null) { + minioService = SpringUtils.getBean(MinioService.class); } - } catch (Exception e) { - log.debug("会话录像停止异常: {}", e.getMessage()); - } - if (local == null || !local.exists()) return; - - try { - // 上传 - String originalName = buildVideoOriginalName(nowMs); - String uniqueObjectName = buildUniqueVideoObjectName(originalName); - MinioService minio = SpringUtils.getBean(MinioService.class); - IVMinioObjectService objSvc = SpringUtils.getBean(IVMinioObjectService.class); - AlarmRecordMapper alarmMapper = SpringUtils.getBean(AlarmRecordMapper.class); - - CustomMultipartFile mf = new CustomMultipartFile(local, "video/mp4"); - MinioService.UploadResult up = minio.uploadWithName(mf, uniqueObjectName); - - // 入库 v_minio_object - VMinioObject recObj = new VMinioObject(); - recObj.setObjectName(up.getObjectName()); - recObj.setUrl(up.getUrl()); - recObj.setOriginalName(originalName); - objSvc.insert(recObj); - - // 回填 AlarmRecord 的 videoPath - if (currentAlarmId != null) { - AlarmRecord patch = new AlarmRecord(); - patch.setAlarmId(currentAlarmId); - patch.setVideoPath(up.getUrl()); - alarmMapper.updateAlarmRecord(patch); - log.info("会话视频上传并回填告警: alarmId={}, url={}", currentAlarmId, up.getUrl()); + if (vMinioObjectService == null) { + vMinioObjectService = SpringUtils.getBean(IVMinioObjectService.class); } - } catch (Exception e) { - log.warn("上传/回填会话视频失败: {}", e.getMessage()); - } finally { - try { local.delete(); } catch (Exception ignore) {} - currentAlarmId = null; - } - } - - private String buildVideoOriginalName(long nowMs) { - return "evidence_" + nowMs + ".mp4"; - } - - private String buildUniqueVideoObjectName(String original) { - String ext = ".mp4"; - int i = original.lastIndexOf('.'); - if (i > 0) ext = original.substring(i); - return UUID.randomUUID().toString().replace("-", "") + ext; - } - - /** 外部可调整置信度阈值 */ - public void setDetectionThreshold(float threshold) { - this.detectionThreshold = threshold; - } - - /** - * 保存一张叠好框的截图到本地 → 上传到 MinIO → v_minio_object 入库 → 写入一条报警记录。 - */ - private void saveSnapshotAndCreateAlarm(List detections, long nowMs) { - // 拿到叠好框的帧 - Mat annotated = getLatestAnnotatedFrameCopy(); - if (annotated == null || annotated.empty()) { - log.debug("没有可用的叠框帧,跳过截图保存"); - return; - } - try { - // 置信度阈值过滤(取最高分作为本次代表) - Detection best = detections.stream() - .max((a,b) -> Float.compare(a.conf(), b.conf())) - .orElse(null); - if (best == null || best.conf() < detectionThreshold) { - log.debug("最高置信度 {} 低于阈值 {},跳过落库与上传", best == null ? 0f : best.conf(), detectionThreshold); - return; + if (taskMapper == null) { + taskMapper = SpringUtils.getBean(InspectionTaskMapper.class); } - // 转为 BufferedImage - Java2DFrameConverter conv = new Java2DFrameConverter(); - OpenCVFrameConverter.ToMat toMat = new OpenCVFrameConverter.ToMat(); - Frame f = toMat.convert(annotated); - BufferedImage img = conv.convert(f); - if (img == null) { - log.debug("帧转图片失败"); - return; - } - // 写到临时文件 - String originalName = buildSnapshotOriginalName(nowMs); - File tmp = File.createTempFile("snapshot_", ".jpg"); - ImageIO.write(img, "jpg", tmp); - - // 上传到 MinIO(唯一名) - String uniqueObjectName = buildUniqueObjectName(originalName); - MinioService minio = SpringUtils.getBean(MinioService.class); - IVMinioObjectService objSvc = SpringUtils.getBean(IVMinioObjectService.class); - AlarmRecordMapper alarmMapper = SpringUtils.getBean(AlarmRecordMapper.class); - - CustomMultipartFile mf = new CustomMultipartFile(tmp, "image/jpeg"); - MinioService.UploadResult up = minio.uploadWithName(mf, uniqueObjectName); - - // v_minio_object 入库 - VMinioObject rec = new VMinioObject(); - rec.setObjectName(up.getObjectName()); - rec.setUrl(up.getUrl()); - rec.setOriginalName(originalName); - objSvc.insert(rec); - - // 写报警记录(仅图片) - AlarmRecord alarm = new AlarmRecord(); - alarm.setAlarmType(best.cls()); - alarm.setAlarmLevel(getLevel(best.conf())); - alarm.setAlarmDesc(String.format("检测到%s,置信度: %.2f", - best.cls(), best.conf())); - alarm.setConfidence((double)best.conf()); - alarm.setImagePath(up.getUrl()); - alarm.setAlarmTime(new Date(nowMs)); - alarm.setHandleStatus("0"); - // 绑定任务/设备:优先窗口上下文,其次 cameraDto 备注/URL - if (currentJobId != null) alarm.setTaskId(currentJobId); - if (currentDeviceId != null) alarm.setDeviceId(currentDeviceId); - if (cameraDto != null) { - String dn = cameraDto.getRemark(); - if (dn == null || dn.isEmpty()) dn = cameraDto.getUrl(); - alarm.setDeviceName(dn); - } - - alarmMapper.insertAlarmRecord(alarm); - // 保存本次会话对应的告警ID,用于视频回填 - currentAlarmId = alarm.getAlarmId(); - - // 清理临时文件 - try { tmp.delete(); } catch (Exception ignore) {} - } catch (Exception e) { - log.warn("保存/上传截图失败: {}", e.getMessage()); - } finally { - try { annotated.release(); } catch (Exception ignore) {} - } - } - - private String buildSnapshotOriginalName(long nowMs) { - String ts = String.valueOf(nowMs); - return "snapshot_" + ts + ".jpg"; - } - - private String buildUniqueObjectName(String original) { - String ext = ".jpg"; - int i = original.lastIndexOf('.'); - if (i > 0) ext = original.substring(i); - return UUID.randomUUID().toString().replace("-", "") + ext; - } - - private String getLevel(float conf) { - if (conf >= 0.9f) return "3"; - if (conf >= 0.7f) return "2"; - return "1"; - } - - @Data - public static class WindowStats { - private int frames; - private int detectedFrames; - private int objects; - private double maxScore; - private long startMs; - private long endMs; - } - - /* ===================== FFmpeg/JavaCV 初始化 ===================== */ - - static { - avutil.av_log_set_level(avutil.AV_LOG_ERROR); - FFmpegLogCallback.set(); - } - - /* ===================== 原有字段 ===================== */ - - private final ConcurrentHashMap wsClients = new ConcurrentHashMap<>(); - private final ConcurrentHashMap httpClients = new ConcurrentHashMap<>(); - - private volatile boolean running = false; - private boolean grabberStatus = false; - private boolean recorderStatus = false; - private int hcSize, wcSize = 0; - private int noClient = 0; - - private byte[] header = null; - private final ByteArrayOutputStream bos = new ByteArrayOutputStream(); - - private FFmpegFrameGrabber grabber; // 拉流器 - private FFmpegFrameRecorder recorder; // 推流录制器 - - /** true: 转复用;false: 转码。启用检测时强制转码(要在像素上叠框) */ - private boolean transferFlag = false; - - private final CameraDto cameraDto; - private Thread listenThread; - - /* ===================== 推理相关字段 ===================== */ - - // 外部开关:是否启用检测(默认启用;也可由任务/页面配置动态设置) - private boolean enableDetection = true; - - private ModelManager modelManager; - private YoloDetector detector; - - // 解码/推理/发送解耦 - private final OpenCVFrameConverter.ToMat toMat = new OpenCVFrameConverter.ToMat(); - private final OpenCVFrameConverter.ToMat matToFrameConverter = new OpenCVFrameConverter.ToMat(); - private final AtomicReference latestFrame = new AtomicReference<>(); - private final AtomicReference> latestDetections = - new AtomicReference<>(java.util.Collections.emptyList()); - - // 窗口巡检控制 - private volatile boolean windowMode = false; - private volatile long windowEndMs = 0L; - private Long currentJobId; - private Long currentDeviceId; - private DetectionListener detectionListener; - private final WindowStats stats = new WindowStats(); - - // 导出最近一次“叠好框的帧”用于截图存证 - private final AtomicReference latestAnnotatedFrame = new AtomicReference<>(); - - // =============== 轻量级“截图留存”会话聚合(不录视频) =============== - private boolean snapshotSessionActive = false; - private long sessionStartMs = 0L; - private long lastSeenMs = 0L; - // 连续存在的最小空窗阈值(超过视为结束),默认3秒 - private long minGapMs = 3000L; - // 单次会话最长保存时长,默认30秒 - private long maxDurationMs = 30000L; - // 置信度阈值(低于该值不落库不截图上传) - private float detectionThreshold = 0.7f; - // 会话视频录制器与上下文 - private FFmpegFrameRecorder sessionRecorder; - private File sessionVideoTmp; - private Long currentAlarmId; // 用于会话结束后回填 videoPath - - public MediaTransferFlvByJavacv(CameraDto cameraDto) { - super(); - this.cameraDto = cameraDto; - } - - public void setRunning(boolean running) { - boolean prev = this.running; - this.running = running; - // 如果是从 true -> false,则按“关闭”处理 - if (prev && !running) { - try { - closeMedia(); // 内部会 stopWindowIfAny()、关闭连接等 - } catch (Exception ignore) {} - } - } - - /** 推荐的新接口:显式停止并释放资源 */ - public void stop() { - setRunning(false); - } - - /* ===================== 外部控制 API ===================== */ - - public boolean isRunning() { return running; } - public boolean isGrabberStatus() { return grabberStatus; } - public boolean isRecorderStatus() { return recorderStatus; } - public void setEnableDetection(boolean enable) { this.enableDetection = enable; } - - /** - * 开启一个“窗口巡检”,持续 windowSeconds 秒;期间每次推理回调 onDetections,结束时 onWindowFinished - */ - public void attachDetectionListener(Long jobId, Long deviceId, int windowSeconds, DetectionListener listener) { - if (windowSeconds <= 0 || listener == null) return; - this.currentJobId = jobId; - this.currentDeviceId = deviceId; - this.detectionListener = listener; - this.windowMode = true; - long now = System.currentTimeMillis(); - this.stats.setStartMs(now); - this.windowEndMs = now + windowSeconds * 1000L; - this.stats.setFrames(0); - this.stats.setDetectedFrames(0); - this.stats.setObjects(0); - this.stats.setMaxScore(0.0); - log.info("[job:{} device:{}] window started {}s", jobId, deviceId, windowSeconds); - } - - /** 主动结束当前窗口(可用于任务被中断的场景) */ - public void stopWindowIfAny() { - if (!windowMode) return; - this.windowMode = false; - stats.setEndMs(System.currentTimeMillis()); - if (detectionListener != null && currentJobId != null && currentDeviceId != null) { - try { - detectionListener.onWindowFinished(currentJobId, currentDeviceId, cloneStats(stats)); - } catch (Exception ignore) {} - } - currentJobId = null; - currentDeviceId = null; - detectionListener = null; - log.info("window finished (stopWindowIfAny)"); - } - - /** 导出最近一次“叠好框的帧”(深拷贝),用于截图/存证。调用方负责释放 Mat */ - public Mat getLatestAnnotatedFrameCopy() { - Mat src = latestAnnotatedFrame.get(); - if (src == null || src.empty()) return null; - Mat copy = new Mat(src.rows(), src.cols(), src.type()); - src.copyTo(copy); - return copy; - } - - /* ===================== 初始化推理 ===================== */ - - private void initDetectors() throws Exception { - if (!enableDetection) return; - - modelManager = new ModelManager(); - URL json = getClass().getResource("/libs/models/models.json"); - modelManager.load(json); - // 你可按需切换单模型或多模型并行 - // detector = modelManager.get("person-helmet"); - detector = new CompositeDetector( - "all-models", - java.util.List.of( - modelManager.get("garbage") - ), - 1 // 并行度 - ); - log.info("YOLO detectors ready: {}", detector.name()); - - // 预热一次,避免前几帧“无框” - try { - Frame warm = grabber != null ? grabber.grabImage() : null; - if (warm != null) { - Mat wm = toMat.convert(warm); - if (wm != null && !wm.empty()) { - long t0 = System.currentTimeMillis(); - List dets = detector.detect(wm); - long cost = System.currentTimeMillis() - t0; - latestDetections.set(dets); - log.info("Detector warm-up OK, cost={}ms, dets={}", cost, - CollectionUtils.isEmpty(dets) ? 0 : dets.size()); + + // 上传视频文件到MinIO + String fileName = "inspection_" + System.currentTimeMillis() + ".mp4"; + String bucketName = "inspection-videos"; + CustomMultipartFile multipartFile = new CustomMultipartFile(sessionVideoTmp, fileName, "video/mp4"); + String objectUrl = minioService.putObject(bucketName, fileName, multipartFile.getInputStream()); + + // 保存对象信息到数据库 + VMinioObject minioObject = new VMinioObject(); + minioObject.setBucketName(bucketName); + minioObject.setObjectName(fileName); + minioObject.setObjectUrl(objectUrl); + minioObject.setCreateBy("system"); + minioObject.setCreateTime(new Date()); + Long minioId = vMinioObjectService.insertVMinioObject(minioObject); + + // 更新巡检任务的原始视频ID + if (currentTaskId != null) { + InspectionTask task = new InspectionTask(); + task.setId(currentTaskId); + task.setVideoOssId(minioId); + task.setVideoStatus(1); // 1: 已上传原始视频 + taskMapper.updateInspectionTask(task); + + log.info("巡检任务视频保存成功: 任务ID={}, MinIO对象ID={}", currentTaskId, minioId); + + // 触发异步视频分析任务 + try { + // 这里可以通过Spring事件、消息队列或直接调用服务来触发 + log.info("触发异步视频分析任务: 任务ID={}", currentTaskId); + // 例如: eventPublisher.publishEvent(new VideoAnalysisEvent(currentTaskId, minioId)); + } catch (Exception e) { + log.error("触发异步分析任务失败: {}", e.getMessage()); } } - } catch (Throwable e) { - log.warn("Detector warm-up failed: {}", e.getMessage()); + + } catch (Exception e) { + log.error("视频上传失败: {}", e.getMessage()); + } finally { + sessionRecorder = null; + if (sessionVideoTmp != null && sessionVideoTmp.exists()) { + if (sessionVideoTmp.delete()) { + log.debug("临时视频文件已删除: {}", sessionVideoTmp); + } + } + sessionVideoTmp = null; + currentTaskId = null; + currentDeviceId = null; } } - /* ===================== 拉流/推流 ===================== */ + /** ================ 巡检任务相关方法 ================ */ + /** + * 开始视频巡检录制 + * @param taskId 巡检任务ID + * @param deviceId 设备ID + * @param duration 巡检时长(秒) + */ + public void startInspection(Long taskId, Long deviceId, Integer duration) { + log.info("开始视频巡检录制: 任务ID={}, 设备ID={}, 时长={}秒", taskId, deviceId, duration); + currentTaskId = taskId; + currentDeviceId = deviceId; + + // 启动视频录制 + startSessionVideoRecorder(); + + // 设置定时器,到时自动结束录制 + if (duration != null && duration > 0) { + new Thread(() -> { + try { + Thread.sleep(duration * 1000L); + } catch (InterruptedException ignored) {} + + if (currentTaskId != null && currentTaskId.equals(taskId)) { + log.info("巡检时间到达,结束录制: 任务ID={}", taskId); + endSessionVideoRecorder(); + } + }).start(); + } + } + /** + * 手动结束视频巡检 + * @param taskId 巡检任务ID + */ + public void stopInspection(Long taskId) { + if (currentTaskId != null && currentTaskId.equals(taskId)) { + log.info("手动结束视频巡检: 任务ID={}", taskId); + endSessionVideoRecorder(); + } else { + log.warn("找不到指定的巡检任务或已结束: {}", taskId); + } + } + + /** ================ 构造函数与初始化 ================ */ + public MediaTransferFlvByJavacv(CameraDto camera) { + cameraDto = camera; + } + + public void setRun(boolean run) { + running = run; + } + + /** + * 设置运行状态 + * @param run 是否运行 + */ + public void setRunning(boolean run) { + running = run; + } + + /** ================ 主处理线程 ================ */ + @Override + public void run() { + int failCount = 0; + do { + if (!running) break; + + try { + if (createGrabber()) { + // 连接成功,重置失败计数 + failCount = 0; + runImpl(); + } else { + throw new Exception("启动拉流器失败"); + } + } catch (Exception e) { + failCount++; + log.error("媒体传输异常: {} ({})", cameraDto.getUrl(), e.getMessage()); + LockSupport.parkNanos(1000_000_000L); // 1秒 + + // 5次重连失败,彻底放弃 + if (failCount > 5) { + log.error("媒体传输失败次数过多,停止重试: {}", cameraDto.getUrl()); + MediaService.cameras.remove(cameraDto.getMediaKey()); + break; + } + } finally { + try { + if (grabber != null) { + grabber.stop(); + grabber.close(); + grabber = null; + grabberStatus = false; + } + } catch (Exception ignore) {} + } + } while (running); + + log.info("媒体传输线程已退出: {}", cameraDto.getUrl()); + } + + /** 主处理循环 */ + private void runImpl() throws Exception { + Frame frame; + long tsPerFrame = 1000 / (grabber.getFrameRate() > 0 ? (long) grabber.getFrameRate() : 25); + totalFrames = droppedFrames = 0; + long t0 = System.currentTimeMillis(); + + while (running && (frame = grabber.grab()) != null) { + totalFrames++; + long now = System.currentTimeMillis(); + + // 检查是否需要录制视频 + if (sessionRecorder != null) { + try { + sessionRecorder.record(frame); + } catch (Exception e) { + log.warn("视频录制失败: {}", e.getMessage()); + } + } + + // 处理视频帧推流 + if (frame.image != null) { + // 只处理视频帧 + if (processVideoFrame(frame)) { + // 帧处理成功,更新时间戳 + t0 = now; + } + } + + // 避免CPU占用过高 + LockSupport.parkNanos(1_000_000L); // 1毫秒 + } + } + + /** 处理视频帧 */ + private boolean processVideoFrame(Frame frame) { + // 计算帧率 + if (totalFrames % 100 == 0 && totalFrames > 0) { + if (droppedFrames > 0) { + log.debug("帧率统计: 总帧数={}, 已丢帧={}, 丢帧率={:.2f}%", + totalFrames, droppedFrames, (droppedFrames * 100.0 / totalFrames)); + droppedFrames = totalFrames = 0; + } + } + + // 处理视频推流给各个连接 + pushFrameToClients(frame); + + return true; + } + + /** 推送帧到所有客户端 */ + private void pushFrameToClients(Frame frame) { + String mediaKey = cameraDto.getMediaKey(); + Map contexts = MediaService.clientConnections.get(mediaKey); + if (contexts == null || contexts.isEmpty()) return; + + try { + // 转换为JPEG字节 + BufferedImage bufferedImage = toImage.convert(frame); + if (bufferedImage == null) return; + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(bufferedImage, "jpg", outputStream); + byte[] imageBytes = outputStream.toByteArray(); + + // 推送给所有客户端 + contexts.forEach((clientId, ctx) -> { + try { + ClientType type = MediaService.clients.get(clientId); + if (type == ClientType.WEBSOCKET) { + BinaryWebSocketFrame binaryWebSocketFrame = new BinaryWebSocketFrame( + Unpooled.wrappedBuffer(imageBytes)); + ctx.writeAndFlush(binaryWebSocketFrame); + } else { + // 其他客户端类型的处理... + } + } catch (Exception e) { + log.warn("推流失败 ({}): {}", clientId, e.getMessage()); + MediaService.clients.remove(clientId); + contexts.remove(clientId); + } + }); + } catch (Exception e) { + log.warn("帧转换失败: {}", e.getMessage()); + } + } + + /** ================ 拉流配置 ================ */ protected boolean createGrabber() { grabber = new FFmpegFrameGrabber(cameraDto.getUrl()); @@ -497,509 +415,56 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable } catch (FrameGrabber.Exception e) { MediaService.cameras.remove(cameraDto.getMediaKey()); log.error("启动拉流器失败: {} ({})", cameraDto.getUrl(), e.getMessage()); - return (grabberStatus = false); + return false; } } - protected boolean createTransterOrRecodeRecorder() { - // 启用检测时必须转码(需要像素级叠框) - if (enableDetection) transferFlag = false; - - recorder = new FFmpegFrameRecorder(bos, grabber.getImageWidth(), grabber.getImageHeight(), - grabber.getAudioChannels()); - recorder.setFormat("flv"); - - if (!transferFlag) { - // 转码:低延迟 H.264 - recorder.setInterleaved(false); - recorder.setVideoOption("tune", "zerolatency"); - recorder.setVideoOption("preset", "ultrafast"); - recorder.setVideoOption("crf", "26"); - recorder.setVideoOption("threads", "1"); - recorder.setFrameRate(25); - recorder.setGopSize(25); - recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); - recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P); - recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); - recorder.setTrellis(1); - recorder.setMaxDelay(0); - try { - recorder.start(); - return recorderStatus = true; - } catch (FrameRecorder.Exception e1) { - log.info("启动转码录制器失败", e1); - MediaService.cameras.remove(cameraDto.getMediaKey()); - } - } else { - // 转复用(仅不叠框时) - recorder.setCloseOutputStream(false); - try { - recorder.start(grabber.getFormatContext()); - return recorderStatus = true; - } catch (FrameRecorder.Exception e) { - log.warn("{} 启动转复用失败,自动切换转码", cameraDto.getUrl()); - transferFlag = false; - try { recorder.stop(); } catch (FrameRecorder.Exception ignored) {} - if (createTransterOrRecodeRecorder()) { - log.error("{} 切换到转码模式", cameraDto.getUrl()); - return true; - } - log.error("{} 切换转码模式失败", cameraDto.getUrl(), e); - } - } - return recorderStatus = false; + // 添加获取摄像头DTO的方法 + public CameraDto getCameraDto() { + return cameraDto; } - private boolean supportFlvFormatCodec() { - int vcodec = grabber.getVideoCodec(); - int acodec = grabber.getAudioCodec(); - return (cameraDto.getType() == 0) - && ("desktop".equals(cameraDto.getUrl()) || avcodec.AV_CODEC_ID_H264 == vcodec - || avcodec.AV_CODEC_ID_H263 == vcodec) - && (avcodec.AV_CODEC_ID_AAC == acodec || avcodec.AV_CODEC_ID_AAC_LATM == acodec); + // 添加检查运行状态的方法 + public boolean isRunning() { + return running; } - /* ===================== 主流程 ===================== */ + // 添加检查grabber状态的方法 + public boolean isGrabberStatus() { + return grabberStatus; + } - protected void transferStream2Flv() { - try { - if (enableDetection) initDetectors(); - } catch (Exception e) { - log.error("初始化检测模型失败:{}", e.getMessage(), e); - // 模型失败不影响推流,但不禁用检测 - } + // 添加检查recorder状态的方法 (假设默认为true) + public boolean isRecorderStatus() { + return true; + } - if (!createGrabber()) return; - if (!enableDetection) transferFlag = supportFlvFormatCodec(); - if (!createTransterOrRecodeRecorder()) return; - - try { grabber.flush(); } catch (FrameGrabber.Exception e) { log.debug("flush grabber fail", e); } - if (header == null) { - header = bos.toByteArray(); - bos.reset(); - } - - running = true; - listenClient(); - - long startTime = 0; - long videoTS; + // 添加客户端管理方法 + public void addClient(ChannelHandlerContext ctx, ClientType clientType) { + // 实现客户端添加逻辑 + String clientId = ctx.channel().id().asLongText(); - // 检测频率控制变量 - final long DETECTION_INTERVAL_MS = 3000; // 每3秒检测一次 - long lastDetectionTime = 0; - List currentDetections = Collections.emptyList(); // 当前显示的检测结果 - - for (; running && grabberStatus && recorderStatus; ) { - try { - if (transferFlag) { - // 仅转复用(未叠框) - long startGrab = System.currentTimeMillis(); - AVPacket pkt = grabber.grabPacket(); - if ((System.currentTimeMillis() - startGrab) > 5000) { - log.info("{} 网络异常(复用)", cameraDto.getUrl()); - closeMedia(); - break; - } - if (pkt != null && !pkt.isNull()) { - if (startTime == 0) startTime = System.currentTimeMillis(); - videoTS = 1000 * (System.currentTimeMillis() - startTime); - if (videoTS > recorder.getTimestamp()) recorder.setTimestamp(videoTS); - recorder.recordPacket(pkt); - } - } else { - // 转码(可叠框) - long startGrab = System.currentTimeMillis(); - Frame frame = grabber.grab(); - - if ((System.currentTimeMillis() - startGrab) > 5000) { - log.info("{} 网络异常(转码)", cameraDto.getUrl()); - closeMedia(); - break; - } - - if (frame != null && enableDetection) { - // 将Frame转换为Mat以进行处理 - Mat mat = toMat.convert(frame); - - if (mat != null && !mat.empty()) { - long currentTime = System.currentTimeMillis(); - - // 每隔DETECTION_INTERVAL_MS执行一次检测 - if (currentTime - lastDetectionTime >= DETECTION_INTERVAL_MS) { - try { - log.debug("执行新一轮检测,上次检测时间: {}ms前", - currentTime - lastDetectionTime); - - // 创建副本进行检测 - Mat detectionMat = new Mat(); - mat.copyTo(detectionMat); - - // 执行检测 - currentDetections = detector.detect(detectionMat); - lastDetectionTime = currentTime; - latestDetections.set(currentDetections); - - // 释放检测Mat - detectionMat.release(); - - // 窗口巡检回调 - if (windowMode && detectionListener != null && - currentJobId != null && currentDeviceId != null) { - detectionListener.onDetections(currentJobId, - currentDeviceId, - currentDetections, - currentTime); - } - - log.debug("检测完成,发现 {} 个目标,框将保持3秒", - currentDetections == null ? 0 : currentDetections.size()); - } catch (Exception e) { - log.debug("检测异常: {}", e.getMessage()); - } - } - - // 每一帧都使用最新的检测结果绘制框 - // 这样框会保持在原位置,直到下一次检测更新 - if (currentDetections != null && !currentDetections.isEmpty()) { - try { - // 在当前帧上绘制检测框 - Overlay.draw(currentDetections, mat); - } catch (Exception e) { - log.debug("绘制检测框异常: {}", e.getMessage()); - } - } - - // 更新"最近叠好框的帧"用于存证 - updateLatestAnnotated(mat); - - // ============ 轻量版会话聚合:仅保存一张截图 ============ - try { - long now = System.currentTimeMillis(); - boolean hasTarget = currentDetections != null && !currentDetections.isEmpty(); - if (hasTarget) { - lastSeenMs = now; - if (!snapshotSessionActive) { - // 会话开始:截一张叠好框的图并落库+上传 - snapshotSessionActive = true; - sessionStartMs = now; - saveSnapshotAndCreateAlarm(currentDetections, now); - // 启动会话视频录制 - startSessionVideoRecorder(); - } - } - // 结束条件:目标消失超过阈值,或会话超过最大时长 - if (snapshotSessionActive) { - boolean gapTimeout = (now - lastSeenMs) >= minGapMs; - boolean maxed = (now - sessionStartMs) >= maxDurationMs; - if (gapTimeout || maxed) { - // 停止并上传会话视频,回填到本次告警 - stopAndUploadSessionVideo(now); - snapshotSessionActive = false; - sessionStartMs = 0L; - lastSeenMs = 0L; - } - } - } catch (Exception ex) { - log.warn("轻量截图会话处理异常: {}", ex.getMessage()); - } - - // 统计(仅窗口巡检时) - if (windowMode) updateStats(currentDetections); - - // 窗口结束判定 - if (windowMode && System.currentTimeMillis() >= windowEndMs) { - finishWindow(); - } - - // 将处理后的Mat转换回Frame - try { - // 创建新的转换器 - OpenCVFrameConverter.ToMat converter = new OpenCVFrameConverter.ToMat(); - Frame processedFrame = converter.convert(mat); - - if (processedFrame != null) { - // 使用处理后的帧替换原始帧 - frame = processedFrame; - } - } catch (Exception e) { - log.debug("Mat转Frame异常: {}", e.getMessage()); - // 如果转换失败,继续使用原始帧 - } - - // 释放Mat - mat.release(); - } - } - - // 记录帧 - if (frame != null) { long now = System.currentTimeMillis(); - - if (startTime == 0) startTime = now; - videoTS = 1000 * (now - startTime); - if (videoTS > recorder.getTimestamp()) recorder.setTimestamp(videoTS); - recorder.record(frame); - // 会话录制:同步追加处理后的帧 - if (snapshotSessionActive && sessionRecorder != null) { - try { - sessionRecorder.record(frame); - } catch (Exception recErr) { - log.debug("会话录像写入失败: {}", recErr.getMessage()); - } - } - } - } - } catch (FrameGrabber.Exception e) { - log.error("拉流异常: {}", e.getMessage()); - grabberStatus = false; - MediaService.cameras.remove(cameraDto.getMediaKey()); - } catch (FrameRecorder.Exception e) { - log.error("推流异常: {}", e.getMessage()); - recorderStatus = false; - MediaService.cameras.remove(cameraDto.getMediaKey()); - } catch (Exception e) { - log.error("其他异常: {}", e.getMessage()); - // 不要立即退出,尝试继续处理 - try { - Thread.sleep(100); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - } - } - - // 输出缓存到客户端 - try { - if (bos.size() > 0) { - byte[] b = bos.toByteArray(); - bos.reset(); - sendFrameData(b); - } - } catch (Exception e) { - log.error("发送数据异常: {}", e.getMessage()); - } - } - - // 安全地关闭资源 - safeCloseResources(); - } - - // 将资源关闭逻辑提取到单独的方法 - private void safeCloseResources() { - try { - if (detector != null) { - try { detector.close(); } catch (Exception ignored) {} - } - - if (modelManager != null) { - try { modelManager.close(); } catch (Exception ignored) {} - } - - if (recorder != null) { - try { recorder.close(); } catch (Exception ignored) {} - } - if (sessionRecorder != null) { - try { sessionRecorder.close(); } catch (Exception ignored) {} - sessionRecorder = null; - } - if (sessionVideoTmp != null) { - try { sessionVideoTmp.delete(); } catch (Exception ignore) {} - sessionVideoTmp = null; - } - - if (grabber != null) { - try { grabber.close(); } catch (Exception ignored) {} - } - - try { bos.close(); } catch (Exception ignored) {} - - Mat m = latestFrame.getAndSet(null); - if (m != null) { - try { m.release(); } catch (Exception ignored) {} - } - - Mat a = latestAnnotatedFrame.getAndSet(null); - if (a != null) { - try { a.release(); } catch (Exception ignored) {} - } - - closeMedia(); - } catch (Exception e) { - log.error("关闭资源异常: {}", e.getMessage()); - } + // 添加到全局客户端映射 + MediaService.clients.put(clientId, clientType); - log.info("关闭媒体流-javacv: {}", cameraDto.getUrl()); - } - - /* ===================== 统计 / 窗口结束 ===================== */ - - private void updateStats(List dets) { - stats.setFrames(stats.getFrames() + 1); - if (dets != null && !dets.isEmpty()) { - stats.setDetectedFrames(stats.getDetectedFrames() + 1); - stats.setObjects(stats.getObjects() + dets.size()); - double localMax = dets.stream().mapToDouble(Detection::conf).max().orElse(0.0); - if (localMax > stats.getMaxScore()) stats.setMaxScore(localMax); + // 添加到当前摄像头的上下文集合 + String mediaKey = cameraDto.getMediaKey(); + Map contexts = MediaService.clientConnections.get(mediaKey); + if (contexts == null) { + contexts = new ConcurrentHashMap<>(); + MediaService.clientConnections.put(mediaKey, contexts); } + contexts.put(clientId, ctx); + + log.info("客户端已添加: {}, 类型: {}", clientId, clientType); } - private void finishWindow() { - windowMode = false; - stats.setEndMs(System.currentTimeMillis()); - if (detectionListener != null && currentJobId != null && currentDeviceId != null) { - try { detectionListener.onWindowFinished(currentJobId, currentDeviceId, cloneStats(stats)); } - catch (Exception ignore) {} - } - currentJobId = null; - currentDeviceId = null; - detectionListener = null; - log.info("window finished (timeout)"); - } - - private static WindowStats cloneStats(WindowStats s) { - WindowStats c = new WindowStats(); - c.setFrames(s.getFrames()); - c.setDetectedFrames(s.getDetectedFrames()); - c.setObjects(s.getObjects()); - c.setMaxScore(s.getMaxScore()); - c.setStartMs(s.getStartMs()); - c.setEndMs(s.getEndMs()); - return c; - } - - private void updateLatestAnnotated(Mat src) { - if (src == null || src.empty()) return; + /** 导出最近一次"叠好框的帧"(深拷贝),用于截图/存证。调用方负责释放 Mat */ + public Mat getLatestAnnotatedFrameCopy() { + Mat src = latestAnnotatedFrame.get(); + if (src == null || src.empty()) return null; Mat copy = new Mat(src.rows(), src.cols(), src.type()); src.copyTo(copy); - Mat old = latestAnnotatedFrame.getAndSet(copy); - if (old != null) old.release(); - } - - /* ===================== 网络发送/连接管理 ===================== */ - - private void sendFrameData(byte[] data) { - // ws - for (Map.Entry entry : wsClients.entrySet()) { - try { - if (entry.getValue().channel().isWritable()) { - entry.getValue().writeAndFlush(new BinaryWebSocketFrame(Unpooled.copiedBuffer(data))); - } else { - wsClients.remove(entry.getKey()); - hasClient(); - } - } catch (Exception e) { - wsClients.remove(entry.getKey()); - hasClient(); - log.debug("ws send err", e); - } - } - // http - for (Map.Entry entry : httpClients.entrySet()) { - try { - if (entry.getValue().channel().isWritable()) { - entry.getValue().writeAndFlush(Unpooled.copiedBuffer(data)); - } else { - httpClients.remove(entry.getKey()); - hasClient(); - } - } catch (Exception e) { - httpClients.remove(entry.getKey()); - hasClient(); - log.debug("http send err", e); - } - } - } - - public void hasClient() { - int newHcSize = httpClients.size(); - int newWcSize = wsClients.size(); - if (hcSize != newHcSize || wcSize != newWcSize) { - hcSize = newHcSize; - wcSize = newWcSize; - log.info("{} http连接数:{}, ws连接数:{}", cameraDto.getUrl(), newHcSize, newWcSize); - } - if (!cameraDto.isAutoClose()) return; - - if (httpClients.isEmpty() && wsClients.isEmpty()) { - if (noClient > cameraDto.getNoClientsDuration()) { - closeMedia(); - } else { - noClient += 1000; - } - } else { - noClient = 0; - } - } - - public void listenClient() { - listenThread = new Thread(() -> { - while (running) { - hasClient(); - try { Thread.sleep(1000); } catch (InterruptedException ignored) {} - } - }); - listenThread.start(); - } - - private void closeMedia() { - // 结束窗口(如果还在) - stopWindowIfAny(); - - running = false; - MediaService.cameras.remove(cameraDto.getMediaKey()); - for (Map.Entry entry : wsClients.entrySet()) { - try { entry.getValue().close(); } catch (Exception ignored) {} finally { wsClients.remove(entry.getKey()); } - } - for (Map.Entry entry : httpClients.entrySet()) { - try { entry.getValue().close(); } catch (Exception ignored) {} finally { httpClients.remove(entry.getKey()); } - } - } - - public void addClient(ChannelHandlerContext ctx, ClientType ctype) { - int timeout = 0; - while (true) { - try { - if (header != null) { - try { - if (ctx.channel().isWritable()) { - if (ClientType.HTTP.getType() == ctype.getType()) { - ChannelFuture future = ctx.writeAndFlush(Unpooled.copiedBuffer(header)); - future.addListener(new GenericFutureListener>() { - @Override - public void operationComplete(Future future) { - if (future.isSuccess()) { - httpClients.put(ctx.channel().id().toString(), ctx); - } - } - }); - } else if (ClientType.WEBSOCKET.getType() == ctype.getType()) { - ChannelFuture future = ctx.writeAndFlush( - new BinaryWebSocketFrame(Unpooled.copiedBuffer(header))); - future.addListener(new GenericFutureListener>() { - @Override - public void operationComplete(Future future) { - if (future.isSuccess()) { - wsClients.put(ctx.channel().id().toString(), ctx); - } - } - }); - } - } - } catch (Exception e) { - log.debug("send header err", e); - } - break; - } - Thread.sleep(50); - timeout += 50; - if (timeout > 30000) break; - } catch (Exception e) { - log.debug("addClient err", e); - } - } - } - - @Override - public void run() { - transferStream2Flv(); + return copy; } } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/thread/detector/HttpYoloDetector.java b/ruoyi-video/src/main/java/com/ruoyi/video/thread/detector/HttpYoloDetector.java new file mode 100644 index 0000000..75723b3 --- /dev/null +++ b/ruoyi-video/src/main/java/com/ruoyi/video/thread/detector/HttpYoloDetector.java @@ -0,0 +1,175 @@ +package com.ruoyi.video.thread.detector; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.video.domain.Detection; +import org.bytedeco.opencv.opencv_core.*; +import org.bytedeco.javacpp.BytePointer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.*; + +import static org.bytedeco.opencv.global.opencv_imgcodecs.imencode; + +/** + * 通过HTTP调用YOLOv8 Python服务的检测器 + * 替换原有的本地模型加载方式 + */ +public class HttpYoloDetector implements YoloDetector { + private static final Logger log = LoggerFactory.getLogger(HttpYoloDetector.class); + + private final String name; + private final String apiUrl; + private final String modelName; + private final int colorBGR; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + /** + * 创建HTTP检测器 + * @param name 检测器名称 + * @param apiUrl Python服务的API URL (例如: "http://localhost:8000/api/detect/file") + * @param modelName 要使用的模型名称 + * @param colorBGR 边界框颜色 (BGR格式) + */ + public HttpYoloDetector(String name, String apiUrl, String modelName, int colorBGR) { + this.name = name; + this.apiUrl = apiUrl; + this.modelName = modelName; + this.colorBGR = colorBGR; + this.restTemplate = new RestTemplate(); + this.objectMapper = new ObjectMapper(); + + log.info("创建HTTP YOLOv8检测器: {}, 服务地址: {}, 模型: {}", name, apiUrl, modelName); + } + + @Override + public String name() { + return name; + } + + @Override + public List detect(Mat bgr) { + if (bgr == null || bgr.empty()) { + return Collections.emptyList(); + } + + try { + // 将OpenCV的Mat转换为JPEG字节数组 + BytePointer buffer = new BytePointer(); + imencode(".jpg", bgr, buffer); + byte[] jpgBytes = new byte[(int)(buffer.capacity())]; + buffer.get(jpgBytes); + buffer.deallocate(); + + // 准备HTTP请求参数 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("model_name", modelName); + body.add("file", new CustomByteArrayResource(jpgBytes, "image.jpg")); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + // 发送请求到Python服务 + ResponseEntity response = restTemplate.postForEntity(apiUrl, requestEntity, String.class); + String responseBody = response.getBody(); + + if (responseBody != null) { + // 解析响应JSON + Map result = objectMapper.readValue(responseBody, Map.class); + List> detectionsJson = (List>) result.get("detections"); + + List detections = new ArrayList<>(); + for (Map det : detectionsJson) { + String label = (String) det.get("label"); + double confidence = ((Number) det.get("confidence")).doubleValue(); + int x = ((Number) det.get("x")).intValue(); + int y = ((Number) det.get("y")).intValue(); + int width = ((Number) det.get("width")).intValue(); + int height = ((Number) det.get("height")).intValue(); + + detections.add(new Detection(label, confidence, new Rect(x, y, width, height), colorBGR)); + } + + return detections; + } + } catch (Exception e) { + log.error("HTTP检测请求失败: {}", e.getMessage()); + } + + return Collections.emptyList(); + } + + // 用于RestTemplate的字节数组资源类 + private static class CustomByteArrayResource implements org.springframework.core.io.Resource { + private final byte[] byteArray; + private final String filename; + + public CustomByteArrayResource(byte[] byteArray, String filename) { + this.byteArray = byteArray; + this.filename = filename; + } + + @Override + public String getFilename() { + return this.filename; + } + + @Override + public java.io.InputStream getInputStream() throws IOException { + return new java.io.ByteArrayInputStream(this.byteArray); + } + + @Override + public boolean exists() { + return true; + } + + @Override + public java.net.URL getURL() throws IOException { + throw new IOException("Not supported"); + } + + @Override + public java.net.URI getURI() throws IOException { + throw new IOException("Not supported"); + } + + @Override + public java.io.File getFile() throws IOException { + throw new IOException("Not supported"); + } + + @Override + public long contentLength() { + return this.byteArray.length; + } + + @Override + public long lastModified() { + return System.currentTimeMillis(); + } + + @Override + public org.springframework.core.io.Resource createRelative(String relativePath) throws IOException { + throw new IOException("Not supported"); + } + + @Override + public String getDescription() { + return "Byte array resource [" + this.filename + "]"; + } + } +} \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/thread/detector/OnnxYoloDetector.java b/ruoyi-video/src/main/java/com/ruoyi/video/thread/detector/OnnxYoloDetector.java deleted file mode 100644 index a1edc98..0000000 --- a/ruoyi-video/src/main/java/com/ruoyi/video/thread/detector/OnnxYoloDetector.java +++ /dev/null @@ -1,248 +0,0 @@ -package com.ruoyi.video.thread.detector; - -import com.ruoyi.video.domain.Detection; -import org.bytedeco.javacpp.indexer.FloatRawIndexer; -import org.bytedeco.opencv.opencv_core.*; -import org.bytedeco.opencv.opencv_dnn.Net; - -import java.nio.file.*; -import java.util.*; - -import static org.bytedeco.opencv.global.opencv_dnn.*; -import static org.bytedeco.opencv.global.opencv_core.*; -import static org.bytedeco.opencv.global.opencv_imgproc.*; - -public final class OnnxYoloDetector implements YoloDetector { - private final String modelName; - private final Net net; - private final Size input; - private final float confTh = 0.25f, nmsTh = 0.45f; - private final String[] classes; - private final int colorBGR; - - public OnnxYoloDetector(String name, Path dir, int inW, int inH, String backend, int colorBGR) throws Exception { - this.modelName = name; - this.input = new Size(inW, inH); - this.colorBGR = colorBGR; - - // 查找ONNX模型文件 - String onnx = findModelFile(dir, ".onnx"); - if (onnx == null) { - throw new Exception("找不到ONNX模型文件,请确保目录中存在 .onnx 文件: " + dir); - } - - // 读取类别文件 - Path clsPath = dir.resolve("classes.txt"); - if (Files.exists(clsPath)) { - this.classes = Files.readAllLines(clsPath).stream().map(String::trim) - .filter(s -> !s.isEmpty()).toArray(String[]::new); - } else { - this.classes = new String[0]; - } - try { - // 加载ONNX模型 - this.net = readNetFromONNX(onnx); - - // 设置OpenCV后端 - net.setPreferableBackend(DNN_BACKEND_OPENCV); - net.setPreferableTarget(DNN_TARGET_CPU); - - System.out.println("ONNX模型加载成功: " + name + " (" + onnx + ")"); - - } catch (Exception e) { - throw new Exception("模型加载失败: " + e.getMessage() + - "\n请确保ONNX模型文件格式正确", e); - } - } - - /** - * 在目录中查找指定扩展名的模型文件 - */ - private String findModelFile(Path dir, String extension) { - try { - return Files.list(dir) - .filter(path -> path.toString().toLowerCase().endsWith(extension.toLowerCase())) - .map(Path::toString) - .findFirst() - .orElse(null); - } catch (Exception e) { - return null; - } - } - - @Override public String name() { return modelName; } - - @Override - public List detect(Mat bgr) { - if (bgr == null || bgr.empty()) return Collections.emptyList(); - - // 统一成 BGR 3 通道,避免 blobFromImage 断言失败 - if (bgr.channels() != 3) { - Mat tmp = new Mat(); - if (bgr.channels() == 1) cvtColor(bgr, tmp, COLOR_GRAY2BGR); - else if (bgr.channels() == 4) cvtColor(bgr, tmp, COLOR_BGRA2BGR); - else bgr.copyTo(tmp); - bgr = tmp; - } - - try (Mat blob = blobFromImage(bgr, 1.0/255.0, input, new Scalar(0.0), true, false, CV_32F)) { - net.setInput(blob); - // ===== 多输出兼容(Bytedeco 正确写法)===== - org.bytedeco.opencv.opencv_core.StringVector outNames = net.getUnconnectedOutLayersNames(); - List outs = new ArrayList<>(); - - if (outNames == null || outNames.size() == 0) { - // 只有一个默认输出 - Mat out = net.forward(); // ← 直接返回 Mat - outs.add(out); - } else { - // 多输出:用 MatVector 承接 - org.bytedeco.opencv.opencv_core.MatVector outBlobs = - new org.bytedeco.opencv.opencv_core.MatVector(outNames.size()); - net.forward(outBlobs, outNames); // ← 正确的重载 - - for (long i = 0; i < outBlobs.size(); i++) { - outs.add(outBlobs.get(i)); - } - } - - int fw = bgr.cols(), fh = bgr.rows(); - List boxes = new ArrayList<>(); - List scores = new ArrayList<>(); - List classIds = new ArrayList<>(); - - for (Mat out : outs) { - parseYoloOutput(out, fw, fh, boxes, scores, classIds); - } - if (boxes.isEmpty()) return Collections.emptyList(); - - // 纯 Java NMS,避免 MatOf* / Vector API 兼容问题 - List keep = nmsIndices(boxes, scores, nmsTh); - - List result = new ArrayList<>(keep.size()); - for (int k : keep) { - Rect2d r = boxes.get(k); - Rect rect = new Rect((int)r.x(), (int)r.y(), (int)r.width(), (int)r.height()); - int cid = classIds.get(k); - String cname = (cid >= 0 && cid < classes.length) ? classes[cid] : ("cls"+cid); - result.add(new Detection("["+modelName+"] "+cname, scores.get(k), rect, colorBGR)); - } - return result; - } catch (Throwable e) { - // 单帧失败不影响整体 - return Collections.emptyList(); - } - } - - /** 解析 YOLO-IR 输出为 N×C(C>=6),并填充 boxes/scores/classIds。 */ - private void parseYoloOutput(Mat out, int fw, int fh, - List boxes, List scores, List classIds) { - int dims = out.dims(); - Mat m; - - if (dims == 2) { - // NxC 或 CxN - if (out.cols() >= 6) { - m = out; - } else { - Mat tmp = new Mat(); - transpose(out, tmp); // CxN -> NxC - m = tmp; - } - } else if (dims == 3) { - // [1,N,C] 或 [1,C,N] - if (out.size(2) >= 6) { - m = out.reshape(1, out.size(1)); // -> N×C - } else { - Mat squeezed = out.reshape(1, out.size(1)); // C×N - Mat tmp = new Mat(); - transpose(squeezed, tmp); // -> N×C - m = tmp; - } - } else if (dims == 4) { - // [1,1,N,C] 或 [1,1,C,N] - int a = out.size(2), b = out.size(3); - if (b >= 6) { - m = out.reshape(1, a).clone(); // -> N×C - } else { - Mat cxn = out.reshape(1, b); // C×N - Mat tmp = new Mat(); - transpose(cxn, tmp); // -> N×C - m = tmp.clone(); - } - } else { - return; // 不支持的形状 - } - - int N = m.rows(), C = m.cols(); - if (C < 6 || N <= 0) return; - - FloatRawIndexer idx = m.createIndexer(); - for (int i = 0; i < N; i++) { - float cx = idx.get(i,0), cy = idx.get(i,1), w = idx.get(i,2), h = idx.get(i,3); - float obj = idx.get(i,4); - - int bestCls = -1; float bestScore = 0f; - for (int c = 5; c < C; c++) { - float p = idx.get(i,c); - if (p > bestScore) { bestScore = p; bestCls = c - 5; } - } - float conf = obj * bestScore; - if (conf < confTh) continue; - - // 默认假设归一化中心点格式 (cx,cy,w,h);若你的 IR 是 x1,y1,x2,y2,请把这里换算改掉 - int bx = Math.max(0, Math.round(cx * fw - (w * fw) / 2f)); - int by = Math.max(0, Math.round(cy * fh - (h * fh) / 2f)); - int bw = Math.min(fw - bx, Math.round(w * fw)); - int bh = Math.min(fh - by, Math.round(h * fh)); - if (bw <= 0 || bh <= 0) continue; - - boxes.add(new Rect2d(bx, by, bw, bh)); - scores.add(conf); - classIds.add(bestCls); - } - } - - /** 纯 Java NMS(IoU 抑制),返回保留的下标列表。 */ - private List nmsIndices(List boxes, List scores, float nmsThreshold) { - List order = new ArrayList<>(boxes.size()); - for (int i = 0; i < boxes.size(); i++) order.add(i); - // 按分数降序 - order.sort((i, j) -> Float.compare(scores.get(j), scores.get(i))); - - List keep = new ArrayList<>(); - boolean[] removed = new boolean[boxes.size()]; - - for (int a = 0; a < order.size(); a++) { - int i = order.get(a); - if (removed[i]) continue; - keep.add(i); - - Rect2d bi = boxes.get(i); - double areaI = bi.width() * bi.height(); - - for (int b = a + 1; b < order.size(); b++) { - int j = order.get(b); - if (removed[j]) continue; - - Rect2d bj = boxes.get(j); - double areaJ = bj.width() * bj.height(); - - double xx1 = Math.max(bi.x(), bj.x()); - double yy1 = Math.max(bi.y(), bj.y()); - double xx2 = Math.min(bi.x() + bi.width(), bj.x() + bj.width()); - double yy2 = Math.min(bi.y() + bi.height(), bj.y() + bj.height()); - - double w = Math.max(0, xx2 - xx1); - double h = Math.max(0, yy2 - yy1); - double inter = w * h; - double iou = inter / (areaI + areaJ - inter + 1e-9); - - if (iou > nmsThreshold) removed[j] = true; - } - } - return keep; - } - - @Override public void close(){ net.close(); } -} \ No newline at end of file diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/utils/CustomMultipartFile.java b/ruoyi-video/src/main/java/com/ruoyi/video/utils/CustomMultipartFile.java index 78c5132..73f0ada 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/utils/CustomMultipartFile.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/utils/CustomMultipartFile.java @@ -2,130 +2,121 @@ package com.ruoyi.video.utils; import org.springframework.web.multipart.MultipartFile; -import java.io.*; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; /** - * File包装成MultipartFile - * @Author: orange - * @CreateTime: 2025-01-16 + * 自定义MultipartFile实现,用于文件上传 */ public class CustomMultipartFile implements MultipartFile { - // 定义一个File类型的变量file private final File file; - // 定义一个String类型的变量contentType + private final String name; + private final String originalFilename; private final String contentType; - // 构造方法,传入一个File类型的变量file和一个String类型的变量contentType - public CustomMultipartFile(File file, String contentType) { + /** + * 构造函数 - 从File创建 + * + * @param file 文件 + * @param filename 文件名 + * @param contentType 内容类型 + */ + public CustomMultipartFile(File file, String filename, String contentType) { this.file = file; + this.name = filename; + this.originalFilename = filename; this.contentType = contentType; } - // 获取文件名 + /** + * 构造函数 - 从字节数组创建 + * + * @param bytes 文件字节数组 + * @param filename 文件名 + * @param contentType 内容类型 + */ + public CustomMultipartFile(byte[] bytes, String filename, String contentType) { + this.file = null; + this.name = filename; + this.originalFilename = filename; + this.contentType = contentType; + } + + /** + * 构造函数 - 从File创建,指定Content-Type + * + * @param file 文件 + * @param contentType 内容类型 + */ + public CustomMultipartFile(File file, String contentType) { + this.file = file; + this.name = file.getName(); + this.originalFilename = file.getName(); + this.contentType = contentType; + } + + @Override public String getName() { - return this.file.getName(); + return name; } - // 获取原始文件名 + @Override public String getOriginalFilename() { - return this.file.getName(); + return originalFilename; } - // 获取文件类型 + @Override public String getContentType() { - return this.contentType; + return contentType; } - // 判断文件是否为空 + @Override public boolean isEmpty() { - return this.file.length() == 0L; + return getSize() == 0; } - // 获取文件大小 + @Override public long getSize() { - return this.file.length(); + if (file != null) { + return file.length(); + } + return 0; } - // 获取文件的字节数组 + @Override public byte[] getBytes() throws IOException { - // 创建一个FileInputStream对象,传入file - InputStream inputStream = new FileInputStream(this.file); - - byte[] var2; - try { - // 读取所有字节数组 - var2 = inputStream.readAllBytes(); - } catch (Throwable var5) { - try { - // 关闭输入流 - inputStream.close(); - } catch (Throwable var4) { - var5.addSuppressed(var4); + if (file != null) { + try (InputStream is = new FileInputStream(file)) { + return is.readAllBytes(); } - - throw var5; } - - // 关闭输入流 - inputStream.close(); - return var2; + return new byte[0]; } - // 获取文件的输入流 + @Override public InputStream getInputStream() throws IOException { - return new FileInputStream(this.file); + if (file != null) { + return new FileInputStream(file); + } + return new ByteArrayInputStream(new byte[0]); } - // 将文件传输到指定的文件 + @Override public void transferTo(File dest) throws IOException, IllegalStateException { - // 如果目标文件不存在,则创建 - if (!dest.exists()) { - dest.createNewFile(); - } - - // 创建一个FileInputStream对象,传入file - InputStream inputStream = new FileInputStream(this.file); - - try { - // 创建一个FileOutputStream对象,传入dest - OutputStream outputStream = new FileOutputStream(dest); - - try { - // 创建一个字节数组,大小为1024 - byte[] buffer = new byte[1024]; - - int bytesRead; - // 循环读取文件,直到读取完毕 - while((bytesRead = inputStream.read(buffer)) != -1) { - // 将读取的字节数组写入目标文件 - outputStream.write(buffer, 0, bytesRead); + if (file != null) { + try (FileInputStream fis = new FileInputStream(file)) { + try (java.io.FileOutputStream fos = new java.io.FileOutputStream(dest)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = fis.read(buffer)) != -1) { + fos.write(buffer, 0, bytesRead); + } } - } catch (Throwable var8) { - try { - // 关闭输出流 - outputStream.close(); - } catch (Throwable var7) { - var8.addSuppressed(var7); - } - - throw var8; } - - // 关闭输出流 - outputStream.close(); - } catch (Throwable var9) { - try { - // 关闭输入流 - inputStream.close(); - } catch (Throwable var6) { - var9.addSuppressed(var6); - } - - throw var9; } - - // 关闭输入流 - inputStream.close(); } } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/utils/Overlay.java b/ruoyi-video/src/main/java/com/ruoyi/video/utils/Overlay.java index fd41f41..234152e 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/utils/Overlay.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/utils/Overlay.java @@ -11,11 +11,11 @@ public final class Overlay { public static void draw(List dets, Mat frame) { for (Detection d : dets) { - Rect r = d.box(); - int bgr = d.colorBGR(); + Rect r = d.getRect(); + int bgr = d.getColorBGR(); Scalar c = new Scalar(bgr & 0xFF, (bgr >> 8) & 0xFF, (bgr >> 16) & 0xFF, 0); rectangle(frame, r, c, 2, LINE_8, 0); - String label = d.cls()+" "+String.format("%.2f", d.conf()); + String label = d.getLabel()+" "+String.format("%.2f", d.getConfidence()); int[] baseline = new int[1]; Size t = getTextSize(label, FONT_HERSHEY_SIMPLEX, 0.5, 1, baseline); int x = Math.max(0, r.x());