理论
PointNet++ 是由斯坦福大学 Qi 等人于 2017 年提出的深度层级化点云特征学习网络。其核心思想是:在点云这种非规则、无序的三维数据上,模仿卷积神经网络(CNN)的层级感受野机制,通过”采样—分组—特征提取”的递归过程,从局部几何结构逐步抽象到全局语义表示,同时解决点云密度不均匀带来的鲁棒性问题。
1.他能做什么?
| 任务 | 输入 | 输出 | 说明 |
|---|---|---|---|
| 分类 | N×3 点云 | 全局类别标签 | 判断整个点云属于哪一类物体(如飞机、椅子、桌子) |
| 语义分割 | N×3 点云 | N 个逐点标签 | 为每个点分配语义类别(如墙面、地面、家具) |
| 部件分割 | N×3 点云 | N 个部件标签 | 对单个物体进行细粒度部件划分(如飞机翼、机身、尾翼) |
2.理论基础与关键技术
2.1 动机:PointNet 的局限
PointNet 的处理流程为:所有点独立通过共享 MLP → 全局对称函数(Max Pooling)聚合 → 得到整体特征向量。
其局限主要体现在两方面:
- 局部结构缺失:Max Pooling 将全局信息压缩为单一向量,点与点之间的局部邻域关系(如平面、棱角、曲面过渡)被抹平。
- 密度敏感性问题:真实采集的点云在不同区域分布极不均匀,近处密集、远处稀疏,或某些表面反射导致空洞。PointNet 对所有点一视同仁,难以自适应这种密度变化。
PointNet++ 的解决思路是引入**层级化(Hierarchical)**处理:像 CNN 一样,浅层捕捉局部边缘/纹理,深层捕捉部件/整体结构。
2.2 Set Abstraction(集合抽象模块)
Set Abstraction(SA)是 PointNet++ 的基本计算单元,功能上类比 CNN 的一个卷积层。每个 SA 层包含三个连续操作:
(1)Sampling(采样)— FPS 最远点采样
从输入点集中选取一组中心点(Centroids),要求这些中心点彼此之间距离尽可能远。
为什么用 FPS 而非随机采样?
随机采样可能导致中心点扎堆在密集区域,而边缘或稀疏角落被遗漏。FPS 通过”最远”约束,天然保证采样点在整个空间中的覆盖均匀性。
(2)Grouping(分组)— Ball Query 球查询
以每个中心点为球心,给定半径 r 划定球形邻域,将落在球内的所有点归为一个局部点群(Local Point Set)。
Ball Query vs KNN:
KNN 强制选取 K 个最近点,在稀疏区域可能被迫纳入远距离噪声;Ball Query 以物理距离硬截断,稀疏处形成的团小,密集处团大,更符合三维空间的物理直觉。
(3)PointNet(局部特征提取)
对每个局部点群独立执行 mini-PointNet:通过共享 MLP 提升维度,再用对称函数聚合,最终将该点群压缩为一个局部特征向量。
效果:每个中心点不再代表单一坐标,而是代表其邻域内的局部几何模式(如”这是一块平面”、“这是一个凸角”)。
2.3 层级编码器(Encoder)
将 SA 模块堆叠,形成从局部到全局的层级抽象:
原始点云 (N, 3) ——[SA Layer 1]——> 抽象点集 (N₁, C₁) ——[SA Layer 2]——> 抽象点集 (N₂, C₂) ——[SA Layer 3]——> 抽象点集 (N₃, C₃) ——[SA Layer 4]——> 全局特征 (1, C₄)每一层同时完成两件事:
- 空间降采样:中心点数量逐层减少(如 1024 → 512 → 128 → 1),每个点代表的空间范围逐层扩大。
- 特征升维:特征通道数逐层增加(如 64 → 128 → 256 → 1024),从低级几何属性过渡到高级语义属性。
这与 CNN 的特征金字塔完全同构:浅层卷积核感知边缘和纹理,深层感知物体部件和整体类别。
2.4 Feature Propagation(特征传播)— 分割解码器
分类任务只需最后一层全局特征,但分割任务需要将抽象特征还原到原始点云分辨率,为每个输入点赋予标签。
FP 模块执行上采样插值与跳跃连接:
全局特征 (1, C₄) ↓ 插值到 Layer 3 分辨率 → 拼接 Layer 3 原始特征 → (N₃, C₃') ↓ 插值到 Layer 2 分辨率 → 拼接 Layer 2 原始特征 → (N₂, C₂') ↓ 插值到 Layer 1 分辨率 → 拼接 Layer 1 原始特征 → (N₁, C₁') ↓ 插值回原始 N 个点 → 逐点分类头 → (N, 类别数)插值机制:基于 KNN 的反距离加权(权重 ∝ 1/distance),距离越近的抽象点贡献越大。
跳跃连接(Skip Connection):将编码器对应层的中间特征拼接到解码器,弥补上采样过程中的细节损失,防止信息瓶颈。这一机制与 U-Net 的跨层连接在原理上等价。
2.5 密度自适应机制
针对真实点云密度不均问题,PointNet++ 提出两种多尺度策略:
MSG(Multi-Scale Grouping,多尺度分组)
在同一 SA 层中,对每个中心点并行使用多个半径进行分组:
- 小半径(如 r=0.1):捕捉精细局部结构,适用于密集区域。
- 中半径(如 r=0.2):平衡局部与半局部信息。
- 大半径(如 r=0.4):捕捉更大范围上下文,适用于稀疏区域。
多尺度特征拼接后送入网络,由模型自主学习不同密度下的特征权重。
MRG(Multi-Resolution Grouping,多分辨率分组)
将”从原始点云直接提取的粗粒度特征”与”上一层已抽象好的细粒度特征”进行融合。计算效率高于 MSG,但特征表达能力略逊,通常 MSG 为默认配置。
3. 网络架构总览
以分类与分割为例:
分类网络
Input (N, 3) ↓ SA1 (N/4, 64) ↓ SA2 (N/16, 128) ↓ SA3 (N/64, 256) ↓ SA4 (1, 1024) —— Global Feature ↓ MLP → Softmax → Class Label分割网络(Encoder-Decoder)
Input (N, 3) ↓ SA1 ────────┐ ↓ SA2 ───────┐│ ↓ SA3 ──────┐││ ↓ SA4 ─────┐│││ FP3 ←─┘│││ FP2 ←──┘││ FP1 ←───┘│ FP0 ←────┘ ↓ Point-wise Classifier → (N, NumClasses)4. 原始论文信息
| 项目 | 内容 |
|---|---|
| 标题 | PointNet++: Deep Hierarchical Feature Learning on Point Sets in a Metric Space |
| 作者 | Charles R. Qi, Li Yi, Hao Su, Leonidas J. Guibas |
| 机构 | Stanford University |
| 会议 | NeurIPS 2017 |
| 论文 | arXiv:1706.02413 |
| 官方代码 | charlesq34/pointnet2(TensorFlow) |
推荐阅读重点:
- Figure 2:网络架构总览,理解 SA 与 FP 的数据流向。
- Section 3.2:Set Abstraction 的数学形式化定义。
- Section 3.3:MSG 与 MRG 的多尺度设计原理。
- Section 4.2:分割任务的 FP 模块实现细节。
5. 学习要点总结
| 核心概念 | 一句话概括 |
|---|---|
| FPS 采样 | 用”最远点”策略选取中心点,保证空间覆盖均匀 |
| Ball Query | 以物理半径划定局部邻域,自适应稀疏/密集区域 |
| Set Abstraction | 采样 + 分组 + PointNet,构成层级化的基本单元 |
| Feature Propagation | 反距离插值 + 跳跃连接,将抽象特征还原到逐点分辨率 |
| MSG | 多半径并行分组,让网络自动适应点云密度变化 |
本质理解:PointNet++ 将无序点云转化为具有层级结构、局部感知、密度鲁棒的深度特征表示,其核心不是发明新算子,而是将 CNN 的”局部卷积 + 层级池化”思想成功迁移到了非规则的三维点域空间。
代码实战-训练(分类)
1.环境准备
1.1 系统与硬件要求
| 项目 | 要求 | 说明 |
|---|---|---|
| 操作系统 | Linux(Ubuntu 20.04/22.04) | 本文基于 WSL2 + Ubuntu 20.04 验证 |
| GPU | NVIDIA 显卡(显存 ≥ 6GB) | 3090(24GB)可轻松跑通,1060 也能跑,需调小 batch_size |
| CUDA | 11.8 或 12.x | PyTorch 2.0.1 + CUDA 11.8 为稳态组合 |
| Python | 3.9 / 3.10 | 3.10 兼容性最好 |
1.2 安装 Miniconda
为什么用 Miniconda 而不是 Anaconda?
Miniconda 只包含 conda 包管理器和 Python,体积约 100MB;Anaconda 预装了大量科学计算包(如 Spyder、Rstudio),体积超过 5GB。对于只需要 PyTorch 和少量依赖的深度学习场景,Miniconda 更轻量,且节省磁盘空间。
# 下载并安装wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda.shbash ~/miniconda.sh -b -p $HOME/miniconda3
# 写入环境变量echo 'export PATH="$HOME/miniconda3/bin:$PATH"' >> ~/.bashrcsource ~/.bashrc为什么需要 source ~/.bashrc?
conda init 或手动修改 ~/.bashrc 后,当前终端进程仍在使用旧的 shell 配置。source 命令的作用是在当前进程内重新读取并执行脚本,实现”热重载”,无需关闭窗口即可让新配置生效。如果不执行,会出现 conda: command not found 或 CondaError: Run 'conda init' before 'conda activate'。
1.3 配置 conda 并创建环境
# 关闭自动激活 base(避免每次开终端都加载默认环境)conda config --set auto_activate_base false
# 创建 Python 3.10 环境conda create -n pointnet python=3.10 -yconda activate pointnet踩坑记录:Anaconda 商业许可 ToS 拦截
2024 年底起,Anaconda 要求用户首次使用 pkgs/main 和 pkgs/r 渠道前显式接受服务条款。若未接受,conda install 会报错 CondaToSNonInteractiveError。
# 执行以下命令接受条款(仅首次需要)conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/mainconda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r1.4 安装 PyTorch(CUDA 11.8)
conda install pytorch==2.0.1 torchvision==0.15.2 torchaudio==2.0.2 pytorch-cuda=11.8 -c pytorch -c nvidia -ypip install tqdm tensorboard matplotlib注意:如果网络不稳定导致 Connection broken: IncompleteRead,conda 不会自动断点续传。此时应清理缓存后重试:
conda clean --all -y# 重试安装命令;若反复失败,可改用 pip:# pip install torch==2.0.1 torchvision==0.15.2 torchaudio==2.0.2 --index-url https://download.pytorch.org/whl/cu1181.5 验证 GPU 可用性
python -c "import torch; print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"预期输出:
TrueNVIDIA GeForce RTX 30902. 数据集准备
2.1 下载 ModelNet40
ModelNet40 是点云分类的基准数据集,包含 40 类共 12311 个 CAD 模型,每个模型采样为 10000 个点并带有法线信息。
mkdir -p ~/projects/pointcloud/datacd ~/projects/pointcloud/datawget https://shapenet.cs.stanford.edu/media/modelnet40_normal_resampled.zipunzip modelnet40_normal_resampled.zip && rm modelnet40_normal_resampled.zip为什么放在 ~/ 下而不是 /mnt/d/(Windows 盘)?
在 WSL2 中,访问 Windows 盘符(如 /mnt/d/)底层走的是 9P 网络文件协议,大文件随机读写性能比原生 ext4 差 3-10 倍。将数据集放在 Linux 家目录(~/)下,可确保 DataLoader 以满速读取,避免 GPU 因等数据而空闲。
2.2 目录结构
~/projects/pointcloud/├── data/│ └── modelnet40_normal_resampled/│ ├── airplane/│ ├── bathtub/│ ├── bed/│ └── ...(共 40 个类别目录)└── Pointnet_Pointnet2_pytorch/3. 代码获取与项目结构
3.1 Clone 仓库
本文采用 yanx27/Pointnet_Pointnet2_pytorch(纯 PyTorch 实现),无需编译 CUDA 扩展,适合快速验证原理。
cd ~/projects/pointcloudgit clone https://github.com/yanx27/Pointnet_Pointnet2_pytorch.git3.2 项目结构解读
Pointnet_Pointnet2_pytorch/├── data/ # 数据集目录(需手动放入 ModelNet40)├── models/│ ├── pointnet2_cls_msg.py # PointNet++ 分类模型(MSG 多尺度分组)│ ├── pointnet2_sem_seg.py # 语义分割模型│ └── pointnet_util.py # SA(Set Abstraction)与 FP(Feature Propagation)实现├── train_classification.py # 分类训练入口├── train_semseg.py # 分割训练入口└── provider.py # 数据增强(旋转、抖动、缩放)核心文件说明:
pointnet_util.py:实现了 FPS 采样、Ball Query、PointNet 局部特征提取,以及 FP 上采样插值,是理解论文架构的最佳入口。pointnet2_cls_msg.py:MSG(Multi-Scale Grouping)分类网络,包含 4 层 SA 降采样 + 1 层全局池化 + MLP 分类头。
4. 训练运行
4.1 启动 ModelNet40 分类训练
cd ~/projects/pointcloud/Pointnet_Pointnet2_pytorchpython train_classification.py --model pointnet2_cls_msg --batch_size 24 --epoch 50 --log_dir pointnet2_cls_msg参数说明:
| 参数 | 值 | 含义 |
|---|---|---|
--model | pointnet2_cls_msg | 使用多尺度分组(MSG)版本 |
--batch_size | 24 | 每批次 24 个样本;显存不足时降为 16 或 8 |
--epoch | 50 | 训练轮数 |
--log_dir | pointnet2_cls_msg | 日志与模型保存目录 |
4.2 训练过程解读
终端会输出类似以下内容:
Epoch 1/50, train loss: 1.8234, train acc: 0.4231, test loss: 1.5432, test acc: 0.5123Epoch 2/50, train loss: 1.4521, train acc: 0.5234, test loss: 1.3124, test acc: 0.5834...Epoch 50/50, train loss: 0.1234, train acc: 0.9543, test loss: 0.3456, test acc: 0.9123指标含义:
- train loss:训练集上的交叉熵损失,越低越好
- train acc:训练集分类准确率
- test acc:测试集分类准确率,反映泛化能力,是最终评价指标
预期结果:ModelNet40 上 PointNet++ (MSG) 的测试准确率通常在 90%-93% 之间,50 epoch 约需 1-2 小时(3090)。
4.3 监控 GPU 状态
另开一个终端,执行:
nvidia-smi可看到 python 进程占用显存约 4-6GB,GPU-Util 接近 100%,说明训练正常进行。
5. 代码层面的关键观察(衔接理论)
训练跑通后,建议回到代码,验证论文中的理论点:
5.1 SA 层的维度变化
打开 models/pointnet_util.py,观察 PointNetSetAbstraction 类:
- 输入
(B, N, 3+C)→ FPS 采样到N1个点 → Ball Query 分组 → PointNet 提取 → 输出(B, N1, C1)
在 pointnet2_cls_msg.py 中,4 层 SA 的默认配置为:
SA1: N=1024, r=[0.1,0.2,0.4], C=128SA2: N=256, r=[0.2,0.4,0.8], C=256SA3: N=64, r=[0.4,0.8,1.6], C=512SA4: N=16, r=[0.8,1.6,3.2], C=1024这与论文 Figure 2 的层级降采样完全对应。
5.2 MSG 的多尺度分组
在 PointNetSetAbstraction 中,若传入多个 radius 和 nsample,会并行执行多次 Ball Query,最终将多尺度特征拼接(torch.cat)。这正是论文中 “稀疏区域信大半径、密集区域信小半径” 的工程实现。
6. 常见问题速查
| 现象 | 原因 | 解法 |
|---|---|---|
ModuleNotFoundError: No module named 'torch' | conda 下载中断,PyTorch 未完整安装 | conda clean --all -y 后重装,或改用 pip |
CondaError: Run 'conda init' before 'conda activate' | 修改 .bashrc 后未重载 | source ~/.bashrc |
CUDA out of memory | batch_size 太大 | 降为 16、8 或 4 |
nvidia-smi 显示 Failed to initialize NVML | WSL2 下 Windows 宿主驱动太老 | 更新 Windows 侧 NVIDIA Game Ready Driver 至 535+ |
| 训练时 GPU-Util 为 0%,CPU 100% | 数据在 /mnt/d/ 下,IO 瓶颈 | 将数据集移至 ~/(WSL2 内部 ext4) |
以下章节直接衔接在博客”## 2. 代码实战”之后,作为其下的三级标题内容。
7.验证
训练完成后,log/classification/pointnet2_cls_msg/ 目录下通常包含:
| 文件 | 说明 |
|---|---|
best_model.pth | 验证集上表现最优的权重快照,推理时首选加载此文件 |
last_model.pth | 最后一轮(第 50 epoch)的权重,可能已过拟合 |
logs/*.txt | 每轮 loss 与 accuracy 的文本记录 |
checkpoints/ | 中间轮次备份(视代码配置而定) |
.pth 文件的本质是 PyTorch 用 torch.save() 序列化的状态字典(state_dict),包含所有可训练参数(卷积权重、BN 滑动平均、MLP 偏置等)。它本身不可读,必须加载回与训练时结构完全一致的模型对象中才能使用。
7.1 推理与训练的本质区别
深度学习模型存在两种互斥状态,切换错误会导致同一输入输出不同结果:
| 模式 | 代码标志 | 内部行为 | 适用阶段 |
|---|---|---|---|
| 训练模式 | model.train() | BatchNorm 用当前 batch 统计量;Dropout 随机丢弃神经元 | 训练 |
| 评估模式 | model.eval() | BatchNorm 用训练期保存的滑动平均;Dropout 关闭 | 推理/测试 |
为什么推理必须加 torch.no_grad()?
训练时 PyTorch 会构建计算图以支持反向传播,占用额外显存。推理无需更新梯度,torch.no_grad() 会关闭计算图构建,显存占用降低 30%~50%,且前向传播速度略快。
7.2 单样本推理:从权重到预测
学术仓库通常只提供计算整体指标的 test_*.py,缺少类似 YOLO 的 detect.py。以下脚本实现单文件/文件夹推理,输入一个 .npy 点云文件,输出类别名称与置信度。
import osimport argparseimport numpy as npimport torch
CLASSES = ['airplane', 'bathtub', 'bed', 'bench', 'bookshelf', 'bottle', 'bowl', 'car', 'chair', 'cone', 'cup', 'curtain', 'desk', 'door', 'dresser', 'flower_pot', 'glass_box', 'guitar', 'keyboard', 'lamp', 'laptop', 'mantel', 'monitor', 'night_stand', 'person', 'piano', 'plant', 'radio', 'range_hood', 'sink', 'sofa', 'stairs', 'stool', 'table', 'tent', 'toilet', 'tv_stand', 'vase', 'wardrobe', 'xbox']
def load_model(log_dir, use_normals=False): """从 log/classification/xxx 加载权重""" if 'ssg' in log_dir: from models.pointnet2_cls_ssg import get_model model = get_model(num_class=40, normal_channel=use_normals).cuda() else: from models.pointnet2_cls_msg import get_model model = get_model(num_class=40, normal_channel=use_normals).cuda()
pth_path = os.path.join('log/classification', log_dir, 'best_model.pth') ckpt = torch.load(pth_path, map_location='cuda') model.load_state_dict(ckpt['model_state_dict'] if 'model_state_dict' in ckpt else ckpt) model.eval() return model
def preprocess(points, num_point=1024): """ 预处理:采样到固定点数 + 归一化到单位球 ⚠️ 必须与训练时做完全相同的操作,否则半径参数失效 """ N = len(points) if N > num_point: idx = np.random.choice(N, num_point, replace=False) else: idx = np.random.choice(N, num_point, replace=True) points = points[idx]
centroid = np.mean(points[:, :3], axis=0) points[:, :3] -= centroid max_dist = np.max(np.sqrt(np.sum(points[:, :3]**2, axis=1))) if max_dist > 1e-6: points[:, :3] /= max_dist return points.astype(np.float32)
def infer(model, points): pts = torch.from_numpy(points).unsqueeze(0).cuda() # (1, 1024, 3) with torch.no_grad(): pred, _ = model(pts) prob = torch.softmax(pred, dim=1) conf, pred_idx = torch.max(prob, dim=1) return pred_idx.item(), conf.item()
def main(args): model = load_model(args.weights, args.normal)
if os.path.isfile(args.source): files = [args.source] else: files = [os.path.join(args.source, f) for f in os.listdir(args.source) if f.endswith('.npy') or f.endswith('.txt')] files.sort()
results = [] for fpath in files: points = np.load(fpath) if fpath.endswith('.npy') else np.loadtxt(fpath) points = preprocess(points, num_point=1024) cls_idx, conf = infer(model, points) line = f"{os.path.basename(fpath):<30} -> {CLASSES[cls_idx]:<12} ({conf*100:.2f}%)" print(line) results.append(f"{os.path.basename(fpath)},{CLASSES[cls_idx]},{conf:.6f}")
if args.save_txt: with open(args.save_txt, 'w') as f: f.write("filename,class,confidence\n") f.write("\n".join(results)) print(f"\n结果已保存: {args.save_txt}")
if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--weights', type=str, required=True, help='如 pointnet2_cls_msg') parser.add_argument('--source', type=str, required=True, help='单个文件或文件夹') parser.add_argument('--normal', action='store_true') parser.add_argument('--save-txt', type=str, default='detect_results.csv') args = parser.parse_args() main(args)使用示例:
# 单文件推理python3 detect_cls.py --weights pointnet2_cls_msg \ --source data/modelnet40_normal_resampled/airplane/airplane_0001.npy
# 批量推理并保存 CSVpython3 detect_cls.py --weights pointnet2_cls_msg \ --source data/modelnet40_normal_resampled/airplane/ \ --save-txt airplane_results.csv7.3 实时性验证
PointNet++ 分类网络的前向传播延迟极低,但需要用正确的计时方式测量。PyTorch 的 CUDA 操作是异步的,直接用 time.time() 会测到错误的数值,必须使用 torch.cuda.Event。
import torchimport numpy as npfrom models.pointnet2_cls_msg import get_model
model = get_model(num_class=40, normal_channel=False).cuda()# ... 加载权重 ...
dummy_input = torch.randn(1, 1024, 3).cuda()
# 热身:GPU 缓存预热for _ in range(10): with torch.no_grad(): _ = model(dummy_input)
# 正式计时starter, ender = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)times = []for _ in range(100): starter.record() with torch.no_grad(): pred = model(dummy_input) ender.record() torch.cuda.synchronize() times.append(starter.elapsed_time(ender))
print(f"平均延迟: {np.mean(times):.2f} ms")print(f"等效 FPS: {1000/np.mean(times):.1f}")3090 实测参考值:
| 配置 | 平均延迟 | 等效 FPS |
|---|---|---|
| 1024 点,MSG,batch=1 | 3~5 ms | 200~300 |
| 1024 点,SSG,batch=1 | 2~4 ms | 250~400 |
| 2048 点,MSG,batch=1 | 6~8 ms | 125~160 |
影响速度的关键因素:
- 输入点数:点数翻倍,FPS 采样与 Ball Query 计算量线性增加
- MSG vs SSG:MSG 多尺度分组每层执行多次查询,比 SSG 慢 20~30%
- Batch Size:实时场景务必使用 batch=1,虽然 GPU 利用率低,但单条响应最快
7.4 可视化:让预测结果看得见
网络输出的是数字,要直观验证模型是否学到了几何特征,需将预测结果绑定到点云可视化。以下脚本使用 Open3D 弹窗显示点云,并在窗口标题中标注预测类别。
import numpy as npimport torchimport open3d as o3d
CLASSES = ['airplane', 'bathtub', 'bed', 'bench', 'bookshelf', 'bottle', 'bowl', 'car', 'chair', 'cone', 'cup', 'curtain', 'desk', 'door', 'dresser', 'flower_pot', 'glass_box', 'guitar', 'keyboard', 'lamp', 'laptop', 'mantel', 'monitor', 'night_stand', 'person', 'piano', 'plant', 'radio', 'range_hood', 'sink', 'sofa', 'stairs', 'stool', 'table', 'tent', 'toilet', 'tv_stand', 'vase', 'wardrobe', 'xbox']
def visualize_prediction(points_path, model, use_normals=False): points = np.load(points_path).astype(np.float32) if points.shape[1] == 6 and not use_normals: points = points[:, :3]
# 预处理 pts = torch.from_numpy(points).unsqueeze(0).cuda() with torch.no_grad(): pred, _ = model(pts) pred_idx = torch.argmax(pred, dim=1).item() conf = torch.softmax(pred, dim=1)[0][pred_idx].item()
# Open3D 可视化 pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points[:, :3])
# 按高度着色 colors = np.zeros((points.shape[0], 3)) z_norm = (points[:, 2] - points[:, 2].min()) / (points[:, 2].max() - points[:, 2].min() + 1e-6) colors[:, 0] = z_norm colors[:, 2] = 1 - z_norm pcd.colors = o3d.utility.Vector3dVector(colors)
vis = o3d.visualization.Visualizer() vis.create_window(window_name=f"Pred: {CLASSES[pred_idx]} ({conf*100:.2f}%)", width=800, height=600) vis.add_geometry(pcd) vis.get_render_option().point_size = 4.0 vis.run() vis.destroy_window()效果:弹出一个 3D 窗口,标题显示 Pred: airplane (94.32%),点云按高度渲染为红蓝渐变色,可旋转缩放观察模型是否正确识别了几何结构。
代码实战-语义分割
语义分割与分类的本质区别在于输出维度:分类任务输出一个全局类别向量 (batch, num_classes),而语义分割输出逐点分类张量 (batch, N, num_classes),即每个输入点都携带一个类别标签。这使得语义分割可以直接衔接下游的几何分析(如体积计算、表面重建)。
1. 数据集准备:逐点标签的格式要求
语义分割的监督信号不再是”这张图是什么”,而是”这个点是什么”。因此数据集必须提供与点云逐点一一对应的标签数组。
1.1 官方数据集(ShapeNet Part)
PointNet++ 官方仓库支持 ShapeNet Part 部件分割数据集,其结构如下:
data/shapenetcore_partanno_segmentation_benchmark_v0_normal/├── 02691156/ # 类别 ID(如 airplane)│ ├── 1a04e3eabea8300cc8c9/ # 实例 ID│ │ └── points/ # 点云文件(含坐标与法向量)│ └── ...├── 02773838/ # 另一类别└── train_test_split/ # 划分文件下载命令:
cd ~/projects/pointcloud/Pointnet_Pointnet2_pytorch/datawget https://shapenet.cs.stanford.edu/media/shapenetcore_partanno_segmentation_benchmark_v0_normal.zipunzip shapenetcore_partanno_segmentation_benchmark_v0_normal.ziprm shapenetcore_partanno_segmentation_benchmark_v0_normal.zip1.2 自定义数据集(以车厢分割为例)
对于工程落地(如车厢壁/车钩/煤炭分割),需自行准备逐点标注数据。标准文件结构为:
my_seg_dataset/├── train/│ ├── points/ # (N, 3) 坐标,float32│ │ ├── 001.npy│ │ └── ...│ └── labels/ # (N,) 整数标签,int64│ ├── 001.npy # 0=背景, 1=车厢壁, 2=车钩, 3=煤炭│ └── ...└── test/ ├── points/ └── labels/标注原则:标签数组的长度必须与点云数组的行数严格相等,且顺序一一对应。若使用 CloudCompare 标注,导出时需确保点序未被重排。
2. 训练命令
语义分割的显存占用显著高于分类(需在解码器 FP 层保存中间特征用于上采样),因此 batch_size 需适当降低。
cd ~/projects/pointcloud/Pointnet_Pointnet2_pytorch
# 语义分割(S3DIS 或自定义数据集)python3 train_semseg.py \ --model pointnet2_sem_seg \ --log_dir pointnet2_sem_seg \ --batch_size 16 \ --epoch 50
# 部件分割(ShapeNet Part,带法向量)python3 train_partseg.py \ --model pointnet2_part_seg_msg \ --normal \ --log_dir pointnet2_part_seg_msg \ --batch_size 16 \ --epoch 50与分类训练的关键差异:
| 参数 | 分类 | 分割 | 原因 |
|---|---|---|---|
batch_size | 24 | 16(或更小) | FP 上采样层需缓存高分辨率特征图 |
epoch | 50 | 50~100 | 逐点收敛慢于全局收敛 |
--normal | 可选 | 强烈建议 | 法向量对”平面/曲面”的边界判断至关重要 |
3. 验证命令与评估指标
python3 test_semseg.py --log_dir pointnet2_sem_seg --batch_size 16核心指标:mIoU(mean Intersection over Union)
语义分割不采用分类任务中的 accuracy,因为类别极度不平衡(如背景点可能占 80%,全部预测为背景也能得到 80% accuracy,但毫无意义)。
IoU 定义:
IoU = 预测正确的点数 / (预测为该类的点数 + 真值为该类的点数 - 预测正确的点数) = 交集 / 并集mIoU 是所有类别 IoU 的算术平均,能公平反映模型在 minority class(如车钩)上的表现。
4. 产物详解
训练完成后,log/sem_seg/pointnet2_sem_seg/ 目录下包含:
| 文件/目录 | 内容 | 与分类产物的区别 |
|---|---|---|
best_model.pth | mIoU 最高轮次的权重 | 评估标准是 IoU 而非 accuracy |
logs/*.txt | 每轮训练 loss 与验证 mIoU | 记录的是逐点交叉熵与各类 IoU |
checkpoints/ | 中间轮次备份 | 分割模型体积略大(含 FP 层参数) |
权重加载方式与分类完全一致,仅模型结构需切换为 pointnet2_sem_seg:
from models.pointnet2_sem_seg import get_modelmodel = get_model(num_class=4).cuda() # 4 类:背景/车厢壁/车钩/煤炭5. 推理代码:逐点分类(核心)
主管需求可抽象为:输入任意点数的原始点云(如 10000 点),输出等长的逐点标签数组(10000,)。工程上最稳妥的方案是”FPS 降采样 → 网络推理 → 最近邻插值回原始分辨率”,该流程不依赖网络内部是否完美支持变长输入,兼容所有实现版本。
import osimport argparseimport numpy as npimport torchfrom scipy.spatial import cKDTree
# 分割类别定义(按需修改)SEG_CLASSES = { 0: 'background', 1: 'carriage_wall', 2: 'coupler', 3: 'coal'}
def farthest_point_sample(points, npoint): """ FPS 最远点采样:保证采样点空间覆盖均匀, 避免随机采样导致角落遗漏。 """ N, D = points.shape xyz = points[:, :3] centroids = np.zeros(npoint) distance = np.ones(N) * 1e10 farthest = np.random.randint(0, N)
for i in range(npoint): centroids[i] = farthest centroid = xyz[farthest, :] dist = np.sum((xyz - centroid) ** 2, -1) mask = dist < distance distance[mask] = dist[mask] farthest = np.argmax(distance, -1)
return points[centroids.astype(np.int32)]
def infer_semseg(model, raw_points, num_point=4096, num_class=4): """ 输入: raw_points (N, 3) 任意点数,如 10000 输出: labels (N,) 每个原始点的类别标签 """ # 1. FPS 降采样到网络训练时的固定输入尺寸 sampled = farthest_point_sample(raw_points, num_point)
# 2. 归一化(必须与训练时完全一致!) centroid = np.mean(sampled, axis=0) sampled -= centroid max_dist = np.max(np.sqrt(np.sum(sampled**2, axis=1))) sampled /= (max_dist + 1e-8)
# 3. 网络推理 pts = torch.from_numpy(sampled).unsqueeze(0).float().cuda() # (1, 4096, 3) model.eval() with torch.no_grad(): pred = model(pts) # (1, 4096, num_class) sampled_labels = torch.argmax(pred, dim=2).squeeze(0).cpu().numpy() # (4096,)
# 4. 插值回原始点数:每个原始点找采样点中最近的一个,复制标签 tree = cKDTree(sampled[:, :3]) _, nearest_idx = tree.query(raw_points[:, :3], k=1) final_labels = sampled_labels[nearest_idx]
return final_labels
def main(args): # 加载模型 from models.pointnet2_sem_seg import get_model model = get_model(num_class=args.num_class).cuda()
checkpoint = torch.load(args.weights, map_location='cuda') if 'model_state_dict' in checkpoint: model.load_state_dict(checkpoint['model_state_dict']) else: model.load_state_dict(checkpoint)
print(f"✅ 已加载语义分割模型: {args.weights}") print(f" 类别数: {args.num_class}, 采样点数: {args.num_point}\n")
# 解析输入源 if os.path.isfile(args.source): files = [args.source] else: files = [os.path.join(args.source, f) for f in os.listdir(args.source) if f.endswith('.npy') or f.endswith('.txt')] files.sort()
os.makedirs(args.save_dir, exist_ok=True)
# 批量推理 for fpath in files: # 加载原始点云 if fpath.endswith('.npy'): raw_points = np.load(fpath).astype(np.float32) else: raw_points = np.loadtxt(fpath).astype(np.float32)
# 推理 labels = infer_semseg(model, raw_points, args.num_point, args.num_class)
# 保存逐点标签 fname = os.path.basename(fpath) out_name = fname.replace('.npy', '_pred.npy').replace('.txt', '_pred.npy') np.save(os.path.join(args.save_dir, out_name), labels)
# 统计各类点数 unique, counts = np.unique(labels, return_counts=True) stats = {SEG_CLASSES.get(u, f'class_{u}'): c for u, c in zip(unique, counts)} print(f"{fname:<30} -> {stats}")
print(f"\n📄 所有逐点标签已保存至: {args.save_dir}/")
if __name__ == '__main__': parser = argparse.ArgumentParser(description='PointNet++ 语义分割推理(逐点分类)') parser.add_argument('--weights', type=str, required=True, help='权重路径,如 log/sem_seg/pointnet2_sem_seg/best_model.pth') parser.add_argument('--source', type=str, required=True, help='输入:单个 .npy/.txt 文件,或文件夹') parser.add_argument('--save_dir', type=str, default='seg_results', help='输出标签保存目录') parser.add_argument('--num_point', type=int, default=4096, help='网络固定输入点数(必须与训练时一致)') parser.add_argument('--num_class', type=int, default=4, help='分割类别数') args = parser.parse_args() main(args)使用示例
# 单文件推理:1 万点车厢点云python3 detect_seg.py \ --weights log/sem_seg/pointnet2_sem_seg/best_model.pth \ --source data/carriage/points/001.npy \ --num_class 4
# 批量推理:整个文件夹python3 detect_seg.py \ --weights log/sem_seg/pointnet2_sem_seg/best_model.pth \ --source data/carriage/points/ \ --save_dir carriage_seg_results \ --num_class 4输出示例:
001.npy -> {'background': 1240, 'carriage_wall': 3850, 'coupler': 210, 'coal': 4700}002.npy -> {'background': 980, 'carriage_wall': 4100, 'coupler': 195, 'coal': 4725}下游衔接:提取煤炭点计算体积
推理完成后,标签文件与原始点云一一对应,可直接按类别筛选:
# 加载原始点云与预测标签points = np.load('data/carriage/points/001.npy') # (10000, 3)labels = np.load('carriage_seg_results/001_pred.npy') # (10000,)
# 提取煤炭点(类别 3)coal_points = points[labels == 3] # (4700, 3)
# 直接送入体积计算模块(体素法 / 切片法 / 凹包法)np.save('coal_001.npy', coal_points)6 小结
语义分割的代码实战与分类共享同一套环境(conda + PyTorch + CUDA),差异主要体现在:
- 数据格式:需要逐点标签
(N,),而非全局标签(1,) - 评估指标:采用 mIoU 而非 accuracy,以应对类别不平衡
- 推理流程:输入任意点数 → FPS 降采样到固定尺寸 → 网络推理 → 最近邻插值回原分辨率 → 输出等长标签数组
- 下游衔接:按类别索引直接提取目标点云子集(如煤炭表面),无缝衔接体积计算
至此,PointNet++ 的分类与语义分割两条技术线均已打通,可根据业务需求(全局识别 or 逐点解析)灵活选用。
部分信息可能已经过时









