sku-webcam-classifier / index.html
Anoderb's picture
Upload index.html with huggingface_hub
c1ca298 verified
Raw
History Blame Contribute Delete
12.4 kB
<!DOCTYPE html>
<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>