Liveness Calculation và Anti-Spoofing
Mục tiêu bài học
- Hiểu liveness detection là gì
- Nắm công thức tính ở frontend (CODE THỰC TẾ)
- Biết backend anti-spoof hoạt động ra sao
- Học best practices và recommendations
⚠️ CHÚ Ý: Bài học này phân biệt rõ:
- ✅ [THỰC TẾ] = Code đang được dùng trong
FaceDetectionCamera.vue- 💡 [ĐỀ XUẤT] = Code recommendation, chưa implement
Liveness Detection là gì?
Định nghĩa
Liveness detection (phát hiện liveness) xác định người trước camera là người thật (live person), không phải:
- ❌ Ảnh in (printed photo)
- ❌ Ảnh trên màn hình (screen replay)
- ❌ Video replay
- ❌ Mask/mặt nạ
- ❌ Deepfake
Phân loại
Active Liveness (liveness chủ động - cần tương tác):
- Nhấp nháy mắt
- Xoay đầu (trái/phải/lên/xuống)
- Mỉm cười
- → ✅ [THỰC TẾ]: Portal dùng active liveness (xoay đầu)
Passive Liveness (liveness thụ động - không cần tương tác):
- Phân tích kết cấu (texture analysis)
- Phát hiện độ sâu (depth detection)
- Phân tích chuyển động (motion analysis)
- → 💡 [ĐỀ XUẤT]: Có thể bổ sung passive
MediaPipe FaceMesh: Cách hoạt động
468 Landmarks Detection
MediaPipe FaceMesh detect 468 điểm trên khuôn mặt:
Phân bố landmarks:
┌─────────────────────────────────────┐
│ 👁️ Mắt trái: 71 points │
│ 👁️ Mắt phải: 71 points │
│ 👃 Mũi: 55 points │
│ 👄 Miệng: 80 points │
│ 😊 Khuôn mặt: 191 points │
├─────────────────────────────────────┤
│ Tổng: 468 landmarks │
└─────────────────────────────────────┘Hệ tọa độ
Mỗi điểm mốc có 3 tọa độ (x, y, z):
interface Landmark {
x: number; // Ngang: 0 (trái) → 1 (phải)
y: number; // Dọc: 0 (trên) → 1 (dưới)
z: number; // Độ sâu: âm (gần camera), dương (xa camera)
}
// ✅ [THỰC TẾ] Ví dụ: Đầu mũi
const noseTip = landmarks[1]; // Chỉ số 1 = đầu mũi
console.log(noseTip);
// { x: 0.5, y: 0.45, z: -0.02 }Chỉ số điểm mốc quan trọng
// ✅ [THỰC TẾ] - Được dùng trong face-liveness.ts
const KEY_POINTS = {
// Tính toán góc khuôn mặt (yaw, pitch, roll)
LEFT_CHEEK: 127,
RIGHT_CHEEK: 356,
NOSE_BRIDGE: 6,
// Mắt (cho phát hiện nháy mắt)
LEFT_EYE: {
TOP: 159,
BOTTOM: 145,
LEFT: 33,
RIGHT: 133,
},
RIGHT_EYE: {
TOP: 386,
BOTTOM: 374,
LEFT: 362,
RIGHT: 263,
},
// Điểm mốc dọc (cho tính toán pitch)
FOREHEAD: 10,
NOSE_TIP: 4,
CHIN: 152,
};Frontend: Active Liveness (CODE THỰC TẾ)
✅ [THỰC TẾ] Face Action Detection
Portal yêu cầu user thực hiện các hành động:
// ✅ CODE THỰC TẾ từ FaceDetectionCamera.vue
// All available face actions
const allFaceActions: IFaceAction[] = [
{ action: "forward", message: "Nhìn thẳng về phía máy ảnh" },
{ action: "left", message: "Quay mặt sang trái" },
{ action: "right", message: "Quay mặt sang phải" },
{ action: "up", message: "Ngước mặt lên trên" },
{ action: "down", message: "Cúi mặt xuống dưới" },
];
// Configuration
const MAX_STEPS = parseInt(import.meta.env.VITE_LIVENESS_MAX_STEPS || "4");
// Default sequence: forward → left → right → forward✅ [THỰC TẾ] Face Angle Calculation
// ✅ CODE THỰC TẾ từ face-liveness.ts
/**
* Tính toán góc khuôn mặt (yaw, pitch, roll)
* Sử dụng landmarks 127 (left cheek), 356 (right cheek), 6 (nose bridge)
*/
export function calculateFaceAngles(results: Results): IFaceAngles {
// 1. Lấy 3 landmarks chính
const p1 = landmarks[127]; // Má trái
const p2 = landmarks[356]; // Má phải
const p3 = landmarks[6]; // Sống mũi
// 2. Tạo 3 vectors từ 3 điểm
const vhelp = [p3.x - p1.x, p3.y - p1.y, p3.z - p1.z];
const vx_d = [p2.x - p1.x, p2.y - p1.y, p2.z - p1.z];
const vy_d = crossProduct(vhelp, vx_d);
// 3. Normalize vectors
const vx = normalize(vx_d);
const vy = normalize(vy_d);
const vz = normalize(crossProduct(vy_d, vx_d));
// 4. Tạo rotation matrix
const rotationMatrix = new THREE.Matrix3().fromArray([...vx, ...vy, ...vz]);
// 5. Convert to Euler angles
const euler = new THREE.Euler().setFromRotationMatrix(rotationMatrix, "ZYX");
return {
yaw: THREE.MathUtils.radToDeg(euler.y), // Quay trái/phải
pitch: THREE.MathUtils.radToDeg(euler.x), // Ngước/cúi
roll: THREE.MathUtils.radToDeg(euler.z), // Nghiêng đầu
};
}Công thức chi tiết:
1. Xây dựng Vector (Không gian 3D):
vhelp = nose - leftCheek
vx_d = rightCheek - leftCheek
vy_d = vhelp × vx_d (tích có hướng)
2. Chuẩn hóa:
vx = vx_d / |vx_d|
vy = vy_d / |vy_d|
vz = (vy_d × vx_d) / |vy_d × vx_d|
3. Ma trận quay (3x3):
[vx.x vx.y vx.z]
[vy.x vy.y vy.z]
[vz.x vz.y vz.z]
4. Góc Euler (thứ tự ZYX):
yaw = atan2(R21, R11) → Trái/phải
pitch = -asin(R31) → Lên/xuống
roll = atan2(R32, R33) → Nghiêng
5. Chuyển đổi sang độ:
degrees = radians * (180/π)✅ [THỰC TẾ] Action Validation
// ✅ CODE THỰC TẾ từ face-liveness.ts
/**
* Kiểm tra xem khuôn mặt có thực hiện đúng action không
*/
export function faceLiveNessCheck(results: Results, action: string): boolean {
const angles = calculateFaceAngles(results);
switch (action) {
case "forward":
// Yaw: -10° to +10° (nhìn thẳng)
// Pitch: -7° to +7° (không cúi/ngước)
return (
angles.yaw >= -10 &&
angles.yaw <= 10 &&
angles.pitch >= -7 &&
angles.pitch <= 7
);
case "left":
// Yaw >= +25° (quay trái từ góc nhìn user)
return angles.yaw >= 25;
case "right":
// Yaw <= -25° (quay phải từ góc nhìn user)
return angles.yaw <= -25;
case "up":
// Pitch >= +10° (ngước lên)
// Yaw: -20° to +20° (không quay ngang quá nhiều)
const pitchFromLandmarks = calculatePitchFromLandmarks(results);
return pitchFromLandmarks >= 11 && angles.yaw >= -20 && angles.yaw <= 20;
case "down":
// Pitch <= -8° (cúi xuống)
// Yaw: -20° to +20°
const pitchDown = calculatePitchFromLandmarks(results);
return pitchDown <= -9 && angles.yaw >= -20 && angles.yaw <= 20;
default:
return false;
}
}Giải thích ngưỡng:
// ✅ [THỰC TẾ] Ngưỡng hiện tại
const THRESHOLDS = {
// Forward (nhìn thẳng)
forward: {
yaw: [-10, 10], // Dung sai ±10°
pitch: [-7, 7], // Dung sai ±7°
},
// Left/Right (quay ngang)
horizontal: {
minYaw: 25, // Phải quay ít nhất 25°
},
// Up/Down (ngước/cúi)
vertical: {
minPitch: {
up: 11, // Phải ngước ít nhất 11°
down: -9, // Phải cúi ít nhất 9°
},
maxYaw: 20, // Không quay ngang quá 20°
},
};✅ [THỰC TẾ] Frame Stability Check
// ✅ CODE THỰC TẾ từ FaceDetectionCamera.vue
const VALID_FRAME = 3; // Cần 3 frames liên tiếp
const validFrameCount = ref(0);
/**
* Handle face detection results
*/
const handleFaceResults = async (results: Results) => {
// Get current action
const currentAction = randomActionSequence.value[step.value]?.action;
// Check if user performs correct action
const isCorrectAction = faceLiveNessCheck(results, currentAction);
if (isCorrectAction && faceInCircle && faceDistanceOK) {
// ✅ Đúng action + mặt trong khung + khoảng cách OK
validFrameCount.value++;
if (validFrameCount.value >= VALID_FRAME) {
// Pass this step → move to next
confirmAudio.play();
validFrameCount.value = 0;
step.value++;
if (step.value >= randomActionSequence.value.length) {
// ✅ Hoàn thành tất cả steps → Capture image
await captureImage();
}
}
} else {
// ❌ Chưa đúng → reset counter
validFrameCount.value = 0;
}
};Công thức:
Kiểm tra độ ổn định:
isStable = (validFrameCount >= VALID_FRAME)
validFrameCount tăng khi:
✅ faceLiveNessCheck(action) === true
✅ faceInCircle === true
✅ faceDistanceOK === true
validFrameCount đặt lại khi:
❌ Bất kỳ điều kiện nào ở trên là false✅ [THỰC TẾ] Face Distance Check
// ✅ CODE THỰC TẾ từ FaceDetectionCamera.vue
/**
* Check if face is at good distance from camera
*/
const { x1, x2, y1, y2 } = getBoundingBox(results); // Bounding box
const faceWidth = x2 - x1;
const faceHeight = y2 - y1;
const faceSize = Math.max(faceWidth, faceHeight);
// Target circle size
const circleSize = isMobile ? 320 : 360; // pixels
// Thresholds (% of circle size)
const minFaceSize = circleSize * 0.5; // 50% = Too far
const maxFaceSize = circleSize * 0.75; // 75% = Too close
if (faceSize < minFaceSize) {
faceDistanceHint.value = "too-far"; // Màu đỏ, "Di chuyển gần hơn"
} else if (faceSize > maxFaceSize) {
faceDistanceHint.value = "too-close"; // Màu đỏ, "Xa ra một chút"
} else {
faceDistanceHint.value = "perfect"; // Màu xanh, "Tốt"
}Ngưỡng trực quan:
Kích thước khuôn mặt so với vòng tròn:
Quá xa (< 50%): Tốt (50-75%): Quá gần (> 75%):
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ◯ (face) │ │ ◯◯◯ (face) │ │ │
│ │ │ │ │ ◯◯◯◯◯◯◯◯ │
│ ⭕ circle │ │ ⭕ circle │ │ ⭕ circle │
└──────────────┘ └──────────────┘ └──────────────┘
faceSize/circle faceSize/circle faceSize/circle
= 0.3 (30%) = 0.6 (60%) = 0.9 (90%)
→ Quá nhỏ → Hoàn hảo! → Quá lớn✅ [THỰC TẾ] Smoothing for Stability
// ✅ CODE THỰC TẾ từ FaceDetectionCamera.vue
let faceSizeHistory: number[] = [];
const FACE_SIZE_HISTORY_LENGTH = 5;
// Add current face size to history
faceSizeHistory.push(faceSize);
if (faceSizeHistory.length > FACE_SIZE_HISTORY_LENGTH) {
faceSizeHistory.shift(); // Remove oldest
}
// Calculate average (smoothed)
const avgFaceSize =
faceSizeHistory.reduce((a, b) => a + b, 0) / faceSizeHistory.length;
// Use avgFaceSize instead of raw faceSize for hint
// → Tránh flickering khi face size dao động nhẹCông thức:
Trung bình động (Làm mượt):
avgFaceSize = (size₁ + size₂ + size₃ + size₄ + size₅) / 5
Ví dụ:
Kích thước thô: [250, 280, 260, 290, 270]
Trung bình: 270
→ Ít dao động hơn dữ liệu thô
→ Gợi ý UI ổn định hơn💡 [ĐỀ XUẤT] Advanced Techniques (Chưa implement)
💡 Phát hiện chuyển động (Passive Liveness)
Ý tưởng: Phát hiện chuyển động nhỏ tự nhiên (hơi thở, mắt chuyển động nhẹ) để xác nhận không phải ảnh tĩnh.
// 💡 [ĐỀ XUẤT] - CHƯA DÙNG TRONG CODE THỰC TẾ
class MotionDetector {
private previousLandmarks: Landmark[] | null = null;
private motionHistory: number[] = [];
private readonly HISTORY_SIZE = 30; // 1 second at 30fps
/**
* Tính toán chuyển động giữa các khung hình
*/
detectMotion(currentLandmarks: Landmark[]): number {
if (!this.previousLandmarks) {
this.previousLandmarks = currentLandmarks;
return 0;
}
// Tính toán chuyển động trung bình trên tất cả 468 điểm mốc
let totalMovement = 0;
for (let i = 0; i < currentLandmarks.length; i++) {
const curr = currentLandmarks[i];
const prev = this.previousLandmarks[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const dz = curr.z - prev.z;
// Khoảng cách Euclid 3D
const movement = Math.sqrt(dx * dx + dy * dy + dz * dz);
totalMovement += movement;
}
const avgMovement = totalMovement / currentLandmarks.length;
// Cập nhật lịch sử
this.motionHistory.push(avgMovement);
if (this.motionHistory.length > this.HISTORY_SIZE) {
this.motionHistory.shift();
}
this.previousLandmarks = currentLandmarks;
return avgMovement;
}
/**
* Kiểm tra xem có đủ chuyển động tự nhiên không
*/
hasNaturalMotion(): boolean {
if (this.motionHistory.length < this.HISTORY_SIZE) {
return false;
}
const avgMotion =
this.motionHistory.reduce((a, b) => a + b, 0) / this.motionHistory.length;
const variance =
this.motionHistory.reduce(
(sum, val) => sum + Math.pow(val - avgMotion, 2),
0
) / this.motionHistory.length;
const stdDev = Math.sqrt(variance);
// Chuyển động tự nhiên có:
// 1. Một số chuyển động trung bình (không phải ảnh tĩnh)
// 2. Một số phương sai (không phải chuyển động giả đều đặn)
const hasMovement = avgMotion > 0.001; // Ngưỡng
const hasVariance = stdDev > 0.0005; // Ngưỡng
return hasMovement && hasVariance;
}
}Giải thích ngưỡng:
// 💡 [ĐỀ XUẤT] Ngưỡng chuyển động
const MOTION_CONFIG = {
// Phát hiện ảnh tĩnh
MIN_MOVEMENT: 0.001,
// < 0.001: Không chuyển động → ảnh tĩnh
// ≥ 0.001: Có chuyển động nhỏ → người thật
// Phát hiện chuyển động giả (đều đặn)
MIN_VARIANCE: 0.0005,
// < 0.0005: Chuyển động đều đặn → có thể fake (video loop)
// ≥ 0.0005: Chuyển động tự nhiên có biến thiên
// Điều chỉnh theo điều kiện:
good_lighting: {
MIN_MOVEMENT: 0.001,
MIN_VARIANCE: 0.0005,
},
low_lighting: {
MIN_MOVEMENT: 0.002, // Tăng (nhiều nhiễu)
MIN_VARIANCE: 0.001,
},
mobile_device: {
MIN_MOVEMENT: 0.003, // Thiết bị rung nhẹ
MIN_VARIANCE: 0.0015,
},
};💡 Kiểm tra tâm khuôn mặt
Ý tưởng: Yêu cầu mặt ở giữa khung hình.
// 💡 [ĐỀ XUẤT] - CHƯA DÙNG
function isFaceInCenter(landmarks: Landmark[], tolerance: number): boolean {
// Tính toán tâm khuôn mặt (trung bình của tất cả điểm mốc)
let sumX = 0,
sumY = 0;
for (const landmark of landmarks) {
sumX += landmark.x;
sumY += landmark.y;
}
const centerX = sumX / landmarks.length;
const centerY = sumY / landmarks.length;
// Tâm khung hình ở (0.5, 0.5)
const deltaX = Math.abs(centerX - 0.5);
const deltaY = Math.abs(centerY - 0.5);
// Cả hai phải nằm trong dung sai
return deltaX < tolerance && deltaY < tolerance;
}
// Sử dụng
const isCentered = isFaceInCenter(landmarks, 0.2); // Dung sai ±20%Trực quan:
Tọa độ khung hình (đã chuẩn hóa):
(0,0) ────────────────── (1,0)
│ Hộp dung sai │
│ ┌─────────────┐ │
│ │ │ │
│ │ (0.5,0.5) │ │ ← Chấp nhận nếu tâm
│ │ X │ │ khuôn mặt ở đây
│ └─────────────┘ │
(0,1) ────────────────── (1,1)
tolerance = 0.2:
- Chấp nhận X: 0.3 đến 0.7
- Chấp nhận Y: 0.3 đến 0.7Backend: Chống giả mạo (Anti-Spoofing)
Backend xử lý phát hiện liveness nghiêm túc. Frontend chỉ chuẩn bị dữ liệu tốt.
1. Yêu cầu API
// ✅ CODE THỰC TẾ từ EkycService.ts
async function verifyFace(frontImage: File, faceImage: string) {
const formData = new FormData();
formData.append("front_card_image", frontImage);
formData.append("face_image", faceImage);
const response = await axios.post(`${ENDPOINT}/api/ekyc/faceid`, formData);
return response.data;
}2. Phản hồi Backend
{
"status": "success",
"message": "Face verification successful",
"verify_result": {
"is_same_person": true,
"similarity": 0.85,
"bounding_box_face_id": [x1, y1, x2, y2],
"bounding_box_ekyc": [x1, y1, x2, y2]
},
"face_anti_spoof_status": {
"status": "REAL",
"confidence": 0.92
}
}3. Logic xác thực
// ✅ CODE THỰC TẾ logic
const isVerificationSuccess = computed(() => {
if (!faceVerificationData.value) return false;
const data = faceVerificationData.value;
return (
data.verify_result?.is_same_person && // Cùng người
data.verify_result?.similarity >= 0.7 && // Độ tương đồng >= 70%
data.face_anti_spoof_status?.status === "REAL" && // Không phải fake
data.face_anti_spoof_status?.confidence >= 0.7 // Độ tin cậy >= 70%
);
});Ngưỡng:
// Ngưỡng chấp nhận (có thể điều chỉnh)
const BACKEND_THRESHOLDS = {
similarity: 0.7, // 0.0 - 1.0 (70% = khớp tốt)
confidence: 0.7, // 0.0 - 1.0 (70% = tin cậy REAL)
status: "REAL", // Phải là REAL (không phải FAKE)
};So sánh: Code thực tế vs đề xuất
| Tính năng | Thực tế (Portal) | Đề xuất (Tương lai) | Ghi chú |
|---|---|---|---|
| Phát hiện 468 điểm mốc | ✅ Có | ✅ Có | MediaPipe core |
| Tính toán góc khuôn mặt | ✅ Có | ✅ Có | yaw, pitch, roll |
| Active Liveness (Hành động) | ✅ Có | ✅ Có | forward/left/right/up/down |
| Độ ổn định khung hình (3 khung) | ✅ Có | ✅ Có | VALID_FRAME = 3 |
| Kiểm tra khoảng cách khuôn mặt | ✅ Có | ✅ Có | too-close/too-far/perfect |
| Làm mượt (Trung bình động) | ✅ Có | ✅ Có | faceSizeHistory |
| Phát hiện chuyển động | ❌ Không | 💡 Đề xuất | Phát hiện chuyển động nhỏ tự nhiên |
| Kiểm tra tâm khuôn mặt | ❌ Không | 💡 Đề xuất | Portal dùng circle guide thay thế |
| Phát hiện nháy mắt | ❌ Không | 💡 Đề xuất | Có calculateEyeAspectRatio() nhưng chưa dùng |
| Chống giả mạo Backend | ✅ Có | ✅ Có | Phát hiện REAL/FAKE |
| Độ tương đồng khuôn mặt | ✅ Có | ✅ Có | So sánh với CCCD |
Tổng kết
✅ Code thực tế đang hoạt động:
- Active liveness với 4-6 hành động (forward/left/right/up/down)
- Tính toán góc khuôn mặt (yaw, pitch, roll)
- Kiểm tra độ ổn định khung hình (3 khung hình liên tiếp)
- Kiểm tra khoảng cách khuôn mặt với gợi ý trực quan
- Làm mượt để tránh nhấp nháy
- Chống giả mạo backend và xác thực khuôn mặt
💡 Có thể cải thiện:
- Phát hiện chuyển động (passive liveness)
- Phát hiện nháy mắt
- Kiểm tra tâm khuôn mặt (nếu không dùng circle guide)
- Ngưỡng thích ứng dựa trên ánh sáng/thiết bị
📍 File liên quan:
portal-client/src/pages/information/directive/FaceDetectionCamera.vue(Component chính)portal-client/src/utils/ekyc/face-liveness.ts(Tính toán góc)portal-client/src/utils/ekyc/MediaPipeFaceMeshWrapper.ts(Wrapper class)portal-server/src/services/client/EkycService.ts(API Backend)
Bài học tiếp theo
→ Backend Integration: OCR & Face Verify APIs
interface FaceVerificationResult { // Overall result status: "success" | "error"; message: string;
// Face comparison verify_result: { is_same_person: boolean; similarity: number; // 0-1 threshold: number; // 0.7 confidence: string; // 'high' | 'medium' | 'low' };
// Anti-spoofing (QUAN TRỌNG!) face_anti_spoof_status: { status: "REAL" | "FAKE"; confidence: number; // 0-1 details: { is_printed: boolean; // Có phải ảnh in? is_screen: boolean; // Có phải màn hình? is_mask: boolean; // Có phải mặt nạ? texture_score: number; // Điểm texture depth_score: number; // Điểm depth }; };
// Error (if any) error_code?: string; error_message?: string; }
### 3. Interpret Results
```typescript
function interpretVerificationResult(result: FaceVerificationResult): {
isValid: boolean;
level: "success" | "warning" | "error";
message: string;
} {
// Lỗi
if (result.status === "error") {
return {
isValid: false,
level: "error",
message: result.error_message || "Xác thực thất bại",
};
}
// Kiểm tra chống giả mạo (PHẢI vượt qua)
if (result.face_anti_spoof_status.status !== "REAL") {
return {
isValid: false,
level: "error",
message: t("locale.face_spoof_detected"),
};
}
// Độ tin cậy chống giả mạo thấp
if (result.face_anti_spoof_status.confidence < 0.7) {
return {
isValid: false,
level: "warning",
message: t("locale.face_quality_too_low"),
};
}
// Kiểm tra cùng người
if (!result.verify_result.is_same_person) {
return {
isValid: false,
level: "error",
message: t("locale.face_not_match"),
};
}
// Độ tương đồng thấp
if (result.verify_result.similarity < 0.7) {
return {
isValid: false,
level: "warning",
message: t("locale.face_similarity_too_low", {
similarity: (result.verify_result.similarity * 100).toFixed(0),
}),
};
}
// Thành công
return {
isValid: true,
level: "success",
message: t("locale.face_verification_success", {
similarity: (result.verify_result.similarity * 100).toFixed(0),
}),
};
}UI Flow
Complete Liveness Check Flow
<template>
<div class="face-liveness">
<!-- Step 1: Camera setup -->
<div v-if="step === 'camera'" class="camera-view">
<video ref="videoRef" autoplay playsinline />
<canvas ref="canvasRef" class="overlay" />
<!-- Stability indicator -->
<div class="stability-indicator">
<div v-if="!isStable" class="warning">
{{ t("locale.position_face_and_hold_still") }}
</div>
<div v-else class="success">
{{ t("locale.face_stable_ready") }}
</div>
</div>
<!-- Capture button -->
<BtnBase
:disabled="!canCapture"
:loading="capturing"
@click="handleCapture"
>
{{ t("locale.capture_face") }}
</BtnBase>
</div>
<!-- Step 2: Verifying -->
<div v-else-if="step === 'verifying'" class="verifying">
<Lucide icon="Loader" class="animate-spin" />
<p>{{ t("locale.verifying_liveness") }}</p>
</div>
<!-- Step 3: Result -->
<div v-else-if="step === 'result'" class="result">
<div :class="`result-${resultLevel}`">
<Lucide :icon="resultLevel === 'success' ? 'CheckCircle' : 'XCircle'" />
<h3>{{ resultMessage }}</h3>
<!-- Details (for success) -->
<div v-if="resultLevel === 'success' && verificationData">
<p>
{{ t("locale.similarity") }}:
{{ (verificationData.verify_result.similarity * 100).toFixed(0) }}%
</p>
<p>
{{ t("locale.anti_spoof") }}:
{{ verificationData.face_anti_spoof_status.status }}
({{
(
verificationData.face_anti_spoof_status.confidence * 100
).toFixed(0)
}}%)
</p>
</div>
<!-- Actions -->
<div class="actions">
<BtnBase v-if="resultLevel !== 'success'" @click="retry">
{{ t("locale.retry") }}
</BtnBase>
<BtnBase v-else class="c-btn-primary" @click="continueToNextStep">
{{ t("locale.continue") }}
</BtnBase>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";
// Props
const props = defineProps<{
frontCardImage: string; // From previous step
}>();
// Emit
const emit = defineEmits<{
success: [data: FaceVerificationResult];
error: [error: string];
}>();
// State
const step = ref<"camera" | "verifying" | "result">("camera");
const isStable = ref(false);
const canCapture = ref(false);
const capturing = ref(false);
const verificationData = ref<FaceVerificationResult | null>(null);
const resultLevel = ref<"success" | "warning" | "error">("success");
const resultMessage = ref("");
// Refs
const videoRef = ref<HTMLVideoElement>();
const canvasRef = ref<HTMLCanvasElement>();
// Instances
let wrapper: MediaPipeFaceMeshWrapper | null = null;
let stabilityChecker: FaceStabilityChecker | null = null;
let stream: MediaStream | null = null;
// Setup
onMounted(async () => {
await setupCamera();
await setupFaceDetection();
});
onBeforeUnmount(() => {
cleanup();
});
// Camera setup
async function setupCamera() {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "user" },
});
if (videoRef.value) {
videoRef.value.srcObject = stream;
}
}
// Face detection
async function setupFaceDetection() {
wrapper = new MediaPipeFaceMeshWrapper();
stabilityChecker = new FaceStabilityChecker();
await wrapper.initialize();
if (videoRef.value) {
wrapper.setVideoElement(videoRef.value);
}
wrapper.onResults((results) => {
isStable.value = stabilityChecker!.checkStability(results);
canCapture.value = isStable.value;
// Draw overlay
if (canvasRef.value && results.multiFaceLandmarks) {
drawFaceMesh(canvasRef.value, results.multiFaceLandmarks[0]);
}
});
wrapper.startDetection();
}
// Capture
async function handleCapture() {
if (!canCapture.value || capturing.value) return;
try {
capturing.value = true;
// Capture image
const faceImage = captureFromVideo(videoRef.value!);
// Stop detection
wrapper?.stopDetection();
// Move to verifying
step.value = "verifying";
// Call backend
const result = await verifyFaceLiveness(props.frontCardImage, faceImage);
// Interpret
const interpretation = interpretVerificationResult(result);
verificationData.value = result;
resultLevel.value = interpretation.level;
resultMessage.value = interpretation.message;
// Move to result
step.value = "result";
// Emit
if (interpretation.isValid) {
emit("success", result);
} else {
emit("error", interpretation.message);
}
} catch (error: any) {
console.error("[Liveness] Error:", error);
resultLevel.value = "error";
resultMessage.value = error.message || t("locale.verification_error");
step.value = "result";
emit("error", error.message);
} finally {
capturing.value = false;
}
}
// Retry
function retry() {
step.value = "camera";
stabilityChecker?.reset();
wrapper?.startDetection();
}
// Cleanup
function cleanup() {
wrapper?.destroy();
stream?.getTracks().forEach((track) => track.stop());
}
</script>Best Practices
1. Không tin tưởng kiểm tra frontend
// ❌ Sai: Chỉ kiểm tra frontend
if (isStable.value) {
// Coi như đạt → NGUY HIỂM!
goToNextStep();
}
// ✅ Đúng: Luôn xác thực với backend
if (isStable.value) {
const result = await verifyFaceLiveness(...);
if (result.face_anti_spoof_status.status === 'REAL') {
goToNextStep();
}
}2. Không tiết lộ chi tiết chống giả mạo
// ❌ Sai: Hiển thị quá nhiều chi tiết cho người dùng
console.log("Chi tiết chống giả mạo:", result.face_anti_spoof_status.details);
// → Hacker có thể học các pattern
// ✅ Đúng: Chỉ hiển thị thông báo chung
if (result.face_anti_spoof_status.status !== "REAL") {
showError(t("locale.verification_failed_generic"));
}3. Giới hạn thử lại
const MAX_RETRIES = 3;
const retryCount = ref(0);
async function handleCapture() {
try {
const result = await verifyFaceLiveness(...);
// ...
} catch (error) {
retryCount.value++;
if (retryCount.value >= MAX_RETRIES) {
// Quá nhiều lần thử lại → nâng cấp
showError(t('locale.too_many_retries'));
showContactSupport();
} else {
// Cho phép thử lại
showError(t('locale.verification_failed_retry'));
}
}
}Tổng kết
Trách nhiệm Frontend:
- ✅ Kiểm tra độ ổn định phát hiện khuôn mặt
- ✅ Đảm bảo chất lượng ảnh tốt
- ✅ Cung cấp UX tốt
- ❌ KHÔNG tự quyết định đạt/không đạt
Trách nhiệm Backend:
- ✅ Chống giả mạo (kết cấu, độ sâu, ...)
- ✅ So sánh khuôn mặt
- ✅ Quyết định đạt/không đạt cuối cùng
Nguyên tắc chính: Frontend là người hỗ trợ, backend là người quyết định.