简介面向希望在自有点云数据上复现PointNet与PointNet语义分割流程的开发者与研究者这组材料围绕带Classification属性的LAS点云提供了可直接用于训练、验证和预测的样例数据并保留两套网络实现方便在同一批数据上切换模型、比较分类与分割效果。压缩包共19个文件约26KB主体是15个Python文件涵盖点云分类、部件分割、语义分割、数据加载及网络工具函数等模块另附已分类LAS训练验证数据、用于预测的LAS文件与一份代码结构说明文档。数据组织与LAS训练、测试脚本相互配套读者可据此理解从原始LAS读取、样本组织到模型训练和结果预测的完整链路并替换成自己的数据做实验。已有896人学习适合具备PyTorch基础、希望快速搭建点云语义分割实验环境并梳理脚本调用关系的中级读者参考。1. 一份带 Classification 的 LAS值不值得花时间啃前段时间有个做城市部件提取的朋友丢过来一个 LAS说 PointNet 训练三轮 loss 就停在 0.69 附近不动了。我打开一看标签字段里 0 号未分类占了八成多剩下是 1、2、5、6 四个自定义码而他的网络输出层是 8 类。问题不在网络在于数据从 LAS 到 PyTorch 张量这段路没走通。PointNet / PointNet 做点云语义分割模型代码网上到处是真正卡人的是把一份带 Classification 属性的实测 LAS 变成能喂进 DataLoader 的固定点数样本块。这篇笔记就按这条链路走一遍怎么读 LAS 的标签字段、怎么分块归一化、怎么接 PointNet 和 PointNet 的逐点分割头、以及哪几个参数一改就翻车。适合手上已经有实测点云、想跑通自己数据的人。2. 拆开 LASClassification 字段到底藏在哪几个字节2.1 从公共头块定位点记录LAS 是二进制格式前半段是 Public Header Block里面有几个字段必须先读对否则后面全是错的。文件签名固定是 LASF紧跟版本号真正决定解析方式的是 point data record format 这个 ID 和 point data record length 这两个值前者说明每个点记录里有哪些字段后者说明每条记录占多少字节。再往后是 offset to point data告诉解析器从第几个字节开始是点记录本身因为头块和变长记录VLR会占掉一段。很多人直接用 laspy 的las.x就完事了确实省事但一旦遇到标签错位、坐标量级离谱的问题还是要回到头块看一眼。常见的自检方式是打印版本号、点格式 ID、点总数三个值再和文件大小做个粗算文件字节数大致等于 offset to point data 加上 点数乘以点记录长度如果差得远说明文件可能被截断或者版本声明和实际内容不符。2.2 点记录格式 0 与格式 6 的字段差异点记录格式直接决定 Classification 字段的位置和宽度。格式 0 是最经典的布局X、Y、Z 三个 int32接着是 Intensity 的 uint16然后是一个字节的返回信息位再下一个字节就是 Classification之后是扫描角、用户数据、点源 ID。格式 1 在末尾追加了 GPS 时间格式 2 追加 RGB 三个 uint16格式 3 是格式 1 加 RGB。这几种格式里 Classification 都是单字节 uint8位置稳定。LAS 1.4 引入的格式 6 到 10 改了不少X、Y、Z 变成 int32 但语义一致Intensity 仍是 uint16返回信息被拆成更细的位域Classification 还是 uint8但多了独立的 Classification Flags 字节用来标记重叠点、关键点、合成点、 withheld 点。这意味着在格式 6 里一个点是否参与训练除了看 Classification 值还要看 flags 里是不是被标记为合成或 withheld——测绘生产里这类点很常见直接当正常样本喂进去模型会学到一堆噪声。点格式常见版本Classification 位置额外字段0LAS 1.0–1.3第 15 字节记录内无1LAS 1.0–1.3第 15 字节GPS 时间2LAS 1.2–1.3第 15 字节RGB3LAS 1.2–1.3第 15 字节GPS 时间 RGB6LAS 1.4第 16 字节独立 flags 字节表格里的偏移是相对单条点记录起点算的实际读取时不要硬编码用库提供的字段访问更稳。2.3 用 laspy 一次读出 XYZ 与标签真正落到代码关键是分清las.X和las.x。前者返回原始的 int32 整数值后者返回已经按头部 scale 和 offset 换算过的浮点坐标。训练必须用后者否则输入坐标动辄几十万网络第一层就爆了。import numpy as np import laspy def load_las(path, class_mapNone, ignore_index255, drop_flagsTrue): 读 LAS返回 (N, 3) 浮点坐标与 (N,) 整型标签 las laspy.read(path) # las.x / las.y / las.z 已完成 scale * 原始值 offset 的换算 xyz np.stack([las.x, las.y, las.z], axis1).astype(np.float32) # classification 在 laspy 2.x 中是子字段访问返回 uint8 数组 raw_cls np.asarray(las.classification, dtypenp.uint8) if drop_flags and las.header.version.minor 4: # LAS 1.4 的合成点与 withheld 点不参与训练 flags np.asarray(las.classification_flags) valid (flags 0b00000110) 0 xyz, raw_cls xyz[valid], raw_cls[valid] if class_map is None: label raw_cls.astype(np.int64) else: # 未列入映射表的类别统一打成 ignore_index不参与 loss label np.full(raw_cls.shape, ignore_index, dtypenp.int64) for raw, new in class_map.items(): label[raw_cls raw] new return xyz, label这段代码有三个参数值得说清楚。class_map是原始码到训练码的映射字典比如{2: 0, 5: 1, 6: 2}表示把地面、高植被、建筑映射成 0、1、2没写进字典的类别全部落到 ignore_index这是防止未标注点污染梯度的关键一步。ignore_index默认 255必须和后面 CrossEntropyLoss 的 ignore_index 保持一致。drop_flags只对 LAS 1.4 生效flags 字节的第 1、2 位分别表示合成点和 withheld 点按位与之后非零就剔除。2.4 类别码清查先画直方图再谈训练在写任何训练脚本之前第一步应该是统计标签分布。ASPRS 标准里 2 是地面、3 到 5 是不同高度植被、6 是建筑、9 是水体、11 是道路但实测数据尤其是国内生产单位的成果经常整套自定义编码1 可能是建筑也可能是电力线。不做统计就凭经验写映射表是踩坑率最高的操作。import collections xyz, _ load_las(site_a.las, class_mapNone) _, raw load_las(site_a.las) hist collections.Counter(raw.tolist()) total sum(hist.values()) for code in sorted(hist): print(fcode{code:3d} count{hist[code]:9d} ratio{hist[code] / total:.4%})输出的比例分布决定了后续所有策略。如果某一个码超过七成说明它是背景类训练时要考虑降采样如果某个码只有几百个点那它在 IoU 上几乎不可能有像样的分数要么合并进邻近类要么明确放弃并把它的点标成 ignore。我一般会设一条经验线单类点数占比低于 0.3% 的优先合并而不是硬训否则最后算出来的 mIoU 会被这一个类拖到没法看。3. 把 LAS 切成能喂进网络的块分块、归一化与目录约定3.1 为什么不能整幅点云直接送进网络一份实测站点 LAS 动辄几百万到上千万点PointNet 的输入是固定长度 N常见取 1024 或 4096。整幅送进去有三个现实问题显存顶不住max pool 会得到一个覆盖整幅场景的全局特征而语义分割要的是局部判别力全局特征太粗不同区域的点密度差异极大整幅归一化后城区和郊区的尺度完全对不上。所以标准做法是 XY 平面切网格块每块固定采样 N 个点。块大小的选择跟目标尺度直接挂钩提取建筑和地面8 到 16 米一格够用提取电力线、路灯杆这类细长目标块要小到 4 到 8 米否则小目标在块内占比太低采样阶段就被稀释没了。这个参数没有通用解只能按数据里的最小目标尺寸反推。3.2 网格分块与重叠采样的实现分块时如果严格不重叠块边界上的目标会被切成两半模型在边界处的预测会很碎。常见做法是加 30% 到 50% 的重叠训练时重叠块都参与推理时再按投票或者只保留块中心区域来拼回去。def grid_split(xyz, label, grid8.0, overlap0.5, min_ratio0.125, ignore_index255): XY 网格切块返回 [(块坐标, 块标签), ...] step grid * (1.0 - overlap) x_min, y_min xyz[:, 0].min(), xyz[:, 1].min() x_max, y_max xyz[:, 0].max(), xyz[:, 1].max() blocks [] for x0 in np.arange(x_min, x_max, step): for y0 in np.arange(y_min, y_max, step): mask ( (xyz[:, 0] x0) (xyz[:, 0] x0 grid) (xyz[:, 1] y0) (xyz[:, 1] y0 grid) ) # 点数过少的块信息量低直接丢弃比补齐更划算 if mask.sum() max(64, int(grid * grid * min_ratio)): continue blocks.append((xyz[mask], label[mask])) return blocks参数上grid是块的物理边长单位跟 LAS 的坐标单位一致通常是米overlap取 0.5 意味着每块和相邻块重叠一半训练样本数量大约变成不重叠时的四倍代价是训练时间同步上升min_ratio用来过滤空块实测数据里常有扫描盲区那些块里的点全是噪声留着只会让 loss 震荡。3.3 归一化尺度决定了 PointNet 的半径参数归一化这一步最容易被草率处理但它和后面对 PointNet 的 radius 参数是强耦合的。如果每块各自按自身的包围盒做归一化那么一块 8 米格网里只有半栋楼的块和一块铺满整栋楼的块归一化后尺度完全不同PointNet 的 ball query 半径就没法用一个固定值覆盖。稳妥的做法是用固定尺度以块中心为原点除以块边长的一半。这样归一化后坐标严格落在约 [-1, 1] 区间不同块之间的尺度是一致的。def normalize_block(xyz, grid, centerNone): 固定尺度归一化保证不同块之间尺度一致 if center is None: center (xyz.max(axis0) xyz.min(axis0)) / 2.0 scale grid / 2.0 out (xyz - center) / scale # Z 方向不做截断但极端值要压一下防止离群点拉爆 return np.clip(out, -3.0, 3.0).astype(np.float32), centercenter之所以要返回是因为推理拼块时要把坐标还原回原坐标系不返回就得用包围盒反推容易在空块上翻车。clip到 [-3, 3] 是经验值超过这个范围的点基本都是飞点保留下来只会增加计算量。3.4 分层采样与类别权重块内点数往往远超 N直接随机下采样会让小类别按比例消失。比如一块里电线杆只有 200 个点、总共 3 万个点随机采 4096 个期望只采到 27 个稍微抖动一下就是 0。这类血泪经验在细目标提取上尤其明显——模型不是学不会是根本没见到。分层采样是标准解法按类别分组每类至少保底一定配额剩余名额再按比例分配。保底比例不宜太高否则大类样本被过度稀释背景判别力下降一般取 N 的 1% 到 3%。def stratified_sample(label, n_points, rng, ignore_index255, floor_ratio0.02): 按类别配额采样保证小类别至少出现 floor_ratio * n_points 个点 idx np.arange(label.size) valid_num int((label ! ignore_index).sum()) if valid_num 0: return rng.choice(idx, n_points, replaceidx.size n_points) floor max(1, int(n_points * floor_ratio)) picked [] for cls in np.unique(label[label ! ignore_index]): cls_idx idx[label cls] quota max(floor, int(n_points * cls_idx.size / valid_num)) quota min(quota, cls_idx.size) picked.append(rng.choice(cls_idx, quota, replaceFalse)) picked np.concatenate(picked) rest np.setdiff1d(idx, picked) need n_points - picked.size if need 0 and rest.size 0: picked np.concatenate([ picked, rng.choice(rest, need, replacerest.size need), ]) return rng.permutation(picked)[:n_points].astype(np.int64)如果采样后仍有点数不足就只能有放回地重复采样这会让同一批点重复出现loss 上表现为方差变大可以把 batch size 调大一点缓解。除了采样损失函数那边也要配权重常用的是按类别频率取倒数再开方比纯倒数温和不至于把小类权重抬到十几倍导致训练发散。3.5 数据集目录与 Dataset 实现块的存储方式有两种一种是一块一个 npy方便增量处理一种是打成单个 h5随机读性能更好。数据量在几万块以内npy 方案足够排查问题时还能直接np.load看单块。data/las_blocks/ ├── train/ │ ├── 000123_045600.xyz.npy # (N, 3) float32 │ └── 000123_045600.lbl.npy # (N,) int64 ├── val/ └── test/Dataset 这层最容易忽略的是「块和块之间点数必须一致」。如果直接torch.stack某块多了几个点就会抛尺寸不匹配。固定采样到 N 之后这个问题自然消失但推理阶段不做固定采样得靠 padding 加 mask 处理这部分放到后面说。from torch.utils.data import Dataset import os class LASBlockDataset(Dataset): def __init__(self, root, n_points4096, seed0): self.root root self.n_points n_points self.seed seed self.files sorted( f[:-8] for f in os.listdir(root) if f.endswith(.xyz.npy) ) def __len__(self): return len(self.files) def __getitem__(self, i): name self.files[i] xyz np.load(os.path.join(self.root, name .xyz.npy)) lbl np.load(os.path.join(self.root, name .lbl.npy)) # 采样阶段已经固定成 N这里只做类型转换 return torch.from_numpy(xyz), torch.from_numpy(lbl)文件名里带块坐标是刻意设计后面按空间位置划分训练验证集、或者排查某块预测异常时能直接从文件名定位回原始 LAS。这个习惯能省掉大量回溯时间。4. PointNet 语义分割链路从 DataLoader 到逐点 logits4.1 分割头和分类头的结构差异PointNet 原始论文里分类和分割共用一个编码器区别在解码方式。分类是把 N 个点的逐点特征做 max pool得到一个 1024 维全局描述接两层全连接出类别分数。分割不能这么干因为每个点都要一个预测所以做法是把全局特征复制回每个点与逐点特征拼接再过几层 1x1 卷积降维到类别数。官方实现里拼接的是第二层输出的 64 维局部特征和 1024 维全局特征合计 1088 维。我这边为了代码简洁直接拼了 1024 维逐点特征效果差别不大但显存占用会多一些。如果显存紧张还是按 64 1024 来。import torch import torch.nn as nn class PointNetSeg(nn.Module): PointNet 逐点分割版 def __init__(self, num_classes, in_dim3): super().__init__() # 逐点共享 MLP用 1x1 卷积实现避免手写逐点循环 self.encoder nn.Sequential( nn.Conv1d(in_dim, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, 1024, 1), nn.BatchNorm1d(1024), nn.ReLU(), ) 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, N, C) - (B, C, N)Conv1d 要求通道维在前 x x.transpose(1, 2) f128 self.encoder[:6](x) # (B, 128, N) f1024 self.encoder[6:](f128) # (B, 1024, N) glob torch.max(f1024, dim2, keepdimTrue)[0] glob glob.repeat(1, 1, x.size(2)) # (B, 1024, N) out self.head(torch.cat([f128, glob], dim1)) return out.transpose(1, 2) # (B, N, num_classes)transpose那一步不能省Conv1d 把第二维当通道把第三维当空间位置直接拿 (B, N, C) 去卷会把点数当通道处理形状对得上但语义全错而且不会报错属于典型的静默翻车。max pool 沿点数维度做这是 PointNet 保证置换不变性的核心换成 mean 就不再是同一个模型了。最后转回 (B, N, C) 是为了对齐 PyTorch 的 CrossEntropyLoss它要求类别维在最后一维。4.2 训练循环与 ignore_index 的处理损失函数这块CrossEntropyLoss 的 ignore_index 必须和数据处理阶段保持一致。调用时要把 (B, N, C) 展平成 (BN, C)标签展平成 (BN,)不能直接传三维 logits否则会按最后一维的类别做 softmax 后算出一堆无意义的值。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total, seen 0.0, 0 for xyz, lbl in loader: xyz xyz.to(devicedevice, dtypetorch.float32) lbl lbl.to(devicedevice, dtypetorch.long) logits model(xyz) # (B, N, C) loss criterion( logits.reshape(-1, logits.size(-1)), # (B*N, C) lbl.reshape(-1), # (B*N,) ) optimizer.zero_grad(set_to_noneTrue) loss.backward() optimizer.step() total loss.item() * xyz.size(0) seen xyz.size(0) return total / max(seen, 1)几个参数上的习惯优化器用 Adam初始学习率 1e-3配合 StepLR 每 20 个 epoch 乘 0.5batch size 在 8 到 16 之间再大对 point-wise 任务收益有限但显存涨得快BN 层在 batch 小于 8 的时候统计量会抖这种情况要么把 batch 提上去要么换成 GroupNorm。如果 loss 在前几轮就掉到某个值再也不降先别怀疑网络去看标签分布和采样比例。4.3 mIoU 与混淆矩阵的正确算法语义分割不能只看 loss一定要看每类 IoU。直接在每个 batch 上算 IoU 再平均是错的小 batch 里某些类可能压根没出现平均出来会和真实值差很远。正确做法是累积一个 num_classes × num_classes 的混淆矩阵跑完整个验证集再一次性算。import numpy as np import torch def update_confusion(conf, logits, lbl, num_classes, ignore_index255): pred logits.argmax(dim-1).reshape(-1) gt lbl.reshape(-1) valid gt ! ignore_index flat gt[valid] * num_classes pred[valid] binc torch.bincount(flat, minlengthnum_classes * num_classes) conf binc.reshape(num_classes, num_classes).cpu().numpy() def miou_from_confusion(conf): inter np.diag(conf).astype(np.float64) union conf.sum(0) conf.sum(1) - inter iou np.divide(inter, union, outnp.zeros_like(inter), whereunion 0) # 只统计验证集中真实出现过的类别避免空类拉低均值 present conf.sum(1) 0 return iou, iou[present].mean()混淆矩阵的行是真实类别、列是预测类别这个方向不能反反了之后 diag 仍然对但 union 算出来的每类 IoU 是错的而且很难从数值上看出来——这也是一个典型的黑匣子陷阱。返回的present掩码用来排除验证集中完全没出现的类别否则有些类 IoU 是 0把整体均值拉低会误导调参方向。5. 五个真实翻车现场现象、原因与改法5.1 坐标没缩放loss 从第一轮就没动过现象训练启动后 loss 一直在 0.69 到 0.71 之间轻微抖动十几轮不下降mIoU 恒为某个接近单类的值。原因读 LAS 时用了las.X而不是las.x拿到的是原始整数坐标量级在几十万到几百万。送入网络后第一层卷积的输出直接溢出BN 的均值方差被拉到极端值梯度基本消失模型退化成输出先验分布。解决统一用las.x/las.y/las.z取换算后的浮点值并在分块后做固定尺度归一化。上线前加一条断言检查每个块的坐标绝对值是否超过 3.0超了直接报错而不是静默训练能省掉半天排查。5.2 类别码不连续模型只学会一个类现象预测结果图上一片全是同一个颜色混淆矩阵的非对角行几乎全落在同一个列上看起来像模型根本没学到特征。原因原始 LAS 的 Classification 是 2、5、6、11 这种稀疏码直接当成标签索引喂给 CrossEntropyLoss输出维度设成 12但实际参与训练只有 4 个通道其余 8 个通道从未被激活。更糟的是如果输出维度只设成类别数 4标签里的 11 会直接越界崩溃或者被截断成错误类别。解决写一个显式的映射字典把原始码压到 0 到 K-1 的连续区间没映射的码统一置 ignore_index。映射表要落成配置文件随数据一起存推理时反查同一张表还原语义不能靠记忆。5.3 随机下采样把小目标采样没了现象建筑和地面的 IoU 能到 0.8 以上电力线或者路灯杆的 IoU 长期在 0 到 0.05 之间调学习率、加轮数都没用。原因块内随机下采样是均匀的小类别在块里本来占比就低采样后经常整个块里一个小目标点都没剩。模型在训练阶段从未见过这个类验证时自然全错。解决改成类别分层采样给小类别设保底配额同时把损失函数的类别权重按频率的平方根倒数设置。做完这两步小类 IoU 通常能从接近 0 抬到 0.3 以上。如果还是上不去就说明块尺寸太大得把 grid 调小让目标在块内的密度提上来。5.4 分块重叠导致验证集泄露现象训练集 loss 正常下降验证集 mIoU 高得反常上线换一片区域后性能断崖式下跌。原因分块用了 50% 重叠然后按块随机划分训练集和验证集。重叠区域里的同一批点可能同时出现在训练块和验证块里验证集实际上在考训练集见过的点指标虚高。解决按空间区域划分先把测区按坐标划成若干大区训练用一个区、验证用另一个区划完之后再各自分块。如果数据量实在不够退一步的做法是在重叠块里只保留中心区域参与验证边缘重叠部分丢弃虽然样本少一些但指标可信。5.5 PointNet 的 ball query 返回空组现象训练能跑但某些 batch 里出现全零的组特征loss 出现 NaN或者梯度爆炸。原因ball query 的 radius 是在归一化坐标下定义的。如果归一化用了每块自适应的包围盒尺度块与块之间的实际物理半径不一致某块的点稀疏时 radius 内一个邻居都找不到group 为空max pool 输出 -inf 或者 0。解决归一化改成固定尺度用 grid/2 作为除数。同时给 radius 设一个下限并且把 nsample 设成有放回采样即使邻居数不够也能补齐。更保险的做法是在 Set Abstraction 层里加一个判断遇到空组用中心点自身填充避免传播到后端。6. PointNet 调参与验证几个真正影响结果的开关PointNet 的语义分割版是编码器加解码器结构编码器做四层 Set Abstraction逐层降采样点数、扩大感受野解码器做四层 Feature Propagation用最近邻插值加跳跃连接把特征还原回原始点数。真正需要调的不是网络层数而是三个列表每层的采样点数 npoint、球查询半径 radius、每组采样点数 nsample。参数常用初值作用调整方向npoint1024 / 256 / 64 / 16每层降采样后的点数块小就整体缩小别让最后一层只剩个位数radius0.1 / 0.2 / 0.4 / 0.8归一化坐标下的查询半径必须和归一化尺度匹配翻倍调整nsample16 / 32每组最多采多少邻居小目标多就加大显存不够就减半block_size8.0 m分块物理边长最小目标尺寸的 3 到 5 倍radius 是最需要联动调整的一个。归一化到 [-1, 1] 之后0.1 大致对应块边长的 5%也就是 8 米块里的 0.4 米。如果实际数据里建筑立面之间的间距在 1 米以上radius 取 0.1 就几乎连不到邻居第一层 SA 之后特征就退化了。我的习惯是先算一下块内点间距的中位数把第一层 radius 设成中位数的 4 到 6 倍后面每层翻倍。从 PointNet 迁移权重能省不少时间。编码器前三层的 1x1 卷积结构和 PointNet 的逐点 MLP 是一致的可以把 PointNet 训练好的权重按层名对上去解码器随机初始化然后整体用 1e-4 的学习率微调。这样通常比从零训收敛快三分之一而且早期 loss 更平稳。命令行参数我一般封装成这样方便做消融python train_semseg.py \ --data_root ./data/las_blocks \ --num_classes 6 \ --npoint 1024 256 64 16 \ --radius 0.1 0.2 0.4 0.8 \ --nsample 16 32 \ --block_size 8.0 \ --batch_size 8 \ --epochs 120 \ --lr 1e-3 \ --ignore_index 255这里的参数名是自己封装后的形式官方仓库用的是不同的命名对接时把 npoint、radius、nsample 三个列表原样传进模型构造函数即可不要逐层手写。验证环节我固定做两件事。一是分层看指标除了整体 mIoU单独打印每个类的 IoU 和点数占比占比低于 1% 的类单独标出来避免被平均值掩盖。二是做空间留出验证从测区里切一块完全不参与训练的区域单独跑一遍推理看拼块后的整体效果而不是只看块级别的平均分。块级别指标好看但整幅拼出来断裂严重通常说明重叠区域的处理有问题或者归一化中心不一致。从那以后我每次接新数据都强制先跑一遍标签直方图和坐标量级检查两个数不对就不往下走。这个习惯是几次通宵排查换来的比任何调参技巧都值钱。希望帮到你。本文还有配套的精品资源点击获取