Xác thực khuôn mặt chạy trên thiết bị
Ứng dụng Next.js PWA đóng gói Android bằng Capacitor, phát hiện và cắt khuôn mặt ngay trên máy, backend FastAPI đối chiếu bằng ArcFace và Qdrant.
- Next.js
- Capacitor
- MediaPipe
- FastAPI
- Python
Ứng dụng xác thực khuôn mặt viết bằng Next.js, chạy như PWA trên web và đóng gói thành APK Android bằng Capacitor, kèm backend FastAPI riêng để đối chiếu.
Cái gì chạy ở máy, cái gì gửi lên server#
Câu hỏi đầu tiên tôi phải trả lời là ranh giới. Cuối cùng có hai flow khác nhau hẳn.
Màn hình chính so 1-1 và chạy trọn trên thiết bị: chọn một ảnh tham chiếu, chụp một ảnh trực tiếp, face-api.js trích descriptor cho cả hai rồi so khoảng cách. Không có gì rời khỏi máy.
Flow đăng ký và nhận diện là 1-N nên phải có server để tìm trong toàn bộ tập người đã
đăng ký. Ở đây tôi gửi ảnh chứ không gửi embedding: multipart JPEG lên
/api/register (năm ảnh cho một người) và /api/identify (một ảnh). Lý do rất đơn giản:
embedding chỉ so được với embedding cùng một model, mà model chạy trong trình duyệt và
model ONNX trên server là hai thứ khác nhau. Gửi descriptor của face-api.js lên để so với
ArcFace thì vô nghĩa.
Bù lại, ảnh gửi đi là ảnh đã cắt sát khuôn mặt, không phải cả khung hình.
Bắt khung bằng MediaPipe rồi mới chụp#
Màn hình camera dùng FaceDetector của @mediapipe/tasks-vision (BlazeFace short-range,
delegate GPU) ở chế độ VIDEO, chạy theo requestAnimationFrame. Mỗi khung hình tôi kiểm
tra bounding box của khuôn mặt có nằm trọn trong khung oval vẽ trên màn hình không; nếu
có thì bắt đầu đếm ngược ba giây và người dùng phải giữ yên hết ba giây thì máy mới chụp.
const detector = await FaceDetector.createFromOptions(vision, {
baseOptions: { modelAssetPath: BLAZE_FACE_SHORT_RANGE, delegate: 'GPU' },
runningMode: 'VIDEO',
})
const detectFace = () => {
const { detections } = faceDetector.detectForVideo(video, performance.now())
const box = detections[0]?.boundingBox
/* Mặt phải nằm trọn trong khung oval và giữ nguyên đủ COUNTDOWN_TIME giây. */
if (box && isInsideFrame(box)) {
alignStartTimeRef.current ??= Date.now()
if (Date.now() - alignStartTimeRef.current >= COUNTDOWN_TIME * 1000) takePhoto()
} else {
alignStartTimeRef.current = null
}
requestAnimationFrame(detectFace)
}Hai feature space, hai phép đo#
Trên máy, face-api.js cho descriptor 128 chiều và tôi so bằng khoảng cách Euclid với ngưỡng 0.4. Trước khi so có mấy cửa chặn: mỗi ảnh đúng một khuôn mặt, confidence tối thiểu 0.75, cạnh nhỏ nhất của bounding box ít nhất 100px. Ảnh mờ hay mặt quá nhỏ bị trả về sớm kèm lý do, thay vì cho ra một con số khoảng cách không đáng tin.
Detector cũng chọn theo nền tảng bằng Capacitor.isNativePlatform(): MTCNN cho web, SSD
MobileNet v1 cho bản native. MTCNN bắt mặt nhỏ tốt hơn nhưng nặng hơn hẳn, và trong
webview thì chi phí đó thấy rõ.
Phía server: ArcFace và Qdrant#
Backend FastAPI chỉ có hai endpoint làm việc thật. Ảnh vào được OpenCV decode, đưa về 112×112, chuẩn hoá theo quy ước ArcFace rồi chạy qua onnxruntime trên CPU.
def extract_face_embedding(self, image_data: bytes):
img = cv2.imdecode(np.frombuffer(image_data, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return None
# Tiền xử lý theo chuẩn ArcFace
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (112, 112))
img = (img.astype(np.float32) - 127.5) / 128.0
embedding = self.session.run(
[self.output_name], {self.input_name: img[np.newaxis, ...]}
)[0][0]
# Chuẩn hoá L2 để điểm cosine bên Qdrant có ý nghĩa
norm = np.linalg.norm(embedding)
return (embedding / norm).tolist() if norm > 1e-6 else embedding.tolist()Vector 512 chiều đi vào một collection Qdrant cấu hình Distance.COSINE. Khi đăng ký tôi
ghi năm điểm riêng cho cùng một người chứ không trung bình lại: mỗi góc mặt là một điểm,
tìm kiếm chỉ cần điểm gần nhất. Nhận diện gọi query_points với limit=1 và
score_threshold do client gửi lên (mặc định 0.8); dưới ngưỡng thì trả thẳng "người lạ"
thay vì cố đoán.
Đóng gói Android#
next.config.js xuất tĩnh vào out/, đúng thư mục Capacitor đọc. Điểm đáng nói là
service worker: next-pwa bị tắt hẳn khi build cho Android, vì tài nguyên đã nằm sẵn
trong APK, thêm một tầng cache nữa chỉ gây lệch phiên bản. Model
face-api.js được copy từ public/models sang out/models bằng một hook webpack
afterEmit nên chúng đi kèm luôn trong gói. Quyền camera xin qua @capacitor/camera,
và đường native dùng @capacitor-community/camera-preview để hiện preview gốc thay cho
thẻ <video>.
Kết quả#
- Flow so 1-1 chạy trọn trên máy; flow 1-N chỉ gửi lên ảnh đã cắt sát mặt
- Backend gọn đúng hai endpoint, embedding 512 chiều trong Qdrant, so bằng cosine với ngưỡng đổi được từ client
- Chưa có anti-spoofing: ảnh in hoặc màn hình điện thoại đưa vào khung vẫn qua được, vì ba giây "giữ yên" chỉ kiểm tra khung hình ổn định chứ không kiểm tra người thật
- Model MediaPipe (wasm và tflite) vẫn tải từ CDN nên lần mở camera đầu tiên cần mạng, khác với model face-api.js đã đóng sẵn trong gói
- Hai endpoint chưa có auth, còn ngưỡng 0.4 / 0.8 là chỉnh tay: tôi chưa dựng dataset đánh giá để nói con số nào mới đúng