scrypted-detection-trainer 0.1.13 → 0.1.15

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.13",
3
+ "version": "0.1.15",
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,15 +291,15 @@ 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 via getVideoClipThumbnail
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 thumbnailId = params.get('thumbnailId')?.replace(/[^a-zA-Z0-9_\-:.]/g, '');
299
- if (!cameraId || !thumbnailId) return response.send('Missing params', { code: 400 });
294
+ // Serve browse event image via getDetectionInput
295
+ if (path === '/api/browse-img' && request.body) {
296
+ const rawBody = request.body as any;
297
+ const body = JSON.parse(typeof rawBody === 'string' ? rawBody : Buffer.isBuffer(rawBody) ? rawBody.toString() : String(rawBody));
298
+ const { cameraId, detectionId } = body;
299
+ if (!cameraId || !detectionId) return response.send('Missing params', { code: 400 });
300
300
  try {
301
- const cam = systemManager.getDeviceById(cameraId) as any;
302
- const mo = await cam.getVideoClipThumbnail(thumbnailId);
301
+ const cam = systemManager.getDeviceById(cameraId) as unknown as ObjectDetector;
302
+ const mo = await cam.getDetectionInput(detectionId);
303
303
  const jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
304
304
  return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
305
305
  } catch (e) {
@@ -349,6 +349,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
349
349
  const params = new URL(request.url, 'http://localhost').searchParams;
350
350
  const cameraId = params.get('cameraId');
351
351
  const hours = parseInt(params.get('hours') || '24');
352
+ const limit = parseInt(params.get('limit') || '100');
352
353
  if (!cameraId) return response.send('Missing cameraId', { code: 400 });
353
354
 
354
355
  try {
@@ -357,24 +358,35 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
357
358
 
358
359
  const endTime = Date.now();
359
360
  const startTime = endTime - hours * 3600 * 1000;
360
- const clips = await cam.getVideoClips({ startTime, endTime });
361
-
362
- const events = (clips || [])
363
- .filter((c: any) => c.detectionClasses?.length && c.thumbnailId)
364
- .slice(0, 100)
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,
373
- cameraId,
374
- cameraName: cam.name,
375
- }));
376
-
377
- return response.send(JSON.stringify(events), { headers: { 'Content-Type': 'application/json' } });
361
+
362
+ // Use getRecordedEvents to get ObjectDetector events with bounding boxes
363
+ const recorded = await cam.getRecordedEvents({ startTime, endTime });
364
+
365
+ const detectionEvents = (recorded || [])
366
+ .filter((e: any) =>
367
+ e.details?.eventInterface === 'ObjectDetector' &&
368
+ e.data?.detections?.length &&
369
+ e.data?.detectionId &&
370
+ e.data?.inputDimensions
371
+ )
372
+ .slice(0, limit)
373
+ .map((e: any) => {
374
+ const ts = e.details?.eventTime || e.data?.timestamp;
375
+ return {
376
+ detectionId: e.data.detectionId,
377
+ timestamp: ts,
378
+ detections: (e.data.detections || []).map((d: any) => ({
379
+ className: d.className,
380
+ score: d.score,
381
+ boundingBox: d.boundingBox,
382
+ })),
383
+ inputDimensions: e.data.inputDimensions,
384
+ cameraId,
385
+ cameraName: cam.name,
386
+ };
387
+ });
388
+
389
+ return response.send(JSON.stringify(detectionEvents), { headers: { 'Content-Type': 'application/json' } });
378
390
  } catch (e: any) {
379
391
  return response.send(JSON.stringify({ error: e.message }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
380
392
  }
@@ -384,30 +396,29 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
384
396
  if (path === '/api/add-event' && request.body) {
385
397
  const rawBody = request.body as any;
386
398
  const body = JSON.parse(typeof rawBody === 'string' ? rawBody : Buffer.isBuffer(rawBody) ? rawBody.toString() : String(rawBody));
387
- const { cameraId, cameraName, thumbnailId, timestamp, detectedClass, boundingBox, inputDimensions, label } = body;
399
+ const { cameraId, cameraName, detectionId, timestamp, detectedClass, score, boundingBox, inputDimensions, label } = body;
388
400
 
389
401
  if (!label || label === 'discard') return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
390
402
 
391
- // Get image via thumbnail
392
403
  let jpeg: Buffer | undefined;
393
404
  try {
394
- const cam = systemManager.getDeviceById(cameraId) as any;
395
- const mo = await cam.getVideoClipThumbnail(thumbnailId);
405
+ const cam = systemManager.getDeviceById(cameraId) as unknown as ObjectDetector;
406
+ const mo = await cam.getDetectionInput(detectionId);
396
407
  jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
397
408
  } catch (e) {
398
- this.console.warn(`Could not get thumbnail for browse event:`, e);
409
+ this.console.warn(`Could not get image for browse event:`, e);
399
410
  }
400
411
 
401
412
  if (!jpeg) return response.send(JSON.stringify({ error: 'Could not retrieve image' }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
402
413
 
403
414
  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
405
415
  const record: CaptureRecord = {
406
416
  id, cameraId, cameraName, timestamp,
407
417
  detectedClass: detectedClass || 'unknown',
408
- score: 1,
418
+ score: score || 1,
409
419
  boundingBox: boundingBox || [0, 0, inputDimensions?.[0] || 1920, inputDimensions?.[1] || 1080],
410
420
  inputDimensions: inputDimensions || [1920, 1080],
421
+ detectionId,
411
422
  reviewed: true, label,
412
423
  };
413
424
  this.captures.set(id, record);
@@ -641,6 +652,12 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
641
652
  <option value="24" selected>Last 24 hours</option>
642
653
  <option value="72">Last 3 days</option>
643
654
  </select>
655
+ <select id="browse-limit" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
656
+ <option value="50">50 events</option>
657
+ <option value="100" selected>100 events</option>
658
+ <option value="250">250 events</option>
659
+ <option value="500">500 events</option>
660
+ </select>
644
661
  <button class="export-btn" onclick="loadBrowse()" style="padding:8px 16px;">Load Events</button>
645
662
  <span id="browse-status" style="font-size:13px;color:#888;"></span>
646
663
  </div>
@@ -860,6 +877,7 @@ let browseEvents = [];
860
877
  async function loadBrowse() {
861
878
  const cameraId = document.getElementById('browse-camera').value;
862
879
  const hours = document.getElementById('browse-hours').value;
880
+ const limit = document.getElementById('browse-limit').value;
863
881
  const status = document.getElementById('browse-status');
864
882
  const list = document.getElementById('browse-list');
865
883
 
@@ -870,7 +888,7 @@ async function loadBrowse() {
870
888
  browseEvents = [];
871
889
 
872
890
  try {
873
- const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
891
+ const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours + '&limit=' + limit);
874
892
  const events = await res.json();
875
893
 
876
894
  if (events.error) { status.textContent = 'Error: ' + events.error; return; }
@@ -930,35 +948,29 @@ async function loadBrowse() {
930
948
  }
931
949
 
932
950
  function loadBrowseImage(i, ev) {
933
- fetch(BASE + '/api/browse-img?cameraId=' + ev.cameraId + '&thumbnailId=' + encodeURIComponent(ev.thumbnailId))
951
+ fetch(BASE + '/api/browse-img', {
952
+ method: 'POST',
953
+ headers: { 'Content-Type': 'application/json' },
954
+ body: JSON.stringify({ cameraId: ev.cameraId, detectionId: ev.detectionId }),
955
+ })
934
956
  .then(r => r.ok ? r.blob() : null)
935
957
  .then(blob => {
936
958
  if (!blob) return;
937
959
  const url = URL.createObjectURL(blob);
938
960
  const img = new Image();
939
961
  img.onload = () => {
962
+ const primary = (ev.detections || [])[0];
963
+ if (!primary?.boundingBox) return;
940
964
  imgCache.set('browse-' + i, img);
965
+ // Use drawDetection by temporarily remapping canvas IDs
941
966
  const fullCanvas = document.getElementById('bcanvas-full-' + i);
942
967
  const cropCanvas = document.getElementById('bcanvas-crop-' + i);
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');
961
- if (cropPanel) cropPanel.style.display = 'none';
968
+ if (fullCanvas) fullCanvas.id = 'canvas-full-' + i;
969
+ if (cropCanvas) cropCanvas.id = 'canvas-crop-' + i;
970
+ const fakeR = { id: String(i), boundingBox: primary.boundingBox, detectedClass: primary.className, score: primary.score, inputDimensions: ev.inputDimensions };
971
+ drawDetection(img, fakeR);
972
+ if (fullCanvas) { fullCanvas.id = 'bcanvas-full-' + i; fullCanvas.onclick = () => openLightbox(fakeR); }
973
+ if (cropCanvas) { cropCanvas.id = 'bcanvas-crop-' + i; cropCanvas.onclick = () => openLightbox(fakeR); }
962
974
  URL.revokeObjectURL(url);
963
975
  };
964
976
  img.src = url;
@@ -973,17 +985,19 @@ async function addBrowseEvent(i, label) {
973
985
 
974
986
  if (label !== 'discard') {
975
987
  try {
988
+ const primary = (ev.detections || [])[0] || {};
976
989
  const res = await fetch(BASE + '/api/add-event', {
977
990
  method: 'POST',
978
991
  headers: { 'Content-Type': 'application/json' },
979
992
  body: JSON.stringify({
980
993
  cameraId: ev.cameraId,
981
994
  cameraName: ev.cameraName,
982
- thumbnailId: ev.thumbnailId,
995
+ detectionId: ev.detectionId,
983
996
  timestamp: ev.timestamp,
984
- detectedClass: (ev.detectionClasses || [])[0] || 'unknown',
985
- boundingBox: null,
986
- inputDimensions: null,
997
+ detectedClass: primary.className || 'unknown',
998
+ score: primary.score || 1,
999
+ boundingBox: primary.boundingBox || null,
1000
+ inputDimensions: ev.inputDimensions || null,
987
1001
  label,
988
1002
  }),
989
1003
  });