OpenCV-实践指南-全-

OpenCV 实践指南(全)

原文:Practical OpenCV

协议:CC BY-NC-SA 4.0

一、计算机视觉和 OpenCV 简介

Abstract

当我们醒着的时候,我们从这个世界获得的信息中有很大一部分是通过视觉获得的。我们的眼睛做了一件奇妙的工作,不停地旋转,根据需要改变焦点来看东西。我们的大脑在处理来自双眼的信息流、创建我们周围世界的 3D 地图以及让我们意识到自己在这张地图中的位置和方向方面做得更好。如果机器人(以及一般的计算机)能像我们一样看到并理解他们看到的东西,那不是很酷吗?

当我们醒着的时候,我们从这个世界获得的信息中有很大一部分是通过视觉获得的。我们的眼睛做了一件奇妙的工作,不停地旋转,根据需要改变焦点来看东西。我们的大脑在处理来自双眼的信息流、创建我们周围世界的 3D 地图以及让我们意识到自己在这张地图中的位置和方向方面做得更好。如果机器人(以及一般的计算机)能像我们一样看到并理解他们看到的东西,那不是很酷吗?

对于机器人来说,视觉本身不是什么问题——各种各样的相机都有,而且非常容易使用。然而,对于一台连接有摄像头的计算机来说,摄像头馈送从技术上来说只是一组随时间变化的数字。

进入计算机视觉。

计算机视觉就是让机器人足够聪明,能够根据它们看到的东西做出决定。

为什么写这本书?

在我看来,今天的机器人就像 35 年前的个人电脑——一项新兴技术,有可能彻底改变我们的日常生活方式。如果有人带你提前 35 年,不要惊讶地看到机器人在街上漫步,在建筑物内工作,在许多日常任务中帮助人类并与人类安全合作。如果你在工业和医院里看到机器人,轻松地执行最复杂和要求最精确的任务,也不要感到惊讶。你猜对了,要做到这一切,他们需要高效、智能、鲁棒的视觉系统。

计算机视觉可能是当今机器人领域最热门的研究领域。世界各地有很多聪明人试图设计算法并实现它们,让机器人有能力智能和正确地解释他们看到的东西。如果你也想在这个研究领域有所贡献,这本书是你的第一步。

在这本书里,我打算通过一系列越来越复杂的项目,在计算机视觉研究的一些最重要的领域里,教你一些基本的概念,以及一些稍微高级的概念。从让计算机识别颜色这样简单的事情开始,我将带领你经历一段旅程,甚至教你如何让机器人根据其摄像头馈送中的对象如何移动来估计其速度和方向。

我们将在一个名为 OpenCV 的编程库(粗略地说,是一组可以执行相关高级任务的预写函数)的帮助下实现我们所有的项目。

这本书将让您熟悉 OpenCV 通过其内置函数提供的算法实现、算法的理论细节以及使用 OpenCV 时通常采用的 C++编程原理。在本书的结尾,我们还将讨论几个项目,在这些项目中,我们将 OpenCV 的框架用于我们自己设计的算法。将假设对 C++编程的熟悉程度适中。

开放计算机视觉

开源计算机视觉。org 是计算机视觉的瑞士军刀。它有各种各样的模块,可以帮助你解决很多计算机视觉问题。但是 OpenCV 最有用的部分可能是它的架构和内存管理。它为您提供了一个框架,您可以使用 OpenCV 的算法或您自己的算法,以任何方式处理图像和视频,而不必担心为图像分配和取消分配内存。

OpenCV 的历史

探究一下 OpenCV 为什么以及如何被创建是很有趣的。OpenCV 作为英特尔研究院的一个研究项目正式启动,旨在推进 CPU 密集型应用中的技术。该项目的许多主要贡献者包括英特尔俄罗斯研究院和英特尔性能库团队的成员。该项目的目标如下:

  • 通过为基础视觉基础设施提供开放且优化的代码,推进视觉研究。(不再多此一举!)
  • 通过提供一个开发人员可以构建的公共基础设施来传播视觉知识,这样代码将更易于阅读和转移。
  • 通过免费提供可移植的、性能优化的代码,推进基于视觉的商业应用——许可证不要求应用本身开放或免费。

OpenCV 的第一个 alpha 版本在 2000 年的 IEEE 计算机视觉和模式识别会议上向公众发布。目前,OpenCV 由一个名为OpenCV.org的非营利基金会所有。

内置模块

OpenCV 的内置模块功能强大,用途广泛,足以解决大多数计算机视觉问题,这些问题都有成熟的解决方案。您可以裁剪图像,通过修改亮度、锐度和对比度来增强图像,检测图像中的形状,将图像分割成直观明显的区域,检测视频中的移动物体,识别已知物体,根据摄像头馈送估计机器人的运动,以及使用立体摄像头获得世界的 3D 视图,这只是其中的几个应用。然而,如果你是一名研究人员,想要开发自己的计算机视觉算法,而这些模块本身并不完全足够,OpenCV 仍然会通过其架构、内存管理环境和 GPU 支持来帮助你。你会发现你自己的算法与 OpenCV 高度优化的模块协同工作确实是一个强有力的组合。

OpenCV 模块需要强调的一个方面是它们是高度优化的。它们旨在用于实时应用,旨在跨各种计算平台(从 MacBooks 到运行精简版 Linux 的小型嵌入式 fitPCs)快速执行。

OpenCV 为您提供了一组模块,可以大致执行表 1-1 中列出的功能。

表 1-1。

Built-in modules offered by OpenCV

| 组件 | 功能 | | --- | --- | | 核心 | 核心数据结构、数据类型和内存管理 | | 伊姆普洛克 | 图像过滤、几何图像变换、结构和形状分析 | | 海贵 | GUI,读取和写入图像和视频 | | 录像 | 视频中的运动分析和目标跟踪 | | calib | 摄像机标定和多视图三维重建 | | 功能 2d | 特征提取、描述和匹配 | | object detect(对象检测) | 使用级联和梯度直方图分类器的目标检测 | | 机器语言(Machine Language) | 用于计算机视觉应用的统计模型和分类算法 | | 弗兰恩 | 近似最近邻的快速库—在高维(特征)空间中的快速搜索 | | 国家政治保卫局。参见 OGPU | 并行化选定算法,以便在 GPU 上快速执行 | | 缝 | 用于图像拼接的扭曲、混合和束调整 | | 非免费 | 在某些国家获得专利的算法实现 |

在这本书里,我将介绍利用这些模块的项目。

摘要

我希望这一介绍性章节已经让你对这本书的内容有了一个大概的了解!我心目中的读者群包括对使用 C++知识编写快速计算机视觉应用感兴趣的学生,以及对学习许多最著名算法背后的基本理论感兴趣的学生。如果你已经知道这个理论,并且对学习 OpenCV 语法和编程方法感兴趣,这本书及其大量的代码示例也会对你有用。

下一章讨论在你的计算机上安装和设置 OpenCV,这样你就可以快速开始一些令人兴奋的项目了!

二、在计算机上设置 OpenCV

Abstract

现在你知道了计算机视觉对你的机器人有多重要,以及 OpenCV 如何帮助你实现很多,本章将指导你在你的计算机上安装 OpenCV 和设置开发工作站的过程。这也将允许你尝试和使用本书后续章节中描述的所有项目。官方的 OpenCV 安装 wiki 可以在 http://opencv.willowgarage.com/wiki/InstallGuide 获得,本章将主要在此基础上构建。

现在你知道了计算机视觉对你的机器人有多重要,以及 OpenCV 如何帮助你实现很多,本章将指导你在你的计算机上安装 OpenCV 和设置开发工作站的过程。这也将允许你尝试和使用本书后续章节中描述的所有项目。官方的 OpenCV 安装 wiki 可以在 http://opencv.willowgarage.com/wiki/InstallGuide 获得,本章将主要在此基础上构建。

操作系统

OpenCV 是一个独立于平台的库,因为它可以安装在几乎所有满足特定要求的操作系统和硬件配置上。然而,如果你有选择你的操作系统的自由,我会建议一个 Linux 版本,最好是 Ubuntu(最新的 LTS 版本是 12.04)。这是因为它是免费的,与 Windows 和 Mac OS X 一样好用(有时甚至更好),你可以将许多其他很酷的库与你的 OpenCV 项目集成在一起,如果你计划在嵌入式系统上工作,如 Beagleboard 或 Raspberry Pi,它将是你唯一的选择。

在这一章中,我将提供 Ubuntu、Windows 和 Mac OSX 的安装说明,但主要集中在 Ubuntu 上。后面章节中的项目本身是独立于平台的。

人的本质

http://sourceforge.net/projects/opencvlibrary/ 下载 OpenCV tarball 并解压到一个首选位置(后续步骤我称之为OPENCV_DIR)。您可以通过使用归档管理器或发出 tar–xvf 命令来提取,如果您对它感到满意的话。

简单安装

这意味着您将安装当前稳定的 OpenCV 版本,带有默认编译标志,并且只支持标准库。

If you don’t have the standard build tools, get them by

sudo apt-get install build-essential checkinstall cmake

Make a build directory in OPENCV_DIR and navigate to it by

mkdir build

cd build

Configure the OpenCV installation by

cmake ..

Compile the source code by

make

Finally, put the library files and header files in standard paths by

sudo make install

自定义安装(32 位)

这意味着您将安装许多支持库并配置 OpenCV 安装以考虑它们。我们将安装的额外库包括:

  • FFmpeg、gstreamer、x264 和 v4l,支持视频观看、录制、流式传输等
  • 如果您没有标准的构建工具,请使用

sudo apt-get install build-essential checkinstall cmake

Install gstreamer

sudo apt-get install libgstreamer0.10-0 libgstreamer0.10-dev gstreamer0.10-tools gstreamer0.10-plugins-base libgstreamer-plugins-base0.10-dev gstreamer0.10-plugins-good gstreamer0.10-plugins-ugly gstreamer0.10-plugins-bad gstreamer0.10-ffmpeg

Remove any installed versions of ffmpeg and x264

sudo apt-get remove ffmpeg x264 libx264-dev

Install dependencies for ffmpeg and x264

sudo apt-get update

sudo apt-get install git libfaac-dev libjack-jackd2-dev libmp3lame-dev libopencore-amrnb-dev libopencore-amrwb-dev libsdl1.2-dev libtheora-dev libva-dev libvdpau-dev libvorbis-dev libx11-dev libxfixes-dev libxvidcore-dev texi2html yasm zlib1g-dev libjpeg8 libjpeg8-dev

Get a recent stable snapshot of x264 from ftp://ftp.videolan.org/pub/videolan/x264/snapshots/ , extract it to a folder on your computer and navigate into it. Then configure, build, and install by

./configure –-enable-static

make

sudo make install

Get a recent stable snapshot of ffmpeg from http://ffmpeg.org/download.html , extract it to a folder on your computer and navigate into it. Then configure, build, and install by

./configure --enable-gpl --enable-libfaac --enable-libmp3lame –-enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libtheora --enable-libvorbis –-enable-libx264 --enable-libxvid --enable-nonfree --enable-postproc --enable-version3 –-enable-x11grab

make

sudo make install

Get a recent stable snapshot of v4l from http://www.linuxtv.org/downloads/v4l-utils/ , extract it to a folder on your computer and navigate into it. Then build and install by

make

sudo make install

Install cmake-curses-gui, a semi-graphical interface to CMake that will allow you to see and edit installation flags easily

sudo apt-get install cmake-curses-gui

Make a build directory in OPENCV_DIR by

mkdir build

cd build

Configure the OpenCV installation by

ccmake ..

Press ‘c’ to configure and ‘g’ to generate, and then build and install by

表 2-1。

Configuration flags for installing OpenCV with support for other common libraries

| 旗 | 价值 | | --- | --- | | 构建 _ 文档 | 安大略 | | 构建示例 | 安大略 | | 安装示例 | 安大略 | | WITH_GSTREAMER | 安大略 | | 使用 _JPEG | 安大略 | | 带 _PNG | 安大略 | | WITH_QT | 安大略 | | WITH_FFMPEG | 安大略 | | 带 _V4L | 安大略 |

A978-1-4302-6080-6_2_Fig1_HTML.jpg

图 2-1。

Configuration flags when you start installing OpenCV Press ‘c’ to start configuring. CMake-GUI should do its thing, discovering all the libraries you installed above, and present you with a screen showing the installation flags (Figure 2-1).   You can navigate among the flags by the up and down arrows, and change the value of a flag by pressing the Return key. Change the following flags to the values shown in Table 2-1.

make

sudo make install

Tell Ubuntu where to find the OpenCV shared libraries by editing the file opencv.conf (first time users might not have that file—in that case, create it)

sudo gedit /etc/ld.so.conf.d/opencv.conf

Add the line ‘/usr/local/lib’ (without quotes) to this file, save and close. Bring these changes into effect by

sudo ldconfig /etc/ld.so.conf

Similarly, edit /etc/bash.bashrc and add the following lines to the bottom of the file, save, and close:

PKG_CONFIG_PATH=$PKG_CONFIG_PATH:/usr/local/lib/pkgconfig

export PKG_CONFIG_PATH

重新启动计算机。

自定义安装(64 位)

如果你用的是 64 位版本的 Ubuntu,除了以下变化,这个过程基本上是一样的。

During the step 5 to configure x264, use this command instead:

./configure --enable-shared –-enable-pic

During the step 6 to configure ffmpeg, use this command instead:

./configure --enable-gpl --enable-libfaac --enable-libmp3lame –-enable-libopencore-amrnb –-enable-libopencore-amrwb --enable-libtheora --enable-libvorbis --enable-libx264 --enable-libxvid --enable-nonfree --enable-postproc --enable-version3 --enable-x11grab –-enable-shared –-enable-pic

检查安装

您可以通过将以下代码放入名为 hello_opencv.cpp 的文件中来检查安装。它会显示一个图像,并在您按下“q”时关闭窗口:

#include <iostream>

#include <opencv2/highgui/highgui.hpp>

using namespace std;

using namespace cv;

int main(int argc, char **argv)

{

Mat im = imread("image.jpg", CV_LOAD_IMAGE_COLOR);

namedWindow("Hello");

imshow("Hello", im);

cout << "Press 'q' to quit..." << endl;

while(1)

{

if(char(waitKey(1)) == 'q') break;

}

destroyAllWindows();

return 0;

}

Open up that directory in a Terminal and give the following command to compile the code:

g++ 'pkg-config opencv --cflags' hello_opencv.cpp -o hello_opencv 'pkg-config opencv --libs'

Run the compiled code by

./hello_opencv

注意,为了运行这个程序,在同一个目录中需要有一个名为“image.jpg”的图像。

没有超级用户权限的情况下安装

很多时候,您对正在使用的计算机没有超级用户访问权限。如果你告诉 Ubuntu 在哪里寻找库和头文件,你仍然可以安装和使用 OpenCV。事实上,这种使用 OpenCV 的方法比以前的方法更值得推荐,因为根据官方 OpenCV 安装 Wiki 页面,它不会用冲突版本的 OpenCV 文件“污染”系统目录。注意,安装额外的库,比如 Qt、Ffmpeg 等等,仍然需要超级用户权限。但是 OpenCV 在没有这些附件的情况下仍然可以工作。涉及的步骤有:

Download the OpenCV tarball and extract it to a directory where you have read/write rights. We shall call this directory OPENCV_DIR. Make the following directories in OPENCV_DIR

mkdir build

cd build

mkdir install-files

Configure your install as mentioned previously. Change the values of flags depending on which extra libraries you have installed in the system. Also, set the value of CMAKE_INSTALL_PREFIX to OPENCV_DIR/build/install-files.   Continue the same making process as the normal install, up to step 12. Then, run make install instead of sudo make install. This will put all the necessary OpenCV files in OPENCV_DIR/build/install-files.   Now, edit the file ∼/.bashrc (your local bashrc file over which you should have read/write access) and add the following lines to the end of the file, then save and close

export INCLUDE_PATH=<path-to-OPENCV_DIR>/build/install-files/include:$INCLUDE_PATH

export LD_LIBRARY_PATH=<path-to-OPENCV_DIR>/build/install-files/lib:$LD_LIBRARY_PATH

export PKG_CONFIG_PATH=<path-to-OPENCV_DIR>/build/install-files/lib/pkgconfig:$PKG_CONFIG_PATH

其中<path-to-OPENCV_DIR>例如可以是/home/user/libraries/opencv/.

Reboot your computer.   You can now compile and use OpenCV code as mentioned previously, like a normal install.

使用集成开发环境

如果您喜欢在 IDE 中工作而不是在终端中工作,那么您必须配置 IDE 项目来找到您的 OpenCV 库文件和头文件。对于广泛使用的 Code::Blocks IDE,在 http://opencv.willowgarage.com/wiki/CodeBlocks 可以找到非常好的指令,对于任何其他 IDE 来说,这些步骤应该都差不多。

Windows 操作系统

Windows 用户的安装说明可以在 http://opencv.willowgarage.com/wiki/InstallGuide 获得,并且运行得很好。与 MS Visual C++集成的说明可在 http://opencv.willowgarage.com/wiki/VisualC++ 获得。

mac os x

Mac OSX 用户可以按照 http://opencv.willowgarage.com/wiki/Mac_OS_X_OpenCV_Port 的指示在自己的电脑上安装 OpenCV。

摘要

所以你可以看到在 Linux 上安装软件比在 Windows 和 Mac OS X 上有趣多了!玩笑归玩笑,浏览整个过程会给初学者提供关于 Linux 内部工作和终端使用的有价值的见解。如果,甚至在按照指示做了之后,你在安装 OpenCV 的时候还有问题,谷歌你的错误。很有可能其他人也遇到过这个问题,并且他们已经在论坛上询问过这个问题。YouTube 上也有许多网站和详细的视频解释 Linux、Windows 和 Mac OS X 的安装过程。

三、CV Bling——OpenCV 内置演示

Abstract

现在你(希望)已经在电脑上安装了 OpenCV,是时候看看 OpenCV 能为你做什么的一些很酷的演示了。运行这些演示也将有助于确认 OpenCV 的正确安装。

现在你(希望)已经在电脑上安装了 OpenCV,是时候看看 OpenCV 能为你做什么的一些很酷的演示了。运行这些演示也将有助于确认 OpenCV 的正确安装。

