【OpenCV】SIFT原理与源码分析

原文出自:http://blog.csdn.net/xiaowei_cqu/article/details/8069548

SIFT简介

Scale Invariant Feature Transform,尺度不变特征变换匹配算法,是由David G. Lowe在1999年(《Object Recognition from Local Scale-Invariant Features》)提出的高效区域检测算法,在2004年(《Distinctive Image Features from Scale-Invariant Keypoints》)得以完善。

SIFT特征对旋转、尺度缩放、亮度变化等保持不变性,是非常稳定的局部特征,现在应用很广泛。而SIFT算法是将Blob检测,特征矢量生成,特征匹配搜索等步骤结合在一起优化。我会更新一系列文章,分析SIFT算法原理及OpenCV 2.4.2实现的SIFT源码:

  1. DoG尺度空间构造(Scale-space extrema detection
  2. 关键点搜索与定位(Keypoint localization
  3. 方向赋值(Orientation assignment
  4. 关键点描述(Keypoint descriptor
OpenCV2.3之后实现了SIFT的代码,2.4改掉了一些bug。本系列文章主要分析OpenCV 2.4.2SIFT函数源码。
SIFT位于OpenCV nonfree的模块, David G. Lowe申请了算法的版权,请尊重作者权力,务必在允许范围内使用。

SIFT in OpenCV

OpenCV中的SIFT函数主要有两个接口。

构造函数:

SIFT::SIFT(int nfeatures=0, int nOctaveLayers=3, double contrastThreshold=0.04, double edgeThreshold=
10, double sigma=1.6)
nfeatures:特征点数目(算法对检测出的特征点排名,返回最好的nfeatures个特征点)。
nOctaveLayers:金字塔中每组的层数(算法中会自己计算这个值,后面会介绍)。
contrastThreshold:过滤掉较差的特征点的对阈值。contrastThreshold越大,返回的特征点越少。
edgeThreshold:过滤掉边缘效应的阈值。edgeThreshold越大,特征点越多(被多滤掉的越少)。
sigma:金字塔第0层图像高斯滤波系数,也就是σ。

重载操作符:

void SIFT::operator()(InputArray img, InputArray mask, vector<KeyPoint>& keypoints, OutputArray
descriptors, bool useProvidedKeypoints=false)

img:8bit灰度图像
mask:图像检测区域(可选)
keypoints:特征向量矩阵
descipotors:特征点描述的输出向量(如果不需要输出,需要传cv::noArray())。
useProvidedKeypoints:是否进行特征点检测。ture,则检测特征点;false,只计算图像特征描述。

函数源码

构造函数SIFT()主要用来初始化参数,并没有特定的操作:
SIFT::SIFT( int _nfeatures, int _nOctaveLayers,
           double _contrastThreshold, double _edgeThreshold, double _sigma )
    : nfeatures(_nfeatures), nOctaveLayers(_nOctaveLayers),
    contrastThreshold(_contrastThreshold), edgeThreshold(_edgeThreshold), sigma(_sigma)
	// sigma:对第0层进行高斯模糊的尺度空间因子。
	// 默认为1.6(如果是软镜摄像头捕获的图像,可以适当减小此值)
{
}

主要操作还是利用重载操作符()来执行:
void SIFT::operator()(InputArray _image, InputArray _mask,
                      vector<KeyPoint>& keypoints,
                      OutputArray _descriptors,
                      bool useProvidedKeypoints) const
// mask :Optional input mask that marks the regions where we should detect features.
// Boolean flag. If it is true, the keypoint detector is not run. Instead,
// the provided vector of keypoints is used and the algorithm just computes their descriptors.
// descriptors – The output matrix of descriptors.
// Pass cv::noArray() if you do not need them.			  
{
    Mat image = _image.getMat(), mask = _mask.getMat();

    if( image.empty() || image.depth() != CV_8U )
        CV_Error( CV_StsBadArg, "image is empty or has incorrect depth (!=CV_8U)" );

    if( !mask.empty() && mask.type() != CV_8UC1 )
        CV_Error( CV_StsBadArg, "mask has incorrect type (!=CV_8UC1)" );

		
	// 得到第1组(Octave)图像
    Mat base = createInitialImage(image, false, (float)sigma);
    vector<Mat> gpyr, dogpyr;
	// 每层金字塔图像的组数(Octave)
    int nOctaves = cvRound(log( (double)std::min( base.cols, base.rows ) ) / log(2.) - 2);

    // double t, tf = getTickFrequency();
    // t = (double)getTickCount();
	
	// 构建金字塔(金字塔层数和组数相等)
    buildGaussianPyramid(base, gpyr, nOctaves);
	// 构建高斯差分金字塔
    buildDoGPyramid(gpyr, dogpyr);

    //t = (double)getTickCount() - t;
    //printf("pyramid construction time: %g\n", t*1000./tf);
	
	// useProvidedKeypoints默认为false
	// 使用keypoints并计算特征点的描述符
    if( !useProvidedKeypoints )
    {
        //t = (double)getTickCount();
        findScaleSpaceExtrema(gpyr, dogpyr, keypoints);
		//除去重复特征点
        KeyPointsFilter::removeDuplicated( keypoints ); 

		// mask标记检测区域(可选)
        if( !mask.empty() )
            KeyPointsFilter::runByPixelsMask( keypoints, mask );

		// retainBest:根据相应保留指定数目的特征点(features2d.hpp)
        if( nfeatures > 0 )
            KeyPointsFilter::retainBest(keypoints, nfeatures);
        //t = (double)getTickCount() - t;
        //printf("keypoint detection time: %g\n", t*1000./tf);
    }
    else
    {
        // filter keypoints by mask
        // KeyPointsFilter::runByPixelsMask( keypoints, mask );
    }

	// 特征点输出数组
    if( _descriptors.needed() )
    {
        //t = (double)getTickCount();
        int dsize = descriptorSize();
        _descriptors.create((int)keypoints.size(), dsize, CV_32F);
        Mat descriptors = _descriptors.getMat();

        calcDescriptors(gpyr, keypoints, descriptors, nOctaveLayers);
        //t = (double)getTickCount() - t;
        //printf("descriptor extraction time: %g\n", t*1000./tf);
    }
}

函数中用到的构造金字塔: buildGaussianPyramid(base, gpyr, nOctaves);等步骤请参见文章后续系列。
 
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

1.DoG尺度空间构造(Scale-space extrema detection


尺度空间理论


自然界中的物体随着观测 尺度不同有不同的表现形态。例如我们形容建筑物用“米”,观测分子、原子等用“纳米”。更形象的例子比如 Google地图,滑动鼠标轮可以改变观测地图的尺度,看到的地图绘制也不同;还有电影中的拉伸镜头等等……
尺度空间中各尺度图像的模糊程度逐渐变大,能够模拟人在距离目标由近到远时目标在视网膜上的形成过程。
尺度越大图像越模糊。

为什么要讨论尺度空间?

用机器视觉系统分析未知场景时,计算机并不预先知道图像中物体的尺度。我们需要同时考虑图像在多尺度下的描述,获知感兴趣物体的 最佳尺度。另外如果不同的尺度下都有同样的关键点,那么在不同的尺度的输入图像下就都可以检测出来关键点匹配,也就是 尺度不变性

图像的尺度空间表达就是图像在所有尺度下的描述。


尺度空间表达与金字塔多分辨率表达


高斯模糊

高斯核是唯一可以产生多尺度空间的核(《Scale-space theory: A basic tool for analysing structures at different scales》)。一个图像的尺度空间L(x,y,σ) ,定义为原始图像I(x,y)与一个可变尺度的2维高斯函数G(x,y,σ)卷积运算。 

二维空间高斯函数:


尺度空间:


尺度是自然客观存在的,不是主观创造的。高斯卷积只是表现尺度空间的一种形式。

二维空间高斯函数是等高线从中心成正太分布的同心圆:


分布不为零的点组成卷积阵与原始图像做变换,即每个像素值是周围相邻像素值的高斯平均。一个5*5的高斯模版如下所示:


高斯模版是圆对称的,且卷积的结果使原始像素值有最大的权重,距离中心越远的相邻像素值权重也越小。
在实际应用中,在计算高斯函数的离散近似时,在大概 距离之外的像素都可以看作不起作用,这些像素的计算也就可以忽略。所以,通常程序只计算 (6σ+1)*(6σ+1)就可以保证相关像素影响。

高斯模糊另一个很厉害的性质就是线性可分:使用二维矩阵变换的高斯模糊可以通过在水平和竖直方向各进行一维高斯矩阵变换相加得到。


O(N^2*m*n)次乘法就缩减成了O(N*m*n)+O(N*m*n)次乘法。(N为高斯核大小,m,n为二维图像高和宽)

其实高斯这一部分只需要简单了解就可以了,在OpenCV也只需要一句代码:

 

GaussianBlur(dbl, dbl, Size(), sig_diff, sig_diff);
我这里详写了一下是因为这块儿对分析算法效率比较有用,而且高斯模糊的算法真的很漂亮~

金字塔多分辨率

金字塔是早期图像多尺度的表示形式。图像金字塔化一般包括两个步骤:使用低通滤波器平滑图像;对平滑图像进行降采样(通常是水平,竖直方向1/2),从而得到一系列尺寸缩小的图像。


上图中(a)是对原始信号进行低通滤波,(b)是降采样得到的信号。

而对于二维图像,一个传统的金字塔中,每一层图像由上一层分辨率的长、宽各一半,也就是四分之一的像素组成:


多尺度和多分辨率

尺度空间表达和金字塔多分辨率表达之间最大的不同是:

  • 尺度空间表达是由不同高斯核平滑卷积得到,在所有尺度上有相同的分辨率;
  • 而金字塔多分辨率表达每层分辨率减少固定比率。
所以,金字塔多分辨率生成较快,且占用存储空间少;而多尺度表达随着尺度参数的增加冗余信息也变多。
多尺度表达的优点在于图像的局部特征可以用简单的形式在不同尺度上描述;而金字塔表达没有理论基础,难以分析图像局部特征。

DoG(Difference of Gaussian)


高斯拉普拉斯LoG金字塔

结合尺度空间表达和金字塔多分辨率表达,就是在使用尺度空间时使用金字塔表示,也就是计算机视觉中最有名的拉普拉斯金子塔( 《The Laplacian pyramid as a compact image code》)。
高斯拉普拉斯LoG(Laplace of Guassian)算子就是对高斯函数进行拉普拉斯变换:

核心思想还是高斯,这个不多叙述。

高斯差分DoG金字塔

DoG(Difference of Gaussian)其实是对高斯拉普拉斯LoG的近似,也就是对 的近似。SIFT算法建议,在某一尺度上的特征检测可以通过对两个相邻高斯尺度空间的图像相减,得到DoG的响应值图像D(x,y,σ)。然后仿照LoG方法,通过对响应值图像D(x,y,σ)进行局部最大值搜索,在空间位置和尺度空间定位局部特征点。其中:

k为相邻两个尺度空间倍数的常数。

上图中(a)是DoG的三维图,(b)是DoG与LoG的对比。

金字塔构建


构建高斯金字塔

为了得到DoG图像,先要构造高斯金字塔。我们回过头来继续说高斯金字塔~
高斯金字塔在多分辨率金字塔简单 降采样基础上加了高斯滤波,也就是对金字塔每层图像用不同参数的σ做高斯模糊,使得每层金字塔有多张高斯模糊图像。金字塔每层多张图像合称为一组(Octave),每组有多张(也叫层Interval)图像。另外,降采样时,金字塔上边一组图像的第一张图像(最底层的一张)是由前一组(金字塔下面一组)图像的倒数第三张隔点采样得到。


以下是OpenCV中构建高斯金字塔的代码,我加了相应的注释:
// 构建nOctaves组(每组nOctaves+3层)高斯金字塔
void SIFT::buildGaussianPyramid( const Mat& base, vector<Mat>& pyr, int nOctaves ) const
{
    vector<double> sig(nOctaveLayers + 3);
    pyr.resize(nOctaves*(nOctaveLayers + 3));

    // precompute Gaussian sigmas using the following formula:
    //  \sigma_{total}^2 = \sigma_{i}^2 + \sigma_{i-1}^2、
	// 计算对图像做不同尺度高斯模糊的尺度因子
    sig[0] = sigma;
    double k = pow( 2., 1. / nOctaveLayers );
    for( int i = 1; i < nOctaveLayers + 3; i++ )
    {
        double sig_prev = pow(k, (double)(i-1))*sigma;
        double sig_total = sig_prev*k;
        sig[i] = std::sqrt(sig_total*sig_total - sig_prev*sig_prev);
    }

    for( int o = 0; o < nOctaves; o++ )
    {
		// DoG金子塔需要nOctaveLayers+2层图像来检测nOctaves层尺度
		// 所以高斯金字塔需要nOctaveLayers+3层图像得到nOctaveLayers+2层DoG金字塔
        for( int i = 0; i < nOctaveLayers + 3; i++ )
        {
			// dst为第o组(Octave)金字塔
            Mat& dst = pyr[o*(nOctaveLayers + 3) + i];
			// 第0组第0层为原始图像
            if( o == 0  &&  i == 0 )
                dst = base;
            
			// base of new octave is halved image from end of previous octave
			// 每一组第0副图像时上一组倒数第三幅图像隔点采样得到
            else if( i == 0 )
            {
                const Mat& src = pyr[(o-1)*(nOctaveLayers + 3) + nOctaveLayers];
                resize(src, dst, Size(src.cols/2, src.rows/2),
                       0, 0, INTER_NEAREST);
            }
			// 每一组第i副图像是由第i-1副图像进行sig[i]的高斯模糊得到
			// 也就是本组图像在sig[i]的尺度空间下的图像
            else
            {
                const Mat& src = pyr[o*(nOctaveLayers + 3) + i-1];
                GaussianBlur(src, dst, Size(), sig[i], sig[i]);
            }
        }
    }
}

高斯金字塔的组数为:

代码10-17行是计算高斯模糊的系数σ,具体关系如下:

其中,σ为尺度空间坐标,s为每组中层坐标,σ0为初始尺度,S为每组层数(一般为3~5)。根据这个公式,我们可以得到金字塔组内各层尺度以及组间各图像尺度关系。
组内相邻图像尺度关系:

相邻组间尺度关系:

所以, 相邻两组的同一层尺度为2倍的关系
最终尺度序列总结为:

o为金字塔组数,n为每组金字塔层数。

构建DoG金字塔

构建高斯金字塔之后,就是用金字塔相邻图像相减构造DoG金字塔。



下面为构造DoG的代码:
// 构建nOctaves组(每组nOctaves+2层)高斯差分金字塔
void SIFT::buildDoGPyramid( const vector<Mat>& gpyr, vector<Mat>& dogpyr ) const
{
    int nOctaves = (int)gpyr.size()/(nOctaveLayers + 3);
    dogpyr.resize( nOctaves*(nOctaveLayers + 2) );

    for( int o = 0; o < nOctaves; o++ )
    {
        for( int i = 0; i < nOctaveLayers + 2; i++ )
        {
			// 第o组第i副图像为高斯金字塔中第o组第i+1和i组图像相减得到
            const Mat& src1 = gpyr[o*(nOctaveLayers + 3) + i];
            const Mat& src2 = gpyr[o*(nOctaveLayers + 3) + i + 1];
            Mat& dst = dogpyr[o*(nOctaveLayers + 2) + i];
            subtract(src2, src1, dst, noArray(), CV_16S);
        }
    }
}
这个比较简单,就是一个 subtract()函数。

至此,SIFT第一步就完成了。参见《SIFT原理与源码分析

 

 

2.关键点搜索与定位(Keypoint localization

由前一步《DoG尺度空间构造》,我们得到了DoG高斯差分金字塔:


如上图的金字塔,高斯尺度空间金字塔中每组有五层不同尺度图像,相邻两层相减得到四层DoG结果。关键点搜索就在这四层DoG图像上寻找局部极值点。

DoG局部极值点

寻找DoG极值点时,每一个像素点和它所有的相邻点比较,当其大于(或小于)它的图像域和尺度域的所有相邻点时,即为极值点。如下图所示,比较的范围是个3×3的立方体:中间的检测点和它同尺度的8个相邻点,以及和上下相邻尺度对应的9×2个点——共26个点比较,以确保在尺度空间和二维图像空间都检测到极值点。 


在一组中,搜索从每组的第二层开始,以第二层为当前层,第一层和第三层分别作为立方体的的上下层;搜索完成后再以第三层为当前层做同样的搜索。所以每层的点搜索两次。通常我们将组Octaves索引以-1开始,则在比较时牺牲了-1组的第0层和第N组的最高层


高斯金字塔,DoG图像及极值计算的相互关系如上图所示。


关键点精确定位

以上极值点的搜索是在离散空间进行搜索的,由下图可以看到,在离散空间找到的极值点不一定是真正意义上的极值点。可以通过对尺度空间DoG函数进行曲线拟合寻找极值点来减小这种误差。

利用DoG函数在尺度空间的Taylor展开式:

则极值点为:

程序中还除去了极值小于0.04的点。如下所示:
  
// Detects features at extrema in DoG scale space.  Bad features are discarded
// based on contrast and ratio of principal curvatures.
// 在DoG尺度空间寻特征点(极值点)
void SIFT::findScaleSpaceExtrema( const vector<Mat>& gauss_pyr, const vector<Mat>& dog_pyr,
                                  vector<KeyPoint>& keypoints ) const
{
    int nOctaves = (int)gauss_pyr.size()/(nOctaveLayers + 3);
	
	// The contrast threshold used to filter out weak features in semi-uniform
	// (low-contrast) regions. The larger the threshold, the less features are produced by the detector.
	// 过滤掉弱特征的阈值 contrastThreshold默认为0.04
    int threshold = cvFloor(0.5 * contrastThreshold / nOctaveLayers * 255 * SIFT_FIXPT_SCALE);
    const int n = SIFT_ORI_HIST_BINS; //36
    float hist[n];
    KeyPoint kpt;

    keypoints.clear();

    for( int o = 0; o < nOctaves; o++ )
        for( int i = 1; i <= nOctaveLayers; i++ )
        {
            int idx = o*(nOctaveLayers+2)+i;
            const Mat& img = dog_pyr[idx];
            const Mat& prev = dog_pyr[idx-1];
            const Mat& next = dog_pyr[idx+1];
            int step = (int)img.step1();
            int rows = img.rows, cols = img.cols;

            for( int r = SIFT_IMG_BORDER; r < rows-SIFT_IMG_BORDER; r++)
            {
                const short* currptr = img.ptr<short>(r);
                const short* prevptr = prev.ptr<short>(r);
                const short* nextptr = next.ptr<short>(r);

                for( int c = SIFT_IMG_BORDER; c < cols-SIFT_IMG_BORDER; c++)
                {
                    int val = currptr[c];

                    // find local extrema with pixel accuracy
					// 寻找局部极值点,DoG中每个点与其所在的立方体周围的26个点比较
					// if (val比所有都大 或者 val比所有都小)
                    if( std::abs(val) > threshold &&
                       ((val > 0 && val >= currptr[c-1] && val >= currptr[c+1] &&
                         val >= currptr[c-step-1] && val >= currptr[c-step] && 
						 val >= currptr[c-step+1] && val >= currptr[c+step-1] && 
						 val >= currptr[c+step] && val >= currptr[c+step+1] &&
                         val >= nextptr[c] && val >= nextptr[c-1] && 
						 val >= nextptr[c+1] && val >= nextptr[c-step-1] && 
						 val >= nextptr[c-step] && val >= nextptr[c-step+1] && 
						 val >= nextptr[c+step-1] && val >= nextptr[c+step] && 
						 val >= nextptr[c+step+1] && val >= prevptr[c] && 
						 val >= prevptr[c-1] && val >= prevptr[c+1] &&
                         val >= prevptr[c-step-1] && val >= prevptr[c-step] && 
						 val >= prevptr[c-step+1] && val >= prevptr[c+step-1] && 
						 val >= prevptr[c+step] && val >= prevptr[c+step+1]) ||
						(val < 0 && val <= currptr[c-1] && val <= currptr[c+1] &&
                         val <= currptr[c-step-1] && val <= currptr[c-step] && 
						 val <= currptr[c-step+1] && val <= currptr[c+step-1] && 
						 val <= currptr[c+step] && val <= currptr[c+step+1] &&
                         val <= nextptr[c] && val <= nextptr[c-1] && 
						 val <= nextptr[c+1] && val <= nextptr[c-step-1] && 
						 val <= nextptr[c-step] && val <= nextptr[c-step+1] && 
						 val <= nextptr[c+step-1] && val <= nextptr[c+step] && 
						 val <= nextptr[c+step+1] && val <= prevptr[c] && 
						 val <= prevptr[c-1] && val <= prevptr[c+1] &&
                         val <= prevptr[c-step-1] && val <= prevptr[c-step] && 
						 val <= prevptr[c-step+1] && val <= prevptr[c+step-1] && 
						 val <= prevptr[c+step] && val <= prevptr[c+step+1])))
                    {
                        int r1 = r, c1 = c, layer = i;
						
						// 关键点精确定位
                        if( !adjustLocalExtrema(dog_pyr, kpt, o, layer, r1, c1,
                                                nOctaveLayers, (float)contrastThreshold,
                                                (float)edgeThreshold, (float)sigma) )
                            continue;
                        
						float scl_octv = kpt.size*0.5f/(1 << o);
						// 计算梯度直方图
                        float omax = calcOrientationHist(
							gauss_pyr[o*(nOctaveLayers+3) + layer],
                            Point(c1, r1),
                            cvRound(SIFT_ORI_RADIUS * scl_octv),
                            SIFT_ORI_SIG_FCTR * scl_octv,
                            hist, n);
                        float mag_thr = (float)(omax * SIFT_ORI_PEAK_RATIO);
                        for( int j = 0; j < n; j++ )
                        {
                            int l = j > 0 ? j - 1 : n - 1;
                            int r2 = j < n-1 ? j + 1 : 0;

                            if( hist[j] > hist[l]  &&  hist[j] > hist[r2]  &&  hist[j] >= mag_thr )
                            {
                                float bin = j + 0.5f * (hist[l]-hist[r2]) / 
								(hist[l] - 2*hist[j] + hist[r2]);
                                bin = bin < 0 ? n + bin : bin >= n ? bin - n : bin;
                                kpt.angle = (float)((360.f/n) * bin);
                                keypoints.push_back(kpt);
                            }
                        }
                    }
                }
            }
        }
}


删除边缘效应

除了DoG响应较低的点,还有一些响应较强的点也不是稳定的特征点。DoG对图像中的边缘有较强的响应值,所以落在图像边缘的点也不是稳定的特征点。
一个平坦的DoG响应峰值在横跨边缘的地方有较大的主曲率,而在垂直边缘的地方有较小的主曲率。主曲率可以通过2×2的Hessian矩阵H求出:

D值可以通过求临近点差分得到。H的特征值与D的主曲率成正比,具体可参见Harris角点检测算法。
为了避免求具体的值,我们可以通过H将特征值的比例表示出来。令 为最大特征值, 为最小特征值,那么:


Tr(H)表示矩阵H的迹,Det(H)表示H的行列式。
表示最大特征值与最小特征值的比值,则有:

上式与两个特征值的比例有关。随着主曲率比值的增加, 也会增加。我们只需要去掉比率大于一定值的特征点。Lowe论文中去掉r=10的点。
// Interpolates a scale-space extremum's location and scale to subpixel
// accuracy to form an image feature.  Rejects features with low contrast.
// Based on Section 4 of Lowe's paper.
// 特征点精确定位
static bool adjustLocalExtrema( const vector<Mat>& dog_pyr, KeyPoint& kpt, int octv,
                                int& layer, int& r, int& c, int nOctaveLayers,
                                float contrastThreshold, float edgeThreshold, float sigma )
{
    const float img_scale = 1.f/(255*SIFT_FIXPT_SCALE);
    const float deriv_scale = img_scale*0.5f;
    const float second_deriv_scale = img_scale;
    const float cross_deriv_scale = img_scale*0.25f;

    float xi=0, xr=0, xc=0, contr;
    int i = 0;

	//三维子像元插值
    for( ; i < SIFT_MAX_INTERP_STEPS; i++ )
    {
        int idx = octv*(nOctaveLayers+2) + layer;
        const Mat& img = dog_pyr[idx];
        const Mat& prev = dog_pyr[idx-1];
        const Mat& next = dog_pyr[idx+1];

        Vec3f dD((img.at<short>(r, c+1) - img.at<short>(r, c-1))*deriv_scale,
                 (img.at<short>(r+1, c) - img.at<short>(r-1, c))*deriv_scale,
                 (next.at<short>(r, c) - prev.at<short>(r, c))*deriv_scale);

        float v2 = (float)img.at<short>(r, c)*2;
        float dxx = (img.at<short>(r, c+1) + 
				img.at<short>(r, c-1) - v2)*second_deriv_scale;
        float dyy = (img.at<short>(r+1, c) + 
				img.at<short>(r-1, c) - v2)*second_deriv_scale;
        float dss = (next.at<short>(r, c) + 
				prev.at<short>(r, c) - v2)*second_deriv_scale;
        float dxy = (img.at<short>(r+1, c+1) - 
				img.at<short>(r+1, c-1) - img.at<short>(r-1, c+1) + 
				img.at<short>(r-1, c-1))*cross_deriv_scale;
        float dxs = (next.at<short>(r, c+1) - 
				next.at<short>(r, c-1) - prev.at<short>(r, c+1) + 
				prev.at<short>(r, c-1))*cross_deriv_scale;
        float dys = (next.at<short>(r+1, c) - 
				next.at<short>(r-1, c) - prev.at<short>(r+1, c) + 
				prev.at<short>(r-1, c))*cross_deriv_scale;

        Matx33f H(dxx, dxy, dxs,
                  dxy, dyy, dys,
                  dxs, dys, dss);

        Vec3f X = H.solve(dD, DECOMP_LU);

        xi = -X[2];
        xr = -X[1];
        xc = -X[0];

        if( std::abs( xi ) < 0.5f  &&  std::abs( xr ) < 0.5f  &&  std::abs( xc ) < 0.5f )
            break;

		//将找到的极值点对应成像素(整数)
        c += cvRound( xc );
        r += cvRound( xr );
        layer += cvRound( xi );

        if( layer < 1 || layer > nOctaveLayers ||
           c < SIFT_IMG_BORDER || c >= img.cols - SIFT_IMG_BORDER  ||
           r < SIFT_IMG_BORDER || r >= img.rows - SIFT_IMG_BORDER )
            return false;
    }

    /* ensure convergence of interpolation */
	// SIFT_MAX_INTERP_STEPS:插值最大步数,避免插值不收敛,程序中默认为5
    if( i >= SIFT_MAX_INTERP_STEPS )
        return false;

    {
        int idx = octv*(nOctaveLayers+2) + layer;
        const Mat& img = dog_pyr[idx];
        const Mat& prev = dog_pyr[idx-1];
        const Mat& next = dog_pyr[idx+1];
        Matx31f dD((img.at<short>(r, c+1) - img.at<short>(r, c-1))*deriv_scale,
                   (img.at<short>(r+1, c) - img.at<short>(r-1, c))*deriv_scale,
                   (next.at<short>(r, c) - prev.at<short>(r, c))*deriv_scale);
        float t = dD.dot(Matx31f(xc, xr, xi));

        contr = img.at<short>(r, c)*img_scale + t * 0.5f;
        if( std::abs( contr ) * nOctaveLayers < contrastThreshold )
            return false;

        /* principal curvatures are computed using the trace and det of Hessian */
       //利用Hessian矩阵的迹和行列式计算主曲率的比值
	   float v2 = img.at<short>(r, c)*2.f;
        float dxx = (img.at<short>(r, c+1) + 
				img.at<short>(r, c-1) - v2)*second_deriv_scale;
        float dyy = (img.at<short>(r+1, c) + 
				img.at<short>(r-1, c) - v2)*second_deriv_scale;
        float dxy = (img.at<short>(r+1, c+1) - 
				img.at<short>(r+1, c-1) - img.at<short>(r-1, c+1) + 
				img.at<short>(r-1, c-1)) * cross_deriv_scale;
        float tr = dxx + dyy;
        float det = dxx * dyy - dxy * dxy;

		//这里edgeThreshold可以在调用SIFT()时输入;
		//其实代码中定义了 static const float SIFT_CURV_THR = 10.f 可以直接使用
        if( det <= 0 || tr*tr*edgeThreshold >= (edgeThreshold + 1)*(edgeThreshold + 1)*det )
            return false;
    }

    kpt.pt.x = (c + xc) * (1 << octv);
    kpt.pt.y = (r + xr) * (1 << octv);
    kpt.octave = octv + (layer << 8) + (cvRound((xi + 0.5)*255) << 16);
    kpt.size = sigma*powf(2.f, (layer + xi) / nOctaveLayers)*(1 << octv)*2;

    return true;
}

至此,SIFT第二步就完成了。参见《SIFT原理与源码分析

3.方向赋值(Orientation assignment

由前一篇《 关键点搜索与定位》,我们已经找到了关键点。为了实现图像旋转不变性,需要根据检测到的关键点局部图像结构为特征点方向赋值。也就是在 findScaleSpaceExtrema()函数里看到的alcOrientationHist()语句:
// 计算梯度直方图
float omax = calcOrientationHist(gauss_pyr[o*(nOctaveLayers+3) + layer],
                                                Point(c1, r1),
                                                cvRound(SIFT_ORI_RADIUS * scl_octv),
                                                SIFT_ORI_SIG_FCTR * scl_octv,
                                                hist, n);

我们使用图像的梯度直方图法求关键点局部结构的稳定方向。

梯度方向和幅值

在前文中,精确定位关键点后也找到改特征点的尺度值σ,根据这一尺度值,得到最接近这一尺度值的高斯图像:


使用有限差分,计算以关键点为中心,以3×1.5σ为半径的区域内图像梯度的幅角和幅值,公式如下:

梯度直方图

在完成关键点邻域内高斯图像梯度计算后,使用直方图统计邻域内像素对应的梯度方向和幅值。

有关直方图的基础知识可以参考《数字图像直方图》,可以看做是离散点的概率表示形式。此处方向直方图的核心是统计以关键点为原点,一定区域内的图像像素点对关键点方向生成所作的贡献

梯度方向直方图的横轴是梯度方向角,纵轴是剃度方向角对应的梯度幅值累加值。梯度方向直方图将0°~360°的范围分为36个柱,每10°为一个柱。下图是从高斯图像上求取梯度,再由梯度得到梯度方向直方图的例图。

在计算直方图时,每个加入直方图的采样点都使用圆形高斯函数函数进行了加权处理,也就是进行高斯平滑。这主要是因为SIFT算法只考虑了尺度和旋转不变形,没有考虑仿射不变性。通过高斯平滑,可以使关键点附近的梯度幅值有较大权重,从而部分弥补没考虑仿射不变形产生的特征点不稳定。

通常离散的梯度直方图要进行插值拟合处理,以求取更精确的方向角度值。(这和《关键点搜索与定位》中插值的思路是一样的)。

关键点方向

直方图峰值代表该关键点处邻域内图像梯度的主方向,也就是该关键点的主方向。在梯度方向直方图中,当存在另一个相当于主峰值    80%能量的峰值时,则将这个方向认为是该关键点的辅方向。所以一个关键点可能检测得到多个方向,这可以增强匹配的鲁棒性。Lowe的论文指出大概有15%关键点具有多方向,但这些点对匹配的稳定性至为关键。

获得图像关键点主方向后,每个关键点有三个信息(x,y,σ,θ):位置、尺度、方向。由此我们可以确定一个SIFT特征区域。通常使用一个带箭头的圆或直接使用箭头表示SIFT区域的三个值:中心表示特征点位置,半径表示关键点尺度(r=2.5σ),箭头表示主方向。具有多个方向的关键点可以复制成多份,然后将方向值分别赋给复制后的关键点。如下图:


源码

// Computes a gradient orientation histogram at a specified pixel
// 计算特定点的梯度方向直方图
static float calcOrientationHist( const Mat& img, Point pt, int radius,
                                  float sigma, float* hist, int n )
{
	//len:2r+1也就是以r为半径的圆(正方形)像素个数
    int i, j, k, len = (radius*2+1)*(radius*2+1);

    float expf_scale = -1.f/(2.f * sigma * sigma);
    AutoBuffer<float> buf(len*4 + n+4);
    float *X = buf, *Y = X + len, *Mag = X, *Ori = Y + len, *W = Ori + len;
    float* temphist = W + len + 2;

    for( i = 0; i < n; i++ )
        temphist[i] = 0.f;

	// 图像梯度直方图统计的像素范围
    for( i = -radius, k = 0; i <= radius; i++ )
    {
        int y = pt.y + i;
        if( y <= 0 || y >= img.rows - 1 )
            continue;
        for( j = -radius; j <= radius; j++ )
        {
            int x = pt.x + j;
            if( x <= 0 || x >= img.cols - 1 )
                continue;

            float dx = (float)(img.at<short>(y, x+1) - img.at<short>(y, x-1));
            float dy = (float)(img.at<short>(y-1, x) - img.at<short>(y+1, x));

            X[k] = dx; Y[k] = dy; W[k] = (i*i + j*j)*expf_scale;
            k++;
        }
    }

    len = k;

    // compute gradient values, orientations and the weights over the pixel neighborhood
	// 计算梯度、幅角和幅值
    exp(W, W, len); 
    fastAtan2(Y, X, Ori, len, true); 
    magnitude(X, Y, Mag, len); //幅角
	
	// 计算直方图的每个bin
    for( k = 0; k < len; k++ )
    {
        int bin = cvRound((n/360.f)*Ori[k]);
        if( bin >= n )
            bin -= n;
        if( bin < 0 )
            bin += n;
        temphist[bin] += W[k]*Mag[k];
    }

    // smooth the histogram
	// 高斯平滑
    temphist[-1] = temphist[n-1];
    temphist[-2] = temphist[n-2];
    temphist[n] = temphist[0];
    temphist[n+1] = temphist[1];
    for( i = 0; i < n; i++ )
    {
        hist[i] = (temphist[i-2] + temphist[i+2])*(1.f/16.f) +
            (temphist[i-1] + temphist[i+1])*(4.f/16.f) +
            temphist[i]*(6.f/16.f);
    }
	
	// 得到主方向
    float maxval = hist[0];
    for( i = 1; i < n; i++ )
        maxval = std::max(maxval, hist[i]);

    return maxval;
}

这一步比较简单~参见《 SIFT原理与源码分析》。
 

 

由前一篇《 方向赋值》,为找到的关键点即SIFT特征点赋了值,包含位置、尺度和方向的信息。接下来的步骤是关键点描述,即用用一组向量将这个关键点描述出来,这个描述子不但包括关键点,也包括关键点周围对其有贡献的像素点。用来作为 目标匹配的依据(所以描述子应该有较高的独特性,以保证匹配率),也可使关键点具有更多的不变特性,如光照变化、3D视点变化等。

SIFT描述子h(x,y,θ)是对关键点附近邻域内高斯图像梯度统计的结果,是一个三维矩阵,但通常用一个矢量来表示。矢量通过对三维矩阵按一定规律排列得到。

描述子采样区域

特征描述子与关键点所在尺度有关,因此对梯度的求取应在特征点对应的高斯图像上进行。将关键点附近划分成d×d个子区域,每个子区域尺寸为mσ个像元(d=4,m=3,σ为尺特征点的尺度值)。考虑到实际计算时需要双线性插值,故计算的图像区域为mσ(d+1),再考虑旋转,则实际计算的图像区域为 ,如下图所示:

源码

[cpp]   view plain copy
  1.    Point pt(cvRound(ptf.x), cvRound(ptf.y));  
  2. //计算余弦,正弦,CV_PI/180:将角度值转化为幅度值  
  3.    float cos_t = cosf(ori*(float)(CV_PI/180));  
  4.    float sin_t = sinf(ori*(float)(CV_PI/180));  
  5.    float bins_per_rad = n / 360.f;  
  6.    float exp_scale = -1.f/(d * d * 0.5f); //d:SIFT_DESCR_WIDTH 4      
  7.    float hist_width = SIFT_DESCR_SCL_FCTR * scl;  // SIFT_DESCR_SCL_FCTR: 3   
  8.                                                // scl: size*0.5f  
  9. // 计算图像区域半径mσ(d+1)/2*sqrt(2)  
  10. // 1.4142135623730951f 为根号2  
  11.    int radius = cvRound(hist_width * 1.4142135623730951f * (d + 1) * 0.5f);  
  12.    cos_t /= hist_width;  
  13.    sin_t /= hist_width;  



区域坐标轴旋转

为了保证特征矢量具有旋转不变性,要以特征点为中心,在附近邻域内旋转θ角,即旋转为特征点的方向。

旋转后区域内采样点新的坐标为:

源码

[cpp]   view plain copy
  1. //计算采样区域点坐标旋转  
  2.     for( i = -radius, k = 0; i <= radius; i++ )  
  3.         for( j = -radius; j <= radius; j++ )  
  4.         {  
  5.             /* 
  6.              Calculate sample's histogram array coords rotated relative to ori. 
  7.              Subtract 0.5 so samples that fall e.g. in the center of row 1 (i.e. 
  8.              r_rot = 1.5) have full weight placed in row 1 after interpolation. 
  9.              */  
  10.             float c_rot = j * cos_t - i * sin_t;  
  11.             float r_rot = j * sin_t + i * cos_t;  
  12.             float rbin = r_rot + d/2 - 0.5f;  
  13.             float cbin = c_rot + d/2 - 0.5f;  
  14.             int r = pt.y + i, c = pt.x + j;  
  15.   
  16.             if( rbin > -1 && rbin < d && cbin > -1 && cbin < d &&  
  17.                r > 0 && r < rows - 1 && c > 0 && c < cols - 1 )  
  18.             {  
  19.                 float dx = (float)(img.at<short>(r, c+1) - img.at<short>(r, c-1));  
  20.                 float dy = (float)(img.at<short>(r-1, c) - img.at<short>(r+1, c));  
  21.                 X[k] = dx; Y[k] = dy; RBin[k] = rbin; CBin[k] = cbin;  
  22.                 W[k] = (c_rot * c_rot + r_rot * r_rot)*exp_scale;  
  23.                 k++;  
  24.             }  
  25.         }  


计算采样区域梯度直方图

将旋转后区域划分为d×d个子区域(每个区域间隔为mσ像元),在子区域内计算8个方向的梯度直方图,绘制每个方向梯度方向的累加值,形成一个种子点。
与求主方向不同的是,此时,每个子区域梯度方向直方图将0°~360°划分为8个方向区间,每个区间为45°。即每个种子点有8个方向区间的梯度强度信息。由于存在d×d,即4×4个子区域,所以最终共有4×4×8=128个数据,形成128维SIFT特征矢量。

对特征矢量需要加权处理,加权采用mσd/2的标准高斯函数。为了除去光照变化影响,还有一步归一化处理。

源码

[cpp]   view plain copy
  1. //计算梯度直方图  
  2.     for( k = 0; k < len; k++ )  
  3.     {  
  4.         float rbin = RBin[k], cbin = CBin[k];  
  5.         float obin = (Ori[k] - ori)*bins_per_rad;  
  6.         float mag = Mag[k]*W[k];  
  7.   
  8.         int r0 = cvFloor( rbin );  
  9.         int c0 = cvFloor( cbin );  
  10.         int o0 = cvFloor( obin );  
  11.         rbin -= r0;  
  12.         cbin -= c0;  
  13.         obin -= o0;  
  14.   
  15.         //n为SIFT_DESCR_HIST_BINS:8,即将360°分为8个区间  
  16.         if( o0 < 0 )  
  17.             o0 += n;  
  18.         if( o0 >= n )  
  19.             o0 -= n;  
  20.           
  21.   
  22.         // histogram update using tri-linear interpolation  
  23.         // 双线性插值  
  24.         float v_r1 = mag*rbin, v_r0 = mag - v_r1;  
  25.         float v_rc11 = v_r1*cbin, v_rc10 = v_r1 - v_rc11;  
  26.         float v_rc01 = v_r0*cbin, v_rc00 = v_r0 - v_rc01;  
  27.         float v_rco111 = v_rc11*obin, v_rco110 = v_rc11 - v_rco111;  
  28.         float v_rco101 = v_rc10*obin, v_rco100 = v_rc10 - v_rco101;  
  29.         float v_rco011 = v_rc01*obin, v_rco010 = v_rc01 - v_rco011;  
  30.         float v_rco001 = v_rc00*obin, v_rco000 = v_rc00 - v_rco001;  
  31.   
  32.         int idx = ((r0+1)*(d+2) + c0+1)*(n+2) + o0;  
  33.         hist[idx] += v_rco000;  
  34.         hist[idx+1] += v_rco001;  
  35.         hist[idx+(n+2)] += v_rco010;  
  36.         hist[idx+(n+3)] += v_rco011;  
  37.         hist[idx+(d+2)*(n+2)] += v_rco100;  
  38.         hist[idx+(d+2)*(n+2)+1] += v_rco101;  
  39.         hist[idx+(d+3)*(n+2)] += v_rco110;  
  40.         hist[idx+(d+3)*(n+2)+1] += v_rco111;  
  41.     }  

关键点描述源码

[cpp]   view plain copy
  1. // SIFT关键点特征描述  
  2. // SIFT描述子是关键点领域高斯图像提取统计结果的一种表示  
  3. static void calcSIFTDescriptor( const Mat& img, Point2f ptf, float ori, float scl,  
  4.                                int d, int n, float* dst )  
  5.                              
  6. {  
  7.     Point pt(cvRound(ptf.x), cvRound(ptf.y));  
  8.     //计算余弦,正弦,CV_PI/180:将角度值转化为幅度值  
  9.     float cos_t = cosf(ori*(float)(CV_PI/180));  
  10.     float sin_t = sinf(ori*(float)(CV_PI/180));  
  11.     float bins_per_rad = n / 360.f;  
  12.     float exp_scale = -1.f/(d * d * 0.5f); //d:SIFT_DESCR_WIDTH 4     
  13.     float hist_width = SIFT_DESCR_SCL_FCTR * scl;  // SIFT_DESCR_SCL_FCTR: 3   
  14.                                                    // scl: size*0.5f  
  15.     // 计算图像区域半径mσ(d+1)/2*sqrt(2)  
  16.     // 1.4142135623730951f 为根号2  
  17.     int radius = cvRound(hist_width * 1.4142135623730951f * (d + 1) * 0.5f);  
  18.     cos_t /= hist_width;  
  19.     sin_t /= hist_width;  
  20.   
  21.     int i, j, k, len = (radius*2+1)*(radius*2+1), histlen = (d+2)*(d+2)*(n+2);  
  22.     int rows = img.rows, cols = img.cols;  
  23.   
  24.     AutoBuffer<float> buf(len*6 + histlen);  
  25.     float *X = buf, *Y = X + len, *Mag = Y, *Ori = Mag + len, *W = Ori + len;  
  26.     float *RBin = W + len, *CBin = RBin + len, *hist = CBin + len;  
  27.   
  28.     //初始化直方图  
  29.     for( i = 0; i < d+2; i++ )  
  30.     {  
  31.         for( j = 0; j < d+2; j++ )  
  32.             for( k = 0; k < n+2; k++ )  
  33.                 hist[(i*(d+2) + j)*(n+2) + k] = 0.;  
  34.     }  
  35.   
  36.     //计算采样区域点坐标旋转  
  37.     for( i = -radius, k = 0; i <= radius; i++ )  
  38.         for( j = -radius; j <= radius; j++ )  
  39.         {  
  40.             /* 
  41.              Calculate sample's histogram array coords rotated relative to ori. 
  42.              Subtract 0.5 so samples that fall e.g. in the center of row 1 (i.e. 
  43.              r_rot = 1.5) have full weight placed in row 1 after interpolation. 
  44.              */  
  45.             float c_rot = j * cos_t - i * sin_t;  
  46.             float r_rot = j * sin_t + i * cos_t;  
  47.             float rbin = r_rot + d/2 - 0.5f;  
  48.             float cbin = c_rot + d/2 - 0.5f;  
  49.             int r = pt.y + i, c = pt.x + j;  
  50.   
  51.             if( rbin > -1 && rbin < d && cbin > -1 && cbin < d &&  
  52.                r > 0 && r < rows - 1 && c > 0 && c < cols - 1 )  
  53.             {  
  54.                 float dx = (float)(img.at<short>(r, c+1) - img.at<short>(r, c-1));  
  55.                 float dy = (float)(img.at<short>(r-1, c) - img.at<short>(r+1, c));  
  56.                 X[k] = dx; Y[k] = dy; RBin[k] = rbin; CBin[k] = cbin;  
  57.                 W[k] = (c_rot * c_rot + r_rot * r_rot)*exp_scale;  
  58.                 k++;  
  59.             }  
  60.         }  
  61.   
  62.     len = k;  
  63.     fastAtan2(Y, X, Ori, len, true);  
  64.     magnitude(X, Y, Mag, len);  
  65.     exp(W, W, len);  
  66.   
  67.       
  68.     //计算梯度直方图  
  69.     for( k = 0; k < len; k++ )  
  70.     {  
  71.         float rbin = RBin[k], cbin = CBin[k];  
  72.         float obin = (Ori[k] - ori)*bins_per_rad;  
  73.         float mag = Mag[k]*W[k];  
  74.   
  75.         int r0 = cvFloor( rbin );  
  76.         int c0 = cvFloor( cbin );  
  77.         int o0 = cvFloor( obin );  
  78.         rbin -= r0;  
  79.         cbin -= c0;  
  80.         obin -= o0;  
  81.   
  82.         //n为SIFT_DESCR_HIST_BINS:8,即将360°分为8个区间  
  83.         if( o0 < 0 )  
  84.             o0 += n;  
  85.         if( o0 >= n )  
  86.             o0 -= n;  
  87.           
  88.   
  89.         // histogram update using tri-linear interpolation  
  90.         // 双线性插值  
  91.         float v_r1 = mag*rbin, v_r0 = mag - v_r1;  
  92.         float v_rc11 = v_r1*cbin, v_rc10 = v_r1 - v_rc11;  
  93.         float v_rc01 = v_r0*cbin, v_rc00 = v_r0 - v_rc01;  
  94.         float v_rco111 = v_rc11*obin, v_rco110 = v_rc11 - v_rco111;  
  95.         float v_rco101 = v_rc10*obin, v_rco100 = v_rc10 - v_rco101;  
  96.         float v_rco011 = v_rc01*obin, v_rco010 = v_rc01 - v_rco011;  
  97.         float v_rco001 = v_rc00*obin, v_rco000 = v_rc00 - v_rco001;  
  98.   
  99.         int idx = ((r0+1)*(d+2) + c0+1)*(n+2) + o0;  
  100.         hist[idx] += v_rco000;  
  101.         hist[idx+1] += v_rco001;  
  102.         hist[idx+(n+2)] += v_rco010;  
  103.         hist[idx+(n+3)] += v_rco011;  
  104.         hist[idx+(d+2)*(n+2)] += v_rco100;  
  105.         hist[idx+(d+2)*(n+2)+1] += v_rco101;  
  106.         hist[idx+(d+3)*(n+2)] += v_rco110;  
  107.         hist[idx+(d+3)*(n+2)+1] += v_rco111;  
  108.     }  
  109.   
  110.     // finalize histogram, since the orientation histograms are circular  
  111.     // 最后确定直方图,目标方向直方图是圆的  
  112.     for( i = 0; i < d; i++ )  
  113.         for( j = 0; j < d; j++ )  
  114.         {  
  115.             int idx = ((i+1)*(d+2) + (j+1))*(n+2);  
  116.             hist[idx] += hist[idx+n];  
  117.             hist[idx+1] += hist[idx+n+1];  
  118.             for( k = 0; k < n; k++ )  
  119.                 dst[(i*d + j)*n + k] = hist[idx+k];  
  120.         }  
  121.     // copy histogram to the descriptor,  
  122.     // apply hysteresis thresholding  
  123.     // and scale the result, so that it can be easily converted  
  124.     // to byte array  
  125.     float nrm2 = 0;  
  126.     len = d*d*n;  
  127.     for( k = 0; k < len; k++ )  
  128.         nrm2 += dst[k]*dst[k];  
  129.     float thr = std::sqrt(nrm2)*SIFT_DESCR_MAG_THR;  
  130.     for( i = 0, nrm2 = 0; i < k; i++ )  
  131.     {  
  132.         float val = std::min(dst[i], thr);  
  133.         dst[i] = val;  
  134.         nrm2 += val*val;  
  135.     }  
  136.     nrm2 = SIFT_INT_DESCR_FCTR/std::max(std::sqrt(nrm2), FLT_EPSILON);  
  137.     for( k = 0; k < len; k++ )  
  138.     {  
  139.         dst[k] = saturate_cast<uchar>(dst[k]*nrm2);  
  140.     }  
  141. }  

至此SIFT描述子生成,SIFT算法也基本完成了~参见《 SIFT原理与源码分析


 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值