☰
SALD符号无关学习与导数监督:中心差分重建隐式表面
2026/10/29 12:11:13 网站建设 项目流程

前阵子整理资料时翻到一篇挺有意思的工作,标题叫 SALD: Sign Agnostic Learning with Derivatives,中文我一般叫它“符号无关学习 + 导数监督”。这名字乍看有点唬人,但拆开看就是一件很朴素的事:手里只有一堆点,每个点只知道它在物体内部还是外部,不知道离表面多远、也不知道表面朝哪,怎么把物体表面重建出来。这个课题我前后折腾过挺久,从最早的纯符号监督一路试到加导数项,踩的坑不少,正好借这篇论文拆解的机会把整条链路捋一遍。

如果你正在做隐式神经表示、点云重建、三维表面提取,或者只是想搞明白“为什么加了梯度约束模型就不炸了”,这篇内容应该能省你不少时间。下面我会从问题定义开始,讲纯符号监督为什么会翻车,再讲 SALD 用中心差分做导数监督的两个关键改动,最后给一套可以直接抄的复现流程和一张排错速查表。全程不绕弯子,代码和参数都给到能跑的程度。

1. 先把课题的目标和约束条件摆清楚

1.1 隐式神经场到底在学什么

隐式表示这件事,本质上是让一个神经网络去拟合一个标量场。输入是三维坐标x = (x, y, z),输出是一个实数f(x)。约定f(x) < 0的点在物体内部,f(x) > 0的点在外部,那么f(x) = 0这一层等值面就是物体的表面。这跟我们熟悉的体素表示、网格表示完全不是一个思路——体素是把空间切成小方块存 0/1,网格是存顶点和面片,而隐式表示存的是一个连续函数,你在任意位置查询都能得到一个值,分辨率理论上不受限制。

打个生活化的比方:想象你在一间屋子里烘了一炉面包,房间里每一点的温度是个连续变化的量,面包表皮恰好是某个特定温度(比如 180 度)的等温面。你不需要把房间切成一格一格的温度表,只要有一个能回答“这个位置多少度”的函数就够了,等温面在哪儿一算就出来。隐式神经场干的就是这个事,只不过“温度”是网络输出的标量,等温面换成了零等值面。

这样做的好处很实在:内存占用跟分辨率解耦,一张 512³ 的体素网格就是 1.3 亿个体素,而一个 8 层 256 宽的 MLP 参数量也就几十万;表面天生连续,不会有体素那种阶梯感;还能顺便拿到解析或数值的梯度,用来自动算表面法向。代价也有——等值面得靠 marching cubes 之类的方法额外提取,而且训练信号的构造比体素分类麻烦得多,这也是后面所有问题的根源。

1.2 训练信号其实分三档,SALD 站在最便宜的那一档

监督信号决定了这条路的难易程度,我一般把它分成三档。

第一档是符号距离真值,也就是每个采样点不仅知道内外,还知道到表面的最短距离和最近点方向。这是最理想的监督,但获取成本极高:合成数据可以做精确计算,真实扫描数据基本只能近似,而且一旦形状有噪声,距离场的定义本身就不干净了。

第二档是法向监督。点云配上每个点的法向,信息量介于中间。法向可以用局部邻域做 PCA 主成分分析估出来,或者从网格上直接读。问题在于真实扫描的点云噪声大、密度不均,PCA 估出来的法向在薄壁和尖角处经常翻向,用这种带噪法向去监督,网络学出来的表面会带着噪声的“指纹”。

第三档就是只有内外符号。这是 SALD 所在的档位,也是获取成本最低的一档。你只要知道某个点在物体里还是外,就能用。判定内外的办法很多:对水密的网格可以做射线投射数交点奇偶,多视角轮廓投票也能给出大致结果,人工在切片工具上点几下也能标一批。这种方法不需要距离、不需要法向,对局部噪声的容忍度也更高——毕竟一个点无论怎么抖,只要没跨过表面,标签就不变。

注意:内外标签看似便宜,但它有个隐含前提——形状得是封闭的。开放曲面(比如一张纸、一片布料)没有明确的“内部”,这套框架直接用会得到很诡异的结果,后面第 6 节会专门说这件事。

这里必须把sign agnostic这个词讲透。它的意思不是说“不需要符号”,而是说训练损失里只出现网络输出的符号与标签的比较,不出现任何具体的数值目标。损失项长这样:标签是y ∈ {-1, +1},惩罚项是softplus(-y · f(x)),只有当f(x)的符号和标签一致且幅度足够大时,这一项才小。至于f(x)到底等于 -3 还是 -300,损失根本不关心。这个特性带来两个后果:好的一面是网络尺度自由,不用担心距离真值的量纲问题;坏的一面是解严重不唯一,无穷多个函数都能把所有点的符号判对,网络到底会收敛到哪一个,完全取决于优化路径和隐式正则,这就是后面一系列麻烦的起点。

1.3 为什么这个课题值得单独拆一篇

单纯从“能不能重建出形状”来看,只靠符号监督的思路早有人做过,也确实能跑出结果。但那类方法有个共同的毛病:表面质量不稳定,时好时坏,换个随机种子结果能差出一大截,而且表面法向基本不可用——梯度方向乱跳,算出来的法向像一团静电噪声。

SALD 这篇工作的价值在于,它没有停留在“加个正则项试试”这种经验层面,而是把“为什么纯符号监督下梯度不可控”这件事讲清楚了,并且给出了一个只需要前向计算的修正方案。这个方案有个很实际的优点:它不需要网络二阶可导,不需要 autograd 的梯度图,甚至能配合 ReLU 这种不光滑的激活函数使用。对显存紧张、或者想塞进现有训练框架里的人来说,这一点比理论上的优雅更值钱。

2. 老方案为什么翻车:纯符号监督的动力学解析

2.1 符号损失的最优解是一族阶跃函数

先把损失写清楚。给定点x和内外标签y,符号项通常写成:

L_sign = softplus(-y * f(x))

或者等价的 logistic 形式。这个损失对f(x)的幅度是没有上界的:只要y · f(x)足够大,损失就趋近于 0,而且越大幅度越小。也就是说,最小化这个损失的最优解不是一个“温和”的函数,而是把所有内部点推到负无穷、所有外部点推到正无穷的极限形态——一个阶跃函数。

这个结论听起来抽象,但在训练曲线上表现得非常具体。你会看到符号损失很快掉到接近 0,看起来“学得很好”,但把等值面提出来一看,表面上一堆小毛刺和碎片,法向完全没法用。原因就在于网络为了让损失更小,会不断加剧零等值面附近的陡峭程度,过渡带越来越窄,梯度越来越大。等到某个时刻,浮点精度撑不住了,表面就开始碎。

更麻烦的是这个趋势没有自然停止的机制。你不加约束,它就一直往陡里走。这也是为什么很多纯符号监督的实现必须靠早停——在损失还没彻底收敛的时候把训练掐掉,靠“训练不充分”来换取表面的平滑。这种做法显然不可靠,换个数据分布、换个随机种子,那个“刚刚好”的时刻就变了。

2.2 单侧差分:随手算的“梯度”其实是有偏的

既然符号项不管梯度,那自然地想法就是补一个梯度项,把梯度约束住。问题来了:怎么算梯度?

最省事的做法是单侧差分。对每个点x,随机采一个单位方向v,那么方向导数可以近似成:

(f(x + eps * v) - f(x)) / eps

这个式子只需要两次前向,不用 autograd,实现起来三行代码。早期的纯符号监督方案里用的就是它,然后拿这个估计值去跟某个目标值对齐。

但这个估计量对阶跃状函数是失效的。设想f已经接近阶跃函数,x附近有个跳变面。分三种情况:跳变面完全落在[x, x + eps*v]区间外,那分子是 0,估计值 0;跳变面刚好落在区间内,分子是跳变量(在符号函数里就是 2),估计值2/eps,eps越小这个值越大;跳变面正好穿过x,估计值又是另一回事。也就是说,这个估计量测的根本不是“梯度”,而是“边界有没有落在这个长度为 eps 的窗口里”——它更像一个边界探测算子,噪声极大,而且随着eps变小,估计值的方差反而爆炸。

从数值分析的角度看更清楚:对光滑函数,单侧差分的截断误差是一阶的,也就是O(eps);而中心差分的截断误差是二阶的O(eps²)。对不连续函数,单侧差分还会额外引入一个与边界位置相关的偏置项,这个偏置不会随着eps缩小而消失,它只是被放大了。拿一个有偏的估计量去做正则,本质上是在优化一个错误的目标,训练能不能稳全看运气。

2.3 常见的“加了梯度项还是不稳”的四种成因

我复盘过自己早期那些不成功的实验,问题基本能归到四类。

第一类是方向采样太少。每个点只采一个方向v,那这一项估计的只是梯度在v上的投影,其它方向完全没被约束。合理做法是每个点采多个方向,或者每个 batch 重新采样,让长期期望覆盖整个球面。单方向采样在小 batch 下梯度噪声极大,表现出来就是 loss 曲线剧烈震荡。

第二类是**eps与点云尺度不匹配**。eps太大,差分窗口跨过了形状的细节特征,表面被抹平、细结构糊成一团;eps太小,差分值落到浮点精度以下,两个前向输出的差值被舍入误差吞掉,梯度项变成纯噪声。这个尺度必须跟点云的最近邻平均间距挂钩,不能拍脑袋定一个 0.01。

第三类是在远离表面的地方也施加了强约束。理论上只有零等值面附近才需要严格约束梯度,包围盒角落里那些点的梯度你根本不在乎。但如果你用一个全局平均,远处大量的采样点会稀释掉表面附近的约束力度,训练后期表现为“表面附近还是不够平滑,但整体 loss 已经很低了”。

第四类是激活函数选了 ReLU 又指望拿到光滑表面。ReLU 网络的输出是分段线性的,零等值面提出来天然带棱面感。这不是损失函数能救的,得换激活函数或者在提取后做平滑。

3. SALD 的两个关键改动

3.1 中心差分:把估计量对称化

SALD 最核心的一个改动,就是把单侧差分换成中心差分。对同样的点x和方向v:

d_hat = (f(x + eps/2 * v) - f(x - eps/2 * v)) / eps

就这一个符号的改动,性质完全变了。对光滑函数,两边的泰勒展开里一阶项符号相反,相加时自动抵消,剩下的主项是二阶的,误差从O(eps)降到O(eps²)。这意味着同样的eps下估计精度高一个量级,或者换句话说,为了达到同样精度,eps可以取大一档,从而差分值更不容易被浮点噪声污染。

对不连续函数,中心差分的表现也更“讲道理”。对称取点意味着只要跳变面不在±eps/2这个小窗口内,两侧同号,差分值是 0;只有当跳变面落在窗口内时才有非零响应,而且响应幅度是2/eps,符号取决于跳变方向。这个行为的含义是:中心差分项实际上在度量“符号在eps邻域内变化的剧烈程度”。把它作为惩罚项,就是在逼着网络在eps尺度上保持符号的单调一致,从而把原来无限陡的阶跃抹成一个宽度与eps相关的过渡带。

我觉得这个视角比“估计梯度”的说法更好理解:中心差分不是在算梯度,它是在给表面定义一个特征宽度。eps决定了你希望网络的过渡带有多宽,也间接决定了重建表面的最小可分辨特征尺寸。这也解释了为什么后面eps的调参那么关键——它不只是一个数值精度参数,它是一个几何尺度参数。

提示:中心差分需要三次前向(x本身、x+eps/2*v、x-eps/2*v)。如果符号项和导数项共用同一个点集,总前向次数是 3 次。想省算力的话,符号项可以复用中心差分两个端点之一,但会引入额外的耦合,我实测下来还是老实做三次前向更稳。

3.2 目标值设成 1:一个尺度归一化的锚

有了差分估计量,接下来得决定往哪儿约束。SALD 的做法是把方向导数往1上拉,损失写成平方形式:

L_der = (d_hat - 1)²

为什么是 1?因为f的尺度是自由的,符号项对幅度完全不敏感,你不给它定个尺度,它就一直往陡里跑。约束方向导数等于 1,等价于说f沿着单位方向的变化率是 1,这正好是符号距离函数的特征性质——在距离场里,沿任意方向走单位长度,距离值就变化单位量,除了在“中轴”附近这个性质会失效。约束在eps的尺度上近似成立,就足够把函数“校准”成一个近似的距离场了。

这一步带来的额外收益是法向可用。既然f近似是距离场,那它的梯度方向就是表面法向,模长接近 1。你不需要额外训练一个法向头,也不需要做 PCA,直接在表面附近采样一批点,用差分估出梯度归一化,就是法向。这个性质在做泊松重建、做表面配准、做碰撞检测的时候非常省事。

代价也得说清楚:f只在eps尺度上近似是距离场,离表面远了(超过几个eps)就不保证了。所以如果你的下游任务需要全空间的精确距离查询,这个函数不能直接拿来用,得做一次真正的距离变换。我在项目里就吃过这个亏,一开始以为训练出来的f可以直接当距离场用来做碰撞检测,结果在远离表面的地方误差大得离谱。

3.3 整体损失与权重设计

两项合起来就是:

L = L_sign + lambda * L_der

lambda的角色是权衡“位置”和“形状”。符号项负责把零等值面推到正确位置,导数项负责让表面光滑、法向可靠。这个权重不是随便给的,我试下来的规律是:

lambda 取值现象适用场景
0(纯符号)表面毛刺、法向不可用只做粗略内外分割
0.01 ~ 0.05表面基本光滑,细节保留好细节丰富的形状重建
0.1 ~ 0.5表面非常光滑,薄结构开始被抹噪声大、需要干净网格
> 1表面鼓胀、细节丢失、训练变慢一般不推荐

lambda太大会出现一个很典型的现象:表面整体“鼓”起来,像充了气。原因很直接——你在远离表面的区域也强制梯度模长等于 1,这等价于要求函数在整个空间都是距离场,而真实形状的距离场在中轴附近本来就有梯度退化,强行约束的结果就是零等值面被推向外侧寻找一个更“规整”的解。我的经验是lambda从 0.05 起调,先保证表面不出毛刺,再逐步加大到细节开始丢失为止,然后退回去一档。

3.4 和 eikonal 类方案的正面对比

市面上另一条主流路线是用 autograd 算解析梯度,然后直接约束||∇f|| = 1,也就是常说的 eikonal 正则。两条路线目标相似,实现和代价差别不小:

维度解析梯度 + eikonal中心差分(SALD 路线)
梯度来源autograd 反向图纯前向,三次调用
对网络的要求需一阶可导,最好二阶可导无要求,ReLU 也能用
显存开销高,需要保留计算图中,只需激活值
实现复杂度中,得处理create_graph低,几行张量运算
梯度方向的准确性精确有O(eps²)偏差
数值稳定性高,但二阶导可能爆依赖eps选取

我个人的判断是:如果你的网络本身光滑(sine、softplus、tanh),算力也够,解析梯度那条路更省心,收敛也更稳;如果你是 ReLU 网络、显存吃紧、或者想把这块塞进一个不方便做高阶求导的框架里,中心差分是更实际的选择。还有一个容易被忽略的点:解析梯度那条路在f接近阶跃时,二阶导会剧烈震荡,反而需要额外的梯度裁剪;中心差分天然对这种情况更钝感。

4. 复现路线:从一份点云到一张可用网格

4.1 数据准备与内外标签的生成

整个流程的第一步是把数据的尺度统一。我习惯把所有点云缩放到单位球内(中心在原点,最大半径 1),这一步不是可选的,因为eps、网络初始化、学习率全都跟尺度耦合。不归一化直接训练,换一份数据就得重调一遍超参,非常痛苦。

内外标签的生成,我用过三种办法。第一种是对水密网格做射线投射,从每个点往随机方向发一条射线,数交点数,奇数是内、偶数是外,这种方法准确率高但要求网格封闭;第二种是多视角轮廓投票,从若干视角渲染轮廓,把点投影上去看落在轮廓内还是外,投票决定标签,这种方法对非水密形状也能凑合用,但凹面区域容易错;第三种是直接拿现成的带标注数据,省事但可控性差。不管用哪种,都建议抽 1% 的点人工看一眼,标签翻转率超过 3% 的时候,表面会明显偏离,而且这种偏离在损失曲线上看不出来。

4.2 采样器设计:两个点集怎么配比

训练用的点集一般由两部分组成。一部分是表面附近点,从原始点云加一个小扰动生成,扰动幅度在0.5 * eps量级,这批点承担了精细形状的监督;另一部分是全局随机点,在包围盒内均匀采样,这批点负责把大范围的符号关系理顺,防止远处的函数乱飘。

配比上我用 6:4 到 7:3 比较多。全是表面点的话,远处没有约束,等值面可能在外围“炸”出一堆孤岛;全是随机点的话,表面附近的采样密度不够,细节出不来。每个 batch 总点数 4000 到 8000 就够,再多收益递减明显。

方向向量v的采样有个小坑:用torch.randn然后归一化,得到的是球面均匀分布;如果直接在[-1, 1]上均匀采样三个分量再归一化,会在对角线方向聚集。这个差异在方向数少的时候影响很大,建议老老实实用高斯归一化。

v = torch.randn_like(x) v = v / (v.norm(dim=-1, keepdim=True) + 1e-8)

4.3 eps 的选择:一个能算出来的起点

eps是这套方法里最需要认真对待的参数。我的做法是先算点云的最近邻平均间距,然后拿它当基准:

import torch def mean_nn_dist(pc, chunk=2048): pc = pc.float() n = pc.shape[0] total = 0.0 for i in range(0, n, chunk): block = pc[i:i + chunk] d = torch.cdist(block, pc) rows = torch.arange(block.shape[0]) cols = torch.arange(i, i + block.shape[0]) d[rows, cols] = float('inf') # 排除自身 total += d.min(dim=1).values.sum() return (total / n).item()

归一化到单位球之后,5 万个点均匀分布在球面上,最近邻间距大概在 0.02 上下(具体值跟分布有关,用上面的函数算一下最靠谱)。eps就从1.5 × d_nn起步,调参范围放在[1, 3] × d_nn。

这个区间的道理是:eps小于 1 倍间距时,差分窗口比采样间隙还小,窗口内可能一个监督点都没有,差分值全靠网络的隐式插值,噪声大;eps大于 3 倍间距时,窗口跨过了多个采样点,细节被平均掉,薄壁结构直接消失。我曾经在一个壁厚只有 0.01 的模型上试过eps = 0.08,结果薄壁整个被填实了,调回 0.03 才恢复。

还有一个浮点精度的下限要注意。网络输出量级通常在 1 左右,float32 的有效位数约 7 位,两个输出的差值如果小于1e-5,基本就被舍入误差淹没了。所以eps再小也别低于1e-3,否则差分项等于在拟合噪声。

4.4 网络结构与初始化

我这套配置是 8 层 MLP,每层 256 宽,隐藏层用 softplus,输出层线性。softplus 相对 ReLU 的好处是二阶导连续,表面不会有硬棱;相对 sine 激活的好处是不需要特殊初始化,训崩的概率低很多。如果你想追求极致的细节,可以把 softplus 换成带频率参数的 sine,但初始化得按特殊分布来,学习率也要降到1e-4以下,调起来更麻烦。

import torch.nn as nn class ImplicitField(nn.Module): def __init__(self, width=256, depth=8, in_dim=3): super().__init__() layers = [] last = in_dim for _ in range(depth): layers.append(nn.Linear(last, width)) layers.append(nn.Softplus(beta=100)) last = width self.body = nn.Sequential(*layers) self.head = nn.Linear(width, 1) self._init_weights() def _init_weights(self): for m in self.body: if isinstance(m, nn.Linear): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias) # 输出层用小初始化,避免训练初期输出幅度过大 nn.init.normal_(self.head.weight, std=1e-4) nn.init.zeros_(self.head.bias) def forward(self, x): return self.head(self.body(x)).squeeze(-1)

输出层小初始化这一点很关键。如果输出层初始权重正常初始化,初始网络的输出幅度会在 1 到 3 之间乱跳,eps又小,差分项一上来就是巨大的值,梯度直接炸掉。用小初始化让初始输出接近 0,等价于初始的零等值面是一张“到处都是”的退化曲面,然后靠符号项慢慢把它塑形,这个过程平稳得多。

4.5 训练循环与损失实现

核心的训练步骤大致是这样:

def sald_step(model, opt, x_surf, y_surf, x_rand, y_rand, eps=0.03, lam=0.05, n_dir=1): x = torch.cat([x_surf, x_rand], dim=0) y = torch.cat([y_surf, y_rand], dim=0).float() # 符号项 logits = model(x) loss_sign = torch.nn.functional.softplus(-y * logits).mean() # 导数项(中心差分) loss_der = 0.0 for _ in range(n_dir): v = torch.randn_like(x) v = v / (v.norm(dim=-1, keepdim=True) + 1e-8) f_plus = model(x + 0.5 * eps * v) f_minus = model(x - 0.5 * eps * v) d_hat = (f_plus - f_minus) / eps loss_der = loss_der + ((d_hat - 1.0) ** 2).mean() loss_der = loss_der / n_dir loss = loss_sign + lam * loss_der opt.zero_grad() loss.backward() opt.step() return loss_sign.item(), loss_der.item()

