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

这篇文章主要介绍 3D 分割。这篇文章主要带你深入了解 PointNet,这是一种理解 3D 形状的方法。PointNet 就像是计算机用来观察 3D 物体的智能工具,特别是空间中漂浮的点云数据。它与其他方法不同,因为它直接处理这些点,而不需要将它们强制转换成网格或图片。

语义分割:基于 TensorFlow 对 FCN 与迁移学习的探究

在本文中,我们将让 PointNet 变得通俗易懂。我们会从宏观概念讲起,一直深入到实际编写 Python 和 PyTorch 代码来进行 3D 分割。但在进入有趣的实操部分之前,让我们先弄清楚 PointNet 的核心内容------它是如何成为解决 3D 物体及其部件识别等难题的关键技术的。

介绍基于 C++ 的运动恢复结构(SfM)的三维重建

因此,请随我们一起回顾 PointNet 论文的概要。我们将探讨其架构设计、背后的精妙理论,以及它在真实实验中展现出的强大能力。在探索随机点云、特殊函数以及 PointNet 如何处理各种 3D 任务的世界时,我们会尽量保持简单易懂。准备好发现 PointNet 在 3D 分割中的强大威力了吗?让我们开始吧。

深入理解 PointNet:核心概念

现在我们已经铺平了道路,让我们将 PointNet 拆解成易于消化的小块。PointNet 就像一种特殊的工具,帮助计算机理解 3D 事物,特别是那些棘手的点云数据。但它到底酷在哪里呢?与其他将数据整齐归位的方法不同,PointNet 按照点的本来面目处理它们------不需要网格,也不需要图片。这让它在 3D 领域中脱颖而出。机器人学的第一部分------机器人如何理解空间:运动学与旋转矩阵

点集基础

想象一下,一群点漂浮在 3D 空间中。这些点没有特定的顺序,它们彼此交互。PointNet 通过适应旋转或移动等变化,来应对这种无序性。当点的位置互换时,它也不会感到困惑。

对称性魔法

PointNet 拥有一种特殊的能力,叫做对称性。想象你有一袋点,无论你怎么打乱它们,PointNet 依然能理解袋子里装的是什么。对于处理无序点云来说,这简直就像法术一样。

聚合局部与全局信息

PointNet 擅长收集信息。它既能看大局(全局),也能看细节(局部)。这有助于它完成诸如识别物体及其部件形状等任务。

对齐技巧

PointNet 也擅长处理变化。如果你旋转或移动点云,PointNet 也能调整过来,从容理解。这就像是一个机器人在自动对齐物体,以便能看清它们。

理论基石

现在,让我们谈谈 PointNet 背后的宏大理论。有两个特殊的定理表明,PointNet 不仅在实践中表现出色,在理论上也是一个明智的选择。解读openAI的文本图像模型-CLIP

  1. 通用近似性: PointNet 能够很好地学习和理解任何 3D 形状。这就好比说,PointNet 是一个超级英雄,无论你扔给它什么形状,它都能搞定。
  2. 瓶颈维度与稳定性: PointNet 非常坚韧。即使你添加了一些额外的点,或者干扰了它已有的点,它也不会感到困惑。它坚守岗位,保持稳定。

这些宏大的理论让 PointNet 成为了一个值得信赖的 3D 形状理解工具。下面,我们将从理论转向实践,亲自动手使用 Python 和 PyTorch 进行编码。

PointNet 架构概览

PointNet 架构由两个主要部分组成:分类网络 和一个扩展的分割网络

  • 分类网络 :接收 n 个输入点,应用输入和特征变换,并通过最大池化(max pooling)聚合点特征,最终为 k 个类别生成分类得分。
  • 分割网络:是分类网络的自然扩展,它结合了全局和局部特征,以生成每个点的得分。

术语"mlp"表示多层感知机(multi-layer perceptron),其层大小在方括号中指定。所有层都一致应用了批归一化(Batch normalization),并伴随 ReLU 激活函数。此外,在分类网络的最后 mlp 中,战略性地加入了 dropout 层。

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

复制代码
# 多层感知机(MLP)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 Normalizationclass 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 架构中的一致应用。

输入与特征变换

输入变换网络是一个被称为 TNet 的小型 PointNet,在处理原始点云数据方面起着关键作用。它旨在通过一系列操作回归出一个 3 × 3 的矩阵。该网络架构定义为:先对每个点应用共享的 MLP(64, 128, 1024),然后在点之间进行最大池化(max pooling),最后经过两个输出大小分别为 512 和 256 的全连接层。生成的矩阵被初始化为单位矩阵。每一层(最后一层除外)都包含了 ReLU 激活函数和批归一化。第二个变换网络的架构与第一个类似,但输出的是一个 64 × 64 的矩阵,并同样初始化为单位矩阵。此外,为了促进矩阵的正交性,向 softmax 分类损失中添加了一个权重为 0.001 的正则化损失。生成式AI vs 预测式AI:揭秘人工智能领域的两大技术

TNet 类被用于根据论文中提供的规范来创建变换网络:

复制代码
# Transformation Network (TNet) classclass 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

这个 TNet 类封装了将输入点云转换为 3 × 3 或 64 × 64 矩阵的过程,其内部利用了共享的多层感知机(MLP)、最大池化(max pooling)以及带有批归一化的全连接层。什么是无监督学习?理解人工智能中无监督学习的机制、各类算法的类型与应用

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_CONV)以及带有批归一化的 1D 卷积层。前向传播过程处理输入和特征变换,随后提取全局特征。最终返回的结果张量以及变换矩阵作为输出。

PointNet 分割网络:

分割网络是分类版 PointNet 的扩展。它将来自第二个变换网络的局部点特征与来自最大池化(max pooling)的全局特征进行拼接,为每个点生成特征。分割网络中不使用 Dropout,且训练参数与分类网络保持一致。

