资讯动态

从 LAS 到 PointNet++:点云语义分割的数据管线与训练实战

发布时间:2026/11/6 2:05:49 来源:尧图企业网站定制
简介面向具备PyTorch基础、希望将自有标注点云接入PointNet与PointNet的学习者内容围绕带Classification属性的LAS数据展开提供可参考的语义分割训练、验证与预测流程。压缩包共19个文件、约26KB以15个Python脚本为主体涵盖数据预处理、LAS数据集加载、训练与测试入口及网络模型调用另附已分类LAS样本和待预测LAS文件分别用于分割训练验证与推理演示并配有一份代码结构说明文档便于快速理清模块关系。资源基于公开的Pointnet_Pointnet2_pytorch工程整理可在PointNet与PointNet之间切换已有896人学习。读者可据此掌握从LAS读取、分类属性映射、样本组织到模型训练评估的完整链路减少自行改造数据接口和调试输入格式的时间。1. 手里那份带 Classification 的 LAS才是 PointNet 真正的第一道坎测绘队给过来一个 LAS 文件Classification 字段已经标好地面、植被、建筑你想直接拿它训一个 PointNet 做语义分割跑通官方 demo 之后把数据一换各种报错。问题的根源往往不在网络官方代码读的是 txt 或 HDF5标签是连续的 0..K-1而 LAS 里保存的是 2、5、6 这类类码还混着一大批 0never classified。PointNet、PointNet 这类点云网络对输入尺度、通道顺序和标签范围都有严格要求任何一项对不上训练都不会报错但训练结果会明显变差。这篇内容沿着我自己跑通这条链路的顺序展开先把 LAS 拆成能用的样本再改网络接口然后是训练、推理、回写最后讲二次开发怎么改。适合手上已经有标注点云、想用 pytorch 跑通点云语义分割的开发者。2. 数据管线把 LAS 的 Classification 字段变成网络标签整条链路里最耗时间的不是调模型是把点云整理成网络能接受的格式。这一步做完后面训练基本就是常规操作这一步做错后面怎么调参都只是表面调整。2.1 先给 LAS 做一次检查拿到文件别急着转格式先看清三个东西点格式point_format决定了 classification 字段能放多大范围的类码坐标的 scale/offset 决定了精度类码分布决定了你的类别体系有多不平衡。import laspy import numpy as np las laspy.read(tile_01.las) # 点格式与版本直接决定 classification 能存 0-31 还是 0-255 print(version:, las.header.version) print(point_format:, las.header.point_format.id) # 里面到底有哪些维度别默认只有 xyz print(dims:, list(las.point_format.dimension_names)) # las.x 是 float64 的真实坐标las.X 是 int32 的原始整型值注意不要搞混 xyz np.vstack([las.x, las.y, las.z]).T.astype(np.float64) print(bbox:, xyz.min(axis0), xyz.max(axis0)) print(scale:, las.header.scales, offset:, las.header.offsets) cls np.asarray(las.classification, dtypenp.uint8) uniq, cnt np.unique(cls, return_countsTrue) for c, n in zip(uniq, cnt): print(fclass {c:3d}: {n:9d} {n / len(cls) * 100:6.2f}%)las.x/.y/.z返回的是套用 scale 和 offset 之后的浮点坐标单位就是文件头里声明的投影单位。las.X/.Y/.Z是原始整型量级通常在百万级。把整型值直接喂给网络归一化之后每个点都挤在一起模型基本学不到东西这是新手最常见的第一个坑。类码分布也要看如果某一类只占千分之几后面就必须做类别加权或重采样不能指望默认的交叉熵。还有一个隐藏的兼容性问题LAS 1.2 的 point format 0–5 里 classification 只有 5 个比特能表示的类码是 0–31LAS 1.4 的 point format 6 以上才扩到 0–255。你要用的类码如果超过 31写回时会被截断而且截断是静默发生的。2.2 标签映射表哪些类码进训练哪些设置成 ignore_indexLAS 的类码来自 ASPRS 标准2 是地面、5 是高植被、6 是建筑、11 是路面。你不需要把所有类都拿来训只保留业务关心的几类其余统一设成忽略值。# 只保留关心的类码映射到连续的 0..K-1 CLASS_MAP { 2: 0, # ground 5: 1, # high vegetation 6: 2, # building 11: 3, # road surface # 0 (never classified) / 1 (unclassified) / 7 (low noise) 全部不参与训练 } IGNORE 255 # 在 Dataset 里再转成 -100 交给 CrossEntropyLoss def remap_label(cls: np.ndarray) - np.ndarray: out np.full(cls.shape, IGNORE, dtypenp.uint8) for src, dst in CLASS_MAP.items(): out[cls src] dst return out INV_MAP {v: k for k, v in CLASS_MAP.items()} # 推理写回时用这里有两个决定要做。第一类码 0 一定要排除。它表示“未分类”在原始数据里经常占很大比例如果当成一个正经类别去训网络很快就学会“全都预测成 0”这条捷径loss 降得很漂亮mIoU 却惨不忍睹。第二映射后的 ID 必须连续因为 CrossEntropyLoss 的输出维度是num_classes类 ID 跳到 11 会直接越界。IGNORE用 255 只是中间态在 Dataset 里再转成 -100这是 pytorch 的ignore_index默认值。中间用 255 是因为要存成 uint8 省空间-100 存不进 uint8。2.3 滑窗切块与归一化单位球、块尺度和半径的对应关系一整个 tile 动辄几百万点不可能一次性送进显存。做法是二维网格滑窗每块固定物理边长常见 8 m 或 10 m步长取块长的一半保证块之间有重叠边缘的点不会被切断。切完之后每块单独做归一化。def grid_blocks(xyz, lab, block8.0, stride4.0, min_pts2048, min_valid0.5): xyz: (N,3) float64, lab: (N,) uint8, IGNORE255 返回 [(pts (M,4), lab (M,), radius_m), ...]pts 第 4 列是块尺度 lo xyz[:, :2].min(axis0) hi xyz[:, :2].max(axis0) xs np.arange(lo[0], max(hi[0] - block, lo[0]) stride, stride) ys np.arange(lo[1], max(hi[1] - block, lo[1]) stride, stride) blocks [] for x0 in xs: for y0 in ys: m ((xyz[:, 0] x0) (xyz[:, 0] x0 block) (xyz[:, 1] y0) (xyz[:, 1] y0 block)) idx np.flatnonzero(m) if idx.size min_pts: # 点太少组不成一个 batch 元素 continue lb lab[idx] if (lb ! IGNORE).mean() min_valid: # 整块几乎都是忽略点 continue p xyz[idx].astype(np.float32) z_ref np.percentile(p[:, 2], 2) # 用低分位数当地面比 min 稳 p[:, 0] - x0 block * 0.5 p[:, 1] - y0 block * 0.5 p[:, 2] - z_ref # 归一化到单位球PointNet 系列的标准做法 radius float(np.max(np.linalg.norm(p, axis1))) radius max(radius, 1e-3) p / radius # 第 4 列塞一个常量通道把真实尺度信息补回去 scale_ch np.full((p.shape[0], 1), np.log(radius), dtypenp.float32) blocks.append((np.hstack([p, scale_ch]), lb, radius)) return blocks三个参数的作用需要说清楚。block8.0是物理边长单位跟 LAS 的投影单位一致一般是米太小则一块里只有一个类别太大则显存不够且类别混合严重。stride4.0取块长的一半让相邻块重叠 50%这是推理阶段做重叠投票的基础。min_pts2048过滤掉建筑边缘、扫描盲区那种点数极少的碎块这种块进网络只会贡献噪声梯度。归一化这一步至少影响一半的精度。把每块缩放到单位球之后默认配置里radius0.2 / 0.4这类邻域半径才有意义如果你改成按全局尺度比如统一除以 50 m归一化同样的radius0.2就变成了 10 m 的邻域感受野直接扩大几十倍模型会退化成“只看全局不看局部”。反过来单位球归一化丢掉了绝对尺度一栋 30 m 的楼和一片 3 m 的灌木在块内可能长得差不多所以我在第 4 列塞了一个log(radius)常量通道当全局特征这一点是 PointNet 原文里max_dist那个技巧的变体。2.4 保存为内存映射文件并构建 Dataset块的数量上万以后一块一个文件会把 inode 撑爆。常见做法是拼成两个大数组所有块的点首尾相接存成一个points.npy标签存一个labels.npy再存一个offsets.npy记录每块的起止下标用mmap_moder打开不占物理内存。import os import numpy as np def save_blocks(blocks, out_dir): os.makedirs(out_dir, exist_okTrue) pts np.concatenate([b[0] for b in blocks], axis0) lab np.concatenate([b[1] for b in blocks], axis0) off np.zeros(len(blocks) 1, dtypenp.int64) off[1:] np.cumsum([b[0].shape[0] for b in blocks]) np.save(f{out_dir}/points.npy, pts) np.save(f{out_dir}/labels.npy, lab) np.save(f{out_dir}/offsets.npy, off) class BlockDataset(torch.utils.data.Dataset): def __init__(self, root, num_points8192, trainingTrue): self.pts np.load(f{root}/points.npy, mmap_moder) self.lab np.load(f{root}/labels.npy, mmap_moder) self.off np.load(f{root}/offsets.npy) self.n num_points self.training training def __len__(self): return len(self.off) - 1 def __getitem__(self, i): s, e self.off[i], self.off[i 1] p np.array(self.pts[s:e]) # (M, 4) l np.array(self.lab[s:e]).astype(np.int64) # 固定点数多则随机下采样少则允许重复采样 idx np.random.choice(p.shape[0], self.n, replacep.shape[0] self.n) p, l p[idx], l[idx] l[l 255] -100 # 交给 CrossEntropyLoss p p.T # (4, N)通道在前 return torch.from_numpy(p).float(), torch.from_numpy(l)num_points取 8192 是个比较稳的折中4 万点的块下采样到这个量级FPS 和 SA 层的耗时能接受同时 8 m 见方里 8192 个点平均点间距大约 9 cm对建筑和植被的边界还留得住。replace的判断不能省点数不足时np.random.choice不加 replace 会直接抛错。划分训练、验证集要按 tile 切不能把同一个 tile 切出来的块随机打散。重叠滑窗让相邻块共享大量点随机划分会把验证集信息泄漏到训练集里mIoU 能虚高十几个点上线就掉回去。3. 网络与训练PointNet 和 PointNet 的接口约定数据整理完接下来是把网络接上去。这一步的关键是接口约定网络结构本身并不复杂。3.1 环境准备PointNet 的 FPS 和 Ball Query 有 CUDA 实现CPU 版本跑起来慢到没法用。装环境的时候最容易卡在扩展编译上。pip install torch torchvision # 按本机 CUDA 版本选对应 wheel pip install laspy numpy tqdm scikit-learn # pointnet2 的算子库从源码目录编译安装 cd pointnet2_ops_lib pip install .编译失败基本是三个原因nvcc不在 PATH 里、本机 CUDA 版本和 torch 编译时用的版本对不上、gcc 版本太新高版本 gcc 对 CUDA 头文件更严格。改CUDA_HOME环境变量、换gcc-9通常能解决。装完import pointnet2_utils不报错才算准备好。3.2 两个网络的接口差异xyz 与 feature 分开传PointNet 和 PointNet 看起来是两代模型接口上最大的差别在于坐标和特征要不要分开传。PointNet 的 encoder 就是一个 MLP 加一个对称函数输入(B, C, N)C 里前三个通道是 xyz后面是附加特征class PointNetEncoder(nn.Module): def __init__(self, in_ch3, out_ch1024): 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, out_ch, 1), nn.BatchNorm1d(out_ch), nn.ReLU(), ) def forward(self, x): # x: (B, C, N) return self.mlp(x) # (B, out_ch, N)对称函数在这里用torch.max(..., dim2)实现取每个通道在所有点上的最大值得到(B, out_ch)的全局描述子。做语义分割时需要把全局特征 broadcast 回每个点再跟局部特征拼接这也是 PointNet 分割精度不如 PointNet 的根本原因它没有真正的局部邻域。PointNet 的 SA 层签名是forward(xyz, points)xyz是(B, 3, N)的坐标points是(B, C, N)的附加特征两者分开传。第一层的points可以是None模块内部会自动把 xyz 当作特征。下面的 SSG 版本是最常见的分割骨干import torch import torch.nn as nn from pointnet2_utils import PointNetSetAbstraction, PointNetFeaturePropagation class PointNet2SSGSeg(nn.Module): 输入 (B, 3E, N)输出 (B, K, N) def __init__(self, num_classes4, extra_ch1): super().__init__() c_in 3 extra_ch # npoint / radius / nsample 都是在单位球坐标系里的量 self.sa1 PointNetSetAbstraction(512, 0.2, 32, c_in, [64, 64, 128], False) self.sa2 PointNetSetAbstraction(128, 0.4, 64, 128 3, [128, 128, 256], False) self.sa3 PointNetSetAbstraction(None, None, None, 256 3, [256, 512, 1024], True) self.fp3 PointNetFeaturePropagation(1024 256, [256, 256]) self.fp2 PointNetFeaturePropagation(256 128, [256, 128]) self.fp1 PointNetFeaturePropagation(128, [128, 128, 128]) self.head nn.Sequential( nn.Conv1d(128, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(0.5), nn.Conv1d(128, num_classes, 1), ) def forward(self, x): l0_xyz, l0_pts x[:, :3, :], x[:, 3:, :] if x.shape[1] 3 else None l1_xyz, l1_pts self.sa1(l0_xyz, l0_pts) l2_xyz, l2_pts self.sa2(l1_xyz, l1_pts) l3_xyz, l3_pts self.sa3(l2_xyz, l2_pts) l2_pts self.fp3(l2_xyz, l3_xyz, l2_pts, l3_pts) l1_pts self.fp2(l1_xyz, l2_xyz, l1_pts, l2_pts) l0_pts self.fp1(l0_xyz, l1_xyz, None, l1_pts) # 官方实现里 l0 特征为 None return self.head(l0_pts) # (B, K, N)通道数是这套代码里最容易算错的地方。sa2的in_channel1283因为上一层输出 128 维特征SA 内部会把 3 维坐标拼上去sa3同理是2563。fp3的1024256是深层特征 1024 加上浅层特征 256。这些数字只要错一个报错信息会是expected input[...] to have X channels对着上一层的 mlp 末尾找就行。sa1的radius0.2是在单位球里量的对应块内最大半径的 20%。8 m 的块最大半径约 5.6 m0.2 差不多是 1.1 m 的邻域对植被和建筑边界是合适的粒度。这就是 2.3 节为什么坚持用单位球归一化换了归一化方式这些默认半径全部失效。另外注意extra_ch1对应我塞进去的log(radius)通道。如果你想再拼强度和回波次数改这个数就行c_in会自动跟着变。3.3 训练循环类别权重与 ignore_index损失函数用带权交叉熵。权重按训练集频率的倒数开根号再归一化到均值为 1避免极少数类被放大到把梯度打爆。def build_class_weight(labels, num_classes, clip(0.2, 5.0)): labels: 一维 int 数组已经去掉 -100 freq np.bincount(labels, minlengthnum_classes).astype(np.float64) freq np.maximum(freq p a hrefhttps://download.csdn.net/download/xinjiang666/88755213 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