anoderb commited on
Commit
cc5d7a3
Β·
0 Parent(s):

Initial commit of static Web app

Browse files
Files changed (6) hide show
  1. .gitattributes +37 -0
  2. .gitignore +7 -0
  3. README.md +39 -0
  4. index.html +718 -0
  5. labels.json +21 -0
  6. models/mobilenetv4_cbam_quantized.tflite +3 -0
.gitattributes ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ **/variables/variables.data-* filter=lfs diff=lfs merge=lfs -text
37
+ **/variables/variables.index filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .ipynb_checkpoints/
5
+ .venv/
6
+ venv/
7
+ batch_results.csv
README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Tokiva Product Detector
3
+ emoji: πŸ›’
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: static
7
+ pinned: false
8
+ ---
9
+
10
+ # πŸ›’ Tokiva Product Detector
11
+
12
+ Aplikasi web untuk mendeteksi produk toko menggunakan model **MobileNetV4 + CBAM** (akurasi 98.77%).
13
+
14
+ ## Fitur
15
+
16
+ - πŸ“Έ **Deteksi via Webcam** β€” Scan produk langsung dari kamera
17
+ - πŸ–ΌοΈ **Upload Foto** β€” Upload gambar produk untuk dideteksi
18
+ - πŸ“Š **Batch Test** β€” Test banyak gambar sekaligus + export CSV
19
+ - πŸ”¬ **Analisis Detail** β€” GradCAM heatmap + probability chart
20
+
21
+ ## Model Info
22
+
23
+ | Property | Value |
24
+ |---|---|
25
+ | Architecture | MobileNetV4 + CBAM |
26
+ | Accuracy | 98.77% |
27
+ | F1 Score | 0.9897 |
28
+ | Classes | 19 produk |
29
+ | Model Size | ~12 MB |
30
+
31
+ ## Produk yang Dikenali
32
+
33
+ Frisian Flag, Gaga Mie, Indomie, Klik Crackers, Nabati Siip, Pepsodent, Sarimi, Sedaap, Siplah, Soffell, Ultra Milk, Vica.
34
+
35
+ ## Tech Stack
36
+
37
+ - **Model**: TensorFlow (SavedModel format)
38
+ - **UI**: Gradio
39
+ - **Deploy**: HuggingFace Spaces
index.html ADDED
@@ -0,0 +1,718 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">
6
+ <title>Tokiva Product Detector</title>
7
+
8
+ <!-- Tailwind CSS & Google Fonts -->
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
12
+
13
+ <!-- TensorFlow.js & TFLite Web -->
14
+ <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs/dist/tf.min.js"></script>
15
+ <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-tflite/dist/tf-tflite.min.js"></script>
16
+
17
+ <style>
18
+ body {
19
+ font-family: 'Plus Jakarta Sans', sans-serif;
20
+ background: radial-gradient(circle at top right, #1e1b4b 0%, #09090b 100%);
21
+ color: #f4f4f5;
22
+ }
23
+ .glass {
24
+ background: rgba(24, 24, 27, 0.7);
25
+ backdrop-filter: blur(16px);
26
+ -webkit-backdrop-filter: blur(16px);
27
+ border: 1px rgba(255, 255, 255, 0.08) solid;
28
+ }
29
+ .gradient-text {
30
+ background: linear-gradient(135deg, #a78bfa 0%, #6366f1 100%);
31
+ -webkit-background-clip: text;
32
+ -webkit-text-fill-color: transparent;
33
+ }
34
+ .progress-bar-fill {
35
+ transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
36
+ }
37
+ </style>
38
+ </head>
39
+ <body class="min-h-screen flex flex-col antialiased">
40
+
41
+ <!-- Header -->
42
+ <header class="glass sticky top-0 z-40 border-b border-white/5 py-4 px-6">
43
+ <div class="max-w-6xl mx-auto flex items-center justify-between">
44
+ <div class="flex items-center gap-3">
45
+ <div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-500/20">
46
+ <i class="fa-solid fa-basket-shopping text-white text-lg"></i>
47
+ </div>
48
+ <div>
49
+ <h1 class="text-xl font-bold tracking-tight">Tokiva <span class="gradient-text">Detector</span></h1>
50
+ <p class="text-xs text-zinc-400">Model Tester β€” Client Side Engine</p>
51
+ </div>
52
+ </div>
53
+ <div class="flex items-center gap-2">
54
+ <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
55
+ <span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
56
+ WASM Engine
57
+ </span>
58
+ </div>
59
+ </div>
60
+ </header>
61
+
62
+ <!-- Main Container -->
63
+ <main class="flex-grow max-w-6xl w-full mx-auto p-4 md:p-6 grid grid-cols-1 lg:grid-cols-12 gap-6">
64
+
65
+ <!-- Left Column: Tabs & Inputs -->
66
+ <div class="lg:col-span-7 flex flex-col gap-6">
67
+
68
+ <!-- Model Loader Progress -->
69
+ <div id="loader-card" class="glass rounded-2xl p-6 flex flex-col gap-4">
70
+ <div class="flex items-center justify-between">
71
+ <h3 class="font-semibold text-sm tracking-wide uppercase text-zinc-400">Loading AI Model...</h3>
72
+ <span id="loader-percentage" class="text-indigo-400 font-bold text-sm">0%</span>
73
+ </div>
74
+ <div class="w-full bg-white/5 h-2 rounded-full overflow-hidden">
75
+ <div id="loader-bar" class="bg-gradient-to-r from-purple-500 to-indigo-500 h-full w-0 progress-bar-fill"></div>
76
+ </div>
77
+ <p id="loader-status" class="text-xs text-zinc-400 italic">Mendownload model (3.5 MB)...</p>
78
+ </div>
79
+
80
+ <!-- Main Tabs (Hidden during loading) -->
81
+ <div id="main-interface" class="hidden flex flex-col gap-6">
82
+
83
+ <!-- Tab Switches -->
84
+ <div class="flex border-b border-white/5">
85
+ <button id="tab-webcam-btn" class="flex-1 py-3 px-4 text-sm font-semibold border-b-2 border-indigo-500 text-indigo-400 flex items-center justify-center gap-2 transition" onclick="switchTab('webcam')">
86
+ <i class="fa-solid fa-camera"></i> πŸ“Έ Webcam
87
+ </button>
88
+ <button id="tab-upload-btn" class="flex-1 py-3 px-4 text-sm font-semibold border-b-2 border-transparent text-zinc-400 hover:text-zinc-200 hover:border-white/10 flex items-center justify-center gap-2 transition" onclick="switchTab('upload')">
89
+ <i class="fa-solid fa-cloud-arrow-up"></i> πŸ–ΌοΈ Upload Foto
90
+ </button>
91
+ <button id="tab-analysis-btn" class="flex-1 py-3 px-4 text-sm font-semibold border-b-2 border-transparent text-zinc-400 hover:text-zinc-200 hover:border-white/10 flex items-center justify-center gap-2 transition" onclick="switchTab('analysis')">
92
+ <i class="fa-solid fa-chart-simple"></i> πŸ”¬ Analisis Detail
93
+ </button>
94
+ </div>
95
+
96
+ <!-- Tab 1: Webcam -->
97
+ <div id="tab-webcam" class="tab-content flex flex-col gap-4">
98
+ <div class="relative aspect-video rounded-2xl overflow-hidden bg-black/40 border border-white/5 shadow-inner">
99
+ <video id="webcam-video" autoplay playsinline muted class="w-full h-full object-cover transform scale-x-[-1]"></video>
100
+ <canvas id="webcam-canvas" class="hidden absolute top-0 left-0 w-full h-full object-cover"></canvas>
101
+
102
+ <!-- Camera Status overlay -->
103
+ <div id="camera-loading" class="absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/80 gap-3">
104
+ <i class="fa-solid fa-circle-notch animate-spin text-indigo-500 text-3xl"></i>
105
+ <span class="text-sm text-zinc-400">Menghubungkan kamera...</span>
106
+ </div>
107
+ </div>
108
+
109
+ <div class="flex gap-3">
110
+ <button id="btn-scan" class="flex-grow py-3.5 px-6 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-indigo-600 hover:to-purple-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 shadow-lg shadow-indigo-600/20 transition active:scale-95">
111
+ <i class="fa-solid fa-magnifying-glass"></i> SCAN SEKARANG
112
+ </button>
113
+ <button id="btn-switch-camera" class="px-5 bg-white/5 hover:bg-white/10 text-zinc-200 rounded-xl transition border border-white/5" title="Ganti Kamera">
114
+ <i class="fa-solid fa-arrows-rotate"></i>
115
+ </button>
116
+ </div>
117
+ </div>
118
+
119
+ <!-- Tab 2: Upload File -->
120
+ <div id="tab-upload" class="tab-content hidden flex flex-col gap-4">
121
+ <div id="drop-zone" class="aspect-video rounded-2xl border-2 border-dashed border-white/10 hover:border-indigo-500/50 bg-white/[0.02] hover:bg-white/[0.04] transition flex flex-col items-center justify-center gap-3 p-6 cursor-pointer relative overflow-hidden group">
122
+ <input type="file" id="file-input" accept="image/*" class="absolute inset-0 opacity-0 cursor-pointer">
123
+ <div id="upload-placeholder" class="text-center flex flex-col items-center gap-2">
124
+ <div class="w-12 h-12 rounded-xl bg-white/5 group-hover:bg-indigo-500/10 group-hover:text-indigo-400 flex items-center justify-center transition text-zinc-400">
125
+ <i class="fa-solid fa-image text-xl"></i>
126
+ </div>
127
+ <p class="font-medium text-sm">Drag & Drop foto produk atau <span class="text-indigo-400 group-hover:underline">pilih file</span></p>
128
+ <p class="text-xs text-zinc-500">Mendukung JPEG, PNG hingga 10MB</p>
129
+ </div>
130
+ <img id="upload-preview" class="hidden max-h-full max-w-full rounded-lg object-contain">
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Tab 3: Detailed Analysis -->
135
+ <div id="tab-analysis" class="tab-content hidden flex flex-col gap-4">
136
+ <div class="grid grid-cols-2 gap-4">
137
+ <div class="flex flex-col gap-2">
138
+ <span class="text-xs font-semibold uppercase tracking-wider text-zinc-400">Original & Heatmap Overlay</span>
139
+ <div class="relative aspect-square rounded-xl overflow-hidden bg-black/20 border border-white/5">
140
+ <canvas id="analysis-overlay-canvas" class="w-full h-full object-contain"></canvas>
141
+ </div>
142
+ </div>
143
+ <div class="flex flex-col gap-2">
144
+ <span class="text-xs font-semibold uppercase tracking-wider text-zinc-400">Heatmap Sensitivitas</span>
145
+ <div class="relative aspect-square rounded-xl overflow-hidden bg-black/20 border border-white/5">
146
+ <canvas id="analysis-heatmap-canvas" class="w-full h-full object-contain"></canvas>
147
+ </div>
148
+ </div>
149
+ </div>
150
+ <button id="btn-analyze" class="w-full py-3.5 px-6 bg-purple-600/20 hover:bg-purple-600/30 text-purple-300 rounded-xl font-bold flex items-center justify-center gap-2 border border-purple-500/20 transition active:scale-95">
151
+ <i class="fa-solid fa-microscope"></i> HITUNG HEATMAP OKLUSI
152
+ </button>
153
+ <div class="glass rounded-xl p-4 text-xs text-zinc-400 leading-relaxed flex gap-3">
154
+ <i class="fa-solid fa-circle-info text-indigo-400 text-sm mt-0.5"></i>
155
+ <p>
156
+ <strong>Occlusion Sensitivity Map</strong> dihasilkan dengan menutupi bagian grid gambar secara dinamis dengan kotak abu-abu di browser kamu. Warna <strong>merah/kuning</strong> menunjukkan bagian gambar yang paling penting bagi keputusan model untuk mendeteksi produk tersebut.
157
+ </p>
158
+ </div>
159
+ </div>
160
+
161
+ </div>
162
+ </div>
163
+
164
+ <!-- Right Column: Results & Info -->
165
+ <div class="lg:col-span-5 flex flex-col gap-6">
166
+
167
+ <!-- Prediction Results -->
168
+ <div class="glass rounded-2xl p-6 flex flex-col gap-4 flex-grow">
169
+ <h3 class="font-bold text-lg tracking-tight flex items-center justify-between border-b border-white/5 pb-3">
170
+ <span>🎯 Hasil Deteksi</span>
171
+ <span id="inference-time" class="text-xs text-zinc-500 font-normal">Inference: -- ms</span>
172
+ </h3>
173
+
174
+ <!-- Default message before detection -->
175
+ <div id="results-placeholder" class="flex flex-col items-center justify-center py-20 text-center text-zinc-500 gap-3">
176
+ <i class="fa-solid fa-box-open text-4xl text-zinc-600"></i>
177
+ <p class="text-sm">Silakan lakukan scan atau upload foto untuk melihat prediksi</p>
178
+ </div>
179
+
180
+ <!-- Realtime / Static Prediction list -->
181
+ <div id="results-container" class="hidden flex flex-col gap-4">
182
+ <!-- Main Prediction Badge -->
183
+ <div class="p-4 rounded-xl border border-indigo-500/20 bg-indigo-500/5 flex items-center justify-between">
184
+ <div class="flex items-center gap-3">
185
+ <div id="top-badge-emoji" class="w-10 h-10 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center text-lg font-bold">
186
+ βœ…
187
+ </div>
188
+ <div>
189
+ <div id="top-display-name" class="font-bold text-zinc-100">Nama Produk</div>
190
+ <div id="top-class-name" class="text-xs text-zinc-500 font-mono">class-name</div>
191
+ </div>
192
+ </div>
193
+ <div id="top-confidence-value" class="text-lg font-extrabold text-emerald-400">0.0%</div>
194
+ </div>
195
+
196
+ <!-- Predictions List -->
197
+ <div class="flex flex-col gap-3">
198
+ <span class="text-xs font-semibold uppercase tracking-wider text-zinc-505">Alternatif Prediksi</span>
199
+ <div id="list-predictions" class="flex flex-col gap-2.5">
200
+ <!-- Dynamically populated rows -->
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </div>
205
+
206
+ <!-- Info Model -->
207
+ <div class="glass rounded-2xl p-6 flex flex-col gap-4">
208
+ <h3 class="font-bold text-sm tracking-wide uppercase text-zinc-400 flex items-center gap-2">
209
+ <i class="fa-solid fa-info-circle text-indigo-400"></i> Informasi Model
210
+ </h3>
211
+ <div class="grid grid-cols-2 gap-3 text-xs">
212
+ <div class="p-3 bg-white/[0.02] rounded-xl border border-white/5">
213
+ <span class="text-zinc-500 block mb-0.5">Nama Model</span>
214
+ <span class="font-semibold text-zinc-200">MobileNetV4 + CBAM</span>
215
+ </div>
216
+ <div class="p-3 bg-white/[0.02] rounded-xl border border-white/5">
217
+ <span class="text-zinc-500 block mb-0.5">Akurasi</span>
218
+ <span class="font-semibold text-emerald-400">98.77%</span>
219
+ </div>
220
+ <div class="p-3 bg-white/[0.02] rounded-xl border border-white/5">
221
+ <span class="text-zinc-500 block mb-0.5">F1-Score</span>
222
+ <span class="font-semibold text-zinc-200">0.9897</span>
223
+ </div>
224
+ <div class="p-3 bg-white/[0.02] rounded-xl border border-white/5">
225
+ <span class="text-zinc-500 block mb-0.5">Size</span>
226
+ <span class="font-semibold text-zinc-200">3.5 MB (Quantized)</span>
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ </div>
232
+ </main>
233
+
234
+ <!-- Footer -->
235
+ <footer class="mt-auto py-6 border-t border-white/5 px-6 text-center text-xs text-zinc-500">
236
+ <p>&copy; 2026 Tokiva Team. Dibuat menggunakan TensorFlow.js TFLite Web engine.</p>
237
+ </footer>
238
+
239
+ <script>
240
+ // ─────────────────────────────────────────────────────────
241
+ // MODEL CONFIG & MAPS
242
+ // ─────────────────────────────────────────────────────────
243
+ const MODEL_PATH = 'models/mobilenetv4_cbam_quantized.tflite';
244
+ const INPUT_SIZE = [224, 224];
245
+
246
+ // 19 Class Labels ordered alphabetically (matching indices from training)
247
+ const CLASS_NAMES = [
248
+ "frisian-flag-fullcrm-250ml",
249
+ "frisian-flag-strwbry-250ml",
250
+ "gaga-100-grg-jalapeto",
251
+ "gaga-100-kuah-jalapeto",
252
+ "indomie-grg-cb-ijo",
253
+ "indomie-grg-cb-ijo-jumbo",
254
+ "klik-crackers-keju",
255
+ "nabati-siip-keju",
256
+ "pepsodent-72g",
257
+ "sarimi-aym-bwng",
258
+ "sarimi-gls-baso-pds",
259
+ "sedaap-grng",
260
+ "sedaap-kuah-aym-bwg",
261
+ "sedaap-sg-laksa",
262
+ "siplah-mineral-220ml",
263
+ "soffell-bunga",
264
+ "soffell-jeruk",
265
+ "ultramilk-fullcrm-200ml",
266
+ "vica-600ml"
267
+ ];
268
+
269
+ const DISPLAY_NAMES = {
270
+ "frisian-flag-fullcrm-250ml": "Frisian Flag Full Cream 250ml",
271
+ "frisian-flag-strwbry-250ml": "Frisian Flag Strawberry 250ml",
272
+ "gaga-100-grg-jalapeto": "Gaga 100 Goreng Jalapeno",
273
+ "gaga-100-kuah-jalapeto": "Gaga 100 Kuah Jalapeno",
274
+ "indomie-grg-cb-ijo": "Indomie Goreng Cabe Ijo",
275
+ "indomie-grg-cb-ijo-jumbo": "Indomie Goreng Cabe Ijo Jumbo",
276
+ "klik-crackers-keju": "Klik Crackers Keju",
277
+ "nabati-siip-keju": "Nabati Siip Keju",
278
+ "pepsodent-72g": "Pepsodent 72g",
279
+ "sarimi-aym-bwng": "Sarimi Ayam Bawang",
280
+ "sarimi-gls-baso-pds": "Sarimi Gelas Bakso Pedas",
281
+ "sedaap-grng": "Sedaap Goreng",
282
+ "sedaap-kuah-aym-bwg": "Sedaap Kuah Ayam Bawang",
283
+ "sedaap-sg-laksa": "Sedaap Singapore Laksa",
284
+ "siplah-mineral-220ml": "Siplah Mineral 220ml",
285
+ "soffell-bunga": "Soffell Bunga",
286
+ "soffell-jeruk": "Soffell Jeruk",
287
+ "ultramilk-fullcrm-200ml": "Ultra Milk Full Cream 200ml",
288
+ "vica-600ml": "Vica 600ml"
289
+ };
290
+
291
+ // ─────────────────────────────────────────────────────────
292
+ // GLOBAL STATE
293
+ // ─────────────────────────────────────────────────────────
294
+ let model = null;
295
+ let currentTab = 'webcam';
296
+ let currentStream = null;
297
+ let activeFacingMode = 'environment'; // environment / user
298
+ let loadedImageElement = null; // caching image element for analysis tab
299
+
300
+ // DOM Elements
301
+ const loaderCard = document.getElementById('loader-card');
302
+ const loaderPercentage = document.getElementById('loader-percentage');
303
+ const loaderBar = document.getElementById('loader-bar');
304
+ const loaderStatus = document.getElementById('loader-status');
305
+
306
+ const mainInterface = document.getElementById('main-interface');
307
+ const video = document.getElementById('webcam-video');
308
+ const webcamCanvas = document.getElementById('webcam-canvas');
309
+ const cameraLoading = document.getElementById('camera-loading');
310
+
311
+ const dropZone = document.getElementById('drop-zone');
312
+ const fileInput = document.getElementById('file-input');
313
+ const uploadPlaceholder = document.getElementById('upload-placeholder');
314
+ const uploadPreview = document.getElementById('upload-preview');
315
+
316
+ const resultsPlaceholder = document.getElementById('results-placeholder');
317
+ const resultsContainer = document.getElementById('results-container');
318
+ const topBadgeEmoji = document.getElementById('top-badge-emoji');
319
+ const topDisplayName = document.getElementById('top-display-name');
320
+ const topClassName = document.getElementById('top-class-name');
321
+ const topConfidenceValue = document.getElementById('top-confidence-value');
322
+ const listPredictions = document.getElementById('list-predictions');
323
+ const inferenceTimeDisplay = document.getElementById('inference-time');
324
+
325
+ // ─────────────────────────────────────────────────────────
326
+ // INIT & MODEL LOAD
327
+ // ─────────────────────────────────────────────────────────
328
+ async function initModel() {
329
+ try {
330
+ // Set WASM paths
331
+ tflite.setWasmPath('https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-tflite/dist/');
332
+
333
+ loaderBar.style.width = '30%';
334
+ loaderPercentage.innerText = '30%';
335
+ loaderStatus.innerText = 'Menginisialisasi WASM engine...';
336
+
337
+ // Load the model
338
+ model = await tflite.loadTFLiteModel(MODEL_PATH);
339
+
340
+ loaderBar.style.width = '100%';
341
+ loaderPercentage.innerText = '100%';
342
+ loaderStatus.innerText = 'Model sukses dimuat!';
343
+
344
+ setTimeout(() => {
345
+ loaderCard.classList.add('hidden');
346
+ mainInterface.classList.remove('hidden');
347
+ // Start webcam by default
348
+ startWebcam();
349
+ }, 800);
350
+ } catch (err) {
351
+ console.error(err);
352
+ loaderStatus.innerText = 'Gagal memuat model: ' + err.message;
353
+ loaderBar.classList.add('bg-red-500');
354
+ }
355
+ }
356
+
357
+ // Initialize model load
358
+ initModel();
359
+
360
+ // ─────────────────────────────────────────────────────────
361
+ // TAB MANAGEMENT
362
+ // ─────────────────────────────────────────────────────────
363
+ function switchTab(tabId) {
364
+ currentTab = tabId;
365
+
366
+ // Update tab buttons style
367
+ const tabs = ['webcam', 'upload', 'analysis'];
368
+ tabs.forEach(t => {
369
+ const btn = document.getElementById(`tab-${t}-btn`);
370
+ const content = document.getElementById(`tab-${t}`);
371
+ if (t === tabId) {
372
+ btn.className = "flex-1 py-3 px-4 text-sm font-semibold border-b-2 border-indigo-500 text-indigo-400 flex items-center justify-center gap-2 transition";
373
+ content.classList.remove('hidden');
374
+ } else {
375
+ btn.className = "flex-1 py-3 px-4 text-sm font-semibold border-b-2 border-transparent text-zinc-400 hover:text-zinc-200 hover:border-white/10 flex items-center justify-center gap-2 transition";
376
+ content.classList.add('hidden');
377
+ }
378
+ });
379
+
380
+ // Stream control
381
+ if (tabId === 'webcam') {
382
+ startWebcam();
383
+ } else {
384
+ stopWebcam();
385
+ }
386
+
387
+ // If switching to analysis, sync with active image
388
+ if (tabId === 'analysis') {
389
+ syncAnalysisImage();
390
+ }
391
+ }
392
+
393
+ // ─────────────────────────────────────────────────────────
394
+ // WEBCAM MANAGEMENT
395
+ // ─────────────────────────────────────────────────────────
396
+ async function startWebcam() {
397
+ stopWebcam();
398
+ cameraLoading.classList.remove('hidden');
399
+ try {
400
+ const constraints = {
401
+ video: {
402
+ facingMode: activeFacingMode,
403
+ width: { ideal: 640 },
404
+ height: { ideal: 480 }
405
+ },
406
+ audio: false
407
+ };
408
+ currentStream = await navigator.mediaDevices.getUserMedia(constraints);
409
+ video.srcObject = currentStream;
410
+ video.onloadedmetadata = () => {
411
+ cameraLoading.classList.add('hidden');
412
+ };
413
+ } catch (err) {
414
+ console.error("Gagal akses kamera:", err);
415
+ cameraLoading.innerHTML = `
416
+ <i class="fa-solid fa-triangle-exclamation text-red-500 text-3xl"></i>
417
+ <span class="text-sm text-red-400 text-center px-4">Gagal mengakses kamera. Berikan izin kamera atau gunakan tab Upload Foto.</span>
418
+ `;
419
+ }
420
+ }
421
+
422
+ function stopWebcam() {
423
+ if (currentStream) {
424
+ currentStream.getTracks().forEach(track => track.stop());
425
+ currentStream = null;
426
+ }
427
+ }
428
+
429
+ document.getElementById('btn-switch-camera').addEventListener('click', () => {
430
+ activeFacingMode = activeFacingMode === 'environment' ? 'user' : 'environment';
431
+ startWebcam();
432
+ });
433
+
434
+ // ─────────────────────────────────────────────────────────
435
+ // UPLOAD / DROPZONE MANAGEMENT
436
+ // ─────────────────────────────────────────────────────────
437
+ dropZone.addEventListener('dragover', (e) => {
438
+ e.preventDefault();
439
+ dropZone.classList.add('border-indigo-500', 'bg-indigo-500/5');
440
+ });
441
+
442
+ dropZone.addEventListener('dragleave', () => {
443
+ dropZone.classList.remove('border-indigo-500', 'bg-indigo-500/5');
444
+ });
445
+
446
+ dropZone.addEventListener('drop', (e) => {
447
+ e.preventDefault();
448
+ dropZone.classList.remove('border-indigo-500', 'bg-indigo-500/5');
449
+ const files = e.dataTransfer.files;
450
+ if (files.length > 0) {
451
+ handleFile(files[0]);
452
+ }
453
+ });
454
+
455
+ fileInput.addEventListener('change', (e) => {
456
+ if (e.target.files.length > 0) {
457
+ handleFile(e.target.files[0]);
458
+ }
459
+ });
460
+
461
+ function handleFile(file) {
462
+ if (!file.type.startsWith('image/')) return;
463
+ const reader = new FileReader();
464
+ reader.onload = (e) => {
465
+ uploadPlaceholder.classList.add('hidden');
466
+ uploadPreview.src = e.target.result;
467
+ uploadPreview.classList.remove('hidden');
468
+
469
+ // Save image representation for prediction & analysis
470
+ const img = new Image();
471
+ img.src = e.target.result;
472
+ img.onload = () => {
473
+ loadedImageElement = img;
474
+ runPrediction(img);
475
+ };
476
+ };
477
+ reader.readAsDataURL(file);
478
+ }
479
+
480
+ // ─────────────────────────────────────────────────────────
481
+ // CORE PREDICTION & INFERENCE
482
+ // ─────────────────────────────────────────────────────────
483
+ async function runPrediction(imageOrVideo) {
484
+ if (!model) return;
485
+
486
+ const startTime = performance.now();
487
+
488
+ // Perform client side inference
489
+ const predictions = tf.tidy(() => {
490
+ // Convert to tensor
491
+ let tensor = tf.browser.fromPixels(imageOrVideo);
492
+ // Resize to 224x224
493
+ tensor = tf.image.resizeBilinear(tensor, INPUT_SIZE);
494
+ // Normalize [0, 1]
495
+ tensor = tensor.toFloat().div(255.0);
496
+ // Expand dimensions to [1, 224, 224, 3]
497
+ tensor = tf.expandDims(tensor, 0);
498
+
499
+ // Predict
500
+ const output = model.predict(tensor);
501
+ // Extract raw probabilities directly (no double softmax)
502
+ return output.dataSync();
503
+ });
504
+
505
+ const elapsed = (performance.now() - startTime).toFixed(1);
506
+ inferenceTimeDisplay.innerText = `Inference: ${elapsed} ms`;
507
+
508
+ // Find top results
509
+ const results = Array.from(predictions).map((prob, idx) => ({
510
+ prob: prob,
511
+ class_name: CLASS_NAMES[idx],
512
+ display_name: DISPLAY_NAMES[class_name] || class_name,
513
+ index: idx
514
+ })).sort((a, b) => b.prob - a.prob);
515
+
516
+ renderResults(results);
517
+ }
518
+
519
+ // Capture frame from live webcam
520
+ document.getElementById('btn-scan').addEventListener('click', () => {
521
+ if (!video.srcObject) return;
522
+
523
+ const ctx = webcamCanvas.getContext('2d');
524
+ webcamCanvas.width = video.videoWidth;
525
+ webcamCanvas.height = video.videoHeight;
526
+
527
+ // Draw camera frame mirroring-free
528
+ ctx.drawImage(video, 0, 0, webcamCanvas.width, webcamCanvas.height);
529
+
530
+ // Save canvas image as active element
531
+ const img = new Image();
532
+ img.src = webcamCanvas.toDataURL('image/png');
533
+ img.onload = () => {
534
+ loadedImageElement = img;
535
+ runPrediction(img);
536
+ };
537
+ });
538
+
539
+ // ─────────────────────────────────────────────────────────
540
+ // RENDER RESULTS
541
+ // ─────────────────────────────────────────────────────────
542
+ function renderResults(results) {
543
+ resultsPlaceholder.classList.add('hidden');
544
+ resultsContainer.classList.remove('hidden');
545
+
546
+ const topResult = results[0];
547
+ const topPct = (topResult.prob * 100).toFixed(1);
548
+
549
+ // Top badge
550
+ if (topResult.prob >= 0.85) {
551
+ topBadgeEmoji.innerText = "βœ…";
552
+ topBadgeEmoji.className = "w-10 h-10 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center text-lg font-bold";
553
+ topConfidenceValue.className = "text-lg font-extrabold text-emerald-400";
554
+ } else if (topResult.prob >= 0.50) {
555
+ topBadgeEmoji.innerText = "⚠️";
556
+ topBadgeEmoji.className = "w-10 h-10 rounded-lg bg-amber-500/10 text-amber-400 flex items-center justify-center text-lg font-bold";
557
+ topConfidenceValue.className = "text-lg font-extrabold text-amber-400";
558
+ } else {
559
+ topBadgeEmoji.innerText = "❌";
560
+ topBadgeEmoji.className = "w-10 h-10 rounded-lg bg-red-500/10 text-red-400 flex items-center justify-center text-lg font-bold";
561
+ topConfidenceValue.className = "text-lg font-extrabold text-red-400";
562
+ }
563
+
564
+ topDisplayName.innerText = topResult.display_name;
565
+ topClassName.innerText = topResult.class_name;
566
+ topConfidenceValue.innerText = `${topPct}%`;
567
+
568
+ // Alternative list
569
+ listPredictions.innerHTML = '';
570
+ for (let i = 1; i < 5; i++) {
571
+ const item = results[i];
572
+ const itemPct = (item.prob * 100).toFixed(1);
573
+ const row = document.createElement('div');
574
+ row.className = "flex flex-col gap-1.5";
575
+
576
+ row.innerHTML = `
577
+ <div class="flex items-center justify-between text-xs">
578
+ <span class="font-medium text-zinc-300">${item.display_name}</span>
579
+ <span class="text-zinc-500 font-bold">${itemPct}%</span>
580
+ </div>
581
+ <div class="w-full bg-white/5 h-1.5 rounded-full overflow-hidden">
582
+ <div class="bg-indigo-500 h-full progress-bar-fill" style="width: ${itemPct}%"></div>
583
+ </div>
584
+ `;
585
+ listPredictions.appendChild(row);
586
+ }
587
+ }
588
+
589
+ // ─────────────────────────────────────────────────────────
590
+ // OCCLUSION SENSITIVITY (HEATMAP) ANALYSIS
591
+ // ─────────────────────────────────────────────────────────
592
+ const heatmapCanvas = document.getElementById('analysis-heatmap-canvas');
593
+ const overlayCanvas = document.getElementById('analysis-overlay-canvas');
594
+
595
+ function syncAnalysisImage() {
596
+ if (!loadedImageElement) {
597
+ // Draw blank/placeholder
598
+ const ctxH = heatmapCanvas.getContext('2d');
599
+ const ctxO = overlayCanvas.getContext('2d');
600
+ ctxH.clearRect(0, 0, heatmapCanvas.width, heatmapCanvas.height);
601
+ ctxO.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
602
+ return;
603
+ }
604
+
605
+ // Draw original image on both canvases
606
+ drawScaledImage(loadedImageElement, overlayCanvas);
607
+ drawScaledImage(loadedImageElement, heatmapCanvas);
608
+ }
609
+
610
+ function drawScaledImage(img, canvas) {
611
+ canvas.width = 300;
612
+ canvas.height = 300;
613
+ const ctx = canvas.getContext('2d');
614
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
615
+ }
616
+
617
+ document.getElementById('btn-analyze').addEventListener('click', async () => {
618
+ if (!loadedImageElement || !model) return;
619
+
620
+ const btn = document.getElementById('btn-analyze');
621
+ btn.disabled = true;
622
+ btn.innerText = "MENGHITUNG HEATMAP (1-2 Detik)...";
623
+
624
+ // Original prediction
625
+ const baseProbs = tf.tidy(() => {
626
+ let tensor = tf.browser.fromPixels(loadedImageElement).resizeBilinear(INPUT_SIZE).toFloat().div(255.0).expandDims(0);
627
+ return model.predict(tensor).dataSync();
628
+ });
629
+
630
+ const topClass = Array.from(baseProbs).reduce((maxIdx, val, idx, arr) => val > arr[maxIdx] ? idx : maxIdx, 0);
631
+ const baseConf = baseProbs[topClass];
632
+
633
+ const gridSize = 8;
634
+ const patchSize = 224 / gridSize;
635
+ const sensitivityGrid = Array(gridSize).fill().map(() => Array(gridSize).fill(0));
636
+
637
+ // Convert image to a 224x224 Float32 Array
638
+ const originalTensor = tf.tidy(() => {
639
+ return tf.browser.fromPixels(loadedImageElement).resizeBilinear(INPUT_SIZE).toFloat().div(255.0);
640
+ });
641
+ const imgArray = await originalTensor.array();
642
+ originalTensor.dispose();
643
+
644
+ let maxDrop = 0.0001;
645
+
646
+ // Scan grid cells
647
+ for (let i = 0; i < gridSize; i++) {
648
+ for (let j = 0; j < gridSize; j++) {
649
+ // Create occluded image array
650
+ const occluded = JSON.parse(JSON.stringify(imgArray));
651
+
652
+ const yStart = Math.round(i * patchSize);
653
+ const yEnd = Math.round((i + 1) * patchSize);
654
+ const xStart = Math.round(j * patchSize);
655
+ const xEnd = Math.round((j + 1) * patchSize);
656
+
657
+ for (let y = yStart; y < yEnd; y++) {
658
+ for (let x = xStart; x < xEnd; x++) {
659
+ if (occluded[y] && occluded[y][x]) {
660
+ occluded[y][x] = [0.5, 0.5, 0.5]; // gray block
661
+ }
662
+ }
663
+ }
664
+
665
+ // Evaluate occluded image
666
+ const occProbs = tf.tidy(() => {
667
+ const t = tf.tensor3d(occluded).expandDims(0);
668
+ return model.predict(t).dataSync();
669
+ });
670
+
671
+ // Drop in target class confidence
672
+ const drop = Math.max(0, baseConf - occProbs[topClass]);
673
+ sensitivityGrid[i][j] = drop;
674
+ if (drop > maxDrop) maxDrop = drop;
675
+ }
676
+ }
677
+
678
+ // Draw Heatmap (WASM Jet-colormap overlay)
679
+ heatmapCanvas.width = 224;
680
+ heatmapCanvas.height = 224;
681
+ overlayCanvas.width = 224;
682
+ overlayCanvas.height = 224;
683
+
684
+ const ctxH = heatmapCanvas.getContext('2d');
685
+ const ctxO = overlayCanvas.getContext('2d');
686
+
687
+ // Draw original image on overlay canvas first
688
+ ctxO.drawImage(loadedImageElement, 0, 0, 224, 224);
689
+
690
+ const cellW = 224 / gridSize;
691
+ const cellH = 224 / gridSize;
692
+
693
+ for (let i = 0; i < gridSize; i++) {
694
+ for (let j = 0; j < gridSize; j++) {
695
+ const val = sensitivityGrid[i][j] / maxDrop; // normalized 0 to 1
696
+
697
+ // Jet colormap color mapping
698
+ const r = Math.round(Math.max(0, 255 * (1.5 - Math.abs(val - 1.0) * 4)));
699
+ const g = Math.round(Math.max(0, 255 * (1.5 - Math.abs(val - 0.5) * 4)));
700
+ const b = Math.round(Math.max(0, 255 * (1.5 - Math.abs(val - 0.0) * 4)));
701
+
702
+ // Heatmap Canvas
703
+ ctxH.fillStyle = `rgb(${r}, ${g}, ${b})`;
704
+ ctxH.fillRect(j * cellW, i * cellH, cellW, cellH);
705
+
706
+ // Overlay Canvas (Heatmap with 0.5 opacity overlayed)
707
+ ctxO.fillStyle = `rgba(${r}, ${g}, ${b}, 0.45)`;
708
+ ctxO.fillRect(j * cellW, i * cellH, cellW, cellH);
709
+ }
710
+ }
711
+
712
+ btn.disabled = false;
713
+ btn.innerText = "HITUNG HEATMAP OKLUSI";
714
+ });
715
+
716
+ </script>
717
+ </body>
718
+ </html>
labels.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
+ "ultramilk-fullcrm-200ml",
20
+ "vica-600ml"
21
+ ]
models/mobilenetv4_cbam_quantized.tflite ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4d8309863138a8843087c7e41b22bd0e7eebf1172aa2d84a020f72266fa6d920
3
+ size 3546976