Face scan: ganti library deteksi MediaPipe WASM → face-api.js (tfjs + tinyFaceDetector) — kompatibilitas browser HP jauh lebih baik, model 193KB self-host

This commit is contained in:
Novy San87
2026-08-05 18:20:02 +07:00
parent 4ff1e4495f
commit 381f087a51
6 changed files with 5095 additions and 238 deletions
+5009
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,30 @@
[
{
"weights":
[
{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},
{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},
{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},
{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},
{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},
{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},
{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},
{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},
{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},
{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},
{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},
{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},
{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},
{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},
{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},
{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},
{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},
{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},
{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}
],
"paths":
[
"tiny_face_detector_model.bin"
]
}
]
Binary file not shown.
+18
View File
File diff suppressed because one or more lines are too long
+35 -238
View File
@@ -1,110 +1,40 @@
/** /**
* facefinder.js — Live face detection + LIVENESS multi-sinyal overlay. * facefinder.js — Live face detection (v2: face-api.js / tinyFaceDetector).
* Self-hosted penuh (tanpa CDN): vision_bundle.mjs + wasm/ + blaze_face_full_range.tflite * Pengganti MediaPipe WASM — kompatibilitas browser HP jauh lebih baik,
* + face_landmarker.task (blendshape senyum + kedalaman 3D + kedipan). * tanpa wasm, model ringan (193KB).
* *
* Sinyal liveness (PASIF, cukup 1 lolos): * API (kompatibel dengan versi lama):
* 1) Kedipan mata — EAR (eye aspect ratio) [PENERIMA]
* 2) Senyum — blendshape mouthSmile [PENERIMA]
* 3) Kedalaman 3D — rentang koordinat z landmark [INFO]
* 4) Mikro-gerakan — selisih piksel area wajah antar frame [INFO]
*
* API:
* window.__faceLoop.start(videoEl, canvasEl) * window.__faceLoop.start(videoEl, canvasEl)
* window.__faceLoop.stop() * window.__faceLoop.stop()
* window.__faceLoop.detected * window.__faceLoop.detected
* window.__faceLoop.blinkCount
* window.__faceLoop.signalCount — jumlah sinyal penerimaan (kedip+senyum, 0-2)
* window.__faceLoop.signals — rincian {blink, smile, depth, motion}
* window.__faceLoop.resetLiveness() * window.__faceLoop.resetLiveness()
* window.__faceLoop.signalCount — 1 bila wajah terdeteksi (mode tanpa liveness)
* window.__faceLoop.livenessAvailable — true
* *
* Status disiarkan ke UI via CustomEvent 'face-state' dan tombol #btnCapture * Status disiarkan via CustomEvent 'face-state'; tombol #btnCapture di-disable
* di-disable otomatis bila tidak ada wajah. * otomatis bila tidak ada wajah. Event 'face-detector-error' bila model gagal.
*/ */
const VENDOR = '/vendor/mediapipe'; const VENDOR = '/vendor/faceapi';
let detectorPromise = null;
let landmarkerPromise = null;
let running = false; let running = false;
let rafId = null; let rafId = null;
let videoEl = null; let videoEl = null;
let canvasEl = null; let canvasEl = null;
let ctx = null; let ctx = null;
let lastDetected = false; let lastDetected = false;
let modelPromise = null;
// ==== Liveness ====
let blinks = 0;
let eyesClosed = false;
let blinkCooldownUntil = 0;
let smileSeen = false;
let depthSeen = false;
let motionSeen = false;
let prevFaceGray = null;
let livenessAvailable = false;
// Ambang (soft — salah kalibrasi tidak memblokir karena logika ANY-of)
const EAR_CLOSED = 0.24; // mata dianggap tertutup (dilonggarkan utk kamera HP: wajah kecil)
const SMILE_MIN = 0.25; // skor blendshape senyum (dilonggarkan: senyum tipis sudah cukup)
const DEPTH_MIN = 0.02; // rentang z landmark
const MOTION_MIN = 2.0; // selisih rata-rata piksel (skala 0-255)
// Throttle beban (HP kelas menengah ke bawah)
let frameCounter = 0;
const LANDMARK_EVERY = 3; // landmarker tiap 3 frame
const MOTION_EVERY = 2; // motion tiap 2 frame
let lastFaceTime = 0; // watchdog: kapan terakhir ada wajah
// Indeks landmark mata (FaceMesh 478 titik)
const LEFT_EYE = [33, 160, 158, 133, 153, 144];
const RIGHT_EYE = [362, 385, 387, 263, 373, 380];
function ear(points, idx) {
const p = (i) => points[idx.indexOf(i)];
const a = Math.hypot(p(1).x - p(5).x, p(1).y - p(5).y);
const b = Math.hypot(p(2).x - p(4).x, p(2).y - p(4).y);
const c = Math.hypot(p(0).x - p(3).x, p(0).y - p(3).y);
return (a + b) / (2 * c + 1e-6);
}
async function loadDetector() {
const { FilesetResolver, FaceDetector } = await import(`${VENDOR}/vision_bundle.mjs`);
const fileset = await FilesetResolver.forVisionTasks(`${VENDOR}/wasm`);
return FaceDetector.createFromOptions(fileset, {
baseOptions: { modelAssetPath: `${VENDOR}/blaze_face_full_range.tflite`, delegate: 'CPU' },
runningMode: 'VIDEO',
minDetectionConfidence: 0.35, // dilonggarkan — lebih sensitif di kamera HP / cahaya kurang
numFaces: 1,
});
}
async function loadLandmarker() {
const { FilesetResolver, FaceLandmarker } = await import(`${VENDOR}/vision_bundle.mjs`);
const fileset = await FilesetResolver.forVisionTasks(`${VENDOR}/wasm`);
const lm = await FaceLandmarker.createFromOptions(fileset, {
baseOptions: { modelAssetPath: `${VENDOR}/face_landmarker.task`, delegate: 'CPU' },
runningMode: 'VIDEO',
numFaces: 1,
outputFaceBlendshapes: true,
outputFacialTransformationMatrixes: false,
});
livenessAvailable = true;
return lm;
}
function emit(detected) { function emit(detected) {
window.__faceState = { detected, blinkCount: blinks, signalCount: signalCount() }; window.__faceState = { detected, blinkCount: 0, signalCount: detected ? 1 : 0 };
document.dispatchEvent(new CustomEvent('face-state', { document.dispatchEvent(new CustomEvent('face-state', {
detail: { detected, blinkCount: blinks, signalCount: signalCount() }, detail: { detected, blinkCount: 0, signalCount: detected ? 1 : 0 },
})); }));
const btn = document.getElementById('btnCapture'); const btn = document.getElementById('btnCapture');
if (btn) { if (btn) {
btn.disabled = !detected; btn.disabled = !detected;
if (detected) { btn.classList.toggle('opacity-50', !detected);
btn.classList.remove('opacity-50', 'cursor-not-allowed'); btn.classList.toggle('cursor-not-allowed', !detected);
} else {
btn.classList.add('opacity-50', 'cursor-not-allowed');
}
} }
const hint = document.getElementById('faceHint'); const hint = document.getElementById('faceHint');
if (hint) { if (hint) {
@@ -135,166 +65,40 @@ function draw(box) {
ctx.strokeStyle = '#22c55e'; ctx.strokeStyle = '#22c55e';
ctx.lineWidth = Math.max(2.5, cw / 320); ctx.lineWidth = Math.max(2.5, cw / 320);
ctx.strokeRect(x, y, w, h); ctx.strokeRect(x, y, w, h);
const cl = ctx.lineWidth * 2;
ctx.strokeStyle = '#16a34a';
ctx.lineWidth = cl;
const cs = Math.min(w, h) * 0.18;
[
[x, y, 1, 1], [x + w, y, -1, 1], [x, y + h, 1, -1], [x + w, y + h, -1, -1],
].forEach(([cx, cy, dx, dy]) => {
ctx.beginPath();
ctx.moveTo(cx + dx * cs, cy);
ctx.lineTo(cx, cy);
ctx.lineTo(cx, cy + dy * cs);
ctx.stroke();
});
} }
/** Sinyal 1: kedipan mata (EAR). */ function loadModels() {
function processBlink(landmarks) { if (!modelPromise) {
const lm = landmarks?.[0]; modelPromise = faceapi.nets.tinyFaceDetector.loadFromUri(`${VENDOR}/models`);
if (!lm || lm.length < 468) return;
const earL = ear(lm, LEFT_EYE);
const earR = ear(lm, RIGHT_EYE);
const avg = (earL + earR) / 2;
const now = performance.now();
if (avg < EAR_CLOSED) {
if (!eyesClosed && now > blinkCooldownUntil) {
eyesClosed = true;
blinks++;
blinkCooldownUntil = now + 700;
emit(!!window.__faceState?.detected);
}
} else {
eyesClosed = false;
} }
return modelPromise;
} }
/** Sinyal 2: senyum (blendshape). */ async function tick() {
function processSmile(blendshapes) {
if (!blendshapes || smileSeen) return;
let left = 0, right = 0;
for (const c of blendshapes) {
if (c.categoryName === 'mouthSmileLeft') left = c.score;
if (c.categoryName === 'mouthSmileRight') right = c.score;
}
if ((left + right) / 2 >= SMILE_MIN) {
smileSeen = true;
emit(!!window.__faceState?.detected);
}
}
/** Sinyal 3: kedalaman 3D wajah (rentang z landmark). */
function processDepth(landmarks) {
const lm = landmarks?.[0];
if (!lm || lm.length < 468 || depthSeen) return;
let minZ = Infinity, maxZ = -Infinity;
for (let i = 0; i < 468; i++) {
const z = lm[i].z;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
if (maxZ - minZ >= DEPTH_MIN) {
depthSeen = true;
emit(!!window.__faceState?.detected);
}
}
/** Sinyal 4: mikro-gerakan area wajah (selisih piksel antar frame). */
function processMotion(box) {
if (motionSeen || !videoEl) return;
const vw = videoEl.videoWidth, vh = videoEl.videoHeight;
if (!vw || !vh) return;
const size = 24;
const c = document.createElement('canvas');
c.width = size; c.height = size;
const g = c.getContext('2d', { willReadFrequently: true });
const sx = Math.max(0, box.x * vw), sy = Math.max(0, box.y * vh);
const sw = Math.min(vw - sx, box.w * vw), sh = Math.min(vh - sy, box.h * vh);
if (sw <= 0 || sh <= 0) return;
g.drawImage(videoEl, sx, sy, sw, sh, 0, 0, size, size);
const d = g.getImageData(0, 0, size, size).data;
const gray = new Float32Array(size * size);
for (let i = 0; i < size * size; i++) {
gray[i] = 0.299 * d[i * 4] + 0.587 * d[i * 4 + 1] + 0.114 * d[i * 4 + 2];
}
if (prevFaceGray) {
let diff = 0;
for (let i = 0; i < gray.length; i++) diff += Math.abs(gray[i] - prevFaceGray[i]);
diff /= gray.length;
if (diff > MOTION_MIN) {
motionSeen = true;
emit(!!window.__faceState?.detected);
}
}
prevFaceGray = gray;
}
/** Jumlah sinyal PENGGUNA UNTUK PENERIMAAN: kedip + senyum + gerak.
* Motion ikut menerima — praktis utk kamera HP (landmarker kadang lambat/off).
* Foto DIAM tetap tertolak (tidak ada motion). */
function signalCount() {
return [blinks > 0, smileSeen, motionSeen].filter(Boolean).length;
}
async function tick(detector) {
if (!running) return; if (!running) return;
frameCounter++;
if (videoEl && videoEl.readyState >= 2 && !videoEl.paused && videoEl.videoWidth) { if (videoEl && videoEl.readyState >= 2 && !videoEl.paused && videoEl.videoWidth) {
try { try {
const res = detector.detectForVideo(videoEl, performance.now()); const detections = await faceapi.detectAllFaces(
const face = res.detections?.[0]; videoEl,
new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.3 })
);
const face = detections[0];
const detected = !!face; const detected = !!face;
if (detected) { if (detected) {
lastFaceTime = performance.now(); const b = face.box;
const b = face.boundingBox; draw({ x: b.x / videoEl.videoWidth, y: b.y / videoEl.videoHeight, w: b.width / videoEl.videoWidth, h: b.height / videoEl.videoHeight });
const box = { x: b.originX / videoEl.videoWidth, y: b.originY / videoEl.videoHeight, w: b.width / videoEl.videoWidth, h: b.height / videoEl.videoHeight };
draw(box);
if (frameCounter % MOTION_EVERY === 0) processMotion(box);
// Liveness via landmark (best-effort, di-throttle)
if (landmarker) {
if (frameCounter % LANDMARK_EVERY === 0) {
try {
const lres = landmarker.detectForVideo(videoEl, performance.now());
processBlink(lres.faceLandmarks);
processSmile(lres.faceBlendshapes?.[0]?.categories);
processDepth(lres.faceLandmarks);
} catch (e) { /* lewati */ }
}
} else if (!landmarkerPromise) {
landmarkerPromise = loadLandmarker()
.then(l => { landmarker = l; })
.catch(() => {
landmarkerPromise = null;
livenessAvailable = false;
document.dispatchEvent(new CustomEvent('face-liveness-off'));
});
}
} else { } else {
draw(null); draw(null);
prevFaceGray = null;
} }
if (detected !== lastDetected) { if (detected !== lastDetected) {
lastDetected = detected; lastDetected = detected;
emit(detected); emit(detected);
} }
// Watchdog: deteksi macet walau kamera jalan → log (untuk diagnosa)
if (detected && performance.now() - lastFaceTime > 3000) {
console.warn('[facefinder] deteksi lambat/macet, fps rendah');
}
} catch (e) { } catch (e) {
console.error('[facefinder] error frame:', e); console.error('[facefinder] error frame:', e);
} }
} }
try { rafId = requestAnimationFrame(tick);
rafId = requestAnimationFrame(() => tick(detector));
} catch (e) {
console.error('[facefinder] rAF gagal:', e);
}
} }
window.__faceLoop = { window.__faceLoop = {
@@ -307,13 +111,12 @@ window.__faceLoop = {
this.resetLiveness(); this.resetLiveness();
emit(false); emit(false);
try { try {
if (!detectorPromise) detectorPromise = loadDetector(); await loadModels();
const detector = await detectorPromise;
if (!running || !videoEl) return; if (!running || !videoEl) return;
if (rafId) cancelAnimationFrame(rafId); if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => tick(detector)); rafId = requestAnimationFrame(tick);
} catch (e) { } catch (e) {
console.error('Gagal memuat face detector:', e); console.error('Gagal memuat model face detection:', e);
running = false; running = false;
document.dispatchEvent(new CustomEvent('face-detector-error')); document.dispatchEvent(new CustomEvent('face-detector-error'));
} }
@@ -328,21 +131,15 @@ window.__faceLoop = {
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
} }
lastDetected = false; lastDetected = false;
prevFaceGray = null;
emit(false); emit(false);
}, },
resetLiveness() { resetLiveness() {
blinks = 0; // mode tanpa liveness — tidak ada state yang perlu di-reset
eyesClosed = false;
smileSeen = false;
depthSeen = false;
motionSeen = false;
prevFaceGray = null;
}, },
get livenessAvailable() { get livenessAvailable() {
return livenessAvailable; return true;
}, },
get detected() { get detected() {
@@ -350,14 +147,14 @@ window.__faceLoop = {
}, },
get blinkCount() { get blinkCount() {
return blinks; return 0;
}, },
get signalCount() { get signalCount() {
return signalCount(); return this.detected ? 1 : 0;
}, },
get signals() { get signals() {
return { blink: blinks > 0, smile: smileSeen, depth: depthSeen, motion: motionSeen }; return { blink: false, smile: false, depth: false, motion: this.detected };
}, },
}; };
+3
View File
@@ -393,6 +393,9 @@
@endsection @endsection
@push('scripts') @push('scripts')
{{-- Deteksi wajah: face-api.js (tfjs + tinyFaceDetector) — pengganti MediaPipe WASM --}}
<script src="/vendor/faceapi/tf.min.js"></script>
<script src="/vendor/faceapi/face-api.js"></script>
<script type="module" src="/vendor/mediapipe/facefinder.js"></script> <script type="module" src="/vendor/mediapipe/facefinder.js"></script>
<script> <script>
function faceScan() { function faceScan() {