mr4 commited on
Commit
948c475
Β·
verified Β·
1 Parent(s): c1e95f2

Upload 2 files

Browse files
Files changed (3) hide show
  1. .gitattributes +1 -0
  2. hikari.jpg +3 -0
  3. index.html +1380 -18
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ hikari.jpg filter=lfs diff=lfs merge=lfs -text
hikari.jpg ADDED

Git LFS Details

  • SHA256: de59bbba0b6c1f4ee92ec5f41c76f58b9dfabc2ead320b015c36fcf6dad00638
  • Pointer size: 131 Bytes
  • Size of remote file: 266 kB
index.html CHANGED
@@ -1,19 +1,1381 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>YOLO Image Detection</title>
7
+ <style>
8
+ *, *::before, *::after {
9
+ box-sizing: border-box;
10
+ margin: 0;
11
+ padding: 0;
12
+ }
13
+
14
+ body {
15
+ font-family: system-ui, -apple-system, sans-serif;
16
+ background: #f0f2f5;
17
+ color: #1a1a2e;
18
+ min-height: 100vh;
19
+ padding: 24px 16px;
20
+ }
21
+
22
+ h1 {
23
+ text-align: center;
24
+ font-size: 1.75rem;
25
+ font-weight: 700;
26
+ margin-bottom: 24px;
27
+ color: #1a1a2e;
28
+ }
29
+
30
+ .container {
31
+ max-width: 1200px;
32
+ margin: 0 auto;
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: 20px;
36
+ }
37
+
38
+ /* Status */
39
+ #status {
40
+ text-align: center;
41
+ font-size: 0.95rem;
42
+ padding: 10px 16px;
43
+ border-radius: 8px;
44
+ background: #e8f4fd;
45
+ color: #1565c0;
46
+ min-height: 40px;
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+ transition: background 0.2s, color 0.2s;
51
+ }
52
+
53
+ #status.loading {
54
+ background: #e8f4fd;
55
+ color: #1565c0;
56
+ }
57
+
58
+ #status.error {
59
+ background: #fdecea;
60
+ color: #c62828;
61
+ }
62
+
63
+ #status.ready {
64
+ background: #e8f5e9;
65
+ color: #2e7d32;
66
+ }
67
+
68
+ #status.processing {
69
+ background: #fff8e1;
70
+ color: #f57f17;
71
+ }
72
+
73
+ /* Input area */
74
+ .input-area {
75
+ display: flex;
76
+ flex-direction: column;
77
+ align-items: center;
78
+ gap: 16px;
79
+ background: #fff;
80
+ border-radius: 12px;
81
+ padding: 24px;
82
+ box-shadow: 0 1px 4px rgba(0,0,0,0.08);
83
+ }
84
+
85
+ /* Source tabs */
86
+ .source-tabs {
87
+ display: flex;
88
+ gap: 8px;
89
+ }
90
+
91
+ .tab-btn {
92
+ padding: 8px 20px;
93
+ border: 2px solid #90caf9;
94
+ border-radius: 8px;
95
+ background: #fff;
96
+ color: #1565c0;
97
+ font-size: 0.9rem;
98
+ font-weight: 600;
99
+ cursor: pointer;
100
+ transition: background 0.2s, color 0.2s;
101
+ }
102
+
103
+ .tab-btn.active {
104
+ background: #1565c0;
105
+ color: #fff;
106
+ border-color: #1565c0;
107
+ }
108
+
109
+ .model-selector {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 10px;
113
+ width: 100%;
114
+ max-width: 400px;
115
+ }
116
+
117
+ .model-selector label {
118
+ font-size: 0.9rem;
119
+ font-weight: 600;
120
+ color: #555;
121
+ white-space: nowrap;
122
+ }
123
+
124
+ #model-select {
125
+ flex: 1;
126
+ padding: 8px 12px;
127
+ border: 1px solid #90caf9;
128
+ border-radius: 8px;
129
+ font-size: 0.95rem;
130
+ color: #1a1a2e;
131
+ background: #fff;
132
+ cursor: pointer;
133
+ }
134
+
135
+ #model-select:disabled {
136
+ opacity: 0.5;
137
+ cursor: not-allowed;
138
+ }
139
+
140
+ .file-label {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ gap: 8px;
144
+ cursor: pointer;
145
+ padding: 10px 20px;
146
+ border: 2px dashed #90caf9;
147
+ border-radius: 8px;
148
+ color: #1565c0;
149
+ font-size: 0.95rem;
150
+ transition: border-color 0.2s, background 0.2s;
151
+ }
152
+
153
+ .file-label:hover {
154
+ border-color: #1565c0;
155
+ background: #e8f4fd;
156
+ }
157
+
158
+ .btn-sample {
159
+ background: none;
160
+ border: none;
161
+ color: #1565c0;
162
+ font-size: 0.85rem;
163
+ cursor: pointer;
164
+ text-decoration: underline;
165
+ padding: 2px 4px;
166
+ opacity: 0.75;
167
+ transition: opacity 0.2s;
168
+ }
169
+
170
+ .btn-sample:hover {
171
+ opacity: 1;
172
+ }
173
+
174
+ #file-input {
175
+ display: none;
176
+ }
177
+
178
+ #detect-btn {
179
+ padding: 10px 32px;
180
+ font-size: 1rem;
181
+ font-weight: 600;
182
+ background: #1565c0;
183
+ color: #fff;
184
+ border: none;
185
+ border-radius: 8px;
186
+ cursor: pointer;
187
+ transition: background 0.2s, opacity 0.2s;
188
+ }
189
+
190
+ #detect-btn:hover:not(:disabled) {
191
+ background: #0d47a1;
192
+ }
193
+
194
+ #detect-btn:disabled {
195
+ opacity: 0.5;
196
+ cursor: not-allowed;
197
+ }
198
+
199
+ /* Webcam */
200
+ #webcam-panel { display: none; flex-direction: column; align-items: center; gap: 10px; width: 100%; }
201
+ #webcam-panel.active { display: flex; }
202
+ #image-panel { display: flex; flex-direction: column; align-items: center; gap: 10px; }
203
+ #image-panel.hidden { display: none; }
204
+
205
+ #webcam-video {
206
+ max-width: 100%;
207
+ border-radius: 8px;
208
+ border: 1px solid #e0e0e0;
209
+ background: #111;
210
+ display: none;
211
+ }
212
+
213
+ .webcam-controls {
214
+ display: flex;
215
+ gap: 10px;
216
+ flex-wrap: wrap;
217
+ justify-content: center;
218
+ }
219
+
220
+ .btn-secondary {
221
+ padding: 8px 20px;
222
+ font-size: 0.9rem;
223
+ font-weight: 600;
224
+ background: #fff;
225
+ color: #1565c0;
226
+ border: 2px solid #1565c0;
227
+ border-radius: 8px;
228
+ cursor: pointer;
229
+ transition: background 0.2s;
230
+ }
231
+
232
+ .btn-secondary:hover:not(:disabled) { background: #e8f4fd; }
233
+ .btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
234
+
235
+ .btn-danger {
236
+ padding: 8px 20px;
237
+ font-size: 0.9rem;
238
+ font-weight: 600;
239
+ background: #fff;
240
+ color: #c62828;
241
+ border: 2px solid #c62828;
242
+ border-radius: 8px;
243
+ cursor: pointer;
244
+ transition: background 0.2s;
245
+ }
246
+
247
+ .btn-danger:hover:not(:disabled) { background: #fdecea; }
248
+
249
+ /* Timing info */
250
+ #timing-bar {
251
+ display: none;
252
+ align-items: center;
253
+ gap: 16px;
254
+ background: #fff;
255
+ border-radius: 12px;
256
+ padding: 10px 20px;
257
+ box-shadow: 0 1px 4px rgba(0,0,0,0.08);
258
+ font-size: 0.88rem;
259
+ color: #555;
260
+ flex-wrap: wrap;
261
+ }
262
+
263
+ #timing-bar.visible { display: flex; }
264
+
265
+ .timing-item { display: flex; align-items: center; gap: 6px; }
266
+ .timing-label { color: #888; }
267
+ .timing-value { font-weight: 700; color: #1565c0; }
268
+
269
+ /* Canvas area */
270
+ .canvas-area {
271
+ display: grid;
272
+ grid-template-columns: 1fr 1fr;
273
+ gap: 16px;
274
+ }
275
+
276
+ @media (max-width: 700px) {
277
+ .canvas-area {
278
+ grid-template-columns: 1fr;
279
+ }
280
+ }
281
+
282
+ .canvas-wrapper {
283
+ background: #fff;
284
+ border-radius: 12px;
285
+ padding: 16px;
286
+ box-shadow: 0 1px 4px rgba(0,0,0,0.08);
287
+ display: flex;
288
+ flex-direction: column;
289
+ align-items: center;
290
+ gap: 10px;
291
+ }
292
+
293
+ .canvas-wrapper h2 {
294
+ font-size: 1rem;
295
+ font-weight: 600;
296
+ color: #555;
297
+ }
298
+
299
+ canvas {
300
+ max-width: 100%;
301
+ border-radius: 6px;
302
+ background: #f5f5f5;
303
+ border: 1px solid #e0e0e0;
304
+ display: block;
305
+ }
306
+
307
+ /* Canvas wrapper β€” position:relative để magnifier tΓ­nh toΓ‘n offset */
308
+ .canvas-wrapper {
309
+ position: relative;
310
+ }
311
+
312
+ /* Magnifier lens */
313
+ #magnifier {
314
+ position: fixed;
315
+ width: 180px;
316
+ height: 180px;
317
+ border-radius: 50%;
318
+ border: 3px solid #1565c0;
319
+ box-shadow: 0 4px 20px rgba(0,0,0,0.35);
320
+ pointer-events: none;
321
+ display: none;
322
+ overflow: hidden;
323
+ z-index: 9999;
324
+ background: #111;
325
+ }
326
+
327
+ #magnifier canvas {
328
+ position: absolute;
329
+ top: 0;
330
+ left: 0;
331
+ border: none;
332
+ border-radius: 0;
333
+ background: transparent;
334
+ max-width: none;
335
+ }
336
+
337
+ /* Zoom control bar */
338
+ #zoom-bar {
339
+ display: flex;
340
+ align-items: center;
341
+ gap: 10px;
342
+ background: #fff;
343
+ border-radius: 12px;
344
+ padding: 12px 20px;
345
+ box-shadow: 0 1px 4px rgba(0,0,0,0.08);
346
+ font-size: 0.9rem;
347
+ color: #555;
348
+ }
349
+
350
+ #zoom-bar label {
351
+ font-weight: 600;
352
+ white-space: nowrap;
353
+ }
354
+
355
+ #zoom-slider {
356
+ flex: 1;
357
+ max-width: 200px;
358
+ accent-color: #1565c0;
359
+ cursor: pointer;
360
+ }
361
+
362
+ #zoom-value {
363
+ font-weight: 700;
364
+ color: #1565c0;
365
+ min-width: 28px;
366
+ text-align: right;
367
+ }
368
+
369
+ /* Stats table */
370
+ #table-section {
371
+ background: #fff;
372
+ border-radius: 12px;
373
+ padding: 20px;
374
+ box-shadow: 0 1px 4px rgba(0,0,0,0.08);
375
+ display: none;
376
+ }
377
+
378
+ #table-section h2 {
379
+ font-size: 1rem;
380
+ font-weight: 600;
381
+ margin-bottom: 12px;
382
+ color: #555;
383
+ }
384
+
385
+ #detection-table {
386
+ width: 100%;
387
+ border-collapse: collapse;
388
+ font-size: 0.9rem;
389
+ }
390
+
391
+ #detection-table thead tr {
392
+ background: #e3f2fd;
393
+ }
394
+
395
+ #detection-table th,
396
+ #detection-table td {
397
+ padding: 10px 14px;
398
+ text-align: left;
399
+ border-bottom: 1px solid #e0e0e0;
400
+ }
401
+
402
+ #detection-table th {
403
+ font-weight: 600;
404
+ color: #1565c0;
405
+ }
406
+
407
+ #detection-table tbody tr:hover {
408
+ background: #f5f5f5;
409
+ }
410
+
411
+ #detection-table tbody tr:last-child td {
412
+ border-bottom: none;
413
+ }
414
+ </style>
415
+ </head>
416
+ <body>
417
+ <div class="container">
418
+ <h1>YOLO Image Detection</h1>
419
+
420
+ <div id="status">Đang khởi tẑo...</div>
421
+
422
+ <div class="input-area">
423
+ <div class="model-selector">
424
+ <label for="model-select">Model:</label>
425
+ <select id="model-select" disabled></select>
426
+ </div>
427
+
428
+ <!-- Source tabs -->
429
+ <div class="source-tabs">
430
+ <button class="tab-btn active" id="tab-image">πŸ–Ό αΊ’nh</button>
431
+ <button class="tab-btn" id="tab-webcam">πŸ“· Webcam</button>
432
+ </div>
433
+
434
+ <!-- Image panel -->
435
+ <div id="image-panel">
436
+ <label class="file-label" for="file-input">
437
+ πŸ“ Chọn αΊ£nh (PNG, JPG, WEBP)
438
+ </label>
439
+ <input type="file" id="file-input" accept="image/png,image/jpeg,image/webp" />
440
+ <button id="sample-btn" class="btn-sample">or try sample</button>
441
+ <button id="detect-btn" disabled>Detect</button>
442
+ </div>
443
+
444
+ <!-- Webcam panel -->
445
+ <div id="webcam-panel">
446
+ <video id="webcam-video" autoplay playsinline muted width="640" height="480"></video>
447
+ <div class="webcam-controls">
448
+ <button class="btn-secondary" id="webcam-start-btn">β–Ά BαΊ­t Webcam</button>
449
+ <button class="btn-secondary" id="webcam-detect-btn" disabled>⏯ BαΊ―t Δ‘αΊ§u nhαΊ­n diện</button>
450
+ <button class="btn-secondary" id="webcam-capture-btn" disabled>πŸ“‹ Capture β†’ Clipboard</button>
451
+ <button class="btn-danger" id="webcam-stop-btn" disabled>β–  Dα»«ng</button>
452
+ </div>
453
+ </div>
454
+ </div>
455
+
456
+ <div class="canvas-area">
457
+ <div class="canvas-wrapper">
458
+ <h2>αΊ’nh gα»‘c</h2>
459
+ <canvas id="original-canvas" width="640" height="480"></canvas>
460
+ </div>
461
+ <div class="canvas-wrapper">
462
+ <h2>KαΊΏt quαΊ£ nhαΊ­n diện</h2>
463
+ <canvas id="result-canvas" width="640" height="480"></canvas>
464
+ </div>
465
+ </div>
466
+
467
+ <!-- Timing info -->
468
+ <div id="timing-bar">
469
+ <div class="timing-item">
470
+ <span class="timing-label">⏱ Thời gian nhαΊ­n diện:</span>
471
+ <span class="timing-value" id="timing-inference">β€”</span>
472
+ </div>
473
+ <div class="timing-item" id="fps-item" style="display:none">
474
+ <span class="timing-label">🎞 FPS:</span>
475
+ <span class="timing-value" id="timing-fps">β€”</span>
476
+ </div>
477
+ </div>
478
+
479
+ <!-- Zoom control -->
480
+ <div id="zoom-bar">
481
+ <label for="zoom-slider">πŸ” KΓ­nh lΓΊp:</label>
482
+ <input type="range" id="zoom-slider" min="1" max="5" step="0.5" value="2" />
483
+ <span id="zoom-value">Γ—2</span>
484
+ <span style="color:#bbb;margin:0 4px">|</span>
485
+ <label for="size-slider" style="white-space:nowrap">KΓ­ch thΖ°α»›c:</label>
486
+ <input type="range" id="size-slider" min="100" max="300" step="10" value="180" />
487
+ <span id="size-value">180px</span>
488
+ </div>
489
+
490
+ <!-- Magnifier lens (follows cursor) -->
491
+ <div id="magnifier">
492
+ <canvas id="magnifier-canvas" width="180" height="180"></canvas>
493
+ </div>
494
+
495
+ <div id="table-section">
496
+ <h2>Thα»‘ng kΓͺ kαΊΏt quαΊ£</h2>
497
+ <table id="detection-table">
498
+ <thead>
499
+ <tr>
500
+ <th>TΓͺn Class</th>
501
+ <th>Sα»‘ Lượng</th>
502
+ <th>Confidence Trung Bình</th>
503
+ </tr>
504
+ </thead>
505
+ <tbody id="table-body"></tbody>
506
+ </table>
507
+ </div>
508
+ </div>
509
+
510
+ <!-- ONNX Runtime Web via CDN -->
511
+ <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
512
+ <!-- App logic -->
513
+ <script>
514
+ // ── State ────────────────────────────────────────────────────────────────
515
+ let session = null;
516
+ let classes = [];
517
+
518
+ // ── UIController ─────────────────────────────────────────────────────────
519
+ /**
520
+ * Update the #status element.
521
+ * @param {'loading'|'ready'|'processing'|'error'} state
522
+ * @param {string} [message]
523
+ */
524
+ function setStatus(state, message) {
525
+ const el = document.getElementById('status');
526
+ el.className = state;
527
+ const defaults = {
528
+ loading: 'Đang tải...',
529
+ ready: 'SαΊ΅n sΓ ng',
530
+ processing: 'Đang xử lý...',
531
+ error: 'Lα»—i',
532
+ };
533
+ el.textContent = message ?? defaults[state] ?? '';
534
+ }
535
+
536
+ /**
537
+ * Clear the result canvas and hide the stats table.
538
+ */
539
+ function clearResults() {
540
+ const canvas = document.getElementById('result-canvas');
541
+ canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
542
+
543
+ const tableSection = document.getElementById('table-section');
544
+ tableSection.style.display = 'none';
545
+
546
+ document.getElementById('table-body').innerHTML = '';
547
+ }
548
+
549
+ // ── ModelLoader ───────────────────────────────────────────────────────────
550
+ /**
551
+ * Load the ONNX model from the given path.
552
+ * @param {string} modelPath
553
+ * @returns {Promise<ort.InferenceSession>}
554
+ */
555
+ async function loadModel(modelPath) {
556
+ return await ort.InferenceSession.create(modelPath);
557
+ }
558
+
559
+ /**
560
+ * Fetch and parse the class list (one class name per line).
561
+ * @param {string} classesPath
562
+ * @returns {Promise<string[]>}
563
+ */
564
+ async function loadClasses(classesPath) {
565
+ const response = await fetch(classesPath);
566
+ if (!response.ok) {
567
+ throw new Error(`KhΓ΄ng thể tαΊ£i classes: ${response.status} ${response.statusText}`);
568
+ }
569
+ const text = await response.text();
570
+ return text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
571
+ }
572
+
573
+ // ── ModelRegistry ─────────────────────────────────────────────────────────
574
+ /**
575
+ * Fetch and parse models/registry.json.
576
+ * @returns {Promise<Array<{id: string, name: string, modelPath: string, classesPath: string}>>}
577
+ */
578
+ async function loadRegistry() {
579
+ const response = await fetch('models/registry.json');
580
+ if (!response.ok) throw new Error(`KhΓ΄ng thể tαΊ£i registry: ${response.status}`);
581
+ const data = await response.json();
582
+ return data.models;
583
+ }
584
+
585
+ /**
586
+ * Populate the model <select> dropdown.
587
+ * @param {Array<{id: string, name: string}>} models
588
+ */
589
+ function populateModelDropdown(models) {
590
+ const select = document.getElementById('model-select');
591
+ select.innerHTML = '';
592
+ models.forEach((m, i) => {
593
+ const opt = document.createElement('option');
594
+ opt.value = i;
595
+ opt.textContent = m.name;
596
+ select.appendChild(opt);
597
+ });
598
+ }
599
+
600
+ // ── State (image) ─────────────────────────────────────────────────────────
601
+ let currentImage = null; // HTMLImageElement of the currently selected image
602
+ let registry = []; // ModelEntry[]
603
+
604
+ // ── File Input Handler ────────────────────────────────────────────────────
605
+ const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
606
+ const MAX_CANVAS_SIZE = 640;
607
+
608
+ document.getElementById('file-input').addEventListener('change', function (e) {
609
+ const file = e.target.files[0];
610
+ if (!file) return;
611
+
612
+ if (!ACCEPTED_TYPES.includes(file.type)) {
613
+ setStatus('error', 'Định dαΊ‘ng khΓ΄ng hợp lệ. Chỉ chαΊ₯p nhαΊ­n PNG, JPG, WEBP.');
614
+ return;
615
+ }
616
+
617
+ clearResults();
618
+
619
+ const reader = new FileReader();
620
+ reader.onload = function (readerEvent) {
621
+ const img = new Image();
622
+ img.onload = function () {
623
+ currentImage = img;
624
+
625
+ const canvas = document.getElementById('original-canvas');
626
+ // Fit within MAX_CANVAS_SIZE while preserving aspect ratio
627
+ let drawW = img.naturalWidth;
628
+ let drawH = img.naturalHeight;
629
+ if (drawW > MAX_CANVAS_SIZE || drawH > MAX_CANVAS_SIZE) {
630
+ const ratio = Math.min(MAX_CANVAS_SIZE / drawW, MAX_CANVAS_SIZE / drawH);
631
+ drawW = Math.round(drawW * ratio);
632
+ drawH = Math.round(drawH * ratio);
633
+ }
634
+ canvas.width = drawW;
635
+ canvas.height = drawH;
636
+ canvas.getContext('2d').drawImage(img, 0, 0, drawW, drawH);
637
+ };
638
+ img.src = readerEvent.target.result;
639
+ };
640
+ reader.readAsDataURL(file);
641
+ });
642
+
643
+ // ── Sample Image Handler ──────────────────────────────────────────────────
644
+ document.getElementById('sample-btn').addEventListener('click', function () {
645
+ clearResults();
646
+ const img = new Image();
647
+ img.onload = function () {
648
+ currentImage = img;
649
+ const canvas = document.getElementById('original-canvas');
650
+ let drawW = img.naturalWidth;
651
+ let drawH = img.naturalHeight;
652
+ if (drawW > MAX_CANVAS_SIZE || drawH > MAX_CANVAS_SIZE) {
653
+ const ratio = Math.min(MAX_CANVAS_SIZE / drawW, MAX_CANVAS_SIZE / drawH);
654
+ drawW = Math.round(drawW * ratio);
655
+ drawH = Math.round(drawH * ratio);
656
+ }
657
+ canvas.width = drawW;
658
+ canvas.height = drawH;
659
+ canvas.getContext('2d').drawImage(img, 0, 0, drawW, drawH);
660
+ };
661
+ img.src = 'hikari.jpg';
662
+ });
663
+
664
+ // ── ImagePreprocessor ─────────────────────────────────────────────────────
665
+ const MODEL_INPUT_SIZE = 640;
666
+ const PAD_VALUE = 128 / 255.0; // gray padding normalized
667
+
668
+ /**
669
+ * Resize and letterbox-pad an image to 640Γ—640, returning a Float32Array
670
+ * tensor in CHW format (shape [1, 3, 640, 640]) with values normalized to
671
+ * [0, 1], plus the scale and padding info needed to map detections back to
672
+ * the original image space.
673
+ *
674
+ * @param {HTMLImageElement} imageElement
675
+ * @returns {{ tensor: Float32Array, scaleX: number, scaleY: number, padX: number, padY: number }}
676
+ */
677
+ function preprocessImage(imageElement) {
678
+ const origW = imageElement.naturalWidth;
679
+ const origH = imageElement.naturalHeight;
680
+
681
+ // Compute uniform scale so the image fits within 640Γ—640
682
+ const scale = Math.min(MODEL_INPUT_SIZE / origW, MODEL_INPUT_SIZE / origH);
683
+ const scaledW = Math.min(Math.max(1, Math.round(origW * scale)), MODEL_INPUT_SIZE);
684
+ const scaledH = Math.min(Math.max(1, Math.round(origH * scale)), MODEL_INPUT_SIZE);
685
+
686
+ // Padding to center the scaled image within the 640Γ—640 canvas
687
+ const padX = Math.floor((MODEL_INPUT_SIZE - scaledW) / 2);
688
+ const padY = Math.floor((MODEL_INPUT_SIZE - scaledH) / 2);
689
+
690
+ // Draw onto an offscreen canvas
691
+ const canvas = new OffscreenCanvas(MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
692
+ const ctx = canvas.getContext('2d');
693
+
694
+ // Fill with gray padding (128, 128, 128)
695
+ ctx.fillStyle = `rgb(128, 128, 128)`;
696
+ ctx.fillRect(0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
697
+
698
+ // Draw the scaled image centered
699
+ ctx.drawImage(imageElement, padX, padY, scaledW, scaledH);
700
+
701
+ // Read pixel data (RGBA, HWC layout)
702
+ const imageData = ctx.getImageData(0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
703
+ const pixels = imageData.data; // Uint8ClampedArray, length = 640*640*4
704
+
705
+ // Build CHW Float32Array: [R flat, G flat, B flat]
706
+ const numPixels = MODEL_INPUT_SIZE * MODEL_INPUT_SIZE;
707
+ const tensor = new Float32Array(3 * numPixels);
708
+
709
+ for (let i = 0; i < numPixels; i++) {
710
+ tensor[i] = pixels[i * 4] / 255.0; // R
711
+ tensor[numPixels + i] = pixels[i * 4 + 1] / 255.0; // G
712
+ tensor[2 * numPixels + i] = pixels[i * 4 + 2] / 255.0; // B
713
+ }
714
+
715
+ return {
716
+ tensor,
717
+ scaleX: scaledW / origW,
718
+ scaleY: scaledH / origH,
719
+ padX,
720
+ padY,
721
+ };
722
+ }
723
+
724
+ // ── NMS ──────────────────────────────────────────────────────────────────
725
+
726
+ /**
727
+ * Compute Intersection over Union (IoU) between two bounding boxes.
728
+ * Boxes are in { x, y, width, height } format where (x, y) is top-left.
729
+ *
730
+ * @param {{ x: number, y: number, width: number, height: number }} boxA
731
+ * @param {{ x: number, y: number, width: number, height: number }} boxB
732
+ * @returns {number} IoU value in [0, 1]
733
+ */
734
+ function computeIoU(boxA, boxB) {
735
+ const xA1 = boxA.x, yA1 = boxA.y, xA2 = boxA.x + boxA.width, yA2 = boxA.y + boxA.height;
736
+ const xB1 = boxB.x, yB1 = boxB.y, xB2 = boxB.x + boxB.width, yB2 = boxB.y + boxB.height;
737
+
738
+ const interX1 = Math.max(xA1, xB1);
739
+ const interY1 = Math.max(yA1, yB1);
740
+ const interX2 = Math.min(xA2, xB2);
741
+ const interY2 = Math.min(yA2, yB2);
742
+
743
+ const interW = Math.max(0, interX2 - interX1);
744
+ const interH = Math.max(0, interY2 - interY1);
745
+ const intersection = interW * interH;
746
+
747
+ if (intersection === 0) return 0;
748
+
749
+ const areaA = boxA.width * boxA.height;
750
+ const areaB = boxB.width * boxB.height;
751
+ const union = areaA + areaB - intersection;
752
+
753
+ return union <= 0 ? 0 : intersection / union;
754
+ }
755
+
756
+ /**
757
+ * Apply Non-Maximum Suppression to a list of detections.
758
+ * Detections are sorted by confidence descending; boxes of the same class
759
+ * with IoU > iouThreshold are suppressed, keeping the highest-confidence box.
760
+ *
761
+ * @param {Array<{ classIndex: number, className: string, confidence: number, box: { x: number, y: number, width: number, height: number } }>} detections
762
+ * @param {number} iouThreshold β€” typically 0.45
763
+ * @returns {Array} filtered detections
764
+ */
765
+ function applyNMS(detections, iouThreshold) {
766
+ // Sort by confidence descending
767
+ const sorted = detections.slice().sort((a, b) => b.confidence - a.confidence);
768
+
769
+ const kept = [];
770
+ const suppressed = new Uint8Array(sorted.length);
771
+
772
+ for (let i = 0; i < sorted.length; i++) {
773
+ if (suppressed[i]) continue;
774
+ kept.push(sorted[i]);
775
+ for (let j = i + 1; j < sorted.length; j++) {
776
+ if (suppressed[j]) continue;
777
+ if (sorted[i].classIndex !== sorted[j].classIndex) continue;
778
+ if (computeIoU(sorted[i].box, sorted[j].box) > iouThreshold) {
779
+ suppressed[j] = 1;
780
+ }
781
+ }
782
+ }
783
+
784
+ return kept;
785
+ }
786
+
787
+ /**
788
+ * Filter detections by confidence threshold.
789
+ * Only detections with confidence >= threshold are kept.
790
+ *
791
+ * @param {Array<{ classIndex: number, className: string, confidence: number, box: { x: number, y: number, width: number, height: number } }>} detections
792
+ * @param {number} [threshold=0.25]
793
+ * @returns {Array} filtered detections
794
+ */
795
+ function filterByConfidence(detections, threshold = 0.25) {
796
+ return detections.filter(d => d.confidence >= threshold);
797
+ }
798
+
799
+ // ── Detector ─────────────────────────────────────────────────────────────
800
+
801
+ /**
802
+ * Parse the raw YOLO output tensor of shape [1, 14, 8400].
803
+ * For each of the 8400 anchors, extracts cx, cy, w, h and 10 class scores,
804
+ * then computes confidence = max(classScores) and classIndex = argmax(classScores).
805
+ * Returns raw detections (before confidence filtering and NMS) with boxes
806
+ * expressed in 640Γ—640 space.
807
+ *
808
+ * @param {Float32Array} outputData β€” flat array of length 14 * 8400
809
+ * @param {string[]} classes β€” array of class name strings
810
+ * @returns {Array<{ classIndex: number, className: string, confidence: number, box: { x: number, y: number, width: number, height: number } }>}
811
+ */
812
+ function parseOutputTensor(outputData, classes) {
813
+ const NUM_ANCHORS = 8400;
814
+ const detections = [];
815
+
816
+ for (let i = 0; i < NUM_ANCHORS; i++) {
817
+ const cx = outputData[0 * NUM_ANCHORS + i];
818
+ const cy = outputData[1 * NUM_ANCHORS + i];
819
+ const w = outputData[2 * NUM_ANCHORS + i];
820
+ const h = outputData[3 * NUM_ANCHORS + i];
821
+
822
+ let confidence = -Infinity;
823
+ let classIndex = 0;
824
+
825
+ for (let c = 0; c < classes.length; c++) {
826
+ const score = outputData[(4 + c) * NUM_ANCHORS + i];
827
+ if (score > confidence) {
828
+ confidence = score;
829
+ classIndex = c;
830
+ }
831
+ }
832
+
833
+ detections.push({
834
+ classIndex,
835
+ className: classes[classIndex],
836
+ confidence,
837
+ box: {
838
+ x: cx - w / 2,
839
+ y: cy - h / 2,
840
+ width: w,
841
+ height: h,
842
+ },
843
+ });
844
+ }
845
+
846
+ return detections;
847
+ }
848
+
849
+ /**
850
+ * Scale bounding box coordinates from 640Γ—640 model space back to original
851
+ * image space, accounting for letterbox padding.
852
+ *
853
+ * @param {Array<{ classIndex: number, className: string, confidence: number, box: { x: number, y: number, width: number, height: number } }>} detections
854
+ * @param {number} scaleX β€” scaledW / origW
855
+ * @param {number} scaleY β€” scaledH / origH
856
+ * @param {number} padX β€” horizontal padding (px in 640 space)
857
+ * @param {number} padY β€” vertical padding (px in 640 space)
858
+ * @returns {Array} new array of detections with boxes in original image space
859
+ */
860
+ function scaleDetections(detections, scaleX, scaleY, padX, padY) {
861
+ return detections.map(det => {
862
+ const { x, y, width, height } = det.box;
863
+ return {
864
+ ...det,
865
+ box: {
866
+ x: Math.max(0, (x - padX) / scaleX),
867
+ y: Math.max(0, (y - padY) / scaleY),
868
+ width: width / scaleX,
869
+ height: height / scaleY,
870
+ },
871
+ };
872
+ });
873
+ }
874
+
875
+ /**
876
+ * Run full detection pipeline: preprocess result β†’ inference β†’ parse β†’ filter β†’ NMS β†’ scale.
877
+ *
878
+ * @param {ort.InferenceSession} session
879
+ * @param {{ tensor: Float32Array, scaleX: number, scaleY: number, padX: number, padY: number }} preprocessResult
880
+ * @param {string[]} classes
881
+ * @param {number} confidenceThreshold β€” e.g. 0.25
882
+ * @param {number} iouThreshold β€” e.g. 0.45
883
+ * @returns {Promise<Array<{ classIndex: number, className: string, confidence: number, box: { x: number, y: number, width: number, height: number } }>>}
884
+ */
885
+ async function runDetection(session, preprocessResult, classes, confidenceThreshold, iouThreshold) {
886
+ const { tensor, scaleX, scaleY, padX, padY } = preprocessResult;
887
+
888
+ // 1. Create ORT tensor from Float32Array with shape [1, 3, 640, 640]
889
+ const ortTensor = new ort.Tensor('float32', tensor, [1, 3, 640, 640]);
890
+
891
+ // 2. Run inference
892
+ const results = await session.run({ images: ortTensor });
893
+
894
+ // 3. Get output data
895
+ const outputData = results[Object.keys(results)[0]].data;
896
+
897
+ // 4. Parse raw output tensor
898
+ const rawDetections = parseOutputTensor(outputData, classes);
899
+
900
+ // 5. Filter by confidence
901
+ const filtered = filterByConfidence(rawDetections, confidenceThreshold);
902
+
903
+ // 6. Apply NMS
904
+ const nmsResult = applyNMS(filtered, iouThreshold);
905
+
906
+ // 7. Scale boxes back to original image space
907
+ return scaleDetections(nmsResult, scaleX, scaleY, padX, padY);
908
+ }
909
+
910
+ // ── Renderer ─────────────────────────────────────────────────────────────
911
+
912
+ /**
913
+ * Get HSL color for a class index, distributed evenly across the hue wheel.
914
+ * @param {number} classIndex
915
+ * @param {number} numClasses
916
+ * @returns {string}
917
+ */
918
+ function getClassColor(classIndex, numClasses) {
919
+ const hue = Math.round((classIndex / Math.max(numClasses, 1)) * 360);
920
+ return `hsl(${hue}, 80%, 55%)`;
921
+ }
922
+
923
+ /**
924
+ * Draw the image on the canvas, then overlay bounding boxes and labels for
925
+ * each detection.
926
+ *
927
+ * @param {HTMLCanvasElement} canvas
928
+ * @param {HTMLImageElement} image
929
+ * @param {Array<{ className: string, confidence: number, box: { x: number, y: number, width: number, height: number } }>} detections
930
+ * @param {Map<string, string>} classColors β€” maps className β†’ CSS color string
931
+ */
932
+ function drawDetections(canvas, image, detections, classColors) {
933
+ // 1. Resize canvas to match the image's natural dimensions
934
+ canvas.width = image.naturalWidth;
935
+ canvas.height = image.naturalHeight;
936
+
937
+ const ctx = canvas.getContext('2d');
938
+
939
+ // 2. Draw the source image
940
+ ctx.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight);
941
+
942
+ // 3. Draw each detection
943
+ ctx.lineWidth = 2;
944
+ ctx.font = 'bold 14px system-ui, sans-serif';
945
+
946
+ for (const det of detections) {
947
+ const { x, y, width, height } = det.box;
948
+ const color = getClassColor(det.classIndex, classes.length);
949
+ const label = `${det.className}: ${det.confidence.toFixed(2)}`;
950
+
951
+ // Bounding box
952
+ ctx.strokeStyle = color;
953
+ ctx.strokeRect(x, y, width, height);
954
+
955
+ // Label background
956
+ const textMetrics = ctx.measureText(label);
957
+ const textW = textMetrics.width + 6;
958
+ const textH = 18;
959
+ const labelY = y > textH ? y - textH : y + height;
960
+
961
+ ctx.fillStyle = color;
962
+ ctx.fillRect(x, labelY, textW, textH);
963
+
964
+ // Label text
965
+ ctx.fillStyle = '#ffffff';
966
+ ctx.fillText(label, x + 3, labelY + 13);
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Aggregate detections into per-class stats, then render the #detection-table.
972
+ * If detections is empty, hides #table-section and returns.
973
+ *
974
+ * @param {Array<{ className: string, confidence: number }>} detections
975
+ */
976
+ function renderTable(detections) {
977
+ const tableSection = document.getElementById('table-section');
978
+
979
+ if (!detections || detections.length === 0) {
980
+ tableSection.style.display = 'none';
981
+ return;
982
+ }
983
+
984
+ // Aggregate: count occurrences and sum confidences per class
985
+ /** @type {Map<string, { count: number, sumConfidence: number }>} */
986
+ const statsMap = new Map();
987
+ for (const det of detections) {
988
+ const existing = statsMap.get(det.className);
989
+ if (existing) {
990
+ existing.count += 1;
991
+ existing.sumConfidence += det.confidence;
992
+ } else {
993
+ statsMap.set(det.className, { count: 1, sumConfidence: det.confidence });
994
+ }
995
+ }
996
+
997
+ // Build ClassStats array and calculate avgConfidence
998
+ const stats = [];
999
+ for (const [className, { count, sumConfidence }] of statsMap) {
1000
+ stats.push({ className, count, avgConfidence: sumConfidence / count });
1001
+ }
1002
+
1003
+ // Sort by count descending
1004
+ stats.sort((a, b) => b.count - a.count);
1005
+
1006
+ // Render rows
1007
+ const tbody = document.getElementById('table-body');
1008
+ tbody.innerHTML = '';
1009
+ for (const { className, count, avgConfidence } of stats) {
1010
+ const tr = document.createElement('tr');
1011
+ tr.innerHTML = `
1012
+ <td>${className}</td>
1013
+ <td>${count}</td>
1014
+ <td>${(avgConfidence * 100).toFixed(1)}%</td>
1015
+ `;
1016
+ tbody.appendChild(tr);
1017
+ }
1018
+
1019
+ tableSection.style.display = 'block';
1020
+ }
1021
+
1022
+ // ── Initialisation ────────────────────────────────────────────────────────
1023
+ (async function init() {
1024
+ const detectBtn = document.getElementById('detect-btn');
1025
+ const modelSelect = document.getElementById('model-select');
1026
+ detectBtn.disabled = true;
1027
+ modelSelect.disabled = true;
1028
+ setStatus('loading', 'Đang tải danh sÑch model...');
1029
+
1030
+ try {
1031
+ registry = await loadRegistry();
1032
+ populateModelDropdown(registry);
1033
+ modelSelect.disabled = false;
1034
+ await loadSelectedModel();
1035
+ } catch (err) {
1036
+ console.error('Khởi tαΊ‘o thαΊ₯t bαΊ‘i:', err);
1037
+ setStatus('error', `Lα»—i khởi tαΊ‘o: ${err.message}`);
1038
+ detectBtn.disabled = true;
1039
+ }
1040
+ })();
1041
+
1042
+ /**
1043
+ * Load the model currently selected in the dropdown.
1044
+ */
1045
+ async function loadSelectedModel() {
1046
+ const detectBtn = document.getElementById('detect-btn');
1047
+ const modelSelect = document.getElementById('model-select');
1048
+ const entry = registry[parseInt(modelSelect.value, 10)];
1049
+ if (!entry) return;
1050
+
1051
+ detectBtn.disabled = true;
1052
+ setStatus('loading', `Đang tải model "${entry.name}"...`);
1053
+
1054
+ try {
1055
+ [session, classes] = await Promise.all([
1056
+ loadModel(entry.modelPath),
1057
+ loadClasses(entry.classesPath),
1058
+ ]);
1059
+ setStatus('ready', `SαΊ΅n sΓ ng β€” ${entry.name} (${classes.length} class)`);
1060
+ detectBtn.disabled = false;
1061
+ } catch (err) {
1062
+ console.error('Load model thαΊ₯t bαΊ‘i:', err);
1063
+ setStatus('error', `Lα»—i tαΊ£i model: ${err.message}`);
1064
+ detectBtn.disabled = true;
1065
+ }
1066
+ }
1067
+
1068
+ // ── Model Selector Handler ────────────────────────────────────────────────
1069
+ document.getElementById('model-select').addEventListener('change', async function () {
1070
+ clearResults();
1071
+ await loadSelectedModel();
1072
+ });
1073
+
1074
+ // ── Detect Button Handler ─────────────────────────────────────────────────
1075
+ document.getElementById('detect-btn').addEventListener('click', async function () {
1076
+ if (!currentImage || !session) return;
1077
+
1078
+ const detectBtn = document.getElementById('detect-btn');
1079
+ detectBtn.disabled = true;
1080
+ setStatus('processing', 'Đang nhαΊ­n diện...');
1081
+
1082
+ try {
1083
+ const t0 = performance.now();
1084
+ const preprocessResult = preprocessImage(currentImage);
1085
+ const detections = await runDetection(session, preprocessResult, classes, 0.25, 0.45);
1086
+ const elapsed = performance.now() - t0;
1087
+
1088
+ drawDetections(document.getElementById('result-canvas'), currentImage, detections, null);
1089
+ renderTable(detections);
1090
+ showTiming(elapsed);
1091
+
1092
+ if (detections.length === 0) {
1093
+ setStatus('ready', 'KhΓ΄ng phΓ‘t hiện Δ‘α»‘i tượng nΓ o');
1094
+ } else {
1095
+ setStatus('ready', `PhΓ‘t hiện ${detections.length} Δ‘α»‘i tượng`);
1096
+ }
1097
+ } catch (err) {
1098
+ console.error('Lα»—i nhαΊ­n diện:', err);
1099
+ setStatus('error', `Lα»—i: ${err.message}`);
1100
+ } finally {
1101
+ detectBtn.disabled = false;
1102
+ }
1103
+ });
1104
+
1105
+ // ── Timing ────────────────────────────────────────────────────────────────
1106
+ function showTiming(ms, fps = null) {
1107
+ const bar = document.getElementById('timing-bar');
1108
+ bar.classList.add('visible');
1109
+ document.getElementById('timing-inference').textContent = ms.toFixed(1) + ' ms';
1110
+ const fpsItem = document.getElementById('fps-item');
1111
+ if (fps !== null) {
1112
+ fpsItem.style.display = 'flex';
1113
+ document.getElementById('timing-fps').textContent = fps.toFixed(1);
1114
+ } else {
1115
+ fpsItem.style.display = 'none';
1116
+ }
1117
+ }
1118
+
1119
+ // ── Source Tabs ───────────────────────────────────────────────────────────
1120
+ document.getElementById('tab-image').addEventListener('click', () => switchTab('image'));
1121
+ document.getElementById('tab-webcam').addEventListener('click', () => switchTab('webcam'));
1122
+
1123
+ function switchTab(tab) {
1124
+ const isImage = tab === 'image';
1125
+ document.getElementById('tab-image').classList.toggle('active', isImage);
1126
+ document.getElementById('tab-webcam').classList.toggle('active', !isImage);
1127
+ document.getElementById('image-panel').classList.toggle('hidden', !isImage);
1128
+ document.getElementById('webcam-panel').classList.toggle('active', !isImage);
1129
+ if (isImage) stopWebcam();
1130
+ }
1131
+
1132
+ // ── Webcam ────────────────────────────────────────────────────────────────
1133
+ let webcamStream = null;
1134
+ let webcamRunning = false;
1135
+ let webcamRafId = null;
1136
+ let fpsFrameCount = 0;
1137
+ let fpsLastTime = 0;
1138
+ let currentFps = 0;
1139
+
1140
+ const video = document.getElementById('webcam-video');
1141
+ const startBtn = document.getElementById('webcam-start-btn');
1142
+ const detectWcBtn = document.getElementById('webcam-detect-btn');
1143
+ const captureBtn = document.getElementById('webcam-capture-btn');
1144
+ const stopBtn = document.getElementById('webcam-stop-btn');
1145
+
1146
+ startBtn.addEventListener('click', startWebcam);
1147
+ detectWcBtn.addEventListener('click', toggleWebcamDetection);
1148
+ stopBtn.addEventListener('click', stopWebcam);
1149
+ captureBtn.addEventListener('click', captureToClipboard);
1150
+
1151
+ async function startWebcam() {
1152
+ try {
1153
+ webcamStream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } });
1154
+ video.srcObject = webcamStream;
1155
+ video.style.display = 'block';
1156
+ startBtn.disabled = true;
1157
+ detectWcBtn.disabled = false;
1158
+ stopBtn.disabled = false;
1159
+ setStatus('ready', 'Webcam Δ‘Γ£ bαΊ­t β€” nhαΊ₯n "BαΊ―t Δ‘αΊ§u nhαΊ­n diện"');
1160
+ } catch (err) {
1161
+ setStatus('error', `KhΓ΄ng thể truy cαΊ­p webcam: ${err.message}`);
1162
+ }
1163
+ }
1164
+
1165
+ function toggleWebcamDetection() {
1166
+ if (webcamRunning) {
1167
+ webcamRunning = false;
1168
+ if (webcamRafId) cancelAnimationFrame(webcamRafId);
1169
+ detectWcBtn.textContent = '⏯ BαΊ―t Δ‘αΊ§u nhαΊ­n diện';
1170
+ captureBtn.disabled = true;
1171
+ setStatus('ready', 'Đã dα»«ng nhαΊ­n diện webcam');
1172
+ } else {
1173
+ if (!session) { setStatus('error', 'ChΖ°a tαΊ£i model'); return; }
1174
+ webcamRunning = true;
1175
+ fpsFrameCount = 0;
1176
+ fpsLastTime = performance.now();
1177
+ detectWcBtn.textContent = '⏸ Tẑm dừng';
1178
+ captureBtn.disabled = false;
1179
+ webcamLoop();
1180
+ }
1181
+ }
1182
+
1183
+ async function webcamLoop() {
1184
+ if (!webcamRunning) return;
1185
+
1186
+ if (video.readyState >= 2) {
1187
+ const t0 = performance.now();
1188
+
1189
+ // Draw video frame to original canvas
1190
+ const origCanvas = document.getElementById('original-canvas');
1191
+ origCanvas.width = video.videoWidth || 640;
1192
+ origCanvas.height = video.videoHeight || 480;
1193
+ origCanvas.getContext('2d').drawImage(video, 0, 0);
1194
+
1195
+ // Preprocess from canvas (treat as image-like)
1196
+ const src = { naturalWidth: origCanvas.width, naturalHeight: origCanvas.height, _canvas: origCanvas };
1197
+ const preprocessResult = preprocessFromCanvas(origCanvas);
1198
+ const detections = await runDetection(session, preprocessResult, classes, 0.25, 0.45);
1199
+ const elapsed = performance.now() - t0;
1200
+
1201
+ // Draw result
1202
+ const resultCanvas = document.getElementById('result-canvas');
1203
+ resultCanvas.width = origCanvas.width;
1204
+ resultCanvas.height = origCanvas.height;
1205
+ const ctx = resultCanvas.getContext('2d');
1206
+ ctx.drawImage(origCanvas, 0, 0);
1207
+ drawDetectionsOnCtx(ctx, detections, origCanvas.width, origCanvas.height);
1208
+
1209
+ renderTable(detections);
1210
+
1211
+ // FPS
1212
+ fpsFrameCount++;
1213
+ const now = performance.now();
1214
+ if (now - fpsLastTime >= 500) {
1215
+ currentFps = fpsFrameCount / ((now - fpsLastTime) / 1000);
1216
+ fpsFrameCount = 0;
1217
+ fpsLastTime = now;
1218
+ }
1219
+ showTiming(elapsed, currentFps);
1220
+ }
1221
+
1222
+ webcamRafId = requestAnimationFrame(webcamLoop);
1223
+ }
1224
+
1225
+ function stopWebcam() {
1226
+ webcamRunning = false;
1227
+ if (webcamRafId) cancelAnimationFrame(webcamRafId);
1228
+ if (webcamStream) {
1229
+ webcamStream.getTracks().forEach(t => t.stop());
1230
+ webcamStream = null;
1231
+ }
1232
+ video.srcObject = null;
1233
+ video.style.display = 'none';
1234
+ startBtn.disabled = false;
1235
+ detectWcBtn.disabled = true;
1236
+ detectWcBtn.textContent = '⏯ BαΊ―t Δ‘αΊ§u nhαΊ­n diện';
1237
+ captureBtn.disabled = true;
1238
+ stopBtn.disabled = true;
1239
+ setStatus('ready', 'Webcam Δ‘Γ£ tαΊ―t');
1240
+ }
1241
+
1242
+ async function captureToClipboard() {
1243
+ const resultCanvas = document.getElementById('result-canvas');
1244
+ try {
1245
+ const blob = await new Promise(res => resultCanvas.toBlob(res, 'image/png'));
1246
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
1247
+ setStatus('ready', 'βœ… Đã copy αΊ£nh vΓ o clipboard');
1248
+ } catch (err) {
1249
+ setStatus('error', `KhΓ΄ng thể copy: ${err.message}`);
1250
+ }
1251
+ }
1252
+
1253
+ // Preprocess directly from a canvas element (no naturalWidth needed)
1254
+ function preprocessFromCanvas(srcCanvas) {
1255
+ const origW = srcCanvas.width;
1256
+ const origH = srcCanvas.height;
1257
+ const scale = Math.min(MODEL_INPUT_SIZE / origW, MODEL_INPUT_SIZE / origH);
1258
+ const scaledW = Math.min(Math.max(1, Math.round(origW * scale)), MODEL_INPUT_SIZE);
1259
+ const scaledH = Math.min(Math.max(1, Math.round(origH * scale)), MODEL_INPUT_SIZE);
1260
+ const padX = Math.floor((MODEL_INPUT_SIZE - scaledW) / 2);
1261
+ const padY = Math.floor((MODEL_INPUT_SIZE - scaledH) / 2);
1262
+
1263
+ const offscreen = new OffscreenCanvas(MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
1264
+ const ctx = offscreen.getContext('2d');
1265
+ ctx.fillStyle = 'rgb(128,128,128)';
1266
+ ctx.fillRect(0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
1267
+ ctx.drawImage(srcCanvas, padX, padY, scaledW, scaledH);
1268
+
1269
+ const pixels = ctx.getImageData(0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE).data;
1270
+ const numPixels = MODEL_INPUT_SIZE * MODEL_INPUT_SIZE;
1271
+ const tensor = new Float32Array(3 * numPixels);
1272
+ for (let i = 0; i < numPixels; i++) {
1273
+ tensor[i] = pixels[i * 4] / 255;
1274
+ tensor[numPixels + i] = pixels[i * 4 + 1] / 255;
1275
+ tensor[2 * numPixels + i] = pixels[i * 4 + 2] / 255;
1276
+ }
1277
+ return { tensor, scaleX: scaledW / origW, scaleY: scaledH / origH, padX, padY };
1278
+ }
1279
+
1280
+ // Draw detections onto an existing ctx (used for webcam β€” canvas already has frame)
1281
+ function drawDetectionsOnCtx(ctx, detections, imgW, imgH) {
1282
+ ctx.lineWidth = 2;
1283
+ ctx.font = 'bold 14px system-ui, sans-serif';
1284
+ for (const det of detections) {
1285
+ const { x, y, width, height } = det.box;
1286
+ const color = getClassColor(det.classIndex, classes.length);
1287
+ const label = `${det.className}: ${det.confidence.toFixed(2)}`;
1288
+ ctx.strokeStyle = color;
1289
+ ctx.strokeRect(x, y, width, height);
1290
+ const tw = ctx.measureText(label).width + 6;
1291
+ const th = 18;
1292
+ const ly = y > th ? y - th : y + height;
1293
+ ctx.fillStyle = color;
1294
+ ctx.fillRect(x, ly, tw, th);
1295
+ ctx.fillStyle = '#fff';
1296
+ ctx.fillText(label, x + 3, ly + 13);
1297
+ }
1298
+ }
1299
+ // ── Magnifier ─────────────────────────────────────────────────────────────
1300
+ (function initMagnifier() {
1301
+ const magnifier = document.getElementById('magnifier');
1302
+ const magCanvas = document.getElementById('magnifier-canvas');
1303
+ const magCtx = magCanvas.getContext('2d');
1304
+ const zoomSlider = document.getElementById('zoom-slider');
1305
+ const zoomValueEl = document.getElementById('zoom-value');
1306
+ const sizeSlider = document.getElementById('size-slider');
1307
+ const sizeValueEl = document.getElementById('size-value');
1308
+
1309
+ let zoomLevel = parseFloat(zoomSlider.value);
1310
+ let lensSize = parseInt(sizeSlider.value, 10);
1311
+
1312
+ function applyLensSize(size) {
1313
+ magnifier.style.width = size + 'px';
1314
+ magnifier.style.height = size + 'px';
1315
+ magCanvas.width = size;
1316
+ magCanvas.height = size;
1317
+ }
1318
+
1319
+ applyLensSize(lensSize);
1320
+
1321
+ zoomSlider.addEventListener('input', () => {
1322
+ zoomLevel = parseFloat(zoomSlider.value);
1323
+ zoomValueEl.textContent = `Γ—${zoomLevel % 1 === 0 ? zoomLevel : zoomLevel.toFixed(1)}`;
1324
+ });
1325
+
1326
+ sizeSlider.addEventListener('input', () => {
1327
+ lensSize = parseInt(sizeSlider.value, 10);
1328
+ sizeValueEl.textContent = lensSize + 'px';
1329
+ applyLensSize(lensSize);
1330
+ });
1331
+
1332
+ const targets = ['original-canvas', 'result-canvas', 'webcam-video'];
1333
+
1334
+ targets.forEach(id => {
1335
+ const canvas = document.getElementById(id);
1336
+
1337
+ canvas.addEventListener('mouseenter', () => {
1338
+ magnifier.style.display = 'block';
1339
+ canvas.style.cursor = 'crosshair';
1340
+ });
1341
+
1342
+ canvas.addEventListener('mouseleave', () => {
1343
+ magnifier.style.display = 'none';
1344
+ canvas.style.cursor = '';
1345
+ });
1346
+
1347
+ canvas.addEventListener('mousemove', (e) => {
1348
+ const rect = canvas.getBoundingClientRect();
1349
+ const elX = e.clientX - rect.left;
1350
+ const elY = e.clientY - rect.top;
1351
+
1352
+ const scaleX = canvas.width / rect.width;
1353
+ const scaleY = canvas.height / rect.height;
1354
+ const srcX = elX * scaleX;
1355
+ const srcY = elY * scaleY;
1356
+
1357
+ const srcW = lensSize / zoomLevel;
1358
+ const srcH = lensSize / zoomLevel;
1359
+
1360
+ magCtx.clearRect(0, 0, lensSize, lensSize);
1361
+ magCtx.drawImage(
1362
+ canvas,
1363
+ srcX - srcW / 2, srcY - srcH / 2, srcW, srcH,
1364
+ 0, 0, lensSize, lensSize
1365
+ );
1366
+
1367
+ const offset = 8;
1368
+ let lensX = e.clientX + offset;
1369
+ let lensY = e.clientY + offset;
1370
+
1371
+ if (lensX + lensSize > window.innerWidth) lensX = e.clientX - lensSize - offset;
1372
+ if (lensY + lensSize > window.innerHeight) lensY = e.clientY - lensSize - offset;
1373
+
1374
+ magnifier.style.left = lensX + 'px';
1375
+ magnifier.style.top = lensY + 'px';
1376
+ });
1377
+ });
1378
+ })();
1379
+ </script>
1380
+ </body>
1381
  </html>