简介:PointNet-SemSeg-VKITTI3D 面向三维点云语义分割的学习者与研究者,给出基于 PointNet 在虚拟 KITTI 数据集上完成场景级点云分割的代码实现,可用于理解点云分割网络结构、复现实验和二次开发。压缩包共 54 个文件、约 21.73MB,以 Python 源码与字节码缓存为主,并含文本说明、模型检查点、HTML 可视化页面、Notebook、Markdown 文档和论文 PDF,覆盖数据准备、网络定义、训练、批量推理、IoU/精度评估及点云可视化等环节。已有 942 人学习,主题关注度较高。数据以 .npy 组织、每个点包含 XYZRGBL 七维特征,配合训练与评估脚本,读者可梳理从输入组织到结果检查的完整流程;模型检查点和示例 Notebook 也便于快速上手和对照实验。评估、推理与可视化脚本分工清晰,便于按模块检索和调试,也能为三维视觉课程设计或科研入门提供参考,适合具备一定 Python 与深度学习基础、希望深入点云语义分割实践的读者。
1. 点云语义分割不是“跑个分类”:这个项目能干什么
一个场景几万个点,跑完一次前向,每个点都要吐出一个类别——路面、建筑、车、树、天空,一个都不能漏。这件事用 PointNet 做,网络代码不到两百行,但真正耗时间的地方从来不是网络本身。
这个项目是 PointNet 在 VKITTI3D 上的语义分割实践:把虚拟 KITTI 场景导出的三维点云逐点打上 13 类标签,数据转换、训练、评估、导出推理结果一条链路齐活,另外配了一个 HTML 页面,把预测结果直接丢进浏览器里转着看。它适合两类人:刚学完 PointNet 分类部分、想知道分割头怎么接的;以及手上有自己的点云数据、想找个能跑通的最小骨架去改的。下面按“能干什么 → 怎么跑 → 怎么改”的顺序拆开。
2. 网络结构拆解:一个点从 9 维坐标到 13 类概率的完整链路
PointNet 的论文图看着简单,落到代码里其实只有三个模块在起作用:输入对齐、逐点共享 MLP + 对称函数、特征对齐。分割任务比分类多出来的那一截,本质上就是“把全局特征原路摊回去,和每个点自己的局部特征拼起来再降维”。这一章把这条链路从头走一遍,后面调参和改结构都建立在这条链路上。
2.1 输入层:为什么喂进去的是 9 维而不是 3 维
VKITTI3D 的原始点云一般带 xyz 和 rgb 六列,很多人直接六列塞进网络,结果训练早期 loss 抖得厉害。原因是 xyz 的量级是米级甚至十米级,rgb 是 0~255,两者差了两个数量级,BatchNorm 拉得回来但前几个 epoch 基本白跑。
仓库里的做法是拼成 9 维:归一化后的 xyz、归一化到 0~1 的 rgb、再加上一组“块内相对坐标”。
# provider.py:把一个 block 的点整理成网络输入 import numpy as np def build_input(xyz, rgb, block_min, block_size): """xyz: (N,3) 世界坐标 rgb: (N,3) 0~255 block_min/block_size: 当前块的包围盒""" # 1) 球面归一化:减去质心、除以最大半径,保证输入与场景尺度无关 centroid = np.mean(xyz, axis=0, keepdims=True) local = xyz - centroid radius = np.max(np.linalg.norm(local, axis=1)) xyz_norm = local / max(radius, 1e-8) # 2) rgb 归一化,不做这步是新手最常见的翻车点 rgb_norm = rgb.astype(np.float32) / 255.0 # 3) 块内归一化坐标,把点压到 [0,1]^3,让网络知道点在块里的相对位置 xyz_block = (xyz - block_min) / max(block_size, 1e-8) return np.concatenate([xyz_norm, rgb_norm, xyz_block], axis=1).astype(np.float32)逻辑上分三段。第一段球面归一化解决的是“同一类物体在不同距离上坐标差异巨大”的问题,不减质心的话,一个远处的小车和一个近处的小车在数值上毫无相似性;第二段把颜色压到 0~1,让颜色和坐标处于同一量级;第三段块内坐标是 PointNet 原论文在 S3DIS 上的做法,保留绝对位置信息,对“地面 vs 建筑”这种靠高度区分的类别很有用。
参数上唯一要动的是block_size。VKITTI3D 单帧点云覆盖范围大概几十米,块边长取 8~12 米比较合适;块太大,一个块里混进十几个类别,网络学不动;块太小,地面点占比过高,类别更不平衡。我一般在 10 米左右起步,看一眼每块的类别分布再微调。
2.2 三个积木:T-Net、共享 MLP、对称函数
整个编码器就是三件事的组合,代码里对应TransformNet、一串Conv1d(kernel_size=1)和一次max。
# model.py import torch import torch.nn as nn import torch.nn.functional as F class TransformNet(nn.Module): """输入/特征对齐网络,k=3 时学 3x3 旋转矩阵,k=64 时学 64x64 特征变换""" def __init__(self, k=64): super().__init__() self.k = k self.conv = nn.Sequential( nn.Conv1d(k, 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.fc = nn.Sequential( nn.Linear(1024, 512), nn.BatchNorm1d(512), nn.ReLU(), nn.Linear(512, 256), nn.BatchNorm1d(256), nn.ReLU(), ) self.out = nn.Linear(256, k * k) # 初始化为单位矩阵,训练初期等价于不做变换,收敛更稳 nn.init.zeros_(self.out.weight) nn.init.eye_(self.out.bias.view(k, k)) def forward(self, x): n = x.size(0) feat = self.conv(x).max(dim=2)[0] # (B, 1024) 对称函数压掉点数维度 feat = self.fc(feat) mat = self.out(feat).view(n, self.k, self.k) return mat class PointNetSeg(nn.Module): def __init__(self, num_classes=13, in_ch=9): super().__init__() self.tnet_in = TransformNet(k=3) # 只作用于前 3 维 xyz self.tnet_feat = TransformNet(k=64) # 作用于第一层输出特征 self.mlp1 = nn.Sequential( nn.Conv1d(in_ch, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), ) self.mlp2 = nn.Sequential( 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), )TransformNet里那个eye_初始化是我踩过坑之后加的:默认随机初始化时,训练前几百步网络要同时学“怎么对齐”和“怎么分类”,很容易卡在一个退化的变换矩阵上,loss 在 2.5 附近横盘。初始化成单位阵,相当于让网络自己决定要不要偏离恒等变换。
mlp1/mlp2用的是Conv1d(kernel_size=1),不是全连接。这不是炫技,而是为了把“逐点共享”这件事用卷积的权重共享天然实现——每个点独立过同一套参数,点与点之间在编码阶段没有任何信息交换。真正的信息聚合发生在max(dim=2):不管你按什么顺序喂点,最大值不变,这就是 PointNet 能处理无序点集的根本原因。
tnet_feat的 64×64 矩阵是训练不稳定的一大来源,必须配合后面的正交正则项,否则它会自己长歪。
2.3 分割头:把 1024 维全局特征摊回每个点
分类网络到 1024 维就结束了,分割要到每个点,所以得把全局特征复制 N 份拼回去。
def forward(self, x): # x: (B, 9, N) trans_in = self.tnet_in(x[:, :3, :]) xyz = torch.bmm(trans_in, x[:, :3, :]) # 只对齐 xyz x = torch.cat([xyz, x[:, 3:, :]], dim=1) f64 = self.mlp1(x) # (B, 64, N) trans_feat = self.tnet_feat(f64) f64 = torch.bmm(trans_feat, f64.transpose(1, 2)).transpose(1, 2) f64 = F.relu(f64) f1024 = self.mlp2(f64) # (B, 1024, N) g = f1024.max(dim=2, keepdim=True)[0] # (B, 1024, 1) 全局特征 g = g.repeat(1, 1, f64.size(2)) # 摊回 N 个点 feat = torch.cat([f64, g], dim=1) # (B, 64+1024, N) return self.head(feat), trans_feat # (B, C, N), 供正则项使用torch.cat([f64, g])这一步是 PointNet 分割版的全部秘密:左边 64 维是“这个点自己长什么样”,右边 1024 维是“整个场景大概是什么样”。前者决定细节,后者决定上下文。比如一堵墙上的窗户点,局部特征是平的,靠全局特征里“这块整体是个建筑立面”才分得对。
返回trans_feat是必须的,训练时要拿它算正则项,漏了这一步网络照样能跑,但精度会悄悄掉几个点,属于那种很难定位的玄学问题。
head最后一层不加 ReLU、不加 BatchNorm,输出原始的 logits,交给CrossEntropyLoss处理。
2.4 损失函数与标签:ignore_index 和类别权重
13 类里,地面和建筑往往占了一半以上的点,行人和标志牌可能不到千分之五。直接上普通交叉熵,模型最省力的策略就是“除了地面全不学”,准确率还挺好看。
# 训练时常用的两个配置 import torch.nn as nn # 交叉熵:忽略无效点,同时给稀有类加权 criterion = nn.CrossEntropyLoss( weight=torch.tensor([1.0, 1.0, 2.0, 0.5, 1.5, 0.3, 0.5, 3.0, 5.0, 4.0, 5.0, 3.0, 2.0]), # 按类别频率反比调 ignore_index=-1, ) # 特征变换矩阵的正交约束,让 64x64 矩阵接近旋转矩阵 def feature_regularizer(trans): I = torch.eye(trans.size(1), device=trans.device).unsqueeze(0) A = torch.bmm(trans, trans.transpose(1, 2)) return torch.mean(torch.norm(A - I, dim=(1, 2)))权重的取法不用太精确,经验是:把训练集每类点数统计出来,取倒数再归一化到 0.3~5 之间,太大会让训练前期震荡。地面这类背景给小于 1 的权重,行人、标志牌、杆子这些细目标给 3~5,效果立竿见影。
正则项在总损失里的系数一般取1e-3,即loss = ce_loss + 0.001 * feature_regularizer(trans_feat)。这个系数很敏感:设成 0.01,特征变换矩阵被压得几乎是恒等变换,等于白加了一层;设成 1e-5,矩阵照样长歪。
3. 环境与数据落地:把 VKITTI3D 整成网络能吃的形状
网络跑不起来,八成不是模型的问题,是数据摆错了位置或者列顺序对不上。这一章从目录结构讲到三条可复制的命令,最后把 HTML 可视化页接上。
3.1 依赖版本与目录约定
依赖很薄,PyTorch + h5py + numpy 就够,不需要编译任何 CUDA 扩展——PointNet 全是 1×1 卷积和全连接,纯 PyTorch 实现足够快。
# 我常用的组合,CUDA 版本按自己显卡改 conda create -n pnseg python=3.9 -y conda activate pnseg pip install torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu118 pip install numpy h5py tqdm tensorboard目录我习惯按下面这样摆,改代码时少找半天路径:
pointnet-vkitti3d/ ├── data/ │ ├── raw/ # VKITTI3D 原始 txt,一帧一个文件 │ ├── train.h5 # 转换后的训练集 │ └── val.h5 ├── provider.py # 输入构造、块采样 ├── model.py # PointNetSeg + TransformNet ├── dataset.py # torch Dataset,读 h5 ├── train.py ├── eval.py ├── export_pred.py # 导出每点预测,给 HTML 页面用 ├── index.html # 浏览器可视化 └── checkpoints/raw/里一帧一个 txt,每行一个点,列顺序是x y z r g b label。这个顺序一定要亲自head一下确认,不同来源的转换脚本列序不一样,搞反了训练也不会报错,只是 mIoU 一直上不去。
3.2 数据转换:png 深度 + 语义图 → 逐点样本
VKITTI3D 拿到手可能是 txt,也可能是原始深度图 + 语义标注图,需要自己反投影。反投影本身不难,难在坐标对齐。
# tools/build_h5.py:txt -> h5,顺手做一轮清洗 import h5py import numpy as np from pathlib import Path CLASSES = ['Misc', 'Building', 'Car', 'Road', 'Vegetation', 'Sky', 'Ground', 'Window', 'Pedestrian', 'Pole', 'Sign', 'Fence', 'Trunk'] def load_frame(path): raw = np.loadtxt(path, dtype=np.float32) xyz, rgb, lab = raw[:, 0:3], raw[:, 3:6], raw[:, 6].astype(np.int64) # 有效标签 0~12,其余(含 255/-1)统一成 ignore_index=-1 lab[(lab < 0) | (lab >= len(CLASSES))] = -1 # 去掉原点附近的无效点(深度图反投影常见产物) valid = np.linalg.norm(xyz, axis=1) > 1e-3 return xyz[valid], rgb[valid], lab[valid] def build(split_dir, out_path): files = sorted(Path(split_dir).glob('*.txt')) with h5py.File(out_path, 'w') as f: for i, p in enumerate(files): xyz, rgb, lab = load_frame(p) g = f.create_group(f'frame_{i:05d}') g.create_dataset('xyz', data=xyz, compression='gzip') g.create_dataset('rgb', data=rgb, compression='gzip') g.create_dataset('label', data=lab, compression='gzip') g.attrs['source'] = p.name print(f'{out_path}: {len(files)} frames')三个参数要留意。lab >= 13的过滤不要省,有些转换脚本会把天空之外的无效区域写成 255;norm > 1e-3的过滤是剔掉深度图反投影在相机原点堆出来的噪声点,不剔的话每帧都有几百个点在原点附近,模型会莫名其妙学会“原点附近是 Misc”;compression='gzip'在点云上能压掉 60% 左右体积,读的时候稍微慢一点,但省磁盘。
存成 h5 而不是每次读 txt,是因为 txt 解析是训练里最容易被忽略的性能黑洞——np.loadtxt读一帧几万点的文件要几十毫秒,配合多进程 dataloader 反而把 CPU 打满在解析上。
3.3 训练、评估、导出推理结果的三条命令
# 训练:4096 点一个 block,batch 16,先跑 50 个 epoch 看趋势 python train.py \ --data data/train.h5 --val data/val.h5 \ --num_point 4096 --batch_size 16 --epoch 50 \ --lr 0.001 --num_classes 13 --gpu 0 # 评估:加载 best 权重,输出 per-class IoU python eval.py --data data/val.h5 --ckpt checkpoints/best.pth --num_point 8192 # 导出:把每点的预测类别写回 txt,供 HTML 页面读 python export_pred.py --data data/val.h5 --ckpt checkpoints/best.pth \ --out viz/frame_0001.txtnum_point训练用 4096、评估用 8192 是有意为之。训练时点少一点,显存吃得下、batch 能开大,梯度噪声反而有正则效果;评估时点多一点,覆盖更完整,mIoU 更接近真实水平。如果显存够,训练也可以直接上 8192,但要注意学习率相应调小一点,块内点数变多后梯度幅值会变大。
export_pred.py的输出格式我固定成x y z pred_label gt_label,六列,不带表头,HTML 页面解析起来最省事。
3.4 HTML 可视化页:在浏览器里转着看分割结果
点云这种东西,光看 mIoU 数字是没有体感的。写一个单文件 HTML,用 Three.js 把预测结果按类别上色,鼠标拖拽旋转,哪个类别分错了、错在哪些区域,一眼就能看出来。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>VKITTI3D 分割结果</title> <style> html,body{margin:0;height:100%;overflow:hidden;background:#12141a} #hud{position:fixed;left:14px;top:14px;color:#d5d8e0;font:12px/1.7 monospace;z-index:9} </style> </head> <body> <div id="hud">把 export_pred.py 生成的 txt 拖进来</div> <script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } } </script> <script type="module"> import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; // 13 类的调色板,和 CLASSES 顺序一一对应,改类别一定要同步改这里 const PALETTE = [ 0x808080, 0xa0522d, 0xff3030, 0x505050, 0x2e8b57, 0x87ceeb, 0x3a3a3a, 0x1e90ff, 0xffd700, 0xc0c0c0, 0xff8c00, 0x9acd32, 0x8b4513 ].map(h => new THREE.Color(h)); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 5000); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); document.body.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; scene.add(new THREE.AmbientLight(0xffffff, 1.2)); let cloud = null; function renderPoints(text) { const lines = text.trim().split('\n'); const pos = new Float32Array(lines.length * 3); const col = new Float32Array(lines.length * 3); lines.forEach((line, i) => { const v = line.trim().split(/\s+/).map(Number); // 列顺序:x y z pred gt,用 pred 上色,想对比就改成 v[4] pos[i * 3] = v[0]; pos[i * 3 + 1] = v[1]; pos[i * 3 + 2] = v[2]; const c = PALETTE[v[3]] || PALETTE[0]; col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b; }); const geo = new THREE.BufferGeometry(); // BufferAttribute 必须吃 Float32Array,普通数组会渲染成空白 geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); geo.computeBoundingSphere(); if (cloud) { scene.remove(cloud); cloud.geometry.dispose(); } cloud = new THREE.Points(geo, new THREE.PointsMaterial({ size: 0.08, vertexColors: true, sizeAttenuation: true })); scene.add(cloud); // 自动把相机摆到能看全点云的位置 const box = new THREE.Box3().setFromObject(cloud); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()).length(); controls.target.copy(center); camera.position.copy(center).add(new THREE.Vector3(size * 0.6, size * 0.5, size * 0.6)); camera.lookAt(center); } document.addEventListener('dragover', e => e.preventDefault()); document.addEventListener('drop', async e => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) renderPoints(await file.text()); }); (function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); })(); </script> </body> </html>逻辑上分三块:PALETTE定义 13 类颜色,顺序必须和训练时的CLASSES完全一致,差一位整个画面就解释不通了;renderPoints负责解析文本、构造BufferGeometry,用pred列上色,想切到真值对比就把v[3]换成v[4];最后一段把相机自动拉到包围球外侧,省得每次手动找。
参数上PointsMaterial.size是唯一要调的,场景大就调到 0.15 左右,小场景 0.05 就够,太大整片糊成色块,看不出结构。
提示:直接双击打开 HTML 会因为
file://协议下 importmap 加载模块受限而白屏,起一个静态服务再访问,python -m http.server 8000就行。
4. 核心代码逐段读:dataset、train、eval 三个文件里的关键逻辑
网络结构定了之后,结果好坏基本由这三段代码决定:怎么切块、怎么算损失、怎么算指标。这三处任何一处写歪,训练日志都会骗你。
4.1 Dataset.getitem:块采样决定了一半的效果
# dataset.py import h5py import numpy as np import torch from torch.utils.data import Dataset from provider import build_input class VKITTISeg(Dataset): def __init__(self, h5_path, num_point=4096, block_size=10.0, training=True): self.f = h5py.File(h5_path, 'r') # 懒加载,不把整个数据集读进内存 self.keys = list(self.f.keys()) self.num_point = num_point self.block_size = block_size self.training = training def __len__(self): return len(self.keys) def __getitem__(self, idx): g = self.f[self.keys[idx]] xyz, rgb, lab = g['xyz'][:], g['rgb'][:], g['label'][:] # 1) 随机选一个种子点,以它为中心切块,保证块内不会全是空白 seed = np.random.randint(len(xyz)) block_min = xyz[seed] - self.block_size / 2 inside = np.all((xyz >= block_min) & (xyz < block_min + self.block_size), axis=1) # 2) 点数不够就重复采样,训练时是随机的,评估时按顺序补齐保证可复现 cur = np.where(inside)[0] if len(cur) < self.num_point: need = self.num_point - len(cur) extra = np.random.choice(cur, need) if self.training else \ np.resize(cur, self.num_point) cur = np.concatenate([cur, extra]) else: cur = np.random.choice(cur, self.num_point, replace=False) if self.training \ else cur[:self.num_point] data = build_input(xyz[cur], rgb[cur], block_min, self.block_size) return torch.from_numpy(data).t(), torch.from_numpy(lab[cur]).long()三个参数值得展开。block_size我前面说 10 米起步,实际还得看类别——如果目标检测类(行人、标志牌)占比太低,就把块缩到 6 米,让同类点更集中;如果想让网络多看上下文,放到 15 米也行,代价是小目标被稀释。
seed选点用的是全场景随机,不是均匀网格。均匀网格切出来的块分布太规整,模型会记住“每个块的左下角一定有什么”,泛化反而差。
replace=False在训练时是必须的。用replace=True随机采样会让同一个点重复出现,等价于给这些点加权,模型会过度拟合局部结构。评估时用np.resize顺序补齐,是为了让同一帧每次评估结果完全一致,不然 mIoU 每次跑都在飘,没法判断改动有没有用。
4.2 训练循环:前向、正则项、反向
# train.py 主循环节选 import torch from model import PointNetSeg model = PointNetSeg(num_classes=13, in_ch=9).cuda() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, betas=(0.9, 0.999), weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.5) for epoch in range(args.epoch): model.train() for data, label in train_loader: data, label = data.cuda(), label.cuda() optimizer.zero_grad() pred, trans_feat = model(data) # (B, 13, N) ce = criterion(pred, label) # label 里 -1 会被忽略 reg = feature_regularizer(trans_feat) # 64x64 正交约束 loss = ce + 0.001 * reg loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 点云梯度容易炸 optimizer.step() scheduler.step()weight_decay=1e-4对应的是论文里的 decay 设置,主要作用在 Adam 上防止权重无界增长,配合 BatchNorm 一般不会欠拟合。
clip_grad_norm_(1.0)是我吃过亏之后固定加上的。点云里偶尔会出现一个块全是同一类点,loss 突然变大,梯度跟着炸,不加裁剪的话训练到第 30 个 epoch 左右会看到 loss 直接变nan,前面几十轮白跑。
0.001 * reg这个系数别乱动,前面说过。想确认正则项有没有生效,把reg的数值打到 tensorboard 上,正常情况它会从 40 左右(64×64 矩阵偏离单位阵的程度)缓慢降到 5~10 并稳定住;如果一直贴在 60 以上不降,说明系数太小或者trans_feat传错了(比如传成了tnet_in的输出)。
4.3 评估:per-class IoU 和 mIoU 的正确算法
# eval.py import numpy as np def compute_iou(pred, gt, num_classes=13, ignore_index=-1): """pred/gt: (N,) 的整型数组,返回 mIoU 和每类 IoU""" valid = gt != ignore_index pred, gt = pred[valid], gt[valid] iou = np.full(num_classes, np.nan) for c in range(num_classes): inter = np.sum((pred == c) & (gt == c)) union = np.sum((pred == c) | (gt == c)) if union > 0: iou[c] = inter / union return np.nanmean(iou), iouvalid = gt != ignore_index这一行必须在循环之前,否则被忽略的点会同时进入分子分母,把每类 IoU 都往下拉。union == 0的类返回nan而不是 0,是因为验证集某一帧里可能确实没有“标志牌”,强行算成 0 会让 mIoU 大幅波动,用nanmean忽略掉更合理。
这里有个容易搞混的点:nanmean只在类级别做平均,不是点级别。有些实现直接对所有点算总体 IoU,那个数字会明显高于 mIoU,看起来漂亮但不反映小目标的真实表现。看结果一定要同时看 per-class 那 13 个数,哪个类是 0.1 一目了然。
4.4 断点与导出
# 保存时把类别名、num_point、block_size 一起存,避免加载时对不上 torch.save({ 'state_dict': model.state_dict(), 'num_classes': 13, 'num_point': args.num_point, 'block_size': args.block_size, 'miou': best_miou, }, 'checkpoints/best.pth')这三个元信息看起来可有可无,实际非常有用。换个num_point评估时网络本身能跑(点数是动态的),但如果block_size变了,输入的第三段块内坐标分布整体偏移,精度会莫名其妙掉一截。存下来,加载时打一条日志对比,能省掉半天排查。
5. 避坑与排查:mIoU 卡在 0.3 的五个真实原因
下面这几条都是我在点云分割上反复撞过的墙,按“现象 → 原因 → 解决”写,遇到了直接对号入座。
5.1 loss 一路降,mIoU 死活不涨
现象:训练 loss 从 3.2 平滑降到 0.25,验证集准确率 0.88,但 mIoU 只有 0.3 出头。
原因:类别极度不平衡。VKITTI3D 里地面、建筑、天空三类能占七成以上,模型学会了全预测成这几类,交叉熵自然很低;但Car、Pedestrian、Sign的 IoU 全是个位数,nanmean一平均就露馅了。
解决:先打 per-class IoU 看清楚是哪几类拖后腿,然后上加权交叉熵,权重按类别频率反比取,稀有类给到 3~5。如果加权之后训练变得不稳,改用“先普通交叉熵训 10 个 epoch,再加权训剩下 40 个”,比一上来就加权收敛得更顺。
5.2 训练集 IoU 0.7,验证集接近 0
现象:训练集上看着一切正常,切到验证集 mIoU 掉到 0.02~0.05,几乎等于随机猜。
原因:九成是标签映射错位。训练集和验证集如果来自两次不同的转换,label列的类别 id 可能对不上——比如一边把Misc放在 0,另一边把 0 当成了背景并过滤掉,剩下的整体左移一位。网络学的是“2 号是 Car”,验证集里 2 号是 Road,全错。
解决:写个几行的小脚本,统计两个 h5 里每个类别的点数占比,横着对比一遍。占比结构应该大体一致,如果某一类在训练集有几十万点、验证集是 0,基本可以确定 id 映射错了。修完重新生成 h5,别在训练脚本里打补丁。
5.3 训练到一半 loss 变成 nan
现象:前 20 多个 epoch 正常,突然某个 batch 之后 loss 显示nan,之后再也恢复不了。
原因:梯度爆炸。点云块里如果恰好全是一类点(比如一整块地面),loss 会瞬间很小,反向传出来的梯度也很小;反过来遇到一个块里混了十几种稀有类,loss 突然飙高,T-Net 的 64×64 矩阵那层最容易炸。
解决:clip_grad_norm_(model.parameters(), 1.0)加上,学习率降到 5e-4 再重跑。另外把tnet_feat的输出矩阵在每次前向后打一下torch.det()的均值,正常应该在 1 附近,如果某几步突然到 10 以上,说明正则项系数太小,往上调到 0.005 试试。
5.4 训练时指标正常,单独推理差一大截
现象:eval.py里跑出来 mIoU 0.55,自己写脚本单帧推理,结果惨不忍睹。
原因:忘了model.eval()。BatchNorm 在训练模式下用当前 batch 的均值方差,单帧推理时 batch 里就一个样本,统计量完全失真。另一个常见原因是推理时的点云没做和训练一致的归一化,尤其是 rgb 忘了除 255。
解决:推理脚本开头固定model.eval()加torch.no_grad(),并且把provider.build_input抽成公共函数,训练和推理都调它,杜绝两份归一化逻辑漂移。
5.5 HTML 页面里点云全黑或者干脆不显示
现象:拖进 txt 之后画面一片黑,控制台没报错;或者点云显示成一个大白球。
原因:BufferAttribute传了普通 JS 数组而不是Float32Array,Three.js 不认;或者export_pred.py导出的列顺序和页面里解析的不一致,把gt当坐标读了,坐标全是 0~12 的小整数,点全挤在原点。
解决:导出和解析两头都固定成x y z pred gt五列无表头,页面里v[0]~v[2]是坐标、v[3]是预测;数组统一用new Float32Array(n * 3)构造。调试点云显示问题时,先console.log(lines[0])看一眼第一行长什么样,比盯着屏幕猜快得多。
6. 二次开发:换数据集、换骨干、接自己的可视化
这套代码的价值不在于跑通 VKITTI3D,而在于它是一副足够小的骨架,改起来不用先花两天读框架。
最常见的改造是换数据集。步骤固定:改CLASSES列表和num_classes,改build_h5.py里的列解析(你的数据可能是x y z i r g b label,多一列强度),重新统计类别频率调损失权重。这里最容易翻车的是CLASSES顺序,它同时被训练、评估、HTML 调色板三处引用,改一处不改另两处,出来的结果就是一团乱麻。我的做法是把CLASSES抽到一个config.py里,三处都从那里 import,杜绝手抄。
第二个方向是换骨干。mlp1+mlp2这一段可以整体替换成 PointNet++ 的 set abstraction 层,接口保持(B, C, N) -> (B, 1024, N)不变,其余代码一行不用动。想加注意力的话,在mlp2输出之后、max之前插一个通道注意力模块,成本很低。下面这张表是我改过几次之后总结的改动点对照:
| 改造目标 | 主要改动文件 | 风险点 |
|---|---|---|
| 换数据集 | config.py、build_h5.py | 类别顺序三处引用要同步 |
| 换骨干 | model.py 的 mlp1/mlp2 | 输出通道必须仍是 1024 |
| 加上下文模块 | model.py forward | 显存占用涨 30% 以上 |
| 多尺度块训练 | dataset.py、train.py | block_size 变了要重训 |
第三个方向是把可视化做实。现在这个 HTML 只能看预测,我通常会再加一个开关,把gt和pred并排放两个 canvas,同一视角同步旋转,错分的点会立刻跳出来——比看混淆矩阵直观得多。再加一层“只显示错分点”的过滤,pred != gt的点染成红色,剩下的灰掉,一帧里错在哪个区域清清楚楚。
验证改动有没有效,我有一个用了很久的习惯:先拿 8 个样本做单 batch 过拟合。把这 8 帧反复训 200 步,看能不能把 loss 压到接近 0、训练集 mIoU 打到 0.95 以上。如果连 8 个样本都过拟合不了,那一定是数据管线、标签映射或者损失函数写错了,跟模型结构和超参一点关系都没有。这一步花十分钟,能挡掉后面几天的瞎调参。
从那以后我每次动数据管线,都强制先走一遍这个单 batch 过拟合,跑通了再开完整训练。希望帮到你。
本文还有配套的精品资源,点击获取