项目中最近需要在嵌入式上实现orb,由于板子的运算器对之没有优化,所以不能用opencv的代码。为了实现这个需求笔者查阅相关资料,阅读opencv3.2中相关的源码(很幸运能看懂),在此进行总结,以备使用。

首先ORB是一种提取图像特征并通过一定方法计算描述子,以用来进行匹配的方法。注意orb只解决了提取特征并计算描述子的问题。而后续的匹配是有其他方法的,不属于orb的范畴,匹配既可以用orb也可以用sift,surf等其他方法。

ORB这个名字是oFast与rBreaf的缩写。其中oFAST指FAST Keypoint Orientation,rBreaf指Rotation-Aware Brief。如名字,ORB其实是分两个部分的:提特征点,算描述子。而这两个部分在我看来是可以分开的。

提特征点其实就告诉算描述子过程在哪里去算,并为算描述子提供了关键信息——特征点的方向。论文原文中用的是oFast去提取特征点。显然可以用其他方法代替(比如板子上带的硬件加速的Shi-Tomasi-like特征提取API),至于特征点的方向,还是用oFast的灰度质心法。这和特征提取也是分开的。

算描述子应该算是论文中提出的精髓了。也有很多相关的文章去介绍。比如这篇就讲解的很详细。

http://blog.csdn.net/lhanchao/article/details/52612954

那么,我的文章就到头了吗?显然不是,文章题名为源码详解。所以本文就OpenCV对ORB的具体实现(只讨论算描述子部分),从源码出发以期对这个算法彻头彻尾的理解透。那就开始正文吧。

OpenCV中ORB的代码调用框架

在开始理描述子计算过程之前,有必要提一提ORB到底是怎么用的,要不然到最后分析了一堆不知道分析的是哪里。

从调用说起

从很多博客上可以看到提取ORB特征进行调用的文章。对于OpenCV 的C++接口来说,其调用套路也就是几步,首先创建一个句柄,这个句柄能够执行特征提取的两大步:提取、算描述子。典型代码片段如下:

 Ptr<Feature2D> featurehdl = ORB::create();

当然create()里面可以加入很多参数这里不展开。之后呢就是通过这个句柄(其实也就是个智能指针)去提取特征点,算描述子。

vector<KeyPoint> keypoints;
Mat descriptors;
Mat image= imread("../image/xxxxxx.jpg");
featurehdl->detect(image,keypoints);
featurehdl->compute(image,keypoints,descriptors);

然后就得到了一组描述符descriptors。在原图上和测试图上分别这么做就得到了一对匹配点。接着调用match相关的接口去匹配就能匹配成功,不展开。本文就仔细分析createdetectcompute这几个调用到底干了些什么。

create

talk is cheap。

Ptr<ORB> ORB::create(int nfeatures, float scaleFactor, int nlevels, int edgeThreshold,
           int firstLevel, int wta_k, int scoreType, int patchSize, int fastThreshold)
{
    return makePtr<ORB_Impl>(nfeatures, scaleFactor, nlevels, edgeThreshold,
                             firstLevel, wta_k, scoreType, patchSize, fastThreshold);
}

其实create的时候,代码new了一个ORB_Impl类的指针(为何没直接new而是makePtr,其实就是一个模板函数,可以方便地new各种类的指针吧)。各个类的继承关系是这样的:ORB_Impl->ORB->Feature2D,很明了,想扩展其他描述子只需继承Feature2D就行。跟进去可以看到构造函数是什么事情都不干的,只是用参数初始化了一些成员变量而已。

detect

void Feature2D::detect( InputArray image,
                        std::vector<KeyPoint>& keypoints,
                        InputArray mask )
{
    CV_INSTRUMENT_REGION()

    if( image.empty() )
    {
        keypoints.clear();
        return;
    }
    detectAndCompute(image, mask, keypoints, noArray(), false);
}

detect是一个虚函数,但在ORBORB_Impl里都没有相关定义,所以走的就是基类的方法,显然其中调用了detectAndCompute函数。从名字上看这个函数实现了detectcompute,想必是从参数上去控制这个函数干什么事情。

compute

既然这两个方法调一个函数那就把这两个调用先写上来最后再分析那个大函数吧。

