做三维建模的人应该都有过这种经历手头明明有一张轮廓清晰、信息完整的二维图像却要花上大半天在三维软件里照着描、拉曲面、调样条最后做出来的形状还不一定满意。尤其是做产品前期验证、快速出模型给别人看的时候这种纯手工流程非常拖节奏。我这两年一直在用MATLAB处理这类问题从二维图像生成3D形状的几条技术路线都摸过一遍今天把这套东西整理出来包括轮廓挤出、明暗恢复形状、多视角重建和深度学习单图重建这几类核心方法代码细节、参数选择、坑点都会讲到希望能帮大家少走弯路。这篇文章适合的人群很广做机械设计、产品造型、逆向工程的朋友可以从轮廓挤出和多视角重建这两节直接拿到能用的流程做科研、做算法验证的可以把后面Shape from Shading和深度学习部分当作参考基线哪怕你是刚接触三维重建的学生按着步骤跑通一遍对图像深度信息从哪来这件事也会有直观的理解。1. 先看全貌二维图像里到底藏着什么三维信息1.1 从像素到三维信息从哪儿来一张普通的二维图像本质上是三维世界在某一个观察方向上的投影深度维度的信息在投影过程中被压缩掉了。所谓从二维图像生成三维形状核心工作就是想办法把丢失的深度信息补回来。这里的关键问题是丢失的信息只能靠推断还原而推断需要依据不同方法依赖的依据完全不同。几何轮廓线索物体边缘的轮廓线可以直接决定形状的外边界。适合生成柱状、拉伸体这类相对规则的结构。明暗光影线索物体表面朝向不同接收光照的强度就不同灰度变化中隐藏着表面法向量的信息。适合恢复连续起伏的曲面。多视角视差线索同一个点从不同角度看过去在图像上的位置会有偏移这个偏移量视差可以用来反推深度。适合还原真实物体的完整几何。可以这样理解轮廓法相当于拿剪纸往两边拉明暗法相当于照着素描阴影判断凸凹多视角法则更接近两只眼睛产生的立体视觉。三种线索不是互斥的实际工程项目里经常会叠加使用。1.2 五条主路线一张表看清在动手写代码之前先把几条路线的特点摆出来。我根据自己做过的项目把它们按输入门槛、输出品质、调试成本三个维度对比如下技术路线输入需求输出结果MATLAB落地难度典型场景轮廓挤出单张二值轮廓图拉伸体/旋转体低logo立体化、零件剖面成型明暗恢复形状(SfS)单张灰度图光照假设连续高度场中表面起伏恢复、浮雕效果多视角重建(SfM/MVS)几十张不同角度照片稀疏/稠密点云高实物数字化、逆向建模深度学习单图重建大量图像-模型训练对体素/点云预测高大规模自动化重建手工建模导入已有CAD模型精确网格-不属于本文范围从表里可以看出轮廓挤出的门槛最低最适合作为入门路线多视角重建能还原真实物体但流程长、坑多深度学习上限高但要先解决数据和训练成本的问题。下面我按这个顺序展开先把最实用的轮廓挤出讲透。2. 最容易落地的一招轮廓挤出法从图片到STL2.1 预处理把一张彩色图处理成干净的二值轮廓轮廓挤出法的前提是拿到一张干净的二值轮廓图。实际项目中我们手里的素材往往是带背景的彩色照片、扫描稿或者设计软件导出的PNG第一步必须把干扰去掉。我常用的预处理流程是这样img imread(profile.png); % 原始图像 gray rgb2gray(img); % 转灰度 bw imbinarize(gray, 0.5); % 固定阈值二值化 bw bwareafilt(bw, 1); % 只保留最大连通域 bw imclose(bw, strel(disk, 5)); % 闭合细小缺口逐行解释每个操作的意图转灰度是为了减少颜色通道对分割的影响让后面的阈值处理只面对一维强度数据。imbinarize是阈值分割。阈值0.5表示灰度超过128的变白否则变黑但实际图像光照不均时固定阈值容易把阴影区域也切进来我通常改用大津法自动选阈值th graythresh(gray); bw imbinarize(gray, th);这样对大多数背景干净的图像效果更稳。bwareafilt(bw, 1)是只保留面积最大的连通区域。这一步能一次性清掉图像角落的小噪点、文字、水印残留。imclose做闭运算作用是把轮廓上断开的细缝补上。为什么要补后面提取边界的时候如果边界有断口生成的三维网格会出现破洞修起来非常麻烦。预处理做完把结果可视化检查一遍imshow(bw)确认轮廓是完整闭合的内部没有多余孔洞。如果轮廓区域内部有镂空设计比如字母O中间的空洞需要用bwboundaries的holes参数配合或者保留内孔边界在生成网格时单独处理内环这里先按下不表。2.2 提取边界从像素点集到可用的多边形二值图里的轮廓其实是一圈像素点直接拿这些像素点做三维网格点数动辄几千上万后续计算慢、网格质量也差。正确的做法是先提取轮廓点集再做抽稀和平滑。B bwboundaries(bw, noholes); xy B{1}; % 边界点行为(Y,X) xy fliplr(xy); % 转成(X,Y)与图像坐标系一致bwboundaries返回的是一个元胞数组每个连通域对应一组边界点坐标。注意它默认输出的顺序是(行,列)也就是(Y,X)和常规的平面坐标轴不一样如果不转换后面生成网格时形状会发生镜像翻转。这里的fliplr就是把两列对调。接下来是抽稀和平滑。抽稀最简单的方法是等间隔采样nTarget 200; % 目标轮廓点数 idx round(linspace(1, size(xy,1), nTarget)); xySampled xy(idx, :);间隔采样简单直接但采样后的折线会有锯齿所以还要加一次平滑。我用的是移动平均滤波器窗口大小取5~15效果比较自然k 7; xySmooth conv2(xySampled, ones(k,1)/k, same);这里conv2只对两列数据在行方向做卷积相当于把相邻点坐标取平均能有效去掉采样造成的折线抖动。但要注意窗口太小平滑效果不明显窗口太大又会把原本的尖角磨圆影响形状还原度。我的经验是轮廓总点数在200左右时窗口取7比较合适。平滑之后把轮廓点从图像像素坐标换算成真实物理坐标。这一步很重要直接关系到后面3D模型的尺寸。换算只需要一个缩放系数scale 0.1; % 每个像素对应0.1毫米 pts xySmooth * scale;2.3 挤出成型三角网格的生成逻辑轮廓点提取好了接下来就是把二维轮廓变成三维网格。最常见的是两种成型方式沿法线方向拉伸挤出和绕某条轴线旋转车削。这里先说挤出成型。挤出成型的思路非常简单把轮廓点放在z0平面上然后整体复制一份放到zheight的位置上下两圈点对应相连侧面就由一排四边形构成每个四边形再切成两个三角形。n size(pts, 1); V [pts, zeros(n,1); % 底层 pts, ones(n,1)*height]; % 顶层 F []; for i 1:n j mod(i, n) 1; % 当前四边形四个顶点i、j、jn、in F(end1, :) [i, j, jn]; % 三角形1 F(end1, :) [i, jn, in]; % 三角形2 end这段代码的核心在于顶点索引的组织方式。因为上下两层是[1..n]和[n1..2n]当前边(i,j)对应的四边形是(i,j,jn,in)沿对角线(i, jn)切开得到两个三角形。所有三角形按顺时针顺序排列侧面网格就闭合了。但这样只有侧壁上下还是空的。要生成一个封闭实体需要补顶面和底面。顶面zheight可以用三角扇以一个中心点为扇心连接一圈轮廓点% 顶面三角扇 centerTop [mean(pts(:,1)), mean(pts(:,2)), height]; V(end1, :) centerTop; % 对每个连续边(i, j)生成三角形(centerTop, i, j)注意顶点顺序要保证法向量朝上底面同理。这里的顶点顺序非常关键三角形顶点的绕序决定了面片法向量的方向法向量朝外是正的朝里会导致渲染时黑面、三维打印切层时出错。判断方法很简单画出网格后如果看到大片黑色反光面就说明有一圈三角形绕序反了把F里该三角形的顶点顺序倒过来即可。补完封口后用trimesh或patch显示trimesh(F, V(:,1), V(:,2), V(:,3)); axis equal;2.4 另一种成型方式绕轴旋转生成回转体挤出成型适合有棱有角的拉伸体但很多实际需求是生成瓶子、罐子、花瓶这类回转体这时候用旋转成型反而更快。核心思路是把二维轮廓当作一条母线绕中心轴旋转若干角度生成一圈顶点再按角度方向连成网格。假设母线用一系列点表示每个点有(r, z)两个分量r是该点到旋转轴的距离z是高度。绕z轴旋转一周分成N份N 60; % 角度切分数量 theta linspace(0, 2*pi, N1); theta theta(1:end-1); % 去掉重复的终点 X rho * cos(theta); % rho是n行1列的半径向量 Y rho * sin(theta); Z zAxis * ones(1, N);这里的rho和zAxis是母线点的坐标X、Y、Z三个矩阵都是n行N列逐点对应旋转后的顶点位置。然后用surf(X,Y,Z)可以显示用surf2patch可以转成网格数据[F, V] surf2patch(X, Y, Z, tri); patch(Faces, F, Vertices, V, FaceColor, [0.8 0.8 0.8]);旋转成型的优点是网格结构非常规整一圈三角形的大小均匀后面做有限元分析或者导入渲染软件都不会出现畸变三角形。缺点也很明显只能做回转体非对称形状无能为力。所以实际使用时要根据目标形状判断该用挤出还是旋转。2.5 导出STL与三维打印前的自查清单网格生成完毕后最终产出通常是一个STL文件。STL是三维打印和大部分CAD软件通用的格式MATLAB导出STL的办法有两种新版本自带相关函数老版本可以从文件交换社区下载现成的stlwrite。如果不想依赖外部文件直接手写一个ASCII STL导出也很容易STL格式本质就是三角形法向量三个顶点坐标的列表fid fopen(output.stl, w); fprintf(fid, solid model\n); for k 1:size(F, 1) v1 V(F(k,1), :); v2 V(F(k,2), :); v3 V(F(k,3), :); n cross(v2 - v1, v3 - v1); n n / norm(n); fprintf(fid, facet normal %f %f %f\n, n); fprintf(fid, outer loop\n); fprintf(fid, vertex %f %f %f\n, v1); fprintf(fid, vertex %f %f %f\n, v2); fprintf(fid, vertex %f %f %f\n, v3); fprintf(fid, endloop\nendfacet\n); end fprintf(fid, endsolid model\n); fclose(fid);这段代码每次写一个三角形都重新计算法向量就不会出现上面说的法线反了的问题。导出后在实际打印之前建议对照下面这份自查表逐项检查检查项问题表现处理方式网格是否封闭网格内部漏光、有缝隙检查轮廓闭合性补封口面片法向量方向渲染时出现黑色面统一三角形顶点绕序为逆时针朝外单位一致性打印尺寸与设计偏差很大确认1个单位1mm还是1cm统一缩放最小壁厚细长结构打印时断裂FDM打印建议设计壁厚不低于0.8mm自相交局部面片互相穿插检查轮廓是否抽稀过度反向交叉点颜色太淡、太淡的颜色无法看到太浓的颜色逐渐变色。3. 连续曲面恢复Shape from Shading方法3.1 单张图为什么能反推高度轮廓法只能还原物体的外边界如果物体表面是连续的曲面比如一块浮雕、一片叶面、一个金属壳轮廓法就失效了。这时需要利用图像中的明暗信息来恢复表面高度这就是Shape from ShadingSfS。SfS的基本假设是物体表面是朗伯体也就是表面把入射光均匀地向各个方向散射图像灰度只由表面法向量和光照方向的夹角决定。数学上可以写成I rho * L · N其中I是像素灰度rho是表面反照率可以理解为表面材质对光的反射能力L是归一化的光照方向向量N是表面法向量。给定图像I和光照方向L每个像素就得到一个关于该点表面法向量的约束方程。而法向量又与表面高度z(x,y)的梯度相关N (-p, -q, 1) / sqrt(p^2 q^2 1)其中p dz/dxq dz/dy把这两个方程联立就得到一个关于z的偏微分方程。SfS的任务就是解这个方程得到每一像素点的高度值。3.2 反射模型与求解思路求解这个方程有很多方式。最直观的是迭代法先给一个初始平面所有z0计算出对应的预测图像把预测图像和真实图像的差当作误差信号反推z的调整量反复迭代直到收敛。简化表示如下z zeros(size(I)); % 初始高度场 lr 0.01; for iter 1:500 [p, q] gradient(z); normFactor sqrt(p.^2 q.^2 1); Nx -p ./ normFactor; Ny -q ./ normFactor; Nz 1 ./ normFactor; I_pred Lx .* Nx Ly .* Ny Lz .* Nz; residual I - I_pred; dZx gradient(residual); % 简化更新实际需做正则化 dZy gradient(residual); z z - lr .* (dZx dZy); end注意这里只是展示求解思路的教学简化版真正可用的SfS代码要复杂得多。实际工程中迭代更新通常要加平滑正则项、做边界条件约束恢复出的高度场还需要去除整体的倾斜趋势否则结果会带一个不自然的坡度。为什么SfS在实际使用中效果不太稳定核心原因是病态性。同一个灰度值可以由无数种表面朝向组合产生光照稍有偏差、材质不是朗伯体、或者有阴影和高光区域结果就会偏离真实形状。所以如果你想用SfS做高精度重建必须严格控制拍摄条件光源方向已知且单一、物体表面接近漫反射、避免镜面高光和硬阴影。3.3 什么时候该用SfS根据我的实际体验SfS最适合两类场景一是表面小起伏的恢复比如硬币浮雕、纸纹、织物褶皱。这些场景的深度变化范围本来就不大即使恢复结果有一定偏差视觉上仍然可观。二是作为深度估计的初始化把SfS结果当作其他重建方法的初值再叠加多视角信息做细化。反过来如果物体有明显遮挡、自阴影严重或者表面是抛光金属、透明材质就不要指望SfS能给出可靠结果。这种情况下建议直接切换到多视角重建路线。4. 多视角重建几十张照片换一个真实模型4.1 三角测量原理两张图如何算出深度多视角重建的基本原理其实不难同一个三维点P从两个不同视角拍摄时分别成像在两个图像的p1、p2位置。如果已知两个相机的位置和朝向从两个相机光心分别穿过p1、p2的射线会在空间中交于P点。这个求两线交点的问题就是经典的三角测量。但这里有个先有鸡还是先有蛋的问题要知道射线的方向需要先知道相机的位置和朝向而这恰恰是未知数。所以整个流程通常分两步特征匹配。先在两幅图像中提取角点、边缘等特征MATLAB里可以用detectSURFFeatures和extractFeatures然后通过matchFeatures找到两组特征点的对应关系。位姿估计。利用两组对应点估计基础矩阵或本质矩阵再分解出相机的相对旋转和平移。这一步在MATLAB里对应estimateFundamentalMatrix之后用estimateWorldCameraPose或者经过本质矩阵分解得到相机姿态。相机姿态估计出来后就可以用triangulate函数把匹配点投成三维坐标生成稀疏点云。后续再用bundleAdjustment做全局优化把累积误差压下去。4.2 MATLAB里的SfM管线MATLAB做SfM我习惯的代码流程大致是imds imageDatastore({view1.jpg, view2.jpg, view3.jpg}); I1 readimage(imds, 1); I2 readimage(imds, 2); pts1 detectSURFFeatures(I1); pts2 detectSURFFeatures(I2); [f1, v1] extractFeatures(I1, pts1); [f2, v2] extractFeatures(I2, pts2); indexPairs matchFeatures(f1, f2); matched1 v1(indexPairs(:,1), :); matched2 v2(indexPairs(:,2), :); [F, mask] estimateFundamentalMatrix(matched1, matched2); % 由F恢复相机姿态三角化生成三维点 % 多帧间逐步注册最后做全局BA优化这里只展示了双视图的基础部分完整的多帧流程还需要涉及新帧的位姿估计、增量式三角化、点云融合和光束法平差。MATLAB的计算机视觉工具箱里都有对应函数但流程的组织要自己写对于没有三维视觉基础的人来说第一次跑通整套SfM流程还是有一定门槛的。如果不想从零搭轮子也可以采用折中方案先用某开源Structure-from-Motion工具跑出相机参数和稀疏点云再用MATLAB加载结果配合triangulate和bundleAdjustment做细化、去噪和稠密重建。我做过几个项目都是这个配合思路省掉了最麻烦的匹配部分又能用MATLAB做后续数据处理效率比全手工流程高一截。4.3 拍摄规范采集质量决定重建质量多视角重建有个很现实的问题算法再强输入的照片质量差输出点云就是垃圾。我踩过多次坑之后总结了下面几条拍摄规范每条都是真金白银的经验使用固定焦距拍摄不要中途变焦或更换相机。焦距变化等于改变了相机内参前面所有位姿估计都会失效。相邻照片之间的相机转动角度控制在15到30度重叠区域尽量保持在70%以上。转得太少特征匹配冗余低转得太多匹配容易出错。避免光照剧烈变化和大面积反光。室外阳光下拍摄高光物体是灾难最好用漫射光或阴天环境。保证画面中有足够的纹理细节。纯白墙、单色物体表面特征点太少算法几乎无法配准。如果物体本身纹理弱可以考虑在表面加不损坏物体的临时纹理。拍摄时要绕物体一圈尽量覆盖多个高度角不要只在一个平面上转圈否则恢复出来的模型顶部和底部会严重缺失。点云生成后通常还需要做滤波去噪、表面重建和网格简化。常用做法是把点云导入网格处理软件或者利用MATLAB的pcdenoise去除离群点再用alphaShape或delaunayTriangulation做三角剖分。5. 深度学习路线单图直接输出体素模型5.1 网络骨架从编码器到体素解码器传统方法虽然各有适用场景但都有一个共同的瓶颈对输入条件要求高要么需要多视角要么需要可控光照。深度学习方法打破了这些限制用大量图像-三维模型训练对让网络直接从单张图像预测完整的三维形状。这个方向我这两年关注比较多它的核心思路是用神经网络逼近二维到三维的映射关系。典型的网络结构分两部分。编码器部分用卷积神经网络提取图像特征输入是一张RGB图经过若干卷积和池化层变成一组高层特征。解码器部分把特征映射成体素网格最常用的输出是一个DxHxW的三维二值体素每个体素表示该位置是否被物体占据用sigmoid输出0到1的概率值。训练损失函数一般用体素级别的二值交叉熵或者用Dice Loss。如果输出体素分辨率是32x32x32就相当于要预测32768个二值变量这对网络容量的需求不小。实际项目里32分辨率能看出物体大致形状64分辨率细节稍好再往上显存消耗和训练时间都会急剧增长。5.2 用MATLAB搭建训练管线MATLAB深度学习工具箱完全能支撑这条路线。搭建一个教学用的面包板级网络可以这样示意如下layers [ imageInputLayer([64 64 3]) convolution2dLayer(3, 32, Padding, same) reluLayer maxPooling2dLayer(2, Stride, 2) convolution2dLayer(3, 64, Padding, same) reluLayer maxPooling2dLayer(2, Stride, 2) fullyConnectedLayer(16*16*16) functionLayer((x) reshape(x, [16 16 16 1])) sigmoidLayer() ]; options trainingOptions(adam, ... InitialLearnRate, 1e-3, ... MaxEpochs, 50, ... MiniBatchSize, 16, ... Plots, training-progress); net trainNetwork(XTrain, YTrain, layers, options);注意这段示意代码的重点在于结构逻辑图像经过卷积特征提取后压缩成一个固定长度向量再reshape成体素网格。sigmoidLayer在部分老版本MATLAB里可能没有现成实现可以自定义一个层或者用functionLayer包一个sigmoid函数。真正做工程项目时不建议直接用这么浅的网络通常会用预训练模型比如残差网络的卷积部分作为编码器只训练解码器部分收敛速度会快得多效果也明显更稳。5.3 数据准备与训练技巧深度学习方法最大的成本在数据。训练需要大量的图像-体素对通常可以从三维模型库获取模型然后在MATLAB里渲染成多视角图像再体素化得到监督标签。体素化的方法有很多可以从STL模型出发计算每个体素网格内是否包含表面三角面片或者用射线法判断体素中心是否在模型内部。训练阶段有几个技巧值得分享。第一是数据增强对输入图像做随机旋转、缩放、颜色抖动能有效提升泛化能力。第二是类别不平衡大多数体素位置都是空的如果直接用二值交叉熵网络容易学会全预测为空必须在损失函数里给正样本更高权重或者用Dice Loss。第三是渐进式分辨率先训练低分辨率网络再用低分辨率结果初始化高分辨率网络能省不少时间。这条路线目前的效果单图恢复复杂形状的精度还比不上多视角重建但在快速生成可用的粗略形状这个需求上已经足够实用。如果做批量自动化处理比如给大量产品图生成预览模型深度学习方法的效率优势非常明显。6. 现场记录常见报错与排查思路6.1 问题速查表这些年的实践里我在每一条路线上都踩过不少坑这里整理成一张排查速查表按现象-原因-解法的格式列出来方便大家直接对照现象可能原因排查与解决二值化后轮廓全是碎点阈值不当或背景有噪点改用graythresh大津法先做高斯滤波再二值化轮廓提取后出现很多小锯齿未做抽稀平滑间隔采样降到200点再做移动平均挤出网格有破洞轮廓线未闭合或端点未首尾相连检查bwboundaries输出确认首尾点重合三维打印预览时面片全黑三角形顶点绕序导致法向量朝内用patch逐面查看反转F中顶点顺序SfS恢复结果整体倾斜光照方向L估计错误改用已知方向光源或手工调整L分量SfM点云分散成一团相机内参估计不准或焦段变化固定相机焦距先做标定深度学习预测体素模糊体素分辨率低或类不平衡提高分辨率加权损失函数STL导入CAD尺寸偏大像素到物理单位换算错误明确scale缩放系数再导出这张表属于保命级参考每次出问题先对着排查一遍能省下大量调试时间。6.2 几条路线的个人体验和建议最后聊点实在的。我个人的建议是如果不是做科研日常项目优先走轮廓挤出和多视角重建这两条线。轮廓挤出5分钟就能出模型适合快速验证多视角重建虽然流程长些但还原真实物体的效果最好是我做产品逆向时主力方案。SfS和深度学习这两条线前者适合特定场景研究后者适合有数据积累的团队单打独斗从零起步成本偏高。另外分享一个我自己用着很顺的工作流先用轮廓挤出生成基础形状再把多视角重建的点云叠加到轮廓网格上做细节修正最后在三维软件里手工调整局部曲面。这套组合兼顾了快和准比单一方法走到底要实用得多。做三维重建这条路没有哪一招是万能的本质上拼的是对输入条件的理解和对输出需求的控制。多看几次结果、多调几次参数比记一百个函数名管用。这篇文章里的方法都是从实际项目里趟出来的照着跑一遍你会对一幅图能还原多少三维信息这件事有更清醒的认识。