diff --git a/docs/cnn/index.html b/docs/cnn/index.html
index 256fcab90..e37ec25b7 100644
--- a/docs/cnn/index.html
+++ b/docs/cnn/index.html
@@ -706,6 +706,7 @@
+
@@ -1009,6 +1010,246 @@
+
+
@@ -3114,6 +3355,472 @@ async function gestureLoop() {
anomalyResult.innerHTML = `Found
${anomalyCount} potential anomaly${anomalyCount !== 1 ? 's' : ''} (red border)`;
}
+ // ==================== SELF-LEARNING DEMOS ====================
+
+ // Shared classifier state
+ const classifier = {
+ classes: new Map(), // className -> { embeddings: [], color: string }
+ colors: ['hsl(262, 83%, 58%)', 'hsl(142, 76%, 36%)', 'hsl(38, 92%, 50%)', 'hsl(0, 62%, 50%)', 'hsl(200, 70%, 50%)', 'hsl(280, 60%, 50%)'],
+ colorIndex: 0,
+ feedbackLog: [],
+ totalTests: 0,
+ correctTests: 0
+ };
+
+ // Helper: Extract embedding from image file
+ async function extractEmbeddingFromFile(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = e => {
+ const img = new Image();
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 224; canvas.height = 224;
+ canvas.getContext('2d').drawImage(img, 0, 0, 224, 224);
+ const imageData = canvas.getContext('2d').getImageData(0, 0, 224, 224);
+ const rgb = new Uint8Array(224 * 224 * 3);
+ for (let i = 0, j = 0; i < imageData.data.length; i += 4, j += 3) {
+ rgb[j] = imageData.data[i]; rgb[j+1] = imageData.data[i+1]; rgb[j+2] = imageData.data[i+2];
+ }
+ resolve({ embedding: embedder.extract(rgb, 224, 224), dataUrl: e.target.result });
+ };
+ img.onerror = reject;
+ img.src = e.target.result;
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+ }
+
+ // Helper: Classify using stored prototypes
+ function classifyEmbedding(embedding) {
+ if (classifier.classes.size === 0) return null;
+ const scores = [];
+ for (const [className, data] of classifier.classes) {
+ if (data.embeddings.length === 0) continue;
+ // Compute prototype (mean embedding)
+ const proto = computePrototype(data.embeddings);
+ const sim = embedder.cosine_similarity(embedding, proto);
+ scores.push({ className, similarity: sim, color: data.color, count: data.embeddings.length });
+ }
+ return scores.sort((a, b) => b.similarity - a.similarity);
+ }
+
+ function computePrototype(embeddings) {
+ const dim = embeddings[0].length;
+ const proto = new Float32Array(dim);
+ for (const emb of embeddings) {
+ for (let i = 0; i < dim; i++) proto[i] += emb[i];
+ }
+ for (let i = 0; i < dim; i++) proto[i] /= embeddings.length;
+ const norm = Math.sqrt(proto.reduce((s, v) => s + v*v, 0));
+ for (let i = 0; i < dim; i++) proto[i] /= norm;
+ return proto;
+ }
+
+ // Live Classifier
+ const addClassBtn = document.getElementById('add-class-btn');
+ const classNameInput = document.getElementById('class-name-input');
+ const classContainers = document.getElementById('class-containers');
+ const classifyDrop = document.getElementById('classify-drop');
+ const classifyInput = document.getElementById('classify-input');
+ const classifyResult = document.getElementById('classify-result');
+
+ if (addClassBtn) {
+ addClassBtn.onclick = () => {
+ const name = classNameInput.value.trim();
+ if (!name || classifier.classes.has(name)) return;
+ const color = classifier.colors[classifier.colorIndex++ % classifier.colors.length];
+ classifier.classes.set(name, { embeddings: [], color, thumbnails: [] });
+ renderClassContainers();
+ classNameInput.value = '';
+ updateMemoryBank();
+ };
+ }
+
+ function renderClassContainers() {
+ if (!classContainers) return;
+ classContainers.innerHTML = '';
+ for (const [name, data] of classifier.classes) {
+ const container = document.createElement('div');
+ container.style.cssText = `background: hsl(var(--secondary)); border: 2px solid ${data.color}; border-radius: 8px; padding: 0.5rem; min-width: 120px;`;
+ container.innerHTML = `
+
${name} (${data.embeddings.length})
+
+
+ `;
+ const thumbsDiv = container.querySelector('.class-thumbs');
+ const fileInput = container.querySelector('.class-file-input');
+
+ // Show thumbnails
+ data.thumbnails.slice(-6).forEach(thumb => {
+ const img = document.createElement('img');
+ img.src = thumb;
+ img.style.cssText = 'width: 24px; height: 24px; object-fit: cover; border-radius: 3px;';
+ thumbsDiv.appendChild(img);
+ });
+
+ // Drop zone behavior
+ container.ondragover = e => { e.preventDefault(); container.style.borderStyle = 'dashed'; };
+ container.ondragleave = () => { container.style.borderStyle = 'solid'; };
+ container.ondrop = async e => {
+ e.preventDefault();
+ container.style.borderStyle = 'solid';
+ for (const file of e.dataTransfer.files) {
+ if (!file.type.startsWith('image/')) continue;
+ const result = await extractEmbeddingFromFile(file);
+ data.embeddings.push(result.embedding);
+ data.thumbnails.push(result.dataUrl);
+ }
+ renderClassContainers();
+ updateMemoryBank();
+ };
+
+ container.onclick = () => fileInput.click();
+ fileInput.onchange = async e => {
+ for (const file of e.target.files) {
+ const result = await extractEmbeddingFromFile(file);
+ data.embeddings.push(result.embedding);
+ data.thumbnails.push(result.dataUrl);
+ }
+ renderClassContainers();
+ updateMemoryBank();
+ };
+
+ classContainers.appendChild(container);
+ }
+ }
+
+ // Classification drop zone
+ if (classifyDrop && classifyInput) {
+ classifyDrop.onclick = () => classifyInput.click();
+ classifyDrop.ondragover = e => { e.preventDefault(); classifyDrop.classList.add('drag-over'); };
+ classifyDrop.ondragleave = () => classifyDrop.classList.remove('drag-over');
+ classifyDrop.ondrop = async e => { e.preventDefault(); classifyDrop.classList.remove('drag-over'); await doClassify(e.dataTransfer.files[0]); };
+ classifyInput.onchange = async e => await doClassify(e.target.files[0]);
+ }
+
+ async function doClassify(file) {
+ if (!file || !embedder) return;
+ const result = await extractEmbeddingFromFile(file);
+ const scores = classifyEmbedding(result.embedding);
+
+ if (!scores || scores.length === 0) {
+ classifyResult.innerHTML = '
Add classes first!';
+ return;
+ }
+
+ const top = scores[0];
+ classifyResult.innerHTML = `
+
+

+
+
${top.className}
+
Confidence: ${(top.similarity * 100).toFixed(1)}%
+
+
+
+ ${scores.slice(0, 4).map(s => `${s.className}: ${(s.similarity * 100).toFixed(0)}%`).join('')}
+
+ `;
+
+ // Add to feedback log
+ classifier.totalTests++;
+ updateFeedbackStats();
+ }
+
+ // Few-Shot Learning
+ const fewshotClasses = { A: [], B: [], C: [] };
+ const fewshotEmbeddings = { A: [], B: [], C: [] };
+
+ ['a', 'b', 'c'].forEach(letter => {
+ const dropEl = document.getElementById(`fewshot-class-${letter}`);
+ const inputEl = document.getElementById(`fewshot-input-${letter}`);
+ const countEl = document.getElementById(`fewshot-count-${letter}`);
+ const classKey = letter.toUpperCase();
+
+ if (dropEl && inputEl) {
+ dropEl.onclick = () => inputEl.click();
+ dropEl.ondragover = e => { e.preventDefault(); dropEl.classList.add('drag-over'); };
+ dropEl.ondragleave = () => dropEl.classList.remove('drag-over');
+ dropEl.ondrop = async e => {
+ e.preventDefault();
+ dropEl.classList.remove('drag-over');
+ for (const file of e.dataTransfer.files) {
+ if (!file.type.startsWith('image/')) continue;
+ const result = await extractEmbeddingFromFile(file);
+ fewshotEmbeddings[classKey].push(result.embedding);
+ }
+ countEl.textContent = `${fewshotEmbeddings[classKey].length} examples`;
+ };
+ inputEl.onchange = async e => {
+ for (const file of e.target.files) {
+ const result = await extractEmbeddingFromFile(file);
+ fewshotEmbeddings[classKey].push(result.embedding);
+ }
+ countEl.textContent = `${fewshotEmbeddings[classKey].length} examples`;
+ };
+ }
+ });
+
+ const fewshotTest = document.getElementById('fewshot-test');
+ const fewshotTestInput = document.getElementById('fewshot-test-input');
+ const fewshotResult = document.getElementById('fewshot-result');
+
+ if (fewshotTest && fewshotTestInput) {
+ fewshotTest.onclick = () => fewshotTestInput.click();
+ fewshotTest.ondragover = e => { e.preventDefault(); fewshotTest.classList.add('drag-over'); };
+ fewshotTest.ondragleave = () => fewshotTest.classList.remove('drag-over');
+ fewshotTest.ondrop = async e => { e.preventDefault(); fewshotTest.classList.remove('drag-over'); await fewshotClassify(e.dataTransfer.files[0]); };
+ fewshotTestInput.onchange = async e => await fewshotClassify(e.target.files[0]);
+ }
+
+ async function fewshotClassify(file) {
+ if (!file || !embedder) return;
+ const result = await extractEmbeddingFromFile(file);
+ const scores = [];
+ const classColors = { A: 'hsl(262, 83%, 58%)', B: 'hsl(142, 76%, 36%)', C: 'hsl(38, 92%, 50%)' };
+
+ for (const [cls, embs] of Object.entries(fewshotEmbeddings)) {
+ if (embs.length === 0) continue;
+ const proto = computePrototype(embs);
+ const sim = embedder.cosine_similarity(result.embedding, proto);
+ scores.push({ cls, similarity: sim, color: classColors[cls] });
+ }
+
+ if (scores.length === 0) {
+ fewshotResult.innerHTML = '
Add examples to classes first!';
+ return;
+ }
+
+ scores.sort((a, b) => b.similarity - a.similarity);
+ fewshotResult.innerHTML = `
Class ${scores[0].cls} (${(scores[0].similarity * 100).toFixed(1)}%) ` +
+ scores.slice(1).map(s => `
${s.cls}: ${(s.similarity * 100).toFixed(0)}%`).join(' ');
+ }
+
+ // Incremental Learning
+ const incrementalModel = { positive: [], negative: [] };
+ const incPosCount = document.getElementById('inc-pos-count');
+ const incNegCount = document.getElementById('inc-neg-count');
+ const incrementalAdd = document.getElementById('incremental-add');
+ const incrementalInput = document.getElementById('incremental-input');
+ const incrementalClass = document.getElementById('incremental-class');
+ const incrementalTest = document.getElementById('incremental-test');
+ const incrementalTestInput = document.getElementById('incremental-test-input');
+ const incrementalResult = document.getElementById('incremental-result');
+ const incrementalReset = document.getElementById('incremental-reset');
+
+ if (incrementalAdd && incrementalInput) {
+ incrementalAdd.onclick = () => incrementalInput.click();
+ incrementalAdd.ondragover = e => { e.preventDefault(); incrementalAdd.classList.add('drag-over'); };
+ incrementalAdd.ondragleave = () => incrementalAdd.classList.remove('drag-over');
+ incrementalAdd.ondrop = async e => { e.preventDefault(); incrementalAdd.classList.remove('drag-over'); await addIncrementalExample(e.dataTransfer.files[0]); };
+ incrementalInput.onchange = async e => await addIncrementalExample(e.target.files[0]);
+ }
+
+ async function addIncrementalExample(file) {
+ if (!file || !embedder) return;
+ const cls = incrementalClass.value;
+ const result = await extractEmbeddingFromFile(file);
+ incrementalModel[cls].push(result.embedding);
+ incPosCount.textContent = incrementalModel.positive.length;
+ incNegCount.textContent = incrementalModel.negative.length;
+ }
+
+ if (incrementalTest && incrementalTestInput) {
+ incrementalTest.onclick = () => incrementalTestInput.click();
+ incrementalTest.ondragover = e => { e.preventDefault(); incrementalTest.classList.add('drag-over'); };
+ incrementalTest.ondragleave = () => incrementalTest.classList.remove('drag-over');
+ incrementalTest.ondrop = async e => { e.preventDefault(); incrementalTest.classList.remove('drag-over'); await testIncremental(e.dataTransfer.files[0]); };
+ incrementalTestInput.onchange = async e => await testIncremental(e.target.files[0]);
+ }
+
+ async function testIncremental(file) {
+ if (!file || !embedder) return;
+ const result = await extractEmbeddingFromFile(file);
+ let posSim = 0, negSim = 0;
+
+ if (incrementalModel.positive.length > 0) {
+ const posProto = computePrototype(incrementalModel.positive);
+ posSim = embedder.cosine_similarity(result.embedding, posProto);
+ }
+ if (incrementalModel.negative.length > 0) {
+ const negProto = computePrototype(incrementalModel.negative);
+ negSim = embedder.cosine_similarity(result.embedding, negProto);
+ }
+
+ const isPositive = posSim > negSim;
+ incrementalResult.innerHTML = `
+
+
${isPositive ? 'Positive (+)' : 'Negative (-)'}
+
+: ${(posSim * 100).toFixed(1)}% | -: ${(negSim * 100).toFixed(1)}%
+
`;
+ }
+
+ if (incrementalReset) {
+ incrementalReset.onclick = () => {
+ incrementalModel.positive = [];
+ incrementalModel.negative = [];
+ incPosCount.textContent = '0';
+ incNegCount.textContent = '0';
+ incrementalResult.innerHTML = '';
+ };
+ }
+
+ // Memory Bank
+ function updateMemoryBank() {
+ const grid = document.getElementById('memory-bank-grid');
+ const stats = document.getElementById('memory-stats');
+ if (!grid) return;
+
+ let totalEmbs = 0;
+ grid.innerHTML = '';
+
+ for (const [name, data] of classifier.classes) {
+ totalEmbs += data.embeddings.length;
+ data.thumbnails.slice(-12).forEach(thumb => {
+ const div = document.createElement('div');
+ div.style.cssText = `aspect-ratio: 1; border-radius: 4px; overflow: hidden; border: 2px solid ${data.color};`;
+ div.innerHTML = `

`;
+ div.title = name;
+ grid.appendChild(div);
+ });
+ }
+
+ if (totalEmbs === 0) {
+ grid.innerHTML = '
No memories yet.
';
+ }
+
+ const sizeKB = (totalEmbs * 512 * 4 / 1024).toFixed(1);
+ stats.textContent = `${classifier.classes.size} classes • ${totalEmbs} embeddings • ${sizeKB} KB`;
+ }
+
+ // Export/Import Memory
+ const exportMemoryBtn = document.getElementById('export-memory-btn');
+ const importMemoryBtn = document.getElementById('import-memory-btn');
+ const importMemoryInput = document.getElementById('import-memory-input');
+
+ if (exportMemoryBtn) {
+ exportMemoryBtn.onclick = () => {
+ const data = {};
+ for (const [name, d] of classifier.classes) {
+ data[name] = { embeddings: d.embeddings.map(e => Array.from(e)), color: d.color };
+ }
+ const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a'); a.href = url; a.download = 'classifier-memory.json'; a.click();
+ };
+ }
+
+ if (importMemoryBtn && importMemoryInput) {
+ importMemoryBtn.onclick = () => importMemoryInput.click();
+ importMemoryInput.onchange = async e => {
+ const file = e.target.files[0];
+ if (!file) return;
+ const text = await file.text();
+ const data = JSON.parse(text);
+ for (const [name, d] of Object.entries(data)) {
+ classifier.classes.set(name, {
+ embeddings: d.embeddings.map(e => new Float32Array(e)),
+ color: d.color,
+ thumbnails: []
+ });
+ }
+ renderClassContainers();
+ updateMemoryBank();
+ };
+ }
+
+ // Feedback Stats
+ function updateFeedbackStats() {
+ const totalEl = document.getElementById('feedback-total');
+ const correctEl = document.getElementById('feedback-correct');
+ const accuracyEl = document.getElementById('feedback-accuracy');
+ if (totalEl) totalEl.textContent = classifier.totalTests;
+ if (correctEl) correctEl.textContent = classifier.correctTests;
+ if (accuracyEl) accuracyEl.textContent = classifier.totalTests > 0 ? `${(classifier.correctTests / classifier.totalTests * 100).toFixed(0)}%` : '—';
+ }
+
+ // Camera Training
+ let trainingStream = null;
+ const trainingVideo = document.getElementById('training-video');
+ const startTrainingCamBtn = document.getElementById('start-training-cam-btn');
+ const stopTrainingCamBtn = document.getElementById('stop-training-cam-btn');
+ const captureTrainingBtn = document.getElementById('capture-training-btn');
+ const autoCaptureBtna = document.getElementById('auto-capture-btn');
+ const camClassInput = document.getElementById('cam-class-input');
+ const camTrainingLog = document.getElementById('cam-training-log');
+ const trainingClassBadge = document.getElementById('training-class-badge');
+
+ if (startTrainingCamBtn) {
+ startTrainingCamBtn.onclick = async () => {
+ try {
+ trainingStream = await navigator.mediaDevices.getUserMedia({ video: { width: 320, height: 240 } });
+ trainingVideo.srcObject = trainingStream;
+ startTrainingCamBtn.classList.add('hidden');
+ stopTrainingCamBtn.classList.remove('hidden');
+ captureTrainingBtn.disabled = false;
+ autoCaptureBtna.disabled = false;
+ } catch (e) { console.error(e); }
+ };
+ }
+
+ if (stopTrainingCamBtn) {
+ stopTrainingCamBtn.onclick = () => {
+ if (trainingStream) { trainingStream.getTracks().forEach(t => t.stop()); trainingStream = null; }
+ trainingVideo.srcObject = null;
+ startTrainingCamBtn.classList.remove('hidden');
+ stopTrainingCamBtn.classList.add('hidden');
+ captureTrainingBtn.disabled = true;
+ autoCaptureBtna.disabled = true;
+ };
+ }
+
+ if (captureTrainingBtn) {
+ captureTrainingBtn.onclick = () => captureTrainingFrame();
+ }
+
+ if (autoCaptureBtna) {
+ autoCaptureBtna.onclick = async () => {
+ for (let i = 0; i < 5; i++) {
+ await captureTrainingFrame();
+ await new Promise(r => setTimeout(r, 500));
+ }
+ };
+ }
+
+ async function captureTrainingFrame() {
+ if (!embedder || !trainingStream) return;
+ const className = camClassInput.value.trim() || 'default';
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 224; canvas.height = 224;
+ canvas.getContext('2d').drawImage(trainingVideo, 0, 0, 224, 224);
+ const imageData = canvas.getContext('2d').getImageData(0, 0, 224, 224);
+ const rgb = new Uint8Array(224 * 224 * 3);
+ for (let i = 0, j = 0; i < imageData.data.length; i += 4, j += 3) {
+ rgb[j] = imageData.data[i]; rgb[j+1] = imageData.data[i+1]; rgb[j+2] = imageData.data[i+2];
+ }
+ const embedding = embedder.extract(rgb, 224, 224);
+ const dataUrl = canvas.toDataURL();
+
+ if (!classifier.classes.has(className)) {
+ const color = classifier.colors[classifier.colorIndex++ % classifier.colors.length];
+ classifier.classes.set(className, { embeddings: [], color, thumbnails: [] });
+ }
+
+ const data = classifier.classes.get(className);
+ data.embeddings.push(embedding);
+ data.thumbnails.push(dataUrl);
+
+ trainingClassBadge.textContent = `${className} (${data.embeddings.length})`;
+ trainingClassBadge.style.background = data.color;
+
+ camTrainingLog.innerHTML = `
+ Added to "${className}" (${data.embeddings.length} total)
` + camTrainingLog.innerHTML;
+ renderClassContainers();
+ updateMemoryBank();
+ }
+
// Initialize similarity search when WASM loads
setTimeout(initSimilaritySearch, 2000);