Schwarzer教你用OpenCV实现基于标记的AR

导读

本文将一步步教您如何使用OpenCV实现基于一个标记的简单AR

作者开发环境:

Windows 10 (64bit)
Visual Studio 2015
OpenCV 3.2.0

源代码

您可以在此处获取源代码工程 去Github

使用的标记:

您可以打印下来,一张纸上打印多个便识别多个

Schwarzer教你用OpenCV实现基于标记的AR_第1张图片

Step 1 开始

在IDE中创建C++工程,并添加好OpenCV的相关环境配置,添加一个源文件,例如命名为SimpleAR.cpp

添加 include 并 使用命名空间

#include
#include
#include

using namespace cv;
using namespace std;

Step 2 类介绍

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);// 处理一帧图像
};

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> 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> 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;
    }

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& 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 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);
                    }
                }
            }
        }
    }

Step 9 编译,运行,享受胜利的果实

Schwarzer教你用OpenCV实现基于标记的AR_第2张图片

Step Extra 不足

标记有一点不全或遮挡都会失败
没有统一标记方向的储存
所以才是最简单的AR

附上我学习的博文链接:

http://blog.csdn.net/chuhang_zhqr/article/details/50034669
http://blog.csdn.net/chuhang_zhqr/article/details/50036443

你可能感兴趣的:(AR)