解码PointNet:使用Python和PyTorch进行3D分割的实用指南

Python
229
0
0
2024-03-07

准备好探索3D分割的世界吧!让我们一起完成PointNet的旅程,探索一种理解3D形状的超酷方式。PointNet就像是计算机观察3D物体的智能工具,特别是对于那些在空间中漂浮的点云。与其他方法不同,PointNet直接处理这些点,不需要将它们强行转换成网格或图片。

在本文中,我们将以简单易懂的方式介绍PointNet。我们将从核心思想出发,通过Python和PyTorch的编程实践来进行3D分割。但在我们深入探讨这个有趣的主题之前,我们需要先了解一下PointNet的基本概念 —— 它是如何成为解决识别3D物体(及其部分)的重要工具的。

现在,我们一起来看一下PointNet论文的总结。我们将讨论其设计思路、背后的概念和实验,以及PointNet在实际应用中的表现。我们将以简洁明了的方式展示随机点、特殊函数以及PointNet在处理不同的3D任务时的优势。

01 理解PointNet:核心概念

PointNet就像是一种特殊的工具,它帮助计算机理解3D物体,特别是那些棘手的点云数据。但是,是什么让它如此炫酷呢?与其他整理数据的方法不同,PointNet直接使用点云数据本身,无需网格或图片。这使得它在3D视觉领域脱颖而出。

点集的基础知识:

想象一堆点在3D空间中漂浮。这些点没有特定的顺序,它们相互作用。PointNet通过对其旋转或移动等变化简单地处理这种随机性。当这些点的位置转换时,不会令人困惑。

PointNet的特殊能力:对称魔术

PointNet具有一种特殊的能力,称为对称性。想象一下,你有一堆点,无论你如何洗牌,PointNet仍然能够理解其中的内容。对于不遵循特定顺序的点来说,这就像是一种魔法。

收集局部和全局信息

PointNet在收集信息方面很聪明。它可以同时关注点的整体情况(全局)和细节(局部)。这有助于它完成诸如确定物体形状和其部分的任务。

对齐技巧

PointNet也擅长处理变化。如果你旋转或移动点,PointNet可以自动调整并正常理解事物。就像一个将物体对齐以清晰看到它们的机器人。

1.1 理论的魔力

现在,让我们谈谈PointNet背后的一些大背景思想。有两个特殊的定理表明PointNet不仅在实践中很酷,而且在理论上也是一个明智的选择。

1)通用逼近:

PointNet可以学会很好地理解任何3D形状。就好像说PointNet是一个超级英雄,可以处理你投入其中的任何形状。

2)瓶颈维度和稳定性:

-PointNet是坚固的。即使你添加了一些额外的点或修改了已有的点,它也不会困惑。它坚持自己的工作,并保持稳定。

这样的大背景思想使PointNet成为理解3D形状的值得信赖的工具。

1.2 PointNet体系结构概述

PointNet体系结构由两个主要组件组成:分类网络和扩展分割网络。

分类网络接收n个输入点,应用输入和特征变换,并通过最大池化聚合点特征。它生成k个类别的分类分数。分割网络是分类网络的自然扩展,将全局和局部特征组合起来生成每个点的分数。术语“mlp”表示多层感知器,其层大小在方括号中指定。批量归一化一致应用于所有层,并使用ReLU激活函数。此外,dropout层还巧妙地加入到分类网络的最终mlp中。

图片

在提供的代码片段中,该类封装了对批量归一化卷积层输出应用ReLU激活函数的操作。这与体系结构图中描述的卷积层和mlp层相对应。让我们仔细看看代码:MLP_CONV

# Multi Layer Perceptron
class MLP_CONV(nn.Module):
   def __init__(self, input_size, output_size):
     super().__init__()
     self.input_size   = input_size
     self.output_size  = output_size
     self.conv  = nn.Conv1d(self.input_size, self.output_size, 1)
     self.bn    = nn.BatchNorm1d(self.output_size)

   def forward(self, input):
     return F.relu(self.bn(self.conv(input)))

