如何根据权重裁剪FaceApi.js返回的人脸数据

一、背景

最近,公司产品重提人脸识别的需求,还记得几年前刚进公司那会儿产品也提了同样的需求,当时用的smartcrop,开发完成,产品验收通过,最后因为用户反馈不是很好该功能被下线,年底部门老大直接把这个锅扔给了我,然后扣了我绩效,WTF!!!

所以当这次收到这个需求,我的内心是有些抵触的,但是没办法,产品提了需求还是得做,经过一番调研,最终决定选用faceapi.js来实现,主要有以下原因:

1、支持直接在浏览器端调用。

2、支持检测多个人脸位置,同时支持年龄、性别、表情检测。产品要求按照小孩->女人->男人优先级从高到低的顺序来创建包含人脸的裁剪框。

3、免费,免费,还是免费。

需求的应用场景是一个在线的编辑器,在编辑的每一页中有一定数量的元素框,我们可以将图片填入元素框中,同时从图片中裁剪出适应元素框大小的图片区域,默认的算法是裁剪图片中间的部分并一边撑满(长或宽,根据图片的横竖确定),这种算法适用于相当数量的图片,但并不是所有的图片人脸都在中间,有的可能在左边,有的可能在右边,甚至上边和下边,当遇到这类图片前面提到的算法就会出现错误。

所以我们需要借助模型来帮我们智能识别出人脸的位置并进一步得到裁剪框,上面有提到smartcrop,它可以直接帮我们裁剪出包含人脸且满足目标宽高的裁剪框,但是我们还需要识别出人脸的性别来辅助我们根据权重裁剪人脸,smartcrop是无法满足的,最终我们选择使用faceapi.js

二、faceapi.js初体验

faceapi.js的使用方式还是比较简单的,加载完模型就可以直接使用了,方式如下:

async function loadModels() {
    await faceapi.nets.ssdMobilenetv1.loadFromUri('/models');
    await faceapi.loadFaceLandmarkModel('/models');
    await faceapi.nets.ageGenderNet.load('/models');
}

async function getFaces(image) {
    const options = new faceapi.SsdMobilenetv1Options({ minConfidence: 0.5 });
    return await faceapi
        .detectAllFaces(image, options)
        .withFaceLandmarks()
        .withAgeAndGender();
}

async function run(image) {
    try {
        await loadModels();
        const faces = await getFaces(image);
        console.log('识别出的人脸', faces);
    } catch(e) {
        console.log('识别人脸信息失败!');
    }
}

调用run方法并传入image即可打印识别出的人脸数据。

三、如何根据权重裁剪图片

前面我们提到产品要求按照小孩->女人->男人优先级从高到低的顺序来创建包含人脸的裁剪框,通过步骤二我们可以得到识别出的人脸数据,我们该如何使用人脸数据创建裁剪框呢?我们来分析一下:

我们最终要得到的裁剪框的宽高是固定的,而一张图片中可能存在多张人脸,我们创建的裁剪框并不一定能完全包含所有的人脸,装不下怎么办,那就淘汰一些人脸呗,该淘汰哪些人脸呢,根据产品的要求我们要按照男人->男人->小孩的顺序来淘汰人脸,进而我们可以得到第一版的算法:

function getCropByFaces({
  image,
  faces,
  target,
  weightTags = ['age', 'gender'],
}) {
  // 打印首次传入的所有人脸 用于调试数据
  if (!lastDropFace) {
    console.log(faces);
  }

  const isLast = faces.length === 1;

  // 1、根据目标裁切区域的大小在待裁切图片中换算出一边撑满的裁切区域
  const imageWidth = image.width,
    imageHeight = image.height;
  const imgRatio = imageWidth / imageHeight;
  const targetRatio = target.width / target.height;
  let targetWidth, targetHeight;
  if (imgRatio > targetRatio) {
    targetHeight = imageHeight;
    targetWidth = Math.round(targetHeight * targetRatio);
  } else {
    targetWidth = imageWidth;
    targetHeight = Math.round(targetWidth / targetRatio);
  }

  // 2、计算由所有的人脸组成的框的区域
  let minX = Infinity,
    minY = Infinity,
    maxX = -Infinity,
    maxY = -Infinity;
  if (faces.length) {
    faces.forEach(d => {
      const { detection } = d;
      const { box } = detection;
      const { x, y, width, height } = box;
      if (x < minX) {
        minX = x;
      }
      if (x + width > maxX) {
        maxX = x + width;
      }
      if (y < minY) {
        minY = y;
      }
      if (y + height > maxY) {
        maxY = y + height;
      }
    });
  }

  // 3、基于所有人脸组成图形的中心点 加上 1 中计算得出的裁切区域得到裁切区域的其实坐标x, y
  let boxX = Math.round(minX);
  let boxY = Math.round(minY);
  let boxWidth = Math.round(maxX - minX);
  let boxHeight = Math.round(maxY - minY);
  const middleX = Math.round(boxX + boxWidth / 2);
  const middleY = Math.round(boxY + boxHeight / 2);
  let x = Math.round(middleX - targetWidth / 2);
  let y = Math.round(middleY - targetHeight / 2);

  // 边界检测
  if (x < 0) {
    x = 0;
  }
  if (y < 0) {
    y = 0;
  }
  if (x + targetWidth > imageWidth) {
    x = imageWidth - targetWidth;
  }
  if (y + targetHeight > imageHeight) {
    y = imageHeight - targetHeight;
  }

  const cropBox = {
    x,
    y,
    width: targetWidth,
    height: targetHeight,
  };
  
  const isFitBox = minX >= cropBox.x &&
    minY >= cropBox.y &&
    maxX <= cropBox.x + cropBox.width &&
    maxY <= cropBox.y + cropBox.height;

  // 如果当前的人脸区域全部在裁切区域中 或者 当前的人脸是最后一个人脸 则cropBox即为结果
  if (isFitBox || isLast) {
    return cropBox;
  }

  // 淘汰权重最低的人脸 用剩下的人脸进行下一次递归 并依据判断是不是需要用留白填充淘汰的人脸
  const sortedFaces = sortBy(faces, weightTags);
  return getCropByFaces({ faces: sortedFaces, image, target });
};

其中sortBy方法用的是lodash的工具函数,测试一下,嗯,似乎一切都挺好,但是不是到这里就完成了呢?不,这还不能达到一个上线的标准,依然还有很多细节有待优化完善:

1、如果图片中没识别出人脸怎么办?

这种情况,我们可以直接按照默认的算法取图片的中间区域。

2、如果测试的多一点就会发现在某些情况下算法的运行效果似乎并不那么尽如人意,比如下面这张图片,

通过faceapi.js识别上面的图片我们可以得到2张人脸,当裁剪框的大小并不能完全装下2张人脸,但是如果硬要装的话,2张脸的大部分人脸区域都可以漏出来,如果根据上面的算法男人的脸显然是会被抛弃掉的,结果如下:

乍一看这张图片,你说它有问题吧,它确实是按照算法预期来执行的,但是你说它没问题却总感觉哪里怪怪的,似乎不太协调,究其原因,主要是女人脸左边的空白区域太多了,如果我们将其补充到右边,完全可以露出大部分区域的男人脸,我们将这一步定义为人脸补偿,补偿多少呢?我们可以定义一个来常量来控制它,这里定义为最小曝光比例minExposureRatio,而且补偿的方向有可能是上边,下边,左边,右边,在之前的算法中在裁剪框无法承载所有人脸的情况下我们直接淘汰了权重低的人脸,为了做人脸补偿,我们需要将淘汰的人脸带入下一次的运算中,优化后的算法如下:

function getCropByFaces({
  image,
  faces,
  target,
  lastDropFace,
  weightTags = ['age', 'gender'],
  minExposureRatio = 0.75,
}) {
  // 打印首次传入的所有人脸 用于调试数据
  if (!lastDropFace) {
    console.log(faces);
  }

  const isEmpty = faces.length === 0;
  const isLast = faces.length === 1;

  // 1、根据目标裁切区域的大小在待裁切图片中换算出一边撑满的裁切区域
  const imageWidth = image.width,
    imageHeight = image.height;
  const imgRatio = imageWidth / imageHeight;
  const targetRatio = target.width / target.height;
  let targetWidth, targetHeight;
  if (imgRatio > targetRatio) {
    targetHeight = imageHeight;
    targetWidth = Math.round(targetHeight * targetRatio);
  } else {
    targetWidth = imageWidth;
    targetHeight = Math.round(targetWidth / targetRatio);
  }

  if (isEmpty) {
    const x = Math.round((imageWidth - targetWidth) / 2);
    const y = Math.round((imageHeight - targetHeight) / 2);
    return {
        x,
        y,
        width: targetWidth,
        height: targetHeight,
    };
  }

  // 2、计算由所有的人脸组成的框的区域
  let minX = Infinity,
    minY = Infinity,
    maxX = -Infinity,
    maxY = -Infinity;
  if (faces.length) {
    faces.forEach(d => {
      const { detection } = d;
      const { box } = detection;
      const { x, y, width, height } = box;
      if (x < minX) {
        minX = x;
      }
      if (x + width > maxX) {
        maxX = x + width;
      }
      if (y < minY) {
        minY = y;
      }
      if (y + height > maxY) {
        maxY = y + height;
      }
    });
  }

  // 3、基于所有人脸组成图形的中心点 加上 1 中计算得出的裁切区域得到裁切区域的其实坐标x, y
  let boxX = Math.round(minX);
  let boxY = Math.round(minY);
  let boxWidth = Math.round(maxX - minX);
  let boxHeight = Math.round(maxY - minY);
  const middleX = Math.round(boxX + boxWidth / 2);
  const middleY = Math.round(boxY + boxHeight / 2);
  let x = Math.round(middleX - targetWidth / 2);
  let y = Math.round(middleY - targetHeight / 2);

  // 如果存在上次因权重被淘汰的人脸 则判断是不是需要用新的裁切区域的留白去补充被淘汰的人脸
  if (lastDropFace) {
    const { x: faceX, y: faceY, width: faceWidth, height: faceHeight } = lastDropFace.detection.box;

    if (faceX < x && x + targetWidth > maxX) {
      // 如果上次淘汰人脸的x坐标小于当前裁切区域的x坐标 且裁切区域的右边还存在留白
      const newX = maxX - targetWidth;
      if ((faceX + faceWidth - newX) / faceWidth >= minExposureRatio) {
        // 如果上次淘汰的人脸在调整后被被包含在裁切区域中的大小的曝光度大于minExposureRatio则调整
        x = newX;
      }
    } else if (faceX > x + targetWidth && x < minX) {
      // 如果上次淘汰人脸的x坐标大于当前裁切区域最右侧的坐标 且裁切区域的左边还存在留白
      const right = minX + targetWidth;
      if ((right - faceX) / faceWidth >= minExposureRatio) {
        // 如果上次淘汰的人脸在调整后被被包含在裁切区域中的大小的曝光度大于minExposureRatio则调整
        x = minX;
      }
    }

    if (faceY < y && y + targetHeight > maxY) {
      // 如果上次淘汰人脸的y坐标小于当前裁切区域的y坐标 且裁切区域的下边还存在留白
      const newY = maxY - targetHeight;
      if ((faceY + faceHeight - newY) / faceHeight >= minExposureRatio) {
        // 如果上次淘汰的人脸在调整后被被包含在裁切区域中的大小的曝光度大于minExposureRatio则调整
        y = newY;
      }
    } else if (faceY > y + target && y < minY) {
      // 如果上次淘汰人脸的x坐标大于当前裁切区域最下侧的坐标 且裁切区域的上边还存在留白
      const bottom = minY + targetHeight;
      if ((bottom - faceY) / faceHeight >= minExposureRatio) {
        // 如果上次淘汰的人脸在调整后被被包含在裁切区域中的大小的曝光度大于minExposureRatio则调整
        x = minY;
      }
    }
  }

  // 边界检测
  if (x < 0) {
    x = 0;
  }
  if (y < 0) {
    y = 0;
  }
  if (x + targetWidth > imageWidth) {
    x = imageWidth - targetWidth;
  }
  if (y + targetHeight > imageHeight) {
    y = imageHeight - targetHeight;
  }

  const cropBox = {
    x,
    y,
    width: targetWidth,
    height: targetHeight,
  };
  const isFitBox =
    minX >= cropBox.x &&
    minY >= cropBox.y &&
    maxX <= cropBox.x + cropBox.width &&
    maxY <= cropBox.y + cropBox.height;

  // 如果当前的人脸区域全部在裁切区域中 或者 当前的人脸是最后一个人脸 则cropBox即为结果
  if (isFitBox || isLast) {
    return cropBox;
  }

  // 淘汰权重最低的人脸 用剩下的人脸进行下一次递归 并依据判断是不是需要用留白填充淘汰的人脸
  const sortedFaces = sortBy(faces, weightTags);

  const dropFace = sortedFaces.pop();
  return getCropByFaces({ faces: sortedFaces, image, target, lastDropFace: dropFace });
};

