一个SIFT关键点拥有三个信息:位置,尺度和方向。前面已经介绍了如何精确定位关键点的位置,通过尺度不变性求极值点,可以使其具有缩放不变的性质。现在来谈谈为特征点指定方向参数,使提取的特征对图像旋转具有不变性,从而实现匹配时图像的旋转无关性。最后,再介绍该用什么样的描述符来表达sift特征。
一、关键点方向分配
SIFT特征的一个关键的特性是旋转不变性,实现旋转不变的基本思想是采用“相对”的概念。利用关键点邻域像素的梯度方向分布特性,我们可以为每个关键点指定方向参数方向。而后面定义的关键点描述特征符是相对于这个主方向的,因而可以实现匹配时图像的旋转无关性。我们通过梯度直方图统计法来确定关键点的方向,即统计以关键点为原点,利用所有在此区域内的像素点的梯度形成一个方向直方图。
具体说来,对于每个特征点P(x,y,δ,σ)所处高斯图像金字塔的δ组σ层图像,其像素点L(x,y)的梯度:(这里的图像是高斯模糊后的图像,非高斯差分图像)
梯度的幅值:
梯度的方向:
直方图的横坐标是梯度方向,共 36 项,每项代表了 10 度的范围;纵坐标是梯度大小,对于归到横坐标上任一项内所有的点,将其梯度大小相加,其和作为纵坐标。下图应该有36项,为了示意只画了8项。(根据Lowe的建议,直方图统计半径采用3*1.5*σ)
从直方图中选出纵坐标值最大的一项所在方向作为该关键点的主方向。如果另一个相当于主峰值 80%能量的峰值时,也将其作为该关键点的方向,称为辅方向。特征点有多个方向的情况下,实际上是在此位置上有多个关键点,他们的方向不同。这些方向可以增强匹配的鲁棒性,Lowe的论文指出大概有15%关键点具有多方向,但这些点对匹配的稳定性至为关键。
另外,为了获得更好的稳定性,可以对关键点邻域的梯度大小进行高斯加权。每相邻三个bin采用高斯加权,根据Lowe的建议,模板采用[0.25,0.5,0.25],并连续加权两次。
算法流程:
1. 遍历特征点集合points,搜索每个特征点的邻域,半径为rad,生成含有36柱的方向直方图,梯度直方图范围0~360度,其中每10度一个柱。
2. 利用高斯加权对方向直方图进行两次平滑,增加稳定性
3. 通过峰值比较,求取关键点方向(可能是多个方向);
4. 通过Taylor展开式对上述峰值进行二次曲线拟合,计算关键点精确方向,即重新计算峰值所在bin的值;
5. 根据bin的值还原角度,作为特征点的方向。
至此,图像的关键点已检测完毕,每个关键点有三个信息:位置、尺度、方向;同时也就使关键点具备平移、缩放、和旋转不变性。
代码:
/*计算梯度方向图和梯度幅度图*/ void GaussianPyramid::cal_mag_ort(int i) { TotalTimer tm("cal_mag_ort"); const Mat32f& orig = data[i]; int w = orig.width(), h = orig.height(); mag[i] = Mat32f(h, w, 1); //初始化全1矩阵,mag幅度,ort角度 ort[i] = Mat32f(h, w, 1); REP(y, h) { float *mag_row = mag[i].ptr(y), *ort_row = ort[i].ptr(y); const float *orig_row = orig.ptr(y), //中间行 *orig_plus = orig.ptr(y + 1), //下一行,如果溢出由下面的between控制 *orig_minus = orig.ptr(y - 1); //上一行,如果溢出由下面的between控制 // x == 0: mag_row[0] = 0; //最左边的亦无法计算,当溢出处理 ort_row[0] = M_PI; //M_PI为内置的,3.1415926 REPL(x, 1, w-1) { if (between(y, 1, h - 1)) { float dy = orig_plus[x] - orig_minus[x], dx = orig_row[x + 1] - orig_row[x - 1]; //计算梯度dx,dy mag_row[x] = hypotf(dx, dy); //计算dx,dy的斜边,理解为梯度的幅度 // approx here cause break working on myself/small*. fix later // when dx==dy==0, no need to set ort ort_row[x] = fast_atan(dy, dx) + M_PI; //计算梯度的方向,该夹角为向量与x轴的夹角 } else { mag_row[x] = 0; ort_row[x] = M_PI; } } // x == w-1 mag_row[w-1] = 0; //最右边的无法计算,当溢出处理 ort_row[w-1] = M_PI; } } OrientationAssign::OrientationAssign( const DOGSpace& dog, const ScaleSpace& ss, const std::vector<SSPoint>& keypoints): dog(dog),ss(ss), points(keypoints) {} vector<SSPoint> OrientationAssign::work() const { vector<SSPoint> ret; for (auto& p : points) { auto major_orient = calc_dir(p); for (auto& o : major_orient) { ret.emplace_back(p); ret.back().dir = o; //将某个特征点的方向o 赋给ret,注意一个特征点的方向有多个 } } return ret; } /*梯度方向直方图统计的是该点周围点的36个方向的地图幅值*/ std::vector<float> OrientationAssign::calc_dir( const SSPoint& p) const { auto& pyramid = ss.pyramids[p.pyr_id]; //当前分辨率图像组,包含多个尺度的高斯滤波图像,赋值见Dog.cc : ScaleSpace函数 auto& orient_img = pyramid.get_ort(p.scale_id); //orient_img角度图 auto& mag_img = pyramid.get_mag(p.scale_id); //mag_img幅度图 float gauss_weight_sigma = p.scale_factor * ORI_WINDOW_FACTOR; //统计直方图采用高斯加权,delta是其尺度delta的1.5倍 int rad = round(p.scale_factor * ORI_RADIUS); //每个点统计直方图的半径为3*1.5*delta ,delta为当前尺度图高斯滤波窗口的组成部分 float exp_denom = 2 * sqr(gauss_weight_sigma); float hist[ORI_HIST_BIN_NUM]; static float halfipi = 0.5f / M_PI; memset(hist, 0, sizeof(hist)); // calculate gaussian/magnitude weighted histogram // of orientation inside a circle for (int xx = -rad; xx < rad; xx ++) { int newx = p.coor.x + xx; // because mag/ort on the border is zero if (! between(newx, 1, pyramid.w - 1)) continue; for (int yy = -rad; yy < rad; yy ++) { int newy = p.coor.y + yy; if (! between(newy, 1, pyramid.h - 1)) continue; // use a circular gaussian window if (sqr(xx) + sqr(yy) > sqr(rad)) continue; float orient = orient_img.at(newy, newx); int bin = round(ORI_HIST_BIN_NUM * halfipi * orient ); //由当前点的梯度方向计算所属哪个bin if (bin == ORI_HIST_BIN_NUM) bin = 0; m_assert(bin < ORI_HIST_BIN_NUM); float weight = expf(-(sqr(xx) + sqr(yy)) / exp_denom); //exp(-(x^2+y^2)/(2*delta^2)) hist[bin] += weight * mag_img.at(newy, newx); //高斯累加当前bin的梯度幅值 } } // TODO do we need this? // smooth the histogram by interpolation,进行两遍直方图平滑,用前后两个bin去平滑当前bin for (int K = ORI_HIST_SMOOTH_COUNT; K --;) REP(i, ORI_HIST_BIN_NUM) { float prev = hist[i == 0 ? ORI_HIST_BIN_NUM - 1 : i - 1]; float next = hist[i == ORI_HIST_BIN_NUM - 1 ? 0 : i + 1]; hist[i] = hist[i] * 0.5 + (prev + next) * 0.25; } float maxbin = 0; for (auto i : hist) update_max(maxbin, i); //i为每个bin的直方图值,此函数找最大bin,为主方向 float thres = maxbin * ORI_HIST_PEAK_RATIO; //辅方向判断阈值 vector<float> ret; REP(i, ORI_HIST_BIN_NUM) { float prev = hist[i == 0 ? ORI_HIST_BIN_NUM - 1 : i - 1]; float next = hist[i == ORI_HIST_BIN_NUM - 1 ? 0 : i + 1]; // choose extreme orientation which is larger than thres,相当于寻找局部极值(前后3个bin内) if (hist[i] > thres && hist[i] > max(prev, next)) { // parabola interpolation,对方向直方图的Taylor展开式进行二次曲线拟合,精确关键点方向: 先找哪个bin,再还原到角度 real_t newbin = (float)i - 0.5 + (hist[i] - prev) / (prev + next - 2 * hist[i]); // I saw this elsewhere... although by derivation the above one should be correct //real_t newbin = (float)i - 0.5 + (next - prev) / (prev + next - 2 * hist[i]); if (newbin < 0) newbin += ORI_HIST_BIN_NUM; else if (newbin >= ORI_HIST_BIN_NUM) newbin -= ORI_HIST_BIN_NUM; float ort = newbin / ORI_HIST_BIN_NUM * 2 * M_PI; ret.push_back(ort); //注意,这里存在多个方向 } } return ret; }
上图现实sift特征点数量有1756个
二、特征描述符
目前为止,我们已经为关键点赋予了坐标位置、尺度信息以及方向。现在我们需要一组向量将这个关键点表达出来,并且这组向量不单包括关键点,还应包括关键点周围对其有贡献的像素点。我们还期望这组向量对仿射变换、光照变换等具有一定的鲁棒性,这些不变特性将会作为目标匹配的依据。
描述子的基本思路:通过对关键点周围图像区域分块,计算块内梯度直方图,生成具有独特性的向量,这个向量是该区域图像信息的一种抽象,具有唯一性。
如下图所示,左侧图片为在关键点周围取一个8*8邻域,每一个小格都代表了特征点邻域所在的尺度空间的一个像素,箭头方向代表了像素梯度方向,箭头长度代表该像素的幅值。
右侧图片为四分之一个邻域,由4个大小2*2的像素区域组成,每个子区域生成一个八方向的梯度直方图,绘制每个梯度方向的累加可形成一个种子点。这样一个特征点由4*4个种子点的信息所组成,如下图所示:
即每个子区域生成一个描述子,一个描述子中涉及 8 个方向。所以每个关键点有 4x4x8 = 128 维。关键点描述子生成步骤:
1. 确定计算描述子所需的图像区域,图像区域的半径通过下式计算
其中,σoct是关键点所在组(octave)的组内尺度,d=4。
2. 将坐标移至关键点主方向,旋转角度后新坐标:
3. 在图像半径区域内对每个像素点求其梯度幅值和方向,然后对每个梯度幅值乘以高斯权重参数,生成方向直方图。描述子梯度方向直方图由关键点所在尺度的模糊图像计算产生。方向直方图的计算如下:
xk:该点与关键点的列距离
yk:该点与关键点的行距离
σw:等于描述子窗口宽度3σ*直方图列数(取4)的一半
weight:表示方向直方图某个bin的数值
4. 在窗口宽度为2X2的区域内计算8个方向的梯度方向直方图,绘制每个梯度方向的累加值,即可形成一个种子点。然后再在下一个2X2的区域内进行直方图统计,形成下一个种子点,共生成16个种子点。
5. 描述子向量元素门限化及门限化后的描述子向量规范化。
描述子向量元素门限化是指方向直方图每个方向上梯度幅值限制在一定门限值以下(门限一般取0.2)。
描述子向量元素规范化,即归一化。我们设:
至此,我们就得到了sift特征的128维向量…
为什么要做门限化和规范化?为什么要乘以高斯权重参数?
这里用到一些生理学知识,一个生物视觉模型尤其是初级视觉皮层中的复杂神经细胞的工作方式。复杂神经细胞对梯度的方向和空间频率的区分十分敏感,但对视网膜上梯度的位置不作十分精确的区分,允许位置做稍许改变。 1997 年 Edelman, Intrator 和 Poggio 根据这种模型假设复杂神经细胞在视角发生一定变化时,也能够认为物体匹配并识别出 3 维物体。
为了对光线变化更具鲁棒性, 描述子都被归一化到单位长度。如果图像的对比度发生变化,每个像素值都会乘上一个数值,归一化后,对比度的影响被消除了。对于图像亮度的变化,每个像素值都会加上一个数值,然而这对计算的梯度是没有影响的。因此,该描述子对亮度的仿射变换是鲁棒的。对于非线性的光线变化,梯度大小会受影响,但是梯度的方向不会有大的变化,我们可以根据前面描述的生物学原理解决此问题。在 128 维的单位向量中,滤除梯度大小大于0.2的梯度值,然后重新归一化。也就是说,梯度大小的作用被虚弱了,而方向信息的作用被强化了。0.2是实验得出的经验值。
代码:
SIFT::SIFT(const ScaleSpace& ss, //ss表示高斯模糊金字塔图像,keypoints表示关键点:位置、尺度、方向 const vector<SSPoint>& keypoints): ss(ss), points(keypoints) { } std::vector<Descriptor> SIFT::get_descriptor() const { TotalTimer tm("sift descriptor"); vector<Descriptor> ret; for (auto& p : points) { //遍历所有特征点,组个计算描述子 auto desp = calc_descriptor(p); ret.emplace_back(move(desp)); } return ret; } Descriptor SIFT::calc_descriptor(const SSPoint& p) const { static float pi2 = 2 * M_PI; static float nbin_per_rad = DESC_HIST_BIN_NUM / pi2; //一度占多少bin,用于确定某方向属于哪个bin const GaussianPyramid& pyramid = ss.pyramids[p.pyr_id]; //获取当前多分辨率多尺度图像金字塔,非dog int w = pyramid.w, h = pyramid.h; auto& mag_img = pyramid.get_mag(p.scale_id); //获取当前分辨率每个像素点的梯度方向图和梯度幅度图 auto& ort_img = pyramid.get_ort(p.scale_id); Coor coor = p.coor; //当前种子特征点坐标和主方向 float ort = p.dir, // size of blurred field of this point in orignal image hist_w = p.scale_factor * DESC_HIST_SCALE_FACTOR, //DESC_HIST_SCALE_FACTOR表示放大倍数,p.scale_factor表示当前高斯模糊图像尺度 // sigma is half of window width from lowe exp_denom = 2 * sqr(DESC_HIST_WIDTH); // radius of gaussian to use int radius = round(M_SQRT1_2 * hist_w * (DESC_HIST_WIDTH + 1)); //要计算梯度直方图的邻域半径 float hist[DESC_HIST_WIDTH * DESC_HIST_WIDTH][DESC_HIST_BIN_NUM]; //4*4*8直方图 memset(hist, 0, sizeof(hist)); float cosort = cos(ort), sinort = sin(ort); //计算当前点邻域内按照各自局部窗口计算方向直方图,共radius/4*radius/4个窗口,窗口大小4*4,8方向 //并不是说64个像素点绕着原点计算,因为之前已经算过了,每个窗口内将2*2个像素的方向梯度累加到一个点,相当于该窗口有4个种子点 for (int xx = -radius; xx <= radius; xx ++) { int nowx = coor.x + xx; if (!between(nowx, 1, w - 1)) continue; for (int yy = -radius; yy <= radius; yy ++) { int nowy = coor.y + yy; if (!between(nowy, 1, h - 1)) continue; if (sqr(xx) + sqr(yy) > sqr(radius)) continue; // to be circle // coordinate change, relative to major orientation // major orientation become (x, 0) float y_rot = (-xx * sinort + yy * cosort) / hist_w, x_rot = (xx * cosort + yy * sinort) / hist_w;//将相对坐标系旋转到种子点主方向,这里是方向图的相对坐标,用于计算局部的梯度幅度 // calculate 2d bin idx (which bin do I fall into) // -0.5 to make the center of bin 1st (x=1.5) falls fully into bin 1st float ybin = y_rot + DESC_HIST_WIDTH / 2 - 0.5, //求取领域内的相对坐标 xbin = x_rot + DESC_HIST_WIDTH / 2 - 0.5; if (!between(ybin, -1, DESC_HIST_WIDTH) || !between(xbin, -1, DESC_HIST_WIDTH)) continue; float now_mag = mag_img.at(nowy, nowx), //nowx,nowy并没有旋转,获取建立高斯金字塔时就计算得到的梯度方向与幅度 now_ort = ort_img.at(nowy, nowx); // gaussian & magitude weight on histogram float weight = expf(-(sqr(x_rot) + sqr(y_rot)) / exp_denom); //方向直方图高斯加权核函数 weight = weight * now_mag; now_ort -= ort; // for rotation invariance,邻域点方向减去当前特征点方向,再+-180度 if (now_ort < 0) now_ort += pi2; if (now_ort > pi2) now_ort -= pi2; // bin number in histogram float hist_bin = now_ort * nbin_per_rad; //最终确定属于哪个bin // all three bin idx are float, do trilinear interpolation,累积的 trilinear_interpolate( xbin, ybin, hist_bin, weight, hist); //三线性插值,xbin对应x,ybin对应y,hist_bin对应哪个bin,hist最终可以根据前三个值重新计算梯度的幅度 } } // build descriptor from hist,描述子向量元素门限化及门限化后的描述子向量规范化 Descriptor ret = hist_to_descriptor((float*)hist); ret.coor = p.real_coor; return ret; } /* * 关键点描述子向量的规范化正是可去除满足此模型的光照影响。 对于图像灰度值整体漂移 ,图像各点的梯度是邻域像素相减得到,所以也能去除。 */ feature::Descriptor hist_to_descriptor(float* hist) { feature::Descriptor ret; ret.descriptor.resize(featlen); memcpy(ret.descriptor.data(), hist, featlen * sizeof(float)); // normalize and thresholding and renormalize float sum = 0; for (auto &i : ret.descriptor) sum += sqr(i); sum = sqrt(sum); for (auto &i : ret.descriptor) { i /= sum; //规范化 update_min(i, (float)DESC_NORM_THRESH); //门限化 } sum = 0; for (auto &i : ret.descriptor) sum += sqr(i); sum = sqrt(sum); sum = (float)DESC_INT_FACTOR / sum; for (auto &i : ret.descriptor) i = i * sum; return ret; } void trilinear_interpolate( float xbin, float ybin, float hbin, float weight, float hist[][DESC_HIST_BIN_NUM]) { // WARNING: x,y can be -1 int ybinf = floor(ybin), xbinf = floor(xbin), hbinf = floor(hbin); float ybind = ybin - ybinf, xbind = xbin - xbinf, hbind = hbin - hbinf; //4*4窗口内再按照2*2累积计算直方图 REP(dy, 2) if (between(ybinf + dy, 0, DESC_HIST_WIDTH)) { float w_y = weight * (dy ? ybind : 1 - ybind); REP(dx, 2) if (between(xbinf + dx, 0, DESC_HIST_WIDTH)) { float w_x = w_y * (dx ? xbind : 1 - xbind); int bin_2d_idx = (ybinf + dy) * DESC_HIST_WIDTH + (xbinf + dx); hist[bin_2d_idx][hbinf % DESC_HIST_BIN_NUM] += w_x * (1 - hbind); hist[bin_2d_idx][(hbinf + 1) % DESC_HIST_BIN_NUM] += w_x * hbind; } } }
由于特征描述子表达的是规范化、门限化后的直方图,再此就不画出这128维直方图了,仅用调用栈信息表达代码执行流程。