Spaces:
Running
Running
Upload index.html with huggingface_hub
Browse files- index.html +40 -46
index.html
CHANGED
|
@@ -4,41 +4,35 @@
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 6 |
<title>SKU Scanner</title>
|
| 7 |
-
<!-- TensorFlow.js TFLite WASM backend -->
|
| 8 |
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@4.22.0/dist/tf-core.min.js"></script>
|
| 9 |
-
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-
|
| 10 |
-
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-tflite
|
|
|
|
| 11 |
<style>
|
| 12 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 13 |
body { font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
| 14 |
background: #0f172a; color: #e2e8f0; min-height: 100dvh; display: flex; justify-content: center; }
|
| 15 |
.container { max-width: 420px; width: 100%; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
| 16 |
-
|
| 17 |
-
/* Header */
|
| 18 |
h1 { font-size: 20px; text-align: center; color: #38bdf8; font-weight: 700; }
|
| 19 |
.subtitle { text-align: center; font-size: 13px; color: #94a3b8; margin-top: -8px; }
|
| 20 |
|
| 21 |
-
/* Status badge */
|
| 22 |
#status { text-align: center; font-size: 12px; padding: 4px 12px; border-radius: 20px;
|
| 23 |
-
background: #1e293b; color: #94a3b8;
|
| 24 |
-
min-height: 22px; transition: all .3s; }
|
| 25 |
#status.loading { color: #fbbf24; }
|
| 26 |
#status.ready { color: #4ade80; background: #14532d; }
|
| 27 |
#status.error { color: #f87171; background: #451a1a; }
|
| 28 |
|
| 29 |
-
/* Camera */
|
| 30 |
.cam-wrap { position: relative; width: 100%; aspect-ratio: 1/1; background: #1e293b;
|
| 31 |
border-radius: 12px; overflow: hidden; }
|
| 32 |
.cam-wrap video, .cam-wrap canvas { position: absolute; top: 0; left: 0;
|
| 33 |
width: 100%; height: 100%; object-fit: cover; }
|
| 34 |
.cam-wrap canvas { display: none; }
|
| 35 |
-
|
| 36 |
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
| 37 |
border: 3px dashed #38bdf8; border-radius: 12px; pointer-events: none; opacity: 0.5; }
|
| 38 |
.placeholder { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
| 39 |
text-align: center; color: #64748b; pointer-events: none; }
|
| 40 |
|
| 41 |
-
/* Controls */
|
| 42 |
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
|
| 43 |
.controls button, #file-label {
|
| 44 |
flex: 1; min-width: 90px; padding: 12px; border: none; border-radius: 8px;
|
|
@@ -46,14 +40,11 @@
|
|
| 46 |
}
|
| 47 |
#btn-cap { background: #2563eb; color: #fff; }
|
| 48 |
#btn-cap:disabled { background: #334155; color: #64748b; cursor: wait; }
|
| 49 |
-
#btn-cap:active { background: #1d4ed8; }
|
| 50 |
#btn-pred { background: #16a34a; color: #fff; display: none; }
|
| 51 |
-
#btn-pred:active { background: #15803d; }
|
| 52 |
#btn-retake { background: #475569; color: #fff; display: none; }
|
| 53 |
#file-label { background: #334155; color: #cbd5e1; display: inline-block; }
|
| 54 |
#file-input { display: none; }
|
| 55 |
|
| 56 |
-
/* Result */
|
| 57 |
#result { display: none; background: #1e293b; border-radius: 12px; padding: 16px; }
|
| 58 |
#result h3 { font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
|
| 59 |
.pred-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
@@ -72,7 +63,6 @@
|
|
| 72 |
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
| 73 |
background: #ef4444; color: #fff; padding: 10px 20px; border-radius: 8px;
|
| 74 |
font-size: 13px; display: none; z-index: 100; white-space: nowrap; }
|
| 75 |
-
|
| 76 |
.time { text-align: center; font-size: 11px; color: #64748b; margin-top: -4px; }
|
| 77 |
</style>
|
| 78 |
</head>
|
|
@@ -93,7 +83,7 @@
|
|
| 93 |
</div>
|
| 94 |
|
| 95 |
<div class="controls">
|
| 96 |
-
<button id="btn-cap" disabled>πΈ Ambil</button>
|
| 97 |
<button id="btn-pred" onclick="predict()">π Prediksi</button>
|
| 98 |
<button id="btn-retake" onclick="retake()">π Ulang</button>
|
| 99 |
<label id="file-label" for="file-input">π Upload</label>
|
|
@@ -115,6 +105,7 @@ let model = null;
|
|
| 115 |
let stream = null;
|
| 116 |
let captured = false;
|
| 117 |
let classNames = [];
|
|
|
|
| 118 |
|
| 119 |
const video = document.getElementById('video');
|
| 120 |
const canvas = document.getElementById('canvas');
|
|
@@ -131,24 +122,29 @@ const fileInput = document.getElementById('file-input');
|
|
| 131 |
const placeholder = document.getElementById('placeholder');
|
| 132 |
|
| 133 |
// ββ SET WASM PATH ββ
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
// ββ LOAD MODEL ββ
|
| 141 |
async function loadModel() {
|
|
|
|
|
|
|
|
|
|
| 142 |
try {
|
| 143 |
model = await tflite.loadTFLiteModel('mobilenetv4_cbam_quantized.tflite');
|
| 144 |
-
console.log('Model loaded:', model);
|
| 145 |
statusEl.textContent = 'β Model siap';
|
| 146 |
statusEl.className = 'ready';
|
| 147 |
btnCap.disabled = false;
|
|
|
|
| 148 |
} catch (e) {
|
| 149 |
-
statusEl.textContent = 'β Gagal
|
| 150 |
statusEl.className = 'error';
|
| 151 |
-
showToast('
|
|
|
|
| 152 |
throw e;
|
| 153 |
}
|
| 154 |
}
|
|
@@ -170,15 +166,14 @@ async function startCam() {
|
|
| 170 |
await video.play();
|
| 171 |
placeholder.style.display = 'none';
|
| 172 |
} catch (e) {
|
| 173 |
-
placeholder.innerHTML = '<div style="color:#ef4444">β Kamera gak bisa diakses<br><small style="font-size:12px">
|
| 174 |
-
btnCap.disabled = true;
|
| 175 |
}
|
| 176 |
}
|
| 177 |
|
| 178 |
// ββ CAPTURE ββ
|
| 179 |
function capture() {
|
| 180 |
-
canvas.width = video.videoWidth || 480;
|
| 181 |
-
canvas.height = video.videoHeight || 480;
|
| 182 |
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 183 |
canvas.style.display = 'block';
|
| 184 |
video.style.display = 'none';
|
|
@@ -206,7 +201,7 @@ async function predict() {
|
|
| 206 |
if (captured) {
|
| 207 |
imageData = canvas;
|
| 208 |
} else if (fileInput.files.length > 0) {
|
| 209 |
-
const img = await
|
| 210 |
canvas.width = img.naturalWidth;
|
| 211 |
canvas.height = img.naturalHeight;
|
| 212 |
ctx.drawImage(img, 0, 0);
|
|
@@ -221,41 +216,44 @@ async function predict() {
|
|
| 221 |
return;
|
| 222 |
}
|
| 223 |
|
| 224 |
-
// Show loading
|
| 225 |
spinner.style.display = 'block';
|
| 226 |
predsDiv.innerHTML = '';
|
| 227 |
timeEl.textContent = '';
|
|
|
|
| 228 |
|
| 229 |
-
// Run inference asynchronously (yield to render)
|
| 230 |
await tf.nextFrame();
|
| 231 |
-
|
| 232 |
const t0 = performance.now();
|
|
|
|
| 233 |
try {
|
| 234 |
-
//
|
| 235 |
const input = tf.tidy(() => {
|
| 236 |
const img = tf.browser.fromPixels(imageData);
|
| 237 |
const resized = tf.image.resizeBilinear(img, [224, 224]);
|
| 238 |
-
return resized.expandDims(0).toFloat();
|
| 239 |
});
|
| 240 |
|
| 241 |
const output = model.predict(input);
|
| 242 |
const scores = output.dataSync();
|
| 243 |
-
const
|
| 244 |
|
| 245 |
-
// Cleanup tensors
|
| 246 |
input.dispose();
|
| 247 |
output.dispose();
|
| 248 |
|
| 249 |
spinner.style.display = 'none';
|
| 250 |
resultDiv.style.display = 'block';
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
// Top 3
|
| 253 |
const top3 = Array.from(scores)
|
| 254 |
.map((s, i) => ({ label: classNames[i] || 'class_' + i, score: s }))
|
| 255 |
.sort((a, b) => b.score - a.score)
|
| 256 |
.slice(0, 3);
|
| 257 |
|
| 258 |
-
timeEl.textContent = `β± ${
|
| 259 |
|
| 260 |
const maxScore = top3[0].score;
|
| 261 |
predsDiv.innerHTML = top3.map((p, i) => {
|
|
@@ -275,7 +273,7 @@ async function predict() {
|
|
| 275 |
}
|
| 276 |
|
| 277 |
// ββ HELPERS ββ
|
| 278 |
-
function
|
| 279 |
return new Promise((resolve, reject) => {
|
| 280 |
const r = new FileReader();
|
| 281 |
r.onload = () => {
|
|
@@ -296,10 +294,10 @@ function showToast(msg) {
|
|
| 296 |
setTimeout(() => t.style.display = 'none', 3000);
|
| 297 |
}
|
| 298 |
|
| 299 |
-
// ββ
|
| 300 |
fileInput.addEventListener('change', async (e) => {
|
| 301 |
if (e.target.files.length === 0) return;
|
| 302 |
-
const img = await
|
| 303 |
canvas.width = img.naturalWidth;
|
| 304 |
canvas.height = img.naturalHeight;
|
| 305 |
ctx.drawImage(img, 0, 0);
|
|
@@ -313,16 +311,12 @@ fileInput.addEventListener('change', async (e) => {
|
|
| 313 |
predict();
|
| 314 |
});
|
| 315 |
|
| 316 |
-
// Use capture function from button
|
| 317 |
-
btnCap.addEventListener('click', capture);
|
| 318 |
-
|
| 319 |
// ββ INIT ββ
|
| 320 |
(async function init() {
|
| 321 |
-
statusEl.textContent = 'β³ Initializing TFJS...';
|
| 322 |
try {
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
} catch (e) {
|
| 327 |
console.error('Init error:', e);
|
| 328 |
}
|
|
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| 6 |
<title>SKU Scanner</title>
|
| 7 |
+
<!-- TensorFlow.js + TFLite WASM backend (correct CDN paths) -->
|
| 8 |
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core@4.22.0/dist/tf-core.min.js"></script>
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-cpu@4.22.0/dist/tf-backend-cpu.min.js"></script>
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-tflite/dist/tf-tflite.min.js"></script>
|
| 11 |
+
|
| 12 |
<style>
|
| 13 |
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 14 |
body { font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
| 15 |
background: #0f172a; color: #e2e8f0; min-height: 100dvh; display: flex; justify-content: center; }
|
| 16 |
.container { max-width: 420px; width: 100%; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
|
|
|
|
|
| 17 |
h1 { font-size: 20px; text-align: center; color: #38bdf8; font-weight: 700; }
|
| 18 |
.subtitle { text-align: center; font-size: 13px; color: #94a3b8; margin-top: -8px; }
|
| 19 |
|
|
|
|
| 20 |
#status { text-align: center; font-size: 12px; padding: 4px 12px; border-radius: 20px;
|
| 21 |
+
background: #1e293b; color: #94a3b8; min-height: 22px; transition: all .3s; }
|
|
|
|
| 22 |
#status.loading { color: #fbbf24; }
|
| 23 |
#status.ready { color: #4ade80; background: #14532d; }
|
| 24 |
#status.error { color: #f87171; background: #451a1a; }
|
| 25 |
|
|
|
|
| 26 |
.cam-wrap { position: relative; width: 100%; aspect-ratio: 1/1; background: #1e293b;
|
| 27 |
border-radius: 12px; overflow: hidden; }
|
| 28 |
.cam-wrap video, .cam-wrap canvas { position: absolute; top: 0; left: 0;
|
| 29 |
width: 100%; height: 100%; object-fit: cover; }
|
| 30 |
.cam-wrap canvas { display: none; }
|
|
|
|
| 31 |
.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
| 32 |
border: 3px dashed #38bdf8; border-radius: 12px; pointer-events: none; opacity: 0.5; }
|
| 33 |
.placeholder { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
| 34 |
text-align: center; color: #64748b; pointer-events: none; }
|
| 35 |
|
|
|
|
| 36 |
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
|
| 37 |
.controls button, #file-label {
|
| 38 |
flex: 1; min-width: 90px; padding: 12px; border: none; border-radius: 8px;
|
|
|
|
| 40 |
}
|
| 41 |
#btn-cap { background: #2563eb; color: #fff; }
|
| 42 |
#btn-cap:disabled { background: #334155; color: #64748b; cursor: wait; }
|
|
|
|
| 43 |
#btn-pred { background: #16a34a; color: #fff; display: none; }
|
|
|
|
| 44 |
#btn-retake { background: #475569; color: #fff; display: none; }
|
| 45 |
#file-label { background: #334155; color: #cbd5e1; display: inline-block; }
|
| 46 |
#file-input { display: none; }
|
| 47 |
|
|
|
|
| 48 |
#result { display: none; background: #1e293b; border-radius: 12px; padding: 16px; }
|
| 49 |
#result h3 { font-size: 14px; color: #94a3b8; margin-bottom: 8px; }
|
| 50 |
.pred-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
|
|
| 63 |
.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
| 64 |
background: #ef4444; color: #fff; padding: 10px 20px; border-radius: 8px;
|
| 65 |
font-size: 13px; display: none; z-index: 100; white-space: nowrap; }
|
|
|
|
| 66 |
.time { text-align: center; font-size: 11px; color: #64748b; margin-top: -4px; }
|
| 67 |
</style>
|
| 68 |
</head>
|
|
|
|
| 83 |
</div>
|
| 84 |
|
| 85 |
<div class="controls">
|
| 86 |
+
<button id="btn-cap" disabled onclick="capture()">πΈ Ambil</button>
|
| 87 |
<button id="btn-pred" onclick="predict()">π Prediksi</button>
|
| 88 |
<button id="btn-retake" onclick="retake()">π Ulang</button>
|
| 89 |
<label id="file-label" for="file-input">π Upload</label>
|
|
|
|
| 105 |
let stream = null;
|
| 106 |
let captured = false;
|
| 107 |
let classNames = [];
|
| 108 |
+
let modelLoading = false;
|
| 109 |
|
| 110 |
const video = document.getElementById('video');
|
| 111 |
const canvas = document.getElementById('canvas');
|
|
|
|
| 122 |
const placeholder = document.getElementById('placeholder');
|
| 123 |
|
| 124 |
// ββ SET WASM PATH ββ
|
| 125 |
+
function setWasmPath() {
|
| 126 |
+
// The WASM files are in the same CDN directory as tf-tflite.min.js
|
| 127 |
+
// jsDelivr resolves them automatically, but we set explicitly for safety
|
| 128 |
+
const base = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-tflite/dist/tflite/';
|
| 129 |
+
try { tflite.setWasmPath(base); } catch(e) {}
|
| 130 |
}
|
| 131 |
|
| 132 |
// ββ LOAD MODEL ββ
|
| 133 |
async function loadModel() {
|
| 134 |
+
modelLoading = true;
|
| 135 |
+
statusEl.textContent = 'β³ Loading model (3.5 MB)...';
|
| 136 |
+
statusEl.className = 'loading';
|
| 137 |
try {
|
| 138 |
model = await tflite.loadTFLiteModel('mobilenetv4_cbam_quantized.tflite');
|
|
|
|
| 139 |
statusEl.textContent = 'β Model siap';
|
| 140 |
statusEl.className = 'ready';
|
| 141 |
btnCap.disabled = false;
|
| 142 |
+
modelLoading = false;
|
| 143 |
} catch (e) {
|
| 144 |
+
statusEl.textContent = 'β Gagal: ' + e.message;
|
| 145 |
statusEl.className = 'error';
|
| 146 |
+
showToast('Model gagal dimuat: ' + e.message);
|
| 147 |
+
modelLoading = false;
|
| 148 |
throw e;
|
| 149 |
}
|
| 150 |
}
|
|
|
|
| 166 |
await video.play();
|
| 167 |
placeholder.style.display = 'none';
|
| 168 |
} catch (e) {
|
| 169 |
+
placeholder.innerHTML = '<div style="color:#ef4444">β Kamera gak bisa diakses<br><small style="font-size:12px">Upload gambar aja</small></div>';
|
|
|
|
| 170 |
}
|
| 171 |
}
|
| 172 |
|
| 173 |
// ββ CAPTURE ββ
|
| 174 |
function capture() {
|
| 175 |
+
canvas.width = video.videoWidth || video.width || 480;
|
| 176 |
+
canvas.height = video.videoHeight || video.height || 480;
|
| 177 |
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 178 |
canvas.style.display = 'block';
|
| 179 |
video.style.display = 'none';
|
|
|
|
| 201 |
if (captured) {
|
| 202 |
imageData = canvas;
|
| 203 |
} else if (fileInput.files.length > 0) {
|
| 204 |
+
const img = await loadImageFile(fileInput.files[0]);
|
| 205 |
canvas.width = img.naturalWidth;
|
| 206 |
canvas.height = img.naturalHeight;
|
| 207 |
ctx.drawImage(img, 0, 0);
|
|
|
|
| 216 |
return;
|
| 217 |
}
|
| 218 |
|
|
|
|
| 219 |
spinner.style.display = 'block';
|
| 220 |
predsDiv.innerHTML = '';
|
| 221 |
timeEl.textContent = '';
|
| 222 |
+
resultDiv.style.display = 'none';
|
| 223 |
|
|
|
|
| 224 |
await tf.nextFrame();
|
|
|
|
| 225 |
const t0 = performance.now();
|
| 226 |
+
|
| 227 |
try {
|
| 228 |
+
// Preprocess: resize to 224x224, keep [0,255] (model handles normalization)
|
| 229 |
const input = tf.tidy(() => {
|
| 230 |
const img = tf.browser.fromPixels(imageData);
|
| 231 |
const resized = tf.image.resizeBilinear(img, [224, 224]);
|
| 232 |
+
return resized.expandDims(0).toFloat();
|
| 233 |
});
|
| 234 |
|
| 235 |
const output = model.predict(input);
|
| 236 |
const scores = output.dataSync();
|
| 237 |
+
const elapsed = (performance.now() - t0).toFixed(0);
|
| 238 |
|
|
|
|
| 239 |
input.dispose();
|
| 240 |
output.dispose();
|
| 241 |
|
| 242 |
spinner.style.display = 'none';
|
| 243 |
resultDiv.style.display = 'block';
|
| 244 |
|
| 245 |
+
if (!classNames.length) {
|
| 246 |
+
// Fallback: generate class names
|
| 247 |
+
for (let i = 0; i < scores.length; i++) classNames.push('class_' + i);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
// Top 3
|
| 251 |
const top3 = Array.from(scores)
|
| 252 |
.map((s, i) => ({ label: classNames[i] || 'class_' + i, score: s }))
|
| 253 |
.sort((a, b) => b.score - a.score)
|
| 254 |
.slice(0, 3);
|
| 255 |
|
| 256 |
+
timeEl.textContent = `β± ${elapsed}ms inferensi (browser)`;
|
| 257 |
|
| 258 |
const maxScore = top3[0].score;
|
| 259 |
predsDiv.innerHTML = top3.map((p, i) => {
|
|
|
|
| 273 |
}
|
| 274 |
|
| 275 |
// ββ HELPERS ββ
|
| 276 |
+
function loadImageFile(file) {
|
| 277 |
return new Promise((resolve, reject) => {
|
| 278 |
const r = new FileReader();
|
| 279 |
r.onload = () => {
|
|
|
|
| 294 |
setTimeout(() => t.style.display = 'none', 3000);
|
| 295 |
}
|
| 296 |
|
| 297 |
+
// ββ FILE UPLOAD ββ
|
| 298 |
fileInput.addEventListener('change', async (e) => {
|
| 299 |
if (e.target.files.length === 0) return;
|
| 300 |
+
const img = await loadImageFile(e.target.files[0]);
|
| 301 |
canvas.width = img.naturalWidth;
|
| 302 |
canvas.height = img.naturalHeight;
|
| 303 |
ctx.drawImage(img, 0, 0);
|
|
|
|
| 311 |
predict();
|
| 312 |
});
|
| 313 |
|
|
|
|
|
|
|
|
|
|
| 314 |
// ββ INIT ββ
|
| 315 |
(async function init() {
|
|
|
|
| 316 |
try {
|
| 317 |
+
setWasmPath();
|
| 318 |
+
await Promise.all([loadModel(), loadClassNames()]);
|
| 319 |
+
startCam();
|
| 320 |
} catch (e) {
|
| 321 |
console.error('Init error:', e);
|
| 322 |
}
|