做了几年三维视觉相关的深度学习项目,我发现一个规律:凡是涉及表面法线的地方,十有八九会成为整个链路里最折腾的环节。深度估计任务里要用法线约束几何,点云配准里要用法线做特征,渲染模块里要用法线配合光照,可一旦法线算得不准,后面所有模块都会跟着一起遭殃。直接按几何公式算吧,噪声大、边缘糊;交给网络去学吧,方向又老是对不齐,训练时怎么看都觉得这模型没学到东西。今天这篇就把这件事彻底聊透:深度学习场景下计算表面法线到底有哪些方法,每种方法的原理、实现思路、边界条件,以及我在实际项目里反复踩过的关键坑。
1. 法线是什么,为什么所有三维任务都在跟它较劲
1.1 表面法线在三维视觉里的三种角色
表面法线是场景中每个位置垂直于物体表面的单位向量,通俗点说,它描述了那个点“面朝哪个方向”。这个信息在三维视觉里往往不是最终产物,却几乎出现在每个关键环节里,我大致把它分成三种用法。
第一种是作为几何特征输入。点云配准、SLAM、物体识别里经常用法线构建局部特征描述子,法线方向稳定的话,特征匹配会准确很多。第二种是作为监督信号。比如单目深度估计,纯靠深度图监督容易让网络只学会平面结构,加上法线图一起监督,模型对墙角、遮挡边界、表面朝向的敏感度会明显提高。第三种是作为中间表示。像三维重建、重光照、材质估计这些任务,法线图比深度图更直接反映物体表面朝向变化,很多渲染相关的网络模型干脆把法线作为输出接口之一。
换句话说,法线不是给用户看的,而是给算法看的。它质量好不好,直接影响后面的模块是正常运转还是胡搞。
1.2 为什么传统算子满足不了深度学习项目
很多新手问我:深度图都有了,用相邻像素的深度差除以距离不就能求梯度吗,再叉积一下不就是法线?确实可以,这也是经典几何法线计算的基础。但在真实项目里,这套方法远不够用。
深度传感器本身有噪声,随着距离增大误差会急剧放大;物体边缘处深度不连续,梯度算出来就是一个巨大的尖刺;反光表面和黑色物体经常产生深度空洞。这些情况叠加在一起,几何算出的法线图上会布满椒盐状的错误方向。更麻烦的是,室内场景里大量平面结构,比如墙面、地面,几何法线算出来往往很准,可一到桌子腿、椅子扶手这些细结构上就立刻崩坏。
神经网络方法在这个背景下被引入,核心思路很直接:与其靠局部像素差分硬算,不如让网络学习更全局的几何先验。物体边缘处的几何形状是能从周围上下文推断出来的,光滑曲面该朝哪个方向,也能从整体结构里估计出来。这是传统算子做不到的。
1.3 两条技术路线的大致区别
我把目前主流的做法按输入数据分成两类:基于深度图/图像的法线估计,和基于点云的法线估计。深度图方法常见的做法是对像素坐标的深度场求梯度,或者直接用卷积神经网络回归法线图;点云方法则围绕邻域点集做PCA,或者用PointNet这类结构对无序点集进行特征学习。
两条路线在实际项目中并不是互斥的,很多时候是先后使用的关系:先跑一版快速几何结果作为基线,再训练网络模型解决问题,最终在推理阶段把两者融合起来。在展开细节之前,我先把两类方法的核心差异列一张表,方便后面对照。
| 维度 | 几何法线(深度图/点云) | 深度学习法线 |
|---|---|---|
| 计算速度 | 极快,CPU即可实时 | 需要GPU,单帧通常几十毫秒 |
| 对噪声鲁棒性 | 差,局部算子对噪声敏感 | 好,能够学习全局先验 |
| 对边缘处理 | 边缘处容易产生法线突变和错误 | 需要专门设计,容易边缘平滑 |
| 是否需要训练 | 不需要 | 需要大量数据或可靠真值 |
| 泛化能力 | 不依赖训练集,但对传感器噪声敏感 | 依赖训练集的场景分布 |
| 典型应用场景 | 快速预处理、实时SLAM | 离线重建、高精度深度估计、视觉模型 |
2. 深度图上的几何法线:公式、实现和它的死穴
2.1 从针孔相机模型推导法线方向
先讲基础。在针孔相机模型下,深度图里的每个像素点可以还原成三维坐标。设内参为焦距 fx、fy 和主点 cx、cy,像素坐标为 (u, v),深度值为 d,那对应三维点坐标是:
X = (u - cx) * d / fx Y = (v - cy) * d / fy Z = d
如果把这个三维表面看作一个高度场函数 (X, Y) -> Z,那么法线方向可以用梯度推导。对深度函数 Z(X, Y) 求偏导,法线方向正比于 (-∂Z/∂X, -∂Z/∂Y, 1),归一化后就是单位法线。
但在工程实现里,我一般不全用这个简化式,因为它是建立在“高度场”假设上的,对侧面遮挡和深度不连续的地方容易失效。更稳的做法是直接用相邻像素的三维点做差分再叉积:
取当前像素的三维点 p,右侧像素的三维点 pRight,下方像素的三维点 pDown: 边向量 e1 = pRight - p 边向量 e2 = pDown - p 法线方向 n = e1 × e2
这个叉积结果再归一化就是近似法线。该方法的物理意义很直观:两个相邻切向量一叉乘,得到的就是垂直于局部平面的方向向量。
2.2 稳定实现的几个细节
代码实现并不复杂,但有几个细节决定最终质量。以Python为例,把深度图先转成三维点云再差分:
import numpy as np def normals_from_depth(depth, fx=520.0, fy=520.0, cx=319.5, cy=239.5): h, w = depth.shape v, u = np.meshgrid(np.arange(h), np.arange(w), indexing="ij") x = (u - cx) * depth / fx y = (v - cy) * depth / fy z = depth.copy() # 使用中心差分,边界处用前向/后向差分 dx = np.empty_like(x) dy = np.empty_like(x) for arr, axis in ((x, 1), (y, 1), (z, 1)): # 简化写法,对坐标张量沿u方向做差分,同理再做v方向 pass # 实际推荐:把x,y,z看作三个通道,沿图像两个方向求梯度 dXdu, dXdv = np.gradient(x, axis=1), np.gradient(x, axis=0) dYdu, dYdv = np.gradient(y, axis=1), np.gradient(y, axis=0) dZdu, dZdv = np.gradient(z, axis=1), np.gradient(z, axis=0) n_x = dYdu * dZdv - dZdu * dYdv n_y = dZdu * dXdv - dXdu * dZdv n_z = dXdu * dYdv - dYdu * dXdv norm = np.sqrt(n_x**2 + n_y**2 + n_z**2) norm[norm == 0] = 1.0 normals = np.stack([n_x / norm, n_y / norm, n_z / norm], axis=-1) return normals我实际项目中会在两处做额外处理。第一,把深度为0的像素标记为invalid,不能参与梯度计算,否则那一圈的法线全是错的。做法是先把深度图做一次中值滤波或者形态学闭运算,把零值空洞填掉一部分,再计算法线,然后把无效像素位置的法线重新置为0。第二,坐标系问题:不同深度传感器的坐标系y轴方向可能不一致,有些是y向下、有些是y向上,这会导致法线在上下方向整体反转。处理办法很简单,规定统一输出朝相机方向即可,计算完法线之后再翻转一次。
2.3 几何方法的实际精度与失效场景
我必须说实话:纯几何方法在干净数据上好用,可一旦进了真实场景就现原形。拿一个常见的室内深度图来说,正确法线在墙面区域误差可以控制在3度以内,但到了物体边缘、细长结构和传感器噪声明显的远距离区域,误差经常超过30度,甚至出现完全反方向的点。
这就是几何算子的“死穴”——它只相信局部数值,不理解场景结构。就算你把差分改成高阶模板、加上双边滤波,也只能缓解,不能根治。这也是为什么我每次做项目都会先把几何法线跑一版,但它永远只是基线,不是最终方案。
3. 用神经网络回归法线图:网络结构、损失函数和训练翻车点
3.1 网络骨架怎么选
输入深度图、输出法线图,本质是一个密集预测任务,跟语义分割、深度估计这类问题是同一类。网络骨架我一般优先选择编码器-解码器结构,编码器负责提取多尺度几何特征,解码器负责恢复像素级分辨率。具体的骨干网络用ResNet类或者MobileNet类都可以,关键是要保留足够的空间细节。
真正影响法线质量的是跳跃连接和多尺度融合。法线预测非常需要保留高频细节,比如墙角、物体轮廓线、桌子边缘,这些信息在编码器下采样过程中很容易被丢掉。如果没有跳跃连接,输出的法线图会“糊成一片”,边缘细节全部丢失。我倾向采用类似U-Net的拓扑:编码器每个阶段的特征都接到对应解码器阶段,让几何细节能回到最终输出。
如果输入不止深度图,还有RGB图像,那推荐做多任务共享编码器。主干网络同时输出深度图和法线图,两个任务共享底层的几何特征提取器,对法线质量的提升非常明显。原因也好理解:RGB提供纹理和语义线索,深度提供尺度线索,两者互补,共用编码器相当于让法线任务白拿了一份免费的上下文信息。
3.2 输出表示与损失函数选择
网络最后一层输出3通道图,每个像素对应一个三维向量。关键操作是让每个位置的向量归一化到单位长度。常见做法是输出层不做额外的归一化激活,直接在训练损失里用余弦相似度约束;推理时再对输出向量做L2归一化。也有人喜欢在网络最后一层接tanh,把输出压缩到[-1,1]再归一化,但我试下来觉得没必要,tanh会让梯度在前向传播时就缩水,训练反而变慢。
损失函数我重点说三个。
第一个是余弦相似度损失,也是最常用的:
L_cos = 1 - mean( dot(n_pred, n_gt) / (|n_pred| * |n_gt|) )
这个损失只约束方向,不约束向量长度,所以网络不需要浪费能力去学习“把模长输出到1”,收敛更稳定。
第二个是角度误差损失:
L_angle = mean( arccos( clamp(dot, -1+eps, 1-eps) ) )
它直接优化我们关心的指标——角度差,理论上最“诚实”。但问题在于arccos在接近±1时梯度会非常大,训练初期容易震荡,必须把dot值用clamp限制在安全区间,否则半精度训练下特别容易出NaN。
第三个是带权重的组合损失:
L = L_cos + λ * L1(n_pred, n_gt)
加权L1项可以让输出向量更快逼近真值向量,避免只优化方向但模长乱飘。权重λ我一般设在0.1到0.5之间,太大了会让网络偏向“模长正确”而方向反而不够精确。
还有一个容易踩的坑:法线真值的分布并不均匀。场景里平面区域多,网络很容易被大面积墙面“带偏”,把所有区域都预测成平面。解决思路是给边界和曲率大的区域更高的损失权重,做法是预先从深度图计算一个梯度幅度图,梯度大的位置权重×2,这样模型会把更多注意力放在真正难搞的边缘和转折区域。
3.3 数据增广里最容易被忽略的坐标变换
法线图的数据增广跟RGB图像完全不同。RGB图翻转之后颜色值不变,但法线图不一样,它是一个向量场,图像怎么旋转、怎么翻转,向量也必须跟着做相同的坐标变换,否则真值完全错误。
举几个具体例子。图像水平翻转(左右翻转),相当于x轴反向,所以法线的x分量要取负;图像垂直翻转,法线的y分量要取负;图像旋转90度、180度、270度,法线各分量要按照旋转矩阵重新计算;如果做仿射变换,法线必须用旋转部分的变换矩阵作用,不能直接用缩放系数乘。
这个问题的隐蔽性很高。我见过不止一个项目,训练集做随机翻转时直接套用了图像分类那套增广代码,法线真值没有同步变换,模型训练完了看起来loss很低,但可视化输出一塌糊涂,方向全乱的,查了半天才定位到是增广的问题。所以在数据加载阶段,我建议把法线增广单独写一个函数,跟深度图增广严格分开,每做一次几何变换,立刻同步对法线向量做对应变换。
3.4 真值来源与质量
深度学习法线估计需要大量真值,真值质量直接决定模型上限。目前常见的真值来源有三类。
一是用激光雷达扫描的高精度点云渲染法线,再投影回图像平面。这种方法质量高,但需要多传感器标定,成本高。二是用高质量深度图做几何法线计算,再用双边滤波、一致性优化等方式去噪,作为伪真值。这种方法便宜,但边缘处还是会有误差,训练时最好对边缘区域降权。三是用多视角重建得到的网格模型渲染法线图,适合物体级别的小场景,不太适合大范围室外场景。
我现在的习惯是:先用几何法线作为初版真值把模型训练起来,然后拿模型预测结果去做人工抽检,把明显错误的方向找出来反馈修正真值生成流程。这样至少保证管道先跑通,再慢慢提质。
4. 点云里的表面法线:PCA基线、邻域选择和残差学习
4.1 点云为什么不能照搬深度图做法
点云和深度图的最大区别是数据结构。深度图是规则的密集网格,像素之间天然有邻接关系,用卷积就行;点云是无序稀疏的散点集,同一个位置可以有不同的坐标顺序,不能直接套用卷积核。所以在点云里算法线,传统上完全靠几何视角:对一个点找它的邻域点集,然后在这个点集上拟合一个平面,平面的法向量就是我们要的结果。
这种思路的代表就是PCA方法,它简单、稳定、不需要训练,直到今天仍然是点云处理工具链里的标配。不过PCA也有明显的先天不足,比如邻域半径不好选,点数太少的时候协方差矩阵不稳定,法线方向有90度的歧义性等。后面这些坑都值得展开说说。
4.2 PCA法线的完整步骤
PCA估算法线的数学原理是:在点p周围取K个近邻点,计算这些点的协方差矩阵,最小特征值对应的特征向量就是局部平面的法向方向。因为协方差矩阵的3个特征值代表了点集在3个主方向上的方差,平面上的点分布在两个方向上有大方差,在法线方向上几乎没方差,所以最小方差的方向自然就是法线方向。
import numpy as np from scipy.spatial import KDTree def estimate_normals_pca(points, k=20): tree = KDTree(points) normals = np.zeros_like(points) for i, p in enumerate(points): _, idx = tree.query(p, k=k) neighbors = points[idx] centered = neighbors - neighbors.mean(axis=0) cov = centered.T @ centered / k eigvals, eigvecs = np.linalg.eigh(cov) normals[i] = eigvecs[:, 0] # 最小特征值对应法线 # 法线朝向一致性处理命中后再统一处理 return normals这个代码能跑,但工程上我会加几个保护。k值太小法线会跟随噪声抖动,k值太大又会把精细结构抹平。一般密度均匀的点云,k取10到30之间算是比较合理的起点。如果点云密度变化很大,比如近处密远处疏,用固定k不如用固定半径球来搜索邻域,或者同时设置半径和k的上限,以先达到的条件为准。
4.3 法线朝向一致性处理
PCA方法算出的特征向量是协方差矩阵的特征向量,特征向量乘以负号之后还是同一个特征向量,这就意味着每个点的法线方向可能是完全相反的。对后续任务来说这个歧义很致命,比如渲染时一面墙的法线一会儿朝里一会儿朝外,视觉上全是黑的。
标准做法是以视点为参考做方向翻转。假设相机/传感器在点p处观察,视线方向为viewDir = cameraPos - p。如果法线和视线方向的点积小于0,说明法线指向背对相机,把它取反即可:
if dot(n, viewDir) < 0: n = -n
对深度图生成的法线也一样,规定所有法线必须朝向相机,这样网络输入和真值的定义才能统一。另一个容易忽略的点:如果点云经历了一次刚性变换,点坐标变换了,法线向量也必须随旋转变换,平移分量不影响法线,但旋转矩阵必须作用在法线上。
4.4 深度学习怎么在点云上做细化
点云上的深度法线估计,目前主流是走PointNet++这类架构路线。思路是把局部邻域点集经过球查询或KNN分组成区域,每个区域用共享的MLP逐点提取特征,再用最大池化聚合出局部区域描述子,最后预测中心点的法线。这个流程本质上还是“局部拟合”,但网络里学习到的特征比单纯坐标协方差丰富得多,可以把多尺度上下文融合进预测。
我实际项目里比较推荐的不是从零开始端到端预测,而是PCA给初值、网络给残差的混合方案。具体做法是:先用PCA算出一版粗糙法线作为初始结果,然后把局部邻域坐标、初始法线、邻域协方差特征一起拼起来作为网络输入,网络输出一个残差向量,加到初始法线上再归一化。这么做有两个好处:一是网络不需要从零学习基础几何,收敛快;二是训练数据不充足时也能保证初始预测不会太离谱,模型再差也有PCA托底。
5. 评价与调优:别被平均误差骗了
5.1 常用指标
法线预测的评价指标看起来就那几个,但选不好很容易被“假象”迷惑。最常用的是平均角度误差,也叫Mean Angle Error,定义是每个像素预测法线与真值法线的夹角做平均。这个指标对少数极端错误非常敏感,一个边缘像素偏差80度,可能把大量像素的优异表现都掩盖掉。
所以我每次都会同时看三个数:平均角度误差、中值角度误差、阈值精度。中值角度误差能反映整体典型水平,对离群点不敏感;阈值精度则是计算角度误差小于某角度阈值的像素占比,常用5度、11.25度、22.5度这几档,对应到实际渲染中分别代表“几乎全部对”“大部分可用”“整体方向没跑偏”。只看平均误差会被纯平面场景表现好而细节糟糕的模型骗过去,必须配合中值和阈值一起看。
我举一组自己在某室内场景测试数据上的参考值:传统几何方法平均角度误差大约28度,中值约13度;加了深度学习的单任务模型平均误差降到10度左右,中值约5度;多任务共享编码器的模型平均误差进一步降到8度以下,中值不到4度。边缘区域的阈值精度在单任务模型里明显偏低,多任务模型会好不少。这组数据不是权威基准,只代表我自己的实验规律,趋势是普遍存在的:深度学习大幅改善整体误差,但边缘区域始终是难点。
5.2 可视化检查
指标再漂亮,也不如直接看法线图来得直观。标准可视化方式是用法线向量映射到RGB:n_x、n_y、n_z的取值范围是[-1,1],先映射到[0,1]再作为RGB通道显示。这样一来,朝右的面显红色,朝上的面显绿色,朝前的面显蓝色。一眼看过去,颜色平滑的区域说明法线连续,颜色跳变的位置要么是真实边缘,要么是计算错误。
我每次训练过程中都会定期存几张可视化图,跟真值法线图并排看。有一个规律很典型:模型预测的法线图整体色调跟真值接近,但细枝末节处明显“糊”,墙根和物体轮廓处颜色过渡不锐利。这就是跳跃连接不够或者边界损失权重偏低的信号。遇到这种情况,我一般优先增加边缘权重,其次再看网络结构问题。
5.3 五大工程坑
第一,方向系统不统一。深度图坐标系、点云坐标系、渲染坐标系各自定义不同,有的y轴朝下,有的z轴朝向屏幕外,不同来源的法线真值混在一起训练,模型会无所适从。我建议在数据进入管道的第一时间就统一到同一套坐标系,用可视化和统计直方图验证后再进训练。
第二,半精度训练的NaN问题。arccos在角度接近0时梯度极大,加上半精度计算容易溢出,训练初期概率性出NaN。对策是损失函数里对余弦值做clamp,角度损失仅在训练中后期启用,或者干脆用余弦损失替代arccos损失。
第三,法线预测被大平面带偏。场景里大面积墙面地面占比高,模型严重倾向于输出“平面法线”,对曲面和复杂结构处理不足。解决方式是样本均衡采样,或者按深度梯度做损失加权。
第四,边缘被过度平滑。常规做法的问题在于边缘像素的邻域跨越了两个深度不连续的表面,网络只能给出一个“平均法线”,结果边缘出现粉红色或紫色过渡带。加入深度梯度权重、边缘感知模块或后处理的CRF精修都能缓解。
第五,推理时的归一化统计漂移。训练时用BatchNorm,推理时如果换到不同传感器、不同分布的数据,法线输出可能整体偏移。对策是推理阶段冻结BN或改用GroupNorm,尤其是小batch训练时,GroupNorm比BN稳定得多。
结尾
关于表面法线的计算,我最后想分享一点个人体会:任何一个法线相关任务,都不要跳过几何基线直接上网络。先用PCA或深度差分把几何法线跑出来,即便它不完美,也能帮你快速暴露传感器噪声、坐标系混乱、边缘处理策略这些底层问题。把这些问题处理干净之后,再让网络模型在这个稳定的管道里做提升,通常是最省力的路径。法线估计本质上是在跟传感器的物理噪声博弈,几何方法和深度学习方法之间从来不是二选一,把两者组装好才是项目里真正拉开差距的地方。