简介:本资源是一套基于光度立体技术实现三维重建的Python应用程序,面向计算机、人工智能、通信、物联网等专业的在校学生、教师及企业员工,可用于毕业设计、课程设计、大作业或初期项目立项演示,也适合对三维重建与计算机视觉感兴趣的学习者入门进阶。压缩包共41个文件,约7.67MB,包含Python源码、Jupyter Notebook实验文件、项目说明文档,以及png、jpg图像数据、npy法向量与深度数据、obj三维模型、xls与csv数据集和pdf实验报告等,覆盖从算法实现到结果可视化的完整流程。项目代码完整且功能验证稳定,配有详细注释,光度立体算法流程清晰,可帮助读者理解表面法向量估计、深度图生成与三维模型导出等关键环节,并支持在此基础上进行二次开发与功能扩展。目前已有243人学习下载,适合需要完整参考方案与排错思路的读者。
1. 光度立体三维重建:从两张照片到毫米级表面法线
你手头有一批工业相机拍的金属件照片,表面有划痕、凹坑、字符压印,普通二维图像检测经常漏判,因为灰度值受反光影响太大。光度立体(Photometric Stereo)就是解决这个问题的:固定相机,用不同方向的光源依次照射同一物体,拍多张图,通过像素亮度变化反推每个像素的表面法线方向,再积分得到深度图。它不需要激光轮廓仪,不需要结构光投影仪,一套环形光源加普通工业相机就能跑。这个方案适合做表面缺陷检测、压印字符识别、微小形变测量的工程师,也适合想用 Python 从零实现三维重建的开发者。标题里的“python源码+详细注释+项目说明”意味着你拿到的是一个可运行、可读、可改的工程,不是论文伪代码。下面我按实际落地顺序拆开讲。
2. 光度立体为什么能用亮度反推法线:从朗伯模型到最小二乘求解
2.1 朗伯反射假设与亮度方程
光度立体的物理基础是朗伯余弦定律:表面某点的反射亮度与光源方向和表面法线夹角的余弦成正比。写成公式:
I = ρ · (N · L)
其中 I 是像素灰度值,ρ 是表面反照率(albedo),N 是单位法线向量 (nx, ny, nz),L 是单位光源方向向量。如果我有 m 个不同方向的光源,每个光源拍一张图,对同一个像素就有 m 个方程:
I₁ = ρ · (N · L₁) I₂ = ρ · (N · L₂) ... Iₘ = ρ · (N · Lₘ)
未知数是 ρ 和 N 的三个分量,共 4 个未知数。只要 m ≥ 3 且光源方向不共面,理论上就能解。实际工程中为了抗噪声,通常用 4 到 8 个光源,做最小二乘。
这里有个关键点:ρ 和 N 是耦合的。常用做法是把 ρ 吸收进法线向量,令 g = ρ · N,先解 g,再归一化得到 N 和 ρ。这样方程变成线性:
I = L · g
其中 L 是 m×3 的光源方向矩阵,g 是 3×1 向量。最小二乘解为:
g = (LᵀL)⁻¹ Lᵀ I
这就是整个光度立体最核心的一步。代码里通常用 numpy 的 lstsq 直接解,不用手动求逆。
2.2 光源方向标定:最容易翻车的一步
光源方向 L 怎么来?常见做法有三种:
第一种,用标定球。放一个已知半径的高反球体,每个光源下拍一张,球面高光点位置可以反推光源方向。这是最准的,但需要额外硬件。
第二种,用平面标定板。把一块白色漫反射平板放在相机视野内,每个光源单独亮,拍到的亮度分布可以拟合光源方向。适合光源对称安装的情况。
第三种,直接按机械安装角度估算。比如环形光源 8 个灯珠均匀分布,仰角 30°,那每个光源方向就是 (cos30°·cosθ, cos30°·sinθ, sin30°),θ 是方位角。这种方法误差大,但很多产线项目就这么干,因为重建结果对光源方向误差有一定容忍度。
我一般会先用机械角度估算跑通,再用标定球修正。如果重建出来整体倾斜,八成是光源方向矩阵的坐标系和相机坐标系没对齐。
2.3 用 Python 解最小二乘:核心代码与参数说明
下面是最小求解的代码片段,假设你已经把 m 张图读成灰度矩阵,并堆叠成形状 (m, H, W) 的数组。
import numpy as np def solve_normals(images, light_dirs): """ images: shape (m, H, W), 每个光源下的灰度图,已转 float32 light_dirs: shape (m, 3), 每个光源的单位方向向量 返回: normals (H, W, 3), albedo (H, W) """ m, H, W = images.shape # 把图像堆叠成 (m, H*W) I = images.reshape(m, -1).astype(np.float32) # 光源矩阵 L: (m, 3) L = light_dirs.astype(np.float32) # 最小二乘解 g = pinv(L) @ I, 形状 (3, H*W) # 用 lstsq 比手动求逆稳定 g, residuals, rank, s = np.linalg.lstsq(L, I, rcond=None) # g 的形状是 (3, H*W) g = g.T.reshape(H, W, 3) # 法线归一化 norm = np.linalg.norm(g, axis=2, keepdims=True) norm[norm == 0] = 1.0 # 防止除零 normals = g / norm albedo = norm.squeeze(axis=2) return normals, albedo逻辑说明:np.linalg.lstsq内部做 SVD 分解,比直接算 (LᵀL)⁻¹Lᵀ 数值更稳。rcond=None让 numpy 用机器精度自动截断小奇异值。返回的g是 (3, H*W),转置后 reshape 成 (H, W, 3)。归一化那一步同时得到法线和反照率,反照率可以单独存成灰度图用于缺陷检测。
参数说明:light_dirs必须是单位向量,否则解出来的 g 会带尺度误差。如果某个像素在所有光源下都是 0(阴影或黑点),最小二乘会给出零向量,归一化时用norm == 0保护。实际项目中还会加一个亮度阈值掩膜,把过暗像素排除。
2.4 从法线到深度:积分那一步的两种做法
得到法线图后,深度重建是另一个独立问题。法线给出的是表面梯度:
p = -nx / nz q = -ny / nz
然后解 p = ∂z/∂x, q = ∂z/∂y。常用方法有:
- 路径积分法:从某个起点开始,沿 x 和 y 方向累加梯度。简单但误差会累积。
- 泊松方程法:解 ∇²z = ∂p/∂x + ∂q/∂y,用离散余弦变换或多重网格。更稳,适合有噪声的法线图。
Python 里可以用scipy.fft做 DCT 求解泊松方程,代码大约 20 行。如果只是做缺陷检测,其实法线图本身已经够用,不一定非要积分到深度。
3. 把源码跑起来:环境配置、数据组织与调试步骤
3.1 Python 环境与依赖安装
拿到源码包后,第一步不是急着运行,而是确认 Python 版本和依赖。光度立体项目通常依赖 numpy、opencv-python、scipy、matplotlib。有些还会用 open3d 做点云可视化。
# 建议用 conda 建独立环境,避免和系统 Python 冲突 conda create -n photometric python=3.9 conda activate photometric # 安装核心依赖 pip install numpy opencv-python scipy matplotlib pip install open3d # 可选,用于三维点云显示如果你用 vscode,记得在右下角切换解释器到 photometric 环境。常见翻车现场是终端里装好了,vscode 里跑的却是另一个 Python,报ModuleNotFoundError。
3.2 数据目录结构与光源方向文件
一个典型的光度立体项目数据组织如下:
project/ ├── data/ │ ├── obj1/ │ │ ├── light_01.png │ │ ├── light_02.png │ │ ├── light_03.png │ │ ├── light_04.png │ │ └── lights.txt # 每行一个光源方向: x y z │ └── obj2/ │ └── ... ├── src/ │ ├── photometric.py # 核心求解 │ ├── integrate.py # 法线积分深度 │ └── visualize.py # 可视化 ├── requirements.txt └── README.mdlights.txt里存的是单位向量,顺序必须和图像文件名顺序一致。我见过有人把顺序搞反,重建出来的凹凸完全颠倒,排查了一下午。
3.3 运行主程序并检查中间结果
假设源码里有一个main.py或run.py,典型调用方式:
python main.py --data_dir ./data/obj1 --light_file ./data/obj1/lights.txt --output ./result运行后不要只看最终深度图。按顺序检查三样东西:
第一,反照率图。它应该看起来像一张均匀光照下的灰度图,没有明显光源方向残留。如果反照率图上还有亮斑,说明光源方向标定有偏差。
第二,法线图可视化。把法线的三个分量映射到 RGB,正常应该看到表面起伏的彩色图。如果整体偏一个颜色,说明法线有系统性倾斜。
第三,深度图。检查是否有大面积平坦区域出现异常尖刺,那通常是阴影或高光导致的坏点。
3.4 参数怎么调:阈值、光源数量与正则化
源码里通常有几个可调参数:
intensity_threshold:低于这个灰度值的像素不参与最小二乘。默认 10 左右。如果图像偏暗,可以降到 5;如果噪声大,提到 20。use_regularization:是否在最小二乘里加 Tikhonov 正则项。对于光源数量少(比如 3 个)的情况,加一个小的 lambda 能抑制噪声放大。integration_method:选poisson还是path。有噪声时选 poisson,速度快但要求法线连续时选 path。
我一般会先用默认参数跑一遍,看反照率图是否干净,再决定要不要调阈值。
4. 避坑与排查:光度立体落地时最容易踩的五个坑
4.1 重建结果整体倾斜或凹凸颠倒
现象:深度图看起来像被斜着拉了一下,或者凹坑变成凸起。
原因:光源方向矩阵的坐标系和相机坐标系不一致。比如光源方向是按世界坐标给的,但图像坐标的 x 轴和 y 轴方向没对齐。另外,如果光源方向 z 分量符号搞反,凹凸就会颠倒。
解决:先确认相机坐标系定义。通常图像 x 向右,y 向下,z 指向物体。光源方向也按这个坐标系给。如果不确定,拿一个已知凸起的球体做测试,看重建结果是否凸起。
4.2 反照率图上有明显光源方向残留
现象:反照率图上能看到某个方向的亮带或暗带。
原因:光源方向误差太大,或者光源不是理想点光源,有扩散。也可能是相机响应非线性,灰度值和实际亮度不成正比。
解决:用标定球重新标定光源方向。如果没法标定,尝试对每张图做平场校正,除以一个均匀白板的图像。相机响应可以用 Gamma 校正近似。
4.3 高光区域重建出尖刺
现象:金属表面高光点附近深度图出现尖刺或空洞。
原因:朗伯模型在高光处失效,高光像素亮度饱和,最小二乘解出的法线方向错误。
解决:在高光区域做掩膜,不参与积分。或者用鲁棒最小二乘,比如 Huber 损失,降低高光像素的权重。源码里如果有mask参数,把高光掩膜传进去。
4.4 光源数量少于 3 个或共面
现象:最小二乘解不稳定,法线图噪声极大。
原因:方程数不够或光源方向矩阵秩亏。3 个光源必须不共面,4 个以上也要保证方向分布均匀。
解决:至少用 4 个光源,最好 6 到 8 个,均匀分布在半球面上。如果只有 3 个,加正则化项。
4.5 深度积分出现累积误差
现象:深度图沿某个方向越来越斜,整体不平。
原因:路径积分法从起点开始累加梯度,误差会累积。或者法线图本身有低频偏差。
解决:改用泊松方程积分,它在频域求解,没有路径依赖。如果必须用路径积分,选多个起点做平均。
5. 进阶技巧:用光度立体做缺陷检测与点云导出
5.1 用法线图做表面缺陷增强
光度立体最大的价值不是深度图,而是法线图。法线图对表面微小起伏极其敏感,普通二维图像里看不清的浅划痕,在法线图的 nx 或 ny 分量上会非常明显。我通常会把法线图转成灰度图,然后做自适应阈值分割,划痕检出率比直接看原图高很多。
具体做法:取 nx 分量,归一化到 0-255,然后用 cv2.adaptiveThreshold 做二值化。划痕通常表现为局部梯度突变,在法线图上就是一条亮线或暗线。
import cv2 import numpy as np # normals 形状 (H, W, 3) nx = normals[:, :, 0] # 归一化到 0-255 nx_vis = ((nx - nx.min()) / (nx.max() - nx.min()) * 255).astype(np.uint8) # 自适应阈值 binary = cv2.adaptiveThreshold(nx_vis, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 5) # binary 里白色区域就是法线突变区域,对应划痕或边缘参数说明:blockSize=31是局部窗口大小,根据划痕宽度调,一般取划痕宽度的 3 到 5 倍。C=5是阈值偏移,越小越敏感,但噪声也越多。
5.2 导出点云到 open3d 做三维查看
如果你想把重建结果导出成点云,用 open3d 很方便。把深度图转成 (H, W, 3) 的点坐标,颜色可以用反照率图。
import open3d as o3d import numpy as np def depth_to_pointcloud(depth, albedo, scale=1.0): H, W = depth.shape # 生成像素网格 x, y = np.meshgrid(np.arange(W), np.arange(H)) # 假设相机焦距 f,这里用简单正交投影 points = np.stack([x, y, depth * scale], axis=-1).reshape(-1, 3) colors = np.stack([albedo, albedo, albedo], axis=-1).reshape(-1, 3) pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points) pcd.colors = o3d.utility.Vector3dVector(colors) return pcd pcd = depth_to_pointcloud(depth, albedo, scale=0.1) o3d.visualization.draw_geometries([pcd])注意:这里用的是正交投影,实际相机有透视,需要根据标定参数做反投影。如果只是看相对起伏,正交投影够用。
5.3 一个我踩过的坑:光源顺序与文件命名
最后说一个血泪教训。有一次我拿到一批图,文件名是1.png到8.png,光源方向文件里也是 8 行。我按文件名排序读入,结果重建出来完全不对。后来发现10.png排在2.png前面,因为字符串排序。改成按数字排序后一切正常。所以读图时一定要用sorted(glob, key=lambda x: int(re.findall(r'\d+', x)[0]))这种按数字提取的排序,别偷懒。
这个方案值不值得做?如果你手头有普通工业相机和可编程光源,光度立体的硬件成本几乎为零,软件用 Python 半天能跑通。它不能替代高精度激光扫描,但在表面缺陷检测和法线增强这个场景里,性价比很高。希望帮到你。
本文还有配套的精品资源,点击获取