arnodjiang commited on
Commit
ded2238
·
verified ·
1 Parent(s): 55cae17

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +48 -0
  2. index.html +1121 -499
  3. requirements.txt +1 -0
app.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from html import escape
2
+ from pathlib import Path
3
+
4
+ import gradio as gr
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parent
8
+ APP_HTML = (ROOT / "index.html").read_text(encoding="utf-8")
9
+
10
+
11
+ def render_app() -> str:
12
+ srcdoc = escape(APP_HTML, quote=True)
13
+ return (
14
+ '<iframe id="bbox-app-frame" '
15
+ f'srcdoc="{srcdoc}" '
16
+ 'sandbox="allow-scripts allow-same-origin allow-downloads" '
17
+ 'allow="clipboard-read; clipboard-write"></iframe>'
18
+ )
19
+
20
+
21
+ with gr.Blocks(
22
+ title="Bounding Box Demo",
23
+ css="""
24
+ .gradio-container {
25
+ max-width: none !important;
26
+ padding: 0 !important;
27
+ background: #f3f6fb !important;
28
+ }
29
+
30
+ #bbox-app-frame {
31
+ display: block;
32
+ width: 100%;
33
+ min-height: 900px;
34
+ height: calc(100vh - 18px);
35
+ border: 0;
36
+ background: #f3f6fb;
37
+ }
38
+
39
+ footer {
40
+ display: none !important;
41
+ }
42
+ """,
43
+ ) as demo:
44
+ gr.HTML(render_app())
45
+
46
+
47
+ if __name__ == "__main__":
48
+ demo.launch()
index.html CHANGED
@@ -1,573 +1,1195 @@
1
- <!DOCTYPE html>
2
- <html>
3
-
4
  <head>
5
- <style>
6
- * {
7
- box-sizing: border-box;
8
- margin: 0;
9
- padding: 0;
10
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- body {
13
- font-family: system-ui;
14
- background-color: #f5f5f5;
15
- min-height: 100vh;
16
- padding: 1rem;
17
- }
18
 
19
- .page-title {
20
- text-align: center;
21
- padding: 0.5rem 0;
22
- color: #333;
23
- margin-bottom: 1.5rem;
24
- font-size: 1.8rem;
25
- }
 
 
 
 
 
 
 
26
 
27
- .main-container {
28
- max-width: 1200px;
29
- margin: 0 auto;
30
- }
 
31
 
32
- /* Top Section: Image and Controls Side by Side */
33
- .top-section {
34
- display: flex;
35
- gap: 20px;
36
- margin-bottom: 30px;
37
- }
38
 
39
- /* Image Section */
40
- .image-section {
41
- flex: 0 0 640px;
42
- display: flex;
43
- flex-direction: column;
44
- align-items: center;
45
- background: #fff;
46
- padding: 15px;
47
- border-radius: 8px;
48
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
49
- }
50
 
51
- .image-container {
52
- position: relative;
53
- width: 100%;
54
- }
 
 
 
 
55
 
56
- #image {
57
- display: block;
58
- width: 100%;
59
- height: auto;
60
- border: 1px solid #ddd;
61
- border-radius: 8px;
62
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
63
- }
64
 
65
- #canvas {
66
- position: absolute;
67
- top: 0;
68
- left: 0;
69
- cursor: crosshair;
70
- }
71
 
72
- .labels-container {
73
- position: absolute;
74
- top: 0;
75
- left: 0;
76
- pointer-events: none;
77
- width: 100%;
78
- height: 100%;
79
- }
80
 
81
- /* Controls Section */
82
- .controls-section {
83
- flex: 1;
84
- background: #fff;
85
- padding: 15px;
86
- border-radius: 8px;
87
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
88
- display: flex;
89
- flex-direction: column;
90
- }
91
 
92
- .coordinates {
93
- display: flex;
94
- flex-direction: column;
95
- flex-grow: 1;
96
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
- .format-controls {
99
- margin-bottom: 20px;
100
- }
 
 
 
101
 
102
- .format-controls h2 {
103
- margin-bottom: 10px;
104
- color: #333;
105
- font-size: 1.2rem;
106
- }
107
 
108
- .radio-group {
109
- margin-bottom: 15px;
110
- padding: 10px;
111
- background: #f9f9f9;
112
- border-radius: 4px;
113
- border: 1px solid #ddd;
114
- }
115
 
116
- .radio-option {
117
- margin-bottom: 8px;
118
- }
 
119
 
120
- .radio-option label {
121
- margin-left: 5px;
122
- color: #555;
123
- }
 
124
 
125
- .format-controls h2:last-of-type {
126
- margin-top: 20px;
127
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
- .format {
130
- margin-bottom: 15px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
- .format-title {
134
- font-weight: bold;
135
- margin-bottom: 5px;
136
- color: #333;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  }
138
 
139
- .coords {
140
- font-family: monospace;
141
- background: #f9f9f9;
142
- padding: 8px;
143
- border-radius: 4px;
144
- border: 1px solid #ddd;
145
- min-height: 20px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  }
 
 
147
 
148
- .clear-button-container {
149
- display: flex;
150
- justify-content: center;
151
- padding-top: 10px;
152
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
- .clear-button {
155
- background-color: #f44336;
156
- color: white;
157
- border: none;
158
- padding: 8px 16px;
159
- border-radius: 4px;
160
- cursor: pointer;
161
- font-size: 14px;
162
- transition: background-color 0.2s;
 
 
 
 
 
 
163
  }
164
 
165
- .clear-button:hover {
166
- background-color: #d32f2f;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  }
168
 
169
- .label {
170
- position: absolute;
171
- background: rgba(0, 0, 0, 0.7);
172
- color: #fff;
173
- padding: 3px 5px;
174
- border-radius: 3px;
175
- font-size: 11px;
176
- pointer-events: none;
177
- white-space: nowrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  }
179
 
180
- /* Bottom Section: Model Explanation Table */
181
- .bottom-section {
182
- background: #fff;
183
- padding: 15px;
184
- border-radius: 8px;
185
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
186
- overflow-x: auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  }
188
 
189
- .model-explanation table {
190
- width: 100%;
191
- border-collapse: collapse;
192
- background: white;
193
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
194
- border-radius: 8px;
195
- overflow: hidden;
196
- font-size: 0.9rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  }
198
 
199
- .model-explanation th,
200
- .model-explanation td {
201
- border: 1px solid #ddd;
202
- padding: 8px;
203
- text-align: left;
 
 
 
 
 
204
  }
205
 
206
- .model-explanation th {
207
- background-color: #f2f2f2;
208
- position: sticky;
209
- top: 0;
210
- z-index: 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  }
212
 
213
- /* Responsive Design */
214
- @media (max-width: 1200px) {
215
- .top-section {
216
- flex-direction: column;
217
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
- .image-section,
220
- .controls-section,
221
- .bottom-section {
222
- width: 100%;
223
- max-width: 100%;
224
- margin: 0 auto;
225
- }
 
 
 
 
 
 
226
 
227
- .image-section,
228
- .controls-section {
229
- flex: none;
230
- }
231
  }
 
 
 
 
 
 
232
 
233
- @media (max-height: 900px) {
234
- body {
235
- overflow: auto;
236
- }
237
  }
238
- </style>
239
- </head>
240
 
241
- <body>
242
- <h1 class="page-title">Bounding Box Coordinate Tool</h1>
243
-
244
- <div class="main-container">
245
- <!-- Top Section: Image and Controls -->
246
- <div class="top-section">
247
- <!-- Image Section -->
248
- <div class="image-section">
249
- <div class="image-container">
250
- <img id="image" src="demo-image.jpg"
251
- alt="Image created by Flux Pro with prompt 'an image of a living room with plenty of different furniture objects'"
252
- width="640">
253
- <canvas id="canvas"></canvas>
254
- <div id="labels-container" class="labels-container"></div>
255
- </div>
256
- <p>Try drawing a box on the image by left clicking and dragging.</p>
257
- <p id="image-dimensions"></p>
258
- </div>
259
 
260
- <!-- Controls Section -->
261
- <div class="controls-section">
262
- <div class="coordinates">
263
- <div class="format-controls">
264
- <h2>Display Format</h2>
265
- <div class="radio-group">
266
- <div class="radio-option">
267
- <input type="radio" id="xyxy-radio" name="format" value="xyxy" checked>
268
- <label for="xyxy-radio">XYXY (x1, y1, x2, y2)</label>
269
- </div>
270
- <div class="radio-option">
271
- <input type="radio" id="xywh-radio" name="format" value="xywh">
272
- <label for="xywh-radio">XYWH (x, y, width, height)</label>
273
- </div>
274
- <div class="radio-option">
275
- <input type="radio" id="normalized-radio" name="format" value="normalized">
276
- <label for="normalized-radio">Normalized XYWH (0-1)</label>
277
- </div>
278
- <div class="radio-option">
279
- <input type="radio" id="center-radio" name="format" value="center">
280
- <label for="center-radio">Center XYWH (cx, cy, w, h)</label>
281
- </div>
282
- </div>
283
-
284
- <h2>Bounding Box Coordinates</h2>
285
- <div class="format">
286
- <div class="format-title">XYXY (x1, y1, x2, y2) → top left: (x1, y1), bottom right: (x2, y2)
287
- </div>
288
- <div id="xyxy" class="coords">No box drawn</div>
289
- </div>
290
-
291
- <div class="format">
292
- <div class="format-title">XYWH (x, y, width, height) → top left: (x, y), width and height of
293
- box</div>
294
- <div id="xywh" class="coords">No box drawn</div>
295
- </div>
296
-
297
- <div class="format">
298
- <div class="format-title">Normalized XYWH (pixel values in range of 0-1)</div>
299
- <div id="normalized" class="coords">No box drawn</div>
300
- </div>
301
-
302
- <div class="format">
303
- <div class="format-title">Center XYWH (cx, cy, w, h) → (center x, center y, width, height)
304
- </div>
305
- <div id="center" class="coords">No box drawn</div>
306
- </div>
307
- </div>
308
-
309
- <div class="clear-button-container">
310
- <button id="clear-button" class="clear-button">Clear Box</button>
311
- </div>
312
- </div>
313
- </div>
314
- </div>
315
 
316
- <!-- Bottom Section: Model Explanation Table -->
317
- <div class="bottom-section">
318
- <div class="model-explanation">
319
- <table>
320
- <thead>
321
- <tr>
322
- <th>Model</th>
323
- <th>Summary</th>
324
- <th>Bounding Box Format</th>
325
- </tr>
326
- </thead>
327
- <tbody>
328
- <tr>
329
- <td><strong>YOLO</strong></td>
330
- <td>Real-time object detection system. Training format uses normalized coordinates for
331
- better generalization across different image sizes.</td>
332
- <td><strong>Normalized XYWH</strong> (x, y, w, h) in range [0,1]</td>
333
- </tr>
334
- <tr>
335
- <td><strong>Faster R-CNN</strong></td>
336
- <td>Region proposal network based detection. Typically uses absolute coordinates during
337
- inference, but may use normalized coordinates during training.</td>
338
- <td><strong>XYXY</strong> (x1, y1, x2, y2) or normalized coordinates</td>
339
- </tr>
340
- <tr>
341
- <td><strong>SSD</strong></td>
342
- <td>Single Shot MultiBox Detector uses normalized coordinates for anchor boxes and
343
- predictions to handle multiple scales efficiently.</td>
344
- <td><strong>Normalized XYWH</strong> (x, y, w, h) in range [0,1]</td>
345
- </tr>
346
- <tr>
347
- <td><strong>RetinaNet</strong></td>
348
- <td>Dense object detector with focal loss. Uses normalized coordinates for anchor boxes and
349
- predictions.</td>
350
- <td><strong>Normalized XYWH</strong> (x, y, w, h) in range [0,1]</td>
351
- </tr>
352
- <tr>
353
- <td><strong>CornerNet</strong></td>
354
- <td>Anchor-free detector that predicts keypoint heatmaps. Uses normalized coordinates for
355
- better scale invariance.</td>
356
- <td><strong>Normalized Corners</strong> (x1, y1, x2, y2) in range [0,1]</td>
357
- </tr>
358
- </tbody>
359
- </table>
360
- </div>
361
- </div>
362
- </div>
363
-
364
- <script>
365
- const image = document.getElementById('image');
366
- const canvas = document.getElementById('canvas');
367
- const ctx = canvas.getContext('2d');
368
- const labelsContainer = document.getElementById('labels-container');
369
-
370
- let isDrawing = false;
371
- let startX = 0;
372
- let startY = 0;
373
- let lastCoords = null;
374
- let labelElements = [];
375
-
376
- function initCanvas() {
377
- canvas.width = image.width;
378
- canvas.height = image.height;
379
- canvas.style.width = image.width + 'px';
380
- canvas.style.height = image.height + 'px';
381
- labelsContainer.style.width = image.width + 'px';
382
- labelsContainer.style.height = image.height + 'px';
383
- document.getElementById('image-dimensions').textContent = 'Image dimensions: ' + image.width + ' x ' + image.height;
384
- }
385
 
386
- if (image.complete) {
387
- initCanvas();
388
- } else {
389
- image.onload = initCanvas;
 
 
 
 
 
 
 
 
 
 
 
390
  }
 
 
 
 
 
 
 
 
 
391
 
392
- function clearCanvas() {
393
- ctx.clearRect(0, 0, canvas.width, canvas.height);
394
- clearLabels();
395
  }
396
 
397
- function clearLabels() {
398
- labelElements.forEach(label => labelsContainer.removeChild(label));
399
- labelElements = [];
 
 
 
 
 
 
 
 
 
 
 
400
  }
401
 
402
- function createLabel(text, x, y, positionClass) {
403
- const label = document.createElement('div');
404
- label.classList.add('label', positionClass);
405
- label.textContent = text;
406
- label.style.left = `${x}px`;
407
- label.style.top = `${y}px`;
408
- const containerRect = labelsContainer.getBoundingClientRect();
409
- label.style.visibility = 'hidden';
410
- labelsContainer.appendChild(label);
411
- const tempRect = label.getBoundingClientRect();
412
- label.style.visibility = 'visible';
413
- if (x + tempRect.width > containerRect.width) {
414
- label.style.left = `${containerRect.width - tempRect.width - 5}px`;
415
- }
416
- if (y + tempRect.height > containerRect.height) {
417
- label.style.top = `${containerRect.height - tempRect.height - 5}px`;
418
- }
419
- labelsContainer.appendChild(label);
420
- labelElements.push(label);
421
  }
422
 
423
- function drawDot(x, y) {
424
- ctx.beginPath();
425
- ctx.arc(x, y, 4, 0, 2 * Math.PI);
426
- ctx.fillStyle = 'black';
427
- ctx.fill();
428
  }
429
 
430
- // Draw dots at the positions corresponding to the selected format.
431
- function drawDots(x1, y1, x2, y2) {
432
- const format = document.querySelector('input[name="format"]:checked').value;
433
- if (format === 'center') {
434
- const centerX = x1 + (x2 - x1) / 2;
435
- const centerY = y1 + (y2 - y1) / 2;
436
- drawDot(centerX, centerY);
437
- drawDot(x1, y1);
438
- } else {
439
- drawDot(x1, y1);
440
- drawDot(x2, y2);
441
- }
442
  }
443
 
444
- function createLabels(x1, y1, x2, y2) {
445
- const width = x2 - x1;
446
- const height = y2 - y1;
447
- const format = document.querySelector('input[name="format"]:checked').value;
448
- let labels = [];
449
- switch (format) {
450
- case 'xyxy':
451
- labels = [
452
- { text: `(${Math.round(x1)}, ${Math.round(y1)})`, x: x1, y: y1, position: 'top-left' },
453
- { text: `(${Math.round(x2)}, ${Math.round(y2)})`, x: x2, y: y2, position: 'bottom-right' }
454
- ];
455
- break;
456
- case 'xywh':
457
- labels = [
458
- { text: `x: ${Math.round(x1)}, y: ${Math.round(y1)}`, x: x1, y: y1, position: 'top-left' },
459
- { text: `w: ${Math.round(width)}, h: ${Math.round(height)}`, x: x1, y: y1 - 15, position: 'top-left' }
460
- ];
461
- break;
462
- case 'normalized':
463
- const normalizedX = (x1 / canvas.width).toFixed(3);
464
- const normalizedY = (y1 / canvas.height).toFixed(3);
465
- const normalizedW = (width / canvas.width).toFixed(3);
466
- const normalizedH = (height / canvas.height).toFixed(3);
467
- labels = [
468
- { text: `x: ${normalizedX}, y: ${normalizedY}`, x: x1, y: y1, position: 'top-left' },
469
- { text: `w: ${normalizedW}, h: ${normalizedH}`, x: x1, y: y1 - 15, position: 'top-left' }
470
- ];
471
- break;
472
- case 'center':
473
- const centerX = Math.round(x1 + width / 2);
474
- const centerY = Math.round(y1 + height / 2);
475
- labels = [
476
- { text: `cx: ${centerX}, cy: ${centerY}`, x: centerX, y: centerY, position: 'top-left' },
477
- { text: `w: ${Math.round(width)}, h: ${Math.round(height)}`, x: x1, y: y1 - 15, position: 'top-left' }
478
- ];
479
- break;
480
- }
481
- labels.forEach(label => {
482
- createLabel(label.text, label.x, label.y, label.position);
483
- });
484
  }
 
 
485
 
486
- function drawBox(x1, y1, x2, y2) {
487
- clearCanvas();
488
- ctx.strokeStyle = '#00ff00';
489
- ctx.lineWidth = 2;
490
- const width = x2 - x1;
491
- const height = y2 - y1;
492
- ctx.strokeRect(x1, y1, width, height);
493
- drawDots(x1, y1, x2, y2);
494
- createLabels(x1, y1, x2, y2);
495
- lastCoords = { x1, y1, x2, y2 };
496
  }
497
 
498
- function updateCoordinates(x1, y1, x2, y2) {
499
- if (x1 === null || y1 === null || x2 === null || y2 === null) {
500
- document.getElementById('xyxy').textContent = 'No box drawn';
501
- document.getElementById('xywh').textContent = 'No box drawn';
502
- document.getElementById('normalized').textContent = 'No box drawn';
503
- document.getElementById('center').textContent = 'No box drawn';
504
- return;
505
- }
506
- const width = x2 - x1;
507
- const height = y2 - y1;
508
- document.getElementById('xyxy').textContent =
509
- `[${Math.round(x1)}, ${Math.round(y1)}, ${Math.round(x2)}, ${Math.round(y2)}]`;
510
- document.getElementById('xywh').textContent =
511
- `[${Math.round(x1)}, ${Math.round(y1)}, ${Math.round(width)}, ${Math.round(height)}]`;
512
- const normalizedX = (x1 / canvas.width).toFixed(3);
513
- const normalizedY = (y1 / canvas.height).toFixed(3);
514
- const normalizedW = (width / canvas.width).toFixed(3);
515
- const normalizedH = (height / canvas.height).toFixed(3);
516
- document.getElementById('normalized').textContent =
517
- `[${normalizedX}, ${normalizedY}, ${normalizedW}, ${normalizedH}]`;
518
- const centerX = x1 + width / 2;
519
- const centerY = y1 + height / 2;
520
- document.getElementById('center').textContent =
521
- `[${Math.round(centerX)}, ${Math.round(centerY)}, ${Math.round(width)}, ${Math.round(height)}]`;
522
  }
 
 
523
 
524
- document.querySelectorAll('input[name="format"]').forEach(radio => {
525
- radio.addEventListener('change', () => {
526
- if (lastCoords) {
527
- drawBox(lastCoords.x1, lastCoords.y1, lastCoords.x2, lastCoords.y2);
528
- updateCoordinates(lastCoords.x1, lastCoords.y1, lastCoords.x2, lastCoords.y2);
529
- }
530
- });
531
- });
532
 
533
- canvas.addEventListener('mousedown', (e) => {
534
- isDrawing = true;
535
- const rect = canvas.getBoundingClientRect();
536
- startX = e.clientX - rect.left;
537
- startY = e.clientY - rect.top;
538
- clearCanvas();
539
- updateCoordinates(null, null, null, null);
540
- });
541
 
542
- canvas.addEventListener('mousemove', (e) => {
543
- if (!isDrawing) return;
544
- const rect = canvas.getBoundingClientRect();
545
- const currentX = e.clientX - rect.left;
546
- const currentY = e.clientY - rect.top;
547
- drawBox(startX, startY, currentX, currentY);
548
- updateCoordinates(startX, startY, currentX, currentY);
549
- });
550
 
551
- canvas.addEventListener('mouseup', (e) => {
552
- if (!isDrawing) return;
553
- const rect = canvas.getBoundingClientRect();
554
- const endX = e.clientX - rect.left;
555
- const endY = e.clientY - rect.top;
556
- drawBox(startX, startY, endX, endY);
557
- updateCoordinates(startX, startY, endX, endY);
558
- isDrawing = false;
559
- });
560
 
561
- canvas.addEventListener('mouseleave', () => {
562
- isDrawing = false;
563
- });
 
 
 
 
 
564
 
565
- document.getElementById('clear-button').addEventListener('click', () => {
566
- clearCanvas();
567
- lastCoords = null;
568
- updateCoordinates(null, null, null, null);
569
- });
570
- </script>
571
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
572
 
573
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
 
3
  <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Bounding Box Demo</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ --bg: #f3f6fb;
11
+ --surface: #ffffff;
12
+ --surface-strong: #f8fafc;
13
+ --ink: #172033;
14
+ --muted: #667085;
15
+ --line: #d8e0ea;
16
+ --accent: #2563eb;
17
+ --accent-strong: #1d4ed8;
18
+ --accent-soft: #eaf1ff;
19
+ --teal: #0f9f8f;
20
+ --danger: #dc2626;
21
+ --danger-soft: #fee2e2;
22
+ --shadow: 0 20px 60px rgba(15, 23, 42, 0.10);
23
+ --radius: 8px;
24
+ }
25
 
26
+ * {
27
+ box-sizing: border-box;
28
+ }
 
 
 
29
 
30
+ html,
31
+ body {
32
+ width: 100%;
33
+ min-height: 100%;
34
+ margin: 0;
35
+ overflow-x: hidden;
36
+ background: var(--bg);
37
+ color: var(--ink);
38
+ font-family:
39
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
40
+ "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
41
+ sans-serif;
42
+ letter-spacing: 0;
43
+ }
44
 
45
+ button,
46
+ input,
47
+ textarea {
48
+ font: inherit;
49
+ }
50
 
51
+ button {
52
+ border: 0;
53
+ }
 
 
 
54
 
55
+ .app {
56
+ min-height: 100vh;
57
+ padding: 18px;
58
+ }
 
 
 
 
 
 
 
59
 
60
+ .topbar {
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ gap: 16px;
65
+ width: min(1480px, 100%);
66
+ margin: 0 auto 14px;
67
+ }
68
 
69
+ .brand {
70
+ display: flex;
71
+ flex-direction: column;
72
+ gap: 3px;
73
+ min-width: 0;
74
+ }
 
 
75
 
76
+ .brand-kicker {
77
+ color: var(--teal);
78
+ font-size: 12px;
79
+ font-weight: 800;
80
+ text-transform: uppercase;
81
+ }
82
 
83
+ h1 {
84
+ margin: 0;
85
+ color: var(--ink);
86
+ font-size: 28px;
87
+ line-height: 1.1;
88
+ font-weight: 850;
89
+ letter-spacing: 0;
90
+ }
91
 
92
+ .top-actions {
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: flex-end;
96
+ gap: 10px;
97
+ flex-wrap: wrap;
98
+ }
 
 
 
99
 
100
+ .button,
101
+ .upload-button {
102
+ min-height: 38px;
103
+ display: inline-flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ gap: 8px;
107
+ padding: 0 14px;
108
+ border-radius: var(--radius);
109
+ background: var(--surface);
110
+ color: var(--ink);
111
+ border: 1px solid var(--line);
112
+ cursor: pointer;
113
+ font-weight: 750;
114
+ white-space: nowrap;
115
+ transition:
116
+ transform 0.16s ease,
117
+ border-color 0.16s ease,
118
+ background 0.16s ease,
119
+ color 0.16s ease;
120
+ }
121
 
122
+ .button:hover,
123
+ .upload-button:hover {
124
+ transform: translateY(-1px);
125
+ border-color: #b8c7da;
126
+ background: var(--surface-strong);
127
+ }
128
 
129
+ .button:disabled {
130
+ opacity: 0.45;
131
+ cursor: not-allowed;
132
+ transform: none;
133
+ }
134
 
135
+ .button.primary,
136
+ .upload-button.primary {
137
+ color: #fff;
138
+ border-color: var(--accent);
139
+ background: var(--accent);
140
+ }
 
141
 
142
+ .button.primary:hover,
143
+ .upload-button.primary:hover {
144
+ background: var(--accent-strong);
145
+ }
146
 
147
+ .button.danger {
148
+ color: var(--danger);
149
+ background: var(--danger-soft);
150
+ border-color: #fecaca;
151
+ }
152
 
153
+ .upload-button input {
154
+ display: none;
155
+ }
156
+
157
+ .workspace {
158
+ width: min(1480px, 100%);
159
+ margin: 0 auto;
160
+ display: grid;
161
+ grid-template-columns: minmax(0, 1fr) 360px;
162
+ gap: 14px;
163
+ align-items: stretch;
164
+ }
165
+
166
+ .stage-panel,
167
+ .panel {
168
+ border: 1px solid var(--line);
169
+ background: var(--surface);
170
+ box-shadow: var(--shadow);
171
+ }
172
+
173
+ .stage-panel {
174
+ min-width: 0;
175
+ border-radius: var(--radius);
176
+ overflow: hidden;
177
+ display: flex;
178
+ flex-direction: column;
179
+ }
180
+
181
+ .stage-toolbar {
182
+ min-height: 58px;
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: flex-end;
186
+ gap: 12px;
187
+ padding: 10px 12px;
188
+ border-bottom: 1px solid var(--line);
189
+ background: var(--surface-strong);
190
+ }
191
+
192
+ .stage-meta {
193
+ display: flex;
194
+ align-items: center;
195
+ justify-content: flex-end;
196
+ gap: 8px;
197
+ min-width: 0;
198
+ color: var(--muted);
199
+ font-size: 13px;
200
+ font-weight: 700;
201
+ }
202
+
203
+ .pill {
204
+ max-width: 260px;
205
+ overflow: hidden;
206
+ text-overflow: ellipsis;
207
+ white-space: nowrap;
208
+ padding: 5px 9px;
209
+ border-radius: 999px;
210
+ border: 1px solid var(--line);
211
+ background: var(--surface);
212
+ }
213
+
214
+ .canvas-wrap {
215
+ position: relative;
216
+ min-height: 560px;
217
+ height: calc(100vh - 170px);
218
+ background:
219
+ linear-gradient(45deg, #eef2f7 25%, transparent 25%) 0 0 / 22px 22px,
220
+ linear-gradient(45deg, transparent 75%, #eef2f7 75%) 0 0 / 22px 22px,
221
+ linear-gradient(45deg, transparent 75%, #eef2f7 75%) 11px 11px / 22px 22px,
222
+ linear-gradient(45deg, #eef2f7 25%, #f8fafc 25%) 11px 11px / 22px 22px;
223
+ overflow: hidden;
224
+ }
225
+
226
+ .canvas-wrap.is-dragover {
227
+ outline: 3px solid rgba(37, 99, 235, 0.34);
228
+ outline-offset: -6px;
229
+ }
230
+
231
+ canvas {
232
+ display: block;
233
+ width: 100%;
234
+ height: 100%;
235
+ touch-action: none;
236
+ }
237
+
238
+ .empty-state {
239
+ position: absolute;
240
+ inset: 50% auto auto 50%;
241
+ transform: translate(-50%, -50%);
242
+ width: min(390px, calc(100% - 48px));
243
+ padding: 22px;
244
+ border-radius: var(--radius);
245
+ background: rgba(255, 255, 255, 0.92);
246
+ border: 1px dashed #b8c7da;
247
+ text-align: center;
248
+ color: var(--muted);
249
+ pointer-events: none;
250
+ }
251
+
252
+ .empty-title {
253
+ margin: 0 0 5px;
254
+ color: var(--ink);
255
+ font-size: 18px;
256
+ font-weight: 850;
257
+ }
258
+
259
+ .empty-subtitle {
260
+ margin: 0;
261
+ font-size: 13px;
262
+ font-weight: 650;
263
+ }
264
+
265
+ .sidebar {
266
+ display: flex;
267
+ min-width: 0;
268
+ flex-direction: column;
269
+ gap: 14px;
270
+ }
271
+
272
+ .panel {
273
+ border-radius: var(--radius);
274
+ padding: 14px;
275
+ }
276
+
277
+ .panel-title {
278
+ display: flex;
279
+ align-items: center;
280
+ justify-content: space-between;
281
+ gap: 8px;
282
+ margin: 0 0 12px;
283
+ color: var(--ink);
284
+ font-size: 15px;
285
+ font-weight: 850;
286
+ }
287
+
288
+ .field {
289
+ display: flex;
290
+ min-width: 0;
291
+ flex-direction: column;
292
+ gap: 6px;
293
+ color: var(--muted);
294
+ font-size: 12px;
295
+ font-weight: 800;
296
+ text-transform: uppercase;
297
+ }
298
+
299
+ .field input,
300
+ .field textarea {
301
+ width: 100%;
302
+ min-width: 0;
303
+ border: 1px solid var(--line);
304
+ border-radius: var(--radius);
305
+ background: var(--surface-strong);
306
+ color: var(--ink);
307
+ outline: none;
308
+ transition:
309
+ border-color 0.16s ease,
310
+ box-shadow 0.16s ease,
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;
323
+ resize: vertical;
324
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
325
+ font-size: 12px;
326
+ line-height: 1.45;
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);
333
+ background: #fff;
334
+ }
335
+
336
+ .field input:disabled {
337
+ color: #98a2b3;
338
+ background: #f2f4f7;
339
+ }
340
+
341
+ .coord-grid {
342
+ display: grid;
343
+ grid-template-columns: repeat(2, minmax(0, 1fr));
344
+ gap: 10px;
345
+ margin-bottom: 10px;
346
+ }
347
+
348
+ .field.full {
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;
377
+ font-weight: 850;
378
+ text-transform: uppercase;
379
+ }
380
+
381
+ .result-code {
382
+ min-height: 24px;
383
+ overflow-wrap: anywhere;
384
+ color: var(--ink);
385
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
386
+ font-size: 12px;
387
+ line-height: 1.5;
388
+ white-space: pre-wrap;
389
+ }
390
+
391
+ .status {
392
+ min-height: 22px;
393
+ margin: 10px auto 0;
394
+ width: min(1480px, 100%);
395
+ color: var(--muted);
396
+ font-size: 12px;
397
+ font-weight: 700;
398
+ }
399
+
400
+ @media (max-width: 980px) {
401
+ .app {
402
+ padding: 12px;
403
+ }
404
+
405
+ .topbar,
406
+ .workspace {
407
+ width: 100%;
408
+ }
409
+
410
+ .topbar {
411
+ align-items: flex-start;
412
+ flex-direction: column;
413
+ }
414
+
415
+ .top-actions {
416
+ width: 100%;
417
+ justify-content: flex-start;
418
+ }
419
+
420
+ .workspace {
421
+ grid-template-columns: 1fr;
422
+ }
423
+
424
+ .canvas-wrap {
425
+ min-height: 420px;
426
+ height: 58vh;
427
+ }
428
+
429
+ .stage-meta {
430
+ width: 100%;
431
+ justify-content: flex-start;
432
+ flex-wrap: wrap;
433
+ }
434
+
435
+ .sidebar {
436
+ display: grid;
437
+ grid-template-columns: 1fr;
438
+ }
439
+ }
440
+
441
+ @media (max-width: 520px) {
442
+ h1 {
443
+ font-size: 23px;
444
+ }
445
+
446
+ .button,
447
+ .upload-button {
448
+ width: 100%;
449
+ }
450
+
451
+ .top-actions,
452
+ .action-row {
453
+ grid-template-columns: 1fr;
454
+ }
455
+
456
+ .top-actions {
457
+ display: grid;
458
+ grid-template-columns: 1fr;
459
+ }
460
+
461
+ .coord-grid {
462
+ grid-template-columns: 1fr;
463
+ }
464
+
465
+ }
466
+ </style>
467
+ </head>
468
+ <body>
469
+ <div class="app">
470
+ <header class="topbar">
471
+ <div class="brand">
472
+ <span class="brand-kicker">Hugging Face Space</span>
473
+ <h1>Bounding Box Annotator</h1>
474
+ </div>
475
+ <div class="top-actions">
476
+ <label class="upload-button primary" for="fileInput">
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
+
485
+ <main class="workspace">
486
+ <section class="stage-panel" aria-label="image annotation canvas">
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">
494
+ <canvas id="canvas"></canvas>
495
+ <div class="empty-state" id="emptyState">
496
+ <p class="empty-title">上传图片开始标注</p>
497
+ <p class="empty-subtitle">坐标以原始图片像素为准</p>
498
+ </div>
499
+ </div>
500
+ </section>
501
+
502
+ <aside class="sidebar">
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">YOLO 格式 cx cy w h</div>
542
+ <div class="result-code" id="yoloOutput">未选择</div>
543
+ </div>
544
+ </div>
545
+ </section>
546
+ </aside>
547
+ </main>
548
+
549
+ <div class="status" id="statusLine"></div>
550
+ </div>
551
+
552
+ <script>
553
+ (() => {
554
+ "use strict";
555
+
556
+ const $ = (id) => document.getElementById(id);
557
+ const canvas = $("canvas");
558
+ const ctx = canvas.getContext("2d");
559
+ const canvasWrap = $("canvasWrap");
560
+ const emptyState = $("emptyState");
561
+
562
+ const controls = {
563
+ fileInput: $("fileInput"),
564
+ fitButton: $("fitButton"),
565
+ clearButton: $("clearButton"),
566
+ addButton: $("addButton"),
567
+ deleteButton: $("deleteButton"),
568
+ xInput: $("xInput"),
569
+ yInput: $("yInput"),
570
+ wInput: $("wInput"),
571
+ hInput: $("hInput"),
572
+ imageMeta: $("imageMeta"),
573
+ boxMeta: $("boxMeta"),
574
+ absoluteOutput: $("absoluteOutput"),
575
+ sizeOutput: $("sizeOutput"),
576
+ relativeOutput: $("relativeOutput"),
577
+ yoloOutput: $("yoloOutput"),
578
+ statusLine: $("statusLine")
579
+ };
580
+
581
+ const palette = [
582
+ "#2563eb",
583
+ "#0f9f8f",
584
+ "#f59e0b",
585
+ "#dc2626",
586
+ "#7c3aed",
587
+ "#0891b2",
588
+ "#16a34a",
589
+ "#e11d48"
590
+ ];
591
 
592
+ const state = {
593
+ image: null,
594
+ imageName: "",
595
+ boxes: [],
596
+ selectedId: null,
597
+ nextId: 1,
598
+ drag: null,
599
+ scale: 1,
600
+ offsetX: 0,
601
+ offsetY: 0,
602
+ canvasWidth: 1,
603
+ canvasHeight: 1,
604
+ dpr: 1
605
+ };
606
+
607
+ function clamp(value, min, max) {
608
+ if (!Number.isFinite(value)) {
609
+ return min;
610
+ }
611
+ if (max < min) {
612
+ return min;
613
  }
614
+ return Math.min(max, Math.max(min, value));
615
+ }
616
+
617
+ function imageWidth() {
618
+ return state.image ? state.image.naturalWidth : 0;
619
+ }
620
+
621
+ function imageHeight() {
622
+ return state.image ? state.image.naturalHeight : 0;
623
+ }
624
+
625
+ function selectedBox() {
626
+ return state.boxes.find((box) => box.id === state.selectedId) || null;
627
+ }
628
 
629
+ function makeBox(x, y, width, height) {
630
+ const id = state.nextId;
631
+ state.nextId += 1;
632
+ return {
633
+ id,
634
+ x,
635
+ y,
636
+ width,
637
+ height,
638
+ color: palette[(id - 1) % palette.length]
639
+ };
640
+ }
641
+
642
+ function constrainBox(box) {
643
+ const maxW = imageWidth();
644
+ const maxH = imageHeight();
645
+ if (!maxW || !maxH) {
646
+ return;
647
  }
648
 
649
+ box.x = clamp(Math.round(box.x), 0, Math.max(0, maxW - 1));
650
+ box.y = clamp(Math.round(box.y), 0, Math.max(0, maxH - 1));
651
+ box.width = clamp(Math.round(box.width), 1, maxW - box.x);
652
+ box.height = clamp(Math.round(box.height), 1, maxH - box.y);
653
+ }
654
+
655
+ function constrainAllBoxes() {
656
+ state.boxes.forEach(constrainBox);
657
+ }
658
+
659
+ function imageToCanvas(point) {
660
+ return {
661
+ x: state.offsetX + point.x * state.scale,
662
+ y: state.offsetY + point.y * state.scale
663
+ };
664
+ }
665
+
666
+ function eventToImagePoint(event) {
667
+ const rect = canvas.getBoundingClientRect();
668
+ const x = (event.clientX - rect.left - state.offsetX) / state.scale;
669
+ const y = (event.clientY - rect.top - state.offsetY) / state.scale;
670
+ return {
671
+ x: clamp(x, 0, imageWidth()),
672
+ y: clamp(y, 0, imageHeight())
673
+ };
674
+ }
675
+
676
+ function handlePoints(box) {
677
+ const x1 = box.x;
678
+ const y1 = box.y;
679
+ const x2 = box.x + box.width;
680
+ const y2 = box.y + box.height;
681
+ const cx = box.x + box.width / 2;
682
+ const cy = box.y + box.height / 2;
683
+ return [
684
+ { name: "nw", x: x1, y: y1 },
685
+ { name: "n", x: cx, y: y1 },
686
+ { name: "ne", x: x2, y: y1 },
687
+ { name: "e", x: x2, y: cy },
688
+ { name: "se", x: x2, y: y2 },
689
+ { name: "s", x: cx, y: y2 },
690
+ { name: "sw", x: x1, y: y2 },
691
+ { name: "w", x: x1, y: cy }
692
+ ];
693
+ }
694
+
695
+ function hitTest(point) {
696
+ const tolerance = Math.max(6 / state.scale, 4);
697
+ for (let index = state.boxes.length - 1; index >= 0; index -= 1) {
698
+ const box = state.boxes[index];
699
+ for (const handle of handlePoints(box)) {
700
+ if (
701
+ Math.abs(point.x - handle.x) <= tolerance &&
702
+ Math.abs(point.y - handle.y) <= tolerance
703
+ ) {
704
+ return { box, action: "resize", handle: handle.name };
705
+ }
706
+ }
707
+
708
+ if (
709
+ point.x >= box.x &&
710
+ point.x <= box.x + box.width &&
711
+ point.y >= box.y &&
712
+ point.y <= box.y + box.height
713
+ ) {
714
+ return { box, action: "move", handle: null };
715
+ }
716
  }
717
+ return null;
718
+ }
719
 
720
+ function cursorForHit(hit) {
721
+ if (!hit) {
722
+ return state.image ? "crosshair" : "default";
 
723
  }
724
+ if (hit.action === "move") {
725
+ return "move";
726
+ }
727
+ const map = {
728
+ n: "ns-resize",
729
+ s: "ns-resize",
730
+ e: "ew-resize",
731
+ w: "ew-resize",
732
+ nw: "nwse-resize",
733
+ se: "nwse-resize",
734
+ ne: "nesw-resize",
735
+ sw: "nesw-resize"
736
+ };
737
+ return map[hit.handle] || "default";
738
+ }
739
+
740
+ function resizeCanvas() {
741
+ const rect = canvasWrap.getBoundingClientRect();
742
+ const width = Math.max(1, Math.round(rect.width));
743
+ const height = Math.max(1, Math.round(rect.height));
744
+ const dpr = window.devicePixelRatio || 1;
745
 
746
+ state.canvasWidth = width;
747
+ state.canvasHeight = height;
748
+ state.dpr = dpr;
749
+ canvas.width = Math.round(width * dpr);
750
+ canvas.height = Math.round(height * dpr);
751
+ canvas.style.width = `${width}px`;
752
+ canvas.style.height = `${height}px`;
753
+
754
+ draw();
755
+ }
756
+
757
+ function drawImageSurface() {
758
+ if (!state.image) {
759
+ emptyState.style.display = "block";
760
+ return;
761
  }
762
 
763
+ emptyState.style.display = "none";
764
+ const padding = state.canvasWidth < 700 ? 16 : 28;
765
+ const availableWidth = Math.max(1, state.canvasWidth - padding * 2);
766
+ const availableHeight = Math.max(1, state.canvasHeight - padding * 2);
767
+ state.scale = Math.min(
768
+ availableWidth / imageWidth(),
769
+ availableHeight / imageHeight()
770
+ );
771
+ state.scale = Math.max(0.01, state.scale);
772
+ const drawWidth = imageWidth() * state.scale;
773
+ const drawHeight = imageHeight() * state.scale;
774
+ state.offsetX = (state.canvasWidth - drawWidth) / 2;
775
+ state.offsetY = (state.canvasHeight - drawHeight) / 2;
776
+
777
+ ctx.save();
778
+ ctx.fillStyle = "#ffffff";
779
+ ctx.shadowColor = "rgba(15, 23, 42, 0.22)";
780
+ ctx.shadowBlur = 22;
781
+ ctx.shadowOffsetY = 12;
782
+ ctx.fillRect(state.offsetX, state.offsetY, drawWidth, drawHeight);
783
+ ctx.restore();
784
+ ctx.drawImage(state.image, state.offsetX, state.offsetY, drawWidth, drawHeight);
785
+ }
786
+
787
+ function drawBoxes() {
788
+ if (!state.image) {
789
+ return;
790
  }
791
 
792
+ state.boxes.forEach((box) => {
793
+ const isActive = box.id === state.selectedId;
794
+ const topLeft = imageToCanvas({ x: box.x, y: box.y });
795
+ const width = box.width * state.scale;
796
+ const height = box.height * state.scale;
797
+
798
+ ctx.save();
799
+ ctx.lineWidth = isActive ? 3 : 2;
800
+ ctx.strokeStyle = box.color;
801
+ ctx.fillStyle = `${box.color}1f`;
802
+ ctx.fillRect(topLeft.x, topLeft.y, width, height);
803
+ ctx.strokeRect(topLeft.x, topLeft.y, width, height);
804
+
805
+ const labelText = `#${box.id}`;
806
+ ctx.font = "700 12px Inter, system-ui, sans-serif";
807
+ const labelWidth = Math.min(
808
+ Math.max(ctx.measureText(labelText).width + 14, 42),
809
+ Math.max(42, width)
810
+ );
811
+ const labelHeight = 22;
812
+ const labelY = Math.max(state.offsetY, topLeft.y - labelHeight);
813
+ ctx.fillStyle = box.color;
814
+ ctx.fillRect(topLeft.x, labelY, labelWidth, labelHeight);
815
+ ctx.fillStyle = "#ffffff";
816
+ ctx.textBaseline = "middle";
817
+ ctx.fillText(labelText, topLeft.x + 7, labelY + labelHeight / 2);
818
+
819
+ if (isActive) {
820
+ const size = 8;
821
+ ctx.fillStyle = "#ffffff";
822
+ ctx.strokeStyle = box.color;
823
+ ctx.lineWidth = 2;
824
+ for (const handle of handlePoints(box)) {
825
+ const point = imageToCanvas(handle);
826
+ ctx.beginPath();
827
+ ctx.rect(point.x - size / 2, point.y - size / 2, size, size);
828
+ ctx.fill();
829
+ ctx.stroke();
830
+ }
831
+ }
832
+ ctx.restore();
833
+ });
834
+ }
835
+
836
+ function draw() {
837
+ ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
838
+ ctx.clearRect(0, 0, state.canvasWidth, state.canvasHeight);
839
+ ctx.fillStyle = "#f8fafc";
840
+ ctx.fillRect(0, 0, state.canvasWidth, state.canvasHeight);
841
+ drawImageSurface();
842
+ drawBoxes();
843
+ }
844
+
845
+ function formatRatio(value) {
846
+ return Number.isFinite(value) ? value.toFixed(6) : "0.000000";
847
+ }
848
+
849
+ function resultLines(box) {
850
+ if (!box || !state.image) {
851
+ return null;
852
  }
853
 
854
+ const x1 = box.x;
855
+ const y1 = box.y;
856
+ const x2 = box.x + box.width;
857
+ const y2 = box.y + box.height;
858
+ const cx = box.x + box.width / 2;
859
+ const cy = box.y + box.height / 2;
860
+ const iw = imageWidth();
861
+ const ih = imageHeight();
862
+
863
+ return {
864
+ absolute: `x1=${x1}, y1=${y1}, x2=${x2}, y2=${y2}`,
865
+ size: `x=${box.x}, y=${box.y}, w=${box.width}, h=${box.height}`,
866
+ relative: [
867
+ `x1=${formatRatio(x1 / iw)}, y1=${formatRatio(y1 / ih)}`,
868
+ `x2=${formatRatio(x2 / iw)}, y2=${formatRatio(y2 / ih)}`
869
+ ].join("\n"),
870
+ yolo: [
871
+ `cx=${formatRatio(cx / iw)}, cy=${formatRatio(cy / ih)}`,
872
+ `w=${formatRatio(box.width / iw)}, h=${formatRatio(box.height / ih)}`
873
+ ].join("\n")
874
+ };
875
+ }
876
+
877
+ function updateResults(selected) {
878
+ const values = resultLines(selected);
879
+ if (!values) {
880
+ controls.absoluteOutput.textContent = "未选择";
881
+ controls.sizeOutput.textContent = "未选择";
882
+ controls.relativeOutput.textContent = "未选择";
883
+ controls.yoloOutput.textContent = "未选择";
884
+ return;
885
  }
886
 
887
+ controls.absoluteOutput.textContent = values.absolute;
888
+ controls.sizeOutput.textContent = values.size;
889
+ controls.relativeOutput.textContent = values.relative;
890
+ controls.yoloOutput.textContent = values.yolo;
891
+ }
892
+
893
+ function updateControls() {
894
+ const hasImage = Boolean(state.image);
895
+ const selected = selectedBox();
896
+ const hasSelected = Boolean(selected);
897
+ const disabled = !hasSelected;
898
+
899
+ controls.fitButton.disabled = !hasImage;
900
+ controls.clearButton.disabled = !state.boxes.length;
901
+ controls.addButton.disabled = !hasImage;
902
+ controls.deleteButton.disabled = disabled;
903
+
904
+ for (const input of [
905
+ controls.xInput,
906
+ controls.yInput,
907
+ controls.wInput,
908
+ controls.hInput
909
+ ]) {
910
+ input.disabled = disabled;
911
  }
912
 
913
+ if (selected) {
914
+ controls.xInput.value = selected.x;
915
+ controls.yInput.value = selected.y;
916
+ controls.wInput.value = selected.width;
917
+ controls.hInput.value = selected.height;
918
+ } else {
919
+ controls.xInput.value = "";
920
+ controls.yInput.value = "";
921
+ controls.wInput.value = "";
922
+ controls.hInput.value = "";
923
  }
924
 
925
+ controls.imageMeta.textContent = hasImage
926
+ ? `${state.imageName} · ${imageWidth()} × ${imageHeight()}`
927
+ : "未上传图片";
928
+ controls.boxMeta.textContent = `${state.boxes.length} 个标注`;
929
+ updateResults(selected);
930
+ }
931
+
932
+ function setStatus(message) {
933
+ controls.statusLine.textContent = message;
934
+ }
935
+
936
+ function sync() {
937
+ constrainAllBoxes();
938
+ draw();
939
+ updateControls();
940
+ }
941
+
942
+ function loadImageFile(file) {
943
+ if (!file || !file.type.startsWith("image/")) {
944
+ setStatus("请选择图片文件");
945
+ return;
946
  }
947
 
948
+ const reader = new FileReader();
949
+ reader.onload = () => {
950
+ const image = new Image();
951
+ image.onload = () => {
952
+ state.image = image;
953
+ state.imageName = file.name || "uploaded-image";
954
+ state.boxes = [];
955
+ state.selectedId = null;
956
+ state.nextId = 1;
957
+ setStatus(`已载入 ${state.imageName}`);
958
+ sync();
959
+ };
960
+ image.onerror = () => setStatus("图片读取失败");
961
+ image.src = reader.result;
962
+ };
963
+ reader.onerror = () => setStatus("图片读取失败");
964
+ reader.readAsDataURL(file);
965
+ }
966
 
967
+ function addCenteredBox() {
968
+ if (!state.image) {
969
+ return;
970
+ }
971
+ const width = Math.max(24, Math.round(imageWidth() * 0.28));
972
+ const height = Math.max(24, Math.round(imageHeight() * 0.22));
973
+ const x = Math.round((imageWidth() - width) / 2);
974
+ const y = Math.round((imageHeight() - height) / 2);
975
+ const box = makeBox(x, y, width, height);
976
+ state.boxes.push(box);
977
+ state.selectedId = box.id;
978
+ sync();
979
+ }
980
 
981
+ function deleteSelectedBox() {
982
+ if (!state.selectedId) {
983
+ return;
 
984
  }
985
+ state.boxes = state.boxes.filter((box) => box.id !== state.selectedId);
986
+ state.selectedId = state.boxes.length
987
+ ? state.boxes[state.boxes.length - 1].id
988
+ : null;
989
+ sync();
990
+ }
991
 
992
+ function updateSelectedFromInputs() {
993
+ const box = selectedBox();
994
+ if (!box || !state.image) {
995
+ return;
996
  }
 
 
997
 
998
+ const x = Number.parseInt(controls.xInput.value, 10);
999
+ const y = Number.parseInt(controls.yInput.value, 10);
1000
+ const width = Number.parseInt(controls.wInput.value, 10);
1001
+ const height = Number.parseInt(controls.hInput.value, 10);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1002
 
1003
+ box.x = clamp(x, 0, Math.max(0, imageWidth() - 1));
1004
+ box.y = clamp(y, 0, Math.max(0, imageHeight() - 1));
1005
+ box.width = clamp(width, 1, imageWidth() - box.x);
1006
+ box.height = clamp(height, 1, imageHeight() - box.y);
1007
+ sync();
1008
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1009
 
1010
+ function moveBox(box, startBox, deltaX, deltaY) {
1011
+ box.x = clamp(startBox.x + deltaX, 0, imageWidth() - startBox.width);
1012
+ box.y = clamp(startBox.y + deltaY, 0, imageHeight() - startBox.height);
1013
+ box.width = startBox.width;
1014
+ box.height = startBox.height;
1015
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1016
 
1017
+ function resizeBox(box, startBox, deltaX, deltaY, handle) {
1018
+ const minSize = 2;
1019
+ let x1 = startBox.x;
1020
+ let y1 = startBox.y;
1021
+ let x2 = startBox.x + startBox.width;
1022
+ let y2 = startBox.y + startBox.height;
1023
+
1024
+ if (handle.includes("w")) {
1025
+ x1 = clamp(startBox.x + deltaX, 0, x2 - minSize);
1026
+ }
1027
+ if (handle.includes("e")) {
1028
+ x2 = clamp(startBox.x + startBox.width + deltaX, x1 + minSize, imageWidth());
1029
+ }
1030
+ if (handle.includes("n")) {
1031
+ y1 = clamp(startBox.y + deltaY, 0, y2 - minSize);
1032
  }
1033
+ if (handle.includes("s")) {
1034
+ y2 = clamp(startBox.y + startBox.height + deltaY, y1 + minSize, imageHeight());
1035
+ }
1036
+
1037
+ box.x = x1;
1038
+ box.y = y1;
1039
+ box.width = x2 - x1;
1040
+ box.height = y2 - y1;
1041
+ }
1042
 
1043
+ function onPointerDown(event) {
1044
+ if (!state.image) {
1045
+ return;
1046
  }
1047
 
1048
+ const point = eventToImagePoint(event);
1049
+ const hit = hitTest(point);
1050
+ if (hit) {
1051
+ state.selectedId = hit.box.id;
1052
+ state.drag = {
1053
+ action: hit.action,
1054
+ handle: hit.handle,
1055
+ start: point,
1056
+ boxId: hit.box.id,
1057
+ startBox: { ...hit.box }
1058
+ };
1059
+ canvas.setPointerCapture(event.pointerId);
1060
+ sync();
1061
+ return;
1062
  }
1063
 
1064
+ const box = makeBox(point.x, point.y, 1, 1);
1065
+ state.boxes.push(box);
1066
+ state.selectedId = box.id;
1067
+ state.drag = {
1068
+ action: "create",
1069
+ handle: null,
1070
+ start: point,
1071
+ boxId: box.id,
1072
+ startBox: { ...box }
1073
+ };
1074
+ canvas.setPointerCapture(event.pointerId);
1075
+ sync();
1076
+ }
1077
+
1078
+ function onPointerMove(event) {
1079
+ if (!state.image) {
1080
+ canvas.style.cursor = "default";
1081
+ return;
 
1082
  }
1083
 
1084
+ const point = eventToImagePoint(event);
1085
+ if (!state.drag) {
1086
+ canvas.style.cursor = cursorForHit(hitTest(point));
1087
+ return;
 
1088
  }
1089
 
1090
+ const box = state.boxes.find((item) => item.id === state.drag.boxId);
1091
+ if (!box) {
1092
+ return;
 
 
 
 
 
 
 
 
 
1093
  }
1094
 
1095
+ const deltaX = point.x - state.drag.start.x;
1096
+ const deltaY = point.y - state.drag.start.y;
1097
+ if (state.drag.action === "create") {
1098
+ const x1 = clamp(Math.min(state.drag.start.x, point.x), 0, imageWidth());
1099
+ const y1 = clamp(Math.min(state.drag.start.y, point.y), 0, imageHeight());
1100
+ const x2 = clamp(Math.max(state.drag.start.x, point.x), 0, imageWidth());
1101
+ const y2 = clamp(Math.max(state.drag.start.y, point.y), 0, imageHeight());
1102
+ box.x = x1;
1103
+ box.y = y1;
1104
+ box.width = Math.max(1, x2 - x1);
1105
+ box.height = Math.max(1, y2 - y1);
1106
+ } else if (state.drag.action === "move") {
1107
+ moveBox(box, state.drag.startBox, deltaX, deltaY);
1108
+ } else if (state.drag.action === "resize") {
1109
+ resizeBox(box, state.drag.startBox, deltaX, deltaY, state.drag.handle);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1110
  }
1111
+ sync();
1112
+ }
1113
 
1114
+ function onPointerUp(event) {
1115
+ if (!state.drag) {
1116
+ return;
 
 
 
 
 
 
 
1117
  }
1118
 
1119
+ const createdBox = state.drag.action === "create" ? selectedBox() : null;
1120
+ if (createdBox && (createdBox.width < 3 || createdBox.height < 3)) {
1121
+ state.boxes = state.boxes.filter((box) => box.id !== createdBox.id);
1122
+ state.selectedId = null;
1123
+ }
1124
+ state.drag = null;
1125
+ if (canvas.hasPointerCapture(event.pointerId)) {
1126
+ canvas.releasePointerCapture(event.pointerId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1127
  }
1128
+ sync();
1129
+ }
1130
 
1131
+ controls.fileInput.addEventListener("change", (event) => {
1132
+ loadImageFile(event.target.files[0]);
1133
+ event.target.value = "";
1134
+ });
 
 
 
 
1135
 
1136
+ controls.fitButton.addEventListener("click", () => {
1137
+ resizeCanvas();
1138
+ setStatus("视图已适配");
1139
+ });
 
 
 
 
1140
 
1141
+ controls.clearButton.addEventListener("click", () => {
1142
+ state.boxes = [];
1143
+ state.selectedId = null;
1144
+ sync();
1145
+ });
 
 
 
1146
 
1147
+ controls.addButton.addEventListener("click", addCenteredBox);
1148
+ controls.deleteButton.addEventListener("click", deleteSelectedBox);
 
 
 
 
 
 
 
1149
 
1150
+ for (const input of [
1151
+ controls.xInput,
1152
+ controls.yInput,
1153
+ controls.wInput,
1154
+ controls.hInput
1155
+ ]) {
1156
+ input.addEventListener("input", updateSelectedFromInputs);
1157
+ }
1158
 
1159
+ canvas.addEventListener("pointerdown", onPointerDown);
1160
+ canvas.addEventListener("pointermove", onPointerMove);
1161
+ canvas.addEventListener("pointerup", onPointerUp);
1162
+ canvas.addEventListener("pointercancel", onPointerUp);
1163
+
1164
+ canvasWrap.addEventListener("dragover", (event) => {
1165
+ event.preventDefault();
1166
+ canvasWrap.classList.add("is-dragover");
1167
+ });
1168
+
1169
+ canvasWrap.addEventListener("dragleave", () => {
1170
+ canvasWrap.classList.remove("is-dragover");
1171
+ });
1172
+
1173
+ canvasWrap.addEventListener("drop", (event) => {
1174
+ event.preventDefault();
1175
+ canvasWrap.classList.remove("is-dragover");
1176
+ loadImageFile(event.dataTransfer.files[0]);
1177
+ });
1178
 
1179
+ document.addEventListener("keydown", (event) => {
1180
+ const activeTag = document.activeElement ? document.activeElement.tagName : "";
1181
+ const isTyping = ["INPUT", "TEXTAREA"].includes(activeTag);
1182
+ if (isTyping) {
1183
+ return;
1184
+ }
1185
+ if (event.key === "Delete" || event.key === "Backspace") {
1186
+ deleteSelectedBox();
1187
+ }
1188
+ });
1189
+
1190
+ new ResizeObserver(resizeCanvas).observe(canvasWrap);
1191
+ sync();
1192
+ })();
1193
+ </script>
1194
+ </body>
1195
+ </html>
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio>=5.0,<6