mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
4888 字
13 分钟
PointNet++学习笔记
2026-04-27

理论#

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 验证
GPUNVIDIA 显卡(显存 ≥ 6GB)3090(24GB)可轻松跑通,1060 也能跑,需调小 batch_size
CUDA11.8 或 12.xPyTorch 2.0.1 + CUDA 11.8 为稳态组合
Python3.9 / 3.103.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.sh
bash ~/miniconda.sh -b -p $HOME/miniconda3
# 写入环境变量
echo 'export PATH="$HOME/miniconda3/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

为什么需要 source ~/.bashrc
conda init 或手动修改 ~/.bashrc 后,当前终端进程仍在使用旧的 shell 配置。source 命令的作用是在当前进程内重新读取并执行脚本,实现”热重载”,无需关闭窗口即可让新配置生效。如果不执行,会出现 conda: command not foundCondaError: 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 -y
conda activate pointnet

踩坑记录:Anaconda 商业许可 ToS 拦截
2024 年底起,Anaconda 要求用户首次使用 pkgs/mainpkgs/r 渠道前显式接受服务条款。若未接受,conda install 会报错 CondaToSNonInteractiveError

# 执行以下命令接受条款(仅首次需要)
conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main
conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r

1.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 -y
pip 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/cu118

1.5 验证 GPU 可用性#

python -c "import torch; print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"

预期输出

True
NVIDIA GeForce RTX 3090

2. 数据集准备#

2.1 下载 ModelNet40#

ModelNet40 是点云分类的基准数据集,包含 40 类共 12311 个 CAD 模型,每个模型采样为 10000 个点并带有法线信息。

mkdir -p ~/projects/pointcloud/data
cd ~/projects/pointcloud/data
wget https://shapenet.cs.stanford.edu/media/modelnet40_normal_resampled.zip
unzip 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/pointcloud
git clone https://github.com/yanx27/Pointnet_Pointnet2_pytorch.git

3.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_pytorch
python train_classification.py --model pointnet2_cls_msg --batch_size 24 --epoch 50 --log_dir pointnet2_cls_msg

参数说明

参数含义
--modelpointnet2_cls_msg使用多尺度分组(MSG)版本
--batch_size24每批次 24 个样本;显存不足时降为 16 或 8
--epoch50训练轮数
--log_dirpointnet2_cls_msg日志与模型保存目录

4.2 训练过程解读#

终端会输出类似以下内容:

Epoch 1/50, train loss: 1.8234, train acc: 0.4231, test loss: 1.5432, test acc: 0.5123
Epoch 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=128
SA2: N=256, r=[0.2,0.4,0.8], C=256
SA3: N=64, r=[0.4,0.8,1.6], C=512
SA4: N=16, r=[0.8,1.6,3.2], C=1024

这与论文 Figure 2 的层级降采样完全对应。

5.2 MSG 的多尺度分组#

PointNetSetAbstraction 中,若传入多个 radiusnsample,会并行执行多次 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 memorybatch_size 太大降为 16、8 或 4
nvidia-smi 显示 Failed to initialize NVMLWSL2 下 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 os
import argparse
import numpy as np
import 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
# 批量推理并保存 CSV
python3 detect_cls.py --weights pointnet2_cls_msg \
--source data/modelnet40_normal_resampled/airplane/ \
--save-txt airplane_results.csv

7.3 实时性验证#

PointNet++ 分类网络的前向传播延迟极低,但需要用正确的计时方式测量。PyTorch 的 CUDA 操作是异步的,直接用 time.time() 会测到错误的数值,必须使用 torch.cuda.Event

import torch
import numpy as np
from 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=13~5 ms200~300
1024 点,SSG,batch=12~4 ms250~400
2048 点,MSG,batch=16~8 ms125~160

影响速度的关键因素:

  • 输入点数:点数翻倍,FPS 采样与 Ball Query 计算量线性增加
  • MSG vs SSG:MSG 多尺度分组每层执行多次查询,比 SSG 慢 20~30%
  • Batch Size:实时场景务必使用 batch=1,虽然 GPU 利用率低,但单条响应最快

7.4 可视化:让预测结果看得见#

网络输出的是数字,要直观验证模型是否学到了几何特征,需将预测结果绑定到点云可视化。以下脚本使用 Open3D 弹窗显示点云,并在窗口标题中标注预测类别。

import numpy as np
import torch
import 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/data
wget https://shapenet.cs.stanford.edu/media/shapenetcore_partanno_segmentation_benchmark_v0_normal.zip
unzip shapenetcore_partanno_segmentation_benchmark_v0_normal.zip
rm shapenetcore_partanno_segmentation_benchmark_v0_normal.zip

1.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_size2416(或更小)FP 上采样层需缓存高分辨率特征图
epoch5050~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.pthmIoU 最高轮次的权重评估标准是 IoU 而非 accuracy
logs/*.txt每轮训练 loss 与验证 mIoU记录的是逐点交叉熵与各类 IoU
checkpoints/中间轮次备份分割模型体积略大(含 FP 层参数)

权重加载方式与分类完全一致,仅模型结构需切换为 pointnet2_sem_seg

from models.pointnet2_sem_seg import get_model
model = get_model(num_class=4).cuda() # 4 类:背景/车厢壁/车钩/煤炭

5. 推理代码:逐点分类(核心)#

主管需求可抽象为:输入任意点数的原始点云(如 10000 点),输出等长的逐点标签数组(10000,)。工程上最稳妥的方案是”FPS 降采样 → 网络推理 → 最近邻插值回原始分辨率”,该流程不依赖网络内部是否完美支持变长输入,兼容所有实现版本。

import os
import argparse
import numpy as np
import torch
from 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),差异主要体现在:

  1. 数据格式:需要逐点标签 (N,),而非全局标签 (1,)
  2. 评估指标:采用 mIoU 而非 accuracy,以应对类别不平衡
  3. 推理流程:输入任意点数 → FPS 降采样到固定尺寸 → 网络推理 → 最近邻插值回原分辨率 → 输出等长标签数组
  4. 下游衔接:按类别索引直接提取目标点云子集(如煤炭表面),无缝衔接体积计算

至此,PointNet++ 的分类与语义分割两条技术线均已打通,可根据业务需求(全局识别 or 逐点解析)灵活选用。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

PointNet++学习笔记
https://fredsblog-2dc.pages.dev/posts/note-pointnet/
作者
Fredzhe
发布于
2026-04-27
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时