简介:面向点云深度学习初学者与需要落地自有数据的开发者,这份资源聚焦于用PyTorch复现PointNet与PointNet++的分类与分割流程,并重点解决带Classification属性的LAS格式点云如何接入训练的问题。包内共19个文件,以15个Python脚本为主体,覆盖分类、语义分割、部件分割及工具函数等模块,另附已分类LAS样本用于训练验证、一个LAS文件用于预测,以及说明文档,压缩包约26KB,体量轻便、便于快速查阅与二次修改。已有896人学习下载,说明该方向需求较为集中。读者可据此理解从LAS数据解析、数据集构建到模型训练与推理的完整链路,并借助现成脚本在自有标注数据上跑通PointNet或PointNet++,减少环境配置与数据格式转换的试错成本,适合作为点云语义分割入门与项目起步的参考实现。
1. 从一份带 Classification 属性的 LAS 说起:PointNet 系列怎么吃下自己的点云
手里有一批测绘或激光雷达扫出来的 LAS 点云,每条点都带着 Classification 分类码,想直接丢进 PointNet / PointNet++ 训练分类或语义分割,结果第一步就卡住——LAS 是二进制格式,PyTorch 那边要的是规整的张量,中间隔着坐标归一化、分类码映射、采样对齐好几道坎。这个项目干的就是把这条链路打通:读 LAS、抽 XYZ 和 Classification、按类别重映射、组织成(B, N, C)张量喂给网络,再跑通训练和推理。适合已经跑过官方 ModelNet 示例、现在要换成自己实测点云的开发者,也适合做 pytorch 点云语义分割、想搞清楚数据管线怎么改的人。下面按「数据怎么进 → 网络怎么搭 → 怎么跑 → 怎么改」拆开讲。
2. 数据管线拆解:LAS 到张量中间到底发生了什么
PointNet 系列对输入的要求其实很朴素:一批点,每个点若干通道,形状(B, N, C),C 常见是 3(纯 XYZ)或 6(XYZ + 法向量/颜色)。LAS 文件不是这个形状,它是一堆带属性的记录,还带地理坐标量级。所以数据管线要干三件事:解析 LAS、把 Classification 变成训练标签、把坐标缩放到网络能学的范围。这三件事任何一步做错,训练 loss 都会给你脸色看。
2.1 LAS 解析与字段抽取
LAS 本质是二进制点记录,常见做法是用laspy读,它把 header 和 points 都解析好,按字段名取数组。我一般先确认版本和 point format,因为不同 format 带的字段不一样,Classification 在低版本是 1 字节,高版本可能拆成 classification 和 classification_flags。
import laspy import numpy as np def read_las(path): las = laspy.read(path) # XYZ 用 scaled 值,避免原始整型坐标量级过大 xyz = np.vstack((las.x, las.y, las.z)).transpose().astype(np.float32) # Classification 在 laspy 里是数组,直接取 cls = np.asarray(las.classification, dtype=np.int64) return xyz, cls xyz, cls = read_las("scan_001.las") print(xyz.shape, cls.shape, np.unique(cls))las.x/y/z返回的是已经乘过 scale 和 offset 的浮点坐标,这点很关键——如果自己去读 raw 整型再手动缩放,很容易漏掉 header 里的 offset,导致点云整体偏移。np.unique(cls)先看一眼有哪些分类码,后面做映射表就靠它。参数上dtype=np.float32是为了和网络权重精度对齐,int64是 PyTorch 交叉熵对标签的默认要求。
2.2 Classification 码到训练标签的映射
LAS 的 Classification 是行业约定码,比如 2 是地面、6 是建筑、5 是植被,但你的任务未必用全部类别,也可能想把若干码合并成一类。直接拿原始码当 label 会出问题:类别不连续(比如只有 2、5、6),交叉熵的 num_classes 对不上,还会浪费输出维度。常见做法是建一张映射表,把用到的码压到 0..K-1。
# 原始码 -> 连续标签,-1 表示忽略 CODE_TO_LABEL = { 2: 0, # ground 5: 1, # vegetation 6: 2, # building } def remap_labels(cls): label = np.full(cls.shape, -1, dtype=np.int64) for code, lab in CODE_TO_LABEL.items(): label[cls == code] = lab return label label = remap_labels(cls) valid = label >= 0 xyz, label = xyz[valid], label[valid]这里用-1标记不关心的点,后面在采样和 loss 里统一过滤。valid掩码同时把坐标也筛一遍,保证点和标签一一对应。参数上CODE_TO_LABEL就是你要按自己数据改的地方,改完记得同步改网络的num_classes。这一步是血泪经验:映射表和 num_classes 不一致,训练能跑但精度永远上不去,因为标签和输出维度错位了。
2.3 采样、归一化与张量组织
一张 LAS 动辄几十万上百万点,PointNet 一次吃不了这么多,得采样到固定 N(常见 4096 或 8192)。采样要保证类别别被大类别淹没,我一般按类别分层采样,再补齐。归一化则是把坐标平移到以质心为原点、缩放到单位球,这一步不做,网络对绝对坐标量级非常敏感。
def sample_and_normalize(xyz, label, n_points=4096): idx = np.random.choice(len(xyz), n_points, replace=len(xyz) < n_points) xyz_s, label_s = xyz[idx], label[idx] centroid = xyz_s.mean(axis=0) xyz_s = xyz_s - centroid scale = np.max(np.linalg.norm(xyz_s, axis=1)) xyz_s = xyz_s / max(scale, 1e-6) return xyz_s.astype(np.float32), label_s xyz_t, label_t = sample_and_normalize(xyz, label)replace参数在点数不足 N 时允许重复采样,避免报错;scale用最大半径而不是标准差,是为了让所有点落在单位球内,和 PointNet 论文的预处理一致。归一化只对 XYZ 做,标签不动。组织成 batch 时用torch.from_numpy(...).unsqueeze(0)加一维即可,形状就是(1, N, 3)。
3. 网络与训练循环:PointNet 和 PointNet++ 各自怎么接
数据管线通了,接下来是网络。PointNet 和 PointNet++ 在输入层是兼容的,都是(B, N, C),区别在于特征提取方式:PointNet 用共享 MLP + 全局 max pool,PointNet++ 用 set abstraction 分层采样聚合局部邻域。选哪个取决于你的任务——单目标分类用 PointNet 够,语义分割或复杂场景建议 PointNet++。这一章把两种接法和训练循环讲清楚。
3.1 PointNet 主干的最小实现
PointNet 的核心就三层:输入变换(可选)、共享 MLP、对称函数。共享 MLP 在实现上就是 1x1 卷积或对每个点独立的全连接,PyTorch 里用Conv1d最省事。
import torch import torch.nn as nn class PointNetCls(nn.Module): def __init__(self, num_classes=3, in_ch=3): super().__init__() self.mlp = nn.Sequential( nn.Conv1d(in_ch, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, 1024, 1), nn.BatchNorm1d(1024), nn.ReLU(), ) self.head = nn.Sequential( nn.Linear(1024, 512), nn.BatchNorm1d(512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, num_classes), ) def forward(self, x): # x: (B, N, C) x = x.transpose(1, 2) # -> (B, C, N) x = self.mlp(x) # -> (B, 1024, N) x = torch.max(x, dim=2)[0] # 全局 max pool -> (B, 1024) return self.head(x)transpose(1, 2)是因为Conv1d要求通道在前,这是新手最容易翻车的地方,忘了转就会把点数当通道。torch.max(..., dim=2)[0]就是对称函数,取每个特征通道的最大响应,保证点顺序无关。num_classes必须等于映射表里的类别数,in_ch如果加了法向量就改成 6。
3.2 PointNet++ 的 set abstraction 接法
PointNet++ 的 set abstraction 层做三件事:最远点采样选中心、ball query 找邻域、对邻域做 mini-PointNet 聚合。自己从零写工作量大,常见做法是复用成熟实现里的SAModule,或者用pointnet2_ops这类算子库。如果不想引额外依赖,可以先用简化版:固定分组 + 局部 max。
def farthest_point_sample(xyz, npoint): # xyz: (B, N, 3) -> 返回 (B, npoint) 索引 B, N, _ = xyz.shape idx = torch.zeros(B, npoint, dtype=torch.long, device=xyz.device) dist = torch.full((B, N), 1e10, device=xyz.device) farthest = torch.zeros(B, dtype=torch.long, device=xyz.device) for i in range(npoint): idx[:, i] = farthest centroid = xyz[torch.arange(B), farthest].unsqueeze(1) d = ((xyz - centroid) ** 2).sum(-1) dist = torch.min(dist, d) farthest = dist.max(-1)[1] return idxfarthest_point_sample是 PointNet++ 的采样核心,保证选出的中心点覆盖均匀,比随机采样稳定。dist维护每个点到已选中心的最小距离,每轮取最大者作为新中心。这个循环在 Python 里跑会慢,实际项目里一般换成 CUDA 算子,但理解逻辑靠这段就够。拿到索引后用gather取点,再做 ball query 分组,形状变成(B, npoint, nsample, C),后面接 mini-PointNet。
3.3 训练循环与损失处理
训练循环本身是标准 PyTorch 套路,关键在忽略标签-1的点和类别不均衡。交叉熵默认ignore_index=-100,我们用的是-1,得显式传。
import torch.nn.functional as F def train_step(model, optimizer, xyz, label): model.train() logits = model(xyz) # (B, num_classes) loss = F.cross_entropy(logits, label, ignore_index=-1) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()ignore_index=-1让被过滤的点不参与梯度,这是和 2.2 节映射表配套的。如果类别不均衡严重,可以给cross_entropy传weight张量,按类别频率的倒数设。学习率常见从 1e-3 起,配合StepLR每 20 epoch 降一次。验证时记得model.eval()加torch.no_grad(),否则 BatchNorm 的统计量会被验证集污染,指标虚高。
4. 跑起来:环境、数据组织与训练命令
前面讲的是原理和代码片段,这一章落到「怎么在自己机器上跑通」。PointNet 系列对环境的坑主要集中在 CUDA 版本和算子编译上,数据组织则决定了你能不能直接复用现成脚本。按顺序走一遍,基本能避开大部分翻车点。
4.1 环境与依赖
PyTorch 版本要和 CUDA 对齐,这是老生常谈但每次换机器都要重新确认。laspy负责读 LAS,numpy做数组操作,如果用到 PointNet++ 的 CUDA 算子,还需要torch的 C++ 扩展编译环境。
# 以 CUDA 11.8 为例,按自己驱动改 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install laspy numpy scipy # 若用 pointnet2_ops,需先装好 nvcc 再编译 pip install pointnet2_ops--index-url指定 wheel 源,避免装到 CPU 版。pointnet2_ops编译失败多半是nvcc不在 PATH 或 CUDA 版本和 torch 不匹配,先nvcc --version和torch.version.cuda对一下。纯 PointNet 不需要这步,能省不少事。
4.2 数据目录与配置
数据组织建议按「原始 LAS 一个目录、预处理后的 npz 一个目录、划分文件一个目录」来放,脚本里用配置集中管理路径,别把路径写死在代码里。
| 目录/文件 | 作用 | 备注 |
|---|---|---|
data/raw/*.las | 原始点云 | 保持只读,别原地改 |
data/processed/*.npz | 采样归一化后的点与标签 | 缓存,加速重复训练 |
data/split/train.txt | 训练集文件列表 | 一行一个文件名 |
configs/default.yaml | 路径、N、类别表 | 改参数只动这里 |
预处理脚本读raw、写processed,训练脚本只读processed。这样调采样参数时重跑预处理即可,不用每次重新解析 LAS。configs里把CODE_TO_LABEL和num_classes放一起,改一处全链路生效,避免前面说的映射错位。
4.3 训练与推理命令
训练入口一般是一个train.py,参数用argparse或配置文件注入。跑之前先用小样本过一遍,确认形状和 loss 正常再上全量。
# 预处理:LAS -> npz python preprocess.py --raw_dir data/raw --out_dir data/processed --n_points 4096 # 训练 python train.py --config configs/default.yaml --epochs 50 --batch_size 8 --lr 1e-3 # 推理单文件 python infer.py --ckpt runs/best.pth --las data/raw/scan_001.las--n_points要和网络输入一致,改了预处理参数记得同步训练配置。batch_size受显存限制,PointNet++ 比 PointNet 吃显存,8 跑不动就降到 4 或 2。推理脚本里要复用同一套归一化逻辑,否则训练和推理的坐标分布不一致,预测会整体偏移——这个坑很隐蔽,指标看着正常但可视化对不上。
5. 避坑与排查:训练不收敛时先看这几处
点云训练不像图像那么直观,loss 不降的时候很难一眼看出问题。下面几条是我在实际项目里反复遇到的,按「现象 → 原因 → 解决」记下来,遇到时对着排查能省不少时间。
5.1 现象:loss 一直不降或震荡
原因通常是标签和输出维度错位,或者归一化没做。先打印一个 batch 的label.max()和num_classes对比,再看xyz的数值范围是否在单位球附近。解决:确认CODE_TO_LABEL的值域是0..K-1,num_classes=K,归一化函数在训练和推理里是同一份代码。
5.2 现象:训练精度高、验证精度崩
多半是数据泄漏或划分不合理。同一片区域的点被随机拆到训练和验证集,模型记住了空间位置而不是类别特征。解决:按文件或按空间块划分,别按点随机拆;验证集单独做归一化统计时用训练集的质心和尺度,避免信息泄漏。
5.3 现象:PointNet++ 编译报错或运行时报找不到算子
pointnet2_ops没编译成功,或者 CUDA 版本和 torch 不匹配。解决:先确认nvcc --version与torch.version.cuda一致,再重装;实在编不过就退回纯 PointNet 或简化版分组实现,先跑通流程再优化。
5.4 现象:显存爆掉
n_points设太大,或 PointNet++ 的nsample邻域点数过多。解决:先把n_points降到 2048 验证流程,再逐步加;batch_size同步下调;推理时用torch.no_grad()并分块处理大文件。
5.5 现象:预测结果整体偏移或镜像
训练和推理的坐标归一化不一致,或者 LAS 的 scale/offset 处理不同。解决:把归一化封装成一个函数,训练和推理都调它;读 LAS 统一用las.x/y/z的 scaled 值,别混用 raw 整型。
6. 二次开发:换类别、换网络、加分割头
跑通之后,真正的工作量在改造。这个项目的结构决定了改造点很集中:类别体系在映射表,网络在模型文件,任务是分类还是分割看输出头。下面按改造频率从高到低讲,最后给一个验证改造是否正确的习惯。
6.1 换类别体系
改CODE_TO_LABEL和num_classes是第一步,但别忘了预处理缓存。data/processed里的 npz 存的是旧标签,改完映射必须重跑preprocess.py,否则训练读到的还是旧标签。我一般会在 npz 里存一个label_version字段,训练时校验版本号,不一致直接报错,比默默训错强。
6.2 分类换语义分割
分类是整场景一个标签,分割是每个点一个标签。改造点:网络输出从(B, num_classes)变成(B, N, num_classes),PointNet 里去掉全局 max pool 换成逐点特征,PointNet++ 用 feature propagation 上采样回原点数。损失还是交叉熵,但要对每个点算。数据侧标签从标量变成(N,)数组,采样时点和标签同步采。
# 分割头示意:逐点分类 class PointNetSeg(nn.Module): def __init__(self, num_classes=3, in_ch=3): super().__init__() self.mlp = nn.Sequential( nn.Conv1d(in_ch, 64, 1), nn.ReLU(), nn.Conv1d(64, 128, 1), nn.ReLU(), nn.Conv1d(128, 1024, 1), nn.ReLU(), ) self.seg_head = nn.Conv1d(1024 + 64, num_classes, 1) def forward(self, x): # (B, N, C) x = x.transpose(1, 2) # (B, C, N) local = self.mlp[:2](x) # 局部特征 (B, 64, N) global_feat = self.mlp(x).max(dim=2, keepdim=True)[0] feat = torch.cat([local, global_feat.expand(-1, -1, x.size(2))], dim=1) return self.seg_head(feat).transpose(1, 2) # (B, N, num_classes)local保留逐点信息,global_feat提供场景上下文,拼接后逐点出分类。expand把全局特征广播到每个点。这个结构是 PointNet 分割版的经典做法,改造时注意transpose的位置别弄反。
6.3 验证改造是否正确
改完别急着看精度,先做形状和数值的冒烟测试:构造一个(2, 1024, 3)的随机张量,前向一遍,确认输出形状是(2, 1024, num_classes),再检查loss.backward()后梯度非空。然后拿一个已知标签的小样本过拟合,loss 能降到接近 0 说明链路通。最后才上全量数据看指标。
| 改造点 | 验证方法 | 通过标准 |
|---|---|---|
| 类别映射 | 打印 unique(label) | 落在 0..K-1 |
| 分割头 | 随机张量前向 | 输出 (B, N, K) |
| 归一化 | 对比训练/推理函数 | 同一份代码 |
| 缓存版本 | 校验 label_version | 与配置一致 |
从那以后我每次改完数据管线,都强制先跑一遍「单样本过拟合」再上全量——这一步花五分钟,能挡掉后面几小时的无效训练。希望帮到你。
本文还有配套的精品资源,点击获取