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/out/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrypted-detection-trainer",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Collect and label detections to fine-tune the Scrypted NVR object detection model.",
5
5
  "keywords": [
6
6
  "scrypted-plugin"
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
- const uiUrl = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true }).catch(() => '/endpoint/scrypted-detection-trainer/public/');
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: 200px 1fr; gap: 0; border-bottom: 1px solid #222; }
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-img { position: relative; background: #111; display: flex; align-items: center; justify-content: center; min-height: 150px; }
398
- .detection-img img { width: 100%; height: 150px; object-fit: cover; display: block; }
399
- .detection-class { position: absolute; top: 6px; left: 6px; background: rgba(0,0,0,0.7); color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 4px; }
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-img">
555
- <img src="\${BASE}/img/\${r.id}" alt="\${r.detectedClass}" loading="lazy" onerror="imgError(this)">
556
- <div class="detection-class">\${r.detectedClass} \${score}%</div>
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>Box: \${r.boundingBox.map(v => Math.round(v)).join(', ')}</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
- } catch(e) {
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>';