对于形状部件分割(shape part segmentation),进行了以下修改:包括添加一个指示输入类别的独热编码(one-hot)向量,并将其与最大池化层的输出进行拼接;增加了某些层中的神经元数量;添加了跳跃连接(skip links)以收集不同层的局部点特征,并将它们拼接起来,形成分割网络的点特征输入。

复制代码
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

PointNetSeg 类

PointNetSeg 类中,前向传播过程整合了从 PointNet 获取的特征,将它们进行拼接,然后通过一系列多层感知机(MLP_CONV)和一个卷积层。最终在应用 LogSoftmax 激活函数后获得输出。

训练与测试 PointNet 模型

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

  • 可通行:包括道路、停车位、人行道等。
  • 不可通行:包括汽车、卡车、围栏、树木、行人及各种物体。
  • 未知:保留给离群值(异常值)。

重新映射过程涉及使用键值(key-value)字典,将原始标签转换为其简化后的对应类别。为了可视化彩色点云,我们使用了 Open3D Python 包。左图展示了 Semantic-Kitti 的原始配色方案,而右图则揭示了重新映射后的配色方案。

下面是用于加载和可视化数据的代码。

复制代码
import numpy as npimport randomimport mathimport timeimport structimport os# pyTorch 相关导入import torchimport torch.nn as nnimport torch.nn.functional as Ffrom torch.utils.data import Dataset, DataLoaderfrom torchvision import transforms# 用于训练进度条的库from tqdm import tqdm# 可视化库import open3d as o3dimport plotly.graph_objects as gonumpoints = 20000 # [点云点数]max_dist = 15     # [米] 最大距离min_dist = 4      # [米] 最小距离# 将距离转换为平方(代码优化)max_dist *= max_distmin_dist *= min_distsize_float = 4       # 浮点数字节大小size_small_int = 2   # 短整型字节大小dataset_path =  "dataset"  # 数据集路径# Semantic-KITTI 颜色方案semantic_kitti_color_scheme = {0 : [0, 0, 0],        # "未标记"1 : [0, 0, 255],      # "异常点"10: [245, 150, 100],  # "汽车"11: [245, 230, 100],  # "自行车"13: [250, 80, 100],   # "公交车"15: [150, 60, 30],    # "摩托车"16: [255, 0, 0],      # "轨道车辆"18: [180, 30, 80],    # "卡车"20: [255, 0, 0],      # "其他车辆"30: [30, 30, 255],    # "行人"31: [200, 40, 255],   # "骑行者"32: [90, 30, 150],    # "摩托车骑行者"40: [255, 0, 255],    # "道路"44: [255, 150, 255],  # "停车区"48: [75, 0, 75],      # "人行道"49: [75, 0, 175],     # "其他地面"50: [0, 200, 255],    # "建筑物"51: [50, 120, 255],   # "围栏"52: [0, 150, 255],    # "其他结构"60: [170, 255, 150],  # "车道标线"70: [0, 175, 0],      # "植被"71: [0, 60, 135],     # "树干"72: [80, 240, 150],   # "地形"80: [150, 240, 255],  # "杆子"81: [0, 0, 255],      # "交通标志"99: [255, 255, 50],   # "其他物体"252: [245, 150, 100], # "移动汽车"253: [200, 40, 255],  # "移动骑行者"254: [30, 30, 255],   # "移动行人"255: [90, 30, 150],   # "移动摩托车骑行者"256: [255, 0, 0],     # "移动轨道车辆"257: [250, 80, 100],  # "移动公交车"258: [180, 30, 80],   # "移动卡车"259: [255, 0, 0],     # "移动其他车辆"}# 标签重映射方案label_remap = {0 :  0, # "未标记"1 :  0, # "异常点"10:  2, # "汽车"11:  2, # "自行车"13:  2, # "公交车"15:  2, # "摩托车"16:  2, # "轨道车辆"18:  2, # "卡车"20:  2, # "其他车辆"30:  2, # "行人"31:  2, # "骑行者"32:  2, # "摩托车骑行者"40:  1, # "道路"44:  1, # "停车区"48:  1, # "人行道"49:  1, # "其他地面"50:  2, # "建筑物"51:  2, # "围栏"52:  2, # "其他结构"60:  1, # "车道标线"70:  2, # "植被"71:  2, # "树干"72:  2, # "地形"80:  2, # "杆子"81:  2, # "交通标志"99:  2, # "其他物体"252: 2, # "移动汽车"253: 2, # "移动骑行者"254: 2, # "移动行人"255: 2, # "移动摩托车骑行者"256: 2, # "移动轨道车辆"257: 2, # "移动公交车"258: 2, # "移动卡车"259: 2, # "移动其他车辆"    }# 重映射后的颜色方案remap_color_scheme = [  [0, 0, 0],     # 类别0:黑色  [0, 255, 0],   # 类别1:绿色  [0, 0, 255]    # 类别2:蓝色]def sample(pointcloud, labels, numpoints_to_sample):    """        输入:            pointcloud          : 3D点列表            labels              : 整数标签列表            numpoints_to_sample : 采样点数    """    tensor = np.concatenate((pointcloud, np.reshape(labels, (labels.shape[0], 1))), axis= 1)    tensor = np.asarray(random.choices(tensor, weights=None, cum_weights=None, k=numpoints_to_sample))    pointcloud_ = tensor[:, 0:3]    labels_ = tensor[:, 3]    labels_ = np.array(labels_, dtype=np.int_)    return pointcloud_, labels_def readpc(pcpath, labelpath, reduced_labels=True):    """    输入:        pcpath         : 点云".bin"文件路径        labelpath      : 标签".label"文件路径        reduced_labels : 选择返回的标签编码类型的标志                        [True]  -> 返回范围[0, 1, 2]的值   -- 默认                        [False] -> 返回原始Semantic-Kitti数据集所有标签    """    pointcloud, labels = [], []    with open(pcpath, "rb") as pc_file, open(labelpath, "rb") as label_file:        byte = pc_file.read(size_float*4)        label_byte = label_file.read(size_small_int)        _ = label_file.read(size_small_int)        while byte:            x,y,z, _ = struct.unpack("ffff", byte)      # 解包4个浮点值            label = struct.unpack("H", label_byte)[0]   # 解包1个无符号短整型值            d = x*x + y*y + z*z       # 欧几里得距离平方            if min_dist<d<max_dist:  # 距离筛选                pointcloud.append([x, y, z])                if reduced_labels:            # 使用简化标签范围                    labels.append(label_remap[label])                else:                         # 使用完整标签范围                    labels.append(label)            byte = pc_file.read(size_float*4)            label_byte = label_file.read(size_small_int)            _ = label_file.read(size_small_int)    pointcloud  = np.array(pointcloud)    labels      = np.array(labels)    # 返回固定数量的点/标签(固定数量:numpoints)    return sample(pointcloud, labels, numpoints)def remap_to_bgr(integer_labels, color_scheme):  """  将整数标签映射到BGR颜色  """  bgr_labels = []  for n in integer_labels:    bgr_labels.append(color_scheme[int(n)][::-1])  # 反转RGB为BGR  np_bgr_labels = np.array(bgr_labels)  return np_bgr_labelsdef draw_geometries(geometries):    """    使用Plotly绘制Open3D几何对象    """    graph_objects = []    for geometry in geometries:        geometry_type = geometry.get_geometry_type()        if geometry_type == o3d.geometry.Geometry.Type.PointCloud:            points = np.asarray(geometry.points)            colors = None            if geometry.has_colors():                colors = np.asarray(geometry.colors)            elif geometry.has_normals():                colors = (0.5, 0.5, 0.5) + np.asarray(geometry.normals) * 0.5            else:                geometry.paint_uniform_color((1.0, 0.0, 0.0))                colors = np.asarray(geometry.colors)            scatter_3d = go.Scatter3d(x=points[:,0], y=points[:,1], z=points[:,2], mode='markers', marker=dict(size=1, color=colors))            graph_objects.append(scatter_3d)        if geometry_type == o3d.geometry.Geometry.Type.TriangleMesh:            triangles = np.asarray(geometry.triangles)            vertices = np.asarray(geometry.vertices)            colors = None            if geometry.has_triangle_normals():                colors = (0.5, 0.5, 0.5) + np.asarray(geometry.triangle_normals) * 0.5                colors = tuple(map(tuple, colors))            else:                colors = (1.0, 0.0, 0.0)            mesh_3d = go.Mesh3d(x=vertices[:,0], y=vertices[:,1], z=vertices[:,2], i=triangles[:,0], j=triangles[:,1], k=triangles[:,2], facecolor=colors, opacity=0.50)            graph_objects.append(mesh_3d)    fig = go.Figure(        data=graph_objects,        layout=dict(            scene=dict(                xaxis=dict(visible=False),                yaxis=dict(visible=False),                zaxis=dict(visible=False),                aspectmode='data'            )        )    )    fig.show()def visualize3DPointCloud(np_pointcloud, np_labels):  """  输入:      np_pointcloud : 3D点的numpy数组      np_labels     : 整数标签的numpy数组  """  assert(len(np_pointcloud) == len(np_labels))  pcd = o3d.geometry.PointCloud()  v3d = o3d.utility.Vector3dVector  # 设置点云几何点  pcd.points = v3d(np_pointcloud)  # 将颜色值缩放到[0:1]范围  pcd.colors = o3d.utility.Vector3dVector(np_labels / 255.0)  # 替换渲染函数  o3d.visualization.draw_geometries = draw_geometries  # 可视化彩色点云  o3d.visualization.draw_geometries([pcd])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_pointcloudclass ToTensor(object):    """将点云转换为Tensor"""    def __call__(self, pointcloud):        assert len(pointcloud.shape)==2        return torch.from_numpy(pointcloud)def default_transforms():    """默认数据转换组合"""    return transforms.Compose([                                Normalize(),                                ToTensor()                              ])class PointCloudData(Dataset):    def __init__(self, dataset_path, transform=default_transforms(), start=0, end=1000):        """          输入:              dataset_path: 数据集文件夹路径              transform   : 应用于点云的转换函数              start       : 属于数据集的首个文件索引              end         : 不属于数据集的首个文件索引        """        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        # 根据输入的起始和结束范围裁剪路径列表        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")      # 加载点和标签      pointcloud, labels = readpc(pcpath, lbpath)      # 转换      torch_pointcloud  = torch.from_numpy(pointcloud)      torch_labels      = torch.from_numpy(labels)      return torch_pointcloud, torch_labelsif __name__ == '__main__':    # 定义点云示例索引和绝对路径    pointcloud_index = 146    pcpath    = os.path.join(dataset_path, "sequences", "00", "velodyne", str(pointcloud_index).zfill(6) + ".bin"  )    labelpath = os.path.join(dataset_path, "sequences", "00", "labels",   str(pointcloud_index).zfill(6) + ".label")    # 使用原始Semantic-Kitti标签加载点云和标签    pointcloud, labels = readpc(pcpath, labelpath, False)    labels = remap_to_bgr(labels, semantic_kitti_color_scheme)    print("Semantic-Kitti原始颜色方案")    visualize3DPointCloud(pointcloud, labels)    # 使用重映射后的标签加载点云和标签    pointcloud, labels = readpc(pcpath, labelpath)    labels = remap_to_bgr(labels, remap_color_scheme)    print("重映射后的颜色方案")    visualize3DPointCloud(pointcloud, labels)

