scrypted-detection-trainer 0.1.10 → 0.1.12
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 +69 -116
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/main.ts +71 -118
package/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -291,17 +291,17 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
291
291
|
const url = new URL(request.url, 'http://localhost');
|
|
292
292
|
const path = url.pathname.replace(request.rootPath, '');
|
|
293
293
|
|
|
294
|
-
// Serve browse event image
|
|
294
|
+
// Serve browse event image via getVideoClipThumbnail
|
|
295
295
|
if (path === '/api/browse-img') {
|
|
296
296
|
const params = new URL(request.url, 'http://localhost').searchParams;
|
|
297
297
|
const cameraId = params.get('cameraId')?.replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
298
|
-
const
|
|
299
|
-
if (!cameraId || !
|
|
298
|
+
const thumbnailId = params.get('thumbnailId')?.replace(/[^a-zA-Z0-9_\-:.]/g, '');
|
|
299
|
+
if (!cameraId || !thumbnailId) return response.send('Missing params', { code: 400 });
|
|
300
300
|
try {
|
|
301
|
-
const cam = systemManager.getDeviceById(cameraId) as
|
|
302
|
-
const mo = await cam.
|
|
301
|
+
const cam = systemManager.getDeviceById(cameraId) as any;
|
|
302
|
+
const mo = await cam.getVideoClipThumbnail(thumbnailId);
|
|
303
303
|
const jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
304
|
-
return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=
|
|
304
|
+
return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
|
|
305
305
|
} catch (e) {
|
|
306
306
|
return response.send('Image unavailable', { code: 404 });
|
|
307
307
|
}
|
|
@@ -357,27 +357,24 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
357
357
|
|
|
358
358
|
const endTime = Date.now();
|
|
359
359
|
const startTime = endTime - hours * 3600 * 1000;
|
|
360
|
-
const
|
|
360
|
+
const clips = await cam.getVideoClips({ startTime, endTime });
|
|
361
361
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
.filter((e: any) => e.details?.eventInterface === 'ObjectDetector' && e.data?.detections?.length)
|
|
362
|
+
const events = (clips || [])
|
|
363
|
+
.filter((c: any) => c.detectionClasses?.length && c.thumbnailId)
|
|
365
364
|
.slice(0, 100)
|
|
366
|
-
.map((
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
inputDimensions: e.data?.inputDimensions,
|
|
365
|
+
.map((c: any) => ({
|
|
366
|
+
clipId: c.id,
|
|
367
|
+
thumbnailId: c.thumbnailId,
|
|
368
|
+
timestamp: c.startTime,
|
|
369
|
+
detectionClasses: c.detectionClasses || [],
|
|
370
|
+
// bounding box not available at clip level — use full frame
|
|
371
|
+
boundingBox: null,
|
|
372
|
+
inputDimensions: null,
|
|
375
373
|
cameraId,
|
|
376
374
|
cameraName: cam.name,
|
|
377
|
-
}))
|
|
378
|
-
.filter((e: any) => e.detectionId && e.inputDimensions);
|
|
375
|
+
}));
|
|
379
376
|
|
|
380
|
-
return response.send(JSON.stringify(
|
|
377
|
+
return response.send(JSON.stringify(events), { headers: { 'Content-Type': 'application/json' } });
|
|
381
378
|
} catch (e: any) {
|
|
382
379
|
return response.send(JSON.stringify({ error: e.message }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
|
|
383
380
|
}
|
|
@@ -387,26 +384,31 @@ class DetectionTrainer extends ScryptedDeviceBase implements Settings, HttpReque
|
|
|
387
384
|
if (path === '/api/add-event' && request.body) {
|
|
388
385
|
const rawBody = request.body as any;
|
|
389
386
|
const body = JSON.parse(typeof rawBody === 'string' ? rawBody : Buffer.isBuffer(rawBody) ? rawBody.toString() : String(rawBody));
|
|
390
|
-
const { cameraId, cameraName,
|
|
387
|
+
const { cameraId, cameraName, thumbnailId, timestamp, detectedClass, boundingBox, inputDimensions, label } = body;
|
|
391
388
|
|
|
392
389
|
if (!label || label === 'discard') return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
|
|
393
390
|
|
|
394
|
-
//
|
|
391
|
+
// Get image via thumbnail
|
|
395
392
|
let jpeg: Buffer | undefined;
|
|
396
393
|
try {
|
|
397
|
-
const cam = systemManager.getDeviceById(cameraId) as
|
|
398
|
-
const mo = await cam.
|
|
394
|
+
const cam = systemManager.getDeviceById(cameraId) as any;
|
|
395
|
+
const mo = await cam.getVideoClipThumbnail(thumbnailId);
|
|
399
396
|
jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
400
397
|
} catch (e) {
|
|
401
|
-
this.console.warn(`Could not get
|
|
398
|
+
this.console.warn(`Could not get thumbnail for browse event:`, e);
|
|
402
399
|
}
|
|
403
400
|
|
|
404
401
|
if (!jpeg) return response.send(JSON.stringify({ error: 'Could not retrieve image' }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
|
|
405
402
|
|
|
406
403
|
const id = `browse-${timestamp}-${Math.random().toString(36).slice(2, 6)}`;
|
|
404
|
+
// For clips we don't have per-detection bounding boxes — store full frame dimensions
|
|
407
405
|
const record: CaptureRecord = {
|
|
408
|
-
id, cameraId, cameraName, timestamp,
|
|
409
|
-
|
|
406
|
+
id, cameraId, cameraName, timestamp,
|
|
407
|
+
detectedClass: detectedClass || 'unknown',
|
|
408
|
+
score: 1,
|
|
409
|
+
boundingBox: boundingBox || [0, 0, inputDimensions?.[0] || 1920, inputDimensions?.[1] || 1080],
|
|
410
|
+
inputDimensions: inputDimensions || [1920, 1080],
|
|
411
|
+
reviewed: true, label,
|
|
410
412
|
};
|
|
411
413
|
this.captures.set(id, record);
|
|
412
414
|
this.saveImage(id, jpeg);
|
|
@@ -796,6 +798,22 @@ function openLightbox(r) {
|
|
|
796
798
|
document.addEventListener('keydown', lbKeyHandler);
|
|
797
799
|
}
|
|
798
800
|
|
|
801
|
+
function openLightboxImg(img, cameraName, timestamp) {
|
|
802
|
+
const lb = document.getElementById('lightbox');
|
|
803
|
+
const lbCanvas = document.getElementById('lightbox-canvas');
|
|
804
|
+
const maxW = window.innerWidth * 0.9;
|
|
805
|
+
const maxH = window.innerHeight * 0.8;
|
|
806
|
+
const scale = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1);
|
|
807
|
+
lbCanvas.width = Math.round(img.naturalWidth * scale);
|
|
808
|
+
lbCanvas.height = Math.round(img.naturalHeight * scale);
|
|
809
|
+
const ctx = lbCanvas.getContext('2d');
|
|
810
|
+
ctx.drawImage(img, 0, 0, lbCanvas.width, lbCanvas.height);
|
|
811
|
+
document.getElementById('lightbox-meta').textContent =
|
|
812
|
+
cameraName + ' · ' + new Date(timestamp).toLocaleString();
|
|
813
|
+
lb.classList.add('open');
|
|
814
|
+
document.addEventListener('keydown', lbKeyHandler);
|
|
815
|
+
}
|
|
816
|
+
|
|
799
817
|
function closeLightbox() {
|
|
800
818
|
document.getElementById('lightbox').classList.remove('open');
|
|
801
819
|
document.removeEventListener('keydown', lbKeyHandler);
|
|
@@ -836,75 +854,6 @@ async function initBrowse() {
|
|
|
836
854
|
}
|
|
837
855
|
}
|
|
838
856
|
|
|
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
857
|
// Store browse events for addEvent closure
|
|
909
858
|
let browseEvents = [];
|
|
910
859
|
|
|
@@ -981,12 +930,7 @@ async function loadBrowse() {
|
|
|
981
930
|
}
|
|
982
931
|
|
|
983
932
|
function loadBrowseImage(i, ev) {
|
|
984
|
-
|
|
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)
|
|
933
|
+
fetch(BASE + '/api/browse-img?cameraId=' + ev.cameraId + '&thumbnailId=' + encodeURIComponent(ev.thumbnailId))
|
|
990
934
|
.then(r => r.ok ? r.blob() : null)
|
|
991
935
|
.then(blob => {
|
|
992
936
|
if (!blob) return;
|
|
@@ -994,15 +938,27 @@ function loadBrowseImage(i, ev) {
|
|
|
994
938
|
const img = new Image();
|
|
995
939
|
img.onload = () => {
|
|
996
940
|
imgCache.set('browse-' + i, img);
|
|
997
|
-
// Draw on full canvas
|
|
998
941
|
const fullCanvas = document.getElementById('bcanvas-full-' + i);
|
|
999
942
|
const cropCanvas = document.getElementById('bcanvas-crop-' + i);
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
943
|
+
const iw = img.naturalWidth, ih = img.naturalHeight;
|
|
944
|
+
// No bounding box for clip thumbnails — just draw the full image
|
|
945
|
+
if (fullCanvas) {
|
|
946
|
+
const ctx = fullCanvas.getContext('2d');
|
|
947
|
+
const cw = fullCanvas.width, ch = fullCanvas.height;
|
|
948
|
+
const scale = Math.min(cw / iw, ch / ih);
|
|
949
|
+
const dw = iw * scale, dh = ih * scale;
|
|
950
|
+
ctx.fillStyle = '#111'; ctx.fillRect(0, 0, cw, ch);
|
|
951
|
+
ctx.drawImage(img, (cw-dw)/2, (ch-dh)/2, dw, dh);
|
|
952
|
+
// Label classes
|
|
953
|
+
const labels = (ev.detectionClasses || []).join(', ');
|
|
954
|
+
ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(0, ch-20, cw, 20);
|
|
955
|
+
ctx.fillStyle = '#f90'; ctx.font = 'bold 11px sans-serif';
|
|
956
|
+
ctx.fillText(labels, 4, ch-5);
|
|
957
|
+
fullCanvas.onclick = () => openLightboxImg(img, ev.cameraName, ev.timestamp);
|
|
958
|
+
}
|
|
959
|
+
// Hide crop panel — no bounding box available
|
|
960
|
+
const cropPanel = document.getElementById('bcanvas-crop-' + i)?.closest('.img-panel') as HTMLElement;
|
|
961
|
+
if (cropPanel) cropPanel.style.display = 'none';
|
|
1006
962
|
URL.revokeObjectURL(url);
|
|
1007
963
|
};
|
|
1008
964
|
img.src = url;
|
|
@@ -1016,8 +972,6 @@ async function addBrowseEvent(i, label) {
|
|
|
1016
972
|
if (el) { el.style.opacity = '0.4'; el.querySelectorAll('button').forEach(b => b.disabled = true); }
|
|
1017
973
|
|
|
1018
974
|
if (label !== 'discard') {
|
|
1019
|
-
const primary = (ev.detections || [])[0];
|
|
1020
|
-
if (!primary) return;
|
|
1021
975
|
try {
|
|
1022
976
|
const res = await fetch(BASE + '/api/add-event', {
|
|
1023
977
|
method: 'POST',
|
|
@@ -1025,12 +979,11 @@ async function addBrowseEvent(i, label) {
|
|
|
1025
979
|
body: JSON.stringify({
|
|
1026
980
|
cameraId: ev.cameraId,
|
|
1027
981
|
cameraName: ev.cameraName,
|
|
1028
|
-
|
|
982
|
+
thumbnailId: ev.thumbnailId,
|
|
1029
983
|
timestamp: ev.timestamp,
|
|
1030
|
-
detectedClass:
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
inputDimensions: ev.inputDimensions,
|
|
984
|
+
detectedClass: (ev.detectionClasses || [])[0] || 'unknown',
|
|
985
|
+
boundingBox: null,
|
|
986
|
+
inputDimensions: null,
|
|
1034
987
|
label,
|
|
1035
988
|
}),
|
|
1036
989
|
});
|