如何使用 OpenCV 编写基于 Node.js 命令行界面和神经网络模型的图像分类

本文为 AI 研习社编译的技术博客,原标题 :

How to Write a Node.js CLI using OpenCV with Neural Network Models for Image Classification

作者 |Jeff Galbraith

翻译 | 苏珊娜•本森、康奈尔•斯摩

校对 | 酱番梨 整理 | 菠萝妹

https://itnext.io/how-to-write-a-node-js-cli-using-opencv-with-neural-network-models-for-image-classification-57785d6f09fe

如何使用 OpenCV 编写基于 Node.js 命令行界面和神经网络模型的图像分类

使用SDD Coco Model 对图像进行分类(没错,这是我的皮卡。)

在这篇文章中我们将学习三件事情(这些是我在Github创建项目时不得不忍受的挣扎。)

如何使用git-lfs(Git大文件系统)上传大文件到GitHub项目中。

如何创建一个Node CLI(命令行接口)。

如何使用深度神经网络进行图像分类。

我对每件事情都创建了一个章节,因此你可以阅读所有或者直接阅读你感兴趣的部分。

背景故事

在我们开始之前,了解一下这些是如何发生的。在我工作的地方,我们使用内置摄像头来做分析(比如检测油或者气体泄露)。当发生警报时,从MOEG流中获取当时的照片。我的团队另一个项目是使用Python程序对这些照片进行分类。我很好奇是否可以用Node做同样的事情。在这之前我从未使用过神经网络,因此这对我来说是具有挑战性的。我开始用tensflow.js,但是我需要tfjs-node包将我们现有的模型转换成一个“web-friendly”模型。

然后我在Medium发现一篇由Vincent Mühler写的很棒的文章叫做“Node.js meets OpenCV’s Deep Neural Networks — Fun with Tensorflow and Caffe”。这篇文章通过node包: opencv4nodejs向我介绍了OpenCV。从这件事情我开始工作并且取得了较好的结果。

在我将所有的包以及readme文件放在一起之后,我开始在Github上开始我的项目,但是模型文件太大了!然后我开始学习git-lfs(Git大文件系统)。几天的挣扎后(那个时候在有限的宽带下--我在露营),我搞明白了。然后npm(标志)问题来了,我试图发布npm,但是在包装后,上传注册表失败因为“javascript heap out of memory”,再次是因为所有的包再放一起太大了!

我仍然没有获得npm注册表。我需要探索不同的方式。如果你已经解决了大文件包的问题,请随时告诉我你是怎么做到的。

Github 和 超大文件

首先,Github是有容量限制的。从他们的官方文档来看,“我们所能存储的文件大小必须小于100MB”。因此,如果模型大于这个大小,则一定不能运行。

输入 git-lfs。这个参数让你在 git 或者 Github 来追踪超大文件。尽管一开始免费的,超过一定限制 Github 也会开始收费。同时,Gitkeaken 也支持 git-lfs —— 赞!

GitKraken 对 LFS 的支持

你注意到文件最后的 LFS 了吗?赞。

好的,所以它并不总是尽如人意。首先你把超大文件放到你的目录下,你必须初始化 git-lfs 并告诉它你的项目需要追踪什么类型的文件。点击阅读详情。

创建一个有 CLI 的结点

我确定你听说过 CLI —— 命令行界面。它让用户通过计算机程序来与电脑交互。通过创建一个 CLI 结点,你的结点库就会向原生的电脑程序那样来运行。

比如说,运行一个叫“classify”的结点库,你通常需要如下操作(在 classify 的文件夹中):

node index.js [arguments]

你可以把库全局安装到结点系统,它会把库增加到路径。如下命令来安装(在 classify 文件夹中):

npm install -g . classify

该语句在当前文件夹中使用“classify”来安装了这个库。

现在你可以从命令行来执行下述语句:

classify --image

--filter ./filter.txt --confidence 50

CLI 输出