void Feature2D::compute( InputArray image,
                         std::vector<KeyPoint>& keypoints,
                         OutputArray descriptors )
{
    CV_INSTRUMENT_REGION()

    if( image.empty() )
    {
        descriptors.release();
        return;
    }
    detectAndCompute(image, noArray(), keypoints, descriptors, true);
}

void ORB_Impl::detectAndCompute( InputArray _image, InputArray _mask,
                                 std::vector<KeyPoint>& keypoints,
                                 OutputArray _descriptors, bool useProvidedKeypoints )

我把detectAndCompute的声明也贴了上来,可以看出detectcompute就是再最后两个参数上做了区分。

detectAndCompute

现在进入detectAndCompute这个函数:

void ORB_Impl::detectAndCompute( InputArray _image, InputArray _mask,
                                 std::vector<KeyPoint>& keypoints,
                                 OutputArray _descriptors, bool useProvidedKeypoints )
{
    CV_INSTRUMENT_REGION()

    CV_Assert(patchSize >= 2);

    bool do_keypoints = !useProvidedKeypoints;
    bool do_descriptors = _descriptors.needed();
...

现在可以看出了,干什么事情是由最后两个参数控制的。只看主干代码,往下走。

for( level = 0; level < nLevels; level++ )
    {
        float scale = getScale(level, firstLevel, scaleFactor);
        layerScale[level] = scale;
        Size sz(cvRound(image.cols/scale), cvRound(image.rows/scale));
        Size wholeSize(sz.width + border*2, sz.height + border*2);
        if( level_ofs.x + wholeSize.width > bufSize.width )
        {
            level_ofs = Point(0, level_ofs.y + level_dy);
            level_dy = wholeSize.height;
        }

        Rect linfo(level_ofs.x + border, level_ofs.y + border, sz.width, sz.height);
        layerInfo[level] = linfo;
        layerOfs[level] = linfo.y*bufSize.width + linfo.x;
        level_ofs.x += wholeSize.width;
    }
    bufSize.height = level_ofs.y + level_dy;

这里其实就是对每次金字塔加了border后的图片的矩形信息以及实际图像在加边后的图像中的位置。border宽度就是std::max(edgeThreshold, std::max(descPatchSize, HARRIS_BLOCK_SIZE/2))+1默认构造函数给edgeThreshold赋了31,这也是rbrief描述子选pattern时的领域半径,显然这么做是为了防止内存越界。下面几十行算了个带边界的金字塔生成边界的方法似乎是用镜像什么的,有兴趣的可以仔细去查查。

if( do_keypoints )
    {
        if( useOCL )
            imagePyramid.copyTo(uimagePyramid);

        // Get keypoints, those will be far enough from the border that no check will be required for the descriptor
        computeKeyPoints(imagePyramid, uimagePyramid, maskPyramid,
                         layerInfo, ulayerInfo, layerScale, keypoints,
                         nfeatures, scaleFactor, edgeThreshold, patchSize, scoreType, useOCL, fastThreshold);
    }

如果do_keypoints,那就do么。这里我没仔细看,因为项目中打算自己提取特征,就不仔细分析了。有兴趣的同学可以仔细进去看看computeKeyPoints,里面的HarrisResponsesICAngles非常显眼,也可以想象,其实就是算个哈里斯响应,在响应高的地方算算灰度质心(还要非极大值抑制?应该吧...)。

else
    {
        KeyPointsFilter::runByImageBorder(keypoints, image.size(), edgeThreshold);

        if( !sortedByLevel )
        {
            std::vector<std::vector<KeyPoint> > allKeypoints(nLevels);
            nkeypoints = (int)keypoints.size();
            for( i = 0; i < nkeypoints; i++ )
            {
                level = keypoints[i].octave;
                CV_Assert(0 <= level);
                allKeypoints[level].push_back(keypoints[i]);
            }
            keypoints.clear();
            for( level = 0; level < nLevels; level++ )
                std::copy(allKeypoints[level].begin(), allKeypoints[level].end(), std::back_inserter(keypoints));
        }
    }

可以看出如果不do_keypoints的话,首先调用KeyPointsFilter::runByImageBorder这个方法把图像边界上的特征点去掉。之后如果特征点不是按照金字塔层来存的话,变变顺序。

if( do_descriptors )
    {
        int dsize = descriptorSize();

        nkeypoints = (int)keypoints.size();
        if( nkeypoints == 0 )
        {
            _descriptors.release();
            return;
        }

        _descriptors.create(nkeypoints, dsize, CV_8U);
        std::vector<Point> pattern;
        ...

如果do_descriptors那么do呗。首先判断下没特征点直接退出咯。

        _descriptors.create(nkeypoints, dsize, CV_8U);
        std::vector<Point> pattern;

        const int npoints = 512;
        Point patternbuf[npoints];

接下来算描述子,自然是分配空间。注意dsize,仔细跟下去,发现它在其他地方被设置成了32,单位是字节。328=256也就是论文中ORB描述子的二进制位的个数。npoints 被赋成了512,也就是256对点,也就是论文中提出的pattern的点对数$n=256$。这么看下来,其实就是在3131=961个点中按照某种策略,取出256*2=512个点。那么按照什么策略呢?往下看。

const Point* pattern0 = (const Point*)bit_pattern_31_;

        if( patchSize != 31 )
        {
            pattern0 = patternbuf;
            makeRandomPattern(patchSize, patternbuf, npoints);
        }

这里首先定义了一个指针,这个指针由一个静态全局数组生成bit_pattern_31_,里面的数据都是定死的,也就是论文中提到的在PASCAL 2006的图像库中提取了300k个特征点作为训练集,训练出来的一个取pattern的方式。具体怎么训的请参考前面博客的分析。然而下面来了一个判断,如果patchSize也就是描述子的计算区域直径不是31,那么直接使用随机的pattern来算描述子。那读者会问,随机的话如何保证两张图片在提取描述子时用的是相同的pattern?其实makeRandomPattern用了一个固定的随机数种子,肯定能得到相同的pattern。

CV_Assert( wta_k == 2 || wta_k == 3 || wta_k == 4 );

        if( wta_k == 2 )
            std::copy(pattern0, pattern0 + npoints, std::back_inserter(pattern));
        else
        {
            int ntuples = descriptorSize()*4;
            initializeOrbPattern(pattern0, pattern, ntuples, wta_k, npoints);
        }

上面这段代码中的wta_k困扰了我一会。往后仔细看了代码后发现,opencv对ORB的实现不仅支持在pattern上用两个点进行比较。还支持3、4个点进行比较。而一组点(2、3或4个)形成一个元组(代码中用ntuples表示)。wta_k即进行匹配的元组内元素的个数,也就是说,wta_k规定了几个像素点来比。这下就能理清逻辑了。如果wta_k为2,那直接就用bit_pattern_31_里的数据来取pattern。否则调用initializeOrbPattern在bit_pattern_31_里随机取点来组成pattern(这合乎情理吗?花里胡哨的,项目中还是享受论文红利用wta_k=2来算吧)。
接下来是个高斯模糊,就不贴代码了。接着往下走会出现一个判断

#ifdef HAVE_OPENCL
        if( useOCL )
        {
            imagePyramid.copyTo(uimagePyramid);
            std::vector<Vec4i> kptbuf;
            UMat ukeypoints, upattern;
            copyVectorToUMat(pattern, upattern);
            uploadORBKeypoints(keypoints, layerScale, kptbuf, ukeypoints);

            UMat udescriptors = _descriptors.getUMat();
            useOCL = ocl_computeOrbDescriptors(uimagePyramid, ulayerInfo,
                                               ukeypoints, udescriptors, upattern,
                                               nkeypoints, dsize, wta_k);
            if(useOCL)
            {
                CV_IMPL_ADD(CV_IMPL_OCL);
            }
        }

        if( !useOCL )
#endif
        {
            Mat descriptors = _descriptors.getMat();
            computeOrbDescriptors(imagePyramid, layerInfo, layerScale,
                                  keypoints, descriptors, pattern, dsize, wta_k);
            //dsize is the size of descriptor. 32 Bytes 256 bits
            //wta_k is the touple of pattern. (how many pixels to compare, typically 2)
        }

呃,opencv集成了opencl来做加速(大概查了一下opencl好像是用GPU来算东西的)。那么真正的逻辑就在下面computeOrbDescriptors里(后面两行注释我加的,源码没有)。那就进去看看。

static void
computeOrbDescriptors( const Mat& imagePyramid, const std::vector<Rect>& layerInfo,
                       const std::vector<float>& layerScale, std::vector<KeyPoint>& keypoints,
                       Mat& descriptors, const std::vector<Point>& _pattern, int dsize, int wta_k )
{
    int step = (int)imagePyramid.step;
    int j, i, nkeypoints = (int)keypoints.size();

    for( j = 0; j < nkeypoints; j++ )
    {
        const KeyPoint& kpt = keypoints[j];
        const Rect& layer = layerInfo[kpt.octave];
        float scale = 1.f/layerScale[kpt.octave];
        float angle = kpt.angle;

        angle *= (float)(CV_PI/180.f);
        float a = (float)cos(angle), b = (float)sin(angle);

        const uchar* center = &imagePyramid.at<uchar>(cvRound(kpt.pt.y*scale) + layer.y,
                                                      cvRound(kpt.pt.x*scale) + layer.x);
        float x, y;
        int ix, iy;
        const Point* pattern = &_pattern[0];
        uchar* desc = descriptors.ptr<uchar>(j);

首先是一些变量的初始化,其中根据keypoints[j]的angel算了个cos即a和sinb以备后用。显然,算的描述子是要支持旋转的。

#if 1
        #define GET_VALUE(idx) \
               (x = pattern[idx].x*a - pattern[idx].y*b, \
                y = pattern[idx].x*b + pattern[idx].y*a, \
                ix = cvRound(x), \
                iy = cvRound(y), \
                *(center + iy*step + ix) )
    #else
        #define GET_VALUE(idx) \
            (x = pattern[idx].x*a - pattern[idx].y*b, \
            y = pattern[idx].x*b + pattern[idx].y*a, \
            ix = cvFloor(x), iy = cvFloor(y), \
            x -= ix, y -= iy, \
            cvRound(center[iy*step + ix]*(1-x)*(1-y) + center[(iy+1)*step + ix]*(1-x)*y + \
                    center[iy*step + ix+1]*x*(1-y) + center[(iy+1)*step + ix+1]*x*y))
    #endif

这里定义了一个宏,根据下标直接得到在图像上pattern表示的坐标旋转后的位置上的像素(希望能理解我的意思,说白了就是取亮度,按照pattern规定的位置取,而且要加旋转)。到这里也就能看出pattern里到底存的啥了,其实就是一堆坐标。用的时候把坐标旋转到灰度质心法给出的角上以实现旋转不变性的描述子特性。

if( wta_k == 2 )
        {
            for (i = 0; i < dsize; ++i, pattern += 16)
            {
                int t0, t1, val;
                t0 = GET_VALUE(0); t1 = GET_VALUE(1);
                val = t0 < t1;
                t0 = GET_VALUE(2); t1 = GET_VALUE(3);
                val |= (t0 < t1) << 1;
                t0 = GET_VALUE(4); t1 = GET_VALUE(5);
                val |= (t0 < t1) << 2;
                t0 = GET_VALUE(6); t1 = GET_VALUE(7);
                val |= (t0 < t1) << 3;
                t0 = GET_VALUE(8); t1 = GET_VALUE(9);
                val |= (t0 < t1) << 4;
                t0 = GET_VALUE(10); t1 = GET_VALUE(11);
                val |= (t0 < t1) << 5;
                t0 = GET_VALUE(12); t1 = GET_VALUE(13);
                val |= (t0 < t1) << 6;
                t0 = GET_VALUE(14); t1 = GET_VALUE(15);
                val |= (t0 < t1) << 7;

                desc[i] = (uchar)val;
            }
        }

这应该就是算描述子最核心的代码段了。其实挺简单的(呃。。。这么简单的东西还发个博客。。。)。先看wta_k=2的情况。算法是8对8对的比较大小的。for循环里每次比较8对,形成一个字节的描述子,这样运行dsize=32次就形成了终极目标:256位的描述子。知道for里干了什么,再去看代码。就很简单了吧。8对点即16个点,也就是在pattern(由bit_pattern_31_强转而来的一个指针,很强的操作,一个数组你一强转就变成个对象指针了??有时间该补补c++了...)上取16个点。注意每次for后pattern都加了16,所以没有在前16个元素上原地打转。接下来就是比较,置位,移位。
呃,wta_k其他情况,请读者自己去分析吧,由于其他情况经分析,由于随机取点,经过分析可能存在着相关性的问题(参考前面引用的博客或者原论文),项目中应该也不会用它了,也就不想了。

这个函数出来,那ORB描述子的建立过程也就完成了。达到了本文的分析目的。至于实际效果,有很多博客可以查看。

标签: OpenCV

添加新评论