Spaces:
Running
Running
Download web/engine/browser.js from budijuarto/f1-team-detector: direct link, hf CLI and curl.
- Browser
- Download file 8.22 kB
-
https://huggingface.co/spaces/budijuarto/f1-team-detector/resolve/main/web/engine/browser.js
- Command line
-
hf download hf://spaces/budijuarto/f1-team-detector/web/engine/browser.js
-
curl -L -o browser.js https://huggingface.co/spaces/budijuarto/f1-team-detector/resolve/main/web/engine/browser.js
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); | |