scrypted-detection-trainer 0.1.7 → 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.7",
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
@@ -114,8 +114,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
114
114
 
115
115
  let uiUrl: string;
116
116
  try {
117
- const authPath = await sdk.endpointManager.getAuthenticatedPath();
118
- uiUrl = `${authPath}endpoint/scrypted-detection-trainer/public/`;
117
+ uiUrl = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
119
118
  } catch {
120
119
  uiUrl = '/endpoint/scrypted-detection-trainer/public/';
121
120
  }
@@ -260,12 +259,6 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
260
259
  // ── HTTP Handler ──────────────────────────────────────────────────────────
261
260
 
262
261
  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
-
269
262
  const url = new URL(request.url, 'http://localhost');
270
263
  const path = url.pathname.replace(request.rootPath, '');
271
264
 
@@ -303,6 +296,17 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
303
296
  return response.send(JSON.stringify(pending), { headers: { 'Content-Type': 'application/json' } });
304
297
  }
305
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
+
306
310
  // API: stats
307
311
  if (path === '/api/stats') {
308
312
  const all = [...this.captures.values()];
@@ -483,6 +487,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
483
487
  <div class="card">
484
488
  <div class="tab-bar">
485
489
  <div class="tab active" onclick="showTab('review')">Review</div>
490
+ <div class="tab" onclick="showTab('labeled')">Labeled</div>
486
491
  <div class="tab" onclick="showTab('stats')">Stats</div>
487
492
  <div class="tab" onclick="showTab('export')">Export Dataset</div>
488
493
  </div>
@@ -492,6 +497,11 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
492
497
  <div id="detections-list"></div>
493
498
  </div>
494
499
 
500
+ <!-- Labeled tab -->
501
+ <div class="tab-panel" id="tab-labeled">
502
+ <div id="labeled-list"></div>
503
+ </div>
504
+
495
505
  <!-- Stats tab -->
496
506
  <div class="tab-panel" id="tab-stats">
497
507
  <div class="tab-content">
@@ -649,14 +659,134 @@ function lbKeyHandler(e) {
649
659
  }
650
660
 
651
661
  function showTab(name) {
662
+ const names = ['review', 'labeled', 'stats', 'export'];
652
663
  document.querySelectorAll('.tab').forEach((t, i) => {
653
- const names = ['review', 'stats', 'export'];
654
664
  t.classList.toggle('active', names[i] === name);
655
665
  });
656
666
  document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
657
667
  document.getElementById('tab-' + name).classList.add('active');
658
668
  if (name === 'stats') loadStats();
659
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;
660
790
  }
661
791
 
662
792
  function toast(msg, color='#1a3') {