arnodjiang commited on
Commit
3ac2d65
·
verified ·
1 Parent(s): d27d967

Upload 2 files

Browse files
Files changed (1) hide show
  1. index.html +300 -192
index.html CHANGED
@@ -297,6 +297,7 @@
297
  }
298
 
299
  .field input,
 
300
  .field textarea {
301
  width: 100%;
302
  min-width: 0;
@@ -311,12 +312,26 @@
311
  background 0.16s ease;
312
  }
313
 
314
- .field input {
 
315
  height: 38px;
316
  padding: 0 10px;
317
  font-weight: 750;
318
  }
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  .field textarea {
321
  min-height: 176px;
322
  padding: 10px;
@@ -327,6 +342,7 @@
327
  }
328
 
329
  .field input:focus,
 
330
  .field textarea:focus {
331
  border-color: rgba(37, 99, 235, 0.78);
332
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
@@ -349,28 +365,37 @@
349
  margin-bottom: 12px;
350
  }
351
 
352
- .action-row {
353
  display: grid;
354
- grid-template-columns: repeat(2, minmax(0, 1fr));
355
  gap: 10px;
 
356
  }
357
 
358
  .result-stack {
359
  display: flex;
360
  flex-direction: column;
361
- gap: 10px;
362
  }
363
 
364
- .result-card {
365
- display: flex;
366
- flex-direction: column;
367
- gap: 6px;
 
368
  padding: 10px;
369
  border: 1px solid var(--line);
370
  border-radius: var(--radius);
371
  background: var(--surface-strong);
372
  }
373
 
 
 
 
 
 
 
 
374
  .result-label {
375
  color: var(--muted);
376
  font-size: 12px;
@@ -388,6 +413,29 @@
388
  white-space: pre-wrap;
389
  }
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  .status {
392
  min-height: 22px;
393
  margin: 10px auto 0;
@@ -449,7 +497,7 @@
449
  }
450
 
451
  .top-actions,
452
- .action-row {
453
  grid-template-columns: 1fr;
454
  }
455
 
@@ -477,8 +525,6 @@
477
  上传图片
478
  <input id="fileInput" type="file" accept="image/*">
479
  </label>
480
- <button class="button" id="fitButton" type="button" disabled>适配视图</button>
481
- <button class="button danger" id="clearButton" type="button" disabled>清空标注</button>
482
  </div>
483
  </header>
484
 
@@ -487,7 +533,7 @@
487
  <div class="stage-toolbar">
488
  <div class="stage-meta">
489
  <span class="pill" id="imageMeta">未上传图片</span>
490
- <span class="pill" id="boxMeta">0 个标注</span>
491
  </div>
492
  </div>
493
  <div class="canvas-wrap" id="canvasWrap">
@@ -503,47 +549,70 @@
503
  <section class="panel">
504
  <h2 class="panel-title">坐标编辑</h2>
505
  <div class="coord-grid">
506
- <label class="field">X
507
- <input id="xInput" type="number" min="0" step="1" disabled>
508
  </label>
509
- <label class="field">Y
510
- <input id="yInput" type="number" min="0" step="1" disabled>
511
  </label>
512
- <label class="field">W
513
- <input id="wInput" type="number" min="1" step="1" disabled>
514
  </label>
515
- <label class="field">H
516
- <input id="hInput" type="number" min="1" step="1" disabled>
517
  </label>
518
  </div>
519
- <div class="action-row">
520
- <button class="button" id="addButton" type="button" disabled>新增注</button>
521
- <button class="button danger" id="deleteButton" type="button" disabled>删除选中</button>
 
 
 
 
 
 
 
 
522
  </div>
523
  </section>
524
 
525
  <section class="panel">
526
  <h2 class="panel-title">坐标结果</h2>
527
  <div class="result-stack">
528
- <div class="result-card">
529
- <div class="result-label">绝对坐标 x1 y1 x2 y2</div>
530
- <div class="result-code" id="absoluteOutput">未选择</div>
 
 
 
531
  </div>
532
- <div class="result-card">
533
- <div class="result-label">绝对坐标 x y w h</div>
534
- <div class="result-code" id="sizeOutput">未选择</div>
 
 
 
535
  </div>
536
- <div class="result-card">
537
- <div class="result-label">相对坐标 x1 y1 x2 y2</div>
538
- <div class="result-code" id="relativeOutput">未选择</div>
 
 
 
539
  </div>
540
- <div class="result-card">
541
- <div class="result-label">相对坐标 0-1000 x1 y1 x2 y2</div>
542
- <div class="result-code" id="relative1000Output">未选择</div>
 
 
 
543
  </div>
