This commit is contained in:
2026-09-02 16:28:02 +08:00
parent fcf5947545
commit 9dd90779c1
19 changed files with 1028 additions and 124 deletions
+407 -18
View File
@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Minus, MagicStick, Search } from '@element-plus/icons-vue'
@@ -12,14 +12,34 @@ const dataset = ref(null)
// ---------- 数据 ----------
const detail = ref(null) // 工作台数据:images[{filename,url,width,height,boxes,labeled}]
const imageMeta = ref({}) // filename -> {id, source}(来自 /datasets/images,行内展示用)
const imageMeta = ref({}) // filename -> {id, source, cleanExcluded}(来自 /datasets/images,行内展示用)
const loading = ref(false)
const page = ref(1)
const pageSize = 18
// 列表 tab:未标注(默认,待处理优先) | 已标注 | 已清洗(clean_excluded=1,数据清洗排除,可恢复)——
// 卡片上不再叠「已标注/未标注」角标,分类由 tab 表达(2026-09-02
const listTab = ref('unlabeled')
const tabCounts = computed(() => {
const imgs = detail.value?.images || []
const cleaned = imgs.filter((it) => meta(it).cleanExcluded).length
const labeled = imgs.filter((it) => it.labeled && !meta(it).cleanExcluded).length
return { labeled, cleaned, unlabeled: imgs.length - labeled - cleaned }
})
const visibleImages = computed(() => {
const imgs = detail.value?.images || []
return imgs.filter((it) => {
if (meta(it).cleanExcluded) return listTab.value === 'cleaned'
return (listTab.value === 'labeled') === !!it.labeled
})
})
const pagedImages = computed(() => {
if (!detail.value || !detail.value.images) return []
const start = (page.value - 1) * pageSize
return detail.value.images.slice(start, start + pageSize).map((item, i) => ({ item, gi: start + i }))
return visibleImages.value.slice(start, start + pageSize).map((item, i) => ({ item, gi: start + i }))
})
const emptyTip = computed(() => {
if (!tabCounts.unlabeled && !tabCounts.labeled && !tabCounts.cleaned) return '暂无图片,请上传或 AI 生成'
if (listTab.value === 'cleaned') return '暂无已清洗图片(数据清洗排除的图在此,可勾选恢复)'
return listTab.value === 'unlabeled' ? '暂无未标注图片' : '暂无已标注图片'
})
function meta(item) {
@@ -45,9 +65,18 @@ async function loadAll() {
}
const imgs = await request.get('/datasets/images', { params: { datasetId: datasetId.value } }).catch(() => null)
imageMeta.value = {}
for (const i of imgs?.list || []) imageMeta.value[i.filename] = { id: i.id, source: i.source }
for (const i of imgs?.list || []) imageMeta.value[i.filename] = { id: i.id, source: i.source, cleanExcluded: i.cleanExcluded || 0 }
// 记录刷新前编辑中的图:刷新可能使其移出当前 tab(预标完成/删除/人工保存等),
// 原 editIndex 会漂移到邻图,继续编辑保存会把框存错图——按 filename 重定位,找不到即关弹窗
const editFn = editorVisible.value && currentImage.value ? currentImage.value.filename : null
detail.value = wb || { images: [] }
if (editIndex.value >= (detail.value.images || []).length) editIndex.value = 0
if (editFn) {
const idx = visibleImages.value.findIndex((it) => it.filename === editFn)
if (idx >= 0) editIndex.value = idx
else editorVisible.value = false
} else if (editIndex.value >= visibleImages.value.length) {
editIndex.value = 0
}
// 刷新时恢复进行中的自动标注任务
const tasks = await request.get('/label-tasks', { params: { page: 1, size: 100 } }).catch(() => null)
const running = (tasks?.list || []).find((t) => t.datasetId === datasetId.value && t.status === 'running')
@@ -60,6 +89,9 @@ async function loadAll() {
} else if (gt && gt.error) {
genTask.value = gt
}
} catch (e) {
// 任一请求失败会中断本轮刷新(列表/计数停留旧值),明示原因避免误以为操作未生效
ElMessage.error(`刷新失败:${e?.message || e}`)
} finally {
loading.value = false
}
@@ -168,6 +200,22 @@ function stopLabelPoll() {
// 勾选标注:重扫勾选图片,覆盖各图已有标注(含人工修改/清理的框)。
// 标注入口仅此一处(2026-09-02 决策):无单图/全量按钮,勾选后点顶栏「标注 N 张」。
const selected = ref([])
// 翻页/切 tab 即清空勾选:预标/补标按选中集执行,跨页/跨 tab 残留会导致误标其他分类图片(2026-09-02)
watch(page, () => {
selected.value = []
})
watch(listTab, () => {
page.value = 1
selected.value = []
})
// 当前 tab 列表缩水(删除/标注状态变更后重载)导致页码越界时回钳
watch(
() => visibleImages.value.length,
() => {
const max = Math.max(1, Math.ceil(visibleImages.value.length / pageSize))
if (page.value > max) page.value = max
},
)
// 单选框:受控模式(el-checkbox 单独用 v-model 绑数组会在勾选时把数组替换成布尔值,故手动维护)
function toggleOne(filename, checked) {
const s = selected.value
@@ -204,6 +252,7 @@ function startLabelTask() {
.then(() => request.post('/label-tasks', { datasetId: datasetId.value, filenames: selected.value }))
.then((data) => {
ElMessage.success('已发起预标,进度见页顶进度条')
selected.value = [] // 任务异步跑完才迁移分类,勾选使命已结束,立即清空防「幽灵勾选」残留计数
pollLabelTask(data.id)
})
.catch((e) => {
@@ -242,6 +291,7 @@ async function vlmBatchReview() {
ElMessage.info(note || 'VLM 未发现可能的藏匿位')
}
await loadAll()
selected.value = [] // 补标完成即清勾选:这批已扫描过,避免按钮残留计数重复补检
// 标注弹窗开着时刷新当前图的标注
if (editorVisible.value) nextTick(resetEditor)
} catch (e) {
@@ -273,10 +323,125 @@ function deleteSelected() {
.catch(() => {})
}
// 恢复勾选的已清洗图片(清 clean_excluded 标记,回训练集;「已清洗」tab 专用动作)
function restoreSelected() {
if (!selected.value.length) return
const images = (detail.value.images || []).filter((it) => selected.value.includes(it.filename))
const ids = images.map((it) => meta(it).id).filter(Boolean)
if (!ids.length) return
ElMessageBox.confirm(`确定恢复选中的 ${ids.length} 张图片?恢复后重新计入训练集打包(图片/标注不动)。`, '恢复图片', {
confirmButtonText: '恢复',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => request.post('/datasets/clean/apply', { datasetId: datasetId.value, imageIds: ids, exclude: false }))
.then((d) => {
ElMessage.success(`已恢复 ${d.applied}`)
selected.value = []
loadAll()
})
.catch(() => {})
}
// ---------- 数据清洗(训练集去冗余:按目标尺寸档配额 + 桶内 dHash 多样性保留出候选,
// 排除仅打标记不删文件,被排除图集中在「已清洗」tab,见技术设计.md「数据清洗」) ----------
const cleanVisible = ref(false)
const cleanLoading = ref(false)
const cleanApplying = ref(false)
const cleanBuckets = ref([]) // 档分布[{label,exempt,total,excluded,quota,over}]
const cleanCand = ref([]) // 超配桶候选清单
const cleanTotal = ref(0)
const cleanExcludedCount = ref(0)
const cleanQuotas = ref([]) // 配额输入(与档位一一对应;豁免档禁用)
const quotaTouched = ref(false)
const cleanSel = ref([]) // 勾选的候选 imageId(默认全选)
const candAllChecked = computed(
() => cleanCand.value.length > 0 && cleanCand.value.every((c) => cleanSel.value.includes(c.imageId)),
)
async function loadCleanPreview() {
cleanLoading.value = true
try {
const body =
cleanQuotas.value.length === 8 ? { datasetId: datasetId.value, quotas: cleanQuotas.value } : { datasetId: datasetId.value }
const d = await request.post('/datasets/clean/preview', body)
cleanBuckets.value = d.buckets || []
cleanCand.value = d.candidates || []
cleanTotal.value = d.total || 0
cleanExcludedCount.value = d.excluded || 0
// 首次打开(用户未动过配额)用后端默认回填输入框
if (!quotaTouched.value && cleanQuotas.value.length !== 8) {
cleanQuotas.value = cleanBuckets.value.map((b) => b.quota)
}
cleanSel.value = cleanCand.value.map((c) => c.imageId) // 候选默认全选
} finally {
cleanLoading.value = false
}
}
// 配额改动防抖重算候选(输入数字即实时预览)
let cleanTimer = null
function onQuotaChange() {
quotaTouched.value = true
if (cleanTimer) clearTimeout(cleanTimer)
cleanTimer = setTimeout(loadCleanPreview, 350)
}
function openClean() {
cleanVisible.value = true
cleanQuotas.value = []
quotaTouched.value = false
loadCleanPreview()
}
function toggleCleanAll() {
if (candAllChecked.value) cleanSel.value = []
else cleanSel.value = cleanCand.value.map((c) => c.imageId)
}
// 候选单选框(受控模式同 toggleOne)
function toggleCleanOne(id, checked) {
const s = cleanSel.value
if (checked) {
if (!s.includes(id)) s.push(id)
} else {
const i = s.indexOf(id)
if (i >= 0) s.splice(i, 1)
}
}
function cleanThumbUrl(c) {
return imgUrl(`/api/v1/admin/datasets/image?datasetId=${datasetId.value}&filename=${encodeURIComponent(c.filename)}`)
}
function applyClean() {
if (!cleanSel.value.length) return
ElMessageBox.confirm(
`将排除选中的 ${cleanSel.value.length} 张图片(仅移出训练集打包,不删文件不删标注;被排除图在「已清洗」tab,可随时恢复)。确定排除?`,
'数据清洗',
{ confirmButtonText: '排除', cancelButtonText: '取消', type: 'warning' },
)
.then(() => {
cleanApplying.value = true
return request.post('/datasets/clean/apply', { datasetId: datasetId.value, imageIds: cleanSel.value, exclude: true })
})
.then((d) => {
ElMessage.success(`已排除 ${d.applied} 张,可在「已清洗」tab 查看/恢复`)
cleanVisible.value = false
selected.value = []
loadAll()
})
.catch(() => {})
.finally(() => {
cleanApplying.value = false
})
}
// ---------- 标注编辑弹窗(canvas 画框) ----------
const editorVisible = ref(false)
const editIndex = ref(0)
const currentImage = computed(() => (detail.value && detail.value.images ? detail.value.images[editIndex.value] : null))
// 编辑器导航基于当前 tab 可见列表(visibleImages):tab 内连续标注不串到另一分类
const currentImage = computed(() => visibleImages.value[editIndex.value] || null)
const canvasEl = ref(null)
const canvasWrap = ref(null)
const imgEl = ref(null)
@@ -381,6 +546,7 @@ async function switchEdit(idx) {
}
function resetEditor() {
if (!editorVisible.value) return // 刷新类回调可能已在 loadAll 内关弹窗,避免对空画布操作
stopHighlight()
dirty.value = false
confirmed.value = []
@@ -688,16 +854,22 @@ function clearAllBoxes() {
async function saveCurrent() {
if (!currentImage.value) return
saving.value = true
const fn = currentImage.value.filename
try {
const data = await request.post('/label-tasks/save', {
datasetId: datasetId.value,
filename: currentImage.value.filename,
filename: fn,
boxes: confirmed.value,
})
ElMessage.success(`已保存(该数据集已标注 ${data.labeledCount} 张)`)
dirty.value = false
currentImage.value.boxes = confirmed.value.map((b) => ({ ...b }))
currentImage.value.labeled = confirmed.value.length > 0
// 保存翻转 labeled 后该行会移出当前 tab(未标注→已标注等),visibleImages 收缩使
// currentImage 静默漂移到邻图,继续编辑再保存会把框存到别的图——检测到漂移立即关弹窗
if (!currentImage.value || currentImage.value.filename !== fn) {
editorVisible.value = false
}
} finally {
saving.value = false
}
@@ -747,7 +919,8 @@ onBeforeUnmount(() => {
</el-tag>
</div>
<!-- 图片工具 -->
<!-- 图片工具未标注/已标注 tab 走勾选预标/补标/删除已标注 tab 另有数据清洗入口
已清洗 tabclean_excluded=1工具条改为恢复驱动2026-09-02 -->
<div class="img-toolbar">
<el-button
:disabled="!pagedImages.length"
@@ -755,15 +928,28 @@ onBeforeUnmount(() => {
>{{ allSelected ? '取消全选' : '全选' }}</el-button>
<el-button type="primary" :icon="Plus" :disabled="genRunning" @click="openAdd">添加</el-button>
<el-button
v-if="listTab === 'labeled'"
:disabled="cleanLoading"
@click="openClean"
>数据清洗<span v-if="tabCounts.cleaned" class="toolbar-note">已排除 {{ tabCounts.cleaned }}</span></el-button>
<el-button
v-if="listTab !== 'cleaned'"
:icon="MagicStick"
:disabled="labelRunning || !selected.length"
@click="startLabelTask"
>预标{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<el-button
v-if="listTab !== 'cleaned'"
:icon="Search"
:disabled="vlmReviewing || !selected.length"
@click="vlmBatchReview"
>补标{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<el-button
v-if="listTab === 'cleaned'"
type="primary"
:disabled="!selected.length"
@click="restoreSelected"
>恢复选中{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<el-button
type="danger"
:disabled="!selected.length"
@@ -799,9 +985,19 @@ onBeforeUnmount(() => {
AI 生成未完成{{ genTask.error }}
</div>
<!-- 图片列表 tab未标注(默认) | 已标注 | 已清洗clean_excluded=1数据清洗排除勾选可恢复
标注状态由 tab 表达卡片上不再叠角标2026-09-02 -->
<div class="list-tabs">
<el-radio-group v-model="listTab" size="small">
<el-radio-button value="unlabeled">未标注 {{ tabCounts.unlabeled }}</el-radio-button>
<el-radio-button value="labeled">已标注 {{ tabCounts.labeled }}</el-radio-button>
<el-radio-button value="cleaned">已清洗 {{ tabCounts.cleaned }}</el-radio-button>
</el-radio-group>
</div>
<!-- 图片卡片点击缩略图直接进标注弹窗有标注框直接显示在图上可继续画框 -->
<div v-loading="loading" class="rows-area">
<template v-if="detail && detail.images.length">
<template v-if="pagedImages.length">
<div class="card-grid">
<div v-for="{ item, gi } in pagedImages" :key="item.filename" class="img-card">
<div class="card-img" @click="openEditor(item, gi)">
@@ -813,7 +1009,6 @@ onBeforeUnmount(() => {
/>
<img :src="imgUrl(item.url)" :alt="item.filename" loading="lazy" />
<span v-if="meta(item).source === 'ai'" class="badge source-badge">AI</span>
<span class="badge" :class="item.labeled ? 'ok' : ''">{{ item.labeled ? '已标注' : '未标注' }}</span>
</div>
<div class="card-name" :title="item.filename">{{ item.filename }}</div>
</div>
@@ -822,13 +1017,13 @@ onBeforeUnmount(() => {
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="detail.images.length"
:total="visibleImages.length"
layout="prev, pager, next, total"
background
/>
</div>
</template>
<el-empty v-else-if="!loading" description="暂无图片,请上传或 AI 生成" />
<el-empty v-else-if="!loading" :description="emptyTip" />
</div>
<!-- 添加图片AI 生成 -->
@@ -854,6 +1049,74 @@ onBeforeUnmount(() => {
</template>
</el-dialog>
<!-- 数据清洗训练集去冗余已标注 tab 工具条入口候选默认全选执行 = 打排除标记不删文件 -->
<el-dialog v-model="cleanVisible" title="数据清洗" width="min(980px, 96vw)" append-to-body :close-on-click-modal="false">
<div v-loading="cleanLoading">
<div class="clean-tip">
按标注目标占画面高度分档统计超配档内整图 dHash 贪心保留多样性同场景连拍帧只留 1 其余进候选
调整配额即实时重算<b>排除 删除</b>仅移出训练集打包图片/标注不动被排除图集中到已清洗tab 可随时恢复
</div>
<div class="clean-bucket-head clean-bucket-row">
<span class="cb-label">目标尺寸档</span>
<span class="cb-num">现有</span>
<span class="cb-num">已排除</span>
<span class="cb-num cb-quota">保留配额</span>
<span class="cb-num">超配可排</span>
</div>
<div v-for="(b, i) in cleanBuckets" :key="b.label" class="clean-bucket-row">
<span class="cb-label">
{{ b.label }}
<el-tag v-if="b.exempt" size="small" type="info" effect="plain">极远档豁免</el-tag>
</span>
<span class="cb-num">{{ b.total }}</span>
<span class="cb-num">{{ b.excluded }}</span>
<span class="cb-num cb-quota">
<el-input-number
v-if="!b.exempt"
v-model="cleanQuotas[i]"
:min="0"
:max="5000"
size="small"
controls-position="right"
@change="onQuotaChange"
/>
<span v-else class="cb-exempt">不适用</span>
</span>
<span class="cb-num" :class="{ 'cb-over': b.over > 0 }">{{ b.over || 0 }}</span>
</div>
<div class="clean-summary">参与统计 {{ cleanTotal }} · 已排除 {{ cleanExcludedCount }} 已清洗tab</div>
<div v-if="cleanCand.length" class="clean-cand-head">
<span class="clean-cand-title">建议排除候选{{ cleanCand.length }} 默认全选</span>
<el-button link type="primary" size="small" @click="toggleCleanAll">{{ candAllChecked ? '取消全选' : '全选' }}</el-button>
</div>
<div v-else class="clean-cand-empty">当前配额下无超配候选无需排除<2% 极远档固定豁免</div>
<div class="clean-cand-list">
<label v-for="c in cleanCand" :key="c.imageId" class="clean-cand">
<el-checkbox
:model-value="cleanSel.includes(c.imageId)"
@click.stop
@change="(v) => toggleCleanOne(c.imageId, v)"
/>
<img class="clean-thumb" :src="cleanThumbUrl(c)" :alt="c.filename" loading="lazy" />
<span class="clean-cand-name" :title="c.filename">{{ c.filename }}</span>
<span class="clean-cand-meta">
<span v-if="c.source === 'ai'" class="badge-mini">AI</span>
<span class="clean-pct" :title="'主目标 ' + c.boxHeightPct + '%'"> {{ c.boxHeightPct }}%
<template v-if="c.targetCount > 1"> · 最小 {{ c.minHeightPct }}% · {{ c.targetCount }} 目标</template>
</span>
</span>
</label>
</div>
</div>
<template #footer>
<el-button :disabled="cleanLoading" @click="cleanVisible = false">关闭</el-button>
<el-button type="primary" :loading="cleanApplying" :disabled="!cleanSel.length" @click="applyClean">
排除选中{{ cleanSel.length ? ` ${cleanSel.length} ` : '' }}
</el-button>
</template>
</el-dialog>
<!-- 图片标注全屏查看 + 标注编辑已标注图直接在其上显示框可确认疑似/删除误检可手动画框补标 -->
<el-dialog
v-model="editorVisible"
@@ -906,8 +1169,8 @@ onBeforeUnmount(() => {
</div>
<div class="nav-bar">
<el-button size="small" :disabled="editIndex <= 0" @click="switchEdit(editIndex - 1)">上一张</el-button>
<span class="nav-info">{{ editIndex + 1 }}/{{ detail?.images.length }}</span>
<el-button size="small" :disabled="editIndex >= detail?.images.length - 1" @click="switchEdit(editIndex + 1)">下一张</el-button>
<span class="nav-info">{{ editIndex + 1 }}/{{ visibleImages.length }}</span>
<el-button size="small" :disabled="editIndex >= visibleImages.length - 1" @click="switchEdit(editIndex + 1)">下一张</el-button>
</div>
<template #footer>
<el-button type="primary" :loading="saving" @click="confirmEditor">确定</el-button>
@@ -1055,6 +1318,9 @@ onBeforeUnmount(() => {
.rows-area {
min-height: 200px;
}
.list-tabs {
margin: 4px 0 14px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
@@ -1105,9 +1371,6 @@ onBeforeUnmount(() => {
right: auto;
background: rgba(0, 0, 0, 0.5);
}
.badge.ok {
background: rgba(103, 194, 58, 0.9);
}
.card-name {
margin-top: 6px;
font-size: 13px;
@@ -1123,6 +1386,132 @@ onBeforeUnmount(() => {
margin-top: 14px;
}
/* 数据清洗:配额表 + 候选清单(2026-09-02 */
.toolbar-note {
font-size: 12px;
color: #909399;
margin-left: 2px;
}
.clean-tip {
font-size: 12px;
color: #606266;
background: #f4f4f5;
padding: 8px 10px;
border-radius: 4px;
line-height: 1.7;
margin-bottom: 10px;
}
.clean-tip b {
color: #303133;
}
.clean-bucket-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
font-size: 13px;
}
.clean-bucket-head {
color: #909399;
font-size: 12px;
border-bottom: 1px solid #ebeef5;
}
.cb-label {
flex: 0 0 160px;
display: flex;
align-items: center;
gap: 6px;
color: #303133;
}
.cb-num {
flex: 0 0 110px;
color: #606266;
}
.cb-quota {
flex: 0 0 170px;
}
.cb-exempt {
color: #c0c4cc;
}
.cb-over {
color: #f56c6c;
font-weight: 600;
}
.clean-summary {
margin: 8px 8px 14px;
font-size: 12px;
color: #909399;
}
.clean-cand-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 2px 0 8px;
}
.clean-cand-title {
font-size: 13px;
color: #303133;
font-weight: 600;
}
.clean-cand-empty {
font-size: 13px;
color: #909399;
padding: 6px 0;
}
.clean-cand-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 8px;
max-height: 360px;
overflow-y: auto;
padding-right: 4px;
}
.clean-cand {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 6px 8px;
background: #fff;
}
.clean-thumb {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 4px;
background: #000;
flex-shrink: 0;
}
.clean-cand-name {
flex: 1;
min-width: 0;
font-size: 12px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clean-cand-meta {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
font-size: 12px;
color: #909399;
}
.badge-mini {
font-size: 10px;
color: #fff;
background: rgba(64, 158, 255, 0.85);
border-radius: 3px;
padding: 0 4px;
line-height: 1.5;
}
.clean-pct {
font-variant-numeric: tabular-nums;
}
/* 标注编辑(全屏弹窗:画布区占满剩余高度,画布等比例居中,可放大缩小) */
.editor-toolbar {
display: flex;