神经辐射场(Neural Radiance Fields,简称NeRF)通过多层感知机隐式表示场景的密度和颜色,再借助体渲染方程合成任意视角的图像,效果逼真到一度让人分不清照片和渲染结果。但很多人上手复现时会在第一步就卡住:官方实现要求提供每张图片的相机内外参,而这些参数通常要靠COLMAP跑一遍Structure from Motion才能得到。COLMAP对图像重叠度、纹理丰富程度都有要求,一旦输入不满足条件,位姿估计直接失败,整条管线就断了。自监督NeRF的核心思路是把相机姿态从“输入”变成“可学习参数”,让位姿估计和辐射场训练在同一次反向传播里完成。

先搞清楚:COLMAP在NeRF管线里到底做了什么
COLMAP执行的SfM流程包含特征提取、特征匹配、稀疏点三角化以及Bundle Adjustment几个阶段。它的产出是一组稀疏三维点和每张图像的相机位姿,NeRF拿到位姿后才能知道每个像素对应的光线在空间中的起点和方向。换句话说,COLMAP解决的是“这一帧相机在哪里、朝哪里看”的问题,而NeRF解决的是“场景长什么样”的问题。两者串行执行,误差会层层传递:SfM的位姿误差会直接体现为渲染画面的重影和模糊。
这套串行方案在实际场景里有几个明显的坑。第一,纹理弱的墙面、玻璃、水面会导致特征匹配失败,位姿残缺;第二,图像数量多时COLMAP的匹配耗时呈平方级增长,几百张图就要跑好几个小时;第三,某些采集设备(比如事件相机、红外相机)的图像根本提取不出SIFT特征,COLMAP直接失效。这三个痛点共同指向一个方向:能不能不依赖显式特征匹配,直接用渲染损失反推相机位姿?答案是肯定的,因为体渲染整个过程都是可微的。
自监督联合优化的核心原理:位姿梯度从哪来
联合优化的关键洞察在于,体渲染函数对相机位姿是可微的。给定一个位姿变换矩阵,图像上的每个像素都能通过相机内参逆变换得到一条相机坐标系下的光线,再经过位姿矩阵的外参变换投到世界坐标系。如果位姿参数发生微小扰动,光线的方向和起点就会变化,采样点的空间位置随之移动,最终渲染出的颜色也会改变。这个链条上的每一步都可以写出解析梯度,因此 photometric loss(渲染图与真实图的像素差)对位姿参数的偏导数可以直接用自动微分算出来。
具体实现时通常不直接优化4x4矩阵,而是把位姿参数化为旋转向量加平移向量(6维),或者用四元数保证旋转的正交性。每个相机一组独立的参数,和MLP的权重一起放进优化器。下面是一个精简的参数定义和光线生成骨架:
import torch
import torch.nn as nn
class LearnablePose(nn.Module):
# 每个相机一组可学习位姿:3维旋转向量 + 3维平移
def __init__(self, num_cams):
super().__init__()
self.rot = nn.Parameter(torch.zeros(num_cams, 3))
self.trans = nn.Parameter(torch.zeros(num_cams, 3))
def get_matrix(self, idx):
# 用罗德里格斯公式把旋转向量转成旋转矩阵
theta = torch.norm(self.rot[idx]) + 1e-8
axis = self.rot[idx] / theta
K = torch.zeros(3, 3, device=theta.device)
K[0, 1], K[1, 0], K[0, 2], K[2, 0], K[1, 2], K[2, 1] = -1, 1, -1, 1, -1, 1
R = torch.eye(3, device=theta.device) + torch.sin(theta) * K + (1 - torch.cos(theta)) * (K @ K)
T = self.trans[idx]
return R, T
def get_rays(H, W, focal, R, T):
# 内参逆变换生成相机系光线,再用R、T转到世界系
i, j = torch.meshgrid(torch.arange(W), torch.arange(H), indexing='xy')
dirs = torch.stack([(i - W * 0.5) / focal,
-(j - H * 0.5) / focal,
-torch.ones_like(i)], dim=-1)
rays_d = dirs @ R.T # 方向变换
rays_o = T.expand(rays_d.shape) # 光线起点即相机中心
return rays_o, rays_d这段代码里最需要注意的是旋转参数化的数值稳定性。旋转向量在接近零旋转时除以模长会出现除零,所以要加一个极小值保护;另外梯度更新后旋转矩阵可能略偏离正交,实践中可以定期做SVD投影,或者干脆用四元数加归一化来避免漂移。
从零实现:训练循环与损失函数设计
有了可学习位姿,训练循环的结构其实和标准NeRF差别不大,只是优化器里多了一组参数。损失函数上推荐用均方误差加上一个位姿平滑正则项,防止个别相机的位姿在训练早期剧烈震荡:
optimizer = torch.optim.Adam([
{'params': nerf_mlp.parameters(), 'lr': 5e-4},
{'params': poses.parameters(), 'lr': 1e-4}, # 位姿学习率要更小
], betas=(0.9, 0.999))
for step in range(max_steps):
img_idx = torch.randint(0, num_cams, (batch_size,))
target = dataset[img_idx] # 采样真实图像
R, T = poses.get_matrix(img_idx) # 取当前位姿估计
rays_o, rays_d = get_rays(H, W, focal, R, T)
rgb_pred, depth = render(nerf_mlp, rays_o, rays_d) # 分层采样体渲染
loss = ((rgb_pred - target) ** 2).mean()
# 相邻帧位姿平滑正则,抑制震荡(图像有序时启用)
rot_diff = (poses.rot[img_idx] - poses.rot[img_idx - 1]).norm(dim=-1).mean()
loss = loss + 0.001 * rot_diff
optimizer.zero_grad()
loss.backward()
optimizer.step()这里有两个工程细节值得强调。第一,位姿学习率必须比网络学习率小一到两个数量级,否则训练初期辐射场还没学到任何几何,位姿会被无意义的梯度拉飞,整个优化发散。第二,初始化很重要:如果图像是有序采集的,可以把所有相机初始化到同一位姿再慢慢分离;如果是无序图像,建议先用一个粗粒度的低分辨率MLP预热几百步,只训练辐射场不训练位姿,等粗略几何成形后再放开位姿梯度。这种“先形状后位姿”的策略和BARF中由粗到细的位置编码思想本质一致。
代表性方案对比与选型建议
学术界在这个方向上已有几条成熟路线,理解它们的差异有助于选型。BARF的核心贡献是频率退化的位置编码:高频编码会让损失函数曲面充满局部极小值,导致位姿优化陷入困境,BARF通过逐步激活高频分量让优化先在低频空间里收敛。SC-NeRF走得更远,它连相机内参(焦距、畸变系数)也一起联合优化,适合完全没有任何标定信息的场景。GNeRF则引入GAN来缓解无位姿初始化时的退化问题,用生成器产生的伪位姿对来引导收敛。下面这个表格做了简单归纳:
| 方案 | 联合优化对象 | 关键技巧 | 适用场景 |
|---|---|---|---|
| BARF | 外参位姿 | 由粗到细的位置编码 | 近似有序的图像序列 |
| SC-NeRF | 内外参全部 | 自标定束调整 | 完全无标定数据 |
| GNeRF | 外参位姿 | GAN辅助收敛 | 大范围无序图像 |
从实践角度给几条建议:如果你的图像是有序视频帧,先试BARF风格的频率退火加平滑正则,实现简单且收敛稳定;如果采集设备连焦距都不可靠,再考虑SC-NeRF这类自标定路线;如果是网络爬取的无序照片,位姿初始化本身就是病态问题,纯自监督的收敛难度会陡增,此时可以考虑混合方案——先用COLMAP或少量SfM结果粗定位,再交给联合优化精修。最后提醒一点,自监督NeRF对评估要格外小心:位姿本身是自由度,几何和位姿可能一起漂移到一个“自洽但错误”的解,训练时务必用留出的验证视角监控渲染质量,必要时冻结部分位姿做 sanity check,避免被表面的低损失迷惑。