544
- <div class="result-card">
545
- <div class="result-label">YOLO 格式 cx cy w h</div>
546
- <div class="result-code" id="yoloOutput">未选择</div>
 
 
 
547
  </div>
548
  </div>
549
  </section>
@@ -565,14 +634,12 @@
565
 
566
  const controls = {
567
  fileInput: $("fileInput"),
568
- fitButton: $("fitButton"),
569
- clearButton: $("clearButton"),
570
- addButton: $("addButton"),
571
- deleteButton: $("deleteButton"),
572
- xInput: $("xInput"),
573
- yInput: $("yInput"),
574
- wInput: $("wInput"),
575
- hInput: $("hInput"),
576
  imageMeta: $("imageMeta"),
577
  boxMeta: $("boxMeta"),
578
  absoluteOutput: $("absoluteOutput"),
@@ -580,6 +647,7 @@
580
  relativeOutput: $("relativeOutput"),
581
  relative1000Output: $("relative1000Output"),
582
  yoloOutput: $("yoloOutput"),
 
583
  statusLine: $("statusLine")
584
  };
585
 
@@ -597,9 +665,7 @@
597
  const state = {
598
  image: null,
599
  imageName: "",
600
- boxes: [],
601
- selectedId: null,
602
- nextId: 1,
603
  drag: null,
604
  scale: 1,
605
  offsetX: 0,
@@ -628,19 +694,16 @@
628
  }
629
 
630
  function selectedBox() {
631
- return state.boxes.find((box) => box.id === state.selectedId) || null;
632
  }
633
 
634
  function makeBox(x, y, width, height) {
635
- const id = state.nextId;
636
- state.nextId += 1;
637
  return {
638
- id,
639
  x,
640
  y,
641
  width,
642
  height,
643
- color: palette[(id - 1) % palette.length]
644
  };
645
  }
646
 
@@ -658,7 +721,9 @@
658
  }
659
 
660
  function constrainAllBoxes() {
661
- state.boxes.forEach(constrainBox);
 
 
662
  }
663
 
664
  function imageToCanvas(point) {
@@ -698,27 +763,29 @@
698
  }
699
 
700
  function hitTest(point) {
701
- const tolerance = Math.max(6 / state.scale, 4);
702
- for (let index = state.boxes.length - 1; index >= 0; index -= 1) {
703
- const box = state.boxes[index];
704
- for (const handle of handlePoints(box)) {
705
- if (
706
- Math.abs(point.x - handle.x) <= tolerance &&
707
- Math.abs(point.y - handle.y) <= tolerance
708
- ) {
709
- return { box, action: "resize", handle: handle.name };
710
- }
711
- }
712
 
 
 
713
  if (
714
- point.x >= box.x &&
715
- point.x <= box.x + box.width &&
716
- point.y >= box.y &&
717
- point.y <= box.y + box.height
718
  ) {
719
- return { box, action: "move", handle: null };
720
  }
721
  }
 
 
 
 
 
 
 
 
 
722
  return null;
723
  }
724
 
@@ -790,52 +857,34 @@
790
  }
791
 
792
  function drawBoxes() {
793
- if (!state.image) {
 
794
  return;
795
  }
796
 
797
- state.boxes.forEach((box) => {
798
- const isActive = box.id === state.selectedId;
799
- const topLeft = imageToCanvas({ x: box.x, y: box.y });
800
- const width = box.width * state.scale;
801
- const height = box.height * state.scale;
802
-
803
- ctx.save();
804
- ctx.lineWidth = isActive ? 3 : 2;
805
- ctx.strokeStyle = box.color;
806
- ctx.fillStyle = `${box.color}1f`;
807
- ctx.fillRect(topLeft.x, topLeft.y, width, height);
808
- ctx.strokeRect(topLeft.x, topLeft.y, width, height);
809
-
810
- const labelText = `#${box.id}`;
811
- ctx.font = "700 12px Inter, system-ui, sans-serif";
812
- const labelWidth = Math.min(
813
- Math.max(ctx.measureText(labelText).width + 14, 42),
814
- Math.max(42, width)
815
- );
816
- const labelHeight = 22;
817
- const labelY = Math.max(state.offsetY, topLeft.y - labelHeight);
818
- ctx.fillStyle = box.color;
819
- ctx.fillRect(topLeft.x, labelY, labelWidth, labelHeight);
820
- ctx.fillStyle = "#ffffff";
821
- ctx.textBaseline = "middle";
822
- ctx.fillText(labelText, topLeft.x + 7, labelY + labelHeight / 2);
823
-
824
- if (isActive) {
825
- const size = 8;
826
- ctx.fillStyle = "#ffffff";
827
- ctx.strokeStyle = box.color;
828
- ctx.lineWidth = 2;
829
- for (const handle of handlePoints(box)) {
830
- const point = imageToCanvas(handle);
831
- ctx.beginPath();
832
- ctx.rect(point.x - size / 2, point.y - size / 2, size, size);
833
- ctx.fill();
834
- ctx.stroke();
835
- }
836
- }
837
- ctx.restore();
838
- });
839
  }
840
 
841
  function draw() {
@@ -859,15 +908,21 @@
859
  return Number.isFinite(value) ? Math.round(value * 1000) : 0;
860
  }
861
 
 
 
 
 
 
 
 
 
 
862
  function resultLines(box) {
863
  if (!box || !state.image) {
864
  return null;
865
  }
866
 
867
- const x1 = box.x;
868
- const y1 = box.y;
869
- const x2 = box.x + box.width;
870
- const y2 = box.y + box.height;
871
  const cx = box.x + box.width / 2;
872
  const cy = box.y + box.height / 2;
873
  const iw = imageWidth();
@@ -905,6 +960,9 @@
905
  controls.relativeOutput.textContent = "未选择";
906
  controls.relative1000Output.textContent = "未选择";
907
  controls.yoloOutput.textContent = "未选择";
 
 
 
908
  return;
909
  }
910
 
@@ -913,6 +971,9 @@
913
  controls.relativeOutput.textContent = values.relative;
914
  controls.relative1000Output.textContent = values.relative1000;
915
  controls.yoloOutput.textContent = values.yolo;
 
 
 
916
  }
917
 
918
  function updateControls() {
@@ -921,36 +982,35 @@
921
  const hasSelected = Boolean(selected);
922
  const disabled = !hasSelected;
923
 
924
- controls.fitButton.disabled = !hasImage;
925
- controls.clearButton.disabled = !state.boxes.length;
926
- controls.addButton.disabled = !hasImage;
927
- controls.deleteButton.disabled = disabled;
928
 
929
  for (const input of [
930
- controls.xInput,
931
- controls.yInput,
932
- controls.wInput,
933
- controls.hInput
934
  ]) {
935
  input.disabled = disabled;
936
  }
937
 
938
  if (selected) {
939
- controls.xInput.value = selected.x;
940
- controls.yInput.value = selected.y;
941
- controls.wInput.value = selected.width;
942
- controls.hInput.value = selected.height;
 
943
  } else {
944
- controls.xInput.value = "";
945
- controls.yInput.value = "";
946
- controls.wInput.value = "";
947
- controls.hInput.value = "";
948
  }
949
 
950
  controls.imageMeta.textContent = hasImage
951
  ? `${state.imageName} · ${imageWidth()} × ${imageHeight()}`
952
  : "未上传图片";
953
- controls.boxMeta.textContent = `${state.boxes.length} 标注`;
954
  updateResults(selected);
955
  }
956
 
@@ -976,9 +1036,8 @@
976
  image.onload = () => {
977
  state.image = image;
978
  state.imageName = file.name || "uploaded-image";
979
- state.boxes = [];
980
- state.selectedId = null;
981
- state.nextId = 1;
982
  setStatus(`已载入 ${state.imageName}`);
983
  sync();
984
  };
@@ -989,47 +1048,75 @@
989
  reader.readAsDataURL(file);
990
  }
991
 
992
- function addCenteredBox() {
993
  if (!state.image) {
994
  return;
995
  }
996
- const width = Math.max(24, Math.round(imageWidth() * 0.28));
997
- const height = Math.max(24, Math.round(imageHeight() * 0.22));
998
- const x = Math.round((imageWidth() - width) / 2);
999
- const y = Math.round((imageHeight() - height) / 2);
1000
- const box = makeBox(x, y, width, height);
1001
- state.boxes.push(box);
1002
- state.selectedId = box.id;
1003
  sync();
1004
  }
1005
 
1006
- function deleteSelectedBox() {
1007
- if (!state.selectedId) {
1008
- return;
1009
- }
1010
- state.boxes = state.boxes.filter((box) => box.id !== state.selectedId);
1011
- state.selectedId = state.boxes.length
1012
- ? state.boxes[state.boxes.length - 1].id
1013
- : null;
1014
  sync();
1015
  }
1016
 
1017
  function updateSelectedFromInputs() {
1018
- const box = selectedBox();
1019
- if (!box || !state.image) {
1020
  return;
1021
  }
1022
 
1023
- const x = Number.parseInt(controls.xInput.value, 10);
1024
- const y = Number.parseInt(controls.yInput.value, 10);
1025
- const width = Number.parseInt(controls.wInput.value, 10);
1026
- const height = Number.parseInt(controls.hInput.value, 10);
 
 
 
 
 
1027
 
1028
- box.x = clamp(x, 0, Math.max(0, imageWidth() - 1));
1029
- box.y = clamp(y, 0, Math.max(0, imageHeight() - 1));
1030
- box.width = clamp(width, 1, imageWidth() - box.x);
1031
- box.height = clamp(height, 1, imageHeight() - box.y);
1032
- sync();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1033
  }
