feat(video): 实现报警记录详情查看与处理功能
- 新增查询报警记录详细接口- 修改处理报警记录接口为 PUT 方法- 新增导出报警记录接口 - 前端页面增加报警视频播放功能 -优化报警记录处理状态显示逻辑- 完善报警详情展示内容,支持图片和视频预览 - 后端实现会话聚合逻辑,支持截图和视频证据保存 - 新增模型修改接口 - 调整权限注解配置 - 完善 MinIO 文件上传和回填逻辑
This commit is contained in:
@@ -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: 'post',
|
||||
params: data
|
||||
method: 'put',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 导出报警记录
|
||||
export function exportAlarm(query) {
|
||||
return request({
|
||||
url: '/video/alarm/export',
|
||||
method: 'post',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
@@ -79,7 +79,7 @@
|
||||
v-hasPermi="['video:alarm:export']"
|
||||
>导出</el-button>
|
||||
</el-col>
|
||||
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||
<right-toolbar v-model="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="alarmList" @selection-change="handleSelectionChange">
|
||||
@@ -112,6 +112,17 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报警视频" align="center" prop="videoPath">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.videoPath"
|
||||
link
|
||||
type="primary"
|
||||
icon="VideoPlay"
|
||||
@click="handleView(scope.row)"
|
||||
>播放</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报警时间" align="center" prop="alarmTime" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ parseTime(scope.row.alarmTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
|
||||
@@ -133,20 +144,20 @@
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.handleStatus === '0'"
|
||||
link
|
||||
type="success"
|
||||
type="primary"
|
||||
icon="Check"
|
||||
@click="handleProcess(scope.row, '1')"
|
||||
v-hasPermi="['video:alarm:handle']"
|
||||
v-if="scope.row.handleStatus === '0'"
|
||||
>处理</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.handleStatus === '0'"
|
||||
link
|
||||
type="info"
|
||||
icon="Close"
|
||||
@click="handleProcess(scope.row, '2')"
|
||||
v-hasPermi="['video:alarm:handle']"
|
||||
v-if="scope.row.handleStatus === '0'"
|
||||
>忽略</el-button>
|
||||
<el-button
|
||||
link
|
||||
@@ -159,21 +170,17 @@
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total>0"
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNum"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
:page="queryParams.pageNum"
|
||||
:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
|
||||
<!-- 处理报警对话框 -->
|
||||
<el-dialog :title="processTitle" v-model="processOpen" width="500px" append-to-body>
|
||||
<el-form ref="processRef" :model="processForm" label-width="80px">
|
||||
<el-form-item label="处理状态">
|
||||
<el-tag v-if="processForm.handleStatus === '1'" type="success">已处理</el-tag>
|
||||
<el-tag v-else-if="processForm.handleStatus === '2'" type="info">已忽略</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理备注">
|
||||
<el-form-item label="处理备注" prop="handleRemark">
|
||||
<el-input
|
||||
v-model="processForm.handleRemark"
|
||||
type="textarea"
|
||||
@@ -190,7 +197,7 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 查看报警详情对话框 -->
|
||||
<!-- 查看详情对话框 -->
|
||||
<el-dialog title="报警详情" v-model="viewOpen" width="800px" append-to-body>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="报警ID">{{ viewForm.alarmId }}</el-descriptions-item>
|
||||
@@ -203,16 +210,20 @@
|
||||
<el-tag v-else-if="viewForm.alarmLevel === '3'" type="danger">高</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="置信度">{{ (viewForm.confidence * 100).toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="报警时间" :span="2">{{ parseTime(viewForm.alarmTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</el-descriptions-item>
|
||||
<el-descriptions-item label="报警时间" :span="2">
|
||||
{{ parseTime(viewForm.alarmTime, '{y}-{m}-{d} {h}:{i}:{s}') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="报警描述" :span="2">{{ viewForm.alarmDesc }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理状态">
|
||||
<el-tag v-if="viewForm.handleStatus === '0'" type="danger">未处理</el-tag>
|
||||
<el-tag v-else-if="viewForm.handleStatus === '1'" type="success">已处理</el-tag>
|
||||
<el-tag v-else-if="viewForm.handleStatus === '2'" type="info">已忽略</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理人">{{ viewForm.handleBy || '无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理时间" :span="2">{{ parseTime(viewForm.handleTime, '{y}-{m}-{d} {h}:{i}:{s}') || '无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理备注" :span="2">{{ viewForm.handleRemark || '无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理人">{{ viewForm.handleBy || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理时间" :span="2">
|
||||
{{ viewForm.handleTime ? parseTime(viewForm.handleTime, '{y}-{m}-{d} {h}:{i}:{s}') : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理备注" :span="2">{{ viewForm.handleRemark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="viewForm.imagePath" style="margin-top: 20px;">
|
||||
@@ -224,6 +235,15 @@
|
||||
fit="contain"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="viewForm.videoPath" style="margin-top: 20px;">
|
||||
<h4>报警视频:</h4>
|
||||
<video
|
||||
:src="viewForm.videoPath"
|
||||
style="width: 100%; max-height: 420px; background: #000"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -99,6 +99,10 @@
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>ruoyi-framework</artifactId>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -220,10 +250,166 @@ public class InspectionTaskServiceImpl implements InspectionTaskService {
|
||||
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) {
|
||||
@@ -240,6 +426,31 @@ public class InspectionTaskServiceImpl implements InspectionTaskService {
|
||||
} 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;
|
||||
@@ -315,7 +526,7 @@ public class InspectionTaskServiceImpl implements InspectionTaskService {
|
||||
alarmRecord.setAlarmLevel(getAlarmLevel(detection.conf()));
|
||||
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"); // 未处理
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -33,6 +33,23 @@
|
||||
SELECT * FROM v_model WHERE model_id = #{id}
|
||||
</select>
|
||||
|
||||
<update id="updateModel" parameterType="com.ruoyi.video.domain.VModel">
|
||||
UPDATE v_model
|
||||
<trim prefix="SET" suffixOverrides=",">
|
||||
<if test="modelName != null and modelName != ''">model_name = #{modelName},</if>
|
||||
<if test="version != null">version = #{version},</if>
|
||||
<if test="framework != null and framework != ''">framework = #{framework},</if>
|
||||
<if test="url != null and url != ''">url = #{url},</if>
|
||||
<if test="fileSize != null">file_size = #{fileSize},</if>
|
||||
<if test="checksum != null">checksum = #{checksum},</if>
|
||||
<if test="enabled != null">enabled = #{enabled},</if>
|
||||
<if test="updateBy != null">update_by = #{updateBy},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
update_time = NOW()
|
||||
</trim>
|
||||
WHERE model_id = #{modelId}
|
||||
</update>
|
||||
|
||||
<delete id="deleteModelById" parameterType="long">
|
||||
DELETE FROM v_model WHERE model_id = #{id}
|
||||
</delete>
|
||||
|
||||
Reference in New Issue
Block a user