ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LAS点云Classification实战:基于PointNet/PointNet++的完整训练与部署指南

LAS点云Classification实战:基于PointNet/PointNet++的完整训练与部署指南 简介面向需要基于PointNet/PointNet处理LAS点云分类任务的PyTorch开发者这套资源提供了一整套完整可迁移的训练方案。它以GitHub开源项目yanx27/Pointnet_Pointnet2_pytorch为基础针对带Classification属性的LAS点云数据做了深度定制适配使用者可按需选择PointNet或PointNet模型解决从原始LAS到分类结果的关键流程问题。包内共19个文件压缩包约26KB核心是15个Python脚本覆盖数据预处理、LAS数据加载、分类训练与验证、模型测试等全部环节同时包含已分类LAS样例用于训练验证、一个待预测LAS文件以及配套说明文档。资源已有894人学习浏览适合有一定PyTorch基础、希望快速将点云分类模型迁移到自有数据的读者。通过这份资料可省去大量数据格式适配与代码调试时间直接获得从LAS文件到训练预测的完整链路并清晰理解数据组织方式、模型调用差异与实验验证思路。1. 为什么要自己训练PointNet处理带Classification的LAS点云拿到一块机载LiDAR点云LAS文件里除了X/Y/Z坐标还有Classification字段地面、建筑、植被已经被人工或全自动软件标在里面。问题的现实版本是这块点云有5000万点甲方要你把剩下100个测区也按同样标准自动分出来或者要修正历史LAS里大量“未分类”的点。这就落到PointNet/PointNet训练自己的数据上。它不像用ModelNet那样把一个完整物体喂进去、输出一个类别LAS的Classification是逐点标签而且标签来自不同作业队、不同LAS版本边界噪声与类别严重不平衡都会出现。本文就从解析LAS、切块、训练PointNet与PointNet到最后一节的写回LAS与部署验证完整过一遍落地路径。2. 准备LAS点云读取、切块、归一化与HDF5数据集生成训练前的数据组织决定后面80%的体验。PointNet/PointNet固定输入N×3点坐标如果你做逐点分类还要给每个点一个标签。LAS文件恰好带Classification字段但坐标系、点密度、标签语义都不适合直接喂网络。这一章先把LAS变成可训练的样本。2.1 用laspy读取LAS与Classification属性先确认坐标系与点字段读取LAS我一般用laspy。下面这段是最基础的起点把坐标和分类码同时取出来import laspy import numpy as np las laspy.read(train_area.las) xyz np.stack([las.x, las.y, las.z], axis1).astype(np.float32) cls las.classification.astype(np.uint8) unique, counts np.unique(cls, return_countsTrue) for u, c in zip(unique, counts): print(fclass {u}: {c} points)注意这里用las.x而不是las.points[X]。laspy 会自动把内部整数坐标乘以缩放比例并加上偏移量得到带真实坐标系的数值。Classification 在LAS里本身是uint8编码0~31之间的码但不同软件里可能是自定义值因此它只是“类别号”不等于网络输出标签。laspy.read()是一次性读入内存。一个2000万点的LASX/Y/Z用float32保存三个数组约240MB加上分类、强度、GPS时间等字段整块数据接近500MB。如果你的LAS达到数亿点这一步就会把内存打满。我处理大测区时改用流式读取with laspy.open(train_area.las) as reader: for chunk in reader.chunks(2_000_000): xyz np.stack([chunk.x, chunk.y, chunk.z], axis1).astype(np.float32) cls chunk.classification.astype(np.uint8) # 每个chunk单独切块与采样处理完立即释放chunks(2_000_000)每次解出200万个点循环里切块后丢掉原数组。这个方法在64GB内存的机器上处理几亿点都没问题。流式读取的代价是看不到全局点云范围但你会发现切块模式下本来就不需要全局归一化。2.2 切block为什么PointNet不能直接吃整个LAS点云文件PointNet有两个硬约束每个样本必须固定点数比如2048或4096它用共享MLP提取逐点特征靠max pooling或局部聚合看更大范围。直接把整个测区当“一个样本”喂进去样本里有几百万个点显存和计算量直接失控。常规做法是把测区切成固定大小的三维块。块可以是立方体、圆柱体或沿地形的patch我一般用立方体简单且边界好控制。下面是一个切块函数def extract_blocks(xyz, cls, block_size1.0, stride0.5, max_points4096, min_points256): xmin, ymin, zmin xyz.min(axis0) xmax, ymax, zmax xyz.max(axis0) blocks [] x xmin while x xmax: y ymin while y ymax: z zmin while z zmax: mask (xyz[:, 0] x) (xyz[:, 0] x block_size) \ (xyz[:, 1] y) (xyz[:, 1] y block_size) \ (xyz[:, 2] z) (xyz[:, 2] z block_size) count int(mask.sum()) if count min_points: block_xyz xyz[mask] block_cls cls[mask] center block_xyz.mean(axis0) block_xyz block_xyz - center if count max_points: idx np.random.choice(count, max_points, replaceFalse) else: idx np.random.choice(count, max_points, replaceTrue) blocks.append((block_xyz[idx], block_cls[idx])) del mask, block_xyz, block_cls z stride y stride x stride return blocks这段代码从包围盒出发按block_size切块stride0.5表示相邻块在X/Y/Z方向都有0.5米重叠。重叠的目的是减少点在块边界被硬切的问题。切完后用块内质心把坐标平移到局部原点这是PointNet系列的标准做法。不要觉得这一步多余如果不减质心坐标可能是[438000.0, 3245000.0, 35.2]这种大数值float32的精度在0.01米级别上会丢失点云细节。参数上最需要调的是block_size和min_points。机载LAS密度通常10~50点/m²1米方块里只有几十个点min_points256会过滤掉大量块。对稀疏测区我把block_size放到5米甚至10米对地面站扫描的高密度点云1米块反而合适。replaceTrue重复采样是PointNet的兜底策略只要重复比例不超过40%模型基本不会受影响。2.3 类别重映射与HDF5生成固定点数与两类数据组织LAS的Classification不是为深度学习设计的。裸地是2低/中/高植被是3/4/5建筑是6水体是9。如果业务只需要区分“地面、植被、建筑、水体、其他”就必须先合并。class_map { 0: 0, # 未分类 - 其他 2: 1, # 地面 3: 2, 4: 2, 5: 2, # 低/中/高植被合并 6: 3, # 建筑 9: 4, # 水体 } new_cls np.zeros_like(cls) for old, new in class_map.items(): new_cls[cls old] new # 未出现在映射里的原分类码全部落到0后续可根据统计结果再调整千万不要直接把原始cls当网络标签因为分类码可能到16而网络输出维度只有5。更隐蔽的问题是不同测区“6”未必都是建筑。所以切块前一定要先执行2.1的统计代码画一遍分类码直方图再定这个映射表。这一步是纯人工经验没有程序能替你判断。数据存储上逐点语义分割和整块分类的组织方式不一样。逐点语义需要这样写import h5py with h5py.File(train_data.h5, w) as f: # train_x: (样本数, 4096, 3) # train_y: (样本数, 4096) f.create_dataset(train_x, datatrain_x, dtypenp.float32, compressiongzip) f.create_dataset(train_y, datatrain_y, dtypenp.uint8, compressiongzip)如果是“整块一个标签”比如把每栋建筑的点云当一个样本train_y的形状是(样本数,)每条样本对应一个建筑类别。这个区别直接决定后面用PointNet的classification头还是seg头。生成HDF5后我再读一遍检查shape和坐标范围能省掉训练时batch维度错位的麻烦。3. 基于Classification属性训练PointNet两种任务分支与关键参数3.1 整体分类还是逐点分割LAS的Classification属性该喂到哪个出口LAS里每个点都带Classification所以最自然的对应是逐点语义分割切出来的block有4096个点每个点有自己的class网络输出B×4096×num_classes对每个点做预测。但工程上确实有“整体分类”需求一个LAS文件是一栋完整建筑或一条管线业务方想输入整个点云、输出“它是高压线塔还是植被”。这种需求下点的Classification字段往往不统一需要先做一次归并通常是把对象内占比最高的类当作样本标签然后走PointNet的classification分支。选哪个看业务。我习惯用下表判断业务目标网络结构输入样本标签shape每个点分地面/植被/建筑语义分割头切好的block点云(B, N)每栋/每根目标一个类别分类头单目标点云(B,)每个点同时分实例实例分割需额外聚类切好的block点云(B, N) 实例id如果只是“把LAS里缺失的Classification自动补上”基本就是逐点分割。下面按逐点分割给出模型改造。3.2 改造PointNet模型去掉T-Net后更稳原版PointNet在ModelNet上做40类物体分类后来派生出了逐点输出的语义分割版本。核心逻辑有三步共享MLP逐点提特征、max pooling提取全局特征、把全局特征拼回逐点特征做预测。LAS场景下我会对原版做一个简化去掉input transform和feature transform两个T-Net。原因很简单LAS block已经做了质心归一化且block只有1~5米范围坐标数值很小T-Net带来的旋转不变性在这个尺度上收益不大反而拖慢训练、增加显存。下面是一个可用于LAS逐点分类的精简PointNetimport torch import torch.nn as nn import torch.nn.functional as F class PointNetSeg(nn.Module): def __init__(self, num_classes5): super().__init__() self.conv1 nn.Conv1d(3, 64, 1) self.bn1 nn.BatchNorm1d(64) self.conv2 nn.Conv1d(64, 64, 1) self.bn2 nn.BatchNorm1d(64) self.conv3 nn.Conv1d(64, 128, 1) self.bn3 nn.BatchNorm1d(128) self.conv4 nn.Conv1d(128, 1024, 1) self.bn4 nn.BatchNorm1d(1024) self.head nn.Sequential( nn.Conv1d(1088, 512, 1), nn.BatchNorm1d(512), nn.ReLU(), nn.Conv1d(512, 256, 1), nn.BatchNorm1d(256), nn.ReLU(), nn.Conv1d(256, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, num_classes, 1) ) def forward(self, x): # x: (B, 3, N) x F.relu(self.bn1(self.conv1(x))) x F.relu(self.bn2(self.conv2(x))) local_feat x # (B, 64, N) x F.relu(self.bn3(self.conv3(x))) x F.relu(self.bn4(self.conv4(x))) global_feat x.max(dim-1, keepdimTrue).values # (B, 1024, 1) x torch.cat([local_feat, global_feat.expand(-1, -1, x.shape[-1])], dim1) return self.head(x) # (B, num_classes, N)这里local_feat是第二层卷积得到的64维逐点特征global_feat是整块的1024维全局特征。把两者拼成1088维再对每个点做MLP实现“逐点分类时参考整块上下文”。LAS block里经常一块既有地面又有植被没有全局特征只靠3个坐标很难把悬空的植被与地面剥开。训练时输入要从(B, N, 3)转成(B, 3, N)points points.transpose(1, 2).contiguous() output model(points) # (B, num_classes, N) loss F.cross_entropy(output, labels) # labels: (B, N)num_classes不是你LAS Classification字段的最大值而是第2.3节映射后的类别数。BatchNorm1d在batch size小的时候很不稳定如果只能batch_size2建议换成InstanceNorm或增大单块点数。3.3 训练循环batch size、学习率、类别权重与评估指标一个能直接跑的训练循环可以这样组织model PointNetSeg(num_classes5).cuda() optimizer torch.optim.Adam(model.parameters(), lr0.001, weight_decay1e-5) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.5) class_weights torch.tensor([0.1, 1.0, 1.2, 1.5, 1.0], devicecuda) criterion nn.CrossEntropyLoss(weightclass_weights) for epoch in range(120): model.train() total_loss 0.0 for i, (px, py) in enumerate(train_loader): px px.transpose(1, 2).contiguous().cuda() py py.cuda() pred model(px) loss criterion(pred, py) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() scheduler.step() # 每个epoch结束跑一次验证计算mIoU而不是只看accuracy我长期用的起点是batch_size 8单卡16G显存learning rate 0.001每30个epoch减半epoch 120~200。batch size在PointNet里比CNN更敏感因为BatchNorm统计量依赖batch内的多样本太小会让网络学得慢、振荡大。损失函数一定要看类别分布。LAS里“未分类(0)”经常占一半以上建筑只有5%如果直接用普通交叉熵模型会学成“全预测地面”acc虚高到75%但建筑类完全没起来。我通常两种手段同时用训练集按类别上限降采样把0类样本压到总样本的40%以内损失函数给类别权重再兜底。评估时不要只报accuracy逐点分类要看mIoU和每类IoUaccuracy会被多数类骗。4. 换用PointNet局部特征与LAS大场景的适配4.1 为什么换PointNetLAS不是ModelNet的一个萝卜一个坑PointNet的全局max pooling有个隐藏问题两个完全不同的局部形状如果它们在某个全局维度上的最大值相同网络就可能给出接近的特征。ModelNet“一个物体占一个样本”的任务里全局特征够用但LAS大场景里一个block内地面、建筑、树木混在一起点密度和遮挡都不同只靠全局特征会把不同局部模式糊在一起。PointNet把“局部区域的PointNet”递归套起来先用最远点采样挑中心再用ball query或kNN分组对每个局部区域跑一次PointNet得到局部特征后再往上一层。这样底层只看几厘米半径内的模式上层逐渐看到几十厘米、几米。这个思路天然适配LAS多尺度几何。但PointNet的点密度假设和ModelNet差几个数量级。ModelNet一个椅子2048点点间距大约2~5cm机载LAS全局点间距可能是20~50cm局部又密集到2cm。所以PointNet的分组半径不能照抄论文必须根据自己切出来的block下方差。4.2 最远点采样与ball query两个核心算子set abstraction每一层都离不开两个算子。最远点采样FPS负责挑下一层中心点保证中心在空间上均匀ball query把中心附近半径内的点聚成一个局部点集。FPS的PyTorch参考实现如下def farthest_point_sample(xyz, npoint): B, N, C xyz.shape device xyz.device centroids torch.zeros(B, npoint, dtypetorch.long, devicedevice) distance torch.full((B, N), 1e10, devicedevice) farthest torch.randint(0, N, (B,), dtypetorch.long, devicedevice) for i in range(npoint): centroids[:, i] farthest batch_ids torch.arange(B, devicedevice) dist torch.cdist(xyz[batch_ids, farthest].unsqueeze(1), xyz) dist dist.squeeze(1) distance torch.min(distance, dist) farthest torch.max(distance, dim-1)[1] return centroids逻辑是第一个中心随机挑之后每次都挑“离已有所有中心最远”的点。torch.cdist这一步计算当前点到全部N个点的距离循环npoint次复杂度O(B*npoint*N)。在4096点里取512个中心还能忍block如果到1万点建议用torch.argsort(distance, descendingTrue)[:, :npoint]做近似FPS会牺牲一点均匀性但训练速度快很多。ball query的纯Python循环太慢我用torch.cdist加掩码实现def ball_query(radius, nsample, xyz, new_xyz): B, N, C xyz.shape M new_xyz.shape[1] dist torch.cdist(new_xyz, xyz) # (B, M, N) idx dist.argsort(dim-1)[:, :, :nsample] mask dist.gather(dim-1, indexidx) radius # 不足时用最近点补齐避免index越界 idx[~mask] idx[..., 0].unsqueeze(-1).expand_as(idx)[~mask] return idx正常情况下局部点数比nsample多时最近的前nsample个点基本都在radius内点太少时必须把最近点复制补满idx否则后面的group操作会因为索引越界崩掉。4.3 针对LAS点云调整PointNet参数一组可直接起步的配置PointNet原论文在S3DIS室内数据集上用的是1m×1m block每组4096点半径从0.1起步。室内点云密度和机载LAS不一样我针对“1m block、4096点、平均点间距约0.01m”给一组起点层级radius(m)nsampleMLP通道作用SA10.0532[16, 16, 32]提取厘米级局部结构SA20.1032[32, 32, 64]聚合屋顶边缘、树枝细部SA30.2064[64, 64, 128]聚合到block全局如果你的block是5米、同样4096点平均点间距会到0.08~0.1米SA1的radius至少0.3~0.5米nsample放到64。经验法则是radius取block内点平均间距的3~5倍nsample取单个球域内平均点数的1.5倍。照抄论文参数会让底层球域里只有零星一两个点局部几何完全学不到。训练时PointNet比PointNet慢很多。数据加载一定要离线把block裁成(N, 4096, 3)和(N, 4096)不要每个epoch现做ball query否则GPU利用率会低到没法看。还有两个常见细节坐标必须转float32如果xyz是float64torch.cdist速度骤降block没有做质心归一化时坐标是几百米量级cdist算出的距离巨大radius会失效这正好对应第2.2节“必须减质心”。4.4 加入新LAS数据增量训练与类别扩展示例项目做到一半甲方又给你10个新测区的LAS。旧模型只有“地面、植被、建筑、水体”四个类新数据多了一个“桥梁”总不能为这一个类重新标注几十万点。我一般按这三步做先统计新LAS的分类码直方图确认“桥梁”在原始Classification里用的是自定义码还是需要人工标。若沿用自定义码把新码写进class_map网络输出维度从4改成5。不要从头训练。把旧模型前几层权重拷到新模型新增的全连接层随机初始化在“旧数据截取一部分 新数据”混合集上用小学习率1e-4微调。旧数据要截取否则新类别占比太低微调会把旧类学崩。如果自己点云太少想用公开数据集预训练常见的做法是下载带逐点标签的公开点云数据集比如S3DIS、Semantic3D这类。它们的标签定义不是ASPRS的Classification码网络类别输出也不一样所以预训练只能学几何特征最后几层还是要用自己的LAS标签重新训。这也是“pointnet数据集下载”这个关键词虽然热但几乎没有一个即下即用的LAS分类数据集的原因。5. 排查LAS分类属性常见坑5个翻车现场与解决路径5.1 现象训练loss不降精度一直在20%上下这个现象我遇到过不止一次最典型的原因不是网络结构而是分类映射混乱。LAS 1.2里1表示“未分类”LAS 1.4里0才是“未分类”1是“已分类但未赋值”。如果新旧数据混在一起又没有统一映射同一个类别的点在几何上完全不是一回事网络自然学不进去。解决方式很笨但有效先用2.1的统计代码导出每个文件的分类码直方图再随机抽100个点用CloudCompare打开逐类看标签和几何对不对得上。地面类明显落到树冠上、建筑类跑到水面里的区域直接整块剔除。数据清洗的收益远比调模型参数大。5.2 现象验证accuracy很高可视化结果却是满屏噪点这是类别不平衡的经典翻车。一个测区里地面占75%模型全预测地面就有75%的acc但建筑和植被的预测基本是随机散点。逐点accuracy在这种任务里几乎没有参考价值。解决报告每个类别的IoU和mIoU。地面IoU到0.98、建筑IoU只有0.21说明模型没学到建筑特征。训练集对低占比类做副本过采样损失函数用类别权重或者把低/中/高植被合并成一个“植被”类避免类别碎片化。训练完还可以做一步后处理每个点取其半径0.1米内的多数类别作为最终预测能洗掉不少孤立噪点。5.3 现象内存爆炸数据准备阶段就卡死原因几乎都在laspy.read()整读超大文件加上np.stack复制一份float32内存占用是原始LAS的好几倍。切块代码如果还把block攒在Python list里几十亿点会被无限放大。解决用2.1的流式读取每个chunk切出的block立刻写入HDF5并释放。HDF5写入慢就拆成多个npz临时文件最后再合并。这里有个特别容易忽略的细节block不要保存成Python list of arrays后面pickling和内存碎片会额外吃2~3倍内存应该每生成一个block就create_dataset连续写入。5.4 现象两个LAS文件同是“6”一个代表建筑一个代表桥梁这问题在跨期、跨作业队的项目里非常常见。LAS分类码理论上遵循ASPRS标准但外包数据和历史数据里经常出现“6桥梁”“6电力线”。如果用全量数据训模型会把一个类塞进两种完全不同的几何形状。解决建立文件级清单。每个文件单独统计分类码、点数、坐标范围和占比导出成CSV交给甲方确认。确认后再做第2.3节的class_map。这一步看起来多花了半天但能避免你训两周后才发现标签是错的。没有比这更痛的后悔药。5.5 现象PointNet训练特别慢GPU利用率只有30%CPU瓶颈占多数。HDF5读取加在线FPS、球查询如果Dataset.__getitem__里跑4.2节的Python版FPS每个样本要循环几百次GPU只能干等。解决有三个方向第一把FPS和ball query换成CUDA扩展或TorchScript算子第二离线阶段把每个block的采样索引算好训练时只做index_select和随机旋转第三DataLoader(num_workers8, prefetch_factor2, persistent_workersTrue)把数据读取线程拉满。我见过很多项目把随机采样、随机旋转、归一化全部写进离线pipeline在线只跑一个很薄的迭代器GPU利用率从30%提到70%以上。6. 验证与落地把预测Classification写回LAS点云模型训完交付物通常不是一个mIoU数字而是“自动分类后的LAS文件”要和原始点云一一对应。最常见做法是用训练时保存的坐标信息把预测标签与原LAS重新匹配并写回with laspy.open(area.las, modew) as writer: with laspy.open(area.las, moder) as reader: for chunk in reader.chunks(1_000_000): pred model_predict(chunk_xyz) chunk.classification pred writer.write_points(chunk)写回前一定先复制一份.las.bak。自动分类的边界区域特别容易把“地面”和“低植被”交叉错分一旦写回原始Classification码就没了。这个备份习惯是真正的后悔药。验证阶段除了每类IoU我会把预测结果按块导出成临时LAS放进CloudCompare把颜色绑定到分类码看地面是否连续、建筑是否成簇、植被是否分散。如果出现规则的条带状错误通常是切块时stride选太大边界处上下文不一致把stride调成block_size的一半重训一版能解决。零星噪点则用邻域多数投票选半径0.1米内的众数类别替换掉。部署时PointNet/PointNet是固定点数输入生产环境的block大小、归一化方式必须和训练时严格一致。我会把训练用的数据准备函数封装成同一个encode函数里面写死block_size、stride、max_points训练和推理共用。这个习惯救过我很多次有一次训练时用“减质心”归一化推理时写成“除以包围盒边长”整个预测结果全乱了还得重新跑一遍。数值上如果mIoU超过0.75、地面和建筑两类IoU都超过0.85这个LAS自动分类结果就能进生产流程如果建筑IoU还不到0.5先别急着换模型结构回到第2章检查标签和block切法。数据清洗的收益远大于换网络。希望这些路径能帮你在自己的LAS点云上少走几步弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进