一、Android 的卡通化器和换肤器
本章将向您展示如何为 Android 智能手机和平板电脑编写一些图像处理过滤器,该过滤器首先针对台式机(使用 C/C )编写,然后移植到 Android(使用相同的 C/C 代码,但使用 Java GUI), 这是为移动设备开发时的推荐方案。 本章将涵盖:
- 如何将真实图像转换为草图
- 如何转换为绘画并叠加草图来生成卡通
- 一种可怕的“邪恶”模式,用于创建坏角色而不是好角色
- 基本的皮肤检测器和皮肤颜色更改器,可为某人提供绿色的“异形”皮肤
- 如何将项目从桌面应用转换为移动应用
以下屏幕快照显示了在 Android 平板电脑上运行的最终 Cartoonifier 应用:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xgKeZM4T-1681871753484)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_1.jpg)]
我们想要使真实世界的相机帧看起来像真的是动画片。 基本思想是用一些颜色填充扁平零件,然后在坚固的边缘上绘制粗线。 换句话说,平坦区域应该变得更加平坦,边缘应该变得更加清晰。 我们将检测边缘并平滑平坦的区域,然后在顶部绘制增强的边缘以产生卡通或漫画效果。
开发移动计算机视觉应用时,最好先构建一个完全正常运行的桌面版本,然后再将其移植到移动设备上,因为开发和调试桌面程序比移动应用容易得多! 因此,本章将以完整的 Cartoonifier 桌面程序开始,您可以使用自己喜欢的 IDE 创建该程序(例如 Visual Studio,XCode , Eclipse, QtCreator 等)。 在桌面上正常运行后,最后一部分将说明如何使用 Eclipse 将其移植到 Android(或可能的 iOS)。 由于我们将创建两个不同的项目,这些项目大多使用不同的图形用户界面共享相同的源代码,因此您可以创建一个由两个项目链接的库,但为简单起见,我们将桌面和 Android 项目彼此相邻并设置 Android 项目以访问Desktop
文件夹中的某些文件(cartoon.cpp
和cartoon.h
,其中包含所有图像处理代码)。 例如:
C:Cartoonifier_Desktopcartoon.cpp
C:Cartoonifier_Desktopcartoon.h
C:Cartoonifier_Desktopmain_desktop.cpp
C:Cartoonifier_Android...
桌面应用使用 OpenCV GUI 窗口,初始化摄像头,并通过每个摄像头框架调用cartoonifyImage()
函数,该函数包含本章中的大部分代码。 然后,它将在 GUI 窗口上显示处理后的图像。 同样,Android 应用使用 Android GUI 窗口,使用 Java 初始化摄像头,并且每个摄像头框架都调用与前面提到的完全相同的 C cartoonifyImage()
函数,但是具有 Android 菜单和手指触摸输入。 本章将解释如何从头开始创建桌面应用,以及如何从一个 OpenCV Android 示例项目中创建 Android 应用。 因此,首先您应该在自己喜欢的 IDE 中创建一个桌面程序,并使用main_desktop.cpp
文件来保存以下各节中提供的 GUI 代码,例如主循环,网络摄像头功能和键盘输入,然后创建在项目之间共享的cartoon.cpp
文件。 您应该将本章的大部分代码作为称为cartoonifyImage()
的函数放入cartoon.cpp
中。
访问网络摄像头
要访问计算机的网络摄像头或摄像头设备,只需在cv::VideoCapture
对象(OpenCV 访问摄像头设备的方法)上调用open()
,然后将0
作为默认摄像头 ID 号。 某些计算机连接了多个摄像机,或者它们不作为默认摄像机0
起作用; 因此,通常的做法是允许用户在希望尝试使用 1 号,2 号或 -1 号摄像机的情况下,将所需的摄像机号作为命令行参数传递。 我们还将尝试使用cv::VideoCapture::set()
将摄像机分辨率设置为640 x 480
,以便在高分辨率摄像机上更快地运行。
注意
根据您的相机模型,驱动程序或系统,OpenCV 可能不会更改相机的属性。 对于这个项目而言,这并不重要,所以请放心,如果它不适用于您的相机。
您可以将此代码放入main_desktop.cpp
的main()
函数中:
int 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);
初始化网络摄像头后,您可以将当前的摄像机图像作为cv::Mat
对象(OpenCV 的图像容器)获取。 您可以使用 C 流运算符从cv::VideoCapture
对象捕获到cv::Mat
对象中,从而抓住每个摄像机帧,就像从控制台获取输入一样。
注意
OpenCV 使加载视频文件(例如 AVI 或 MPG 文件)并使用它代替网络摄像头非常容易。 与您的代码唯一的不同是,您应该使用视频文件名(例如camera.open("my_video.avi")
)而不是摄像机编号(例如camera.open(0)
)创建cv::VideoCapture
对象。 两种方法均会创建可以以相同方式使用的cv::VideoCapture
对象。
桌面应用的主摄像头处理循环
如果要使用 OpenCV 在屏幕上显示 GUI 窗口,请为每个图像调用cv::imshow()
,但还必须每帧调用一次cv::waitKey()
, 否则,您的 Windows 将根本不会更新! 调用cv::waitKey(0)
会无限期地等待,直到用户敲击窗口中的某个键为止,但是正数(例如waitKey(20)
或更高版本)将至少等待那么多毫秒。
将此主循环放在main_desktop.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 at least 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.
char keypress = cv::waitKey(20); // Need this to see anything!
if (keypress == 27) { // Escape Key
// Quit the program!
break;
}
}//end while
生成黑白草图
要获得相机帧的草图(黑白图),我们将使用边缘检测过滤器; 而要获得彩色绘画,我们将使用边缘保留过滤器(双边过滤器)进一步平滑平坦区域,同时保持边缘完整。 通过将素描图覆盖在彩色绘画的顶部,我们获得了卡通效果,如最终应用的屏幕截图中所示。
有许多不同的边缘检测过滤器,例如 Sobel, Scharr,拉普拉斯过滤器或 Canny 边缘检测器。 我们将使用 Laplacian 边缘过滤器,因为它产生的边缘与索贝尔或 Scharr 相比看起来与手绘草图最为相似,并且与 Canny 边缘检测器相比非常一致,后者产生的线条非常清晰,但受到随机噪声的影响更大。 因此,相机镜架中的“线条”和“线条图”通常会在镜架之间发生巨大变化。
尽管如此,在使用拉普拉斯边缘过滤器之前,我们仍然需要减少图像中的噪声。 我们将使用中值过滤器,因为它可以在消除噪声的同时保持边缘清晰; 而且,它不如双边过滤器慢。 由于拉普拉斯过滤器使用灰度图像,因此我们必须将 OpenCV 的默认 BGR 格式转换为灰度。 在空文件cartoon.cpp
中,将此代码放在顶部,这样您就可以访问 OpenCV 和标准 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);
拉普拉斯过滤器产生的边缘具有变化的亮度,因此为了使边缘看起来更像草图,我们应用二进制阈值使边缘为白色或黑色:
代码语言:javascript复制Mat mask;
const int EDGES_THRESHOLD = 80;
threshold(edges, mask, EDGES_THRESHOLD, 255, THRESH_BINARY_INV);
在下图中,您可以看到原始图像(左侧)和生成的边缘遮罩(右侧),看起来与草图相似。 生成彩色绘画(稍后说明)后,我们可以将此边缘遮罩放在黑色线条画的顶部:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JF5o0PBr-1681871753486)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_2.jpg)]
生成彩色绘画和卡通
强大的双边过滤器使边缘平滑的同时保持边缘清晰,因此非常适合作为自动卡通化器或绘画过滤器,但它非常慢(即以秒甚至数分钟而不是毫秒为单位! )。 因此,我们将使用一些技巧来获得仍然可以以可接受的速度运行的漂亮的卡通化器。 我们可以使用的最重要的技巧是以较低的分辨率执行双边过滤。 它具有与全分辨率相似的效果,但运行速度更快。 让我们将像素总数减少四倍(例如,一半宽度和一半高度):
代码语言:javascript复制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
)。 该截断的过滤器将应用过滤器的主要部分(灰色区域),而不会浪费时间在过滤器的次要部分(曲线下方的白色区域)上,因此它将运行几倍:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YVuKbFnt-1681871753486)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_3.jpg)]
我们有四个参数来控制双边过滤器:颜色强度,位置强度,大小和重复计数。 我们需要一个临时Mat
,因为bilateralFilter()
无法覆盖其输入(称为“原地处理”),但是我们可以应用一个存储临时Mat
的过滤器,另一个存储返回到输入的过滤器:
Mat tmp = Mat(smallSize, CV_8UC3);
int repetitions = 7; // Repetitions for strong cartoon effect.
for (int i=0; i<repetitions; i ) {
int ksize = 9; // Filter size. Has a 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);
}
记住这是应用于缩小的图像,因此我们需要将图像扩展回原始大小。 然后,我们可以覆盖之前发现的边缘遮罩。 要将边缘遮罩“素描”覆盖到双边过滤器“绘画”(下图的左侧),我们可以从黑色背景开始,复制“素描”中不是边缘的“绘画”像素:
代码语言:javascript复制Mat bigImg;
resize(smallImg, bigImg, size, 0,0, INTER_LINEAR);
dst.setTo(0);
bigImg.copyTo(dst, mask);
结果是原始照片的卡通版本,如右图所示,其中“素描”遮罩覆盖在“绘画”上:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Yi5PrRTh-1681871753486)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_4.jpg)]
使用边缘过滤器生成“邪恶”模式
卡通和漫画总是有好有坏的角色。 使用边缘过滤器的正确组合,最无辜的人可能会生成可怕的图像! 诀窍是使用小边缘过滤器,它将在整个图像中找到许多边缘,然后使用小中值过滤器合并边缘。
我们将在具有一定降噪效果的灰度图像上执行此操作,因此应再次使用前面的代码将原始图像转换为灰度并应用7 x 7
中值过滤器(下图中的第一幅图像显示了灰度的输出) 中值模糊)。 如果我们沿 x 和 y 应用3 x 3
Scharr 梯度过滤器(图中的第二个图像),然后应用具有非常高的二值阈值,则不用拉普拉斯过滤器和二进制阈值跟随它,就可以得到更恐怖的外观。 低截止(图中的第三幅图像)和7 x 7
中值模糊,从而产生最终的“邪恶”遮罩(图中的第四幅图像):
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);
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QSnfJ7Cs-1681871753487)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_5.jpg)]
现在,我们有了一个“邪恶”遮罩,可以像使用常规“素描”边缘遮罩那样,将该遮罩叠加到卡通化的“绘画”图像上。 最终结果显示在下图的右侧:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K5qJCnSM-1681871753487)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_6.jpg)]
使用皮肤检测生成“异物”模式
现在我们具有素描模式,卡通模式(绘画 素描遮罩)和邪恶模式(绘画 邪恶遮罩),为了好玩,让我们尝试更复杂的东西:“异形”模式, 检测脸部的皮肤区域,然后将皮肤颜色更改为绿色。
皮肤检测算法
从使用 RGB(红绿蓝)或 HSV(色相饱和度值)的简单颜色阈值,或颜色直方图的计算和重新投影,到需要在 CIELab 颜色空间中进行摄像机校准,和进行离线训练的混合模型的复杂机器学习算法中,有许多用于检测皮肤区域的技术。 但是,即使是复杂的方法也不一定能在各种相机,照明条件和皮肤类型下正常运行。 由于我们希望皮肤检测无需任何校准或训练就可以在移动设备上运行,并且我们仅将“有趣”的图像过滤器用于皮肤检测,因此我们只需使用简单的皮肤- 检测方法。 但是,来自移动设备中微小的摄像头传感器的颜色响应往往会发生很大变化,并且我们希望支持任何肤色的人的皮肤检测,而无需任何校准,因此我们需要比简单的颜色阈值更强大的功能。
例如,如果一个简单的 HSV 皮肤检测器的色相相当红色,饱和度相当高但不是很高,并且其亮度不是太暗或太亮,则可以将任何像素视为皮肤。 但是移动相机的白平衡通常很差,因此一个人的皮肤看起来可能略带蓝色,而不是红色,依此类推,这对于简单的 HSV 阈值来说将是一个主要问题。
一种更强大的解决方案是使用 Haar 或 LBP 级联分类器执行人脸检测(如第 8 章,“使用 EigenFace 进行人脸识别”所示),然后查看检测到的面部中间像素的颜色,因为您知道这些像素应该是实际人物的皮肤像素。 然后,您可以扫描整个图像或附近区域中与脸部中心颜色相似的像素。 这具有的优点是,无论他们的肤色是什么,或者即使他们的皮肤在相机图像中显得有些蓝色或红色,也很有可能找到任何被检测到的人的至少某些真实皮肤区域。
不幸的是,在当前的移动设备上,使用级联分类器进行人脸检测的速度相当慢,因此该方法对于某些实时移动应用可能不太理想。 另一方面,我们可以利用以下事实:对于移动应用,可以假设用户将相机从近处直接朝向人脸握持,并且由于用户握住了相机可以轻松移动,因此要求用户将脸部放置在特定的位置和距离,而不是尝试检测脸部的位置和大小是很合理的。 这是许多移动电话应用的基础,其中该应用要求用户将其脸部放置在某个位置,或者手动在屏幕上拖动点以显示其脸角在照片中的位置。 因此,我们只需在屏幕中央绘制一个脸部轮廓,然后让用户将其脸部移动到所示位置和大小即可。
向用户显示放置脸部的位置
首次启动外星人模式时,我们将在相机框的顶部绘制脸部轮廓,以便用户知道将脸部放置在何处。 我们将绘制一个大椭圆,覆盖图像高度的 70%,并且纵横比固定为 0.72,以便根据相机的纵横比,面部不会变得太瘦或太胖:
代码语言:javascript复制// Draw the color face onto a black background.
Mat faceOutline = Mat::zeros(size, CV_8UC3);
Scalar color = CV_RGB(255,255,0); // Yellow.
int thickness = 4;
// Use 70% of the screen height as the face height.
int sw = size.width;
int sh = size.height;
int faceH = sh/2 * 70/100; // "faceH" is the radius of the ellipse.
// Scale the width to be the same 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.
int eyeA = 15; // angle in degrees.
int 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);
我们可以使用相同的方法绘制嘴的下唇:
代码语言:javascript复制// 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);
为了使用户将脸部放在显示的位置更加明显,让我们在屏幕上写一条消息!
代码语言:javascript复制// 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 混合将卡通化的图像与此绘制的轮廓相结合,将其叠加到显示的图像上:
代码语言:javascript复制addWeighted(dst, 1.0, faceOutline, 0.7, 0, dst, CV_8UC3);
它导致下图的轮廓,向用户显示了将脸放在哪里,因此我们无需检测脸部位置:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6K5VNAF0-1681871753487)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_7.jpg)]
换肤器的实现
我们可以使用 OpenCV 的floodFill()
而不是先检测肤色,然后再检测具有该肤色的区域,这与许多图像编辑程序中的存储桶填充工具类似。 我们知道屏幕中间的区域应该是皮肤像素(因为我们要求用户将其脸部放在中间),因此要将整个脸部更改为绿色皮肤,我们只需在屏幕上应用绿色填充中心像素即可,它将始终将脸部的至少某些部分着色为绿色。 实际上,脸部的不同部分的颜色,饱和度和亮度可能会有所不同,因此,除非阈值太低以至于也覆盖了脸部之外的多余像素,否则泛色填充将很少覆盖脸部的所有皮肤像素。 面对。 因此,与其在图像的中心应用单个泛洪填充,不如在脸部周围六个不同的点(应该是皮肤像素)上应用泛洪填充。
OpenCV 的floodFill()
函数的一个不错的功能是它可以将泛洪填充绘制到外部图像中,而不用修改输入图像。 因此,此函数可以为我们提供用于调整皮肤像素颜色的遮罩图像,而不必更改亮度或饱和度,从而比所有皮肤像素都变成相同的绿色像素(因此会丢失大量面部细节)时产生更逼真的图像。
肤色更改在 RGB 颜色空间中效果不佳。 这是因为您要允许脸部的亮度变化,但不允许肤色的变化很大,并且 RGB 不能将亮度与颜色分开。 一种解决方案是使用色相-饱和度-亮度(HSV) 色空间,因为它可以将亮度与颜色(色相)和彩色(饱和度)分开。 不幸的是,HSV 将色调值包裹在红色周围,并且由于皮肤主要是红色,这意味着您需要同时使用小于 10% 的色调和大于 90% 的色调,因为它们都是红色。 因此,我们将改用 Y’CrCb 颜色空间(YUV 的变体,在 OpenCV 中),因为它可以将亮度与颜色分开,并且对于典型的皮肤颜色只有一个值范围,而不是两个。 请注意,大多数相机,图像和视频在转换为 RGB 之前实际上使用某种类型的 YUV 作为其色彩空间,因此在许多情况下,您无需手动转换就可以获取 YUV 图像。
由于我们希望外星人模式看起来像卡通漫画,因此我们将在图像已被卡通化后应用外星人过滤器; 换句话说,我们可以访问由双边过滤器生成的缩小的彩色图像,以及完整尺寸的边缘遮罩。 皮肤检测通常在低分辨率下效果更好,因为它等效于分析每个高分辨率像素的邻居(或低频信号而不是高频噪声信号)的平均值。 因此,让我们以与双边过滤器相同的缩小比例(一半宽度和一半高度)工作。 让我们将绘画图像转换为 YUV:
代码语言:javascript复制Mat yuv = Mat(smallSize, CV_8UC3);
cvtColor(smallImg, yuv, CV_BGR2YCrCb);
我们还需要缩小边缘遮罩,使其与绘画图像的比例相同。 当存储到单独的遮罩图像时,OpenCV 的floodFill()
函数有一个复杂之处,因为遮罩在整个图像周围应具有 1 个像素的边框,因此如果输入图像为W x H
像素,单独的遮罩图像应为(W 2) x (H 2)
像素。 但是floodFill()
还允许我们使用填充算法可以确保其不会交叉的边缘来初始化遮罩。 让我们使用此函数,希望它有助于防止洪水填充扩展到工作面之外。 因此,我们需要提供两个遮罩图像:尺寸为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
区域:
int sw = smallSize.width;
int sh = smallSize.height;
Mat mask, maskPlusBorder;
maskPlusBorder = Mat::zeros(sh 2, sw 2, CV_8UC1);
mask = maskPlusBorder(Rect(1,1,sw,sh)); // mask is in maskPlusBorder.
resize(edge, mask, smallSize); // Put edges in both of them.
边缘遮罩 (如下图的左侧所示)充满了强边缘和弱边缘。 但我们只需要强边缘,因此我们将应用二进制阈值(导致下图的中间图像)。 为了连接边缘之间的一些间隙,我们将形态运算符dilate()
和erode()
结合起来以去除一些间隙(也称为“闭合”运算符),在图的右侧 :
const int EDGES_THRESHOLD = 80;
threshold(mask, mask, EDGES_THRESHOLD, 255, THRESH_BINARY);
dilate(mask, mask, Mat());
erode(mask, mask, Mat());
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gwgnH4vY-1681871753488)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_8.jpg)]
如前所述,我们希望在脸部周围的多个点上应用泛洪填充,以确保我们包括整个脸部的各种颜色和阴影。 让我们在鼻子,脸颊和前额周围选择六个点,如下图的左侧所示。 请注意,这些值取决于之前绘制的面部轮廓:
代码语言:javascript复制int 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 颜色空间中执行的,因此我们基本上决定了亮度,红色分量和蓝色分量可以变化多少。 我们希望允许亮度变化很大,包括阴影,高光和反射,但我们不希望颜色变化太多:
代码语言:javascript复制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)清晰可见:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CrgR6fIi-1681871753488)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_9.jpg)]
mask
图像(显示在上图的右侧)现在包含:
- 边缘像素的值为 255
- 皮肤区域的值为 1
- 其余像素值为 0
同时,仅edgeMask
包含边缘像素(值为 255)。 因此,仅获取皮肤像素,我们可以从中删除边缘:
mask -= edgeMask;
mask
图像现在仅包含 1 表示皮肤像素,0 表示非皮肤像素。 要更改原始图像的皮肤颜色和亮度,我们可以将cv::add()
与皮肤遮罩一起使用,以增加原始 BGR 图像中的绿色分量:
int Red = 0;
int Green = 70;
int Blue = 0;
add(smallImgBGR, CV_RGB(Red, Green, Blue), smallImgBGR, mask);
下图在左侧显示了原始图像,在右侧显示了最终的外星卡通图像,现在,脸部的至少六个部分将变为绿色!
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7E8AnD7O-1681871753488)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_10.jpg)]
请注意,我们不仅使皮肤看起来绿色,而且更亮(看起来像是在黑暗中发光的外星人)。 如果您只想改变肤色而不使其变亮,则可以使用其他颜色改变方法,例如在绿色上增加 70,而在红色和蓝色之间减去 70,或者使用cvtColor(src, dst, "CV_BGR2HSV_FULL")
转换为 HSV 颜色空间,然后调整色相和饱和度。
就这样! 在准备好将其移植到手机上之前,请以不同的模式运行该应用。
从台式机移植到 Android
现在,程序可在桌面上运行,我们可以从中制作一个 Android 或 iOS 应用。 此处提供的详细信息特定于 Android,但在为 Apple iPhone 和 iPad 或类似设备移植到 iOS 时也适用。 在开发 Android 应用时,可以直接从 Java 使用 OpenCV,但是结果不太可能像本机 C/C 代码一样高效,并且不允许在桌面上运行与移动设备相同的代码。 因此,建议在大多数 OpenCV Android 应用开发中使用 C/C (想要纯粹用 Java 编写 OpenCV 应用的读者可以使用 Samuel Audet 的 JavaCV 库,可从这个页面,以便在我们在 Android 上运行的桌面上运行相同的代码)。
注意
这个 Android 专案使用摄影机进行即时输入,因此无法在 Android 模拟器上运作。 它需要一个带相机的真实 Android 2.2(Froyo)或更高版本的设备。
Android 应用的用户界面应使用 Java 编写,但对于图像处理,我们将使用与桌面相同的cartoon.cpp
C 文件。 要在 Android 应用中使用 C/C 代码,我们必须使用基于 JNI(Java 本机接口)的 NDK(本机开发套件)。 我们将为cartoonifyImage()
函数创建一个 JNI 包装器,以便可以在具有 Java 的 Android 中使用它。
设置一个使用 OpenCV 的 Android 项目
Android OpenCV 的端口每年都会发生很大变化,Android 的摄像头访问方法也是如此,因此,本书并不是描述如何设置的最佳地方。 因此,读者可以按照这个页面上的最新说明,使用 OpenCV 设置和构建本机(NDK)Android 应用。 OpenCV 带有一个名为 Sample3Native 的 Android 示例项目,该示例项目使用 OpenCV 访问相机并在屏幕上显示修改后的图像。 该示例项目可用作本章中开发的 Android 应用的基础,因此读者应熟悉此示例应用(当前可在这个页面)。 然后,我们将修改一个 Android OpenCV 基础项目,以便它可以对摄像机的视频帧进行卡通化处理,并在屏幕上显示结果帧。
如果您坚持使用 Android 的 OpenCV 开发,例如,如果遇到编译错误,或者相机始终显示空白帧,请尝试在以下网站上搜索解决方案:
- 前面提到的针对 OpenCV 的 Android Binary Package NDK 教程。
- 官方的 Android-OpenCV Google 组。
- OpenCV 的问答网站。
- StackOverflow 问答网站。
- 网络(例如 Google)。
- 如果尝试了所有这些方法后仍然无法解决问题,则应在 Android-OpenCV Google 组中发布问题,并提供错误消息的详细信息,等等。
用于 Android 上图像处理的颜色格式
在为桌面开发时,我们只需要处理 BGR 像素格式,因为输入(来自相机,图像或视频文件)的输入是 BGR 格式,输出(HighGUI 窗口,图像或视频文件)。 但是,为手机开发时,通常必须自己转换本机颜色格式。
从相机输入色彩格式
查看jnijni_part.cpp
中的示例代码,myuv
变量是 Android 默认相机格式"NV21" YUV420sp
的彩色图像。 数组的第一部分是灰度像素数组,其后是在 U 和 V 颜色通道之间交替的半尺寸像素数组。 因此,如果我们只想访问灰度图像,则可以直接从YUV420sp
半平面图像的第一部分获取它,而无需进行任何转换。 但是,如果需要彩色图像(例如 BGR 或 BGRA 彩色格式),则必须使用cvtColor()
转换颜色格式。
输出显示的颜色格式
查看来自 OpenCV 的 Sample3Native 代码, mbgra
变量是要在 Android 设备上以 BGRA 格式显示的彩色图像。 OpenCV 的默认格式是 BGR(与 RGB 相反的字节顺序),而 BGRA 只是在每个像素的末尾添加了一个未使用的字节,因此每个像素都存储为“蓝-绿-红-未使用”。 您可以使用 OpenCV 的默认 BGR 格式进行所有处理,然后在屏幕上显示之前将最终输出从 BGR 转换为 BGRA,或者可以确保图像处理代码可以处理 BGRA 格式,而不是 BGR 格式。 在 OpenCV 中通常很容易做到这一点,因为许多 OpenCV 函数都接受 BGRA,但是您必须确保通过查看图像中的Mat::channels()
值是否是与输入相同的通道数来创建图像 3 或 4。此外,如果您直接访问代码中的像素,则需要单独的代码来处理 3 通道 BGR 和 4 通道 BGRA 图像。
注意
某些 CV 操作使用 BGRA 像素运行更快(因为它对齐到 32 位),而某些使用 BGR 像素运行(更快,因为它需要更少的内存来读写),因此为了获得最大的效率,您应该同时支持 BGR 和 BGRA,然后找到哪种颜色格式在您的应用中总体上运行最快。
让我们从简单的事情开始:在 OpenCV 中访问摄像机帧,但不对其进行处理,而是将其显示在屏幕上。 使用 Java 代码可以很容易地做到这一点,但是了解如何使用 OpenCV 做到这一点也很重要。 如前所述,摄像机图像以YUV420sp
格式到达我们的 C 代码,并应以 BGRA 格式保留。 因此,如果我们准备好cv::Mat
用于输入和输出,则只需使用cvtColor
从YUV420sp
转换为 BGRA。 要为 Android Java 应用编写 C/C 代码,我们需要使用特殊的 JNI 函数名称,该名称与将使用该 JNI 函数的 Java 类和包名称相匹配,格式为:
JNIEXPORT <Return> JNICALL Java_<Package>_<Class>_<Function>(JNIEnv* env, jobject, <Args>)
因此,让我们创建一个ShowPreview()
C/C 函数,该函数在Cartoonifier
Java 包中的CartoonifierView
Java 类中使用。 将此ShowPreview()
C/C 函数添加到jnijni_part.cpp
中:
// Just show the plain camera image without modifying it.
JNIEXPORT void JNICALL Java_com_Cartoonifier_CartoonifierView_ShowPreview(
JNIEnv* env, jobject,
jint width, jint height, jbyteArray yuv, jintArray bgra)
{
jbyte* _yuv = env->GetByteArrayElements(yuv, 0);
jint* _bgra = env->GetIntArrayElements(bgra, 0);
Mat myuv = Mat(height height/2, width, CV_8UC1, (uchar *)_yuv);
Mat mbgra = Mat(height, width, CV_8UC4, (uchar *)_bgra);
// Convert the color format from the camera's
// NV21 "YUV420sp" format to an Android BGRA color image.
cvtColor(myuv, mbgra, CV_YUV420sp2BGRA);
// OpenCV can now access/modify the BGRA image "mbgra" ...
env->ReleaseIntArrayElements(bgra, _bgra, 0);
env->ReleaseByteArrayElements(yuv, _yuv, 0);
}
虽然这段代码乍看之下很复杂,但该函数的前两行仅使我们能够对给定的 Java 数组进行本机访问,而后两行则围绕给定的像素缓冲区构造cv::Mat
对象(也就是说,它们不分配新的图像,它们使myuv
访问_yuv
数组中的像素,依此类推,等等),该函数的最后两行释放了我们在 Java 数组上放置的本机锁。 我们在函数中所做的唯一实际工作是将 YUV 转换为 BGRA 格式,因此该函数是我们可以用于新函数的基础。 现在,我们将其扩展为在显示之前分析和修改 BGRA cv::Mat
。
注意
OpenCV v2.4.2 中的jnijni_part.cpp
示例代码使用以下代码:
cvtColor(myuv, mbgra, CV_YUV420sp2BGR, 4);
看起来它转换为 3 通道 BGR 格式(OpenCV 的默认格式),但是由于使用[4"
参数,它实际上转换为 4 通道 BGRA(Android 的默认输出格式)!因此,它与此代码相同, 不太混乱:
cvtColor(myuv, mbgra, CV_YUV420sp2BGRA);
由于我们现在有了 BGRA 图像作为输入和输出,而不是 OpenCV 的默认 BGR,因此为我们提供了两种处理方式:
- 在执行图像处理之前,先从 BGRA 转换为 BGR,然后在 BGR 中进行处理,然后将输出转换为 BGRA,以便 Android 可以显示
- 修改我们所有的代码以处理 BGRA 格式,以补充 BGR 格式(或代替 BGR 格式),因此我们无需在 BGRA 和 BGR 之间执行慢速转换
为简单起见,我们将仅应用从 BGRA 到 BGR 的色彩转换,而不支持 BGR 和 BGRA 格式。 如果您正在编写实时应用,则应考虑在代码中添加 4 通道 BGRA 支持以潜在地提高表现。 我们将做一个简单的更改,以使操作更快一些:我们将输入从YUV420sp
转换为 BGRA,然后从 BGRA 转换为 BGR,所以我们也可能直接从YUV420sp
转换为 BGR!
在设备上使用ShowPreview()
函数(如前所示)进行构建和运行是一个好主意,因此,如果以后对 C/C 代码有疑问,可以参考一下。 要从 Java 调用它,我们在CartoonifyView.java
底部附近的CartoonifyImage()
的 Java 声明旁边添加 Java 声明:
public native void ShowPreview(int width, int height,
byte[] yuv, int[] rgba);
然后,我们可以像称为FindFeatures()
的 OpenCV 示例代码一样来调用它。 将其放在CartoonifierView.java
的processFrame()
函数的中间:
ShowPreview(getFrameWidth(), getFrameHeight(), data, rgba);
您现在应该在设备上构建并运行它,以查看实时摄像机预览。
将卡通化代码添加到 Android NDK 应用
我们需要添加用于桌面应用的cartoon.cpp
文件。 文件jniAndroid.mk
为您的项目设置 C/C /Assembly 源文件,标头搜索路径,本机库和 GCC 编译器设置:
在LOCAL_SRC_FILES
中添加cartoon.cpp
(如果想更方便地调试,请添加ImageUtils_0.7.cpp
),但请记住,它们位于桌面文件夹中,而不是默认的jni
文件夹中。 因此,请在以下位置添加:LOCAL_SRC_FILES := jni_part.cpp
:
LOCAL_SRC_FILES = ../../Cartoonifier_Desktop/cartoon.cpp
LOCAL_SRC_FILES = ../../Cartoonifier_Desktop/ImageUtils_0.7.cpp
添加头文件搜索路径,以便它可以在公共父文件夹中找到cartoon.h
:
LOCAL_C_INCLUDES = $(LOCAL_PATH)/../../Cartoonifier_Desktop
在文件jnijni_part.cpp
中,将其插入顶部而不是#include <vector>
:
#include "cartoon.h" // Cartoonifier.
#include "ImageUtils.h" // (Optional) OpenCV debugging // functions.
向该文件添加一个 JNI 函数CartoonifyImage()
; 这将使图像卡通化。 我们可以从复制我们先前创建的ShowPreview()
函数开始,该函数仅显示摄像机预览而无需对其进行修改。 请注意,由于我们不想处理 BGRA 图像,因此我们直接从YUV420sp
转换为 BGR:
// Modify the camera image using the Cartoonifier filter.
JNIEXPORT void JNICALL Java_com_Cartoonifier_CartoonifierView_CartoonifyImage(
JNIEnv* env, jobject,
jint width, jint height, jbyteArray yuv, jintArray bgra)
{
// Get native access to the given Java arrays.
jbyte* _yuv = env->GetByteArrayElements(yuv, 0);
jint* _bgra = env->GetIntArrayElements(bgra, 0);
// Create OpenCV wrappers around the input & output data.
Mat myuv(height height/2, width, CV_8UC1, (uchar *)_yuv);
Mat mbgra(height, width, CV_8UC4, (uchar *)_bgra);
// Convert the color format from the camera's YUV420sp // semi-planar
// format to OpenCV's default BGR color image.
Mat mbgr(height, width, CV_8UC3); // Allocate a new image buffer.
cvtColor(myuv, mbgr, CV_YUV420sp2BGR);
// OpenCV can now access/modify the BGR image "mbgr", and should
// store the output as the BGR image "displayedFrame".
Mat displayedFrame(mbgr.size(), CV_8UC3);
// TEMPORARY: Just show the camera image without modifying it.
displayedFrame = mbgr;
// Convert the output from OpenCV's BGR to Android's BGRA //format.
cvtColor(displayedFrame, mbgra, CV_BGR2BGRA);
// Release the native lock we placed on the Java arrays.
env->ReleaseIntArrayElements(bgra, _bgra, 0);
env->ReleaseByteArrayElements(yuv, _yuv, 0);
}
先前的代码不会修改图像,但是我们想使用本章前面开发的卡通化器来处理图像。 现在,让我们插入对我们在cartoon.cpp
中为桌面应用创建的现有cartoonifyImage()
函数的调用。 将临时代码行displayedFrame = mbgr
替换为:
cartoonifyImage(mbgr, displayedFrame);
而已! 生成代码(Eclipse 应该使用ndk-build
为您编译 C/C 代码)并在设备上运行它。 您应该有一个可以正常工作的卡通化器 Android 应用(在本章开始的地方,有一个示例屏幕快照显示了您的期望)! 如果它没有生成或运行,请返回步骤并解决问题(如果需要,请查看本书随附的代码)。 工作正常后,继续下一步。
审核 Android 应用
您会很快注意到设备上正在运行的应用存在四个问题:
- 它非常慢; 每帧很多秒! 因此,我们应该只显示摄像机预览,并且仅在用户触摸屏幕以说它是一张好照片时才将摄像机帧卡通化。
- 它需要处理用户输入,例如在草图,绘画,邪恶或异物模式之间更改模式。 我们将它们添加到 Android 菜单栏中。
- 如果我们可以将卡通化的结果保存到图像文件中,并与他人共享,那将是很好的。 每当用户触摸屏幕以获取卡通化图像时,我们都会将结果另存为图像文件到用户的 SD 卡中,并将其显示在 Android Gallery 中。
- 草图边缘检测器中存在很多随机噪声。 我们将创建一个特殊的“胡椒”降噪过滤器以在以后处理。
当用户点击屏幕时将图像卡通化
要显示摄像机预览(直到用户要对选定的摄像机帧进行卡通化),我们可以调用我们先前编写的ShowPreview()
JNI 函数。 在将摄像机图像卡通化之前,我们还将等待用户的触摸事件。 我们只想在用户触摸屏幕时对一幅图像进行卡通化; 因此,我们设置了一个标志,说应该将下一个摄像机帧卡通化,然后重置该标志,以便再次进行摄像机预览。 但这意味着动画片化后的图像仅显示一秒钟,然后会再次显示下一个摄像机预览。 因此,我们将使用第二个标志来表示当前图像应在摄像机帧覆盖它之前在屏幕上冻结几秒钟,以便用户有时间查看它:
在srccomCartoonifier
文件夹中CartoonifierApp.java
文件顶部附近添加以下标头导入:
import android.view.View;
import android.view.View.OnTouchListener;
import android.view.MotionEvent;
修改CartoonifierApp.java
顶部附近的类定义:
public class CartoonifierApp
extends Activity implements OnTouchListener {
将此代码插入onCreate()
函数的底部:
// Call our "onTouch()" callback function whenever the user // touches the screen.
mView.setOnTouchListener(this);
添加函数onTouch()
以处理触摸事件:
public boolean onTouch(View v, MotionEvent m) {
// Ignore finger movement event, we just care about when the
// finger first touches the screen.
if (m.getAction() != MotionEvent.ACTION_DOWN) {
return false; // We didn't use this touch movement event.
}
Log.i(TAG, "onTouch down event");
// Signal that we should cartoonify the next camera frame and save
// it, instead of just showing the preview.
mView.nextFrameShouldBeSaved(getBaseContext());
return true;
}
现在我们需要将nextFrameShouldBeSaved()
函数添加到CartoonifierView.java
中:
// Cartoonify the next camera frame & save it instead of preview.
protected void nextFrameShouldBeSaved(Context context) {
bSaveThisFrame = true;
}
在CartoonifierView
类的顶部附近添加以下变量:
private boolean bSaveThisFrame = false;
private boolean bFreezeOutput = false;
private static final int FREEZE_OUTPUT_MSECS = 3000;
CartoonifierView
的processFrame()
函数现在可以在卡通和预览之间切换,但是还应确保仅在不尝试显示冻结卡通图像几秒钟的情况下才显示某些内容。 因此,将processFrame()
替换为:
@Override
protected Bitmap processFrame(byte[] data) {
// Store the output image to the RGBA member variable.
int[] rgba = mRGBA;
// Only process the camera or update the screen if we aren't
// supposed to just show the cartoon image.
if (bFreezeOutputbFreezeOutput) {
// Only needs to be triggered here once.
bFreezeOutput = false;
// Wait for several seconds, doing nothing!
try {
wait(FREEZE_OUTPUT_MSECS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
if (!bSaveThisFrame) {
ShowPreview(getFrameWidth(), getFrameHeight(), data, rgba);
}
else {
// Just do it once, then go back to preview mode.
bSaveThisFrame = false;
// Don't update the screen for a while, so the user can // see the cartoonifier output.
bFreezeOutput = true;
CartoonifyImage(getFrameWidth(), getFrameHeight(), data,
rgba, m_sketchMode, m_alienMode, m_evilMode,
m_debugMode);
}
// Put the processed image into the Bitmap object that will be // returned for display on the screen.
Bitmap bmp = mBitmap;
bmp.setPixels(rgba, 0, getFrameWidth(), 0, 0, getFrameWidth(),
getFrameHeight());
return bmp;
}
您应该可以生成并运行它,以验证该应用现在是否可以正常运行。
将图像保存到文件和 Android 图片库
我们将输出保存为 PNG 文件并显示在 Android 图片库中。 Android Gallery 专为 JPEG 文件设计,但是 JPEG 对于具有纯色和边缘的卡通图像不利,因此我们将使用繁琐的方法将 PNG 图像添加到图库中。 我们将创建一个 Java 函数savePNGImageToGallery()
来为我们执行此操作。 在前面看到的processFrame()
函数的底部,我们看到使用输出数据创建了一个 Android Bitmap
对象; 因此,我们需要一种将Bitmap
对象保存到 PNG 文件的方法。 OpenCV 的imwrite()
Java 函数可用于保存到 PNG 文件,但这将需要链接到 OpenCV 的 Java API 和 OpenCV 的 C/C API(就像 OpenCV4Android 示例项目“tutorial-4-mixed”一样) )。 由于我们不需要 OpenCV Java API,因此以下代码将仅显示如何使用 Android API 而非 OpenCV Java API 保存 PNG 文件:
Android 的Bitmap
类可以将文件保存为 PNG 格式,因此让我们使用它。 另外,我们需要为图像选择文件名。 让我们使用当前的日期和时间,以允许保存许多文件,并使用户可以记住拍摄时间。 将其插入processFrame()
的return bmp
语句之前:
if (bFreezeOutput) {
// Get the current date & time
SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd,HH-mm-ss");
String timestamp = s.format(new Date());
String baseFilename = "Cartoon" timestamp ".png";
// Save the processed image as a PNG file on the SD card and show // it in the Android Gallery.
savePNGImageToGallery(bmp, mContext, baseFilename);
}
将其添加到CartoonifierView.java
的顶部:
// For saving Bitmaps to file and the Android picture gallery.
import android.graphics.Bitmap.CompressFormat;
import android.net.Uri;
import android.os.Environment;
import android.provider.MediaStore;
import android.provider.MediaStore.Images;
import android.text.format.DateFormat;
import android.util.Log;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
将它插入CartoonifierView
类的顶部:
private static final String TAG = "CartoonifierView";
private Context mContext; // So we can access the Android // Gallery.
将此添加到CartoonifierView
中的nextFrameShouldBeSaved()
函数中:
mContext = context; // Save the Android context, for GUI // access.
将savePNGImageToGallery()
函数添加到CartoonifierView
:
// Save the processed image as a PNG file on the SD card
// and shown in the Android Gallery.
protected void savePNGImageToGallery(Bitmap bmp, Context context,
String baseFilename)
{
try {
// Get the file path to the SD card.
String baseFolder =
Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_PICTURES).getAbsolutePath()
"/";
File file = new File(baseFolder baseFilename);
Log.i(TAG, "Saving the processed image to file ["
file.getAbsolutePath() "]");
// Open the file.
OutputStream out = new BufferedOutputStream(
new FileOutputStream(file));
// Save the image file as PNG.
bmp.compress(CompressFormat.PNG, 100, out);
// Make sure it is saved to file soon, because we are about
// to add it to the Gallery.
out.flush();
out.close();
// Add the PNG file to the Android Gallery.
ContentValues image = new ContentValues();
image.put(Images.Media.TITLE, baseFilename);
image.put(Images.Media.DISPLAY_NAME, baseFilename);
image.put(Images.Media.DESCRIPTION,
"Processed by the Cartoonifier App");
image.put(Images.Media.DATE_TAKEN,
System.currentTimeMillis()); // msecs since 1970 UTC.
image.put(Images.Media.MIME_TYPE, "https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/png");
image.put(Images.Media.ORIENTATION, 0);
image.put(Images.Media.DATA, file.getAbsolutePath());
Uri result = context.getContentResolver().insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,image);
}
catch (Exception e) {
e.printStackTrace();
}
}
如果 Android 应用需要在设备上存储文件,则需要在安装过程中获得用户的许可。 因此,请将此行插入AndroidManifest.xml
中,请求请求摄像机访问权限的类似行旁边:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
生成并运行该应用! 当您触摸屏幕以保存照片时,您最终应该看到屏幕上显示的卡通化图像(也许经过 5 或 10 秒的处理后)。 一旦它显示在屏幕上,这意味着它应该被保存到您的 SD 卡和您的相册。 退出 Cartoonifier 应用,打开 Android Gallery 应用,然后查看图片相册。 您应该在屏幕的全分辨率下将卡通图像视为 PNG 图像。
显示有关已保存图像的 Android 通知消息
如果要在每次将新图像保存到 SD 卡和 Android Gallery 中时显示通知消息,请按照以下步骤操作: 否则,请跳过此部分:
将以下内容添加到CartoonifierView.java
的顶部:
// For showing a Notification message when saving a file.
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ContentValues;
import android.content.Intent;
将其添加到CartoonifierView
的顶部附近:
private int mNotificationID = 0;
// To show just 1 notification.
将其插入到processFrame()
中对savePNGImageToGallery()
的调用下面的if
语句内:
showNotificationMessage(mContext, baseFilename);
将showNotificationMessage()
函数添加到CartoonifierView
:
// Show a notification message, saying we've saved another image.
protected void showNotificationMessage(Context context,
String filename)
{
// Popup a notification message in the Android status
// bar. To make sure a notification is shown for each
// image but only 1 is kept in the status bar at a time, // use a different ID each time
// but delete previous messages before creating it.
final NotificationManager mgr = (NotificationManager)
context.getSystemService(Context.NOTIFICATION_SERVICE);
// Close the previous popup message, so we only have 1 //at a time, but it still shows a popup message for each //one.
if (mNotificationID > 0)
mgr.cancel(mNotificationID);
mNotificationID ;
Notification notification = new Notification(R.drawable.icon,
"Saving to gallery (image " mNotificationID ") ...",
System.currentTimeMillis());
Intent intent = new Intent(context, CartoonifierView.class);
// Close it if the user clicks on it.
notification.flags |= Notification.FLAG_AUTO_CANCEL;
PendingIntent pendingIntent = PendingIntent.getActivity(context,
0, intent, 0);
notification.setLatestEventInfo(context, "Cartoonifier saved "
mNotificationID " images to Gallery", "Saved as '"
filename "'", pendingIntent);
mgr.notify(mNotificationID, notification);
}
再次构建并运行该应用! 每当您触摸屏幕上另一张保存的图像时,您应该会看到一条通知消息弹出。 如果要在长时间的图像处理之前而不是之后弹出通知消息,请将调用移至showNotificationMessage()
,然后移至cartoonifyImage()
,然后将用于生成日期和时间的代码移至时间字符串,以便为通知消息提供相同的字符串,并保存实际文件。
通过 Android 菜单栏更改卡通模式
让我们允许用户通过菜单更改模式:
在文件srccomCartoonifierCartoonifierApp.java
顶部附近添加以下标头:
import android.view.Menu;
import android.view.MenuItem;
在CartoonifierApp
类中插入以下成员变量:
// Items for the Android menu bar.
private MenuItem mMenuAlien;
private MenuItem mMenuEvil;
private MenuItem mMenuSketch;
private MenuItem mMenuDebug;
将以下函数添加到CartoonifierApp
:
/** Called when the menu bar is being created by Android. */
public boolean onCreateOptionsMenu(Menu menu) {
Log.i(TAG, "onCreateOptionsMenu");
mMenuSketch = menu.add("Sketch or Painting");
mMenuAlien = menu.add("Alien or Human");
mMenuEvil = menu.add("Evil or Good");
mMenuDebug = menu.add("[Debug mode]");
return true;
}
/** Called whenever the user pressed a menu item in the menu bar. */
public boolean onOptionsItemSelected(MenuItem item) {
Log.i(TAG, "Menu Item selected: " item);
if (item == mMenuSketch)
mView.toggleSketchMode();
else if (item == mMenuAlien)
mView.toggleAlienMode();
else if (item == mMenuEvil)
mView.toggleEvilMode();
else if (item == mMenuDebug)
mView.toggleDebugMode();
return true;
}
在CartoonifierView
类中插入以下成员变量:
private boolean m_sketchMode = false;
private boolean m_alienMode = false;
private boolean m_evilMode = false;
private boolean m_debugMode = false;
在CartoonifierView
中添加以下函数:
protected void toggleSketchMode() {
m_sketchMode = !m_sketchMode;
}
protected void toggleAlienMode() {
m_alienMode = !m_alienMode;
}
protected void toggleEvilMode() {
m_evilMode = !m_evilMode;
}
protected void toggleDebugMode() {
m_debugMode = !m_debugMode;
}
我们需要将模式值传递给cartoonifyImage()
JNI 代码,因此让我们将它们作为参数发送。 修改CartoonifierView
中CartoonifyImage()
的 Java 声明:
public native void CartoonifyImage(int width, int height,byte[] yuv,
int[] rgba, boolean sketchMode, boolean alienMode,
boolean evilMode, boolean debugMode);
现在修改 Java 代码,以便我们在processFrame()
中传递当前的模式值:
CartoonifyImage(getFrameWidth(), getFrameHeight(), data,rgba,
m_sketchMode, m_alienMode, m_evilMode, m_debugMode);
jnijni_part.cpp
中CartoonifyImage()
的 JNI 声明现在应为:
JNIEXPORT void JNICALL Java_com_Cartoonifier_CartoonifierView_CartoonifyImage(
JNIEnv* env, jobject, jint width, jint height,
jbyteArray yuv, jintArray bgra, jboolean sketchMode,
jboolean alienMode, jboolean evilMode, jboolean debugMode)
然后,我们需要从jnijni_part.cpp
中的 JNI 函数将模式传递给cartoon.cpp
中的 C/C 代码。 在为 Android 开发时,一次只能显示一个 GUI 窗口,但是在桌面上,在调试时显示额外的窗口很方便。 因此,我们不要为debugMode
设置布尔值标志,而是为非调试传递一个数字0
,为移动设备传递 1 的数字(在 OpenCV 中创建 GUI 窗口会导致崩溃!),为 2 传递一个数字。 在桌面上进行调试(我们可以在其中创建任意数量的额外窗口):
int debugType = 0;
if (debugMode)
debugType = 1;
cartoonifyImage(mbgr, displayedFrame, sketchMode, alienMode, evilMode, debugType);
更新cartoon.cpp
中的实际 C/C 实现:
```cpp
void cartoonifyImage(Mat srcColor, Mat dst, bool sketchMode,
bool alienMode, bool evilMode, int debugType)
{
```
- 并更新
cartoon.h
中的 C/C 声明:
```cpp
void cartoonifyImage(Mat srcColor, Mat dst, bool sketchMode,
bool alienMode, bool evilMode, int debugType);
```
- 编译并运行它 ; 然后尝试按窗口底部的小选项菜单按钮。 您应该发现草图模式是实时的,而绘画模式由于双边过滤器而具有较大的延迟。
从素描图像中减少明显的随机噪声
当前智能手机和平板电脑中的大多数相机都具有明显的图像噪点。 这通常是可以接受的,但是对我们的5 x 5
拉普拉斯边缘过滤器有很大的影响。 边缘遮罩(显示为草图模式)通常会具有成千上万个称为“胡椒粉”噪声的黑色小斑点,由白色背景中彼此相邻的几个黑色像素组成。 我们已经在使用中值过滤器,该过滤器通常强度足以消除胡椒噪声,但在我们的情况下可能不够强。 我们的边缘遮罩大部分是纯白色背景(值为 255),带有一些黑色边缘(值为 0)和噪声点(也值为 0)。 我们可以使用标准的闭合形态运算符,但是它将去除很多边缘。 因此,相反,我们将应用自定义过滤器,以删除完全被白色像素包围的小的黑色区域。 这将消除大量噪声,而对实际边缘几乎没有影响。
我们将扫描图像中的黑色像素,并在每个黑色像素处检查其周围5 x 5
正方形的边框,以查看所有5 x 5
边框像素是否均为白色。 如果它们都是白色,我们知道我们有一个黑色的小岛,因此我们用白色像素填充整个块,以消除黑色岛。 为了简单起见,在我们的5 x 5
过滤器中,我们将忽略图像周围的两个边框像素,并保持原样。
下图左侧显示了 Android 平板电脑的原始图像,中间是草图模式(显示胡椒粉的小黑点),右侧显示了去除胡椒粉噪声的结果,其中皮肤看起来更干净:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O468RyuO-1681871753489)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_1_11.jpg)]
可以将以下代码命名为函数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 (int x=2; x<mask.cols-2; x ) {
uchar value = *pThis; // Get this pixel value (0 or 255).
// Check if this is a black pixel that is surrounded by
// white 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 know
// the 5x5 borders are already white, we just need to
// fill 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 ;
}
}
}
显示应用的 FPS
如果您想在屏幕上显示每秒帧(FPS)的速度(对于像这样的慢速应用来说不太重要,但仍然有用),请执行以下步骤 :
将文件srcorgopencvsamplesimagemanipulationsFpsMeter.java
从 OpenCV 中的imagemanipulations
示例文件夹(例如C:OpenCV-2.4.1samplesandroidimage-manipulations
)复制到srccomCartoonifier
文件夹。
将FpsMeter.java
顶部的包名称替换为com.Cartoonifier
。
在CartoonifierViewBase.java
文件中,在private byte[] mBuffer;
之后声明您的FpsMeter
成员变量:
private FpsMeter mFps;
在mHolder.addCallback(this);
之后,在CartoonifierViewBase()
构造器中初始化FpsMeter
对象:
mFps = new FpsMeter();
mFps.init();
在try/catch
块之后测量run()
中每个帧的 FPS:
mFps.measure();
在canvas.drawBitmap()
函数后的run()
中,将 FPS 绘制到每一帧的屏幕上:
mFps.draw(canvas, (canvas.getWidth() - bmp.getWidth()) /2, 0);
使用其他相机分辨率
如果您希望自己的应用运行得更快,并且会影响质量,那么您绝对应该考虑从硬件中请求较小的相机图像,或者在获得图像后缩小图像。 卡通化器所基于的示例代码使用最接近屏幕高度的相机预览分辨率。 因此,如果您的设备具有 5 百万像素的摄像头,并且屏幕仅为640 x 480
,则它可能会使用720 x 480
的摄像头分辨率,依此类推。 如果要控制选择哪种摄像机分辨率,可以在CartoonifierViewBase.java
中的surfaceChanged()
函数中将参数修改为setupCamera()
。 例如:
public void surfaceChanged(SurfaceHolder _holder, int format,
int width, int height) {
Log.i(TAG, "Screen size: " width "x" height);
// Use a camera resolution of roughly half the screen height.
setupCamera(width/2, height/2);
}
从摄像机获得最高预览分辨率的一种简单方法是传递一个较大的尺寸,例如10,000 x 10,000
,它将选择可用的最大分辨率(请注意,它只会给出最大的预览分辨率,即摄像机的视频分辨率,因此通常比相机的静止图像分辨率要小得多)。 或者,如果您希望它运行得非常快,则传递1 x 1
,它将为您找到最低的相机预览分辨率(例如160 x 120
)。
自定义应用
现在,您已经创建了整个 Android 卡通化器应用,您应该了解它的工作原理以及哪些部分可以完成操作; 您应该自定义它! 更改 GUI,应用行为和工作流程,卡通化器过滤器常量,外观检测器算法,或用您自己的想法替换卡通化器代码。
您可以通过多种方式来改进皮肤检测算法,例如,使用更复杂的皮肤检测算法(例如,使用这个页面上许多最近的 CVPR 或 ICCV 会议论文的训练过的高斯模型)或通过添加人脸检测(请参见第 8 章“使用 EigenFace 进行人脸识别”的“人脸检测”部分) 检测器,以便它检测用户的脸部位置,而不是要求用户将其脸部放在屏幕中央。 请注意,在某些设备或高分辨率相机上人脸检测可能需要花费几秒钟的时间,因此该方法可能会受到处理速度相对较慢的限制,但是智能手机和平板电脑每年的速度都在显着提高,因此这将不再是一个问题。
加快移动计算机视觉应用速度的最重要方法是尽可能降低相机分辨率(例如,从 0.5 兆像素而不是 5 兆像素),尽可能少地分配和释放图像以及尽可能少地进行图像转换(例如,通过在整个代码中支持 BGRA 图像)。 您还可以从设备的 CPU 供应商(例如 NVIDIA Tegra,Texas Instruments OMAP,Samsung Exynos,Apple Ax 或 QualComm Snapdragon)中寻找优化的图像处理或数学库。 系列(例如,ARM Cortex-A9)。 请记住,您的设备可能存在 OpenCV 的优化版本。
为了使自定义 NDK 和桌面图像处理代码更容易,本书附带了文件ImageUtils.cpp
和ImageUtils.h
以帮助您进行实验。 它包含printMatInfo()
之类的函数,该函数可打印有关cv::Mat
对象的许多信息,从而使 OpenCV 的调试更加容易。 还有一些计时宏,可以轻松地将详细的计时统计信息添加到 C/C 代码中。 例如:
DECLARE_TIMING(myFilter);
void myImageFunction(Mat img) {
printMatInfo(img, "input");
START_TIMING(myFilter);
bilateralFilter(img, …);
STOP_TIMING(myFilter);
SHOW_TIMING(myFilter, "My Filter");
}
然后,您会在控制台上看到类似以下内容的内容:
代码语言:javascript复制input: 800w600h 3ch 8bpp, range[19,255][17,243][47,251]
My Filter: time: 213ms (ave=215ms min=197ms max=312ms, across 57 runs).
当您的 OpenCV 代码未按预期工作时,此函数非常有用。 特别是对于使用 IDE 调试器通常非常困难的移动开发,printf()
语句通常在 Android NDK 中不起作用。 但是,ImageUtils
中的函数在 Android 和台式机上均可使用。
总结
本章介绍了几种可用于生成各种卡通效果的图像处理过滤器:一种看起来像铅笔素描的普通素描模式,一个看起来像彩色绘画的绘画模式以及一个覆盖在绘画模式之上的草图模式,使图像看起来像卡通。 它还显示可以获得其他有趣的效果,例如可以大大增强噪点边缘的邪恶模式以及将脸部皮肤更改为亮绿色的外星人模式。
有许多商用智能手机应用在用户的脸上执行类似的有趣效果,例如卡通过滤器和换色器。 还有一些使用类似概念的专业工具,例如,使视频平滑的视频后处理工具,旨在通过平滑皮肤同时保持边缘和非皮肤区域的锐化来美化女人的脸,以使自己的脸看起来更年轻。
本章介绍了如何按照建议的准则将应用从桌面应用移植到 Android 移动应用,首先开发可工作的桌面版本,将其移植到移动应用,并创建适合该移动应用的用户界面 。 图像处理代码在两个项目之间共享,以便读者可以修改桌面应用的卡通过滤器,并且通过重建 Android 应用,它也应自动在 Android 应用中显示其修改内容。
使用 OpenCV4Android 所需的步骤会定期更改,并且 Android 开发本身不是静态的; 因此,本章将介绍如何通过向 OpenCV 示例项目之一添加功能来构建 Android 应用。 预计读者可以在以后的 OpenCV4Android 版本中将相同功能添加到等效项目中。
本书包括桌面项目和 Android 项目的源代码。
二、iPhone 或 iPad 上基于标记的增强现实
增强现实(AR)是真实环境的实时视图,其元素由计算机生成的图形增强。 结果,该技术通过增强当前对现实的感知而起作用。 增强通常是实时的,并且在语义环境中具有环境元素。 借助先进的增强现实技术(例如,添加计算机视觉和对象识别),有关用户周围真实世界的信息将变为交互性并可进行数字化处理。 有关环境及其对象的人工信息可以覆盖在现实世界中。
在本章中,我们将为 iPhone/iPad 设备创建一个 AR 应用。 从头开始,我们将创建一个使用标记的应用,以在从相机获取的图像上绘制一些人造物体。 您将学习如何在 XCode IDE 中设置项目并将其配置为在应用中使用 OpenCV。 此外,将说明诸如从内置摄像机捕获视频,使用 OpenGL ES 进行 3D 场景渲染以及构建通用 AR 应用架构等方面。
在开始之前,让我给您简要介绍所需的知识和软件:
- 您将需要一台装有 XCode IDE 的 Apple 计算机。 只有使用 Apple 的 XCode IDE 才能为 iPhone/iPad 开发应用。 这是为此平台构建应用的唯一方法。
- 您将需要 iPhone,iPad 或 iPod Touch 设备的型号。 要在设备上运行应用,您必须每年以 99 美元的价格购买 Apple 开发者证书。 没有此证书,无法在设备上运行开发的应用。
- 您还将需要 XCode IDE 的基本知识。 我们将假定读者具有使用此 IDE 的经验。
- 也需要 Objective-C 和 C 编程语言的基本知识。 但是,将详细说明应用源代码的所有复杂部分。
从本章中,您将了解有关标记的更多信息。 说明了完整的检测例程。 阅读完本章后,您将能够编写自己的标记检测算法,根据相机姿势估计 3D 世界中的标记姿势,并使用它们之间的这种转换来可视化任意 3D 对象。
您将在本书的媒体中找到本章的示例项目。 这是创建您的第一个移动增强现实应用的良好起点。
在本章中,我们将介绍以下主题:
- 创建一个使用 OpenCV 的 iOS 项目
- 应用架构
- 标记检测
- 标记识别
- 标记代码识别
- 在 3D 中放置标记
- 渲染 3D 虚拟对象
创建使用 OpenCV 的 iOS 项目
在本节中,我们将为 iPhone/iPad 设备创建一个演示应用,该应用将使用 OpenCV(开源计算机视觉)库来检测相机帧中的标记并在其上渲染 3D 对象。 此示例将展示如何从设备相机访问原始视频数据流,如何使用 OpenCV 库执行图像处理,如何在图像中找到标记以及渲染 AR 叠加层。
我们将首先通过选择 iOS 单视图应用模板来创建一个新的 XCode 项目,如以下屏幕快照所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JqPGefEf-1681871753489)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_01.jpg)]
现在,我们必须将 OpenCV 添加到我们的项目中。 这一步是必需的,因为在此应用中,我们将使用该库中的许多功能来检测标记并估计位置。
OpenCV 是用于实时计算机视觉的编程功能库。 它最初由 Intel 开发,现在得到 Willow Garage 和 Itseez 的支持。 该库是用 C 和 C 语言编写的。 它还具有官方的 Python 绑定和对 Java 和.NET 语言的非官方绑定。
添加 OpenCV 框架
幸运的是该库是跨平台的,因此可以在 iOS 设备上使用。 从 2.4.2 版本开始,iOS 平台上正式支持 OpenCV 库,您可以从库网站下载发行包。 用于 iOS 的 OpenCV 链接指向压缩的 OpenCV 框架。 如果您不熟悉 iOS 开发,请不要担心。 框架就像一堆文件。 通常,每个框架包都包含一个头文件列表和一个静态链接库列表。 应用框架提供了一种将预编译的库分发给开发人员的简便方法。
当然,您可以从头开始构建自己的库。 OpenCV 文档详细解释了此过程。 为简单起见,我们遵循推荐的方法并使用本章的框架。
下载文件后,我们将其内容提取到项目文件夹中,如以下屏幕截图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RyIxqxsR-1681871753489)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_02.jpg)]
要通知 XCode IDE 在构建阶段使用任何框架,请单击项目选项,然后找到构建阶段选项卡。 在这里,我们可以添加或删除构建过程中涉及的框架列表。 单击加号以添加新帧,如以下屏幕截图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-01ycozBO-1681871753489)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_03.jpg)]
从这里,我们可以从标准框架列表中选择 。 但是要添加自定义框架,我们应该单击添加其他按钮。 将显示打开文件对话框。 将其指向项目文件夹中的opencv2.framework
,如以下屏幕截图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zASwqghv-1681871753490)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_04.jpg)]
包含 OpenCV 头文件
现在我们已经将 OpenCV 框架添加到了项目中,一切都差不多了。 最后一件事-让 OpenCV 标头添加到项目的预编译标头中。 预编译头文件是加快编译时间的重要功能。 通过向它们添加 OpenCV 标头,您的所有源代码也会自动包含 OpenCV 标头。 在项目源代码树中找到一个.pch
文件,并按以下方式对其进行修改。
以下代码显示了如何在项目源代码树中修改.pch
文件:
//
// Prefix header for all source files of the 'Example_MarkerBasedAR'
//
#import <Availability.h>
#ifndef __IPHONE_5_0
#warning "This project uses features only available in iOS SDK 5.0 and later."
#endif
#ifdef __cplusplus
#include <opencv2/opencv.hpp>
#endif
#ifdef __OBJC__
#import <UIKit/UIKit.h>
#import <Foundation/Foundation.h>
#endif
现在,您可以从项目中的任何位置调用任何 OpenCV 函数。
就这样。 我们的项目模板已配置完毕,我们准备进一步进行操作。 免费建议:复制该项目; 这将在您创建下一个时节省您的时间!
应用架构
每个 iOS 应用至少包含UIViewController
接口接口的一个实例,该实例处理所有视图事件并管理该应用的业务逻辑。 此类提供了所有 iOS 应用的基本视图管理模型。 视图控制器管理一组视图,这些视图构成了应用用户界面的一部分。 作为应用控制器层的一部分,视图控制器将其工作与模型对象和其他控制器对象(包括其他视图控制器)进行协调,因此您的应用将呈现一个统一的用户界面。
我们将要编写的应用只有一个视图。 这就是为什么我们选择单视图应用模板来创建一个模板的原因。 该视图将用于呈现渲染的图片。 我们的ViewController
类将包含每个 AR 应用应具有的三个主要组件(请参见下图):
- 视频来源
- 处理管道
- 可视化引擎 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tDZIt9Sx-1681871753490)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_05.jpg)]
视频源负责将内置摄像机拍摄的新帧提供给用户代码。 这意味着视频源应该能够选择摄像头设备(前置或后置摄像头),调整其参数(例如捕获的视频的分辨率,白平衡和快门速度)以及抓取帧而不会冻结视频源。 主界面。
图像处理例程将封装在[HTG7] MarkerDetector
类中。 此类为用户代码提供了非常薄的接口。 通常,它是processFrame
和getResult
之类的一组函数。 实际上,这就是ViewController
应该知道的全部。 没有强烈的必要性,我们决不能将低层数据结构和算法暴露给视图层。 在我们看来,VisualizationController
包含与增强现实的可视化有关的所有逻辑。 VisualizationController
还是隐藏渲染引擎特定实现的外观。 低代码一致性使我们可以自由更改这些组件,而无需重写其余代码。
这种方法使您可以自由地在其他平台和编译器上使用独立模块。 例如,您可以轻松使用MarkerDetector
类在 Mac,Windows 和 Linux 系统上开发桌面应用,而无需更改代码。 同样,您可以决定在 Windows 平台上移植VisualizationController
并使用 Direct3D 进行渲染。 在这种情况下,您应该只编写新的VisualizationController
实现; 其他代码部分将保持不变。
主要处理例程从接收到来自视频源的新帧开始。 这将触发视频源,以通过回调将有关此事件的信息通知用户代码。 ViewController
处理此回调并执行以下操作:
- 将新帧发送到可视化控制器。
- 使用我们的管道执行新帧的处理。
- 将检测到的标记发送到可视化阶段。
- 渲染场景。
让我们详细研究这个例程。 AR 场景的渲染包括具有最后接收到的帧的内容的背景图像的绘制; 稍后将绘制人造 3D 对象。 当我们发送新的帧进行可视化时,我们正在将图像数据复制到渲染引擎的内部缓冲区。 这还不是实际的渲染。 我们只是使用新的位图更新文本。
第二步是新帧和标记检测的处理。 我们将图像作为输入,并因此收到检测到的标记的列表。 在上面。 这些标记被传递到可视化控制器,该控制器知道如何处理它们。 让我们看一下显示此例程的以下序列图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Su7pIM7y-1681871753490)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_06.jpg)]
我们通过编写视频捕获组件来开始开发。 此类将负责所有帧捕获,并负责通过用户回调发送捕获的帧的通知。 稍后,我们将编写标记检测算法。 此检测例程是应用的核心。 在程序的这一部分中,我们将使用许多 OpenCV 函数来处理图像,检测图像上的轮廓,找到标记矩形并估计其位置。 之后,我们将集中于使用增强现实技术对结果进行可视化。 将所有这些内容整合在一起之后,我们将完成我们的第一个 AR 应用。 让我们继续前进!
访问相机
没有两个主要内容,就无法创建增强现实应用:视频捕获和 AR 可视化。 视频捕获阶段包括从设备相机接收帧,执行必要的颜色转换并将其发送到处理管道。 由于单帧处理时间对于 AR 应用至关重要,因此捕获过程应尽可能高效。 达到最佳性能的最佳方法是直接访问从相机接收的帧。 从 iOS 版本 4 开始,这成为可能。AVFoundation 框架中的现有 API 提供了直接从内存中的图像缓冲区读取所需的必要功能。
您可以找到很多使用AVCaptureVideoPreviewLayer
类和UIGetScreenImage
函数从摄像机捕获视频的示例。 此技术用于 iOS 版本 3 和更早版本。 现在它已经过时,并且具有两个主要缺点:
- 缺少对帧数据的直接访问。 要获得位图,您必须创建
UIImage
的中间实例,将图像复制到该实例,然后将其取回。 对于 AR 应用来说,这个价格太高了,因为每个毫秒都很重要。 每秒丢失几帧(FPS)会大大降低总体用户体验。 - 要绘制 AR,您必须添加一个透明的叠加视图以显示 AR。 参考 Apple 准则,应避免使用不透明的图层,因为它们对移动处理器来说很难混合。
类别AVCaptureDevice
和AVCaptureVideoDataOutput
允许您配置,捕获和指定 32 bpp BGRA 格式的未处理视频帧。 您还可以设置输出帧的所需分辨率。 但是,它确实会影响整体性能 ,因为帧越大,就需要更多的处理时间和内存。
高性能视频捕获是一个很好的选择。 AVFoundation API 提供了一种更快,更优雅的方法来直接从相机抓取帧。 但首先,让我们看一下下图,其中显示了 iOS 的捕获过程:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KlpEBwO6-1681871753491)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_07.jpg)]
AVCaptureSession
是我们应该创建的根捕获对象。 捕获会话需要两个组件-输入和输出。 输入设备可以是物理设备(摄像机)或视频文件(未在图中显示)。 在我们的情况下,它是内置摄像头(正面或背面)。 可以通过以下接口之一显示输出设备:
AVCaptureMovieFileOutput
AVCaptureStillImageOutput
AVCaptureVideoPreviewLayer
AVCaptureVideoDataOutput
AVCaptureMovieFileOutput
接口用于将视频录制到文件,AVCaptureStillImageOutput
接口用于制作静态图像,AVCaptureVideoPreviewLayer
接口用于在屏幕上播放视频预览。 我们对AVCaptureVideoDataOutput
界面感兴趣,因为它可以直接访问视频数据。
注意
iOS 平台基于 Objective-C 编程语言构建。 因此,要使用 AVFoundation 框架,我们的类也必须用 Objective-C 编写。 在本节中,所有代码清单均使用 Objective-C 语言。
为了封装视频捕获过程,我们创建了VideoSource
接口,如以下代码所示:
@protocol VideoSourceDelegate<NSObject>
-(void)frameReady:(BGRAVideoFrame) frame;
@end
@interface VideoSource : NSObject<AVCaptureVideoDataOutputSampleBufferDelegate>
{
}
@property (nonatomic, retain) AVCaptureSession *captureSession;
@property (nonatomic, retain) AVCaptureDeviceInput *deviceInput;
@property (nonatomic, retain) id<VideoSourceDelegate> delegate;
- (bool) startWithDevicePosition:(AVCaptureDevicePosition)devicePosition;
- (CameraCalibration) getCalibration;
- (CGSize) getFrameSize;
@end
在此回调中,我们锁定图像缓冲区以防止被任何新帧修改,获取指向图像数据和帧尺寸的指针。 然后,我们构造临时的 BGRAVideoFrame 对象,该对象通过特殊的委托传递给外部。 该代表具有以下原型:
代码语言:javascript复制@protocol VideoSourceDelegate<NSObject>
-(void)frameReady:(BGRAVideoFrame) frame;
@end
在VideoSourceDelegate
中,VideoSource
接口通知用户代码新帧可用。
下面列出了视频捕获的初始化的分步指南:
- 创建
AVCaptureSession
的实例并设置捕获会话质量预设。 - 选择并创建
AVCaptureDevice
。 您可以选择前置或后置摄像头,也可以使用默认摄像头。 - 使用创建的捕获设备初始化
AVCaptureDeviceInput
并将其添加到捕获会话。 - 创建
AVCaptureVideoDataOutput
的实例,并使用视频帧,回调委托的格式对其进行初始化,然后分派队列。 - 将捕获输出添加到捕获会话对象。
- 启动捕获会话。
让我们更详细地解释其中一些步骤。 创建捕获会话后,我们可以指定所需的质量预设,以确保获得最佳性能。 我们不需要处理高清质量的视频,因此640 x 480
或更低的帧分辨率是一个不错的选择:
- (id)init
{
if ((self = [super init]))
{
AVCaptureSession * capSession = [[AVCaptureSession alloc] init];
if ([capSession canSetSessionPreset:AVCaptureSessionPreset640x480])
{
[capSession setSessionPreset:AVCaptureSessionPreset640x480];
NSLog(@"Set capture session preset AVCaptureSessionPreset640x480");
}
else if ([capSession canSetSessionPreset:AVCaptureSessionPresetLow])
{
[capSession setSessionPreset:AVCaptureSessionPresetLow];
NSLog(@"Set capture session preset AVCaptureSessionPresetLow");
}
self.captureSession = capSession;
}
return self;
}
注意
始终使用适当的 API 检查硬件功能; 不能保证每个摄像机都可以设置特定的会话预设。
在创建捕获会话之后,我们应该添加捕获输入-AVCaptureDeviceInput
的实例将代表物理相机设备。 cameraWithPosition
函数是一个辅助函数,可将相机设备返回到请求的位置(前,后或默认):
- (bool) startWithDevicePosition:(AVCaptureDevicePosition)devicePosition
{
AVCaptureDevice *videoDevice = [self cameraWithPosition:devicePosition];
if (!videoDevice)
return FALSE;
{
NSError *error;
AVCaptureDeviceInput *videoIn = [AVCaptureDeviceInput
deviceInputWithDevice:videoDevice error:&error];
self.deviceInput = videoIn;
if (!error)
{
if ([[self captureSession] canAddInput:videoIn])
{
[[self captureSession] addInput:videoIn];
}
else
{
NSLog(@"Couldn't add video input");
return FALSE;
}
}
else
{
NSLog(@"Couldn't create video input");
return FALSE;
}
}
[self addRawViewOutput];
[captureSession startRunning];
return TRUE;
}
请注意错误处理代码。 在重要的事情上要注意返回值,因为使用硬件设置是一个好习惯。 否则,您的代码可能会在意外情况下崩溃,而不会通知用户发生了什么。
我们创建了一个捕获会话,并添加了视频帧的来源。 现在是时候添加一个接收器—一个将接收实际帧数据的对象。 AVCaptureVideoDataOutput
类用于处理视频流中的未压缩帧。 摄像机可以提供 BGRA,CMYK 或简单的灰度颜色模型的帧。 出于我们的目的,BGRA 颜色模型最适合所有人,因为我们将使用此框架进行可视化和图像处理。 以下代码显示addRawViewOutput
函数:
- (void) addRawViewOutput
{
/*We setupt the output*/
AVCaptureVideoDataOutput *captureOutput = [[AVCaptureVideoDataOutput alloc] init];
/*While a frame is processes in -captureOutput:didOutputSampleBuffer:fromConnection: delegate methods no other frames are added in the queue.
If you don't want this behaviour set the property to NO */
captureOutput.alwaysDiscardsLateVideoFrames = YES;
/*We create a serial queue to handle the processing of our frames*/
dispatch_queue_t queue;
queue = dispatch_queue_create("com.Example_MarkerBasedAR.cameraQueue",
NULL);
[captureOutput setSampleBufferDelegate:self queue:queue];
dispatch_release(queue);
// Set the video output to store frame in BGRA (It is supposed to be faster)
NSString* key = (NSString*)kCVPixelBufferPixelFormatTypeKey;
NSNumber* value = [NSNumber
numberWithUnsignedInt:kCVPixelFormatType_32BGRA];
NSDictionary* videoSettings = [NSDictionary dictionaryWithObject:value
forKey:key];
[captureOutput setVideoSettings:videoSettings];
// Register an output
[self.captureSession addOutput:captureOutput];
}
现在,终于配置了捕获会话。 启动后,它将捕获相机中的帧并将其发送给用户代码。 当新帧可用时,AVCaptureSession
对象将执行captureOutput: didOutputSampleBuffer:fromConnection
回调。 在此函数中,我们将执行次要的数据转换操作,以更可用的格式获取图像数据并将其传递给用户代码:
- (void)captureOutput:(AVCaptureOutput *)captureOutput
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
fromConnection:(AVCaptureConnection *)connection
{
// Get a image buffer holding video frame
CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
// Lock the image buffer
CVPixelBufferLockBaseAddress(imageBuffer,0);
// Get information about the image
uint8_t *baseAddress = (uint8_t *)CVPixelBufferGetBaseAddress(imageBuffer);
size_t width = CVPixelBufferGetWidth(imageBuffer);
size_t height = CVPixelBufferGetHeight(imageBuffer);
size_t stride = CVPixelBufferGetBytesPerRow(imageBuffer);
BGRAVideoFrame frame = {width, height, stride, baseAddress};
[delegate frameReady:frame];
/*We unlock the image buffer*/
CVPixelBufferUnlockBaseAddress(imageBuffer,0);
}
我们获得了一个用于存储帧数据的图像缓冲区的引用。 然后我们将其锁定,以防止被新帧修改。 现在,我们可以独占访问帧数据。 借助 CoreVideo API,我们可以获得图像尺寸,步幅(每行像素数)以及指向图像数据开头的指针。
注意
我提请您注意回调代码中的CVPixelBufferLockBaseAddress
/ CVPixelBufferUnlockBaseAddress
函数调用。 在我们锁定像素缓冲区之前,它可以保证其数据的一致性和正确性。 只有获得锁定后,才能读取像素。 完成后,别忘了解锁它,以使操作系统可以用新数据填充它。
标记检测
标记通常被设计为一个矩形图像,其中包含黑色和白色区域。 由于已知的限制,标记物检测过程很简单。 首先,我们需要在输入图像上找到闭合轮廓并将其内部的图像扭曲成矩形,然后根据我们的标记模型进行检查。
在此示例中,将使用5 x 5
标记。 看起来是这样的:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZPT8McT1-1681871753491)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_08.jpg)]
在本书的示例项目中,标记检测例程封装在[HTG2] MarkerDetector
类中:
/**
* A top-level class that encapsulate marker detector algorithm
*/
class MarkerDetector
{
public:
/**
* Initialize a new instance of marker detector object
* @calibration[in] - Camera calibration necessary for pose estimation.
*/
MarkerDetector(CameraCalibration calibration);
void processFrame(const BGRAVideoFrame& frame);
const std::vector<Transformation>& getTransformations() const;
protected:
bool findMarkers(const BGRAVideoFrame& frame, std::vector<Marker>&
detectedMarkers);
void prepareImage(const cv::Mat& bgraMat,
cv::Mat& grayscale);
void performThreshold(const cv::Mat& grayscale,
cv::Mat& thresholdImg);
void findContours(const cv::Mat& thresholdImg,
std::vector<std::vector<cv::Point> >& contours,
int minContourPointsAllowed);
void findMarkerCandidates(const std::vector<std::vector<cv::Point> >&
contours, std::vector<Marker>& detectedMarkers);
void detectMarkers(const cv::Mat& grayscale,
std::vector<Marker>& detectedMarkers);
void estimatePosition(std::vector<Marker>& detectedMarkers);
private:
};
为了帮助您更好地了解标记检测例程,将显示视频中一帧的逐步处理。 以从 iPad 相机拍摄的源图像为例:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-arfGl0gt-1681871753491)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_09.jpg)]
标记识别
这是标记检测例程的工作流程:
- 将输入图像转换为灰度。
- 执行二进制阈值操作。
- 检测轮廓。
- 搜索可能的标记。
- 检测和解码标记。
- 估计标记 3D 姿势。
灰度转换
必须转换为灰度,因为标记通常只包含黑白块,并且在灰度图像上使用它们更容易操作。 幸运的是,OpenCV 颜色转换非常简单。
请看一下下面的 C 代码清单:
代码语言:javascript复制void MarkerDetector::prepareImage(const cv::Mat& bgraMat, cv::Mat& grayscale)
{
// Convert to grayscale
cv::cvtColor(bgraMat, grayscale, CV_BGRA2GRAY);
}
此函数会将输入的 BGRA 图像转换为灰度(如果需要,它将分配图像缓冲区)并将结果放入第二个参数中。 所有其他步骤将对灰度图像执行。
图像二值化
二值化操作将图像的每个像素转换为黑色(零强度)或白色(全强度)。 查找轮廓需要此步骤。 有几种阈值方法; 每个方面都有强项和弱项。 最简单,最快的方法是绝对阈值。 在这种方法中,结果值取决于当前像素强度和某个阈值。 如果像素强度大于阈值,则结果将为白色(255);否则,结果为白色(255)。 否则将为黑色(0)。
这种方法有一个很大的缺点-它取决于照明条件和柔和的强度变化。 更优选的方法是自适应阈值。 该方法的主要区别是使用了被检查像素周围给定半径的所有像素。 使用平均强度可获得良好的结果,并确保更可靠的角点检测。
以下代码段显示了MarkerDetector
函数:
void MarkerDetector::performThreshold(const cv::Mat& grayscale, cv::Mat& thresholdImg)
{
cv::adaptiveThreshold(grayscale, // Input image
thresholdImg,// Result binary image
255, //
cv::ADAPTIVE_THRESH_GAUSSIAN_C, //
cv::THRESH_BINARY_INV, //
7, //
7 //
);
}
将自适应阈值应用于输入图像后,所得图像看起来类似于以下图像:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X19vRmlH-1681871753491)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_10.jpg)]
每个标记通常看起来像一个带有黑色和白色区域的正方形图形。 因此,定位标记的最佳方法是找到闭合轮廓,并使用 4 个顶点的多边形对其进行近似。
轮廓检测
cv::findCountours
函数将检测输入二进制图像上的轮廓:
void MarkerDetector::findContours(const cv::Mat& thresholdImg,
std::vector<std::vector<cv::Point> >& contours,
int minContourPointsAllowed)
{
std::vector< std::vector<cv::Point> > allContours;
cv::findContours(thresholdImg, allContours, CV_RETR_LIST, CV_CHAIN_APPROX_NONE);
contours.clear();
for (size_t i=0; i<allContours.size(); i )
{
int contourSize = allContours[i].size();
if (contourSize > minContourPointsAllowed)
{
contours.push_back(allContours[i]);
}
}
}
此函数的返回值是一个多边形列表,其中每个多边形代表一个轮廓。 该函数将跳过其周边像素值设置为小于minContourPointsAllowed
变量的值的轮廓。 这是因为我们对小轮廓不感兴趣。 (它们可能不包含任何标记,或者由于标记尺寸太小而无法检测到轮廓。)
下图显示了检测到的轮廓的可视化:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QPr19aE1-1681871753496)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_11.jpg)]
候选者搜索
找到轮廓后,执行多边形逼近阶段。 这样做是为了减少描述轮廓形状的点的数量。 过滤掉没有标记的区域是一项很好的质量检查,因为它们总是可以用包含四个顶点的多边形来表示。 如果近似多边形的顶点多于或少于 4 个,则绝对不是我们想要的。 以下代码实现了这个想法:
代码语言:javascript复制void MarkerDetector::findCandidates
(
const ContoursVector& contours,
std::vector<Marker>& detectedMarkers
)
{
std::vector<cv::Point> approxCurve;
std::vector<Marker> possibleMarkers;
// For each contour, analyze if it is a parallelepiped likely to be the
marker
for (size_t i=0; i<contours.size(); i )
{
// Approximate to a polygon
double eps = contours[i].size() * 0.05;
cv::approxPolyDP(contours[i], approxCurve, eps, true);
// We interested only in polygons that contains only four points
if (approxCurve.size() != 4)
continue;
// And they have to be convex
if (!cv::isContourConvex(approxCurve))
continue;
// Ensure that the distance between consecutive points is large enough
float minDist = std::numeric_limits<float>::max();
for (int i = 0; i < 4; i )
{
cv::Point side = approxCurve[i] - approxCurve[(i 1)%4];
float squaredSideLength = side.dot(side);
minDist = std::min(minDist, squaredSideLength);
}
// Check that distance is not very small
if (minDist < m_minContourLengthAllowed)
continue;
// All tests are passed. Save marker candidate:
Marker m;
for (int i = 0; i<4; i )
m.points.push_back( cv::Point2f(approxCurve[i].x,approxCurve[i].y) );
// Sort the points in anti-clockwise order
// Trace a line between the first and second point.
// If the third point is at the right side, then the points are anti-
clockwise
cv::Point v1 = m.points[1] - m.points[0];
cv::Point v2 = m.points[2] - m.points[0];
double o = (v1.x * v2.y) - (v1.y * v2.x);
if (o < 0.0) //if the third point is in the left side, then
sort in anti-clockwise order
std::swap(m.points[1], m.points[3]);
possibleMarkers.push_back(m);
}
// Remove these elements which corners are too close to each other.
// First detect candidates for removal:
std::vector< std::pair<int,int> > tooNearCandidates;
for (size_t i=0;i<possibleMarkers.size();i )
{
const Marker& m1 = possibleMarkers[i];
//calculate the average distance of each corner to the nearest corner
of the other marker candidate
for (size_t j=i 1;j<possibleMarkers.size();j )
{
const Marker& m2 = possibleMarkers[j];
float distSquared = 0;
for (int c = 0; c < 4; c )
{
cv::Point v = m1.points[c] - m2.points[c];
distSquared = v.dot(v);
}
distSquared /= 4;
if (distSquared < 100)
{
tooNearCandidates.push_back(std::pair<int,int>(i,j));
}
}
}
// Mark for removal the element of the pair with smaller perimeter
std::vector<bool> removalMask (possibleMarkers.size(), false);
for (size_t i=0; i<tooNearCandidates.size(); i )
{
float p1 = perimeter(possibleMarkers[tooNearCandidates[i].first
].points);
float p2 =
perimeter(possibleMarkers[tooNearCandidates[i].second].points);
size_t removalIndex;
if (p1 > p2)
removalIndex = tooNearCandidates[i].second;
else
removalIndex = tooNearCandidates[i].first;
removalMask[removalIndex] = true;
}
// Return candidates
detectedMarkers.clear();
for (size_t i=0;i<possibleMarkers.size();i )
{
if (!removalMask[i])
detectedMarkers.push_back(possibleMarkers[i]);
}
}
现在我们已经获得了可能是标记的平行六面体列表。 要验证它们是否是标记,我们需要执行三个步骤:
- 首先,我们应该删除透视投影,以便获得矩形区域的正视图。
- 然后,我们使用大津算法对图像进行阈值处理。 该算法采用双峰分布,并找到使类别外方差最大化的阈值,同时保持较低的类别内方差。
- 最后,我们执行标记代码的识别。 如果是标记,则具有内部代码。 标记分为
7 x 7
的网格,内部7 x 7
的单元格包含 ID 信息。 其余部分对应于外部黑色边框。 在这里,我们首先检查外部黑色边框是否存在。 然后,我们读取内部的7 x 7
单元并检查它们是否提供有效的代码。 (可能需要旋转代码以获得有效的代码。)
为了获得矩形标记图像,我们必须使用透视变换使输入图像不变形。 可以借助cv::getPerspectiveTransform
函数来计算该矩阵。 它从四对对应点中找到透视变换。 第一个参数是图像空间中的标记坐标,第二个点对应于方形标记图像的坐标。 估计的转换会将标记转换为正方形,然后让我们对其进行分析:
cv::Mat canonicalMarker;
Marker& marker = detectedMarkers[i];
// Find the perspective transfomation that brings current marker to rectangular form
cv::Mat M = cv::getPerspectiveTransform(marker.points, m_markerCorners2d);
// Transform image to get a canonical marker image
cv::warpPerspective(grayscale, canonicalMarker, M, markerSize);
图像扭曲使用透视变换将图像变换为矩形形式:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b0drZ9F8-1681871753496)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_12.jpg)]
现在我们可以测试图像,以验证它是否是有效的标记图像。 然后,我们尝试使用标记代码提取位掩码。 因为我们期望标记仅包含黑白颜色,所以我们可以执行大津阈值处理以去除灰色像素并仅保留黑白像素:
代码语言:javascript复制//threshold image
cv::threshold(markerImage, markerImage, 125, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2wlJL7cm-1681871753496)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_13.jpg)]
标记代码识别
每个标记都有一个内部代码,该代码由 5 个字(每个 5 位)给出。 使用的编码是对汉明码的略微修改。 总体上,每个字在使用的 5 位中只有 2 位信息。 其他 3 个用于错误检测。 因此,我们最多可以有 1024 个不同的 ID。
与汉明码的主要区别在于第一位(位 3 和 5 的奇偶校验)被反转。 因此,ID 0(在汉明码中为 00000)在我们的代码中变为 10000。 这样做的目的是为了防止整个黑色矩形成为有效的标记 ID,目的是减少环境物体误报的可能性。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zFnVJZMS-1681871753496)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_14.jpg)]
计算每个单元的黑白像素数量,我们得到带有标记代码的5 x 5
位掩码。 要计算某个图像上的非零像素数,请使用cv::countNonZero
函数。 此函数对给定的 1D 或 2D 数组中的非零数组元素进行计数。 cv::Mat
类型可以返回子图像视图,即cv::Mat
的新实例,其中包含原始图像的一部分。 例如,如果您有一个cv::Mat
大小为400 x 400
,则下面的代码段将从(10, 10)
开始为5 x 5
图像块创建一个子矩阵:
cv::Mat src(400,400,CV_8UC1);
cv::Rect r(10,10,50,50);
cv::Mat subView = src(r);
读取标记代码
使用这种技术,我们可以轻松地在标记板上找到黑白单元格:
代码语言:javascript复制 cv::Mat bitMatrix = cv::Mat::zeros(5,5,CV_8UC1);
//get information(for each inner square, determine if it is black or white)
for (int y=0;y<5;y )
{
for (int x=0;x<5;x )
{
int cellX = (x 1)*cellSize;
int cellY = (y 1)*cellSize;
cv::Mat cell = grey(cv::Rect(cellX,cellY,cellSize,cellSize));
int nZ = cv::countNonZero(cell);
if (nZ> (cellSize*cellSize) /2)
bitMatrix.at<uchar>(y,x) = 1;
}
}
看下图的 。 根据相机的角度,同一标记可以有四种可能的表示形式:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E3vhcTku-1681871753497)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_15.jpg)]
由于标记图片有四个可能的方向,因此我们必须找到正确的标记位置。 记住,我们为信息的每两位引入了三个奇偶校验位。 在他们的帮助下,我们可以找到每种可能的标记方向的汉明距离。 正确的标记位置的汉明距离误差为零,而其他旋转不会。
这是一个代码片段,该代码片段将位矩阵旋转四次并找到正确的标记方向:
代码语言:javascript复制 //check all possible rotations
cv::Mat rotations[4];
int distances[4];
rotations[0] = bitMatrix;
distances[0] = hammDistMarker(rotations[0]);
std::pair<int,int> minDist(distances[0],0);
for (int i=1; i<4; i )
{
//get the hamming distance to the nearest possible word
rotations[i] = rotate(rotations[i-1]);
distances[i] = hammDistMarker(rotations[i]);
if (distances[i] < minDist.first)
{
minDist.first = distances[i];
minDist.second = i;
}
}
该代码以使汉明距离量度的误差最小的方式找到位矩阵的方向。 对于正确的标记 ID,此误差应为零; 如果不是,则表示我们遇到了错误的标记模式(图像损坏或标记检测错误)。
标记位置优化
找到正确的标记方向后,我们分别旋转标记的角以符合其顺序:
代码语言:javascript复制//sort the points so that they are always in the same order
// no matter the camera orientation
std::rotate(marker.points.begin(), marker.points.begin() 4 - nRotations,
marker.points.end());
在检测到标记并对其 ID 进行解码之后,我们将优化其角点。 当我们估计 3D 标记位置时,此操作将帮助我们进行下一步。 为了找到亚像素精度的角点位置,使用cv::cornerSubPix
函数:
std::vector<cv::Point2f> preciseCorners(4 * goodMarkers.size());
for (size_t i=0; i<goodMarkers.size(); i )
{
Marker& marker = goodMarkers[i];
for (int c=0;c<4;c )
{
preciseCorners[i*4 c] = marker.points[c];
}
}
cv::cornerSubPix(grayscale, preciseCorners, cvSize(5,5), cvSize(-1,-1), cvTermCriteria(CV_TERMCRIT_ITER,30,0.1));
//copy back
for (size_t i=0;i<goodMarkers.size();i )
{
Marker&marker = goodMarkers[i];
for (int c=0;c<4;c )
{
marker.points[c] = preciseCorners[i*4 c];
}
}
第一步是为此函数准备输入数据。 我们将顶点列表复制到输入数组。 然后,我们将cv::cornerSubPix
传递给实际图像,点列表以及影响位置改进质量和性能的参数集。 完成后,我们将精炼的位置复制回标记角,如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6YI20c7h-1681871753497)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_16.jpg)]
由于其复杂性,我们在标记检测的早期阶段不使用cornerSubPix
。 对于大量的点(就计算时间而言)调用此函数非常昂贵。 因此,我们仅对有效标记执行此操作。
将标记放置在 3D 中
增强现实尝试将真实世界的对象与虚拟内容融合在一起。 要将 3D 模型放置在场景中,我们需要了解用于获取视频帧的摄像机的姿势。 我们将在笛卡尔坐标系中使用欧几里得变换来表示这样的姿势。
标记在 3D 中的位置及其在 2D 中的对应投影受以下公式限制:
代码语言:javascript复制P = A * [R | T] * M
其中:
M
表示 3D 空间中的点[R | T]
表示表示欧几里德变换的[3 | 4]
矩阵A
表示摄像机矩阵或固有参数矩阵P
表示M
在屏幕空间中的投影
在执行标记检测步骤之后,我们现在知道 2D 中四个标记角的位置(屏幕空间中的投影)。 在下一节中,您将学习如何获取A
矩阵和M
向量参数,以及如何计算[R | T]变换。
相机校准
每个摄像机镜头都有独特的参数,例如焦距,主点和镜头畸变模型。 查找相机固有参数的过程称为相机校准。 相机校准过程对于增强现实应用非常重要,因为它描述了输出图像上的透视变换和镜头失真。 为了在增强现实中获得最佳用户体验,应该使用相同的透视投影来完成增强对象的可视化。
要校准相机,我们需要特殊的图案图像(棋盘或白色背景上的黑色圆圈)。 从不同的角度来看,正在校准的相机会对此模式拍摄 10-15 张照片。 然后,校准算法会找到最佳的相机固有参数和失真向量:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fyjq8hMh-1681871753497)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_17.jpg)]
为了在我们的程序中表示摄像机的校准,我们使用CameraCalibration
类:
/**
* A camera calibration class that stores intrinsic matrix and distorsion coefficients.
*/
class CameraCalibration
{
public:
CameraCalibration();
CameraCalibration(float fx, float fy, float cx, float cy);
CameraCalibration(float fx, float fy, float cx, float cy, float
distorsionCoeff[4]);
void getMatrix34(float cparam[3][4]) const;
const Matrix33& getIntrinsic() const;
const Vector4& getDistorsion() const;
private:
Matrix33 m_intrinsic;
Vector4 m_distorsion;
};
校准程序的详细说明不在本章范围之内。 请参考“OpenCV camera_calibration
示例”或《OpenCV:在投影关系中估计图像》,以获取其他信息和源代码。
对于此示例 ,我们提供了所有现代 iOS 设备(iPad 2,iPad 3 和 iPhone 4)的内部参数。
标记姿势估计
利用标记角的精确位置,我们可以估计相机和 3D 空间中标记之间的转换。 将该操作称为根据 2D-3D 对应关系的姿势估计。 姿势估计过程会在相机和对象之间找到一个欧几里得变换(仅包含旋转和平移分量)。
让我们看一下下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yFTrQqy4-1681871753497)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_18.jpg)]
C
用于表示相机中心。P1-P4
点是世界坐标系中的 3D 点, p1-p4
点是它们在相机像面上的投影。 我们的目标是使用本征矩阵和图像平面(P1-P4
)上的已知点投影和相机C
找到已知标记位置之间的相对转换。 但是,我们在哪里可以获得 3D 空间中标记位置(p1-p4
)的坐标? 我们想象他们。 由于我们的标记始终具有正方形形状,并且所有顶点都位于一个平面上,因此我们可以如下定义它们的角:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WqwSgaym-1681871753497)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_19.jpg)]
我们将标记放在 XY 平面(Z 分量为零)中,标记中心对应于(0, 0, 0)
点。 这是一个很好的提示,因为在这种情况下,我们的坐标系的起点将位于标记的中心(Z 轴垂直于标记平面)。
要使用已知的 2D-3D 对应关系查找摄像机位置,可以使用cv::solvePnP
函数:
void solvePnP(const Mat& objectPoints, const Mat& imagePoints, const Mat&
cameraMatrix, const Mat& distCoeffs, Mat& rvec, Mat& tvec, bool
useExtrinsicGuess=false);
objectPoints
数组是对象坐标空间中对象点的输入数组。 可以在此处传递std::vector<cv::Point3f>
。 也可以将 OpenCV 矩阵3 x N
或N x 3
(其中N
是点数)作为输入参数传递。 在这里,我们传递 3D 空间(四个点的向量)中标记坐标的列表。
imagePoints
数组是相应图像点(或投影)的数组。 此自变量也可以是2 x N
或N x 2
的std::vector<cv::Point2f>
或cv::Mat
,其中N
是点数。 在这里,我们传递找到的标记角的列表。
cameraMatrix
:这是3 x 3
摄像机固有矩阵。distCoeffs
:这是失真系数(k1, k2, p1, p2, k3)
的输入4 x 1
、1 x 4
、5 x 1
或1 x 5
向量。 如果为NULL
,则所有失真系数均设置为 0。rvec
:这是输出旋转向量(与tvec
一起)将点从模型坐标系带到摄像机坐标系。tvec
:这是输出转换向量。useExtrinsicGuess
:如果为true
,则该函数将分别使用提供的rvec
和tvec
向量作为旋转向量和平移向量的初始近似值,并将对其进行进一步优化。
该函数以最小化重投影误差(即观察到的投影imagePoints
与投影的objectPoints
之间的距离平方的总和)的方式计算相机变换。
估计的变换由旋转(rvec
)和平移分量(tvec
)定义。 这也称为欧几里得变换或刚性变换。
刚性转换的正式定义是,当作用于任何向量v
时,产生以下形式的转换向量T(v)
的转换:
T(v) = Rv t
其中R^T = R^(-1)
(即R
是正交变换),t
是给出原点平移的向量。 此外,适当的刚性转换还具有
It(R) = 1
这意味着R
不产生反射,因此代表旋转(保留方向的正交变换)。
为了从旋转向量获得3 x 3
旋转矩阵,使用函数cv::Rodrigues
。 此函数转换由旋转向量表示的旋转,并返回其等效旋转矩阵。
注意
因为cv::solvePnP
根据 3D 空间中的标记姿势找到了相机位置,所以我们必须反转找到的变换。 生成的变换将描述相机坐标系中的标记变换,这对于渲染过程而言更为友好。
这是estimatePosition
函数的清单 ,可找到检测到的标记的位置:
void MarkerDetector::estimatePosition(std::vector<Marker>& detectedMarkers)
{
for (size_t i=0; i<detectedMarkers.size(); i )
{
Marker& m = detectedMarkers[i];
cv::Mat Rvec;
cv::Mat_<float> Tvec;
cv::Mat raux,taux;
cv::solvePnP(m_markerCorners3d, m.points, camMatrix, distCoeff,raux,taux);
raux.convertTo(Rvec,CV_32F);
taux.convertTo(Tvec ,CV_32F);
cv::Mat_<float> rotMat(3,3);
cv::Rodrigues(Rvec, rotMat);
// Copy to transformation matrix
m.transformation = Transformation();
for (int col=0; col<3; col )
{
for (int row=0; row<3; row )
{
m.transformation.r().mat[row][col] = rotMat(row,col); // Copy rotation
component
}
m.transformation.t().data[col] = Tvec(col); // Copy translation
component
}
// Since solvePnP finds camera location, w.r.t to marker pose, to get
marker pose w.r.t to the camera we invert it.
m.transformation = m.transformation.getInverted();
}
渲染 3D 虚拟对象
因此,到目前为止,您已经知道如何在图像上找到标记,以计算它们相对于相机在空间中的确切位置。 现在该画点东西了。 如前所述,要渲染场景,我们将使用 OpenGL 函数。 3D 可视化是增强现实的核心部分。 OpenGL 提供了用于创建高质量渲染的所有基本功能。
注意
有大量的商业和开源 3D 引擎(Unity ,虚幻引擎, Ogre 等)。 但是所有这些引擎都使用 OpenGL 或 DirectX 将命令传递给视频卡。 DirectX 是专有 API,仅 Windows 平台支持。 因此,OpenGL 是构建跨平台渲染系统的第一个也是最后一个候选对象。
了解渲染系统的原理将为您提供必要的经验和知识,以供将来使用这些引擎或编写自己的引擎。
创建 OpenGL 渲染层
为了在应用中使用 OpenGL 函数,您应该获得一个 iOS 图形上下文表面,它将向用户呈现渲染的场景。 该上下文通常绑定到用户看到的视图。 以下屏幕快照显示了 XCode 的界面构建器中应用接口的层次结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4qhEaSAl-1681871753498)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_20.jpg)]
为了封装 OpenGL 上下文初始化逻辑,我们引入EAGLView
类:
@class EAGLContext;
// This class wraps the CAEAGLLayer from CoreAnimation into a convenient UIView subclass.
// The view content is basically an EAGL surface you render your OpenGL scene into.
// Note that setting the view non-opaque will only work if the EAGL surface has an alpha channel.
@interface EAGLView : UIView
{
@private
// The OpenGL ES names for the framebuffer and renderbuffer used to render
to this view.
GLuint defaultFramebuffer, colorRenderbuffer;
}
@property (nonatomic, retain) EAGLContext *context;
// The pixel dimensions of the CAEAGLLayer.
@property (readonly) GLint framebufferWidth;
@property (readonly) GLint framebufferHeight;
- (void)setFramebuffer;
- (BOOL)presentFramebuffer;
- (void)initContext;
@end
此类连接到接口定义文件中的视图,因此,在加载NIB
文件时,运行时将实例化EAGLView
的新实例。 创建后,它将接收来自 iOS 的事件并初始化 OpenGL 渲染上下文。
以下是显示initWithCoder
函数的代码清单:
//The EAGL view is stored in the nib file. When it's unarchived it's sent -initWithCoder:.
- (id)initWithCoder:(NSCoder*)coder
{
self = [super initWithCoder:coder];
if (self) {
CAEAGLLayer *eaglLayer = (CAEAGLLayer *)self.layer;
eaglLayer.opaque = TRUE;
eaglLayer.drawableProperties = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithBool:FALSE],
kEAGLDrawablePropertyRetainedBacking,
kEAGLColorFormatRGBA8,
kEAGLDrawablePropertyColorFormat,
nil];
[self initContext];
}
return self;
}
- (void)createFramebuffer
{
if (context && !defaultFramebuffer) {
[EAGLContext setCurrentContext:context];
// Create default framebuffer object.
glGenFramebuffers(1, &defaultFramebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, defaultFramebuffer);
// Create color render buffer and allocate backing store.
glGenRenderbuffers(1, &colorRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, colorRenderbuffer);
[context renderbufferStorage:GL_RENDERBUFFER fromDrawable:(CAEAGLLayer *)self.layer];
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &framebufferWidth);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &framebufferHeight);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_RENDERBUFFER, colorRenderbuffer);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
NSLog(@"Failed to make complete framebuffer object %x",
glCheckFramebufferStatus(GL_FRAMEBUFFER));
//glClearColor(0, 0, 0, 0);
NSLog(@"Framebuffer created");
}
}
渲染 AR 场景
正如所见,EAGLView
类不包含用于 3D 对象和视频可视化的方法。 这是有目的的。 EAGLView
的任务是提供渲染上下文。 职责分离使我们以后可以更改可视化的逻辑。
为了可视化增强现实,我们将创建一个单独的类,称为VisualizationController
:
@interface SimpleVisualizationController : NSObject<VisualizationController>
{
EAGLView * m_glview;
GLuint m_backgroundTextureId;
std::vector<Transformation> m_transformations;
CameraCalibration m_calibration;
CGSize m_frameSize;
}
-(id) initWithGLView:(EAGLView*)view calibration:(CameraCalibration) calibration frameSize:(CGSize) size;
-(void) drawFrame;
-(void) updateBackground:(BGRAVideoFrame) frame;
-(void) setTransformationList:(const std::vector<Transformation>&) transformations;
drawFrame
函数将 AR 渲染到给定的EAGLView
目标视图上。 它执行以下步骤:
- 清除场景。
- 设置正投影以绘制背景。
- 在视口上绘制从相机接收的最新图像。
- 根据相机的固有参数设置透视投影。
- 对于每个检测到的标记,它将坐标系移动到 3D 中的标记位置。 (它将
4 x 4
转换矩阵放入 OpenGl 模型视图矩阵。) - 渲染任意 3D 对象。
- 显示帧缓冲区。
准备绘制框架时,将调用drawFrame
函数。 当新的相机帧已上载到视频存储器并且标记检测阶段已完成时,就会发生这种情况。
以下代码显示drawFrame
函数:
- (void)drawFrame
{
// Set the active framebuffer
[m_glview setFramebuffer];
// Draw a video on the background
[self drawBackground];
// Draw 3D objects on the position of the detected markers
[self drawAR];
// Present framebuffer
bool ok = [m_glview presentFramebuffer];
int glErCode = glGetError();
if (!ok || glErCode != GL_NO_ERROR)
{
std::cerr << "GL error detected. Error code:" << glErCode << std::endl;
}
}
绘制背景非常容易。 我们设置正交投影并使用当前帧中的图像绘制全屏纹理。 这是使用 GLES 1 API 进行此操作的代码清单:
代码语言:javascript复制- (void) drawBackground
{
GLfloat w = m_glview.bounds.size.width;
GLfloat h = m_glview.bounds.size.height;
const GLfloat squareVertices[] =
{
0, 0,
w, 0,
0, h,
w, h
};
static const GLfloat textureVertices[] =
{
1, 0,
1, 1,
0, 0,
0, 1
};
static const GLfloat proj[] =
{
0, -2.f/w, 0, 0,
-2.f/h, 0, 0, 0,
0, 0, 1, 0,
1, 1, 0, 1
};
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(proj);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glDisable(GL_COLOR_MATERIAL);
glEnable(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, m_backgroundTextureId);
// Update attribute values.
glVertexPointer(2, GL_FLOAT, 0, squareVertices);
glEnableClientState(GL_VERTEX_ARRAY);
glTexCoordPointer(2, GL_FLOAT, 0, textureVertices);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
glColor4f(1,1,1,1);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glDisableClientState(GL_VERTEX_ARRAY);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);
glDisable(GL_TEXTURE_2D);
}
场景中人造对象的渲染有些棘手。 首先,我们必须针对相机固有(校准)矩阵调整 OpenGL 投影矩阵。 没有这一步,我们将有错误的透视图。 错误的视角会使人造物体看起来不自然,就好像它们在“空中飞舞”,而不是真实世界的一部分。 正确的视角对于任何增强现实应用都是必不可少的。
这是一个代码片段,可根据相机的内在函数创建一个 OpenGL 投影矩阵:
代码语言:javascript复制- (void)buildProjectionMatrix:(Matrix33)cameraMatrix: (int)screen_width: (int)screen_height: (Matrix44&) projectionMatrix
{
float near = 0.01; // Near clipping distance
float far = 100; // Far clipping distance
// Camera parameters
float f_x = cameraMatrix.data[0]; // Focal length in x axis
float f_y = cameraMatrix.data[4]; // Focal length in y axis (usually the
same?)
float c_x = cameraMatrix.data[2]; // Camera primary point x
float c_y = cameraMatrix.data[5]; // Camera primary point y
projectionMatrix.data[0] = - 2.0 * f_x / screen_width;
projectionMatrix.data[1] = 0.0;
projectionMatrix.data[2] = 0.0;
projectionMatrix.data[3] = 0.0;
projectionMatrix.data[4] = 0.0;
projectionMatrix.data[5] = 2.0 * f_y / screen_height;
projectionMatrix.data[6] = 0.0;
projectionMatrix.data[7] = 0.0;
projectionMatrix.data[8] = 2.0 * c_x / screen_width - 1.0;
projectionMatrix.data[9] = 2.0 * c_y / screen_height - 1.0;
projectionMatrix.data[10] = -( far near ) / ( far - near );
projectionMatrix.data[11] = -1.0;
projectionMatrix.data[12] = 0.0;
projectionMatrix.data[13] = 0.0;
projectionMatrix.data[14] = -2.0 * far * near / ( far - near );
projectionMatrix.data[15] = 0.0;
}
在将这个矩阵加载到 OpenGL 管道后,该绘制一些对象了。 每个变换可以表示为4 x 4
矩阵,并加载到 OpenGL 模型视图矩阵中。 这会将坐标系移动到世界坐标系中的标记位置。
例如,让我们在每个标记的顶部绘制一个坐标轴,以显示其在空间中的方向,并在整个标记上绘制一个带有梯度填充的矩形。 这种可视化将为我们提供可视反馈,表明我们的代码正在按预期工作。
以下是显示drawAR
函数的代码段:
- (void) drawAR
{
Matrix44 projectionMatrix;
[self buildProjectionMatrix:m_calibration.getIntrinsic():m_frameSize.width
:m_frameSize.height :projectionMatrix];
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(projectionMatrix.data);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_NORMAL_ARRAY);
glPushMatrix();
glLineWidth(3.0f);
float lineX[] = {0,0,0,1,0,0};
float lineY[] = {0,0,0,0,1,0};
float lineZ[] = {0,0,0,0,0,1};
const GLfloat squareVertices[] = {
-0.5f, -0.5f,
0.5f, -0.5f,
-0.5f, 0.5f,
0.5f, 0.5f,
};
const GLubyte squareColors[] = {
255, 255, 0, 255,
0, 255, 255, 255,
0, 0, 0, 0,
255, 0, 255, 255,
};
for (size_t transformationIndex=0;
transformationIndex<m_transformations.size(); transformationIndex )
{
const Transformation& transformation =
m_transformations[transformationIndex];
Matrix44 glMatrix = transformation.getInverted().getMat44();
glLoadMatrixf(reinterpret_cast<const GLfloat*>(&glMatrix.data[0]));
// draw data
glVertexPointer(2, GL_FLOAT, 0, squareVertices);
glEnableClientState(GL_VERTEX_ARRAY);
glColorPointer(4, GL_UNSIGNED_BYTE, 0, squareColors);
glEnableClientState(GL_COLOR_ARRAY);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glDisableClientState(GL_COLOR_ARRAY);
float scale = 0.5;
glScalef(scale, scale, scale);
glColor4f(1.0f, 0.0f, 0.0f, 1.0f);
glVertexPointer(3, GL_FLOAT, 0, lineX);
glDrawArrays(GL_LINES, 0, 2);
glColor4f(0.0f, 1.0f, 0.0f, 1.0f);
glVertexPointer(3, GL_FLOAT, 0, lineY);
glDrawArrays(GL_LINES, 0, 2);
glColor4f(0.0f, 0.0f, 1.0f, 1.0f);
glVertexPointer(3, GL_FLOAT, 0, lineZ);
glDrawArrays(GL_LINES, 0, 2);
}
glPopMatrix();
glDisableClientState(GL_VERTEX_ARRAY);
}
如果运行应用,则会得到下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iT5hk0sU-1681871753498)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_02_21.jpg)]
尽管的事实是我们没有使用特殊的 3D 渲染引擎进行场景可视化,但我们拥有所有必要的数据来自行完成此操作。 让我们总结一下我们获得的数据:
- 来自相机设备的 BGRA 格式的帧
- 正确的投影矩阵可以为我们提供适合 AR 场景渲染的透视投影
- 找到的标记姿势列表
您可以轻松地将此数据放入任何 3D 引擎,并创建自己的基于标记的成品 AR 应用
如您所见,具有梯度填充和枢轴的四边形正好放置在标记上。 这是增强现实的关键功能-真实图片和人造物体的无缝融合。
总结
在本章中,我们学习了如何为 iPhone/iPad 设备创建移动增强现实应用。 您了解了如何在 XCode 项目中使用 OpenCV 库来创建令人惊叹的最新应用。 使用 OpenCV 可使您的应用在移动设备上执行实时性能的复杂图像处理计算。
从本章中,您还学习了如何执行初始图像处理(以灰色阴影和二值化进行平移),如何在图像中找到闭合的轮廓并使用多边形对其进行近似,如何在图像中找到标记并对其进行解码,如何计算标记在空间中的位置,以及增强现实中 3D 对象的可视化。
参考
- 《ArUco:基于 OpenCV 的增强现实应用的最小库》
- 《OpenCV 摄像机校准和 3D 重建》
- 《OpenCV:估计图像中的投影关系》
- 《计算机视觉中的多视图几何(第二版)》,RI Hartley 和 A. Zisserman,剑桥大学出版社,ISBN 0-521-54051-8
三、无标记增强现实
在本章中,读者将学习如何使用 OpenCV(用于桌面)创建标准的实时项目,以及如何使用实际环境作为输入而不是打印的方形标记来执行无标记增强现实的新方法。 本章将介绍一些无标记 AR 的理论,并展示如何在有用的项目中应用它。
以下是本章将涉及的主题列表:
- 基于标记与无标记的 AR
- 使用特征描述符在视频上查找任意图像
- 模式姿势估计
- 应用基础架构
- 在 OpenCV 中启用对 OpenGL 可视化的支持
- 渲染增强现实
- 示例
在开始之前,让我给您简要列出本章所需的知识以及所需的软件:
- CMake 的基本知识。 CMake 是一个跨平台的开源构建系统,旨在构建,测试和打包软件。 像 OpenCV 库一样,本章的演示项目也使用 CMake 构建系统。 可以从这个页面下载 CMake。
- 还必须具备 C 编程语言的基本知识。 但是,将详细说明应用源代码的所有复杂部分。
基于标记的 AR 与无标记的 AR
在上一章中,您学习了如何使用称为标记的特殊图像来增强真实场景。 标记的强方面如下:
- 便宜的检测算法
- 抵抗照明变化
标记还具有几个弱点。 它们如下:
- 如果部分重叠则不起作用
- 标记图片必须为黑白
- 在大多数情况下为正方形(因为易于检测)
- 标记的非审美视觉外观
- 与现实世界没有任何共同点
因此,标记是开始使用增强现实的好点。 但是如果您想要更多,该是时候从基于标记的 AR 过渡到无标记的 AR 了。 无标记 AR 是一种基于识别现实世界中存在的对象的技术。 无标记 AR 的目标示例包括:杂志封面,公司徽标,玩具等。 通常,任何具有足够有关场景其余部分的描述性和区分性信息的对象都可以成为无标记 AR 的目标。
无标记 AR 方法的强项是:
- 可用于检测现实世界中的物体
- 即使目标对象部分重叠也可以
- 可以具有任意形式和纹理(实心或平滑梯度纹理除外)
无需标记的 AR 系统可以使用真实的图像和对象将相机放置在 3D 空间中,并在真实图片的顶部呈现醒目的效果。 无标记 AR 的核心是图像识别和物体检测算法。 与形状和内部结构是固定且已知的标记不同,不能以这种方式定义真实对象。 同样,对象可能具有复杂的形状,需要修改的姿态估计算法才能找到其正确的 3D 变换。
注意
为了让您了解无标记 AR,我们将平面图像作为目标。 具有复杂形状的物体将不在此处详细考虑。 我们将在本章稍后讨论 AR 复杂形状的使用。
无标记的 AR 执行大量的 CPU 计算,因此移动设备通常无法确保平滑的 FPS。 在本章中,我们将针对台式机平台,例如 PC 或 Mac。 为此,我们需要一个跨平台的构建系统。 在本章中,我们使用 CMake 构建系统。
使用特征描述符在视频上查找任意图像
图像识别是一种计算机视觉技术,可在输入图像中搜索特定的位图图案。 即使图像被缩放,旋转或具有与原始图像不同的亮度,我们的图像识别算法也应该能够检测到该图案。
我们如何将图案图像与其他图像进行比较? 由于图案可能会受到透视变换的影响,因此很明显我们无法直接比较图案的像素和测试图像。 在这种情况下,特征点和特征描述符会有所帮助。 没有关于特征是什么的通用或确切定义。 确切的定义通常取决于问题或应用的类型。 通常,特征被定义为图像的“有趣”部分,并且特征被用作许多计算机视觉算法的起点。 在本章中,我们将使用特征点术语,这是由中心点,半径和方向定义的图像的一部分。 每种特征检测算法都尝试检测相同的特征点,而与应用的透视变换无关。
特征提取
特征检测是从输入图像中找到兴趣区域的方法。 有很多特征检测算法,它们搜索边缘,角点或斑点。 在我们的案例中,我们对角点检测感兴趣。 角点检测基于图像边缘的分析。 基于角点的边缘检测算法可搜索图像梯度的快速变化。 通常,它是通过在 X 和 Y 方向上寻找图像梯度的一阶导数的极值来完成的。
特征点方向通常被计算为特定区域中主要图像梯度的方向。 旋转或缩放图像时,特征检测算法会重新计算主梯度的方向。 这意味着无论图像旋转如何,特征点的方向都不会改变。 这样的特征称为旋转不变量。
另外,我还必须提到有关尺寸特征点的几点。 一些特征检测算法使用固定大小的特征,而另一些则分别计算每个关键点的最佳大小。 知道特征尺寸后,我们就可以在缩放图像上找到相同的特征点。 这使特征比例不变。
OpenCV 有几种特征检测算法。 它们都是从基类cv::FeatureDetector
派生的。 可以通过两种方式创建特征检测算法:
通过对具体特征检测器类构造器的显式调用:
代码语言:javascript复制cv::Ptr<cv::FeatureDetector> detector = cv::Ptr<cv::FeatureDetector>(new cv::SurfFeatureDetector());
或通过算法名称创建特征检测器:
代码语言:javascript复制cv::Ptr<cv::FeatureDetector> detector = cv::FeatureDetector::create("SURF");
两种方法都有其优点,因此请选择最喜欢的一种。 显式类的创建使您可以将其他参数传递给特征检测器构造器,而按算法名称创建则可以更轻松地在运行时切换算法。
要检测特征点,应调用detect
方法:
std::vector<cv::KeyPoint> keypoints;
detector->detect(image, keypoints);
检测到的特征点放置在keypoints
容器中。 每个关键点都包含其中心,半径,角度和分数,并且与特征点的“质量”或“强度”具有一定的关联性。 每个特征检测算法都有自己的分数计算算法,因此比较由特定检测算法检测到的关键点的分数是有效的。
注意
基于角点的特征检测器使用灰度图像查找特征点。 描述符提取算法也可用于灰度图像。 当然,它们两个都可以隐式地进行颜色转换。 但是在这种情况下,颜色转换将进行两次。 我们可以通过将输入图像进行显式的颜色转换为灰度并将其用于特征检测和描述符提取来提高性能。
如果检测器计算关键点方向和大小,则可以在模式检测中获得最佳结果。 这使关键点对于旋转和缩放不变。 最著名和最强大的关键点检测算法是众所周知的,它们用于 SIFT 和 SURF 特征检测/描述提取。 不幸的是,它们已申请专利。 因此它们并非免费用于商业用途。 但是,它们的实现存在于 OpenCV 中,因此您可以自由地对其进行评估。 但是有很好的免费替代品。 您可以改用 ORB 或 FREAK 算法。 ORB 检测是经过修改的 FAST 特征检测器。 原始的 FAST 检测器速度惊人,但无法计算关键点的方向或大小。 幸运的是,ORB 算法确实可以估计关键点的方向,但是特征尺寸仍然是固定的。 从以下几段中,您将学到处理这些问题的便宜方法。 但是首先,让我解释一下为什么特征点在图像识别中如此重要。
如果处理的图像通常具有每像素 24 位的色深,并且分辨率为640 x 480
,则数据为 912 KB。 我们如何在现实世界中找到图案图像? 像素到像素的匹配时间太长,我们也必须处理旋转和缩放。 这绝对不是一个选择。 使用特征点可以解决此问题。 通过检测关键点,我们可以确保返回的特征描述了包含大量信息的图像部分(这是因为基于角的检测器会返回边缘,角和其他清晰的图形)。 因此,要查找两个框架之间的对应关系,我们只需要匹配关键点即可。
从关键点定义的补丁中,我们提取一个称为描述符的向量。 这是特征点的一种表示形式。 从特征点提取描述符的方法有很多。 他们都有自己的优点和缺点。 例如,SIFT 和 SURF 描述符提取算法占用大量 CPU,但是提供具有良好区分性的强大描述符。 在我们的示例项目中,我们使用 ORB 描述符提取算法,因为我们也选择它作为特征检测器。
注意
同时使用来自同一算法的特征检测器和描述符提取器始终是一个好主意,因为它们可以完美地相互配合。
特征描述符表示为固定大小(16 个或更多元素)的向量。 假设我们的图像分辨率为640 x 480
像素,并且具有 1,500 个特征点。 然后,将需要1500 * 16 * sizeof(float) = 96 KB
(用于 SURF)。 它比原始图像数据小十倍。 而且,使用描述符比使用光栅位图要容易得多。 对于两个特征描述符,我们可以引入相似度评分-一种定义两个向量之间相似度的度量。 通常是其 L2 范数或汉明距离(基于所使用的特征描述符的种类)。
特征描述符提取算法是从cv::DescriptorExtractor
基类派生的。 同样,作为特征检测算法,可以通过指定其名称或使用显式的构造器调用来创建它们。
模式对象的定义
为了描述模式对象,我们引入一个名为Pattern
的类,该类包含训练图像,特征列表和提取的描述符以及初始模式位置的 2D 和 3D 对应关系:
/**
* Store the image data and computed descriptors of target pattern
*/
struct Pattern
{
cv::Size size;
cv::Mat data;
std::vector<cv::KeyPoint> keypoints;
cv::Mat descriptors;
std::vector<cv::Point2f> points2d;
std::vector<cv::Point3f> points3d;
};
特征点的匹配
查找帧与帧之间的对应关系的过程可以公式化为:从一组描述符中为另一组的每个元素搜索最近邻。 这称为“匹配”过程。 OpenCV 中有两种主要的描述符匹配算法:
- 暴力比赛(
cv::BFMatcher
) - 基于 Flann 的匹配器(
cv::FlannBasedMatcher
)
暴力匹配器通过尝试每一个(穷举搜索)在第一组中寻找每个描述符,在第二组中寻找最接近的描述符。 cv::FlannBasedMatcher
使用快速近似最近邻搜索算法来查找对应关系(为此,它使用快速第三方库作为近似最近邻库)。
描述符匹配的结果是两组描述符之间的对应关系的列表。 第一组描述符通常称为训练集,因为它与我们的图案图像相对应。 第二个集合称为查询集,因为它属于我们将在其中寻找模式的图像。 找到的正确匹配越多(存在更多与图像对应的图案),图案出现在图像上的机会就越大。
为了提高匹配速度,您可以通过调用match
函数来训练匹配器。 训练阶段可用于优化cv::FlannBasedMatcher
的性能。 为此,train
类将为训练描述符构建索引树。 这将提高大型数据集的匹配速度(例如,如果要从数百张图像中找到匹配项)。 对于cv::BFMatcher
,train
类什么都不做,因为没有要预处理的东西; 它只是将训练描述符存储在内部字段中。
PatternDetector.cpp
以下代码块使用模式图像训练描述符匹配器:
代码语言:javascript复制void PatternDetector::train(const Pattern& pattern)
{
// Store the pattern object
m_pattern = pattern;
// API of cv::DescriptorMatcher is somewhat tricky
// First we clear old train data:
m_matcher->clear();
// That we add vector of descriptors
// (each descriptors matrix describe one image).
// This allows us to perform search across multiple images:
std::vector<cv::Mat> descriptors(1);
descriptors[0] = pattern.descriptors.clone();
m_matcher->add(descriptors);
// After adding train data perform actual train:
m_matcher->train();
}
为了匹配查询描述符,我们可以使用cv::DescriptorMatcher
的以下方法之一:
要查找最佳匹配的简单列表:
代码语言:javascript复制void match(const Mat& queryDescriptors, vector<DMatch>& matches,
const vector<Mat>& masks=vector<Mat>() );
要为每个描述符找到最接近的K
个匹配项:
void knnMatch(const Mat& queryDescriptors, vector<vector<DMatch> >& matches, int k, const vector<Mat>& masks=vector<Mat>(),bool compactResult=false );
查找距离不超过指定距离的匹配:
代码语言:javascript复制void radiusMatch(const Mat& queryDescriptors, vector<vector<DMatch> >& matches, maxDistance, const vector<Mat>& masks=vector<Mat>(), bool compactResult=false );
异常值移除
在匹配阶段可能发生不匹配。 这是正常的。 匹配中有两种错误:
- 假正面匹配项:当特征点对应关系错误时
- 假负面匹配项:当两个图像上的特征点均可见时,不存在匹配项
假负面匹配显然不好。 但是我们无法处理它们,因为匹配算法拒绝了它们。 因此,我们的目标是尽量减少假正面匹配的次数。 要拒绝错误的对应关系,我们可以使用交叉匹配技术。 想法是使训练描述符与查询集匹配,反之亦然。 仅返回这两个匹配项的普通匹配项。 当有足够的匹配项时,此类技术通常会以最少的异常值产生最佳结果。
交叉匹配过滤器
交叉匹配在cv::BFMatcher
类中可用。 要启用交叉检查测试,请在将第二个参数设置为true
的情况下创建cv::BFMatcher
:
cv::Ptr<cv::DescriptorMatcher> matcher(new cv::BFMatcher(cv::NORM_HAMMING, true));
在以下屏幕截图中可以看到使用交叉检查进行匹配的结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Kt4X4ZK4-1681871753498)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_01.jpg)]
比率测试
第二种众所周知的离群值去除技术是比率测试。 我们首先以K = 2
进行 KNN 匹配。 每个匹配都返回两个最接近的描述符。 仅当第一次和第二次比赛之间的距离比足够大(比率阈值通常接近 2)时,才返回比赛。
PatternDetector.cpp
以下代码使用比率测试执行鲁棒的描述符匹配 :
代码语言:javascript复制void PatternDetector::getMatches(const cv::Mat& queryDescriptors, std::vector<cv::DMatch>& matches)
{
matches.clear();
if (enableRatioTest)
{
// To avoid NaNs when best match has
// zero distance we will use inverse ratio.
const float minRatio = 1.f / 1.5f;
// KNN match will return 2 nearest
// matches for each query descriptor
m_matcher->knnMatch(queryDescriptors, m_knnMatches, 2);
for (size_t i=0; i<m_knnMatches.size(); i )
{
const cv::DMatch& bestMatch = m_knnMatches[i][0];
const cv::DMatch& betterMatch = m_knnMatches[i][1];
float distanceRatio = bestMatch.distance / betterMatch.distance;
// Pass only matches where distance ratio between
// nearest matches is greater than 1.5
// (distinct criteria)
if (distanceRatio < minRatio)
{
matches.push_back(bestMatch);
}
}
}
else
{
// Perform regular match
m_matcher->match(queryDescriptors, matches);
}
}
比率测试可以删除几乎所有异常值。 但是在某些情况下,假正面匹配项可以通过此测试。 在下一部分中,我们将向您展示如何删除其余的异常值,并仅保留正确的匹配项。
单应性估计
为了进一步改善匹配,我们可以使用随机样本共识(RANSAC)方法执行离群值过滤。 在处理图像(平面对象)时,我们希望它是刚性的,因此可以找到图案图像上的特征点与查询图像上的特征点之间的单应性变换。 同形转换将点从模式带到查询图像坐标系。 为了找到这种转换,我们使用cv::findHomography
函数。 它使用 RANSAC 通过探测输入点的子集找到最佳的单应性矩阵。 副作用是,此函数根据计算的单应性矩阵的重投影误差,将每个对应关系标记为离群或离群。
PatternDetector.cpp
以下代码使用单应矩阵估计,并使用 RANSAC 算法过滤掉几何上不正确的匹配项:
代码语言:javascript复制bool PatternDetector::refineMatchesWithHomography
(
const std::vector<cv::KeyPoint>& queryKeypoints,
const std::vector<cv::KeyPoint>& trainKeypoints,
float reprojectionThreshold,
std::vector<cv::DMatch>& matches,
cv::Mat& homography
)
{
const int minNumberMatchesAllowed = 8;
if (matches.size() < minNumberMatchesAllowed)
return false;
// Prepare data for cv::findHomography
std::vector<cv::Point2f> srcPoints(matches.size());
std::vector<cv::Point2f> dstPoints(matches.size());
for (size_t i = 0; i < matches.size(); i )
{
srcPoints[i] = trainKeypoints[matches[i].trainIdx].pt;
dstPoints[i] = queryKeypoints[matches[i].queryIdx].pt;
}
// Find homography matrix and get inliers mask
std::vector<unsigned char> inliersMask(srcPoints.size());
homography = cv::findHomography(srcPoints,
dstPoints,
CV_FM_RANSAC,
reprojectionThreshold,
inliersMask);
std::vector<cv::DMatch> inliers;
for (size_t i=0; i<inliersMask.size(); i )
{
if (inliersMask[i])
inliers.push_back(matches[i]);
}
matches.swap(inliers);
return matches.size() > minNumberMatchesAllowed;
}
这是使用此技术精炼的比赛的可视化效果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dVsl3RmD-1681871753498)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_02.jpg)]
单应性搜索步骤很重要,因为获得的变换是在查询图像中找到图案位置的关键。
单应性优化
当我们寻找单应性变换时,我们已经具有所有必要的数据以在 3D 中找到它们的位置。 但是,我们可以通过找到更准确的图案角来进一步改善其位置。 为此,我们使用估计的单应性使输入图像变形以获取已找到的图案。 结果应该非常接近源训练图像。 单应性优化可以帮助找到更准确的单应性变换。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gzIf83Uq-1681871753499)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_03.jpg)]
然后,我们获得另一个单应性和另一组线性特征。 所得的精确单应性将是第一(H1)和第二(H2)单应性的矩阵乘积。
PatternDetector.cpp
以下代码块包含模式检测例程的最终版本:
代码语言:javascript复制bool PatternDetector::findPattern(const cv::Mat& image, PatternTrackingInfo& info)
{
// Convert input image to gray
getGray(image, m_grayImg);
// Extract feature points from input gray image
extractFeatures(m_grayImg, m_queryKeypoints, m_queryDescriptors);
// Get matches with current pattern
getMatches(m_queryDescriptors, m_matches);
// Find homography transformation and detect good matches
bool homographyFound = refineMatchesWithHomography(
m_queryKeypoints,
m_pattern.keypoints,
homographyReprojectionThreshold,
m_matches,
m_roughHomography);
if (homographyFound)
{
// If homography refinement enabled
// improve found transformation
if (enableHomographyRefinement)
{
// Warp image using found homography
cv::warpPerspective(m_grayImg, m_warpedImg, m_roughHomography, m_pattern.size, cv::WARP_INVERSE_MAP | cv::INTER_CUBIC);
// Get refined matches:
std::vector<cv::KeyPoint> warpedKeypoints;
std::vector<cv::DMatch> refinedMatches;
// Detect features on warped image
extractFeatures(m_warpedImg, warpedKeypoints, m_queryDescriptors);
// Match with pattern
getMatches(m_queryDescriptors, refinedMatches);
// Estimate new refinement homography
homographyFound = refineMatchesWithHomography(
warpedKeypoints,
m_pattern.keypoints,
homographyReprojectionThreshold,
refinedMatches,
m_refinedHomography);
// Get a result homography as result of matrix product
// of refined and rough homographies:
info.homography = m_roughHomography * m_refinedHomography;
// Transform contour with precise homography
cv::perspectiveTransform(m_pattern.points2d, info.points2d, info.homography);
}
else
{
info.homography = m_roughHomography;
// Transform contour with rough homography
cv::perspectiveTransform(m_pattern.points2d, info.points2d, m_roughHomography);
}
}
return homographyFound;
}
如果在所有离群值去除阶段之后,匹配项的数量仍然相当大(图案图像中至少 25% 的特征与输入的特征相对应),则可以确定位置正确的图案图像。 如果是这样,我们将进入下一个阶段-相对于相机估计花样姿势的 3D 位置。
全部放在一起
为了容纳特征检测器,描述符提取器和匹配器算法的实例,我们创建了一个类PatternMatcher
,该类将封装所有这些数据。 它在特征检测和描述符提取算法,特征匹配逻辑以及控制检测过程的设置上拥有所有权。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O3wZnsHk-1681871753499)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_04.jpg)]
该类提供了方法来计算所有必要数据,以根据给定图像构建图案结构:
代码语言:javascript复制void PatternDetector::computePatternFromImage(const cv::Mat& image, Pattern& pattern);
该方法在输入图像上找到特征点,并使用指定的检测器和提取器算法提取描述符,然后用此数据填充图案结构以备后用。
当计算出Pattern
时,我们可以通过调用train
方法来训练一个检测器:
void PatternDetector::train(const Pattern& pattern)
此函数将参数设置为我们将要找到的当前目标模式。 同样,它训练带有模式的描述符集的描述符匹配器。 调用此方法后,我们准备查找训练图像。 模式检测是在最后的公共函数findPattern
中完成的。 该方法封装了如前所述的整个例程,包括特征检测,描述符提取以及与异常值过滤的匹配。
让我们再次简要介绍一下我们执行的步骤:
- 将输入图像转换为灰度。
- 使用我们的特征检测算法在查询图像上检测到特征。
- 从输入图像中提取检测到的特征点的描述符。
- 与模式描述符匹配的描述符。
- 使用了交叉检查或比率测试来去除异常值。
- 使用内部匹配找到单应变换。
- 通过使用上一步中的单应性扭曲查询图像来完善单应性。
- 由于粗略和精细单应性的相乘,找到了精确的单应性。
- 将图案角转换为图像坐标系,以获取输入图像上的图案位置。
模式姿势估计
姿势估计的方式类似于上一章中标记姿势的估计方式。 像往常一样,我们需要 2D-3D 对应关系来估计摄像机的外部参数。 我们分配四个 3D 点以与位于 XY 平面(Z 轴向上)中的单位矩形的角协调,而 2D 点对应于图像位图的角。
PatternDetector.cpp
buildPatternFromImage
类从输入图像创建Pattern
对象,如下所示:
void PatternDetector::buildPatternFromImage(const cv::Mat& image, Pattern& pattern) const
{
int numImages = 4;
float step = sqrtf(2.0f);
// Store original image in pattern structure
pattern.size = cv::Size(image.cols, image.rows);
pattern.frame = image.clone();
getGray(image, pattern.grayImg);
// Build 2d and 3d contours (3d contour lie in XY plane since // it's planar)
pattern.points2d.resize(4);
pattern.points3d.resize(4);
// Image dimensions
const float w = image.cols;
const float h = image.rows;
// Normalized dimensions:
const float maxSize = std::max(w,h);
const float unitW = w / maxSize;
const float unitH = h / maxSize;
pattern.points2d[0] = cv::Point2f(0,0);
pattern.points2d[1] = cv::Point2f(w,0);
pattern.points2d[2] = cv::Point2f(w,h);
pattern.points2d[3] = cv::Point2f(0,h);
pattern.points3d[0] = cv::Point3f(-unitW, -unitH, 0);
pattern.points3d[1] = cv::Point3f( unitW, -unitH, 0);
pattern.points3d[2] = cv::Point3f( unitW, unitH, 0);
pattern.points3d[3] = cv::Point3f(-unitW, unitH, 0);
extractFeatures(pattern.grayImg, pattern.keypoints, pattern.descriptors);
}
角的配置非常有用,因为此图案坐标系将直接放置在 XY 平面中图案位置的中心,而 Z 轴朝相机的方向看。
获取相机固有矩阵
摄像机固有参数可以使用 OpenCV 分发包中名为camera_cailbration.exe
的示例程序来计算。 该程序将使用一系列图案图像找到内部镜头参数,例如焦距,主点和畸变系数。 假设从不同的角度来看,我们有一组八个校准图案图像,如下所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H2l8TWnW-1681871753499)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_05.jpg)]
然后,用于执行校准的命令行语法如下:
代码语言:javascript复制imagelist_creator imagelist.yaml *.png
calibration -w 9 -h 6 -o camera_intrinsic.yaml imagelist.yaml
第一条命令将创建 YAML 格式的图像列表,校准工具希望该图像列表作为当前目录中所有 PNG 文件的输入。 您可以使用确切的文件名,例如img1.png
,img2.png
和img3.png
。 然后将生成的文件imagelist.yaml
传递到校准应用。 而且,校准工具可以从常规网络摄像头拍摄图像。
我们指定校准图案的尺寸以及将要写入校准数据的输入和输出文件。
校准完成后,您将在 YAML 文件中获得以下结果:
代码语言:javascript复制%YAML:1.0
calibration_time: "06/12/12 11:17:56"
image_width: 640
image_height: 480
board_width: 9
board_height: 6
square_size: 1.
flags: 0
camera_matrix: !!opencv-matrix
rows: 3
cols: 3
dt: d
data: [ 5.2658037684199849e 002, 0., 3.1841744018680112e 002, 0.,
5.2465577209994706e 002, 2.0296659047014398e 002, 0., 0., 1. ]
distortion_coefficients: !!opencv-matrix
rows: 5
cols: 1
dt: d
data: [ 7.3253671786835686e-002, -8.6143199924308911e-002,
-2.0800255026966759e-002, -6.8004894417795971e-004,
-1.7750733073535208e-001 ]
avg_reprojection_error: 3.6539552933501085e-001
我们主要对camera_matrix
感兴趣,它是3 x 3
相机校准矩阵。 它具有以下表示法:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kzGku8XV-1681871753499)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_06.jpg)]
我们主要对的四个组成部分感兴趣:fx
,fy
,cx
和cy
。 有了这些数据,我们可以使用以下代码创建相机校准对象的实例:
CameraCalibration calibration(526.58037684199849e, 524.65577209994706e, 318.41744018680112, 202.96659047014398)
没有正确的相机校准,就不可能创建看起来自然的增强现实。 估计的透视变换将与相机的变换不同。 这将导致增强对象看起来太近或太远。 以下是一个示例屏幕截图,其中相机校准是有意更改的:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-siD1mATL-1681871753499)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_07.jpg)]
如您所见,盒子的透视外观与整体场景有所不同。
为了估计图案位置,我们使用 OpenCV 函数cv::solvePnP
解决了 PnP 问题。 您可能熟悉此函数,因为我们也在基于标记的 AR 中使用了它。 我们需要当前图像上图案角的坐标,以及我们先前定义的参考 3D 坐标。
注意
cv::solvePnP
函数可以使用四个以上的点。 另外,如果要创建具有复杂形状图案的 AR,它也是关键函数。 想法保持不变-您只需要定义图案的 3D 结构和 2D 查找点对应关系即可。 当然,单应性估计在这里不适用。
我们从训练好的模式对象中获取参考 3D 点,并从PatternTrackingInfo
结构中获取其对应的 2D 投影; 摄像机校准存储在PatternDetector
专用字段中。
Pattern.cpp
3D 空间中的图案位置由computePose
函数估计如下:
void PatternTrackingInfo::computePose(const Pattern& pattern, const CameraCalibration& calibration)
{
cv::Mat camMatrix, distCoeff;
cv::Mat(3,3, CV_32F, const_cast<float*>(&calibration.getIntrinsic().data[0])).copyTo(camMatrix);
cv::Mat(4,1, CV_32F, const_cast<float*>(&calibration.getDistorsion().data[0])).copyTo(distCoeff);
cv::Mat Rvec;
cv::Mat_<float> Tvec;
cv::Mat raux,taux;
cv::solvePnP(pattern.points3d, points2d, camMatrix, distCoeff,raux,taux);
raux.convertTo(Rvec,CV_32F);
taux.convertTo(Tvec ,CV_32F);
cv::Mat_<float> rotMat(3,3);
cv::Rodrigues(Rvec, rotMat);
// Copy to transformation matrix
pose3d = Transformation();
for (int col=0; col<3; col )
{
for (int row=0; row<3; row )
{
pose3d.r().mat[row][col] = rotMat(row,col);
// Copy rotation component
}
pose3d.t().data[col] = Tvec(col);
// Copy translation component
}
// Since solvePnP finds camera location, w.r.t to marker pose,
// to get marker pose w.r.t to the camera we invert it.
pose3d = pose3d.getInverted();
}
应用基础结构
到目前为止,我们已经学习了如何检测图案并估计相对于相机的 3D 位置。 现在该展示如何将这些算法应用于实际应用了。 因此,本部分的目标是展示如何使用 OpenCV 从网络摄像机捕获视频并创建 3D 渲染的可视化上下文。
因为我们的目标是展示如何使用无标记 AR 的关键功能,所以我们将创建一个简单的命令行应用,它将能够检测视频序列或静止图像中的任意图案图像。
为了容纳所有图像处理逻辑和中间数据,我们引入了ARPipeline
类。 它是一个根对象,其中包含增强现实所需的所有子组件,并在输入帧上执行所有处理例程。 以下是ARPipeline
及其子组件的 UML 图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VHL1VzCi-1681871753505)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_08.jpg)]
它包括:
- 相机校准对象
- 模式检测器对象的实例
- 训练过的图案对象
- 模式跟踪的中间数据
ARPipeline.hpp
以下代码包含ARPipeline
类的声明:
class ARPipeline
{
public:
ARPipeline(const cv::Mat& patternImage, const CameraCalibration& calibration);
bool processFrame(const cv::Mat& inputFrame);
const Transformation& getPatternLocation() const;
private:
CameraCalibration m_calibration;
Pattern m_pattern;
PatternTrackingInfo m_patternInfo;
PatternDetector m_patternDetector;
};
在ARPipeline
构造器中,将初始化一个图案对象,并将校准数据保存到private
字段中。 processFrame
函数实现模式检测和人的姿势估计例程。 返回值表示模式检测成功。 您可以通过调用getPatternLocation
函数获得计算出的花样姿势。
ARPipeline.cpp
以下代码包含类的实现:
代码语言:javascript复制ARPipeline::ARPipeline(const cv::Mat& patternImage, const CameraCalibration& calibration)
: m_calibration(calibration)
{
m_patternDetector.buildPatternFromImage (patternImage, m_pattern);
m_patternDetector.train(m_pattern);
}
bool ARPipeline::processFrame(const cv::Mat& inputFrame)
{
bool patternFound = m_patternDetector.findPattern(inputFrame, m_patternInfo);
if (patternFound)
{
m_patternInfo.computePose(m_pattern, m_calibration);
}
return patternFound;
}
const Transformation& ARPipeline::getPatternLocation() const
{
return m_patternInfo.pose3d;
}
在 OpenCV 中启用对 3D 可视化的支持
与上一章一样,我们将使用 OpenGL 渲染 3D 工作。 但是,与必须遵循 iOS 应用架构要求的 iOS 环境不同,我们现在有了更大的自由度。 在 Windows 和 Mac 上,您可以从许多 3D 引擎中进行选择。 在本章中,我们将学习如何使用 OpenCV 创建跨平台的 3D 可视化。 从 2.4.2 版开始,OpenCV 在可视化窗口中具有 OpenGL 的支持。 这意味着您现在可以轻松地在 OpenCV 中渲染任何 3D 内容。
要在 OpenCV 中设置 OpenGL 窗口,您需要做的第一件事就是使用 OpenGL 支持构建 OpenCV。 否则,当您尝试使用 OpenCV 的 OpenGL 相关功能时,将引发异常。 要启用 OpenGL 支持,您应该使用ENABLE_OPENGL=YES
标志构建 OpenCV 库。
注意
从当前版本(OpenCV 2.4.2)开始,默认情况下关闭 OpenGL 支持。 我们无法保证,但是将来的版本中可能默认启用 OpenGL。 如果是这样,则无需手动构建 OpenCV。
要在 OpenCV 中设置 OpenGL 窗口,请执行以下操作:
- 从 GitHub 克隆 OpenCV 存储库。 您将需要命令行 Git 工具或计算机上安装的 GitHub 应用来执行此步骤。
- 配置 OpenCV 并为您的 IDE 生成一个工作区。 您将需要 CMake 应用来完成此步骤。 可以从这个页面免费下载 CMake。
要配置 OpenCV,可以按以下方式使用命令行 CMake 命令(从要放置生成的项目的目录中运行):
代码语言:javascript复制cmake -D ENABLE_OPENGL=YES <path to the OpenCV source directory>
或者,如果您更喜欢 GUI 风格,请使用 CMake-GUI 进行更加用户友好的项目配置:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KmSuXTVg-1681871753505)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_09.jpg)]
为选定的 IDE 生成 OpenCV 工作区之后,打开项目并执行安装目标以构建库并安装它。 完成此过程后,您可以使用刚刚构建的新 OpenCV 库配置示例项目。
使用 OpenCV 创建 OpenGL 窗口
现在,我们已经具有支持 OpenGL 的 OpenCV 二进制文件,是时候创建第一个 OpenGL 窗口了。 OpenGL 窗口的初始化从创建带有 OpenGL 标志的命名窗口开始:
代码语言:javascript复制cv::namedWindow(ARWindowName, cv::WINDOW_OPENGL);
ARWindowName
是窗口名称的字符串常量。 我们将在此处使用Markerless AR
。 该调用将创建一个具有指定名称的窗口。 cv::WINDOW_OPENGL
标志表示我们将在此窗口中使用 OpenGL。 然后我们设置所需的窗口大小:
cv::resizeWindow(ARWindowName, 640, 480);
然后,我们为此窗口设置绘图上下文:
代码语言:javascript复制cv::setOpenGlContext(ARWindowName);
现在我们的窗口可以使用了。 要在上面绘制内容,我们应该使用以下方法注册一个回调函数:
代码语言:javascript复制cv::setOpenGlDrawCallback(ARWindowName, drawAR, NULL);
此回调将在重新绘制窗口上调用。 第一个参数设置窗口名称,第二个参数设置回调函数,第三个可选参数将传递给回调函数。
drawAR
函数应具有以下签名:
void drawAR(void* param)
{
// Draw something using OpenGL here
}
要通知系统您要重绘窗口,请使用cv::updateWindow
函数:
cv::updateWindow(ARWindowName);
使用 OpenCV 的视频捕获
OpenCV 允许轻松地从几乎每个网络摄像机和视频文件中检索帧。 要从网络摄像头或视频文件捕获视频,我们可以使用cv::VideoCapture
类,如第 1 章,“卡通化器和适用于 Android 的换肤工具”。
渲染增强现实
我们引入和ARDrawingContext
结构来保存可视化可能需要的所有必要数据:
- 从相机拍摄的最新图像
- 相机校准矩阵
- 3D 中的图案姿势(如果存在)
- 与 OpenGL 相关的内部数据(纹理 ID 等)
ARDrawingContext.hpp
以下代码包含ARDrawingContext
类的声明:
class ARDrawingContext
{
public:
ARDrawingContext(const CameraCalibration& c);
bool patternPresent;
Transformation patternPose;
//! Request the redraw of the OpenGl window
void draw();
//! Set the new frame for the background
void updateBackground(const cv::Mat& frame);
private:
//! Draws the background with video
void drawCameraFrame ();
//! Draws the AR
void drawAugmentedScene();
//! Builds the right projection matrix
//! from the camera calibration for AR
void buildProjectionMatrix(const Matrix33& calibration, int w, int h, Matrix44& result);
//! Draws the coordinate axis
void drawCoordinateAxis();
//! Draw the cube model
void drawCubeModel();
private:
bool m_textureInitialized;
unsigned int m_backgroundTextureId;
CameraCalibration m_calibration;
cv::Mat m_backgroundImage;
};
ARDrawingContext.cpp
OpenGL 窗口的初始化在ARDrawingContext
类的构造器中完成,如下所示:
ARDrawingContext::ARDrawingContext(std::string windowName, cv::Size frameSize, const CameraCalibration& c)
: m_isTextureInitialized(false)
, m_calibration(c)
, m_windowName(windowName)
{
// Create window with OpenGL support
cv::namedWindow(windowName, cv::WINDOW_OPENGL);
// Resize it exactly to video size
cv::resizeWindow(windowName, frameSize.width, frameSize.height);
// Initialize OpenGL draw callback:
cv::setOpenGlContext(windowName);
cv::setOpenGlDrawCallback(windowName, ARDrawingContextDrawCallback, this);
}
现在我们有了一个单独的类来存储可视化状态,因此我们修改了cv::setOpenGlDrawCallback
调用,并将ARDrawingContext
的实例作为参数传递。
修改后的回调函数如下:
代码语言:javascript复制void ARDrawingContextDrawCallback(void* param)
{
ARDrawingContext * ctx = static_cast<ARDrawingContext*>(param);
if (ctx)
{
ctx->draw();
}
}
ARDrawingContext
负责渲染增强现实。 帧渲染首先通过绘制具有正交投影的背景开始。 然后,使用正确的透视投影和模型转换来渲染 3D 模型。 以下代码包含draw
函数的最终版本:
void ARDrawingContext::draw()
{
// Clear entire screen
glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
// Render background
drawCameraFrame();
// Draw AR
drawAugmentedScene();
}
清除屏幕和深度缓冲区后,我们检查用于显示视频的纹理是否已初始化。 如果是这样,我们继续绘制背景,否则我们通过调用glGenTextures
创建一个新的 2D 纹理。
为了绘制背景,我们设置了正交投影并绘制了覆盖所有屏幕视口的实心矩形。 该矩形与纹理单元绑定。 该纹理填充有m_backgroundImage
对象的内容。 它的内容会预先上传到 OpenGL 内存中。 该函数与上一章的函数相同,因此在此我们将省略其代码。
从相机绘制图片后,我们切换到绘制 AR。 必须设置与我们的相机校准相匹配的正确透视投影。
以下代码显示了如何通过相机校准构建正确的 OpenGL 投影矩阵并渲染场景:
代码语言:javascript复制void ARDrawingContext::drawAugmentedScene()
{
// Init augmentation projection
Matrix44 projectionMatrix;
int w = m_backgroundImage.cols;
int h = m_backgroundImage.rows;
buildProjectionMatrix(m_calibration, w, h, projectionMatrix);
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(projectionMatrix.data);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
if (isPatternPresent)
{
// Set the pattern transformation
Matrix44 glMatrix = patternPose.getMat44();
glLoadMatrixf(reinterpret_cast<const GLfloat*>(&glMatrix.data[0]));
// Render model
drawCoordinateAxis();
drawCubeModel();
}
}
buildProjectionMatrix
函数取自上一章,因此相同。 应用透视投影后,我们将GL_MODELVIEW
矩阵设置为模式转换。 为了证明我们的姿势估计正确工作,我们在图案位置绘制了一个单位坐标系。
几乎所有都已完成。 我们创建了一种模式检测算法,然后估计在 3D 空间中发现的模式的姿势,该空间是呈现 AR 的可视化系统。 让我们看一下下面的 UML 序列图,该图演示了我们应用中的帧处理例程:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sSJqASAY-1681871753505)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_10.jpg)]
示例
我们的演示项目支持通过网络摄像机处理静态图像,录制的视频和实时取景。 我们创建了两个函数来帮助我们。
main.cpp
函数processVideo
处理视频的处理,函数processSingleImage
用于处理单个图像,如下所示:
void processVideo(const cv::Mat& patternImage, CameraCalibration& calibration, cv::VideoCapture& capture);
void processSingleImage(const cv::Mat& patternImage, CameraCalibration& calibration, const cv::Mat& image);
从函数名称中可以明显看出,第一个函数处理了视频源,第二个函数处理了单个图像(此函数可用于调试目的)。 两者都有图像处理,模式检测,场景渲染和用户交互的非常通用的例程。
processFrame
函数包含以下步骤:
/**
* Performs full detection routine on camera frame
.* and draws the scene using drawing context.
* In addition, this function draw overlay with debug information
.* on top of the AR window. Returns true
.* if processing loop should be stopped; otherwise - false.
*/
bool processFrame(const cv::Mat& cameraFrame, ARPipeline& pipeline, ARDrawingContext& drawingCtx)
{
// Clone image used for background (we will
// draw overlay on it)
cv::Mat img = cameraFrame.clone();
// Draw information:
if (pipeline.m_patternDetector.enableHomographyRefinement)
cv::putText(img, "Pose refinement: On ('h' to switch off)", cv::Point(10,15), CV_FONT_HERSHEY_PLAIN, 1, CV_RGB(0,200,0));
else
cv::putText(img, "Pose refinement: Off ('h' to switch
on)", cv::Point(10,15), CV_FONT_HERSHEY_PLAIN, 1, CV_RGB(0,200,0));
cv::putText(img, "RANSAC threshold: " ToString(pipeline.m_patternDetector.homographyReprojectionThreshold) "( Use'-'/' ' to adjust)", cv::Point(10, 30), CV_FONT_HERSHEY_PLAIN, 1, CV_RGB(0,200,0));
// Set a new camera frame:
drawingCtx.updateBackground(img);
// Find a pattern and update its detection status:
drawingCtx.isPatternPresent = pipeline.processFrame(cameraFrame);
// Update a pattern pose:
drawingCtx.patternPose = pipeline.getPatternLocation();
// Request redraw of the window:
drawingCtx.updateWindow();
// Read the keyboard input:
int keyCode = cv::waitKey(5);
bool shouldQuit = false;
if (keyCode == ' ' || keyCode == '=')
{
pipeline.m_patternDetector.homographyReprojectionThreshold = 0.2f;
pipeline.m_patternDetector.homographyReprojectionThreshold = std::min(10.0f, pipeline.m_patternDetector.homographyReprojectionThreshold);
}
else if (keyCode == '-')
{
pipeline.m_patternDetector.homographyReprojectionThreshold -= 0.2f;
pipeline.m_patternDetector.homographyReprojectionThreshold = std::max(0.0f, pipeline.m_patternDetector.homographyReprojectionThreshold);
}
else if (keyCode == 'h')
{
pipeline.m_patternDetector.enableHomographyRefinement = !pipeline.m_patternDetector.enableHomographyRefinement;
}
else if (keyCode == 27 || keyCode == 'q')
{
shouldQuit = true;
}
return shouldQuit;
}
ARPipeline
和ARDrawingContext
的初始化是在processSingleImage
或processVideo
函数中完成的,如下所示:
void processSingleImage(const cv::Mat& patternImage, CameraCalibration& calibration, const cv::Mat& image)
{
cv::Size frameSize(image.cols, image.rows);
ARPipeline pipeline(patternImage, calibration);
ARDrawingContext drawingCtx("Markerless AR", frameSize, calibration);
bool shouldQuit = false;
do
{
shouldQuit = processFrame(image, pipeline, drawingCtx);
} while (!shouldQuit);
}
我们从图案图像和校准参数创建ARPipeline
。 然后,我们再次使用校准来初始化ARDrawingContext
。 这些步骤之后,将创建 OpenGL 窗口。 然后,我们将查询图像上传到绘画上下文中,并调用ARPipeline.processFrame
查找模式。 如果找到了姿势模式,我们将其位置复制到绘图上下文中以进行进一步的帧渲染。 如果未检测到图案,我们将仅渲染相机帧而没有任何 AR。
您可以通过以下方式之一运行演示应用:
要在单个图像调用上运行:
代码语言:javascript复制markerless_ar_demo pattern.png test_image.png
要进行录制的视频通话,请执行以下操作:
代码语言:javascript复制markerless_ar_demo pattern.png test_video.avi
要使用网络摄像头的实时馈送运行,请致电:
代码语言:javascript复制markerless_ar_demo pattern.png
以下屏幕快照显示了放大单个图像的结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iCTMZxJF-1681871753506)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_03_11.jpg)]
总结
在本章中,您了解了特征描述符以及如何使用它们来定义比例尺和旋转不变模式描述。 此描述可用于在其他图像中查找相似的条目。 还解释了大多数流行特征描述符的优缺点。 在本章的后半部分,我们学习了如何将 OpenGL 和 OpenCV 一起用于渲染增强现实。
参考
- 《比例不变关键点的独特图像特征》
- 《SURF:加速鲁棒特征》
Model-Based Object Pose in 25 Lines of Code, Dementhon and L.S Davis, International Journal of Computer Vision, edition 15, pp. 123-141, 1995
Linear N-Point Camera Pose Determination, L.Quan, IEEE Trans. on Pattern Analysis and Machine Intelligence, vol. 21, edition. 7, July 1999
Random Sample Consensus: A Paradigm for Model Fitting with Applications to Image Analysis and Automated Cartography, M. Fischer and R. Bolles, Graphics and Image Processing, vol. 24, edition. 6, pp. 381-395, June 1981
- 《计算机视觉中的多视图几何》,R. Hartley 和 A.Zisserman,剑桥大学出版社
- 《摄像机姿势重新讨论–新的线性算法》,M.Ameller,B.Triggs,L.Quan
Closed-form solution of absolute orientation using unit quaternions, Berthold K. P. Horn, Journal of the Optical Society A, vol. 4, 629–642
四、使用 OpenCV 探索运动结构
在本章中,我们将讨论使用运动结构(SfM),或更好称为从图像中提取的几何结构,图像使用 OpenCV 的 API 通过摄像机运动拍摄。 首先,让我们限制使用单一相机的方法原本就漫长的步伐,通常称为单眼方法,以及离散且稀疏的帧集,而不是连续的视频流。 这两个约束将大大简化我们将在接下来的页面中概述的系统,并帮助我们理解任何 SfM 方法的基础。 为了实现我们的方法,我们将遵循 Hartley 和 Zisserman(以下称为 H 和 Z)的脚步,它们的开创性著作《计算机视觉中的多视图几何》在第 9 至 12 章中进行了介绍。
在本章中,我们涵盖以下内容:
- 运动结构的概念
- 从一对图像估计相机运动
- 重建场景
- 多角度重建
- 优化后的重建
- 可视化 3D 点云
在本章中,我们假设使用的是经过校准的摄像机,该摄像机是事先校准的。 校准是计算机视觉中无处不在的操作,使用命令行工具在 OpenCV 中得到完全支持,并在前面的章节中进行了讨论。 因此,我们假设相机矩阵的固有参数存在于 K 矩阵中,这是校准过程的输出之一。
为了使语言更清晰,从现在开始,我们将摄像机称为场景的单一视图,而不是指用于拍摄图像的光学和硬件。 照相机具有在空间中的位置和观察方向。 在两个摄像头之间,有一个平移元素(在空间中移动)和方向的旋转。
我们还将场景,世界,真实或 3D 中的点的术语统一为同一事物,这是我们现实世界中存在的点。 对于在该位置和时间投影在相机传感器上的某些真实 3D 点,图像或 2D 中的点(即图像坐标中的点)也是如此。
在本章的代码部分中,您会注意到对《计算机视觉中的多视图几何》的引用,例如// HZ 9.12
。 这指的是本书第 9 章的方程式 12。 此外,文本仅包含代码摘录,而完整的可运行代码包含在本书随附的材料中。
运动结构的概念
我们应该做的第一个区别是立体(或实际上是任何多视图),使用已校准装备的 3D 重建和 SfM 之间的区别。 尽管有两个或更多摄像机的装备假设我们已经知道摄像机之间的运动是什么,但在 SfM 中我们实际上并不知道该运动,我们希望找到它。 从简单的角度来看,经过校准的装备可以更精确地重建 3D 几何形状,因为在估计摄像机之间的距离和旋转方面没有错误-这是众所周知的。 实现 SfM 系统的第一步是找到摄像机之间的运动。 OpenCV 可以通过多种方式帮助我们获得此运动,特别是使用findFundamentalMat
函数。
让我们思考一下选择 SfM 算法背后的目标。 在大多数情况下,我们希望获得场景的几何形状,例如,对象与摄影机有关以及它们的形式是什么。 假设我们已经从合理相似的角度知道了拍摄同一场景的摄像机之间的运动,那么现在我们想重建几何形状。 在计算机视觉术语中,这称为三角剖分,并且有很多解决方法。 这可以通过光线相交来完成,在光线相交中,我们构造了两条光线:一个来自每个相机的投影中心,另一个位于每个像面上。 理想情况下,这些光线在空间中的交集将在每个照相机中成像的现实世界中的一个 3D 点处相交,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1kdUaQzV-1681871753506)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_05_30.jpg)]
实际上,射线相交是高度不可靠的。 H 和 Z 建议不要这样做。 这是因为光线通常不相交,使我们退回到使用连接两条光线的最短线段上的中点。 相反,H 和 Z 建议使用多种方法对 3D 点进行三角剖分,我们将在“重构场景”部分中讨论其中的几种方法。 OpenCV 的当前版本不包含用于三角剖分的简单 API,因此我们将自行编写这一部分。
在学习了如何从两个视图恢复 3D 几何形状之后,我们将看到如何合并同一场景的更多视图以获得更丰富的重建。 那时,在“重建的细化”部分中,大多数 SfM 方法都试图通过束调整来优化摄像机和 3D 点的估计位置束。 OpenCV 在其新的图像拼接工具箱中包含用于捆绑调整的方法。 但是,使用 OpenCV 和 C 的好处在于可以轻松集成到管道中的大量外部工具。 因此,我们将看到如何集成外部捆绑器调整器,即简洁的 SSBA 库。
现在,我们已经概述了使用 OpenCV 进行 SfM 的方法的概要,我们将看到如何实现每个元素。
从一对图像估计相机运动
在我们开始实际寻找两个摄像机之间的运动之前,让我们检查一下输入和执行此操作所需的工具。 首先,我们从空间中的不同位置(希望不是非常地多)获得同一场景的两个图像。 这是一项强大的资产,我们将确保使用它。 现在,就工具而言,我们应该看一下对我们的图像,相机和场景施加约束的数学对象。
两个非常有用的数学对象是基本矩阵(用 F 表示)和基本矩阵(用 E 表示)。 它们基本相似,不同之处在于基本矩阵假设使用已校准的摄像机。 对于我们来说就是这种情况,因此我们将选择它。 OpenCV 仅允许我们通过findFundamentalMat
函数找到基本矩阵; 但是,使用校准矩阵K
从中获取基本矩阵非常简单,如下所示:
Mat_<double> E = K.t() * F * K; //according to HZ (9.12)
基本矩阵是3 x 3
大小的矩阵,它在x'Ex = 0
的情况下在一个图像中的一个点与另一图像中的一个点之间施加了约束,其中x
是图像一中的一个点,x'
是图像二中的对应点。 正如我们即将看到的,这非常有用。 我们使用的另一个重要事实是,基本相机是我们需要的,以便为图像恢复两个相机,尽管只是按比例绘制的。 但我们稍后再讲。 因此,如果我们获得基本矩阵,我们就会知道每个摄像机在空间中的位置以及它的位置。 如果我们有足够的约束方程,就可以很容易地计算出矩阵,这仅仅是因为每个方程都可以用来求解矩阵的一小部分。 实际上,OpenCV 允许我们仅使用七个点对来计算它,但是希望我们会有更多对,并获得更可靠的解决方案。
使用丰富特征描述符的点匹配
现在我们将利用我们的约束方程式来计算基本矩阵。 为了获得约束,请记住,对于图像 A 中的每个点,我们必须在图像 B 中找到一个对应点。如何实现这种匹配? 只需使用 OpenCV 广泛的特征匹配框架,该框架在过去几年中已经非常成熟。
特征提取和描述符匹配是计算机视觉中必不可少的过程,并且在许多方法中用于执行各种操作。 例如,检测对象在图像中的位置和方向,或者通过给定查询在大型图像数据库中搜索相似图像。 本质上,提取意味着在图像中选择可使特征良好的点,并为其计算描述符。 描述符是描述图像中特征点周围周围环境的数字向量。 不同的方法为其描述符向量具有不同的长度和数据类型。 匹配是使用其描述符从另一组中找到对应特征的过程。 OpenCV 提供了非常简单而强大的方法来支持特征提取和匹配。 有关特征匹配的更多信息,请参见第 3 章,“无标记增强现实”。
让我们研究一个非常简单的特征提取和匹配方案:
代码语言:javascript复制// detectingkeypoints
SurfFeatureDetectordetector();
vector<KeyPoint> keypoints1, keypoints2;
detector.detect(img1, keypoints1);
detector.detect(img2, keypoints2);
// computing descriptors
SurfDescriptorExtractor extractor;
Mat descriptors1, descriptors2;
extractor.compute(img1, keypoints1, descriptors1);
extractor.compute(img2, keypoints2, descriptors2);
// matching descriptors
BruteForceMatcher<L2<float>> matcher;
vector<DMatch> matches;
matcher.match(descriptors1, descriptors2, matches);
您可能已经看过类似的 OpenCV 代码,但让我们快速进行检查。 我们的目标是获得三个元素:两个图像的特征点,它们的描述符以及两组特征之间的匹配。 OpenCV 提供了一系列特征检测器,描述符提取器和匹配器。 在此简单示例中,我们使用SurfFeatureDetector
函数获取加速鲁棒特征(SURF)的 2D 位置特征和SurfDescriptorExtractor
函数获得 SURF 描述符。 我们使用暴力匹配器来进行匹配,这是匹配两个特征集的最直接的方法,方法是将第一集合中的每个特征与第二集合中的每个特征进行比较(因此称为暴力破解)并获得最佳匹配。
在下一个图像中,我们将在这个页面找到的 Fountain-P11 序列的两个图像上看到特征点的匹配。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YBQJUA43-1681871753506)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_5_1.jpg)]
实际上,像我们刚刚执行的原始匹配只有在一定程度上才是好的,并且许多匹配可能是错误的。 因此,大多数 SfM 方法都会对匹配项执行某种形式的过滤,以确保正确性并减少错误。 筛选的一种形式是交叉检查筛选,它是内置的 OpenCV 的暴力匹配器。 即,如果第一图像的特征与第二图像的特征相匹配,并且反向检查也将第二图像的特征与第一图像的特征相匹配,则认为匹配为真。 所提供的代码中使用的另一种常见过滤机制是基于以下事实进行过滤:两个图像属于同一场景,并且它们之间具有一定的立体视图关系。 在实践中,过滤器会尝试鲁棒地计算基本矩阵,我们将在“查找相机矩阵”部分中学习该基本矩阵,并保留与该计算相对应的那些特征对,且误差很小。
使用光流的点匹配
使用诸如 SURF 之类的功能丰富的的替代方案是使用光流。 以下信息框简要介绍了光流。 OpenCV 最近扩展了其 API,以从两个图像获取流场,现在它变得更快,功能更强大。 我们将尝试将其用作匹配功能的替代方法。**
注意
光流是将一个图像中的选定点匹配到另一个图像的过程,假定两个图像都是序列的一部分并且彼此相对接近。 大多数光流方法会比较从图像 A 到图像 B 中相同区域的每个点周围的小区域,称为搜索窗口或补丁。遵循计算机视觉中一个非常普遍的规则,即亮度恒定约束(及其他名称) ,图像的小块将不会从一个图像到另一个图像急剧变化,因此它们的相减幅度应接近于零。 除了匹配补丁外,更新的光流方法还使用许多其他方法来获得更好的结果。 一种是使用图像金字塔,图像金字塔的尺寸越来越小,可以进行“从粗到细”的工作,这是计算机视觉中非常有用的技巧。 另一种方法是在流场上定义全局约束,假设这些点彼此靠近并在同一方向上“一起移动”。 可在 Packt 网站上的“使用 Microsoft Kinect 开发流体墙”一章中找到有关 OpenCV 中光流方法的更深入的综述。
通过调用calcOpticalFlowPyrLK
函数,在 OpenCV 中使用光流相当容易。 但是,我们希望保持 OF 的结果匹配与使用丰富特征的结果相似,因为将来我们希望这两种方法可以互换。 为此,我们必须安装一种特殊的匹配方法,该方法可以与以前的基于特征的方法互换,该方法将在下面的代码部分中看到:
Vector<KeyPoint>left_keypoints,right_keypoints;
// Detect keypoints in the left and right images
FastFeatureDetectorffd;
ffd.detect(img1, left_keypoints);
ffd.detect(img2, right_keypoints);
vector<Point2f>left_points;
KeyPointsToPoints(left_keypoints,left_points);
vector<Point2f>right_points(left_points.size());
// making sure images are grayscale
Mat prevgray,gray;
if (img1.channels() == 3) {
cvtColor(img1,prevgray,CV_RGB2GRAY);
cvtColor(img2,gray,CV_RGB2GRAY);
} else {
prevgray = img1;
gray = img2;
}
// Calculate the optical flow field:
// how each left_point moved across the 2 images
vector<uchar>vstatus; vector<float>verror;
calcOpticalFlowPyrLK(prevgray, gray, left_points, right_points, vstatus, verror);
// First, filter out the points with high error
vector<Point2f>right_points_to_find;
vector<int>right_points_to_find_back_index;
for (unsigned inti=0; i<vstatus.size(); i ) {
if (vstatus[i] &&verror[i] < 12.0) {
// Keep the original index of the point in the
// optical flow array, for future use
right_points_to_find_back_index.push_back(i);
// Keep the feature point itself
right_points_to_find.push_back(j_pts[i]);
} else {
vstatus[i] = 0; // a bad flow
}
}
// for each right_point see which detected feature it belongs to
Mat right_points_to_find_flat = Mat(right_points_to_find).reshape(1,to_find.size()); //flatten array
vector<Point2f>right_features; // detected features
KeyPointsToPoints(right_keypoints,right_features);
Mat right_features_flat = Mat(right_features).reshape(1,right_features.size());
// Look around each OF point in the right image
// for any features that were detected in its area
// and make a match.
BFMatchermatcher(CV_L2);
vector<vector<DMatch>>nearest_neighbors;
matcher.radiusMatch(
right_points_to_find_flat,
right_features_flat,
nearest_neighbors,
2.0f);
// Check that the found neighbors are unique (throw away neighbors
// that are too close together, as they may be confusing)
std::set<int>found_in_right_points; // for duplicate prevention
for(inti=0;i<nearest_neighbors.size();i ) {
DMatch _m;
if(nearest_neighbors[i].size()==1) {
_m = nearest_neighbors[i][0]; // only one neighbor
} else if(nearest_neighbors[i].size()>1) {
// 2 neighbors – check how close they are
double ratio = nearest_neighbors[i][0].distance / nearest_neighbors[i][1].distance;
if(ratio < 0.7) { // not too close
// take the closest (first) one
_m = nearest_neighbors[i][0];
} else { // too close – we cannot tell which is better
continue; // did not pass ratio test – throw away
}
} else {
continue; // no neighbors... :(
}
// prevent duplicates
if (found_in_right_points.find(_m.trainIdx) == found_in_right_points.end()) {
// The found neighbor was not yet used:
// We should match it with the original indexing
// ofthe left point
_m.queryIdx = right_points_to_find_back_index[_m.queryIdx];
matches->push_back(_m); // add this match
found_in_right_points.insert(_m.trainIdx);
}
}
cout<<"pruned "<< matches->size() <<" / "<<nearest_neighbors.size() <<" matches"<<endl;
函数KeyPointsToPoints
和PointsToKeyPoints
只是在cv::Point2f
和cv::KeyPoint
结构之间转换的简便函数。
在上一部分代码中,我们可以看到很多有趣的东西。 首先要注意的是,当我们使用光流时,我们的结果显示了一个特征从图像左侧的位置移动到图像右侧的另一个位置。 但是我们在图像的右侧检测到一组新特征,不一定与光流中从图像流向左侧的特征对齐。 我们必须使其一致。 要找到这些丢失的特征,我们使用 K 最近邻(kNN)半径搜索,这使我们最多获得两个特征,这些特征在距离兴趣点两个像素的半径内。
我们可以看到的另一件事是针对 kNN 的比率测试的实现,这是 SfM 中减少错误的常见做法。 从本质上讲,当我们在左侧图像中的一个特征与右侧图像中的两个特征之间具有匹配项时,它是一种过滤器,可消除混乱的匹配项。 如果右侧图像中的两个特征太靠近,或者它们之间的比率太大(接近 1.0),我们认为它们会造成混淆,请不要使用它们。 我们还安装了重复预防过滤器,以进一步删减匹配项。
下图显示了从一个图像到另一个图像的流场。 左侧图像中的粉红色箭头显示了色块从左侧图像到右侧图像的移动。 在左侧的第二张图像中,我们看到流场的一小部分被放大了。粉红色的箭头再次显示了斑块的运动,我们可以通过查看图块上的两个原始图像分段来看到它是有意义的。 右手边。 左侧图像中的视觉特征沿粉红色箭头方向在图像上向左移动,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-97M0q5dZ-1681871753506)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_5_20.jpg)]
使用光流代替丰富特征的优点是该过程通常更快,并且可以容纳更多的匹配点,从而使重建更加密集。 在许多光流方法中,还存在贴片整体运动的整体模型,其中通常不考虑匹配的丰富特征。 使用光流的警告是,它最适用于由相同硬件拍摄的连续图像,而丰富的特征对此几乎是不可知的。 差异是由于以下事实造成的:光流方法通常使用非常基本的特征,例如关键点周围的图像斑块,而高阶更丰富的特征(例如 SURF)会考虑每个关键点的高级信息。 使用光流或丰富的特征能是应用设计人员应根据输入做出的决定。
查找相机矩阵
现在我们已获得关键点之间的匹配,我们可以计算基本矩阵,并从中获得基本矩阵。 但是,我们必须首先将匹配点对准两个数组,其中一个数组中的索引对应于另一个数组中的相同索引。 这是findFundamentalMat
函数所必需的。 我们还需要将KeyPoint
结构转换为Point2f
结构。 我们必须特别注意DMatch
的queryIdx
和trainIdx
成员变量,它们是两个关键点之间匹配的 OpenCV 结构,因为它们必须与我们使用matcher.match()
函数的方式保持一致。 以下代码部分显示了如何将匹配项对齐到两个相应的 2D 点集中,以及如何将其用于查找基本矩阵:
vector<Point2f>imgpts1,imgpts2;
for( unsigned inti = 0; i<matches.size(); i )
{
// queryIdx is the "left" image
imgpts1.push_back(keypoints1[matches[i].queryIdx].pt);
// trainIdx is the "right" image
imgpts2.push_back(keypoints2[matches[i].trainIdx].pt);
}
Mat F = findFundamentalMat(imgpts1, imgpts2, FM_RANSAC, 0.1, 0.99, status);
Mat_<double> E = K.t() * F * K; //according to HZ (9.12)
稍后我们可能会使用status
二元向量来修剪与恢复的基本矩阵对齐的那些点。 有关对基本矩阵进行修剪后的点匹配的说明,请参见下图。 红色箭头标记在找到F
矩阵的过程中删除的特征匹配,绿色箭头表示保留的特征匹配。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CFPKn5H5-1681871753507)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_5_21.jpg)]
现在,我们准备找到相机矩阵。 H 和 Z 的书的第 9 章详细描述了此过程。 但是,我们将使用非常简单明了的实现,而 OpenCV 使我们的工作变得非常简单。 但是首先,我们将简要检查我们将要使用的相机矩阵的结构。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TmBZS6z7-1681871753507)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_05_18.jpg)]
这是我们相机的模型,它由两个元素组成:旋转(表示为R
)和平移(表示为t
)。 有趣的是,它拥有一个非常重要的方程:x = PX
,其中x
是图像上的 2D 点,X
是空间中的 3D 点。 还有更多,但此矩阵为我们提供了图像点和场景点之间非常重要的关系。 因此,既然我们有寻找相机矩阵的动力,我们将看到它是如何实现的。 以下代码部分显示了如何将基本矩阵分解为旋转和平移元素:
SVD svd(E);
Matx33d W(0,-1,0,//HZ 9.13
1,0,0,
0,0,1);
Mat_<double> R = svd.u * Mat(W) * svd.vt; //HZ 9.19
Mat_<double> t = svd.u.col(2); //u3
Matx34d P1( R(0,0),R(0,1), R(0,2), t(0),
R(1,0),R(1,1), R(1,2), t(1),
R(2,0),R(2,1), R(2,2), t(2));
很简单。 我们要做的就是取之前获得的基本矩阵的奇异值分解(SVD),然后将其乘以一个特殊矩阵W
。 无需太深入地研究我们所做的数学运算,我们可以说SVD
运算将矩阵E
分解为两部分,即旋转元素和平移元素。 实际上,基本矩阵最初是由这两个元素的乘法组成的。 为了满足我们的好奇心,我们可以查看以下基本矩阵方程,该方程出现在文献中:E = [t]xR
。 我们看到它由平移元素和旋转元素R
(的某种形式)组成。
我们注意到,我们刚才所做的只是给我们一个相机矩阵,那么另一个相机矩阵在哪里? 好吧,我们在一个相机矩阵是固定且规范的(无旋转且无平移)的假设下执行此操作。 下一个相机矩阵也是规范的:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WEEtasPR-1681871753507)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_05_19.jpg)]
我们从基本矩阵中恢复的另一台摄像机已经相对于固定摄像机移动和旋转。 这也意味着我们从这两个相机矩阵中恢复的任何 3D 点都将在世界原点(0, 0, 0)
拥有第一个相机。
但是,这不是完整的解决方案。 H 和 Z 在他们的书中说明了这种分解如何以及为什么实际上具有四个可能的相机矩阵,但是只有其中一个是真实的。 正确的矩阵将产生一个带有正 Z 值的重建点(位于摄像机前面的点)。 但是我们只有在了解了三角剖分和 3D 重构之后才能理解这一点,这将在下一部分中进行讨论。
我们可以想到的另一件事就是错误检查。 很多时候,根据点匹配计算基本矩阵是错误的,这会影响相机矩阵。 用错误的相机矩阵继续进行三角剖分是毫无意义的。 我们可以安装检查以检查旋转元素是否为有效的旋转矩阵。 请记住,旋转矩阵的行列式必须为 1(或 -1),我们可以简单地执行以下操作:
代码语言:javascript复制bool CheckCoherentRotation(cv::Mat_<double>& R) {
if(fabsf(determinant(R))-1.0 > 1e-07) {
cerr<<"det(R) != -1.0, this is not a rotation matrix"<<endl;
return false;
}
return true;
}
现在,我们可以看到所有这些元素如何组合成一个恢复P
矩阵的函数,如下所示:
void FindCameraMatrices(const Mat& K,
const Mat& Kinv,
const vector<KeyPoint>& imgpts1,
const vector<KeyPoint>& imgpts2,
Matx34d& P,
Matx34d& P1,
vector<DMatch>& matches,
vector<CloudPoint>& outCloud
)
{
//Find camera matrices
//Get Fundamental Matrix
Mat F = GetFundamentalMat(imgpts1,imgpts2,matches);
//Essential matrix: compute then extract cameras [R|t]
Mat_<double> E = K.t() * F * K; //according to HZ (9.12)
//decompose E to P' , HZ (9.19)
SVD svd(E,SVD::MODIFY_A);
Mat svd_u = svd.u;
Mat svd_vt = svd.vt;
Mat svd_w = svd.w;
Matx33d W(0,-1,0,//HZ 9.13
1,0,0,
0,0,1);
Mat_<double> R = svd_u * Mat(W) * svd_vt; //HZ 9.19
Mat_<double> t = svd_u.col(2); //u3
if (!CheckCoherentRotation(R)) {
cout<<"resulting rotation is not coherentn";
P1 = 0;
return;
}
P1 = Matx34d(R(0,0),R(0,1),R(0,2),t(0),
R(1,0),R(1,1),R(1,2),t(1),
R(2,0),R(2,1),R(2,2),t(2));
}
至此,我们有了重建场景所需的两台摄像机。 P
变量中的第一台标准摄像机和我们计算出的第二台摄像机,在P1
变量中形成基本矩阵。 下一部分将揭示我们如何使用这些相机获得场景的 3D 结构。
重建场景
接下来,我们将研究从到目前为止所获得的信息中恢复场景的 3D 结构的问题。 正如我们之前所做的那样,我们应该查看实现此目的所需的工具和信息。 在上一节中,我们从基本矩阵和基本矩阵中获得了两个相机矩阵。 我们已经讨论了这些工具如何对获取空间中点的 3D 位置有用。 然后,我们可以返回匹配点对以将数值数据填充到方程式中。 这些点对在计算所有近似计算得出的误差时也将很有用。
现在是时候看看我们如何使用 OpenCV 执行三角剖分了。 这次,我们将按照 Hartley 和 Sturm 在其文章《三角剖分》中采取的步骤,在本文中他们实现并比较一些三角剖分方法。 我们将实现其线性方法之一,因为使用 OpenCV 进行编码非常简单。
请记住,我们有两个主要的方程式是由 2D 点匹配和P
矩阵产生的:x = PX
和x'= P'X
,其中x
和x'
匹配 2D 点,而X
是由两个摄像机成像的真实世界 3D 点。 如果我们重写方程式,我们可以制定一个线性方程组,该方程组可以解决X
的值,这是我们想要找到的。 假设X = (x, y, z, 1)t
(对于距离摄像机中心不太近或太远的点的合理假设)会创建形式为AX = B
的不均匀线性方程组。我们可以编码并求解该方程组如下:
Mat_<double> LinearLSTriangulation(
Point3d u,//homogenous image point (u,v,1)
Matx34d P,//camera 1 matrix
Point3d u1,//homogenous image point in 2nd camera
Matx34d P1//camera 2 matrix
)
{
//build A matrix
Matx43d A(u.x*P(2,0)-P(0,0),u.x*P(2,1)-P(0,1),u.x*P(2,2)-P(0,2),
u.y*P(2,0)-P(1,0),u.y*P(2,1)-P(1,1),u.y*P(2,2)-P(1,2),
u1.x*P1(2,0)-P1(0,0), u1.x*P1(2,1)-P1(0,1),u1.x*P1(2,2)-P1(0,2),
u1.y*P1(2,0)-P1(1,0), u1.y*P1(2,1)-P1(1,1),u1.y*P1(2,2)-P1(1,2)
);
//build B vector
Matx41d B(-(u.x*P(2,3)-P(0,3)),
-(u.y*P(2,3)-P(1,3)),
-(u1.x*P1(2,3)-P1(0,3)),
-(u1.y*P1(2,3)-P1(1,3)));
//solve for X
Mat_<double> X;
solve(A,B,X,DECOMP_SVD);
return X;
}
这将使我们近似于由两个 2D 点产生的 3D 点。 还有一点要注意的是,二维点用均一坐标表示,这意味着 x 和 y 值后面附加了 1。我们应确保这些点在归一化坐标中,这意味着它们乘以校准矩阵K
。 我们可能会注意到,就像在第 9 章中 H 和 Z 所做的那样,我们可以简单地利用 KP 矩阵(K
矩阵乘以P
矩阵)而不是将每个点乘以矩阵K
。 现在在点匹配上编写一个循环,以获取完整的三角剖分,如下所示:
double TriangulatePoints(
const vector<KeyPoint>& pt_set1,
const vector<KeyPoint>& pt_set2,
const Mat&Kinv,
const Matx34d& P,
const Matx34d& P1,
vector<Point3d>& pointcloud)
{
vector<double> reproj_error;
for (unsigned int i=0; i<pts_size; i ) {
//convert to normalized homogeneous coordinates
Point2f kp = pt_set1[i].pt;
Point3d u(kp.x,kp.y,1.0);
Mat_<double> um = Kinv * Mat_<double>(u);
u = um.at<Point3d>(0);
Point2f kp1 = pt_set2[i].pt;
Point3d u1(kp1.x,kp1.y,1.0);
Mat_<double> um1 = Kinv * Mat_<double>(u1);
u1 = um1.at<Point3d>(0);
//triangulate
Mat_<double> X = LinearLSTriangulation(u,P,u1,P1);
//calculate reprojection error
Mat_<double> xPt_img = K * Mat(P1) * X;
Point2f xPt_img_(xPt_img(0)/xPt_img(2),xPt_img(1)/xPt_img(2));
reproj_error.push_back(norm(xPt_img_-kp1));
//store 3D point
pointcloud.push_back(Point3d(X(0),X(1),X(2)));
}
//return mean reprojection error
Scalar me = mean(reproj_error);
return me[0];
}
在下面的图像中,我们将在这个页面上看到来自 Fountain P-11 序列的两个图像的三角剖分结果。 顶部的两个图像是场景的原始两个视图,底部的一对是从这两个视图重建的点云的视图,包括估计的注视着喷泉的摄像机。 我们可以看到红砖墙壁的右侧部分是如何重建的,还有从墙壁突出的喷泉。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gJWa47pj-1681871753507)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_5_26.jpg)]
但是,正如我们前面所讨论的,我们存在一个问题,那就是重建只是规模上的。 我们应该花一点时间来理解什么是规模化的意思。 我们在两个摄像机之间获得的运动将具有一个任意的度量单位,即不是以厘米或英寸为单位,而仅仅是给定的比例单位。 我们重建的相机将是一个比例尺距离的单位。 如果我们决定以后再恢复更多摄像机,这将产生很大的影响,因为每对摄像机将具有自己的比例单位,而不是通用的单位。
现在,我们将讨论我们设置的错误度量如何帮助我们找到更可靠的重建方法。 首先,我们应该注意的是,重新投影意味着我们仅需获取三角剖分的 3D 点并将其在相机上重新成像即可获得重新投影的 2D 点,然后比较原始 2D 点和重新投影的 2D 点之间的距离。 如果此距离较大,则意味着我们在三角剖分中可能会出错,因此我们可能不希望将此点包括在最终结果中。 我们的全局度量是平均投影距离,可能会提示我们三角剖分的整体效果。 高平均重投影率可能会指出P
矩阵存在问题,因此可能会导致基本矩阵或匹配特征点的计算出现问题。
我们应该简要地回到上一节中对相机矩阵的讨论。 我们提到可以通过四种不同的方式来合成相机矩阵P1
,但是只有一种合成是正确的。 现在我们知道了如何对一个点进行三角剖分,现在可以添加检查以查看四个相机矩阵中的哪一个有效。 由于这是随书附带的示例代码中介绍的细节,因此我们现在将跳过实现细节。
接下来,我们将看一下恢复在同一场景中看到的更多摄像机,并结合 3D 重建结果。
多角度重建
现在,我们知道如何从两个摄像机恢复运动和场景的几何形状,看起来很简单,只需应用相同的过程即可获得更多摄像机和更多场景点的参数。 实际上,这件事并不是那么简单,因为我们只能得到最大比例的重建,并且每对图片给我们一个不同的比例。
有多种方法可以从多个视图正确地重建 3D 场景数据。 一种方法是切除或相机姿态估计,也称为 N 点透视(PNP),我们尝试使用我们已经找到的场景点求解新相机的位置。 另一种方法是对更多的点进行三角剖分,并查看它们如何适合我们现有的场景几何体。 这将通过迭代最近点(ICP)程序告诉我们新相机的位置。 在本章中,我们将讨论使用 OpenCV 的solvePnP
函数实现第一种方法。
我们在这种重建中选择的第一步(通过摄像机后方切除进行增量 3D 重建)是获得基线场景结构。 当我们要基于场景的已知结构寻找任何新相机的位置时,我们需要找到一个初始结构和一个基准来使用。 我们可以使用前面讨论的方法(例如,在第一帧和第二帧之间)通过找到相机矩阵(使用FindCameraMatrices
函数)和对几何进行三角剖分(使用TriangulatePoints
函数)来获取基线。
找到初始结构后,我们可以继续; 但是,我们的方法需要大量的簿记。 首先,我们应该注意solvePnP
函数需要两个对齐的 3D 和 2D 点向量。 对齐的向量表示一个向量中的第i
个位置与另一个向量中的第i
个位置对齐。 为了获得这些向量,我们需要在我们较早恢复的 3D 点中找到与新帧中 2D 点对齐的那些点。 一种简单的方法是为云中的每个 3D 点附加一个向量,该向量表示它来自的 2D 点。 然后,我们可以使用特征匹配来获得匹配对。
让我们介绍一下 3D 点的新结构,如下所示:
代码语言:javascript复制struct CloudPoint {
cv::Point3d pt;
std::vector<int>index_of_2d_origin;
};
它在 3D 点的顶部保持指向每个帧具有的 2D 点的向量内的 2D 点的索引,而 2D 点对这一 3D 点有所贡献。 在对新的 3D 点进行三角剖分时,必须初始化index_of_2d_origin
的信息,并记录参与三角剖分的摄像机。 然后,我们可以使用它从我们的 3D 点云追溯到每个帧中的 2D 点,如下所示:
std::vector<CloudPoint> pcloud; //our global 3D point cloud
//check for matches between i'th frame and 0'th frame (and thus the current cloud)
std::vector<cv::Point3f> ppcloud;
std::vector<cv::Point2f> imgPoints;
vector<int> pcloud_status(pcloud.size(),0);
//scan the views we already used (good_views)
for (set<int>::iterator done_view = good_views.begin(); done_view != good_views.end(); done_view)
{
int old_view = *done_view; //a view we already used for reconstrcution
//check for matches_from_old_to_working between <working_view>'th frame and <old_view>'th frame (and thus the current cloud)
std::vector<cv::DMatch> matches_from_old_to_working = matches_matrix[std::make_pair(old_view,working_view)];
//scan the 2D-2D matched-points
for (unsigned int match_from_old_view=0; match_from_old_view<matches_from_old_to_working.size(); match_from_old_view ) {
// the index of the matching 2D point in <old_view>
int idx_in_old_view = matches_from_old_to_working[match_from_old_view].queryIdx;
//scan the existing cloud to see if this point from <old_view> exists for (unsigned int pcldp=0; pcldp<pcloud.size(); pcldp ) {
// see if this 2D point from <old_view> contributed to this 3D point in the cloud
if (idx_in_old_view == pcloud[pcldp].index_of_2d_origin[old_view] && pcloud_status[pcldp] == 0) //prevent duplicates
{
//3d point in cloud
ppcloud.push_back(pcloud[pcldp].pt);
//2d point in image <working_view>
Point2d pt_ = imgpts[working_view][matches_from_old_to_working[match_from_old_view].trainIdx].pt;
imgPoints.push_back(pt_);
pcloud_status[pcldp] = 1;
break;
}
}
}
}
cout<<"found "<<ppcloud.size() <<" 3d-2d point correspondences"<<endl;
现在,我们将场景中的 3D 点与新帧中的 2D 点对齐对齐,可以使用它们来恢复相机位置,如下所示:
代码语言:javascript复制cv::Mat_<double> t,rvec,R;
cv::solvePnPRansac(ppcloud, imgPoints, K, distcoeff, rvec, t, false);
//get rotation in 3x3 matrix form
Rodrigues(rvec, R);
P1 = cv::Matx34d(R(0,0),R(0,1),R(0,2),t(0),
R(1,0),R(1,1),R(1,2),t(1),
R(2,0),R(2,1),R(2,2),t(2));
请注意,我们在使用solvePnPRansac
函数而不是solvePnP
函数的,因为它对异常值更鲁棒。 现在我们有了一个新的P1
矩阵,我们可以简单地使用我们先前定义的TriangulatePoints
函数,并用更多 3D 点填充点云。
在下图中,我们从第四个图像开始,在这个页面处看到 Fountain-P11 场景的增量重建。 左上图是使用四个图像后的重建; 参与的摄像机显示为红色金字塔,白线显示方向。 其他图像显示更多的摄像机如何向云中添加更多的点。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RYIJcRPb-1681871753507)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_5_27.jpg)]
优化后的重建
SfM 方法最重要的部分之一是优化和优化重建的场景,也称为包调整(BA)的过程。 此是优化步骤,其中,我们收集的所有数据均拟合为整体模型。 3D 点的位置和相机的位置都得到了优化,因此重投影误差最小化(即,将近似的 3D 点投影到图像上接近原始 2D 点的位置)。 该过程通常需要求解成千上万个参数的非常大的线性方程。 该过程可能会有些费力,但是我们之前采取的步骤将使与 Bundle Adjuster 的集成变得容易。 以前看起来有些奇怪的某些事情可能会变得清晰起来。 例如,我们为云中的每个 3D 点保留原点 2D 点的原因。
捆绑调整算法的一种实现是简单稀疏捆绑调整(SSBA)库; 我们将选择它作为我们的 BA 优化器,因为它具有简单的 API。 它只需要几个输入参数,就可以从数据结构中轻松创建这些输入参数。 我们将从 SSBA 使用的关键对象是CommonInternalsMetricBundleOptimizer
函数,该函数执行优化。 它需要相机参数,3D 点云,与点云中每个点相对应的 2D 图像点,以及观看场景的相机。 到现在为止,这些参数应该很简单。 我们应该注意,BA 的这种方法假定所有图像都是由相同的硬件拍摄的,因此,内部通用的其他操作模式可能不会采用此方法。 我们可以按如下方式进行捆绑调整:
voidBundleAdjuster::adjustBundle(
vector<CloudPoint>&pointcloud,
const Mat&cam_intrinsics,
conststd::vector<std::vector<cv::KeyPoint>>&imgpts,
std::map<int ,cv::Matx34d>&Pmats
)
{
int N = Pmats.size(), M = pointcloud.size(), K = -1;
cout<<"N (cams) = "<< N <<" M (points) = "<< M <<" K (measurements) = "<< K <<endl;
StdDistortionFunction distortion;
// intrinsic parameters matrix
Matrix3x3d KMat;
makeIdentityMatrix(KMat);
KMat[0][0] = cam_intrinsics.at<double>(0,0);
KMat[0][1] = cam_intrinsics.at<double>(0,1);
KMat[0][2] = cam_intrinsics.at<double>(0,2);
KMat[1][1] = cam_intrinsics.at<double>(1,1);
KMat[1][2] = cam_intrinsics.at<double>(1,2);
...
// 3D point cloud
vector<Vector3d >Xs(M);
for (int j = 0; j < M; j)
{
Xs[j][0] = pointcloud[j].pt.x;
Xs[j][1] = pointcloud[j].pt.y;
Xs[j][2] = pointcloud[j].pt.z;
}
cout<<"Read the 3D points."<<endl;
// convert cameras to BA datastructs
vector<CameraMatrix> cams(N);
for (inti = 0; i< N; i)
{
intcamId = i;
Matrix3x3d R;
Vector3d T;
Matx34d& P = Pmats[i];
R[0][0] = P(0,0); R[0][1] = P(0,1); R[0][2] = P(0,2); T[0] = P(0,3);
R[1][0] = P(1,0); R[1][1] = P(1,1); R[1][2] = P(1,2); T[1] = P(1,3);
R[2][0] = P(2,0); R[2][1] = P(2,1); R[2][2] = P(2,2); T[2] = P(2,3);
cams[i].setIntrinsic(Knorm);
cams[i].setRotation(R);
cams[i].setTranslation(T);
}
cout<<"Read the cameras."<<endl;
vector<Vector2d > measurements;
vector<int> correspondingView;
vector<int> correspondingPoint;
// 2D corresponding points
for (unsigned int k = 0; k <pointcloud.size(); k)
{
for (unsigned int i=0; i<pointcloud[k].imgpt_for_img.size(); i ) {
if (pointcloud[k].imgpt_for_img[i] >= 0) {
int view = i, point = k;
Vector3d p, np;
Point cvp = imgpts[i][pointcloud[k].imgpt_for_img[i]].pt;
p[0] = cvp.x;
p[1] = cvp.y;
p[2] = 1.0;
// Normalize the measurements to match the unit focal length.
scaleVectorIP(1.0/f0, p);
measurements.push_back(Vector2d(p[0], p[1]));
correspondingView.push_back(view);
correspondingPoint.push_back(point);
}
}
} // end for (k)
K = measurements.size();
cout<<"Read "<< K <<" valid 2D measurements."<<endl;
...
// perform the bundle adjustment
{
CommonInternalsMetricBundleOptimizeropt(V3D::FULL_BUNDLE_FOCAL_LENGTH_PP, inlierThreshold, K0, distortion, cams, Xs, measurements, correspondingView, correspondingPoint);
opt.tau = 1e-3;
opt.maxIterations = 50;
opt.minimize();
cout<<"optimizer status = "<<opt.status<<endl;
}
...
//extract 3D points
for (unsigned int j = 0; j <Xs.size(); j)
{
pointcloud[j].pt.x = Xs[j][0];
pointcloud[j].pt.y = Xs[j][1];
pointcloud[j].pt.z = Xs[j][2];
}
//extract adjusted cameras
for (int i = 0; i< N; i)
{
Matrix3x3d R = cams[i].getRotation();
Vector3d T = cams[i].getTranslation();
Matx34d P;
P(0,0) = R[0][0]; P(0,1) = R[0][1]; P(0,2) = R[0][2]; P(0,3) = T[0];
P(1,0) = R[1][0]; P(1,1) = R[1][1]; P(1,2) = R[1][2]; P(1,3) = T[1];
P(2,0) = R[2][0]; P(2,1) = R[2][1]; P(2,2) = R[2][2]; P(2,3) = T[2];
Pmats[i] = P;
}
}
这段代码虽然很长,但主要用于将内部数据结构与 SSBA 的数据结构相互转换,并调用优化过程。
下图显示了 BA 的效果。 从两个角度看,左侧的两个图像是调整前的点云的点,右侧的图像显示了优化的云。 这种变化是非常显着的,并且从不同角度剖分的点之间的许多不对齐现在已得到了巩固。 我们还可以注意到调整是如何更好地重建平面的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Lld8P2wQ-1681871753508)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_5_28.jpg)]
使用 PCL 可视化 3D 点云
在处理 3D 数据时,仅通过查看重投影误差度量或原始点信息就很难快速了解结果是否正确。 另一方面,如果我们查看点云本身,则可以立即验证它是否有意义或是否有错误。 为了可视化,我们将使用一个新兴的 OpenCV 姊妹项目,称为点云库(PCL)。 它带有许多用于可视化和分析点云的工具,例如查找平面,匹配点云,分割对象和消除离群值。 如果我们的目标不是点云,而是一些更高阶的信息(例如 3D 模型),则这些工具非常有用。
首先,我们应该在 PCL 的数据结构中表示我们的云(基本上是 3D 点列表)。 可以按照以下步骤进行:
代码语言:javascript复制pcl::PointCloud<pcl::PointXYZRGB>::Ptr cloud;
void PopulatePCLPointCloud(const vector<Point3d>& pointcloud,
const std::vector<cv::Vec3b>& pointcloud_RGB
)
//Populate point cloud
{
cout<<"Creating point cloud...";
cloud.reset(new pcl::PointCloud<pcl::PointXYZRGB>);
for (unsigned int i=0; i<pointcloud.size(); i ) {
// get the RGB color value for the point
Vec3b rgbv(255,255,255);
if (pointcloud_RGB.size() >= i) {
rgbv = pointcloud_RGB[i];
}
// check for erroneous coordinates (NaN, Inf, etc.)
if (pointcloud[i].x != pointcloud[i].x || isnan(pointcloud[i].x) ||
pointcloud[i].y != pointcloud[i].y || isnan(pointcloud[i].y) ||
pointcloud[i].z != pointcloud[i].z || isnan(pointcloud[i].z) ||
fabsf(pointcloud[i].x) > 10.0 ||
fabsf(pointcloud[i].y) > 10.0 ||
fabsf(pointcloud[i].z) > 10.0) {
continue;
}
pcl::PointXYZRGB pclp;
// 3D coordinates
pclp.x = pointcloud[i].x;
pclp.y = pointcloud[i].y;
pclp.z = pointcloud[i].z;
// RGB color, needs to be represented as an integer
uint32_t rgb = ((uint32_t)rgbv[2] << 16 | (uint32_t)rgbv[1] << 8 | (uint32_t)rgbv[0]);
pclp.rgb = *reinterpret_cast<float*>(&rgb);
cloud->push_back(pclp);
}
cloud->width = (uint32_t) cloud->points.size(); // number of points
cloud->height = 1; // a list of points, one row of data
}
为了使可视化效果很好,我们还可以提供颜色数据作为从图像中获取的 RGB 值。 我们还可以使用统计离群值去除(SOR)工具对原始云应用过滤器,以消除可能离群的点:
代码语言:javascript复制Void SORFilter() {
pcl::PointCloud<pcl::PointXYZRGB>::Ptr cloud_filtered (new pcl::PointCloud<pcl::PointXYZRGB>);
std::cerr<<"Cloud before SOR filtering: "<< cloud->width * cloud->height <<" data points"<<std::endl;
// Create the filtering object
pcl::StatisticalOutlierRemoval<pcl::PointXYZRGB>sor;
sor.setInputCloud (cloud);
sor.setMeanK (50);
sor.setStddevMulThresh (1.0);
sor.filter (*cloud_filtered);
std::cerr<<"Cloud after SOR filtering: "<<cloud_filtered->width * cloud_filtered->height <<" data points "<<std::endl;
copyPointCloud(*cloud_filtered,*cloud);
}
然后,我们可以使用 PCL 的 API 运行简单的点云可视化程序,如下所示:
代码语言:javascript复制Void RunVisualization(const vector<cv::Point3d>& pointcloud,
const std::vector<cv::Vec3b>& pointcloud_RGB) {
PopulatePCLPointCloud(pointcloud,pointcloud_RGB);
SORFilter();
copyPointCloud(*cloud,*orig_cloud);
pcl::visualization::CloudViewer viewer("Cloud Viewer");
// run the cloud viewer
viewer.showCloud(orig_cloud,"orig");
while (!viewer.wasStopped ())
{
// NOP
}
}
下图显示了使用统计异常值消除工具后的输出。 左侧的图像是 SfM 的原始合成云,带有相机位置和该云特定部分的放大视图。 右侧的图像显示了 SOR 操作后的过滤后的云。 我们可以注意到一些杂散点已被删除,留下了更干净的点云:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HA5SNkQn-1681871753508)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829OS_5_29.jpg)]
使用示例代码
我们可以在本书的辅助材料中找到 SfM 的示例代码。 现在,我们将看到如何构建,运行和利用它。 该代码利用了 CMake,这是一个类似于 Maven 或 SCons 的跨平台构建环境。 我们还应确保具有以下所有先决条件才能构建应用:
- OpenCV v2.3 或更高版本
- PCL v1.6 或更高版本
- SSBA v3.0 或更高版本
首先,我们必须建立构建环境。 为此,我们可以创建一个名为build
的文件夹,所有与构建相关的文件都将放入该文件夹; 我们现在假定所有命令行操作都在build/
文件夹中,尽管即使不使用build
文件夹,该过程也是相似的(取决于文件的位置)。
我们应该确保 CMake 可以找到 SSBA 和 PCL。 如果 PCL 安装正确,则应该不会有问题。 但是,我们必须设置正确的位置,才能通过-DSSBA_LIBRARY_DIR =…
构建参数找到 SSBA 的预构建二进制文件。 如果使用 Windows 作为操作系统,则可以使用 Microsoft Visual Studio 进行构建。 因此,我们应该运行以下命令:
cmake –G "Visual Studio 10" -DSSBA_LIBRARY_DIR=../3rdparty/SSBA-3.0/build/ ..
如果使用 Linux,Mac OS 或其他类似 Unix 的操作系统,则执行以下命令:
代码语言:javascript复制cmake –G "Unix Makefiles" -DSSBA_LIBRARY_DIR=../3rdparty/SSBA-3.0/build/ ..
如果我们更喜欢在 Mac OS 上使用 XCode,请执行以下命令:
代码语言:javascript复制cmake –G Xcode -DSSBA_LIBRARY_DIR=../3rdparty/SSBA-3.0/build/ ..
CMake 还具有为 Eclipse,代码块等构建宏的能力。 完成 CMake 的创建环境后,我们就可以开始构建了。 如果我们使用的是类似 Unix 的系统,我们可以简单地执行 make 工具,否则我们应该使用开发环境的构建过程。
构建完成后,我们应该留下一个名为ExploringSfMExec
的可执行文件,该可执行文件将运行 SfM 进程。 不带参数运行它会导致以下结果:Usage: ./ExploringSfMExec <path_to_images>
。
要对一组图像执行该过程,我们应在驱动器上提供一个位置以查找图像文件。 如果提供了有效位置,则该过程应该开始,并且我们应该在屏幕上看到进度和调试信息。 该过程将结束于图像产生的点云的显示。 按下1
和2
键将在已调整点云和未调整点云之间切换。
总结
在本章中,我们已经了解了 OpenCV 如何以一种既易于编码又易于理解的方式帮助我们从 Motion 处理结构。 OpenCV 的 API 包含许多有用的功能和数据结构,这些功能和数据结构使我们的生活更轻松,也有助于更清洁的实现。
但是,最新的 SfM 方法要复杂得多。 为了简单起见,我们选择忽略许多问题,通常还会进行许多错误检查。 我们针对 SfM 不同元素选择的方法也可以重新考虑。 首先,H 和 Z 提出了一种高精度的三角剖分方法,该方法可将图像域中的重投影误差降至最低。 一旦了解了多幅图像中特征之间的关系,某些方法甚至会使用 N 视图三角剖分。
如果我们想扩展和加深对 SfM 的了解,一定会从其他开源 SfM 库中受益。 一个特别有趣的项目是 libMV,它实现了大量 SfM 元素,可以互换这些元素以获得最佳结果。 华盛顿大学有很多出色的工作,可以为多种 SfM(Bundler 和 VisualSfM)提供工具。 这项工作启发了微软的在线产品 PhotoSynth。 SfM 的更多实现可随时在线获得,并且仅需搜索即可找到很多实现。
我们尚未深入讨论的另一个重要关系是 SfM 与视觉本地化和映射的关系,在即时定位与地图构建(SLAM)方法中更为人所知。 在本章中,我们处理了给定的图像和视频序列数据集,在这些情况下使用 SfM 是可行的。 但是,某些应用没有预先记录的数据集,因此必须即时引导重建。 这个过程被称为地图构建,它是在我们使用 2D 中的特征匹配和跟踪以及在三角剖分之后创建世界 3D 地图时完成的。
在下一章中,我们将了解如何使用机器学习中的各种技术将 OpenCV 用于从图像中提取车牌号。
参考
Multiple View Geometry in Computer Vision, Richard Hartley and Andrew Zisserman, Cambridge University Press
Triangulation, Richard I. Hartley and Peter Sturm, Computer vision and image understanding, Vol. 68, pp. 146-157
- http://cvlab.epfl.ch/~strecha/multiview/denseMVS.html
On Benchmarking Camera Calibration and Multi-View Stereo for High Resolution Imagery,C. Strecha, W. von Hansen, L. Van Gool, P. Fua, and U. Thoennessen, CVPR
- http://www.inf.ethz.ch/personal/chzach/opensource.html
- http://www.ics.forth.gr/~lourakis/sba/
- http://code.google.com/p/libmv/
- http://www.cs.washington.edu/homes/ccwu/vsfm/
- http://phototour.cs.washington.edu/bundler/
- http://photosynth.net/
- http://en.wikipedia.org/wiki/Simultaneous_localization_and_mapping
- http://pointclouds.org
- http://www.cmake.org
五、使用 SVM 和神经网络识别车牌
本章向我们介绍了创建自动车牌识别(ANPR)应用所需的步骤。 根据不同的情况有不同的方法和技术,例如,IR 摄像机,固定的汽车位置,光线条件等。 我们可以着手构建一个 ANPR 应用,以检测在距离汽车 2-3 米之间,光线不清晰,地面不平行且汽车牌照的透视变形很小的照片中检测汽车牌照的情况。
本章的主要目的是向我们介绍图像分割和特征提取,模式识别基础以及两种重要的模式识别算法支持向量机和人工神经网络。 在本章中,我们将介绍:
- ANPR
- 车牌
- 车牌识别
ANPR 简介
自动车牌识别(ANPR),也称为自动车牌识别(ALPR),自动车辆识别(AVI)或车牌识别(CPR),是一种使用光学字符识别(OCR)和其他方法的监视方法,例如分割和检测来读取车辆牌照。
使用红外(IR)摄像机可以获得 ANPR 系统中的最佳结果,因为检测和 OCR 分割的分割步骤简单,干净,并最大程度地减少了错误。 这是由于光的规律,最基本的是入射角等于反射角; 当我们看到光滑的表面(例如平面镜)时,我们可以看到这种基本反射。 粗糙表面(例如纸张)的反射会导致一种反射类型,即漫反射或散射反射。 大多数车牌都有一个特殊的特性,称为后向反射-车牌的表面是用一种材料制成的,该材料覆盖了成千上万个微小的半球,导致光被反射回光源,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s4X4dY9y-1681871753508)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_01.jpg)]
如果我们使用带滤光片的摄像机和结构化的红外光投影仪,我们可以仅检索红外光,然后就可以分割出非常高质量的图像,随后进行检测并识别与独立于任何光线环境的车牌号码,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OyzJoSGm-1681871753508)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_02.jpg)]
在本章中,我们不使用 IR 照片; 我们使用普通照片。 这样做是为了避免获得最佳结果,并获得更高水平的检测错误和更高的错误识别率,这与使用红外热像仪所期望的结果相反; 但是,两者的步骤是相同的。
每个国家都有不同的车牌尺寸和规格; 了解这些规格对于获得最佳结果并减少错误很有用。 本章中使用的算法旨在说明 ANPR 的基本知识以及西班牙车牌的规格,但我们可以将其扩展到任何国家或规格。
在本章中,我们将使用西班牙的车牌。 在西班牙,有三种不同尺寸和形状的车牌; 我们将仅使用最常见的(大型)车牌520 x 110
毫米。 两组字符之间相隔 41 毫米,然后 14 毫米宽将每个字符分开。 第一组字符有四个数字,第二组字符有三个字母,没有元音 A,E,I,O,U,也没有字母 N 或 Q。 所有字符的尺寸均为520 x 110
毫米。
此数据对于字符分割非常重要,因为我们可以同时检查字符和空格以验证是否得到了字符,而没有其他图像分割。 下图是一个这样的车牌的图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SZdQylPM-1681871753508)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_05.jpg)]
ANPR 算法
在解释 ANPR 代码之前,我们需要定义 ANPR 算法中的主要步骤和任务。 ANPR 分为两个主要步骤:印版检测和印版识别。 印版检测的目的是检测印版在整个相机帧中的位置。 当在图像中检测到印版时,将印版段传递到第二步(印版识别),该步骤使用 OCR 算法确定印版上的字母数字字符。
在下图中,我们可以看到两个主要算法步骤,即印版检测和印版识别。 完成这些步骤后,程序会在摄像机帧上绘制已检测到的印版字符。 这些算法可能会返回不良结果,甚至没有结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2fzF2eK5-1681871753509)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_03.jpg)]
在上图中显示的每个步骤中,我们将定义模式识别算法中常用的三个附加步骤:
- 分割:此步骤检测并移除图像中每个感兴趣的面片/区域。
- 特征提取:此步骤从每个补丁中提取一组特征。
- 分类:此步骤从印版识别步骤中提取每个字符,或在印版检测步骤中将每个图像块分类为“印版”或“无印版”。
下图显示了整个算法应用中的模式识别步骤:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-c38xJzBX-1681871753509)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_04.jpg)]
除了主要的应用(其目的是检测和识别汽车的车牌号)之外,我们还将简要说明另外两个通常不解释的任务:
- 如何训练模式识别系统
- 如何评估这样的系统
但是,这些任务比主应用本身更重要,因为如果我们没有正确地训练模式识别系统,我们的系统可能会失败并且无法正常工作; 不同的模式需要不同类型的训练和评估。 我们需要在不同的环境,条件和具有不同特征的情况下评估我们的系统,以获得最佳结果。 这两个任务有时会一起使用,因为不同的特征会产生不同的结果,我们可以在评估部分中看到这些结果。
车牌检测
在此步骤中,我们必须检测当前相机帧中的所有印版。 为此,我们将其分为两个主要步骤:分割和分割分类。 由于我们将图像块用作向量特征,因此未解释特征步骤。
在第一步(分段)中,我们应用不同的过滤器,形态运算,轮廓算法和验证来检索图像中可能具有印版的那些部分。
在第二步(分类)中,我们将支持向量机(SVM)分类器应用于每个图像补丁,即我们的特征。 在创建我们的主要应用之前,我们使用两种不同的类别进行训练:平板和非平板。 我们处理的平行正面彩色图像的宽度为 800 像素,距离汽车 2–4 米。 这些要求对于确保正确的分割很重要。 如果创建多尺度图像算法,则可以执行检测。
在下一个图像中,我们显示了车牌所涉及的所有过程:
- Sobel 过滤器
- 门限操作
- 紧密的形态学操作
- 一个填充区域的遮罩
- 可能检测到的印有红色标记的板(特征图像)
- SVM 分类器后检测到的板 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3ZbSqycx-1681871753509)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_06.jpg)]
分割
分割是将图像分成多个段的过程。 此过程是为了简化图像以进行分析并使特征提取更加容易。
车牌分割的一个重要特征是,假设图像是正面拍摄的,并且车牌中的垂直边缘数量很多,并且车牌没有旋转并且没有透视失真。 可以在第一个分割步骤中利用此特征,以消除没有任何垂直边缘的区域。
在找到垂直边缘之前,我们需要将彩色图像转换为灰度图像(因为彩色无法帮助我们完成此任务),并消除由相机产生的可能的噪声或其他环境噪声。 我们将应用5 x 5
的高斯模糊并去除噪声。 如果不采用噪声消除方法,则可能会产生许多垂直边缘,从而导致检测失败。
//convert image to gray
Mat img_gray;
cvtColor(input, img_gray, CV_BGR2GRAY);
blur(img_gray, img_gray, Size(5,5));
为了找到垂直边缘,我们将使用 Sobel 过滤器并找到第一水平导数。 导数是一个数学函数,它使我们能够找到图像的垂直边缘。 OpenCV 中 Sobel 函数的定义是:
代码语言:javascript复制void Sobel(InputArray src, OutputArray dst, int ddepth, int xorder, int yorder, int ksize=3, double scale=1, double delta=0, int borderType=BORDER_DEFAULT )
在这里,ddepth
是目标图像深度,xorder
是x
的导数阶,yorder
是y
的导数阶,ksize
是 1、3、5 或在图 7 中,scale
是计算的导数值的可选因子,delta
是添加到结果中的可选值,borderType
是像素插值方法。
对于我们的情况,我们可以使用xorder=1
,yorder=0
和ksize=3
:
//Find vertical lines. Car plates have high density of vertical lines
Mat img_sobel;
Sobel(img_gray, img_sobel, CV_8U, 1, 0, 3, 1, 0);
在 Sobel 过滤器之后,我们应用阈值过滤器来获得具有通过大津方法获得的阈值的二进制图像。大津的算法需要 8 位输入图像,而大津的方法会自动确定最佳阈值:
代码语言:javascript复制//threshold image
Mat img_threshold;
threshold(img_sobel, img_threshold, 0, 255, CV_THRESH_OTSU CV_THRESH_BINARY);
要在threshold
函数中定义大津的方法,如果我们将类型参数与CV_THRESH_OTSU
值组合,则将忽略阈值参数。
提示
当定义了CV_THRESH_OTSU
的值时,阈值函数返回通过大津算法获得的最佳阈值。
通过应用接近的形态学操作,我们可以删除每条垂直边线之间的空白,并连接所有具有大量边的区域。 在此步骤中,我们可能会包含板块。
首先,我们定义要在形态学操作中使用的结构元素。 我们将使用getStructuringElement
函数定义尺寸为17 x 3
的结构矩形元素。 其他图像尺寸可能有所不同:
Mat element = getStructuringElement(MORPH_RECT, Size(17, 3));
并使用morphologyEx
函数在紧密的形态学操作中使用此结构元素:
morphologyEx(img_threshold, img_threshold, CV_MOP_CLOSE, element);
应用这些函数后,图像中的区域可能包含印版; 但是,大多数地区不会包含车牌。 这些区域可以通过连接组件分析或使用findContours
函数进行拆分。 最后一个函数使用不同的方法和结果检索二进制图像的轮廓。 我们只需要获取具有任何层次关系和任何多边形逼近结果的外部轮廓:
//Find contours of possibles plates
vector< vector< Point> > contours;
findContours(img_threshold,
contours, // a vector of contours
CV_RETR_EXTERNAL, // retrieve the external contours
CV_CHAIN_APPROX_NONE); // all pixels of each contour
对于检测到的每个轮廓,提取最小面积的边界矩形。 OpenCV 为此任务打开了minAreaRect
函数。 此函数返回称为RotatedRect
的旋转矩形类。 然后在每个轮廓上使用向量迭代器,我们可以得到旋转的矩形并在对每个区域进行分类之前进行一些初步验证:
//Start to iterate to each contour found
vector<vector<Point> >::iterator itc= contours.begin();
vector<RotatedRect> rects;
//Remove patch that has no inside limits of aspect ratio and area.
while (itc!=contours.end()) {
//Create bounding rect of object
RotatedRect mr= minAreaRect(Mat(*itc));
if( !verifySizes(mr)){
itc= contours.erase(itc);
}else{
itc;
rects.push_back(mr);
}
}
我们基于检测到的区域的面积和纵横比进行基本验证。 我们仅认为,如果长宽比约为520/110 = 4.727272
(板宽除以板高)且误差范围为 40%,且面积至少为 15 个像素,最大为 125,则该区域可以是一个平板高度的像素。 这些值的计算取决于图像尺寸和相机位置:
bool DetectRegions::verifySizes(RotatedRect candidate ){
float error=0.4;
//Spain car plate size: 52x11 aspect 4,7272
const float aspect=4.7272;
//Set a min and max area. All other patches are discarded
int min= 15*aspect*15; // minimum area
int max= 125*aspect*125; // maximum area
//Get only patches that match to a respect ratio.
float rmin= aspect-aspect*error;
float rmax= aspect aspect*error;
int area= candidate.size.height * candidate.size.width;
float r= (float)candidate.size.width / (float)candidate.size.height;
if(r<1)
r= 1/r;
if(( area < min || area > max ) || ( r < rmin || r > rmax )){
return false;
}else{
return true;
}
}
我们可以使用车牌的白色背景属性进行更多改进。 所有印版具有相同的背景色,我们可以使用泛洪填充算法来检索旋转的矩形以进行精确裁剪。
修剪车牌的第一步是在最后一个旋转的矩形中心附近获取几粒种子。 然后在宽度和高度之间获得最小的平板尺寸,并使用它在贴片中心附近生成随机种子。
我们要选择白色区域,并且需要多个种子才能触摸至少一个白色像素。 然后,对于每个种子,我们使用floodFill
函数绘制新的遮罩图像以存储新的最接近的裁剪区域:
for(int i=0; i< rects.size(); i ){
//For better rect cropping for each possible box
//Make floodfill algorithm because the plate has white background
//And then we can retrieve more clearly the contour box
circle(result, rects[i].center, 3, Scalar(0,255,0), -1);
//get the min size between width and height
float minSize=(rects[i].size.width < rects[i].size.height)?rects[i].size.width:rects[i].size.height;
minSize=minSize-minSize*0.5;
//initialize rand and get 5 points around center for floodfill algorithm
srand ( time(NULL) );
//Initialize floodfill parameters and variables
Mat mask;
mask.create(input.rows 2, input.cols 2, CV_8UC1);
mask= Scalar::all(0);
int loDiff = 30;
int upDiff = 30;
int connectivity = 4;
int newMaskVal = 255;
int NumSeeds = 10;
Rect ccomp;
int flags = connectivity (newMaskVal << 8 ) CV_FLOODFILL_FIXED_RANGE CV_FLOODFILL_MASK_ONLY;
for(int j=0; j<NumSeeds; j ){
Point seed;
seed.x=rects[i].center.x rand()%(int)minSize-(minSize/2);
seed.y=rects[i].center.y rand()%(int)minSize-(minSize/2);
circle(result, seed, 1, Scalar(0,255,255), -1);
int area = floodFill(input, mask, seed, Scalar(255,0,0), &ccomp, Scalar(loDiff, loDiff, loDiff), Scalar(upDiff, upDiff, upDiff), flags);
}
floodFill
函数从种子点开始将具有颜色的连接分量填充到遮罩图像中,并设置要填充的像素与相邻像素或种子像素之间的最大上下亮度/色差:
int floodFill(InputOutputArray image, InputOutputArray mask, Point seed, Scalar newVal, Rect* rect=0, Scalar loDiff=Scalar(), Scalar upDiff=Scalar(), int flags=4 )
newVal
参数是我们要在填充时放入图像中的新颜色。 参数loDiff
和upDiff
是要填充的像素与相邻像素或种子像素之间的最大下部和最大上部亮度/色差。
flag
参数是以下各项的组合:
- 低位:这些位包含函数中使用的连接性值 4(默认值)或 8。 连通性确定要考虑像素的哪个邻居。
- 高位:这些可以是 0 或以下值的组合:
CV_FLOODFILL_FIXED_RANGE
和CV_FLOODFILL_MASK_ONLY
。
CV_FLOODFILL_FIXED_RANGE
设置当前像素和种子像素之间的差异。 CV_FLOODFILL_MASK_ONLY
仅会填充图像遮罩,而不会更改图像本身。
一旦有了裁剪遮罩,就可以从图像遮罩点获得最小面积的矩形,然后再次检查有效尺寸。 对于每个遮罩,一个白色像素获取位置并使用minAreaRect
函数检索最近的裁剪区域:
//Check new floodfill mask match for a correct patch.
//Get all points detected for minimal rotated Rect
vector<Point> pointsInterest;
Mat_<uchar>::iterator itMask= mask.begin<uchar>();
Mat_<uchar>::iterator end= mask.end<uchar>();
for( ; itMask!=end; itMask)
if(*itMask==255)
pointsInterest.push_back(itMask.pos());
RotatedRect minRect = minAreaRect(pointsInterest);
if(verifySizes(minRect)){
…
现在,分割过程已经完成,并且我们具有有效的区域,我们可以裁剪每个检测到的区域,删除任何可能的旋转,裁剪图像区域,调整图像大小,并均衡裁剪图像区域的光。
首先,我们需要使用getRotationMatrix2D
生成变换矩阵,以去除检测区域中可能的旋转。 我们需要注意高度,因为RotatedRect
类可以返回并旋转 90 度,所以我们必须检查矩形的宽高比,如果小于 1,则将其旋转 90 度:
//Get rotation matrix
float r= (float)minRect.size.width / (float)minRect.size.height;
float angle=minRect.angle;
if(r<1)
angle=90 angle;
Mat rotmat= getRotationMatrix2D(minRect.center, angle,1);
使用变换矩阵,我们现在可以使用warpAffine
函数通过仿射变换(几何中的仿射变换是将平行线转换为平行线的变换)旋转输入图像,在其中设置输入图像和目标图像 ,转换矩阵,输出大小(与本例中的输入相同)以及要使用的插值方法。 如果需要,我们可以定义border
方法和border
值:
//Create and rotate image
Mat img_rotated;
warpAffine(input, img_rotated, rotmat, input.size(), CV_INTER_CUBIC);
旋转图像后,我们使用getRectSubPix
裁剪图像,裁剪并复制以点为中心的给定宽度和高度的图像部分。 如果图像已旋转,则需要使用 C swap
函数更改宽度和高度大小。
//Crop image
Size rect_size=minRect.size;
if(r < 1)
swap(rect_size.width, rect_size.height);
Mat img_crop;
getRectSubPix(img_rotated, rect_size, minRect.center, img_crop);
裁剪的图像尺寸不一样,因此不适用于训练和分类。 而且,每个图像包含不同的光照条件,从而增加了它们的相对差异。 为了解决这个问题,我们将所有图像调整为相同的宽度和高度,并应用光直方图均衡化:
代码语言:javascript复制Mat resultResized;
resultResized.create(33,144, CV_8UC3);
resize(img_crop, resultResized, resultResized.size(), 0, 0, INTER_CUBIC);
//Equalize cropped image
Mat grayResult;
cvtColor(resultResized, grayResult, CV_BGR2GRAY);
blur(grayResult, grayResult, Size(3,3));
equalizeHist(grayResult, grayResult);
对于每个检测到的区域,我们将裁剪后的图像及其位置存储在向量中:
代码语言:javascript复制output.push_back(Plate(grayResult,minRect.boundingRect()));
分类
在预处理并分割图像的所有可能部分之后,我们现在需要确定每个分割段是否是(或不是)车牌。 为此,我们将使用支持向量机(SVM)算法。
支持向量机是一种模式识别算法,包含在最初为二分类创建的一系列监督学习算法中。 监督学习是一种机器学习算法,它通过使用标记的数据来学习。 我们需要使用标记的大量数据来训练算法; 每个数据集都需要有一个类。
SVM 创建一个或多个用于区分数据每一类的超平面。
经典示例是定义两个类的 2D 点集。 SVM 搜索可区分每个类别的最佳行:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hjdaaLpQ-1681871753509)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_08.jpg)]
分类之前的首要任务是训练我们的分类器。 这项工作是在开始主应用之前完成的,它被称为离线训练。 这不是一件容易的事,因为它需要足够的数据量来训练系统,但是更大的数据集并不总是意味着最好的结果。 在我们的情况下,由于没有公共车牌数据库,我们没有足够的数据。 因此,我们需要拍摄数百张汽车照片,然后预处理并分割所有照片。
我们用 75 张车牌图像和 35 张不带144 x 33
像素车牌的图像训练了我们的系统。 我们可以在下图中看到此数据的样本。 这不是一个很大的数据集,但足以满足我们的要求。 在实际应用中,我们需要训练更多数据:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CYsOaamO-1681871753510)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_09.jpg)]
为了轻松了解机器学习的工作原理,我们继续使用分类器算法的图像像素特征(请记住,有更好的方法和功能来训练 SVM,例如主成分分析,傅立叶变换, 纹理分析等)。
我们需要使用DetectRegions
类创建图像来训练我们的系统,并将savingRegions
变量设置为true
,以保存图像。 我们可以使用segmentAllFiles.sh
bash 脚本对文件夹下的所有图像文件重复该过程。 可以从本书的源代码中获取。
为了简化操作,我们将所有经过处理和准备的图像训练数据存储到 XML 文件中,以直接与 SVM 功能一起使用。 trainSVM.cpp
应用使用文件夹和图像文件数量创建此文件。
提示
带有机器学习 OpenCV 算法的训练数据存储在N
xM
矩阵中,并具有N
个样本和M
特征。 每个数据集在训练矩阵中保存为一行。
这些类别存储在N x 1
大小的另一个矩阵中,其中每个类别均由浮点数标识。
OpenCV 使用FileStorage
类可以轻松地管理 XML 或 JSON 格式的数据文件,该类使我们可以存储和读取 OpenCV 变量和结构或自定义变量。 使用此功能,我们可以读取训练数据矩阵和训练类并将其保存在SVM_TrainingData
和SVM_Classes
中:
FileStorage fs;
fs.open("SVM.xml", FileStorage::READ);
Mat SVM_TrainingData;
Mat SVM_Classes;
fs["TrainingData"] >> SVM_TrainingData;
fs["classes"] >> SVM_Classes;
现在我们需要设置 SVM 参数,这些参数定义了在 SVM 算法中使用的基本参数。 我们将使用CvSVMParams
结构进行定义。 它是对训练数据的映射,以提高其与线性可分离数据集的相似度。 该映射包括增加数据的维数,并使用核函数有效地完成了映射。 我们在这里选择CvSVM::LINEAR
类型,这意味着没有映射完成:
//Set SVM params
CvSVMParams SVM_params;
SVM_params.kernel_type = CvSVM::LINEAR;
然后,我们创建并训练分类器。 OpenCV 为支持向量机算法定义了CvSVM
类,我们使用训练数据,类和参数数据对其进行初始化:
CvSVM svmClassifier(SVM_TrainingData, SVM_Classes, Mat(), Mat(), SVM_params);
我们的分类器已准备好使用 SVM 类的predict
函数来预测可能的裁剪图像; 此函数返回类标识符i
。 在我们的案例中,我们将板级标记为1
,而没有板级标记为0
。 然后,对于每个可能是板块的检测区域,我们使用 SVM 将其分类为板块或无板块,并仅保存正确的响应。 以下代码是主应用的一部分,称为在线处理:
vector<Plate> plates;
for(int i=0; i< possible_regions.size(); i )
{
Mat img=possible_regions[i].plateImg;
Mat p= img.reshape(1, 1);//convert img to 1 row m features
p.convertTo(p, CV_32FC1);
int response = (int)svmClassifier.predict( p );
if(response==1)
plates.push_back(possible_regions[i]);
}
车牌识别
车牌识别的第二步旨在通过光学字符识别来检索牌照的字符。 对于每个检测到的印版,我们继续对每个字符的印版进行分割,并使用人工神经网络(ANN)机器学习算法来识别字符。 同样在本节中,我们将学习如何评估分类算法。
OCR 分割
首先,我们获得一个板块图像斑块,作为具有均等直方图的分割 OCR 函数的输入,然后我们需要应用阈值过滤器并将此阈值图像用作查找轮廓算法的输入; 我们可以在下图中看到这个过程:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UYWQv8QG-1681871753510)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_10.jpg)]
此分割过程的编码为:
代码语言:javascript复制Mat img_threshold;
threshold(input, img_threshold, 60, 255, CV_THRESH_BINARY_INV);
if(DEBUG)
imshow("Threshold plate", img_threshold);
Mat img_contours;
img_threshold.copyTo(img_contours);
//Find contours of possibles characters
vector< vector< Point> > contours;
findContours(img_contours,
contours, // a vector of contours
CV_RETR_EXTERNAL, // retrieve the external contours
CV_CHAIN_APPROX_NONE); // all pixels of each contour
我们使用CV_THRESH_BINARY_INV
参数通过将白色输入值变为黑色并将黑色输入值变为白色来反转阈值输出。 这是获取每个字符的轮廓所必需的,因为轮廓算法会寻找白色像素。
对于每个检测到的轮廓,我们可以进行尺寸验证,并删除尺寸较小或外观不正确的所有区域。 在我们的案例中,字符的宽高比为 45/77,对于旋转或扭曲的字符,我们可以接受 35% 的宽高比错误。 如果面积大于 80%,则认为该区域是黑色块,而不是字符。 为了计算面积,我们可以使用countNonZero
函数来计算值大于 0 的像素数:
bool OCR::verifySizes(Mat r)
{
//Char sizes 45x77
float aspect=45.0f/77.0f;
float charAspect= (float)r.cols/(float)r.rows;
float error=0.35;
float minHeight=15;
float maxHeight=28;
//We have a different aspect ratio for number 1, and it can be
//~0.2
float minAspect=0.2;
float maxAspect=aspect aspect*error;
//area of pixels
float area=countNonZero(r);
//bb area
float bbArea=r.cols*r.rows;
//% of pixel in area
float percPixels=area/bbArea;
if(percPixels < 0.8 && charAspect > minAspect && charAspect <
maxAspect && r.rows >= minHeight && r.rows < maxHeight)
return true;
else
return false;
}
如果验证了分段的字符,我们必须对其进行预处理以为所有字符设置相同的大小和位置,并将其保存在带有辅助CharSegment
类的向量中。 此类保存分段的字符图像和需要排序字符的位置,因为“查找轮廓”算法不会按要求的顺序返回轮廓。
特征提取
分割每个字符的下一步是提取用于训练和分类人工神经网络算法的特征。
与 SVM 中使用的印版检测特征提取步骤不同,我们不使用所有图像像素;而是使用所有图像像素。 我们将应用在光学字符识别中使用的更多常见特征,包括水平和垂直累积直方图和低分辨率图像样本。 我们可以在下一张图像中更形象地看到此特征,其中每个图像的分辨率均为5 x 5
,并且直方图会累加:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rGwhQhAK-1681871753510)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_11.jpg)]
对于每个字符,我们使用countNonZero
函数对具有非零值的行或列中的像素数进行计数,并将其存储在名为mhist
的新数据矩阵中。 我们通过使用minMaxLoc
函数在数据矩阵中查找最大值来对其进行归一化,然后通过convertTo
函数将mhist
的所有元素除以最大值。 我们创建ProjectedHistogram
函数来创建累积直方图,这些直方图具有输入的二进制图像和所需的直方图类型(水平或垂直):
Mat OCR::ProjectedHistogram(Mat img, int t)
{
int sz=(t)?img.rows:img.cols;
Mat mhist=Mat::zeros(1,sz,CV_32F);
for(int j=0; j<sz; j ){
Mat data=(t)?img.row(j):img.col(j);
mhist.at<float>(j)=countNonZero(data);
}
//Normalize histogram
double min, max;
minMaxLoc(mhist, &min, &max);
if(max>0)
mhist.convertTo(mhist,-1 , 1.0f/max, 0);
return mhist;
}
其他函数使用低分辨率样本图像。 而不是使用整个字符图像,我们创建一个低分辨率字符,例如5 x 5
。我们用5 x 5
、10 x 10
、15 x 15
和20 x 20
个字符训练系统,然后评估哪个字符返回最佳结果,以便我们可以在系统中使用它。 一旦拥有所有特征,就可以按行创建M
列的矩阵,其中这些列是特征:
Mat OCR::features(Mat in, int sizeData)
{
//Histogram features
Mat vhist=ProjectedHistogram(in,VERTICAL);
Mat hhist=ProjectedHistogram(in,HORIZONTAL);
//Low data feature
Mat lowData;
resize(in, lowData, Size(sizeData, sizeData) );
int numCols=vhist.cols hhist.cols lowData.cols *
lowData.cols;
Mat out=Mat::zeros(1,numCols,CV_32F);
//Assign values to feature
int j=0;
for(int i=0; i<vhist.cols; i )
{
out.at<float>(j)=vhist.at<float>(i);
j ;
}
for(int i=0; i<hhist.cols; i )
{
out.at<float>(j)=hhist.at<float>(i);
j ;
}
for(int x=0; x<lowData.cols; x )
{
for(int y=0; y<lowData.rows; y )
{
out.at<float>(j)=(float)lowData.at<unsigned char>(x,y);
j ;
}
}
return out;
}
OCR 分类
在分类步骤中,我们使用人工神经网络机器学习算法。 更具体地说,多层感知器(MLP)是最常用的 ANN 算法。
MLP 由具有输入层,输出层和一个或多个隐藏层的神经元网络组成。 每一层都有一个或多个与上一层和下一层相连的神经元。
以下示例表示一个 3 层感知器(它是将实值向量输入映射到单个二进制值输出的二分类器),具有 3 个输入,2 个输出以及包含 5 个神经元的隐藏层:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bCmpL0rd-1681871753510)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_14.jpg)]
MLP 中的所有神经元都是相似的,每个神经元都有多个输入(前一个链接的神经元)和几个具有相同值的输出链接(下一个链接的神经元)。 每个神经元将输出值计算为加权输入加上偏差项的总和,并通过选定的激活函数进行转换:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HANfK2xx-1681871753510)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_15.jpg)]
有三种广泛使用的激活函数:身份,Sigmoid 和高斯函数; 最常见和默认的激活函数是 Sigmoid 函数。 它的 alpha 和 beta 值设置为 1:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ufXvxdue-1681871753511)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_16.jpg)]
经 ANN 训练的网络具有具有特征的输入向量。 它将值传递到隐藏层,并使用权重和激活函数计算结果。 它将输出传递到更下游,直到获得具有神经元类数量的输出层。
通过训练 ANN 算法来计算和学习每层,突触和神经元的权重。 为了训练我们的分类器,我们像在 SVM 训练中一样创建了两个数据矩阵,但是训练标签有些不同。 我们使用标签编号标识符代替N x 1
矩阵,其中N
代表训练数据行,而 1 为列。 我们必须创建一个N x M
矩阵,其中N
是训练/样本数据,M
是类别(10 个数字和 20 个字母),如果我们将数据行i
归类为j
,将位置(i, j)
设为 1。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kDlRjXxO-1681871753511)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_17.jpg)]
我们创建OCR::train
函数,以创建所有需要的矩阵,并使用训练数据矩阵,类矩阵以及隐藏层中的隐藏神经元数量来训练我们的系统。 就像我们进行 SVM 训练一样,从 XML 文件加载训练数据。
我们必须定义每层神经元的数量以初始化 ANN 类。 对于我们的示例,我们仅使用一个隐藏层,然后定义一个 1 行 3 列的矩阵。 第一列位置是特征的数量,第二列位置是隐藏层中隐藏的神经元的数量,第三列位置是类别的数量。
OpenCV 为 ANN 定义了一个CvANN_MLP
类。 使用create
函数,我们可以通过定义层和神经元的数量,激活函数以及alpha
和beta
参数来启动类:
void OCR::train(Mat TrainData, Mat classes, int nlayers)
{
Mat layerSizes(1,3,CV_32SC1);
layerSizes.at<int>(0)= TrainData.cols;
layerSizes.at<int>(1)= nlayers;
layerSizes.at<int>(2)= numCharacters;
ann.create(layerSizes, CvANN_MLP::SIGMOID_SYM, 1, 1); //ann is
global class variable
//Prepare trainClasses
//Create a mat with n trained data by m classes
Mat trainClasses;
trainClasses.create( TrainData.rows, numCharacters, CV_32FC1 );
for( int i = 0; i < trainClasses.rows; i )
{
for( int k = 0; k < trainClasses.cols; k )
{
//If class of data i is same than a k class
if( k == classes.at<int>(i) )
trainClasses.at<float>(i,k) = 1;
else
trainClasses.at<float>(i,k) = 0;
}
}
Mat weights( 1, TrainData.rows, CV_32FC1, Scalar::all(1) );
//Learn classifier
ann.train( TrainData, trainClasses, weights );
trained=true;
}
训练后,我们可以使用OCR::classify
函数对任何分割的板特征进行分类:
int OCR::classify(Mat f)
{
int result=-1;
Mat output(1, numCharacters, CV_32FC1);
ann.predict(f, output);
Point maxLoc;
double maxVal;
minMaxLoc(output, 0, &maxVal, 0, &maxLoc);
//We need to know where in output is the max val, the x (cols) is
//the class.
return maxLoc.x;
}
CvANN_MLP
类使用predict
函数对类中的特征向量进行分类。 与 SVM classify
函数不同,ANN 的predict
函数返回一行,其大小等于类的数量,并且有可能属于每个类的输入特征。
为了获得最佳结果,我们可以使用minMaxLoc
函数来获取最大和最小响应以及矩阵中的位置。 我们字符的类别由较高值的 x 位置指定:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SwojJrxx-1681871753511)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_12.jpg)]
为了完成检测到的每个板子,我们使用Plate
类的str()
函数排序其字符并返回一个字符串,然后可以将其绘制在原始图像上:
string licensePlate=plate.str();
rectangle(input_image, plate.position, Scalar(0,0,200));
putText(input_image, licensePlate, Point(plate.position.x, plate.position.y), CV_FONT_HERSHEY_SIMPLEX, 1, Scalar(0,0,200),2);
评估
我们的项目已经完成,但是当我们训练像 OCR 这样的机器学习算法时,我们需要知道要使用的最佳特征和参数,以及如何纠正系统中的分类,识别和检测错误。
我们需要用不同的情况和参数评估系统,评估产生的误差,并获得使这些误差最小化的最佳参数。
在本章中,我们使用以下变量评估了 OCR 任务:低分辨率图像特征的大小以及隐藏层中隐藏神经元的数量。
我们创建了evalOCR.cpp
应用,在其中使用了trainOCR.cpp
应用生成的 XML 训练数据文件。 OCR.xml
文件包含针对5 x 5
、10 x 10
、15 x 15
和20 x 20
下采样图像特征的训练数据矩阵。
Mat classes;
Mat trainingData;
//Read file storage.
FileStorage fs;
fs.open("OCR.xml", FileStorage::READ);
fs[data] >> trainingData;
fs["classes"] >> classes;
评估应用获取每个降采样的矩阵特征,并获取 100 个随机行进行训练,以及其他行以测试 ANN 算法并检查错误。
在训练系统之前,我们测试每个随机样本并检查响应是否正确。 如果响应不正确,我们将增加错误计数器变量,然后除以要评估的样本数。 这表示使用随机数据进行训练时,错误率介于 0 和 1 之间:
代码语言:javascript复制float test(Mat samples, Mat classes)
{
float errors=0;
for(int i=0; i<samples.rows; i )
{
int result= ocr.classify(samples.row(i));
if(result!= classes.at<int>(i))
errors ;
}
return errors/samples.rows;
}
应用返回每个样本大小的输出命令行错误率。 为了获得良好的评估,我们需要使用不同的随机训练行来训练应用; 这会产生不同的测试误差值,然后我们可以将所有误差相加并取平均值。 为此,我们创建以下bash
Unix 脚本以使其自动化:
#!/bin/bash
echo "#ITS t 5 t 10 t 15 t 20" > data.txt
folder=$(pwd)
for numNeurons in 10 20 30 40 50 60 70 80 90 100 120 150 200 500
do
s5=0;
s10=0;
s15=0;
s20=0;
for j in {1..100}
do
echo $numNeurons $j
a=$($folder/build/evalOCR $numNeurons TrainingDataF5)
s5=$(echo "scale=4; $s5 $a" | bc -q 2>/dev/null)
a=$($folder/build/evalOCR $numNeurons TrainingDataF10)
s10=$(echo "scale=4; $s10 $a" | bc -q 2>/dev/null)
a=$($folder/build/evalOCR $numNeurons TrainingDataF15)
s15=$(echo "scale=4; $s15 $a" | bc -q 2>/dev/null)
a=$($folder/build/evalOCR $numNeurons TrainingDataF20)
s20=$(echo "scale=4; $s20 $a" | bc -q 2>/dev/null)
done
echo "$i t $s5 t $s10 t $s15 t $s20"
echo "$i t $s5 t $s10 t $s15 t $s20" >> data.txt
done
该脚本保存了data.txt
文件,其中包含每种尺寸和神经元隐藏层号的所有结果。 该文件可用于使用 Gnuplot 进行绘图。 我们可以在下图中看到结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IW6nyvQU-1681871753511)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/master-opencv-prac-cv-proj/img/7829_06_13.jpg)]
我们可以看到的最低误差在 8% 以下,并且在隐藏层中使用了 20 个神经元,并且从缩小的10 x 10
图像补丁中提取了字符的特征。
总结
在本章中,我们学习了自动车牌识别程序的工作方式,以及它的两个重要步骤:车牌定位和车牌识别。
在第一步中,我们学习了如何分割图像以寻找可以放置印版的补丁,以及如何使用简单的试探法和支持向量机算法对没有印版的印版进行二分类。
在第二步中,我们学习了如何使用“查找轮廓”算法进行分割,从每个字符中提取特征向量,以及如何使用人工神经网络对字符类中的每个特征进行分类。
我们还学习了如何通过使用随机样本进行训练来评估机器算法,以及如何使用不同的参数和特征对其进行评估。