从阴影恢复形状:Matlab实现SFS三维重建全解析
2026/9/20 22:59:20 网站建设 项目流程

简介:这份压缩包聚焦计算机视觉中的 Shape from Shading(SFS)技术,提供了基于 Matlab 的完整实现代码,适合具备一定 Matlab 编程基础、希望研究明暗恢复形状算法或开展三维重建实验的研究者与开发者。资源共含 89 个文件,压缩包大小约 1.59MB,内部以 49 个 m 脚本为主体,覆盖图像预处理、光照模型、迭代求解与误差度量等核心环节,同时附带 c/mex 源文件、编译好的 mexw64/mexmaci64/mexa64 动态库、用于验证的 mat 深度数据与 png 示例图像,便于直接运行 demo 查看 Lena、花瓶等经典测试图的重建效果。目前已有 81 人学习下载。通过该代码库,读者能快速上手 SFS 的 eikonal 求解思路,理解正交与透视投影下的光照估计流程,并能导出 OBJ 模型用于后续渲染或算法对比。整体目录按 demo、Toolbox、Data 组织,结构清晰,是一份兼顾教学演示与二次开发的实用参考。 拿到这个 zip 的时候,我以为是某个课程作业包,解压完才发现整套工程挺完整:主函数、核心迭代、反射模型、合成数据生成脚本和可视化都在,结构非常清爽。补了两天代码,跑通了整个流程,也踩了几个坑,今天把 shape from shading 的 Matlab 实现从原理到调试一次性讲透。如果你正好在做人脸三维重建、表面缺陷检测、或者拿单张遥感影像反演地形高度,这篇可以直接抄作业。

1. 从阴影恢复形状:一个图像问题背后的三维重建逻辑

1.1 单张图片为什么能算出高度

Shape from shading(简称 SFS)解决的是这样一个问题:只给一张灰度图,怎么推出物体表面每个点的高度。直觉上说,一张照片里,亮的地方通常是正对着光源的平面,暗的地方往往是背光或者凹陷区域,这种明暗差异里其实藏了大量的几何信息。

SFS 的核心假设是朗伯体反射,也就是物体表面均匀散射光线,观察到的亮度只取决于表面法向量和光源方向的夹角。用公式写就是:

I(x,y) = ρ · max( n · L , 0 )

其中 I 是像素灰度,ρ 是表面反照率,n 是表面法向量,L 是光源单位方向向量。如果我们把表面看成函数 z(x,y),那么法向量可以由梯度 p = ∂z/∂x、q = ∂z/∂y 表示。代进去以后,灰度图像和表面几何之间就形成了一个偏微分方程,也叫亮度方程。解这个方程,就能反推出高度场 z(x,y)。

我第一次接触这个概念时觉得绕,后来想通了一个类比:就像你用手电筒斜着照一个圆顶,背光一侧会有一个暗弧,暗弧的位置和宽度直接告诉你圆顶的弧度。SFS 做的就是把这个“弧度推断”用像素级的计算铺满整张图。

1.2 梯度空间与朗伯反射模型

SFS 里最重要的空间变换是梯度空间(gradient space),也就是把每个像素位置上的 p 和 q 当成两个新坐标轴,表面几何信息在这里变成一条条等亮度曲线。这张图在最早期的 SFS 论文里反复出现,是理解整个算法的一把钥匙。

实际操作里,朗伯反射模型还要带上光源方向的 z 分量。假设光源方向为 L = (lx, ly, lz),表面法向量为 n = (-p, -q, 1)/√(1+p²+q²),那么亮度方程可以写成:

R(p,q) = ρ · ( -p·lx - q·ly + lz ) / √(1+p²+q²)

这个 R(p,q) 就是某个位置的理想灰度值。如果图像的实际灰度 I 和 R 一致,说明当前的 p、q 是准确的;如果不一致,就把 p、q 往减小误差的方向修正。所有 SFS 算法本质上都是围绕“如何让 R(p,q) 逼近 I”做文章。

这里有一个必须注意的隐患:亮度方程是欠约束的,同一个灰度值可能对应无数个 p、q 组合,所以必须有额外约束才能得到稳定解。最常见的约束是光滑性先验,也就是假设表面高度变化不会太快,数学上就是给目标函数加一个 p 和 q 的梯度惩罚项。

1.3 主流 SFS 算法横向对比

经典 SFS 算法大致分三类:变分法、偏微分方程特征线法、线性化方法。我这次在 Matlab 工程里用的是 Tsai-Shah 线性化方法,因为它计算稳定、实现直观、对新手最友好,而且在这个 zip 的原始代码基础上改写成本最低。

算法类型典型代表优点缺点适用场景
变分法Horn & Brooks数学框架完整,可加入各种约束迭代收敛慢,容易陷入局部极小学术研究、精度要求高
PDE 特征线法Oliensis对光源方向敏感度低,重建细节好边界条件处理复杂,代码实现难有清晰光照条件的单光源场景
线性化方法Tsai-Shah迭代简单、速度快、易实现对噪声敏感,恢复曲面较平滑入门学习、快速原型验证

选 Tsai-Shah 很核心的原因是这个 zip 的目标场景是合成数据和受控光源环境,灰度图噪声小、光照单一,线性化方法正好发挥优势。如果你要处理的是手机随手拍的照片,建议看完第 4 节的改进思路再做选择。

2. Matlab 代码工程整体拆解

2.1 文件结构与核心模块

解压后的目录结构大概是这样的:

shape-from-shading-matlab/ ├── main_sfs.m # 主脚本:加载数据、调用核心算法、显示结果 ├── sfs_tsai.m # 核心迭代函数 ├── reflectance_lambert.m # 朗伯反射模型计算 ├── synthetic_sphere.m # 生成合成球体测试数据 ├── load_and_preprocess.m # 图像读取和归一化预处理 ├── render_surface.m # 根据高度场重新渲染灰度图 └── README.md

这是一个很典型的“单目录多脚本”科学计算工程,没有复杂包结构。好处是调试方便,坏处是参数全部散落在各脚本里,所以拿到代码后的第一件事,不是跑,而是把所有脚本里的参数集中到 main_sfs.m 顶部,统一维护。

2.2 核心迭代函数逻辑

sfs_tsai.m 是整个工程的灵魂,它的基本思路是把高度 z 当成未知数,用有限差分近似 p、q,然后按像素逐点更新。我重写并简化后的核心循环如下:

function Z = sfs_tsai(I, L, iterMax, dt) % I : 归一化灰度图像,范围 [0,1] % L : 光源方向,[lx, ly, lz] % iterMax : 最大迭代次数 % dt : 更新步长 rho = 1; [rows, cols] = size(I); Z = zeros(rows, cols); for iter = 1:iterMax Z_old = Z; for i = 2:rows-1 for j = 2:cols-1 p = Z(i,j) - Z(i,j-1); q = Z(i,j) - Z(i-1,j); denom = sqrt(1 + p^2 + q^2); r = rho * (-p*L(1) - q*L(2) + L(3)) / max(denom, eps); delta = I(i,j) - r; grad = (L(1)*Z(i,j-1) + L(2)*Z(i-1,j) - L(1)*Z(i,j) ... - L(2)*Z(i,j) + L(3)) / max(denom^3, eps); Z(i,j) = Z(i,j) + dt * delta * grad; end end if mod(iter, 100) == 0 res = mean(mean(abs(I - render_surface(Z, L)))); fprintf('iter %d, residual %.4f\n', iter, res); end end end

这段代码的关键在于 p 和 q 的差分方向,因为我采用的是后向差分,所以循环里从第 2 行、第 2 列开始。边界像素保持为 0,也就是默认表面边界高度为 0,这个约束对结果影响很大,后面会细说。

2.3 参数配置与调参建议

主脚本里建议设置如下参数组合,实测收敛速度和重建效果平衡得比较好:

I = load_and_preprocess('sphere.png'); L = [-0.577, -0.577, 0.577]; % 归一化光源方向,指向左上 iterMax = 300; dt = 0.1;

dt 这个参数是最大的坑。dt 太大,高度值会振荡发散,最后图片上全是雪花噪点;dt 太小,300 次迭代根本收敛不了。我推荐用自适应步长策略,比如每轮迭代后检查残差是否增大,如果增大就把 dt 乘以 0.8,连续 10 轮残差下降幅度小于千分之一就提前终止。这个工程里可以自己补上这个逻辑,效果会显著提升。

3. 实操过程:完整跑通从图像到三维重建

3.1 准备测试数据:合成球体

真实照片做 SFS 有一个大麻烦:光照方向难以精确估计。所以这个 zip 里首选测试数据是合成球体,在任何坐标系下都知道球心坐标,可以直接算出理论高度场,便于验证算法误差。

synthetic_sphere.m 的实现思路很简单,生成一个半径 r 的半球,然后把高度归一化到 [0,1],再按照光源方向渲染成灰度图。核心片段如下:

function [I, Z_true] = synthetic_sphere(n, r) % n : 图像尺寸,生成 n x n 的球体图像 % r : 球体半径(像素) [x, y] = meshgrid(1:n, 1:n); mask = (x - n/2).^2 + (y - n/2).^2 <= r^2; Z_true = zeros(n); Z_true(mask) = sqrt(r^2 - (x(mask)-n/2).^2 - (y(mask)-n/2).^2); zmin = min(Z_true(mask)); Z_true(mask) = Z_true(mask) - zmin; I = render_surface(Z_true, [-0.577, -0.577, 0.577]); end

这里有个经验点:生成球体后必须减去最低高度,否则重建出来的 Z 会整体往上飘。很多初学 SFS 的同学拿到的结果对比图像看起来形状对,但相对高度始终跟 ground truth 差一个常数,往往就是这个细节导致的。

