代码示例 / 计算机视觉 / 3D体积渲染与NeRF

3D体积渲染与NeRF

作者: Aritra Roy Gosthipaty, Ritwik Raha
创建日期: 2021/08/09
最后修改日期: 2023/11/13
描述: 体积渲染的最小实现,如NeRF所示。

在Colab中查看 GitHub源代码


介绍

在这个示例中,我们展示了研究论文 NeRF: Representing Scenes as Neural Radiance Fields for View Synthesis 由Ben Mildenhall等人提出。作者提出了一种巧妙的方法 通过神经网络建模体积场景函数合成场景的新视图

为了帮助您直观理解这一点,让我们从以下问题开始: 是否有可能向神经网络提供图像中一个像素的位置,并要求网络 预测该位置的颜色?

2d-train
图1: 一个神经网络接受图像坐标作为输入并预测该坐标的颜色。

神经网络假设上会记忆(过拟合)该图像。这意味着我们的神经网络会在其权重中编码整个图像。我们可以通过每个位置查询神经网络,最终它会重新构建整个图像。

2d-test
图2: 训练好的神经网络从头重建图像。

现在出现了一个问题,我们如何扩展这个想法来学习一个3D 体积场景?实现与上述类似的过程将需要了解每个体素(体积像素)。事实证明,这是一项相当具挑战性的任务。

论文的作者提出了一种最小且优雅的方法,通过几张场景图像来学习一个3D场景。他们放弃了体素的使用用于训练。网络学习建模体积场景,从而生成模型在训练时未显示的3D场景的新视图(图像)。

要充分理解这个过程,需要了解一些前提知识。我们将示例结构化,以便您在开始实现之前具有所有必要的知识。


设置

import os

os.environ["KERAS_BACKEND"] = "tensorflow"

# 设置随机种子以获得可重复的结果。
import tensorflow as tf

tf.random.set_seed(42)

import keras
from keras import layers

import os
import glob
import imageio.v2 as imageio
import numpy as np
from tqdm import tqdm
import matplotlib.pyplot as plt

# 初始化全局变量。
AUTO = tf.data.AUTOTUNE
BATCH_SIZE = 5
NUM_SAMPLES = 32
POS_ENCODE_DIMS = 16
EPOCHS = 20

下载和加载数据

npz数据文件包含图像、相机位姿和焦距。 这些图像是从多个相机角度拍摄的,如 图3所示。

camera-angles
图3: 多个相机角度
来源: NeRF

要理解在这种情况下的相机位姿,我们首先要考虑 相机是现实世界与二维图像之间的映射

mapping
图4: 通过相机进行的3D世界到2D图像的映射
来源: Mathworks

考虑以下方程:

其中x是二维图像点,X是三维世界点,P是相机矩阵。P是一个3 x 4矩阵,起着将现实世界对象映射到图像平面的关键作用。

相机矩阵是一个仿射变换矩阵,与一个3 x 1列[图像高度,图像宽度,焦距] 连接以生成位姿矩阵。该矩阵的大小为3 x 5,其中前三个3 x 3块是相机的视点。轴为[向下,向右,向后][-y, x, z],其中相机正对-z方向。

camera-mapping
图5: 仿射变换。

COLMAP框架为[向右,向下,向前][x, -y, -z]。有关COLMAP的更多信息,请点击这里

# Download the data if it does not already exist.
url = (
    "http://cseweb.ucsd.edu/~viscomp/projects/LF/papers/ECCV20/nerf/tiny_nerf_data.npz"
)
data = keras.utils.get_file(origin=url)

data = np.load(data)
images = data["images"]
im_shape = images.shape
(num_images, H, W, _) = images.shape
(poses, focal) = (data["poses"], data["focal"])

# Plot a random image from the dataset for visualization.
plt.imshow(images[np.random.randint(low=0, high=num_images)])
plt.show()

png


数据管道

现在你已经理解了相机矩阵的概念以及从 3D 场景到 2D 图像的映射,接下来我们来谈谈逆映射,即从 2D 图像到 3D 场景。

我们需要讨论通过光线投射和追踪的体积渲染,这些都是常见的计算机图形技术。本节将帮助你快速掌握这些技术。

考虑一幅包含 N 像素的图像。我们通过每个像素射出一条光线,并在光线上采样一些点。光线通常通过方程 r(t) = o + td 进行参数化,其中 t 是参数,o 是原点,d 是单位方向向量,如 图 6 所示。

img
图 6: r(t) = o + td 其中 t 为 3

图 7 中,我们考虑一条光线,并在光线上采样一些随机点。这些采样点每个都有一个独特的位置 (x, y, z),而光线具有一个视角 (theta, phi)。视角特别有趣,因为我们可以通过单个像素以很多不同的方式射出一条光线,每种方式都有一个独特的视角。这里另一个有趣的点是,采样过程中添加的噪声。我们给每个样本添加均匀噪声,使得这些样本对应于一个连续的分布。在 图 7 中,蓝色点是均匀分布的样本,而白色点 (t1, t2, t3) 是随机放置在样本之间的。

img
图 7: 从光线上采样点。

图 8 展示了 3D 中的整个采样过程,你可以看到从白色图像中射出的光线。这意味着每个像素将拥有对应的光线,并且每条光线将在不同的点上进行采样。

3-d rays
图 8: 从图像的所有像素发射光线的 3-D

这些采样点作为 NeRF 模型的输入。然后要求模型预测该点的 RGB 颜色和体积密度。

3-Drender
图 9: 数据管道
来源: NeRF
def encode_position(x):
    """将位置编码为其对应的傅里叶特征。

    Args:
        x: 输入坐标。

    Returns:
        位置的傅里叶特征张量。
    """
    positions = [x]
    for i in range(POS_ENCODE_DIMS):
        for fn in [tf.sin, tf.cos]:
            positions.append(fn(2.0**i * x))
    return tf.concat(positions, axis=-1)


def get_rays(height, width, focal, pose):
    """计算光线的原点和方向向量。

    Args:
        height: 图像的高度。
        width: 图像的宽度。
        focal: 图像与相机之间的焦距。
        pose: 相机的姿态矩阵。

    Returns:
        光线的原点和方向向量的元组。
    """
    # 为光线构建网格。
    i, j = tf.meshgrid(
        tf.range(width, dtype=tf.float32),
        tf.range(height, dtype=tf.float32),
        indexing="xy",
    )

    # 归一化 x 坐标。
    transformed_i = (i - width * 0.5) / focal

    # 归一化 y 坐标。
    transformed_j = (j - height * 0.5) / focal

    # 创建方向单位向量。
    directions = tf.stack([transformed_i, -transformed_j, -tf.ones_like(i)], axis=-1)

    # 获取相机矩阵。
    camera_matrix = pose[:3, :3]
    height_width_focal = pose[:3, -1]

    # 获取光线的原点和方向。
    transformed_dirs = directions[..., None, :]
    camera_dirs = transformed_dirs * camera_matrix
    ray_directions = tf.reduce_sum(camera_dirs, axis=-1)
    ray_origins = tf.broadcast_to(height_width_focal, tf.shape(ray_directions))

    # 返回原点和方向。
    return (ray_origins, ray_directions)


def render_flat_rays(ray_origins, ray_directions, near, far, num_samples, rand=False):
    """渲染光线并将其扁平化。

    Args:
        ray_origins: 光线的原点。
        ray_directions: 光线的方向单位向量。
        near: 体积场景的近边界。
        far: 体积场景的远边界。
        num_samples: 光线上的采样点数量。
        rand: 随机化采样策略的选择。

    Returns:
       扁平化光线和每条光线上的采样点的元组。
    """
    # 计算 3D 查询点。
    # 方程:r(t) = o+td -> 在这里构建 "t"。
    t_vals = tf.linspace(near, far, num_samples)
    if rand:
        # 在采样空间中注入均匀噪声,以使采样
        # 连续。
        shape = list(ray_origins.shape[:-1]) + [num_samples]
        noise = tf.random.uniform(shape=shape) * (far - near) / num_samples
        t_vals = t_vals + noise

    # 方程:r(t) = o + td -> 在这里构建 "r"。
    rays = ray_origins[..., None, :] + (
        ray_directions[..., None, :] * t_vals[..., None]
    )
    rays_flat = tf.reshape(rays, [-1, 3])
    rays_flat = encode_position(rays_flat)
    return (rays_flat, t_vals)