1034
 
1035
  function moveBox(box, startBox, deltaX, deltaY) {
@@ -1073,12 +1160,10 @@
1073
  const point = eventToImagePoint(event);
1074
  const hit = hitTest(point);
1075
  if (hit) {
1076
- state.selectedId = hit.box.id;
1077
  state.drag = {
1078
  action: hit.action,
1079
  handle: hit.handle,
1080
  start: point,
1081
- boxId: hit.box.id,
1082
  startBox: { ...hit.box }
1083
  };
1084
  canvas.setPointerCapture(event.pointerId);
@@ -1087,13 +1172,11 @@
1087
  }
1088
 
1089
  const box = makeBox(point.x, point.y, 1, 1);
1090
- state.boxes.push(box);
1091
- state.selectedId = box.id;
1092
  state.drag = {
1093
  action: "create",
1094
  handle: null,
1095
  start: point,
1096
- boxId: box.id,
1097
  startBox: { ...box }
1098
  };
1099
  canvas.setPointerCapture(event.pointerId);
@@ -1112,7 +1195,7 @@
1112
  return;
1113
  }
1114
 
1115
- const box = state.boxes.find((item) => item.id === state.drag.boxId);
1116
  if (!box) {
1117
  return;
1118
  }
@@ -1143,8 +1226,7 @@
1143
 
1144
  const createdBox = state.drag.action === "create" ? selectedBox() : null;
1145
  if (createdBox && (createdBox.width < 3 || createdBox.height < 3)) {
1146
- state.boxes = state.boxes.filter((box) => box.id !== createdBox.id);
1147
- state.selectedId = null;
1148
  }
1149
  state.drag = null;
1150
  if (canvas.hasPointerCapture(event.pointerId)) {
@@ -1153,34 +1235,60 @@
1153
  sync();
1154
  }
1155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1156
  controls.fileInput.addEventListener("change", (event) => {
1157
  loadImageFile(event.target.files[0]);
1158
  event.target.value = "";
1159
  });
1160
 
1161
- controls.fitButton.addEventListener("click", () => {
1162
- resizeCanvas();
1163
- setStatus("视图已适配");
1164
- });
1165
-
1166
- controls.clearButton.addEventListener("click", () => {
1167
- state.boxes = [];
1168
- state.selectedId = null;
1169
- sync();
1170
- });
1171
-
1172
- controls.addButton.addEventListener("click", addCenteredBox);
1173
- controls.deleteButton.addEventListener("click", deleteSelectedBox);
1174
-
1175
  for (const input of [
1176
- controls.xInput,
1177
- controls.yInput,
1178
- controls.wInput,
1179
- controls.hInput
1180
  ]) {
1181
  input.addEventListener("input", updateSelectedFromInputs);
1182
  }
1183
 
 
 
 
 
 
 
 
 
 
1184
  canvas.addEventListener("pointerdown", onPointerDown);
1185
  canvas.addEventListener("pointermove", onPointerMove);
1186
  canvas.addEventListener("pointerup", onPointerUp);
@@ -1208,7 +1316,7 @@
1208
  return;
1209
  }
1210
  if (event.key === "Delete" || event.key === "Backspace") {
1211
- deleteSelectedBox();
1212
  }
1213
  });
1214
 
 
297
  }
298
 
299
  .field input,
300
+ .field select,
301
  .field textarea {
302
  width: 100%;
303
  min-width: 0;
 
312
  background 0.16s ease;
313
  }
314
 
315
+ .field input,
316
+ .field select {
317
  height: 38px;
318
  padding: 0 10px;
319
  font-weight: 750;
320
  }
321
 
322
+ .field select {
323
+ appearance: none;
324
+ background-image:
325
+ linear-gradient(45deg, transparent 50%, var(--muted) 50%),
326
+ linear-gradient(135deg, var(--muted) 50%, transparent 50%);
327
+ background-position:
328
+ calc(100% - 16px) 16px,
329
+ calc(100% - 11px) 16px;
330
+ background-size: 5px 5px;
331
+ background-repeat: no-repeat;
332
+ padding-right: 30px;
333
+ }
334
+
335
  .field textarea {
336
  min-height: 176px;
337
  padding: 10px;
 
342
  }
343
 
344
  .field input:focus,
345
+ .field select:focus,
346
  .field textarea:focus {
347
  border-color: rgba(37, 99, 235, 0.78);
348
  box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
 
365
  margin-bottom: 12px;
366
  }
367
 
368
+ .paste-grid {
369
  display: grid;
370
+ grid-template-columns: minmax(0, 1fr) 112px;
371
  gap: 10px;
372
+ margin-bottom: 2px;
373
  }
374
 
375
  .result-stack {
376
  display: flex;
377
  flex-direction: column;
378
+ gap: 8px;
379
  }
380
 
381
+ .result-row {
382
+ display: grid;
383
+ grid-template-columns: minmax(0, 1fr) 58px;
384
+ gap: 8px;
385
+ align-items: center;
386
  padding: 10px;
387
  border: 1px solid var(--line);
388
  border-radius: var(--radius);
389
  background: var(--surface-strong);
390
  }
391
 
392
+ .result-text {
393
+ min-width: 0;
394
+ display: flex;
395
+ flex-direction: column;
396
+ gap: 5px;
397
+ }
398
+
399
  .result-label {
400
  color: var(--muted);
401
  font-size: 12px;
 
413
  white-space: pre-wrap;
414
  }
415
 
416
+ .copy-button {
417
+ width: 58px;
418
+ height: 34px;
419
+ border-radius: 6px;
420
+ border: 1px solid var(--line);
421
+ background: #fff;
422
+ color: var(--accent);
423
+ cursor: pointer;
424
+ font-size: 12px;
425
+ font-weight: 850;
426
+ }
427
+
428
+ .copy-button:hover:not(:disabled) {
429
+ border-color: rgba(37, 99, 235, 0.45);
430
+ background: var(--accent-soft);
431
+ }
432
+
433
+ .copy-button:disabled {
434
+ color: #98a2b3;
435
+ cursor: not-allowed;
436
+ background: #f2f4f7;
437
+ }
438
+
439
  .status {
440
  min-height: 22px;
441
  margin: 10px auto 0;
 
497
  }
498
 
499
  .top-actions,
500
+ .paste-grid {
501
  grid-template-columns: 1fr;
502
  }
503
 
 
525
  上传图片
526
  <input id="fileInput" type="file" accept="image/*">
527
  </label>
 
 
528
  </div>
529
  </header>
530
 
 
533
  <div class="stage-toolbar">
534
  <div class="stage-meta">
535
  <span class="pill" id="imageMeta">未上传图片</span>
536
+ <span class="pill" id="boxMeta">标注</span>
537
  </div>
538
  </div>
539
  <div class="canvas-wrap" id="canvasWrap">
 
549
  <section class="panel">
550
  <h2 class="panel-title">坐标编辑</h2>
551
  <div class="coord-grid">
552
+ <label class="field">X1
553
+ <input id="x1Input" type="number" min="0" step="1" disabled>
554
  </label>
555
+ <label class="field">Y1
556
+ <input id="y1Input" type="number" min="0" step="1" disabled>
557
  </label>
558
+ <label class="field">X2
559
+ <input id="x2Input" type="number" min="1" step="1" disabled>
560
  </label>
561
+ <label class="field">Y2
562
+ <input id="y2Input" type="number" min="1" step="1" disabled>
563
  </label>
564
  </div>
565
+ <div class="paste-grid">
566
+ <label class="field">粘贴坐
567
+ <input id="pasteInput" type="text" placeholder="[x1,y1,x2,y2]" disabled>
568
+ </label>
569
+ <label class="field">类型
570
+ <select id="pasteMode" disabled>
571
+ <option value="absolute">绝对</option>
572
+ <option value="scale1000">0-1000</option>
573
+ <option value="relative">0-1</option>
574
+ </select>
575
+ </label>
576
  </div>
577
  </section>
578
 
579
  <section class="panel">
580
  <h2 class="panel-title">坐标结果</h2>
581
  <div class="result-stack">
582
+ <div class="result-row">
583
+ <div class="result-text">
584
+ <div class="result-label">绝对坐标 x1 y1 x2 y2</div>
585
+ <div class="result-code" id="absoluteOutput">未选择</div>
586
+ </div>
587
+ <button class="copy-button" type="button" data-copy="absoluteOutput" disabled>复制</button>
588
  </div>
589
+ <div class="result-row">
590
+ <div class="result-text">
591
+ <div class="result-label">绝对坐标 x y w h</div>
592
+ <div class="result-code" id="sizeOutput">未选择</div>
593
+ </div>
594
+ <button class="copy-button" type="button" data-copy="sizeOutput" disabled>复制</button>
595
  </div>
596
+ <div class="result-row">
597
+ <div class="result-text">
598
+ <div class="result-label">相对坐标 x1 y1 x2 y2</div>
599
+ <div class="result-code" id="relativeOutput">未选择</div>
600
+ </div>
601
+ <button class="copy-button" type="button" data-copy="relativeOutput" disabled>复制</button>
602
  </div>
