|
@@ -0,0 +1,621 @@
|
|
|
|
|
+<!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: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: flex-start;
|
|
|
|
|
+ padding: 20px;
|
|
|
|
|
+ max-width: 900px;
|
|
|
|
|
+ max-height: 600px;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ }
|
|
|
|
|
+ #canvas {
|
|
|
|
|
+ border: 1px solid #ccc;
|
|
|
|
|
+ cursor: crosshair;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ box-shadow: 0 2px 8px #eee;
|
|
|
|
|
+ max-width: 900px;
|
|
|
|
|
+ max-height: 600px;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: auto;
|
|
|
|
|
+ }
|
|
|
|
|
+ #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;
|
|
|
|
|
+ }
|
|
|
|
|
+ .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;
|
|
|
|
|
+ }
|
|
|
|
|
+ </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>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ <div id="imgList"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- 中间标注区 -->
|
|
|
|
|
+ <div id="center-panel">
|
|
|
|
|
+ <h2>图片标注区</h2>
|
|
|
|
|
+ <div id="zoom-controls" style="margin-bottom:10px;display:flex;gap:8px;justify-content:center;">
|
|
|
|
|
+ <button id="zoomInBtn" style="padding:4px 14px;">放大</button>
|
|
|
|
|
+ <button id="zoomOutBtn" style="padding:4px 14px;">缩小</button>
|
|
|
|
|
+ <button id="zoomResetBtn" style="padding:4px 14px;">重置</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <canvas id="canvas"></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- 右侧标注结果 -->
|
|
|
|
|
+ <div id="right-panel">
|
|
|
|
|
+ <h2>标注结果</h2>
|
|
|
|
|
+ <div id="boxes-list"></div>
|
|
|
|
|
+ <button id="exportBtn" disabled>导出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 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 spacePressed = false; // 是否按下空格
|
|
|
|
|
+
|
|
|
|
|
+ // 判断点(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)是否在某个手柄上,返回手柄类型
|
|
|
|
|
+ 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();
|
|
|
|
|
+ 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
|
|
|
|
|
+ boxes.forEach((box, idx) => {
|
|
|
|
|
+ ctx.save();
|
|
|
|
|
+ if (idx === selectedBoxIndex) {
|
|
|
|
|
+ ctx.strokeStyle = 'orange'; // 选中框高亮
|
|
|
|
|
+ ctx.lineWidth = lineWidth;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.strokeStyle = 'red';
|
|
|
|
|
+ ctx.lineWidth = lineWidth;
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.strokeRect(box.x, box.y, box.w, box.h);
|
|
|
|
|
+ // 绘制8个缩放手柄
|
|
|
|
|
+ if (idx === selectedBoxIndex) {
|
|
|
|
|
+ ctx.fillStyle = 'blue';
|
|
|
|
|
+ const handles = getHandles(box, 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;
|
|
|
|
|
+ ctx.strokeRect(
|
|
|
|
|
+ Math.min(startX, endX),
|
|
|
|
|
+ Math.min(startY, endY),
|
|
|
|
|
+ Math.abs(endX - startX),
|
|
|
|
|
+ Math.abs(endY - startY)
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function updateBoxesList() {
|
|
|
|
|
+ let html = '';
|
|
|
|
|
+ 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>`;
|
|
|
|
|
+ });
|
|
|
|
|
+ $('#boxes-list').html(html);
|
|
|
|
|
+ $('#exportBtn').prop('disabled', boxes.length === 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 工具函数:canvas坐标转原图坐标
|
|
|
|
|
+ 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)
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $(function() {
|
|
|
|
|
+ // 图片URL缩略图列表,默认一张图片
|
|
|
|
|
+ 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',
|
|
|
|
|
+ 'https://image.cszcyl.cn/2022/image/1A2B3C4D5E6F7G8H9I0J1K2L3M4N5O6P7Q8R9S0T1U2V3W4X5Y6Z7A8B9C0D1E2F3.png',
|
|
|
|
|
+ 'https://image.cszcyl.cn/2022/image/abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc890def123.png',
|
|
|
|
|
+ 'https://image.cszcyl.cn/2022/image/sample2.png'
|
|
|
|
|
+ ];
|
|
|
|
|
+ let currentImgIndex = 0;
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染左侧图片缩略图列表
|
|
|
|
|
+ function renderImgList() {
|
|
|
|
|
+ let html = '';
|
|
|
|
|
+ imgUrlArr.forEach((url, idx) => {
|
|
|
|
|
+ html += `<img src="${url}" class="img-thumb${idx===currentImgIndex?' active':''}" data-idx="${idx}" title="${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 url = imgUrlArr[currentImgIndex];
|
|
|
|
|
+ img.onload = function() {
|
|
|
|
|
+ imgWidth = img.width;
|
|
|
|
|
+ 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;
|
|
|
|
|
+ boxes = [];
|
|
|
|
|
+ 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').on('mousedown', function(e) {
|
|
|
|
|
+ if (!imgWidth) return;
|
|
|
|
|
+ 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 ((spacePressed && e.button === 0) || e.button === 2) {
|
|
|
|
|
+ isPanning = true;
|
|
|
|
|
+ panStart = {x: e.clientX, y: e.clientY};
|
|
|
|
|
+ panOffsetStart = {x: offsetX, y: offsetY};
|
|
|
|
|
+ $('#canvas').css('cursor', 'grab');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ let found = false;
|
|
|
|
|
+ resizeHandle = null;
|
|
|
|
|
+ // 优先判断是否点中手柄
|
|
|
|
|
+ for (let i = boxes.length - 1; i >= 0; i--) {
|
|
|
|
|
+ const handleType = getHandleAt(mouseX, mouseY, boxes[i]);
|
|
|
|
|
+ if (handleType) {
|
|
|
|
|
+ selectedBoxIndex = i;
|
|
|
|
|
+ dragMode = 'resize';
|
|
|
|
|
+ resizeHandle = handleType;
|
|
|
|
|
+ found = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ } else if (isInBox(mouseX, mouseY, boxes[i])) {
|
|
|
|
|
+ // 点中框内部,准备移动
|
|
|
|
|
+ selectedBoxIndex = i;
|
|
|
|
|
+ dragMode = 'move';
|
|
|
|
|
+ dragOffsetX = mouseX - boxes[i].x;
|
|
|
|
|
+ dragOffsetY = mouseY - boxes[i].y;
|
|
|
|
|
+ found = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (found) {
|
|
|
|
|
+ drawing = false;
|
|
|
|
|
+ drawAll();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 没点中任何框,开始新建
|
|
|
|
|
+ selectedBoxIndex = -1;
|
|
|
|
|
+ drawing = true;
|
|
|
|
|
+ startX = endX = mouseX;
|
|
|
|
|
+ startY = endY = mouseY;
|
|
|
|
|
+ drawAll();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 鼠标移动事件
|
|
|
|
|
+ $('#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 (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;
|
|
|
|
|
+ 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;
|
|
|
|
|
+ // 限制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) {
|
|
|
|
|
+ boxes.push({ x, y, w, h, classId: 0 });
|
|
|
|
|
+ }
|
|
|
|
|
+ drawAll();
|
|
|
|
|
+ updateBoxesList();
|
|
|
|
|
+ } else if (selectedBoxIndex !== -1 && dragMode) {
|
|
|
|
|
+ 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();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 导出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();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 缩放按钮事件
|
|
|
|
|
+ $('#zoomInBtn').on('click', function() {
|
|
|
|
|
+ scale = Math.min(maxScale, scale * 1.2);
|
|
|
|
|
+ drawAll();
|
|
|
|
|
+ });
|
|
|
|
|
+ $('#zoomOutBtn').on('click', function() {
|
|
|
|
|
+ scale = Math.max(minScale, scale / 1.2);
|
|
|
|
|
+ drawAll();
|
|
|
|
|
+ });
|
|
|
|
|
+ $('#zoomResetBtn').on('click', function() {
|
|
|
|
|
+ scale = 1.0;
|
|
|
|
|
+ offsetX = 0;
|
|
|
|
|
+ offsetY = 0;
|
|
|
|
|
+ drawAll();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 鼠标滚轮缩放
|
|
|
|
|
+ $('#canvas').on('wheel', function(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ let mouseX = e.offsetX, mouseY = e.offsetY;
|
|
|
|
|
+ // 以鼠标为中心缩放
|
|
|
|
|
+ let {x: imgX, y: imgY} = toImageCoord(mouseX, mouseY);
|
|
|
|
|
+ let oldScale = scale;
|
|
|
|
|
+ if (e.originalEvent.deltaY < 0) {
|
|
|
|
|
+ scale = Math.min(maxScale, scale * 1.1);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ scale = Math.max(minScale, scale / 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;
|
|
|
|
|
+ drawAll();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 禁止右键菜单弹出
|
|
|
|
|
+ $('#canvas').on('contextmenu', function(e) { e.preventDefault(); });
|
|
|
|
|
+ });
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|