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

Use Google MediaPipe tasks-vision for TFLite compatibility in browser

Browse files
Files changed (1) hide show
  1. index.html +92 -81
index.html CHANGED
@@ -10,10 +10,6 @@
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;
@@ -53,7 +49,7 @@
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>
@@ -195,7 +191,7 @@
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>
@@ -233,15 +229,17 @@
233
 
234
  <!-- Footer -->
235
  <footer class="mt-auto py-6 border-t border-white/5 px-6 text-center text-xs text-zinc-500">
236
- <p>&copy; 2026 Tokiva Team. Dibuat menggunakan TensorFlow.js TFLite Web engine.</p>
237
  </footer>
238
 
239
- <script>
 
 
 
240
  // ─────────────────────────────────────────────────────────
241
  // MODEL CONFIG & MAPS
242
  // ─────────────────────────────────────────────────────────
243
  const MODEL_PATH = 'models/mobilenetv4_cbam_quantized.tflite';
244
- const INPUT_SIZE = [224, 224];
245
 
246
  // 19 Class Labels ordered alphabetically (matching indices from training)
247
  const CLASS_NAMES = [
@@ -291,7 +289,7 @@
291
  // ─────────────────────────────────────────────────────────
292
  // GLOBAL STATE
293
  // ─────────────────────────────────────────────────────────
294
- let model = null;
295
  let currentTab = 'webcam';
296
  let currentStream = null;
297
  let activeFacingMode = 'environment'; // environment / user
@@ -327,26 +325,39 @@
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;
@@ -357,6 +368,9 @@
357
  // Initialize model load
358
  initModel();
359
 
 
 
 
360
  // ─────────────────────────────────────────────────────────
361
  // TAB MANAGEMENT
362
  // ─────────────────────────────────────────────────────────
@@ -481,39 +495,34 @@
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
@@ -567,7 +576,8 @@
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');
@@ -615,67 +625,68 @@
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;
@@ -703,7 +714,7 @@
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
  }
 
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
  <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>
 
191
 
192
  <!-- Predictions List -->
193
  <div class="flex flex-col gap-3">
194
+ <span class="text-xs font-semibold uppercase tracking-wider text-zinc-500">Alternatif Prediksi</span>
195
  <div id="list-predictions" class="flex flex-col gap-2.5">
196
  <!-- Dynamically populated rows -->
197
  </div>
 
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
  // ─────────────────────────────────────────────────────────
290
  // GLOBAL STATE
291
  // ─────────────────────────────────────────────────────────
292
+ let imageClassifier = 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 = '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');
357
  mainInterface.classList.remove('hidden');
358
  // Start webcam by default
359
  startWebcam();
360
+ }, 600);
361
  } catch (err) {
362
  console.error(err);
363
  loaderStatus.innerText = 'Gagal memuat model: ' + err.message;
 
368
  // Initialize model load
369
  initModel();
370
 
371
+ // Export tab switching to global scope for HTML onclick bindings
372
+ window.switchTab = switchTab;
373
+
374
  // ─────────────────────────────────────────────────────────
375
  // TAB MANAGEMENT
376
  // ─────────────────────────────────────────────────────────
 
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
 
576
 
577
  // Alternative list
578
  listPredictions.innerHTML = '';
579
+ const limit = Math.min(5, results.length);
580
+ for (let i = 1; i < limit; i++) {
581
  const item = results[i];
582
  const itemPct = (item.prob * 100).toFixed(1);
583
  const row = document.createElement('div');
 
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;
649
  const sensitivityGrid = Array(gridSize).fill().map(() => Array(gridSize).fill(0));
650
 
651
+ // We will perform grid occlusion directly on a helper canvas to get image fragments
652
+ const tempCanvas = document.createElement('canvas');
653
+ tempCanvas.width = 224;
654
+ tempCanvas.height = 224;
655
+ const tempCtx = tempCanvas.getContext('2d');
 
656
 
657
  let maxDrop = 0.0001;
658
 
659
  // Scan grid cells
660
  for (let i = 0; i < gridSize; i++) {
661
  for (let j = 0; j < gridSize; j++) {
662
+ // Draw original image resized to 224x224
663
+ tempCtx.drawImage(loadedImageElement, 0, 0, 224, 224);
664
 
665
+ // Draw grey occlusion block
666
+ tempCtx.fillStyle = 'rgb(128, 128, 128)';
667
+ const yStart = i * patchSize;
668
+ const xStart = j * patchSize;
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);
684
  sensitivityGrid[i][j] = drop;
685
  if (drop > maxDrop) maxDrop = drop;
686
  }
687
  }
688
 
689
+ // Draw Heatmap (Jet-colormap overlay)
690
  heatmapCanvas.width = 224;
691
  heatmapCanvas.height = 224;
692
  overlayCanvas.width = 224;
 
714
  ctxH.fillStyle = `rgb(${r}, ${g}, ${b})`;
715
  ctxH.fillRect(j * cellW, i * cellH, cellW, cellH);
716
 
717
+ // Overlay Canvas (Heatmap overlayed)
718
  ctxO.fillStyle = `rgba(${r}, ${g}, ${b}, 0.45)`;
719
  ctxO.fillRect(j * cellW, i * cellH, cellW, cellH);
720
  }