使用 JavaScript 在瀏覽器中進行真人臉部辨識 (基於 face-api.js)

前言

人臉辨識技術已經是一個非常成熟的技術了,但在過去主要都還是在桌面程式上運行,這幾年隨著硬體性能的飛躍進步和人工智慧框架的日益成熟,如今我們現在已經能夠在瀏覽器中實現許多過去難以完成的功能。

今天這篇文章,我想和大家分享如何在瀏覽器中實現真人臉部辨識,在開始之前,請先了解人臉辨識的基本流程(如下圖所示),主要分為三個部分:取得人臉特徵、活體偵測、以及比對活體特徵。


請注意,網路上很多臉部辨識範例大都沒有提到活體偵測的部分,但這個部分卻是實務運用上很重要的一個環節,因為過去曾經有小學生利用列印的照片進行身份欺騙的例子發生。

另外針對活體偵測,目前業界已經提出了多種方案,例如使用 3D 攝影機或紅外線設備等來輔助判斷,不過本文的目標是在瀏覽器中實現真人臉部辨識,因此我採用其他方法來達成這個目標,後面會再補充說明,底下的影片是最終的實做成果,如果遇到非真人的圖片會顯示無法辨識,若是真人則會進行身分判斷。


實作前的準備工作

為了在瀏覽器中實現準確的真人臉部辨識與活體偵測功能,本文將使用兩個主要的套件來實現這個功能。

  • 人臉辨識:臉部辨識都逃脫不了人臉偵測、特徵偵測及對齊、和人臉辨識三個步驟,本文將使用 face-api.js 套件來完成這些步驟,該套件是基於 tensorflow.js 開發的,其詳細使用方法可以參考 官方文件 說明。
  • 活體辨識:本文將使用一個俄國開發者在 Github 上分享的類別庫 face-liveness-web。該類別庫利用 OpenCV 和 NCNN 進行真假圖片辨識的訓練,並封裝成 WebAssembly 類別庫。未來如果有機會,也可以嘗試訓練自己的模型來使用。

接下來我會針對【註冊人臉特徵】、【執行人臉辨識】這兩階段進行程式的實作。


第一階段,註冊人臉特徵

在這部分,我們將模擬員工入職後如何將個人的臉部特徵與身份進行綁定,綁定的流程如下幾個重要步驟。

  • 攝像頭拍攝照片:首先,我們需要使用瀏覽器的攝影機來拍攝員工的照片。 
  • 人臉偵測:使用 face-api.js 偵測照片中的人臉。 
  • 人臉特徵擷取:提取偵測到的人臉並轉換成特徵向量。 
  • 綁定身份資訊:將提取的特徵向量與員工的身份進行綁定,並保存到資料庫中。

※ 法律提醒:各位開發者請注意,在不同國家和地區,使用生物特徵數據可能受到嚴格的法律規範,在實際應用中,請先確保你已經獲得了員工的明確同意,並且有相應的法律文件支持才能合法使用喔,接下來就直接看 code 吧。


載入相關模型權重

在開始之前,必須將人臉偵測、特徵點偵測、臉部辨識的權重載入後才可開始開啟攝影機進行特徵向量的擷取,關鍵程式碼如下,詳細程式碼可到 webCamFaceCreate.html 查看。

// 啟動 Camera
async function startWebcam() {
  const video = $('#inputVideo').get(0);
  try {
    const stream = await navigator.mediaDevices.getUserMedia({ video: true });
    video.srcObject = stream;
  } catch (error) {
    console.error('Error accessing webcam:', error);
  }
} 

$(document).ready(function () {
  // 加載模型
  Promise.all([
    faceapi.nets.ssdMobilenetv1.loadFromUri('/weights'),
    faceapi.loadFaceRecognitionModel('/weights'),
    faceapi.loadFaceLandmarkModel('/weights'),
    startWebcam()
  ]);
}); 


抓取臉部特徵向量

拍照或上傳至少一張圖片後,如果檢測到人臉,將會建立一個 Canvas 物件並剪裁人臉區域後,取得該區域的特徵向量和圖片,關鍵程式碼如下。

async function onTakePhoto(element) {
  // 偵測單個人臉
  const detection = await faceapi.detectSingleFace(video)
    .withFaceLandmarks()
    .withFaceDescriptor();

  if (!detection) return;

  // 如果檢測到人臉,創建 Canvas 並剪裁人臉區域後,取得該區域的特徵向量和圖片
  const img = document.createElement('img');
  const box = detection.detection.box;
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  canvas.width = box.width;
  canvas.height = box.height;

  // 將人臉區域剪裁到 Canvas 上
  ctx.drawImage(video, box.x, box.y, box.width, box.height, 0, 0, box.width, box.height);

  // 計算人臉特徵向量
  const descriptors = await faceapi.computeFaceDescriptor(canvas);

  // 將 Canvas 轉換為 Blob 並將特徵向量附加到 Image 上
  canvas.toBlob((blob) => {
    img.src = URL.createObjectURL(blob);

    // 將 Float32Array 轉換為陣列
    const json = JSON.stringify(Array.from(descriptors));
    img.setAttribute('data-descriptor', json);

    $('#user-image-container').get(0).appendChild(img);
  });
}


