scrypted-detection-trainer 0.1.5 → 0.1.7
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 +194 -24
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/main.ts +194 -24
package/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -112,7 +112,13 @@ 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
|
+
const authPath = await sdk.endpointManager.getAuthenticatedPath();
|
|
118
|
+
uiUrl = `${authPath}endpoint/scrypted-detection-trainer/public/`;
|
|
119
|
+
} catch {
|
|
120
|
+
uiUrl = '/endpoint/scrypted-detection-trainer/public/';
|
|
121
|
+
}
|
|
116
122
|
|
|
117
123
|
const settings: Setting[] = [
|
|
118
124
|
{
|
|
@@ -254,6 +260,12 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
254
260
|
// ── HTTP Handler ──────────────────────────────────────────────────────────
|
|
255
261
|
|
|
256
262
|
async onRequest(request: HttpRequest, response: HttpResponse) {
|
|
263
|
+
// Require authentication — reject unauthenticated requests
|
|
264
|
+
if (!request.username) {
|
|
265
|
+
response.send('Unauthorized', { code: 401, headers: { 'WWW-Authenticate': 'Basic realm="Detection Trainer"' } });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
257
269
|
const url = new URL(request.url, 'http://localhost');
|
|
258
270
|
const path = url.pathname.replace(request.rootPath, '');
|
|
259
271
|
|
|
@@ -392,11 +404,13 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
392
404
|
.badge.green { background: #0d2d0d; color: #4c4; }
|
|
393
405
|
|
|
394
406
|
/* Detection card */
|
|
395
|
-
.detection { display: grid; grid-template-columns:
|
|
407
|
+
.detection { display: grid; grid-template-columns: 420px 1fr; gap: 0; border-bottom: 1px solid #222; }
|
|
396
408
|
.detection:last-child { border-bottom: none; }
|
|
397
|
-
.detection-
|
|
398
|
-
.
|
|
399
|
-
.
|
|
409
|
+
.detection-imgs { display: flex; gap: 8px; padding: 10px; background: #111; align-items: center; }
|
|
410
|
+
.img-panel { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
|
411
|
+
.img-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: .5px; }
|
|
412
|
+
.det-canvas { border-radius: 6px; display: block; }
|
|
413
|
+
.det-class-badge { display: inline-block; background: #3d2a00; color: #f90; font-size: 11px; padding: 2px 8px; border-radius: 4px; }
|
|
400
414
|
.detection-info { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
|
|
401
415
|
.detection-meta { font-size: 12px; color: #888; display: flex; flex-wrap: wrap; gap: 10px; }
|
|
402
416
|
.detection-meta strong { color: #ccc; }
|
|
@@ -444,6 +458,15 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
444
458
|
.breakdown-item { background: #222; border-radius: 8px; padding: 12px; }
|
|
445
459
|
.breakdown-item .label { font-size: 12px; color: #888; margin-bottom: 4px; }
|
|
446
460
|
.breakdown-item .value { font-size: 20px; font-weight: 600; color: #fff; }
|
|
461
|
+
|
|
462
|
+
/* Lightbox */
|
|
463
|
+
.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; }
|
|
464
|
+
.lightbox.open { display: flex; }
|
|
465
|
+
.lightbox canvas { max-width: 92vw; max-height: 82vh; border-radius: 8px; cursor: zoom-out; }
|
|
466
|
+
.lightbox-meta { color: #aaa; font-size: 13px; text-align: center; }
|
|
467
|
+
.lightbox-close { position: absolute; top: 16px; right: 20px; font-size: 28px; color: #888; cursor: pointer; line-height: 1; }
|
|
468
|
+
.lightbox-close:hover { color: #fff; }
|
|
469
|
+
.det-canvas { cursor: zoom-in; }
|
|
447
470
|
</style>
|
|
448
471
|
</head>
|
|
449
472
|
<body>
|
|
@@ -499,10 +522,16 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
499
522
|
</div>
|
|
500
523
|
</div>
|
|
501
524
|
|
|
525
|
+
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
|
|
526
|
+
<div class="lightbox-close" onclick="closeLightbox()">✕</div>
|
|
527
|
+
<canvas id="lightbox-canvas"></canvas>
|
|
528
|
+
<div class="lightbox-meta" id="lightbox-meta"></div>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
502
531
|
<div class="toast" id="toast"></div>
|
|
503
532
|
|
|
504
533
|
<script>
|
|
505
|
-
const BASE = location.pathname.
|
|
534
|
+
const BASE = location.pathname.endsWith('/') ? location.pathname.slice(0, -1) : location.pathname;
|
|
506
535
|
let pending = [];
|
|
507
536
|
let labeledCount = 0;
|
|
508
537
|
|
|
@@ -510,6 +539,115 @@ function imgError(img) {
|
|
|
510
539
|
img.parentElement.innerHTML = '<div style="padding:20px;color:#555;font-size:12px;text-align:center">No image</div>';
|
|
511
540
|
}
|
|
512
541
|
|
|
542
|
+
function drawDetection(img, r) {
|
|
543
|
+
const [bx, by, bw, bh] = r.boundingBox;
|
|
544
|
+
const [iw, ih] = r.inputDimensions;
|
|
545
|
+
|
|
546
|
+
// --- Full frame canvas with box overlay ---
|
|
547
|
+
const fullCanvas = document.getElementById('canvas-full-' + r.id);
|
|
548
|
+
if (fullCanvas) {
|
|
549
|
+
const cw = fullCanvas.width, ch = fullCanvas.height;
|
|
550
|
+
const scale = Math.min(cw / iw, ch / ih);
|
|
551
|
+
const dw = iw * scale, dh = ih * scale;
|
|
552
|
+
const ox = (cw - dw) / 2, oy = (ch - dh) / 2;
|
|
553
|
+
const ctx = fullCanvas.getContext('2d');
|
|
554
|
+
ctx.fillStyle = '#111';
|
|
555
|
+
ctx.fillRect(0, 0, cw, ch);
|
|
556
|
+
ctx.drawImage(img, ox, oy, dw, dh);
|
|
557
|
+
// Draw bounding box
|
|
558
|
+
const rx = ox + bx * scale, ry = oy + by * scale;
|
|
559
|
+
const rw = bw * scale, rh = bh * scale;
|
|
560
|
+
ctx.strokeStyle = '#f90';
|
|
561
|
+
ctx.lineWidth = 2;
|
|
562
|
+
ctx.strokeRect(rx, ry, rw, rh);
|
|
563
|
+
// Label badge
|
|
564
|
+
ctx.fillStyle = 'rgba(255,153,0,0.85)';
|
|
565
|
+
ctx.fillRect(rx, ry - 18, rw, 18);
|
|
566
|
+
ctx.fillStyle = '#000';
|
|
567
|
+
ctx.font = 'bold 11px sans-serif';
|
|
568
|
+
ctx.fillText(r.detectedClass + ' ' + Math.round(r.score * 100) + '%', rx + 3, ry - 4);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// --- Crop canvas ---
|
|
572
|
+
const cropCanvas = document.getElementById('canvas-crop-' + r.id);
|
|
573
|
+
if (cropCanvas) {
|
|
574
|
+
const cc = cropCanvas.width, ch2 = cropCanvas.height;
|
|
575
|
+
const ctx2 = cropCanvas.getContext('2d');
|
|
576
|
+
ctx2.fillStyle = '#111';
|
|
577
|
+
ctx2.fillRect(0, 0, cc, ch2);
|
|
578
|
+
// Add padding around the crop
|
|
579
|
+
const pad = Math.min(bw, bh) * 0.15;
|
|
580
|
+
const sx = Math.max(0, bx - pad), sy = Math.max(0, by - pad);
|
|
581
|
+
const sw = Math.min(iw - sx, bw + pad * 2), sh = Math.min(ih - sy, bh + pad * 2);
|
|
582
|
+
const scale2 = Math.min(cc / sw, ch2 / sh);
|
|
583
|
+
const dw2 = sw * scale2, dh2 = sh * scale2;
|
|
584
|
+
const ox2 = (cc - dw2) / 2, oy2 = (ch2 - dh2) / 2;
|
|
585
|
+
ctx2.drawImage(img, sx, sy, sw, sh, ox2, oy2, dw2, dh2);
|
|
586
|
+
// Thin box outline on crop
|
|
587
|
+
ctx2.strokeStyle = '#f90';
|
|
588
|
+
ctx2.lineWidth = 1.5;
|
|
589
|
+
const rx2 = ox2 + pad * scale2, ry2 = oy2 + pad * scale2;
|
|
590
|
+
ctx2.strokeRect(rx2, ry2, bw * scale2, bh * scale2);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Cache loaded images so lightbox reuses them
|
|
595
|
+
const imgCache = new Map();
|
|
596
|
+
|
|
597
|
+
function openLightbox(r) {
|
|
598
|
+
const img = imgCache.get(r.id);
|
|
599
|
+
if (!img) return;
|
|
600
|
+
|
|
601
|
+
const lb = document.getElementById('lightbox');
|
|
602
|
+
const lbCanvas = document.getElementById('lightbox-canvas');
|
|
603
|
+
|
|
604
|
+
// Size canvas to image, capped at viewport
|
|
605
|
+
const maxW = window.innerWidth * 0.9;
|
|
606
|
+
const maxH = window.innerHeight * 0.8;
|
|
607
|
+
const [iw, ih] = r.inputDimensions;
|
|
608
|
+
const scale = Math.min(maxW / iw, maxH / ih, 1);
|
|
609
|
+
lbCanvas.width = Math.round(iw * scale);
|
|
610
|
+
lbCanvas.height = Math.round(ih * scale);
|
|
611
|
+
|
|
612
|
+
const ctx = lbCanvas.getContext('2d');
|
|
613
|
+
ctx.drawImage(img, 0, 0, lbCanvas.width, lbCanvas.height);
|
|
614
|
+
|
|
615
|
+
// Draw all bounding boxes for this detection (primary + others in same event if available)
|
|
616
|
+
const boxes = r.allDetections || [{ boundingBox: r.boundingBox, detectedClass: r.detectedClass, score: r.score }];
|
|
617
|
+
const colors = ['#f90', '#4af', '#f44', '#4f4', '#f4f', '#ff4'];
|
|
618
|
+
boxes.forEach((d, i) => {
|
|
619
|
+
const [bx, by, bw, bh] = d.boundingBox;
|
|
620
|
+
const color = colors[i % colors.length];
|
|
621
|
+
const rx = bx * scale, ry = by * scale, rw = bw * scale, rh = bh * scale;
|
|
622
|
+
ctx.strokeStyle = color;
|
|
623
|
+
ctx.lineWidth = 2;
|
|
624
|
+
ctx.strokeRect(rx, ry, rw, rh);
|
|
625
|
+
const label = d.detectedClass + ' ' + Math.round((d.score || 0) * 100) + '%';
|
|
626
|
+
const textW = ctx.measureText(label).width + 8;
|
|
627
|
+
ctx.fillStyle = color;
|
|
628
|
+
ctx.globalAlpha = 0.85;
|
|
629
|
+
ctx.fillRect(rx, Math.max(0, ry - 20), textW, 20);
|
|
630
|
+
ctx.globalAlpha = 1;
|
|
631
|
+
ctx.fillStyle = '#000';
|
|
632
|
+
ctx.font = 'bold 12px sans-serif';
|
|
633
|
+
ctx.fillText(label, rx + 4, Math.max(14, ry - 4));
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
document.getElementById('lightbox-meta').textContent =
|
|
637
|
+
r.cameraName + ' · ' + new Date(r.timestamp).toLocaleString() + ' · ' + iw + '×' + ih;
|
|
638
|
+
lb.classList.add('open');
|
|
639
|
+
document.addEventListener('keydown', lbKeyHandler);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function closeLightbox() {
|
|
643
|
+
document.getElementById('lightbox').classList.remove('open');
|
|
644
|
+
document.removeEventListener('keydown', lbKeyHandler);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function lbKeyHandler(e) {
|
|
648
|
+
if (e.key === 'Escape') closeLightbox();
|
|
649
|
+
}
|
|
650
|
+
|
|
513
651
|
function showTab(name) {
|
|
514
652
|
document.querySelectorAll('.tab').forEach((t, i) => {
|
|
515
653
|
const names = ['review', 'stats', 'export'];
|
|
@@ -530,35 +668,44 @@ function toast(msg, color='#1a3') {
|
|
|
530
668
|
}
|
|
531
669
|
|
|
532
670
|
async function loadPending() {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
671
|
+
try {
|
|
672
|
+
const res = await fetch(BASE + '/api/pending');
|
|
673
|
+
pending = await res.json();
|
|
674
|
+
|
|
675
|
+
const statsRes = await fetch(BASE + '/api/stats');
|
|
676
|
+
const stats = await statsRes.json();
|
|
677
|
+
document.getElementById('stat-pending').textContent = stats.pending;
|
|
678
|
+
document.getElementById('stat-labeled').textContent = stats.labeled;
|
|
679
|
+
document.getElementById('stat-total').textContent = stats.total;
|
|
680
|
+
|
|
681
|
+
const list = document.getElementById('detections-list');
|
|
682
|
+
if (!pending.length) {
|
|
683
|
+
list.innerHTML = '<div class="empty"><div class="icon">✅</div><div>No pending detections to review.<br><span style="font-size:12px;color:#444">Captures will appear here as cameras detect objects.</span></div></div>';
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
547
686
|
|
|
548
687
|
list.innerHTML = pending.map(r => {
|
|
549
688
|
const date = new Date(r.timestamp).toLocaleString();
|
|
550
689
|
const score = Math.round(r.score * 100);
|
|
690
|
+
const bb = r.boundingBox;
|
|
691
|
+
const dim = r.inputDimensions;
|
|
551
692
|
return \`
|
|
552
693
|
<div class="detection" id="det-\${r.id}">
|
|
553
|
-
<div class="detection-
|
|
554
|
-
<
|
|
555
|
-
|
|
694
|
+
<div class="detection-imgs">
|
|
695
|
+
<div class="img-panel">
|
|
696
|
+
<div class="img-label">Full frame</div>
|
|
697
|
+
<canvas id="canvas-full-\${r.id}" class="det-canvas" width="240" height="160"></canvas>
|
|
698
|
+
</div>
|
|
699
|
+
<div class="img-panel">
|
|
700
|
+
<div class="img-label">Crop</div>
|
|
701
|
+
<canvas id="canvas-crop-\${r.id}" class="det-canvas" width="160" height="160"></canvas>
|
|
702
|
+
</div>
|
|
556
703
|
</div>
|
|
557
704
|
<div class="detection-info">
|
|
558
705
|
<div class="detection-meta">
|
|
559
706
|
<div><strong>\${r.cameraName}</strong></div>
|
|
560
707
|
<div>\${date}</div>
|
|
561
|
-
<div
|
|
708
|
+
<div class="det-class-badge">\${r.detectedClass} \${score}%</div>
|
|
562
709
|
</div>
|
|
563
710
|
<div style="font-size:12px;color:#888;">What is this actually?</div>
|
|
564
711
|
<div class="label-buttons">
|
|
@@ -573,6 +720,29 @@ async function loadPending() {
|
|
|
573
720
|
</div>
|
|
574
721
|
</div>\`;
|
|
575
722
|
}).join('');
|
|
723
|
+
|
|
724
|
+
// Draw bounding boxes and crops onto canvases after DOM is ready
|
|
725
|
+
for (const r of pending) {
|
|
726
|
+
const img = new Image();
|
|
727
|
+
img.onload = () => {
|
|
728
|
+
imgCache.set(r.id, img);
|
|
729
|
+
drawDetection(img, r);
|
|
730
|
+
// Wire up click to open lightbox
|
|
731
|
+
const fullCanvas = document.getElementById('canvas-full-' + r.id);
|
|
732
|
+
const cropCanvas = document.getElementById('canvas-crop-' + r.id);
|
|
733
|
+
if (fullCanvas) fullCanvas.onclick = () => openLightbox(r);
|
|
734
|
+
if (cropCanvas) cropCanvas.onclick = () => openLightbox(r);
|
|
735
|
+
};
|
|
736
|
+
img.onerror = () => {
|
|
737
|
+
const c = document.getElementById('canvas-full-' + r.id);
|
|
738
|
+
if (c) c.parentElement.innerHTML = '<div style="padding:10px;color:#555;font-size:11px">No image</div>';
|
|
739
|
+
};
|
|
740
|
+
img.src = BASE + '/img/' + r.id;
|
|
741
|
+
} } catch(e) {
|
|
742
|
+
console.error('loadPending error', e);
|
|
743
|
+
const list = document.getElementById('detections-list');
|
|
744
|
+
if (list) list.innerHTML = '<div class="empty"><div style="color:#a44">Error loading captures: ' + e.message + '</div></div>';
|
|
745
|
+
}
|
|
576
746
|
}
|
|
577
747
|
|
|
578
748
|
async function label(id, labelVal) {
|