内容简介:本文将结合移动设备摄像能力与 TensorFlow.js,在浏览器里实现一个实时的人脸情绪分类器。鉴于文章的故事背景较长,对实现本身更有兴趣的同学可直接跳转至看遍了 25 载的雪月没风花,本旺早已不悲不喜。万万没想到,在圣诞节前夕,女神居然答应了在下的约会请求。可是面对这么个大好机会,本前端工程狮竟突然慌张起来。想在下正如在座的一些看官一样,虽玉树临风、风流倜傥,却总因猜不透女孩的心思,一不留神就落得个母胎单身。如今已是 8102 年,像我等这么优秀的少年若再不脱单,党和人民那都是一万个不同意!痛定思痛,
本文将结合移动设备摄像能力与 TensorFlow.js,在浏览器里实现一个实时的人脸情绪分类器。鉴于文章的故事背景较长,对实现本身更有兴趣的同学可直接跳转至 技术方案概述 。
前言
看遍了 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 演技还是有点…好了好了,时间紧迫,赶紧带上武器准备赴约吧。穿上一身帅气格子衫,打扮成 程序员 模样~
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 用Python解读“女神大会”,直男心目中的女神是这样的~
- Git 居然可以用来跟女神聊天?
- 决胜未来,2019年前端开发十大战略性技术布局
- 深入场景洞察用户 诸葛io决胜2017国际黑客松大赛
- 炫酷粒子表白 | 听说女神都想谈恋爱了!
- 实力与颜值齐飞!这里的 IT 女神不得了!
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。