Spaces:
Running
Running
anoderb commited on
Commit Β·
cc5d7a3
0
Parent(s):
Initial commit of static Web app
Browse files- .gitattributes +37 -0
- .gitignore +7 -0
- README.md +39 -0
- index.html +718 -0
- labels.json +21 -0
- 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>© 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
|