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/dist/plugin.zip
CHANGED
|
Binary file
|
package/out/main.nodejs.js
CHANGED
|
@@ -1549,8 +1549,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
1549
1549
|
return result;
|
|
1550
1550
|
};
|
|
1551
1551
|
})();
|
|
1552
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
1553
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
1554
|
+
};
|
|
1552
1555
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|
1553
1556
|
const sdk_1 = __importStar(__webpack_require__(/*! @scrypted/sdk */ "./node_modules/@scrypted/sdk/dist/src/index.js"));
|
|
1557
|
+
const fs_1 = __importDefault(__webpack_require__(/*! fs */ "fs"));
|
|
1558
|
+
const path_1 = __importDefault(__webpack_require__(/*! path */ "path"));
|
|
1554
1559
|
const { systemManager, deviceManager, mediaManager } = sdk_1.default;
|
|
1555
1560
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
1556
1561
|
const LABELS = ['person', 'animal', 'face', 'vehicle', 'plate', 'package', 'discard'];
|
|
@@ -1571,10 +1576,14 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1571
1576
|
this.lastCapture = new Map();
|
|
1572
1577
|
// Map<captureId, CaptureRecord>
|
|
1573
1578
|
this.captures = new Map();
|
|
1574
|
-
// Map<captureId, jpegBuffer>
|
|
1575
|
-
this.images = new Map();
|
|
1576
1579
|
// Active event listeners
|
|
1577
1580
|
this.listeners = [];
|
|
1581
|
+
// Use a stable directory inside the plugin's volume
|
|
1582
|
+
this.imgDir = path_1.default.join(process.env.SCRYPTED_PLUGIN_VOLUME || '/tmp', 'detection-trainer-images');
|
|
1583
|
+
try {
|
|
1584
|
+
fs_1.default.mkdirSync(this.imgDir, { recursive: true });
|
|
1585
|
+
}
|
|
1586
|
+
catch { }
|
|
1578
1587
|
this.loadState();
|
|
1579
1588
|
this.registerListeners();
|
|
1580
1589
|
}
|
|
@@ -1592,25 +1601,48 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1592
1601
|
catch (e) {
|
|
1593
1602
|
this.console.warn('Could not load captures from storage:', e);
|
|
1594
1603
|
}
|
|
1595
|
-
//
|
|
1604
|
+
// Clean up any old base64 image entries from previous versions
|
|
1596
1605
|
for (const [id] of this.captures) {
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1606
|
+
try {
|
|
1607
|
+
this.storage.removeItem(`img:${id}`);
|
|
1608
|
+
}
|
|
1609
|
+
catch { }
|
|
1600
1610
|
}
|
|
1601
1611
|
}
|
|
1602
1612
|
saveCaptures() {
|
|
1603
|
-
|
|
1613
|
+
try {
|
|
1614
|
+
this.storage.setItem('captures', JSON.stringify([...this.captures.values()]));
|
|
1615
|
+
}
|
|
1616
|
+
catch (e) {
|
|
1617
|
+
this.console.warn('Could not save captures:', e);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
imgPath(id) {
|
|
1621
|
+
return path_1.default.join(this.imgDir, `${id}.jpg`);
|
|
1604
1622
|
}
|
|
1605
1623
|
saveImage(id, buf) {
|
|
1606
|
-
|
|
1624
|
+
try {
|
|
1625
|
+
fs_1.default.writeFileSync(this.imgPath(id), buf);
|
|
1626
|
+
}
|
|
1627
|
+
catch (e) {
|
|
1628
|
+
this.console.warn(`Could not save image ${id}:`, e);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
loadImage(id) {
|
|
1632
|
+
try {
|
|
1633
|
+
const p = this.imgPath(id);
|
|
1634
|
+
if (fs_1.default.existsSync(p))
|
|
1635
|
+
return fs_1.default.readFileSync(p);
|
|
1636
|
+
}
|
|
1637
|
+
catch { }
|
|
1638
|
+
return undefined;
|
|
1607
1639
|
}
|
|
1608
1640
|
deleteCapture(id) {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1641
|
+
try {
|
|
1642
|
+
fs_1.default.unlinkSync(this.imgPath(id));
|
|
1643
|
+
}
|
|
1644
|
+
catch { }
|
|
1612
1645
|
this.captures.delete(id);
|
|
1613
|
-
this.images.delete(id);
|
|
1614
1646
|
this.saveCaptures();
|
|
1615
1647
|
}
|
|
1616
1648
|
// ── Settings ─────────────────────────────────────────────────────────────
|
|
@@ -1643,6 +1675,13 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1643
1675
|
readonly: true,
|
|
1644
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>`,
|
|
1645
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
|
+
},
|
|
1646
1685
|
];
|
|
1647
1686
|
for (const cam of cameras) {
|
|
1648
1687
|
const key = `rate:${cam.id}`;
|
|
@@ -1691,6 +1730,8 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1691
1730
|
}
|
|
1692
1731
|
// ── Detection Handler ─────────────────────────────────────────────────────
|
|
1693
1732
|
async onDetection(cameraId, cameraName, data, rateLimitMs) {
|
|
1733
|
+
if ((this.storage.getItem('autoCapture') ?? 'true') === 'false')
|
|
1734
|
+
return;
|
|
1694
1735
|
if (!data?.detections?.length || !data.inputDimensions)
|
|
1695
1736
|
return;
|
|
1696
1737
|
// Rate limit per camera
|
|
@@ -1744,7 +1785,6 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1744
1785
|
reviewed: false,
|
|
1745
1786
|
};
|
|
1746
1787
|
this.captures.set(id, record);
|
|
1747
|
-
this.images.set(id, jpeg);
|
|
1748
1788
|
this.saveImage(id, jpeg);
|
|
1749
1789
|
this.saveCaptures();
|
|
1750
1790
|
this.console.log(`Captured ${best.className} (${Math.round((best.score || 0) * 100)}%) from ${cameraName} [${this.captures.size} total]`);
|
|
@@ -1753,10 +1793,27 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1753
1793
|
async onRequest(request, response) {
|
|
1754
1794
|
const url = new URL(request.url, 'http://localhost');
|
|
1755
1795
|
const path = url.pathname.replace(request.rootPath, '');
|
|
1796
|
+
// Serve browse event image (fetch on demand from camera)
|
|
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 detectionId = params.get('detectionId')?.replace(/[^a-zA-Z0-9_\-]/g, '');
|
|
1801
|
+
if (!cameraId || !detectionId)
|
|
1802
|
+
return response.send('Missing params', { code: 400 });
|
|
1803
|
+
try {
|
|
1804
|
+
const cam = systemManager.getDeviceById(cameraId);
|
|
1805
|
+
const mo = await cam.getDetectionInput(detectionId);
|
|
1806
|
+
const jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
1807
|
+
return response.send(jpeg, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=60' } });
|
|
1808
|
+
}
|
|
1809
|
+
catch (e) {
|
|
1810
|
+
return response.send('Image unavailable', { code: 404 });
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1756
1813
|
// Serve image
|
|
1757
1814
|
if (path.startsWith('/img/')) {
|
|
1758
|
-
const id = path.slice(5);
|
|
1759
|
-
const img = this.
|
|
1815
|
+
const id = path.slice(5).replace(/[^a-zA-Z0-9_\-]/g, ''); // sanitize
|
|
1816
|
+
const img = this.loadImage(id);
|
|
1760
1817
|
if (!img)
|
|
1761
1818
|
return response.send('Not found', { code: 404 });
|
|
1762
1819
|
return response.send(img, { headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=3600' } });
|
|
@@ -1779,6 +1836,82 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1779
1836
|
}
|
|
1780
1837
|
return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
|
|
1781
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 events = await cam.getRecordedEvents({ startTime, endTime });
|
|
1863
|
+
// Filter to ObjectDetector events only and limit to 100
|
|
1864
|
+
const detectionEvents = (events || [])
|
|
1865
|
+
.filter((e) => e.details?.eventInterface === 'ObjectDetector' && e.data?.detections?.length)
|
|
1866
|
+
.slice(0, 100)
|
|
1867
|
+
.map((e) => ({
|
|
1868
|
+
detectionId: e.data?.detectionId,
|
|
1869
|
+
timestamp: e.details?.eventTime || e.data?.timestamp,
|
|
1870
|
+
detections: (e.data?.detections || []).map((d) => ({
|
|
1871
|
+
className: d.className,
|
|
1872
|
+
score: d.score,
|
|
1873
|
+
boundingBox: d.boundingBox,
|
|
1874
|
+
})),
|
|
1875
|
+
inputDimensions: e.data?.inputDimensions,
|
|
1876
|
+
cameraId,
|
|
1877
|
+
cameraName: cam.name,
|
|
1878
|
+
}))
|
|
1879
|
+
.filter((e) => e.detectionId && e.inputDimensions);
|
|
1880
|
+
return response.send(JSON.stringify(detectionEvents), { headers: { 'Content-Type': 'application/json' } });
|
|
1881
|
+
}
|
|
1882
|
+
catch (e) {
|
|
1883
|
+
return response.send(JSON.stringify({ error: e.message }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
// API: add a browsed event directly to dataset as labeled
|
|
1887
|
+
if (path === '/api/add-event' && request.body) {
|
|
1888
|
+
const rawBody = request.body;
|
|
1889
|
+
const body = JSON.parse(typeof rawBody === 'string' ? rawBody : Buffer.isBuffer(rawBody) ? rawBody.toString() : String(rawBody));
|
|
1890
|
+
const { cameraId, cameraName, detectionId, timestamp, detectedClass, score, boundingBox, inputDimensions, label } = body;
|
|
1891
|
+
if (!label || label === 'discard')
|
|
1892
|
+
return response.send(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } });
|
|
1893
|
+
// Try to get the image
|
|
1894
|
+
let jpeg;
|
|
1895
|
+
try {
|
|
1896
|
+
const cam = systemManager.getDeviceById(cameraId);
|
|
1897
|
+
const mo = await cam.getDetectionInput(detectionId);
|
|
1898
|
+
jpeg = await mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
1899
|
+
}
|
|
1900
|
+
catch (e) {
|
|
1901
|
+
this.console.warn(`Could not get image for browse event ${detectionId}:`, e);
|
|
1902
|
+
}
|
|
1903
|
+
if (!jpeg)
|
|
1904
|
+
return response.send(JSON.stringify({ error: 'Could not retrieve image' }), { headers: { 'Content-Type': 'application/json' }, code: 500 });
|
|
1905
|
+
const id = `browse-${timestamp}-${Math.random().toString(36).slice(2, 6)}`;
|
|
1906
|
+
const record = {
|
|
1907
|
+
id, cameraId, cameraName, timestamp, detectedClass, score,
|
|
1908
|
+
boundingBox, inputDimensions, detectionId, reviewed: true, label,
|
|
1909
|
+
};
|
|
1910
|
+
this.captures.set(id, record);
|
|
1911
|
+
this.saveImage(id, jpeg);
|
|
1912
|
+
this.saveCaptures();
|
|
1913
|
+
return response.send(JSON.stringify({ ok: true, id }), { headers: { 'Content-Type': 'application/json' } });
|
|
1914
|
+
}
|
|
1782
1915
|
// API: get pending captures
|
|
1783
1916
|
if (path === '/api/pending') {
|
|
1784
1917
|
const pending = [...this.captures.values()]
|
|
@@ -1827,7 +1960,7 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1827
1960
|
// Build a simple tarball-like structure as JSON for download
|
|
1828
1961
|
const dataset = [];
|
|
1829
1962
|
for (const record of labeled) {
|
|
1830
|
-
const img = this.
|
|
1963
|
+
const img = this.loadImage(record.id);
|
|
1831
1964
|
if (!img)
|
|
1832
1965
|
continue;
|
|
1833
1966
|
const fname = `${record.id}`;
|
|
@@ -1968,6 +2101,7 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1968
2101
|
<div class="card">
|
|
1969
2102
|
<div class="tab-bar">
|
|
1970
2103
|
<div class="tab active" onclick="showTab('review')">Review</div>
|
|
2104
|
+
<div class="tab" onclick="showTab('browse')">Browse Events</div>
|
|
1971
2105
|
<div class="tab" onclick="showTab('labeled')">Labeled</div>
|
|
1972
2106
|
<div class="tab" onclick="showTab('stats')">Stats</div>
|
|
1973
2107
|
<div class="tab" onclick="showTab('export')">Export Dataset</div>
|
|
@@ -1978,12 +2112,30 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
|
|
|
1978
2112
|
<div id="detections-list"></div>
|
|
1979
2113
|
</div>
|
|
1980
2114
|
|
|
2115
|
+
<!-- Browse tab -->
|
|
2116
|
+
<div class="tab-panel" id="tab-browse">
|
|
2117
|
+
<div class="tab-content">
|
|
2118
|
+
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px;">
|
|
2119
|
+
<select id="browse-camera" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
|
|
2120
|
+
<option value="">Select camera…</option>
|
|
2121
|
+
</select>
|
|
2122
|
+
<select id="browse-hours" style="padding:8px 12px;background:#222;border:1px solid #444;color:#fff;border-radius:6px;font-size:13px;">
|
|
2123
|
+
<option value="1">Last 1 hour</option>
|
|
2124
|
+
<option value="6">Last 6 hours</option>
|
|
2125
|
+
<option value="24" selected>Last 24 hours</option>
|
|
2126
|
+
<option value="72">Last 3 days</option>
|
|
2127
|
+
</select>
|
|
2128
|
+
<button class="export-btn" onclick="loadBrowse()" style="padding:8px 16px;">Load Events</button>
|
|
2129
|
+
<span id="browse-status" style="font-size:13px;color:#888;"></span>
|
|
2130
|
+
</div>
|
|
2131
|
+
<div id="browse-list"></div>
|
|
2132
|
+
</div>
|
|
2133
|
+
</div>
|
|
2134
|
+
|
|
1981
2135
|
<!-- Labeled tab -->
|
|
1982
2136
|
<div class="tab-panel" id="tab-labeled">
|
|
1983
2137
|
<div id="labeled-list"></div>
|
|
1984
2138
|
</div>
|
|
1985
|
-
|
|
1986
|
-
<!-- Stats tab -->
|
|
1987
2139
|
<div class="tab-panel" id="tab-stats">
|
|
1988
2140
|
<div class="tab-content">
|
|
1989
2141
|
<p style="font-size:13px;color:#888;margin-bottom:16px;">Breakdown of captured and labeled detections.</p>
|
|
@@ -2140,7 +2292,7 @@ function lbKeyHandler(e) {
|
|
|
2140
2292
|
}
|
|
2141
2293
|
|
|
2142
2294
|
function showTab(name) {
|
|
2143
|
-
const names = ['review', 'labeled', 'stats', 'export'];
|
|
2295
|
+
const names = ['review', 'browse', 'labeled', 'stats', 'export'];
|
|
2144
2296
|
document.querySelectorAll('.tab').forEach((t, i) => {
|
|
2145
2297
|
t.classList.toggle('active', names[i] === name);
|
|
2146
2298
|
});
|
|
@@ -2149,6 +2301,246 @@ function showTab(name) {
|
|
|
2149
2301
|
if (name === 'stats') loadStats();
|
|
2150
2302
|
if (name === 'export') loadExportInfo();
|
|
2151
2303
|
if (name === 'labeled') loadLabeled(0);
|
|
2304
|
+
if (name === 'browse') initBrowse();
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
async function initBrowse() {
|
|
2308
|
+
const sel = document.getElementById('browse-camera');
|
|
2309
|
+
if (sel.options.length > 1) return; // already loaded
|
|
2310
|
+
try {
|
|
2311
|
+
const res = await fetch(BASE + '/api/cameras');
|
|
2312
|
+
const cameras = await res.json();
|
|
2313
|
+
for (const cam of cameras) {
|
|
2314
|
+
const opt = document.createElement('option');
|
|
2315
|
+
opt.value = cam.id;
|
|
2316
|
+
opt.textContent = cam.name;
|
|
2317
|
+
sel.appendChild(opt);
|
|
2318
|
+
}
|
|
2319
|
+
if (cameras.length === 1) sel.value = cameras[0].id;
|
|
2320
|
+
} catch(e) {
|
|
2321
|
+
document.getElementById('browse-status').textContent = 'Error loading cameras';
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
async function loadBrowse() {
|
|
2326
|
+
const cameraId = document.getElementById('browse-camera').value;
|
|
2327
|
+
const hours = document.getElementById('browse-hours').value;
|
|
2328
|
+
const status = document.getElementById('browse-status');
|
|
2329
|
+
const list = document.getElementById('browse-list');
|
|
2330
|
+
|
|
2331
|
+
if (!cameraId) { status.textContent = 'Select a camera first'; return; }
|
|
2332
|
+
|
|
2333
|
+
status.textContent = 'Loading…';
|
|
2334
|
+
list.innerHTML = '';
|
|
2335
|
+
|
|
2336
|
+
try {
|
|
2337
|
+
const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
|
|
2338
|
+
const events = await res.json();
|
|
2339
|
+
|
|
2340
|
+
if (events.error) { status.textContent = 'Error: ' + events.error; return; }
|
|
2341
|
+
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; }
|
|
2342
|
+
|
|
2343
|
+
status.textContent = events.length + ' events found';
|
|
2344
|
+
|
|
2345
|
+
list.innerHTML = events.map((ev, i) => {
|
|
2346
|
+
const date = new Date(ev.timestamp).toLocaleString();
|
|
2347
|
+
const dets = ev.detections || [];
|
|
2348
|
+
const primary = dets[0] || {};
|
|
2349
|
+
const score = Math.round((primary.score || 0) * 100);
|
|
2350
|
+
const allClasses = dets.map(d => d.className + ' ' + Math.round((d.score||0)*100) + '%').join(', ');
|
|
2351
|
+
return \`
|
|
2352
|
+
<div class="detection" id="bev-\${i}" style="opacity:1;transition:opacity .3s">
|
|
2353
|
+
<div class="detection-imgs">
|
|
2354
|
+
<div class="img-panel">
|
|
2355
|
+
<div class="img-label">Full frame</div>
|
|
2356
|
+
<canvas id="bcanvas-\${i}" class="det-canvas" width="240" height="160"></canvas>
|
|
2357
|
+
</div>
|
|
2358
|
+
<div class="img-panel" id="bcrop-panel-\${i}">
|
|
2359
|
+
<div class="img-label">Crop</div>
|
|
2360
|
+
<canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
|
|
2361
|
+
</div>
|
|
2362
|
+
</div>
|
|
2363
|
+
<div class="detection-info">
|
|
2364
|
+
<div class="detection-meta">
|
|
2365
|
+
<div><strong>\${ev.cameraName}</strong></div>
|
|
2366
|
+
<div>\${date}</div>
|
|
2367
|
+
<div class="det-class-badge">\${allClasses}</div>
|
|
2368
|
+
</div>
|
|
2369
|
+
<div style="font-size:12px;color:#888;">Add to dataset as:</div>
|
|
2370
|
+
<div class="label-buttons" id="blabels-\${i}">
|
|
2371
|
+
<button class="label-btn person" onclick="addEvent(\${i})('person')">👤 Person</button>
|
|
2372
|
+
<button class="label-btn animal" onclick="addEvent(\${i})('animal')">🐾 Animal</button>
|
|
2373
|
+
<button class="label-btn face" onclick="addEvent(\${i})('face')">😀 Face</button>
|
|
2374
|
+
<button class="label-btn vehicle" onclick="addEvent(\${i})('vehicle')">🚗 Vehicle</button>
|
|
2375
|
+
<button class="label-btn" onclick="addEvent(\${i})('plate')">🔢 Plate</button>
|
|
2376
|
+
<button class="label-btn" onclick="addEvent(\${i})('package')">📦 Package</button>
|
|
2377
|
+
<button class="label-btn discard" onclick="addEvent(\${i})('discard')">🗑 Skip</button>
|
|
2378
|
+
</div>
|
|
2379
|
+
</div>
|
|
2380
|
+
</div>\`;
|
|
2381
|
+
}).join('');
|
|
2382
|
+
|
|
2383
|
+
// Load images for each event
|
|
2384
|
+
for (let i = 0; i < events.length; i++) {
|
|
2385
|
+
const ev = events[i];
|
|
2386
|
+
loadBrowseImage(i, ev);
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
} catch(e) {
|
|
2390
|
+
status.textContent = 'Error: ' + e.message;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// Store browse events for addEvent closure
|
|
2395
|
+
let browseEvents = [];
|
|
2396
|
+
|
|
2397
|
+
async function loadBrowse() {
|
|
2398
|
+
const cameraId = document.getElementById('browse-camera').value;
|
|
2399
|
+
const hours = document.getElementById('browse-hours').value;
|
|
2400
|
+
const status = document.getElementById('browse-status');
|
|
2401
|
+
const list = document.getElementById('browse-list');
|
|
2402
|
+
|
|
2403
|
+
if (!cameraId) { status.textContent = 'Select a camera first'; return; }
|
|
2404
|
+
|
|
2405
|
+
status.textContent = 'Loading…';
|
|
2406
|
+
list.innerHTML = '';
|
|
2407
|
+
browseEvents = [];
|
|
2408
|
+
|
|
2409
|
+
try {
|
|
2410
|
+
const res = await fetch(BASE + '/api/browse?cameraId=' + cameraId + '&hours=' + hours);
|
|
2411
|
+
const events = await res.json();
|
|
2412
|
+
|
|
2413
|
+
if (events.error) { status.textContent = 'Error: ' + events.error; return; }
|
|
2414
|
+
if (!events.length) {
|
|
2415
|
+
status.textContent = 'No detection events found.';
|
|
2416
|
+
list.innerHTML = '<div class="empty"><div class="icon">🔍</div><div>No ObjectDetector events in this time range.</div></div>';
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
browseEvents = events;
|
|
2421
|
+
status.textContent = events.length + ' events';
|
|
2422
|
+
|
|
2423
|
+
list.innerHTML = events.map((ev, i) => {
|
|
2424
|
+
const date = new Date(ev.timestamp).toLocaleString();
|
|
2425
|
+
const dets = ev.detections || [];
|
|
2426
|
+
const allClasses = [...new Set(dets.map(d => d.className))].join(', ');
|
|
2427
|
+
return \`
|
|
2428
|
+
<div class="detection" id="bev-\${i}">
|
|
2429
|
+
<div class="detection-imgs">
|
|
2430
|
+
<div class="img-panel">
|
|
2431
|
+
<div class="img-label">Full frame</div>
|
|
2432
|
+
<canvas id="bcanvas-full-\${i}" class="det-canvas" width="240" height="160"></canvas>
|
|
2433
|
+
</div>
|
|
2434
|
+
<div class="img-panel">
|
|
2435
|
+
<div class="img-label">Crop</div>
|
|
2436
|
+
<canvas id="bcanvas-crop-\${i}" class="det-canvas" width="160" height="160"></canvas>
|
|
2437
|
+
</div>
|
|
2438
|
+
</div>
|
|
2439
|
+
<div class="detection-info">
|
|
2440
|
+
<div class="detection-meta">
|
|
2441
|
+
<div><strong>\${ev.cameraName}</strong></div>
|
|
2442
|
+
<div>\${date}</div>
|
|
2443
|
+
<div class="det-class-badge">\${allClasses}</div>
|
|
2444
|
+
</div>
|
|
2445
|
+
<div style="font-size:12px;color:#888;">Add to dataset as:</div>
|
|
2446
|
+
<div class="label-buttons">
|
|
2447
|
+
<button class="label-btn person" onclick="addBrowseEvent(\${i},'person')">👤 Person</button>
|
|
2448
|
+
<button class="label-btn animal" onclick="addBrowseEvent(\${i},'animal')">🐾 Animal</button>
|
|
2449
|
+
<button class="label-btn face" onclick="addBrowseEvent(\${i},'face')">😀 Face</button>
|
|
2450
|
+
<button class="label-btn vehicle" onclick="addBrowseEvent(\${i},'vehicle')">🚗 Vehicle</button>
|
|
2451
|
+
<button class="label-btn" onclick="addBrowseEvent(\${i},'plate')">🔢 Plate</button>
|
|
2452
|
+
<button class="label-btn" onclick="addBrowseEvent(\${i},'package')">📦 Package</button>
|
|
2453
|
+
<button class="label-btn discard" onclick="addBrowseEvent(\${i},'discard')">🗑 Skip</button>
|
|
2454
|
+
</div>
|
|
2455
|
+
</div>
|
|
2456
|
+
</div>\`;
|
|
2457
|
+
}).join('');
|
|
2458
|
+
|
|
2459
|
+
// Load thumbnails for each event
|
|
2460
|
+
for (let i = 0; i < events.length; i++) {
|
|
2461
|
+
loadBrowseImage(i, events[i]);
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
} catch(e) {
|
|
2465
|
+
status.textContent = 'Error: ' + e.message;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
function loadBrowseImage(i, ev) {
|
|
2470
|
+
const primary = (ev.detections || [])[0];
|
|
2471
|
+
if (!primary?.boundingBox) return;
|
|
2472
|
+
// Request image via the img endpoint using detectionId as the key
|
|
2473
|
+
// We store a browse-prefixed image server-side only after adding — for preview
|
|
2474
|
+
// use a placeholder fetch to trigger server-side caching
|
|
2475
|
+
fetch(BASE + '/api/browse-img?cameraId=' + ev.cameraId + '&detectionId=' + ev.detectionId)
|
|
2476
|
+
.then(r => r.ok ? r.blob() : null)
|
|
2477
|
+
.then(blob => {
|
|
2478
|
+
if (!blob) return;
|
|
2479
|
+
const url = URL.createObjectURL(blob);
|
|
2480
|
+
const img = new Image();
|
|
2481
|
+
img.onload = () => {
|
|
2482
|
+
imgCache.set('browse-' + i, img);
|
|
2483
|
+
// Draw on full canvas
|
|
2484
|
+
const fullCanvas = document.getElementById('bcanvas-full-' + i);
|
|
2485
|
+
const cropCanvas = document.getElementById('bcanvas-crop-' + i);
|
|
2486
|
+
if (fullCanvas) fullCanvas.id = 'canvas-full-browse' + i;
|
|
2487
|
+
if (cropCanvas) cropCanvas.id = 'canvas-crop-browse' + i;
|
|
2488
|
+
const fakeR = { ...ev, id: 'browse' + i, boundingBox: primary.boundingBox, detectedClass: primary.className, score: primary.score };
|
|
2489
|
+
drawDetection(img, fakeR);
|
|
2490
|
+
if (fullCanvas) { fullCanvas.id = 'bcanvas-full-' + i; fullCanvas.onclick = () => openLightbox(fakeR); }
|
|
2491
|
+
if (cropCanvas) { cropCanvas.id = 'bcanvas-crop-' + i; cropCanvas.onclick = () => openLightbox(fakeR); }
|
|
2492
|
+
URL.revokeObjectURL(url);
|
|
2493
|
+
};
|
|
2494
|
+
img.src = url;
|
|
2495
|
+
}).catch(() => {});
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
async function addBrowseEvent(i, label) {
|
|
2499
|
+
const ev = browseEvents[i];
|
|
2500
|
+
if (!ev) return;
|
|
2501
|
+
const el = document.getElementById('bev-' + i);
|
|
2502
|
+
if (el) { el.style.opacity = '0.4'; el.querySelectorAll('button').forEach(b => b.disabled = true); }
|
|
2503
|
+
|
|
2504
|
+
if (label !== 'discard') {
|
|
2505
|
+
const primary = (ev.detections || [])[0];
|
|
2506
|
+
if (!primary) return;
|
|
2507
|
+
try {
|
|
2508
|
+
const res = await fetch(BASE + '/api/add-event', {
|
|
2509
|
+
method: 'POST',
|
|
2510
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2511
|
+
body: JSON.stringify({
|
|
2512
|
+
cameraId: ev.cameraId,
|
|
2513
|
+
cameraName: ev.cameraName,
|
|
2514
|
+
detectionId: ev.detectionId,
|
|
2515
|
+
timestamp: ev.timestamp,
|
|
2516
|
+
detectedClass: primary.className,
|
|
2517
|
+
score: primary.score,
|
|
2518
|
+
boundingBox: primary.boundingBox,
|
|
2519
|
+
inputDimensions: ev.inputDimensions,
|
|
2520
|
+
label,
|
|
2521
|
+
}),
|
|
2522
|
+
});
|
|
2523
|
+
const data = await res.json();
|
|
2524
|
+
if (data.error) { toast('Error: ' + data.error, '#633'); if (el) el.style.opacity = '1'; el?.querySelectorAll('button').forEach(b => b.disabled = false); return; }
|
|
2525
|
+
toast('Added: ' + label, '#1a6');
|
|
2526
|
+
} catch(e) {
|
|
2527
|
+
toast('Failed: ' + e.message, '#633');
|
|
2528
|
+
if (el) el.style.opacity = '1';
|
|
2529
|
+
el?.querySelectorAll('button').forEach(b => b.disabled = false);
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
} else {
|
|
2533
|
+
toast('Skipped', '#555');
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
// Remove from list after short delay
|
|
2537
|
+
setTimeout(() => { if (el) el.remove(); }, 400);
|
|
2538
|
+
|
|
2539
|
+
// Update stats
|
|
2540
|
+
const statsRes = await fetch(BASE + '/api/stats');
|
|
2541
|
+
const stats = await statsRes.json();
|
|
2542
|
+
document.getElementById('stat-labeled').textContent = stats.labeled;
|
|
2543
|
+
document.getElementById('stat-total').textContent = stats.total;
|
|
2152
2544
|
}
|
|
2153
2545
|
|
|
2154
2546
|
const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };
|
|
@@ -2449,6 +2841,17 @@ setInterval(loadPending, 30_000);
|
|
|
2449
2841
|
exports["default"] = DetectionTrainer;
|
|
2450
2842
|
|
|
2451
2843
|
|
|
2844
|
+
/***/ },
|
|
2845
|
+
|
|
2846
|
+
/***/ "fs"
|
|
2847
|
+
/*!*********************!*\
|
|
2848
|
+
!*** external "fs" ***!
|
|
2849
|
+
\*********************/
|
|
2850
|
+
(module) {
|
|
2851
|
+
|
|
2852
|
+
"use strict";
|
|
2853
|
+
module.exports = require("fs");
|
|
2854
|
+
|
|
2452
2855
|
/***/ },
|
|
2453
2856
|
|
|
2454
2857
|
/***/ "module"
|
|
@@ -2460,6 +2863,17 @@ exports["default"] = DetectionTrainer;
|
|
|
2460
2863
|
"use strict";
|
|
2461
2864
|
module.exports = require("module");
|
|
2462
2865
|
|
|
2866
|
+
/***/ },
|
|
2867
|
+
|
|
2868
|
+
/***/ "path"
|
|
2869
|
+
/*!***********************!*\
|
|
2870
|
+
!*** external "path" ***!
|
|
2871
|
+
\***********************/
|
|
2872
|
+
(module) {
|
|
2873
|
+
|
|
2874
|
+
"use strict";
|
|
2875
|
+
module.exports = require("path");
|
|
2876
|
+
|
|
2463
2877
|
/***/ }
|
|
2464
2878
|
|
|
2465
2879
|
/******/ });
|