
简介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, axis0, keepdimsTrue) local xyz - centroid radius np.max(np.linalg.norm(local, axis1)) 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], axis1).astype(np.float32)逻辑上分三段。第一段球面归一化解决的是“同一类物体在不同距离上坐标差异巨大”的问题不减质心的话一个远处的小车和一个近处的小车在数值上毫无相似性第二段把颜色压到 0~1让颜色和坐标处于同一量级第三段块内坐标是 PointNet 原论文在 S3DIS 上的做法保留绝对位置信息对“地面 vs 建筑”这种靠高度区分的类别很有用。参数上唯一要动的是block_size。VKITTI3D 单帧点云覆盖范围大概几十米块边长取 8~12 米比较合适块太大一个块里混进十几个类别网络学不动块太小地面点占比过高类别更不平衡。我一般在 10 米左右起步看一眼每块的类别分布再微调。2.2 三个积木T-Net、共享 MLP、对称函数整个编码器就是三件事的组合代码里对应TransformNet、一串Conv1d(kernel_size1)和一次max。# model.py import torch import torch.nn as nn import torch.nn.functional as F class TransformNet(nn.Module): 输入/特征对齐网络k3 时学 3x3 旋转矩阵k64 时学 64x64 特征变换 def __init__(self, k64): 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(dim2)[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_classes13, in_ch9): super().__init__() self.tnet_in TransformNet(k3) # 只作用于前 3 维 xyz self.tnet_feat TransformNet(k64) # 作用于第一层输出特征 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_size1)不是全连接。这不是炫技而是为了把“逐点共享”这件事用卷积的权重共享天然实现——每个点独立过同一套参数点与点之间在编码阶段没有任何信息交换。真正的信息聚合发生在max(dim2)不管你按什么顺序喂点最大值不变这就是 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:, :]], dim1) 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(dim2, keepdimTrue)[0] # (B, 1024, 1) 全局特征 g g.repeat(1, 1, f64.size(2)) # 摊回 N 个点 feat torch.cat([f64, g], dim1) # (B, 641024, 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( weighttorch.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), devicetrans.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 python3.9 -y conda activate pnseg pip install torch2.1.0 torchvision0.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.pytxt - 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, dtypenp.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, axis1) 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(fframe_{i:05d}) g.create_dataset(xyz, dataxyz, compressiongzip) g.create_dataset(rgb, datargb, compressiongzip) g.create_dataset(label, datalab, compressiongzip) g.attrs[source] p.name print(f{out_path}: {len(files)} frames)三个参数要留意。lab 13的过滤不要省有些转换脚本会把天空之外的无效区域写成 255norm 1e-3的过滤是剔掉深度图反投影在相机原点堆出来的噪声点不剔的话每帧都有几百个点在原点附近模型会莫名其妙学会“原点附近是 Misc”compressiongzip在点云上能压掉 60% 左右体积读的时候稍微慢一点但省磁盘。存成 h5 而不是每次读 txt是因为 txt 解析是训练里最容易被忽略的性能黑洞——np.loadtxt读一帧几万点的文件要几十毫秒配合多进程 dataloader 反而把 CPU 打满在解析上。3.3 训练、评估、导出推理结果的三条命令# 训练4096 点一个 blockbatch 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 langzh-CN head meta charsetutf-8 titleVKITTI3D 分割结果/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 idhud把 export_pred.py 生成的 txt 拖进来/div script typeimportmap { imports: { three: https://unpkg.com/three0.160.0/build/three.module.js, three/addons/: https://unpkg.com/three0.160.0/examples/jsm/ } } /script script typemodule 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_point4096, block_size10.0, trainingTrue): 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), axis1) # 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, replaceFalse) 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选点用的是全场景随机不是均匀网格。均匀网格切出来的块分布太规整模型会记住“每个块的左下角一定有什么”泛化反而差。replaceFalse在训练时是必须的。用replaceTrue随机采样会让同一个点重复出现等价于给这些点加权模型会过度拟合局部结构。评估时用np.resize顺序补齐是为了让同一帧每次评估结果完全一致不然 mIoU 每次跑都在飘没法判断改动有没有用。4.2 训练循环前向、正则项、反向# train.py 主循环节选 import torch from model import PointNetSeg model PointNetSeg(num_classes13, in_ch9).cuda() optimizer torch.optim.Adam(model.parameters(), lr1e-3, betas(0.9, 0.999), weight_decay1e-4) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.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_decay1e-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_classes13, 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 数组而不是Float32ArrayThree.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杜绝手抄。第二个方向是换骨干。mlp1mlp2这一段可以整体替换成 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.pyblock_size 变了要重训第三个方向是把可视化做实。现在这个 HTML 只能看预测我通常会再加一个开关把gt和pred并排放两个 canvas同一视角同步旋转错分的点会立刻跳出来——比看混淆矩阵直观得多。再加一层“只显示错分点”的过滤pred ! gt的点染成红色剩下的灰掉一帧里错在哪个区域清清楚楚。验证改动有没有效我有一个用了很久的习惯先拿 8 个样本做单 batch 过拟合。把这 8 帧反复训 200 步看能不能把 loss 压到接近 0、训练集 mIoU 打到 0.95 以上。如果连 8 个样本都过拟合不了那一定是数据管线、标签映射或者损失函数写错了跟模型结构和超参一点关系都没有。这一步花十分钟能挡掉后面几天的瞎调参。从那以后我每次动数据管线都强制先走一遍这个单 batch 过拟合跑通了再开完整训练。希望帮到你。本文还有配套的精品资源点击获取