f1-team-detector / web /engine /browser.js
budijuarto's picture
QA fixes: label audit tab, attribution, a11y, robustness, honest reasoning texts
d2b6dca verified
Raw History Blame Contribute Delete
8.22 kB
/* F1Browser - runs the whole pipeline in the visitor's browser (Static Space build).
*
* Loads onnxruntime-web (global `ort`, from the CDN script tag), downloads the ONNX model once
* (kept in the Cache API afterwards), decodes photos with their EXIF orientation, and returns a
* result object with the same shape as the Python server's /api/detect response, so app.js
* renders both modes with the same code. Photos never leave the device.
*/
(function (root) {
"use strict";
const MAX_SIDE = 2048; // same downscale rule as routes.decode_upload
const MAX_PIXELS = 40_000_000; // same limits as routes.decode_upload
const MAX_ASPECT = 20;
const CAM_MAX_SIDE = 800; // same as inference.cam_to_png
const ORT_DIST = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.30.0/dist/";
const CACHE_NAME = "f1-detector-model-v1";
let detector = null;
let backend = null;
let initPromise = null;
async function fetchWithProgress(url, onProgress) {
let cache = null;
try { cache = await caches.open(CACHE_NAME); } catch (_) { /* Cache API unavailable (private mode) */ }
if (cache) {
const hit = await cache.match(url);
if (hit) { onProgress?.(1, true); return new Uint8Array(await hit.arrayBuffer()); }
}
const resp = await fetch(url);
if (!resp.ok) throw new Error(`Model gagal diunduh (status ${resp.status}).`);
const total = Number(resp.headers.get("content-length")) || 0;
const reader = resp.body.getReader();
const chunks = [];
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
if (total) onProgress?.(got / total, false);
}
const buf = new Uint8Array(got);
let o = 0;
for (const c of chunks) { buf.set(c, o); o += c.length; }
if (cache) {
try { await cache.put(url, new Response(buf, { headers: { "content-type": "application/octet-stream" } })); } catch (_) { /* quota */ }
}
return buf;
}
async function createDetector(model, ep) {
// logSeverityLevel 3 = errors only: the WebGPU EP otherwise warns (via console.error) that shape
// ops were placed on the CPU, which is intended and harmless.
const opts = { executionProviders: [ep], graphOptimizationLevel: "all", logSeverityLevel: 3 };
return root.F1Detector.create(root.ort, model, { imgsz: 512, stride: 32, names: ["Ferrari", "Mclaren", "Mercedes", "Redbull"] }, opts);
}
/** onnxruntime-web is loaded with an async script tag; wait for it (up to 60 s). */
async function waitForOrt(timeoutMs = 60000) {
const t0 = Date.now();
while (!root.ort) {
if (Date.now() - t0 > timeoutMs) throw new Error("onnxruntime-web tidak termuat dari CDN. Periksa koneksi internet lalu muat ulang halaman.");
await new Promise((r) => setTimeout(r, 100));
}
return root.ort;
}
/** Load model + centroids. Tries WebGPU first and falls back to WebAssembly. */
function init({ modelUrl, centroidsUrl, onProgress }) {
if (initPromise) return initPromise;
initPromise = (async () => {
const ort = await waitForOrt();
ort.env.wasm.wasmPaths = ORT_DIST;
ort.env.wasm.numThreads = root.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency || 1) : 1;
ort.env.logLevel = "error"; // ORT logs benign node-placement warnings through console.error
// ?backend=wasm forces the CPU path (useful when a GPU driver misbehaves, and for testing).
const forced = new URLSearchParams(root.location?.search || "").get("backend");
const [model, centroids] = await Promise.all([
fetchWithProgress(modelUrl, onProgress),
fetch(centroidsUrl).then((r) => r.json()),
]);
root.F1Reasoning.init(centroids);
const eps = [];
if (navigator.gpu && forced !== "wasm") eps.push("webgpu");
eps.push("wasm");
let lastErr = null;
for (const ep of eps) {
try {
const det = await createDetector(model, ep);
// One warm-up run proves the backend actually executes this graph.
const probe = root.F1Imaging.image(64, 48, 4);
probe.data.fill(128);
await det.detect(probe, { conf: 0.25, iou: 0.45, withCam: true });
detector = det;
backend = ep;
return { backend };
} catch (e) {
lastErr = e;
console.warn(`Backend ${ep} gagal, mencoba berikutnya:`, e);
}
}
throw lastErr || new Error("Tidak ada backend ONNX yang berjalan di browser ini.");
})();
initPromise.catch(() => { initPromise = null; }); // allow a retry after a failure
return initPromise;
}
/** Decode a Blob into an F1Imaging RGBA image, honouring EXIF orientation and MAX_SIDE. */
async function decode(blob) {
let bmp;
try {
// colorSpaceConversion "none": keep the decoded values as cv2.imread does (it ignores ICC profiles).
bmp = await createImageBitmap(blob, { imageOrientation: "from-image", colorSpaceConversion: "none" });
} catch (e) {
throw new Error("File bukan gambar yang dikenali. Gunakan JPG, PNG, atau WEBP (HEIC dari iPhone belum didukung).");
}
let w = bmp.width, h = bmp.height;
// Same limits as routes.decode_upload on the server.
if (w * h > MAX_PIXELS) { bmp.close?.(); throw new Error(`Resolusi foto terlalu besar (${w}x${h} piksel). Maksimal ${MAX_PIXELS / 1e6} megapiksel.`); }
if (Math.max(w, h) > MAX_ASPECT * Math.min(w, h)) { bmp.close?.(); throw new Error("Rasio sisi foto terlalu ekstrem untuk dideteksi. Gunakan foto dengan bentuk lebih wajar."); }
const s = Math.min(1, MAX_SIDE / Math.max(w, h));
w = Math.max(1, Math.round(w * s)); h = Math.max(1, Math.round(h * s));
const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(w, h) : Object.assign(document.createElement("canvas"), { width: w, height: h });
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.imageSmoothingQuality = "high";
ctx.fillStyle = "#000"; // transparent pixels become black, as on the server
ctx.fillRect(0, 0, w, h);
ctx.drawImage(bmp, 0, 0, w, h);
bmp.close?.();
const px = ctx.getImageData(0, 0, w, h);
return { data: px.data, width: w, height: h, channels: 4 };
}
/** EigenCAM map -> base64 PNG with the same colouring and size cap as the server. */
async function camToPngBase64(cam) {
const I = root.F1Imaging;
const s = Math.min(1, CAM_MAX_SIDE / Math.max(cam.width, cam.height));
const small = s < 1 ? I.resizeByFactor(cam, s, "area") : cam;
const rgba = I.turboOverlay(small);
const canvas = document.createElement("canvas");
canvas.width = small.width; canvas.height = small.height;
canvas.getContext("2d").putImageData(new ImageData(rgba, small.width, small.height), 0, 0);
return canvas.toDataURL("image/png").split(",")[1];
}
async function detect(blob, conf = 0.25, iou = 0.45) {
if (!detector) throw new Error("Model belum siap.");
const t0 = performance.now();
const img = await decode(blob);
const t1 = performance.now();
const out = await detector.detect(img, { conf, iou, withCam: true });
const t2 = performance.now();
const result = root.F1Reasoning.analyze(img, out.detections, out.candidates, out.cam, conf);
const t3 = performance.now();
result.image = { width: img.width, height: img.height };
result.params = { conf, iou, imgsz: 512 };
const tm = out.timing_ms || {};
result.timing_ms = {
decode: Math.round(t1 - t0),
detect: Math.round((tm.preprocess || 0) + (tm.inference || 0) + (tm.postprocess || 0)),
cam: Math.round(tm.cam || 0),
reasoning: Math.round(t3 - t2),
};
if (out.cam) result.cam_png = await camToPngBase64(out.cam);
return result;
}
root.F1Browser = {
init, detect,
get backend() { return backend; },
get ready() { return !!detector; },
deviceLabel() { return backend === "webgpu" ? "browser Anda (WebGPU)" : backend === "wasm" ? "browser Anda (WebAssembly, CPU)" : "browser Anda"; },
};
})(typeof self !== "undefined" ? self : this);