作者:SkyXZ
CSDN:SkyXZ~-CSDN博客
博客园:SkyXZ - 博客园
- PointNet论文Arxiv地址:[1612.00593] PointNet: Deep Learning on Point Sets for 3D Classification and Segmentation
传统的目标检测算法已经非常成熟,例如 YOLO 系列、DETR、Faster R-CNN 等,它们主要处理的是规则的二维图像数据。在图像中,像素按照规则网格排列,不同网格之间排列的不同会导致图像结果完全不同,这种有序性非常适合卷积神经网络进行特征提取。然而,3D 的点云完全不同。它是一组离散、无序且稀疏分布的空间点,没有固定的拓扑结构和排列顺序,也就是说点与点之间的邻居关系不是固定的。想象一下,你有14个乒乓球,他们随机地散落在桌子上,但共同组成了一个雨伞的形状。
这些小球就像点云中的点:它们位置无序,没有行列坐标;即使你把小球拿起来打乱顺序再放回去,雨伞的形状依然不变,而理想情况下,一个点云处理模型也应该具有这样的**“顺序不变性”——输入顺序变了,输出识别结果不变。然而传统基于卷积的网络并不具备这种能力,卷积依赖规则栅格来共享权重和提取局部特征,直接用在点云上不仅效率低下,还会让提取到的特征对点输入顺序非常敏感,并且难以捕捉物体的整体形状和局部几何关系。为了解决这些问题,斯坦福大学提出了一种开创性的思路,他们通过共享多层感知机(MLP)对每个点独立提取特征,再使用全局对称函数将这些特征汇总,从而将无序的点云信息转化为顺序不敏感的全局特征,实现了对点云的端到端学习与识别,这也是本文将要介绍的核心算法PointNet**。
PS:💻 项目完整代码已上传至Github:,这篇文章是我的学习总结,如果你在阅读中有任何问题、建议或错误指出,也欢迎在评论区与我讨论,我们共同进步!
一、白话解析PointNet架构设计及各组件原理
在传统的检测算法中,模型通常依赖规则栅格和卷积运算来捕捉局部邻域特征,需要在输入端人为地建立点与点之间的空间关系;而 PointNet 则直接在点集上进行特征学习,平等的对待桌面上的每一个“小球”,认为他们同等重要,在这个理论下,PointNet网络通过三个步骤来解决点云检测的问题:先把点云“摆正”,再给每个点“贴标签”,最后把信息“汇总起来”:
- 先把点云“摆正”(T-Net)
前面我们提到过,点云数据具有无序性和空间姿态不确定性,对于同一辆车来说,雷达从不同角度扫描得到的点云可能完全不一样,甚至整片点云都会发生旋转或平移,而PointNet的第一步便是让这些点先**“坐好”**,其用一个叫 T-Net 的小网络来预测一个 3 × 3 3×3 3×3或更高维的变换矩阵 T T T,并将输入点云 ${x_i \in \mathbb{R}^3} 映射到一个更“规范”的坐标系中 映射到一个更“规范”的坐标系中 映射到一个更“规范”的坐标系中 x^′_i=T⋅x_i.$,这样,不s管车是转了个方向还是稍微偏移,后面处理点云的步骤都在一个“标准姿态”下进行,后续特征提取过程就能对空间扰动更稳健。
- 给每个点“贴标签”(共享 MLP)
点云里每个点都可能包含一些形状信息,但这些点是无序的,就像一堆散落在桌上的小球,传统的卷积神经网络习惯让像素按行按列排好队来逐个卷积得到特征顺序,可点云不讲规矩,所以PointNet不强行整理"队伍",而是平等对待点云信息中的每一个点,其将点云信息中的每个点都单独通过同一组多层感知机(MLP)来进行特征映射,我们设 MLP 为函数$ h(\cdot) ,其对每个点独立计算: ,其对每个点独立计算: ,其对每个点独立计算:f_i=h(x_i^′),i=1,…,n.$,由于 MLP 权重是共享的,网络会平等对待每一个点,而不关心他们的输入顺序,因此不管点的顺序怎么换,其得到的结果都会是一样的 。
- 把信息“汇总起来”(最大值池化MaxPooling)
在上一步中我们将每个点都经过了MLP进行处理得到了每个点单独的特征维度,但由于每个点的顺序都是乱的,如果只是简单的对特征进行相加或者拼接得到的结果便会依赖于点云的数据,这时候斯坦福的研究人员便想到了使用最大值池化MaxPooling,也就是对每个特征维度 j j j,我们从所有点的特征里挑一个最大值出来 g j = m a x ( f i j ) , j = 1 , … , k g_j=max(f_{ij}),j=1,…,k gj=max(fij),j=1,…,k,组成一个固定维度的最大值特征向量,而由于最大值运算与输入顺序无关,因此这一步保证了整个网络具有数学意义上的“顺序不变性”。
我们可以用下面这段动画来辅助理解这三个步骤,假设我们有按任意顺序排列的五个小球且每个小球都有 ( x , y , z ) (x,y,z) (x,y,z)三维数据,我们将其放进一个多层感知机(MLP)中,将原来每个点的三维特征升维为八维特征,并将这个五个点对应的八个维度中不同通道的最大值保存下来得到一个最终的最大值向量,而由于取每个通道最大值的操作和点云的处理顺序是无关的,所以不管怎么改变点云的排列顺序我们最终得到的特征向量也是不变的,因此我们如果使用最终得到的这个无关的输入的特征向量进行分类、检测、分割便可以得到一个无论输入如何改变结果也不会发生任何变化的模型啦
而在PointNet网络中“小球”的数量不止五个,而MLP处理后的维度也不是八维,而是先从3维提升至64维再提升至1024维接着便使用最大池化得到一个1024维的特征向量后便可以直接将这个向量接入一个全连接层预测整个点云属于哪个类别;那如果我们需要接入分割任务的话,由于需要对每个点的类别概率进行预测,因此我们需要将前面MLP提取的各点各自的局部特征向量与全局的整体特征向量进行拼接,这样每个点既保留了整体点云的全局特征也保留了其自身的局部差异性特征,至此我们便可以对每个点的类别进行预测完成分割任务啦!


