This commit is contained in:
2026-09-07 15:04:11 +08:00
parent f4fe079703
commit c58f69cbe1
11 changed files with 432 additions and 65 deletions
+214 -49
View File
@@ -1,5 +1,5 @@
<script setup>
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { EditPen, Loading, Picture, Plus, VideoPlay } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
@@ -49,8 +49,44 @@ const pageTab = ref('datasets') // datasets 数据集卡片 | negatives 负样
const negLib = ref(null) // 负样本库数据集记录 {id, name, imageCount}
const negList = ref([]) // 图片列表 [{id, filename, url}]
const negLoading = ref(false)
const negUploading = ref(false)
const negSelected = ref(new Set()) // 勾选的图片 id
// 分页与详情页同款(客户端分页;每页条数 localStorage 持久化)
const negPage = ref(1)
const pageSizeOptions = [10, 20, 30, 50, 100]
const savedNegSize = Number(localStorage.getItem('negImagePageSize'))
const negPageSize = ref(pageSizeOptions.includes(savedNegSize) ? savedNegSize : 20)
const pagedNegList = computed(() => {
const start = (negPage.value - 1) * negPageSize.value
return negList.value.slice(start, start + negPageSize.value)
})
const allNegSelected = computed(
() => pagedNegList.value.length > 0 && pagedNegList.value.every((img) => negSelected.value.has(img.id)),
)
const negViewerUrl = ref('') // 非空 = 显示大图查看器
// 翻页清空勾选(与详情页一致,防跨页误删);列表缩水时页码回钳
watch(negPage, () => {
negSelected.value = new Set()
})
watch(negPageSize, (v) => {
localStorage.setItem('negImagePageSize', String(v))
negPage.value = 1
negSelected.value = new Set()
})
watch(negList, (list) => {
const max = Math.max(1, Math.ceil(list.length / negPageSize.value))
if (negPage.value > max) negPage.value = max
})
function toggleNegSelectAll() {
const s = new Set(negSelected.value)
if (allNegSelected.value) {
pagedNegList.value.forEach((img) => s.delete(img.id))
} else {
pagedNegList.value.forEach((img) => s.add(img.id))
}
negSelected.value = s
}
function negImageUrl(filename) {
return `${location.origin}/api/v1/admin/datasets/image?datasetId=${negLib.value.id}&filename=${encodeURIComponent(filename)}&token=${encodeURIComponent(localStorage.getItem('adminToken') || '')}`
@@ -59,7 +95,7 @@ function negImageUrl(filename) {
// 懒创建 + 拉图片列表(进入负样本 tab 时调用;库不存在后端自动创建)
function loadNegatives() {
negLoading.value = true
request
return request
.post('/datasets/negative')
.then((lib) => {
negLib.value = lib
@@ -76,29 +112,69 @@ function loadNegatives() {
function switchTab(tab) {
pageTab.value = tab
if (tab === 'negatives' && !negLib.value) loadNegatives()
if (tab === 'negatives') {
loadNegatives().then(loadNegGenTask)
}
}
const fileInput = ref(null)
function pickFiles() {
fileInput.value?.click()
// ---------- 负样本批量生成(2026-09-07):config.yml 内置场景池按序循环 + RF-DETR 空检自动剔除,
// 复用 gen_task 进度(datasetId=负样本库),约 40s/张 ----------
const negGenVisible = ref(false)
const negGenCount = ref(100)
const negGenSubmitting = ref(false)
const negGenTask = ref(null) // {status,total,done,rejected,error}
const negGenPoll = ref(null)
function openNegGen() {
negGenCount.value = 100
negGenVisible.value = true
}
async function onFilesPicked(e) {
const files = Array.from(e.target.files || [])
e.target.value = ''
if (!files.length || !negLib.value) return
negUploading.value = true
try {
const fd = new FormData()
fd.append('datasetId', negLib.value.id)
for (const f of files) fd.append('files', f)
const res = await request.post('/datasets/upload', fd, { timeout: 300000 })
ElMessage.success(`已上传 ${res.added}${res.skipped ? `,重名跳过 ${res.skipped}` : ''}`)
loadNegatives()
} catch {
} finally {
negUploading.value = false
}
function submitNegGen() {
negGenSubmitting.value = true
request
.post('/datasets/negative/generate', { count: negGenCount.value }, { timeout: 60000 })
.then((res) => {
negGenVisible.value = false
negGenTask.value = { status: 'running', total: res.total, done: 0, rejected: 0, error: '' }
startNegGenPoll()
ElMessage.success('生成任务已发起,进度见本页进度条')
})
.catch(() => {})
.finally(() => {
negGenSubmitting.value = false
})
}
function loadNegGenTask() {
if (!negLib.value) return Promise.resolve()
return request
.get('/datasets/gen-task', { params: { datasetId: negLib.value.id } })
.then((t) => {
negGenTask.value = t
if (t && t.status === 'running') startNegGenPoll()
})
.catch(() => {})
}
function startNegGenPoll() {
if (negGenPoll.value) return
negGenPoll.value = setInterval(async () => {
const t = await request
.get('/datasets/gen-task', { params: { datasetId: negLib.value.id } })
.catch(() => null)
negGenTask.value = t
if (!t || t.status !== 'running') {
clearInterval(negGenPoll.value)
negGenPoll.value = null
loadNegatives()
if (t && t.status === 'done') ElMessage.success(`生成完成:入库 ${t.total - t.rejected} 张,空检剔除 ${t.rejected}`)
if (t && t.status === 'failed') ElMessage.error(`生成失败:${t.error}`)
}
}, 5000)
}
function removeNegSelected() {
@@ -490,6 +566,7 @@ onMounted(() => {
onBeforeUnmount(() => {
if (trainPoll) clearInterval(trainPoll)
if (negGenPoll.value) clearInterval(negGenPoll.value)
})
</script>
@@ -603,36 +680,81 @@ onBeforeUnmount(() => {
</div>
</template>
<!-- 负样本 tab上传/删除/网格无标注与训练流程训练打包自动混入 -->
<!-- 负样本 tab与数据集详情页同款卡片网格点击放大/多选删除/同款分页 AI 生成无本地上传 -->
<template v-else>
<div class="toolbar">
<div class="neg-tip">
负样本 = 不含目标动物的背景图草地/树丛/误报源如猫狗树桩等训练打包时统一混入全部物种数据集
负样本 = 不含目标动物的背景图内置场景池 AI 生成 + 真实随手拍训练打包时统一混入全部物种数据集
空标签 = 背景教会模型这不是目标动物无需任何标注
</div>
<div class="toolbar-right">
<input ref="fileInput" type="file" accept=".jpg,.jpeg,.png" multiple hidden @change="onFilesPicked" />
<el-button type="primary" :icon="Plus" :loading="negUploading" @click="pickFiles">上传图片</el-button>
<el-button @click="toggleNegSelectAll">{{ allNegSelected ? '取消全选' : '全选' }}</el-button>
<el-button type="success" plain :loading="negGenSubmitting" @click="openNegGen">AI 生成</el-button>
<el-button type="danger" :disabled="!negSelected.size" @click="removeNegSelected">
删除选中{{ negSelected.size ? ` ${negSelected.size} ` : '' }}
</el-button>
<el-button :loading="negLoading" @click="loadNegatives">刷新</el-button>
</div>
</div>
<div v-loading="negLoading" class="neg-grid">
<div
v-for="img in negList"
:key="img.id"
class="neg-cell"
:class="{ 'is-sel': negSelected.has(img.id) }"
@click="toggleNeg(img.id)"
>
<img :src="img.url" :alt="img.filename" loading="lazy" />
</div>
<el-empty v-if="!negLoading && !negList.length" description="暂无负样本,点击右上角上传" />
<div v-if="negGenTask && negGenTask.status === 'running'" class="neg-progress">
<el-progress
:percentage="negGenTask.total ? Math.min(100, Math.round((negGenTask.done / negGenTask.total) * 100)) : 0"
:stroke-width="10"
/>
<span class="neg-progress-tip">
生成中 {{ negGenTask.done }}/{{ negGenTask.total }} 空检已剔除 {{ negGenTask.rejected }} 40 /
</span>
</div>
<div v-loading="negLoading" class="card-grid">
<div v-for="img in pagedNegList" :key="img.id" class="img-card">
<div class="card-img">
<el-checkbox
class="card-select"
:model-value="negSelected.has(img.id)"
@click.stop
@change="() => toggleNeg(img.id)"
/>
<img :src="img.url" :alt="img.filename" loading="lazy" @click="negViewerUrl = img.url" />
</div>
<div class="card-name" :title="img.filename">{{ img.filename }}</div>
</div>
<el-empty v-if="!negLoading && !negList.length" description="暂无负样本,点击右上角「AI 生成」批量生成" />
</div>
<div class="pager">
<el-pagination
v-model:current-page="negPage"
v-model:page-size="negPageSize"
:page-sizes="pageSizeOptions"
:total="negList.length"
layout="total, sizes, prev, pager, next"
background
/>
</div>
<!-- 点击缩略图放大查看element 全屏查看器 -->
<el-image-viewer
v-if="negViewerUrl"
:url-list="[negViewerUrl]"
@close="negViewerUrl = ''"
/>
</template>
<!-- 负样本批量生成内置场景池 + RF-DETR 空检自动剔除进度在负样本 tab 内展示 -->
<el-dialog v-model="negGenVisible" title="AI 生成负样本" width="min(480px, 94vw)">
<el-form label-width="90px">
<el-form-item label="生成张数">
<el-input-number v-model="negGenCount" :min="1" :max="1000" :step="10" />
</el-form-item>
</el-form>
<p class="start-note">
内置场景池按序循环生成细长轮廓 / 圆拱形 / 高处停息悬挂 / 室内户外空镜 / 混淆动物与正样本同相机风格
每张生成后自动过 RF-DETR 空检有检出的自动剔除不入库 40 /进度见负样本 tab 进度条
</p>
<template #footer>
<el-button @click="negGenVisible = false">取消</el-button>
<el-button type="primary" :loading="negGenSubmitting" @click="submitNegGen">开始生成</el-button>
</template>
</el-dialog>
<!-- 发起训练档位选择默认双档 s+n训练机 GPU 独占串行有任务时新任务排队 queued 逐个执行 -->
<el-dialog v-model="startVisible" title="发起训练" width="min(440px, 94vw)">
<div class="start-dataset">数据集{{ startRow?.name }}</div>
@@ -750,22 +872,65 @@ onBeforeUnmount(() => {
gap: 10px;
min-height: 120px;
}
.neg-cell {
aspect-ratio: 4 / 3;
border: 2px solid transparent;
border-radius: 6px;
/* 负样本卡片:与 DatasetDetail 图片网格同款 */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.img-card {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
}
.card-img {
position: relative;
overflow: hidden;
cursor: pointer;
background: #f5f7fa;
border-radius: 6px;
background: #000;
cursor: zoom-in;
border: 1px solid #dcdfe6;
}
.neg-cell img {
width: 100%;
height: 100%;
object-fit: cover;
.card-img img {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.neg-cell.is-sel {
border-color: var(--el-color-primary);
.card-select {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
padding: 2px 3px;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
}
.card-name {
margin-top: 6px;
font-size: 13px;
color: #303133;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pager {
display: flex;
justify-content: center;
margin-top: 14px;
}
.neg-progress {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.neg-progress-tip {
font-size: 12px;
color: #909399;
white-space: nowrap;
}
.toolbar {
display: flex;