scrypted-detection-trainer 0.1.10 → 0.1.12

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.10",
3
+ "version": "0.1.12",
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
@@ -291,17 +291,17 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
291
291
  const url = new URL(request.url, 'http://localhost');
292
292
  const path = url.pathname.replace(request.rootPath, '');
293
293
 
294
- // Serve browse event image (fetch on demand from camera)
294
+ // Serve browse event image via getVideoClipThumbnail
295
295
  if (path === '/api/browse-img') {
296
296
  const params = new URL(request.url, 'http://localhost').searchParams;
297
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 });
298
+ const thumbnailId = params.get('thumbnailId')?.replace(/[^a-zA-Z0-9_\-:.]/g, '');
299
+ if (!cameraId || !thumbnailId) return response.send('Missing params', { code: 400 });
300
300
  try {
301
- const cam = systemManager.getDeviceById(cameraId) as unknown as ObjectDetector;
302
- const mo = await cam.getDetectionInput(detectionId);
301
+ const cam = systemManager.getDeviceById(cameraId) as any;
302
+ const mo = await cam.getVideoClipThumbnail(thumbnailId);
303
303
  const jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
304
- return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=60' } });
304
+ return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
305
305
  } catch (e) {
306
306
  return response.send('Image unavailable', { code: 404 });
307
307
  }
@@ -357,27 +357,24 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
357
357
 
358
358
  const endTime = Date.now();
359
359
  const startTime = endTime - hours * 3600 * 1000;
360
- const events = await cam.getRecordedEvents({ startTime, endTime });
360
+ const clips = await cam.getVideoClips({ startTime, endTime });
361
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)
362
+ const events = (clips || [])
363
+ .filter((c: any) => c.detectionClasses?.length && c.thumbnailId)
365
364
  .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,
365
+ .map((c: any) => ({
366
+ clipId: c.id,
367
+ thumbnailId: c.thumbnailId,
368
+ timestamp: c.startTime,
369
+ detectionClasses: c.detectionClasses || [],
370
+ // bounding box not available at clip level — use full frame
371
+ boundingBox: null,
372
+ inputDimensions: null,
375
373
  cameraId,
376
374
  cameraName: cam.name,
377
- }))
378
- .filter((e: any) => e.detectionId && e.inputDimensions);
375
+ }));
379
376
 