数据转换

准备数据的一个关键步骤是通过自定义转换进行归一化和张量转换。我们采用了两种主要的转换方式:

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

复制代码
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() 函数中。

PointCloud 数据集

接着,我们构建了一个自定义数据集 PointCloudData,它继承自 PyTorch 的 Dataset 类。该数据集代表了用于训练和测试的点云集合。其结构包括:

  • 使用数据集详细信息进行初始化,并包含一个可选的转换函数。

  • 定义数据集的长度。

  • 检索数据项,并在指定时应用转换。

    class PointCloudData(Dataset): def init(self, dataset_path, transform=default_transforms(), start=0, end=1000): """ 输入: dataset_path: 数据集文件夹路径 transform : 应用于点云的转换函数 start : 属于数据集的首个文件索引 end : 不属于数据集的首个文件索引 """ 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 # 根据输入的起始和结束范围裁剪路径列表 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") # 加载点和标签 pointcloud, labels = readpc(pcpath, lbpath) # 转换 torch_pointcloud = torch.from_numpy(pointcloud) torch_labels = torch.from_numpy(labels) return torch_pointcloud, torch_labels

数据集创建

有了数据集类后,我们实例化了训练集、验证集和测试集。这不仅提供了结构化的组织方式,也为高效使用 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)

DataLoader 利用