该类定义对应于体系结构的构建块,其中卷积层、批量归一化和ReLU激活被组合在一起以实现所需的特征变换。此外,当使用全连接层时,下面描述的这个类可以为该体系结构提供补充。FC_BN

# Fully Connected with Batch Normalization
class FC_BN(nn.Module):
   def __init__(self, input_size, output_size):
     super().__init__()
     self.input_size   = input_size
     self.output_size  = output_size
     self.lin  = nn.Linear(self.input_size, self.output_size)
     self.bn = nn.BatchNorm1d(self.output_size)

   def forward(self, input):
     return F.relu(self.bn(self.lin(input)))

该类进一步说明了如何将全连接层与批量归一化和ReLU激活相结合,强调了在PointNet体系结构中一致应用这些技术的重要性。

1.3 输入和特征变换

输入变换网络,也称为TNet(小型PointNet),在处理原始点云方面起到了关键作用。它通过一系列操作旨在回归到一个3×3的矩阵。网络的架构由一个共享的MLP(64,128,1024)对每个点应用,然后通过点进行最大池化,再经过两个输出大小为512和256的全连接层。生成的矩阵初始化为单位矩阵。除最后一层外,每个层都使用ReLU激活和批量归一化。第二个变换网络与第一个的架构相同,但输出一个64×64的矩阵,同样被初始化为单位矩阵。为了促进正交性,对softmax分类损失添加了一个权重为0.001的正则化损失。

该类用于根据论文中提供的规格创建变换网络:TNet。

# Transformation Network (TNet) class
class TNet(nn.Module):
   def __init__(self, k=3):
      super().__init__()
      self.k = k

      self.mlp1 = MLP_CONV(self.k, 64)
      self.mlp2 = MLP_CONV(64, 128)
      self.mlp3 = MLP_CONV(128, 1024)

      self.fc_bn1 = FC_BN(1024, 512)
      self.fc_bn2 = FC_BN(512, 256)

      self.fc3 = nn.Linear(256, k*k)

   def forward(self, input):
      # input.shape == (batch_size, n, 3)

      bs = input.size(0)
      xb = self.mlp1(input)
      xb = self.mlp2(xb)
      xb = self.mlp3(xb)

      pool = nn.MaxPool1d(xb.size(-1))(xb)
      flat = nn.Flatten(1)(pool)

      xb = self.fc_bn1(flat)
      xb = self.fc_bn2(xb)

      # initialize as identity
      init = torch.eye(self.k, requires_grad=True).repeat(bs, 1, 1)
      if xb.is_cuda:
        init = init.cuda()
      matrix = self.fc3(xb).view(-1, self.k, self.k) + init
      return matrix

该类封装了将输入点云转换为3×3或64×64矩阵的过程,利用了共享的MLP、最大池化和带有批量归一化的全连接层.TNet

1.4 PointNet网络

PointNet网络,封装在这个类中,遵循了PointNet架构图中的设计原则:PointNet

class PointNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.input_transform = TNet(k=3)
        self.feature_transform = TNet(k=64)
        self.mlp1 = MLP_CONV(3, 64)
        self.mlp2 = MLP_CONV(64, 128)
        
        # 1D convolutional layer with kernel size 1
        self.conv = nn.Conv1d(128, 1024, 1)
        
        # Batch normalization for stability and faster training
        self.bn = nn.BatchNorm1d(1024)

    def forward(self, input):
        n_pts = input.size()[2]
        matrix3x3 = self.input_transform(input)
        input_transform_output = torch.bmm(torch.transpose(input, 1, 2), matrix3x3).transpose(1, 2)
        x = self.mlp1(input_transform_output)
        matrix64x64 = self.feature_transform(x)
        feature_transform_output = torch.bmm(torch.transpose(x, 1, 2), matrix64x64).transpose(1, 2)
        x = self.mlp2(feature_transform_output)
        x = self.bn(self.conv(x))
        global_feature = nn.MaxPool1d(x.size(-1))(x)
        global_feature_repeated = nn.Flatten(1)(global_feature).repeat(n_pts, 1, 1).transpose(0, 2).transpose(0, 1)

        return [feature_transform_output, global_feature_repeated], matrix3x3, matrix64x64

