anoderb commited on
Commit
86593c6
·
1 Parent(s): bb42c8b

feat: upgrade SKU scanner UI & add object verification

Browse files
beep.mp3 ADDED
Binary file (25.4 kB). View file
 
index.html CHANGED
@@ -1,747 +1,1369 @@
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
- <!-- ONNX Runtime Web -->
14
- <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
15
-
16
- <style>
17
- body {
18
- font-family: 'Plus Jakarta Sans', sans-serif;
19
- background: radial-gradient(circle at top right, #1e1b4b 0%, #09090b 100%);
20
- color: #f4f4f5;
21
- }
22
- .glass {
23
- background: rgba(24, 24, 27, 0.7);
24
- backdrop-filter: blur(16px);
25
- -webkit-backdrop-filter: blur(16px);
26
- border: 1px rgba(255, 255, 255, 0.08) solid;
27
- }
28
- .gradient-text {
29
- background: linear-gradient(135deg, #a78bfa 0%, #6366f1 100%);
30
- -webkit-background-clip: text;
31
- -webkit-text-fill-color: transparent;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
- .progress-bar-fill {
34
- transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
35
- }
36
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  </head>
38
- <body class="min-h-screen flex flex-col antialiased">
39
-
40
- <!-- Header -->
41
- <header class="glass sticky top-0 z-40 border-b border-white/5 py-4 px-6">
42
- <div class="max-w-6xl mx-auto flex items-center justify-between">
43
- <div class="flex items-center gap-3">
44
- <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">
45
- <i class="fa-solid fa-basket-shopping text-white text-lg"></i>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  </div>
47
- <div>
48
- <h1 class="text-xl font-bold tracking-tight">Tokiva <span class="gradient-text">Detector</span></h1>
49
- <p class="text-xs text-zinc-400">Model Tester — Client Side Engine</p>
 
 
 
50
  </div>
51
  </div>
52
- <div class="flex items-center gap-2">
53
- <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">
54
- <span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
55
- ONNX Runtime Web
56
- </span>
 
 
 
 
 
 
 
 
57
  </div>
58
- </div>
59
- </header>
60
 
61
- <!-- Main Container -->
62
- <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">
63
-
64
- <!-- Left Column: Tabs & Inputs -->
65
- <div class="lg:col-span-7 flex flex-col gap-6">
66
-
67
- <!-- Model Loader Progress -->
68
- <div id="loader-card" class="glass rounded-2xl p-6 flex flex-col gap-4">
69
- <div class="flex items-center justify-between">
70
- <h3 class="font-semibold text-sm tracking-wide uppercase text-zinc-400">Loading AI Model...</h3>
71
- <span id="loader-percentage" class="text-indigo-400 font-bold text-sm">0%</span>
72
- </div>
73
- <div class="w-full bg-white/5 h-2 rounded-full overflow-hidden">
74
- <div id="loader-bar" class="bg-gradient-to-r from-purple-500 to-indigo-500 h-full w-0 progress-bar-fill"></div>
75
- </div>
76
- <p id="loader-status" class="text-xs text-zinc-400 italic">Mendownload model ONNX (13 MB)...</p>
77
  </div>
78
 
79
- <!-- Main Tabs (Hidden during loading) -->
80
- <div id="main-interface" class="hidden flex flex-col gap-6">
81
-
82
- <!-- Tab Switches -->
83
- <div class="flex border-b border-white/5">
84
- <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')">
85
- <i class="fa-solid fa-camera"></i> 📸 Webcam
86
- </button>
87
- <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')">
88
- <i class="fa-solid fa-cloud-arrow-up"></i> 🖼️ Upload Foto
89
- </button>
90
- <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')">
91
- <i class="fa-solid fa-chart-simple"></i> 🔬 Analisis Detail
92
- </button>
93
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
- <!-- Tab 1: Webcam -->
96
- <div id="tab-webcam" class="tab-content flex flex-col gap-4">
97
- <div class="relative aspect-video rounded-2xl overflow-hidden bg-black/40 border border-white/5 shadow-inner">
98
- <video id="webcam-video" autoplay playsinline muted class="w-full h-full object-cover transform scale-x-[-1]"></video>
99
- <canvas id="webcam-canvas" class="hidden absolute top-0 left-0 w-full h-full object-cover"></canvas>
100
-
101
- <!-- Camera Status overlay -->
102
- <div id="camera-loading" class="absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/80 gap-3">
103
- <i class="fa-solid fa-circle-notch animate-spin text-indigo-500 text-3xl"></i>
104
- <span class="text-sm text-zinc-400">Menghubungkan kamera...</span>
105
- </div>
106
- </div>
107
-
108
- <div class="flex gap-3">
109
- <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">
110
- <i class="fa-solid fa-magnifying-glass"></i> SCAN SEKARANG
111
- </button>
112
- <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">
113
- <i class="fa-solid fa-arrows-rotate"></i>
114
- </button>
115
  </div>
116
  </div>
117
 
118
- <!-- Tab 2: Upload File -->
119
- <div id="tab-upload" class="tab-content hidden flex flex-col gap-4">
120
- <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">
121
- <input type="file" id="file-input" accept="image/*" class="absolute inset-0 opacity-0 cursor-pointer">
122
- <div id="upload-placeholder" class="text-center flex flex-col items-center gap-2">
123
- <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">
124
- <i class="fa-solid fa-image text-xl"></i>
125
- </div>
126
- <p class="font-medium text-sm">Drag & Drop foto produk atau <span class="text-indigo-400 group-hover:underline">pilih file</span></p>
127
- <p class="text-xs text-zinc-500">Mendukung JPEG, PNG hingga 10MB</p>
128
- </div>
129
- <img id="upload-preview" class="hidden max-h-full max-w-full rounded-lg object-contain">
130
- </div>
131
  </div>
132
 
133
- <!-- Tab 3: Detailed Analysis -->
134
- <div id="tab-analysis" class="tab-content hidden flex flex-col gap-4">
135
- <div class="grid grid-cols-2 gap-4">
136
- <div class="flex flex-col gap-2">
137
- <span class="text-xs font-semibold uppercase tracking-wider text-zinc-400">Original & Heatmap Overlay</span>
138
- <div class="relative aspect-square rounded-xl overflow-hidden bg-black/20 border border-white/5">
139
- <canvas id="analysis-overlay-canvas" class="w-full h-full object-contain"></canvas>
140
- </div>
141
- </div>
142
- <div class="flex flex-col gap-2">
143
- <span class="text-xs font-semibold uppercase tracking-wider text-zinc-400">Heatmap Sensitivitas</span>
144
- <div class="relative aspect-square rounded-xl overflow-hidden bg-black/20 border border-white/5">
145
- <canvas id="analysis-heatmap-canvas" class="w-full h-full object-contain"></canvas>
146
- </div>
147
- </div>
148
- </div>
149
- <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">
150
- <i class="fa-solid fa-microscope"></i> HITUNG HEATMAP OKLUSI
151
- </button>
152
- <div class="glass rounded-xl p-4 text-xs text-zinc-400 leading-relaxed flex gap-3">
153
- <i class="fa-solid fa-circle-info text-indigo-400 text-sm mt-0.5"></i>
154
- <p>
155
- <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.
156
- </p>
157
- </div>
158
  </div>
 
159
 
 
 
160
  </div>
161
  </div>
162
 
163
- <!-- Right Column: Results & Info -->
164
- <div class="lg:col-span-5 flex flex-col gap-6">
165
-
166
- <!-- Prediction Results -->
167
- <div class="glass rounded-2xl p-6 flex flex-col gap-4 flex-grow">
168
- <h3 class="font-bold text-lg tracking-tight flex items-center justify-between border-b border-white/5 pb-3">
169
- <span>🎯 Hasil Deteksi</span>
170
- <span id="inference-time" class="text-xs text-zinc-500 font-normal">Inference: -- ms</span>
171
- </h3>
172
-
173
- <!-- Default message before detection -->
174
- <div id="results-placeholder" class="flex flex-col items-center justify-center py-20 text-center text-zinc-500 gap-3">
175
- <i class="fa-solid fa-box-open text-4xl text-zinc-600"></i>
176
- <p class="text-sm">Silakan lakukan scan atau upload foto untuk melihat prediksi</p>
177
- </div>
178
-
179
- <!-- Realtime / Static Prediction list -->
180
- <div id="results-container" class="hidden flex flex-col gap-4">
181
- <!-- Main Prediction Badge -->
182
- <div class="p-4 rounded-xl border border-indigo-500/20 bg-indigo-500/5 flex items-center justify-between">
183
- <div class="flex items-center gap-3">
184
- <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">
185
- ✅
186
- </div>
187
- <div>
188
- <div id="top-display-name" class="font-bold text-zinc-100">Nama Produk</div>
189
- <div id="top-class-name" class="text-xs text-zinc-500 font-mono">class-name</div>
190
- </div>
191
- </div>
192
- <div id="top-confidence-value" class="text-lg font-extrabold text-emerald-400">0.0%</div>
193
- </div>
194
 
195
- <!-- Predictions List -->
196
- <div class="flex flex-col gap-3">
197
- <span class="text-xs font-semibold uppercase tracking-wider text-zinc-500">Alternatif Prediksi</span>
198
- <div id="list-predictions" class="flex flex-col gap-2.5">
199
- <!-- Dynamically populated rows -->
200
- </div>
201
- </div>
202
- </div>
203
  </div>
204
 
205
- <!-- Info Model -->
206
- <div class="glass rounded-2xl p-6 flex flex-col gap-4">
207
- <h3 class="font-bold text-sm tracking-wide uppercase text-zinc-400 flex items-center gap-2">
208
- <i class="fa-solid fa-info-circle text-indigo-400"></i> Informasi Model
209
- </h3>
210
- <div class="grid grid-cols-2 gap-3 text-xs">
211
- <div class="p-3 bg-white/[0.02] rounded-xl border border-white/5">
212
- <span class="text-zinc-500 block mb-0.5">Nama Model</span>
213
- <span class="font-semibold text-zinc-200">MobileNetV4 + CBAM</span>
214
- </div>
215
- <div class="p-3 bg-white/[0.02] rounded-xl border border-white/5">
216
- <span class="text-zinc-500 block mb-0.5">Akurasi</span>
217
- <span class="font-semibold text-emerald-400">98.77%</span>
218
- </div>
219
- <div class="p-3 bg-white/[0.02] rounded-xl border border-white/5">
220
- <span class="text-zinc-500 block mb-0.5">Format</span>
221
- <span class="font-semibold text-zinc-200">ONNX Graph Model</span>
222
- </div>
223
- <div class="p-3 bg-white/[0.02] rounded-xl border border-white/5">
224
- <span class="text-zinc-500 block mb-0.5">Size</span>
225
- <span class="font-semibold text-zinc-200">12.4 MB</span>
226
- </div>
227
- </div>
228
  </div>
229
 
 
 
 
 
230
  </div>
231
- </main>
232
-
233
- <!-- Footer -->
234
- <footer class="mt-auto py-6 border-t border-white/5 px-6 text-center text-xs text-zinc-500">
235
- <p>&copy; 2026 Tokiva Team. Dibuat menggunakan ONNX Runtime Web engine.</p>
236
- </footer>
237
-
238
- <script type="module">
239
- // ─────────────────────���───────────────────────────────────
240
- // MODEL CONFIG & MAPS
241
- // ─────────────────────────────────────────────────────────
242
- const MODEL_PATH = 'models/mobilenetv4_cbam.onnx';
243
-
244
- // 19 Class Labels ordered alphabetically (matching indices from training)
245
- const CLASS_NAMES = [
246
- "frisian-flag-fullcrm-250ml",
247
- "frisian-flag-strwbry-250ml",
248
- "gaga-100-grg-jalapeto",
249
- "gaga-100-kuah-jalapeto",
250
- "indomie-grg-cb-ijo",
251
- "indomie-grg-cb-ijo-jumbo",
252
- "klik-crackers-keju",
253
- "nabati-siip-keju",
254
- "pepsodent-72g",
255
- "sarimi-aym-bwng",
256
- "sarimi-gls-baso-pds",
257
- "sedaap-grng",
258
- "sedaap-kuah-aym-bwg",
259
- "sedaap-sg-laksa",
260
- "siplah-mineral-220ml",
261
- "soffell-bunga",
262
- "soffell-jeruk",
263
- "ultramilk-fullcrm-200ml",
264
- "vica-600ml"
265
- ];
266
-
267
- const DISPLAY_NAMES = {
268
- "frisian-flag-fullcrm-250ml": "Frisian Flag Full Cream 250ml",
269
- "frisian-flag-strwbry-250ml": "Frisian Flag Strawberry 250ml",
270
- "gaga-100-grg-jalapeto": "Gaga 100 Goreng Jalapeno",
271
- "gaga-100-kuah-jalapeto": "Gaga 100 Kuah Jalapeno",
272
- "indomie-grg-cb-ijo": "Indomie Goreng Cabe Ijo",
273
- "indomie-grg-cb-ijo-jumbo": "Indomie Goreng Cabe Ijo Jumbo",
274
- "klik-crackers-keju": "Klik Crackers Keju",
275
- "nabati-siip-keju": "Nabati Siip Keju",
276
- "pepsodent-72g": "Pepsodent 72g",
277
- "sarimi-aym-bwng": "Sarimi Ayam Bawang",
278
- "sarimi-gls-baso-pds": "Sarimi Gelas Bakso Pedas",
279
- "sedaap-grng": "Sedaap Goreng",
280
- "sedaap-kuah-aym-bwg": "Sedaap Kuah Ayam Bawang",
281
- "sedaap-sg-laksa": "Sedaap Singapore Laksa",
282
- "siplah-mineral-220ml": "Siplah Mineral 220ml",
283
- "soffell-bunga": "Soffell Bunga",
284
- "soffell-jeruk": "Soffell Jeruk",
285
- "ultramilk-fullcrm-200ml": "Ultra Milk Full Cream 200ml",
286
- "vica-600ml": "Vica 600ml"
287
- };
288
-
289
- // ─────────────────────────────────────────────────────────
290
- // GLOBAL STATE
291
- // ─────────────────────────────────────────────────────────
292
- let session = null;
293
- let currentTab = 'webcam';
294
- let currentStream = null;
295
- let activeFacingMode = 'environment'; // environment / user
296
- let loadedImageElement = null; // caching image element for analysis tab
297
-
298
- // DOM Elements
299
- const loaderCard = document.getElementById('loader-card');
300
- const loaderPercentage = document.getElementById('loader-percentage');
301
- const loaderBar = document.getElementById('loader-bar');
302
- const loaderStatus = document.getElementById('loader-status');
303
-
304
- const mainInterface = document.getElementById('main-interface');
305
- const video = document.getElementById('webcam-video');
306
- const webcamCanvas = document.getElementById('webcam-canvas');
307
- const cameraLoading = document.getElementById('camera-loading');
308
-
309
- const dropZone = document.getElementById('drop-zone');
310
- const fileInput = document.getElementById('file-input');
311
- const uploadPlaceholder = document.getElementById('upload-placeholder');
312
- const uploadPreview = document.getElementById('upload-preview');
313
-
314
- const resultsPlaceholder = document.getElementById('results-placeholder');
315
- const resultsContainer = document.getElementById('results-container');
316
- const topBadgeEmoji = document.getElementById('top-badge-emoji');
317
- const topDisplayName = document.getElementById('top-display-name');
318
- const topClassName = document.getElementById('top-class-name');
319
- const topConfidenceValue = document.getElementById('top-confidence-value');
320
- const listPredictions = document.getElementById('list-predictions');
321
- const inferenceTimeDisplay = document.getElementById('inference-time');
322
-
323
- // ─────────────────────────────────────────────────────────
324
- // INIT & MODEL LOAD
325
- // ─────────────────────────────────────────────────────────
326
- async function initModel() {
327
- try {
328
- loaderBar.style.width = '20%';
329
- loaderPercentage.innerText = '20%';
330
- loaderStatus.innerText = 'Menginisialisasi ONNX Runtime...';
331
-
332
- // Warm up / Configure session options
333
- const sessionOptions = {
334
- executionProviders: ['wasm']
335
- };
336
-
337
- loaderBar.style.width = '50%';
338
- loaderPercentage.innerText = '50%';
339
- loaderStatus.innerText = 'Mendownload model ONNX (13 MB)...';
340
-
341
- // Load the model
342
- session = await ort.InferenceSession.create(MODEL_PATH, sessionOptions);
343
-
344
- loaderBar.style.width = '100%';
345
- loaderPercentage.innerText = '100%';
346
- loaderStatus.innerText = 'Model sukses dimuat!';
347
-
348
- setTimeout(() => {
349
- loaderCard.classList.add('hidden');
350
- mainInterface.classList.remove('hidden');
351
- // Start webcam by default
352
- startWebcam();
353
- }, 600);
354
- } catch (err) {
355
- console.error("Gagal load model:", err);
356
- loaderStatus.innerText = 'Gagal memuat model: ' + err.message;
357
- loaderBar.classList.add('bg-red-500');
358
- }
359
- }
360
-
361
- // Initialize model load
362
- initModel();
363
-
364
- // Export tab switching to global scope for HTML onclick bindings
365
- window.switchTab = switchTab;
366
-
367
- // ─────────────────────────────────────────────────────────
368
- // TAB MANAGEMENT
369
- // ─────────────────────────────────────────────────────────
370
- function switchTab(tabId) {
371
- currentTab = tabId;
372
-
373
- // Update tab buttons style
374
- const tabs = ['webcam', 'upload', 'analysis'];
375
- tabs.forEach(t => {
376
- const btn = document.getElementById(`tab-${t}-btn`);
377
- const content = document.getElementById(`tab-${t}`);
378
- if (t === tabId) {
379
- 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";
380
- content.classList.remove('hidden');
381
- } else {
382
- 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";
383
- content.classList.add('hidden');
384
- }
385
- });
386
-
387
- // Stream control
388
- if (tabId === 'webcam') {
389
- startWebcam();
390
- } else {
391
- stopWebcam();
392
- }
393
 
394
- // If switching to analysis, sync with active image
395
- if (tabId === 'analysis') {
396
- syncAnalysisImage();
397
- }
398
- }
399
-
400
- // ─────────────────────────────────────────────────────────
401
- // WEBCAM MANAGEMENT
402
- // ─────────────────────────────────────────────────────────
403
- async function startWebcam() {
404
- stopWebcam();
405
- cameraLoading.classList.remove('hidden');
406
- try {
407
- const constraints = {
408
- video: {
409
- facingMode: activeFacingMode,
410
- width: { ideal: 640 },
411
- height: { ideal: 480 }
412
- },
413
- audio: false
414
- };
415
- currentStream = await navigator.mediaDevices.getUserMedia(constraints);
416
- video.srcObject = currentStream;
417
- video.onloadedmetadata = () => {
418
- cameraLoading.classList.add('hidden');
419
- };
420
- } catch (err) {
421
- console.error("Gagal akses kamera:", err);
422
- cameraLoading.innerHTML = `
423
- <i class="fa-solid fa-triangle-exclamation text-red-500 text-3xl"></i>
424
- <span class="text-sm text-red-400 text-center px-4">Gagal mengakses kamera. Berikan izin kamera atau gunakan tab Upload Foto.</span>
425
- `;
426
- }
427
- }
428
-
429
- function stopWebcam() {
430
- if (currentStream) {
431
- currentStream.getTracks().forEach(track => track.stop());
432
- currentStream = null;
433
- }
434
- }
435
-
436
- document.getElementById('btn-switch-camera').addEventListener('click', () => {
437
- activeFacingMode = activeFacingMode === 'environment' ? 'user' : 'environment';
438
- startWebcam();
439
- });
440
-
441
- // ─────────────────────────────────────────────────────────
442
- // UPLOAD / DROPZONE MANAGEMENT
443
- // ─────────────────────────────────────────────────────────
444
- dropZone.addEventListener('dragover', (e) => {
445
- e.preventDefault();
446
- dropZone.classList.add('border-indigo-500', 'bg-indigo-500/5');
447
- });
448
-
449
- dropZone.addEventListener('dragleave', () => {
450
- dropZone.classList.remove('border-indigo-500', 'bg-indigo-500/5');
451
- });
452
-
453
- dropZone.addEventListener('drop', (e) => {
454
- e.preventDefault();
455
- dropZone.classList.remove('border-indigo-500', 'bg-indigo-500/5');
456
- const files = e.dataTransfer.files;
457
- if (files.length > 0) {
458
- handleFile(files[0]);
459
- }
460
- });
461
 
462
- fileInput.addEventListener('change', (e) => {
463
- if (e.target.files.length > 0) {
464
- handleFile(e.target.files[0]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  }
466
  });
467
-
468
- function handleFile(file) {
469
- if (!file.type.startsWith('image/')) return;
470
- const reader = new FileReader();
471
- reader.onload = (e) => {
472
- uploadPlaceholder.classList.add('hidden');
473
- uploadPreview.src = e.target.result;
474
- uploadPreview.classList.remove('hidden');
475
-
476
- // Save image representation for prediction & analysis
477
- const img = new Image();
478
- img.src = e.target.result;
479
- img.onload = () => {
480
- loadedImageElement = img;
481
- runPrediction(img);
482
- };
483
- };
484
- reader.readAsDataURL(file);
485
- }
486
-
487
- // ─────────────────────────────────────────────────────────
488
- // PREPROCESSING HELPER
489
- // ─────────────────────────────────────────────────────────
490
- function preprocessImage(imageOrVideo) {
491
- // 1. Draw onto 224x224 canvas to resize
492
- const canvas = document.createElement('canvas');
493
- canvas.width = 224;
494
- canvas.height = 224;
495
- const ctx = canvas.getContext('2d');
496
- ctx.drawImage(imageOrVideo, 0, 0, 224, 224);
497
-
498
- // 2. Extract pixel data
499
- const imgData = ctx.getImageData(0, 0, 224, 224);
500
- const data = imgData.data;
501
-
502
- // 3. Normalize pixels from [0, 255] to [0, 1] as Float32
503
- const floatData = new Float32Array(224 * 224 * 3);
504
- let floatIdx = 0;
505
- for (let i = 0; i < data.length; i += 4) {
506
- floatData[floatIdx++] = data[i] / 255.0; // R
507
- floatData[floatIdx++] = data[i + 1] / 255.0; // G
508
- floatData[floatIdx++] = data[i + 2] / 255.0; // B
509
- }
510
- return floatData;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  }
512
 
513
- // ─────────────────────────────────────────────────────────
514
- // CORE PREDICTION & INFERENCE
515
- // ─────────────────────────────────────────────────────────
516
- async function runPrediction(imageOrVideo) {
517
- if (!session) return;
518
-
519
- const startTime = performance.now();
520
-
521
- // Perform client side inference using ONNX Runtime
522
- const floatData = preprocessImage(imageOrVideo);
523
- const inputTensor = new ort.Tensor('float32', floatData, [1, 224, 224, 3]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
 
525
- // Bind input names from model signature
526
- const feeds = {};
527
- feeds[session.inputNames[0]] = inputTensor;
528
-
529
- // Run session
530
- const outputMap = await session.run(feeds);
531
- const outputTensor = outputMap[session.outputNames[0]];
532
- const predictions = outputTensor.data; // Float32Array size 19
533
-
534
- const elapsed = (performance.now() - startTime).toFixed(1);
535
- inferenceTimeDisplay.innerText = `Inference: ${elapsed} ms`;
536
-
537
- // Find top results
538
- const results = Array.from(predictions).map((prob, idx) => ({
539
- prob: prob,
540
- class_name: CLASS_NAMES[idx],
541
- display_name: DISPLAY_NAMES[CLASS_NAMES[idx]] || CLASS_NAMES[idx],
542
- index: idx
543
- })).sort((a, b) => b.prob - a.prob);
544
-
545
- renderResults(results);
546
- }
547
 
548
- // Capture frame from live webcam
549
- document.getElementById('btn-scan').addEventListener('click', () => {
550
- if (!video.srcObject) return;
551
-
552
- const ctx = webcamCanvas.getContext('2d');
553
- webcamCanvas.width = video.videoWidth;
554
- webcamCanvas.height = video.videoHeight;
555
-
556
- // Draw camera frame mirroring-free
557
- ctx.drawImage(video, 0, 0, webcamCanvas.width, webcamCanvas.height);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
 
559
- // Save canvas image as active element
560
- const img = new Image();
561
- img.src = webcamCanvas.toDataURL('image/png');
562
- img.onload = () => {
563
- loadedImageElement = img;
564
- runPrediction(img);
565
- };
566
- });
567
-
568
- // ─────────────────────────────────────────────────────────
569
- // RENDER RESULTS
570
- // ─────────────────────────────────────────────────────────
571
- function renderResults(results) {
572
- resultsPlaceholder.classList.add('hidden');
573
- resultsContainer.classList.remove('hidden');
574
 
575
- const topResult = results[0];
576
- const topPct = (topResult.prob * 100).toFixed(1);
577
-
578
- // Top badge
579
- if (topResult.prob >= 0.85) {
580
- topBadgeEmoji.innerText = "✅";
581
- topBadgeEmoji.className = "w-10 h-10 rounded-lg bg-emerald-500/10 text-emerald-400 flex items-center justify-center text-lg font-bold";
582
- topConfidenceValue.className = "text-lg font-extrabold text-emerald-400";
583
- } else if (topResult.prob >= 0.50) {
584
- topBadgeEmoji.innerText = "⚠️";
585
- topBadgeEmoji.className = "w-10 h-10 rounded-lg bg-amber-500/10 text-amber-400 flex items-center justify-center text-lg font-bold";
586
- topConfidenceValue.className = "text-lg font-extrabold text-amber-400";
587
- } else {
588
- topBadgeEmoji.innerText = "❌";
589
- topBadgeEmoji.className = "w-10 h-10 rounded-lg bg-red-500/10 text-red-400 flex items-center justify-center text-lg font-bold";
590
- topConfidenceValue.className = "text-lg font-extrabold text-red-400";
591
- }
592
-
593
- topDisplayName.innerText = topResult.display_name;
594
- topClassName.innerText = topResult.class_name;
595
- topConfidenceValue.innerText = `${topPct}%`;
596
-
597
- // Alternative list
598
- listPredictions.innerHTML = '';
599
- const limit = Math.min(5, results.length);
600
- for (let i = 1; i < limit; i++) {
601
- const item = results[i];
602
- const itemPct = (item.prob * 100).toFixed(1);
603
- const row = document.createElement('div');
604
- row.className = "flex flex-col gap-1.5";
605
-
606
- row.innerHTML = `
607
- <div class="flex items-center justify-between text-xs">
608
- <span class="font-medium text-zinc-300">${item.display_name}</span>
609
- <span class="text-zinc-500 font-bold">${itemPct}%</span>
610
- </div>
611
- <div class="w-full bg-white/5 h-1.5 rounded-full overflow-hidden">
612
- <div class="bg-indigo-500 h-full progress-bar-fill" style="width: ${itemPct}%"></div>
613
- </div>
614
- `;
615
- listPredictions.appendChild(row);
616
- }
617
- }
618
-
619
- // ─────────────────────────────────────────────────────────
620
- // OCCLUSION SENSITIVITY (HEATMAP) ANALYSIS
621
- // ─────────────────────────���───────────────────────────────
622
- const heatmapCanvas = document.getElementById('analysis-heatmap-canvas');
623
- const overlayCanvas = document.getElementById('analysis-overlay-canvas');
624
-
625
- function syncAnalysisImage() {
626
- if (!loadedImageElement) {
627
- // Draw blank/placeholder
628
- const ctxH = heatmapCanvas.getContext('2d');
629
- const ctxO = overlayCanvas.getContext('2d');
630
- ctxH.clearRect(0, 0, heatmapCanvas.width, heatmapCanvas.height);
631
- ctxO.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
632
- return;
633
- }
634
-
635
- // Draw original image on both canvases
636
- drawScaledImage(loadedImageElement, overlayCanvas);
637
- drawScaledImage(loadedImageElement, heatmapCanvas);
638
  }
639
-
640
- function drawScaledImage(img, canvas) {
641
- canvas.width = 300;
642
- canvas.height = 300;
643
- const ctx = canvas.getContext('2d');
644
- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
 
 
 
 
 
 
 
 
 
645
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
 
647
- document.getElementById('btn-analyze').addEventListener('click', async () => {
648
- if (!loadedImageElement || !session) return;
649
-
650
- const btn = document.getElementById('btn-analyze');
651
- btn.disabled = true;
652
- btn.innerText = "MENGHITUNG HEATMAP (1-2 Detik)...";
653
-
654
- // Get base prediction
655
- const floatData = preprocessImage(loadedImageElement);
656
- const inputTensor = new ort.Tensor('float32', floatData, [1, 224, 224, 3]);
657
- const feeds = {};
658
- feeds[session.inputNames[0]] = inputTensor;
659
-
660
- const baseResults = await session.run(feeds);
661
- const baseProbs = baseResults[session.outputNames[0]].data;
662
-
663
- const topClassIndex = Array.from(baseProbs).reduce((maxIdx, val, idx, arr) => val > arr[maxIdx] ? idx : maxIdx, 0);
664
- const baseConf = baseProbs[topClassIndex];
665
-
666
- const gridSize = 8;
667
- const patchSize = 224 / gridSize;
668
- const sensitivityGrid = Array(gridSize).fill().map(() => Array(gridSize).fill(0));
669
-
670
- // We will perform grid occlusion directly on a helper canvas to get image fragments
671
- const tempCanvas = document.createElement('canvas');
672
- tempCanvas.width = 224;
673
- tempCanvas.height = 224;
674
- const tempCtx = tempCanvas.getContext('2d');
675
-
676
- let maxDrop = 0.0001;
677
-
678
- // Scan grid cells
679
- for (let i = 0; i < gridSize; i++) {
680
- for (let j = 0; j < gridSize; j++) {
681
- // Draw original image resized to 224x224
682
- tempCtx.drawImage(loadedImageElement, 0, 0, 224, 224);
683
-
684
- // Draw grey occlusion block
685
- tempCtx.fillStyle = 'rgb(128, 128, 128)';
686
- const yStart = i * patchSize;
687
- const xStart = j * patchSize;
688
- tempCtx.fillRect(xStart, yStart, patchSize, patchSize);
689
-
690
- // Classify the occluded image
691
- const occFloatData = preprocessImage(tempCanvas);
692
- const occTensor = new ort.Tensor('float32', occFloatData, [1, 224, 224, 3]);
693
- const occFeeds = {};
694
- occFeeds[session.inputNames[0]] = occTensor;
695
- const occResults = await session.run(occFeeds);
696
- const occProbs = occResults[session.outputNames[0]].data;
697
-
698
- const occConf = occProbs[topClassIndex];
699
-
700
- // Drop in target class confidence
701
- const drop = Math.max(0, baseConf - occConf);
702
- sensitivityGrid[i][j] = drop;
703
- if (drop > maxDrop) maxDrop = drop;
704
- }
705
- }
706
-
707
- // Draw Heatmap (Jet-colormap overlay)
708
- heatmapCanvas.width = 224;
709
- heatmapCanvas.height = 224;
710
- overlayCanvas.width = 224;
711
- overlayCanvas.height = 224;
712
-
713
- const ctxH = heatmapCanvas.getContext('2d');
714
- const ctxO = overlayCanvas.getContext('2d');
715
-
716
- // Draw original image on overlay canvas first
717
- ctxO.drawImage(loadedImageElement, 0, 0, 224, 224);
718
-
719
- const cellW = 224 / gridSize;
720
- const cellH = 224 / gridSize;
721
-
722
- for (let i = 0; i < gridSize; i++) {
723
- for (let j = 0; j < gridSize; j++) {
724
- const val = sensitivityGrid[i][j] / maxDrop; // normalized 0 to 1
725
-
726
- // Jet colormap color mapping
727
- const r = Math.round(Math.max(0, 255 * (1.5 - Math.abs(val - 1.0) * 4)));
728
- const g = Math.round(Math.max(0, 255 * (1.5 - Math.abs(val - 0.5) * 4)));
729
- const b = Math.round(Math.max(0, 255 * (1.5 - Math.abs(val - 0.0) * 4)));
730
-
731
- // Heatmap Canvas
732
- ctxH.fillStyle = `rgb(${r}, ${g}, ${b})`;
733
- ctxH.fillRect(j * cellW, i * cellH, cellW, cellH);
734
-
735
- // Overlay Canvas (Heatmap overlayed)
736
- ctxO.fillStyle = `rgba(${r}, ${g}, ${b}, 0.45)`;
737
- ctxO.fillRect(j * cellW, i * cellH, cellW, cellH);
738
- }
739
- }
740
-
741
- btn.disabled = false;
742
- btn.innerText = "HITUNG HEATMAP OKLUSI";
743
- });
744
-
745
- </script>
746
  </body>
747
  </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">
6
+ <title>Tokiva SKU Scanner — POS Kasir Pintar</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
11
+ <style>
12
+ :root {
13
+ --bg-dark: #07080e;
14
+ --bg-card: rgba(16, 18, 27, 0.7);
15
+ --border-card: rgba(255, 255, 255, 0.07);
16
+ --primary: #3b82f6;
17
+ --primary-glow: rgba(59, 130, 246, 0.35);
18
+ --success: #10b981;
19
+ --success-glow: rgba(16, 185, 129, 0.35);
20
+ --warning: #fbbf24;
21
+ --danger: #ef4444;
22
+ --text-main: #f3f4f6;
23
+ --text-muted: #9ca3af;
24
+ --font-family: 'Outfit', -apple-system, sans-serif;
25
+ }
26
+
27
+ * {
28
+ box-sizing: border-box;
29
+ margin: 0;
30
+ padding: 0;
31
+ }
32
+
33
+ body {
34
+ font-family: var(--font-family);
35
+ background-color: var(--bg-dark);
36
+ background-image:
37
+ radial-gradient(circle at top right, rgba(59, 130, 246, 0.1), transparent 450px),
38
+ radial-gradient(circle at bottom left, rgba(16, 185, 129, 0.08), transparent 450px);
39
+ background-attachment: fixed;
40
+ color: var(--text-main);
41
+ padding: 24px;
42
+ min-height: 100vh;
43
+ display: flex;
44
+ flex-direction: column;
45
+ justify-content: flex-start;
46
+ align-items: center;
47
+ }
48
+
49
+ /* Header Section */
50
+ header {
51
+ width: 100%;
52
+ max-width: 1200px;
53
+ margin-bottom: 24px;
54
+ display: flex;
55
+ justify-content: space-between;
56
+ align-items: center;
57
+ padding-bottom: 16px;
58
+ border-bottom: 1px solid var(--border-card);
59
+ }
60
+
61
+ .logo-container {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 10px;
65
+ }
66
+
67
+ .logo-icon {
68
+ font-size: 28px;
69
+ background: linear-gradient(135deg, var(--primary), var(--success));
70
+ -webkit-background-clip: text;
71
+ -webkit-text-fill-color: transparent;
72
+ font-weight: 800;
73
+ }
74
+
75
+ .logo-title {
76
+ font-size: 22px;
77
+ font-weight: 700;
78
+ letter-spacing: -0.5px;
79
+ }
80
+
81
+ .logo-title span {
82
+ color: var(--success);
83
+ font-weight: 400;
84
+ }
85
+
86
+ .status-badge {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 8px;
90
+ background: rgba(255, 255, 255, 0.03);
91
+ border: 1px solid var(--border-card);
92
+ padding: 6px 14px;
93
+ border-radius: 20px;
94
+ font-size: 13px;
95
+ font-weight: 500;
96
+ }
97
+
98
+ .status-dot {
99
+ width: 8px;
100
+ height: 8px;
101
+ border-radius: 50%;
102
+ background-color: var(--warning);
103
+ box-shadow: 0 0 8px var(--warning);
104
+ }
105
+
106
+ .status-dot.ready {
107
+ background-color: var(--success);
108
+ box-shadow: 0 0 10px var(--success);
109
+ }
110
+
111
+ .status-dot.loading {
112
+ background-color: var(--warning);
113
+ box-shadow: 0 0 10px var(--warning);
114
+ animation: pulse 1.5s infinite alternate;
115
+ }
116
+
117
+ .status-dot.error {
118
+ background-color: var(--danger);
119
+ box-shadow: 0 0 10px var(--danger);
120
+ }
121
+
122
+ /* Main Dashboard Grid */
123
+ .dashboard-grid {
124
+ width: 100%;
125
+ max-width: 1200px;
126
+ display: grid;
127
+ grid-template-columns: 1.2fr 1fr;
128
+ gap: 24px;
129
+ }
130
+
131
+ @media (max-width: 900px) {
132
+ .dashboard-grid {
133
+ grid-template-columns: 1fr;
134
  }
135
+ }
136
+
137
+ /* Glassmorphic Cards */
138
+ .card {
139
+ background: var(--bg-card);
140
+ backdrop-filter: blur(12px);
141
+ -webkit-backdrop-filter: blur(12px);
142
+ border: 1px solid var(--border-card);
143
+ border-radius: 16px;
144
+ padding: 20px;
145
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
146
+ margin-bottom: 24px;
147
+ }
148
+
149
+ .card-title {
150
+ font-size: 16px;
151
+ font-weight: 600;
152
+ margin-bottom: 16px;
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 8px;
156
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
157
+ padding-bottom: 8px;
158
+ }
159
+
160
+ .card-title svg {
161
+ color: var(--primary);
162
+ }
163
+
164
+ /* Left Panel: Camera & Viewport */
165
+ .viewport-container {
166
+ position: relative;
167
+ width: 100%;
168
+ aspect-ratio: 1/1;
169
+ max-height: 400px;
170
+ margin: 0 auto 16px auto;
171
+ border-radius: 12px;
172
+ overflow: hidden;
173
+ background: #000;
174
+ border: 2px solid rgba(255, 255, 255, 0.05);
175
+ box-shadow: inset 0 0 30px rgba(0,0,0,0.8);
176
+ display: flex;
177
+ align-items: center;
178
+ justify-content: center;
179
+ }
180
+
181
+ #video, #previewImg {
182
+ width: 100%;
183
+ height: 100%;
184
+ object-fit: cover;
185
+ border-radius: 10px;
186
+ transition: transform 0.3s ease;
187
+ }
188
+
189
+ #canvas {
190
+ display: none;
191
+ }
192
+
193
+ /* Cyberpunk brackets / scanner decoration */
194
+ .scanner-overlay {
195
+ position: absolute;
196
+ top: 0;
197
+ left: 0;
198
+ width: 100%;
199
+ height: 100%;
200
+ pointer-events: none;
201
+ z-index: 10;
202
+ }
203
+
204
+ .corner-bracket {
205
+ position: absolute;
206
+ width: 24px;
207
+ height: 24px;
208
+ border-color: rgba(255, 255, 255, 0.3);
209
+ border-style: solid;
210
+ transition: border-color 0.3s;
211
+ }
212
+
213
+ .corner-bracket.top-left { top: 16px; left: 16px; border-width: 3px 0 0 3px; border-top-left-radius: 4px; }
214
+ .corner-bracket.top-right { top: 16px; right: 16px; border-width: 3px 3px 0 0; border-top-right-radius: 4px; }
215
+ .corner-bracket.bottom-left { bottom: 16px; left: 16px; border-width: 0 0 3px 3px; border-bottom-left-radius: 4px; }
216
+ .corner-bracket.bottom-right { bottom: 16px; right: 16px; border-width: 0 3px 3px 0; border-bottom-right-radius: 4px; }
217
+
218
+ /* Pulse scan animation when active */
219
+ .scanner-laser {
220
+ position: absolute;
221
+ left: 0;
222
+ right: 0;
223
+ height: 3px;
224
+ background: linear-gradient(to right, transparent, var(--success), transparent);
225
+ box-shadow: 0 0 10px var(--success), 0 0 20px var(--success);
226
+ opacity: 0;
227
+ pointer-events: none;
228
+ z-index: 11;
229
+ }
230
+
231
+ .scanner-active .scanner-laser {
232
+ animation: laser-sweep 2s linear infinite;
233
+ opacity: 1;
234
+ }
235
+
236
+ .scanner-active .corner-bracket {
237
+ border-color: var(--success);
238
+ }
239
+
240
+ /* Flash overlay for successful detection */
241
+ .scanner-flash {
242
+ position: absolute;
243
+ top: 0;
244
+ left: 0;
245
+ width: 100%;
246
+ height: 100%;
247
+ background: var(--success);
248
+ opacity: 0;
249
+ z-index: 12;
250
+ pointer-events: none;
251
+ transition: opacity 0.1s ease-out;
252
+ }
253
+
254
+ .scanner-flash.flash-active {
255
+ opacity: 0.25;
256
+ transition: none;
257
+ }
258
+
259
+ /* Controls and sliders */
260
+ .controls-row {
261
+ display: grid;
262
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
263
+ gap: 10px;
264
+ margin-bottom: 16px;
265
+ }
266
+
267
+ button, .file-btn-label {
268
+ display: inline-flex;
269
+ align-items: center;
270
+ justify-content: center;
271
+ gap: 8px;
272
+ padding: 10px 16px;
273
+ border-radius: 10px;
274
+ font-size: 13.5px;
275
+ font-weight: 600;
276
+ cursor: pointer;
277
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
278
+ color: white;
279
+ border: 1px solid rgba(255, 255, 255, 0.05);
280
+ background: rgba(255, 255, 255, 0.05);
281
+ }
282
+
283
+ button:hover, .file-btn-label:hover {
284
+ background: rgba(255, 255, 255, 0.1);
285
+ transform: translateY(-1px);
286
+ }
287
+
288
+ button:active, .file-btn-label:active {
289
+ transform: translateY(1px);
290
+ }
291
+
292
+ button.btn-primary {
293
+ background: linear-gradient(135deg, var(--primary), #2563eb);
294
+ box-shadow: 0 4px 14px var(--primary-glow);
295
+ }
296
+ button.btn-primary:hover {
297
+ background: linear-gradient(135deg, #2563eb, #1d4ed8);
298
+ box-shadow: 0 6px 18px var(--primary-glow);
299
+ }
300
+
301
+ button.btn-success {
302
+ background: linear-gradient(135deg, var(--success), #059669);
303
+ box-shadow: 0 4px 14px var(--success-glow);
304
+ }
305
+ button.btn-success:hover {
306
+ background: linear-gradient(135deg, #059669, #047857);
307
+ box-shadow: 0 6px 18px var(--success-glow);
308
+ }
309
+
310
+ button.btn-danger {
311
+ background: linear-gradient(135deg, var(--danger), #dc2626);
312
+ box-shadow: 0 4px 14px rgba(239, 68, 68, 0.3);
313
+ }
314
+ button.btn-danger:hover {
315
+ background: linear-gradient(135deg, #dc2626, #b91c1c);
316
+ box-shadow: 0 6px 18px rgba(239, 68, 68, 0.35);
317
+ }
318
+
319
+ button:disabled, .file-btn-label.disabled {
320
+ opacity: 0.4;
321
+ cursor: not-allowed;
322
+ transform: none !important;
323
+ box-shadow: none !important;
324
+ }
325
+
326
+ /* File Input styling */
327
+ input[type="file"] {
328
+ display: none;
329
+ }
330
+
331
+ .upload-zone {
332
+ border: 2px dashed rgba(255, 255, 255, 0.12);
333
+ border-radius: 12px;
334
+ padding: 16px;
335
+ text-align: center;
336
+ background: rgba(255, 255, 255, 0.01);
337
+ transition: all 0.2s;
338
+ cursor: pointer;
339
+ display: flex;
340
+ flex-direction: column;
341
+ align-items: center;
342
+ gap: 6px;
343
+ }
344
+
345
+ .upload-zone:hover, .upload-zone.dragover {
346
+ border-color: var(--primary);
347
+ background: rgba(59, 130, 246, 0.04);
348
+ }
349
+
350
+ .upload-zone svg {
351
+ color: var(--text-muted);
352
+ transition: color 0.2s;
353
+ }
354
+ .upload-zone:hover svg, .upload-zone.dragover svg {
355
+ color: var(--primary);
356
+ }
357
+
358
+ .upload-zone span {
359
+ font-size: 13px;
360
+ font-weight: 500;
361
+ color: var(--text-muted);
362
+ }
363
+
364
+ /* Threshold Control Slider */
365
+ .slider-container {
366
+ background: rgba(255, 255, 255, 0.02);
367
+ border: 1px solid rgba(255, 255, 255, 0.04);
368
+ padding: 12px;
369
+ border-radius: 10px;
370
+ margin-top: 14px;
371
+ }
372
+
373
+ .slider-label-row {
374
+ display: flex;
375
+ justify-content: space-between;
376
+ font-size: 13px;
377
+ font-weight: 500;
378
+ margin-bottom: 8px;
379
+ color: var(--text-muted);
380
+ }
381
+
382
+ .slider-value {
383
+ color: var(--primary);
384
+ font-weight: 700;
385
+ }
386
+
387
+ .range-slider {
388
+ width: 100%;
389
+ -webkit-appearance: none;
390
+ appearance: none;
391
+ height: 6px;
392
+ border-radius: 3px;
393
+ background: rgba(255, 255, 255, 0.1);
394
+ outline: none;
395
+ }
396
+
397
+ .range-slider::-webkit-slider-thumb {
398
+ -webkit-appearance: none;
399
+ appearance: none;
400
+ width: 16px;
401
+ height: 16px;
402
+ border-radius: 50%;
403
+ background: var(--primary);
404
+ cursor: pointer;
405
+ box-shadow: 0 0 8px var(--primary-glow);
406
+ transition: transform 0.1s;
407
+ }
408
+
409
+ .range-slider::-webkit-slider-thumb:hover {
410
+ transform: scale(1.2);
411
+ }
412
+
413
+ /* Right Panel: Results */
414
+ .prediction-display {
415
+ background: rgba(255, 255, 255, 0.02);
416
+ border-radius: 12px;
417
+ border: 1px solid rgba(255, 255, 255, 0.04);
418
+ padding: 16px;
419
+ margin-bottom: 16px;
420
+ position: relative;
421
+ overflow: hidden;
422
+ }
423
+
424
+ .pred-label {
425
+ font-size: 11px;
426
+ text-transform: uppercase;
427
+ letter-spacing: 1px;
428
+ color: var(--text-muted);
429
+ font-weight: 600;
430
+ margin-bottom: 6px;
431
+ }
432
+
433
+ .pred-main-name {
434
+ font-size: 20px;
435
+ font-weight: 700;
436
+ color: var(--success);
437
+ margin-bottom: 4px;
438
+ white-space: nowrap;
439
+ overflow: hidden;
440
+ text-overflow: ellipsis;
441
+ }
442
+
443
+ .pred-meta-row {
444
+ display: flex;
445
+ justify-content: space-between;
446
+ align-items: center;
447
+ font-size: 12.5px;
448
+ color: var(--text-muted);
449
+ }
450
+
451
+ .badge {
452
+ padding: 3px 8px;
453
+ border-radius: 4px;
454
+ font-size: 11px;
455
+ font-weight: 600;
456
+ background: rgba(255, 255, 255, 0.06);
457
+ }
458
+
459
+ /* Top 5 list */
460
+ .top-list {
461
+ display: flex;
462
+ flex-direction: column;
463
+ gap: 8px;
464
+ }
465
+
466
+ .top-item {
467
+ display: flex;
468
+ align-items: center;
469
+ gap: 12px;
470
+ font-size: 13px;
471
+ }
472
+
473
+ .top-item-name {
474
+ width: 140px;
475
+ text-align: right;
476
+ white-space: nowrap;
477
+ overflow: hidden;
478
+ text-overflow: ellipsis;
479
+ color: var(--text-muted);
480
+ font-weight: 500;
481
+ }
482
+
483
+ .top-item-bar-track {
484
+ flex: 1;
485
+ background: rgba(255, 255, 255, 0.05);
486
+ height: 12px;
487
+ border-radius: 6px;
488
+ overflow: hidden;
489
+ border: 1px solid rgba(255, 255, 255, 0.02);
490
+ }
491
+
492
+ .top-item-bar-fill {
493
+ height: 100%;
494
+ background: var(--primary);
495
+ border-radius: 6px;
496
+ transition: width 0.3s ease-out;
497
+ }
498
+
499
+ .top-item-bar-fill.rank-success {
500
+ background: linear-gradient(90deg, var(--primary), var(--success));
501
+ }
502
+ .top-item-bar-fill.rank-warning {
503
+ background: linear-gradient(90deg, var(--primary), var(--warning));
504
+ }
505
+ .top-item-bar-fill.rank-danger {
506
+ background: linear-gradient(90deg, var(--primary), var(--danger));
507
+ }
508
+
509
+ .top-item-pct {
510
+ width: 50px;
511
+ text-align: left;
512
+ font-weight: 600;
513
+ color: var(--text-muted);
514
+ }
515
+
516
+ .warning-box {
517
+ margin-top: 14px;
518
+ background: rgba(251, 191, 36, 0.08);
519
+ border: 1px solid rgba(251, 191, 36, 0.2);
520
+ padding: 10px;
521
+ border-radius: 8px;
522
+ color: var(--warning);
523
+ font-size: 12.5px;
524
+ display: flex;
525
+ align-items: center;
526
+ gap: 8px;
527
+ }
528
+
529
+ /* POS Shopping Cart & History Section */
530
+ .pos-container {
531
+ display: grid;
532
+ grid-template-columns: 1fr;
533
+ gap: 20px;
534
+ }
535
+
536
+ .cart-list {
537
+ max-height: 180px;
538
+ overflow-y: auto;
539
+ display: flex;
540
+ flex-direction: column;
541
+ gap: 8px;
542
+ margin-bottom: 12px;
543
+ padding-right: 4px;
544
+ }
545
+
546
+ /* scrollbar */
547
+ .cart-list::-webkit-scrollbar, .history-list::-webkit-scrollbar {
548
+ width: 6px;
549
+ }
550
+ .cart-list::-webkit-scrollbar-thumb, .history-list::-webkit-scrollbar-thumb {
551
+ background: rgba(255, 255, 255, 0.1);
552
+ border-radius: 3px;
553
+ }
554
+
555
+ .cart-item {
556
+ display: flex;
557
+ justify-content: space-between;
558
+ align-items: center;
559
+ background: rgba(255, 255, 255, 0.02);
560
+ border: 1px solid rgba(255, 255, 255, 0.04);
561
+ padding: 10px 12px;
562
+ border-radius: 8px;
563
+ font-size: 13px;
564
+ }
565
+
566
+ .cart-item-info {
567
+ flex: 1;
568
+ overflow: hidden;
569
+ padding-right: 8px;
570
+ }
571
+
572
+ .cart-item-name {
573
+ font-weight: 600;
574
+ white-space: nowrap;
575
+ overflow: hidden;
576
+ text-overflow: ellipsis;
577
+ }
578
+
579
+ .cart-item-price {
580
+ font-size: 12px;
581
+ color: var(--text-muted);
582
+ }
583
+
584
+ .cart-item-actions {
585
+ display: flex;
586
+ align-items: center;
587
+ gap: 12px;
588
+ }
589
+
590
+ .cart-item-qty {
591
+ font-weight: 700;
592
+ color: var(--primary);
593
+ }
594
+
595
+ .cart-item-total {
596
+ font-weight: 700;
597
+ }
598
+
599
+ .btn-remove {
600
+ background: none;
601
+ border: none;
602
+ color: var(--text-muted);
603
+ cursor: pointer;
604
+ font-size: 16px;
605
+ padding: 2px 6px;
606
+ border-radius: 4px;
607
+ transition: all 0.2s;
608
+ }
609
+ .btn-remove:hover {
610
+ color: var(--danger);
611
+ background: rgba(239, 68, 68, 0.15);
612
+ }
613
+
614
+ .cart-empty {
615
+ text-align: center;
616
+ padding: 24px;
617
+ color: var(--text-muted);
618
+ font-size: 13px;
619
+ font-style: italic;
620
+ }
621
+
622
+ .cart-summary {
623
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
624
+ padding-top: 12px;
625
+ display: flex;
626
+ justify-content: space-between;
627
+ align-items: center;
628
+ margin-bottom: 12px;
629
+ }
630
+
631
+ .summary-label {
632
+ font-weight: 600;
633
+ font-size: 14px;
634
+ }
635
+
636
+ .summary-total {
637
+ font-size: 18px;
638
+ font-weight: 800;
639
+ color: var(--success);
640
+ }
641
+
642
+ /* History list */
643
+ .history-list {
644
+ max-height: 140px;
645
+ overflow-y: auto;
646
+ display: flex;
647
+ flex-direction: column;
648
+ gap: 6px;
649
+ padding-right: 4px;
650
+ }
651
+
652
+ .history-item {
653
+ display: flex;
654
+ justify-content: space-between;
655
+ align-items: center;
656
+ padding: 6px 10px;
657
+ background: rgba(255, 255, 255, 0.01);
658
+ border-radius: 6px;
659
+ font-size: 12px;
660
+ border-left: 3px solid var(--primary);
661
+ }
662
+
663
+ .history-item.high-conf {
664
+ border-left-color: var(--success);
665
+ }
666
+
667
+ .history-item-name {
668
+ font-weight: 500;
669
+ white-space: nowrap;
670
+ overflow: hidden;
671
+ text-overflow: ellipsis;
672
+ max-width: 180px;
673
+ }
674
+
675
+ .history-item-time {
676
+ color: var(--text-muted);
677
+ font-size: 11px;
678
+ }
679
+
680
+ .history-empty {
681
+ text-align: center;
682
+ padding: 12px;
683
+ color: var(--text-muted);
684
+ font-size: 12px;
685
+ font-style: italic;
686
+ }
687
+
688
+ @keyframes pulse {
689
+ 0% { transform: scale(1); opacity: 0.7; }
690
+ 100% { transform: scale(1.2); opacity: 1; }
691
+ }
692
+
693
+ @keyframes laser-sweep {
694
+ 0% { top: 0%; }
695
+ 50% { top: 100%; }
696
+ 100% { top: 0%; }
697
+ }
698
+ </style>
699
  </head>
700
+ <body>
701
+
702
+ <header>
703
+ <div class="logo-container">
704
+ <div class="logo-icon">▲</div>
705
+ <h1 class="logo-title">Tokiva <span>SKU Scanner</span></h1>
706
+ </div>
707
+ <div class="status-badge">
708
+ <div id="statusDot" class="status-dot loading"></div>
709
+ <span id="statusText">Memuat modul deteksi...</span>
710
+ </div>
711
+ </header>
712
+
713
+ <div class="dashboard-grid">
714
+ <!-- Left Panel: Scanning Feed & Controls -->
715
+ <div>
716
+ <div class="card">
717
+ <div class="card-title">
718
+ <svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
719
+ <span>Pemindai Kamera & Input</span>
720
+ </div>
721
+
722
+ <div id="viewportContainer" class="viewport-container">
723
+ <!-- Decoration brackets for target area -->
724
+ <div class="scanner-overlay">
725
+ <div class="corner-bracket top-left"></div>
726
+ <div class="corner-bracket top-right"></div>
727
+ <div class="corner-bracket bottom-left"></div>
728
+ <div class="corner-bracket bottom-right"></div>
729
+ <div id="scannerLaser" class="scanner-laser"></div>
730
+ <div id="scannerFlash" class="scanner-flash"></div>
731
  </div>
732
+
733
+ <video id="video" autoplay playsinline muted style="display:none;"></video>
734
+ <img id="previewImg" style="display:none;">
735
+ <canvas id="canvas" width="224" height="224"></canvas>
736
+ <div id="viewportPlaceholder" style="color: var(--text-muted); font-size: 13.5px; font-weight: 500;">
737
+ Kamera tidak aktif
738
  </div>
739
  </div>
740
+
741
+ <div class="controls-row">
742
+ <button id="btnCamera" class="btn-primary" onclick="startCamera()">
743
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
744
+ Nyalakan
745
+ </button>
746
+ <button id="btnToggleCamera" onclick="switchCameraFacing()" disabled>
747
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 7.89"></path></svg>
748
+ Putar Kamera
749
+ </button>
750
+ <button id="btnRealtime" class="btn-success" onclick="toggleRealtime()" disabled>
751
+ Mulai Scan
752
+ </button>
753
  </div>
 
 
754
 
755
+ <div class="upload-zone" id="dropzone" onclick="document.getElementById('upload').click()">
756
+ <svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
757
+ <span>Klik atau seret file gambar produk di sini</span>
758
+ <input type="file" id="upload" accept="image/*" onchange="handleUpload(event)">
 
 
 
 
 
 
 
 
 
 
 
 
759
  </div>
760
 
761
+ <div class="slider-container">
762
+ <div class="slider-label-row">
763
+ <span>Confidence Threshold (Kasir)</span>
764
+ <span class="slider-value" id="sliderValDisplay">60%</span>
 
 
 
 
 
 
 
 
 
 
765
  </div>
766
+ <input type="range" class="range-slider" id="thresholdSlider" min="10" max="95" value="60" oninput="updateThreshold(this.value)">
767
+ </div>
768
+ </div>
769
+ </div>
770
+
771
+ <!-- Right Panel: Results & POS -->
772
+ <div class="pos-container">
773
+ <!-- Live Results Card -->
774
+ <div class="card">
775
+ <div class="card-title">
776
+ <svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path></svg>
777
+ <span>Hasil Deteksi Model</span>
778
+ </div>
779
 
780
+ <div id="resultBox" style="display:none;">
781
+ <div class="prediction-display">
782
+ <div class="pred-label">Prediksi SKU Tertinggi</div>
783
+ <div id="mainPrediction" class="pred-main-name">Silakan scan produk...</div>
784
+ <div class="pred-meta-row">
785
+ <span id="inferenceTime" class="badge">⚡ - ms</span>
786
+ <span id="accuracyLabel">Confidence: -</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
787
  </div>
788
  </div>
789
 
790
+ <div class="pred-label" style="margin-bottom: 8px;">Top-5 Kandidat Kecocokan</div>
791
+ <div class="top-list" id="top5Container">
792
+ <!-- items dynamically filled -->
 
 
 
 
 
 
 
 
 
 
793
  </div>
794
 
795
+ <div id="lowConfidenceWarning" class="warning-box" style="display:none;">
796
+ <span>⚠️</span>
797
+ <span id="lowConfidenceWarningText">Batas confidence rendah.</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
  </div>
799
+ </div>
800
 
801
+ <div id="resultPlaceholder" style="text-align: center; padding: 24px; color: var(--text-muted); font-size: 13.5px; font-style: italic;">
802
+ Belum ada gambar yang dipindai.
803
  </div>
804
  </div>
805
 
806
+ <!-- POS Kasir (Simulasi) Card -->
807
+ <div class="card">
808
+ <div class="card-title">
809
+ <svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
810
+ <span>POS Kasir Tokiva</span>
811
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
 
813
+ <div class="cart-list" id="cartList">
814
+ <div class="cart-empty">Belum ada item belanja. Scan produk untuk memasukkan ke kasir.</div>
 
 
 
 
 
 
815
  </div>
816
 
817
+ <div class="cart-summary">
818
+ <div class="summary-label">Total Belanja</div>
819
+ <div class="summary-total" id="cartTotal">Rp 0</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  </div>
821
 
822
+ <button id="btnCheckout" class="btn-success" style="width: 100%; display: flex; justify-content: center; gap: 8px;" onclick="checkoutCart()">
823
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"></path></svg>
824
+ Bayar & Cetak Struk
825
+ </button>
826
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
827
 
828
+ <!-- Scan History Card -->
829
+ <div class="card">
830
+ <div class="card-title">
831
+ <svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
832
+ <span>Riwayat Scan Terakhir</span>
833
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
834
 
835
+ <div class="history-list" id="historyList">
836
+ <div class="history-empty">Belum ada riwayat pemindaian.</div>
837
+ </div>
838
+ </div>
839
+ </div>
840
+ </div>
841
+
842
+ <script>
843
+ const IMG_SIZE = 224;
844
+ const REALTIME_INTERVAL_MS = 750; // jeda scan realtime (ms)
845
+ const COOLDOWN_MS = 3000; // 3 detik cooldown per item agar tidak spamming audio & cart
846
+
847
+ // Mock database harga produk berdasarkan SKU names
848
+ const PRODUCT_CATALOG = {
849
+ "frisian-flag-fullcrm-250ml": { name: "Frisian Flag Full Cream 250ml", price: 7500 },
850
+ "frisian-flag-strwbry-250ml": { name: "Frisian Flag Strawberry 250ml", price: 7500 },
851
+ "gaga-100-grg-jalapeto": { name: "Mie Gaga 100 Goreng Jalapeno", price: 4500 },
852
+ "gaga-100-kuah-jalapeto": { name: "Mie Gaga 100 Kuah Jalapeno", price: 4500 },
853
+ "indomie-grg-cb-ijo": { name: "Indomie Goreng Cabe Ijo", price: 3500 },
854
+ "indomie-grg-cb-ijo-jumbo": { name: "Indomie Goreng Cabe Ijo Jumbo", price: 4500 },
855
+ "klik-crackers-keju": { name: "Klik Crackers Keju", price: 6000 },
856
+ "nabati-siip-keju": { name: "Nabati Siip Keju", price: 2000 },
857
+ "pepsodent-72g": { name: "Pepsodent 72g", price: 12500 },
858
+ "sarimi-aym-bwng": { name: "Sarimi Ayam Bawang", price: 3200 },
859
+ "sarimi-gls-baso-pds": { name: "Sarimi Gelas Baso Pedas", price: 2500 },
860
+ "sedaap-grng": { name: "Mie Sedaap Goreng", price: 3500 },
861
+ "sedaap-kuah-aym-bwg": { name: "Mie Sedaap Kuah Ayam Bawang", price: 3300 },
862
+ "sedaap-sg-laksa": { name: "Mie Sedaap Singapore Laksa", price: 3600 },
863
+ "siplah-mineral-220ml": { name: "Siplah Air Mineral Gelas 220ml", price: 1500 },
864
+ "soffell-bunga": { name: "Soffell Bunga Kulit", price: 15000 },
865
+ "soffell-jeruk": { name: "Soffell Jeruk Kulit", price: 15000 },
866
+ "ultramlk-fullcrm-200ml": { name: "Ultra Milk Full Cream 200ml", price: 6800 },
867
+ "vica-600ml": { name: "Vica Air Mineral 600ml", price: 4000 }
868
+ };
869
+
870
+ let model = null;
871
+ let classNames = [];
872
+ let realtimeActive = false;
873
+ let realtimeTimer = null;
874
+ let cameraStream = null;
875
+ let currentFacingMode = 'environment'; // environment = back camera, user = front camera
876
+ let CONFIDENCE_THRESHOLD = 0.60;
877
+
878
+ // State Cooldown
879
+ let lastScannedItem = '';
880
+ let lastScanTime = 0;
881
+
882
+ // POS State & History State
883
+ const cart = {};
884
+ const scanHistory = [];
885
+
886
+ // Audio Object
887
+ const beepAudio = new Audio('beep.mp3');
888
+
889
+ // Elements
890
+ const statusDot = document.getElementById('statusDot');
891
+ const statusText = document.getElementById('statusText');
892
+ const videoEl = document.getElementById('video');
893
+ const previewImgEl = document.getElementById('previewImg');
894
+ const canvasEl = document.getElementById('canvas');
895
+ const btnCamera = document.getElementById('btnCamera');
896
+ const btnToggleCamera = document.getElementById('btnToggleCamera');
897
+ const btnRealtime = document.getElementById('btnRealtime');
898
+ const viewportPlaceholder = document.getElementById('viewportPlaceholder');
899
+ const viewportContainer = document.getElementById('viewportContainer');
900
+ const scannerFlash = document.getElementById('scannerFlash');
901
+ const sliderValDisplay = document.getElementById('sliderValDisplay');
902
+ const thresholdSlider = document.getElementById('thresholdSlider');
903
+
904
+ const resultBox = document.getElementById('resultBox');
905
+ const resultPlaceholder = document.getElementById('resultPlaceholder');
906
+ const mainPredictionEl = document.getElementById('mainPrediction');
907
+ const inferenceTimeEl = document.getElementById('inferenceTime');
908
+ const accuracyLabel = document.getElementById('accuracyLabel');
909
+ const top5ContainerEl = document.getElementById('top5Container');
910
+ const lowConfidenceWarningEl = document.getElementById('lowConfidenceWarning');
911
+ const lowConfidenceWarningText = document.getElementById('lowConfidenceWarningText');
912
+
913
+ const cartListEl = document.getElementById('cartList');
914
+ const cartTotalEl = document.getElementById('cartTotal');
915
+ const historyListEl = document.getElementById('historyList');
916
+
917
+ // ---------------------------------------------------------
918
+ // Load model + daftar kelas
919
+ // ---------------------------------------------------------
920
+ async function loadModel() {
921
+ try {
922
+ statusDot.className = 'status-dot loading';
923
+ statusText.textContent = 'Memuat model TensorFlow.js...';
924
+
925
+ model = await tf.loadGraphModel('model/model.json');
926
+
927
+ const res = await fetch('model/class_names.json');
928
+ classNames = await res.json();
929
+
930
+ statusDot.className = 'status-dot ready';
931
+ statusText.textContent = `Model Siap (${classNames.length} SKU). Nyalakan kamera atau unggah foto.`;
932
+ } catch (err) {
933
+ statusDot.className = 'status-dot error';
934
+ statusText.textContent = 'Gagal memuat model: ' + err.message;
935
+ console.error(err);
936
+ }
937
+ }
938
+ loadModel();
939
+
940
+ // ---------------------------------------------------------
941
+ // Kamera controls
942
+ // ---------------------------------------------------------
943
+ async function startCamera() {
944
+ stopCamera(); // Pastikan kamera lama dimatikan sebelum membuat koneksi baru
945
+ try {
946
+ statusText.textContent = 'Mengakses kamera...';
947
+ cameraStream = await navigator.mediaDevices.getUserMedia({
948
+ video: {
949
+ facingMode: currentFacingMode,
950
+ width: { ideal: 640 },
951
+ height: { ideal: 640 }
952
  }
953
  });
954
+
955
+ videoEl.srcObject = cameraStream;
956
+ videoEl.style.display = 'block';
957
+ previewImgEl.style.display = 'none';
958
+ viewportPlaceholder.style.display = 'none';
959
+
960
+ btnToggleCamera.disabled = false;
961
+ btnRealtime.disabled = false;
962
+
963
+ btnCamera.textContent = 'Matikan Kamera';
964
+ btnCamera.className = 'btn-danger';
965
+ btnCamera.setAttribute('onclick', 'stopCameraUI()');
966
+
967
+ statusText.textContent = 'Kamera aktif. Klik "Mulai Scan" untuk mendeteksi otomatis.';
968
+ } catch (err) {
969
+ statusText.textContent = 'Gagal mengakses kamera: ' + err.message;
970
+ statusDot.className = 'status-dot error';
971
+ }
972
+ }
973
+
974
+ // Stop Camera stream
975
+ function stopCamera() {
976
+ if (realtimeActive) toggleRealtime();
977
+
978
+ if (cameraStream) {
979
+ cameraStream.getTracks().forEach(track => track.stop());
980
+ cameraStream = null;
981
+ }
982
+ videoEl.srcObject = null;
983
+ }
984
+
985
+ function stopCameraUI() {
986
+ stopCamera();
987
+ videoEl.style.display = 'none';
988
+ viewportPlaceholder.style.display = 'block';
989
+ btnToggleCamera.disabled = true;
990
+ btnRealtime.disabled = true;
991
+
992
+ btnCamera.textContent = 'Nyalakan Kamera';
993
+ btnCamera.className = 'btn-primary';
994
+ btnCamera.setAttribute('onclick', 'startCamera()');
995
+ statusText.textContent = 'Kamera dinonaktifkan.';
996
+
997
+ if (model) {
998
+ statusDot.className = 'status-dot ready';
999
+ }
1000
+ }
1001
+
1002
+ async function switchCameraFacing() {
1003
+ currentFacingMode = currentFacingMode === 'environment' ? 'user' : 'environment';
1004
+ await startCamera();
1005
+ }
1006
+
1007
+ function toggleRealtime() {
1008
+ realtimeActive = !realtimeActive;
1009
+ btnRealtime.textContent = realtimeActive ? 'Stop Scan' : 'Mulai Scan';
1010
+ btnRealtime.className = realtimeActive ? 'btn-danger' : 'btn-success';
1011
+
1012
+ if (realtimeActive) {
1013
+ viewportContainer.classList.add('scanner-active');
1014
+ realtimeLoop();
1015
+ } else {
1016
+ viewportContainer.classList.remove('scanner-active');
1017
+ if (realtimeTimer) clearTimeout(realtimeTimer);
1018
+ }
1019
+ }
1020
+
1021
+ async function realtimeLoop() {
1022
+ if (!realtimeActive) return;
1023
+ await predictFromVideoFrame();
1024
+ realtimeTimer = setTimeout(realtimeLoop, REALTIME_INTERVAL_MS);
1025
+ }
1026
+
1027
+ async function predictFromVideoFrame() {
1028
+ const ctx = canvasEl.getContext('2d');
1029
+ ctx.drawImage(videoEl, 0, 0, IMG_SIZE, IMG_SIZE);
1030
+ await runPrediction();
1031
+ }
1032
+
1033
+ // ---------------------------------------------------------
1034
+ // Upload & Drag-Drop Gambar
1035
+ // ---------------------------------------------------------
1036
+ function handleUpload(event) {
1037
+ const file = event.target.files[0];
1038
+ processUploadedFile(file);
1039
+ }
1040
+
1041
+ function processUploadedFile(file) {
1042
+ if (!file) return;
1043
+
1044
+ if (realtimeActive) toggleRealtime();
1045
+
1046
+ const img = new Image();
1047
+ img.onload = async () => {
1048
+ previewImgEl.src = img.src;
1049
+ previewImgEl.style.display = 'block';
1050
+ videoEl.style.display = 'none';
1051
+ viewportPlaceholder.style.display = 'none';
1052
+
1053
+ // Matikan stream kamera di belakang layar agar hemat daya
1054
+ if (cameraStream) {
1055
+ stopCameraUI();
1056
  }
1057
 
1058
+ const ctx = canvasEl.getContext('2d');
1059
+ ctx.drawImage(img, 0, 0, IMG_SIZE, IMG_SIZE);
1060
+ await runPrediction();
1061
+ };
1062
+ img.src = URL.createObjectURL(file);
1063
+ }
1064
+
1065
+ // Drag & Drop event handlers
1066
+ const dropzone = document.getElementById('dropzone');
1067
+ ['dragenter', 'dragover'].forEach(eventName => {
1068
+ dropzone.addEventListener(eventName, e => {
1069
+ e.preventDefault();
1070
+ dropzone.classList.add('dragover');
1071
+ }, false);
1072
+ });
1073
+
1074
+ ['dragleave', 'drop'].forEach(eventName => {
1075
+ dropzone.addEventListener(eventName, e => {
1076
+ e.preventDefault();
1077
+ dropzone.classList.remove('dragover');
1078
+ }, false);
1079
+ });
1080
+
1081
+ dropzone.addEventListener('drop', e => {
1082
+ const dt = e.dataTransfer;
1083
+ const files = dt.files;
1084
+ if (files.length > 0) {
1085
+ processUploadedFile(files[0]);
1086
+ }
1087
+ });
1088
+
1089
+ // ---------------------------------------------------------
1090
+ // Threshold slider control
1091
+ // ---------------------------------------------------------
1092
+ function updateThreshold(val) {
1093
+ CONFIDENCE_THRESHOLD = val / 100;
1094
+ sliderValDisplay.textContent = val + '%';
1095
+ }
1096
+
1097
+ // ---------------------------------------------------------
1098
+ // Model Prediction
1099
+ // ---------------------------------------------------------
1100
+ async function runPrediction() {
1101
+ if (!model) return;
1102
+
1103
+ const startTime = performance.now();
1104
+
1105
+ const inputTensor = tf.tidy(() => {
1106
+ return tf.browser.fromPixels(canvasEl)
1107
+ .resizeBilinear([IMG_SIZE, IMG_SIZE])
1108
+ .toFloat()
1109
+ .expandDims(0);
1110
+ });
1111
+
1112
+ const outputTensor = model.predict(inputTensor);
1113
+ const probs = await outputTensor.data();
1114
+
1115
+ const inferenceTimeMs = (performance.now() - startTime).toFixed(1);
1116
+
1117
+ inputTensor.dispose();
1118
+ outputTensor.dispose();
1119
+
1120
+ displayResult(probs, inferenceTimeMs);
1121
+ }
1122
+
1123
+ // Format IDR Price format
1124
+ function formatIDR(price) {
1125
+ return 'Rp ' + price.toLocaleString('id-ID');
1126
+ }
1127
+
1128
+ // Get SKU Friendly Name from database Catalog
1129
+ function getFriendlySKUName(sku) {
1130
+ return PRODUCT_CATALOG[sku] ? PRODUCT_CATALOG[sku].name : sku;
1131
+ }
1132
+
1133
+ function getProductPrice(sku) {
1134
+ return PRODUCT_CATALOG[sku] ? PRODUCT_CATALOG[sku].price : 5000;
1135
+ }
1136
+
1137
+ function triggerCameraFlash() {
1138
+ scannerFlash.classList.add('flash-active');
1139
+ setTimeout(() => {
1140
+ scannerFlash.classList.remove('flash-active');
1141
+ }, 150);
1142
+ }
1143
+
1144
+ function displayResult(probs, inferenceTimeMs) {
1145
+ const indexed = Array.from(probs).map((p, i) => ({ name: classNames[i], prob: p }));
1146
+ indexed.sort((a, b) => b.prob - a.prob);
1147
+ const top5 = indexed.slice(0, 5);
1148
+ const bestMatch = top5[0];
1149
+
1150
+ resultPlaceholder.style.display = 'none';
1151
+ resultBox.style.display = 'block';
1152
+
1153
+ // Display Main Prediction
1154
+ const friendlyName = getFriendlySKUName(bestMatch.name);
1155
+ const bestProbPct = (bestMatch.prob * 100).toFixed(2);
1156
+
1157
+ inferenceTimeEl.textContent = `⚡ ${inferenceTimeMs} ms`;
1158
 
1159
+ // Validasi Objek Tidak Terdeteksi / Tidak Dikenali
1160
+ // Jika confidence di bawah 30%, kita anggap tidak terdeteksi sama sekali.
1161
+ const UNKNOWN_THRESHOLD = 0.30;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1162
 
1163
+ if (bestMatch.prob < UNKNOWN_THRESHOLD) {
1164
+ mainPredictionEl.textContent = `⚠️ Objek Tidak Dikenali / Tidak Terdeteksi`;
1165
+ mainPredictionEl.style.color = 'var(--danger)';
1166
+ accuracyLabel.textContent = `Confidence: ${(bestMatch.prob * 100).toFixed(1)}% (Di bawah batas minimum)`;
1167
+
1168
+ lowConfidenceWarningEl.style.display = 'flex';
1169
+ lowConfidenceWarningText.textContent = `Produk tidak terdeteksi dengan jelas. Posisikan produk lebih dekat ke kamera atau perbaiki pencahayaan.`;
1170
+ }
1171
+ // Jika confidence di antara 30% dan threshold kasir (e.g. 60%)
1172
+ else if (bestMatch.prob < CONFIDENCE_THRESHOLD) {
1173
+ mainPredictionEl.textContent = `⚠️ ${friendlyName} (${bestProbPct}%)`;
1174
+ mainPredictionEl.style.color = 'var(--warning)';
1175
+ accuracyLabel.textContent = `Confidence: ${(bestMatch.prob * 100).toFixed(1)}%`;
1176
+
1177
+ lowConfidenceWarningEl.style.display = 'flex';
1178
+ lowConfidenceWarningText.textContent = `Confidence rendah (< ${(CONFIDENCE_THRESHOLD * 100).toFixed(0)}%). Produk tidak otomatis dimasukkan ke kasir.`;
1179
+ }
1180
+ // Jika confidence tinggi >= threshold kasir
1181
+ else {
1182
+ mainPredictionEl.textContent = `${friendlyName} (${bestProbPct}%)`;
1183
+ mainPredictionEl.style.color = 'var(--success)';
1184
+ accuracyLabel.textContent = `Confidence: ${(bestMatch.prob * 100).toFixed(1)}%`;
1185
+ lowConfidenceWarningEl.style.display = 'none';
1186
+
1187
+ // SUCCESS SCANNED: handle POS auto cart & beep (with cooldown)
1188
+ const currentTime = Date.now();
1189
+ if (bestMatch.name !== lastScannedItem || (currentTime - lastScanTime) > COOLDOWN_MS) {
1190
+ lastScannedItem = bestMatch.name;
1191
+ lastScanTime = currentTime;
1192
+
1193
+ // Play Beep sound
1194
+ beepAudio.play().catch(e => console.log('Audio playback delayed until user interaction.'));
1195
 
1196
+ // Trigger camera border flash effect
1197
+ triggerCameraFlash();
 
 
 
 
 
 
 
 
 
 
 
 
 
1198
 
1199
+ // Add to Cart State
1200
+ addToCart(bestMatch.name);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1201
  }
1202
+ }
1203
+
1204
+ // Display History logs (hanya jika confidence > UNKNOWN_THRESHOLD)
1205
+ if (bestMatch.prob >= UNKNOWN_THRESHOLD) {
1206
+ addToHistory(bestMatch.name, bestMatch.prob);
1207
+ }
1208
+
1209
+ // Top-5 visualization
1210
+ top5ContainerEl.innerHTML = top5.map((item, idx) => {
1211
+ const pct = (item.prob * 100).toFixed(2);
1212
+ let barClass = '';
1213
+ if (idx === 0) {
1214
+ if (item.prob >= CONFIDENCE_THRESHOLD) barClass = 'rank-success';
1215
+ else if (item.prob >= UNKNOWN_THRESHOLD) barClass = 'rank-warning';
1216
+ else barClass = 'rank-danger';
1217
  }
1218
+ return `
1219
+ <div class="top-item">
1220
+ <div class="top-item-name" title="${getFriendlySKUName(item.name)}">${getFriendlySKUName(item.name)}</div>
1221
+ <div class="top-item-bar-track">
1222
+ <div class="top-item-bar-fill ${barClass}" style="width: ${pct}%"></div>
1223
+ </div>
1224
+ <div class="top-item-pct">${pct}%</div>
1225
+ </div>
1226
+ `;
1227
+ }).join('');
1228
+ }
1229
+
1230
+ // ---------------------------------------------------------
1231
+ // POS Shopping Cart simulation
1232
+ // ---------------------------------------------------------
1233
+ function addToCart(sku) {
1234
+ if (cart[sku]) {
1235
+ cart[sku].qty += 1;
1236
+ } else {
1237
+ const catalogItem = PRODUCT_CATALOG[sku] || { name: sku, price: 5000 };
1238
+ cart[sku] = {
1239
+ name: catalogItem.name,
1240
+ price: catalogItem.price,
1241
+ qty: 1
1242
+ };
1243
+ }
1244
+ renderCart();
1245
+ }
1246
+
1247
+ function removeFromCart(sku) {
1248
+ delete cart[sku];
1249
+ renderCart();
1250
+ }
1251
+
1252
+ function changeQty(sku, delta) {
1253
+ if (!cart[sku]) return;
1254
+ cart[sku].qty += delta;
1255
+ if (cart[sku].qty <= 0) {
1256
+ removeFromCart(sku);
1257
+ } else {
1258
+ renderCart();
1259
+ }
1260
+ }
1261
+
1262
+ function renderCart() {
1263
+ const skus = Object.keys(cart);
1264
+ if (skus.length === 0) {
1265
+ cartListEl.innerHTML = '<div class="cart-empty">Belum ada item belanja. Scan produk untuk memasukkan ke kasir.</div>';
1266
+ cartTotalEl.textContent = 'Rp 0';
1267
+ return;
1268
+ }
1269
+
1270
+ let total = 0;
1271
+ cartListEl.innerHTML = skus.map(sku => {
1272
+ const item = cart[sku];
1273
+ const subtotal = item.price * item.qty;
1274
+ total += subtotal;
1275
+ return `
1276
+ <div class="cart-item">
1277
+ <div class="cart-item-info">
1278
+ <div class="cart-item-name" title="${item.name}">${item.name}</div>
1279
+ <div class="cart-item-price">${formatIDR(item.price)}</div>
1280
+ </div>
1281
+ <div class="cart-item-actions">
1282
+ <button class="btn-remove" style="padding: 2px 8px;" onclick="changeQty('${sku}', -1)">-</button>
1283
+ <span class="cart-item-qty">${item.qty}</span>
1284
+ <button class="btn-remove" style="padding: 2px 8px;" onclick="changeQty('${sku}', 1)">+</button>
1285
+ <span class="cart-item-total">${formatIDR(subtotal)}</span>
1286
+ <button class="btn-remove" onclick="removeFromCart('${sku}')">×</button>
1287
+ </div>
1288
+ </div>
1289
+ `;
1290
+ }).join('');
1291
+
1292
+ cartTotalEl.textContent = formatIDR(total);
1293
+ }
1294
+
1295
+ function checkoutCart() {
1296
+ const skus = Object.keys(cart);
1297
+ if (skus.length === 0) {
1298
+ alert("Keranjang belanja masih kosong!");
1299
+ return;
1300
+ }
1301
+
1302
+ let summary = "--- STRUK TRANSAKSI TOKIVA ---\n\n";
1303
+ let total = 0;
1304
+ skus.forEach(sku => {
1305
+ const item = cart[sku];
1306
+ const subtotal = item.price * item.qty;
1307
+ total += subtotal;
1308
+ summary += `${item.name}\n ${item.qty} x ${formatIDR(item.price)} = ${formatIDR(subtotal)}\n`;
1309
+ });
1310
+ summary += `\n------------------------------\n`;
1311
+ summary += `TOTAL BELANJA: ${formatIDR(total)}\n\nTerima kasih telah berbelanja di Tokiva!`;
1312
+
1313
+ alert(summary);
1314
+
1315
+ // Clear cart
1316
+ for (const sku in cart) {
1317
+ delete cart[sku];
1318
+ }
1319
+ renderCart();
1320
+ }
1321
+
1322
+ // ---------------------------------------------------------
1323
+ // Scan History Log
1324
+ // ---------------------------------------------------------
1325
+ function addToHistory(sku, confidence) {
1326
+ const now = new Date();
1327
+ const timeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS
1328
+ const friendlyName = getFriendlySKUName(sku);
1329
+ const isHighConf = confidence >= CONFIDENCE_THRESHOLD;
1330
+
1331
+ // Insert to the front of history
1332
+ scanHistory.unshift({
1333
+ name: friendlyName,
1334
+ time: timeStr,
1335
+ highConf: isHighConf,
1336
+ pct: (confidence * 100).toFixed(0) + '%'
1337
+ });
1338
+
1339
+ // Limit history items to last 8
1340
+ if (scanHistory.length > 8) {
1341
+ scanHistory.pop();
1342
+ }
1343
+
1344
+ renderHistory();
1345
+ }
1346
+
1347
+ function renderHistory() {
1348
+ if (scanHistory.length === 0) {
1349
+ historyListEl.innerHTML = '<div class="history-empty">Belum ada riwayat pemindaian.</div>';
1350
+ return;
1351
+ }
1352
+
1353
+ historyListEl.innerHTML = scanHistory.map(item => {
1354
+ const confClass = item.highConf ? 'high-conf' : '';
1355
+ return `
1356
+ <div class="history-item ${confClass}">
1357
+ <span class="history-item-name" title="${item.name}">${item.name}</span>
1358
+ <div style="display: flex; gap: 8px; align-items: center;">
1359
+ <span class="badge" style="background: rgba(255,255,255,0.03);">${item.pct}</span>
1360
+ <span class="history-item-time">${item.time}</span>
1361
+ </div>
1362
+ </div>
1363
+ `;
1364
+ }).join('');
1365
+ }
1366
 
1367
+ </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1368
  </body>
1369
  </html>
labels.json DELETED
@@ -1,21 +0,0 @@
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
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
model/class_names.json ADDED
@@ -0,0 +1 @@
 
 
1
+ ["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"]
models/mobilenetv4_cbam.onnx → model/group1-shard1of4.bin RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:22da85536dde28b02e793ca0bad907f13ddfb3e50da2a33f85ff67a1168608af
3
- size 13051952
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1dd1d1d78d7f589adbb6289a0c2cd31c2d62173eb63a62294b5baadc02bedacf
3
+ size 4194304
model/group1-shard2of4.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:befb16773d67f3da01e5d91903bb25358552d3fcf2fc62f4ff494097f019fecc
3
+ size 4194304
model/group1-shard3of4.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b3919b3db53649fe91897f01bbfdbb6afbb1a947790af3f3ba9621d3c4e8b1a2
3
+ size 4194304
model/group1-shard4of4.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ccf3d1a561aed466bb11f3c581c901d5feb3ba7b66a45a65b475089fefac6b2f
3
+ size 1209596
model/model.json ADDED
The diff for this file is too large to render. See raw diff