def map_fn(pose):
    """将单个姿态映射到扁平化光线和采样点。

    Args:
        pose: 相机的姿态矩阵。

    Returns:
        与相机姿态对应的扁平化光线和采样点的元组。
    """
    (ray_origins, ray_directions) = get_rays(height=H, width=W, focal=focal, pose=pose)
    (rays_flat, t_vals) = render_flat_rays(
        ray_origins=ray_origins,
        ray_directions=ray_directions,
        near=2.0,
        far=6.0,
        num_samples=NUM_SAMPLES,
        rand=True,
    )
    return (rays_flat, t_vals)


# 创建训练集划分。
split_index = int(num_images * 0.8)

# 将图像分为训练集和验证集。
train_images = images[:split_index]
val_images = images[split_index:]

# 将姿态分为训练集和验证集。
train_poses = poses[:split_index]
val_poses = poses[split_index:]

# 制作训练管道。
train_img_ds = tf.data.Dataset.from_tensor_slices(train_images)
train_pose_ds = tf.data.Dataset.from_tensor_slices(train_poses)
train_ray_ds = train_pose_ds.map(map_fn, num_parallel_calls=AUTO)
training_ds = tf.data.Dataset.zip((train_img_ds, train_ray_ds))
train_ds = (
    training_ds.shuffle(BATCH_SIZE)
    .batch(BATCH_SIZE, drop_remainder=True, num_parallel_calls=AUTO)
    .prefetch(AUTO)
)

# 制作验证管道。
val_img_ds = tf.data.Dataset.from_tensor_slices(val_images)
val_pose_ds = tf.data.Dataset.from_tensor_slices(val_poses)
val_ray_ds = val_pose_ds.map(map_fn, num_parallel_calls=AUTO)
validation_ds = tf.data.Dataset.zip((val_img_ds, val_ray_ds))
val_ds = (
    validation_ds.shuffle(BATCH_SIZE)
    .batch(BATCH_SIZE, drop_remainder=True, num_parallel_calls=AUTO)
    .prefetch(AUTO)
)

NeRF模型

该模型是一个多层感知器(MLP),其非线性采用ReLU。

论文摘录:

"我们通过限制网络仅根据位置 x 预测体积密度 sigma 来鼓励表示具有多视角一致性,同时允许 RGB 颜色 c 作为位置和视角方向的函数进行预测。为实现这一点,MLP 首先用 8 个全连接层(使用ReLU激活和每层256个通道)处理输入的3D坐标x,并输出 sigma 和一个256维的特征向量。然后将该特征向量与摄像机光线的视角方向拼接,并传递给一层额外的全连接层(使用 ReLU 激活和 128 个通道),该层输出视角依赖的 RGB 颜色。

在这里,我们采用了最小实现,使用了 64 个 Dense 单元,而不是论文中提到的 256。

def get_nerf_model(num_layers, num_pos):
    """生成 NeRF 神经网络。

    参数:
        num_layers: MLP 层数。
        num_pos: 位置编码的维度数量。

    返回:
        `keras` 模型。
    """
    inputs = keras.Input(shape=(num_pos, 2 * 3 * POS_ENCODE_DIMS + 3))
    x = inputs
    for i in range(num_layers):
        x = layers.Dense(units=64, activation="relu")(x)
        if i % 4 == 0 and i > 0:
            # 注入残差连接。
            x = layers.concatenate([x, inputs], axis=-1)
    outputs = layers.Dense(units=4)(x)
    return keras.Model(inputs=inputs, outputs=outputs)


def render_rgb_depth(model, rays_flat, t_vals, rand=True, train=True):
    """从模型预测生成 RGB 图像和深度图。

    参数:
        model: 训练用于预测 RGB 和体积密度的 MLP 模型。
        rays_flat: 作为 NeRF 模型输入的扁平化光线。
        t_vals: 光线的采样点。
        rand: 选择随机化采样策略。
        train: 模型是否处于训练或测试阶段。

    返回:
        RGB 图像和深度图的元组。
    """
    # 从 NeRF 模型获取预测并重新调整形状。
    if train:
        predictions = model(rays_flat)
    else:
        predictions = model.predict(rays_flat)
    predictions = tf.reshape(predictions, shape=(BATCH_SIZE, H, W, NUM_SAMPLES, 4))

    # 将预测切片为 rgb 和 sigma。
    rgb = tf.sigmoid(predictions[..., :-1])
    sigma_a = tf.nn.relu(predictions[..., -1])

    # 获取相邻区间的距离。
    delta = t_vals[..., 1:] - t_vals[..., :-1]
    # delta 的形状 = (num_samples)
    if rand:
        delta = tf.concat(
            [delta, tf.broadcast_to([1e10], shape=(BATCH_SIZE, H, W, 1))], axis=-1
        )
        alpha = 1.0 - tf.exp(-sigma_a * delta)
    else:
        delta = tf.concat(
            [delta, tf.broadcast_to([1e10], shape=(BATCH_SIZE, 1))], axis=-1
        )
        alpha = 1.0 - tf.exp(-sigma_a * delta[:, None, None, :])

    # 获取透射率。
    exp_term = 1.0 - alpha
    epsilon = 1e-10
    transmittance = tf.math.cumprod(exp_term + epsilon, axis=-1, exclusive=True)
    weights = alpha * transmittance
    rgb = tf.reduce_sum(weights[..., None] * rgb, axis=-2)

    if rand:
        depth_map = tf.reduce_sum(weights * t_vals, axis=-1)
    else:
        depth_map = tf.reduce_sum(weights * t_vals[:, None, None], axis=-1)
    return (rgb, depth_map)

训练

训练步骤作为自定义 keras.Model 子类的一部分实现,以便我们可以利用 model.fit 功能。

class NeRF(keras.Model):
    def __init__(self, nerf_model):
        super().__init__()
        self.nerf_model = nerf_model

    def compile(self, optimizer, loss_fn):
        super().compile()
        self.optimizer = optimizer
        self.loss_fn = loss_fn
        self.loss_tracker = keras.metrics.Mean(name="loss")
        self.psnr_metric = keras.metrics.Mean(name="psnr")

    def train_step(self, inputs):
        # 获取图像和光线。
        (images, rays) = inputs
        (rays_flat, t_vals) = rays

        with tf.GradientTape() as tape:
            # 获取模型的预测。
            rgb, _ = render_rgb_depth(
                model=self.nerf_model, rays_flat=rays_flat, t_vals=t_vals, rand=True
            )
            loss = self.loss_fn(images, rgb)

        # 获取可训练变量。
        trainable_variables = self.nerf_model.trainable_variables

        # 获取可训练变量相对于损失的梯度。
        gradients = tape.gradient(loss, trainable_variables)

        # 应用梯度并优化模型。
        self.optimizer.apply_gradients(zip(gradients, trainable_variables))

        # 获取重构图像与源图像的PSNR。
        psnr = tf.image.psnr(images, rgb, max_val=1.0)

        # 计算我们自己的指标
        self.loss_tracker.update_state(loss)
        self.psnr_metric.update_state(psnr)
        return {"loss": self.loss_tracker.result(), "psnr": self.psnr_metric.result()}

    def test_step(self, inputs):
        # 获取图像和光线。
        (images, rays) = inputs
        (rays_flat, t_vals) = rays

        # 获取模型的预测。
        rgb, _ = render_rgb_depth(
            model=self.nerf_model, rays_flat=rays_flat, t_vals=t_vals, rand=True
        )
        loss = self.loss_fn(images, rgb)

        # 获取重构图像与源图像的PSNR。
        psnr = tf.image.psnr(images, rgb, max_val=1.0)

        # 计算我们自己的指标
        self.loss_tracker.update_state(loss)
        self.psnr_metric.update_state(psnr)
        return {"loss": self.loss_tracker.result(), "psnr": self.psnr_metric.result()}

    @property
    def metrics(self):
        return [self.loss_tracker, self.psnr_metric]


test_imgs, test_rays = next(iter(train_ds))
test_rays_flat, test_t_vals = test_rays

loss_list = []


class TrainMonitor(keras.callbacks.Callback):
    def on_epoch_end(self, epoch, logs=None):
        loss = logs["loss"]
        loss_list.append(loss)
        test_recons_images, depth_maps = render_rgb_depth(
            model=self.model.nerf_model,
            rays_flat=test_rays_flat,
            t_vals=test_t_vals,
            rand=True,
            train=False,
        )

        # 绘制rgb、深度和损失图。
        fig, ax = plt.subplots(nrows=1, ncols=3, figsize=(20, 5))
        ax[0].imshow(keras.utils.array_to_img(test_recons_images[0]))
        ax[0].set_title(f"预测图像: {epoch:03d}")

        ax[1].imshow(keras.utils.array_to_img(depth_maps[0, ..., None]))
        ax[1].set_title(f"深度图: {epoch:03d}")

        ax[2].plot(loss_list)
        ax[2].set_xticks(np.arange(0, EPOCHS + 1, 5.0))
        ax[2].set_title(f"损失图: {epoch:03d}")

        fig.savefig(f"images/{epoch:03d}.png")
        plt.show()
        plt.close()


num_pos = H * W * NUM_SAMPLES
nerf_model = get_nerf_model(num_layers=8, num_pos=num_pos)

model = NeRF(nerf_model)
model.compile(
    optimizer=keras.optimizers.Adam(), loss_fn=keras.losses.MeanSquaredError()
)

# 创建一个目录,在训练期间保存图像。
if not os.path.exists("images"):
    os.makedirs("images")

model.fit(
    train_ds,
    validation_data=val_ds,
    batch_size=BATCH_SIZE,
    epochs=EPOCHS,
    callbacks=[TrainMonitor()],
)


def create_gif(path_to_images, name_gif):
    filenames = glob.glob(path_to_images)
    filenames = sorted(filenames)
    images = []
    for filename in tqdm(filenames):
        images.append(imageio.imread(filename))
    kargs = {"duration": 0.25}
    imageio.mimsave(name_gif, images, "GIF", **kargs)


create_gif("images/*.png", "training.gif")
Epoch 1/20
  1/16 ━━━━━━━━━━━━━━━━━━━━  3:54 16s/step - loss: 0.0948 - psnr: 10.6234

WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1699908753.457905   65271 device_compiler.h:187] Compiled cluster using XLA!  This line is logged at most once for the lifetime of the process.

 1/1 ━━━━━━━━━━━━━━━━━━━━ 1s 924ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 29s 889ms/step - loss: 0.1091 - psnr: 9.8283 - val_loss: 0.0753 - val_psnr: 11.5686
Epoch 2/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 477ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 926ms/step - loss: 0.0633 - psnr: 12.4819 - val_loss: 0.0657 - val_psnr: 12.1781
Epoch 3/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 921ms/step - loss: 0.0589 - psnr: 12.6268 - val_loss: 0.0637 - val_psnr: 12.3413
Epoch 4/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 470ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 15s 915ms/step - loss: 0.0573 - psnr: 12.8150 - val_loss: 0.0617 - val_psnr: 12.4789
Epoch 5/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 477ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 15s 918ms/step - loss: 0.0552 - psnr: 12.9703 - val_loss: 0.0594 - val_psnr: 12.6457
Epoch 6/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 476ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 15s 894ms/step - loss: 0.0538 - psnr: 13.0895 - val_loss: 0.0533 - val_psnr: 13.0049
Epoch 7/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 473ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 940ms/step - loss: 0.0436 - psnr: 13.9857 - val_loss: 0.0381 - val_psnr: 14.4764
Epoch 8/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 475ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 15s 919ms/step - loss: 0.0325 - psnr: 15.1856 - val_loss: 0.0294 - val_psnr: 15.5187
Epoch 9/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 478ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 927ms/step - loss: 0.0276 - psnr: 15.8105 - val_loss: 0.0259 - val_psnr: 16.0297
Epoch 10/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 952ms/step - loss: 0.0251 - psnr: 16.1994 - val_loss: 0.0252 - val_psnr: 16.0842
Epoch 11/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 15s 909ms/step - loss: 0.0239 - psnr: 16.3749 - val_loss: 0.0228 - val_psnr: 16.5269
Epoch 12/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 19s 1s/step - loss: 0.0215 - psnr: 16.8117 - val_loss: 0.0186 - val_psnr: 17.3930
Epoch 13/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 923ms/step - loss: 0.0188 - psnr: 17.3916 - val_loss: 0.0174 - val_psnr: 17.6570
Epoch 14/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 476ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 973ms/step - loss: 0.0175 - psnr: 17.6871 - val_loss: 0.0172 - val_psnr: 17.6644
Epoch 15/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 468ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 15s 919ms/step - loss: 0.0172 - psnr: 17.7639 - val_loss: 0.0161 - val_psnr: 18.0313
Epoch 16/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 477ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 915ms/step - loss: 0.0150 - psnr: 18.3860 - val_loss: 0.0151 - val_psnr: 18.2832
Epoch 17/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 473ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 926ms/step - loss: 0.0154 - psnr: 18.2210 - val_loss: 0.0146 - val_psnr: 18.4284
Epoch 18/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 468ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 959ms/step - loss: 0.0145 - psnr: 18.4869 - val_loss: 0.0134 - val_psnr: 18.8039
Epoch 19/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 473ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 16s 933ms/step - loss: 0.0136 - psnr: 18.8040 - val_loss: 0.0138 - val_psnr: 18.6680
Epoch 20/20
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 472ms/step

