|
@@ -176,6 +176,14 @@
|
|
|
<!-- 右侧标注结果 -->
|
|
<!-- 右侧标注结果 -->
|
|
|
<div id="right-panel">
|
|
<div id="right-panel">
|
|
|
<h2>标注结果</h2>
|
|
<h2>标注结果</h2>
|
|
|
|
|
+ <div style="margin-bottom:10px;">
|
|
|
|
|
+ <label for="classSelect">分类:</label>
|
|
|
|
|
+ <select id="classSelect" style="font-size:15px;padding:2px 8px;">
|
|
|
|
|
+ <option value="0">人</option>
|
|
|
|
|
+ <option value="1">车</option>
|
|
|
|
|
+ <option value="2">动物</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
<div id="boxes-list"></div>
|
|
<div id="boxes-list"></div>
|
|
|
<button id="clearBoxesBtn" style="width:100%;margin-bottom:10px;background:#ff7875;color:#fff;border:none;border-radius:4px;padding:8px 0;font-size:15px;cursor:pointer;">清空所有标记</button>
|
|
<button id="clearBoxesBtn" style="width:100%;margin-bottom:10px;background:#ff7875;color:#fff;border:none;border-radius:4px;padding:8px 0;font-size:15px;cursor:pointer;">清空所有标记</button>
|
|
|
<button id="exportBtn" disabled style="margin-top:0;">导出YOLO标注</button>
|
|
<button id="exportBtn" disabled style="margin-top:0;">导出YOLO标注</button>
|
|
@@ -205,6 +213,25 @@
|
|
|
let panStart = {x: 0, y: 0}; // 拖动画布起点
|
|
let panStart = {x: 0, y: 0}; // 拖动画布起点
|
|
|
let panOffsetStart = {x: 0, y: 0}; // 拖动画布时的初始偏移
|
|
let panOffsetStart = {x: 0, y: 0}; // 拖动画布时的初始偏移
|
|
|
|
|
|
|
|
|
|
+ // 分类配置:名称和颜色
|
|
|
|
|
+ const classConfig = [
|
|
|
|
|
+ { index: 0, name: '人', border: '#1890ff' },
|
|
|
|
|
+ { index: 1, name: '车', border: '#52c41a' },
|
|
|
|
|
+ { index: 2, name: '动物', border: '#722ed1' }
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ // 辅助函数:将hex色转为rgba字符串,a为透明度
|
|
|
|
|
+ function hexToRgba(hex, a) {
|
|
|
|
|
+ hex = hex.replace('#', '');
|
|
|
|
|
+ if (hex.length === 3) {
|
|
|
|
|
+ hex = hex.split('').map(x => x + x).join('');
|
|
|
|
|
+ }
|
|
|
|
|
+ const r = parseInt(hex.substring(0,2), 16);
|
|
|
|
|
+ const g = parseInt(hex.substring(2,4), 16);
|
|
|
|
|
+ const b = parseInt(hex.substring(4,6), 16);
|
|
|
|
|
+ return `rgba(${r},${g},${b},${a})`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// -------------------- 工具函数 --------------------
|
|
// -------------------- 工具函数 --------------------
|
|
|
// 判断点(x, y)是否在框box内部(图片坐标)
|
|
// 判断点(x, y)是否在框box内部(图片坐标)
|
|
|
function isInBox(x, y, box) {
|
|
function isInBox(x, y, box) {
|
|
@@ -277,20 +304,17 @@
|
|
|
let c = toCanvasCoord(box.x, box.y); // 左上角canvas坐标
|
|
let c = toCanvasCoord(box.x, box.y); // 左上角canvas坐标
|
|
|
let cw = box.w * imgScale;
|
|
let cw = box.w * imgScale;
|
|
|
let ch = box.h * imgScale;
|
|
let ch = box.h * imgScale;
|
|
|
- // 先绘制半透明背景色
|
|
|
|
|
|
|
+ // 先绘制半透明背景色和边框色,按分类区分
|
|
|
if (idx === selectedBoxIndex) {
|
|
if (idx === selectedBoxIndex) {
|
|
|
- ctx.fillStyle = 'rgba(255,200,0,0.18)';
|
|
|
|
|
|
|
+ ctx.fillStyle = 'rgba(255,200,0,0.18)'; // 选中填充
|
|
|
|
|
+ ctx.strokeStyle = 'orange'; // 选中边框
|
|
|
} else {
|
|
} else {
|
|
|
- ctx.fillStyle = 'rgba(255,0,0,0.18)';
|
|
|
|
|
|
|
+ const conf = classConfig.find(c => c.index === box.classId) || { border: 'red' };
|
|
|
|
|
+ ctx.fillStyle = hexToRgba(conf.border, 0.18);
|
|
|
|
|
+ ctx.strokeStyle = conf.border;
|
|
|
}
|
|
}
|
|
|
|
|
+ ctx.lineWidth = lineWidth;
|
|
|
ctx.fillRect(c.x, c.y, cw, ch);
|
|
ctx.fillRect(c.x, c.y, cw, ch);
|
|
|
- if (idx === selectedBoxIndex) {
|
|
|
|
|
- ctx.strokeStyle = 'orange'; // 选中高亮
|
|
|
|
|
- ctx.lineWidth = lineWidth;
|
|
|
|
|
- } else {
|
|
|
|
|
- ctx.strokeStyle = 'red';
|
|
|
|
|
- ctx.lineWidth = lineWidth;
|
|
|
|
|
- }
|
|
|
|
|
ctx.strokeRect(c.x, c.y, cw, ch);
|
|
ctx.strokeRect(c.x, c.y, cw, ch);
|
|
|
// 绘制选中框的8个手柄
|
|
// 绘制选中框的8个手柄
|
|
|
if (idx === selectedBoxIndex) {
|
|
if (idx === selectedBoxIndex) {
|
|
@@ -324,7 +348,14 @@
|
|
|
let html = '';
|
|
let html = '';
|
|
|
boxes.forEach((box, idx) => {
|
|
boxes.forEach((box, idx) => {
|
|
|
const active = (idx === selectedBoxIndex) ? ' active' : '';
|
|
const active = (idx === selectedBoxIndex) ? ' active' : '';
|
|
|
- html += `<div class="box-item${active}">框${idx+1} [x:${box.x}, y:${box.y}, w:${box.w}, h:${box.h}] <button data-idx="${idx}" class="del-btn">删除</button></div>`;
|
|
|
|
|
|
|
+ html += `<div class="box-item${active}">
|
|
|
|
|
+ 框${idx+1} [
|
|
|
|
|
+ <select class='class-select' data-idx='${idx}' style='font-size:13px;padding:1px 6px;'>
|
|
|
|
|
+ ${classConfig.map(c => `<option value="${c.index}"${box.classId==c.index?' selected':''}>${c.name}</option>`).join('')}
|
|
|
|
|
+ </select>
|
|
|
|
|
+ ] x:${box.x}, y:${box.y}, w:${box.w}, h:${box.h}
|
|
|
|
|
+ <button data-idx="${idx}" class="del-btn">删除</button>
|
|
|
|
|
+ </div>`;
|
|
|
});
|
|
});
|
|
|
$('#boxes-list').html(html);
|
|
$('#boxes-list').html(html);
|
|
|
$('#exportBtn').prop('disabled', boxes.length === 0);
|
|
$('#exportBtn').prop('disabled', boxes.length === 0);
|
|
@@ -358,30 +389,30 @@
|
|
|
|
|
|
|
|
$(function() {
|
|
$(function() {
|
|
|
// -------------------- 图片列表与mock数据 --------------------
|
|
// -------------------- 图片列表与mock数据 --------------------
|
|
|
- // 图片URL缩略图列表,默认两张图片
|
|
|
|
|
let imgUrlArr = [
|
|
let imgUrlArr = [
|
|
|
- 'https://image.cszcyl.cn/2022/image/YfJA8eON3exqppNSQrW5EhE9rGdNS1qwou5dgj3L3651WcDDZEy89Hn1A296TXIx.png',
|
|
|
|
|
- 'https://image.cszcyl.cn/coze/%E9%AB%98%E8%80%83%E5%BF%85%E8%83%9C-%E8%B6%85%E6%B8%85.png'
|
|
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'img1',
|
|
|
|
|
+ url: 'https://image.cszcyl.cn/2022/image/YfJA8eON3exqppNSQrW5EhE9rGdNS1qwou5dgj3L3651WcDDZEy89Hn1A296TXIx.png',
|
|
|
|
|
+ boxes: [
|
|
|
|
|
+ { x: 100, y: 120, w: 80, h: 60, classId: 0 },
|
|
|
|
|
+ { x: 300, y: 200, w: 120, h: 90, classId: 0 }
|
|
|
|
|
+ ]
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'img2',
|
|
|
|
|
+ url: 'https://image.cszcyl.cn/coze/%E9%AB%98%E8%80%83%E5%BF%85%E8%83%9C-%E8%B6%85%E6%B8%85.png',
|
|
|
|
|
+ boxes: [
|
|
|
|
|
+ { x: 50, y: 50, w: 60, h: 60, classId: 0 }
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
];
|
|
];
|
|
|
let currentImgIndex = 0; // 当前图片索引
|
|
let currentImgIndex = 0; // 当前图片索引
|
|
|
|
|
|
|
|
- // mock 标注数据,key为图片url
|
|
|
|
|
- let mockBoxes = {
|
|
|
|
|
- 'https://image.cszcyl.cn/2022/image/YfJA8eON3exqppNSQrW5EhE9rGdNS1qwou5dgj3L3651WcDDZEy89Hn1A296TXIx.png': [
|
|
|
|
|
- { x: 100, y: 120, w: 80, h: 60, classId: 0 },
|
|
|
|
|
- { x: 300, y: 200, w: 120, h: 90, classId: 0 }
|
|
|
|
|
- ],
|
|
|
|
|
- 'https://image.cszcyl.cn/coze/%E9%AB%98%E8%80%83%E5%BF%85%E8%83%9C-%E8%B6%85%E6%B8%85.png': [
|
|
|
|
|
- { x: 50, y: 50, w: 60, h: 60, classId: 0 }
|
|
|
|
|
- ]
|
|
|
|
|
- // 其它图片可继续添加
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
// 渲染左侧图片缩略图列表
|
|
// 渲染左侧图片缩略图列表
|
|
|
function renderImgList() {
|
|
function renderImgList() {
|
|
|
let html = '';
|
|
let html = '';
|
|
|
- imgUrlArr.forEach((url, idx) => {
|
|
|
|
|
- html += `<img src="${url}" class="img-thumb${idx===currentImgIndex?' active':''}" data-idx="${idx}" title="${url}">`;
|
|
|
|
|
|
|
+ imgUrlArr.forEach((item, idx) => {
|
|
|
|
|
+ html += `<img src="${item.url}" class="img-thumb${idx===currentImgIndex?' active':''}" data-idx="${idx}" title="${item.url}">`;
|
|
|
});
|
|
});
|
|
|
$('#imgList').html(html);
|
|
$('#imgList').html(html);
|
|
|
}
|
|
}
|
|
@@ -398,7 +429,8 @@
|
|
|
|
|
|
|
|
// 加载当前选中图片到canvas,并初始化标注框
|
|
// 加载当前选中图片到canvas,并初始化标注框
|
|
|
function loadCurrentImage() {
|
|
function loadCurrentImage() {
|
|
|
- const url = imgUrlArr[currentImgIndex];
|
|
|
|
|
|
|
+ const imgObj = imgUrlArr[currentImgIndex];
|
|
|
|
|
+ const url = imgObj.url;
|
|
|
// 显示文件名
|
|
// 显示文件名
|
|
|
const name = url.split('/').pop();
|
|
const name = url.split('/').pop();
|
|
|
$('#filename').text(name);
|
|
$('#filename').text(name);
|
|
@@ -424,7 +456,7 @@
|
|
|
$('#canvas').attr({ width: 900, height: 600 });
|
|
$('#canvas').attr({ width: 900, height: 600 });
|
|
|
$('#canvas').css({ width: '900px', height: '600px', margin: 0, padding: 0 });
|
|
$('#canvas').css({ width: '900px', height: '600px', margin: 0, padding: 0 });
|
|
|
// 初始化标注框
|
|
// 初始化标注框
|
|
|
- boxes = mockBoxes[url] ? JSON.parse(JSON.stringify(mockBoxes[url])) : [];
|
|
|
|
|
|
|
+ boxes = imgObj.boxes ? JSON.parse(JSON.stringify(imgObj.boxes)) : [];
|
|
|
selectedBoxIndex = -1;
|
|
selectedBoxIndex = -1;
|
|
|
drawAll();
|
|
drawAll();
|
|
|
updateBoxesList();
|
|
updateBoxesList();
|
|
@@ -633,7 +665,11 @@
|
|
|
if (x + w > imgWidth) w = imgWidth - x;
|
|
if (x + w > imgWidth) w = imgWidth - x;
|
|
|
if (y + h > imgHeight) h = imgHeight - y;
|
|
if (y + h > imgHeight) h = imgHeight - y;
|
|
|
if (w > 5 && h > 5) {
|
|
if (w > 5 && h > 5) {
|
|
|
- boxes.push({ x, y, w, h, classId: 0 });
|
|
|
|
|
|
|
+ // 新建框时用当前分类
|
|
|
|
|
+ const classId = parseInt($('#classSelect').val(), 10);
|
|
|
|
|
+ boxes.push({ x, y, w, h, classId });
|
|
|
|
|
+ // 同步到图片对象的boxes
|
|
|
|
|
+ imgUrlArr[currentImgIndex].boxes = JSON.parse(JSON.stringify(boxes));
|
|
|
}
|
|
}
|
|
|
drawAll();
|
|
drawAll();
|
|
|
updateBoxesList();
|
|
updateBoxesList();
|
|
@@ -650,18 +686,38 @@
|
|
|
if (selectedBoxIndex === idx) selectedBoxIndex = -1;
|
|
if (selectedBoxIndex === idx) selectedBoxIndex = -1;
|
|
|
drawAll();
|
|
drawAll();
|
|
|
updateBoxesList();
|
|
updateBoxesList();
|
|
|
|
|
+ // 同步到图片对象的boxes
|
|
|
|
|
+ imgUrlArr[currentImgIndex].boxes = JSON.parse(JSON.stringify(boxes));
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// 右侧标注项点击选中,联动canvas高亮
|
|
// 右侧标注项点击选中,联动canvas高亮
|
|
|
$('#boxes-list').on('click', '.box-item', function(e) {
|
|
$('#boxes-list').on('click', '.box-item', function(e) {
|
|
|
- // 避免点击删除按钮时触发
|
|
|
|
|
- if ($(e.target).hasClass('del-btn')) return;
|
|
|
|
|
|
|
+ // 避免点击删除按钮或下拉框时触发
|
|
|
|
|
+ if (
|
|
|
|
|
+ $(e.target).hasClass('del-btn') ||
|
|
|
|
|
+ $(e.target).hasClass('class-select') ||
|
|
|
|
|
+ e.target.tagName === 'SELECT' ||
|
|
|
|
|
+ e.target.tagName === 'OPTION'
|
|
|
|
|
+ ) return;
|
|
|
const idx = $(this).index();
|
|
const idx = $(this).index();
|
|
|
selectedBoxIndex = idx;
|
|
selectedBoxIndex = idx;
|
|
|
drawAll();
|
|
drawAll();
|
|
|
updateBoxesList();
|
|
updateBoxesList();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // 右侧分类下拉框修改事件
|
|
|
|
|
+ $('#boxes-list').on('change', '.class-select', function() {
|
|
|
|
|
+ const idx = $(this).data('idx');
|
|
|
|
|
+ const val = parseInt($(this).val(), 10);
|
|
|
|
|
+ if (boxes[idx]) {
|
|
|
|
|
+ boxes[idx].classId = val;
|
|
|
|
|
+ updateBoxesList();
|
|
|
|
|
+ drawAll();
|
|
|
|
|
+ // 同步到图片对象的boxes
|
|
|
|
|
+ imgUrlArr[currentImgIndex].boxes = JSON.parse(JSON.stringify(boxes));
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
// -------------------- 导出/清空/缩放/切换图片等事件 --------------------
|
|
// -------------------- 导出/清空/缩放/切换图片等事件 --------------------
|
|
|
// 导出YOLO格式标注
|
|
// 导出YOLO格式标注
|
|
|
$('#exportBtn').on('click', function() {
|
|
$('#exportBtn').on('click', function() {
|
|
@@ -685,6 +741,8 @@
|
|
|
boxes = [];
|
|
boxes = [];
|
|
|
drawAll();
|
|
drawAll();
|
|
|
updateBoxesList();
|
|
updateBoxesList();
|
|
|
|
|
+ // 同步到图片对象的boxes
|
|
|
|
|
+ imgUrlArr[currentImgIndex].boxes = [];
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// 放大缩小按钮事件
|
|
// 放大缩小按钮事件
|