十、开发用于文本识别的分割算法
在前面的章节中,我们学习了广泛的图像处理技术,如阈值、轮廓描述符和数学形态学。 在本章中,我们将讨论您在处理扫描文档时可能遇到的常见问题,例如识别文本位置或调整其旋转。 我们还将学习如何结合前面章节中介绍的技术来解决这些问题。 在本章结束时,我们将拥有可发送到光学字符识别(OCR)库的文本分段区域。
在本章结束时,您应该能够回答以下问题:
- 存在哪些类型的 OCR 应用?
- 编写 OCR 应用时有哪些常见问题?
- 如何识别文档的区域?
- 如何处理文本中间的歪斜和其他元素等问题?
- 如何使用 Tesseract OCR 识别我的文本?
技术要求
本章要求熟悉基本的 C++ 编程语言。 本章中使用的所有代码都可以从以下 giHub 链接下载:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter10GitHub。 该代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。
请查看以下视频,了解实际操作中的代码:
http://bit.ly/2KIoJFX
光学字符识别简介
识别图像中的文本是计算机视觉中非常流行的应用。 该过程通常被称为光学字符识别,其划分如下:
- 文本预处理和分割:在此步骤中,计算机必须处理图像噪声和旋转(倾斜),并识别哪些区域是候选文本。
- 文本识别:这是识别文本中每个字母的过程,将在后面的章节中介绍。
预处理和分割阶段可能会因文本来源的不同而大不相同。 让我们来看看进行预处理的常见情况:
- 带有扫描仪的生产 OCR 应用:这是一个非常可靠的文本来源。 在这种情况下,图像的背景通常为白色,文档几乎与扫描仪页边距对齐。 被扫描的内容基本上都是文本,几乎没有噪音。 这类应用依赖于简单的预处理技术,可以快速调整文本并保持快速扫描速度。 在编写生产 OCR 软件时,通常会将重要文本区域的标识委托给用户,并创建用于文本验证和索引的质量管道。
- 扫描随意拍摄的照片或视频中的文本:这是一个复杂得多的场景,因为没有指示文本可能在哪里。 此场景称为场景文本识别,OpenCV 4.0 包含一个 Conrib 库来处理它。 我们将在第 11 章,使用 Tesseract 进行文本识别中介绍这一点。 通常,预处理器将使用纹理分析技术来识别文本模式。
- 为历史文本创建制作质量的 OCR:历史文本也会被扫描,但它们有几个额外的问题,例如旧纸张颜色和墨水的使用产生的噪音。 其他常见的问题是装饰字母和特定的文本字体,以及由墨水创建的低对比度内容,这些内容会随着时间的推移而被擦除。 为手头的文档编写特定的 OCR 软件并不少见。
- 扫描地图、图表、和图表:地图、图表和图表构成了一个特别困难的场景,因为文本通常位于图像内容的任何方向和中间。 例如,城市名称通常是群集的,海洋名称通常遵循国家海岸等高线。 有些图表颜色浓重,文本以清晰和深色两种色调显示。
根据识别目标的不同,OCR 应用策略也会有所不同。 它会用于全文搜索吗? 或者,是否应该将文本分成多个逻辑字段,以便用结构化搜索的信息为数据库编制索引?
在本章中,我们将重点介绍对扫描的文本或由相机拍摄的文本进行预处理。 我们将考虑文本是图像的主要目的,例如,在一张照片纸或卡片中,例如,在这张停车罚单中:
我们将尝试删除常见的噪声,处理文本旋转(如果有的话),并裁剪可能的文本区域。 虽然大多数 OCRAPI 已经自动完成了这些工作–可能还使用了最先进的算法–但了解事情是如何在幕后发生的仍然是值得的。 这将使您更好地了解大多数 OCR API 参数,并使您更好地了解可能面临的潜在 OCR 问题。
预处理阶段
识别字母的软件通过将文本与之前记录的数据进行比较来实现这一点。 如果输入的文本清晰,如果字母处于垂直位置,并且没有其他元素(如发送到分类软件的图像),则分类结果可以大大提高。 在本节中,我们将学习如何使用预处理来调整文本。
对图像进行阈值处理
我们通常通过对图像进行阈值处理来开始预处理。 这将消除所有颜色信息。 大多数 OpenCV 函数认为信息是用白色书写的,而背景是黑色的。 因此,让我们首先创建一个阈值函数来匹配此条件:
#include opencv2/opencv.hpp;
#include vector;
using namespace std;
using namespace cv;
Mat binarize(Mat input)
{
//Uses otsu to threshold the input image
Mat binaryImage;
cvtColor(input, input, COLOR_BGR2GRAY);
threshold(input, binaryImage, 0, 255, THRESH_OTSU);
//Count the number of black and white pixels
int white = countNonZero(binaryImage);
int black = binaryImage.size().area() - white;
//If the image is mostly white (white background), invert it
return white black ? binaryImage : ~binaryImage;
}
binarize函数应用阈值,类似于我们在第 4 章、深入研究直方图和过滤器中所做的操作。 但在这里,我们将通过在函数的第四个参数中传递THRESH_OTSU来使用 Otsu 方法。Otsu 方法最大化类间方差。 由于阈值仅创建两个类别(黑色和白色像素),因此这与最小化类内方差相同。 此方法使用图像直方图工作。 然后,它迭代所有可能的阈值,并为阈值的每一侧(即图像的背景或前景中的像素)计算像素值的散布。 这个过程的目的是找出两个价差之和最小的阈值。
阈值设置完成后,该函数计算图像中有多少白色像素。 黑色像素就是图像区域给出的图像中的总像素数减去白色像素数。 由于文本通常是在纯背景上书写的,因此我们将验证是否存在更多的白色像素而不是黑色像素。 在本例中,我们处理的是白色背景上的黑色文本,因此我们将反转图像以进行进一步处理。
对停车罚单图像进行阈值处理的结果如下:
文本分割
下一步是找到文本所在的位置并将其提取出来。 为此,有两种常见的策略:
- 使用连通分量分析:搜索图像中的连通像素组。 这将是本章将使用的技术。
- 使用分类器搜索先前训练的字母纹理模式:对于纹理特征,如Haralick 和特征,通常使用小波变换。 另一种选择是在本任务中识别个最稳定的极值区域(MSERs)。 这种方法对于复杂背景中的文本更加健壮,将在第 11 章,使用 Tesseract进行文本识别中进行研究。 你可以在他自己的网站上读到关于哈拉里克的特写,可以在http://haralick.org/journals/TexturalFeatures.pdf上找到。
创建连接区域
如果仔细观察图像,您会注意到字母总是以块的形式排列在一起,由文本段落组成。 这就给我们留下了一个问题,我们如何检测和删除这些块?
第一步是让这些障碍更加明显。 我们可以通过使用膨胀形态运算符来实现这一点。 回想一下第 8 章、视频监控、背景建模、和形态运算,这种膨胀会使图像元素变得更厚。 让我们看一小段能做到这一点的代码片段:
auto kernel = getStructuringElement(MORPH_CROSS, Size(3,3));
Mat dilated;
dilate(input, dilated, kernel, cv::Point(-1, -1), 5);
imshow("Dilated", dilated);
在前面的代码中,我们首先创建一个将在形态学操作中使用的 3x3 交叉内核。 然后,我们以这个内核为中心,进行五次膨胀。 确切的内核大小和次数因情况而异。 只需确保这些值将同一行中的所有字母粘合在一起即可。
此操作的结果显示在以下屏幕截图中:
请注意,我们现在有了巨大的白色方块。 它们与文本的每一段精确匹配,也与其他非文本元素(如图像或边界噪声)匹配。
The ticket image that comes with the code is a low resolution image. OCR engines usually work with high resolution images (200 or 300 DPI), so it may be necessary to apply dilation more than five times.
标识段落块
下一步是执行连接分量分析,以找到与段落对应的块。 OpenCV 具有此功能,我们之前在第 5 章、自动光学检测、对象分割、和检测中使用过。 这是findContours函数:
vector;vector;Point;contours;
findContours(dilated, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
在第一个参数中,我们传递放大的图像。 第二个参数是检测到的轮廓的向量。 然后,我们使用该选项仅检索外部轮廓并使用简单近似。 图像轮廓如下所示。 每种灰色色调代表不同的轮廓:
最后一步是确定每个轮廓的最小旋转边界矩形。 OpenCV 为此操作提供了一个名为minAreaRect的方便函数。 此函数接收任意点的向量,并返回包含边界框的RoundedRect。这也是丢弃不需要的矩形(即明显不是文本的矩形)的好机会。 由于我们正在制作用于 OCR 的软件,我们将假定文本包含一组字母。 在此假设下,我们将在以下情况下丢弃文本:
- 矩形宽度或大小太小,即小于 20 像素。 这将有助于丢弃边界噪声和其他小型人工制品。
- 图像的矩形的宽/高比例小于 2。 也就是说,类似于正方形的矩形(如图像图标)或更高的矩形也将被丢弃。
在第二种情况下有一个小小的警告。 由于我们处理的是旋转的边界框,因此必须测试边界框角度是否小于-45 度。 如果是,文本是垂直旋转的,所以我们必须考虑的比例是高度/宽度。
让我们通过查看以下代码来检查这一点:
//For each contour
vector;RotatedRect; areas;
for (const auto& contour : contours)
{
//Find it's rotated rect
auto box = minAreaRect(contour);
//Discard very small boxes
if (box.size.width 20 || box.size.height 20)
continue;
//Discard squares shaped boxes and boxes
//higher than larger
double proportion = box.angle -45.0 ?
box.size.height / box.size.width :
box.size.width / box.size.height;
if (proportion 2)
continue;
//Add the box
areas.push_back(box);
}
让我们看看该算法选择了哪些框:
这当然是个好结果!
我们应该注意到,在前面的代码中,步骤 2 中描述的算法也将丢弃单个字母。 这不是什么大问题,因为我们正在创建一个 OCR 预处理器,单个符号对于上下文信息通常是没有意义的;页码就是这种情况的一个例子。 在此过程中,页码将被丢弃,因为它们通常单独出现在页面底部,并且文本的大小和比例也会受到干扰。 但这不是问题,因为在文本通过 OCR 之后,您将得到大量的文本文件,根本没有分页。
我们将把所有这些代码放在一个具有以下签名的函数中:
vector RotatedRect; findTextAreas(Mat input)
文本提取和倾斜调整
现在,我们要做的就是提取文本并调整文本倾斜。 这由deskewAndCrop函数完成,如下所示:
Mat deskewAndCrop(Mat input, const RotatedRect& box)
{
double angle = box.angle;
auto size = box.size;
//Adjust the box angle
if (angle -45.0)
{
angle += 90.0;
std::swap(size.width, size.height);
}
//Rotate the text according to the angle
auto transform = getRotationMatrix2D(box.center, angle, 1.0);
Mat rotated;
warpAffine(input, rotated, transform, input.size(), INTER_CUBIC);
//Crop the result
Mat cropped;
getRectSubPix(rotated, size, box.center, cropped);
copyMakeBorder(cropped,cropped,10,10,10,10,BORDER_CONSTANT,Scalar(0));
return cropped;
}
首先,我们从读取所需的区域角度和大小开始。 正如我们之前看到的,角度可能小于-45 度。 这意味着文本是垂直对齐的,因此我们必须将旋转角度增加 90 度,并切换宽度和高度属性。 接下来,我们需要旋转文本。 首先,我们首先创建一个描述旋转的 2D 仿射变换矩阵。 我们通过使用getRotationMatrix2DOpenCV 函数来实现这一点。 此函数接受三个参数:
- 中心:旋转的中心位置。 旋转将围绕该中心旋转。 在我们的例子中,我们使用盒子中心。
- 角度:旋转角度。 如果角度为负,则旋转方向为顺时针。
- 比例:各向同性比例因子。 我们将使用
1.0,因为我们希望保持长方体的原始比例不变。
旋转本身通过使用warpAffine功能进行。 此函数接受四个必选参数:
- SRC:要转换的输入
mat数组。 - DST:目标
mat数组。 - M:变换矩阵。 这个矩阵是 2x3 仿射变换矩阵。 这可以是平移、缩放或旋转矩阵。 在我们的例子中,我们将只使用最近创建的矩阵。
- size:输出图像的大小。 我们将生成一个与输入图像大小相同的图像。
以下是另外三个可选参数:
- 标志:这些标志指示应该如何对图像进行插值。 我们使用
BICUBIC_INTERPOLATION来获得更好的质量。 默认值为LINEAR_INTERPOLATION。 - BORDER:边框模式。 我们使用默认值
BORDER_CONSTANT。 - 边框值:边框的颜色。 我们使用默认设置,即黑色。 然后,我们使用
getRectSubPix函数。 旋转图像后,需要裁剪边界框的矩形区域。 此函数接受四个强制参数和一个可选参数,并返回裁剪后的图像:- image:要裁剪的图像。
- size:描述要裁剪的框的宽度和高度的
cv::Size对象。 - Center:要裁剪区域的中心像素。 请注意,因为我们绕中心旋转,所以这个点很方便地是相同的。
- 补丁:目标镜像。
- PATCH_TYPE:目标图像的深度。 我们使用默认值,表示源图像的相同深度。
最后一步由copyMakeBorder函数完成。 此函数用于在图像周围添加边框。 这一点很重要,因为分类阶段通常要求文本周围留有边距。 函数参数非常简单:输入和输出图像、顶部、底部、左侧和右侧的边框粗细、边框类型和新边框的颜色。
对于卡片图像,将生成以下图像:
现在,是将所有功能组合在一起的时候了。 让我们来介绍执行以下操作的 Main 方法:
- 加载票证图像
- 调用我们的二值化函数
- 查找所有文本区域
- 在窗口中显示每个区域
我们将主要介绍以下方法:
int main(int argc, char* argv[])
{
//Loads the ticket image and binarize it
auto ticket = binarize(imread("ticket.png"));
auto regions = findTextAreas(ticket);
//For each region
for (const auto& region : regions) {
//Crop
auto cropped = deskewAndCrop(ticket, region);
//Show
imshow("Cropped text", cropped);
waitKey(0);
destroyWindow("Border Skew");
}
}
For the complete source code, take a look at the segment.cpp file that comes with this book.
在操作系统上安装 Tesseract OCR
Tesseract 是一种开源的光学字符识别引擎,最初由惠普实验室布里斯托尔和惠普公司开发。它的所有代码都在阿帕奇许可下获得许可,并托管在 GitHub 的https://github.com/tesseract-ocr上。 它被认为是目前最精确的 OCR 引擎之一:它可以读取各种各样的图像格式,并可以转换用 60 多种语言书写的文本。 在本课程中,我们将教您如何在 Windows 或 Mac 上安装 Tesseract。 因为有很多 Linux 发行版,我们不会教您如何在这个操作系统上安装它。 通常,Tesseract 在您的包存储库中提供安装包,因此,在您自己编译 Tesseract 之前,只需在那里搜索它即可。
在 Windows 上安装 Tesseract
Tesseract 使用C++ 归档网络(CPPAN)作为其依赖项管理器。 要安装 Tesseract,请执行以下步骤。
建造最新的图书馆
- 从https://cppan.org/client/下载最新的 CPPAN 客户端。
- 在命令行中,运行
cppan --build pvt.cppan.demo.google.tesseract.tesseract-master。
在 Visual Studio 中设置 Tesseract
- 在https://github.com/Microsoft/vcpkg设置 Visual C++ 包管理器
vcpkg。 - 对于 64 位编译,请使用
vcpkg install tesseract:x64-windows。 您还可以为主分支添加--head。
静态链接
还可以在项目中静态链接(https://github.com/tesseract-ocr/tesseract/wiki/Compiling#static-linking)Tesseract。 这将避免将dlls与您的可执行文件打包在一起。 要执行此操作,请像我们之前所做的那样,将vcpkg与以下命令一起用于 32 位安装:
vcpkg install tesseract:x86-windows-static
或者,您也可以使用以下命令进行 64 位安装:
vckpg install tesseract:x64-windows-static
在 Mac 上安装 Tesseract
在 Mac 上安装 Tesseract OCR 的最简单方法是使用Homebrew。 如果您还没有安装 Homebrew,只需转到 Homebrew 的站点(Ruby),打开您的控制台,然后运行首页上的http://brew.sh/脚本。 您可能需要输入管理员密码。
安装 HomeBREW 后,只需键入以下内容:
brew install tesseract
英语已包含在此安装中。 如果要安装其他语言包,只需运行以下命令:
brew install tesseract --all-languages
这将安装所有语言包。 然后,只需转到 Tesseract 安装目录并删除任何不需要的语言。 自制软件通常在/usr/local/目录中安装内容。
使用 Tesseract OCR 库
虽然 Tesseract OCR 已经与 OpenCV 3.0 集成,但它的 API 仍然值得研究,因为它允许对 Tesseract 参数进行更细粒度的控制。 此集成将在第 11 章,与 Tesseract 的文本识别中进行研究。
创建 OCR 功能
我们将更改前面的示例以使用 Tesseract。 首先将tesseract/baseapi.h和fstream添加到include列表:
#include opencv2/opencv.hpp;
#include tesseract/baseapi.h;
#include vector;
#include fstream;
然后,我们将创建一个表示我们的 Tesseract OCR 引擎的全局TessBaseAPI对象:
tesseract::TessBaseAPI ocr;
The ocr engine is completely self-contained. If you want to create a multi-threaded piece of OCR software, just add a different TessBaseAPI object in each thread, and the execution will be fairly thread-safe. You just need to guarantee that file writing is not done over the same file, otherwise you’ll need to guarantee safety for this operation.
接下来,我们将创建一个名为Identify Text for(identifyText)的函数,该函数将运行ocr:
const char* identifyText(Mat input, const char* language = "eng")
{
ocr.Init(NULL, language, tesseract::OEM_TESSERACT_ONLY);
ocr.SetPageSegMode(tesseract::PSM_SINGLE_BLOCK);
ocr.SetImage(input.data, input.cols, input.rows, 1, input.step);
const char* text = ocr.GetUTF8Text();
cout "Text:" endl;
cout text endl;
cout "Confidence: " ocr.MeanTextConf() endl;
// Get the text
return text;
}
让我们逐行解释这个函数。 在第一行中,我们从初始化tesseract开始。 这是通过调用Init函数来完成的。 此函数具有以下签名:
int Init(const char* datapath, const char* language,
OcrEngineMode oem)
下面我们来解释一下每个参数:
datapath:这是指向tessdata个文件的根目录的路径。 路径必须以反斜杠/字符结束。tessdata目录包含您安装的语言文件。 将NULL传递给此参数将使tesseract搜索其安装目录,这是此文件夹通常所在的位置。 在部署应用时,通常会将此值更改为args[0],并在应用路径中包含tessdata文件夹。language:这是语言代码的三个字母的单词(例如,英语为英语,葡萄牙语为 POR,印地语为 HIN)。 Tesseract 支持使用+符号加载多语言代码。 因此,通过eng+por将同时加载英语和葡萄牙语。 当然,您只能使用以前安装的语言,否则加载过程将失败。 语言配置文件可以指定必须一起加载两种或两种以上语言。 为了防止出现这种情况,您可以使用波浪号~。 例如,您可以使用hin+~eng来保证英语不会加载印地语,即使它被配置为这样做。OcrEngineMode:这些是将使用的 OCR 算法。 它可以具有下列值之一:OEM_TESSERACT_ONLY:仅使用tesseract。 这是最快的方法,但精度也较低。OEM_CUBE_ONLY:使用多维数据集引擎。 它更慢,但更精确。 只有当您的语言经过培训以支持此引擎模式时,这才会起作用。 要检查是否如此,请在tessdata文件夹中查找您的语言的.cube文件。 对英语的支持是有保证的。OEM_TESSERACT_CUBE_COMBINED:这结合了 Tesseract 和 Cube,以实现最佳的 OCR 分类。 该引擎具有最好的精确度和最慢的执行时间。OEM_DEFAULT:这将根据语言配置文件或命令行配置文件推断策略,如果两者都不存在,则使用OEM_TESSERACT_ONLY。
需要强调的是,Init函数可以多次执行。 如果提供了不同的语言或引擎模式,Tesseract 将清除以前的配置并重新启动。 如果提供了相同的参数,则 Tesseract 足够聪明,可以简单地忽略该命令。 函数init在成功的情况下返回0,在失败的情况下返回-1。
然后,我们的程序将继续设置页面分割模式:
ocr.SetPageSegMode(tesseract::PSM_SINGLE_BLOCK);
有几种可用的分段模式:
PSM_OSD_ONLY:使用此模式,Tesseract 将只运行其预处理算法来检测方向和脚本检测。PSM_AUTO_OSD:这告诉 Tesseract 使用方向和脚本检测进行自动页面分割。PSM_AUTO_ONLY:这会进行页面分割,但会避免进行定向、脚本检测或 OCR。PSM_AUTO:这会进行页面分割和 OCR,但会避免进行方向或脚本检测。PSM_SINGLE_COLUMN:这假设可变大小的文本显示在单个列中。PSM_SINGLE_BLOCK_VERT_TEXT:这会将图像视为垂直对齐的单个统一文本块。PSM_SINGLE_BLOCK:这假定为单个文本块,并且是默认配置。 我们将使用这个标志,因为我们的预处理阶段保证了这个条件。PSM_SINGLE_LINE:表示图像仅包含一行文本。PSM_SINGLE_WORD:表示图像只包含一个单词。PSM_SINGLE_WORD_CIRCLE:告诉我们图像只是一个排列在圆圈中的单词。PSM_SINGLE_CHAR:表示图像包含单个字符。
注意,Tesseract 已经实现了去偏斜和文本分割算法,就像大多数 OCR 库一样。 但了解这些算法是很有趣的,因为您可能会为特定需求提供自己的预处理阶段。 这使您可以在许多情况下改进文本检测。 例如,如果要为旧文档创建 OCR 应用,则 Tesseract 使用的默认阈值可能会创建黑色背景。 Tesseract 也可能会被边界或严重的文本歪斜搞混。
接下来,我们使用以下签名调用SetImage方法:
void SetImage(const unsigned char* imagedata, int width,
int height, int bytes_per_pixel, int bytes_per_line);
这些参数几乎是不言而喻的,并且大多数参数都可以直接从我们的Mat对象中读取:
data:包含图像数据的原始字节数组。 OpenCV 在Mat类中包含一个名为data()的函数,该函数提供指向数据的直接指针。width:图像宽度。height:图像高度。bytes_per_pixel:每个像素的字节数。 我们使用的是1,因为我们处理的是二进制图像。 如果希望代码更通用,还可以使用Mat::elemSize()函数,该函数提供相同的信息。bytes_per_line:单行中的字节数。 我们使用Mat::step属性,因为有些图像会添加尾随字节。
然后,我们调用GetUTF8Text来运行识别本身。 返回识别出的文本,使用 UTF8 编码,不带 BOM。 在返回它之前,我们还打印一些调试信息。
MeanTextConf返回置信度指数,该指数可以是介于0到100之间的一个数字:
auto text = ocr.GetUTF8Text();
cout "Text:" endl;
cout text endl;
cout "Confidence: " ocr.MeanTextConf() endl;
将输出发送到文件
让我们更改 Main 方法,将识别的输出发送到文件。 为此,我们使用标准的ofstream:
int main(int argc, char* argv[])
{
//Loads the ticket image and binarize it
Mat ticket = binarize(imread("ticket.png"));
auto regions = findTextAreas(ticket);
std::ofstream file;
file.open("ticket.txt", std::ios::out | std::ios::binary);
//For each region
for (const auto& region : regions) {
//Crop
auto cropped = deskewAndCrop(ticket, region);
auto text = identifyText(cropped, "por");
file.write(text, strlen(text));
file endl;
}
file.close();
}
以下行以二进制模式打开文件:
file.open("ticket.txt", std::ios::out | std::ios::binary);
这一点很重要,因为 Tesseract 返回以 UTF-8 编码的文本,并考虑了 Unicode 中提供的特殊字符。 我们还使用以下命令直接编写输出:
file.write(text, strlen(text));
在此示例中,我们使用葡萄牙语作为输入语言(这是票证编写时使用的语言)调用identify函数。 如果你愿意,你可以用另一张照片。
The complete source file is provided in the segmentOcr.cpp file, which comes with this book. ticket.png is a low resolution image, since we imagined you would want to display a window with the image while studying this code. For this image, the Tesseract results are rather poor. If you want to test with a higher resolution image, the code for this book provides you with a ticketHigh.png image. To test with this image, change the dilation repetitions to 12 and the minimum box size from 20 to 60. You’ll get a much higher confidence rate (about 87%), and the resulting text will be almost fully readable. The segmentOcrHigh.cpp file contains these modifications.
简略的 / 概括的 / 简易判罪的 / 简易的
在本章中,我们简要介绍了 OCR 应用。 我们看到,这类系统的预处理阶段必须根据我们计划识别的文档类型进行调整。 我们了解了预处理文本文件时的常见操作,如阈值、裁剪、倾斜和文本区域分割。 最后,我们学习了如何安装和使用 Tesseract OCR 将图像转换为文本。
在下一章中,我们将使用更复杂的 OCR 技术来识别随意拍摄的图片或视频中的文本-这种情况称为场景文本识别。 这是一个复杂得多的场景,因为文本可以在任何地方,使用任何字体,并且具有不同的照明和方向。 甚至可以根本没有文字! 我们还将学习如何使用 OpenCV 3.0 文本贡献模块,该模块与 Tesseract 完全集成。
十一、使用 Tesseract 的文本识别
在第 10 章,开发用于文本识别的分割算法中,我们介绍了非常基本的 OCR 处理函数。 虽然它们对于扫描或拍照的文档非常有用,但在处理随意出现在图片中的文本时几乎毫无用处。
在本章中,我们将探索 OpenCV 4.0 文本模块,该模块专门处理场景文本检测。 使用此接口,可以检测网络摄像头视频中出现的文本,或者分析拍摄的图像(如街景或监控摄像头拍摄的图像),以实时提取文本信息。 这允许创建范围广泛的应用,从可访问性到营销,甚至是机器人领域。
在本章结束时,您将能够执行以下操作:
- 了解什么是场景文本识别
- 了解 Text API 的工作原理
- 使用 OpenCV 4.0 Text API 检测文本
- 将检测到的文本提取到图像中
- 使用 Text API 和 Tesseract 集成来识别字母
技术要求
本章要求熟悉基本的 C++ 编程语言。 本章使用的所有代码都可以从以下 giHub 链接下载:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter11。 该代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。
请查看以下视频,了解实际操作中的代码:
http://bit.ly/2Slht5A
Text API 的工作原理
Text API 实现了Lukás Neumann和Jiri Matas在 2012 年计算机视觉和模式识别(CVPR)会议期间在文章Real-Time Scene Text Location and Recognition中提出的算法。 该算法代表了场景文本检测的显著提高,在 CVPR 数据库和 Google Street View 数据库中都执行了最先进的检测。 在使用 API 之前,让我们先来看看这个算法是如何在幕后工作的,以及它是如何解决场景文本检测问题的。
Remember: The OpenCV 4.0 text API does not come with the standard OpenCV modules. It’s an additional module that’s present in the OpenCV contrib package. If you installed OpenCV using the Windows Installer, you should take a look back at Chapter 1, Getting Started with OpenCV; this will guide you on how to install these modules.
场景检测问题
检测场景中随机出现的文本是一个比看起来更难的问题。 在与识别的扫描文本进行比较时,需要考虑以下几个新变量:
- 三维性:文本可以是任何比例、方向或视角。 此外,文本可能被部分遮挡或中断。 从字面上看,它可能出现在图像中的区域有数千个。
- Varity:文本可以有几种不同的字体和颜色。 字体可能有轮廓边框。 背景可以是深色、浅色或复杂的图像。
- 照明和阴影:太阳光的位置和外观颜色随时间变化。 雾或雨等不同的天气条件会产生噪音。 即使在封闭的空间里,照明也可能是个问题,因为光线会反射到彩色物体上,并照射到文本上。
- 模糊:文本可能出现在未通过镜头自动对焦确定优先级的区域。 模糊在移动相机、透视文本或有雾的情况下也很常见。
下面的图片来自谷歌街景,说明了这些问题。 请注意,其中几种情况是如何在一张图像中同时发生的:
由于存在**2*n***个像素子集,n是图像中的像素数,因此执行文本检测来处理此类情况可能会证明计算代价很高。
为了降低复杂性,通常使用两种策略:
- 使用滑动窗口仅搜索图像矩形的子集:此策略只是将子集的数量减少到较小的数量。 根据所考虑的文本的复杂程度,区域的数量会有所不同。 与还处理旋转、倾斜、透视等的算法相比,仅处理文本旋转的算法可能使用较小的值。 这种方法的优点在于它的简单性,但它们通常仅限于很小范围的字体,而且通常限于特定单词的词典。
- 连通分量分析的使用:此方法假设像素可以分组为具有相似属性的区域。 这些地区被认为有更高的机会被识别为人物。 这种方法的优点是它不依赖于几个文本属性(方向、比例、字体等),而且它们还提供了可用于将文本裁剪到 OCR 的分割区域。 这是我们在第 10 章,开发用于文本识别的分割算法中使用的方法。 照明也可能影响结果,例如,如果阴影投射在字母上,会产生两个截然不同的区域。 但是,由于场景检测通常用于移动车辆(例如,无人机或汽车)和视频,因此文本最终将被检测到,因为这些照明条件会因帧而异。
OpenCV 4.0 算法通过执行连通分量分析和搜索极值区域来使用第二种策略。
极值区域
极值区域是以几乎均匀的强度为特征的连通区域,周围环绕着对比鲜明的背景。 一个区域的稳定性可以通过计算该区域对阈值变化的抵抗力来衡量。 这种差异可以用一种简单的算法来测量:
- 应用阈值,生成图像A。 检测其连接的像素区域(极值区域)。
- 将阈值增加一个增量,生成图像B。 检测其连接的像素区域(极值区域)。
- 将图像B与A进行比较。 如果图像 A 中的某个区域与图像B中的相同区域相似,则将其添加到树中的同一分支。 相似性的标准可能因实现而异,但通常与图像区域或一般形状有关。 如果图像A中的区域似乎在图像B中被拆分,则在树中为新区域创建两个新分支,并将其与前一个分支相关联。
- 设置A=B并返回步骤 2,直到应用最大阈值。
这将组装一个区域树,如下所示:
对方差的抵抗力是通过计算同一级别中有多少个节点来确定的。 通过分析这棵树,还可以确定个最稳定的极值区域(MSERs),即该区域在各种阈值下保持稳定的区域。 在上图中,很明显这些区域将包含字母O、N和Y。 最大极值区域的主要缺点是它们在存在模糊的情况下很弱。 OpenCV 在Feature 2d模块中提供了一个 MSER 特性检测器。 极值区域很有趣,因为它们对光照、比例和方向都有很强的不变性。 它们也是很好的文本候选者,因为它们在使用的字体类型方面也是不变的,即使在设置了字体样式的情况下也是如此。 还可以分析每个区域以确定其边界省略,并且可以具有仿射变换和数值确定的面积等属性。 最后,值得一提的是,整个过程速度很快,这使得它成为实时应用的一个非常好的候选者。
极值区域滤波
虽然 MSER 是定义哪些极端区域值得使用的常用方法,但Neumann和Matas算法使用不同的方法,将所有极端区域提交给经过字符检测训练的顺序分类器。 此分类器在两个不同的阶段工作:
- 第一阶段递增地计算每个区域的描述符(边界框、周长、面积和欧拉数)。 这些描述符被提交给分类器,该分类器估计该区域成为字母表中的字符的可能性有多大。 然后,仅为阶段 2 选择高概率区域。
- 在这一阶段中,计算了整体面积比、凸壳比、外边界拐点个数等特征。 这提供了更详细的信息,允许分类器丢弃非文本字符,但它们的计算速度也要慢得多。
在 OpenCV 下,此过程在名为ERFilter的类中实现。 还可以使用不同的图像单通道投影,例如R、G、B、亮度或灰度转换来提高字符识别率。 最后,必须将所有字符分组为文本块(例如单词或段落)。 OpenCV 3.0 为此提供了两种算法:
- 修剪穷举搜索:同样是由Mattas在 2011 年提出的,该算法不需要任何先前的训练或分类,但仅限于水平对齐的文本
- 定向文本的分层方法:它处理任意方向的文本,但需要经过训练的分类器
Note that since these operations require classifiers, it is also necessary to provide a trained set as input. OpenCV 4.0 provides some of these trained sets in the following sample package: https://github.com/opencv/opencv_contrib/tree/master/modules/text/samples.
This also means that this algorithm is sensitive to the fonts used in classifier training.
在下面的视频中可以看到该算法的演示,该视频由诺伊曼本人提供:https://www.youtube.com/watch?v=ejd5gGea2Fo&Feature=youtu.be。 一旦文本被分割,它只需要被发送到像 Tesseract 这样的 OCR,类似于我们在第 10 章,开发用于文本识别的分割算法中所做的工作。 唯一的区别是,现在我们将使用 OpenCV 文本模块类与 Tesseract 交互,因为它们提供了一种封装我们正在使用的特定 OCR 引擎的方法。
使用 Text API
理论说得够多了。 现在我们来看看文本模块在实践中是如何工作的。 让我们研究一下如何使用它来执行文本检测、提取和识别。
文本检测
让我们从创建一个简单的程序开始,这样我们就可以使用ERFilters执行文本分割。 在本程序中,我们将使用文本 API 样本中训练好的分类器。 您可以从 OpenCV 资源库下载,但也可以在本书的配套代码中找到。
首先,我们首先包括所有必要的libs和usings:
#include "opencv2/highgui.hpp"
#include "opencv2/imgproc.hpp"
#include "opencv2/text.hpp"
#include <vector>
#include <iostream>
using namespace std;
using namespace cv;
using namespace cv::text;
回想一下极值区域过滤部分,ERFilter在每个图像通道中单独工作。 因此,我们必须提供一种在不同的单个通道cv::Mat中分离每个所需通道的方法。 这由separateChannels函数完成:
vector<Mat> separateChannels(const Mat& src)
{
vector<Mat> channels;
//Grayscale images
if (src.type() == CV_8U || src.type() == CV_8UC1) {
channels.push_back(src);
channels.push_back(255-src);
return channels;
}
//Colored images
if (src.type() == CV_8UC3) {
computeNMChannels(src, channels);
int size = static_cast<int>(channels.size())-1;
for (int c = 0; c < size; c++)
channels.push_back(255-channels[c]);
return channels;
}
//Other types
cout << "Invalid image format!" << endl;
exit(-1);
}
首先,我们验证图像是否已经是单通道图像(灰度图像)。 如果是这样,我们只需添加此图像-它不需要处理。 否则,我们检查它是否是RGB图像。 对于彩色图像,我们调用computeNMChannels函数将图像分割成几个通道。 该函数定义如下:
void computeNMChannels(InputArray src, OutputArrayOfArrays channels, int mode = ERFILTER_NM_RGBLGrad);
以下是其参数:
src:源输入数组。 它必须是 8UC3 型彩色图像。channels:将用结果通道填充的Mats的向量。mode:定义将计算哪些通道。 可以使用两个可能的值:ERFILTER_NM_RGBLGrad:指示算法是否使用 RGB 颜色、亮度和渐变幅值作为通道(默认)ERFILTER_NM_IHSGrad:指示是否按图像的强度、色调、饱和度和渐变大小分割图像
我们还附加了向量中所有颜色分量的负片。 由于图像将有三个不同的通道(R、G和B),这通常就足够了。 也可以添加未翻转的通道,就像我们对去灰度化图像所做的那样,但我们最终会得到 6 个通道,这可能会占用大量的计算机资源。 当然,如果这会带来更好的结果,您可以自由地使用您的图像进行测试。 最后,如果提供了另一种图像,该函数将终止程序并显示错误消息。
Negatives are appended, so the algorithms will cover both bright text in a dark background and dark text in a bright background. There is no sense in adding a negative for the gradient magnitude.
让我们继续主要方法。 我们将使用此程序对easel.png图像进行分割,该图像随源代码提供:
这张照片是我走在街上时用手机相机拍的。 让我们对此进行编码,以便您也可以通过在第一个程序参数中提供其名称来轻松地使用不同的图像:
int main(int argc, const char * argv[])
{
const char* image = argc < 2 ? "easel.png" : argv[1];
auto input = imread(image);
接下来,我们将通过调用separateChannels函数将图像转换为灰度并分隔其通道:
Mat processed;
cvtColor(input, processed, COLOR_RGB2GRAY);
auto channels = separateChannels(processed);
如果要使用彩色图像中的所有通道,只需将此代码摘录的前两行替换为以下内容:
Mat processed = input;
我们需要分析六个通道(RGB 和反转),而不是两个(灰色和反转)。 事实上,处理时间所增加的,远较我们所能得到的改善为多。 有了通道后,我们需要为算法的两个阶段创建ERFilters。 幸运的是,OpenCV 文本贡献模块提供了这样的功能:
// Create ERFilter objects with the 1st and 2nd stage classifiers
auto filter1 = createERFilterNM1(
loadClassifierNM1("trained_classifierNM1.xml"), 15, 0.00015f,
0.13f, 0.2f,true,0.1f);
auto filter2 = createERFilterNM2(
loadClassifierNM2("trained_classifierNM2.xml"),0.5);
对于第一阶段,我们调用loadClassifierNM1函数来加载先前训练的分类模型。 包含训练数据的.xml 是其唯一参数。 然后,我们调用createERFilterNM1来创建将执行分类的ERFilter类的实例。 该函数具有以下签名:
Ptr<ERFilter> createERFilterNM1(const Ptr<ERFilter::Callback>& cb, int thresholdDelta = 1, float minArea = 0.00025, float maxArea = 0.13, float minProbability = 0.4, bool nonMaxSuppression = true, float minProbabilityDiff = 0.1);
此函数的参数如下:
cb:分类模型。 这与我们使用loadCassifierNM1函数加载的模型相同。thresholdDelta:每次算法迭代中要加到阈值的量。 默认值为1,但我们在示例中将使用15。minArea:可以找到文本的极值区域(ER)的最小区域。 这是通过图像大小的百分比来衡量的。 面积小于此值的 ERR 会立即被丢弃。maxArea:ER 中可以找到文本的最大区域。 这也是通过图像大小的百分比来衡量的。 面积大于这一范围的 ER 会立即被丢弃。minProbability:区域必须是字符才能进入下一阶段的最小概率。nonMaxSupression:用于指示是否在每个分支概率中执行非最大抑制。minProbabilityDiff:最小和最大极值区域之间的最小概率差。
第二阶段的过程与此类似。 我们调用loadClassifierNM2来加载第二阶段的分类器模型,调用createERFilterNM2来创建第二阶段分类器。 该函数只接受加载的分类模型的输入参数和区域被视为字符所必须达到的最小概率。 因此,让我们在每个通道中调用这些算法来识别所有可能的文本区域:
//Extract text regions using Newmann & Matas algorithm
cout << "Processing " << channels.size() << " channels...";
cout << endl;
vector<vector<ERStat> > regions(channels.size());
for (int c=0; c < channels.size(); c++)
{
cout << " Channel " << (c+1) << endl;
filter1->run(channels[c], regions[c]);
filter2->run(channels[c], regions[c]);
}
filter1.release();
filter2.release();
在前面的代码中,我们使用了ERFilter类的run函数。 此函数接受两个参数:
- 输入通道:包括要处理的图像。
- 区域:在第一阶段算法中,此参数将填充检测到的区域。 在第二阶段(由
filter2执行),此参数必须包含在阶段 1 中选择的区域。这些区域将由阶段 2 处理和过滤。
最后,我们释放这两个过滤器,因为程序中将不再需要它们。 最后的分割步骤是将所有 ERRegion 分组为可能的单词,并定义它们的边界框。 这可以通过调用erGrouping函数来完成:
//Separate character groups from regions
vector< vector<Vec2i> > groups;
vector<Rect> groupRects;
erGrouping(input, channels, regions, groups, groupRects, ERGROUPING_ORIENTATION_HORIZ);
此函数具有以下签名:
void erGrouping(InputArray img, InputArrayOfArrays channels, std::vector<std::vector<ERStat> > ®ions, std::vector<std::vector<Vec2i> > &groups, std::vector<Rect> &groups_rects, int method = ERGROUPING_ORIENTATION_HORIZ, const std::string& filename = std::string(), float minProbablity = 0.5);
让我们来看看每个参数的含义:
img:输入图像,也称为原始图像。regions:提取区域的单通道图像的矢量。groups:分组区域的索引的输出向量。 每组区域包含单个单词的所有极值区域。groupRects:带有检测到的文本区域的矩形列表。method:这是分组的方法。 它可以是以下任一项:ERGROUPING_ORIENTATION_HORIZ:默认值。 这只会按照Neumann和Matas最初提出的方法,通过进行详尽的搜索来生成具有水平方向的文本组。ERGROUPING_ORIENTATION_ANY:这将使用单一链接聚类和分类器生成具有任意方向的文本的组。 如果使用此方法,则必须在下一个参数中提供分类器模型的文件名。Filename:分类器模型的名称。 仅当选择了ERGROUPING_ORIENTATION_ANY时才需要此选项。minProbability:检测到的接受组的最小概率。 仅当选择了ERGROUPING_ORIENTATION_ANY时才需要此选项。
代码还提供了对第二个方法的调用,但它被注释掉了。 您可以在两者之间切换以测试这一点。 只需注释上一个调用,并取消对此调用的注释:
erGrouping(input, channels, regions,
groups, groupRects, ERGROUPING_ORIENTATION_ANY,
"trained_classifier_erGrouping.xml", 0.5);
对于此调用,我们还使用了文本模块示例包中提供的默认训练分类器。 最后,我们绘制区域框并显示结果:
// draw groups boxes
for (const auto& rect : groupRects)
rectangle(input, rect, Scalar(0, 255, 0), 3);
imshow("grouping",input);
waitKey(0);
此程序输出以下结果:
您可以查看detection.cpp文件中的整个源代码。
While most OpenCV text module functions are written to support both grayscale and colored images as its input parameter, at the time of writing this book, there were bugs preventing us from using grayscale images in functions such as erGrouping. For more information, take a look at the following GitHub link: https://github.com/Itseez/opencv_contrib/issues/309. Always remember that the OpenCV contrib modules package is not as stable as the default OpenCV packages.
文本提取
既然我们已经检测到区域,我们必须在将文本提交给 OCR 之前对其进行裁剪。 我们可以简单地使用像getRectSubpix或Mat::copy这样的函数,将每个区域矩形用作感兴趣的区域(ROI),但是,由于字母倾斜,一些不需要的文本也可能被裁剪。 例如,如果我们仅根据给定的矩形提取 ROI,则其中一个区域的外观如下所示:
幸运的是,ERFilter为我们提供了一个名为ERStat的对象,它包含每个极端区域内的像素。 有了这些像素,我们就可以使用 OpenCV 的floodFill函数来重建每个字母。 此函数能够基于种子点绘制相似颜色的像素,就像大多数绘图应用的bucket工具一样。 函数签名如下所示:
int floodFill(InputOutputArray image, InputOutputArray mask, Point seedPoint, Scalar newVal,
CV_OUT Rect* rect=0, Scalar loDiff = Scalar(), Scalar upDiff = Scalar(), int flags = 4 );
让我们了解一下这些参数以及它们的使用方法:
image:输入图像。 我们将使用拍摄极端区域的通道图像。 除非提供了FLOODFILL_MASK_ONLY,否则这是该函数通常执行泛洪填充的位置。 在这种情况下,图像保持不变,绘制发生在蒙版中。 这正是我们要做的。mask:蒙版必须是比输入图像大两行两列的图像。 当整体填充绘制像素时,它会验证蒙版中相应的像素是否为零。 在这种情况下,它将绘制该像素并将其标记为 1(或传递到标志中的另一个值)。 如果像素不为零,则整体应用填充不会绘制像素。 在我们的例子中,我们将提供一个空白蒙版,这样每个字母都会被绘制到蒙版中。seedPoint:起点。 它类似于您想要使用图形应用的Bucket工具时单击的位置。newVal:重新绘制的像素的新值。loDiff和upDiff:这些参数表示正在处理的像素与其相邻像素之间的上下差异。 如果邻居落在这个范围内,它就会被画出来。 如果使用FLOODFILL_FIXED_RANGE标志,则将使用种子点和正在处理的像素之间的差值。rect:这是一个可选参数,用于限制将应用泛洪填充的区域。flags:该值由位掩码表示:- 标志的最低有效 8 位包含连接值。 值
4表示将使用所有四个边缘像素,值8表示还必须考虑对角线像素。 我们将使用4作为此参数。 - 接下来的 8 到 16 位包含一个从
1到255的值,用于填充掩码。 因为我们想用白色填充蒙版,所以我们将使用255 << 8作为此值。 - 正如我们已经描述的,可以通过添加
FLOODFILL_FIXED_RANGE和FLOODFILL_MASK_ONLY标志来设置另外两位。
- 标志的最低有效 8 位包含连接值。 值
我们将创建一个名为drawER的函数。 此函数将接收四个参数:
- 具有所有已处理通道的矢量
ERStat区域- 必须抽签的组
- 组矩形
此函数将返回包含由该组表示的单词的图像。 让我们通过创建遮罩图像并定义标志来开始此函数:
Mat out = Mat::zeros(channels[0].rows+2, channels[0].cols+2, CV_8UC1);
int flags = 4 //4 neighbors
+ (255 << 8) //paint mask in white (255)
+ FLOODFILL_FIXED_RANGE //fixed range
+ FLOODFILL_MASK_ONLY; //Paint just the mask
然后,我们将遍历每组。 有必要找出地区指数及其地位。 这个极端的区域有可能是根,它不包含任何点。 在本例中,我们将忽略它:
for (int g=0; g < group.size(); g++)
{
int idx = group[g][0];
auto er = regions[idx][group[g][1]];
//Ignore root region
if (er.parent == NULL)
continue;
现在,我们可以从ERStat对象读取像素坐标。 它由像素数表示,从上到下,从左到右计数。 此线性索引必须转换为行(y)和列(z)表示法,使用与我们在第 2 章,OpenCV基础简介中看到的公式类似的公式:
int px = er.pixel % channels[idx].cols;
int py = er.pixel / channels[idx].cols;
Point p(px, py);
然后,我们可以调用floodFill函数。 ERStat对象为我们提供了要在loDiff参数中使用的值:
floodFill(
channels[idx], out, //Image and mask
p, Scalar(255), //Seed and color
nullptr, //No rect
Scalar(er.level),Scalar(0), //LoDiff and upDiff
flags //Flags
在对组中的所有区域执行此操作后,我们将以一个比原始图像稍大的图像结束,该图像的背景为黑色,单词为白色字母。 现在,让我们只裁剪字母的区域。 由于给出了区域矩形,我们首先将其定义为感兴趣的区域:
out = out(rect);
然后,我们将找到所有非零像素。 这是我们将在minAreaRect函数中使用的值,以获得围绕字母旋转的矩形。 最后,我们将借用上一章的deskewAndCrop函数为我们裁剪和旋转图像:
vector<Point> points;
findNonZero(out, points);
//Use deskew and crop to crop it perfectly
return deskewAndCrop(out, minAreaRect(points));
}
这是画架图像处理的结果:
文本识别
在第 10 章,*开发用于文本识别的分割算法中,*我们直接使用了 Tesseract API 来识别文本区域。 这一次,我们将使用 OpenCV 类来实现相同的目标。
在 OpenCV 中,所有特定于 OCR 的类都派生自BaseOCR虚拟类。 此类为 OCR 执行方法本身提供公共接口。 特定的实现必须从该类继承。 默认情况下,文本模块提供三种不同的实现:OCRTesseract、OCRHMMDecoder和OCRBeamSearchDecoder。
下面的类图描述了此层次结构:
使用这种方法,我们可以将创建 OCR 机制的代码部分与执行本身分开。 这使得将来更容易更改 OCR 实现。
因此,让我们从创建一个方法开始,该方法决定我们将基于字符串使用哪个实现。 我们目前只支持 Tesseract,但您可以查看本章的代码,其中还提供了HMMDecoder的演示。 此外,我们接受字符串参数中的 OCR 引擎名称,但我们可以通过从外部 JSON 或 XML 配置文件中读取它来提高应用的灵活性:
cv::Ptr<BaseOCR> initOCR2(const string& ocr) { if (ocr == "tesseract") { return OCRTesseract::create(nullptr, "eng+por"); } throw string("Invalid OCR engine: ") + ocr; }
您可能已经注意到,该函数返回Ptr<BaseOCR>。 现在,看一下突出显示的代码。 它调用create方法来初始化 Tesseract OCR 实例。 我们来看看它的官方签名,因为它允许几个具体的参数:
Ptr<OCRTesseract> create(const char* datapath=NULL,
const char* language=NULL,
const char* char_whitelist=NULL,
int oem=3, int psmode=3);
让我们分析一下这些参数中的每一个:
datapath:这是根目录的tessdata文件的路径。 路径必须以反斜杠/字符结束。tessdata目录包含您安装的语言文件。 将nullptr传递给此参数将使 Tesseract 在其安装目录中进行搜索,该目录通常是该文件夹所在的位置。 在部署应用时,通常会将此值更改为args[0],并在应用路径中包含tessdata文件夹。language:这是一个带有语言代码的三个字母的单词(例如,Eng 代表英语,POR 代表葡萄牙语,Hin 代表印地语)。 Tesseract 支持使用+符号加载多语言代码。 因此,通过eng+por将加载英语和葡萄牙语。 当然,您只能使用以前安装的语言,否则加载将失败。 Languageconfig文件可以指定必须一起加载两种或两种以上语言。 为了防止出现这种情况,您可以使用波浪号~。 例如,您可以使用hin+~eng来保证英语不会加载印地语,即使它被配置为这样做。whitelist:这是设置为识别的字符。 在传递nullptr的情况下,字符将为0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ。oem:这些是将使用的 OCR 算法。 它可以具有下列值之一:OEM_TESSERACT_ONLY:仅使用 Tesseract。 这是最快的方法,但精度也较低。OEM_CUBE_ONLY:使用多维数据集引擎。 它更慢,但更精确。 只有当您的语言经过培训以支持此引擎模式时,这才会起作用。 要检查是否如此,请在tessdata文件夹中查找您的语言的.cube文件。 对英语的支持是有保证的。OEM_TESSERACT_CUBE_COMBINED:组合 Tesseract 和 Cube 以实现最佳的 OCR 分类。 该引擎具有最好的精确度和最慢的执行时间。OEM_DEFAULT:根据语言配置文件或命令行配置文件推断策略,如果两者都不存在,则使用OEM_TESSERACT_ONLY。
psmode:这是分段模式。 它可以是以下任一项:PSM_OSD_ONLY:使用此模式,Tesseract 将只运行其预处理算法来检测方向和脚本检测。PSM_AUTO_OSD:这告诉 Tesseract 使用方向和脚本检测进行自动页面分割。PSM_AUTO_ONLY:执行页面分割,但避免执行定向、脚本检测或 OCR。 这是默认值。PSM_AUTO:执行页面分割和 OCR,但避免执行方向或脚本检测。PSM_SINGLE_COLUMN:假设可变大小的文本显示在单个列中。PSM_SINGLE_BLOCK_VERT_TEXT:将图像视为垂直对齐的单个统一文本块。PSM_SINGLE_BLOCK:假定为单个文本块。 这是默认配置。 我们将使用这个标志,因为我们的预处理阶段保证了这个条件。PSM_SINGLE_LINE:表示图像仅包含一行文本。PSM_SINGLE_WORD:表示图像只包含一个单词。PSM_SINGLE_WORD_CIRCLE:表示图像只是一个排列在圆圈中的单词。PSM_SINGLE_CHAR:表示图像包含单个字符。
对于最后两个参数,建议您使用#includeTesseract 目录来使用常量名称,而不是直接插入它们的值。 最后一步是在我们的主函数中添加文本检测。 为此,只需将以下代码添加到 Main 方法的末尾:
auto ocr = initOCR("tesseract");
for (int i = 0; i < groups.size(); i++)
{
auto wordImage = drawER(channels, regions, groups[i],
groupRects[i]);
string word;
ocr->run(wordImage, word);
cout << word << endl;
}
在这段代码中,我们首先调用initOCR方法创建一个 Tesseract 实例。 请注意,如果我们选择不同的 OCR 引擎,剩余的代码将不会更改,因为 Run 方法签名由BaseOCR类保证。 接下来,我们迭代每个检测到的ERFilter组。 由于每组代表一个不同的单词,我们将执行以下操作:
- 调用前面创建的
drawER函数来创建包含该单词的图像。 - 创建一个名为
word的文本字符串,并调用run函数来识别单词 image。 识别的单词将存储在字符串中。 - 在屏幕上打印文本字符串。
让我们来看看run方法签名。 此方法在BaseOCR类中定义,对于所有特定的 OCR 实现都是相同的-即使是将来可能实现的实现:
virtual void run(Mat& image, std::string& output_text,
std::vector<Rect>* component_rects=NULL,
std::vector<std::string>* component_texts=NULL,
std::vector<float>* component_confidences=NULL, int component_level=0) = 0;
当然,这是一个纯虚函数,必须由每个特定类实现(比如我们刚才使用的OCRTesseract类):
image:输入图像。 它必须是 RGB 或灰度图像。component_rects:我们可以提供一个向量,用 OCR 引擎检测到的每个组件(单词或文本行)的边界框填充。component_texts:如果给定,此向量将填充 OCR 检测到的每个组件的文本字符串。component_confidences:如果给定,向量将用浮点数填充,每个分量的置信度值。component_level:定义什么是组件。 它可以具有值OCR_LEVEL_WORD(默认情况下)或OCR_LEVEL_TEXT_LINE。
If necessary, you may prefer changing the component level to a word or line in the run() method instead of doing the same thing in the psmode parameter of the create() function. This is preferable since the run method will be supported by any OCR engine that decides to implement the BaseOCR class. Always remember that the create() method is where vendor-specific configurations are set.
以下是程序的最终输出:
尽管与&符号有一点混淆,但每个单词都被完全识别。 您可以在本章的代码文件中查看ocr.cpp文件中的整个源代码。
简略的 / 概括的 / 简易判罪的 / 简易的
在本章中,我们看到场景文本识别比处理扫描文本要困难得多。 我们研究了文本模块如何使用Newmann和Matas算法进行极值区域识别。 我们还了解了如何通过floodFill函数使用此 API 将文本提取到图像中,并将其提交给 Tesseract OCR。 最后,我们学习了 OpenCV 文本模块如何与 Tesseract 和其他 OCR 引擎集成,以及如何使用它的类来识别图像中所写的内容。
在下一章中,我们将向您介绍 OpenCV 中的深度学习。 您将通过使用只看一次(YOLO)算法了解对象检测和分类。
十二、OpenCV 深度学习
深度学习是一种最先进的机器学习形式,在图像分类和语音识别中达到了最高的准确率。 深度学习也被用于其他领域,如机器人和具有强化学习的人工智能。 这就是 OpenCV 做出重大努力将深度学习纳入其核心的主要原因。 我们将学习 OpenCV 深度学习界面的基本用法,并了解如何在两个用例中使用它们:对象检测和人脸检测。
在本章中,我们将学习深度学习的基础知识,并了解如何在 OpenCV 中使用深度学习。 为了达到我们的目标,我们将使用You Only Look Once((YOLO)算法学习目标检测和分类。
本章将介绍以下主题:
- 什么是深度学习?
- OpenCV 如何使用深度学习和实施深度学习神经网络(NNs)
- YOLO 提出了一种非常快速的深度学习目标检测算法
- 基于单镜头检测器的人脸检测
技术要求
要轻松阅读本章,需要安装 OpenCV 并编译深度学习模块。 如果没有此模块,您将无法编译和运行示例代码。
拥有支持 CUDA 的 NVIDIA GPU 非常有用。 您可以在 OpenCV 上启用 CUDA 以提高训练和检测的速度。
最后,您可以从https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter12下载本章使用的代码。
请查看以下视频,了解实际操作中的代码:
http://bit.ly/2SmbWf7
深度学习入门
深度学习是当今关于图像分类和语音识别的科学论文中最常见的内容。 这是机器学习的一个子领域,基于传统的神经网络,并受到大脑结构的启发。 要理解这项技术,了解神经网络是什么以及它是如何工作的是非常重要的。
什么是神经网络?我们如何从数据中学习?
神经网络的灵感来自大脑的结构,在大脑中,多个神经元相互连接,形成一个网络。 每个神经元都有多个输入和多个输出,就像生物神经元一样。
这个网络是分层分布的,每一层都包含许多神经元,这些神经元与前一层的所有神经元相连。 它总是有一个输入层和一个输出层,输入层通常由描述输入图像或数据的要素组成,输出层通常由分类结果组成。 其他中间层称为隐藏层。 下图显示了一个基本的三层神经网络,其中输入层包含三个神经元,输出层包含两个神经元,一个隐藏层包含四个神经元:
神经元是神经网络的基本元素,它使用一个简单的数学公式,如下图所示:
正如我们所看到的,对于每个神经元i,我们数学地将前一个神经元的所有输出(即神经元i(x1,x2…)的输出按权重(wi1,wi2…)相加。)。 加上偏置值,结果是激活函数的自变量f。 最终结果是i神经元的输出:
经典神经网络中最常见的激活函数(f)是 Sigmoid 函数或线性函数。 最常用的是 Sigmoid 函数,如下所示:
但是,我们怎样才能用这个公式和这些联系来学习神经网络呢? 我们如何对输入数据进行分类? 神经网络的学习算法可以称为有监督,如果我们知道期望的输出,那么在学习时,输入模式被提供给网络的输入层。 最初,我们将所有权重设置为随机数,然后将输入要素发送到网络中,检查输出结果。 如果这是错误的,我们必须调整网络的所有权重以获得正确的输出。 该算法称为反向传播。 如果你想更多地了解神经网络是如何学习的,请查看http://neuralnetworksanddeeplearning.com/chap2.html和https://youtu.be/IHZwWFHWa-w。
现在我们已经简要介绍了什么是神经网络和神经网络的内部结构,我们将探讨神经网络和深度学习之间的区别。
卷积神经网络
深度学习神经网络与经典神经网络有着相同的背景。 然而,在图像分析的情况下,主要区别在于输入层。 在经典的机器学习算法中,研究人员必须确定定义要分类的图像目标的最佳特征。 例如,如果我们想对数字进行分类,我们可以提取每幅图像中数字的边框和线条,测量图像中对象的面积,所有这些特征都是神经网络或任何其他机器学习算法的输入。 然而,在深度学习中,您不必探究特征是什么;相反,您可以直接使用整个图像作为神经网络的输入。 深度学习可以学习最重要的特征是什么,深度神经网络(DNN)能够检测图像或输入并识别它。
为了了解这些特征是什么,我们使用深度学习和神经网络中最重要的层之一:卷积层。 卷积层的工作方式类似于卷积运算符,其中将核过滤器应用于前一层,从而提供新的过滤图像,类似于 Sobel 运算符:
然而,在卷积层中,我们可以定义不同的参数,其中之一是要应用于前一层或图像的滤镜数量和大小。 这些滤波器是在学习步骤中计算的,就像经典神经网络上的权重一样。 这就是深度学习的魔力:它可以从标记的图像中提取最重要的特征。
然而,这些卷积层是名称Deep背后的主要原因,我们将在下面的基本示例中了解原因。 假设我们有一幅 100x100 的图像。 在经典的神经网络中,我们将从输入图像中提取我们能想象到的最相关的特征。 这通常会有大约 1000 个特征,对于每个隐藏层,我们可以增加或减少这个数字,但用来计算其权重的神经元数量在正常的计算机中是合理的。 然而,在深度学习中,我们通常会开始应用卷积层–64 个 3x3 大小的滤波器核。 这将生成一个新的 100x100x64 神经元层,其权重为 3x3x64。 如果我们继续添加越来越多的层,这些数字会迅速增加,并且需要巨大的计算能力来学习我们的深度学习架构的良好权重和参数。
卷积层是深度学习体系结构最重要的方面之一,但也有其他重要层,如池化、丢弃、扁平和Softmax。 在下图中,我们可以看到一个基本的深度学习架构,其中堆叠了一些卷积和池层:
然而,还有一件非常重要的事情使深度学习获得最好的结果:标签数据量。 如果您的数据集很小,深度学习算法将无法帮助您进行分类,因为没有足够的数据来学习特征(深度学习体系结构的权重和参数)。 但是,如果你有海量的数据,你会得到非常好的结果。 但是要小心,您将需要大量的时间来计算和学习您的体系结构的权重和参数。 这就是为什么在这个过程的早期没有使用深度学习,因为计算需要大量的时间。 然而,多亏了新的并行架构,如 NVIDIA GPU,我们可以优化学习反向传播并加快学习任务。
OpenCV 中的深度学习
深度学习模块作为贡献模块引入到版本 3.1 的 OpenCV 中。 这在 3.3 版本中被转移到 OpenCV 的一部分,但直到 3.4.3 和 4 版本才被开发人员广泛采用。
OpenCV 实现的深度学习只用于推理,这意味着您不能创建自己的深度学习架构,也不能在 OpenCV 中进行训练;您只能导入预先训练好的模型,在 OpenCV 库下执行,并将其作为前馈(推理)来获得结果。
实现前馈算法的最重要原因是对 OpenCV 进行优化,以加快推理的计算时间和性能。 不实现后向方法的另一个原因是为了避免浪费时间开发其他库(如 TensorFlow 或 Caffe)专门从事的东西。 OpenCV 随后为最重要的深度学习库和框架创建了导入器,使导入预先训练好的模型成为可能。
然后,如果要创建新的深度学习模型以在 OpenCV 中使用,首先必须使用 TensorFlow、Caffe、Torch 或 Dark Net 框架或可用于以开放式神经网络交换(ONX)格式导出模型的框架来创建和训练该模型。 使用此框架创建模型可能很简单,也可能很复杂,具体取决于您使用的框架,但本质上您必须堆叠多个层,就像我们在上一个图表中所做的那样,设置 DNN 所需的参数和功能。 现在有其他工具可以帮助您在不编码的情况下创建模型,例如https://www.tensoreditor.com或Lobe.ai。 TensorEditor 允许您下载从可视化设计架构生成的 TensorFlow 代码,以便在您的计算机或云中进行训练。 在下面的屏幕截图中,我们可以看到 TensorEditor:
对模型进行训练并对结果满意后,可以将其直接导入到 OpenCV 以预测新的输入图像。 在下一节中,您将了解如何在 OpenCV 中导入和使用深度学习模型。
YOLO-实时目标检测
为了学习如何在 OpenCV 中使用深度学习,我们将给出一个基于 YOLO 算法的目标检测和分类的例子。 这是最快的物体检测和识别算法之一,在 NVIDIA Titan X 上运行速度约为 30fps。
YOLO v3 深度学习模型架构
经典计算机视觉中常见的目标检测使用滑动窗口来检测目标,以不同的窗口大小和比例扫描整个图像。 这里的主要问题是多次扫描图像以查找对象会耗费大量时间。
YOLO 使用了一种不同的方法,将图表划分为 S x S 网格。 对于每个网格,YOLO 检查 B 个边界框,然后深度学习模型提取每个面片的边界框、包含可能对象的置信度以及每个框的训练数据集中每个类别的置信度。 以下屏幕截图显示了 S x S 网格:
YOLO 使用包含 19 个和 5 个边界框的网格进行训练,每个网格使用 80 个类别。 然后,输出结果为 19x19x425,其中 425 来自边界框(x,y,宽,高)、对象置信度和 80 个类别的数据,置信度乘以每个网格的框数; 5_ 边界框*(x,y,w,h,对象置信度, 分类置信度[80])=5*(4+1+80):
YOLO v3 架构基于暗网,它包含 53 层网络,YOLO 又增加了 53 层,总共有 106 层网络。 如果你想要一个更快的架构,你可以选择版本 2 或 TinyYOLO 版本,它们使用的层更少。
YOLO 数据集、词汇表和模型
在我们开始将模型导入到我们的 OpenCV 代码之前,我们必须通过 yolo 网站获得它:https://pjreddie.com/darknet/yolo/。 这提供了基于COCO数据集的预先训练的模型文件,该数据集包含 80 个对象类别,例如人、伞、自行车、摩托车、汽车、苹果、香蕉、计算机和椅子。
要获取用于可视化的所有类别和用途的名称,请查看https://github.com/pjreddie/darknet/blob/master/data/coco.names?raw=true。
这些名称的顺序与深度学习模型置信度的结果相同。 如果您想按类别查看 COCO 数据集的一些图像,可以在http://cocodataset.org/#explore浏览该数据集,并下载其中一些图像来测试我们的示例应用。
要获取模型配置和预先训练的权重,您必须下载以下文件:
- https://pjreddie.com/media/files/yolov3.weights
- https://github.com/pjreddie/darknet/blob/master/cfg/yolov3.cfg?raw=true
现在,我们已经准备好开始将模型导入到 OpenCV 中。
将 YOLO 导入到 OpenCV
深度学习 OpenCV 模块位于opencv2/dnn.hpp标头下,我们必须将其包括在我们的源标头和cv::dnn namespace中。
则 OpenCV 的标题必须如下所示:
...
#include <opencv2/core.hpp>
#include <opencv2/dnn.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/highgui.hpp>
using namespace cv;
using namespace dnn;
...
我们要做的第一件事是导入 Coco 名称的词汇表,该词汇表位于coco.names文件中。 该文件是一个纯文本文件,每行包含一个类类别,其排序方式与置信度结果相同。 然后,我们将读取该文件的每一行,并将其存储在称为类的字符串向量中:
...
int main(int argc, char** argv)
{
// Load names of classes
string classesFile = "coco.names";
ifstream ifs(classesFile.c_str());
string line;
while (getline(ifs, line)) classes.push_back(line);
...
现在我们将深度学习模型导入到 OpenCV 中。 OpenCV 为深度学习框架实现了最常见的阅读器/导入器,比如 TensorFlow 和 DarkNet,它们都有相似的语法。 在我们的示例中,我们将使用权重导入暗网模型,并使用readNetFromDarknetOpenCV 函数导入模型:
...
// Give the configuration and weight files for the model
String modelConfiguration = "yolov3.cfg";
String modelWeights = "yolov3.weights";
// Load the network
Net net = readNetFromDarknet(modelConfiguration, modelWeights);
...
现在我们可以读取图像,并将深度神经网络发送到推理。 首先,我们必须使用imread函数读取图像,并将其转换为可以读取DotNetNuke(DNN)的张量/BLOB 数据。 要从图像创建斑点,我们将通过传递图像来使用blobFromImage函数。 此函数接受以下参数:
- image:输入图像(具有 1、3 或 4 个通道)。
- BLOB:输出
mat。 - scalefactor:图像值的乘数。
- SIZE:DNN 的输入所需的输出 BLOB 的空间大小。
- Mean:从通道减去平均值的标量。 如果图像具有 BGR 排序且
swapRB为真,则值应按(Mean-R、Mean-G 和 Mean-B)顺序排列。 - swapRB:3 通道图像中指示交换第一个通道和最后一个通道的标志是必需的。
- 裁剪:指示调整大小后是否裁剪图像的标志。
您可以在以下代码片段中阅读有关如何读取图像并将其转换为 BLOB 的完整代码:
...
input= imread(argv[1]);
// Stop the program if reached end of video
if (input.empty()) {
cout << "No input image" << endl;
return 0;
}
// Create a 4D blob from a frame.
blobFromImage(input, blob, 1/255.0, Size(inpWidth, inpHeight), Scalar(0,0,0), true, false);
...
最后,我们必须将 BLOB 提供给 Deep Net,并使用forward函数调用推理,该函数需要两个参数:OUTmat结果和输出需要检索的层的名称:
...
//Sets the input to the network
net.setInput(blob);
// Runs the forward pass to get output of the output layers
vector<Mat> outs;
net.forward(outs, getOutputsNames(net));
// Remove the bounding boxes with low confidence
postprocess(input, outs);
...
在mat输出向量中,我们有神经网络检测到的所有边界框,我们必须对输出进行后处理,以仅获得置信度大于阈值(通常为 0.5)的结果,最后应用非最大值抑制来消除多余的重叠框。 您可以在 GitHub 上获得完整的后处理代码。
我们示例的最终结果是深度学习中的多目标检测和分类,它显示了一个类似于以下内容的窗口:
现在我们来学习另一个为人脸检测定制的常用目标检测函数。
基于 SSD 的人脸检测
单镜头检测(SSD)是另一种快速、准确的深度学习目标检测方法,其概念类似于 YOLO,在同一架构中预测目标和边界框。
固态硬盘模型架构
SSD 算法被称为单镜头算法,因为它在处理同一深度学习模型中的图像时同时预测边界框和类别。 基本上,架构概括为以下几个步骤:
- 一幅 300x300 的图像被输入到该架构中。
- 输入图像通过多个卷积层,在不同尺度上获得不同的特征。
- 对于在 2 中获得的每个特征地图,我们使用 3x3 卷积过滤器来评估一小部分默认边界框。
- 对于评估的每个默认框,预测边界框偏移量和类别概率。
模型体系结构如下所示:
SSD 用于预测多个类别,类似于 YOLO 中的预测,但它可以修改为检测单个对象,更改最后一层,只对一个类别进行训练-这就是我们在示例中使用的人脸检测的重新训练模型,其中只预测一个类别。
将固态硬盘人脸检测导入 OpenCV
要在我们的代码中使用深度学习,我们必须导入相应的标头:
#include <opencv2/dnn.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/highgui.hpp>
之后,我们将导入所需的命名空间:
using namespace cv;
using namespace std;
using namespace cv::dnn;
现在,我们将定义将在代码中使用的输入图像大小和常量:
const size_t inWidth = 300;
const size_t inHeight = 300;
const double inScaleFactor = 1.0;
const Scalar meanVal(104.0, 177.0, 123.0);
在本例中,如果我们要处理摄像机或视频输入,我们需要一些参数作为输入,例如模型配置和预先训练的模型。 我们还需要最低的置信度才能接受预测是正确的还是错误的:
const char* params
= "{ help | false | print usage }"
"{ proto | | model configuration (deploy.prototxt) }"
"{ model | | model weights (res10_300x300_ssd_iter_140000.caffemodel) }"
"{ camera_device | 0 | camera device number }"
"{ video | | video or image for detection }"
"{ opencl | false | enable OpenCL }"
"{ min_confidence | 0.5 | min confidence }";
现在,我们将从main函数开始,在该函数中,我们将使用CommandLineParser函数解析参数:
int main(int argc, char** argv)
{
CommandLineParser parser(argc, argv, params);
if (parser.get<bool>("help"))
{
cout << about << endl;
parser.printMessage();
return 0;
}
我们还将加载模型架构和预先训练的模型文件,并将模型加载到深度学习网络中:
String modelConfiguration = parser.get<string>("proto");
String modelBinary = parser.get<string>("model");
//! [Initialize network]
dnn::Net net = readNetFromCaffe(modelConfiguration, modelBinary);
//! [Initialize network]
检查我们是否正确导入了网络,这一点非常重要。 我们还必须使用empty功能检查模型是否已导入,如下所示:
if (net.empty())
{
cerr << "Can't load network by using the following files" << endl;
exit(-1);
}
加载网络后,我们将初始化输入源、摄像机或视频文件,并加载到VideoCapture中,如下所示:
VideoCapture cap;
if (parser.get<String>("video").empty())
{
int cameraDevice = parser.get<int>("camera_device");
cap = VideoCapture(cameraDevice);
if(!cap.isOpened())
{
cout << "Couldn't find camera: " << cameraDevice << endl;
return -1;
}
}
else
{
cap.open(parser.get<String>("video"));
if(!cap.isOpened())
{
cout << "Couldn't open image or video: " << parser.get<String>("video") << endl;
return -1;
}
}
现在,我们准备开始捕捉帧,并将每个帧处理到深度神经网络中,以找到人脸。
首先,我们必须捕获循环中的每一帧:
for(;;)
{
Mat frame;
cap >> frame; // get a new frame from camera/video or read image
if (frame.empty())
{
waitKey();
break;
}
接下来,我们将把输入帧放入可以管理深度神经网络的Mat斑点结构中。 我们必须发送 SSD 大小合适的图像,即 300 x 300(我们已经初始化了inWidth和inHeight常量变量),并从输入图像中减去平均值,这是使用定义的meanVal常量变量在 SSD 中所需的:
Mat inputBlob = blobFromImage(frame, inScaleFactor, Size(inWidth, inHeight), meanVal, false, false);
现在我们可以将数据设置到网络中,并分别使用net.setInput和net.forward函数获得预测/检测。 这会将检测结果转换为我们可以读取的检测mat,其中detection.size[2]是检测到的对象的数量,detection.size[3]是每次检测的结果数量(边界框数据和置信度):
net.setInput(inputBlob, "data"); //set the network input
Mat detection = net.forward("detection_out"); //compute output
Mat detectionMat(detection.size[2], detection.size[3], CV_32F, detection.ptr<float>());
Mat检测每行包含以下内容:
-
列 0:物体存在的置信度
-
第 1 列:包围盒的置信度
-
列 2:检测到的人脸置信度
-
列 3:X 左下边界框
-
列 4:Y 左下边界框
-
列 5:X 个右上边框
-
列 6:Y 右上边界框
边界框相对于图像大小(0 比 1)。
现在,我们必须应用该阈值,以根据定义的输入阈值仅获得所需的检测:
float confidenceThreshold = parser.get<float>("min_confidence");
for(int i = 0; i < detectionMat.rows; i++)
{
float confidence = detectionMat.at<float>(i, 2);
if(confidence > confidenceThreshold)
{
现在,我们将提取边界框,在每个检测到的面上绘制一个矩形,并如下所示:
int xLeftBottom = static_cast<int>(detectionMat.at<float>(i, 3) * frame.cols);
int yLeftBottom = static_cast<int>(detectionMat.at<float>(i, 4) * frame.rows);
int xRightTop = static_cast<int>(detectionMat.at<float>(i, 5) * frame.cols);
int yRightTop = static_cast<int>(detectionMat.at<float>(i, 6) * frame.rows);
Rect object((int)xLeftBottom, (int)yLeftBottom, (int)(xRightTop - xLeftBottom), (int)(yRightTop - yLeftBottom));
rectangle(frame, object, Scalar(0, 255, 0));
}
}
imshow("detections", frame);
if (waitKey(1) >= 0) break;
}
最终结果如下所示:
在本节中,您学习了一种新的深度学习架构 SSD,以及如何使用它进行人脸检测。
简略的 / 概括的 / 简易判罪的 / 简易的
在本章中,我们学习了什么是深度学习,以及如何在 OpenCV 上使用深度学习进行对象检测和分类。 本章是为任何目的使用其他模型和深度神经网络的基础。
到目前为止,我们学习了如何获取和编译 OpenCV,如何使用基本图像和mat操作,以及如何创建自己的图形用户界面。 您使用了基本过滤器,并在工业检查示例中应用了所有这些过滤器。 我们了解了如何使用 OpenCV 进行人脸检测,以及如何操作它来添加面具。 最后,我们向您介绍了非常复杂的对象跟踪、文本分割和识别用例。 现在,您可以在 OpenCV 中创建自己的应用了,这要归功于这些用例,这些用例向您展示了如何应用每种技术或算法。 在下一章中,我们将学习如何为台式机和小型嵌入式系统(如 Raspberry Pi)编写一些图像处理过滤器。
十三、使用树莓派的卡通化和肤色分析
本章将介绍如何为台式机和小型嵌入式系统(如 Raspberry PI)编写一些图像处理过滤器。 首先,我们为桌面(使用 C/C++)进行开发,然后将项目移植到 Raspberry PI,因为这是为嵌入式设备开发时推荐的场景。 本章将介绍以下主题:
- 如何将真实图像转换为草图
- 如何转换成一幅画并叠加素描来制作卡通画
- 一种可怕的邪恶模式,创造坏角色而不是好角色
- 一个基本的皮肤探测仪和皮肤变色器,可以让人拥有绿色的外星人皮肤
- 最后,如何创建一个基于桌面应用的嵌入式系统
请注意,嵌入式系统基本上是放置在产品或设备内部的计算机主板,旨在执行特定任务,而Raspberry Pi则是一款非常低成本且受欢迎的用于构建嵌入式系统的主板:
上面的图片展示了你在这一章之后可以做什么:一个电池供电的树莓 Pi plus 屏幕,你可以戴着它去参加动漫展,把每个人都变成卡通人物!
我们希望自动使真实世界的摄影机帧看起来像是卡通中的。 基本的想法是在平坦的部分填上一些颜色,然后在坚固的边缘画出粗线条。 换句话说,平坦的区域应该变得更加平坦,边缘应该变得更加清晰。 我们将检测边缘,平滑平坦的区域,并在顶部绘制增强的边缘,以产生卡通或漫画效果。
在开发嵌入式计算机视觉系统时,最好先构建一个完全工作的桌面版本,然后再将其移植到嵌入式系统上,因为桌面程序的开发和调试比嵌入式系统容易得多! 因此,本章将从一个完整的 Cartoonizer 桌面程序开始,您可以使用您喜欢的 IDE(例如,Visual Studio、XCode、Eclipse 或 QtCreator)创建它。 当它在您的桌面上正常工作后,最后一节将展示如何基于桌面版本创建嵌入式系统。 许多嵌入式项目需要嵌入式系统的一些自定义代码,例如使用不同的输入和输出,或者使用一些特定于平台的代码优化。 然而,在本章中,我们实际上将在嵌入式系统和桌面上运行相同的代码,因此我们只需要创建一个项目。
该应用使用OpenCV的 GUI 窗口,初始化相机,并且对于每个相机帧,它调用包含本章中的大部分代码的cartoonifyImage()函数。 然后,它会在 GUI 窗口中显示处理后的图像。 本章将解释如何使用 USB 网络摄像头和基于桌面应用的嵌入式系统,使用 Raspberry PI Camera 模块从头开始创建桌面应用。 因此,首先您将在您喜欢的 IDE 中创建一个 Desktop 项目,其中包含一个main.cpp文件来保存以下部分中给出的 GUI 代码,例如主循环、网络摄像头功能和键盘输入,并且您将使用图像处理操作创建一个包含本章大部分代码的cartoon.cpp和文件,其中的大部分代码位于一个名为cartoonifyImage()的函数中。
访问网络摄像头
要访问计算机的网络摄像头或摄像头设备,只需在cv::VideoCapture个对象(OpenCV 访问摄像头设备的方法)上调用open()函数,并将0作为默认摄像头 ID 号传递即可。 有些计算机连接了多台摄像机,或者它们不能使用默认摄像机0,因此通常做法是允许用户将所需的摄像机编号作为命令行参数传递,例如,如果他们想尝试摄像机1、2或-1。 我们还将尝试使用cv::VideoCapture::set()功能将相机分辨率设置为 640 x 480,以便在高分辨率相机上运行得更快。
Depending on your camera model, driver, or system, OpenCV might not change the properties of your camera. It is not important for this project, so don’t worry if it does not work with your webcam.
您也可以将此代码放入您的文件main.cpp的main()和函数中:
auto cameraNumber = 0;
if (argc> 1)
cameraNumber = atoi(argv[1]);
// Get access to the camera.
cv::VideoCapture camera;
camera.open(cameraNumber);
if (!camera.isOpened()) {
std::cerr<<"ERROR: Could not access the camera or video!"<< std::endl;
exit(1);
}
// Try to set the camera resolution.
camera.set(cv::CV_CAP_PROP_FRAME_WIDTH, 640);
camera.set(cv::CV_CAP_PROP_FRAME_HEIGHT, 480);
摄像头初始化完成后,可以将当前摄像头图像抓取为 OPENCVcv::Mat对象(OpenCV 的图像容器)。 您可以使用 C++ 流运算符从cv::VideoCapture对象中的cv::Mat对象抓取每个摄影机帧,就像从控制台获取输入一样。
OpenCV makes it very easy to capture frames from a video file (such as an AVI or MP4 file) or network stream instead of a webcam. Instead of passing an integer such as camera.open(0), pass a string such as camera.open("my_video.avi") and then grab frames just like it was a webcam. The source code provided with this book has an initCamera() function that opens a webcam, video file, or network stream.
桌面应用的主摄像头处理循环
如果您希望使用 OpenCV 在屏幕上显示 GUI 窗口,您可以调用*、、cv::namedWindow()函数,然后调用每个图像的cv::imshow()函数,但您还必须每帧调用一次cv::waitKey()、,否则您的窗口根本不会更新! 调用和*cv::waitKey(0)将永远等待,直到用户点击窗口中的某个键,但像waitKey(20)或更高的正数将至少等待那么多毫秒。
将此主循环放入main.cpp文件中,作为您的实时相机应用的基础:
while (true) {
// Grab the next camera frame.
cv::Mat cameraFrame;
camera >> cameraFrame;
if (cameraFrame.empty()) {
std::cerr<<"ERROR: Couldn't grab a camera frame."<<
std::endl;
exit(1);
}
// Create a blank output image, that we will draw onto.
cv::Mat displayedFrame(cameraFrame.size(), cv::CV_8UC3);
// Run the cartoonifier filter on the camera frame.
cartoonifyImage(cameraFrame, displayedFrame);
// Display the processed image onto the screen.
imshow("Cartoonifier", displayedFrame);
// IMPORTANT: Wait for atleast 20 milliseconds,
// so that the image can be displayed on the screen!
// Also checks if a key was pressed in the GUI window.
// Note that it should be a "char" to support Linux.
auto keypress = cv::waitKey(20); // Needed to see anything!
if (keypress == 27) { // Escape Key
// Quit the program!
break;
}
}//end while
生成黑白草图
为了获得相机帧的草图(黑白素描),我们将使用边缘检测滤镜,而要获得彩色绘画,我们将使用边缘保持滤镜(双边滤镜)来进一步平滑平坦区域,同时保持边缘完整。 通过将素描叠加在彩画上,我们获得了卡通效果,如前面最终应用的截图所示。
有许多不同的边缘检测滤波器,例如 Sobel、Scharr 和 Laplacian 滤波器,或者 Canny 边缘检测器。 我们将使用拉普拉斯边缘过滤器,因为与 Sobel 或 Scharr 相比,它生成的边缘看起来最像手绘草图,并且与 Canny 边缘检测器相比非常一致,后者生成非常干净的线条图,但更多地受到相机帧中随机噪声的影响,因此线条图会在帧之间经常发生剧烈变化。
然而,在使用拉普拉斯边缘滤波器之前,我们仍然需要降低图像中的噪声。 我们将使用中值滤波器,因为它能很好地去除噪声,同时保持边缘的锐化,但不像双边滤波器那样慢。 由于拉普拉斯滤镜使用灰度图像,我们必须将 OpenCV 的默认 BGR 格式转换为灰度图像。 在空的cartoon.cpp和文件中,将此代码放在顶部,这样您就可以访问 OpenCV 和 STD C++ 模板,而无需在任何地方键入cv::和std:::
// Include OpenCV's C++ Interface
#include <opencv2/opencv.hpp>
using namespace cv;
using namespace std;
将此代码和所有剩余代码放在您的文件cartoon.cpp中的一个cartoonifyImage()函数中:
Mat gray;
cvtColor(srcColor, gray, CV_BGR2GRAY);
const int MEDIAN_BLUR_FILTER_SIZE = 7;
medianBlur(gray, gray, MEDIAN_BLUR_FILTER_SIZE);
Mat edges;
const int LAPLACIAN_FILTER_SIZE = 5;
Laplacian(gray, edges, CV_8U, LAPLACIAN_FILTER_SIZE);
拉普拉斯滤镜生成亮度不同的边缘,因此为了使边缘看起来更像草图,我们应用二进制阈值使边缘变为白色或黑色:
Mat mask;
const int EDGES_THRESHOLD = 80;
threshold(edges, mask, EDGES_THRESHOLD, 255, THRESH_BINARY_INV);
在下图中,您可以看到最原始的图像(左侧)和生成的边缘蒙版(右侧),它们看起来类似于草图。 在生成彩色绘画(稍后解释)之后,我们还将此边缘蒙版放在顶部以绘制黑色线条图:
生成一幅彩画和一幅卡通
一个强大的双边滤镜可以平滑平坦的区域,同时保持边缘的锐利,因此作为自动漫画器或绘画滤镜是很棒的,除了它非常慢(即,以秒甚至分钟衡量,而不是毫秒!)。 因此,我们将使用一些技巧来获得一个好的卡通器,同时仍然以可以接受的速度运行。 我们可以使用的最重要的技巧是,我们可以在更低的分辨率下执行双边过滤,它仍然具有与全分辨率类似的效果,但运行速度要快得多。 让我们将总像素数减少四个(例如,半宽半高):
Size size = srcColor.size();
Size smallSize;
smallSize.width = size.width/2;
smallSize.height = size.height/2;
Mat smallImg = Mat(smallSize, CV_8UC3);
resize(srcColor, smallImg, smallSize, 0,0, INTER_LINEAR);
我们不会使用大的双边滤镜,而是应用许多小的双边滤镜,在更短的时间内产生强大的卡通效果。 我们将截断滤镜(参见下图),以便它不执行整个滤镜(例如,当钟形曲线宽为 21 像素时,滤镜大小为 21 x 21),而只使用获得令人信服的结果所需的最小滤镜大小(例如,即使钟形曲线宽为 21 像素,滤镜大小也仅为 9 x 9)。 此截断滤镜将应用滤镜的主要部分(灰色区域),而不会在滤镜的次要部分(曲线下的白色区域)上浪费时间,因此它的运行速度会快几倍:
因此,我们有四个主要参数来控制双边滤镜:颜色强度、位置强度、大小和重复计数。 我们需要一个 TempMat,因为bilateralFilter()函数不能覆盖其输入(称为就地处理),但我们可以应用一个存储 TempMat的筛选器和另一个存储回输入的筛选器:
Mat tmp = Mat(smallSize, CV_8UC3);
auto repetitions = 7; // Repetitions for strong cartoon effect.
for (auto i=0; i<repetitions; i++) {
auto ksize = 9; // Filter size. Has large effect on speed.
double sigmaColor = 9; // Filter color strength.
double sigmaSpace = 7; // Spatial strength. Affects speed.
bilateralFilter(smallImg, tmp, ksize, sigmaColor, sigmaSpace); bilateralFilter(tmp, smallImg, ksize, sigmaColor, sigmaSpace);
}
请记住,这是应用于缩小的图像,因此我们需要将图像扩展回原始大小。 然后,我们可以覆盖前面找到的边缘蒙版。 要将边缘蒙版和素描部分叠加到双边滤镜和画板(下图左侧)上,我们可以从黑色背景开始,然后用不是素描和蒙版中的边缘的像素复制第一幅画:
Mat bigImg;
resize(smallImg, bigImg, size, 0,0, INTER_LINEAR);
dst.setTo(0);
bigImg.copyTo(dst, mask);
最终的结果是两张原始素描照片的卡通版本,如下图右手边所示,画中的素描蒙版叠加在画上:
使用边缘滤波器生成邪恶模式
动画片和漫画总是有好的和坏的人物。 搭配得当的边缘滤镜,就能从最纯真、最好看的人身上生成可怕的影像! 诀窍是使用一个小的边缘过滤器,它将在整个图像中找到许多边缘,然后使用一个小的中值过滤器合并边缘。
我们将在降噪的灰度图像上执行此操作,因此仍应使用前面用于将原始图像转换为灰度并应用 7x7 中值滤波器的代码(下图中的第一个图像显示了灰度中值模糊的输出)。 如果我们沿着x和y(图中第二幅图像)应用 3 x 3 Scharr 渐变滤镜,然后使用截止值非常低的二进制阈值(图中第三幅图像)和 3 x 3 中值模糊,从而产生最终的邪恶遮罩(图中第四幅图像),而不是使用拉普拉斯过滤器和二值阈值,我们可以获得更可怕的外观:
Mat gray;
cvtColor(srcColor, gray, CV_BGR2GRAY);
const int MEDIAN_BLUR_FILTER_SIZE = 7;
medianBlur(gray, gray, MEDIAN_BLUR_FILTER_SIZE);
Mat edges, edges2;
Scharr(srcGray, edges, CV_8U, 1, 0);
Scharr(srcGray, edges2, CV_8U, 1, 0, -1);
edges += edges2;
// Combine the x & y edges together.
const int EVIL_EDGE_THRESHOLD = 12
threshold(edges, mask, EVIL_EDGE_THRESHOLD, 255,
THRESH_BINARY_INV);
medianBlur(mask, mask, 3)
下图显示了第四幅图像中应用的邪恶效果:
现在我们有了一个新的邪恶蒙版,我们可以将这个蒙版叠加到卡通化的绘画图像上,就像我们对常规的素描边缘蒙版所做的那样。 最终结果如下图右侧所示:
使用皮肤检测生成外来模式
现在我们已经有了一个新的素描模式、一个新的卡通模式(画+素描面具)和一个新的恶模式(画)+恶面具,为了好玩,让我们来试试更复杂的东西:一个外星人。
皮肤检测算法
有许多不同的技术用于检测皮肤区域,从使用RGB(简写为红-绿-蓝)或HSV(简写为色调-饱和度-亮度)的简单颜色阈值,或者颜色直方图计算和重新投影,到复杂的混合模型的机器学习算法,这些混合模型需要在CIELab的颜色中进行相机校准。 但是,即使是复杂的方法也不一定适用于各种相机、照明条件和皮肤类型。 由于我们希望我们的皮肤检测在嵌入式设备上运行,无需任何校准或训练,而我们只是将皮肤检测用于有趣的图像过滤器;对于我们来说,使用简单的皮肤检测方法就足够了。 然而,Raspberry Pi 相机模块中的微型相机传感器的颜色响应往往差异很大,我们希望支持任何肤色的人的皮肤检测,但不需要任何校准,因此我们需要比简单的颜色阈值更强大的东西。
例如,一个简单的 HSV 皮肤检测器可以将任何像素视为皮肤,如果它的色调颜色相当红,饱和度相当高但不是非常高,并且它的亮度不是太暗也不是非常亮。 但手机或树莓 Pi 相机模块中的摄像头通常白色平衡不佳;因此,例如,一个人的皮肤可能看起来略有蓝色,而不是红色,这将是简单的 HSV 阈值的主要问题。
更可靠的解决方案是使用 Haar 或 LBP 级联分类器执行人脸检测(如第章,第章使用 DNN 模块进行人脸检测和识别中所示),然后查看检测到的人脸中间像素的颜色范围,因为您知道这些像素应该是真人的皮肤像素。 然后,你可以扫描整个图像或附近区域,寻找与脸部中心颜色相似的像素。 这样做的好处是,无论他们的肤色是什么,或者即使他们的皮肤在相机图像中看起来有点蓝或红,它都很有可能找到至少一些被检测到的人的真实皮肤区域。
不幸的是,在当前的嵌入式设备上,使用级联分类器进行人脸检测的速度非常慢,因此该方法对于一些实时嵌入式应用可能不太理想。 另一方面,我们可以利用这样一个事实,对于移动应用和一些嵌入式系统,可以预期用户将从非常近的距离直接面对摄像头,因此要求用户将脸部放置在特定位置和距离,而不是试图检测脸部的位置和大小是合理的。 这是许多手机应用的基础,应用要求用户将脸部放置在某个位置,或者可能手动拖动屏幕上的点,以显示照片中脸部角落的位置。 因此,让我们简单地在屏幕中央绘制一张脸的轮廓,并要求用户将他们的脸移动到所示的位置和大小。
向用户展示他们的脸应该放在哪里
当第一次启动外星人模式时,我们会在相机框架的顶部绘制人脸轮廓,这样用户就知道应该把脸放在哪里。 我们会画一个覆盖 70%图像高度的大椭圆,固定的宽高比为 0.72,这样脸部就不会因为摄像头的宽高比而变得太瘦或太胖:
// Draw the color face onto a black background.
Mat faceOutline = Mat::zeros(size, CV_8UC3);
Scalar color = CV_RGB(255,255,0); // Yellow.
auto thickness = 4;
// Use 70% of the screen height as the face height.
auto sw = size.width;
auto sh = size.height;
int faceH = sh/2 * 70/100; // "faceH" is radius of the ellipse.
// Scale the width to be the same nice shape for any screen width.
int faceW = faceH * 72/100;
// Draw the face outline.
ellipse(faceOutline, Point(sw/2, sh/2), Size(faceW, faceH),
0, 0, 360, color, thickness, CV_AA);
为了更清楚地表明这是一张脸,我们还画了两个眼睛轮廓。 与将眼睛绘制为椭圆不同,我们可以通过为眼睛顶部绘制截断椭圆和为眼睛底部绘制截断椭圆来使其更逼真(请参阅下图),因为我们可以在使用{ellipse()}函数绘制时指定起点和终点角度:
// Draw the eye outlines, as 2 arcs per eye.
int eyeW = faceW * 23/100;
int eyeH = faceH * 11/100;
int eyeX = faceW * 48/100;
int eyeY = faceH * 13/100;
Size eyeSize = Size(eyeW, eyeH);
// Set the angle and shift for the eye half ellipses.
auto eyeA = 15; // angle in degrees.
auto eyeYshift = 11;
// Draw the top of the right eye.
ellipse(faceOutline, Point(sw/2 - eyeX, sh/2 -eyeY),
eyeSize, 0, 180+eyeA, 360-eyeA, color, thickness, CV_AA);
// Draw the bottom of the right eye.
ellipse(faceOutline, Point(sw/2 - eyeX, sh/2 - eyeY-eyeYshift),
eyeSize, 0, 0+eyeA, 180-eyeA, color, thickness, CV_AA);
// Draw the top of the left eye.
ellipse(faceOutline, Point(sw/2 + eyeX, sh/2 - eyeY),
eyeSize, 0, 180+eyeA, 360-eyeA, color, thickness, CV_AA);
// Draw the bottom of the left eye.
ellipse(faceOutline, Point(sw/2 + eyeX, sh/2 - eyeY-eyeYshift),
eyeSize, 0, 0+eyeA, 180-eyeA, color, thickness, CV_AA);
我们可以用同样的方法画下嘴唇:
// Draw the bottom lip of the mouth.
int mouthY = faceH * 48/100;
int mouthW = faceW * 45/100;
int mouthH = faceH * 6/100;
ellipse(faceOutline, Point(sw/2, sh/2 + mouthY), Size(mouthW,
mouthH), 0, 0, 180, color, thickness, CV_AA);
为了更清楚地表明用户应该把脸放在显示的位置,让我们在屏幕上写一条消息!
// Draw anti-aliased text.
int fontFace = FONT_HERSHEY_COMPLEX;
float fontScale = 1.0f;
int fontThickness = 2;
char *szMsg = "Put your face here";
putText(faceOutline, szMsg, Point(sw * 23/100, sh * 10/100),
fontFace, fontScale, color, fontThickness, CV_AA);
现在我们已经绘制了人脸轮廓,可以通过使用 Alpha 混合将卡通化图像与绘制的轮廓组合在一起,将其覆盖到显示的图像上:
addWeighted(dst, 1.0, faceOutline, 0.7, 0, dst, CV_8UC3);
这会产生下图中的轮廓,向用户显示脸部放置的位置,因此我们不必检测脸部位置:
肤色变更器的实现
我们可以使用 OpenCV 的floodFill()和功能,这类似于大多数图像编辑软件中的桶填充工具,而不是先检测肤色,然后再检测具有该肤色的区域。 我们知道屏幕中间的区域应该是皮肤像素(因为我们要求用户将他们的脸放在中间),所以要将整个脸部更改为绿色皮肤,我们只需在中心像素上应用绿色泛滥填充,这将始终将脸部的某些部分着色为绿色。 实际上,脸部不同部位的颜色、饱和度和亮度可能会有所不同,因此泛洪填充很少会覆盖脸部的所有蒙皮像素,除非阈值太低,以至于它也会覆盖脸部以外的不需要的像素。 因此,与其在图像中心应用单一的泛洪填充,不如在脸部周围六个不同的点上应用泛洪填充,这些点应该是皮肤像素。
OpenCV 的floodFill()和的一个很好的功能是,它可以在外部图像中绘制泛洪填充,而不是修改输入图像。 因此,此功能可以为我们提供一个蒙版图像,用于调整皮肤像素的颜色,而不需要更改亮度或饱和度,从而产生比所有皮肤像素都变成相同的绿色像素(丢失明显的人脸细节)时更逼真的图像。
皮肤颜色更改在 RGB 颜色空间中效果不佳,因为您希望允许人脸亮度变化,但不允许皮肤颜色变化太多,而且 RGB 不会将亮度与颜色分开。 一种解决方案是使用 HSV 颜色空间,因为它将亮度与颜色(色调)以及色彩(饱和度)分开。 不幸的是,HSV 将色调值包裹在红色周围,由于皮肤主要是红色的,这意味着您需要同时使用色调<10%和色调>90%,因为这两个色调都是红色。 因此,我们将改用Y‘CrCb颜色空间(OpenCV 中 YUV 的变体),因为它将亮度与颜色分开,并且只有一个典型肤色的值范围,而不是两个。 请注意,在转换为 RGB 之前,大多数相机、图像和视频实际上使用某种类型的 YUV 作为其色彩空间,因此在许多情况下,您可以免费获得 YUV 图像,而无需自己转换它。
由于我们希望我们的外星人模式看起来像卡通,我们将在图像已经卡通化后应用最新的外星人滤镜。 换句话说,我们可以访问由双边滤波器产生的缩小的彩色图像,并且可以访问全尺寸的边缘蒙版。 皮肤检测通常在低分辨率下工作得更好,因为它等同于分析每个高分辨率像素的邻居的平均值(或者是低频信号而不是高频噪声信号)。 因此,让我们使用与双边滤镜相同的缩小比例(半宽半高)。 让我们将绘画图像转换为 YUV:
Mat yuv = Mat(smallSize, CV_8UC3);
cvtColor(smallImg, yuv, CV_BGR2YCrCb);
我们还需要缩小边缘蒙版,使其与绘画图像的比例相同。 OpenCV 的floodFill()和函数在存储到单独的蒙版图像时有一个复杂之处,即蒙版应该在整个图像周围有一个像素的边界,因此如果输入图像的大小是W x H像素,那么单独的蒙版图像的大小应该是*(W+2)x(H+2)像素。 但 FirstfloodFill()函数还允许我们使用泛洪填充算法将确保其不会交叉的边来初始化遮罩。 让我们使用这个功能,希望它能帮助防止泛滥的填充物延伸到脸部之外。 因此,我们需要提供两张蒙版图片:一张是大小为W x H的边缘蒙版,另一张是与(W+2)x(H+2)大小完全相同的边缘蒙版,因为它应该在图像周围包含一个边框。 可以有多个cv::Mat和对象(或标题)引用相同的数据,甚至可以有一个引用另一个cv::Mat图像的子区域的cv::Mat对象。 因此,与其分配两个单独的图像并复制边缘遮罩像素,不如分配一个包括边框的遮罩图像,并额外创建一个标题为W x H的额外的cv::Mat和头(它只引用没有边框的泛洪填充遮罩中的感兴趣区域)。 换句话说,只有一个大小为(W+2)x(H+2)的像素数组,但有两个cv::Mat和对象,其中一个引用整个图像(W+2)x(H+2),另一个引用该图像中间的区域W x H*:
auto sw = smallSize.width;
auto sh = smallSize.height;
Mat mask, maskPlusBorder;
maskPlusBorder = Mat::zeros(sh+2, sw+2, CV_8UC1);
mask = maskPlusBorder(Rect(1,1,sw,sh));
// mask is now in maskPlusBorder.
resize(edges, mask, smallSize); // Put edges in both of them.
边缘遮罩(如下图左侧所示)充满了强边缘和弱边缘,但我们只想要强边缘,因此我们将应用二进制阈值(生成下图中的中间图像)。 要连接边缘之间的一些间隙,我们将结合形态运算符dilate()和erode()来删除一些间隙(也称为 Close 运算符),从而产生右侧的图像:
const int EDGES_THRESHOLD = 80;
threshold(mask, mask, EDGES_THRESHOLD, 255, THRESH_BINARY);
dilate(mask, mask, Mat());
erode(mask, mask, Mat());
我们可以在下图中看到应用阈值和形态学运算的结果,第一幅图像是输入边缘图,第二幅是阈值滤波器,最后一幅是膨胀和侵蚀形态滤波器:
正如前面提到的,我们希望在脸部周围的许多点上应用泛洪填充,以确保包括整个脸部的各种颜色和阴影。 让我们选择鼻子、脸颊和前额周围的六个点,如下面屏幕截图的左侧所示。 请注意,这些值取决于之前绘制的人脸轮廓:
auto const NUM_SKIN_POINTS = 6;
Point skinPts[NUM_SKIN_POINTS];
skinPts[0] = Point(sw/2, sh/2 - sh/6);
skinPts[1] = Point(sw/2 - sw/11, sh/2 - sh/6);
skinPts[2] = Point(sw/2 + sw/11, sh/2 - sh/6);
skinPts[3] = Point(sw/2, sh/2 + sh/16);
skinPts[4] = Point(sw/2 - sw/9, sh/2 + sh/16);
skinPts[5] = Point(sw/2 + sw/9, sh/2 + sh/16);
现在,我们只需要为洪水填充物找到一些合适的下限和上界。 请记住,这是在 Y‘CrCb 的颜色空间中执行的,因此我们基本上决定了亮度可以变化多少,红色分量可以变化多少,蓝色分量可以变化多少。 我们希望允许亮度变化很大,包括阴影以及高光和反射,但我们根本不希望颜色变化太大:
const int LOWER_Y = 60;
const int UPPER_Y = 80;
const int LOWER_Cr = 25;
const int UPPER_Cr = 15;
const int LOWER_Cb = 20;
const int UPPER_Cb = 15;
Scalar lowerDiff = Scalar(LOWER_Y, LOWER_Cr, LOWER_Cb);
Scalar upperDiff = Scalar(UPPER_Y, UPPER_Cr, UPPER_Cb);
除了要存储到外部掩码之外,我们将使用带有默认标志的floodFill()和函数,因此我们必须指定FLOODFILL_MASK_ONLY:
const int CONNECTED_COMPONENTS = 4; // To fill diagonally, use 8.
const int flags = CONNECTED_COMPONENTS | FLOODFILL_FIXED_RANGE
| FLOODFILL_MASK_ONLY;
Mat edgeMask = mask.clone(); // Keep a copy of the edge mask.
// "maskPlusBorder" is initialized with edges to block floodFill().
for (int i = 0; i < NUM_SKIN_POINTS; i++) {
floodFill(yuv, maskPlusBorder, skinPts[i], Scalar(), NULL,
lowerDiff, upperDiff, flags);
}
左手边的下图显示了六个泛洪填充位置(显示为圆圈),右手边显示的是生成的外部蒙版,其中皮肤显示为灰色,边缘显示为白色。 请注意,本书的右侧图像已修改,以便皮肤像素(值为1)清晰可见:
下面的mask图像(显示在上一图像的右侧)现在包含以下内容:
- 边缘像素的值为 255 的像素
- 皮肤区域的值为 1 的像素
- 其余像素的值为 0
同时,edgeMask仅包含边缘像素(值 255)。 所以要只得到皮肤像素,我们可以去掉它的边缘:
mask -= edgeMask;
变量mask现在只包含皮肤像素的 1 和非皮肤像素的 0。 要更改原始图像的肤色和亮度,我们可以使用带有蒙版的cv::add()函数来增加原始 BGR 图像中的绿色分量:
auto Red = 0;
auto Green = 70;
auto Blue = 0;
add(smallImgBGR, CV_RGB(Red, Green, Blue), smallImgBGR, mask);
下图左边是原始图像,右边是最终的外星人卡通形象,现在脸部至少有六个部分将是绿色的!
请注意,我们已经使皮肤看起来是绿色的,但也更亮了(看起来像在黑暗中发光的外星人)。 如果只想更改肤色而不使其更亮,可以使用其他颜色更改方法,例如将70添加到绿色,同时从红色和蓝色减去70,或者使用cvtColor(src, dst, "CV_BGR2HSV_FULL")转换为 HSV 颜色空间并调整色调和饱和度。
降低草图图像中的随机胡椒噪声
智能手机中的大多数微型摄像头、树莓 PI 摄像头模块和一些网络摄像头都有明显的图像噪音。 这通常是可以接受的,但它对我们的 5x5 拉普拉斯边缘滤波器有很大的影响。 边缘蒙版(显示为草图模式)通常会有数千个称为胡椒噪波的黑色像素小斑点,由白色背景上彼此相邻的几个黑色像素组成。 我们已经在使用中值滤波器,通常它的强度足以去除胡椒噪声,但在我们的情况下,它可能还不够强。 我们的边缘蒙版主要是纯白色背景(值 255),带有一些黑色边缘(值 0)和噪声点(值也是 0)。 我们可以使用标准的闭合形态运算符,但它会去除很多边缘。 因此,我们将应用自定义滤镜来移除完全被白色像素包围的小黑色区域。 这将消除大量噪波,而对实际边缘影响不大。
我们将扫描图像中的黑色像素,并且在每个黑色像素处,我们将检查其周围 5x5 正方形的边界,以查看是否所有 5x5 边界像素都是白色的。 如果它们都是白色的,那么我们知道我们有一个黑色噪声的小岛,所以我们用白色像素填充整个区块来移除黑岛。 为简单起见,在我们的 5x5 过滤器中,我们将忽略图像周围的两个边界像素,并保留它们的原样。
下图左侧是安卓平板电脑的原始图片,中间是素描模式,显示了胡椒噪点的小黑点,右侧显示了我们去除胡椒噪点的结果,皮肤看起来更干净:
为了简单起见,下面的代码也可以命名为removePepperNoise()和函数,用于在适当的位置编辑图像文件:
void removePepperNoise(Mat &mask)
{
for (int y=2; y<mask.rows-2; y++) {
// Get access to each of the 5 rows near this pixel.
uchar *pUp2 = mask.ptr(y-2);
uchar *pUp1 = mask.ptr(y-1);
uchar *pThis = mask.ptr(y);
uchar *pDown1 = mask.ptr(y+1);
uchar *pDown2 = mask.ptr(y+2);
// Skip the first (and last) 2 pixels on each row.
pThis += 2;
pUp1 += 2;
pUp2 += 2;
pDown1 += 2;
pDown2 += 2;
for (auto x=2; x<mask.cols-2; x++) {
uchar value = *pThis; // Get pixel value (0 or 255).
// Check if it's a black pixel surrounded bywhite
// pixels (ie: whether it is an "island" of black).
if (value == 0) {
bool above, left, below, right, surroundings;
above = *(pUp2 - 2) && *(pUp2 - 1) && *(pUp2) && *(pUp2 + 1)
&& *(pUp2 + 2);
left = *(pUp1 - 2) && *(pThis - 2) && *(pDown1 - 2);
below = *(pDown2 - 2) && *(pDown2 - 1) && *(*pDown2*) &&* (pDown2 + 1) && *(pDown2 + 2);
right = *(pUp1 + 2) && *(pThis + 2) && *(pDown1 + 2);
surroundings = above && left && below && right;
if (surroundings == true) {
// Fill the whole 5x5 block as white. Since we
// knowthe 5x5 borders are already white, we just
// need tofill the 3x3 inner region.
*(pUp1 - 1) = 255;
*(pUp1 + 0) = 255;
*(pUp1 + 1) = 255;
*(pThis - 1) = 255;
*(pThis + 0) = 255;
*(pThis + 1) = 255;
*(pDown1 - 1) = 255;
*(pDown1 + 0) = 255;
*(pDown1 + 1) = 255;
// Since we just covered the whole 5x5 block with
// white, we know the next 2 pixels won't be
// black,so skip the next 2 pixels on the right.
pThis += 2;
pUp1 += 2;
pUp2 += 2;
pDown1 += 2;
pDown2 += 2;
}
}
// Move to the next pixel on the right.
pThis++ ;
pUp1++ ;
pUp2++ ;
pDown1++ ;
pDown2++ ;
}
}
}
就这样!。 在不同模式下运行应用,直到您准备好将其移植到嵌入式设备!
从台式机移植到嵌入式设备
既然我们的程序可以在桌面上运行,我们就可以用它来制作嵌入式系统了。 这里给出的细节特定于 Raspberry PI,但在为其他嵌入式 Linux 系统(如 Beaglebone、ODROID、Olimex、Jetson 等)开发时也适用类似的步骤。
在嵌入式系统上运行我们的代码有几种不同的选择,每种选择在不同的场景中各有优缺点。
为嵌入式设备编译代码有两种常见方法:
- 将源代码从桌面复制到设备上,然后直接在设备板上编译。 这通常被称为本机编译,因为我们在最终运行代码的同一系统上进行本机编译。
- 编译桌面上的所有代码,但使用特殊方法为设备生成代码,然后将最终的可执行程序复制到设备上。 这通常被称为交叉编译,因为您需要一个知道如何为其他类型的 CPU 生成代码的特殊编译器。
交叉编译通常比本机编译更难配置,特别是在您使用许多共享库的情况下,但是由于您的桌面通常比您的嵌入式设备快得多,所以在编译大型项目时,交叉编译通常要快得多。 如果你预计要编译你的项目数百次,以便在上面工作几个月,而你的设备比你的台式机(如 Raspberry Pi 1 或 Raspberry Pi Zero)速度相当慢,而这两款设备比台式机慢得多,那么交叉编译是一个好主意。 但在大多数情况下,尤其是对于小型、简单的项目,您应该坚持使用本机编译,因为这样更容易。
请注意,您的项目使用的所有库也需要为设备编译,因此您需要为您的设备编译 OpenCV。 在 Raspberry PI 1 上本地编译 OpenCV 可能需要几个小时,而在桌面上交叉编译 OpenCV 可能只需要 15 分钟。 但是您通常只需要编译一次 OpenCV,然后您就可以对所有项目使用它,所以在大多数情况下仍然值得坚持项目的本机编译(包括 OpenCV 的本机编译)。
关于如何在嵌入式系统上运行代码,还有几种选择:
- 使用您在桌面上使用的相同输入和输出方法,例如与输入相同的视频文件、USB 网络摄像头或键盘,并以与在桌面上相同的方式在 HDMI 显示器上显示文本或图形。
- 使用特殊设备进行输入和输出。 例如,与其坐在办公桌前使用 USB 网络摄像头和键盘作为输入并在桌面显示器上显示输出,您可以使用特殊的 Raspberry Pi Camera Module 进行视频输入,使用定制的 GPIO 按钮或传感器进行输入,使用 7 英寸的 MIPI DSI 屏幕或 GPIO LED 灯作为输出,然后通过使用通用的便携式 USB 充电器为其供电,您可以在背包中穿戴整个计算机平台或将其连接到您的自行车上!
- 另一种选择是将数据流入或流出嵌入式设备到其他计算机,或者甚至使用一个设备来流出相机数据,使用一个设备来使用该数据。 例如,您可以使用 GStreamer 框架将 Raspberry PI 配置为将 H.264 压缩视频从其摄像头模块流式传输到以太网或通过 Wi-Fi,以便本地网络或 Amazon AWS 云计算服务上功能强大的 PC 或服务器机架可以在其他地方处理视频流。 该方法允许在需要位于其他地方的大量处理资源的复杂项目中使用小而便宜的摄像设备。
如果您确实希望在设备上执行计算机视觉,请注意一些低成本的嵌入式设备,如 Raspberry Pi 1、Raspberry Pi Zero 和 Beaglebone Black,其计算能力明显低于台式机,甚至低于廉价的上网本或智能手机,可能比您的台式机慢 10-50 倍,因此根据您的应用,您可能需要功能强大的嵌入式设备或将视频流到单独的计算机,如前所述。 如果你不需要太多的计算能力(例如,你只需要每 2 秒处理一帧,或者你只需要使用 160x120 图像分辨率),那么在机上运行一些计算机视觉的 Raspberry Pi Zero 可能就足够快了。 但许多计算机视觉系统需要更强的计算能力,因此,如果你想在设备上执行计算机视觉,你通常会想要使用 CPU 在 2 GHz 范围内的速度更快的设备,如 Raspberry Pi 3、ODROID-XU4 或 Jetson TK1。
为嵌入式设备开发代码的设备设置
让我们从保持尽可能简单开始,就像我们的桌面系统一样,使用 USB 键盘和鼠标以及 HDMI 显示器,在设备上本地编译代码,并在设备上运行我们的代码。 我们的第一步是将代码复制到设备上,安装构建工具,并在嵌入式系统上编译 OpenCV 和源代码。
许多嵌入式设备(如 Raspberry Pi)都有一个 HDMI 端口和至少一个 USB 端口。 因此,开始使用嵌入式设备最简单的方法是插入设备的 HDMI 显示器和 USB 键盘鼠标,配置设置并查看输出,同时使用台式机进行代码开发和测试。 如果你有一台备用的 HDMI 显示器,把它插到设备上,但如果你没有备用的 HDMI 显示器,你可以考虑只为你的嵌入式设备购买一个小的 HDMI 屏幕。
此外,如果你没有备用的 USB 键盘和鼠标,你可以考虑买一个只有一个 USB 无线加密狗的无线键盘和鼠标,这样你就只用了一个 USB 端口来连接键盘和鼠标。 许多嵌入式设备使用 5V 电源,但它们通常需要比台式机或笔记本电脑的 USB 端口提供更多的电力(电流)。 因此,你应该获得一个单独的 5V USB 充电器(至少 1.5 安培,理想情况下是 2.5 安培),或者一个可以提供至少 1.5 安培输出电流的便携式 USB 电池充电器。 您的设备大部分时间可能只使用 0.5 安培,但偶尔需要超过 1 安培,因此使用额定功率至少为 1.5 安培或更高的电源是很重要的,否则您的设备将偶尔重新启动,或者某些硬件在重要时刻可能表现异常,否则文件系统可能会损坏并丢失文件! 如果你不使用相机或配件,1 安培的电源可能已经足够好了,但 2.0-2.5 安培更安全。
例如,下面的照片显示了一个方便的设置,其中包括一个 Raspberry Pi 3,一个 10 美元(http://ebay.to/2ayp6Bo)的高质量 8 GB Micro-SD 卡,一个 30-45 美元(http://bit.ly/2aHQO2G)的 5 英寸高清晰度电阻触摸屏,一个 30 美元(http://ebay.to/2aN2oXi)的无线 USB 键盘和鼠标, 5 美元的 5V 2.5A 电源(https://amzn.to/2UafanD),只需 5 美元(http://ebay.to/2aVWCUS)的非常快速的PS3 Eye等 USB 网络摄像头,15-30 美元的树莓 Pi 摄像头模块 v1 或 v2(http://bit.ly/2aF9PxD), 2 美元的以太网线(http://ebay.to/2aznnjd),将 Raspberry Pi 连接到与您的开发 PC 或笔记本电脑相同的局域网。 请注意,这款高清 MI 屏是专门为 Raspberry Pi 设计的,因为屏幕直接插入其下方的 Raspberry PI,并且有一个用于 Raspberry Pi 的高清晰度 MI 接口插头(如右手照片所示),因此您不需要 HDMI 线,而其他屏幕可能需要 HDMI 线(https://amzn.to/2Rvet6H),或者 MIPIDSI 或 SPI 线。
还请注意,某些屏幕和触摸屏需要配置才能工作,而大多数 HDMI 屏幕应该在没有任何配置的情况下工作:
请注意黑色 USB 网络摄像头(在 LCD 的最左侧)、Raspberry PI 摄像头模块(位于 LCD 左上角的绿黑相间的板)、Raspberry PI 板(位于 LCD 下方)、HDMI 适配器(将 LCD 连接到其下方的 Raspberry PI)、一根蓝色以太网电缆(插入路由器)、一个小型 USB 无线键盘和鼠标转换器以及一根微型 USB 电源线(插入5V 2.5A[T1
配置新的树莓 PI
以下步骤特定于 Raspberry PI,因此,如果您使用不同的嵌入式设备或想要不同类型的设置,请在网络上搜索如何设置您的主板。 要设置 Raspberry PI 1、2 或 3(包括它们的变体,例如 Raspberry PI Zero、Raspberry PI 2B、3B 等,如果您插入 USB 以太网加密狗,则还可以设置 Raspberry PI 1A+),请执行以下步骤:
-
买一张相当新的、质量好的至少 8 GB 的 microSD 卡。 如果你使用的是以前已经用过很多次的便宜的 Micro-SD 卡或旧的 Micro-SD 卡,而它的质量已经下降,那么启动 Raspberry PI 可能不够可靠,所以如果你在启动 Raspberry PI 时遇到问题,你应该尝试质量好的 10 类 Micro-SD 卡(如 SanDisk Ultra 或更好的卡),它说它至少可以处理 45 Mbps 或 4K 视频。
-
下载并将最新版本Raspbian IMG(非 NOOBS)刻录到 Micro-SD 卡。 请注意,刻录 img 与简单地将文件复制到 SD 是不同的。 访问 https://www.raspberrypi.org/documentation/installation/installing-img/Raspbian,按照桌面操作系统的说明将 Raspbian 刻录到 Micro-SD 卡。 请注意,您将丢失卡上以前存在的所有文件。
-
将 USB 键盘、鼠标和 HDMI 显示器插入 Raspberry PI,这样您就可以轻松地运行一些命令并查看输出。
-
将 Raspberry PI 插入至少 1.5 安(理想情况下为 2.5 安或更高)的 5V USB 电源。 计算机 USB 端口不够强大。
-
当它启动 Raspbian Linux 时,您应该会看到许多页面的文本滚动,然后它应该在 1 到 2 分钟后就准备好了。
-
如果在引导之后,它只是显示带有一些文本的黑色控制台屏幕(例如,如果您下载了Raspbian Lite),那么您将进入纯文本登录提示符。 以用户名键入
pi以登录,然后按Enter。 然后,键入raspberry作为密码,并再次按下Enter键。 -
或者,如果它已引导至图形显示,请单击顶部的黑色终端图标以打开外壳(命令提示符)。
-
初始化树莓 PI 中的一些设置:
- 键入
sudo raspi-config或,然后按Enter按钮(参见下面的屏幕截图)。 - 首先,运行并展开 Filessystem,然后完成并重启设备,这样 Raspberry PI 就可以使用整个 microSD 卡。
- 如果您要使用普通(美国)键盘,而不是英式键盘,请在国际化选项中更改为通用 104 键键盘,其他,英语(美国),然后对于 AltGr 键盘和类似问题,只需按Enter,除非您使用的是特殊键盘。
- 在 Enable Camera 中,启用 Raspberry PI Camera Module。
- 在超频选项中,设置为 Raspberry PI 2 或类似于设备运行速度更快(但会产生更多热量)。
- 在高级选项中,启用 SSH 服务器。
- 在高级选项中,如果您使用的是 Raspberry PI 2 或 3,请将内存分割改为 256MB,这样 GPU 就有足够的 RAM 用于视频处理。 对于 Raspberry PI 1 或 0,使用 64 MB 或默认值。
- 完成,然后重新启动设备。
- 键入
-
(可选):删除 Wolfram 以节省 SD 卡 600 MB 空间:
sudo apt-get purge -y wolfram-engine
可以使用sudo apt-get install wolfram-engine重新安装。
要查看 SD 卡上的剩余空间,请运行命令df -h | head -2:
- 假设你已经把树莓 PI 插到了你的互联网路由器上,它应该已经可以上网了。 因此,请将您的 Raspberry Pi 更新到最新的 Raspberry Pi 固件、软件位置、操作系统和软件。警告:许多 Raspberry Pi 教程建议您应该运行
sudo rpi-update;然而,近年来,运行rpi-update不再是一个好主意,因为它会给您带来不稳定的系统或固件。 以下说明将更新您的 Raspberry PI,使其具有稳定的软件和固件(请注意,这些命令可能需要长达一个小时):
sudo apt-get -y update
sudo apt-get -y upgrade
sudo apt-get -y dist-upgrade
sudo reboot
- 查找设备的 IP 地址:
hostname -I
- 尝试从您的桌面访问该设备。 例如,假设设备的 IP 地址是
192.168.2.101。要在 Linux 桌面上输入以下内容:
ssh-X pi@192.168.2.101
- 或者,在 Windows 桌面上执行此操作:
1. 下载、安装和运行 PuTTY
2. 然后在 PuTTY 中,连接到 IP 地址(192.168.2.101),用户输入pi,密码为:raspberry - 或者,如果希望命令提示符的颜色与命令不同,并在每个命令后显示错误值,请使用以下命令:
nano ~/.bashrc
- 将此行添加到底部:
PS1="[e[0;44m]u@h: w ($?) $[e[0m] "
- 保存文件(按Ctrl+X,然后按Y,然后按Enter)。
- 开始使用新设置:
source ~/.bashrc
- 要防止 Raspbian 中的屏幕保护程序/屏幕空白省电功能在空闲状态下关闭屏幕,请使用以下命令:
sudo nano /etc/lightdm/lightdm.conf
- 并遵循以下步骤:
1. 查找显示“#xserver-command=X”的行(按Alt+G,然后键入87,再按Enter,跳到第87行)。
2. 将其更改为xserver-command=X -s 0 dpms。
3. 保存文件(按Ctrl)+X,,然后按Y,,然后按Enter)。 - 最后,重新启动 Raspberry PI:
sudo reboot
你现在应该已经准备好开始在这款设备上开发了!
在嵌入式设备上安装 OpenCV
有一种非常简单的方法可以在基于 Debian 的嵌入式移动设备(如 Raspberry Pi)上安装 OpenCV 及其所有依赖项:
sudo apt-get install libopencv-dev
然而,这可能会安装一两年前的旧版本 OpenCV。
要在 Raspberry PI 等嵌入式设备上安装最新版本的 OpenCV,我们需要从源代码构建 OpenCV。 首先,我们安装编译器并构建系统,然后安装供 OpenCV 使用的库,最后是 OpenCV 本身。 请注意,无论您是针对台式机还是针对嵌入式系统进行编译,在 Linux 上从源代码编译 OpenCV 的步骤都是相同的。 本书附带一个 Linux 脚本:install_opencv_from_source.sh;建议您将该文件复制到您的 Raspberry PI 上(例如,使用 USB 闪存盘),然后运行该脚本以下载、构建和安装 OpenCV,包括潜在的多核 CPU 和ARM 霓虹灯 SIMD的优化(取决于硬件支持):
chmod +x install_opencv_from_source.sh
./install_opencv_from_source.sh
The script will stop if there is an error, for example, if you don’t have internet access or a dependency package conflicts with something else you already installed. If the script stops with an error, try using info on the web to solve that error, then run the script again. The script will quickly check all the previous steps and then continue from where it finished last time. Note that it will take between 20 minutes and 12 hours depending on your hardware and software!
强烈建议您在每次安装 OpenCV 时构建并运行几个 OpenCV 示例,这样当您在构建自己的代码时遇到问题,至少可以知道问题是安装 OpenCV 还是您的代码有问题。
让我们试着构建一个简单的edge示例程序。 如果我们尝试使用相同的 Linux 命令从 OpenCV 2 构建它,则会收到构建错误:
cd ~/opencv-4.*/samples/cpp
g++ edge.cpp -lopencv_core -lopencv_imgproc -lopencv_highgui
-o edge
/usr/bin/ld: /tmp/ccDqLWSz.o: undefined reference to symbol '_ZN2cv6imreadERKNS_6StringEi'
/usr/local/lib/libopencv_imgcodecs.so.4..: error adding symbols: DSO missing from command line
collect2: error: ld returned 1 exit status
该错误消息的倒数第二行告诉我们,命令行中缺少一个库,因此我们只需在命令中的链接到的其他 OpenCV 库旁边添加-lopencv_imgcodecs。 现在,当您在编译 OpenCV 3 程序时看到该错误消息时,您知道如何修复该问题。 所以,让我们做正确的事:
cd ~/opencv-4.*/samples/cpp
g++ edge.cpp -lopencv_core -lopencv_imgproc -lopencv_highgui
-lopencv_imgcodecs -o edge
啊,真灵!。 现在,您可以运行该程序了:
./edge
按键盘上的Ctrl+C键退出程序。 请注意,如果您尝试在 SSH 终端中运行该命令,而不重定向窗口以在设备的 LCD 屏幕上显示,则该edge命令程序可能会崩溃。 因此,如果您使用 SSH 远程运行程序,请在命令前添加DISPLAY=:0命令:
DISPLAY=:0 ./edge
您还应将 USB 网络摄像头插入设备,并测试其是否正常工作:
g++ starter_video.cpp -lopencv_core -lopencv_imgproc
-lopencv_highgui -lopencv_imgcodecs -lopencv_videoio \
-o starter_video
DISPLAY=:0 ./starter_video 0
注:如果您没有带 USB 接口的网络摄像头,可以使用视频文件进行测试:
DISPLAY=:0 ./starter_video ../data/768x576.avi
现在,OpenCV 已经成功安装在您的设备上,您可以运行我们之前开发的 Cartoonizer 应用了。 将Cartoonifier文件夹复制到设备上(例如,使用 USB 闪存盘,或使用scp文件夹通过网络复制文件)。 然后,构建代码,就像您在桌面上所做的那样:
cd ~/Cartoonifier
export OpenCV_DIR="~/opencv-3.1.0/build"
mkdir build
cd build
cmake -D OpenCV_DIR=$OpenCV_DIR ..
make
并运行它:
DISPLAY=:0 ./Cartoonifier
如果一切正常,我们将看到一个窗口,其中显示我们的应用正在运行,如下所示:
使用 Raspberry PI 相机模块
虽然在 Raspberry Pi 上使用 USB 摄像头可以方便地在桌面上支持与嵌入式设备相同的行为和代码,但您可以考虑使用官方的 Raspberry Pi 摄像头模块之一(称为 Raspberry Pi Cams)。 它们与 USB 网络摄像头相比有一些优点和缺点。
Raspberry Pi Cam 采用特殊的 MIPI CSI 摄像头格式,专为智能手机摄像头设计,耗电较少。 与 USB 相比,它们具有更小的物理尺寸、更快的带宽、更高的分辨率、更高的帧速率和更短的延迟。 大多数 USB 2.0 网络摄像头只能提供 640 x 480 或 1280 x 720 30 FPS 的视频,因为对于任何更高的摄像头来说,USB 2.0 都太慢了(除了一些执行板载视频压缩的昂贵 USB 网络摄像头),而 USB 3.0 仍然太贵。 然而,智能手机摄像头(包括 Raspberry Pi Cams)通常可以提供 1920x108030FPS 甚至超高清/4K 分辨率。 事实上,Raspberry Pi Cam v1 即使在 5 美元的 Raspberry Pi Zero 上也可以提供高达 2592 x 1944 15 FPS 或 1920 x 1080 30 FPS 的视频,这要归功于 Raspberry Pi 摄像头使用了 MIPI CSI,以及 Raspberry Pi 内部兼容的视频处理 ISP 和 GPU 硬件。 Raspberry Pi Cam 还支持 90 FPS 模式下的 640 x 480(例如慢动作捕捉),这对于实时计算机视觉非常有用,因此您可以在每帧中看到非常小的运动,而不是更难分析的大运动。
然而,Raspberry Pi Cam 是一块普通电路板,对电气干扰、静电或物理损坏非常敏感*(只需用手指触摸小小的橙色扁平电缆就可能导致视频干扰,甚至永久损坏您的相机!)。 大的白色扁平电缆的敏感度要低得多,但它对电气噪音或物理损坏仍然非常敏感。 树莓圆周率凸轮配备了一个非常短的 15 厘米电缆。 在 eBay 上可以购买长度在 5 厘米到 1 米之间的第三方电缆,但长度在 50 厘米或更长的电缆可靠性较差,而 USB 网络摄像头可以使用 2 米到 5 米的电缆,可以插入 USB 集线器或有源延长电缆进行更长距离的连接。*
*目前有几种不同的 Raspberry Pi Cam 型号,特别是没有内置红外过滤器的黑色版本;因此,黑色相机可以很容易地在黑暗中看到(如果你有一个看不见的红外光源),或者比内置红外过滤器的普通相机更清晰地看到红外激光或信号。 还有两个不同版本的 Raspberry Pi Cam:Raspberry Pi Cam v1.3 和 Raspberry Pi Cam v2.1,其中 v2.1 使用了带有索尼 800 万像素传感器的宽角镜头,而不是 500 万像素的传感器。OmniVision传感器在微光条件下更好地支持动画效果,并添加了对 15FPS 的 3240 x 2464 视频的支持,以及可能高达 120 FPS 的 720p 视频。 然而,USB 网络摄像头有数千种不同的形状和版本,因此很容易找到防水或工业级网络摄像头等特殊网络摄像头,而不需要您为 Raspberry Pi Cam 创建自己的定制外壳。
IP 摄像机也是摄像机接口的另一种选择,它可以通过 Raspberry Pi 支持 1080p 或更高分辨率的视频,IP 摄像机不仅支持超长电缆,甚至可以使用互联网在世界任何地方工作。 但 IP 摄像头与 OpenCV 的接口并不像 USB 网络摄像头或树莓 PI 摄像头那样容易。
过去,Raspberry Pi Cam 和官方驱动程序不能直接与 OpenCV 兼容;为了从 Raspberry Pi Cam 抓取帧,您经常使用自定义驱动程序并修改代码,但现在可以在 OpenCV 中以与 USB 网络摄像头完全相同的方式访问 Raspberry PI Cam! 由于最近对 V4L2 驱动程序的改进,一旦您加载了 V4L2 驱动程序,Raspberry Pi Cam 就会像普通 USB 网络摄像头一样显示为/dev/video0或/dev/video1文件。 因此,传统的 OpenCV 网络摄像头代码(如cv::VideoCapture(0))将能够像使用网络摄像头一样使用它。
安装 Raspberry PI 摄像头模块驱动程序
首先,让我们暂时为 Raspberry Pi Cam 加载 V4L2 驱动程序,以确保我们的摄像头插入正确:
sudo modprobe bcm2835-v4l2
如果命令失败(如果它向控制台打印了一条错误消息,它死机了,或者该命令返回了除0之外的一个数字),则可能是您的相机没有正确插入。 关闭并拔下树莓 PI 的电源插头,然后再次尝试连接白色扁平电缆,查看网络上的照片以确保插头正确。 如果这是正确的方式,很可能在您关闭 Raspberry Pi 上的锁定卡舌之前,电缆没有完全插入。 此外,请使用sudoraspi-config命令检查您之前配置 Raspberry PI 时是否忘记单击启用摄像头设置。
如果该命令有效(如果该命令返回0并且没有错误打印到控制台),那么我们可以通过将其添加到/etc/modules文件的底部来确保 Raspberry PI Cam 的 V4L2 驱动程序始终在引导时加载:
sudo nano /etc/modules
# Load the Raspberry Pi Camera Module v4l2 driver on bootup:
bcm2835-v4l2
保存文件并重新启动 Raspberry PI 后,您应该可以运行ls /dev/video*来查看 Raspberry PI 上可用的摄像头列表。 如果 Raspberry Pi Cam 是唯一插入您的主板的摄像头,您应该将其视为默认摄像头(/dev/video0),或者如果您还插入了 USB 网络摄像头,则它将是/dev/video0或/dev/video1。
让我们使用之前编译的starter_video示例程序来测试 Raspberry Pi Cam:
cd ~/opencv-4.*/samples/cpp
DISPLAY=:0 ./starter_video 0
如果显示错误的相机,请尝试DISPLAY=:0 ./starter_video 1。
现在我们已经知道 Raspberry PI Cam 可以在 OpenCV 中工作,让我们来试试 Cartoonizer:
cd ~/Cartoonifier
DISPLAY=:0 ./Cartoonifier 0
或者,对另一台相机使用DISPLAY=:0 ./Cartoonifier 1键。
让卡通机在全屏运行
在嵌入式操作系统中,您通常希望您的应用是全屏的,并隐藏 Linux GUI 和菜单。 OpenCV 提供了一种设置全屏窗口属性的简单方法,但请确保使用*NORMAL*标志创建窗口:
// Create a fullscreen GUI window for display on the screen.
namedWindow(windowName, WINDOW_NORMAL);
setWindowProperty(windowName, PROP_FULLSCREEN, CV_WINDOW_FULLSCREEN);
隐藏鼠标光标
您可能会注意到,即使您不想在嵌入式系统中使用鼠标,鼠标光标也会显示在窗口顶部。 要隐藏鼠标光标,可以使用 xdotool命令将其移动到右下角像素,这样就不会引起注意,但如果您想偶尔插入鼠标来调试设备,它仍然可用。 安装xdotool并创建一个简短的 Linux 脚本,以便与 Cartoonizer 一起运行:
sudo apt-get install -y xdotool
cd ~/Cartoonifier/build
安装xdotool后,现在是创建脚本的时候了,使用您喜欢的编辑器创建一个新文件,名称为runCartoonifier.sh,内容如下:
#!/bin/sh
# Move the mouse cursor to the screen's bottom-right pixel.
xdotoolmousemove 3000 3000
# Run Cartoonifier with any arguments given.
/home/pi/Cartoonifier/build/Cartoonifier "$@"
最后,使您的脚本可执行:
chmod +x runCartoonifier.sh
尝试运行您的脚本以确保其正常工作:
DISPLAY=:0 ./runCartoonifier.sh
开机后自动运行 Cartoonizer
通常,当您构建全新的嵌入式移动设备时,您希望应用在设备启动后自动执行,而不是要求用户手动运行您的应用。 要在设备完全启动并登录到图形桌面后自动运行我们的应用,请创建一个autostart文件夹,其中包含包含以下内容的文件,包括脚本或应用的完整路径:
mkdir ~/.config/autostart
nano ~/.config/autostart/Cartoonifier.desktop
[Desktop Entry]
Type=Application
Exec=/home/pi/Cartoonifier/build/runCartoonifier.sh
X-GNOME-Autostart-enabled=true
现在,每当您打开或重新启动设备时,Cartoonizer 都会开始运行!
桌面 Cartoonizer 与嵌入式 Cartoonizer 的速度比较
你会注意到,代码在 Raspberry Pi 上的运行速度比在你的桌面上慢得多! 到目前为止,运行速度更快的两种最简单的方法是使用速度更快的设备或使用较小的相机分辨率。 下表显示了桌面上 Raspberry PI 1、Raspberry PI 2、Raspberry PI 3 和 Jetson TK1 模式下的 sketch和Paint模式的一些帧速率,帧/秒和(FPS)。 请注意,速度没有任何自定义优化,仅在单个 CPU 内核上运行,并且计时包括将图像渲染到屏幕上的时间。 使用的 USB 摄像头是运行速度为 640x480 的快速 PS3 Eye 摄像头,因为它是市场上运行速度最快的低成本摄像头。
值得一提的是,Cartoonizer 只使用了单 CPU 内核,但所有列出的设备都有四个 CPU 内核,除了 Raspberry Pi 1,它只有一个内核,而且很多 x86 电脑都有超线程,可以提供大约八个 CPU 内核。 因此,如果您编写代码以高效地利用多个 CPU 内核(或 GPU),速度可能会比单线程图快 1.5 到 3 倍:
| 计算机 | 草图模式 | 涂装模式 |
| 英特尔酷睿 i7 PC | 20 FPS | 2.7 FPS |
| Jetson TK1ARM CPU | 16 FPS | 2.3 FPS |
| 树莓皮 3 | 4.3 FPS | 0.32 FPS(3 秒/帧) |
| 覆盆子 PI 2 | 3.2 FPS | 0.28 FPS(4 秒/帧) |
| 覆盆子皮零 | 2.5 FPS | 0.21 FPS(5 秒/帧) |
| 覆盆子 PI 1 | 1.9 FPS | 0.12 FPS(8 秒/帧) |
请注意,Raspberry Pi 在运行代码时速度极慢,尤其是Paint模式,因此我们将尝试简单地更改摄像头和摄像头的分辨率。
更改相机和相机分辨率
下表显示了在 Raspberry PI 2 上使用不同类型的摄像头和不同的摄像头分辨率时,草图模式的速度比较结果:
| 硬件 | 640 x 480 分辨率 | 320 x 240 分辨率 |
| 覆盆子 PI 2 配覆盆子 PI 凸轮 | 3.8 FPS | 12.9 FPS |
| 覆盆子 PI 2,带 PS3 Eye 网络摄像头 | 3.2 FPS | 11.7 FPS |
| 带有无品牌网络摄像头的覆盆子 PI 2 | 1.8 FPS | 7.4 FPS |
正如你所看到的,当使用 320 x 240 的 Raspberry Pi Cam 时,我们似乎有一个足够好的解决方案来享受一些乐趣,即使它不在我们希望的 20-30 FPS 范围内。
台式卡通机与嵌入式系统的功耗比较
我们已经看到各种嵌入式设备都比台式机慢,从 Raspberry PI 1 大约比台式机慢 20 倍,到 Jetson TK1 大约比台式机慢 1.5 倍。 但对于一些任务来说,低速是可以接受的,如果这意味着电池消耗也会大幅降低,允许服务器使用较小的电池或较低的全年电力成本,或者产生较低的热量。
即使是同一款处理器,Raspberry Pi 也有不同的型号,比如 Raspberry Pi 1B、Zero 和 1A+,它们的运行速度都很相似,但功耗却有很大不同。 像 Raspberry Pi Cam 这样的 MIPI CSI 摄像头也比网络摄像头耗电更少。 下表显示了运行同一个 Cartoonizer 代码的不同硬件使用了多少电能。 如下图所示,使用简单的通用串行总线电流监视器(例如,J7-T 安全测试仪(http://bit.ly/2aSZa6H))和数字万用表对其他设备进行功率测量,如下图所示:
空闲功率测量计算机运行但未使用主要应用时的功率,而Cartoonizer 功率测量 Cartoonizer 运行时的功率。效率是 640 x 480草图模式下 Cartoonizer 的功率/卡通速度:
| 硬件 | 空闲电源 | 卡通机功率 | 效率 |
| 带 PS3 眼睛的覆盆子皮零 | 1.2 瓦 1.2 瓦 | 1.8 瓦特:1.8 瓦特 | 1.4 帧/瓦 |
| 覆盆子 PI 1A+,带 PS3 眼 | ►T0χ1.1W 元 T1* | ►T0χ1.5W 元 T1* | 1.1 帧/瓦 |
| 带 PS3 眼的覆盆子 PI 1B | 2.4 瓦 2.4 瓦 | 3.2 瓦 3.2 瓦 | 0.5 帧/瓦 |
| PS3 眼覆盆子 PI 2B | 1.8 瓦特:1.8 瓦特 | 2.2 瓦 2.2 瓦 | 1.4 帧/瓦 |
| 带 PS3 眼的覆盆子 PI 3B | 2.0 瓦 | 2.5 瓦特:2.5 瓦特 | 1.7 帧/瓦 |
| 带 PS3 眼睛的 Jetson TK1 | 2.8 瓦特及其他 | 4.3 瓦特及其他 | 3.7 帧/瓦 |
| 酷睿 i7 笔记本电脑,带 PS3 眼睛 | 14.0 瓦 | 39.0 瓦:39.0 瓦 | 0.5 帧/瓦 |
我们可以看到,Raspberry Pi 1A+耗电量最少,但最省电的选项是 Jetson TK1 和 Raspberry Pi 3B。 有趣的是,最初的 Raspberry Pi(Raspberry Pi 1B)与 x86 笔记本电脑的效率大致相同。 所有后来的覆盆子 PI 都比最初的(覆盆子 PI 1B)能效高得多。
Disclaimer: The author is a former employee of NVIDIA, which produced the Jetson TK1, but the results and conclusions are believed to be authentic.
让我们来看看与树莓 PI 配合使用的不同摄像头的功耗:
| 硬件 | 空闲电源 | 卡通机功率 | 效率 |
| 带 PS3 眼睛的覆盆子皮零 | 1.2 瓦 1.2 瓦 | 1.8 瓦特:1.8 瓦特 | 1.4 帧/瓦 |
| 覆盆子 PI 零配覆盆子 PI Cam v1.3 | .6 瓦特.6 瓦特 | 1.5 瓦 1.5 瓦 | 2.1 帧/瓦 |
| 覆盆子 PI 零配覆盆子 PI Cam v2.1 | 同步,由 Elderman 更正@ELDER_MAN | ►T0χ1.3W 元 T1* | 2.4 帧/瓦 |
我们看到,Raspberry Pi Cam v2.1 比 Raspberry Pi Cam v1.3 的能效略高,比 USB 网络摄像头的能效要高得多。
将视频从 Raspberry Pi 流式传输到功能强大的计算机
多亏了包括 Raspberry Pi 在内的所有现代 ARM 设备中的硬件加速视频编码器,在嵌入式设备上执行计算机视觉的有效替代方案是使用该设备只捕获视频,并通过网络将其实时流式传输到 PC 或服务器机架。 所有 Raspberry PI 型号都包含相同的视频编码器硬件,因此对于低成本、低功耗的便携式视频流服务器来说,带 PI 摄像头的 Raspberry Pi 1A+或 Raspberry Pi Zero 是一个相当不错的选择。 覆盆子 PI 3 增加了 Wi-Fi,增加了便携功能。
可以通过多种方式从 Raspberry Pi 流式传输实时摄像头视频,例如使用 Raspberry Pi V4L2 官方摄像头驱动程序使 Raspberry Pi Cam 看起来像网络摄像头,然后使用 GStreamer、liveMedia、Netcat 或 VLC 在网络上流式传输视频。 然而,这些方法通常会引入一到两秒的延迟,并且通常需要定制 OpenCV 客户端代码或学习如何有效地使用 GStreamer。 因此,以下部分将介绍如何使用名为UV4L的备用摄像头驱动程序执行摄像头捕获和网络流:
- 按照http://www.linux-projects.org/uv4l/installation/的说明将 UV4L 安装在树莓 PI 上:
curl http://www.linux-projects.org/listing/uv4l_repo/lrkey.asc
sudo apt-key add -
sudo su
echo "# UV4L camera streaming repo:">> /etc/apt/sources.list
echo "deb http://www.linux-
projects.org/listing/uv4l_repo/raspbian/jessie main">>
/etc/apt/sources.list
exit
sudo apt-get update
sudo apt-get install uv4l uv4l-raspicam uv4l-server
- 手动运行 UV4L 流媒体服务器(在 Raspberry PI 上)以检查其是否正常工作:
sudo killall uv4l
sudo LD_PRELOAD=/usr/lib/uv4l/uv4lext/armv6l/libuv4lext.so
uv4l -v7 -f --sched-rr --mem-lock --auto-video_nr
--driverraspicam --encoding mjpeg
--width 640 --height 480 --framerate15
- 测试摄像头的网络以从您的桌面传输视频,请按照以下步骤检查一切是否正常工作:
- 安装 VLC 媒体播放器。
- 导航到 Media(媒体)|Open Network Stream(打开网络流媒体),然后输入
http://192.168.2.111:8080/stream/video.mjpeg。 - 将 URL 调整为 Raspberry PI 的 IP 地址。 在 Raspberry Pi 上运行
hostname -I命令以找到其 IP 地址。
- 在启动时自动运行 UV4L 服务器:
sudo apt-get install uv4l-raspicam-extras
- 编辑您在
uv4l-raspicam.conf中需要的任何 UV4L 服务器设置,例如分辨率和帧速率,以自定义流媒体:
sudo nano /etc/uv4l/uv4l-raspicam.conf
drop-bad-frames = yes
nopreview = yes
width = 640
height = 480
framerate = 24
您需要重新启动才能使所有更改生效。
- 告诉 OpenCV 像使用网络摄像头一样使用我们的网络流。 只要您安装的 OpenCV 可以在内部使用 FFMPEG,OpenCV 就可以像网络摄像头一样从 MJPEG 网络流中抓取帧:
./Cartoonifier http://192.168.2.101:8080/stream/video.mjpeg
您的 Raspberry PI 现在正在使用 UV4L 将实时的 640 x 480 24 FPS 视频流传输到在草图模式下运行 Cartoonizer 的 PC,实现了大约 19 FPS(具有 0.4 秒的延迟)。 请注意,这几乎与在 PC 上直接使用 PS3 Eye 网络摄像头(20FPS)的速度相同!
请注意,当您将视频流式传输到 OpenCV 时,它将无法设置相机分辨率;您需要调整 UV4L 服务器设置以更改相机分辨率。 还要注意的是,我们可以流式传输 H.264 视频,而不是流式传输 MJPEG,这使用了较低的带宽,但是一些计算机视觉算法不能很好地处理视频压缩,例如 H.264,所以 MJPEG 引起的算法问题比 H.264 要少。
If you have both the official Raspberry Pi V4L2 driver and the UV4L driver installed, they will both be available as cameras 0 and 1 (devices /dev/video0 and /dev/video1), but you can only use one camera driver at a time.
定制您的嵌入式系统!
现在你已经创建了一个完整的嵌入式 Cartoonizer 系统,你知道它是如何工作的,哪些部件做什么,你应该定制它! 使视频全屏,更改 GUI,更改应用行为和工作流程,更改 Cartoonizer 过滤器常量或皮肤检测器算法,用您自己的项目想法替换 Cartoonizer 代码,或者将视频流式传输到云中并在那里进行处理!
您可以从很多方面改进皮肤检测算法,例如使用更复杂的皮肤检测算法(例如,使用最近在http://www.cvpapers.com上的许多 CVPR 或 ICCV 会议论文中训练好的高斯模型),或者将人脸检测(参见第 17 章,人脸检测和识别模块的第人脸检测章节)添加到皮肤检测器中,从而检测用户的人脸在哪里。 而不是要求用户将他们的脸放在屏幕中央。 请注意,在某些设备或高分辨率摄像头上,人脸检测可能需要数秒时间,因此它们当前的实时使用可能会受到限制。 但嵌入式系统平台每年都在变得更快,所以随着时间的推移,这可能不是什么问题。
提高嵌入式计算机视觉应用速度的最重要方法是尽可能地降低摄像头分辨率(例如,将摄像头分辨率从 500 万像素降至 50 万像素),尽可能少地分配和释放图像,以及尽可能少地执行图像格式转换。 在某些情况下,可能有一些优化的图像处理或数学库,或者您设备的 CPU 供应商(例如 Broadcom、NVIDIA Tegra、Texas Instruments OMAP 或 Samsung Exynos)或您的 CPU 系列(例如 ARM Cortex-A9)提供的 OpenCV 优化版本。
简略的 / 概括的 / 简易判罪的 / 简易的
本章介绍了几种不同类型的图像处理滤镜,这些滤镜可用于生成各种卡通效果,从看起来像铅笔画的素描模式、看起来像彩画的画图模式,到将草图模式覆盖在画图模式之上使其看起来像卡通的卡通模式。 它还表明,还可以获得其他有趣的效果,比如邪恶模式,它大大增强了嘈杂的边缘,以及外星人模式,它改变了脸部的皮肤,使其看起来像明亮的绿色。
有许多商业智能手机应用可以在用户的脸上添加类似的有趣效果,比如卡通滤镜和肤色变化。 也有使用类似概念的专业工具,比如皮肤平滑视频后处理工具,试图通过平滑女性的皮肤,同时保持边缘和非皮肤区域的锐利来美化她们的脸,以使她们的脸看起来更年轻。
本章介绍如何将应用从台式机移植到嵌入式系统,方法是遵循建议的指导原则,即首先开发工作台式机版本,然后将其移植到嵌入式系统,并创建适合嵌入式应用的用户界面。 图像处理代码在这两个项目之间共享,以便读者可以修改桌面应用的卡通滤镜,也可以很容易地在嵌入式系统中看到这些修改。
请记住,本书包括 Linux 的 OpenCV 安装脚本和讨论的所有项目的完整源代码。
在下一章中,我们将学习如何使用运动(SFM)中的多视图立体(MVS)和结构进行 3D 重建,以及如何以 OpenMVG 格式导出最终结果。*
十四、使用 SfM 模块从运动中探索结构
运动的结构(SfM)是恢复摄像机注视场景的位置和场景的稀疏几何体的过程。 相机之间的运动施加了几何约束,可以帮助我们恢复物体的结构,因此这个过程被称为 Sfm。 从 OpenCV v3.0+开始,添加了一个名为sfm的贡献("contrib")模块,它帮助从多个图像执行端到端的 SfM 处理。 在本章中,我们将学习如何使用 SfM 模块将场景重建为稀疏点云,包括相机姿势。 稍后,我们还将加密点云,通过使用名为 OpenMVS 的开放多视图立体(MVS)软件包向其添加更多点以使其密集。 SFM 被用于高质量的三维扫描,自主导航的视觉里程计,航空摄影测绘,以及更多的应用,使其成为计算机视觉中最基本的追求之一。 计算机视觉工程师应该熟悉 SfM 的核心概念,计算机视觉课程经常讲授这一主题。
本章将介绍以下主题:
- SfM 的核心概念:多视图几何(MVG)、三维重建和多视图立体(MVS)
- 使用 OpenCV SfM 模块实施 SfM 管道
- 可视化重建结果
- 将重建导出到 OpenMVG,并将稀疏云加密为完全重建
技术要求
构建和运行本章中的代码需要以下技术和安装:
- OpenCV 4(使用
sfm contrib模块编译) - Eigen v3.3+(
sfm模块要求) - CERES 求解器 v2+1(
sfm模块要求) - CMake 3.12+
- Boost v1.66+
- OpenMVS
- CGAL v4.12+(OpenMVS 要求)
所列组件的构建说明以及实现本章中概念的代码将在附带的代码存储库中提供。 使用 OpenMVS 是可选的,我们可以在得到稀疏重建后停止。 然而,完整的 MVS 重建更令人印象深刻,也更有用;例如,对于 3D 打印复制品。
具有足够重叠的任何一组照片对于 3D 重建可能是足够的。 例如,我们可以使用我在南达科他州拍摄的一组疯马纪念头的照片,它与这个章节代码捆绑在一起。 要求拍摄的图像之间应该有足够的移动,但足够有明显的重叠,以便进行强有力的配对匹配。
在下面的例子中,从疯马纪念数据集,我们可以注意到图像之间的视角有轻微的变化,重叠非常强烈。 请注意,我们还可以在人们行走的雕像下方看到巨大的变化;这不会干扰石面的 3D 重建:
Sfm 的核心概念
在我们深入研究 SfM 管道的实现之前,让我们回顾一下作为该过程的重要部分的一些关键概念。 SfM 中最重要的一类理论主题是核极几何(EG),即多视图几何或 MVG,它建立在图像形成和相机校准知识的基础上;然而,我们将只略过这些基本主题。在我们介绍了 EG 的几个基础知识之后,我们将很快讨论立体重建,并回顾诸如从视差获得深度的主题。 SfM 中的其他关键主题,如健壮特征匹配,更多的是机械性的,而不是理论上的,我们将在对系统进行编码的过程中介绍这些主题。 我们有意省略了一些非常有趣的主题,例如相机分割、PNP 算法和重构因子分解,因为这些都是由底层的sfm模块处理的,我们不需要调用它们,尽管 OpenCV 中确实存在执行它们的函数。
在过去的四十年里,所有这些主题都是大量研究和文献的来源,并成为数以千计的学术论文、专利和其他出版物的主题。Hartley 和 Zisserman 的多视图几何是迄今为止 SfM 和 MVG 数学和算法的最重要的资源,尽管令人难以置信的次要资产是 Szeliski 的计算机视觉:算法和应用,它非常详细地解释了 SfM,重点是 Richard Szelisiski。 对于第三个解释来源,我建议你买一本普林斯的计算机视觉:模型、学习和推理,这本书的特点是漂亮的图形、图表和细致的数学推导。
校准相机和核线几何
我们的图像从投影开始。 他们通过镜头看到的 3D 世界在相机内部的 2D 传感器上被展平,基本上失去了所有的深度信息。 那么我们如何才能从 2D 图像回到 3D 结构呢? 在许多情况下,标准强度相机的答案是 MVG。 直观地说,如果我们至少可以从两个视图看到一个物体(2D),我们就可以估计它与摄像机的距离。 作为人类,我们经常这样做,用我们的两只眼睛。 我们人类的深度感知来自多个(两个)视角,但不仅仅是这样。 事实上,人类的视觉感知,因为它与感知深度和 3D 结构有关,是非常复杂的,与眼睛的肌肉和传感器有关,而不仅仅是我们视网膜上的图像及其在大脑中的处理。 人类的视觉及其神奇特征远远超出了本章的范围;然而,在不止一个方面,SFM(以及所有的计算机视觉!)。 灵感来自人类的视觉。
回到我们的摄像机。 在标准的 SfM 中,我们使用针孔相机模型,它简化了真实相机中进行的整个光学、机械、电气和软件过程。 针孔模型描述了现实世界中的对象如何变成像素,并涉及一些我们称为内部参数的参数,因为它们描述了相机的内部功能:
使用针孔模型,我们通过应用投影来确定 3D 点在图像平面上的 2D 位置。 请注意 3D 点https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/498936b9-e78b-4b5c-95f8-bc1da85a5ff9.png和相机原点如何形成直角三角形,其中相邻边等于https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0572cfb2-437a-495e-9693-40ac997748a8.png。 图像点https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/1c5e7edf-0594-4136-b0ee-501f4b63ef79.png与相邻的点https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/cbbf9c79-d306-4641-af63-6bb635458820.png共享相同的角度,即从原点到图像平面的距离。 这个距离被称为焦距,但这个名称可能具有欺骗性,因为图像平面实际上并不是焦平面;为了简单起见,我们将这两个名称融合在一起。 重叠直角三角形的初等几何将告诉我们https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/d9b518ff-8906-4e09-a419-1211bdaa3127.png;然而,由于我们处理图像,我们必须考虑原点和https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/450a7c7b-6097-4e87-a1bc-36517a8ff506.png,并得出https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/687a0d5f-0990-45c5-899d-fdf787e07fa0.png。 如果我们对https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0adc346a-eb35-4b08-bf9c-b5a243cf244f.png轴执行同样的操作,则如下所示:
3x3 矩阵称为本征参数矩阵,通常表示为https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/79546030-18cb-4b6f-a00f-f31ce372081c.png;然而,这个方程式有一些地方似乎不太对劲,需要解释。 首先,我们错过了分区https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/9149b434-ea6c-4310-81cf-8630dfa0c7d8.png,它去了哪里? 第二,方程式的 LHS 上出现的那个神秘的公式https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/86b3b8a7-30ed-4984-a66e-a5528946bc87.png是什么? 答案是齐次坐标,这意味着我们在向量的末尾加上一个https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/990a120c-05fb-448c-b5be-a67382f8355d.png符号。这个有用的符号允许我们线性化这些运算,并在以后执行除法。 在矩阵乘法步骤的末尾,我们可能会一次对数千个点进行乘法,我们将结果除以向量中的最后一项,这恰好就是我们要寻找的结果https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/f6cf70b5-fa60-4b7c-aac9-c673d6542d05.png。 至于https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/24286605-7bb6-4bfc-beab-7a409ffc9b91.png,这是一个我们必须牢记的未知的任意尺度因素,它来自我们在预测中的一个视角。 想象一下,我们有一辆玩具车离摄像机非常近,旁边是一辆真实大小的车,距离摄像机 10 米远;在图像中,它们看起来大小相同。 换句话说,我们可以将 3D 点https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/e240393d-1fc2-4f3b-bb0a-d316a6974fbc.png沿着相机发出的光线移动到任何地方,但仍然可以在图像中获得相同的https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/1486b837-5e97-4360-a0ee-8931eb6066eb.png坐标。 这就是透视投影的魔咒:我们失去了我们在本章开头提到的深度信息。
还有一件事我们必须考虑,那就是我们的相机在世界上的姿势。 并不是所有的摄像头都放置在原点https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0c64c536-f0c1-4fc9-b8fd-e2c6735c4b99.png,特别是如果我们有一个有很多摄像头的系统。 我们可以方便地将一个摄影机放置在原点,但其余摄影机将具有相对于其自身的旋转和平移(刚性变换)组件。 因此,我们将另一个矩阵添加到投影方程式中:
新的 3x4 矩阵通常称为外部参数矩阵,包含 3x3 旋转和 3x1 平移分量。 请注意,我们使用了相同的齐次坐标技巧,通过在https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/226f6bf5-865b-44d5-a575-52fbcdd8fcdd.png末尾添加 1 来帮助将平移合并到计算中。 我们经常会在文献中看到写成https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/f2f70666-b3ac-48d9-a588-9e151d48264d.png的整个方程式:
假设两个相机看着相同的对象点。 正如我们刚才所讨论的,我们可以将 3D 点的真实位置沿着相机的轴向滑动,但仍然观察到相同的 2D 点,从而丢失深度信息。直观地说,两个视角应该足以找到真实的 3D 位置,因为来自两个视点的光线会聚在这里。但实际上,当我们在光线上滑动该点时,在另一个从不同角度看的相机中,这个位置会改变。 事实上,相机L 和(左)中的任何点都将对应于相机R(右)中的线,称为核极线****e(有时称为尾线),该线位于由两个相机的光学中心和 3D 点构成的核极面上。 这可以用作两个视图之间的几何约束,帮助我们找到关系。
我们已经知道,在两个摄像头之间,有一个刚性的变换https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/4f2768da-fb04-466c-ac0d-e036e68c12c4.png。 如果我们想在摄像机L的坐标系中表示摄像机R中的一个点–https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/e98a79a1-2046-4605-b91b-b2c784ec1918.png,我们可以这样写:https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/fbd558b4-04cb-4454-8c23-237c62395335.png。 如果我们取叉积https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/e105ca00-304f-4b38-af40-8eff2849dca1.png,我们将得到一个垂直于极平面的矢量*。 因此,推论https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/c1738ddc-d810-4ef0-86a5-2f334415acbc.png,因为https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/aebede97-b48d-4ffb-a7f5-3e200c63c3ae.png在的极面上是,所以一个点积将产生 0。 我们取叉积的斜对称形式,我们可以写成https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/76e93164-1d72-461d-b9d9-9fdaf452341d.png,然后把它组合成一个矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/1e874185-64d9-490f-855f-365a19afd47c.png。 我们称https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/964ea557-0160-47ce-91ec-abfd72ea06c5.png为本质矩阵**。 本质矩阵给出了一个核线约束,约束范围是摄像机 L 和摄像机 R 之间所有会聚于真实 3D 点的点对。 如果一对点(来自L和R)不能满足此约束,则它很可能不是有效的配对。 我们也可以使用一些点对来估计本质矩阵,因为它们简单地构造了齐次线性方程组。 用特征值或奇异值分解(奇异值分解)可以很容易地得到解。*
到目前为止,在我们的几何学中,我们假设我们的相机是规格化的,本质上是指单位矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/45e7cf80-35ac-4f80-9ba3-166c3869e43b.png。 然而,在具有特定像素大小和焦距的真实图像中,我们必须考虑真实的固有特性。 为此,我们可以在两侧应用https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/c7b59eb9-58a4-439f-b9ff-cd68ff546c5b.png的逆:https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/1fd3890b-2230-42b1-b339-b3ce1b8d7f7a.png。 我们最终得到的这个新矩阵称为基本矩阵,它可以从足够多的像素坐标点对中估计出来。 如果我们知道https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/e3d9b59b-1dcc-4077-b22b-1de97b31f42b.png,我们就可以得到本质矩阵;然而,基础矩阵本身可以作为一个很好的核线约束。
立体重建与 SfM
在 SFM 中,我们希望同时恢复摄像机的姿势和 3D 特征点的位置。 我们刚刚看到了简单的 2D 点对匹配如何帮助我们估计本质矩阵,从而编码视图之间的严格几何关系:https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/c5c3a167-9300-4cd7-af31-e8b6ac905b0c.png。 本质矩阵可以通过奇异值分解(SVD)的方式分解为https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/5dc78f78-fe50-41aa-9c24-6da76f0f79e8.png和https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/032b6d43-cf18-4ed0-83fd-11f043be1820.png,在找到https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/227449b2-a921-45bb-9882-a691972a467b.png和https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/4662b537-3103-4520-ad41-d25b3fe9fa6b.png之后,我们继续寻找 3D 点,并完成这两幅图像的 SfM 任务。
我们已经看到了两个 2D 视图和 3D 世界之间的几何关系;但是,我们还没有看到如何从 2D 视图恢复 3D 形状。 我们的一个见解是,给出同一点的两个视图,我们可以从相机的光学中心和图像平面上的 2D 点穿过这两条光线,它们将会聚在 3D 点上。 这是三角剖分的基本思想。 求解 3D 点的一种简单方法是写出投影方程并将其等值,因为 3D 点(https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/f683bdc9-d3fc-4513-bb65-9809b7c37c9b.png)是常见的https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/a61df32d-c3a9-4226-a4eb-3e8742dc4b21.png,其中第https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/4c12cd51-4ead-40b2-babd-253e63301184.png个矩阵是第https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/af901573-a745-43e1-ae05-12957ddc804b.png个投影矩阵。 这些方程可以化为齐次线性方程组,并且可以例如用奇异值分解(SVD)来求解。 这被称为三角剖分的直接线性方法;然而,它是严重次优的,因为它没有直接最小化有意义的误差函数。 还提出了其他几种方法,包括查看光线之间的最近点(通常不直接相交),称为中点法。
从两个视图获得基线 3D 重建后,我们可以继续添加更多视图。 这通常以不同的方法完成,在现有的 3D 点和传入的 2D 点之间使用匹配。 这类算法称为点-n-透视(PnP),我们不在这里讨论。 另一种方法是执行成对立体重建(我们已经看到),并计算比例因子,因为如前所述,重建的每个图像对可能会产生不同的比例。
恢复深度信息的另一个有趣的方法是进一步利用核线。 我们知道,图像L中的一个点将位于图像R中的一条直线上,我们也可以使用https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/05e112b2-8ef5-4456-885f-4dd9c82646de.png精确地计算这条直线。 因此,任务是在图像R的核线上找到与图像 L 中的点最匹配的点。这种线匹配方法可称为立体深度重建,由于我们可以恢复图像中几乎每个像素的深度信息,因此它是密集重建的大多数倍。 实际上,核线首先校正为完全水平,模仿图像之间的纯水平平移。 这减少了仅在x轴上匹配的问题:
水平平移的主要吸引力是视差、和,它描述了兴趣点在两幅图像之间水平移动的距离。 在上图中,我们可以注意到,由于右重叠三角形:https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/0116281d-2788-4c6c-b8e7-7326ab9eff9b.png,这导致了https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/ab1d422d-ace3-4456-905c-c112dafa0ea0.png。 基线https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/d9ae5b13-4201-475a-9ae7-3cd0c474b558.png(水平运动)和焦距https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/19622f3a-e1d4-402c-a9da-9682df2dc713.png相对于特定的 3D 点及其与相机的距离是恒定的。 因此,我们的洞察力是,差异与深度成反比。 视差越小,点离相机越远。 当我们从移动的火车车窗看地平线时,远处的山脉移动得很慢,而近处的树木移动得很快。 这种效果也称为视差。 利用视差进行三维重建是所有立体算法的基础。
另一个被广泛研究的主题是 MVS,它利用核线约束一次从多个视图中寻找匹配点。 同时扫描多个图像中的尾部可以对匹配特征施加进一步的约束。 只有当找到满足所有约束的匹配项时,才会考虑它。 当我们恢复多个相机位置时,我们可以使用 MVS 进行密集重建,这也是我们在本章后面要做的。
在 OpenCV 中实现 SfM
OpenCV 拥有丰富的工具,可以从 First Principle 实现成熟的 SfM 管道。 然而,这样的任务要求非常高,超出了本章的范围。 这本书的前一版只是略微介绍了构建这样一个系统需要做些什么,但幸运的是,现在我们已经掌握了一种经过验证和测试的技术,它直接集成到 OpenCV 的 API 中。尽管sfm模块允许我们通过简单地提供一个带有图像列表的非参数函数来处理和接收具有稀疏点云和相机姿势的完全重建的场景,但是我们不会走这条路。 相反,我们将在这一节中看到一些有用的方法,这些方法将使我们能够更好地控制重建,并举例说明我们在上一节中讨论的一些主题,以及更强的抗噪能力。
本节将从 sfm:使用关键点和功能描述符匹配图像的非常基础开始。 然后,我们将使用匹配图通过图像集查找个轨迹以及相似特征的多个视图。 我们继续进行3D 图像重建,3D 可视化,最后使用 OpenMVS 进行 MVS。
图像特征匹配
如上一节所述,SFM 依赖于理解图像之间的几何关系,因为它与图像中的可见对象相关。 我们看到,我们可以计算出两幅图像之间的精确运动,并且有足够的关于图像中对象如何运动的信息。 可以从图像特征线性估计的基本矩阵或基本矩阵可以分解成定义3D 刚性变换的旋转和平移元素。 此后,这种变换可以帮助我们从 3D-2D 投影方程或根据校正后的尾部上的密集立体匹配来三角测量对象的 3D 位置。 这一切都是从图像特征匹配开始的,因此我们将看到如何获得健壮且无噪声的匹配。
OpenCV 提供了大量的 2D 特征检测器(也称为提取器)和描述符。 特征被设计为与图像变形不变,因此它们可以通过场景中对象的平移、旋转、缩放和其他更复杂的变换(仿射、投影)来匹配。 OpenCV API 的最新功能之一是AKAZE特征提取器和检测器,它在计算速度和对变换的稳健性之间提供了非常好的折衷。 结果显示,AKAZE的表现优于其他突出特征,例如ORB(Oriented Brief的缩写)和SURF(加速健壮特征的缩写)。
以下代码片断将提取一个AKAZE关键点,为我们在imagesFilenames中收集的每个图像计算AKAZE个特征,并将它们分别保存在keypoints和descriptors数组中:
auto detector = AKAZE::create();
auto extractor = AKAZE::create();
for (const auto& i : imagesFilenames) {
Mat grayscale;
cvtColor(images[i], grayscale, COLOR_BGR2GRAY);
detector->detect(grayscale, keypoints[i]);
extractor->compute(grayscale, keypoints[i], descriptors[i]);
CV_LOG_INFO(TAG, "Found " + to_string(keypoints[i].size()) + "
keypoints in " + i);
}
注意,我们还将图像转换为灰度;但是,这一步可能会被省略,结果不会受到影响。
这是在两张相邻图像中检测到的特征的可视化。 注意其中有多少是重复的;这称为 FeatureRepeatability,这是一个好的特征提取器最需要的功能之一:
下一步是匹配每对图像之间的特征。 OpenCV 提供了一个出色的功能匹配套件。 AKAZE特征描述符是二进制,这意味着它们在匹配时不能被视为二进制编码数;它们必须在位级别上与逐位运算符进行比较。 OpenCV 为二进制特征匹配器提供了汉明距离度量,该度量实质上计算两位序列之间不正确匹配的数量:
vector<DMatch> matchWithRatioTest(const DescriptorMatcher& matcher,
const Mat& desc1,
const Mat& desc2)
{
// Raw match
vector< vector<DMatch> > nnMatch;
matcher.knnMatch(desc1, desc2, nnMatch, 2);
// Ratio test filter
vector<DMatch> ratioMatched;
for (size_t i = 0; i < nnMatch.size(); i++) {
const DMatch first = nnMatch[i][0];
const float dist1 = nnMatch[i][0].distance;
const float dist2 = nnMatch[i][1].distance;
if (dist1 < MATCH_RATIO_THRESHOLD * dist2) {
ratioMatched.push_back(first);
}
}
return ratioMatched;
}
前面的函数不仅定期调用我们的匹配器(例如,aBFMatcher(NORM_HAMMING)),它还执行比率测试。 这一简单的测试在许多依赖于特征匹配的计算机视觉算法(如 SfM、全景拼接、稀疏跟踪等)中是一个非常基本的概念。 我们不再为图像B中的图像A中的特征寻找单个匹配项,而是在图像B中查找两个匹配项,并确保没有混淆。 如果两个潜在的匹配特征描述符太相似(就它们的距离度量而言),并且我们不能区分它们中的哪一个是查询的正确匹配,则可能会在匹配中出现混淆,因此我们将它们都丢弃以防止混淆。
接下来,我们实现一个互易过滤器。 此过滤器仅允许在A至B、以及B至A中匹配(使用比率测试)的功能。 本质上,这是确保图像A和图像*B:*中的特征之间存在对称匹配的一对一匹配。 互易过滤器消除了更多的歧义,有助于实现更清晰、更稳健的匹配:
// Match with ratio test filter
vector<DMatch> match = matchWithRatioTest(matcher, descriptors[imgi], descriptors[imgj]);
// Reciprocity test filter
vector<DMatch> matchRcp = matchWithRatioTest(matcher, descriptors[imgj], descriptors[imgi]);
vector<DMatch> merged;
for (const DMatch& dmrecip : matchRcp) {
bool found = false;
for (const DMatch& dm : match) {
// Only accept match if 1 matches 2 AND 2 matches 1.
if (dmrecip.queryIdx == dm.trainIdx and dmrecip.trainIdx ==
dm.queryIdx) {
merged.push_back(dm);
found = true;
break;
}
}
if (found) {
continue;
}
}
最后,我们应用核线约束。 每两个图像之间有一个有效的刚性变换,它们将遵守对其特征点的核线约束:https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/772d660f-4e47-4485-a9d9-adfd5652197a.png,而那些没有通过此测试(获得足够成功)的图像很可能不是很好的匹配,并且可能会导致噪声。 我们通过使用投票算法(RANSAC)计算基本矩阵并检查内异值之比来实现这一点。 我们应用一个阈值来丢弃与原始匹配相比存活率较低的匹配:
// Fundamental matrix filter
vector<uint8_t> inliersMask(merged.size());
vector<Point2f> imgiPoints, imgjPoints;
for (const DMatch& m : merged) {
imgiPoints.push_back(keypoints[imgi][m.queryIdx].pt);
imgjPoints.push_back(keypoints[imgj][m.trainIdx].pt);
}
findFundamentalMat(imgiPoints, imgjPoints, inliersMask);
vector<DMatch> final;
for (size_t m = 0; m < merged.size(); m++) {
if (inliersMask[m]) {
final.push_back(merged[m]);
}
}
if ((float)final.size() / (float)match.size() < PAIR_MATCH_SURVIVAL_RATE) {
CV_LOG_INFO(TAG, "Final match '" + imgi + "'->'" + imgj + "' has less than "+to_string(PAIR_MATCH_SURVIVAL_RATE)+" inliers from orignal. Skip");
continue;
}
我们可以在下图中看到每个过滤步骤(原始匹配、比率、互易性和核线)的效果:
查找要素轨迹
特征轨迹的概念早在 1992 年 Tomasi 和 Kanade 的工作(Shape and Motion from Image Streams,1992)中就被引入到 SFM 文献中,并在 Snaful 和 Szeliski 于 2007 年因大规模无约束重建而在其开创性的摄影旅游工作中声名鹊起。 轨迹只是单个场景要素(一个有趣的点)在多个视图上的 2D 位置。 轨迹很重要,因为它们保持了帧之间的一致性,而不是像 Snaful 建议的那样,可以组合成全局优化问题。 轨迹对我们特别重要,因为 OpenCV 的sfm模块允许通过仅提供所有视图上的 2D 轨迹来重建场景:
在所有视图之间已经找到成对匹配之后,我们就有了在这些匹配特征中查找轨迹所需的信息。 如果我们沿着第一张图中的特征i通过匹配到第二张图,那么从第二张图到第三张图通过他们自己的匹配,以此类推,我们可能最终会得到它的轨迹。 这种记账方式很容易变得太难使用标准数据结构以简单的方式实现。 但是,如果我们表示匹配图中的所有匹配,就可以简单地完成。 图中的每个节点都是在一张图像中检测到的特征,边是我们恢复的匹配。 从第一个图像的特征节点到第二个图像、第三个图像、第四个图像的特征节点有许多边,依此类推(对于未被过滤器丢弃的匹配项)。 因为我们的匹配是相互的(对称的),所以图可以是无向的。 此外,互易性测试确保对于第一图像中的特征i,在第二图像*、中只有一个匹配特征j*,反之亦然:特征j将仅与特征i相匹配。
以下是这样的匹配图的可视示例。 节点颜色表示特征点(节点)的来源图像。 边缘表示图像特征之间的匹配。 我们可以注意到从第一张图像到最后一张图像的特征匹配链的非常强的模式:
要编码匹配图,我们可以使用Boost Graph Library(bgl),它具有广泛的图形处理和算法 API。 构建图形很简单;我们只需使用图像 ID 和 Feature ID 来增加节点,这样稍后我们就可以追溯原点:
using namespace boost;
struct ImageFeature {
string image;
size_t featureID;
};
typedef adjacency_list < listS, vecS, undirectedS, ImageFeature > Graph;
typedef graph_traits < Graph >::vertex_descriptor Vertex;
map<pair<string, int>, Vertex> vertexByImageFeature;
Graph g;
// Add vertices - image features
for (const auto& imgi : keypoints) {
for (size_t i = 0; i < imgi.second.size(); i++) {
Vertex v = add_vertex(g);
g[v].image = imgi.first;
g[v].featureID = i;
vertexByImageFeature[make_pair(imgi.first, i)] = v;
}
}
// Add edges - feature matches
for (const auto& match : matches) {
for (const DMatch& dm : match.second) {
Vertex& vI = vertexByImageFeature[make_pair(match.first.first, dm.queryIdx)];
Vertex& vJ = vertexByImageFeature[make_pair(match.first.second, dm.trainIdx)];
add_edge(vI, vJ, g);
}
}
查看结果图的可视化(使用boost::write_graphviz()),我们可以看到许多情况下我们的匹配是错误的。 坏的匹配链将涉及来自链中同一图像的多个特征。 我们在下图中标记了几个这样的实例;请注意,有些链具有两个或更多颜色相同的节点:
我们可以注意到,这些链本质上是图中的连通组件。 使用boost::connected_components()提取组件很简单:
// Get connected components
std::vector<int> component(num_vertices(gFiltered), -1);
int num = connected_components(gFiltered, &component[0]);
map<int, vector<Vertex> > components;
for (size_t i = 0; i != component.size(); ++ i) {
if (component[i] >= 0) {
components[component[i]].push_back(i);
}
}
我们可以过滤掉不好的成分(任何一幅图像中有多个特征),以得到干净的匹配图。
三维重建和可视化
原则上获得轨迹后,我们需要按照 OpenCV 的 SfM 模块期望的数据结构对齐它们。不幸的是,sfm模块没有很好的文档记录,所以这一部分我们必须从源代码中自己找出。 我们将调用cv::sfm::名称空间下的以下函数,该函数可以在opencv_contrib/modules/sfm/include/opencv2/sfm/reconstruct.hpp中找到:
void reconstruct(InputArrayOfArrays points2d, OutputArray Ps, OutputArray points3d, InputOutputArray K, bool is_projective = false);
下面opencv_contrib/modules/sfm/src/simple_pipeline.cpp文件提供了一个重要提示,说明该函数期望作为输入的内容:
static void
parser_2D_tracks( const std::vector<Mat> &points2d, libmv::Tracks &tracks )
{
const int nframes = static_cast<int>(points2d.size());
for (int frame = 0; frame < nframes; ++ frame) {
const int ntracks = points2d[frame].cols;
for (int track = 0; track < ntracks; ++ track) {
const Vec2d track_pt = points2d[frame].col(track);
if ( track_pt[0] > 0 && track_pt[1] > 0 )
tracks.Insert(frame, track, track_pt[0], track_pt[1]);
}
}
}
通常,sfm模块使用精简版本的libmvhttps://developer.blender.org/tag/libmv/(https://www.blender.org/),这是一个成熟的 SFM 软件包,用于使用 Blender 3D(Sfm)图形软件进行影院制作的 3D 重建。
我们可以告诉我们,需要将轨迹放在多个单独cv::Mat的向量中,其中每个都包含作为列的cv::Vec2d对齐列表,这意味着它有两行double。 我们还可以推断,轨迹中缺失(不匹配)的特征点将具有负坐标。 以下代码片断将从匹配图中提取所需数据结构中的轨迹:
vector<Mat> tracks(nViews); // Initialize to number of views
// Each component is a track
const size_t nViews = imagesFilenames.size();
tracks.resize(nViews);
for (int i = 0; i < nViews; i++) {
tracks[i].create(2, components.size(), CV_64FC1);
tracks[i].setTo(-1.0); // default is (-1, -1) - no match
}
int i = 0;
for (auto c = components.begin(); c != components.end(); ++ c, ++ i) {
for (const int v : c->second) {
const int imageID = imageIDs[g[v].image];
const size_t featureID = g[v].featureID;
const Point2f p = keypoints[g[v].image][featureID].pt;
tracks[imageID].at<double>(0, i) = p.x;
tracks[imageID].at<double>(1, i) = p.y;
}
}
我们继续运行重建功能,收集稀疏的 3D 点云和每个 3D 点的颜色,然后可视化结果(使用cv::viz::中的函数):
cv::sfm::reconstruct(tracks, Rs, Ts, K, points3d, true);
这将使用点云和相机位置生成稀疏重建,如下图所示:
将 3D 点重新投影到 2D 图像上,我们可以验证正确的重建:
在附带的源代码存储库中查看重构和可视化的完整代码。
请注意,重建非常稀疏;我们只看到特征匹配的 3D 点。 在获取场景中对象的几何体时,这不会产生非常吸引人的效果。 在许多情况下,Sfm 管道不会以稀疏重建结束,这对许多应用(如 3D 扫描)没有用处。 接下来,我们将了解如何获得密集重建。
用于密集重建的 MVS
利用稀疏的三维点云和摄像机的位置,我们可以利用 MVS 进行密集重建。 在第一节中我们已经学习了 MVS 的基本概念;但是,我们不需要从头开始实现它,而是可以使用OpenMVS项目。 要使用 OpenMVS 进行云加密,我们必须将我们的项目保存为专门的格式。 OpenMVS 提供了一个用于保存和加载.mvs项目的类,即在MVS/Interface.h中定义的MVS::Interface类。
让我们从摄像机开始:
MVS::Interface interface;
MVS::Interface::Platform p;
// Add camera
MVS::Interface::Platform::Camera c;
c.K = Matx33d(K_); // The intrinsic matrix as refined by the bundle adjustment
c.R = Matx33d::eye(); // Camera doesn't have any inherent rotation
c.C = Point3d(0,0,0); // or translation
c.name = "Camera1";
const Size imgS = images[imagesFilenames[0]].size();
c.width = imgS.width; // Size of the image, to normalize the intrinsics
c.height = imgS.height;
p.cameras.push_back(c);
在添加相机姿势(视图)时,我们必须小心。 OpenMVS 希望获得相机的旋转和中心,而不是点投影https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/2715d623-fd0b-42a7-85aa-8a6b52bfc7d6.png的相机姿势矩阵。 因此,我们必须通过应用反向旋转https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/build-cv-proj-opencv4-cpp/img/2b77ab75-4efe-422f-bb7a-c4cefe9c9f29.png来平移平移向量以表示相机的中心:
// Add views
p.poses.resize(Rs.size());
for (size_t i = 0; i < Rs.size(); ++ i) {
Mat t = -Rs[i].t() * Ts[i]; // Camera *center*
p.poses[i].C.x = t.at<double>(0);
p.poses[i].C.y = t.at<double>(1);
p.poses[i].C.z = t.at<double>(2);
Rs[i].convertTo(p.poses[i].R, CV_64FC1);
// Add corresponding image (make sure index aligns)
MVS::Interface::Image image;
image.cameraID = 0;
image.poseID = i;
image.name = imagesFilenames[i];
image.platformID = 0;
interface.images.push_back(image);
}
p.name = "Platform1";
interface.platforms.push_back(p);
在将点云也添加到Interface之后,我们可以在命令行中继续进行云的增密:
$ ${openMVS}/build/bin/DensifyPointCloud -i crazyhorse.mvs
18:48:32 [App ] Command line: -i crazyhorse.mvs
18:48:32 [App ] Camera model loaded: platform 0; camera 0; f 0.896x0.896; poses 7
18:48:32 [App ] Image loaded 0: P1000965.JPG
18:48:32 [App ] Image loaded 1: P1000966.JPG
18:48:32 [App ] Image loaded 2: P1000967.JPG
18:48:32 [App ] Image loaded 3: P1000968.JPG
18:48:32 [App ] Image loaded 4: P1000969.JPG
18:48:32 [App ] Image loaded 5: P1000970.JPG
18:48:32 [App ] Image loaded 6: P1000971.JPG
18:48:32 [App ] Scene loaded from interface format (11ms):
7 images (7 calibrated) with a total of 5.25 MPixels (0.75 MPixels/image)
1557 points, 0 vertices, 0 faces
18:48:32 [App ] Preparing images for dense reconstruction completed: 7 images (125ms)
18:48:32 [App ] Selecting images for dense reconstruction completed: 7 images (5ms)
Estimated depth-maps 7 (100%, 1m44s705ms)
Filtered depth-maps 7 (100%, 1s671ms)
Fused depth-maps 7 (100%, 421ms)
18:50:20 [App ] Depth-maps fused and filtered: 7 depth-maps, 1653963 depths, 263027 points (16%%) (1s684ms)
18:50:20 [App ] Densifying point-cloud completed: 263027 points (1m48s263ms)
18:50:21 [App ] Scene saved (489ms):
7 images (7 calibrated)
263027 points, 0 vertices, 0 faces
18:50:21 [App ] Point-cloud saved: 263027 points (46ms)
此过程可能需要几分钟才能完成。 然而,一旦它完成了,结果是非常令人印象深刻的。 密集的点云拥有惊人的263,027 个 3D 点,而稀疏云中只有 1,557 个点。 我们可以使用 OpenMVS 中捆绑的Viewer应用来可视化密集的 OpenMVS 项目:
OpenMVS 还有几个功能来完成重建,比如从密集的点云中提取三角网格。
简略的 / 概括的 / 简易判罪的 / 简易的
本章重点介绍了 SfM 及其使用 OpenCV 的sfm贡献模块和 OpenMVS 的实现。 探讨了多视点几何中的一些理论概念和几个实际问题:关键特征点的提取、匹配、匹配图的创建和分析、重建,最后对稀疏的三维点云进行 MVS 加密。
在下一章中,我们将了解如何使用 OpenCV 的face contrib模块检测照片中的人脸地标,以及如何使用solvePnP函数检测人脸指向的方向。**
5394

被折叠的 条评论
为什么被折叠?



