scrypted-detection-trainer 0.1.6 → 0.1.8

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/plugin.zip CHANGED
Binary file
@@ -1620,7 +1620,13 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1620
1620
  .filter(d => d &&
1621
1621
  (d.type === sdk_1.ScryptedDeviceType.Camera || d.type === sdk_1.ScryptedDeviceType.Doorbell) &&
1622
1622
  d.interfaces?.includes(sdk_1.ScryptedInterface.ObjectDetector));
1623
- const uiUrl = await sdk_1.default.endpointManager.getLocalEndpoint(undefined, { public: true }).catch(() => '/endpoint/scrypted-detection-trainer/public/');
1623
+ let uiUrl;
1624
+ try {
1625
+ uiUrl = await sdk_1.default.endpointManager.getLocalEndpoint(undefined, { public: true });
1626
+ }
1627
+ catch {
1628
+ uiUrl = '/endpoint/scrypted-detection-trainer/public/';
1629
+ }
1624
1630
  const settings = [
1625
1631
  {
1626
1632
  key: 'info',
@@ -1781,6 +1787,16 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1781
1787
  .slice(0, 50);
1782
1788
  return response.send(JSON.stringify(pending), { headers: { 'Content-Type': 'application/json' } });
1783
1789
  }
1790
+ // API: get labeled captures
1791
+ if (path === '/api/labeled') {
1792
+ const page = parseInt(new URL(request.url, 'http://localhost').searchParams.get('page') || '0');
1793
+ const pageSize = 50;
1794
+ const labeled = [...this.captures.values()]
1795
+ .filter(c => c.reviewed)
1796
+ .sort((a, b) => b.timestamp - a.timestamp);
1797
+ const slice = labeled.slice(page * pageSize, (page + 1) * pageSize);
1798
+ return response.send(JSON.stringify({ items: slice, total: labeled.length, page, pageSize }), { headers: { 'Content-Type': 'application/json' } });
1799
+ }
1784
1800
  // API: stats
1785
1801
  if (path === '/api/stats') {
1786
1802
  const all = [...this.captures.values()];
@@ -1873,11 +1889,13 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1873
1889
  .badge.green { background: #0d2d0d; color: #4c4; }
1874
1890
 
1875
1891
  /* Detection card */
1876
- .detection { display: grid; grid-template-columns: 200px 1fr; gap: 0; border-bottom: 1px solid #222; }
1892
+ .detection { display: grid; grid-template-columns: 420px 1fr; gap: 0; border-bottom: 1px solid #222; }
1877
1893
  .detection:last-child { border-bottom: none; }
1878
- .detection-img { position: relative; background: #111; display: flex; align-items: center; justify-content: center; min-height: 150px; }
1879
- .detection-img img { width: 100%; height: 150px; object-fit: cover; display: block; }
1880
- .detection-class { position: absolute; top: 6px; left: 6px; background: rgba(0,0,0,0.7); color: #fff; font-size: 11px; padding: 2px 6px; border-radius: 4px; }
1894
+ .detection-imgs { display: flex; gap: 8px; padding: 10px; background: #111; align-items: center; }
1895
+ .img-panel { display: flex; flex-direction: column; align-items: center; gap: 4px; }
1896
+ .img-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: .5px; }
1897
+ .det-canvas { border-radius: 6px; display: block; }
1898
+ .det-class-badge { display: inline-block; background: #3d2a00; color: #f90; font-size: 11px; padding: 2px 8px; border-radius: 4px; }
1881
1899
  .detection-info { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
1882
1900
  .detection-meta { font-size: 12px; color: #888; display: flex; flex-wrap: wrap; gap: 10px; }
1883
1901
  .detection-meta strong { color: #ccc; }
@@ -1925,6 +1943,15 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1925
1943
  .breakdown-item { background: #222; border-radius: 8px; padding: 12px; }
1926
1944
  .breakdown-item .label { font-size: 12px; color: #888; margin-bottom: 4px; }
1927
1945
  .breakdown-item .value { font-size: 20px; font-weight: 600; color: #fff; }
1946
+
1947
+ /* Lightbox */
1948
+ .lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.92); z-index: 1000; align-items: center; justify-content: center; flex-direction: column; gap: 12px; }
1949
+ .lightbox.open { display: flex; }
1950
+ .lightbox canvas { max-width: 92vw; max-height: 82vh; border-radius: 8px; cursor: zoom-out; }
1951
+ .lightbox-meta { color: #aaa; font-size: 13px; text-align: center; }
1952
+ .lightbox-close { position: absolute; top: 16px; right: 20px; font-size: 28px; color: #888; cursor: pointer; line-height: 1; }
1953
+ .lightbox-close:hover { color: #fff; }
1954
+ .det-canvas { cursor: zoom-in; }
1928
1955
  </style>
1929
1956
  </head>
1930
1957
  <body>
@@ -1941,6 +1968,7 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1941
1968
  <div class="card">
1942
1969
  <div class="tab-bar">
1943
1970
  <div class="tab active" onclick="showTab('review')">Review</div>
1971
+ <div class="tab" onclick="showTab('labeled')">Labeled</div>
1944
1972
  <div class="tab" onclick="showTab('stats')">Stats</div>
1945
1973
  <div class="tab" onclick="showTab('export')">Export Dataset</div>
1946
1974
  </div>
@@ -1950,6 +1978,11 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1950
1978
  <div id="detections-list"></div>
1951
1979
  </div>
1952
1980
 
1981
+ <!-- Labeled tab -->
1982
+ <div class="tab-panel" id="tab-labeled">
1983
+ <div id="labeled-list"></div>
1984
+ </div>
1985
+
1953
1986
  <!-- Stats tab -->
1954
1987
  <div class="tab-panel" id="tab-stats">
1955
1988
  <div class="tab-content">
@@ -1980,6 +2013,12 @@ class DetectionTrainer extends sdk_1.ScryptedDeviceBase {
1980
2013
  </div>
1981
2014
  </div>
1982
2015
 
2016
+ <div class="lightbox" id="lightbox" onclick="closeLightbox()">
2017
+ <div class="lightbox-close" onclick="closeLightbox()">✕</div>
2018
+ <canvas id="lightbox-canvas"></canvas>
2019
+ <div class="lightbox-meta" id="lightbox-meta"></div>
2020
+ </div>
2021
+
1983
2022
  <div class="toast" id="toast"></div>
1984
2023
 
1985
2024
  <script>
@@ -1991,15 +2030,244 @@ function imgError(img) {
1991
2030
  img.parentElement.innerHTML = '<div style="padding:20px;color:#555;font-size:12px;text-align:center">No image</div>';
1992
2031
  }
1993
2032
 
2033
+ function drawDetection(img, r) {
2034
+ const [bx, by, bw, bh] = r.boundingBox;
2035
+ const [iw, ih] = r.inputDimensions;
2036
+
2037
+ // --- Full frame canvas with box overlay ---
2038
+ const fullCanvas = document.getElementById('canvas-full-' + r.id);
2039
+ if (fullCanvas) {
2040
+ const cw = fullCanvas.width, ch = fullCanvas.height;
2041
+ const scale = Math.min(cw / iw, ch / ih);
2042
+ const dw = iw * scale, dh = ih * scale;
2043
+ const ox = (cw - dw) / 2, oy = (ch - dh) / 2;
2044
+ const ctx = fullCanvas.getContext('2d');
2045
+ ctx.fillStyle = '#111';
2046
+ ctx.fillRect(0, 0, cw, ch);
2047
+ ctx.drawImage(img, ox, oy, dw, dh);
2048
+ // Draw bounding box
2049
+ const rx = ox + bx * scale, ry = oy + by * scale;
2050
+ const rw = bw * scale, rh = bh * scale;
2051
+ ctx.strokeStyle = '#f90';
2052
+ ctx.lineWidth = 2;
2053
+ ctx.strokeRect(rx, ry, rw, rh);
2054
+ // Label badge
2055
+ ctx.fillStyle = 'rgba(255,153,0,0.85)';
2056
+ ctx.fillRect(rx, ry - 18, rw, 18);
2057
+ ctx.fillStyle = '#000';
2058
+ ctx.font = 'bold 11px sans-serif';
2059
+ ctx.fillText(r.detectedClass + ' ' + Math.round(r.score * 100) + '%', rx + 3, ry - 4);
2060
+ }
2061
+
2062
+ // --- Crop canvas ---
2063
+ const cropCanvas = document.getElementById('canvas-crop-' + r.id);
2064
+ if (cropCanvas) {
2065
+ const cc = cropCanvas.width, ch2 = cropCanvas.height;
2066
+ const ctx2 = cropCanvas.getContext('2d');
2067
+ ctx2.fillStyle = '#111';
2068
+ ctx2.fillRect(0, 0, cc, ch2);
2069
+ // Add padding around the crop
2070
+ const pad = Math.min(bw, bh) * 0.15;
2071
+ const sx = Math.max(0, bx - pad), sy = Math.max(0, by - pad);
2072
+ const sw = Math.min(iw - sx, bw + pad * 2), sh = Math.min(ih - sy, bh + pad * 2);
2073
+ const scale2 = Math.min(cc / sw, ch2 / sh);
2074
+ const dw2 = sw * scale2, dh2 = sh * scale2;
2075
+ const ox2 = (cc - dw2) / 2, oy2 = (ch2 - dh2) / 2;
2076
+ ctx2.drawImage(img, sx, sy, sw, sh, ox2, oy2, dw2, dh2);
2077
+ // Thin box outline on crop
2078
+ ctx2.strokeStyle = '#f90';
2079
+ ctx2.lineWidth = 1.5;
2080
+ const rx2 = ox2 + pad * scale2, ry2 = oy2 + pad * scale2;
2081
+ ctx2.strokeRect(rx2, ry2, bw * scale2, bh * scale2);
2082
+ }
2083
+ }
2084
+
2085
+ // Cache loaded images so lightbox reuses them
2086
+ const imgCache = new Map();
2087
+
2088
+ function openLightbox(r) {
2089
+ const img = imgCache.get(r.id);
2090
+ if (!img) return;
2091
+
2092
+ const lb = document.getElementById('lightbox');
2093
+ const lbCanvas = document.getElementById('lightbox-canvas');
2094
+
2095
+ // Size canvas to image, capped at viewport
2096
+ const maxW = window.innerWidth * 0.9;
2097
+ const maxH = window.innerHeight * 0.8;
2098
+ const [iw, ih] = r.inputDimensions;
2099
+ const scale = Math.min(maxW / iw, maxH / ih, 1);
2100
+ lbCanvas.width = Math.round(iw * scale);
2101
+ lbCanvas.height = Math.round(ih * scale);
2102
+
2103
+ const ctx = lbCanvas.getContext('2d');
2104
+ ctx.drawImage(img, 0, 0, lbCanvas.width, lbCanvas.height);
2105
+
2106
+ // Draw all bounding boxes for this detection (primary + others in same event if available)
2107
+ const boxes = r.allDetections || [{ boundingBox: r.boundingBox, detectedClass: r.detectedClass, score: r.score }];
2108
+ const colors = ['#f90', '#4af', '#f44', '#4f4', '#f4f', '#ff4'];
2109
+ boxes.forEach((d, i) => {
2110
+ const [bx, by, bw, bh] = d.boundingBox;
2111
+ const color = colors[i % colors.length];
2112
+ const rx = bx * scale, ry = by * scale, rw = bw * scale, rh = bh * scale;
2113
+ ctx.strokeStyle = color;
2114
+ ctx.lineWidth = 2;
2115
+ ctx.strokeRect(rx, ry, rw, rh);
2116
+ const label = d.detectedClass + ' ' + Math.round((d.score || 0) * 100) + '%';
2117
+ const textW = ctx.measureText(label).width + 8;
2118
+ ctx.fillStyle = color;
2119
+ ctx.globalAlpha = 0.85;
2120
+ ctx.fillRect(rx, Math.max(0, ry - 20), textW, 20);
2121
+ ctx.globalAlpha = 1;
2122
+ ctx.fillStyle = '#000';
2123
+ ctx.font = 'bold 12px sans-serif';
2124
+ ctx.fillText(label, rx + 4, Math.max(14, ry - 4));
2125
+ });
2126
+
2127
+ document.getElementById('lightbox-meta').textContent =
2128
+ r.cameraName + ' · ' + new Date(r.timestamp).toLocaleString() + ' · ' + iw + '×' + ih;
2129
+ lb.classList.add('open');
2130
+ document.addEventListener('keydown', lbKeyHandler);
2131
+ }
2132
+
2133
+ function closeLightbox() {
2134
+ document.getElementById('lightbox').classList.remove('open');
2135
+ document.removeEventListener('keydown', lbKeyHandler);
2136
+ }
2137
+
2138
+ function lbKeyHandler(e) {
2139
+ if (e.key === 'Escape') closeLightbox();
2140
+ }
2141
+
1994
2142
  function showTab(name) {
2143
+ const names = ['review', 'labeled', 'stats', 'export'];
1995
2144
  document.querySelectorAll('.tab').forEach((t, i) => {
1996
- const names = ['review', 'stats', 'export'];
1997
2145
  t.classList.toggle('active', names[i] === name);
1998
2146
  });
1999
2147
  document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
2000
2148
  document.getElementById('tab-' + name).classList.add('active');
2001
2149
  if (name === 'stats') loadStats();
2002
2150
  if (name === 'export') loadExportInfo();
2151
+ if (name === 'labeled') loadLabeled(0);
2152
+ }
2153
+
2154
+ const LABEL_COLORS = { person:'#4d9', animal:'#d85', face:'#6be', vehicle:'#99d', plate:'#cc9', package:'#fc9', discard:'#a44' };
2155
+
2156
+ async function loadLabeled(page) {
2157
+ const list = document.getElementById('labeled-list');
2158
+ list.innerHTML = '<div class="empty"><div style="color:#888">Loading…</div></div>';
2159
+ try {
2160
+ const res = await fetch(BASE + '/api/labeled?page=' + page);
2161
+ const data = await res.json();
2162
+ const { items, total, pageSize } = data;
2163
+ const totalPages = Math.ceil(total / pageSize);
2164
+
2165
+ if (!items.length) {
2166
+ list.innerHTML = '<div class="empty"><div class="icon">🏷️</div><div>No labeled detections yet.</div></div>';
2167
+ return;
2168
+ }
2169
+
2170
+ const pagerHtml = totalPages > 1 ? \`
2171
+ <div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-top:1px solid #222;font-size:13px;color:#888;">
2172
+ \${page > 0 ? \`<button class="label-btn" onclick="loadLabeled(\${page-1})">← Prev</button>\` : ''}
2173
+ <span>Page \${page+1} of \${totalPages} · \${total} total</span>
2174
+ \${page < totalPages-1 ? \`<button class="label-btn" onclick="loadLabeled(\${page+1})">Next →</button>\` : ''}
2175
+ </div>\` : '';
2176
+
2177
+ list.innerHTML = items.map(r => {
2178
+ const date = new Date(r.timestamp).toLocaleString();
2179
+ const score = Math.round(r.score * 100);
2180
+ const labelColor = LABEL_COLORS[r.label] || '#aaa';
2181
+ return \`
2182
+ <div class="detection" id="ldet-\${r.id}">
2183
+ <div class="detection-imgs">
2184
+ <div class="img-panel">
2185
+ <div class="img-label">Full frame</div>
2186
+ <canvas id="lcanvas-full-\${r.id}" class="det-canvas" width="240" height="160"></canvas>
2187
+ </div>
2188
+ <div class="img-panel">
2189
+ <div class="img-label">Crop</div>
2190
+ <canvas id="lcanvas-crop-\${r.id}" class="det-canvas" width="160" height="160"></canvas>
2191
+ </div>
2192
+ </div>
2193
+ <div class="detection-info">
2194
+ <div class="detection-meta">
2195
+ <div><strong>\${r.cameraName}</strong></div>
2196
+ <div>\${date}</div>
2197
+ <div class="det-class-badge">\${r.detectedClass} \${score}%</div>
2198
+ <div style="display:inline-block;background:#1a1a1a;border:1px solid \${labelColor};color:\${labelColor};font-size:11px;padding:2px 8px;border-radius:4px;">✓ \${r.label}</div>
2199
+ </div>
2200
+ <div style="font-size:12px;color:#888;">Change label:</div>
2201
+ <div class="label-buttons">
2202
+ <button class="label-btn person" onclick="relabel('\${r.id}', 'person')">👤 Person</button>
2203
+ <button class="label-btn animal" onclick="relabel('\${r.id}', 'animal')">🐾 Animal</button>
2204
+ <button class="label-btn face" onclick="relabel('\${r.id}', 'face')">😀 Face</button>
2205
+ <button class="label-btn vehicle" onclick="relabel('\${r.id}', 'vehicle')">🚗 Vehicle</button>
2206
+ <button class="label-btn" onclick="relabel('\${r.id}', 'plate')">🔢 Plate</button>
2207
+ <button class="label-btn" onclick="relabel('\${r.id}', 'package')">📦 Package</button>
2208
+ <button class="label-btn discard" onclick="relabel('\${r.id}', 'discard')">🗑 Discard</button>
2209
+ </div>
2210
+ </div>
2211
+ </div>\`;
2212
+ }).join('') + pagerHtml;
2213
+
2214
+ // Draw bounding boxes
2215
+ for (const r of items) {
2216
+ const img = new Image();
2217
+ img.onload = () => {
2218
+ imgCache.set(r.id, img);
2219
+ // Reuse drawDetection with the labeled canvases
2220
+ const origFull = document.getElementById('canvas-full-' + r.id);
2221
+ const origCrop = document.getElementById('canvas-crop-' + r.id);
2222
+ // Temporarily point to labeled canvases
2223
+ const fakeFull = document.getElementById('lcanvas-full-' + r.id);
2224
+ const fakeCrop = document.getElementById('lcanvas-crop-' + r.id);
2225
+ if (fakeFull) fakeFull.id = 'canvas-full-' + r.id;
2226
+ if (fakeCrop) fakeCrop.id = 'canvas-crop-' + r.id;
2227
+ drawDetection(img, r);
2228
+ if (fakeFull) fakeFull.id = 'lcanvas-full-' + r.id;
2229
+ if (fakeCrop) fakeCrop.id = 'lcanvas-crop-' + r.id;
2230
+ if (fakeFull) fakeFull.onclick = () => openLightbox(r);
2231
+ if (fakeCrop) fakeCrop.onclick = () => openLightbox(r);
2232
+ };
2233
+ img.src = BASE + '/img/' + r.id;
2234
+ }
2235
+ } catch(e) {
2236
+ list.innerHTML = '<div class="empty"><div style="color:#a44">Error: ' + e.message + '</div></div>';
2237
+ }
2238
+ }
2239
+
2240
+ async function relabel(id, labelVal) {
2241
+ await fetch(BASE + '/api/label', {
2242
+ method: 'POST',
2243
+ headers: { 'Content-Type': 'application/json' },
2244
+ body: JSON.stringify({ id, label: labelVal }),
2245
+ });
2246
+ const el = document.getElementById('ldet-' + id);
2247
+ if (labelVal === 'discard') {
2248
+ if (el) el.remove();
2249
+ toast('Discarded', '#633');
2250
+ } else {
2251
+ // Update the label badge in place
2252
+ const badge = el && el.querySelector('[style*="✓"]');
2253
+ const labelColor = LABEL_COLORS[labelVal] || '#aaa';
2254
+ if (el) {
2255
+ const badges = el.querySelectorAll('.detection-meta > div');
2256
+ badges.forEach(b => { if (b.textContent.startsWith('✓')) b.remove(); });
2257
+ const meta = el.querySelector('.detection-meta');
2258
+ const newBadge = document.createElement('div');
2259
+ newBadge.style.cssText = \`display:inline-block;background:#1a1a1a;border:1px solid \${labelColor};color:\${labelColor};font-size:11px;padding:2px 8px;border-radius:4px;\`;
2260
+ newBadge.textContent = '✓ ' + labelVal;
2261
+ meta.appendChild(newBadge);
2262
+ }
2263
+ toast('Re-labeled: ' + labelVal, '#1a6');
2264
+ }
2265
+ // Refresh stats
2266
+ const statsRes = await fetch(BASE + '/api/stats');
2267
+ const stats = await statsRes.json();
2268
+ document.getElementById('stat-pending').textContent = stats.pending;
2269
+ document.getElementById('stat-labeled').textContent = stats.labeled;
2270
+ document.getElementById('stat-total').textContent = stats.total;
2003
2271
  }
2004
2272
 
2005
2273
  function toast(msg, color='#1a3') {
@@ -2030,17 +2298,25 @@ async function loadPending() {
2030
2298
  list.innerHTML = pending.map(r => {
2031
2299
  const date = new Date(r.timestamp).toLocaleString();
2032
2300
  const score = Math.round(r.score * 100);
2301
+ const bb = r.boundingBox;
2302
+ const dim = r.inputDimensions;
2033
2303
  return \`
2034
2304
  <div class="detection" id="det-\${r.id}">
2035
- <div class="detection-img">
2036
- <img src="\${BASE}/img/\${r.id}" alt="\${r.detectedClass}" loading="lazy" onerror="imgError(this)">
2037
- <div class="detection-class">\${r.detectedClass} \${score}%</div>
2305
+ <div class="detection-imgs">
2306
+ <div class="img-panel">
2307
+ <div class="img-label">Full frame</div>
2308
+ <canvas id="canvas-full-\${r.id}" class="det-canvas" width="240" height="160"></canvas>
2309
+ </div>
2310
+ <div class="img-panel">
2311
+ <div class="img-label">Crop</div>
2312
+ <canvas id="canvas-crop-\${r.id}" class="det-canvas" width="160" height="160"></canvas>
2313
+ </div>
2038
2314
  </div>
2039
2315
  <div class="detection-info">
2040
2316
  <div class="detection-meta">
2041
2317
  <div><strong>\${r.cameraName}</strong></div>
2042
2318
  <div>\${date}</div>
2043
- <div>Box: \${r.boundingBox.map(v => Math.round(v)).join(', ')}</div>
2319
+ <div class="det-class-badge">\${r.detectedClass} \${score}%</div>
2044
2320
  </div>
2045
2321
  <div style="font-size:12px;color:#888;">What is this actually?</div>
2046
2322
  <div class="label-buttons">
@@ -2055,7 +2331,25 @@ async function loadPending() {
2055
2331
  </div>
2056
2332
  </div>\`;
2057
2333
  }).join('');
2058
- } catch(e) {
2334
+
2335
+ // Draw bounding boxes and crops onto canvases after DOM is ready
2336
+ for (const r of pending) {
2337
+ const img = new Image();
2338
+ img.onload = () => {
2339
+ imgCache.set(r.id, img);
2340
+ drawDetection(img, r);
2341
+ // Wire up click to open lightbox
2342
+ const fullCanvas = document.getElementById('canvas-full-' + r.id);
2343
+ const cropCanvas = document.getElementById('canvas-crop-' + r.id);
2344
+ if (fullCanvas) fullCanvas.onclick = () => openLightbox(r);
2345
+ if (cropCanvas) cropCanvas.onclick = () => openLightbox(r);
2346
+ };
2347
+ img.onerror = () => {
2348
+ const c = document.getElementById('canvas-full-' + r.id);
2349
+ if (c) c.parentElement.innerHTML = '<div style="padding:10px;color:#555;font-size:11px">No image</div>';
2350
+ };
2351
+ img.src = BASE + '/img/' + r.id;
2352
+ } } catch(e) {
2059
2353
  console.error('loadPending error', e);
2060
2354
  const list = document.getElementById('detections-list');
2061
2355
  if (list) list.innerHTML = '<div class="empty"><div style="color:#a44">Error loading captures: ' + e.message + '</div></div>';