|
@@ -35,24 +35,30 @@
|
|
|
}
|
|
}
|
|
|
#center-panel {
|
|
#center-panel {
|
|
|
flex: 1;
|
|
flex: 1;
|
|
|
- display: flex;
|
|
|
|
|
- flex-direction: column;
|
|
|
|
|
- align-items: center;
|
|
|
|
|
- justify-content: flex-start;
|
|
|
|
|
- padding: 20px;
|
|
|
|
|
- max-width: 900px;
|
|
|
|
|
- max-height: 600px;
|
|
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ min-height: 600px;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ background: #f5f5f5;
|
|
|
box-sizing: border-box;
|
|
box-sizing: border-box;
|
|
|
}
|
|
}
|
|
|
|
|
+ .canvas-wrapper {
|
|
|
|
|
+ width: 900px;
|
|
|
|
|
+ height: 600px;
|
|
|
|
|
+ background: #f5f5f5;
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ }
|
|
|
#canvas {
|
|
#canvas {
|
|
|
border: 1px solid #ccc;
|
|
border: 1px solid #ccc;
|
|
|
cursor: crosshair;
|
|
cursor: crosshair;
|
|
|
- background: #fff;
|
|
|
|
|
- box-shadow: 0 2px 8px #eee;
|
|
|
|
|
- max-width: 900px;
|
|
|
|
|
- max-height: 600px;
|
|
|
|
|
- width: 100%;
|
|
|
|
|
- height: auto;
|
|
|
|
|
|
|
+ background: #acacac;
|
|
|
|
|
+ box-shadow: 0 2px 8px #222;
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ width: 900px;
|
|
|
|
|
+ height: 600px;
|
|
|
|
|
+ /* 宽高固定 */
|
|
|
}
|
|
}
|
|
|
#controls {
|
|
#controls {
|
|
|
margin-bottom: 10px;
|
|
margin-bottom: 10px;
|
|
@@ -85,6 +91,10 @@
|
|
|
justify-content: space-between;
|
|
justify-content: space-between;
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
}
|
|
}
|
|
|
|
|
+ .box-item.active {
|
|
|
|
|
+ background: #ffe58f;
|
|
|
|
|
+ border: 1.5px solid #faad14;
|
|
|
|
|
+ }
|
|
|
.del-btn {
|
|
.del-btn {
|
|
|
background: #ff7875;
|
|
background: #ff7875;
|
|
|
color: #fff;
|
|
color: #fff;
|
|
@@ -131,6 +141,12 @@
|
|
|
.img-thumb.active {
|
|
.img-thumb.active {
|
|
|
border: 2px solid #1890ff;
|
|
border: 2px solid #1890ff;
|
|
|
}
|
|
}
|
|
|
|
|
+ #filename {
|
|
|
|
|
+ color: #ccc;
|
|
|
|
|
+ font-size: 15px;
|
|
|
|
|
+ margin-top: 10px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ }
|
|
|
</style>
|
|
</style>
|
|
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
|
|
</head>
|
|
</head>
|
|
@@ -144,13 +160,18 @@
|
|
|
<!-- 中间标注区 -->
|
|
<!-- 中间标注区 -->
|
|
|
<div id="center-panel">
|
|
<div id="center-panel">
|
|
|
<h2>图片标注区</h2>
|
|
<h2>图片标注区</h2>
|
|
|
- <div id="zoom-controls" style="margin-bottom:10px;display:flex;gap:8px;justify-content:center;">
|
|
|
|
|
|
|
+ <div id="zoom-controls" style="margin:18px 0 10px 0;display:flex;gap:8px;justify-content:center;">
|
|
|
|
|
+ <button id="prevImgBtn" style="padding:6px 18px;font-size:15px;border-radius:4px;border:none;background:#1890ff;color:#fff;cursor:pointer;">上一张</button>
|
|
|
<button id="zoomInBtn" style="padding:4px 14px;">放大</button>
|
|
<button id="zoomInBtn" style="padding:4px 14px;">放大</button>
|
|
|
<button id="zoomOutBtn" style="padding:4px 14px;">缩小</button>
|
|
<button id="zoomOutBtn" style="padding:4px 14px;">缩小</button>
|
|
|
<button id="zoomResetBtn" style="padding:4px 14px;">重置</button>
|
|
<button id="zoomResetBtn" style="padding:4px 14px;">重置</button>
|
|
|
|
|
+ <button id="nextImgBtn" style="padding:6px 18px;font-size:15px;border-radius:4px;border:none;background:#1890ff;color:#fff;cursor:pointer;">下一张</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="canvas-wrapper">
|
|
|
|
|
+ <canvas id="canvas"></canvas>
|
|
|
|
|
+ <div id="coordTip" style="position:fixed;display:none;pointer-events:none;z-index:9999;font-size:12px;color:#fff;background:rgba(0,0,0,0.7);padding:1px 6px;border-radius:4px;"></div>
|
|
|
</div>
|
|
</div>
|
|
|
- <canvas id="canvas"></canvas>
|
|
|
|
|
- <div id="coordTip" style="position:fixed;display:none;pointer-events:none;z-index:9999;font-size:12px;color:#fff;background:rgba(0,0,0,0.7);padding:1px 6px;border-radius:4px;"></div>
|
|
|
|
|
|
|
+ <div id="filename" style="color:#ccc;font-size:15px;margin:16px 0 0 0;text-align:center;"></div>
|
|
|
</div>
|
|
</div>
|
|
|
<!-- 右侧标注结果 -->
|
|
<!-- 右侧标注结果 -->
|
|
|
<div id="right-panel">
|
|
<div id="right-panel">
|
|
@@ -162,11 +183,12 @@
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<script>
|
|
<script>
|
|
|
- let img = new window.Image();
|
|
|
|
|
- let boxes = [];
|
|
|
|
|
- let drawing = false;
|
|
|
|
|
- let startX = 0, startY = 0, endX = 0, endY = 0;
|
|
|
|
|
- let imgWidth = 0, imgHeight = 0;
|
|
|
|
|
|
|
+ // -------------------- 全局变量定义 --------------------
|
|
|
|
|
+ let img = new window.Image(); // 当前加载的图片对象
|
|
|
|
|
+ let boxes = []; // 当前图片的所有标注框数组
|
|
|
|
|
+ let drawing = false; // 是否正在绘制新框
|
|
|
|
|
+ let startX = 0, startY = 0, endX = 0, endY = 0; // 新建框的起止点(图片坐标)
|
|
|
|
|
+ let imgWidth = 0, imgHeight = 0; // 当前图片的原始宽高
|
|
|
let selectedBoxIndex = -1; // 当前选中的框索引
|
|
let selectedBoxIndex = -1; // 当前选中的框索引
|
|
|
let dragMode = null; // 拖动模式:null/move/resize
|
|
let dragMode = null; // 拖动模式:null/move/resize
|
|
|
let dragOffsetX = 0, dragOffsetY = 0; // 拖动时鼠标与框左上角的偏移
|
|
let dragOffsetX = 0, dragOffsetY = 0; // 拖动时鼠标与框左上角的偏移
|
|
@@ -174,19 +196,21 @@
|
|
|
// 8个手柄类型,分别对应四个角和四条边中点
|
|
// 8个手柄类型,分别对应四个角和四条边中点
|
|
|
const HANDLE_TYPES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
|
const HANDLE_TYPES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
|
|
// 缩放相关
|
|
// 缩放相关
|
|
|
- let scale = 1.0;
|
|
|
|
|
- let minScale = 0.2, maxScale = 5.0;
|
|
|
|
|
- let offsetX = 0, offsetY = 0; // 平移偏移(可扩展拖动画布)
|
|
|
|
|
- let isPanning = false; // 是否正在拖动画布
|
|
|
|
|
- let panStart = {x: 0, y: 0}; // 拖拽起点
|
|
|
|
|
- let panOffsetStart = {x: 0, y: 0}; // 拖拽时的初始偏移
|
|
|
|
|
|
|
+ let imgScale = 1.0; // 图片缩放比例
|
|
|
|
|
+ let minImgScale = 0.2, maxImgScale = 5.0; // 缩放上下限
|
|
|
|
|
+ let imgOffsetX = 0, imgOffsetY = 0; // 图片在canvas内的平移
|
|
|
|
|
+ let baseScale = 1.0; // 图片全图自适应时的缩放比例
|
|
|
let spacePressed = false; // 是否按下空格
|
|
let spacePressed = false; // 是否按下空格
|
|
|
|
|
+ let isPanning = false; // 是否正在拖动画布
|
|
|
|
|
+ let panStart = {x: 0, y: 0}; // 拖动画布起点
|
|
|
|
|
+ let panOffsetStart = {x: 0, y: 0}; // 拖动画布时的初始偏移
|
|
|
|
|
|
|
|
- // 判断点(x, y)是否在框box内部
|
|
|
|
|
|
|
+ // -------------------- 工具函数 --------------------
|
|
|
|
|
+ // 判断点(x, y)是否在框box内部(图片坐标)
|
|
|
function isInBox(x, y, box) {
|
|
function isInBox(x, y, box) {
|
|
|
return x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h;
|
|
return x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h;
|
|
|
}
|
|
}
|
|
|
- // 获取8个手柄的中心坐标
|
|
|
|
|
|
|
+ // 获取8个手柄的中心坐标(用于缩放)
|
|
|
function getHandles(box, size) {
|
|
function getHandles(box, size) {
|
|
|
const x = box.x, y = box.y, w = box.w, h = box.h;
|
|
const x = box.x, y = box.y, w = box.w, h = box.h;
|
|
|
const hs = (size || HANDLE_SIZE) / 2;
|
|
const hs = (size || HANDLE_SIZE) / 2;
|
|
@@ -201,7 +225,7 @@
|
|
|
w: { x: x - hs, y: y + h/2 - hs } // 左中
|
|
w: { x: x - hs, y: y + h/2 - hs } // 左中
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
- // 判断点(x, y)是否在某个手柄上,返回手柄类型
|
|
|
|
|
|
|
+ // 判断点(x, y)是否在某个手柄上,返回手柄类型(canvas坐标)
|
|
|
function getHandleAt(x, y, box) {
|
|
function getHandleAt(x, y, box) {
|
|
|
let domW = $('#canvas').width();
|
|
let domW = $('#canvas').width();
|
|
|
let pixelRatio = $('#canvas')[0].width / domW;
|
|
let pixelRatio = $('#canvas')[0].width / domW;
|
|
@@ -230,85 +254,116 @@
|
|
|
$('#canvas').css('cursor', map[type] || 'default');
|
|
$('#canvas').css('cursor', map[type] || 'default');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 绘制函数,支持缩放和平移
|
|
|
|
|
|
|
+ // -------------------- 绘制函数 --------------------
|
|
|
|
|
+ // 负责绘制图片、所有标注框、选中框的手柄、正在绘制的新框
|
|
|
function drawAll() {
|
|
function drawAll() {
|
|
|
const ctx = $('#canvas')[0].getContext('2d');
|
|
const ctx = $('#canvas')[0].getContext('2d');
|
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
|
ctx.save();
|
|
ctx.save();
|
|
|
- ctx.scale(scale, scale);
|
|
|
|
|
- ctx.translate(offsetX, offsetY);
|
|
|
|
|
- ctx.drawImage(img, 0, 0, imgWidth, imgHeight);
|
|
|
|
|
- // 计算canvas显示缩放比例
|
|
|
|
|
- let domW = $('#canvas').width();
|
|
|
|
|
- let pixelRatio = $('#canvas')[0].width / domW;
|
|
|
|
|
- let lineWidth = 2 * pixelRatio / scale; // 屏幕上2px
|
|
|
|
|
- let handleSize = 10 * pixelRatio / scale; // 屏幕上10px
|
|
|
|
|
|
|
+ const info = window._imgDrawInfo;
|
|
|
|
|
+ if (info) {
|
|
|
|
|
+ // 计算图片在canvas上的显示区域
|
|
|
|
|
+ let drawW = info.imgWidth * imgScale;
|
|
|
|
|
+ let drawH = info.imgHeight * imgScale;
|
|
|
|
|
+ let drawX = (900 - drawW) / 2 + imgOffsetX;
|
|
|
|
|
+ let drawY = (600 - drawH) / 2 + imgOffsetY;
|
|
|
|
|
+ ctx.drawImage(img, drawX, drawY, drawW, drawH);
|
|
|
|
|
+ }
|
|
|
|
|
+ let lineWidth = 2;
|
|
|
|
|
+ let handleSize = 10;
|
|
|
|
|
+ // 绘制所有标注框
|
|
|
boxes.forEach((box, idx) => {
|
|
boxes.forEach((box, idx) => {
|
|
|
ctx.save();
|
|
ctx.save();
|
|
|
|
|
+ let c = toCanvasCoord(box.x, box.y); // 左上角canvas坐标
|
|
|
|
|
+ let cw = box.w * imgScale;
|
|
|
|
|
+ let ch = box.h * imgScale;
|
|
|
|
|
+ // 先绘制半透明背景色
|
|
|
if (idx === selectedBoxIndex) {
|
|
if (idx === selectedBoxIndex) {
|
|
|
- ctx.strokeStyle = 'orange'; // 选中框高亮
|
|
|
|
|
|
|
+ ctx.fillStyle = 'rgba(255,200,0,0.18)';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.fillStyle = 'rgba(255,0,0,0.18)';
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.fillRect(c.x, c.y, cw, ch);
|
|
|
|
|
+ if (idx === selectedBoxIndex) {
|
|
|
|
|
+ ctx.strokeStyle = 'orange'; // 选中高亮
|
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.lineWidth = lineWidth;
|
|
|
} else {
|
|
} else {
|
|
|
ctx.strokeStyle = 'red';
|
|
ctx.strokeStyle = 'red';
|
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.lineWidth = lineWidth;
|
|
|
}
|
|
}
|
|
|
- ctx.strokeRect(box.x, box.y, box.w, box.h);
|
|
|
|
|
- // 绘制8个缩放手柄
|
|
|
|
|
|
|
+ ctx.strokeRect(c.x, c.y, cw, ch);
|
|
|
|
|
+ // 绘制选中框的8个手柄
|
|
|
if (idx === selectedBoxIndex) {
|
|
if (idx === selectedBoxIndex) {
|
|
|
ctx.fillStyle = 'blue';
|
|
ctx.fillStyle = 'blue';
|
|
|
- const handles = getHandles(box, handleSize);
|
|
|
|
|
|
|
+ const handles = getHandles({x: c.x, y: c.y, w: cw, h: ch}, handleSize);
|
|
|
for (let type of HANDLE_TYPES) {
|
|
for (let type of HANDLE_TYPES) {
|
|
|
ctx.fillRect(handles[type].x, handles[type].y, handleSize, handleSize);
|
|
ctx.fillRect(handles[type].x, handles[type].y, handleSize, handleSize);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
ctx.restore();
|
|
ctx.restore();
|
|
|
});
|
|
});
|
|
|
- // 绘制新建时的临时框
|
|
|
|
|
|
|
+ // 绘制正在新建的框
|
|
|
if (drawing) {
|
|
if (drawing) {
|
|
|
ctx.strokeStyle = 'blue';
|
|
ctx.strokeStyle = 'blue';
|
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.lineWidth = lineWidth;
|
|
|
|
|
+ let c1 = toCanvasCoord(startX, startY);
|
|
|
|
|
+ let c2 = toCanvasCoord(endX, endY);
|
|
|
ctx.strokeRect(
|
|
ctx.strokeRect(
|
|
|
- Math.min(startX, endX),
|
|
|
|
|
- Math.min(startY, endY),
|
|
|
|
|
- Math.abs(endX - startX),
|
|
|
|
|
- Math.abs(endY - startY)
|
|
|
|
|
|
|
+ Math.min(c1.x, c2.x),
|
|
|
|
|
+ Math.min(c1.y, c2.y),
|
|
|
|
|
+ Math.abs(c2.x - c1.x),
|
|
|
|
|
+ Math.abs(c2.y - c1.y)
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
ctx.restore();
|
|
ctx.restore();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // -------------------- 右侧标注框列表渲染 --------------------
|
|
|
|
|
+ // 渲染右侧标注框列表,并高亮选中项
|
|
|
function updateBoxesList() {
|
|
function updateBoxesList() {
|
|
|
let html = '';
|
|
let html = '';
|
|
|
boxes.forEach((box, idx) => {
|
|
boxes.forEach((box, idx) => {
|
|
|
- html += `<div class="box-item">框${idx+1} [x:${box.x}, y:${box.y}, w:${box.w}, h:${box.h}] <button data-idx="${idx}" class="del-btn">删除</button></div>`;
|
|
|
|
|
|
|
+ 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>`;
|
|
|
});
|
|
});
|
|
|
$('#boxes-list').html(html);
|
|
$('#boxes-list').html(html);
|
|
|
$('#exportBtn').prop('disabled', boxes.length === 0);
|
|
$('#exportBtn').prop('disabled', boxes.length === 0);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 工具函数:canvas坐标转原图坐标
|
|
|
|
|
|
|
+ // -------------------- 坐标换算函数 --------------------
|
|
|
|
|
+ // canvas坐标转图片坐标
|
|
|
function toImageCoord(canvasX, canvasY) {
|
|
function toImageCoord(canvasX, canvasY) {
|
|
|
- // 需要考虑canvas缩放后的显示尺寸
|
|
|
|
|
- let domW = $('#canvas').width();
|
|
|
|
|
- let domH = $('#canvas').height();
|
|
|
|
|
- let ratioX = imgWidth / domW;
|
|
|
|
|
- let ratioY = imgHeight / domH;
|
|
|
|
|
- return {
|
|
|
|
|
- x: (canvasX * ratioX / scale - offsetX),
|
|
|
|
|
- y: (canvasY * ratioY / scale - offsetY)
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ const info = window._imgDrawInfo;
|
|
|
|
|
+ if (!info) return {x: 0, y: 0};
|
|
|
|
|
+ let drawW = info.imgWidth * imgScale;
|
|
|
|
|
+ let drawH = info.imgHeight * imgScale;
|
|
|
|
|
+ let drawX = (900 - drawW) / 2 + imgOffsetX;
|
|
|
|
|
+ let drawY = (600 - drawH) / 2 + imgOffsetY;
|
|
|
|
|
+ let x = (canvasX - drawX) / imgScale;
|
|
|
|
|
+ let y = (canvasY - drawY) / imgScale;
|
|
|
|
|
+ return {x, y};
|
|
|
|
|
+ }
|
|
|
|
|
+ // 图片坐标转canvas坐标
|
|
|
|
|
+ function toCanvasCoord(imgX, imgY) {
|
|
|
|
|
+ const info = window._imgDrawInfo;
|
|
|
|
|
+ if (!info) return {x: 0, y: 0};
|
|
|
|
|
+ let drawW = info.imgWidth * imgScale;
|
|
|
|
|
+ let drawH = info.imgHeight * imgScale;
|
|
|
|
|
+ let drawX = (900 - drawW) / 2 + imgOffsetX;
|
|
|
|
|
+ let drawY = (600 - drawH) / 2 + imgOffsetY;
|
|
|
|
|
+ let x = imgX * imgScale + drawX;
|
|
|
|
|
+ let y = imgY * imgScale + drawY;
|
|
|
|
|
+ return {x, y};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
$(function() {
|
|
$(function() {
|
|
|
- // 图片URL缩略图列表,默认一张图片
|
|
|
|
|
|
|
+ // -------------------- 图片列表与mock数据 --------------------
|
|
|
|
|
+ // 图片URL缩略图列表,默认两张图片
|
|
|
let imgUrlArr = [
|
|
let imgUrlArr = [
|
|
|
'https://image.cszcyl.cn/2022/image/YfJA8eON3exqppNSQrW5EhE9rGdNS1qwou5dgj3L3651WcDDZEy89Hn1A296TXIx.png',
|
|
'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',
|
|
|
|
|
- 'https://image.cszcyl.cn/2022/image/1A2B3C4D5E6F7G8H9I0J1K2L3M4N5O6P7Q8R9S0T1U2V3W4X5Y6Z7A8B9C0D1E2F3.png',
|
|
|
|
|
- 'https://image.cszcyl.cn/2022/image/abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc890def123.png',
|
|
|
|
|
- 'https://image.cszcyl.cn/2022/image/sample2.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'
|
|
|
];
|
|
];
|
|
|
- let currentImgIndex = 0;
|
|
|
|
|
|
|
+ let currentImgIndex = 0; // 当前图片索引
|
|
|
|
|
|
|
|
// mock 标注数据,key为图片url
|
|
// mock 标注数据,key为图片url
|
|
|
let mockBoxes = {
|
|
let mockBoxes = {
|
|
@@ -341,24 +396,36 @@
|
|
|
loadCurrentImage();
|
|
loadCurrentImage();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // 加载当前选中图片到canvas
|
|
|
|
|
|
|
+ // 加载当前选中图片到canvas,并初始化标注框
|
|
|
function loadCurrentImage() {
|
|
function loadCurrentImage() {
|
|
|
const url = imgUrlArr[currentImgIndex];
|
|
const url = imgUrlArr[currentImgIndex];
|
|
|
|
|
+ // 显示文件名
|
|
|
|
|
+ const name = url.split('/').pop();
|
|
|
|
|
+ $('#filename').text(name);
|
|
|
|
|
+ // 更新按钮禁用状态
|
|
|
|
|
+ $('#prevImgBtn').prop('disabled', currentImgIndex === 0);
|
|
|
|
|
+ $('#nextImgBtn').prop('disabled', currentImgIndex === imgUrlArr.length - 1);
|
|
|
|
|
+ // 先解绑,防止多次触发
|
|
|
|
|
+ img.onload = null;
|
|
|
|
|
+ img.onerror = null;
|
|
|
img.onload = function() {
|
|
img.onload = function() {
|
|
|
imgWidth = img.width;
|
|
imgWidth = img.width;
|
|
|
imgHeight = img.height;
|
|
imgHeight = img.height;
|
|
|
- // 计算缩放比例,canvas最大900x600
|
|
|
|
|
- let maxW = 900, maxH = 600;
|
|
|
|
|
- let ratio = Math.min(1, maxW / imgWidth, maxH / imgHeight);
|
|
|
|
|
- let showW = Math.round(imgWidth * ratio);
|
|
|
|
|
- let showH = Math.round(imgHeight * ratio);
|
|
|
|
|
- $('#canvas').attr({ width: imgWidth, height: imgHeight }); // 逻辑像素
|
|
|
|
|
- $('#canvas').css({ width: showW + 'px', height: showH + 'px' }); // 显示像素
|
|
|
|
|
- scale = 1.0;
|
|
|
|
|
- offsetX = 0;
|
|
|
|
|
- offsetY = 0;
|
|
|
|
|
- // 根据mock数据初始化boxes
|
|
|
|
|
|
|
+ // 计算自适应缩放比例
|
|
|
|
|
+ if (imgWidth <= 900 && imgHeight <= 600) {
|
|
|
|
|
+ baseScale = 1;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ baseScale = Math.min(900 / imgWidth, 600 / imgHeight);
|
|
|
|
|
+ }
|
|
|
|
|
+ imgScale = baseScale;
|
|
|
|
|
+ imgOffsetX = 0;
|
|
|
|
|
+ imgOffsetY = 0;
|
|
|
|
|
+ window._imgDrawInfo = {imgWidth, imgHeight};
|
|
|
|
|
+ $('#canvas').attr({ width: 900, height: 600 });
|
|
|
|
|
+ $('#canvas').css({ width: '900px', height: '600px', margin: 0, padding: 0 });
|
|
|
|
|
+ // 初始化标注框
|
|
|
boxes = mockBoxes[url] ? JSON.parse(JSON.stringify(mockBoxes[url])) : [];
|
|
boxes = mockBoxes[url] ? JSON.parse(JSON.stringify(mockBoxes[url])) : [];
|
|
|
|
|
+ selectedBoxIndex = -1;
|
|
|
drawAll();
|
|
drawAll();
|
|
|
updateBoxesList();
|
|
updateBoxesList();
|
|
|
};
|
|
};
|
|
@@ -371,7 +438,7 @@
|
|
|
loadCurrentImage();
|
|
loadCurrentImage();
|
|
|
|
|
|
|
|
let resizeHandle = null; // 当前正在拖动的手柄类型
|
|
let resizeHandle = null; // 当前正在拖动的手柄类型
|
|
|
- // 监听键盘空格按下/松开
|
|
|
|
|
|
|
+ // 监听键盘空格按下/松开(用于画布拖动)
|
|
|
$(document).on('keydown', function(e) {
|
|
$(document).on('keydown', function(e) {
|
|
|
if (e.code === 'Space') spacePressed = true;
|
|
if (e.code === 'Space') spacePressed = true;
|
|
|
});
|
|
});
|
|
@@ -379,53 +446,65 @@
|
|
|
if (e.code === 'Space') spacePressed = false;
|
|
if (e.code === 'Space') spacePressed = false;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // -------------------- canvas鼠标事件 --------------------
|
|
|
// 鼠标按下事件
|
|
// 鼠标按下事件
|
|
|
$('#canvas').on('mousedown', function(e) {
|
|
$('#canvas').on('mousedown', function(e) {
|
|
|
if (!imgWidth) return;
|
|
if (!imgWidth) return;
|
|
|
const rect = this.getBoundingClientRect();
|
|
const rect = this.getBoundingClientRect();
|
|
|
const canvasX = e.clientX - rect.left;
|
|
const canvasX = e.clientX - rect.left;
|
|
|
const canvasY = e.clientY - rect.top;
|
|
const canvasY = e.clientY - rect.top;
|
|
|
- const {x: mouseX, y: mouseY} = toImageCoord(canvasX, canvasY);
|
|
|
|
|
// 新增:空格+左键 或 右键拖动画布
|
|
// 新增:空格+左键 或 右键拖动画布
|
|
|
if ((spacePressed && e.button === 0) || e.button === 2) {
|
|
if ((spacePressed && e.button === 0) || e.button === 2) {
|
|
|
isPanning = true;
|
|
isPanning = true;
|
|
|
panStart = {x: e.clientX, y: e.clientY};
|
|
panStart = {x: e.clientX, y: e.clientY};
|
|
|
- panOffsetStart = {x: offsetX, y: offsetY};
|
|
|
|
|
|
|
+ panOffsetStart = {x: imgOffsetX, y: imgOffsetY};
|
|
|
$('#canvas').css('cursor', 'grab');
|
|
$('#canvas').css('cursor', 'grab');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
let found = false;
|
|
let found = false;
|
|
|
resizeHandle = null;
|
|
resizeHandle = null;
|
|
|
- // 优先判断是否点中手柄
|
|
|
|
|
|
|
+ // 优先判断是否点中手柄或框(全部用canvas坐标判定)
|
|
|
for (let i = boxes.length - 1; i >= 0; i--) {
|
|
for (let i = boxes.length - 1; i >= 0; i--) {
|
|
|
- const handleType = getHandleAt(mouseX, mouseY, boxes[i]);
|
|
|
|
|
|
|
+ let cbox = toCanvasCoord(boxes[i].x, boxes[i].y);
|
|
|
|
|
+ let cw = boxes[i].w * imgScale;
|
|
|
|
|
+ let ch = boxes[i].h * imgScale;
|
|
|
|
|
+ const handleType = getHandleAt(canvasX, canvasY, {x: cbox.x, y: cbox.y, w: cw, h: ch});
|
|
|
if (handleType) {
|
|
if (handleType) {
|
|
|
selectedBoxIndex = i;
|
|
selectedBoxIndex = i;
|
|
|
dragMode = 'resize';
|
|
dragMode = 'resize';
|
|
|
resizeHandle = handleType;
|
|
resizeHandle = handleType;
|
|
|
found = true;
|
|
found = true;
|
|
|
break;
|
|
break;
|
|
|
- } else if (isInBox(mouseX, mouseY, boxes[i])) {
|
|
|
|
|
- // 点中框内部,准备移动
|
|
|
|
|
|
|
+ } else if (
|
|
|
|
|
+ canvasX >= cbox.x && canvasX <= cbox.x + cw &&
|
|
|
|
|
+ canvasY >= cbox.y && canvasY <= cbox.y + ch
|
|
|
|
|
+ ) {
|
|
|
selectedBoxIndex = i;
|
|
selectedBoxIndex = i;
|
|
|
dragMode = 'move';
|
|
dragMode = 'move';
|
|
|
- dragOffsetX = mouseX - boxes[i].x;
|
|
|
|
|
- dragOffsetY = mouseY - boxes[i].y;
|
|
|
|
|
|
|
+ dragOffsetX = toImageCoord(canvasX, canvasY).x - boxes[i].x;
|
|
|
|
|
+ dragOffsetY = toImageCoord(canvasX, canvasY).y - boxes[i].y;
|
|
|
found = true;
|
|
found = true;
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
if (found) {
|
|
if (found) {
|
|
|
drawing = false;
|
|
drawing = false;
|
|
|
- drawAll();
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 没点中任何框,开始新建或取消选中
|
|
|
|
|
+ selectedBoxIndex = -1;
|
|
|
|
|
+ // 新建时,只有初始点在图片区域内才允许框选
|
|
|
|
|
+ const {x: mouseX, y: mouseY} = toImageCoord(canvasX, canvasY);
|
|
|
|
|
+ if (mouseX >= 0 && mouseX <= imgWidth && mouseY >= 0 && mouseY <= imgHeight) {
|
|
|
|
|
+ drawing = true;
|
|
|
|
|
+ startX = mouseX;
|
|
|
|
|
+ startY = mouseY;
|
|
|
|
|
+ endX = mouseX;
|
|
|
|
|
+ endY = mouseY;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- // 没点中任何框,开始新建
|
|
|
|
|
- selectedBoxIndex = -1;
|
|
|
|
|
- drawing = true;
|
|
|
|
|
- startX = endX = mouseX;
|
|
|
|
|
- startY = endY = mouseY;
|
|
|
|
|
|
|
+ // 无论如何都刷新一次,保证视觉同步
|
|
|
drawAll();
|
|
drawAll();
|
|
|
|
|
+ updateBoxesList();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// 鼠标移动事件
|
|
// 鼠标移动事件
|
|
@@ -450,16 +529,10 @@
|
|
|
}
|
|
}
|
|
|
// 新增:画布拖拽
|
|
// 新增:画布拖拽
|
|
|
if (isPanning) {
|
|
if (isPanning) {
|
|
|
- let domW = $('#canvas').width();
|
|
|
|
|
- let domH = $('#canvas').height();
|
|
|
|
|
- let canvasW = $('#canvas')[0].width;
|
|
|
|
|
- let canvasH = $('#canvas')[0].height;
|
|
|
|
|
- let ratioX = canvasW / domW;
|
|
|
|
|
- let ratioY = canvasH / domH;
|
|
|
|
|
- let dx = (e.clientX - panStart.x) * ratioX / scale;
|
|
|
|
|
- let dy = (e.clientY - panStart.y) * ratioY / scale;
|
|
|
|
|
- offsetX = panOffsetStart.x + dx;
|
|
|
|
|
- offsetY = panOffsetStart.y + dy;
|
|
|
|
|
|
|
+ let dx = e.clientX - panStart.x;
|
|
|
|
|
+ let dy = e.clientY - panStart.y;
|
|
|
|
|
+ imgOffsetX = panOffsetStart.x + dx;
|
|
|
|
|
+ imgOffsetY = panOffsetStart.y + dy;
|
|
|
drawAll();
|
|
drawAll();
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
@@ -549,7 +622,7 @@
|
|
|
const {x: mouseX, y: mouseY} = toImageCoord(canvasX, canvasY);
|
|
const {x: mouseX, y: mouseY} = toImageCoord(canvasX, canvasY);
|
|
|
if (drawing) {
|
|
if (drawing) {
|
|
|
drawing = false;
|
|
drawing = false;
|
|
|
- // 限制endX、endY不出界
|
|
|
|
|
|
|
+ // 重新用toImageCoord获取endX、endY,保证为图片原始坐标
|
|
|
endX = Math.max(0, Math.min(mouseX, imgWidth));
|
|
endX = Math.max(0, Math.min(mouseX, imgWidth));
|
|
|
endY = Math.max(0, Math.min(mouseY, imgHeight));
|
|
endY = Math.max(0, Math.min(mouseY, imgHeight));
|
|
|
const x = Math.max(0, Math.min(startX, endX));
|
|
const x = Math.max(0, Math.min(startX, endX));
|
|
@@ -564,10 +637,10 @@
|
|
|
}
|
|
}
|
|
|
drawAll();
|
|
drawAll();
|
|
|
updateBoxesList();
|
|
updateBoxesList();
|
|
|
- } else if (selectedBoxIndex !== -1 && dragMode) {
|
|
|
|
|
- dragMode = null;
|
|
|
|
|
- resizeHandle = null;
|
|
|
|
|
}
|
|
}
|
|
|
|
|
+ // 鼠标松开后,始终清空拖动/缩放状态
|
|
|
|
|
+ dragMode = null;
|
|
|
|
|
+ resizeHandle = null;
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// 删除按钮事件
|
|
// 删除按钮事件
|
|
@@ -579,6 +652,17 @@
|
|
|
updateBoxesList();
|
|
updateBoxesList();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // 右侧标注项点击选中,联动canvas高亮
|
|
|
|
|
+ $('#boxes-list').on('click', '.box-item', function(e) {
|
|
|
|
|
+ // 避免点击删除按钮时触发
|
|
|
|
|
+ if ($(e.target).hasClass('del-btn')) return;
|
|
|
|
|
+ const idx = $(this).index();
|
|
|
|
|
+ selectedBoxIndex = idx;
|
|
|
|
|
+ drawAll();
|
|
|
|
|
+ updateBoxesList();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // -------------------- 导出/清空/缩放/切换图片等事件 --------------------
|
|
|
// 导出YOLO格式标注
|
|
// 导出YOLO格式标注
|
|
|
$('#exportBtn').on('click', function() {
|
|
$('#exportBtn').on('click', function() {
|
|
|
// YOLO格式: class x_center y_center width height (均为归一化)
|
|
// YOLO格式: class x_center y_center width height (均为归一化)
|
|
@@ -603,51 +687,65 @@
|
|
|
updateBoxesList();
|
|
updateBoxesList();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // 缩放按钮事件
|
|
|
|
|
|
|
+ // 放大缩小按钮事件
|
|
|
$('#zoomInBtn').on('click', function() {
|
|
$('#zoomInBtn').on('click', function() {
|
|
|
- scale = Math.min(maxScale, scale * 1.2);
|
|
|
|
|
|
|
+ imgScale = Math.min(maxImgScale, imgScale * 1.2);
|
|
|
drawAll();
|
|
drawAll();
|
|
|
});
|
|
});
|
|
|
$('#zoomOutBtn').on('click', function() {
|
|
$('#zoomOutBtn').on('click', function() {
|
|
|
- scale = Math.max(minScale, scale / 1.2);
|
|
|
|
|
|
|
+ imgScale = Math.max(minImgScale, imgScale / 1.2);
|
|
|
drawAll();
|
|
drawAll();
|
|
|
});
|
|
});
|
|
|
$('#zoomResetBtn').on('click', function() {
|
|
$('#zoomResetBtn').on('click', function() {
|
|
|
- scale = 1.0;
|
|
|
|
|
- offsetX = 0;
|
|
|
|
|
- offsetY = 0;
|
|
|
|
|
|
|
+ imgScale = baseScale;
|
|
|
|
|
+ imgOffsetX = 0;
|
|
|
|
|
+ imgOffsetY = 0;
|
|
|
drawAll();
|
|
drawAll();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // 鼠标滚轮缩放
|
|
|
|
|
|
|
+ // 鼠标滚轮缩放(以鼠标为中心)
|
|
|
$('#canvas').on('wheel', function(e) {
|
|
$('#canvas').on('wheel', function(e) {
|
|
|
e.preventDefault();
|
|
e.preventDefault();
|
|
|
let mouseX = e.offsetX, mouseY = e.offsetY;
|
|
let mouseX = e.offsetX, mouseY = e.offsetY;
|
|
|
- // 以鼠标为中心缩放
|
|
|
|
|
- let {x: imgX, y: imgY} = toImageCoord(mouseX, mouseY);
|
|
|
|
|
- let oldScale = scale;
|
|
|
|
|
|
|
+ // 1. 记录缩放前鼠标在图片上的坐标
|
|
|
|
|
+ let before = toImageCoord(mouseX, mouseY);
|
|
|
|
|
+ // 2. 计算缩放
|
|
|
if (e.originalEvent.deltaY < 0) {
|
|
if (e.originalEvent.deltaY < 0) {
|
|
|
- scale = Math.min(maxScale, scale * 1.1);
|
|
|
|
|
|
|
+ imgScale = Math.min(maxImgScale, imgScale * 1.1);
|
|
|
} else {
|
|
} else {
|
|
|
- scale = Math.max(minScale, scale / 1.1);
|
|
|
|
|
|
|
+ imgScale = Math.max(minImgScale, imgScale / 1.1);
|
|
|
}
|
|
}
|
|
|
- // 修正:缩放后让imgX、imgY依然在(mouseX, mouseY)位置
|
|
|
|
|
- let domW = $('#canvas').width();
|
|
|
|
|
- let domH = $('#canvas').height();
|
|
|
|
|
- let ratioX = imgWidth / domW;
|
|
|
|
|
- let ratioY = imgHeight / domH;
|
|
|
|
|
- offsetX = (mouseX * ratioX) / scale - imgX;
|
|
|
|
|
- offsetY = (mouseY * ratioY) / scale - imgY;
|
|
|
|
|
|
|
+ // 3. 计算缩放后该点在canvas上的新位置
|
|
|
|
|
+ let after = toCanvasCoord(before.x, before.y);
|
|
|
|
|
+ // 4. 调整offset,使鼠标下的图片点不动
|
|
|
|
|
+ imgOffsetX += (mouseX - after.x);
|
|
|
|
|
+ imgOffsetY += (mouseY - after.y);
|
|
|
drawAll();
|
|
drawAll();
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// 禁止右键菜单弹出
|
|
// 禁止右键菜单弹出
|
|
|
$('#canvas').on('contextmenu', function(e) { e.preventDefault(); });
|
|
$('#canvas').on('contextmenu', function(e) { e.preventDefault(); });
|
|
|
|
|
|
|
|
- // 鼠标移出canvas时隐藏坐标
|
|
|
|
|
|
|
+ // 鼠标移出canvas时隐藏坐标提示
|
|
|
$('#canvas').on('mouseleave', function() {
|
|
$('#canvas').on('mouseleave', function() {
|
|
|
$('#coordTip').hide();
|
|
$('#coordTip').hide();
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
|
|
+ // 按钮切换上一张/下一张图片
|
|
|
|
|
+ $('#prevImgBtn').on('click', function() {
|
|
|
|
|
+ if (currentImgIndex > 0) {
|
|
|
|
|
+ currentImgIndex--;
|
|
|
|
|
+ renderImgList();
|
|
|
|
|
+ loadCurrentImage();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ $('#nextImgBtn').on('click', function() {
|
|
|
|
|
+ if (currentImgIndex < imgUrlArr.length - 1) {
|
|
|
|
|
+ currentImgIndex++;
|
|
|
|
|
+ renderImgList();
|
|
|
|
|
+ loadCurrentImage();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
});
|
|
});
|
|
|
</script>
|
|
</script>
|
|
|
</body>
|
|
</body>
|