运行效果如下:

到这里,单就算法本身而言基本上是达到了一个可以上线的标准了,当然要更好的接入使用算法有待考虑的点还有很多。

四、写在最后

1、可能有不少朋友注意到了,在算法实现中,我们使用了递归,会担心如果图片中的人脸太多会不会有爆栈的风险,没错,我们这里确实用到了递归,但不是常规的递归方式,而是尾递归,所以这块儿完全不用太担心,还不了解尾递归的朋友可以查看JS性能优化-神奇的尾递归调用这篇文章。

2、不建议直接在浏览器主线程中直接使用faceapi.js,使用webworker可能会更好一点,当然要在webworker中使用faceapi.js,有巨坑,解决方式可以参考在WebWorker中使用FaceAPI.js的正确姿势这篇文章。

3、如果存在多张图片同时进行智能裁剪的场景,建议多开webworker并使用并发池来控制。

4、faceapi.js的模型需要预加载,我们可以选择在应用初始化的时候初始化模型,不过预加载模型的faceapi对象要和最后调用的faceapi对象一致,特别是在多并发的场景下,不然会报模型没有加载的错误。

5、faceapi.js的模型需要预热,通过测试发现在应用打开后首次使用faceapi.js识别图片人脸时速度普遍较慢,第一次过后速度会快很多,猜测第一次需要将模型数据加载到内存,所以我们可以选择在应用和模型初始化后,选择一张比较小的图片先调一下faceapi.js的人脸识别,相当于预热了模型,后面正常用户的识别操作就会加快。

6、faceapi.js的识别准确度并不能达到100%,有可能会出现异常,比如明明有人脸,却没有识别出来,或者识别出的性别不对等等情况,再加上该库本身是基于国外的数据训练的,对于国人的照片,准确度上可能还会打一些折扣,而且现在大部分照片都是精修过的,也可能会抹平一些识别特征,对置信度有一些影响,不过整体用下来大部分图片是没有问题的。

7、这是我遇到的最奇怪的问题,在不同的机器上,用一张图片识别出的结果可能是不一样的,比如A电脑识别出2张人脸,B电脑可能只能识别出1张人脸,A电脑可能识别出一个男的,B电脑可能识别出是一个女的,诸如此类。查询Chatgpt得到的回答是:“人脸检测和分析是计算密集型任务,需要一定的计算资源。不同电脑的硬件性能不同,包括 CPU、GPU 和内存等,这可能会影响处理速度和结果的准确性。性能更强大的计算机可能能够更好地处理人脸数据”,当然我们可以在服务器端统一部署faceapi.js,然后和前端通过接口交互,不过这种方式在用户体验上可能会有所牺牲。

五、测试demo

在线demo:https://demo.deanhan.cn/smartcrop/

如需demo源码及素材包,请在关注本站公众号后发送:smartcrop

  • 支付宝二维码 支付宝
  • 微信二维码 微信

本文地址: /smartcrop-by-faceapi.html

版权声明: 本文为原创文章,版权归 逐梦个人博客 所有,欢迎分享本文,转载请保留出处!

相关文章