scrypted-detection-trainer 0.1.9 → 0.1.10

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.9",
3
+ "version": "0.1.10",
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
@@ -157,6 +157,13 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
157
157
  readonly: true,
158
158
  value: `<a href="${uiUrl}" target="_blank" style="display:inline-block;padding:8px 16px;background:#1a4d8a;color:#fff;border-radius:6px;text-decoration:none;font-size:13px;">Open Review UI ↗</a>`,
159
159
  },
160
+ {
161
+ key: 'autoCapture',
162
+ title: 'Auto-Capture',
163
+ description: 'Automatically capture detections in the background. Disable to use manual browsing only.',
164
+ type: 'boolean',
165
+ value: (this.storage.getItem('autoCapture') ?? 'true'),
166
+ },
160
167
  ];
161
168
 
162
169
  for (const cam of cameras) {
@@ -214,6 +221,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
214
221
  // ── Detection Handler ─────────────────────────────────────────────────────
215
222
 
216
223
  private async onDetection(cameraId: string, cameraName: string, data: ObjectsDetected, rateLimitMs: number) {
224
+ if ((this.storage.getItem('autoCapture') ?? 'true') === 'false') return;
217
225
  if (!data?.detections?.length || !data.inputDimensions) return;
218
226
 
219
227
  // Rate limit per camera
@@ -283,6 +291,22 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
283
291
  const url = new URL(request.url, 'http://localhost');
284
292
  const path = url.pathname.replace(request.rootPath, '');
285
293
 
294
+ // Serve browse event image (fetch on demand from camera)
295
+ if (path === '/api/browse-img') {
296
+ const params = new URL(request.url, 'http://localhost').searchParams;
297
+ const cameraId = params.get('cameraId')?.replace(/[^a-zA-Z0-9_\-]/g, '');
298
+ const detectionId = params.get('detectionId')?.replace(/[^a-zA-Z0-9_\-]/g, '');
299
+ if (!cameraId || !detectionId) return response.send('Missing params', { code: 400 });
300
+ try {
301
+ const cam = systemManager.getDeviceById(cameraId) as unknown as ObjectDetector;
302
+ const mo = await cam.getDetectionInput(detectionId);
303
+ const jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
304
+ return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=60' } });
305
+ } catch (e) {
306
+ return response.send('Image unavailable', { code: 404 });
307
+ }
308
+ }
309
+
286
310
  // Serve image
287
311
  if (path.startsWith('/img/')) {
288
312
  const id = path.slice(5).replace(/[^a-zA-Z0-9_\-]/g, ''); // sanitize
@@ -308,6 +332,89 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
308
332
  return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
309
333
  }
310
334
 
335
+ // API: list cameras for browse
336
+ if (path === '/api/cameras') {
337
+ const cameras = Object.keys(systemManager.getSystemState())
338
+ .map(id => systemManager.getDeviceById(id))
339
+ .filter(d => d &&
340
+ (d.type === ScryptedDeviceType.Camera || d.type === ScryptedDeviceType.Doorbell) &&
341
+ d.interfaces?.includes(ScryptedInterface.ObjectDetector)
342
+ )
343
+ .map(d => ({ id: d.id, name: d.name }));
344
+ return response.send(JSON.stringify(cameras), { headers: { 'Content-Type': 'application/json' } });
345
+ }
346
+
347
+ // API: browse recent events for a camera
348
+ if (path === '/api/browse') {
349
+ const params = new URL(request.url, 'http://localhost').searchParams;
350
+ const cameraId = params.get('cameraId');
351
+ const hours = parseInt(params.get('hours') || '24');
352
+ if (!cameraId) return response.send('Missing cameraId', { code: 400 });
353
+
354
+ try {
355
+ const cam = systemManager.getDeviceById(cameraId) as any;
356
+ if (!cam) return response.send('Camera not found', { code: 404 });
357
+
358
+ const endTime = Date.now();
359
+ const startTime = endTime - hours * 3600 * 1000;
360
+ const events = await cam.getRecordedEvents({ startTime, endTime });
361
+
362
+ // Filter to ObjectDetector events only and limit to 100
363
+ const detectionEvents = (events || [])
364
+ .filter((e: any) => e.details?.eventInterface === 'ObjectDetector' && e.data?.detections?.length)
365
+ .slice(0, 100)
366
+ .map((e: any) => ({
367
+ detectionId: e.data?.detectionId,
368
+ timestamp: e.details?.eventTime || e.data?.timestamp,
369
+ detections: (e.data?.detections || []).map((d: any) => ({
370
+ className: d.className,
371
+ score: d.score,
372
+ boundingBox: d.boundingBox,
373
+ })),
374
+ inputDimensions: e.data?.inputDimensions,
375
+ cameraId,
376
+ cameraName: cam.name,
377
+ }))
378
+ .filter((e: any) => e.detectionId && e.inputDimensions);
379
+
380
+ return response.send(JSON.stringify(detectionEvents), { headers: { 'Content-Type': 'application/json' } });
381
+ } catch (e: any) {
382
+ return response.send(JSON.stringify({ error: e.message }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
383
+ }
384
+ }
385
+
386
+ // API: add a browsed event directly to dataset as labeled
387
+ if (path === '/api/add-event' && request.body) {
388
+ const rawBody = request.body as any;
389
+ const body = JSON.parse(typeof rawBody === 'string' ? rawBody : Buffer.isBuffer(rawBody) ? rawBody.toString() : String(rawBody));
390
+ const { cameraId, cameraName, detectionId, timestamp, detectedClass, score, boundingBox, inputDimensions, label } = body;
391
+
392
+ if (!label || label === 'discard') return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
393
+
394
+ // Try to get the image
395
+ let jpeg: Buffer | undefined;
396
+ try {
397
+ const cam = systemManager.getDeviceById(cameraId) as unknown as ObjectDetector;
398
+ const mo = await cam.getDetectionInput(detectionId);
399
+ jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
400
+ } catch (e) {
401
+ this.console.warn(`Could not get image for browse event ${detectionId}:`, e);
402
+ }
403
+
404
+ if (!jpeg) return response.send(JSON.stringify({ error: 'Could not retrieve image' }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
405
+
406
+ const id = `browse-${timestamp}-${Math.random().toString(36).slice(2, 6)}`;
407
+ const record: CaptureRecord = {
408
+ id, cameraId, cameraName, timestamp, detectedClass, score,
409
+ boundingBox, inputDimensions, detectionId, reviewed: true, label,
410
+ };
411
+ this.captures.set(id, record);
412
+ this.saveImage(id, jpeg);
413
+ this.saveCaptures();
414
+
415
+ return response.send(JSON.stringify({ ok: true, id }), { headers: { 'Content-Type': 'application/json' } });
416
+ }
417
+
311
418
  // API: get pending captures
312
419
  if (path === '/api/pending') {
313
420
  const pending = [...this.captures.values()]
@@ -508,6 +615,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
508
615
  <div class="card">
509
616
  <div class="tab-bar">
510
617
  <div class="tab active" onclick="showTab('review')">Review</div>
618
+ <div class="tab" onclick="showTab('browse')">Browse Events</div>
511
619
  <div class="tab" onclick="showTab('labeled')">Labeled</div>
512
620
  <div class="tab" onclick="showTab('stats')">Stats</div>
513
621
  <div class="tab" onclick="showTab('export')">Export Dataset</div>
@@ -518,12 +626,30 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
518
626
  <div id="detections-list"></div>
519
627
  </div>
520
628
 
629
+ <!-- Browse tab -->
630
+ <div class="tab-panel" id="tab-browse">
631
+ <div class="tab-content">
632
+ <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px;">
633
+ <select id="browse-camera" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
634
+ <option value="">Select camera…</option>
635
+ </select>
636
+ <select id="browse-hours" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
637
+ <option value="1">Last 1 hour</option>
638
+ <option value="6">Last 6 hours</option>
639
+ <option value="24" selected>Last 24 hours</option>
640
+ <option value="72">Last 3 days</option>
641
+ </select>
642
+ <button class="export-btn" onclick="loadBrowse()" style="padding:8px 16px;">Load Events</button>
643
+ <span id="browse-status" style="font-size:13px;color:#888;"></span>
644
+ </div>
645
+ <div id="browse-list"></div>
646
+ </div>
647
+ </div>
648
+
521
649
  <!-- Labeled tab -->
522
650
  <div class="tab-panel" id="tab-labeled">
523
651
  <div id="labeled-list"></div>
524
652
  </div>
525
-
526
- <!-- Stats tab -->
527
653
  <div class="tab-panel" id="tab-stats">
528
654
  <div class="tab-content">
529
655
  <p style="font-size:13px;color:#888;margin-bottom:16px;">Breakdown of captured and labeled detections.</p>
@@ -680,7 +806,7 @@ function lbKeyHandler(e) {
680
806
  }
681
807
 
682
808
  function showTab(name) {
683
- const names = ['review', 'labeled', 'stats', 'export'];
809
+ const names = ['review', 'browse', 'labeled', 'stats', 'export'];
684
810
  document.querySelectorAll('.tab').forEach((t, i) => {
685
811
  t.classList.toggle('active', names[i] === name);
686
812
  });
@@ -689,6 +815,246 @@ function showTab(name) {
689
815
  if (name === 'stats') loadStats();
690
816
  if (name === 'export') loadExportInfo();
691
817
  if (name === 'labeled') loadLabeled(0);
818
+ if (name === 'browse') initBrowse();
819
+ }
820
+
821
+ async function initBrowse() {
822
+ const sel = document.getElementById('browse-camera');
823
+ if (sel.options.length > 1) return; // already loaded
824
+ try {
825
+ const res = await fetch(BASE + '/api/cameras');
826
+ const cameras = await res.json();
827
+ for (const cam of cameras) {
828
+ const opt = document.createElement('option');
829
+ opt.value = cam.id;
830
+ opt.textContent = cam.name;
831
+ sel.appendChild(opt);
832
+ }
833
+ if (cameras.length === 1) sel.value = cameras[0].id;
834
+ } catch(e) {
835
+ document.getElementById('browse-status').textContent = 'Error loading cameras';
836
+ }
837
+ }
838
+
839
+ async function loadBrowse() {
840
+ const cameraId = document.getElementById('browse-camera').value;
841
+ const hours = document.getElementById('browse-hours').value;
842
+ const status = document.getElementById('browse-status');
843
+ const list = document.getElementById('browse-list');
844
+
845
+ if (!cameraId) { status.textContent = 'Select a camera first'; return; }
846
+
847
+ status.textContent = 'Loading…';
848
+ list.innerHTML = '';
849
+
850
+ try {
851
+ const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
852
+ const events = await res.json();
853
+
854
+ if (events.error) { status.textContent = 'Error: ' + events.error; return; }
855
+ if (!events.length) { status.textContent = 'No detection events found.'; list.innerHTML = '<div class="empty"><div class="icon">🔍</div><div>No ObjectDetector events in this time range.</div></div>'; return; }
856
+
857
+ status.textContent = events.length + ' events found';
858
+
859
+ list.innerHTML = events.map((ev, i) => {
860
+ const date = new Date(ev.timestamp).toLocaleString();
861
+ const dets = ev.detections || [];
862
+ const primary = dets[0] || {};
863
+ const score = Math.round((primary.score || 0) * 100);
864
+ const allClasses = dets.map(d => d.className + ' ' + Math.round((d.score||0)*100) + '%').join(', ');
865
+ return \`
866
+ <div class="detection" id="bev-\${i}" style="opacity:1;transition:opacity .3s">
867
+ <div class="detection-imgs">
868
+ <div class="img-panel">
869
+ <div class="img-label">Full frame</div>
870
+ <canvas id="bcanvas-\${i}" class="det-canvas" width="240" height="160"></canvas>
871
+ </div>
872
+ <div class="img-panel" id="bcrop-panel-\${i}">
873
+ <div class="img-label">Crop</div>
874
+ <canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
875
+ </div>
876
+ </div>
877
+ <div class="detection-info">
878
+ <div class="detection-meta">
879
+ <div><strong>\${ev.cameraName}</strong></div>
880
+ <div>\${date}</div>
881
+ <div class="det-class-badge">\${allClasses}</div>
882
+ </div>
883
+ <div style="font-size:12px;color:#888;">Add to dataset as:</div>
884
+ <div class="label-buttons" id="blabels-\${i}">
885
+ <button class="label-btn person" onclick="addEvent(\${i})('person')">👤 Person</button>
886
+ <button class="label-btn animal" onclick="addEvent(\${i})('animal')">🐾 Animal</button>
887
+ <button class="label-btn face" onclick="addEvent(\${i})('face')">😀 Face</button>
888
+ <button class="label-btn vehicle" onclick="addEvent(\${i})('vehicle')">🚗 Vehicle</button>
889
+ <button class="label-btn" onclick="addEvent(\${i})('plate')">🔢 Plate</button>
890
+ <button class="label-btn" onclick="addEvent(\${i})('package')">📦 Package</button>
891
+ <button class="label-btn discard" onclick="addEvent(\${i})('discard')">🗑 Skip</button>
892
+ </div>
893
+ </div>
894
+ </div>\`;
895
+ }).join('');
896
+
897
+ // Load images for each event
898
+ for (let i = 0; i < events.length; i++) {
899
+ const ev = events[i];
900
+ loadBrowseImage(i, ev);
901
+ }
902
+
903
+ } catch(e) {
904
+ status.textContent = 'Error: ' + e.message;
905
+ }
906
+ }
907
+
908
+ // Store browse events for addEvent closure
909
+ let browseEvents = [];
910
+
911
+ async function loadBrowse() {
912
+ const cameraId = document.getElementById('browse-camera').value;
913
+ const hours = document.getElementById('browse-hours').value;
914
+ const status = document.getElementById('browse-status');
915
+ const list = document.getElementById('browse-list');
916
+
917
+ if (!cameraId) { status.textContent = 'Select a camera first'; return; }
918
+
919
+ status.textContent = 'Loading…';
920
+ list.innerHTML = '';
921
+ browseEvents = [];
922
+
923
+ try {
924
+ const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
925
+ const events = await res.json();
926
+
927
+ if (events.error) { status.textContent = 'Error: ' + events.error; return; }
928
+ if (!events.length) {
929
+ status.textContent = 'No detection events found.';
930
+ list.innerHTML = '<div class="empty"><div class="icon">🔍</div><div>No ObjectDetector events in this time range.</div></div>';
931
+ return;
932
+ }
933
+
934
+ browseEvents = events;
935
+ status.textContent = events.length + ' events';
936
+
937
+ list.innerHTML = events.map((ev, i) => {
938
+ const date = new Date(ev.timestamp).toLocaleString();
939
+ const dets = ev.detections || [];
940
+ const allClasses = [...new Set(dets.map(d => d.className))].join(', ');
941
+ return \`
942
+ <div class="detection" id="bev-\${i}">
943
+ <div class="detection-imgs">
944
+ <div class="img-panel">
945
+ <div class="img-label">Full frame</div>
946
+ <canvas id="bcanvas-full-\${i}" class="det-canvas" width="240" height="160"></canvas>
947
+ </div>
948
+ <div class="img-panel">
949
+ <div class="img-label">Crop</div>
950
+ <canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
951
+ </div>
952
+ </div>
953
+ <div class="detection-info">
954
+ <div class="detection-meta">
955
+ <div><strong>\${ev.cameraName}</strong></div>
956
+ <div>\${date}</div>
957
+ <div class="det-class-badge">\${allClasses}</div>
958
+ </div>
959
+ <div style="font-size:12px;color:#888;">Add to dataset as:</div>
960
+ <div class="label-buttons">
961
+ <button class="label-btn person" onclick="addBrowseEvent(\${i},'person')">👤 Person</button>
962
+ <button class="label-btn animal" onclick="addBrowseEvent(\${i},'animal')">🐾 Animal</button>
963
+ <button class="label-btn face" onclick="addBrowseEvent(\${i},'face')">😀 Face</button>
964
+ <button class="label-btn vehicle" onclick="addBrowseEvent(\${i},'vehicle')">🚗 Vehicle</button>
965
+ <button class="label-btn" onclick="addBrowseEvent(\${i},'plate')">🔢 Plate</button>
966
+ <button class="label-btn" onclick="addBrowseEvent(\${i},'package')">📦 Package</button>
967
+ <button class="label-btn discard" onclick="addBrowseEvent(\${i},'discard')">🗑 Skip</button>
968
+ </div>
969
+ </div>
970
+ </div>\`;
971
+ }).join('');
972
+
973
+ // Load thumbnails for each event
974
+ for (let i = 0; i < events.length; i++) {
975
+ loadBrowseImage(i, events[i]);
976
+ }
977
+
978
+ } catch(e) {
979
+ status.textContent = 'Error: ' + e.message;
980
+ }
981
+ }
982
+
983
+ function loadBrowseImage(i, ev) {
984
+ const primary = (ev.detections || [])[0];
985
+ if (!primary?.boundingBox) return;
986
+ // Request image via the img endpoint using detectionId as the key
987
+ // We store a browse-prefixed image server-side only after adding — for preview
988
+ // use a placeholder fetch to trigger server-side caching
989
+ fetch(BASE + '/api/browse-img?cameraId=' + ev.cameraId + '&detectionId=' + ev.detectionId)
990
+ .then(r => r.ok ? r.blob() : null)
991
+ .then(blob => {
992
+ if (!blob) return;
993
+ const url = URL.createObjectURL(blob);
994
+ const img = new Image();
995
+ img.onload = () => {
996
+ imgCache.set('browse-' + i, img);
997
+ // Draw on full canvas
998
+ const fullCanvas = document.getElementById('bcanvas-full-' + i);
999
+ const cropCanvas = document.getElementById('bcanvas-crop-' + i);
1000
+ if (fullCanvas) fullCanvas.id = 'canvas-full-browse' + i;
1001
+ if (cropCanvas) cropCanvas.id = 'canvas-crop-browse' + i;
1002
+ const fakeR = { ...ev, id: 'browse' + i, boundingBox: primary.boundingBox, detectedClass: primary.className, score: primary.score };
1003
+ drawDetection(img, fakeR);
1004
+ if (fullCanvas) { fullCanvas.id = 'bcanvas-full-' + i; fullCanvas.onclick = () => openLightbox(fakeR); }
1005
+ if (cropCanvas) { cropCanvas.id = 'bcanvas-crop-' + i; cropCanvas.onclick = () => openLightbox(fakeR); }
1006
+ URL.revokeObjectURL(url);
1007
+ };
1008
+ img.src = url;
1009
+ }).catch(() => {});
1010
+ }
1011
+
1012
+ async function addBrowseEvent(i, label) {
1013
+ const ev = browseEvents[i];
1014
+ if (!ev) return;
1015
+ const el = document.getElementById('bev-' + i);
1016
+ if (el) { el.style.opacity = '0.4'; el.querySelectorAll('button').forEach(b => b.disabled = true); }
1017
+
1018
+ if (label !== 'discard') {
1019
+ const primary = (ev.detections || [])[0];
1020
+ if (!primary) return;
1021
+ try {
1022
+ const res = await fetch(BASE + '/api/add-event', {
1023
+ method: 'POST',
1024
+ headers: { 'Content-Type': 'application/json' },
1025
+ body: JSON.stringify({
1026
+ cameraId: ev.cameraId,
1027
+ cameraName: ev.cameraName,
1028
+ detectionId: ev.detectionId,
1029
+ timestamp: ev.timestamp,
1030
+ detectedClass: primary.className,
1031
+ score: primary.score,
1032
+ boundingBox: primary.boundingBox,
1033
+ inputDimensions: ev.inputDimensions,
1034
+ label,
1035
+ }),
1036
+ });
1037
+ const data = await res.json();
1038
+ if (data.error) { toast('Error: ' + data.error, '#633'); if (el) el.style.opacity = '1'; el?.querySelectorAll('button').forEach(b => b.disabled = false); return; }
1039
+ toast('Added: ' + label, '#1a6');
1040
+ } catch(e) {
1041
+ toast('Failed: ' + e.message, '#633');
1042
+ if (el) el.style.opacity = '1';
1043
+ el?.querySelectorAll('button').forEach(b => b.disabled = false);
1044
+ return;
1045
+ }
1046
+ } else {
1047
+ toast('Skipped', '#555');
1048
+ }
1049
+
1050
+ // Remove from list after short delay
1051
+ setTimeout(() => { if (el) el.remove(); }, 400);
1052
+
1053
+ // Update stats
1054
+ const statsRes = await fetch(BASE + '/api/stats');
1055
+ const stats = await statsRes.json();
1056
+ document.getElementById('stat-labeled').textContent = stats.labeled;
1057
+ document.getElementById('stat-total').textContent = stats.total;
692
1058
  }
693
1059
 
694
1060
  const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };