【Nodejs】记一次图像识别的冒险

  笔者的团队最近接到了一个有关图像识别的需求,本来应该由后端团队提供能力,native提供容器,前端团队仅负责一些“外围的形式工作”,不过由于各种各样的原因,最后的结果变成了前端团队在原有工作基础上,承担了图像识别的能力,后端专注其他服务,于是一场图像识别的冒险就此开始。

  因为项目背景是图像识别,而笔者仅有一些本科时图形学的一些皮毛知识,自己实现想来基本不太可能了,于是一开始就准备伸出手去探索下有没有现实可用的既成方案。因为最开始的项目需求是文字识别,首先进入笔者视野的就是OCR(Optical Character Recognition),也就是光学字符识别,毕竟node具备完全的服务端能力,只需要找到库和调用库的nodejs包,实现就很简单了,不过简单查找了一下,笔者发现业界还是挺有这方面的探索的,首当其冲的就是google的开源项目tesseract-ocr,但是经过笔者的测试发现,tesseract对于印刷字的识别能力确实不错,但是笔者的项目更接近去年支付宝扫“福”字的场景,在自然光和手写字的各种干扰下,ocr整个一套方案的可行性就有待商榷了,而更让笔者吃惊的是,识别的耗时也是笔者的业务场景所不太能接受的,基于种种原因,笔者不得不放弃在OCR上的继续探索,转而寻找其他方案,笔者心想:既然识别文字做不到,那么如果是把文字抽象为图形,只是和图形做比较呢?抱着这样的思想,笔者开始将目光转向一些图片相似度比较的库,此时opencv就进入了笔者的视野,也适逢opencv在人脸识别上备受青睐,也坚定了笔者使用opencv这一方案的信心。

  方向定了之后,实施起来就快了。本应该是如此,但是笔者虽然找到了opencv binding nodejs的包,不过这个包的作者写的readme适合并不是面向笔者这个场景的,里面的api和demo也倾向于在表达opencv在人脸识别上的能力,而不是笔者所需要的图片相似度比较的能力,经过一些抹黑的探索之后,笔者在node-opencv库里找到了一个由作者实现了但并没有记录在文档中的api——ImageSimilarity,看到这个名字的时候,笔者好似终于在茫茫的C++黑夜里找到了一丝光明一般,果不其然,这个api果然是进行图片的相似度比较的,最终代码很简单:

var similarityResult = yield new Promise((resolve, reject) => {
        opencv.ImageSimilarity(matrixSource, matrixTarget, function(err, dissimilarity){
            resolve({dissimilarity:dissimilarity});
        });    
    });

  经过一些简单的nodejs webservice开发之后,整个业务需求的服务就告一段落了,不过正当笔者准备测试上线的时候,却又迎来了新的问题。笔者本地使用的是mac os作为开发环境,而服务器使用的都是linux centos的操作系统,在安装opencv上也有很多不同的地方,而且还需要考虑到上线后服务器扩容的问题,在线上服务器上一台台安装环境笔者心想可不是件容易事,经过自己的整理和运维同事的沟通,最后通过rpm包的方式解决了该问题。

  想想虽然一波三折,不过事情总算是做完了的时候,笔者突然回想起了之前ocr性能堪忧的问题,于是多了个心眼,想压测下opencv的处理性能,毕竟就笔者所了解的图形学的知识来说,图形处理其实是很消耗cpu的,这和笔者之前单纯使用nodejs作为restful的场景毕竟不同,虽然本地有100ms以下的不错表现(虽然和正常的node service差距已经很大),但考虑到实际还有目标图片传输、读写、比较过程,于是笔者拿出jmeter进行了一次压力测试,结果果然令人大吃一惊:并发的请求数稍大就直接占满了CPU,而笔者所面对的业务场景的目标DAU有1200w,这种性能肯定是撑不住的!

  怎么办!即使有能力进行图片识别,但是图片识别的并发量不足依然不能满足笔者的业务需求,经过笔者的一些观察发现,最核心的耗时发生在opencv的ImageSimilarity这个方法的执行上,但是这个api对于笔者而言几乎接近于黑盒,笔者只是善意的第三调用方,而且node-opencv也只是作为对c++底层库的中间层,底层实现完全依赖c++,对笔者而言,基本短时间内就不存在了优化的余地,于是笔者只能从其他方面曲线救国了:首先,为了增加硬实力,笔者不得不才用扩容的方式,从物理上提高图片识别服务的并发处理能力;然后,因为物理的扩容并不能够解决高峰期瞬时流量过大的问题,笔者不得不采取“非常手段”,保证服务不至于崩溃,于是笔者做好容灾预案了。不过笔者还是纠结于使用怎样的容灾实现比较好:要么是显示每秒的总请求数,将会导致过载的请求直接降级处理,要么就是实施监控cpu的使用率,保证cpu正常的情况下做降级处理。两种方案取舍的关键在于哪一种能够真正反应服务器的负载状况,基于种种考虑,笔者最终选择了数量的方案(由于nodejs获取的cpu数据总是滞后,当然这只是笔者了解的情况)。最终代码如下:

    let processResult = yield new Promise((resolve, reject) => {
        limiter.removeTokens(1, (err, remainingRequests) => {
            if (remainingRequests <= 0) {
                resolve(co(disasterRecoveryBranch.bind(this)));
            } else {
                resolve(co(normalBranch.bind(this)));
            }
        })
    }); 

  到此,整个需求才算是真正告一段落。回过头来我们重新review下整个业务流程:

1、native 上传图片,调用node服务

2、nodejs 接受请求

3、将各种参数从请求体中读出(当然包括上传的图片)

4、将上传图片转换为待比较的格式,并且取出比较的源图片(当然你也可以在一开始就把它们取出来放内存里,不过由于笔者的源图片可以动态变更,所以这里的额外io并没有省)

5、调用ImageSimilarity方法,比较目标图片和源图片(容灾情况则直接会执行降级方案,然后继续往下进行)

6、通过得出的比较值,调用对应的后端服务

7、拿到后端的响应结果,经过封装后回传回客户端

  其实其中还有不少可以优化的点,最重要的是,opencv本身由于对于笔者其实是个黑匣子,这也一定程度上导致了最后的服务可用性的问题的出现。不过虽然话需要怎么说,但是反过来,笔者也庆幸能生活于这个互联网时代,能够享受如此多的时代文明之光的馈赠,更深觉还有好多好多这样早已出现,却还不曾发光的事物等待我们去发现,去链接,去让那些埋藏于浮华深处的光芒,重新发出耀眼的光辉。