最近在折腾RGB-D三维重建,跑了几套开源方案都被同一个问题卡住:多帧深度图融合出来的表面总有一层半透明的拖影,靠近看像隔着一层磨砂玻璃。有人翻出一篇老论文丢给我——《Real-Time Visibility-Based Fusion of Depth Maps》,标题里的"Visibility-Based"正好打在痛点上。这篇论文做的是实时深度图融合,核心不是把多帧深度简单平均,而是先判断每一帧深度图里哪些区域"有资格"改写当前模型,再按可见性加权累计,多帧结果干净很多。适合正在做3D重建、SLAM、RGB-D传感器融合或相关工作的人参考,尤其是被动态物体、遮挡边缘和表面重影折磨过的同学,这篇值得精读。
算起来这是最近论文清单里第15篇,读完之后最大的感受是:融合这件事,真正难的不是"怎么把多帧数据叠在一起",而是"每一帧数据到底该信多少"。这篇论文把答案写在了名字里:先解决可见性,再做融合。
1. 深度图融合到底难在哪
1.1 多帧深度图不能直接叠平均
先说我遇到的实际现象。手持深度相机对着同一个桌面扫一圈,每个位置都能看到桌面,但每帧深度值都不一样:有传感器噪声、有量化误差,还有因为视角变化带来的采样不均匀。如果把这些深度图直接转到同一个世界坐标系下求平均,表面不会变得更光滑,反而会变糊。原因很简单,深度相机的噪声不是严格零均值的高斯噪声,尤其在边缘和低纹理区域会出现系统性偏移,平均操作最多只能压制一部分随机抖动,对系统性偏差无能为力。
打个比方,就像你隔着有点变形的玻璃拍同一张海报,拍十张再叠加平均,海报不会变清晰,只会看到十层错位的轮廓。三维重建里的深度融合一样,直接平均得到的不叫表面,叫"一块厚度不可控的雾状云"。
更要命的是位姿误差。实时系统里,相机位姿通常来自跟踪模块,多少会带一点漂移。哪怕只有几毫米的旋转误差,在深度值几十厘米到几米的情况下,投影误差都会被放大。这种误差不是随机噪声,直接平均根本消不掉,反而会让表面一层一层错开,像千层饼一样。
所以融合的本质问题变成:如何在带噪声、带误差的多帧观测里,估计出唯一且一致的表面。这里的关键不是堆数据量,而是要知道每一帧观测的"可信范围"。
1.2 遮挡关系才是融合的隐形天花板
很多入门教程让我以为,融合就是把多帧深度点云拼在一起,拼完再跑一遍曲面重建。但真正做起来会发现,遮挡关系才是绕不过去的坎。
每个深度像素的物理含义很明确:从相机光心出发,穿过这个像素的一条射线,打在最近的物体表面上,深度值就是射线从光心到交点之间的距离。这条射线在到达交点之前穿越的区域,当前帧认为它是空的、没有遮挡物;交点所在的邻域,当前帧认为这里有表面;而交点之后的部分,当前帧看不到,不提供任何正面信息。
这句话看起来像常识,但融合时非常容易被忽略。很多简单融合算法的做法是:只要一帧深度图投影到了某个体素附近,就给它增加权重、更新表面值。结果就是,相机移动后,原本被当前表面挡住的地方,可能还残留着上一帧"曾经看到过"的表面痕迹。几帧之后,模型里就会出现那种半透明的鬼影,尤其是场景里有行人、有移动物体,或者相机快速转动时,痕迹拖得很长。
这种拖影不是简单的噪声问题,而是没有处理"遮挡"这个几何事实。融合算法如果想做好,必须回答一个问题:当前这一帧深度图,凭什么能改写模型某个位置的表面值?答案就是可见性。
1.3 一句话理解论文核心思路
这篇论文的核心思路,用大白话讲就一句:用深度图更新体素网格时,先判断这个体素位于当前观测表面前方还是后方;位于前方自由空间的体素,说明当前帧明确认为那里是空的,就给大权重把旧表面推掉;位于表面后方的体素,说明当前帧被遮挡了,就给很小的权重,不让观测从背面反复破坏真实表面;只有表面附近的体素,才被当成有价值的表面观测认真更新。
这个机制一旦建立,旧表面残留、动态物体痕迹、遮挡边缘鬼影都会被持续削弱,因为当前帧只要看到"这里没有东西",就会自动修正之前错误残留的"这里有东西"。
实时性则来自另一层设计:融合模块不需要做全局优化,每个体素的更新只依赖当前帧深度图、相机位姿、以及体素自身旧值,天然适合并行计算。配合GPU按体素逐点更新,一帧融合下来能控制在实际可用的速度,所以论文标题敢写"Real-Time"。
2. 方法拆解:可见性权重加上有符号距离场
2.1 用体素网格存什么
论文用的表示方式,简单说就是把整个重建空间离散成小格子,也就是体素网格,每个格子内部不直接存"有没有表面",而是存一个有符号距离值,通常叫TSDF(截断符号距离场)。
这个值的物理含义是:当前体素到最近真实表面的距离。正数表示体素在表面前方,也就是自由空间一侧;负数表示体素在表面后方,也就是被表面遮挡的一侧;零附近就是表面穿过的地方。为了避免距离值被拉得过大,论文像绝大多数方案一样做了截断:超过一定范围的数值被压缩到区间里,只关心表面附近一个薄壳内的信息。骨架提取、网格平滑等后续操作都用这个符号距离场来定位表面。
具体更新时,给定一帧深度图和相机位姿,对于世界坐标系里的任意一个体素,可以这样算:
- 把体素的世界坐标通过相机位姿变换到相机坐标系;
- 用相机内参把体素投影到图像像素坐标;
- 读取该像素位置的深度值;
- 计算体素到相机光心的距离和深度测量值之差,得到符号距离。
写成公式就是:
sdf = depth(u, v) - z_cam如果体素在测得的表面前方,这个值是正的;在后方就是负的。再把sdf除以截断距离,限制在[-1, 1]之间,得到的就是这一帧观测对这个体素位置"表面状态"的投票。深度值无效的位置,比如深度相机常见的零值、NaN、超出量程,直接跳过这一帧对该体素的更新。
这里有个新手必踩的坑:坐标系的轴向定义和单位必须一致。有的相机坐标系Z轴朝前,有的朝后,有的深度图单位是毫米,有的是米,还有的内参需要先除以深度值归一化。任何一个地方错了,融合出来的表面就是扭曲甚至完全散开的。
2.2 可见性权重如何工作
最朴素的方式是给每个体素一个固定权重,所有帧一视同仁。但这样会出现前面说的鬼影。这篇论文在权重上做了一个关键动作:权重不再是常数,而是根据体素在当前帧观测中的可见状态决定。
具体来说,论文把体素分成三种情况:
第一种,体素位于当前观测表面前方的自由空间。当前帧明确告诉你,从相机到这个体素之间没有障碍物,所以如果模型里旧权重还在这里积累过表面值,当前帧应该用较大的权重把旧表面"清理"掉。体现在更新公式里,这一帧给该体素的TSDF值是一个接近正方向的值,用大权重参与加权平均,把旧值拉向"空"的方向。
第二种,体素位于表面附近,这里就是当前帧真正观测到表面的区域,属于最有价值的信息,按正常的TSDF更新方式给一个中间权重,让表面位置逐步收敛。
第三种,体素位于当前观测表面的后方,也就是被表面遮挡的区域。当前帧对于这个体素其实没有任何可靠信息,就算你把数据翻遍,也不会知道表面后面到底有什么。所以这一帧给的权重应该压得非常低,尽量不要用它去破坏之前更可信的观测结果。
实现的时候不需要把这三类写得多精确,符号方向比斜率重要。简化之后可以这样:
if sdf >= 0: w = 1.0 else: w = 0.1意思是:当前帧认为体素在表面上或表面前方,就给足话语权;认为体素被挡住了,就几乎不发言。实际项目里,可以在表面附近再叠加一个线性过渡,避免权重突变造成表面抖动,但核心逻辑就是这个方向。
这个设计最直观的效果就是动态物体自动消退。一个人从相机前走过去,在他走过的区域,当前帧看到的是背景墙面,按照朴素融合算法,人的表面值会残留很久。而按可见性权重更新,当前帧看到"墙面这里应该是空的",就会用大权重把那个位置的人体表面值往空方向拉,几帧之后,残留的人影就被清掉了。
2.3 实时性的来源
提到TSDF融合,很多人第一反应是"这东西不是早就有了吗"。但实时实时,论文能在那个年代把速度做上去,靠的不是什么高深的数学,而是工程上的巧妙分工。
第一,融合模块和跟踪模块解耦。融合不负责算相机位姿,位姿由前面的跟踪模块提供,融合只做体素更新。这样就不需要把几百帧的深度数据放在一个全局优化问题里反复迭代,而是来一帧处理一帧,做成流式更新。
第二,每个体素的更新相互独立。同一帧里,一个体素的新值只由它的旧值、当前深度图和位姿决定,不依赖其他体素的结果。这种结构放到GPU上就是天然的并行任务,一个线程管一个或几个体素,几百万个体素可以同时算。
第三,权重有上限,不会无限增长。如果权重一直累加,新观测就很难覆盖旧观测,动态物体清不掉,系统对场景变化也反应迟钝。所以论文和后来工程实践都会给权重设一个封顶值,观测信息达到一定程度后,新的观测依然能产生影响,这就是所谓"遗忘旧信息"的工程实现。
我实际测过的经验是,在GPU实现下,体素数量在200的三次方级别时,每帧融合时间可以控制得很稳。瓶颈一般都出现在体素遍历和显存带宽上,而不是计算本身。只要你不是在CPU上写三层大循环硬算,实时性是有保障的。
3. 实操复现:从伪代码到参数调优
3.1 最小可运行骨架
先给一个最直观的伪代码,方便理解整个流程。这个版本仅用于学习,别直接拿去生产,因为全空间三层循环在CPU上会很慢,但逻辑完整清晰。
# depth: 单帧深度图, HxW, float, 单位与三维坐标一致 # T_cw: 4x4,世界坐标系到相机坐标系的变换 # grid_origin, voxel_size: 体素网格原点与体素边长 # V: 每个维度体素数量 # tsdf, weight: 两个形状为(V,V,V)的数组,初始为0 T_inv = inv(T_cw) for x in range(V): for y in range(V): for z in range(V): p_world = grid_origin + voxel_size * (x, y, z) p_cam = T_inv @ p_world if p_cam[2] <= near_plane or p_cam[2] > far_plane: continue u, v = project(p_cam, K) # 根据相机内参投影 if u < 0 or u >= width or v < 0 or v >= height: continue d = depth[v, u] if d <= 0: continue z_cam = p_cam[2] sdf = d - z_cam if sdf < -trunc_dist: continue tsdf_val = clamp(sdf / trunc_dist, -1.0, 1.0) w = 1.0 if sdf >= 0 else 0.1 old_w = weight[x, y, z] old_t = tsdf[x, y, z] new_w = old_w + w tsdf[x, y, z] = (old_t * old_w + tsdf_val * w) / new_w weight[x, y, z] = min(max_weight, new_w)几个容易错的地方:
一是p_cam[2]必须在投影前就检查正负。相机坐标里正常情况下Z应该大于零,小于等于零的点在相机后方,不可能被当前帧观测到,跳过即可。
二是深度图的无效值处理。不同传感器表示无效深度的方式完全不一样,有些是0,有些是65535,有些是NaN。一定要在融合前统一处理,否则无效值会参与计算,产生大量飞点。
三是体素网格的边界。如果体素落到视锥体外面,就直接跳过,不要硬算。真实数据里大量体素在相机视野外,提前判断能省不少计算量。
3.2 关键参数怎么调
用表格快速过一遍参数:
| 参数 | 常见区间 | 影响 |
|---|---|---|
| 体素大小 | 1mm~10mm | 体素越小细节越多,内存和计算耗时按三次方增长 |
| 截断距离 | 3~5倍体素大小 | 太小表面容易空洞,太大表面变厚、细节丢失 |
| 权重上限 | 10~50 | 太小噪声压不住,太大动态物体清不掉 |
| 深度有效范围 | 0.3m~5m | 太近太远传感器噪声都很糟糕,限制范围能减少错误更新 |
截断距离这个参数最容易被忽略。如果设成体素大小的一倍,表面上稍微有一点噪声,符号距离就会反复横跳,最终表面破洞百出。如果设成几十倍,表面会肿成厚厚一层,细节全丢。我用下来比较稳的是4倍左右。
权重上限也很关键。很多人做可见性融合时只记得给自由空间大权重,却忘了给全局权重封顶。结果一段时间后旧观测的累计权重变得很大,新的观测哪怕权重再大,几帧之内也拉不回来,动态物体痕迹怎么都清不掉。我的习惯是把自由空间单帧权重设成1或2,权重上限设在20到30之间,这样既能让多帧平均去噪,又保留了对场景变化的响应速度。
另外,如果传感器噪声随着距离明显增大,可以在权重里加一个随距离衰减的系数。这个不是论文的核心,但对真实传感器很有帮助。近处物体给高一点权重,远处物体给低一点,融合出来的表面会更均匀。
3.3 自测顺序:先合成数据,再上真实传感器
复现这类算法,我强烈建议不要一上来就拿真实传感器跑,因为位姿噪声和深度噪声混在一起,出了问题根本分不清是融合算法错了,还是前端跟踪错了。
第一步,用合成数据做全流程验证。用渲染器生成一个桌面场景,桌面最好有纹理但不要太复杂,把相机位姿导出来,渲染对应深度图。因为位姿是真实已知的,融合阶段不存在跟踪误差,你可以专心验证算法本身。如果合成数据都出现拖影、空洞、飞点,那一定是融合这块的逻辑有问题,回头查坐标变换、TSDF符号、权重函数。
第二步,用固定相机扫一个静止场景。比如把相机放在支架上,缓慢平移,扫一个小摆件。这个阶段开始引入真实深度噪声,但位姿仍然可以通过静态场景配准做得很准。重点看物体边缘是不是干净,表面厚度是否合理。
第三步,做动态场景测试。在场景里放一个来回移动的小物体,比如纸盒,来回走动几秒。融合结果里,移动物体的轨迹应该很快被清除,背景墙面保持干净。这一步是最能体现可见性权重价值的测试,普通平均的做法在这里会原形毕露。
我自己的习惯是,每到这一步就把可见性权重函数临时改成常量值跑一遍,和带可见性的版本对比,很容易看清方法到底解决了什么问题。
4. 常见问题定位与解决
4.1 表面总是有拖影
这是复现可见性融合时最常见的现象。可以先按下面表格排查:
| 现象 | 大概率原因 | 解决方向 |
|---|---|---|
| 固定物体表面有一层错位重影 | 位姿误差或跟踪漂移 | 先修前端,别怪融合算法 |
| 动态物体周围有明显残影 | 自由空间权重不足 | 提高sdf为正时的权重 |
| 旧痕迹怎么都清不掉 | 权重累计上限太高 | 降低max_weight |
| 拖影只在边缘出现 | 截断距离太小 | 增大trunc_dist |
拖影问题里有个容易被忽略的细节:如果sdf大于截断距离,也就是体素离表面比较远的前方空间,它依然应该是自由空间,仍然需要给可见性权重。有的实现为了省事,只更新靠近表面的区域,远处的自由空间直接跳过,这样旧表面的"清理"信号传不过去,拖影自然会残留。
4.2 重建结果里有洞或者飞点
空洞最直接的原因是深度图上对应位置没有有效深度值。透明物体、黑色物体、强反光表面、超出量程,都会让深度传感器返回无效值。这类空洞通常很难完全靠融合算法补上,换更好的深度图才是正路。
飞点则经常和背面权重有关。表面后方如果给了太高权重,当前帧偶尔在物体边缘出现的深度跳变就会被累计进去,形成悬浮在物体背后的孤立点。解决办法是把sdf为负的权重压到更低,甚至可以在sdf小于负截断距离的时候直接跳过这个体素的更新。
还有一种情况是体素分辨率太低,表面薄壳没有足够的体素来表达,检出的表面就会断断续续。这不是bug,是参数问题,适当提高分辨率或者增大截断距离能缓解。
4.3 速度上不去时优先优化什么
先别急着上多线程,第一步是缩小需要更新的体素范围。完整遍历一个512的三次方体素网格,绝大多数体素根本不在当前相机视锥体内,纯属浪费。一个简单的做法是,根据当前深度图的视锥体,计算一个轴对齐包围盒,只更新这个包围盒内的体素。我实测在手持相机场景下,这一条通常就能省掉一半以上的遍历量。
第二步是考虑GPU并行。TSDF更新是极度适合并行的计算,每个体素独立,没有线程间的数据依赖。GPU实现里要注意的是,尽量不要在更新TSDF时做原子操作,因为每个体素只对应一个线程,普通写就行。访问深度图时用纹理路径而不是普通数组访问,可以利用缓存提升效率。
第三步才是优化计算细节。比如把坐标变换里的公共项提前算好,不要每个体素都重算矩阵乘。体素网格的起始坐标和递加步长都是常量,可以先把世界坐标转成相机坐标的增量算好,遍历时只做加法。
5. 从论文到工程:一点延伸与个人看法
5.1 这套可见性思路影响了什么
后来我在看各种三维重建方案时,经常能见到这篇论文的影子。可见性权重这个概念,本质上奠定了"融合时必须考虑遮挡关系"的共识。后面很多做动态重建、动态物体剔除、语义建图的工作,在处理多帧观测冲突时,基本都沿用了"光线穿过的自由空间拥有更高话语权"这一原则。
TSDF加上可见性权重,也慢慢变成了RGB-D实时重建的事实标准流程。表面提取时用marching cubes,实时可视化时用光线投射,这些后续步骤都是建立在融合结果干净的前提上。如果融合那一步做得脏,后面做什么都白搭,因为网格提取和纹理映射都会把脏数据放大。
5.2 我踩坑之后最想分享的几点体会
第一,读这类论文,一定要动手做一次"去掉核心模块"的对比实验。不要只看效果图表觉得很厉害,要把可见性权重改成朴素平均,跑同一段数据,你才会真正理解这个模块的价值。我每次给新人讲这个算法,都先让他跑一遍朴素版本,再看可见性版本,很多疑问不用解释就明白了。
第二,权重符号比权重具体数值更重要。很多人纠结自由空间权重到底设1还是2,表面后方到底设0.1还是0.5,其实这些数值只要量级不出格,对结果影响有限。真正关键的是右边的正负方向:是不是真的用大权重清理了自由空间,是不是真的用小权重保护了被遮挡区域。方向错了,数值调得再精细也是白费。
第三,也是我个人的核心感受:融合算法只能做到"不恶化信息",真正决定重建质量上限的永远是深度图本身和位姿精度。做这类系统,花时间把传感器标定搞准确、把无效深度处理好,往往比调融合参数收益更大。如果回到最初那个磨砂玻璃的问题,现在我会先检查两个地方:一是深度图本身有没有系统偏差,二是融合权重里有没有把"当前帧认为空"的区域给足话语权。这两处都弄干净了,拖影基本就消了,跟算法模型有多花哨关系不大。