From 9a682f4ff2273760f3b4947eb50be90934c528a6 Mon Sep 17 00:00:00 2001 From: Joshi <3040996759@qq.com> Date: Mon, 29 Sep 2025 13:25:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(video):=20=E5=AE=9E=E7=8E=B0=E6=8A=A5?= =?UTF-8?q?=E8=AD=A6=E8=AE=B0=E5=BD=95=E8=AF=A6=E6=83=85=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E4=B8=8E=E5=A4=84=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增查询报警记录详细接口- 修改处理报警记录接口为 PUT 方法- 新增导出报警记录接口 - 前端页面增加报警视频播放功能 -优化报警记录处理状态显示逻辑- 完善报警详情展示内容,支持图片和视频预览 - 后端实现会话聚合逻辑,支持截图和视频证据保存 - 新增模型修改接口 - 调整权限注解配置 - 完善 MinIO 文件上传和回填逻辑 --- rtsp-vue/src/api/video/alarm.js | 23 +- rtsp-vue/src/views/video/alarm/index.vue | 80 ++++-- ruoyi-video/pom.xml | 4 + .../video/controller/ModelController.java | 22 +- .../controller}/VMinioObjectController.java | 2 +- .../com/ruoyi/video/mapper/VModelMapper.java | 1 + .../ruoyi/video/service/IVModelService.java | 1 + .../impl/InspectionTaskServiceImpl.java | 237 ++++++++++++++- .../video/service/impl/VModelServiceImpl.java | 5 + .../thread/MediaTransferFlvByJavacv.java | 270 ++++++++++++++++++ .../resources/mapper/video/VModelMapper.xml | 17 ++ 11 files changed, 609 insertions(+), 53 deletions(-) rename {ruoyi-admin/src/main/java/com/ruoyi/web/controller/system => ruoyi-video/src/main/java/com/ruoyi/video/controller}/VMinioObjectController.java (98%) diff --git a/rtsp-vue/src/api/video/alarm.js b/rtsp-vue/src/api/video/alarm.js index c39d038..fbb1da7 100644 --- a/rtsp-vue/src/api/video/alarm.js +++ b/rtsp-vue/src/api/video/alarm.js @@ -9,12 +9,20 @@ export function listAlarm(query) { }) } +// 查询报警记录详细 +export function getAlarm(alarmId) { + return request({ + url: '/video/alarm/' + alarmId, + method: 'get' + }) +} + // 处理报警记录 export function handleAlarm(data) { return request({ url: '/video/alarm/handle', - method: 'post', - params: data + method: 'put', + data: data }) } @@ -22,7 +30,16 @@ export function handleAlarm(data) { export function batchHandleAlarm(data) { return request({ url: '/video/alarm/batchHandle', + method: 'put', + data: data + }) +} + +// 导出报警记录 +export function exportAlarm(query) { + return request({ + url: '/video/alarm/export', method: 'post', - params: data + params: query }) } \ No newline at end of file diff --git a/rtsp-vue/src/views/video/alarm/index.vue b/rtsp-vue/src/views/video/alarm/index.vue index 7e3f180..10c3d2b 100644 --- a/rtsp-vue/src/views/video/alarm/index.vue +++ b/rtsp-vue/src/views/video/alarm/index.vue @@ -79,7 +79,7 @@ v-hasPermi="['video:alarm:export']" >导出 - + @@ -112,6 +112,17 @@ /> + + + diff --git a/ruoyi-video/pom.xml b/ruoyi-video/pom.xml index 96da1cb..248cc58 100644 --- a/ruoyi-video/pom.xml +++ b/ruoyi-video/pom.xml @@ -99,6 +99,10 @@ com.ruoyi ruoyi-common + + com.ruoyi + ruoyi-framework + diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/controller/ModelController.java b/ruoyi-video/src/main/java/com/ruoyi/video/controller/ModelController.java index 8734a71..7d3ab15 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/controller/ModelController.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/controller/ModelController.java @@ -28,7 +28,6 @@ public class ModelController extends BaseController { } /** 新增模型(JSON) */ - @PreAuthorize("@ss.hasPermi('video:model:add')") @PostMapping public AjaxResult create(@RequestBody VModel model) { if (StringUtils.isEmpty(model.getModelName())) { @@ -44,8 +43,23 @@ public class ModelController extends BaseController { return rows > 0 ? AjaxResult.success(model) : AjaxResult.error("新增失败"); } + /** 修改模型 */ + @PutMapping + public AjaxResult update(@RequestBody VModel model) { + if (model.getModelId() == null) { + return AjaxResult.error("模型ID不能为空"); + } + if (StringUtils.isEmpty(model.getModelName())) { + return AjaxResult.error("模型名称不能为空"); + } + if (StringUtils.isEmpty(model.getFramework())) { + model.setFramework("onnx"); + } + int rows = modelService.update(model); + return rows > 0 ? AjaxResult.success("修改成功") : AjaxResult.error("修改失败"); + } + /** 根据ID查询 */ - @PreAuthorize("@ss.hasPermi('video:model:query')") @GetMapping("/{id}") public AjaxResult get(@PathVariable("id") Long id) { VModel model = modelService.selectById(id); @@ -53,7 +67,6 @@ public class ModelController extends BaseController { } /** 列表查询(可选条件) */ - @PreAuthorize("@ss.hasPermi('video:model:list')") @GetMapping("/list") public TableDataInfo list(@RequestParam(value = "modelName", required = false) String modelName, @RequestParam(value = "framework", required = false) String framework, @@ -70,7 +83,6 @@ public class ModelController extends BaseController { } /** 删除 */ - @PreAuthorize("@ss.hasPermi('video:model:remove')") @DeleteMapping("/{id}") public AjaxResult delete(@PathVariable("id") Long id) { int rows = modelService.deleteById(id); @@ -78,7 +90,6 @@ public class ModelController extends BaseController { } /** 启用/禁用 */ - @PreAuthorize("@ss.hasPermi('video:model:edit')") @PutMapping("/{id}/enable") public AjaxResult enable(@PathVariable("id") Long id, @RequestParam("enabled") Integer enabled) { @@ -90,7 +101,6 @@ public class ModelController extends BaseController { } /** 下载:直接 302 重定向到模型URL,确保可点击下载 */ - @PreAuthorize("@ss.hasPermi('video:model:download')") @GetMapping("/download/{id}") public void download(@PathVariable("id") Long id, HttpServletResponse response) throws IOException { VModel model = modelService.selectById(id); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VMinioObjectController.java b/ruoyi-video/src/main/java/com/ruoyi/video/controller/VMinioObjectController.java similarity index 98% rename from ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VMinioObjectController.java rename to ruoyi-video/src/main/java/com/ruoyi/video/controller/VMinioObjectController.java index 7d89d58..733c537 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/VMinioObjectController.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/controller/VMinioObjectController.java @@ -1,4 +1,4 @@ -package com.ruoyi.web.controller.system; +package com.ruoyi.video.controller; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.utils.StringUtils; diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/mapper/VModelMapper.java b/ruoyi-video/src/main/java/com/ruoyi/video/mapper/VModelMapper.java index 0b5e8de..8b3218d 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/mapper/VModelMapper.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/mapper/VModelMapper.java @@ -9,6 +9,7 @@ import java.util.Map; public interface VModelMapper { int insertModel(VModel model); VModel selectModelById(@Param("id") Long id); + int updateModel(VModel model); int deleteModelById(@Param("id") Long id); int updateEnabled(@Param("id") Long id, @Param("enabled") Integer enabled); diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/IVModelService.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/IVModelService.java index 8658a7d..744e1ca 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/service/IVModelService.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/IVModelService.java @@ -8,6 +8,7 @@ import java.util.Map; public interface IVModelService { int insert(VModel model); VModel selectById(Long id); + int update(VModel model); int deleteById(Long id); int updateEnabled(Long id, Integer enabled); List selectList(Map params); 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 7f92fdf..ba600c4 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 @@ -11,6 +11,14 @@ import com.ruoyi.video.service.InspectionTaskService; import com.ruoyi.video.thread.MediaTransferFlvByJavacv; import com.ruoyi.video.common.ModelManager; import com.ruoyi.video.thread.detector.YoloDetector; +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.utils.CustomMultipartFile; +import com.ruoyi.video.utils.Overlay; +import org.bytedeco.javacv.FFmpegFrameRecorder; +import org.bytedeco.javacv.Java2DFrameConverter; import lombok.extern.slf4j.Slf4j; import org.bytedeco.javacv.Frame; import org.bytedeco.javacv.FrameGrabber; @@ -177,6 +185,8 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { } FFmpegFrameGrabber grabber = null; + FFmpegFrameRecorder sessionRecorder = null; + File sessionVideoTmp = null; try { // 初始化模型管理器 if (modelManager == null) { @@ -201,12 +211,32 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { int frameCount = 0; List allDetections = new ArrayList<>(); + // 会话聚合参数与状态 + final long minGapMs = 3000L; // 目标消失超过该值视为结束 + final long maxDurationMs = 30000L; // 单次会话最长30s + final float detectionThreshold = task.getThreshold() != null ? task.getThreshold().floatValue() : 0.7f; + + boolean sessionActive = false; + long sessionStartMs = 0L; + long lastSeenMs = 0L; + sessionRecorder = null; + sessionVideoTmp = null; + Long currentAlarmId = null; + while (System.currentTimeMillis() - startTime < duration) { Frame frame = grabber.grabImage(); if (frame == null) continue; frameCount++; - + + // 录像帧追加 + if (sessionActive && sessionRecorder != null) { + try { + sessionRecorder.record(frame); + } catch (Exception ignore) { + } + } + // 每隔一定帧数进行一次检测(避免过于频繁) if (frameCount % 25 == 0) { // 假设25fps,每秒检测一次 try { @@ -214,16 +244,172 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { if (toMat == null) { toMat = new OpenCVFrameConverter.ToMat(); } - + Mat mat = toMat.convert(frame); if (mat != null && !mat.empty()) { List detections = performDetection(mat); allDetections.addAll(detections); - - // 如果检测到异常,立即保存图片并记录报警 - if (!detections.isEmpty()) { - String imagePath = saveFrameAsImage(frame, task.getTaskId()); - handleDetectionResults(task.getTaskId(), detections, imagePath); + + // 会话聚合:阈值触发开始,目标消失/超过30s结束;开始时叠框截图+告警,结束后上传视频并回填 + long now = System.currentTimeMillis(); + + // 叠框,便于生成可核验证据图 + try { + if (detections != null && !detections.isEmpty() && mat != null && !mat.empty()) { + Overlay.draw(detections, mat); + } + } catch (Exception ignore) { + } + + // 最高置信度 + Detection best = detections.stream() + .max(Comparator.comparingDouble(Detection::conf)) + .orElse(null); + boolean hasTarget = best != null && best.conf() >= detectionThreshold; + + if (hasTarget) { + lastSeenMs = now; + if (!sessionActive) { + // 1) 叠框后截图上传 + 插入告警(写 image_path) + try { + BufferedImage img = null; + try { + Java2DFrameConverter conv = new Java2DFrameConverter(); + if (mat != null && !mat.empty()) { + img = conv.convert(new OpenCVFrameConverter.ToMat().convert(mat)); + } + if (img == null) { + img = conv.convert(frame); + } + } catch (Exception ___) { + } + + if (img != null) { + File tmp = File.createTempFile("snapshot_", ".jpg"); + ImageIO.write(img, "jpg", tmp); + + String originalName = "task_" + task.getTaskId() + "_" + now + ".jpg"; + String uniqueObjectName = UUID.randomUUID().toString().replace("-", "") + ".jpg"; + MinioService minio = SpringUtils.getBean(MinioService.class); + IVMinioObjectService objSvc = SpringUtils.getBean(IVMinioObjectService.class); + MinioService.UploadResult up = minio.uploadWithName(new CustomMultipartFile(tmp, "image/jpeg"), uniqueObjectName); + + VMinioObject rec = new VMinioObject(); + rec.setObjectName(up.getObjectName()); + rec.setUrl(up.getUrl()); + rec.setOriginalName(originalName); + objSvc.insert(rec); + + AlarmRecord alarm = new AlarmRecord(); + alarm.setTaskId(task.getTaskId()); + 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()); + saveAlarmRecord(alarm); + currentAlarmId = alarm.getAlarmId(); + + try { + tmp.delete(); + } catch (Exception ignore) { + } + } + } catch (Exception ee) { + log.warn("巡检会话-保存截图失败: {}", ee.getMessage()); + } + + // 2) 启动会话录像(mp4) + try { + sessionVideoTmp = File.createTempFile("evidence_", ".mp4"); + sessionRecorder = new FFmpegFrameRecorder( + sessionVideoTmp, + grabber.getImageWidth(), + grabber.getImageHeight(), + grabber.getAudioChannels() + ); + sessionRecorder.setFormat("mp4"); + sessionRecorder.setInterleaved(true); + sessionRecorder.setVideoOption("preset", "ultrafast"); + sessionRecorder.setVideoOption("tune", "zerolatency"); + sessionRecorder.setFrameRate(25); + sessionRecorder.setGopSize(25); + sessionRecorder.start(); + } catch (Exception sre) { + log.warn("巡检会话-录像器启动失败: {}", sre.getMessage()); + sessionRecorder = null; + sessionVideoTmp = null; + } + + sessionActive = true; + sessionStartMs = now; + } + } + + // 会话结束判定:目标消失超过阈值或会话超过最大时长 + if (sessionActive) { + boolean gapTimeout = (now - lastSeenMs) >= minGapMs; + boolean maxed = (now - sessionStartMs) >= maxDurationMs; + if (gapTimeout || maxed) { + File local = sessionVideoTmp; + FFmpegFrameRecorder rec = sessionRecorder; + sessionVideoTmp = null; + sessionRecorder = null; + + try { + if (rec != null) { + try { + rec.stop(); + } catch (Exception ignore) { + } + try { + rec.release(); + } catch (Exception ignore) { + } + } + } catch (Exception se) { + log.debug("巡检会话-停止录像异常: {}", se.getMessage()); + } + + if (local != null && local.exists() && currentAlarmId != null) { + try { + MinioService minio = SpringUtils.getBean(MinioService.class); + IVMinioObjectService objSvc = SpringUtils.getBean(IVMinioObjectService.class); + String originalName = "evidence_" + now + ".mp4"; + String uniqueName = UUID.randomUUID().toString().replace("-", "") + ".mp4"; + MinioService.UploadResult upv = minio.uploadWithName(new CustomMultipartFile(local, "video/mp4"), uniqueName); + + VMinioObject vrec = new VMinioObject(); + vrec.setObjectName(upv.getObjectName()); + vrec.setUrl(upv.getUrl()); + vrec.setOriginalName(originalName); + objSvc.insert(vrec); + + AlarmRecord patch = new AlarmRecord(); + patch.setAlarmId(currentAlarmId); + patch.setVideoPath(upv.getUrl()); + alarmRecordMapper.updateAlarmRecord(patch); + } catch (Exception ue) { + log.warn("巡检会话-上传/回填视频失败: {}", ue.getMessage()); + } finally { + try { + local.delete(); + } catch (Exception ignore) { + } + } + } + + sessionActive = false; + sessionStartMs = 0L; + lastSeenMs = 0L; + currentAlarmId = null; + } } } } catch (Exception e) { @@ -234,12 +420,37 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { } } - log.info("巡检任务完成: {} - 分析帧数: {}, 检测结果: {}", + log.info("巡检任务完成: {} - 分析帧数: {}, 检测结果: {}", task.getTaskId(), frameCount, allDetections.size()); } catch (Exception e) { log.error("视频分析失败: {}", e.getMessage(), e); } finally { + // 兜底清理会话资源(缓存并置空,避免并发/重复close问题) + FFmpegFrameRecorder rec = sessionRecorder; + File local = sessionVideoTmp; + sessionRecorder = null; + sessionVideoTmp = null; + try { + if (rec != null) { + try { + rec.stop(); + } catch (Exception ignore) { + } + try { + rec.release(); + } catch (Exception ignore) { + } + } + } catch (Exception ignore) { + } + if (local != null) { + try { + local.delete(); + } catch (Exception ignore) { + } + } + if (grabber != null) { try { grabber.stop(); @@ -285,7 +496,7 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { String filePath = saveDir + "/" + fileName; // 转换并保存图片 - BufferedImage bufferedImage = new org.bytedeco.javacv.Java2DFrameConverter().convert(frame); + BufferedImage bufferedImage = new Java2DFrameConverter().convert(frame); ImageIO.write(bufferedImage, "jpg", new File(filePath)); return filePath; @@ -313,17 +524,17 @@ public class InspectionTaskServiceImpl implements InspectionTaskService { alarmRecord.setDeviceName(task.getDeviceName()); alarmRecord.setAlarmType(detection.cls()); alarmRecord.setAlarmLevel(getAlarmLevel(detection.conf())); - alarmRecord.setAlarmDesc(String.format("检测到%s,置信度: %.2f", + alarmRecord.setAlarmDesc(String.format("检测到%s,置信度: %.2f", detection.cls(), detection.conf())); - alarmRecord.setConfidence((double)detection.conf()); + alarmRecord.setConfidence((double) detection.conf()); alarmRecord.setImagePath(imagePath); alarmRecord.setAlarmTime(new Date()); alarmRecord.setHandleStatus("0"); // 未处理 alarmRecord.setCreateBy(SecurityUtils.getUsername()); saveAlarmRecord(alarmRecord); - - log.warn("生成报警记录: 任务[{}] 检测到[{}] 置信度[{}]", + + log.warn("生成报警记录: 任务[{}] 检测到[{}] 置信度[{}]", taskId, detection.cls(), detection.conf()); } } diff --git a/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/VModelServiceImpl.java b/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/VModelServiceImpl.java index 6504276..e68541d 100644 --- a/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/VModelServiceImpl.java +++ b/ruoyi-video/src/main/java/com/ruoyi/video/service/impl/VModelServiceImpl.java @@ -28,6 +28,11 @@ public class VModelServiceImpl implements IVModelService { return mapper.selectModelById(id); } + @Override + public int update(VModel model) { + return mapper.updateModel(model); + } + @Override public int deleteById(Long id) { return mapper.deleteModelById(id); 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 4b3f4e5..7390239 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 @@ -8,6 +8,13 @@ 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.utils.CustomMultipartFile; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; @@ -24,14 +31,21 @@ import org.bytedeco.opencv.opencv_core.Mat; 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; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; import java.util.Collections; +import java.util.UUID; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; import static org.bytedeco.opencv.global.opencv_core.CV_8UC3; @@ -57,6 +71,200 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable void onWindowFinished(Long jobId, Long deviceId, WindowStats stats); } + /** 启动会话视频录制(本地mp4临时文件,结束后上传MinIO) */ + private void startSessionVideoRecorder() { + try { + // 以系统临时目录创建mp4 + sessionVideoTmp = File.createTempFile("evidence_", ".mp4"); + int w = (grabber != null) ? grabber.getImageWidth() : 1280; + int h = (grabber != null) ? grabber.getImageHeight() : 720; + int ac = (grabber != null) ? grabber.getAudioChannels() : 0; + + sessionRecorder = new FFmpegFrameRecorder(sessionVideoTmp, w, h, ac); + sessionRecorder.setFormat("mp4"); + sessionRecorder.setInterleaved(true); + sessionRecorder.setVideoOption("preset", "ultrafast"); + sessionRecorder.setVideoOption("tune", "zerolatency"); + sessionRecorder.setFrameRate(25); + sessionRecorder.setGopSize(25); + sessionRecorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); + sessionRecorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P); + if (ac > 0) sessionRecorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); + sessionRecorder.start(); + log.debug("会话录像器启动: {}", sessionVideoTmp); + } catch (Exception e) { + log.warn("会话录像器启动失败: {}", e.getMessage()); + try { if (sessionRecorder != null) sessionRecorder.close(); } catch (Exception ignore) {} + sessionRecorder = null; + sessionVideoTmp = null; + } + } + + /** 结束会话录像并上传到 MinIO,然后更新 AlarmRecord.videoPath */ + private void stopAndUploadSessionVideo(long nowMs) { + File local = sessionVideoTmp; + FFmpegFrameRecorder rec = sessionRecorder; + sessionVideoTmp = null; + sessionRecorder = null; + try { + if (rec != null) { + try { rec.stop(); } catch (Exception ignore) {} + try { rec.release(); } catch (Exception ignore) {} + } + } 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()); + } + } 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; + } + // 转为 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; @@ -123,6 +331,21 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable // 导出最近一次“叠好框的帧”用于截图存证 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; @@ -451,6 +674,37 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable // 更新"最近叠好框的帧"用于存证 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); @@ -486,6 +740,14 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable 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) { @@ -536,6 +798,14 @@ public class MediaTransferFlvByJavacv extends MediaTransfer implements Runnable 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) {} diff --git a/ruoyi-video/src/main/resources/mapper/video/VModelMapper.xml b/ruoyi-video/src/main/resources/mapper/video/VModelMapper.xml index 8ae5793..d5e588a 100644 --- a/ruoyi-video/src/main/resources/mapper/video/VModelMapper.xml +++ b/ruoyi-video/src/main/resources/mapper/video/VModelMapper.xml @@ -33,6 +33,23 @@ SELECT * FROM v_model WHERE model_id = #{id} + + UPDATE v_model + + model_name = #{modelName}, + version = #{version}, + framework = #{framework}, + url = #{url}, + file_size = #{fileSize}, + checksum = #{checksum}, + enabled = #{enabled}, + update_by = #{updateBy}, + remark = #{remark}, + update_time = NOW() + + WHERE model_id = #{modelId} + + DELETE FROM v_model WHERE model_id = #{id}