988 lines
29 KiB
Vue
988 lines
29 KiB
Vue
<script setup>
|
||
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 request from '../api/request'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const datasetId = computed(() => Number(route.params.id))
|
||
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 loading = ref(false)
|
||
const page = ref(1)
|
||
const pageSize = 18
|
||
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 }))
|
||
})
|
||
|
||
function meta(item) {
|
||
return imageMeta.value[item.filename] || {}
|
||
}
|
||
|
||
function imgUrl(u) {
|
||
return `${location.origin}${u}&token=${encodeURIComponent(localStorage.getItem('adminToken') || '')}`
|
||
}
|
||
|
||
async function loadAll() {
|
||
loading.value = true
|
||
try {
|
||
const [d, wb] = await Promise.all([
|
||
request.get('/datasets', { params: { page: 1, size: 100 } }),
|
||
request.get('/label-workbench', { params: { datasetId: datasetId.value } }).catch(() => null),
|
||
])
|
||
dataset.value = (d.list || []).find((x) => x.id === datasetId.value) || null
|
||
if (!dataset.value) {
|
||
ElMessage.error('数据集不存在')
|
||
router.push('/datasets')
|
||
return
|
||
}
|
||
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 }
|
||
detail.value = wb || { images: [] }
|
||
if (editIndex.value >= (detail.value.images || []).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')
|
||
if (running) pollLabelTask(running.id)
|
||
// 刷新时恢复进行中的 AI 生成任务(或展示最近一次失败的提示)
|
||
const gt = await request.get('/datasets/gen-task', { params: { datasetId: datasetId.value } }).catch(() => null)
|
||
if (gt && gt.status === 'running') {
|
||
genTask.value = { id: gt.id, status: gt.status, total: gt.total, done: gt.done, error: gt.error }
|
||
pollGenTask()
|
||
} else if (gt && gt.error) {
|
||
genTask.value = gt
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// ---------- 添加图片(AI 生成) ----------
|
||
const addVisible = ref(false)
|
||
// 单物种规则:物种固定取数据集名,表单无物种输入
|
||
const genForm = reactive({ count: 1, size: '704x1248', distance: 25 })
|
||
const generating = ref(false)
|
||
|
||
function openAdd() {
|
||
genForm.count = 1
|
||
genForm.size = '704x1248'
|
||
genForm.distance = 25
|
||
addVisible.value = true
|
||
}
|
||
|
||
// AI 生成为异步任务:提交即返回 taskId,轮询 /datasets/gen-task 看进度
|
||
const genTask = ref(null)
|
||
const genPoll = ref(null)
|
||
const genRunning = computed(() => genTask.value && genTask.value.status === 'running')
|
||
|
||
function pollGenTask() {
|
||
stopGenPoll()
|
||
genPoll.value = setInterval(async () => {
|
||
const d = await request.get('/datasets/gen-task', { params: { datasetId: datasetId.value } }).catch(() => null)
|
||
if (!d) return
|
||
genTask.value = { id: d.id, status: d.status, total: d.total, done: d.done, error: d.error }
|
||
if (d.status !== 'running') {
|
||
stopGenPoll()
|
||
if (d.error) {
|
||
ElMessage.error(`AI 生成未完成:${d.error}`)
|
||
} else if (d.total > 0) {
|
||
ElMessage.success(`AI 生成完成(${d.done}/${d.total} 张),自动标注处理中…`)
|
||
}
|
||
loadAll().then(() => {
|
||
if (editorVisible.value) nextTick(resetEditor)
|
||
})
|
||
}
|
||
}, 3000)
|
||
}
|
||
|
||
function stopGenPoll() {
|
||
if (genPoll.value) {
|
||
clearInterval(genPoll.value)
|
||
genPoll.value = null
|
||
}
|
||
}
|
||
|
||
function submitGen() {
|
||
generating.value = true
|
||
request
|
||
.post('/datasets/generate', {
|
||
datasetId: datasetId.value,
|
||
count: genForm.count,
|
||
size: genForm.size,
|
||
distance: genForm.distance,
|
||
})
|
||
.then((data) => {
|
||
addVisible.value = false
|
||
genTask.value = { id: data.taskId, status: 'running', total: data.total, done: 0, error: '' }
|
||
ElMessage.success(`已提交生成任务(共 ${data.total} 张,约 40 秒/张),进度见页顶进度条`)
|
||
pollGenTask()
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
generating.value = false
|
||
})
|
||
}
|
||
|
||
// ---------- 自动标注(图片入库自动触发,此处仅展示任务进度) ----------
|
||
const labelTask = ref(null)
|
||
const labelPoll = ref(null)
|
||
const labelRunning = computed(() => labelTask.value && labelTask.value.status === 'running')
|
||
|
||
function pollLabelTask(id) {
|
||
stopLabelPoll()
|
||
labelTask.value = { id, status: 'running', total: 0, done: 0, error: '' }
|
||
labelPoll.value = setInterval(async () => {
|
||
const d = await request.get('/label-tasks/detail', { params: { id } }).catch(() => null)
|
||
if (!d) return
|
||
labelTask.value = { id, status: d.status, total: d.total, done: d.done, error: d.error }
|
||
if (d.status === 'done') {
|
||
stopLabelPoll()
|
||
if (d.error) {
|
||
ElMessage.error(`自动标注未完成:${d.error}`)
|
||
} else {
|
||
ElMessage.success(`自动标注完成(${d.done}/${d.total} 张),标注已更新`)
|
||
}
|
||
loadAll().then(() => {
|
||
// 标注弹窗开着时刷新当前图的标注
|
||
if (editorVisible.value) nextTick(resetEditor)
|
||
})
|
||
}
|
||
}, 3000)
|
||
}
|
||
|
||
function stopLabelPoll() {
|
||
if (labelPoll.value) {
|
||
clearInterval(labelPoll.value)
|
||
labelPoll.value = null
|
||
}
|
||
}
|
||
|
||
// 全量标注:重扫数据集全部图片,覆盖各图已有标注(含人工修改/清理的框)
|
||
function startFullLabelTask() {
|
||
ElMessageBox.confirm('将重新扫描数据集全部图片并生成标注,覆盖各图已有标注(含人工确认/清理的结果)。确定继续?', '全量标注', {
|
||
confirmButtonText: '开始标注',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
})
|
||
.then(() => request.post('/label-tasks', { datasetId: datasetId.value }))
|
||
.then((data) => {
|
||
ElMessage.success('已发起全量标注,进度见页顶进度条')
|
||
pollLabelTask(data.id)
|
||
})
|
||
.catch(() => {})
|
||
}
|
||
|
||
// VLM 藏匿位补检(两阶段标注第二阶段):排除已确认框,按环境/光线/习性找可能藏匿处,追加疑似框
|
||
const vlmReviewing = ref(false)
|
||
async function vlmReview() {
|
||
if (!currentImage.value) return
|
||
const m = meta(currentImage.value)
|
||
if (!m.id) 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} 个疑似框(黄框,请人工确认)`)
|
||
} else {
|
||
ElMessage.info(d.note || 'VLM 未发现可能的藏匿位')
|
||
}
|
||
dirty.value = false
|
||
await loadAll()
|
||
nextTick(resetEditor)
|
||
} finally {
|
||
vlmReviewing.value = false
|
||
}
|
||
}
|
||
|
||
// 删除单张图片(标注随图片删除,后端级联)
|
||
function removeCardImage(item) {
|
||
const m = meta(item)
|
||
const tip = m.source === 'ai' ? '该图为 AI 生成的付费资产,删除后无法恢复。' : ''
|
||
ElMessageBox.confirm(
|
||
`确定删除图片「${item.filename}」?其标注将一并删除。${tip}`,
|
||
'删除图片',
|
||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
|
||
)
|
||
.then(() => request.post('/datasets/images/delete', { datasetId: datasetId.value, ids: [m.id] }))
|
||
.then(() => {
|
||
ElMessage.success('已删除')
|
||
loadAll()
|
||
})
|
||
.catch(() => {})
|
||
}
|
||
|
||
// ---------- 标注编辑弹窗(canvas 画框) ----------
|
||
const editorVisible = ref(false)
|
||
const editIndex = ref(0)
|
||
const currentImage = computed(() => (detail.value && detail.value.images ? detail.value.images[editIndex.value] : null))
|
||
const canvasEl = ref(null)
|
||
const canvasWrap = ref(null)
|
||
const imgEl = ref(null)
|
||
const naturalW = ref(0)
|
||
const naturalH = ref(0)
|
||
const confirmed = ref([])
|
||
const dirty = ref(false)
|
||
const pointerDownPos = ref(null)
|
||
const saving = ref(false)
|
||
// class 0 = 确认(红框),class 1 = 疑似(黄框)
|
||
const classColor = ['#f56c6c', '#e6a23c']
|
||
const classLabel = ['确认', '疑似']
|
||
const confirmCount = computed(() => confirmed.value.filter((b) => b.class === 0).length)
|
||
const suspectCount = computed(() => confirmed.value.filter((b) => b.class === 1).length)
|
||
|
||
// 标注列表高亮闪烁(点击列表项在画布上定位对应框)
|
||
const highlightIndex = ref(-1)
|
||
const highlightOn = ref(false)
|
||
let highlightTimer = null
|
||
|
||
function stopHighlight() {
|
||
if (highlightTimer) {
|
||
clearInterval(highlightTimer)
|
||
highlightTimer = null
|
||
}
|
||
highlightIndex.value = -1
|
||
}
|
||
|
||
function toggleHighlight(i) {
|
||
if (i === highlightIndex.value) return
|
||
stopHighlight()
|
||
highlightIndex.value = i
|
||
highlightOn.value = true
|
||
highlightTimer = setInterval(() => {
|
||
highlightOn.value = !highlightOn.value
|
||
draw()
|
||
}, 350)
|
||
draw()
|
||
}
|
||
|
||
function removeBox(i) {
|
||
confirmed.value.splice(i, 1)
|
||
markDirty()
|
||
if (highlightIndex.value === i) stopHighlight()
|
||
else if (highlightIndex.value > i) highlightIndex.value--
|
||
draw()
|
||
}
|
||
|
||
// 疑似框确认(class1 → class0):VLM 补检产出的疑似框由人工确认
|
||
function confirmBox(i) {
|
||
const b = confirmed.value[i]
|
||
if (!b || b.class !== 1) return
|
||
b.class = 0
|
||
markDirty()
|
||
draw()
|
||
}
|
||
|
||
function openEditor(item, gi) {
|
||
editIndex.value = gi
|
||
editorVisible.value = true
|
||
nextTick(resetEditor)
|
||
}
|
||
|
||
function onEditorBeforeClose(done) {
|
||
if (!dirty.value) {
|
||
done()
|
||
return
|
||
}
|
||
ElMessageBox.confirm('当前图片有未保存的修改,确定放弃?', '未保存修改', {
|
||
confirmButtonText: '放弃修改',
|
||
cancelButtonText: '继续编辑',
|
||
type: 'warning',
|
||
})
|
||
.then(() => done())
|
||
.catch(() => {})
|
||
}
|
||
|
||
async function switchEdit(idx) {
|
||
if (idx === editIndex.value || !detail.value) return
|
||
if (dirty.value) {
|
||
try {
|
||
await ElMessageBox.confirm('当前图片有未保存的修改,确定放弃并切换?', '未保存修改', {
|
||
confirmButtonText: '放弃修改',
|
||
cancelButtonText: '留在本张',
|
||
type: 'warning',
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
editIndex.value = idx
|
||
page.value = Math.floor(idx / pageSize) + 1
|
||
nextTick(resetEditor)
|
||
}
|
||
|
||
function resetEditor() {
|
||
stopHighlight()
|
||
dirty.value = false
|
||
confirmed.value = []
|
||
if (currentImage.value) {
|
||
confirmed.value = (currentImage.value.boxes || []).map((b) => ({ ...b }))
|
||
}
|
||
nextTick(draw)
|
||
}
|
||
|
||
async function onImageLoad() {
|
||
const img = imgEl.value
|
||
if (!img) return
|
||
naturalW.value = img.naturalWidth
|
||
naturalH.value = img.naturalHeight
|
||
await nextTick()
|
||
fitZoom()
|
||
draw()
|
||
}
|
||
|
||
// 显示缩放:初始自动适应弹窗(等比例 fit),可放大缩小
|
||
const zoom = ref(1)
|
||
const zoomPercent = computed(() => `${Math.round(zoom.value * 100)}%`)
|
||
|
||
function fitZoom() {
|
||
const wrap = canvasWrap.value
|
||
if (!wrap || !naturalW.value || !naturalH.value) return
|
||
const f = Math.min(wrap.clientWidth / naturalW.value, wrap.clientHeight / naturalH.value)
|
||
zoom.value = f > 0 ? f : 1
|
||
}
|
||
|
||
function zoomIn() {
|
||
// 上限 4 倍:画布按原图分辨率等比放大,4x 约 151MB 已是内存安全边界
|
||
zoom.value = Math.min(4, zoom.value * 1.2)
|
||
draw()
|
||
}
|
||
|
||
function zoomOut() {
|
||
zoom.value = Math.max(0.05, zoom.value / 1.2)
|
||
draw()
|
||
}
|
||
|
||
// Ctrl/⌘+滚轮缩放;普通滚轮留给画布区滚动
|
||
function onWheel(e) {
|
||
if (!e.ctrlKey && !e.metaKey) return
|
||
e.preventDefault()
|
||
const f = e.deltaY < 0 ? 1.1 : 1 / 1.1
|
||
zoom.value = Math.min(4, Math.max(0.05, zoom.value * f))
|
||
draw()
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
const c = canvasEl.value
|
||
if (!c) return
|
||
c.width = Math.round(naturalW.value * zoom.value)
|
||
c.height = Math.round(naturalH.value * zoom.value)
|
||
}
|
||
|
||
function normToPx(box) {
|
||
const c = canvasEl.value
|
||
return {
|
||
x: box.cx * c.width - (box.w * c.width) / 2,
|
||
y: box.cy * c.height - (box.h * c.height) / 2,
|
||
w: box.w * c.width,
|
||
h: box.h * c.height,
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
const c = canvasEl.value
|
||
if (!c || !currentImage.value) return
|
||
const ctx = c.getContext('2d')
|
||
resizeCanvas()
|
||
ctx.clearRect(0, 0, c.width, c.height)
|
||
// 放大(>1x)时最近邻采样保留原文件像素,平滑插值会模糊成"缩略图"观感;缩小才平滑
|
||
ctx.imageSmoothingEnabled = zoom.value <= 1
|
||
ctx.drawImage(imgEl.value, 0, 0, c.width, c.height)
|
||
|
||
for (let i = 0; i < confirmed.value.length; i++) {
|
||
const b = confirmed.value[i]
|
||
drawBox(ctx, b, false, i === highlightIndex.value && highlightOn.value)
|
||
}
|
||
}
|
||
|
||
function drawBox(ctx, box, ghost = false, highlighted = false) {
|
||
const color = classColor[box.class] || '#409eff'
|
||
const p = normToPx(box)
|
||
ctx.save()
|
||
ctx.strokeStyle = color
|
||
ctx.lineWidth = highlighted ? 4.5 : 2.5
|
||
ctx.globalAlpha = ghost ? 0.6 : 1
|
||
ctx.fillStyle = color
|
||
ctx.globalAlpha = 0.14 * (ghost ? 0.5 : 1)
|
||
ctx.fillRect(p.x, p.y, p.w, p.h)
|
||
ctx.globalAlpha = 1
|
||
ctx.strokeRect(p.x, p.y, p.w, p.h)
|
||
// 高亮闪烁:外圈白色描边增强定位效果
|
||
if (highlighted) {
|
||
ctx.lineWidth = 2
|
||
ctx.strokeStyle = '#fff'
|
||
ctx.strokeRect(p.x - 2.5, p.y - 2.5, p.w + 5, p.h + 5)
|
||
}
|
||
// AI 框带置信度(<1 时显示),人工框(confidence=1)不显示
|
||
const conf = box.confidence && box.confidence < 1 ? ' ' + box.confidence.toFixed(2) : ''
|
||
const text = `${classLabel[box.class] || box.class}${conf}`
|
||
ctx.font = '11px sans-serif'
|
||
ctx.fillStyle = color
|
||
ctx.globalAlpha = 0.9
|
||
ctx.fillText(text, p.x, p.y - 3)
|
||
ctx.restore()
|
||
}
|
||
|
||
function eventPos(e) {
|
||
const rect = canvasEl.value.getBoundingClientRect()
|
||
const px = (e.clientX - rect.left) / rect.width
|
||
const py = (e.clientY - rect.top) / rect.height
|
||
return {
|
||
x: Math.min(1, Math.max(0, px)),
|
||
y: Math.min(1, Math.max(0, py)),
|
||
}
|
||
}
|
||
|
||
function onPointerDown(e) {
|
||
e.preventDefault()
|
||
const pos = eventPos(e)
|
||
pointerDownPos.value = { ...pos }
|
||
canvasEl.value.setPointerCapture(e.pointerId)
|
||
}
|
||
|
||
// 点击框选中(审核模式:不做手动画框,疑似框由 VLM 补检产出,人工仅确认/删除)
|
||
function onPointerUp(e) {
|
||
if (!pointerDownPos.value) return
|
||
const pos = eventPos(e)
|
||
const moved = Math.abs(pos.x - pointerDownPos.value.x) + Math.abs(pos.y - pointerDownPos.value.y)
|
||
pointerDownPos.value = null
|
||
if (moved < 0.03) {
|
||
const idx = confirmed.value.findIndex((b) => pointInBox(pos, b))
|
||
if (idx >= 0) toggleHighlight(idx)
|
||
else stopHighlight()
|
||
}
|
||
draw()
|
||
}
|
||
|
||
function pointInBox(pos, box) {
|
||
const x1 = box.cx - box.w / 2
|
||
const x2 = box.cx + box.w / 2
|
||
const y1 = box.cy - box.h / 2
|
||
const y2 = box.cy + box.h / 2
|
||
return pos.x >= x1 && pos.x <= x2 && pos.y >= y1 && pos.y <= y2
|
||
}
|
||
|
||
function markDirty() {
|
||
dirty.value = true
|
||
}
|
||
|
||
function clearAllBoxes() {
|
||
confirmed.value = []
|
||
markDirty()
|
||
draw()
|
||
}
|
||
|
||
async function saveCurrent() {
|
||
if (!currentImage.value) return
|
||
saving.value = true
|
||
try {
|
||
const data = await request.post('/label-tasks/save', {
|
||
datasetId: datasetId.value,
|
||
filename: currentImage.value.filename,
|
||
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
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
// 弹窗「确定」:有未保存修改先保存,再关闭
|
||
async function confirmEditor() {
|
||
if (dirty.value) {
|
||
try {
|
||
await saveCurrent()
|
||
} catch {
|
||
ElMessage.error('保存失败,请重试')
|
||
return
|
||
}
|
||
}
|
||
editorVisible.value = false
|
||
}
|
||
|
||
function onWinResize() {
|
||
if (editorVisible.value) draw()
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadAll()
|
||
window.addEventListener('resize', onWinResize)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
stopLabelPoll()
|
||
stopGenPoll()
|
||
stopHighlight()
|
||
window.removeEventListener('resize', onWinResize)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<el-card shadow="never">
|
||
<div class="detail-topbar">
|
||
<el-button link @click="router.push('/datasets')">← 返回数据集</el-button>
|
||
<span class="detail-title">{{ dataset?.name || '数据训练' }}</span>
|
||
<el-tag
|
||
v-if="dataset"
|
||
:type="dataset.trainingStatus === 'running' ? 'primary' : (dataset.trainingStatus === 'success' ? 'success' : (dataset.trainingStatus === 'failed' ? 'danger' : 'info'))"
|
||
size="small"
|
||
>
|
||
{{ dataset.trainingStatus === 'running' ? `训练中 ${dataset.trainingCurrentEpoch || 0}/${dataset.trainingTotalEpochs || '-'}` : (dataset.trainingStatus === 'success' ? '训练完成' : (dataset.trainingStatus === 'failed' ? '训练失败' : (dataset.status === 'labeled' ? '已标注' : '建设中'))) }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<!-- 图片工具 -->
|
||
<div class="img-toolbar">
|
||
<el-button type="primary" :icon="Plus" :disabled="genRunning" @click="openAdd">添加</el-button>
|
||
<el-button :icon="MagicStick" :disabled="labelRunning" @click="startFullLabelTask">全量标注</el-button>
|
||
</div>
|
||
|
||
<div v-if="labelRunning" class="label-progress">
|
||
<el-progress
|
||
:percentage="labelTask.total ? Math.min(100, Math.round((labelTask.done / labelTask.total) * 100)) : 0"
|
||
:stroke-width="10"
|
||
/>
|
||
<span class="label-progress-tip">自动标注中 {{ labelTask.done }}/{{ labelTask.total }} 张(AI 全图扫描)…</span>
|
||
</div>
|
||
<div v-if="labelTask && labelTask.status === 'done' && labelTask.error" class="label-error">
|
||
自动标注未完成:{{ labelTask.error }}
|
||
</div>
|
||
<div v-if="genRunning" class="label-progress">
|
||
<el-progress
|
||
:percentage="genTask.total ? Math.min(100, Math.round((genTask.done / genTask.total) * 100)) : 0"
|
||
:stroke-width="10"
|
||
/>
|
||
<span class="label-progress-tip">AI 生成中 {{ genTask.done }}/{{ genTask.total }} 张(约 40 秒/张)…</span>
|
||
</div>
|
||
<div v-if="genTask && genTask.status !== 'running' && genTask.error" class="label-error">
|
||
AI 生成未完成:{{ genTask.error }}
|
||
</div>
|
||
|
||
<!-- 图片卡片:点击缩略图直接进标注弹窗(有标注框直接显示在图上,可继续画框) -->
|
||
<div v-loading="loading" class="rows-area">
|
||
<template v-if="detail && detail.images.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)">
|
||
<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">
|
||
<el-pagination
|
||
v-model:current-page="page"
|
||
:page-size="pageSize"
|
||
:total="detail.images.length"
|
||
layout="prev, pager, next, total"
|
||
background
|
||
/>
|
||
</div>
|
||
</template>
|
||
<el-empty v-else-if="!loading" description="暂无图片,请上传或 AI 生成" />
|
||
</div>
|
||
|
||
<!-- 添加图片:AI 生成 -->
|
||
<el-dialog v-model="addVisible" title="AI 生成图片" width="min(520px, 92vw)" append-to-body>
|
||
<el-form label-width="90px">
|
||
<el-form-item label="生成张数">
|
||
<el-input-number v-model="genForm.count" :min="1" :max="1000" />
|
||
</el-form-item>
|
||
<el-form-item label="目标距离">
|
||
<div class="gen-inline">
|
||
<el-input-number v-model="genForm.distance" :min="1" :max="500" />
|
||
<span class="gen-note">米,注入提示词距离描述(如 25米外)</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="图片尺寸">
|
||
<span class="gen-size">竖版 704x1248</span>
|
||
</el-form-item>
|
||
<div class="gen-tip">按本数据集物种({{ dataset?.name || '' }})生成,每张固定 1 只;生成图片为付费资产,请勿随意删除;批量生成为后台任务(约 40 秒/张),进度见页顶进度条;手填提示词时无需写拍摄角度/位置(系统自动约束画面中有且只有这一只动物)。</div>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<el-button type="primary" :loading="generating" @click="submitGen">开始生成</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 图片审核(全屏查看 + 标注审核):已标注图直接在其上显示框,可确认疑似/删除误检,不做手动画框 -->
|
||
<el-dialog
|
||
v-model="editorVisible"
|
||
fullscreen
|
||
class="editor-fullscreen"
|
||
:title="currentImage?.filename || '图片审核'"
|
||
append-to-body
|
||
:before-close="onEditorBeforeClose"
|
||
:close-on-click-modal="false"
|
||
>
|
||
<div class="editor-toolbar">
|
||
<span class="tip">点框选中 · 列表确认疑似/删除误检 · VLM 自动标注审核</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" />
|
||
<span class="zoom-pct">{{ zoomPercent }}</span>
|
||
<el-button size="small" circle :icon="Minus" @click="zoomOut" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="currentImage" class="canvas-wrap" ref="canvasWrap" @wheel="onWheel">
|
||
<canvas
|
||
ref="canvasEl"
|
||
class="wb-canvas"
|
||
@pointerdown="onPointerDown"
|
||
@pointerup="onPointerUp"
|
||
@pointercancel="onPointerUp"
|
||
/>
|
||
<img :src="imgUrl(currentImage.url)" class="wb-img" ref="imgEl" alt="" @load="onImageLoad" />
|
||
</div>
|
||
<!-- 本图全部标注列表:点击高亮定位画布对应框(闪烁),疑似框可确认、可删除 -->
|
||
<div v-if="confirmed.length" class="box-list">
|
||
<div
|
||
v-for="(b, i) in confirmed"
|
||
:key="i"
|
||
class="box-item"
|
||
:class="{ active: i === highlightIndex }"
|
||
@click="toggleHighlight(i)"
|
||
>
|
||
<i class="dot" :class="b.class === 0 ? 'red' : 'yellow'" />
|
||
<span class="box-item-label">{{ classLabel[b.class] || b.class }}{{ b.confidence && b.confidence < 1 ? ' ' + b.confidence.toFixed(2) : '' }}</span>
|
||
<el-button v-if="b.class === 1" size="small" type="success" text @click.stop="confirmBox(i)">确认</el-button>
|
||
<el-button size="small" type="danger" text @click.stop="removeBox(i)">删除</el-button>
|
||
</div>
|
||
</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>
|
||
</div>
|
||
<template #footer>
|
||
<el-button type="primary" :loading="saving" @click="confirmEditor">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</el-card>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 类别色点:确认红 / 疑似黄,与画布框色一致 */
|
||
.dot {
|
||
display: inline-block;
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 50%;
|
||
margin-right: 4px;
|
||
vertical-align: middle;
|
||
}
|
||
.dot.red {
|
||
background: #f56c6c;
|
||
}
|
||
.dot.yellow {
|
||
background: #e6a23c;
|
||
}
|
||
.box-count {
|
||
font-size: 12px;
|
||
color: #606266;
|
||
white-space: nowrap;
|
||
}
|
||
/* 标注列表:本图全部框,点击高亮闪烁定位 */
|
||
.box-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
max-height: 108px;
|
||
overflow-y: auto;
|
||
}
|
||
.box-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 2px 8px;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
background: #fff;
|
||
}
|
||
.box-item:hover,
|
||
.box-item.active {
|
||
border-color: #409eff;
|
||
background: #ecf5ff;
|
||
}
|
||
.box-item-label {
|
||
color: #606266;
|
||
}
|
||
.detail-topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.detail-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
.img-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
margin: 12px 0;
|
||
}
|
||
.label-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.label-progress .el-progress {
|
||
flex: 1;
|
||
max-width: 420px;
|
||
}
|
||
.label-progress-tip {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
.label-error {
|
||
margin-bottom: 12px;
|
||
color: #f56c6c;
|
||
font-size: 13px;
|
||
background: #fef0f0;
|
||
padding: 8px 10px;
|
||
border-radius: 4px;
|
||
}
|
||
.gen-tip {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.6;
|
||
}
|
||
.add-mode {
|
||
margin-bottom: 14px;
|
||
}
|
||
.add-upload {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
.add-tip {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.6;
|
||
}
|
||
/* 生成表单行内组合(数量/距离范围 + 说明) */
|
||
.gen-inline {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.gen-note {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.6;
|
||
}
|
||
/* 上传方块:虚线边框 + 居中加号(el-upload picture-card 触发块) */
|
||
.upload-tile {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #8c939d;
|
||
gap: 4px;
|
||
}
|
||
.upload-tile-text {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
/* 图片卡片 */
|
||
.rows-area {
|
||
min-height: 200px;
|
||
}
|
||
.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;
|
||
border-radius: 6px;
|
||
background: #000;
|
||
cursor: zoom-in;
|
||
border: 1px solid #dcdfe6;
|
||
}
|
||
.card-img img {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
object-fit: contain;
|
||
}
|
||
.badge {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
font-size: 11px;
|
||
color: #fff;
|
||
background: rgba(64, 158, 255, 0.85);
|
||
border-radius: 3px;
|
||
padding: 1px 6px;
|
||
line-height: 1.5;
|
||
}
|
||
.badge.source-badge {
|
||
left: 6px;
|
||
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;
|
||
color: #303133;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
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;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
/* 标注编辑(全屏弹窗:画布区占满剩余高度,画布等比例居中,可放大缩小) */
|
||
.editor-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.toolbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.tip {
|
||
color: #909399;
|
||
font-size: 12px;
|
||
}
|
||
.zoom-ctrl {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.zoom-pct {
|
||
font-size: 12px;
|
||
color: #606266;
|
||
min-width: 44px;
|
||
text-align: center;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.canvas-wrap {
|
||
position: relative;
|
||
background: #000;
|
||
border-radius: 6px;
|
||
overflow: auto;
|
||
touch-action: none;
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
}
|
||
.wb-canvas {
|
||
display: block;
|
||
cursor: crosshair;
|
||
margin: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
.wb-img {
|
||
display: none;
|
||
}
|
||
.nav-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
margin-top: 10px;
|
||
}
|
||
.nav-info {
|
||
font-size: 13px;
|
||
color: #606266;
|
||
}
|
||
</style>
|
||
|
||
<!-- 全屏弹窗由 teleport 渲染到 body,scoped 属性不会落到其内部 DOM,须用非 scoped 规则 -->
|
||
<style>
|
||
.editor-fullscreen.el-dialog.is-fullscreen {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.editor-fullscreen .el-dialog__body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
overflow: hidden;
|
||
padding: 12px 16px;
|
||
}
|
||
</style>
|