Skip to content

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):

typescript
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

typescript
// ✅ [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:

typescript
// ✅ 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

typescript
// ✅ 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

typescript
// ✅ 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:

typescript
// ✅ [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

typescript
// ✅ 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

typescript
// ✅ 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

typescript
// ✅ 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.

typescript
// 💡 [ĐỀ 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:

typescript
// 💡 [ĐỀ 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.

typescript
// 💡 [ĐỀ 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.7

Backend: 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

typescript
// ✅ 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

json
{
  "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

typescript
// ✅ 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:

typescript
// 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ăngThự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ấtPhát hiện chuyển động nhỏ tự nhiên
Kiểm tra tâm khuôn mặt❌ Không💡 Đề xuấtPortal dùng circle guide thay thế
Phát hiện nháy mắt❌ Không💡 Đề xuấtCó 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

vue
<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

typescript
// ❌ 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

typescript
// ❌ 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

typescript
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.

Bài học tiếp theo

Backend Integration: OCR & Face Verify APIs

Internal documentation for iNET Portal