Created
April 23, 2026 12:39
-
-
Save cebe/56aa667d92cbb9277e184e6058189367 to your computer and use it in GitHub Desktop.
Teachable Machine – MobileNet v3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="de"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Teachable Machine – MobileNet v3</title> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.22.0/dist/tf.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/knn-classifier@1.2.5/dist/knn-classifier.min.js"></script> | |
| <style> | |
| :root { | |
| --bg: #0f1116; | |
| --panel: #161a22; | |
| --text: #e6e8ee; | |
| --muted: #8a90a0; | |
| --accent: #4da3ff; | |
| --good: #7dd87d; | |
| --warn: #ffb84d; | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 24px; | |
| } | |
| h1 { margin: 0 0 4px; font-weight: 500; font-size: 20px; } | |
| p.hint { margin: 0 0 16px; color: var(--muted); font-size: 13px; max-width: 720px; } | |
| .stage { | |
| background: var(--panel); | |
| border-radius: 12px; | |
| padding: 16px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.4); | |
| width: 100%; | |
| max-width: 960px; | |
| } | |
| .row { | |
| display: flex; | |
| gap: 16px; | |
| } | |
| .video-wrap { | |
| position: relative; | |
| flex: 0 0 480px; | |
| height: 360px; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| background: #000; | |
| } | |
| video { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| transform: scaleX(-1); | |
| } | |
| .pred-overlay { | |
| position: absolute; | |
| left: 0; right: 0; bottom: 0; | |
| background: linear-gradient(180deg, transparent, rgba(0,0,0,0.75)); | |
| padding: 14px 16px 12px; | |
| color: #fff; | |
| font-family: ui-monospace, monospace; | |
| font-size: 13px; | |
| } | |
| .pred-label { | |
| font-size: 18px; | |
| font-family: -apple-system, sans-serif; | |
| font-weight: 500; | |
| } | |
| .panel { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| .class { | |
| background: #1d222d; | |
| border: 1px solid #2a3040; | |
| border-radius: 8px; | |
| padding: 10px 12px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .class-row { | |
| display: flex; | |
| gap: 8px; | |
| align-items: center; | |
| } | |
| .class input[type=text] { | |
| flex: 1; | |
| background: transparent; | |
| border: none; | |
| color: var(--text); | |
| font-size: 14px; | |
| font-weight: 500; | |
| outline: none; | |
| } | |
| .count { | |
| font-family: ui-monospace, monospace; | |
| font-size: 11px; | |
| color: var(--muted); | |
| min-width: 60px; | |
| text-align: right; | |
| } | |
| .capture { | |
| background: #2a4a7a; | |
| color: var(--text); | |
| border: 1px solid #3a5a8a; | |
| padding: 6px 12px; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| user-select: none; | |
| } | |
| .capture:hover { background: #345890; } | |
| .capture.active { background: #3a7a4a; border-color: #4a8a5a; } | |
| .bar { | |
| height: 4px; | |
| background: #252b38; | |
| border-radius: 2px; | |
| overflow: hidden; | |
| } | |
| .bar-fill { | |
| height: 100%; | |
| background: var(--accent); | |
| width: 0%; | |
| transition: width 0.15s; | |
| } | |
| .controls { | |
| display: flex; | |
| gap: 8px; | |
| margin-top: 12px; | |
| } | |
| button.secondary { | |
| background: #242a36; | |
| color: var(--text); | |
| border: 1px solid #323846; | |
| padding: 8px 14px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| } | |
| button.secondary:hover { background: #2c3240; } | |
| .status { | |
| margin-top: 12px; | |
| font-family: ui-monospace, monospace; | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| .status.ready { color: var(--good); } | |
| .status.error { color: #ff6b6b; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Teachable Machine – MobileNet v3</h1> | |
| <p class="hint"> | |
| Halte ein Objekt in die Kamera und <strong>halte den "Aufnehmen"-Button gedrückt</strong>, um Beispiele zu sammeln (mind. 10 pro Klasse). Wiederhole für andere Klassen. Die Live-Vorhersage erscheint unten im Video. Embedding kommt vom MobileNet v3, Klassifikation per KNN — kein Backprop nötig. | |
| </p> | |
| <div class="stage"> | |
| <div class="row"> | |
| <div class="video-wrap"> | |
| <video id="webcam" autoplay playsinline muted></video> | |
| <div class="pred-overlay"> | |
| <div class="pred-label" id="pred">—</div> | |
| <div>Konfidenz: <span id="conf">—</span></div> | |
| </div> | |
| </div> | |
| <div class="panel" id="classes"></div> | |
| </div> | |
| <div class="controls"> | |
| <button class="secondary" id="reset">Alle Beispiele löschen</button> | |
| <button class="secondary" id="add-class">+ Klasse hinzufügen</button> | |
| </div> | |
| <div class="status" id="status">Initialisiere…</div> | |
| </div> | |
| <script> | |
| const MODEL_URL = 'https://tfhub.dev/google/tfjs-model/imagenet/mobilenet_v3_small_100_224/feature_vector/5/default/1'; | |
| const IMG_SIZE = 224; | |
| const statusEl = document.getElementById('status'); | |
| const video = document.getElementById('webcam'); | |
| const predEl = document.getElementById('pred'); | |
| const confEl = document.getElementById('conf'); | |
| const classesEl = document.getElementById('classes'); | |
| const colors = ['#4da3ff', '#ffb84d', '#7dd87d', '#e07dff', '#ff7d7d']; | |
| let classes = []; | |
| let capturingId = null; | |
| let knn = null; | |
| let model = null; | |
| function setStatus(msg, type = '') { | |
| statusEl.textContent = msg; | |
| statusEl.className = 'status' + (type ? ' ' + type : ''); | |
| } | |
| function addClass(label) { | |
| const id = classes.length; | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'class'; | |
| wrap.innerHTML = ` | |
| <div class="class-row"> | |
| <input type="text" value="${label}"> | |
| <span class="count">0 Beispiele</span> | |
| <button class="capture">Aufnehmen</button> | |
| </div> | |
| <div class="bar"><div class="bar-fill" style="background:${colors[id % colors.length]}"></div></div> | |
| `; | |
| classesEl.appendChild(wrap); | |
| const btn = wrap.querySelector('.capture'); | |
| const start = (e) => { e.preventDefault(); capturingId = id; btn.classList.add('active'); }; | |
| const stop = () => { capturingId = null; btn.classList.remove('active'); }; | |
| btn.addEventListener('mousedown', start); | |
| btn.addEventListener('touchstart', start); | |
| ['mouseup', 'mouseleave', 'touchend', 'touchcancel'].forEach(ev => | |
| btn.addEventListener(ev, stop) | |
| ); | |
| classes.push({ | |
| id, | |
| wrap, | |
| nameEl: wrap.querySelector('input'), | |
| countEl: wrap.querySelector('.count'), | |
| barEl: wrap.querySelector('.bar-fill'), | |
| count: 0, | |
| }); | |
| } | |
| function updateCount(id) { | |
| const c = classes[id]; | |
| c.countEl.textContent = `${c.count} Beispiele`; | |
| } | |
| function updateBars(confidences) { | |
| for (const c of classes) { | |
| const p = confidences[c.id] || 0; | |
| c.barEl.style.width = (p * 100) + '%'; | |
| } | |
| } | |
| function embed() { | |
| return tf.tidy(() => { | |
| const img = tf.browser.fromPixels(video); | |
| const [h, w] = img.shape; | |
| const size = Math.min(h, w); | |
| const top = ((h - size) / 2) | 0; | |
| const left = ((w - size) / 2) | 0; | |
| const cropped = img.slice([top, left, 0], [size, size, 3]); | |
| const resized = tf.image.resizeBilinear(cropped, [IMG_SIZE, IMG_SIZE]); | |
| const input = resized.toFloat().div(255.0).expandDims(0); | |
| const features = model.predict(input); | |
| return features.squeeze(); | |
| }); | |
| } | |
| async function captureLoop() { | |
| while (true) { | |
| if (capturingId !== null && model) { | |
| const emb = embed(); | |
| knn.addExample(emb, capturingId); | |
| emb.dispose(); | |
| classes[capturingId].count++; | |
| updateCount(capturingId); | |
| } | |
| await tf.nextFrame(); | |
| } | |
| } | |
| async function predictLoop() { | |
| while (true) { | |
| if (model && knn.getNumClasses() > 0 && capturingId === null) { | |
| const emb = embed(); | |
| try { | |
| const result = await knn.predictClass(emb); | |
| const name = classes[result.label]?.nameEl.value || `Klasse ${result.label}`; | |
| const conf = result.confidences[result.label]; | |
| predEl.textContent = name; | |
| confEl.textContent = (conf * 100).toFixed(1) + '%'; | |
| updateBars(result.confidences); | |
| } catch (e) { /* knn empty edge case */ } | |
| emb.dispose(); | |
| } else { | |
| updateBars({}); | |
| if (knn && knn.getNumClasses() === 0) { | |
| predEl.textContent = '—'; | |
| confEl.textContent = '—'; | |
| } | |
| } | |
| await tf.nextFrame(); | |
| } | |
| } | |
| async function initWebcam() { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: 'user', width: 640, height: 480 }, | |
| audio: false, | |
| }); | |
| video.srcObject = stream; | |
| await new Promise(r => video.onloadedmetadata = r); | |
| await video.play(); | |
| } | |
| async function init() { | |
| try { | |
| setStatus('Starte Webcam…'); | |
| await initWebcam(); | |
| setStatus('Lade MobileNet v3 (~5 MB)…'); | |
| model = await tf.loadGraphModel(MODEL_URL, { fromTFHub: true }); | |
| // Warm-up | |
| setStatus('Wärme Modell auf…'); | |
| tf.tidy(() => { | |
| const warm = tf.zeros([1, IMG_SIZE, IMG_SIZE, 3]); | |
| model.predict(warm).dispose(); | |
| }); | |
| knn = knnClassifier.create(); | |
| ['Klasse A', 'Klasse B', 'Klasse C'].forEach(addClass); | |
| setStatus(`Bereit. Backend: ${tf.getBackend()}`, 'ready'); | |
| captureLoop(); | |
| predictLoop(); | |
| } catch (err) { | |
| console.error(err); | |
| setStatus('Fehler: ' + err.message, 'error'); | |
| } | |
| } | |
| document.getElementById('reset').addEventListener('click', () => { | |
| if (knn) knn.clearAllClasses(); | |
| for (const c of classes) { c.count = 0; updateCount(c.id); } | |
| predEl.textContent = '—'; | |
| confEl.textContent = '—'; | |
| updateBars({}); | |
| }); | |
| document.getElementById('add-class').addEventListener('click', () => { | |
| addClass(`Klasse ${String.fromCharCode(65 + classes.length)}`); | |
| }); | |
| init(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment