point.html 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>YOLO图片标注工具</title>
  6. <style>
  7. body { font-family: Arial, sans-serif; margin: 0; background: #f7f7f7; }
  8. #main-container {
  9. display: flex;
  10. height: 100vh;
  11. }
  12. #left-panel {
  13. width: 220px;
  14. background: #fff;
  15. border-right: 1px solid #eee;
  16. padding: 20px 10px 10px 10px;
  17. box-sizing: border-box;
  18. display: flex;
  19. flex-direction: column;
  20. }
  21. #imgList {
  22. flex: 1;
  23. overflow-y: auto;
  24. margin-top: 10px;
  25. }
  26. .img-url-item {
  27. padding: 6px 8px;
  28. border-radius: 4px;
  29. cursor: pointer;
  30. margin-bottom: 4px;
  31. transition: background 0.2s;
  32. }
  33. .img-url-item.active, .img-url-item:hover {
  34. background: #e6f7ff;
  35. }
  36. #center-panel {
  37. flex: 1;
  38. display: block;
  39. min-height: 600px;
  40. height: 100%;
  41. background: #f5f5f5;
  42. box-sizing: border-box;
  43. }
  44. .canvas-wrapper {
  45. width: 900px;
  46. height: 600px;
  47. background: #f5f5f5;
  48. position: relative;
  49. overflow: hidden;
  50. margin: 0 auto;
  51. display: block;
  52. }
  53. #canvas {
  54. border: 1px solid #ccc;
  55. cursor: crosshair;
  56. background: #acacac;
  57. box-shadow: 0 2px 8px #222;
  58. display: block;
  59. width: 900px;
  60. height: 600px;
  61. /* 宽高固定 */
  62. }
  63. #controls {
  64. margin-bottom: 10px;
  65. width: 100%;
  66. display: flex;
  67. gap: 8px;
  68. justify-content: center;
  69. }
  70. #right-panel {
  71. width: 260px;
  72. background: #fff;
  73. border-left: 1px solid #eee;
  74. padding: 20px 10px 10px 10px;
  75. box-sizing: border-box;
  76. display: flex;
  77. flex-direction: column;
  78. }
  79. #boxes-list {
  80. margin-top: 10px;
  81. flex: 1;
  82. overflow-y: auto;
  83. }
  84. .box-item {
  85. font-size: 14px;
  86. background: #f5f5f5;
  87. margin-bottom: 6px;
  88. padding: 6px 8px;
  89. border-radius: 4px;
  90. display: flex;
  91. justify-content: space-between;
  92. align-items: center;
  93. }
  94. .box-item.active {
  95. background: #ffe58f;
  96. border: 1.5px solid #faad14;
  97. }
  98. .del-btn {
  99. background: #ff7875;
  100. color: #fff;
  101. border: none;
  102. border-radius: 3px;
  103. padding: 2px 8px;
  104. cursor: pointer;
  105. font-size: 12px;
  106. }
  107. .del-btn:hover {
  108. background: #d9363e;
  109. }
  110. #exportBtn {
  111. margin-top: 10px;
  112. width: 100%;
  113. background: #1890ff;
  114. color: #fff;
  115. border: none;
  116. border-radius: 4px;
  117. padding: 8px 0;
  118. font-size: 15px;
  119. cursor: pointer;
  120. }
  121. #exportBtn:disabled {
  122. background: #ccc;
  123. color: #fff;
  124. cursor: not-allowed;
  125. }
  126. h2 { margin: 0 0 18px 0; text-align: center; }
  127. #imgUrlInput { width: 100%; box-sizing: border-box; }
  128. #loadImgBtn { width: 100%; }
  129. .img-thumb {
  130. width: 100%;
  131. max-width: 180px;
  132. height: 90px;
  133. object-fit: cover;
  134. border-radius: 6px;
  135. border: 2px solid transparent;
  136. margin-bottom: 8px;
  137. cursor: pointer;
  138. transition: border 0.2s;
  139. background: #f0f0f0;
  140. }
  141. .img-thumb.active {
  142. border: 2px solid #1890ff;
  143. }
  144. </style>
  145. <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
  146. </head>
  147. <body>
  148. <div id="main-container">
  149. <!-- 左侧图片列表 -->
  150. <div id="left-panel">
  151. <h2>图片列表</h2>
  152. <div id="imgList"></div>
  153. </div>
  154. <!-- 中间标注区 -->
  155. <div id="center-panel">
  156. <h2>图片标注区</h2>
  157. <div id="zoom-controls" style="margin:18px 0 10px 0;display:flex;gap:8px;justify-content:center;">
  158. <button id="prevImgBtn" style="padding:6px 18px;font-size:15px;border-radius:4px;border:none;background:#1890ff;color:#fff;cursor:pointer;">上一张</button>
  159. <button id="zoomInBtn" style="padding:4px 14px;">放大</button>
  160. <button id="zoomOutBtn" style="padding:4px 14px;">缩小</button>
  161. <button id="zoomResetBtn" style="padding:4px 14px;">重置</button>
  162. <button id="nextImgBtn" style="padding:6px 18px;font-size:15px;border-radius:4px;border:none;background:#1890ff;color:#fff;cursor:pointer;">下一张</button>
  163. </div>
  164. <div class="canvas-wrapper">
  165. <canvas id="canvas"></canvas>
  166. <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>
  167. </div>
  168. </div>
  169. <!-- 右侧标注结果 -->
  170. <div id="right-panel">
  171. <h2>标注结果</h2>
  172. <div style="margin-bottom:10px;">
  173. <label for="classSelect">分类:</label>
  174. <select id="classSelect" style="font-size:15px;padding:2px 8px;">
  175. <option value="0">cat</option>
  176. <option value="1">chicken</option>
  177. <option value="2">cow</option>
  178. <option value="3">dog</option>
  179. <option value="4">fox</option>
  180. <option value="5">goat</option>
  181. <option value="6">horse</option>
  182. <option value="7">person</option>
  183. <option value="8">racoon</option>
  184. <option value="9">skunk</option>
  185. </select>
  186. </div>
  187. <div id="boxes-list"></div>
  188. <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>
  189. <button id="exportBtn" disabled style="margin-top:0;">导出YOLO标注</button>
  190. </div>
  191. </div>
  192. <script>
  193. // -------------------- 全局变量定义 --------------------
  194. let img = new window.Image(); // 当前加载的图片对象
  195. let boxes = []; // 当前图片的所有标注框数组
  196. let drawing = false; // 是否正在绘制新框
  197. let startX = 0, startY = 0, endX = 0, endY = 0; // 新建框的起止点(图片坐标)
  198. let imgWidth = 0, imgHeight = 0; // 当前图片的原始宽高
  199. let selectedBoxIndex = -1; // 当前选中的框索引
  200. let dragMode = null; // 拖动模式:null/move/resize
  201. let dragOffsetX = 0, dragOffsetY = 0; // 拖动时鼠标与框左上角的偏移
  202. const HANDLE_SIZE = 10; // 手柄的像素大小
  203. // 8个手柄类型,分别对应四个角和四条边中点
  204. const HANDLE_TYPES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
  205. // 缩放相关
  206. let imgScale = 1.0; // 图片缩放比例
  207. let minImgScale = 0.2, maxImgScale = 5.0; // 缩放上下限
  208. let imgOffsetX = 0, imgOffsetY = 0; // 图片在canvas内的平移
  209. let baseScale = 1.0; // 图片全图自适应时的缩放比例
  210. let spacePressed = false; // 是否按下空格
  211. let isPanning = false; // 是否正在拖动画布
  212. let panStart = {x: 0, y: 0}; // 拖动画布起点
  213. let panOffsetStart = {x: 0, y: 0}; // 拖动画布时的初始偏移
  214. // 分类配置:名称和颜色
  215. const classConfig = [
  216. { index: 0, name: 'cat', border: '#e67e22' }, // 橙色
  217. { index: 1, name: 'chicken',border: '#f1c40f' }, // 黄色
  218. { index: 2, name: 'cow', border: '#95a5a6' }, // 灰色
  219. { index: 3, name: 'dog', border: '#2980b9' }, // 蓝色
  220. { index: 4, name: 'fox', border: '#d35400' }, // 深橙
  221. { index: 5, name: 'goat', border: '#16a085' }, // 青色
  222. { index: 6, name: 'horse', border: '#8e44ad' }, // 紫色
  223. { index: 7, name: 'person', border: '#e74c3c' }, // 红色
  224. { index: 8, name: 'racoon', border: '#34495e' }, // 深蓝灰
  225. { index: 9, name: 'skunk', border: '#2c3e50' } // 深灰
  226. ];
  227. // 辅助函数:将hex色转为rgba字符串,a为透明度
  228. function hexToRgba(hex, a) {
  229. hex = hex.replace('#', '');
  230. if (hex.length === 3) {
  231. hex = hex.split('').map(x => x + x).join('');
  232. }
  233. const r = parseInt(hex.substring(0,2), 16);
  234. const g = parseInt(hex.substring(2,4), 16);
  235. const b = parseInt(hex.substring(4,6), 16);
  236. return `rgba(${r},${g},${b},${a})`;
  237. }
  238. // -------------------- 工具函数 --------------------
  239. // 判断点(x, y)是否在框box内部(图片坐标)
  240. function isInBox(x, y, box) {
  241. return x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h;
  242. }
  243. // 获取8个手柄的中心坐标(用于缩放)
  244. function getHandles(box, size) {
  245. const x = box.x, y = box.y, w = box.w, h = box.h;
  246. const hs = (size || HANDLE_SIZE) / 2;
  247. return {
  248. nw: { x: x - hs, y: y - hs }, // 左上角
  249. n: { x: x + w/2 - hs, y: y - hs }, // 上中
  250. ne: { x: x + w - hs, y: y - hs }, // 右上角
  251. e: { x: x + w - hs, y: y + h/2 - hs }, // 右中
  252. se: { x: x + w - hs, y: y + h - hs }, // 右下角
  253. s: { x: x + w/2 - hs, y: y + h - hs }, // 下中
  254. sw: { x: x - hs, y: y + h - hs }, // 左下角
  255. w: { x: x - hs, y: y + h/2 - hs } // 左中
  256. };
  257. }
  258. // 判断点(x, y)是否在某个手柄上,返回手柄类型(canvas坐标)
  259. function getHandleAt(x, y, box) {
  260. let domW = $('#canvas').width();
  261. let pixelRatio = $('#canvas')[0].width / domW;
  262. let handleSize = 10 * pixelRatio;
  263. const handles = getHandles(box, handleSize);
  264. for (let type of HANDLE_TYPES) {
  265. const hx = handles[type].x, hy = handles[type].y;
  266. if (x >= hx && x <= hx + handleSize && y >= hy && y <= hy + handleSize) {
  267. return type;
  268. }
  269. }
  270. return null;
  271. }
  272. // 根据手柄类型设置鼠标指针样式
  273. function setCursorByHandle(type) {
  274. const map = {
  275. nw: 'nwse-resize',
  276. se: 'nwse-resize',
  277. ne: 'nesw-resize',
  278. sw: 'nesw-resize',
  279. n: 'ns-resize',
  280. s: 'ns-resize',
  281. e: 'ew-resize',
  282. w: 'ew-resize'
  283. };
  284. $('#canvas').css('cursor', map[type] || 'default');
  285. }
  286. // -------------------- 绘制函数 --------------------
  287. // 负责绘制图片、所有标注框、选中框的手柄、正在绘制的新框
  288. function drawAll() {
  289. const ctx = $('#canvas')[0].getContext('2d');
  290. ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  291. ctx.save();
  292. const info = window._imgDrawInfo;
  293. if (info) {
  294. // 计算图片在canvas上的显示区域
  295. let drawW = info.imgWidth * imgScale;
  296. let drawH = info.imgHeight * imgScale;
  297. let drawX = (900 - drawW) / 2 + imgOffsetX;
  298. let drawY = (600 - drawH) / 2 + imgOffsetY;
  299. ctx.drawImage(img, drawX, drawY, drawW, drawH);
  300. }
  301. let lineWidth = 2;
  302. let handleSize = 10;
  303. // 绘制所有标注框
  304. boxes.forEach((box, idx) => {
  305. ctx.save();
  306. let c = toCanvasCoord(box.x, box.y); // 左上角canvas坐标
  307. let cw = box.w * imgScale;
  308. let ch = box.h * imgScale;
  309. // 先绘制半透明背景色和边框色,按分类区分
  310. if (idx === selectedBoxIndex) {
  311. ctx.fillStyle = 'rgba(255,200,0,0.18)'; // 选中填充
  312. ctx.strokeStyle = 'orange'; // 选中边框
  313. } else {
  314. const conf = classConfig.find(c => c.index === box.classId) || { border: 'red' };
  315. ctx.fillStyle = hexToRgba(conf.border, 0.18);
  316. ctx.strokeStyle = conf.border;
  317. }
  318. ctx.lineWidth = lineWidth;
  319. ctx.fillRect(c.x, c.y, cw, ch);
  320. ctx.strokeRect(c.x, c.y, cw, ch);
  321. // 绘制选中框的8个手柄
  322. if (idx === selectedBoxIndex) {
  323. ctx.fillStyle = 'blue';
  324. const handles = getHandles({x: c.x, y: c.y, w: cw, h: ch}, handleSize);
  325. for (let type of HANDLE_TYPES) {
  326. ctx.fillRect(handles[type].x, handles[type].y, handleSize, handleSize);
  327. }
  328. }
  329. ctx.restore();
  330. });
  331. // 绘制正在新建的框
  332. if (drawing) {
  333. ctx.strokeStyle = 'blue';
  334. ctx.lineWidth = lineWidth;
  335. let c1 = toCanvasCoord(startX, startY);
  336. let c2 = toCanvasCoord(endX, endY);
  337. ctx.strokeRect(
  338. Math.min(c1.x, c2.x),
  339. Math.min(c1.y, c2.y),
  340. Math.abs(c2.x - c1.x),
  341. Math.abs(c2.y - c1.y)
  342. );
  343. }
  344. ctx.restore();
  345. }
  346. // -------------------- 右侧标注框列表渲染 --------------------
  347. // 渲染右侧标注框列表,并高亮选中项
  348. function updateBoxesList() {
  349. let html = '';
  350. boxes.forEach((box, idx) => {
  351. const active = (idx === selectedBoxIndex) ? ' active' : '';
  352. html += `<div class="box-item${active}">
  353. 框${idx+1} [
  354. <select class='class-select' data-idx='${idx}' style='font-size:13px;padding:1px 6px;'>
  355. ${classConfig.map(c => `<option value="${c.index}"${box.classId==c.index?' selected':''}>${c.name}</option>`).join('')}
  356. </select>
  357. ] x:${box.x}, y:${box.y}, w:${box.w}, h:${box.h}
  358. <button data-idx="${idx}" class="del-btn">删除</button>
  359. </div>`;
  360. });
  361. $('#boxes-list').html(html);
  362. $('#exportBtn').prop('disabled', boxes.length === 0);
  363. }
  364. // -------------------- 坐标换算函数 --------------------
  365. // canvas坐标转图片坐标
  366. function toImageCoord(canvasX, canvasY) {
  367. const info = window._imgDrawInfo;
  368. if (!info) return {x: 0, y: 0};
  369. let drawW = info.imgWidth * imgScale;
  370. let drawH = info.imgHeight * imgScale;
  371. let drawX = (900 - drawW) / 2 + imgOffsetX;
  372. let drawY = (600 - drawH) / 2 + imgOffsetY;
  373. let x = (canvasX - drawX) / imgScale;
  374. let y = (canvasY - drawY) / imgScale;
  375. return {x, y};
  376. }
  377. // 图片坐标转canvas坐标
  378. function toCanvasCoord(imgX, imgY) {
  379. const info = window._imgDrawInfo;
  380. if (!info) return {x: 0, y: 0};
  381. let drawW = info.imgWidth * imgScale;
  382. let drawH = info.imgHeight * imgScale;
  383. let drawX = (900 - drawW) / 2 + imgOffsetX;
  384. let drawY = (600 - drawH) / 2 + imgOffsetY;
  385. let x = imgX * imgScale + drawX;
  386. let y = imgY * imgScale + drawY;
  387. return {x, y};
  388. }
  389. $(function() {
  390. // -------------------- 图片列表与mock数据 --------------------
  391. let imgUrlArr = [
  392. {
  393. id: 'img1',
  394. url: 'train/images/1_jpg.rf.5c86eb65dfbf1ec4e2fc4830270f2e16.jpg',
  395. boxes: [
  396. { x: 0.67734375, y: 0.5359375, w: 0.6453125, h: 0.88203125, classId: 4 }
  397. ]
  398. },
  399. {
  400. id: 'img2',
  401. 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',
  402. boxes: [
  403. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  404. ]
  405. },
  406. {
  407. id: 'img2',
  408. 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',
  409. boxes: [
  410. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  411. ]
  412. },
  413. {
  414. id: 'img2',
  415. 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',
  416. boxes: [
  417. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  418. ]
  419. },
  420. {
  421. id: 'img2',
  422. 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',
  423. boxes: [
  424. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  425. ]
  426. },
  427. {
  428. id: 'img2',
  429. 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',
  430. boxes: [
  431. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  432. ]
  433. },
  434. {
  435. id: 'img2',
  436. 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',
  437. boxes: [
  438. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  439. ]
  440. },
  441. {
  442. id: 'img2',
  443. 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',
  444. boxes: [
  445. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  446. ]
  447. },
  448. {
  449. id: 'img2',
  450. 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',
  451. boxes: [
  452. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  453. ]
  454. },
  455. {
  456. id: 'img2',
  457. 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',
  458. boxes: [
  459. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  460. ]
  461. },
  462. {
  463. id: 'img2',
  464. 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',
  465. boxes: [
  466. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  467. ]
  468. },
  469. {
  470. id: 'img2',
  471. 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',
  472. boxes: [
  473. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  474. ]
  475. },
  476. {
  477. id: 'img2',
  478. 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',
  479. boxes: [
  480. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  481. ]
  482. },
  483. {
  484. id: 'img2',
  485. 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',
  486. boxes: [
  487. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  488. ]
  489. },
  490. {
  491. id: 'img2',
  492. 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',
  493. boxes: [
  494. { x: 50, y: 50, w: 60, h: 60, classId: 0 }
  495. ]
  496. }
  497. ];
  498. let currentImgIndex = 0; // 当前图片索引
  499. // 渲染左侧图片缩略图列表
  500. function renderImgList() {
  501. let html = '';
  502. imgUrlArr.forEach((item, idx) => {
  503. html += `<img src="${item.url}" class="img-thumb${idx===currentImgIndex?' active':''}" data-idx="${idx}" title="${item.url}">`;
  504. });
  505. $('#imgList').html(html);
  506. // 渲染后自动滚动选中图片到可见区域并居中
  507. setTimeout(() => {
  508. const $active = $('#imgList .img-thumb.active')[0];
  509. if ($active) $active.scrollIntoView({ block: 'center', behavior: 'auto' });
  510. }, 0);
  511. }
  512. renderImgList();
  513. // 点击图片缩略图切换图片
  514. $('#imgList').on('click', '.img-thumb', function() {
  515. const idx = $(this).data('idx');
  516. if (currentImgIndex === idx) return;
  517. currentImgIndex = idx;
  518. renderImgList();
  519. loadCurrentImage();
  520. });
  521. // 加载当前选中图片到canvas,并初始化标注框
  522. function loadCurrentImage() {
  523. const imgObj = imgUrlArr[currentImgIndex];
  524. const url = imgObj.url;
  525. // 更新按钮禁用状态
  526. $('#prevImgBtn').prop('disabled', currentImgIndex === 0);
  527. $('#nextImgBtn').prop('disabled', currentImgIndex === imgUrlArr.length - 1);
  528. // 先解绑,防止多次触发
  529. img.onload = null;
  530. img.onerror = null;
  531. img.onload = function() {
  532. imgWidth = img.width;
  533. imgHeight = img.height;
  534. // 计算自适应缩放比例
  535. if (imgWidth <= 900 && imgHeight <= 600) {
  536. baseScale = 1;
  537. } else {
  538. baseScale = Math.min(900 / imgWidth, 600 / imgHeight);
  539. }
  540. imgScale = baseScale;
  541. imgOffsetX = 0;
  542. imgOffsetY = 0;
  543. window._imgDrawInfo = {imgWidth, imgHeight};
  544. $('#canvas').attr({ width: 900, height: 600 });
  545. $('#canvas').css({ width: '900px', height: '600px', margin: 0, padding: 0 });
  546. // 初始化标注框
  547. boxes = imgObj.boxes ? JSON.parse(JSON.stringify(imgObj.boxes)) : [];
  548. selectedBoxIndex = -1;
  549. drawAll();
  550. updateBoxesList();
  551. };
  552. img.onerror = function() {
  553. alert('图片加载失败,请检查URL是否正确');
  554. };
  555. img.src = url;
  556. }
  557. // 页面加载时自动加载第一张图片
  558. loadCurrentImage();
  559. let resizeHandle = null; // 当前正在拖动的手柄类型
  560. // 监听键盘空格按下/松开(用于画布拖动)
  561. $(document).on('keydown', function(e) {
  562. if (e.code === 'Space') spacePressed = true;
  563. });
  564. $(document).on('keyup', function(e) {
  565. if (e.code === 'Space') spacePressed = false;
  566. });
  567. // -------------------- canvas鼠标事件 --------------------
  568. // 鼠标按下事件
  569. $('#canvas').on('mousedown', function(e) {
  570. if (!imgWidth) return;
  571. const rect = this.getBoundingClientRect();
  572. const canvasX = e.clientX - rect.left;
  573. const canvasY = e.clientY - rect.top;
  574. // 新增:空格+左键 或 右键拖动画布
  575. if ((spacePressed && e.button === 0) || e.button === 2) {
  576. isPanning = true;
  577. panStart = {x: e.clientX, y: e.clientY};
  578. panOffsetStart = {x: imgOffsetX, y: imgOffsetY};
  579. $('#canvas').css('cursor', 'grab');
  580. return;
  581. }
  582. let found = false;
  583. resizeHandle = null;
  584. // 优先判断是否点中手柄或框(全部用canvas坐标判定)
  585. for (let i = boxes.length - 1; i >= 0; i--) {
  586. let cbox = toCanvasCoord(boxes[i].x, boxes[i].y);
  587. let cw = boxes[i].w * imgScale;
  588. let ch = boxes[i].h * imgScale;
  589. const handleType = getHandleAt(canvasX, canvasY, {x: cbox.x, y: cbox.y, w: cw, h: ch});
  590. if (handleType) {
  591. selectedBoxIndex = i;
  592. dragMode = 'resize';
  593. resizeHandle = handleType;
  594. found = true;
  595. break;
  596. } else if (
  597. canvasX >= cbox.x && canvasX <= cbox.x + cw &&
  598. canvasY >= cbox.y && canvasY <= cbox.y + ch
  599. ) {
  600. selectedBoxIndex = i;
  601. dragMode = 'move';
  602. dragOffsetX = toImageCoord(canvasX, canvasY).x - boxes[i].x;
  603. dragOffsetY = toImageCoord(canvasX, canvasY).y - boxes[i].y;
  604. found = true;
  605. break;
  606. }
  607. }
  608. if (found) {
  609. drawing = false;
  610. } else {
  611. // 没点中任何框,开始新建或取消选中
  612. selectedBoxIndex = -1;
  613. // 新建时,只有初始点在图片区域内才允许框选
  614. const {x: mouseX, y: mouseY} = toImageCoord(canvasX, canvasY);
  615. if (mouseX >= 0 && mouseX <= imgWidth && mouseY >= 0 && mouseY <= imgHeight) {
  616. drawing = true;
  617. startX = mouseX;
  618. startY = mouseY;
  619. endX = mouseX;
  620. endY = mouseY;
  621. }
  622. }
  623. // 无论如何都刷新一次,保证视觉同步
  624. drawAll();
  625. updateBoxesList();
  626. });
  627. // 鼠标移动事件
  628. $('#canvas').on('mousemove', function(e) {
  629. const rect = this.getBoundingClientRect();
  630. const canvasX = e.clientX - rect.left;
  631. const canvasY = e.clientY - rect.top;
  632. const {x: mouseX, y: mouseY} = toImageCoord(canvasX, canvasY);
  633. // 实时显示坐标
  634. if (imgWidth) {
  635. let showX = Math.round(mouseX);
  636. let showY = Math.round(mouseY);
  637. if (showX >= 0 && showX <= imgWidth && showY >= 0 && showY <= imgHeight) {
  638. $('#coordTip').text(`${showX}, ${showY}`).css({
  639. left: (e.clientX - 10) + 'px',
  640. top: (e.clientY - 28) + 'px',
  641. display: 'block'
  642. });
  643. } else {
  644. $('#coordTip').hide();
  645. }
  646. }
  647. // 新增:画布拖拽
  648. if (isPanning) {
  649. let dx = e.clientX - panStart.x;
  650. let dy = e.clientY - panStart.y;
  651. imgOffsetX = panOffsetStart.x + dx;
  652. imgOffsetY = panOffsetStart.y + dy;
  653. drawAll();
  654. return;
  655. }
  656. if (drawing) {
  657. // 新建框时,动态绘制,限制不出界
  658. endX = Math.max(0, Math.min(mouseX, imgWidth));
  659. endY = Math.max(0, Math.min(mouseY, imgHeight));
  660. drawAll();
  661. } else if (selectedBoxIndex !== -1 && dragMode) {
  662. let box = boxes[selectedBoxIndex];
  663. let minW = 10, minH = 10;
  664. let x = box.x, y = box.y, w = box.w, h = box.h;
  665. if (dragMode === 'move') {
  666. // 拖动移动框
  667. let newX = mouseX - dragOffsetX;
  668. let newY = mouseY - dragOffsetY;
  669. newX = Math.max(0, Math.min(newX, imgWidth - w));
  670. newY = Math.max(0, Math.min(newY, imgHeight - h));
  671. box.x = newX;
  672. box.y = newY;
  673. } else if (dragMode === 'resize' && resizeHandle) {
  674. // 8方向缩放
  675. switch (resizeHandle) {
  676. case 'nw': // 左上角
  677. box.x = Math.min(mouseX, x + w - minW);
  678. box.y = Math.min(mouseY, y + h - minH);
  679. box.w = w + (x - box.x);
  680. box.h = h + (y - box.y);
  681. break;
  682. case 'n': // 上中
  683. box.y = Math.min(mouseY, y + h - minH);
  684. box.h = h + (y - box.y);
  685. break;
  686. case 'ne': // 右上角
  687. box.y = Math.min(mouseY, y + h - minH);
  688. box.w = Math.max(minW, mouseX - x);
  689. box.h = h + (y - box.y);
  690. break;
  691. case 'e': // 右中
  692. box.w = Math.max(minW, mouseX - x);
  693. break;
  694. case 'se': // 右下角
  695. box.w = Math.max(minW, mouseX - x);
  696. box.h = Math.max(minH, mouseY - y);
  697. break;
  698. case 's': // 下中
  699. box.h = Math.max(minH, mouseY - y);
  700. break;
  701. case 'sw': // 左下角
  702. box.x = Math.min(mouseX, x + w - minW);
  703. box.w = w + (x - box.x);
  704. box.h = Math.max(minH, mouseY - y);
  705. break;
  706. case 'w': // 左中
  707. box.x = Math.min(mouseX, x + w - minW);
  708. box.w = w + (x - box.x);
  709. break;
  710. }
  711. // 限制不出界
  712. if (box.x < 0) { box.w += box.x; box.x = 0; }
  713. if (box.y < 0) { box.h += box.y; box.y = 0; }
  714. if (box.x + box.w > imgWidth) box.w = imgWidth - box.x;
  715. if (box.y + box.h > imgHeight) box.h = imgHeight - box.y;
  716. }
  717. drawAll();
  718. updateBoxesList();
  719. } else if (selectedBoxIndex !== -1) {
  720. // 悬停手柄时改变鼠标样式
  721. const box = boxes[selectedBoxIndex];
  722. const handleType = getHandleAt(mouseX, mouseY, box);
  723. setCursorByHandle(handleType);
  724. } else {
  725. $('#canvas').css('cursor', 'crosshair');
  726. }
  727. });
  728. // 鼠标松开事件
  729. $(document).on('mouseup', function(e) {
  730. if (isPanning) {
  731. isPanning = false;
  732. $('#canvas').css('cursor', spacePressed ? 'grab' : 'crosshair');
  733. return;
  734. }
  735. const rect = $('#canvas')[0].getBoundingClientRect();
  736. const canvasX = e.clientX - rect.left;
  737. const canvasY = e.clientY - rect.top;
  738. const {x: mouseX, y: mouseY} = toImageCoord(canvasX, canvasY);
  739. if (drawing) {
  740. drawing = false;
  741. // 重新用toImageCoord获取endX、endY,保证为图片原始坐标
  742. endX = Math.max(0, Math.min(mouseX, imgWidth));
  743. endY = Math.max(0, Math.min(mouseY, imgHeight));
  744. const x = Math.max(0, Math.min(startX, endX));
  745. const y = Math.max(0, Math.min(startY, endY));
  746. let w = Math.abs(endX - startX);
  747. let h = Math.abs(endY - startY);
  748. // 再次限制宽高不超界
  749. if (x + w > imgWidth) w = imgWidth - x;
  750. if (y + h > imgHeight) h = imgHeight - y;
  751. if (w > 5 && h > 5) {
  752. // 新建框时用当前分类
  753. const classId = parseInt($('#classSelect').val(), 10);
  754. boxes.push({ x, y, w, h, classId });
  755. // 同步到图片对象的boxes
  756. imgUrlArr[currentImgIndex].boxes = JSON.parse(JSON.stringify(boxes));
  757. // 新增后默认选中该框
  758. selectedBoxIndex = boxes.length - 1;
  759. }
  760. drawAll();
  761. updateBoxesList();
  762. }
  763. // 鼠标松开后,始终清空拖动/缩放状态
  764. dragMode = null;
  765. resizeHandle = null;
  766. });
  767. // 删除按钮事件
  768. $('#boxes-list').on('click', '.del-btn', function() {
  769. const idx = $(this).data('idx');
  770. boxes.splice(idx, 1);
  771. if (selectedBoxIndex === idx) selectedBoxIndex = -1;
  772. drawAll();
  773. updateBoxesList();
  774. // 同步到图片对象的boxes
  775. imgUrlArr[currentImgIndex].boxes = JSON.parse(JSON.stringify(boxes));
  776. });
  777. // 支持选中边框时按Delete键删除
  778. $(document).on('keydown', function(e) {
  779. if ((e.key === 'Delete' || e.key === 'Backspace') && selectedBoxIndex !== -1 && boxes.length > 0) {
  780. boxes.splice(selectedBoxIndex, 1);
  781. selectedBoxIndex = -1;
  782. drawAll();
  783. updateBoxesList();
  784. imgUrlArr[currentImgIndex].boxes = JSON.parse(JSON.stringify(boxes));
  785. }
  786. });
  787. // 右侧标注项点击选中,联动canvas高亮
  788. $('#boxes-list').on('click', '.box-item', function(e) {
  789. // 避免点击删除按钮或下拉框时触发
  790. if (
  791. $(e.target).hasClass('del-btn') ||
  792. $(e.target).hasClass('class-select') ||
  793. e.target.tagName === 'SELECT' ||
  794. e.target.tagName === 'OPTION'
  795. ) return;
  796. const idx = $(this).index();
  797. selectedBoxIndex = idx;
  798. drawAll();
  799. updateBoxesList();
  800. });
  801. // 右侧分类下拉框修改事件
  802. $('#boxes-list').on('change', '.class-select', function() {
  803. const idx = $(this).data('idx');
  804. const val = parseInt($(this).val(), 10);
  805. if (boxes[idx]) {
  806. boxes[idx].classId = val;
  807. updateBoxesList();
  808. drawAll();
  809. // 同步到图片对象的boxes
  810. imgUrlArr[currentImgIndex].boxes = JSON.parse(JSON.stringify(boxes));
  811. }
  812. });
  813. // -------------------- 导出/清空/缩放/切换图片等事件 --------------------
  814. // 导出YOLO格式标注
  815. $('#exportBtn').on('click', function() {
  816. // YOLO格式: class x_center y_center width height (均为归一化)
  817. let lines = boxes.map(box => {
  818. const x_center = (box.x + box.w / 2) / imgWidth;
  819. const y_center = (box.y + box.h / 2) / imgHeight;
  820. const w = box.w / imgWidth;
  821. const h = box.h / imgHeight;
  822. return `${box.classId} ${x_center} ${y_center} ${w} ${h}`;
  823. });
  824. const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
  825. const a = document.createElement('a');
  826. a.href = URL.createObjectURL(blob);
  827. a.download = 'labels.txt';
  828. a.click();
  829. });
  830. // 清空所有标记按钮
  831. $('#clearBoxesBtn').on('click', function() {
  832. boxes = [];
  833. drawAll();
  834. updateBoxesList();
  835. // 同步到图片对象的boxes
  836. imgUrlArr[currentImgIndex].boxes = [];
  837. });
  838. // 放大缩小按钮事件
  839. $('#zoomInBtn').on('click', function() {
  840. imgScale = Math.min(maxImgScale, imgScale * 1.2);
  841. drawAll();
  842. });
  843. $('#zoomOutBtn').on('click', function() {
  844. imgScale = Math.max(minImgScale, imgScale / 1.2);
  845. drawAll();
  846. });
  847. $('#zoomResetBtn').on('click', function() {
  848. imgScale = baseScale;
  849. imgOffsetX = 0;
  850. imgOffsetY = 0;
  851. drawAll();
  852. });
  853. // 鼠标滚轮缩放(以鼠标为中心)
  854. $('#canvas').on('wheel', function(e) {
  855. e.preventDefault();
  856. let mouseX = e.offsetX, mouseY = e.offsetY;
  857. // 1. 记录缩放前鼠标在图片上的坐标
  858. let before = toImageCoord(mouseX, mouseY);
  859. // 2. 计算缩放
  860. if (e.originalEvent.deltaY < 0) {
  861. imgScale = Math.min(maxImgScale, imgScale * 1.1);
  862. } else {
  863. imgScale = Math.max(minImgScale, imgScale / 1.1);
  864. }
  865. // 3. 计算缩放后该点在canvas上的新位置
  866. let after = toCanvasCoord(before.x, before.y);
  867. // 4. 调整offset,使鼠标下的图片点不动
  868. imgOffsetX += (mouseX - after.x);
  869. imgOffsetY += (mouseY - after.y);
  870. drawAll();
  871. });
  872. // 禁止右键菜单弹出
  873. $('#canvas').on('contextmenu', function(e) { e.preventDefault(); });
  874. // 鼠标移出canvas时隐藏坐标提示
  875. $('#canvas').on('mouseleave', function() {
  876. $('#coordTip').hide();
  877. });
  878. // 按钮切换上一张/下一张图片
  879. $('#prevImgBtn').on('click', function() {
  880. if (currentImgIndex > 0) {
  881. currentImgIndex--;
  882. renderImgList();
  883. loadCurrentImage();
  884. }
  885. });
  886. $('#nextImgBtn').on('click', function() {
  887. if (currentImgIndex < imgUrlArr.length - 1) {
  888. currentImgIndex++;
  889. renderImgList();
  890. loadCurrentImage();
  891. }
  892. });
  893. });
  894. </script>
  895. </body>
  896. </html>