updraft / features /upscaler /engine /face-detector-engine.js
Nicholas Celestin
Build update — 2026-05-22T18:34:00.912Z
3f22414
Raw
History Blame Contribute Delete
11 kB
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;
// [N, C]
if (dims.length === 2) {
return {
rows: dims[0],
cols: dims[1],
at: (row, col) => data[row * dims[1] + col],
};
}
// [1, N, C]
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;
// [1, A, F]
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;
}
// [1, F, H, W]
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;
}
// [1, H, W, F]
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;
// Set by loadSession; kept current by #backendListener so a runtime EP
// fallback doesn't leave a stale label that the loadModel early-return
// re-announces later.
#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;
// Match OpenCV DNN blobFromImage defaults used by FaceDetectorYN:
// BGR order, no scale, zero mean.
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';
}