png

 16/16 ━━━━━━━━━━━━━━━━━━━━ 15s 916ms/step - loss: 0.0131 - psnr: 18.9661 - val_loss: 0.0132 - val_psnr: 18.8687

100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 20/20 [00:00<00:00, 59.40it/s]

可视化训练步骤

在这里,我们看到训练步骤。随着损失的减少,渲染的 图像和深度图变得更好。在你的本地系统中,你 将看到生成的 training.gif 文件。

training-20


推断

在这一部分,我们要求模型建立场景的新视角。 模型在训练步骤中给予了 106 个场景视角。 训练图像的集合无法包含场景的每个角度。 经过训练的模型可以用稀疏的训练图像集表示整个三维场景。

在这里,我们为模型提供不同的姿态,并要求它给出 对应于该相机视角的二维图像。如果我们对模型进行 360度视角的推断,它应该提供从各个方向的 整个景观的概述。

# 获取经过训练的 NeRF 模型并进行推断。
nerf_model = model.nerf_model
test_recons_images, depth_maps = render_rgb_depth(
    model=nerf_model,
    rays_flat=test_rays_flat,
    t_vals=test_t_vals,
    rand=True,
    train=False,
)

# 创建子图。
fig, axes = plt.subplots(nrows=5, ncols=3, figsize=(10, 20))

for ax, ori_img, recons_img, depth_map in zip(
    axes, test_imgs, test_recons_images, depth_maps
):
    ax[0].imshow(keras.utils.array_to_img(ori_img))
    ax[0].set_title("原始")

    ax[1].imshow(keras.utils.array_to_img(recons_img))
    ax[1].set_title("重建")

    ax[2].imshow(keras.utils.array_to_img(depth_map[..., None]), cmap="inferno")
    ax[2].set_title("深度图")
 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 475ms/step

png


渲染3D场景

在这里,我们将合成新颖的3D视角并将它们拼接在一起 以渲染一个包含360度视角的视频。

def get_translation_t(t):
    """获取移动的平移矩阵 t。"""
    matrix = [
        [1, 0, 0, 0],
        [0, 1, 0, 0],
        [0, 0, 1, t],
        [0, 0, 0, 1],
    ]
    return tf.convert_to_tensor(matrix, dtype=tf.float32)


def get_rotation_phi(phi):
    """获取移动的旋转矩阵 phi。"""
    matrix = [
        [1, 0, 0, 0],
        [0, tf.cos(phi), -tf.sin(phi), 0],
        [0, tf.sin(phi), tf.cos(phi), 0],
        [0, 0, 0, 1],
    ]
    return tf.convert_to_tensor(matrix, dtype=tf.float32)


