2026/10/29 2:32:52

LAS点云数据预处理与PointNet++语义分割实战指南

LAS点云数据预处理与PointNet++语义分割实战指南 简介面向点云深度学习初学者与需要落地自有数据的开发者这份资源聚焦于用PointNet与PointNet完成带Classification属性的LAS点云分类与分割训练解决公开数据集之外「如何训练自己的数据」这一常见卡点。包内共19个文件以15个Python脚本为主体涵盖网络模型、数据加载与LAS训练预测流程另附一份已分类LAS文件用于训练验证、一份LAS文件用于预测以及说明文档压缩包约26KB体量轻便、便于快速上手。目前已有896人学习下载说明该方向需求集中。读者可据此理解从LAS读取、分类属性解析到数据集构建、模型选择与训练验证的完整链路并借助现成脚本替换自有数据复现实验减少环境与数据格式上的试错成本适合具备PyTorch基础、希望深入点云语义分割实践的中级学习者参考。1. 手里那堆带 Classification 的 LAS怎么喂进 PointNet上周有个做电力巡检的朋友甩过来一个压缩包里面是几十份 LAS 点云每份都带 Classification 字段问我能不能直接丢进 PointNet 跑语义分割。我打开一看分类码从 0 到 20 不等坐标是投影坐标系下的几十万级大数点密度还不一致。这就是典型的「数据有标签、但模型吃不下」的状态。PointNet 和 PointNet 在 PyTorch 生态里是点云语义分割最常被拿来当 baseline 的两套实现前者吃全局特征、后者靠 set abstraction 做局部聚合对无序点云天然友好。但它们的输入约定很死一个(B, N, C)的张量C 通常是 xyz 加额外特征标签是(B, N)的整型。LAS 里的 Classification 恰好能当这个标签用前提是你得把 LAS 拆成「坐标 分类码」两路还得处理坐标归一化和类别重映射。这篇就把这条链路从头拆到尾适合手里已经有标注点云、想跑通自己数据集的从业者。2. 从 LAS 到张量坐标、分类码与归一化的三件事LAS 文件本质是个二进制容器头信息里记录了点的数量、坐标范围和点格式后面按点格式逐点存储 X/Y/Z 的整型偏移量、强度、回波、分类码等字段。用 Python 读它最省事的是laspy它把每个维度暴露成 numpy 数组取las.x、las.classification就是现成的。但直接拿原始坐标去训练会翻车投影坐标系下的 X 动辄六位数网络第一层就是矩阵乘数值量级差太大会让梯度直接炸掉。所以第一件事是把坐标平移到局部原点再缩放常见做法是减去点云质心、除以最大范围让所有坐标落在[-1, 1]附近。2.1 用 laspy 拆出坐标与分类码先看读取和字段抽取。下面这段把一份 LAS 拆成xyz和label两个数组并顺手打印分类码分布方便你判断哪些类要合并、哪些是噪声。import numpy as np import laspy def read_las(path): las laspy.read(path) # 取 XYZlaspy 返回的是 scaled 后的浮点坐标 xyz np.vstack((las.x, las.y, las.z)).transpose().astype(np.float32) # Classification 是 uint8直接转 int64 给交叉熵用 label np.asarray(las.classification, dtypenp.int64) return xyz, label xyz, label read_las(tower_01.las) print(点数:, xyz.shape[0]) print(坐标范围:, xyz.min(axis0), xyz.max(axis0)) uniq, cnt np.unique(label, return_countsTrue) for u, c in zip(uniq, cnt): print(fclass {u}: {c} 点)las.x这类属性在 laspy 2.x 里已经是应用了 scale 和 offset 的浮点值不需要你手动乘0.01。如果你用的是老版本 laspy 1.x接口是las.X加las.header.scale得自己算这里不展开。分类码分布那几行很关键如果某个类只有几十个点训练时几乎必然被淹没要么合并到邻近类要么在采样时做类别均衡。2.2 坐标归一化与类别重映射归一化不是简单除以 255。点云是三维的三个轴的范围往往差一个量级比如巡检场景里水平铺开几百米、垂直只有几十米。我一般按「整体最大跨度」统一缩放保持各轴比例避免把塔身压扁。def normalize(xyz): centroid xyz.mean(axis0, keepdimsTrue) xyz xyz - centroid # 用三轴最大跨度做统一缩放保持几何比例 scale np.max(np.abs(xyz)) return xyz / scale # 原始分类码 - 连续标签0 保留为未分类/忽略 raw_to_idx {1: 0, 2: 1, 6: 2, 9: 3, 17: 4} def remap(label, mapping): out np.full_like(label, -1) # -1 表示 ignore_index for raw, idx in mapping.items(): out[label raw] idx return out xyz_n normalize(xyz) label_idx remap(label, raw_to_idx)scale取的是绝对值最大分量这样归一化后所有点都在[-1, 1]内且不会因为某个轴特别长而把其他轴压没。remap里用-1标记忽略类PyTorch 的CrossEntropyLoss(ignore_index-1)会跳过这些点这比直接删掉未分类点更省事也保留了原始点数。注意映射表必须和你的num_classes对齐类别数写错是最常见的静默错误——loss 能降但预测全错。2.3 采样到固定点数PointNet 的 set abstraction 层虽然能处理变长输入但 PyTorch 的 batch 拼接要求同一 batch 内点数一致。LAS 每份点数从几万到几百万不等直接 pad 到最大会浪费显存。常见做法是训练时随机采样固定 N比如 8192 或 16384推理时用滑动窗口或全量分块。采样要保证类别不被采没我一般按类别分层采样。def sample_fixed(xyz, label, n8192): if xyz.shape[0] n: idx np.random.choice(xyz.shape[0], n, replaceFalse) else: idx np.random.choice(xyz.shape[0], n, replaceTrue) return xyz[idx], label[idx]replaceTrue用于点数不足时补齐代价是重复点对小目标类反而有轻微上采样效果。如果你做的是分割而不是分类随机采样会破坏空间连续性更稳的是体素下采样或最远点采样但 FPS 在纯 numpy 里很慢训练时我一般先用随机采样跑通再换torch_cluster的fps做精调。3. Dataset 与 DataLoader把 LAS 目录接进 PyTorch 训练循环数据拆好了下一步是把它包成torch.utils.data.Dataset。这一步的坑不在代码本身而在「文件组织」和「增强时机」。LAS 文件通常按场景分目录每个目录里可能还有多个测站标签体系要统一。我习惯在 Dataset 初始化时就把所有文件路径扫成列表__getitem__里只做读取、归一化、采样增强放在 collate 之后或__getitem__末尾避免在原始坐标上做旋转导致归一化失效。3.1 自定义 Dataset 的骨架import os import torch from torch.utils.data import Dataset, DataLoader class LasSegDataset(Dataset): def __init__(self, root, mapping, n_points8192, trainTrue): self.files [] for dirpath, _, names in os.walk(root): for nm in names: if nm.lower().endswith(.las): self.files.append(os.path.join(dirpath, nm)) self.mapping mapping self.n_points n_points self.train train def __len__(self): return len(self.files) def __getitem__(self, i): xyz, label read_las(self.files[i]) xyz normalize(xyz) label remap(label, self.mapping) xyz, label sample_fixed(xyz, label, self.n_points) if self.train: # 绕 Z 轴随机旋转模拟不同朝向 theta np.random.uniform(0, 2 * np.pi) rot np.array([[np.cos(theta), -np.sin(theta), 0], [np.sin(theta), np.cos(theta), 0], [0, 0, 1]], dtypenp.float32) xyz xyz rot.T return torch.from_numpy(xyz), torch.from_numpy(label)os.walk递归扫目录好处是不用关心 LAS 放在几层子目录里。旋转增强放在归一化之后因为归一化已经把点云居中绕 Z 轴转不会改变坐标范围。xyz rot.T是标准的右乘旋转矩阵写法注意别写成rot xyz维度对不上会直接报错。标签不做增强因为旋转不改变类别。3.2 DataLoader 的 batch 拼接与 ignore_indexds LasSegDataset(data/las_train, raw_to_idx, n_points8192, trainTrue) loader DataLoader(ds, batch_size8, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue) for xyz, label in loader: print(xyz.shape, label.shape) # (8, 8192, 3) (8, 8192) breakdrop_lastTrue是为了避免最后一个 batch 点数不足导致 BN 层统计异常点云网络里 BN 用得不少这个细节容易被忽略。num_workers4在 Linux 下没问题Windows 上要包在if __name__ __main__:里否则多进程会反复 import 主模块。pin_memoryTrue配合 GPU 训练能减少拷贝开销但如果你的显存本来就紧张可以先关掉。3.3 类别不均衡的加权策略语义分割里小目标类被大背景淹没是常态。除了采样时分层还可以在 loss 上做文章。CrossEntropyLoss支持weight参数按类别频率的倒数给权重但别直接取倒数容易把极稀有类权重拉到天上导致训练震荡。我一般用1 / sqrt(freq)再归一化。freq np.bincount(label_idx[label_idx 0], minlengthlen(raw_to_idx)) w 1.0 / np.sqrt(freq 1e-6) w w / w.sum() * len(w) class_weight torch.tensor(w, dtypetorch.float32) criterion torch.nn.CrossEntropyLoss(weightclass_weight, ignore_index-1)minlength保证即使某个类在当前文件里没出现权重数组长度也对。ignore_index-1和前面 remap 的约定一致。权重算完最好打印出来看一眼如果某个类权重超过 10说明它点数太少考虑合并类别而不是硬加权。4. 训练配置与验证学习率、块大小和 mIoU 怎么定PointNet 的官方实现里分割任务常用初始学习率 1e-3、batch size 8 到 16、Adam 优化器配合 StepLR 每 20 个 epoch 衰减 0.5。但那是 S3DIS 室内场景的配置搬到室外 LAS 上要改。室外点云范围大、点密度低块大小也就是采样点数 N给太小会让塔身、绝缘子这类细长结构只剩几个点给太大显存吃不消。我的经验是 8192 起步显存够就上 16384再大就得用分块推理。4.1 一个能跑起来的最小训练循环import torch from torch.optim import Adam from torch.optim.lr_scheduler import StepLR device torch.device(cuda if torch.cuda.is_available() else cpu) model PointNet2Seg(num_classeslen(raw_to_idx)).to(device) opt Adam(model.parameters(), lr1e-3, weight_decay1e-4) sched StepLR(opt, step_size20, gamma0.5) for epoch in range(100): model.train() total_loss 0.0 for xyz, label in loader: xyz xyz.to(device).transpose(1, 2) # (B,3,N) label label.to(device) logits model(xyz) # (B,C,N) loss criterion(logits, label) opt.zero_grad() loss.backward() opt.step() total_loss loss.item() sched.step() print(fepoch {epoch} loss {total_loss / len(loader):.4f})注意transpose(1, 2)PointNet 系列实现里输入约定是(B, C, N)而 Dataset 返回的是(N, 3)DataLoader 拼成(B, N, 3)所以要在进网络前转置。这个转置写错不会报错但网络会把点数当通道训练 loss 看着在降验证 mIoU 却惨不忍睹属于血泪经验级别的坑。weight_decay1e-4是点云网络的常规正则太大欠拟合、太小过拟合可以先按这个跑。4.2 验证指标别只看 accuracy点云语义分割的 accuracy 会被背景类拉高真正要看的是 mIoU。验证时不要做随机采样增强用固定采样或全量分块保证每次评估一致。def evaluate(model, loader, num_classes): model.eval() inter np.zeros(num_classes) union np.zeros(num_classes) with torch.no_grad(): for xyz, label in loader: xyz xyz.to(device).transpose(1, 2) pred model(xyz).argmax(dim1).cpu().numpy() gt label.numpy() for c in range(num_classes): inter[c] np.logical_and(pred c, gt c).sum() union[c] np.logical_or(pred c, gt c).sum() iou inter / np.maximum(union, 1) return iou.mean(), iounp.maximum(union, 1)防止除零。返回的iou数组要逐类看如果某一类 IoU 长期为 0多半是 remap 时标签对错了或者该类在训练集里根本没出现。验证集最好按场景划分别把同一测站的文件同时放进训练和验证否则 mIoU 虚高上线就翻车。4.3 学习率与块大小的联动块大小 N 变了等效 batch 里的点数也变了学习率要跟着调。经验公式是lr_new lr_base * sqrt(N_new / N_base)N 从 8192 翻到 16384学习率乘 1.4 左右。另外 PointNet 的 set abstraction 里有采样半径参数室外场景的半径要比室内大否则邻域里一个点都没有特征聚合直接退化成单点。这个参数在模型定义里不在训练脚本里改的时候要一起看。5. 避坑排查坐标、标签和显存的三类翻车现场5.1 现象loss 不降或变成 nan原因通常是坐标没归一化或者归一化时除以了 0。LAS 里如果有重复点导致scale为 0除法直接产生 inf。解决归一化前检查np.max(np.abs(xyz))是否大于 1e-6小于就跳过该文件或加 epsilon。另外确认las.x取到的是浮点坐标而不是整型原始值整型直接喂进去量级更大。5.2 现象训练正常但验证 mIoU 接近 0先查标签映射。raw_to_idx的 key 必须和 LAS 里实际的 Classification 值完全一致有的数据用 0 表示未分类、有的用 1映射错一位全盘皆输。再查转置(B, N, 3)没转成(B, 3, N)时网络仍能前向但空间结构全乱。最后查验证集是否混入了训练集文件按文件名前缀分场景能避免。5.3 现象CUDA out of memoryPointNet 的显存占用和 N 的平方相关因为 set abstraction 要做邻域查询。8192 点、batch 8 在 8GB 卡上通常够但如果你把 N 提到 32768 还开 batch 16必炸。解决降 batch、开torch.cuda.amp混合精度、或者把大文件切成固定块分别推理再拼回去。切块时块之间要有重叠否则边界点的邻域特征会缺失。5.4 现象小目标类预测全为背景除了加权还要看采样。随机采样 8192 点时如果小目标只占 0.5%平均每份只有 40 个点网络根本学不到。解决分层采样保证每类至少占一定比例或者在 Dataset 里对小目标做局部裁剪增强把包含小目标的区域单独裁出来当样本。这个改动比调 loss 权重更直接。6. 进阶用分块推理把整份 LAS 跑出逐点标签训练跑通只是第一步真正落地是把整份 LAS 的每个点都打上预测标签。整份文件动辄几百万点不能一次性进网络得切块。我的做法是按空间网格切每块取固定点数块间留 20% 重叠推理完把重叠区的预测按距离加权融合再写回 LAS 的 Classification 字段。这样输出的 LAS 能直接在 CloudCompare 里按分类着色检查。def predict_las(model, path, mapping, block16384, stride0.8): xyz, _ read_las(path) xyz_n normalize(xyz) inv {v: k for k, v in mapping.items()} pred_all np.full(xyz.shape[0], -1, dtypenp.int64) step int(block * stride) for start in range(0, xyz.shape[0], step): idx np.arange(start, min(start block, xyz.shape[0])) if len(idx) block: idx np.concatenate([idx, np.random.choice(idx, block - len(idx))]) chunk torch.from_numpy(xyz_n[idx]).float().unsqueeze(0).transpose(1, 2).to(device) with torch.no_grad(): pred model(chunk).argmax(dim1).squeeze(0).cpu().numpy() # 只写回未填充部分重叠区后写覆盖先写 valid min(block, xyz.shape[0] - start) pred_all[start:start valid] pred[:valid] return np.array([inv.get(p, 0) for p in pred_all])stride0.8表示每次前进 80% 块长留 20% 重叠。重叠区用「后写覆盖」简化处理严格做法是按到块中心的距离加权投票但工程上先跑通更重要。inv把连续索引映射回原始分类码写回时用las.classification pred_all再las.write()。注意normalize必须和训练时用同一套参数否则推理分布偏移预测会系统性偏一类。这套流程我在几个巡检数据集上跑过最深的体会是数据预处理花的时间永远比调模型多而 80% 的翻车都发生在「坐标没对齐」和「标签映射错位」这两件事上。从那以后我每次接新数据都强制先跑一遍分类码分布和坐标范围打印确认量级和类别都对得上再进训练。希望帮到你。本文还有配套的精品资源点击获取