n_dir我在小规模实验里用 1,正式训练用 2 到 3。多方向采样会让梯度项的方差明显下降,训练曲线平滑很多,代价是前向次数线性增长。优化器用 Adam,学习率1e-3,配余弦退火到1e-5,训练 3 万到 5 万步。batch 里表面点和随机点合并送进去,不用分两个优化器。

有一个细节值得单独说:符号项和导数项的点集要不要一致。我的做法是让它们共用同一批点,实现简单,而且同一个点既有符号约束又有导数约束,耦合更强。但也有人把导数项只加在表面附近的点上,理由是远离表面的地方梯度约束没意义,只在表面附近加能提高有效约束密度。两种做法我都试过,前者收敛更稳,后者在精细模型上表面质量略好一点,差异不算大,看你的形状细节需求。

4.6 提取网格与后处理

训练完之后提网格,在[-1.1, 1.1]³的包围盒里打256³或512³的规则网格,逐点前向求值,然后跑 marching cubes。包围盒要留 10% 的边,防止形状贴边被切断。512³就是 1.34 亿个点,直接一次前向显存肯定不够,得按块循环,每块 100 万点,跑完拼起来。

提完之后的后处理有三步。第一步去掉小的连通分量,零等值面经常会在角落留几个孤岛,按面片数筛掉就行。第二步做几轮拉普拉斯平滑,softplus网络的表面虽然连续,但仍然会有轻微的波纹,平滑 2 到 3 轮就能压掉。第三步重算法向,用网格拓扑算,别用f的梯度算——后者的方向准但模长在远离表面处会偏,混着用会出现明暗不均。

4.7 怎么判断模型真的学到了

光看损失曲线是不够的,符号损失掉到 0.01 但表面一塌糊涂的情况太常见了。我一般看四个指标。

法向一致性:在表面附近采 1 万个点,用中心差分估出梯度方向,跟网格最近点的法向比夹角,中位数夹角在 10 度以内就算合格。

体积一致性:从网格算封闭体积,再跟标签统计估出来的体积比,偏差超过 5% 说明有大块区域被系统性挤出去了。

切片检查:沿三个轴向各切几张截面,肉眼看轮廓。这一步最土但最有用,很多问题(比如薄壁被填实、凹槽被抹平)在数值指标上不明显,一眼就能看出来。

Chamfer 距离和 F-score:如果手里有扫描真值,这两项是标配。阈值一般取0.5 × d_nn,在这个阈值下的 F-score 能到 0.9 以上,基本可以交付了。

5. 排错手册与踩坑记录

5.1 症状、原因、对策速查表

这套流程我跑过十几遍,问题基本集中在下面这些症状上:

症状最可能的原因处理办法
表面大量小毛刺和碎片导数项权重过低,或eps过小lambda提到 0.1,eps提到 2 倍间距
表面整体鼓胀、细节模糊lambda过大,全局梯度约束过度lambda降到 0.03 以下
薄壁结构被填实eps大于薄壁厚度eps降到薄壁厚度的 1/3 以下
训练后期 loss 震荡方向采样太少,梯度项方差大n_dir提到 2 到 3
loss 一开始就爆掉输出层初始化太大,或学习率过高小初始化输出层,lr 降到 3e-4
表面有周期性条纹eps与网格分辨率接近,混叠错开两者,网格分辨率提一档
凹面区域缺失内外标签在凹面处判错检查标签生成,必要时局部重标
法向方向乱跳网络用了 ReLU,或eps过大换 softplus,eps降到 1.5 倍间距
提取时出现孤岛远处随机点采样不足全局点比例提到 0.4
训练收敛但表面偏移整体数据未归一化,或标签有系统性偏差归一化到单位球,抽检标签

5.2 三个最容易忽略的细节

第一个是方向向量的分布。前面提过,但值得再强调:如果你贪快在[-1,1]³上均匀采样再归一化,方向会往八个角聚集,导致某些方向上的约束被过度加权,某些方向上完全没约束。表现出来就是表面在某些朝向的面上特别光滑,另一些朝向的面上还有毛刺。这个 bug 很隐蔽,因为它不会让训练失败,只是让结果变差,你得盯着表面质量才能发现。

