This commit is contained in:
2026-09-02 11:18:04 +08:00
parent f55ec114cc
commit 1eb97faaa7
11 changed files with 142 additions and 89 deletions
+107 -61
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Minus, MagicStick } from '@element-plus/icons-vue'
import { Plus, Minus, MagicStick, Search } from '@element-plus/icons-vue'
import request from '../api/request'
const route = useRoute()
@@ -12,7 +12,7 @@ const dataset = ref(null)
// ---------- 数据 ----------
const detail = ref(null) // 工作台数据:images[{filename,url,width,height,boxes,labeled}]
const imageMeta = ref({}) // filename -> {id, source, prompt}(来自 /datasets/images,行内展示用)
const imageMeta = ref({}) // filename -> {id, source}(来自 /datasets/images,行内展示用)
const loading = ref(false)
const page = ref(1)
const pageSize = 18
@@ -45,7 +45,7 @@ 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, prompt: i.prompt }
for (const i of imgs?.list || []) imageMeta.value[i.filename] = { id: i.id, source: i.source }
detail.value = wb || { images: [] }
if (editIndex.value >= (detail.value.images || []).length) editIndex.value = 0
// 刷新时恢复进行中的自动标注任务
@@ -165,55 +165,98 @@ function stopLabelPoll() {
}
}
// 全量标注:重扫数据集全部图片,覆盖各图已有标注(含人工修改/清理的框)
function startFullLabelTask() {
ElMessageBox.confirm('将重新扫描数据集全部图片并生成标注,覆盖各图已有标注(含人工确认/清理的结果)。确定继续?', '全量标注', {
confirmButtonText: '开始标注',
// 勾选标注:重扫勾选图片,覆盖各图已有标注(含人工修改/清理的框)
// 标注入口仅此一处(2026-09-02 决策):无单图/全量按钮,勾选后点顶栏「标注 N 张」。
const selected = ref([])
// 单选框:受控模式(el-checkbox 单独用 v-model 绑数组会在勾选时把数组替换成布尔值,故手动维护)
function toggleOne(filename, checked) {
const s = selected.value
if (checked) {
if (!s.includes(filename)) s.push(filename)
} else {
const i = s.indexOf(filename)
if (i >= 0) s.splice(i, 1)
}
}
// 全选 = 当前页图片;已全选时再点取消当前页勾选(其他页勾选保留)
const allSelected = computed(
() => pagedImages.value.length > 0 &&
pagedImages.value.every(({ item }) => selected.value.includes(item.filename)),
)
function toggleSelectAll() {
const pageFiles = pagedImages.value.map(({ item }) => item.filename)
if (!pageFiles.length) return
const pageSet = new Set(pageFiles)
if (allSelected.value) {
selected.value = selected.value.filter((f) => !pageSet.has(f))
} else {
const keep = new Set(selected.value)
selected.value = [...selected.value, ...pageFiles.filter((f) => !keep.has(f))]
}
}
function startLabelTask() {
if (!selected.value.length) return
ElMessageBox.confirm(`将重新扫描选中的 ${selected.value.length} 张图片并生成标注,覆盖各图已有标注(含人工确认/清理的结果)。确定继续?`, '预标', {
confirmButtonText: '开始预标',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => request.post('/label-tasks', { datasetId: datasetId.value }))
.then(() => request.post('/label-tasks', { datasetId: datasetId.value, filenames: selected.value }))
.then((data) => {
ElMessage.success('已发起全量标注,进度见页顶进度条')
ElMessage.success('已发起预标,进度见页顶进度条')
pollLabelTask(data.id)
})
.catch(() => {})
}
// VLM 藏匿位补检(两阶段标注第二阶段):排除已确认框,按环境/光线/习性找可能藏匿处,追加疑似框
// VLM 藏匿位补检(「补标」按钮,勾选驱动批量执行):排除已确认框,按环境/光线/习性
// 找可能藏匿处,逐张追加疑似框(黄框,人工确认);依赖已确认框作为排除基准
const vlmReviewing = ref(false)
async function vlmReview() {
if (!currentImage.value) return
const m = meta(currentImage.value)
if (!m.id) return
async function vlmBatchReview() {
if (!selected.value.length) return
const images = (detail.value.images || []).filter((it) => selected.value.includes(it.filename))
if (!images.length) return
vlmReviewing.value = true
try {
const d = await request.post('/datasets/images/vlm-review', { datasetId: datasetId.value, imageId: m.id }, { timeout: 300000 })
if (d.added > 0) {
ElMessage.success(`VLM 补检新增 ${d.added} 个疑似框(黄框,请人工确认)`)
let added = 0
let note = ''
for (const item of images) {
const m = meta(item)
if (!m.id) continue
const d = await request.post('/datasets/images/vlm-review', { datasetId: datasetId.value, imageId: m.id }, { timeout: 300000 })
added += d.added || 0
note = d.note || note
}
if (added > 0) {
ElMessage.success(`补标新增 ${added} 个疑似框(黄框,请人工确认)`)
} else {
ElMessage.info(d.note || 'VLM 未发现可能的藏匿位')
ElMessage.info(note || 'VLM 未发现可能的藏匿位')
}
dirty.value = false
await loadAll()
nextTick(resetEditor)
// 标注弹窗开着时刷新当前图的标注
if (editorVisible.value) nextTick(resetEditor)
} finally {
vlmReviewing.value = false
}
}
// 删除单张图片(标注随图片删除,后端级联)
function removeCardImage(item) {
const m = meta(item)
const tip = m.source === 'ai' ? '该图为 AI 生成的付费资产,删除后无法恢复。' : ''
// 批量删除勾选图片(标注随图片删除,后端级联)
function deleteSelected() {
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
const aiCount = images.filter((it) => meta(it).source === 'ai').length
const tip = aiCount ? `(含 ${aiCount} 张 AI 生成图,为付费资产,删除后无法恢复)` : ''
ElMessageBox.confirm(
`确定删除图片「${item.filename}?其标注将一并删除。${tip}`,
'删除图片',
`确定删除选中的 ${ids.length} 张图片?其标注将一并删除。${tip}`,
'删除选中',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
)
.then(() => request.post('/datasets/images/delete', { datasetId: datasetId.value, ids: [m.id] }))
.then(() => request.post('/datasets/images/delete', { datasetId: datasetId.value, ids }))
.then(() => {
ElMessage.success('已删除')
selected.value = []
loadAll()
})
.catch(() => {})
@@ -695,8 +738,26 @@ onBeforeUnmount(() => {
<!-- 图片工具 -->
<div class="img-toolbar">
<el-button
:disabled="!pagedImages.length"
@click="toggleSelectAll"
>{{ allSelected ? '取消全选' : '全选' }}</el-button>
<el-button type="primary" :icon="Plus" :disabled="genRunning" @click="openAdd">添加</el-button>
<el-button :icon="MagicStick" :disabled="labelRunning" @click="startFullLabelTask">全量标注</el-button>
<el-button
:icon="MagicStick"
:disabled="labelRunning || !selected.length"
@click="startLabelTask"
>预标{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<el-button
:icon="Search"
:disabled="vlmReviewing || !selected.length"
@click="vlmBatchReview"
>补标{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
<el-button
type="danger"
:disabled="!selected.length"
@click="deleteSelected"
>删除选中{{ selected.length ? ` ${selected.length}` : '' }}</el-button>
</div>
<div v-if="labelRunning" class="label-progress">
@@ -726,19 +787,17 @@ onBeforeUnmount(() => {
<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)">
<el-checkbox
class="card-select"
:model-value="selected.includes(item.filename)"
@click.stop
@change="(v) => toggleOne(item.filename, v)"
/>
<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 class="card-sub">
{{ (item.boxes || []).length }} 个标注框
</div>
<div v-if="meta(item).prompt" class="card-prompt" :title="meta(item).prompt">{{ meta(item).prompt }}</div>
<div class="card-actions">
<el-button size="small" type="primary" @click="openEditor(item, gi)">审核</el-button>
<el-button size="small" type="danger" @click="removeCardImage(item)">删除</el-button>
</div>
</div>
</div>
<div class="pager">
@@ -777,22 +836,21 @@ onBeforeUnmount(() => {
</template>
</el-dialog>
<!-- 图片审核全屏查看 + 标注审核已标注图直接在其上显示框可确认疑似/删除误检不做手动画框 -->
<!-- 图片标注全屏查看 + 标注编辑已标注图直接在其上显示框可确认疑似/删除误检手动画框补标 -->
<el-dialog
v-model="editorVisible"
fullscreen
class="editor-fullscreen"
:title="currentImage?.filename || '图片审核'"
:title="currentImage?.filename || '图片标注'"
append-to-body
:before-close="onEditorBeforeClose"
:close-on-click-modal="false"
>
<div class="editor-toolbar">
<span class="tip">空白处拖动画红框补标 · 拖拽已有框移位 · 拖角点改大小 · 点框选中 · 列表确认疑似/删除误检 · VLM 自动标注审核</span>
<span class="tip">空白处拖动画红框补标 · 拖拽已有框移位 · 拖角点改大小 · 点框选中 · 列表确认疑似/删除误检</span>
<div class="toolbar-right">
<span class="box-count"><i class="dot red" />确认 {{ confirmCount }} · <i class="dot yellow" />疑似 {{ suspectCount }}</span>
<el-button size="small" :disabled="!confirmed.length" @click="clearAllBoxes">清空本张</el-button>
<el-button size="small" type="warning" :loading="vlmReviewing" @click="vlmReview">VLM 补检</el-button>
<el-divider direction="vertical" />
<span class="zoom-ctrl">
<el-button size="small" circle :icon="Plus" @click="zoomIn" />
@@ -1004,6 +1062,15 @@ onBeforeUnmount(() => {
height: auto;
object-fit: contain;
}
.card-select {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
padding: 2px 3px;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
}
.badge {
position: absolute;
top: 6px;
@@ -1032,27 +1099,6 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
white-space: nowrap;
}
.card-sub {
font-size: 12px;
color: #606266;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-prompt {
font-size: 11px;
color: #909399;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.pager {
display: flex;
justify-content: center;