通过利用 PyTorch DataLoader 的功能,我们实现了批量处理(batching)、数据打乱(shuffling)以及并行加载等特性。

复制代码
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)

这种对数据集创建和加载的细致处理方式,不仅对解决基础问题大有裨益,而且随着数据集和训练流程复杂性的增加,它变得不可或缺。它为训练和测试期间高效、可扩展且并行化的数据处理奠定了基础。

损失函数

在神经网络训练领域,损失函数在指导模型参数更新方面起着关键作用。我们的 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)

分解其各个组成部分:

outputs:模型的预测结果。

labels:真实标签(Ground truth)。

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

alpha:正则化项的权重。

该损失函数将用于 softmax 分类的标准负对数似然(NLL)损失与正则化项结合在一起。正则化项会惩罚变换矩阵偏离正交性的行为,这与论文中强调实现正交性的观点保持一致。

这种细致的构建确保了我们的 PointNet 模型不仅在分类精度上表现出色,还能遵守结构约束,从而在训练过程中增强其鲁棒性和泛化能力。

训练循环

训练循环是一个程序化序列,用于迭代更新 PointNet 模型的权重。它由设定数量的轮次(epochs)组成,每一轮都包含一个训练阶段和一个可选的验证阶段。模型在这些阶段之间交替切换训练和评估状态。

复制代码
def train(pointnet, optimizer, train_loader, val_loader=None, epochs=15, save=True):    """训练PointNet模型    参数:        pointnet: PointNet模型        optimizer: 优化器        train_loader: 训练数据加载器        val_loader: 验证数据加载器(可选)        epochs: 训练轮数,默认为15        save: 是否保存最佳模型,默认为True    """    best_val_acc = -1.0  # 最佳验证准确率    for epoch in range(epochs):        pointnet.train()  # 设置为训练模式        running_loss = 0.0  # 当前轮次的累计损失        # 训练阶段        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()            # 每10个批次打印一次损失            if i % 10 == 9 or True:                print('[第%d轮, 第%5d个批次] 损失: %.3f' % (epoch + 1, i + 1, running_loss / 10))                running_loss = 0.0        # 验证阶段        pointnet.eval()  # 设置为评估模式        correct = 0  # 正确预测的数量        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(f"正确预测: {correct} / {total}")        val_acc = 100.0 * correct / total        print('验证准确率: %.2f %%' % val_acc)        # 如果当前验证准确率超过最佳准确率,则保存模型        if save and val_acc > best_val_acc:            best_val_acc = val_acc            path = os.path.join('', "MyDrive", "pointnetmodel.yml")            print(f"最佳验证准确率: {val_acc}, 正在保存模型到: {path}")            torch.save(pointnet.state_dict(), path)  # 保存模型权重# 初始化优化器optimizer = torch.optim.Adam(pointnet.parameters(), lr=0.005)# 开始训练train(pointnet, optimizer, train_loader, val_loader, save=True)

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

测试

