ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NeRF神经辐射场论文精读:从体渲染原理到三维重建复现实战

NeRF神经辐射场论文精读:从体渲染原理到三维重建复现实战

1. 为什么NeRF值得每一个做三维视觉的人花时间啃

第一次接触NeRF(Neural Radiance Fields,神经辐射场)是在一个三维重建的课题里,当时手头有一组多视角拍摄的照片,传统SfM加MVS的流程跑出来的点云在纹理稀疏区域和反光表面上惨不忍睹,空洞、噪点、边缘毛刺全来了。后来有人丢了一篇NeRF的论文过来,说“你试试这个”,我花了一个周末把论文啃完、代码跑通,看到渲染出来的新视角图像时确实被震住了——那种对光照、反射、半透明材质的还原能力,传统管线根本做不到。

这篇内容就是围绕NeRF这篇论文做一次系统性的解读。我会从论文的核心思想讲起,把位置编码、体渲染、分层采样、优化策略这些关键设计逐一拆开,再结合我自己复现和调参过程中踩过的坑,给出可直接参考的实操建议。适合已经有一定深度学习基础、想做三维重建或新视角合成方向的人阅读,也适合刚入门NeRF、看论文时被公式绕晕的朋友拿来对照理解。

NeRF解决的核心问题其实很朴素:给定一组围绕某个场景拍摄的多视角图像,能不能合成出任意新视角下的图像?传统方法要么依赖显式的三维几何表示(点云、网格、体素),要么依赖图像域的插值,前者在细节和材质表现上受限,后者在视角变化大时直接崩掉。NeRF的思路是换一条路——不显式重建几何,而是用一个多层感知机(MLP)把整个场景“记”在网络权重里,通过体渲染的方式从任意视角查询颜色和密度,最终合成图像。

这个思路之所以work,关键在于它把“场景表示”和“渲染”统一到了一个可微的框架里,端到端优化。下面我按论文的逻辑顺序,把每个关键模块拆开讲。

2. 论文核心思路拆解:用MLP记住一个场景

2.1 从“重建几何”到“学习辐射场”的范式转换

传统三维重建的套路是:先从图像里估计相机位姿,然后三角化出稀疏点云,再做稠密重建得到网格或体素,最后贴纹理。这条链路每一步都有误差累积,而且显式几何表示的分辨率是固定的——体素网格再细也有离散化的极限,网格面数再多也拟合不了体积雾、毛发边缘这类非刚性结构。

NeRF的范式转换在于:它不重建任何显式几何,而是学习一个连续函数。这个函数的输入是一个三维空间坐标x = (x, y, z)和一个观察方向d = (θ, φ),输出是该点的体密度σ和该方向上的颜色c = (r, g, b)。用数学语言写出来就是:

F_Θ: (x, d) → (c, σ)

其中 Θ 是MLP的参数。体密度 σ 只跟位置 x 有关,颜色 c 跟位置和方向都有关。这个设计很关键——密度是几何属性,不应该随观察方向变化;颜色是外观属性,镜面反射、高光这些效果必须依赖观察方向才能表现。

我第一次看到这个设计时觉得挺巧妙,但也没觉得多惊艳,直到跑通代码看到渲染结果才意识到:这个MLP实际上是在用网络权重“压缩”整个场景的信息。一个场景可能有几十万到几百万个有效采样点,全部编码进一个几层深的MLP里,参数量也就几MB,这种隐式表示的紧凑程度是显式方法比不了的。

2.2 体渲染:把MLP的输出变成像素

有了辐射场函数,怎么从它渲染出一张图像?这里用的是经典体渲染公式。对于一条从相机原点 o 出发、方向为 d 的射线 r(t) = o + t·d,像素颜色 C(r) 是沿射线积分得到的:

C(r) = ∫[t_n, t_f] T(t) · σ(r(t)) · c(r(t), d) dt

其中 T(t) 是从 t_n 到 t 的累积透射率:

T(t) = exp(-∫[t_n, t] σ(r(s)) ds)

这个公式的物理含义很直观:沿射线走,每个位置都有一个密度 σ,密度越大越可能“撞到”东西,透射率 T 就是光线走到 t 处还没被遮挡的概率。最终颜色是所有采样点颜色的加权和,权重是“走到这里还没被挡住、且在这里被挡住”的概率。

实际实现时不可能做连续积分,论文用的是分层采样加离散求和。把 [t_n, t_f] 分成 N 个区间,在每个区间内均匀采样一个点,然后:

Ĉ(r) = Σ_i T_i · (1 - exp(-σ_i · δ_i)) · c_i

其中 δ_i 是相邻采样点的间距,T_i = exp(-Σ_{j<i} σ_j · δ_j)。这个离散化形式在代码里就是一个累加循环,非常好实现。

注意:这里的 α_i = 1 - exp(-σ_i · δ_i) 就是alpha合成里的alpha值,整个体渲染本质上就是沿射线的alpha合成。理解这一点之后,代码里那些累乘、累加的写法就很好懂了。

2.3 位置编码:让MLP能拟合高频细节

如果直接把 (x, y, z) 坐标喂给MLP,会发现渲染出来的图像非常模糊,高频细节全丢了。论文里专门讨论了这个问题:神经网络天然倾向于学习低频函数,这是谱偏置(spectral bias)导致的。而三维场景的几何和纹理细节恰恰是高频信号。

解决方案是位置编码(positional encoding),把每个坐标值映射到一组正弦函数上:

γ(p) = (sin(2^0 π p), cos(2^0 π p), sin(2^1 π p), cos(2^1 π p), ..., sin(2^{L-1} π p), cos(2^{L-1} π p))

对于位置 x 用 L=10,对于方向 d 用 L=4。这样每个标量被映射成 2L 维向量,位置输入从3维变成60维,方向从2维变成24维。这个操作把低频输入“抬”到高维空间,让MLP在高维空间里更容易拟合高频变化。

我自己的理解是:这相当于给MLP提供了一组不同频率的“基函数”,网络只需要学习这些基函数的组合系数,而不是从零开始拟合高频曲线。实测下来,去掉位置编码,PSNR直接掉好几个dB,图像糊得没法看。

2.4 分层采样:把采样点用在刀刃上

均匀采样有个问题:场景里大部分空间是空的,均匀撒点会浪费大量采样在无意义的区域。论文提出了分层采样策略,同时训练两个网络:一个“粗”网络用 N_c 个均匀采样点,一个“细”网络用 N_f 个采样点,但细网络的采样点不是均匀分布的,而是根据粗网络输出的权重分布来采样。

具体做法是:把粗网络输出的权重 w_i 归一化成概率分布,然后在这个分布下做逆变换采样,把细网络的采样点集中在密度高的区域。这样既保证了覆盖,又把计算资源集中在真正有物体的地方。

这个设计在实现上有个细节:粗网络和细网络可以是同一个MLP(只是输入采样点不同),也可以是两个独立的MLP。论文用的是后者,参数量翻倍但效果更好。实际复现时如果显存吃紧,可以先用共享权重的版本跑通,再换独立版本。

3. 网络结构与训练细节:论文里没写透的那些事

3.1 MLP的具体架构与维度设计

论文里的MLP结构不算复杂,但有几个设计选择值得说清楚。位置编码后的输入是60维(位置)+ 24维(方向),但方向不是一开始就拼进去的。网络先处理位置输入,经过8层全连接(每层256维,ReLU激活),在第5层输出一个256维特征向量,同时接一个分支输出密度 σ(经过ReLU保证非负)。然后把第5层的特征和方向编码拼接,再经过一层128维全连接,输出RGB颜色(经过Sigmoid保证在[0,1])。

这个“先位置后方向”的设计是有讲究的:密度只依赖位置,所以应该在方向信息加入之前就输出;颜色依赖方向,所以要在方向信息加入之后输出。如果反过来,密度就会受到方向影响,违反物理直觉,训练也会不稳定。

我在复现时试过把方向和位置一开始就拼接,结果训练loss震荡明显,收敛慢了很多。后来改回论文的结构,训练曲线就平滑了。这个细节论文里只是一笔带过,但实际影响不小。

3.2 损失函数与优化配置

损失函数很直接,就是渲染颜色和真实颜色的均方误差:

L = Σ_r ||Ĉ_c(r) - C(r)||² + ||Ĉ_f(r) - C(r)||²

粗网络和细网络的输出都参与loss计算,这样粗网络的权重分布才能被有效监督。优化器用Adam,学习率从5e-4开始,指数衰减到5e-5。论文里还提到对 σ 加了一点噪声来正则化,但我在复现时发现这个噪声对最终效果影响不大,可以省略。

Batch size方面,论文用的是4096条射线,每条射线粗采样64个点、细采样128个点。这个配置在单卡上跑起来显存占用大概8-10GB,如果显存不够可以减半,但收敛会慢一些。

实操心得:学习率衰减的步数设置很关键。论文里是在500k次迭代内衰减,但实际训练时如果场景简单,200k次就收敛了;如果场景复杂,可能需要800k次以上。建议先用小分辨率(比如1/4分辨率)快速试跑,确认收敛趋势后再上全分辨率。

3.3 相机位姿与坐标系约定

NeRF假设相机位姿是已知的,通常用COLMAP或类似工具做SfM得到。这里有个容易踩的坑:不同工具输出的坐标系约定不一样,有的用OpenGL约定(相机看向-Z),有的用OpenCV约定(相机看向+Z)。如果坐标系搞反了,训练出来的结果会完全错乱。

我的做法是:先用一个简单场景(比如一个立方体)验证坐标系,渲染一张已知视角的图像,看和真实图像是否对齐。对齐了再上复杂场景。这个验证步骤花不了多少时间,但能省掉后面几小时的无效训练。

另外,NeRF的射线生成是在归一化坐标系里做的,通常把场景缩放到一个单位球内。这个归一化参数(near和far)需要根据场景的实际尺度调整,设得太小会截断物体,设得太大采样效率低。

4. 从零复现:完整流程与关键代码解析

4.1 数据准备与相机参数处理

假设你已经用SfM工具得到了一组图像和对应的相机内外参。数据准备阶段需要做几件事:

第一,把所有图像缩放到统一分辨率。论文用的是800x800,但实际可以根据显存调整。我一般先用400x400跑通流程,再上800x800。

第二,解析相机参数。每张图像对应一个相机到世界的变换矩阵(extrinsics)和焦距、主点(intrinsics)。把这些参数整理成一个列表,每条记录包含图像路径、变换矩阵、焦距。

第三,计算场景的归一化参数。把所有相机中心的位置取平均作为场景中心,计算相机中心到场景中心的最大距离,用这个距离来缩放场景,使得所有相机都在单位球附近。

import numpy as np def normalize_scene(c2w_list): # c2w_list: list of 4x4 camera-to-world matrices centers = np.array([c2w[:3, 3] for c2w in c2w_list]) scene_center = centers.mean(axis=0) max_dist = np.linalg.norm(centers - scene_center, axis=1).max() scale = 1.0 / max_dist return scene_center, scale

这个归一化步骤很重要,它保证了不同场景的尺度一致,网络不需要额外学习尺度变化。

4.2 射线生成与采样实现

射线生成的核心是:对于每个像素,计算它在相机坐标系下的方向,然后变换到世界坐标系。代码大概长这样:

def get_rays(H, W, focal, c2w): i, j = np.meshgrid(np.arange(W), np.arange(H), indexing='xy') dirs = np.stack([(i - W * 0.5) / focal, -(j - H * 0.5) / focal, -np.ones_like(i)], axis=-1) rays_d = np.sum(dirs[..., None, :] * c2w[:3, :3], axis=-1) rays_o = np.broadcast_to(c2w[:3, 3], rays_d.shape) return rays_o, rays_d

注意这里的-np.ones_like(i)对应的是OpenGL约定(相机看向-Z)。如果你用的SfM工具输出的是OpenCV约定,这里要改成np.ones_like(i)。

采样部分,粗采样是在 [near, far] 区间内均匀取 N_c 个点,然后加上一点随机扰动(训练时加,推理时不加)。细采样是根据粗网络的权重做逆变换采样:

def sample_pdf(bins, weights, N_samples): weights = weights + 1e-5 pdf = weights / weights.sum(-1, keepdims=True) cdf = np.cumsum(pdf, axis=-1) cdf = np.concatenate([np.zeros_like(cdf[..., :1]), cdf], axis=-1) u = np.random.uniform(size=list(cdf.shape[:-1]) + [N_samples]) inds = np.searchsorted(cdf, u, side='right') below = np.maximum(0, inds - 1) above = np.minimum(cdf.shape[-1] - 1, inds) cdf_g0 = np.take_along_axis(cdf, below, axis=-1) cdf_g1 = np.take_along_axis(cdf, above, axis=-1) bins_g0 = np.take_along_axis(bins, below, axis=-1) bins_g1 = np.take_along_axis(bins, above, axis=-1) denom = cdf_g1 - cdf_g0 denom = np.where(denom < 1e-5, np.ones_like(denom), denom) t = (u - cdf_g0) / denom samples = bins_g0 + t * (bins_g1 - bins_g0) return samples

这段逆变换采样的代码是NeRF实现里最容易写错的地方,建议直接参考官方实现,不要自己从头写。

4.3 体渲染累加与损失计算

体渲染的累加过程在代码里就是一个循环:

def render_rays(rays_o, rays_d, near, far, network, N_c=64, N_f=128): # coarse sampling t_c = np.linspace(near, far, N_c) t_c = t_c + np.random.uniform(size=t_c.shape) * (far - near) / N_c pts_c = rays_o[..., None, :] + rays_d[..., None, :] * t_c[..., :, None] sigma_c, rgb_c = network(pts_c, rays_d) dists_c = t_c[..., 1:] - t_c[..., :-1] dists_c = np.concatenate([dists_c, 1e9 * np.ones_like(dists_c[..., :1])], axis=-1) alpha_c = 1.0 - np.exp(-sigma_c * dists_c) weights_c = alpha_c * np.cumprod(1.0 - alpha_c + 1e-10, axis=-1) # fine sampling t_f = sample_pdf(t_c, weights_c[..., :-1], N_f) pts_f = rays_o[..., None, :] + rays_d[..., None, :] * t_f[..., :, None] sigma_f, rgb_f = network(pts_f, rays_d) # ... same accumulation for fine return rgb_c, rgb_f

这里有个细节:np.cumprod(1.0 - alpha_c + 1e-10)里的1e-10是防止数值下溢。如果不加这个,当alpha接近1时,累乘会变成0,梯度就断了。

损失就是((rgb_c - gt)**2).mean() + ((rgb_f - gt)**2).mean(),反向传播更新网络参数。

5. 常见问题与排查技巧实录

5.1 训练loss不下降或震荡

这是最常见的问题,原因通常有几个:学习率太大、位置编码的L值设置不当、相机位姿有误。我的排查顺序是:先把学习率降到1e-4试试,如果loss开始下降说明是学习率问题;如果还不行,检查位置编码的L值,位置用10、方向用4是论文的配置,但有些场景需要调整;最后检查相机位姿,用一张已知视角渲染对比,看是否对齐。

5.2 渲染结果模糊、细节丢失

如果训练loss降下去了但图像还是糊,大概率是位置编码没生效或者采样点太少。检查位置编码是否真的把输入映射到了高维,检查粗采样和细采样的点数是否足够。另外,如果场景里有大量高频纹理(比如草地、毛发),可能需要增加网络层数或每层的维度。

5.3 训练速度太慢

NeRF的训练确实慢,单卡跑一个场景通常要几小时到十几小时。加速的方法有:降低分辨率、减少采样点数、用混合精度训练、用多卡并行。我实测下来,混合精度能提速30%左右,显存占用也降了,但要注意数值稳定性,有些操作在fp16下会溢出。

5.4 新视角合成出现伪影

伪影通常出现在视角变化较大的区域,原因是训练视角覆盖不够。如果训练图像只覆盖了场景的某个半球,合成另一个半球的视角时就会出现拉伸、模糊。解决办法是增加训练视角的多样性,或者在loss里加正则项约束未观测区域。

问题现象可能原因排查方法解决措施
loss不下降学习率过大降到1e-4试跑调整学习率衰减策略
渲染模糊位置编码失效检查编码维度确认L值配置
训练慢采样点过多统计每步耗时减少N_c和N_f
新视角伪影视角覆盖不足可视化相机分布增加训练视角
显存溢出batch太大监控显存占用减小batch或分辨率

避坑技巧:训练前先用小分辨率跑1000步,确认loss在下降、渲染结果在变清晰,再上全分辨率。这个预检步骤能省掉大量无效等待。

6. 论文的局限与后续改进方向

NeRF论文本身有几个明显的局限,了解这些对理解后续的改进工作很有帮助。第一,训练和渲染速度都很慢,原始实现渲染一张图要几十秒,训练要一两天。后续的Instant-NGP、Plenoxels等工作就是冲着速度去的。第二,NeRF假设场景是静态的,无法处理动态场景。第三,NeRF需要精确的相机位姿,位姿不准效果会大打折扣。第四,NeRF的辐射场是视角相关的,但只建模了直接光照,没有显式建模阴影、全局光照。

我在实际项目里用NeRF时,最头疼的是训练速度。后来换用Instant-NGP的哈希编码方案,训练时间从十几小时压缩到几分钟,效果还差不多。如果你的场景对速度有要求,建议直接上Instant-NGP或类似的高效变体,没必要在原始NeRF上死磕。

另外,NeRF对反光表面和透明物体的处理也有局限。反光表面的颜色强烈依赖观察方向,NeRF虽然输入了方向信息,但MLP的表达能力有限,复杂反射效果还是拟合不好。透明物体更麻烦,因为密度场无法区分“透明”和“不存在”,渲染出来要么不透明要么直接消失。

这个方向后续还有很多可扩展的点,比如结合SDF做表面重建、结合光照模型做重光照、结合动态场做4D重建。如果你刚入门,建议先把原始NeRF吃透,再挑一个感兴趣的方向深入。我自己是沿着“加速”这条线走的,从原始NeRF到Instant-NGP再到3D Gaussian Splatting,每一步都能感受到三维表示方法在效率和效果上的权衡。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进