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/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -10,6 +10,8 @@ import sdk, {
|
|
|
10
10
|
ObjectsDetected,
|
|
11
11
|
ObjectDetector,
|
|
12
12
|
} from '@scrypted/sdk';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
13
15
|
|
|
14
16
|
const { systemManager, deviceManager, mediaManager } = sdk;
|
|
15
17
|
|
|
@@ -55,13 +57,16 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
55
57
|
private lastCapture = new Map<string, number>();
|
|
56
58
|
// Map<captureId, CaptureRecord>
|
|
57
59
|
private captures = new Map<string, CaptureRecord>();
|
|
58
|
-
// Map<captureId, jpegBuffer>
|
|
59
|
-
private images = new Map<string, Buffer>();
|
|
60
60
|
// Active event listeners
|
|
61
61
|
private listeners: (() => void)[] = [];
|
|
62
|
+
// Directory for storing images on disk
|
|
63
|
+
private imgDir: string;
|
|
62
64
|
|
|
63
65
|
constructor(nativeId?: string) {
|
|
64
66
|
super(nativeId);
|
|
67
|
+
// Use a stable directory inside the plugin's volume
|
|
68
|
+
this.imgDir = path.join(process.env.SCRYPTED_PLUGIN_VOLUME || '/tmp', 'detection-trainer-images');
|
|
69
|
+
try { fs.mkdirSync(this.imgDir, { recursive: true }); } catch {}
|
|
65
70
|
this.loadState();
|
|
66
71
|
this.registerListeners();
|
|
67
72
|
}
|
|
@@ -79,26 +84,43 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
79
84
|
} catch (e) {
|
|
80
85
|
this.console.warn('Could not load captures from storage:', e);
|
|
81
86
|
}
|
|
82
|
-
//
|
|
87
|
+
// Clean up any old base64 image entries from previous versions
|
|
83
88
|
for (const [id] of this.captures) {
|
|
84
|
-
|
|
85
|
-
if (raw) this.images.set(id, Buffer.from(raw, 'base64'));
|
|
89
|
+
try { this.storage.removeItem(`img:${id}`); } catch {}
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
private saveCaptures() {
|
|
90
|
-
|
|
94
|
+
try {
|
|
95
|
+
this.storage.setItem('captures', JSON.stringify([...this.captures.values()]));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
this.console.warn('Could not save captures:', e);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private imgPath(id: string): string {
|
|
102
|
+
return path.join(this.imgDir, `${id}.jpg`);
|
|
91
103
|
}
|
|
92
104
|
|
|
93
105
|
private saveImage(id: string, buf: Buffer) {
|
|
94
|
-
|
|
106
|
+
try {
|
|
107
|
+
fs.writeFileSync(this.imgPath(id), buf);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
this.console.warn(`Could not save image ${id}:`, e);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private loadImage(id: string): Buffer | undefined {
|
|
114
|
+
try {
|
|
115
|
+
const p = this.imgPath(id);
|
|
116
|
+
if (fs.existsSync(p)) return fs.readFileSync(p);
|
|
117
|
+
} catch {}
|
|
118
|
+
return undefined;
|
|
95
119
|
}
|
|
96
120
|
|
|
97
121
|
private deleteCapture(id: string) {
|
|
98
|
-
|
|
99
|
-
if (old) this.storage.removeItem(`img:${id}`);
|
|
122
|
+
try { fs.unlinkSync(this.imgPath(id)); } catch {}
|
|
100
123
|
this.captures.delete(id);
|
|
101
|
-
this.images.delete(id);
|
|
102
124
|
this.saveCaptures();
|
|
103
125
|
}
|
|
104
126
|
|
|
@@ -114,8 +136,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
114
136
|
|
|
115
137
|
let uiUrl: string;
|
|
116
138
|
try {
|
|
117
|
-
|
|
118
|
-
uiUrl = `${authPath}endpoint/scrypted-detection-trainer/public/`;
|
|
139
|
+
uiUrl = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
|
|
119
140
|
} catch {
|
|
120
141
|
uiUrl = '/endpoint/scrypted-detection-trainer/public/';
|
|
121
142
|
}
|
|
@@ -250,7 +271,6 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
250
271
|
};
|
|
251
272
|
|
|
252
273
|
this.captures.set(id, record);
|
|
253
|
-
this.images.set(id, jpeg);
|
|
254
274
|
this.saveImage(id, jpeg);
|
|
255
275
|
this.saveCaptures();
|
|
256
276
|
|
|
@@ -260,19 +280,13 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
260
280
|
// ── HTTP Handler ──────────────────────────────────────────────────────────
|
|
261
281
|
|
|
262
282
|
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
|
-
|
|
269
283
|
const url = new URL(request.url, 'http://localhost');
|
|
270
284
|
const path = url.pathname.replace(request.rootPath, '');
|
|
271
285
|
|
|
272
286
|
// Serve image
|
|
273
287
|
if (path.startsWith('/img/')) {
|
|
274
|
-
const id = path.slice(5);
|
|
275
|
-
const img = this.
|
|
288
|
+
const id = path.slice(5).replace(/[^a-zA-Z0-9_\-]/g, ''); // sanitize
|
|
289
|
+
const img = this.loadImage(id);
|
|
276
290
|
if (!img) return response.send('Not found', { code: 404 });
|
|
277
291
|
return response.send(img, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
|
|
278
292
|
}
|
|
@@ -303,6 +317,17 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
303
317
|
return response.send(JSON.stringify(pending), { headers: { 'Content-Type': 'application/json' } });
|
|
304
318
|
}
|
|
305
319
|
|
|
320
|
+
// API: get labeled captures
|
|
321
|
+
if (path === '/api/labeled') {
|
|
322
|
+
const page = parseInt(new URL(request.url, 'http://localhost').searchParams.get('page') || '0');
|
|
323
|
+
const pageSize = 50;
|
|
324
|
+
const labeled = [...this.captures.values()]
|
|
325
|
+
.filter(c => c.reviewed)
|
|
326
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
327
|
+
const slice = labeled.slice(page * pageSize, (page + 1) * pageSize);
|
|
328
|
+
return response.send(JSON.stringify({ items: slice, total: labeled.length, page, pageSize }), { headers: { 'Content-Type': 'application/json' } });
|
|
329
|
+
}
|
|
330
|
+
|
|
306
331
|
// API: stats
|
|
307
332
|
if (path === '/api/stats') {
|
|
308
333
|
const all = [...this.captures.values()];
|
|
@@ -335,7 +360,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
335
360
|
const dataset: { filename: string; content: string; encoding: string }[] = [];
|
|
336
361
|
|
|
337
362
|
for (const record of labeled) {
|
|
338
|
-
const img = this.
|
|
363
|
+
const img = this.loadImage(record.id);
|
|
339
364
|
if (!img) continue;
|
|
340
365
|
|
|
341
366
|
const fname = `${record.id}`;
|
|
@@ -483,6 +508,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
483
508
|
<div class="card">
|
|
484
509
|
<div class="tab-bar">
|
|
485
510
|
<div class="tab active" onclick="showTab('review')">Review</div>
|
|
511
|
+
<div class="tab" onclick="showTab('labeled')">Labeled</div>
|
|
486
512
|
<div class="tab" onclick="showTab('stats')">Stats</div>
|
|
487
513
|
<div class="tab" onclick="showTab('export')">Export Dataset</div>
|
|
488
514
|
</div>
|
|
@@ -492,6 +518,11 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
492
518
|
<div id="detections-list"></div>
|
|
493
519
|
</div>
|
|
494
520
|
|
|
521
|
+
<!-- Labeled tab -->
|
|
522
|
+
<div class="tab-panel" id="tab-labeled">
|
|
523
|
+
<div id="labeled-list"></div>
|
|
524
|
+
</div>
|
|
525
|
+
|
|
495
526
|
<!-- Stats tab -->
|
|
496
527
|
<div class="tab-panel" id="tab-stats">
|
|
497
528
|
<div class="tab-content">
|
|
@@ -649,14 +680,134 @@ function lbKeyHandler(e) {
|
|
|
649
680
|
}
|
|
650
681
|
|
|
651
682
|
function showTab(name) {
|
|
683
|
+
const names = ['review', 'labeled', 'stats', 'export'];
|
|
652
684
|
document.querySelectorAll('.tab').forEach((t, i) => {
|
|
653
|
-
const names = ['review', 'stats', 'export'];
|
|
654
685
|
t.classList.toggle('active', names[i] === name);
|
|
655
686
|
});
|
|
656
687
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
657
688
|
document.getElementById('tab-' + name).classList.add('active');
|
|
658
689
|
if (name === 'stats') loadStats();
|
|
659
690
|
if (name === 'export') loadExportInfo();
|
|
691
|
+
if (name === 'labeled') loadLabeled(0);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };
|
|
695
|
+
|
|
696
|
+
async function loadLabeled(page) {
|
|
697
|
+
const list = document.getElementById('labeled-list');
|
|
698
|
+
list.innerHTML = '<div class="empty"><div style="color:#888">Loading…</div></div>';
|
|
699
|
+
try {
|
|
700
|
+
const res = await fetch(BASE + '/api/labeled?page=' + page);
|
|
701
|
+
const data = await res.json();
|
|
702
|
+
const { items, total, pageSize } = data;
|
|
703
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
704
|
+
|
|
705
|
+
if (!items.length) {
|
|
706
|
+
list.innerHTML = '<div class="empty"><div class="icon">🏷️</div><div>No labeled detections yet.</div></div>';
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const pagerHtml = totalPages > 1 ? \`
|
|
711
|
+
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-top:1px solid #222;font-size:13px;color:#888;">
|
|
712
|
+
\${page > 0 ? \`<button class="label-btn" onclick="loadLabeled(\${page-1})">← Prev</button>\` : ''}
|
|
713
|
+
<span>Page \${page+1} of \${totalPages} · \${total} total</span>
|
|
714
|
+
\${page < totalPages-1 ? \`<button class="label-btn" onclick="loadLabeled(\${page+1})">Next →</button>\` : ''}
|
|
715
|
+
</div>\` : '';
|
|
716
|
+
|
|
717
|
+
list.innerHTML = items.map(r => {
|
|
718
|
+
const date = new Date(r.timestamp).toLocaleString();
|
|
719
|
+
const score = Math.round(r.score * 100);
|
|
720
|
+
const labelColor = LABEL_COLORS[r.label] || '#aaa';
|
|
721
|
+
return \`
|
|
722
|
+
<div class="detection" id="ldet-\${r.id}">
|
|
723
|
+
<div class="detection-imgs">
|
|
724
|
+
<div class="img-panel">
|
|
725
|
+
<div class="img-label">Full frame</div>
|
|
726
|
+
<canvas id="lcanvas-full-\${r.id}" class="det-canvas" width="240" height="160"></canvas>
|
|
727
|
+
</div>
|
|
728
|
+
<div class="img-panel">
|
|
729
|
+
<div class="img-label">Crop</div>
|
|
730
|
+
<canvas id="lcanvas-crop-\${r.id}" class="det-canvas" width="160" height="160"></canvas>
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
<div class="detection-info">
|
|
734
|
+
<div class="detection-meta">
|
|
735
|
+
<div><strong>\${r.cameraName}</strong></div>
|
|
736
|
+
<div>\${date}</div>
|
|
737
|
+
<div class="det-class-badge">\${r.detectedClass} \${score}%</div>
|
|
738
|
+
<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>
|
|
739
|
+
</div>
|
|
740
|
+
<div style="font-size:12px;color:#888;">Change label:</div>
|
|
741
|
+
<div class="label-buttons">
|
|
742
|
+
<button class="label-btn person" onclick="relabel('\${r.id}', 'person')">👤 Person</button>
|
|
743
|
+
<button class="label-btn animal" onclick="relabel('\${r.id}', 'animal')">🐾 Animal</button>
|
|
744
|
+
<button class="label-btn face" onclick="relabel('\${r.id}', 'face')">😀 Face</button>
|
|
745
|
+
<button class="label-btn vehicle" onclick="relabel('\${r.id}', 'vehicle')">🚗 Vehicle</button>
|
|
746
|
+
<button class="label-btn" onclick="relabel('\${r.id}', 'plate')">🔢 Plate</button>
|
|
747
|
+
<button class="label-btn" onclick="relabel('\${r.id}', 'package')">📦 Package</button>
|
|
748
|
+
<button class="label-btn discard" onclick="relabel('\${r.id}', 'discard')">🗑 Discard</button>
|
|
749
|
+
</div>
|
|
750
|
+
</div>
|
|
751
|
+
</div>\`;
|
|
752
|
+
}).join('') + pagerHtml;
|
|
753
|
+
|
|
754
|
+
// Draw bounding boxes
|
|
755
|
+
for (const r of items) {
|
|
756
|
+
const img = new Image();
|
|
757
|
+
img.onload = () => {
|
|
758
|
+
imgCache.set(r.id, img);
|
|
759
|
+
// Reuse drawDetection with the labeled canvases
|
|
760
|
+
const origFull = document.getElementById('canvas-full-' + r.id);
|
|
761
|
+
const origCrop = document.getElementById('canvas-crop-' + r.id);
|
|
762
|
+
// Temporarily point to labeled canvases
|
|
763
|
+
const fakeFull = document.getElementById('lcanvas-full-' + r.id);
|
|
764
|
+
const fakeCrop = document.getElementById('lcanvas-crop-' + r.id);
|
|
765
|
+
if (fakeFull) fakeFull.id = 'canvas-full-' + r.id;
|
|
766
|
+
if (fakeCrop) fakeCrop.id = 'canvas-crop-' + r.id;
|
|
767
|
+
drawDetection(img, r);
|
|
768
|
+
if (fakeFull) fakeFull.id = 'lcanvas-full-' + r.id;
|
|
769
|
+
if (fakeCrop) fakeCrop.id = 'lcanvas-crop-' + r.id;
|
|
770
|
+
if (fakeFull) fakeFull.onclick = () => openLightbox(r);
|
|
771
|
+
if (fakeCrop) fakeCrop.onclick = () => openLightbox(r);
|
|
772
|
+
};
|
|
773
|
+
img.src = BASE + '/img/' + r.id;
|
|
774
|
+
}
|
|
775
|
+
} catch(e) {
|
|
776
|
+
list.innerHTML = '<div class="empty"><div style="color:#a44">Error: ' + e.message + '</div></div>';
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function relabel(id, labelVal) {
|
|
781
|
+
await fetch(BASE + '/api/label', {
|
|
782
|
+
method: 'POST',
|
|
783
|
+
headers: { 'Content-Type': 'application/json' },
|
|
784
|
+
body: JSON.stringify({ id, label: labelVal }),
|
|
785
|
+
});
|
|
786
|
+
const el = document.getElementById('ldet-' + id);
|
|
787
|
+
if (labelVal === 'discard') {
|
|
788
|
+
if (el) el.remove();
|
|
789
|
+
toast('Discarded', '#633');
|
|
790
|
+
} else {
|
|
791
|
+
// Update the label badge in place
|
|
792
|
+
const badge = el && el.querySelector('[style*="✓"]');
|
|
793
|
+
const labelColor = LABEL_COLORS[labelVal] || '#aaa';
|
|
794
|
+
if (el) {
|
|
795
|
+
const badges = el.querySelectorAll('.detection-meta > div');
|
|
796
|
+
badges.forEach(b => { if (b.textContent.startsWith('✓')) b.remove(); });
|
|
797
|
+
const meta = el.querySelector('.detection-meta');
|
|
798
|
+
const newBadge = document.createElement('div');
|
|
799
|
+
newBadge.style.cssText = \`display:inline-block;background:#1a1a1a;border:1px solid \${labelColor};color:\${labelColor};font-size:11px;padding:2px 8px;border-radius:4px;\`;
|
|
800
|
+
newBadge.textContent = '✓ ' + labelVal;
|
|
801
|
+
meta.appendChild(newBadge);
|
|
802
|
+
}
|
|
803
|
+
toast('Re-labeled: ' + labelVal, '#1a6');
|
|
804
|
+
}
|
|
805
|
+
// Refresh stats
|
|
806
|
+
const statsRes = await fetch(BASE + '/api/stats');
|
|
807
|
+
const stats = await statsRes.json();
|
|
808
|
+
document.getElementById('stat-pending').textContent = stats.pending;
|
|
809
|
+
document.getElementById('stat-labeled').textContent = stats.labeled;
|
|
810
|
+
document.getElementById('stat-total').textContent = stats.total;
|
|
660
811
|
}
|
|
661
812
|
|
|
662
813
|
function toast(msg, color='#1a3') {
|