380
- return response.send(JSON.stringify(detectionEvents), { headers: { 'Content-Type': 'application/json' } });
377
+ return response.send(JSON.stringify(events), { headers: { 'Content-Type': 'application/json' } });
381
378
  } catch (e: any) {
382
379
  return response.send(JSON.stringify({ error: e.message }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
383
380
  }
@@ -387,26 +384,31 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
387
384
  if (path === '/api/add-event' && request.body) {
388
385
  const rawBody = request.body as any;
389
386
  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;
387
+ const { cameraId, cameraName, thumbnailId, timestamp, detectedClass, boundingBox, inputDimensions, label } = body;
391
388
 
392
389
  if (!label || label === 'discard') return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
393
390
 
394
- // Try to get the image
391
+ // Get image via thumbnail
395
392
  let jpeg: Buffer | undefined;
396
393
  try {
397
- const cam = systemManager.getDeviceById(cameraId) as unknown as ObjectDetector;
398
- const mo = await cam.getDetectionInput(detectionId);
394
+ const cam = systemManager.getDeviceById(cameraId) as any;
395
+ const mo = await cam.getVideoClipThumbnail(thumbnailId);
399
396
  jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
400
397
  } catch (e) {
401
- this.console.warn(`Could not get image for browse event ${detectionId}:`, e);
398
+ this.console.warn(`Could not get thumbnail for browse event:`, e);
402
399
  }
403
400
 
404
401
  if (!jpeg) return response.send(JSON.stringify({ error: 'Could not retrieve image' }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
405
402
 
406
403
  const id = `browse-${timestamp}-${Math.random().toString(36).slice(2, 6)}`;
404
+ // For clips we don't have per-detection bounding boxes — store full frame dimensions
407
405
  const record: CaptureRecord = {
408
- id, cameraId, cameraName, timestamp, detectedClass, score,
409
- boundingBox, inputDimensions, detectionId, reviewed: true, label,
406
+ id, cameraId, cameraName, timestamp,
407
+ detectedClass: detectedClass || 'unknown',
408
+ score: 1,
409
+ boundingBox: boundingBox || [0, 0, inputDimensions?.[0] || 1920, inputDimensions?.[1] || 1080],
410
+ inputDimensions: inputDimensions || [1920, 1080],
411
+ reviewed: true, label,
410
412
  };
411
413
  this.captures.set(id, record);
412
414
  this.saveImage(id, jpeg);
@@ -796,6 +798,22 @@ function openLightbox(r) {
796
798
  document.addEventListener('keydown', lbKeyHandler);
797
799
  }
798
800
 
801
+ function openLightboxImg(img, cameraName, timestamp) {
802
+ const lb = document.getElementById('lightbox');
803
+ const lbCanvas = document.getElementById('lightbox-canvas');
804
+ const maxW = window.innerWidth * 0.9;
805
+ const maxH = window.innerHeight * 0.8;
806
+ const scale = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1);
807
+ lbCanvas.width = Math.round(img.naturalWidth * scale);
808
+ lbCanvas.height = Math.round(img.naturalHeight * scale);
809
+ const ctx = lbCanvas.getContext('2d');
810
+ ctx.drawImage(img, 0, 0, lbCanvas.width, lbCanvas.height);
811
+ document.getElementById('lightbox-meta').textContent =
812
+ cameraName + ' · ' + new Date(timestamp).toLocaleString();
813
+ lb.classList.add('open');
814
+ document.addEventListener('keydown', lbKeyHandler);
815
+ }
816
+
799
817
  function closeLightbox() {
800
818
  document.getElementById('lightbox').classList.remove('open');
801
819
  document.removeEventListener('keydown', lbKeyHandler);
@@ -836,75 +854,6 @@ async function initBrowse() {
836
854
  }
837
855
  }
838
856
 
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
857
  // Store browse events for addEvent closure
909
858
  let browseEvents = [];
910
859
 
@@ -981,12 +930,7 @@ async function loadBrowse() {
981
930
  }
982
931
 
983
932
  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)
933
+ fetch(BASE + '/api/browse-img?cameraId=' + ev.cameraId + '&thumbnailId=' + encodeURIComponent(ev.thumbnailId))
990
934
  .then(r => r.ok ? r.blob() : null)
991
935
  .then(blob => {
992
936
  if (!blob) return;
@@ -994,15 +938,27 @@ function loadBrowseImage(i, ev) {
994
938
  const img = new Image();
995
939
  img.onload = () => {
996
940
  imgCache.set('browse-' + i, img);
997
- // Draw on full canvas
998
941
  const fullCanvas = document.getElementById('bcanvas-full-' + i);
999
942
  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); }
943
+ const iw = img.naturalWidth, ih = img.naturalHeight;
944
+ // No bounding box for clip thumbnails — just draw the full image
945
+ if (fullCanvas) {
946
+ const ctx = fullCanvas.getContext('2d');
947
+ const cw = fullCanvas.width, ch = fullCanvas.height;
948
+ const scale = Math.min(cw / iw, ch / ih);
949
+ const dw = iw * scale, dh = ih * scale;
950
+ ctx.fillStyle = '#111'; ctx.fillRect(0, 0, cw, ch);
951
+ ctx.drawImage(img, (cw-dw)/2, (ch-dh)/2, dw, dh);
952
+ // Label classes
953
+ const labels = (ev.detectionClasses || []).join(', ');
954
+ ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(0, ch-20, cw, 20);
955
+ ctx.fillStyle = '#f90'; ctx.font = 'bold 11px sans-serif';
956
+ ctx.fillText(labels, 4, ch-5);
957
+ fullCanvas.onclick = () => openLightboxImg(img, ev.cameraName, ev.timestamp);
958
+ }
959
+ // Hide crop panel — no bounding box available
960
+ const cropPanel = document.getElementById('bcanvas-crop-' + i)?.closest('.img-panel') as HTMLElement;
961
+ if (cropPanel) cropPanel.style.display = 'none';
1006
962
  URL.revokeObjectURL(url);
1007
963
  };
1008
964
  img.src = url;
@@ -1016,8 +972,6 @@ async function addBrowseEvent(i, label) {
1016
972
  if (el) { el.style.opacity = '0.4'; el.querySelectorAll('button').forEach(b => b.disabled = true); }
1017
973
 
1018
974
  if (label !== 'discard') {
1019
- const primary = (ev.detections || [])[0];
1020
- if (!primary) return;
1021
975
  try {
1022
976
  const res = await fetch(BASE + '/api/add-event', {
1023
977
  method: 'POST',
@@ -1025,12 +979,11 @@ async function addBrowseEvent(i, label) {
1025
979
  body: JSON.stringify({
1026
980
  cameraId: ev.cameraId,
1027
981
  cameraName: ev.cameraName,
1028
- detectionId: ev.detectionId,
982
+ thumbnailId: ev.thumbnailId,
1029
983
  timestamp: ev.timestamp,
1030
- detectedClass: primary.className,
1031
- score: primary.score,
1032
- boundingBox: primary.boundingBox,
1033
- inputDimensions: ev.inputDimensions,
984
+ detectedClass: (ev.detectionClasses || [])[0] || 'unknown',
985
+ boundingBox: null,
986
+ inputDimensions: null,
1034
987
  label,
1035
988
  }),
1036
989
  });