Spaces:
Running
Running
anoderb commited on
Commit Β·
9a6102c
1
Parent(s): cc5d7a3
Use Google MediaPipe tasks-vision for TFLite compatibility in browser
Browse files- 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-
|
| 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>© 2026 Tokiva Team. Dibuat menggunakan
|
| 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
|
| 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 = '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
// Load the model
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
loaderBar.style.width = '100%';
|
| 341 |
loaderPercentage.innerText = '100%';
|
| 342 |
-
loaderStatus.innerText = '
|
| 343 |
|
| 344 |
setTimeout(() => {
|
| 345 |
loaderCard.classList.add('hidden');
|
| 346 |
mainInterface.classList.remove('hidden');
|
| 347 |
// Start webcam by default
|
| 348 |
startWebcam();
|
| 349 |
-
},
|
| 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 (!
|
| 485 |
|
| 486 |
const startTime = performance.now();
|
| 487 |
|
| 488 |
-
// Perform
|
| 489 |
-
const
|
| 490 |
-
|
| 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 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
}
|
| 518 |
|
| 519 |
// Capture frame from live webcam
|
|
@@ -567,7 +576,8 @@
|
|
| 567 |
|
| 568 |
// Alternative list
|
| 569 |
listPredictions.innerHTML = '';
|
| 570 |
-
|
|
|
|
| 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 || !
|
| 619 |
|
| 620 |
const btn = document.getElementById('btn-analyze');
|
| 621 |
btn.disabled = true;
|
| 622 |
btn.innerText = "MENGHITUNG HEATMAP (1-2 Detik)...";
|
| 623 |
|
| 624 |
-
//
|
| 625 |
-
const
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
const gridSize = 8;
|
| 634 |
const patchSize = 224 / gridSize;
|
| 635 |
const sensitivityGrid = Array(gridSize).fill().map(() => Array(gridSize).fill(0));
|
| 636 |
|
| 637 |
-
//
|
| 638 |
-
const
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
const
|
| 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 |
-
//
|
| 650 |
-
|
| 651 |
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
const
|
| 655 |
-
const
|
|
|
|
| 656 |
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 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 -
|
| 673 |
sensitivityGrid[i][j] = drop;
|
| 674 |
if (drop > maxDrop) maxDrop = drop;
|
| 675 |
}
|
| 676 |
}
|
| 677 |
|
| 678 |
-
// Draw Heatmap (
|
| 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
|
| 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>© 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 |
}
|