603
+ <div class="result-row">
604
+ <div class="result-text">
605
+ <div class="result-label">相对坐标 0-1000 x1 y1 x2 y2</div>
606
+ <div class="result-code" id="relative1000Output">未选择</div>
607
+ </div>
608
+ <button class="copy-button" type="button" data-copy="relative1000Output" disabled>复制</button>
609
  </div>
610
+ <div class="result-row">
611
+ <div class="result-text">
612
+ <div class="result-label">YOLO 格式 cx cy w h</div>
613
+ <div class="result-code" id="yoloOutput">未选择</div>
614
+ </div>
615
+ <button class="copy-button" type="button" data-copy="yoloOutput" disabled>复制</button>
616
  </div>
617
  </div>
618
  </section>
 
634
 
635
  const controls = {
636
  fileInput: $("fileInput"),
637
+ x1Input: $("x1Input"),
638
+ y1Input: $("y1Input"),
639
+ x2Input: $("x2Input"),
640
+ y2Input: $("y2Input"),
641
+ pasteInput: $("pasteInput"),
642
+ pasteMode: $("pasteMode"),
 
 
643
  imageMeta: $("imageMeta"),
644
  boxMeta: $("boxMeta"),
645
  absoluteOutput: $("absoluteOutput"),
 
647
  relativeOutput: $("relativeOutput"),
648
  relative1000Output: $("relative1000Output"),
649
  yoloOutput: $("yoloOutput"),
650
+ copyButtons: Array.from(document.querySelectorAll("[data-copy]")),
651
  statusLine: $("statusLine")
652
  };
653
 
 
665
  const state = {
666
  image: null,
667
  imageName: "",
668
+ box: null,
 
 
669
  drag: null,
670
  scale: 1,
671
  offsetX: 0,
 
694
  }
695
 
696
  function selectedBox() {
697
+ return state.box;
698
  }
699
 
700
  function makeBox(x, y, width, height) {
 
 
701
  return {
 
702
  x,
703
  y,
704
  width,
705
  height,
706
+ color: palette[0]
707
  };
708
  }
709
 
 
721
  }
722
 
723
  function constrainAllBoxes() {
724
+ if (state.box) {
725
+ constrainBox(state.box);
726
+ }
727
  }
728
 
729
  function imageToCanvas(point) {
 
763
  }
764
 
765
  function hitTest(point) {
766
+ const box = state.box;
767
+ if (!box) {
768
+ return null;
769
+ }
 
 
 
 
 
 
 
770
 
771
+ const tolerance = Math.max(6 / state.scale, 4);
772
+ for (const handle of handlePoints(box)) {
773
  if (
774
+ Math.abs(point.x - handle.x) <= tolerance &&
775
+ Math.abs(point.y - handle.y) <= tolerance
 
 
776
  ) {
777
+ return { box, action: "resize", handle: handle.name };
778
  }
779
  }
780
+
781
+ if (
782
+ point.x >= box.x &&
783
+ point.x <= box.x + box.width &&
784
+ point.y >= box.y &&
785
+ point.y <= box.y + box.height
786
+ ) {
787
+ return { box, action: "move", handle: null };
788
+ }
789
  return null;
790
  }
791
 
 
857
  }
858
 
859
  function drawBoxes() {
860
+ const box = state.box;
861
+ if (!state.image || !box) {
862
  return;
863
  }
864
 
865
+ const topLeft = imageToCanvas({ x: box.x, y: box.y });
866
+ const width = box.width * state.scale;
867
+ const height = box.height * state.scale;
868
+
869
+ ctx.save();
870
+ ctx.lineWidth = 3;
871
+ ctx.strokeStyle = box.color;
872
+ ctx.fillStyle = `${box.color}1f`;
873
+ ctx.fillRect(topLeft.x, topLeft.y, width, height);
874
+ ctx.strokeRect(topLeft.x, topLeft.y, width, height);
875
+
876
+ const size = 8;
877
+ ctx.fillStyle = "#ffffff";
878
+ ctx.strokeStyle = box.color;
879
+ ctx.lineWidth = 2;
880
+ for (const handle of handlePoints(box)) {
881
+ const point = imageToCanvas(handle);
882
+ ctx.beginPath();
883
+ ctx.rect(point.x - size / 2, point.y - size / 2, size, size);
884
+ ctx.fill();
885
+ ctx.stroke();
886
+ }
887
+ ctx.restore();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
888
  }
889
 
890
  function draw() {
 
908
  return Number.isFinite(value) ? Math.round(value * 1000) : 0;
909
  }
910
 