所有的 CLI 都有输出因此用户可以理解如何如何来使用它。在下面这个案例中,“classify”是这样的:

当然,库可以帮助你能来了解它的功能。我这里使用command-line-usage和command-line-args来了解每个库的功能。

但是,在我们做这个之前,我们先来看看电脑是如何不在命令行里面定义一个Node,却能够通过结点来运行一个JavaScript文件的?

这都归功于Linux系统中所有脚本的第一行。这行代码帮助脚本编译器来使用she-bang解译:

该代码告诉系统使用“node”作为该脚本的编译器,因此当你需要使用一个 CLI,它应该永远位于你的 JavaScript 文件中的顶部。

命令行使用

命令行的使用非常简单,它定义了用户看到的样式。

命令行如下:

const commandLineUsage = require('command-line-usage')

const sections = [

{

header:'classify',

content:'Classifies an image using machine learning from passed in image path.'

},

{

header:'Options',

optionList:[

{

name:'image',

typeLabel:'',

description:'[required] The image path.'

},

{

name:'confidence',

typeLabel:'',

description:'[optional; default 50] The minimum confidence level to use for classification (ex: 50 for 50%).'

},

{

name:'filter',

typeLabel:'',

description:'[optional] A filter file used to filter out classification not wanted.'

},

{

name:'quick',

description:'[optional; default slow] Use quick classification, but may be more inaccurate.'

},

{

name:'version',

description:'Application version.'

},

{

name:'help',

description:'Print this usage guide.'

}

]

}

]

const usage = commandLineUsage(sections)

然后,要输出结果,代码如下所示:console.log(usage)

命令行-ARGS

再一次地,相当容易使用。只需确保您处理并验证所有内容:

const fs = require('fs')

const path = require('path')

const commandLineArgs = require('command-line-args')

/**

* Returns trueifthe passed in objectisempty* @param obj

*/

const isEmptyObject = (obj) => {

returnJSON.stringify(obj) === JSON.stringify({})

}

const optionDefinitions = [

{ name:'image', alias:'i',type: String },

{ name:'confidence', alias:'c',type: Number },

{ name:'filter', alias:'f',type: String },

{ name:'quick', alias:'q'},

{ name:'version', alias:'v'},

{ name:'help', alias:'h'}

]letoptionstry {

options= commandLineArgs(optionDefinitions)

}catch(e) {

console.error()

console.error('classify:',e.name,e.optionName)

console.log(usage)

process.exit(1)

}

// checkforhelpif (isEmptyObject(options) ||'help'inoptions) {

console.log(usage)

process.exit(1)

}

// checkforversionif ('version'inoptions) {

letpkg = require('./package.json')

console.log(pkg.version)

process.exit(1)

}letimagePath

// checkforpathif ('image'inoptions) {

imagePath =options.image

}if(!imagePath) {

console.error('"--image imagePath" is required.')

process.exit(1)

}if(!fs.existsSync(imagePath)) {

console.log(`exiting:could notfindimage: $`)

process.exit(2)

}letconfidence =50// defaultif ('confidence'inoptions) {

confidence =options.confidence

}

// validate confidenceif (confidence

console.error(`Negative numbers are not validfor'confidence'.`)

process.exit(1)

}if(confidence >100) {

console.error(`A value greater than100isnot validfor'confidence'.`)

process.exit(1)

}

confidence = confidence /100.0letfilterItems = []if('filter'inoptions) {

const filterFile =options.filter// verifyfileexist

if(!fs.existsSync(filterFile)) {

console.log(`exiting:could notfindfilterfile: $`)

process.exit(2)

}

filterItems = fs.readFileSync(filterFile).toString().split('\n')

}

//getquick option,ifavailable - defaulttoslowlet quick = falseif ('quick'inoptions) {

quick = true

}

//getdatafilebasedonmodelandquick optionslet dataFileif (model ==='coco') {

if(quick) {

dataFile ='coco300'}

