導讀
本文將一步步教您如何使用OpenCV實現基於一個標記的簡單AR
作者開發環境:
Windows 10 (64bit)
Visual Studio 2015
OpenCV 3.2.0
源代碼
您可以在此處獲取源代碼工程 去Github
使用的標記:
您可以打印下來,一張紙上打印多個便識別多個
Step 1 開始
在IDE中創建C++工程,並添加好OpenCV的相關環境配置,添加一個源文件,例如命名爲SimpleAR.cpp
添加 include 並 使用命名空間
#include<opencv2\opencv.hpp>
#include<iostream>
#include<math.h>
using namespace cv;
using namespace std;
Step 2 類介紹
class MarkerBasedARProcessor
{
Mat Image, ImageGray, ImageAdaptiveBinary; //分別是 原圖像 灰度圖像 自適應閾值化圖像
vector<vector<Point>> ImageContours; //圖像所有邊界信息
vector<vector<Point2f>> ImageQuads, ImageMarkers; //圖像所有四邊形 與 驗證成功的四邊形
vector<Point2f> 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<Point2f> &Points); // 計算周長
public:
Mat ImageToDraw;// 要在標記上繪製的圖像
MarkerBasedARProcessor();// 構造函數
Mat Process(Mat& Image);// 處理一幀圖像
};
Step 3 主體流程
首先我們來看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. 享受勝利的果實接下來就開始分部說明
Step 4 轉換顏色
最簡單的步驟
首先初始化
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
Step 5 獲取邊界
參數說明:
int ContourCountThreshold
最大邊界數量閾值
四邊形有可能不是有4個頂點構成的,稍後需要擬合,此值設置爲50
void GetContours(int ContourCountThreshold)
{
vector<vector<Point>> 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
儲存了需要的邊界信息
Step 6 尋找四邊形
參數說明:
int ContourLengthThreshold
最小四邊形邊長閾值
void FindQuads(int ContourLengthThreshold)
{
vector<vector<Point2f>> PossibleQuads;
for (int i = 0;i < ImageContours.size();++i)
{
vector<Point2f> 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<Point2f> 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); // 保存進可能的四邊形,進行進一步判斷
}
至此獲得了一些被逆時針儲存的,可能爲標記的四邊形座標
vector<pair<int, int>> TooNearQuads; // 準備刪除幾組靠太近的多邊形
for (int i = 0;i < PossibleQuads.size();++i)
{
vector<Point2f>& Quad1 = PossibleQuads[i]; // 第一個
for (int j = i + 1;j < PossibleQuads.size();++j)
{
vector<Point2f>& 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<Point2f> &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;
}
Step 7 變形與校驗
我們需要把扭曲的標記轉換爲正方形來進行判斷,使用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<Point2f>& 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;
}
Step 8 繪圖
接下來到了最後一步
首先繪製邊界
void DrawMarkerBorder(Scalar Color)
{
for (vector<Point2f> 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<Point2f> ImageCorners = { Point2f(0,0),Point2f(ImageToDraw.cols - 1,0),
Point2f(ImageToDraw.cols - 1,ImageToDraw.rows - 1),
Point2f(0,ImageToDraw.rows - 1) }; // 與變爲正方形類似,也需要這樣的四個頂點
Mat_<Vec3b> ImageWarp = Image; // 便於操作像素點
for (vector<Point2f> Marker : ImageMarkers)
{
Mat TransformMartix = getPerspectiveTransform(ImageCorners, Marker);
Mat_<Vec3b> 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);
}
}
}
}
}
Step 9 編譯,運行,享受勝利的果實
Step Extra 不足
標記有一點不全或遮擋都會失敗
沒有統一標記方向的儲存
所以纔是最簡單的AR
附上我學習的博文鏈接:
http://blog.csdn.net/chuhang_zhqr/article/details/50034669
http://blog.csdn.net/chuhang_zhqr/article/details/50036443