本文将结合移动设备摄像能力与 TensorFlow.js,在浏览器里实现一个实时的人脸情绪分类器。鉴于文章的故事背景较长,对实现本身更有兴趣的同学可直接跳转至技术方案概述

DEMO 试玩

前言

看遍了 25 载的雪月没风花,本旺早已不悲不喜。万万没想到,在圣诞节前夕,女神居然答应了在下的约会请求。可是面对这么个大好机会,本前端工程狮竟突然慌张起来。想在下正如在座的一些看官一样,虽玉树临风、风流倜傥,却总因猜不透女孩的心思,一不留神就落得个母胎单身。如今已是 8102 年,像我等这么优秀的少年若再不脱单,党和人民那都是一万个不同意!痛定思痛,在下这就要发挥自己的技术优势,将察「颜」观色的技能树点满,做一个洞悉女神喜怒哀愁的优秀少年,决胜圣诞之巅!

正题开始

需求分析

我们前端工程师终于在 2018 年迎来了 TensorFlow.js,这就意味着,就算算法学的再弱鸡,又不会 py 交易,我们也能靠着 js 跟着算法的同学们学上个一招半式。如果我们能够在约会期间,通过正规渠道获得女神的照片,是不是就能用算法分析分析女神看到在下的时候,是开心还是...不,一定是开心的!

可是,约会的战场瞬息万变,我们总不能拍了照就放手机里,约完会回到静悄悄的家,再跑代码分析吧,那可就 「too young too late」 了!时间就是生命,如果不能当场知道女神的心情,我们还不如给自己 -1s!

因此,我们的目标就是能够在手机上,实时看到这样的效果(嘛,有些简陋,不过本文将专注于功能实现,哈哈):

技术方案概述

很简单,我们需要的就两点,图像采集 & 模型应用,至于结果怎么展示,嗨呀,作为一个前端工程师,render 就是家常便饭呀。对于前端的同学来说,唯一可能不熟悉的也就是算法模型怎么用;对于算法的同学来说,唯一可能不熟悉的也就是移动设备怎么使用摄像头。

我们的流程即如下图所示(下文会针对计算速度的问题进行优化):

下面,我们就根据这个流程图来梳理下如何实现吧!

核心一:图像采集与展示

图像采集

我们如何使用移动设备进行图像或者视频流的采集呢?这就需要借助 WebRTC 了。WebRTC,即网页即时通信(Web Real-Time Communication),是一个支持网页浏览器进行实时语音对话或视频对话的 API。它于 2011 年 6 月 1 日开源,并在 Google、Mozilla、Opera 支持下被纳入万维网联盟的 W3C 推荐标准。

拉起摄像头并获取采集到的视频流,这正是我们需要使用到的由 WebRTC 提供的能力,而核心的 API 就是 navigator.mediaDevices.getUserMedia

该方法的兼容性如下,可以看到,对于常见的手机来说,还是可以较好支持的。不过,不同手机、系统种类与版本、浏览器种类与版本可能还是存在一些差异。如果想要更好的做兼容的话,可以考虑使用 Adapter.js 来做 shim,它可以让我们的 App 与 Api 的差异相隔离。此外,在这里可以看到一些有趣的例子。具体 Adapter.js 的实现可以自行查阅。

那么这个方法是如何使用的呢?我们可以通过 MDN 来查阅一下。MediaDevices getUserMedia() 会向用户申请权限,使用媒体输入,获得具有指定类型的 MediaStream(如音频流、视频流),并且会 resolve 一个 MediaStream 对象,如果没有权限或没有匹配的媒体,会报出相应异常:

navigator.mediaDevices.getUserMedia(constraints)
.then(function(stream) {
  /* use the stream */
})
.catch(function(err) {
  /* handle the error */
});

因此,我们可以在入口文件统一这样做:

class App extends Component {
  constructor(props) {
    super(props);
    // ...
    this.stream = null;
    this.video = null;
    // ...
  }

  componentDidMount() {
    // ...
    this.startMedia();
  }

  startMedia = () => {
    const constraints = {
      audio: false,
      video: true,
    };
    navigator.mediaDevices.getUserMedia(constraints)
      .then(this.handleSuccess)
      .catch(this.handleError);
  }

  handleSuccess = (stream) => {
    this.stream = stream; // 获取视频流
    this.video.srcObject = stream; // 传给 video
  }

  handleError = (error) => {
    console.log('navigator.getUserMedia error: ', error);
  }
  // ...
}

实时展示

为什么需要 this.video 呢,我们不仅要展示拍摄到的视频流,还要能直观的将女神的面部神情标记出来,因此需要通过 canvas 来同时实现展示视频流和绘制基本图形这两点,而连接这两点的方法如下:

canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);

当然,我们并不需要在视图中真的提供一个 video DOM,而是由 App 维护在实例内部即可。canvas.width 和 canvas.height 需要考虑移动端设备的尺寸,这里略去不表。

而绘制矩形框与文字信息则非常简单,我们只需要拿到算法模型计算出的位置信息即可:

export const drawBox = ({ ctx, x, y, w, h, emoji }) => {
  ctx.strokeStyle = EmojiToColor[emoji];
  ctx.lineWidth = '4';
  ctx.strokeRect(x, y, w, h);
}

export const drawText = ({ ctx, x, y, text }) => {
  const padding = 4
  ctx.fillStyle = '#ff6347'
  ctx.font = '16px'
  ctx.textBaseline = 'top'
  ctx.fillText(text, x + padding, y + padding)
}

核心二:模型预测

在这里,我们需要将问题进行拆解。鉴于本文所说的「识别女神表情背后的情绪」属于图像分类问题,那么这个问题就需要我们完成两件事:

  • 从图像中提取出人脸部分的图像;
  • 将提取出的图像块作为输入交给模型进行分类计算。

下面我们来围绕这两点逐步讨论。

人脸提取

我们将借助 face-api.js 来处理。face-api.js 是基于 tensorflow.js 核心 API (@tensorflow/tfjs-core) 来实现的在浏览器环境中使用的面部检测与识别库,本身就提供了
SSD Mobilenet V1、Tiny Yolo V2、MTCNN 这三种非常轻量的、适合移动设备使用的模型。很好理解的是效果自然是打了不少折扣,这些模型都从模型大小、计算复杂度、机器功耗等多方面做了精简,尽管有些专门用来计算的移动设备还是可以驾驭完整模型的,但我们一般的手机是肯定没有卡的,自然只能使用 Mobile 版的模型。

这里我们将使用 MTCNN。我们可以小瞄一眼模型的设计,如下图所示。可以看到,我们的图像帧会被转换成不同 size 的张量传入不同的 net,并做了一堆 Max-pooling,最后同时完成人脸分类、bb box 的回归与 landmark 的定位。大致就是说,输入一张图像,我们可以得到图像中所有人脸的类别、检测框的位置信息和比如眼睛、鼻子、嘴唇的更细节的位置信息。

当然,当我们使用了 face-api.js 时就不需要太仔细的去考虑这些,它做了较多的抽象与封装,甚至非常凶残的对前端同学屏蔽了张量的概念,你只需要取到一个 img DOM,是的,一个已经加载好 src 的 img DOM 作为封装方法的输入(加载 img 就是一个 Promise 咯),其内部会自己转换成需要的张量。通过下面的代码,我们可以将视频帧中的人脸提取出来。

export class FaceExtractor {
  constructor(path = MODEL_PATH, params = PARAMS) {
    this.path = path;
    this.params = params;
  }

  async load() {
    this.model = new faceapi.Mtcnn();
    await this.model.load(this.path);
  }

  async findAndExtractFaces(img) {
    // ...一些基本判空保证在加载好后使用
    const input = await faceapi.toNetInput(img, false, true);
    const results = await this.model.forward(input, this.params);
    const detections = results.map(r => r.faceDetection);
    const faces = await faceapi.extractFaces(input.inputs[0], detections);
    return { detections, faces };
  }
}

情绪分类

好了,终于到了核心功能了!一个「好」习惯是,扒一扒 GitHub 看看有没有开源代码可以参考一下,如果你是大佬请当我没说。这里我们将使用一个实时面部检测和情绪分类模型来完成我们的核心功能,这个模型可以区分开心、生气、难过、恶心、没表情等。

对于在浏览器中使用 TensorFlow.js 而言,很多时候我们更多的是应用现有模型,通过 tfjs-converter 来将已有的 TensorFlow 的模型、Keras 的模型转换成 tfjs 可以使用的模型。值得一提的是,手机本身集成了很多的传感器,可以采集到很多的数据,相信未来一定有 tfjs 发挥的空间。具体转换方法可参考文档,我们继续往下讲。

