AMB3R:带 Backend 的度量尺度前馈 3D 重建
论文:AMB3R: Accurate Feed-forward Metric-scale 3D Reconstruction with Backend
arXiv:2511.20343
官方页面:项目主页
代码:HengyiWang/amb3r
这篇论文解决什么问题
前面读过的 DUSt3R、MASt3R、VGGT、Pi3、Depth Anything 3,都已经说明了一件事:
多视图几何可以被前馈网络学习出来
但这些方法通常还会留下几个现实问题:
reference view 依赖
尺度不一定稳定
大场景下的全局一致性不够
仅靠前端 pointmap / depth map 不一定足够稳
AMB3R 的目标是把这件事往前推进一步:
用一个前馈模型完成度量尺度 3D 重建,并加入一个 backend,把稠密前端预测组织成更稳定的全局 3D 场景表示。
它强调的不只是“预测得出 3D”,而是:
前端负责局部几何
backend 负责全局组织和稳定
最终输出 metric-scale 的场景重建
一句话版本
AMB3R 可以记成:
multi-view images
-> feed-forward front-end
-> dense geometry predictions
-> compact volumetric backend
-> metric-scale reconstruction
-> VO / SfM style downstream use
它和 VGGT / Pi3 / DA3 的共同点是:都想减少传统几何 pipeline 的显式中间步骤。
它和这些方法的差别是:AMB3R 更强调一个可组织全局场景的 backend。
模型输入与输出
输入是一组图像:
每张图像:
在一些使用场景里,还可能输入相机先验或多视图帧序列。
模型输出的核心是三类量:
| 输出 | 含义 | 用途 |
|---|---|---|
| depth / pointmap | 每张图的局部几何 | 直接恢复 3D 结构 |
| confidence / uncertainty | 预测可信度 | 供 backend / 融合阶段加权 |
| global 3D state | 后端组织后的场景表示 | metric-scale reconstruction、VO、SfM |
架构:前端 + backend
AMB3R 的关键不是单独一个 head,而是两段式设计。
images
-> front-end geometry predictor
-> dense depth / pointmap / confidence
-> backend volumetric scene representation
-> global reconstruction
前端负责从图像里提取局部几何线索,后端则把这些线索变成更稳定的全局场景表达。
一次前向的完整流程
AMB3R 把 2D 图像特征提取与 3D 全局空间推理结合起来,一次前向大致是:
- 前端(Front-end):由一个 ViT Encoder + ViT Decoder 组成。图中的“锁”图标表示预训练权重被冻结(frozen),目的是保留先验知识并降低训练成本。输入多视角图像后,前端初步预测出 pointmap 和几何特征。
- 稀疏体素融合:把前端提取的 2D 几何特征投射并融合为 3D 的稀疏体素。
- 序列化(S):把 3D 稀疏体素展平、序列化成 1D 数据序列。
- 全局处理(Transformer):1D 序列送入 Transformer 做全局 3D 空间推理,输出增强后的特征序列。
- 反序列化():把增强后的 1D 序列还原回 3D 稀疏体素。
- 特征回流:用 KNN 插值把 3D 体素特征映射回 2D 像素级特征,再通过零卷积层(zero conv,初始权重为零,保证初始训练稳定性)注入回冻结的 ViT Decoder。
- 最终输出:融合 3D 全局信息后,输出高精度的 3D 重建点云、相机空间姿态(红色线框金字塔),以及多视角下的彩色 pointmap 和 depth。
一句话概括:从 2D 中提取特征 → 在 3D 空间中进行全局理解 → 把 3D 理解反哺给 2D 网络 → 输出高精度重建。
⭐ 为什么要单独加 backend,而不是只靠前端 pointmap?
只靠 pointmap 的问题是:
每一帧都能预测出局部 3D
但不同帧之间未必自动对齐成一个稳定全局
如果场景比较大,或者输入视角变化比较明显,单纯的局部回归容易出现:
局部看起来对
全局拼起来不稳
尺度漂移
不同区域的几何不一致
backend 的作用就是把这些局部输出组织成一个更 compact 的场景状态。
你可以把它理解成:
前端:我看到什么,我就先估计什么
backend:我把所有视角的结果统一装进一个场景记忆里
这和纯 pointmap 方法的区别在于,AMB3R 不满足于“每张图都能回归出一个 3D field”,而是进一步要求“这些 3D field 能形成稳定的 metric-scale 全局场景”。
Backend 到底在做什么
官方仓库和项目页明确提到的是一个 sparse compact volumetric scene representation。
这意味着 backend 不是简单把所有像素特征再堆一遍,而是先把场景压成更紧凑的 3D 表达,再做全局推理。
可以把它粗略理解为:
per-view dense features / pointmaps
-> sparse volumetric representation
-> sequence model on 3D tokens
-> updated global scene memory
-> back-projection / interpolation to image space
这个设计的关键点是:
- 3D 空间里的相邻关系比 2D token 更贴近几何本质。
- 稀疏体表示比“所有像素都做一次全连接交互”更省。
- backend 可以在全局范围内修正局部前端的误差。
⭐ compact volumetric scene representation 为什么比纯 2D token 更合理?
因为重建问题的本体不是 2D 分类,而是 3D 几何一致性。
如果只在 2D token 空间里做推理,模型需要同时学会:
视角变化
遮挡
尺度
多图对齐
表面连续性
全局布局
这当然可以学,但表示会比较绕。
如果先把信息折到一个 3D / volumetric 结构里,模型看到的就是更接近场景本身的状态:
哪里有表面
哪里是空的
哪里还不确定
哪些区域来自多视图一致支持
这也是 AMB3R 叫 backend 的原因之一:它更像一个场景级组织器,而不是单纯的像素回归头。
⭐ Backend 的三个数学步骤:体素化、序列化与插值回流
Backend 的数学原理(公式 6–8):体素化、序列化与插值回流(对应 tmp1 对话1)
整个 backend 的目标是:把前端提取的 2D 特征转换到 3D 空间做全局处理,再映射回 2D。输入是前端输出的 pointmap 序列 以及对应的几何特征 。
第 1 步:稀疏体素化与特征聚合(voxelization)
先对齐 pointmap 与几何特征的分辨率,再构建一个稀疏体素网格(sparse voxel grid)。为了适应不同场景的尺度,模型在归一化空间中把体素大小设为 0.01,场景缩放后体素数量随之变化,相当于自适应的体素分辨率。
每个体素的特征 取落在该体素内的所有像素特征的平均(公式 6):
其中 是所有 3D 坐标落在体素 内的像素点 的集合, 是像素坐标, 是该集合的大小。
第 2 步:序列化与 Transformer 全局处理(serialization & processing)
Transformer 更擅长处理 1D 序列而不是 3D 网格,所以稀疏体素网格通过空间填充曲线(space-filling curves)被序列化(记为 )成 1D 特征序列,送进 Transformer 处理后再反序列化()回 3D 体素空间(公式 7):
其中的 通过 Point Transformer v3 实现,这是一种类似 U-Net 的架构,能够高效处理几何信息。
第 3 步:特征插值与零卷积回流(interpolation & fusion)
把体素特征转回逐点(per-point)特征时,使用 K 近邻(KNN)插值(公式 8):
插值得到的特征再通过零卷积(zero convolution)融合回解码器的每一层。零卷积初始权重为零,保证开始训练时不会破坏冻结前端的预测。
设计优势:这种融合方式允许模型复用前端已经学到的 attention 和 confidence 函数,从而大幅降低训练成本。
⭐ 几何特征 G_t 是从 encoder / decoder 得出的吗?为什么能和 pointmap 逐点对应?
几何特征 G_t 是从 encoder / decoder 得出的吗?encoder 之后特征不应该是和 patch 对齐的吗?
是的, 确实是从视觉特征和 Decoder 特征中提取出来的。核心秘密在于 Decoder 的密集预测(dense prediction)与上采样(upsampling)机制。
1. 直觉上的矛盾
ViT Encoder 确实把图像打散成 patch(例如 16×16 像素的块),因此 Encoder 输出的特征是 patch 级别的,而不是像素级别的:一个特征向量对应一个图像块,无法直接和单个像素点对应。
2. 从 patch 对齐到像素对齐
前端(front-end)不是只有 Encoder,而是 ViT Encoder + ViT Decoder 共同组成,它们分工实现了从 patch 到像素的转换:
- Encoder(降维与全局特征提取):图像被切分成多个 patch,Encoder 在 patch 之间做自注意力,输出低分辨率、包含丰富全局语义和几何先验的 patch 级特征。
- Decoder(上采样与密集预测):这是实现“逐点对应”的关键。为了输出逐像素的 pointmap ,Decoder 接收 Encoder 输出的 patch 特征,通过一系列上采样模块(特征金字塔、反卷积或双线性插值),把低分辨率特征图逐步放大、恢复分辨率。
3. 结论
经过 Decoder 处理后,特征图的空间分辨率被拉伸到与原输入图像(或目标网格)相同的大小,每个“格点”从代表一个 patch 变成代表一个具体像素。因此 Decoder 输出的几何特征 能与 pointmap 逐像素 1:1 精确对应,这也是数学表达中可以用统一的像素坐标 同时索引 pointmap 和几何特征( 与 )的原因。
度量尺度:median 归一化与尺度头
⭐ VGGT 输出的 pointmap 为什么按所有帧的“中值距离”归一化?
什么是“VGGT 输出的 pointmap 是根据所有帧的中值距离进行归一化处理的”?
这句话描述的是 3D 视觉基础模型(如 VGGT)在输出预测结果时,为了让模型训练更稳定而采用的“相对比例缩放”策略。拆开来看:
1. 什么是 pointmap?
普通彩色图像中每个像素点存储的是颜色值;而 pointmap 中每个像素点存储的是该点在 3D 空间中的三维坐标 。可以把它理解为一张包含丰富 3D 几何形状信息的“深度图”。
2. 什么是“所有帧的中值距离”?
假设模型同时处理 5 张不同角度拍摄的照片(即“所有帧”),它会初步计算出这些照片中每个像素点到相机的距离。把这成千上万个距离数值从小到大排成一列,最中间的那个数值就是中值距离(median distance),代表当前这组照片里场景物体与相机之间的一个“典型距离”。
3. 什么是归一化处理?
神经网络通常很难直接预测跨度极大的绝对物理数值(比如很难搞懂 0.1 米和 10000 米的绝对差异)。为了让模型更容易学习,VGGT 会在输出最终结果前,将预测出的所有 3D 坐标统一除以刚算出的中值距离。相除之后,整组 3D 场景的尺寸被按比例缩放到了一个标准的相对范围内,场景相对相机的中值距离在数值上变成 1。
结合 AMB3R 论文,这意味着什么?
“除以中值距离”虽然让基础网络跑得非常顺畅,却带来了一个副作用:它彻底抹杀了场景真实的物理大小(metric scale)。
直观例子:无论拍的是一辆 5 米长的真汽车,还是一辆 5 厘米长的玩具汽车,只要画面中的构图和比例一样,经过这种归一化处理后,VGGT 最终输出的 3D pointmap 在数值上完全一模一样,无法分辨哪个大哪个小。
这就是 AMB3R 作者的核心逻辑:正因为 VGGT 输出的 pointmap 只能告诉你物体的相对形状、丢失了绝对大小,所以 AMB3R 必须在网络内部(在数据被强行归一化之前)额外接入一个“尺度头(scale head)”。这个尺度头的唯一工作,就是从网络深层的特征中,把那个被掩盖的真实物理尺寸重新预测出来。
尺度头怎么恢复真实尺度
论文 4.1 节(Metric-scale Reconstruction)展开说明了尺度头的具体做法:
- 动机:VGGT 输出的 pointmap 按所有帧的中值距离归一化,作者认为这意味着 VGGT 冻结的特征网络中已经隐式包含了物理尺度的线索,因此决定添加一个尺度头(scale head),专门从这些特征中恢复真实的物理尺度。
- 初试失败:最直接的思路是利用所有帧的中间特征,通过 ROE 求解器直接回归预测值与真实值(ground truth)之间的“全局尺度差异”。作者发现这种方法极难训练且容易过拟合:一旦输入帧的组合方式或输入顺序发生变化,全局尺度差异就会随之改变,模型无所适从。
- 最终方案:把“全局依赖”降解为“单帧处理”。对每一帧,先找到预测深度处于中位数位置的像素,专门回归该像素的真实物理尺度对数深度(metric log depth)。这种策略提取的是每一帧自身的内在属性,摆脱了对所有帧全局预测的强依赖,训练容易得多;同时引入 Decoder 的深度特征提供额外引导。
- 推理阶段:先估算每一帧各自的尺度,然后取所有帧尺度的中位数,用这个中位数值把最终的 3D 重建结果对齐到真实的物理空间尺度。
训练:损失函数、ROE 尺度对齐与低成本
总损失(公式 9):AMB3R 的训练目标是三个误差之和:
即深度损失(depth)+ 点图损失(pointmap)+ 相机姿态损失(camera)。
尺度不匹配与 ROE 对齐:训练 backend 时,AMB3R 的损失计算方式、使用的数据集以及数据预处理脚本都与原来的前端基础模型 VGGT 不同,导致新模型学到的“规范尺度(canonical scale)”和 VGGT 原本的尺度对不上。
如果直接让模型在归一化后的数据上硬学,网络会浪费大量“学习能力(capacity)”去强行纠正尺度偏差,而不是学习真正有用的 3D 结构。所以作者在计算损失(即监督过程)之前,先用名为 ROE 的求解器,把模型预测出的几何形状与真实标签在尺度上强行对齐。
对齐时对不同任务采取不同宽容度:
- pointmap:为了保证整个图像序列的 3D 一致性,为整个序列估算一个统一的全局缩放因子。
- depth:放宽限制,为每一张单独的图像估算一个独立的缩放因子。
由于前端(VGGT)本身能输出尺度一致的几何结果,且其权重被冻结,这种对齐策略解放了 backend:它不用再操心宏观尺度对齐,可以集中优化重建中细微的结构细节,同时不破坏全局一致性。
极低的训练成本:
- 数据量小:backend 在 12 个混合数据集上总共只训练了 40 个 epoch(每个 epoch 2000 个样本,总计约 8 万个样本),比 VGGT 训练数据的一个 epoch 还少。
- 耗时极短:每个训练样本包含 5 到 16 帧图像,完成整个 backend 的训练大约只需要 50 个 H100 GPU 小时,在动辄成百上千 GPU 小时的 3D 视觉大模型领域非常低。
代码阅读:backend 与前端的实现细节
下面四段是 AMB3R 仓库代码阅读对话的完整记录,分别对应体素特征融合、空间填充曲线序列化、前端零卷积融合与 aggregator 注入位置,折叠存放:
⭐ 代码阅读:get_voxel_feat 如何把 pointmap 与几何特征融合成体素特征
tmp1 对话2 完整记录:get_voxel_feat 如何把 pointmap 与几何特征融合成体素特征
你这段代码正好对应论文里:
也就是把每张图上的 point map + 几何特征,融合成稀疏三维体素特征。
- 前端到底给后端什么
在 get_voxel_feat() 中:
feat = torch.cat([res['enc'], res['dec']], dim=-1) pts = res['world_points']
这里:
是前端预测的世界/公共参考坐标系 point map,形状为:
(B,T,H,W,3).
其中:
B:batch size; T:图像数量; H,W:point map 分辨率; 最后三维:每个像素的三维坐标 (x,y,z)。
而:
feat = torch.cat([res['enc'], res['dec']], dim=-1)
对应:
也就是每个图像 patch 的几何特征。代码把 encoder 特征和 decoder 特征拼接起来:
初始化中可以看到后端输入维度:
self.backend = BackEnd( in_dim=2048+1024, out_dim=1024, )
因此拼接后的特征维数是:
2048+1024=3072.
随后经过 aligner:
self.aligner = nn.Sequential( nn.Linear(in_dim, in_dim // 2), nn.GELU(), nn.Linear(in_dim // 2, out_dim), nn.GELU() )
将特征从:
3072→1536→1024.
也就是说,真正进入体素后端的是 1024 维特征。代码明确将前端分成 FrontEnd,后端分成 BackEnd,并将两者组合在 AMB3R 模型中。
- 为什么需要调整 point map 和 feature 的分辨率
原始特征通常是 DINO/VGGT patch token,例如 patch size 为 14,所以:
feat = feat.view( Bs, T, H // 14, W // 14, feat.shape[-1] )
说明 encoder/decoder 特征原本处于:
的 patch 网格上。
但是代码没有直接在这个低分辨率上体素化,而是先把 point map 调整到:
pts = self.resize_feat( pts, target_size=(H // 7, W // 7) )
因此后端实际处理的点数为每张图:
.
随后特征也插值到相同大小:
feat = self.resize_feat( feat, target_size=(Hs, Ws) )
于是 point map 和 feature 一一对应:
二者形状分别为:
.
代码中的分辨率对齐过程可以直接在 get_voxel_feat() 中看到。
- 多张图像如何被放到同一个三维空间
关键在于:
pts = res['world_points']
它不是每张图各自在相机坐标系中的 point map,而是已经处于统一公共坐标系中的点。
可以理解为:
.
其中:
:第 t 张图自身相机坐标系中的点;
:从第 t 个相机坐标系到参考系 1 的变换;
:统一参考坐标系中的点。
因此,来自不同图像的点可以直接比较:
.
如果距离很近,它们就可能属于同一个体素。
这就是 AMB3R 能够把不同视图显式融合的前提。
- 后端首先把所有图像和像素展平
在 BackEnd.forward() 中:
if len(feats.shape) != 3: feats = feats.reshape(Bs, -1, C) pts = pts.reshape(Bs, -1, 3)
原来:
会变成:
其中:
.
对应点坐标则变成:
(B,N,3).
接下来又展平 batch:
pts = pts.reshape(-1, 3) feats = feats.reshape(-1, C)
于是:
.
同时构造每个点属于哪个 batch:
batch_ids = torch.arange(Bs).repeat_interleave(N)
所以每个点实际携带:
(b,x,y,z).
这很重要,因为不同 batch 中即使坐标相同,也不能融合到同一个体素。
- 如何确定一个三维点属于哪个体素
核心代码是:
voxel_indices = get_vox_indices( points, batch_ids, voxel_size, bounding_boxes, shift=False, cat_batch_ids=True )
概念上,体素索引通常计算为:
其中:
:点坐标; v:体素大小; o:体素网格原点; :整数体素坐标。
在默认:
voxel_resolutions=[0.01]
下:
.
例如两个点:
若按 0.01 划分,则它们可能得到相同体素坐标:
(10,20,100).
因此会被判断为落入同一个体素。
- bounding_boxes 的作用是什么
代码首先计算每个 batch 点云的包围盒:
bounding_boxes[:, 0, :] = pts.min(dim=1).values bounding_boxes[:, 1, :] = pts.max(dim=1).values
即:
.
理论上可以以包围盒最小点作为体素网格原点:
.
不过这里调用:
shift=False
意味着具体是否减去 bounding-box 最小值,要看 get_vox_indices() 的实现。仅从当前文件能够确认的是,包围盒被传入体素索引函数,但无法仅凭这段代码完全断定 shift=False 的内部计算公式。
后面有一个与此相关的选项:
if self.interp_v2: coord = coord + bounding_boxes[vox_id[:, 0], 0]
默认情况下:
coord = voxel_size * vox_id[:, 1:]
也就是将体素坐标恢复为连续坐标:
.
当 interp_v2=True 时,再加包围盒最小坐标:
. 7. 为什么代码要构造 hash
得到体素索引后:
voxel_hash = self.hash_fn(voxel_indices)
其中:
b, x, y, z = coords.unbind(dim=1)
return ( (b.long() << 48) | (x.long() << 32) | (y.long() << 16) | z.long() )
它把:
编码成一个整数 hash:
.
其目的不是学习特征,而是方便快速判断:
哪些点具有完全相同的 batch ID 和体素坐标。
例如:
(0,10,20,30)
和:
(0,10,20,30)
会产生相同 hash。
但:
(1,10,20,30)
因为 batch ID 不同,会得到不同 hash,因此不会跨 batch 融合。
- 公式 6 在代码中具体是哪一行
论文公式:
对应代码:
unique_hash, inverse_id = torch.unique( voxel_hash, return_inverse=True )
voxel_feats = scatter_mean( feats, inverse_id, dim=0 )
其中:
unique_hash:所有被占据的不同体素; inverse_id[j]:第 j 个点属于第几个唯一体素; scatter_mean:把属于同一体素的特征求平均。
假设:
point 0 → voxel 2 point 1 → voxel 0 point 2 → voxel 2 point 3 → voxel 1 point 4 → voxel 2
那么:
inverse_id = [2, 0, 2, 1, 2]
于是:
这就是公式 6 的完整实现。文件中也明确写明 mean_by_voxel() 返回每个体素内特征的平均值,并使用 scatter_mean 实现。
- 为什么这种平均能够融合不同视图
展平之前,点的索引实际上是:
(t,h,w).
但展平之后,体素化不再关心它来自:
哪一张图; 哪一个像素; 哪一个 patch。
它只关心:
落在哪个三维体素。
假设三张图像分别观察到同一真实三维点:
.
,则:
因此不同视图的信息被显式合并。
这和普通 attention 不同:
attention:通过特征相似性学习哪个 token 对应哪个 token; AMB3R 体素化:通过预测三维坐标直接决定哪些 token 应融合。 10. 体素位置为何取“第一次出现的点”
代码还有一段:
first_occurrence_original_indices = torch.scatter_reduce( ..., inverse_id, original_indices, reduce="amin" )
unique_voxel_indices = voxel_indices[ first_occurrence_original_indices ]
对于属于同一体素的多个点,它需要保留一份体素索引:
.
因为同一体素中的点具有相同整数索引,所以取第一次出现的那个即可。
注意:
这里只是体素索引取第一次出现值,体素特征仍然是所有点的平均,并不是只使用第一个点的特征。
最终返回:
info = { 'unique_indices': unique_voxel_indices } 11. 构造 Point Transformer 输入
随后代码构造:
data_dict = { 'feat': feat, 'grid_coord': vox_id[:, 1:], 'coord': coord, 'batch': vox_id[:, 0], }
四个字段分别表示:
feat:
即每个体素的平均特征。
grid_coord:
整数体素坐标,主要用于空间序列化、稀疏结构和邻域划分。
coord:
连续空间坐标,默认约为:
. batch
体素属于哪个 batch:
.
接着输入:
point_out = self.point_transformer(data_dict)
Point Transformer V3 会在稀疏三维体素之间传播信息。该处理流程在后端 forward() 中完整展开。
- 体素 Transformer 输出为什么还要插值回原始点
Point Transformer 输出的是每个体素的特征:
.
但是前端 decoder 仍然需要每个图像位置的二维特征,因此代码调用:
interpolated_feats = self.voxel_to_point_interpolation( point_out, pts, chunk_size )
对于每个原始 point-map 点 ,寻找最近的 K=16 个体素:
knn = knn_points( original_pts, voxel_coords_padded, K=K_interp )
初始化里:
k_neighbors=16
所以:
表示点 附近的 16 个体素。
然后采用逆距离加权:
weights_chunk = 1.0 / (dists_chunk + 1e-8) weights_chunk = weights_chunk / weights_chunk.sum(...)
数学上:
最后:
因此体素特征重新回到每个原始 point-map 点上。KNN 插值、逆距离权重和加权求和分别对应代码中的 knn_points、权重归一化和 weighted sum。
- 如何重新恢复为每张图的二维特征图
体素插值结果最初是:
.
随后:
voxel_feat_fine = voxel_feat[-1].reshape( Bs, T, Hs, Ws, -1 )
恢复为:
.
然后:
voxel_feat_aligned = self.backend.downsample(...)
将其从 H/7×W/7 下采样回前端 patch token 的:
.
这样它才可以与 VGGT 的 patch token 对齐。
- 3D 特征如何注入前端
先经过:
voxel_feat_aligned_vis = ( self.backend.zero_conv(voxel_feat_aligned)
- self.backend.gate_scale )
可以写成:
其中:
Z:zero convolution; α:可学习的 gate_scale。
然后:
patch_tokens = self.front_end.add_voxel_feat_to_patch_tokens( patch_tokens, voxel_feat_aligned_vis )
将三维后端信息加到 patch token 中。
接着前端重新解码:
res_all.append( self.front_end.decode_patch_tokens_and_heads( images, patch_tokens, voxel_feat=voxel_feat_aligned, voxel_layer_list=voxel_layer_list ) )
所以完整迭代是:
初始 point map→体素化→3D Transformer→插值回 2D→修改 patch tokens→重新预测 point map.
代码甚至允许执行多次:
for i in range(iters):
即:
不过默认:
iters=1
也就是通常只运行一次后端修正。
- 用一个极简例子理解
假设两张图,各自只有三个像素点:
其中:
是同一个真实三维点,
也是同一个真实三维点。
体素化后可能得到:
voxel 0: A1, A2 voxel 1: B1, B2 voxel 2: C1 voxel 3: D2
那么:
经过 Point Transformer 后:
.
再插值回每个点:
A1 ← 附近体素特征 A2 ← 附近体素特征 B1 ← 附近体素特征 B2 ← 附近体素特征
由于 使用了同一个融合体素,两张图对点 A 的预测会更容易保持一致。
- 一个很重要的细节:体素化不会直接平均点坐标
这段实现中平均的是:
voxel_feats = scatter_mean(feats, ...)
即:
.
它没有写:
voxel_points = scatter_mean(points, ...)
所以体素代表位置不是体素内所有点的几何中心,而是根据整数体素索引恢复出的规则网格坐标:
coord = voxel_size * vox_id[:, 1:]
即近似:
.
所以这里的设计是:
特征:来自同体素观测的均值; 位置:规则体素位置; 原始连续点:只在最后 KNN 插值时作为查询位置使用。 17. 体素大小 0.01 为什么能够“自适应”
代码本身只是设置:
voxel_resolutions=[0.01]
它并不会自动知道 0.01 米、0.01 厘米还是 0.01 个房间。
所谓自适应的前提是:输入 point map 已经被归一化到相似尺度。
假设每个场景都经过归一化,使典型深度或点云尺度大约为 1,那么:
表示场景典型尺度的约百分之一。
例如:
小场景归一化后仍约为 1; 大场景归一化后也约为 1;
于是相同的 0.01 对不同原始尺度场景具有相似的相对分辨率。
因此更准确的说法是:
0.01本身不是自适应;“场景尺度归一化+固定0.01”共同实现相对尺度上的自适应。
你上传文本末尾也明确描述了:输入是 point-map 序列及对应几何特征,系统对齐分辨率,在归一化空间中构建稀疏体素网格,并平均同一体素内的特征。
- 完整数据流总结
可以把代码压缩成下面这一条链:
将 调整到相同分辨率 展平所有图像点 恢复为多视图二维特征图 重新解码 point map、depth、pose、confidence。
最核心的一句话是:
AMB3R先用前端pointmap把二维token放入三维空间,再把落入同一体素的多视图特征平均,用PointTransformer在三维中处理,最后将结果插值回每个图像token。
另外,你文件第 133–134 行出现的:
self.backenThe Switch and the Spur d.forward(...)
明显是文本粘贴过程中混入了无关字符串;正常源码应当是:
voxel_feat = self.backend.forward( pts, feat, voxel_sizes=self.voxel_resolutions )
⭐ 代码阅读:point_transformer 的空间填充曲线序列化(z-order / Hilbert)
tmp1 对话3 完整记录:point_transformer 的空间填充曲线序列化与局部 Attention
阅读 point_transformer.py, default.py import torch from .z_order import xyz2key as z_order_encode_ from .z_order import key2xyz as z_order_decode_ from .hilbert import encode as hilbert_encode_ from .hilbert import decode as hilbert_decode_
@torch.inference_mode() def encode(grid_coord, batch=None, depth=16, order="z"): assert order in {"z", "z-trans", "hilbert", "hilbert-trans"} if order == "z": code = z_order_encode(grid_coord, depth=depth) elif order == "z-trans": code = z_order_encode(grid_coord[:, [1, 0, 2]], depth=depth) elif order == "hilbert": code = hilbert_encode(grid_coord, depth=depth) elif order == "hilbert-trans": code = hilbert_encode(grid_coord[:, [1, 0, 2]], depth=depth) else: raise NotImplementedError if batch is not None: batch = batch.long() code = batch << depth * 3 | code return code
@torch.inference_mode() def decode(code, depth=16, order="z"): assert order in {"z", "hilbert"} batch = code >> depth * 3 code = code & ((1 << depth * 3) - 1) if order == "z": grid_coord = z_order_decode(code, depth=depth) elif order == "hilbert": grid_coord = hilbert_decode(code, depth=depth) else: raise NotImplementedError return grid_coord, batch
def z_order_encode(grid_coord: torch.Tensor, depth: int = 16): x, y, z = grid_coord[:, 0].long(), grid_coord[:, 1].long(), grid_coord[:, 2].long()
we block the support to batch, maintain batched code in Point class
code = z_order_encode_(x, y, z, b=None, depth=depth) return code
def z_order_decode(code: torch.Tensor, depth): x, y, z = z_order_decode_(code, depth=depth) grid_coord = torch.stack([x, y, z], dim=-1) # (N, 3) return grid_coord
def hilbert_encode(grid_coord: torch.Tensor, depth: int = 16): return hilbert_encode_(grid_coord, num_dims=3, num_bits=depth)
def hilbert_decode(code: torch.Tensor, depth: int = 16): return hilbert_decode_(code, num_dims=3, num_bits=depth) z_order.py # -------------------------------------------------------- # Octree-based Sparse Convolutional Neural Networks # Copyright (c) 2022 Peng-Shuai Wang <wangps@hotmail.com> # Licensed under The MIT License [see LICENSE for details] # Written by Peng-Shuai Wang # --------------------------------------------------------
\import torch from typing import Optional, Union
class KeyLUT: def init(self): r256 = torch.arange(256, dtype=torch.int64) r512 = torch.arange(512, dtype=torch.int64) zero = torch.zeros(256, dtype=torch.int64) device = torch.device("cpu")
self._encode = { device: ( self.xyz2key(r256, zero, zero, 8), self.xyz2key(zero, r256, zero, 8), self.xyz2key(zero, zero, r256, 8), ) } self._decode = {device: self.key2xyz(r512, 9)}
def encode_lut(self, device=torch.device("cpu")): if device not in self._encode: cpu = torch.device("cpu") self._encode[device] = tuple(e.to(device) for e in self._encode[cpu]) return self._encode[device]
def decode_lut(self, device=torch.device("cpu")): if device not in self._decode: cpu = torch.device("cpu") self._decode[device] = tuple(e.to(device) for e in self._decode[cpu]) return self._decode[device]
def xyz2key(self, x, y, z, depth): key = torch.zeros_like(x) for i in range(depth): mask = 1 << i key = ( key | ((x & mask) << (2 * i + 2)) | ((y & mask) << (2 * i + 1)) | ((z & mask) << (2 * i + 0)) ) return key
def key2xyz(self, key, depth): x = torch.zeros_like(key) y = torch.zeros_like(key) z = torch.zeros_like(key) for i in range(depth): x = x | ((key & (1 << (3 * i + 2))) >> (2 * i + 2)) y = y | ((key & (1 << (3 * i + 1))) >> (2 * i + 1)) z = z | ((key & (1 << (3 * i + 0))) >> (2 * i + 0)) return x, y, z
_key_lut = KeyLUT()
def xyz2key( x: torch.Tensor, y: torch.Tensor, z: torch.Tensor, b: Optional[Union[torch.Tensor, int]] = None, depth: int = 16, ): r"""Encodes :attr:x, :attr:y, :attr:z coordinates to the shuffled keys based on pre-computed look up tables. The speed of this function is much faster than the method based on for-loop.
Args: x (torch.Tensor): The x coordinate. y (torch.Tensor): The y coordinate. z (torch.Tensor): The z coordinate. b (torch.Tensor or int): The batch index of the coordinates, and should be smaller than 32768. If :attr:b is :obj:torch.Tensor, the size of :attr:b must be the same as :attr:x, :attr:y, and :attr:z. depth (int): The depth of the shuffled key, and must be smaller than 17 (< 17). """
EX, EY, EZ = _key_lut.encode_lut(x.device) x, y, z = x.long(), y.long(), z.long()
mask = 255 if depth > 8 else (1 << depth) - 1 key = EX[x & mask] | EY[y & mask] | EZ[z & mask] if depth > 8: mask = (1 << (depth - 8)) - 1 key16 = EX[(x >> 8) & mask] | EY[(y >> 8) & mask] | EZ[(z >> 8) & mask] key = key16 << 24 | key
if b is not None: b = b.long() key = b << 48 | key
return key
def key2xyz(key: torch.Tensor, depth: int = 16): r"""Decodes the shuffled key to :attr:x, :attr:y, :attr:z coordinates and the batch index based on pre-computed look up tables.
Args: key (torch.Tensor): The shuffled key. depth (int): The depth of the shuffled key, and must be smaller than 17 (< 17). """
DX, DY, DZ = _key_lut.decode_lut(key.device) x, y, z = torch.zeros_like(key), torch.zeros_like(key), torch.zeros_like(key)
b = key >> 48 key = key & ((1 << 48) - 1)
n = (depth + 2) // 3 for i in range(n): k = key >> (i * 9) & 511 x = x | (DX[k] << (i * 3)) y = y | (DY[k] << (i * 3)) z = z | (DZ[k] << (i * 3))
return x, y, z, b 和 backend.py 回答:降维与处理(公式 7)——稀疏体素网格通过“空间填充曲线(space-filling curves)”被序列化(序列化操作记为 )成 1D 的特征序列,接着交由 Transformer 处理,处理完后再反序列化()回 3D 体素空间。公式表达为: 这部分是怎么实现的。
这部分在代码里不是“把 3D 坐标真正压成一个连续 1D 张量,再调用一次显式 decode() 恢复坐标”,而是通过三步实现:
3D 体素坐标→空间曲线编码并排序→按排序后的局部序列做 Attention→用 inverse permutation 恢复原体素顺序
也就是说,论文中的
在实际代码中, 主要是一个逆置换操作,不是调用你贴出的 decode(code)。
- 从 backend.py 进入 Point Transformer
在 backend.py 中,体素平均之后构造:
data_dict = { 'feat': feat, 'grid_coord': vox_id[:, 1:], 'coord': coord, 'batch': vox_id[:, 0], }
其中:
feat=
是每个非空体素的平均特征;
grid_coord:
是离散整数体素坐标;
batch:
表示体素属于哪个 batch。
然后调用:
point_out = self.point_transformer(data_dict)
因此 Point Transformer V3 的输入已经不是原始逐像素点,而是 AMB3R 体素化之后的 N 个稀疏体素。
- PointTransformerV3.forward() 对应公式的入口
核心代码是:
point = Point(data_dict) point.serialization( order=self.order, shuffle_orders=self.shuffle_orders ) point.sparsify()
point = self.embedding(point) point = self.enc(point)
if not self.cls_mode: point = self.dec(point)
return point
其中最直接对应 S 的是:
point.serialization(...)
而 Transformer 处理 对应:
point = self.embedding(point) point = self.enc(point) point = self.dec(point)
代码明确在 forward() 中先构造 Point,然后 serialization、sparsify,再通过 embedding、encoder 和 decoder。
因此可以先写成:
3. S 的第一步:为每个三维体素生成一维 code
在:
Point.serialization()
中,代码执行:
code = [ encode( self.grid_coord, self.batch, depth, order=order_ ) for order_ in order ]
默认:
order = ( "z", "z-trans", "hilbert", "hilbert-trans" )
所以每个三维体素会生成四种空间序列编码。
Point Transformer 默认使用四种序列顺序,并将它们保存在 serialized_code、serialized_order 和 serialized_inverse 中。
3.1 Z-order 是如何把三维坐标变成一个数的
假设体素坐标为:
(x,y,z).
Z-order,也叫 Morton code,会把 x,y,z 的二进制位交错排列。
假设只有 3 bit:
编码后的 code 为:
你贴出的循环版代码:
for i in range(depth): mask = 1 << i key = ( key | ((x & mask) << (2 * i + 2)) | ((y & mask) << (2 * i + 1)) | ((z & mask) << (2 * i + 0)) )
本质上就是把:
x 的第 i 位放到第 3i+2 位; y 的第 i 位放到第 3i+1 位; z 的第 i 位放到第 3i 位。
即:
所以:
.
这就是从三维离散位置到一维序列 key 的映射。
3.2 为什么还要编码 batch
default.py 中:
if batch is not None: batch = batch.long() code = batch << depth * 3 | code
所以完整 code 为:
.
这样可以确保:
batch 0 的全部体素排在一个编码区间; batch 1 的全部体素排在另一个区间; 不同样本不会混到同一个序列 patch 中。
例如 depth=16 时,三维位置占:
个 bit,batch 被放在更高位。
- depth 是什么意思
代码:
if depth is None: depth = int( torch.abs(self.grid_coord).max() ).bit_length()
它根据最大体素坐标确定编码需要多少 bit。
例如最大体素索引是:
1000,
因为:
,
所以:
.
于是每个坐标轴可表示: .
三维 Morton/Hilbert code 使用:
个位置位。
代码同时限制:
assert depth <= 16
因为当前编码最多为三维坐标保留 48 bit。serialization() 会自动计算序列化深度,并检查总 bit 数和 depth <= 16 的限制。
- S 的第二步:根据 code 排序
生成 code 后:
code = torch.stack(code) order = torch.argsort(code)
假设原始体素顺序是:
index: 0 1 2 3 code: 9 2 7 4
则:
order = [1, 3, 2, 0]
因为:
2<4<7<9.
于是序列化后的特征为:
数学上:
其中:
π=argsort(c).
注意:code 本身只是每个点的一维编号,真正把特征排成序列的是:
order = torch.argsort(code)
后续再用这个 order 索引 point.feat。
- 为什么还要保存 inverse
代码:
inverse = torch.zeros_like(order).scatter_( dim=1, index=order, src=torch.arange( 0, code.shape[1], device=order.device ).repeat(code.shape[0], 1), )
它构造排序的逆映射。
仍以前面的例子:
order = [1, 3, 2, 0]
含义是:
序列位置 0 ← 原始点 1 序列位置 1 ← 原始点 3 序列位置 2 ← 原始点 2 序列位置 3 ← 原始点 0
对应 inverse:
inverse = [3, 0, 2, 1]
含义是:
原始点 0 在序列位置 3 原始点 1 在序列位置 0 原始点 2 在序列位置 2 原始点 3 在序列位置 1
满足:
.
代码将这些结果保存为:
self["serialized_code"] = code self["serialized_order"] = order self["serialized_inverse"] = inverse
这三者就是后续完成 S 和 S −1 的核心数据。
- 四条空间填充曲线怎么使用
默认有四种:
( "z", "z-trans", "hilbert", "hilbert-trans" )
分别为:
z:直接编码 。 z-trans
先交换 x,y:
grid_coord[:, [1, 0, 2]]
即:
。
再做 Morton 编码。
hilbert
对:
做三维 Hilbert 编码。
hilbert-trans
对:
做 Hilbert 编码。
它们产生四种不同的一维邻域划分:
.
这样可以缓解单一空间曲线的局限:在某一条曲线上被切断的三维邻居,在另一条曲线中可能变成邻近序列元素。
- 为什么要 shuffle_orders
在序列化后:
if shuffle_orders: perm = torch.randperm(code.shape[0]) code = code[perm] order = order[perm] inverse = inverse[perm]
这不是打乱每条曲线内部的点,而是打乱:
z z-trans hilbert hilbert-trans
这四种 order 的排列顺序。
例如原本可能是:
[z, z-trans, hilbert, hilbert-trans]
训练时变成:
[hilbert-trans, z, hilbert, z-trans]
因为不同 Transformer block 通过:
order_index = i % len(self.order)
轮流选择一种序列顺序。
所以 shuffle 的目的是避免网络始终让固定层绑定固定曲线,增加训练中的空间顺序多样性。
- Transformer 中的 S 真正发生在哪里
在 SerializedAttention.forward() 中:
order = point.serialized_order[ self.order_index ][pad]
这里选择当前 block 使用的空间曲线顺序。
然后:
qkv = self.qkv(point.feat)[order]
这一步才真正执行特征序列化。
原始:
经过:
point.feat[order]
变成:
因此论文中的:
在代码中最核心的对应是:
point.feat[order]
而不是单独的 encode() 函数。
encode() 只是产生排序依据;argsort 得到置换;真正重排特征是在 attention 里用 order 索引。
- 不是整条一维序列做全局 Attention,而是切成 patch
代码默认:
patch_size = 64
排好序后,特征被按连续 64 个体素划分:
以此类推。
代码:
q, k, v = ( qkv .reshape(-1, K, 3, H, C // H) .permute(2, 0, 3, 1, 4) .unbind(dim=0) )
其中:
.
所以 attention 复杂度大致从全局的:
空间曲线的作用就是尽量保证每个连续 patch 中的 64 个体素,在三维空间中也是局部邻近的。
这就是 Point Transformer V3 中所谓的 serialized local attention。
- 是怎么处理序列的
是怎么处理序列的
非 Flash Attention 分支:
attn = (q * self.scale) @ k.transpose(-2, -1) attn = self.softmax(attn) feat = ( attn @ v ).transpose(1, 2).reshape(-1, C)
对应标准多头自注意力:
只是每次 attention 限制在一个 serialized patch 内。
启用 Flash Attention 时:
flash_attn.flash_attn_varlen_qkvpacked_func(...)
数学含义相同,只是实现更高效。
所以:
并不只是一次 Attention,而是整个 Point Transformer U-Net,包括:
sparse convolution positional encoding; serialized attention; MLP; residual connection; pooling; unpooling; encoder-decoder 多尺度处理。 12. 到底在哪里
最关键代码:
inverse = unpad[ point.serialized_inverse[self.order_index] ]
然后 Attention 得到序列顺序下的输出后:
feat = feat[inverse]
这就是:
.
假设序列化后输出:
使用:
inverse = [3, 0, 2, 1]
后:
恢复到原始体素排列。
代码正是先通过 serialized_order 排序,再通过 serialized_inverse 恢复,最后把结果写回 point.feat。
因此,论文公式在单个 Attention block 中非常直接地对应:
也就是:
qkv = self.qkv(point.feat)[order] # S ... feat = attention(qkv) # f_theta feat = feat[inverse] # S^{-1} 13. 为什么没有调用你贴出的 decode()
你贴出的:
def decode(code, depth=16, order="z"): ... grid_coord = z_order_decode(...) return grid_coord, batch
是:
Morton/Hilbert code→(x,y,z,batch).
但 Point Transformer 的 Attention 并没有改变体素坐标,只改变特征。
因此它不需要把 code 解码回坐标。
原始 Point 对象始终保留:
point.grid_coord point.coord point.batch
序列化时只是临时重排特征:
point.feat[order]
处理后再逆重排:
feat[inverse]
所以这里的“反序列化”是:
恢复特征索引顺序,
而不是:
重新解码坐标.
这是理解代码与论文公式之间关系的关键。
- pad 和 unpad 为什么存在
不同 batch 的体素数量可能不是 patch_size=64 的整数倍。
例如一个样本有:
个体素。
划分为 64 大小时,需要:
64+64+22.
最后 22 个不足一个 patch。
get_padding_and_inverse() 会把它补成:
.
但这里不是简单补零,而是重复前一个 patch 中部分索引,以避免引入不存在的空特征。
它返回:
pad:从原始点序列扩展到补齐序列的索引; unpad:从补齐序列恢复到原始长度; cu_seqlens:Flash Attention 所需的各 patch 累计长度。
因此实际排序是:
order = serialized_order[order_index][pad]
即:
π padded
恢复时:
inverse = unpad[serialized_inverse[order_index]]
同时处理:
空间曲线排序的逆变换; padding 的移除。
相关 padding、unpadding 和序列长度索引在 get_padding_and_inverse() 中构造。
- 一个完整的小例子
假设有 6 个体素:
原始索引 3D 坐标 Z-order code 0 (1,1,0) 6 1 (0,0,0) 0 2 (1,0,0) 4 3 (0,1,0) 2 4 (1,1,1) 7 5 (0,0,1) 1
排序后:
order=[1,5,3,2,0,4].
于是:
假设 patch_size=3,分成:
每组三个体素内部做 Attention:
拼起来仍是序列顺序:
对应逆置换:
inverse=[4,0,3,2,5,1].
执行:
feat = feat[inverse]
得到:
这就是:
16. 公式 7 不只是一次排序和 Attention
从论文的简化公式看:
容易误以为 Point Transformer 只做:
排序 → 一次 Transformer → 恢复
但实际 PTv3 是一个多阶段 U-Net。
默认 encoder:
enc_depths = (2, 2, 2, 6, 2) enc_channels = (128, 128, 128, 256, 512)
decoder:
dec_depths = (2, 2, 2, 2) dec_channels = (1024, 256, 128, 256)
因此中间还包括多次:
serialization order 切换; local attention; 体素 pooling; 分辨率降低; unpooling; skip connection。
所以更精确地写:
. 17. SerializedPooling 如何在空间曲线上降采样
Pooling 中:
pooling_depth = ( math.ceil(self.stride) - 1 ).bit_length()
code = ( point.serialized_code
pooling_depth * 3 )
若 stride 为 2,则:
.
将 code 右移 3 bit:
.
因为每个八叉树层级包含:
三个位
三个位,右移 3 bit 相当于丢掉最低一级空间细节。
在坐标上等价于:
⌋).
代码也显式写成:
grid_coord = point.grid_coord[head_indices]
pooling_depth
即每个坐标右移一位,等价于除以 2。
具有相同 coarse code 的体素被聚成一个 cluster:
code_, cluster, counts = torch.unique( code[0], return_inverse=True, return_counts=True )
再对 cluster 内特征做:
torch_scatter.segment_csr(..., reduce=self.reduce)
默认 reduce="max"。
这就是多尺度 3D 下采样。相关 code 位移、cluster 构造和特征聚合都在 SerializedPooling.forward() 中。
- Unpooling 如何回到原体素
Pooling 时保存:
point_dict["pooling_inverse"] = cluster point_dict["pooling_parent"] = point
其中 cluster[j] 表示原细粒度体素 j 属于哪个粗体素。
Unpooling 时:
parent = point.pop("pooling_parent") inverse = point.pop("pooling_inverse")
point = self.proj(point) parent = self.proj_skip(parent)
parent.feat = ( parent.feat
- point.feat[inverse] )
其中:
point.feat[inverse]
把粗体素特征复制回所有对应的细体素。
数学上:
.
再与 encoder skip feature 相加:
.
这使最终输出重新具有最初的 N 个体素。
- 与 AMB3R backend.py 的完整对应关系
因此 AMB3R 里的整个后端可以写为:
第一步:像素特征合并到体素 第二步:构造 PTv3 输入
第三步:空间曲线编码
其中:
第四步:得到排序和逆排序 π 第五步:按曲线顺序分 patch 做 Attention 第六步:恢复原体素顺序 第七步:多尺度 encoder-decoder 反复执行 第八步:回到 AMB3R
backend.py 得到:
point_out.feat point_out.coord point_out.batch
然后通过 KNN 将:
插值回每个原始 point-map 点。
- 最重要的纠正
论文所说:
通过空间填充曲线被序列化成 1D 特征序列,Transformer 处理后再反序列化回 3D 体素空间。
在代码层面更准确的说法是:
体素本身从未消失,也没有真正从3D数据结构转换为普通完整1D序列;代码始终保存体素坐标,只根据空间曲线生成一个排序索引,临时将体素特征按该索引排列做局部Attention,然后用逆索引恢复原体素顺序。
所以:
主要对应:
code = encode(grid_coord, batch) order = argsort(code) feat_seq = feat[order]
而:
主要对应:
feat_original_order = feat_seq[inverse]
z_order_decode() 或 hilbert_decode() 并不是这个网络前向过程中的必要步骤,因为三维坐标一直保存在 Point.grid_coord 和 Point.coord 中。
⭐ 代码阅读:frontend.py 的零卷积融合
tmp1 对话4 完整记录:frontend.py 的零卷积融合
现在附上fronted.py , 帮我解释 是如何 零卷积融合 的 import os \import sys \import torch \import torch.nn as nn
sys.path.append(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(file))), 'thirdparty'))
from vggt.models.vggt import VGGT from vggt.utils.pose_enc import pose_encoding_to_extri_intri from vggt.utils.geometry import unproject_depth_map_to_point_map_torch, closed_form_inverse_se3
from .blocks import ScaleProjector
class FrontEnd(nn.Module): def init(self, ckpt_path='./checkpoints/VGGT.pt', metric_scale=True): super().init() self.metric_scale=metric_scale self.model = VGGT(return_depth_feat=metric_scale)
if os.path.isfile(ckpt_path): checkpoint = torch.load(ckpt_path) self.model.load_state_dict(checkpoint)
self.metric_scale_projector = ScaleProjector(depth_feat_channels=128)
def encode_patch_tokens(self, frames): ''' Encode patch tokens from frames.
Params:
- frames: dict containing 'images' (B, nimgs, 3, H, W) in [-1, 1] range
Returns:
- images: normalized images in [0, 1] range
- patch_tokens: encoded patch tokens '''
images = frames['images'] # B, nimgs, 3, H, W images = (images + 1.0) / 2.0 # Normalize to [0, 1] range if needed assert images.min() >= 0.0 and images.max() <= 1.0, "Images should be in the range [0, 1], got min {}, max {}".format(images.min(), images.max()) return images, self.model.aggregator.encode_patch_tokens(images)
def add_voxel_feat_to_patch_tokens(self, patch_tokens, voxel_feat): ''' Add voxel features to patch tokens.
Params:
- patch_tokens: dict containing patch tokens
- voxel_feat: voxel features to be added
Returns:
- patch_tokens: updated patch tokens with voxel features added '''
patch_tokens["x_norm_patchtokens"] += voxel_feat.flatten(0, 1).flatten(1, 2) # Add voxel features to patch tokens return patch_tokens
def decode_patch_tokens(self, patch_tokens, images, voxel_feat=None, voxel_layer_list=None, detach=False): ''' Decode patch tokens using VGGT decoder.
Params:
- patch_tokens: dict containing patch tokens
- images: input images (B, nimgs, 3, H, W) in [0, 1] range
- voxel_feat: encoder voxel features
- voxel_layer_list: list of voxel features for each decoder layer
Returns:
- decoded_features: dict containing decoded features '''
return self.model.aggregator.decode_patch_tokens(patch_tokens, images, voxel_feat=voxel_feat, voxel_layer_list=voxel_layer_list, detach=detach, mem_eff=True)
def decode_heads(self, images, decoded_features):
''' Decode feature using VGGT heads to get depth, camera pose, and 3D points.
Params:
- images: input images (B, nimgs, 3, H, W) in [0, 1] range
- decoded_features: dict containing decoded features from the decoder
Returns:
- predictions: dict containing depth, camera pose, 3D points, camera poses, etc. '''
aggregated_tokens_list = decoded_features['aggregated_tokens_list'] ps_idx = decoded_features['patch_start_idx'] patch_tokens = decoded_features['patch_tokens'] cls_token = decoded_features['cls_token'] reg_token = decoded_features['reg_token']
predictions = {}
with torch.amp.autocast("cuda", enabled=False):
Camere pose prediction
if self.model.camera_head is not None: pose_enc_list = self.model.camera_head(aggregated_tokens_list) predictions["pose_enc"] = pose_enc_list[-1] # pose encoding of the last iteration predictions["pose_enc_list"] = pose_enc_list # all pose encodings
Depth prediction
if self.model.depth_head is not None: if self.metric_scale: depth, depth_conf, depth_feat = self.model.depth_head( aggregated_tokens_list, images=images, patch_start_idx=ps_idx ) Bs, nimgs, H, W, _ = depth.shape enc_tokens = torch.cat([patch_tokens, cls_token[:, None], reg_token], dim=1) median_z_log = self.metric_scale_projector(depth_feat, enc_tokens).view(Bs, nimgs, 1) else: depth, depth_conf = self.model.depth_head( aggregated_tokens_list, images=images, patch_start_idx=ps_idx )
predictions["depth"] = depth predictions["depth_conf"] = depth_conf
Point prediction
if self.model.point_head is not None: pts3d, pts3d_conf = self.model.point_head( aggregated_tokens_list, images=images, patch_start_idx=ps_idx ) predictions["world_points"] = pts3d predictions["world_points_conf"] = pts3d_conf
predictions["images"] = images
if self.metric_scale: median_pred_values_flat, _ = torch.median(predictions["depth"].view(Bs * nimgs, H * W), dim=1) median_pred_values_flat = median_pred_values_flat.view(Bs, nimgs, 1) predictions['median_metric_z_log'] = median_z_log predictions["median_metric_z"] = median_z_log.exp()
metric_scale = predictions["median_metric_z"] / (median_pred_values_flat + 1e-8) # Bs, nimgs, 1 metric_scale_median, _ = torch.median(metric_scale, dim=1, keepdim=True) # Bs, 1, 1 predictions["depth_metric"] = depth * metric_scale_median.view(Bs, 1, 1, 1, 1)
Bs, nimgs, H, W, one_ = depth.shape predictions["enc"] = patch_tokens.view(Bs, nimgs, patch_tokens.shape[-2], patch_tokens.shape[-1]) predictions["dec"] = aggregated_tokens_list[-1].view(Bs, nimgs, aggregated_tokens_list[-1].shape[-2], aggregated_tokens_list[-1].shape[-1])[..., ps_idx:, :]
extrinsic, intrinsic = pose_encoding_to_extri_intri(predictions["pose_enc"], images.shape[-2:]) point_map_by_unprojection = unproject_depth_map_to_point_map_torch(predictions["depth"].view(-1, H, W, 1), extrinsic.view(-1, 3, 4), intrinsic.view(-1, 3, 3))
predictions["pts3d_by_unprojection"] = point_map_by_unprojection.view(Bs, nimgs, H, W, 3)
predictions['extrinsic'] = extrinsic.view(Bs, nimgs, 3, 4) predictions['intrinsic'] = intrinsic.view(Bs, nimgs, 3, 3) predictions['pose'] = closed_form_inverse_se3(predictions['extrinsic'].view(-1, 3, 4)).view(Bs, nimgs, 4, 4)
predictions['model'] = 'vggt' predictions['aggregated_tokens_list'] = aggregated_tokens_list predictions['patch_start_idx'] = ps_idx predictions['cls_token'] = cls_token predictions['reg_token'] = reg_token
return predictions
def decode_patch_tokens_and_heads(self, images, patch_tokens, voxel_feat=None, voxel_layer_list=None, detach=False): ''' Decode patch tokens and heads to get final predictions. Params:
- images: input images (B, nimgs, 3, H, W) in [0, 1]
- patch_tokens: dict containing patch tokens
- voxel_feat: encoder voxel features
- voxel_layer_list: list of voxel features for each decoder layer Returns:
- predictions: dict containing depth, camera pose, 3D points, camera poses, '''
decoded_features = self.decode_patch_tokens(patch_tokens, images, voxel_feat=voxel_feat, voxel_layer_list=voxel_layer_list, detach=detach) predictions = self.decode_heads(images, decoded_features)
return predictions
现在有了 frontend.py,可以把零卷积融合的外层调用链解释清楚了。
不过要先明确一点:
frontend.py 负责把 zero-conv 处理后的特征传给 VGGT aggregator;真正“在 decoder 第几层、哪个位置相加”的代码仍然位于 VGGT.aggregator.decode_patch_tokens() 内部。
因此目前可以确定两级融合:
第一级:加入初始 patch tokens;第二级:传给 decoder 各层逐层注入
- 第一次前向:先得到原始 VGGT 预测
在 AMB3R.forward() 中,首先:
images, patch_tokens = self.front_end.encode_patch_tokens(frames)
res = self.front_end.decode_patch_tokens_and_heads( images, patch_tokens )
这一轮没有传入:
voxel_feat voxel_layer_list
所以它就是普通 VGGT 前向:
这里得到:
res["world_points"]; res["enc"]; res["dec"]; res["depth"]; res["pose"]; res["world_points_conf"]。
这些结果随后用于建立 3D backend。
- 3D 后端产生什么特征
get_voxel_feat() 最后得到:
voxel_feat_aligned voxel_feat_aligned_vis voxel_layer_list
三者的含义不同。
voxel_feat_aligned
这是未经 zero-conv 的 3D 增强特征:
其中:
它已经经过:
体素平均; Point Transformer; KNN 插值; DownBlock 对齐到 patch 分辨率。 voxel_feat_aligned_vis
代码:
voxel_feat_aligned_vis = ( self.backend.zero_conv(voxel_feat_aligned)
- self.backend.gate_scale )
对应:
其中:
:入口 zero-conv;:入口 gate;
:入口 gate; conv2 初始化为零。
所以初始化时:
从而:
. voxel_layer_list
代码给每个 decoder 层准备一个独立模块:
voxel_layer_list.append( { 'layer': layer, 'H': H // 14, 'W': W // 14, 'gate_scale': self.backend.gate_scales[l_idx] } )
因此第 l 层拥有:
.
这部分稍后被传入 VGGT aggregator。
- 第一级融合:直接修改 patch tokens
frontend.py 中最明确的融合发生在:
def add_voxel_feat_to_patch_tokens( self, patch_tokens, voxel_feat ): patch_tokens["x_norm_patchtokens"] += ( voxel_feat .flatten(0, 1) .flatten(1, 2) ) return patch_tokens
这就是一个直接的残差加法。
3.1 原始 patch token 形状
通常:
patch_tokens["x_norm_patchtokens"]
形状是:
例如:
. 3.2 voxel feature 的形状
在 model.py 中,voxel_feat_aligned_vis 先被恢复为:
.
传入 add_voxel_feat_to_patch_tokens() 后:
voxel_feat.flatten(0, 1)
得到:
.
然后:
.flatten(1, 2)
把二维 patch 网格展平:
.
即:
.
这样就与原始 patch tokens 完全对齐。
3.3 实际融合公式
代码:
patch_tokens["x_norm_patchtokens"] += voxel_feat_flat
对应:
进一步展开:
初始化时:
因此:
训练之后才逐渐变成:
- 这里为什么是原地加法
代码使用:
+=
而不是:
new_patch_tokens = ...
意味着它原地修改了:
patch_tokens["x_norm_patchtokens"]
随后第二次 decoder 调用使用的就是修改后的 patch tokens:
res_all.append( self.front_end.decode_patch_tokens_and_heads( images, patch_tokens, voxel_feat=voxel_feat_aligned, voxel_layer_list=voxel_layer_list ) )
所以第二轮不是重新做 image encoder,而是:
复用第一次编码得到的 patch tokens,给它们加上 3D 后端残差,再重新运行 decoder。
计算流程是:
第一次:
建立后端:
修改 token:
第二次:
5. 第二级融合:传进 VGGT decoder
frontend.py 中:
def decode_patch_tokens( self, patch_tokens, images, voxel_feat=None, voxel_layer_list=None, detach=False ): return self.model.aggregator.decode_patch_tokens( patch_tokens, images, voxel_feat=voxel_feat, voxel_layer_list=voxel_layer_list, detach=detach, mem_eff=True )
这说明第二次 decoder 不仅收到了已经修改过的 patch tokens,还额外收到:
voxel_feat=voxel_feat_aligned
以及:
voxel_layer_list=voxel_layer_list
因此 decoder 内部拥有:
原始、未经过入口 zero-conv 的 3D 特征:; 每层自己的 zero-conv:; 每层自己的 gate:。 6. decoder 每层的融合形式
从现有参数设计来看,decoder 内部的融合应当是类似:
然后:
或者如果 decoder token 是展平格式,则内部可能先把 token 转成二维:
应用:
再展平后加入:
但目前没有 aggregator 的具体源码,所以不能仅凭 frontend.py 确定:
在 attention 前加; attention 后加; MLP 后加; frame attention 与 global attention 之间加; 每个 block 都加还是只在某些 block 加。
能够确定的是:
voxel_feat 和每层独立的 zero-conv/gate 被完整传入 aggregator.decode_patch_tokens(),真正逐层融合由 aggregator 内部执行。
- 为什么入口融合和逐层融合都需要
看起来似乎只需要其中一个,但二者功能不同。
入口融合
它在 decoder 开始前就修改视觉 patch token。
这意味着后续所有 decoder 层都会基于带有 3D 信息的 token 运行。
它提供一种全局、统一的初始条件修正。
逐层融合
这允许 3D 特征在 decoder 深处持续进入网络,而不会随着层数增加逐渐被冲淡。
而且每层有不同的 ,可以学习不同语义映射。
所以整体更像:
而不是只在最后输出头前补一次信息。
- ZeroConvBlock 实际相当于逐像素 MLP
ZeroConvBlock 使用的是两个 1×1 卷积:
self.conv1 = nn.Conv2d( 1024, 1024, kernel_size=1 )
self.conv2 = nn.Conv2d( 1024, 1024, kernel_size=1 )
对于每个二维 patch 位置 (h,w),它执行:
因为 kernel size 是 1,所以它不混合相邻位置,只混合通道:
这很合理,因为空间邻域信息已经在以下步骤中处理过:
Point Transformer; KNN interpolation; DownBlock 的 3×3 卷积。
zero-conv 的任务不是继续做空间建模,而是:
把 3D 后端通道重新投影到适合 VGGT decoder 当前层的特征空间。
- 为什么 conv2 为零就能完全关闭支路
ZeroConvBlock:
return self.conv2( self.act( self.conv1(x) ) )
设:
输出:
初始化:
因此对任意输入:
.
哪怕:
Point Transformer 输出随机; DownBlock 输出随机; conv1 输出很大;
最后都被 conv2=0 截断。
所以首次训练迭代中:
这使第二次 decoder 的行为在初始状态下理论上与原 decoder 非常接近。
- gate 的作用
每个 zero-conv 后面还有:
- gate_scale
因此实际输出是:
虽然 gate 初始化为 1:
nn.Parameter(torch.ones(1))
但因为 ,初始输出还是零。
训练后 gate 可以控制整层注入强度。
例如:
意味着第 l 层几乎不需要 3D 后端信息;
意味着该层强烈依赖 3D 信息;
理论上还可以学习反向修正。
但需要注意一个训练细节:
初始化第一步时,由于:
gate 梯度为:
所以训练最初主要先更新 conv2。
当 conv2 产生非零输出后,gate 才开始获得非零梯度。
- 梯度是怎样流动的
融合式:
损失通过 VGGT heads 产生:
梯度路径为:
由于 get_voxel_feat() 默认:
detach=True
第一次前端产生的:
res["enc"] res["dec"] res["world_points"]
会 detach。
因此:
这意味着梯度不会穿过 3D backend 回到第一次 VGGT forward。
但第二次 decoder 中,新增后端特征仍然可以通过 frozen VGGT 的计算图,把损失梯度传给:
zero-conv; gate; DownBlock; Point Transformer; aligner。
即使 VGGT 参数冻结,梯度仍然可以穿过 VGGT 运算传到输入端的新增模块。
冻结参数不等于切断梯度。
- decode_heads() 如何复用原 VGGT 输出头
第二次 decoder 输出后,代码仍然调用同样的:
self.model.camera_head(...) self.model.depth_head(...) self.model.point_head(...)
因此:
置信度也是由原 head 一起输出:
depth, depth_conf = self.model.depth_head(...)
以及:
pts3d, pts3d_conf = self.model.point_head(...)
所以 AMB3R 没有建立新的:
depth confidence head; point confidence head; camera prediction head。
它只修改这些原始 heads 所读取的 decoder feature。
这就是论文说的复用已有:
attention; confidence function; prediction heads。 13. enc 与 dec 特征为何会再次变化
在 decode_heads() 最后:
predictions["enc"] = patch_tokens.view(...)
以及:
predictions["dec"] = ( aggregated_tokens_list[-1] .view(...) [..., ps_idx:, :] )
第二次解码后:
enc 对应已经加入入口 3D 残差的 patch tokens; dec 对应经过逐层 3D 注入后的最终 decoder tokens。
所以若 iters>1,下一轮后端输入:
feat = torch.cat( [res["enc"], res["dec"]], dim=-1 )
使用的将是上一轮已经增强后的特征。
因此多轮时形成:
但默认只执行一轮。
- 完整的两级融合流程
现在可以把代码精确整理为:
A. 原始编码 patch_tokens = aggregator.encode_patch_tokens(images)
得到:
X. B. 原始解码 decode_patch_tokens( patch_tokens, voxel_feat=None, voxel_layer_list=None )
得到:
C. 3D backend
D. 入口 zero-conv voxel_feat_aligned_vis = ( zero_conv(F_3D) * gate_scale ) E. 加到 patch token x_norm_patchtokens += flatten(F_0)
F. 第二次解码 decode_patch_tokens( patch_tokens=X', voxel_feat=F_3D, voxel_layer_list=[ (Z_1, alpha_1), ..., (Z_{48}, alpha_{48}) ] )
decoder 内部大致执行:
G. 原 VGGT heads 输出 15. 一处值得注意的代码副作用
这里:
patch_tokens["x_norm_patchtokens"] += ...
是原地修改。
因此在:
for i in range(iters):
中,如果 iters>1,每轮会继续在已经修改过的 patch_tokens 上累加新的 voxel feature:
并不是每轮都回到最初的 encoder token。
因此多轮行为是残差累积式 refinement,而非每轮独立重算。
- 现在还缺哪段代码
要完全确认逐层注入位置,需要查看:
vggt.models.aggregator.decode_patch_tokens
特别搜索以下变量:
voxel_feat voxel_layer_list gate_scale
大概率能看到类似:
voxel_delta = layer(voxel_feat) * gate_scale tokens = tokens + voxel_delta.flatten(...)
然后就能确定它发生在:
frame block 前后; global block 前后; attention 之前; MLP 之后; 每层还是每个 alternating group。
基于当前 frontend.py 能确定的是:
入口融合已经明确发生在x_norm_patchtokens += voxel_feat
而逐层融合则由:
aggregator.decode_patch_tokens( ..., voxel_feat=voxel_feat, voxel_layer_list=voxel_layer_list )
触发,具体层内位置必须由 aggregator 源码确认。
最核心的公式是:
以及 decoder 内的:
前者已经在 frontend.py 中完全可见,后者的模块和参数已准备并传入,但具体相加行位于 VGGT aggregator 内部。
⭐ 代码阅读:aggregator.py 中零卷积的注入位置与 alternating attention
tmp1 对话5 完整记录:aggregator.py 中零卷积的注入位置
这是 aggregator
现在这段 aggregator.py 已经把“每层零卷积融合”的具体位置完全暴露出来了。实际实现比之前推测更明确:
每个 frame-attention block 前注入一次;每个 global-attention block 前注入一次
并且只加到 patch tokens,不会加到 camera token 和 register tokens。
- VGGT 的 alternating attention 结构
默认配置:
depth = 24 aa_order = ["frame", "global"] aa_block_size = 1
因此网络依次执行:
frame block 0 global block 0 frame block 1 global block 1 ... frame block 23 global block 23
也就是:
24 个 frame-attention block; 24 个 global-attention block;
总计:
个 Transformer block。
这正好解释了 backend.py 为什么构造:
以及:
self.zero_conv_layers = nn.ModuleList( [ZeroConvBlock() for _ in range(depth)] )
即:
每个 alternating-attention block 对应一个独立 zero-conv。Aggregator 本身也明确说明它在输入帧上执行 alternating attention。
- token 的组成
在 decoder 开头:
tokens = torch.cat( [camera_token, register_token, patch_tokens], dim=1 )
所以完整 token 序列是:
其中:
self.patch_start_idx = 1 + num_register_tokens
默认 num_register_tokens=4,所以:
.
即 token 索引:
0 : camera token 1,2,3,4 : register tokens 5... : patch tokens
因此:
tokens[:, self.patch_start_idx:, :]
只取 patch tokens:
camera 和 register token 完全不被 zero-conv 直接修改。
- 入口处第一次融合
在调用 aggregator 之前,frontend.py 已经执行:
patch_tokens["x_norm_patchtokens"] += ( voxel_feat.flatten(0, 1).flatten(1, 2) )
传入这里的 voxel_feat 实际上是:
voxel_feat_aligned_vis
self.backend.zero_conv(voxel_feat_aligned)
- self.backend.gate_scale
因此入口融合为:
然后 aggregator 再添加 camera 和 register token:
这是 decoder 开始之前的第一层 3D 回流。
- frame attention 前如何融合
关键代码是:
if attn_type == "frame":
if voxel_layer_list is not None: if tokens.shape != (B * S, P, C): tokens = tokens.view(B, S, P, C).view(B * S, P, C)
v_info = voxel_layer_list[block_id * 2]
tokens[:, self.patch_start_idx:, :] += ( v_info['layer'](voxel_feat) .permute(0, 2, 3, 1) .flatten(1, 2)
- v_info['gate_scale'] )
tokens, frame_idx, frame_intermediates = ( self._process_frame_attention(...) )
所以第 block_id 组的 frame attention 使用:
voxel_layer_list[block_id * 2]
例如:
| block_id | attention | zero-conv |
|---|---|---|
| 0 | frame 0 | |
| 1 | frame 1 | |
| 2 | frame 2 | |
| ... | ... | ... |
| 23 | frame 23 |
也就是说偶数编号 zero-conv 用于 frame attention。
对应数学表达:
然后才进入 frame block:
代码明确在调用 _process_frame_attention() 之前,将经过对应 zero-conv 和 gate 的体素特征加到 patch token 上。
- global attention 前如何融合
frame attention 完成之后,进入:
elif attn_type == "global":
if voxel_layer_list is not None: v_info = voxel_layer_list[block_id * 2 + 1]
tokens[:, self.patch_start_idx:, :] += ( v_info['layer'](voxel_feat) .permute(0, 2, 3, 1) .flatten(1, 2)
- v_info['gate_scale'] )
tokens, global_idx, global_intermediates = ( self._process_global_attention(...) )
所以同一组的 global attention 使用:
voxel_layer_list[block_id * 2 + 1]
对应:
| block_id | attention | zero-conv |
|---|---|---|
| 0 | global 0 | |
| 1 | global 1 | |
| 2 | global 2 | |
| ... | ... | ... |
| 23 | global 23 |
即奇数编号 zero-conv 用于 global attention。
数学上:
随后:
逐层 global 注入发生在 _process_global_attention() 之前。
- 一组 alternating attention 的完整公式
对于第 l 组,输入 token 为:
.
先在 frame attention 前加 3D 残差:
执行单帧内部 attention:
然后在 global attention 前再次加 3D 残差:
执行跨帧 global attention:
所以整个网络是:
注意,第二个加法发生在 frame attention 输出之后、global attention 之前。
- 为什么先统一为 (B*S, P, C)
frame attention 要求 token 形状为:
其中:
B:batch; S:图像数量; P:包含特殊 token 的 token 数; C=1024。
因此 frame 分支先检查:
if tokens.shape != (B * S, P, C): tokens = tokens.view(B, S, P, C).view(B * S, P, C)
因为上一层 global attention 输出的 token 形状可能是:
.
这里先恢复:
(B,SP,C)→(B,S,P,C)→(BS,P,C).
这样每张图单独成为一个 batch item,随后执行 frame attention。
- 体素特征如何变成 token 序列
原始 voxel_feat 形状:
.
经过每层 zero-conv:
v_info['layer'](voxel_feat)
形状仍然是:
.
随后:
.permute(0, 2, 3, 1)
变为:
.
再:
.flatten(1, 2)
变为:
.
令:
则结果为:
.
恰好和:
tokens[:, self.patch_start_idx:, :]
形状一致。
因此实际融合是逐 patch 对齐相加:
不是广播到所有 token,也不是全局平均后加一个向量。
每一个 patch 位置 u 都得到其对应 point-map/voxel 插值位置的 3D 特征。
- 为什么不加给特殊 token
代码只修改:
tokens[:, self.patch_start_idx:, :]
因此:
和:
不直接接收 3D 特征。
但它们仍然会在后续 attention 中间接获得这些信息。
例如 frame attention 中,camera token 可以关注当前帧所有带 3D 增强的 patch tokens:
global attention 中,不同帧 camera/register token 也可以通过跨帧 attention 获取 3D 一致性信息。
所以设计是:
3D 信息先绑定到具有明确空间位置的 patch tokens,再由原 VGGT attention 自然传播到 camera/register tokens。
这比直接向 camera token 加一个空间特征更合理,因为 camera token 没有明确的单个二维位置。
- 为什么 frame 和 global 使用不同的 zero-conv
虽然输入都是同一个:
但是 frame attention 和 global attention 的功能不同。
Frame attention
_process_frame_attention() 保持:
.
每张图独立处理,只在单张图的 token 内进行 attention。
它主要负责:
单帧空间结构; 局部几何; 图像内上下文; 当前帧 camera token 和 patch token 的交互。
可以学习适合单帧处理的 3D 特征投影。
Global attention
_process_global_attention() 将 token 转成:
(B,SP,C).
也就是把所有图像 token 串到一起做跨帧 attention。
它主要负责:
多视图对应; 相机间关系; 跨视图几何一致性; 多帧信息传递。
可以学习另一套更适合跨帧融合的通道映射。
这就是为什么不是同一个 zero-conv 输出重复加两次,而是每层都拥有独立参数。
- zero-conv 注入是累积的
代码使用原地加法:
tokens[:, self.patch_start_idx:, :] += ...
因此 3D 特征不是临时用一次后立即丢弃,而是不断累积进 token 状态。
第一组:
第二组继续:
因此某一层注入的信息会通过后续所有 attention 和 MLP 层继续传播。
- 为什么叫“零卷积”,但网络中一共有 49 个
严格来说,有:
1 个入口 zero-conv: self.backend.zero_conv 48 个逐层 zero-conv: self.backend.zero_conv_layers
总计:
.
入口 zero-conv 修改 DINO patch tokens:
其余 48 个分别在:
24 个 frame block 前; 24 个 global block 前;
进行注入。
所以完整结构是:
DINO patch token │ ├── Entry ZeroConv ▼ Frame block 0 ▲ └── ZeroConv 0 before frame │ ├── ZeroConv 1 before global ▼ Global block 0 │ ├── ZeroConv 2 before frame ▼ Frame block 1 │ ├── ZeroConv 3 before global ▼ Global block 1 ... ▼ Global block 23
更准确地调整顺序后是:
DINO patch token
- entry ZeroConv │
- ZeroConv 0 ▼ Frame 0 │
- ZeroConv 1 ▼ Global 0 │
- ZeroConv 2 ▼ Frame 1 │
- ZeroConv 3 ▼ Global 1 ...
- 为什么入口 zero-conv 后,frame 0 前还要再加一次
这看上去有点重复,但两者参数不同:
入口注入修改的是最原始的 DINO patch embedding:
随后拼接 camera/register token。
frame 0 前又注入:
因此初始 frame attention 实际接收:
这使网络可以学习两种不同作用:
入口 zero-conv:修改基础视觉表示; frame-0 zero-conv:针对第一个 frame attention 做额外条件化。
是否存在冗余,是一个很好的消融实验问题。
- 论文所谓“复用已有 attention”在这里如何体现
AMB3R 没有改变:
self.frame_blocks self.global_blocks
它仍然调用原 VGGT:
tokens = self.frame_blocks[frame_idx](tokens, pos=pos)
以及:
tokens = self.global_blocks[global_idx](tokens, pos=pos)
这些 attention block 本身没有新增 3D attention 逻辑。
AMB3R 只是把输入改为:
因此原本学到的 attention 函数:
现在处理:
也就是:
3D 后端负责产生残差提示,VGGT 已有的 frame/global attention 负责解释和传播这些提示。
这样不需要重新训练一套完整的多视图 Transformer。
- 为什么能复用 confidence head
aggregator 最终输出:
aggregated_tokens_list
后续仍然进入原始:
camera head; depth head; point head。
例如 point head 输出:
pts3d, pts3d_conf
因此 confidence 函数没有被替换。
只是它读取的特征从:
变成:
所以:
这正是“复用 confidence function”的具体含义。
- aggregated_tokens_list 保存什么
每完成一组 frame/global attention,代码把两者的中间输出拼接:
concat_inter = torch.cat( [frame_intermediates[i], global_intermediates[i]], dim=-1 )
因此每个保存结果维度是:
.
其中一半来自:
另一半来自:
这也是之前 model.py 中:
res['dec']
维度为 2048 的原因之一,而 encoder patch feature 是 1024。
随后:
feat = torch.cat([res['enc'], res['dec']], dim=-1)
得到:
.
这正好对应:
BackEnd(in_dim=2048+1024)
所以代码维度链现在完全对上了。
- mem_eff=True 为什么只保存部分层
前端调用:
decode_patch_tokens( ..., mem_eff=True )
aggregator 中:
save_id = [4, 11, 17, 23]
如果当前 block 不在这些位置:
if mem_eff and (block_id not in save_id): output_list.append(None) else: output_list.append(concat_inter)
因此只保存第:
4,11,17,23
组的 frame/global 拼接特征。
这些通常是 heads 使用的多尺度/多深度中间特征。
但注意,虽然没有把每层输出全部保存到 output_list,所有 24 组 attention 仍然实际执行,48 次 zero-conv 注入也仍然实际发生。
None 只是节省中间输出存储,不代表跳过该层。
- block_id * 2 为什么成立
默认:
aa_order = ["frame", "global"] aa_block_size = 1
每个 block_id 恰好有两次 attention:
frame; global。
因此映射为:
frame zero-conv index=2l, global zero-conv index=2l+1.
对于 l=0,…,23,最大索引为:
.
刚好需要 48 个 zero-conv。
不过这段实现隐含依赖:
aa_order 必须仍是两个元素; 顺序最好是 ["frame","global"]; aa_block_size=1。
若以后把:
aa_block_size=2
或增加其他 attention 类型,block_id*2 的映射可能不再与真实 block 数严格对应。
因此这是一个比较硬编码的设计。
- 一个完整的 shape 示例
假设:
输入图像:
patch size 为 14,所以:
特殊 token 数:
于是:
.
完整 token:
其中 patch 部分:
体素特征:
经过 zero-conv:
.
经过:
permute(0,2,3,1)
变为:
.
再:
flatten(1,2)
变为:
.
因此可以逐元素加到:
. 20. 最终精确流程
现在可以把 AMB3R 的零卷积融合写成:
后端输出
入口注入 第 l 个 alternating block
其中:
l=0,…,23.
最终:
21. 最值得注意的本质
这不是把 voxel feature 和 token concat:
也不是 cross-attention:
而是简单的条件残差:
它的优势是:
不改变 token 维度; 不修改原 attention block 结构; 不需要新增 KV 序列; 不增加 attention 长度; zero-init 保证初始等价于原 VGGT; 每层独立投影,适配不同 Transformer 深度; 3D 信息可以通过原 frame/global attention 传播。
因此 AMB3R 的融合方式可以概括为:
和前面这些模型的关系
AMB3R 和你前面读过的方法可以放在一条线上看:
| 方法 | 主要输出 | 关键特点 |
|---|---|---|
| DUSt3R | pointmap | 直接回归 3D 对应点 |
| MASt3R | pointmap + descriptor | 在 3D grounding 上补足匹配能力 |
| VGGT | camera + depth + pointmap + tracks | 多任务前馈几何推理 |
| Pi3 | permutation-equivariant 几何输出 | 去掉 reference view 偏置 |
| Depth Anything 3 | depth + ray | 用更简洁的表示统一 any-view 几何 |
| AMB3R | metric-scale reconstruction + backend | 用后端把前端几何组织成全局场景 |
AMB3R 的位置比较像:
前端几何回归已经能做得不错
但全局组织和 metric-scale 稳定性还不够
-> backend 补这一层
VO:AMB3R 怎么做 visual odometry
项目页明确给了 AMB3R-VO。
它的核心思路是:
一段图像序列
-> 逐帧前馈几何预测
-> backend 维护一个 keyframe memory
-> 当前帧与 keyframe 对齐
-> 估计相对位姿和尺度
-> 输出轨迹
更具体地说,官方描述里提到:
- 维护一个 keyframe memory。
- 新 frame 与 memory 中的 keyframe 做配准。
- 用预测的 relative pose 和 scale,把局部 3D 对齐到全局。
- 将局部地图以加权平均的方式融合成全局状态。
⭐ AMB3R-VO 为什么还要 keyframe memory?
因为 VO 不是单帧深度估计,而是连续跟踪相机运动。
如果没有 memory,当前帧只能看到“这一帧像什么”,很难持续追踪:
我现在在哪
我上一帧看到的结构还在不在
这一帧和前面哪些帧是同一个场景
keyframe memory 让 backend 能保留过去的场景状态。
这类设计的作用很直接:
短时局部预测
-> 长时全局记忆
-> 更稳的位姿估计
所以 AMB3R-VO 不只是“每帧单独预测 pose”,而是一个带场景状态的在线重建问题。
传统扩展法的痛点
要把已有方法扩展为视觉里程计(VO)或 SLAM 系统,最直接的做法是按顺序构建有重叠部分的子地图(submaps),随后用 Kabsch-Umeyama 等算法估算子地图之间的相对位姿变换和尺度差异。
痛点在于:这种对齐方式会引入不可忽视的误差,所以现有方法通常不得不依赖极其耗时的“基于优化的后端(optimisation-based backend)”来做误差修正。
核心洞察:预测天然在同一坐标系
作者指出,传统做法忽略了 pointmap 方法的一个强大先验优势:模型的预测结果总是自动表达在参考帧(即第一帧)的坐标系中,只是存在一个未知的中值尺度(median scale)。
既然所有预测天然就在同一个坐标系下,专门估算位姿变换来做“坐标系对齐”其实完全没有必要。
因此作者在公式 (10) 中把网络的处理逻辑重写为流式/记忆网络形式:在流式视频输入下,网络可以被视为一种特殊的记忆网络(special memory network),利用非因果注意力(non-causal attention)机制,在处理新画面的同时更新之前的预测结果。系统只需要精心挑选一些关键帧(keyframes)作为记忆存储起来,就能直接在 VO 模式下高效运行,彻底摆脱繁重的坐标系对齐和后端优化步骤。
关键帧选择(公式 11)
为了让模型在处理长视频流时既能记住过去,又不被海量数据撑爆内存,需要挑选最具代表性的关键帧作为记忆。先定义帧 与 之间的“位姿距离(pose distance)”:
- 前半项利用旋转矩阵的迹 计算两帧的旋转角度差异;
- 后半项计算归一化平移向量 之间的欧氏距离, 用于平衡两项。
筛选策略:设定最小位姿距离阈值 ;在每个包含 帧的新建图窗口中,剔除与现有关键帧太近的画面,然后挑选置信度(confidence)最高的帧作为新的关键帧加入“记忆库”。
尺度对齐估计(公式 12)
每个新窗口预测出的 pointmap 都基于该窗口内部的相对尺度,直接拼接会出现大小错位。因此系统使用 ROE 求解器,通过对比当前窗口的关键帧点图 与全局地图中已有的对应点图 ,计算相对的缩放比例因子:
用 把新数据缩放到与全局地图统一的尺寸。
置信度加权滑动平均融合(公式 13–17)
算出统一尺度后,系统以预测置信度 为权重做滑动平均(running average),把新窗口的预测(带上标 的变量)平滑更新进全局地图状态:
- 公式 13(更新 3D 点图):新点图乘以尺度因子后按置信度权重与旧点图混合:
- 公式 14(更新物理尺度因子 ,用于恢复真实世界绝对尺寸):
- 公式 15(更新相机平移):
- 公式 16(更新相机旋转姿态):旋转用四元数表示,直接相加会产生不符合物理规律的形变,因此用球面线性插值(slerp)平滑过渡:
- 公式 17(更新全局置信度):
被多次观测到的可靠区域置信度越来越高,权重也随之增加。
长期运行:关键帧管理、坐标对齐与鲁棒估计
为了保证 VO 模式长期运行的稳定性、准确性和计算效率,AMB3R 还设计了三个高级策略。
主动关键帧管理(active keyframe management):活跃关键帧数量达到上限 时,从所有历史关键帧中重新采样出 个:
- 最新的一帧关键帧永久保留;
- 挑选 个与最新关键帧“位姿距离”最近的帧;
- 闭环检测鼓励机制:设置反向搜索窗口 ,迭代地把符合该窗口的最早关键帧加入(总计 个),促使模型利用早期画面形成闭环(loop)以修正误差;
- 选取与现有关键帧总位姿距离最小的那一帧作为新的第一帧;
- 安全限制:如果活跃关键帧之间的最大位姿距离超过 ,强制重新采样,确保模型处理的场景范围始终落在训练数据的分布范围内,防止模型崩溃。
坐标对齐(公式 18):重采样后记忆库中的“第一帧”可能变了,局部预测与全局地图之间的坐标系不再匹配。与其用复杂算法联合估算局部与全局之间的相对变换和尺度,AMB3R 先把全局地图逆向映射到局部地图的坐标系中:
其中 代表旋转和平移。完成映射后,先估算尺度,再缩放相机位姿,最后用加权平均把局部地图重新映射回全局参考系并融合。这一系列操作避开了计算量庞大的显式 Kabsch-Umeyama 对齐算法。
鲁棒估计(robust estimation):消耗算力的 backend 并不是时刻运行的。只有当前端预测的置信度低于预设阈值时,backend 才被唤醒参与计算。运行时综合考量“置信度”和前后端输出 pointmap 的“自洽得分(self-consistency score)”,据此决定完全采纳后端预测,还是把前后端预测进行混合(blend),保证连续系统在各种复杂环境下的鲁棒表现。
SfM:AMB3R 怎么做 structure from motion
项目页还给了 AMB3R-SfM。
它的流程大致是:
image clustering
-> coarse registration
-> global mapping
这说明它并不只想做短序列 VO,而是要把方法扩展到更一般的多图像重建。
其中:
- clustering:先把彼此可能相关的图像分组。
- coarse registration:先做粗配准,得到初始几何。
- global mapping:再把所有局部结果统一到一个全局地图里。
⭐ 为什么 SfM 还要分 clustering、coarse registration、global mapping?
因为真实图像集合往往不是一个理想的视频流。
它可能有:
视角跨度大
重叠稀疏
图像数量多
场景尺度变化大
局部可见性差
直接把所有图像一次性硬塞进一个全局优化,代价很高,也不稳定。
所以先分组,再粗配准,再全局映射,是一种更稳的工程化做法:
先解决局部可对齐的部分
再把局部块拼成全局场景
这和传统 SfM 的层次化思想是一致的,只是 AMB3R 用的是前馈几何网络加 backend,而不是手工特征 + bundle adjustment 的老链条。
为什么需要分治
把 AMB3R 扩展为大规模、无序图像集上的前馈 SfM 系统时有两个挑战:
- 泛化能力受限:传统前馈 pointmap 模型通常受限于训练时见过的尺度和运动范围,难以直接应对大规模开阔场景。
- 计算复杂度爆炸:某些已有方法(type-c)处理多张图片时复杂度与图像数量呈平方关系(),图像数量庞大时直接处理极其低效。
因此 AMB3R 采用“分而治之”的策略:先把海量无序图像划分成小型图像簇(clusters),再用增量式 SfM 方法逐步求解,全程不需要传统耗时优化。
图像聚类
系统需要自动把拍摄位置和视角相近的图片归为一类:
- 对每一张图像 提取特征描述子 ,并应用特征白化(feature whitening);
- 计算所有图像两两之间的特征距离矩阵 ;
- 结合迭代的拆分与合并策略,应用最远点采样(FPS)把图集划分成多个小簇,每个簇的图像数量控制在 到 之间。
粗略配准与增量建图
分好簇后,系统像拼图一样把这些簇逐步拼接到全局 3D 地图:
- 初始化:挑选预测置信度最高的图像簇作为起始点初始化全局地图,并维护一个全局关键帧列表(位姿距离阈值 )。
- 增量构建:在未建图的簇中找出与当前全局关键帧特征距离最近的 top-k=5 个簇,与现有关键帧做配准映射;比较配准结果后,选取置信度最高的一个簇正式合并进全局地图。
- 内存管理:当全局关键帧数量超过上限 时,触发类似于 VO 章节中的分区/重采样机制,保证计算效率和内存安全(论文原文此处被截断,属于合理推断)。
为什么这篇论文值得看
AMB3R 的价值不只是“又一个重建网络”,而是它把两个方向合起来了:
- 前馈 3D 几何预测已经很强。
- 但没有 backend 的全局组织,很多场景还是不够稳。
它试图说明:
几何前馈网络 + 3D backend
可能比只堆一个更大的 2D/3D transformer 更实用
这对你前面做的 pointmap、VGGT、Pi3、DA3 笔记都能直接接上:
- DUSt3R / MASt3R 说明 pairwise / descriptor 级几何可以学。
- VGGT / Pi3 说明多视图全局推理可以学。
- DA3 说明 depth-ray 表示可以统一 any-view 几何。
- AMB3R 进一步说明,全局场景组织本身也是关键模块。
你读这篇论文时最该盯住的点
- 前端到底输出什么几何量。
- backend 的 3D 表示是怎么压缩和更新的。
- VO 里 keyframe memory 怎么维持稳定轨迹。
- SfM 里 coarse registration 和 global mapping 怎么衔接。
- metric-scale 是怎么被保住的。
官方资料
- 项目主页:AMB3R Project Page
- 代码:HengyiWang/amb3r
- 论文:AMB3R: Accurate Feed-forward Metric-scale 3D Reconstruction with Backend