Anoderb commited on
Commit
2acf4dd
Β·
verified Β·
1 Parent(s): 00257e2

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. README.md +25 -5
  2. class_names.json +21 -0
  3. index.html +331 -18
  4. mobilenetv4_cbam_quantized.tflite +3 -0
README.md CHANGED
@@ -1,10 +1,30 @@
1
  ---
2
- title: Sku Webcam Classifier
3
- emoji: πŸ“‰
4
- colorFrom: gray
5
- colorTo: red
6
  sdk: static
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SKU Scanner
3
+ emoji: πŸ“·
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
  ---
10
 
11
+ # πŸ“· SKU Scanner - Webcam Classifier
12
+
13
+ Klasifikasi 19 SKU produk UMKM via webcam HP.
14
+
15
+ **Stack:** TensorFlow.js TFLite + WebAssembly β€” semua inferensi **client-side**. Gak ada data dikirim ke server. Gak perlu backend.
16
+
17
+ ## πŸ”— Links
18
+ - Model: https://huggingface.co/Anoderb/sku-mobilenetv4-cbam-classifier
19
+ - Dataset: https://huggingface.co/datasets/anoderb/sku-product-image-classification
20
+
21
+ ## πŸ“Š Metrics
22
+ | Metric | Value |
23
+ |--------|-------|
24
+ | Accuracy | **98.8%** |
25
+ | F1 Macro | 0.984 |
26
+ | Model Size | 3.5 MB (TFLite Quantized) |
27
+ | Inference | Client-side (WASM) |
28
+
29
+ ## 🏷️ 19 SKU
30
+ frisian-flag-fullcrm-250ml, frisian-flag-strwbry-250ml, gaga-100-grg-jalapeto, gaga-100-kuah-jalapeto, indomie-grg-cb-ijo, indomie-grg-cb-ijo-jumbo, klik-crackers-keju, nabati-siip-keju, pepsodent-72g, sarimi-aym-bwng, sarimi-gls-baso-pds, sedaap-grng, sedaap-kuah-aym-bwg, sedaap-sg-laksa, siplah-mineral-220ml, soffell-bunga, soffell-jeruk, ultramlk-fullcrm-200ml, vica-600ml
class_names.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "frisian-flag-fullcrm-250ml",
3
+ "frisian-flag-strwbry-250ml",
4
+ "gaga-100-grg-jalapeto",
5
+ "gaga-100-kuah-jalapeto",
6
+ "indomie-grg-cb-ijo",
7
+ "indomie-grg-cb-ijo-jumbo",
8
+ "klik-crackers-keju",
9
+ "nabati-siip-keju",
10
+ "pepsodent-72g",
11
+ "sarimi-aym-bwng",
12
+ "sarimi-gls-baso-pds",
13
+ "sedaap-grng",
14
+ "sedaap-kuah-aym-bwg",
15
+ "sedaap-sg-laksa",
16
+ "siplah-mineral-220ml",
17
+ "soffell-bunga",
18
+ "soffell-jeruk",
19
+ "ultramlk-fullcrm-200ml",
20
+ "vica-600ml"
21
+ ]
index.html CHANGED
@@ -1,19 +1,332 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
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-wasm@4.22.0/dist/tf-backend-wasm.min.js"></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-tflite@0.0.1/dist/tf-tflite.min.js"></script>
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; margin-bottom: 4px;
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;
45
+ font-size: 14px; font-weight: 600; cursor: pointer; transition: all .15s; text-align: center;
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; }
60
+ .pred-row .label { flex: 1; font-size: 13px; font-weight: 500; }
61
+ .pred-row .bar-wrap { flex: 2; height: 20px; background: #334155; border-radius: 4px; overflow: hidden; }
62
+ .pred-row .bar { height: 100%; border-radius: 4px; transition: width .4s ease; }
63
+ .pred-row .score { width: 42px; text-align: right; font-size: 12px; color: #94a3b8; }
64
+ .rank-1 .bar { background: #38bdf8; }
65
+ .rank-2 .bar { background: #60a5fa; }
66
+ .rank-3 .bar { background: #818cf8; }
67
+
68
+ .spinner { width: 20px; height: 20px; border: 2px solid #334155; border-top-color: #38bdf8;
69
+ border-radius: 50%; animation: spin .6s linear infinite; margin: 20px auto; }
70
+ @keyframes spin { to { transform: rotate(360deg); } }
71
+
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>
79
+ <body>
80
+ <div class="container">
81
+ <h1>πŸ“· SKU Scanner</h1>
82
+ <p class="subtitle" id="sub">Scan produk UMKM β€” 19 kategori</p>
83
+ <div id="status">⏳ Loading model...</div>
84
+
85
+ <div class="cam-wrap" id="camWrap">
86
+ <video id="video" autoplay playsinline></video>
87
+ <canvas id="canvas"></canvas>
88
+ <div class="overlay"></div>
89
+ <div class="placeholder" id="placeholder">
90
+ <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>
91
+ <div style="margin-top:8px">Aktifkan kamera</div>
92
+ </div>
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>
100
+ <input type="file" id="file-input" accept="image/*" capture="environment">
101
+ </div>
102
+
103
+ <div id="result">
104
+ <h3>πŸ”Ž Hasil Prediksi</h3>
105
+ <div id="preds"></div>
106
+ <div class="time" id="infer-time"></div>
107
+ </div>
108
+ <div class="spinner" id="spinner" style="display:none"></div>
109
+ </div>
110
+ <div class="toast" id="toast"></div>
111
+
112
+ <script>
113
+ // ── GLOBALS ──
114
+ 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');
121
+ const ctx = canvas.getContext('2d');
122
+ const statusEl = document.getElementById('status');
123
+ const btnCap = document.getElementById('btn-cap');
124
+ const btnPred = document.getElementById('btn-pred');
125
+ const btnRetake = document.getElementById('btn-retake');
126
+ const resultDiv = document.getElementById('result');
127
+ const predsDiv = document.getElementById('preds');
128
+ const spinner = document.getElementById('spinner');
129
+ const timeEl = document.getElementById('infer-time');
130
+ const fileInput = document.getElementById('file-input');
131
+ const placeholder = document.getElementById('placeholder');
132
+
133
+ // ── SET WASM PATH ──
134
+ async function initTFJS() {
135
+ const wasmBase = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-tflite@0.0.1/dist/tflite/';
136
+ await tflite.setWasmPath(wasmBase);
137
+ await tf.ready();
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 load model: ' + e.message;
150
+ statusEl.className = 'error';
151
+ showToast('Gagal load model: ' + e.message);
152
+ throw e;
153
+ }
154
+ }
155
+
156
+ // ── LOAD CLASS NAMES ──
157
+ async function loadClassNames() {
158
+ const res = await fetch('class_names.json');
159
+ classNames = await res.json();
160
+ }
161
+
162
+ // ── START CAMERA ──
163
+ async function startCam() {
164
+ try {
165
+ stream = await navigator.mediaDevices.getUserMedia({
166
+ video: { facingMode: 'environment', width: { ideal: 640 }, height: { ideal: 640 } },
167
+ audio: false,
168
+ });
169
+ video.srcObject = stream;
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">Gunakan Upload saja</small></div>';
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';
185
+ captured = true;
186
+ btnCap.style.display = 'none';
187
+ btnPred.style.display = 'block';
188
+ btnRetake.style.display = 'block';
189
+ }
190
+
191
+ // ── RETAKE ──
192
+ function retake() {
193
+ canvas.style.display = 'none';
194
+ video.style.display = 'block';
195
+ captured = false;
196
+ btnPred.style.display = 'none';
197
+ btnRetake.style.display = 'none';
198
+ btnCap.style.display = 'block';
199
+ resultDiv.style.display = 'none';
200
+ }
201
+
202
+ // ── PREDICT ──
203
+ async function predict() {
204
+ let imageData;
205
+
206
+ if (captured) {
207
+ imageData = canvas;
208
+ } else if (fileInput.files.length > 0) {
209
+ const img = await loadImage(fileInput.files[0]);
210
+ canvas.width = img.naturalWidth;
211
+ canvas.height = img.naturalHeight;
212
+ ctx.drawImage(img, 0, 0);
213
+ imageData = canvas;
214
+ } else {
215
+ showToast('Ambil foto dulu!');
216
+ return;
217
+ }
218
+
219
+ if (!model) {
220
+ showToast('Model belum siap!');
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
+ // Resize to 224x224
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(); // raw [0,255]
239
+ });
240
+
241
+ const output = model.predict(input);
242
+ const scores = output.dataSync();
243
+ const time = (performance.now() - t0).toFixed(0);
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 = `⏱ ${time}ms inferensi (client-side WASM)`;
259
+
260
+ const maxScore = top3[0].score;
261
+ predsDiv.innerHTML = top3.map((p, i) => {
262
+ const pct = Math.max((p.score / maxScore) * 100, 5);
263
+ return `<div class="pred-row rank-${i+1}">
264
+ <span class="label">${p.label.replace(/-/g, ' ')}</span>
265
+ <div class="bar-wrap"><div class="bar" style="width:${pct}%"></div></div>
266
+ <span class="score">${(p.score * 100).toFixed(1)}%</span>
267
+ </div>`;
268
+ }).join('');
269
+
270
+ } catch (e) {
271
+ spinner.style.display = 'none';
272
+ showToast('Prediksi gagal: ' + e.message);
273
+ console.error(e);
274
+ }
275
+ }
276
+
277
+ // ── HELPERS ──
278
+ function loadImage(file) {
279
+ return new Promise((resolve, reject) => {
280
+ const r = new FileReader();
281
+ r.onload = () => {
282
+ const img = new Image();
283
+ img.onload = () => resolve(img);
284
+ img.onerror = reject;
285
+ img.src = r.result;
286
+ };
287
+ r.onerror = reject;
288
+ r.readAsDataURL(file);
289
+ });
290
+ }
291
+
292
+ function showToast(msg) {
293
+ const t = document.getElementById('toast');
294
+ t.textContent = msg;
295
+ t.style.display = 'block';
296
+ setTimeout(() => t.style.display = 'none', 3000);
297
+ }
298
+
299
+ // ── EVENT HANDLERS ──
300
+ fileInput.addEventListener('change', async (e) => {
301
+ if (e.target.files.length === 0) return;
302
+ const img = await loadImage(e.target.files[0]);
303
+ canvas.width = img.naturalWidth;
304
+ canvas.height = img.naturalHeight;
305
+ ctx.drawImage(img, 0, 0);
306
+ canvas.style.display = 'block';
307
+ video.style.display = 'none';
308
+ placeholder.style.display = 'none';
309
+ captured = true;
310
+ btnCap.style.display = 'none';
311
+ btnPred.style.display = 'block';
312
+ btnRetake.style.display = 'block';
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
+ await initTFJS();
324
+ statusEl.textContent = '⏳ Loading model (3.5 MB)...';
325
+ await Promise.all([loadModel(), loadClassNames(), startCam()]);
326
+ } catch (e) {
327
+ console.error('Init error:', e);
328
+ }
329
+ })();
330
+ </script>
331
+ </body>
332
  </html>
mobilenetv4_cbam_quantized.tflite ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:606f8201a060070778d6b49463f0053a2896c35d2e41b8a880e39cd0cfc851e6
3
+ size 3547384