compute_stats 函数旨在分析模型在测试阶段的性能。它统计真实标签中不同类别(unk, trav, nontrav)的出现次数,计算预测总数,并统计正确预测的数量。结果以元组 (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

结论

PointNet 作为一种开创性的工具,在 3D 分割领域脱颖而出,成功克服了无序点集带来的挑战。其理论基础、架构设计及实际实现都展示了它的多功能性和可靠性。通过将理论实力与实践实施相结合,我们揭开了理解并利用 PointNet 进行 3D 分割的神秘面纱。PyTorch 与 Python 的集成提供了一个实用的框架,用于探索 PointNet 在实际应用中的潜力。如需查看全部代码,请私信我。

这篇文章主要介绍 3D 分割。这篇文章主要带你深入了解 PointNet,这是一种理解 3D 形状的方法。PointNet 就像是计算机用来观察 3D 物体的智能工具,特别是空间中漂浮的点云数据。它与其他方法不同,因为它直接处理这些点,而不需要将它们强制转换成网格或图片。

语义分割:基于 TensorFlow 对 FCN 与迁移学习的探究

在本文中,我们将让 PointNet 变得通俗易懂。我们会从宏观概念讲起,一直深入到实际编写 Python 和 PyTorch 代码来进行 3D 分割。但在进入有趣的实操部分之前,让我们先弄清楚 PointNet 的核心内容------它是如何成为解决 3D 物体及其部件识别等难题的关键技术的。

介绍基于 C++ 的运动恢复结构(SfM)的三维重建

因此,请随我们一起回顾 PointNet 论文的概要。我们将探讨其架构设计、背后的精妙理论,以及它在真实实验中展现出的强大能力。在探索随机点云、特殊函数以及 PointNet 如何处理各种 3D 任务的世界时,我们会尽量保持简单易懂。准备好发现 PointNet 在 3D 分割中的强大威力了吗?让我们开始吧。

深入理解 PointNet:核心概念

现在我们已经铺平了道路,让我们将 PointNet 拆解成易于消化的小块。PointNet 就像一种特殊的工具,帮助计算机理解 3D 事物,特别是那些棘手的点云数据。但它到底酷在哪里呢?与其他将数据整齐归位的方法不同,PointNet 按照点的本来面目处理它们------不需要网格,也不需要图片。这让它在 3D 领域中脱颖而出。机器人学的第一部分------机器人如何理解空间:运动学与旋转矩阵

点集基础

想象一下,一群点漂浮在 3D 空间中。这些点没有特定的顺序,它们彼此交互。PointNet 通过适应旋转或移动等变化,来应对这种无序性。当点的位置互换时,它也不会感到困惑。

对称性魔法

PointNet 拥有一种特殊的能力,叫做对称性。想象你有一袋点,无论你怎么打乱它们,PointNet 依然能理解袋子里装的是什么。对于处理无序点云来说,这简直就像法术一样。

聚合局部与全局信息

PointNet 擅长收集信息。它既能看大局(全局),也能看细节(局部)。这有助于它完成诸如识别物体及其部件形状等任务。

对齐技巧

PointNet 也擅长处理变化。如果你旋转或移动点云,PointNet 也能调整过来,从容理解。这就像是一个机器人在自动对齐物体,以便能看清它们。

理论基石

现在,让我们谈谈 PointNet 背后的宏大理论。有两个特殊的定理表明,PointNet 不仅在实践中表现出色,在理论上也是一个明智的选择。解读openAI的文本图像模型-CLIP

  1. 通用近似性: PointNet 能够很好地学习和理解任何 3D 形状。这就好比说,PointNet 是一个超级英雄,无论你扔给它什么形状,它都能搞定。
  2. 瓶颈维度与稳定性: PointNet 非常坚韧。即使你添加了一些额外的点,或者干扰了它已有的点,它也不会感到困惑。它坚守岗位,保持稳定。

这些宏大的理论让 PointNet 成为了一个值得信赖的 3D 形状理解工具。下面,我们将从理论转向实践,亲自动手使用 Python 和 PyTorch 进行编码。

PointNet 架构概览

PointNet 架构由两个主要部分组成:分类网络 和一个扩展的分割网络

  • 分类网络 :接收 n 个输入点,应用输入和特征变换,并通过最大池化(max pooling)聚合点特征,最终为 k 个类别生成分类得分。
  • 分割网络:是分类网络的自然扩展,它结合了全局和局部特征,以生成每个点的得分。

术语"mlp"表示多层感知机(multi-layer perceptron),其层大小在方括号中指定。所有层都一致应用了批归一化(Batch normalization),并伴随 ReLU 激活函数。此外,在分类网络的最后 mlp 中,战略性地加入了 dropout 层。

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

复制代码
# 多层感知机(MLP)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 Normalizationclass 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 架构中的一致应用。

输入与特征变换

输入变换网络是一个被称为 TNet 的小型 PointNet,在处理原始点云数据方面起着关键作用。它旨在通过一系列操作回归出一个 3 × 3 的矩阵。该网络架构定义为:先对每个点应用共享的 MLP(64, 128, 1024),然后在点之间进行最大池化(max pooling),最后经过两个输出大小分别为 512 和 256 的全连接层。生成的矩阵被初始化为单位矩阵。每一层(最后一层除外)都包含了 ReLU 激活函数和批归一化。第二个变换网络的架构与第一个类似,但输出的是一个 64 × 64 的矩阵,并同样初始化为单位矩阵。此外,为了促进矩阵的正交性,向 softmax 分类损失中添加了一个权重为 0.001 的正则化损失。生成式AI vs 预测式AI:揭秘人工智能领域的两大技术

TNet 类被用于根据论文中提供的规范来创建变换网络:

复制代码
# Transformation Network (TNet) classclass 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

这个 TNet 类封装了将输入点云转换为 3 × 3 或 64 × 64 矩阵的过程,其内部利用了共享的多层感知机(MLP)、最大池化(max pooling)以及带有批归一化的全连接层。什么是无监督学习?理解人工智能中无监督学习的机制、各类算法的类型与应用

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_CONV)以及带有批归一化的 1D 卷积层。前向传播过程处理输入和特征变换,随后提取全局特征。最终返回的结果张量以及变换矩阵作为输出。

PointNet 分割网络:

分割网络是分类版 PointNet 的扩展。它将来自第二个变换网络的局部点特征与来自最大池化(max pooling)的全局特征进行拼接,为每个点生成特征。分割网络中不使用 Dropout,且训练参数与分类网络保持一致。

对于形状部件分割(shape part segmentation),进行了以下修改:包括添加一个指示输入类别的独热编码(one-hot)向量,并将其与最大池化层的输出进行拼接;增加了某些层中的神经元数量;添加了跳跃连接(skip links)以收集不同层的局部点特征,并将它们拼接起来,形成分割网络的点特征输入。

复制代码
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

PointNetSeg 类

PointNetSeg 类中,前向传播过程整合了从 PointNet 获取的特征,将它们进行拼接,然后通过一系列多层感知机(MLP_CONV)和一个卷积层。最终在应用 LogSoftmax 激活函数后获得输出。

