PaddlePaddle实现NeRF:从多视角照片到文物三维重建的极简指南
简介面向文物保护与计算机视觉方向的毕业设计、课程作业场景这份基于飞桨框架实现神经辐射场的文物重建系统源码可将二维图像集重建为三维模型。项目代码完整、功能验证通过适合计算机、人工智能、数据科学等相关专业学生用于入门进阶或二次开发。压缩包共119个文件主要包含36个Python源码文件、80张用于训练与测试的图片以及2个说明文档整体体积5.01MB结构精简、便于快速部署。已有110人学习下载。通过源码可深入理解NeRF从相机位姿估计、采样渲染到体积重建的完整流程掌握飞桨实现隐式神经场景表示的具体工程写法还可基于现有模块拓展不同文物的重建实验是一份兼具理论学习和实战参考价值的项目资料。代码按数据加载、网络构建、训练推理等模块组织注释清晰方便在此基础上做二次开发与效果调优。1. 用 PaddlePaddle 复现 NeRF文物重建的另一种打开方式拿到“文物重建 PaddlePaddle 框架实现基于 NeRF”这套源码时我首先做的是把 121.jpg、122.jpg、130.jpg 这些素材按拍摄轨迹重排了一遍。NeRF 不像传统三维重建那样靠特征点直接拼点云它是在多视角照片的约束下用一个 MLP 把场景写成连续函数输入空间坐标和观察方向输出该点的颜色和体积密度。PaddlePaddle 版本和常见 PyTorch 版本最大的差别在算子风格和梯度返传但训练逻辑基本互通。如果你只想把一组文物照片变成可旋转、可剖切的三维结果这套代码比传统 MVS 管线更适合处理陶罐、瓷器这类表面纹理弱、高光强的对象。对课程设计、毕业设计来说工程量集中在对 PaddlePaddle API 的适配和数据处理上适合作为 2D 到 3D 重建方向的入门项目。2. NeRF 隐式建模的 PaddlePaddle 实现坐标编码与 MLP 骨架2.1 一个连续场景函数怎么替代点云拼接传统 MVS 重建要先做特征匹配再三角化生成稀疏点云最后补成网格。遇到文物这种表面纹理弱、拍摄视角又带着反光的情况特征点数量会明显不够。NeRF 不直接产点而是把场景当作一个函数F(x, y, z, θ, φ) - (rgb, σ)其中σ表示该点的体积密度。训练完成后模型本身就是一个连续的三维场输出和图片分辨率没有直接关系。PaddlePaddle 实现里这个函数由两段 MLP 组成第一段对位置编码做映射得到密度和中间特征第二段把观察方向拼进去再预测颜色。这样设计是为了逼网络学会“密度只依赖位置颜色才依赖角度”的物理直觉。在真实文物重建里陶瓷表面的釉面反光会随视角变化所以这段角度依赖很重要。2.2 位置编码把低维坐标抬到高频空间直接拿连续坐标喂给 MLP网络会优先拟合低频形状细节很容易糊。位置编码的作用是把坐标映射到高频空间让网络有能力表达裂纹、纹饰这类细节。PaddlePaddle 版实现并不复杂核心代码如下。import paddle class PositionalEncoder(paddle.nn.Layer): def __init__(self, input_dim3, num_freqs10): super().__init__() self.input_dim input_dim self.num_freqs num_freqs self.out_dim input_dim * (1 2 * num_freqs) def forward(self, x): # x 形状 [B, 3]通常是归一化后的空间坐标 enc [x] for i in range(self.num_freqs): freq 2.0 ** i enc.append(paddle.sin(freq * x)) enc.append(paddle.cos(freq * x)) return paddle.concat(enc, axis-1)这里input_dim3对应x,y,z三个坐标分量num_freqs10是官方推荐值。输出维度是3*(12*10)63频率从2^0递增到2^9最低频负责整体轮廓最高频负责瓶身纹路。若num_freqs提高到 15网络容易去拟合图像噪声降到 5 以下重建出的陶器表面会明显发圆细节丢失严重。2.3 一个能直接改的 NeRF MLP 骨架下面是一个简化版的 PaddlePaddle 网络结构去掉了原版里冗余的跳跃连接但保留了“先密度后颜色”的核心逻辑。class NeRFMLP(paddle.nn.Layer): def __init__(self, hidden_dim256): super().__init__() self.pos_enc PositionalEncoder(3, 10) # 输出 63 维 self.dir_enc PositionalEncoder(3, 4) # 输出 27 维 self.density_fc paddle.nn.Linear(self.pos_enc.out_dim, hidden_dim) self.feature_fc paddle.nn.Linear(hidden_dim, hidden_dim) self.density_head paddle.nn.Linear(hidden_dim, 1) self.rgb_fc paddle.nn.Linear(hidden_dim self.dir_enc.out_dim, hidden_dim // 2) self.rgb_head paddle.nn.Linear(hidden_dim // 2, 3) def forward(self, pos, direction): pe paddle.nn.functional.relu(self.density_fc(self.pos_enc(pos))) fea paddle.nn.functional.relu(self.feature_fc(pe)) raw_density self.density_head(fea) # 不立即激活 dir_enc self.dir_enc(direction) # [B, 27] rgb_feat paddle.concat([fea, dir_enc], axis-1) rgb_feat paddle.nn.functional.relu(self.rgb_fc(rgb_feat)) rgb paddle.nn.functional.sigmoid(self.rgb_head(rgb_feat)) return rgb, raw_density密度输出不接激活函数留到体积渲染时再对密度做relu这样能保留负梯度颜色输出用sigmoid压到 0 到 1 之间。PaddlePaddle 版本里需要注意旧版本paddle.where在反向传播时偶尔会丢失梯度所以我更习惯用raw_density * (raw_density 0).cast(float32)来替代relu在 2.3 及以后版本测试都没有问题。源码包里还有一版带残差连接的NeRFNet思路一致只是中间层增加了跳跃输入。2.4 与 PyTorch 原版源码的关键差异很多同学直接把 PyTorch NeRF 代码改成 PaddlePaddle 就跑不通问题通常不在模型而在算子映射。下面是源码适配里最常见的几个点。PyTorch 写法PaddlePaddle 写法注意点torch.catpaddle.concataxis从 0 开始-1表示最后一维torch.nn.ReLU()paddle.nn.ReLU()两者接受inplace参数Paddle 用is_inplacetorch.linspacepaddle.linspacePaddle 的dtype默认float32不用额外转torch.sigmoidpaddle.nn.functional.sigmoid对 3D 张量同样逐元素生效nn.Module.load_state_dictpaddle.nn.Layer.set_state_dict键名规则不同通常要过滤fc.前缀这些差异看起来小但一旦训练中途出现nan先检查位置编码里的sin和cos输入是不是float32再检查光照归一化。文物照片的光照往往不一致训练前需要把所有 RGB 值除以 255 并减去数据集均值。Paddle 版本的源码已经在这个处理里做了per-image均值修正改起来也方便。3. 多视角图像预处理从 jpg 素材到 COLMAP 位姿文件3.1 拍摄与素材筛选标准源码包里给出的 121.jpg 到 130.jpg 只是示例实际使用时建议准备 30 到 80 张围绕文物一周的照片。NeRF 对视角覆盖很敏感少拍一个角度重建结果就会出现半边空洞或者模糊。如果是用手机拍摄尽量保持相机高度不变让文物在画面中心缓慢旋转而不是人绕着文物走。以下是整理素材时的基本要求。参数项建议值说明图像数量30~80 张少于 20 张会很难估计位姿相邻视角重叠率60% 以上重叠不够时 COLMAP 特征匹配失败分辨率1500px 以上训练时再降采样到 512 或 768背景纯色或弱纹理避免反光地板分散特征点相机参数焦距固定光圈优先不要开启自动对焦否则位姿收敛不稳官方源码运行前建议把中文路径改成英文这一步不是玄学。COLMAP 和 PaddlePaddle 对中文路径的兼容性都很差尤其 Windows 下容易在读取database.db时直接崩溃。解压后把整个目录改成artifact_nerf这类名字再开始做数据准备。3.2 用 COLMAP 做稀疏重建拿到相机位姿NeRF 训练需要知道每张图片的相机姿态。最稳定做法是用 COLMAP 做稀疏重建然后导出相机外参。下面是每一步命令。cd artifact_nerf mkdir -p data/images data/output # 先把 121.jpg、122.jpg 等图片复制到 data/images # 统一命名为 train_001.png 这种格式避免图片名有中文或空格 colmap feature_extractor \ --database_path data/database.db \ --image_path data/images colmap exhaustive_matcher \ --database_path data/database.db colmap mapper \ --database_path data/database.db \ --image_path data/images \ --output_path data/sparse第一次跑feature_extractor时COLMAP 会提取每张图的 SIFT 特征写进数据库exhaustive_matcher对所有图像两两匹配适合 30 到 100 张图像的场景超过 100 张建议改用sequential_matcher不然匹配时间会指数上升。最后mapper输出data/sparse/0里面的images.bin、cameras.bin、points3D.bin就是位姿和稀疏点云。如果mapper只成功注册了部分图片常见原因是部分照片模糊或重复度过高需要先删掉这些图再重跑。3.3 把 COLMAP 位姿转成 transforms.jsonCOLMAP 给出的外参是世界到相机坐标NeRF 训练通常需要相机到世界坐标c2w矩阵。转换代码不长直接读取images.bin提取旋转四元数和位移向量。import numpy as np import struct def read_images_bin(path): 读取 COLMAP images.bin返回 image_id 对应的位姿数据 images {} with open(path, rb) as f: num_images struct.unpack(Q, f.read(8))[0] for _ in range(num_images): image_id struct.unpack(I, f.read(4))[0] qvec np.array(struct.unpack(4d, f.read(32))) tvec np.array(struct.unpack(3d, f.read(24))) camera_id struct.unpack(i, f.read(4))[0] name_len struct.unpack(I, f.read(4))[0] name f.read(name_len).decode(utf-8) f.read(8) # 跳过 2D 特征点数量 images[image_id] { qvec: qvec, tvec: tvec, camera_id: camera_id, name: name } return images读取后需要把四元数转换成旋转矩阵顺序是 w, x, y, z。转换公式在源码tools/quaternion.py里已经封装好直接调用即可。需要注意 NeRF 的坐标系通常定义为x向右、y向下、z向观察方向而 COLMAP 是x向右、y向上、z向里因此旋转矩阵还需要做一个[1, -1, -1]轴翻转否则训练出的模型会前后颠倒。3.4 图像降采样和背景遮罩原始 jpg 分辨率太高直接塞进 NeRF 网络会让采样点爆炸。常见做法是用 OpenCV 把最长边限制到 512 或 768 像素。如果是做课程设计512 足以看到清晰轮廓要发表论文或做项目展示768 到 1024 更为稳妥。背景遮罩可以帮网络更快收敛。因为 NeRF 会对每个像素采样大量背景点若背景纹理杂乱密度场会在背景区域也学出立体感。一种简单做法是在拍摄时放一块纯色背景布然后用color_range阈值抠出前景 mask源码里的preprocess.py已经实现了基于 HSV 的背景剔除。4. PaddlePaddle 训练管线光线采样、Loss 与收敛检查4.1 生成像素光线和均匀采样点NeRF 的训练难点是输入数据不是一张完整图片而是从相机出发穿过每个像素的光线。光线用起点rays_o和方向rays_d表示采样点由深度t决定。PaddlePaddle 中常用paddle.linspace生成深度序列。import paddle def sample_along_ray(rays_o, rays_d, near2.0, far6.0, N_samples64): 输入: rays_o: [B, 3] 光线起点 rays_d: [B, 3] 光线方向 输出: points: [B, N_samples, 3] t_vals paddle.linspace(near, far, N_samples) # [N] # rays_o.unsqueeze(-2) 得到 [B, 1, 3] # t_vals.unsqueeze(-1) 得到 [N, 1] # 广播后 points 为 [B, N_samples, 3] points rays_o.unsqueeze(-2) rays_d.unsqueeze(-2) * t_vals.unsqueeze(-1) return t_vals, points均匀采样是最基础的做法适合理解原理源码里还加入了粗网络到细网络的层次采样。训练时先用偶数层采样点预测颜色再根据密度分布重新采样更多点这样的目的是把有限采样点放到物体表面附近降低背景空白区域的无效计算量。你可以自己调整N_samples64 是速度和精度的中间值调成 128 后细节更好但显存占用会明显增加。4.2 粗网络和细网络的联合 LossNeRF 的损失是两个网络预测结果与真实像素颜色的均方误差之和。粗网络采样稀疏负责大致轮廓细网络采样更多负责细节。PaddlePaddle 实现如下。def calculate_loss(model_coarse, model_fine, points_coarse, points_fine, dirs, target_rgb): rgb_coarse, _ model_coarse(points_coarse, dirs) rgb_fine, _ model_fine(points_fine, dirs) mse paddle.nn.MSELoss() loss_coarse mse(rgb_coarse, target_rgb) loss_fine mse(rgb_fine, target_rgb) return loss_coarse loss_fine, loss_fine.item()target_rgb是当前光线对应像素的真实颜色形状为[B, 3]。由于两条网络共享同一个相机姿态所以在训练循环里只需要一次backward()梯度会同时更新两个网络。要注意MSELoss在 PaddlePaddle 里默认返回均值不要额外除以 3否则颜色通道权重会被稀释。4.3 训练超参数和硬件占用参考源码包的默认配置应对 512 分辨率、60 张图使用单张 12GB 显存显卡可以跑完 6000 步。以下是一份可复现的参数表。参数名推荐值参数作用max_iters6000~12000图像数量多时适当增加N_samples64粗网络每根光线的采样点数N_importance64细网络附加采样点数chunk_size4096一次送入网络的光线数量learning_rate5e-4使用 ExponentialDecaygamma0.1学习率衰减系数num_freqs10位置编码最高频率指数perturb1是否对采样点加扰动避免网格伪影raw_noise_std0.0混合训练时建议设 0.01防止过拟合其中chunk_size是显存控制的关键。如果训练时报 OOM优先降低chunk_size而不是降低分辨率。PaddlePaddle 对动态图的内存回收比 PyTorch 激进频繁paddle.no_grad()也能减少显存峰值代码里已经对数据扩增部分做了这个处理。4.4 怎么判断模型是否收敛训练日志里最直接指标是 validation PSNR。每 500 步从验证集随机取 16 根光线算一次 PSNR低于 20 说明欠拟合高于 25 时轮廓已经很清楚。文物重建一般到 28 以上才适合提取 Mesh。还有一个不容易注意的指标是 Scene Density 的稀疏程度。收敛良好的网络密度值在大多数空间点接近 0只有物体表面附近出现较大值。你可以在训练结束后统计密度大于阈值的体素占比理想情况应小于 5%。若这个比例超过 15%说明背景区域也被网络误建模通常是训练数据里背景太杂或 mask 没有生效。5. 从辐射场到可交付 Mesh渲染、提取与精度验证5.1 用并行渲染输出旋转动画训练完成后的模型本质是一组权重直接可视化比较麻烦。常见做法是生成一条绕文物中轴的螺旋路径逐帧渲染图片最后合成 video。渲染脚本会导入训练好的NeRFMLP权重用验证集的平均相机位姿作为基准沿圆周方向改变c2w矩阵的旋转分量。import numpy as np def create_spiral_path(avg_c2w, radius0.8, frames120): path [] center avg_c2w[:3, 3].copy() for i in range(frames): theta 2 * np.pi * i / frames new_c2w avg_c2w.copy() new_c2w[:3, 3] center radius * np.array([ np.cos(theta), 0.0, np.sin(theta) ]) path.append(new_c2w) return np.stack(path, axis0)radius控制旋转半径取验证集相机到物体中心距离的中位数最合适。路径生成后逐帧调用render_one_view(model, c2w)即可。建议每帧设置固定随机种子否则细网络的采样扰动会造成画面闪烁。5.2 用 Marching Cubes 提取表面网格NeRF 训练的是密度场要得到可导入 Blender 或 MeshLab 的模型需要利用 Marching Cubes 算法提取等值面。操作过程是先在三维空间中生成均匀网格再逐点查询模型的密度最后提取密度等于某个阈值的面。import numpy as np from skimage.measure import marching_cubes volume np.zeros((128, 128, 128), dtypenp.float32) coord_range np.linspace(-1.0, 1.0, 128) for i in range(128): for j in range(128): for k in range(128): xyz paddle.to_tensor([[coord_range[i], coord_range[j], coord_range[k]]]) direction paddle.zeros_like(xyz) _, density model(xyz, direction) volume[i, j, k] density.item() vertices, faces, _, _ marching_cubes(volume, level0.5) print(fMesh: {vertices.shape[0]} vertices, {faces.shape[0]} faces)level参数就是密度阈值。阈值设置越低网格越膨胀表面越粗糙阈值越高网格越容易断裂。文物这类封闭物体第一次提取时取预测密度最大值的 10% 作为初始阈值然后手动微调 0.5 到 2.0 之间。网格顶点会存在 scale 偏移导出时要乘回实际场景尺寸。5.3 三个不需要真值的精度验证方法没有扫描仪拿到真实三维模型时可以通过下面三种方式交叉验证重建质量。验证方法操作通过标准重投影误差用新视角渲染图与原图做 PSNRPSNR 25深度一致性把 Mesh 渲染到训练视角对比边缘边缘重叠率 80%法向平滑度统计 Mesh 相邻面法向夹角中位数中位数 15°重投影误差最直观但只能说明颜色场拟合得好法向平滑度能反映表面是否有大量噪声突起。三者结合使用能避免“渲染图好看但提取 Mesh 是烂面”的尴尬情况。5.4 最后调参技巧先低分辨率验证再高分辨率出图不会让训练时间翻倍但一个高分辨率数据集的验证周期会拖到半天。我习惯先用 384 分辨率、2000 步把完整流程跑通确认位姿、mask、损失曲线都没问题再换成 768 分辨率正式训练。这样能筛掉 90% 的配置问题。对于反光强烈的釉面文物还可以把raw_noise_std提至 0.01并在最后一次学习率衰减时把min_lr从 5e-5 降至 1e-5这样表面高光处不容易出现白色碎斑。本文还有配套的精品资源点击获取