feat: 新增人员徘徊/静止行为分析功能

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

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

View File

@@ -9,12 +9,22 @@ export const detectionApi = {
getModels() {
return api.get('/models')
},
detectImage(formData) {
getAlgorithmConfig() {
return api.get('/algorithms/config')
},
detectImage(formData, algorithmConfig = null) {
const params = {}
if (algorithmConfig) {
params.algorithm_config = JSON.stringify(algorithmConfig)
}
return api.post('/detect/image', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
},
params
})
}
}

View File

@@ -0,0 +1,326 @@
<template>
<div v-if="showConfig" class="algorithm-config">
<el-divider content-position="left">
<el-icon><Cpu /></el-icon>
<span style="margin-left: 8px;">行为分析算法</span>
</el-divider>
<div v-if="loading" class="loading-wrapper">
<el-skeleton :rows="3" animated />
</div>
<div v-else-if="algorithms.length === 0" class="empty-config">
<el-empty description="暂无可配置算法" :image-size="60" />
</div>
<div v-else class="algorithm-list">
<div
v-for="algo in algorithms"
:key="algo.id"
class="algorithm-item"
>
<div class="algorithm-header">
<el-switch
v-model="config[algo.id].enabled"
@change="onConfigChange"
:active-text="algo.name"
/>
<el-tooltip :content="algo.description" placement="top">
<el-icon class="info-icon"><InfoFilled /></el-icon>
</el-tooltip>
</div>
<div v-if="config[algo.id].enabled" class="algorithm-params">
<div
v-for="param in algo.params"
:key="param.name"
class="param-item"
>
<div class="param-label">
{{ param.label }}
<el-tooltip :content="param.description" placement="top">
<el-icon class="help-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="param-control">
<el-slider
v-if="param.type === 'number'"
v-model="config[algo.id].params[param.name]"
:min="param.min"
:max="param.max"
:step="param.name.includes('threshold') ? 1 : 1"
@change="onConfigChange"
show-input
:show-input-controls="false"
input-size="small"
/>
</div>
<div class="param-unit">{{ param.unit }}</div>
</div>
</div>
</div>
</div>
<div class="config-actions">
<el-button size="small" @click="resetConfig" :icon="RefreshRight">
重置
</el-button>
<el-button type="primary" size="small" @click="applyConfig" :icon="Check">
应用
</el-button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { ElMessage } from 'element-plus'
import {
Cpu,
InfoFilled,
QuestionFilled,
RefreshRight,
Check
} from '@element-plus/icons-vue'
import { detectionApi } from '@/api/detection'
const props = defineProps({
modelValue: {
type: Object,
default: () => ({})
},
modelId: {
type: String,
default: ''
}
})
// 支持行为分析的模型列表
const SUPPORTED_MODELS = [
'loitering_detection', // 徘徊检测
'crowd_detection', // 人群检测
'person_detection' // 人员检测
]
// 是否显示配置
const showConfig = computed(() => {
return SUPPORTED_MODELS.some(model => props.modelId.includes(model))
})
const emit = defineEmits(['update:modelValue', 'change'])
const loading = ref(false)
const algorithms = ref([])
const config = reactive({})
// 获取算法配置
const fetchAlgorithmConfig = async () => {
loading.value = true
try {
const response = await detectionApi.getAlgorithmConfig()
algorithms.value = response.data.algorithms || []
// 初始化配置
algorithms.value.forEach(algo => {
if (!config[algo.id]) {
config[algo.id] = {
enabled: false,
params: {}
}
}
// 设置默认参数
algo.params.forEach(param => {
if (config[algo.id].params[param.name] === undefined) {
config[algo.id].params[param.name] = param.default
}
})
})
} catch (error) {
console.error('获取算法配置失败:', error)
} finally {
loading.value = false
}
}
// 生成后端需要的配置格式
const generateConfig = () => {
const result = {}
algorithms.value.forEach(algo => {
const algoConfig = config[algo.id]
if (algoConfig && algoConfig.enabled) {
// 根据算法类型设置启用标志
if (algo.id === 'stationary_detection') {
result.enable_stationary_detection = true
} else if (algo.id === 'loitering_detection') {
result.enable_loitering_detection = true
}
// 添加参数
Object.entries(algoConfig.params).forEach(([key, value]) => {
result[key] = value
})
}
})
return result
}
// 配置变化
const onConfigChange = () => {
const backendConfig = generateConfig()
emit('update:modelValue', backendConfig)
emit('change', backendConfig)
}
// 重置配置
const resetConfig = () => {
algorithms.value.forEach(algo => {
config[algo.id] = {
enabled: false,
params: {}
}
algo.params.forEach(param => {
config[algo.id].params[param.name] = param.default
})
})
onConfigChange()
ElMessage.success('配置已重置')
}
// 应用配置
const applyConfig = () => {
onConfigChange()
ElMessage.success('配置已应用')
}
// 监听外部配置变化
watch(() => props.modelValue, (newVal) => {
if (newVal && Object.keys(newVal).length > 0) {
// 根据外部配置更新内部状态
if (newVal.enable_stationary_detection) {
config['stationary_detection'].enabled = true
}
if (newVal.enable_loitering_detection) {
config['loitering_detection'].enabled = true
}
// 更新参数
Object.entries(newVal).forEach(([key, value]) => {
algorithms.value.forEach(algo => {
if (config[algo.id].params[key] !== undefined) {
config[algo.id].params[key] = value
}
})
})
}
}, { deep: true })
onMounted(() => {
fetchAlgorithmConfig()
})
</script>
<style scoped>
.algorithm-config {
margin-top: 16px;
}
.loading-wrapper {
padding: 20px;
}
.empty-config {
padding: 20px;
}
.algorithm-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.algorithm-item {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 12px;
background: #fafafa;
}
.algorithm-header {
display: flex;
align-items: center;
gap: 8px;
}
.algorithm-header :deep(.el-switch__label) {
font-weight: 500;
}
.info-icon {
color: #909399;
cursor: help;
font-size: 14px;
}
.algorithm-params {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #dcdfe6;
}
.param-item {
margin-bottom: 12px;
}
.param-item:last-child {
margin-bottom: 0;
}
.param-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 4px;
}
.help-icon {
color: #c0c4cc;
cursor: help;
font-size: 12px;
}
.param-control {
display: flex;
align-items: center;
gap: 8px;
}
.param-control :deep(.el-slider) {
flex: 1;
}
.param-control :deep(.el-slider__input) {
width: 60px;
}
.param-unit {
font-size: 12px;
color: #909399;
min-width: 40px;
}
.config-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e4e7ed;
}
</style>

