☰
SALD符号无关学习:无内外标签点云的隐式SDF重建
2026/10/29 15:46:29 网站建设 项目流程

做3D扫描重建的朋友大概都遇到过这种局面:手里只有一堆从设备导出的散乱点,没有内外标签,没有可靠的朝向,法向估计出来还一半朝里一半朝外。想训一个隐式神经表示去拟合形状,传统的SDF监督要求每个采样点都得有一个明确的有符号距离值,这个前提在实际数据上几乎不可能满足。这篇《SALD: SIGN AGNOSTIC LEARNING WITH DERIVATIVES》盯的就是这个疙瘩:它把监督信号从"距离值的正负"挪到了"函数在表面点处的梯度方向"上,只用原始点云和一堆朝向混乱的法向,就能学出一个零水平集正确的隐式场。关键词里的符号无关学习、隐式神经表示、有符号距离场、Eikonal约束、法向一致性,全部在这套框架里串起来了。这篇拆解适合两类人看:一类是刚接触隐式表示、想知道这套损失函数为什么这么写的入门者;另一类是已经在跑SDF重建、被法向定向和内外标注折磨过的实践者。我会按论文的思路走一遍,再把我自己在复现时踩过的坑、调过的参数、改过的实现细节摊开讲。

1. 这篇论文到底在解决什么痛点

1.1 传统SDF监督的三个昂贵前提

在讲这篇论文之前,得先把传统路线为什么要价这么高说清楚。用隐式神经表示去学一个有符号距离场,标准的监督形式是:在空间里撒点,每个点给出它到表面的距离,同时给出符号——点在物体内部是负的,外部是正的。这个设定在合成数据上很舒服,因为解析形状可以直接算出精确的SDF值,随便撒多少点都有标签。

可一旦换到真实扫描数据,三个前提同时崩掉。第一,扫描设备给你的是表面上的点,它本身不知道"内部"在哪,符号无从谈起。第二,要构造空间中的距离标签,需要先有一个干净的水密网格去做内外测试,而原始扫描往往是非水密的、有孔洞的、甚至带扫描噪声的。第三,即便你退一步,只要求表面点满足 f(x)=0,那也只是一个零水平集约束,它管不住函数在表面两侧怎么长——网络完全可以把整个空间都压成接近零,零水平集退化成一团糊。

这三个前提里,最要命的是符号。符号本质上是一个全局定向信息,它要求你对每个点判断"在里还是在外",而在开放曲面、薄壳结构、自相交模型上,"里"和"外"本身就是个拓扑上说不清的概念。论文的出发点就是把这层依赖彻底剥掉。

1.2 SAL的妥协与SALD补上的那一刀

在这篇之前,同一个研究方向上有过一版SAL(Sign Agnostic Learning),思路是:既然符号拿不到,那就只约束零水平集,再加一个Eikonal正则项,强制梯度的模长处处为1。Eikonal项的作用是把解从"任意过零点的函数"收窄到"距离函数"这一类——因为满足 |∇f|=1 的函数,其零水平集附近的等值面间距是均匀的,这就间接把形状的几何撑起来了。

但SAL有个明显短板:它只用到了点的位置,没用点的法向。法向信息在原始点云里其实是免费的——局部邻域做一个PCA就能估出来,即便朝向乱七八糟,那个"法向所在的直线"是准的。SALD做的事情,就是把这条直线信息以符号无关的方式塞进损失函数里。

具体说,对于一个真正的SDF,在表面点 x 处,梯度 ∇f(x) 必然等于该点的单位外法向。注意这里是"等于",不是"平行于",因为SDF的梯度模长恒为1,方向就是外法向。而在符号未知的设定下,我们只知道法向所在的直线,不知道箭头朝哪。于是约束就退化成:∇f(x) 必须与估计法向 n(x) 共线。共线这件事,用向量表达就是"∇f 在垂直于 n 的方向上的分量为零",这一步是整个方法的核心,因为它对 n 整体取反完全不变。

1.3 谁适合读这篇、需要什么前置

如果你手上只有点云、想快速拿到一个能用于渲染或碰撞检测的隐式形状,这篇的方法是可以直接抄的。如果你做的是逆向工程、医学影像重建、文物数字化这类"原始数据没有内外标注"的场景,它的价值更明显。前置知识不用太多:知道SDF是什么、知道MLP可以拟合连续函数、会用PyTorch的自动求导,基本就能跟上。真正需要提前建立的直觉是——梯度和法向是同一件事的两种说法,这个换算关系后面会反复用到。

提示:读这篇论文时不要一上来就啃公式,先把"符号无关"这四个字在心里过一遍:所有损失项都必须对 f 取反保持不变。任何一项不满足这个性质,整套推导就塌了。

2. 核心思路拆解:符号无关是怎么成立的

2.1 把约束从"值"搬到"导数"

传统监督是直接管函数值:f(x) = d,d 带符号。SALD管的是函数在表面点处的导数行为,函数值只管到"等于零"这一层。这个转移看起来是把信息变弱了,实际上是换了一个信息更稳的载体。

打个比方:让你描述一个房间的墙,一种方式是告诉你"往东走3米是墙,往西走5米是墙",这需要你先知道自己在房间里还是房间外;另一种方式是告诉你"站在墙边时,垂直于墙面往外指的箭头是哪个方向",这个描述不依赖你站在哪一侧,箭头本身的方向信息就够了。SALD走的是第二条路,而法向估计恰好提供的就是"垂直于墙面"这条线。

所以损失函数的结构变成三层:表面点约束 f=0,空间中任意点约束 |∇f|=1,表面点再约束 ∇f 与 n 共线。三层里没有任何一层涉及 f 的正负号,这就是"sign agnostic"的字面含义。

2.2 损失函数四项构成与各自的直觉

把论文里的损失拆开看,大致是这么几块,我用偏实践的语言重写一遍:

  • 表面项:对采样自点云的点 x,惩罚 |f(x)|。用绝对值而不是平方,是为了让梯度更稳,平方项在 f 接近零时会给出很小的梯度,收敛偏慢。
  • Eikonal项:对空间中的采样点,惩罚 (|∇f(x)| - 1)²。这一项保证 f 在全局尺度上是一个距离函数,而不是一个形状任意、只在表面附近归零的函数。
  • 法向对齐项:对表面点,惩罚 ∇f 在法向垂直平面上的投影能量,也就是 ‖∇f - (∇f·n)n‖²。等价写法是 |∇f × n|² 或者 1 - (∇f·n)²/|∇f|²。写法不同,数值行为差别不小,后面细说。
  • 可选的正则项:对网络参数或者对 f 的某种平滑性做约束,论文里不是主角,复现时可以省。

这四项里,第一项管"位置对",第二项管"尺度对",第三项管"方向对"。三个约束合的合并起来,唯一的不确定性就只剩一个全局的符号翻转——f 和 -f 同时是解,因为翻转后梯度也整体取反,法向共线约束照样满足。

2.3 对称性:f 与 -f 是同一个解的两种写法

这是整篇论文最漂亮的地方,也是最容易被读者忽略的一层。损失函数在变换 f → -f 下逐项不变:表面项的绝对值不变,Eikonal项的模长不变,法向共线项的投影能量不变。这意味着优化问题的解集天然包含 {f, -f} 这一对。

有人会担心:那训练出来到底是哪个?答案是训练过程本身不区分,它会随机落进其中一个。但关键在于,这两个解的零水平集是完全相同的。你做Marching Cubes提取等值面,提取的是 f=0 的面,取反不影响。所以对于"我只想要形状"这个目标,符号不确定根本不构成问题。

真正需要符号的场合是判断内外,比如做体积计算、做碰撞查询的inside测试。这种情况就得在训练完之后补一步:用射线投射数交点奇偶,或者取一个远离表面的参考点比较 f 值,或者在点云上做一次基于连通性的内外判定。这一步是工程补丁,论文里没展开,但实际落地绕不开。

2.4 为什么用导数比用符号更"便宜"

从信息论的角度看,给一个点标内外,等于给了一个比特的全局信息,但这个比特的获取成本极高,需要水密性和拓扑正确性。而法向提供的信息是局部的、逐点的,PCA就能估,噪声大一点也只是方向抖动,不会像符号错误那样把整个形状翻过来。

更实际的一点是,方向约束的梯度质量更好。SAL只用零水平集约束时,网络在表面附近容易学出一个"扁"的函数,梯度模长忽大忽小;加上法向对齐后,梯度方向被钉住,收敛速度和细节保留都明显改善。我在薄壁件和细长结构上对比过,只加Eikonal的版本容易把小凸起抹平,加了法向项之后边缘能立起来。

3. 损失函数与关键参数逐个拆解

3.1 表面项:绝对值还是平方,差别在哪

表面项写成 |f(x)| 还是 f(x)²,看起来只是范数选择,实测差别不小。平方项在 f 接近0时梯度也接近0,训练后期会变得很"懒",尤其是在表面点采样密度不均匀的时候,稀疏区域的点几乎推不动。绝对值项的梯度是常数(除了0点),推动力均匀,收敛更干脆。

但绝对值项在0点不可导,实践中用 sqrt(f² + ε) 平滑一下,ε 取 1e-8 到 1e-6 之间。不要取太大,否则相当于给表面加了一层软化的壳,细节会糊。我一般取 1e-8,配合双精度或者至少保证梯度计算图里不要出现除零。

另一个细节是表面点的采样方式。如果点云本身密度不均匀,直接均匀抽点会让稠密区主导损失。一种做法是按点云的法向估计置信度加权采样,另一种更简单:对点云做一次体素降采样,把密度拉平了再抽。后者实现成本低,效果也够用。

3.2 Eikonal项:采样范围决定了形状能不能"撑开"

Eikonal项是全局项,它对空间里每一个采样点都要求梯度模长为1。这里的关键参数不是权重,是采样分布。

如果你只在表面附近的窄带里采样,Eikonal约束只在这个窄带里生效,远离表面的地方 f 仍然是自由的,提取网格时可能出现远离主体的伪面。如果均匀撒在包围盒里,约束覆盖全面,但大部分点远离表面,对细节没有贡献,等于浪费算力。

实践中比较稳的方案是混合采样:一半在表面附近的窄带(沿法向偏移一定距离内),一半在整个包围盒里均匀撒。窄带半径取包围盒对角线的1%到5%,看场景尺度。窄带点负责细节,全局点负责把距离场撑开、抑制伪面。

还有一个容易被忽略的点:Eikonal项用的梯度必须是通过 create_graph=True 算出来的,因为你还要对这个梯度求导回传。忘了这个参数,PyTorch会静默地把它当成常量,损失看起来在降,但网络根本没在学这一项。这个坑我踩过,排查了半天。

3.3 法向对齐项:三种写法与数值稳定性

共线约束的实现方式有三种常见写法,数值行为差异很大:

写法表达式优点问题
投影能量‖∇f − (∇f·n)n‖²直接、物理意义清晰训练初期 ∇f 接近0时几乎无梯度
叉积‖∇f × n‖²对 ∇f 模长敏感,天然带尺度模长小的区域梯度也小
余弦1 − (∇f·n)²/(‖∇f‖²+ε)只看方向、与模长解耦除小量,数值不稳,需 ε 兜底

论文用的是哪种在实现上可以灵活替换,但我建议新手先用投影能量这一版。原因很实在:它不需要除法,不会出现除以接近零的数导致 loss 爆炸或者 NaN,训练稳。等跑通了再换余弦版本压细节。

余弦版本虽然数值刁钻,但它有个独特优势——它把方向约束和模长约束彻底解耦,Eikonal项只管模长,余弦项只管方向,两项不打架。用投影能量时,∇f 模长越大,这一项的值也越大,等于隐式地鼓励了大的梯度模长,和 Eikonal 项有轻微冲突。所以在训练后期想再压一压细节的时候,切到余弦版本往往有收益。

注意:用余弦版本必须在除法里加 ε(1e-6量级),并且训练初期要给这一项一个 warmup,前几百步权重设为0。因为初始网络输出的梯度接近随机,方向项会给出大量互相矛盾的信号,反而拖慢收敛。

3.4 权重配比:三个数的经验范围

三个损失项的权重是复现时最花时间的部分。论文里给了一组值,但换数据集就得重调。我按自己的经验给一个起手范围:

  • 表面项权重固定在1,作为基准。
  • Eikonal项权重取0.1到1之间。太小,距离场撑不开;太大,早期网络被Eikonal项主导,表面项推不动,表现为等值面收缩成一团。
  • 法向对齐项权重取0.1到0.5。太大容易在法向估计噪声大的区域产生局部扭曲,尤其是扫描数据边缘处法向估计本身就不准。

判断权重是否合适的信号很直观:训练几千步后提一次网格看,如果网格整体缩水、比点云小一圈,说明Eikonal或法向项偏大;如果网格表面布满细碎的褶皱和毛刺,说明表面项偏弱或者法向项把噪声放大了。

4. 从零复现:环境、数据、训练流程

4.1 网络结构与位置编码的选型

论文用的主干是标准的多层MLP,输入三维坐标,输出一个标量。层数和宽度不算激进,8层、每层256到512个单元就够了。激活函数选 softplus 或者 ReLU 都行,softplus 的输出更平滑,适合要算二阶导的场合;ReLU 的二阶导在大部分地方是0,用在Eikonal项上会退化成没有约束,所以如果你用ReLU,Eikonal项的梯度回传要格外小心。

位置编码这一项论文里没当重点,但实测对高频细节影响很大。原始坐标直接喂进MLP,网络对几十厘米尺度以上的起伏敏感,对毫米级的小特征几乎无感,这是MLP的频谱偏置。加一层傅里叶特征(把坐标映射成 sin(2^k π x)、cos(2^k π x),k 取0到6)能明显改善。代价是训练变慢、容易过拟合噪声,所以建议先不加跑通,细节不够再叠上去。

代码结构大概是这样:

import torch import torch.nn as nn class SDFNet(nn.Module): def __init__(self, hidden=256, layers=8, use_pe=True, pe_bands=6): super().__init__() self.use_pe = use_pe in_dim = 3 if use_pe: self.register_buffer( "freqs", 2.0 ** torch.arange(pe_bands).float() * torch.pi ) in_dim = 3 + 3 * 2 * pe_bands mods = [nn.Linear(in_dim, hidden), nn.Softplus()] for _ in range(layers - 2): mods += [nn.Linear(hidden, hidden), nn.Softplus()] mods += [nn.Linear(hidden, 1)] self.net = nn.Sequential(*mods) def forward(self, x): if self.use_pe: # x: (N, 3) proj = x[:, None, :] * self.freqs[None, :, None] # (N, B, 3) pe = torch.cat([torch.sin(proj), torch.cos(proj)], dim=-1) pe = pe.reshape(x.shape[0], -1) x = torch.cat([x, pe], dim=-1) return self.net(x).squeeze(-1)

位置编码的频带不要一上来就开满,6带已经能覆盖大部分扫描精度,开到10带以上基本是在拟合噪声,除非你的数据是合成的高精度模型。

4.2 法向估计与预处理:这一步决定上限

SALD对法向的依赖是"只要那条线对,箭头反了无所谓",听起来宽容,但前提是法向估计本身的误差要小。PCA估法向的做法是:对每个点取k近邻,算协方差矩阵,取最小特征值对应的特征向量。k的取值很关键——太小,噪声主导,法向抖;太大,跨过棱边,法向被平均掉,锐利边缘会变圆。

我的经验是k取15到30之间,点云稠密就往上取,稀疏就往下取。更稳的做法是自适应:先按点云平均间距估算一个基准k,再对每个点根据局部邻域的曲率调整。曲率大的地方用小k保棱角,平坦区用大k降噪。

预处理还有两步不能省。一是体素降采样,把密度拉平,顺便去掉扫描产生的重复点。二是归一化,把点云平移缩放到单位球内,这一步直接影响学习率和Eikonal权重的选择,不归一化的话换一个模型就得重调一轮超参。

提示:法向估计完之后顺手检查一下模长,PCA出来的特征向量理论上是单位向量,但数值误差会带来1e-3量级的偏差。训练前统一 normalize 一遍,能省掉后面莫名其妙的方向项残差。

4.3 训练循环:把三个损失拼起来

训练循环的骨架不复杂,关键是梯度计算那几行的顺序和参数。

def compute_loss(model, surf_pts, surf_normals, space_pts, w_eik=0.5, w_nrm=0.2): # 表面点:f -> 0 surf_pts = surf_pts.clone().requires_grad_(True) f_surf = model(surf_pts) loss_surf = torch.sqrt(f_surf ** 2 + 1e-8).mean() # 表面点梯度,用于法向对齐 grad_surf = torch.autograd.grad( f_surf.sum(), surf_pts, create_graph=True )[0] # 投影能量写法,符号无关 proj = (grad_surf * surf_normals).sum(-1, keepdim=True) * surf_normals loss_nrm = ((grad_surf - proj) ** 2).sum(-1).mean() # 空间点:Eikonal space_pts = space_pts.clone().requires_grad_(True) f_space = model(space_pts) grad_space = torch.autograd.grad( f_space.sum(), space_pts, create_graph=True )[0] loss_eik = ((grad_space.norm(dim=-1) - 1.0) ** 2).mean() return loss_surf + w_eik * loss_eik + w_nrm * loss_nrm

几个必须注意的点。第一,两处create_graph=True都不能漏,表面点那处漏了法向项就没有梯度回传,空间点那处漏了Eikonal项就是死的。第二,surf_pts.clone().requires_grad_(True)里的 clone 很重要,直接对 dataloader 出来的张量开梯度可能污染上游。第三,如果显存吃紧,可以把表面点和空间点分成两次前向,不要拼在一个 batch 里,梯度图会小一半。

优化器用 Adam,学习率 1e-4 起步,训练 1万到3万步,batch 里表面点取 2000 到 5000,空间点取同等量级。学习率调度可以用余弦退火,最后几千步降到 1e-5,等值面会明显干净一截。

4.4 提取网格与符号的善后

训练完之后,在包围盒里铺一个 128³ 或 256³ 的网格,把每个格点喂给网络拿到 f 值,再跑 Marching Cubes 提 level=0 的等值面。这一步不关心符号,因为零水平集在 f 和 -f 下一致。

会出问题的是网格的三角形朝向。由于符号未定,提取出来的面片法向可能整体朝内,直接丢进渲染器会看到黑面或者光照反了。处理方式很简单:提完之后统计一下面片法向与最近点云法向的一致性,如果整体是负相关,就把所有三角形顶点顺序翻一遍。

如果要判断内外,补一步:取点云包围盒的中心点,如果它在物体内部(大多数闭合扫描件成立),那么 f(center) 的符号就定义了"内部"的符号约定,据此给整个场乘一个 ±1 即可。对非闭合的开放曲面,这一步不成立,得改用射线奇偶法,而且开放曲面本身就没有良定义的内外,建议直接放弃 inside 查询,只当形状用。

分辨率的选择也有讲究。128³ 对多数扫描件够用,但薄壁结构的壁厚如果小于两个体素,Marching Cubes 会把它抹掉。这种情况要么提到 256³,要么改用稀疏的八叉树式提取。

5. 常见坑与排查速查表

复现这套方法,报错和效果不对的原因高度集中在几个地方。我把自己和身边人遇到过的问题整理成一张表,方便对照着查:

现象可能原因排查与处理
损失不降,f 全场接近0表面点没开梯度,或 create_graph 漏了打印 grad 的模长,正常应在1附近;检查两处自动求导参数
提取的网格整体缩水Eikonal权重过大,或空间点采样过密于远场降 w_eik 到0.1,增加窄带采样比例
表面布满毛刺与褶皱法向估计噪声大,法向项权重过高降 w_nrm,增大PCA的k,或对法向做一次平滑
锐利边缘变圆PCA的k过大,跨棱平均减小k,或按曲率自适应调整
薄壁结构消失Marching Cubes分辨率不足提到256³,或检查壁厚是否小于两体素
训练后期 loss 突然 NaN余弦写法的除零,或 sqrt 在0点求导加 ε,改用投影能量写法
训练几万步仍无细节缺位置编码,或频带太低加傅里叶特征,k 从4带到6带试
远离主体的地方出现伪面Eikonal覆盖不足增加全局均匀采样点比例

除了表里这些,还有两个不那么显眼但很折磨人的问题。一个是法向整体翻转但局部一致的数据——比如扫描仪从两个视角拼接,一半点云法向朝里一半朝外,但各自区域内是连续的。这种情况SALD能处理,因为共线约束对翻转不敏感;但如果你的实现里不小心用了带符号的点积(比如直接写 ∇f·n = 1),就会一半点学反,表现为网格一半正常一半塌陷。这也是为什么强调要用绝对值或者投影能量。

另一个是开放曲面的边界效应。在曲面的边缘处,点云的局部邻域是单侧的,PCA估出来的法向会偏向切平面方向,误差很大。这些误差点会通过法向项把场往错误方向拉。简单的缓解办法是在预处理时识别边界点(邻域点分布在一个半平面内),把它们的法向项权重降下来或者直接屏蔽。

注意:不要试图用增大法向项权重的方式来"压"噪声,方向约束的权重和噪声放大是正相关的。噪声大的数据应该先把法向估计算法换掉,而不是调权重。

6. 能力边界与后续可以延伸的方向

这套方法不是万能的,知道它在哪里会失效,比知道它在哪里好用更重要。第一,它对法向质量的依赖是硬性的。如果点云稀疏到 PCA 根本估不出稳定法向(比如每平方米只有几十个点),共线约束给的就是噪声,反而不如退回到纯 SAL 只用Eikonal。第二,它对拓扑变化不敏感——如果你需要重建的是拓扑会变的动态序列,单帧独立训练会得到帧间不一致的零水平集,得额外加时序约束。

第三,符号缺失在有些下游任务里是真问题。做物理仿真要判断碰撞点的内外,做体积积分要知道哪边是实体,这些都得靠后处理补。补得准不准,取决于形状是否闭合。所以如果你的数据是开放扫描,最好在项目一开始就把"是否需要内外查询"这个问题想清楚,别等训完了才发现用不了。

从这篇往后看,这条技术线其实指向一个更大的命题:如何用最弱的监督信号把几何约束住。符号无关只是其中一种弱化,后面还有很多变体——用渲染损失代替几何损失、用多视角一致性代替法向、用稀疏的稀疏点云加单目深度先验。它们的共同思路是一样的:把一个昂贵的全局标签,换成一堆便宜的局部约束,然后用一个正则项(这里是Eikonal)把局部拼成全局。

我自己在这个方向上折腾下来的体会是,损失函数的设计比网络结构重要得多。同一套MLP,损失项换一换,重建质量的差距能拉开一个数量级;而把8层换成16层、256宽换成512宽,收益往往只有一点点,还容易过拟合。所以如果你要在这条线上做改进,优先动损失项和采样策略,别一上来就堆网络容量。

最后分享一个我在调这类模型时固定会做的小动作:每训练两千步就提一次低分辨率(64³)的网格,存下来按序拼成动图。损失曲线看不出问题的时候,网格序列往往能一眼看出是收缩、是膨胀、还是局部塌陷,定位问题的速度快得多。这个习惯帮我省下的调试时间,比任何超参搜索都值。

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

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

立即咨询