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/dist/plugin.zip CHANGED
Binary file
@@ -1675,6 +1675,13 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1675
1675
  readonly: true,
1676
1676
  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>`,
1677
1677
  },
1678
+ {
1679
+ key: 'autoCapture',
1680
+ title: 'Auto-Capture',
1681
+ description: 'Automatically capture detections in the background. Disable to use manual browsing only.',
1682
+ type: 'boolean',
1683
+ value: (this.storage.getItem('autoCapture') ?? 'true'),
1684
+ },
1678
1685
  ];
1679
1686
  for (const cam of cameras) {
1680
1687
  const key = `rate:${cam.id}`;
@@ -1723,6 +1730,8 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1723
1730
  }
1724
1731
  // ── Detection Handler ─────────────────────────────────────────────────────
1725
1732
  async onDetection(cameraId, cameraName, data, rateLimitMs) {
1733
+ if ((this.storage.getItem('autoCapture') ?? 'true') === 'false')
1734
+ return;
1726
1735
  if (!data?.detections?.length || !data.inputDimensions)
1727
1736
  return;
1728
1737
  // Rate limit per camera
@@ -1784,6 +1793,23 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1784
1793
  async onRequest(request, response) {
1785
1794
  const url = new URL(request.url, 'http://localhost');
1786
1795
  const path = url.pathname.replace(request.rootPath, '');
1796
+ // Serve browse event image (fetch on demand from camera)
1797
+ if (path === '/api/browse-img') {
1798
+ const params = new URL(request.url, 'http://localhost').searchParams;
1799
+ const cameraId = params.get('cameraId')?.replace(/[^a-zA-Z0-9_\-]/g, '');
1800
+ const detectionId = params.get('detectionId')?.replace(/[^a-zA-Z0-9_\-]/g, '');
1801
+ if (!cameraId || !detectionId)
1802
+ return response.send('Missing params', { code: 400 });
1803
+ try {
1804
+ const cam = systemManager.getDeviceById(cameraId);
1805
+ const mo = await cam.getDetectionInput(detectionId);
1806
+ const jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
1807
+ return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=60' } });
1808
+ }
1809
+ catch (e) {
1810
+ return response.send('Image unavailable', { code: 404 });
1811
+ }
1812
+ }
1787
1813
  // Serve image
1788
1814
  if (path.startsWith('/img/')) {
1789
1815
  const id = path.slice(5).replace(/[^a-zA-Z0-9_\-]/g, ''); // sanitize
@@ -1810,6 +1836,82 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1810
1836
  }
1811
1837
  return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
1812
1838
  }
1839
+ // API: list cameras for browse
1840
+ if (path === '/api/cameras') {
1841
+ const cameras = Object.keys(systemManager.getSystemState())
1842
+ .map(id => systemManager.getDeviceById(id))
1843
+ .filter(d => d &&
1844
+ (d.type === sdk_1.ScryptedDeviceType.Camera || d.type === sdk_1.ScryptedDeviceType.Doorbell) &&
1845
+ d.interfaces?.includes(sdk_1.ScryptedInterface.ObjectDetector))
1846
+ .map(d => ({ id: d.id, name: d.name }));
1847
+ return response.send(JSON.stringify(cameras), { headers: { 'Content-Type': 'application/json' } });
1848
+ }
1849
+ // API: browse recent events for a camera
1850
+ if (path === '/api/browse') {
1851
+ const params = new URL(request.url, 'http://localhost').searchParams;
1852
+ const cameraId = params.get('cameraId');
1853
+ const hours = parseInt(params.get('hours') || '24');
1854
+ if (!cameraId)
1855
+ return response.send('Missing cameraId', { code: 400 });
1856
+ try {
1857
+ const cam = systemManager.getDeviceById(cameraId);
1858
+ if (!cam)
1859
+ return response.send('Camera not found', { code: 404 });
1860
+ const endTime = Date.now();
1861
+ const startTime = endTime - hours * 3600 * 1000;
1862
+ const events = await cam.getRecordedEvents({ startTime, endTime });
1863
+ // Filter to ObjectDetector events only and limit to 100
1864
+ const detectionEvents = (events || [])
1865
+ .filter((e) => e.details?.eventInterface === 'ObjectDetector' && e.data?.detections?.length)
1866
+ .slice(0, 100)
1867
+ .map((e) => ({
1868
+ detectionId: e.data?.detectionId,
1869
+ timestamp: e.details?.eventTime || e.data?.timestamp,
1870
+ detections: (e.data?.detections || []).map((d) => ({
1871
+ className: d.className,
1872
+ score: d.score,
1873
+ boundingBox: d.boundingBox,
1874
+ })),
1875
+ inputDimensions: e.data?.inputDimensions,
1876
+ cameraId,
1877
+ cameraName: cam.name,
1878
+ }))
1879
+ .filter((e) => e.detectionId && e.inputDimensions);
1880
+ return response.send(JSON.stringify(detectionEvents), { headers: { 'Content-Type': 'application/json' } });
1881
+ }
1882
+ catch (e) {
1883
+ return response.send(JSON.stringify({ error: e.message }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
1884
+ }
1885
+ }
1886
+ // API: add a browsed event directly to dataset as labeled
1887
+ if (path === '/api/add-event' && request.body) {
1888
+ const rawBody = request.body;
1889
+ const body = JSON.parse(typeof rawBody === 'string' ? rawBody : Buffer.isBuffer(rawBody) ? rawBody.toString() : String(rawBody));
1890
+ const { cameraId, cameraName, detectionId, timestamp, detectedClass, score, boundingBox, inputDimensions, label } = body;
1891
+ if (!label || label === 'discard')
1892
+ return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
1893
+ // Try to get the image
1894
+ let jpeg;
1895
+ try {
1896
+ const cam = systemManager.getDeviceById(cameraId);
1897
+ const mo = await cam.getDetectionInput(detectionId);
1898
+ jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
1899
+ }
1900
+ catch (e) {
1901
+ this.console.warn(`Could not get image for browse event ${detectionId}:`, e);
1902
+ }
1903
+ if (!jpeg)
1904
+ return response.send(JSON.stringify({ error: 'Could not retrieve image' }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
1905
+ const id = `browse-${timestamp}-${Math.random().toString(36).slice(2, 6)}`;
1906
+ const record = {
1907
+ id, cameraId, cameraName, timestamp, detectedClass, score,
1908
+ boundingBox, inputDimensions, detectionId, reviewed: true, label,
1909
+ };
1910
+ this.captures.set(id, record);
1911
+ this.saveImage(id, jpeg);
1912
+ this.saveCaptures();
1913
+ return response.send(JSON.stringify({ ok: true, id }), { headers: { 'Content-Type': 'application/json' } });
1914
+ }
1813
1915
  // API: get pending captures
1814
1916
  if (path === '/api/pending') {
1815
1917
  const pending = [...this.captures.values()]
@@ -1999,6 +2101,7 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1999
2101
  <div class="card">
2000
2102
  <div class="tab-bar">
2001
2103
  <div class="tab active" onclick="showTab('review')">Review</div>
2104
+ <div class="tab" onclick="showTab('browse')">Browse Events</div>
2002
2105
  <div class="tab" onclick="showTab('labeled')">Labeled</div>
2003
2106
  <div class="tab" onclick="showTab('stats')">Stats</div>
2004
2107
  <div class="tab" onclick="showTab('export')">Export Dataset</div>
@@ -2009,12 +2112,30 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
2009
2112
  <div id="detections-list"></div>
2010
2113
  </div>
2011
2114
 
2115
+ <!-- Browse tab -->
2116
+ <div class="tab-panel" id="tab-browse">
2117
+ <div class="tab-content">
2118
+ <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px;">
2119
+ <select id="browse-camera" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
2120
+ <option value="">Select camera…</option>
2121
+ </select>
2122
+ <select id="browse-hours" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
2123
+ <option value="1">Last 1 hour</option>
2124
+ <option value="6">Last 6 hours</option>
2125
+ <option value="24" selected>Last 24 hours</option>
2126
+ <option value="72">Last 3 days</option>
2127
+ </select>
2128
+ <button class="export-btn" onclick="loadBrowse()" style="padding:8px 16px;">Load Events</button>
2129
+ <span id="browse-status" style="font-size:13px;color:#888;"></span>
2130
+ </div>
2131
+ <div id="browse-list"></div>
2132
+ </div>
2133
+ </div>
2134
+
2012
2135
  <!-- Labeled tab -->
2013
2136
  <div class="tab-panel" id="tab-labeled">
2014
2137
  <div id="labeled-list"></div>
2015
2138
  </div>
2016
-
2017
- <!-- Stats tab -->
2018
2139
  <div class="tab-panel" id="tab-stats">
2019
2140
  <div class="tab-content">
2020
2141
  <p style="font-size:13px;color:#888;margin-bottom:16px;">Breakdown of captured and labeled detections.</p>
@@ -2171,7 +2292,7 @@ function lbKeyHandler(e) {
2171
2292
  }
2172
2293
 
2173
2294
  function showTab(name) {
2174
- const names = ['review', 'labeled', 'stats', 'export'];
2295
+ const names = ['review', 'browse', 'labeled', 'stats', 'export'];
2175
2296
  document.querySelectorAll('.tab').forEach((t, i) => {
2176
2297
  t.classList.toggle('active', names[i] === name);
2177
2298
  });
@@ -2180,6 +2301,246 @@ function showTab(name) {
2180
2301
  if (name === 'stats') loadStats();
2181
2302
  if (name === 'export') loadExportInfo();
2182
2303
  if (name === 'labeled') loadLabeled(0);
2304
+ if (name === 'browse') initBrowse();
2305
+ }
2306
+
2307
+ async function initBrowse() {
2308
+ const sel = document.getElementById('browse-camera');
2309
+ if (sel.options.length > 1) return; // already loaded
2310
+ try {
2311
+ const res = await fetch(BASE + '/api/cameras');
2312
+ const cameras = await res.json();
2313
+ for (const cam of cameras) {
2314
+ const opt = document.createElement('option');
2315
+ opt.value = cam.id;
2316
+ opt.textContent = cam.name;
2317
+ sel.appendChild(opt);
2318
+ }
2319
+ if (cameras.length === 1) sel.value = cameras[0].id;
2320
+ } catch(e) {
2321
+ document.getElementById('browse-status').textContent = 'Error loading cameras';
2322
+ }
2323
+ }
2324
+
2325
+ async function loadBrowse() {
2326
+ const cameraId = document.getElementById('browse-camera').value;
2327
+ const hours = document.getElementById('browse-hours').value;
2328
+ const status = document.getElementById('browse-status');
2329
+ const list = document.getElementById('browse-list');
2330
+
2331
+ if (!cameraId) { status.textContent = 'Select a camera first'; return; }
2332
+
2333
+ status.textContent = 'Loading…';
2334
+ list.innerHTML = '';
2335
+
2336
+ try {
2337
+ const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
2338
+ const events = await res.json();
2339
+
2340
+ if (events.error) { status.textContent = 'Error: ' + events.error; return; }
2341
+ 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; }
2342
+
2343
+ status.textContent = events.length + ' events found';
2344
+
2345
+ list.innerHTML = events.map((ev, i) => {
2346
+ const date = new Date(ev.timestamp).toLocaleString();
2347
+ const dets = ev.detections || [];
2348
+ const primary = dets[0] || {};
2349
+ const score = Math.round((primary.score || 0) * 100);
2350
+ const allClasses = dets.map(d => d.className + ' ' + Math.round((d.score||0)*100) + '%').join(', ');
2351
+ return \`
2352
+ <div class="detection" id="bev-\${i}" style="opacity:1;transition:opacity .3s">
2353
+ <div class="detection-imgs">
2354
+ <div class="img-panel">
2355
+ <div class="img-label">Full frame</div>
2356
+ <canvas id="bcanvas-\${i}" class="det-canvas" width="240" height="160"></canvas>
2357
+ </div>
2358
+ <div class="img-panel" id="bcrop-panel-\${i}">
2359
+ <div class="img-label">Crop</div>
2360
+ <canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
2361
+ </div>
2362
+ </div>
2363
+ <div class="detection-info">
2364
+ <div class="detection-meta">
2365
+ <div><strong>\${ev.cameraName}</strong></div>
2366
+ <div>\${date}</div>
2367
+ <div class="det-class-badge">\${allClasses}</div>
2368
+ </div>
2369
+ <div style="font-size:12px;color:#888;">Add to dataset as:</div>
2370
+ <div class="label-buttons" id="blabels-\${i}">
2371
+ <button class="label-btn person" onclick="addEvent(\${i})('person')">👤 Person</button>
2372
+ <button class="label-btn animal" onclick="addEvent(\${i})('animal')">🐾 Animal</button>
2373
+ <button class="label-btn face" onclick="addEvent(\${i})('face')">😀 Face</button>
2374
+ <button class="label-btn vehicle" onclick="addEvent(\${i})('vehicle')">🚗 Vehicle</button>
2375
+ <button class="label-btn" onclick="addEvent(\${i})('plate')">🔢 Plate</button>
2376
+ <button class="label-btn" onclick="addEvent(\${i})('package')">📦 Package</button>
2377
+ <button class="label-btn discard" onclick="addEvent(\${i})('discard')">🗑 Skip</button>
2378
+ </div>
2379
+ </div>
2380
+ </div>\`;
2381
+ }).join('');
2382
+
2383
+ // Load images for each event
2384
+ for (let i = 0; i < events.length; i++) {
2385
+ const ev = events[i];
2386
+ loadBrowseImage(i, ev);
2387
+ }
2388
+
2389
+ } catch(e) {
2390
+ status.textContent = 'Error: ' + e.message;
2391
+ }
2392
+ }
2393
+
2394
+ // Store browse events for addEvent closure
2395
+ let browseEvents = [];
2396
+
2397
+ async function loadBrowse() {
2398
+ const cameraId = document.getElementById('browse-camera').value;
2399
+ const hours = document.getElementById('browse-hours').value;
2400
+ const status = document.getElementById('browse-status');
2401
+ const list = document.getElementById('browse-list');
2402
+
2403
+ if (!cameraId) { status.textContent = 'Select a camera first'; return; }
2404
+
2405
+ status.textContent = 'Loading…';
2406
+ list.innerHTML = '';
2407
+ browseEvents = [];
2408
+
2409
+ try {
2410
+ const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
2411
+ const events = await res.json();
2412
+
2413
+ if (events.error) { status.textContent = 'Error: ' + events.error; return; }
2414
+ if (!events.length) {
2415
+ status.textContent = 'No detection events found.';
2416
+ list.innerHTML = '<div class="empty"><div class="icon">🔍</div><div>No ObjectDetector events in this time range.</div></div>';
2417
+ return;
2418
+ }
2419
+
2420
+ browseEvents = events;
2421
+ status.textContent = events.length + ' events';
2422
+
2423
+ list.innerHTML = events.map((ev, i) => {
2424
+ const date = new Date(ev.timestamp).toLocaleString();
2425
+ const dets = ev.detections || [];
2426
+ const allClasses = [...new Set(dets.map(d => d.className))].join(', ');
2427
+ return \`
2428
+ <div class="detection" id="bev-\${i}">
2429
+ <div class="detection-imgs">
2430
+ <div class="img-panel">
2431
+ <div class="img-label">Full frame</div>
2432
+ <canvas id="bcanvas-full-\${i}" class="det-canvas" width="240" height="160"></canvas>
2433
+ </div>
2434
+ <div class="img-panel">
2435
+ <div class="img-label">Crop</div>
2436
+ <canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
2437
+ </div>
2438
+ </div>
2439
+ <div class="detection-info">
2440
+ <div class="detection-meta">
2441
+ <div><strong>\${ev.cameraName}</strong></div>
2442
+ <div>\${date}</div>
2443
+ <div class="det-class-badge">\${allClasses}</div>
2444
+ </div>
2445
+ <div style="font-size:12px;color:#888;">Add to dataset as:</div>
2446
+ <div class="label-buttons">
2447
+ <button class="label-btn person" onclick="addBrowseEvent(\${i},'person')">👤 Person</button>
2448
+ <button class="label-btn animal" onclick="addBrowseEvent(\${i},'animal')">🐾 Animal</button>
2449
+ <button class="label-btn face" onclick="addBrowseEvent(\${i},'face')">😀 Face</button>
2450
+ <button class="label-btn vehicle" onclick="addBrowseEvent(\${i},'vehicle')">🚗 Vehicle</button>
2451
+ <button class="label-btn" onclick="addBrowseEvent(\${i},'plate')">🔢 Plate</button>
2452
+ <button class="label-btn" onclick="addBrowseEvent(\${i},'package')">📦 Package</button>
2453
+ <button class="label-btn discard" onclick="addBrowseEvent(\${i},'discard')">🗑 Skip</button>
2454
+ </div>
2455
+ </div>
2456
+ </div>\`;
2457
+ }).join('');
2458
+
2459
+ // Load thumbnails for each event
2460
+ for (let i = 0; i < events.length; i++) {
2461
+ loadBrowseImage(i, events[i]);
2462
+ }
2463
+
2464
+ } catch(e) {
2465
+ status.textContent = 'Error: ' + e.message;
2466
+ }
2467
+ }
2468
+
2469
+ function loadBrowseImage(i, ev) {
2470
+ const primary = (ev.detections || [])[0];
2471
+ if (!primary?.boundingBox) return;
2472
+ // Request image via the img endpoint using detectionId as the key
2473
+ // We store a browse-prefixed image server-side only after adding — for preview
2474
+ // use a placeholder fetch to trigger server-side caching
2475
+ fetch(BASE + '/api/browse-img?cameraId=' + ev.cameraId + '&detectionId=' + ev.detectionId)
2476
+ .then(r => r.ok ? r.blob() : null)
2477
+ .then(blob => {
2478
+ if (!blob) return;
2479
+ const url = URL.createObjectURL(blob);
2480
+ const img = new Image();
2481
+ img.onload = () => {
2482
+ imgCache.set('browse-' + i, img);
2483
+ // Draw on full canvas
2484
+ const fullCanvas = document.getElementById('bcanvas-full-' + i);
2485
+ const cropCanvas = document.getElementById('bcanvas-crop-' + i);
2486
+ if (fullCanvas) fullCanvas.id = 'canvas-full-browse' + i;
2487
+ if (cropCanvas) cropCanvas.id = 'canvas-crop-browse' + i;
2488
+ const fakeR = { ...ev, id: 'browse' + i, boundingBox: primary.boundingBox, detectedClass: primary.className, score: primary.score };
2489
+ drawDetection(img, fakeR);
2490
+ if (fullCanvas) { fullCanvas.id = 'bcanvas-full-' + i; fullCanvas.onclick = () => openLightbox(fakeR); }
2491
+ if (cropCanvas) { cropCanvas.id = 'bcanvas-crop-' + i; cropCanvas.onclick = () => openLightbox(fakeR); }
2492
+ URL.revokeObjectURL(url);
2493
+ };
2494
+ img.src = url;
2495
+ }).catch(() => {});
2496
+ }
2497
+
2498
+ async function addBrowseEvent(i, label) {
2499
+ const ev = browseEvents[i];
2500
+ if (!ev) return;
2501
+ const el = document.getElementById('bev-' + i);
2502
+ if (el) { el.style.opacity = '0.4'; el.querySelectorAll('button').forEach(b => b.disabled = true); }
2503
+
2504
+ if (label !== 'discard') {
2505
+ const primary = (ev.detections || [])[0];
2506
+ if (!primary) return;
2507
+ try {
2508
+ const res = await fetch(BASE + '/api/add-event', {
2509
+ method: 'POST',
2510
+ headers: { 'Content-Type': 'application/json' },
2511
+ body: JSON.stringify({
2512
+ cameraId: ev.cameraId,
2513
+ cameraName: ev.cameraName,
2514
+ detectionId: ev.detectionId,
2515
+ timestamp: ev.timestamp,
2516
+ detectedClass: primary.className,
2517
+ score: primary.score,
2518
+ boundingBox: primary.boundingBox,
2519
+ inputDimensions: ev.inputDimensions,
2520
+ label,
2521
+ }),
2522
+ });
2523
+ const data = await res.json();
2524
+ if (data.error) { toast('Error: ' + data.error, '#633'); if (el) el.style.opacity = '1'; el?.querySelectorAll('button').forEach(b => b.disabled = false); return; }
2525
+ toast('Added: ' + label, '#1a6');
2526
+ } catch(e) {
2527
+ toast('Failed: ' + e.message, '#633');
2528
+ if (el) el.style.opacity = '1';
2529
+ el?.querySelectorAll('button').forEach(b => b.disabled = false);
2530
+ return;
2531
+ }
2532
+ } else {
2533
+ toast('Skipped', '#555');
2534
+ }
2535
+
2536
+ // Remove from list after short delay
2537
+ setTimeout(() => { if (el) el.remove(); }, 400);
2538
+
2539
+ // Update stats
2540
+ const statsRes = await fetch(BASE + '/api/stats');
2541
+ const stats = await statsRes.json();
2542
+ document.getElementById('stat-labeled').textContent = stats.labeled;
2543
+ document.getElementById('stat-total').textContent = stats.total;
2183
2544
  }
2184
2545
 
2185
2546
  const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };