SALD符号无关学习:无内外标签点云的隐式SDF重建 做3D扫描重建的朋友大概都遇到过这种局面手里只有一堆从设备导出的散乱点没有内外标签没有可靠的朝向法向估计出来还一半朝里一半朝外。想训一个隐式神经表示去拟合形状传统的SDF监督要求每个采样点都得有一个明确的有符号距离值这个前提在实际数据上几乎不可能满足。这篇《SALD: SIGN AGNOSTIC LEARNING WITH DERIVATIVES》盯的就是这个疙瘩它把监督信号从距离值的正负挪到了函数在表面点处的梯度方向上只用原始点云和一堆朝向混乱的法向就能学出一个零水平集正确的隐式场。关键词里的符号无关学习、隐式神经表示、有符号距离场、Eikonal约束、法向一致性全部在这套框架里串起来了。这篇拆解适合两类人看一类是刚接触隐式表示、想知道这套损失函数为什么这么写的入门者另一类是已经在跑SDF重建、被法向定向和内外标注折磨过的实践者。我会按论文的思路走一遍再把我自己在复现时踩过的坑、调过的参数、改过的实现细节摊开讲。1. 这篇论文到底在解决什么痛点1.1 传统SDF监督的三个昂贵前提在讲这篇论文之前得先把传统路线为什么要价这么高说清楚。用隐式神经表示去学一个有符号距离场标准的监督形式是在空间里撒点每个点给出它到表面的距离同时给出符号——点在物体内部是负的外部是正的。这个设定在合成数据上很舒服因为解析形状可以直接算出精确的SDF值随便撒多少点都有标签。可一旦换到真实扫描数据三个前提同时崩掉。第一扫描设备给你的是表面上的点它本身不知道内部在哪符号无从谈起。第二要构造空间中的距离标签需要先有一个干净的水密网格去做内外测试而原始扫描往往是非水密的、有孔洞的、甚至带扫描噪声的。第三即便你退一步只要求表面点满足 f(x)0那也只是一个零水平集约束它管不住函数在表面两侧怎么长——网络完全可以把整个空间都压成接近零零水平集退化成一团糊。这三个前提里最要命的是符号。符号本质上是一个全局定向信息它要求你对每个点判断在里还是在外而在开放曲面、薄壳结构、自相交模型上里和外本身就是个拓扑上说不清的概念。论文的出发点就是把这层依赖彻底剥掉。1.2 SAL的妥协与SALD补上的那一刀在这篇之前同一个研究方向上有过一版SALSign 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) dd 带符号。SALD管的是函数在表面点处的导数行为函数值只管到等于零这一层。这个转移看起来是把信息变弱了实际上是换了一个信息更稳的载体。打个比方让你描述一个房间的墙一种方式是告诉你往东走3米是墙往西走5米是墙这需要你先知道自己在房间里还是房间外另一种方式是告诉你站在墙边时垂直于墙面往外指的箭头是哪个方向这个描述不依赖你站在哪一侧箭头本身的方向信息就够了。SALD走的是第二条路而法向估计恰好提供的就是垂直于墙面这条线。所以损失函数的结构变成三层表面点约束 f0空间中任意点约束 |∇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提取等值面提取的是 f0 的面取反不影响。所以对于我只想要形状这个目标符号不确定根本不构成问题。真正需要符号的场合是判断内外比如做体积计算、做碰撞查询的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_graphTrue 算出来的因为你还要对这个梯度求导回传。忘了这个参数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项上会退化成没有约束所以如果你用ReLUEikonal项的梯度回传要格外小心。位置编码这一项论文里没当重点但实测对高频细节影响很大。原始坐标直接喂进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, hidden256, layers8, use_peTrue, pe_bands6): 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_eik0.5, w_nrm0.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_graphTrue )[0] # 投影能量写法符号无关 proj (grad_surf * surf_normals).sum(-1, keepdimTrue) * 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_graphTrue )[0] loss_eik ((grad_space.norm(dim-1) - 1.0) ** 2).mean() return loss_surf w_eik * loss_eik w_nrm * loss_nrm几个必须注意的点。第一两处create_graphTrue都不能漏表面点那处漏了法向项就没有梯度回传空间点那处漏了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 提 level0 的等值面。这一步不关心符号因为零水平集在 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³的网格存下来按序拼成动图。损失曲线看不出问题的时候网格序列往往能一眼看出是收缩、是膨胀、还是局部塌陷定位问题的速度快得多。这个习惯帮我省下的调试时间比任何超参搜索都值。