Spaces:
Running
Running
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 |
-
|
| 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 (
|
| 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">
|
| 218 |
-
<span class="font-semibold text-zinc-200">
|
| 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">
|
| 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>© 2026 Tokiva Team. Dibuat menggunakan
|
| 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/
|
| 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
|
| 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 = '
|
| 329 |
-
loaderPercentage.innerText = '
|
| 330 |
-
loaderStatus.innerText = '
|
| 331 |
-
|
| 332 |
-
//
|
| 333 |
-
const
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
loaderBar.style.width = '
|
| 338 |
-
loaderPercentage.innerText = '
|
| 339 |
-
loaderStatus.innerText = '
|
| 340 |
|
| 341 |
// Load the model
|
| 342 |
-
|
| 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 = '
|
| 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 (!
|
| 499 |
|
| 500 |
const startTime = performance.now();
|
| 501 |
|
| 502 |
-
// Perform inference using
|
| 503 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
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 || !
|
| 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
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
|
|
|
| 641 |
|
| 642 |
-
const
|
| 643 |
-
const
|
| 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
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 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>© 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:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:22da85536dde28b02e793ca0bad907f13ddfb3e50da2a33f85ff67a1168608af
|
| 3 |
+
size 13051952
|