scrypted-detection-trainer 0.1.9 → 0.1.11
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 +386 -3
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/main.ts +391 -3
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/out/main.nodejs.js
CHANGED
|
@@ -1675,6 +1675,13 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1675
1675
|
readonly: true,
|
|
1676
1676
|
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>`,
|
|
1677
1677
|
},
|
|
1678
|
+
{
|
|
1679
|
+
key: 'autoCapture',
|
|
1680
|
+
title: 'Auto-Capture',
|
|
1681
|
+
description: 'Automatically capture detections in the background. Disable to use manual browsing only.',
|
|
1682
|
+
type: 'boolean',
|
|
1683
|
+
value: (this.storage.getItem('autoCapture') ?? 'true'),
|
|
1684
|
+
},
|
|
1678
1685
|
];
|
|
1679
1686
|
for (const cam of cameras) {
|
|
1680
1687
|
const key = `rate:${cam.id}`;
|
|
@@ -1723,6 +1730,8 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1723
1730
|
}
|
|
1724
1731
|
// ── Detection Handler ─────────────────────────────────────────────────────
|
|
1725
1732
|
async onDetection(cameraId, cameraName, data, rateLimitMs) {
|
|
1733
|
+
if ((this.storage.getItem('autoCapture') ?? 'true') === 'false')
|
|
1734
|
+
return;
|
|
1726
1735
|
if (!data?.detections?.length || !data.inputDimensions)
|
|
1727
1736
|
return;
|
|
1728
1737
|
// Rate limit per camera
|
|
@@ -1784,6 +1793,23 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1784
1793
|
async onRequest(request, response) {
|
|
1785
1794
|
const url = new URL(request.url, 'http://localhost');
|
|
1786
1795
|
const path = url.pathname.replace(request.rootPath, '');
|
|
1796
|
+
// Serve browse event image via getVideoClipThumbnail
|
|
1797
|
+
if (path === '/api/browse-img') {
|
|
1798
|
+
const params = new URL(request.url, 'http://localhost').searchParams;
|
|
1799
|
+
const cameraId = params.get('cameraId')?.replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
1800
|
+
const thumbnailId = params.get('thumbnailId')?.replace(/[^a-zA-Z0-9_\-:.]/g, '');
|
|
1801
|
+
if (!cameraId || !thumbnailId)
|
|
1802
|
+
return response.send('Missing params', { code: 400 });
|
|
1803
|
+
try {
|
|
1804
|
+
const cam = systemManager.getDeviceById(cameraId);
|
|
1805
|
+
const mo = await cam.getVideoClipThumbnail(thumbnailId);
|
|
1806
|
+
const jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
1807
|
+
return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
|
|
1808
|
+
}
|
|
1809
|
+
catch (e) {
|
|
1810
|
+
return response.send('Image unavailable', { code: 404 });
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1787
1813
|
// Serve image
|
|
1788
1814
|
if (path.startsWith('/img/')) {
|
|
1789
1815
|
const id = path.slice(5).replace(/[^a-zA-Z0-9_\-]/g, ''); // sanitize
|
|
@@ -1810,6 +1836,84 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1810
1836
|
}
|
|
1811
1837
|
return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
|
|
1812
1838
|
}
|
|
1839
|
+
// API: list cameras for browse
|
|
1840
|
+
if (path === '/api/cameras') {
|
|
1841
|
+
const cameras = Object.keys(systemManager.getSystemState())
|
|
1842
|
+
.map(id => systemManager.getDeviceById(id))
|
|
1843
|
+
.filter(d => d &&
|
|
1844
|
+
(d.type === sdk_1.ScryptedDeviceType.Camera || d.type === sdk_1.ScryptedDeviceType.Doorbell) &&
|
|
1845
|
+
d.interfaces?.includes(sdk_1.ScryptedInterface.ObjectDetector))
|
|
1846
|
+
.map(d => ({ id: d.id, name: d.name }));
|
|
1847
|
+
return response.send(JSON.stringify(cameras), { headers: { 'Content-Type': 'application/json' } });
|
|
1848
|
+
}
|
|
1849
|
+
// API: browse recent events for a camera
|
|
1850
|
+
if (path === '/api/browse') {
|
|
1851
|
+
const params = new URL(request.url, 'http://localhost').searchParams;
|
|
1852
|
+
const cameraId = params.get('cameraId');
|
|
1853
|
+
const hours = parseInt(params.get('hours') || '24');
|
|
1854
|
+
if (!cameraId)
|
|
1855
|
+
return response.send('Missing cameraId', { code: 400 });
|
|
1856
|
+
try {
|
|
1857
|
+
const cam = systemManager.getDeviceById(cameraId);
|
|
1858
|
+
if (!cam)
|
|
1859
|
+
return response.send('Camera not found', { code: 404 });
|
|
1860
|
+
const endTime = Date.now();
|
|
1861
|
+
const startTime = endTime - hours * 3600 * 1000;
|
|
1862
|
+
const clips = await cam.getVideoClips({ startTime, endTime });
|
|
1863
|
+
const events = (clips || [])
|
|
1864
|
+
.filter((c) => c.detectionClasses?.length && c.thumbnailId)
|
|
1865
|
+
.slice(0, 100)
|
|
1866
|
+
.map((c) => ({
|
|
1867
|
+
clipId: c.id,
|
|
1868
|
+
thumbnailId: c.thumbnailId,
|
|
1869
|
+
timestamp: c.startTime,
|
|
1870
|
+
detectionClasses: c.detectionClasses || [],
|
|
1871
|
+
// bounding box not available at clip level — use full frame
|
|
1872
|
+
boundingBox: null,
|
|
1873
|
+
inputDimensions: null,
|
|
1874
|
+
cameraId,
|
|
1875
|
+
cameraName: cam.name,
|
|
1876
|
+
}));
|
|
1877
|
+
return response.send(JSON.stringify(events), { headers: { 'Content-Type': 'application/json' } });
|
|
1878
|
+
}
|
|
1879
|
+
catch (e) {
|
|
1880
|
+
return response.send(JSON.stringify({ error: e.message }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
// API: add a browsed event directly to dataset as labeled
|
|
1884
|
+
if (path === '/api/add-event' && request.body) {
|
|
1885
|
+
const rawBody = request.body;
|
|
1886
|
+
const body = JSON.parse(typeof rawBody === 'string' ? rawBody : Buffer.isBuffer(rawBody) ? rawBody.toString() : String(rawBody));
|
|
1887
|
+
const { cameraId, cameraName, thumbnailId, timestamp, detectedClass, boundingBox, inputDimensions, label } = body;
|
|
1888
|
+
if (!label || label === 'discard')
|
|
1889
|
+
return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
|
|
1890
|
+
// Get image via thumbnail
|
|
1891
|
+
let jpeg;
|
|
1892
|
+
try {
|
|
1893
|
+
const cam = systemManager.getDeviceById(cameraId);
|
|
1894
|
+
const mo = await cam.getVideoClipThumbnail(thumbnailId);
|
|
1895
|
+
jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
1896
|
+
}
|
|
1897
|
+
catch (e) {
|
|
1898
|
+
this.console.warn(`Could not get thumbnail for browse event:`, e);
|
|
1899
|
+
}
|
|
1900
|
+
if (!jpeg)
|
|
1901
|
+
return response.send(JSON.stringify({ error: 'Could not retrieve image' }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
|
|
1902
|
+
const id = `browse-${timestamp}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1903
|
+
// For clips we don't have per-detection bounding boxes — store full frame dimensions
|
|
1904
|
+
const record = {
|
|
1905
|
+
id, cameraId, cameraName, timestamp,
|
|
1906
|
+
detectedClass: detectedClass || 'unknown',
|
|
1907
|
+
score: 1,
|
|
1908
|
+
boundingBox: boundingBox || [0, 0, inputDimensions?.[0] || 1920, inputDimensions?.[1] || 1080],
|
|
1909
|
+
inputDimensions: inputDimensions || [1920, 1080],
|
|
1910
|
+
reviewed: true, label,
|
|
1911
|
+
};
|
|
1912
|
+
this.captures.set(id, record);
|
|
1913
|
+
this.saveImage(id, jpeg);
|
|
1914
|
+
this.saveCaptures();
|
|
1915
|
+
return response.send(JSON.stringify({ ok: true, id }), { headers: { 'Content-Type': 'application/json' } });
|
|
1916
|
+
}
|
|
1813
1917
|
// API: get pending captures
|
|
1814
1918
|
if (path === '/api/pending') {
|
|
1815
1919
|
const pending = [...this.captures.values()]
|
|
@@ -1999,6 +2103,7 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1999
2103
|
<div class="card">
|
|
2000
2104
|
<div class="tab-bar">
|
|
2001
2105
|
<div class="tab active" onclick="showTab('review')">Review</div>
|
|
2106
|
+
<div class="tab" onclick="showTab('browse')">Browse Events</div>
|
|
2002
2107
|
<div class="tab" onclick="showTab('labeled')">Labeled</div>
|
|
2003
2108
|
<div class="tab" onclick="showTab('stats')">Stats</div>
|
|
2004
2109
|
<div class="tab" onclick="showTab('export')">Export Dataset</div>
|
|
@@ -2009,12 +2114,30 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
2009
2114
|
<div id="detections-list"></div>
|
|
2010
2115
|
</div>
|
|
2011
2116
|
|
|
2117
|
+
<!-- Browse tab -->
|
|
2118
|
+
<div class="tab-panel" id="tab-browse">
|
|
2119
|
+
<div class="tab-content">
|
|
2120
|
+
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px;">
|
|
2121
|
+
<select id="browse-camera" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
|
|
2122
|
+
<option value="">Select camera…</option>
|
|
2123
|
+
</select>
|
|
2124
|
+
<select id="browse-hours" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
|
|
2125
|
+
<option value="1">Last 1 hour</option>
|
|
2126
|
+
<option value="6">Last 6 hours</option>
|
|
2127
|
+
<option value="24" selected>Last 24 hours</option>
|
|
2128
|
+
<option value="72">Last 3 days</option>
|
|
2129
|
+
</select>
|
|
2130
|
+
<button class="export-btn" onclick="loadBrowse()" style="padding:8px 16px;">Load Events</button>
|
|
2131
|
+
<span id="browse-status" style="font-size:13px;color:#888;"></span>
|
|
2132
|
+
</div>
|
|
2133
|
+
<div id="browse-list"></div>
|
|
2134
|
+
</div>
|
|
2135
|
+
</div>
|
|
2136
|
+
|
|
2012
2137
|
<!-- Labeled tab -->
|
|
2013
2138
|
<div class="tab-panel" id="tab-labeled">
|
|
2014
2139
|
<div id="labeled-list"></div>
|
|
2015
2140
|
</div>
|
|
2016
|
-
|
|
2017
|
-
<!-- Stats tab -->
|
|
2018
2141
|
<div class="tab-panel" id="tab-stats">
|
|
2019
2142
|
<div class="tab-content">
|
|
2020
2143
|
<p style="font-size:13px;color:#888;margin-bottom:16px;">Breakdown of captured and labeled detections.</p>
|
|
@@ -2161,6 +2284,22 @@ function openLightbox(r) {
|
|
|
2161
2284
|
document.addEventListener('keydown', lbKeyHandler);
|
|
2162
2285
|
}
|
|
2163
2286
|
|
|
2287
|
+
function openLightboxImg(img, cameraName, timestamp) {
|
|
2288
|
+
const lb = document.getElementById('lightbox');
|
|
2289
|
+
const lbCanvas = document.getElementById('lightbox-canvas');
|
|
2290
|
+
const maxW = window.innerWidth * 0.9;
|
|
2291
|
+
const maxH = window.innerHeight * 0.8;
|
|
2292
|
+
const scale = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1);
|
|
2293
|
+
lbCanvas.width = Math.round(img.naturalWidth * scale);
|
|
2294
|
+
lbCanvas.height = Math.round(img.naturalHeight * scale);
|
|
2295
|
+
const ctx = lbCanvas.getContext('2d');
|
|
2296
|
+
ctx.drawImage(img, 0, 0, lbCanvas.width, lbCanvas.height);
|
|
2297
|
+
document.getElementById('lightbox-meta').textContent =
|
|
2298
|
+
cameraName + ' · ' + new Date(timestamp).toLocaleString();
|
|
2299
|
+
lb.classList.add('open');
|
|
2300
|
+
document.addEventListener('keydown', lbKeyHandler);
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2164
2303
|
function closeLightbox() {
|
|
2165
2304
|
document.getElementById('lightbox').classList.remove('open');
|
|
2166
2305
|
document.removeEventListener('keydown', lbKeyHandler);
|
|
@@ -2171,7 +2310,7 @@ function lbKeyHandler(e) {
|
|
|
2171
2310
|
}
|
|
2172
2311
|
|
|
2173
2312
|
function showTab(name) {
|
|
2174
|
-
const names = ['review', 'labeled', 'stats', 'export'];
|
|
2313
|
+
const names = ['review', 'browse', 'labeled', 'stats', 'export'];
|
|
2175
2314
|
document.querySelectorAll('.tab').forEach((t, i) => {
|
|
2176
2315
|
t.classList.toggle('active', names[i] === name);
|
|
2177
2316
|
});
|
|
@@ -2180,6 +2319,250 @@ function showTab(name) {
|
|
|
2180
2319
|
if (name === 'stats') loadStats();
|
|
2181
2320
|
if (name === 'export') loadExportInfo();
|
|
2182
2321
|
if (name === 'labeled') loadLabeled(0);
|
|
2322
|
+
if (name === 'browse') initBrowse();
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
async function initBrowse() {
|
|
2326
|
+
const sel = document.getElementById('browse-camera');
|
|
2327
|
+
if (sel.options.length > 1) return; // already loaded
|
|
2328
|
+
try {
|
|
2329
|
+
const res = await fetch(BASE + '/api/cameras');
|
|
2330
|
+
const cameras = await res.json();
|
|
2331
|
+
for (const cam of cameras) {
|
|
2332
|
+
const opt = document.createElement('option');
|
|
2333
|
+
opt.value = cam.id;
|
|
2334
|
+
opt.textContent = cam.name;
|
|
2335
|
+
sel.appendChild(opt);
|
|
2336
|
+
}
|
|
2337
|
+
if (cameras.length === 1) sel.value = cameras[0].id;
|
|
2338
|
+
} catch(e) {
|
|
2339
|
+
document.getElementById('browse-status').textContent = 'Error loading cameras';
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
async function loadBrowse() {
|
|
2344
|
+
const cameraId = document.getElementById('browse-camera').value;
|
|
2345
|
+
const hours = document.getElementById('browse-hours').value;
|
|
2346
|
+
const status = document.getElementById('browse-status');
|
|
2347
|
+
const list = document.getElementById('browse-list');
|
|
2348
|
+
|
|
2349
|
+
if (!cameraId) { status.textContent = 'Select a camera first'; return; }
|
|
2350
|
+
|
|
2351
|
+
status.textContent = 'Loading…';
|
|
2352
|
+
list.innerHTML = '';
|
|
2353
|
+
|
|
2354
|
+
try {
|
|
2355
|
+
const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
|
|
2356
|
+
const events = await res.json();
|
|
2357
|
+
|
|
2358
|
+
if (events.error) { status.textContent = 'Error: ' + events.error; return; }
|
|
2359
|
+
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; }
|
|
2360
|
+
|
|
2361
|
+
status.textContent = events.length + ' events found';
|
|
2362
|
+
|
|
2363
|
+
list.innerHTML = events.map((ev, i) => {
|
|
2364
|
+
const date = new Date(ev.timestamp).toLocaleString();
|
|
2365
|
+
const dets = ev.detections || [];
|
|
2366
|
+
const primary = dets[0] || {};
|
|
2367
|
+
const score = Math.round((primary.score || 0) * 100);
|
|
2368
|
+
const allClasses = dets.map(d => d.className + ' ' + Math.round((d.score||0)*100) + '%').join(', ');
|
|
2369
|
+
return \`
|
|
2370
|
+
<div class="detection" id="bev-\${i}" style="opacity:1;transition:opacity .3s">
|
|
2371
|
+
<div class="detection-imgs">
|
|
2372
|
+
<div class="img-panel">
|
|
2373
|
+
<div class="img-label">Full frame</div>
|
|
2374
|
+
<canvas id="bcanvas-\${i}" class="det-canvas" width="240" height="160"></canvas>
|
|
2375
|
+
</div>
|
|
2376
|
+
<div class="img-panel" id="bcrop-panel-\${i}">
|
|
2377
|
+
<div class="img-label">Crop</div>
|
|
2378
|
+
<canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
|
|
2379
|
+
</div>
|
|
2380
|
+
</div>
|
|
2381
|
+
<div class="detection-info">
|
|
2382
|
+
<div class="detection-meta">
|
|
2383
|
+
<div><strong>\${ev.cameraName}</strong></div>
|
|
2384
|
+
<div>\${date}</div>
|
|
2385
|
+
<div class="det-class-badge">\${allClasses}</div>
|
|
2386
|
+
</div>
|
|
2387
|
+
<div style="font-size:12px;color:#888;">Add to dataset as:</div>
|
|
2388
|
+
<div class="label-buttons" id="blabels-\${i}">
|
|
2389
|
+
<button class="label-btn person" onclick="addEvent(\${i})('person')">👤 Person</button>
|
|
2390
|
+
<button class="label-btn animal" onclick="addEvent(\${i})('animal')">🐾 Animal</button>
|
|
2391
|
+
<button class="label-btn face" onclick="addEvent(\${i})('face')">😀 Face</button>
|
|
2392
|
+
<button class="label-btn vehicle" onclick="addEvent(\${i})('vehicle')">🚗 Vehicle</button>
|
|
2393
|
+
<button class="label-btn" onclick="addEvent(\${i})('plate')">🔢 Plate</button>
|
|
2394
|
+
<button class="label-btn" onclick="addEvent(\${i})('package')">📦 Package</button>
|
|
2395
|
+
<button class="label-btn discard" onclick="addEvent(\${i})('discard')">🗑 Skip</button>
|
|
2396
|
+
</div>
|
|
2397
|
+
</div>
|
|
2398
|
+
</div>\`;
|
|
2399
|
+
}).join('');
|
|
2400
|
+
|
|
2401
|
+
// Load images for each event
|
|
2402
|
+
for (let i = 0; i < events.length; i++) {
|
|
2403
|
+
const ev = events[i];
|
|
2404
|
+
loadBrowseImage(i, ev);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
} catch(e) {
|
|
2408
|
+
status.textContent = 'Error: ' + e.message;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// Store browse events for addEvent closure
|
|
2413
|
+
let browseEvents = [];
|
|
2414
|
+
|
|
2415
|
+
async function loadBrowse() {
|
|
2416
|
+
const cameraId = document.getElementById('browse-camera').value;
|
|
2417
|
+
const hours = document.getElementById('browse-hours').value;
|
|
2418
|
+
const status = document.getElementById('browse-status');
|
|
2419
|
+
const list = document.getElementById('browse-list');
|
|
2420
|
+
|
|
2421
|
+
if (!cameraId) { status.textContent = 'Select a camera first'; return; }
|
|
2422
|
+
|
|
2423
|
+
status.textContent = 'Loading…';
|
|
2424
|
+
list.innerHTML = '';
|
|
2425
|
+
browseEvents = [];
|
|
2426
|
+
|
|
2427
|
+
try {
|
|
2428
|
+
const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
|
|
2429
|
+
const events = await res.json();
|
|
2430
|
+
|
|
2431
|
+
if (events.error) { status.textContent = 'Error: ' + events.error; return; }
|
|
2432
|
+
if (!events.length) {
|
|
2433
|
+
status.textContent = 'No detection events found.';
|
|
2434
|
+
list.innerHTML = '<div class="empty"><div class="icon">🔍</div><div>No ObjectDetector events in this time range.</div></div>';
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
browseEvents = events;
|
|
2439
|
+
status.textContent = events.length + ' events';
|
|
2440
|
+
|
|
2441
|
+
list.innerHTML = events.map((ev, i) => {
|
|
2442
|
+
const date = new Date(ev.timestamp).toLocaleString();
|
|
2443
|
+
const dets = ev.detections || [];
|
|
2444
|
+
const allClasses = [...new Set(dets.map(d => d.className))].join(', ');
|
|
2445
|
+
return \`
|
|
2446
|
+
<div class="detection" id="bev-\${i}">
|
|
2447
|
+
<div class="detection-imgs">
|
|
2448
|
+
<div class="img-panel">
|
|
2449
|
+
<div class="img-label">Full frame</div>
|
|
2450
|
+
<canvas id="bcanvas-full-\${i}" class="det-canvas" width="240" height="160"></canvas>
|
|
2451
|
+
</div>
|
|
2452
|
+
<div class="img-panel">
|
|
2453
|
+
<div class="img-label">Crop</div>
|
|
2454
|
+
<canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
|
|
2455
|
+
</div>
|
|
2456
|
+
</div>
|
|
2457
|
+
<div class="detection-info">
|
|
2458
|
+
<div class="detection-meta">
|
|
2459
|
+
<div><strong>\${ev.cameraName}</strong></div>
|
|
2460
|
+
<div>\${date}</div>
|
|
2461
|
+
<div class="det-class-badge">\${allClasses}</div>
|
|
2462
|
+
</div>
|
|
2463
|
+
<div style="font-size:12px;color:#888;">Add to dataset as:</div>
|
|
2464
|
+
<div class="label-buttons">
|
|
2465
|
+
<button class="label-btn person" onclick="addBrowseEvent(\${i},'person')">👤 Person</button>
|
|
2466
|
+
<button class="label-btn animal" onclick="addBrowseEvent(\${i},'animal')">🐾 Animal</button>
|
|
2467
|
+
<button class="label-btn face" onclick="addBrowseEvent(\${i},'face')">😀 Face</button>
|
|
2468
|
+
<button class="label-btn vehicle" onclick="addBrowseEvent(\${i},'vehicle')">🚗 Vehicle</button>
|
|
2469
|
+
<button class="label-btn" onclick="addBrowseEvent(\${i},'plate')">🔢 Plate</button>
|
|
2470
|
+
<button class="label-btn" onclick="addBrowseEvent(\${i},'package')">📦 Package</button>
|
|
2471
|
+
<button class="label-btn discard" onclick="addBrowseEvent(\${i},'discard')">🗑 Skip</button>
|
|
2472
|
+
</div>
|
|
2473
|
+
</div>
|
|
2474
|
+
</div>\`;
|
|
2475
|
+
}).join('');
|
|
2476
|
+
|
|
2477
|
+
// Load thumbnails for each event
|
|
2478
|
+
for (let i = 0; i < events.length; i++) {
|
|
2479
|
+
loadBrowseImage(i, events[i]);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
} catch(e) {
|
|
2483
|
+
status.textContent = 'Error: ' + e.message;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
function loadBrowseImage(i, ev) {
|
|
2488
|
+
fetch(BASE + '/api/browse-img?cameraId=' + ev.cameraId + '&thumbnailId=' + encodeURIComponent(ev.thumbnailId))
|
|
2489
|
+
.then(r => r.ok ? r.blob() : null)
|
|
2490
|
+
.then(blob => {
|
|
2491
|
+
if (!blob) return;
|
|
2492
|
+
const url = URL.createObjectURL(blob);
|
|
2493
|
+
const img = new Image();
|
|
2494
|
+
img.onload = () => {
|
|
2495
|
+
imgCache.set('browse-' + i, img);
|
|
2496
|
+
const fullCanvas = document.getElementById('bcanvas-full-' + i);
|
|
2497
|
+
const cropCanvas = document.getElementById('bcanvas-crop-' + i);
|
|
2498
|
+
const iw = img.naturalWidth, ih = img.naturalHeight;
|
|
2499
|
+
// No bounding box for clip thumbnails — just draw the full image
|
|
2500
|
+
if (fullCanvas) {
|
|
2501
|
+
const ctx = fullCanvas.getContext('2d');
|
|
2502
|
+
const cw = fullCanvas.width, ch = fullCanvas.height;
|
|
2503
|
+
const scale = Math.min(cw / iw, ch / ih);
|
|
2504
|
+
const dw = iw * scale, dh = ih * scale;
|
|
2505
|
+
ctx.fillStyle = '#111'; ctx.fillRect(0, 0, cw, ch);
|
|
2506
|
+
ctx.drawImage(img, (cw-dw)/2, (ch-dh)/2, dw, dh);
|
|
2507
|
+
// Label classes
|
|
2508
|
+
const labels = (ev.detectionClasses || []).join(', ');
|
|
2509
|
+
ctx.fillStyle = 'rgba(0,0,0,0.6)'; ctx.fillRect(0, ch-20, cw, 20);
|
|
2510
|
+
ctx.fillStyle = '#f90'; ctx.font = 'bold 11px sans-serif';
|
|
2511
|
+
ctx.fillText(labels, 4, ch-5);
|
|
2512
|
+
fullCanvas.onclick = () => openLightboxImg(img, ev.cameraName, ev.timestamp);
|
|
2513
|
+
}
|
|
2514
|
+
// Hide crop panel — no bounding box available
|
|
2515
|
+
const cropPanel = document.getElementById('bcanvas-crop-' + i)?.closest('.img-panel') as HTMLElement;
|
|
2516
|
+
if (cropPanel) cropPanel.style.display = 'none';
|
|
2517
|
+
URL.revokeObjectURL(url);
|
|
2518
|
+
};
|
|
2519
|
+
img.src = url;
|
|
2520
|
+
}).catch(() => {});
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
async function addBrowseEvent(i, label) {
|
|
2524
|
+
const ev = browseEvents[i];
|
|
2525
|
+
if (!ev) return;
|
|
2526
|
+
const el = document.getElementById('bev-' + i);
|
|
2527
|
+
if (el) { el.style.opacity = '0.4'; el.querySelectorAll('button').forEach(b => b.disabled = true); }
|
|
2528
|
+
|
|
2529
|
+
if (label !== 'discard') {
|
|
2530
|
+
try {
|
|
2531
|
+
const res = await fetch(BASE + '/api/add-event', {
|
|
2532
|
+
method: 'POST',
|
|
2533
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2534
|
+
body: JSON.stringify({
|
|
2535
|
+
cameraId: ev.cameraId,
|
|
2536
|
+
cameraName: ev.cameraName,
|
|
2537
|
+
thumbnailId: ev.thumbnailId,
|
|
2538
|
+
timestamp: ev.timestamp,
|
|
2539
|
+
detectedClass: (ev.detectionClasses || [])[0] || 'unknown',
|
|
2540
|
+
boundingBox: null,
|
|
2541
|
+
inputDimensions: null,
|
|
2542
|
+
label,
|
|
2543
|
+
}),
|
|
2544
|
+
});
|
|
2545
|
+
const data = await res.json();
|
|
2546
|
+
if (data.error) { toast('Error: ' + data.error, '#633'); if (el) el.style.opacity = '1'; el?.querySelectorAll('button').forEach(b => b.disabled = false); return; }
|
|
2547
|
+
toast('Added: ' + label, '#1a6');
|
|
2548
|
+
} catch(e) {
|
|
2549
|
+
toast('Failed: ' + e.message, '#633');
|
|
2550
|
+
if (el) el.style.opacity = '1';
|
|
2551
|
+
el?.querySelectorAll('button').forEach(b => b.disabled = false);
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
} else {
|
|
2555
|
+
toast('Skipped', '#555');
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
// Remove from list after short delay
|
|
2559
|
+
setTimeout(() => { if (el) el.remove(); }, 400);
|
|
2560
|
+
|
|
2561
|
+
// Update stats
|
|
2562
|
+
const statsRes = await fetch(BASE + '/api/stats');
|
|
2563
|
+
const stats = await statsRes.json();
|
|
2564
|
+
document.getElementById('stat-labeled').textContent = stats.labeled;
|
|
2565
|
+
document.getElementById('stat-total').textContent = stats.total;
|
|
2183
2566
|
}
|
|
2184
2567
|
|
|
2185
2568
|
const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };
|