这个PointNet实现无缝地集成了TNet转换网络、多层感知器(MLP)和带有批量归一化的一维卷积层。前向传播处理输入和特征变换,然后提取全局特征。生成的张量连同变换矩阵一起作为输出返回.MLP_CONV

1.5 PointNet分割网络

分割网络是在分类的PointNet基础上扩展而来的。每个点的局部点特征来自第二个转换网络和最大池化的全局特征,这些特征被串联起来。分割网络中不使用dropout,并且训练参数与分类网络保持一致。

对于形状部分分割,修改包括添加一个指示输入类别的独热向量,与最大池化层的输出连接。某些层中的神经元数量增加,添加了跳跃连接来收集不同层中的局部点特征,并将它们串联起来形成分割网络的点特征输入。

class PointNetSeg(nn.Module):
    def __init__(self, classes=3):
        super().__init__()
        self.pointnet = PointNet()
        self.mlp1 = MLP_CONV(1088, 512)
        self.mlp2 = MLP_CONV(512, 256)
        self.mlp3 = MLP_CONV(256, 128)
        self.conv = nn.Conv1d(128, classes, 1)
        self.logsoftmax = nn.LogSoftmax(dim=1)
    
    def forward(self, input):
        inputs, matrix3x3, matrix64x64 = self.pointnet(input)
        stack = torch.cat(inputs, 1)
        x = self.mlp1(stack)
        x = self.mlp2(x)
        x = self.mlp3(x)
        output = self.conv(x)
        return self.logsoftmax(output), matrix3x3, matrix64x64

在这个类中,前向传播将从PointNet中获取的特征进行串联,然后通过一系列的多层感知器(MLP)和卷积层进行传递。在应用LogSoftmax激活函数之后,得到最终输出.PointNetSegMLP_CONV

02 训练和测试PointNet模型

在我们的模型训练过程中,我们利用了著名的Semantic-Kitti数据集中的点云来发挥PointNet的威力。这个有影响力的数据集捕捉了各种城市场景,最初包含大约30个标签。然而,为了我们的目的,我们谨慎地将它们重新映射成三个类别:

· 可通行:包括道路、停车场、人行道等。

· 不可通行:包括汽车、卡车、栅栏、树木、人和各种物体。

· 未知:保留给异常值。

重新映射的过程涉及使用键值字典将原始标签转换为简化的标签。为了可视化着色的点云,我们使用了Open3D Python包。左图展示了Semantic-Kitti原始的颜色方案,而右图显示了重新映射的颜色方案。

图片

