chore(server): 训练/封面/数据集配置改动快照(含管理端构建产物与数据迁移)
- dataset 封面定位、训练产物命名(RNPHE/pigeon 前缀)、localai/config 训练机配置 - server_admin DatasetDetail/Datasets 调整 + admin_dist 重建产物 - observer.db 数据变更
This commit is contained in:
@@ -231,7 +231,15 @@ const naturalH = ref(0)
|
||||
const confirmed = ref([])
|
||||
const dirty = ref(false)
|
||||
const pointerDownPos = ref(null)
|
||||
const drawing = ref(null)
|
||||
const draggingIndex = ref(-1) // 正在拖动的已有框索引(-1 = 无)
|
||||
const dragOffset = ref(null) // 按下点相对框中心的归一化偏移
|
||||
const resizeIndex = ref(-1) // 正在缩放的已有框索引(-1 = 无)
|
||||
const resizeCorner = ref(null) // 拖动的角:nw/ne/sw/se
|
||||
const saving = ref(false)
|
||||
// 角点命中半径与最小边长(归一化坐标;1248 高图约 15px / 12px)
|
||||
const HIT_R = 0.012
|
||||
const MIN_EDGE = 0.01
|
||||
// class 0 = 确认(红框),class 1 = 疑似(黄框)
|
||||
const classColor = ['#f56c6c', '#e6a23c']
|
||||
const classLabel = ['确认', '疑似']
|
||||
@@ -322,6 +330,11 @@ function resetEditor() {
|
||||
stopHighlight()
|
||||
dirty.value = false
|
||||
confirmed.value = []
|
||||
drawing.value = null
|
||||
draggingIndex.value = -1
|
||||
dragOffset.value = null
|
||||
resizeIndex.value = -1
|
||||
resizeCorner.value = null
|
||||
if (currentImage.value) {
|
||||
confirmed.value = (currentImage.value.boxes || []).map((b) => ({ ...b }))
|
||||
}
|
||||
@@ -400,6 +413,20 @@ function draw() {
|
||||
const b = confirmed.value[i]
|
||||
drawBox(ctx, b, false, i === highlightIndex.value && highlightOn.value)
|
||||
}
|
||||
// 拖拽画框预览(半透明 ghost 框,人工标注补标用)
|
||||
if (drawing.value) {
|
||||
drawBox(
|
||||
ctx,
|
||||
{
|
||||
cx: drawing.value.x + drawing.value.w / 2,
|
||||
cy: drawing.value.y + drawing.value.h / 2,
|
||||
w: drawing.value.w,
|
||||
h: drawing.value.h,
|
||||
class: 0,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function drawBox(ctx, box, ghost = false, highlighted = false) {
|
||||
@@ -427,6 +454,22 @@ function drawBox(ctx, box, ghost = false, highlighted = false) {
|
||||
ctx.fillStyle = color
|
||||
ctx.globalAlpha = 0.9
|
||||
ctx.fillText(text, p.x, p.y - 3)
|
||||
// 四角拖动手柄(ghost 预览框不画):白边色块,提示可拖角改大小
|
||||
if (!ghost) {
|
||||
const hs = 5
|
||||
ctx.fillStyle = color
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 1
|
||||
for (const [hx, hy] of [
|
||||
[p.x, p.y],
|
||||
[p.x + p.w, p.y],
|
||||
[p.x, p.y + p.h],
|
||||
[p.x + p.w, p.y + p.h],
|
||||
]) {
|
||||
ctx.fillRect(hx - hs / 2, hy - hs / 2, hs, hs)
|
||||
ctx.strokeRect(hx - hs / 2, hy - hs / 2, hs, hs)
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
@@ -444,20 +487,106 @@ function onPointerDown(e) {
|
||||
e.preventDefault()
|
||||
const pos = eventPos(e)
|
||||
pointerDownPos.value = { ...pos }
|
||||
drawing.value = null
|
||||
// 角点命中优先:拖动角改大小;否则框内拖动平移;空白处画新框
|
||||
const resizeHit = findResizeHit(pos)
|
||||
if (resizeHit) {
|
||||
resizeIndex.value = resizeHit.idx
|
||||
resizeCorner.value = resizeHit.corner
|
||||
draggingIndex.value = -1
|
||||
dragOffset.value = null
|
||||
return canvasEl.value.setPointerCapture(e.pointerId)
|
||||
}
|
||||
resizeIndex.value = -1
|
||||
resizeCorner.value = null
|
||||
const hit = confirmed.value.findIndex((b) => pointInBox(pos, b))
|
||||
if (hit >= 0) {
|
||||
draggingIndex.value = hit
|
||||
dragOffset.value = { x: pos.x - confirmed.value[hit].cx, y: pos.y - confirmed.value[hit].cy }
|
||||
} else {
|
||||
draggingIndex.value = -1
|
||||
dragOffset.value = null
|
||||
}
|
||||
canvasEl.value.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
// 点击框选中(审核模式:不做手动画框,疑似框由 VLM 补检产出,人工仅确认/删除)
|
||||
function onPointerMove(e) {
|
||||
if (!pointerDownPos.value) return
|
||||
e.preventDefault()
|
||||
const pos = eventPos(e)
|
||||
if (resizeIndex.value >= 0) {
|
||||
// 拖角改大小:对角固定,被拖角跟随指针,最小边长 MIN_EDGE
|
||||
const b = confirmed.value[resizeIndex.value]
|
||||
const corner = resizeCorner.value
|
||||
let x1 = b.cx - b.w / 2
|
||||
let x2 = b.cx + b.w / 2
|
||||
let y1 = b.cy - b.h / 2
|
||||
let y2 = b.cy + b.h / 2
|
||||
if (corner.includes('w')) x1 = Math.min(pos.x, x2 - MIN_EDGE)
|
||||
if (corner.includes('e')) x2 = Math.max(pos.x, x1 + MIN_EDGE)
|
||||
if (corner.includes('n')) y1 = Math.min(pos.y, y2 - MIN_EDGE)
|
||||
if (corner.includes('s')) y2 = Math.max(pos.y, y1 + MIN_EDGE)
|
||||
b.cx = (x1 + x2) / 2
|
||||
b.cy = (y1 + y2) / 2
|
||||
b.w = x2 - x1
|
||||
b.h = y2 - y1
|
||||
draw()
|
||||
return
|
||||
}
|
||||
if (draggingIndex.value >= 0) {
|
||||
// 拖动已有框:平移保持尺寸,中心限制在画面内
|
||||
const b = confirmed.value[draggingIndex.value]
|
||||
b.cx = Math.min(1 - b.w / 2, Math.max(b.w / 2, pos.x - dragOffset.value.x))
|
||||
b.cy = Math.min(1 - b.h / 2, Math.max(b.h / 2, pos.y - dragOffset.value.y))
|
||||
draw()
|
||||
return
|
||||
}
|
||||
drawing.value = {
|
||||
x: Math.min(pointerDownPos.value.x, pos.x),
|
||||
y: Math.min(pointerDownPos.value.y, pos.y),
|
||||
w: Math.abs(pos.x - pointerDownPos.value.x),
|
||||
h: Math.abs(pos.y - pointerDownPos.value.y),
|
||||
}
|
||||
draw()
|
||||
}
|
||||
|
||||
// 按下点在已有框内且拖动 → 平移框;空白处拖动 → 画新红框(人工补标,class 0 确认框);
|
||||
// 未拖动为点击:点框选中高亮/点空白取消
|
||||
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) {
|
||||
if (resizeIndex.value >= 0) {
|
||||
if (moved >= 0.03) markDirty()
|
||||
resizeIndex.value = -1
|
||||
resizeCorner.value = null
|
||||
draw()
|
||||
return
|
||||
}
|
||||
if (draggingIndex.value >= 0) {
|
||||
if (moved >= 0.03) markDirty()
|
||||
draggingIndex.value = -1
|
||||
dragOffset.value = null
|
||||
draw()
|
||||
return
|
||||
}
|
||||
if (drawing.value && drawing.value.w * drawing.value.h > 0.0002) {
|
||||
confirmed.value.push({
|
||||
cx: drawing.value.x + drawing.value.w / 2,
|
||||
cy: drawing.value.y + drawing.value.h / 2,
|
||||
w: drawing.value.w,
|
||||
h: drawing.value.h,
|
||||
confidence: 1,
|
||||
class: 0,
|
||||
})
|
||||
markDirty()
|
||||
} else if (moved < 0.03) {
|
||||
const idx = confirmed.value.findIndex((b) => pointInBox(pos, b))
|
||||
if (idx >= 0) toggleHighlight(idx)
|
||||
else stopHighlight()
|
||||
}
|
||||
drawing.value = null
|
||||
draw()
|
||||
}
|
||||
|
||||
@@ -469,6 +598,29 @@ function pointInBox(pos, box) {
|
||||
return pos.x >= x1 && pos.x <= x2 && pos.y >= y1 && pos.y <= y2
|
||||
}
|
||||
|
||||
// 角点缩放命中:返回 {idx, corner};命中角点优先于框内拖动
|
||||
function findResizeHit(pos) {
|
||||
for (let i = 0; i < confirmed.value.length; i++) {
|
||||
const b = confirmed.value[i]
|
||||
const x1 = b.cx - b.w / 2
|
||||
const x2 = b.cx + b.w / 2
|
||||
const y1 = b.cy - b.h / 2
|
||||
const y2 = b.cy + b.h / 2
|
||||
const corners = [
|
||||
{ corner: 'nw', x: x1, y: y1 },
|
||||
{ corner: 'ne', x: x2, y: y1 },
|
||||
{ corner: 'sw', x: x1, y: y2 },
|
||||
{ corner: 'se', x: x2, y: y2 },
|
||||
]
|
||||
for (const c of corners) {
|
||||
if (Math.abs(pos.x - c.x) <= HIT_R && Math.abs(pos.y - c.y) <= HIT_R) {
|
||||
return { idx: i, corner: c.corner }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
dirty.value = true
|
||||
}
|
||||
@@ -636,7 +788,7 @@ onBeforeUnmount(() => {
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="editor-toolbar">
|
||||
<span class="tip">点框选中 · 列表确认疑似/删除误检 · VLM 自动标注审核</span>
|
||||
<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>
|
||||
@@ -655,6 +807,7 @@ onBeforeUnmount(() => {
|
||||
ref="canvasEl"
|
||||
class="wb-canvas"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,7 @@ const dlgForm = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
namePrefix: '',
|
||||
sortOrder: 0, // 序号(列表排序主键,升序;同号按创建时间倒序)
|
||||
description: '',
|
||||
cover: '',
|
||||
pendingCover: '', // 新建预生成封面文件名(创建请求回传写库)
|
||||
@@ -83,6 +84,7 @@ function openCreate() {
|
||||
id: 0,
|
||||
name: '',
|
||||
namePrefix: '',
|
||||
sortOrder: 0,
|
||||
description: '',
|
||||
cover: '',
|
||||
pendingCover: '',
|
||||
@@ -105,6 +107,7 @@ function openEdit(row) {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
namePrefix: row.namePrefix || '',
|
||||
sortOrder: row.sortOrder ?? 0,
|
||||
description: row.description || '',
|
||||
cover: row.cover || '',
|
||||
pendingCover: '',
|
||||
@@ -165,7 +168,7 @@ async function submitDlg() {
|
||||
// 创建会同步调 VLM 生成参数池 + z-image 生成封面,超时放宽 180s
|
||||
const res = await request.post(
|
||||
'/datasets',
|
||||
{ name, namePrefix: dlgForm.namePrefix.trim(), source: 'manual', cover: dlgForm.pendingCover || undefined },
|
||||
{ name, namePrefix: dlgForm.namePrefix.trim(), source: 'manual', cover: dlgForm.pendingCover || undefined, sortOrder: dlgForm.sortOrder || 0 },
|
||||
{ timeout: 180000 },
|
||||
)
|
||||
id = res.id
|
||||
@@ -196,6 +199,7 @@ async function submitDlg() {
|
||||
namePrefix: dlgForm.namePrefix.trim(),
|
||||
description: dlgForm.description.trim(),
|
||||
cover: '',
|
||||
sortOrder: dlgForm.sortOrder || 0,
|
||||
})
|
||||
}
|
||||
if (dlgForm.mode === 'edit') ElMessage.success('已保存')
|
||||
@@ -379,6 +383,7 @@ onBeforeUnmount(() => {
|
||||
<div class="ds-name" :title="row.name">{{ row.name }}</div>
|
||||
<div class="ds-desc">{{ row.description || '暂无描述,点击进入管理图片与标注' }}</div>
|
||||
<div class="ds-stats">
|
||||
<span v-if="row.sortOrder">序号 {{ row.sortOrder }}</span>
|
||||
<span>图片 {{ row.imageCount }}</span>
|
||||
<span>已标注 {{ row.labeledCount }}</span>
|
||||
<el-tag :type="statusTag[row.status] || 'info'" size="small">{{ statusMap[row.status] || row.status }}</el-tag>
|
||||
@@ -455,6 +460,10 @@ onBeforeUnmount(() => {
|
||||
placeholder="中文/字母/数字/下划线/短横线,唯一且作磁盘目录名"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="序号">
|
||||
<el-input-number v-model="dlgForm.sortOrder" :min="0" :max="999999" :step="1" controls-position="right" style="width: 160px" />
|
||||
<span class="field-tip">列表按序号升序排列,同号按创建时间倒序;默认 0</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="文件名前缀">
|
||||
<el-input v-model="dlgForm.namePrefix" maxlength="50" placeholder="可选,如 pigeon:AI 生成图按 pigeon_01.jpg 顺序命名;留空用时间戳命名" />
|
||||
</el-form-item>
|
||||
|
||||
Reference in New Issue
Block a user