Files
jc-video-recognize/apps/web/src/components/VideoDetection.vue
wwh 7aa71c5f83 feat: 新增人员徘徊/静止行为分析功能
本次提交实现了完整的人员行为分析系统,包括:
1. 新增基于位置和跟踪ID的两种行为检测算法
2. 新增徘徊检测服务与行为处理器模块
3. 前后端集成算法配置界面与告警展示
4. 支持图片和视频流场景下的行为分析
5. 新增算法配置接口与文档说明

具体改动:
- 新增loitering_detection模型目录与算法实现
- 新增AlgorithmConfig组件实现可视化配置
- 扩展图片/视频检测接口支持算法参数传递
- 新增行为告警推送与前端展示页面
- 优化检测服务,集成行为分析逻辑
- 移除冗余日志输出,完善代码注释
2026-05-19 09:17:09 +08:00

1025 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="video-detection-container">
<!-- 左侧配置面板 -->
<div class="left-panel" :style="{ width: leftPanelWidth + 'px' }">
<el-card class="config-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Setting /></el-icon>
<span>视频检测配置</span>
</div>
</template>
<el-form label-position="top" class="config-form">
<el-form-item label="选择模型">
<el-select
v-model="config.model"
placeholder="选择检测模型"
class="full-width"
@change="updateCameraConfig"
>
<el-option
v-for="model in models"
:key="model.id"
:label="model.name"
:value="model.id"
>
<span>{{ model.name }}</span>
<span class="model-size">{{ model.size }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<template #label>
<span>置信度阈值</span>
<el-tooltip placement="top" :show-after="200">
<template #content>
<div style="max-width: 300px; line-height: 1.6;">
<p><strong>置信度是什么</strong></p>
<p>表示模型对检测结果的"确定程度"范围 0-1</p>
<ul style="margin: 8px 0; padding-left: 16px;">
<li>0.8高置信度绿色- 模型非常确定</li>
<li>0.6-0.8中等置信度黄色- 模型比较确定</li>
<li>&lt;0.6低置信度红色- 模型不太确定</li>
</ul>
<p><strong>阈值作用</strong></p>
<p>低于此值的检测结果会被过滤掉</p>
<p>建议0.3-0.5火灾检测可适当降低</p>
</div>
</template>
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-slider
v-model="config.confidence"
:min="0.1"
:max="1.0"
:step="0.05"
:format-tooltip="formatConfidence"
@change="updateCameraConfig"
/>
<div class="slider-value">{{ config.confidence.toFixed(2) }}</div>
</el-form-item>
<el-form-item>
<template #label>
<span>IOU阈值</span>
<el-tooltip placement="top" :show-after="200">
<template #content>
<div style="max-width: 320px; line-height: 1.6;">
<p><strong>IOU是什么</strong></p>
<p>交并比(Intersection Over Union)衡量两个检测框的重叠程度</p>
<p style="margin: 8px 0;"><strong>计算公式</strong></p>
<p style="background: #f5f5f5; padding: 4px 8px; border-radius: 4px; font-family: monospace;">IOU = 交集面积 / 并集面积</p>
<p style="margin: 8px 0;"><strong>阈值作用</strong></p>
<p>用于NMS去重IOU超过此值的框被认为是重复检测</p>
<ul style="margin: 8px 0; padding-left: 16px;">
<li>高阈值(0.7-0.9)保留更多框适合密集场景</li>
<li>中阈值(0.45-0.6)平衡适合一般场景</li>
<li>低阈值(0.1-0.3)结果更精简适合稀疏场景</li>
</ul>
<p><strong>建议0.45-0.6</strong></p>
</div>
</template>
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-slider
v-model="config.iou"
:min="0.1"
:max="0.9"
:step="0.05"
:format-tooltip="formatIOU"
@change="updateCameraConfig"
/>
<div class="slider-value">{{ config.iou.toFixed(2) }}</div>
</el-form-item>
<!-- 算法配置仅对人员检测模型显示 -->
<AlgorithmConfig
v-model="config.algorithmConfig"
@change="onAlgorithmChange"
:model-id="config.model"
/>
</el-form>
</el-card>
</div>
<!-- 拖拽调整条 -->
<div
class="resize-handle"
@mousedown="startResize"
:class="{ 'resizing': isResizing }"
>
<div class="resize-indicator"></div>
</div>
<!-- 右侧展示区域 -->
<div class="right-panel" :style="{ width: `calc(100% - ${leftPanelWidth}px - 8px)` }">
<!-- 检测结果和统计信息并排 -->
<el-row :gutter="20">
<!-- 检测结果区域 -->
<el-col :span="stats ? 16 : 24">
<el-card class="video-card result-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<el-icon class="header-icon"><View /></el-icon>
<span>检测结果</span>
</div>
<el-button
type="primary"
size="small"
@click="toggleCamera"
:loading="cameraStarting"
>
<el-icon v-if="!cameraConnected"><VideoPlay /></el-icon>
<el-icon v-else><VideoPause /></el-icon>
<span>{{ cameraConnected ? '停止摄像头' : '启用摄像头' }}</span>
</el-button>
</div>
</template>
<div class="video-container">
<img
v-if="resultCameraFrame"
:src="'data:image/jpeg;base64,' + resultCameraFrame"
class="display-video"
alt="检测结果"
/>
<div v-else class="empty-placeholder">
<el-icon class="empty-icon"><VideoCamera /></el-icon>
<p class="empty-text">请启用摄像头进行检测</p>
<p class="empty-hint">点击右上角按钮开启摄像头</p>
</div>
</div>
</el-card>
</el-col>
<!-- 统计信息 -->
<el-col v-if="stats" :span="8">
<el-card class="stats-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><DataLine /></el-icon>
<span>检测统计</span>
</div>
</template>
<div class="stats-content">
<div class="stat-item">
<div class="stat-label">检测数量</div>
<el-tag size="large" type="primary">{{ stats.total_detections }} </el-tag>
</div>
<div class="stat-item">
<div class="stat-label">平均置信度</div>
<el-tag size="large" :type="getConfidenceType(stats.avg_confidence)">
{{ stats.avg_confidence?.toFixed(2) }}
</el-tag>
</div>
<div class="stat-item">
<div class="stat-label">处理时间</div>
<el-tag size="large" type="info">{{ stats.processing_time?.toFixed(2) }}s</el-tag>
</div>
<div class="stat-item">
<div class="stat-label">使用模型</div>
<el-tag size="large" type="success">{{ modelName }}</el-tag>
</div>
<div v-if="stats.fps" class="stat-item">
<div class="stat-label">FPS</div>
<el-tag size="large" type="warning">{{ stats.fps }}</el-tag>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 行为告警 -->
<el-card v-if="alerts && alerts.length > 0" class="alerts-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<el-icon class="header-icon"><Warning /></el-icon>
<span>行为告警</span>
<el-tag size="small" type="danger" class="alert-count">{{ alerts.length }} </el-tag>
</div>
</div>
</template>
<div class="alerts-container">
<div
v-for="(alert, index) in alerts"
:key="index"
class="alert-item"
>
<div class="alert-header">
<el-tag :type="alert.type === 'stationary' ? 'warning' : 'danger'" size="small">
{{ alert.type === 'stationary' ? '静止' : '徘徊' }}
</el-tag>
<span class="alert-time">{{ new Date(alert.timestamp * 1000).toLocaleTimeString('zh-CN') }}</span>
</div>
<div class="alert-detail">
<span class="alert-message">{{ alert.message }}</span>
<span v-if="alert.duration" class="alert-duration">持续: {{ alert.duration.toFixed(1) }}s</span>
</div>
<div v-if="alert.bbox" class="alert-bbox">
<code class="bbox-code">[{{ alert.bbox.join(', ') }}]</code>
</div>
</div>
</div>
</el-card>
<!-- 检测详情 -->
<el-card v-if="detections.length > 0" class="details-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><List /></el-icon>
<span>检测详情</span>
</div>
</template>
<el-table :data="detections" border class="details-table">
<el-table-column prop="label" label="类别" min-width="120" />
<el-table-column prop="confidence" label="置信度" width="120">
<template #default="scope">
<el-tag :type="getConfidenceType(scope.row.confidence)">
{{ scope.row.confidence }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="bbox" label="位置" min-width="200">
<template #default="scope">
<code class="bbox-code">[{{ scope.row.bbox.join(', ') }}]</code>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 检测日志 -->
<el-card v-if="detectionLogs.length > 0" class="logs-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="header-left">
<el-icon class="header-icon"><Timer /></el-icon>
<span>检测日志</span>
<el-tag size="small" type="info" class="log-count">{{ detectionLogs.length }} </el-tag>
</div>
<el-button type="danger" size="small" @click="clearLogs" plain>
<el-icon><Delete /></el-icon>
清空日志
</el-button>
</div>
</template>
<div class="logs-container">
<el-timeline>
<el-timeline-item
v-for="log in detectionLogs.slice(0, 20)"
:key="log.id"
:type="getConfidenceType(log.confidence)"
:timestamp="log.timestamp"
placement="top"
>
<div class="log-item">
<div class="log-header">
<el-tag :type="getConfidenceType(log.confidence)" size="small">
{{ log.label }}
</el-tag>
<span class="log-confidence">置信度: {{ log.confidence }}</span>
</div>
<div class="log-detail">
<span class="log-model">模型: {{ log.model }}</span>
<code class="log-bbox">[{{ log.bbox.join(', ') }}]</code>
</div>
</div>
</el-timeline-item>
</el-timeline>
<div v-if="detectionLogs.length > 20" class="logs-more">
<el-tag type="info" size="small">还有 {{ detectionLogs.length - 20 }} 条记录...</el-tag>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
VideoCamera,
Setting,
VideoPlay,
VideoPause,
View,
DataLine,
List,
QuestionFilled,
Timer,
Delete
} from '@element-plus/icons-vue'
import AlgorithmConfig from './AlgorithmConfig.vue'
const props = defineProps({
models: {
type: Array,
default: () => []
}
})
const config = ref({
model: props.models.length > 0 ? props.models[0].id : 'fire_detection',
confidence: 0.5,
iou: 0.45,
algorithmConfig: {}
})
// 可拖拽调整宽度相关
const leftPanelWidth = ref(320)
const isResizing = ref(false)
const startX = ref(0)
const startWidth = ref(0)
const startResize = (e) => {
isResizing.value = true
startX.value = e.clientX
startWidth.value = leftPanelWidth.value
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
}
const handleResize = (e) => {
if (!isResizing.value) return
const delta = e.clientX - startX.value
const newWidth = startWidth.value + delta
leftPanelWidth.value = Math.max(280, Math.min(500, newWidth))
}
const stopResize = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
}
const cameraConnected = ref(false)
const cameraStarting = ref(false)
const originalCameraFrame = ref('')
const resultCameraFrame = ref('')
const detections = ref([])
const stats = ref(null)
const alerts = ref([])
const websocket = ref(null)
// 检测日志
const detectionLogs = ref([])
const maxLogs = 100 // 最多保留100条日志
// 添加检测日志
const addDetectionLog = (detections, stats) => {
if (!detections || detections.length === 0) return
const now = new Date()
const timeStr = now.toLocaleTimeString('zh-CN')
detections.forEach(detection => {
const logEntry = {
id: Date.now() + Math.random(),
timestamp: timeStr,
datetime: now,
label: detection.label,
confidence: detection.confidence,
bbox: detection.bbox,
model: stats?.model_used || config.value.model,
totalDetections: stats?.total_detections || detections.length
}
detectionLogs.value.unshift(logEntry)
// 限制日志数量
if (detectionLogs.value.length > maxLogs) {
detectionLogs.value = detectionLogs.value.slice(0, maxLogs)
}
})
}
// 清空日志
const clearLogs = () => {
detectionLogs.value = []
ElMessage.success('日志已清空')
}
const formatConfidence = (value) => {
return `置信度: ${value.toFixed(2)}`
}
const formatIOU = (value) => {
return `IOU: ${value.toFixed(2)}`
}
const toggleCamera = async () => {
if (cameraConnected.value) {
stopCamera()
} else {
await startCamera()
}
}
const startCamera = async () => {
cameraStarting.value = true
try {
websocket.value = new WebSocket('ws://localhost:8000/ws/camera')
websocket.value.onopen = () => {
console.log('WebSocket connected')
cameraConnected.value = true
cameraStarting.value = false
const startConfig = {
action: 'start',
config: {
model_id: config.value.model,
confidence: config.value.confidence,
iou: config.value.iou
}
}
// 添加算法配置
if (config.value.algorithmConfig && Object.keys(config.value.algorithmConfig).length > 0) {
startConfig.config.algorithm_config = config.value.algorithmConfig
}
websocket.value.send(JSON.stringify(startConfig))
}
websocket.value.onmessage = (event) => {
const message = JSON.parse(event.data)
console.log('WebSocket message received:', message)
if (message.type === 'original_frame') {
originalCameraFrame.value = message.frame
} else if (message.type === 'annotated_frame') {
resultCameraFrame.value = message.frame
} else if (message.type === 'detection') {
console.log('Detection result:', message)
detections.value = message.detections
stats.value = message.stats
// 添加检测日志
addDetectionLog(message.detections, message.stats)
} else if (message.type === 'error') {
ElMessage.error(message.message)
} else if (message.type === 'camera_started') {
ElMessage.success(message.message)
}
}
websocket.value.onerror = (error) => {
console.error('WebSocket error:', error)
ElMessage.error('摄像头连接失败')
cameraStarting.value = false
cameraConnected.value = false
}
websocket.value.onclose = () => {
console.log('WebSocket closed')
cameraConnected.value = false
}
} catch (error) {
ElMessage.error('启动摄像头失败')
cameraStarting.value = false
cameraConnected.value = false
}
}
const stopCamera = () => {
if (websocket.value) {
websocket.value.send(JSON.stringify({ action: 'stop' }))
websocket.value.close()
websocket.value = null
}
cameraConnected.value = false
originalCameraFrame.value = ''
resultCameraFrame.value = ''
ElMessage.success('摄像头已停止')
}
const updateCameraConfig = () => {
if (websocket.value && cameraConnected.value) {
const wsConfig = {
action: 'update_config',
config: {
model_id: config.value.model,
confidence: config.value.confidence,
iou: config.value.iou
}
}
// 添加算法配置
if (config.value.algorithmConfig && Object.keys(config.value.algorithmConfig).length > 0) {
wsConfig.config.algorithm_config = config.value.algorithmConfig
}
websocket.value.send(JSON.stringify(wsConfig))
}
}
const onAlgorithmChange = (algoConfig) => {
config.value.algorithmConfig = algoConfig
// 如果摄像头已连接,实时更新配置
if (websocket.value && cameraConnected.value) {
updateCameraConfig()
}
}
const getConfidenceType = (confidence) => {
if (!confidence && confidence !== 0) return 'info'
if (confidence >= 0.8) return 'success'
if (confidence >= 0.6) return 'warning'
return 'danger'
}
const modelName = computed(() => {
const model = props.models.find(m => m.id === config.value.model)
return model ? model.name : config.value.model
})
onUnmounted(() => {
if (websocket.value) {
websocket.value.close()
}
})
</script>
<style scoped>
.video-detection-container {
display: flex;
width: 100%;
height: calc(100vh - 100px);
gap: 0;
}
.left-panel {
flex-shrink: 0;
overflow: hidden;
}
.left-panel .config-card {
height: 100%;
overflow-y: auto;
}
/* 拖拽调整条 */
.resize-handle {
width: 8px;
flex-shrink: 0;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
transition: background 0.2s;
position: relative;
}
.resize-handle:hover {
background: #d0d0d0;
}
.resize-handle.resizing {
background: #409eff;
cursor: col-resize;
}
.resize-indicator {
width: 3px;
height: 40px;
background: #c0c4cc;
border-radius: 2px;
transition: background 0.2s;
}
.resize-handle:hover .resize-indicator,
.resize-handle.resizing .resize-indicator {
background: #409eff;
}
.right-panel {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* 卡片通用样式 */
:deep(.el-card) {
border-radius: 12px;
border: none;
transition: all 0.3s ease;
}
:deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid #e4e7ed;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px 12px 0 0;
}
/* 卡片头部样式 */
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 16px;
color: #fff;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
font-size: 18px;
}
/* 配置卡片样式 */
.config-card {
height: auto;
max-height: calc(100vh - 100px);
overflow-y: auto;
}
.config-card :deep(.el-card__body) {
padding: 24px;
}
.config-form :deep(.el-form-item) {
margin-bottom: 20px;
}
.config-form :deep(.el-form-item__label) {
font-weight: 500;
font-size: 14px;
color: #303133;
padding-bottom: 8px;
}
.full-width {
width: 100%;
}
/* 模型选择器 */
.model-size {
float: right;
color: #909399;
font-size: 12px;
}
/* 滑块样式 */
.slider-value {
text-align: center;
font-size: 14px;
font-weight: 600;
color: #409eff;
margin-top: 8px;
padding: 6px 12px;
background: #ecf5ff;
border-radius: 20px;
display: inline-block;
min-width: 60px;
}
/* 帮助图标样式 */
.help-icon {
margin-left: 6px;
font-size: 14px;
color: #909399;
cursor: pointer;
transition: color 0.2s;
}
.help-icon:hover {
color: #409eff;
}
/* 视频展示区域 */
.video-card {
margin-bottom: 20px;
}
.result-card {
margin-bottom: 20px;
}
.video-card :deep(.el-card__body) {
padding: 0;
}
.video-container {
width: 100%;
aspect-ratio: 16 / 9;
min-height: 400px;
max-height: 600px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
border-radius: 0 0 12px 12px;
overflow: hidden;
position: relative;
}
.display-video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.placeholder {
text-align: center;
color: #909399;
padding: 40px 20px;
}
.placeholder-icon {
font-size: 64px;
color: #dcdfe6;
margin-bottom: 16px;
}
.placeholder p {
font-size: 14px;
margin: 0;
}
/* 空状态占位区域 */
.empty-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #909399;
}
.empty-icon {
font-size: 64px;
color: #dcdfe6;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
color: #606266;
margin: 0 0 8px 0;
}
.empty-hint {
font-size: 13px;
color: #909399;
margin: 0;
}
/* 统计卡片 */
.stats-card {
margin-bottom: 20px;
height: calc(100% - 20px);
}
.stats-card :deep(.el-card__body) {
padding: 20px;
}
.stats-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: #f5f7fa;
border-radius: 8px;
}
.stat-label {
font-size: 13px;
color: #606266;
font-weight: 500;
}
.stat-item :deep(.el-tag) {
font-size: 14px;
font-weight: 500;
align-self: flex-start;
}
/* 详情卡片 */
.details-card {
margin-bottom: 20px;
}
.details-card :deep(.el-card__body) {
padding: 0;
}
.details-table :deep(.el-table__header) {
background: #f5f7fa;
}
.details-table :deep(.el-table__header th) {
background: #f5f7fa;
font-weight: 600;
color: #303133;
}
.bbox-code {
background: #f5f7fa;
padding: 4px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #606266;
}
/* 日志卡片 */
.logs-card {
margin-bottom: 20px;
}
.logs-card :deep(.el-card__header) {
padding: 12px 20px;
}
.logs-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-count {
margin-left: 8px;
}
.logs-container {
max-height: 400px;
overflow-y: auto;
padding: 16px;
}
.log-item {
background: #f5f7fa;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
}
.log-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.log-confidence {
font-size: 13px;
color: #606266;
}
.log-detail {
display: flex;
flex-direction: column;
gap: 4px;
}
.log-model {
font-size: 12px;
color: #909399;
}
.log-bbox {
font-size: 11px;
color: #606266;
background: #fff;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.logs-more {
text-align: center;
padding: 12px;
}
/* 告警卡片 */
.alerts-card {
margin-bottom: 20px;
border: 2px solid #f56c6c;
}
.alerts-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.alert-count {
margin-left: 8px;
}
.alerts-container {
max-height: 400px;
overflow-y: auto;
padding: 16px;
}
.alert-item {
background: #fef0f0;
border-left: 4px solid #f56c6c;
padding: 12px;
border-radius: 4px;
margin-bottom: 12px;
}
.alert-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.alert-time {
font-size: 12px;
color: #909399;
}
.alert-detail {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.alert-message {
font-size: 14px;
color: #f56c6c;
font-weight: 500;
}
.alert-duration {
font-size: 13px;
color: #606266;
background: #fff;
padding: 2px 8px;
border-radius: 4px;
}
.alert-bbox {
font-size: 12px;
color: #606266;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
/* 响应式布局 */
@media (max-width: 768px) {
.video-detection-container {
flex-direction: column;
height: auto;
}
.left-panel {
width: 100% !important;
}
.config-card {
max-height: none;
margin-bottom: 16px;
}
.resize-handle {
display: none;
}
.right-panel {
width: 100% !important;
}
.video-container {
aspect-ratio: 4 / 3;
min-height: 200px;
}
}
@media (max-width: 480px) {
.video-container {
aspect-ratio: 1 / 1;
min-height: 180px;
}
}
</style>