Spaces:
Running
Running
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Bounding Box Demo</title> | |
| <style> | |
| :root { | |
| color-scheme: light; | |
| --bg: #f3f6fb; | |
| --surface: #ffffff; | |
| --surface-strong: #f8fafc; | |
| --ink: #172033; | |
| --muted: #667085; | |
| --line: #d8e0ea; | |
| --accent: #2563eb; | |
| --accent-strong: #1d4ed8; | |
| --accent-soft: #eaf1ff; | |
| --teal: #0f9f8f; | |
| --danger: #dc2626; | |
| --danger-soft: #fee2e2; | |
| --shadow: 0 20px 60px rgba(15, 23, 42, 0.10); | |
| --radius: 8px; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| width: 100%; | |
| min-height: 100%; | |
| margin: 0; | |
| overflow-x: hidden; | |
| background: var(--bg); | |
| color: var(--ink); | |
| font-family: | |
| Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, | |
| "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", | |
| sans-serif; | |
| letter-spacing: 0; | |
| } | |
| button, | |
| input, | |
| textarea { | |
| font: inherit; | |
| } | |
| button { | |
| border: 0; | |
| } | |
| .app { | |
| min-height: 100vh; | |
| padding: 18px; | |
| } | |
| .topbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 16px; | |
| width: min(1480px, 100%); | |
| margin: 0 auto 14px; | |
| } | |
| .brand { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 3px; | |
| min-width: 0; | |
| } | |
| .brand-kicker { | |
| color: var(--teal); | |
| font-size: 12px; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| } | |
| h1 { | |
| margin: 0; | |
| color: var(--ink); | |
| font-size: 28px; | |
| line-height: 1.1; | |
| font-weight: 850; | |
| letter-spacing: 0; | |
| } | |
| .top-actions { | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .button, | |
| .upload-button { | |
| min-height: 38px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| padding: 0 14px; | |
| border-radius: var(--radius); | |
| background: var(--surface); | |
| color: var(--ink); | |
| border: 1px solid var(--line); | |
| cursor: pointer; | |
| font-weight: 750; | |
| white-space: nowrap; | |
| transition: | |
| transform 0.16s ease, | |
| border-color 0.16s ease, | |
| background 0.16s ease, | |
| color 0.16s ease; | |
| } | |
| .button:hover, | |
| .upload-button:hover { | |
| transform: translateY(-1px); | |
| border-color: #b8c7da; | |
| background: var(--surface-strong); | |
| } | |
| .button:disabled { | |
| opacity: 0.45; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| .button.primary, | |
| .upload-button.primary { | |
| color: #fff; | |
| border-color: var(--accent); | |
| background: var(--accent); | |
| } | |
| .button.primary:hover, | |
| .upload-button.primary:hover { | |
| background: var(--accent-strong); | |
| } | |
| .button.danger { | |
| color: var(--danger); | |
| background: var(--danger-soft); | |
| border-color: #fecaca; | |
| } | |
| .upload-button input { | |
| display: none; | |
| } | |
| .workspace { | |
| width: min(1480px, 100%); | |
| margin: 0 auto; | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) 360px; | |
| gap: 14px; | |
| align-items: stretch; | |
| } | |
| .stage-panel, | |
| .panel { | |
| border: 1px solid var(--line); | |
| background: var(--surface); | |
| box-shadow: var(--shadow); | |
| } | |
| .stage-panel { | |
| min-width: 0; | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .stage-toolbar { | |
| min-height: 58px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| gap: 12px; | |
| padding: 10px 12px; | |
| border-bottom: 1px solid var(--line); | |
| background: var(--surface-strong); | |
| } | |
| .stage-meta { | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| gap: 8px; | |
| min-width: 0; | |
| color: var(--muted); | |
| font-size: 13px; | |
| font-weight: 700; | |
| } | |
| .pill { | |
| max-width: 260px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| padding: 5px 9px; | |
| border-radius: 999px; | |
| border: 1px solid var(--line); | |
| background: var(--surface); | |
| } | |
| .canvas-wrap { | |
| position: relative; | |
| min-height: 560px; | |
| height: calc(100vh - 170px); | |
| background: | |
| linear-gradient(45deg, #eef2f7 25%, transparent 25%) 0 0 / 22px 22px, | |
| linear-gradient(45deg, transparent 75%, #eef2f7 75%) 0 0 / 22px 22px, | |
| linear-gradient(45deg, transparent 75%, #eef2f7 75%) 11px 11px / 22px 22px, | |
| linear-gradient(45deg, #eef2f7 25%, #f8fafc 25%) 11px 11px / 22px 22px; | |
| overflow: hidden; | |
| } | |
| .canvas-wrap.is-dragover { | |
| outline: 3px solid rgba(37, 99, 235, 0.34); | |
| outline-offset: -6px; | |
| } | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| touch-action: none; | |
| } | |
| .empty-state { | |
| position: absolute; | |
| inset: 50% auto auto 50%; | |
| transform: translate(-50%, -50%); | |
| width: min(390px, calc(100% - 48px)); | |
| padding: 22px; | |
| border-radius: var(--radius); | |
| background: rgba(255, 255, 255, 0.92); | |
| border: 1px dashed #b8c7da; | |
| text-align: center; | |
| color: var(--muted); | |
| pointer-events: none; | |
| } | |
| .empty-title { | |
| margin: 0 0 5px; | |
| color: var(--ink); | |
| font-size: 18px; | |
| font-weight: 850; | |
| } | |
| .empty-subtitle { | |
| margin: 0; | |
| font-size: 13px; | |
| font-weight: 650; | |
| } | |
| .sidebar { | |
| display: flex; | |
| min-width: 0; | |
| flex-direction: column; | |
| gap: 14px; | |
| } | |
| .panel { | |
| border-radius: var(--radius); | |
| padding: 14px; | |
| } | |
| .panel-title { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| margin: 0 0 12px; | |
| color: var(--ink); | |
| font-size: 15px; | |
| font-weight: 850; | |
| } | |
| .field { | |
| display: flex; | |
| min-width: 0; | |
| flex-direction: column; | |
| gap: 6px; | |
| color: var(--muted); | |
| font-size: 12px; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| } | |
| .field input, | |
| .field select, | |
| .field textarea { | |
| width: 100%; | |
| min-width: 0; | |
| border: 1px solid var(--line); | |
| border-radius: var(--radius); | |
| background: var(--surface-strong); | |
| color: var(--ink); | |
| outline: none; | |
| transition: | |
| border-color 0.16s ease, | |
| box-shadow 0.16s ease, | |
| background 0.16s ease; | |
| } | |
| .field input, | |
| .field select { | |
| height: 38px; | |
| padding: 0 10px; | |
| font-weight: 750; | |
| } | |
| .field select { | |
| appearance: none; | |
| background-image: | |
| linear-gradient(45deg, transparent 50%, var(--muted) 50%), | |
| linear-gradient(135deg, var(--muted) 50%, transparent 50%); | |
| background-position: | |
| calc(100% - 16px) 16px, | |
| calc(100% - 11px) 16px; | |
| background-size: 5px 5px; | |
| background-repeat: no-repeat; | |
| padding-right: 30px; | |
| } | |
| .field textarea { | |
| min-height: 176px; | |
| padding: 10px; | |
| resize: vertical; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; | |
| font-size: 12px; | |
| line-height: 1.45; | |
| } | |
| .field input:focus, | |
| .field select:focus, | |
| .field textarea:focus { | |
| border-color: rgba(37, 99, 235, 0.78); | |
| box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); | |
| background: #fff; | |
| } | |
| .field input:disabled { | |
| color: #98a2b3; | |
| background: #f2f4f7; | |
| } | |
| .coord-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 10px; | |
| margin-bottom: 10px; | |
| } | |
| .field.full { | |
| margin-bottom: 12px; | |
| } | |
| .paste-grid { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) 112px; | |
| gap: 10px; | |
| margin-bottom: 2px; | |
| } | |
| .result-stack { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .result-row { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) 58px; | |
| gap: 8px; | |
| align-items: center; | |
| padding: 10px; | |
| border: 1px solid var(--line); | |
| border-radius: var(--radius); | |
| background: var(--surface-strong); | |
| } | |
| .result-text { | |
| min-width: 0; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 5px; | |
| } | |
| .result-label { | |
| color: var(--muted); | |
| font-size: 12px; | |
| font-weight: 850; | |
| text-transform: uppercase; | |
| } | |
| .result-code { | |
| min-height: 24px; | |
| overflow-wrap: anywhere; | |
| color: var(--ink); | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; | |
| font-size: 12px; | |
| line-height: 1.5; | |
| white-space: pre-wrap; | |
| } | |
| .copy-button { | |
| width: 58px; | |
| height: 34px; | |
| border-radius: 6px; | |
| border: 1px solid var(--line); | |
| background: #fff; | |
| color: var(--accent); | |
| cursor: pointer; | |
| font-size: 12px; | |
| font-weight: 850; | |
| } | |
| .copy-button:hover:not(:disabled) { | |
| border-color: rgba(37, 99, 235, 0.45); | |
| background: var(--accent-soft); | |
| } | |
| .copy-button:disabled { | |
| color: #98a2b3; | |
| cursor: not-allowed; | |
| background: #f2f4f7; | |
| } | |
| .status { | |
| min-height: 22px; | |
| margin: 10px auto 0; | |
| width: min(1480px, 100%); | |
| color: var(--muted); | |
| font-size: 12px; | |
| font-weight: 700; | |
| } | |
| @media (max-width: 980px) { | |
| .app { | |
| padding: 12px; | |
| } | |
| .topbar, | |
| .workspace { | |
| width: 100%; | |
| } | |
| .topbar { | |
| align-items: flex-start; | |
| flex-direction: column; | |
| } | |
| .top-actions { | |
| width: 100%; | |
| justify-content: flex-start; | |
| } | |
| .workspace { | |
| grid-template-columns: 1fr; | |
| } | |
| .canvas-wrap { | |
| min-height: 420px; | |
| height: 58vh; | |
| } | |
| .stage-meta { | |
| width: 100%; | |
| justify-content: flex-start; | |
| flex-wrap: wrap; | |
| } | |
| .sidebar { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| @media (max-width: 520px) { | |
| h1 { | |
| font-size: 23px; | |
| } | |
| .button, | |
| .upload-button { | |
| width: 100%; | |
| } | |
| .top-actions, | |
| .paste-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .top-actions { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| } | |
| .coord-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <header class="topbar"> | |
| <div class="brand"> | |
| <span class="brand-kicker">Hugging Face Space</span> | |
| <h1>Bounding Box Annotator</h1> | |
| </div> | |
| <div class="top-actions"> | |
| <label class="upload-button primary" for="fileInput"> | |
| 上传图片 | |
| <input id="fileInput" type="file" accept="image/*"> | |
| </label> | |
| </div> | |
| </header> | |
| <main class="workspace"> | |
| <section class="stage-panel" aria-label="image annotation canvas"> | |
| <div class="stage-toolbar"> | |
| <div class="stage-meta"> | |
| <span class="pill" id="imageMeta">未上传图片</span> | |
| <span class="pill" id="boxMeta">未标注</span> | |
| </div> | |
| </div> | |
| <div class="canvas-wrap" id="canvasWrap"> | |
| <canvas id="canvas"></canvas> | |
| <div class="empty-state" id="emptyState"> | |
| <p class="empty-title">上传图片开始标注</p> | |
| <p class="empty-subtitle">坐标以原始图片像素为准</p> | |
| </div> | |
| </div> | |
| </section> | |
| <aside class="sidebar"> | |
| <section class="panel"> | |
| <h2 class="panel-title">坐标编辑</h2> | |
| <div class="coord-grid"> | |
| <label class="field">X1 | |
| <input id="x1Input" type="number" min="0" step="1" disabled> | |
| </label> | |
| <label class="field">Y1 | |
| <input id="y1Input" type="number" min="0" step="1" disabled> | |
| </label> | |
| <label class="field">X2 | |
| <input id="x2Input" type="number" min="1" step="1" disabled> | |
| </label> | |
| <label class="field">Y2 | |
| <input id="y2Input" type="number" min="1" step="1" disabled> | |
| </label> | |
| </div> | |
| <div class="paste-grid"> | |
| <label class="field">粘贴坐标 | |
| <input id="pasteInput" type="text" placeholder="[x1,y1,x2,y2]" disabled> | |
| </label> | |
| <label class="field">类型 | |
| <select id="pasteMode" disabled> | |
| <option value="absolute">绝对</option> | |
| <option value="scale1000">0-1000</option> | |
| <option value="relative">0-1</option> | |
| </select> | |
| </label> | |
| </div> | |
| </section> | |
| <section class="panel"> | |
| <h2 class="panel-title">坐标结果</h2> | |
| <div class="result-stack"> | |
| <div class="result-row"> | |
| <div class="result-text"> | |
| <div class="result-label">绝对坐标 x1 y1 x2 y2</div> | |
| <div class="result-code" id="absoluteOutput">未选择</div> | |
| </div> | |
| <button class="copy-button" type="button" data-copy="absoluteOutput" disabled>复制</button> | |
| </div> | |
| <div class="result-row"> | |
| <div class="result-text"> | |
| <div class="result-label">绝对坐标 x y w h</div> | |
| <div class="result-code" id="sizeOutput">未选择</div> | |
| </div> | |
| <button class="copy-button" type="button" data-copy="sizeOutput" disabled>复制</button> | |
| </div> | |
| <div class="result-row"> | |
| <div class="result-text"> | |
| <div class="result-label">相对坐标 x1 y1 x2 y2</div> | |
| <div class="result-code" id="relativeOutput">未选择</div> | |
| </div> | |
| <button class="copy-button" type="button" data-copy="relativeOutput" disabled>复制</button> | |
| </div> | |
| <div class="result-row"> | |
| <div class="result-text"> | |
| <div class="result-label">相对坐标 0-1000 x1 y1 x2 y2</div> | |
| <div class="result-code" id="relative1000Output">未选择</div> | |
| </div> | |
| <button class="copy-button" type="button" data-copy="relative1000Output" disabled>复制</button> | |
| </div> | |
| <div class="result-row"> | |
| <div class="result-text"> | |
| <div class="result-label">YOLO 格式 cx cy w h</div> | |
| <div class="result-code" id="yoloOutput">未选择</div> | |
| </div> | |
| <button class="copy-button" type="button" data-copy="yoloOutput" disabled>复制</button> | |
| </div> | |
| </div> | |
| </section> | |
| </aside> | |
| </main> | |
| <div class="status" id="statusLine"></div> | |
| </div> | |
| <script> | |
| (() => { | |
| "use strict"; | |
| const $ = (id) => document.getElementById(id); | |
| const canvas = $("canvas"); | |
| const ctx = canvas.getContext("2d"); | |
| const canvasWrap = $("canvasWrap"); | |
| const emptyState = $("emptyState"); | |
| const controls = { | |
| fileInput: $("fileInput"), | |
| x1Input: $("x1Input"), | |
| y1Input: $("y1Input"), | |
| x2Input: $("x2Input"), | |
| y2Input: $("y2Input"), | |
| pasteInput: $("pasteInput"), | |
| pasteMode: $("pasteMode"), | |
| imageMeta: $("imageMeta"), | |
| boxMeta: $("boxMeta"), | |
| absoluteOutput: $("absoluteOutput"), | |
| sizeOutput: $("sizeOutput"), | |
| relativeOutput: $("relativeOutput"), | |
| relative1000Output: $("relative1000Output"), | |
| yoloOutput: $("yoloOutput"), | |
| copyButtons: Array.from(document.querySelectorAll("[data-copy]")), | |
| statusLine: $("statusLine") | |
| }; | |
| const palette = [ | |
| "#2563eb", | |
| "#0f9f8f", | |
| "#f59e0b", | |
| "#dc2626", | |
| "#7c3aed", | |
| "#0891b2", | |
| "#16a34a", | |
| "#e11d48" | |
| ]; | |
| const state = { | |
| image: null, | |
| imageName: "", | |
| box: null, | |
| drag: null, | |
| scale: 1, | |
| offsetX: 0, | |
| offsetY: 0, | |
| canvasWidth: 1, | |
| canvasHeight: 1, | |
| dpr: 1 | |
| }; | |
| function clamp(value, min, max) { | |
| if (!Number.isFinite(value)) { | |
| return min; | |
| } | |
| if (max < min) { | |
| return min; | |
| } | |
| return Math.min(max, Math.max(min, value)); | |
| } | |
| function imageWidth() { | |
| return state.image ? state.image.naturalWidth : 0; | |
| } | |
| function imageHeight() { | |
| return state.image ? state.image.naturalHeight : 0; | |
| } | |
| function selectedBox() { | |
| return state.box; | |
| } | |
| function makeBox(x, y, width, height) { | |
| return { | |
| x, | |
| y, | |
| width, | |
| height, | |
| color: palette[0] | |
| }; | |
| } | |
| function constrainBox(box) { | |
| const maxW = imageWidth(); | |
| const maxH = imageHeight(); | |
| if (!maxW || !maxH) { | |
| return; | |
| } | |
| box.x = clamp(Math.round(box.x), 0, Math.max(0, maxW - 1)); | |
| box.y = clamp(Math.round(box.y), 0, Math.max(0, maxH - 1)); | |
| box.width = clamp(Math.round(box.width), 1, maxW - box.x); | |
| box.height = clamp(Math.round(box.height), 1, maxH - box.y); | |
| } | |
| function constrainAllBoxes() { | |
| if (state.box) { | |
| constrainBox(state.box); | |
| } | |
| } | |
| function imageToCanvas(point) { | |
| return { | |
| x: state.offsetX + point.x * state.scale, | |
| y: state.offsetY + point.y * state.scale | |
| }; | |
| } | |
| function eventToImagePoint(event) { | |
| const rect = canvas.getBoundingClientRect(); | |
| const x = (event.clientX - rect.left - state.offsetX) / state.scale; | |
| const y = (event.clientY - rect.top - state.offsetY) / state.scale; | |
| return { | |
| x: clamp(x, 0, imageWidth()), | |
| y: clamp(y, 0, imageHeight()) | |
| }; | |
| } | |
| function handlePoints(box) { | |
| const x1 = box.x; | |
| const y1 = box.y; | |
| const x2 = box.x + box.width; | |
| const y2 = box.y + box.height; | |
| const cx = box.x + box.width / 2; | |
| const cy = box.y + box.height / 2; | |
| return [ | |
| { name: "nw", x: x1, y: y1 }, | |
| { name: "n", x: cx, y: y1 }, | |
| { name: "ne", x: x2, y: y1 }, | |
| { name: "e", x: x2, y: cy }, | |
| { name: "se", x: x2, y: y2 }, | |
| { name: "s", x: cx, y: y2 }, | |
| { name: "sw", x: x1, y: y2 }, | |
| { name: "w", x: x1, y: cy } | |
| ]; | |
| } | |
| function hitTest(point) { | |
| const box = state.box; | |
| if (!box) { | |
| return null; | |
| } | |
| const tolerance = Math.max(6 / state.scale, 4); | |
| for (const handle of handlePoints(box)) { | |
| if ( | |
| Math.abs(point.x - handle.x) <= tolerance && | |
| Math.abs(point.y - handle.y) <= tolerance | |
| ) { | |
| return { box, action: "resize", handle: handle.name }; | |
| } | |
| } | |
| if ( | |
| point.x >= box.x && | |
| point.x <= box.x + box.width && | |
| point.y >= box.y && | |
| point.y <= box.y + box.height | |
| ) { | |
| return { box, action: "move", handle: null }; | |
| } | |
| return null; | |
| } | |
| function cursorForHit(hit) { | |
| if (!hit) { | |
| return state.image ? "crosshair" : "default"; | |
| } | |
| if (hit.action === "move") { | |
| return "move"; | |
| } | |
| const map = { | |
| n: "ns-resize", | |
| s: "ns-resize", | |
| e: "ew-resize", | |
| w: "ew-resize", | |
| nw: "nwse-resize", | |
| se: "nwse-resize", | |
| ne: "nesw-resize", | |
| sw: "nesw-resize" | |
| }; | |
| return map[hit.handle] || "default"; | |
| } | |
| function resizeCanvas() { | |
| const rect = canvasWrap.getBoundingClientRect(); | |
| const width = Math.max(1, Math.round(rect.width)); | |
| const height = Math.max(1, Math.round(rect.height)); | |
| const dpr = window.devicePixelRatio || 1; | |
| state.canvasWidth = width; | |
| state.canvasHeight = height; | |
| state.dpr = dpr; | |
| canvas.width = Math.round(width * dpr); | |
| canvas.height = Math.round(height * dpr); | |
| canvas.style.width = `${width}px`; | |
| canvas.style.height = `${height}px`; | |
| draw(); | |
| } | |
| function drawImageSurface() { | |
| if (!state.image) { | |
| emptyState.style.display = "block"; | |
| return; | |
| } | |
| emptyState.style.display = "none"; | |
| const padding = state.canvasWidth < 700 ? 16 : 28; | |
| const availableWidth = Math.max(1, state.canvasWidth - padding * 2); | |
| const availableHeight = Math.max(1, state.canvasHeight - padding * 2); | |
| state.scale = Math.min( | |
| availableWidth / imageWidth(), | |
| availableHeight / imageHeight() | |
| ); | |
| state.scale = Math.max(0.01, state.scale); | |
| const drawWidth = imageWidth() * state.scale; | |
| const drawHeight = imageHeight() * state.scale; | |
| state.offsetX = (state.canvasWidth - drawWidth) / 2; | |
| state.offsetY = (state.canvasHeight - drawHeight) / 2; | |
| ctx.save(); | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.shadowColor = "rgba(15, 23, 42, 0.22)"; | |
| ctx.shadowBlur = 22; | |
| ctx.shadowOffsetY = 12; | |
| ctx.fillRect(state.offsetX, state.offsetY, drawWidth, drawHeight); | |
| ctx.restore(); | |
| ctx.drawImage(state.image, state.offsetX, state.offsetY, drawWidth, drawHeight); | |
| } | |
| function drawBoxes() { | |
| const box = state.box; | |
| if (!state.image || !box) { | |
| return; | |
| } | |
| const topLeft = imageToCanvas({ x: box.x, y: box.y }); | |
| const width = box.width * state.scale; | |
| const height = box.height * state.scale; | |
| ctx.save(); | |
| ctx.lineWidth = 3; | |
| ctx.strokeStyle = box.color; | |
| ctx.fillStyle = `${box.color}1f`; | |
| ctx.fillRect(topLeft.x, topLeft.y, width, height); | |
| ctx.strokeRect(topLeft.x, topLeft.y, width, height); | |
| const size = 8; | |
| ctx.fillStyle = "#ffffff"; | |
| ctx.strokeStyle = box.color; | |
| ctx.lineWidth = 2; | |
| for (const handle of handlePoints(box)) { | |
| const point = imageToCanvas(handle); | |
| ctx.beginPath(); | |
| ctx.rect(point.x - size / 2, point.y - size / 2, size, size); | |
| ctx.fill(); | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| } | |
| function draw() { | |
| ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); | |
| ctx.clearRect(0, 0, state.canvasWidth, state.canvasHeight); | |
| ctx.fillStyle = "#f8fafc"; | |
| ctx.fillRect(0, 0, state.canvasWidth, state.canvasHeight); | |
| drawImageSurface(); | |
| drawBoxes(); | |
| } | |
| function formatRatio(value) { | |
| return Number.isFinite(value) ? value.toFixed(6) : "0.000000"; | |
| } | |
| function formatList(values) { | |
| return `[${values.join(",")}]`; | |
| } | |
| function formatScale1000(value) { | |
| return Number.isFinite(value) ? Math.round(value * 1000) : 0; | |
| } | |
| function toCorners(box) { | |
| return { | |
| x1: box.x, | |
| y1: box.y, | |
| x2: box.x + box.width, | |
| y2: box.y + box.height | |
| }; | |
| } | |
| function resultLines(box) { | |
| if (!box || !state.image) { | |
| return null; | |
| } | |
| const { x1, y1, x2, y2 } = toCorners(box); | |
| const cx = box.x + box.width / 2; | |
| const cy = box.y + box.height / 2; | |
| const iw = imageWidth(); | |
| const ih = imageHeight(); | |
| return { | |
| absolute: formatList([x1, y1, x2, y2]), | |
| size: formatList([box.x, box.y, box.width, box.height]), | |
| relative: formatList([ | |
| formatRatio(x1 / iw), | |
| formatRatio(y1 / ih), | |
| formatRatio(x2 / iw), | |
| formatRatio(y2 / ih) | |
| ]), | |
| relative1000: formatList([ | |
| formatScale1000(x1 / iw), | |
| formatScale1000(y1 / ih), | |
| formatScale1000(x2 / iw), | |
| formatScale1000(y2 / ih) | |
| ]), | |
| yolo: formatList([ | |
| formatRatio(cx / iw), | |
| formatRatio(cy / ih), | |
| formatRatio(box.width / iw), | |
| formatRatio(box.height / ih) | |
| ]) | |
| }; | |
| } | |
| function updateResults(selected) { | |
| const values = resultLines(selected); | |
| if (!values) { | |
| controls.absoluteOutput.textContent = "未选择"; | |
| controls.sizeOutput.textContent = "未选择"; | |
| controls.relativeOutput.textContent = "未选择"; | |
| controls.relative1000Output.textContent = "未选择"; | |
| controls.yoloOutput.textContent = "未选择"; | |
| controls.copyButtons.forEach((button) => { | |
| button.disabled = true; | |
| }); | |
| return; | |
| } | |
| controls.absoluteOutput.textContent = values.absolute; | |
| controls.sizeOutput.textContent = values.size; | |
| controls.relativeOutput.textContent = values.relative; | |
| controls.relative1000Output.textContent = values.relative1000; | |
| controls.yoloOutput.textContent = values.yolo; | |
| controls.copyButtons.forEach((button) => { | |
| button.disabled = false; | |
| }); | |
| } | |
| function updateControls() { | |
| const hasImage = Boolean(state.image); | |
| const selected = selectedBox(); | |
| const hasSelected = Boolean(selected); | |
| const disabled = !hasSelected; | |
| controls.pasteInput.disabled = !hasImage; | |
| controls.pasteMode.disabled = !hasImage; | |
| for (const input of [ | |
| controls.x1Input, | |
| controls.y1Input, | |
| controls.x2Input, | |
| controls.y2Input | |
| ]) { | |
| input.disabled = disabled; | |
| } | |
| if (selected) { | |
| const { x1, y1, x2, y2 } = toCorners(selected); | |
| controls.x1Input.value = x1; | |
| controls.y1Input.value = y1; | |
| controls.x2Input.value = x2; | |
| controls.y2Input.value = y2; | |
| } else { | |
| controls.x1Input.value = ""; | |
| controls.y1Input.value = ""; | |
| controls.x2Input.value = ""; | |
| controls.y2Input.value = ""; | |
| } | |
| controls.imageMeta.textContent = hasImage | |
| ? `${state.imageName} · ${imageWidth()} × ${imageHeight()}` | |
| : "未上传图片"; | |
| controls.boxMeta.textContent = hasSelected ? "已标注" : "未标注"; | |
| updateResults(selected); | |
| } | |
| function setStatus(message) { | |
| controls.statusLine.textContent = message; | |
| } | |
| function sync() { | |
| constrainAllBoxes(); | |
| draw(); | |
| updateControls(); | |
| } | |
| function loadImageFile(file) { | |
| if (!file || !file.type.startsWith("image/")) { | |
| setStatus("请选择图片文件"); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = () => { | |
| const image = new Image(); | |
| image.onload = () => { | |
| state.image = image; | |
| state.imageName = file.name || "uploaded-image"; | |
| state.box = null; | |
| controls.pasteInput.value = ""; | |
| setStatus(`已载入 ${state.imageName}`); | |
| sync(); | |
| }; | |
| image.onerror = () => setStatus("图片读取失败"); | |
| image.src = reader.result; | |
| }; | |
| reader.onerror = () => setStatus("图片读取失败"); | |
| reader.readAsDataURL(file); | |
| } | |
| function setBoxFromCorners(x1, y1, x2, y2) { | |
| if (!state.image) { | |
| return; | |
| } | |
| const left = clamp(Math.round(Math.min(x1, x2)), 0, Math.max(0, imageWidth() - 1)); | |
| const top = clamp(Math.round(Math.min(y1, y2)), 0, Math.max(0, imageHeight() - 1)); | |
| const right = clamp(Math.round(Math.max(x1, x2)), left + 1, imageWidth()); | |
| const bottom = clamp(Math.round(Math.max(y1, y2)), top + 1, imageHeight()); | |
| state.box = makeBox(left, top, right - left, bottom - top); | |
| sync(); | |
| } | |
| function clearBox() { | |
| state.box = null; | |
| sync(); | |
| } | |
| function updateSelectedFromInputs() { | |
| if (!selectedBox() || !state.image) { | |
| return; | |
| } | |
| const values = [ | |
| Number.parseFloat(controls.x1Input.value), | |
| Number.parseFloat(controls.y1Input.value), | |
| Number.parseFloat(controls.x2Input.value), | |
| Number.parseFloat(controls.y2Input.value) | |
| ]; | |
| if (!values.every(Number.isFinite)) { | |
| return; | |
| } | |
| setBoxFromCorners(...values); | |
| } | |
| function parseCoordinateList(value) { | |
| const numbers = value.match(/-?\d+(?:\.\d+)?/g); | |
| if (!numbers || numbers.length < 4) { | |
| return null; | |
| } | |
| return numbers.slice(0, 4).map(Number); | |
| } | |
| function applyPastedCoordinates() { | |
| if (!state.image) { | |
| return; | |
| } | |
| const coords = parseCoordinateList(controls.pasteInput.value); | |
| if (!coords) { | |
| return; | |
| } | |
| let [x1, y1, x2, y2] = coords; | |
| if (controls.pasteMode.value === "scale1000") { | |
| x1 = (x1 / 1000) * imageWidth(); | |
| y1 = (y1 / 1000) * imageHeight(); | |
| x2 = (x2 / 1000) * imageWidth(); | |
| y2 = (y2 / 1000) * imageHeight(); | |
| } else if (controls.pasteMode.value === "relative") { | |
| x1 *= imageWidth(); | |
| y1 *= imageHeight(); | |
| x2 *= imageWidth(); | |
| y2 *= imageHeight(); | |
| } | |
| setBoxFromCorners(x1, y1, x2, y2); | |
| setStatus("坐标已应用"); | |
| } | |
| function moveBox(box, startBox, deltaX, deltaY) { | |
| box.x = clamp(startBox.x + deltaX, 0, imageWidth() - startBox.width); | |
| box.y = clamp(startBox.y + deltaY, 0, imageHeight() - startBox.height); | |
| box.width = startBox.width; | |
| box.height = startBox.height; | |
| } | |
| function resizeBox(box, startBox, deltaX, deltaY, handle) { | |
| const minSize = 2; | |
| let x1 = startBox.x; | |
| let y1 = startBox.y; | |
| let x2 = startBox.x + startBox.width; | |
| let y2 = startBox.y + startBox.height; | |
| if (handle.includes("w")) { | |
| x1 = clamp(startBox.x + deltaX, 0, x2 - minSize); | |
| } | |
| if (handle.includes("e")) { | |
| x2 = clamp(startBox.x + startBox.width + deltaX, x1 + minSize, imageWidth()); | |
| } | |
| if (handle.includes("n")) { | |
| y1 = clamp(startBox.y + deltaY, 0, y2 - minSize); | |
| } | |
| if (handle.includes("s")) { | |
| y2 = clamp(startBox.y + startBox.height + deltaY, y1 + minSize, imageHeight()); | |
| } | |
| box.x = x1; | |
| box.y = y1; | |
| box.width = x2 - x1; | |
| box.height = y2 - y1; | |
| } | |
| function onPointerDown(event) { | |
| if (!state.image) { | |
| return; | |
| } | |
| const point = eventToImagePoint(event); | |
| const hit = hitTest(point); | |
| if (hit) { | |
| state.drag = { | |
| action: hit.action, | |
| handle: hit.handle, | |
| start: point, | |
| startBox: { ...hit.box } | |
| }; | |
| canvas.setPointerCapture(event.pointerId); | |
| sync(); | |
| return; | |
| } | |
| const box = makeBox(point.x, point.y, 1, 1); | |
| state.box = box; | |
| state.drag = { | |
| action: "create", | |
| handle: null, | |
| start: point, | |
| startBox: { ...box } | |
| }; | |
| canvas.setPointerCapture(event.pointerId); | |
| sync(); | |
| } | |
| function onPointerMove(event) { | |
| if (!state.image) { | |
| canvas.style.cursor = "default"; | |
| return; | |
| } | |
| const point = eventToImagePoint(event); | |
| if (!state.drag) { | |
| canvas.style.cursor = cursorForHit(hitTest(point)); | |
| return; | |
| } | |
| const box = state.box; | |
| if (!box) { | |
| return; | |
| } | |
| const deltaX = point.x - state.drag.start.x; | |
| const deltaY = point.y - state.drag.start.y; | |
| if (state.drag.action === "create") { | |
| const x1 = clamp(Math.min(state.drag.start.x, point.x), 0, imageWidth()); | |
| const y1 = clamp(Math.min(state.drag.start.y, point.y), 0, imageHeight()); | |
| const x2 = clamp(Math.max(state.drag.start.x, point.x), 0, imageWidth()); | |
| const y2 = clamp(Math.max(state.drag.start.y, point.y), 0, imageHeight()); | |
| box.x = x1; | |
| box.y = y1; | |
| box.width = Math.max(1, x2 - x1); | |
| box.height = Math.max(1, y2 - y1); | |
| } else if (state.drag.action === "move") { | |
| moveBox(box, state.drag.startBox, deltaX, deltaY); | |
| } else if (state.drag.action === "resize") { | |
| resizeBox(box, state.drag.startBox, deltaX, deltaY, state.drag.handle); | |
| } | |
| sync(); | |
| } | |
| function onPointerUp(event) { | |
| if (!state.drag) { | |
| return; | |
| } | |
| const createdBox = state.drag.action === "create" ? selectedBox() : null; | |
| if (createdBox && (createdBox.width < 3 || createdBox.height < 3)) { | |
| state.box = null; | |
| } | |
| state.drag = null; | |
| if (canvas.hasPointerCapture(event.pointerId)) { | |
| canvas.releasePointerCapture(event.pointerId); | |
| } | |
| sync(); | |
| } | |
| function copyText(text) { | |
| if (!text || text === "未选择") { | |
| return; | |
| } | |
| const fallback = () => { | |
| const textarea = document.createElement("textarea"); | |
| textarea.value = text; | |
| textarea.style.position = "fixed"; | |
| textarea.style.opacity = "0"; | |
| document.body.append(textarea); | |
| textarea.focus(); | |
| textarea.select(); | |
| document.execCommand("copy"); | |
| textarea.remove(); | |
| }; | |
| if (navigator.clipboard && window.isSecureContext) { | |
| navigator.clipboard | |
| .writeText(text) | |
| .then(() => setStatus("已复制")) | |
| .catch(() => { | |
| fallback(); | |
| setStatus("已复制"); | |
| }); | |
| } else { | |
| fallback(); | |
| setStatus("已复制"); | |
| } | |
| } | |
| controls.fileInput.addEventListener("change", (event) => { | |
| loadImageFile(event.target.files[0]); | |
| event.target.value = ""; | |
| }); | |
| for (const input of [ | |
| controls.x1Input, | |
| controls.y1Input, | |
| controls.x2Input, | |
| controls.y2Input | |
| ]) { | |
| input.addEventListener("input", updateSelectedFromInputs); | |
| } | |
| controls.pasteInput.addEventListener("input", applyPastedCoordinates); | |
| controls.pasteMode.addEventListener("change", applyPastedCoordinates); | |
| controls.copyButtons.forEach((button) => { | |
| button.addEventListener("click", () => { | |
| copyText($(button.dataset.copy).textContent); | |
| }); | |
| }); | |
| canvas.addEventListener("pointerdown", onPointerDown); | |
| canvas.addEventListener("pointermove", onPointerMove); | |
| canvas.addEventListener("pointerup", onPointerUp); | |
| canvas.addEventListener("pointercancel", onPointerUp); | |
| canvasWrap.addEventListener("dragover", (event) => { | |
| event.preventDefault(); | |
| canvasWrap.classList.add("is-dragover"); | |
| }); | |
| canvasWrap.addEventListener("dragleave", () => { | |
| canvasWrap.classList.remove("is-dragover"); | |
| }); | |
| canvasWrap.addEventListener("drop", (event) => { | |
| event.preventDefault(); | |
| canvasWrap.classList.remove("is-dragover"); | |
| loadImageFile(event.dataTransfer.files[0]); | |
| }); | |
| document.addEventListener("keydown", (event) => { | |
| const activeTag = document.activeElement ? document.activeElement.tagName : ""; | |
| const isTyping = ["INPUT", "TEXTAREA"].includes(activeTag); | |
| if (isTyping) { | |
| return; | |
| } | |
| if (event.key === "Delete" || event.key === "Backspace") { | |
| clearBox(); | |
| } | |
| }); | |
| new ResizeObserver(resizeCanvas).observe(canvasWrap); | |
| sync(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |