如何用200行JavaScript代码实现人脸检测?
(给IT平头哥联盟加星标,提升前端技能)
在超市、地铁、车站等很多场景中,人脸识别已经被广泛应用,但是这个功能究竟是怎么实现的?
在本文中,将以 pico.js 库为例,分享实现轻量级人脸识别功能的具体开发过程 。
以下为译文:
pico.js 是一个只有 200 行纯 JavaScript 代码的人脸检测库,具备实时检测功能(在实际环境中可达到200+ FPS),压缩后仅 2kB 。
开源代码地址:https://github.com/tehnokv/picojs;
1. 简介
本文将介绍pico.js,这一由JavaScript编写的用于人脸检测的代码库,并展示其工作原理。尽管现已有类似的项目,但我们的目标是提供更小、计算效率更高的替代方案。
在深入考究其细节前,建议各位用计算机的网络摄像头体验一下人脸检测的实时演示(也适用于移动设备)。注意,所有进程都是在客户端完成的,即不向服务端发送图像。因此,各位无需担心在运行这段代码时的隐私问题。
在接下来的篇幅里,我将阐述pico.js的理论背景及其工作原理。
2. Pico对象监测框架
2013年,Markus团队在一个技术报告中介绍了这一由JavaScript实现的pico.js代码库。它是参考C语言实现的,我们可在GitHub上获取其源码:https://github.com/nenadmarkus/pico。我们密切关注其实现方法,因为我们不打算复制学习过程,而仅关注它的运行。这背后的原因是,我们最好学习带有官方代码的检测器,将其加载到JavaScript中并执行进程,如此就带有独特的优势(比如跨操作系统与设备的强大的可移植性)。
Pico对象检测框架是流行的Viola-Jones方法的一个改进。
Viola-Jones方法是基于区域分类的概念。这意味着在图像的每个合理位置和尺度上都使用分类器。这个区域枚举过程的可视化如下图所示:
该分类器试图判断当前区域是否存在人脸。最后,获取到的人脸区域将根据重叠程度进行聚类。鉴于每张图像都有很多区域,在这实时进程中有两个小技巧:
将分类器归类为级联分类器;
级联的每个成员都可以在0(1)时间内根据区域的大小进行计算。
分类级联由一系列分类器组成。这些分类器中的每一个都能正确识别几乎所有的人脸,并丢弃一小部分非人脸区域。如果一个图像区域通过了级联的所有成员,那么它就被认定为人脸。通过(设计)序列中靠前的分类器比靠后的分类器更简单,这种效果得到了进一步放大。级联分类算法如下图所示:
每个阶段包括一个分类器Cn,它既可以拒绝图像区域(R),也可以接受图像区域(A)。一旦被拒绝,该区域将不会进入下一级联成员。如果没有一个分类器拒绝该区域,我们认为它是一张人脸。
在Viola-Jones框架中,每个分类器Cn都基于Haar-like特性。这使得每个区域可通过名为积分图像的预算结构来进行O(1)计算时间。
然而,积分图像也有一些缺点。最明显的缺点是,这种数据结构需要额外的内存来储存:通常是unit8输入图像的4倍。另外一个问题是构建一个完整的图像所需的时间(也与输入的像素数有关)。在功能有限的小型硬件上处理大的图像也可能会有问题。这种方法的一个更微妙的问题是它的优雅性:随之而来的问题是我们是否能够创建一个不需要这种结构、并且具有所有重要属性的框架。
Pico框架对每个分类器Cn用像素对比测试取代了Haar-like特性,形式如下:
其中R是一个图像区域,(Xi,Yi)表示用于比较像素值的位置。注意,这种测试可以应用于各种尺寸的区域,而不需要任何专门的数据结构,这与Haar-like的特性不同。这是通过将位置(Xi,Yi)存储在标准化坐标中(例如,(Xi,Yi)在[−1,1]×[−1,1]中),并乘以当前区域的比例。这就是pico实现多尺度检测功能的思路。
由于此类测试很简单,又因混叠和噪声而存在潜在问题,我们有必要将大量测试应用于该区域,以便对其内容进行推理。在pico框架中,这是通过
将测试组合考虑到决策树中;
有多个这样的决策树,通过对它们的输出求和,形成级联成员Cn。
这可以用数学符号表示,如下:
其中Tt(R)表示决策树Tt在输入区域R上生成的标量输出。由于每个决策树都由若干个像素比较测试组成,这些测试可以根据需要调整大小,因此运行分类阶段Cn的计算复杂度与区域大小无关。
每个Cn决策树都是AdaBoost的变体。接下来以这种方式将阈值设置为Cn的输出,以获取期望的真阳率(例如0.995)。所有得分低于这个阈值的区域都不认为是人脸。添加级联的新成员,直到达到预期的假阳率。请参阅原出版物学习相关细节内容。
正如简介中说的那样,我们不会复制pico的学习过程,而仅关注它的运行。如果您想学习自定义对象/人脸检测器,请使用官方的实现方法。Pico.js能够加载二进制级联文件并有效地处理图像。接下来的小节将解释如何使用pico.js来检测图像中的人脸。
pico.js的组件
库的组成部分如下:
从级联数据实例化区域分类功能的过程;
在图像上运行此分类器以检测面部的过程;
对获得的检测结果进行聚类的过程;
时序记忆模块。
通过<script src="pico.js"></script>(或它的压缩版本) 引入并进行一些预处理后,就可以使用这些工具了。我们将讨论对图像进行人脸检测的JS代码(GitHub repo中的代码)。但愿这能详尽说明使用该库的方法。实时演示也有说明。
实例化区域分类器
区域分类器应识别图像区域是否为人脸。其思路是在整个图像中运行这个分类器,以获得其中的所有面孔(稍后详细介绍)。Pico.js的区域分类过程封装在一个函数中,其原型如下:
function(r, c, s, pixels, ldim) {
/*
...
*/
}
前三个参数(r、c和s)指定区域的位置(其中心的行和列)及其大小。pixels阵列包含图像的灰度强度值。参数ldim规定从图像的一行移动到下一行的方式(在诸如OpenCV的库中称为stride)。也就是说,从代码中可以看出(r,c)位置的像素强度为[r*ldim + c]像素。该函数会返回一个浮点值,表示该区域的得分。如果分数大于或等于0.0,则该区域认定为人脸。如果分数低于0.0,则该区域认定为非人脸,即属于背景类。
Pico.js中pico.unpack_cascade过程将二进制的级联作为参数,将其解压并返回一个带有分类过程和分类器数据的闭包函数。我们用它初始化区域分类过程,以下是详细说明。
官方pico的人脸检测级联称为facefinder。它由近450个决策树组成,每个决策树的深度为6,它们集成一个25级联。该级联将在我们是实验中用到,它能对正脸图像以适当的检测速率进行实时处理,正如实时演示看到的那样。
facefinder级联可以直接从官方的github库上下载,代码写为:
var facefinder_classify_region = function(r, c, s, pixels, ldim) {return -1.0;};
var cascadeurl = 'https://raw.githubusercontent.com/nenadmarkus/pico/c2e81f9d23cc11d1a612fd21e4f9de0921a5d0d9/rnt/cascades/facefinder';
fetch(cascadeurl).then(function(response) {
response.arrayBuffer().then(function(buffer) {
var bytes = new Int8Array(buffer);
facefinder_classify_region = pico.unpack_cascade(bytes);
console.log('* cascade loaded');
})
})
首先,将facefinder_classify_region初始化,即任何图像区域先认定为非人脸(它总是返回-1.0)。接下来,我们使用Fetch API从cascadeurl URL中获取级联二进制数据。这是一个异步调用,我们不能即刻获取到数据。最后,在获取到响应数据后,将其转换为int8数组并传递给pico.unpack_cascade,然后pico.unpack_cascade生成正确的facefinder_classify_region函数。
将facefinder_classify_region函数应用于图像中每个区域的合理位置和等级以便检测到所有的人脸。这个过程将在下一小节中解释。
在图像上运行分类器
假定HTML body内有一个canvas元素,一个image标签和一个带有onclick回调的button标签。用户一旦点击了人脸检测按钮,检测过程就开始了。
下面的JS代码用于绘制内容和图像,并获取原始像素值(红、绿、蓝+ alpha的格式):
var img = document.getElementById('image');
var ctx = document.getElementById('canvas').getContext('2d');
ctx.drawImage(img, 0, 0);
var rgba = ctx.getImageData(0, 0, 480, 360).data; // the size of the image is 480x360 (width x height)
下面,我们编写一个辅助函数,将输入的RGBA数组转换为灰度:
function rgba_to_grayscale(rgba, nrows, ncols) {
var gray = new Uint8Array(nrows*ncols);
for(var r=0; r<nrows; ++r)
for(var c=0; c<ncols; ++c)
// gray = 0.2*red + 0.7*green + 0.1*blue
gray[r*ncols + c] = (2*rgba[r*4*ncols+4*c+0]+7*rgba[r*4*ncols+4*c+1]+1*rgba[r*4*ncols+4*c+2])/10;
return gray;
}
现在我们准备调用这个过程,它将在整个图像中运行facefinder_classify_region函数:
image = {
"pixels": rgba_to_grayscale(rgba, 360, 480),
"nrows": 360,
"ncols": 480,
"ldim": 480
}
params = {
"shiftfactor": 0.1, // move the detection window by 10% of its size
"minsize": 20, // minimum size of a face
"maxsize": 1000, // maximum size of a face
"scalefactor": 1.1 // for multiscale processing: resize the detection window by 10% when moving to the higher scale
}
// run the cascade over the image
// dets is an array that contains (r, c, s, q) quadruplets
// (representing row, column, scale and detection score)
dets = pico.run_cascade(image, facefinder_classify_region, params);
注意,人脸的最小尺寸默认设置为20。这太小了,对于大部分应用程序来说都是不必要的。但还需要注意的是,运行速度在很大程度上取决于此参数。对于实时应用程序,应该将此值设置为100。但是,设置的最小尺寸需匹配示例图像。
检测过程完成后,数组dets包含表单(r,c,s,q),其中r,c,s指定人脸区域的位置(行,列)和大小,q表示检测分数。该地区得分越高,越有可能是人脸。
我们可以将得到的检测结果渲染到画布上:
qthresh = 5.0
for(i=0; i<dets.length; ++i)
// check the detection score
// if it's above the threshold, draw it
if(dets[i][3]>qthresh)
{
ctx.beginPath();
ctx.arc(dets[i][1], dets[i][0], dets[i][2]/2, 0, 2*Math.PI, false);
ctx.lineWidth = 3;
ctx.strokeStyle = 'red';
ctx.stroke();
}
我们需要根据经验设置变量qthresh(5.0刚好,适用于facefinder级联和静止图像中的人脸检测)。典型的检测结果是这样的:
我们可以看到每张脸周围都有多个探测器。这个问题用非极大值抑制来解决,在下一小节中解释。
原始检测的非极大值抑制(聚类)
非极大值抑制聚类的目的是将重叠的人脸区域融合在一起。每个集群的代表是其中得分最高的一次检测(该方法因此而得名)。它的分数更新为集群中所有检测分数的总和。
pico.js中的实现方式是:
dets = pico.cluster_detections(dets, 0.2); // set IoU threshold to 0.2
IoU阈值设置为0.2。这意味着两个重叠大于该值的检测将合并在一起。
现在的结果是这样的:
我们已经学习了使用pico.js检测静止图像中人脸的基本知识。值得注意的是,pico方法不如基于深度学习的现代人脸检测器强大。然而,pico非常快,这使得它成为许多应用程序的首选,比如那些需要实时处理的应用程序。
在视频中使用pico.js进行实时人脸检测
由于pico.js产生的检测噪声比较大,我们开发了一种时间记忆模块,在处理实时视频时可减轻少此问题。该方法用于上述实时演示中,显著提高了主观检测质量。
其思想是将几个连续帧的检测结合起来,以准确判断给定区域是否为人脸。这是通过实例化一个电路缓冲区来实现的,该缓冲区包含从最后一个f帧检测到的信号:
var update_memory = pico.instantiate_detection_memory(5); // f is set to 5 in this example
update_memory闭包封装了电路缓冲区和刷新数据的代码。返回的数组包含来自最后f帧的检测。
现在我们不再从单帧中检测聚类,而是在聚类之前进行累加:
dets = pico.run_cascade(image, facefinder_classify_region, params);
dets = update_memory(dets); // accumulates detections from last f frames
dets = pico.cluster_detections(dets, 0.2); // set IoU threshold to 0.2
最终的分类阈值qthresh会显著提高,这会减少假阳性的数量,而不会显著影响到真阳率。
来自csdn,作者 | tehnokv 译者 | 谭开朗,责编 | 屠敏
https://tehnokv.com/posts/picojs-intro/
end -
用心分享 一起成长 做有温度的攻城狮
每天记得对自己说:你是最棒的!
好文阅读:
都看到这里了,给个“在看”再走呗~