计算机视觉基础探讨(1):金字塔思想
前言
金字塔是一种常见的计算机视觉处理思想。它将尺度信息融入进来,使得计算机视觉更加符合人眼的认知规律。最典型的是由不同尺度图像构成的“图像金字塔”:
一般来说,金字塔可以有效的缓解“算法陷入局部最优”的问题。因为金字塔顶部是高度浓缩的,代表了整体的信息,而底端则较为细节。如果算法能够在顶端取得一个较好的初值,层层迭代到底端,就可以引导算法找到较好的收敛点。例如,在SLAM中,当原始图像的像素运动较大时,在金字塔顶端看来,运动仍然处于一个较少的像素范围内。
——————————
一般而言,要使用金字塔思想来解决一个由粗到精的迭代问题,大致可以分为如下几步:
(1)
构建足够数量的图像金字塔
,可能是1个,也可能是2个;
(2)
初始化一个最粗的特征
,注意,只需要最粗的就行,不需要对特征构建金字塔;
(3)
构建关于level的for循环,由粗到精的调用单层函数
。
提示:以下是本篇文章正文内容,下面案例可供参考
一、案例:多层光流法追踪点
本文选用第一个案例是多层光流法。在多层光流的时候,由粗到精调用单层光流计算函数,该函数的接口如下:
void OpticalFlowSingleLayer(
const cv::Mat &img1,
const cv::Mat &img2,
const vector<cv::KeyPoint> &kp1,
vector<cv::KeyPoint> &kp2,
vector<bool> &success,
bool inverse,
bool has_initial,
);
Opencv的函数都有一个特点,很少有返回值的,计算的东西需要先声明,然后引用传递进函数,这样的话就拿到了。我们在阅读文档的时候,常常见到InputArray,OutputArray,就是给我们的返回值留位置。我们在写函数的时候,也要遵循这样的写法,避免类型上的错误。该函数的具体实现可以参考光流法的内容,前面三个const修饰的变量分别是两张图像,以及从第一张图像中随机选出的点,我们要在图像2中寻找到这些点的位置,因此第四个变量是输出变量。该函数的has_initial就支持初始值:其代码如下:
double dx = 0,dy = 0;//像素位置更新量
for(size_t i=range.start;i<range.end;i++){
//这里是并行化遍历每个关键点
if(has_initial){
dx = kp2[i].pt.x - kp1[i].pt.x;
dy = kp2[i].pt.y - kp1[i].pt.y;
}
}
当然,这里的初始值也并非是绝对的,毕竟kp2是不断被更新的。在后一个案例——直接法位姿估计中,就不需要在函数中显示的给出has_initial参数,因为像素误差是好初始化的,而位姿T是不好被初始化的,往往采用其他方式进行传递。
1. 构建图像金字塔
如何构建图像金字塔?只要按照比例进行缩放就可以了:
vector<cv::Mat>img1_pyr,img2_pyr;
int layers = 4;
double pyr_scale = 0.5;
double scales[] = {1.0,0.5,0.25,0.125};
//第0层是最详细的,这一点要说明,也给自己一个提示
//这样,i++的时候,下一层始终是上一层的0.5倍,这是最近重复性
for(int i=0;i<layers;i++){
if(i==0){
img1_pyr.push_back(img1);
img2_pyr.push_back(img2);
}else{
cv::Mat img1_cur,img2_cur;
cv::resize(img1_pyr[i-1],img1_cur,
cv::Size(img1_pyr[i-1].cols*pyr_scale,img1_pyr[i-1].cols*pyr_scale));
cv::resize(img2_pyr[i-1],img1_cur,
cv::Size(img2_pyr[i-1].cols*pyr_scale,img2_pyr[i-1].cols*pyr_scale));
img1_pyr.push_back(img1_cur);
img2_pyr.push_back(img2_cur);
}
}
这里创建了两个图像金字塔,主要是为了接下来的实例使用,因为实例演示的是光流法,基本原料是两张图像。可以看出,使用cv::resize函数+中间变量的形式,可以有效创建。resize函数的第三个参数,是Size类型的对象,直接进源码查阅这个类的构造函数,顺着一大堆的typedef之后,发现最原始的那个参数列表为width和height(
我们常常在阅读文档的时候不知道函数参数的用法,那么不妨找到这个类的构造函数吧
),而width就是上一层的cols属性乘以scale,height就是上一层的rows属性乘以 scale。不必担心int还是double类型,因为源码中已经用typename来标识泛型了。
——————————
从这里可以看出,图像金字塔其实就是不同分辨率图像构成的“序列”。只不过不同的语言表达“序列”的语法不一样,C++中使用的是STL标准模板库最好,而Python中则有现成列表。
2. 初始化一个最粗的特征
我们力求通过最模糊一层的特征进行初始化,由于两张相机的位姿变换不大,因此可以以第一张图像上的像素位置来作为第二张图像上的同名像素位置(这也是金字塔的优点)
vector<cv::KeyPoint> kp1_pyr,kp2_pyr;
for(auto &kp:kp1){
kp_top = kp;
kp1_pyr.push_back(kp_top);
kp2_pyr.push_back(kp_top);
}
这里将两个顶点给加上,后续准备采用逐个KeyPoint更新像素坐标。注意,kp1,2_pyr并不是特征点金字塔,因为那样的话将会是vector<vector<cv::KeyPoint>>类型的才可以,非常的麻烦,而且实际情况是,
我们不知道kp2会被优化成什么样子,因此为什么要建完整的金字塔呢
?
3. 由粗到精的调用这个函数
在调用之前,我们要想清楚这么几件事:
(1)循环的level怎么设置才能体现“由粗到精”?
(2)如何保持kp2_pyr和kp1_pyr的更新?
对于前一个问题,只要顾及金字塔层级即可,对于后一个问题,主要是逐个像素更新xy。
代码如下(示例):
for(int level=layers-1;level>=0;level--){
success.claer();
OpticalFlowSingleLayer(img1_pyr[level],img2_pyr[level],kp1_pyr,kp2_pyr,inverse,true);
//update the kp
if(level>0){
for(auto &kp:kp1_pyr)kp.pt /= scale;
for(auto &kp:kp2_pyr)kp.pt /= scale;
}
//when the level is 0, kp will keep
}
for(auto &kp:kp2_pyr) kp2.push_back(kp);
二、案例:多层光流法直接估计位姿
前面一个案例使用较为直接的初始值(像素值的偏移)。由粗到精的核心是实现初始值的传递,那么在一个较为晦涩的优化问题当中,如何实现初始值的传递呢?
仔细观察第一个案例,也许opencv各类函数普遍采用引用传递的好处就可以体现出来了,即便是没有has_initial,
由于上一次执行的函数已经修改了外围的变量kp2_pyr,而每一次执行该函数都会更新kp2_pyr,而函数中又是一个迭代优化算法,就会渐进的更新这个变量
。
——————————
使用多层光流法,需要在每次调用之前,将相机内参进行修改。光流法的计算过程不属于本帖的内容,但是为了方便读者理解,笔者绘制了一张图理解其调用关系:
光流需要的是两张图像,第一张图像的像素坐标,由main函数随机选取。T_cur_ref是待估计变量,表示从参考图像到当前图像的位姿转换关系。多层光流循环调用单层光流函数,为了对nPoints个点并行计算,采用了另外构造类的方法(在我的另一篇博客:opencv中并行计算的简单讨论中有详细的描述)。相机的内参是全局需要使用的,虽然不在参数列表里,但是随时需要取用,因此相机内参也需要根据尺度进行修改更新:
double fxG=fx,fyG=fy,cxG=cx,cyG=cy;
for(int level=layers-1;level>=0;level--){
VecVector2d px_ref_pyr;
for(auto &px:px_ref){
px_ref_pyr.push_back(px*scales[level]);
}
fx = fxG * scales[level];
fy = fyG * scales[level];
cx = cxG * scales[level];
cy = cyG * scales[level];
DirectPoseEstimationSingleLayer(img1_pyr[level],img2_pyr[level],px_ref_pyr,depth_ref,T_cur_ref);
}
事实上,在设计那些变量需要改动的时候,
根据参数列表来调整是比较好的
。例如在这里,写的顺序首先应该是for循环,以及单层光流调用函数。先不用去考虑前面的变化。首先看函数实体,两个图像肯定选取金字塔的对应层,然后发现参考点肯定得是当前的尺度,深度信息不需要修改,最后的位姿估计量保持更新状态也不需要修改。而每次调用单层光流之前,都要在尺度上把相机内参改掉。
总结
使用金字塔的方法步骤如下:
(1)构建足够数量的图像金字塔;
(2)for循环构建由粗到精的单层调用,
利用引用传递将问题简化为“线性的串联关系”而不是复杂的迭代关系;
(3)根据参数列表,将相应的参数在for循环里缩放到对应尺度,注意适配。
本帖会不断更新,加入更多使用金字塔思想的功能代码与理解。