Files
jc-video-recognize/apps/web/src/components/ImageDetection.vue
wwh 1813bc8c0b refactor: 重构图片检测功能,拆分组件并优化返回格式
1. 后端api修改:将图片结果返回从文件路径改为base64格式,移除本地文件存储
2. 新增图片检测组件ImageDetection.vue,封装独立的图片检测UI逻辑
3. 重构Home页面,使用tab切换图片/视频检测模块,简化原有布局
4. 更新web包名与rollup依赖版本
2026-05-18 15:15:29 +08:00

674 lines
16 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>
</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'
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
})
// 可拖拽调整宽度相关
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(() => `/api/detect/image?model_id=${config.value.model}&confidence=${config.value.confidence}&iou=${config.value.iou}`)
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
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
})
</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;
}
/* 响应式布局 */
@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>