一、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 的做法是:把全局特征拼接到每个点的局部特征上

具体流程:

  1. 每个点经过 MLP 得到局部特征(64维)
  2. 所有局部特征经过最大池化得到全局特征(1024维)
  3. 将全局特征复制 n 份,与每个点的局部特征拼接
  4. 再经过 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 的整体流程。你可以看到数据从原始点云到分类/分割输出的完整路径。

原始点云 (B, 3, N) T-Net 3×3变换 MLP 3→64 T-Net 64×64变换 MLP 64→1024 最大池化 全局特征 分类输出 拼接(64+1024) 分割MLP 分割输出 输入/输出 T-Net MLP 池化 拼接

六、训练技巧与避坑指南

问题 现象 解决方案
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 的影子?

嗯,这一章就到这里。代码我已经放在课程仓库里了,你可以直接跑起来试试。记住,理解原理比跑通代码更重要


专注资料整理