911
+ function toCorners(box) {
912
+ return {
913
+ x1: box.x,
914
+ y1: box.y,
915
+ x2: box.x + box.width,
916
+ y2: box.y + box.height
917
+ };
918
+ }
919
+
920
  function resultLines(box) {
921
  if (!box || !state.image) {
922
  return null;
923
  }
924
 
925
+ const { x1, y1, x2, y2 } = toCorners(box);
 
 
 
926
  const cx = box.x + box.width / 2;
927
  const cy = box.y + box.height / 2;
928
  const iw = imageWidth();
 
960
  controls.relativeOutput.textContent = "未选择";
961
  controls.relative1000Output.textContent = "未选择";
962
  controls.yoloOutput.textContent = "未选择";
963
+ controls.copyButtons.forEach((button) => {
964
+ button.disabled = true;
965
+ });
966
  return;
967
  }
968
 
 
971
  controls.relativeOutput.textContent = values.relative;
972
  controls.relative1000Output.textContent = values.relative1000;
973
  controls.yoloOutput.textContent = values.yolo;
974
+ controls.copyButtons.forEach((button) => {
975
+ button.disabled = false;
976
+ });
977
  }
978
 
979
  function updateControls() {
 
982
  const hasSelected = Boolean(selected);
983
  const disabled = !hasSelected;
984
 
985
+ controls.pasteInput.disabled = !hasImage;
986
+ controls.pasteMode.disabled = !hasImage;
 
 
987
 
988
  for (const input of [
989
+ controls.x1Input,
990
+ controls.y1Input,
991
+ controls.x2Input,
992
+ controls.y2Input
993
  ]) {
994
  input.disabled = disabled;
995
  }
996
 
997
  if (selected) {
998
+ const { x1, y1, x2, y2 } = toCorners(selected);
999
+ controls.x1Input.value = x1;
1000
+ controls.y1Input.value = y1;
1001
+ controls.x2Input.value = x2;
1002
+ controls.y2Input.value = y2;
1003
  } else {
1004
+ controls.x1Input.value = "";
1005
+ controls.y1Input.value = "";
1006
+ controls.x2Input.value = "";
1007
+ controls.y2Input.value = "";
1008
  }
1009
 
1010
  controls.imageMeta.textContent = hasImage
1011
  ? `${state.imageName} · ${imageWidth()} × ${imageHeight()}`
1012
  : "未上传图片";
1013
+ controls.boxMeta.textContent = hasSelected ? "已标注" : "未标注";
1014
  updateResults(selected);
1015
  }
1016
 
 
1036
  image.onload = () => {
1037
  state.image = image;
1038
  state.imageName = file.name || "uploaded-image";
1039
+ state.box = null;
1040
+ controls.pasteInput.value = "";
 
1041
  setStatus(`已载入 ${state.imageName}`);
1042
  sync();
1043
  };
 
1048
  reader.readAsDataURL(file);
1049
  }
1050
 
1051
+ function setBoxFromCorners(x1, y1, x2, y2) {
1052
  if (!state.image) {
1053
  return;
1054
  }
1055
+
1056
+ const left = clamp(Math.round(Math.min(x1, x2)), 0, Math.max(0, imageWidth() - 1));
1057
+ const top = clamp(Math.round(Math.min(y1, y2)), 0, Math.max(0, imageHeight() - 1));
1058
+ const right = clamp(Math.round(Math.max(x1, x2)), left + 1, imageWidth());
1059
+ const bottom = clamp(Math.round(Math.max(y1, y2)), top + 1, imageHeight());
1060
+ state.box = makeBox(left, top, right - left, bottom - top);
 
1061
  sync();
1062
  }
1063
 
1064
+ function clearBox() {
1065
+ state.box = null;
 
 
 
 
 
 
1066
  sync();
1067
  }
1068
 