儲存特徵向量

將上一步驟的圖片和特徵向量透過 Http POST 發送到 net core 的 api,API 收到請求後會將特徵向量儲存在 json 檔案內,實務上你也可以儲存在資料庫內,程式碼就不特別貼了,可以參考 webCamFaceCreate.html 和 UserFaceController.cs。


第二階段,執行人臉辨識

在這部分,我們將介紹如何在取得人員特徵向量後,進行活體偵測,並且在判定是真人後,將特徵向量送到後端進行比對。此流程與第一階段的取得人臉特徵的步驟相同,但增加了活體偵測的步驟,人臉辨識的流程如下幾個重要步驟。

  • 攝像頭拍攝照片:首先,我們需要使用瀏覽器的攝影機來拍攝員工的照片。 
  • 人臉偵測:使用 face-api.js 偵測照片中的人臉。 
  • 人臉特徵擷取:提取偵測到的人臉並轉換成特徵向量。 
  • 活體偵測:使用 face-liveness-web 進行活體偵測,判定是真人後才繼續。
  • 特徵向量比對:將提取的特徵向量送到後端進行比對,後端則會針對該特徵向量與先前已經儲存的特張向量進行歐幾里得距離的平均值計算,並返回平均最短距離。

※ 補充說明,在這個範例程式中,單位員工可以上傳多張圖片,每張圖片會產生一個特徵向量,而這些特徵向量在後端進行計算,透過歐幾里得距離的方式來判斷相似度。計算方式是將每個員工的所有特徵向量分別計算出距離後再取平均值,也就是說如果同一員工上傳的多張圖片之間差異較大,即使某張圖片的距離最小,但整體距離的平均值可能會變大,從而導致辨識錯誤。因此,建議以下幾點來提高辨識準確性。

  • 每個員工上傳的圖片數量應該相同,來確保計算的公平性。
  • 避免使用差異過大的照片,如果可以的話,盡量使用網路攝影機來拍攝,你知道的有時候修圖可能會影響五官的位置 XD。


擷取人臉區域

我試圖使用所謂的活體偵測來辨識是否是真人,但由於沒有使用額外的設備,而是使用了別人已經訓練好的模型來進行辨識。該訓練模型原本是用來辨識真假圖片,因此我不希望它針對整張圖片來判斷是否是真的,所以必須先將人臉的部分擷取出來。

在範例程式碼中,我刻意將人臉區域放大了兩倍,這樣做的原因是,如果僅使用人臉的部分來偵測真假照片,該模型很可能會判斷為假。因此,為了提高判斷的準確性,我刻意放大了人臉區域,以下是關鍵程式碼,詳細內容可以參考 webCamFaceDetection.html。

/** 擷取人臉區域 */
function cropFaceRegion(detection, video) {
  const box = detection.detection.box;
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  // 將 box 區域放大 2 倍
  const boxWidth = box.width * 2;
  const boxHeight = box.height * 2;
  canvas.width = boxWidth;
  canvas.height = boxHeight;

  // 將人臉區域剪裁到 Canvas 上
  ctx.drawImage(video, box.x - box.width / 2, box.y - box.height / 2, boxWidth, boxHeight, 0, 0, boxWidth, boxHeight);

  const result = canvas.toDataURL('image/jpeg', 1);
  return result;
}

/** 活體檢測 */
function livenessDetection(imageData) {
  const utf8Encoder = new TextEncoder('utf-8');
  const encodedData = utf8Encoder.encode(imageData);

  const dst = _malloc(encodedData.length + 1);
  HEAPU8.set(encodedData, dst);
  HEAPU8[dst + encodedData.length] = 0;
  const result = _nentendo(dst);
  _free(dst);

  let isLiveness = result > 0.90;
  return isLiveness;
}


特徵比對找出最像的人員

後端收到當前使用者的臉部特徵向量後,會跟先前已儲存的人員特徵向量進行歐幾里得距離的比對,關鍵程式碼如下參考,詳細可參考 FaceMatcher.cs。

// 找出平均最佳的特徵向量對應的 Lable
public FaceMatch FindBestMatch(float[] queryDescriptor)
{
    FaceMatch bestMatch = MatchDescriptor(queryDescriptor);

    return bestMatch?.Distance < DistanceThreshold
        ? bestMatch
        : new FaceMatch("unknown", bestMatch?.Distance ?? -1);
}

// 比對特徵向量,找出最佳匹配
private FaceMatch MatchDescriptor(float[] queryDescriptor)
{
    return LabeledDescriptors
        .Select(ld => new FaceMatch(ld.Label, ComputeMeanDistance(queryDescriptor, ld.Descriptors)))
        .Aggregate((best, curr) => best.Distance < curr.Distance ? best : curr);
}

// 計算每個人所有特徵的平均距離
private double ComputeMeanDistance(float[] queryDescriptor, List descriptors)
{
    return descriptors
        .Select(d => MathUtils.EuclideanDistance(d, queryDescriptor))
        .Average();
}



本文範例下載 : Github,前端使用 JavaScript ,後端 NetCore 8 。


參考網站

留言