第二个是 float16 混合精度不能用在差分上。混合精度训练能省不少显存,但中心差分那个减法对精度太敏感了,eps = 0.03、f的差值在0.03量级,float16 只有 3 位有效数字,差值基本全是噪声。我的做法是主干用autocast,差分那一段强制float32,用.float()转一下再算,代价可以接受。

第三个是训练集和提取网格的采样不能共用一个随机数发生器的状态。听起来像废话,但我真的踩过:差分那一段用torch.randn_like采方向,如果没给 generator 设种子,提取阶段的大量采样会跟训练阶段共享同一套伪随机序列,导致某些网格坐标上的取值跟训练时的采样点重合,出现极窄的数值伪影。正式跑的时候,提取阶段单独用一个torch.Generator就行。

5.3 eps 和 lambda 的调参顺序

很多人一上来就同时调两个参数,结果调了半天不知道是谁的问题。我建议的顺序是:

先固定lambda = 0.05,把eps从3 × d_nn往下扫到1 × d_nn,跑四五个点,只看表面细节的保留程度和毛刺情况,选一个细节最清楚的。然后固定eps,把lambda从 0.01 往上扫到 0.2,看表面光滑度和鼓胀程度,选一个表面干净但细节还在的。最后两个参数微调一轮,一般两轮就收敛了。

每轮实验我建议只跑 1 万步,不用跑满。eps和lambda的影响在 1 万步的时候已经能看出来,跑满纯属浪费算力。等参数定了再跑完整训练。

6. 这套方法的边界和我自己的使用心得

6.1 什么时候不该用它

这套方法的适用边界比想象中窄,几种情况我建议直接换方案。

需要精确距离查询的场景。前面说过,f只在eps尺度上近似是距离场。如果你拿它去做碰撞检测、做光线步进、做物理仿真,误差会随着距离增大迅速累积。这类任务还是老老实实算真正的距离变换。

开放曲面和薄壳。内外标签的定义依赖封闭性,一张纸、一片布、一个破裂的壳体,都没有明确的“内部”。硬套这套框架,网络会在开放边界附近乱长,表面会往外“封口”。这种情况可以考虑把问题改成有向点云重建,或者用双层壳的表示。

厚度小于 eps 的结构。中心差分的窗口是eps,比窗口还薄的结构会被直接抹平。我在一个带 0.01 厚度筋板的模型上试过,eps压到 0.015 以下才能勉强看到筋板,但那时候差分噪声已经很大了,得配合更大的 batch 和更小的lambda才勉强能用。

需要拓扑变化的重建。从一个单球开始重建多个分离部件,隐式场在拓扑变化时会出现“桥接”现象,两个部件之间会连一条薄薄的管子,marching cubes 之后很难看。这类问题需要专门的拓扑处理策略,不是调参能解决的。

6.2 我在实际项目里的几个用法

最后分享几个我觉得挺实用的小技巧。

一是分阶段训练。前 5000 步只用符号项,让零等值面先大致成形,再打开导数项。这样做的好处是避免导数项在初期把函数往“到处都平”的方向拉。我试过一开始就开导数项,收敛反而慢,而且容易卡在把所有输出的梯度都压成 0 的平凡解上。

二是给导数项加一个按到表面距离的权重。用上一次迭代的|f(x)|做近似距离,权重取exp(-|f(x)| / (3 * eps)),让表面附近的点主导约束。这个改动对小样本数据提升明显,因为远离表面的随机点不再稀释约束。

三是用重建后的网格反哺采样。第一轮训练出来的网格虽然粗糙,但它给出了一个比原始点云更均匀的表面分布。从网格上采一批点,混进第二轮训练的表面点集里,能让表面更均匀,薄壁处的孔洞也能补上。我一般这么迭代两轮,第二轮之后的法向一致性会有可见提升。

四是别迷信更深的网络。我试过 16 层,结果比 8 层差,原因是深网络的高频分量更难控制,表面上会出现波纹状的伪影,而且eps得调大一档才压得住。8 层 256 宽基本够用,真要提细节,加宽到 512 比加深划算。

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

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

立即咨询