跳到主要内容

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。

模型输入与输出

输入是一组图像:

I={Ii}i=1N\mathcal{I}=\{\mathbf{I}_i\}_{i=1}^{N}

每张图像:

IiRH×W×3\mathbf{I}_i\in\mathbb{R}^{H\times W\times 3}

在一些使用场景里,还可能输入相机先验或多视图帧序列。

模型输出的核心是三类量:

输出含义用途
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 全局空间推理结合起来,一次前向大致是:

  1. 前端(Front-end):由一个 ViT Encoder + ViT Decoder 组成。图中的“锁”图标表示预训练权重被冻结(frozen),目的是保留先验知识并降低训练成本。输入多视角图像后,前端初步预测出 pointmap 和几何特征。
  2. 稀疏体素融合:把前端提取的 2D 几何特征投射并融合为 3D 的稀疏体素。
  3. 序列化(S):把 3D 稀疏体素展平、序列化成 1D 数据序列。
  4. 全局处理(Transformer):1D 序列送入 Transformer 做全局 3D 空间推理,输出增强后的特征序列。
  5. 反序列化(S1S^{-1}:把增强后的 1D 序列还原回 3D 稀疏体素。
  6. 特征回流:用 KNN 插值把 3D 体素特征映射回 2D 像素级特征,再通过零卷积层(zero conv,初始权重为零,保证初始训练稳定性)注入回冻结的 ViT Decoder。
  7. 最终输出:融合 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

这个设计的关键点是:

  1. 3D 空间里的相邻关系比 2D token 更贴近几何本质。
  2. 稀疏体表示比“所有像素都做一次全连接交互”更省。
  3. 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 序列 {Pt(1)}t=1T\{P_t^{(1)}\}_{t=1}^{T} 以及对应的几何特征 {Gt}t=1T\{G_t\}_{t=1}^{T}

第 1 步:稀疏体素化与特征聚合(voxelization)

先对齐 pointmap 与几何特征的分辨率,再构建一个稀疏体素网格(sparse voxel grid)VV。为了适应不同场景的尺度,模型在归一化空间中把体素大小设为 0.01,场景缩放后体素数量随之变化,相当于自适应的体素分辨率。

每个体素的特征 {Hi}i=1N\{H_i\}_{i=1}^{N} 取落在该体素内的所有像素特征的平均(公式 6):

Hi=1Pi(t,u)PiGt[u]H_i = \frac{1}{|P_i|} \sum_{(t,u)\in P_i} G_t[u]

其中 PiP_i 是所有 3D 坐标落在体素 ViV_i 内的像素点 (t,u)(t,u) 的集合,uu 是像素坐标,Pi|P_i| 是该集合的大小。

第 2 步:序列化与 Transformer 全局处理(serialization & processing)

Transformer 更擅长处理 1D 序列而不是 3D 网格,所以稀疏体素网格通过空间填充曲线(space-filling curves)被序列化(记为 SS)成 1D 特征序列,送进 Transformer 处理后再反序列化(S1S^{-1})回 3D 体素空间(公式 7):

{H^i}i=1N=(S1fθS)({Hi}i=1N)\{\hat{H}_i\}_{i=1}^{N} = (S^{-1} \circ f_\theta \circ S)(\{H_i\}_{i=1}^{N})

其中的 fθf_\theta 通过 Point Transformer v3 实现,这是一种类似 U-Net 的架构,能够高效处理几何信息。

第 3 步:特征插值与零卷积回流(interpolation & fusion)

把体素特征转回逐点(per-point)特征时,使用 K 近邻(KNN)插值(公式 8):

G~t[u]=KNN(Pt(1)[u],{H^i}i=1N)\tilde{G}_t[u] = \mathrm{KNN}\left(P_t^{(1)}[u], \{\hat{H}_i\}_{i=1}^{N}\right)

插值得到的特征再通过零卷积(zero convolution)融合回解码器的每一层。零卷积初始权重为零,保证开始训练时不会破坏冻结前端的预测。

设计优势:这种融合方式允许模型复用前端已经学到的 attention 和 confidence 函数,从而大幅降低训练成本。

⭐ 几何特征 G_t 是从 encoder / decoder 得出的吗?为什么能和 pointmap 逐点对应?

几何特征 G_t 是从 encoder / decoder 得出的吗?encoder 之后特征不应该是和 patch 对齐的吗?

是的,GtG_t 确实是从视觉特征和 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 PtP_t,Decoder 接收 Encoder 输出的 patch 特征,通过一系列上采样模块(特征金字塔、反卷积或双线性插值),把低分辨率特征图逐步放大、恢复分辨率。

3. 结论

经过 Decoder 处理后,特征图的空间分辨率被拉伸到与原输入图像(或目标网格)相同的大小,每个“格点”从代表一个 patch 变成代表一个具体像素。因此 Decoder 输出的几何特征 GtG_t 能与 pointmap PtP_t 逐像素 1:1 精确对应,这也是数学表达中可以用统一的像素坐标 uu 同时索引 pointmap 和几何特征(Pt[u]P_t[u]Gt[u]G_t[u])的原因。

度量尺度:median 归一化与尺度头

⭐ VGGT 输出的 pointmap 为什么按所有帧的“中值距离”归一化?

什么是“VGGT 输出的 pointmap 是根据所有帧的中值距离进行归一化处理的”?

这句话描述的是 3D 视觉基础模型(如 VGGT)在输出预测结果时,为了让模型训练更稳定而采用的“相对比例缩放”策略。拆开来看:

1. 什么是 pointmap?

普通彩色图像中每个像素点存储的是颜色值;而 pointmap 中每个像素点存储的是该点在 3D 空间中的三维坐标 (X,Y,Z)(X, Y, Z)。可以把它理解为一张包含丰富 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)展开说明了尺度头的具体做法:

  1. 动机:VGGT 输出的 pointmap 按所有帧的中值距离归一化,作者认为这意味着 VGGT 冻结的特征网络中已经隐式包含了物理尺度的线索,因此决定添加一个尺度头(scale head),专门从这些特征中恢复真实的物理尺度。
  2. 初试失败:最直接的思路是利用所有帧的中间特征,通过 ROE 求解器直接回归预测值与真实值(ground truth)之间的“全局尺度差异”。作者发现这种方法极难训练且容易过拟合:一旦输入帧的组合方式或输入顺序发生变化,全局尺度差异就会随之改变,模型无所适从。
  3. 最终方案:把“全局依赖”降解为“单帧处理”。对每一帧,先找到预测深度处于中位数位置的像素,专门回归该像素的真实物理尺度对数深度(metric log depth)。这种策略提取的是每一帧自身的内在属性,摆脱了对所有帧全局预测的强依赖,训练容易得多;同时引入 Decoder 的深度特征提供额外引导。
  4. 推理阶段:先估算每一帧各自的尺度,然后取所有帧尺度的中位数,用这个中位数值把最终的 3D 重建结果对齐到真实的物理空间尺度。

训练:损失函数、ROE 尺度对齐与低成本

总损失(公式 9):AMB3R 的训练目标是三个误差之和:

L=Ldepth+Lpointmap+Lcamera\mathcal{L} = \mathcal{L}_{\mathrm{depth}} + \mathcal{L}_{\mathrm{pointmap}} + \mathcal{L}_{\mathrm{camera}}

即深度损失(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 与几何特征融合成体素特征

你这段代码正好对应论文里:

{Pt(1),Gt}t=1T{Hi}i=1N\{P_t^{(1)}, G_t\}_{t=1}^{T} \longrightarrow \{H_i\}_{i=1}^{N} 也就是把每张图上的 point map + 几何特征,融合成稀疏三维体素特征。

  1. 前端到底给后端什么

在 get_voxel_feat() 中:

feat = torch.cat([res['enc'], res['dec']], dim=-1) pts = res['world_points']

这里:

pts=Pt(1)pts = P_t^{(1)} 是前端预测的世界/公共参考坐标系 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)

对应:

Gt[u]G_t[u] 也就是每个图像 patch 的几何特征。代码把 encoder 特征和 decoder 特征拼接起来:

Gt=Concat(Gtenc,Gtdec)G_t = \mathrm{Concat}(G_t^{\mathrm{enc}}, G_t^{\mathrm{dec}}) 初始化中可以看到后端输入维度:

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 模型中。

  1. 为什么需要调整 point map 和 feature 的分辨率

原始特征通常是 DINO/VGGT patch token,例如 patch size 为 14,所以:

feat = feat.view( Bs, T, H // 14, W // 14, feat.shape[-1] )

说明 encoder/decoder 特征原本处于:

H14×W14\frac{H}{14} \times \frac{W}{14} 的 patch 网格上。

但是代码没有直接在这个低分辨率上体素化,而是先把 point map 调整到:

pts = self.resize_feat( pts, target_size=(H // 7, W // 7) )

因此后端实际处理的点数为每张图:

H7×W7\frac{H}{7} \times \frac{W}{7}.

随后特征也插值到相同大小:

feat = self.resize_feat( feat, target_size=(Hs, Ws) )

于是 point map 和 feature 一一对应:

Pt(1)[u]Gt[u],P_t^{(1)}[u] \leftrightarrow G_t[u],

二者形状分别为:

ptsRB×T×Hs×Ws×3, featRB×T×Hs×Ws×1024pts \in \mathbb{R}^{B \times T \times H_s \times W_s \times 3}, \ feat \in \mathbb{R}^{B \times T \times H_s \times W_s \times 1024}.

代码中的分辨率对齐过程可以直接在 get_voxel_feat() 中看到。

  1. 多张图像如何被放到同一个三维空间

关键在于:

pts = res['world_points']

它不是每张图各自在相机坐标系中的 point map,而是已经处于统一公共坐标系中的点。

可以理解为:

Pt(1)[u]=Tt1Pt(t)[u]P_t^{(1)}[u] = T_{t \to 1} P_t^{(t)}[u].

其中:

Pt(t)P_t^{(t)}

:第 t 张图自身相机坐标系中的点; Tt1T_{t \to 1}

:从第 t 个相机坐标系到参考系 1 的变换; Pt(1)P_t^{(1)}

:统一参考坐标系中的点。

因此,来自不同图像的点可以直接比较:

Pa(1)[ua]Pb(1)[ub]P_a^{(1)}[u_a] - P_b^{(1)}[u_b].

如果距离很近,它们就可能属于同一个体素。

这就是 AMB3R 能够把不同视图显式融合的前提。

  1. 后端首先把所有图像和像素展平

在 BackEnd.forward() 中:

if len(feats.shape) != 3: feats = feats.reshape(Bs, -1, C) pts = pts.reshape(Bs, -1, 3)

原来:

(B,T,Hs,Ws,C)(B, T, H_s, W_s, C)

会变成:

(B,N,C),(B, N, C),

其中:

N=THsWsN = T H_s W_s.

对应点坐标则变成:

(B,N,3).

接下来又展平 batch:

pts = pts.reshape(-1, 3) feats = feats.reshape(-1, C)

于是:

ptsRBN×3, featsRBN×Cpts \in \mathbb{R}^{BN \times 3}, \ feats \in \mathbb{R}^{BN \times C}.

同时构造每个点属于哪个 batch:

batch_ids = torch.arange(Bs).repeat_interleave(N)

所以每个点实际携带:

(b,x,y,z).

这很重要,因为不同 batch 中即使坐标相同,也不能融合到同一个体素。

  1. 如何确定一个三维点属于哪个体素

核心代码是:

voxel_indices = get_vox_indices( points, batch_ids, voxel_size, bounding_boxes, shift=False, cat_batch_ids=True )

概念上,体素索引通常计算为:

qj=pjov,q_j = \left\lfloor \frac{p_j - o}{v} \right\rfloor,

其中:

pj=(xj,yj,zj)p_j = (x_j, y_j, z_j):点坐标; v:体素大小; o:体素网格原点; qj=(qx,qy,qz)q_j = (q_x, q_y, q_z):整数体素坐标。

在默认:

voxel_resolutions=[0.01]

下:

v=0.01v = 0.01.

例如两个点:

Pa=(0.101,0.205,1.003),Pb=(0.107,0.209,1.008),P_a = (0.101, 0.205, 1.003), \quad P_b = (0.107, 0.209, 1.008),

若按 0.01 划分,则它们可能得到相同体素坐标:

(10,20,100).

因此会被判断为落入同一个体素。

  1. bounding_boxes 的作用是什么

代码首先计算每个 batch 点云的包围盒:

bounding_boxes[:, 0, :] = pts.min(dim=1).values bounding_boxes[:, 1, :] = pts.max(dim=1).values

即:

Bb=[pbmin,pbmax]B_b = [p_b^{\min}, p_b^{\max}].

理论上可以以包围盒最小点作为体素网格原点:

q=ppminvq = \left\lfloor \frac{p - p_{\min}}{v} \right\rfloor.

不过这里调用:

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:]

也就是将体素坐标恢复为连续坐标:

ci=vqic_i = v q_i.

当 interp_v2=True 时,再加包围盒最小坐标:

ci=vqi+pbminc_i = v q_i + p_b^{\min}. 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() )

它把:

(b,qx,qy,qz)(b, q_x, q_y, q_z)

编码成一个整数 hash:

h=b48qx32qy16qzh = b \ll 48 \mid q_x \ll 32 \mid q_y \ll 16 \mid q_z.

其目的不是学习特征,而是方便快速判断:

哪些点具有完全相同的 batch ID 和体素坐标。

例如:

(0,10,20,30)

和:

(0,10,20,30)

会产生相同 hash。

但:

(1,10,20,30)

因为 batch ID 不同,会得到不同 hash,因此不会跨 batch 融合。

  1. 公式 6 在代码中具体是哪一行

论文公式:

Hi=1Pi(t,u)PiGt[u]H_i = \frac{1}{|P_i|} \sum_{(t,u)\in P_i} G_t[u]

对应代码:

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]

于是:

H0=G1,H1=G3,H2=G0+G2+G43H_0 = G_1, \quad H_1 = G_3, \quad H_2 = \frac{G_0 + G_2 + G_4}{3}

这就是公式 6 的完整实现。文件中也明确写明 mean_by_voxel() 返回每个体素内特征的平均值,并使用 scatter_mean 实现。

  1. 为什么这种平均能够融合不同视图

展平之前,点的索引实际上是:

(t,h,w).

但展平之后,体素化不再关心它来自:

哪一张图; 哪一个像素; 哪一个 patch。

它只关心:

Pt(1)[h,w]P_t^{(1)}[h, w]

落在哪个三维体素。

假设三张图像分别观察到同一真实三维点:

P1(1)[u1]P2(1)[u2]P3(1)[u3]P_1^{(1)}[u_1] \approx P_2^{(1)}[u_2] \approx P_3^{(1)}[u_3].

ViV_i,则:

Hi=G1[u1]+G2[u2]+G3[u3]3H_i = \frac{G_1[u_1] + G_2[u_2] + G_3[u_3]}{3}

因此不同视图的信息被显式合并。

这和普通 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 ]

对于属于同一体素的多个点,它需要保留一份体素索引:

(qx,qy,qz)(q_x, q_y, q_z).

因为同一体素中的点具有相同整数索引,所以取第一次出现的那个即可。

注意:

这里只是体素索引取第一次出现值,体素特征仍然是所有点的平均,并不是只使用第一个点的特征。

最终返回:

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:HiR1024H_i \in \mathbb{R}^{1024}

即每个体素的平均特征。

grid_coord:qi=(qx,qy,qz)q_i = (q_x, q_y, q_z)

整数体素坐标,主要用于空间序列化、稀疏结构和邻域划分。

coord:ciR3c_i \in \mathbb{R}^3

连续空间坐标,默认约为:

ci=vqic_i = v q_i. batch

体素属于哪个 batch:

bib_i.

接着输入:

point_out = self.point_transformer(data_dict)

Point Transformer V3 会在稀疏三维体素之间传播信息。该处理流程在后端 forward() 中完整展开。

  1. 体素 Transformer 输出为什么还要插值回原始点

Point Transformer 输出的是每个体素的特征:

H^i\hat{H}_i.

但是前端 decoder 仍然需要每个图像位置的二维特征,因此代码调用:

interpolated_feats = self.voxel_to_point_interpolation( point_out, pts, chunk_size )

对于每个原始 point-map 点 pjp_j,寻找最近的 K=16 个体素:

knn = knn_points( original_pts, voxel_coords_padded, K=K_interp )

初始化里:

k_neighbors=16

所以:

N16(pj)N_{16}(p_j)

表示点 pjp_j 附近的 16 个体素。

然后采用逆距离加权:

weights_chunk = 1.0 / (dists_chunk + 1e-8) weights_chunk = weights_chunk / weights_chunk.sum(...)

数学上:

wji=1/(dji+ϵ)kN(j)1/(djk+ϵ)w_{ji} = \frac{1/(d_{ji}+\epsilon)}{\sum_{k \in N(j)} 1/(d_{jk}+\epsilon)}

最后:

G~j=iN16(j)wjiH^i\tilde{G}_j = \sum_{i \in N_{16}(j)} w_{ji} \hat{H}_i

因此体素特征重新回到每个原始 point-map 点上。KNN 插值、逆距离权重和加权求和分别对应代码中的 knn_points、权重归一化和 weighted sum。

  1. 如何重新恢复为每张图的二维特征图

体素插值结果最初是:

(B,THsWs,1024)(B, T H_s W_s, 1024).

随后:

voxel_feat_fine = voxel_feat[-1].reshape( Bs, T, Hs, Ws, -1 )

恢复为:

(B,T,Hs,Ws,1024)(B, T, H_s, W_s, 1024).

然后:

voxel_feat_aligned = self.backend.downsample(...)

将其从 H/7×W/7 下采样回前端 patch token 的:

H/14×W/14H/14 \times W/14.

这样它才可以与 VGGT 的 patch token 对齐。

  1. 3D 特征如何注入前端

先经过:

voxel_feat_aligned_vis = ( self.backend.zero_conv(voxel_feat_aligned)

  • self.backend.gate_scale )

可以写成:

Finject=αZ(Fvoxel),F_{\mathrm{inject}} = \alpha Z(F_{\mathrm{voxel}}),

其中:

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):

即:

P(0)P(1)P(2)P^{(0)} \to P^{(1)} \to P^{(2)} \to \cdots

不过默认:

iters=1

也就是通常只运行一次后端修正。

  1. 用一个极简例子理解

假设两张图,各自只有三个像素点:

P1={A1,B1,C1},P2={A2,B2,D2}P_1 = \{A_1, B_1, C_1\}, \quad P_2 = \{A_2, B_2, D_2\}

其中:

A1,A2A_1, A_2 是同一个真实三维点,

B1,B2B_1, B_2 也是同一个真实三维点。

体素化后可能得到:

voxel 0: A1, A2 voxel 1: B1, B2 voxel 2: C1 voxel 3: D2

那么:

H0=G(A1)+G(A2)2,H_0 = \frac{G(A_1) + G(A_2)}{2}, H1=G(B1)+G(B2)2,H_1 = \frac{G(B_1) + G(B_2)}{2}, H2=G(C1),H3=G(D2)H_2 = G(C_1), \quad H_3 = G(D_2)

经过 Point Transformer 后:

HiH^iH_i \to \hat{H}_i.

再插值回每个点:

A1 ← 附近体素特征 A2 ← 附近体素特征 B1 ← 附近体素特征 B2 ← 附近体素特征

由于 A1,A2A_1, A_2 使用了同一个融合体素,两张图对点 A 的预测会更容易保持一致。

  1. 一个很重要的细节:体素化不会直接平均点坐标

这段实现中平均的是:

voxel_feats = scatter_mean(feats, ...)

即:

Hi=mean(Gj)H_i = \mathrm{mean}(G_j).

它没有写:

voxel_points = scatter_mean(points, ...)

所以体素代表位置不是体素内所有点的几何中心,而是根据整数体素索引恢复出的规则网格坐标:

coord = voxel_size * vox_id[:, 1:]

即近似:

ci=vqic_i = v q_i.

所以这里的设计是:

特征:来自同体素观测的均值; 位置:规则体素位置; 原始连续点:只在最后 KNN 插值时作为查询位置使用。 17. 体素大小 0.01 为什么能够“自适应”

代码本身只是设置:

voxel_resolutions=[0.01]

它并不会自动知道 0.01 米、0.01 厘米还是 0.01 个房间。

所谓自适应的前提是:输入 point map 已经被归一化到相似尺度。

假设每个场景都经过归一化,使典型深度或点云尺度大约为 1,那么:

v=0.01v = 0.01

表示场景典型尺度的约百分之一。

例如:

小场景归一化后仍约为 1; 大场景归一化后也约为 1;

于是相同的 0.01 对不同原始尺度场景具有相似的相对分辨率。

因此更准确的说法是:

0.01本身不是自适应;“场景尺度归一化+固定0.01”共同实现相对尺度上的自适应。

你上传文本末尾也明确描述了:输入是 point-map 序列及对应几何特征,系统对齐分辨率,在归一化空间中构建稀疏体素网格,并平均同一体素内的特征。

  1. 完整数据流总结

可以把代码压缩成下面这一条链:

{Pt(1),Gtenc,Gtdec}t=1T\{P_t^{(1)}, G_t^{\mathrm{enc}}, G_t^{\mathrm{dec}}\}_{t=1}^{T} \downarrow Gt=MLP([Gtenc,Gtdec])G_t = \mathrm{MLP}([G_t^{\mathrm{enc}}, G_t^{\mathrm{dec}}]) \downarrowPt,GtP_t, G_t 调整到相同分辨率 \downarrow 展平所有图像点 \downarrow qj=VoxelIndex(pj,v)q_j = \mathrm{VoxelIndex}(p_j, v) \downarrow Hi=1PijPiGjH_i = \frac{1}{|P_i|} \sum_{j \in P_i} G_j \downarrow H^i=PointTransformerV3(Hi,qi,ci)\hat{H}_i = \mathrm{PointTransformerV3}(H_i, q_i, c_i) \downarrow G~j=KNNInterp(pj,{ci,H^i})\tilde{G}_j = \mathrm{KNNInterp}(p_j, \{c_i, \hat{H}_i\}) \downarrow G~j\tilde{G}_j 恢复为多视图二维特征图 \downarrow Fpatch=Fpatch+αZ(G~)F_{\mathrm{patch}}' = F_{\mathrm{patch}} + \alpha Z(\tilde{G}) \downarrow 重新解码 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)”被序列化(序列化操作记为 S\mathcal{S})成 1D 的特征序列,接着交由 Transformer 处理,处理完后再反序列化(S1\mathcal{S}^{-1})回 3D 体素空间。公式表达为:{H^i}i=1N=(S1fθS)({Hi}i=1N)\{\hat{H}_i\}_{i=1}^N = (\mathcal{S}^{-1} \circ f_\theta \circ \mathcal{S})(\{H_i\}_{i=1}^N) 这部分是怎么实现的。

这部分在代码里不是“把 3D 坐标真正压成一个连续 1D 张量,再调用一次显式 decode() 恢复坐标”,而是通过三步实现:

3D 体素坐标→空间曲线编码并排序→按排序后的局部序列做 Attention→用 inverse permutation 恢复原体素顺序

也就是说,论文中的

S1fθSS^{-1} \circ f_\theta \circ S

在实际代码中,S1S^{-1} 主要是一个逆置换操作,不是调用你贴出的 decode(code)。

  1. 从 backend.py 进入 Point Transformer

在 backend.py 中,体素平均之后构造:

data_dict = { 'feat': feat, 'grid_coord': vox_id[:, 1:], 'coord': coord, 'batch': vox_id[:, 0], }

其中:

feat={Hi}i=1N\{H_i\}_{i=1}^{N}

是每个非空体素的平均特征;

grid_coord:qi=(qx,i,qy,i,qz,i)q_i = (q_{x,i}, q_{y,i}, q_{z,i})

是离散整数体素坐标;

batch:bib_i

表示体素属于哪个 batch。

然后调用:

point_out = self.point_transformer(data_dict)

因此 Point Transformer V3 的输入已经不是原始逐像素点,而是 AMB3R 体素化之后的 N 个稀疏体素。

  1. 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 处理 fθf_\theta 对应:

point = self.embedding(point) point = self.enc(point) point = self.dec(point)

代码明确在 forward() 中先构造 Point,然后 serialization、sparsify,再通过 embedding、encoder 和 decoder。

因此可以先写成:

{Hi,qi}i=1NSserialized Pointfθ{H^i}i=1N\{H_i, q_i\}_{i=1}^{N} \xrightarrow{S} \mathrm{serialized\ Point} \xrightarrow{f_\theta} \{\hat{H}_i\}_{i=1}^{N} 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:

x=x2x1x0,y=y2y1y0,z=z2z1z0x = x_2 x_1 x_0, \quad y = y_2 y_1 y_0, \quad z = z_2 z_1 z_0

编码后的 code 为:

x2y2z2x1y1z1x0y0z0x_2 y_2 z_2 \, x_1 y_1 z_1 \, x_0 y_0 z_0

你贴出的循环版代码:

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 位。

即:

Morton(x,y,z)=i=0d1[xi23i+2+yi23i+1+zi23i]\mathrm{Morton}(x,y,z) = \sum_{i=0}^{d-1} \left[ x_i 2^{3i+2} + y_i 2^{3i+1} + z_i 2^{3i} \right]

所以:

(x,y,z)Z3cZ(x,y,z) \in \mathbb{Z}^3 \longrightarrow c \in \mathbb{Z}.

这就是从三维离散位置到一维序列 key 的映射。

3.2 为什么还要编码 batch

default.py 中:

if batch is not None: batch = batch.long() code = batch << depth * 3 | code

所以完整 code 为:

ci=bi23d+CurveCode(qi)c_i = b_i \cdot 2^{3d} + \mathrm{CurveCode}(q_i).

这样可以确保:

batch 0 的全部体素排在一个编码区间; batch 1 的全部体素排在另一个区间; 不同样本不会混到同一个序列 patch 中。

例如 depth=16 时,三维位置占:

3×16=483 \times 16 = 48

个 bit,batch 被放在更高位。

  1. depth 是什么意思

代码:

if depth is None: depth = int( torch.abs(self.grid_coord).max() ).bit_length()

它根据最大体素坐标确定编码需要多少 bit。

例如最大体素索引是:

1000,

因为:

1000<2101000 < 2^{10},

所以:

depth=10depth = 10.

于是每个坐标轴可表示: 021010 \sim 2^{10} - 1.

三维 Morton/Hilbert code 使用:

3×10=303 \times 10 = 30

个位置位。

代码同时限制:

assert depth <= 16

因为当前编码最多为三维坐标保留 48 bit。serialization() 会自动计算序列化深度,并检查总 bit 数和 depth <= 16 的限制。

  1. 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.

于是序列化后的特征为:

[H1,H3,H2,H0][H_1, H_3, H_2, H_0]

数学上:

S(H)j=Hπ(j),S(H)_j = H_{\pi(j)},

其中:

π=argsort(c).

注意:code 本身只是每个点的一维编号,真正把特征排成序列的是:

order = torch.argsort(code)

后续再用这个 order 索引 point.feat。

  1. 为什么还要保存 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

满足:

π1(π(j))=j\pi^{-1}(\pi(j)) = j.

代码将这些结果保存为:

self["serialized_code"] = code self["serialized_order"] = order self["serialized_inverse"] = inverse

这三者就是后续完成 S 和 S −1 的核心数据。

  1. 四条空间填充曲线怎么使用

默认有四种:

( "z", "z-trans", "hilbert", "hilbert-trans" )

分别为:

z:直接编码 (x,y,z)(x,y,z)。 z-trans

先交换 x,y:

grid_coord[:, [1, 0, 2]]

即:

(y,x,z)(y,x,z)

再做 Morton 编码。

hilbert

对:

(x,y,z)(x,y,z)

做三维 Hilbert 编码。

hilbert-trans

对:

(y,x,z)(y,x,z)

做 Hilbert 编码。

它们产生四种不同的一维邻域划分:

πz,πz-trans,πh,πh-trans\pi_z, \pi_{z\text{-trans}}, \pi_h, \pi_{h\text{-trans}}.

这样可以缓解单一空间曲线的局限:在某一条曲线上被切断的三维邻居,在另一条曲线中可能变成邻近序列元素。

  1. 为什么要 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 的目的是避免网络始终让固定层绑定固定曲线,增加训练中的空间顺序多样性。

  1. Transformer 中的 S 真正发生在哪里

在 SerializedAttention.forward() 中:

order = point.serialized_order[ self.order_index ][pad]

这里选择当前 block 使用的空间曲线顺序。

然后:

qkv = self.qkv(point.feat)[order]

这一步才真正执行特征序列化。

原始:

F=[H0,H1,,HN1]F = [H_0, H_1, \ldots, H_{N-1}]

经过:

point.feat[order]

变成:

Fseq=[Hπ(0),,Hπ(N1)]F_{\mathrm{seq}} = [H_{\pi(0)}, \ldots, H_{\pi(N-1)}]

因此论文中的:

S({Hi})S(\{H_i\})

在代码中最核心的对应是:

point.feat[order]

而不是单独的 encode() 函数。

encode() 只是产生排序依据;argsort 得到置换;真正重排特征是在 attention 里用 order 索引。

  1. 不是整条一维序列做全局 Attention,而是切成 patch

代码默认:

patch_size = 64

排好序后,特征被按连续 64 个体素划分:

[Hπ(0),,Hπ(63)],[Hπ(64),,Hπ(127)],[H_{\pi(0)}, \ldots, H_{\pi(63)}], \quad [H_{\pi(64)}, \ldots, H_{\pi(127)}], \ldots

以此类推。

代码:

q, k, v = ( qkv .reshape(-1, K, 3, H, C // H) .permute(2, 0, 3, 1, 4) .unbind(dim=0) )

其中:

K=patch_size=64K = \mathrm{patch\_size} = 64.

所以 attention 复杂度大致从全局的:

O(N2)O(NKK2)=O(NK)O(N^2) \longrightarrow O\left(\frac{N}{K} K^2\right) = O(NK)

空间曲线的作用就是尽量保证每个连续 patch 中的 64 个体素,在三维空间中也是局部邻近的。

这就是 Point Transformer V3 中所谓的 serialized local attention。

  1. fθf_\theta 是怎么处理序列的

是怎么处理序列的

非 Flash Attention 分支:

attn = (q * self.scale) @ k.transpose(-2, -1) attn = self.softmax(attn) feat = ( attn @ v ).transpose(1, 2).reshape(-1, C)

对应标准多头自注意力:

Q=FseqWQ,K=FseqWK,V=FseqWVQ = F_{\mathrm{seq}} W_Q, \quad K = F_{\mathrm{seq}} W_K, \quad V = F_{\mathrm{seq}} W_V A=softmax(QKd),Fseq=AVA = \mathrm{softmax}\left(\frac{QK^\top}{\sqrt{d}}\right), \quad F_{\mathrm{seq}}' = AV

只是每次 attention 限制在一个 serialized patch 内。

启用 Flash Attention 时:

flash_attn.flash_attn_varlen_qkvpacked_func(...)

数学含义相同,只是实现更高效。

所以:

fθf_\theta

并不只是一次 Attention,而是整个 Point Transformer U-Net,包括:

sparse convolution positional encoding; serialized attention; MLP; residual connection; pooling; unpooling; encoder-decoder 多尺度处理。 12. S1S^{-1} 到底在哪里

最关键代码:

inverse = unpad[ point.serialized_inverse[self.order_index] ]

然后 Attention 得到序列顺序下的输出后:

feat = feat[inverse]

这就是:

S1S^{-1}.

假设序列化后输出:

Fseq=[H^1,H^3,H^2,H^0]F_{\mathrm{seq}}' = [\hat{H}_1, \hat{H}_3, \hat{H}_2, \hat{H}_0]

使用:

inverse = [3, 0, 2, 1]

后:

Fseq[inverse]=[H^0,H^1,H^2,H^3]F_{\mathrm{seq}}'[\mathrm{inverse}] = [\hat{H}_0, \hat{H}_1, \hat{H}_2, \hat{H}_3]

恢复到原始体素排列。

代码正是先通过 serialized_order 排序,再通过 serialized_inverse 恢复,最后把结果写回 point.feat。

因此,论文公式在单个 Attention block 中非常直接地对应:

F^=Fseq[π1]=fθ(F[π])[π1]\hat{F} = F_{\mathrm{seq}}'[\pi^{-1}] = f_\theta(F[\pi])[\pi^{-1}]

也就是:

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]

所以这里的“反序列化”是:

恢复特征索引顺序,

而不是:

重新解码坐标.

这是理解代码与论文公式之间关系的关键。

  1. pad 和 unpad 为什么存在

不同 batch 的体素数量可能不是 patch_size=64 的整数倍。

例如一个样本有:

N=150N=150

个体素。

划分为 64 大小时,需要:

64+64+22.

最后 22 个不足一个 patch。

get_padding_and_inverse() 会把它补成:

64+64+64=19264+64+64=192.

但这里不是简单补零,而是重复前一个 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() 中构造。

  1. 一个完整的小例子

假设有 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].

于是:

S(H)=[H1,H5,H3,H2,H0,H4]S(H) = [H_1, H_5, H_3, H_2, H_0, H_4]

假设 patch_size=3,分成:

[H1,H5,H3],[H2,H0,H4][H_1, H_5, H_3], \quad [H_2, H_0, H_4]

每组三个体素内部做 Attention:

[H~1,H~5,H~3],[H~2,H~0,H~4][\tilde{H}_1, \tilde{H}_5, \tilde{H}_3], \quad [\tilde{H}_2, \tilde{H}_0, \tilde{H}_4]

拼起来仍是序列顺序:

Fseq=[H~1,H~5,H~3,H~2,H~0,H~4]F_{\mathrm{seq}}' = [\tilde{H}_1, \tilde{H}_5, \tilde{H}_3, \tilde{H}_2, \tilde{H}_0, \tilde{H}_4]

对应逆置换:

inverse=[4,0,3,2,5,1].

执行:

feat = feat[inverse]

得到:

[H~0,H~1,H~2,H~3,H~4,H~5][\tilde{H}_0, \tilde{H}_1, \tilde{H}_2, \tilde{H}_3, \tilde{H}_4, \tilde{H}_5]

这就是:

S1(fθ(S(H)))S^{-1}(f_\theta(S(H))) 16. 公式 7 不只是一次排序和 Attention

从论文的简化公式看:

{H^i}=(S1fθS)({Hi})\{\hat{H}_i\} = (S^{-1} \circ f_\theta \circ S)(\{H_i\})

容易误以为 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。

所以更精确地写:

fθ=DecoderEncoderEmbeddingf_\theta = \mathrm{Decoder} \circ \mathrm{Encoder} \circ \mathrm{Embedding}. 17. SerializedPooling 如何在空间曲线上降采样

Pooling 中:

pooling_depth = ( math.ceil(self.stride) - 1 ).bit_length()

code = ( point.serialized_code

pooling_depth * 3 )

若 stride 为 2,则:

pooling_depth=1\mathrm{pooling\_depth} = 1.

将 code 右移 3 bit:

ccoarse=cfine3c_{\mathrm{coarse}} = c_{\mathrm{fine}} \gg 3.

因为每个八叉树层级包含:

xi,yi,zix_i, y_i, z_i 三个位

三个位,右移 3 bit 相当于丢掉最低一级空间细节。

在坐标上等价于:

(qx,qy,qz)(qx2,qy2,qz2)(q_x, q_y, q_z) \to \left(\left\lfloor \frac{q_x}{2} \right\rfloor, \left\lfloor \frac{q_y}{2} \right\rfloor, \left\lfloor \frac{q_z}{2} \right\rfloor\right)

⌋).

代码也显式写成:

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() 中。

  1. 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]

把粗体素特征复制回所有对应的细体素。

数学上:

Fjup=Fcluster(j)coarseF_j^{\mathrm{up}} = F_{\mathrm{cluster}(j)}^{\mathrm{coarse}}.

再与 encoder skip feature 相加:

Fjout=WskipFjenc+WupFcluster(j)coarseF_j^{\mathrm{out}} = W_{\mathrm{skip}} F_j^{\mathrm{enc}} + W_{\mathrm{up}} F_{\mathrm{cluster}(j)}^{\mathrm{coarse}}.

这使最终输出重新具有最初的 N 个体素。

  1. 与 AMB3R backend.py 的完整对应关系

因此 AMB3R 里的整个后端可以写为:

第一步:像素特征合并到体素 Hi=1PijPiGj.H_i = \frac{1}{|P_i|} \sum_{j \in P_i} G_j. 第二步:构造 PTv3 输入 {Hi,qi,ci,bi}i=1N\{H_i, q_i, c_i, b_i\}_{i=1}^{N}

第三步:空间曲线编码 ci(m)=Encodem(qi,bi),c_i^{(m)} = \mathrm{Encode}_m(q_i, b_i),

其中:

m{z,zt,h,ht}m \in \{z, z_t, h, h_t\} 第四步:得到排序和逆排序 π πm=argsort(c(m)),πm1=inverse(πm)\pi_m = \mathrm{argsort}(c^{(m)}), \quad \pi_m^{-1} = \mathrm{inverse}(\pi_m) 第五步:按曲线顺序分 patch 做 Attention Fseq=H[πm],Fseq=AttentionPatch(Fseq)F_{\mathrm{seq}} = H[\pi_m], \quad F_{\mathrm{seq}}' = \mathrm{AttentionPatch}(F_{\mathrm{seq}}) 第六步:恢复原体素顺序 H^=Fseq[πm1]\hat{H} = F_{\mathrm{seq}}'[\pi_m^{-1}] 第七步:多尺度 encoder-decoder 反复执行 H^=PTv3(H)\hat{H} = \mathrm{PTv3}(H) 第八步:回到 AMB3R

backend.py 得到:

point_out.feat point_out.coord point_out.batch

然后通过 KNN 将:

H^i\hat{H}_i

插值回每个原始 point-map 点。

  1. 最重要的纠正

论文所说:

通过空间填充曲线被序列化成 1D 特征序列,Transformer 处理后再反序列化回 3D 体素空间。

在代码层面更准确的说法是:

体素本身从未消失,也没有真正从3D数据结构转换为普通完整1D序列;代码始终保存体素坐标,只根据空间曲线生成一个排序索引,临时将体素特征按该索引排列做局部Attention,然后用逆索引恢复原体素顺序。

所以:

SS

主要对应:

code = encode(grid_coord, batch) order = argsort(code) feat_seq = feat[order]

而:

S1S^{-1}

主要对应:

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 各层逐层注入

  1. 第一次前向:先得到原始 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 前向:

IXpatchVGGT decoderP(0),D(0),T(0),C(0)I \to X_{\mathrm{patch}} \to \mathrm{VGGT\ decoder} \to P^{(0)}, D^{(0)}, T^{(0)}, C^{(0)}

这里得到:

res["world_points"]; res["enc"]; res["dec"]; res["depth"]; res["pose"]; res["world_points_conf"]。

这些结果随后用于建立 3D backend。

  1. 3D 后端产生什么特征

get_voxel_feat() 最后得到:

voxel_feat_aligned voxel_feat_aligned_vis voxel_layer_list

三者的含义不同。

voxel_feat_aligned

这是未经 zero-conv 的 3D 增强特征:

F3DRBT×1024×Hp×WpF_{\mathrm{3D}} \in \mathbb{R}^{BT \times 1024 \times H_p \times W_p}

其中:

Hp=H14,Wp=W14H_p = \frac{H}{14}, \quad W_p = \frac{W}{14}

它已经经过:

体素平均; Point Transformer; KNN 插值; DownBlock 对齐到 patch 分辨率。 voxel_feat_aligned_vis

代码:

voxel_feat_aligned_vis = ( self.backend.zero_conv(voxel_feat_aligned)

  • self.backend.gate_scale )

对应:

Fentry=α0Z0(F3D)F_{\mathrm{entry}} = \alpha_0 Z_0(F_{\mathrm{3D}})

其中:

Z0Z_0:入口 zero-conv;α0\alpha_0:入口 gate;

:入口 gate; conv2 初始化为零。

所以初始化时:

Z0(F3D)=0,Z_0(F_{\mathrm{3D}}) = 0,

从而:

Fentry=0F_{\mathrm{entry}} = 0. 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 层拥有:

Zl,αlZ_l, \alpha_l.

这部分稍后被传入 VGGT aggregator。

  1. 第一级融合:直接修改 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"]

形状是:

(BT,Np,C),Np=HpWp,C=1024(BT, N_p, C), \quad N_p = H_p W_p, \quad C = 1024

例如:

(BT,1369,1024)(BT, 1369, 1024). 3.2 voxel feature 的形状

在 model.py 中,voxel_feat_aligned_vis 先被恢复为:

(B,T,Hp,Wp,C)(B, T, H_p, W_p, C).

传入 add_voxel_feat_to_patch_tokens() 后:

voxel_feat.flatten(0, 1)

得到:

(BT,Hp,Wp,C)(BT, H_p, W_p, C).

然后:

.flatten(1, 2)

把二维 patch 网格展平:

(BT,HpWp,C)(BT, H_p W_p, C).

即:

(BT,Np,C)(BT, N_p, C).

这样就与原始 patch tokens 完全对齐。

3.3 实际融合公式

代码:

patch_tokens["x_norm_patchtokens"] += voxel_feat_flat

对应:

Xpatch=Xpatch+FentryX_{\mathrm{patch}}' = X_{\mathrm{patch}} + F_{\mathrm{entry}}

进一步展开:

Xpatch=Xpatch+α0Z0(F3D)X_{\mathrm{patch}}' = X_{\mathrm{patch}} + \alpha_0 Z_0(F_{\mathrm{3D}})

初始化时:

Z0=0,Z_0 = 0,

因此:

Xpatch=XpatchX_{\mathrm{patch}}' = X_{\mathrm{patch}}

训练之后才逐渐变成:

Xpatch=Xpatch+ΔX3DX_{\mathrm{patch}}' = X_{\mathrm{patch}} + \Delta X_{\mathrm{3D}}

  1. 这里为什么是原地加法

代码使用:

+=

而不是:

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。

计算流程是:

IencoderXI \xrightarrow{\mathrm{encoder}} X

第一次:

XdecoderY(0)X \xrightarrow{\mathrm{decoder}} Y^{(0)}

建立后端:

Y(0)F3DY^{(0)} \to F_{\mathrm{3D}}

修改 token:

X=X+α0Z0(F3D)X' = X + \alpha_0 Z_0(F_{\mathrm{3D}})

第二次:

Xsame decoderY(1)X' \xrightarrow{\mathrm{same\ decoder}} Y^{(1)} 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 特征:F3DF_{\mathrm{3D}}; 每层自己的 zero-conv:ZlZ_l; 每层自己的 gate:αl\alpha_l。 6. decoder 每层的融合形式

从现有参数设计来看,decoder 内部的融合应当是类似: ΔXl=αlZl(F3D),\Delta X_l = \alpha_l Z_l(F_{\mathrm{3D}}),

然后:

Xl=Xl+ΔXlX_l' = X_l + \Delta X_l

或者如果 decoder token 是展平格式,则内部可能先把 token 转成二维:

Xl:(BT,Np,C)(BT,C,Hp,Wp),X_l : (BT, N_p, C) \to (BT, C, H_p, W_p),

应用:

Zl(F3D)RBT×C×Hp×Wp,Z_l(F_{\mathrm{3D}}) \in \mathbb{R}^{BT \times C \times H_p \times W_p},

再展平后加入:

Xl=Xl+Flatten(αlZl(F3D))X_l' = X_l + \mathrm{Flatten}(\alpha_l Z_l(F_{\mathrm{3D}}))

但目前没有 aggregator 的具体源码,所以不能仅凭 frontend.py 确定:

在 attention 前加; attention 后加; MLP 后加; frame attention 与 global attention 之间加; 每个 block 都加还是只在某些 block 加。

能够确定的是:

voxel_feat 和每层独立的 zero-conv/gate 被完整传入 aggregator.decode_patch_tokens(),真正逐层融合由 aggregator 内部执行。

  1. 为什么入口融合和逐层融合都需要

看起来似乎只需要其中一个,但二者功能不同。

入口融合 X0=X0+α0Z0(F3D)X_0' = X_0 + \alpha_0 Z_0(F_{\mathrm{3D}})

它在 decoder 开始前就修改视觉 patch token。

这意味着后续所有 decoder 层都会基于带有 3D 信息的 token 运行。

它提供一种全局、统一的初始条件修正。

逐层融合 Xl=Xl+αlZl(F3D)X_l' = X_l + \alpha_l Z_l(F_{\mathrm{3D}})

这允许 3D 特征在 decoder 深处持续进入网络,而不会随着层数增加逐渐被冲淡。

而且每层有不同的 ZlZ_l,可以学习不同语义映射。

所以整体更像:

X0=X0+α0Z0(F3D),X1=B1(X0),X1=X1+α1Z1(F3D),X2=B2(X1),X2=X2+α2Z2(F3D),X_0' = X_0 + \alpha_0 Z_0(F_{\mathrm{3D}}), \quad X_1 = B_1(X_0'), \quad X_1' = X_1 + \alpha_1 Z_1(F_{\mathrm{3D}}), \quad X_2 = B_2(X_1'), \quad X_2' = X_2 + \alpha_2 Z_2(F_{\mathrm{3D}}), \ldots

而不是只在最后输出头前补一次信息。

  1. 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),它执行:

zh,w=W2GELU(W1fh,w+b1)+b2z_{h,w} = W_2 \mathrm{GELU}(W_1 f_{h,w} + b_1) + b_2

因为 kernel size 是 1,所以它不混合相邻位置,只混合通道:

R1024R1024R1024\mathbb{R}^{1024} \to \mathbb{R}^{1024} \to \mathbb{R}^{1024}

这很合理,因为空间邻域信息已经在以下步骤中处理过:

Point Transformer; KNN interpolation; DownBlock 的 3×3 卷积。

zero-conv 的任务不是继续做空间建模,而是:

把 3D 后端通道重新投影到适合 VGGT decoder 当前层的特征空间。

  1. 为什么 conv2 为零就能完全关闭支路

ZeroConvBlock:

return self.conv2( self.act( self.conv1(x) ) )

设:

h=GELU(W1x+b1)h = \mathrm{GELU}(W_1 x + b_1)

输出:

z=W2h+b2z = W_2 h + b_2

初始化:

W2=0, b2=0W_2 = 0, \ b_2 = 0

因此对任意输入:

z=0z = 0.

哪怕:

Point Transformer 输出随机; DownBlock 输出随机; conv1 输出很大;

最后都被 conv2=0 截断。

所以首次训练迭代中:

Xl=XlX_l' = X_l

这使第二次 decoder 的行为在初始状态下理论上与原 decoder 非常接近。

  1. gate 的作用

每个 zero-conv 后面还有:

  • gate_scale

因此实际输出是: ΔXl=αlZl(F3D)\Delta X_l = \alpha_l Z_l(F_{\mathrm{3D}})

虽然 gate 初始化为 1:

nn.Parameter(torch.ones(1))

但因为 Zl=0Z_l = 0,初始输出还是零。

训练后 gate 可以控制整层注入强度。

例如:

αl0\alpha_l \approx 0

意味着第 l 层几乎不需要 3D 后端信息;

αl1|\alpha_l| \gg 1

意味着该层强烈依赖 3D 信息;

αl<0\alpha_l < 0

理论上还可以学习反向修正。

但需要注意一个训练细节:

初始化第一步时,由于:

Zl(F)=0,Z_l(F) = 0,

gate 梯度为:

Lαl=LΔXlZl(F)=0\frac{\partial \mathcal{L}}{\partial \alpha_l} = \frac{\partial \mathcal{L}}{\partial \Delta X_l} Z_l(F) = 0

所以训练最初主要先更新 conv2。

当 conv2 产生非零输出后,gate 才开始获得非零梯度。

  1. 梯度是怎样流动的

融合式:

Xl=Xl+αlZl(F3D)X_l' = X_l + \alpha_l Z_l(F_{\mathrm{3D}})

损失通过 VGGT heads 产生:

L=Ldepth+Lpoint+Lcamera\mathcal{L} = \mathcal{L}_{\mathrm{depth}} + \mathcal{L}_{\mathrm{point}} + \mathcal{L}_{\mathrm{camera}}

梯度路径为:

LheadsdecoderZlF3DDownBlockPTv3\mathcal{L} \to \mathrm{heads} \to \mathrm{decoder} \to Z_l \to F_{\mathrm{3D}} \to \mathrm{DownBlock} \to \mathrm{PTv3}

由于 get_voxel_feat() 默认:

detach=True

第一次前端产生的:

res["enc"] res["dec"] res["world_points"]

会 detach。

因此:

Finput3D=stopgrad(P(0),G(0))F_{\mathrm{input}}^{\mathrm{3D}} = \mathrm{stopgrad}(P^{(0)}, G^{(0)})

这意味着梯度不会穿过 3D backend 回到第一次 VGGT forward。

但第二次 decoder 中,新增后端特征仍然可以通过 frozen VGGT 的计算图,把损失梯度传给:

zero-conv; gate; DownBlock; Point Transformer; aligner。

即使 VGGT 参数冻结,梯度仍然可以穿过 VGGT 运算传到输入端的新增模块。

冻结参数不等于切断梯度。

  1. decode_heads() 如何复用原 VGGT 输出头

第二次 decoder 输出后,代码仍然调用同样的:

self.model.camera_head(...) self.model.depth_head(...) self.model.point_head(...)

因此:

Xdecoder{camera head,depth head,point head.X_{\mathrm{decoder}}' \to \begin{cases} \mathrm{camera\ head}, \\ \mathrm{depth\ head}, \\ \mathrm{point\ head}. \end{cases} 置信度也是由原 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 )

使用的将是上一轮已经增强后的特征。

因此多轮时形成:

(P(0),G(0))F3D(0)(P(1),G(1))F3D(1)(P(2),G(2))(P^{(0)}, G^{(0)}) \to F_{\mathrm{3D}}^{(0)} \to (P^{(1)}, G^{(1)}) \to F_{\mathrm{3D}}^{(1)} \to (P^{(2)}, G^{(2)})

但默认只执行一轮。

  1. 完整的两级融合流程

现在可以把代码精确整理为:

A. 原始编码 patch_tokens = aggregator.encode_patch_tokens(images)

得到:

X. B. 原始解码 decode_patch_tokens( patch_tokens, voxel_feat=None, voxel_layer_list=None )

得到:

P(0),G(0)P^{(0)}, G^{(0)} C. 3D backend (P(0),G(0))F3D(P^{(0)}, G^{(0)}) \to F_{\mathrm{3D}}

D. 入口 zero-conv voxel_feat_aligned_vis = ( zero_conv(F_3D) * gate_scale ) F0=α0Z0(F3D)F_0 = \alpha_0 Z_0(F_{\mathrm{3D}}) E. 加到 patch token x_norm_patchtokens += flatten(F_0) X=X+F0X' = X + 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 内部大致执行:

Xl=Xl+αlZl(F3D)X_l' = X_l + \alpha_l Z_l(F_{\mathrm{3D}}) G. 原 VGGT heads 输出 XLD^,P^,T^,C^X_L' \to \hat{D}, \hat{P}, \hat{T}, \hat{C} 15. 一处值得注意的代码副作用

这里:

patch_tokens["x_norm_patchtokens"] += ...

是原地修改。

因此在:

for i in range(iters):

中,如果 iters>1,每轮会继续在已经修改过的 patch_tokens 上累加新的 voxel feature:

X(i+1)=X(i)+αiZi(F3D(i))X^{(i+1)} = X^{(i)} + \alpha_i Z_i(F_{\mathrm{3D}}^{(i)})

并不是每轮都回到最初的 encoder token。

因此多轮行为是残差累积式 refinement,而非每轮独立重算。

  1. 现在还缺哪段代码

要完全确认逐层注入位置,需要查看:

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 源码确认。

最核心的公式是:

X0=X0+α0Z0(F3D)X_0' = X_0 + \alpha_0 Z_0(F_{\mathrm{3D}})

以及 decoder 内的:

Xl=Xl+αlZl(F3D)X_l' = X_l + \alpha_l Z_l(F_{\mathrm{3D}})

前者已经在 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。

  1. 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;

总计:

24+24=4824+24=48

个 Transformer block。

这正好解释了 backend.py 为什么构造:

depth=48depth = 48

以及:

self.zero_conv_layers = nn.ModuleList( [ZeroConvBlock() for _ in range(depth)] )

即:

Z0,Z1,,Z47Z_0, Z_1, \ldots, Z_{47}

每个 alternating-attention block 对应一个独立 zero-conv。Aggregator 本身也明确说明它在输入帧上执行 alternating attention。

  1. token 的组成

在 decoder 开头:

tokens = torch.cat( [camera_token, register_token, patch_tokens], dim=1 )

所以完整 token 序列是:

X=[Xcamera,Xregister,Xpatch]X = [X_{\mathrm{camera}}, X_{\mathrm{register}}, X_{\mathrm{patch}}]

其中:

self.patch_start_idx = 1 + num_register_tokens

默认 num_register_tokens=4,所以:

patch_start_idx=5\mathrm{patch\_start\_idx} = 5.

即 token 索引:

0 : camera token 1,2,3,4 : register tokens 5... : patch tokens

因此:

tokens[:, self.patch_start_idx:, :]

只取 patch tokens:

XpatchX_{\mathrm{patch}}

camera 和 register token 完全不被 zero-conv 直接修改。

  1. 入口处第一次融合

在调用 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

因此入口融合为:

Xpatch0=XDINO+αentryZentry(F3D)X_{\mathrm{patch}}^0 = X_{\mathrm{DINO}} + \alpha_{\mathrm{entry}} Z_{\mathrm{entry}}(F_{\mathrm{3D}})

然后 aggregator 再添加 camera 和 register token:

X0=[Xcamera,Xregister,Xpatch0]X^0 = [X_{\mathrm{camera}}, X_{\mathrm{register}}, X_{\mathrm{patch}}^0]

这是 decoder 开始之前的第一层 3D 回流。

  1. 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_idattentionzero-conv
0frame 0Z0Z_0
1frame 1Z2Z_2
2frame 2Z4Z_4
.........
23frame 23Z46Z_{46}

也就是说偶数编号 zero-conv 用于 frame attention。

对应数学表达:

ΔXf,l=α2lZ2l(F3D),Xf,lin=Xl+ΔXf,l\Delta X_{f,l} = \alpha_{2l} Z_{2l}(F_{\mathrm{3D}}), \quad X_{f,l}^{\mathrm{in}} = X_l + \Delta X_{f,l}

然后才进入 frame block:

Xf,lout=FrameBlockl(Xf,lin)X_{f,l}^{\mathrm{out}} = \mathrm{FrameBlock}_l(X_{f,l}^{\mathrm{in}})

代码明确在调用 _process_frame_attention() 之前,将经过对应 zero-conv 和 gate 的体素特征加到 patch token 上。

  1. 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_idattentionzero-conv
0global 0Z1Z_1
1global 1Z3Z_3
2global 2Z5Z_5
.........
23global 23Z47Z_{47}

即奇数编号 zero-conv 用于 global attention。

数学上: ΔXg,l=α2l+1Z2l+1(F3D),Xg,lin=Xf,lout+ΔXg,l\Delta X_{g,l} = \alpha_{2l+1} Z_{2l+1}(F_{\mathrm{3D}}), \quad X_{g,l}^{\mathrm{in}} = X_{f,l}^{\mathrm{out}} + \Delta X_{g,l}

随后:

Xg,lout=GlobalBlockl(Xg,lin)X_{g,l}^{\mathrm{out}} = \mathrm{GlobalBlock}_l(X_{g,l}^{\mathrm{in}})

逐层 global 注入发生在 _process_global_attention() 之前。

  1. 一组 alternating attention 的完整公式

对于第 l 组,输入 token 为:

XlX_l.

先在 frame attention 前加 3D 残差:

Xlf,in=Xl+α2lZ2l(F3D)X_l^{\mathrm{f,in}} = X_l + \alpha_{2l} Z_{2l}(F_{\mathrm{3D}})

执行单帧内部 attention:

Xlf=Fl(Xlf,in)X_l^f = F_l(X_l^{\mathrm{f,in}})

然后在 global attention 前再次加 3D 残差:

Xlg,in=Xlf+α2l+1Z2l+1(F3D)X_l^{\mathrm{g,in}} = X_l^f + \alpha_{2l+1} Z_{2l+1}(F_{\mathrm{3D}})

执行跨帧 global attention:

Xl+1=Gl(Xlg,in)X_{l+1} = G_l(X_l^{\mathrm{g,in}})

所以整个网络是:

Xl+1=Gl(Fl(Xl+α2lZ2l(F3D))+α2l+1Z2l+1(F3D))X_{l+1} = G_l\left(F_l\left(X_l + \alpha_{2l} Z_{2l}(F_{\mathrm{3D}})\right) + \alpha_{2l+1} Z_{2l+1}(F_{\mathrm{3D}})\right)

注意,第二个加法发生在 frame attention 输出之后、global attention 之前。

  1. 为什么先统一为 (B*S, P, C)

frame attention 要求 token 形状为:

(BS,P,C),(BS, P, C),

其中:

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, SP, C).

这里先恢复:

(B,SP,C)→(B,S,P,C)→(BS,P,C).

这样每张图单独成为一个 batch item,随后执行 frame attention。

  1. 体素特征如何变成 token 序列

原始 voxel_feat 形状:

(BS,C,Hp,Wp)(BS, C, H_p, W_p).

经过每层 zero-conv:

v_info['layer'](voxel_feat)

形状仍然是:

(BS,C,Hp,Wp)(BS, C, H_p, W_p).

随后:

.permute(0, 2, 3, 1)

变为:

(BS,Hp,Wp,C)(BS, H_p, W_p, C).

再:

.flatten(1, 2)

变为:

(BS,HpWp,C)(BS, H_p W_p, C).

令:

Np=HpWpN_p = H_p W_p

则结果为:

(BS,Np,C)(BS, N_p, C).

恰好和:

tokens[:, self.patch_start_idx:, :]

形状一致。

因此实际融合是逐 patch 对齐相加:

