专栏首页AI研习社如何使用 OpenCV 编写基于 Node.js 命令行界面和神经网络模型的图像分类

如何使用 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创建项目时不得不忍受的挣扎。)

  1. 如何使用git-lfs(Git大文件系统)上传大文件到GitHub项目中。
  2. 如何创建一个Node CLI(命令行接口)。
  3. 如何使用深度神经网络进行图像分类。

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

背景故事

在我们开始之前,了解一下这些是如何发生的。在我工作的地方,我们使用内置摄像头来做分析(比如检测油或者气体泄露)。当发生警报时,从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: '{underline imagePath}',
        description: '[required] The image path.'
      },
      {
        name: 'confidence',
        typeLabel: '{underline value}',
        description: '[optional; default 50] The minimum confidence level to use for classification (ex: 50 for 50%).'
      },
      {
        name: 'filter',
        typeLabel: '{underline filterFile}',
        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 true if the passed in object is empty * @param {Object} obj 
 */
const isEmptyObject = (obj) => {
  return JSON.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' }
]let optionstry {
  options = commandLineArgs(optionDefinitions)
}catch(e) {
  console.error()
  console.error('classify:', e.name, e.optionName)
  console.log(usage)
  process.exit(1)
}

// check for helpif (isEmptyObject(options) || 'help' in options) {
  console.log(usage)
  process.exit(1)
}

// check for versionif ('version' in options) {
  let pkg = require('./package.json')
  console.log(pkg.version)
  process.exit(1)
}let imagePath
// check for pathif ('image' in options) {
  imagePath = options.image
}if (!imagePath) {
  console.error('"--image imagePath" is required.')
  process.exit(1)
}if (!fs.existsSync(imagePath)) {
  console.log(`exiting: could not find image: ${imagePath}`)
  process.exit(2)
}let confidence = 50 // defaultif ('confidence' in options) {
  confidence = options.confidence
}

// validate confidenceif (confidence < 0) {
  console.error(`Negative numbers are not valid for 'confidence'.`)
  process.exit(1)
}if (confidence > 100) {
  console.error(`A value greater than 100 is not valid for 'confidence'.`)
  process.exit(1)
}

confidence = confidence / 100.0let filterItems = []if ('filter' in options) {
  const filterFile = options.filter  // verify file exist
  if (!fs.existsSync(filterFile)) {
    console.log(`exiting: could not find filter file: ${filterFile}`)
    process.exit(2)
  }
  filterItems = fs.readFileSync(filterFile).toString().split('\n')
}

// get quick option, if available - default to slowlet quick = falseif ('quick' in options) {
  quick = true
}

// get data file based on model and quick optionslet dataFileif (model === 'coco') {
  if (quick) {
    dataFile = 'coco300'  }
  else {
    dataFile = 'coco512'  }
}else if (model === 'inception') {
  dataFile = 'inception224'}if (!dataFile) {
  console.error(`'${model}' is not valid model.`)
  process.exit(1)
}

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

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

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

使用OpenCV来做图像分类

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

// OpenCV
const cv = require('opencv4nodejs')

// initialize model from prototxt and modelFile
let net
if (dataFile === 'coco300' || dataFile === 'coco512') {
  net = cv.readNetFromCaffe(prototxt, modelFile)
}

// read the image
const img = cv.imread(imagePath)

// starting time of classification
let start = new Date()

// get predictions
const predictions = predict(img).filter((item) => {
  // filter out what we don't want
  if (item.confidence < confidence) {
    return false
  }
  // user wants to filter items
  if (filterItems.length > 0) {
    if (filterItems.indexOf(classes[item.classIndex]) < 0) {
      return false
    }
  }
  return true
})

// end of classification
let end = new Date()
finalize(start, end)

// write updated image with new name
updateImage(imagePath, img, predictions)

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

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

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

/**
 * Predicts classifications based on passed in image
 * @param {Object} img The image to use for predictions
 */
const predict = (img) => {
  // white is the better padding color
  const white = new cv.Vec(255, 255, 255)

  // resize to model size
  const theImage = img.resizeToMax(modelData.size, modelData.size).padToSquare(white)

  // network accepts blobs as input
  const inputBlob = cv.blobFromImage(theImage)
  net.setInput(inputBlob)

  // forward pass input through entire network, will return
  // classification result as (coco: 1x1xNxM Mat) (inception: 1xN Mat)
  let outputBlob = 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
    return extractResultsCoco(outputBlob, img)
  }
}

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

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

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

// initialize model from prototxt and modelFile
let net
if (dataFile === 'coco300' || dataFile === 'coco512') {
  net = cv.readNetFromCaffe(prototxt, modelFile)
}

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

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

/**
 * Extracts results from a network OutputBob
 * @param {Object} outputBlob The outputBlob returned from net.forward()
 * @param {Object} img The image used for classification
 */
