scrypted-detection-trainer 0.1.7 → 0.1.9

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
@@ -1549,8 +1549,13 @@ var __importStar = (this && this.__importStar) || (function () {
1549
1549
  return result;
1550
1550
  };
1551
1551
  })();
1552
+ var __importDefault = (this && this.__importDefault) || function (mod) {
1553
+ return (mod && mod.__esModule) ? mod : { "default": mod };
1554
+ };
1552
1555
  Object.defineProperty(exports, "__esModule", ({ value: true }));
1553
1556
  const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
1557
+ const fs_1 = __importDefault(__webpack_require__(/*! fs */ "fs"));
1558
+ const path_1 = __importDefault(__webpack_require__(/*! path */ "path"));
1554
1559
  const { systemManager, deviceManager, mediaManager } = sdk_1.default;
1555
1560
  // ─── Constants ────────────────────────────────────────────────────────────────
1556
1561
  const LABELS = ['person', 'animal', 'face', 'vehicle', 'plate', 'package', 'discard'];
@@ -1571,10 +1576,14 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1571
1576
  this.lastCapture = new Map();
1572
1577
  // Map<captureId, CaptureRecord>
1573
1578
  this.captures = new Map();
1574
- // Map<captureId, jpegBuffer>
1575
- this.images = new Map();
1576
1579
  // Active event listeners
1577
1580
  this.listeners = [];
1581
+ // Use a stable directory inside the plugin's volume
1582
+ this.imgDir = path_1.default.join(process.env.SCRYPTED_PLUGIN_VOLUME || '/tmp', 'detection-trainer-images');
1583
+ try {
1584
+ fs_1.default.mkdirSync(this.imgDir, { recursive: true });
1585
+ }
1586
+ catch { }
1578
1587
  this.loadState();
1579
1588
  this.registerListeners();
1580
1589
  }
@@ -1592,25 +1601,48 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1592
1601
  catch (e) {
1593
1602
  this.console.warn('Could not load captures from storage:', e);
1594
1603
  }
1595
- // images are stored as individual items
1604
+ // Clean up any old base64 image entries from previous versions
1596
1605
  for (const [id] of this.captures) {
1597
- const raw = this.storage.getItem(`img:${id}`);
1598
- if (raw)
1599
- this.images.set(id, Buffer.from(raw, 'base64'));
1606
+ try {
1607
+ this.storage.removeItem(`img:${id}`);
1608
+ }
1609
+ catch { }
1600
1610
  }
1601
1611
  }
1602
1612
  saveCaptures() {
1603
- this.storage.setItem('captures', JSON.stringify([...this.captures.values()]));
1613
+ try {
1614
+ this.storage.setItem('captures', JSON.stringify([...this.captures.values()]));
1615
+ }
1616
+ catch (e) {
1617
+ this.console.warn('Could not save captures:', e);
1618
+ }
1619
+ }
1620
+ imgPath(id) {
1621
+ return path_1.default.join(this.imgDir, `${id}.jpg`);
1604
1622
  }
1605
1623
  saveImage(id, buf) {
1606
- this.storage.setItem(`img:${id}`, buf.toString('base64'));
1624
+ try {
1625
+ fs_1.default.writeFileSync(this.imgPath(id), buf);
1626
+ }
1627
+ catch (e) {
1628
+ this.console.warn(`Could not save image ${id}:`, e);
1629
+ }
1630
+ }
1631
+ loadImage(id) {
1632
+ try {
1633
+ const p = this.imgPath(id);
1634
+ if (fs_1.default.existsSync(p))
1635
+ return fs_1.default.readFileSync(p);
1636
+ }
1637
+ catch { }
1638
+ return undefined;
1607
1639
  }
1608
1640
  deleteCapture(id) {
1609
- const old = this.storage.getItem(`img:${id}`);
1610
- if (old)
1611
- this.storage.removeItem(`img:${id}`);
1641
+ try {
1642
+ fs_1.default.unlinkSync(this.imgPath(id));
1643
+ }
1644
+ catch { }
1612
1645
  this.captures.delete(id);
1613
- this.images.delete(id);
1614
1646
  this.saveCaptures();
1615
1647
  }
1616
1648
  // ── Settings ─────────────────────────────────────────────────────────────
@@ -1622,8 +1654,7 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1622
1654
  d.interfaces?.includes(sdk_1.ScryptedInterface.ObjectDetector));
1623
1655
  let uiUrl;