3.2 运行主流程

把 main_sfs.m 整理好后,按顺序执行以下流程即可:

  1. 运行 synthetic_sphere 生成合成球体图像和理论高度场。
  2. 调用 load_and_preprocess 对图像做归一化,把灰度范围压到 [0,1] 之间。
  3. 设定光源方向 L,调用 sfs_tsai 进行迭代重建。
  4. 用 render_surface 根据重建的高度场重新渲染图像,计算灰度残差,验证收敛情况。
  5. 使用 surf 或 mesh 可视化重建高度场。

我这里实测的典型结果是:球体表面重建形状正确,中心相对高度误差在 5% 以内,但靠近图像边缘的背光区域会出现明显的凹陷,原因后面专门讲。

3.3 结果可视化技巧

Matlab 里看重建结果,我强烈建议至少开两个 figure:一个用 surf 显示高度场,另一个用 pcolor 显示误差热力图。单纯看 surf 会被视觉欺骗,因为 surf 本身会根据高度着色,很容易忽略系统误差。

[X, Y] = meshgrid(1:size(Z,2), 1:size(Z,1)); figure; surf(X, Y, Z, 'EdgeColor', 'none'); colormap(jet); lighting gouraud; title('SFS 重建高度场'); figure; err = abs(Z - Z_true); pcolor(err); shading interp; colorbar; title('绝对误差分布');

注意 surf 和 mesh 的区别:surf 是填充面,mesh 是网格线。看光滑曲面用 surf,看算法迭代过程用 mesh 更直观。第一次跑不建议加光照效果,先把形状看清楚再追求显示效果。

4. 常见问题与实战调参经验

4.1 高频问题速查表

这是我实测过程中反复遇到的几类情况,整理成表,方便直接对照排除。

现象根本原因解决方案
高度值迅速膨胀到 10³ 量级迭代步长 dt 过大dt 降到 0.01~0.05
重建表面出现大量“尖刺”光源方向设定与实际不匹配换用对称光源或手动标定 L
边界区域高度异常隆起边界固定为 0 约束太强改用 Neumann 边界条件,允许边界自由
灰度图过曝区域高度全是 NaN像素为 255 时反射模型分母过小预处理时对灰度做 lim 非线性压缩,并加 eps
结果整体高度比真实值小一倍反照率 rho 被错误设为小于 1合成数据必定 rho=1,真实图需先做反射率补偿
Matlab 打开代码报路径错误工程放在中文路径下整个目录移动到纯英文路径

4.2 光照方向估计不准导致的问题

SFS 对光源方向极其敏感。哪怕光源方向偏了 10 度,重建出来的人脸就会扁掉或者拉出奇怪的凸起。用合成数据时我们精确知道光源方向,但用真实图像时必须自己估计。我的建议是用 PCA 或最小二乘法拟合球体表面的灰度分布来反推 L,而不是肉眼猜。

一个比较稳的估计方法:在图像中找一个近似平面区域,那块区域的灰度近似等于 ρ·lz,这样先估出 lz;再找有梯度变化的区域估计 lx 和 ly。这个流程在代码里可以封装成 estimate_light_direction.m,会比手调参数快得多。

4.3 我的几条独家经验

第一,迭代到一半看残差曲线。如果残差在前 50 轮快速下降,后面 200 轮几乎不动,那说明算法已经到达线性化近似的极限,再迭代也是浪费时间,这时应该调整差分格式而不是加大迭代次数。

第二,尽量用高斯平滑预处理图像。不是平滑高度场,而是平滑输入灰度图。灰度图里的高频噪声会被有限差分急剧放大,三重差分会直接毁掉重建结果。我实测高斯核 σ=1.0 的三通道效果最好。

第三,这个工程如果继续往下扩展,建议优先加入表面反照率估计和阴影检测。发丝、金属高光这类像素在朗伯模型下是天然错误源,把这些像素 mask 掉以后再迭代,重建质量会提升一个档次。

最后再分享一个小技巧

如果手上的灰度图不是合成数据,而是相机拍的,建议先做一次反gamma校正。我最初直接用 raw 相机输出灰度图跑 SFS,一直觉得重建结果发胖变形,后来才发现是相机内部的 gamma 映射破坏了亮度与几何的线性关系。在 load_and_preprocess.m 里加一行 I = I.^(2.2),厚度感立刻就不一样了。

这个领域论文叠加了很多数学包装,但核心始终是那一个:亮度和梯度之间的物理关系。Matlab 代码把这句话翻译成几十行循环,跑通一遍之后,你会对偏微分方程、梯度下降和光照模型都有更具体的体感。沿着这个方向继续玩,可以试试用更快的超松弛迭代替代简单梯度更新,或者把代码迁移到 GPU 上做实时重建,可延展的路径不少。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询