您可以在这里找到用于加载和可视化数据的代码。(https://github.com/sepideh-shamsizadeh/3DP-Point-Cloud-Segmentation/blob/1d3a874919988c2c508ac64934566fa02f1060ce/data_processing.py)

2.1 数据转换

在准备数据的关键步骤中,我们需要通过自定义转换进行归一化和张量转换。主要使用了两种转换操作:

归一化(Normalize):该操作将点云进行归中处理,通过减去其均值并进行缩放,以确保最大范数为单位。

class Normalize(object):
    def __call__(self, pointcloud):
        assert len(pointcloud.shape)==2

        norm_pointcloud = pointcloud - np.mean(pointcloud, axis=0)
        norm_pointcloud /= np.max(np.linalg.norm(norm_pointcloud, axis=1))

        return  norm_pointcloud

ToTensor:此转换将点云转换为 PyTorch 张量。

class ToTensor(object):
    def __call__(self, pointcloud):
        assert len(pointcloud.shape)==2

        return torch.from_numpy(pointcloud)

这些转换的组合被封装在函数 default_transforms() 中。

2.2 点云数据集

然后,我们创建了一个自定义数据集 PointCloudDataset,扩展了 PyTorch 的类。该数据集表示用于训练和测试的点云集合。其结构包括:

- 使用数据集详细信息和可选的转换函数进行初始化。

- 定义数据集的长度。

- 检索一个数据项,并在指定的情况下应用转换。

class PointCloudData(Dataset):
    def __init__(self, dataset_path, transform=default_transforms(), start=0, end=1000):
        """
          INPUT
              dataset_path: path to the dataset folder
              transform   : transform function to apply to point cloud
              start       : index of the first file that belongs to dataset
              end         : index of the first file that do not belong to dataset
        """
        self.dataset_path = dataset_path
        self.transforms = transform

        self.pc_path = os.path.join(self.dataset_path, "sequences", "00", "velodyne")
        self.lb_path = os.path.join(self.dataset_path, "sequences", "00", "labels")

        self.pc_paths = os.listdir(self.pc_path)
        self.lb_paths = os.listdir(self.lb_path)
        assert(len(self.pc_paths) == len(self.lb_paths))

        self.start = start
        self.end   = end

        # clip paths according to the start and end ranges provided in input
        self.pc_paths = self.pc_paths[start: end]
        self.lb_paths = self.lb_paths[start: end]

    def __len__(self):
        return len(self.pc_paths)

    def __getitem__(self, idx):
      item_name = str(idx + self.start).zfill(6)
      pcpath = os.path.join(self.pc_path, item_name + ".bin")
      lbpath = os.path.join(self.lb_path, item_name + ".label")

      # load points and labels
      pointcloud, labels = readpc(pcpath, lbpath)

      # transform
      torch_pointcloud  = torch.from_numpy(pointcloud)
      torch_labels      = torch.from_numpy(labels)

      return torch_pointcloud, torch_labels

2.3 数据集创建

有了数据集类,我们实例化了训练、验证和测试数据集。这不仅提供了有结构的组织,还为使用 PyTorch 的 DataLoader 模块提供了高效的基础。

train_ds = PointCloudData(dataset_path, start=0, end=100)
val_ds = PointCloudData(dataset_path, start=100, end=120)
test_ds = PointCloudData(dataset_path, start=120, end=150)

2.4 DataLoader 的使用

利用 PyTorch 的 DataLoader 的功能,我们可以实现批处理、随机化和并行加载等功能。

train_loader = DataLoader(dataset=train_ds, batch_size=5, shuffle=True)
val_loader = DataLoader(dataset=val_ds, batch_size=5, shuffle=False)
test_loader = DataLoader(dataset=test_ds, batch_size=1, shuffle=False)

这种对数据集创建和加载的细致处理不仅对于基本问题有好处,而且在数据集和训练过程的复杂性增加时变得不可或缺。它为训练和测试过程中的高效、可扩展和并行化数据处理奠定了基础。

2.5 损失函数

在神经网络训练领域,损失函数在引导模型参数更新方面起着至关重要的作用。我们的 PointNet 模型采用了一个精心设计的损失函数,受以下论文中提供的见解的影响:

“我们在 softmax 分类损失上添加了一个正则化损失(权重为 0.001),使得矩阵接近正交。”

该损失函数的代码表达如下:

def pointNetLoss(outputs, labels, m3x3, m64x64, alpha=0.0001):
    criterion = torch.nn.NLLLoss()
    bs =  outputs.size(0)
    id3x3 = torch.eye(3, requires_grad=True).repeat(bs, 1, 1)
    id64x64 = torch.eye(64, requires_grad=True).repeat(bs, 1, 1)
    
    # Check if outputs are on CUDA
    if outputs.is_cuda:
        id3x3 = id3x3.cuda()
        id64x64 = id64x64.cuda()
    
    # Calculate matrix differences
    diff3x3 = id3x3 - torch.bmm(m3x3, m3x3.transpose(1, 2))
    diff64x64 = id64x64 - torch.bmm(m64x64, m64x64.transpose(1, 2))
    
    # Compute the loss
    return criterion(outputs, labels) + alpha * (torch.norm(diff3x3) + torch.norm(diff64x64)) / float(bs)

2.6 细分各个组件

· outputs:模型的预测结果。

· labels:真实标签。

· m3x3 和 m64x64:来自 PointNet 转换网络的矩阵。

· alpha:正则化项的权重。

这个损失函数将标准的负对数似然(NLL)损失与正则化项相结合。正则化项惩罚了转换矩阵与正交性的偏差,符合论文中对于实现正交性的强调。

通过精心设计,我们的 PointNet 模型不仅在分类精度上表现出色,而且符合结构约束,在训练过程中增强了其鲁棒性和泛化能力。

2.7 训练循环

训练循环是一个顺序过程,迭代地更新 PointNet 模型的权重。它由一定数量的 epochs 组成,每个 epochs 包括一个训练阶段和一个可选的验证阶段。在这些阶段中,模型在训练和评估状态之间交替。

def train(pointnet, optimizer, train_loader, val_loader=None, epochs=15, save=True):
    best_val_acc = -1.0

    for epoch in range(epochs):
        pointnet.train()
        running_loss = 0.0

        # Training phase
        for i, data in enumerate(train_loader, 0):
            inputs, labels = data
            inputs = inputs.to(device).float()
            labels = labels.to(device)
            optimizer.zero_grad()

            outputs, m3x3, m64x64 = pointnet(inputs.transpose(1, 2))
            loss = pointNetLoss(outputs, labels, m3x3, m64x64)
            loss.backward()
            optimizer.step()

            running_loss += loss.item()
            if i % 10 == 9 or True:
                print('[%d, %5d] loss: %.3f' % (epoch + 1, i + 1, running_loss / 10))
                running_loss = 0.0

        # Validation phase
        pointnet.eval()
        correct = total = 0

        with torch.no_grad():
            for data in val_loader:
                inputs, labels = data
                inputs = inputs.to(device).float()
                labels = labels.to(device)
                outputs, __, __ = pointnet(inputs.transpose(1, 2))
                _, predicted = torch.max(outputs.data, 1)

                total += labels.size(0) * labels.size(1)
                correct += (predicted == labels).sum().item()

        print("correct", correct, "/", total)
        val_acc = 100.0 * correct / total
        print('Valid accuracy: %d %%' % val_acc)

        # Save the model if current validation accuracy surpasses the best
        if save and val_acc > best_val_acc:
            best_val_acc = val_acc
            path = os.path.join('', "MyDrive", "pointnetmodel.yml")
            print("best_val_acc:", val_acc, "saving model at", path)
            torch.save(pointnet.state_dict(), path)

# Initialize the optimizer
optimizer = torch.optim.Adam(pointnet.parameters(), lr=0.005)

# Commence the training
train(pointnet, optimizer, train_loader, val_loader, save=True)

该循环作为一个系统化的框架,用于在多次迭代中更新模型参数、监控损失并评估性能。

2.8 测试

该函数旨在分析模型在测试阶段的性能。它计算真实标签中不同类别的出现次数(unk,trav,nontrav),计算预测的总数,并统计正确预测的数量。结果以一个元组的形式返回.compute_statsunktravnontrav(correct, total_predictions)

def compute_stats(true_labels, pred_labels):
  unk     = np.count_nonzero(true_labels == 0)
  trav    = np.count_nonzero(true_labels == 1)
  nontrav = np.count_nonzero(true_labels == 2)

  total_predictions = labels.shape[1]*labels.shape[0]
  correct = (true_labels == pred_labels).sum().item()

  return correct, total_predictions

03 结论

PointNet 是一种突破性的用于 3D 分割的工具,克服了无序点集带来的挑战。其理论基础、架构设计和实际实现展示了其多功能性和可靠性。通过将理论与实践相结合,我们揭开了理解和利用 PointNet 进行 3D 分割的过程的神秘面纱。PyTorch 和 Python 的整合为在实际应用中探索 PointNet 的潜力提供了一个实用的框架。你可以在我的 GitHub 上找到所有的代码。

值得一提的是,这个项目是我在帕多瓦大学攻读硕士学位期间 3D 视觉课程的关键部分。大部分代码是由课程导师 Alberto Pretto 教授慷慨提供的。我负责完成代码,并添加了解释,以简化对于实现 PointNet 网络用于 3D 分割的过程感兴趣的人们。我真诚希望你们能够从这个指南中获得有益和愉快的体验。