1069
  function updateSelectedFromInputs() {
1070
+ if (!selectedBox() || !state.image) {
 
1071
  return;
1072
  }
1073
 
1074
+ const values = [
1075
+ Number.parseFloat(controls.x1Input.value),
1076
+ Number.parseFloat(controls.y1Input.value),
1077
+ Number.parseFloat(controls.x2Input.value),
1078
+ Number.parseFloat(controls.y2Input.value)
1079
+ ];
1080
+ if (!values.every(Number.isFinite)) {
1081
+ return;
1082
+ }
1083
 
1084
+ setBoxFromCorners(...values);
1085
+ }
1086
+
1087
+ function parseCoordinateList(value) {
1088
+ const numbers = value.match(/-?\d+(?:\.\d+)?/g);
1089
+ if (!numbers || numbers.length < 4) {
1090
+ return null;
1091
+ }
1092
+ return numbers.slice(0, 4).map(Number);
1093
+ }
1094
+
1095
+ function applyPastedCoordinates() {
1096
+ if (!state.image) {
1097
+ return;
1098
+ }
1099
+
1100
+ const coords = parseCoordinateList(controls.pasteInput.value);
1101
+ if (!coords) {
1102
+ return;
1103
+ }
1104
+
1105
+ let [x1, y1, x2, y2] = coords;
1106
+ if (controls.pasteMode.value === "scale1000") {
1107
+ x1 = (x1 / 1000) * imageWidth();
1108
+ y1 = (y1 / 1000) * imageHeight();
1109
+ x2 = (x2 / 1000) * imageWidth();
1110
+ y2 = (y2 / 1000) * imageHeight();
1111
+ } else if (controls.pasteMode.value === "relative") {
1112
+ x1 *= imageWidth();
1113
+ y1 *= imageHeight();
1114
+ x2 *= imageWidth();
1115
+ y2 *= imageHeight();
1116
+ }
1117
+
1118
+ setBoxFromCorners(x1, y1, x2, y2);
1119
+ setStatus("坐标已应用");
1120
  }
1121
 
1122
  function moveBox(box, startBox, deltaX, deltaY) {
 
1160
  const point = eventToImagePoint(event);
1161
  const hit = hitTest(point);
1162
  if (hit) {
 
1163
  state.drag = {
1164
  action: hit.action,
1165
  handle: hit.handle,
1166
  start: point,
 
1167
  startBox: { ...hit.box }
1168
  };
1169
  canvas.setPointerCapture(event.pointerId);
 
1172
  }
1173
 
1174
  const box = makeBox(point.x, point.y, 1, 1);
1175
+ state.box = box;
 
1176
  state.drag = {
1177
  action: "create",
1178
  handle: null,
1179
  start: point,
 
1180
  startBox: { ...box }
1181
  };
1182
  canvas.setPointerCapture(event.pointerId);
 
1195
  return;
1196
  }
1197
 
1198
+ const box = state.box;
1199
  if (!box) {
1200
  return;
1201
  }
 
1226
 
1227
  const createdBox = state.drag.action === "create" ? selectedBox() : null;
1228
  if (createdBox && (createdBox.width < 3 || createdBox.height < 3)) {
1229
+ state.box = null;
 
1230
  }
1231
  state.drag = null;
1232
  if (canvas.hasPointerCapture(event.pointerId)) {
 
1235
  sync();
1236
  }
1237
 
1238
+ function copyText(text) {
1239
+ if (!text || text === "未选择") {
1240
+ return;
1241
+ }
1242
+
1243
+ const fallback = () => {
1244
+ const textarea = document.createElement("textarea");
1245
+ textarea.value = text;
1246
+ textarea.style.position = "fixed";
1247
+ textarea.style.opacity = "0";
1248
+ document.body.append(textarea);
1249
+ textarea.focus();
1250
+ textarea.select();
1251
+ document.execCommand("copy");
1252
+ textarea.remove();
1253
+ };
1254
+
1255
+ if (navigator.clipboard && window.isSecureContext) {
1256
+ navigator.clipboard
1257
+ .writeText(text)
1258
+ .then(() => setStatus("已复制"))
1259
+ .catch(() => {
1260
+ fallback();
1261
+ setStatus("已复制");
1262
+ });
1263
+ } else {
1264
+ fallback();
1265
+ setStatus("已复制");
1266
+ }
1267
+ }
1268
+
1269
  controls.fileInput.addEventListener("change", (event) => {
1270
  loadImageFile(event.target.files[0]);
1271
  event.target.value = "";
1272
  });
1273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1274
  for (const input of [
1275
+ controls.x1Input,
1276
+ controls.y1Input,
1277
+ controls.x2Input,
1278
+ controls.y2Input
1279
  ]) {
1280
  input.addEventListener("input", updateSelectedFromInputs);
1281
  }
1282
 
1283
+ controls.pasteInput.addEventListener("input", applyPastedCoordinates);
1284
+ controls.pasteMode.addEventListener("change", applyPastedCoordinates);
1285
+
1286
+ controls.copyButtons.forEach((button) => {
1287
+ button.addEventListener("click", () => {
1288
+ copyText($(button.dataset.copy).textContent);
1289
+ });
1290
+ });
1291
+
1292
  canvas.addEventListener("pointerdown", onPointerDown);
1293
  canvas.addEventListener("pointermove", onPointerMove);
1294
  canvas.addEventListener("pointerup", onPointerUp);
 
1316
  return;
1317
  }
1318
  if (event.key === "Delete" || event.key === "Backspace") {
1319
+ clearBox();
1320
  }
1321
  });
1322