View File

@@ -93,6 +93,13 @@
<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>
@@ -225,6 +232,7 @@ import {
QuestionFilled
} from '@element-plus/icons-vue'
import { detectionApi } from '@/api/detection'
import AlgorithmConfig from './AlgorithmConfig.vue'
const props = defineProps({
models: {
@@ -236,7 +244,8 @@ const props = defineProps({
const config = ref({
model: props.models.length > 0 ? props.models[0].id : 'fire_detection',
confidence: 0.5,
iou: 0.45
iou: 0.45,
algorithmConfig: {}
})
// 可拖拽调整宽度相关
@@ -271,7 +280,20 @@ const originalImage = ref('')
const resultImage = ref('')
const detections = ref([])
const stats = ref(null)
const uploadUrl = computed(() => `/api/detect/image?model_id=${config.value.model}&confidence=${config.value.confidence}&iou=${config.value.iou}`)
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)}`
@@ -303,6 +325,22 @@ const handleUploadSuccess = (response) => {
}
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)
@@ -320,6 +358,10 @@ 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>
@@ -624,6 +666,78 @@ const modelName = computed(() => {
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 {

View File

@@ -95,6 +95,13 @@
/>
<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>
@@ -186,6 +193,40 @@
</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>
@@ -273,6 +314,7 @@ import {
Timer,
Delete
} from '@element-plus/icons-vue'
import AlgorithmConfig from './AlgorithmConfig.vue'
const props = defineProps({
models: {
@@ -284,7 +326,8 @@ const props = defineProps({
const config = ref({
model: props.models.length > 0 ? props.models[0].id : 'fire_detection',
confidence: 0.5,
iou: 0.45
iou: 0.45,
algorithmConfig: {}
})
// 可拖拽调整宽度相关
@@ -321,6 +364,7 @@ const originalCameraFrame = ref('')
const resultCameraFrame = ref('')
const detections = ref([])
const stats = ref(null)
const alerts = ref([])
const websocket = ref(null)
// 检测日志
@@ -387,14 +431,21 @@ const startCamera = async () => {
cameraConnected.value = true
cameraStarting.value = false
websocket.value.send(JSON.stringify({
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) => {
@@ -450,14 +501,29 @@ const stopCamera = () => {
const updateCameraConfig = () => {
if (websocket.value && cameraConnected.value) {
websocket.value.send(JSON.stringify({
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()
}
}
@@ -847,6 +913,78 @@ onUnmounted(() => {
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 {