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/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +207 -24
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/main.ts +174 -23
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/out/main.nodejs.js
CHANGED
|
@@ -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
|
-
//
|
|
1604
|
+
// Clean up any old base64 image entries from previous versions
|
|
1596
1605
|
for (const [id] of this.captures) {
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1606
|
+
try {
|
|
1607
|
+
this.storage.removeItem(`img:${id}`);
|
|
1608
|
+
}
|
|
1609
|
+
catch { }
|
|
1600
1610
|
}
|
|
1601
1611
|
}
|
|
1602
1612
|
saveCaptures() {
|
|
1603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
/******/ });
|