const extractResultsCoco = (outputBlob, img) => {
  return Array(outputBlob.rows).fill(0)
    .map((res, i) => {
      // get class index
      const classIndex = outputBlob.at(i, 1);
      const confidence = outputBlob.at(i, 2);
      // output blobs are in a percentage
      const bottomLeft = new cv.Point(
        outputBlob.at(i, 3) * img.cols,
        outputBlob.at(i, 6) * img.rows
      );
      const topRight = new cv.Point(
        outputBlob.at(i, 5) * img.cols,
        outputBlob.at(i, 4) * img.rows
      );
      // create a rect
      const rect = new cv.Rect(
        bottomLeft.x,
        topRight.y,
        topRight.x - bottomLeft.x,
        bottomLeft.y - topRight.y
      );

      return ({
        classIndex,
        confidence,
        rect
      })
    })
}

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

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

// write updated image with new name
updateImage(imagePath, img, predictions)

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

/**
 * Generate a random color
 */
const getRandomColor = () => new cv.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 {Arry} predictions Array of predictions
 */
const makeDrawClassDetections = (predictions) => (drawImg, getColor, thickness = 2) => {
  predictions
    .forEach((p) => {
      let color = getColor()
      let confidence = p.confidence
      let rect = p.rect
      let className = classes[p.classIndex]
      drawRect(className, confidence, drawImg, rect, color, { thickness })
    })
  return drawImg
}

/*
  Take the original image and add rectanges on predictions.
  Write it to a new file.
 */
const updateImage = (imagePath, img, predictions) => {
  // get the filename and replace last occurrence of '.' with '_classified.'
  const filename = imagePath.replace(/^.*[\\\/]/, '').replace(/.([^.]*)$/,`_classified_${dataFile}_${confidence * 100.0}.` + '$1')

  // get function to draw rect around predicted object
  const drawClassDetections = 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 {String} className Predicted class name (identified object)
 * @param {Number} confidence The confidence level (ie: .80 = 80%)
 * @param {Object} image The image
 * @param {Object} rect The rect area
 * @param {Object} color The color to use
 * @param {Object} [opts={ thickness: 2 }] Options (currently only supports thikness)
 */
const drawRect = (className, confidence, image, rect, color, opts = { thickness: 2 }) => {
  let level = Math.round(confidence * 100.0)
  image.drawRectangle(
    rect,
    color,
    opts.thickness,
    cv.LINE_8
  )
  // draw the label (className and confidence level)
  let label = className + ': ' + level
  image.putText(label, new cv.Point2(rect.x, rect.y + 20), cv.FONT_ITALIC, .65, color, 2)

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

缺点

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

没有置信过滤的分类结果

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

案例

没有分类的火车

分类的火车

未分类的皇室

分类的皇室

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

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

本文分享自微信公众号 - AI研习社(okweiwu)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2018-11-26

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 数据科学家需要了解的 5 种采样方法

    采样问题是数据科学中的常见问题,对此,WalmartLabs 的数据科学家 Rahul Agarwal 分享了数据科学家需要了解的 5 种采样方法,AI 开发者...

    AI研习社
  • 用PyTorch做物体检测和追踪

    在我之前的工作中,我尝试过用自己的图像在PyTorch中训练一个图像分类器,然后用它来进行图像识别。现在,我将向你们展示如何使用预训练的分类器在一张图像中检测多...

    AI研习社
  • CV 新手避坑指南:计算机视觉常见的8个错误

    人类并不是完美的,我们经常在编写软件的时候犯错误。有时这些错误很容易找到:你的代码根本不工作,你的应用程序会崩溃。但有些 bug 是隐藏的,很难发现,这使它们更...

    AI研习社
  • 创建对象时If语句该放哪?

    看到我没有? 在那个validate函数中, if ...... else if ,那就是我。

    chenchenchen
  • python中的条件判断语句

    在python中使用条件判断语句一定不要忘记if else elif后面的冒号:哦

    py3study
  • 教你一招用 IDE 编程提升效率的骚操作!

    IDEA 有个很牛逼的功能,那就是后缀补全(不是自动补全),很多人竟然不知道这个操作,还在手动敲代码。

    良月柒
  • 教你一些IDE中比较骚的操作技巧!

    IDEA 有个很牛逼的功能,那就是后缀补全(不是自动补全),很多人竟然不知道这个操作,还在手动敲代码。

    Java团长
  • 整理了一些 IDEA 中比较骚的技巧

    IDEA 有个很牛逼的功能,那就是后缀补全(不是自动补全),很多人竟然不知道这个操作,还在手动敲代码。

    一个优秀的废人
  • 用了 IDEA 这么久,你知道有哪些比较骚的技巧?

    IDEA 有个很牛逼的功能,那就是后缀补全(不是自动补全),很多人竟然不知道这个操作,还在手动敲代码。

    一个优秀的废人
  • 用了这么久 IDEA ,你竟然不知道有个功能叫自动补全!

    IDEA 有个很牛逼的功能,那就是后缀补全(不是自动补全),很多人竟然不知道这个操作,还在手动敲代码。

    Java技术江湖

扫码关注云+社区

领取腾讯云代金券