训练与测试 PointNet 模型

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

  • 可通行:包括道路、停车位、人行道等。
  • 不可通行:包括汽车、卡车、围栏、树木、行人及各种物体。
  • 未知:保留给离群值(异常值)。

重新映射过程涉及使用键值(key-value)字典,将原始标签转换为其简化后的对应类别。为了可视化彩色点云,我们使用了 Open3D Python 包。左图展示了 Semantic-Kitti 的原始配色方案,而右图则揭示了重新映射后的配色方案。

下面是用于加载和可视化数据的代码。

复制代码
import numpy as npimport randomimport mathimport timeimport structimport os# pyTorch 相关导入import torchimport torch.nn as nnimport torch.nn.functional as Ffrom torch.utils.data import Dataset, DataLoaderfrom torchvision import transforms# 用于训练进度条的库from tqdm import tqdm# 可视化库import open3d as o3dimport plotly.graph_objects as gonumpoints = 20000 # [点云点数]max_dist = 15     # [米] 最大距离min_dist = 4      # [米] 最小距离# 将距离转换为平方(代码优化)max_dist *= max_distmin_dist *= min_distsize_float = 4       # 浮点数字节大小size_small_int = 2   # 短整型字节大小dataset_path =  "dataset"  # 数据集路径# Semantic-KITTI 颜色方案semantic_kitti_color_scheme = {0 : [0, 0, 0],        # "未标记"1 : [0, 0, 255],      # "异常点"10: [245, 150, 100],  # "汽车"11: [245, 230, 100],  # "自行车"13: [250, 80, 100],   # "公交车"15: [150, 60, 30],    # "摩托车"16: [255, 0, 0],      # "轨道车辆"18: [180, 30, 80],    # "卡车"20: [255, 0, 0],      # "其他车辆"30: [30, 30, 255],    # "行人"31: [200, 40, 255],   # "骑行者"32: [90, 30, 150],    # "摩托车骑行者"40: [255, 0, 255],    # "道路"44: [255, 150, 255],  # "停车区"48: [75, 0, 75],      # "人行道"49: [75, 0, 175],     # "其他地面"50: [0, 200, 255],    # "建筑物"51: [50, 120, 255],   # "围栏"52: [0, 150, 255],    # "其他结构"60: [170, 255, 150],  # "车道标线"70: [0, 175, 0],      # "植被"71: [0, 60, 135],     # "树干"72: [80, 240, 150],   # "地形"80: [150, 240, 255],  # "杆子"81: [0, 0, 255],      # "交通标志"99: [255, 255, 50],   # "其他物体"252: [245, 150, 100], # "移动汽车"253: [200, 40, 255],  # "移动骑行者"254: [30, 30, 255],   # "移动行人"255: [90, 30, 150],   # "移动摩托车骑行者"256: [255, 0, 0],     # "移动轨道车辆"257: [250, 80, 100],  # "移动公交车"258: [180, 30, 80],   # "移动卡车"259: [255, 0, 0],     # "移动其他车辆"}# 标签重映射方案label_remap = {0 :  0, # "未标记"1 :  0, # "异常点"10:  2, # "汽车"11:  2, # "自行车"13:  2, # "公交车"15:  2, # "摩托车"16:  2, # "轨道车辆"18:  2, # "卡车"20:  2, # "其他车辆"30:  2, # "行人"31:  2, # "骑行者"32:  2, # "摩托车骑行者"40:  1, # "道路"44:  1, # "停车区"48:  1, # "人行道"49:  1, # "其他地面"50:  2, # "建筑物"51:  2, # "围栏"52:  2, # "其他结构"60:  1, # "车道标线"70:  2, # "植被"71:  2, # "树干"72:  2, # "地形"80:  2, # "杆子"81:  2, # "交通标志"99:  2, # "其他物体"252: 2, # "移动汽车"253: 2, # "移动骑行者"254: 2, # "移动行人"255: 2, # "移动摩托车骑行者"256: 2, # "移动轨道车辆"257: 2, # "移动公交车"258: 2, # "移动卡车"259: 2, # "移动其他车辆"    }# 重映射后的颜色方案remap_color_scheme = [  [0, 0, 0],     # 类别0:黑色  [0, 255, 0],   # 类别1:绿色  [0, 0, 255]    # 类别2:蓝色]def sample(pointcloud, labels, numpoints_to_sample):    """        输入:            pointcloud          : 3D点列表            labels              : 整数标签列表            numpoints_to_sample : 采样点数    """    tensor = np.concatenate((pointcloud, np.reshape(labels, (labels.shape[0], 1))), axis= 1)    tensor = np.asarray(random.choices(tensor, weights=None, cum_weights=None, k=numpoints_to_sample))    pointcloud_ = tensor[:, 0:3]    labels_ = tensor[:, 3]    labels_ = np.array(labels_, dtype=np.int_)    return pointcloud_, labels_def readpc(pcpath, labelpath, reduced_labels=True):    """    输入:        pcpath         : 点云".bin"文件路径        labelpath      : 标签".label"文件路径        reduced_labels : 选择返回的标签编码类型的标志                        [True]  -> 返回范围[0, 1, 2]的值   -- 默认                        [False] -> 返回原始Semantic-Kitti数据集所有标签    """    pointcloud, labels = [], []    with open(pcpath, "rb") as pc_file, open(labelpath, "rb") as label_file:        byte = pc_file.read(size_float*4)        label_byte = label_file.read(size_small_int)        _ = label_file.read(size_small_int)        while byte:            x,y,z, _ = struct.unpack("ffff", byte)      # 解包4个浮点值            label = struct.unpack("H", label_byte)[0]   # 解包1个无符号短整型值            d = x*x + y*y + z*z       # 欧几里得距离平方            if min_dist<d<max_dist:  # 距离筛选                pointcloud.append([x, y, z])                if reduced_labels:            # 使用简化标签范围                    labels.append(label_remap[label])                else:                         # 使用完整标签范围                    labels.append(label)            byte = pc_file.read(size_float*4)            label_byte = label_file.read(size_small_int)            _ = label_file.read(size_small_int)    pointcloud  = np.array(pointcloud)    labels      = np.array(labels)    # 返回固定数量的点/标签(固定数量:numpoints)    return sample(pointcloud, labels, numpoints)def remap_to_bgr(integer_labels, color_scheme):  """  将整数标签映射到BGR颜色  """  bgr_labels = []  for n in integer_labels:    bgr_labels.append(color_scheme[int(n)][::-1])  # 反转RGB为BGR  np_bgr_labels = np.array(bgr_labels)  return np_bgr_labelsdef draw_geometries(geometries):    """    使用Plotly绘制Open3D几何对象    """    graph_objects = []    for geometry in geometries:        geometry_type = geometry.get_geometry_type()        if geometry_type == o3d.geometry.Geometry.Type.PointCloud:            points = np.asarray(geometry.points)            colors = None            if geometry.has_colors():                colors = np.asarray(geometry.colors)            elif geometry.has_normals():                colors = (0.5, 0.5, 0.5) + np.asarray(geometry.normals) * 0.5            else:                geometry.paint_uniform_color((1.0, 0.0, 0.0))                colors = np.asarray(geometry.colors)            scatter_3d = go.Scatter3d(x=points[:,0], y=points[:,1], z=points[:,2], mode='markers', marker=dict(size=1, color=colors))            graph_objects.append(scatter_3d)        if geometry_type == o3d.geometry.Geometry.Type.TriangleMesh:            triangles = np.asarray(geometry.triangles)            vertices = np.asarray(geometry.vertices)            colors = None            if geometry.has_triangle_normals():                colors = (0.5, 0.5, 0.5) + np.asarray(geometry.triangle_normals) * 0.5                colors = tuple(map(tuple, colors))            else:                colors = (1.0, 0.0, 0.0)            mesh_3d = go.Mesh3d(x=vertices[:,0], y=vertices[:,1], z=vertices[:,2], i=triangles[:,0], j=triangles[:,1], k=triangles[:,2], facecolor=colors, opacity=0.50)            graph_objects.append(mesh_3d)    fig = go.Figure(        data=graph_objects,        layout=dict(            scene=dict(                xaxis=dict(visible=False),                yaxis=dict(visible=False),                zaxis=dict(visible=False),                aspectmode='data'            )        )    )    fig.show()def visualize3DPointCloud(np_pointcloud, np_labels):  """  输入:      np_pointcloud : 3D点的numpy数组      np_labels     : 整数标签的numpy数组  """  assert(len(np_pointcloud) == len(np_labels))  pcd = o3d.geometry.PointCloud()  v3d = o3d.utility.Vector3dVector  # 设置点云几何点  pcd.points = v3d(np_pointcloud)  # 将颜色值缩放到[0:1]范围  pcd.colors = o3d.utility.Vector3dVector(np_labels / 255.0)  # 替换渲染函数  o3d.visualization.draw_geometries = draw_geometries  # 可视化彩色点云  o3d.visualization.draw_geometries([pcd])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_pointcloudclass ToTensor(object):    """将点云转换为Tensor"""    def __call__(self, pointcloud):        assert len(pointcloud.shape)==2        return torch.from_numpy(pointcloud)def default_transforms():    """默认数据转换组合"""    return transforms.Compose([                                Normalize(),                                ToTensor()                              ])class PointCloudData(Dataset):    def __init__(self, dataset_path, transform=default_transforms(), start=0, end=1000):        """          输入:              dataset_path: 数据集文件夹路径              transform   : 应用于点云的转换函数              start       : 属于数据集的首个文件索引              end         : 不属于数据集的首个文件索引        """        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        # 根据输入的起始和结束范围裁剪路径列表        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")      # 加载点和标签      pointcloud, labels = readpc(pcpath, lbpath)      # 转换      torch_pointcloud  = torch.from_numpy(pointcloud)      torch_labels      = torch.from_numpy(labels)      return torch_pointcloud, torch_labelsif __name__ == '__main__':    # 定义点云示例索引和绝对路径    pointcloud_index = 146    pcpath    = os.path.join(dataset_path, "sequences", "00", "velodyne", str(pointcloud_index).zfill(6) + ".bin"  )    labelpath = os.path.join(dataset_path, "sequences", "00", "labels",   str(pointcloud_index).zfill(6) + ".label")    # 使用原始Semantic-Kitti标签加载点云和标签    pointcloud, labels = readpc(pcpath, labelpath, False)    labels = remap_to_bgr(labels, semantic_kitti_color_scheme)    print("Semantic-Kitti原始颜色方案")    visualize3DPointCloud(pointcloud, labels)    # 使用重映射后的标签加载点云和标签    pointcloud, labels = readpc(pcpath, labelpath)    labels = remap_to_bgr(labels, remap_color_scheme)    print("重映射后的颜色方案")    visualize3DPointCloud(pointcloud, labels)

