返回

解决face-api.js人脸匹配问题:多/单人脸检测与匹配

Ai

如何识别上传图片中与本地图片匹配的人脸

最近做了一个 Angular 应用,用 face-api.js 来做人脸识别。这个应用处理一堆本地图片来识别人脸,并生成对应的面部符。当用户上传一张新图片,应用会检测并提取这张图片的面部符,然后和本地图片的面部描述符比较,找出并返回所有包含匹配人脸的图片。

但是,目前这段代码无法正确返回匹配的人脸图片。 下面我们来分析下问题,并给出解决办法。

一、 问题原因分析

从代码看, 主要问题可能出现在几个地方:

  1. 单张/多张人脸检测: 本地图片加载(loadLocalImages)时使用了 detectSingleFace, 假设每张本地图片中都只有一张人脸。如果本地图片包含多张人脸,detectSingleFace只会检测并返回第一个人脸, 其余人脸被忽略。上传图片处理(onUpload)部分同样使用了detectSingleFace.
  2. FaceMatcher 的使用: FaceMatcher 构造函数接收一个 LabeledFaceDescriptors 数组,或者一个已有的 FaceMatcher 对象。当前代码中每次调用 onUpload 时,用 this.faceDescriptors 构建了一个新的 FaceMatcher,而之前的匹配状态和数据并没有保留。尽管这个点本身不是不返回图片直接原因,但会导致匹配效率低。 更主要的问题出现在下面一点:
  3. 匹配逻辑错误: 使用this.faceDescriptors直接做filter,然后对每一个descriptorfaceMatcher.findBestMatch(detection.descriptor),是错误的. 因为this.faceDescriptors的label是imagePath, 而descriptor是一个数组(即使其中只有一个元素),包含人脸特征数据。即使找到最佳匹配,也无法知道是本地多张照片中的哪张人脸. faceMatcher.findBestMatch 是对每个labeledFaceDescriptors做匹配. 换句话说,即使找到最接近的,也只能知道这张人脸最接近localImages中若干人脸数据(注意:这里“若干人脸”是指本地图片可能加载了多张人脸的特征)的哪一个特征值,并不知道来自哪张图。

二、 解决方案

针对上面分析的原因,提出下面几个解决步骤。

1. 改造人脸检测方法

需要同时支持单张人脸和多张人脸检测. 把 detectSingleFace 换成 detectAllFaces.

原理: detectAllFaces 可以检测图片中的所有人脸,返回一个包含所有人脸信息的数组。

代码示例 (对 loadLocalImagesonUpload 进行修改):

  // ... 其他代码 ...

  async loadLocalImages() {
    try {
      for (const imagePath of this.localImages) {
        const img = await faceapi.fetchImage(imagePath);
        const detections = await faceapi.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors(); // 改成 detectAllFaces
        if (detections && detections.length > 0) {
          for (const detection of detections) { // 遍历所有检测到的人脸
              const label = imagePath; // Use image file path as label
              this.faceDescriptors.push(new faceapi.LabeledFaceDescriptors(label, [detection.descriptor]));
          }
          console.log(`Loaded descriptors for ${imagePath}:`, detections);
        } else {
          console.log(`No face detected in ${imagePath}`);
        }
      }
      console.log('Local images loaded and processed:', this.faceDescriptors);
    } catch (error) {
      console.error('Error loading local images:', error);
    }
  }

  async onUpload() {
    if (this.selectedFile) {
      const reader = new FileReader();
      reader.onload = async () => {
        try {
          const imageBlob = this.dataURLToBlob(reader.result as string);
          if (imageBlob) {
            const img = await faceapi.bufferToImage(imageBlob);
            const detections = await faceapi.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors();// 改成 detectAllFaces
            if (detections && detections.length > 0) {
               //后续匹配逻辑见第3点
            } else {
              this.images = [];
              console.log('No face detected in the uploaded image.');
            }
          } else {
            this.images = [];
            console.error('Failed to convert Data URL to Blob.');
          }
        } catch (error) {
          console.error('Error processing uploaded image:', error);
        }
      };
      reader.readAsDataURL(this.selectedFile);
    }
  }

  // ... 其他代码 ...
  dataURLToBlob(dataURL: string) {
    const arr = dataURL.split(',');
    const mime = arr[0].match(/:(.*?);/)![1];
    const bstr = atob(arr[1]);
    let n = bstr.length;
    const u8arr = new Uint8Array(n);
    while (n--) {
      u8arr[n] = bstr.charCodeAt(n);
    }
    return new Blob([u8arr], { type: mime });
  }

2. 创建并维护 FaceMatcher

ngOnInit 中创建 FaceMatcher,而不是在 onUpload 中。

原理: 只创建一个 FaceMatcher 对象,在多次上传图片时可以复用,提高效率,且确保了基准一致。

代码示例:

  // ... 其他代码 ...
  faceMatcher: faceapi.FaceMatcher | null = null; // 添加 FaceMatcher 属性

  async ngOnInit() {
    await this.loadModels();
    await this.loadLocalImages();
    this.faceMatcher = new faceapi.FaceMatcher(this.faceDescriptors); // 创建 FaceMatcher
  }

 // ... 其他代码 ...

3. 修改匹配逻辑

使用detectAllFaces后,onUpload 中的匹配逻辑也要修改。 对上传图片检测到的每一个人脸,都在 FaceMatcher 中寻找最佳匹配。

原理: 对上传图片中的 每个 人脸,都和本地图片中 所有 人脸进行比对. 这样,即使上传的是一张集体照,也能正确匹配.

