简介:面向希望在自有点云数据上复现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. 拆开 LAS:Classification 字段到底藏在哪几个字节
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 位置 | 额外字段 |
|---|---|---|---|
| 0 | LAS 1.0–1.3 | 第 15 字节(记录内) | 无 |
| 1 | LAS 1.0–1.3 | 第 15 字节 | GPS 时间 |
| 2 | LAS 1.2–1.3 | 第 15 字节 | RGB |
| 3 | LAS 1.2–1.3 | 第 15 字节 | GPS 时间 + RGB |
| 6 | LAS 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_map=None, ignore_index=255, drop_flags=True): """读 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], axis=1).astype(np.float32) # classification 在 laspy 2.x 中是子字段访问,返回 uint8 数组 raw_cls = np.asarray(las.classification, dtype=np.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, dtype=np.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_map=None) _, raw = load_las("site_a.las") hist = collections.Counter(raw.tolist()) total = sum(hist.values()) for code in sorted(hist): print(f"code={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, grid=8.0, overlap=0.5, min_ratio=0.125, ignore_index=255): """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, center=None): """固定尺度归一化,保证不同块之间尺度一致""" if center is None: center = (xyz.max(axis=0) + xyz.min(axis=0)) / 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_index=255, floor_ratio=0.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, replace=idx.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, replace=False)) 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, replace=rest.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_points=4096, seed=0): 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 到逐点 logits
4.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_dim=3): 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, dim=2, keepdim=True)[0] glob = glob.repeat(1, 1, x.size(2)) # (B, 1024, N) out = self.head(torch.cat([f128, glob], dim=1)) 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(device=device, dtype=torch.float32) lbl = lbl.to(device=device, dtype=torch.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_none=True) 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.5;batch 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_index=255): 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, minlength=num_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, out=np.zeros_like(inter), where=union > 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。
| 参数 | 常用初值 | 作用 | 调整方向 |
|---|---|---|---|
| npoint | 1024 / 256 / 64 / 16 | 每层降采样后的点数 | 块小就整体缩小,别让最后一层只剩个位数 |
| radius | 0.1 / 0.2 / 0.4 / 0.8 | 归一化坐标下的查询半径 | 必须和归一化尺度匹配,翻倍调整 |
| nsample | 16 / 32 | 每组最多采多少邻居 | 小目标多就加大,显存不够就减半 |
| block_size | 8.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% 的类单独标出来,避免被平均值掩盖。二是做空间留出验证,从测区里切一块完全不参与训练的区域,单独跑一遍推理,看拼块后的整体效果,而不是只看块级别的平均分。块级别指标好看但整幅拼出来断裂严重,通常说明重叠区域的处理有问题,或者归一化中心不一致。
从那以后我每次接新数据,都强制先跑一遍标签直方图和坐标量级检查,两个数不对就不往下走。这个习惯是几次通宵排查换来的,比任何调参技巧都值钱。希望帮到你。
本文还有配套的精品资源,点击获取