Skip to content

Giới thiệu MediaPipe FaceMesh

Mục tiêu bài học

  • Hiểu MediaPipe là gì và tại sao chọn nó
  • Nắm được cách hoạt động của FaceMesh
  • Biết cài đặt và cấu hình cơ bản
  • Hiểu ưu nhược điểm khi sử dụng

MediaPipe là gì?

MediaPipe là framework học máy (machine learning) của Google, chạy được trên:

  • Web (WebAssembly + WebGL)
  • Mobile (iOS, Android)
  • Desktop

FaceMesh là một giải pháp (solution) trong MediaPipe để:

  • Phát hiện khuôn mặt thời gian thực (realtime)
  • Trích xuất 468 điểm mốc (landmarks) trên khuôn mặt
  • Chạy hoàn toàn trên máy khách (client-side, không cần server)

Tại sao chọn MediaPipe?

So sánh với các giải pháp khác

Tiêu chíMediaPipeFace-api.jsML KitCloud Vision API
Hiệu năng⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Quyền riêng tư✅ Local✅ Local✅ Local❌ Cloud
Chi phíMiễn phíMiễn phíMiễn phíTrả phí
Độ chính xácCaoTrung bìnhCaoRất cao
Kích thước gói~7MB~4MBNativeN/A
Bảo trìGoogleCộng đồngGoogleGoogle

Lý do chọn MediaPipe

Ưu điểm:

  1. Độ chính xác cao: 468 điểm mốc, phát hiện tốt ngay cả góc nghiêng
  2. Hiệu năng tốt: Tăng tốc bằng WebGL, 60fps trên mobile
  3. Ưu tiên quyền riêng tư: Chạy hoàn toàn local, không gửi dữ liệu lên server
  4. Hỗ trợ tốt: Được Google duy trì, tài liệu đầy đủ
  5. Miễn phí: Không giới hạn số lần gọi

Nhược điểm:

  1. Kích thước gói lớn: ~7MB WASM + models
  2. Thiết lập phức tạp: Cần cấu hình locateFile, tải models
  3. Vấn đề khi build: Export không nhất quán giữa môi trường dev và prod
  4. Vấn đề phản ứng (reactivity): Không tương thích với Vue Proxy (xem bài 04 để biết cách xử lý)

Cài đặt

bash
npm install @mediapipe/face_mesh

Cấu trúc gói (package structure)

@mediapipe/face_mesh/
├── face_mesh.js          # Module chính
├── face_mesh.d.ts        # Định nghĩa TypeScript
├── face_mesh_solution_packed_assets_loader.js
├── face_mesh_solution_simd_wasm_bin.js
└── face_mesh_solution_packed_assets.data  # Models (~7MB)

Cách hoạt động của FaceMesh

Quy trình xử lý (pipeline)

Ảnh/Video đầu vào


┌───────────────────┐
│  Phát hiện khuôn mặt │  ← BlazeFace (nhẹ)
│  (Hộp giới hạn)   │
└────────┬──────────┘


┌───────────────────┐
│   Phát hiện điểm mốc │  ← 468 điểm
│   trên khuôn mặt   │
└────────┬──────────┘


┌───────────────────┐
│  Xử lý hậu kỳ     │  ← Làm mượt, tinh chỉnh
│  điểm mốc          │
└────────┬──────────┘


    Kết quả {
      multiFaceLandmarks,
      image
    }

468 điểm mốc (landmarks)

FaceMesh trả về 468 điểm đặc trưng, bao gồm:

  • Silhouette: Đường viền khuôn mặt
  • Lips: Môi (40 điểm)
  • Left Eye: Mắt trái (71 điểm)
  • Right Eye: Mắt phải (71 điểm)
  • Left Iris: Tròng đen mắt trái (5 điểm)
  • Right Iris: Tròng đen mắt phải (5 điểm)
  • Face Oval: Đường viền mặt
  • Left Eyebrow: Lông mày trái
  • Right Eyebrow: Lông mày phải
typescript
// Cấu trúc kết quả
interface Results {
  multiFaceLandmarks?: NormalizedLandmarkList[];
  image: HTMLCanvasElement | HTMLImageElement | HTMLVideoElement;
}

interface NormalizedLandmark {
  x: number; // 0-1 (đã chuẩn hóa)
  y: number; // 0-1 (đã chuẩn hóa)
  z: number; // Độ sâu
}

Cấu hình cơ bản

Thiết lập tối thiểu (Production)

Trong ứng dụng thực tế, chúng ta sử dụng file local từ public/face_mesh/ để:

  • Ưu tiên offline: Không cần internet để tải models
  • Hiệu năng tốt hơn: Tải từ local nhanh hơn CDN
  • Kiểm soát phiên bản: Đảm bảo models luôn tương thích
