scrypted-detection-trainer 0.1.7 → 0.1.9

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.9",
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
@@ -10,6 +10,8 @@ import sdk, {
10
10
  ObjectsDetected,
11
11
  ObjectDetector,
12
12
  } from '@scrypted/sdk';
13
+ import fs from 'fs';
14
+ import path from 'path';
13
15
 
14
16
  const { systemManager, deviceManager, mediaManager } = sdk;
15
17
 
@@ -55,13 +57,16 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
55
57
  private lastCapture = new Map<string, number>();
56
58
  // Map<captureId, CaptureRecord>
57
59
  private captures = new Map<string, CaptureRecord>();
58
- // Map<captureId, jpegBuffer>
59
- private images = new Map<string, Buffer>();
60
60
  // Active event listeners
61
61
  private listeners: (() => void)[] = [];
62
+ // Directory for storing images on disk
63
+ private imgDir: string;
62
64
 
63
65
  constructor(nativeId?: string) {
64
66
  super(nativeId);
67
+ // Use a stable directory inside the plugin's volume
68
+ this.imgDir = path.join(process.env.SCRYPTED_PLUGIN_VOLUME || '/tmp', 'detection-trainer-images');
69
+ try { fs.mkdirSync(this.imgDir, { recursive: true }); } catch {}
65
70
  this.loadState();
66
71
  this.registerListeners();
67
72
  }
@@ -79,26 +84,43 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
79
84
  } catch (e) {
80
85
  this.console.warn('Could not load captures from storage:', e);
81
86
  }
82
- // images are stored as individual items
87
+ // Clean up any old base64 image entries from previous versions
83
88
  for (const [id] of this.captures) {
84
- const raw = this.storage.getItem(`img:${id}`);
85
- if (raw) this.images.set(id, Buffer.from(raw, 'base64'));
89
+ try { this.storage.removeItem(`img:${id}`); } catch {}
86
90
  }
87
91
  }
88
92
 
89
93
  private saveCaptures() {
90
- this.storage.setItem('captures', JSON.stringify([...this.captures.values()]));
94
+ try {
95
+ this.storage.setItem('captures', JSON.stringify([...this.captures.values()]));
96
+ } catch (e) {
97
+ this.console.warn('Could not save captures:', e);
98
+ }
99
+ }
100
+
101
+ private imgPath(id: string): string {
102
+ return path.join(this.imgDir, `${id}.jpg`);
91
103
  }
92
104
 
93
105
  private saveImage(id: string, buf: Buffer) {
94
- this.storage.setItem(`img:${id}`, buf.toString('base64'));
106
+ try {
107
+ fs.writeFileSync(this.imgPath(id), buf);
108
+ } catch (e) {
109
+ this.console.warn(`Could not save image ${id}:`, e);
110
+ }
111
+ }
112
+
113
+ private loadImage(id: string): Buffer | undefined {
114
+ try {
115
+ const p = this.imgPath(id);
116
+ if (fs.existsSync(p)) return fs.readFileSync(p);
117
+ } catch {}
118
+ return undefined;
95
119
  }
96
120
 
97
121
  private deleteCapture(id: string) {
98
- const old = this.storage.getItem(`img:${id}`);
99
- if (old) this.storage.removeItem(`img:${id}`);
122
+ try { fs.unlinkSync(this.imgPath(id)); } catch {}
100
123
  this.captures.delete(id);
101
- this.images.delete(id);
102
124
  this.saveCaptures();
103
125
  }
104
126
 
@@ -114,8 +136,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
114
136
 
115
137
  let uiUrl: string;
116
138
  try {
117
- const authPath = await sdk.endpointManager.getAuthenticatedPath();
118
- uiUrl = `${authPath}endpoint/scrypted-detection-trainer/public/`;
139
+ uiUrl = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
119
140
  } catch {
120
141
  uiUrl = '/endpoint/scrypted-detection-trainer/public/';
121
142
  }
@@ -250,7 +271,6 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
250
271
  };
251
272
 
252
273
  this.captures.set(id, record);
253
- this.images.set(id, jpeg);
254
274
  this.saveImage(id, jpeg);
255
275
  this.saveCaptures();
256
276
 
@@ -260,19 +280,13 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
260
280
  // ── HTTP Handler ──────────────────────────────────────────────────────────
261
281
 
262
282
  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
283
  const url = new URL(request.url, 'http://localhost');
270
284
  const path = url.pathname.replace(request.rootPath, '');
271
285
 
272
286
  // Serve image
273
287
  if (path.startsWith('/img/')) {
274
- const id = path.slice(5);
275
- const img = this.images.get(id);
288
+ const id = path.slice(5).replace(/[^a-zA-Z0-9_\-]/g, ''); // sanitize
289
+ const img = this.loadImage(id);
276
290
  if (!img) return response.send('Not found', { code: 404 });
277
291
  return response.send(img, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
278
292
  }
@@ -303,6 +317,17 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
303
317
  return response.send(JSON.stringify(pending), { headers: { 'Content-Type': 'application/json' } });
304
318
  }
