计算机视觉基础与实践

超越传统:探索神经辐射场(NeRF)的3D重建魔法

摘要

神经辐射场(NeRF)是一种革命性的3D场景表示与重建技术。它通过一个简单的多层感知机,将空间坐标和视角映射为颜色和密度,仅需一组2D图像即可合成逼真的新视角。本文将带你了解NeRF的核心原理、工作流程、优势与挑战,并展示其代码实现的核心思想。

引言:从2D到3D的桥梁

传统的3D重建技术,如多视图立体几何(MVS)或结构光扫描,通常依赖于复杂的几何计算和硬件设备,且重建结果往往缺乏真实感细节。2020年,来自加州大学伯克利分校、谷歌等机构的研究者提出了一种名为“神经辐射场”(Neural Radiance Fields, NeRF)的方法,彻底改变了这一领域。

NeRF的核心思想非常优雅:它不直接构建显式的3D网格或点云,而是用一个神经网络隐式地学习一个连续的3D场景表示。这个网络就像一个“魔法黑盒”,你只需要输入一组从不同角度拍摄的2D照片及其对应的相机参数,它就能学会整个3D空间的“样子”,并可以渲染出任意新视角下的逼真图像。

NeRF渲染效果对比图

图1: NeRF的渲染效果(右)与真实照片(左)对比,展示了其惊人的真实感。 (图片来源: Mildenhall et al., ECCV 2020)

核心概念:什么是NeRF?

神经辐射场本质上是一个函数,它由多层感知机(MLP)参数化。这个函数将5D坐标作为输入,并输出该点的颜色和体积密度。

  • 输入 (5D坐标)
    • 空间位置 \((x, y, z)\)
    • 观察方向 \((\theta, \phi)\),通常用3D向量 \((d_x, d_y, d_z)\) 表示。
  • 输出
    • 体积密度 \(\sigma\):一个标量,表示该空间点被“占据”的可能性,与观察方向无关。
    • RGB颜色 \((r, g, b)\):一个三维向量,表示从该观察方向看过去,该点的颜色。

用数学公式表示这个函数就是:

\[ F_{\Theta}: (x, y, z, d_x, d_y, d_z) \rightarrow (r, g, b, \sigma) \]

其中 \(\Theta\) 代表神经网络的权重参数。网络通过学习来逼近这个复杂的映射关系,从而编码了整个3D场景的光照、几何和材质信息。

工作流程:从输入到渲染

NeRF的工作流程可以分为训练和渲染两个阶段。

训练阶段

  • 数据准备:收集一组同一静态场景的多视角2D图像,并精确标定每张图像的相机位姿(位置和朝向)。
  • 射线投射:对于训练图像中的每个像素,根据相机模型生成一条从相机原点穿过该像素的3D射线。
  • 采样与查询:沿着这条射线在近景和远景边界之间采样一系列3D点。将每个点的坐标和射线方向输入NeRF网络,得到该点的颜色和密度。
  • 体渲染与损失计算:通过“体渲染”方程(见下一节)将这条射线上所有采样点的颜色和密度积分,合成该像素的预测颜色。然后与训练图像中该像素的真实颜色计算损失(如均方误差),并通过反向传播更新网络参数。
NeRF工作流程示意图

图2: NeRF工作流程示意图:沿着相机射线采样,通过MLP查询颜色和密度,最后通过体渲染合成像素颜色。(图片来源: Mildenhall et al., ECCV 2020)

渲染(推理)阶段

训练完成后,要渲染一个新视角的图像,只需指定该视角的相机参数,然后重复上述“射线投射 -> 采样查询 -> 体渲染”的过程,为虚拟相机图像平面的每个像素生成颜色即可。

体渲染:合成像素的关键

体渲染是连接离散采样点与最终2D图像的核心技术。它模拟了光线在参与性介质(如雾、云)中传播并累积颜色的物理过程。

对于一条射线 \(r(t) = o + t d\),其最终渲染颜色 \(C(r)\) 的计算公式为:

\[ C(r) = \int_{t_n}^{t_f} T(t) \cdot \sigma(r(t)) \cdot c(r(t), d) \, dt \]

其中:

  • \(t_n\) 和 \(t_f\) 是射线的近端和远端边界。
  • \(\sigma(r(t))\) 和 \(c(r(t), d)\) 是网络在点 \(r(t)\) 处预测的密度和颜色。
  • \(T(t)\) 是累积透射率,表示光线从 \(t_n\) 传播到 \(t\) 而没有击中任何粒子的概率:
\[ T(t) = \exp \left( - \int_{t_n}^{t} \sigma(r(s)) \, ds \right) \]

在实际实现中,这个连续积分通过离散采样来近似计算。直观理解是:光线沿着射线前进,在密度高的地方(物体表面)会“吸收”更多该点的颜色,并阻止光线继续穿透,从而自然形成了物体的遮挡关系和外观。

位置编码:学习高频细节

研究者发现,如果将原始的 \((x, y, z)\) 坐标直接输入MLP,网络倾向于学习过于平滑的函数,导致渲染结果模糊,丢失高频的纹理和几何细节(如物体的边缘、花纹)。

为了解决这个问题,NeRF采用了一种称为“位置编码”或“正弦编码”的技巧。它将低维输入映射到更高维的空间,使MLP能够更容易地拟合高频变化。编码函数 \(\gamma\) 定义如下:

\[ \gamma(p) = (\sin(2^0 \pi p), \cos(2^0 \pi p), \dots, \sin(2^{L-1} \pi p), \cos(2^{L-1} \pi p)) \]

其中 \(p\) 是输入的一个标量分量(如 \(x\)),\(L\) 是编码的频率级别数量。这个操作将每个标量扩展为 \(2L\) 维的向量。在实践中,空间坐标 \((x,y,z)\) 和观察方向 \((d_x, d_y, d_z)\) 会分别经过不同 \(L\) 值的位置编码后再拼接起来输入网络。

这个简单的技巧对NeRF生成清晰、细节丰富的图像起到了至关重要的作用。

优势与挑战

主要优势

  • 超高视觉质量:能够合成具有复杂光照、反射和半透明效果的逼真新视角图像,质量远超许多传统方法。
  • 隐式连续表示:场景被表示为一个连续函数,理论上具有无限分辨率,避免了体素或网格表示中的离散化瑕疵。
  • 输入简单:仅需要2D图像和相机参数,无需深度图、激光扫描等特殊硬件。
  • 视角一致性:由于模型学习了整个3D空间的辐射场,其生成的所有新视角在几何和外观上都是严格一致的。

当前挑战

  • 训练与渲染速度极慢:原始NeRF渲染一张图需要数分钟,训练一个场景需要数小时到数天。这是其最突出的瓶颈。
  • 仅限静态场景:原始框架无法处理动态物体或场景变化。
  • 对输入数据要求高:需要覆盖较全的多视角图像和精确的相机标定。遮挡严重或反射强烈的区域重建效果可能不佳。
  • 编辑困难:由于是隐式表示,难以像操作网格一样对重建出的模型进行直接编辑。

代码实现核心

以下是使用PyTorch框架实现NeRF核心组件的一个高度简化的示例,展示了位置编码、网络前向传播和体渲染积分的核心思想。

import torch
import torch.nn as nn
import torch.nn.functional as F

class PositionalEncoder(nn.Module):
    """位置编码器"""
    def __init__(self, L=10):
        super().__init__()
        self.L = L

    def forward(self, x):
        # x: [..., 1]
        encodings = []
        for i in range(self.L):
            encodings.append(torch.sin(2**i * torch.pi * x))
            encodings.append(torch.cos(2**i * torch.pi * x))
        return torch.cat(encodings, dim=-1)  # 输出维度: [..., 2*L]

class TinyNeRF(nn.Module):
    """一个极简的NeRF网络结构"""
    def __init__(self, pos_L=10, dir_L=4, hidden_dim=256):
        super().__init__()
        # 位置编码后的维度: 3*2*pos_L = 60 (当pos_L=10时)
        input_dim = 3 * 2 * pos_L
        # 方向编码在中间层接入
        dir_dim = 3 * 2 * dir_L

        self.block1 = nn.Sequential(nn.Linear(input_dim, hidden_dim), nn.ReLU())
        self.block2 = nn.Sequential(nn.Linear(hidden_dim, hidden_dim), nn.ReLU())
        self.block3 = nn.Sequential(nn.Linear(hidden_dim, hidden_dim), nn.ReLU())

        # 输出密度(标量)和中间特征
        self.sigma_layer = nn.Linear(hidden_dim, 1)
        self.feature_layer = nn.Linear(hidden_dim, hidden_dim)

        # 将特征与编码后的方向结合,预测颜色
        self.color_layer = nn.Sequential(
            nn.Linear(hidden_dim + dir_dim, hidden_dim//2),
            nn.ReLU(),
            nn.Linear(hidden_dim//2, 3),  # RGB
            nn.Sigmoid()  # 颜色值在0-1之间
        )

    def forward(self, x, d):
        # x: 位置 [N, 3], d: 方向 [N, 3]
        # 1. 位置编码和主干网络
        encoded_x = self.positional_encode(x, self.pos_L)
        h = self.block1(encoded_x)
        h = self.block2(h) + h  # 残差连接
        h = self.block3(h)

        # 2. 预测密度
        sigma = F.relu(self.sigma_layer(h))  # 密度应为非负

        # 3. 预测颜色(需要方向信息)
        encoded_d = self.positional_encode(d, self.dir_L)
        feature = self.feature_layer(h)
        h_color = torch.cat([feature, encoded_d], dim=-1)
        color = self.color_layer(h_color)

        return color, sigma

def volume_rendering(sigmas, colors, t_vals):
    """离散体渲染积分"""
    # sigmas, colors: [N_rays, N_samples, 1/3]
    # t_vals: [N_samples],采样点的t值
    deltas = t_vals[1:] - t_vals[:-1]  # 计算采样区间长度
    # 最后一个区间用一个大数填充
    deltas = torch.cat([deltas, torch.tensor([1e10], device=deltas.device).expand(deltas[...,:1].shape)], dim=-1)

    # 计算透射率 T_i 和权重 w_i
    alpha = 1 - torch.exp(-sigmas * deltas.unsqueeze(-1))  # [N_rays, N_samples, 1]
    exp_term = torch.exp(-torch.cumsum(sigmas * deltas.unsqueeze(-1), dim=1))  # 累积透射率
    transmittance = torch.cat([torch.ones_like(exp_term[:,:1]), exp_term[:,:-1]], dim=1)
    weights = transmittance * alpha  # [N_rays, N_samples, 1]

    # 加权求和得到最终像素颜色
    rendered_color = torch.sum(weights * colors, dim=1)  # [N_rays, 3]

    return rendered_color, weights

这段代码省略了数据加载、射线生成、分层采样(Hierarchical Sampling)和训练循环等复杂部分,但清晰地展示了NeRF模型架构和渲染算法的核心逻辑。