简介:基于NeRF和手机拍摄物体图片的三维重建项目包,面向计算机视觉方向毕业设计、课程实践及三维重建入门与进阶者。资源核心解决手机多视角照片如何重建出连续视角三维场景的问题,提供可运行验证的完整工程,而非停留在理论片段。压缩包共31个文件,以21个Python源码文件为主,包含NeRF训练、数据加载、位姿估计、渲染路径生成等核心模块;辅以pyc预编译文件、环境依赖txt、Markdown说明文档,以及直观展示重建效果的gif与jpg样例,整体仅5.37MB,便于快速下载部署。已有181人浏览学习。源码经本地编译通过,项目评审分达95分以上,难度适中,适合作为毕业设计参考。内容涵盖COLMAP位姿估计、LLFF数据集读取、NeRF前向与训练流程、渲染路径生成等环节,并提供运行效果动图与文档,可帮助理解从图像序列到辐射场再到新视角渲染的完整链路,快速复现并开展二次开发。
1. 手机拍照做三维重建:为什么选 NeRF 而不是传统 MVS
用手机围着物体转圈拍几十张照片,丢给 NeRF 训练出神经辐射场,就能从任意新视角渲染出原本没拍过的画面——这是目前三维重建里性价比最高、也最容易出成果的方向。相比传统 MVS 多视图立体,NeRF 不依赖特征点三角化和纹理匹配,对弱纹理、反光表面更宽容,体渲染输出的新视角天生具备连续性和真实感。这份基于 NeRF 的 python 源码把整条链路收在一起:手机照片经 COLMAP 估算位姿,LLFF 格式加载数据,PyTorch 版 NeRF 训练辐射场,最后输出渲染与可视化。资源自带数据集和文档说明,适合做毕业设计,或者想把自己的手机照片变成可旋转三维模型的人。你只要会 Python 和基础 PyTorch 配置,剩下那些坑我逐段展开。
2. 环境搭建与数据管线:COLMAP 位姿估计与 LLFF 格式适配
2.1 项目结构与依赖:先认清每个模块是干嘛的
这份资源解压后是一个传统的 PyTorch 工程,但目录组织和标准 nerf-pytorch 略有不同:训练主脚本run_nerf.py、数据加载脚本data.py、load_llff.py、load_dtu.py放在llff/子目录下,imgs2poses.py、downsampling.py在根目录,渲染路径生成和可视化脚本收在utils/里,COLMAP 模型读写封装放在colmapUtils/。我建议不要急着跑,先花五分钟把核心模块的职责对一遍,否则后面很容易把训练脚本和可视化脚本混在一起。
| 模块 | 职责 | 什么时候碰它 |
|---|---|---|
| llff/run_nerf.py | 训练与渲染主入口,解析参数 | 每次实验都从它启动 |
| run_nerf_helpers.py | NeRF 网络结构、位置编码、采样函数 | 改模型结构时 |
| loss.py | 损失函数、PSNR 计算 | 改训练目标时 |
| llff/load_llff.py | 加载 LLFF 格式与手机照片 | 跑自己的数据时 |
| llff/load_dtu.py | 加载 DTU 基准数据集 | 验证基准实验时 |
| imgs2poses.py | 调 COLMAP 从照片估算位姿 | 新拍一组照片时 |
| downsampling.py | 降采样图片 | 照片分辨率太大时 |
| utils/generate_renderpath.py | 生成平滑渲染轨迹 | 出渲染视频时 |
| utils/visualization.py | 相机轨迹与结果可视化 | 检查位姿时 |
| utils/generate_html.py | 输出交互式展示页面 | 答辩展示时 |
| colmapUtils/ | COLMAP 模型读写封装 | 位姿转换内部调用 |
这张表里最容易忽略的是配置和 COLMAP 封装。configs/目录下的每个 txt 对应一个实验场景,文件名和你设置的expname必须语义一致,否则训练结果会写进错误的 logs 路径。colmapUtils平时不需要单独调,它是imgs2poses.py的底层依赖,里面的read_write_model.py同时支持二进制和文本两种 COLMAP 模型格式,如果你的 COLMAP 输出了images.bin而脚本读的是images.txt,会直接报解析错误。项目里resources/COLMAP_test_spiral_001000_rgb.gif是官方测试配置跑出来的螺旋轨迹渲染样例,第一次复现建议先用这个配置验证环境,确认整条链路通了再换自己的手机照片。
依赖安装方面,requirements.txt给出的是基础清单,但真正决定稳定性的是 torch 和 CUDA 的搭配。下面这套命令是我反复验证过的组合,Python 3.8 + torch 1.13 + CUDA 11.7,跑 NeRF 老代码基本不会碰到张量 API 不兼容的问题:
conda create -n nerf python=3.8 -y conda activate nerf pip install torch==1.13.1+cu117 torchvision==0.14.1+cu117 \ --extra-index-url https://download.pytorch.org/whl/cu117 pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple第一行创建独立的 conda 环境,避免污染系统 Python;第二行把 torch 锁定到 1.13,配套 CUDA 11.7。为什么不用最新的 2.x?因为 NeRF 源码里部分旧式写法(比如.data赋值、某些张量原地操作)在 1.13 上没有任何告警,升到 2.x 就可能直接抛异常。第三行从清华镜像安装项目依赖,速度快且不容易断流。装完之后跑一个一行命令冒烟测试,确认 GPU 真的可用,别等到开训才发现 CUDA 没配对:
python -c "import torch; x=torch.rand(2048,2048).cuda(); print((x@x).sum().item())"这个命令创建 2048×2048 的随机矩阵并做乘法,能输出浮点值说明 torch 的 CUDA 后端正常。如果报CUDA error: no kernel image is available,基本是 torch 编译对应的 CUDA 版本比驱动新,换低版本 torch 即可;如果直接 OOM,说明显存不足以支持这个矩阵尺寸,训练时也要相应降低批大小。
2.2 imgs2poses.py:从手机照片到 poses_bounds.npy
手机照片本身只有像素,NeRF 训练真正需要的输入是「每张图像 + 相机位姿 + 内参」。imgs2poses.py把这套流程封装成一行命令。你只需要准备一个场景目录,里面建images/子目录放所有照片,然后执行:
python imgs2poses.py ./data/my_phone_scan脚本内部按三步走:先调用 COLMAP 做特征提取与特征匹配,再做增量式稀疏重建,得到每张图像的相机外参和场景稀疏点云;然后用colmapUtils/read_write_model.py把二进制模型文件读进内存;最后把相机矩阵整理成 LLFF 的poses_bounds.npy格式写入./data/my_phone_scan/poses_bounds.npy。这个输出数组的形状是(N, 17):每行前 16 个元素存放 4×4 相机位姿矩阵,最后两个元素是 near 和 far 深度边界。整个链路对使用者来说像个黑匣子,但你必须知道它输出的 near/far 直接控制后续光线采样范围,后面第 4 章会讲到这个参数翻车的典型场景。
这里有个常见误解:poses_bounds.npy里的矩阵并不是 COLMAP 原始的相机输出,而是经过了一次坐标轴变换,把 COLMAP 的「相机朝 -Z」坐标系翻转到 NeRF 的「相机朝 +Z」坐标系。如果你图省事直接读 COLMAP 的位姿喂给 NeRF,渲染结果会上下颠倒或者左右镜像,原因就是缺了这层变换。所以不要跳过imgs2poses.py自己拼位姿。
分辨率过高是手机照片最常见的坑。4000×3000 的原图直接跑 COLMAP,特征匹配阶段慢到你怀疑人生,内存占用也夸张。我习惯先做降采样再跑位姿:
python downsampling.py --input ./data/my_phone_scan/images \ --output ./data/my_phone_scan/images_2 --max_size 1600这条命令把图片长边缩到 1600 像素,输出的images_2目录给 COLMAP 用。拿到位姿之后,我还会把喂给 NeRF 的训练图再压一档,缩到长边 1000 以内,因为 NeRF 训练分辨率越高显存压力越大,而小物体重建在 1000×750 左右的分辨率下已经有足够效果。
2.3 不碰 COLMAP 的备选:DTU 数据加载器
如果你只想知道 NeRF 本身怎么跑通,暂时不想处理自己的拍摄数据,那llff/load_dtu.py是最短路径。DTU 数据集每个场景提供 49 个或 64 个固定视角的图像,官方还给出了精确的相机参数,不存在位姿估计误差的干扰。load_dtu_data(args)读取cameras.npz,把world_mat_0、world_mat_1两个投影矩阵转换成 NeRF 所需的光线方向与原点。注意这里world_mat_0对应一种视角数量,world_mat_1对应另一种,索引别选反。
DTU 这条路的优点是数据干净、链路稳定,跑出来的 PSNR 可以横向对比原论文结果;缺点是拍摄环境是实验室台架,光照均匀、物体固定,代表不了手机实拍的手持场景。我一般建议把 DTU 当作「代码连通性验证」:先跑通run_nerf.py对 DTU 的训练,再切换到自己的手机照片。这样可以把「代码问题」和「数据问题」分开排查,效率远高于一上来就拿着手机照片死磕 COLMAP。
提示:无论走
imgs2poses.py还是load_dtu.py,最后都会归一成 LLFF 的poses_bounds.npy格式。跑通一次之后建议把所有中间产物存进data/目录,后续反复实验只改配置文件,不动数据。
3. 训练 NeRF 的核心参数:run_nerf.py 与 loss.py 改哪里
3.1 配置文件里的关键参数:near/far、采样数与迭代数
NeRF 的训练参数大致分三类:网络结构相关在run_nerf_helpers.py里定义,训练控制参数由llff/run_nerf.py解析,数据参数由load_llff.py读取。集中入口是configs/下的 txt 文件,下面是一个典型配置:
expname = my_phone_scan basedir = ./logs dataset_type = llff factor = 4 llffhold = 8 N_iters = 80000 N_samples = 64 N_importance = 128 use_viewdirs = True raw_noise_std = 0.1 render_only = Falseexpname是实验名,basedir是实验根目录,二者拼接出实际输出路径./logs/my_phone_scan。factor是降采样因子,factor=4表示长边缩到原图的四分之一。llffhold=8的意思是每 8 张图中取 1 张作为测试集,其余 7 张参与训练;手机拍 30 张图大致就是 26 张训练、4 张验证,验证集 PSNR 能反映新视角的泛化能力。N_iters=80000是我对手机小物体的推荐值,原始默认 200000 在普通显卡上要跑十多个小时,实际收益边际递减。N_samples和N_importance分别控制粗网络均匀采样和细网络重要性采样的点数,论文原值 64 加 128 在大多数情况下够用,改成 128 加 256 质量提升有限、耗时却近乎翻倍。use_viewdirs控制是否把观察方向输入网络,对高光材质必须为 True。raw_noise_std是加在密度预测上的高斯噪声标准差,用于正则化,默认 0 即可,手动调大只会让画面发雾。
对应启动命令是:
python llff/run_nerf.py --config configs/my_phone_scan.txt在这里直接跑根目录的python run_nerf.py会报模块找不到,因为主脚本在llff/子目录下,这是这份资源目录设计上和标准仓库不一样的地方。配置里没有列出的参数会落到run_nerf.py的 argparse 默认值上,比如网络层数宽度、初始学习率、优化器选择。想快速验证链路时,只需临时把N_iters改到 1000,能跑通再改回来。
3.2 网络前向与两条光线的渲染流程
run_nerf_helpers.py的NeRF类定义了一个多层感知机,输入是三维坐标的位置编码和观察方向编码,输出是该点的 RGB 颜色和体密度。Embedder类把连续坐标映射到多频率正弦/余弦组合,这是 NeRF 能表达高频纹理的关键;位置编码频率数multires默认 10,如果重建表面出现细密波纹,优先往低调到 8,因为高频项对噪声非常敏感。get_rays函数依据相机内参与位姿生成每条光线的原点和方向;render_rays完成体渲染积分,沿光线方向采样粗网络 64 点,再根据粗网络密度分布做重要性采样补 128 点,加权求和得到像素颜色。
训练循环里每次迭代取N_rand条光线,而不是随机取若干张完整图。N_rand=1024时每个 batch 只含 1024 个像素的光线,显存相对可控;显存不够时优先降到 512,而不是去砍网络宽度。对手机近景物体,LLFF 的 NDC 归一化设备坐标假设相机朝向大致平行,如果你拍摄时是大角度环绕,需要在配置里加-no_ndc,否则远端背景会被拉伸变形。
# run_nerf.py train() 核心循环(简化) for i in range(start, args.N_iters): rays_o, rays_d, target_rgb = get_rays_np(H, W, K, poses, args) rgb_map, disp_map, acc_map, weights, depth_map = render_rays( nerf_coarse, nerf_fine, rays_o, rays_d, args.N_samples, args.N_importance, args.near, args.far, args.use_viewdirs, args.perturb, args.raw_noise_std ) loss = img2mse(rgb_map, target_rgb) psnr = mse2psnr(loss) loss += 0.1 * img2mse(coarse_map, target_rgb) loss.backward() optimizer.step()这段逻辑里值得注意的最后一行的0.1 * coarse_loss:粗网络虽然不直接用于最终渲染,但它的预测质量决定了细网络重要性采样的分布,所以训练时要把粗网络 loss 以 0.1 的权重加进总 loss,强制粗网络也往正确方向优化。省掉这一项,表面细节会明显变差。
3.3 loss.py 的修改方向:除了 MSE 还能做什么
原始的img2mse对所有像素一视同仁。实际手机照片里背景占比往往很大,网络容易优先拟合大片的背景区域,忽略前景物体细节。一个简单有效的改法是按深度给像素加权,前景近距离权重大、遥远背景权重小:
# loss.py 中自定义加权 MSE def img2mse_weighted(rgb_map, target_rgb, depth_map, alpha=0.5): # depth 越小表示越靠近相机,权重越大 w = torch.exp(-alpha * depth_map) w = w / w.mean() return ((rgb_map - target_rgb) ** 2 * w.unsqueeze(-1)).mean()这里的思路是:深度图数值小的像素对应近距离物体,torch.exp(-alpha * depth)映射后权重接近 1,远距离背景指数衰减。alpha控制衰减速度,0.5 对多数手机场景够用。注意depth_map的通道数是 1,必须用unsqueeze(-1)扩展到和 RGB 相同的最后一维,否则广播维度对不上直接报 shape mismatch。
从实践看,改造 loss 对最终重建效果的影响往往被高估——效果差距更多来自数据质量和 near/far 取值,而不是 loss 函数的微小变化。但如果你需要为毕业设计找一个可解释的创新点,在 loss 里加一个与深度相关的可解释约束,是最容易讲清楚故事的方向之一。
4. NeRF 训练常见问题排查:重建质量差的五个真凶
4.1 现象一:训练 loss 降不下去,一直徘徊在高位
训练两万步后 loss 仍持续在 0.4 以上震荡,PSNR 不足 15dB。绝大多数情况是 near/far 设置不合理。poses_bounds.npy最后两个数来自 COLMAP 稀疏点云的深度范围估算,但稀疏点云只覆盖特征点明显的区域,对光滑表面覆盖不全,导致 far 被低估、near 被高估。光线在错误的深度区间采样,网络把大量采样点花在真空区域,根本碰不到物体表面。解决方法是手工核对最后一列:打印poses_bounds.npy的深度上下界,把 near 设为物体最近表面的 0.1 到 0.5 倍,far 设为最远表面的 1.2 到 1.5 倍。修正后通常能提升 3 到 5dB,这个参数单独拿出来讲,效果比调任何网络结构都明显。
4.2 现象二:渲染结果像隔了一层雾,边缘不锐利
生成的新视角画面整体发灰,物体和背景边界模糊,像是隔着一层毛玻璃。本质是体密度积分不够尖锐:沿光线的密度峰值没有精确落在物体表面,前后采样点都被赋予了不小权重。最常见原因是手动把raw_noise_std调大想对抗过拟合,结果噪声直接糊掉密度场;其次是粗网络 64 个采样点不足以精确定位表面。解决方法是把raw_noise_std降回 0 或 0.1 以下,同时优先增加N_importance到 256 而不是增加N_samples,因为重要性采样会在密度变化剧烈的位置自动加密,效率远高于均匀加密。可以用中间输出的深度图确认权重是否集中在一个薄层内。
4.3 现象三:PSNR 曲线突然跳变到负值或 NaN
训练进行到两三万步时,PSNR 从 24 直接跌到负值,loss 变成 NaN。直接原因是数值溢出,通常出在sample_pdf对权重归一化时遇上了全零分布。手机照片里大量纯黑或纯白区域,密度权重在某些空间位置小到浮点精度为 0,累计分布函数得到 NaN,反向传播梯度爆炸。解决方法是给权重加一个极小 epsilon 并做兜底:
# sample_pdf 中加防御 weights = weights + 1e-5 pdf = weights / torch.sum(weights, dim=-1, keepdim=True) cdf = torch.cumsum(pdf, dim=-1) cdf = torch.nan_to_num(cdf, nan=0.0, posinf=1.0)这样虽然理论上轻微改变了原始分布,但实际影响微乎其微,训练稳定性却大幅提升。改完后从最近的正常 checkpoint 继续训练即可,不需要从头再来。
4.4 现象四:新视角渲染出现重影或双像
同一轮廓被重复绘制,像是图像没对齐。这几乎可以断定是相机位姿失准。最常见的拍摄问题是手持手机绕物体移动时,物体本身被手碰上发生位移;或者自动对焦导致不同照片焦距不一致。COLMAP 对焦距漂移极其敏感,它会为每帧估算独立的内参,焦距跳变会直接污染重建出的相机矩阵。解决方法是拍摄时切到专业模式,锁定对焦、曝光和白平衡,转圈速度均匀,相邻照片重叠率保持在 70% 以上。拍完后用visualization.py输出相机轨迹,肉眼检查是否为平滑圆弧;如果轨迹是锯齿状折线,删除跳变点的照片重新补拍,不要硬着头皮训练。
4.5 现象五:CUDA out of memory,8G 显存直接崩
启动不久就 OOM,或者训练到一半显存耗尽。显存峰值来自每条光线同时维护粗网络 64 点和细网络 128 点的中间张量,N_rand=1024时反向传播的显存开销非常可观。解决分三步走:先把N_rand降到 512;再把N_importance从 128 降到 64,牺牲一点表面细节换稳定训练;如果还不行,关闭perturb减少随机扰动带来的额外中间张量。最后兜底手段是把factor改成 8 强制低分辨率,确认链路通后再逐步放宽。这套降级顺序能解决大约九成的显存问题。
5. 渲染路径生成与可视化:从训练权重到可展示的成果
5.1 训练完成后的标准渲染流程
训练完成后,./logs/my_phone_scan/下会出现checkpoints/与渲染输出目录。单独渲染某一帧时用配置加渲染开关:
python llff/run_nerf.py --config configs/my_phone_scan.txt --render_only --render_test--render_only告诉程序跳过训练阶段,直接加载最新 checkpoint;--render_test表示渲染测试集视角。测试集来自llffhold=8留下的那四张照片,这部分视角从未参与训练,渲染图和真实照片并排比对,是评估泛化能力最直观的方法:肉眼观察边缘、纹理和颜色,比单看 PSNR 数字更有说服力。渲染阶段是逐像素做完整光线求积,在 1000×750 分辨率下单帧可能需要 10 到 30 秒,这是正常速度,不要以为是卡死了。
5.2 沿平滑轨迹渲染视频:generate_renderpath.py 的正确用法
静态视角体现不出三维性,utils/generate_renderpath.py能在训练位姿之间做插值,生成一条平滑的螺旋轨迹,沿轨迹逐帧渲染就能得到旋转视频:
python utils/generate_renderpath.py --basedir ./logs \ --expname my_phone_scan --n_views 120 --render_factor 2n_views=120生成 120 个视角样本,render_factor=2表示渲染分辨率再除以 2,大约 500×375,这是为了让预览快速跑完。脚本输出的 PNG 序列可以直接交给 ffmpeg 合成 mp4:
ffmpeg -framerate 30 -i renderpath_%03d.png -c:v libx264 -pix_fmt yuv420p render.mp4如果只想做快速交互预览,utils/generate_html.py会把渲染序列打包成一个独立 HTML 文件,浏览器打开后拖动滑块就能切换视角。毕业设计答辩时这种交互页面的演示效果比静态图强很多。有一点要注意:HTML 里引用的图片尽量用 PNG 或 JPG,不要混入 GIF,否则页面加载会明显变慢。项目resources/里那个COLMAP_test_spiral_001000_rgb.gif就是这条管线在测试配置下生成的样例,你可以拿它当预期效果参考。
5.3 从 NeRF 到网格模型:密度场等值面提取的基本思路
严格说 NeRF 输出的是连续密度场,不是三角网格。想要一个能导入建模软件的 OBJ 模型,常见做法是把三维空间离散成规则网格,让网络在每个格点上预测密度,再用 marching cubes 提取等值面:
# 假设 model 是训练好的 NeRF,grid_size=128 import torch import numpy as np from skimage import measure def predict_density(model, xyz): with torch.no_grad(): raw = model(xyz) # raw[..., 3] 是未激活的密度,激活函数是 relu return torch.relu(raw[..., 3]).cpu().numpy() xs = np.linspace(-1, 1, grid_size) xx, yy, zz = np.meshgrid(xs, xs, xs) xyz = np.stack([xx.ravel(), yy.ravel(), zz.ravel()], axis=-1) density = predict_density(model, torch.tensor(xyz, dtype=torch.float32).cuda()) verts, faces, _, _ = measure.marching_cubes( density.reshape(grid_size, grid_size, grid_size), level=5.0)这个脚本把归一化空间均分成 128 的立方网格,每格送入网络预测密度,再在 density=5.0 处构造等值面。level 的选取直接决定表面位置:偏大导致表面收缩,偏小则表面膨胀到背景中。校准 level 的方法是用训练好的模型渲染几帧深度图,对比深度图里物体边界的归一化深度位置与网格模型的范围。128 的三次方是起步配置,追求精细可以提到 256,但显存和耗时都会成倍上涨。另外 NeRF 在无数据区域经常产生「幽灵密度」,提取出的网格会带悬浮杂块,可以用连通域分析只保留最大连通分量来清理。手机拍的小物体用这个流程导出粗糙网格,作为 NeRF 体渲染之外的一个补充交付物是够用的。
6. 先跑 1000 步冒烟验证:我每次训练前都强制执行的顺序
拿到任何 NeRF 相关源码,我都习惯把第一轮训练当作链路连通性测试,而不是正式的模型训练。把N_iters临时改成 1000,N_rand改成 256,启动训练后只观察三件事:训练循环是否打印出平滑变化的 loss 和 PSNR;logs/目录下是否生成预览渲染图;GPU 利用率是否持续维持在高位。如果 PSNR 从负值逐渐转正、预览图出现物体轮廓,说明数据管线、模型前向、loss 反向和渲染输出全链路都是通的;如果这一步就报错,直接修代码,不要浪费时间在正式训练上。
第二步是位姿可视化。训练前用visualization.py把 COLMAP 算出的相机轨迹画出来,确认是一条平滑圆弧而不是杂乱折线。轨迹异常时无论怎么调参数都救不回来,提前剔除跳变照片是最划算的操作。第三步才是 near/far 核对,打印poses_bounds.npy的最后一列,把深度范围与物体实际摆放距离做对比,确保光线采样区间没有大幅偏离真实场景。这三步全过,再按正式配置跑全量训练。
从那以后我每次跑 NeRF 都会强制把「1000 步冒烟 → 位姿可视化 → near/far 核对 → 正式训练」完整走一遍,这套顺序帮我省下的重训时间远远超过多花的那点检查时间。希望这份笔记能帮你少走几次我走过的弯路。
本文还有配套的精品资源,点击获取