光度立体三维重建实战:从猫图序列到带法线OBJ模型
发布时间:2026/9/28 13:51:45来源:尧图网络
简介本资源是一套基于光度立体技术的三维重建Python应用程序源码包面向计算机、人工智能、通信、物联网等专业的在校学生、教师及企业开发者适用于毕业设计、课程设计、大作业或初期项目立项演示等场景。压缩包共41个文件约7.67MB包含Python源码、Jupyter Notebook实验文件、PNG与JPG图像数据、npy法向量与深度数据、obj三维模型、xls与csv数据集以及pdf项目说明覆盖从数据采集、算法实现到结果可视化的完整流程。项目代码完整且功能验证通过配有详细注释与项目说明文档光度立体算法流程图与多视角重建结果图可辅助理解核心原理。目前已有243人学习下载适合零基础入门者进阶学习也便于有基础的读者进行二次开发与功能DIY。需注意解压后项目路径避免使用中文建议重命名为英文后再运行。1. 光度立体三维重建从一组猫图到带法线的 OBJ 模型手里有一组同一只猫在 12 个不同光照方向下拍的照片想恢复出它的表面法线、深度图最后导出能拖进 MeshLab 里转的 OBJ 模型——这就是光度立体Photometric Stereo要干的事。它和 NeRF 那类靠多视角隐式重建的路子完全不同光度立体只用一个固定机位靠光源方向变化带来的明暗差异反推每个像素的法线朝向输入门槛低、计算量小特别适合毕业设计、课程设计这类需要在有限算力下跑出完整结果链路的场景。这份资源把 untreated_data 里的 cat 序列、光源方向 light.txt、主算法 Photometric_Stereo.py、图像预处理 iamge_operations.py 和 main.ipynb 串成了一条能跑通的流水线输出 normal_vactor_cat.npy、depth_cat.npy 和 cat.obj。适合谁正在做三维重建方向毕设、想找一个不依赖深度相机和标定板的可复现项目、并且愿意读 Python 源码注释的人。下面按「数据长什么样 → 算法怎么算 → 代码怎么跑 → 坑在哪 → 怎么验证」的顺序拆开讲。2. 光度立体的数学底子法线、反照率与光源矩阵怎么对上2.1 从 Lambertian 反射模型说起光度立体的核心假设是物体表面满足朗伯反射Lambertian也就是漫反射反射光强只和法线与光源方向的夹角有关跟观察方向无关。单个像素的灰度值可以写成I ρ · (N · L)其中 I 是观测亮度ρ 是表面反照率albedoN 是单位法线向量 (nx, ny, nz)L 是单位光源方向向量 (lx, ly, lz)。一个像素一个方程未知数有四个ρ 和 N 的三个分量。但 N 是单位向量所以实际独立未知数是三个两个角度 反照率。这意味着至少需要 3 个不同光照方向的观测才能解出来。这份资源给了 12 张图属于超定方程组用最小二乘解更稳。把 12 个光源方向堆成矩阵 L12×312 个亮度值堆成向量 I12×1那么I ρ · L · N令 g ρ · N则 I L · g直接最小二乘解 g (LᵀL)⁻¹LᵀI。解出 g 后ρ |g|N g / |g|。这就是经典解法代码里 Photometric_Stereo.py 干的就是这件事。2.2 光源方向从哪来light.txt 的读法与校验untreated_data 里的 light.txt 存的就是 12 个光源方向。常见格式是每行三个浮点数对应 lx、ly、lz。读进来之后必须做两件事一是归一化保证每个光源向量模长为 1二是检查是否有零向量或重复方向否则最小二乘会病态。import numpy as np def load_light_directions(path): # light.txt 每行三个浮点数对应一个光源方向 L np.loadtxt(path, dtypenp.float64) # 逐行归一化避免光源距离不一致导致亮度缩放 norms np.linalg.norm(L, axis1, keepdimsTrue) if np.any(norms 1e-8): raise ValueError(light.txt 中存在零向量检查数据) L L / norms # 检查方向重复两两夹角过小会让方程组病态 gram L L.T np.fill_diagonal(gram, 0) if np.max(np.abs(gram)) 0.999: print(警告存在近似重复光源方向重建法线可能不稳) return L逻辑说明np.loadtxt直接按空白分隔读入得到 12×3 矩阵。归一化那一步很多人会漏如果 light.txt 里存的是未归一化的光源位置向量不归一化会让不同光源的亮度尺度不一致解出的反照率会整体偏移。重复方向检查用 Gram 矩阵两个单位向量点积接近 1 就说明方向几乎一样这种数据在最小二乘里会放大噪声。参数上1e-8是零向量阈值0.999是重复判定阈值实际数据里如果光源角度间隔只有几度可以放宽到 0.995。2.3 逐像素求解与法线可视化拿到 L 和图像栈 I 之后对每个像素做一次最小二乘。图像栈的形状是 (H, W, 12)需要 reshape 成 (H*W, 12) 再和 L 运算。def photometric_stereo(image_stack, L): # image_stack: (H, W, K)K 为光照数量 H, W, K image_stack.shape # 展平成 (K, H*W)每列是一个像素在 K 个光照下的亮度 I image_stack.reshape(-1, K).T.astype(np.float64) # 最小二乘解 g pinv(L) IL 为 (K, 3) L_pinv np.linalg.pinv(L) G L_pinv I # (3, H*W) albedo np.linalg.norm(G, axis0) # 反照率 albedo[albedo 1e-8] 1e-8 # 防止除零 N (G / albedo).T.reshape(H, W, 3) # 单位法线 albedo albedo.reshape(H, W) return N, albedo逻辑说明np.linalg.pinv用的是伪逆比直接求逆稳因为 L 不是方阵。G的每一列是一个像素的 ρ·N 向量。反照率取模长法线取归一化。除零保护那行是血泪经验——背景或阴影区域亮度全为零时模长接近零不保护会出 NaN后面法线可视化整片变黑。参数上如果图像有 8 位量化噪声可以先做高斯模糊再求解但模糊核别超过 3×3否则法线边缘会糊。3. 把 cat 序列跑起来从 untreated_data 到 cat.obj 的完整链路3.1 数据组织与命名对应关系untreated_data 里的文件命名是 cat.0.png 到 cat.11.png共 12 张外加 cat.mask.png 和 cat.txt。cat.txt 大概率记录的是每张图对应的光源序号或采集参数cat.mask.png 是前景掩膜用来剔除背景像素。常见做法是先读 mask把背景像素的亮度置零或直接跳过只对前景做最小二乘这样能省掉大量无效计算也能避免背景噪声污染法线。import cv2 import numpy as np import os def load_cat_sequence(data_dir): # 按编号顺序读 12 张光照图顺序必须和 light.txt 行序一致 imgs [] for i in range(12): p os.path.join(data_dir, fcat.{i}.png) img cv2.imread(p, cv2.IMREAD_GRAYSCALE) if img is None: raise FileNotFoundError(f缺少 {p}) imgs.append(img.astype(np.float64) / 255.0) stack np.stack(imgs, axis-1) # (H, W, 12) mask cv2.imread(os.path.join(data_dir, cat.mask.png), cv2.IMREAD_GRAYSCALE) mask (mask 127).astype(np.float64) # 背景置零只保留前景参与求解 stack stack * mask[..., None] return stack, mask逻辑说明cv2.IMREAD_GRAYSCALE保证读成单通道光度立体只需要亮度。除以 255 归一化到 [0,1]避免数值过大。mask 二值化阈值 127 是常规选择如果掩膜边缘有半透明过渡可以改成mask / 255.0做软加权。关键点cat.0 到 cat.11 的顺序必须和 light.txt 的行顺序严格对应错一行整个法线方向就全乱这是最常见的翻车点。3.2 主流程串接与中间结果落盘main.ipynb 里把加载、求解、保存串起来。核心步骤是读光源 → 读图像栈 → 求解法线 → 保存 npy → 积分出深度 → 导出 OBJ。法线保存成 normal_vactor_cat.npy深度保存成 depth_cat.npyOBJ 是 cat.obj。# 主流程 L load_light_directions(untreated_data/light.txt) stack, mask load_cat_sequence(untreated_data) N, albedo photometric_stereo(stack, L) # 保存法线和反照率 np.save(reasult/normal_vactor_cat.npy, N) cv2.imwrite(reasult/albedo_cat.png, (albedo * 255).astype(np.uint8)) # 法线可视化把 [-1,1] 映射到 [0,255] normal_vis ((N 1) / 2 * 255).astype(np.uint8) cv2.imwrite(reasult/normal_vactor_cat.jpg, normal_vis)逻辑说明np.save存的是 float 数组后续积分深度直接读不用重新算。法线可视化那步把分量从 [-1,1] 线性映射到 [0,255]这是标准做法normal_vactor_cat.jpg 里 RGB 三个通道分别对应 nx、ny、nz偏蓝说明法线朝 z 正方向朝向相机。注意 OpenCV 的通道顺序是 BGR如果直接 imwrite 三通道数组可视化图颜色会反需要cv2.cvtColor(normal_vis, cv2.COLOR_RGB2BGR)转一下。3.3 从法线积分出深度depth_cat 与 OBJ 导出法线只是方向要得到 depth_cat 和 cat.obj 还需要积分。常见做法是求解泊松方程或沿 x、y 方向做路径积分。简单实现是沿行和列分别累加法线分量比值def integrate_depth(N, mask): # N: (H, W, 3)用 nx/nz 和 ny/nz 近似梯度 H, W, _ N.shape nx, ny, nz N[..., 0], N[..., 1], N[..., 2] nz np.where(np.abs(nz) 1e-6, 1e-6, nz) gx -nx / nz # 沿 x 方向梯度 gy -ny / nz # 沿 y 方向梯度 depth np.zeros((H, W), dtypenp.float64) # 简单累加积分实际可用泊松求解器替换 for i in range(1, H): depth[i, :] depth[i-1, :] gy[i, :] for j in range(1, W): depth[:, j] depth[:, j-1] gx[:, j] depth depth * mask return depth逻辑说明gx、gy是表面高度沿两个方向的偏导近似-nx/nz来自法线与梯度的几何关系。累加积分是最朴素的路径积分误差会累积长序列上容易出现低频漂移。工程上更稳的是用泊松方程∇²z ∇·(gx, gy)配合 FFT 或稀疏求解器这份资源如果只做演示累加够用要做定量测量就得换泊松。OBJ 导出时把 depth 当 z 坐标像素坐标当 x、y逐顶点写v行再按网格写f行即可。4. 避坑与排查光度立体跑不出结果时先看这几条4.1 现象法线图整片偏色或方向反了原因light.txt 的行顺序和 cat.0~cat.11 的编号顺序不一致或者光源坐标系和图像坐标系没对齐比如 y 轴方向相反。解决先只取前 3 个光源做最小二乘看解出的法线是否大致朝向相机如果 z 分量普遍为负把 light.txt 的 y 或 z 取反再试。顺序问题只能靠核对采集记录没有捷径。4.2 现象反照率图出现大量 NaN 或黑斑原因背景或阴影区域亮度全零模长接近零除零产生 NaN。解决加载时用 mask 把背景置零并跳过或者在albedo 1e-8处强制设为 1e-8。如果阴影区域也有有效前景考虑加一个亮度下限阈值低于阈值的像素标记为无效不参与法线计算。4.3 现象depth_cat 整体倾斜或出现条纹原因累加积分误差累积或者法线在 nz 接近零的区域表面几乎平行于视线梯度爆炸。解决把nz的绝对值下限从 1e-6 提到 1e-3牺牲少量精度换稳定积分改用泊松求解器对深度图做一次中值滤波去条纹。4.4 现象cat.obj 在 MeshLab 里打开是碎片或法线全黑原因OBJ 顶点顺序或面索引写错或者法线没有归一化就写进vn行。解决导出前对 N 再做一次N / np.linalg.norm(N, axis-1, keepdimsTrue)面索引从 1 开始OBJ 规范别用 Python 的 0 起始如果模型是碎片检查 mask 是否把前景切断了。4.5 现象项目路径含中文导致读文件失败原因OpenCV 的imread和imwrite在部分平台上对中文路径支持不好会静默返回 None。解决解压后把项目目录重命名为纯英文比如photometric_stereountreated_data 和 reasult 也别用中文。这是项目说明里专门提醒的一条别不当回事。5. 验证重建质量与二次开发三个可量化的检查点跑通不等于跑对。我一般会用三个检查点来判断这份光度立体结果能不能用。第一个是法线单位性检查读 normal_vactor_cat.npy算每个像素法线的模长正常应该全在 1.0 附近如果出现大量 0.9 或 1.1说明求解或保存环节有缩放错误。第二个是反照率一致性同一物体表面不同区域的反照率应该平滑变化如果反照率图上有明显的光照残留比如某个方向的亮斑说明光源方向或亮度归一化有问题。第三个是深度梯度与法线的互验从 depth_cat 重新算梯度再和 nx/nz、ny/nz 对比残差大的区域就是积分误差集中的地方。# 检查点一法线模长 N np.load(reasult/normal_vactor_cat.npy) norms np.linalg.norm(N, axis-1) print(法线模长均值:, norms.mean(), 标准差:, norms.std()) # 正常应接近 1.0标准差小于 0.01 # 检查点三深度梯度与法线互验 depth np.load(reasult/depth_cat.npy) gy, gx np.gradient(depth) nz np.where(np.abs(N[..., 2]) 1e-3, 1e-3, N[..., 2]) residual_x gx - (-N[..., 0] / nz) residual_y gy - (-N[..., 1] / nz) print(梯度残差均值:, np.abs(residual_x).mean(), np.abs(residual_y).mean())参数上法线模长标准差超过 0.05 就值得回头查数据梯度残差均值在 0.01 量级算正常超过 0.1 说明积分方法需要换泊松。二次开发方向也清晰把photometric_stereo里的最小二乘换成带鲁棒权重的迭代重加权IRLS能抗高光把累加积分换成 FFT 泊松求解器深度质量会明显提升如果想做彩色重建把单通道灰度换成三通道分别求解再合并反照率即可。资源里还附了一份基于遗传算法优化神经网络的源码和实验报告虽然和光度立体不是同一条链路但如果你毕设需要「传统方法 优化算法」的对比章节那份 GA-NN 的 data.xls 和实验报告可以直接当素材。从那以后我每次拿到一组新的光度立体数据都强制先跑一遍法线模长和梯度残差这两个检查再去看可视化图——肉眼会被漂亮的法线贴图骗过去数字不会。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网