Skip to content

Instantly share code, notes, and snippets.

@cebe
Created April 23, 2026 12:39
Show Gist options
  • Select an option

  • Save cebe/56aa667d92cbb9277e184e6058189367 to your computer and use it in GitHub Desktop.

Select an option

Save cebe/56aa667d92cbb9277e184e6058189367 to your computer and use it in GitHub Desktop.
Teachable Machine – MobileNet v3
<!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