else{

dataFile ='coco512'}

}elseif(model ==='inception') {

dataFile ='inception224'}if(!dataFile) {

console.error(`'$'isnot valid model.`)

process.exit(1)

}

你会注意到的 --version 命令,让我们接下来要做的就是——在package.json中读取并输出版本。这样,我们只需要将它保存在一个地方。

剩下的处理是检查是否使用了一个选项,如果是,则验证它,等等。

一旦我们收集了分类处理所需的所有数据,我们就可以开始分类了。

使用OpenCV来做图像分类

现在我们已经收集并验证了从用户与CLI交互中收集的参数,真正的乐趣就可以开始了。高级处理并不像您想象的那么困难。

// OpenCV

constcv =require('opencv4nodejs')

// initialize model from prototxt and modelFile

letnet

if(dataFile ==='coco300'|| dataFile ==='coco512') {

net = cv.readNetFromCaffe(prototxt, modelFile)

}

// read the image

constimg = cv.imread(imagePath)

// starting time of classification

letstart =newDate()

// get predictions

constpredictions = predict(img).filter((item) =>{

// filter out what we don't want

if(item.confidence

returnfalse

}

// user wants to filter items

if(filterItems.length >) {

if(filterItems.indexOf(classes[item.classIndex])

returnfalse

}

}

returntrue

})

// end of classification

letend =newDate()

finalize(start, end)

// write updated image with new name

updateImage(imagePath, img, predictions)

结果不是很坏啦。你需要知道,这是我们使用了一些实际用户的数据来信任或过滤文件的。

但是,这其中大部分的工作量是预测功能,用来返回预测值。并且,这也需要许多数据抓取功能来支持这个预测功能。

让我们看看这个预测是如何实现的:

/**

* Predicts classifications based on passed in image

* @param img The image to use for predictions

*/

constpredict =(img) =>{

// white is the better padding color

constwhite =newcv.Vec(255,255,255)

// resize to model size

consttheImage = img.resizeToMax(modelData.size, modelData.size).padToSquare(white)

// network accepts blobs as input

constinputBlob = cv.blobFromImage(theImage)

net.setInput(inputBlob)

// forward pass input through entire network, will return

// classification result as (coco: 1x1xNxM Mat) (inception: 1xN Mat)

letoutputBlob = net.forward()

if(dataFile ==='coco300'|| dataFile ==='coco512') {

// extract NxM Mat from 1x1xNxM Mat

outputBlob = outputBlob.flattenFloat(outputBlob.sizes[2], outputBlob.sizes[3])

// pass original image

returnextractResultsCoco(outputBlob, img)

}

}

首先,这些模型都是训练好的。一个是300x300,另一个是512x512。300x300的这个模型会快一些,它需要的数据也较少。512x512的模型相对慢一点,但是它总体的预测精度更高,因此它需要更多数据。

上面的代码还有一个功能是对输入图片进行重采样,使它的尺寸能够满足模型训练图片的要求。如果原始图片不是矩形,我们需要把它填充至矩形。填充时通常使用白色,因为白色相对比黑色对原图的影响要小。

然后,图片会被转换成一个“blob”,并传入“net.setInput”。务必记得,我们之前有过这个代码:

// initialize model from prototxtandmodelFile

letnet

if(dataFile ==='coco300'|| dataFile ==='coco512') {

net = cv.readNetFromCaffe(prototxt, modelFile)

}

我们再展示一下,防止你忘了(当然,这是一个全局的定义,因此也会被传入模型)。

所以,现在你主导上述功能中的最后一个步骤是获取结果:

/**

* Extracts results from a network OutputBob

*@paramoutputBlob The outputBlob returned from net.forward()

*@paramimg The image used for classification

*/

constextractResultsCoco = (outputBlob, img) => {

returnArray(outputBlob.rows).fill()

.map((res, i) => {

// get class index

constclassIndex = outputBlob.at(i,1);

constconfidence = outputBlob.at(i,2);

// output blobs are in a percentage

constbottomLeft =newcv.Point(

outputBlob.at(i,3) * img.cols,

outputBlob.at(i,6) * img.rows

);

consttopRight =newcv.Point(

outputBlob.at(i,5) * img.cols,

outputBlob.at(i,4) * img.rows

);

// create a rect

constrect =newcv.Rect(

bottomLeft.x,

topRight.y,

topRight.x - bottomLeft.x,

bottomLeft.y - topRight.y

);

return({

classIndex,

confidence,

rect

})

})

}

这就是你能够读取到的“index”(这将与分类结果对应),分类的“置信”水平,识别对象的“锚系”方位。这些就是我们的“预测”,随后我们来过滤结果。

还记得你在上面也看到过下面这个代码吗:

//writeupdated image withnewname

updateImage(imagePath, img, predictions)

该代码将过滤后的结果作为新的图像写入文件,这样用户就可以看到预测结果以及它的“置信”水平。下面展示了多种方法来重现结果:

/**

* Generate a random color

*/

constgetRandomColor =()=>newcv.Vec(Math.random() *255,Math.random() *255,Math.random() *255);

/**

* Returns a function that, for each prediction, draws a rect area with rndom color

* @param predictions Array of predictions

*/

constmakeDrawClassDetections =(predictions) =>(drawImg, getColor, thickness =2) => {

predictions

.forEach((p) =>{

letcolor = getColor()

letconfidence = p.confidence

letrect = p.rect

letclassName = classes[p.classIndex]

drawRect(className, confidence, drawImg, rect, color, { thickness })

})

returndrawImg

}

/*

Take the original image and add rectanges on predictions.

Write it to a new file.

*/

constupdateImage =(imagePath, img, predictions) =>{

// get the filename and replace last occurrence of '.' with '_classified.'

constfilename = imagePath.replace(/^.*[\\\/]/,'').replace(/.([^.]*)$/,`_classified_$_$.`+'$1')

// get function to draw rect around predicted object

constdrawClassDetections = makeDrawClassDetections(predictions);

// draw a rect around predicted object

drawClassDetections(img, getRandomColor);

// write updated image to current directory

cv.imwrite('./'+ filename, img)

}

// draw a rect and label in specified area

/**

*

* @param className Predicted class name (identified object)

* @param confidence The confidence level (ie: .80 = 80%)

* @param image The image

* @param rect The rect area

* @param color The color to use

* @param [opts={ thickness: 2 }] Options (currently only supports thikness)

*/

constdrawRect =(className, confidence, image, rect, color, opts = { thickness: 2 }) =>{

letlevel =Math.round(confidence *100.0)

image.drawRectangle(

rect,

color,

opts.thickness,

cv.LINE_8

)

// draw the label (className and confidence level)

letlabel = className +': '+ level

image.putText(label,newcv.Point2(rect.x, rect.y +20), cv.FONT_ITALIC,.65, color,2)

我不会详细来解释这段代码,因为他们还是比较常见的JavaScript代码(而且文中注释也写得很好)。

缺点

你应该使用一些过滤器,通常是基于置信水平的过滤器。我通常会使用50作为阈值来过滤,但是有时候也会降低到30。你想知道为什么?因为这是我们有时会碰到的情况:

没有置信过滤的分类结果

如果图像中的物体过于“繁重”,你会得到许多分类结果。这其中的大部分是假的。大部分的置信水平低于10。你可以试试调整过滤置信水平的阈值,来看看哪个值的效果最好。请记得,这是和本文的第一个图片一样的那张图哦(哈哈,我是不是让你回看文章的开头了?)

案例

没有分类的火车

分类的火车

未分类的皇室

分类的皇室

Harry,露齿呀!这样你就是一个100%的置信的人了。哈哈,不开玩笑了,这还是很有趣的!我依然还在学习中。并且还有很多可以学的。我希望我写的内容可以帮助到你的学习,希望你也这么觉得。

你可以在GitHub里找到完整的项目。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20181126A1JU6E00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券

年度创作总结 领取年终奖励