scrypted-detection-trainer 0.1.10 → 0.1.11

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.11",
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);
@@ -981,12 +999,7 @@ async function loadBrowse() {
981
999
  }
982
1000
 
983
1001
  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)
1002
+ fetch(BASE + '/api/browse-img?cameraId=' + ev.cameraId + '&thumbnailId=' + encodeURIComponent(ev.thumbnailId))
990
1003
  .then(r => r.ok ? r.blob() : null)
991
1004
  .then(blob => {
992
1005
  if (!blob) return;
@@ -994,15 +1007,27 @@ function loadBrowseImage(i, ev) {
994
1007
  const img = new Image();
995
1008
  img.onload = () => {
996
1009
  imgCache.set('browse-' + i, img);
997
- // Draw on full canvas
998
1010
  const fullCanvas = document.getElementById('bcanvas-full-' + i);
999
1011
  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); }
1012
+ const iw = img.naturalWidth, ih = img.naturalHeight;
1013
+ // No bounding box for clip thumbnails — just draw the full image
1014
+ if (fullCanvas) {
1015
+ const ctx = fullCanvas.getContext('2d');
1016
+ const cw = fullCanvas.width, ch = fullCanvas.height;
1017
+ const scale = Math.min(cw / iw, ch / ih);
1018
+ const dw = iw * scale, dh = ih * scale;
1019
+ ctx.fillStyle = '#111'; ctx.fillRect(0, 0, cw, ch);
1020
+ ctx.drawImage(img, (cw-dw)/2, (ch-dh)/2, dw, dh);
1021
+ // Label classes
1022
+ const labels = (ev.detectionClasses || []).join(', ');
1023
+ ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(0, ch-20, cw, 20);
1024
+ ctx.fillStyle = '#f90'; ctx.font = 'bold 11px sans-serif';
1025
+ ctx.fillText(labels, 4, ch-5);
1026
+ fullCanvas.onclick = () => openLightboxImg(img, ev.cameraName, ev.timestamp);
1027
+ }
1028
+ // Hide crop panel — no bounding box available
1029
+ const cropPanel = document.getElementById('bcanvas-crop-' + i)?.closest('.img-panel') as HTMLElement;
1030
+ if (cropPanel) cropPanel.style.display = 'none';
1006
1031
  URL.revokeObjectURL(url);
1007
1032
  };
1008
1033
  img.src = url;
@@ -1016,8 +1041,6 @@ async function addBrowseEvent(i, label) {
1016
1041
  if (el) { el.style.opacity = '0.4'; el.querySelectorAll('button').forEach(b => b.disabled = true); }
1017
1042
 
1018
1043
  if (label !== 'discard') {
1019
- const primary = (ev.detections || [])[0];
1020
- if (!primary) return;
1021
1044
  try {
1022
1045
  const res = await fetch(BASE + '/api/add-event', {
1023
1046
  method: 'POST',
@@ -1025,12 +1048,11 @@ async function addBrowseEvent(i, label) {
1025
1048
  body: JSON.stringify({
1026
1049
  cameraId: ev.cameraId,
1027
1050
  cameraName: ev.cameraName,
1028
- detectionId: ev.detectionId,
1051
+ thumbnailId: ev.thumbnailId,
1029
1052
  timestamp: ev.timestamp,
1030
- detectedClass: primary.className,
1031
- score: primary.score,
1032
- boundingBox: primary.boundingBox,
1033
- inputDimensions: ev.inputDimensions,
1053
+ detectedClass: (ev.detectionClasses || [])[0] || 'unknown',
1054
+ boundingBox: null,
1055
+ inputDimensions: null,
1034
1056
  label,
1035
1057
  }),
1036
1058
  });