代码示例 (修改 onUpload):

  // ... 其他代码 ...
  async onUpload() {
    if (this.selectedFile) {
      const reader = new FileReader();
      reader.onload = async () => {
        try {
          const imageBlob = this.dataURLToBlob(reader.result as string);
          if (imageBlob) {
            const img = await faceapi.bufferToImage(imageBlob);
            const detections = await faceapi.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors();
            if (detections && detections.length > 0) {
              console.log('Uploaded image descriptors:', detections);
              const threshold = 0.6; // 阈值,可以根据实际情况调整
              const matchedImageURLs: string[] = [];

              for (const detection of detections) { // 遍历上传图片中检测到的每个人脸
                 for (const labeledFaceDescriptors of this.faceDescriptors){
                     const match = this.faceMatcher!.findBestMatch(detection.descriptor);
                      if (match.distance < threshold && match.label != 'unknown') {
                           if (!matchedImageURLs.includes(labeledFaceDescriptors.label)) {
                             matchedImageURLs.push(labeledFaceDescriptors.label);
                           }

                      }
                 }
              }
              if (matchedImageURLs.length > 0) {
                console.log('Matched image URLs:', matchedImageURLs);
                this.images = matchedImageURLs;
              } else {
                this.images = [];
                console.log('No exact matches found.');
              }

            } else {
              this.images = [];
              console.log('No face detected in the uploaded image.');
            }
          } else {
            this.images = [];
            console.error('Failed to convert Data URL to Blob.');
          }
        } catch (error) {
          console.error('Error processing uploaded image:', error);
        }
      };
      reader.readAsDataURL(this.selectedFile);
    }
  }

// ... 其他代码 ...

4. 处理 “unknown” 标签

如果 FaceMatcher 找不到匹配的人脸,findBestMatch 会返回一个标签为 "unknown" 的结果。 我们的场景中,需要排除这些结果。

原理: "unknown" 表示没有找到匹配的人脸,不应被包含在匹配结果中。

代码示例: 在上面的匹配逻辑里,已经通过 match.label != 'unknown' 进行了过滤.

5. (进阶)优化本地图片加载

如果本地图片数量非常多, 每次启动都重新加载、计算描述符会很慢。可以把描述符保存到本地存储(比如 localStorageIndexedDB), 启动时先检查是否有已保存的描述符,如果有,直接加载,不用重新计算。

原理: 空间换时间,避免重复计算。

代码示例 (仅展示思路,具体实现较复杂):

  // ... 其他代码 ...

  async loadLocalImages() {
    // 1. 尝试从 localStorage 加载已保存的描述符
    const savedDescriptors = localStorage.getItem('faceDescriptors');
    if (savedDescriptors) {
      this.faceDescriptors = JSON.parse(savedDescriptors);
      console.log('Loaded descriptors from localStorage');
      return;
    }

    // 2. 如果没有保存的描述符,正常加载图片、计算描述符
      try {
      for (const imagePath of this.localImages) {
        const img = await faceapi.fetchImage(imagePath);
        const detections = await faceapi.detectAllFaces(img).withFaceLandmarks().withFaceDescriptors(); // 改成 detectAllFaces
        if (detections && detections.length > 0) {
          for (const detection of detections) { // 遍历所有检测到的人脸
              const label = imagePath; // Use image file path as label
              this.faceDescriptors.push(new faceapi.LabeledFaceDescriptors(label, [detection.descriptor]));
          }
          console.log(`Loaded descriptors for ${imagePath}:`, detections);
        } else {
          console.log(`No face detected in ${imagePath}`);
        }
      }
      console.log('Local images loaded and processed:', this.faceDescriptors);
    } catch (error) {
      console.error('Error loading local images:', error);
    }

    // 3. 把计算好的描述符保存到 localStorage
     localStorage.setItem('faceDescriptors', JSON.stringify(this.faceDescriptors));
  }

 // ...其他代码,使用IndexedDB类似...

注意: 序列化/反序列化 LabeledFaceDescriptors 对象比较麻烦,直接用 JSON.stringify 不行。 上面的代码只是简单演示。 正确的做法是只保存描述符数组(Float32Array), 加载后,再重新创建 LabeledFaceDescriptors 对象。

6. 安全建议

  • 限制上传图片大小和格式: 防止恶意用户上传超大图片导致服务器资源耗尽,或上传恶意文件。可以在前端和后端都进行校验。
  • 对人脸识别结果进行二次确认(可选): 如果应用场景对准确性要求很高,可以在返回匹配结果后, 增加人工确认环节, 避免误判.
  • 谨慎处理用户的人脸数据。 遵守相关隐私法规, 比如 GDPR。

总结

经过以上修改, 现在应用应该能正确识别上传图片中与本地图片匹配的人脸了。主要改进是:

  1. 使用 detectAllFaces 检测所有人脸, 包括单张和多张人脸图片。
  2. 在组件初始化时创建 FaceMatcher, 而不是每次上传都创建,避免重复运算。
  3. 修改了匹配循环逻辑, 使上传图像中的每个人脸特征都和本地所有图片人脸进行对比.
  4. 过滤掉 FaceMatcher 返回的 "unknown" 结果.
  5. (可选)增加预加载,缓存机制, 提升大量本地图片时的加载速度.

按照上面修改后, 多人脸,单人脸应该都能正确处理和匹配。 如果还有问题,请检查一下 threshold 阈值是否设置合理. 如果阈值过高,可能导致本来匹配的人脸被过滤掉。