下载完整源码,点击进入: https://github.com/ethan-li-coding/PatchMatchStereo.git
欢迎同学们在Github项目里讨论,如果觉得博主代码质量不错,给颗小星星,Follow 我!感激不尽!
算法效果图镇楼:
|
|
|
|
上一篇博客代价计算中,我们讲解了PatchMatchStereo(PMS)的代价计算器,有了代价计算器,我们将在迭代传播步骤中频繁调用它计算某个像素的聚合代价。本篇,博主要介绍的是PMS最关键的核心:迭代传播。迭代传播有多重要呢?相比读过前面几篇的同学一定提前认识了未经迭代传播,也就是只做随机初始化的结果:
|
|
|
|
迭代传播可以让这个完全看不懂的麻点图变成无限接近于文章开头展示的结果。
来来来,代码拿来!
我将迭代传播写到两个独立的文件里:pms_propagation.h和pms_propagation.cpp,这样结构会比较清晰,两个文件只实现了一个类:PMSPropagation,它将完成迭代传播的所有步骤,从前文可以得知步骤有三:(1)空间传播(2)视图传播(3)平面优化。
我们先看看类PMSPropagation的唯一公有函数:
public:
/** \brief 执行传播一次 */
void DoPropagation();
它是PMS主类调用的唯一接口,调用它一次就可以完成一次迭代,调用多次就完成多次迭代,PMS的原文中是推荐迭代3次。
DoPropagation方法里面的实现,大家或许能猜到是依次执行上面3个步骤,这3个步骤我放到私有函数里:
/**
* \brief 空间传播
* \param x 像素x坐标
* \param y 像素y坐标
* \param direction 传播方向
*/
void SpatialPropagation(const sint32& x, const sint32& y, const sint32& direction) const;
/**
* \brief 视图传播
* \param x 像素x坐标
* \param y 像素y坐标
*/
void ViewPropagation(const sint32& x, const sint32& y) const;
/**
* \brief 平面优化
* \param x 像素x坐标
* \param y 像素y坐标
*/
void PlaneRefine(const sint32& x, const sint32& y) const;
看注释大家应该没有太多疑问,唯一需要多说一句的是传播是对于每个像素做的,也就是每个像素都要依次做完3个步骤才轮到下一个像素,这里博主也走了一点弯路,我开始是全图做完空间传播,再全图做视图传播,跑出来的结果打了我face。
我们来看具体实现。
首先,我们看看开放的唯一接口DoPropagation都干了些什么呢?
void PMSPropagation::DoPropagation()
{
if(!cost_cpt_left_|| !cost_cpt_right_ || !img_left_||!img_right_||!grad_left_||!grad_right_ ||!cost_left_||!plane_left_||!plane_right_||!disparity_map_||
!rand_disp_||!rand_norm_) {
return;
}
// 偶数次迭代从左上到右下传播
// 奇数次迭代从右下到左上传播
const sint32 dir = (num_iter_%2==0) ? 1 : -1;
sint32 y = (dir == 1) ? 0 : height_ - 1;
for (sint32 i = 0; i < height_; i++) {
sint32 x = (dir == 1) ? 0 : width_ - 1;
for (sint32 j = 0; j < width_; j++) {
// 空间传播
SpatialPropagation(x, y, dir);
// 平面优化
if (!option_.is_fource_fpw) {
PlaneRefine(x, y);
}
// 视图传播
ViewPropagation(x, y);
x += dir;
}
y += dir;
}
++num_iter_;
}
开始的指针检查咱们就不说了,从后面开始看,遍历每个像素进行迭代传播没什么多说的,这里我要重点讲解的有两点:
进入每个子步骤的实现吧。
首先,来看空间传播SpatialPropagation。看看代码:
void PMSPropagation::SpatialPropagation(const sint32& x, const sint32& y, const sint32& direction) const
{
// ---
// 空间传播
// 偶数次迭代从左上到右下传播
// 奇数次迭代从右下到左上传播
const sint32 dir = direction;
// 获取p当前的视差平面并计算代价
auto& plane_p = plane_left_[y * width_ + x];
auto& cost_p = cost_left_[y * width_ + x];
auto* cost_cpt = dynamic_cast<CostComputerPMS*>(cost_cpt_left_);
// 获取p左(右)侧像素的视差平面,计算将平面分配给p时的代价,取较小值
const sint32 xd = x - dir;
if (xd >= 0 && xd < width_) {
auto& plane = plane_left_[y * width_ + xd];
if (plane != plane_p) {
const auto cost = cost_cpt->ComputeA(x, y, plane);
if (cost < cost_p) {
plane_p = plane;
cost_p = cost;
}
}
}
// 获取p上(下)侧像素的视差平面,计算将平面分配给p时的代价,取较小值
const sint32 yd = y - dir;
if (yd >= 0 && yd < height_) {
auto& plane = plane_left_[yd * width_ + x];
if (plane != plane_p) {
const auto cost = cost_cpt->ComputeA(x, y, plane);
if (cost < cost_p) {
plane_p = plane;
cost_p = cost;
}
}
}
}
总的来说,函数体里有两步:
第一步,将左边(如果是反向,则为右边)像素的视差平面赋给当前像素,计算新的代价,判断新的代价是否比当前代价小,如果更小,则接受该视差平面为新的视差平面,代价也会更新。
第二步,将上边(如果是反向,则为下边)像素的视差平面赋给当前像素,计算新的代价,判断新的代价是否比当前代价小,如果更小,则接受该视差平面为新的视差平面,代价也会更新。
逻辑是比较清晰的,总共也没多少行代码,理解起来不难。
我们来看看只做一次空间传播,结果如何:(大家可以屏蔽掉其他两个步骤的代码来实验)
|
|
|
|
结果并不太好。如果我选择前端平行窗口模型呢?(把option的is_fource_fpw设置为true,即为Frontal-Parallel Window)
|
|
|
|
看起来好多了,这是为什么呢?同学们知道原因吗?我们可以就用Frontal-Parallel Window吗?大家可以在留言区讨论下。我后面会给答案大家。
平面优化显得要复杂一些,但是理解起来也不难。先看代码:
void PMSPropagation::PlaneRefine(const sint32& x, const sint32& y) const
{
// --
// 平面优化
const auto max_disp = static_cast<float32>(option_.max_disparity);
const auto min_disp = static_cast<float32>(option_.min_disparity);
// 随机数生成器
std::random_device rd;
std::mt19937 gen(rd());
const auto& rand_d = *rand_disp_;
const auto& rand_n= *rand_norm_;
// 像素p的平面、代价、视差、法线
auto& plane_p = plane_left_[y * width_ + x];
auto& cost_p = cost_left_[y * width_ + x];
auto* cost_cpt = dynamic_cast<CostComputerPMS*>(cost_cpt_left_);
float32 d_p = plane_p.to_disparity(x, y);
PVector3f norm_p = plane_p.to_normal();
float32 disp_update = (max_disp - min_disp) / 2.0f;
float32 norm_update = 1.0f;
const float32 stop_thres = 0.1f;
// 迭代优化
while (disp_update > stop_thres) {
// 在 -disp_update ~ disp_update 范围内随机一个视差增量
float32 disp_rd = rand_d(gen) * disp_update;
if (option_.is_integer_disp) {
disp_rd = static_cast<float32>(round(disp_rd));
}
// 计算像素p新的视差
const float32 d_p_new = d_p + disp_rd;
if (d_p_new < min_disp || d_p_new > max_disp) {
disp_update /= 2;
norm_update /= 2;
continue;
}
// 在 -norm_update ~ norm_update 范围内随机三个值作为法线增量的三个分量
PVector3f norm_rd;
if (!option_.is_fource_fpw) {
norm_rd.x = rand_n(gen) * norm_update;
norm_rd.y = rand_n(gen) * norm_update;
float32 z = rand_n(gen) * norm_update;
while (z == 0.0f) {
z = rand_n(gen) * norm_update;
}
norm_rd.z = z;
}
else {
norm_rd.x = 0.0f; norm_rd.y = 0.0f; norm_rd.z = 0.0f;
}
// 计算像素p新的法线
auto norm_p_new = norm_p + norm_rd;
norm_p_new.normalize();
// 计算新的视差平面
auto plane_new = DisparityPlane(x, y, norm_p_new, d_p_new);
// 比较Cost
if (plane_new != plane_p) {
const float32 cost = cost_cpt->ComputeA(x, y, plane_new);
if (cost < cost_p) {
plane_p = plane_new;
cost_p = cost;
d_p = d_p_new;
norm_p = norm_p_new;
}
}
disp_update /= 2.0f;
norm_update /= 2.0f;
}
}
一开始,我获取了两个随机数生成器,一个用来生成视差的随机值,一个用来生成法线的随机值(随机数生成器的初始化是放在PMSPropagation类的构造函数里的)。并获取了像素 p p p的平面、代价、视差和法线以做后用。
平面传播的原理,有必要再介绍下:
PMS将 f p f_p fp转换为点加法向量的表达方式,并设置两个参数: Δ z 0 m a x Δ_{z_0}^{max} Δz0max和 Δ n m a x Δ_{n}^{max} Δnmax。 Δ z 0 m a x Δ_{z_0}^{max} Δz0max为点 P ( x 0 , y 0 , z 0 ) P(x_0,y_0,z_0) P(x0,y0,z0)的z-坐标的可变化范围, Δ n m a x Δ_{n}^{max} Δnmax为法向量 n ⃗ \vec{n} n各分量的可变化范围。在 [ − Δ z 0 m a x , Δ z 0 m a x ] [-Δ_{z_0}^{max},Δ_{z_0}^{max}] [−Δz0max,Δz0max]范围内随机一个值 Δ z 0 Δ_{z_0} Δz0加到 z 0 z_0 z0上得 z 0 ′ = z 0 + Δ z 0 z_0'=z_0+Δ_{z_0} z0′=z0+Δz0,由此得到新的点 P ′ ( x 0 , y 0 , z 0 ′ ) P'(x_0,y_0,z_0') P′(x0,y0,z0′);随后,在 [ − Δ n m a x , Δ n m a x ] [-Δ_{n}^{max},Δ_{n}^{max}] [−Δnmax,Δnmax]范围内随机3个值组成向量 Δ n ⃗ \vec{Δ_{n}} Δn,计算新的法向量 n ′ ⃗ = u ( n ⃗ + Δ n ⃗ ) \vec{n'}=u(\vec{n}+\vec{Δ_{n}}) n′=u(n+Δn), u u u为取单位向量。新的点 P ′ P' P′和法向量 n ′ ⃗ \vec{n'} n′组成新的平面 f p ′ f_{p'} fp′,若 m ( p , f p ′ ) < m ( p , f p ) m(p,f_{p'})
m(p,fp′)<m(p,fp) ,则把平面 f p ′ f_{p'} fp′作为像素 p p p的新平面。
平面优化步骤也是迭代进行的,初始设置 Δ z 0 m a x = m a x d i s p / 2 Δ_{z_0}^{max}=maxdisp/2 Δz0max=maxdisp/2( m a x d i s p maxdisp maxdisp为设置的最大视差值)、 Δ n m a x = 1 Δ_{n}^{max}=1 Δnmax=1。每次迭代后,设置 Δ z 0 m a x = Δ z 0 m a x / 2 Δ_{z_0}^{max}=Δ_{z_0}^{max}/2 Δz0max=Δz0max/2、 Δ n m a x = Δ n m a x / 2 Δ_{n}^{max}=Δ_{n}^{max}/2 Δnmax=Δnmax/2,由此来逐渐缩小搜索空间。迭代终止条件为 Δ z 0 m a x = 0.1 Δ_{z_0}^{max}=0.1 Δz0max=0.1。
从原理我们可以得知,平面优化是一个迭代过程。对于视差,先指定一个范围 ( − Δ z 0 , Δ z 0 ) (-Δ_{z_0},Δ_{z_0}) (−Δz0,Δz0),在这个范围内随机一个值加到原视差上;对于法线,每个分量都在指定范围 ( − Δ n , Δ n ) (-Δ_{n},Δ_{n}) (−Δn,Δn)内随机一个值加到原来的分量上,最后归一化到单位向量。新得到的视差值和法线组成新的视差平面,计算新的代价,如果新代价小于当前代价,则把新代价赋给 p p p。范围一开始很大,迭代一次就缩小一倍,直到范围小于一定阈值。类似于一个二分查找定位,这段弄明白后,再去看代码,我想不难理解了。
我们来看看空间传播+平面优化一次迭代的结果,同样一开始测试原始模式,也就是Slanted Window:
|
|
|
|
可以说产生了质的飞跃。可见平面优化对结果有决定性的影响(可偏偏它最慢!)。
|
|
|
|
首先,来看视图传播ViewPropagation。看看代码:
void PMSPropagation::ViewPropagation(const sint32& x, const sint32& y) const
{
// --
// 视图传播
// 搜索p在右视图的同名点q,更新q的平面
// 左视图匹配点p的位置及其视差平面
const sint32 p = y * width_ + x;
const auto& plane_p = plane_left_[p];
auto* cost_cpt = dynamic_cast<CostComputerPMS*>(cost_cpt_right_);
const float32 d_p = plane_p.to_disparity(x, y);
// 计算右视图列号
const sint32 xr = lround(x - d_p);
if (xr < 0 || xr >= width_) {
return;
}
const sint32 q = y * width_ + xr;
auto& plane_q = plane_right_[q];
auto& cost_q = cost_right_[q];
// 将左视图的视差平面转换到右视图
const auto plane_p2q = plane_p.to_another_view(x, y);
const float32 d_q = plane_p2q.to_disparity(xr,y);
const auto cost = cost_cpt->ComputeA(xr, y, plane_p2q);
if (cost < cost_q) {
plane_q = plane_p2q;
cost_q = cost;
}
}
这里我们需要解释一下,原文中是说的把右视图中以左视图像素 p p p为同名点的像素 q q q的视差平面传播过来,按照原文我们该怎么做呢?右视图以左视图像素 p p p为同名点的像素不能假设只有一个吧,实际上很可能是多个,所以要遍历同一行内的所有右视图像素,计算它的同名点是不是像素 p p p,想想都很费时,时间复杂度是 O ( w ∗ h ∗ w ) O(w*h*w) O(w∗h∗w) 。
所以我们转换下思路,把左视图的视差平面传播到右视图去,对左视图像素 p p p,找到其在右视图的同名点 q q q,把p的视差平面传播给 q q q,这样只用遍历左视图像素一遍,时间复杂度为 O ( w ∗ h ) O(w*h) O(w∗h),效率就高多了。
也进一步解释了上面为什么要把平面优化放到视图传播前面,是为了让传播给右视图的视差平面尽可能好。
再看看代码,代码里一开始通过左视图的视差平面和像素坐标,计算出视差 d d d,再通过 d d d计算右视图上的同名点。再将左视图的视差平面转换到右视图,转换代码我这里讲解一下,大家可以跟踪进去看看转换部分的代码:
/**
* \brief 将视差平面转换到另一视图
* 假设左视图平面方程为 d = a_p*xl + b_p*yl + c_p
* 左右视图满足:(1) xr = xl - d_p; (2) yr = yl; (3) 视差符号相反(本代码左视差为正值,右视差为负值)
* 代入左视图视差平面方程就可得到右视图坐标系下的平面方程: d = -a_p*xr - b_p*yr - (c_p+a_p*d_p)
* 右至左同理
* \param x 像素x坐标
* \param y 像素y坐标
* \return 转换后的平面
*/
DisparityPlane to_another_view(const sint32& x, const sint32& y) const
{
const float32 d = to_disparity(x, y);
return { -p.x, -p.y, -p.z - p.x * d };
}
前面我说过,为了让左右视图公用一个传播类,所以我让左视图和右视图的视差互为相反数,实际上是左视图视差为正值,右视图视差为负值。假设左视图像素 p ( x l , y l ) p(x_l,y_l) p(xl,yl)的视差平面方程为:
d = a p x l + b p y l + c p d = a_px_l + b_py_l + c_p d=apxl+bpyl+cp
若像素 p p p的视差为 d p d_p dp,对应的右视图同名点坐标为 q ( x r , y r ) q(x_r,y_r) q(xr,yr)。则 p p p和 q q q满足以下3个条件:
将这3个条件代入上面的视差平面方程,就得到右视图坐标系的视差平面方程:
d = − a p x r − b p y r − ( c p + a p d p ) d = -a_px_r - b_py_r - (c_p+a_pd_p) d=−apxr−bpyr−(cp+apdp)
平面的三个参数为 ( − a p , − b p , − c p − a p d p ) (-a_p,-b_p,-c_p-a_pd_p) (−ap,−bp,−cp−apdp)。就是转换函数里的代码。
有了以上的讲解,再去看视图传播的全部代码就容易了,就是把 p p p的视差平面转换坐标系后赋给 q q q,计算新代价,如果新代价小于 q q q的当前代价,就把该视差平面赋给 q q q,并更新 q q q的代价值。
最后看下空间传播+平面优化+视图传播3步全做完后的效果:
|
|
|
|
细节处效果进一步得到优化。
以上做的实验是迭代1次的,视差图看上去已经还不错了,当然边缘等细节处还有优化的空间,我们迭代3次看看效果:
|
|
|
|
这效果就很到位了,边缘都很清晰,一些细节处也很好,当然难免会有错误匹配,对于错误匹配,一致性检查和视差填充是常规手段,也是下一篇我们的内容。
好了,本篇就到这吧,篇幅很长,但内容关键,建议大家对着完整代码一起看!
同学们拜拜!
下载完整源码,点击进入: https://github.com/ethan-li-coding/PatchMatchStereo.git
欢迎同学们在Github项目里讨论,如果觉得博主代码质量不错,给颗小星星,Follow 我!感激不尽!
博主简介:
Ethan Li 李迎松(知乎:李迎松)
武汉大学 摄影测量与遥感专业博士
主方向立体匹配、三维重建
2019年获测绘科技进步一等奖(省部级)
爱三维,爱分享,爱开源
GitHub: https://github.com/ethan-li-coding
邮箱:[email protected]
个人微信:
欢迎交流!
关注博主不迷路,感谢!
博客主页:https://blog.csdn.net/rs_lys