二、PyTorch完整复现PointNet
在理解了PointNet的原理之后我们便可以开始着手复现PointNet啦,这个网络并不复杂,接下来,我们将一步步用 PyTorch 搭建 PointNet,包括输入处理、T-Net 空间变换模块、共享 MLP 层、全局特征提取以及分类和分割任务的实现
1.数据加载DataLoad部分
ModelNet40 是由普林斯顿大学提出的一个 3D 形状分类数据集,包含 40 个不同类别的三维模型(例如椅子、桌子、飞机、汽车等),总共有 12,311 个 CAD 模型,其中训练集 9,843 个,测试集 2,468 个,由于模型均由CAD模型转化而来,因此每个点云模均无噪声,且无背景,仅单个物体,我们可以使用如下方式来获取本数据集:
wget http://modelnet.cs.princeton.edu/ModelNet40.zip
下载并解压之后的文件结构如下所示,所有数据按照类别划分并且分为 train 和 test 两个部分,文件格式为 .off(Object File Format),这是一种比较常见的三维模型存储格式,其中包含点的数量、面片数量,每个点的三维坐标,每个面片的顶点索引(描述三角面或多边形):
# off格式示例
- 第一行:固定为 `OFF`
- 第二行:三个整数,分别是 顶点数、面数、边数
- 接下来顶点坐标:每行一个点的 (x y z)
- 最后是面片数据:每行的第一个数是该面的顶点数 n,后面是 n 个顶点的索引(从 0 开始计数)
# 示例:
OFF #第一行
8 6 12 # 顶点数 面数 边数
0.0 0.0 0.0 # 顶点坐标
1.0 0.0 0.0 # 顶点坐标
1.0 1.0 0.0 # 顶点坐标
0.0 1.0 0.0 # 顶点坐标
0.0 0.0 1.0 # 顶点坐标
1.0 0.0 1.0 # 顶点坐标
1.0 1.0 1.0 # 顶点坐标
0.0 1.0 1.0 # 顶点坐标
4 0 1 2 3 # 面片数据
4 7 6 5 4 # 面片数据
4 0 4 5 1 # 面片数据
4 1 5 6 2 # 面片数据
4 2 6 7 3 # 面片数据
4 3 7 4 0 # 面片数据