OpenCV 附带了许多演示。它们以 C、C++和 Python 代码文件的形式存在于OPENCV_DIR内的samples文件夹中(安装时解压 OpenCV 档案的目录;具体参见第二章。如果您在配置您的安装时指定了标志BUILD_EXAMPLESON,那么编译后的可执行文件应该在OPENCV_DIR/build/bin中可用。如果您没有这样做,您可以在打开标志的情况下再次运行您的配置和安装,如第二章中所述。

让我们看看 OpenCV 提供的一些演示。请注意,您可以通过以下方式运行这些演示

./<demo_name> [options]

其中options是程序期望的一组命令行参数,通常是文件名。下面显示的演示已经在 OpenCV 附带的图像上运行,可以在OPENCV_DIR/samples/cpp中找到。

注意,下面提到的所有命令都是在导航到OPENCV_DIR/build/bin后执行的。

凸轮换档

Camshift 是一个简单的对象跟踪算法。它使用指定对象的亮度和颜色直方图在另一个图像中查找该对象的实例。OpenCV 演示首先要求您在相机馈送中的目标对象周围画一个框。它从该框的内容中生成所需的直方图,然后继续使用 camshift 算法来跟踪相机馈送中的对象。导航至OPENCV_DIR/build/bin运行演示,并执行以下操作

./cpp-example-camshiftdemo

A978-1-4302-6080-6_3_Fig3_HTML.jpg

图 3-3。

Camshift object tracking

A978-1-4302-6080-6_3_Fig2_HTML.jpg

图 3-2。

Camshift object tracking

A978-1-4302-6080-6_3_Fig1_HTML.jpg

图 3-1。

Camshift object tracking—specifying the object to be tracked

但是,camshift 总是试图找到对象的实例。如果对象不存在,它显示最近的匹配作为检测(见图 3-4 )。

A978-1-4302-6080-6_3_Fig4_HTML.jpg

图 3-4。

Camshift giving a false positive

立体匹配

stereo_matching演示展示了 OpenCV 的立体块匹配和视差计算能力。它将两幅图像(由左右立体摄像机拍摄)作为输入,并产生一幅视差为灰色编码的图像。我将在本书的后面用整整一章来讨论立体视觉,同时,简短地解释一下视差:当你使用两个相机(左和右)看到一个物体时,它在两个图像中的水平位置会略有不同,右帧中的物体相对于左帧的位置差异称为视差。视差可以给出关于对象深度的概念,即,它离相机的距离,因为视差与距离成反比。在输出图像中,视差较大的像素较亮。(回想一下,较高的视差意味着离摄像机的距离较小。)您可以通过以下方式在著名的筑波图片上运行演示

./cpp-example-stereo_match OPENCV_DIR/samples/cpp/tsukuba_l.png OPENCV_DIR/samples/cpp/tsukuba_r.png

其中OPENCV_DIR是到OPENCV_DIR的路径

A978-1-4302-6080-6_3_Fig5_HTML.jpg

图 3-5。

OpenCV stereo matching

视频中单应性估计

video_homography演示使用快速角点检测器检测图像中的兴趣点,并匹配关键点处评估的简短描述符。它对“参考”帧和任何其他帧这样做,以估计两个图像之间的单应变换。单应只是将点从一个平面转换到另一个平面的矩阵。在这个演示中,您可以从摄像机画面中选择您的参考帧。演示程序在参考帧和当前帧之间的单应变换方向上画线。你可以运行它

./cpp-example-video_homography 0

其中 0 是摄像机的设备 ID。0 通常意味着笔记本电脑的集成网络摄像头。

A978-1-4302-6080-6_3_Fig7_HTML.jpg

图 3-7。

Estimated homography shown by lines

A978-1-4302-6080-6_3_Fig6_HTML.jpg

图 3-6。

The reference frame for homography estimation, also showing FAST corners

圆和线检测

OpenCV 中的 houghcircles 和 houghlines 演示使用 Hough 变换分别检测给定图像中的圆和线。我将在第六章中对霍夫变换有更多的说明。现在,只要知道霍夫变换是一个非常有用的工具,它可以让你检测图像中的规则形状。您可以通过以下方式运行演示

./cpp-example-houghcircles OPENCV_DIR/samples/cpp/board.jpg

A978-1-4302-6080-6_3_Fig8_HTML.jpg

图 3-8。

Circle detection using Hough transform and

./cpp-example-houghlines OPENCV_DIR/samples/cpp/pic1.png

A978-1-4302-6080-6_3_Fig9_HTML.jpg

图 3-9。

Line detection using Hough transform

图象分割法

meanshift_segmentation演示实现了图像分割的 meanshift 算法(区分图像的不同“部分”)。它还允许您设置与算法相关的各种阈值。运行它

./cpp-example-meanshift_segmentation OPENCV_DIR/samples/cpp/tsukuba_l.png

A978-1-4302-6080-6_3_Fig10_HTML.jpg

图 3-10。

Image segmentation using the meanshift algorithm

如你所见,图像中的不同区域颜色不同。

包围盒和圆形

演示程序找到包围一组点的最小的矩形和圆形。在演示中,点是从图像区域内随机选择的。

./cpp-example-minarea

A978-1-4302-6080-6_3_Fig11_HTML.jpg

图 3-11。

Bounding box and circle

图像修复

图像修复是用周围的像素替换图像中的某些像素。它主要用于修复图像的损坏,如意外的笔触。OpenCV inpaint演示允许你通过在图像上做白色标记来破坏图像,然后运行修复算法来修复损坏。

./cpp-example-inpaint OPENCV_DIR/samples/cpp/fruits.jpg

A978-1-4302-6080-6_3_Fig12_HTML.jpg

图 3-12。

Image inpainting

摘要

本章的目的是让您对 OpenCV 的各种能力有所了解。还有很多其他的演示。请随意尝试,以获得更好的想法。一个特别著名的 OpenCV 演示是使用哈尔级联的人脸检测。主动的读者也可以浏览这些示例的源代码,可以在OPENCV_DIR/samples/cpp中找到。本书中的许多未来项目将利用这些样本中的代码片段和思想。

四、图像和 GUI 窗口的基本操作

Abstract

在这一章中,你将最终开始接触你自己编写的 OpenCV 代码。我们将从一些简单的任务开始。本章将教你如何:

在这一章中,你将最终开始接触你自己编写的 OpenCV 代码。我们将从一些简单的任务开始。本章将教你如何:

  • 在窗口中显示图像
  • 将您的图像从彩色转换为灰度
  • 创建 GUI 跟踪条并编写回调函数
  • 从图像中裁剪部分
  • 访问图像的单个像素
  • 阅读、显示和编写视频

我们开始吧!从本章开始,我将假设您知道如何编译和运行您的代码,您熟悉目录/路径管理,并且您将把程序需要的所有文件(例如,输入图像)放在与可执行文件相同的目录中。

我还建议你在 http://docs.opencv.org/ 广泛使用 OpenCV 文档。在本书中不可能讨论所有 OpenCV 函数的所有形式和用例。但是在文档页面上,关于所有 OpenCV 函数及其用法语法和参数类型的信息是以一种非常容易理解的方式组织起来的。所以每当你看到本书中介绍的新功能时,养成在文档中查找的习惯。您将熟悉使用该函数的各种方法,并且可能还会遇到几个相关的函数,这将增加您的技能。

在窗口中显示来自磁盘的图像

在 OpenCV 中显示磁盘映像非常简单。highgui模块的imread()namedWindow()imshow()功能为您完成所有工作。看一下清单 4-1,它在一个窗口中显示了一个图像,当你按 Esc 或‘Q’或‘Q’时退出(这和我们在第二章中用来检查 OpenCV 安装的代码完全一样):

清单 4-1。在窗口中显示图像

#include <iostream>

#include <opencv2/highgui/highgui.hpp>

using namespace std;

using namespace cv;

int main(int argc, char **argv)

{

Mat im = imread("image.jpg", CV_LOAD_IMAGE_COLOR);

namedWindow("Hello");

imshow("Hello", im);

cout << "Press 'q' to quit..." << endl;

while(char(waitKey(1)) != 'q') {}

return 0;

}

我现在将把代码分成几个部分来解释。

Mat im = imread("image.jpg", CV_LOAD_IMAGE_COLOR);

这创建了一个类型为cv::Mat的变量im(我们只写了Mat而不是cv::Mat,因为我们已经使用了上面的名称空间cv;,这是标准做法)。它还从磁盘中读取名为image.jpg的图像,并通过函数imread(). CV_LOAD_IMAGE_COLORis flag(在highgui.hpp头文件中定义的常量)将其放入im中,该函数告诉imread()将图像作为彩色图像加载。彩色图像有三个通道——红色、绿色和蓝色,而灰度图像只有一个通道——强度。您可以使用标志CV_LOAD_IMAGE_GRAYSCALE将图像加载为灰度。这里的im类型为CV_8UC3,其中 8 表示每个通道中每个像素所占的位数,U表示无符号字符(每个像素的每个通道是一个 8 位无符号字符),而C3表示 3 个通道。

namedWindow("Hello");

imshow("Hello", im);

首先创建一个名为 Hello 的窗口(Hello 也显示在窗口的标题栏中),然后在窗口中显示存储在im中的图像。就是这样!剩下的代码只是为了防止 OpenCV 在用户按下' Q '或' Q '之前退出并破坏窗口。

这里一个值得注意的函数是waitKey()。这将无限期等待一个按键事件(当n <= 0为正时)或等待n毫秒。它返回所按下的键的 ASCII 码,或者如果在指定时间过去之前没有按下键,则返回-1。注意waitKey()只有在 OpenCV GUI 窗口打开并处于焦点时才起作用。

cv::Mat 结构

cv::Mat 结构是 OpenCV 中用于存储数据(图像等)的主要数据结构。稍微走点弯路,了解一下 cv::Mat 有多牛逼是值得的。

cv::Mat 被组织为一个标题和实际数据。因为数据的布局与其他库和 SDK 中使用的数据结构相似或兼容,所以这种组织允许非常好的互操作性。可以为用户分配的数据创建一个 cv::Mat 头,并使用 OpenCV 函数就地处理它。

表 4-1 、 4-2 、 4-3 描述了 cv::Mat 结构的一些常见操作。不要担心马上就能记住;相反,通读一遍,了解你能做的事情,然后使用这些表格作为参考。

创建 cv::Mat

表 4-1。

Creating a cv::Mat

| 句法 | 描述 | | --- | --- | | 双 m[2][2] = {{1.0,2.0},{3.0,4.0 } };mat m(2.2,CV_32F,m); | 从多维数组数据创建一个 2 x 2 矩阵 | | Mat M(100,100,CV_32FC2,标量(1,3)); | 创建一个 100 x 100 的双通道矩阵,第一个通道填充 1,第二个通道填充 3 | | M.create(300,300,CV _ 8UC(15)); | 创建一个 300 x 300 的 15 通道矩阵,以前分配的数据将被释放 | | int size[3]= { 7,8,9 };Mat M(3,sizes,CV_8U,Scalar::all(0)); | 创建一个三维数组,其中每个维度的大小分别为 7、8 和 9。该数组用 0 填充 | | mat m = mat::eye(7.7,cv _ 32f); | 创建一个 7 x 7 的单位矩阵,每个元素是一个 32 位的浮点数 | | Mat M = Mat::零(7.7,cv _ 64f); | 创建一个用 64 位浮点零填充的 7 x 7 矩阵。类似地,Mat::ones()创建用 1 填充的矩阵 |

访问 cv::Mat 的元素

表 4-2。

Accessing elements from a cv::Mat

| 句法 | 描述 | | --- | --- | | M.at (i,j) | 访问 M 的第 I 行第 j 列的元素,M 是一个双精度矩阵。请注意,行号和列号从 0 开始 | | M.row(1) | 访问 m 的第一行。注意行数从 0 开始 | | 男 col(3) | 访问 m 的第 3 列。同样,列数从 0 开始 | | m . rowrange(1.4) | 访问 M 的第 1 到第 4 行 | | m . col range(1.4) | 访问 M 的第 1 到第 4 列 | | m . rowrange(2.5)。colrange(1.3) | 访问 M 的第 2 至第 5 行和第 1 至第 3 列 | | M.diag() | 访问方阵的对角线元素。也可用于从一组对角线值创建方阵 |

带 cv::Mat 的表达式

表 4-3。

Expressions with a cv::Mat

| 句法 | 描述 | | --- | --- | | 马特·M2 = m1 . clone(); | 让 M2 成为 M1 的翻版 | | Mat M2(消歧义):m1 . copy to(m2); | 让 M2 成为 M1 的翻版 | | Mat M1 = Mat::零(9.3,cv _ 32 fc 3);mat m2 = m1 . reshape(0.3); | 使 M2 成为具有与 M1 相同数量通道(由 0 表示)和 3 行(因此 9 列)的矩阵 | | mat m2 = m1 . t(); | 使 M2 成为 M1 的翻版 | | mat m2 = m1 . inv(); | 使 M2 成为 M1 的反面 | | mat m3 = m1 * m2; | 使 M3 成为 M1 和 M2 的母体产物 | | 马特·M2 = M1+s; | 将标量 s 添加到矩阵 M1,并将结果存储在 M2 中 |

关于 cv::Mats 的更多操作可以在 OpenCV 文档页面的 http://docs.opencv.org/modules/core/doc/basic_structures.html#mat 找到。

色彩空间之间的转换

色彩空间是描述图像中颜色的一种方式。最简单的颜色空间是 RGB 颜色空间,它只是将每个像素的颜色表示为红色、绿色和蓝色值,因为红色、绿色和蓝色是原色,您可以通过以各种比例组合这三种颜色来创建所有其他颜色。通常,每个“通道”是一个 8 位无符号整数(取值范围为 0-255);因此,您会发现 OpenCV 中的大多数彩色图像都具有 CV_8UC3 类型。表 4-4 中描述了一些常见的 RGB 三元组。

表 4-4。

Common RGB triplets

| 三个一组 | 颜色 | | --- | --- | | (255, 0, 0) | 红色 | | (0, 255, 0) | 格林(姓氏);绿色的 | | (0, 0, 255) | 蓝色 | | (0, 0, 0) | 黑色 | | (255, 255, 255) | 白色的 |

另一种颜色空间是灰度,从技术上讲根本不是颜色空间,因为它丢弃了颜色信息。它存储的只是每个像素的亮度,通常是一个 8 位无符号整数。还有许多其他颜色空间,其中值得注意的是 YUV、CMYK 和 LAB。(你可以在维基百科上读到它们。)

如前所述,通过 imread()分别使用 CV_LOAD_IMAGE_COLOR 和 CV _ LOAD _ IMAGE _ gray 标志,可以在 RGB 或灰度颜色空间中加载图像。然而,如果你已经加载了一个图像,OpenCV 有转换它的颜色空间的函数。出于各种原因,您可能希望在色彩空间之间进行转换。一个常见的原因是,YUV 颜色空间中的 U 和 V 通道编码所有颜色信息,但对照明或亮度不变。因此,如果你想对你的图像进行一些处理,需要光照不变,你应该转移到 YUV 颜色空间,并使用 U 和 V 通道(Y 通道专门存储强度信息)。注意,没有一个 R、G 或 B 通道是光照不变的。

函数 cvtColor()进行颜色空间转换。例如,要将 img1 中的 RGB 图像转换为灰度图像,您需要:

cvtColor(img1, img2, CV_RGB2GRAY);

其中 CV_RGB2GRAY 是一个预定义的代码,它告诉 OpenCV 要执行哪个转换。这个函数可以在许多颜色空间之间转换,你可以在 OpenCV 文档页面( http://docs.opencv.org/modules/imgproc/doc/miscellaneous_transformations.html?highlight=cvtcolor#cv.CvtColor )上阅读更多关于它的内容。

GUI 跟踪条和回调函数

本节将向您介绍 OpenCV highgui模块的一个非常有用的特性——跟踪条或滑块,以及操作它们所必需的回调函数。我们将使用滑块将图像从 RGB 颜色转换为灰度,反之亦然,因此希望这也将强化您的颜色空间转换概念。

回调函数

回调函数是事件发生时自动调用的函数。它们可以与 OpenCV 中的各种 GUI 事件相关联,比如单击鼠标左键或右键、移动滑块等等。对于我们的颜色空间转换应用,我们将把回调函数与滑块的移动关联起来。每当用户移动滑块时,这个函数就会被自动调用。简而言之,该函数检查滑块的值,并在相应地转换其颜色空间后显示图像。虽然这听起来很复杂,但是 OpenCV 让它变得非常简单。让我们看看清单 4-2 中的代码。

清单 4-2。色彩空间转换

// Function to change between color and grayscale representations of an image using a GUI trackbar

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <iostream>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace std;

using namespace cv;

// Global variables

const int slider_max = 1;

int slider;

Mat img;

// Callback function for trackbar event

void on_trackbar(int pos, void *)

{

Mat img_converted;

if(pos > 0) cvtColor(img, img_converted, CV_RGB2GRAY);

else img_converted = img;

imshow("Trackbar app", img_converted);

}

int main()

{

img = imread("image.jpg");

namedWindow("Trackbar app");

imshow("Trackbar app", img);

slider = 0;

createTrackbar("RGB <-> Grayscale", "Trackbar app", &slider, slider_max, on_trackbar);

while(char(waitKey(1)) != 'q') {}

return 0;

}

像往常一样,我将把代码分成几个部分来解释。

台词

const int slider_max = 1;

int slider;

Mat img;

声明保存原始图像、滑块位置和最大可能滑块位置的全局变量。因为我们只需要滑块的两个选项——颜色和灰度(0 和 1 ),并且最小可能的滑块位置总是 0,所以我们将最大滑块位置设置为 1。全局变量是必要的,这样两个函数都可以访问它们。

台词

img = imread("image.jpg");

namedWindow("Trackbar app");

imshow("Trackbar app", img);

在主函数中,只需读取一个名为image.jpg的彩色图像,创建一个名为“Trackbar app”的窗口(创建跟踪条需要一个窗口),并在窗口中显示图像。

createTrackbar("RGB <-> Grayscale", "Trackbar app", &slider, slider_max, on_trackbar);

在我们之前创建的名为“跟踪条应用”的窗口中创建一个名为“RGB 灰度”的跟踪条(你应该在 OpenCV 文档中查找这个函数)。我们还通过使用&slider、跟踪条的最大可能值以及将名为on_trackbar的回调函数与跟踪条事件相关联,传递一个指向保存跟踪条起始值的变量的指针。

现在让我们看看回调函数on_trackbar(),,它(对于跟踪条回调)必须总是类型void foo(int. void *).。这里的变量pos保存跟踪条的值,每次用户滑动跟踪条时,这个函数将被调用,并更新pos的值。台词

if(pos > 0) cvtColor(img, img_converted, CV_RGB2GRAY);

else img_converted = img;

imshow("Trackbar app", img_converted);

只需检查pos的值,并在之前创建的窗口中显示正确的图像。

编译并运行您的色彩空间转换器应用,如果一切顺利,您应该会看到它的运行,如图 4-1 所示。

A978-1-4302-6080-6_4_Fig1a_HTML.jpg A978-1-4302-6080-6_4_Fig1b_HTML.jpg

图 4-1。

The color-space conversion app in action

ROI:从图像中裁剪出一个矩形部分

在本节中,您将了解感兴趣区域。然后,您将使用这些知识制作一个应用,允许您选择图像中的矩形部分并将其裁剪掉。

图像中的感兴趣区域

感兴趣的区域正是它听起来的样子。这是我们特别感兴趣的图像区域,并且我们希望将我们的处理集中在这一区域上。它主要用于图像太大并且图像的所有部分都与我们的使用无关的情况;或者处理操作太重,以至于将其应用于整个图像在计算上是禁止的。通常 ROI 被指定为矩形。在 OpenCV 中,使用rect结构指定矩形 ROI(同样,在 OpenCV 文档中查找rect)。我们需要左上角的位置,宽度和高度来定义一个rect

让我们看一下我们的应用的代码(清单 4-3 ),然后一次分析一点。

清单 4-3。从图像中裁剪出一部分

// Program to crop images using GUI mouse callbacks

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <iostream>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace std;

using namespace cv;

// Global variables

// Flags updated according to left mouse button activity

bool ldown = false, lup = false;

// Original image

Mat img;

// Starting and ending points of the user's selection

Point corner1, corner2;

// ROI

Rect box;

// Callback function for mouse events

static void mouse_callback(int event, int x, int y, int, void *)

{

// When the left mouse button is pressed, record its position and save it in corner1

if(event == EVENT_LBUTTONDOWN)

{

ldown = true;

corner1.x = x;

corner1.y = y;

cout << "Corner 1 recorded at " << corner1 << endl;

}

// When the left mouse button is released, record its position and save it in corner2

if(event == EVENT_LBUTTONUP)

{

// Also check if user selection is bigger than 20 pixels (jut for fun!)

if(abs(x - corner1.x) > 20 && abs(y - corner1.y) > 20)

{

lup = true;

corner2.x = x;

corner2.y = y;

cout << "Corner 2 recorded at " << corner2 << endl << endl;

}

else

{

cout << "Please select a bigger region" << endl;

ldown = false;

}

}

// Update the box showing the selected region as the user drags the mouse

if(ldown == true && lup == false)

{

Point pt;

pt.x = x;

pt.y = y;

Mat local_img = img.clone();

rectangle(local_img, corner1, pt, Scalar(0, 0, 255));

imshow("Cropping app", local_img);

}

// Define ROI and crop it out when both corners have been selected

if(ldown == true && lup == true)

{

box.width = abs(corner1.x - corner2.x);

box.height = abs(corner1.y - corner2.y);

box.x = min(corner1.x, corner2.x);

box.y = min(corner1.y, corner2.y);

// Make an image out of just the selected ROI and display it in a new window

Mat crop(img, box);

namedWindow("Crop");

imshow("Crop", crop);

ldown = false;

lup = false;

}

}

int main()

{

img = imread("image.jpg");

namedWindow("Cropping app");

imshow("Cropping app", img);

// Set the mouse event callback function

setMouseCallback("Cropping app", mouse_callback);

// Exit by pressing 'q'

while(char(waitKey(1)) != 'q') {}

return 0;

}

这段代码目前看起来可能很大,但是,正如您可能已经意识到的,它的大部分只是鼠标事件的逻辑处理。在队列中

setMouseCallback("Cropping app", mouse_callback);

在主函数中,我们将鼠标回调设置为名为mouse_callback的函数。函数mouse_callback主要完成以下工作:

  • 记录按下左键时鼠标的(x,y)位置。
  • 记录释放左键时鼠标的(x,y)位置。
  • 当这两项操作完成后,在图像中定义一个 ROI,并在另一个窗口中显示仅由 ROI 组成的另一个图像(您可以添加一个保存 ROI 的功能—为此使用 imwrite())。
  • 绘制用户选择,并在用户拖动鼠标并按下左键时保持更新。

实现非常简单,一目了然。我想把重点放在这个程序中引入的三个新的编程特性上:Pointrect,以及通过指定一个rect ROI 从另一个图像创建一个图像。

Point结构用于存储关于一个点的信息,在我们的例子中是用户选择的角。该结构有两个数据成员,都是int,称为xy。其他的点结构如Point3dPoint2dPoint3f也存在于 OpenCV 中,你应该在 OpenCV 文档中查看它们。

rect结构用于存储一个矩形的信息,使用它的xywidthheight. xy这里是图像中矩形左上角的坐标。

如果名为rrect保存了图像M1中 ROI 的信息,您可以使用

Mat M2(M1, r);

裁剪应用看起来如图 4-2 所示。

A978-1-4302-6080-6_4_Fig2_HTML.jpg

图 4-2。

The cropping app in action

访问图像的单个像素

有时有必要访问图像或其 ROI 中各个像素的值。OpenCV 有有效的方法做到这一点。要访问cv::Mat图像中位置(i, j)处的像素,可以使用cv::Matat()属性,如下所示:

对于每个像素都是 8 位无符号字符的灰度图像M,使用M.at<uchar>(i, j).

对于 3 通道(RGB)图像M,其中每个像素是 3 个 8 位无符号字符的向量,使用M.at<Vec3b>[c],其中c是通道号,从 0 到 2。

锻炼

能否根据目前所学的概念,制作一个非常简单的彩色图像分割 app?

分割意味着识别图像的不同部分。这里的零件是用颜色来定义的。我们希望识别图像中的红色区域:给定一个彩色图像,您应该产生一个黑白图像输出,其像素在原始图像的红色区域为 255(开),在非红色区域为 0(关)。

您遍历彩色图像中的像素,检查它们的红色值是否在某个范围内。如果是,则打开输出图像的相应像素。你当然可以用简单的方法,遍历图像中的所有像素。但是看看你是否能在 OpenCV 文档中找到一个为你做完全相同任务的函数。也许你甚至可以做一个跟踪条来动态调整这个范围!

录像

OpenCV 中的视频通过 FFMPEG 支持进行处理。在继续本节中的代码之前,请确保您已经安装了支持 FFMPEG 的 OpenCV。

显示来自网络摄像头或 USB 摄像头/文件的源

让我们检查一段很短的代码(清单 4-4 ),它将显示来自你的计算机的默认摄像设备的视频。对于大多数笔记本电脑来说,这是集成的网络摄像头。

清单 4-4。显示来自默认摄像机设备的视频源

// Program to display a video from attached default camera device

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

using namespace cv;

using namespace std;

int main()

{

// 0 is the ID of the built-in laptop camera, change if you want to use other camera

VideoCapture cap(0);

//check if the file was opened properly

if(!cap.isOpened())

{

cout << "Capture could not be opened successfully" << endl;

return -1;

}

namedWindow("Video");

// Play the video in a loop till it ends

while(char(waitKey(1)) != 'q' && cap.isOpened())

{

Mat frame;

cap >> frame;

// Check if the video is over

if(frame.empty())

{

cout << "Video over" << endl;

break;

}

imshow("Video", frame);

}

return 0;

}

代码本身是不言自明的,我只想简单介绍几行代码。

VideoCapture cap(0);

这将创建一个 VideoCapture 对象,该对象链接到您计算机上的设备编号 0(默认设备)。和

cap >> frame;

从 VideoCapture 对象 cap 链接到的设备中提取帧。还有一些其他方法可以从相机设备中提取帧,特别是当您有多个相机并且想要同步它们时(同时从所有相机中提取帧)。我将在第十章中介绍这些方法。

您也可以给 VideoCapture 构造函数一个文件名,OpenCV 将以完全相同的方式为您播放该文件中的视频(参见清单 4-5)。

清单 4-5。显示文件中视频的程序

// Program to display a video from a file

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

// Video from:http://ftp.nluug.nl/ftp/graphics/blender/apricot/trailer/sintel_trailer-480p.mp4

#include <opencv2/opencv.hpp>

using namespace cv;

using namespace std;

int main()

{

// Create a VideoCapture object to read from video file

VideoCapture cap("video.mp4");

//check if the file was opened properly

if(!cap.isOpened())

{

cout << "Capture could not be opened succesfully" << endl;

return -1;

}

namedWindow("Video");

// Play the video in a loop till it ends

while(char(waitKey(1)) != 'q' && cap.isOpened())

{

Mat frame;

cap >> frame;

// Check if the video is over

if(frame.empty())

{

cout << "Video over" << endl;

break;

}

imshow("Video", frame);

}

return 0;

}

将视频写入磁盘

一个VideoWriter对象用于将视频写入磁盘。此类的构造函数需要以下内容作为输入:

  • 输出文件名
  • 输出文件的编解码器。在下面的代码中,我们使用 MPEG 编解码器,这是非常常见的。您可以使用CV_FOURCC宏指定编解码器。各种编解码器的四个字符代码可以在 www.fourcc.org/codecs.php 找到。请注意,要使用编解码器,您必须在计算机上安装该编解码器
  • 帧频
  • 框架尺寸

你可以得到一个视频的各种属性(像帧大小,帧速率,亮度,对比度,曝光度等。)从一个VideoCapture对象使用get()函数。在清单 4-6 中,它将视频从默认的摄像机设备写入磁盘,我们使用get()函数来获取帧的大小。如果你的相机支持的话,你也可以用它来获得帧速率。

清单 4-6。将视频从默认摄像机设备源写入磁盘的代码

// Program to write video from default camera device to file

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

using namespace cv;

using namespace std;

int main()

{

// 0 is the ID of the built-in laptop camera, change if you want to use other camera

VideoCapture cap(0);

//check if the file was opened properly

if(!cap.isOpened())

{

cout << "Capture could not be opened succesfully" << endl;

return -1;

}

// Get size of frames

Size S = Size((int) cap.get(CV_CAP_PROP_FRAME_WIDTH), (int) cap.get(CV_CAP_PROP_FRAME_HEIGHT));

// Make a video writer object and initialize it at 30 FPS

VideoWriter put("output.mpg", CV_FOURCC('M','P','E','G'), 30, S);

if(!put.isOpened())

{

cout << "File could not be created for writing. Check permissions" << endl;

return -1;

}

namedWindow("Video");

// Play the video in a loop till it ends

while(char(waitKey(1)) != 'q' && cap.isOpened())

{

Mat frame;

cap >> frame;

// Check if the video is over

if(frame.empty())

{

cout << "Video over" << endl;

break;

}

imshow("Video", frame);

put << frame;

}

return 0;

}

摘要

在这一章中,您接触了大量的 OpenCV 代码,并且看到了编写复杂任务的程序是多么容易,比如在 OpenCV 中显示视频。这一章没有很多计算机视觉。它的目的是向您介绍 OpenCV 的来龙去脉。下一章将处理图像过滤和转换,并将利用你在这里学到的编程概念。

五、过滤图像

Abstract

在本章中,我们将继续讨论对图像的基本操作。特别是,我们将讨论一些滤波器理论和不同种类的滤波器,您可以将它们应用于图像,以便提取各种信息或抑制各种噪声。

在本章中,我们将继续讨论对图像的基本操作。特别是,我们将讨论一些滤波器理论和不同种类的滤波器,您可以将它们应用于图像,以便提取各种信息或抑制各种噪声。

图像处理和计算机视觉之间只有一线之隔。图像处理主要处理通过以各种方式变换图像来获得图像的不同表示。通常(但不总是)这样做是为了“查看”,例如更改图像的色彩空间、锐化或模糊图像、更改对比度、仿射变换、裁剪、调整大小等等。相比之下,计算机视觉关注的是从图像中提取信息,以便人们做出决策。通常,必须从有噪声的图像中提取信息,因此还必须分析噪声,并想办法抑制噪声,同时不会过多影响图像的相关信息内容。

举个例子,一个问题是你必须制造一个简单的轮式自动机器人,它可以向一个方向移动,跟踪并拦截一个红色的球。

这里的计算机视觉问题是双重的:查看从机器人上的摄像头获取的图像中是否有一个红球,如果有,知道它沿着机器人的运动方向相对于机器人的位置。请注意,这两个都是决定性的信息,基于这些信息,机器人可以决定是否移动,如果是,向哪个方向移动。

滤镜是最基本的操作,您可以对图像执行这些操作来提取信息。(它们可能极其复杂。但是我们将从简单的开始。)为了让您对本章的内容有一个大致的了解,我们将首先从一些图像滤镜理论开始,然后研究一些简单的滤镜。在许多计算机视觉流水线中,应用这些过滤器可以作为有用的预处理或后处理步骤。这些操作包括:

  • 模糊
  • 上下调整图像大小
  • 侵蚀和扩张
  • 检测边缘和拐角

然后,我们将讨论如何有效地检查图像中像素值的界限。利用这一新发现的知识,我们将制作第一个非常简单的 objector 探测器应用。接下来将讨论打开和关闭的图像形态学操作,这是从图像中消除噪声的有用工具(我们将通过向我们的 object detector 应用添加打开和关闭步骤来消除噪声来演示这一点)。

图像过滤器

滤波器只不过是一个函数,它获取信号的局部值,并给出以某种方式与信号中包含的信息成比例的输出。通常,一个“滑动”滤波器通过信号。为了明确这两个重要的陈述,考虑下面的一维时变信号,它可以是一个城市每天的温度(或类似的东西)。

我们想要提取的信息是温度波动;具体来说,我们想看看每天的气温变化有多剧烈。所以我们做了一个过滤函数,它给出了今天的温度和昨天的温度之差的绝对值。我们遵循的等式是 y[n]= | x[n]—x[n 1]|,其中 y[n]是第 n 天的滤波器输出,x[n]是信号,即第 n 天的城市温度。

这个滤波器(长度为 2)在信号中“滑动”,输出类似于图 5-1 。

A978-1-4302-6080-6_5_Fig1_HTML.jpg

图 5-1。

A simple signal (above) and output of a differential filter on that signal (below)

正如您所观察到的,滤波器增强了信号的差异。简单地说,如果今天的温度与昨天有很大不同,那么今天的过滤器输出将会更高。如果今天的温度和昨天差不多,那么今天的滤波器输出几乎为零。希望这个非常简单的例子能让您相信,滤波器设计基本上就是计算出一个函数,它将接收信号值,并增强其中所选的信息内容。还有一些其他的条件和规则需要注意,但是对于我们简单的应用,我们可以忽略它们。

现在让我们继续讨论图像滤波器,它与我们之前讨论的 1-D 滤波器有些不同,因为图像信号是 2-D 的,因此滤波器也必须是 2-D 的(如果我们想考虑所有四边的像素邻居)。检测图像中垂直边缘的示例过滤器将帮助您更好地理解。第一步是确定滤波器矩阵。筛选器矩阵是筛选器函数的离散化版本,使在计算机上应用筛选器成为可能。它们的长度和宽度通常是奇数,因此可以明确地确定中心元素。对于我们检测垂直边缘的情况,矩阵非常简单:

0 0 0

-1 2 -1

0 0 0

或者如果我们想考虑两个相邻的像素:

0 0 0 0 0

0 0 0 0 0

-1 -2 6 -2 -1

0 0 0 0 0

0 0 0 0 0

现在,让我们滑动这个过滤器通过一个图像,看看它是否工作!在此之前,我必须详细说明什么是“应用”一个过滤器矩阵(或内核)的图像意味着什么。内核放在图像上,通常从图像的左上角开始。每次迭代都会执行以下步骤:

  • 在内核的元素和由内核覆盖的图像的像素之间执行逐元素乘法
  • 函数用于使用所有这些元素乘法的结果来计算一个数字。这个函数可以是总和、平均值、最小值、最大值或者非常复杂的东西。如此计算的值被称为图像在该迭代中对滤波器的“响应”
  • 位于内核中心元素下方的像素采用响应值
  • 内核向右移动,必要时向下移动

用这个过滤器矩阵(也称为内核)过滤一个仅由水平和垂直边缘组成的图像,得到如图 5-2 所示的过滤图像。

A978-1-4302-6080-6_5_Fig2_HTML.jpg

图 5-2。

A simple image with horizontal and vertical edges (left) and output of filtering with kernel (right)

OpenCV 有一个名为filter2D()的函数,我们可以使用它进行高效的基于内核的过滤。要了解如何使用它,请研究前面讨论的用于过滤的代码,并阅读它的文档。这个函数非常强大,因为它允许您通过指定的任何内核过滤图像。清单 5-1 展示了这个函数的使用。

清单 5-1。程序应用一个简单的过滤器矩阵来检测图像的水平边缘

// Program to apply a simple filter matrix to an image

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

using namespace std;

using namespace cv;

int main() {

Mat img = imread("image.jpg", CV_LOAD_IMAGE_GRAYSCALE), img_filtered;

// Filter kernel for detecting vertical edges

float vertical_fk[5][5] = {{0,0,0,0,0}, {0,0,0,0,0}, {-1,-2,6,-2,-1}, {0,0,0,0,0}, {0,0,0,0,0}};

// Filter kernel for detecting horizontal edges

float horizontal_fk[5][5] = {{0,0,-1,0,0}, {0,0,-2,0,0}, {0,0,6,0,0}, {0,0,-2,0,0}, {0,0,-1,0,0}};

Mat filter_kernel = Mat(5, 5, CV_32FC1, horizontal_fk);

// Apply filter

filter2D(img, img_filtered, -1, filter_kernel);

namedWindow("Image");

namedWindow("Filtered image");

imshow("Image", img);

imshow("Filtered image", img_filtered);

// imwrite("filtered_image.jpg", img_filtered);

while(char(waitKey(1)) != 'q') {}

return 0;

}

您可能已经猜到,检测垂直边缘的内核是:

0 0 -1 0 0

0 0 -2 0 0

0 0 6 0 0

0 0 -2 0 0

0 0 -1 0 0

它可以很好地检测垂直边缘,如图 5-3 所示。

A978-1-4302-6080-6_5_Fig3_HTML.jpg

图 5-3。

Detecting vertical edges in an image

尝试制作不同的检测器内核和实验各种图像是很有趣的!

如果您给多通道彩色图像作为filter2D()的输入,它将相同的内核矩阵应用于所有通道。有时,您可能希望只检测某个通道中的边缘,或者对不同的通道使用不同的核,并选择最强的边缘(或平均边缘强度)。在这种情况下,您应该使用split()函数分割图像,并分别应用内核。

不要忘记查看 OpenCV 文档中所有你正在学习的新函数!

因为边缘检测是计算机视觉中非常重要的操作,所以已经进行了大量的研究来设计可以在任意方向检测边缘的方法和智能滤波器矩阵。OpenCV 提供了其中一些算法的实现,在这一章的后面我会有更多的介绍。同时,让我们坚持我们的章节计划,讨论第一个图像预处理步骤,模糊。

模糊图像

模糊图像是在不改变图像外观的情况下缩小图像尺寸的第一步。模糊可以被认为是低通滤波操作,并使用简单直观的核矩阵来完成。可以认为一幅图像沿其两个轴的方向都有不同的“频率成分”。边缘具有高频率,而缓慢变化的强度值具有低频率。更具体地说,垂直边缘沿着图像的水平轴产生高频分量,反之亦然。精细纹理区域也具有高频率(请注意,如果一个区域中的像素强度值在短像素距离内变化很大,则该区域称为精细纹理区域)。较小的图像不能很好地处理高频。

可以这样想:假设你有一张纹理清晰的 640 x 480 的图片。您无法在 320 x 240 的图像中保持所有这些短时间间隔的高强度像素值变化,因为它只有像素数量的四分之一。所以无论何时你想缩小一幅图像的尺寸,你都应该从中去除高频成分。换句话说,模糊它。平滑掉那些高量级的短间隔变化。如果在调整大小之前没有模糊,您可能会在调整大小后的图像中看到伪像。原因很简单,取决于信号理论的一个基本定理,即采样一个信号会导致该信号的频域出现无限重复。因此,如果信号有许多高频成分,重复频域表示的边缘会相互干扰。一旦发生这种情况,信号就无法准确恢复。这里,信号是我们的图像,调整大小是通过移除行和列来完成的,也就是下采样。这种现象被称为混叠。如果你想了解更多,你应该能够在任何好的数字信号处理资源中找到详细的解释。因为模糊去除了图像中的高频成分,所以有助于避免混叠。

当您想要增加图像的大小时,模糊也是一个重要的后处理步骤。如果您想要将图像的大小增加一倍,可以为每一行(列)添加一个空白行(列),然后模糊生成的图像,使空白行(列)的外观与相邻行(列)相似。

模糊可以通过用图像周围区域中像素的某种平均值替换图像中的每个像素来实现。为了有效地做到这一点,该区域保持矩形并围绕像素对称,并且图像与“归一化”核进行卷积(归一化是因为我们想要平均值,而不是总和)。一个非常简单的内核是箱式内核:

1 1 1 1 1

1 1 1 1 1

1 1 1 1 1

1 1 1 1 1

1 1 1 1 1

这个内核认为每个像素都同等重要。一个更好的核应该是随着像素与中心像素的距离的增加而减少像素的影响。高斯核可以做到这一点,并且是最常用的模糊核:

1 4 6 4 1

4 16 24 16 4

6 24 36 24 6

4 16 24 16 4

1 4 6 4 1

一种方法是通过除以所有元素的总和来“归一化”核,25 用于盒核,256 用于高斯核。您可以使用 OpenCV 函数getGaussianKernel()创建不同大小的高斯内核。查看这个函数的文档,了解 OpenCV 用来计算内核的公式。您可以将这些内核插入到清单 5-1 中,以模糊一些图像(不要忘记将内核除以其元素的总和)。然而,OpenCV 也提供了更高级的函数GaussianBlur(),它只是将高斯函数的核大小和方差作为输入,并为我们完成所有其他工作。我们在清单 5-2 的代码中使用了这个函数,它用一个滑块指示大小的高斯核来模糊一个图像。它应该有助于你实际理解模糊。图 5-4 显示了运行中的代码。

清单 5-2。使用不同大小的高斯核交互式模糊图像的程序

// Program to interactively blur an image using a Gaussian kernel of varying size

// Author: Samarth Manoj Brahmbhatt, University of Pennyslvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace std;

using namespace cv;

Mat image, image_blurred;

int slider = 5;

float sigma = 0.3 * ((slider - 1) * 0.5 - 1) + 0.8;

void on_trackbar(int, void *) {

int k_size = max(1, slider);

k_size = k_size % 2 == 0 ? k_size + 1 : k_size;

setTrackbarPos("Kernel Size", "Blurred image", k_size);

sigma = 0.3 * ((k_size - 1) * 0.5 - 1) + 0.8;

GaussianBlur(image, image_blurred, Size(k_size, k_size), sigma);

imshow("Blurred image", image_blurred);

}

int main() {

image = imread("baboon.jpg");

namedWindow("Original image");

namedWindow("Blurred image");

imshow("Original image", image);

sigma = 0.3 * ((slider - 1) * 0.5 - 1) + 0.8;

GaussianBlur(image, image_blurred, Size(slider, slider), sigma);

imshow("Blurred image", image_blurred);

createTrackbar("Kernel Size", "Blurred image", &slider, 21, on_trackbar);

while(char(waitKey(1) != 'q')) {}

return 0;

}

A978-1-4302-6080-6_5_Fig4_HTML.jpg

图 5-4。

Blurring an image with a Gaussian kernel

请注意我们使用的基于内核大小计算方差的启发式公式,以及使用setTrackbarPos()函数强制内核大小为奇数且大于 0 的工具栏“锁定”机制。

上下调整图像大小

现在我们知道了在调整图像大小时模糊图像的重要性,我们准备好调整图像大小并验证我所阐述的理论是否正确。

你可以通过使用如图 5-5 所示的resize()函数来做一个简单的几何尺寸调整(简单地抛出行和列)。

A978-1-4302-6080-6_5_Fig5a_HTML.jpg A978-1-4302-6080-6_5_Fig5b_HTML.jpg

图 5-5。

Aliasing artifacts—simple geometric resizing of an image down by a factor of 4

观察由于混叠而在调整大小的图像中产生的伪像。pyrDown()函数通过高斯核模糊图像,并将其缩小 2 倍。图 5-6 中的图像是原始图像的四倍缩小版,通过使用两次pyrDown()获得(注意没有混叠伪影)。

A978-1-4302-6080-6_5_Fig6_HTML.jpg

图 5-6。

Avoiding aliasing while resizing images by first blurring them with a Gaussian kernel

如果你想放大一幅图像,函数resize()也是有效的,它采用了一系列你可以选择的插值技术。如果您想在放大后模糊图像,请使用pyrUp()功能适当的次数(因为它的工作系数是 2)。

侵蚀和扩张图像

腐蚀和膨胀是图像的两种基本形态学操作。顾名思义,形态学运算作用于图像的形式和结构。

侵蚀是通过在图像上滑动所有 1 的矩形核(盒核)来完成的。响应被定义为内核元素和属于内核的像素之间的所有元素乘法的最大值。因为所有的核元素都是一,所以应用这个核意味着用围绕像素的矩形区域中的最小值替换每个像素值。您可以想象这将导致图像中的黑色区域“侵占”到白色区域(因为白色的像素值高于黑色)。

放大图像是相同的,唯一的区别是响应被定义为元素方式乘法的最大值而不是最小值。这将导致白色区域侵占黑色区域。

内核的大小决定了腐蚀或膨胀的程度。清单 5-3 制作了一个在腐蚀和膨胀之间切换的应用,并允许您选择内核的大小(在形态学操作的上下文中也称为结构化元素)。

清单 5-3。检查图像腐蚀和膨胀的程序

// Program to examine erosion and dilation of images

// Author: Samarth Manoj Brahmbhatt, University of Pennyslvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace std;

using namespace cv;

Mat image, image_processed;

int choice_slider = 0, size_slider = 5; // 0 - erode, 1 - dilate

void process() {

Mat st_elem = getStructuringElement(MORPH_RECT, Size(size_slider, size_slider));

if(choice_slider == 0) {

erode(image, image_processed, st_elem);

}

else {

dilate(image, image_processed, st_elem);

}

imshow("Processed image", image_processed);

}

void on_choice_slider(int, void *) {

process();

}

void on_size_slider(int, void *) {

int size = max(1, size_slider);

size = size % 2 == 0 ? size + 1 : size;

setTrackbarPos("Kernel Size", "Processed image", size);

process();

}

int main() {

image = imread("j.png");

namedWindow("Original image");

namedWindow("Processed image");

imshow("Original image", image);

Mat st_elem = getStructuringElement(MORPH_RECT, Size(size_slider, size_slider));

erode(image, image_processed, st_elem);

imshow("Processed image", image_processed);

createTrackbar("Erode/Dilate", "Processed image", &choice_slider, 1, on_choice_slider);

createTrackbar("Kernel Size", "Processed image", &size_slider, 21, on_size_slider);

while(char(waitKey(1) != 'q')) {}

return 0;

}

图 5-7 显示了用户使用滑块指定的不同侵蚀和放大量的图像。

A978-1-4302-6080-6_5_Fig7_HTML.jpg

图 5-7。

Eroding and dilating images

除了函数erode()dilate()之外,这段代码中值得注意的是函数getStructuralElement() ,,它返回指定形状和大小的结构元素(核矩阵)。预定义的形状包括矩形、椭圆形和十字形。您甚至可以创建自定义形状。所有这些形状都嵌入在一个由零组成的矩形矩阵中返回(属于该形状的元素是 1)。

有效检测图像中的边缘和角点

您之前已经看到,使用滤波器可以很容易地检测出垂直和水平边缘。如果你构建了一个合适的内核,你可以检测任何方向的边缘,只要它是一个固定的方向。然而,在实践中,人们必须在同一图像中检测所有方向的边缘。我们将讨论一些智能的方法来做到这一点。也可以通过使用适当种类的核来检测角点。

优势

边缘是图像中图像梯度非常高的点。我们所说的梯度是指像素强度值的变化。通过计算 X 和 Y 方向上的梯度,然后使用毕达哥拉斯定理将它们结合,来计算图像的梯度。虽然通常不需要,但是您可以通过分别取 Y 和 X 方向的梯度比的反正切来计算梯度的角度。

x 和 Y 方向梯度分别通过用以下核卷积图像来计算:

-3 0 3

-10 0 10

-3 0 3 (for X direction)

-3 -10 -3

0 0 0

3 10 3 (for Y direction)

整体梯度,G = sqrt(Gx 2 + Gy 2

倾斜角,ф=反正切(Gy / Gx)

上面显示的两个内核被称为 Scharr 操作符,OpenCV 提供了一个名为 Scharr()的函数,它将指定大小和指定方向(X 或 Y)的 Scharr 操作符应用于图像。所以让我们制作如清单 5-4 所示的 Scharr 边缘检测程序。

清单 5-4。使用 Scharr 算子检测图像边缘的程序

// Program to detect edges in an image using the Scharr operator

// Author: Samarth Manoj Brahmbhatt, University of Pennyslvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace std;

using namespace cv;

int main() {

Mat image = imread("lena.jpg"), image_blurred;

// Blur image with a Gaussian kernel to remove edge noise

GaussianBlur(image, image_blurred, Size(3, 3), 0, 0);

// Convert to gray

Mat image_gray;

cvtColor(image_blurred, image_gray, CV_RGB2GRAY);

// Gradients in X and Y directions

Mat grad_x, grad_y;

Scharr(image_gray, grad_x, CV_32F, 1, 0);

Scharr(image_gray, grad_y, CV_32F, 0, 1);

// Calculate overall gradient

pow(grad_x, 2, grad_x);

pow(grad_y, 2, grad_y);

Mat grad = grad_x + grad_y;

sqrt(grad, grad);

// Display

namedWindow("Original image");

namedWindow("Scharr edges");

// Convert to 8 bit depth for displaying

Mat edges;

grad.convertTo(edges, CV_8U);

imshow("Original image", image);

imshow("Scharr edges", edges);

while(char(waitKey(1)) != 'q') {}

return 0;

}

图 5-8 显示了美丽的莉娜图像的沙尔边缘。

A978-1-4302-6080-6_5_Fig8_HTML.jpg

图 5-8。

Scharr edge detector

您可以看到 Scharr 操作符像承诺的那样找到了梯度。然而,在边缘图像中有许多噪声。因为边缘图像具有 8 位深度,所以您可以用 0 到 255 之间的一个数字对其进行阈值处理,以消除噪声。像往常一样,你可以制作一个带有滑块的应用。该应用的代码如清单 5-5 所示,不同阈值的阈值 Scharr 输出如图 5-9 所示。

清单 5-5。使用阈值 Scharr 算子检测图像边缘的程序

// Program to detect edges in an image using the thresholded Scharr operator

// Author: Samarth Manoj Brahmbhatt, University of Pennyslvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace std;

using namespace cv;

Mat edges, edges_thresholded;

int slider = 50;

void on_slider(int, void *) {

if(!edges.empty()) {

Mat edges_thresholded;

threshold(edges, edges_thresholded, slider, 255, THRESH_TOZERO);

imshow("Thresholded Scharr edges", edges_thresholded);

}

}

int main() {

//Mat image = imread("lena.jpg"), image_blurred;

Mat image = imread("lena.jpg"), image_blurred;

// Blur image with a Gaussian kernel to remove edge noise

GaussianBlur(image, image_blurred, Size(3, 3), 0, 0);

// Convert to gray

Mat image_gray;

cvtColor(image_blurred, image_gray, CV_BGR2GRAY);

// Gradients in X and Y directions

Mat grad_x, grad_y;

Scharr(image_gray, grad_x, CV_32F, 1, 0);

Scharr(image_gray, grad_y, CV_32F, 0, 1);

// Calculate overall gradient

pow(grad_x, 2, grad_x);

pow(grad_y, 2, grad_y);

Mat grad = grad_x + grad_y;

sqrt(grad, grad);

// Display

namedWindow("Original image");

namedWindow("Thresholded Scharr edges");

// Convert to 8 bit depth for displaying

grad.convertTo(edges, CV_8U);

threshold(edges, edges_thresholded, slider, 255, THRESH_TOZERO);

imshow("Original image", image);

imshow("Thresholded Scharr edges", edges_thresholded);

createTrackbar("Threshold", "Thresholded Scharr edges", &slider, 255, on_slider);

while(char(waitKey(1)) != 'q') {}

return 0;

}

A978-1-4302-6080-6_5_Fig9a_HTML.jpg A978-1-4302-6080-6_5_Fig9b_HTML.jpg

图 5-9。

Scharr edge detector with thresholds of 100 (top) and 200 (bottom)

锐利的边缘

Canny 算法使用一些后处理来清理边缘输出,并给出细而锐利的边缘。计算 Canny 边缘的步骤包括:

  • 通过用大小为 5 的归一化高斯核卷积图像来去除边缘噪声
  • 使用两种不同的内核计算 X 和 Y 梯度:

-1 0 1

-2 0 2

-1 0 1 for X direction and

-1 -2 -1

0 0 0

1 2 1 for Y direction

  • 如前所述,通过毕达哥拉斯定理找到总梯度强度,通过反正切找到梯度角度。角度四舍五入为四个选项:0 度、45 度、90 度和 135 度
  • 非最大抑制:只有当像素的梯度幅度大于其在梯度方向上的相邻像素的梯度幅度时,该像素才被视为在边缘上。这产生了尖锐而薄的边缘
  • 滞后阈值:这个过程使用两个阈值。如果像素的梯度幅度高于上阈值,则该像素被接受为边缘,如果其梯度幅度低于下阈值,则该像素被拒绝。如果梯度幅度在两个阈值之间,则只有当它连接到作为边缘的像素时,它才会被接受为边缘

你可以在 Lena 图片上运行 OpenCV Canny edge 演示,看看 Canny 和 Scharr edges 的区别。图 5-10 显示了从相同的 Lena 照片中提取的 Canny 边缘。

A978-1-4302-6080-6_5_Fig10_HTML.jpg

图 5-10。

Canny edge detector

困境

OpenCV 函数goodFeaturesToTrack()实现了一个鲁棒的角点检测器。该算法采用了史和托马西提出的兴趣点检测算法。关于这个函数内部工作的更多信息可以在 http://docs.opencv.org/modules/imgproc/doc/feature_detection.html?highlight=goodfeaturestotrack#goodfeaturestotrack 的文档页面中找到。

该函数接受以下输入:

  • 灰度图像
  • 一个点 2d 的 STL 向量,用来存储角点的位置(稍后会详细介绍 STL 向量)
  • 要返回的最大拐角数。如果算法检测到的角多于这个数量,则只返回最强的适当数量的角
  • 质量水平:拐角的最低可接受质量。拐角的质量被定义为在一个像素处的图像强度梯度矩阵的最小特征值,或者(如果使用哈里斯边角侦测)图像在该像素处对哈里斯函数的响应。有关更多详细信息,请阅读cornerHarris()cornerMinEigenVal()的文档
  • 两个返回角点位置之间的最小欧几里德距离
  • 一个标志,指示是使用哈里斯边角侦测还是最小特征值角检测器(默认为最小特征值)
  • 如果使用哈里斯边角侦测,调整哈里斯检测器的参数(关于该参数的用法,参见cornerHarris()的文档)

STL 是标准模板库的缩写,它提供了非常有用的数据结构,可以模板化成任何数据类型。其中一个数据结构是vector,我们将使用 OpenCV 的Point2dvector来存储角的位置。您可能还记得,Point2d是 OpenCV 存储一对整数值(通常是图像中一个点的位置)的方式。清单 5-6 显示了使用goodFeaturesToTrack()函数从图像中提取角点的代码,允许用户决定角点的最大数量。

清单 5-6。检测图像中的角点的程序

// Program to detect corners in an image

// Author: Samarth Manoj Brahmbhatt, University of Pennyslvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

#include <stdlib.h>

using namespace std;

using namespace cv;

Mat image, image_gray;

int max_corners = 20;

void on_slider(int, void *) {

if(image_gray.empty()) return;

max_corners = max(1, max_corners);

setTrackbarPos("Max no. of corners", "Corners", max_corners);

float quality = 0.01;

int min_distance = 10;

vector<Point2d> corners;

goodFeaturesToTrack(image_gray, corners, max_corners, quality, min_distance);

// Draw the corners as little circles

Mat image_corners = image.clone();

for(int i = 0; i < corners.size(); i++) {

circle(image_corners, corners[i], 4, CV_RGB(255, 0, 0), -1);

}

imshow("Corners", image_corners);

}

int main() {

image = imread("building.jpg");

cvtColor(image, image_gray, CV_RGB2GRAY);

namedWindow("Corners");

on_slider(0, 0);

createTrackbar("Max. no. of corners", "Corners", &max_corners, 250, on_slider);

while(char(waitKey(1)) != 'q') {}

return 0;

}

在这个应用中,我们通过一个滑块来改变可返回拐角的最大数量。观察我们如何使用circle()函数在角的位置绘制红色填充的小圆圈。app 产生的输出如图 5-11 所示。

A978-1-4302-6080-6_5_Fig11a_HTML.jpg A978-1-4302-6080-6_5_Fig11b_HTML.jpg

图 5-11。

Corners at different values of max_corners

物体探测器应用

我们的第一个对象检测程序将只使用颜色信息。事实上,它更像是一个颜色边界检查程序,而不是严格意义上的对象检测器,因为它不涉及机器学习。这个想法是为了解决我们在本章开始时讨论的问题——找出一个红色球的大致位置,并控制一个简单的轮式机器人拦截它。检测红球最简单的方法是查看图像中像素的 RGB 值是否与红球相对应,这就是我们将要开始的。我们也将在下一章继续学习新技术的同时努力改进这个应用。如果你已经解决了上一章的练习题,你就已经知道使用哪个 OpenCV 函数了。下面是清单 5-7,这是我们在目标检测方面的第一次尝试!

清单 5-7。简单的基于颜色的对象检测器

// Program to display a video from attached default camera device and detect colored blobs using simple // R G and B thresholding

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace cv;

using namespace std;

Mat frame, frame_thresholded;

int rgb_slider = 0, low_slider = 30, high_slider = 100;

int low_r = 30, low_g = 30, low_b = 30, high_r = 100, high_g = 100, high_b = 100;

void on_rgb_trackbar(int, void *) {

switch(rgb_slider) {

case 0:

setTrackbarPos("Low threshold", "Segmentation", low_r);

setTrackbarPos("High threshold", "Segmentation", high_r);

break;

case 1:

setTrackbarPos("Low threshold", "Segmentation", low_g);

setTrackbarPos("High threshold", "Segmentation", high_g);

break;

case 2:

setTrackbarPos("Low threshold", "Segmentation", low_b);

setTrackbarPos("High threshold", "Segmentation", high_b);

break;

}

}

void on_low_thresh_trackbar(int, void *) {

switch(rgb_slider) {

case 0:

low_r = min(high_slider - 1, low_slider);

setTrackbarPos("Low threshold", "Segmentation", low_r);

break;

case 1:

low_g = min(high_slider - 1, low_slider);

setTrackbarPos("Low threshold", "Segmentation", low_g);

break;

case 2:

low_b = min(high_slider - 1, low_slider);

setTrackbarPos("Low threshold", "Segmentation", low_b);

break;

}

}

void on_high_thresh_trackbar(int, void *) {

switch(rgb_slider) {

case 0:

high_r = max(low_slider + 1, high_slider);

setTrackbarPos("High threshold", "Segmentation", high_r);

break;

case 1:

high_g = max(low_slider + 1, high_slider);

setTrackbarPos("High threshold", "Segmentation", high_g);

break;

case 2:

high_b = max(low_slider + 1, high_slider);

setTrackbarPos("High threshold", "Segmentation", high_b);

break;

}

}

int main()

{

// Create a VideoCapture object to read from video file

// 0 is the ID of the built-in laptop camera, change if you want to use other camera

VideoCapture cap(0);

//check if the file was opened properly

if(!cap.isOpened())

{

cout << "Capture could not be opened succesfully" << endl;

return -1;

}

namedWindow("Video");

namedWindow("Segmentation");

createTrackbar("0\. R\n1\. G\n2.B", "Segmentation", &rgb_slider, 2, on_rgb_trackbar);

createTrackbar("Low threshold", "Segmentation", &low_slider, 255, on_low_thresh_trackbar);

createTrackbar("High threshold", "Segmentation", &high_slider, 255, on_high_thresh_trackbar);

while(char(waitKey(1)) != 'q' && cap.isOpened())

{

cap >> frame;

// Check if the video is over

if(frame.empty())

{

cout << "Video over" << endl;

break;

}

inRange(frame, Scalar(low_b, low_g, low_r), Scalar(high_b, high_g, high_r), frame_thresholded);

imshow("Video", frame);

imshow("Segmentation", frame_thresholded);

}

return 0;

}

图 5-12 显示程序检测一个橙色物体。

A978-1-4302-6080-6_5_Fig12_HTML.jpg

图 5-12。

Color-based object detector

观察我们如何使用锁定机制来确保较低的阈值永远不会高于较高的阈值,反之亦然。要使用此应用,首先将物体放在相机前。然后,将鼠标悬停在名为“视频”的窗口中的对象上,观察 R、G 和 B 值。最后,在“分段”窗口中适当调整范围。这个应用有很多缺点:

  • 它不能检测多种颜色的物体
  • 它高度依赖于照明
  • 它会对相同颜色的其他物体产生误报

但这是一个好的开始!

形态学打开和关闭图像以去除噪声

回想一下形态学腐蚀和膨胀的定义。开口是通过腐蚀图像,然后放大图像而获得的。它将具有移除图像中的小白色区域的效果。闭合是通过扩张图像然后腐蚀它来实现的;这将产生相反的效果。这两种操作经常用于从图像中去除噪声。打开会移除白色的小像素,而关闭会移除黑色的小“洞”我们的 object detector 应用是检查这一点的理想平台,因为我们在“分割”窗口中有一些白点和黑点形式的噪声,如图 5-12 所示。

OpenCV 函数morphologyEX()可用于执行高级形态学操作,如打开和关闭图像。因此,我们可以通过在前面的对象检测器代码的main()函数的while循环中添加三行来打开和关闭inRange()函数的输出,以移除黑点和白点。新的main()函数如清单 5-8 所示。

清单 5-8。向对象检测器代码添加打开和关闭步骤

int main()

{

// Create a VideoCapture object to read from video file

// 0 is the ID of the built-in laptop camera, change if you want to use other camera

VideoCapture cap(0);

//check if the file was opened properly

if(!cap.isOpened())

{

cout << "Capture could not be opened succesfully" << endl;

return -1;

}

namedWindow("Video");

namedWindow("Segmentation");

createTrackbar("0\. R\n1\. G\n2.B", "Segmentation", &rgb_slider, 2, on_rgb_trackbar);

createTrackbar("Low threshold", "Segmentation", &low_slider, 255, on_low_thresh_trackbar);

createTrackbar("High threshold", "Segmentation", &high_slider, 255, on_high_thresh_trackbar);

while(char(waitKey(1)) != 'q' && cap.isOpened())

{

cap >> frame;

// Check if the video is over

if(frame.empty())

{

cout << "Video over" << endl;

break;

}

inRange(frame, Scalar(low_b, low_g, low_r), Scalar(high_b, high_g, high_r), frame_thresholded);

Mat str_el = getStructuringElement(MORPH_RECT, Size(3, 3));

morphologyEx(frame_thresholded, frame_thresholded, MORPH_OPEN, str_el);

morphologyEx(frame_thresholded, frame_thresholded, MORPH_CLOSE, str_el);

imshow("Video", frame);

imshow("Segmentation", frame_thresholded);

}

return 0;

}

图 5-13 显示打开和关闭色彩绑定检查器输出确实可以去除斑点和孔洞。

A978-1-4302-6080-6_5_Fig13_HTML.jpg

图 5-13。

Removing small patches of noisy pixels by opening and closing

摘要

图像滤波是所有计算机视觉操作的基础。在抽象的意义上,应用于图像的每一种算法都可以被认为是一种过滤操作,因为您试图从图像中包含的大量不同种类的信息中提取一些相关的信息。在这一章中,你学习了很多基于过滤器的图像操作,这将有助于你开始许多复杂的计算机视觉项目。记住,只有当你从图像中提取出决定性的信息时,计算机视觉才是完整的。你还开发了简单的基于颜色的物体探测器应用,我们将在下一章继续。

本章讨论了许多低级算法,而下一章将更多地关注处理图像中区域的形式和结构的算法。

六、图像中的形状

Abstract

当我们看到物体时,形状是我们最先注意到的细节之一。本章将致力于赋予计算机这种能力。识别图像中的形状通常是做决定的重要一步。形状是由图像的轮廓定义的。因此,合乎逻辑的是,形状识别步骤通常在检测边缘或轮廓之后应用。

当我们看到物体时,形状是我们最先注意到的细节之一。本章将致力于赋予计算机这种能力。识别图像中的形状通常是做决定的重要一步。形状是由图像的轮廓定义的。因此,合乎逻辑的是,形状识别步骤通常在检测边缘或轮廓之后应用。

因此,我们将首先讨论从图像中提取轮廓。然后我们将开始讨论形状,包括:

  • 霍夫变换,这将使我们能够检测图像中的直线和圆形等规则形状
  • 随机抽样共识(RANSAC),一个广泛使用的框架,以确定符合特定模型的数据点。我们将为该算法编写代码,并用它来检测图像中的椭圆
  • 计算对象周围的包围盒、包围椭圆和凸包
  • 匹配形状

轮廓

轮廓和边缘有明显的区别。边缘是图像中亮度梯度的局部最大值(还记得上一章的 Scharr 边缘吗?).正如我们也看到的,这些梯度最大值并不都在物体的轮廓上,它们非常嘈杂。Canny 边缘有点不同,它们很像轮廓,因为它们在梯度最大值提取后经过了许多后处理步骤。相比之下,轮廓是一组相互连接的点,最有可能位于对象的轮廓上。

OpenCV 的轮廓提取对二进制图像(如 Canny 边缘检测的输出或应用于 Scharr 边缘或黑白图像的阈值)起作用,并提取边缘上连接点的层次结构。层次结构被组织成使得树中较高位置的轮廓更可能是物体的轮廓,而较低位置的轮廓可能是有噪声的边缘以及“洞”和有噪声的补片的轮廓。

实现这些功能的函数称为findContours(),它使用 s .铃木和 K. Abe 的论文“通过边界跟踪对数字化二进制图像进行拓扑结构分析”(发表于 1985 年版 CVGIP)中描述的算法来提取轮廓并将它们排列成层次结构。论文中描述了决定层次结构的确切规则集,但是简单地说,如果一个轮廓围绕着另一个轮廓,则该轮廓被认为是该轮廓的“父轮廓”。

为了实际展示我们所说的层次结构,我们将编写一个程序,如清单 6-1 所示,它使用我们最喜欢的工具滑块来选择要显示的层次结构的级别数。请注意,该函数只接受二进制图像作为输入。从普通图像获取二值图像的一些方法有:

  • 使用threshold()adaptiveThreshold()的阈值
  • 使用inRange()检查像素值的边界,就像我们对基于颜色的对象检测器所做的那样
  • 锐利的边缘
  • 阈值化沙尔边缘

清单 6-1。说明分层轮廓提取的程序

// Program to illustrate hierarchical contour extraction

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace std;

using namespace cv;

Mat img;

vector<vector<Point> > contours;

vector<Vec4i> heirarchy;

int levels = 0;

void on_trackbar(int, void *) {

if(contours.empty()) return;

Mat img_show = img.clone();

// Draw contours of the level indicated by slider

drawContours(img_show, contours, -1, Scalar(0, 0, 255), 3, 8, heirarchy, levels);

imshow("Contours", img_show);

}

int main() {

img = imread("circles.jpg");

Mat img_b;

cvtColor(img, img_b, CV_RGB2GRAY);

Mat edges;

Canny(img_b, edges, 50, 100);

// Extract contours and heirarchy

findContours(edges, contours, heirarchy, CV_RETR_TREE, CV_CHAIN_APPROX_NONE);

namedWindow("Contours");

createTrackbar("levels", "Contours", &levels, 15, on_trackbar);

// Initialize by drawing the top level contours (as 'levels' is initialized to 0)

on_trackbar(0, 0);

while(char(waitKey(1)) != 'q') {}

return 0;

}

注意每个轮廓是一个点的 STL 向量。因此,保存轮廓的数据结构是点的向量的向量。层次是四个整数向量的向量。对于每个轮廓,其层次位置由四个整数描述:它们是轮廓向量中基于 0 的索引,指示下一个(在同一级别)、上一个(在同一级别)、父轮廓和第一个子轮廓的位置。如果其中任何一个不存在(例如,如果一个轮廓没有父轮廓),相应的整数将是负的。还要注意函数drawContours()是如何根据层次和该层次的最大允许绘制级别,通过在输入图像上绘制轮廓来修改输入图像的(查阅函数文档)!

图 6-1 在一张方便的图片中显示了不同级别的轮廓。

A978-1-4302-6080-6_6_Fig1a_HTML.jpgA978-1-4302-6080-6_6_Fig1b_HTML.jpg

图 6-1。

Various levels of the hierarchy of contours

通常与findContours()一起使用的函数是approxPolyDP(). approxPolyDP()用另一条具有较少顶点的曲线逼近一条曲线或多边形,这样两条曲线之间的距离小于或等于指定的精度。您还可以选择闭合该近似曲线(即起点和终点相同)。

点多边形测试

我们绕道来描述一个有趣的特性:点多边形测试。正如您可能已经猜到的,函数pointPolygonTest()确定一个点是否在多边形内。如果设置了measureDist标志,它还会返回从轮廓上最近的点到该点的带符号欧几里德距离。如果点在曲线内,距离为正,如果在曲线外,距离为负,如果点在轮廓上,距离为零。如果标志关闭,带符号的距离会相应地被+1、1 和 0 取代。

让我们制作一个应用来展示我们在点多边形测试和闭合曲线近似方面的新知识,这是一个在图像中找到包围用户单击的点的最小闭合轮廓的应用。它还将展示等高线层次结构中的导航。代码如清单 6-2 所示。

清单 6-2。程序寻找围绕点击点的最小轮廓

// Program to find the smallest contour that surrounds the clicked point

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace std;

using namespace cv;

Mat img_all_contours;

vector<vector<Point> > closed_contours;

vector<Vec4i> heirarchy;

// Function to approximate contours by closed contours

vector<vector<Point> > make_contours_closed(vector<vector<Point> > contours) {

vector<vector<Point> > closed_contours;

closed_contours.resize(contours.size());

for(int i = 0; i < contours.size(); i++)

approxPolyDP(contours[i], closed_contours[i], 0.1, true);

return closed_contours;

}

// Function to return the index of smallest contour in 'closed_contours' surrounding the clicked point

int smallest_contour(Point p, vector<vector<Point> > contours, vector<Vec4i> heirarchy) {

int idx = 0, prev_idx = -1;

while(idx >= 0) {

vector<Point> c = contours[idx];

// Point-polgon test

double d = pointPolygonTest(c, p, false);

// If point is inside the contour, check its children for an even smaller contour...

if(d > 0) {

prev_idx = idx;

idx = heirarchy[idx][2];

}

// ...else, check the next contour on the same level

else idx = heirarchy[idx][0];

}

return prev_idx;

}

void on_mouse(int event, int x, int y, int, void *) {

if(event != EVENT_LBUTTONDOWN) return;

// Clicked point

Point p(x, y);

// Find index of smallest enclosing contour

int contour_show_idx = smallest_contour(p, closed_contours, heirarchy);

// If no such contour, user clicked outside all contours, hence clear image

if(contour_show_idx < 0) {

imshow("Contours", img_all_contours);

return;

}

// Draw the smallest contour using a thick red line

vector<vector<Point> > contour_show;

contour_show.push_back(closed_contours[contour_show_idx]);

if(!contour_show.empty()) {

Mat img_show = img_all_contours.clone();

drawContours(img_show, contour_show, -1, Scalar(0, 0, 255), 3);

imshow("Contours", img_show);

}

}

int main() {

Mat img = imread("circles.jpg");

img_all_contours = img.clone();

Mat img_b;

cvtColor(img, img_b, CV_RGB2GRAY);

Mat edges;

Canny(img_b, edges, 50, 100);

// Extract contours and heirarchy

vector<vector<Point> > contours;

findContours(edges, contours, heirarchy, CV_RETR_TREE, CV_CHAIN_APPROX_NONE);

// Make contours closed so point-polygon test is valid

closed_contours = make_contours_closed(contours);

// Draw all contours usign a thin green line

drawContours(img_all_contours, closed_contours, -1, Scalar(0, 255, 0));

imshow("Contours", img_all_contours);

// Mouse callback

setMouseCallback("Contours", on_mouse);

while(char(waitKey(1)) != 'q') {}

return 0;

}

注释应该帮助你理解我在代码中遵循的逻辑。需要一点关于导航轮廓层次的细节。如果idx是点的向量的向量中轮廓的索引,并且hierarchy是层级:

  • hierarchy[idx][0]将返回同一层次下一个轮廓的索引
  • hierarchy[idx[1]将返回前一个同级轮廓的索引
  • hierarchy[idx][2]将返回第一个子轮廓的索引
  • hierarchy[idx][3]将返回父轮廓的索引

如果这些等高线中的任何一个不存在,则返回的索引将是负的。

图 6-2 中显示了一些 app 在运行中的截图。

A978-1-4302-6080-6_6_Fig2a_HTML.jpgA978-1-4302-6080-6_6_Fig2b_HTML.jpg

图 6-2。

Smallest enclosing contour app

OpenCV 还提供了其他一些功能,可以通过检查轮廓的一些属性来帮助您过滤噪声图像中的轮廓。这些在表 6-1 中列出。

表 6-1。

Contour post-processing functions in OpenCV

| 功能 | 描述 | | --- | --- | | ArcLength() | 查找轮廓的长度 | | 轮廓区域() | 查找轮廓的面积和方向 | | BoundingRect() | 计算轮廓的垂直包围矩形 | | convexhull() | 计算轮廓周围的凸包 | | IsContourConvex() | 测试轮廓的凸性 | | MinAreaRect() | 计算轮廓周围最小面积的旋转矩形 | | MinEnclosingCircle() | 寻找包围轮廓的最小面积圆 | | FitLine() | 将直线(最小平方)拟合到轮廓 |

霍夫变换

霍夫变换将轮廓从 X-Y 空间变换到参数空间。然后,它使用目标曲线的某些参数属性(如直线或圆)来识别参数空间中适合目标曲线的点。例如,让我们考虑在应用于图像的边缘检测的输出中检测线条的问题。

用霍夫变换检测直线

2D 图像中的点可以用两种方式表示:

A978-1-4302-6080-6_6_Fig3_HTML.jpg

图 6-3。

The (r, theta) coordinate representation

  • (X, Y)坐标
  • (r, theta)坐标:r是距(0, 0)的距离,theta是距参考线的角度,通常是 x 轴。这种表示如图 6-3 所示。

这些坐标之间的关系是:

x*cos(theta) + y*sin(theta) = 2*r

如你所见,(r, theta)参数空间中的点(x, y)的曲线是正弦曲线。因此,笛卡尔空间中共线的点将对应于霍夫空间中的不同正弦曲线,这些正弦曲线将相交于一个公共点(r, theta)。这个(r,theta)点代表笛卡尔空间中通过所有这些点的一条线。为了给你一些例子,图 6-4 分别显示了一个点、一对点和五个点的霍夫空间表示。清单 6-3 中所示的 Matlab 代码用于生成图形。

清单 6-3。理解霍夫变换的 Matlab 代码

%% one point

theta = linspace(0, 2*pi, 500);

x = 1;

y = 1;

r = 0.5 * (x * cos(theta) + y * sin(theta));

figure; plot(theta, r);

xlabel('theta'); ylabel('r');

%% two points

theta = linspace(0, 2*pi, 500);

x = [1 3];

y = 2*x + 1;

r1 = 0.5 * (x(1) * cos(theta) + y(1) * sin(theta));

r2 = 0.5 * (x(2) * cos(theta) + y(2) * sin(theta));

figure; plot(theta, r1, theta, r2);

xlabel('theta'); ylabel('r');

%% five collinear points

theta = linspace(0, 2*pi, 500);

x = [1 3 5 7 9];

y = 2*x + 1;

figure; hold on;

r = zeros(numel(x), numel(theta));

for i = 1 : size(r, 1)

r(i, :) = 0.5 * (x(i) * cos(theta) + y(i) * sin(theta));

plot(theta, r(i, :));

end

xlabel('theta'); ylabel('r');

A978-1-4302-6080-6_6_Fig4a_HTML.jpgA978-1-4302-6080-6_6_Fig4b_HTML.jpg

图 6-4。

Hough transform of a point, a pair of points, and 5 points (top to bottom, clockwise), respectively

然后,我们可以通过以下策略检测线条:

  • 定义离散化霍夫空间的 2-D 矩阵,例如沿着行使用r值,沿着列使用theta
  • 对于边缘图像中的每个(x, y)点,使用等式找到可能(r, theta)值的列表,并递增霍夫空间矩阵(该矩阵也称为累加器)中的相应条目
  • 当您对所有边缘点执行此操作时,累加器中的某些(r, theta)值将具有高值。这些是线,因为每个(r, theta)点代表一条唯一的线

OpenCV 函数HoughLines()实施这一策略,并接受以下输入:

  • 二进制边缘图像(例如,Canny 边缘检测器的输出)
  • rtheta分辨率
  • 将(r, theta)点视为一条线的累加器阈值

利用霍夫变换检测圆

圆可以由三个参数表示:两个表示圆心,一个表示半径。因为圆的中心位于圆上每个点的法线上,所以我们可以使用以下策略:

  • 使用 2D 累加器(映射到图像上的点)为中心累积选票。对于每个边缘点,通过增加对应于沿法线的像素的累加器位置来投票。当你对圆上的所有像素都这样做时,因为中心位于所有的法线上,所以对中心的投票将开始增加。通过设定累加器的阈值,可以找到圆心
  • 在下一步中,您将为每个候选中心制作一个 1D 半径直方图,用于估计半径。属于围绕候选中心的圆的每个边缘点将投票选择几乎相同的半径(因为它们将与中心的距离几乎相同),而其他边缘点(可能属于围绕其他候选中心的圆)将投票选择其他伪半径

实现霍夫圆检测的 OpenCV 函数叫做HoughCircles()。它接受以下输入:

  • 灰度图像(在其上应用 Canny 边缘检测)
  • 累加器分辨率与图像分辨率的反比(用于检测圆心)。例如,如果设定为 2,累加器是图像大小的一半
  • 检测圆中心之间的最小距离。该参数可用于拒绝虚假中心
  • Canny 边缘检测器用于预处理输入图像的较高阈值(较低阈值设置为较高阈值的一半)
  • 累加器阈值
  • 最小和最大圆半径,也可以用来过滤掉嘈杂的小圆和虚假的大圆

图 6-5 显示了使用 Hough 变换的直线和圆检测,带有累加器阈值滑块和检测直线或圆选择开关。代码如清单 6-4 所示。

A978-1-4302-6080-6_6_Fig5a_HTML.jpgA978-1-4302-6080-6_6_Fig5b_HTML.jpgA978-1-4302-6080-6_6_Fig5c_HTML.jpg

图 6-5。

Circle and line detection at different accumulator thresholds using the Hough transform

清单 6-4。这个程序演示了使用霍夫变换检测直线和圆

// Program to illustrate line and circle detection using Hough transform

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace std;

using namespace cv;

Mat img;

int shape = 0; //0 -> lines, 1 -> circles

int thresh = 100; // Accumulator threshold

void on_trackbar(int, void *) { // Circles

if(shape == 1) {

Mat img_gray;

cvtColor(img, img_gray, CV_RGB2GRAY);

// Find circles

vector<Vec3f> circles;

HoughCircles(img_gray, circles, CV_HOUGH_GRADIENT, 1, 10, 100, thresh, 5);

// Draw circles

Mat img_show = img.clone();

for(int i = 0; i < circles.size(); i++) {

Point center(cvRound(circles[i][0]), cvRound(circles[i][1]));

int radius = cvRound(circles[i][2]);

// draw the circle center

circle(img_show, center, 3, Scalar(0, 0, 255), -1);

// draw the circle outline

circle(img_show, center, radius, Scalar(0, 0, 255), 3, 8, 0);

}

imshow("Shapes", img_show);

}

else if(shape == 0) { // Lines

Mat edges;

Canny(img, edges, 50, 100);

// Find lines

vector<Vec2f> lines;

HoughLines(edges, lines, 1, CV_PI/180.f, thresh);

// Draw lines

Mat img_show = img.clone();

for(int i = 0; i < lines.size(); i++) {

float rho = lines[i][0];

float theta = lines[i][1];

double a = cos(theta), b = sin(theta);

double x0 = a * rho, y0 = b * rho;

Point pt1(cvRound(x0 + 1000 * (-b)), cvRound(y0 + 1000 * (a)));

Point pt2(cvRound(x0 - 1000 * (-b)), cvRound(y0 - 1000 * (a)));

line(img_show, pt1, pt2, Scalar(0, 0, 255));

}

imshow("Shapes", img_show);

}

}

int main() {

img = imread("hough.jpg");

namedWindow("Shapes");

// Create sliders

createTrackbar("Lines/Circles", "Shapes", &shape, 1, on_trackbar);

createTrackbar("Acc. Thresh.", "Shapes", &thresh, 300, on_trackbar);

// Initialize window

on_trackbar(0, 0);

while(char(waitKey(1)) != 'q') {}

return 0;

}

广义霍夫变换

广义霍夫变换可用于检测图像中不遵循简单方程的不规则形状。理论上,任何形状都可以用一个方程来表示,但是回想一下,随着方程中参数数量的增加,所需累加器的维数也随之增加。维基百科的文章和 http://homepages.inf.ed.ac.uk/rbf/HIPR2/hough.htm 都是对广义霍夫变换的很好介绍。

随机样本一致性(RANSAC)

RANSAC 是一个强大的框架,用于查找符合特定模型的数据点。我们将在此考虑应用 RANSAC 的模型是椭圆的圆锥截面模型。RANSAC 对“异常值”——不符合给定模型的数据点——有很强的抵抗力。为了解释算法本身,让我们考虑在 2D 点的噪声数据集中寻找直线的问题。策略是:

  • 从数据集中随机采样点
  • 找出符合这些点的直线方程
  • 找到“内连线”——符合该线模型的点。为了确定一个点相对于一个模型是内点还是外点,我们需要一个距离的度量。这里,度量将是直线到点欧几里得距离。我们将决定该距离度量的值,该值充当用于内侧/外侧确定的阈值
  • 迭代,直到找到一行比预先确定的数目更多的内联体

听起来很简单,对吧?然而,正如您在椭圆检测示例中所观察到的那样,这种简单的机制对噪声非常鲁棒。在继续之前,您应该阅读:

  • 维基百科上关于 RANSAC 的文章,因为我们将使用其中概述的 RANSAC 算法框架,它有一些很好的可视化效果,可以帮助您对算法有一个直观的理解
  • Wolfram Alpha 关于椭圆的文章( http://mathworld.wolfram.com/Ellipse.html ),尤其是公式 15 到 23,我们将使用它们来计算椭圆的各种属性
  • 文章 http://nicky.vanforeest.com/misc/fitEllipse/fitEllipse.html ,描述了我们将使用的策略来拟合一个椭圆到一组点。理解这篇文章需要矩阵代数的知识,特别是特征值和特征向量

对于这个应用,我决定采用面向对象的策略,以利用 C++的实际能力。函数式编程(就像我们到目前为止一直在做的编程一样——为不同的任务使用函数)对于小型应用来说是可以的。然而,在代码可读性和策略形成方面,对较大的应用使用面向对象的策略是非常有利的,而且运行速度也一样快。该算法的流程如下:

  • 使用严格的阈值检测图像中的 Canny 边缘,并提取轮廓以避免虚假轮廓。使用功能arcLength()测量轮廓长度,剔除长度小于某个阈值的轮廓
  • 对每个轮廓进行一定次数的迭代,其中:
    • 从轮廓中随机选择一定数量的点,并拟合到这些点的椭圆上。根据距离阈值决定当前轮廓中该椭圆的内联体数量
    • 找到最适合当前轮廓的椭圆(具有最大数量的内曲线)。不应该一次使用轮廓中的所有点来拟合椭圆,因为轮廓可能有噪声,因此拟合轮廓中所有点的椭圆可能不是最佳的。通过随机选择点并多次这样做,可以给算法更多的机会找到最佳拟合
  • 对所有轮廓重复此过程,并找到具有最多内点的椭圆

为了实现该算法,我使用以下 RANSAC 参数:

  • 我们选择随机点的迭代次数。减小该值会减少找到表示椭圆的最佳随机点集的机会,而增大该参数会增加机会,但会导致算法花费更多的处理时间
  • 每次迭代中要考虑的随机点数。选择过低的值(如 4 或 5)将无法正确定义椭圆,而选择过高的值将增加从轮廓的不需要部分绘制点的机会。因此,必须小心调整该参数
  • 点与椭圆之间距离的阈值,用于将点视为椭圆的内侧
  • 要进一步处理的椭圆的最小内联数。这在拟合到每次迭代中选择的随机点的椭圆上进行检查。增加该参数会使算法更加严格

代码本身非常简单。它很长,我还写了一堆调试函数,您可以激活它们来查看一些调试图像(例如,算法选择的最佳轮廓,等等)。功能debug()允许您将椭圆拟合到特定轮廓。请注意,这是一次性使用轮廓中的所有点,结果可能不如前面讨论的随机点策略好。我们使用特征库来寻找特征值和特征向量,以将一个椭圆拟合到一组点上,因为未知的原因,OpenCV eigen()不能像预期的那样工作。清单 6-5 中的代码没有经过优化,所以可能需要一些时间来处理有很多轮廓的更大的图像。建议您使用尽可能严格的 Canny 阈值,以减少算法必须处理的轮廓数量。如果需要,您还可以更改 RANSAC 参数。

因为除了 OpenCV,我们还使用 Eigen 库,所以如果你没有它,你当然应该安装它(尽管大多数 Ubuntu 系统应该安装了 Eigen),并编译代码如下:

g++ -I/usr/include/eigen3 'pkg-config opencv --cflags' findEllipse.cpp -o findEllipse 'pkg-config opencv --libs'

你可以在终端中输入sudo apt-get install libeigen3-dev来安装 Eigen。

代码如清单 6-5 所示。

清单 6-5。使用 RANSAC 寻找最大椭圆的程序

// Program to find the largest ellipse using RANSAC

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <Eigen/Dense>

#include <opencv2/core/eigen.hpp>

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

#include <algorithm>

#include <math.h>

#define PI 3.14159265

using namespace std;

using namespace cv;

using namespace Eigen;

// Class to hold RANSAC parameters

class RANSACparams {

private:

//number of iterations

int iter;

//minimum number of inliers to further process a model

int min_inliers;

//distance threshold to be conted as inlier

float dist_thresh;

//number of points to select randomly at each iteration

int N;

public:

RANSACparams(int _iter, int _min_inliers, float _dist_thresh, int _N) { //constructor

iter = _iter;

min_inliers = _min_inliers;

dist_thresh = _dist_thresh;

N = _N;

}

int get_iter() {return iter;}

int get_min_inliers() {return min_inliers;}

float get_dist_thresh() {return dist_thresh;}

int get_N() {return N;}

};

// Class that deals with fitting an ellipse, RANSAC and drawing the ellipse in the image

class ellipseFinder {

private:

Mat img; // input image

vector<vector<Point> > contours; // contours in image

Mat Q; // Matrix representing conic section of detected ellipse

Mat fit_ellipse(vector<Point>); // function to fit ellipse to a contour

Mat RANSACellipse(vector<vector<Point> >); // function to find ellipse in contours using RANSAC

bool is_good_ellipse(Mat); // function that determines whether given conic section represents a valid ellipse

vector<vector<Point> > choose_random(vector<Point>); //function to choose points at random from contour

vector<float> distance(Mat, vector<Point>); //function to return distance of points from the ellipse

float distance(Mat, Point); //overloaded function to return signed distance of point from ellipse

void draw_ellipse(Mat); //function to draw ellipse in an image

vector<Point> ellipse_contour(Mat); //function to convert equation of ellipse to a contour of points

void draw_inliers(Mat, vector<Point>); //function to debug inliers

// RANSAC parameters

int iter, min_inliers, N;

float dist_thresh;

public:

ellipseFinder(Mat _img, int l_canny, int h_canny, RANSACparams rp) { // constructor

img = _img.clone();

// Edge detection and contour extraction

Mat edges; Canny(img, edges, l_canny, h_canny);

vector<vector<Point> > c;

findContours(edges, c, CV_RETR_LIST, CV_CHAIN_APPROX_NONE);

// Remove small spurious short contours

for(int i = 0; i < c.size(); i++) {

bool is_closed = false;

vector<Point> _c = c[i];

Point p1 = _c.front(), p2 = _c.back();

float d = sqrt(pow(p1.x - p2.x,2) + pow(p1.y - p2.y,2));

if(d <= 0.5) is_closed = true;

d = arcLength(_c, is_closed);

if(d > 50) contours.push_back(_c);

}

iter = rp.get_iter();

min_inliers = rp.get_min_inliers();

N = rp.get_N();

dist_thresh = rp.get_dist_thresh();

Q = Mat::eye(6, 1, CV_32F);

/*

//for debug

Mat img_show = img.clone();

drawContours(img_show, contours, -1, Scalar(0, 0, 255));

imshow("Contours", img_show);

//imshow("Edges", edges);

*/

cout << "No. of Contours = " << contours.size() << endl;

}

void detect_ellipse(); //final wrapper function

void debug(); //debug function

};

vector<float> ellipseFinder::distance(Mat Q, vector<Point> c) {

vector<Point> ellipse = ellipse_contour(Q);

vector<float> distances;

for(int i = 0; i < c.size(); i++) {

distances.push_back(float(pointPolygonTest(ellipse, c[i], true)));

}

return distances;

}

float ellipseFinder::distance(Mat Q, Point p) {

vector<Point> ellipse = ellipse_contour(Q);

return float(pointPolygonTest(ellipse, p, true));

}

vector<vector<Point> > ellipseFinder::choose_random(vector<Point> c) {

vector<vector<Point> > cr;

vector<Point> cr0, cr1;

// Randomly shuffle all elements of contour

std::random_shuffle(c.begin(), c.end());

// Put the first N elements into cr[0] as consensus set (see Wikipedia RANSAC algorithm) and

// the rest in cr[1] to check for inliers

for(int i = 0; i < c.size(); i++) {

if(i < N) cr0.push_back(c[i]);

else cr1.push_back(c[i]);

}

cr.push_back(cr0);

cr.push_back(cr1);

return cr;

}

Mat ellipseFinder::fit_ellipse(vector<Point> c) {

/*

// for debug

Mat img_show = img.clone();

vector<vector<Point> > cr;

cr.push_back(c);

drawContours(img_show, cr, -1, Scalar(0, 0, 255), 2);

imshow("Debug fitEllipse", img_show);

*/

int N = c.size();

Mat D;

for(int i = 0; i < N; i++) {

Point p = c[i];

Mat r(1, 6, CV_32FC1);

r = (Mat_<float>(1, 6) << (p.x)*(p.x), (p.x)*(p.y), (p.y)*(p.y), p.x, p.y, 1.f);

D.push_back(r);

}

Mat S = D.t() * D, _S;

double d = invert(S, _S);

//if(d < 0.001) cout << "S matrix is singular" << endl;

Mat C = Mat::zeros(6, 6, CV_32F);

C.at<float>(2, 0) = 2;

C.at<float>(1, 1) = -1;

C.at<float>(0, 2) = 2;

// Using EIGEN to calculate eigenvalues and eigenvectors

Mat prod = _S * C;

Eigen::MatrixXd prod_e;

cv2eigen(prod, prod_e);

EigenSolver<Eigen::MatrixXd> es(prod_e);

Mat evec, eval, vec(6, 6, CV_32FC1), val(6, 1, CV_32FC1);

eigen2cv(es.eigenvectors(), evec);

eigen2cv(es.eigenvalues(), eval);

evec.convertTo(evec, CV_32F);

eval.convertTo(eval, CV_32F);

// Eigen returns complex parts in the second channel (which are all 0 here) so select just the first channel

int from_to[] = {0, 0};

mixChannels(&evec, 1, &vec, 1, from_to, 1);

mixChannels(&eval, 1, &val, 1, from_to, 1);

Point maxLoc;

minMaxLoc(val, NULL, NULL, NULL, &maxLoc);

return vec.col(maxLoc.y);

}

bool ellipseFinder::is_good_ellipse(Mat Q) {

float a = Q.at<float>(0, 0),

b = (Q.at<float>(1, 0))/2,

c = Q.at<float>(2, 0),

d = (Q.at<float>(3, 0))/2,

f = (Q.at<float>(4, 0))/2,

g = Q.at<float>(5, 0);

if(b*b - a*c == 0) return false;

float thresh = 0.09,

num = 2 * (a*f*f + c*d*d + g*b*b - 2*b*d*f - a*c*g),

den1 = (b*b - a*c) * (sqrt((a-c)*(a-c) + 4*b*b) - (a + c)),

den2 = (b*b - a*c) * (-sqrt((a-c)*(a-c) + 4*b*b) - (a + c)),

a_len = sqrt(num / den1),

b_len = sqrt(num / den2),

major_axis = max(a_len, b_len),

minor_axis = min(a_len, b_len);

if(minor_axis < thresh*major_axis || num/den1 < 0.f || num/den2 < 0.f || major_axis > max(img.rows, img.cols)) return false;

else return true;

}

Mat ellipseFinder::RANSACellipse(vector<vector<Point> > contours) {

int best_overall_inlier_score = 0;

Mat Q_best = 777 * Mat::ones(6, 1, CV_32FC1);

int idx_best = -1;

//for each contour...

for(int i = 0; i < contours.size(); i++) {

vector<Point> c = contours[i];

if(c.size() < min_inliers) continue;

Mat Q;

int best_inlier_score = 0;

for(int j = 0; j < iter; j++) {

// ...choose points at random...

vector<vector<Point> > cr = choose_random(c);

vector<Point> consensus_set = cr[0], rest = cr[1];

// ...fit ellipse to those points...

Mat Q_maybe = fit_ellipse(consensus_set);

// ...check for inliers...

vector<float> d = distance(Q_maybe, rest);

for(int k = 0; k < d.size(); k++)

if(abs(d[k]) < dist_thresh) consensus_set.push_back(rest[k]);

// ...and find the random set with the most number of inliers

if(consensus_set.size() > min_inliers && consensus_set.size() > best_inlier_score) {

Q = fit_ellipse(consensus_set);

best_inlier_score = consensus_set.size();

}

}

// find cotour with ellipse that has the most number of inliers

if(best_inlier_score > best_overall_inlier_score && is_good_ellipse(Q)) {

best_overall_inlier_score = best_inlier_score;

Q_best = Q.clone();

if(Q_best.at<float>(5, 0) < 0) Q_best *= -1.f;

idx_best = i;

}

}

/*

//for debug

Mat img_show = img.clone();

drawContours(img_show, contours, idx_best, Scalar(0, 0, 255), 2);

imshow("Best Contour", img_show);

cout << "inliers " << best_overall_inlier_score << endl;

*/

if(idx_best >= 0) draw_inliers(Q_best, contours[idx_best]);

return Q_best;

}

vector<Point> ellipseFinder::ellipse_contour(Mat Q) {

float a = Q.at<float>(0, 0),

b = (Q.at<float>(1, 0))/2,

c = Q.at<float>(2, 0),

d = (Q.at<float>(3, 0))/2,

f = (Q.at<float>(4, 0))/2,

g = Q.at<float>(5, 0);

vector<Point> ellipse;

if(b*b - a*c == 0) {

ellipse.push_back(Point(0, 0));

return ellipse;

}

Point2f center((c*d - b*f)/(b*b - a*c), (a*f - b*d)/(b*b - a*c));

float num = 2 * (a*f*f + c*d*d + g*b*b - 2*b*d*f - a*c*g),

den1 = (b*b - a*c) * (sqrt((a-c)*(a-c) + 4*b*b) - (a + c)),

den2 = (b*b - a*c) * (-sqrt((a-c)*(a-c) + 4*b*b) - (a + c)),

a_len = sqrt(num / den1),

b_len = sqrt(num / den2),

major_axis = max(a_len, b_len),

minor_axis = min(a_len, b_len);

//angle of rotation of ellipse

float alpha = 0.f;

if(b == 0.f && a == c) alpha = PI/2;

else if(b != 0.f && a > c) alpha = 0.5 * atan2(2*b, a-c);

else if(b != 0.f && a < c) alpha = PI/2 - 0.5 * atan2(2*b, a-c);

// 'draw' the ellipse and put it into a STL Point vector so you can use drawContours()

int N = 200;

float theta = 0.f;

for(int i = 0; i < N; i++, theta += 2*PI/N) {

float x = center.x + major_axis*cos(theta)*cos(alpha) + minor_axis*sin(theta)*sin(alpha);

float y = center.y - major_axis*cos(theta)*sin(alpha) + minor_axis*sin(theta)*cos(alpha);

Point p(x, y);

if(x < img.cols && y < img.rows) ellipse.push_back(p);

}

if(ellipse.size() == 0) ellipse.push_back(Point(0, 0));

return ellipse;

}

void ellipseFinder::detect_ellipse() {

Q = RANSACellipse(contours);

cout << "Q" << Q << endl;

draw_ellipse(Q);

}

void ellipseFinder::debug() {

int i = 1; //index of contour you want to debug

cout << "No. of points in contour " << contours[i].size() << endl;

Mat a = fit_ellipse(contours[i]);

Mat img_show = img.clone();

drawContours(img_show, contours, i, Scalar(0, 0, 255), 3);

imshow("Debug contour", img_show);

draw_inliers(a, contours[i]);

draw_ellipse(a);

}

void ellipseFinder::draw_ellipse(Mat Q) {

vector<Point> ellipse = ellipse_contour(Q);

vector<vector<Point> > c;

c.push_back(ellipse);

Mat img_show = img.clone();

drawContours(img_show, c, -1, Scalar(0, 0, 255), 3);

imshow("Ellipse", img_show);

}

void ellipseFinder::draw_inliers(Mat Q, vector<Point> c) {

vector<Point> ellipse = ellipse_contour(Q);

vector<vector<Point> > cs;

cs.push_back(ellipse);

Mat img_show = img.clone();

// draw all contours in thin red

drawContours(img_show, contours, -1, Scalar(0, 0, 255));

// draw ellipse in thin blue

drawContours(img_show, cs, 0, Scalar(255, 0, 0));

int count = 0;

// draw inliers as green points

for(int i = 0; i < c.size(); i++) {

double d = pointPolygonTest(ellipse, c[i], true);

float d1 = float(d);

if(abs(d1) < dist_thresh) {

circle(img_show, c[i], 1, Scalar(0, 255, 0), -1);

count ++;

}

}

imshow("Debug inliers", img_show);

cout << "inliers " << count << endl;

}

int main() {

Mat img = imread("test4.jpg");

namedWindow("Ellipse");

// object holding RANSAC parameters, initialized using the constructor

RANSACparams rp(400, 100, 1, 5);

// Canny thresholds

int canny_l = 250, canny_h = 300;

// Ellipse finder object, initialized using the constructor

ellipseFinder ef(img, canny_l, canny_h, rp);

ef.detect_ellipse();

//ef.debug();

while(char(waitKey(1)) != 'q') {}

return 0;

}

图 6-6 显示了运行中的算法。

A978-1-4302-6080-6_6_Fig6a_HTML.jpgA978-1-4302-6080-6_6_Fig6b_HTML.jpg

图 6-6。

Finding the largest ellipse in the image using RANSAC

边界框和圆形

OpenCV 提供了计算包围一组点的最小面积的矩形或圆形的函数。该矩形可以是直立的(在这种情况下,它将不是具有包围点集的最小面积的矩形),也可以是旋转的(在这种情况下,它将是)。这些形状有两种用途:

  • OpenCV 中的许多高级特征检测功能接受一个直立矩形作为感兴趣区域(ROI)。ROI 通常用于加速计算。很多时候,我们希望使用计算密集型函数,例如立体块匹配函数,该函数给出左右图像的差异。然而,我们只对图像的某一部分感兴趣。通过指定适当的 ROI,可以告诉函数忽略图像的其他部分,从而不会在我们不感兴趣的图像部分浪费计算。
  • 人们可以使用这些边界框和圆的属性(例如,面积、长宽比)来粗略地推断物体的大小或物体离相机的距离。

函数minAreaRrect()计算旋转的矩形,minEnclosingCircle()计算圆,boundingRect()计算包围一组点的最小尺寸的直立矩形。这组点通常被指定为 STL 向量,但是你也可以使用 Mat 来完成。在清单 6-6 中,看看椭圆检测器代码中的draw_ellipse()的修改版本,了解如何使用这些函数。这个版本的应用不仅可以在图像中找到一个椭圆,还可以显示围绕椭圆的外接矩形和圆形,如图 6-7 所示。

清单 6-6。修改 draw_ellipse()以显示边界圆和矩形

void ellipseFinder::draw_ellipse(Mat Q) {

vector<Point> ellipse = ellipse_contour(Q);

vector<vector<Point> > c;

c.push_back(ellipse);

Mat img_show = img.clone();

//draw ellipse

drawContours(img_show, c, -1, Scalar(0, 0, 255), 3);

//compute bounding shapes

RotatedRect r_rect = minAreaRect(ellipse);

Rect rect = boundingRect(ellipse);

Point2f center; float radius; minEnclosingCircle(ellipse, center, radius);

//draw bounding shapes

rectangle(img_show, rect, Scalar(255, 0, 255)); //magenta

circle(img_show, center, radius, Scalar(255, 0, 0)); //blue

Point2f vertices[4]; r_rect.points(vertices);

for(int i = 0; i < 4; i++)

line(img_show, vertices[i], vertices[(i + 1) % 4], Scalar(0, 255, 0)); //green

imshow("Ellipse", img_show);

}

A978-1-4302-6080-6_6_Fig7_HTML.jpg

图 6-7。

Bounding circle and rectangles (upright and rotated) for the ellipse detected using RANSAC

凸包

一组 2D 点的凸包是包围所有点的最小凸闭合轮廓。因为它是凸的,所以连接凸壳上任意两点的线不与壳边界相交。因为它必须是最小的,所以一组点的凸包是这个集合的子集。OpenCV 函数convexHull()可以为一组点计算外壳。它以两种不同的形式给出其输出——构成外壳的点的输入轮廓的索引向量,或外壳点本身。OpenCV 有一个很好看的凸包演示,随机选择一组点,在它周围画出凸包。图 6-8 显示了它的作用。

A978-1-4302-6080-6_6_Fig8_HTML.jpg

图 6-8。

OpenCV convex hull demo

计算凸包的动机是为了得到最小可能的封闭轮廓,该轮廓是凸的并且包围了集合中的所有点。然后,您可以使用contourArea()arcLength()分别估算您的点集的面积和周长。

作为一个练习,你可能想从上一章中得到基于颜色的目标检测器代码,并修改它以得到红色物体的面积和周长。以下是您需要做的事情:

  • 在检测器的二进制输出中提取轮廓
  • 使用approxPolyDP()convexHull(). convexHull()将这些轮廓转换成封闭的轮廓可能会有些矫枉过正,而且会更慢。
  • 分别用contourArea()和/或arcLength()找到闭合轮廓的面积和/或周长,显示面积最大的轮廓,因为它最有可能是你想要的对象

摘要

这一章讲了很多关于处理图像中的形状。你可能已经注意到,我们现在已经转移到许多“更高”级别的算法,我不会过多地讨论较低像素级别的过程。这是因为我假设您已经仔细阅读了前面的章节!形状是识别图像中已知对象的简单方法。但是它们也容易出现很多错误,尤其是因为物体的一部分被另一个物体遮挡。另一种识别物体的方法,基于关键点特征的方法,解决了这个问题,我们将在下一章讨论基于关键点的物体识别。

同时,我想强调我在本章中介绍的椭圆检测程序的重要性。它很重要,因为它具有现实世界计算机视觉程序的一个重要特征——它不只是将一堆内置的 OpenCV 函数放在一起,而是使用它们来帮助你自己的本地算法。它也是面向对象的,这是编写大型程序的首选风格。如果您不理解该代码中的任何一行,请参考该函数的在线 OpenCV 文档和/或参考前面的章节。

七、图像分割和直方图

Abstract

欢迎来到第七章!在上一章讨论了形状和轮廓之后,我想谈谈图像分割这个非常重要的话题,这是当今吸引大量研究的最基本的计算机视觉问题之一。我们将讨论 OpenCV 已经准备好的一些分割算法,以及如何使用其他技术(如形态学操作)来定制自己的分割算法。

欢迎来到第七章!在上一章讨论了形状和轮廓之后,我想谈谈图像分割这个非常重要的话题,这是当今吸引大量研究的最基本的计算机视觉问题之一。我们将讨论 OpenCV 已经准备好的一些分割算法,以及如何使用其他技术(如形态学操作)来定制自己的分割算法。

具体来说,我们将从简单的分割技术开始——阈值分割,然后通过洪水填充、分水岭分割和 grabCut 分割逐步提高复杂度。我们还将在 floodFill 的基础上构建自己的分割算法,来计算照片中的对象。

在本章的最后,我还讨论了图像直方图及其应用,它们是有用的预处理步骤。

图象分割法

图像分割可以被定义为在视觉上分离图像的不同部分的过程。事实上,我们已经为基于颜色的对象检测应用做了一些基本的图像分割!在这一章中,你将学到一些技巧,帮助你大幅度提高物体探测器的应用。

现在,由于“视觉上”的不同是一种主观属性,可以随着手头的问题而改变,所以当分割能够为了期望的目的而适当地分割图像时,分割通常也被认为是正确的。例如,让我们考虑同一个图像(图 7-1 ),我们想要解决两个不同的问题——计数球和提取前景。正如您可能已经意识到的,这两个问题都是正确图像分割的问题。对于球的计数问题,我们需要一个分割算法,将图像分成所有视觉上不同的区域——背景、手和球。相反,对于另一个问题(提取前景的问题),将手和球视为一个单一区域是可以接受的。

A978-1-4302-6080-6_7_Fig1_HTML.jpg

图 7-1。

Image for two segmentation problems: Foreground extraction and counting objects

关于代码和语法,有一点需要注意:我现在将介绍函数并讨论使用它们的策略,不会在语法上花太多时间。这是因为我希望你查阅在线 OpenCV 文档( http://docs.opencv.org )来了解这些函数。此外,函数的引入之后通常是以一种建设性的方式使用该函数的代码。查看该用法示例将进一步帮助您理解该函数的语法。

通过阈值进行简单分割

最简单的分割策略之一是设定颜色值的阈值(这就是我们对基于颜色的对象检测器所做的)。OpenCV 有函数threshold()(阈值在一个Mat中,当超过阈值和遵循阈值时,有不同的操作选项——查看文档!)、inRange()(对Mat中的值应用低和高阈值)和adaptiveThreshold()(与threshold()相同,除了每个像素的阈值取决于其在补丁中的邻居的值,补丁的大小由用户定义)来帮助您设定阈值。到目前为止,我们在像素的 RGB 值中使用了inRange(),发现简单的 RGB 值对光照非常敏感。

然而,光照只是影响像素的强度。在 RBG 色彩空间中进行阈值处理的问题是,亮度信息由所有 R、G 和 B 共享(亮度= 30% R + 59% G + 11% B)。相比之下,HSV(色调、饱和度、值)色彩空间只对亮度进行编码,而 H 和 S 只对颜色信息进行编码。h 代表实际的颜色,而 S 代表它的“强度”或纯度。 http://www.dig.cs.gc.cuny.edu/manuals/Gimp2/Grokking-the-GIMP-v1.0/node51.html 如果你想在 HSV 色彩空间上多读点,是个好地方。这很棒,因为我们现在可以只对图像中的 H 和 S 通道进行阈值处理,并获得很多光照不变性。因此,清单 7-1 与我们上一个对象检测器代码相同,除了它首先使用cvtColor()将帧转换到 HSV 色彩空间,并设置 H 和 S 通道的阈值。图 7-2 显示了运行中的代码,但是你应该在不同的光照条件下进行实验,看看你得到了多少不变性。

清单 7-1。使用色调和饱和度阈值的基于颜色的对象检测

// Program to display a video from attached default camera device and detect colored blobs using H and S thresholding

// Remove noise using opening and closing morphological operations

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace cv;

using namespace std;

int hs_slider = 0, low_slider = 30, high_slider = 100;

int low_h = 30, low_s = 30, high_h = 100, high_s = 100;

void on_hs_trackbar(int, void *) {

switch(hs_slider) {

case 0:

setTrackbarPos("Low threshold", "Segmentation", low_h);

setTrackbarPos("High threshold", "Segmentation", high_h);

break;

case 1:

setTrackbarPos("Low threshold", "Segmentation", low_s);

setTrackbarPos("High threshold", "Segmentation", high_s);

break;

}

}

void on_low_thresh_trackbar(int, void *) {

switch(hs_slider) {

case 0:

low_h = min(high_slider - 1, low_slider);

setTrackbarPos("Low threshold", "Segmentation", low_h);

break;

case 1:

low_s = min(high_slider - 1, low_slider);

setTrackbarPos("Low threshold", "Segmentation", low_s);

break;

}

}

void on_high_thresh_trackbar(int, void *) {

switch(hs_slider) {

case 0:

high_h = max(low_slider + 1, high_slider);

setTrackbarPos("High threshold", "Segmentation", high_h);

break;

case 1:

high_s = max(low_slider + 1, high_slider);

setTrackbarPos("High threshold", "Segmentation", high_s);

break;

}

}

int main()

{

// Create a VideoCapture object to read from video file

// 0 is the ID of the built-in laptop camera, change if you want to use other camera

VideoCapture cap(0);

//check if the file was opened properly

if(!cap.isOpened())

{

cout << "Capture could not be opened succesfully" << endl;

return -1;

}

namedWindow("Video");

namedWindow("Segmentation");

createTrackbar("0\. H\n1\. S", "Segmentation", &hs_slider, 1, on_hs_trackbar);

createTrackbar("Low threshold", "Segmentation", &low_slider, 255, on_low_thresh_trackbar);

createTrackbar("High threshold", "Segmentation", &high_slider, 255, on_high_thresh_trackbar);

while(char(waitKey(1)) != 'q' && cap.isOpened())

{

Mat frame, frame_thresholded, frame_hsv;

cap >> frame;

cvtColor(frame, frame_hsv, CV_BGR2HSV);

// Check if the video is over

if(frame.empty())

{

cout << "Video over" << endl;

break;

}

// extract the Hue and Saturation channels

int from_to[] = {0,0, 1,1};

Mat hs(frame.size(), CV_8UC2);

mixChannels(&frame_hsv, 1, &hs, 1, from_to, 2);

// check the image for a specific range of H and S

inRange(hs, Scalar(low_h, low_s), Scalar(high_h, high_s), frame_thresholded);

// open and close to remove noise

Mat str_el = getStructuringElement(MORPH_ELLIPSE, Size(7, 7));

morphologyEx(frame_thresholded, frame_thresholded, MORPH_OPEN, str_el);

morphologyEx(frame_thresholded, frame_thresholded, MORPH_CLOSE, str_el);

imshow("Video", frame);

imshow("Segmentation", frame_thresholded);

}

return 0;

}

A978-1-4302-6080-6_7_Fig2a_HTML.jpgA978-1-4302-6080-6_7_Fig2b_HTML.jpg

图 7-2。

Object detection by Hue and Saturation range-checking

正如你在图 7-2 中看到的,我正试图探测一个蓝色的物体。由于 HSV 色彩空间的固有属性,如果您试图设置红色的范围,您将面临一些困难。色调通常表示为一个从 0 到 360 的圆圈(OpenCV 将这个数字减半以存储在CV_8U图像中),周围环绕着红色。这意味着红色的色调范围大约是> 340 和< 20,0 在 360 之后。所以你不能用我们正在使用的滑块处理来指定红色的整个色调范围。这是一个练习,让你弄清楚如何处理你的滑块值来解决这个问题。提示:由于色相减半,任何图像中最大可能的色相是 180。充分利用你拥有的额外空间(255–180 = 75)。

洪水泛滥

OpenCV 的floodFill()函数确定图像中与种子像素相似并与之相连的像素。在灰度图像的情况下,相似性通常由灰度级定义,而在彩色图像的情况下,由 RGB 值定义。该函数采用种子点的灰度(或 RGB)值周围的灰度(或 RGB)值范围,定义像素是否应被视为与种子像素相似。有两种方法可以确定一个像素是否与另一个像素相连:4-连通和 8-连通,从图 7-3 所示的例子中可以明显看出。

A978-1-4302-6080-6_7_Fig3_HTML.jpg

图 7-3。

4-connectivity (left) and 8-connectivity (right)

OpenCV floodFill 演示非常有趣。它允许您单击图像来指定种子点,并使用滑块来指定种子点周围的上限和下限范围,以定义相似性。

A978-1-4302-6080-6_7_Fig4_HTML.jpg

图 7-4。

OpenCV floodFill demo

可以使用以下策略利用floodFill()来自动化基于颜色的对象检测器应用:

  • 请用户点击该对象
  • 通过floodFill()获得到该点的相似连接像素
  • 将这个像素集合转换为 HSV,并从中决定使用inRange()检查的 H 和 S 值的范围

看看清单 7-2,这是我们现在的“智能的”基于颜色的物体探测器应用!

清单 7-2。程序使用洪水填补,使基于颜色的对象探测器“智能”

// Program to automate the color-based object detector using floodFill

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace cv;

using namespace std;

Mat frame_hsv, frame, mask;

int low_diff = 10, high_diff = 10, conn = 4, val = 255, flags = conn + (val << 8) + CV_FLOODFILL_MASK_ONLY;

double h_h = 0, l_h = 0, h_s = 0, l_s = 0;

bool selected = false;

void on_low_diff_trackbar(int, void *) {}

void on_high_diff_trackbar(int, void *) {}

void on_mouse(int event, int x, int y, int, void *) {

if(event != EVENT_LBUTTONDOWN) return;

selected = true;

//seed point

Point p(x, y);

// make mask using floodFill

mask = Scalar::all(0);

floodFill(frame, mask, p, Scalar(255, 255, 255), 0, Scalar(low_diff, low_diff, low_diff), Scalar(high_diff, high_diff, high_diff), flags);

// find the H and S range of piexels selected by floodFill

Mat channels[3];

split(frame_hsv, channels);

minMaxLoc(channels[0], &l_h, &h_h, NULL, NULL, mask.rowRange(1, mask.rows-1).colRange(1, mask.cols-1));

minMaxLoc(channels[1], &l_s, &h_s, NULL, NULL, mask.rowRange(1, mask.rows-1).colRange(1, mask.cols-1));

}

int main() {

// Create a VideoCapture object to read from video file

// 0 is the ID of the built-in laptop camera, change if you want to use other camera

VideoCapture cap(0);

//check if the file was opened properly

if(!cap.isOpened()) {

cout << "Capture could not be opened succesfully" << endl;

return -1;

}

namedWindow("Video");

namedWindow("Segmentation");

createTrackbar("Low Diff", "Segmentation", &low_diff, 50, on_low_diff_trackbar);

createTrackbar("High Diff ", "Segmentation", &high_diff, 50, on_high_diff_trackbar);

setMouseCallback("Video", on_mouse);

while(char(waitKey(1)) != 'q' && cap.isOpened()) {

cap >> frame;

if(!selected) mask.create(frame.rows+2, frame.cols+2, CV_8UC1);

// Check if the video is over

if(frame.empty()) {

cout << "Video over" << endl;

break;

}

cvtColor(frame, frame_hsv, CV_BGR2HSV);

// extract the hue and saturation channels

int from_to[] = {0,0, 1,1};

Mat hs(frame.size(), CV_8UC2);

mixChannels(&frame_hsv, 1, &hs, 1, from_to, 2);

// check for the range of H and S obtained from floodFill

Mat frame_thresholded;

inRange(hs, Scalar(l_h, l_s), Scalar(h_h, h_s), frame_thresholded);

// open and close to remove noise

Mat str_el = getStructuringElement(MORPH_RECT, Size(5, 5));

morphologyEx(frame_thresholded, frame_thresholded, MORPH_OPEN, str_el);

morphologyEx(frame_thresholded, frame_thresholded, MORPH_CLOSE, str_el);

imshow("Video", frame);

imshow("Segmentation", frame_thresholded);

}

return 0;

}

A978-1-4302-6080-6_7_Fig5a_HTML.jpg A978-1-4302-6080-6_7_Fig5b_HTML.jpg

图 7-5。

Using floodFill in the color-based object detector

分水岭分割

分水岭算法是分割具有相互接触的对象的图像的好方法,但是边缘对于精确分割来说不够强。考虑这样一个例子:一箱水果的俯视图,以及分割单个水果来计数的问题。如图 7-6 所示,在严格阈值下的 Canny 边缘仍然噪声过大。将霍夫圆(对圆的最小半径有限制)拟合到这些边上显然是行不通的,正如您在同一张图中所看到的。

A978-1-4302-6080-6_7_Fig6a_HTML.jpgA978-1-4302-6080-6_7_Fig6b_HTML.jpgA978-1-4302-6080-6_7_Fig6c_HTML.jpg

图 7-6。

(clockwise, from top left) original image, Canny edges with a tight threshold and attempts to fit circles to the image at different Hough accumulator thresholds

现在让我们看看分水岭分割如何帮助我们。它是这样工作的:

  • 使用一些函数来决定图像中像素的“高度”,例如灰度级
  • 重构图像以将所有区域最小值强制到相同的水平。因此“盆地”在极小值周围形成
  • 从该深度结构的最低点开始向其注入液体,直到液体到达该结构的最高点。只要两个盆地的液体相遇,就会建造一道“堤坝”或“分水线”来防止它们混合
  • 该过程完成后,盆地作为图像的不同区域返回,而分水岭线是区域的边界

OpenCV 函数watershed()实现了标记控制的分水岭分割,这让事情变得简单了一些。用户给定某些标记作为算法的输入,该算法首先重建图像以仅在这些标记点处具有局部最小值。该过程的其余部分如前所述继续进行。OpenCV 分水岭演示让用户在图像上标记这些标记(图 7-7 )。

A978-1-4302-6080-6_7_Fig7_HTML.jpg

图 7-7。

OpenCV watershed demo—marker-based watershed segmentation

在对象计数器应用中使用分水岭变换的主要挑战是通过代码计算出标记,而不是让人工输入它们。清单 7-3 中使用了下面的策略,它使用了很多你以前学过的形态学运算:

A978-1-4302-6080-6_7_Fig12_HTML.jpg

图 7-12。

Eroding the previous image twice completely isolates the objects

  • 腐蚀两次以分离掩模中的一些区域(图 7-12

A978-1-4302-6080-6_7_Fig11_HTML.jpg

图 7-11。

Adaptively thresholding the previous image (almost) isolates the different objects

  • 应用自适应阈值创建一个二进制蒙版,在前景对象区域为 255,否则为 0(图 7-11 )

A978-1-4302-6080-6_7_Fig10_HTML.jpg

图 7-10。

Opening and closing with a relatively large circular structuring element highlights foreground objects

  • 打开和关闭以突出显示前景对象(图 7-10

A978-1-4302-6080-6_7_Fig9_HTML.jpg

图 7-9。

Dilating the image removes black spots

  • 放大图像以去除小黑点(图 7-9

A978-1-4302-6080-6_7_Fig8_HTML.jpg

图 7-8。

Histogram equalization improves image contrast

  • 均衡图像的灰度直方图以提高对比度,如图 7-8 所示。我们将在本章的后面讨论这是如何工作的。现在,只要记住它提高了图像的对比度,可以通过使用equalizeHist()来实现

现在,我们可以在watershed()功能中将该遮罩中的区域用作标记。但在此之前,这些区域必须用正整数标注。我们通过检测轮廓,然后使用连续增加的整数通过drawContours()填充这些轮廓来做到这一点。图 7-13 显示了分水岭变换的最终输出。请注意我从 OpenCV 分水岭演示代码中借用的一些小技巧,这些技巧用于给区域着色并透明地显示它们。清单 7-3 显示了实际的程序。

清单 7-3。使用形态学操作和分水岭变换计算对象数量的程序

// Program to count the number of objects using morphology operations and the watershed transform

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace cv;

using namespace std;

class objectCounter {

private:

Mat image, gray, markers, output;

int count;

public:

objectCounter(Mat); //constructor

void get_markers(); //function to get markers for watershed segmentation

int count_objects(); //function to implement watershed segmentation and count catchment basins

};

objectCounter::objectCounter(Mat _image) {

image = _image.clone();

cvtColor(image, gray, CV_BGR2GRAY);

imshow("image", image);

}

void objectCounter::get_markers() {

// equalize histogram of image to improve contrast

Mat im_e; equalizeHist(gray, im_e);

//imshow("im_e", im_e);

// dilate to remove small black spots

Mat strel = getStructuringElement(MORPH_ELLIPSE, Size(9, 9));

Mat im_d; dilate(im_e, im_d, strel);

//imshow("im_d", im_d);

// open and close to highlight objects

strel = getStructuringElement(MORPH_ELLIPSE, Size(19, 19));

Mat im_oc; morphologyEx(im_d, im_oc, MORPH_OPEN, strel);

morphologyEx(im_oc, im_oc, MORPH_CLOSE, strel);

//imshow("im_oc", im_oc);

// adaptive threshold to create binary image

Mat th_a; adaptiveThreshold(im_oc, th_a, 255, ADAPTIVE_THRESH_MEAN_C, THRESH_BINARY, 105, 0);

//imshow("th_a", th_a);

// erode binary image twice to separate regions

Mat th_e; erode(th_a, th_e, strel, Point(-1, -1), 2);

//imshow("th_e", th_e);

vector<vector<Point> > c, contours;

vector<Vec4i> hierarchy;

findContours(th_e, c, hierarchy, CV_RETR_CCOMP, CV_CHAIN_APPROX_NONE);

// remove very small contours

for(int idx = 0; idx >= 0; idx = hierarchy[idx][0])

if(contourArea(c[idx]) > 20) contours.push_back(c[idx]);

cout << "Extracted " << contours.size() << " contours" << endl;

count = contours.size();

markers.create(image.rows, image.cols, CV_32SC1);

for(int idx = 0; idx < contours.size(); idx++)

drawContours(markers, contours, idx, Scalar::all(idx + 1), -1, 8);

}

int objectCounter::count_objects() {

watershed(image, markers);

// colors generated randomly to make the output look pretty

vector<Vec3b> colorTab;

for(int i = 0; i < count; i++) {

int b = theRNG().uniform(0, 255);

int g = theRNG().uniform(0, 255);

int r = theRNG().uniform(0, 255);

colorTab.push_back(Vec3b((uchar)b, (uchar)g, (uchar)r));

}

// watershed output image

Mat wshed(markers.size(), CV_8UC3);

// paint the watershed output image

for(int i = 0; i < markers.rows; i++)

for(int j = 0; j < markers.cols; j++) {

int index = markers.at<int>(i, j);

if(index == -1)

wshed.at<Vec3b>(i, j) = Vec3b(255, 255, 255);

else if(index <= 0 || index > count)

wshed.at<Vec3b>(i, j) = Vec3b(0, 0, 0);

else

wshed.at<Vec3b>(i, j) = colorTab[index - 1];

}

// superimpose the watershed image with 50% transparence on the grayscale original image

Mat imgGray; cvtColor(gray, imgGray, CV_GRAY2BGR);

wshed = wshed*0.5 + imgGray*0.5;

imshow("Segmentation", wshed);

return count;

}

int main() {

Mat im = imread("fruit.jpg");

objectCounter oc(im);

oc.get_markers();

int count = oc.count_objects();

cout << "Counted " << count << " fruits." << endl;

while(char(waitKey(1)) != 'q') {}

return 0;

}

A978-1-4302-6080-6_7_Fig13_HTML.jpg

图 7-13。

Segmentation after the watershed transform

GrabCut 分割

GrabCut 是一种基于图形的分割方法,允许您在感兴趣的对象周围指定一个矩形,然后尝试将对象从图像中分割出来。虽然我将把对该算法的讨论限制在 OpenCV 演示中,但是您可以查看 c .罗泽尔、V. Kolmogorov 和 A. Blake 关于该算法的论文“GrabCut:使用迭代图切割的交互式前景提取”。OpenCV 演示程序允许您运行该算法的多次迭代,每次迭代都会产生更好的结果。请注意,您指定的初始矩形必须尽可能紧密。

A978-1-4302-6080-6_7_Fig14a_HTML.jpg A978-1-4302-6080-6_7_Fig14b_HTML.jpg

图 7-14。

OpenCV GrabCut demo

直方图

直方图是一种简单而强大的表示数据分布的方式。如果你不知道直方图是什么,我建议你去维基百科页面看看。在图像中,最简单的直方图可以由图像所有像素的灰度级(或 R、G 或 B 值)构成。这将使您能够发现图像数据分布的总体趋势。例如,在暗图像中,直方图的大部分峰值位于 0 到 255 范围的较低部分。

均衡直方图

直方图最简单的应用是归一化亮度和提高图像的对比度。这是通过首先从直方图中阈值化出非常低的值,然后“拉伸”它,使得直方图占据整个 0 到 255 范围来实现的。一个非常有效的方法是:

  • 根据原始图像src制作新图像dst,如下所示:

  • 计算直方图并将其归一化,使直方图中所有元素的总和为 255

  • 计算直方图的累积和:

OpenCV 函数equalizeHist()实现了这一点。清单 7-4 是一个小程序,它可以向你展示普通图像和直方图均衡化图像的区别。您也可以将equalizeHist()应用于 RGB 图像,方法是将它分别应用于所有三个通道。图 7-15 展示了直方图均衡化的魔力!

清单 7-4。说明直方图均衡化的程序

// Program to illustrate histogram equalization in RGB images

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace cv;

using namespace std;

Mat image, image_eq;

int choice = 0;

void on_trackbar(int, void*) {

if(choice == 0) // normal image

imshow("Image", image);

else // histogram equalized image

imshow("Image", image_eq);

}

int main() {

image = imread("scene.jpg");

image_eq.create(image.rows, image.cols, CV_8UC3);

//separate channels, equalize histograms and them merge them

vector<Mat> channels, channels_eq;

split(image, channels);

for(int i = 0; i < channels.size(); i++) {

Mat eq;

equalizeHist(channels[i], eq);

channels_eq.push_back(eq);

}

merge(channels_eq, image_eq);

namedWindow("Image");

createTrackbar("Normal/Eq.", "Image", &choice, 1, on_trackbar);

on_trackbar(0, 0);

while(char(waitKey(1)) != 'q') {}

return 0;

}

A978-1-4302-6080-6_7_Fig15a_HTML.jpg A978-1-4302-6080-6_7_Fig15b_HTML.jpg

图 7-15。

Histogram equalization

直方图反投影

直方图反投影是计算直方图的反向过程。假设你已经有了一个直方图。您可以通过将该图像的每个像素值替换为像素值所在面元的直方图值,将其反投影到另一个图像中。这有效地填充了图像中与直方图分布匹配的高值区域和其余的小值区域。OpenCV 函数calcHist()calcBackProject()可以分别用于计算直方图和反投影直方图。为了给你一个使用它们的例子,清单 7-5 是对自动目标检测器的修改:

  • 它允许用户点击窗口中的对象
  • 它使用floodFill()计算相连的相似点
  • 计算floodFill()所选点的色调和饱和度直方图
  • 它将这个直方图反投影到连续的视频帧中

图 7-16 显示了背投应用的运行情况。

清单 7-5。说明直方图反投影的程序

// Program to illustrate histogram backprojection

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/imgproc/imgproc.hpp>

using namespace cv;

using namespace std;

Mat frame_hsv, frame, mask;

MatND hist; //2D histogram

int conn = 4, val = 255, flags = conn + (val << 8) + CV_FLOODFILL_MASK_ONLY;

bool selected = false;

// hue and saturation histogram ranges

float hrange[] = {0, 179}, srange[] = {0, 255};

const float *ranges[] = {hrange, srange};

void on_mouse(int event, int x, int y, int, void *) {

if(event != EVENT_LBUTTONDOWN) return;

selected = true;

// floodFill

Point p(x, y);

mask = Scalar::all(0);

floodFill(frame, mask, p, Scalar(255, 255, 255), 0, Scalar(10, 10, 10), Scalar(10, 10, 10), flags);

Mat _mask = mask.rowRange(1, mask.rows-1).colRange(1, mask.cols-1);

// number of bins in the histogram for each channel

int histSize[] = {50, 50}, channels[] = {0, 1};

// calculate and normalize histogram

calcHist(&frame_hsv, 1, channels, _mask, hist, 2, histSize, ranges);

normalize(hist, hist, 0, 255, NORM_MINMAX, -1, Mat());

}

int main() {

// Create a VideoCapture object to read from video file

// 0 is the ID of the built-in laptop camera, change if you want to use other camera

VideoCapture cap(0);

//check if the file was opened properly

if(!cap.isOpened()) {

cout << "Capture could not be opened succesfully" << endl;

return -1;

}

namedWindow("Video");

namedWindow("Backprojection");

setMouseCallback("Video", on_mouse);

while(char(waitKey(1)) != 'q' && cap.isOpened()) {

cap >> frame;

if(!selected) mask.create(frame.rows+2, frame.cols+2, CV_8UC1);

// Check if the video is over

if(frame.empty()) {

cout << "Video over" << endl;

break;

}

cvtColor(frame, frame_hsv, CV_BGR2HSV);

// backproject on the HSV image

Mat frame_backprojected = Mat::zeros(frame.size(), CV_8UC1);

if(selected) {

int channels[] = {0, 1};

calcBackProject(&frame_hsv, 1, channels, hist, frame_backprojected, ranges);

}

imshow("Video", frame);

imshow("Backprojection", frame_backprojected);

}

return 0;

}

A978-1-4302-6080-6_7_Fig16a_HTML.jpg A978-1-4302-6080-6_7_Fig16b_HTML.jpg

图 7-16。

Histogram backprojection

Meanshift 和 Camshift

Meanshift 是一种在背投直方图图像中查找对象的算法。它将初始搜索窗口作为输入,然后迭代地移动该搜索窗口,使得该窗口内的反投影的质心位于该窗口的中心。

Camshift 是一种基于直方图反投影的对象跟踪算法,其核心使用 meanshift。它采用 meanshift 输出的检测窗口,并计算出该窗口的最佳大小和旋转来跟踪对象。OpenCV 函数meanShift()CamShift()实现了这些算法,你可以在内置演示中看到 camshift 的 OpenCV 实现。

A978-1-4302-6080-6_7_Fig17a_HTML.jpg A978-1-4302-6080-6_7_Fig17b_HTML.jpg

图 7-17。

OpenCV camshift demo

摘要

本章将使你了解 OpenCV 提供的分割算法。但是我真正想让你从本章学到的是如何结合你的图像处理知识来构建你自己的分割算法,就像我们对水果计数器应用所做的那样。这是因为,正如我在本章开始时所说的,分段对于不同的问题有不同的定义,它需要你有创造性。直方图均衡化和反投影可能是一些高级算法的重要预处理步骤,所以不要忘记它们!

八、基于关键点的基本机器学习和对象检测

Abstract

在这激动人心的一章中,我计划讨论以下内容:

在这激动人心的一章中,我计划讨论以下内容:

  • 一些通用术语,包括关键点和关键点描述符的定义
  • OpenCV 引以为豪的一些最先进的关键点提取和描述算法,包括 SIFT、SURF、FAST、BRIEF 和 ORB
  • 基本机器学习概念和支持向量机的工作原理(SVM)
  • 我们将使用我们的知识来制作一个基于关键点的对象检测器应用,该应用使用机器学习来使用视觉单词包框架实时检测多个对象。这个应用将对场景中的光照不变性和杂波具有鲁棒性

关键点和关键点描述符:简介和术语

到目前为止,我们一直在开发的基于颜色的对象检测器应用还有很多不足之处。基于颜色(或灰度强度)检测对象有两个明显的缺陷:

  • 仅适用于单色对象。你可以反投影一个纹理物体的色调直方图,但是这很可能包括很多颜色,这将导致大量的误报
  • 会被相同颜色的不同物体所迷惑

但是基于颜色的目标检测非常快。这使得它非常适合环境受到严格控制的应用,但在未知环境中几乎没有用。我将给你们举一个我在宾夕法尼亚大学机器人足球世界杯人形足球队工作时的例子。该比赛是关于让一个由三个人形机器人组成的团队与其他类似的机器人团队进行 100%自主足球比赛。这些机器人需要快速的球、场、场线和球门柱检测,以便有效地比赛,由于规则要求所有这些对象都具有特定的唯一纯色,我们使用基于颜色的对象检测,通过一些逻辑检查进行验证(如球高度和球门柱高宽比)。基于颜色的对象检测在这里工作得很好,因为环境是受控的,对象都是唯一的纯色。但是,如果你想设计一个搜索和救援机器人的视觉系统,你显然不应该依赖颜色来检测物体,因为你不知道你的机器人的工作环境会是什么样子,你感兴趣的物体可以由任意多种颜色组成。有了这个动机,让我们来学习关键点和关键点描述符是什么意思!

泛称

在目标检测问题中,你通常有两组图像。训练集将用于向计算机显示所需对象的外观。这可以通过计算色调直方图(正如您已经知道的)或通过计算关键点和关键点描述符(正如您将很快了解的)来完成。显然,优选的是,训练图像或者被注释(感兴趣的对象的位置由边界框指定),或者仅包含感兴趣的对象而没有其他内容。测试集包含您的算法将被使用的图像——简而言之,您的应用的用例。

基于关键点的方法是如何工作的?

这种方法的理念是,不应该让计算机“学习”整个对象模板的特征(像计算充满洪水的点的直方图)并在其他图像中寻找类似的实例。相反,应该在对象模板中找到某些“重要”点(关键点),并将关于这些关键点的邻域的信息(关键点描述符)存储为对象的描述。在其他测试图像中,应该找到整个图像中关键点的关键点描述符,并尝试使用某种相似性概念来“匹配”两个描述符集(一个来自对象模板,一个来自测试图像),并查看有多少描述符匹配。对于包含对象模板中的对象实例的测试图像,您将获得许多匹配,并且这些匹配将具有规则的趋势。图 8-1 将帮助您更好地理解这一点。它显示了一个训练和测试图像,每个图像都有其 SIFT(尺度不变特征变换-一种著名的关键点提取和描述算法)关键点,以及两幅图像之间的匹配描述符。查看所有匹配是如何遵循一个趋势的。

A978-1-4302-6080-6_8_Fig1_HTML.jpg

图 8-1。

SIFT keypoint and feature matching

看起来很酷?下一节描述 SIFT 是如何工作的。

筛选关键点和描述符

SIFT 可以说是当今最著名和最广泛实现的关键点检测和描述算法。这就是我选择首先介绍它的原因,希望能够介绍一些与关键点检测和描述相关的其他概念。你可以在 David G. Lowe 的经典论文“来自尺度不变关键点的独特图像特征”中获得该算法的非常详细的描述。对于所有其他算法,我将把讨论限制在该方法的要点上,并提供它们所基于的论文的参考。

关键点描述符通常也称为特征。使用 SIFT 的目标检测是尺度和旋转不变的。这意味着该算法将检测以下对象:

  • 与训练图像相比,在测试图像中具有相同的外观但是更大或更小的尺寸(比例不变性)
  • 相对于训练对象旋转(大约垂直于图像的比例)
  • 表现出这两个条件的组合(旋转不变性)

这是因为 SIFT 提取的关键点具有与其相关联的比例和方向。术语“比例”需要一些解释,因为它在计算机视觉文献中被广泛使用。比例指的是物体被看到时的大小,或者相机离物体有多远。规模也几乎总是相对的。更高的比例意味着对象看起来更小(因为相机移动得更远),反之亦然。更高的比例通常通过使用高斯内核平滑图像来实现(还记得gaussianBlur()?)和下采样。通过平滑和上采样的类似过程来实现较低的比例。这就是为什么比例也通常由用于实现它的高斯核的方差来指定(对于当前比例σ = 1)。

关键点检测和方向估计

关键点可以像边角一样简单。然而,因为 SIFT 要求关键点具有与其相关联的比例和方向,并且因为一些一旦知道如何构造关键点描述符就将清楚的效率原因,SIFT 使用以下方法来计算图像中的关键点位置:

A978-1-4302-6080-6_8_Fig5_HTML.jpg

图 8-5。

Square patch around a keypoint location and gradient orientations with gradient magnitudes (From “Distinctive Image Features from Scale-Invariant Keypoints,” David G. Lowe. Reprinted by permission of Springer Science+Business Media.)

  • 通过使用一些巧妙的数学方法(如果你阅读了论文的第四部分,你就会明白)检查它们是否具有足够的对比度并且它们不是边缘的一部分,从而进一步过滤这样计算出的关键点位置(具有标度)
  • 为了给关键点分配方向,使用它们的比例来选择具有最接近比例的高斯平滑图像(所有这些我们已经计算并存储用于构建狗金字塔),从而以比例不变的方式执行所有计算。在图像(图 8-5 )中选择关键点周围的一个正方形区域,该区域中每一点的梯度方向由以下等式计算(L 为高斯平滑图像)

A978-1-4302-6080-6_8_Fig4_HTML.jpg

图 8-4。

Maxima and minima selection by comparison with 26 neighbors (From “Distinctive Image Features from Scale-Invariant Keypoints,” David G. Lowe. Reprinted by permission of Springer Science+Business Media.)

  • Lowe 在他的论文中已经用数学方法(通过热扩散方程的解)表明,应用 DoG 算子的输出与应用于图像的拉普拉斯高斯算子的输出的近似直接成比例。经历整个过程的要点在于,文献中已经示出,应用于图像的拉普拉斯高斯算子的输出的最大值和最小值是比通过传统的基于梯度的方法获得的那些更稳定的关键点。将高斯算子的拉普拉斯应用于图像涉及在与高斯核卷积之后的二阶微分,而这里我们在应用高斯核之后仅使用图像差异来获得狗金字塔(其与对数金字塔非常接近)。如图 8-4 所示,通过将一个像素与其在金字塔中的 26 个邻居进行比较,获得狗金字塔的最大值和最小值。仅当一个点高于或低于其所有 26 个相邻点时,该点才被选为关键点。这种检查的成本相当低,因为大多数点将在最初的几次检查中被消除。关键点的比例是两个高斯分布中较小的方差的平方根,这两个高斯分布用于产生狗金字塔的特定级别。

A978-1-4302-6080-6_8_Fig3_HTML.jpg

图 8-3。

Difference of Gaussians Pyramid (From “Distinctive Image Features from Scale-Invariant Keypoints,” David G. Lowe. Reprinted by permission of Springer Science+Business Media.)

  • 这个金字塔中的连续图像彼此相减。生成的图像被认为是原始图像上高斯差分(DoG)算子的输出,如图 8-3 所示。

A978-1-4302-6080-6_8_Fig2_HTML.jpg

图 8-2。

Scale Pyramid (From “Distinctive Image Features from Scale-Invariant Keypoints,” David G. Lowe. Reprinted by permission of Springer Science+Business Media.)

  • 它将图像与方差连续增加的高斯图像进行卷积,从而增加比例(并且每当比例增加一倍时,缩减采样因子为 2;即高斯的方差增加了 4 倍)。这样,就形成了一个“比例金字塔”,如图 8-2 所示。记住,方差为σ 2 的 2-D 高斯函数由下式给出:

使用具有 36 个 10 度仓的方向直方图来收集这些方向。梯度方向对直方图的贡献根据等于关键点尺度的 1.5 倍的σ的高斯核来加权。直方图中的峰值对应于关键点的方向。如果有多个峰值(最高峰值的 80%以内的值),则在同一位置以多个方向创建多个关键点。

筛选关键点描述符

每个 SIFT 关键点都有一个与之关联的 128 元素描述符。图 8-5 所示的正方形区域被分成 16 个相等的块(图中只显示了四个块)。对于每个块,梯度方向被编入八个仓中,贡献等于梯度幅度和高斯核的乘积,σ等于正方形宽度的一半。为了实现旋转不变性,正方形区域中的点的坐标和这些点处的梯度方向相对于关键点的方向旋转。图 8-6 显示了四个区块的四个这样的八面元直方图(箭头的长度表示相应面元的值)。因此,16 个八箱直方图构成了 128 元素的 SIFT 描述符。最后,128 元素向量被归一化为单位长度,以提供照明不变性。

A978-1-4302-6080-6_8_Fig6_HTML.jpg

图 8-6。

Gradient orientation histograms for keypoint description in SIFT (From “Distinctive Image Features from Scale-Invariant Keypoints,” David G. Lowe. Reprinted by permission of Springer Science+Business Media.)

匹配 SIFT 描述符

如果两个 128 元素 SIFT 描述符之间的欧几里德(又名 L2)距离很低,则认为它们匹配。Lowe 还提出了一个过滤这些匹配的条件,以使匹配更加健壮。假设您有两组描述符,一组来自训练图像,另一组来自测试图像,您希望找到鲁棒的匹配。通常,称为“最近邻搜索”的特殊算法被用于找出在欧几里得意义上最接近每个训练描述符的测试描述符。假设您使用最近邻搜索来获得所有训练描述符的 2 个最近邻。如果从训练描述符到其第一最近邻居的距离大于阈值乘以到第二最近邻居的距离,则描述符匹配被消除。这个阈值显然小于 1,通常设置为 0.8。这迫使匹配是唯一的和高度“有区别的”通过降低该阈值的值,可以使该要求更加严格。

OpenCV 有一组丰富的函数(作为类实现),用于各种关键点检测、描述符提取和描述符匹配。这样做的好处是,您可以使用一种算法提取关键点,使用另一种算法提取这些关键点周围的描述符,这样就可以根据您的需要来定制这个过程。

FeatureDetector是所有特定关键点检测类(如 SIFT、SURF、ORB 等)的基类。)都是遗传的。这个父类中的detect()方法接收一个图像并检测关键点,将它们返回到关键点对象的 STL 向量中。KeyPoint是一个通用类,用于存储关键点(位置、方向、比例、过滤器响应和其他一些信息)。SiftFeatureDetector是从FeatureDetector继承来的类,它使用我之前概述的算法从灰度图像中提取关键点。

FeatureDetector类似,DescriptorExtractor是 OpenCV 中实现的各种描述符提取器的基类。它有一个名为compute()的方法,接受图像和关键点作为输入,并给出一个 Mat,其中每一行都是相应关键点的描述符。对于 SIFT,可以使用SiftDescriptorExtractor

所有描述符匹配算法都继承自DescriptorMatcher类,该类有一些非常有用的方法。典型的流程是用add()方法将训练描述符放入匹配器对象,用train()方法初始化最近邻搜索数据结构。然后,您可以分别使用match()knnMatch()方法为查询测试描述符找到最接近的匹配或最近的邻居。有两种方法可以得到最近的邻居:进行强力搜索(遍历每个测试点的所有训练点,BFmatcher())或者使用 OpenCV 包装器,在FlannBasedMatcher()类中实现快速近似最近邻居库(FLANN)。清单 8-1 非常简单:它使用 SIFT 关键点、SIFT 描述符,并使用蛮力方法获得最近邻。一定要仔细阅读它的语法细节。图 8-7 展示了我们第一个基于关键点的目标检测器!

清单 8-1。程序说明 SIFT 关键点和描述符提取,并使用蛮力匹配

// Program to illustrate SIFT keypoint and descriptor extraction, and matching using brute force

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/nonfree/features2d.hpp>

#include <opencv2/features2d/features2d.hpp>

using namespace cv;

using namespace std;

int main() {

Mat train = imread("template.jpg"), train_g;

cvtColor(train, train_g, CV_BGR2GRAY);

//detect SIFT keypoints and extract descriptors in the train image

vector<KeyPoint> train_kp;

Mat train_desc;

SiftFeatureDetector featureDetector;

featureDetector.detect(train_g, train_kp);

SiftDescriptorExtractor featureExtractor;

featureExtractor.compute(train_g, train_kp, train_desc);

// Brute Force based descriptor matcher object

BFMatcher matcher;

vector<Mat> train_desc_collection(1, train_desc);

matcher.add(train_desc_collection);

matcher.train();

// VideoCapture object

VideoCapture cap(0);

unsigned int frame_count = 0;

while(char(waitKey(1)) != 'q') {

double t0 = getTickCount();

Mat test, test_g;

cap >> test;

if(test.empty())

continue;

cvtColor(test, test_g, CV_BGR2GRAY);

//detect SIFT keypoints and extract descriptors in the test image

vector<KeyPoint> test_kp;

Mat test_desc;

featureDetector.detect(test_g, test_kp);

featureExtractor.compute(test_g, test_kp, test_desc);

// match train and test descriptors, getting 2 nearest neighbors for all test descriptors

vector<vector<DMatch> > matches;

matcher.knnMatch(test_desc, matches, 2);

// filter for good matches according to Lowe's algorithm

vector<DMatch> good_matches;

for(int i = 0; i < matches.size(); i++) {

if(matches[i][0].distance < 0.6 * matches[i][1].distance)

good_matches.push_back(matches[i][0]);

}

Mat img_show;

drawMatches(test, test_kp, train, train_kp, good_matches, img_show);

imshow("Matches", img_show);

cout << "Frame rate = " << getTickFrequency() / (getTickCount() - t0) << endl;

}

return 0;

}

A978-1-4302-6080-6_8_Fig7_HTML.jpg

图 8-7。

SIFT keypoint based object detector using the Brute Force matcher

注意使用非常方便的drawMatches()函数来可视化检测和我用来测量帧速率的方法。您可以看到,当没有对象实例时,我们得到的帧速率约为 2.1 fps,当有实例时,我们得到的帧速率为 2.1 fps,如果您考虑的是实时应用,这不是很好。

清单 8-2 和图 8-8 显示,使用基于 FLANN 的匹配器将帧速率提高到 2.2 fps 和 1.8 fps。

清单 8-2。程序说明 SIFT 关键点和描述符提取,并使用 FLANN 匹配

// Program to illustrate SIFT keypoint and descriptor extraction, and matching using FLANN

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/nonfree/features2d.hpp>

#include <opencv2/features2d/features2d.hpp>

using namespace cv;

using namespace std;

int main() {

Mat train = imread("template.jpg"), train_g;

cvtColor(train, train_g, CV_BGR2GRAY);

//detect SIFT keypoints and extract descriptors in the train image

vector<KeyPoint> train_kp;

Mat train_desc;

SiftFeatureDetector featureDetector;

featureDetector.detect(train_g, train_kp);

SiftDescriptorExtractor featureExtractor;

featureExtractor.compute(train_g, train_kp, train_desc);

// FLANN based descriptor matcher object

FlannBasedMatcher matcher;

vector<Mat> train_desc_collection(1, train_desc);

matcher.add(train_desc_collection);

matcher.train();

// VideoCapture object

VideoCapture cap(0);

unsigned int frame_count = 0;

while(char(waitKey(1)) != 'q') {

double t0 = getTickCount();

Mat test, test_g;

cap >> test;

if(test.empty())

continue;

cvtColor(test, test_g, CV_BGR2GRAY);

//detect SIFT keypoints and extract descriptors in the test image

vector<KeyPoint> test_kp;

Mat test_desc;

featureDetector.detect(test_g, test_kp);

featureExtractor.compute(test_g, test_kp, test_desc);

// match train and test descriptors, getting 2 nearest neighbors for all test descriptors

vector<vector<DMatch> > matches;

matcher.knnMatch(test_desc, matches, 2);

// filter for good matches according to Lowe's algorithm

vector<DMatch> good_matches;

for(int i = 0; i < matches.size(); i++) {

if(matches[i][0].distance < 0.6 * matches[i][1].distance)

good_matches.push_back(matches[i][0]);

}

Mat img_show;

drawMatches(test, test_kp, train, train_kp, good_matches, img_show);

imshow("Matches", img_show);

cout << "Frame rate = " << getTickFrequency() / (getTickCount() - t0) << endl;

}

return 0;

}

A978-1-4302-6080-6_8_Fig8_HTML.jpg

图 8-8。

SIFT keypoint based object detector using the FLANN based matcher

浏览要点和描述符

Herbert Bay 等人在他们的论文“SURF:加速的鲁棒特征”中提出了一种关键点检测和描述算法,该算法与 SIFT 一样鲁棒且可重复,但速度是 SIFT 的两倍多。在这里,我将概述这个算法的要点。(如果你想要更详细的信息,请参考论文。)

冲浪关键点检测

SURF 之所以快,是因为它对复杂的连续实值函数使用矩形离散化整数近似。SURF 使用 Hessian 矩阵行列式的最大值。现在,Hessian 矩阵被定义为:

其中:

是高斯二阶导数与点x处的图像I的卷积。

图 8-9 显示了二阶高斯导数及其 SURF 矩形整数近似。

A978-1-4302-6080-6_8_Fig9_HTML.jpg

图 8-9。

Second order Gaussian derivatives in the Y and XY direction (left); their box-filter approximations (right). (From “SURF: Speeded Up Robust Features,” by Herbert Bay et al. Reprinted by permission of Springer Science+Business Media)

为了理解为什么 Hessian 矩阵行列式极值表示角状关键点,请记住卷积是关联的。因此,在用高斯滤波器对图像进行平滑之后,Hessian 矩阵的元素也可以被认为是图像的二阶空间导数。如果有突然的强度变化,图像的二阶空间导数将有一个峰值。然而,边缘也可以算作突然的强度变化。矩阵的行列式有助于我们区分边和角。现在,Hessian 矩阵的行列式由下式给出:

从滤波器的结构可以清楚地看出,Lxx 和 Lyy 分别响应于垂直和水平边缘,而 Lxy 最适合于由对角边缘形成的拐角。因此,当有一对水平和垂直边相交时(形成一个角),或者当有一个由对角边构成的角时,行列式将具有高值。这就是我们想要的。

SURF 使用的矩形近似也称为箱式过滤器。盒式滤波器的使用使得能够使用积分图像,这是一种将卷积运算加速几个数量级的巧妙结构。

积分图像的概念及其在用盒滤波器加速卷积中的应用值得讨论,因为它在图像处理中被广泛使用。所以让我们绕一小段路。

对于任何图像,其在一个像素的积分图像是其从原点(左上角)开始直到该像素的累积和。数学上,如果I是一幅图像,H是积分图像,H的像素(x, y)由下式给出:

可以使用递归方程在线性时间内从原始图像计算积分图像:

积分图像最有趣的特性之一是,一旦你有了它,你就可以用它来得到原始图像中任意大小像素的总和,只需要 4 次运算,使用这个等式(也见图 8-10 ):

A978-1-4302-6080-6_8_Fig10_HTML.jpg

图 8-10。

Using integral image to sum up sum across a rectangular region

与核的卷积只是像素值与核元素的逐元素乘法,然后求和。如果内核元素不变,事情就变得简单多了。我们可以将核下的像素值相加,然后将和乘以常数核值。现在你明白为什么箱式过滤器(在矩形区域中具有常数元素的核)和积分图像(帮助你非常快速地合计矩形区域中的像素值的图像)是如此伟大的一对了吧?

为了构建尺度空间金字塔,SURF 增加了高斯滤波器的大小,而不是减小图像的大小。在构建之后,它通过比较金字塔中的一个点与其 26 个邻居来寻找不同尺度下的 Hessian 矩阵行列式值的极值,就像 SIFT 一样。这给了冲浪关键点和它们的比例。

关键点方向通过选择关键点周围半径为关键点比例 6 倍的圆形邻域来确定。在这个邻域的每一点,水平和垂直盒式滤波器(称为 Haar 小波,如图 8-11 所示)的响应被记录。

A978-1-4302-6080-6_8_Fig11_HTML.jpg

图 8-11。

Vertical (top) and horizontal (bottom) box filters used for orientation assignment

一旦用高斯函数(σ = 2.5 倍标度)对响应进行加权,它们就被表示为空间中的向量,水平响应强度沿 x 轴,垂直响应强度沿 y 轴。角度为 60 度的滑动弧扫过该空间一圈(图 8-12 )。

A978-1-4302-6080-6_8_Fig12_HTML.jpg

图 8-12。

Sliding orientation windows used in SURF

对窗口内的所有响应求和,以给出新的向量;这些向量的数量与滑动窗口的迭代次数一样多。这些向量中最大的一个将其方向借给关键点。

冲浪描述符

获得定向关键点后,在计算 SURF 描述符时会涉及以下步骤:

  • 这个区域被分成 16 个正方形的子区域。在每个次区域中,在 5 x 5 个规则间隔的网格点上计算出一组四个特征。这些特征包括水平和垂直方向上的 Harr 小波响应及其绝对值
  • 这 4 个特征在每个单独的子区域上被总结,并构成每个子区域的四元素描述符。十六个这样的子区域构成了关键点的 64 元素描述符

A978-1-4302-6080-6_8_Fig13_HTML.jpg

图 8-13。

Oriented square patches around SURF keypoints

  • 在关键点周围构造一个正方形区域,边长等于关键点比例的 20 倍,方向由关键点的方向决定,如图 8-13 所示。

使用与 SIFT 相同的最近邻距离比策略来匹配 SURF 描述符。

在不牺牲性能的情况下,SURF 的速度至少是 SIFT 的两倍。清单 8-3 显示了基于关键点的对象检测器应用的 SURF 版本,它使用了 SurfFeatureDetector 和 SurfDescriptorExtractor 类。图 8-14 显示 SURF 和 SIFT 一样精确,同时给我们高达 6 fps 的帧速率。

清单 8-3。说明 SURF 关键点和描述符提取以及使用 FLANN 进行匹配的程序

// Program to illustrate SURF keypoint and descriptor extraction, and matching using FLANN

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/nonfree/features2d.hpp>

#include <opencv2/features2d/features2d.hpp>

using namespace cv;

using namespace std;

int main() {

Mat train = imread("template.jpg"), train_g;

cvtColor(train, train_g, CV_BGR2GRAY);

//detect SIFT keypoints and extract descriptors in the train image

vector<KeyPoint> train_kp;

Mat train_desc;

SurfFeatureDetector featureDetector(100);

featureDetector.detect(train_g, train_kp);

SurfDescriptorExtractor featureExtractor;

featureExtractor.compute(train_g, train_kp, train_desc);

// FLANN based descriptor matcher object

FlannBasedMatcher matcher;

vector<Mat> train_desc_collection(1, train_desc);

matcher.add(train_desc_collection);

matcher.train();

// VideoCapture object

VideoCapture cap(0);

unsigned int frame_count = 0;

while(char(waitKey(1)) != 'q') {

double t0 = getTickCount();

Mat test, test_g;

cap >> test;

if(test.empty())

continue;

cvtColor(test, test_g, CV_BGR2GRAY);

//detect SIFT keypoints and extract descriptors in the test image

vector<KeyPoint> test_kp;

Mat test_desc;

featureDetector.detect(test_g, test_kp);

featureExtractor.compute(test_g, test_kp, test_desc);

// match train and test descriptors, getting 2 nearest neighbors for all test descriptors

vector<vector<DMatch> > matches;

matcher.knnMatch(test_desc, matches, 2);

// filter for good matches according to Lowe's algorithm

vector<DMatch> good_matches;

for(int i = 0; i < matches.size(); i++) {

if(matches[i][0].distance < 0.6 * matches[i][1].distance)

good_matches.push_back(matches[i][0]);

}

Mat img_show;

drawMatches(test, test_kp, train, train_kp, good_matches, img_show);

imshow("Matches", img_show);

cout << "Frame rate = " << getTickFrequency() / (getTickCount() - t0) << endl;

}

return 0;

}

A978-1-4302-6080-6_8_Fig14_HTML.jpg

图 8-14。

SURF-based object detector using FLANN matching

请注意,SIFT 和 SURF 算法都在美国获得了专利(可能在其他一些国家也是如此),因此您将无法在商业应用中使用它们。

ORB(定向快速旋转简报)

ORB 是一种关键点检测和描述技术,它的流行程度正赶上 SIFT 和 SURF。它以极快的运算速度而闻名,同时几乎没有牺牲性能精度。ORB 是比例和旋转不变的,对噪声和仿射变换具有鲁棒性,并且仍然能够提供 25 fps 的帧速率!

该算法实际上是快速(来自加速段测试的特征)关键点检测与添加到该算法中的定向以及被修改以处理定向关键点的 BRIEF(二进制鲁棒独立基本特征)关键点描述符算法的组合。在进一步描述 ORB 之前,您可以阅读以下文章,以获得关于这些算法的详细信息:

  • “ORB:筛选或冲浪的有效替代方案”,作者 Ethan Rublee 等人。
  • “更快更好:角点检测的机器学习方法”,Edward Rosten 等人。
  • “简介:二进制鲁棒独立基本特征”,迈克尔·科兰德等人。

定向快速关键点

最初的快速关键点检测器在围绕一个像素的一个圆圈中测试 16 个像素。如果中心像素比 16 个像素中的阈值数量更暗或更亮,则确定为拐角。为了使这个过程更快,使用机器学习方法来决定检查 16 个像素的有效顺序。ORB 中 FAST 的自适应通过制作图像的比例金字塔来检测多个比例的角点,并通过找到强度质心来为这些角点添加方向。像素块的强度质心由下式给出:其中

补片的方向是连接补片中心和强度质心的向量的方向。具体来说:

简要描述

BRIEF 的基本原理是,图像中的关键点可以通过围绕该关键点的一系列二进制像素强度测试来充分描述。这是通过选取关键点周围的像素对(根据随机或非随机采样模式)然后比较两个强度来完成的。如果第一个像素的亮度高于第二个像素的亮度,测试返回 1,否则返回 0。由于所有这些输出都是二进制的,所以可以将它们打包成字节,以便有效地存储。一个很大的优点是,由于描述符是二进制的,两个描述符之间的距离度量是汉明的,而不是欧几里得的。两个相同长度的二进制字符串之间的汉明距离是它们之间不同的位数。通过在两个描述符之间进行逐位 XOR 运算,然后计算 1 的数量,可以非常有效地实现汉明距离。

ORB 中的简短实现使用机器学习算法来获得一个配对挑选模式,以挑选 256 个将捕获最多信息的配对,看起来有点像图 8-15 。

A978-1-4302-6080-6_8_Fig15_HTML.jpg

图 8-15。

Typical pair-picking pattern for BRIEF

为了补偿关键点的方向,在挑选对并执行 256 二进制测试之前,围绕关键点的补片的坐标被旋转该方向。

清单 8-4 使用 ORB 关键点和特性实现了基于关键点的对象检测器,图 8-16 显示帧速率上升到了 28 fps,而性能没有受到太大影响。我们还使用局部敏感哈希(LSH)算法来执行基于 FLANN 的搜索,这进一步加快了基于汉明距离的最近邻搜索。

清单 8-4。程序说明 ORB 关键点和描述符提取,并使用弗兰恩 LSH 匹配

// Program to illustrate ORB keypoint and descriptor extraction, and matching using FLANN-LSH

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/nonfree/features2d.hpp>

#include <opencv2/features2d/features2d.hpp>

using namespace cv;

using namespace std;

int main() {

Mat train = imread("template.jpg"), train_g;

cvtColor(train, train_g, CV_BGR2GRAY);

//detect SIFT keypoints and extract descriptors in the train image

vector<KeyPoint> train_kp;

Mat train_desc;

OrbFeatureDetector featureDetector;

featureDetector.detect(train_g, train_kp);

OrbDescriptorExtractor featureExtractor;

featureExtractor.compute(train_g, train_kp, train_desc);

cout << "Descriptor depth " << train_desc.depth() << endl;

// FLANN based descriptor matcher object

flann::Index flannIndex(train_desc, flann::LshIndexParams(12, 20, 2), cvflann::FLANN_DIST_HAMMING);

// VideoCapture object

VideoCapture cap(0);

unsigned int frame_count = 0;

while(char(waitKey(1)) != 'q') {

double t0 = getTickCount();

Mat test, test_g;

cap >> test;

if(test.empty())

continue;

cvtColor(test, test_g, CV_BGR2GRAY);

//detect SIFT keypoints and extract descriptors in the test image

vector<KeyPoint> test_kp;

Mat test_desc;

featureDetector.detect(test_g, test_kp);

featureExtractor.compute(test_g, test_kp, test_desc);

// match train and test descriptors, getting 2 nearest neighbors for all test descriptors

Mat match_idx(test_desc.rows, 2, CV_32SC1), match_dist(test_desc.rows, 2, CV_32FC1);

flannIndex.knnSearch(test_desc, match_idx, match_dist, 2, flann::SearchParams());

// filter for good matches according to Lowe's algorithm

vector<DMatch> good_matches;

for(int i = 0; i < match_dist.rows; i++) {

if(match_dist.at<float>(i, 0) < 0.6 * match_dist.at<float>(i, 1)) {

DMatch dm(i, match_idx.at<int>(i, 0), match_dist.at<float>(i, 0));

good_matches.push_back(dm);

}

}

Mat img_show;

drawMatches(test, test_kp, train, train_kp, good_matches, img_show);

imshow("Matches", img_show);

cout << "Frame rate = " << getTickFrequency() / (getTickCount() - t0) << endl;

}

return 0;

}

A978-1-4302-6080-6_8_Fig16_HTML.jpg

图 8-16。

ORB keypoint based object detecor

基础机器学习

在本节中,我们将讨论一些入门级的机器学习概念,我们将在下一节中使用这些概念来制作我们的终极对象检测器应用!机器学习(ML)主要研究让机器(计算机)从经验中学习的技术。机器可以学习解决两种类型的问题:

  • 分类:预测一个数据样本属于哪个类别。因此,分类算法的输出是一个类别,它是离散的,而不是连续的。例如,我们将在本章稍后处理的机器学习问题是确定图像中存在哪个对象(来自一系列不同的对象)。这是一个分类问题,因为输出是对象类别,这是一个离散的类别号
  • 回归:预测一个很难用方程表达的函数的输出。例如,学习从以前的数据预测股票价格的算法就是回归算法。注意,输出是一个连续的数字——它可以取任何值

ML 算法的训练输入由两部分组成:

  • 特征:人们认为与手头问题相关的参数。例如,对于我们之前讨论的对象分类问题,特征可以是图像的 SURF 描述符。典型地,ML 算法被给予来自不是一个而是许多数据实例的特征,以“教导”它许多可能的输入条件
  • 标签:与数据实例相关联的每组特性都有相应的“正确响应”这叫做标签。例如,对象分类问题中的标签可以是训练图像中存在的对象类别。分类问题会有离散标签,而回归算法会有连续标签。

特征和标签一起构成了 ML 算法所需的“经验”。

有太多的 ML 算法了。朴素贝叶斯分类器、逻辑回归器、支持向量机和隐马尔可夫模型是一些例子。每种算法都有自己的优缺点。我们将在这里简要讨论支持向量机(SVM)的工作原理,然后继续在我们的 object detector 应用中使用 OpenCV 实现支持向量机。

支持向量机

由于其通用性和易用性,支持向量机是当今使用最广泛的 ML 算法。典型的 SVM 是一种分类算法,但一些研究人员已经将其修改为执行回归。基本上,给定一组带标签的数据点,SVM 试图找到一个能最好地正确分离数据的超平面。超平面是给定维数的线性结构。例如,如果数据是二维的,则它是一条线,三维的是一个平面,四维的是一个“四维平面”,依此类推。所谓“最佳分离”,我的意思是从超平面到超平面两侧最近点的距离必须相等,并且尽可能最大。图 8-17 中显示了一个二维的简单例子,其中直线是超平面。

A978-1-4302-6080-6_8_Fig17_HTML.jpg

图 8-17。

SVM classifier hyperplane

敏锐的读者可能会说,并不是所有的数据点排列都能被超平面正确地分离。例如,如果你有四个带标签的点,如图 8-18 所示,没有一条线可以正确地将它们全部分开。

A978-1-4302-6080-6_8_Fig18_HTML.jpg

图 8-18。

SVM XOR problem

为了解决这个问题,支持向量机使用了核的概念。内核是将数据从一种配置映射到另一种配置的函数,可能会改变维度。例如,可以解决图 8-18 中问题的内核将是一个可以围绕x1 = x2线“折叠”平面的内核(图 8-18 )。如果使用核,SVM 算法会在由核变换的特征空间中找到一个用于正确分类的超平面。然后通过核变换每个测试输入,并使用训练好的超平面进行分类。超平面在原始特征空间中可能不保持线性,但是在变换后的特征空间中是线性的。

在 OpenCV 中,CvSVM类及其train()predict()方法可以分别用于训练使用不同内核的 SVM 和使用训练好的 SVM 进行预测。

对象分类

在本节中,我们将开发一个对象检测器应用,它使用关键点的 SURF 描述符作为特征,并使用支持向量机来预测图像中存在的对象的类别。对于这个应用,我们将使用 CMake 构建系统,这使得配置代码项目并将其链接到各种库变得轻而易举。我们还将使用 Boost C++库的filesystem组件来遍历我们的数据文件夹和 STL 数据结构mapmultimap,以一种易于访问的方式存储和组织数据。编写代码中的注释是为了使所有内容都易于理解,但是鼓励您在继续之前先了解一些关于 CMake 和 STL 数据结构映射和重映射的基础知识。

战略

在这里,我概述了我们将使用的策略,它是由 Gabriella Csurka 等人在他们的论文“用关键点包进行视觉分类”中提出的,并在 OpenCV features2d模块中得到了很好的实现。

  • 在所有模板中的关键点处计算(冲浪)描述符,并将它们汇集在一起
  • 将相似的描述符分组到任意数量的簇中。这里,相似性由 64 元素 SURF 描述符之间的欧几里德距离决定,分组由BOWKMeansTrainer类的cluster()方法完成。这些集群在论文中被称为“关键点包”或“视觉单词”,它们共同代表了程序的“词汇”。每个聚类都有一个聚类中心,它可以被认为是属于该聚类的所有描述符的代表描述符
  • 现在,计算 SVM 分类器的训练数据。这由以下人员完成
    • 为每个训练图像计算(SURF)描述符
    • 通过到聚类中心的欧几里德距离将这些描述符中的每一个与词汇表中的一个聚类相关联
    • 根据这种关联制作直方图。该直方图具有与词汇表中的聚类一样多的仓。每个箱计数训练图像中有多少描述符与对应于该箱的聚类相关联。直观地说,这个直方图描述了图像“词汇”中的“视觉单词”,被称为图像的“视觉单词包描述符”。这是通过使用BOWImageDescriptorExtractor类的compute()方法来完成的
  • 之后,使用训练数据为每一类对象训练一个一对一 SVM。详细信息:
    • 对于每个类别,正数据示例是其训练图像的 BOW 描述符,而负数据示例是所有其他类别的训练图像的 BOW 描述符
    • 正例标为 1,反例标为 0
    • 这两个都给了CvSVM类的train()方法来训练一个 SVM。因此,每个类别都有一个 SVM
    • 支持向量机也能够进行多类分类,但是直观上(和数学上)分类器更容易决定一个数据样本是否属于一个类,而不是决定一个数据样本属于多个类中的哪个类
  • 现在,使用训练好的支持向量机进行分类。详细信息:
    • 从相机中捕捉一个图像并计算弓描述符,同样使用BOWImageDescriptorExtractor类的compute()方法
    • 在使用CvSVM类的predict()方法时,将此描述提供给 SVM 进行预测
    • 对于每个类别,相应的 SVM 将告诉我们描述符所描述的图像是否属于该类别,以及它对其决定的信心程度。这个指标越小,SVM 对自己的决定越有信心
    • 选择度量值最小的类别作为检测到的类别

组织

项目文件夹应如图 8-19 所示进行组织。

A978-1-4302-6080-6_8_Fig19_HTML.jpg

图 8-19。

Project root folder organization

图 8-20 显示了“数据”文件夹的结构。它包含两个名为“模板”和“训练图像”的文件夹。“模板”文件夹包含显示我们要根据类别名称分类的对象的图像。如图 8-20 所示,我的类别被称为“a”、“b”和“c”。“train _ images”文件夹中的文件夹与模板一样多,同样以对象类别命名。每个文件夹都包含用于训练 SVM 进行分类的图像,图像本身的名称并不重要。

A978-1-4302-6080-6_8_Fig20_HTML.jpg

图 8-20。

Organization of the two folders in the “data” folder—“templates” and “train_images”

图 8-21 显示了我的三个类别的模板和训练图像。请注意,模板被裁剪为只显示对象,不显示其他内容。

A978-1-4302-6080-6_8_Fig21a_HTML.jpgA978-1-4302-6080-6_8_Fig21b_HTML.jpgA978-1-4302-6080-6_8_Fig21c_HTML.jpg

图 8-21。

(Top to bottom) Templates and training images for categories ”a,” “b,” and “c”

现在来看看文件CmakeLists.txtConfig.h.in,分别如清单 8-5 和 8-6 所示。CmakeLists.txt是主文件,在我们的例子中,它被我的 CMake 用来设置项目的所有属性和链接库(OpenCV 和 Boost)的位置。Config.h.in设置我们的文件夹路径配置。这将在“include”文件夹中自动生成文件Config.h,该文件将包含在我们的源 CPP 文件中,并将在变量TEMPLATE_FOLDERTRAIN_FOLDER中分别定义存储我们的模板和训练图像的文件夹的路径。

清单 8-5: CmakeLists.txt

# Minimum required CMake version

cmake_minimum_required(VERSION 2.8)

# Project name

project(object_categorization)

# Find the OpenCV installation

find_package(OpenCV REQUIRED)

# Find the Boost installation, specifically the components 'system' and 'filesystem'

find_package(Boost COMPONENTS system filesystem REQUIRED)

# ${PROJECT_SOURCE_DIR} is the name of the root directory of the project

# TO_NATIVE_PATH converts the path ${PROJECT_SOURCE_DIR}/data/ to a full path and the file() command stores it in DATA_FOLDER

file(TO_NATIVE_PATH "${PROJECT_SOURCE_DIR}/data/" DATA_FOLDER)

# set TRAIN_FOLDER to DATA_FOLDER/train_images - this is where we will put our templates for constructing the vocabulary

set(TRAIN_FOLDER "${DATA_FOLDER}train_images/")

# set TEMPLATE_FOLDER to DATA_FOLDER/templates - this is where we will put our traininfg images, in folders organized by category

set(TEMPLATE_FOLDER "${DATA_FOLDER}templates/")

# set the configuration input file to ${PROJECT_SOURCE_DIR}/Config.h.in and the includable header file holding configuration information to ${PROJECT_SOURCE_DIR}/include/Config.h

configure_file("${PROJECT_SOURCE_DIR}/Config.h.in" "${PROJECT_SOURCE_DIR}/include/Config.h")

# Other directories where header files for linked libraries can be found

include_directories(${OpenCV_INCLUDE_DIRS} "${PROJECT_SOURCE_DIR}/include" ${Boost_INCLUDE_DIRS})

# executable produced as a result of compilation

add_executable(code8-5 src/code8-5.cpp)

# libraries to be linked with this executable - OpenCV and Boost (system and filesystem components)

target_link_libraries(code8-5 ${OpenCV_LIBS} ${Boost_SYSTEM_LIBRARY} ${Boost_FILESYSTEM_LIBRARY})

代码 8-6:配置文件

// Preprocessor directives to set variables from values in the CMakeLists.txt files

#define DATA_FOLDER "@DATA_FOLDER@"

#define TRAIN_FOLDER "@TRAIN_FOLDER@"

#define TEMPLATE_FOLDER "@TEMPLATE_FOLDER@"

文件夹的这种组织方式,结合配置文件,将允许我们自动有效地管理和读取我们的数据集,并使整个算法可扩展到任何数量的对象类别,当您通读主代码时,您会发现这一点。

主代码应该放在名为“src”的文件夹中,并根据CMakeLists.txt文件中的add_executable()命令中提到的文件名命名,如清单 8-7 所示。它被大量注释以帮助你理解正在发生的事情。我将在后面讨论代码的一些特性,但是和往常一样,我们鼓励您在在线 OpenCV 文档中查找函数!

清单 8-7。演示 BOW 对象分类的程序

// Program to illustrate BOW object categorization

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/features2d/features2d.hpp>

#include <opencv2/nonfree/features2d.hpp>

#include <opencv2/ml/ml.hpp>

#include <boost/filesystem.hpp>

#include "Config.h"

using namespace cv;

using namespace std;

using namespace boost::filesystem3;

class categorizer {

private:

map<string, Mat> templates, objects, positive_data, negative_data; //maps from category names to data

multimap<string, Mat> train_set; //training images, mapped by category name

map<string, CvSVM> svms; //trained SVMs, mapped by category name

vector<string> category_names; //names of the categories found in TRAIN_FOLDER

int categories; //number of categories

int clusters; //number of clusters for SURF features to build vocabulary

Mat vocab; //vocabulary

// Feature detectors and descriptor extractors

Ptr<FeatureDetector> featureDetector;

Ptr<DescriptorExtractor> descriptorExtractor;

Ptr<BOWKMeansTrainer> bowtrainer;

Ptr<BOWImgDescriptorExtractor> bowDescriptorExtractor;

Ptr<FlannBasedMatcher> descriptorMatcher;

void make_train_set(); //function to build the training set multimap

void make_pos_neg(); //function to extract BOW features from training images and organize them into positive and negative samples

string remove_extension(string); //function to remove extension from file name, used for organizing templates into categories

public:

categorizer(int); //constructor

void build_vocab(); //function to build the BOW vocabulary

void train_classifiers(); //function to train the one-vs-all SVM classifiers for all categories

void categorize(VideoCapture); //function to perform real-time object categorization on camera frames

};

string categorizer::remove_extension(string full) {

int last_idx = full.find_last_of(".");

string name = full.substr(0, last_idx);

return name;

}

categorizer::categorizer(int _clusters) {

clusters = _clusters;

// Initialize pointers to all the feature detectors and descriptor extractors

featureDetector = (new SurfFeatureDetector());

descriptorExtractor = (new SurfDescriptorExtractor());

bowtrainer = (new BOWKMeansTrainer(clusters));

descriptorMatcher = (new FlannBasedMatcher());

bowDescriptorExtractor = (new BOWImgDescriptorExtractor(descriptorExtractor, descriptorMatcher));

// Organize the object templates by category

// Boost::filesystem directory iterator

for(directory_iterator i(TEMPLATE_FOLDER), end_iter; i != end_iter; i++) {

// Prepend full path to the file name so we can imread() it

string filename = string(TEMPLATE_FOLDER) + i->path().filename().string();

// Get category name by removing extension from name of file

string category = remove_extension(i->path().filename().string());

Mat im = imread(filename), templ_im;

objects[category] = im;

cvtColor(im, templ_im, CV_BGR2GRAY);

templates[category] = templ_im;

}

cout << "Initialized" << endl;

// Organize training images by category

make_train_set();

}

void categorizer::make_train_set() {

string category;

// Boost::filesystem recursive directory iterator to go through all contents of TRAIN_FOLDER

for(recursive_directory_iterator i(TRAIN_FOLDER), end_iter; i != end_iter; i++) {

// Level 0 means a folder, since there are only folders in TRAIN_FOLDER at the zeroth level

if(i.level() == 0) {

// Get category name from name of the folder

category = (i -> path()).filename().string();

category_names.push_back(category);

}

// Level 1 means a training image, map that by the current category

else {

// File name with path

string filename = string(TRAIN_FOLDER) + category + string("/") + (i -> path()).filename().string();

// Make a pair of string and Mat to insert into multimap

pair<string, Mat> p(category, imread(filename, CV_LOAD_IMAGE_GRAYSCALE));

train_set.insert(p);

}

}

// Number of categories

categories = category_names.size();

cout << "Discovered " << categories << " categories of objects" << endl;

}

void categorizer::make_pos_neg() {

// Iterate through the whole training set of images

for(multimap<string, Mat>::iterator i = train_set.begin(); i != train_set.end(); i++) {

// Category name is the first element of each entry in train_set

string category = (*i).first;

// Training image is the second elemnt

Mat im = (*i).second, feat;

// Detect keypoints, get the image BOW descriptor

vector<KeyPoint> kp;

featureDetector -> detect(im, kp);

bowDescriptorExtractor -> compute(im, kp, feat);

// Mats to hold the positive and negative training data for current category

Mat pos, neg;

for(int cat_index = 0; cat_index < categories; cat_index++) {

string check_category = category_names[cat_index];

// Add BOW feature as positive sample for current category ...

if(check_category.compare(category) == 0)

positive_data[check_category].push_back(feat);

//... and negative sample for all other categories

else

negative_data[check_category].push_back(feat);

}

}

// Debug message

for(int i = 0; i < categories; i++) {

string category = category_names[i];

cout << "Category " << category << ": " << positive_data[category].rows << " Positives, " << negative_data[category].rows << " Negatives" << endl;

}

}

void categorizer::build_vocab() {

// Mat to hold SURF descriptors for all templates

Mat vocab_descriptors;

// For each template, extract SURF descriptors and pool them into vocab_descriptors

for(map<string, Mat>::iterator i = templates.begin(); i != templates.end(); i++) {

vector<KeyPoint> kp; Mat templ = (*i).second, desc;

featureDetector -> detect(templ, kp);

descriptorExtractor -> compute(templ, kp, desc);

vocab_descriptors.push_back(desc);

}

// Add the descriptors to the BOW trainer to cluster

bowtrainer -> add(vocab_descriptors);

// cluster the SURF descriptors

vocab = bowtrainer->cluster();

// Save the vocabulary

FileStorage fs(DATA_FOLDER "vocab.xml", FileStorage::WRITE);

fs << "vocabulary" << vocab;

fs.release();

cout << "Built vocabulary" << endl;

}

void categorizer::train_classifiers() {

// Set the vocabulary for the BOW descriptor extractor

bowDescriptorExtractor -> setVocabulary(vocab);

// Extract BOW descriptors for all training images and organize them into positive and negative samples for each category

make_pos_neg();

for(int i = 0; i < categories; i++) {

string category = category_names[i];

// Postive training data has labels 1

Mat train_data = positive_data[category], train_labels = Mat::ones(train_data.rows, 1, CV_32S);

// Negative training data has labels 0

train_data.push_back(negative_data[category]);

Mat m = Mat::zeros(negative_data[category].rows, 1, CV_32S);

train_labels.push_back(m);

// Train SVM!

svms[category].train(train_data, train_labels);

// Save SVM to file for possible reuse

string svm_filename = string(DATA_FOLDER) + category + string("SVM.xml");

svms[category].save(svm_filename.c_str());

cout << "Trained and saved SVM for category " << category << endl;

}

}

void categorizer::categorize(VideoCapture cap) {

cout << "Starting to categorize objects" << endl;

namedWindow("Image");

while(char(waitKey(1)) != 'q') {

Mat frame, frame_g;

cap >> frame;

imshow("Image", frame);

cvtColor(frame, frame_g, CV_BGR2GRAY);

// Extract frame BOW descriptor

vector<KeyPoint> kp;

Mat test;

featureDetector -> detect(frame_g, kp);

bowDescriptorExtractor -> compute(frame_g, kp, test);

// Predict using SVMs for all catgories, choose the prediction with the most negative signed distance measure

float best_score = 777;

string predicted_category;

for(int i = 0; i < categories; i++) {

string category = category_names[i];

float prediction = svms[category].predict(test, true);

//cout << category << " " << prediction << " ";

if(prediction < best_score) {

best_score = prediction;

predicted_category = category;

}

}

//cout << endl;

// Pull up the object template for the detected category and show it in a separate window

imshow("Detected object", objects[predicted_category]);

}

}

int main() {

// Number of clusters for building BOW vocabulary from SURF features

int clusters = 1000;

categorizer c(clusters);

c.build_vocab();

c.train_classifiers();

VideoCapture cap(0);

namedWindow("Detected object");

c.categorize(cap);

return 0;

}

我想在代码中强调的唯一语法概念是 STL 数据结构mapmultimap的使用。它们允许您以(键,值)对的形式存储数据,其中键和值几乎可以是任何数据类型。通过用键索引映射,可以访问与键关联的值。在这里,我们将关键字设置为对象类别名称,这样我们就可以使用类别名称作为映射的索引,轻松地访问特定于类别的模板、训练图像和 SVM。我们必须使用multimap来组织训练图像,因为一个类别可以有多个训练图像。这个技巧允许程序很容易地使用任意数量的对象类别!

摘要

这是本书的主要章节之一,原因有二。首先,我希望,它向您展示了 OpenCV、STL 数据结构和面向对象编程方法在一起使用时的全部威力。第二,您现在拥有了能够完成任何中等物体探测器应用的工具。在许多机器人应用中,基于 SIFT、SURF 和 ORB 关键点的对象检测器是非常常见的基本级对象检测器。我们看到 SIFT 是三者中最慢的(但也是匹配最准确的,能够提取最多数量的有意义的关键点),而计算和匹配 ORB 描述符非常快(但 ORB 往往会错过一些关键点)。SURF 介于两者之间,但更倾向于准确性而不是速度。一个有趣的事实是,由于 OpenCV 的features2d模块的结构,你可以使用一种关键点提取器和另一种描述符提取器和匹配器。例如,可以使用 SURF 关键点来提高速度,然后在这些关键点处计算和匹配 SIFT 描述符来提高匹配精度。

我还讨论了基本的机器学习,这是我认为每个计算机视觉科学家都必须具备的技能,因为你的视觉程序越自动化和智能化,你的机器人就越酷!

CMake 构建系统也是大型项目中简化构建管理的标准,现在您已经知道了使用它的基本知识!

下一章将讨论如何使用关键点描述符匹配的知识来找出从不同视角观察到的物体图像之间的对应关系。你还将看到这些投影关系如何让你从一堆图像中制作出美丽的无缝全景图!

Footnotes 1

http://www.cs.ubc.ca/∼lowe/papers/ijcv04.pdf

九、仿射和透视变换及其在图像全景中的应用

Abstract

在这一章中,你将学习两种重要的几何图像变换——仿射和透视——以及如何在你的代码中用矩阵来表示和使用它们。这将作为下一章的基础知识,下一章涉及立体视觉和大量的 3D 图像几何。

在这一章中,你将学习两种重要的几何图像变换——仿射和透视——以及如何在你的代码中用矩阵来表示和使用它们。这将作为下一章的基础知识,下一章涉及立体视觉和大量的 3D 图像几何。

几何图像变换只是遵循几何规则的图像变换。最简单的几何变换是旋转和缩放图像。还可以有其他更复杂的几何变换。这些变换的另一个特性是它们都是线性的,因此可以表示为矩阵,图像的变换相当于矩阵乘法。可以想象,给定两幅图像(一幅是原始的,另一幅是变换的),如果两幅图像之间有足够的点对应,就可以恢复变换矩阵。您将学习如何通过使用用户点击的图像之间的点对应来恢复仿射和透视变换。稍后,您还将学习如何通过在关键点匹配描述符来自动完成寻找对应的过程。哦,通过学习如何使用 OpenCV 的优秀拼接模块将一堆图像拼接在一起制作美丽的全景图,你将能够运用所有这些知识!

仿射变换

仿射变换是在变换后保持线条“平行性”的任何线性变换。它还保留点作为点、直线作为直线以及点沿直线的距离比。它不保持直线之间的角度。仿射变换包括图像的所有类型的旋转、平移和镜像。现在让我们看看仿射变换是如何用矩阵表示的。

(x, y)是原始图像中某点的坐标,而(x', y')是变换后该点在变换图像中的坐标。不同的转换包括:

  • 缩放:x' = a*x, y' = b*y
  • 翻转 X 和 Y 坐标:x' = -x, y' = -y
  • 绕原点逆时针旋转角度θ: x' = x*cos( θ )—y*sin( θ ), y' = x*sin( θ ) + y*cos( θ )

因为所有的几何变换都是线性的,我们可以通过一个 2x2 矩阵M的矩阵乘法将(x', y')(x, y)联系起来:

(x', y') = M * (x, y)

对于上述三种变换,矩阵 M 采用以下形式:

  • 缩放:,其中a是 X 坐标的缩放因子,而b是 Y 坐标的缩放因子
  • 翻转 X 和 Y 坐标:
  • 围绕原点逆时针旋转角度θ:

除了翻转矩阵,所有仿射变换矩阵的 2×2 部分的行列式必须是+1。

应用仿射变换

在 OpenCV 中,很容易构建仿射变换矩阵并将该变换应用于图像。让我们首先看看应用仿射变换的函数,以便我们可以更好地理解 OpenCV 仿射变换矩阵的结构。函数warpAffine()获取一幅源图像和一个 2×3 矩阵M,并给出一幅变换后的输出图像。假设 M 的形式为:

warpAffine()应用以下变换:

手动构造要给warpAffine()的矩阵时,一个潜在的错误来源是 OpenCV 将原点放在图像的左上角。这一事实不会影响缩放变换,但会影响翻转和旋转变换。具体来说,为了成功翻转,warpAffine()M输入必须为:

OpenCV 函数getRotationMatrix2D()给出一个进行旋转的 2×3 仿射变换矩阵。它将旋转角度(从水平轴逆时针测量)和旋转中心作为输入。对于正常旋转,您可能希望旋转的中心位于图像的中心。清单 9-1 展示了如何使用getRotationMatrix2D()获得一个旋转矩阵,并使用warpAffine()将其应用到一幅图像上。图 9-1 显示了原始图像和仿射变换图像。

清单 9-1。程序来说明一个简单的仿射变换

//Program to illustrate a simple affine transform

//Author: Samarth Manoj Brahmbhatt, University of Pennsyalvania

#include <opencv2/opencv.hpp>

#include <opencv2/stitching/stitcher.hpp>

#include <opencv2/stitching/warpers.hpp>

#include "Config.h"

using namespace std;

using namespace cv;

int main() {

Mat im = imread(DATA_FOLDER_1 + string("/image.jpg")), im_transformed;

imshow("Original", im);

int rotation_degrees = 30;

// Construct Affine rotation matrix

Mat M = getRotationMatrix2D(Point(im.cols/2, im.rows/2), rotation_degrees, 1);

cout << M << endl;

// Apply Affine transform

warpAffine(im, im_transformed, M, im.size(), INTER_LINEAR);

imshow("Transformed", im_transformed);

while(char(waitKey(1)) != 'q') {}

return 0;

}

A978-1-4302-6080-6_9_Fig1_HTML.jpg

图 9-1。

Applying simple Affine transforms

估计仿射变换

有时,您知道一个图像通过仿射(或近似仿射)变换与另一个图像相关,并且您想要获得仿射变换矩阵以用于一些其他计算(例如,估计相机的旋转)。OpenCV 函数getAffineTransform()对于这样的应用来说很方便。这个想法是,如果你在两幅图像中有三对对应点,你可以使用简单的数学方法恢复它们之间的仿射变换。这是因为每一对给你两个方程(一个与 X 坐标相关,一个与 Y 坐标相关)。因此,你需要三个这样的对来求解 2×3 仿射变换矩阵的所有六个元素。getAffineTransform()通过引入两个各含三个点 2f 的向量来为您解方程——一个是原始点,一个是变换点。在清单 9-2 中,用户被要求点击两幅图像中相应的点。这些点用于恢复仿射变换。为了验证恢复的变换是正确的,还向用户显示原始变换图像和由恢复的仿射变换变换的未变换图像之间的差异。图 9-2 显示恢复的仿射变换实际上是正确的(差分图像几乎全是零——黑色)。

清单 9-2。说明仿射变换恢复的程序

//Program to illustrate affine transform recovery

//Author: Samarth Manoj Brahmbhatt, University of Pennsyalvania

#include <opencv2/opencv.hpp>

#include <opencv2/stitching/stitcher.hpp>

#include <opencv2/stitching/warpers.hpp>

#include <opencv2/highgui/highgui.hpp>

#include "Config.h"

using namespace std;

using namespace cv;

// Mouse callback function

void on_mouse(int event, int x, int y, int, void* _p) {

Point2f* p = (Point2f *)_p;

if (event == CV_EVENT_LBUTTONUP) {

p->x = x;

p->y = y;

}

}

class affine_transformer {

private:

Mat im, im_transformed, im_affine_transformed, im_show, im_transformed_show;

vector<Point2f> points, points_transformed;

Mat M; // Estimated Affine transformation matrix

Point2f get_click(string, Mat);

public:

affine_transformer(); //constructor

void estimate_affine();

void show_diff();

};

affine_transformer::affine_transformer() {

im = imread(DATA_FOLDER_2 + string("/image.jpg"));

im_transformed = imread(DATA_FOLDER_2 + string("/transformed.jpg"));

}

// Function to get location clicked by user on a specific window

Point2f affine_transformer::get_click(string window_name, Mat im) {

Point2f p(-1, -1);

setMouseCallback(window_name, on_mouse, (void *)&p);

while(p.x == -1 && p.y == -1) {

imshow(window_name, im);

waitKey(20);

}

return p;

}

void affine_transformer::estimate_affine() {

imshow("Original", im);

imshow("Transformed", im_transformed);

cout << "To estimate the Affine transform between the original and transformed images you will have to click on 3 matching pairs of points" << endl;

im_show = im.clone();

im_transformed_show = im_transformed.clone();

Point2f p;

// Get 3 pairs of matching points from user

for(int i = 0; i < 3; i++) {

cout << "Click on a distinguished point in the ORIGINAL image" << endl;

p = get_click("Original", im_show);

cout << p << endl;

points.push_back(p);

circle(im_show, p, 2, Scalar(0, 0, 255), -1);

imshow("Original", im_show);

cout << "Click on a distinguished point in the TRANSFORMED image" << endl;

p = get_click("Transformed", im_transformed_show);

cout << p << endl;

points_transformed.push_back(p);

circle(im_transformed_show, p, 2, Scalar(0, 0, 255), -1);

imshow("Transformed", im_transformed_show);

}

// Estimate Affine transform

M = getAffineTransform(points, points_transformed);

cout << "Estimated Affine transform = " << M << endl;

// Apply estimates Affine transfrom to check its correctness

warpAffine(im, im_affine_transformed, M, im.size());

imshow("Estimated Affine transform", im_affine_transformed);

}

void affine_transformer::show_diff() {

imshow("Difference", im_transformed - im_affine_transformed);

}

int main() {

affine_transformer a;

a.estimate_affine();

cout << "Press 'd' to show difference, 'q' to end" << endl;

if(char(waitKey(-1)) == 'd') {

a.show_diff();

cout << "Press 'q' to end" << endl;

if(char(waitKey(-1)) == 'q') return 0;

}

else

return 0;

}

A978-1-4302-6080-6_9_Fig2_HTML.jpg

图 9-2。

Affine transform recovery using three pairs of matching points

透视变换

透视变换比仿射变换更普遍。它们不一定保持线条的“平行性”。但是因为它们更通用,它们也更实用——日常图像中遇到的几乎所有变换都是透视变换。有没有想过为什么两条铁轨似乎在远处相遇?这是因为你眼睛的图像平面以一种透视方式观察它们,并且透视变换不一定保持平行线平行。如果你从上面看这些铁轨,它们似乎根本不会相交。

给定 3×3 透视变换矩阵MwarpPerspective()应用以下变换:

注意,透视变换矩阵的左上角 2×2 部分的行列式不必是+1。此外,由于前面显示的变换中的除法,将透视变换矩阵的所有元素乘以一个常数不会对所表示的变换产生任何影响。因此,通常要计算透视变换矩阵,使得 M33 = 1。这给我们留下了 M 中的八个自由数,因此四对对应点足以恢复两幅图像之间的透视变换。OpenCV 函数findHomography()会帮你做到这一点。有趣的是,如果您在调用此函数时指定了标志 CV_RANSAC(参见在线文档),它甚至可以接受四个以上的点,并使用 RANSAC 算法从所有这些点稳健地估计变换。RANSAC 使得变换估计过程不受噪声“错误”对应的影响。清单 9-3 读取两幅图像(通过透视变换关联),要求用户点击八对点,使用 RANSAC 稳健地估计透视变换,并显示原始和新透视变换图像之间的差异以验证估计的变换。同样,差异图像在相关区域中主要是黑色的,这意味着估计的变换是正确的。

清单 9-3。程序演示了一个简单的透视变换恢复和应用

//Program to illustrate a simple perspective transform recovery and application

//Author: Samarth Manoj Brahmbhatt, University of Pennsyalvania

#include <opencv2/opencv.hpp>

#include <opencv2/stitching/stitcher.hpp>

#include <opencv2/stitching/warpers.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/calib3d/calib3d.hpp>

#include "Config.h"

using namespace std;

using namespace cv;

void on_mouse(int event, int x, int y, int, void* _p) {

Point2f* p = (Point2f *)_p;

if (event == CV_EVENT_LBUTTONUP) {

p->x = x;

p->y = y;

}

}

class perspective_transformer {

private:

Mat im, im_transformed, im_perspective_transformed, im_show, im_transformed_show;

vector<Point2f> points, points_transformed;

Mat M;

Point2f get_click(string, Mat);

public:

perspective_transformer();

void estimate_perspective();

void show_diff();

};

perspective_transformer::perspective_transformer() {

im = imread(DATA_FOLDER_3 + string("/image.jpg"));

im_transformed = imread(DATA_FOLDER_3 + string("/transformed.jpg"));

cout << DATA_FOLDER_3 + string("/transformed.jpg") << endl;

}

Point2f perspective_transformer::get_click(string window_name, Mat im) {

Point2f p(-1, -1);

setMouseCallback(window_name, on_mouse, (void *)&p);

while(p.x == -1 && p.y == -1) {

imshow(window_name, im);

waitKey(20);

}

return p;

}

void perspective_transformer::estimate_perspective() {

imshow("Original", im);

imshow("Transformed", im_transformed);

cout << "To estimate the Perspective transform between the original and transformed images you will have to click on 8 matching pairs of points" << endl;

im_show = im.clone();

im_transformed_show = im_transformed.clone();

Point2f p;

for(int i = 0; i < 8; i++) {

cout << "POINT " << i << endl;

cout << "Click on a distinguished point in the ORIGINAL image" << endl;

p = get_click("Original", im_show);

cout << p << endl;

points.push_back(p);

circle(im_show, p, 2, Scalar(0, 0, 255), -1);

imshow("Original", im_show);

cout << "Click on a distinguished point in the TRANSFORMED image" << endl;

p = get_click("Transformed", im_transformed_show);

cout << p << endl;

points_transformed.push_back(p);

circle(im_transformed_show, p, 2, Scalar(0, 0, 255), -1);

imshow("Transformed", im_transformed_show);

}

// Estimate perspective transform

M = findHomography(points, points_transformed, CV_RANSAC, 2);

cout << "Estimated Perspective transform = " << M << endl;

// Apply estimated perspecive trasnform

warpPerspective(im, im_perspective_transformed, M, im.size());

imshow("Estimated Perspective transform", im_perspective_transformed);

}

void perspective_transformer::show_diff() {

imshow("Difference", im_transformed - im_perspective_transformed);

}

int main() {

perspective_transformer a;

a.estimate_perspective();

cout << "Press 'd' to show difference, 'q' to end" << endl;

if(char(waitKey(-1)) == 'd') {

a.show_diff();

cout << "Press 'q' to end" << endl;

if(char(waitKey(-1)) == 'q') return 0;

}

else

return 0;

}

A978-1-4302-6080-6_9_Fig3_HTML.jpg

图 9-3。

Perspective transform recovery by clicking matching points

到目前为止,您一定已经意识到,通过使用高距离阈值匹配两幅图像之间的图像特征,整个配对过程也可以实现自动化。这正是清单 9-4 所做的。它计算 ORB 关键点和描述符(我们在第八章中学到了这一点),匹配它们,并使用匹配来稳健地估计图像之间的透视变换。图 9-4 显示了运行中的代码。请注意 RANSAC 如何使变换估计过程对错误的 ORB 特征匹配具有鲁棒性。差异图像几乎是黑色的,这意味着估计的变换是正确的。

清单 9-4。通过匹配 ORB 特征来说明透视变换恢复的程序

//Program to illustrate perspective transform recovery by matching ORB features

//Author: Samarth Manoj Brahmbhatt, University of Pennsyalvania

#include <opencv2/opencv.hpp>

#include <opencv2/stitching/stitcher.hpp>

#include <opencv2/stitching/warpers.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/calib3d/calib3d.hpp>

#include "Config.h"

using namespace std;

using namespace cv;

class perspective_transformer {

private:

Mat im, im_transformed, im_perspective_transformed;

vector<Point2f> points, points_transformed;

Mat M;

public:

perspective_transformer();

void estimate_perspective();

void show_diff();

};

perspective_transformer::perspective_transformer() {

im = imread(DATA_FOLDER_3 + string("/image.jpg"));

im_transformed = imread(DATA_FOLDER_3 + string("/transformed.jpg"));

}

void perspective_transformer::estimate_perspective() {

// Match ORB features to point correspondences between the images

vector<KeyPoint> kp, t_kp;

Mat desc, t_desc, im_g, t_im_g;

cvtColor(im, im_g, CV_BGR2GRAY);

cvtColor(im_transformed, t_im_g, CV_BGR2GRAY);

OrbFeatureDetector featureDetector;

OrbDescriptorExtractor featureExtractor;

featureDetector.detect(im_g, kp);

featureDetector.detect(t_im_g, t_kp);

featureExtractor.compute(im_g, kp, desc);

featureExtractor.compute(t_im_g, t_kp, t_desc);

flann::Index flannIndex(desc, flann::LshIndexParams(12, 20, 2), cvflann::FLANN_DIST_HAMMING);

Mat match_idx(t_desc.rows, 2, CV_32SC1), match_dist(t_desc.rows, 2, CV_32FC1);

flannIndex.knnSearch(t_desc, match_idx, match_dist, 2, flann::SearchParams());

vector<DMatch> good_matches;

for(int i = 0; i < match_dist.rows; i++) {

if(match_dist.at<float>(i, 0) < 0.6 * match_dist.at<float>(i, 1)) {

DMatch dm(i, match_idx.at<int>(i, 0), match_dist.at<float>(i, 0));

good_matches.push_back(dm);

points.push_back((kp[dm.trainIdx]).pt);

points_transformed.push_back((t_kp[dm.queryIdx]).pt);

}

}

Mat im_show;

drawMatches(im_transformed, t_kp, im, kp, good_matches, im_show);

imshow("ORB matches", im_show);

M = findHomography(points, points_transformed, CV_RANSAC, 2);

cout << "Estimated Perspective transform = " << M << endl;

warpPerspective(im, im_perspective_transformed, M, im.size());

imshow("Estimated Perspective transform", im_perspective_transformed);

}

void perspective_transformer::show_diff() {

imshow("Difference", im_transformed - im_perspective_transformed);

}

int main() {

perspective_transformer a;

a.estimate_perspective();

cout << "Press 'd' to show difference, 'q' to end" << endl;

if(char(waitKey(-1)) == 'd') {

a.show_diff();

cout << "Press 'q' to end" << endl;

if(char(waitKey(-1)) == 'q') return 0;

}

else

return 0;

}

A978-1-4302-6080-6_9_Fig4_HTML.jpg

图 9-4。

Perspective transform recovery by matching ORB features

全景照片

制作全景图是自动恢复透视变换的主要应用之一。先前讨论的技术可以用于估计由旋转/回转(但不是平移)相机捕获的一组图像之间的透视变换。然后,人们可以通过在一个大的空白“画布”图像上“排列”所有这些图像来构建一个全景。根据估计的透视变换完成排列。尽管这是最常用于制作全景图的高级算法,但为了制作无缝的全景图,还需要注意一些小细节:

  • 估计的透视变换很可能不完美。因此,如果仅通过估计的变换来在画布上排列图像,则在两幅图像重叠的区域中会观察到小的不连续。因此,在估计成对变换之后,必须进行第二次“全局”估计,这将干扰各个变换,以使所有变换彼此很好地一致
  • 必须实施某种形式的接缝混合来消除重叠区域中的不连续性。大多数现代相机都有自动曝光设置。因此,不同的图像可能是在不同的曝光下拍摄的,因此它们可能比全景图中的相邻图像更暗或更亮。曝光的差异必须在所有相邻的图像中被中和

OpenCV stitching模块出色地内置了所有这些功能。它使用图 9-5 中概述的高级算法将图像拼接成视觉上正确的全景图。

A978-1-4302-6080-6_9_Fig5_HTML.jpg

图 9-5。

OpenCV image stitching pipeline, taken from OpenCV online documentation

从制作全景图的角度来看,stitching模块使用起来非常简单,只需创建一个stitching对象,并传递给它一个包含你想要拼接的图像的Mat向量。清单 9-5 显示了用于从图 9-6 所示的六幅图像中生成美丽全景图的简单代码。注意,这段代码要求图像出现在名为DATA_FOLDER_1的位置,并在Config.h头文件中定义。它使用CMake将可执行文件链接到 Boost 文件系统库。你可以使用第八章的末尾解释的架构和CMake组织来编译代码。

清单 9-5。从图像集合创建全景的代码

//Code to create a panorama from a collection of images

//Author: Samarth Manoj Brahmbhatt, University of Pennsyalvania

#include <opencv2/opencv.hpp>

#include <opencv2/stitching/stitcher.hpp>

#include <opencv2/stitching/warpers.hpp>

#include "Config.h"

#include <boost/filesystem.hpp>

using namespace std;

using namespace cv;

using namespace boost::filesystem;

int main() {

vector<Mat> images;

// Read images

for(directory_iterator i(DATA_FOLDER_5), end_iter; i != end_iter; i++) {

string im_name = i->path().filename().string();

string filename = string(DATA_FOLDER_5) + im_name;

Mat im = imread(filename);

if(!im.empty())

images.push_back(im);

}

cout << "Read " << images.size() << " images" << endl << "Now making panorama..." << endl;

Mat panorama;

Stitcher stitcher = Stitcher::createDefault();

stitcher.stitch(images, panorama);

namedWindow("Panorama", CV_WINDOW_NORMAL);

imshow("Panorama", panorama);

while(char(waitKey(1)) != 'q') {}

return 0;

}

A978-1-4302-6080-6_9_Fig6a_HTML.jpg A978-1-4302-6080-6_9_Fig6b_HTML.jpg

图 9-6。

6 images (top) used to generate the Golden Gate panorama (bottom)

全景代码也可以很好地扩展。图 9-7 显示了使用相同代码从 23 幅图像生成的全景图。

A978-1-4302-6080-6_9_Fig7_HTML.jpg

图 9-7。

Panorama made by stitching 23 images

位于 http://docs.opencv.org/modules/stitching/doc/stitching.html 的拼接模块的在线文档显示,流水线的不同部分有很多选项。例如,您可以:

  • 使用 SURF 或 ORB 作为您选择的图像特征
  • 平面、球形或圆柱形作为全景图的形状(排列所有图像的画布的形状)
  • 作为寻找需要混合的接缝区域的方法的图切割或 Voronoi 图

清单 9-6 展示了如何使用stitching类的各种“setter”函数插入和拔出流水线的不同模块。它将全景的形状从默认的平面更改为圆柱形。图 9-8 显示了这样得到的柱面全景图。

清单 9-6。从图像集合中创建具有圆柱形扭曲的全景图的代码

//Code to create a panorama with cylindrical warping from a collection of images

//Author: Samarth Manoj Brahmbhatt, University of Pennsyalvania

#include <opencv2/opencv.hpp>

#include <opencv2/stitching/stitcher.hpp>

#include <opencv2/stitching/warpers.hpp>

#include "Config.h"

#include <boost/filesystem.hpp>

using namespace std;

using namespace cv;

using namespace boost::filesystem;

int main() {

vector<Mat> images;

for(directory_iterator i(DATA_FOLDER_5), end_iter; i != end_iter; i++) {

string im_name = i->path().filename().string();

string filename = string(DATA_FOLDER_5) + im_name;

Mat im = imread(filename);

if(!im.empty())

images.push_back(im);

}

cout << "Read " << images.size() << " images" << endl << "Now making panorama..." << endl;

Mat panorama;

Stitcher stitcher = Stitcher::createDefault();

CylindricalWarper* warper = new CylindricalWarper();

stitcher.setWarper(warper);

// Estimate perspective transforms between images

Stitcher::Status status = stitcher.estimateTransform(images);

if (status != Stitcher::OK) {

cout << "Can't stitch images, error code = " << int(status) << endl;

return -1;

}

// Make panorama

status = stitcher.composePanorama(panorama);

if (status != Stitcher::OK) {

cout << "Can't stitch images, error code = " << int(status) << endl;

return -1;

}

namedWindow("Panorama", CV_WINDOW_NORMAL);

imshow("Panorama", panorama);

while(char(waitKey(1)) != 'q') {}

return 0;

}

A978-1-4302-6080-6_9_Fig8_HTML.jpg

图 9-8。

Cylindrical panorama made from seven images

摘要

几何图像变换是处理真实世界的所有计算机视觉程序的重要部分,因为在世界和相机的图像平面之间以及相机的两个位置的图像平面之间总是存在透视变换。他们也很酷,因为他们可以用来制作全景图!在本章中,您学习了如何编写实现透视变换的代码,以及如何恢复两个给定图像之间的变换,这在许多实际的计算机视觉项目中是一项有用的技能。下一章是关于立体视觉的。我们将使用透视变换矩阵的知识来表示立体摄像机的左右摄像机之间的变换,这将是学习如何校准立体摄像机的重要一步。

十、3D 几何和立体视觉

Abstract

这一章向你介绍了使用单个和多个摄像机的计算机视觉背后的令人兴奋的几何世界。这种几何知识将使您能够将图像坐标转换为实际的 3D 世界坐标,简而言之,您将能够将图像中的位置与世界上明确定义的物理位置相关联。

这一章向你介绍了使用单个和多个摄像机的计算机视觉背后的令人兴奋的几何世界。这种几何知识将使您能够将图像坐标转换为实际的 3D 世界坐标,简而言之,您将能够将图像中的位置与世界上明确定义的物理位置相关联。

本章分为两个主要部分——单摄像机和立体(双)摄像机。对于每个部分,我们将首先讨论相关的数学概念,然后深入研究该数学的 OpenCV 实现。关于立体相机的部分尤其令人兴奋,因为它将使您能够从一对图像中计算出完整的 3D 信息。因此,让我们立即探索相机型号吧!

单摄像机校准

您可能知道,每个传感器都需要校准。简而言之,校准是将传感器的测量范围与其测量的真实量相关联的过程。相机是一个传感器,它也需要被校准以给我们物理单位的信息。在没有校准的情况下,我们只能了解图像坐标中的对象,当我们想要在与现实世界交互的机器人上使用视觉系统时,这不是很有用。对于摄像机的标定,首先需要了解摄像机的数学模型,如图 10-1 所示。

A978-1-4302-6080-6_10_Fig1_HTML.jpg

图 10-1。

A simple camera model

假设来自真实世界物体的光线使用透镜系统聚焦在称为投影中心的相机点上。显示了一个 3D 坐标系,相机投影中心位于其原点。这就是所谓的相机坐标系。注意,从摄像机图像获得的所有信息都将在该摄像机坐标系中;如果你想将这些信息转换到你的机器人上的任何其他坐标框架,你需要知道从那个框架到相机坐标框架的旋转和平移,我不会在本书中讨论这些。照相机的成像平面沿着 Z 轴位于等于焦距 f 的距离处。成像平面有自己的 2D 坐标系(u, v),现在让我们把它的原点放在 Z 相机轴与成像平面相交的点(u0, v0)。使用相似三角形的概念,很容易观察到相机坐标系中的对象的(X, Y, Z)坐标与其图像的(u, v)坐标之间的关系是:

然而,在几乎所有的成像软件中,图像的原点都位于左上角。考虑到这一点,关系变成:

单个摄像机的标定是寻找焦距 f 和摄像机坐标系原点的图像(u0v0)的过程。从这些等式中可以明显看出,即使你知道fu0v0,通过校准摄像机,你也不能确定图像点的Z坐标,你只能确定一定比例的 X 和 Y 坐标(因为Z是未知的)。那么,你可能会说,这些关系的意义是什么?关键是这些关系(以矩阵的形式表示)允许你从物体自身坐标框架中的物体的 3D 坐标和物体图像中的点的 2D 图像坐标之间的一组已知对应关系中找到相机参数fu0v0。这是通过一些巧妙的矩阵数学来完成的,我将在本章后面简要介绍。

相机参数可以被安排到相机矩阵 K 中,如下所示:

假设(X, Y, Z)是物体点在物体自身坐标系中的坐标。对于校准,通常使用平面棋盘,因为检测角点相对容易,从而自动确定 3D-2D 点对应关系。现在,因为物体坐标系的选择掌握在我们手中,我们将通过选择使棋盘在XY平面上来使生活变得容易得多。因此,所有目标点的Z坐标将为 0。如果我们知道棋盘正方形的边长,XY将只是一个点的网格,它们之间的距离。假设RT是物体坐标系和摄像机坐标系之间的旋转矩阵和平移。这意味着一旦我们通过RT转换了对象坐标,我们将能够使用我们从图 10-1 中导出的关系。RT未知。如果我们用列向量[X Y Z 1] T 来表示物体坐标,那么我们从向量[u1, u2, u3] T 中得到:可以用来得到图像的像素坐标。具体来说,像素坐标:

现在,如果你有一个算法来检测棋盘图像中的内角,你将有一组棋盘图像的 2D-3D 对应关系。将所有这些对应组合在一起,你会得到两个等式——一个是针对u的,一个是针对v的。这些方程中的未知数是R, T和相机参数。如果你有大量的这些对应关系,你可以解一个庞大的线性方程组来获得相机参数fu0v0以及每张图像的RT

单摄像机标定的 OpenCV 实现

OpenCV 函数findChessboardCorners()可以找到棋盘图像中的内角。

A978-1-4302-6080-6_10_Fig2_HTML.jpg

图 10-2。

Chessboard corner extraction in OpenCV

它接受图像和图案的大小(沿宽度和高度的内角)作为输入,并使用复杂的算法来计算和返回这些角的像素位置。这些是你的 2D 形象点。您可以自己为 3D 对象点创建一个点 3f 的向量——XY坐标是一个网格,而Z坐标都是 0,如前所述。OpenCV 函数calibrateCamera()获取这些物体 3D 点和相应的图像 2D 点,并求解方程以输出相机矩阵、RT和失真系数。是的,每个相机都有镜头失真,OpenCV 通过使用失真系数来建模。虽然关于确定这些系数背后的数学讨论超出了本书的范围,但是对失真系数的良好估计可以大大提高相机校准。好消息是,如果您有一组不同视图的棋盘图像,OpenCV 通常可以很好地确定系数。

清单 10-1 显示了一个面向对象的方法来读取一组图像,寻找角落,并校准一个相机。它使用了我们在上一章中使用的相同的CMake构建系统,以及类似的文件夹组织。假设图像存在于位置IMAGE_FOLDER。该程序还将相机矩阵和失真系数存储到一个 XML 文件中,以备后用。

清单 10-1。说明单个摄像机校准的程序

// Program illustrate single camera calibration

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/calib3d/calib3d.hpp>

#include <boost/filesystem.hpp>

#include "Config.h"

using namespace cv;

using namespace std;

using namespace boost::filesystem3;

class calibrator {

private:

string path; //path of folder containing chessboard images

vector<Mat> images; //chessboard images

Mat cameraMatrix, distCoeffs; //camera matrix and distortion coefficients

bool show_chess_corners; //visualize the extracted chessboard corners?

float side_length; //side length of a chessboard square in mm

int width, height; //number of internal corners of the chessboard along width and height

vector<vector<Point2f> > image_points; //2D image points

vector<vector<Point3f> > object_points; //3D object points

public:

calibrator(string, float, int, int); //constructor, reads in the images

void calibrate(); //function to calibrate the camera

Mat get_cameraMatrix(); //access the camera matrix

Mat get_distCoeffs(); //access the distortion coefficients

void calc_image_points(bool); //calculate internal corners of the chessboard image

};

calibrator::calibrator(string _path, float _side_length, int _width, int _height) {

side_length = _side_length;

width = _width;

height = _height;

path = _path;

cout << path << endl;

// Read images

for(directory_iterator i(path), end_iter; i != end_iter; i++) {

string filename = path + i->path().filename().string();

images.push_back(imread(filename));

}

}

void calibrator::calc_image_points(bool show) {

// Calculate the object points in the object co-ordinate system (origin at top left corner)

vector<Point3f> ob_p;

for(int i = 0; i < height; i++) {

for(int j = 0; j < width; j++) {

ob_p.push_back(Point3f(j * side_length, i * side_length, 0.f));

}

}

if(show) namedWindow("Chessboard corners");

for(int i = 0; i < images.size(); i++) {

Mat im = images[i];

vector<Point2f> im_p;

//find corners in the chessboard image

bool pattern_found = findChessboardCorners(im, Size(width, height), im_p, CALIB_CB_ADAPTIVE_THRESH + CALIB_CB_NORMALIZE_IMAGE+ CALIB_CB_FAST_CHECK);

if(pattern_found) {

object_points.push_back(ob_p);

Mat gray;

cvtColor(im, gray, CV_BGR2GRAY);

cornerSubPix(gray, im_p, Size(5, 5), Size(-1, -1), TermCriteria(CV_TERMCRIT_EPS + CV_TERMCRIT_ITER, 30, 0.1));

image_points.push_back(im_p);

if(show) {

Mat im_show = im.clone();

drawChessboardCorners(im_show, Size(width, height), im_p, true);

imshow("Chessboard corners", im_show);

while(char(waitKey(1)) != ' ') {}

}

}

//if a valid pattern was not found, delete the entry from vector of images

else images.erase(images.begin() + i);

}

}

void calibrator::calibrate() {

vector<Mat> rvecs, tvecs;

float rms_error = calibrateCamera(object_points, image_points, images[0].size(), cameraMatrix, distCoeffs, rvecs, tvecs);

cout << "RMS reprojection error " << rms_error << endl;

}

Mat calibrator::get_cameraMatrix() {

return cameraMatrix;

}

Mat calibrator::get_distCoeffs() {

return distCoeffs;

}

int main() {

calibrator calib(IMAGE_FOLDER, 25.f, 5, 4);

calib.calc_image_points(true);

cout << "Calibrating camera…" << endl;

calib.calibrate();

//save the calibration for future use

string filename = DATA_FOLDER + string("cam_calib.xml");

FileStorage fs(filename, FileStorage::WRITE);

fs << "cameraMatrix" << calib.get_cameraMatrix();

fs << "distCoeffs" << calib.get_distCoeffs();

fs.release();

cout << "Saved calibration matrices to " << filename << endl;

return 0;

}

calibrateCamera()返回 RMS 重新投影误差,对于良好的校准,该误差应小于 0.5 像素。我使用的相机和图像集的重投影误差为 0.0547194。请记住,您必须有一套至少 10 幅图像,棋盘在不同的位置和角度,但始终完全可见。

立体视觉

立体摄像机就像你的眼睛——两个摄像机水平分开一个固定的距离,称为基线。立体摄像机设置允许您使用视差概念计算图像点的物理深度。为了理解视差,假设您正在通过立体装备的两个相机查看一个 3D 点。如果两个摄像机没有相互指向对方(并且它们通常没有指向对方),则右边图像中的点的图像将比左边图像中的点的图像具有更低的水平坐标。这种点在两个相机中的图像的明显偏移被称为视差。延伸这个逻辑也告诉我们,视差与点的深度成反比。

三角测量

现在,让我们考虑图 10-3 中所示的立体摄像机模型,从更数学的角度来讨论深度和视差之间的关系。

A978-1-4302-6080-6_10_Fig3_HTML.jpg

图 10-3。

A stereo camera model

观察两幅图像中点 P 的图像位置。这种情况下的差异是:

使用相似三角形的概念,点的深度(P在左摄像机坐标系中的Z坐标)由以下表达式决定:因此,点的深度:

一旦你知道了Z,你就可以通过使用前面提到的单个相机模型的方程来精确地计算该点的XY坐标。这整个过程被称为“立体三角测量”

校准

要校准立体摄像机,首先必须单独校准摄像机。立体声装备的校准需要找出基线T。还要注意的是,我在绘制图 10-3 时做了一个强有力的假设——两个图像平面完全垂直对齐,并且彼此平行。小的制造缺陷通常不会导致这种情况,并且存在明确的旋转矩阵R来对准两个成像平面。校准还计算出R. T也不是一个单一的数字,而是一个代表从左相机原点到右相机原点的平移的向量。OpenCV 函数stereoCalibrate()通过接受以下输入来计算R, T,以及称为E(本质矩阵)和F(基本矩阵)的两个其他矩阵:

  • 3D 物体点(与单摄像机校准情况相同)
  • 左右 2D 图像点(使用左右图像中的findCameraCorners()计算)
  • 左右摄像机矩阵和失真系数(可选)

请注意,左右摄像机校准信息是可选的,如果没有提供,该函数会尝试计算它。但是强烈建议您提供它,以便该函数只需优化较少的参数。

如果您的 USB 端口连接了立体摄像机,该端口通常没有足够的带宽以 30 fps 的速度传输左右 640 x 480 彩色帧。您可以通过改变与摄像机相关联的VideoCapture对象的属性来减小帧的大小,如清单 10-2 所示。图 10-4 显示了我的立体相机拍摄的 320 x 240 帧。对于立体应用,左图像和右图像必须在同一时刻捕捉。这可以通过硬件同步来实现,但是如果你的立体摄像机没有这个功能,你可以首先使用VideoCapture类的grab()方法快速抓取原始帧,然后使用retrieve()方法完成更繁重的去马赛克、解码和Mat存储任务。这确保了两个帧几乎同时被捕获。

清单 10-2。这个程序演示了从 USB 立体摄像机捕捉帧

// Program to illustrate frame capture from a USB stereo camera

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

using namespace cv;

using namespace std;

int main() {

VideoCapture capr(1), capl(2);

//reduce frame size

capl.set(CV_CAP_PROP_FRAME_HEIGHT, 240);

capl.set(CV_CAP_PROP_FRAME_WIDTH, 320);

capr.set(CV_CAP_PROP_FRAME_HEIGHT, 240);

capr.set(CV_CAP_PROP_FRAME_WIDTH, 320);

namedWindow("Left");

namedWindow("Right");

while(char(waitKey(1)) != 'q') {

//grab raw frames first

capl.grab();

capr.grab();

//decode later so the grabbed frames are less apart in time

Mat framel, framer;

capl.retrieve(framel);

capr.retrieve(framer);

if(framel.empty() || framer.empty()) break;

imshow("Left", framel);

imshow("Right", framer);

}

capl.release();

capr.release();

return 0;

}

A978-1-4302-6080-6_10_Fig4_HTML.jpg

图 10-4。

Frames from a stereo camera

清单 10-3 是一个应用,你可以用它来捕捉一组棋盘图像来校准你的立体相机。当你按下“c”时,它会将一对图像保存到单独的文件夹中(称为LEFT_FOLDERRIGHT_FOLDER)。它使用了CMake构建系统和一个类似于我们在CMake项目中使用的配置文件。

清单 10-3。收集立体声快照进行校准的程序

// Program to collect stereo snapshots for calibration

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include "Config.h"

#include <iomanip>

using namespace cv;

using namespace std;

int main() {

VideoCapture capr(1), capl(2);

//reduce frame size

capl.set(CV_CAP_PROP_FRAME_HEIGHT, 240);

capl.set(CV_CAP_PROP_FRAME_WIDTH, 320);

capr.set(CV_CAP_PROP_FRAME_HEIGHT, 240);

capr.set(CV_CAP_PROP_FRAME_WIDTH, 320);

namedWindow("Left");

namedWindow("Right");

cout << "Press 'c' to capture ..." << endl;

char choice = 'z';

int count = 0;

while(choice != 'q') {

//grab frames quickly in succession

capl.grab();

capr.grab();

//execute the heavier decoding operations

Mat framel, framer;

capl.retrieve(framel);

capr.retrieve(framer);

if(framel.empty() || framer.empty()) break;

imshow("Left", framel);

imshow("Right", framer);

if(choice == 'c') {

//save files at proper locations if user presses 'c'

stringstream l_name, r_name;

l_name << "left" << setw(4) << setfill('0') << count << ".jpg";

r_name << "right" << setw(4) << setfill('0') << count << ".jpg";

imwrite(string(LEFT_FOLDER) + l_name.str(), framel);

imwrite(string(RIGHT_FOLDER) + r_name.str(), framer);

cout << "Saved set " << count << endl;

count++;

}

choice = char(waitKey(1));

}

capl.release();

capr.release();

return 0;

}

清单 10-4 是一个应用,它通过读入存储在LEFT_FOLDERRIGHT_FOLDER中的先前捕获的立体图像,以及先前保存在 DATA_FOLDER 中的单个相机校准信息来校准立体相机。我的相机和一组图像给我的均方根重投影误差为 0.377848 像素。

清单 10-4。说明立体摄像机校准的程序

// Program illustrate stereo camera calibration

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/calib3d/calib3d.hpp>

#include <boost/filesystem.hpp>

#include "Config.h"

using namespace cv;

using namespace std;

using namespace boost::filesystem3;

class calibrator {

private:

string l_path, r_path; //path for folders containing left and right checkerboard images

vector<Mat> l_images, r_images; //left and right checkerboard images

Mat l_cameraMatrix, l_distCoeffs, r_cameraMatrix, r_distCoeffs; //Mats for holding individual camera calibration information

bool show_chess_corners; //visualize checkerboard corner detections?

float side_length; //side length of checkerboard squares

int width, height; //number of internal corners in checkerboard along width and height

vector<vector<Point2f> > l_image_points, r_image_points; //left and right image points

vector<vector<Point3f> > object_points; //object points (grid)

Mat R, T, E, F; //stereo calibration information

public:

calibrator(string, string, float, int, int); //constructor

bool calibrate(); //function to calibrate stereo camera

void calc_image_points(bool); //function to calculae image points by detecting checkerboard corners

};

calibrator::calibrator(string _l_path, string _r_path, float _side_length, int _width, int _height) {

side_length = _side_length;

width = _width;

height = _height;

l_path = _l_path;

r_path = _r_path;

// Read images

for(directory_iterator i(l_path), end_iter; i != end_iter; i++) {

string im_name = i->path().filename().string();

string l_filename = l_path + im_name;

im_name.replace(im_name.begin(), im_name.begin() + 4, string("right"));

string r_filename = r_path + im_name;

Mat lim = imread(l_filename), rim = imread(r_filename);

if(!lim.empty() && !rim.empty()) {

l_images.push_back(lim);

r_images.push_back(rim);

}

}

}

void calibrator::calc_image_points(bool show) {

// Calculate the object points in the object co-ordinate system (origin at top left corner)

vector<Point3f> ob_p;

for(int i = 0; i < height; i++) {

for(int j = 0; j < width; j++) {

ob_p.push_back(Point3f(j * side_length, i * side_length, 0.f));

}

}

if(show) {

namedWindow("Left Chessboard corners");

namedWindow("Right Chessboard corners");

}

for(int i = 0; i < l_images.size(); i++) {

Mat lim = l_images[i], rim = r_images[i];

vector<Point2f> l_im_p, r_im_p;

bool l_pattern_found = findChessboardCorners(lim, Size(width, height), l_im_p, CALIB_CB_ADAPTIVE_THRESH + CALIB_CB_NORMALIZE_IMAGE+ CALIB_CB_FAST_CHECK);

bool r_pattern_found = findChessboardCorners(rim, Size(width, height), r_im_p, CALIB_CB_ADAPTIVE_THRESH + CALIB_CB_NORMALIZE_IMAGE+ CALIB_CB_FAST_CHECK);

if(l_pattern_found && r_pattern_found) {

object_points.push_back(ob_p);

Mat gray;

cvtColor(lim, gray, CV_BGR2GRAY);

cornerSubPix(gray, l_im_p, Size(5, 5), Size(-1, -1), TermCriteria(CV_TERMCRIT_EPS + CV_TERMCRIT_ITER, 30, 0.1));

cvtColor(rim, gray, CV_BGR2GRAY);

cornerSubPix(gray, r_im_p, Size(5, 5), Size(-1, -1), TermCriteria(CV_TERMCRIT_EPS + CV_TERMCRIT_ITER, 30, 0.1));

l_image_points.push_back(l_im_p);

r_image_points.push_back(r_im_p);

if(show) {

Mat im_show = lim.clone();

drawChessboardCorners(im_show, Size(width, height), l_im_p, true);

imshow("Left Chessboard corners", im_show);

im_show = rim.clone();

drawChessboardCorners(im_show, Size(width, height), r_im_p, true);

imshow("Right Chessboard corners", im_show);

while(char(waitKey(1)) != ' ') {}

}

}

else {

l_images.erase(l_images.begin() + i);

r_images.erase(r_images.begin() + i);

}

}

}

bool calibrator::calibrate() {

string filename = DATA_FOLDER + string("left_cam_calib.xml");

FileStorage fs(filename, FileStorage::READ);

fs["cameraMatrix"] >> l_cameraMatrix;

fs["distCoeffs"] >> l_distCoeffs;

fs.release();

filename = DATA_FOLDER + string("right_cam_calib.xml");

fs.open(filename, FileStorage::READ);

fs["cameraMatrix"] >> r_cameraMatrix;

fs["distCoeffs"] >> r_distCoeffs;

fs.release();

if(!l_cameraMatrix.empty() && !l_distCoeffs.empty() && !r_cameraMatrix.empty() && !r_distCoeffs.empty()) {

double rms = stereoCalibrate(object_points, l_image_points, r_image_points, l_cameraMatrix, l_distCoeffs, r_cameraMatrix, r_distCoeffs, l_images[0].size(), R, T, E, F);

cout << "Calibrated stereo camera with a RMS error of " << rms << endl;

filename = DATA_FOLDER + string("stereo_calib.xml");

fs.open(filename, FileStorage::WRITE);

fs << "l_cameraMatrix" << l_cameraMatrix;

fs << "r_cameraMatrix" << r_cameraMatrix;

fs << "l_distCoeffs" << l_distCoeffs;

fs << "r_distCoeffs" << r_distCoeffs;

fs << "R" << R;

fs << "T" << T;

fs << "E" << E;

fs << "F" << F;

cout << "Calibration parameters saved to " << filename << endl;

return true;

}

else return false;

}

int main() {

calibrator calib(LEFT_FOLDER, RIGHT_FOLDER, 1.f, 5, 4);

calib.calc_image_points(true);

bool done = calib.calibrate();

if(!done) cout << "Stereo Calibration not successful because individial calibration matrices could not be read" << endl;

return 0;

}

匹配矫正与差异

回想一下关于立体三角测量的讨论,您可以通过找出右图像中的哪个像素与左图像中的像素相匹配,然后计算它们的水平坐标差,来确定一对立体图像中像素的视差。但是在整个右图像中搜索像素匹配非常困难。如何优化匹配像素的搜索?

理论上,左像素的匹配右像素将与左像素在相同的垂直坐标上(但是沿着与深度成反比的水平坐标移动)。这是因为理论上的立体摄像机的单个摄像机仅水平偏移。这大大简化了搜索——您只需在与左侧像素行相同的行中搜索右侧图像!实际上,由于制造缺陷,立体装备中的两个相机没有精确地垂直对齐。校准为我们解决了这个问题——它计算从左摄像机到右摄像机的旋转 R 和平移 T。如果我们通过 R 和 T 变换我们的右图像,两幅图像可以精确地对齐,并且单行搜索变得有效。

在 OpenCV 中,对齐两幅图像的过程(称为立体校正)由函数完成— stereoRectify(), initUndistortRectifyMap()remap(). stereoRectify()接收单个摄像机和立体装配的校准信息,并给出以下输出(矩阵名称与在线文档匹配):

  • R1—应用于左摄像机图像的旋转矩阵,以使其对齐
  • R2—应用于右摄像机图像的旋转矩阵,以使其对齐
  • P1—对准的左摄像机的表观摄像机矩阵,附加一列,用于转换到校准坐标系的原点(左摄像机的坐标系)。对于左边的摄像机,这是[0 0 0]T
  • P2—对准的右摄像机的表观摄像机矩阵,附加一列,用于转换到校准坐标系(左摄像机坐标系)的原点。对于右边的相机,这大约是[-T 0 0]T,其中 T 是两个相机原点之间的距离
  • Q—视差-深度映射矩阵(公式见函数 reprojectImageTo3d()文档)

一旦你得到了这些变换,你必须把它们应用到相应的图像上。做到这一点的有效方法是像素映射。下面是像素图的工作原理——假设校正后的图像是一块适当大小的空白画布,我们希望用未校正图像中的适当像素填充它。像素图是与校正图像大小相同的矩阵,并且充当从校正图像中的位置到未校正图像中的位置的查找表。为了知道必须采用未校正图像中的哪个像素来填充校正图像中的空白像素,只需在像素图中查找相应的条目。因为与浮点乘法相比,矩阵查找非常有效,所以像素图使得对齐图像的过程非常快。OpenCV 函数initUndistortRectifyMap()通过将stereoRectify()输出的矩阵作为输入来计算像素映射。函数remap()将像素图应用于未校正的图像,以对其进行校正。

清单 10-5 显示了所有这些函数的使用例子;我们也鼓励您查阅他们的在线文档,探索各种选项。图 10-5 显示了立体校正的效果——请注意两幅图像中视觉上对应的像素现在处于相同的水平位置。

清单 10-5。说明立体摄像机校正的程序

// Program illustrate stereo camera rectification

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/calib3d/calib3d.hpp>

#include <boost/filesystem.hpp>

#include "Config.h"

using namespace cv;

using namespace std;

using namespace boost::filesystem3;

class calibrator {

private:

string l_path, r_path;

vector<Mat> l_images, r_images;

Mat l_cameraMatrix, l_distCoeffs, r_cameraMatrix, r_distCoeffs;

bool show_chess_corners;

float side_length;

int width, height;

vector<vector<Point2f> > l_image_points, r_image_points;

vector<vector<Point3f> > object_points;

Mat R, T, E, F;

public:

calibrator(string, string, float, int, int);

void calc_image_points(bool);

bool calibrate();

void save_info(string);

Size get_image_size();

};

class rectifier {

private:

Mat map_l1, map_l2, map_r1, map_r2; //pixel maps for rectification

string path;

public:

rectifier(string, Size); //constructor

void show_rectified(Size); //function to show live rectified feed from stereo camera

};

calibrator::calibrator(string _l_path, string _r_path, float _side_length, int _width, int _height) {

side_length = _side_length;

width = _width;

height = _height;

l_path = _l_path;

r_path = _r_path;

// Read images

for(directory_iterator i(l_path), end_iter; i != end_iter; i++) {

string im_name = i->path().filename().string();

string l_filename = l_path + im_name;

im_name.replace(im_name.begin(), im_name.begin() + 4, string("right"));

string r_filename = r_path + im_name;

Mat lim = imread(l_filename), rim = imread(r_filename);

if(!lim.empty() && !rim.empty()) {

l_images.push_back(lim);

r_images.push_back(rim);

}

}

}

void calibrator::calc_image_points(bool show) {

// Calculate the object points in the object co-ordinate system (origin at top left corner)

vector<Point3f> ob_p;

for(int i = 0; i < height; i++) {

for(int j = 0; j < width; j++) {

ob_p.push_back(Point3f(j * side_length, i * side_length, 0.f));

}

}

if(show) {

namedWindow("Left Chessboard corners");

namedWindow("Right Chessboard corners");

}

for(int i = 0; i < l_images.size(); i++) {

Mat lim = l_images[i], rim = r_images[i];

vector<Point2f> l_im_p, r_im_p;

bool l_pattern_found = findChessboardCorners(lim, Size(width, height), l_im_p, CALIB_CB_ADAPTIVE_THRESH + CALIB_CB_NORMALIZE_IMAGE+ CALIB_CB_FAST_CHECK);

bool r_pattern_found = findChessboardCorners(rim, Size(width, height), r_im_p, CALIB_CB_ADAPTIVE_THRESH + CALIB_CB_NORMALIZE_IMAGE+ CALIB_CB_FAST_CHECK);

if(l_pattern_found && r_pattern_found) {

object_points.push_back(ob_p);

Mat gray;

cvtColor(lim, gray, CV_BGR2GRAY);

cornerSubPix(gray, l_im_p, Size(5, 5), Size(-1, -1), TermCriteria(CV_TERMCRIT_EPS + CV_TERMCRIT_ITER, 30, 0.1));

cvtColor(rim, gray, CV_BGR2GRAY);

cornerSubPix(gray, r_im_p, Size(5, 5), Size(-1, -1), TermCriteria(CV_TERMCRIT_EPS + CV_TERMCRIT_ITER, 30, 0.1));

l_image_points.push_back(l_im_p);

r_image_points.push_back(r_im_p);

if(show) {

Mat im_show = lim.clone();

drawChessboardCorners(im_show, Size(width, height), l_im_p, true);

imshow("Left Chessboard corners", im_show);

im_show = rim.clone();

drawChessboardCorners(im_show, Size(width, height), r_im_p, true);

imshow("Right Chessboard corners", im_show);

while(char(waitKey(1)) != ' ') {}

}

}

else {

l_images.erase(l_images.begin() + i);

r_images.erase(r_images.begin() + i);

}

}

}

bool calibrator::calibrate() {

string filename = DATA_FOLDER + string("left_cam_calib.xml");

FileStorage fs(filename, FileStorage::READ);

fs["cameraMatrix"] >> l_cameraMatrix;

fs["distCoeffs"] >> l_distCoeffs;

fs.release();

filename = DATA_FOLDER + string("right_cam_calib.xml");

fs.open(filename, FileStorage::READ);

fs["cameraMatrix"] >> r_cameraMatrix;

fs["distCoeffs"] >> r_distCoeffs;

fs.release();

if(!l_cameraMatrix.empty() && !l_distCoeffs.empty() && !r_cameraMatrix.empty() && !r_distCoeffs.empty()) {

double rms = stereoCalibrate(object_points, l_image_points, r_image_points, l_cameraMatrix, l_distCoeffs, r_cameraMatrix, r_distCoeffs, l_images[0].size(), R, T, E, F);

cout << "Calibrated stereo camera with a RMS error of " << rms << endl;

return true;

}

else return false;

}

void calibrator::save_info(string filename) {

FileStorage fs(filename, FileStorage::WRITE);

fs << "l_cameraMatrix" << l_cameraMatrix;

fs << "r_cameraMatrix" << r_cameraMatrix;

fs << "l_distCoeffs" << l_distCoeffs;

fs << "r_distCoeffs" << r_distCoeffs;

fs << "R" << R;

fs << "T" << T;

fs << "E" << E;

fs << "F" << F;

fs.release();

cout << "Calibration parameters saved to " << filename << endl;

}

Size calibrator::get_image_size() {

return l_images[0].size();

}

rectifier::rectifier(string filename, Size image_size) {

// Read individal camera calibration information from saved XML file

Mat l_cameraMatrix, l_distCoeffs, r_cameraMatrix, r_distCoeffs, R, T;

FileStorage fs(filename, FileStorage::READ);

fs["l_cameraMatrix"] >> l_cameraMatrix;

fs["l_distCoeffs"] >> l_distCoeffs;

fs["r_cameraMatrix"] >> r_cameraMatrix;

fs["r_distCoeffs"] >> r_distCoeffs;

fs["R"] >> R;

fs["T"] >> T;

fs.release();

if(l_cameraMatrix.empty() || r_cameraMatrix.empty() || l_distCoeffs.empty() || r_distCoeffs.empty() || R.empty() || T.empty())

cout << "Rectifier: Loading of files not successful" << endl;

// Calculate transforms for rectifying images

Mat Rl, Rr, Pl, Pr, Q;

stereoRectify(l_cameraMatrix, l_distCoeffs, r_cameraMatrix, r_distCoeffs, image_size, R, T, Rl, Rr, Pl, Pr, Q);

// Calculate pixel maps for efficient rectification of images via lookup tables

initUndistortRectifyMap(l_cameraMatrix, l_distCoeffs, Rl, Pl, image_size, CV_16SC2, map_l1, map_l2);

initUndistortRectifyMap(r_cameraMatrix, r_distCoeffs, Rr, Pr, image_size, CV_16SC2, map_r1, map_r2);

fs.open(filename, FileStorage::APPEND);

fs << "Rl" << Rl;

fs << "Rr" << Rr;

fs << "Pl" << Pl;

fs << "Pr" << Pr;

fs << "Q" << Q;

fs << "map_l1" << map_l1;

fs << "map_l2" << map_l2;

fs << "map_r1" << map_r1;

fs << "map_r2" << map_r2;

fs.release();

}

void rectifier::show_rectified(Size image_size) {

VideoCapture capr(1), capl(2);

//reduce frame size

capl.set(CV_CAP_PROP_FRAME_HEIGHT, image_size.height);

capl.set(CV_CAP_PROP_FRAME_WIDTH, image_size.width);

capr.set(CV_CAP_PROP_FRAME_HEIGHT, image_size.height);

capr.set(CV_CAP_PROP_FRAME_WIDTH, image_size.width);

destroyAllWindows();

namedWindow("Combo");

while(char(waitKey(1)) != 'q') {

//grab raw frames first

capl.grab();

capr.grab();

//decode later so the grabbed frames are less apart in time

Mat framel, framel_rect, framer, framer_rect;

capl.retrieve(framel);

capr.retrieve(framer);

if(framel.empty() || framer.empty()) break;

// Remap images by pixel maps to rectify

remap(framel, framel_rect, map_l1, map_l2, INTER_LINEAR);

remap(framer, framer_rect, map_r1, map_r2, INTER_LINEAR);

// Make a larger image containing the left and right rectified images side-by-side

Mat combo(image_size.height, 2 * image_size.width, CV_8UC3);

framel_rect.copyTo(combo(Range::all(), Range(0, image_size.width)));

framer_rect.copyTo(combo(Range::all(), Range(image_size.width, 2*image_size.width)));

// Draw horizontal red lines in the combo image to make comparison easier

for(int y = 0; y < combo.rows; y += 20)

line(combo, Point(0, y), Point(combo.cols, y), Scalar(0, 0, 255));

imshow("Combo", combo);

}

capl.release();

capr.release();

}

int main() {

string filename = DATA_FOLDER + string("stereo_calib.xml");

/*

calibrator calib(LEFT_FOLDER, RIGHT_FOLDER, 25.f, 5, 4);

calib.calc_image_points(true);

bool done = calib.calibrate();

if(!done) cout << "Stereo Calibration not successful because individial calibration matrices could not be read" << endl;

calib.save_info(filename);

Size image_size = calib.get_image_size();

*/

Size image_size(320, 240);

rectifier rec(filename, image_size);

rec.show_rectified(image_size);

return 0;

}

A978-1-4302-6080-6_10_Fig5_HTML.jpg

图 10-5。

Stereo rectification

现在,在两幅图像中获得匹配的像素并计算视差变得相当容易。OpenCV 在StereoSGBM类中实现了半全局块匹配(SGBM)算法。这种算法使用一种称为动态规划的技术来使匹配更加鲁棒。该算法有一系列与之相关的参数,主要是:

  • SADWindowSize:要匹配的块的大小(必须是奇数)
  • P1P2:控制计算出的视差的平滑度的参数。如果两个相邻像素之间的视差分别变化+/- 1 或大于 1,则它们是由动态规划匹配算法引起的“惩罚”
  • speckleWindowSizespeckleRange:视差往往有斑点。这些参数控制视差斑点过滤器。speckleWindowSize表示被认为是斑点的平滑视差区域的最大尺寸,而speckleRange表示
  • minDisparitynumberOfDisparities:这些参数共同控制立体声设置的“范围”。如前所述,右图像中匹配像素的位置在左图像中对应像素位置的左侧。如果minDisparity为 0,该算法从左图像中的像素位置开始在右图像中搜索匹配像素,并向左前进。如果minDisparity为正,则算法从左图像中位置的左侧(通过minDisparity像素)开始在右图像中搜索,然后向左进行。当两个摄像头指向彼此相反的方向时,您会希望这样。当两个摄像头相互指向对方时,您也可以将minDisparity设置为负。因此,搜索的开始位置由minDisparity确定。然而,搜索总是从其起始位置在右图像中向左进行。有多远?这是由numberOfDisparities决定的。注意numberOfDisparities必须是 OpenCV 实现的 16 的倍数

清单 10-6 展示了如何使用StereoSGBM类计算两幅校正图像的差异。它还允许您使用滑块试验minDisparitynumberOfDisparities的值。请注意StereoSGBM计算的视差转换为可视形式,因为StereoSGBM输出的原始视差被缩放 16 倍(阅读StereoSGBM的在线文档)。该程序使用 OpenCV stereo_match演示代码中立体算法的一些参数值。图 10-6 显示了特定场景的差异。

清单 10-6。程序说明了从一个校准的立体摄像机视差计算

// Program illustrate disparity calculation from a calibrated stereo camera

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/calib3d/calib3d.hpp>

#include "Config.h"

using namespace cv;

using namespace std;

class disparity {

private:

Mat map_l1, map_l2, map_r1, map_r2; // rectification pixel maps

StereoSGBM stereo; // stereo matching object for disparity computation

int min_disp, num_disp; // parameters of StereoSGBM

public:

disparity(string, Size); //constructor

void set_minDisp(int minDisp) { stereo.minDisparity = minDisp; }

void set_numDisp(int numDisp) { stereo.numberOfDisparities = numDisp; }

void show_disparity(Size); // show live disparity by processing stereo camera feed

};

// Callback functions for minDisparity and numberOfDisparities trackbars

void on_minDisp(int min_disp, void * _disp_obj) {

disparity * disp_obj = (disparity *) _disp_obj;

disp_obj -> set_minDisp(min_disp - 30);

}

void on_numDisp(int num_disp, void * _disp_obj) {

disparity * disp_obj = (disparity *) _disp_obj;

num_disp = (num_disp / 16) * 16;

setTrackbarPos("numDisparity", "Disparity", num_disp);

disp_obj -> set_numDisp(num_disp);

}

disparity::disparity(string filename, Size image_size) {

// Read pixel maps from XML file

FileStorage fs(filename, FileStorage::READ);

fs["map_l1"] >> map_l1;

fs["map_l2"] >> map_l2;

fs["map_r1"] >> map_r1;

fs["map_r2"] >> map_r2;

if(map_l1.empty() || map_l2.empty() || map_r1.empty() || map_r2.empty())

cout << "WARNING: Loading of mapping matrices not successful" << endl;

// Set SGBM parameters (from OpenCV stereo_match.cpp demo)

stereo.preFilterCap = 63;

stereo.SADWindowSize = 3;

stereo.P1 = 8 * 3 * stereo.SADWindowSize * stereo.SADWindowSize;

stereo.P2 = 32 * 3 * stereo.SADWindowSize * stereo.SADWindowSize;

stereo.uniquenessRatio = 10;

stereo.speckleWindowSize = 100;

stereo.speckleRange = 32;

stereo.disp12MaxDiff = 1;

stereo.fullDP = true;

}

void disparity::show_disparity(Size image_size) {

VideoCapture capr(1), capl(2);

//reduce frame size

capl.set(CV_CAP_PROP_FRAME_HEIGHT, image_size.height);

capl.set(CV_CAP_PROP_FRAME_WIDTH, image_size.width);

capr.set(CV_CAP_PROP_FRAME_HEIGHT, image_size.height);

capr.set(CV_CAP_PROP_FRAME_WIDTH, image_size.width);

min_disp = 30;

num_disp = ((image_size.width / 8) + 15) & -16;

namedWindow("Disparity", CV_WINDOW_NORMAL);

namedWindow("Left", CV_WINDOW_NORMAL);

createTrackbar("minDisparity + 30", "Disparity", &min_disp, 60, on_minDisp, (void *)this);

createTrackbar("numDisparity", "Disparity", &num_disp, 150, on_numDisp, (void *)this);

on_minDisp(min_disp, this);

on_numDisp(num_disp, this);

while(char(waitKey(1)) != 'q') {

//grab raw frames first

capl.grab();

capr.grab();

//decode later so the grabbed frames are less apart in time

Mat framel, framel_rect, framer, framer_rect;

capl.retrieve(framel);

capr.retrieve(framer);

if(framel.empty() || framer.empty()) break;

remap(framel, framel_rect, map_l1, map_l2, INTER_LINEAR);

remap(framer, framer_rect, map_r1, map_r2, INTER_LINEAR);

// Calculate disparity

Mat disp, disp_show;

stereo(framel_rect, framer_rect, disp);

// Convert disparity to a form easy for visualization

disp.convertTo(disp_show, CV_8U, 255/(stereo.numberOfDisparities * 16.));

imshow("Disparity", disp_show);

imshow("Left", framel);

}

capl.release();

capr.release();

}

int main() {

string filename = DATA_FOLDER + string("stereo_calib.xml");

Size image_size(320, 240);

disparity disp(filename, image_size);

disp.show_disparity(image_size);

return 0;

}

A978-1-4302-6080-6_10_Fig6_HTML.jpg

图 10-6。

Stereo disparity

如何从视差中获得深度?函数reprojectImageTo3d()通过获取视差和由stereoRectify()生成的视差-深度映射矩阵Q来实现这一点。清单 10-7 显示了一个简单的概念验证应用,它根据视差计算 3D 坐标,并打印出图像中心矩形内各点深度的平均值。从图 10-7 中可以看出,计算出的距离有一点跳跃,但是大部分时间都非常接近正确值 400 mm。回想一下StereoSGBM输出的视差是按 16 缩放的,所以我们必须除以 16 才能得到真实的视差。

清单 10-7。使用立体摄像机演示距离测量的程序

// Program illustrate distance measurement using a stereo camera

// Author: Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

#include <opencv2/calib3d/calib3d.hpp>

#include "Config.h"

using namespace cv;

using namespace std;

class disparity {

private:

Mat map_l1, map_l2, map_r1, map_r2, Q;

StereoSGBM stereo;

int min_disp, num_disp;

public:

disparity(string, Size);

void set_minDisp(int minDisp) { stereo.minDisparity = minDisp; }

void set_numDisp(int numDisp) { stereo.numberOfDisparities = numDisp; }

void show_disparity(Size);

};

void on_minDisp(int min_disp, void * _disp_obj) {

disparity * disp_obj = (disparity *) _disp_obj;

disp_obj -> set_minDisp(min_disp - 30);

}

void on_numDisp(int num_disp, void * _disp_obj) {

disparity * disp_obj = (disparity *) _disp_obj;

num_disp = (num_disp / 16) * 16;

setTrackbarPos("numDisparity", "Disparity", num_disp);

disp_obj -> set_numDisp(num_disp);

}

disparity::disparity(string filename, Size image_size) {

FileStorage fs(filename, FileStorage::READ);

fs["map_l1"] >> map_l1;

fs["map_l2"] >> map_l2;

fs["map_r1"] >> map_r1;

fs["map_r2"] >> map_r2;

fs["Q"] >> Q;

if(map_l1.empty() || map_l2.empty() || map_r1.empty() || map_r2.empty() || Q.empty())

cout << "WARNING: Loading of mapping matrices not successful" << endl;

stereo.preFilterCap = 63;

stereo.SADWindowSize = 3;

stereo.P1 = 8 * 3 * stereo.SADWindowSize * stereo.SADWindowSize;

stereo.P2 = 32 * 3 * stereo.SADWindowSize * stereo.SADWindowSize;

stereo.uniquenessRatio = 10;

stereo.speckleWindowSize = 100;

stereo.speckleRange = 32;

stereo.disp12MaxDiff = 1;

stereo.fullDP = true;

}

void disparity::show_disparity(Size image_size) {

VideoCapture capr(1), capl(2);

//reduce frame size

capl.set(CV_CAP_PROP_FRAME_HEIGHT, image_size.height);

capl.set(CV_CAP_PROP_FRAME_WIDTH, image_size.width);

capr.set(CV_CAP_PROP_FRAME_HEIGHT, image_size.height);

capr.set(CV_CAP_PROP_FRAME_WIDTH, image_size.width);

min_disp = 30;

num_disp = ((image_size.width / 8) + 15) & -16;

namedWindow("Disparity", CV_WINDOW_NORMAL);

namedWindow("Left", CV_WINDOW_NORMAL);

createTrackbar("minDisparity + 30", "Disparity", &min_disp, 60, on_minDisp, (void *)this);

createTrackbar("numDisparity", "Disparity", &num_disp, 150, on_numDisp, (void *)this);

on_minDisp(min_disp, this);

on_numDisp(num_disp, this);

while(char(waitKey(1)) != 'q') {

//grab raw frames first

capl.grab();

capr.grab();

//decode later so the grabbed frames are less apart in time

Mat framel, framel_rect, framer, framer_rect;

capl.retrieve(framel);

capr.retrieve(framer);

if(framel.empty() || framer.empty()) break;

remap(framel, framel_rect, map_l1, map_l2, INTER_LINEAR);

remap(framer, framer_rect, map_r1, map_r2, INTER_LINEAR);

Mat disp, disp_show, disp_compute, pointcloud;

stereo(framel_rect, framer_rect, disp);

disp.convertTo(disp_show, CV_8U, 255/(stereo.numberOfDisparities * 16.));

disp.convertTo(disp_compute, CV_32F, 1.f/16.f);

// Calculate 3D co-ordinates from disparity image

reprojectImageTo3D(disp_compute, pointcloud, Q, true);

// Draw red rectangle around 40 px wide square area im image

int xmin = framel.cols/2 - 20, xmax = framel.cols/2 + 20, ymin = framel.rows/2 - 20, ymax = framel.rows/2 + 20;

rectangle(framel_rect, Point(xmin, ymin), Point(xmax, ymax), Scalar(0, 0, 255));

// Extract depth of 40 px rectangle and print out their mean

pointcloud = pointcloud(Range(ymin, ymax), Range(xmin, xmax));

Mat z_roi(pointcloud.size(), CV_32FC1);

int from_to[] = {2, 0};

mixChannels(&pointcloud, 1, &z_roi, 1, from_to, 1);

cout << "Depth: " << mean(z_roi) << " mm" << endl;

imshow("Disparity", disp_show);

imshow("Left", framel_rect);

}

capl.release();

capr.release();

}

int main() {

string filename = DATA_FOLDER + string("stereo_calib.xml");

Size image_size(320, 240);

disparity disp(filename, image_size);

disp.show_disparity(image_size);

return 0;

}

A978-1-4302-6080-6_10_Fig7a_HTML.jpg A978-1-4302-6080-6_10_Fig7b_HTML.jpg

图 10-7。

Depth measurement using stereo vision

摘要

3D 几何不刺激吗?如果你和我一样,对一堆矩阵如何使你能够从一对照片中测量物理图像的实际距离感到着迷,你应该阅读理查德·哈特利和安德鲁·齐泽曼(剑桥大学出版社,2004 年)的《计算机视觉中的多视图几何》,这是这方面的经典著作。立体视觉为您开辟了许多可能性,最重要的是,它允许您在其他计算机视觉算法的输出和现实世界之间建立直接关系!例如,您可以将立体视觉与 object detector 应用相结合,以可靠地了解到被检测对象的物理单位距离。

下一章将带你向在实际移动机器人上部署计算机视觉算法更近一步——它讨论了在 Raspberry Pi 上运行 OpenCV 应用的细节,Raspberry Pi 是一种多功能的小(但功能强大)微控制器,在嵌入式应用中日益闻名。

十一、嵌入式计算机视觉:在 Raspberry Pi 上运行 OpenCV 程序

Abstract

嵌入式计算机视觉是计算机视觉的一个非常实用的分支,它关注于开发或修改视觉算法,以在嵌入式系统上运行,嵌入式系统是指小型移动计算机,如智能手机处理器或爱好板。嵌入式计算机视觉系统的两个主要考虑因素是明智地使用处理能力和低电池功耗。作为嵌入式计算系统的一个例子,我们将考虑 Raspberry Pi(图 11-1),这是一款基于 ARM 处理器的小型开源计算机,由于其易用性、多功能性以及令人惊讶的低成本(尽管具有良好的构建和支持质量),在爱好者和研究人员中迅速受到欢迎。

嵌入式计算机视觉是计算机视觉的一个非常实用的分支,它关注于开发或修改视觉算法,以在嵌入式系统上运行,嵌入式系统是指小型移动计算机,如智能手机处理器或爱好板。嵌入式计算机视觉系统的两个主要考虑因素是明智地使用处理能力和低电池功耗。作为嵌入式计算系统的一个例子,我们将考虑 Raspberry Pi(图 11-1 ),这是一款基于 ARM 处理器的小型开源计算机,由于其易用性、多功能性以及令人惊讶的低成本(尽管构建和支持质量良好),在爱好者和研究人员中迅速受到欢迎。

树莓派

A978-1-4302-6080-6_11_Fig1_HTML.jpg

图 11-1。

The Raspberry Pi board

Raspberry Pi 是一台小型计算机,可以从 SD 存储卡运行基于 Linux 的操作系统。它有一个 700 MHz 的 ARM 处理器和一个小型的 Broadcom VideoCore IV 250 MHz GPU。CPU 和 GPU 共享 512 MB 的 SDRAM 内存,您可以根据您的使用模式更改两者之间的内存共享。如图 11-1 所示,Pi 具有一个以太网、一个 HDMI、两个 USB 2.0 端口、8 个通用输入/输出引脚和一个 UART 以与其他设备交互。最近还发布了 5 MP 相机板,以促进 Pi 在小规模计算机视觉应用中的使用。这个相机板有它自己的特殊并行连接器;因此,它有望支持比连接到 USB 端口之一的网络摄像头更高的帧速率。Pi 也非常省电——它可以通过电脑的 USB 端口或 iPhone 的 USB 壁式充电器供电!

设置您的新树莓派

因为 Pi 只是一个处理器,上面有各种通信端口和引脚,如果你打算购买一个,请确保订购时附带所有必需的附件。这些主要包括连接器电缆:

  • 用于连接互联网的以太网电缆
  • USB-A 到 USB-B 转换器电缆,用于从通电的 USB 端口供电
  • 用于连接显示器的 HDMI 电缆
  • 摄像头模块(可选)
  • USB 扩展器集线器如果您计划将两个以上的 USB 设备连接到 Pi
  • 容量至少为 4 GB 的 SD 存储卡,用于安装操作系统和其他程序

虽然可以在 Pi 上安装几个基于 Linux 的操作系统,但是推荐初学者使用 Raspbian(本文撰写时的最新版本“wheezy ”),它是针对 Pi 优化的 Debian 版本。本章的其余部分将假设您已经在 Pi 上安装了 Raspbian,因为它开箱即用,而且有大量的社区支持。一旦你安装了操作系统并连接了显示器、键盘和鼠标,它就变成了一台功能齐全的电脑!典型的基于 GUI 的设置如图 11-2 所示。

A978-1-4302-6080-6_11_Fig2_HTML.jpg

图 11-2。

Raspberry Pi connected to keyboard, mouse, and monitor—a complete computer!

在码头上安装 Raspbian

您将需要一张容量至少为 4 GB 的空白 SD 卡,并能访问一台计算机来完成这个简单的过程。如果你是第一次使用,建议你在 www.raspberrypi.org/downloads 使用专门打包的新开箱软件(NOOBS)。使用它的详细说明可以在 www.raspberrypi.org/wp-content/uploads/2012/04/quick-start-guide-v2_1.pdf 的快速入门指南中找到。本质上,您:

  • 将 NOOBS 软件下载到您的计算机上
  • 在 SD 卡上解压
  • 将 SD 卡插入 Pi,并使用分别连接到 USB 和 HDMI 的键盘和显示器启动
  • 遵循屏幕上的简单说明,确保选择 Raspbian 作为要安装的操作系统

请注意,这将创建一个密码为“raspberry”的用户帐户“pi”这个帐户也有 sudo 访问相同的密码。

初始设置

安装 Raspbian 后,重启并按住“Shift”进入raspi-config设置画面,如图 11-3 所示。

  • 首先,您应该使用'expand_rootfs'选项来使 Pi 使用您的整个存储卡。
  • 您也可以使用'memory_split'选项更改 RAM 内存共享设置。我的建议是尽可能多地分配给 CPU,因为 OpenCV 还不支持 VideoCore GPU,所以我们最终将使用 ARM CPU 进行所有计算。
  • 您还可以使用raspi-config中的'boot_behavior'选项来指定 Pi 是引导到 GUI 还是命令行。显然,维护 GUI 会占用处理能力,所以我建议您熟悉 Linux 终端并关闭 GUI。访问已经设置为引导到命令行的 Pi 的典型方法是使用 X-forwarding 通过 SSH 进入它。这实质上意味着您使用以太网连接到 Pi,并从您计算机上的终端访问 Pi 的终端。X-forwarding 意味着 Pi 将你的计算机屏幕渲染成任何窗口(例如,OpenCV imshow()窗口)。Adafruit 在 http://learn.adafruit.com/downloads/pdf/adafruits-raspberry-pi-lesson-6-using-ssh.pdf .为首次使用 SSH 的用户提供了很好的指南

A978-1-4302-6080-6_11_Fig3_HTML.jpg

图 11-3。

The raspi-config screen

安装 OpenCV

因为 Raspbian 是一个 Linux 版本,以 Debian 作为它的包管理系统,安装 OpenCV 的过程与在 64 位系统上安装的过程完全相同,如第二章中所述。一旦你安装了 OpenCV,你就可以像在你的电脑上一样运行演示程序了。如果您将 USB 网络摄像头连接到 Pi,需要摄像头进行实时反馈的演示也可以进行。

图 11-4 显示了从 Pi 上的 USB 摄像头运行的 OpenCV 视频单应性估计演示(cpp-example-video_homography)。您可能还记得,这个函数跟踪帧中的角点,然后根据用户可以选择的帧找到一个变换(用绿线表示)。如果您自己运行这个演示,您会发现 Pi 中的 700 MHz 处理器不足以处理此类演示中的 640x480 帧——帧速率非常低。

A978-1-4302-6080-6_11_Fig4_HTML.jpg

图 11-4。

OpenCV built-in video homography demo being run on the Raspberry Pi

图 11-5 显示了 OpenCV 凸包演示(cpp-example-convexhull),它选择了一组随机的点,并在 Pi 上计算出它们周围的最小凸包。

A978-1-4302-6080-6_11_Fig5_HTML.jpg

图 11-5。

OpenCV convex hull demo being run on the Pi

摄像板

树莓派 的制造商最近还发布了一个定制的相机板,以鼓励 Pi 的图像处理和计算机视觉应用。该板很小,重量只有 3 克,但它拥有一个 500 万像素的 CMOS 传感器。它使用带状电缆连接到 Pi,看起来如图 11-6 所示。

A978-1-4302-6080-6_11_Fig6_HTML.jpg

图 11-6。

The Raspberry Pi with the camera board attached to it

www.raspberrypi.org/camera 按照指导视频连接后,您需要从raspi-config屏幕启用它(可以在启动时按住“Shift”或在终端键入sudo raspi-config调出)。预装的实用程序raspistillraspivid可分别用于捕捉静态图像和视频。他们提供了许多捕捉选项,你可以调整。你可以通过浏览 https://github.com/raspberrypi/userland/blob/master/host_applications/linux/apps/raspicam/RaspiCamDocs.odt 的文档来了解更多。这些应用的源代码是开源的,因此我们将看到如何使用它来使开发板与 OpenCV 进行交互。

相机板与 USB 相机

你可能会问,当你可以使用像你在台式电脑上使用的那种简单的 USB 相机时,为什么要使用相机板呢?两个原因:

The Raspberry Pi camera board connects to the Pi using a special connector that is designed to transfer data in parallel. This makes is faster than a USB camera   The camera board has a better sensor than a lot of other USB cameras that cost the same (or even more)

唯一的问题是,因为该板不是 USB 设备,OpenCV 不能识别它的开箱即用。我做了一个小的 C++包装程序,通过重用我在 Raspberry Pi 论坛上找到的一些 C 代码,从相机缓冲区中抓取字节,并将它们放入 OpenCV Mat中。这段代码利用了相机驱动程序开发者发布的开源代码。这个程序允许你指定你要捕获的帧的大小和一个布尔标志,该标志指示捕获的帧应该是彩色的还是灰度的。相机以(YUV 颜色空间)的 Y 通道和二次采样 U 和 V 通道的形式返回图像信息。为了获得灰度图像,包装程序只需将 Y 通道设置为 OpenCV Mat的数据源。如果你想要颜色,事情会变得复杂(和缓慢)。为了获得彩色图像,包装程序必须执行以下步骤:

  • 将 Y、U 和 V 通道复制到 OpenCV Mats 中
  • 调整 U 和 V 通道的大小,因为相机返回的 U 和 V 是二次采样的
  • 执行 YUV 到 RGB 转换

这些操作非常耗时,因此,如果您想要从相机板传输彩色图像,帧速率会下降。

相比之下,默认情况下,USB 摄像头捕捉的是彩色图像,你必须花费额外的时间将其转换为灰度图像。因此,除非你知道如何将相机的捕捉模式设置为灰度,否则来自 USB 相机的灰度图像比彩色图像更昂贵。

正如我们到目前为止所看到的,大多数计算机视觉应用都是基于强度进行操作的,因此只需要灰度图像。这是使用相机板而不是 USB 相机的另一个原因。让我们看看如何使用包装器代码从 Pi 相机板获取帧。注意,这个过程需要在 Pi 上安装 OpenCV。

  • 如果你从raspi-config,开始启用你的相机,你应该已经有了与/opt/vc/.中的相机板接口的源代码和库,通过查看命令raspistillraspivid是否按预期工作来确认这一点。
  • 通过在终端中导航到您的主文件夹中的任何目录并运行以下命令,克隆官方的 MMAL Github 存储库以获得所需的头文件。注意,在清单 11-1 中的 CMakeLists.txt 文件中,这个目录将被称为 USERLAND_DIR,所以要确保用这个目录的完整路径替换这个文件中的 USERLAND_DIR。如果您的 Raspberry Pi 上没有安装 Git,您可以通过在终端中键入sudo apt-get install git来安装它。)

git clonehttps://github.com/raspberrypi/userland.git

  • 包装程序将与 CMake 构建环境一起工作。在一个单独的文件夹中(下面称为 DIR ),创建名为“src”和“include”的文件夹将代码 10-1 中的Picam.cppcap.h文件分别放入“src”和“include”文件夹。这些构成了包装代码
  • 要使用包装器代码,创建一个简单的文件,如代码 10-1 所示的 main.cpp,从相机板获取帧,在窗口中显示它们,并测量帧速率。将文件放在“src”文件夹中
  • 想法是创建一个可执行文件,使用来自main.cppPiCapture.cpp文件的函数和类。这可以通过使用类似代码 10-1 所示的CMakeLists.txt文件并将其保存在 DIR 中来完成
  • 要编译和构建可执行文件,请在 DIR 中运行

mkdir build

cd build

cmake ..

make

下面是关于包装器代码的一点解释。正如您可能已经意识到的那样,包装器代码创建了一个名为PiCapture的类,该类的构造函数接受宽度、高度和布尔标志(true 表示抓取彩色图像)。该类还定义了一个名为grab()的方法,该方法返回一个 OpenCV Mat,其中包含适当大小和类型的抓取图像。

清单 11-1。简单的 CMake 项目展示了从 Raspberry Pi 相机板捕获帧的包装器代码

main.cpp:

//Code to check the OpenCV installation on Raspberry Pi and measure frame rate

//Author: Samarth Manoj Brahmbhatt, University of Pennsyalvania

#include "cap.h"

#include <opencv2/opencv.hpp>

#include <opencv2/highgui/highgui.hpp>

using namespace cv;

using namespace std;

int main() {

namedWindow("Hello");

PiCapture cap(320, 240, false);

Mat im;

double time = 0;

unsigned int frames = 0;

while(char(waitKey(1)) != 'q') {

double t0 = getTickCount();

im = cap.grab();

frames++;

if(!im.empty()) imshow("Hello", im);

else cout << "Frame dropped" << endl;

time += (getTickCount() - t0) / getTickFrequency();

cout << frames / time << " fps" << endl;

}

return 0;

}

cap.h:

#include <opencv2/opencv.hpp>

#include "interface/mmal/mmal.h"

#include "interface/mmal/util/mmal_default_components.h"

#include "interface/mmal/util/mmal_connection.h"

#include "interface/mmal/util/mmal_util.h"

#include "interface/mmal/util/mmal_util_params.h"

class PiCapture {

private:

MMAL_COMPONENT_T *camera;

MMAL_COMPONENT_T *preview;

MMAL_ES_FORMAT_T *format;

MMAL_STATUS_T status;

MMAL_PORT_T *camera_preview_port, *camera_video_port, *camera_still_port;

MMAL_PORT_T *preview_input_port;

MMAL_CONNECTION_T *camera_preview_connection;

bool color;

public:

static cv::Mat image;

static int width, height;

static MMAL_POOL_T *camera_video_port_pool;

static void set_image(cv::Mat _image) {image = _image;}

PiCapture(int, int, bool);

cv::Mat grab() {return image;}

};

static void color_callback(MMAL_PORT_T *, MMAL_BUFFER_HEADER_T *);

static void gray_callback(MMAL_PORT_T *, MMAL_BUFFER_HEADER_T *);

PiCapture.cpp:

/*

* File: opencv_demo.c

* Author: Tasanakorn

*

* Created on May 22, 2013, 1:52 PM

*/

// OpenCV 2.x C++ wrapper written by Samarth Manoj Brahmbhatt, University of Pennsylvania

#include <stdio.h>

#include <stdlib.h>

#include <opencv2/opencv.hpp>

#include "bcm_host.h"

#include "interface/mmal/mmal.h"

#include "interface/mmal/util/mmal_default_components.h"

#include "interface/mmal/util/mmal_connection.h"

#include "interface/mmal/util/mmal_util.h"

#include "interface/mmal/util/mmal_util_params.h"

#include "cap.h"

#define MMAL_CAMERA_PREVIEW_PORT 0

#define MMAL_CAMERA_VIDEO_PORT 1

#define MMAL_CAMERA_CAPTURE_PORT 2

using namespace cv;

using namespace std;

int PiCapture::width = 0;

int PiCapture::height = 0;

MMAL_POOL_T * PiCapture::camera_video_port_pool = NULL;

Mat PiCapture::image = Mat();

static void color_callback(MMAL_PORT_T *port, MMAL_BUFFER_HEADER_T *buffer) {

MMAL_BUFFER_HEADER_T *new_buffer;

mmal_buffer_header_mem_lock(buffer);

unsigned char* pointer = (unsigned char *)(buffer -> data);

int w = PiCapture::width, h = PiCapture::height;

Mat y(h, w, CV_8UC1, pointer);

pointer = pointer + (h*w);

Mat u(h/2, w/2, CV_8UC1, pointer);

pointer = pointer + (h*w/4);

Mat v(h/2, w/2, CV_8UC1, pointer);

mmal_buffer_header_mem_unlock(buffer);

mmal_buffer_header_release(buffer);

if (port->is_enabled) {

MMAL_STATUS_T status;

new_buffer = mmal_queue_get(PiCapture::camera_video_port_pool->queue);

if (new_buffer)

status = mmal_port_send_buffer(port, new_buffer);

if (!new_buffer || status != MMAL_SUCCESS)

printf("Unable to return a buffer to the video port\n");

}

Mat image(h, w, CV_8UC3);

resize(u, u, Size(), 2, 2, INTER_LINEAR);

resize(v, v, Size(), 2, 2, INTER_LINEAR);

int from_to[] = {0, 0};

mixChannels(&y, 1, &image, 1, from_to, 1);

from_to[1] = 1;

mixChannels(&v, 1, &image, 1, from_to, 1);

from_to[1] = 2;

mixChannels(&u, 1, &image, 1, from_to, 1);

cvtColor(image, image, CV_YCrCb2BGR);

PiCapture::set_image(image);

}

static void gray_callback(MMAL_PORT_T *port, MMAL_BUFFER_HEADER_T *buffer) {

MMAL_BUFFER_HEADER_T *new_buffer;

mmal_buffer_header_mem_lock(buffer);

unsigned char* pointer = (unsigned char *)(buffer -> data);

PiCapture::set_image(Mat(PiCapture::height, PiCapture::width, CV_8UC1, pointer));

mmal_buffer_header_release(buffer);

if (port->is_enabled) {

MMAL_STATUS_T status;

new_buffer = mmal_queue_get(PiCapture::camera_video_port_pool->queue);

if (new_buffer)

status = mmal_port_send_buffer(port, new_buffer);

if (!new_buffer || status != MMAL_SUCCESS)

printf("Unable to return a buffer to the video port\n");

}

}

PiCapture::PiCapture(int _w, int _h, bool _color) {

color = _color;

width = _w;

height = _h;

camera = 0;

preview = 0;

camera_preview_port = NULL;

camera_video_port = NULL;

camera_still_port = NULL;

preview_input_port = NULL;

camera_preview_connection = 0;

bcm_host_init();

status = mmal_component_create(MMAL_COMPONENT_DEFAULT_CAMERA, &camera);

if (status != MMAL_SUCCESS) {

printf("Error: create camera %x\n", status);

}

camera_preview_port = camera->output[MMAL_CAMERA_PREVIEW_PORT];

camera_video_port = camera->output[MMAL_CAMERA_VIDEO_PORT];

camera_still_port = camera->output[MMAL_CAMERA_CAPTURE_PORT];

{

MMAL_PARAMETER_CAMERA_CONFIG_T cam_config = {

{ MMAL_PARAMETER_CAMERA_CONFIG, sizeof (cam_config)}, width, height, 0, 0, width, height, 3, 0, 1, MMAL_PARAM_TIMESTAMP_MODE_RESET_STC };

mmal_port_parameter_set(camera->control, &cam_config.hdr);

}

format = camera_video_port->format;

format->encoding = MMAL_ENCODING_I420;

format->encoding_variant = MMAL_ENCODING_I420;

format->es->video.width = width;

format->es->video.height = height;

format->es->video.crop.x = 0;

format->es->video.crop.y = 0;

format->es->video.crop.width = width;

format->es->video.crop.height = height;

format->es->video.frame_rate.num = 30;

format->es->video.frame_rate.den = 1;

camera_video_port->buffer_size = width * height * 3 / 2;

camera_video_port->buffer_num = 1;

status = mmal_port_format_commit(camera_video_port);

if (status != MMAL_SUCCESS) {

printf("Error: unable to commit camera video port format (%u)\n", status);

}

// create pool form camera video port

camera_video_port_pool = (MMAL_POOL_T *) mmal_port_pool_create(camera_video_port, camera_video_port->buffer_num, camera_video_port->buffer_size);

if(color) {

status = mmal_port_enable(camera_video_port, color_callback);

if (status != MMAL_SUCCESS)

printf("Error: unable to enable camera video port (%u)\n", status);

else

cout << "Attached color callback" << endl;

}

else {

status = mmal_port_enable(camera_video_port, gray_callback);

if (status != MMAL_SUCCESS)

printf("Error: unable to enable camera video port (%u)\n", status);

else

cout << "Attached gray callback" << endl;

}

status = mmal_component_enable(camera);

// Send all the buffers to the camera video port

int num = mmal_queue_length(camera_video_port_pool->queue);

int q;

for (q = 0; q < num; q++) {

MMAL_BUFFER_HEADER_T *buffer = mmal_queue_get(camera_video_port_pool->queue);

if (!buffer) {

printf("Unable to get a required buffer %d from pool queue\n", q);

}

if (mmal_port_send_buffer(camera_video_port, buffer) != MMAL_SUCCESS) {

printf("Unable to send a buffer to encoder output port (%d)\n", q);

}

}

if (mmal_port_parameter_set_boolean(camera_video_port, MMAL_PARAMETER_CAPTURE, 1) != MMAL_SUCCESS) {

printf("%s: Failed to start capture\n", __func__);

}

cout << "Capture started" << endl;

}

CMakeLists.txt:

cmake_minimum_required(VERSION 2.8)

project(PiCapture)

SET(COMPILE_DEFINITIONS -Werror)

find_package( OpenCV REQUIRED )

include_directories(/opt/vc/include)

include_directories(/opt/vc/include/interface/vcos/pthreads)

include_directories(/opt/vc/include/interface/vmcs_host)

include_directories(/opt/vc/include/interface/vmcs_host/linux)

include_directories(USERLAND_DIR)

include_directories("${PROJECT_SOURCE_DIR}/include")

link_directories(/opt/vc/lib)

link_directories(/opt/vc/src/hello_pi/libs/vgfont)

add_executable(main src/main.cpp src/PiCapture.cpp)

target_link_libraries(main mmal_core mmal_util mmal_vc_client bcm_host ${OpenCV_LIBS})

从现在开始,你可以使用同样的策略从相机板抓取帧——在你的源文件中包含cam.h,并创建一个使用你的源文件和PiCapture.cpp的可执行文件。

帧速率比较

图 11-7 显示了 USB 摄像头与摄像头板的帧率比较。使用的程序很简单;他们只是抓取帧,并在循环中使用imshow()显示它们。在相机板的情况下,PiCapture构造函数的参数列表中的布尔标志用于从彩色切换到灰度。对于 USB 摄像头,通过对彩色图像使用cvtColor()来获得灰度图像。

A978-1-4302-6080-6_11_Fig7_HTML.jpg

图 11-7。

Frame-rate tests. Clockwise from top left: Grayscale from USB camera, grayscale from camera board, color from camera board, and color from USB camera

帧率的计算和之前一样使用 OpenCV 函数getTickCount()getTickFrequency()来完成。然而,有时在PiCapture.cpp进行的并行进程似乎弄乱了滴答计数,据报道相机板测得的帧速率比实际要高得多。当你自己运行程序时,你会发现这两种方法对彩色图像给出了几乎相同的帧速率(视觉上),但从相机板抓取比使用 USB 相机对灰度图像快得多。

用法示例

一旦你在你的 Pi 上设置好了所有的东西,它的行为就非常像你的普通计算机,你应该能够运行你在普通计算机上运行的所有 OpenCV 代码,除了它会运行得更慢。作为使用示例,我们将检查我们在本书中开发的两种类型的对象检测器的帧速率,基于颜色的和基于 ORB 关键点的。

基于颜色的对象检测器

还记得我们在第五章中完善的基于颜色的目标检测器吗(清单 5-7)?让我们看看它是如何运行的 USB 摄像头与彩色图像抓取使用包装代码。图 11-8 显示,对于尺寸为 320 x 240 的帧,如果我们使用包装代码抓取帧,检测器的运行速度会快 50 %!

A978-1-4302-6080-6_11_Fig8_HTML.jpg

图 11-8。

Color-based object detector using color frames from USB camera (top) and camera board (bottom)

基于 ORB 关键点的对象检测器

我们在第八章(清单 8-4)中开发的基于 ORB 关键点的对象检测器需要比计算直方图反投影多得多的计算量,因此当瓶颈不是帧抓取速度而是帧处理速度时,看看这两种方法如何执行是个好主意。图 11-9 显示使用包装器代码抓取 320 x 240 灰度帧(记住,ORB 关键点检测和描述只需要一张灰度图像)比从 USB 摄像头抓取彩色帧并手动将其转换为灰度图像快 14%。

A978-1-4302-6080-6_11_Fig9_HTML.jpg

图 11-9。

ORB-based object detector using frames grabbed from a USB camera (top) and camera board (bottom)

摘要

通过这一章,我结束了我们对嵌入式计算机视觉这一迷人领域的介绍。本章详细介绍了如何在无处不在的 Raspberry Pi 上运行自己的视觉算法,包括使用其时髦的新相机板。尽情发挥你的想象力,想出一些令人敬畏的用例!你会发现知道如何让一个 Raspberry Pi 理解它所看到的是一个非常强大的工具,它的应用是无限的。

除了 Pi 之外,市场上还有很多嵌入式系统可以运行 OpenCV。我的建议是选择一款最适合您的应用和预算的产品,并真正善于有效地使用它,而不是对所有嵌入式视觉产品都有所了解。归根结底,这些平台只是达到目的的一种手段;造成差异的是算法和思路。

第一部分:熟悉

第二部分:高级计算机视觉问题及其在 OpenCV 中的编码

posted @ 2024-10-01 20:58  绝不原创的飞龙  阅读(99)  评论(0编辑  收藏  举报