本文将一步步教您如何使用OpenCV实现基于一个标记的简单AR
作者开发环境:
Windows 10 (64bit)
Visual Studio 2015
OpenCV 3.2.0
源代码
您可以在此处获取源代码工程 去Github
使用的标记:
您可以打印下来,一张纸上打印多个便识别多个
在IDE中创建C++工程,并添加好OpenCV的相关环境配置,添加一个源文件,例如命名为SimpleAR.cpp
#include
#include
#include
using namespace cv;
using namespace std;
class MarkerBasedARProcessor
{
Mat Image, ImageGray, ImageAdaptiveBinary; //分别是 原图像 灰度图像 自适应阈值化图像
vector<vector > ImageContours; //图像所有边界信息
vector<vector > ImageQuads, ImageMarkers; //图像所有四边形 与 验证成功的四边形
vector FlatMarkerCorners; //正方形化标记时用到的信息
Size FlatMarkerSize; //正方形化标记时用到的信息
//7x7黑白标记的颜色信息
uchar CorrectMarker[7 * 7] =
{
0,0,0,0,0,0,0,
0,0,0,0,0,255,0,
0,0,255,255,255,0,0,
0,255,255,255,0,255,0,
0,255,255,255,0,255,0,
0,255,255,255,0,255,0,
0,0,0,0,0,0,0
};
void Clean(); // 用于新一帧处理前的初始化
void ConvertColor(); //转换图片颜色
void GetContours(int ContourCountThreshold); //获取图片所有边界
void FindQuads(int ContourLengthThreshold); //寻找所有四边形
void TransformVerifyQuads(); //变换为正方形并验证是否为标记
void DrawMarkerBorder(Scalar Color); //绘制标记边界
void DrawImageAboveMarker(); //在标记上绘图
bool MatchQuadWithMarker(Mat & Quad); // 检验正方形是否为标记
float CalculatePerimeter(const vector &Points); // 计算周长
public:
Mat ImageToDraw;// 要在标记上绘制的图像
MarkerBasedARProcessor();// 构造函数
Mat Process(Mat& Image);// 处理一帧图像
};
首先我们来看main()函数
int main()
{
Mat Frame, ProceedFrame;
VideoCapture Camera(0); // 初始化相机
while (!Camera.isOpened()); // 等待相机加载完成
MarkerBasedARProcessor Processor; // 构造一个AR处理类
Processor.ImageToDraw = imread("ImageToDraw.jpg"); // 读入绘制图像
while (waitKey(1)) // 每次循环延迟1ms
{
Camera >> Frame; // 读一帧
imshow("Frame", Frame); // 显示原始图像
ProceedFrame = Processor.Process(Frame); // 处理图像
imshow("ProceedFrame", ProceedFrame); // 显示结果图像
}
}
很显然,接下来进一步查看Process函数中发生了什么
Mat Process(Mat& Image)
{
Clean(); // 新一帧初始化
Image.copyTo(this->Image); // 复制原始图像到Image中
ConvertColor(); // 转换颜色
GetContours(50); // 获取边界
FindQuads(100); // 寻找四边形
TransformVerifyQuads(); // 变形并校验四边形
DrawMarkerBorder(Scalar(255, 255, 255)); // 在得到的标记周围画边界
DrawImageAboveMarker(); // 在标记上画图
return this->Image; // 返回结果图案
}
一个最简单的AR就完成了。
让我们列出要经历的步骤:
1. 转换图像颜色(cvtColor,adaptiveThreshold)
2. 拿自适应阈值化(adaptiveThreshold)
后图像获取(findContours)
图形中所有边界
3. 寻找(approxPolyDP)
所有边界中的四边形
4. 把图像中扭曲的四边形转换(getPerspectiveTransform,warpPerspective)
为正方形
5. 用二值化后的图像与正确标记的颜色对比
6. 得到的标记坐标拿来绘制图像
7. 享受胜利的果实接下来就开始分部说明
最简单的步骤
首先初始化
void Clean()
{
ImageContours.clear();
ImageQuads.clear();
ImageMarkers.clear();
}
然后转换颜色
void ConvertColor()
{
cvtColor(Image, ImageGray, CV_BGR2GRAY);
adaptiveThreshold(ImageGray, ImageAdaptiveBinary, 255,
ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY_INV, 7, 7);
}
分别把灰度图像和自适应阈值化图像保存至
ImageGray
和ImageAdaptiveBinary
参数说明:
int ContourCountThreshold
最大边界数量阈值
四边形有可能不是有4个顶点构成的,稍后需要拟合,此值设置为50
void GetContours(int ContourCountThreshold)
{
vector<vector > AllContours; // 所有边界信息
findContours(ImageAdaptiveBinary, AllContours,
CV_RETR_LIST, CV_CHAIN_APPROX_NONE); // 用自适应阈值化图像寻找边界
for (size_t i = 0;i < AllContours.size();++i) // 只储存低于阈值的边界
{
int contourSize = AllContours[i].size();
if (contourSize > ContourCountThreshold)
{
ImageContours.push_back(AllContours[i]);
}
}
}
结束后
ImageContour
储存了需要的边界信息
参数说明:
int ContourLengthThreshold
最小四边形边长阈值
void FindQuads(int ContourLengthThreshold)
{
vector<vector > PossibleQuads;
for (int i = 0;i < ImageContours.size();++i)
{
vector InDetectPoly;
approxPolyDP(ImageContours[i], InDetectPoly,
ImageContours[i].size() * 0.05, true); // 对边界进行多边形拟合
if (InDetectPoly.size() != 4) continue;// 只对四边形感兴趣
if (!isContourConvex(InDetectPoly)) continue; // 只对凸四边形感兴趣
float MinDistance = 1e10; // 寻找最短边
for (int j = 0;j < 4;++j)
{
Point2f Side = InDetectPoly[j] - InDetectPoly[(j + 1) % 4];
float SquaredSideLength = Side.dot(Side);
MinDistance = min(MinDistance, SquaredSideLength);
}
if (MinDistance < ContourLengthThreshold) continue; // 最短边必须大于阈值
vector TargetPoints;
for (int j = 0;j < 4;++j) // 储存四个点
{
TargetPoints.push_back(Point2f(InDetectPoly[j].x, InDetectPoly[j].y));
}
Point2f Vector1 = TargetPoints[1] - TargetPoints[0]; // 获取一个边的向量
Point2f Vector2 = TargetPoints[2] - TargetPoints[0]; // 获取一个斜边的向量
if (Vector2.cross(Vector1) < 0.0) // 计算两向量的叉乘 判断点是否为逆时针储存
swap(TargetPoints[1], TargetPoints[3]); // 如果大于0则为顺时针,需要交替
PossibleQuads.push_back(TargetPoints); // 保存进可能的四边形,进行进一步判断
}
至此获得了一些被逆时针储存的,可能为标记的四边形坐标
vectorint , int>> TooNearQuads; // 准备删除几组靠太近的多边形
for (int i = 0;i < PossibleQuads.size();++i)
{
vector & Quad1 = PossibleQuads[i]; // 第一个
for (int j = i + 1;j < PossibleQuads.size();++j)
{
vector & Quad2 = PossibleQuads[j]; // 第二个
float distSquared = 0;
float x1Sum = 0.0, x2Sum = 0.0, y1Sum = 0.0, y2Sum = 0.0, dx = 0.0, dy = 0.0;
for (int c = 0;c < 4;++c)
{
x1Sum += Quad1[c].x;
x2Sum += Quad2[c].x;
y1Sum += Quad1[c].y;
y2Sum += Quad2[c].y;
}
x1Sum /= 4; x2Sum /= 4; y1Sum /= 4; y2Sum /= 4; // 计算平均值(中点)
dx = x1Sum - x2Sum;
dy = y1Sum - y2Sum;
distSquared = sqrt(dx*dx + dy*dy); // 计算两多边形距离
if (distSquared < 50)
{
TooNearQuads.push_back(pair<int, int>(i, j)); // 过近则准备剔除
}
}
}
至此我们一一比较了多边形们,将距离过近的挑选了出来
vector<bool> RemovalMask(PossibleQuads.size(), false); // 移除标记列表
for (int i = 0;i < TooNearQuads.size();++i)
{
float p1 = CalculatePerimeter(PossibleQuads[TooNearQuads[i].first]); //求周长
float p2 = CalculatePerimeter(PossibleQuads[TooNearQuads[i].second]);
int removalIndex; //移除周长小的多边形
if (p1 > p2) removalIndex = TooNearQuads[i].second;
else removalIndex = TooNearQuads[i].first;
RemovalMask[removalIndex] = true;
}
至此我们标记出周长小的相邻多边形,并在下一步储存中跳过他
for (size_t i = 0;i < PossibleQuads.size();++i)
{
// 只录入没被剔除的多边形
if (!RemovalMask[i]) ImageQuads.push_back(PossibleQuads[i]);
}
}
计算边长函数如下
float CalculatePerimeter(const vector &Points) //求多边形周长
{
float sum = 0, dx, dy;
for (size_t i = 0;i < Points.size();++i)
{
size_t i2 = (i + 1) % Points.size();
dx = Points[i].x - Points[i2].x;
dy = Points[i].y - Points[i2].y;
sum += sqrt(dx*dx + dy*dy);
}
return sum;
}
我们需要把扭曲的标记转换为正方形来进行判断,使用getPerspectiveTransform函数可以帮助我们实现这一点,他接受2个参数,分别是源图像中的四点坐标与正方形图像中的四点坐标。
源图像的四点坐标即上面我们得到的
ImageQuads
。正方形图像的四点坐标即一开始在类介绍环节您可能产生疑问的
FlatMarkerCorners
,因为我们把他存入新的图像中,实际上就是新图像的四个顶点。它返回一个变换矩阵,我们将他交给下一步
warpPerspective
中,即可从原图像中获取裁剪下来的变为正方形的可能标记了。于此同时,类介绍中的FlatMarkerSize
在这里也起了作用,他是用来告诉函数生成图像的大小的。这两个变量在类的构造函数中定义:
MarkerBasedARProcessor()
{
FlatMarkerSize = Size(35, 35);
FlatMarkerCorners = { Point2f(0,0),Point2f(FlatMarkerSize.width - 1,0),
Point2f(FlatMarkerSize.width - 1,FlatMarkerSize.height - 1),
Point2f(0,FlatMarkerSize.height - 1) };
}
可见正方形四点坐标是由大小决定的。
下面正式进入函数
void TransformVerifyQuads()
{
Mat FlatQuad;
for (size_t i = 0;i < ImageQuads.size();++i)
{
vector & Quad = ImageQuads[i];
Mat TransformMartix = getPerspectiveTransform(Quad, FlatMarkerCorners);
warpPerspective(ImageGray, FlatQuad, TransformMartix, FlatMarkerSize);
正方形图像已经存入
FlatQuad
中
threshold(FlatQuad, FlatQuad, 0, 255, THRESH_OTSU); // 变为二值化图像
if (MatchQuadWithMarker(FlatQuad)) // 与正确标记比对
{
ImageMarkers.push_back(ImageQuads[i]); // 成功则记录
}
else // 如果失败,则旋转,每次90度进行比对
{
for (int j = 0;j < 3;++j)
{
rotate(FlatQuad, FlatQuad, ROTATE_90_CLOCKWISE);
if (MatchQuadWithMarker(FlatQuad))
{
ImageMarkers.push_back(ImageQuads[i]); // 成功则记录
break;
}
}
}
}
}
比对函数如下
bool MatchQuadWithMarker(Mat & Quad)
{
int Pos = 0;
for (int r = 2;r < 33;r += 5) // 正方形图像大小为(35,35)
{
for (int c = 2;c < 33;c += 5)// 读取每块图像中心点
{
uchar V = Quad.at<uchar>(r, c);
uchar K = CorrectMarker[Pos];
if (K != V) // 与正确标记颜色信息比对
return false;
Pos++;
}
}
return true;
}
接下来到了最后一步
首先绘制边界
void DrawMarkerBorder(Scalar Color)
{
for (vector Marker : ImageMarkers)
{
line(Image, Marker[0], Marker[1], Color, 2, CV_AA);
line(Image, Marker[1], Marker[2], Color, 2, CV_AA);
line(Image, Marker[2], Marker[3], Color, 2, CV_AA);
line(Image, Marker[3], Marker[0], Color, 2, CV_AA);//CV_AA是抗锯齿
}
}
最后将图像绘制到标记上,方法类似于变为正方形,只不过是由标准矩形图像变为扭曲的标记坐标而已。
void DrawImageAboveMarker()
{
if (ImageToDraw.empty())return;
vector ImageCorners = { Point2f(0,0),Point2f(ImageToDraw.cols - 1,0),
Point2f(ImageToDraw.cols - 1,ImageToDraw.rows - 1),
Point2f(0,ImageToDraw.rows - 1) }; // 与变为正方形类似,也需要这样的四个顶点
Mat_ ImageWarp = Image; // 便于操作像素点
for (vector Marker : ImageMarkers)
{
Mat TransformMartix = getPerspectiveTransform(ImageCorners, Marker);
Mat_ Result(Size(Image.cols, Image.rows), CV_8UC3);
warpPerspective(ImageToDraw, Result, TransformMartix, Size(Image.cols, Image.rows));
先求出旋转矩阵,然后得到变换后的图像,并不是直接绘制到原图像上的,得到的图像除了标记的区域其他全为黑色
把变换后的图像非黑色的部分绘制到原图像上
for (int r = 0;r < Image.rows;++r)
{
for (int c = 0;c < Image.cols;++c)
{
if (Result(r, c) != Vec3b(0, 0, 0))
{
ImageWarp(r, c) = Result(r, c);
}
}
}
}
}
标记有一点不全或遮挡都会失败
没有统一标记方向的储存
所以才是最简单的AR
http://blog.csdn.net/chuhang_zhqr/article/details/50034669
http://blog.csdn.net/chuhang_zhqr/article/details/50036443