简介:面向具备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 这类类码,还混着一大批 0(never 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(axis=0), xyz.max(axis=0)) print("scale:", las.header.scales, "offset:", las.header.offsets) cls = np.asarray(las.classification, dtype=np.uint8) uniq, cnt = np.unique(cls, return_counts=True) for c, n in zip(uniq, cnt): print(f"class {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–31;LAS 1.4 的 point format 6 以上才扩到 0–255。你要用的类码如果超过 31,写回时会被截断,而且截断是静默发生的。
2.2 标签映射表:哪些类码进训练,哪些设置成 ignore_index
LAS 的类码来自 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, dtype=np.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, block=8.0, stride=4.0, min_pts=2048, min_valid=0.5): """xyz: (N,3) float64, lab: (N,) uint8, IGNORE=255 返回 [(pts (M,4), lab (M,), radius_m), ...],pts 第 4 列是块尺度""" lo = xyz[:, :2].min(axis=0) hi = xyz[:, :2].max(axis=0) 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, axis=1))) radius = max(radius, 1e-3) p /= radius # 第 4 列塞一个常量通道,把真实尺度信息补回去 scale_ch = np.full((p.shape[0], 1), np.log(radius), dtype=np.float32) blocks.append((np.hstack([p, scale_ch]), lb, radius)) return blocks三个参数的作用需要说清楚。block=8.0是物理边长,单位跟 LAS 的投影单位一致,一般是米;太小则一块里只有一个类别,太大则显存不够且类别混合严重。stride=4.0取块长的一半,让相邻块重叠 50%,这是推理阶段做重叠投票的基础。min_pts=2048过滤掉建筑边缘、扫描盲区那种点数极少的碎块,这种块进网络只会贡献噪声梯度。
归一化这一步至少影响一半的精度。把每块缩放到单位球之后,默认配置里radius=0.2 / 0.4这类邻域半径才有意义;如果你改成按全局尺度(比如统一除以 50 m)归一化,同样的radius=0.2就变成了 10 m 的邻域,感受野直接扩大几十倍,模型会退化成“只看全局不看局部”。反过来,单位球归一化丢掉了绝对尺度,一栋 30 m 的楼和一片 3 m 的灌木在块内可能长得差不多,所以我在第 4 列塞了一个log(radius)常量通道当全局特征,这一点是 PointNet 原文里max_dist那个技巧的变体。
2.4 保存为内存映射文件并构建 Dataset
块的数量上万以后,一块一个文件会把 inode 撑爆。常见做法是拼成两个大数组:所有块的点首尾相接存成一个points.npy,标签存一个labels.npy,再存一个offsets.npy记录每块的起止下标,用mmap_mode="r"打开,不占物理内存。
import os import numpy as np def save_blocks(blocks, out_dir): os.makedirs(out_dir, exist_ok=True) pts = np.concatenate([b[0] for b in blocks], axis=0) lab = np.concatenate([b[1] for b in blocks], axis=0) off = np.zeros(len(blocks) + 1, dtype=np.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_points=8192, training=True): self.pts = np.load(f"{root}/points.npy", mmap_mode="r") self.lab = np.load(f"{root}/labels.npy", mmap_mode="r") 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, replace=p.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_ch=3, out_ch=1024): 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(..., dim=2)实现,取每个通道在所有点上的最大值,得到(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, 3+E, N),输出 (B, K, N)""" def __init__(self, num_classes=4, extra_ch=1): 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_channel=128+3,因为上一层输出 128 维特征,SA 内部会把 3 维坐标拼上去;sa3同理是256+3。fp3的1024+256是深层特征 1024 加上浅层特征 256。这些数字只要错一个,报错信息会是expected input[...] to have X channels,对着上一层的 mlp 末尾找就行。
sa1的radius=0.2是在单位球里量的,对应块内最大半径的 20%。8 m 的块最大半径约 5.6 m,0.2 差不多是 1.1 m 的邻域,对植被和建筑边界是合适的粒度。这就是 2.3 节为什么坚持用单位球归一化:换了归一化方式,这些默认半径全部失效。
另外注意extra_ch=1对应我塞进去的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, minlength=num_classes).astype(np.float64) freq = np.maximum(freq <p> <a href="https://download.csdn.net/download/xinjiang666/88755213" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>