1624
1656
  try {
1625
- const authPath = await sdk_1.default.endpointManager.getAuthenticatedPath();
1626
- uiUrl = `${authPath}endpoint/scrypted-detection-trainer/public/`;
1657
+ uiUrl = await sdk_1.default.endpointManager.getLocalEndpoint(undefined, { public: true });
1627
1658
  }
1628
1659
  catch {
1629
1660
  uiUrl = '/endpoint/scrypted-detection-trainer/public/';
@@ -1745,24 +1776,18 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1745
1776
  reviewed: false,
1746
1777
  };
1747
1778
  this.captures.set(id, record);
1748
- this.images.set(id, jpeg);
1749
1779
  this.saveImage(id, jpeg);
1750
1780
  this.saveCaptures();
1751
1781
  this.console.log(`Captured ${best.className} (${Math.round((best.score || 0) * 100)}%) from ${cameraName} [${this.captures.size} total]`);
1752
1782
  }
1753
1783
  // ── HTTP Handler ──────────────────────────────────────────────────────────
1754
1784
  async onRequest(request, response) {
1755
- // Require authentication — reject unauthenticated requests
1756
- if (!request.username) {
1757
- response.send('Unauthorized', { code: 401, headers: { 'WWW-Authenticate': 'Basic realm="Detection Trainer"' } });
1758
- return;
1759
- }
1760
1785
  const url = new URL(request.url, 'http://localhost');
1761
1786
  const path = url.pathname.replace(request.rootPath, '');
1762
1787
  // Serve image
1763
1788
  if (path.startsWith('/img/')) {
1764
- const id = path.slice(5);
1765
- const img = this.images.get(id);
1789
+ const id = path.slice(5).replace(/[^a-zA-Z0-9_\-]/g, ''); // sanitize
1790
+ const img = this.loadImage(id);
1766
1791
  if (!img)
1767
1792
  return response.send('Not found', { code: 404 });
1768
1793
  return response.send(img, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
@@ -1793,6 +1818,16 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1793
1818
  .slice(0, 50);
1794
1819
  return response.send(JSON.stringify(pending), { headers: { 'Content-Type': 'application/json' } });
1795
1820
  }
1821
+ // API: get labeled captures
1822
+ if (path === '/api/labeled') {
1823
+ const page = parseInt(new URL(request.url, 'http://localhost').searchParams.get('page') || '0');
1824
+ const pageSize = 50;
1825
+ const labeled = [...this.captures.values()]
1826
+ .filter(c => c.reviewed)
1827
+ .sort((a, b) => b.timestamp - a.timestamp);
1828
+ const slice = labeled.slice(page * pageSize, (page + 1) * pageSize);
1829
+ return response.send(JSON.stringify({ items: slice, total: labeled.length, page, pageSize }), { headers: { 'Content-Type': 'application/json' } });
1830
+ }
1796
1831
  // API: stats
1797
1832
  if (path === '/api/stats') {
1798
1833
  const all = [...this.captures.values()];
@@ -1823,7 +1858,7 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1823
1858
  // Build a simple tarball-like structure as JSON for download
1824
1859
  const dataset = [];
1825
1860
  for (const record of labeled) {
1826
- const img = this.images.get(record.id);
1861
+ const img = this.loadImage(record.id);
1827
1862
  if (!img)
1828
1863
  continue;
1829
1864
  const fname = `${record.id}`;
@@ -1964,6 +1999,7 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1964
1999
  <div class="card">
1965
2000
  <div class="tab-bar">
1966
2001
  <div class="tab active" onclick="showTab('review')">Review</div>
2002
+ <div class="tab" onclick="showTab('labeled')">Labeled</div>
1967
2003
  <div class="tab" onclick="showTab('stats')">Stats</div>
1968
2004
  <div class="tab" onclick="showTab('export')">Export Dataset</div>
1969
2005
  </div>
@@ -1973,6 +2009,11 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1973
2009
  <div id="detections-list"></div>
1974
2010
  </div>
1975
2011
 
2012
+ <!-- Labeled tab -->
2013
+ <div class="tab-panel" id="tab-labeled">
2014
+ <div id="labeled-list"></div>
2015
+ </div>
2016
+
1976
2017
  <!-- Stats tab -->
1977
2018
  <div class="tab-panel" id="tab-stats">
1978
2019
  <div class="tab-content">
@@ -2130,14 +2171,134 @@ function lbKeyHandler(e) {
2130
2171
  }
2131
2172
 
2132
2173
  function showTab(name) {
2174
+ const names = ['review', 'labeled', 'stats', 'export'];
2133
2175
  document.querySelectorAll('.tab').forEach((t, i) => {
2134
- const names = ['review', 'stats', 'export'];
2135
2176
  t.classList.toggle('active', names[i] === name);
2136
2177
  });
2137
2178
  document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
2138
2179
  document.getElementById('tab-' + name).classList.add('active');
2139
2180
  if (name === 'stats') loadStats();
2140
2181
  if (name === 'export') loadExportInfo();
2182
+ if (name === 'labeled') loadLabeled(0);
2183
+ }
2184
+
2185
+ const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };
2186
+
2187
+ async function loadLabeled(page) {
2188
+ const list = document.getElementById('labeled-list');
2189
+ list.innerHTML = '<div class="empty"><div style="color:#888">Loading…</div></div>';
2190
+ try {
2191
+ const res = await fetch(BASE + '/api/labeled?page=' + page);
2192
+ const data = await res.json();
2193
+ const { items, total, pageSize } = data;
2194
+ const totalPages = Math.ceil(total / pageSize);
2195
+
2196
+ if (!items.length) {
2197
+ list.innerHTML = '<div class="empty"><div class="icon">🏷️</div><div>No labeled detections yet.</div></div>';
2198
+ return;
2199
+ }
2200
+
2201
+ const pagerHtml = totalPages > 1 ? \`
2202
+ <div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-top:1px solid #222;font-size:13px;color:#888;">
2203
+ \${page > 0 ? \`<button class="label-btn" onclick="loadLabeled(\${page-1})">← Prev</button>\` : ''}
2204
+ <span>Page \${page+1} of \${totalPages} · \${total} total</span>
2205
+ \${page < totalPages-1 ? \`<button class="label-btn" onclick="loadLabeled(\${page+1})">Next →</button>\` : ''}
2206
+ </div>\` : '';
2207
+
2208
+ list.innerHTML = items.map(r => {
2209
+ const date = new Date(r.timestamp).toLocaleString();
2210
+ const score = Math.round(r.score * 100);
2211
+ const labelColor = LABEL_COLORS[r.label] || '#aaa';
2212
+ return \`
2213
+ <div class="detection" id="ldet-\${r.id}">
2214
+ <div class="detection-imgs">
2215
+ <div class="img-panel">
2216
+ <div class="img-label">Full frame</div>
2217
+ <canvas id="lcanvas-full-\${r.id}" class="det-canvas" width="240" height="160"></canvas>
2218
+ </div>
2219
+ <div class="img-panel">
2220
+ <div class="img-label">Crop</div>
2221
+ <canvas id="lcanvas-crop-\${r.id}" class="det-canvas" width="160" height="160"></canvas>
2222
+ </div>
2223
+ </div>
2224
+ <div class="detection-info">
2225
+ <div class="detection-meta">
2226
+ <div><strong>\${r.cameraName}</strong></div>
2227
+ <div>\${date}</div>
2228
+ <div class="det-class-badge">\${r.detectedClass} \${score}%</div>
2229
+ <div style="display:inline-block;background:#1a1a1a;border:1px solid \${labelColor};color:\${labelColor};font-size:11px;padding:2px 8px;border-radius:4px;">✓ \${r.label}</div>
2230
+ </div>
2231
+ <div style="font-size:12px;color:#888;">Change label:</div>
2232
+ <div class="label-buttons">
2233
+ <button class="label-btn person" onclick="relabel('\${r.id}', 'person')">👤 Person</button>
2234
+ <button class="label-btn animal" onclick="relabel('\${r.id}', 'animal')">🐾 Animal</button>
2235
+ <button class="label-btn face" onclick="relabel('\${r.id}', 'face')">😀 Face</button>
2236
+ <button class="label-btn vehicle" onclick="relabel('\${r.id}', 'vehicle')">🚗 Vehicle</button>
2237
+ <button class="label-btn" onclick="relabel('\${r.id}', 'plate')">🔢 Plate</button>
2238
+ <button class="label-btn" onclick="relabel('\${r.id}', 'package')">📦 Package</button>
2239
+ <button class="label-btn discard" onclick="relabel('\${r.id}', 'discard')">🗑 Discard</button>
2240
+ </div>
2241
+ </div>
2242
+ </div>\`;
2243
+ }).join('') + pagerHtml;
2244
+
2245
+ // Draw bounding boxes
2246
+ for (const r of items) {
2247
+ const img = new Image();
2248
+ img.onload = () => {
2249
+ imgCache.set(r.id, img);
2250
+ // Reuse drawDetection with the labeled canvases
2251
+ const origFull = document.getElementById('canvas-full-' + r.id);
2252
+ const origCrop = document.getElementById('canvas-crop-' + r.id);
2253
+ // Temporarily point to labeled canvases
2254
+ const fakeFull = document.getElementById('lcanvas-full-' + r.id);
2255
+ const fakeCrop = document.getElementById('lcanvas-crop-' + r.id);
2256
+ if (fakeFull) fakeFull.id = 'canvas-full-' + r.id;
2257
+ if (fakeCrop) fakeCrop.id = 'canvas-crop-' + r.id;
2258
+ drawDetection(img, r);
2259
+ if (fakeFull) fakeFull.id = 'lcanvas-full-' + r.id;
2260
+ if (fakeCrop) fakeCrop.id = 'lcanvas-crop-' + r.id;
2261
+ if (fakeFull) fakeFull.onclick = () => openLightbox(r);
2262
+ if (fakeCrop) fakeCrop.onclick = () => openLightbox(r);
2263
+ };
2264
+ img.src = BASE + '/img/' + r.id;
2265
+ }
2266
+ } catch(e) {
2267
+ list.innerHTML = '<div class="empty"><div style="color:#a44">Error: ' + e.message + '</div></div>';
2268
+ }
2269
+ }
2270
+
2271
+ async function relabel(id, labelVal) {
2272
+ await fetch(BASE + '/api/label', {
2273
+ method: 'POST',
2274
+ headers: { 'Content-Type': 'application/json' },
2275
+ body: JSON.stringify({ id, label: labelVal }),
2276
+ });
2277
+ const el = document.getElementById('ldet-' + id);
2278
+ if (labelVal === 'discard') {
2279
+ if (el) el.remove();
2280
+ toast('Discarded', '#633');
2281
+ } else {
2282
+ // Update the label badge in place
2283
+ const badge = el && el.querySelector('[style*="✓"]');
2284
+ const labelColor = LABEL_COLORS[labelVal] || '#aaa';
2285
+ if (el) {
2286
+ const badges = el.querySelectorAll('.detection-meta > div');
2287
+ badges.forEach(b => { if (b.textContent.startsWith('✓')) b.remove(); });
2288
+ const meta = el.querySelector('.detection-meta');
2289
+ const newBadge = document.createElement('div');
2290
+ newBadge.style.cssText = \`display:inline-block;background:#1a1a1a;border:1px solid \${labelColor};color:\${labelColor};font-size:11px;padding:2px 8px;border-radius:4px;\`;
2291
+ newBadge.textContent = '✓ ' + labelVal;
2292
+ meta.appendChild(newBadge);
2293
+ }
2294
+ toast('Re-labeled: ' + labelVal, '#1a6');
2295
+ }
2296
+ // Refresh stats
2297
+ const statsRes = await fetch(BASE + '/api/stats');
2298
+ const stats = await statsRes.json();
2299
+ document.getElementById('stat-pending').textContent = stats.pending;
2300
+ document.getElementById('stat-labeled').textContent = stats.labeled;
2301
+ document.getElementById('stat-total').textContent = stats.total;
2141
2302
  }
2142
2303
 
2143
2304
  function toast(msg, color='#1a3') {
@@ -2319,6 +2480,17 @@ setInterval(loadPending, 30_000);
2319
2480
  exports["default"] = DetectionTrainer;
2320
2481
 
2321
2482
 
2483
+ /***/ },
2484
+
2485
+ /***/ "fs"
2486
+ /*!*********************!*\
2487
+ !*** external "fs" ***!
2488
+ \*********************/
2489
+ (module) {
2490
+
2491
+ "use strict";
2492
+ module.exports = require("fs");
2493
+
2322
2494
  /***/ },
2323
2495
 
2324
2496
  /***/ "module"
@@ -2330,6 +2502,17 @@ exports["default"] = DetectionTrainer;
2330
2502
  "use strict";
2331
2503
  module.exports = require("module");
2332
2504
 
2505
+ /***/ },
2506
+
2507
+ /***/ "path"
2508
+ /*!***********************!*\
2509
+ !*** external "path" ***!
2510
+ \***********************/
2511
+ (module) {
2512
+
2513
+ "use strict";
2514
+ module.exports = require("path");
2515
+
2333
2516
  /***/ }
2334
2517
 
2335
2518
  /******/ });