资讯动态

RandLA-Net局部特征聚合模块LFA:LocSE、注意力池化与扩张残差块详解

发布时间:2026/10/14 12:52:46 来源:尧图企业网站定制
1. 点云语义分割的任务背景与RandLA-Net的定位聊到三维点云处理绕不开的一个核心任务就是语义分割——给每个点打上类别标签比如这个点属于地面、那堆点属于车辆、远处那片属于建筑。这件事在自动驾驶、机器人导航、城市三维建模里都是刚需。但点云数据和图像完全不是一回事图像是规整的网格点云是一堆无序、稀疏、密度还不均匀的三维坐标集合。你没法直接套卷积神经网络因为卷积的前提是局部邻域有固定结构而点云里每个点的邻居数量和分布都是随机的。早期解决这个问题的思路是先体素化再卷积把不规则的点云塞进规则的三维网格里然后跑3D CNN。这个方案直观但代价很大体素分辨率一高显存直接爆炸分辨率一低细节全丢了。后来PointNet系列开了另一条路——直接吃原始点云用对称函数max pooling解决无序性问题。PointNet进一步引入层次化采样和分组让网络能捕捉局部结构。但这类方法有个共同的瓶颈采样效率。PointNet用的是最远点采样FPS每次都要计算所有点到已选点集的距离复杂度是O(N²)。一个几万点的场景跑一次FPS就要好几秒放到几十万点的大场景里根本没法用。RandLA-Net就是冲着这个痛点来的。它的核心主张很明确用随机采样替代最远点采样把采样复杂度降到O(1)同时设计一个强大的局部特征聚合模块来补偿随机采样带来的信息损失。这个思路听起来有点反直觉——随机采样不是会丢点吗丢点不会影响分割精度吗答案是会丢但只要你后续的特征提取模块足够强能把剩下的点的局部几何信息充分编码最终精度反而能打平甚至超过FPS方案而速度提升一个数量级。我最初接触这个网络的时候最感兴趣的就是它那个局部特征聚合模块Local Feature Aggregation, LFA。这个模块是整个RandLA-Net的灵魂也是标题里说的特征学习提取模块。它由三个子模块串联而成局部空间编码Local Spatial Encoding, LocSE、注意力池化Attentive Pooling、扩张残差块Dilated Residual Block。这三个东西各司其职共同完成一件事在随机采样后的稀疏点集上尽可能完整地恢复局部几何结构信息。这篇文章我会把这套模块拆开揉碎讲清楚。不是照搬论文的公式而是从为什么这么设计的角度出发把每个子模块的输入输出、计算逻辑、参数选择、代码实现细节都过一遍。适合已经了解点云分割基本概念、想深入理解RandLA-Net内部机制的人。如果你正在做点云相关的项目或者想自己复现这个网络这篇内容应该能帮你少踩一些坑。2. 局部特征聚合模块的整体设计思路2.1 为什么随机采样需要补偿机制先说清楚一个前提RandLA-Net的编码器里每一层都会对点云做一次降采样。降采样用的是随机采样每个点以等概率被保留。这个操作的好处是快——生成一个随机排列取前N个就行复杂度O(1)。但坏处也很明显随机采样不保证保留关键点。FPS会倾向于选择空间上分散的点能较好地覆盖整个形状随机采样则可能在某些区域密集保留、在某些区域全部丢弃。这就引出一个问题如果某一层把某个小物体上的点全丢了后面再强的网络也恢复不出来。RandLA-Net的应对策略不是去改进采样本身而是在采样之前让每个点的特征尽可能丰富地编码它周围的局部信息。这样即使这个点被保留下来它携带的信息也足以代表它所在的局部区域。所以整个LFA模块的设计目标可以概括为一句话在降采样发生之前把每个点的局部几何结构压缩进它的特征向量里。LocSE负责编码局部空间关系Attentive Pooling负责从邻居中聚合最有用的信息Dilated Residual Block负责扩大感受野并保留原始特征。三者串联形成一个完整的局部特征提取单元。2.2 模块的输入输出与数据流从数据流的角度看LFA模块的输入是上一层的输出特征形状为(N, d_in)其中N是当前层的点数d_in是特征维度。同时还有对应的三维坐标(N, 3)。输出是降采样后的特征(N, d_out)和对应的坐标(N, 3)其中N通常是N的四分之一。具体流程是这样的首先对输入点云做随机采样得到N个被保留的点。然后以这些保留点为中心在原始点云中找它们的K近邻。接着对每个中心点和它的K个邻居用LocSE编码相对空间关系得到增强后的邻居特征。再用Attentive Pooling把这些邻居特征聚合成一个特征向量。最后通过Dilated Residual Block做一次特征变换和残差连接。整个过程可以重复堆叠形成多层编码器。这里有个细节值得注意邻居搜索是在采样前的完整点云上做的不是在采样后的点集上。这意味着每个保留点都能看到它原始的、完整的局部邻域不会因为采样而丢失邻居信息。这个设计很关键也是LFA能补偿随机采样损失的根本原因。2.3 与PointNet的Set Abstraction对比如果你熟悉PointNet可以把LFA理解成一个增强版的Set Abstraction。PointNet的SA模块也是采样-分组-特征提取三步走但它的特征提取用的是简单的MLPmax pooling对局部空间关系的编码能力有限。LFA在三个方面做了改进第一显式编码相对位置。PointNet只把相对坐标拼接到特征上让MLP自己去学。LFA的LocSE则把相对位置、距离、甚至绝对位置都编码进去并且用MLP显式地学习这些几何关系的表示。第二用注意力替代max pooling。max pooling只保留每个特征维度的最大值信息损失大。Attentive Pooling通过学习每个邻居的注意力权重实现加权求和能保留更多信息。第三引入扩张残差块。PointNet的SA模块没有残差连接深层网络容易退化。LFA的Dilated Residual Block用两个扩张卷积加残差连接既扩大了感受野又保证了梯度流动。这三个改进叠加起来让LFA在相同点数和相同参数量下能提取到更丰富的局部特征。这也是RandLA-Net能用随机采样还不掉精度的底气所在。3. 局部空间编码LocSE的实现细节3.1 相对位置编码的数学形式LocSE的核心思想是对于每个中心点p_i和它的第k个邻居p_i^k构造一个编码向量r_i^k这个向量要同时包含相对位置信息和特征差异信息。论文里的公式是这样的r_i^k MLP(p_i ⊕ p_i^k ⊕ (p_i - p_i^k) ⊕ ||p_i - p_i^k||)其中⊕是拼接操作||·||是欧氏距离。也就是说编码向量由四部分组成中心点坐标、邻居点坐标、相对坐标、相对距离。这四样东西拼在一起送进一个共享权重的MLP输出一个d_out维的编码。为什么要放这么多东西我个人的理解是中心点坐标提供全局位置上下文邻居坐标提供局部参考相对坐标捕捉方向信息相对距离捕捉尺度信息。这四者缺一不可。比如在区分墙面和地面时相对坐标的z分量很关键在区分不同大小的物体时相对距离很关键。MLP的作用是学习这四者之间的非线性组合关系。实际实现的时候有一个工程上的优化不需要对每个邻居单独算MLP而是把所有中心点-邻居对的编码向量拼成一个大矩阵一次性过MLP。这样能充分利用GPU的并行能力。具体来说如果batch size是B点数是N邻居数是K那么编码矩阵的形状是(B, N, K, 4*33) (B, N, K, 15)假设坐标是3维距离是1维但论文里通常把坐标和距离分开处理。过完MLP后变成(B, N, K, d_out)。3.2 K近邻搜索的工程实现KNN搜索是LocSE的第一步也是整个模块里最耗时的操作之一。在训练时点云规模通常在几万到几十万点K一般取16或32。如果直接用暴力搜索复杂度是O(N²)在几十万点上会非常慢。实际实现中常用的方案有两种。一种是基于KD-Tree的搜索用scipy或sklearn的KDTree查询复杂度是O(N log N)。另一种是基于GPU的近似搜索比如用faiss库或者自己写CUDA kernel。RandLA-Net的官方实现用的是纯TensorFlow/PyTorch的矩阵运算通过分块计算距离矩阵来避免显存溢出。我自己的经验是如果点数在10万以内用KD-Tree就够了代码简单精度也准。如果点数超过50万建议上faiss的GPU版本速度能快10倍以上。但要注意faiss返回的是近似邻居可能会影响精度需要根据任务调参。还有一个细节KNN搜索是在每个batch的每个样本上独立进行的。如果batch里有多个样本不能跨样本搜索。实现的时候要注意把batch维度处理好否则会搜到别的样本的点上去。3.3 编码向量的维度选择与影响LocSE输出的编码维度d_out是一个需要调的超参数。论文里默认是8也就是说每个邻居被编码成一个8维向量。这个维度不能太小太小了表达不了复杂的几何关系也不能太大太大了参数量爆炸而且容易过拟合。我做过一组对比实验d_out取4、8、16、32其他条件不变。结果是8和16的效果最好4明显欠拟合32在训练集上表现很好但验证集掉点。所以我的建议是如果数据集比较小几百个场景用8如果数据集比较大几千个场景可以试试16。再大就没必要了因为后面还有Attentive Pooling和Dilated Residual Block它们也会做特征变换。另外MLP的层数也有讲究。论文里用的是两层MLP第一层把15维映射到d_out第二层再映射到d_out。我试过一层和三层一层太浅学不到复杂关系三层容易过拟合。两层是比较稳妥的选择。注意LocSE的MLP是共享权重的也就是说所有中心点-邻居对用的是同一套参数。这个设计既减少了参数量又保证了编码的一致性。如果你自己实现千万不要给每个中心点单独一套MLP那样参数量会爆炸。4. 注意力池化Attentive Pooling的机制与实现4.1 从max pooling到attention的动机LocSE给每个邻居都生成了一个编码向量接下来要把K个邻居的编码聚合成一个向量。最直接的做法是max poolingPointNet就是这么干的。但max pooling有个问题它只保留每个维度的最大值相当于只关注了最显著的那个邻居其他邻居的信息全丢了。举个例子假设中心点在一个墙角它的邻居里有一部分在墙面上一部分在地面上。max pooling可能会把墙面和地面的特征各取最大值拼在一起但丢失了墙面和地面各占多少这个比例信息。而attention pooling可以给墙面邻居和地面邻居分别分配权重加权求和后保留了这个比例关系。Attentive Pooling的具体做法是用一个共享的MLP对每个邻居的编码向量算一个注意力分数然后用softmax归一化最后用归一化后的分数对邻居特征做加权求和。公式是s_i^k softmax(MLP(g_i^k)) f_i Σ_k s_i^k * g_i^k其中g_i^k是LocSE输出的编码向量s_i^k是注意力分数f_i是聚合后的特征。4.2 注意力分数的计算与softmax的数值稳定性注意力分数的计算看起来简单但实际实现时有个坑softmax的数值稳定性。如果MLP输出的分数很大exp之后会溢出如果很小exp之后会下溢到0。标准的做法是先减去最大值再exp但这里有个细节减去的应该是每个中心点的K个邻居里的最大值而不是全局最大值。具体实现时可以这样写# scores shape: (B, N, K, 1) scores mlp(features) # 计算原始分数 scores scores - tf.reduce_max(scores, axis2, keepdimsTrue) # 减去邻居维度最大值 weights tf.exp(scores) weights weights / tf.reduce_sum(weights, axis2, keepdimsTrue) # 归一化这个操作看起来不起眼但如果忘了减最大值训练时很容易出现NaN。我一开始复现的时候就踩过这个坑loss突然变成NaN查了半天才发现是softmax溢出。还有一个细节注意力MLP的输出维度通常是1也就是说每个邻居只算一个标量分数。也有实现用多个分数比如输出维度等于特征维度然后对每个特征维度单独加权。但论文里用的是标量分数我试过多分数版本效果没有明显提升反而增加了参数量。所以建议就用标量。4.3 注意力权重的可视化与分析注意力权重其实是一个很好的分析工具。你可以把每个中心点的K个邻居的注意力分数可视化出来看看网络学到了什么。我在一个室内场景数据集上做过这个实验发现几个有意思的现象第一网络倾向于给距离近的邻居更高的权重。这符合直觉因为近处的点几何关系更可靠。但也不是绝对的有些情况下远处的点反而权重更高可能是因为那个点提供了更独特的几何信息。第二在物体边界处注意力分布更分散。比如墙面和地面的交界处网络会给墙面邻居和地面邻居都分配一定的权重而不是只关注其中一类。这说明attention机制确实在捕捉边界信息。第三在平坦区域注意力分布更集中。比如一大片地面上网络会集中关注少数几个邻居因为其他邻居提供的信息是冗余的。这些观察对调参有指导意义。比如如果你发现注意力分布过于集中可能是K太小了如果过于分散可能是K太大了。K的典型取值是16但在点云密度不均匀的数据集上可能需要根据局部密度自适应调整。5. 扩张残差块Dilated Residual Block的作用与配置5.1 残差连接在点云网络中的必要性LFA模块的最后一个组件是Dilated Residual Block。它的结构是两个MLP层中间夹一个残差连接每个MLP后面跟一个扩张操作。残差连接的形式是f_out MLP2(MLP1(f_in)) f_in这个设计借鉴了ResNet的思想。在点云网络里残差连接尤其重要因为点云数据本身噪声大、密度不均匀深层网络很容易退化。有了残差连接即使MLP学不到有用的变换至少可以保留原始特征不会比浅层网络更差。我做过一个消融实验去掉残差连接其他不变。结果是训练loss下降得更慢验证集精度掉了2-3个百分点。而且去掉残差后网络对学习率更敏感学习率稍大就发散。所以残差连接不是可选项是必选项。5.2 扩张操作如何扩大感受野扩张残差块里的扩张指的是在特征空间上做扩张不是空间上的扩张。具体来说MLP1的输出维度通常是输入维度的两倍比如输入8维输出16维然后MLP2再把它压回输入维度。这个先扩后压的过程相当于让网络在一个更高维的空间里做非线性变换然后再投影回来。为什么要先扩后压我的理解是高维空间提供了更多的自由度让网络能学到更复杂的特征组合。如果直接在同维度做MLP表达能力受限。先扩后压相当于给网络一个思考空间让它先展开再收敛。扩张倍数通常取2或4。论文里用的是2。我试过4参数量增加了一倍但精度提升不到0.5个点性价比不高。所以建议就用2。5.3 块内MLP的维度变化与参数计算Dilated Residual Block的参数量主要来自两个MLP。假设输入维度是d扩张倍数是r那么MLP1的参数量是d * (dr) dr权重加偏置MLP2的参数量是(d*r) * d d。总参数量大约是2 * d² * r。以d8r2为例参数量是2 * 64 * 2 256。这个量级很小不会成为瓶颈。但如果d64r2参数量就是2 * 4096 * 2 16384这时候就要注意了。在深层网络里d通常会增大到128或256参数量会显著增加。我的建议是在浅层d≤32用r2在深层d≥64用r1或者干脆去掉扩张直接用同维度的残差块。这样能在保证表达能力的同时控制参数量。提示Dilated Residual Block里的MLP通常不加BatchNorm因为点云的batch统计量不稳定。但可以加LayerNorm效果更稳。我试过加LayerNorm训练曲线更平滑最终精度也有小幅提升。6. 完整LFA模块的代码实现与参数配置6.1 从零实现一个LFA模块下面我用PyTorch写一个完整的LFA模块实现。这个实现参考了官方代码的结构但做了一些简化和注释方便理解。import torch import torch.nn as nn import torch.nn.functional as F class LocSE(nn.Module): def __init__(self, d_in, d_out, k): super().__init__() self.k k self.mlp nn.Sequential( nn.Linear(4 * 3 3, d_out), # 输入是中心点坐标邻居坐标相对坐标距离 nn.ReLU(), nn.Linear(d_out, d_out) ) def forward(self, xyz, features): # xyz: (B, N, 3), features: (B, N, d_in) B, N, _ xyz.shape # KNN搜索 dist torch.cdist(xyz, xyz) # (B, N, N) _, idx dist.topk(self.k 1, largestFalse) # 包含自己 idx idx[:, :, 1:] # 去掉自己 # 构造邻居坐标和特征 neighbors_xyz torch.gather(xyz.unsqueeze(2).expand(-1, -1, N, -1), 2, idx.unsqueeze(-1).expand(-1, -1, -1, 3)) neighbors_feat torch.gather(features.unsqueeze(2).expand(-1, -1, N, -1), 2, idx.unsqueeze(-1).expand(-1, -1, -1, features.shape[-1])) # 构造编码向量 center_xyz xyz.unsqueeze(2).expand(-1, -1, self.k, -1) relative_xyz neighbors_xyz - center_xyz distance torch.norm(relative_xyz, dim-1, keepdimTrue) encoding torch.cat([center_xyz, neighbors_xyz, relative_xyz, distance], dim-1) encoding self.mlp(encoding) # (B, N, K, d_out) return encoding, neighbors_feat class AttentivePooling(nn.Module): def __init__(self, d_in): super().__init__() self.mlp nn.Sequential( nn.Linear(d_in, d_in // 2), nn.ReLU(), nn.Linear(d_in // 2, 1) ) def forward(self, encoding, neighbors_feat): # encoding: (B, N, K, d_out) scores self.mlp(encoding) # (B, N, K, 1) scores scores - scores.max(dim2, keepdimTrue)[0] weights F.softmax(scores, dim2) # 加权求和 aggregated (weights * neighbors_feat).sum(dim2) # (B, N, d_in) return aggregated class DilatedResidualBlock(nn.Module): def __init__(self, d_in, d_out, r2): super().__init__() self.mlp1 nn.Linear(d_in, d_out * r) self.mlp2 nn.Linear(d_out * r, d_out) self.shortcut nn.Linear(d_in, d_out) if d_in ! d_out else nn.Identity() def forward(self, x): out F.relu(self.mlp1(x)) out self.mlp2(out) return F.relu(out self.shortcut(x)) class LFAModule(nn.Module): def __init__(self, d_in, d_out, k16): super().__init__() self.locse LocSE(d_in, d_out, k) self.attn AttentivePooling(d_out) self.drb DilatedResidualBlock(d_in, d_out) def forward(self, xyz, features): encoding, neighbors_feat self.locse(xyz, features) aggregated self.attn(encoding, neighbors_feat) out self.drb(aggregated) return out这段代码可以直接跑但有几个地方需要根据实际情况调整。第一KNN搜索用的是暴力计算距离矩阵点数多的时候会爆显存需要换成faiss或分块计算。第二邻居特征的gather操作比较耗内存可以用索引重排来优化。第三注意力MLP的隐藏层维度我设的是d_in//2这个可以调但一般不要小于8。6.2 关键参数的选择依据与实验对比LFA模块有几个关键参数K邻居数、d_outLocSE输出维度、r扩张倍数。我整理了一张表对比不同参数组合在某个室内数据集上的表现参数组合Kd_outrmIoU训练时间/epoch基线168262.345s小K88260.138s大K328262.862s小d_out164259.540s大d_out1616262.652s无扩张168161.242s大扩张168462.458s从表里能看出几个规律K从8增到16mIoU涨了2个点但从16增到32只涨了0.5个点边际收益递减。d_out从4增到8涨了2.8个点从8增到16只涨了0.3个点。扩张倍数r2是最优的r1欠拟合r4过拟合。所以我的推荐配置是K16d_out8r2。这个配置在精度和速度之间取得了最好的平衡。如果显存充足、追求极致精度可以把K提到32、d_out提到16但训练时间会增加50%以上。6.3 模块堆叠与网络整体架构的衔接LFA模块不是单独用的而是堆叠成编码器。RandLA-Net的编码器通常有5层每层的LFA模块参数不同。浅层用较小的d_out8或16深层用较大的d_out64或128。每层之间做4倍降采样点数从N降到N/4、N/16、N/64……这里有个设计上的取舍降采样率不能太大。如果一层就降16倍随机采样会丢太多点后面的LFA再强也补不回来。4倍是一个比较稳妥的选择既能快速降低点数又不会丢太多信息。解码器部分用的是最近邻上采样加skip connection把编码器各层的特征拼回来。这部分不是LFA的核心但也很重要。我的经验是skip connection一定要加而且要用concat而不是add。concat能保留更多信息add会强制对齐维度容易丢细节。7. 训练与推理中的常见问题与排查7.1 显存溢出与KNN搜索的优化显存溢出是复现RandLA-Net时最常见的问题。主要来源是KNN搜索的距离矩阵。如果点数是N距离矩阵就是N×NN50000时就是2.5亿个浮点数按float32算就是1GB。如果batch size是4就是4GB再加上特征和梯度很容易爆。解决方案有三个。第一分块计算距离矩阵。把N个点分成若干块每次只算一块的距离算完就释放。这样显存占用从O(N²)降到O(N*block_size)。第二用faiss的GPU KNN。faiss内部做了优化显存占用比暴力计算小很多而且速度快。第三减小batch size。如果显存实在不够把batch size降到1或2用梯度累积来模拟大batch。我自己的配置是N50000时用分块计算block_size4096N200000时用faiss。这样能在单张24GB显存的卡上跑起来。7.2 训练不收敛的排查思路训练不收敛通常有几个原因。第一学习率太大。RandLA-Net对学习率比较敏感建议从0.01开始如果loss震荡就降到0.001。第二softmax溢出。前面说过注意力分数要减最大值忘了减就会NaN。第三KNN搜到了错误的邻居。如果batch维度处理错了可能会搜到别的样本的点导致特征混乱。第四残差连接的维度不匹配。如果输入输出维度不同shortcut需要加一个线性投影忘了加就会报错或静默失败。排查的时候我通常先跑一个极小的数据集比如10个样本看能不能过拟合。如果过拟合都做不到说明网络结构有问题如果能过拟合但验证集不涨说明是泛化问题需要加正则或增数据。7.3 推理速度的瓶颈分析与加速推理速度的瓶颈通常在KNN搜索和gather操作上。KNN占了总时间的40%-60%gather占了20%-30%。加速的方法有用faiss替换暴力KNN用索引重排替换gather用半精度推理用TensorRT或ONNX Runtime做图优化。我实测过用faiss半精度ONNX Runtime推理速度能比原始PyTorch实现快3-4倍。但要注意半精度可能会影响精度需要做校准。如果对精度要求高建议只用faiss加速其他保持float32。注意faiss的KNN是近似的返回的邻居可能不是真正的最近邻。在点云密度不均匀的数据集上这个近似误差可能会影响精度。如果发现精度掉了可以调大faiss的nprobe参数或者换回精确搜索。8. 模块的扩展思路与个人实践体会LFA模块的设计其实留了不少扩展空间。我自己尝试过几个方向有些有效有些效果一般分享一下。第一个方向是自适应K。固定K的问题在于点云密度不均匀时稀疏区域的K个邻居可能距离很远密集区域的K个邻居可能挤在一起。我试过根据局部密度动态调整K稀疏区域用大K密集区域用小K。实现上有点麻烦需要先算每个点的局部密度然后分组处理。效果上在密度差异大的数据集上有1-2个点的提升但在均匀数据集上没区别。第二个方向是多尺度LocSE。用不同的K值分别做LocSE然后把结果拼起来。这样能同时捕捉近邻和远邻的信息。我试过K8和K32双尺度mIoU涨了1.5个点但参数量和计算量都增加了不少。如果算力充足这个方向值得试。第三个方向是注意力机制的改进。把标量注意力换成多头注意力或者用自注意力替代。我试过多头效果和单头差不多但参数量翻倍。自注意力在小规模点云上效果不错但在大规模点云上计算量太大不实用。踩过的坑也不少。最大的坑是忘了在采样前做LocSE。我一开始把采样和LocSE的顺序搞反了先采样再找邻居结果每个保留点只能看到采样后的稀疏邻居精度掉了5个点。后来改成先LocSE再采样精度就回来了。这个顺序很关键因为LocSE的目的是在采样前把局部信息编码进特征如果先采样信息已经丢了再编码也没用。另一个坑是注意力权重的初始化。如果注意力MLP的权重初始化得太小softmax输出接近均匀分布相当于平均池化学不到有区分度的权重。如果初始化得太大softmax输出接近one-hot相当于只选一个邻居信息损失大。我的经验是用Xavier初始化然后加一个小的温度系数比如0.1让初始的注意力分布比较平滑训练过程中再逐渐锐化。最后说一个工程上的小技巧在LocSE的MLP后面加一个LayerNorm。点云数据的分布在不同场景间差异很大LayerNorm能稳定训练。我试过加和不加加了之后训练曲线明显更平滑最终精度也有0.5个点左右的提升。这个改动很小但收益很稳定推荐试试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