Xb,s,upatchXb,s,upatch+αlZl(F3D)b,s,uX_{b,s,u}^{\mathrm{patch}} \leftarrow X_{b,s,u}^{\mathrm{patch}} + \alpha_l Z_l(F_{\mathrm{3D}})_{b,s,u}

不是广播到所有 token,也不是全局平均后加一个向量。

每一个 patch 位置 u 都得到其对应 point-map/voxel 插值位置的 3D 特征。

  1. 为什么不加给特殊 token

代码只修改:

tokens[:, self.patch_start_idx:, :]

因此:

XcameraX_{\mathrm{camera}}

和:

XregisterX_{\mathrm{register}}

不直接接收 3D 特征。

但它们仍然会在后续 attention 中间接获得这些信息。

例如 frame attention 中,camera token 可以关注当前帧所有带 3D 增强的 patch tokens:

Xcamera=Attention(Xcamera,Xpatch+ΔX3D)X_{\mathrm{camera}}' = \mathrm{Attention}(X_{\mathrm{camera}}, X_{\mathrm{patch}} + \Delta X_{\mathrm{3D}})

global attention 中,不同帧 camera/register token 也可以通过跨帧 attention 获取 3D 一致性信息。

所以设计是:

3D 信息先绑定到具有明确空间位置的 patch tokens,再由原 VGGT attention 自然传播到 camera/register tokens。

这比直接向 camera token 加一个空间特征更合理,因为 camera token 没有明确的单个二维位置。

  1. 为什么 frame 和 global 使用不同的 zero-conv

虽然输入都是同一个:

F3DF_{\mathrm{3D}}

但是 frame attention 和 global attention 的功能不同。

Frame attention

_process_frame_attention() 保持:

(BS,P,C)(BS, P, C).

每张图独立处理,只在单张图的 token 内进行 attention。

它主要负责:

单帧空间结构; 局部几何; 图像内上下文; 当前帧 camera token 和 patch token 的交互。

Z2lZ_{2l} 可以学习适合单帧处理的 3D 特征投影。

Global attention

_process_global_attention() 将 token 转成:

(B,SP,C).

也就是把所有图像 token 串到一起做跨帧 attention。

它主要负责:

多视图对应; 相机间关系; 跨视图几何一致性; 多帧信息传递。

Z2l+1Z_{2l+1} 可以学习另一套更适合跨帧融合的通道映射。

这就是为什么不是同一个 zero-conv 输出重复加两次,而是每层都拥有独立参数。

  1. zero-conv 注入是累积的

代码使用原地加法:

tokens[:, self.patch_start_idx:, :] += ...

因此 3D 特征不是临时用一次后立即丢弃,而是不断累积进 token 状态。

第一组:

X0+Z0frame 0X0f+Z1global 0X1X_0 + Z_0 \xrightarrow{\mathrm{frame\ 0}} X_0^f + Z_1 \xrightarrow{\mathrm{global\ 0}} X_1

第二组继续:

X1+Z2frame 1X1f+Z3global 1X2X_1 + Z_2 \xrightarrow{\mathrm{frame\ 1}} X_1^f + Z_3 \xrightarrow{\mathrm{global\ 1}} X_2

因此某一层注入的信息会通过后续所有 attention 和 MLP 层继续传播。

  1. 为什么叫“零卷积”,但网络中一共有 49 个

严格来说,有:

1 个入口 zero-conv: self.backend.zero_conv 48 个逐层 zero-conv: self.backend.zero_conv_layers

总计:

1+48=491+48=49.

入口 zero-conv 修改 DINO patch tokens:

XDINOXDINO+ΔXentryX_{\mathrm{DINO}} \to X_{\mathrm{DINO}} + \Delta X_{\mathrm{entry}}

其余 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 ...
  1. 为什么入口 zero-conv 后,frame 0 前还要再加一次

这看上去有点重复,但两者参数不同:

ZentryZ0Z_{\mathrm{entry}} \neq Z_0

入口注入修改的是最原始的 DINO patch embedding:

XDINO=XDINO+αeZe(F3D)X_{\mathrm{DINO}}' = X_{\mathrm{DINO}} + \alpha_e Z_e(F_{\mathrm{3D}})

随后拼接 camera/register token。

frame 0 前又注入:

X0f,in=XDINO+α0Z0(F3D)X_0^{\mathrm{f,in}} = X_{\mathrm{DINO}}' + \alpha_0 Z_0(F_{\mathrm{3D}})

因此初始 frame attention 实际接收:

X0f,in=XDINO+αeZe(F3D)+α0Z0(F3D)X_0^{\mathrm{f,in}} = X_{\mathrm{DINO}} + \alpha_e Z_e(F_{\mathrm{3D}}) + \alpha_0 Z_0(F_{\mathrm{3D}})

这使网络可以学习两种不同作用:

入口 zero-conv:修改基础视觉表示; frame-0 zero-conv:针对第一个 frame attention 做额外条件化。

是否存在冗余,是一个很好的消融实验问题。

  1. 论文所谓“复用已有 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 只是把输入改为:

X+ΔX3DX + \Delta X_{\mathrm{3D}}

因此原本学到的 attention 函数:

AVGGT(X)A_{\mathrm{VGGT}}(X)

现在处理:

AVGGT(X+ΔX3D)A_{\mathrm{VGGT}}(X + \Delta X_{\mathrm{3D}})

也就是:

3D 后端负责产生残差提示,VGGT 已有的 frame/global attention 负责解释和传播这些提示。

这样不需要重新训练一套完整的多视图 Transformer。

  1. 为什么能复用 confidence head

aggregator 最终输出:

aggregated_tokens_list

后续仍然进入原始:

camera head; depth head; point head。

例如 point head 输出:

pts3d, pts3d_conf

因此 confidence 函数没有被替换。

只是它读取的特征从:

XVGGTX_{\mathrm{VGGT}}

变成:

XVGGT+3DX_{\mathrm{VGGT+3D}}

所以:

Cnew=hconfVGGT(XVGGT+3D)C_{\mathrm{new}} = h_{\mathrm{conf}}^{\mathrm{VGGT}}(X_{\mathrm{VGGT+3D}})

这正是“复用 confidence function”的具体含义。

  1. aggregated_tokens_list 保存什么

每完成一组 frame/global attention,代码把两者的中间输出拼接:

concat_inter = torch.cat( [frame_intermediates[i], global_intermediates[i]], dim=-1 )

因此每个保存结果维度是:

(B,S,P,2C)(B, S, P, 2C).

其中一半来自:

XlfX_l^f

另一半来自:

XlgX_l^g

这也是之前 model.py 中:

res['dec']

维度为 2048 的原因之一,而 encoder patch feature 是 1024。

随后:

feat = torch.cat([res['enc'], res['dec']], dim=-1)

得到:

1024+2048=30721024+2048=3072.

这正好对应:

BackEnd(in_dim=2048+1024)

所以代码维度链现在完全对上了。

  1. 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 只是节省中间输出存储,不代表跳过该层。

  1. 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,最大索引为:

2×23+1=472 \times 23 + 1 = 47.

刚好需要 48 个 zero-conv。

不过这段实现隐含依赖:

aa_order 必须仍是两个元素; 顺序最好是 ["frame","global"]; aa_block_size=1。

若以后把:

aa_block_size=2

或增加其他 attention 类型,block_id*2 的映射可能不再与真实 block 数严格对应。

因此这是一个比较硬编码的设计。

  1. 一个完整的 shape 示例

假设:

B=2,S=4,B=2, S=4,

输入图像:

518×518,518 \times 518,

patch size 为 14,所以:

Hp=Wp=37,Np=372=1369H_p = W_p = 37, \quad N_p = 37^2 = 1369

特殊 token 数:

于是:

P=1374P = 1374.

完整 token:

tokensR8×1374×1024tokens \in \mathbb{R}^{8 \times 1374 \times 1024}

其中 patch 部分:

tokens[:,5:,:]R8×1369×1024tokens[:, 5:, :] \in \mathbb{R}^{8 \times 1369 \times 1024}

体素特征:

F3DR8×1024×37×37F_{\mathrm{3D}} \in \mathbb{R}^{8 \times 1024 \times 37 \times 37}

经过 zero-conv:

8×1024×37×378 \times 1024 \times 37 \times 37.

经过:

permute(0,2,3,1)

变为:

8×37×37×10248 \times 37 \times 37 \times 1024.

再:

flatten(1,2)

变为:

8×1369×10248 \times 1369 \times 1024.

因此可以逐元素加到:

tokens[:,5:,:]tokens[:, 5:, :]. 20. 最终精确流程

现在可以把 AMB3R 的零卷积融合写成:

后端输出 F3D=D(KNN(PTv3(Voxelize(P,G))))F_{\mathrm{3D}} = D(\mathrm{KNN}(\mathrm{PTv3}(\mathrm{Voxelize}(P, G))))

入口注入 Xpatch0=XDINO+αeZe(F3D)X_{\mathrm{patch}}^0 = X_{\mathrm{DINO}} + \alpha_e Z_e(F_{\mathrm{3D}}) 第 l 个 alternating block Xlf,in=Xl+α2lZ2l(F3D),X_l^{\mathrm{f,in}} = X_l + \alpha_{2l} Z_{2l}(F_{\mathrm{3D}}), Xlf=FrameAttnl(Xlf,in),X_l^f = \mathrm{FrameAttn}_l(X_l^{\mathrm{f,in}}), Xlg,in=Xlf+α2l+1Z2l+1(F3D),X_l^{\mathrm{g,in}} = X_l^f + \alpha_{2l+1} Z_{2l+1}(F_{\mathrm{3D}}), Xl+1=GlobalAttnl(Xlg,in)X_{l+1} = \mathrm{GlobalAttn}_l(X_l^{\mathrm{g,in}})

其中:

l=0,…,23.

最终:

X24depth/point/camera/confidence headsX_{24} \to \mathrm{depth/point/camera/confidence\ heads} 21. 最值得注意的本质

这不是把 voxel feature 和 token concat:

[X;F3D][X; F_{\mathrm{3D}}]

也不是 cross-attention:

CrossAttn(X,F3D)\mathrm{CrossAttn}(X, F_{\mathrm{3D}})

而是简单的条件残差:

XX+αZ(F3D)X \leftarrow X + \alpha Z(F_{\mathrm{3D}})

它的优势是:

不改变 token 维度; 不修改原 attention block 结构; 不需要新增 KV 序列; 不增加 attention 长度; zero-init 保证初始等价于原 VGGT; 每层独立投影,适配不同 Transformer 深度; 3D 信息可以通过原 frame/global attention 传播。

因此 AMB3R 的融合方式可以概括为:

和前面这些模型的关系

AMB3R 和你前面读过的方法可以放在一条线上看:

方法主要输出关键特点
DUSt3Rpointmap直接回归 3D 对应点
MASt3Rpointmap + descriptor在 3D grounding 上补足匹配能力
VGGTcamera + depth + pointmap + tracks多任务前馈几何推理
Pi3permutation-equivariant 几何输出去掉 reference view 偏置
Depth Anything 3depth + ray用更简洁的表示统一 any-view 几何
AMB3Rmetric-scale reconstruction + backend用后端把前端几何组织成全局场景

AMB3R 的位置比较像:

前端几何回归已经能做得不错
但全局组织和 metric-scale 稳定性还不够
-> backend 补这一层

VO:AMB3R 怎么做 visual odometry

项目页明确给了 AMB3R-VO。

它的核心思路是:

一段图像序列
-> 逐帧前馈几何预测
-> backend 维护一个 keyframe memory
-> 当前帧与 keyframe 对齐
-> 估计相对位姿和尺度
-> 输出轨迹

更具体地说,官方描述里提到:

  1. 维护一个 keyframe memory。
  2. 新 frame 与 memory 中的 keyframe 做配准。
  3. 用预测的 relative pose 和 scale,把局部 3D 对齐到全局。
  4. 将局部地图以加权平均的方式融合成全局状态。
⭐ 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)

为了让模型在处理长视频流时既能记住过去,又不被海量数据撑爆内存,需要挑选最具代表性的关键帧作为记忆。先定义帧 iijj 之间的“位姿距离(pose distance)”:

Di,j=arccos(Tr(RjRiT)12)+λτiτj2D_{i,j} = \arccos\left(\frac{\mathrm{Tr}(R_j R_i^T) - 1}{2}\right) + \lambda\, \|\tau'_i - \tau'_j\|_2
  • 前半项利用旋转矩阵的迹 Tr\mathrm{Tr} 计算两帧的旋转角度差异;
  • 后半项计算归一化平移向量 τ\tau' 之间的欧氏距离,λ\lambda 用于平衡两项。

筛选策略:设定最小位姿距离阈值 ηd=0.15\eta_d = 0.15;在每个包含 Nw=8N_w = 8 帧的新建图窗口中,剔除与现有关键帧太近的画面,然后挑选置信度(confidence)最高的帧作为新的关键帧加入“记忆库”。

尺度对齐估计(公式 12)

每个新窗口预测出的 pointmap 都基于该窗口内部的相对尺度,直接拼接会出现大小错位。因此系统使用 ROE 求解器,通过对比当前窗口的关键帧点图 Pk(1),wP_k^{(1),w} 与全局地图中已有的对应点图 Pk(1)P_k^{(1)},计算相对的缩放比例因子:

sw=ROE(Pk(1),Pk(1),w)s_w = \mathrm{ROE}\left(P_k^{(1)}, P_k^{(1),w}\right)

sws_w 把新数据缩放到与全局地图统一的尺寸。

置信度加权滑动平均融合(公式 13–17)

算出统一尺度后,系统以预测置信度 CkC_k 为权重做滑动平均(running average),把新窗口的预测(带上标 ww 的变量)平滑更新进全局地图状态:

  • 公式 13(更新 3D 点图):新点图乘以尺度因子后按置信度权重与旧点图混合:
PkCkPk+CkwswPkwCk+CkwP_k \leftarrow \frac{C_k P_k + C_k^w s_w P_k^w}{C_k + C_k^w}
  • 公式 14(更新物理尺度因子 mm,用于恢复真实世界绝对尺寸):
mCkm+Ckwmw/swCk+Ckwm \leftarrow \frac{C_k m + C_k^w m^w / s_w}{C_k + C_k^w}
  • 公式 15(更新相机平移):
τkCkτk+CkwswτkwCk+Ckw\tau_k \leftarrow \frac{C_k \tau_k + C_k^w s_w \tau_k^w}{C_k + C_k^w}
  • 公式 16(更新相机旋转姿态):旋转用四元数表示,直接相加会产生不符合物理规律的形变,因此用球面线性插值(slerp)平滑过渡:
qkslerp(qk,qkw,CkwCk+Ckw)q_k \leftarrow \mathrm{slerp}\left(q_k, q_k^w, \frac{C_k^w}{C_k + C_k^w}\right)
  • 公式 17(更新全局置信度):
CkCk+CkwC_k \leftarrow C_k + C_k^w

被多次观测到的可靠区域置信度越来越高,权重也随之增加。

长期运行:关键帧管理、坐标对齐与鲁棒估计

为了保证 VO 模式长期运行的稳定性、准确性和计算效率,AMB3R 还设计了三个高级策略。

主动关键帧管理(active keyframe management):活跃关键帧数量达到上限 Nmax=10N_{\max}=10 时,从所有历史关键帧中重新采样出 Nmin=7N_{\min}=7 个:

  • 最新的一帧关键帧永久保留;
  • 挑选 NtopkN_{\mathrm{topk}} 个与最新关键帧“位姿距离”最近的帧;
  • 闭环检测鼓励机制:设置反向搜索窗口 ηb=0.4\eta_b = 0.4,迭代地把符合该窗口的最早关键帧加入(总计 NoldN_{\mathrm{old}} 个),促使模型利用早期画面形成闭环(loop)以修正误差;
  • 选取与现有关键帧总位姿距离最小的那一帧作为新的第一帧;
  • 安全限制:如果活跃关键帧之间的最大位姿距离超过 ηmax=1.2\eta_{\max} = 1.2,强制重新采样,确保模型处理的场景范围始终落在训练数据的分布范围内,防止模型崩溃。

坐标对齐(公式 18):重采样后记忆库中的“第一帧”可能变了,局部预测与全局地图之间的坐标系不再匹配。与其用复杂算法联合估算局部与全局之间的相对变换和尺度,AMB3R 先把全局地图逆向映射到局部地图的坐标系中:

Pk(k0)=Tk01Pk(1),Tk0=[Rk0,τk0]P_k^{(k_0)} = T_{k_0}^{-1} P_k^{(1)}, \quad T_{k_0} = [R_{k_0}, \tau_{k_0}]

其中 Tk0=[Rk0,τk0]T_{k_0} = [R_{k_0}, \tau_{k_0}] 代表旋转和平移。完成映射后,先估算尺度,再缩放相机位姿,最后用加权平均把局部地图重新映射回全局参考系并融合。这一系列操作避开了计算量庞大的显式 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)处理多张图片时复杂度与图像数量呈平方关系(O(N2)O(N^2)),图像数量庞大时直接处理极其低效。

因此 AMB3R 采用“分而治之”的策略:先把海量无序图像划分成小型图像簇(clusters),再用增量式 SfM 方法逐步求解,全程不需要传统耗时优化。

图像聚类

系统需要自动把拍摄位置和视角相近的图片归为一类:

  1. 对每一张图像 ItI_t 提取特征描述子 FˉtRC\bar{F}_t \in \mathbb{R}^{C},并应用特征白化(feature whitening);
  2. 计算所有图像两两之间的特征距离矩阵 DFRT×TD_F \in \mathbb{R}^{T \times T}
  3. 结合迭代的拆分与合并策略,应用最远点采样(FPS)把图集划分成多个小簇,每个簇的图像数量控制在 NcminN_c^{\min}NcmaxN_c^{\max} 之间。

粗略配准与增量建图

分好簇后,系统像拼图一样把这些簇逐步拼接到全局 3D 地图:

  1. 初始化:挑选预测置信度最高的图像簇作为起始点初始化全局地图,并维护一个全局关键帧列表(位姿距离阈值 ηd=0.2\eta_d = 0.2)。
  2. 增量构建:在未建图的簇中找出与当前全局关键帧特征距离最近的 top-k=5 个簇,与现有关键帧做配准映射;比较配准结果后,选取置信度最高的一个簇正式合并进全局地图。
  3. 内存管理:当全局关键帧数量超过上限 Nkmax=8N_k^{\max} = 8 时,触发类似于 VO 章节中的分区/重采样机制,保证计算效率和内存安全(论文原文此处被截断,属于合理推断)。

为什么这篇论文值得看

AMB3R 的价值不只是“又一个重建网络”,而是它把两个方向合起来了:

  1. 前馈 3D 几何预测已经很强。
  2. 但没有 backend 的全局组织,很多场景还是不够稳。

它试图说明:

几何前馈网络 + 3D backend
可能比只堆一个更大的 2D/3D transformer 更实用

这对你前面做的 pointmap、VGGT、Pi3、DA3 笔记都能直接接上:

  • DUSt3R / MASt3R 说明 pairwise / descriptor 级几何可以学。
  • VGGT / Pi3 说明多视图全局推理可以学。
  • DA3 说明 depth-ray 表示可以统一 any-view 几何。
  • AMB3R 进一步说明,全局场景组织本身也是关键模块。

你读这篇论文时最该盯住的点

  1. 前端到底输出什么几何量。
  2. backend 的 3D 表示是怎么压缩和更新的。
  3. VO 里 keyframe memory 怎么维持稳定轨迹。
  4. SfM 里 coarse registration 和 global mapping 怎么衔接。
  5. metric-scale 是怎么被保住的。

官方资料