typescript
import { FaceMesh } from "@mediapipe/face_mesh";

// 1. Tạo instance với local files
const faceMesh = new FaceMesh({
  locateFile: (file) => {
    // Files được copy vào public/face_mesh/ từ node_modules
    return `/face_mesh/${file}`;
  },
});

// 2. Cấu hình (EXACTLY như trong ứng dụng)
faceMesh.setOptions({
  selfieMode: true, // Mirror effect cho front camera (quan trọng!)
  maxNumFaces: 1, // Số khuôn mặt tối đa
  refineLandmarks: true, // Landmarks chi tiết (mắt, môi) - CẦN cho liveness
  minDetectionConfidence: 0.5, // Ngưỡng phát hiện
  minTrackingConfidence: 0.5, // Ngưỡng tracking
});

// 3. Lắng nghe results
faceMesh.onResults((results) => {
  if (results.multiFaceLandmarks && results.multiFaceLandmarks.length > 0) {
    const landmarks = results.multiFaceLandmarks[0];
    console.log(`Detected ${landmarks.length} landmarks`);
  }
});

// 4. Gửi frame
await faceMesh.send({ image: videoElement });

Thiết lập với CDN (Development/Testing)

Nếu muốn test nhanh với CDN (cần internet):

typescript
const faceMesh = new FaceMesh({
  locateFile: (file) => {
    return `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`;
  },
});

Các options quan trọng

selfieMode (mặc định: false) ⭐ QUAN TRỌNG

Bật hiệu ứng gương (mirror effect) cho camera trước (selfie). BẮT BUỘC khi dùng camera trước:

typescript
selfieMode: true; // Lật video để người dùng thấy tự nhiên như gương

Lưu ý: Khi selfieMode: true, MediaPipe sẽ tự động lật video, nhưng các điểm mốc vẫn ở tọa độ gốc (chưa lật). Cần xử lý lật các điểm mốc nếu cần vẽ lên canvas.

maxNumFaces (mặc định: 1)

Số khuôn mặt tối đa cần phát hiện. Trong eKYC, chúng ta chỉ cần 1:

typescript
maxNumFaces: 1; // Chỉ phát hiện 1 khuôn mặt, hiệu năng tốt hơn

refineLandmarks (mặc định: false) ⭐ QUAN TRỌNG

Có phát hiện điểm mốc chi tiết hay không (mắt, môi, tròng đen). BẮT BUỘC cho face liveness:

typescript
refineLandmarks: true; // Cần cho face liveness (phát hiện nháy mắt, quay đầu)

Khi refineLandmarks: true:

  • Số điểm mốc tăng từ 468 → 478 (thêm 10 điểm cho tròng đen)
  • Độ chính xác cao hơn cho mắt và môi
  • Hiệu năng giảm nhẹ (~5-10%)

minDetectionConfidence (0-1, mặc định: 0.5)

Ngưỡng tin cậy khi phát hiện khuôn mặt lần đầu:

typescript
minDetectionConfidence: 0.5; // Cân bằng giữa độ chính xác và hiệu năng

Nếu giảm xuống → phát hiện dễ hơn nhưng nhiều dương tính giả (false positive) Nếu tăng lên → chính xác hơn nhưng khó phát hiện

minTrackingConfidence (0-1, mặc định: 0.5)

Ngưỡng tin cậy khi theo dõi (tracking) khuôn mặt qua các khung hình:

typescript
minTrackingConfidence: 0.5; // Theo dõi mượt mà giữa các khung hình

Ví dụ đầy đủ: Phát hiện từ webcam (Production)

Ví dụ dưới đây là cách sử dụng cơ bản với vanilla JavaScript:

typescript
// HTML
<video id="video" autoplay></video>
<canvas id="canvas"></canvas>

// TypeScript
import { FaceMesh, Results } from '@mediapipe/face_mesh';