我们首先对应读取每个OFF文件,ModelNet40数据集中的OFF文件除了标准的OFF num_vertices num_faces num_edges格式外,还存在一些特殊的格式变体,比如连体格式OFF4528(OFF后面直接跟顶点数)以及简化格式num_vertices num_faces num_edges,因此我们需要识别并处理这些特殊的off格式,同时还要自动跳过注释行和空行,然后读取完数据之后返回标准的点云数据格式Points: [N, 3] :
def read_off_file(self, file_path):
with open(file_path, 'r') as f:
lines = [line.strip() for line in f if line.strip() and not line.startswith('#')]
if not lines[0].upper().startswith('OFF'):
raise ValueError(f"{
file_path} is not a valid OFF file.")
# 解析顶点数、面数
if len(lines[0].split()) == 1:
# 标准格式:OFF\nnum_vertices num_faces num_edges
num_vertices, num_faces, *_ = map(int, lines[1].split())
start = 2
else:
# 简化格式:OFF num_vertices num_faces [num_edges]
parts = lines[0].split()
num_vertices, num_faces = map(int, parts[1:3])
start = 1
points = []
for i in range(start, start + num_vertices):
x, y, z = map(float, lines[i].split()[:3])
points.append([x, y, z])
return np.array(points, dtype=np.float32)
在完成了OFF文件的获取之后我们接下来完成对点云的采样,通常一个 OFF 文件包含的点数 N N N并不固定,但在训练模型时,我们通常希望每个点云有相同数量的点 num_points。这时就需要对点云进行采样。最简单的方法是随机采样,同时由于一些简单物体的原始点云点数可能会少于我们要求的点数,这时候在采集点云的时候则允许重复采样,我们的实现代码如下:
def sample_points(self, points):
n = len(points)
replace = n < self.num_points
indices = np.random.choice(n, self.num_points, replace=replace)
return points[indices]
由于DataLoad比较基础,在本任务中唯一有难度的仅有OFF文件数据的处理,其余的与Torch正常的数据加载流程一致,因此这里仅介绍如何处理off文件格式,其余的文件获取加载部分不再赘述直接上源代码:
import os
import numpy as np
import torch
from torch.utils.data import Dataset, DataLoader
import glob
class ModelNet40Dataset(Dataset):
def __init__(self, root_dir, split='train', num_points=1024):
self.root_dir = root_dir
self.split = split
self.num_points = num_points
self.classes = sorted([d for d in os.listdir(root_dir)
if os.path.isdir(os.path.join(root_dir, d))])
self.class_to_idx = {
cls: idx for idx, cls in enumerate(self.classes)}
self.file_paths = []
self.labels = []
for class_name in self.classes:
class_dir = os.path.join(root_dir, class_name, split)
if os.path.exists(class_dir):
files = glob.glob(os.path.join(class_dir, "*.off"))
self.file_paths.extend(files)
self.labels.extend([self.class_to_idx[class_name]] * len(files))
print(f"Found {
len(self.file_paths)} {
split} files in {
len(self.classes)} classes")
def __len__(self):
return len(self.file_paths)
def __getitem__

3111

被折叠的 条评论
为什么被折叠?