数据转换

准备数据的一个关键步骤是通过自定义转换进行归一化和张量转换。我们采用了两种主要的转换方式:

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

复制代码
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() 函数中。

PointCloud 数据集

接着,我们构建了一个自定义数据集 PointCloudData,它继承自 PyTorch 的 Dataset 类。该数据集代表了用于训练和测试的点云集合。其结构包括:

  • 使用数据集详细信息进行初始化,并包含一个可选的转换函数。

  • 定义数据集的长度。

  • 检索数据项,并在指定时应用转换。

    class PointCloudData(Dataset): def init(self, dataset_path, transform=default_transforms(), start=0, end=1000): """ 输入: dataset_path: 数据集文件夹路径 transform : 应用于点云的转换函数 start : 属于数据集的首个文件索引 end : 不属于数据集的首个文件索引 """ 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 # 根据输入的起始和结束范围裁剪路径列表 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") # 加载点和标签 pointcloud, labels = readpc(pcpath, lbpath) # 转换 torch_pointcloud = torch.from_numpy(pointcloud) torch_labels = torch.from_numpy(labels) return torch_pointcloud, torch_labels

数据集创建

有了数据集类后,我们实例化了训练集、验证集和测试集。这不仅提供了结构化的组织方式,也为高效使用 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)

DataLoader 利用