那么我们可以像使用 face-api.js 一样将 img DOM 传入模型吗?不行,事实上,我们使用的模型的输入并不是随意的图像,而是需要转换到指定大小、并只保留灰度图的张量。因此在继续之前,我们需要对原图像进行一些预处理。

哈哈,躲得了初一躲不过十五,我们还是来了解下什么是张量吧!TensorFlow 的官网是这么解释的:

算了没关系,我们画个图来理解张量是什么样的:

因此,我们可将其简单理解为更高维的矩阵,并且存储的时候就是个数组套数组。当然,我们通常使用的 RGB 图像有三个通道,那是不是就是说我们的图像数据就是三维张量(宽、高、通道)了呢?也不是,在 TensorFlow 里,第一维通常是 n,具体来说就是图像个数(更准确的说法是 batch),因此一个图像张量的 shape 一般是 [n, height, width, channel],也即四维张量。

那么我们要怎么对图像进行预处理呢?首先我们将分布在 [0, 255] 的像素值中心化到 [-127.5, 127.5],然后标准化到 [-1, 1]即可。

const NORM_OFFSET = tf.scalar(127.5);

export const normImg = (img, size) => {
  // 转换成张量
  const imgTensor = tf.fromPixels(img);

  // 从 [0, 255] 标准化到 [-1, 1].
  const normalized = imgTensor
    .toFloat()
    .sub(NORM_OFFSET) // 中心化
    .div(NORM_OFFSET); // 标准化

  const { shape } = imgTensor;
  if (shape[0] === size && shape[1] === size) {
    return normalized;
  }

  // 按照指定大小调整
  const alignCorners = true;
  return tf.image.resizeBilinear(normalized, [size, size], alignCorners);
}

然后将图像转成灰度图:

export const rgbToGray = async imgTensor => {
  const minTensor = imgTensor.min()
  const maxTensor = imgTensor.max()
  const min = (await minTensor.data())[0]
  const max = (await maxTensor.data())[0]
  minTensor.dispose()
  maxTensor.dispose()

  // 灰度图则需要标准化到 [0, 1],按照像素值的区间来标准化
  const normalized = imgTensor.sub(tf.scalar(min)).div(tf.scalar(max - min))

  // 灰度值取 RGB 的平均值
  let grayscale = normalized.mean(2)

  // 扩展通道维度来获取正确的张量形状 (h, w, 1)
  return grayscale.expandDims(2)
}

这样一来,我们的输入就从 3 通道的彩色图片变成了只有 1 个通道的黑白图。

准备好图像后,我们需要开始准备模型了!我们的模型主要需要暴露加载模型的方法 load 和对图像进行分类的 classify 这两个方法。加载模型非常简单,只需要调用 tf.loadModel 即可,需要注意的是,加载模型是一个异步过程。我们使用 create-react-app 构建的项目,封装的 Webpack 配置已经支持了 async-await 的方法。

class Model {
  constructor({ path, imageSize, classes, isGrayscale = false }) {
    this.path = path
    this.imageSize = imageSize
    this.classes = classes
    this.isGrayscale = isGrayscale
  }

  async load() {
    this.model = await tf.loadModel(this.path)

    // 预热一下
    const inShape = this.model.inputs[0].shape.slice(1)
    const result = tf.tidy(() => this.model.predict(tf.zeros([1, ...inShape])))
    await result.data()
    result.dispose()
  }

  async imgToInputs(img) {
    // 转换成张量并 resize
    let norm = await prepImg(img, this.imageSize)
    // 转换成灰度图输入
    norm = await rgbToGrayscale(norm)
    // 这就是所说的设置 batch 为 1
    return norm.reshape([1, ...norm.shape])
  }

  async classify(img, topK = 10) {
    const inputs = await this.imgToInputs(img)
    const logits = this.model.predict(inputs)
    const classes = await this.getTopKClasses(logits, topK)
    return classes
  }

  async getTopKClasses(logits, topK = 10) {
    const values = await logits.data()
    let predictionList = []

    for (let i = 0; i < values.length; i++) {
      predictionList.push({ value: values[i], index: i })
    }

    predictionList = predictionList
      .sort((a, b) => b.value - a.value)
      .slice(0, topK)

    return predictionList.map(x => {
      return { label: this.classes[x.index], value: x.value }
    })
  }
}

export default Model

我们可以看到,我们的模型返回的是一个叫 logits 的量,而为了知道分类的结果,我们又做了 getTopKClasses 的操作。这可能会使得较少了解这块的同学有些困惑。实际上,对于一个分类模型而言,我们返回的结果并不是一个特定的类,而是对各个 class 的概率分布,举个例子:

// 示意用
const classifyResult = [0.1, 0.2, 0.25, 0.15, 0.3];

也就是说,我们分类的结果其实并不是说图像中的东西「一定是人或者狗」,而是「可能是人或者可能是狗」。以上面的示意代码为例,如果我们的 label 对应的是 ['女人', '男人', '大狗子', '小狗子', '二哈'],那么上述的结果其实应该理解为:图像中的物体 25% 的可能性为大狗子,20% 的可能性为一个男人。

因此,我们需要做 getTopKClasses,根据我们的场景我们只关心最可能的情绪,那么我们也就会取 top1 的概率分布值,从而知道最可能的预测结果。

怎么样,tfjs 封装后的高级方法是不是在语义上较为清晰呢?

最终我们将上文提到的人脸提取功能与情绪分类模型整合到一起,并加上一些基本的 canvas 绘制:

  // 略有调整
  analyzeFaces = async (img) => {
    // ...
    const faceResults = await this.faceExtractor.findAndExtractFaces(img);
    const { detections, faces } = faceResults;

    // 对提取到的每一个人脸进行分类
    let emotions = await Promise.all(
      faces.map(async face => await this.emotionModel.classify(face))
    );
    // ...
  }

  drawDetections = () => {
    const { detections, emotions } = this.state;
    if (!detections.length) return;

    const { width, height } = this.canvas;
    const ctx = this.canvas.getContext('2d');
    const detectionsResized = detections.map(d => d.forSize(width, height));
    detectionsResized.forEach((det, i) => {
      const { x, y } = det.box
      const { emoji, name } = emotions[i][0].label;
      drawBox({ ctx, ...det.box, emoji });
      drawText({ ctx, x, y, text: emoji, name });
    });
  }

大功告成!

实时性优化

事实上,我们还应该考虑的一个问题是实时性。事实上,我们的计算过程用到了两个模型,即便已经是针对移动设备做了优化的精简模型,但仍然会存在性能问题。如果我们在组织代码的时候以阻塞的方式进行预测,那么就会出现一帧一帧的卡顿,女神的微笑也会变得抖动和僵硬。

因此,我们要考虑做一些优化,来更好地画出效果。

笔者这里利用一个 flag 来标记当前是否有正在进行的模型计算,如果有,则进入下一个事件循环,否则则进入模型计算的异步操作。同时,每一个事件循环都会执行 canvas 操作,从而保证标记框总是会展示出来,且每次展示的其实都是缓存在 state 中的前一次模型计算结果。这种操作是具有合理性的,因为人脸的移动通常是连续的(如果不连续这个世界可能要重新审视一下),这种处理方法能较好的对结果进行展示,且不会因为模型计算而阻塞,导致卡顿,本质上是一种离散化采样的技巧吧。

  handleSnapshot = async () => {
    // ... 一些 canvas 准备操作
    canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
    this.drawDetections(); // 绘制 state 中维护的结果

    // 利用 flag 判断是否有正在进行的模型预测
    if (!this.isForwarding) {
      this.isForwarding = true;
      const imgSrc = await getImg(canvas.toDataURL('image/png'));
      this.analyzeFaces(imgSrc);
    }

    const that = this;
    setTimeout(() => {
      that.handleSnapshot();
    }, 10);
  }

  analyzeFaces = async (img) => {
    // ...其他操作
    const faceResults = await this.models.face.findAndExtractFaces(img);
    const { detections, faces } = faceResults;

    let emotions = await Promise.all(
      faces.map(async face => await this.models.emotion.classify(face))
    );
    this.setState(
      { loading: false, detections, faces, emotions },
      () => {
        // 获取到新的预测值后,将 flag 置为 false,以再次进行预测
        this.isForwarding = false;
      }
    );
  }

效果展示

我们来在女神这试验下效果看看:

嗯,马马虎虎吧!虽然有时候还是会把笑容识别成没什么表情,咦,是不是 Gakki 演技还是有点…好了好了,时间紧迫,赶紧带上武器准备赴约吧。穿上一身帅气格子衫,打扮成程序员模样~

结尾

约会当晚,吃着火锅唱着歌,在下与女神相谈甚欢。正当气氛逐渐暧昧,话题开始深入到感情方面时,我自然的问起女神的理想型。万万没想到,女神突然说了这样的话:

那一刻我想起了 Eason 的歌:

参考

03-05 23:44