Schwarzer教你用OpenCV實現基於標記的AR

導讀

本文將一步步教您如何使用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);
    }

分別把灰度圖像和自適應閾值化圖像保存至ImageGrayImageAdaptiveBinary

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章