通过利用 PyTorch DataLoader 的功能,我们实现了批量处理(batching)、数据打乱(shuffling)以及并行加载等特性。

复制代码
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)

这种对数据集创建和加载的细致处理方式,不仅对解决基础问题大有裨益,而且随着数据集和训练流程复杂性的增加,它变得不可或缺。它为训练和测试期间高效、可扩展且并行化的数据处理奠定了基础。

损失函数

在神经网络训练领域,损失函数在指导模型参数更新方面起着关键作用。我们的 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)

分解其各个组成部分:

outputs:模型的预测结果。

labels:真实标签(Ground truth)。

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

alpha:正则化项的权重。

该损失函数将用于 softmax 分类的标准负对数似然(NLL)损失与正则化项结合在一起。正则化项会惩罚变换矩阵偏离正交性的行为,这与论文中强调实现正交性的观点保持一致。

这种细致的构建确保了我们的 PointNet 模型不仅在分类精度上表现出色,还能遵守结构约束,从而在训练过程中增强其鲁棒性和泛化能力。

训练循环

训练循环是一个程序化序列,用于迭代更新 PointNet 模型的权重。它由设定数量的轮次(epochs)组成,每一轮都包含一个训练阶段和一个可选的验证阶段。模型在这些阶段之间交替切换训练和评估状态。

复制代码
def train(pointnet, optimizer, train_loader, val_loader=None, epochs=15, save=True):    """训练PointNet模型    参数:        pointnet: PointNet模型        optimizer: 优化器        train_loader: 训练数据加载器        val_loader: 验证数据加载器(可选)        epochs: 训练轮数,默认为15        save: 是否保存最佳模型,默认为True    """    best_val_acc = -1.0  # 最佳验证准确率    for epoch in range(epochs):        pointnet.train()  # 设置为训练模式        running_loss = 0.0  # 当前轮次的累计损失        # 训练阶段        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()            # 每10个批次打印一次损失            if i % 10 == 9 or True:                print('[第%d轮, 第%5d个批次] 损失: %.3f' % (epoch + 1, i + 1, running_loss / 10))                running_loss = 0.0        # 验证阶段        pointnet.eval()  # 设置为评估模式        correct = 0  # 正确预测的数量        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(f"正确预测: {correct} / {total}")        val_acc = 100.0 * correct / total        print('验证准确率: %.2f %%' % val_acc)        # 如果当前验证准确率超过最佳准确率,则保存模型        if save and val_acc > best_val_acc:            best_val_acc = val_acc            path = os.path.join('', "MyDrive", "pointnetmodel.yml")            print(f"最佳验证准确率: {val_acc}, 正在保存模型到: {path}")            torch.save(pointnet.state_dict(), path)  # 保存模型权重# 初始化优化器optimizer = torch.optim.Adam(pointnet.parameters(), lr=0.005)# 开始训练train(pointnet, optimizer, train_loader, val_loader, save=True)

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

测试

compute_stats 函数旨在分析模型在测试阶段的性能。它统计真实标签中不同类别(unk, trav, nontrav)的出现次数,计算预测总数,并统计正确预测的数量。结果以元组 (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

结论

PointNet 作为一种开创性的工具,在 3D 分割领域脱颖而出,成功克服了无序点集带来的挑战。其理论基础、架构设计及实际实现都展示了它的多功能性和可靠性。通过将理论实力与实践实施相结合,我们揭开了理解并利用 PointNet 进行 3D 分割的神秘面纱。PyTorch 与 Python 的集成提供了一个实用的框架,用于探索 PointNet 在实际应用中的潜力。如需查看全部代码,请私信我。基于深度学习的图像匹配与三维重建算法MASt3R和MASt3R-SfM详解

相关推荐
Piar1231sdafa8 小时前
蓝莓目标检测——改进YOLO11-C2TSSA-DYT-Mona模型实现
人工智能·目标检测·计算机视觉
愚公搬代码8 小时前
【愚公系列】《AI短视频创作一本通》002-AI引爆短视频创作革命(短视频创作者必备的能力)
人工智能
数据猿视觉8 小时前
新品上市|奢音S5耳夹耳机:3.5g无感佩戴,178.8元全场景适配
人工智能
蚁巡信息巡查系统8 小时前
网站信息发布再巡查机制怎么建立?
大数据·人工智能·数据挖掘·内容运营
AI浩8 小时前
C-RADIOv4(技术报告)
人工智能·目标检测
Purple Coder8 小时前
AI赋予超导材料预测论文初稿
人工智能
Data_Journal8 小时前
Scrapy vs. Crawlee —— 哪个更好?!
运维·人工智能·爬虫·媒体·社媒营销
云边云科技_云网融合8 小时前
AIoT智能物联网平台:架构解析与边缘应用新图景
大数据·网络·人工智能·安全
康康的AI博客8 小时前
什么是API中转服务商?如何低成本高稳定调用海量AI大模型?
人工智能·ai
技术与健康8 小时前
AI Coding协作开发工作台 实战案例:为电商系统添加用户评论功能
人工智能