scrypted-detection-trainer 0.1.2
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 +2 -0
- package/dist/main.nodejs.js.map +1 -0
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +2200 -0
- package/out/main.nodejs.js.map +1 -0
- package/out/plugin.zip +0 -0
- package/package.json +34 -0
- package/src/main.ts +651 -0
- package/tsconfig.json +13 -0
package/src/main.ts
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import sdk, {
|
|
2
|
+
ScryptedDeviceBase,
|
|
3
|
+
Settings,
|
|
4
|
+
Setting,
|
|
5
|
+
HttpRequestHandler,
|
|
6
|
+
HttpRequest,
|
|
7
|
+
HttpResponse,
|
|
8
|
+
ScryptedInterface,
|
|
9
|
+
ScryptedDeviceType,
|
|
10
|
+
ObjectsDetected,
|
|
11
|
+
ObjectDetector,
|
|
12
|
+
} from '@scrypted/sdk';
|
|
13
|
+
|
|
14
|
+
const { systemManager, deviceManager, mediaManager } = sdk;
|
|
15
|
+
|
|
16
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const LABELS = ['person', 'animal', 'face', 'vehicle', 'plate', 'package', 'discard'] as const;
|
|
19
|
+
type Label = typeof LABELS[number];
|
|
20
|
+
|
|
21
|
+
const CAPTURE_CLASSES = new Set(['person', 'cat', 'dog', 'animal', 'bird', 'face', 'vehicle', 'car', 'truck', 'bus', 'motorcycle', 'bicycle', 'plate', 'package']);
|
|
22
|
+
|
|
23
|
+
const RATE_OPTIONS = ['disabled', '1 per minute', '1 per 10 seconds', 'every detection'] as const;
|
|
24
|
+
type RateOption = typeof RATE_OPTIONS[number];
|
|
25
|
+
|
|
26
|
+
const RATE_MS: Record<RateOption, number> = {
|
|
27
|
+
'disabled': Infinity,
|
|
28
|
+
'1 per minute': 60_000,
|
|
29
|
+
'1 per 10 seconds': 10_000,
|
|
30
|
+
'every detection': 0,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MAX_CAPTURES = 2000;
|
|
34
|
+
|
|
35
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
interface CaptureRecord {
|
|
38
|
+
id: string;
|
|
39
|
+
cameraId: string;
|
|
40
|
+
cameraName: string;
|
|
41
|
+
timestamp: number;
|
|
42
|
+
detectedClass: string;
|
|
43
|
+
score: number;
|
|
44
|
+
boundingBox: number[]; // [x, y, w, h] in pixels
|
|
45
|
+
inputDimensions: number[]; // [width, height]
|
|
46
|
+
detectionId?: string;
|
|
47
|
+
label?: Label; // set after review
|
|
48
|
+
reviewed: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpRequestHandler {
|
|
54
|
+
// Map<cameraId, lastCaptureTimestamp>
|
|
55
|
+
private lastCapture = new Map<string, number>();
|
|
56
|
+
// Map<captureId, CaptureRecord>
|
|
57
|
+
private captures = new Map<string, CaptureRecord>();
|
|
58
|
+
// Map<captureId, jpegBuffer>
|
|
59
|
+
private images = new Map<string, Buffer>();
|
|
60
|
+
// Active event listeners
|
|
61
|
+
private listeners: (() => void)[] = [];
|
|
62
|
+
|
|
63
|
+
constructor(nativeId?: string) {
|
|
64
|
+
super(nativeId);
|
|
65
|
+
this.loadState();
|
|
66
|
+
this.registerListeners();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Persistence ──────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
private loadState() {
|
|
72
|
+
try {
|
|
73
|
+
const raw = this.storage.getItem('captures');
|
|
74
|
+
if (raw) {
|
|
75
|
+
const arr: CaptureRecord[] = JSON.parse(raw);
|
|
76
|
+
for (const r of arr) this.captures.set(r.id, r);
|
|
77
|
+
this.console.log(`Loaded ${this.captures.size} captures from storage.`);
|
|
78
|
+
}
|
|
79
|
+
} catch (e) {
|
|
80
|
+
this.console.warn('Could not load captures from storage:', e);
|
|
81
|
+
}
|
|
82
|
+
// images are stored as individual items
|
|
83
|
+
for (const [id] of this.captures) {
|
|
84
|
+
const raw = this.storage.getItem(`img:${id}`);
|
|
85
|
+
if (raw) this.images.set(id, Buffer.from(raw, 'base64'));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private saveCaptures() {
|
|
90
|
+
this.storage.setItem('captures', JSON.stringify([...this.captures.values()]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private saveImage(id: string, buf: Buffer) {
|
|
94
|
+
this.storage.setItem(`img:${id}`, buf.toString('base64'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private deleteCapture(id: string) {
|
|
98
|
+
const old = this.storage.getItem(`img:${id}`);
|
|
99
|
+
if (old) this.storage.removeItem(`img:${id}`);
|
|
100
|
+
this.captures.delete(id);
|
|
101
|
+
this.images.delete(id);
|
|
102
|
+
this.saveCaptures();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Settings ─────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async getSettings(): Promise<Setting[]> {
|
|
108
|
+
const cameras = Object.keys(systemManager.getSystemState())
|
|
109
|
+
.map(id => systemManager.getDeviceById(id))
|
|
110
|
+
.filter(d => d &&
|
|
111
|
+
(d.type === ScryptedDeviceType.Camera || d.type === ScryptedDeviceType.Doorbell) &&
|
|
112
|
+
d.interfaces?.includes(ScryptedInterface.ObjectDetector)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const settings: Setting[] = [
|
|
116
|
+
{
|
|
117
|
+
key: 'info',
|
|
118
|
+
title: 'Detection Trainer',
|
|
119
|
+
description: `${this.captures.size} captures stored (${[...this.captures.values()].filter(c => !c.reviewed).length} pending review, ${[...this.captures.values()].filter(c => c.reviewed && c.label !== 'discard').length} labeled). Open the web UI to review and export.`,
|
|
120
|
+
readonly: true,
|
|
121
|
+
value: '',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
key: 'open_ui',
|
|
125
|
+
title: 'Open Review UI',
|
|
126
|
+
description: 'Open the detection review and labeling interface.',
|
|
127
|
+
type: 'button',
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
for (const cam of cameras) {
|
|
132
|
+
const key = `rate:${cam.id}`;
|
|
133
|
+
settings.push({
|
|
134
|
+
key,
|
|
135
|
+
title: cam.name,
|
|
136
|
+
group: 'Capture Rate per Camera',
|
|
137
|
+
description: 'How often to capture detections from this camera.',
|
|
138
|
+
value: this.storage.getItem(key) || '1 per minute',
|
|
139
|
+
choices: [...RATE_OPTIONS],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return settings;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async putSetting(key: string, value: string) {
|
|
147
|
+
if (key === 'open_ui') return;
|
|
148
|
+
this.storage.setItem(key, value);
|
|
149
|
+
if (key.startsWith('rate:')) {
|
|
150
|
+
// Re-register listeners when rates change
|
|
151
|
+
this.registerListeners();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Listeners ─────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
private registerListeners() {
|
|
158
|
+
// Remove old listeners
|
|
159
|
+
for (const remove of this.listeners) remove();
|
|
160
|
+
this.listeners = [];
|
|
161
|
+
|
|
162
|
+
const cameras = Object.keys(systemManager.getSystemState())
|
|
163
|
+
.map(id => systemManager.getDeviceById(id))
|
|
164
|
+
.filter(d => d &&
|
|
165
|
+
(d.type === ScryptedDeviceType.Camera || d.type === ScryptedDeviceType.Doorbell) &&
|
|
166
|
+
d.interfaces?.includes(ScryptedInterface.ObjectDetector)
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
for (const cam of cameras) {
|
|
170
|
+
const rateKey = `rate:${cam.id}`;
|
|
171
|
+
const rateLabel = (this.storage.getItem(rateKey) || '1 per minute') as RateOption;
|
|
172
|
+
if (rateLabel === 'disabled') continue;
|
|
173
|
+
|
|
174
|
+
const listener = cam.listen(ScryptedInterface.ObjectDetector, async (source, details, data) => {
|
|
175
|
+
await this.onDetection(cam.id, cam.name, data as ObjectsDetected, RATE_MS[rateLabel]);
|
|
176
|
+
});
|
|
177
|
+
this.listeners.push(() => listener.removeListener());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.console.log(`Listening to ${this.listeners.length} camera(s).`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Detection Handler ─────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
private async onDetection(cameraId: string, cameraName: string, data: ObjectsDetected, rateLimitMs: number) {
|
|
186
|
+
if (!data?.detections?.length || !data.inputDimensions) return;
|
|
187
|
+
|
|
188
|
+
// Rate limit per camera
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
const last = this.lastCapture.get(cameraId) || 0;
|
|
191
|
+
if (now - last < rateLimitMs) return;
|
|
192
|
+
|
|
193
|
+
// Filter to target classes
|
|
194
|
+
const targets = data.detections.filter(d =>
|
|
195
|
+
d.className && CAPTURE_CLASSES.has(d.className.toLowerCase()) && d.boundingBox
|
|
196
|
+
);
|
|
197
|
+
if (!targets.length) return;
|
|
198
|
+
|
|
199
|
+
// Pick the highest-confidence target detection
|
|
200
|
+
const best = targets.sort((a, b) => (b.score || 0) - (a.score || 0))[0];
|
|
201
|
+
|
|
202
|
+
// Enforce max storage
|
|
203
|
+
if (this.captures.size >= MAX_CAPTURES) {
|
|
204
|
+
// Evict oldest unreviewed capture
|
|
205
|
+
const oldest = [...this.captures.values()]
|
|
206
|
+
.filter(c => !c.reviewed)
|
|
207
|
+
.sort((a, b) => a.timestamp - b.timestamp)[0];
|
|
208
|
+
if (oldest) this.deleteCapture(oldest.id);
|
|
209
|
+
else return; // All reviewed, don't evict labeled data
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.lastCapture.set(cameraId, now);
|
|
213
|
+
|
|
214
|
+
// Try to get the detection image
|
|
215
|
+
let jpeg: Buffer | undefined;
|
|
216
|
+
try {
|
|
217
|
+
if (data.detectionId) {
|
|
218
|
+
const cam = systemManager.getDeviceById(cameraId) as unknown as ObjectDetector;
|
|
219
|
+
const mo = await cam.getDetectionInput(data.detectionId);
|
|
220
|
+
jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
221
|
+
}
|
|
222
|
+
} catch (e) {
|
|
223
|
+
this.console.warn(`Could not get detection image for ${cameraName}:`, e);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!jpeg) return; // Skip if no image
|
|
227
|
+
|
|
228
|
+
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
|
229
|
+
const record: CaptureRecord = {
|
|
230
|
+
id,
|
|
231
|
+
cameraId,
|
|
232
|
+
cameraName,
|
|
233
|
+
timestamp: now,
|
|
234
|
+
detectedClass: best.className!,
|
|
235
|
+
score: best.score || 0,
|
|
236
|
+
boundingBox: best.boundingBox as number[],
|
|
237
|
+
inputDimensions: data.inputDimensions as number[],
|
|
238
|
+
detectionId: data.detectionId,
|
|
239
|
+
reviewed: false,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
this.captures.set(id, record);
|
|
243
|
+
this.images.set(id, jpeg);
|
|
244
|
+
this.saveImage(id, jpeg);
|
|
245
|
+
this.saveCaptures();
|
|
246
|
+
|
|
247
|
+
this.console.log(`Captured ${best.className} (${Math.round((best.score || 0) * 100)}%) from ${cameraName} [${this.captures.size} total]`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── HTTP Handler ──────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
async onRequest(request: HttpRequest, response: HttpResponse) {
|
|
253
|
+
const url = new URL(request.url, 'http://localhost');
|
|
254
|
+
const path = url.pathname.replace(request.rootPath, '');
|
|
255
|
+
|
|
256
|
+
// Serve image
|
|
257
|
+
if (path.startsWith('/img/')) {
|
|
258
|
+
const id = path.slice(5);
|
|
259
|
+
const img = this.images.get(id);
|
|
260
|
+
if (!img) return response.send('Not found', { code: 404 });
|
|
261
|
+
return response.send(img, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// API: label a capture
|
|
265
|
+
if (path === '/api/label' && request.body) {
|
|
266
|
+
const rawBody = request.body as any;
|
|
267
|
+
const body = JSON.parse(typeof rawBody === 'string' ? rawBody : Buffer.isBuffer(rawBody) ? rawBody.toString() : String(rawBody));
|
|
268
|
+
const record = this.captures.get(body.id);
|
|
269
|
+
if (!record) return response.send('Not found', { code: 404 });
|
|
270
|
+
record.label = body.label as Label;
|
|
271
|
+
record.reviewed = true;
|
|
272
|
+
if (body.label === 'discard') {
|
|
273
|
+
this.deleteCapture(body.id);
|
|
274
|
+
} else {
|
|
275
|
+
this.captures.set(body.id, record);
|
|
276
|
+
this.saveCaptures();
|
|
277
|
+
}
|
|
278
|
+
return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// API: get pending captures
|
|
282
|
+
if (path === '/api/pending') {
|
|
283
|
+
const pending = [...this.captures.values()]
|
|
284
|
+
.filter(c => !c.reviewed)
|
|
285
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
286
|
+
.slice(0, 50);
|
|
287
|
+
return response.send(JSON.stringify(pending), { headers: { 'Content-Type': 'application/json' } });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// API: stats
|
|
291
|
+
if (path === '/api/stats') {
|
|
292
|
+
const all = [...this.captures.values()];
|
|
293
|
+
const stats = {
|
|
294
|
+
total: all.length,
|
|
295
|
+
pending: all.filter(c => !c.reviewed).length,
|
|
296
|
+
labeled: all.filter(c => c.reviewed && c.label !== 'discard').length,
|
|
297
|
+
byLabel: {} as Record<string, number>,
|
|
298
|
+
byCamera: {} as Record<string, number>,
|
|
299
|
+
byDetectedClass: {} as Record<string, number>,
|
|
300
|
+
};
|
|
301
|
+
for (const r of all) {
|
|
302
|
+
if (r.label) stats.byLabel[r.label] = (stats.byLabel[r.label] || 0) + 1;
|
|
303
|
+
stats.byCamera[r.cameraName] = (stats.byCamera[r.cameraName] || 0) + 1;
|
|
304
|
+
stats.byDetectedClass[r.detectedClass] = (stats.byDetectedClass[r.detectedClass] || 0) + 1;
|
|
305
|
+
}
|
|
306
|
+
return response.send(JSON.stringify(stats), { headers: { 'Content-Type': 'application/json' } });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// API: export YOLO dataset
|
|
310
|
+
if (path === '/api/export') {
|
|
311
|
+
const labeled = [...this.captures.values()].filter(c => c.reviewed && c.label && c.label !== 'discard');
|
|
312
|
+
if (!labeled.length) return response.send(JSON.stringify({ error: 'No labeled data yet' }), { headers: { 'Content-Type': 'application/json' }, code: 400 });
|
|
313
|
+
|
|
314
|
+
const classMap: Record<Label, number> = {
|
|
315
|
+
person: 0, animal: 1, face: 2, vehicle: 3, plate: 4, package: 5, discard: -1,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Build a simple tarball-like structure as JSON for download
|
|
319
|
+
const dataset: { filename: string; content: string; encoding: string }[] = [];
|
|
320
|
+
|
|
321
|
+
for (const record of labeled) {
|
|
322
|
+
const img = this.images.get(record.id);
|
|
323
|
+
if (!img) continue;
|
|
324
|
+
|
|
325
|
+
const fname = `${record.id}`;
|
|
326
|
+
dataset.push({ filename: `images/${fname}.jpg`, content: img.toString('base64'), encoding: 'base64' });
|
|
327
|
+
|
|
328
|
+
const [x, y, w, h] = record.boundingBox;
|
|
329
|
+
const [imgW, imgH] = record.inputDimensions;
|
|
330
|
+
const cx = (x + w / 2) / imgW;
|
|
331
|
+
const cy = (y + h / 2) / imgH;
|
|
332
|
+
const nw = w / imgW;
|
|
333
|
+
const nh = h / imgH;
|
|
334
|
+
const classId = classMap[record.label!];
|
|
335
|
+
const labelLine = `${classId} ${cx.toFixed(6)} ${cy.toFixed(6)} ${nw.toFixed(6)} ${nh.toFixed(6)}\n`;
|
|
336
|
+
dataset.push({ filename: `labels/${fname}.txt`, content: labelLine, encoding: 'utf8' });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const yaml = [
|
|
340
|
+
'path: dataset',
|
|
341
|
+
'train: images',
|
|
342
|
+
'val: images',
|
|
343
|
+
'',
|
|
344
|
+
'nc: 6',
|
|
345
|
+
"names: ['person', 'animal', 'face', 'vehicle', 'plate', 'package']",
|
|
346
|
+
'',
|
|
347
|
+
'# Generated by Scrypted Detection Trainer',
|
|
348
|
+
`# ${labeled.length} labeled samples`,
|
|
349
|
+
].join('\n');
|
|
350
|
+
dataset.push({ filename: 'data.yaml', content: yaml, encoding: 'utf8' });
|
|
351
|
+
|
|
352
|
+
return response.send(JSON.stringify({ files: dataset, count: labeled.length }), {
|
|
353
|
+
headers: { 'Content-Type': 'application/json' },
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Serve Web UI
|
|
358
|
+
if (path === '/' || path === '' || path === '/index.html') {
|
|
359
|
+
return response.send(this.renderUI(), { headers: { 'Content-Type': 'text/html' } });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
response.send('Not found', { code: 404 });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Web UI ────────────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
private renderUI(): string {
|
|
368
|
+
return `<!DOCTYPE html>
|
|
369
|
+
<html lang="en">
|
|
370
|
+
<head>
|
|
371
|
+
<meta charset="UTF-8">
|
|
372
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
373
|
+
<title>Detection Trainer</title>
|
|
374
|
+
<style>
|
|
375
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
376
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f0f0f; color: #e8e8e8; min-height: 100vh; }
|
|
377
|
+
header { background: #1a1a1a; border-bottom: 1px solid #333; padding: 16px 24px; display: flex; align-items: center; justify-content: space-between; }
|
|
378
|
+
header h1 { font-size: 18px; font-weight: 600; color: #fff; }
|
|
379
|
+
.stats { display: flex; gap: 20px; font-size: 13px; color: #aaa; }
|
|
380
|
+
.stat span { color: #fff; font-weight: 600; }
|
|
381
|
+
.container { max-width: 1000px; margin: 0 auto; padding: 24px; }
|
|
382
|
+
.card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
|
|
383
|
+
.card-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; display: flex; align-items: center; justify-content: space-between; }
|
|
384
|
+
.card-header h2 { font-size: 15px; font-weight: 600; }
|
|
385
|
+
.badge { background: #333; color: #aaa; font-size: 12px; padding: 2px 8px; border-radius: 20px; }
|
|
386
|
+
.badge.orange { background: #3d2a00; color: #f90; }
|
|
387
|
+
.badge.green { background: #0d2d0d; color: #4c4; }
|
|
388
|
+
|
|
389
|
+
/* Detection card */
|
|
390
|
+
.detection { display: grid; grid-template-columns: 200px 1fr; gap: 0; border-bottom: 1px solid #222; }
|
|
391
|
+
.detection:last-child { border-bottom: none; }
|
|
392
|
+
.detection-img { position: relative; background: #111; display: flex; align-items: center; justify-content: center; min-height: 150px; }
|
|
393
|
+
.detection-img img { width: 100%; height: 150px; object-fit: cover; display: block; }
|
|
394
|
+
.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; }
|
|
395
|
+
.detection-info { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
|
|
396
|
+
.detection-meta { font-size: 12px; color: #888; display: flex; flex-wrap: wrap; gap: 10px; }
|
|
397
|
+
.detection-meta strong { color: #ccc; }
|
|
398
|
+
.label-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
399
|
+
.label-btn { padding: 7px 14px; border-radius: 8px; border: 1px solid #444; background: #222; color: #ccc; cursor: pointer; font-size: 13px; transition: all .15s; }
|
|
400
|
+
.label-btn:hover { border-color: #666; background: #2a2a2a; color: #fff; }
|
|
401
|
+
.label-btn.person { border-color: #2a6; color: #4d9; }
|
|
402
|
+
.label-btn.person:hover { background: #0d2a1a; }
|
|
403
|
+
.label-btn.animal { border-color: #a63; color: #d85; }
|
|
404
|
+
.label-btn.animal:hover { background: #2a1a0d; }
|
|
405
|
+
.label-btn.face { border-color: #49c; color: #6be; }
|
|
406
|
+
.label-btn.face:hover { background: #0d1a2a; }
|
|
407
|
+
.label-btn.vehicle { border-color: #76b; color: #99d; }
|
|
408
|
+
.label-btn.vehicle:hover { background: #1a1a2a; }
|
|
409
|
+
.label-btn.discard { border-color: #622; color: #a44; }
|
|
410
|
+
.label-btn.discard:hover { background: #2a0d0d; }
|
|
411
|
+
.detection.labeled { opacity: 0.4; pointer-events: none; }
|
|
412
|
+
.labeled-tag { font-size: 11px; color: #4d9; background: #0d2a1a; border: 1px solid #2a6; padding: 2px 8px; border-radius: 4px; }
|
|
413
|
+
|
|
414
|
+
/* Empty state */
|
|
415
|
+
.empty { padding: 48px; text-align: center; color: #555; }
|
|
416
|
+
.empty .icon { font-size: 48px; margin-bottom: 12px; }
|
|
417
|
+
|
|
418
|
+
/* Export section */
|
|
419
|
+
.export-btn { padding: 10px 20px; background: #1a4d8a; border: none; border-radius: 8px; color: #fff; cursor: pointer; font-size: 14px; font-weight: 500; }
|
|
420
|
+
.export-btn:hover { background: #1e5ca0; }
|
|
421
|
+
.export-btn:disabled { background: #333; color: #666; cursor: not-allowed; }
|
|
422
|
+
.export-info { font-size: 13px; color: #888; padding: 12px 20px; }
|
|
423
|
+
|
|
424
|
+
/* Progress bar */
|
|
425
|
+
.progress { height: 4px; background: #222; border-radius: 2px; overflow: hidden; margin-top: 8px; }
|
|
426
|
+
.progress-bar { height: 100%; background: #1a6; border-radius: 2px; transition: width .3s; }
|
|
427
|
+
|
|
428
|
+
.toast { position: fixed; bottom: 24px; right: 24px; background: #1a3; color: #fff; padding: 10px 18px; border-radius: 8px; font-size: 13px; opacity: 0; transition: opacity .3s; pointer-events: none; }
|
|
429
|
+
.toast.show { opacity: 1; }
|
|
430
|
+
|
|
431
|
+
.tab-bar { display: flex; gap: 2px; padding: 12px 20px 0; border-bottom: 1px solid #2a2a2a; }
|
|
432
|
+
.tab { padding: 8px 14px; font-size: 13px; color: #888; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
433
|
+
.tab.active { color: #fff; border-bottom-color: #4a9; }
|
|
434
|
+
.tab-content { padding: 20px; }
|
|
435
|
+
.tab-panel { display: none; }
|
|
436
|
+
.tab-panel.active { display: block; }
|
|
437
|
+
|
|
438
|
+
.breakdown-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
|
|
439
|
+
.breakdown-item { background: #222; border-radius: 8px; padding: 12px; }
|
|
440
|
+
.breakdown-item .label { font-size: 12px; color: #888; margin-bottom: 4px; }
|
|
441
|
+
.breakdown-item .value { font-size: 20px; font-weight: 600; color: #fff; }
|
|
442
|
+
</style>
|
|
443
|
+
</head>
|
|
444
|
+
<body>
|
|
445
|
+
<header>
|
|
446
|
+
<h1>🎯 Detection Trainer</h1>
|
|
447
|
+
<div class="stats">
|
|
448
|
+
<div>Pending <span id="stat-pending">—</span></div>
|
|
449
|
+
<div>Labeled <span id="stat-labeled">—</span></div>
|
|
450
|
+
<div>Total <span id="stat-total">—</span></div>
|
|
451
|
+
</div>
|
|
452
|
+
</header>
|
|
453
|
+
<div class="container">
|
|
454
|
+
|
|
455
|
+
<div class="card">
|
|
456
|
+
<div class="tab-bar">
|
|
457
|
+
<div class="tab active" onclick="showTab('review')">Review</div>
|
|
458
|
+
<div class="tab" onclick="showTab('stats')">Stats</div>
|
|
459
|
+
<div class="tab" onclick="showTab('export')">Export Dataset</div>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
<!-- Review tab -->
|
|
463
|
+
<div class="tab-panel active" id="tab-review">
|
|
464
|
+
<div id="detections-list"></div>
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
<!-- Stats tab -->
|
|
468
|
+
<div class="tab-panel" id="tab-stats">
|
|
469
|
+
<div class="tab-content">
|
|
470
|
+
<p style="font-size:13px;color:#888;margin-bottom:16px;">Breakdown of captured and labeled detections.</p>
|
|
471
|
+
<h3 style="font-size:13px;color:#aaa;margin-bottom:10px;">By Detected Class (what the model said)</h3>
|
|
472
|
+
<div class="breakdown-grid" id="stats-detected"></div>
|
|
473
|
+
<h3 style="font-size:13px;color:#aaa;margin:20px 0 10px;">By Corrected Label (what you said)</h3>
|
|
474
|
+
<div class="breakdown-grid" id="stats-label"></div>
|
|
475
|
+
<h3 style="font-size:13px;color:#aaa;margin:20px 0 10px;">By Camera</h3>
|
|
476
|
+
<div class="breakdown-grid" id="stats-camera"></div>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
|
|
480
|
+
<!-- Export tab -->
|
|
481
|
+
<div class="tab-panel" id="tab-export">
|
|
482
|
+
<div class="tab-content">
|
|
483
|
+
<p style="font-size:13px;color:#888;margin-bottom:16px;">
|
|
484
|
+
Exports a YOLO-format dataset (images + labels + data.yaml) as a downloadable bundle.
|
|
485
|
+
Only labeled detections are included. Review more detections first to build a larger dataset.
|
|
486
|
+
</p>
|
|
487
|
+
<div id="export-stats" class="export-info">Loading…</div>
|
|
488
|
+
<div style="display:flex;gap:12px;align-items:center;margin-top:12px;">
|
|
489
|
+
<button class="export-btn" id="export-btn" onclick="exportDataset()">Download Dataset</button>
|
|
490
|
+
<span id="export-status" style="font-size:13px;color:#888;"></span>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
<div class="toast" id="toast"></div>
|
|
498
|
+
|
|
499
|
+
<script>
|
|
500
|
+
const BASE = location.pathname.replace(/\\/$/, '');
|
|
501
|
+
let pending = [];
|
|
502
|
+
let labeledCount = 0;
|
|
503
|
+
|
|
504
|
+
function showTab(name) {
|
|
505
|
+
document.querySelectorAll('.tab').forEach((t, i) => {
|
|
506
|
+
const names = ['review', 'stats', 'export'];
|
|
507
|
+
t.classList.toggle('active', names[i] === name);
|
|
508
|
+
});
|
|
509
|
+
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
510
|
+
document.getElementById('tab-' + name).classList.add('active');
|
|
511
|
+
if (name === 'stats') loadStats();
|
|
512
|
+
if (name === 'export') loadExportInfo();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function toast(msg, color='#1a3') {
|
|
516
|
+
const el = document.getElementById('toast');
|
|
517
|
+
el.textContent = msg;
|
|
518
|
+
el.style.background = color;
|
|
519
|
+
el.classList.add('show');
|
|
520
|
+
setTimeout(() => el.classList.remove('show'), 2500);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function loadPending() {
|
|
524
|
+
const res = await fetch(BASE + '/api/pending');
|
|
525
|
+
pending = await res.json();
|
|
526
|
+
|
|
527
|
+
const statsRes = await fetch(BASE + '/api/stats');
|
|
528
|
+
const stats = await statsRes.json();
|
|
529
|
+
document.getElementById('stat-pending').textContent = stats.pending;
|
|
530
|
+
document.getElementById('stat-labeled').textContent = stats.labeled;
|
|
531
|
+
document.getElementById('stat-total').textContent = stats.total;
|
|
532
|
+
|
|
533
|
+
const list = document.getElementById('detections-list');
|
|
534
|
+
if (!pending.length) {
|
|
535
|
+
list.innerHTML = '<div class="empty"><div class="icon">✅</div><div>No pending detections to review.<br><span style="font-size:12px;color:#444">Captures will appear here as cameras detect objects.</span></div></div>';
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
list.innerHTML = pending.map(r => {
|
|
540
|
+
const date = new Date(r.timestamp).toLocaleString();
|
|
541
|
+
const score = Math.round(r.score * 100);
|
|
542
|
+
return \`
|
|
543
|
+
<div class="detection" id="det-\${r.id}">
|
|
544
|
+
<div class="detection-img">
|
|
545
|
+
<img src="\${BASE}/img/\${r.id}" alt="\${r.detectedClass}" loading="lazy" onerror="this.parentElement.innerHTML='<div style=\\"padding:20px;color:#555;font-size:12px;text-align:center\\">Image unavailable</div>'">
|
|
546
|
+
<div class="detection-class">\${r.detectedClass} \${score}%</div>
|
|
547
|
+
</div>
|
|
548
|
+
<div class="detection-info">
|
|
549
|
+
<div class="detection-meta">
|
|
550
|
+
<div><strong>\${r.cameraName}</strong></div>
|
|
551
|
+
<div>\${date}</div>
|
|
552
|
+
<div>Box: \${r.boundingBox.map(v => Math.round(v)).join(', ')}</div>
|
|
553
|
+
</div>
|
|
554
|
+
<div style="font-size:12px;color:#888;">What is this actually?</div>
|
|
555
|
+
<div class="label-buttons">
|
|
556
|
+
<button class="label-btn person" onclick="label('\${r.id}', 'person')">👤 Person</button>
|
|
557
|
+
<button class="label-btn animal" onclick="label('\${r.id}', 'animal')">🐾 Animal</button>
|
|
558
|
+
<button class="label-btn face" onclick="label('\${r.id}', 'face')">😀 Face</button>
|
|
559
|
+
<button class="label-btn vehicle" onclick="label('\${r.id}', 'vehicle')">🚗 Vehicle</button>
|
|
560
|
+
<button class="label-btn" onclick="label('\${r.id}', 'plate')">🔢 Plate</button>
|
|
561
|
+
<button class="label-btn" onclick="label('\${r.id}', 'package')">📦 Package</button>
|
|
562
|
+
<button class="label-btn discard" onclick="label('\${r.id}', 'discard')">🗑 Discard</button>
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
</div>\`;
|
|
566
|
+
}).join('');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function label(id, labelVal) {
|
|
570
|
+
const el = document.getElementById('det-' + id);
|
|
571
|
+
if (el) {
|
|
572
|
+
el.classList.add('labeled');
|
|
573
|
+
const btns = el.querySelectorAll('.label-btn');
|
|
574
|
+
btns.forEach(b => b.disabled = true);
|
|
575
|
+
}
|
|
576
|
+
await fetch(BASE + '/api/label', {
|
|
577
|
+
method: 'POST',
|
|
578
|
+
headers: { 'Content-Type': 'application/json' },
|
|
579
|
+
body: JSON.stringify({ id, label: labelVal }),
|
|
580
|
+
});
|
|
581
|
+
toast(labelVal === 'discard' ? 'Discarded' : 'Labeled: ' + labelVal, labelVal === 'discard' ? '#633' : '#1a6');
|
|
582
|
+
setTimeout(() => {
|
|
583
|
+
if (el) el.remove();
|
|
584
|
+
loadPending();
|
|
585
|
+
}, 600);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function loadStats() {
|
|
589
|
+
const res = await fetch(BASE + '/api/stats');
|
|
590
|
+
const stats = await res.json();
|
|
591
|
+
|
|
592
|
+
const renderBreakdown = (obj, container) => {
|
|
593
|
+
const el = document.getElementById(container);
|
|
594
|
+
const entries = Object.entries(obj).sort((a, b) => b[1] - a[1]);
|
|
595
|
+
el.innerHTML = entries.length
|
|
596
|
+
? entries.map(([k, v]) => \`<div class="breakdown-item"><div class="label">\${k}</div><div class="value">\${v}</div></div>\`).join('')
|
|
597
|
+
: '<div style="color:#555;font-size:13px;">None yet</div>';
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
renderBreakdown(stats.byDetectedClass, 'stats-detected');
|
|
601
|
+
renderBreakdown(stats.byLabel, 'stats-label');
|
|
602
|
+
renderBreakdown(stats.byCamera, 'stats-camera');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function loadExportInfo() {
|
|
606
|
+
const res = await fetch(BASE + '/api/stats');
|
|
607
|
+
const stats = await res.json();
|
|
608
|
+
document.getElementById('export-stats').textContent =
|
|
609
|
+
\`\${stats.labeled} labeled samples ready for export across \${Object.keys(stats.byCamera).length} camera(s).\`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function exportDataset() {
|
|
613
|
+
const btn = document.getElementById('export-btn');
|
|
614
|
+
const status = document.getElementById('export-status');
|
|
615
|
+
btn.disabled = true;
|
|
616
|
+
status.textContent = 'Preparing…';
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const res = await fetch(BASE + '/api/export');
|
|
620
|
+
if (!res.ok) { status.textContent = 'Nothing to export yet.'; btn.disabled = false; return; }
|
|
621
|
+
const data = await res.json();
|
|
622
|
+
if (data.error) { status.textContent = data.error; btn.disabled = false; return; }
|
|
623
|
+
|
|
624
|
+
// Build a zip-like structure using a self-extracting HTML page
|
|
625
|
+
// Actually just download as a JSON bundle that train.py can consume
|
|
626
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
627
|
+
const url = URL.createObjectURL(blob);
|
|
628
|
+
const a = document.createElement('a');
|
|
629
|
+
a.href = url;
|
|
630
|
+
a.download = 'scrypted_dataset_' + new Date().toISOString().slice(0,10) + '.json';
|
|
631
|
+
a.click();
|
|
632
|
+
URL.revokeObjectURL(url);
|
|
633
|
+
status.textContent = \`Downloaded \${data.count} samples.\`;
|
|
634
|
+
toast('Dataset downloaded!');
|
|
635
|
+
} catch (e) {
|
|
636
|
+
status.textContent = 'Export failed: ' + e.message;
|
|
637
|
+
}
|
|
638
|
+
btn.disabled = false;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Initial load
|
|
642
|
+
loadPending();
|
|
643
|
+
// Auto-refresh pending every 30s
|
|
644
|
+
setInterval(loadPending, 30_000);
|
|
645
|
+
</script>
|
|
646
|
+
</body>
|
|
647
|
+
</html>`;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export default DetectionTrainer;
|