Spaces:
Running
Running
| <html lang="id"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>SKU Scanner</title> | |
| <!-- TensorFlow.js + TFLite WASM backend (correct CDN paths) --> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@4.22.0/dist/tf-core.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-cpu@4.22.0/dist/tf-backend-cpu.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-tflite@0.0.1-alpha.10/dist/tf-tflite.min.js"></script> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; | |
| background: #0f172a; color: #e2e8f0; min-height: 100dvh; display: flex; justify-content: center; } | |
| .container { max-width: 420px; width: 100%; padding: 16px; display: flex; flex-direction: column; gap: 12px; } | |
| h1 { font-size: 20px; text-align: center; color: #38bdf8; font-weight: 700; } | |
| .subtitle { text-align: center; font-size: 13px; color: #94a3b8; margin-top: -8px; } | |
| #status { text-align: center; font-size: 12px; padding: 4px 12px; border-radius: 20px; | |
| background: #1e293b; color: #94a3b8; min-height: 22px; transition: all .3s; } | |
| #status.loading { color: #fbbf24; } | |
| #status.ready { color: #4ade80; background: #14532d; } | |
| #status.error { color: #f87171; background: #451a1a; } | |
| .cam-wrap { position: relative; width: 100%; aspect-ratio: 1/1; background: #1e293b; | |
| border-radius: 12px; overflow: hidden; } | |
| .cam-wrap video, .cam-wrap canvas { position: absolute; top: 0; left: 0; | |
| width: 100%; height: 100%; object-fit: cover; } | |
| .cam-wrap canvas { display: none; } | |
| .overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; | |
| border: 3px dashed #38bdf8; border-radius: 12px; pointer-events: none; opacity: 0.5; } | |
| .placeholder { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); | |
| text-align: center; color: #64748b; pointer-events: none; } | |
| .controls { display: flex; gap: 8px; flex-wrap: wrap; } | |
| .controls button, #file-label { | |
| flex: 1; min-width: 90px; padding: 12px; border: none; border-radius: 8px; | |
| font-size: 14px; font-weight: 600; cursor: pointer; transition: all .15s; text-align: center; | |
| } | |
| #btn-cap { background: #2563eb; color: #fff; } | |
| #btn-cap:disabled { background: #334155; color: #64748b; cursor: wait; } | |
| #btn-pred { background: #16a34a; color: #fff; display: none; } | |
| #btn-retake { background: #475569; color: #fff; display: none; } | |
| #file-label { background: #334155; color: #cbd5e1; display: inline-block; } | |
| #file-input { display: none; } | |
| #result { display: none; background: #1e293b; border-radius: 12px; padding: 16px; } | |
| #result h3 { font-size: 14px; color: #94a3b8; margin-bottom: 8px; } | |
| .pred-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } | |
| .pred-row .label { flex: 1; font-size: 13px; font-weight: 500; } | |
| .pred-row .bar-wrap { flex: 2; height: 20px; background: #334155; border-radius: 4px; overflow: hidden; } | |
| .pred-row .bar { height: 100%; border-radius: 4px; transition: width .4s ease; } | |
| .pred-row .score { width: 42px; text-align: right; font-size: 12px; color: #94a3b8; } | |
| .rank-1 .bar { background: #38bdf8; } | |
| .rank-2 .bar { background: #60a5fa; } | |
| .rank-3 .bar { background: #818cf8; } | |
| .spinner { width: 20px; height: 20px; border: 2px solid #334155; border-top-color: #38bdf8; | |
| border-radius: 50%; animation: spin .6s linear infinite; margin: 20px auto; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); | |
| background: #ef4444; color: #fff; padding: 10px 20px; border-radius: 8px; | |
| font-size: 13px; display: none; z-index: 100; white-space: nowrap; } | |
| .time { text-align: center; font-size: 11px; color: #64748b; margin-top: -4px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>π· SKU Scanner</h1> | |
| <p class="subtitle" id="sub">Scan produk UMKM β 19 kategori</p> | |
| <div id="status">β³ Loading model...</div> | |
| <div class="cam-wrap" id="camWrap"> | |
| <video id="video" autoplay playsinline></video> | |
| <canvas id="canvas"></canvas> | |
| <div class="overlay"></div> | |
| <div class="placeholder" id="placeholder"> | |
| <svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.16a15.53 15.53 0 0 1-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z"/></svg> | |
| <div style="margin-top:8px">Aktifkan kamera</div> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <button id="btn-cap" disabled onclick="capture()">πΈ Ambil</button> | |
| <button id="btn-pred" onclick="predict()">π Prediksi</button> | |
| <button id="btn-retake" onclick="retake()">π Ulang</button> | |
| <label id="file-label" for="file-input">π Upload</label> | |
| <input type="file" id="file-input" accept="image/*" capture="environment"> | |
| </div> | |
| <div id="result"> | |
| <h3>π Hasil Prediksi</h3> | |
| <div id="preds"></div> | |
| <div class="time" id="infer-time"></div> | |
| </div> | |
| <div class="spinner" id="spinner" style="display:none"></div> | |
| </div> | |
| <div class="toast" id="toast"></div> | |
| <script> | |
| // ββ GLOBALS ββ | |
| let model = null; | |
| let stream = null; | |
| let captured = false; | |
| let classNames = []; | |
| let modelLoading = false; | |
| const video = document.getElementById('video'); | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const statusEl = document.getElementById('status'); | |
| const btnCap = document.getElementById('btn-cap'); | |
| const btnPred = document.getElementById('btn-pred'); | |
| const btnRetake = document.getElementById('btn-retake'); | |
| const resultDiv = document.getElementById('result'); | |
| const predsDiv = document.getElementById('preds'); | |
| const spinner = document.getElementById('spinner'); | |
| const timeEl = document.getElementById('infer-time'); | |
| const fileInput = document.getElementById('file-input'); | |
| const placeholder = document.getElementById('placeholder'); | |
| // ββ SET WASM PATH ββ | |
| function setWasmPath() { | |
| // The WASM files are in the same CDN directory as tf-tflite.min.js | |
| // jsDelivr resolves them automatically, but we set explicitly for safety | |
| const base = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-tflite@0.0.1-alpha.10/wasm/'; | |
| try { tflite.setWasmPath(base); } catch(e) {} | |
| } | |
| // ββ LOAD MODEL ββ | |
| async function loadModel() { | |
| modelLoading = true; | |
| statusEl.textContent = 'β³ Loading model (3.5 MB)...'; | |
| statusEl.className = 'loading'; | |
| try { | |
| model = await tflite.loadTFLiteModel('mobilenetv4_cbam_fp32.tflite'); | |
| statusEl.textContent = 'β Model siap'; | |
| statusEl.className = 'ready'; | |
| btnCap.disabled = false; | |
| modelLoading = false; | |
| } catch (e) { | |
| statusEl.textContent = 'β Gagal: ' + e.message; | |
| statusEl.className = 'error'; | |
| showToast('Model gagal dimuat: ' + e.message); | |
| modelLoading = false; | |
| throw e; | |
| } | |
| } | |
| // ββ LOAD CLASS NAMES ββ | |
| async function loadClassNames() { | |
| const res = await fetch('class_names.json'); | |
| classNames = await res.json(); | |
| } | |
| // ββ START CAMERA ββ | |
| async function startCam() { | |
| try { | |
| stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: 'environment', width: { ideal: 640 }, height: { ideal: 640 } }, | |
| audio: false, | |
| }); | |
| video.srcObject = stream; | |
| await video.play(); | |
| placeholder.style.display = 'none'; | |
| } catch (e) { | |
| placeholder.innerHTML = '<div style="color:#ef4444">β Kamera gak bisa diakses<br><small style="font-size:12px">Upload gambar aja</small></div>'; | |
| } | |
| } | |
| // ββ CAPTURE ββ | |
| function capture() { | |
| canvas.width = video.videoWidth || video.width || 480; | |
| canvas.height = video.videoHeight || video.height || 480; | |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); | |
| canvas.style.display = 'block'; | |
| video.style.display = 'none'; | |
| captured = true; | |
| btnCap.style.display = 'none'; | |
| btnPred.style.display = 'block'; | |
| btnRetake.style.display = 'block'; | |
| } | |
| // ββ RETAKE ββ | |
| function retake() { | |
| canvas.style.display = 'none'; | |
| video.style.display = 'block'; | |
| captured = false; | |
| btnPred.style.display = 'none'; | |
| btnRetake.style.display = 'none'; | |
| btnCap.style.display = 'block'; | |
| resultDiv.style.display = 'none'; | |
| } | |
| // ββ PREDICT ββ | |
| async function predict() { | |
| let imageData; | |
| if (captured) { | |
| imageData = canvas; | |
| } else if (fileInput.files.length > 0) { | |
| const img = await loadImageFile(fileInput.files[0]); | |
| canvas.width = img.naturalWidth; | |
| canvas.height = img.naturalHeight; | |
| ctx.drawImage(img, 0, 0); | |
| imageData = canvas; | |
| } else { | |
| showToast('Ambil foto dulu!'); | |
| return; | |
| } | |
| if (!model) { | |
| showToast('Model belum siap!'); | |
| return; | |
| } | |
| spinner.style.display = 'block'; | |
| predsDiv.innerHTML = ''; | |
| timeEl.textContent = ''; | |
| resultDiv.style.display = 'none'; | |
| await tf.nextFrame(); | |
| const t0 = performance.now(); | |
| try { | |
| // Preprocess: resize to 224x224, keep [0,255] (model handles normalization) | |
| const input = tf.tidy(() => { | |
| const img = tf.browser.fromPixels(imageData); | |
| const resized = tf.image.resizeBilinear(img, [224, 224]); | |
| return resized.expandDims(0).toFloat(); | |
| }); | |
| const output = model.predict(input); | |
| const scores = output.dataSync(); | |
| const elapsed = (performance.now() - t0).toFixed(0); | |
| input.dispose(); | |
| output.dispose(); | |
| spinner.style.display = 'none'; | |
| resultDiv.style.display = 'block'; | |
| if (!classNames.length) { | |
| // Fallback: generate class names | |
| for (let i = 0; i < scores.length; i++) classNames.push('class_' + i); | |
| } | |
| // Top 3 | |
| const top3 = Array.from(scores) | |
| .map((s, i) => ({ label: classNames[i] || 'class_' + i, score: s })) | |
| .sort((a, b) => b.score - a.score) | |
| .slice(0, 3); | |
| timeEl.textContent = `β± ${elapsed}ms inferensi (browser)`; | |
| const maxScore = top3[0].score; | |
| predsDiv.innerHTML = top3.map((p, i) => { | |
| const pct = Math.max((p.score / maxScore) * 100, 5); | |
| return `<div class="pred-row rank-${i+1}"> | |
| <span class="label">${p.label.replace(/-/g, ' ')}</span> | |
| <div class="bar-wrap"><div class="bar" style="width:${pct}%"></div></div> | |
| <span class="score">${(p.score * 100).toFixed(1)}%</span> | |
| </div>`; | |
| }).join(''); | |
| } catch (e) { | |
| spinner.style.display = 'none'; | |
| showToast('Prediksi gagal: ' + e.message); | |
| console.error(e); | |
| } | |
| } | |
| // ββ HELPERS ββ | |
| function loadImageFile(file) { | |
| return new Promise((resolve, reject) => { | |
| const r = new FileReader(); | |
| r.onload = () => { | |
| const img = new Image(); | |
| img.onload = () => resolve(img); | |
| img.onerror = reject; | |
| img.src = r.result; | |
| }; | |
| r.onerror = reject; | |
| r.readAsDataURL(file); | |
| }); | |
| } | |
| function showToast(msg) { | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; | |
| t.style.display = 'block'; | |
| setTimeout(() => t.style.display = 'none', 3000); | |
| } | |
| // ββ FILE UPLOAD ββ | |
| fileInput.addEventListener('change', async (e) => { | |
| if (e.target.files.length === 0) return; | |
| const img = await loadImageFile(e.target.files[0]); | |
| canvas.width = img.naturalWidth; | |
| canvas.height = img.naturalHeight; | |
| ctx.drawImage(img, 0, 0); | |
| canvas.style.display = 'block'; | |
| video.style.display = 'none'; | |
| placeholder.style.display = 'none'; | |
| captured = true; | |
| btnCap.style.display = 'none'; | |
| btnPred.style.display = 'block'; | |
| btnRetake.style.display = 'block'; | |
| predict(); | |
| }); | |
| // ββ INIT ββ | |
| (async function init() { | |
| try { | |
| setWasmPath(); | |
| await Promise.all([loadModel(), loadClassNames()]); | |
| startCam(); | |
| } catch (e) { | |
| console.error('Init error:', e); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |