/* 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);