| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <title>YOLO图片标注工具</title>
- <style>
- body { font-family: Arial, sans-serif; margin: 0; background: #f7f7f7; }
- #main-container {
- display: flex;
- height: 100vh;
- }
- #left-panel {
- width: 220px;
- background: #fff;
- border-right: 1px solid #eee;
- padding: 20px 10px 10px 10px;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- }
- #imgList {
- flex: 1;
- overflow-y: auto;
- margin-top: 10px;
- }
- .img-url-item {
- padding: 6px 8px;
- border-radius: 4px;
- cursor: pointer;
- margin-bottom: 4px;
- transition: background 0.2s;
- }
- .img-url-item.active, .img-url-item:hover {
- background: #e6f7ff;
- }
- #center-panel {
- flex: 1;
- display: block;
- min-height: 600px;
- height: 100%;
- background: #f5f5f5;
- box-sizing: border-box;
- }
- .canvas-wrapper {
- width: 900px;
- height: 600px;
- background: #f5f5f5;
- position: relative;
- overflow: hidden;
- margin: 0 auto;
- display: block;
- }
- #canvas {
- border: 1px solid #ccc;
- cursor: crosshair;
- background: #acacac;
- box-shadow: 0 2px 8px #222;
- display: block;
- width: 900px;
- height: 600px;
- /* 宽高固定 */
- }
- #controls {
- margin-bottom: 10px;
- width: 100%;
- display: flex;
- gap: 8px;
- justify-content: center;
- }
- #right-panel {
- width: 260px;
- background: #fff;
- border-left: 1px solid #eee;
- padding: 20px 10px 10px 10px;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- }
- #boxes-list {
- margin-top: 10px;
- flex: 1;
- overflow-y: auto;
- }
- .box-item {
- font-size: 14px;
- background: #f5f5f5;
- margin-bottom: 6px;
- padding: 6px 8px;
- border-radius: 4px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .box-item.active {
- background: #ffe58f;
- border: 1.5px solid #faad14;
- }
- .del-btn {
- background: #ff7875;
- color: #fff;
- border: none;
- border-radius: 3px;
- padding: 2px 8px;
- cursor: pointer;
- font-size: 12px;
- }
- .del-btn:hover {
- background: #d9363e;
- }
- #exportBtn {
- margin-top: 10px;
- width: 100%;
- background: #1890ff;
- color: #fff;
- border: none;
- border-radius: 4px;
- padding: 8px 0;
- font-size: 15px;
- cursor: pointer;
- }
- #exportBtn:disabled {
- background: #ccc;
- color: #fff;
- cursor: not-allowed;
- }
- h2 { margin: 0 0 18px 0; text-align: center; }
- #imgUrlInput { width: 100%; box-sizing: border-box; }
- #loadImgBtn { width: 100%; }
- .img-thumb {
- width: 100%;
- max-width: 180px;
- height: 90px;
- object-fit: cover;
- border-radius: 6px;
- border: 2px solid transparent;
- margin-bottom: 8px;
- cursor: pointer;
- transition: border 0.2s;
- background: #f0f0f0;
- }
- .img-thumb.active {
- border: 2px solid #1890ff;
- }
- #filename {
- color: #ccc;
- font-size: 15px;
- margin-top: 10px;
- text-align: center;
- }
- </style>
- <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
- </head>
- <body>
- <div id="main-container">
- <!-- 左侧图片列表 -->
- <div id="left-panel">
- <h2>图片列表</h2>
- <div id="imgList"></div>
- </div>
- <!-- 中间标注区 -->
- <div id="center-panel">
- <h2>图片标注区</h2>
- <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="zoomOutBtn" 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 id="filename" style="color:#ccc;font-size:15px;margin:16px 0 0 0;text-align:center;"></div>
- </div>
- <!-- 右侧标注结果 -->
- <div id="right-panel">
- <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>
- <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>
- </div>
- </div>
- <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 selectedBoxIndex = -1; // 当前选中的框索引
- let dragMode = null; // 拖动模式:null/move/resize
- let dragOffsetX = 0, dragOffsetY = 0; // 拖动时鼠标与框左上角的偏移
- const HANDLE_SIZE = 10; // 手柄的像素大小
- // 8个手柄类型,分别对应四个角和四条边中点
- const HANDLE_TYPES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
- // 缩放相关
- 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 isPanning = false; // 是否正在拖动画布
- let panStart = {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内部(图片坐标)
- function isInBox(x, y, box) {
- return x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h;
- }
- // 获取8个手柄的中心坐标(用于缩放)
- function getHandles(box, size) {
- const x = box.x, y = box.y, w = box.w, h = box.h;
- const hs = (size || HANDLE_SIZE) / 2;
- return {
- nw: { x: x - hs, y: y - hs }, // 左上角
- n: { x: x + w/2 - hs, y: y - hs }, // 上中
- ne: { x: x + w - hs, y: y - hs }, // 右上角
- e: { x: x + w - hs, y: y + h/2 - hs }, // 右中
- se: { x: x + w - hs, y: y + h - hs }, // 右下角
- s: { x: x + w/2 - hs, y: y + h - hs }, // 下中
- sw: { x: x - hs, y: y + h - hs }, // 左下角
- w: { x: x - hs, y: y + h/2 - hs } // 左中
- };
- }
- // 判断点(x, y)是否在某个手柄上,返回手柄类型(canvas坐标)
- function getHandleAt(x, y, box) {
- let domW = $('#canvas').width();
- let pixelRatio = $('#canvas')[0].width / domW;
- let handleSize = 10 * pixelRatio;
- const handles = getHandles(box, handleSize);
- for (let type of HANDLE_TYPES) {
- const hx = handles[type].x, hy = handles[type].y;
- if (x >= hx && x <= hx + handleSize && y >= hy && y <= hy + handleSize) {
- return type;
- }
- }
- return null;
- }
- // 根据手柄类型设置鼠标指针样式
- function setCursorByHandle(type) {
- const map = {
- nw: 'nwse-resize',
- se: 'nwse-resize',
- ne: 'nesw-resize',
- sw: 'nesw-resize',
- n: 'ns-resize',
- s: 'ns-resize',
- e: 'ew-resize',
- w: 'ew-resize'
- };
- $('#canvas').css('cursor', map[type] || 'default');
- }
- // -------------------- 绘制函数 --------------------
- // 负责绘制图片、所有标注框、选中框的手柄、正在绘制的新框
- function drawAll() {
- const ctx = $('#canvas')[0].getContext('2d');
- ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
- ctx.save();
- 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) => {
- ctx.save();
- let c = toCanvasCoord(box.x, box.y); // 左上角canvas坐标
- let cw = box.w * imgScale;
- let ch = box.h * imgScale;
- // 先绘制半透明背景色和边框色,按分类区分
- if (idx === selectedBoxIndex) {
- ctx.fillStyle = 'rgba(255,200,0,0.18)'; // 选中填充
- ctx.strokeStyle = 'orange'; // 选中边框
- } else {
- 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.strokeRect(c.x, c.y, cw, ch);
- // 绘制选中框的8个手柄
- if (idx === selectedBoxIndex) {
- ctx.fillStyle = 'blue';
- const handles = getHandles({x: c.x, y: c.y, w: cw, h: ch}, handleSize);
- for (let type of HANDLE_TYPES) {
- ctx.fillRect(handles[type].x, handles[type].y, handleSize, handleSize);
- }
- }
- ctx.restore();
- });
- // 绘制正在新建的框
- if (drawing) {
- ctx.strokeStyle = 'blue';
- ctx.lineWidth = lineWidth;
- let c1 = toCanvasCoord(startX, startY);
- let c2 = toCanvasCoord(endX, endY);
- ctx.strokeRect(
- 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();
- }
- // -------------------- 右侧标注框列表渲染 --------------------
- // 渲染右侧标注框列表,并高亮选中项
- function updateBoxesList() {
- let html = '';
- boxes.forEach((box, idx) => {
- const active = (idx === selectedBoxIndex) ? ' active' : '';
- 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);
- $('#exportBtn').prop('disabled', boxes.length === 0);
- }
- // -------------------- 坐标换算函数 --------------------
- // canvas坐标转图片坐标
- function toImageCoord(canvasX, canvasY) {
- 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() {
- // -------------------- 图片列表与mock数据 --------------------
- let imgUrlArr = [
- {
- 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; // 当前图片索引
- // 渲染左侧图片缩略图列表
- function renderImgList() {
- let html = '';
- imgUrlArr.forEach((item, idx) => {
- html += `<img src="${item.url}" class="img-thumb${idx===currentImgIndex?' active':''}" data-idx="${idx}" title="${item.url}">`;
- });
- $('#imgList').html(html);
- }
- renderImgList();
- // 点击图片缩略图切换图片
- $('#imgList').on('click', '.img-thumb', function() {
- const idx = $(this).data('idx');
- if (currentImgIndex === idx) return;
- currentImgIndex = idx;
- renderImgList();
- loadCurrentImage();
- });
- // 加载当前选中图片到canvas,并初始化标注框
- function loadCurrentImage() {
- const imgObj = imgUrlArr[currentImgIndex];
- const url = imgObj.url;
- // 显示文件名
- 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() {
- imgWidth = img.width;
- imgHeight = img.height;
- // 计算自适应缩放比例
- 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 = imgObj.boxes ? JSON.parse(JSON.stringify(imgObj.boxes)) : [];
- selectedBoxIndex = -1;
- drawAll();
- updateBoxesList();
- };
- img.onerror = function() {
- alert('图片加载失败,请检查URL是否正确');
- };
- img.src = url;
- }
- // 页面加载时自动加载第一张图片
- loadCurrentImage();
- let resizeHandle = null; // 当前正在拖动的手柄类型
- // 监听键盘空格按下/松开(用于画布拖动)
- $(document).on('keydown', function(e) {
- if (e.code === 'Space') spacePressed = true;
- });
- $(document).on('keyup', function(e) {
- if (e.code === 'Space') spacePressed = false;
- });
- // -------------------- canvas鼠标事件 --------------------
- // 鼠标按下事件
- $('#canvas').on('mousedown', function(e) {
- if (!imgWidth) return;
- const rect = this.getBoundingClientRect();
- const canvasX = e.clientX - rect.left;
- const canvasY = e.clientY - rect.top;
- // 新增:空格+左键 或 右键拖动画布
- if ((spacePressed && e.button === 0) || e.button === 2) {
- isPanning = true;
- panStart = {x: e.clientX, y: e.clientY};
- panOffsetStart = {x: imgOffsetX, y: imgOffsetY};
- $('#canvas').css('cursor', 'grab');
- return;
- }
- let found = false;
- resizeHandle = null;
- // 优先判断是否点中手柄或框(全部用canvas坐标判定)
- for (let i = boxes.length - 1; i >= 0; 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) {
- selectedBoxIndex = i;
- dragMode = 'resize';
- resizeHandle = handleType;
- found = true;
- break;
- } else if (
- canvasX >= cbox.x && canvasX <= cbox.x + cw &&
- canvasY >= cbox.y && canvasY <= cbox.y + ch
- ) {
- selectedBoxIndex = i;
- dragMode = 'move';
- dragOffsetX = toImageCoord(canvasX, canvasY).x - boxes[i].x;
- dragOffsetY = toImageCoord(canvasX, canvasY).y - boxes[i].y;
- found = true;
- break;
- }
- }
- if (found) {
- drawing = false;
- } 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;
- }
- }
- // 无论如何都刷新一次,保证视觉同步
- drawAll();
- updateBoxesList();
- });
- // 鼠标移动事件
- $('#canvas').on('mousemove', function(e) {
- const rect = this.getBoundingClientRect();
- const canvasX = e.clientX - rect.left;
- const canvasY = e.clientY - rect.top;
- const {x: mouseX, y: mouseY} = toImageCoord(canvasX, canvasY);
- // 实时显示坐标
- if (imgWidth) {
- let showX = Math.round(mouseX);
- let showY = Math.round(mouseY);
- if (showX >= 0 && showX <= imgWidth && showY >= 0 && showY <= imgHeight) {
- $('#coordTip').text(`${showX}, ${showY}`).css({
- left: (e.clientX - 10) + 'px',
- top: (e.clientY - 28) + 'px',
- display: 'block'
- });
- } else {
- $('#coordTip').hide();
- }
- }
- // 新增:画布拖拽
- if (isPanning) {
- let dx = e.clientX - panStart.x;
- let dy = e.clientY - panStart.y;
- imgOffsetX = panOffsetStart.x + dx;
- imgOffsetY = panOffsetStart.y + dy;
- drawAll();
- return;
- }
- if (drawing) {
- // 新建框时,动态绘制,限制不出界
- endX = Math.max(0, Math.min(mouseX, imgWidth));
- endY = Math.max(0, Math.min(mouseY, imgHeight));
- drawAll();
- } else if (selectedBoxIndex !== -1 && dragMode) {
- let box = boxes[selectedBoxIndex];
- let minW = 10, minH = 10;
- let x = box.x, y = box.y, w = box.w, h = box.h;
- if (dragMode === 'move') {
- // 拖动移动框
- let newX = mouseX - dragOffsetX;
- let newY = mouseY - dragOffsetY;
- newX = Math.max(0, Math.min(newX, imgWidth - w));
- newY = Math.max(0, Math.min(newY, imgHeight - h));
- box.x = newX;
- box.y = newY;
- } else if (dragMode === 'resize' && resizeHandle) {
- // 8方向缩放
- switch (resizeHandle) {
- case 'nw': // 左上角
- box.x = Math.min(mouseX, x + w - minW);
- box.y = Math.min(mouseY, y + h - minH);
- box.w = w + (x - box.x);
- box.h = h + (y - box.y);
- break;
- case 'n': // 上中
- box.y = Math.min(mouseY, y + h - minH);
- box.h = h + (y - box.y);
- break;
- case 'ne': // 右上角
- box.y = Math.min(mouseY, y + h - minH);
- box.w = Math.max(minW, mouseX - x);
- box.h = h + (y - box.y);
- break;
- case 'e': // 右中
- box.w = Math.max(minW, mouseX - x);
- break;
- case 'se': // 右下角
- box.w = Math.max(minW, mouseX - x);
- box.h = Math.max(minH, mouseY - y);
- break;
- case 's': // 下中
- box.h = Math.max(minH, mouseY - y);
- break;
- case 'sw': // 左下角
- box.x = Math.min(mouseX, x + w - minW);
- box.w = w + (x - box.x);
- box.h = Math.max(minH, mouseY - y);
- break;
- case 'w': // 左中
- box.x = Math.min(mouseX, x + w - minW);
- box.w = w + (x - box.x);
- break;
- }
- // 限制不出界
- if (box.x < 0) { box.w += box.x; box.x = 0; }
- if (box.y < 0) { box.h += box.y; box.y = 0; }
- if (box.x + box.w > imgWidth) box.w = imgWidth - box.x;
- if (box.y + box.h > imgHeight) box.h = imgHeight - box.y;
- }
- drawAll();
- updateBoxesList();
- } else if (selectedBoxIndex !== -1) {
- // 悬停手柄时改变鼠标样式
- const box = boxes[selectedBoxIndex];
- const handleType = getHandleAt(mouseX, mouseY, box);
- setCursorByHandle(handleType);
- } else {
- $('#canvas').css('cursor', 'crosshair');
- }
- });
- // 鼠标松开事件
- $(document).on('mouseup', function(e) {
- if (isPanning) {
- isPanning = false;
- $('#canvas').css('cursor', spacePressed ? 'grab' : 'crosshair');
- return;
- }
- const rect = $('#canvas')[0].getBoundingClientRect();
- const canvasX = e.clientX - rect.left;
- const canvasY = e.clientY - rect.top;
- const {x: mouseX, y: mouseY} = toImageCoord(canvasX, canvasY);
- if (drawing) {
- drawing = false;
- // 重新用toImageCoord获取endX、endY,保证为图片原始坐标
- endX = Math.max(0, Math.min(mouseX, imgWidth));
- endY = Math.max(0, Math.min(mouseY, imgHeight));
- const x = Math.max(0, Math.min(startX, endX));
- const y = Math.max(0, Math.min(startY, endY));
- let w = Math.abs(endX - startX);
- let h = Math.abs(endY - startY);
- // 再次限制宽高不超界
- if (x + w > imgWidth) w = imgWidth - x;
- if (y + h > imgHeight) h = imgHeight - y;
- if (w > 5 && h > 5) {
- // 新建框时用当前分类
- const classId = parseInt($('#classSelect').val(), 10);
- boxes.push({ x, y, w, h, classId });
- // 同步到图片对象的boxes
- imgUrlArr[currentImgIndex].boxes = JSON.parse(JSON.stringify(boxes));
- }
- drawAll();
- updateBoxesList();
- }
- // 鼠标松开后,始终清空拖动/缩放状态
- dragMode = null;
- resizeHandle = null;
- });
- // 删除按钮事件
- $('#boxes-list').on('click', '.del-btn', function() {
- const idx = $(this).data('idx');
- boxes.splice(idx, 1);
- if (selectedBoxIndex === idx) selectedBoxIndex = -1;
- drawAll();
- updateBoxesList();
- // 同步到图片对象的boxes
- imgUrlArr[currentImgIndex].boxes = JSON.parse(JSON.stringify(boxes));
- });
- // 右侧标注项点击选中,联动canvas高亮
- $('#boxes-list').on('click', '.box-item', function(e) {
- // 避免点击删除按钮或下拉框时触发
- if (
- $(e.target).hasClass('del-btn') ||
- $(e.target).hasClass('class-select') ||
- e.target.tagName === 'SELECT' ||
- e.target.tagName === 'OPTION'
- ) return;
- const idx = $(this).index();
- selectedBoxIndex = idx;
- drawAll();
- 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格式标注
- $('#exportBtn').on('click', function() {
- // YOLO格式: class x_center y_center width height (均为归一化)
- let lines = boxes.map(box => {
- const x_center = (box.x + box.w / 2) / imgWidth;
- const y_center = (box.y + box.h / 2) / imgHeight;
- const w = box.w / imgWidth;
- const h = box.h / imgHeight;
- return `${box.classId} ${x_center} ${y_center} ${w} ${h}`;
- });
- const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = 'labels.txt';
- a.click();
- });
- // 清空所有标记按钮
- $('#clearBoxesBtn').on('click', function() {
- boxes = [];
- drawAll();
- updateBoxesList();
- // 同步到图片对象的boxes
- imgUrlArr[currentImgIndex].boxes = [];
- });
- // 放大缩小按钮事件
- $('#zoomInBtn').on('click', function() {
- imgScale = Math.min(maxImgScale, imgScale * 1.2);
- drawAll();
- });
- $('#zoomOutBtn').on('click', function() {
- imgScale = Math.max(minImgScale, imgScale / 1.2);
- drawAll();
- });
- $('#zoomResetBtn').on('click', function() {
- imgScale = baseScale;
- imgOffsetX = 0;
- imgOffsetY = 0;
- drawAll();
- });
- // 鼠标滚轮缩放(以鼠标为中心)
- $('#canvas').on('wheel', function(e) {
- e.preventDefault();
- let mouseX = e.offsetX, mouseY = e.offsetY;
- // 1. 记录缩放前鼠标在图片上的坐标
- let before = toImageCoord(mouseX, mouseY);
- // 2. 计算缩放
- if (e.originalEvent.deltaY < 0) {
- imgScale = Math.min(maxImgScale, imgScale * 1.1);
- } else {
- imgScale = Math.max(minImgScale, imgScale / 1.1);
- }
- // 3. 计算缩放后该点在canvas上的新位置
- let after = toCanvasCoord(before.x, before.y);
- // 4. 调整offset,使鼠标下的图片点不动
- imgOffsetX += (mouseX - after.x);
- imgOffsetY += (mouseY - after.y);
- drawAll();
- });
- // 禁止右键菜单弹出
- $('#canvas').on('contextmenu', function(e) { e.preventDefault(); });
- // 鼠标移出canvas时隐藏坐标提示
- $('#canvas').on('mouseleave', function() {
- $('#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>
- </body>
- </html>
|