Compare commits
2 Commits
52b769c09a
...
82370d687c
| Author | SHA1 | Date |
|---|---|---|
|
|
82370d687c | |
|
|
228cedab07 |
|
|
@ -8,7 +8,7 @@
|
|||
<el-button icon="el-icon-arrow-right" @click="nextImage" title="下一张" :disabled="imageList.length <= 1"></el-button>
|
||||
<el-button icon="el-icon-zoom-in" @click="zoomIn" title="放大"></el-button>
|
||||
<el-button icon="el-icon-zoom-out" @click="zoomOut" title="缩小"></el-button>
|
||||
<el-button icon="el-icon-refresh" @click="resetZoom" title="重置视图"></el-button>
|
||||
<!-- <el-button icon="el-icon-refresh" @click="resetZoom" title="重置视图"></el-button>-->
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
|
|
@ -121,7 +121,10 @@ import {
|
|||
submitAuditResultAPI // 审核结果提交接口(需根据实际接口名调整)
|
||||
} from '@/api/data/audit'
|
||||
import {decryptWithSM4} from "@/utils/sm";
|
||||
|
||||
import {
|
||||
getLabelingTaskLabelTreeAPI,
|
||||
getLabelPicturesDetailAPI
|
||||
} from '@/api/data/labeling';
|
||||
export default {
|
||||
name: 'LabelingAudit',
|
||||
data() {
|
||||
|
|
@ -131,6 +134,17 @@ export default {
|
|||
polygon: '#0099ff',
|
||||
circle: '#ff9900'
|
||||
},
|
||||
// 标注文字/样式绘制配置(和详情页完全对齐)
|
||||
labelDrawConfig: {
|
||||
baseLineWidth: 3,
|
||||
minLineWidth: 1,
|
||||
maxLineWidth: 6,
|
||||
textFontSize: 16,
|
||||
textPadding: 3,
|
||||
bgOpacity: 0.4, // 标签文字背景透明度
|
||||
shapeFillOpacity: 0.1, // 图形填充透明度
|
||||
textColor: "#ffffff"
|
||||
},
|
||||
imageList: [],
|
||||
currentImageIndex: 0,
|
||||
currentImage: {},
|
||||
|
|
@ -139,10 +153,12 @@ export default {
|
|||
labelLoading: false,
|
||||
ailabelInstance: null,
|
||||
zoomScale: 1.0,
|
||||
// 映射关系(和详情页对齐,用于标注与标签绑定)
|
||||
shapeLabelMap: {},
|
||||
labelShapeMap: {},
|
||||
// 审核相关
|
||||
labelingTaskId: this.convertToNumber(decryptWithSM4(this.$route.query.labelingTaskId || '')),
|
||||
isAuditing: false, // 审核提交状态锁
|
||||
// 新增:审核不通过弹窗相关
|
||||
rejectDialogVisible: false,
|
||||
auditRemark: '' // 审核不通过原因
|
||||
}
|
||||
|
|
@ -157,7 +173,7 @@ export default {
|
|||
this.loadLabelTreeData()
|
||||
},
|
||||
methods: {
|
||||
// 类型转换
|
||||
// 类型转换(复用详情页逻辑)
|
||||
convertToNumber(value) {
|
||||
if (!value || value === '' || value === 'undefined' || value === 'null') {
|
||||
return '';
|
||||
|
|
@ -166,7 +182,46 @@ export default {
|
|||
return isNaN(num) ? '' : num;
|
||||
},
|
||||
|
||||
// 加载审核图片列表
|
||||
// 工具方法1:16进制颜色转RGBA(支持#fff/#ffffff,添加透明度)
|
||||
hexToRgba(hex, opacity) {
|
||||
const cleanHex = hex.replace('#', '');
|
||||
const fullHex = cleanHex.length === 3
|
||||
? cleanHex.split('').map(c => c + c).join('')
|
||||
: cleanHex;
|
||||
const r = parseInt(fullHex.substring(0, 2), 16);
|
||||
const g = parseInt(fullHex.substring(2, 4), 16);
|
||||
const b = parseInt(fullHex.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
},
|
||||
|
||||
// 工具方法2:判断16进制颜色是否为浅色(亮度>0.5为浅色,用于文字颜色智能切换)
|
||||
isLightColor(hex) {
|
||||
const cleanHex = hex.replace('#', '');
|
||||
const fullHex = cleanHex.length === 3
|
||||
? cleanHex.split('').map(c => c + c).join('')
|
||||
: cleanHex;
|
||||
const r = parseInt(fullHex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(fullHex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(fullHex.substring(4, 6), 16) / 255;
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b);
|
||||
return luminance > 0.5;
|
||||
},
|
||||
|
||||
// 动态计算线条宽度:缩放越小线条越粗,保证视觉一致性
|
||||
getDynamicLineWidth() {
|
||||
const { baseLineWidth, minLineWidth, maxLineWidth } = this.labelDrawConfig
|
||||
const dynamicWidth = baseLineWidth / this.zoomScale
|
||||
return Math.max(minLineWidth, Math.min(maxLineWidth, dynamicWidth))
|
||||
},
|
||||
|
||||
// 动态计算标签文字字号:适配缩放比例,限制字号范围
|
||||
getDynamicFontSize() {
|
||||
const { textFontSize } = this.labelDrawConfig
|
||||
const dynamicSize = textFontSize / this.zoomScale
|
||||
return Math.max(10, Math.min(24, dynamicSize))
|
||||
},
|
||||
|
||||
// 加载审核图片列表(和详情页对齐数据处理逻辑)
|
||||
async loadAuditImages() {
|
||||
try {
|
||||
this.imageLoading = true
|
||||
|
|
@ -179,24 +234,25 @@ export default {
|
|||
console.log('审核图片接口返回:', res)
|
||||
|
||||
if (res.code === 200 && res.data && Array.isArray(res.data)) {
|
||||
// 按图片分组处理标注数据
|
||||
// 按labelingSampleId分组处理扁平的标注数据
|
||||
const imageMap = {}
|
||||
|
||||
res.data.forEach(item => {
|
||||
const key = item.labelingSampleId
|
||||
// 初始化图片基础信息
|
||||
if (!imageMap[key]) {
|
||||
imageMap[key] = {
|
||||
id: item.labelingSampleId,
|
||||
datasetSampleId: item.datasetSampleId || item.labelingSampleId,
|
||||
filePath: item.filePath,
|
||||
isInvalidData: item.isInvalidData || '1',
|
||||
shapes: [],
|
||||
shapeLabelMap: {},
|
||||
labelShapeMap: {}
|
||||
shapes: [], // 该图片的所有标注形状
|
||||
shapeLabelMap: {}, // 标注ID -> 标签ID 映射
|
||||
labelShapeMap: {} // 标签ID -> 标注ID列表 映射
|
||||
}
|
||||
}
|
||||
|
||||
// 解析标注坐标
|
||||
// 安全解析标注坐标JSON
|
||||
let shapeData = {}
|
||||
try {
|
||||
shapeData = JSON.parse(item.labelingCoordinate || '{}')
|
||||
|
|
@ -205,10 +261,10 @@ export default {
|
|||
shapeData = {}
|
||||
}
|
||||
|
||||
// 生成标注ID
|
||||
// 生成唯一标注ID(保证图片内唯一)
|
||||
const shapeId = `shape_${item.labelingSampleId}_${item.labelId}_${Date.now()}`
|
||||
|
||||
// 构造标注数据
|
||||
// 构造标注形状数据(完全复用详情页逻辑,绑定接口返回的labelName/color)
|
||||
const shape = {
|
||||
id: shapeId,
|
||||
type: this.getShapeTypeByBoxType(item.boxType),
|
||||
|
|
@ -219,14 +275,15 @@ export default {
|
|||
radius: shapeData.radius || 0,
|
||||
points: shapeData.points || [],
|
||||
labelId: item.labelId,
|
||||
labelName: item.labelName || '未知标签', // 接口返回的标签名称(核心回显字段)
|
||||
textContent: item.textContent || '',
|
||||
color: item.labelingColor || this.shapeColorMap[this.getShapeTypeByBoxType(item.boxType)]
|
||||
color: item.labelingColor || this.shapeColorMap[this.getShapeTypeByBoxType(item.boxType)] // 接口返回的标注颜色
|
||||
}
|
||||
|
||||
// 添加到图片标注列表
|
||||
// 添加到图片的标注列表
|
||||
imageMap[key].shapes.push(shape)
|
||||
|
||||
// 维护映射关系
|
||||
// 维护标注-标签映射关系
|
||||
imageMap[key].shapeLabelMap[shapeId] = item.labelId
|
||||
if (!imageMap[key].labelShapeMap[item.labelId]) {
|
||||
imageMap[key].labelShapeMap[item.labelId] = []
|
||||
|
|
@ -234,7 +291,7 @@ export default {
|
|||
imageMap[key].labelShapeMap[item.labelId].push(shapeId)
|
||||
})
|
||||
|
||||
// 转换为数组
|
||||
// 转换为数组并赋值
|
||||
this.imageList = Object.values(imageMap)
|
||||
|
||||
if (this.imageList.length === 0) {
|
||||
|
|
@ -253,33 +310,44 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// 加载标签树数据(仅展示)
|
||||
// 加载标签树数据(可根据实际审核接口替换,此处保留扩展能力)
|
||||
async loadLabelTreeData() {
|
||||
try {
|
||||
this.labelLoading = true
|
||||
// 注:如果审核接口有单独的标签树接口,请替换此处
|
||||
// 暂时复用原有逻辑,实际项目中请根据后端接口调整
|
||||
this.labelGroups = []
|
||||
this.labelLoading = true;
|
||||
if (!this.labelingTaskId) return;
|
||||
const res = await getLabelingTaskLabelTreeAPI({ labelingTaskId: this.labelingTaskId });
|
||||
if (res.code === 200 && res.data) {
|
||||
console.log('res',res)
|
||||
this.labelGroups = res.data.map(group => ({
|
||||
...group,
|
||||
expanded: true,
|
||||
labels: (group.labels || []).map(label => ({
|
||||
...label,
|
||||
ocrResults: []
|
||||
}))
|
||||
}));
|
||||
} else {
|
||||
this.$message.warning('标签数据加载失败:' + (res.msg || '接口返回异常'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载标签树失败:', error)
|
||||
this.$message.error('标签数据加载失败,请刷新重试')
|
||||
this.labelGroups = []
|
||||
console.error('加载标签树失败:', error);
|
||||
this.$message.error('标签数据加载失败,请刷新重试');
|
||||
} finally {
|
||||
this.labelLoading = false
|
||||
this.labelLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 转换box_type为前端标注类型
|
||||
// 转换后端boxType为前端标注类型(和详情页完全对齐)
|
||||
getShapeTypeByBoxType(boxType) {
|
||||
switch (boxType) {
|
||||
case "1": return 'rect'
|
||||
case "2": return 'polygon'
|
||||
case "3": return 'circle'
|
||||
case "1": return 'rect' // 矩形
|
||||
case "2": return 'polygon' // 多边形
|
||||
case "3": return 'circle' // 圆形
|
||||
default: return 'rect'
|
||||
}
|
||||
},
|
||||
|
||||
// 图片加载完成
|
||||
// 图片加载完成:计算适配缩放 + 初始化标注工具
|
||||
onImageLoad() {
|
||||
this.calcFitScale()
|
||||
if (this.ailabelInstance?.setScale) {
|
||||
|
|
@ -293,12 +361,12 @@ export default {
|
|||
})
|
||||
},
|
||||
|
||||
// 图片加载失败
|
||||
// 图片加载失败提示
|
||||
onImageError() {
|
||||
this.$message.error(`图片加载失败:${this.currentImage.filePath}`)
|
||||
},
|
||||
|
||||
// 计算适配缩放比例
|
||||
// 计算图片适配容器的缩放比例(居中显示,不超出容器)
|
||||
calcFitScale() {
|
||||
const container = this.$refs.labelContainer
|
||||
const image = this.$refs.labelImage
|
||||
|
|
@ -315,28 +383,28 @@ export default {
|
|||
this.zoomScale = Math.max(0.1, Math.min(2.0, this.zoomScale))
|
||||
},
|
||||
|
||||
// 初始化标注展示工具
|
||||
// 初始化标注展示工具(禁用所有编辑功能,仅保留展示)
|
||||
initAILabel() {
|
||||
const container = this.$refs.labelContainer
|
||||
const image = this.$refs.labelImage
|
||||
if (!container || !image || !this.currentImage.filePath) return
|
||||
|
||||
// 销毁旧实例
|
||||
// 销毁旧实例,避免重复渲染
|
||||
this.destroyAILabel()
|
||||
|
||||
try {
|
||||
let instance = null
|
||||
// 适配标注库实例化方式
|
||||
// 适配不同的AILabelLib实例化方式
|
||||
if (AILabelLib.create) {
|
||||
instance = AILabelLib.create({
|
||||
el: container,
|
||||
image: image,
|
||||
config: {
|
||||
enableRect: false,
|
||||
enablePolygon: false,
|
||||
enableCircle: false,
|
||||
enableDrag: false,
|
||||
enableScale: false,
|
||||
enableRect: false, // 禁用矩形编辑
|
||||
enablePolygon: false, // 禁用多边形编辑
|
||||
enableCircle: false, // 禁用圆形编辑
|
||||
enableDrag: false, // 禁用标注拖动
|
||||
enableScale: false, // 禁用标注缩放
|
||||
style: {
|
||||
rect: { strokeStyle: '#00ff00', lineWidth: 2, fillStyle: 'rgba(0,255,0,0.1)' },
|
||||
polygon: { strokeStyle: '#0099ff', lineWidth: 2, fillStyle: 'rgba(0,153,255,0.1)' },
|
||||
|
|
@ -350,13 +418,14 @@ export default {
|
|||
} else if (window.AILabel) {
|
||||
instance = window.AILabel({ el: container, image: image })
|
||||
} else {
|
||||
instance = this.createSimpleLabelTool(container, image)
|
||||
// 自研简易标注工具:挂载当前组件实例,调用样式工具方法
|
||||
instance = this.createSimpleLabelTool(container, image, this)
|
||||
}
|
||||
|
||||
if (instance) {
|
||||
this.ailabelInstance = instance
|
||||
if (instance.setScale) instance.setScale(this.zoomScale)
|
||||
this.loadLabelData()
|
||||
this.loadLabelData() // 加载并绘制标注数据
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('标注工具初始化失败:', e)
|
||||
|
|
@ -364,7 +433,7 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// 销毁标注实例
|
||||
// 销毁标注实例(兼容不同销毁方法)
|
||||
destroyAILabel() {
|
||||
if (this.ailabelInstance) {
|
||||
try {
|
||||
|
|
@ -374,8 +443,8 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// 简易标注展示工具
|
||||
createSimpleLabelTool(container, image) {
|
||||
// 自研简易标注工具(仅展示,和详情页完全对齐标签文字绘制逻辑)
|
||||
createSimpleLabelTool(container, image, instance) {
|
||||
const tool = {
|
||||
el: container,
|
||||
image,
|
||||
|
|
@ -383,9 +452,11 @@ export default {
|
|||
mainCanvas: null,
|
||||
mainCtx: null,
|
||||
zoomScale: 1.0,
|
||||
instance: instance, // 挂载组件实例,调用样式工具方法
|
||||
resizeCanvas: null, // 保存resize方法引用,用于销毁
|
||||
|
||||
init() {
|
||||
// 主画布
|
||||
// 创建主画布:仅展示标注和标签文字,不拦截鼠标事件
|
||||
const mainCanvas = document.createElement('canvas')
|
||||
mainCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10;width:100%;height:100%;'
|
||||
mainCanvas.width = container.offsetWidth
|
||||
|
|
@ -394,27 +465,28 @@ export default {
|
|||
this.mainCanvas = mainCanvas
|
||||
this.mainCtx = mainCanvas.getContext('2d')
|
||||
|
||||
// 监听容器大小变化
|
||||
window.addEventListener('resize', () => this.resizeCanvas())
|
||||
// 绑定resize方法(箭头函数保留this)
|
||||
this.resizeCanvas = () => {
|
||||
this.mainCanvas.width = this.el.offsetWidth
|
||||
this.mainCanvas.height = this.el.offsetHeight
|
||||
this.redrawMainCanvas()
|
||||
}
|
||||
// 监听窗口大小变化,自适应画布
|
||||
window.addEventListener('resize', this.resizeCanvas)
|
||||
|
||||
// 绘制图片和标注
|
||||
// 初始绘制
|
||||
this.redrawMainCanvas()
|
||||
|
||||
return this
|
||||
},
|
||||
|
||||
resizeCanvas() {
|
||||
this.mainCanvas.width = this.el.offsetWidth
|
||||
this.mainCanvas.height = this.el.offsetHeight
|
||||
this.redrawMainCanvas()
|
||||
},
|
||||
|
||||
// 设置缩放比例
|
||||
setScale(scale) {
|
||||
this.zoomScale = scale
|
||||
this.resizeCanvas()
|
||||
},
|
||||
|
||||
// 绘制标注形状
|
||||
// 绘制矩形标注
|
||||
drawRect(x, y, w, h) {
|
||||
const shape = { id: `shape_${Date.now()}`, type: 'rect', x, y, width: w, height: h }
|
||||
this.shapes.push(shape)
|
||||
|
|
@ -422,6 +494,7 @@ export default {
|
|||
return shape.id
|
||||
},
|
||||
|
||||
// 绘制圆形标注
|
||||
drawCircle(x, y, radius) {
|
||||
const shape = { id: `shape_${Date.now()}`, type: 'circle', x, y, radius }
|
||||
this.shapes.push(shape)
|
||||
|
|
@ -429,6 +502,7 @@ export default {
|
|||
return shape.id
|
||||
},
|
||||
|
||||
// 绘制多边形标注
|
||||
drawPolygon(points) {
|
||||
const shape = { id: `shape_${Date.now()}`, type: 'polygon', points }
|
||||
this.shapes.push(shape)
|
||||
|
|
@ -436,25 +510,24 @@ export default {
|
|||
return shape.id
|
||||
},
|
||||
|
||||
// 清空所有标注
|
||||
clearShapes() {
|
||||
this.shapes = []
|
||||
this.mainCtx.clearRect(0, 0, this.mainCanvas.width, this.mainCanvas.height)
|
||||
},
|
||||
|
||||
getShapes() {
|
||||
return [...this.shapes]
|
||||
},
|
||||
|
||||
// 设置标注数据(绑定labelName/color等回显字段)
|
||||
setShapeData(id, data) {
|
||||
const shape = this.shapes.find(s => s.id === id)
|
||||
if (shape) Object.assign(shape, data)
|
||||
},
|
||||
|
||||
// 核心:重绘画布,绘制图片+标注+标签文字(和详情页完全对齐)
|
||||
redrawMainCanvas() {
|
||||
this.mainCtx.clearRect(0, 0, this.mainCanvas.width, this.mainCanvas.height)
|
||||
this.mainCtx.save()
|
||||
|
||||
// 居中逻辑
|
||||
// 画布居中+缩放逻辑(和编辑/详情页保持一致)
|
||||
this.mainCtx.translate(
|
||||
this.el.offsetWidth / 2,
|
||||
this.el.offsetHeight / 2
|
||||
|
|
@ -465,7 +538,7 @@ export default {
|
|||
-this.image.naturalWidth / 2,
|
||||
-this.image.naturalHeight / 2
|
||||
)
|
||||
// 绘制图片
|
||||
// 绘制底图
|
||||
this.mainCtx.drawImage(
|
||||
this.image,
|
||||
0, 0,
|
||||
|
|
@ -474,24 +547,31 @@ export default {
|
|||
)
|
||||
}
|
||||
|
||||
// 绘制所有标注
|
||||
// 获取动态样式(适配缩放,保证视觉一致)
|
||||
const dynamicLineWidth = this.instance.getDynamicLineWidth()
|
||||
const dynamicFontSize = this.instance.getDynamicFontSize()
|
||||
const { textPadding, bgOpacity, shapeFillOpacity } = this.instance.labelDrawConfig
|
||||
|
||||
// 遍历绘制所有标注 + 标签文字
|
||||
this.shapes.forEach(s => {
|
||||
if (!s.color) return; // 无颜色则不绘制
|
||||
|
||||
// 设置标注图形基础样式
|
||||
this.mainCtx.lineWidth = dynamicLineWidth
|
||||
this.mainCtx.strokeStyle = s.color
|
||||
this.mainCtx.fillStyle = this.instance.hexToRgba(s.color, shapeFillOpacity)
|
||||
|
||||
// 绘制不同类型的标注图形
|
||||
if (s.type === 'rect') {
|
||||
this.mainCtx.strokeStyle = s.color || '#00ff00'
|
||||
this.mainCtx.fillStyle = 'rgba(0,255,0,0.1)'
|
||||
this.mainCtx.strokeRect(s.x, s.y, s.width, s.height)
|
||||
this.mainCtx.fillRect(s.x, s.y, s.width, s.height)
|
||||
} else if (s.type === 'circle') {
|
||||
this.mainCtx.strokeStyle = s.color || '#ff9900'
|
||||
this.mainCtx.fillStyle = 'rgba(255,153,0,0.1)'
|
||||
this.mainCtx.beginPath()
|
||||
this.mainCtx.arc(s.x, s.y, s.radius, 0, Math.PI * 2)
|
||||
this.mainCtx.closePath()
|
||||
this.mainCtx.fill()
|
||||
this.mainCtx.stroke()
|
||||
} else if (s.type === 'polygon') {
|
||||
this.mainCtx.strokeStyle = s.color || '#0099ff'
|
||||
this.mainCtx.fillStyle = 'rgba(0,153,255,0.1)'
|
||||
} else if (s.type === 'polygon' && s.points.length >= 3) {
|
||||
this.mainCtx.beginPath()
|
||||
s.points.forEach((point, idx) => {
|
||||
idx === 0 ? this.mainCtx.moveTo(point.x, point.y) : this.mainCtx.lineTo(point.x, point.y)
|
||||
|
|
@ -500,11 +580,53 @@ export default {
|
|||
this.mainCtx.fill()
|
||||
this.mainCtx.stroke()
|
||||
}
|
||||
|
||||
// 绘制标签名称:有labelName才绘制
|
||||
if (!s.labelName) return;
|
||||
// 计算不同形状的中心坐标(文字居中显示)
|
||||
let textX, textY;
|
||||
if (s.type === 'rect') {
|
||||
textX = s.x + s.width / 2;
|
||||
textY = s.y + s.height / 2;
|
||||
} else if (s.type === 'circle') {
|
||||
textX = s.x;
|
||||
textY = s.y;
|
||||
} else if (s.type === 'polygon' && s.points.length >= 3) {
|
||||
const totalX = s.points.reduce((sum, p) => sum + p.x, 0);
|
||||
const totalY = s.points.reduce((sum, p) => sum + p.y, 0);
|
||||
textX = totalX / s.points.length;
|
||||
textY = totalY / s.points.length;
|
||||
} else {
|
||||
return; // 无效形状不绘制文字
|
||||
}
|
||||
|
||||
// 设置标签文字基础样式
|
||||
this.mainCtx.font = `${dynamicFontSize}px Microsoft YaHei`
|
||||
this.mainCtx.textAlign = 'center'
|
||||
this.mainCtx.textBaseline = 'middle'
|
||||
|
||||
// 计算文字背景框大小(自适应文字长度)
|
||||
const textWidth = this.mainCtx.measureText(s.labelName).width
|
||||
const textHeight = dynamicFontSize
|
||||
|
||||
// 绘制文字背景遮罩:使用标注颜色+透明度,保证文字可读性
|
||||
this.mainCtx.fillStyle = this.instance.hexToRgba(s.color, bgOpacity)
|
||||
this.mainCtx.fillRect(
|
||||
textX - (textWidth + 2 * textPadding) / 2,
|
||||
textY - (textHeight + 2 * textPadding) / 2,
|
||||
textWidth + 2 * textPadding,
|
||||
textHeight + 2 * textPadding
|
||||
)
|
||||
|
||||
// 绘制标签文字:智能切换黑白(根据标注颜色深浅,和详情页对齐)
|
||||
this.mainCtx.fillStyle = this.instance.isLightColor(s.color) ? '#000000' : '#ffffff'
|
||||
this.mainCtx.fillText(s.labelName, textX, textY)
|
||||
})
|
||||
|
||||
this.mainCtx.restore()
|
||||
},
|
||||
|
||||
// 销毁工具:移除画布+解绑事件,避免内存泄漏
|
||||
destroy() {
|
||||
this.mainCanvas?.remove()
|
||||
window.removeEventListener('resize', this.resizeCanvas)
|
||||
|
|
@ -513,22 +635,27 @@ export default {
|
|||
return tool.init()
|
||||
},
|
||||
|
||||
// 加载标注数据(仅展示)
|
||||
// 加载标注数据(仅展示,绑定labelName/color等核心回显字段)
|
||||
loadLabelData() {
|
||||
if (!this.ailabelInstance || !this.currentImage) return
|
||||
|
||||
// 清空现有标注
|
||||
this.ailabelInstance.clearShapes?.()
|
||||
|
||||
// 恢复映射关系
|
||||
// 恢复当前图片的标注-标签映射关系
|
||||
if (this.currentImage.shapeLabelMap) {
|
||||
this.shapeLabelMap = JSON.parse(JSON.stringify(this.currentImage.shapeLabelMap))
|
||||
}
|
||||
if (this.currentImage.labelShapeMap) {
|
||||
this.labelShapeMap = JSON.parse(JSON.stringify(this.currentImage.labelShapeMap))
|
||||
}
|
||||
|
||||
// 绘制标注
|
||||
// 遍历绘制所有标注,并绑定标签名称/颜色
|
||||
const shapes = this.currentImage.shapes || []
|
||||
shapes.forEach(shapeData => {
|
||||
try {
|
||||
let shapeId = ''
|
||||
// 根据标注类型绘制图形
|
||||
if (shapeData.type === 'rect' && shapeData.x !== undefined) {
|
||||
shapeId = this.ailabelInstance.drawRect?.(shapeData.x, shapeData.y, shapeData.width, shapeData.height) || shapeData.id
|
||||
} else if (shapeData.type === 'polygon' && shapeData.points && shapeData.points.length >= 3) {
|
||||
|
|
@ -537,10 +664,12 @@ export default {
|
|||
shapeId = this.ailabelInstance.drawCircle?.(shapeData.x, shapeData.y, shapeData.radius) || shapeData.id
|
||||
}
|
||||
|
||||
// 绑定标注样式
|
||||
// 绑定标签名称、颜色、ID(核心:标签文字回显的关键)
|
||||
if (shapeId) {
|
||||
this.ailabelInstance.setShapeData?.(shapeId, {
|
||||
color: shapeData.color || this.shapeColorMap[shapeData.type]
|
||||
labelName: shapeData.labelName || '未知标签',
|
||||
color: shapeData.color || this.shapeColorMap[shapeData.type],
|
||||
labelId: shapeData.labelId
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -549,7 +678,7 @@ export default {
|
|||
})
|
||||
},
|
||||
|
||||
// 滚轮缩放
|
||||
// 滚轮缩放:缩放后强制重绘,保证文字/线条视觉一致
|
||||
handleWheelZoom(e) {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
|
|
@ -558,30 +687,35 @@ export default {
|
|||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale)
|
||||
}
|
||||
// 缩放后强制重绘,避免样式错乱
|
||||
this.ailabelInstance?.redrawMainCanvas?.()
|
||||
},
|
||||
|
||||
// 放大图片
|
||||
// 放大图片:步长0.1,限制最大2.0倍
|
||||
zoomIn() {
|
||||
this.zoomScale = Math.min(2.0, this.zoomScale + 0.1)
|
||||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale)
|
||||
}
|
||||
this.ailabelInstance?.redrawMainCanvas?.()
|
||||
},
|
||||
|
||||
// 缩小图片
|
||||
// 缩小图片:步长0.1,限制最小0.1倍(和详情页对齐缩放范围)
|
||||
zoomOut() {
|
||||
this.zoomScale = Math.max(0.1, this.zoomScale - 0.1)
|
||||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale)
|
||||
}
|
||||
this.ailabelInstance?.redrawMainCanvas?.()
|
||||
},
|
||||
|
||||
// 重置缩放
|
||||
// 重置缩放到1.0倍
|
||||
resetZoom() {
|
||||
this.zoomScale = 1.0
|
||||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale)
|
||||
}
|
||||
this.ailabelInstance?.redrawMainCanvas?.()
|
||||
},
|
||||
|
||||
// 上一张图片
|
||||
|
|
@ -602,51 +736,61 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// 切换图片
|
||||
|
||||
nextImage1() {
|
||||
if (this.currentImageIndex <= this.imageList.length - 1) {
|
||||
this.switchImage(this.currentImageIndex)
|
||||
} else {
|
||||
this.$message.info('已是最后一张!')
|
||||
this.$router.push({
|
||||
path: '/data/audit',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 切换图片:重新初始化标注工具,加载新图片的标注
|
||||
switchImage(index) {
|
||||
this.currentImageIndex = index
|
||||
this.currentImage = this.imageList[index]
|
||||
this.shapeLabelMap = {}
|
||||
this.labelShapeMap = {}
|
||||
|
||||
// 重新初始化标注展示
|
||||
// 下一次DOM更新后初始化标注,避免DOM未渲染完成
|
||||
this.$nextTick(() => {
|
||||
this.initAILabel()
|
||||
})
|
||||
},
|
||||
|
||||
// 审核通过
|
||||
// 审核通过:提交状态1
|
||||
async auditPass() {
|
||||
await this.submitAuditResult(1)
|
||||
},
|
||||
|
||||
// 新增:打开审核不通过弹窗
|
||||
// 打开审核不通过弹窗:清空输入框
|
||||
openRejectDialog() {
|
||||
// 每次打开弹窗清空原因输入框
|
||||
this.auditRemark = ''
|
||||
this.rejectDialogVisible = true
|
||||
},
|
||||
|
||||
// 新增:关闭弹窗时的处理
|
||||
// 关闭弹窗:清空输入框,避免残留
|
||||
handleDialogClose() {
|
||||
this.rejectDialogVisible = false
|
||||
this.auditRemark = ''
|
||||
},
|
||||
|
||||
// 新增:确认审核不通过并提交
|
||||
// 确认审核不通过:校验原因 + 提交状态2
|
||||
async confirmReject() {
|
||||
// 校验原因是否填写
|
||||
if (!this.auditRemark.trim()) {
|
||||
this.$message.warning('请填写审核不通过的原因!')
|
||||
return
|
||||
}
|
||||
// 提交审核不通过结果
|
||||
await this.submitAuditResult(2)
|
||||
// 提交成功后关闭弹窗
|
||||
this.rejectDialogVisible = false
|
||||
},
|
||||
|
||||
// 提交审核结果
|
||||
// 提交审核结果(通用方法,1-通过,2-不通过)
|
||||
async submitAuditResult(auditStatus) {
|
||||
if (this.isAuditing) return
|
||||
if (this.isAuditing) return // 防止重复提交
|
||||
if (!this.currentImage.id) {
|
||||
this.$message.error('当前图片ID为空,无法提交审核!')
|
||||
return
|
||||
|
|
@ -656,26 +800,24 @@ export default {
|
|||
this.isAuditing = true
|
||||
const loadingInstance = this.$loading({ text: '提交审核结果中...' })
|
||||
|
||||
// 构造审核提交参数
|
||||
// 构造提交参数
|
||||
const submitParams = {
|
||||
labelingTaskId: this.labelingTaskId,
|
||||
labelingSampleId: this.currentImage.id,
|
||||
auditStatus: auditStatus, // 1-通过,2-不通过
|
||||
datasetSampleId: this.currentImage.datasetSampleId || '',
|
||||
auditRemark: auditStatus === 2 ? this.auditRemark.trim() : '' // 仅审核不通过时传递原因
|
||||
auditRemark: auditStatus === 2 ? this.auditRemark.trim() : '' // 仅不通过时传递原因
|
||||
}
|
||||
|
||||
console.log('提交审核参数:', submitParams)
|
||||
|
||||
// 调用审核提交接口
|
||||
const res = await submitAuditResultAPI(submitParams)
|
||||
|
||||
if (res.code === 200) {
|
||||
this.$message.success(auditStatus === 1 ? '审核通过提交成功!' : '审核不通过提交成功!')
|
||||
|
||||
// 自动切换到下一张(如果有)
|
||||
if (this.currentImageIndex < this.imageList.length - 1) {
|
||||
this.nextImage()
|
||||
await this.loadAuditImages();
|
||||
this.nextImage1()
|
||||
}
|
||||
} else {
|
||||
this.$message.error('提交失败:' + (res.msg || '接口返回异常'))
|
||||
|
|
@ -696,7 +838,7 @@ export default {
|
|||
// 全局容器
|
||||
.labeling-page-container {
|
||||
width: 100%;
|
||||
height: 92vh;
|
||||
height: 90.5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
|
@ -885,6 +1027,13 @@ export default {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-label-data {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -898,7 +1047,7 @@ export default {
|
|||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: black;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.reject-textarea {
|
||||
|
|
@ -907,7 +1056,7 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
// 滚动条样式优化(和详情页对齐)
|
||||
::v-deep .el-scrollbar__bar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
|
@ -916,5 +1065,8 @@ export default {
|
|||
::v-deep .el-scrollbar__thumb {
|
||||
background-color: #ccc;
|
||||
border-radius: 3px;
|
||||
&:hover {
|
||||
background-color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -684,7 +684,7 @@ export default {
|
|||
// 全局容器
|
||||
.labeling-page-container {
|
||||
width: 100%;
|
||||
height: 92vh;
|
||||
height: 90.5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
|
|
@ -554,6 +554,17 @@ export default {
|
|||
this.$message.info('已是最后一张!');
|
||||
}
|
||||
},
|
||||
|
||||
nextImage1() {
|
||||
if (this.currentImageIndex <= this.imageList.length - 1) {
|
||||
this.switchImage(this.currentImageIndex + 1);
|
||||
} else {
|
||||
this.$message.info('已是最后一张!')
|
||||
this.$router.push({
|
||||
path: '/data/audit',
|
||||
});
|
||||
}
|
||||
},
|
||||
switchImage(index) {
|
||||
this.currentImageIndex = index;
|
||||
this.currentImage = this.imageList[index];
|
||||
|
|
@ -630,7 +641,8 @@ export default {
|
|||
this.$message.success(auditStatus === 1 ? '审核通过提交成功!' : '审核不通过提交成功!');
|
||||
// 自动切换到下一张(如果有)
|
||||
if (this.currentImageIndex < this.imageList.length - 1) {
|
||||
this.nextImage();
|
||||
await this.loadAuditImages();
|
||||
this.nextImage1();
|
||||
}
|
||||
} else {
|
||||
this.$message.error('提交失败:' + (res.msg || '接口返回异常'));
|
||||
|
|
|
|||
|
|
@ -524,7 +524,8 @@ export default {
|
|||
datasetSampleId: this.currentImage.datasetSampleId || '',
|
||||
isInvalidData: this.isInvalidData ? "0" : "1", // 保持原接口字段规则
|
||||
labelingDataList: this.selectedLabelIds.map(labelId => ({
|
||||
labelId: labelId
|
||||
labelId: labelId,
|
||||
boxType: '0'
|
||||
}))
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@
|
|||
<!-- 无效数据标记 -->
|
||||
<el-checkbox v-model="isInvalidData" label="标为无效数据"></el-checkbox>
|
||||
<!-- 保存按钮组 -->
|
||||
<el-button type="primary" @click="saveLabel" class="save-btn">保存</el-button>
|
||||
<el-button type="success" @click="saveAndContinue" class="save-btn">保存并继续</el-button>
|
||||
<!-- <el-button type="primary" @click="saveLabel" class="save-btn">保存</el-button>-->
|
||||
<el-button type="primary" @click="saveAndContinue" class="save-btn">保存并继续</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ import {
|
|||
getLabelPicturesAPI, // 获取标注图片列表
|
||||
saveLabelingDataAPI
|
||||
} from '@/api/data/labeling'
|
||||
import {decryptWithSM4} from "@/utils/sm"; // 请根据实际路径调整
|
||||
import {decryptWithSM4, encryptWithSM4} from "@/utils/sm"; // 请根据实际路径调整
|
||||
|
||||
export default {
|
||||
name: 'LabelingAdd',
|
||||
|
|
@ -713,21 +713,21 @@ export default {
|
|||
},
|
||||
|
||||
onImageLoad() {
|
||||
this.imageLoaded = true
|
||||
this.calcFitScale()
|
||||
// 关键修复:先设置缩放,再强制触发重绘
|
||||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale)
|
||||
this.imageLoaded = true;
|
||||
// 1. 重新计算新图片的适配缩放比例(核心:基于新图片尺寸)
|
||||
this.calcFitScale();
|
||||
// 2. 若工具已存在,先更新工具内部的image引用(解决工具层尺寸错位)
|
||||
if (this.ailabelInstance) {
|
||||
this.ailabelInstance.image = this.$refs.labelImage;
|
||||
}
|
||||
this.initAILabel()
|
||||
// 3. 设置缩放+初始化工具+重绘
|
||||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale);
|
||||
}
|
||||
this.initAILabel();
|
||||
this.$nextTick(() => {
|
||||
if (this.ailabelInstance?.redrawMainCanvas) {
|
||||
this.ailabelInstance.redrawMainCanvas()
|
||||
}
|
||||
// this.recordState()
|
||||
this.ailabelInstance?.redrawMainCanvas()
|
||||
|
||||
})
|
||||
this.ailabelInstance?.redrawMainCanvas?.();
|
||||
});
|
||||
},
|
||||
|
||||
getDynamicLineWidth() {
|
||||
|
|
@ -1163,12 +1163,15 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// 自研工具内部的isShapeOutOfImage方法(替换原有代码)
|
||||
isShapeOutOfImage(shape) {
|
||||
if (!this.image) return true; // 图片未加载则默认越界
|
||||
// 获取图片原始尺寸
|
||||
const imgWidth = this.image.naturalWidth;
|
||||
const imgHeight = this.image.naturalHeight;
|
||||
// 不同形状的边界检测逻辑
|
||||
// 关键:动态从组件层获取当前图片,而非工具初始化时的this.image
|
||||
const currentImage = this.instance.$refs.labelImage;
|
||||
if (!currentImage) return true; // 无当前图片则默认越界
|
||||
// 获取当前图片的原始真实尺寸
|
||||
const imgWidth = currentImage.naturalWidth;
|
||||
const imgHeight = currentImage.naturalHeight;
|
||||
// 不同形状的边界检测逻辑(原有逻辑保留,仅替换尺寸来源)
|
||||
switch (shape.type) {
|
||||
case 'rect':
|
||||
return shape.x < 0 || shape.y < 0 || shape.x + shape.width > imgWidth || shape.y + shape.height > imgHeight;
|
||||
|
|
@ -1180,7 +1183,6 @@ export default {
|
|||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 绘制临时形状
|
||||
// 自研工具中的临时绘制方法:drawTempShape(修改线条粗细)
|
||||
drawTempShape() {
|
||||
|
|
@ -1837,37 +1839,35 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
nextImage1() {
|
||||
if (this.currentImageIndex < this.imageList.length - 1) {
|
||||
this.switchImage(this.currentImageIndex)
|
||||
} else {
|
||||
this.$message.info('已是最后一张!')
|
||||
}
|
||||
},
|
||||
|
||||
// 切换图片
|
||||
async switchImage(index) {
|
||||
// 检查是否有未保存的标注
|
||||
const currentShapes = this.ailabelInstance?.getShapes?.() || [];
|
||||
if (currentShapes.length > 0 || this.isInvalidData) {
|
||||
try {
|
||||
// 先保存当前标注(无提示,静默保存)
|
||||
await this.saveLabel();
|
||||
} catch (err) {
|
||||
// 保存失败则提示用户
|
||||
const confirm = await this.$confirm('当前图片标注未保存,是否放弃修改并切换?', '提示', {
|
||||
type: 'warning'
|
||||
});
|
||||
if (!confirm) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到新图片
|
||||
// 1. 先销毁旧的标注工具,释放资源
|
||||
this.destroyAILabel();
|
||||
// 2. 重置组件层所有状态(原有逻辑保留)
|
||||
this.currentImageIndex = index;
|
||||
this.currentImage = this.imageList[index];
|
||||
this.imageLoaded = false;
|
||||
|
||||
this.imageLoaded = false; // 标记新图片未加载
|
||||
this.resetLabelState();
|
||||
|
||||
// 重置撤销/恢复栈(原有逻辑保留)
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.zoomScale = 1.0; // 重置缩放比例,避免沿用前一张的缩放
|
||||
|
||||
// 重新初始化标注工具(原有逻辑保留)
|
||||
// 3. 关键:移除直接initAILabel,交给onImageLoad处理
|
||||
// 手动触发img标签重新加载(解决部分浏览器缓存不触发onImageLoad的问题)
|
||||
this.$nextTick(() => {
|
||||
this.initAILabel();
|
||||
const img = this.$refs.labelImage;
|
||||
if (img) {
|
||||
img.src = ''; // 清空src
|
||||
img.src = this.currentImage.filePath; // 重新赋值,触发onImageLoad
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -2064,7 +2064,7 @@ export default {
|
|||
if (res.code === 200) {
|
||||
this.$message.success('标注保存成功!');
|
||||
// 保存后更新本地数据(深拷贝避免引用问题)
|
||||
this.saveCurrentLabelData();
|
||||
await this.loadTreeData();
|
||||
// 重置撤销/恢复栈(保存后视为新起点)
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
|
|
@ -2090,10 +2090,12 @@ export default {
|
|||
// 等待保存完成
|
||||
await this.saveLabel();
|
||||
this.isInvalidData = false;
|
||||
if (this.currentImageIndex < this.imageList.length - 1) {
|
||||
this.nextImage();
|
||||
if (this.currentImageIndex <= this.imageList.length - 1) {
|
||||
this.nextImage1();
|
||||
} else {
|
||||
this.$message.info('已是最后一张图片!');
|
||||
this.$router.push({
|
||||
path: '/data/labeling',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.$message.error('保存失败,无法继续');
|
||||
|
|
|
|||
|
|
@ -74,9 +74,9 @@
|
|||
<div
|
||||
class="label-item"
|
||||
v-for="(label, labelIndex) in group.labels"
|
||||
:key="label.labelId || labelIndex"
|
||||
:key="label.id || labelIndex"
|
||||
>
|
||||
<span class="label-name">#{{ labelIndex + 1 }} {{ label.labelName }} </span>
|
||||
<span class="label-name">#{{ labelIndex + 1 }} {{ label.name }} </span>
|
||||
<span class="shape-count" v-if="label.shapeIds && label.shapeIds.length">
|
||||
({{ label.shapeIds.length }}个标注)
|
||||
</span>
|
||||
|
|
@ -109,28 +109,17 @@ export default {
|
|||
name: 'LabelingDetail',
|
||||
data() {
|
||||
return {
|
||||
// 标签绘制配置:复用原标注页的核心配置
|
||||
// 标注样式配置:控制文字、背景、线条
|
||||
labelDrawConfig: {
|
||||
baseLineWidth: 3,
|
||||
minLineWidth: 1,
|
||||
maxLineWidth: 6,
|
||||
textFontSize: 16,
|
||||
textPadding: 3,
|
||||
shapeMainColor: {
|
||||
rect: '#00ff00', // 矩形填充主色
|
||||
polygon: '#0099ff', // 多边形填充主色
|
||||
circle: '#ff9900' // 圆形填充主色
|
||||
},
|
||||
selectedColor: '#ff0000', // 选中时所有形状的填充/描边主色
|
||||
bgOpacity: 0.4, // 文字背景透明度
|
||||
shapeFillOpacity: 0.4, // 图形自身填充透明度
|
||||
bgOpacity: 0.4, // 标签文字背景透明度
|
||||
shapeFillOpacity: 0.1, // 图形填充透明度
|
||||
textColor: "#ffffff"
|
||||
},
|
||||
shapeColorMap: {
|
||||
rect: '#00ff00', // 矩形 - 绿色
|
||||
polygon: '#0099ff', // 多边形 - 蓝色
|
||||
circle: '#ff9900' // 圆形 - 橙色
|
||||
},
|
||||
// 图片列表(从详情接口获取)
|
||||
imageList: [],
|
||||
currentImageIndex: 0,
|
||||
|
|
@ -174,7 +163,7 @@ export default {
|
|||
return isNaN(num) ? '' : num;
|
||||
},
|
||||
|
||||
// 【核心工具方法1】16进制颜色转RGBA(支持#fff/#ffffff格式,添加透明度)
|
||||
// 工具方法1:16进制颜色转RGBA(支持#fff/#ffffff格式,添加透明度)
|
||||
hexToRgba(hex, opacity) {
|
||||
const cleanHex = hex.replace('#', '');
|
||||
const fullHex = cleanHex.length === 3
|
||||
|
|
@ -186,7 +175,7 @@ export default {
|
|||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
},
|
||||
|
||||
// 【核心工具方法2】判断16进制颜色是否为浅色(基于亮度公式,亮度>0.5为浅色)
|
||||
// 工具方法2:判断16进制颜色是否为浅色(基于亮度公式,亮度>0.5为浅色)
|
||||
isLightColor(hex) {
|
||||
const cleanHex = hex.replace('#', '');
|
||||
const fullHex = cleanHex.length === 3
|
||||
|
|
@ -195,35 +184,22 @@ export default {
|
|||
const r = parseInt(fullHex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(fullHex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(fullHex.substring(4, 6), 16) / 255;
|
||||
// 国际标准相对亮度公式,判断颜色深浅最准确
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b);
|
||||
return luminance > 0.5;
|
||||
},
|
||||
|
||||
// 【核心工具方法3】动态计算线条宽度(适配缩放)
|
||||
// 动态计算线条宽度:缩放越小线条越粗,保证视觉一致
|
||||
getDynamicLineWidth() {
|
||||
const { baseLineWidth, minLineWidth, maxLineWidth } = this.labelDrawConfig
|
||||
// 缩放比例越小,线条越粗(反比例),并限制最大/最小值
|
||||
const dynamicWidth = baseLineWidth / this.zoomScale
|
||||
return Math.max(minLineWidth, Math.min(maxLineWidth, dynamicWidth))
|
||||
},
|
||||
|
||||
// 【核心工具方法4】动态计算标签文字字号(适配缩放)
|
||||
// 动态计算标签文字字号:适配缩放比例
|
||||
getDynamicFontSize() {
|
||||
const { textFontSize } = this.labelDrawConfig
|
||||
// 文字字号反比例适配,保证缩放后文字大小视觉一致
|
||||
const dynamicSize = textFontSize / this.zoomScale
|
||||
return Math.max(10, Math.min(24, dynamicSize)) // 限制字号范围,避免过大/过小
|
||||
},
|
||||
|
||||
// 【工具方法】根据标签ID查找标签详情
|
||||
findLabelById(labelId) {
|
||||
for (const group of this.labelGroups) {
|
||||
for (const label of group.labels) {
|
||||
if (label.labelId === labelId) return label
|
||||
}
|
||||
}
|
||||
return null
|
||||
return Math.max(10, Math.min(24, dynamicSize)) // 限制字号范围
|
||||
},
|
||||
|
||||
// 加载标签树数据(仅展示)
|
||||
|
|
@ -255,7 +231,7 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// 加载详情图片列表
|
||||
// 加载详情图片列表:适配接口返回的labelName/labelingColor
|
||||
async loadTreeData() {
|
||||
try {
|
||||
this.imageLoading = true
|
||||
|
|
@ -266,7 +242,7 @@ export default {
|
|||
console.log('标注详情接口返回:', res)
|
||||
|
||||
if (res.code === 200 && res.data && Array.isArray(res.data)) {
|
||||
// 处理后端返回的扁平数据,按图片分组
|
||||
// 处理后端返回的扁平数据,按labelingSampleId分组
|
||||
const imageMap = {}
|
||||
|
||||
// 遍历所有标注数据,按labeling_sample_id分组
|
||||
|
|
@ -295,9 +271,9 @@ export default {
|
|||
}
|
||||
|
||||
// 生成标注ID
|
||||
const shapeId = `shape_${item.labelingSampleId}_${item.id || Date.now()}`
|
||||
const shapeId = `shape_${item.labelingSampleId}_${item.labelId}_${Date.now()}`
|
||||
|
||||
// 构造标注形状数据
|
||||
// 构造标注形状数据:直接复用接口的labelName和labelingColor
|
||||
const shape = {
|
||||
id: shapeId,
|
||||
type: this.getShapeTypeByBoxType(item.boxType), // 转换box_type为标注类型
|
||||
|
|
@ -308,8 +284,9 @@ export default {
|
|||
radius: shapeData.radius || 0,
|
||||
points: shapeData.points || [],
|
||||
labelId: item.labelId,
|
||||
labelName: item.labelName || '未知标签', // 接口返回的标签名称
|
||||
textContent: item.textContent || '',
|
||||
color: item.labelingColor || this.shapeColorMap[this.getShapeTypeByBoxType(item.boxType)]
|
||||
color: item.labelingColor || '#00ff00' // 接口返回的标注颜色
|
||||
}
|
||||
|
||||
// 添加到图片的标注列表
|
||||
|
|
@ -426,7 +403,7 @@ export default {
|
|||
} else if (window.AILabel) {
|
||||
instance = window.AILabel({ el: container, image: image })
|
||||
} else {
|
||||
// 自研工具传入当前实例,用于调用工具方法
|
||||
// 传入当前组件实例,让工具能调用样式工具方法
|
||||
instance = this.createSimpleLabelTool(container, image, this)
|
||||
}
|
||||
|
||||
|
|
@ -453,21 +430,22 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
// 自研简易标注工具(仅展示,集成标签名称绘制逻辑)
|
||||
createSimpleLabelTool(container, image, parentInstance) {
|
||||
// 自研简易标注工具(仅展示,新增标签名称绘制,移除所有编辑功能)
|
||||
createSimpleLabelTool(container, image, instance) {
|
||||
const tool = {
|
||||
el: container,
|
||||
image,
|
||||
shapes: [],
|
||||
selectedShape: null,
|
||||
mainCanvas: null,
|
||||
tempCanvas: null,
|
||||
mainCtx: null,
|
||||
tempCtx: null,
|
||||
zoomScale: 1.0,
|
||||
// 挂载父组件实例,用于调用工具方法和获取配置
|
||||
parent: parentInstance,
|
||||
instance: instance, // 挂载组件实例,调用样式工具方法
|
||||
|
||||
init() {
|
||||
// 主画布(仅展示标注+标签文字)
|
||||
// 主画布(仅展示标注+标签名称)
|
||||
const mainCanvas = document.createElement('canvas')
|
||||
mainCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10;width:100%;height:100%;'
|
||||
mainCanvas.width = container.offsetWidth
|
||||
|
|
@ -492,13 +470,22 @@ export default {
|
|||
this.redrawMainCanvas()
|
||||
},
|
||||
|
||||
// 绘制临时形状(空实现,移除编辑功能)
|
||||
drawTempShape() {},
|
||||
|
||||
// 取消绘制(空实现)
|
||||
cancelDrawing() {},
|
||||
|
||||
// 设置绘制类型(空实现)
|
||||
setDrawType() {},
|
||||
|
||||
// 设置缩放
|
||||
setScale(scale) {
|
||||
this.zoomScale = scale
|
||||
this.resizeCanvas()
|
||||
},
|
||||
|
||||
// 绘制形状(仅展示)
|
||||
// 绘制形状(仅展示,保留ID用于数据绑定)
|
||||
drawRect(x, y, w, h) {
|
||||
const shape = { id: `shape_${Date.now()}`, type: 'rect', x, y, width: w, height: h }
|
||||
this.shapes.push(shape)
|
||||
|
|
@ -531,12 +518,12 @@ export default {
|
|||
return [...this.shapes]
|
||||
},
|
||||
|
||||
// 【核心修改】重绘画布(展示标注+居中绘制标签名称)
|
||||
// 重绘画布:核心修改-绘制标注+中心标签名称,适配缩放
|
||||
redrawMainCanvas() {
|
||||
this.mainCtx.clearRect(0, 0, this.mainCanvas.width, this.mainCanvas.height)
|
||||
this.mainCtx.save()
|
||||
|
||||
// 画布居中+缩放逻辑(原有保留)
|
||||
// 画布居中+缩放逻辑:和编辑页保持一致
|
||||
this.mainCtx.translate(
|
||||
this.el.offsetWidth / 2,
|
||||
this.el.offsetHeight / 2
|
||||
|
|
@ -556,24 +543,20 @@ export default {
|
|||
)
|
||||
}
|
||||
|
||||
// 获取动态配置(适配缩放)
|
||||
const dynamicLineWidth = this.parent.getDynamicLineWidth()
|
||||
const dynamicFontSize = this.parent.getDynamicFontSize()
|
||||
const { textPadding, bgOpacity, shapeFillOpacity, shapeMainColor } = this.parent.labelDrawConfig
|
||||
// 获取动态样式:线条宽度、字号(适配缩放)
|
||||
const dynamicLineWidth = this.instance.getDynamicLineWidth()
|
||||
const dynamicFontSize = this.instance.getDynamicFontSize()
|
||||
const { textPadding, bgOpacity, shapeFillOpacity } = this.instance.labelDrawConfig
|
||||
|
||||
// 绘制所有标注+标签文字
|
||||
// 绘制所有标注+标签名称
|
||||
this.shapes.forEach(s => {
|
||||
// 1. 获取标注基础信息
|
||||
const shapeColor = s.color || shapeMainColor[s.type] || '#000000'
|
||||
const labelId = s.labelId || ''
|
||||
const label = this.parent.findLabelById(labelId)
|
||||
|
||||
// 2. 设置标注绘制样式
|
||||
if (!s.color) return; // 无颜色则不绘制
|
||||
// 设置基础样式
|
||||
this.mainCtx.lineWidth = dynamicLineWidth
|
||||
this.mainCtx.strokeStyle = shapeColor
|
||||
this.mainCtx.fillStyle = this.parent.hexToRgba(shapeColor, shapeFillOpacity)
|
||||
this.mainCtx.strokeStyle = s.color
|
||||
this.mainCtx.fillStyle = this.instance.hexToRgba(s.color, shapeFillOpacity)
|
||||
|
||||
// 3. 绘制标注图形(原有逻辑保留,样式统一)
|
||||
// 绘制不同类型的标注图形
|
||||
if (s.type === 'rect') {
|
||||
this.mainCtx.strokeRect(s.x, s.y, s.width, s.height)
|
||||
this.mainCtx.fillRect(s.x, s.y, s.width, s.height)
|
||||
|
|
@ -583,7 +566,7 @@ export default {
|
|||
this.mainCtx.closePath()
|
||||
this.mainCtx.fill()
|
||||
this.mainCtx.stroke()
|
||||
} else if (s.type === 'polygon') {
|
||||
} else if (s.type === 'polygon' && s.points.length >= 3) {
|
||||
this.mainCtx.beginPath()
|
||||
s.points.forEach((point, idx) => {
|
||||
idx === 0 ? this.mainCtx.moveTo(point.x, point.y) : this.mainCtx.lineTo(point.x, point.y)
|
||||
|
|
@ -593,36 +576,36 @@ export default {
|
|||
this.mainCtx.stroke()
|
||||
}
|
||||
|
||||
// 4. 绘制标签名称(核心逻辑:无标签则不绘制)
|
||||
if (!label) return
|
||||
// 计算标注图形的正中心坐标(不同形状通用)
|
||||
let textX, textY
|
||||
// 绘制标签名称:有labelName才绘制
|
||||
if (!s.labelName) return;
|
||||
// 计算图形中心坐标(不同形状中心计算逻辑)
|
||||
let textX, textY;
|
||||
if (s.type === 'rect') {
|
||||
textX = s.x + s.width / 2
|
||||
textY = s.y + s.height / 2
|
||||
textX = s.x + s.width / 2;
|
||||
textY = s.y + s.height / 2;
|
||||
} else if (s.type === 'circle') {
|
||||
textX = s.x
|
||||
textY = s.y
|
||||
} else if (s.type === 'polygon') {
|
||||
const totalX = s.points.reduce((sum, p) => sum + p.x, 0)
|
||||
const totalY = s.points.reduce((sum, p) => sum + p.y, 0)
|
||||
textX = totalX / s.points.length
|
||||
textY = totalY / s.points.length
|
||||
textX = s.x;
|
||||
textY = s.y;
|
||||
} else if (s.type === 'polygon' && s.points.length >= 3) {
|
||||
const totalX = s.points.reduce((sum, p) => sum + p.x, 0);
|
||||
const totalY = s.points.reduce((sum, p) => sum + p.y, 0);
|
||||
textX = totalX / s.points.length;
|
||||
textY = totalY / s.points.length;
|
||||
} else {
|
||||
return; // 无效形状不绘制文字
|
||||
}
|
||||
|
||||
// 5. 设置文字基础样式
|
||||
// 设置文字样式
|
||||
this.mainCtx.font = `${dynamicFontSize}px Microsoft YaHei`
|
||||
this.mainCtx.textAlign = 'center' // 水平居中
|
||||
this.mainCtx.textBaseline = 'middle' // 垂直居中
|
||||
this.mainCtx.textAlign = 'center'
|
||||
this.mainCtx.textBaseline = 'middle'
|
||||
|
||||
// 6. 智能计算文字背景和文字颜色(适配不同标注颜色)
|
||||
const bgRgba = this.parent.hexToRgba(shapeColor, bgOpacity)
|
||||
const textColor = this.parent.isLightColor(shapeColor) ? '#000000' : '#ffffff'
|
||||
|
||||
// 7. 绘制文字背景框(适配文字宽度,居中展示)
|
||||
const textWidth = this.mainCtx.measureText(label.labelName).width
|
||||
// 计算文字背景框大小
|
||||
const textWidth = this.mainCtx.measureText(s.labelName).width
|
||||
const textHeight = dynamicFontSize
|
||||
this.mainCtx.fillStyle = bgRgba
|
||||
|
||||
// 绘制文字背景遮罩:自适应颜色+透明度
|
||||
this.mainCtx.fillStyle = this.instance.hexToRgba(s.color, bgOpacity)
|
||||
this.mainCtx.fillRect(
|
||||
textX - (textWidth + 2 * textPadding) / 2,
|
||||
textY - (textHeight + 2 * textPadding) / 2,
|
||||
|
|
@ -630,15 +613,18 @@ export default {
|
|||
textHeight + 2 * textPadding
|
||||
)
|
||||
|
||||
// 8. 绘制标签文字
|
||||
this.mainCtx.fillStyle = textColor
|
||||
this.mainCtx.fillText(label.labelName, textX, textY)
|
||||
// 绘制标签文字:智能切换黑白(根据背景色深浅)
|
||||
this.mainCtx.fillStyle = this.instance.isLightColor(s.color) ? '#000000' : '#ffffff'
|
||||
this.mainCtx.fillText(s.labelName, textX, textY)
|
||||
})
|
||||
|
||||
this.mainCtx.restore()
|
||||
},
|
||||
|
||||
// 形状数据操作(绑定labelId/color等信息)
|
||||
// 事件绑定(空实现)
|
||||
on() {},
|
||||
|
||||
// 形状数据操作:绑定labelName/color等核心数据
|
||||
setShapeData(id, data) {
|
||||
const shape = this.shapes.find(s => s.id === id)
|
||||
if (shape) Object.assign(shape, data)
|
||||
|
|
@ -657,7 +643,7 @@ export default {
|
|||
return tool.init()
|
||||
},
|
||||
|
||||
// 滚轮缩放
|
||||
// 滚轮缩放:缩放后重绘保证文字/线条视觉一致
|
||||
handleWheelZoom(e) {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
|
|
@ -666,9 +652,11 @@ export default {
|
|||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale)
|
||||
}
|
||||
// 缩放后强制重绘
|
||||
this.ailabelInstance?.redrawMainCanvas?.()
|
||||
},
|
||||
|
||||
// 加载标注数据(仅展示,绑定labelId用于绘制标签)
|
||||
// 加载标注数据(仅展示,绑定labelName/color到形状)
|
||||
loadLabelData() {
|
||||
if (!this.ailabelInstance || !this.currentImage) return
|
||||
|
||||
|
|
@ -683,12 +671,12 @@ export default {
|
|||
this.labelShapeMap = JSON.parse(JSON.stringify(this.currentImage.labelShapeMap))
|
||||
}
|
||||
|
||||
// 重新绘制标注
|
||||
// 重新绘制标注并绑定标签名称/颜色
|
||||
const shapes = this.currentImage.shapes || []
|
||||
shapes.forEach(shapeData => {
|
||||
try {
|
||||
let shapeId = ''
|
||||
// 根据标注类型绘制图形
|
||||
// 根据形状类型绘制
|
||||
if (shapeData.type === 'rect' && shapeData.x !== undefined) {
|
||||
shapeId = this.ailabelInstance.drawRect?.(shapeData.x, shapeData.y, shapeData.width, shapeData.height) || shapeData.id
|
||||
} else if (shapeData.type === 'polygon' && shapeData.points && shapeData.points.length >= 3) {
|
||||
|
|
@ -697,11 +685,12 @@ export default {
|
|||
shapeId = this.ailabelInstance.drawCircle?.(shapeData.x, shapeData.y, shapeData.radius) || shapeData.id
|
||||
}
|
||||
|
||||
// 【核心】绑定标注的labelId和color(用于绘制标签名称)
|
||||
// 绑定标签名称、颜色等核心数据(关键:用于绘制文字)
|
||||
if (shapeId) {
|
||||
this.ailabelInstance.setShapeData?.(shapeId, {
|
||||
labelId: shapeData.labelId, // 绑定标签ID,用于查找标签名称
|
||||
color: shapeData.color || this.shapeColorMap[shapeData.type] // 绑定标注颜色
|
||||
labelName: shapeData.labelName || '未知标签',
|
||||
color: shapeData.color || '#00ff00',
|
||||
labelId: shapeData.labelId
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -710,28 +699,31 @@ export default {
|
|||
})
|
||||
},
|
||||
|
||||
// 放大图片
|
||||
// 放大图片:强制重绘
|
||||
zoomIn() {
|
||||
this.zoomScale = Math.min(2.0, this.zoomScale + 0.1)
|
||||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale)
|
||||
}
|
||||
this.ailabelInstance?.redrawMainCanvas?.()
|
||||
},
|
||||
|
||||
// 缩小图片
|
||||
// 缩小图片:强制重绘
|
||||
zoomOut() {
|
||||
this.zoomScale = Math.max(0.5, this.zoomScale - 0.1)
|
||||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale)
|
||||
}
|
||||
this.ailabelInstance?.redrawMainCanvas?.()
|
||||
},
|
||||
|
||||
// 重置缩放
|
||||
// 重置缩放:强制重绘
|
||||
resetZoom() {
|
||||
this.zoomScale = 1.0
|
||||
if (this.ailabelInstance?.setScale) {
|
||||
this.ailabelInstance.setScale(this.zoomScale)
|
||||
}
|
||||
this.ailabelInstance?.redrawMainCanvas?.()
|
||||
},
|
||||
|
||||
// 上一张图片
|
||||
|
|
@ -774,6 +766,7 @@ export default {
|
|||
handleShapeDeleted() {},
|
||||
selectLabel() {},
|
||||
findShapeIdsByLabelId() { return [] },
|
||||
findLabelById() { return null },
|
||||
removeLabelById() {},
|
||||
confirmAddLabel() {},
|
||||
deleteSelected() {},
|
||||
|
|
@ -794,7 +787,7 @@ export default {
|
|||
// 全局容器
|
||||
.labeling-page-container {
|
||||
width: 100%;
|
||||
height: 92vh;
|
||||
height: 90.5vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@
|
|||
<!-- 无效数据标记 -->
|
||||
<el-checkbox v-model="isInvalidData" label="标为无效数据"></el-checkbox>
|
||||
<!-- 保存按钮组 -->
|
||||
<el-button type="primary" @click="saveLabel" class="save-btn">保存</el-button>
|
||||
<el-button type="success" @click="saveAndContinue" class="save-btn">保存并继续</el-button>
|
||||
<!-- <el-button type="primary" @click="saveLabel" class="save-btn">保存</el-button>-->
|
||||
<el-button type="primary" @click="saveAndContinue" class="save-btn">保存并继续</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1057,6 +1057,14 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
nextImage1() {
|
||||
if (this.currentImageIndex < this.imageList.length - 1) {
|
||||
this.switchImage(this.currentImageIndex);
|
||||
} else {
|
||||
this.$message.info('已是最后一张!');
|
||||
}
|
||||
},
|
||||
|
||||
async switchImage(index) {
|
||||
// 1. 保存当前标注(原有逻辑不变)
|
||||
if (this.ailabelInstance?.getShapes()?.length > 0 || this.isInvalidData) {
|
||||
|
|
@ -1182,7 +1190,7 @@ export default {
|
|||
height: Number(shape.height) || 0,
|
||||
points: points.map(p => ({ x: p[0], y: p[1] }))
|
||||
}),
|
||||
boxType: shape.type === 'rect' ? "1" : "2", // 1-矩形 2-四边形
|
||||
boxType: '4', // 1-矩形 2-四边形
|
||||
textContent: ocrData.textContent || '' // 核心:OCR内容赋值给textContent
|
||||
});
|
||||
}
|
||||
|
|
@ -1201,6 +1209,7 @@ export default {
|
|||
this.$message.success('OCR标注保存成功!');
|
||||
this.currentImage.isInvalid = this.isInvalidData;
|
||||
this.imageList[this.currentImageIndex] = this.currentImage;
|
||||
await this.loadTreeData();
|
||||
return Promise.resolve(res);
|
||||
} else {
|
||||
const errorMsg = '保存失败:' + (res.msg || '接口异常');
|
||||
|
|
@ -1221,10 +1230,12 @@ export default {
|
|||
try {
|
||||
await this.saveLabel();
|
||||
this.isInvalidData = false;
|
||||
if (this.currentImageIndex < this.imageList.length - 1) {
|
||||
this.nextImage();
|
||||
if (this.currentImageIndex <= this.imageList.length - 1) {
|
||||
this.nextImage1();
|
||||
} else {
|
||||
this.$message.info('已是最后一张图片!');
|
||||
this.$router.push({
|
||||
path: '/data/labeling',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.$message.error('保存失败,无法继续');
|
||||
|
|
|
|||
Loading…
Reference in New Issue