受 LabelImg 啟發(fā)的基于 web 的圖像標(biāo)注工具,基于 Vue 框架 喲,網(wǎng)友們好,年更鴿子終于想起了他的博客園密碼。如標(biāo)題所述,今天給大家?guī)?lái)的是一個(gè)基于 vue2 的圖像標(biāo)注工具。至于它誕生的契機(jī)呢,應(yīng)該是我導(dǎo) pass 掉了我的提議(讓甲方使用 LabelImg 進(jìn)行數(shù)據(jù)標(biāo)注),說(shuō)是要
受 LabelImg 啟發(fā)的基于 web 的圖像標(biāo)注工具,基于 Vue 框架
喲,網(wǎng)友們好,年更鴿子終于想起了他的博客園密碼。如標(biāo)題所述,今天給大家?guī)?lái)的是一個(gè)基于 vue2 的圖像標(biāo)注工具。至于它誕生的契機(jī)呢,應(yīng)該是我導(dǎo) pass 掉了我的提議( 讓甲方使用 LabelImg 進(jìn)行數(shù)據(jù)標(biāo)注 ),說(shuō)是要把功能集成起來(lái)。截止到寫(xiě)這篇文章時(shí)完成度應(yīng)該有90%,至于剩下的10%嘛,問(wèn)就是相信網(wǎng)友的智慧(其實(shí)就是不包括數(shù)據(jù)持久化),想必一定難不倒看文章的各位。那么廢話不多說(shuō),下面進(jìn)入正文。
項(xiàng)目地址: https://github.com/xiao-qi-w/LabelVue.git
視頻演示:敬請(qǐng)期待...
首先我們對(duì) LabelImg 進(jìn)行一個(gè)簡(jiǎn)單的介紹,這樣屏幕前的你會(huì)對(duì)我的設(shè)計(jì)思路有更準(zhǔn)確地認(rèn)知。
LabelImg 是一個(gè)開(kāi)源的圖像標(biāo)注工具,主要用于創(chuàng)建機(jī)器學(xué)習(xí)模型所需的訓(xùn)練數(shù)據(jù)。它支持標(biāo)注圖像中的對(duì)象,通過(guò)提供界面來(lái)創(chuàng)建矩形框(bounding boxes)并對(duì)其進(jìn)行分類(lèi)。主要特點(diǎn)包括:
適合用于物體檢測(cè)任務(wù)的數(shù)據(jù)準(zhǔn)備階段。
其工作界面及基本功能介紹如下:
從圖中不難看出其實(shí)要實(shí)現(xiàn)的功能并不多,重點(diǎn)在于矩形框標(biāo)注的繪制、拖動(dòng)與縮放上面。而前端想要實(shí)現(xiàn)這些操作,當(dāng)然是推薦使用 canvas。
canvas 是 HTML5 提供的一個(gè)元素,用于在網(wǎng)頁(yè)上繪制圖形和動(dòng)畫(huà)。它允許在網(wǎng)頁(yè)中直接繪制和操作圖像、形狀和文本,主要通過(guò) JavaScript 進(jìn)行控制。主要特點(diǎn)包括:
使用 元素可以創(chuàng)建動(dòng)態(tài)、交互式的圖形和視覺(jué)效果。
在這里鳴謝B站 up 主 渡一教育-提薪課 和 尚硅谷 ,我的 vue 和 canvas 功底全靠二位的視頻撐著。
介紹完了前置內(nèi)容,下面來(lái)看看核心代碼。
首先是頁(yè)面布局,我是按照下面的方式進(jìn)行劃分的,代碼結(jié)構(gòu)和 css如下:
代碼結(jié)構(gòu):
css:
介紹完布局后,我們?cè)賮?lái)看看需要用到的各種響應(yīng)式變量:
data() {
return {
/* 圖片相關(guān) */
images: [ // 每個(gè)圖像可以是更復(fù)雜的對(duì)象結(jié)構(gòu),但要保證具備可訪問(wèn)到的相對(duì)路徑(url)
{
id: 1,
url: require('@/assets/cat.jpg'),
},
{
id: 2,
url: require('@/assets/bay.jpg'),
},
],
/* 狀態(tài)變量 */
creating: false, // 是否正在創(chuàng)建
canvasChanged: false, // 畫(huà)布狀態(tài)是否改變
showNameInput: false, // 是否顯示標(biāo)注命名彈窗
showSaveAlert: false, // 是否顯示保存提示彈窗
/* 縮放相關(guān) */
dpr: 1, // 設(shè)備像素比
scale: 0, // 縮放倍率
maxScale: 3.0, // 最大縮放倍率
minScale: 0.1, // 最小縮放倍率
adaptiveScale: 0, // 自適應(yīng)縮放倍率
scaleStep: 0.1, // 縮放變化幅度
/* 鼠標(biāo)上一刻所在位置 */
prevX: 0,
prevY: 0,
/* 鼠標(biāo)實(shí)時(shí)位置 */
currentX: 0,
currentY: 0,
/* 緩存 */
currentImage: null, // 當(dāng)前圖像
currentImageIndex: 0, // 當(dāng)前圖像在圖像列表中的下標(biāo)
targetImageIndex: -1, // 目標(biāo)圖像在圖像列表中的下標(biāo),切換圖片時(shí)使用
wrapper: null, // canvas 父級(jí)元素 DOM
canvas: null, // 當(dāng)前 canvas
bufferCanvas: null, // 離屏 canvas,緩存用
currentRect: null, // 當(dāng)前矩形
selectedRect: null, // 選中矩形
selectedRectIndex: -1, // 選中矩形在矩形列表中的下標(biāo)
labelName: "", // 矩形標(biāo)簽
rects: [], // 保存當(dāng)前圖片的矩形
};
},
然后是圖像部分,使用 canvas 繪制并展示,主要體現(xiàn)在以下方法中:
loadImage() {
this.currentImage = new Image();
this.currentImage.src = this.imagePath;
this.currentImage.onload = () => {
this.currentImage.width *= this.dpr;
this.currentImage.height *= this.dpr;
this.setSize();
this.drawCanvas();
};
}
setSize() {
// 未設(shè)置縮放倍率
if (this.scale === 0) {
// 獲取所在容器寬高
const width = this.wrapper.clientWidth * this.dpr;
const height = this.wrapper.clientHeight * this.dpr;
// 計(jì)算縮放比例
const scaleX = width / this.currentImage.width;
const scaleY = height / this.currentImage.height;
this.scale = Math.min(scaleX, scaleY);
this.adaptiveScale = this.scale;
}
// 計(jì)算縮放后的圖片尺寸
const scaledWidth = this.currentImage.width * this.scale;
const scaledHeight = this.currentImage.height * this.scale;
// 設(shè)置畫(huà)布寬高
this.canvas.width = scaledWidth;
this.canvas.height = scaledHeight;
this.canvas.style.width = `${scaledWidth / this.dpr}px`;
this.canvas.style.height = `${scaledHeight / this.dpr}px`;
// 設(shè)置離屏畫(huà)布寬高
this.bufferCanvas.width = scaledWidth;
this.bufferCanvas.height = scaledHeight;
this.bufferCanvas.style.width = `${scaledWidth / this.dpr}px`;
this.bufferCanvas.style.height = `${scaledHeight / this.dpr}px`;
// 設(shè)置居中
this.$nextTick(() => {
// 設(shè)置垂直居中
if (this.wrapper.clientHeight <= scaledHeight / this.dpr) {
// 畫(huà)布高度超過(guò)父元素視窗高度時(shí),取消居中設(shè)置
this.wrapper.style.justifyContent = '';
} else {
// 畫(huà)布高度未超過(guò)父元素視窗高度時(shí),重新居中設(shè)置
this.wrapper.style.justifyContent = 'center';
}
// 設(shè)置水平居中
if (this.wrapper.clientWidth <= scaledWidth / this.dpr) {
// 畫(huà)布寬度超過(guò)父元素視窗寬度時(shí),取消居中設(shè)置
this.wrapper.style.alignItems = '';
} else {
// 畫(huà)布寬度未超過(guò)父元素視窗寬度時(shí),重新居中設(shè)置
this.wrapper.style.alignItems = 'center';
}
});
}
drawCanvas() {
const ctx = this.canvas.getContext('2d');
const bufferCtx = this.bufferCanvas.getContext('2d');
const width = this.canvas.width;
const height = this.canvas.height;
// 繪制縮放后的圖片到離屏畫(huà)布
bufferCtx.clearRect(0, 0, width, height);
bufferCtx.drawImage(this.currentImage, 0, 0, width, height);
// 繪制已創(chuàng)建矩形
if (this.currentRect) {
this.currentRect.draw(this.scale);
}
for (const rect of this.rects) {
if (rect === this.selectedRect) {
rect.color = 'rgba(255, 0, 0, 0.3)';
} else {
rect.color = 'rgba(0, 0, 255, 0.3)';
}
rect.draw(this.scale);
}
// 將縮放后的圖片繪制到主畫(huà)布
ctx.drawImage(this.bufferCanvas, 0, 0, width, height);
}
繪制方法中使用到了 bufferCanvas,一個(gè)隱藏的 canvas 元素作為緩存,主要是為了避免繪制矩形框標(biāo)注時(shí)因重繪頻率過(guò)高產(chǎn)生的畫(huà)面閃爍現(xiàn)象。繪制效果如下:
有了圖片,接下來(lái)就是考慮如何繪制矩形框標(biāo)注了,主要是鼠標(biāo)按下事件,鼠標(biāo)移動(dòng)事件和鼠標(biāo)抬起事件。代碼如下:
handleMouseDown(e) {
const mouseX = e.offsetX;
const mouseY = e.offsetY;
this.prevX = mouseX;
this.prevY = mouseY;
// 找出被選中的矩形
this.selectedRect = null;
this.selectedRectIndex = -1;
for (let i = this.rects.length - 1; i > -1; i--) {
const rect = this.rects[i];
if (rect.isSelected(mouseX, mouseY)) {
this.selectedRect = rect;
this.selectedRectIndex = i;
break;
}
}
if (this.creating) {
// 新建
const bufferCtx = this.bufferCanvas.getContext('2d');
this.currentRect = new Rect(bufferCtx, this.dpr, mouseX, mouseY, this.scale);
} else if (this.selectedRect) {
// 拖動(dòng)或縮放
this.selectedRect.mouseDown(mouseX, mouseY);
}
}
handleMouseMove(e) {
// 獲取鼠標(biāo)在Canvas中的坐標(biāo)
const mouseX = e.offsetX;
const mouseY = e.offsetY;
this.currentX = mouseX;
this.currentY = mouseY;
const ctx = this.canvas.getContext('2d');
if (this.creating) {
// 新建
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.drawImage(this.bufferCanvas, 0, 0);
// 繪制交叉輔助線
ctx.beginPath();
ctx.moveTo(mouseX * this.dpr, 0);
ctx.lineTo(mouseX * this.dpr, this.canvas.height);
ctx.moveTo(0, mouseY * this.dpr);
ctx.lineTo(this.canvas.width, mouseY * this.dpr);
ctx.strokeStyle = 'red'; // 設(shè)置線條顏色
ctx.stroke();
if (!this.currentRect) return;
this.currentRect.maxX = mouseX;
this.currentRect.maxY = mouseY;
} else if (this.selectedRect) {
// 拖動(dòng)或縮放
this.selectedRect.mouseMove(e, this);
}
// 畫(huà)布狀態(tài)發(fā)生變化重新渲染
if (this.creating || this.selectedRect) {
this.drawCanvas(); // 繪制背景和已有矩形
}
}
handleMouseUp(e) {
if (this.creating) {
// 新建
this.currentRect.maxX = e.offsetX;
this.currentRect.maxY = e.offsetY;
this.creating = false;
// 矩形形狀合法,加入到矩形集合
if (this.currentRect.minX !== this.currentRect.maxX
&& this.currentRect.minY !== this.currentRect.maxY) {
this.showNameInput = true;
}
} else if (this.selectedRect) {
// 拖動(dòng)或縮放
this.selectedRect.mouseUp(this.currentImage.width, this.currentImage.height);
}
this.drawCanvas();
}
這三種鼠標(biāo)事件與實(shí)際矩形框標(biāo)注的繪制離不開(kāi)自定義矩形類(lèi)提供的方法,矩形類(lèi)定義如下:
export default class Rect {
constructor(ctx, dpr, startX, startY, scale) {
this.name = 'undefined';
this.timestamp = Date.now();
/* 繪制相關(guān) */
this.ctx = ctx;
this.dpr = dpr;
this.color = 'rgba(0, 0, 255, 0.3)';
this.minX = startX;
this.minY = startY;
this.maxX = startX;
this.maxY = startY;
this.vertexSize = 8 * dpr;
/* 縮放相關(guān) */
this.scale = scale;
this.realScale = scale;
/* 狀態(tài)相關(guān) */
this.dragging = false;
this.resizing = false;
this.changed = true;
this.vertexIndex = -1;
}
/**
* 調(diào)整起止坐標(biāo)
*/
adjustCoordinate() {
let temp = 0;
if (this.minX > this.maxX) {
temp = this.minX;
this.minX = this.maxX;
this.maxX = temp;
}
if (this.minY > this.maxY) {
temp = this.minY;
this.minY = this.maxY;
this.maxY = temp;
}
}
/**
* 繪制矩形
* @param scale 縮放倍率
*/
draw(scale) {
if (this.minX === this.maxX || this.minY === this.maxY) {
return;
}
this.realScale = 1 / this.scale * scale;
const factor = this.realScale * this.dpr;
const minX = this.minX * factor;
const minY = this.minY * factor;
const maxX = this.maxX * factor;
const maxY = this.maxY * factor;
this.ctx.beginPath();
this.ctx.moveTo(minX, minY);
this.ctx.lineTo(maxX, minY);
this.ctx.lineTo(maxX, maxY);
this.ctx.lineTo(minX, maxY);
this.ctx.lineTo(minX, minY);
this.ctx.fillStyle = this.color;
this.ctx.strokeStyle = "#fff";
this.ctx.lineWidth = 1;
this.ctx.lineCap = 'square';
this.ctx.fill();
this.ctx.stroke();
// 繪制四個(gè)頂點(diǎn)
this.drawVertex(minX, maxX, minY, maxY);
}
/**
* 繪制矩形四個(gè)頂點(diǎn)
* @param minX 縮放后的最小橫坐標(biāo)
* @param maxX 縮放后的最大橫坐標(biāo)
* @param minY 縮放后的最小縱坐標(biāo)
* @param maxY 縮放后的最大縱坐標(biāo)
*/
drawVertex(minX, maxX, minY, maxY) {
if (this.dragging || this.resizing) {
this.ctx.fillStyle = '#FF4500'; // 拖動(dòng)或縮放狀態(tài),紅色頂點(diǎn)
} else {
this.ctx.fillStyle = '#A7FC00'; // 正常狀態(tài),青色頂點(diǎn)
}
const size = this.vertexSize;
this.ctx.fillRect(minX - size / 2, minY - size / 2, size, size);
this.ctx.fillRect(maxX - size / 2, minY - size / 2, size, size);
this.ctx.fillRect(maxX - size / 2, maxY - size / 2, size, size);
this.ctx.fillRect(minX - size / 2, maxY - size / 2, size, size);
}
/**
* 根據(jù)坐標(biāo)(x, y)判斷矩形是否被選中
* @param x 橫坐標(biāo)
* @param y 縱坐標(biāo)
*/
isSelected(x, y) {
return this.isPointInside(x, y) || this.isPointInsideVertex(x, y) !== -1;
}
/**
* 判斷坐標(biāo)(x, y)是否在矩形內(nèi)部
* @param x 橫坐標(biāo)
* @param y 縱坐標(biāo)
*/
isPointInside(x, y) {
x = x / this.realScale;
y = y / this.realScale;
return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY;
}
/**
* 判斷坐標(biāo)(x, y)是否在矩形頂點(diǎn)內(nèi)部
* @param x
* @param y
*/
isPointInsideVertex(x, y) {
x = x / this.realScale;
y = y / this.realScale;
const vertices = [
{x: this.minX, y: this.minY},
{x: this.maxX, y: this.minY},
{x: this.maxX, y: this.maxY},
{x: this.minX, y: this.maxY}
];
const size = this.vertexSize / 2;
let index = -1;
for (let i = 0; i < vertices.length; i++) {
const vx = vertices[i].x;
const vy = vertices[i].y;
if (x >= vx - size && x <= vx + size && y >= vy - size && y <= vy + size) {
// return i;
index = i; break;
}
}
return index;
}
/**
* 歸一化為 yolo 格式
* @param width 所在圖片寬度
* @param height 所在圖片高度
*/
normalize(width, height) {
const scaledWidth = width * this.scale / this.dpr;
const scaledHeight = height * this.scale / this.dpr;
const rectWidth = (this.maxX - this.minX) / scaledWidth;
const rectHeight = (this.maxY - this.minY) / scaledHeight;
const centerX = (this.maxX + this.minX) / 2 / scaledWidth;
const centerY = (this.maxY + this.minY) / 2 / scaledHeight;
return {
x: centerX,
y: centerY,
w: rectWidth,
h: rectHeight,
}
}
/**
* 鼠標(biāo)按下事件,按下坐標(biāo)(x, y)
* @param x
* @param y
*/
mouseDown(x, y) {
this.vertexIndex = this.isPointInsideVertex(x, y);
if (this.vertexIndex !== -1) {
this.resizing = true;
} else if (this.isPointInside(x, y)) {
this.dragging = true;
}
}
/**
* 鼠標(biāo)移動(dòng)事件
* @param e 鼠標(biāo)事件
* @param that vue組件
*/
mouseMove(e, that) {
const mouseX = e.offsetX;
const mouseY = e.offsetY;
if (this.dragging) {
this.changed = true;
// 拖動(dòng)矩形
const deltaX = mouseX - that.prevX;
const deltaY = mouseY - that.prevY;
const scaledDeltaX = (mouseX - that.prevX) / this.realScale;
const scaledDeltaY = (mouseY - that.prevY) / this.realScale;
this.minX += scaledDeltaX;
this.minY += scaledDeltaY;
this.maxX += scaledDeltaX;
this.maxY += scaledDeltaY;
that.prevX += deltaX;
that.prevY += deltaY;
}
if (this.resizing) {
this.changed = true;
// 縮放矩形
const scaledX = mouseX / this.realScale;
const scaledY = mouseY / this.realScale;
switch (this.vertexIndex) {
case 0: // 左上角頂點(diǎn)
this.minX = scaledX;
this.minY = scaledY;
break;
case 1: // 右上角頂點(diǎn)
this.maxX = scaledX;
this.minY = scaledY;
break;
case 2: // 右下角頂點(diǎn)
this.maxX = scaledX;
this.maxY = scaledY;
break;
case 3: // 左下角頂點(diǎn)
this.minX = scaledX;
this.maxY = scaledY;
break;
}
}
this.draw();
}
/**
* 鼠標(biāo)抬起事件
* @param width 所在圖片寬度
* @param height 所在圖片高度
*/
mouseUp(width, height) {
this.dragging = false;
this.resizing = false;
this.adjustCoordinate();
// 避免縮放過(guò)程中把矩形縮成看不見(jiàn)的一點(diǎn)
if (this.minX === this.maxX) {
this.maxX += 1;
}
if(this.minY === this.maxY) {
this.maxY += 1;
}
}
}
至此,核心功能基本實(shí)現(xiàn),至于對(duì)矩形框的命名、保存與刪除等操作,都比較簡(jiǎn)單,演示視頻中已經(jīng)提到了,這里不做過(guò)多介紹。最終效果如下(完整功能演示請(qǐng)看文章開(kāi)頭的視頻):
——————————————我———是———分———割———線—————————————
長(zhǎng)大后的日子是一天快過(guò)一天,一年的時(shí)間就這么一聲不吭地溜走了,對(duì)比去年這個(gè)時(shí)候的我,貌似還是沒(méi)有太大的長(zhǎng)進(jìn),我這進(jìn)步速度就算是按年算也過(guò)于遲緩了,望各位引以為戒。我們有緣明年再見(jiàn)ヾ(?ω?`)o
機(jī)器學(xué)習(xí):神經(jīng)網(wǎng)絡(luò)構(gòu)建(下)
閱讀華為Mate品牌盛典:HarmonyOS NEXT加持下游戲性能得到充分釋放
閱讀實(shí)現(xiàn)對(duì)象集合與DataTable的相互轉(zhuǎn)換
閱讀鴻蒙NEXT元服務(wù):論如何免費(fèi)快速上架作品
閱讀算法與數(shù)據(jù)結(jié)構(gòu) 1 - 模擬
閱讀升訊威在線客服與營(yíng)銷(xiāo)系統(tǒng)介紹
閱讀基于鴻蒙NEXT的血型遺傳計(jì)算器開(kāi)發(fā)案例
閱讀5. Spring Cloud OpenFeign 聲明式 WebService 客戶端的超詳細(xì)使用
閱讀Java代理模式:靜態(tài)代理和動(dòng)態(tài)代理的對(duì)比分析
閱讀Win11筆記本“自動(dòng)管理應(yīng)用的顏色”顯示規(guī)則
閱讀本站所有軟件,都由網(wǎng)友上傳,如有侵犯你的版權(quán),請(qǐng)發(fā)郵件[email protected]
湘ICP備2022002427號(hào)-10 湘公網(wǎng)安備:43070202000427號(hào)© 2013~2025 haote.com 好特網(wǎng)