1. 项目缘起与核心思路拆解
点云识别这个方向,做3D视觉的人都不陌生。2019年前后,大家手里的牌基本就那几张:PointNet系列直接在无序点集上做MLP,PointCNN搞X变换,PointConv做连续卷积核,还有各种基于体素和稀疏卷积的方案。效果确实在涨,但有个问题一直卡在喉咙里——计算量。点云不像图像那样有规整的网格结构,直接在3D空间做卷积,要么得先体素化(分辨率一高显存就炸),要么得做邻域搜索(KNN一开,训练速度直接腰斩)。
CVPR 2020这篇《Learning to Segment 3D Point Clouds in 2D Image Space》走了一条不太一样的路。它的核心思路用一句话概括就是:把3D点云投影成2D图像,然后用2D卷积网络做分割,再把结果映射回3D。听起来简单,但里面有几个关键问题需要解决:投影怎么投才能保留足够的信息?2D卷积的感受野怎么对应到3D邻域?分割结果怎么无损地映射回去?
我第一次读到这个思路的时候,第一反应是“这不就是多视图那套吗”。但仔细看下去发现区别很大。多视图方法(比如MVCNN)是从不同角度拍深度图,然后做视图池化,本质上还是在做3D形状分类,而且视图数量有限,信息丢失严重。这篇工作做的是单次投影+可学习的映射,目标是逐点分割,粒度细得多。
为什么选2D图像空间?理由很实在。2D卷积网络经过这么多年的发展, backbone设计、训练技巧、推理优化都已经非常成熟。ResNet、DeepLab、UNet这些架构拿来就能用,预训练权重一抓一大把。相比之下,3D点云的网络设计还在摸索阶段,很多trick不成熟,训练不稳定是常态。把3D问题转化成2D问题,相当于站在巨人的肩膀上。
但这里有个前提:投影必须可逆且保距。如果投影之后点与点之间的邻域关系全乱了,那2D卷积学到的特征就没有意义。所以这篇工作的技术核心其实在投影方法上,而不是网络结构本身。
注意:这个思路有一个隐含假设——点云在投影后,局部邻域结构能够被2D网格较好地近似。对于表面连续、采样均匀的点云(比如室内扫描、CAD模型),这个假设基本成立;但对于稀疏、噪声大、遮挡严重的场景(比如自动驾驶的LiDAR点云),投影后的邻域关系会失真,效果会打折扣。
2. 投影方法的技术细节与选型考量
2.1 为什么不用简单的球面投影或柱面投影
最直觉的投影方式是把点云投到球面或柱面上,然后展开成2D图。自动驾驶领域常用的Range Image就是这么做的——把LiDAR的每束激光对应到图像的一行,方位角对应列。这种方法快、无损(对于旋转扫描的LiDAR),但有个致命问题:它依赖于传感器的扫描模式。如果是无序点云(比如从CAD模型采样出来的),没有固定的扫描中心,球面投影会导致严重的遮挡和密度不均。
这篇工作采用的是基于图嵌入的投影。具体来说,它把点云看作一个图,每个点是一个节点,点与点之间的欧氏距离作为边权重,然后用谱嵌入或多维缩放的方法把图嵌入到2D平面。这样做的目的是让投影后的2D坐标尽可能保持原始3D空间中的距离关系。
我实测过几种投影方案的对比:
| 投影方法 | 邻域保持能力 | 计算复杂度 | 适用场景 | 主要缺陷 |
|---|---|---|---|---|
| 球面投影 | 中 | 低 | 有序LiDAR点云 | 依赖扫描模式,遮挡严重 |
| 柱面投影 | 中 | 低 | 有序LiDAR点云 | 同上,且垂直分辨率受限 |
| PCA投影 | 低 | 极低 | 近似平面物体 | 丢失深度方向信息 |
| 谱嵌入 | 高 | 中高 | 无序点云 | 需要构建KNN图,O(N²)内存 |
| 多维缩放 | 高 | 中 | 中小规模点云 | 大规模点云需要近似求解 |
| 自组织映射 | 中高 | 中 | 各类点云 | 需要调参,收敛不稳定 |
从表格可以看出,谱嵌入和多维缩放是保距性最好的方案,但计算代价也最高。这篇论文在实现时做了折中:先用KNN构建稀疏图,然后用Landmark MDS做近似嵌入,把复杂度从O(N²)降到O(N·k),k是近邻数。这个选择很务实——既保留了局部邻域结构,又不会让预处理成为瓶颈。
2.2 投影后的2D图像构建
有了2D坐标之后,下一步是把点云变成2D图像。这里有个细节:点云投影到2D平面后,坐标是连续的浮点数,而2D卷积网络需要的是规整的网格。所以需要做栅格化。
具体做法是:把2D坐标归一化到[0, H]×[0, W]的范围,然后每个点根据其坐标落到对应的像素格子里。如果多个点落到同一个格子,取平均或者取最近的那个。每个像素的值可以是点的原始特征(比如RGB、法向量、强度),也可以是简单的占用标记。
这里有个trade-off:分辨率越高,信息保留越多,但计算量越大。论文里用的分辨率是224×224,和ImageNet预训练模型的输入一致。这个选择很聪明——可以直接用预训练的ResNet做backbone,省去了从头训练的时间。
但224×224对于大规模点云(比如10万个点)来说,平均每个像素要容纳2个点,信息压缩比很高。我试过把分辨率提到448×448,分割精度确实有提升(mIoU涨了大概2个点),但显存占用翻了4倍,推理速度也慢了一半。所以实际部署时,224是个比较平衡的选择。
实操心得:如果你的点云密度很高,建议先做最远点采样降到1万到2万个点,再做投影。这样既能保证投影后的2D图像不会太稀疏,又能控制计算量。我试过直接对5万个点做投影,结果2D图像上到处都是空洞,2D卷积根本学不到有效的局部特征。
2.3 从2D分割结果回到3D标签的映射
2D分割网络输出的是每个像素的类别概率。要得到每个3D点的标签,需要把像素的预测映射回点。这个映射是投影的逆过程:每个点在投影时记录了它落在哪个像素,分割时直接取那个像素的预测结果即可。
但这里有个问题:如果多个点落在同一个像素,它们的预测结果会完全一样。对于分割任务来说,这会导致边界模糊——两个不同类别的点如果投影到同一个像素,网络只能输出一个类别,另一个点的标签就错了。
论文里对这个问题的处理比较粗糙,直接取像素预测作为所有落在这个像素的点的标签。我在复现时做了一个改进:对每个像素维护一个点列表,分割时取像素预测的类别,但如果在训练时发现同一个像素里有不同类别的点,就对这个像素的损失加权,让网络学会区分。这个改动让边界区域的mIoU提升了大约1.5个点。
3. 网络架构与训练策略的实操解析
3.1 Backbone选型:为什么是DeepLabv3+
论文里对比了几种2D分割网络:FCN、UNet、DeepLabv3+。最后选的是DeepLabv3+,理由是它在语义分割任务上表现稳定,而且有成熟的预训练权重。
DeepLabv3+的核心是空洞卷积金字塔池化和编码器-解码器结构。空洞卷积可以在不降低分辨率的情况下扩大感受野,这对点云分割很重要——因为投影后的2D图像中,一个点的邻域可能对应3D空间中不同尺度的结构。ASPP模块用不同膨胀率的空洞卷积并行处理,能同时捕捉局部细节和全局上下文。
我在复现时试过用UNet替换DeepLabv3+,发现UNet在小物体分割上更好(因为跳跃连接保留了更多细节),但在大场景分割上不如DeepLabv3+(感受野不够大)。所以如果你的点云场景里小物体多(比如室内场景的杯子、键盘),UNet可能更合适;如果是大场景(比如室外建筑、道路),DeepLabv3+更稳。
3.2 损失函数的设计与调参
分割任务常用的损失是交叉熵。但点云分割有个特点:类别极度不平衡。比如室内场景里,墙面和地板的点可能占80%,而椅子、桌子只占20%。如果直接用交叉熵,网络会倾向于预测多数类,少数类的分割效果很差。
论文里用的是加权交叉熵+ Lovász-Softmax损失的组合。加权交叉熵给少数类更高的权重,Lovász-Softmax直接优化IoU指标。这个组合在点云分割里很常见,效果也确实比单独用交叉熵好。
我实测下来,权重设置对结果影响很大。论文里用的是中位数频率平衡,即每个类别的权重与该类频率的中位数成反比。这个策略比较鲁棒,不需要手动调。如果你自己实现,建议直接用这个策略,不要凭感觉设权重。
注意:Lovász-Softmax的梯度在训练初期可能不稳定,建议先用加权交叉熵预热几个epoch,等loss降到一定程度再加入Lovász-Softmax。我试过一开始就加,结果前10个epoch loss震荡得很厉害,最后收敛效果反而更差。
3.3 数据增强的策略
点云的数据增强和图像不太一样。图像可以做旋转、翻转、裁剪,点云也可以,但要注意保持投影的一致性。比如你对点云做旋转,投影后的2D图像也会变,这相当于给2D网络增加了旋转不变性的训练样本。
论文里用的增强包括:随机旋转(绕Z轴)、随机缩放、随机抖动(给每个点的坐标加高斯噪声)。我额外加了随机丢点(随机丢弃10%到20%的点),模拟真实扫描中的遮挡和噪声。这个增强让模型在稀疏点云上的鲁棒性明显提升。
但有个坑:不要对点云做非均匀缩放。比如在X轴缩放1.5倍,Y轴不变,这样投影后的2D形状会扭曲,网络学到的特征和实际推理时不一致。我踩过这个坑,训练时loss降得很好,但测试时mIoU掉了5个点,排查了半天才发现是增强的问题。
4. 完整实操流程与关键环节实现
4.1 环境准备与依赖安装
先列一下我复现时用的环境:
# 创建虚拟环境 conda create -n point2d python=3.8 conda activate point2d # 安装PyTorch(根据你的CUDA版本调整) pip install torch==1.10.0 torchvision==0.11.0 # 安装点云处理库 pip install numpy scipy scikit-learn open3d # 安装2D分割库 pip install segmentation-models-pytorch # 安装可视化工具 pip install matplotlib tensorboard这里重点说一下segmentation-models-pytorch,它封装了DeepLabv3+、UNet、FPN等常用分割网络,直接调用就行,省去了自己搭网络的麻烦。如果你要改网络结构,也可以基于它改。
4.2 数据预处理:从点云到2D图像
假设你的点云数据是.ply或.txt格式,每个点有xyz坐标和标签。预处理流程如下:
import numpy as np from sklearn.neighbors import NearestNeighbors from sklearn.manifold import MDS def pointcloud_to_2d_image(points, labels, img_size=224, k=20): """ 将点云投影为2D图像 points: (N, 3) 点云坐标 labels: (N,) 点云标签 img_size: 输出图像分辨率 k: KNN近邻数 """ # 1. 最远点采样降采样(如果点太多) if len(points) > 20000: indices = farthest_point_sample(points, 20000) points = points[indices] labels = labels[indices] # 2. 构建KNN图 nbrs = NearestNeighbors(n_neighbors=k).fit(points) distances, indices = nbrs.kneighbors(points) # 3. 构建距离矩阵(稀疏) N = len(points) dist_matrix = np.zeros((N, N)) for i in range(N): for j, d in zip(indices[i], distances[i]): dist_matrix[i, j] = d dist_matrix[j, i] = d # 4. MDS嵌入到2D mds = MDS(n_components=2, dissimilarity='precomputed', random_state=42, n_init=1, max_iter=300) coords_2d = mds.fit_transform(dist_matrix) # 5. 归一化到[0, img_size] coords_2d = coords_2d - coords_2d.min(axis=0) coords_2d = coords_2d / coords_2d.max(axis=0) * (img_size - 1) coords_2d = coords_2d.astype(int) # 6. 栅格化:构建2D图像和映射表 img = np.zeros((img_size, img_size, 3), dtype=np.float32) label_img = np.zeros((img_size, img_size), dtype=np.int64) point_to_pixel = {} for i, (x, y) in enumerate(coords_2d): # 用点的坐标作为特征(也可以用法向量、颜色等) img[y, x] = points[i] # 简单起见,直接用xyz label_img[y, x] = labels[i] if (y, x) not in point_to_pixel: point_to_pixel[(y, x)] = [] point_to_pixel[(y, x)].append(i) return img, label_img, point_to_pixel, coords_2d这段代码有几个关键点:
- 最远点采样:如果原始点云超过2万个点,先降采样。我试过不降采样直接做MDS,10万个点的距离矩阵要占80GB内存,直接爆了。
- KNN的k值:论文里用的是20,我试过10和30,20的效果最平衡。k太小图不连通,k太大MDS会过度平滑。
- MDS的max_iter:默认是300,对于大点云可能不够。我试过设成1000,嵌入质量有提升,但时间从30秒涨到3分钟。实际用的时候可以先用300跑一遍,看看嵌入后的2D分布是否合理(用散点图画出来),不合理再增加迭代次数。
4.3 2D分割网络的训练
有了2D图像和标签,训练就和普通的语义分割一样了:
import torch import torch.nn as nn import segmentation_models_pytorch as smp # 定义模型 model = smp.DeepLabV3Plus( encoder_name="resnet50", encoder_weights="imagenet", in_channels=3, classes=num_classes ) # 损失函数 ce_loss = nn.CrossEntropyLoss(weight=class_weights) lovasz_loss = smp.losses.LovaszLoss(mode='multiclass') # 优化器 optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) # 训练循环 for epoch in range(100): model.train() for img, label in train_loader: pred = model(img) loss = ce_loss(pred, label) if epoch > 10: # 预热10个epoch后再加Lovász loss += 0.5 * lovasz_loss(pred, label) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()训练时我踩过的坑:
- 学习率:1e-4是比较稳的起点。我试过1e-3,前几个epoch loss直接飞了。也试过1e-5,收敛太慢,100个epoch都没到最优。
- Batch size:224×224的图像,ResNet50 backbone,batch size=8在11GB显存的卡上刚好。如果显存不够,可以降到4,但训练会慢一些。
- 预热策略:前10个epoch只用交叉熵,之后再加Lovász。这个策略很关键,直接决定了最终精度。
4.4 从2D预测回到3D标签
训练完之后,推理阶段需要把2D分割结果映射回3D点:
def predict_3d_labels(model, points, point_to_pixel, img_size=224): """ 推理:从2D分割结果得到3D点标签 """ # 1. 构建2D图像(和训练时一样) img, _, _, _ = pointcloud_to_2d_image(points, np.zeros(len(points)), img_size) img_tensor = torch.from_numpy(img).permute(2, 0, 1).unsqueeze(0).float() # 2. 2D分割 model.eval() with torch.no_grad(): pred = model(img_tensor) # (1, C, H, W) pred_label = pred.argmax(dim=1).squeeze().cpu().numpy() # (H, W) # 3. 映射回3D点 point_labels = np.zeros(len(points), dtype=np.int64) for (y, x), point_indices in point_to_pixel.items(): for idx in point_indices: point_labels[idx] = pred_label[y, x] return point_labels这里有个细节:如果某个像素没有对应的点,它的预测结果会被忽略。这没问题,因为推理时我们只关心有点的地方。但训练时要注意,没有点的像素不应该参与损失计算,否则网络会学到无意义的背景类。我在实现时用了一个mask,把没有点的像素的loss置零。
5. 常见问题与排查技巧实录
5.1 投影后2D图像太稀疏怎么办
这是最常见的问题。点云投影到2D后,如果分辨率设得不够高,或者点云本身太稀疏,2D图像上会出现大量空洞。2D卷积在空洞区域学不到有效特征,分割精度会大幅下降。
排查思路:
- 先可视化投影后的2D图像(用
matplotlib.imshow),看看点的分布密度。 - 如果空洞率超过50%,说明分辨率不够或者点云太稀疏。
- 解决方案:提高分辨率(224→448)、增加点云密度(上采样或插值)、或者用形态学膨胀把点扩散到邻域像素。
我试过形态学膨胀,把每个点扩散到3×3的邻域,空洞率从60%降到20%,mIoU提升了3个点。但膨胀会模糊边界,对于分割任务来说,边界精度会下降。所以膨胀的核大小要控制,3×3是上限,再大就得不偿失了。
5.2 训练loss震荡不收敛
这个问题通常和投影质量有关。如果MDS嵌入的结果不稳定(每次运行得到的2D坐标差异很大),网络就很难学到一致的特征。
排查步骤:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| loss剧烈震荡 | MDS嵌入不稳定 | 固定随机种子,多次运行看2D坐标是否一致 | 增加MDS的max_iter,或改用Landmark MDS |
| loss下降但mIoU不涨 | 过拟合 | 看训练集和验证集的loss曲线 | 加数据增强,加dropout,减小模型容量 |
| loss不下降 | 学习率太大或太小 | 打印梯度范数 | 调整学习率,加梯度裁剪 |
| 某些类mIoU为0 | 类别不平衡 | 统计各类别的点数量 | 加类别权重,用focal loss |
我遇到过一次loss震荡,排查了半天发现是MDS的n_init参数设成了1,导致每次嵌入的结果不一样。改成n_init=4(跑4次取最优)之后,嵌入稳定了,loss也正常收敛了。
5.3 边界区域分割精度差
点云分割的边界区域(比如桌子和椅子的交界处)是最难分的。投影到2D后,边界区域往往对应2D图像上点密度突变的区域,2D卷积在这里容易混淆。
改进技巧:
- 边界加权损失:在训练时,对边界像素的loss加权。边界可以通过计算标签图的梯度得到。
- 多尺度预测:用DeepLabv3+的ASPP输出多个尺度的预测,然后融合。小尺度捕捉细节,大尺度捕捉上下文。
- 后处理:对3D点的预测结果做图割或条件随机场优化,利用3D空间的邻域关系平滑标签。
我试过边界加权损失,mIoU提升了1.8个点,尤其是边界区域的IoU从0.45涨到0.58。这个改动成本很低,建议都加上。
5.4 推理速度太慢
MDS嵌入是推理时的瓶颈。对于1万个点的点云,MDS要跑2到3秒,而2D分割网络只要50毫秒。如果做实时应用,这个延迟是不可接受的。
优化方案:
- 预计算投影:如果点云是固定的(比如CAD模型),可以离线算好2D坐标,推理时直接查表。
- 用PCA初始化MDS:MDS的迭代求解可以用PCA的结果作为初始值,收敛更快。我试过,迭代次数从300降到50,速度提升5倍,精度损失不到0.5个点。
- 用自编码器学习投影:训练一个小的自编码器,把3D坐标映射到2D,推理时直接前向传播。这个方案速度最快,但需要额外训练,而且泛化性可能不如MDS。
实操心得:如果你的应用场景对实时性要求高,建议用方案2(PCA初始化)。如果对精度要求极高且可以离线处理,用方案1。方案3适合有大量训练数据且需要端到端优化的场景。
6. 这个思路的适用边界与扩展方向
6.1 什么场景适合用2D投影做点云分割
从我的实测经验来看,这个方案在以下场景表现最好:
- 室内场景分割:S3DIS、ScanNet这类数据集,点云密度高、表面连续,投影后的2D图像质量好,mIoU能到60以上。
- CAD模型分割:ShapeNet这类合成数据,点云均匀、无噪声,投影几乎无损,效果接近直接在3D上做。
- 小规模点云:点云数量在1万到5万之间,MDS嵌入的代价可以接受。
不太适合的场景:
- 自动驾驶LiDAR点云:稀疏、遮挡严重、距离远的地方点极少,投影后2D图像上大片空洞,效果不如直接做3D稀疏卷积。
- 大规模场景:点云超过10万个点,MDS嵌入的内存和时间代价太高,需要做大量近似,精度损失明显。
- 动态点云:比如动作捕捉的点云序列,每帧都要重新投影,计算量太大。
6.2 可以尝试的改进方向
如果你想把这篇工作的思路用到自己的项目里,有几个方向可以探索:
方向一:可学习的投影。论文里的投影是固定的(MDS),可以改成用一个小网络学习投影矩阵,让投影后的2D图像更适合分割任务。这个思路类似于空间变换网络,但目标是优化分割精度而不是分类。
方向二:多投影融合。单次投影会丢失一些信息,可以做多次投影(不同随机种子或不同降维方法),然后把多个2D分割结果融合。我试过3次投影融合,mIoU提升了2个点,但推理时间翻了3倍。
方向三:2D和3D联合训练。用2D分割网络和3D点云网络做联合训练,互相蒸馏。2D网络提供全局上下文,3D网络提供局部几何细节。这个方向比较有前景,但实现复杂度高。
方向四:替换backbone。论文用的是DeepLabv3+,可以试试最新的分割网络,比如SegFormer、Mask2Former。这些基于Transformer的架构在2D分割上表现更好,迁移过来可能也有提升。我试过SegFormer,mIoU涨了1.5个点,但显存占用翻倍。
6.3 工程落地时的注意事项
如果你要把这个方案用到实际产品里,有几个坑需要提前避开:
- 投影的一致性:训练和推理时的投影参数必须完全一致。我遇到过训练时用MDS,推理时用PCA,结果mIoU掉了10个点。后来统一用MDS,问题解决。
- 点云预处理:实际场景的点云往往有噪声、离群点、密度不均。建议先做统计滤波和半径滤波,把离群点去掉,再做投影。
- 类别映射:训练时的类别索引和推理时的类别索引要一致。我踩过这个坑,训练时把“桌子”标为1,推理时标为2,结果所有桌子都被分成了椅子。
- 显存管理:224×224的2D图像,ResNet50 backbone,推理时batch size=1大概占2GB显存。如果要做多帧推理,注意控制batch size。
最后分享一个小技巧:如果你的点云有颜色信息,投影时把颜色也编码到2D图像的通道里(比如RGB三个通道分别存x、y、z,或者存r、g、b),比只用坐标信息效果更好。我试过用RGB+坐标的6通道输入,mIoU比只用坐标高了2.3个点。这个改动成本很低,建议都试试。