async function setupFaceDetection() {
  // 1. Lấy video element
  const videoElement = document.getElementById('video') as HTMLVideoElement;
  const canvasElement = document.getElementById('canvas') as HTMLCanvasElement;
  const canvasCtx = canvasElement.getContext('2d')!;

  // 2. Khởi tạo FaceMesh với local files (như trong ứng dụng)
  const faceMesh = new FaceMesh({
    locateFile: (file) => {
      // Files trong public/face_mesh/
      return `/face_mesh/${file}`;
    }
  });

  // 3. Cấu hình (EXACTLY như trong ứng dụng)
  faceMesh.setOptions({
    selfieMode: true, // ⭐ QUAN TRỌNG: Mirror cho front camera
    maxNumFaces: 1,
    refineLandmarks: true, // ⭐ QUAN TRỌNG: Cần cho liveness
    minDetectionConfidence: 0.5,
    minTrackingConfidence: 0.5
  });

  // 4. Khởi động model (warmup) với ảnh nhỏ (như trong ứng dụng)
  const warmupImage = new Image();
  warmupImage.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII=';

  await new Promise<void>((resolve) => {
    warmupImage.onload = () => {
      faceMesh.onResults(() => {
        console.log('Model đã tải và sẵn sàng!');
        resolve();
      });
      faceMesh.send({ image: warmupImage }).catch(() => resolve());
    };
  });

  // 5. Xử lý kết quả
  faceMesh.onResults((results: Results) => {
    // Xóa canvas
    canvasCtx.save();
    canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height);
    canvasCtx.drawImage(results.image, 0, 0, canvasElement.width, canvasElement.height);

    // Vẽ các điểm mốc nếu có
    if (results.multiFaceLandmarks && results.multiFaceLandmarks.length > 0) {
      const landmarks = results.multiFaceLandmarks[0];

      // Vẽ các điểm
      for (const landmark of landmarks) {
        const x = landmark.x * canvasElement.width;
        const y = landmark.y * canvasElement.height;

        canvasCtx.beginPath();
        canvasCtx.arc(x, y, 1, 0, 2 * Math.PI);
        canvasCtx.fillStyle = '#00FF00';
        canvasCtx.fill();
      }
    }

    canvasCtx.restore();
  });

  // 6. Mở camera (camera trước cho selfie)
  const stream = await navigator.mediaDevices.getUserMedia({
    video: {
      width: { max: 1280 },
      height: { max: 720 },
      facingMode: 'user' // Camera trước
    }
  });
  videoElement.srcObject = stream;

  // 7. Gửi các khung hình liên tục với khoảng thời gian (như trong ứng dụng: 10ms)
  let intervalId: number;

  videoElement.onloadeddata = () => {
    videoElement.play();

    // Dùng setInterval thay vì requestAnimationFrame (như trong ứng dụng)
    intervalId = window.setInterval(async () => {
      if (videoElement.readyState === 4) {
        await faceMesh.send({ image: videoElement });
      }
    }, 10); // Khoảng thời gian 10ms (~100fps) - như trong ứng dụng
  };

  // Dọn dẹp
  return () => {
    clearInterval(intervalId);
    faceMesh.close();
    stream.getTracks().forEach(track => track.stop());
  };
}

setupFaceDetection();

Cân nhắc về hiệu năng

Tốc độ khung hình (Frame Rate)

MediaPipe có thể xử lý 30-60fps trên thiết bị hiện đại. Trong ứng dụng, chúng ta dùng khoảng thời gian 10ms (~100fps) để đảm bảo phát hiện thời gian thực:

typescript
// Trong ứng dụng: khoảng thời gian 10ms (như MediaPipeFaceMeshWrapper)
setInterval(async () => {
  if (videoElement.readyState === 4) {
    await faceMesh.send({ image: videoElement });
  }
}, 10); // 10ms = ~100fps (như trong ứng dụng)

// Lựa chọn khác: 33ms cho ~30fps (tiết kiệm CPU hơn)
// setInterval(..., 33); // ~30fps

Lý do dùng 10ms:

  • Face liveness cần phát hiện nhanh các hành động (quay đầu, nháy mắt)
  • MediaPipe xử lý bất đồng bộ (async), nên gửi nhiều khung hình không chặn UI
  • Trên mobile hiện đại vẫn chạy mượt

Tải model

Models (~7MB) cần thời gian tải lần đầu. Trong ứng dụng, chúng ta khởi động (warmup) với ảnh 1x1 pixel:

typescript
// Khởi động với ảnh nhỏ để tải models trước (như trong ứng dụng)
const warmupImage = new Image();
warmupImage.src =
  "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII=";

warmupImage.onload = async () => {
  faceMesh.onResults(() => {
    console.log("Model đã tải và sẵn sàng!");
  });
  await faceMesh.send({ image: warmupImage });
};

Lưu ý: Khởi động (warmup) giúp:

  • Tải các module WASM và models trước
  • Giảm độ trễ khi bắt đầu phát hiện
  • Tránh lag khi người dùng mở camera

Bài học quan trọng

1. Chọn đúng locateFile

Trong ứng dụng, chúng ta dùng file local từ public/face_mesh/:

  • Local: Đóng gói cùng app, ưu tiên offline, hiệu năng tốt
  • CDN: Cần internet, có thể chậm, không offline

Cách thiết lập file local:

  1. Copy files từ node_modules/@mediapipe/face_mesh/ vào public/face_mesh/
  2. Hoặc dùng script build để copy tự động

2. BẮT BUỘC: selfieMode và refineLandmarks

typescript
faceMesh.setOptions({
  selfieMode: true, // ⭐ BẮT BUỘC cho front camera
  refineLandmarks: true, // ⭐ BẮT BUỘC cho face liveness
  // ...
});

