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

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

788 lines
19 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="image-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"
>
<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>小于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"
/>
<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=" 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"
/>
<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="image-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-upload
:action="uploadUrl"
:on-success="handleUploadSuccess"
:before-upload="beforeUpload"
:show-file-list="false"
accept="image/*"
class="header-upload"
>
<el-button type="primary" size="small">
<el-icon><UploadFilled /></el-icon>
<span>{{ resultImage ? '上传新图片' : '上传图片' }}</span>
</el-button>
</el-upload>
</div>
</template>
<div class="image-container">
<img
v-if="resultImage"
:src="resultImage"
class="display-image"
alt="检测结果"
/>
<div v-else class="empty-placeholder">
<el-icon class="empty-icon"><Picture /></el-icon>
<p class="empty-text">请上传图片进行检测</p>
<p class="empty-hint">支持 JPGPNGWEBP 格式</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>
</el-card>
</el-col>
</el-row>
<!-- 检测详情 -->
<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>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import {
UploadFilled,
Picture,
Document,
Setting,
View,
DataLine,
List,
QuestionFilled
} from '@element-plus/icons-vue'
import { detectionApi } from '@/api/detection'
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 originalImage = ref('')
const resultImage = ref('')
const detections = ref([])
const stats = ref(null)
const uploadUrl = computed(() => {
const params = new URLSearchParams({
model_id: config.value.model,
confidence: config.value.confidence,
iou: config.value.iou
})
// 添加算法配置
if (config.value.algorithmConfig && Object.keys(config.value.algorithmConfig).length > 0) {
params.append('algorithm_config', JSON.stringify(config.value.algorithmConfig))
}
return `/api/detect/image?${params.toString()}`
})
const formatConfidence = (value) => {
return `置信度: ${value.toFixed(2)}`
}
const formatIOU = (value) => {
return `IOU: ${value.toFixed(2)}`
}
const beforeUpload = (file) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件')
return false
}
originalImage.value = URL.createObjectURL(file)
return true
}
const handleUploadSuccess = (response) => {
console.log('Upload success response:', response)
if (response.success) {
// 使用 base64 图片数据
if (response.data.image_base64) {
resultImage.value = `data:image/jpeg;base64,${response.data.image_base64}`
console.log('Result image set, length:', response.data.image_base64.length)
} else {
console.error('No image_base64 in response:', response.data)
}
detections.value = response.data.detections || []
stats.value = response.data.stats
// 处理告警信息
if (response.data.alerts && response.data.alerts.length > 0) {
alerts.value = response.data.alerts
console.log('收到告警:', response.data.alerts)
// 显示告警通知
response.data.alerts.forEach(alert => {
ElMessage({
message: `行为告警: ${alert.type} - ${alert.message}`,
type: 'warning',
duration: 3000
})
})
}
ElMessage.success('检测完成')
} else {
ElMessage.error(response.message)
}
}
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
})
const onAlgorithmChange = (algoConfig) => {
config.value.algorithmConfig = algoConfig
}
</script>
<style scoped>
.image-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;
}
.header-upload :deep(.el-upload) {
display: block;
}
/* 配置卡片样式 */
.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;
}
/* 空状态占位区域 */
.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;
}
/* 图片展示区域 */
.image-card {
margin-bottom: 20px;
}
.result-card {
margin-bottom: 20px;
}
.image-card :deep(.el-card__body) {
padding: 0;
}
.image-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-image {
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;
}
/* 统计卡片 */
.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;
}
/* 帮助图标样式 */
.help-icon {
margin-left: 6px;
font-size: 14px;
color: #909399;
cursor: pointer;
transition: color 0.2s;
}
.help-icon:hover {
color: #409eff;
}
/* 告警卡片 */
.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) {
.image-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;
}
.image-container {
aspect-ratio: 4 / 3;
min-height: 200px;
}
.stats-descriptions :deep(.el-descriptions__body) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stats-descriptions :deep(.el-descriptions__cell) {
padding: 12px;
}
}
@media (max-width: 480px) {
.image-container {
aspect-ratio: 1 / 1;
min-height: 180px;
}
}
</style>