1
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user