scrypted-detection-trainer 0.1.6 → 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 +174 -10
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/main.ts +174 -10
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,6 +522,12 @@ 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>
|
|
@@ -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'];
|
|
@@ -549,17 +687,25 @@ async function loadPending() {
|
|
|
549
687
|
list.innerHTML = pending.map(r => {
|
|
550
688
|
const date = new Date(r.timestamp).toLocaleString();
|
|
551
689
|
const score = Math.round(r.score * 100);
|
|
690
|
+
const bb = r.boundingBox;
|
|
691
|
+
const dim = r.inputDimensions;
|
|
552
692
|
return \`
|
|
553
693
|
<div class="detection" id="det-\${r.id}">
|
|
554
|
-
<div class="detection-
|
|
555
|
-
<
|
|
556
|
-
|
|
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>
|
|
557
703
|
</div>
|
|
558
704
|
<div class="detection-info">
|
|
559
705
|
<div class="detection-meta">
|
|
560
706
|
<div><strong>\${r.cameraName}</strong></div>
|
|
561
707
|
<div>\${date}</div>
|
|
562
|
-
<div
|
|
708
|
+
<div class="det-class-badge">\${r.detectedClass} \${score}%</div>
|
|
563
709
|
</div>
|
|
564
710
|
<div style="font-size:12px;color:#888;">What is this actually?</div>
|
|
565
711
|
<div class="label-buttons">
|
|
@@ -574,7 +720,25 @@ async function loadPending() {
|
|
|
574
720
|
</div>
|
|
575
721
|
</div>\`;
|
|
576
722
|
}).join('');
|
|
577
|
-
|
|
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) {
|
|
578
742
|
console.error('loadPending error', e);
|
|
579
743
|
const list = document.getElementById('detections-list');
|
|
580
744
|
if (list) list.innerHTML = '<div class="empty"><div style="color:#a44">Error loading captures: ' + e.message + '</div></div>';
|