简介:面向点云深度学习初学者与需要落地自有数据的开发者,这份资源聚焦于用PyTorch复现PointNet与PointNet++分类流程,并解决「有Classification属性的LAS点云如何直接训练」这一常见痛点。包内共19个文件,以15个Python脚本为主体,覆盖网络结构、数据加载与训练预测入口,另附1个已分类LAS文件用于训练验证、1个LAS文件用于预测,以及1份docx说明文档,压缩包约26KB,轻量易读。已有896人学习,说明该流程在点云分类实践中具有较高参考价值。读者可据此理解从LAS读取、分类属性解析到数据集构建的完整链路,并可在PointNet与PointNet++之间切换对比,快速把自有标注点云接入训练与推理,减少环境与数据格式适配的试错成本。
1. 从一份带 Classification 属性的 LAS 说起:PointNet 系列怎么吃下自己的点云
手里拿到一份测绘级 LAS 点云,打开一看每个点除了 XYZ 和强度,还挂着一个 Classification 字段,里面是建筑、植被、地面这些类别码。第一反应往往是:这不就是现成的标注吗,直接丢进 PointNet 训练不就行了。真跑起来才发现坑在后面——LAS 是二进制格式,PyTorch 那边要的是规整的张量;分类码是整数标签,网络要的是从 0 开始的连续索引;点云密度动辄几百万点,一次性喂进去显存直接爆。这套 PointNet / PointNet++ 的训练工程,解决的就是从「原始 LAS 文件」到「可训练样本」再到「分类/分割模型收敛」这条链路。适合两类人:手里有带分类属性点云、想跑通语义分割或分类的工程同学,以及想拿 PointNet++ 做二次开发、需要一套能改的数据管线的人。下面按数据怎么进、模型怎么搭、怎么跑、怎么改的顺序拆。
2. 数据管线拆解:LAS 分类码怎么变成 PyTorch 能吃的张量
PointNet 系列对输入的要求其实很朴素:一个(B, N, C)的张量,B 是 batch,N 是采样点数,C 是每个点的特征维度(常见是 XYZ、XYZ+强度、XYZ+法向量)。难点全在「怎么把 LAS 变成这个形状,并且让标签对得上」。这一章把整条管线拆成读取、采样、标签映射、Dataset 封装四段,每段都能单独替换。
2.1 LAS 读取与字段提取
LAS 是 ASPRS 定义的二进制点云格式,Python 侧常用laspy读。关键是别只读 XYZ,Classification 字段和 Intensity 往往是有用特征。下面这段是读取并转成 numpy 的常见写法:
import laspy import numpy as np def read_las(path, use_intensity=True): las = laspy.read(path) # XYZ 统一转 float32,避免后续归一化出精度问题 xyz = np.vstack((las.x, las.y, las.z)).transpose().astype(np.float32) # Classification 在 laspy 2.x 里是数组,直接取 labels = np.asarray(las.classification, dtype=np.int64) if use_intensity: intensity = np.asarray(las.intensity, dtype=np.float32).reshape(-1, 1) feats = np.concatenate([xyz, intensity], axis=1) else: feats = xyz return feats, labelslas.x这类属性在 laspy 2.x 返回的是缩放后的真实坐标,不用自己乘 scale。classification是 uint8 数组,长度等于点数。use_intensity控制是否把强度拼进特征,语义分割任务里强度对植被和建筑区分有帮助,分类任务可以关掉减少维度。注意有些 LAS 的 Classification 是 0(未分类)占大头,读进来先看一眼分布再决定要不要过滤。
2.2 体素下采样与分块
原始 LAS 动辄上千万点,直接送网络不现实。常见做法是先体素下采样降密度,再按空间分块,每块固定点数。体素下采样用 Open3D 最省事:
import open3d as o3d import numpy as np def voxel_downsample(feats, labels, voxel_size=0.5): pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(feats[:, :3]) # 用体素内点的标签众数作为该体素标签 down_pcd, _, index = pcd.voxel_down_sample_and_trace( voxel_size, pcd.get_min_bound(), pcd.get_max_bound()) idx = np.array([i[0] for i in index]) # 每个体素取第一个原始点索引 return feats[idx], labels[idx]voxel_size是体素边长,单位跟你的 LAS 坐标一致(通常是米)。0.5 米对城市级点云比较合适,太细降不下来,太粗丢细节。voxel_down_sample_and_trace返回的index是每个体素对应的原始点索引列表,这里取第一个代表点,简单但够用;要更严谨可以取体素内标签众数。分块则按 XY 平面切网格,每块再随机采样到固定 N 点,不足的重复采样补齐,这是 PointNet 训练里最常见的「定长采样」套路。
2.3 标签映射与类别不平衡处理
LAS 的 Classification 码是 ASPRS 标准里的一整套(地面 2、植被 3-5、建筑 6 等),但你的数据里可能只出现其中几类。网络输出层维度必须等于实际类别数,所以要先做一次「出现过的类别 → 0..K-1」的映射,并且把映射表存下来,推理时才能还原:
def build_label_map(labels): uniq = np.unique(labels) # 过滤掉 0(未分类)和 1(未指定),按需调整 uniq = uniq[~np.isin(uniq, [0, 1])] return {int(c): i for i, c in enumerate(uniq)} def remap_labels(labels, label_map): out = np.full_like(labels, -1) for raw, new in label_map.items(): out[labels == raw] = new return out # -1 表示忽略,训练时用 ignore_index映射表一定要落盘成 json,推理阶段把预测的 0..K-1 反查回原始分类码,否则可视化出来全是错的。类别不平衡是点云分割的老问题,地面点往往占一半以上,常见做法是在损失函数里给CrossEntropyLoss传weight,权重按类别频率的倒数算,或者用ignore_index=-1把未分类点排除掉。
2.4 Dataset 与 DataLoader 封装
把上面几步串成一个torch.utils.data.Dataset,__getitem__返回(feats, label),feats形状(N, C),label形状(N,)(分割)或标量(分类)。collate 时 PyTorch 默认会 stack 成(B, N, C),正好是 PointNet 要的。这里有个容易翻车的点:归一化。PointNet 原文是对整个点云做统一缩放,让所有点落在单位球内,如果你的分块是固定物理尺寸,可以按块内最大半径归一化:
def normalize_block(feats): xyz = feats[:, :3] centroid = xyz.mean(axis=0, keepdims=True) xyz = xyz - centroid scale = np.max(np.linalg.norm(xyz, axis=1)) feats[:, :3] = xyz / max(scale, 1e-6) return feats归一化只动 XYZ,强度那维保持原样或单独做 min-max。不做归一化的话,不同区域坐标量级差异会让网络很难收敛,这是血泪经验。
3. 模型结构拆解:PointNet 与 PointNet++ 的取舍和关键实现
数据管线通了,接下来是网络。PointNet 和 PointNet++ 不是二选一的关系,而是看任务粒度:整块分类用 PointNet 就够,逐点语义分割建议上 PointNet++ 的分层采样结构。这一章把两者的核心模块拆开,给出可直接用的 PyTorch 实现,并说清参数怎么调。
3.1 PointNet 的对称函数与特征变换
PointNet 的核心思想是用一个对称函数(max pooling)聚合所有点的特征,保证输入点顺序无关。两个关键模块是 T-Net(输入/特征变换)和 shared MLP。下面是一个精简但完整的分类骨干:
import torch import torch.nn as nn import torch.nn.functional as F class TNet(nn.Module): def __init__(self, k=3): super().__init__() self.k = k self.conv1 = nn.Conv1d(k, 64, 1) self.conv2 = nn.Conv1d(64, 128, 1) self.conv3 = nn.Conv1d(128, 1024, 1) self.fc1 = nn.Linear(1024, 512) self.fc2 = nn.Linear(512, 256) self.fc3 = nn.Linear(256, k * k) self.bn1 = nn.BatchNorm1d(64) self.bn2 = nn.BatchNorm1d(128) self.bn3 = nn.BatchNorm1d(1024) self.bn4 = nn.BatchNorm1d(512) self.bn5 = nn.BatchNorm1d(256) def forward(self, x): B = x.size(0) x = F.relu(self.bn1(self.conv1(x))) x = F.relu(self.bn2(self.conv2(x))) x = F.relu(self.bn3(self.conv3(x))) x = torch.max(x, 2, keepdim=True)[0].view(B, -1) x = F.relu(self.bn4(self.fc1(x))) x = F.relu(self.bn5(self.fc2(x))) x = self.fc3(x) # 初始化为单位矩阵,训练更稳 iden = torch.eye(self.k, device=x.device).view(1, self.k * self.k).repeat(B, 1) x = x + iden return x.view(B, self.k, self.k)k是变换维度,输入变换用 3(XYZ),特征变换用 64。conv1d的 kernel 为 1 等价于对每个点独立做全连接,这是 PointNet 处理无序点的标准写法。TNet 输出加单位矩阵是原文技巧,让网络从恒等变换开始学,收敛更稳。分类头再接一个 max pool 到全局特征,过几层 FC 输出类别 logits。
3.2 PointNet++ 的 set abstraction 与特征传播
PointNet++ 在 PointNet 基础上加了分层结构:set abstraction(SA)层做最远点采样 + 球查询 + 局部 PointNet,逐层降采样提特征;feature propagation(FP)层做上采样插值,恢复逐点分辨率。语义分割必须走 FP,否则输出点数对不上。SA 层的核心是 ball query:
def ball_query(radius, nsample, xyz, new_xyz): # xyz: (B, N, 3), new_xyz: (B, S, 3) B, N, _ = xyz.shape S = new_xyz.shape[1] dist = torch.cdist(new_xyz, xyz) # (B, S, N) # 半径内取前 nsample 个,不足的用最近点补齐 dist, idx = dist.sort(dim=-1) mask = dist <= radius # 简化处理:直接取最近的 nsample 个,工程上够用 idx = idx[:, :, :nsample] return idxradius是球查询半径,跟点云密度强相关,密度大就调小。nsample是每个局部邻域采样的点数,常见 16 或 32。上面用cdist加排序是最直观的实现,点数特别大时内存吃紧,可以换torch_cluster的radius_graph。FP 层用最近邻插值把低层特征上采样,再和 skip connection 拼接,最后过 MLP,这部分代码较长,工程里一般直接复用开源实现,改的时候重点看插值的k值(常见 3)。
3.3 分割头与损失函数配置
语义分割的输出是每个点一个类别,所以最后要过一个Conv1d(C, num_classes, 1),得到(B, num_classes, N),再转置成(B, N, num_classes)跟标签算交叉熵。损失函数配置直接决定收敛质量:
# 按类别频率算权重,缓解地面点过多的问题 class_weights = torch.tensor([1.0, 2.0, 1.5, 3.0], device=device) criterion = nn.CrossEntropyLoss(weight=class_weights, ignore_index=-1) # 输出 (B, N, C),标签 (B, N),需要转置 loss = criterion(logits.transpose(1, 2), labels)ignore_index=-1对应前面标签映射里未分类点的标记。weight的数值不用精确,按「少数类权重大、多数类权重小」的原则给就行,给太极端反而震荡。学习率上 PointNet 系列对初始 lr 比较敏感,常见从 1e-3 配 Adam 起步,配合StepLR每 20 个 epoch 降一半。
4. 训练与推理跑通:从配置到可视化的完整流程
模型和数据都齐了,这一章讲怎么真正跑起来:环境怎么装、训练脚本怎么组织、断点怎么续、推理结果怎么落回 LAS 做可视化。目标是照着敲能跑出一个能看的混淆矩阵和分类着色图。
4.1 环境与依赖
依赖不多,但版本要对齐,尤其是 laspy 和 numpy:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install laspy[lazrs] numpy open3d scikit-learn tqdm tensorboardlaspy[lazrs]是为了读压缩的 laz 文件,不带这个 extras 读 laz 会报错。Open3D 只在下采样和可视化用,版本 0.17 以上 API 稳定。CUDA 版本按自己显卡选,没卡就装 CPU 版,训练慢但能跑通验证逻辑。装完先跑一个python -c "import laspy, torch; print(torch.cuda.is_available())"确认环境。
4.2 训练脚本的组织与断点续训
训练脚本建议拆成config.yaml、dataset.py、model.py、train.py四块,别全塞一个文件。train.py主循环里两个细节必须有:每个 epoch 存 checkpoint,以及记录验证集 mIoU。断点续训的写法:
def save_ckpt(path, epoch, model, optimizer, best_miou): torch.save({ 'epoch': epoch, 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'best_miou': best_miou, }, path) def load_ckpt(path, model, optimizer=None): ckpt = torch.load(path, map_location='cpu') model.load_state_dict(ckpt['model']) if optimizer is not None: optimizer.load_state_dict(ckpt['optimizer']) return ckpt['epoch'], ckpt['best_miou']存optimizer.state_dict()是为了续训时动量状态不丢,不然续上后前几个 step 会抖。map_location='cpu'保证在没卡的机器上也能加载。验证指标用sklearn.metrics.confusion_matrix算 mIoU,比单纯看 accuracy 靠谱,因为类别不平衡时 accuracy 会被地面点带偏。
4.3 推理与结果回写 LAS
推理阶段把预测的类别索引反查回原始分类码,写进 LAS 的 Classification 字段,再用 CloudCompare 或 Open3D 按分类着色看效果:
def write_pred_to_las(src_path, dst_path, pred_labels, label_map): inv_map = {v: k for k, v in label_map.items()} las = laspy.read(src_path) raw = np.array([inv_map.get(int(p), 0) for p in pred_labels]) las.classification = raw.astype(np.uint8) las.write(dst_path)inv_map是训练时映射表的反查,get(..., 0)兜底成未分类。写回时注意classification字段是 uint8,类别码别超过 255。可视化时按 Classification 上色,建筑和植被应该能明显分开,如果全糊在一起,先回去查归一化和标签映射有没有错位。
5. 避坑与排查:训练不收敛、标签错位、显存爆掉的真实记录
这套流程我前后在不同数据上跑过几轮,翻车点高度集中。下面五条按「现象 → 原因 → 解决」写,都是能直接对号入座的。
5.1 损失不降、mIoU 卡在随机水平
现象:训练几十个 epoch,loss 在 1.6 附近不动,验证 mIoU 跟瞎猜差不多。原因多半是标签映射和输出维度对不上——比如数据里有 5 类,但模型输出层写死 4 类,或者ignore_index把所有点都忽略了。解决:训练前打印np.unique(labels)和模型最后一层out_channels,确认一致;再检查remap_labels后有没有大量 -1,如果超过一半,说明过滤条件写错了。
5.2 显存 OOM,batch 调到 1 还爆
现象:CUDA out of memory,把 batch 降到 1 依然爆。原因通常是单样本点数 N 太大,或者 PointNet++ 的 ball query 用了cdist生成(B, S, N)的大矩阵。解决:先把 N 降到 4096 或 8192 试;SA 层改用torch_cluster.radius分块查询;训练时开torch.cuda.amp混合精度,显存能省近一半。
5.3 推理结果整体偏移一个类别
现象:可视化看着类别都对,但颜色跟真值差一位。原因:训练时做了「原始码 → 0..K-1」映射,推理时忘了反查,直接把 0..K-1 写回 LAS。解决:把label_map存成 json 跟 checkpoint 放一起,推理脚本强制加载,inv_map反查后再写。这个坑我踩过两次,后来养成习惯——映射表不进 checkpoint 就不许开训。
5.4 不同区域坐标量级差异导致局部预测崩
现象:模型在训练区域表现好,换一片区域预测全乱。原因:没做块内归一化,或者归一化用了全局统计量。解决:每个块独立做质心平移 + 最大半径缩放,训练和推理用同一套normalize_block;强度特征单独 min-max,别跟 XYZ 混在一起归一化。
5.5 laz 读取报错或字段缺失
现象:laspy.read读 laz 抛异常,或者读进来classification全是 0。原因:没装lazrs后端,或者这份 LAS 的 Classification 字段确实没填(很多原始扫描数据是 0)。解决:装laspy[lazrs];读进来先np.bincount(las.classification)看分布,全 0 就说明这份数据得先人工标注或换数据源,别硬训。
6. 二次开发改造:换数据、换类别、换骨干的三条路径
跑通之后真正有价值的是改。这套工程的可改造点集中在三处:数据层换格式、标签层换类别体系、模型层换骨干。下面给三条具体路径和验证方法,最后说一个我自己的习惯。
6.1 换数据格式:从 LAS 到 txt / ply / 自定义二进制
如果手头不是 LAS,而是每行x y z label的 txt,只需要替换read_las,其余管线不动。关键契约是返回(feats, labels)两个 numpy 数组,feats 至少含 XYZ。ply 用 Open3D 读也行,但注意 ply 的自定义属性读取不如 laspy 直接,常见做法是先用 CloudCompare 转成 txt 或 las。自定义二进制格式就在read_las里用np.fromfile按 dtype 解析,解析完同样走voxel_downsample和normalize_block。验证方法:换完格式后先跑一个 mini batch,打印 feats 的 shape、min/max 和 labels 的 unique,跟原格式对比一致再开训。
6.2 换类别体系:从 ASPRS 码到自定义分类
ASPRS 那套码不一定贴合你的业务,比如你只关心「建筑 / 道路 / 植被 / 其他」四类。改法是重写build_label_map,把原始码按业务规则合并:
def build_custom_map(labels): # 业务映射:ASPRS 码 -> 自定义 4 类 rules = { 0: 3, 1: 3, # 未分类/未指定 -> 其他 2: 1, # 地面 -> 道路 3: 2, 4: 2, 5: 2, # 植被 -> 植被 6: 0, # 建筑 -> 建筑 } mapped = np.array([rules.get(int(c), 3) for c in labels]) return mapped改完记得同步改模型输出层num_classes=4和损失函数的weight长度。验证方法:抽 100 个点人工核对映射结果,再跑一个 epoch 看混淆矩阵对角线是否合理。类别合并后样本会更不平衡,weight要重新算。
6.3 换骨干:PointNet 换 PointNet++ 或接入 Transformer
分类任务从 PointNet 换 PointNet++,主要改模型组装:把分类头前的全局 max pool 换成 SA 层堆叠 + 全局池化,输入仍是(B, N, C)。语义分割反过来,PointNet 加逐点特征拼接也能做,但精度通常不如 PointNet++。想接点云 Transformer(比如把局部邻域特征过注意力),改造点在 SA 层内部,把局部 PointNet 的 max pool 换成 attention 聚合,其余管线不动。验证方法:固定同一份验证集,对比 mIoU 和单 epoch 耗时,别只看精度不看成本。
| 改造方向 | 改动文件 | 主要风险 | 验证指标 |
|---|---|---|---|
| 换数据格式 | dataset.py | 字段顺序错位 | shape 与 unique 对比 |
| 换类别体系 | dataset.py + model.py | 输出维度不一致 | 混淆矩阵对角线 |
| 换骨干 | model.py | 显存与收敛速度 | mIoU + epoch 耗时 |
这三条路径里,换类别体系最容易出隐性错误,因为映射错了 loss 照样降,只是降到了一个错的目标上。我现在的习惯是:任何标签映射改动,先在一个 200 点的小样本上把「原始码 → 映射后 → 反查回原始码」走一遍闭环,确认能原样还原再开训。这个后悔药成本极低,但救过我好几次。希望帮到你。
本文还有配套的精品资源,点击获取