def get_rotation_theta(theta):
    """获取移动的旋转矩阵 theta。"""
    matrix = [
        [tf.cos(theta), 0, -tf.sin(theta), 0],
        [0, 1, 0, 0],
        [tf.sin(theta), 0, tf.cos(theta), 0],
        [0, 0, 0, 1],
    ]
    return tf.convert_to_tensor(matrix, dtype=tf.float32)


def pose_spherical(theta, phi, t):
    """
    获取对应的 theta、phi 和 t 的相机到世界矩阵。
    """
    c2w = get_translation_t(t)
    c2w = get_rotation_phi(phi / 180.0 * np.pi) @ c2w
    c2w = get_rotation_theta(theta / 180.0 * np.pi) @ c2w
    c2w = np.array([[-1, 0, 0, 0], [0, 0, 1, 0], [0, 1, 0, 0], [0, 0, 0, 1]]) @ c2w
    return c2w


rgb_frames = []
batch_flat = []
batch_t = []

# 遍历不同的 theta 值并生成场景。
for index, theta in tqdm(enumerate(np.linspace(0.0, 360.0, 120, endpoint=False))):
    # 获取相机到世界的矩阵。
    c2w = pose_spherical(theta, -30.0, 4.0)

    #
    ray_oris, ray_dirs = get_rays(H, W, focal, c2w)
    rays_flat, t_vals = render_flat_rays(
        ray_oris, ray_dirs, near=2.0, far=6.0, num_samples=NUM_SAMPLES, rand=False
    )

    if index % BATCH_SIZE == 0 and index > 0:
        batched_flat = tf.stack(batch_flat, axis=0)
        batch_flat = [rays_flat]

        batched_t = tf.stack(batch_t, axis=0)
        batch_t = [t_vals]

        rgb, _ = render_rgb_depth(
            nerf_model, batched_flat, batched_t, rand=False, train=False
        )

        temp_rgb = [np.clip(255 * img, 0.0, 255.0).astype(np.uint8) for img in rgb]

        rgb_frames = rgb_frames + temp_rgb
    else:
        batch_flat.append(rays_flat)
        batch_t.append(t_vals)

rgb_video = "rgb_video.mp4"
imageio.mimwrite(rgb_video, rgb_frames, fps=30, quality=7, macro_block_size=None)
1it [00:01,  1.02s/it]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 475ms/step

6it [00:03,  1.95it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 478ms/step

11it [00:05,  2.11it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

16it [00:07,  2.17it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 477ms/step

25it [00:10,  3.05it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 477ms/step

27it [00:12,  2.14it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 479ms/step

31it [00:14,  2.02it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 472ms/step

36it [00:16,  2.11it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

41it [00:18,  2.16it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 472ms/step

46it [00:21,  2.19it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 475ms/step

51it [00:23,  2.22it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 473ms/step

56it [00:25,  2.24it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 464ms/step

61it [00:27,  2.26it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

66it [00:29,  2.26it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 476ms/step

71it [00:32,  2.26it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 473ms/step

76it [00:34,  2.26it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 475ms/step

81it [00:36,  2.26it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

86it [00:38,  2.26it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 476ms/step

91it [00:40,  2.26it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 465ms/step

96it [00:43,  2.27it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 473ms/step

101it [00:45,  2.28it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 473ms/step

106it [00:47,  2.28it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 473ms/step

111it [00:49,  2.27it/s]

 1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 474ms/step

120it [00:52,  2.31it/s]
[swscaler @ 0x67626c0] 警告: 数据未对齐!这可能导致速度损失

可视化视频

在这里,我们可以看到场景的360度渲染视图。模型成功地通过仅20个周期学习了整个体积空间。您可以查看本地保存的渲染视频,文件名为rgb_video.mp4

渲染视频


结论

我们已制作了NeRF的最小实现,以提供对其核心思想和方法论的直观理解。这种方法已在计算机图形领域的其他多个工作中得到应用。

我们希望鼓励我们的读者将此代码作为示例,调整超参数并可视化输出。下面我们还提供了经过更多周期训练的模型输出。

训练周期 训练步骤的GIF
100 100个周期训练
200 200个周期训练

前进方向

如果有人对深入了解NeRF感兴趣,我们在PyImageSearch上建立了一个三部分的博客系列。


参考文献

您可以在Hugging Face Spaces上尝试该模型。