scrypted-detection-trainer 0.1.6 → 0.1.7

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.6",
3
+ "version": "0.1.7",
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
@@ -112,7 +112,13 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
112
112
  d.interfaces?.includes(ScryptedInterface.ObjectDetector)
113
113
  );
114
114
 
115
- const uiUrl = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true }).catch(() => '/endpoint/scrypted-detection-trainer/public/');
115
+ let uiUrl: string;
116
+ try {
117
+ const authPath = await sdk.endpointManager.getAuthenticatedPath();
118
+ uiUrl = `${authPath}endpoint/scrypted-detection-trainer/public/`;
119
+ } catch {
120
+ uiUrl = '/endpoint/scrypted-detection-trainer/public/';
121
+ }
116
122
 
117
123
  const settings: Setting[] = [
118
124
  {
@@ -254,6 +260,12 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
254
260
  // ── HTTP Handler ──────────────────────────────────────────────────────────
255
261
 
256
262
  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
+
257
269
  const url = new URL(request.url, 'http://localhost');
258
270
  const path = url.pathname.replace(request.rootPath, '');
259
271
 
@@ -392,11 +404,13 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
392
404
  .badge.green { background: #0d2d0d; color: #4c4; }
393
405
 
394
406
  /* Detection card */
395
- .detection { display: grid; grid-template-columns: 200px 1fr; gap: 0; border-bottom: 1px solid #222; }
407
+ .detection { display: grid; grid-template-columns: 420px 1fr; gap: 0; border-bottom: 1px solid #222; }
396
408
  .detection:last-child { border-bottom: none; }
397
- .detection-img { position: relative; background: #111; display: flex; align-items: center; justify-content: center; min-height: 150px; }
398
- .detection-img img { width: 100%; height: 150px; object-fit: cover; display: block; }
399
- .detection-class { position: absolute; top: 6px; left: 6px; background: rgba(0,0,0,0.7); color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 4px; }
409
+ .detection-imgs { display: flex; gap: 8px; padding: 10px; background: #111; align-items: center; }
410
+ .img-panel { display: flex; flex-direction: column; align-items: center; gap: 4px; }
411
+ .img-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: .5px; }
412
+ .det-canvas { border-radius: 6px; display: block; }
413
+ .det-class-badge { display: inline-block; background: #3d2a00; color: #f90; font-size: 11px; padding: 2px 8px; border-radius: 4px; }
400
414
  .detection-info { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
401
415
  .detection-meta { font-size: 12px; color: #888; display: flex; flex-wrap: wrap; gap: 10px; }
402
416
  .detection-meta strong { color: #ccc; }
@@ -444,6 +458,15 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
444
458
  .breakdown-item { background: #222; border-radius: 8px; padding: 12px; }
445
459
  .breakdown-item .label { font-size: 12px; color: #888; margin-bottom: 4px; }
446
460
  .breakdown-item .value { font-size: 20px; font-weight: 600; color: #fff; }
461
+
462
+ /* Lightbox */
463
+ .lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 1000; align-items: center; justify-content: center; flex-direction: column; gap: 12px; }
464
+ .lightbox.open { display: flex; }
465
+ .lightbox canvas { max-width: 92vw; max-height: 82vh; border-radius: 8px; cursor: zoom-out; }
466
+ .lightbox-meta { color: #aaa; font-size: 13px; text-align: center; }
467
+ .lightbox-close { position: absolute; top: 16px; right: 20px; font-size: 28px; color: #888; cursor: pointer; line-height: 1; }
468
+ .lightbox-close:hover { color: #fff; }
469
+ .det-canvas { cursor: zoom-in; }
447
470
  </style>
448
471
  </head>
449
472
  <body>
@@ -499,6 +522,12 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
499
522
  </div>
500
523
  </div>
501
524
 
525
+ <div class="lightbox" id="lightbox" onclick="closeLightbox()">
526
+ <div class="lightbox-close" onclick="closeLightbox()">✕</div>
527
+ <canvas id="lightbox-canvas"></canvas>
528
+ <div class="lightbox-meta" id="lightbox-meta"></div>
529
+ </div>
530
+
502
531
  <div class="toast" id="toast"></div>
503
532
 
504
533
  <script>
@@ -510,6 +539,115 @@ function imgError(img) {
510
539
  img.parentElement.innerHTML = '<div style="padding:20px;color:#555;font-size:12px;text-align:center">No image</div>';
511
540
  }
512
541
 
542
+ function drawDetection(img, r) {
543
+ const [bx, by, bw, bh] = r.boundingBox;
544
+ const [iw, ih] = r.inputDimensions;
545
+
546
+ // --- Full frame canvas with box overlay ---
547
+ const fullCanvas = document.getElementById('canvas-full-' + r.id);
548
+ if (fullCanvas) {
549
+ const cw = fullCanvas.width, ch = fullCanvas.height;
550
+ const scale = Math.min(cw / iw, ch / ih);
551
+ const dw = iw * scale, dh = ih * scale;
552
+ const ox = (cw - dw) / 2, oy = (ch - dh) / 2;
553
+ const ctx = fullCanvas.getContext('2d');
554
+ ctx.fillStyle = '#111';
555
+ ctx.fillRect(0, 0, cw, ch);
556
+ ctx.drawImage(img, ox, oy, dw, dh);
557
+ // Draw bounding box
558
+ const rx = ox + bx * scale, ry = oy + by * scale;
559
+ const rw = bw * scale, rh = bh * scale;
560
+ ctx.strokeStyle = '#f90';
561
+ ctx.lineWidth = 2;
562
+ ctx.strokeRect(rx, ry, rw, rh);
563
+ // Label badge
564
+ ctx.fillStyle = 'rgba(255,153,0,0.85)';
565
+ ctx.fillRect(rx, ry - 18, rw, 18);
566
+ ctx.fillStyle = '#000';
567
+ ctx.font = 'bold 11px sans-serif';
568
+ ctx.fillText(r.detectedClass + ' ' + Math.round(r.score * 100) + '%', rx + 3, ry - 4);
569
+ }
570
+
571
+ // --- Crop canvas ---
572
+ const cropCanvas = document.getElementById('canvas-crop-' + r.id);
573
+ if (cropCanvas) {
574
+ const cc = cropCanvas.width, ch2 = cropCanvas.height;
575
+ const ctx2 = cropCanvas.getContext('2d');
576
+ ctx2.fillStyle = '#111';
577
+ ctx2.fillRect(0, 0, cc, ch2);
578
+ // Add padding around the crop
579
+ const pad = Math.min(bw, bh) * 0.15;
580
+ const sx = Math.max(0, bx - pad), sy = Math.max(0, by - pad);
581
+ const sw = Math.min(iw - sx, bw + pad * 2), sh = Math.min(ih - sy, bh + pad * 2);
582
+ const scale2 = Math.min(cc / sw, ch2 / sh);
583
+ const dw2 = sw * scale2, dh2 = sh * scale2;
584
+ const ox2 = (cc - dw2) / 2, oy2 = (ch2 - dh2) / 2;
585
+ ctx2.drawImage(img, sx, sy, sw, sh, ox2, oy2, dw2, dh2);
586
+ // Thin box outline on crop
587
+ ctx2.strokeStyle = '#f90';
588
+ ctx2.lineWidth = 1.5;
589
+ const rx2 = ox2 + pad * scale2, ry2 = oy2 + pad * scale2;
590
+ ctx2.strokeRect(rx2, ry2, bw * scale2, bh * scale2);
591
+ }
592
+ }
593
+
594
+ // Cache loaded images so lightbox reuses them
595
+ const imgCache = new Map();
596
+
597
+ function openLightbox(r) {
598
+ const img = imgCache.get(r.id);
599
+ if (!img) return;
600
+
601
+ const lb = document.getElementById('lightbox');
602
+ const lbCanvas = document.getElementById('lightbox-canvas');
603
+
604
+ // Size canvas to image, capped at viewport
605
+ const maxW = window.innerWidth * 0.9;
606
+ const maxH = window.innerHeight * 0.8;
607
+ const [iw, ih] = r.inputDimensions;
608
+ const scale = Math.min(maxW / iw, maxH / ih, 1);
609
+ lbCanvas.width = Math.round(iw * scale);
610
+ lbCanvas.height = Math.round(ih * scale);
611
+
612
+ const ctx = lbCanvas.getContext('2d');
613
+ ctx.drawImage(img, 0, 0, lbCanvas.width, lbCanvas.height);
614
+
615
+ // Draw all bounding boxes for this detection (primary + others in same event if available)
616
+ const boxes = r.allDetections || [{ boundingBox: r.boundingBox, detectedClass: r.detectedClass, score: r.score }];
617
+ const colors = ['#f90', '#4af', '#f44', '#4f4', '#f4f', '#ff4'];
618
+ boxes.forEach((d, i) => {
619
+ const [bx, by, bw, bh] = d.boundingBox;
620
+ const color = colors[i % colors.length];
621
+ const rx = bx * scale, ry = by * scale, rw = bw * scale, rh = bh * scale;
622
+ ctx.strokeStyle = color;
623
+ ctx.lineWidth = 2;
624
+ ctx.strokeRect(rx, ry, rw, rh);
625
+ const label = d.detectedClass + ' ' + Math.round((d.score || 0) * 100) + '%';
626
+ const textW = ctx.measureText(label).width + 8;
627
+ ctx.fillStyle = color;
628
+ ctx.globalAlpha = 0.85;
629
+ ctx.fillRect(rx, Math.max(0, ry - 20), textW, 20);
630
+ ctx.globalAlpha = 1;
631
+ ctx.fillStyle = '#000';
632
+ ctx.font = 'bold 12px sans-serif';
633
+ ctx.fillText(label, rx + 4, Math.max(14, ry - 4));
634
+ });
635
+
636
+ document.getElementById('lightbox-meta').textContent =
637
+ r.cameraName + ' · ' + new Date(r.timestamp).toLocaleString() + ' · ' + iw + '×' + ih;
638
+ lb.classList.add('open');
639
+ document.addEventListener('keydown', lbKeyHandler);
640
+ }
641
+
642
+ function closeLightbox() {
643
+ document.getElementById('lightbox').classList.remove('open');
644
+ document.removeEventListener('keydown', lbKeyHandler);
645
+ }
646
+
647
+ function lbKeyHandler(e) {
648
+ if (e.key === 'Escape') closeLightbox();
649
+ }
650
+
513
651
  function showTab(name) {
514
652
  document.querySelectorAll('.tab').forEach((t, i) => {
515
653
  const names = ['review', 'stats', 'export'];
@@ -549,17 +687,25 @@ async function loadPending() {
549
687
  list.innerHTML = pending.map(r => {
550
688
  const date = new Date(r.timestamp).toLocaleString();
551
689
  const score = Math.round(r.score * 100);
690
+ const bb = r.boundingBox;
691
+ const dim = r.inputDimensions;
552
692
  return \`
553
693
  <div class="detection" id="det-\${r.id}">
554
- <div class="detection-img">
555
- <img src="\${BASE}/img/\${r.id}" alt="\${r.detectedClass}" loading="lazy" onerror="imgError(this)">
556
- <div class="detection-class">\${r.detectedClass} \${score}%</div>
694
+ <div class="detection-imgs">
695
+ <div class="img-panel">
696
+ <div class="img-label">Full frame</div>
697
+ <canvas id="canvas-full-\${r.id}" class="det-canvas" width="240" height="160"></canvas>
698
+ </div>
699
+ <div class="img-panel">
700
+ <div class="img-label">Crop</div>
701
+ <canvas id="canvas-crop-\${r.id}" class="det-canvas" width="160" height="160"></canvas>
702
+ </div>
557
703
  </div>
558
704
  <div class="detection-info">
559
705
  <div class="detection-meta">
560
706
  <div><strong>\${r.cameraName}</strong></div>
561
707
  <div>\${date}</div>
562
- <div>Box: \${r.boundingBox.map(v => Math.round(v)).join(', ')}</div>
708
+ <div class="det-class-badge">\${r.detectedClass} \${score}%</div>
563
709
  </div>
564
710
  <div style="font-size:12px;color:#888;">What is this actually?</div>
565
711
  <div class="label-buttons">
@@ -574,7 +720,25 @@ async function loadPending() {
574
720
  </div>
575
721
  </div>\`;
576
722
  }).join('');
577
- } catch(e) {
723
+
724
+ // Draw bounding boxes and crops onto canvases after DOM is ready
725
+ for (const r of pending) {
726
+ const img = new Image();
727
+ img.onload = () => {
728
+ imgCache.set(r.id, img);
729
+ drawDetection(img, r);
730
+ // Wire up click to open lightbox
731
+ const fullCanvas = document.getElementById('canvas-full-' + r.id);
732
+ const cropCanvas = document.getElementById('canvas-crop-' + r.id);
733
+ if (fullCanvas) fullCanvas.onclick = () => openLightbox(r);
734
+ if (cropCanvas) cropCanvas.onclick = () => openLightbox(r);
735
+ };
736
+ img.onerror = () => {
737
+ const c = document.getElementById('canvas-full-' + r.id);
738
+ if (c) c.parentElement.innerHTML = '<div style="padding:10px;color:#555;font-size:11px">No image</div>';
739
+ };
740
+ img.src = BASE + '/img/' + r.id;
741
+ } } catch(e) {
578
742
  console.error('loadPending error', e);
579
743
  const list = document.getElementById('detections-list');
580
744
  if (list) list.innerHTML = '<div class="empty"><div style="color:#a44">Error loading captures: ' + e.message + '</div></div>';