| import { fetchWithProgress } from 'lib/fetch-progress'; |
| import { loadSession } from 'lib/backend'; |
| import { dispatchBackendEvent } from 'lib/backend-events'; |
|
|
| const DETECTORS = { |
| 'face-yunet': { |
| label: 'Face (YuNet)', |
| url: 'https://huggingface.co/opencv/face_detection_yunet/resolve/main/face_detection_yunet_2023mar.onnx', |
| inputWidth: 640, |
| inputHeight: 640, |
| scoreThreshold: 0.7, |
| iouThreshold: 0.3, |
| topK: 20, |
| }, |
| }; |
|
|
| export { DETECTORS }; |
|
|
| function clamp(v, min, max) { |
| return v < min ? min : v > max ? max : v; |
| } |
|
|
|
|
| function tensorToRows(tensor) { |
| const dims = tensor.dims || []; |
| const data = tensor.data; |
| if (!data || !dims.length) return null; |
|
|
| |
| if (dims.length === 2) { |
| return { |
| rows: dims[0], |
| cols: dims[1], |
| at: (row, col) => data[row * dims[1] + col], |
| }; |
| } |
|
|
| |
| if (dims.length === 3 && dims[0] === 1) { |
| return { |
| rows: dims[1], |
| cols: dims[2], |
| at: (row, col) => data[row * dims[2] + col], |
| }; |
| } |
|
|
| return null; |
| } |
|
|
| function nms(boxes, iouThreshold, topK) { |
| const sorted = [...boxes].sort((a, b) => b.score - a.score); |
| const kept = []; |
|
|
| function iou(a, b) { |
| const x1 = Math.max(a.x, b.x); |
| const y1 = Math.max(a.y, b.y); |
| const x2 = Math.min(a.x + a.w, b.x + b.w); |
| const y2 = Math.min(a.y + a.h, b.y + b.h); |
| const iw = Math.max(0, x2 - x1); |
| const ih = Math.max(0, y2 - y1); |
| const inter = iw * ih; |
| const union = a.w * a.h + b.w * b.h - inter; |
| return union <= 0 ? 0 : inter / union; |
| } |
|
|
| for (const cand of sorted) { |
| if (kept.length >= topK) break; |
| let suppressed = false; |
| for (const k of kept) { |
| if (iou(cand, k) > iouThreshold) { |
| suppressed = true; |
| break; |
| } |
| } |
| if (!suppressed) kept.push(cand); |
| } |
|
|
| return kept; |
| } |
|
|
| function parseDecodedDetections(outputTensor, scoreThreshold, srcW, srcH, inW, inH) { |
| const rows = tensorToRows(outputTensor); |
| if (!rows || rows.cols < 15) return []; |
| const sx = srcW / inW; |
| const sy = srcH / inH; |
| const faces = []; |
|
|
| for (let i = 0; i < rows.rows; i++) { |
| const score = rows.at(i, 14); |
| if (score < scoreThreshold) continue; |
|
|
| const x = rows.at(i, 0) * sx; |
| const y = rows.at(i, 1) * sy; |
| const w = rows.at(i, 2) * sx; |
| const h = rows.at(i, 3) * sy; |
| if (w <= 1 || h <= 1) continue; |
|
|
| faces.push({ x, y, w, h, score }); |
| } |
|
|
| return faces; |
| } |
|
|
| function readFeatureVector(tensor, anchorIndex, featureCount) { |
| const dims = tensor.dims || []; |
| const data = tensor.data; |
| if (!data) return null; |
|
|
| |
| if (dims.length === 3 && dims[0] === 1 && dims[2] >= featureCount) { |
| const off = anchorIndex * dims[2]; |
| const out = new Array(featureCount); |
| for (let i = 0; i < featureCount; i++) out[i] = data[off + i]; |
| return out; |
| } |
|
|
| |
| if (dims.length === 4 && dims[0] === 1 && dims[1] >= featureCount) { |
| const anchors = dims[2] * dims[3]; |
| if (anchorIndex >= anchors) return null; |
| const out = new Array(featureCount); |
| for (let i = 0; i < featureCount; i++) out[i] = data[i * anchors + anchorIndex]; |
| return out; |
| } |
|
|
| |
| if (dims.length === 4 && dims[0] === 1 && dims[3] >= featureCount) { |
| const off = anchorIndex * dims[3]; |
| const out = new Array(featureCount); |
| for (let i = 0; i < featureCount; i++) out[i] = data[off + i]; |
| return out; |
| } |
|
|
| const off = anchorIndex * featureCount; |
| if (off + featureCount - 1 >= data.length) return null; |
| const out = new Array(featureCount); |
| for (let i = 0; i < featureCount; i++) out[i] = data[off + i]; |
| return out; |
| } |
|
|
| function decodeRawYunet(results, scoreThreshold, srcW, srcH, padW, padH) { |
| const sx = srcW / padW; |
| const sy = srcH / padH; |
| const outByName = new Map(Object.entries(results)); |
| const faces = []; |
| const strides = [8, 16, 32]; |
|
|
| for (const stride of strides) { |
| const cls = outByName.get(`cls_${stride}`); |
| const obj = outByName.get(`obj_${stride}`); |
| const bbox = outByName.get(`bbox_${stride}`); |
| if (!cls || !obj || !bbox) continue; |
|
|
| const fmW = Math.floor(padW / stride); |
| const fmH = Math.floor(padH / stride); |
| const anchorCount = fmW * fmH; |
|
|
| for (let i = 0; i < anchorCount; i++) { |
| const clsVec = readFeatureVector(cls, i, 1); |
| const objVec = readFeatureVector(obj, i, 1); |
| if (!clsVec || !objVec) continue; |
| const clsScore = clamp(clsVec[0], 0, 1); |
| const objScore = clamp(objVec[0], 0, 1); |
| const score = Math.sqrt(clsScore * objScore); |
| if (score < scoreThreshold) continue; |
|
|
| const bb = readFeatureVector(bbox, i, 4); |
| if (!bb) continue; |
| const [dx, dy, dw, dh] = bb; |
|
|
| const c = i % fmW; |
| const r = Math.floor(i / fmW); |
| const cx = (c + dx) * stride; |
| const cy = (r + dy) * stride; |
| const w = Math.exp(dw) * stride; |
| const h = Math.exp(dh) * stride; |
| const x1 = cx - w / 2; |
| const y1 = cy - h / 2; |
| if (w <= 1 || h <= 1) continue; |
|
|
| faces.push({ |
| x: clamp(x1 * sx, 0, srcW - 1), |
| y: clamp(y1 * sy, 0, srcH - 1), |
| w: Math.min(w * sx, srcW), |
| h: Math.min(h * sy, srcH), |
| score, |
| }); |
| } |
| } |
|
|
| return faces; |
| } |
|
|
| export class FaceDetectorEngine { |
| #session = null; |
| #modelBuffer = null; |
| #currentDetectorKey = null; |
| #intent = null; |
| |
| |
| |
| #realizedBackend = null; |
| #backendListener = null; |
| get isLoaded() { return this.#session !== null; } |
| get realizedBackend() { return this.#realizedBackend; } |
| get intent() { return this.#intent; } |
| get currentDetector() { return this.#currentDetectorKey; } |
|
|
| #trackRealizedBackend() { |
| if (this.#backendListener) return; |
| this.#backendListener = (e) => { |
| const d = e?.detail; |
| if (d && d.kind === 'success' && typeof d.backend === 'string') { |
| this.#realizedBackend = d.backend; |
| } |
| }; |
| document.addEventListener('aitools:backend-event', this.#backendListener); |
| } |
| #untrackRealizedBackend() { |
| if (!this.#backendListener) return; |
| document.removeEventListener('aitools:backend-event', this.#backendListener); |
| this.#backendListener = null; |
| } |
|
|
| async loadModel(detectorKey = 'face-yunet', intent = 'cpu', onProgress) { |
| if (onProgress != null && typeof onProgress !== 'function') { |
| console.warn('[FaceDetectorEngine] Ignoring non-function onProgress callback.', { |
| type: typeof onProgress, |
| value: onProgress, |
| detectorKey, |
| intent, |
| }); |
| } |
| intent = normalizeIntent(intent); |
| const report = typeof onProgress === 'function' ? onProgress : null; |
| if (this.#session && this.#currentDetectorKey === detectorKey && this.#intent === intent) { |
| if (this.#realizedBackend) { |
| dispatchBackendEvent({ kind: 'success', backend: this.#realizedBackend }); |
| } |
| return; |
| } |
|
|
| const cfg = DETECTORS[detectorKey]; |
| if (!cfg) throw new Error(`Unknown detector: ${detectorKey}`); |
|
|
| if (this.#session) { |
| try { this.#session.release(); } catch {} |
| this.#session = null; |
| } |
| if (this.#currentDetectorKey !== detectorKey) { |
| this.#modelBuffer = null; |
| } |
|
|
| if (!this.#modelBuffer) { |
| this.#modelBuffer = await fetchWithProgress(cfg.url, report); |
| } |
|
|
| report?.(1, 'Loading detector into runtime...'); |
| console.info(`[FaceDetectorEngine] Loading detector "${detectorKey}" with intent "${intent}"`); |
| const { session, realizedBackend } = await loadSession(this.#modelBuffer, intent); |
| this.#session = session; |
| this.#intent = intent; |
| this.#realizedBackend = realizedBackend; |
| this.#currentDetectorKey = detectorKey; |
| this.#trackRealizedBackend(); |
| console.info(`[FaceDetectorEngine] Detector ready on ${realizedBackend}`); |
| report?.(1, 'Detector loaded.'); |
| } |
|
|
| async detectFaces(image, { |
| detectorKey = 'face-yunet', |
| scoreThreshold, |
| iouThreshold, |
| topK, |
| signal, |
| } = {}) { |
| if (!this.#session || this.#currentDetectorKey !== detectorKey) { |
| throw new Error('Detector not loaded — call loadModel() first'); |
| } |
| if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError'); |
|
|
| const cfg = DETECTORS[detectorKey]; |
| const minScore = Number.isFinite(scoreThreshold) ? scoreThreshold : cfg.scoreThreshold; |
| const maxIou = Number.isFinite(iouThreshold) ? iouThreshold : cfg.iouThreshold; |
| const maxKeep = Number.isFinite(topK) ? topK : cfg.topK; |
| const srcW = image.width; |
| const srcH = image.height; |
| const inW = cfg.inputWidth; |
| const inH = cfg.inputHeight; |
|
|
| const prepCanvas = document.createElement('canvas'); |
| prepCanvas.width = inW; |
| prepCanvas.height = inH; |
| const prepCtx = prepCanvas.getContext('2d'); |
| prepCtx.drawImage(image, 0, 0, inW, inH); |
| const imageData = prepCtx.getImageData(0, 0, inW, inH); |
| const px = imageData.data; |
|
|
| const planeSize = inW * inH; |
| const input = new Float32Array(3 * planeSize); |
| for (let i = 0; i < planeSize; i++) { |
| const si = i * 4; |
| |
| |
| input[i] = px[si + 2]; |
| input[planeSize + i] = px[si + 1]; |
| input[2 * planeSize + i] = px[si]; |
| } |
|
|
| prepCanvas.width = 0; |
| prepCanvas.height = 0; |
|
|
| if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError'); |
|
|
| const ort = globalThis.ort; |
| const tensor = new ort.Tensor('float32', input, [1, 3, inH, inW]); |
| const inputName = this.#session.inputNames[0]; |
| const results = await this.#session.run({ [inputName]: tensor }); |
| tensor.dispose(); |
|
|
| let candidates = []; |
| const outputNames = this.#session.outputNames || []; |
| if (outputNames.length === 1) { |
| const raw = results[outputNames[0]]; |
| candidates = parseDecodedDetections(raw, minScore, srcW, srcH, inW, inH); |
| } |
|
|
| if (!candidates.length) { |
| candidates = decodeRawYunet(results, minScore, srcW, srcH, inW, inH); |
| } |
|
|
| for (const name of outputNames) { |
| try { results[name]?.dispose?.(); } catch {} |
| } |
|
|
| const filtered = nms( |
| candidates, |
| maxIou, |
| maxKeep, |
| ); |
|
|
| return filtered.map(face => ({ |
| ...face, |
| x: clamp(face.x, 0, srcW - 1), |
| y: clamp(face.y, 0, srcH - 1), |
| w: clamp(face.w, 1, srcW), |
| h: clamp(face.h, 1, srcH), |
| })); |
| } |
|
|
| release() { |
| this.#untrackRealizedBackend(); |
| if (this.#session) { |
| try { this.#session.release(); } catch {} |
| this.#session = null; |
| } |
| this.#modelBuffer = null; |
| this.#currentDetectorKey = null; |
| this.#intent = null; |
| this.#realizedBackend = null; |
| } |
| } |
|
|
| function normalizeIntent(value) { |
| if (value === 'webgpu' || value === 'gpu') return 'gpu'; |
| if (value === 'wasm' || value === 'cpu') return 'cpu'; |
| return 'cpu'; |
| } |
|
|