朗伯光度立体法 Python 3.11 实现:从 3 光源图像到法线贴图的完整流程
朗伯光度立体法 Python 3.11 实现:从 3 光源图像到法线贴图的完整流程
在计算机视觉领域,光度立体法(Photometric Stereo)是一种通过分析物体在不同光照条件下的图像变化来重建物体表面三维形状的技术。本文将详细介绍如何使用 Python 3.11 和现代计算机视觉库(如 NumPy 和 OpenCV)实现朗伯光度立体法,从三张不同光源方向的图像生成法线贴图和反照率图。
1. 光度立体法基础原理
光度立体法的核心思想基于朗伯反射模型(Lambertian Reflectance),该模型假设物体表面是理想漫反射表面,即光线在所有方向上均匀散射。根据这一模型,图像中某一点的亮度可以表示为:
I = ρ * (L · N)其中:
- I:观察到的像素亮度
- ρ:表面反照率(albedo)
- L:光源方向向量
- N:表面法线向量
当使用多个(至少三个)不同方向的光源照射同一物体时,我们可以建立方程组来求解每个像素点的法线和反照率。
1.1 数学推导
对于 n 个光源,我们可以将方程组表示为矩阵形式:
[I₁, I₂, ..., In]ᵀ = ρ * L * N其中 L 是一个 3×n 的矩阵,每一列代表一个光源方向。通过最小二乘法,我们可以解出 G = ρN:
G = (LᵀL)⁻¹LᵀI然后分别计算反照率和归一化的法线:
ρ = ||G|| N = G / ρ2. 环境准备与依赖安装
在开始编码前,需要确保 Python 3.11 环境和必要的库已安装。以下是推荐的环境配置:
# 创建并激活虚拟环境 python3.11 -m venv photometric_env source photometric_env/bin/activate # Linux/Mac photometric_env\Scripts\activate # Windows # 安装核心依赖 pip install numpy opencv-python matplotlib scipy关键库的作用:
- NumPy:用于矩阵运算和线性代数求解
- OpenCV:图像加载和预处理
- Matplotlib:结果可视化
- SciPy:可选,用于高级数学运算
3. 数据准备与预处理
3.1 输入图像要求
理想的光度立体法输入应满足以下条件:
- 完全静态的场景和相机
- 至少三个不同方向的光源
- 已知精确的光源方向向量
- 无环境光干扰
典型的输入图像集可能如下:
input/ ├── light1.jpg # 光源方向1 ├── light2.jpg # 光源方向2 └── light3.jpg # 光源方向33.2 图像加载与对齐
即使相机固定,微小的位移也可能影响结果。我们可以使用特征匹配来确保图像对齐:
import cv2 import numpy as np def align_images(images): # 使用第一张图像作为参考 ref_image = images[0] aligned = [ref_image] # 初始化ORB检测器 orb = cv2.ORB_create() kp1, des1 = orb.detectAndCompute(ref_image, None) # 创建BFMatcher对象 bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) for img in images[1:]: kp2, des2 = orb.detectAndCompute(img, None) # 匹配描述符 matches = bf.match(des1, des2) matches = sorted(matches, key=lambda x: x.distance) # 提取匹配点位置 src_pts = np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1,1,2) dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1,1,2) # 计算单应性矩阵 M, _ = cv2.findHomography(dst_pts, src_pts, cv2.RANSAC, 5.0) # 应用变换 aligned_img = cv2.warpPerspective(img, M, (ref_image.shape[1], ref_image.shape[0])) aligned.append(aligned_img) return aligned4. 核心算法实现
4.1 光源方向矩阵构建
光源方向矩阵 L 是算法的关键输入。假设我们已经通过校准知道三个光源的方向向量:
# 示例:三个光源方向(需根据实际设置调整) L = np.array([ [0, 0, 1], # 正上方光源 [1, 0, 0.5], # 右侧光源 [-1, 0, 0.5] # 左侧光源 ]).T # 转置为3×n矩阵注意:实际应用中需要通过校准球或其他方法精确测量光源方向。错误的光源方向会导致法线计算不准确。
4.2 光度立体法核心计算
下面是完整的法线和反照率计算实现:
def photometric_stereo(images, L): """ 光度立体法核心计算 :param images: 对齐后的图像列表 (h, w) 或 (h, w, 3) :param L: 光源方向矩阵 (3, n) :return: albedo, normal """ # 确保L是3×n矩阵 assert L.shape[0] == 3 # 将图像堆叠为 (h, w, n) 或 (h, w, 3, n) if images[0].ndim == 2: # 灰度图 intensities = np.dstack(images) # (h, w, n) else: # 彩色图 intensities = np.stack(images, axis=-1) # (h, w, 3, n) # 计算伪逆 L_inv = np.linalg.pinv(L) # (n, 3) if intensities.ndim == 3: # 灰度处理 # 计算G矩阵 (h, w, 3) G = np.einsum('ij,klj->kli', L_inv, intensities) # 计算反照率和法线 albedo = np.linalg.norm(G, axis=2) # (h, w) normal = G / (albedo[..., np.newaxis] + 1e-10) # 避免除以零 return albedo, normal else: # 彩色处理 h, w = intensities.shape[:2] albedo = np.zeros((h, w, 3)) normal = np.zeros((h, w, 3, 3)) # 对每个颜色通道分别处理 for c in range(3): G = np.einsum('ij,klj->kli', L_inv, intensities[..., c, :]) albedo[..., c] = np.linalg.norm(G, axis=2) normal[..., c, :] = G / (albedo[..., c, np.newaxis] + 1e-10) # 合并通道(取平均法线) final_normal = np.mean(normal, axis=2) final_normal /= np.linalg.norm(final_normal, axis=2, keepdims=True) return albedo, final_normal4.3 法线可视化
计算得到的法线向量各分量在[-1,1]范围内,需要映射到[0,1]以便可视化:
def visualize_normal(normal): """ 将法线向量可视化为RGB图像 :param normal: (h, w, 3) 法线图,分量范围[-1,1] :return: (h, w, 3) uint8图像 """ # 将法线从[-1,1]映射到[0,1] vis = (normal + 1) / 2 # 处理可能的NaN值 vis = np.nan_to_num(vis) # 转换为8位图像 return (vis * 255).astype(np.uint8)5. 完整流程示例
下面是一个完整的从图像加载到结果可视化的示例:
# 1. 加载图像 image_files = ['light1.jpg', 'light2.jpg', 'light3.jpg'] images = [cv2.imread(f, cv2.IMREAD_COLOR) for f in image_files] images = [cv2.cvtColor(img, cv2.COLOR_BGR2RGB) for img in images] # 2. 图像对齐(可选) aligned_images = align_images(images) # 3. 转换为灰度图 gray_images = [cv2.cvtColor(img, cv2.COLOR_RGB2GRAY).astype(float)/255.0 for img in aligned_images] # 4. 定义光源方向矩阵(需根据实际设置调整) L = np.array([ [0, 0, 1], [1, 0, 0.5], [-1, 0, 0.5] ]).T # 5. 计算法线和反照率 albedo, normal = photometric_stereo(gray_images, L) # 6. 可视化结果 normal_vis = visualize_normal(normal) # 显示结果 import matplotlib.pyplot as plt plt.figure(figsize=(15,5)) plt.subplot(131); plt.imshow(aligned_images[0]); plt.title('输入图像1') plt.subplot(132); plt.imshow(albedo, cmap='gray'); plt.title('反照率图') plt.subplot(133); plt.imshow(normal_vis); plt.title('法线图') plt.show()6. 高级优化与注意事项
6.1 阴影和高光处理
实际图像中可能存在阴影(亮度为零)和高光(饱和区域),这些区域会干扰法线计算。我们可以通过以下策略改进:
- 阴影检测:排除亮度低于阈值的像素
- 高光剔除:排除亮度高于阈值的像素
- 鲁棒回归:使用RANSAC等鲁棒算法拟合
改进后的核心计算部分:
def robust_photometric_stereo(images, L, shadow_thresh=0.05, highlight_thresh=0.95): h, w = images[0].shape[:2] n = len(images) albedo = np.zeros((h, w)) normal = np.zeros((h, w, 3)) for y in range(h): for x in range(w): # 收集所有光源下的亮度 I = np.array([img[y,x] for img in images]) # 排除阴影和高光 valid_mask = (I > shadow_thresh) & (I < highlight_thresh) if np.sum(valid_mask) < 3: # 至少需要3个有效光源 albedo[y,x] = 0 normal[y,x] = [0,0,0] continue # 使用有效光源计算 valid_L = L[:, valid_mask] valid_I = I[valid_mask] # 最小二乘解 G = np.linalg.lstsq(valid_L.T, valid_I, rcond=None)[0] # 计算反照率和法线 albedo[y,x] = np.linalg.norm(G) if albedo[y,x] > 0: normal[y,x] = G / albedo[y,x] return albedo, normal6.2 彩色图像处理
对于彩色图像,我们可以分别处理每个颜色通道,然后合并结果:
def color_photometric_stereo(color_images, L): # 分离通道 channels = [ [img[...,0] for img in color_images], # R通道 [img[...,1] for img in color_images], # G通道 [img[...,2] for img in color_images] # B通道 ] # 各通道独立计算 results = [photometric_stereo(ch, L) for ch in channels] # 合并反照率 albedo = np.stack([res[0] for res in results], axis=-1) # 平均法线(需重新归一化) combined_normal = np.mean([res[1] for res in results], axis=0) norm = np.linalg.norm(combined_normal, axis=2, keepdims=True) norm[norm == 0] = 1 # 避免除以零 combined_normal /= norm return albedo, combined_normal7. 结果分析与应用
7.1 法线贴图的应用
生成的法线贴图可以用于:
- 表面缺陷检测:通过分析法线异常发现表面缺陷
- 三维重建:结合深度积分算法重建三维形状
- 材质分析:结合反照率图分析表面材质特性
7.2 深度积分
从法线图可以进一步计算深度图。基本思路是通过求解泊松方程:
from scipy import sparse from scipy.sparse.linalg import spsolve def integrate_normal(normal): h, w = normal.shape[:2] # 计算梯度场 dzdx = -normal[...,0] / (normal[...,2] + 1e-10) dzdy = -normal[...,1] / (normal[...,2] + 1e-10) # 构建稀疏矩阵 indices = np.arange(h*w).reshape(h,w) A = sparse.lil_matrix((2*h*w, h*w)) b = np.zeros(2*h*w) # 填充x方向梯度约束 row = 0 for y in range(h): for x in range(w-1): A[row, indices[y,x]] = -1 A[row, indices[y,x+1]] = 1 b[row] = dzdx[y,x] row += 1 # 填充y方向梯度约束 for y in range(h-1): for x in range(w): A[row, indices[y,x]] = -1 A[row, indices[y+1,x]] = 1 b[row] = dzdy[y,x] row += 1 # 添加边界条件(固定一个点) A = sparse.csr_matrix(A) A = A[:row] # 移除未使用的行 b = b[:row] # 求解线性系统 z = spsolve(A.T @ A, A.T @ b) return z.reshape(h,w)8. 性能优化技巧
对于大图像或实时应用,可以考虑以下优化:
- 向量化计算:使用NumPy的广播机制避免循环
- GPU加速:使用CuPy替代NumPy
- 多分辨率处理:先在小尺度计算,再上采样细化
- 并行处理:对图像分块并行计算
示例GPU加速实现:
import cupy as cp def gpu_photometric_stereo(images, L): # 将数据转移到GPU L_gpu = cp.array(L) images_gpu = cp.array(images) # 计算伪逆 L_inv = cp.linalg.pinv(L_gpu) # 计算G矩阵 G = cp.einsum('ij,klj->kli', L_inv, images_gpu) # 计算反照率和法线 albedo = cp.linalg.norm(G, axis=2) normal = G / (albedo[..., cp.newaxis] + 1e-10) # 将结果传回CPU return cp.asnumpy(albedo), cp.asnumpy(normal)