scrypted-detection-trainer 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +305 -11
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/main.ts +305 -11
package/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -112,7 +112,12 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
112
112
|
d.interfaces?.includes(ScryptedInterface.ObjectDetector)
|
|
113
113
|
);
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
let uiUrl: string;
|
|
116
|
+
try {
|
|
117
|
+
uiUrl = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
|
|
118
|
+
} catch {
|
|
119
|
+
uiUrl = '/endpoint/scrypted-detection-trainer/public/';
|
|
120
|
+
}
|
|
116
121
|
|
|
117
122
|
const settings: Setting[] = [
|
|
118
123
|
{
|
|
@@ -291,6 +296,17 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
291
296
|
return response.send(JSON.stringify(pending), { headers: { 'Content-Type': 'application/json' } });
|
|
292
297
|
}
|
|
293
298
|
|
|
299
|
+
// API: get labeled captures
|
|
300
|
+
if (path === '/api/labeled') {
|
|
301
|
+
const page = parseInt(new URL(request.url, 'http://localhost').searchParams.get('page') || '0');
|
|
302
|
+
const pageSize = 50;
|
|
303
|
+
const labeled = [...this.captures.values()]
|
|
304
|
+
.filter(c => c.reviewed)
|
|
305
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
306
|
+
const slice = labeled.slice(page * pageSize, (page + 1) * pageSize);
|
|
307
|
+
return response.send(JSON.stringify({ items: slice, total: labeled.length, page, pageSize }), { headers: { 'Content-Type': 'application/json' } });
|
|
308
|
+
}
|
|
309
|
+
|
|
294
310
|
// API: stats
|
|
295
311
|
if (path === '/api/stats') {
|
|
296
312
|
const all = [...this.captures.values()];
|
|
@@ -392,11 +408,13 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
392
408
|
.badge.green { background: #0d2d0d; color: #4c4; }
|
|
393
409
|
|
|
394
410
|
/* Detection card */
|
|
395
|
-
.detection { display: grid; grid-template-columns:
|
|
411
|
+
.detection { display: grid; grid-template-columns: 420px 1fr; gap: 0; border-bottom: 1px solid #222; }
|
|
396
412
|
.detection:last-child { border-bottom: none; }
|
|
397
|
-
.detection-
|
|
398
|
-
.
|
|
399
|
-
.
|
|
413
|
+
.detection-imgs { display: flex; gap: 8px; padding: 10px; background: #111; align-items: center; }
|
|
414
|
+
.img-panel { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
|
415
|
+
.img-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: .5px; }
|
|
416
|
+
.det-canvas { border-radius: 6px; display: block; }
|
|
417
|
+
.det-class-badge { display: inline-block; background: #3d2a00; color: #f90; font-size: 11px; padding: 2px 8px; border-radius: 4px; }
|
|
400
418
|
.detection-info { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
|
|
401
419
|
.detection-meta { font-size: 12px; color: #888; display: flex; flex-wrap: wrap; gap: 10px; }
|
|
402
420
|
.detection-meta strong { color: #ccc; }
|
|
@@ -444,6 +462,15 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
444
462
|
.breakdown-item { background: #222; border-radius: 8px; padding: 12px; }
|
|
445
463
|
.breakdown-item .label { font-size: 12px; color: #888; margin-bottom: 4px; }
|
|
446
464
|
.breakdown-item .value { font-size: 20px; font-weight: 600; color: #fff; }
|
|
465
|
+
|
|
466
|
+
/* Lightbox */
|
|
467
|
+
.lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 1000; align-items: center; justify-content: center; flex-direction: column; gap: 12px; }
|
|
468
|
+
.lightbox.open { display: flex; }
|
|
469
|
+
.lightbox canvas { max-width: 92vw; max-height: 82vh; border-radius: 8px; cursor: zoom-out; }
|
|
470
|
+
.lightbox-meta { color: #aaa; font-size: 13px; text-align: center; }
|
|
471
|
+
.lightbox-close { position: absolute; top: 16px; right: 20px; font-size: 28px; color: #888; cursor: pointer; line-height: 1; }
|
|
472
|
+
.lightbox-close:hover { color: #fff; }
|
|
473
|
+
.det-canvas { cursor: zoom-in; }
|
|
447
474
|
</style>
|
|
448
475
|
</head>
|
|
449
476
|
<body>
|
|
@@ -460,6 +487,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
460
487
|
<div class="card">
|
|
461
488
|
<div class="tab-bar">
|
|
462
489
|
<div class="tab active" onclick="showTab('review')">Review</div>
|
|
490
|
+
<div class="tab" onclick="showTab('labeled')">Labeled</div>
|
|
463
491
|
<div class="tab" onclick="showTab('stats')">Stats</div>
|
|
464
492
|
<div class="tab" onclick="showTab('export')">Export Dataset</div>
|
|
465
493
|
</div>
|
|
@@ -469,6 +497,11 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
469
497
|
<div id="detections-list"></div>
|
|
470
498
|
</div>
|
|
471
499
|
|
|
500
|
+
<!-- Labeled tab -->
|
|
501
|
+
<div class="tab-panel" id="tab-labeled">
|
|
502
|
+
<div id="labeled-list"></div>
|
|
503
|
+
</div>
|
|
504
|
+
|
|
472
505
|
<!-- Stats tab -->
|
|
473
506
|
<div class="tab-panel" id="tab-stats">
|
|
474
507
|
<div class="tab-content">
|
|
@@ -499,6 +532,12 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
499
532
|
</div>
|
|
500
533
|
</div>
|
|
501
534
|
|
|
535
|
+
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
|
|
536
|
+
<div class="lightbox-close" onclick="closeLightbox()">✕</div>
|
|
537
|
+
<canvas id="lightbox-canvas"></canvas>
|
|
538
|
+
<div class="lightbox-meta" id="lightbox-meta"></div>
|
|
539
|
+
</div>
|
|
540
|
+
|
|
502
541
|
<div class="toast" id="toast"></div>
|
|
503
542
|
|
|
504
543
|
<script>
|
|
@@ -510,15 +549,244 @@ function imgError(img) {
|
|
|
510
549
|
img.parentElement.innerHTML = '<div style="padding:20px;color:#555;font-size:12px;text-align:center">No image</div>';
|
|
511
550
|
}
|
|
512
551
|
|
|
552
|
+
function drawDetection(img, r) {
|
|
553
|
+
const [bx, by, bw, bh] = r.boundingBox;
|
|
554
|
+
const [iw, ih] = r.inputDimensions;
|
|
555
|
+
|
|
556
|
+
// --- Full frame canvas with box overlay ---
|
|
557
|
+
const fullCanvas = document.getElementById('canvas-full-' + r.id);
|
|
558
|
+
if (fullCanvas) {
|
|
559
|
+
const cw = fullCanvas.width, ch = fullCanvas.height;
|
|
560
|
+
const scale = Math.min(cw / iw, ch / ih);
|
|
561
|
+
const dw = iw * scale, dh = ih * scale;
|
|
562
|
+
const ox = (cw - dw) / 2, oy = (ch - dh) / 2;
|
|
563
|
+
const ctx = fullCanvas.getContext('2d');
|
|
564
|
+
ctx.fillStyle = '#111';
|
|
565
|
+
ctx.fillRect(0, 0, cw, ch);
|
|
566
|
+
ctx.drawImage(img, ox, oy, dw, dh);
|
|
567
|
+
// Draw bounding box
|
|
568
|
+
const rx = ox + bx * scale, ry = oy + by * scale;
|
|
569
|
+
const rw = bw * scale, rh = bh * scale;
|
|
570
|
+
ctx.strokeStyle = '#f90';
|
|
571
|
+
ctx.lineWidth = 2;
|
|
572
|
+
ctx.strokeRect(rx, ry, rw, rh);
|
|
573
|
+
// Label badge
|
|
574
|
+
ctx.fillStyle = 'rgba(255,153,0,0.85)';
|
|
575
|
+
ctx.fillRect(rx, ry - 18, rw, 18);
|
|
576
|
+
ctx.fillStyle = '#000';
|
|
577
|
+
ctx.font = 'bold 11px sans-serif';
|
|
578
|
+
ctx.fillText(r.detectedClass + ' ' + Math.round(r.score * 100) + '%', rx + 3, ry - 4);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// --- Crop canvas ---
|
|
582
|
+
const cropCanvas = document.getElementById('canvas-crop-' + r.id);
|
|
583
|
+
if (cropCanvas) {
|
|
584
|
+
const cc = cropCanvas.width, ch2 = cropCanvas.height;
|
|
585
|
+
const ctx2 = cropCanvas.getContext('2d');
|
|
586
|
+
ctx2.fillStyle = '#111';
|
|
587
|
+
ctx2.fillRect(0, 0, cc, ch2);
|
|
588
|
+
// Add padding around the crop
|
|
589
|
+
const pad = Math.min(bw, bh) * 0.15;
|
|
590
|
+
const sx = Math.max(0, bx - pad), sy = Math.max(0, by - pad);
|
|
591
|
+
const sw = Math.min(iw - sx, bw + pad * 2), sh = Math.min(ih - sy, bh + pad * 2);
|
|
592
|
+
const scale2 = Math.min(cc / sw, ch2 / sh);
|
|
593
|
+
const dw2 = sw * scale2, dh2 = sh * scale2;
|
|
594
|
+
const ox2 = (cc - dw2) / 2, oy2 = (ch2 - dh2) / 2;
|
|
595
|
+
ctx2.drawImage(img, sx, sy, sw, sh, ox2, oy2, dw2, dh2);
|
|
596
|
+
// Thin box outline on crop
|
|
597
|
+
ctx2.strokeStyle = '#f90';
|
|
598
|
+
ctx2.lineWidth = 1.5;
|
|
599
|
+
const rx2 = ox2 + pad * scale2, ry2 = oy2 + pad * scale2;
|
|
600
|
+
ctx2.strokeRect(rx2, ry2, bw * scale2, bh * scale2);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Cache loaded images so lightbox reuses them
|
|
605
|
+
const imgCache = new Map();
|
|
606
|
+
|
|
607
|
+
function openLightbox(r) {
|
|
608
|
+
const img = imgCache.get(r.id);
|
|
609
|
+
if (!img) return;
|
|
610
|
+
|
|
611
|
+
const lb = document.getElementById('lightbox');
|
|
612
|
+
const lbCanvas = document.getElementById('lightbox-canvas');
|
|
613
|
+
|
|
614
|
+
// Size canvas to image, capped at viewport
|
|
615
|
+
const maxW = window.innerWidth * 0.9;
|
|
616
|
+
const maxH = window.innerHeight * 0.8;
|
|
617
|
+
const [iw, ih] = r.inputDimensions;
|
|
618
|
+
const scale = Math.min(maxW / iw, maxH / ih, 1);
|
|
619
|
+
lbCanvas.width = Math.round(iw * scale);
|
|
620
|
+
lbCanvas.height = Math.round(ih * scale);
|
|
621
|
+
|
|
622
|
+
const ctx = lbCanvas.getContext('2d');
|
|
623
|
+
ctx.drawImage(img, 0, 0, lbCanvas.width, lbCanvas.height);
|
|
624
|
+
|
|
625
|
+
// Draw all bounding boxes for this detection (primary + others in same event if available)
|
|
626
|
+
const boxes = r.allDetections || [{ boundingBox: r.boundingBox, detectedClass: r.detectedClass, score: r.score }];
|
|
627
|
+
const colors = ['#f90', '#4af', '#f44', '#4f4', '#f4f', '#ff4'];
|
|
628
|
+
boxes.forEach((d, i) => {
|
|
629
|
+
const [bx, by, bw, bh] = d.boundingBox;
|
|
630
|
+
const color = colors[i % colors.length];
|
|
631
|
+
const rx = bx * scale, ry = by * scale, rw = bw * scale, rh = bh * scale;
|
|
632
|
+
ctx.strokeStyle = color;
|
|
633
|
+
ctx.lineWidth = 2;
|
|
634
|
+
ctx.strokeRect(rx, ry, rw, rh);
|
|
635
|
+
const label = d.detectedClass + ' ' + Math.round((d.score || 0) * 100) + '%';
|
|
636
|
+
const textW = ctx.measureText(label).width + 8;
|
|
637
|
+
ctx.fillStyle = color;
|
|
638
|
+
ctx.globalAlpha = 0.85;
|
|
639
|
+
ctx.fillRect(rx, Math.max(0, ry - 20), textW, 20);
|
|
640
|
+
ctx.globalAlpha = 1;
|
|
641
|
+
ctx.fillStyle = '#000';
|
|
642
|
+
ctx.font = 'bold 12px sans-serif';
|
|
643
|
+
ctx.fillText(label, rx + 4, Math.max(14, ry - 4));
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
document.getElementById('lightbox-meta').textContent =
|
|
647
|
+
r.cameraName + ' · ' + new Date(r.timestamp).toLocaleString() + ' · ' + iw + '×' + ih;
|
|
648
|
+
lb.classList.add('open');
|
|
649
|
+
document.addEventListener('keydown', lbKeyHandler);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function closeLightbox() {
|
|
653
|
+
document.getElementById('lightbox').classList.remove('open');
|
|
654
|
+
document.removeEventListener('keydown', lbKeyHandler);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function lbKeyHandler(e) {
|
|
658
|
+
if (e.key === 'Escape') closeLightbox();
|
|
659
|
+
}
|
|
660
|
+
|
|
513
661
|
function showTab(name) {
|
|
662
|
+
const names = ['review', 'labeled', 'stats', 'export'];
|
|
514
663
|
document.querySelectorAll('.tab').forEach((t, i) => {
|
|
515
|
-
const names = ['review', 'stats', 'export'];
|
|
516
664
|
t.classList.toggle('active', names[i] === name);
|
|
517
665
|
});
|
|
518
666
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
519
667
|
document.getElementById('tab-' + name).classList.add('active');
|
|
520
668
|
if (name === 'stats') loadStats();
|
|
521
669
|
if (name === 'export') loadExportInfo();
|
|
670
|
+
if (name === 'labeled') loadLabeled(0);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };
|
|
674
|
+
|
|
675
|
+
async function loadLabeled(page) {
|
|
676
|
+
const list = document.getElementById('labeled-list');
|
|
677
|
+
list.innerHTML = '<div class="empty"><div style="color:#888">Loading…</div></div>';
|
|
678
|
+
try {
|
|
679
|
+
const res = await fetch(BASE + '/api/labeled?page=' + page);
|
|
680
|
+
const data = await res.json();
|
|
681
|
+
const { items, total, pageSize } = data;
|
|
682
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
683
|
+
|
|
684
|
+
if (!items.length) {
|
|
685
|
+
list.innerHTML = '<div class="empty"><div class="icon">🏷️</div><div>No labeled detections yet.</div></div>';
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const pagerHtml = totalPages > 1 ? \`
|
|
690
|
+
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-top:1px solid #222;font-size:13px;color:#888;">
|
|
691
|
+
\${page > 0 ? \`<button class="label-btn" onclick="loadLabeled(\${page-1})">← Prev</button>\` : ''}
|
|
692
|
+
<span>Page \${page+1} of \${totalPages} · \${total} total</span>
|
|
693
|
+
\${page < totalPages-1 ? \`<button class="label-btn" onclick="loadLabeled(\${page+1})">Next →</button>\` : ''}
|
|
694
|
+
</div>\` : '';
|
|
695
|
+
|
|
696
|
+
list.innerHTML = items.map(r => {
|
|
697
|
+
const date = new Date(r.timestamp).toLocaleString();
|
|
698
|
+
const score = Math.round(r.score * 100);
|
|
699
|
+
const labelColor = LABEL_COLORS[r.label] || '#aaa';
|
|
700
|
+
return \`
|
|
701
|
+
<div class="detection" id="ldet-\${r.id}">
|
|
702
|
+
<div class="detection-imgs">
|
|
703
|
+
<div class="img-panel">
|
|
704
|
+
<div class="img-label">Full frame</div>
|
|
705
|
+
<canvas id="lcanvas-full-\${r.id}" class="det-canvas" width="240" height="160"></canvas>
|
|
706
|
+
</div>
|
|
707
|
+
<div class="img-panel">
|
|
708
|
+
<div class="img-label">Crop</div>
|
|
709
|
+
<canvas id="lcanvas-crop-\${r.id}" class="det-canvas" width="160" height="160"></canvas>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
<div class="detection-info">
|
|
713
|
+
<div class="detection-meta">
|
|
714
|
+
<div><strong>\${r.cameraName}</strong></div>
|
|
715
|
+
<div>\${date}</div>
|
|
716
|
+
<div class="det-class-badge">\${r.detectedClass} \${score}%</div>
|
|
717
|
+
<div style="display:inline-block;background:#1a1a1a;border:1px solid \${labelColor};color:\${labelColor};font-size:11px;padding:2px 8px;border-radius:4px;">✓ \${r.label}</div>
|
|
718
|
+
</div>
|
|
719
|
+
<div style="font-size:12px;color:#888;">Change label:</div>
|
|
720
|
+
<div class="label-buttons">
|
|
721
|
+
<button class="label-btn person" onclick="relabel('\${r.id}', 'person')">👤 Person</button>
|
|
722
|
+
<button class="label-btn animal" onclick="relabel('\${r.id}', 'animal')">🐾 Animal</button>
|
|
723
|
+
<button class="label-btn face" onclick="relabel('\${r.id}', 'face')">😀 Face</button>
|
|
724
|
+
<button class="label-btn vehicle" onclick="relabel('\${r.id}', 'vehicle')">🚗 Vehicle</button>
|
|
725
|
+
<button class="label-btn" onclick="relabel('\${r.id}', 'plate')">🔢 Plate</button>
|
|
726
|
+
<button class="label-btn" onclick="relabel('\${r.id}', 'package')">📦 Package</button>
|
|
727
|
+
<button class="label-btn discard" onclick="relabel('\${r.id}', 'discard')">🗑 Discard</button>
|
|
728
|
+
</div>
|
|
729
|
+
</div>
|
|
730
|
+
</div>\`;
|
|
731
|
+
}).join('') + pagerHtml;
|
|
732
|
+
|
|
733
|
+
// Draw bounding boxes
|
|
734
|
+
for (const r of items) {
|
|
735
|
+
const img = new Image();
|
|
736
|
+
img.onload = () => {
|
|
737
|
+
imgCache.set(r.id, img);
|
|
738
|
+
// Reuse drawDetection with the labeled canvases
|
|
739
|
+
const origFull = document.getElementById('canvas-full-' + r.id);
|
|
740
|
+
const origCrop = document.getElementById('canvas-crop-' + r.id);
|
|
741
|
+
// Temporarily point to labeled canvases
|
|
742
|
+
const fakeFull = document.getElementById('lcanvas-full-' + r.id);
|
|
743
|
+
const fakeCrop = document.getElementById('lcanvas-crop-' + r.id);
|
|
744
|
+
if (fakeFull) fakeFull.id = 'canvas-full-' + r.id;
|
|
745
|
+
if (fakeCrop) fakeCrop.id = 'canvas-crop-' + r.id;
|
|
746
|
+
drawDetection(img, r);
|
|
747
|
+
if (fakeFull) fakeFull.id = 'lcanvas-full-' + r.id;
|
|
748
|
+
if (fakeCrop) fakeCrop.id = 'lcanvas-crop-' + r.id;
|
|
749
|
+
if (fakeFull) fakeFull.onclick = () => openLightbox(r);
|
|
750
|
+
if (fakeCrop) fakeCrop.onclick = () => openLightbox(r);
|
|
751
|
+
};
|
|
752
|
+
img.src = BASE + '/img/' + r.id;
|
|
753
|
+
}
|
|
754
|
+
} catch(e) {
|
|
755
|
+
list.innerHTML = '<div class="empty"><div style="color:#a44">Error: ' + e.message + '</div></div>';
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function relabel(id, labelVal) {
|
|
760
|
+
await fetch(BASE + '/api/label', {
|
|
761
|
+
method: 'POST',
|
|
762
|
+
headers: { 'Content-Type': 'application/json' },
|
|
763
|
+
body: JSON.stringify({ id, label: labelVal }),
|
|
764
|
+
});
|
|
765
|
+
const el = document.getElementById('ldet-' + id);
|
|
766
|
+
if (labelVal === 'discard') {
|
|
767
|
+
if (el) el.remove();
|
|
768
|
+
toast('Discarded', '#633');
|
|
769
|
+
} else {
|
|
770
|
+
// Update the label badge in place
|
|
771
|
+
const badge = el && el.querySelector('[style*="✓"]');
|
|
772
|
+
const labelColor = LABEL_COLORS[labelVal] || '#aaa';
|
|
773
|
+
if (el) {
|
|
774
|
+
const badges = el.querySelectorAll('.detection-meta > div');
|
|
775
|
+
badges.forEach(b => { if (b.textContent.startsWith('✓')) b.remove(); });
|
|
776
|
+
const meta = el.querySelector('.detection-meta');
|
|
777
|
+
const newBadge = document.createElement('div');
|
|
778
|
+
newBadge.style.cssText = \`display:inline-block;background:#1a1a1a;border:1px solid \${labelColor};color:\${labelColor};font-size:11px;padding:2px 8px;border-radius:4px;\`;
|
|
779
|
+
newBadge.textContent = '✓ ' + labelVal;
|
|
780
|
+
meta.appendChild(newBadge);
|
|
781
|
+
}
|
|
782
|
+
toast('Re-labeled: ' + labelVal, '#1a6');
|
|
783
|
+
}
|
|
784
|
+
// Refresh stats
|
|
785
|
+
const statsRes = await fetch(BASE + '/api/stats');
|
|
786
|
+
const stats = await statsRes.json();
|
|
787
|
+
document.getElementById('stat-pending').textContent = stats.pending;
|
|
788
|
+
document.getElementById('stat-labeled').textContent = stats.labeled;
|
|
789
|
+
document.getElementById('stat-total').textContent = stats.total;
|
|
522
790
|
}
|
|
523
791
|
|
|
524
792
|
function toast(msg, color='#1a3') {
|
|
@@ -549,17 +817,25 @@ async function loadPending() {
|
|
|
549
817
|
list.innerHTML = pending.map(r => {
|
|
550
818
|
const date = new Date(r.timestamp).toLocaleString();
|
|
551
819
|
const score = Math.round(r.score * 100);
|
|
820
|
+
const bb = r.boundingBox;
|
|
821
|
+
const dim = r.inputDimensions;
|
|
552
822
|
return \`
|
|
553
823
|
<div class="detection" id="det-\${r.id}">
|
|
554
|
-
<div class="detection-
|
|
555
|
-
<
|
|
556
|
-
|
|
824
|
+
<div class="detection-imgs">
|
|
825
|
+
<div class="img-panel">
|
|
826
|
+
<div class="img-label">Full frame</div>
|
|
827
|
+
<canvas id="canvas-full-\${r.id}" class="det-canvas" width="240" height="160"></canvas>
|
|
828
|
+
</div>
|
|
829
|
+
<div class="img-panel">
|
|
830
|
+
<div class="img-label">Crop</div>
|
|
831
|
+
<canvas id="canvas-crop-\${r.id}" class="det-canvas" width="160" height="160"></canvas>
|
|
832
|
+
</div>
|
|
557
833
|
</div>
|
|
558
834
|
<div class="detection-info">
|
|
559
835
|
<div class="detection-meta">
|
|
560
836
|
<div><strong>\${r.cameraName}</strong></div>
|
|
561
837
|
<div>\${date}</div>
|
|
562
|
-
<div
|
|
838
|
+
<div class="det-class-badge">\${r.detectedClass} \${score}%</div>
|
|
563
839
|
</div>
|
|
564
840
|
<div style="font-size:12px;color:#888;">What is this actually?</div>
|
|
565
841
|
<div class="label-buttons">
|
|
@@ -574,7 +850,25 @@ async function loadPending() {
|
|
|
574
850
|
</div>
|
|
575
851
|
</div>\`;
|
|
576
852
|
}).join('');
|
|
577
|
-
|
|
853
|
+
|
|
854
|
+
// Draw bounding boxes and crops onto canvases after DOM is ready
|
|
855
|
+
for (const r of pending) {
|
|
856
|
+
const img = new Image();
|
|
857
|
+
img.onload = () => {
|
|
858
|
+
imgCache.set(r.id, img);
|
|
859
|
+
drawDetection(img, r);
|
|
860
|
+
// Wire up click to open lightbox
|
|
861
|
+
const fullCanvas = document.getElementById('canvas-full-' + r.id);
|
|
862
|
+
const cropCanvas = document.getElementById('canvas-crop-' + r.id);
|
|
863
|
+
if (fullCanvas) fullCanvas.onclick = () => openLightbox(r);
|
|
864
|
+
if (cropCanvas) cropCanvas.onclick = () => openLightbox(r);
|
|
865
|
+
};
|
|
866
|
+
img.onerror = () => {
|
|
867
|
+
const c = document.getElementById('canvas-full-' + r.id);
|
|
868
|
+
if (c) c.parentElement.innerHTML = '<div style="padding:10px;color:#555;font-size:11px">No image</div>';
|
|
869
|
+
};
|
|
870
|
+
img.src = BASE + '/img/' + r.id;
|
|
871
|
+
} } catch(e) {
|
|
578
872
|
console.error('loadPending error', e);
|
|
579
873
|
const list = document.getElementById('detections-list');
|
|
580
874
|
if (list) list.innerHTML = '<div class="empty"><div style="color:#a44">Error loading captures: ' + e.message + '</div></div>';
|