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