1. 三维重建配准思路的关键转折:从管线堆叠到端到端点图回归
三维重建这个方向,过去十年里我打交道最多的流程是:拍一圈照片,跑特征提取、匹配、RANSAC、增量式运动恢复结构,再进多视图立体做稠密化,最后网格化。这一套下来,纹理丰富、光照稳定的场景确实能出很漂亮的成果。但只要场景稍微不给面子——白墙、玻璃、金属反光、重复纹理的地砖,或者拍摄时视角跨度拉得太大——中间任何一个环节断掉,整条流程就得从头调参。更别提那些根本没有标定信息的图像,很多管线连输入都过不去。
DUSt3R 这类工作吸引我的地方就在这:它把"两视角三维重建"这件事从一条八股管线,压缩成了一次前向推理。你给它两张图,它吐出两张点图(pointmap),每个像素对应一个三维坐标,两张图共享同一个参考坐标系,也就是第一张图的相机系。没有内参、没有外参、没有特征匹配、没有三角化,连三角化需要的基线都不需要预先知道。听起来像魔术,但拆开看其实非常朴素:它是一个把"几何回归"直接做在网络里的方案。
这篇文章适合谁看?如果你已经在用传统管线做重建,想找一个能快速出粗模、做初始化、或者在弱纹理场景兜底的工具,那这篇对你有用;如果你刚入门三维视觉,想搞明白"点图"这个概念到底意味着什么,以及端到端几何回归为什么可行,这篇也能带你走一遍;如果你是要在业务里接一个快速重建模块,我会把显存预算、参数选择、常见坑都摊开讲。
我下面按"为什么这么设计—机制怎么跑—环境怎么搭—参数怎么调—坑怎么排"的顺序来写,中间会穿插我自己踩过的地方。需要先说明一点:文中涉及的具体函数名、权重命名、超参默认值,都以你手头那份实现为准,我给的是一套通用骨架和判断逻辑,不是逐字照抄的教条。
2. 为什么值得换一套思路:传统管线的结构性痛点
2.1 传统 SfM 加 MVS 的断点在哪里
先说清楚老管线为什么脆弱,才知道新方案在补哪个洞。传统流程的核心假设是"同一个三维点在不同视角下的局部外观足够相似,可以被描述子匹配上"。这个假设在自然纹理下有八成成立,在弱纹理下直接崩。SIFT 之类的描述子本质上是在描述梯度分布,一面白墙的梯度几乎为零,提取不出关键点,匹配图就断开成孤岛,后面的位姿图优化直接没法建。
第二个断点是标定。多数增量式流程要求你先提供焦距或者 EXIF,或者至少要求所有图来自同一台相机、同一组内参。混合来源的图像——手机拍的、相机拍的、截屏的、老照片——内参未知且不一致,传统管线的第一关就卡住。DUSt3R 的"无约束"三个字,主要就是冲着这个来的:内参未知、外参未知、图像来源混杂,它照样吐点图。
第三个断点是误差累积。增量式重建每加一帧都在做局部优化,长序列下来漂移是必然的,回环检测能缓解但不能根治。而两视角点图回归天生没有这个问题,因为它压根不做序列增量,多视图是靠后端的全局对齐一次性拼起来的。
我的判断标准很简单:如果场景纹理丰富、相机固定、序列不长,传统管线依然是性价比最高的选择;如果场景杂乱、来源不明、只想快速要一个粗几何看效果,那就该换端到端的路子。
2.2 端到端回归的核心取舍:扔掉匹配这个中间表示
DUSt3R 最反直觉的一步,是把"匹配"这个中间层彻底删了。传统思路是:像素 A 找到像素 B → 三角化出三维点。DUSt3R 的思路是:直接回归每个像素的三维坐标。匹配关系隐含在结果里——如果两个像素真的对应同一个空间点,它们的点图坐标在参考系下自然就重合了,不需要显式说出来。
这个取舍的好处是绕开了描述子的表达瓶颈。网络不需要"局部外观相似"这个前提,它可以靠全局上下文、靠语义、靠结构先验去推断深度关系。一面白墙没有纹理,但它有边界、有透视收敛、有和周围物体的遮挡关系,Transformer 的全局注意力能把这些用上。
代价也很明确:一是输出的绝对尺度不可知。单张图或者两张图,网络只能给出相对几何,真实米制尺度需要额外信息注入。二是分辨率受限。点图是逐像素回归的,输出分辨率被主干和解码器的计算量卡在 512 这个量级,细节精度天然比不上全分辨率的 MVS。三是它给的是点图,不是封闭网格,边缘和遮挡边界上必然有飞点,需要后处理。
2.3 适用边界:什么场景该用它,什么场景别硬上
我用下来的体感是,它最舒服的场景是:图像数量在几张到几十张之间、相机内外参完全未知、需要快速拿到一个能看的三维结构做验证。比如给一批产品照做初步的三维轮廓,给室内房间的随手拍做粗略的空间布局,或者给一个下游任务提供初始位姿。
不适合的场景同样明确:需要亚毫米级测量精度的工程应用,别考虑;大范围室外场景的长序列重建,几十上百张图全连接对齐的计算量是平方级增长的,而且漂移依然存在;包含大量动态物体(行人、车辆、飘动的树叶)的场景,网络会把动态物体当成静态几何回归进去,产生重影;强反光、透明材质,这些物理上就不满足朗伯假设的表面,任何基于多视图一致性的方法都会失真,它也不例外。
3. 机制拆解:点图、双分支注意力与全局对齐
3.1 点图到底是个什么东西
点图可以理解成"把深度图的三通道版本"。普通深度图每个像素一个标量,表示沿光轴的距离;点图每个像素三个分量,表示这个像素对应的空间点在第一张图的相机坐标系下的 XYZ 坐标。它比深度图多出来的信息是:横向和纵向的坐标也被显式回归了,所以它不需要内参就能反投影——因为它本身就是三维坐标,不是需要反投影的二维测量。
这个设计有个很妙的副产品:点图天生对齐图像网格。第 (u,v) 个元素就是第 (u,v) 个像素对应的三维点。这意味着你可以直接把点图当成一张"顶点网格"来用,相邻像素连成三角形就得到了表面,完全不需要跑匹配或者泊松重建。很多实现里的网格导出函数就是这么干的,简单粗暴但有效。
另一部分是置信度图,和点图同尺寸,每个像素一个标量。它的作用有两个:训练时给回归损失加权,让网络学会"这片区域我不确定,你别罚我";推理时用来过滤飞点,把低置信度的像素从点云里剔掉。我在实际使用中发现,置信度图还有一个隐藏用法——把它可视化出来,能极快地判断这批输入图的质量。如果整张置信度图普遍偏低,说明输入本身有问题(过曝、模糊、视角差异过大),不用往下跑了,先换图。
3.2 编码器的非对称设计与交叉注意力
网络结构上,编码器是共享权重的双分支,两张图各自过一遍 ViT,得到两组 token。解码器则是两个分支,中间插了交叉注意力:第一组 token 作为 query 去 attend 第二组 token,反过来也做一次。这样两边的信息在解码阶段是互相可见的,网络才有可能推断出"这两个像素是不是同一个点"。
这里有个容易忽略的细节:两个分支的权重是共享的,但参考系是固定的——输出始终在第一张图的相机系下。这意味着交换输入顺序,结果会变。我做过对比,同一对图像正反各跑一次,得到的点云在细节区域确实有差异,尤其是遮挡边缘附近。所以如果你对稳定性要求高,可以跑两次,把两组点图各自对齐后做融合,能压掉一部分单次推理的随机性。这个技巧成本翻倍,但对关键帧值得。
主干规模上常见的有 Base 和 Large 两档,配不同输入分辨率的解码器。Base 快、显存省,适合批量处理和调试;Large 精度明显更好,尤其是弱纹理区域的结构连续性。我的做法是先用 Base 把流程跑通、把参数摸清楚,最后出结果再换 Large。
3.3 从两视角到多视角:全局对齐在解什么
单对图像的输出只在一个参考系下自洽,多张图要拼成一个场景,就得把每对图像的结果统一到一个世界坐标系里。这一步叫全局对齐,本质是一个带尺度的位姿图优化。
问题的形式大概是这样:每张图有一个相机位姿,还有一个尺度因子(因为每对点图回归出来的尺度是各自归一的,彼此不一致)。优化目标是让同一个三维点在所有观测到它的视角下的投影尽可能重合,用置信度加权,再加一个鲁棒核抑制错误配对。求解上不是直接扔进通用优化器,而是用了交替策略——固定其他视角,用加权平均闭式地更新当前视角的位姿和尺度,反复迭代。这样做的好处是收敛稳定、不需要梯度,几百次迭代也就秒级。
实现里通常给两档:快速版迭代次数少、精度够看;精确版迭代次数多、更稳。还有一个更省事的做法,当图像数量不多时直接做全连接配对,把任意两张图都送进网络算一对点图,然后统一对齐。全连接的效果最好,代价是配对数随图像数平方增长。图像超过二十张左右,我就开始改用星型或者链式拓扑,只保留必要的配对,牺牲一点精度换时间。
3.4 损失设计里的两个小心思
训练侧的损失有两个设计值得说,因为它们直接决定了你推理时看到的行为。
第一个是尺度归一化。回归损失不是直接对坐标做 L1,而是先把点图除以该图所有点的平均距离,做一个尺度归一,再算损失。否则网络会去拟合绝对尺度,而绝对尺度在单目条件下本来就是不可辨识的,这会让训练目标变得病态。归一化之后,网络专注学形状,尺度交给后端对齐去解。
第二个是置信度的正则。如果只做置信度加权回归,网络会发现一个捷径:把所有像素的置信度压到接近零,损失就趋近于零了。所以必须加一个惩罚项,防止置信度整体塌缩。这个平衡点的调节是训练里最麻烦的部分之一,我见过不少复现失败都是栽在这。
4. 环境搭建与最小可运行流程
4.1 依赖清单与显存预算
环境不算复杂,但要提前算好显存。我整理了一个粗略的对照,具体数值会因实现版本和是否混合精度浮动,只作为选型参考:
| 配置 | 输入分辨率 | 单对图像显存 | 适合场景 |
|---|---|---|---|
| Base 主干 | 224 | 约 4-6 GB | 快速调试、批量预筛 |
| Base 主干 | 512 | 约 7-9 GB | 日常出图、中小场景 |
| Large 主干 | 512 | 约 12-16 GB | 出最终结果、弱纹理场景 |
| Large 主干 | 512 + 多视图全连接 | 16 GB 以上 | 十几张图精致重建 |
依赖上主要是 Python 3.10 一带、PyTorch 2.x 配对应 CUDA 版本,外加常规的图像处理库和点云读写库。装完之后先跑一张官方示例图确认环境通了,别急着上自己的数据。
# 建环境、装依赖,版本按你手头实现的说明走 conda create -n dust3r_demo python=3.10 -y conda activate dust3r_demo pip install torch torchvision --index-url <对应你 CUDA 版本的源> pip install -r requirements.txt4.2 权重摆放与目录约定
权重一般按两个维度命名:主干规模和解码器输入分辨率。下载完统一放到一个checkpoints/目录下,代码里通过一个路径参数指过去。我建议在项目根目录建一个固定的结构,后面所有脚本都读这个约定,省得来回改路径:
project/ ├── checkpoints/ # 权重文件 ├── data/ │ ├── raw/ # 原始图像 │ └── processed/ # 预处理后的缓存 ├── outputs/ │ ├── pointmaps/ # 点图和置信度的中间结果 │ └── clouds/ # 导出的点云/网格 └── scripts/把点图缓存到磁盘这一步非常值得做。多视图对齐的参数你大概率要调很多遍,如果每次都重新跑一遍网络,时间全浪费在重复计算上。我第一次做的时候没缓存,调一次对齐参数等十几分钟,后来把点图存成压缩的数组文件,之后调参就是秒级的事。
4.3 推理主干代码走读
下面这段是骨架,函数名和返回结构按你手头的实现替换,逻辑是通用的:
import torch import numpy as np from PIL import Image def load_pair(img_path_a, img_path_b, target_size=512, patch=16): """读图、等比缩放、pad 到 patch 的整数倍、归一化""" imgs = [] for p in (img_path_a, img_path_b): im = Image.open(p).convert("RGB") w, h = im.size # 等比缩放,长边对齐到 target_size,短边按比例 scale = target_size / max(w, h) nw, nh = int(round(w * scale)), int(round(h * scale)) im = im.resize((nw, nh), Image.LANCZOS) # pad 到 patch 的整数倍,右下补零,避免形变 pw = (patch - nw % patch) % patch ph = (patch - nh % patch) % patch canvas = Image.new("RGB", (nw + pw, nh + ph), (0, 0, 0)) canvas.paste(im, (0, 0)) imgs.append(canvas) return imgs def to_tensor(imgs, mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5)): arrs = [np.asarray(im, dtype=np.float32) / 255.0 for im in imgs] arrs = [(a - np.array(mean)) / np.array(std) for a in arrs] ts = [torch.from_numpy(a).permute(2, 0, 1) for a in arrs] return [t.unsqueeze(0).cuda() for t in ts]这里有两个地方特别容易出错,我一个一个说。
第一个是缩放策略。等比缩放之后补零,而不是直接拉伸到正方形。直接拉伸会改变相机的等效焦距比例,网络看到的几何关系和你真实拍的不一致,点图会出现系统性的形变。补零的方式至少保证了像素的横纵比不变。补零的颜色选黑或者选灰影响不大,但一定要保证两张图用同一套规则,不然参考系就对不上。
第二个是归一化。均值方差要和训练时一致,一般就是 0.5 那套。如果你用了 ImageNet 那套统计量而权重是按 0.5 训的,结果会整体偏,点云会呈现出奇怪的缩放和偏移,很难查。
前向和导出:
model = load_model(ckpt_path).eval() # 按实现替换 views = [dict(img=t) for t in to_tensor(load_pair(pA, pB))] with torch.no_grad(): out = model(views[0]["img"], views[1]["img"]) # 返回结构按实现替换 pts1 = out["pts3d"][0].cpu().numpy() # H x W x 3,第一张图坐标系 pts2 = out["pts3d"][1].cpu().numpy() conf1 = out["conf"][0].cpu().numpy() # H x W conf2 = out["conf"][1].cpu().numpy() # 置信度过滤后导出点云 def save_ply(path, pts, conf, thr): m = conf > thr p = pts[m] with open(path, "w") as f: f.write("ply\nformat ascii 1.0\n") f.write(f"element vertex {len(p)}\n") f.write("property float x\nproperty float y\nproperty float z\n") f.write("end_header\n") for x, y, z in p: f.write(f"{x:.6f} {y:.6f} {z:.6f}\n") save_ply("outputs/clouds/pair.ply", pts1, conf1, thr=1.5)置信度阈值这个数没有通用最优解,和你的权重、场景、后续用途都相关。我的经验是先按分位数来定——取所有置信度的 30% 分位当阈值,先把最差的三成剔掉,看剩下的结构对不对,再往上调。硬编码一个绝对值往往在换数据集时就失效了。
4.4 多视图场景的组织方式
多视图的处理流程大致是:读入 N 张图 → 生成配对列表 → 每对跑一次网络拿点图 → 收集所有点图和对齐所需的元信息 → 全局对齐 → 拿每个视角的位姿和尺度 → 把所有点图变换到统一世界系 → 融合导出。
配对列表的生成策略我总结成三种,按场景选:
- 全连接:N 张图两两配对,共 N(N-1)/2 对。精度最高,N 超过 15 就开始肉疼。
- 链式:只连相邻的,N-1 对。最快,但长链漂移明显,而且链断了就全崩。
- 星型:选一张中心图连所有其他的,N-1 对。速度和精度的折中,前提是中心图能看到大部分场景。
我一般先用星型跑一遍看整体结构,如果某个局部对不齐,再在那个局部补全连接。这个"先粗后精"的策略在实际项目里省了很多时间。
5. 参数调优与效果提升
5.1 输入分辨率:不是越高越好
预设的训练分辨率是有意义的,输入尺寸偏离它太多,精度会掉。往上加分辨率不是简单的"更多像素更多细节",因为位置编码和 patch 划分是按固定尺寸设计的,超出的部分网络没见过。我试过把长边拉到 1024 输入,结果点云并没有更细腻,反而在边缘出现了轻微的拉伸伪影。正确做法是在训练分辨率下跑,需要更高精度就用更多视角融合,而不是靠单张图堆像素。
真正影响精度的是长宽比的处理。宽幅或竖幅图像,等比缩放加补零之后有效像素占比会下降,两端可能被裁掉一部分内容。如果你要重建的对象正好在画面边缘,考虑先裁一刀再送进去,把主体放到画面中央。
5.2 全局对齐的迭代参数
对齐环节的可调项不多但都关键。迭代次数给少了,各视角之间明显错位,点云看起来像几层薄片叠在一起;给多了收益递减,而且如果配对质量差,迭代久了反而会把误差摊平到全局。
学习率调度一般有几档可选,线性的最稳,指数的收敛快但对初值敏感。我的默认做法是:先用较小的迭代次数加线性调度快速看一遍,确认没有大面积错位,再上大迭代次数出结果。
还有个实用技巧是分阶段调。第一轮用低分辨率点图做对齐,拿到每个视角的粗略位姿;第二轮用高分辨率点图、以第一轮的位姿为初值再对齐一次。这样既稳又快,比一口气上高分辨率硬算省事得多。
5.3 尺度复原:怎么把相对几何变成真实尺寸
点图回归出的坐标是无量纲的。如果你需要真实米制尺度,有三条路:
第一,在场景里放一个已知尺寸的参照物(标定板、卷尺、已知直径的球),在对齐后的点云里量出它的尺寸,算一个比例因子全局缩放。这是最省事也最可靠的方法。
第二,如果图像带有可靠的位姿信息(比如从其他设备同步过来的),可以把已知的相机基线长度作为约束注入对齐过程。这条路精度高但需要你手上有额外信息。
第三,如果你有深度传感器同步采集的稀疏深度,可以直接当约束用。我在一个室内项目里试过用手机的结构光深度做锚点,几百个稀疏点就足够把整体尺度定下来。
注意:不要在没做尺度复原的情况下拿去测量或者和其他模型对齐,很容易得到一个"看起来很对其实尺寸全错"的结果,后面接下游任务会连环出错。
5.4 和既有方案的混合用法
我不建议把这类方法当成传统管线的替代品,更实用的定位是"前置初始化器"。两个混合用法我验证过效果不错:
一是给多视图立体提供初始位姿。传统 SfM 在弱纹理段断开的时候,用点图对齐出来的位姿直接填进去,能救回不少断掉的序列。二是用点图做深度先验,去引导稠密化的代价体积,弱纹理区域的孔洞会明显减少。
反过来的用法也有:如果你已经有精确标定和位姿,可以在对齐阶段把这些位姿作为硬约束固定住,只优化尺度,结果会比全放开优化稳得多。
6. 常见问题与排查速查
这张表是我从自己踩过的坑里整理出来的,按现象索引,方便对着排:
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 点云整体翻折或镜像 | 两张图预处理规则不一致 | 检查缩放、pad、归一化是否完全同一套 |
| 只出了一半的点 | 置信度阈值过高 | 降到 30% 分位线,看剩余结构是否完整 |
| 点云呈片状分层、拼不上 | 对齐迭代不足或配对拓扑断链 | 加大迭代次数,补一条跨断层的配对 |
| 边缘大量飞点 | 遮挡边界回归误差 | 用置信度加邻域一致性双重过滤 |
| 细节发糊、结构不锐 | 分辨率受限 | 增加视角数做融合,别硬拉输入分辨率 |
| 显存爆掉 | 分辨率或多视图配对数过高 | 换小主干、降分辨率、改星型拓扑、开混合精度 |
| 坐标不在预期位置 | 参考系是第一张图 | 显式做一次变换,转到你要的世界系 |
| 前后两次结果不一致 | 输入顺序不同 | 固定图像顺序,或正反各跑一次做融合 |
| 动态物体出现重影 | 网络按静态假设回归 | 先做动态区域掩膜,或拍摄时避开 |
再补充几个表里放不下的经验点。
置信度图的诊断价值被严重低估。很多人只看点云,不看置信度。我的习惯是每个新场景先出一张置信度可视化,如果它呈现出大面积的均匀低值,说明输入质量有问题——可能是过曝、可能是运动模糊、可能是两张图几乎没有重叠。这时候再怎么调参数都是白费,先回去换图。
对齐失败通常不是算法问题,是拓扑问题。我遇到过一次,十几张图拼起来总是中间缺一块,查了半天参数没结果。最后发现是拍摄时中间有几步视角跳得太远,那两对图的共同可见区域太小,网络回归出的点图基本没重叠,对齐自然拼不上。补拍两张过渡视角,问题当场消失。
多跑一次正反顺序,能救回不少稳定性。前面提过参考系固定在第一张图,交换顺序结果会变。如果你在做关键帧输出,正反两次推理、各自对齐、然后用置信度加权融合,边缘和遮挡区的抖动会明显减小。成本翻倍,但只在关键帧上做,总体可以接受。
7. 实操节奏与几个不太写在文档里的心得
先说节奏。我建议的上手顺序是:两张图跑通 → 单对点云可视化 → 加第三张试试对齐 → 五到十张小场景 → 再考虑扩规模。每一步都要肉眼确认结果,别一口气跑三十张然后盯着屏幕发呆。三维重建的调试特别依赖中间结果的可见性,缓存点图、随手导出 ply、用任意一个点云查看器扫一眼,这些动作花不了几秒,但能让你在问题还很小时就发现它。
关于精度预期,我要说句泼冷水的话:这类方法的输出是"结构正确、细节粗糙"级别的几何。它能把房间的墙、地、家具的体块关系摆对,能给你一个可信的相对布局,但你别指望它的平面平整度、边缘锐度、尺寸精度能撑起测量级的应用。我见过一些项目一开始想用它直接出产品模型,跑到后面发现瓶颈不在算法而在预期管理。
再分享一个我常用的评估套路。不要只看点云"看起来像不像",那太主观了。我会算两个指标:一是成对视角的相对位姿误差,用对齐后的位姿和一组参考位姿比对,这个能量化对齐质量;二是点图的重投影一致性,把视角 A 的点图投到视角 B 的像素网格上,看和 B 的点图偏差多大。这两个数字一变差,问题就能定位到是网络回归环节还是对齐环节。
最后一个建议跟数据组织有关。把你的实验按"数据集 / 配对拓扑 / 分辨率 / 主干规模"四层目录来存,每次跑完把关键参数和指标写一个小的说明文件放在同级目录。我前期没做这个,两周后回头想对比两组参数,完全想不起来当时改了什么,只能重跑。这个习惯在任何需要反复实验的方向上都成立,三维重建尤其明显,因为一次跑起来的时间成本不低。
如果后面要继续往下做,我会往两个方向扩:一是把点图当作几何先验接到已有的神经渲染流程里,用它的位姿做初始化,收敛速度提升很明显;二是针对特定物体类别做小样本微调,把置信度的分布往目标类别上掰,弱纹理物体的完整度能有肉眼可见的改善。这两条路都需要先把你现在这条最小流程跑通,再往上加东西。