Press "Enter" to skip to content

决胜圣诞,女神心情不用猜!

本文将结合移动设备摄像能力与 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 的官网是这幺解释的:

 

张量是对矢量和矩阵向潜在的更高维度的泛化。TensorFlow 在内部将张量表示为基本数据类型的 n 维数组。

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

因此,我们可将其简单理解为更高维的矩阵,并且存储的时候就是个数组套数组。当然,我们通常使用的 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 演技还是有点…好了好了,时间紧迫,赶紧带上武器准备赴约吧。穿上一身帅气格子衫,打扮成程序员模样~

One Comment

  1. jonsnow

    666

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注