scrypted-detection-trainer 0.1.8 → 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/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +433 -19
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/main.ts +404 -17
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
|
|
|
@@ -135,6 +157,13 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
135
157
|
readonly: true,
|
|
136
158
|
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>`,
|
|
137
159
|
},
|
|
160
|
+
{
|
|
161
|
+
key: 'autoCapture',
|
|
162
|
+
title: 'Auto-Capture',
|
|
163
|
+
description: 'Automatically capture detections in the background. Disable to use manual browsing only.',
|
|
164
|
+
type: 'boolean',
|
|
165
|
+
value: (this.storage.getItem('autoCapture') ?? 'true'),
|
|
166
|
+
},
|
|
138
167
|
];
|
|
139
168
|
|
|
140
169
|
for (const cam of cameras) {
|
|
@@ -192,6 +221,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
192
221
|
// ── Detection Handler ─────────────────────────────────────────────────────
|
|
193
222
|
|
|
194
223
|
private async onDetection(cameraId: string, cameraName: string, data: ObjectsDetected, rateLimitMs: number) {
|
|
224
|
+
if ((this.storage.getItem('autoCapture') ?? 'true') === 'false') return;
|
|
195
225
|
if (!data?.detections?.length || !data.inputDimensions) return;
|
|
196
226
|
|
|
197
227
|
// Rate limit per camera
|
|
@@ -249,7 +279,6 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
249
279
|
};
|
|
250
280
|
|
|
251
281
|
this.captures.set(id, record);
|
|
252
|
-
this.images.set(id, jpeg);
|
|
253
282
|
this.saveImage(id, jpeg);
|
|
254
283
|
this.saveCaptures();
|
|
255
284
|
|
|
@@ -262,10 +291,26 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
262
291
|
const url = new URL(request.url, 'http://localhost');
|
|
263
292
|
const path = url.pathname.replace(request.rootPath, '');
|
|
264
293
|
|
|
294
|
+
// Serve browse event image (fetch on demand from camera)
|
|
295
|
+
if (path === '/api/browse-img') {
|
|
296
|
+
const params = new URL(request.url, 'http://localhost').searchParams;
|
|
297
|
+
const cameraId = params.get('cameraId')?.replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
298
|
+
const detectionId = params.get('detectionId')?.replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
299
|
+
if (!cameraId || !detectionId) return response.send('Missing params', { code: 400 });
|
|
300
|
+
try {
|
|
301
|
+
const cam = systemManager.getDeviceById(cameraId) as unknown as ObjectDetector;
|
|
302
|
+
const mo = await cam.getDetectionInput(detectionId);
|
|
303
|
+
const jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
304
|
+
return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=60' } });
|
|
305
|
+
} catch (e) {
|
|
306
|
+
return response.send('Image unavailable', { code: 404 });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
265
310
|
// Serve image
|
|
266
311
|
if (path.startsWith('/img/')) {
|
|
267
|
-
const id = path.slice(5);
|
|
268
|
-
const img = this.
|
|
312
|
+
const id = path.slice(5).replace(/[^a-zA-Z0-9_\-]/g, ''); // sanitize
|
|
313
|
+
const img = this.loadImage(id);
|
|
269
314
|
if (!img) return response.send('Not found', { code: 404 });
|
|
270
315
|
return response.send(img, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
|
|
271
316
|
}
|
|
@@ -287,6 +332,89 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
287
332
|
return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
|
|
288
333
|
}
|
|
289
334
|
|
|
335
|
+
// API: list cameras for browse
|
|
336
|
+
if (path === '/api/cameras') {
|
|
337
|
+
const cameras = Object.keys(systemManager.getSystemState())
|
|
338
|
+
.map(id => systemManager.getDeviceById(id))
|
|
339
|
+
.filter(d => d &&
|
|
340
|
+
(d.type === ScryptedDeviceType.Camera || d.type === ScryptedDeviceType.Doorbell) &&
|
|
341
|
+
d.interfaces?.includes(ScryptedInterface.ObjectDetector)
|
|
342
|
+
)
|
|
343
|
+
.map(d => ({ id: d.id, name: d.name }));
|
|
344
|
+
return response.send(JSON.stringify(cameras), { headers: { 'Content-Type': 'application/json' } });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// API: browse recent events for a camera
|
|
348
|
+
if (path === '/api/browse') {
|
|
349
|
+
const params = new URL(request.url, 'http://localhost').searchParams;
|
|
350
|
+
const cameraId = params.get('cameraId');
|
|
351
|
+
const hours = parseInt(params.get('hours') || '24');
|
|
352
|
+
if (!cameraId) return response.send('Missing cameraId', { code: 400 });
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const cam = systemManager.getDeviceById(cameraId) as any;
|
|
356
|
+
if (!cam) return response.send('Camera not found', { code: 404 });
|
|
357
|
+
|
|
358
|
+
const endTime = Date.now();
|
|
359
|
+
const startTime = endTime - hours * 3600 * 1000;
|
|
360
|
+
const events = await cam.getRecordedEvents({ startTime, endTime });
|
|
361
|
+
|
|
362
|
+
// Filter to ObjectDetector events only and limit to 100
|
|
363
|
+
const detectionEvents = (events || [])
|
|
364
|
+
.filter((e: any) => e.details?.eventInterface === 'ObjectDetector' && e.data?.detections?.length)
|
|
365
|
+
.slice(0, 100)
|
|
366
|
+
.map((e: any) => ({
|
|
367
|
+
detectionId: e.data?.detectionId,
|
|
368
|
+
timestamp: e.details?.eventTime || e.data?.timestamp,
|
|
369
|
+
detections: (e.data?.detections || []).map((d: any) => ({
|
|
370
|
+
className: d.className,
|
|
371
|
+
score: d.score,
|
|
372
|
+
boundingBox: d.boundingBox,
|
|
373
|
+
})),
|
|
374
|
+
inputDimensions: e.data?.inputDimensions,
|
|
375
|
+
cameraId,
|
|
376
|
+
cameraName: cam.name,
|
|
377
|
+
}))
|
|
378
|
+
.filter((e: any) => e.detectionId && e.inputDimensions);
|
|
379
|
+
|
|
380
|
+
return response.send(JSON.stringify(detectionEvents), { headers: { 'Content-Type': 'application/json' } });
|
|
381
|
+
} catch (e: any) {
|
|
382
|
+
return response.send(JSON.stringify({ error: e.message }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// API: add a browsed event directly to dataset as labeled
|
|
387
|
+
if (path === '/api/add-event' && request.body) {
|
|
388
|
+
const rawBody = request.body as any;
|
|
389
|
+
const body = JSON.parse(typeof rawBody === 'string' ? rawBody : Buffer.isBuffer(rawBody) ? rawBody.toString() : String(rawBody));
|
|
390
|
+
const { cameraId, cameraName, detectionId, timestamp, detectedClass, score, boundingBox, inputDimensions, label } = body;
|
|
391
|
+
|
|
392
|
+
if (!label || label === 'discard') return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
|
|
393
|
+
|
|
394
|
+
// Try to get the image
|
|
395
|
+
let jpeg: Buffer | undefined;
|
|
396
|
+
try {
|
|
397
|
+
const cam = systemManager.getDeviceById(cameraId) as unknown as ObjectDetector;
|
|
398
|
+
const mo = await cam.getDetectionInput(detectionId);
|
|
399
|
+
jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
400
|
+
} catch (e) {
|
|
401
|
+
this.console.warn(`Could not get image for browse event ${detectionId}:`, e);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!jpeg) return response.send(JSON.stringify({ error: 'Could not retrieve image' }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
|
|
405
|
+
|
|
406
|
+
const id = `browse-${timestamp}-${Math.random().toString(36).slice(2, 6)}`;
|
|
407
|
+
const record: CaptureRecord = {
|
|
408
|
+
id, cameraId, cameraName, timestamp, detectedClass, score,
|
|
409
|
+
boundingBox, inputDimensions, detectionId, reviewed: true, label,
|
|
410
|
+
};
|
|
411
|
+
this.captures.set(id, record);
|
|
412
|
+
this.saveImage(id, jpeg);
|
|
413
|
+
this.saveCaptures();
|
|
414
|
+
|
|
415
|
+
return response.send(JSON.stringify({ ok: true, id }), { headers: { 'Content-Type': 'application/json' } });
|
|
416
|
+
}
|
|
417
|
+
|
|
290
418
|
// API: get pending captures
|
|
291
419
|
if (path === '/api/pending') {
|
|
292
420
|
const pending = [...this.captures.values()]
|
|
@@ -339,7 +467,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
339
467
|
const dataset: { filename: string; content: string; encoding: string }[] = [];
|
|
340
468
|
|
|
341
469
|
for (const record of labeled) {
|
|
342
|
-
const img = this.
|
|
470
|
+
const img = this.loadImage(record.id);
|
|
343
471
|
if (!img) continue;
|
|
344
472
|
|
|
345
473
|
const fname = `${record.id}`;
|
|
@@ -487,6 +615,7 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
487
615
|
<div class="card">
|
|
488
616
|
<div class="tab-bar">
|
|
489
617
|
<div class="tab active" onclick="showTab('review')">Review</div>
|
|
618
|
+
<div class="tab" onclick="showTab('browse')">Browse Events</div>
|
|
490
619
|
<div class="tab" onclick="showTab('labeled')">Labeled</div>
|
|
491
620
|
<div class="tab" onclick="showTab('stats')">Stats</div>
|
|
492
621
|
<div class="tab" onclick="showTab('export')">Export Dataset</div>
|
|
@@ -497,12 +626,30 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
497
626
|
<div id="detections-list"></div>
|
|
498
627
|
</div>
|
|
499
628
|
|
|
629
|
+
<!-- Browse tab -->
|
|
630
|
+
<div class="tab-panel" id="tab-browse">
|
|
631
|
+
<div class="tab-content">
|
|
632
|
+
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px;">
|
|
633
|
+
<select id="browse-camera" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
|
|
634
|
+
<option value="">Select camera…</option>
|
|
635
|
+
</select>
|
|
636
|
+
<select id="browse-hours" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
|
|
637
|
+
<option value="1">Last 1 hour</option>
|
|
638
|
+
<option value="6">Last 6 hours</option>
|
|
639
|
+
<option value="24" selected>Last 24 hours</option>
|
|
640
|
+
<option value="72">Last 3 days</option>
|
|
641
|
+
</select>
|
|
642
|
+
<button class="export-btn" onclick="loadBrowse()" style="padding:8px 16px;">Load Events</button>
|
|
643
|
+
<span id="browse-status" style="font-size:13px;color:#888;"></span>
|
|
644
|
+
</div>
|
|
645
|
+
<div id="browse-list"></div>
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
|
|
500
649
|
<!-- Labeled tab -->
|
|
501
650
|
<div class="tab-panel" id="tab-labeled">
|
|
502
651
|
<div id="labeled-list"></div>
|
|
503
652
|
</div>
|
|
504
|
-
|
|
505
|
-
<!-- Stats tab -->
|
|
506
653
|
<div class="tab-panel" id="tab-stats">
|
|
507
654
|
<div class="tab-content">
|
|
508
655
|
<p style="font-size:13px;color:#888;margin-bottom:16px;">Breakdown of captured and labeled detections.</p>
|
|
@@ -659,7 +806,7 @@ function lbKeyHandler(e) {
|
|
|
659
806
|
}
|
|
660
807
|
|
|
661
808
|
function showTab(name) {
|
|
662
|
-
const names = ['review', 'labeled', 'stats', 'export'];
|
|
809
|
+
const names = ['review', 'browse', 'labeled', 'stats', 'export'];
|
|
663
810
|
document.querySelectorAll('.tab').forEach((t, i) => {
|
|
664
811
|
t.classList.toggle('active', names[i] === name);
|
|
665
812
|
});
|
|
@@ -668,6 +815,246 @@ function showTab(name) {
|
|
|
668
815
|
if (name === 'stats') loadStats();
|
|
669
816
|
if (name === 'export') loadExportInfo();
|
|
670
817
|
if (name === 'labeled') loadLabeled(0);
|
|
818
|
+
if (name === 'browse') initBrowse();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function initBrowse() {
|
|
822
|
+
const sel = document.getElementById('browse-camera');
|
|
823
|
+
if (sel.options.length > 1) return; // already loaded
|
|
824
|
+
try {
|
|
825
|
+
const res = await fetch(BASE + '/api/cameras');
|
|
826
|
+
const cameras = await res.json();
|
|
827
|
+
for (const cam of cameras) {
|
|
828
|
+
const opt = document.createElement('option');
|
|
829
|
+
opt.value = cam.id;
|
|
830
|
+
opt.textContent = cam.name;
|
|
831
|
+
sel.appendChild(opt);
|
|
832
|
+
}
|
|
833
|
+
if (cameras.length === 1) sel.value = cameras[0].id;
|
|
834
|
+
} catch(e) {
|
|
835
|
+
document.getElementById('browse-status').textContent = 'Error loading cameras';
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function loadBrowse() {
|
|
840
|
+
const cameraId = document.getElementById('browse-camera').value;
|
|
841
|
+
const hours = document.getElementById('browse-hours').value;
|
|
842
|
+
const status = document.getElementById('browse-status');
|
|
843
|
+
const list = document.getElementById('browse-list');
|
|
844
|
+
|
|
845
|
+
if (!cameraId) { status.textContent = 'Select a camera first'; return; }
|
|
846
|
+
|
|
847
|
+
status.textContent = 'Loading…';
|
|
848
|
+
list.innerHTML = '';
|
|
849
|
+
|
|
850
|
+
try {
|
|
851
|
+
const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
|
|
852
|
+
const events = await res.json();
|
|
853
|
+
|
|
854
|
+
if (events.error) { status.textContent = 'Error: ' + events.error; return; }
|
|
855
|
+
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; }
|
|
856
|
+
|
|
857
|
+
status.textContent = events.length + ' events found';
|
|
858
|
+
|
|
859
|
+
list.innerHTML = events.map((ev, i) => {
|
|
860
|
+
const date = new Date(ev.timestamp).toLocaleString();
|
|
861
|
+
const dets = ev.detections || [];
|
|
862
|
+
const primary = dets[0] || {};
|
|
863
|
+
const score = Math.round((primary.score || 0) * 100);
|
|
864
|
+
const allClasses = dets.map(d => d.className + ' ' + Math.round((d.score||0)*100) + '%').join(', ');
|
|
865
|
+
return \`
|
|
866
|
+
<div class="detection" id="bev-\${i}" style="opacity:1;transition:opacity .3s">
|
|
867
|
+
<div class="detection-imgs">
|
|
868
|
+
<div class="img-panel">
|
|
869
|
+
<div class="img-label">Full frame</div>
|
|
870
|
+
<canvas id="bcanvas-\${i}" class="det-canvas" width="240" height="160"></canvas>
|
|
871
|
+
</div>
|
|
872
|
+
<div class="img-panel" id="bcrop-panel-\${i}">
|
|
873
|
+
<div class="img-label">Crop</div>
|
|
874
|
+
<canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
<div class="detection-info">
|
|
878
|
+
<div class="detection-meta">
|
|
879
|
+
<div><strong>\${ev.cameraName}</strong></div>
|
|
880
|
+
<div>\${date}</div>
|
|
881
|
+
<div class="det-class-badge">\${allClasses}</div>
|
|
882
|
+
</div>
|
|
883
|
+
<div style="font-size:12px;color:#888;">Add to dataset as:</div>
|
|
884
|
+
<div class="label-buttons" id="blabels-\${i}">
|
|
885
|
+
<button class="label-btn person" onclick="addEvent(\${i})('person')">👤 Person</button>
|
|
886
|
+
<button class="label-btn animal" onclick="addEvent(\${i})('animal')">🐾 Animal</button>
|
|
887
|
+
<button class="label-btn face" onclick="addEvent(\${i})('face')">😀 Face</button>
|
|
888
|
+
<button class="label-btn vehicle" onclick="addEvent(\${i})('vehicle')">🚗 Vehicle</button>
|
|
889
|
+
<button class="label-btn" onclick="addEvent(\${i})('plate')">🔢 Plate</button>
|
|
890
|
+
<button class="label-btn" onclick="addEvent(\${i})('package')">📦 Package</button>
|
|
891
|
+
<button class="label-btn discard" onclick="addEvent(\${i})('discard')">🗑 Skip</button>
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
</div>\`;
|
|
895
|
+
}).join('');
|
|
896
|
+
|
|
897
|
+
// Load images for each event
|
|
898
|
+
for (let i = 0; i < events.length; i++) {
|
|
899
|
+
const ev = events[i];
|
|
900
|
+
loadBrowseImage(i, ev);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
} catch(e) {
|
|
904
|
+
status.textContent = 'Error: ' + e.message;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Store browse events for addEvent closure
|
|
909
|
+
let browseEvents = [];
|
|
910
|
+
|
|
911
|
+
async function loadBrowse() {
|
|
912
|
+
const cameraId = document.getElementById('browse-camera').value;
|
|
913
|
+
const hours = document.getElementById('browse-hours').value;
|
|
914
|
+
const status = document.getElementById('browse-status');
|
|
915
|
+
const list = document.getElementById('browse-list');
|
|
916
|
+
|
|
917
|
+
if (!cameraId) { status.textContent = 'Select a camera first'; return; }
|
|
918
|
+
|
|
919
|
+
status.textContent = 'Loading…';
|
|
920
|
+
list.innerHTML = '';
|
|
921
|
+
browseEvents = [];
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
|
|
925
|
+
const events = await res.json();
|
|
926
|
+
|
|
927
|
+
if (events.error) { status.textContent = 'Error: ' + events.error; return; }
|
|
928
|
+
if (!events.length) {
|
|
929
|
+
status.textContent = 'No detection events found.';
|
|
930
|
+
list.innerHTML = '<div class="empty"><div class="icon">🔍</div><div>No ObjectDetector events in this time range.</div></div>';
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
browseEvents = events;
|
|
935
|
+
status.textContent = events.length + ' events';
|
|
936
|
+
|
|
937
|
+
list.innerHTML = events.map((ev, i) => {
|
|
938
|
+
const date = new Date(ev.timestamp).toLocaleString();
|
|
939
|
+
const dets = ev.detections || [];
|
|
940
|
+
const allClasses = [...new Set(dets.map(d => d.className))].join(', ');
|
|
941
|
+
return \`
|
|
942
|
+
<div class="detection" id="bev-\${i}">
|
|
943
|
+
<div class="detection-imgs">
|
|
944
|
+
<div class="img-panel">
|
|
945
|
+
<div class="img-label">Full frame</div>
|
|
946
|
+
<canvas id="bcanvas-full-\${i}" class="det-canvas" width="240" height="160"></canvas>
|
|
947
|
+
</div>
|
|
948
|
+
<div class="img-panel">
|
|
949
|
+
<div class="img-label">Crop</div>
|
|
950
|
+
<canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
|
|
951
|
+
</div>
|
|
952
|
+
</div>
|
|
953
|
+
<div class="detection-info">
|
|
954
|
+
<div class="detection-meta">
|
|
955
|
+
<div><strong>\${ev.cameraName}</strong></div>
|
|
956
|
+
<div>\${date}</div>
|
|
957
|
+
<div class="det-class-badge">\${allClasses}</div>
|
|
958
|
+
</div>
|
|
959
|
+
<div style="font-size:12px;color:#888;">Add to dataset as:</div>
|
|
960
|
+
<div class="label-buttons">
|
|
961
|
+
<button class="label-btn person" onclick="addBrowseEvent(\${i},'person')">👤 Person</button>
|
|
962
|
+
<button class="label-btn animal" onclick="addBrowseEvent(\${i},'animal')">🐾 Animal</button>
|
|
963
|
+
<button class="label-btn face" onclick="addBrowseEvent(\${i},'face')">😀 Face</button>
|
|
964
|
+
<button class="label-btn vehicle" onclick="addBrowseEvent(\${i},'vehicle')">🚗 Vehicle</button>
|
|
965
|
+
<button class="label-btn" onclick="addBrowseEvent(\${i},'plate')">🔢 Plate</button>
|
|
966
|
+
<button class="label-btn" onclick="addBrowseEvent(\${i},'package')">📦 Package</button>
|
|
967
|
+
<button class="label-btn discard" onclick="addBrowseEvent(\${i},'discard')">🗑 Skip</button>
|
|
968
|
+
</div>
|
|
969
|
+
</div>
|
|
970
|
+
</div>\`;
|
|
971
|
+
}).join('');
|
|
972
|
+
|
|
973
|
+
// Load thumbnails for each event
|
|
974
|
+
for (let i = 0; i < events.length; i++) {
|
|
975
|
+
loadBrowseImage(i, events[i]);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
} catch(e) {
|
|
979
|
+
status.textContent = 'Error: ' + e.message;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function loadBrowseImage(i, ev) {
|
|
984
|
+
const primary = (ev.detections || [])[0];
|
|
985
|
+
if (!primary?.boundingBox) return;
|
|
986
|
+
// Request image via the img endpoint using detectionId as the key
|
|
987
|
+
// We store a browse-prefixed image server-side only after adding — for preview
|
|
988
|
+
// use a placeholder fetch to trigger server-side caching
|
|
989
|
+
fetch(BASE + '/api/browse-img?cameraId=' + ev.cameraId + '&detectionId=' + ev.detectionId)
|
|
990
|
+
.then(r => r.ok ? r.blob() : null)
|
|
991
|
+
.then(blob => {
|
|
992
|
+
if (!blob) return;
|
|
993
|
+
const url = URL.createObjectURL(blob);
|
|
994
|
+
const img = new Image();
|
|
995
|
+
img.onload = () => {
|
|
996
|
+
imgCache.set('browse-' + i, img);
|
|
997
|
+
// Draw on full canvas
|
|
998
|
+
const fullCanvas = document.getElementById('bcanvas-full-' + i);
|
|
999
|
+
const cropCanvas = document.getElementById('bcanvas-crop-' + i);
|
|
1000
|
+
if (fullCanvas) fullCanvas.id = 'canvas-full-browse' + i;
|
|
1001
|
+
if (cropCanvas) cropCanvas.id = 'canvas-crop-browse' + i;
|
|
1002
|
+
const fakeR = { ...ev, id: 'browse' + i, boundingBox: primary.boundingBox, detectedClass: primary.className, score: primary.score };
|
|
1003
|
+
drawDetection(img, fakeR);
|
|
1004
|
+
if (fullCanvas) { fullCanvas.id = 'bcanvas-full-' + i; fullCanvas.onclick = () => openLightbox(fakeR); }
|
|
1005
|
+
if (cropCanvas) { cropCanvas.id = 'bcanvas-crop-' + i; cropCanvas.onclick = () => openLightbox(fakeR); }
|
|
1006
|
+
URL.revokeObjectURL(url);
|
|
1007
|
+
};
|
|
1008
|
+
img.src = url;
|
|
1009
|
+
}).catch(() => {});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function addBrowseEvent(i, label) {
|
|
1013
|
+
const ev = browseEvents[i];
|
|
1014
|
+
if (!ev) return;
|
|
1015
|
+
const el = document.getElementById('bev-' + i);
|
|
1016
|
+
if (el) { el.style.opacity = '0.4'; el.querySelectorAll('button').forEach(b => b.disabled = true); }
|
|
1017
|
+
|
|
1018
|
+
if (label !== 'discard') {
|
|
1019
|
+
const primary = (ev.detections || [])[0];
|
|
1020
|
+
if (!primary) return;
|
|
1021
|
+
try {
|
|
1022
|
+
const res = await fetch(BASE + '/api/add-event', {
|
|
1023
|
+
method: 'POST',
|
|
1024
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1025
|
+
body: JSON.stringify({
|
|
1026
|
+
cameraId: ev.cameraId,
|
|
1027
|
+
cameraName: ev.cameraName,
|
|
1028
|
+
detectionId: ev.detectionId,
|
|
1029
|
+
timestamp: ev.timestamp,
|
|
1030
|
+
detectedClass: primary.className,
|
|
1031
|
+
score: primary.score,
|
|
1032
|
+
boundingBox: primary.boundingBox,
|
|
1033
|
+
inputDimensions: ev.inputDimensions,
|
|
1034
|
+
label,
|
|
1035
|
+
}),
|
|
1036
|
+
});
|
|
1037
|
+
const data = await res.json();
|
|
1038
|
+
if (data.error) { toast('Error: ' + data.error, '#633'); if (el) el.style.opacity = '1'; el?.querySelectorAll('button').forEach(b => b.disabled = false); return; }
|
|
1039
|
+
toast('Added: ' + label, '#1a6');
|
|
1040
|
+
} catch(e) {
|
|
1041
|
+
toast('Failed: ' + e.message, '#633');
|
|
1042
|
+
if (el) el.style.opacity = '1';
|
|
1043
|
+
el?.querySelectorAll('button').forEach(b => b.disabled = false);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
} else {
|
|
1047
|
+
toast('Skipped', '#555');
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Remove from list after short delay
|
|
1051
|
+
setTimeout(() => { if (el) el.remove(); }, 400);
|
|
1052
|
+
|
|
1053
|
+
// Update stats
|
|
1054
|
+
const statsRes = await fetch(BASE + '/api/stats');
|
|
1055
|
+
const stats = await statsRes.json();
|
|
1056
|
+
document.getElementById('stat-labeled').textContent = stats.labeled;
|
|
1057
|
+
document.getElementById('stat-total').textContent = stats.total;
|
|
671
1058
|
}
|
|
672
1059
|
|
|
673
1060
|
const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };
|