anoderb commited on
Commit
bb42c8b
Β·
1 Parent(s): 9a6102c

Migrate to ONNX Runtime Web client for fully offline browser-side inference

Browse files
index.html CHANGED
@@ -10,6 +10,9 @@
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
  <style>
14
  body {
15
  font-family: 'Plus Jakarta Sans', sans-serif;
@@ -49,7 +52,7 @@
49
  <div class="flex items-center gap-2">
50
  <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">
51
  <span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
52
- MediaPipe WASM Engine
53
  </span>
54
  </div>
55
  </div>
@@ -70,7 +73,7 @@
70
  <div class="w-full bg-white/5 h-2 rounded-full overflow-hidden">
71
  <div id="loader-bar" class="bg-gradient-to-r from-purple-500 to-indigo-500 h-full w-0 progress-bar-fill"></div>
72
  </div>
73
- <p id="loader-status" class="text-xs text-zinc-400 italic">Mendownload model (3.5 MB)...</p>
74
  </div>
75
 
76
  <!-- Main Tabs (Hidden during loading) -->
@@ -214,12 +217,12 @@
214
  <span class="font-semibold text-emerald-400">98.77%</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">F1-Score</span>
218
- <span class="font-semibold text-zinc-200">0.9897</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">Size</span>
222
- <span class="font-semibold text-zinc-200">3.5 MB (Quantized)</span>
223
  </div>
224
  </div>
225
  </div>
@@ -229,17 +232,14 @@
229
 
230
  <!-- Footer -->
231
  <footer class="mt-auto py-6 border-t border-white/5 px-6 text-center text-xs text-zinc-500">
232
- <p>&copy; 2026 Tokiva Team. Dibuat menggunakan Google MediaPipe Tasks Vision engine.</p>
233
  </footer>
234
 
235
  <script type="module">
236
- // Import MediaPipe Image Classifier from jsDelivr ES Module URL
237
- import { ImageClassifier, FilesetResolver } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.8/vision_bundle.mjs";
238
-
239
  // ─────────────────────────────────────────────────────────
240
  // MODEL CONFIG & MAPS
241
  // ─────────────────────────────────────────────────────────
242
- const MODEL_PATH = 'models/mobilenetv4_cbam_quantized.tflite';
243
 
244
  // 19 Class Labels ordered alphabetically (matching indices from training)
245
  const CLASS_NAMES = [
@@ -289,7 +289,7 @@
289
  // ─────────────────────────────────────────────────────────
290
  // GLOBAL STATE
291
  // ─────────────────────────────────────────────────────────
292
- let imageClassifier = null;
293
  let currentTab = 'webcam';
294
  let currentStream = null;
295
  let activeFacingMode = 'environment'; // environment / user
@@ -325,32 +325,25 @@
325
  // ─────────────────────────────────────────────────────────
326
  async function initModel() {
327
  try {
328
- loaderBar.style.width = '30%';
329
- loaderPercentage.innerText = '30%';
330
- loaderStatus.innerText = 'Mencari dependensi WebAssembly...';
331
-
332
- // Load Fileset Resolver
333
- const vision = await FilesetResolver.forVisionTasks(
334
- "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.8/wasm"
335
- );
336
-
337
- loaderBar.style.width = '60%';
338
- loaderPercentage.innerText = '60%';
339
- loaderStatus.innerText = 'Memuat model TFLite (3.5 MB)...';
340
 
341
  // Load the model
342
- imageClassifier = await ImageClassifier.createFromOptions(vision, {
343
- baseOptions: {
344
- modelAssetPath: MODEL_PATH,
345
- delegate: "GPU" // Will fall back to CPU if WebGL not supported
346
- },
347
- runningMode: "IMAGE",
348
- maxResults: 19
349
- });
350
 
351
  loaderBar.style.width = '100%';
352
  loaderPercentage.innerText = '100%';
353
- loaderStatus.innerText = 'Sukses memuat model!';
354
 
355
  setTimeout(() => {
356
  loaderCard.classList.add('hidden');
@@ -359,7 +352,7 @@
359
  startWebcam();
360
  }, 600);
361
  } catch (err) {
362
- console.error(err);
363
  loaderStatus.innerText = 'Gagal memuat model: ' + err.message;
364
  loaderBar.classList.add('bg-red-500');
365
  }
@@ -491,38 +484,65 @@
491
  reader.readAsDataURL(file);
492
  }
493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  // ─────────────────────────────────────────────────────────
495
  // CORE PREDICTION & INFERENCE
496
  // ─────────────────────────────────────────────────────────
497
  async function runPrediction(imageOrVideo) {
498
- if (!imageClassifier) return;
499
 
500
  const startTime = performance.now();
501
 
502
- // Perform inference using MediaPipe tasks-vision
503
- const results = imageClassifier.classify(imageOrVideo);
 
 
 
 
 
504
 
 
 
 
 
 
505
  const elapsed = (performance.now() - startTime).toFixed(1);
506
  inferenceTimeDisplay.innerText = `Inference: ${elapsed} ms`;
507
 
508
- if (results && results.classifications && results.classifications.length > 0) {
509
- const categories = results.classifications[0].categories;
510
-
511
- // Map MediaPipe category output (by index/label name)
512
- const sortedResults = categories.map(cat => {
513
- // If the model does not have labels embedded, cat.index is the integer index
514
- const idx = parseInt(cat.index);
515
- const class_name = CLASS_NAMES[idx] || cat.categoryName || `Unknown Class #${idx}`;
516
- return {
517
- prob: cat.score,
518
- class_name: class_name,
519
- display_name: DISPLAY_NAMES[class_name] || class_name,
520
- index: idx
521
- };
522
- }).sort((a, b) => b.prob - a.prob);
523
-
524
- renderResults(sortedResults);
525
- }
526
  }
527
 
528
  // Capture frame from live webcam
@@ -625,24 +645,23 @@
625
  }
626
 
627
  document.getElementById('btn-analyze').addEventListener('click', async () => {
628
- if (!loadedImageElement || !imageClassifier) return;
629
 
630
  const btn = document.getElementById('btn-analyze');
631
  btn.disabled = true;
632
  btn.innerText = "MENGHITUNG HEATMAP (1-2 Detik)...";
633
 
634
  // Get base prediction
635
- const baseResult = imageClassifier.classify(loadedImageElement);
636
- if (!baseResult || !baseResult.classifications || baseResult.classifications.length === 0) {
637
- btn.disabled = false;
638
- btn.innerText = "HITUNG HEATMAP OKLUSI";
639
- return;
640
- }
 
641
 
642
- const categories = baseResult.classifications[0].categories;
643
- const topCat = categories.reduce((max, val) => val.score > max.score ? val : max, categories[0]);
644
- const topClassIndex = parseInt(topCat.index);
645
- const baseConf = topCat.score;
646
 
647
  const gridSize = 8;
648
  const patchSize = 224 / gridSize;
@@ -669,15 +688,14 @@
669
  tempCtx.fillRect(xStart, yStart, patchSize, patchSize);
670
 
671
  // Classify the occluded image
672
- const occResult = imageClassifier.classify(tempCanvas);
673
- let occConf = 0;
674
- if (occResult && occResult.classifications && occResult.classifications.length > 0) {
675
- const occCats = occResult.classifications[0].categories;
676
- const matchCat = occCats.find(c => parseInt(c.index) === topClassIndex);
677
- if (matchCat) {
678
- occConf = matchCat.score;
679
- }
680
- }
681
 
682
  // Drop in target class confidence
683
  const drop = Math.max(0, baseConf - occConf);
 
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;
 
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>
 
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) -->
 
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>
 
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 = [
 
289
  // ─────────────────────────────────────────────────────────
290
  // GLOBAL STATE
291
  // ─────────────────────────────────────────────────────────
292
+ let session = null;
293
  let currentTab = 'webcam';
294
  let currentStream = null;
295
  let activeFacingMode = 'environment'; // environment / user
 
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');
 
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
  }
 
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
 
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;
 
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);
models/{mobilenetv4_cbam_quantized.tflite β†’ mobilenetv4_cbam.onnx} RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:4d8309863138a8843087c7e41b22bd0e7eebf1172aa2d84a020f72266fa6d920
3
- size 3546976
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:22da85536dde28b02e793ca0bad907f13ddfb3e50da2a33f85ff67a1168608af
3
+ size 13051952