feat(web):补充YOLOv8的打架斗殴模型Web和Vue

This commit is contained in:
2026-06-10 14:37:00 +08:00
parent 77bd437fdb
commit 30ea6eb0fb
2 changed files with 368 additions and 14 deletions

View File

@@ -39,13 +39,13 @@
:on-error="handleUploadError"
:before-upload="beforeUpload"
:show-file-list="false"
:accept="isActionDetection ? 'video/*' : 'image/*'"
:accept="supportsVideoUpload ? 'image/*,video/*' : 'image/*'"
:disabled="isDetecting"
class="header-upload"
>
<el-button type="primary" size="small" :loading="isDetecting">
<el-icon><UploadFilled /></el-icon>
<span>{{ resultImage || resultVideo ? '上传新文件' : (isActionDetection ? '上传视频' : '上传图片') }}</span>
<span>{{ resultImage || resultVideo ? '上传新文件' : (supportsVideoUpload ? '上传图片/视频' : '上传图片') }}</span>
</el-button>
</el-upload>
</div>
@@ -53,31 +53,39 @@
<div class="image-container">
<!-- 图片显示 -->
<img
v-if="resultImage && !isActionDetection"
v-if="resultImage && !supportsVideoUpload"
:src="resultImage"
class="display-image"
alt="检测结果"
/>
<!-- 视频显示 -->
<div v-else-if="isActionDetection" class="video-result-container">
<!-- 视频显示 (action_detection / fight_detection) -->
<div v-else-if="supportsVideoUpload" class="video-result-container">
<video
v-if="resultVideo"
v-if="resultVideo && !videoError"
:src="resultVideo"
type="video/mp4"
class="display-video"
controls
alt="检测结果视频"
@error="onVideoError"
/>
<div v-else-if="resultVideo && videoError" class="video-error-hint">
<el-icon class="error-icon"><WarningFilled /></el-icon>
<p class="error-title">视频播放失败</p>
<p class="error-desc">浏览器无法解码该视频可能是编码格式不兼容</p>
<p class="error-desc">请查看下方的关键帧截图直观查看打架画面</p>
</div>
<div v-else-if="isDetecting" class="detection-loading">
<div class="loading-spinner">
<el-icon class="loading-icon"><Loading /></el-icon>
</div>
<p class="loading-text">正在进行打架检测...</p>
<p class="loading-hint">请稍候视频检测可能需要几秒钟</p>
<p class="loading-text">正在进行打架斗殴检测...</p>
<p class="loading-hint">请稍候视频检测可能需要较长时间</p>
</div>
<div v-else class="empty-placeholder">
<el-icon class="empty-icon"><VideoCamera /></el-icon>
<p class="empty-text">请上传视频进行检测</p>
<p class="empty-hint">支持 MP4AVI 格式</p>
<p class="empty-text">请上传图片或视频进行检测</p>
<p class="empty-hint">支持图片 (JPG/PNG) 和视频 (MP4/AVI) 格式</p>
</div>
</div>
<!-- 默认空状态 -->
@@ -136,8 +144,80 @@
</div>
</el-card>
<!-- 检测详情 -->
<el-card v-if="detections.length > 0 && !isActionDetection" class="details-card" shadow="hover">
<!-- 关键帧截图画廊 (打架斗殴检测) -->
<el-card v-if="keyFrames.length > 0 && isFightDetection" class="details-card keyframes-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Camera /></el-icon>
<span>关键帧截图 ( {{ keyFrames.length }} )</span>
<el-tag v-if="videoError" type="warning" size="small" class="codec-warning-tag">
视频播放失败以下截图供直观查看
</el-tag>
</div>
</template>
<div class="keyframes-grid">
<div
v-for="(kf, idx) in keyFrames"
:key="idx"
class="keyframe-item"
@click="previewKeyFrame(kf)"
>
<img :src="kf.image" class="keyframe-img" :alt="`第${kf.frame_index}帧`" />
<div class="keyframe-overlay">
<span class="kf-time">{{ kf.timestamp }}s</span>
<span class="kf-frame">#{{ kf.frame_index }}</span>
</div>
<div class="keyframe-badges">
<el-tag
v-for="(det, dIdx) in kf.detections.slice(0, 2)"
:key="dIdx"
size="small"
:type="isFightClass(det.class) ? 'danger' : 'success'"
class="kf-badge"
>
{{ det.label }} {{ (det.confidence * 100).toFixed(0) }}%
</el-tag>
<el-tag v-if="kf.detections.length > 2" size="small" type="info" class="kf-badge">
+{{ kf.detections.length - 2 }}
</el-tag>
</div>
</div>
</div>
</el-card>
<!-- 打架斗殴 YOLO 视频帧级检测结果 -->
<el-card v-if="fightVideoFrameResults.length > 0 && isFightDetection" class="details-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><List /></el-icon>
<span>帧级检测结果 ( {{ fightVideoFrameResults.length }} 帧检测到目标)</span>
</div>
</template>
<el-table :data="fightVideoFrameResults" border class="details-table" max-height="400">
<el-table-column prop="frame_index" label="帧序号" width="100" />
<el-table-column prop="timestamp" label="时间 (s)" width="120" />
<el-table-column prop="detection_count" label="检测数量" width="120">
<template #default="scope">
<el-tag :type="scope.row.detection_count > 0 ? 'danger' : 'info'">
{{ scope.row.detection_count }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="检测详情" min-width="300">
<template #default="scope">
<div v-for="(det, idx) in scope.row.detections" :key="idx" class="frame-det-item">
<el-tag size="small" :type="isFightClass(det.class) ? 'danger' : 'success'">
{{ det.label }}
</el-tag>
<span class="frame-det-conf">{{ (det.confidence * 100).toFixed(1) }}%</span>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 检测详情图片检测结果 -->
<el-card v-if="detections.length > 0 && !isActionDetection && !isFightDetection" class="details-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><List /></el-icon>
@@ -200,7 +280,7 @@
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
UploadFilled,
Picture,
@@ -210,6 +290,8 @@ import {
List,
Files,
Warning,
WarningFilled,
Camera,
Loading
} from '@element-plus/icons-vue'
import { detectionApi } from '@/api/detection'
@@ -277,15 +359,33 @@ const alerts = ref([])
const isDetecting = ref(false)
const dockerResult = ref(null)
const actionResult = ref(null)
const fightVideoFrameResults = ref([])
const fightVideoStats = ref(null)
const keyFrames = ref([])
const videoError = ref(false)
const isActionDetection = computed(() => {
return config.value.model === 'action_detection'
})
// 打架斗殴 YOLO 模型(支持图片+视频)
const isFightDetection = computed(() => {
return config.value.model === 'fight_detection'
})
// 是否支持视频上传Docker 行为检测 或 本地打架斗殴 YOLO 检测)
const supportsVideoUpload = computed(() => {
return isActionDetection.value || isFightDetection.value
})
const uploadUrl = computed(() => {
if (isActionDetection.value) {
return null
}
// fight_detection 走本地视频检测 API不走 el-upload 直接上传
if (isFightDetection.value) {
return null
}
const params = new URLSearchParams({
model_id: config.value.model,
confidence: config.value.confidence,
@@ -327,6 +427,39 @@ const beforeUpload = async (file) => {
}
return false
} else if (isFightDetection.value) {
// 打架斗殴 YOLO 模型:自动识别图片/视频
const isImage = file.type.startsWith('image/')
const isVideo = file.type.startsWith('video/')
if (!isImage && !isVideo) {
ElMessage.error('请上传图片或视频文件')
isDetecting.value = false
return false
}
if (isVideo) {
// 视频走本地 YOLO 检测 API
const formData = new FormData()
formData.append('file', file)
try {
const response = await detectionApi.detectLocalVideo(
formData,
config.value.model,
config.value.confidence,
config.value.iou
)
handleFightVideoSuccess(response.data)
} catch (error) {
handleUploadError(error)
}
return false
} else {
// 图片走普通图片检测接口
originalImage.value = URL.createObjectURL(file)
return true
}
} else {
const isImage = file.type.startsWith('image/')
if (!isImage) {
@@ -395,12 +528,72 @@ const handleUploadError = (error) => {
if (error.response) {
ElMessage.error(`上传失败: ${error.response.data.message || error.response.status}`)
} else if (error.request) {
ElMessage.error('请求超时或服务器未响应,请检查Docker服务是否正常运行')
ElMessage.error('请求超时或服务器未响应,请检查后端服务是否正常运行')
} else {
ElMessage.error(`上传失败: ${error.message}`)
}
}
const handleFightVideoSuccess = (response) => {
isDetecting.value = false
console.log('Fight video detection response:', response)
if (response.success) {
// 设置标注视频的 URL
resultVideo.value = response.data.video_url
fightVideoFrameResults.value = response.data.frame_results || []
fightVideoStats.value = response.data.stats || null
stats.value = response.data.stats || null
keyFrames.value = response.data.key_frames || []
videoError.value = false
detections.value = []
const kfCount = response.data.key_frames?.length || 0
const detCount = response.data.stats?.total_detections || 0
ElMessage.success(`检测完成:${detCount} 个目标,已提取 ${kfCount} 张关键帧`)
// 如果后端提示编码不兼容,自动标记视频错误
if (response.data.video_codec_warning) {
console.warn('视频编码不兼容浏览器,将以关键帧形式展示')
}
} else {
ElMessage.error(response.message || '视频检测失败')
}
}
const onVideoError = (e) => {
videoError.value = true
console.error('视频播放失败,编码不兼容:', e)
}
// 打架斗殴检测模型的所有危险类别(统一判断)
const FIGHT_DANGER_CLASSES = ['violence', 'fight', 'fighting', 'burglary', 'robbery']
const isFightClass = (className) => {
return FIGHT_DANGER_CLASSES.includes(className)
}
const previewKeyFrame = (kf) => {
const dangerClasses = FIGHT_DANGER_CLASSES
const detectionTags = kf.detections.map(d => {
const danger = dangerClasses.includes(d.class)
const bg = danger ? 'rgba(239,68,68,0.2)' : 'rgba(34,197,94,0.2)'
const color = danger ? '#EF4444' : '#22C55E'
const label = d.label + ' ' + (d.confidence * 100).toFixed(1) + '%'
return '<span style="display:inline-block;margin:2px;padding:2px 8px;border-radius:4px;background:' + bg + ';color:' + color + ';font-size:12px">' + label + '</span>'
}).join(' ')
const html = '<div style="text-align:center">' +
'<img src="' + kf.image + '" style="max-width:100%;border-radius:8px;margin-bottom:12px" />' +
'<div style="color:#94A3B8;font-size:14px">帧 #' + kf.frame_index + ' &nbsp;|&nbsp; 时间 ' + kf.timestamp + 's</div>' +
'<div style="margin-top:8px">' + detectionTags + '</div>' +
'</div>'
ElMessageBox.alert(html, '关键帧预览', {
dangerouslyUseHTMLString: true,
confirmButtonText: '关闭',
customClass: 'keyframe-preview-dialog'
})
}
const getConfidenceType = (confidence) => {
if (!confidence && confidence !== 0) return 'info'
if (confidence >= 0.8) return 'success'
@@ -959,6 +1152,20 @@ const modelName = computed(() => {
gap: 20px;
}
.frame-det-item {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 8px;
margin-bottom: 4px;
}
.frame-det-conf {
font-size: 12px;
color: #94A3B8;
font-family: 'Fira Code', monospace;
}
.result-item {
display: flex;
flex-direction: column;
@@ -1021,4 +1228,131 @@ const modelName = computed(() => {
min-height: 180px;
}
}
/* 视频编码错误提示 */
.video-error-hint {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
text-align: center;
color: #E2E8F0;
background: rgba(15, 23, 42, 0.8);
border-radius: 12px;
border: 1px dashed rgba(239, 68, 68, 0.4);
}
.error-icon {
font-size: 48px;
color: #EF4444;
margin-bottom: 12px;
}
.error-title {
font-size: 16px;
font-weight: 600;
color: #EF4444;
margin-bottom: 8px;
}
.error-desc {
font-size: 13px;
color: #94A3B8;
line-height: 1.6;
margin: 2px 0;
}
/* 关键帧画廊 */
.keyframes-card :deep(.el-card__body) {
padding: 16px;
}
.codec-warning-tag {
margin-left: auto;
}
.keyframes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.keyframe-item {
position: relative;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(15, 23, 42, 0.6);
transition: all 0.3s ease;
}
.keyframe-item:hover {
transform: translateY(-4px);
border-color: rgba(239, 68, 68, 0.4);
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.15);
}
.keyframe-img {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
}
.keyframe-overlay {
position: absolute;
top: 6px;
left: 6px;
display: flex;
gap: 6px;
}
.kf-time,
.kf-frame {
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
color: #F8FAFC;
font-family: 'Fira Code', monospace;
}
.keyframe-badges {
padding: 8px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.kf-badge {
font-size: 11px;
}
/* 关键帧预览弹窗 */
:deep(.keyframe-preview-dialog) {
max-width: 90vw;
background: rgba(15, 23, 42, 0.98);
border: 1px solid rgba(255, 255, 255, 0.1);
}
:deep(.keyframe-preview-dialog .el-message-box__title) {
color: #F8FAFC;
}
:deep(.keyframe-preview-dialog .el-message-box__content) {
padding: 20px;
}
@media (max-width: 768px) {
.keyframes-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.keyframe-img {
height: 90px;
}
}
</style>