feat(web):补充YOLOv8的打架斗殴模型Web和Vue
This commit is contained in:
@@ -10,6 +10,12 @@ const dockerApi = axios.create({
|
||||
timeout: 120000
|
||||
})
|
||||
|
||||
// 视频检测专用,超时更长
|
||||
const videoApi = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 300000
|
||||
})
|
||||
|
||||
export const detectionApi = {
|
||||
getModels() {
|
||||
return api.get('/models')
|
||||
@@ -39,5 +45,19 @@ export const detectionApi = {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 本地 YOLO 视频检测(打架斗殴检测等)
|
||||
detectLocalVideo(formData, modelId = 'fight_detection', confidence = 0.5, iou = 0.45) {
|
||||
return videoApi.post('/detect/video', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
params: {
|
||||
model_id: modelId,
|
||||
confidence,
|
||||
iou
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">支持 MP4、AVI 等格式</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 + ' | 时间 ' + 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>
|
||||
Reference in New Issue
Block a user