feat(video): 实现报警记录详情查看与处理功能
- 新增查询报警记录详细接口- 修改处理报警记录接口为 PUT 方法- 新增导出报警记录接口 - 前端页面增加报警视频播放功能 -优化报警记录处理状态显示逻辑- 完善报警详情展示内容,支持图片和视频预览 - 后端实现会话聚合逻辑,支持截图和视频证据保存 - 新增模型修改接口 - 调整权限注解配置 - 完善 MinIO 文件上传和回填逻辑
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.ruoyi.video.controller;
|
||||
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.framework.service.MinioService;
|
||||
import com.ruoyi.video.domain.VMinioObject;
|
||||
import com.ruoyi.video.service.IVMinioObjectService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/system/minio-object")
|
||||
public class VMinioObjectController {
|
||||
|
||||
private final IVMinioObjectService vMinioObjectService;
|
||||
private final MinioService minioService;
|
||||
|
||||
public VMinioObjectController(IVMinioObjectService vMinioObjectService,
|
||||
MinioService minioService) {
|
||||
this.vMinioObjectService = vMinioObjectService;
|
||||
this.minioService = minioService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询:根据主键ID查询一条记录
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
public AjaxResult getById(@PathVariable("id") Long id) {
|
||||
VMinioObject obj = vMinioObjectService.selectById(id);
|
||||
if (obj == null) {
|
||||
return AjaxResult.error("记录不存在");
|
||||
}
|
||||
return AjaxResult.success(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询:根据唯一对象名查询
|
||||
*/
|
||||
@GetMapping("/name/{objectName}")
|
||||
public AjaxResult getByObjectName(@PathVariable("objectName") String objectName) {
|
||||
VMinioObject obj = vMinioObjectService.selectByObjectName(objectName);
|
||||
if (obj == null) {
|
||||
return AjaxResult.error("记录不存在");
|
||||
}
|
||||
return AjaxResult.success(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除:根据主键ID删除(先删 MinIO,后删数据库)
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
public AjaxResult deleteById(@PathVariable("id") Long id) {
|
||||
VMinioObject obj = vMinioObjectService.selectById(id);
|
||||
if (obj == null) {
|
||||
return AjaxResult.error("记录不存在或已删除");
|
||||
}
|
||||
String objectName = obj.getObjectName();
|
||||
if (StringUtils.isEmpty(objectName)) {
|
||||
return AjaxResult.error("对象名为空,无法删除 MinIO 对象");
|
||||
}
|
||||
try {
|
||||
// 先删除 MinIO 中的对象,确保不留悬挂数据
|
||||
minioService.deleteObject(objectName);
|
||||
} catch (Exception e) {
|
||||
return AjaxResult.error("删除 MinIO 对象失败: " + e.getMessage());
|
||||
}
|
||||
// 再删除数据库记录
|
||||
int rows = vMinioObjectService.deleteById(id);
|
||||
return rows > 0 ? AjaxResult.success() : AjaxResult.error("删除数据库记录失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除:根据唯一对象名删除(先 MinIO,再 DB)
|
||||
*/
|
||||
@DeleteMapping("/name/{objectName}")
|
||||
public AjaxResult deleteByObjectName(@PathVariable("objectName") String objectName) {
|
||||
VMinioObject obj = vMinioObjectService.selectByObjectName(objectName);
|
||||
if (obj == null) {
|
||||
return AjaxResult.error("记录不存在或已删除");
|
||||
}
|
||||
try {
|
||||
minioService.deleteObject(objectName);
|
||||
} catch (Exception e) {
|
||||
return AjaxResult.error("删除 MinIO 对象失败: " + e.getMessage());
|
||||
}
|
||||
int rows = vMinioObjectService.deleteByObjectName(objectName);
|
||||
return rows > 0 ? AjaxResult.success() : AjaxResult.error("删除数据库记录失败");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<VModel> selectList(Map<String, Object> params);
|
||||
|
||||
@@ -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<Detection> 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<Detection> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Detection> 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<Mat> 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) {}
|
||||
|
||||
Reference in New Issue
Block a user