305
319
 
320
+ // API: get labeled captures
321
+ if (path === '/api/labeled') {
322
+ const page = parseInt(new URL(request.url, 'http://localhost').searchParams.get('page') || '0');
323
+ const pageSize = 50;
324
+ const labeled = [...this.captures.values()]
325
+ .filter(c => c.reviewed)
326
+ .sort((a, b) => b.timestamp - a.timestamp);
327
+ const slice = labeled.slice(page * pageSize, (page + 1) * pageSize);
328
+ return response.send(JSON.stringify({ items: slice, total: labeled.length, page, pageSize }), { headers: { 'Content-Type': 'application/json' } });
329
+ }
330
+
306
331
  // API: stats
307
332
  if (path === '/api/stats') {
308
333
  const all = [...this.captures.values()];
@@ -335,7 +360,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
335
360
  const dataset: { filename: string; content: string; encoding: string }[] = [];
336
361
 
337
362
  for (const record of labeled) {
338
- const img = this.images.get(record.id);
363
+ const img = this.loadImage(record.id);
339
364
  if (!img) continue;
340
365
 
341
366
  const fname = `${record.id}`;
@@ -483,6 +508,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
483
508
  <div class="card">
484
509
  <div class="tab-bar">
485
510
  <div class="tab active" onclick="showTab('review')">Review</div>
511
+ <div class="tab" onclick="showTab('labeled')">Labeled</div>
486
512
  <div class="tab" onclick="showTab('stats')">Stats</div>
487
513
  <div class="tab" onclick="showTab('export')">Export Dataset</div>
488
514
  </div>
@@ -492,6 +518,11 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
492
518
  <div id="detections-list"></div>
493
519
  </div>
494
520
 
521
+ <!-- Labeled tab -->
522
+ <div class="tab-panel" id="tab-labeled">
523
+ <div id="labeled-list"></div>
524
+ </div>
525
+
495
526
  <!-- Stats tab -->
496
527
  <div class="tab-panel" id="tab-stats">
497
528
  <div class="tab-content">
@@ -649,14 +680,134 @@ function lbKeyHandler(e) {
649
680
  }
650
681
 
651
682
  function showTab(name) {
683
+ const names = ['review', 'labeled', 'stats', 'export'];
652
684
  document.querySelectorAll('.tab').forEach((t, i) => {
653
- const names = ['review', 'stats', 'export'];
654
685
  t.classList.toggle('active', names[i] === name);
655
686
  });
656
687
  document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
657
688
  document.getElementById('tab-' + name).classList.add('active');
658
689
  if (name === 'stats') loadStats();
659
690
  if (name === 'export') loadExportInfo();
691
+ if (name === 'labeled') loadLabeled(0);
692
+ }
693
+
694
+ const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };
695
+
696
+ async function loadLabeled(page) {
697
+ const list = document.getElementById('labeled-list');
698
+ list.innerHTML = '<div class="empty"><div style="color:#888">Loading…</div></div>';
699
+ try {
700
+ const res = await fetch(BASE + '/api/labeled?page=' + page);
701
+ const data = await res.json();
702
+ const { items, total, pageSize } = data;
703
+ const totalPages = Math.ceil(total / pageSize);
704
+
705
+ if (!items.length) {
706
+ list.innerHTML = '<div class="empty"><div class="icon">🏷️</div><div>No labeled detections yet.</div></div>';
707
+ return;
708
+ }
709
+
710
+ const pagerHtml = totalPages > 1 ? \`
711
+ <div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-top:1px solid #222;font-size:13px;color:#888;">
712
+ \${page > 0 ? \`<button class="label-btn" onclick="loadLabeled(\${page-1})">← Prev</button>\` : ''}
713
+ <span>Page \${page+1} of \${totalPages} · \${total} total</span>
714
+ \${page < totalPages-1 ? \`<button class="label-btn" onclick="loadLabeled(\${page+1})">Next →</button>\` : ''}
715
+ </div>\` : '';
716
+
717
+ list.innerHTML = items.map(r => {
718
+ const date = new Date(r.timestamp).toLocaleString();
719
+ const score = Math.round(r.score * 100);
720
+ const labelColor = LABEL_COLORS[r.label] || '#aaa';
721
+ return \`
722
+ <div class="detection" id="ldet-\${r.id}">
723
+ <div class="detection-imgs">
724
+ <div class="img-panel">
725
+ <div class="img-label">Full frame</div>
726
+ <canvas id="lcanvas-full-\${r.id}" class="det-canvas" width="240" height="160"></canvas>
727
+ </div>
728
+ <div class="img-panel">
729
+ <div class="img-label">Crop</div>
730
+ <canvas id="lcanvas-crop-\${r.id}" class="det-canvas" width="160" height="160"></canvas>
731
+ </div>
732
+ </div>
733
+ <div class="detection-info">
734
+ <div class="detection-meta">
735
+ <div><strong>\${r.cameraName}</strong></div>
736
+ <div>\${date}</div>
737
+ <div class="det-class-badge">\${r.detectedClass} \${score}%</div>
738
+ <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>
739
+ </div>
740
+ <div style="font-size:12px;color:#888;">Change label:</div>
741
+ <div class="label-buttons">
742
+ <button class="label-btn person" onclick="relabel('\${r.id}', 'person')">👤 Person</button>
743
+ <button class="label-btn animal" onclick="relabel('\${r.id}', 'animal')">🐾 Animal</button>
744
+ <button class="label-btn face" onclick="relabel('\${r.id}', 'face')">😀 Face</button>
745
+ <button class="label-btn vehicle" onclick="relabel('\${r.id}', 'vehicle')">🚗 Vehicle</button>
746
+ <button class="label-btn" onclick="relabel('\${r.id}', 'plate')">🔢 Plate</button>
747
+ <button class="label-btn" onclick="relabel('\${r.id}', 'package')">📦 Package</button>
748
+ <button class="label-btn discard" onclick="relabel('\${r.id}', 'discard')">🗑 Discard</button>
749
+ </div>
750
+ </div>
751
+ </div>\`;
752
+ }).join('') + pagerHtml;
753
+
754
+ // Draw bounding boxes
755
+ for (const r of items) {
756
+ const img = new Image();
757
+ img.onload = () => {
758
+ imgCache.set(r.id, img);
759
+ // Reuse drawDetection with the labeled canvases
760
+ const origFull = document.getElementById('canvas-full-' + r.id);
761
+ const origCrop = document.getElementById('canvas-crop-' + r.id);
762
+ // Temporarily point to labeled canvases
763
+ const fakeFull = document.getElementById('lcanvas-full-' + r.id);
764
+ const fakeCrop = document.getElementById('lcanvas-crop-' + r.id);
765
+ if (fakeFull) fakeFull.id = 'canvas-full-' + r.id;
766
+ if (fakeCrop) fakeCrop.id = 'canvas-crop-' + r.id;
767
+ drawDetection(img, r);
768
+ if (fakeFull) fakeFull.id = 'lcanvas-full-' + r.id;
769
+ if (fakeCrop) fakeCrop.id = 'lcanvas-crop-' + r.id;
770
+ if (fakeFull) fakeFull.onclick = () => openLightbox(r);
771
+ if (fakeCrop) fakeCrop.onclick = () => openLightbox(r);
772
+ };
773
+ img.src = BASE + '/img/' + r.id;
774
+ }
775
+ } catch(e) {
776
+ list.innerHTML = '<div class="empty"><div style="color:#a44">Error: ' + e.message + '</div></div>';
777
+ }
778
+ }
779
+
780
+ async function relabel(id, labelVal) {
781
+ await fetch(BASE + '/api/label', {
782
+ method: 'POST',
783
+ headers: { 'Content-Type': 'application/json' },
784
+ body: JSON.stringify({ id, label: labelVal }),
785
+ });
786
+ const el = document.getElementById('ldet-' + id);
787
+ if (labelVal === 'discard') {
788
+ if (el) el.remove();
789
+ toast('Discarded', '#633');
790
+ } else {
791
+ // Update the label badge in place
792
+ const badge = el && el.querySelector('[style*="✓"]');
793
+ const labelColor = LABEL_COLORS[labelVal] || '#aaa';
794
+ if (el) {
795
+ const badges = el.querySelectorAll('.detection-meta > div');
796
+ badges.forEach(b => { if (b.textContent.startsWith('✓')) b.remove(); });
797
+ const meta = el.querySelector('.detection-meta');
798
+ const newBadge = document.createElement('div');
799
+ newBadge.style.cssText = \`display:inline-block;background:#1a1a1a;border:1px solid \${labelColor};color:\${labelColor};font-size:11px;padding:2px 8px;border-radius:4px;\`;
800
+ newBadge.textContent = '✓ ' + labelVal;
801
+ meta.appendChild(newBadge);
802
+ }
803
+ toast('Re-labeled: ' + labelVal, '#1a6');
804
+ }
805
+ // Refresh stats
806
+ const statsRes = await fetch(BASE + '/api/stats');
807
+ const stats = await statsRes.json();
808
+ document.getElementById('stat-pending').textContent = stats.pending;
809
+ document.getElementById('stat-labeled').textContent = stats.labeled;
810
+ document.getElementById('stat-total').textContent = stats.total;
660
811
  }
661
812
 
662
813
  function toast(msg, color='#1a3') {