一、PointNet 核心思想:为什么它敢直接处理点云?
2017年,PointNet 横空出世。说实话,当时做点云的人大多在用体素网格或者多视图投影。我刚开始接触点云时,也觉得「点云不就是一堆无序的点嘛,直接扔进网络能行?」
PointNet 的答案很简单:能行,但需要解决两个核心问题。
- 无序性:点云没有顺序,你交换两个点的位置,表示的还是同一个物体。
- 旋转不变性:一个椅子转个角度,它还是椅子。网络得认出来。
PointNet 用了一个非常优雅的思路:对每个点独立提取特征,然后用对称函数(比如最大池化)聚合全局信息。说白了,就是先让每个点自己「说话」,再选出最重要的声音。
核心公式(非对称函数):
f({x1, x2, ..., xn}) ≈ g( h(x1), h(x2), ..., h(xn) )
其中 h 是 MLP(多层感知机),g 是最大池化。这个组合保证了输出与输入顺序无关。
二、T-Net 空间变换网络:让点云学会「站正」
你想想看,如果点云旋转了,网络还能认出来吗?PointNet 的做法是:在特征提取之前,先学一个变换矩阵,把点云对齐到标准姿态。
这个模块叫 T-Net。它本质上是一个小型 PointNet,输入是原始点云,输出是一个 3×3 的仿射变换矩阵。
我的经验: 我在做自动驾驶场景分类时,发现 T-Net 对旋转敏感的场景特别有用。比如车辆点云,不同角度下形状差异很大,T-Net 能显著提升泛化能力。但要注意,训练初期 T-Net 容易不稳定,我建议加一个正则项,让变换矩阵尽量接近单位矩阵。
T-Net 的损失函数里有一个正则项:
L_reg = || I - A * A^T ||^2
这个约束让变换矩阵保持正交性,避免过度扭曲点云结构。
三、全局特征与局部特征:一个都不能少
PointNet 的全局特征是通过最大池化得到的。但问题来了:最大池化只保留了最显著的特征,局部细节怎么办?
比如分类任务,全局特征就够了。但分割任务呢?每个点都需要知道自己属于哪个部件。这时候,PointNet 的做法是:把全局特征拼接到每个点的局部特征上。
具体流程:
- 每个点经过 MLP 得到局部特征(64维)
- 所有局部特征经过最大池化得到全局特征(1024维)
- 将全局特征复制 n 份,与每个点的局部特征拼接
- 再经过 MLP 输出每个点的分类结果
注意: 这种拼接方式虽然简单,但有个缺点——全局特征会「淹没」局部细节。我在做精细分割时,发现 PointNet 对细小部件(比如椅子的螺丝)分割效果不好。后来改用 PointNet++ 才解决。
四、代码实战:PointNet 分类与分割
下面我给出一个精简版的 PointNet 实现。为了便于理解,我删除了部分细节,保留了核心结构。
4.1 分类网络
import torch
import torch.nn as nn
class PointNetClassifier(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# 输入变换
self.tnet1 = TNet(k=3)
# 特征提取 MLP
self.mlp1 = nn.Sequential(
nn.Conv1d(3, 64, 1),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Conv1d(64, 64, 1),
nn.BatchNorm1d(64),
nn.ReLU()
)
# 特征变换
self.tnet2 = TNet(k=64)
# 高维特征
self.mlp2 = nn.Sequential(
nn.Conv1d(64, 128, 1),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Conv1d(128, 1024, 1),
nn.BatchNorm1d(1024),
nn.ReLU()
)
# 分类头
self.fc = nn.Sequential(
nn.Linear(1024, 512),
nn.BatchNorm1d(512),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(512, 256),
nn.BatchNorm1d(256),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(256, num_classes)
)
def forward(self, x):
# x: (B, 3, N)
x = self.tnet1(x)
x = self.mlp1(x)
x = self.tnet2(x)
x = self.mlp2(x)
# 全局最大池化
x = torch.max(x, dim=2)[0] # (B, 1024)
x = self.fc(x)
return x
4.2 分割网络
class PointNetSegmenter(nn.Module):
def __init__(self, num_classes=50):
super().__init__()
# 共享分类网络的前半部分
self.tnet1 = TNet(k=3)
self.mlp1 = nn.Sequential(
nn.Conv1d(3, 64, 1),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Conv1d(64, 64, 1),
nn.BatchNorm1d(64),
nn.ReLU()
)
self.tnet2 = TNet(k=64)
self.mlp2 = nn.Sequential(
nn.Conv1d(64, 128, 1),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Conv1d(128, 1024, 1),
nn.BatchNorm1d(1024),
nn.ReLU()
)
# 分割头:拼接全局特征
self.seg_head = nn.Sequential(
nn.Conv1d(1088, 512, 1), # 64(局部) + 1024(全局) = 1088
nn.BatchNorm1d(512),
nn.ReLU(),
nn.Conv1d(512, 256, 1),
nn.BatchNorm1d(256),
nn.ReLU(),
nn.Conv1d(256, 128, 1),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Conv1d(128, num_classes, 1)
)
def forward(self, x):
# x: (B, 3, N)
x = self.tnet1(x)
local_feat = self.mlp1(x) # (B, 64, N)
x = self.tnet2(local_feat)
x = self.mlp2(x) # (B, 1024, N)
global_feat = torch.max(x, dim=2, keepdim=True)[0] # (B, 1024, 1)
global_feat = global_feat.repeat(1, 1, x.shape[2]) # (B, 1024, N)
# 拼接
concat_feat = torch.cat([local_feat, global_feat], dim=1) # (B, 1088, N)
out = self.seg_head(concat_feat)
return out
五、核心结构图
下面我用一张 SVG 图展示 PointNet 的整体流程。你可以看到数据从原始点云到分类/分割输出的完整路径。
六、训练技巧与避坑指南
| 问题 | 现象 | 解决方案 |
|---|---|---|
| T-Net 不收敛 | Loss 震荡,变换矩阵偏离单位矩阵 | 增大正则项权重,或先用单位矩阵初始化 |
| 过拟合 | 训练集准确率高,测试集低 | 增加 Dropout(0.3-0.5),数据增强(随机旋转、抖动) |
| 分割边缘模糊 | 物体边界处分类错误 | 增加局部特征维度,或改用 PointNet++ |
| 显存不足 | 点云数量太大(>10000点) | 随机采样到 2048 或 4096 点 |
我曾经踩过的坑: 有一次训练分割网络,发现 Loss 降不下去。排查了半天,原来是 T-Net 的变换矩阵学歪了,导致点云被扭曲。后来我加了一个正则项,并且每 10 个 epoch 打印一次变换矩阵,问题就解决了。所以,监控中间变量非常重要。
七、总结
PointNet 虽然简单,但它的思想影响深远。它证明了:直接处理点云是可行的,而且效果不差。T-Net 解决了旋转不变性,全局+局部特征解决了分类和分割的统一框架。
说实话,现在做点云的人很少直接用 PointNet 了,但它的设计哲学——对称函数、空间变换、特征拼接——几乎成了所有点云网络的基石。你想想看,PointNet++、PointCNN、甚至 Transformer 类的点云模型,哪个没有 PointNet 的影子?
嗯,这一章就到这里。代码我已经放在课程仓库里了,你可以直接跑起来试试。记住,理解原理比跑通代码更重要。