做三维视觉的同学应该都有过类似的经历:模型跑通了,点云也建出来了,下游拿结果去配准、去渲染,效果却怎么都不对。排查到最后,锅往往不在主流程,而在最不起眼的表面法线计算上。法线这个东西,看着只是每个点上的一个三维单位向量,但它几乎是所有几何算法的地基。做过一段时间深度学习法线估计之后,我的感受是:这个问题比表面看起来深得多,而且训练里的坑比想象中多得多。
这篇文章就把我在实际项目里踩过的坑、验证过的方案、总结出的套路一次说清楚。范围从传统思路讲到深度学习,重点放在后者,内容包括任务定义、网络设计、损失函数、训练细节、后处理和故障排查。适合刚入门三维视觉,想做点云或深度图法线估计的研究者,也适合在工业场景里被法线问题折腾到怀疑人生的工程同学。
1. 先别急着训模型,法线这个问题值得拆开看
1.1 表面法线到底是什么,为什么深度学习非要来插一脚
表面法线可以理解成曲面上某个点处的“朝向”,数学上它是曲面切平面的垂直方向。点云里没有显式的曲面,法线只能从邻域信息里估计出来。传统做法通常是在每个点周围取一堆近邻点,拟合一个局部平面,然后把这个平面的法向量当作该点的法线。听起来很简单,但实际做起来问题很多:邻域选多大、点云密不密、噪声多不多、边缘锐不锐,全都直接影响结果。尤其是在深度图或者稀疏点云上,传统方法经常把细节抹掉,或者把尖锐边缘算得圆乎乎的。
深度学习介入的方式完全不同。它不再单独看每个点的局部邻域,而是把一整片深度图或点云结构喂给网络,让网络从大量数据里学会“什么样的几何结构对应什么样的法线”。这个视角的转变很关键。因为它让模型能用上全局上下文,比如远处的结构、物体与背景的边界,甚至物体之间的遮挡关系。这些信息是局部PCA永远拿不到的。
我见过不少同学一开始轻视这个问题,觉得反正用现成库算个法线就行。但等你把预测的法线送到配准、重建、渲染流程里,才会发现一个小角度的偏差就能让效果崩掉。深度学习方法虽然训练麻烦,但一旦训好,速度和鲁棒性都更稳定,尤其适合处理有噪声、有遮挡的真实传感器数据。
1.2 传统方法到底卡在哪里
传统法线估计的“扛把子”是PCA,也叫局部最小二乘平面拟合。思路是取每个点的k个近邻,构建协方差矩阵,然后做特征值分解。最小特征值对应的特征向量就是拟合平面的法线方向。这个方法胜在简单、快、可解释。但它的短板也极其明显。
第一个问题是邻域尺度。k太小,噪声会把方向带偏;k太大,局部曲面被当成平面,细节丢光。实际场景里点云密度变化很大,近处密密麻麻,远处稀稀拉拉,用一个固定k根本不可能照顾全局。第二是几何退化。在平坦区域,特征值分布还算正常,但遇到锐利边、棱角、细长结构,最小特征值和其他特征值相差不大,选哪个方向都不稳定。第三是朝向歧义。PCA只给出一个轴,不区分正反方向。同一个平面上的两个点,可能一个法线朝里,一个朝外,后处理还要靠视点方向做翻转统一,这一步在遮挡和自交区域也经常出错。
这些痛点本质上不是“算法不够好”,而是“信息不足”。传统方法只依赖局部点坐标,面对噪声和采样不均时没有先验可依。深度学习的优势恰恰在于它把“几何先验”存进了网络权重里。同一个场景,深度学习模型能根据整体形状推断出哪里是平面、哪里是棱边,甚至能补全被噪声破坏的区域。
1.3 深度学习真正解决的痛是什么
用一个我实际遇到的例子说明:某次拿结构光扫一个半透明材质的物体,深度图边缘疯狂跳变,点云上一圈全是毛刺。传统PCA算出来的法线在毛刺位置随机乱指,下游做渲染时光影完全没法看。换上训练过的法线估计网络之后,虽然边缘区域也有误差,但整体方向连续,稍微做个后处理就能直接用。这就是深度模型“知道什么是合理的平面”的体现。
另外一点是速度。传统方法对百万点云做PCA,每点还要查找近邻,慢的时候一帧要几百毫秒。深度学习模型在GPU上一次前向就能输出整张法线图,配合TensorRT之类的加速,几十毫秒就能跑完。对实时三维重建、机器人抓取这类应用来说,这个差距是决定性的。所以不要觉得深度学习是“杀鸡用牛刀”,在法线这件事上,它是性能和效果的双重提升。
2. 定义清楚任务:输入、真值和评价指标
2.1 输入表示:深度图、点云、网格怎么选
法线估计任务首先得决定输入是什么。同一个物体可以表示为深度图、点云、三角网格,不同表示对网络设计影响很大。我见过不少人在这上面纠结很久,其实核心原则是:输入表示应该和最终使用场景一致。
深度图是最方便的表示。它是一个单通道图像,可以用成熟的2D卷积网络来做,计算效率高。而且深度图自带规则的像素邻域关系,法线预测天生就不容易乱。缺点是它只覆盖一个视角,有遮挡,而且深度边界处是巨大的跳变。
点云表示更通用,不依赖相机视角。但点云是无序的、密度不均匀的,网络设计麻烦很多。需要用到点云特征提取的结构,通常还要额外处理近邻搜索,训练和推理都更贵。网格表示则多用在已经有重建模型的场景,深度学习直接作用在网格上的方案相对少,一般还是先采样成点云或渲染成深度图再预测。
我自己的经验是:如果数据来源是RGB-D相机,直接预测深度图法线最省事;如果产品里只有点云,那就老老实实做点云方案,不要强行把点云栅格化成深度图去套2D网络,栅格化会丢失很多几何信息。
2.2 法线真值怎么来
训练必须要有真值,这是很多入门同学卡住的地方。公共数据集里的法线真值并不是天上掉下来的,生成方式主要分三种:从CAD模型或高精度扫描网格上重采样;用多视角重建结果近似;在合成数据里直接由渲染管线生成。
首选的方案是“网格重采样法线”。思路是先准备一个质量还不错的三角网格模型,然后在每个采样点上计算网格面片的法线和插值,再把它分配到对应的像素或点云坐标上。这个方法精度高,但有个坑:网格法线朝向必须一致,不能出现同一个平面上一半朝里一半朝外的情况。我处理过不少第三方网格,里面经常有翻转面,导致训练集的真值本身就矛盾,模型学什么都不对。
另一种常见的真值获取方式是“多视角深度图融合”。把不同视角的深度图配准对齐之后,融合成一个稠密点云或者SDF场,再从里面重新计算每个像素位置的法线。这种方式适合真实场景,但要注意多帧之间的配准误差,真值在深度不连续的位置往往很脏。如果发现训练loss一直卡在一个较高值,多半是真值出问题了。
合成数据也是个很好的来源。用渲染器生成带深度的彩色图,同时直接导出法线图,这样真值绝对干净,还能自由控制相机角度、光照、物体种类。缺点是合成数据和真实传感器数据之间存在domain gap,模型训出来在真实数据上要微调。我在做早期验证时通常先用合成数据跑通流程,再混入真实数据精调。
2.3 评价指标不搞清楚等于白训
很多同学训练法线模型时只看训练集loss,不够。法线是方向量,不能简单用像素差来感知好坏。常用的指标有两个:平均角度误差和阈值准确率。
平均角度误差(Mean Angle Error)定义是每个像素/点上预测法线与真值法线之间的夹角θ,对所有点取平均。角度越小越好。这个指标直观,但容易被少量大误差点拉高,所以要结合阈值准确率看。阈值准确率统计的是“角度误差小于某个阈值”的点占所有点的比例,常见阈值有5度、11.25度、30度。比如记成P5、P11.25、P30。如果P11.25达到90%以上,说明大部分点法线都在合理范围内;反之如果MAE不大,但P5很低,说明虽然整体误差尚可,但细节区域仍然不行。
实务中建议同时追踪这两个指标,并且分开看不同区域:平面区域、边缘区域、远离传感器的区域。如果不分区域统计,模型很可能牺牲边缘来保平面,MAE看着不错,但下游对边缘敏感的任务就崩了。
3. 网络设计与损失函数:从“暴力回归三通道”到“几何约束”
3.1 骨干网络:先选骨架,再想head
法线估计网络通常由骨干网络和预测头组成。骨干网络负责提取几何特征,预测头负责输出法线。
对于深度图输入,我推荐从带残差连接的编码器-解码器结构开始。编码器逐步下采样,提取多尺度特征;解码器逐步上采样,恢复细节。中间用跳跃连接把同尺度的编码器特征拼过来,这样可以保留浅层几何细节。我自己常用一个简单的结构:输入单通道深度图,经过四次下采样,到1/32分辨率,再由四次上采样恢复到原图尺寸。最后在1/2、1/4、1/8、1/16多个尺度各输出一份法线预测,让模型在多个尺度上同时被监督。这个技巧对提升边缘表现特别有帮助。
对于点云输入,结构更复杂。通常做法是先选一些种子点,在每个种子点的邻域里提取特征,然后经过若干层特征传播,最后对每个点输出法线。关键点是特征提取时要同时用坐标和特征,而不是只处理坐标。很多入门实现把点云变成体素再用3D卷积,这个方案在数据稀疏时浪费大量算力,不如直接用基于点的方法。
一个容易被忽略的点是:网络输出之前要过归一化层。法线是单位向量,长度必须为1。常见做法是在最后一层用tanh或线性激活,输出3个通道,然后做L2归一化。如果不归一化直接回归,模型需要自己学尺度大小,收敛会更慢,预测结果也不稳定。
3.2 输出层与归一化:向量归一化别短路
输出层很多人不重视,实际上它决定了模型的优化目标。假设网络最后输出三个特征图,分别对应法线的x、y、z分量。此时这三个图的值范围没有约束,直接算损失会让模型陷入两难:不仅要学方向,还要学长度。法线的长度是恒为1的,但优化器不知道这个约束,于是可能会输出模长在0.8或1.2之间波动,角度误差变小了但模长也偏差了。
正确的做法是在输出层之后立即做一次归一化,把三通道向量投影到单位球面上。公式很简单:先算模长,再把每个分量除以模长。要注意分母为0的情况,工程上一般加一个很小的epsilon,比如1e-6。最好在损失计算之前和推理阶段都用同一套归一化逻辑,避免训练和推理行为不一致。
还有一种做法是让网络输出一个未归一化的向量,损失函数里强制使用方向损失,例如只算cosine距离。这样模型虽然没显式归一化,但优化目标逼着它输出方向。实测下来,显式归一化更容易训练,建议默认采用。
3.3 损失函数选择是法线估计的灵魂
损失函数决定了模型能学到什么样的法线。最简单的损失是L2损失,直接计算预测向量和真值向量各分量的平方差。这种方式计算简单,但它惩罚的是向量坐标差异,不是角度差异。当两个向量都接近单位向量时,角度越小,L2损失近似等于2(1−cosθ),尚可。但如果归一化不够好,L2损失会被模长差异主导,模型反而忽略角度。
更常用的是角度相关的损失。一个经典实现是余弦损失,定义为1减去两个单位向量点积的平均值。这个损失最小化的方向就是让预测和真值点积趋近于1,也就是夹角趋近0。它的好处是只关心方向,不关心长度,而且对角度惩罚基本是单调的。不过它在大角度误差区域的梯度相对平缓,刚开始训练时可能收敛慢一点。
我自己的经验是:先用L2损失预训练几个epoch,让网络先把粗糙法线方向学出来,然后切换到角度损失,或者直接把两个损失加权相加。常用组合是:
- 主损失:角度损失或余弦损失,权重1.0
- 辅助损失:多尺度输出上的L1损失,让每个尺度都监督
- 惩罚项:法线在图像域上的平滑项,权重0.1左右,防止单点预测跳变
平滑项可以这样加:在预测法线图上计算相邻像素法线之间的角度差,然后把差值的绝对值累加作为正则项。这个项对平面区域很有用,但它也会削弱边缘处的预测,所以权重不能太大。如果场景里有很多尖锐边界,我会把这个平滑项下到0.05以下,或者干脆只在平面区域监督。
3.4 多尺度一致与边缘感知的改进线
法线估计里有一组常见矛盾:大尺度结构要平滑,小尺度细节要保留。想同时满足,就需要多尺度一致性约束。我在网络里加了三个尺度的法线输出,让每个尺度都对齐到原图分辨率,然后分别计算损失。低尺度输出负责整体结构,高尺度输出负责细节。不同尺度的特征自然融合之后,模型会比单尺度输出更稳定。
还有一个提升显著的技巧:边缘感知损失。深度图里有深度不连续的地方,法线通常也在那里发生剧烈变化。如果损失函数不区分边缘和平面,模型会倾向于把边缘抹平以降低平均误差。解决方法是预先计算一张边缘权重图,比如用深度梯度的绝对值来生成权重,在损失计算时边缘点加大权重。实际操作时,我一般设置权重为1 + 若干倍深度梯度,梯度大的地方权重更大,这样模型会花更多精力把边缘角度学准。
当然,边缘感知不能过度。因为边缘区域真值本身就可能带噪声,权重太大会让模型去拟合噪声。建议先用固定权重0.5实验一轮,观察边缘P11.25是否提升、平面区域是否恶化,再做调整。
4. 训练细节、工程化与推理后处理
4.1 数据增强不能随便做
法线估计的数据增强和其他图像任务不一样,它必须保持“几何一致”。比如对深度图做随机旋转时,法线真值也必须跟着旋转同一个角度;做水平翻转时,法线的x分量要变号。很多人忘了这一点,导致真值和输入对不上,模型怎么训都不收敛。
我做数据增强时的标配是:
- 随机旋转:角度范围正负10度,旋转时同时变换输入和真值
- 随机缩放:0.8到1.2倍,深度值随之缩放,但法线不需要缩放(因为法线是单位向量)
- 随机裁剪:从原图随机裁出正方形区域,再缩放到固定分辨率
- 深度噪声:给输入的深度图加高斯噪声,模拟传感器不理想特性,噪声标准差几个毫米,量级视深度单位而定
小心不要对法线图做颜色类增强,比如亮度、对比度调整。法线是几何方向,不是颜色亮度,做了这类增强只会引入无意义的高频噪声。另外,如果用了多视角数据,还要注意不要随机翻转左右眼,否则遮挡关系会乱。
4.2 边界样本、深度噪声与类别不平衡
真实深度图边缘往往有很多无效区域,也就是深度值为0或NaN的像素。如果不处理,网络会在这些位置学到错误模式。我的做法是生成一个有效像素掩码,在输入深度图时将无效像素置为0,并多输入一个“有效性通道”;在损失计算时只统计有效像素。这样模型能学到“边界外是未知区域”,而不是硬预测一个法线。
深度噪声对法线影响非常大。传感器在边缘处产生的“飞点”,也就是深度突变点,会导致局部邻域出现假平面,法线预测得乱七八糟。除了在输入端加一点点滤波,更重要的是数据加载时剔除飞点。我常用中值滤波对深度图做一次预处理,窗口3×3,可以显著降低飞点,同时不太影响细节。
类别不平衡在法线估计里不像分割那么明显,但平面区域和边缘区域的比例确实悬殊。解决方式是前面提过的边缘感知损失,或者在采样时让边缘点被采样的概率更高。如果训练数据里全是墙面、地面这类平面,模型会对曲面物体很无从下手。所以数据采集时要有意识地加入弯曲物体和边缘结构,不要偷懒扫一屋子平面就开训。
4.3 推理后处理:让法线进入下游前再补一刀
哪怕模型训得很好,直接输出的法线图通常也还带一点高频噪声。下游配准或渲染之前,我习惯做简单的后处理。最常用的是对法线图做一个双边滤波,在保持法线方向跳变的同时平滑平面区域。也可以先把它求成角度图,滤波,再还原成向量。
如果预测的是点云法线,没有规则网格,可以用k近邻法线平均:对每个点取它的k个近邻预测法线,做加权平均,再归一化。权重可以用距离倒数,或者根据法线夹角给一个阈值,夹角过大的邻居不参与平均,防止边缘过平滑。这个操作比较安全,通常能提升MAE一到两度。
还有一个经常被忽略的问题是法线朝向。深度学习方法通常会学出相对于相机坐标系的可重复朝向,但如果下游任务需要法线指向外部,需要根据视点方向做一次朝向翻转。做法很简单:点乘视点方向与法线方向,如果结果为负,就把法线取反。这一步放在后处理最后,别在前面做了,还得再翻回来。
5. 常见问题与排查实录
5.1 预测法线整体光滑但细节糊掉
这个问题几乎人人都遇到。最常见的成因是损失函数里平滑项权重太大,或者训练数据本身缺少细节边缘。先检查后处理里的平均邻域是否过大,如果后处理把细节抹掉,那先别怪模型,把平均邻域缩小一点再看。
如果确实是模型问题,试着降低平滑项权重,同时加大对边缘区域的损失权重。我用过一个非常有效的方法:单独训练一个只有边缘区域参与损失计算的副本模型,看它能否预测出锐利边缘。如果这个副本模型也糊,说明网络容量或者训练数据里边缘样本不够;如果副本模型能锐利预测,说明主模型被平面区域带偏了。
5.2 边缘双线或法线噪声大
有时你会看到预测法线在边缘处出现“双线”,也就是边缘内侧和外侧各有一条响应,中间隔着暗缝。这往往是因为真值在边缘处的标签不干净,或者损失函数对边缘处的微小位置偏移过度敏感。排查时先把真值可视化,如果真值本来就在边缘有错位,那问题在数据管线,不在网络。如果真值干净,再看输入深度图边缘是否被做了平滑处理,深度图边缘模糊也会让法线预测变成双线。
另一种情况是法线噪声密集,训练loss明明不高,但预测图上到处是琐碎抖动。这类情况多数来自输入深度图的量化噪声,或者深度相机标定不准。试着对输入深度图做轻微高斯滤波,同时检查数据增强里的噪声强度,噪声加得太猛也会让模型学到抖动。
5.3 训练loss降不下去,精度不如传统方法
这可能是很多入门者最沮丧的一刻。我自己也遇到过,训了好几轮,P11.25还不如直接对点云做PCA。这时先别急着调网络,把真值可视化,逐像素检查是否出现了以下问题:
- 真值法线朝向不统一,一半朝里一半朝外
- 真值存在时间戳错位,与输入深度不是同一帧
- 边缘区域的点采样到了背景上,该算物体的却算成墙面
- 输入深度图与真值分辨率不一致,没有正确对齐
其中最常见的是朝向不统一。一旦发现真值里有随机翻转的点,训练基本没救,必须回到生成真值的环节重新修复。用一个DSO工具或者渲染器导出法线时,要显式规范好朝向规则。
如果真值没问题,再检查损失计算。我见过一个案例,有人把法线预测输出和真值的通道顺序搞反,模型训练很久都无法收敛。正确做法是先在代码里打印出一批预测和真值,手动比较几个点的数值,确认坐标轴对应正确。
5.4 算力受限时的工程技巧
训练法线模型很吃显存,尤其是用深度图全分辨率输出。如果不方便用大卡,可以从这几个地方入手:
- 混合精度训练,显存占用直接砍半,速度还能提升
- 使用更小的输入分辨率,例如从512×512降到384×384,精度的损失一般不大
- 损失函数里只在多个尺度上监督,但在主输出尺度上避免同时监督太多项
- 使用模型推理时量化,把输出法线从float32转成float16或int8,下游再用CPU转回float32
工程上还有一个建议:把真值法线预处理成“正单位球半球分量”格式,也就是把原本的三通道向量存储为两个角度值,推理时再转回向量。这个技巧能省一部分存储,但会增加解码复杂度,除非存储压力极大,否则我一般不做,还是保持三通道简单直接。
6. 一点个人经验
最后聊点自己的习惯。我现在做新项目时,不会一上来就用最复杂的网络,而是先用手头数据跑一个最基础的编码器-解码器基准,配一个简单的角度损失,把数据管线走通,再迭代网络结构。很多人觉得基准模型太土,结果在数据对齐问题上浪费大量时间。其实法线估计这个任务,数据质量对结果的影响远超模型结构,一套干净的训练管线比堆算力更重要。
另外一个很有用的技巧是:在预测法线进入下游之前,先做一轮“法线滤波一致性检查”。具体做法是每隔一段时间就把预测法线用可视化工具渲染出来,观察表面光照是否连续。不用盯着数值指标,人眼对法线不平滑其实很敏感,能帮你快速发现那些指标没暴露出来的问题。
如果你正准备入手法线估计,我的建议是从深度图输入、多尺度监督、边缘感知损失这三个点起步。这个组合简单、好复现,在很多场景下已经能超过传统PCA,而且框架搭好之后,扩展到点云、网格只是换输入头的事。希望这篇文章能帮你少走一些弯路,也欢迎多分享实际项目里的坑,这问题看着小,深挖起来是真的有意思。