3. Điều chỉnh ngưỡng tin cậy (confidence thresholds)

  • Môi trường tốt (ánh sáng đủ, camera tốt) → 0.5-0.6
  • Môi trường kém → 0.3-0.4

Trong ứng dụng, chúng ta dùng 0.5 cho cả hai để cân bằng.

4. Xử lý khi không phát hiện khuôn mặt

typescript
faceMesh.onResults((results) => {
  if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) {
    // Không phát hiện khuôn mặt
    showMessage("Vui lòng đưa mặt vào khung");
    return;
  }

  // Có khuôn mặt
  processLandmarks(results.multiFaceLandmarks[0]);
});

5. ⚠️ Vấn đề phản ứng (reactivity) của Vue

MediaPipe WASM không tương thích với Vue Proxy. Khi sử dụng trong Vue, cần dùng HTMLVideoElement thuần thay vì Vue ref:

typescript
// ❌ SAI: Dùng Vue ref trực tiếp
const videoElement = ref<HTMLVideoElement | null>(null);
faceMesh.send({ image: videoElement.value }); // Lỗi!

// ✅ ĐÚNG: Dùng HTMLVideoElement thuần
const videoElement = document.getElementById("video") as HTMLVideoElement;
faceMesh.send({ image: videoElement }); // OK

Để tích hợp với Vue một cách an toàn, xem bài 04 về cách xây dựng wrapper class.

Cấu hình Build (Vite)

Trong ứng dụng, chúng ta cần cấu hình Vite để sửa các vấn đề khi build:

1. Vite Plugin (mediaPipePlugin.ts)

MediaPipe có vấn đề export sau khi minify (tối ưu hóa). Cần plugin để sửa:

typescript
// mediaPipePlugin.ts
import type { Plugin } from "vite";

function mediaPipePlugin(): Plugin {
  return {
    name: "mediapipe_workaround",
    load(id: string) {
      if (
        path.basename(id) === "face_mesh.js" &&
        id.includes("@mediapipe/face_mesh")
      ) {
        let code = fs.readFileSync(id, "utf-8");
        if (!code.includes("exports.FaceMesh")) {
          code += "\nexports.FaceMesh = FaceMesh;";
        }
        return { code };
      }
      return null;
    },
  };
}

2. Cấu hình Vite

typescript
// vite.config.ts
import mediaPipePlugin from "./mediaPipePlugin";

export default {
  build: {
    rollupOptions: {
      plugins: [mediaPipePlugin()],
      output: {
        manualChunks: (id) => {
          if (id.includes("@mediapipe/face_mesh")) {
            return "mediapipe-face-mesh";
          }
        },
        exports: "named", // Giữ nguyên exports
      },
    },
    chunkSizeWarningLimit: 1000, // MediaPipe có kích thước lớn
  },
  optimizeDeps: {
    include: ["@mediapipe/face_mesh"],
    esbuildOptions: {
      keepNames: true, // Giữ nguyên tên class
    },
  },
};

3. Import động (Dynamic Import)

Trong wrapper, chúng ta dùng import động để tránh các vấn đề khi build:

typescript
// Import động để tránh các vấn đề khi build
const faceMeshModule = await import("@mediapipe/face_mesh");
const FaceMeshClass =
  faceMeshModule.FaceMesh || faceMeshModule.default?.FaceMesh;

Tổng kết

MediaPipe FaceMesh là lựa chọn tốt cho face liveness vì:

  • ✅ Chạy local (bảo mật quyền riêng tư)
  • ✅ Hiệu năng cao (60-100fps với khoảng thời gian 10ms)
  • ✅ Độ chính xác tốt (468-478 điểm mốc với refineLandmarks)
  • ✅ Ưu tiên offline (file local)
  • ⚠️ Cần xử lý các vấn đề khi build (Vite plugin)
  • ⚠️ Cần xử lý vấn đề phản ứng của Vue (xem bài 04)

Cấu hình trong ứng dụng

typescript
// CHÍNH XÁC như trong MediaPipeFaceMeshWrapper
const faceMesh = new FaceMesh({
  locateFile: (file) => `/face_mesh/${file}`, // File local
});

faceMesh.setOptions({
  selfieMode: true, // ⭐ BẮT BUỘC
  maxNumFaces: 1,
  refineLandmarks: true, // ⭐ BẮT BUỘC
  minDetectionConfidence: 0.5,
  minTrackingConfidence: 0.5,
});

// Khoảng thời gian 10ms (~100fps)
setInterval(async () => {
  if (videoElement.readyState === 4) {
    await faceMesh.send({ image: videoElement });
  }
}, 10);

Bài học tiếp theo

Xây dựng MediaPipe Wrapper cho Vue

Internal documentation for iNET Portal