简介:面向点云深度学习初学者与需要落地自有数据的开发者,这份资源聚焦于用PyTorch复现PointNet与PointNet++的分类与分割流程,并重点解决「如何把带Classification属性的LAS点云接入训练」这一常见卡点。包内共19个文件,以15个Python脚本为主体,涵盖分类、语义分割、部件分割等模型实现与数据加载逻辑,另附已分类LAS样本用于训练验证、一份LAS文件用于预测,以及说明文档,压缩包约26KB,体量轻便、便于快速跑通。已有896人学习下载,说明该流程在社区中具备一定参考价值。读者可据此对照原始开源代码,理解LAS点云从读取、分类属性解析到数据集构建的衔接方式,并借助现成脚本完成训练与预测的完整闭环,减少自行改造数据接口时的试错成本,适合具备基础PyTorch使用经验、希望将点云模型迁移到实际测绘或三维场景数据上的学习者。
1. 手里那批带 Classification 的 LAS,到底能不能直接喂给 PointNet++
上周有个做电力巡检的朋友甩过来一个压缩包,里面是几十份 LAS 点云,每份都带 Classification 字段,问我能不能直接丢进 PointNet++ 训练语义分割。我打开一看,分类码是 1、2、5、6 这种 LAS 标准类别,而他的任务其实是把绝缘子、导线、杆塔分开。这就是典型的「数据看着齐、标签对不上」——LAS 里的 Classification 是测绘规范里的地物分类,不是你要训练的语义类别,两者之间还差一层映射。
这篇笔记就围绕这件事展开:一份带 Classification 属性的 LAS 点云,怎么一步步变成 PointNet / PointNet++ 能吃的 PyTorch 数据集,中间哪些参数会翻车,训练时 loss 不降该往哪看。适合已经跑通过官方 ModelNet / S3DIS 示例、想换自己数据的人,也适合刚接触 pytorch 点云语义分割、手里只有测绘成果的新手。下面所有代码都是我在实际项目里跑过的骨架,改路径就能用。
2. 从 LAS 到 npy:坐标、分类码与采样密度三件事
PointNet 系列的网络输入非常朴素:一个(N, 3)或(N, 6)的浮点张量,外加一个(N,)的整型标签。它不认 LAS,也不认 PCD,所以第一道工序永远是把 LAS 拆成「坐标矩阵 + 标签向量」。这一步看着简单,实际决定了后面训练能不能收敛。
2.1 为什么不能直接把 LAS 的 XYZ 丢进去
LAS 文件里的 X、Y、Z 通常是双精度,而且带投影坐标(比如 6 位数甚至 7 位数的东坐标)。直接送进网络有两个后果:一是 float32 精度不够,几百万量级的坐标转 float32 后相邻点会被压成同一个值;二是数值范围太大,网络第一层 MLP 的权重初始化根本压不住。常见做法是先减去点云包围盒中心,再做归一化,把坐标缩放到[-1, 1]区间。这一步在 PointNet 原文里叫「normalize to unit sphere」,但工程上更常用的是按最大半径缩放,避免离群点把整体压扁。
另一个坑是 Classification 字段。LAS 1.4 里它是classification,0 是未分类,1 是未分配,2 是地面,3、4、5 是植被,6 是建筑。你要训练的类别如果和这套编码不一致,必须先建映射表。我一般会在配置里写一个 dict,把原始码映射到 0..K-1 的连续标签,映射不到的一律归到 ignore_index,别硬塞。
2.2 用 laspy 读取并落成 npy 的完整脚本
下面这段是我常用的转换脚本,依赖laspy和numpy,跑完每份 LAS 生成一个.npy,里面存xyz(float32,已归一化)和label(int64)。
import numpy as np import laspy from pathlib import Path # 原始 LAS 分类码 -> 训练类别,-1 表示忽略 CLS_MAP = { 2: 0, # 地面 -> 背景 5: 1, # 高植被 -> 植被 6: 2, # 建筑 -> 建筑 } IGNORE_INDEX = -1 def las_to_npy(las_path: Path, out_path: Path, max_points: int = 4096): las = laspy.read(str(las_path)) xyz = np.vstack([las.x, las.y, las.z]).T.astype(np.float64) raw_cls = np.asarray(las.classification, dtype=np.int64) # 分类码映射,未命中的置为 ignore label = np.full(raw_cls.shape, IGNORE_INDEX, dtype=np.int64) for k, v in CLS_MAP.items(): label[raw_cls == k] = v # 去中心 + 按最大半径归一化到单位球 xyz = xyz - xyz.mean(axis=0) radius = np.max(np.linalg.norm(xyz, axis=1)) if radius > 0: xyz = xyz / radius # 点数过多时随机下采样,保证每份样本规模一致 if xyz.shape[0] > max_points: idx = np.random.choice(xyz.shape[0], max_points, replace=False) xyz, label = xyz[idx], label[idx] np.savez_compressed(out_path, xyz=xyz.astype(np.float32), label=label) if __name__ == "__main__": src_dir = Path("./las_raw") dst_dir = Path("./npy_out") dst_dir.mkdir(exist_ok=True) for p in src_dir.glob("*.las"): las_to_npy(p, dst_dir / (p.stem + ".npz"))逻辑上分四段:读取、映射、归一化、采样。max_points这个参数很关键,PointNet++ 的 set abstraction 层对点数敏感,训练时如果每份样本点数差异太大,batch 里就得 padding,显存和速度都吃亏。我一般统一到 4096 或 8192,具体看单份点云的密度。np.savez_compressed比np.save慢一点,但点云文件压缩率很高,磁盘能省一半以上。
提示:
las.classification在 laspy 2.x 里是直接属性,如果你用的是旧版 1.x,要写成las.classification的 array 形式,读之前先pip show laspy确认版本,别照抄报错。
2.3 采样密度与 block 划分:别让一份 LAS 变成一个样本
很多人第一次做点云语义分割,会把一整份 LAS 当成一个训练样本,结果一个 batch 只有一两份,训练慢得离谱,而且大场景里类别极度不平衡。正确做法是按空间做 block 划分,比如 XY 平面上切 1m×1m 或 2m×2m 的格子,每个格子内采样固定点数作为一个样本。这样既增加了样本量,又让每个 block 内的类别分布更均匀。
block 划分的代码不复杂,核心是np.floor((xy - xy.min()) / block_size)得到格子索引,再按索引分组。要注意边界 block 可能点数不足,我的处理是点数少于min_points(比如 512)的直接丢弃,别用重复采样硬凑,否则边界样本会污染训练。另一个细节是 block 之间可以留重叠,重叠率 10%~20% 能缓解分割边缘的锯齿,代价是样本量增加,按显存权衡。
3. 把 npz 接进 PyTorch Dataset:collate_fn 才是真正的分水岭
数据落成 npz 之后,下一步是写Dataset和DataLoader。这一步的难点不在读取,而在 batch 内样本点数不一致时怎么拼。PointNet 的分类分支可以靠 max pooling 吃掉变长,但分割分支要逐点输出,batch 里必须对齐,所以collate_fn得自己写。
3.1 Dataset 的最小实现与标签对齐
import numpy as np import torch from torch.utils.data import Dataset class LASPointCloudDataset(Dataset): def __init__(self, npz_dir, split="train", val_ratio=0.2): self.files = sorted(Path(npz_dir).glob("*.npz")) n_val = int(len(self.files) * val_ratio) self.files = self.files[n_val:] if split == "train" else self.files[:n_val] def __len__(self): return len(self.files) def __getitem__(self, idx): data = np.load(self.files[idx]) xyz = torch.from_numpy(data["xyz"]) # (N, 3) float32 label = torch.from_numpy(data["label"]) # (N,) int64 # 转成 PointNet 习惯的通道优先布局 xyz = xyz.transpose(0, 1).contiguous() # (3, N) return xyz, label这里有个容易忽略的点:官方 PointNet 实现里输入是(B, 3, N),而很多自定义脚本按(B, N, 3)写,两者在Conv1d和Linear混用时会对不上。我统一转成(3, N),在模型里再用permute调整,避免中途维度混乱。标签保持(N,),ignore_index用 -1,和CrossEntropyLoss的默认行为一致。
3.2 collate_fn:变长点云的 padding 与 mask
def collate_fn(batch): xs, ys = zip(*batch) max_n = max(x.shape[1] for x in xs) B = len(xs) x_pad = torch.zeros(B, 3, max_n) y_pad = torch.full((B, max_n), -1, dtype=torch.long) mask = torch.zeros(B, max_n, dtype=torch.bool) for i, (x, y) in enumerate(zip(xs, ys)): n = x.shape[1] x_pad[i, :, :n] = x y_pad[i, :n] = y mask[i, :n] = True return x_pad, y_pad, maskmask是给 loss 用的,padding 位置不参与梯度。如果你直接拿y_pad算交叉熵而不加 mask,padding 的 -1 会被ignore_index吃掉,看似没问题,但模型在 padding 位置输出的 logits 仍然参与 softmax 归一化,会轻微影响梯度方向。稳妥做法是在 loss 前把无效位置的 logits 置为负无穷,或者用 mask 加权。这个细节在样本点数差异大时影响明显,我踩过一次,loss 曲线抖得厉害,查了半天才发现是 padding 没屏蔽干净。
3.3 类别不平衡:weight 与采样策略怎么选
测绘点云里地面点往往占 60% 以上,建筑和植被是少数类。直接训练,模型会倾向于全预测地面,mIoU 看着还行,但少数类召回惨不忍睹。两种常见处理:一是给CrossEntropyLoss传weight,按类别频率的倒数算;二是用WeightedRandomSampler按 block 的主类别采样。我一般先用 weight,简单直接,公式是weight[c] = total / (num_classes * count[c]),算完归一化一下。如果少数类样本实在太少,再叠加 sampler,但要注意 sampler 会让每个 epoch 看到的样本分布偏移,验证集不能这么搞。
注意:weight 别拍脑袋设成 10、100 这种整数,按实际频率算,否则梯度会被少数类主导,训练前期直接发散。
4. 训练配置与验证:学习率、batch size 和 mIoU 的实测边界
模型结构直接用官方 PointNet++ 的get_model(num_classes)就行,改num_classes为你映射后的类别数。真正需要调的是训练超参和验证指标。这一章给一组我在 4096 点、8 类场景下跑通的配置,以及几个关键参数的边界。
4.1 一组能收敛的超参组合
| 参数 | 取值 | 说明 |
|---|---|---|
| optimizer | Adam | 点云任务上比 SGD 稳 |
| lr | 1e-3 | 配合 cosine 退火 |
| batch_size | 8~16 | 受显存限制,4096 点时 16 一般够 |
| epochs | 100~200 | 看验证集 mIoU 早停 |
| weight_decay | 1e-4 | 防过拟合 |
| scheduler | CosineAnnealingLR | T_max 设为 epochs |
学习率这块有个血泪经验:PointNet++ 的 set abstraction 里有 ball query 和 grouping,前期梯度比较敏感,lr 超过 3e-3 很容易在头几个 epoch 就 NaN。我一般从 1e-3 起步,观察前 10 个 epoch 的 loss,如果下降平滑再考虑调大。batch size 别贪大,点云任务的 batch 内样本点数差异大,显存占用不是线性的,16 往上收益递减明显。
4.2 验证指标:别只看 accuracy
语义分割的验证至少看三个数:整体 accuracy、各类 IoU、mIoU。整体 accuracy 在类别不平衡时毫无参考价值,我见过全预测地面还能到 0.7 的。计算 IoU 时要把ignore_index排除,混淆矩阵按类别累加,最后逐类算TP / (TP + FP + FN)。下面是一个轻量的验证循环骨架:
@torch.no_grad() def evaluate(model, loader, num_classes, device): model.eval() conf = torch.zeros(num_classes, num_classes, dtype=torch.long) for x, y, mask in loader: x, y = x.to(device), y.to(device) logits = model(x) # (B, C, N) pred = logits.argmax(dim=1) # (B, N) valid = mask & (y != -1) for t, p in zip(y[valid].view(-1), pred[valid].view(-1)): conf[t.long(), p.long()] += 1 iou = [] for c in range(num_classes): tp = conf[c, c].item() fp = conf[:, c].sum().item() - tp fn = conf[c, :].sum().item() - tp if tp + fp + fn > 0: iou.append(tp / (tp + fp + fn)) return sum(iou) / len(iou) if iou else 0.0混淆矩阵按(真实, 预测)累加,这个顺序别写反,否则算出来的 IoU 是错的。valid同时过滤 padding 和 ignore 标签,两个条件缺一不可。mIoU 只对出现过的类别求平均,如果某类在验证集里一个样本都没有,强行算进去会拉低指标,掩盖真实问题。
4.3 从 loss 曲线判断问题出在哪
训练不收敛时,先看 loss 曲线的形状。前 5 个 epoch 就 NaN,八成是 lr 太大或输入没归一化;loss 缓慢下降但验证 mIoU 卡在 0.2 左右,多半是类别不平衡或标签映射错了;训练 loss 降、验证 loss 升,是过拟合,加 dropout 或减模型宽度。还有一种隐蔽情况:loss 正常降,但预测结果全是同一类,这时候去查CLS_MAP,十有八九是映射表把多数类映射到了 0,少数类没命中全进了 ignore。
5. 避坑清单:五个让我返工的细节
这一章单独列,因为下面每一条都是我实际调试时卡过半天以上的。
现象一:训练一开始 loss 就是 NaN。原因通常是坐标没归一化,或者 LAS 的 Z 值量级和 XY 差太多(比如高程是几十,平面坐标是几百万),减均值后仍然有大数。解决:先做去中心,再按最大半径缩放,缩放后打印xyz.max()确认在 1 附近。
现象二:mIoU 一直上不去,但 accuracy 很高。原因是类别极度不平衡,模型退化成多数类预测器。解决:加类别 weight,同时检查验证集的类别分布,如果少数类样本占比低于 1%,考虑 block 划分时做类别均衡采样。
现象三:验证时 CUDA out of memory,训练时却正常。原因是验证没加torch.no_grad(),或者 collate 时 padding 到了训练集的最大点数。解决:验证循环套no_grad,并且验证集的max_points单独设小一点,别和训练共用。
现象四:同一份 LAS 转出来的 npz,两次训练结果差异很大。原因是下采样用了随机choice且没固定种子。解决:在转换脚本里设np.random.seed(42),或者干脆用确定性的体素下采样,保证数据可复现。
现象五:模型在测试集上分割边缘全是锯齿。原因是 block 划分没有重叠,相邻 block 的预测在边界不连续。解决:block 之间留 10%~20% 重叠,推理时对重叠区域做投票或平均,代价是推理时间增加,但边缘质量提升明显。
提示:这五条里前两条出现频率最高,建议转换完数据先跑一个 10 个 epoch 的小实验,确认 loss 正常下降再上全量,别一上来就训 200 轮。
6. 进阶:用投票融合和 TTA 把 mIoU 再抬两个点
数据管线跑通之后,想再压榨一点精度,最划算的是推理侧的技巧,不用重训模型。点云分割常用的两个:block 重叠投票和测试时增强(TTA)。前者在 block 划分时留重叠,推理时把每个点的多次预测做多数投票;后者对输入做绕 Z 轴的小角度旋转,把多次预测平均。两个叠加,在我那批巡检数据上 mIoU 从 0.61 抬到了 0.64 左右,代价是推理时间翻三倍。
投票融合的实现要点是维护一个(num_points, num_classes)的累加矩阵,每个 block 推理完把 logits 加到对应点的行上,最后argmax。注意重叠区域的点会被多个 block 覆盖,累加前要按 block 的实际点数归一化,否则重叠多的区域会被过度加权。TTA 的角度别设太大,±10° 以内就够,角度大了点云和标签的对齐会引入误差,反而掉点。
还有一个容易被忽略的验证习惯:每次改完数据管线,先拿 5~10 个样本过一遍前向,打印输入 shape、标签唯一值和 loss,确认没有 ignore 标签混进有效区域。这个检查花不了一分钟,但能挡掉大部分「训练半天发现标签全错」的翻车。从那以后我每次换数据集都强制走一遍这个小检查,再开始正式训练。希望帮到你。
本文还有配套的精品资源,点击获取