简介:面向点云分割研究与应用的数据集压缩包,对应ShapeNetCore大规模三维形状库的子集,包含带法线信息的三维模型与部件级标注,适合研究人员和开发者训练、评测分割算法。压缩包为ZIP格式,大小约674.24MB,内含多种物体类别的点云坐标、法线方向与部位标签,可直接作为RS-CNN、PointNet、PointNet++、DGCNN等深度学习模型的输入数据,支持论文中的实验复现以及不同算法间的横向对比。已有2533人学习下载,是三维形状解析、部件分割与几何结构理解任务中被广泛使用的基准资源。使用该资源可免去自行采集与标注的繁琐流程,快速获得带监督信息的三维点云样本,配合常见评价指标开展完整的模型验证,也是推进自动驾驶、机器人导航与三维重建等应用落地的基础数据来源。
1. 初见这个压缩包:它凭什么成为 3D 分割的默认起点
shapenetcore_partanno_segmentation_benchmark_v0_normal.zip这个压缩包,是 ShapeNetCore 数据集的部件标注分割基准版本,附带了法线(normal)信息,在点云分割领域几乎是所有人的第一个训练集。很多入门 3D 深度学习的开发者第一次跑 PointNet 或 DGCNN,用的都是这份数据。它解决的问题很具体:给定一个物体的三维点云,让模型判断每个点属于哪个部件,比如一把椅子要分出头枕、椅背、椅座和椅腿。这个数据集的价值在于,它把「部件语义」和「几何形状」绑在了一起,成为了衡量一个模型到底有没有理解三维结构的标尺。这篇笔记我按实际使用的顺序讲清楚:压缩包里有什么、怎么解析成模型能吃的样本、训练评估怎么做、以及我踩过的那些坑。适合准备入坑点云分割、或者想换个数据集验证模型能力的人。
2. 拆开压缩包:目录结构、标注体系和那个 16 类别的部件基准
2.1 压缩包内部的组织方式
这个 zip 解压之后,不会看到一个统一的点云文件夹,而是按类别组织的 mesh 模型目录。常见做法是每个类别一个文件夹,文件夹名用 WordNet 的 synset 编号,下面再按具体实例分组。每个实例目录里放着原始 mesh 文件(.obj 格式居多),以及对应的部件标签文件。
我一般会先写一段脚本把目录结构打出来,确认文件组织方式再动手,避免后面写数据加载器时对着不存在的路径猜来猜去。下面这段代码可以快速生成一个清单:
unzip shapenetcore_partanno_segmentation_benchmark_v0_normal.zip -d ./shapenet_part cd ./shapenet_part find . -maxdepth 2 -type d | head -30 # 只看两层目录,了解类别与实例的组织 find . -name "*.obj" | wc -l # 统计 mesh 文件总量 find . -name "*.seg" -o -name "*.txt" | head -20 # 看看标签文件长什么样这段命令的逻辑是先解压,再分别统计 mesh 和标签文件的数量与分布。参数说明:-maxdepth 2限制目录深度,避免把每个实例内部的碎文件全部打出来;.obj是常见的 mesh 格式,如果你的压缩包版本里是.off或.ply,把扩展名替换即可。标签文件有的版本叫.seg,有的版本是每行一个标签的.txt,所以这里用-o做了并联查找。跑完这一步你就能知道数据集的实际规模,以及标注文件是不是每个实例都齐全,这在后面写训练脚本时非常关键。
2.2 taxonomy:那 16 个类别是怎么定下来的
整个 part segmentation benchmark 并不是用 ShapeNetCore 全部 55 个类别,而是选了一个 16 类子集。这 16 类分别是飞机、背包、帽子、汽车、椅子、耳机、吉他、刀具、台灯、笔记本电脑、摩托车、马克杯、手枪、火箭、滑板、桌子。选这 16 类的理由很直接——它们都有清晰的部件结构,比如飞机有机身、机翼、尾翼、发动机,椅子的部件边界也比较稳定,适合作为部件级语义分割的训练与评测集。
我用一个表格把这 16 类的 ID、中文名和典型部件列出来,方便后面核对数据。
| Synset 编号 | 类别 | 典型部件 |
|---|---|---|
| 02691156 | 飞机 | 机身/机翼/尾翼/发动机 |
| 02773838 | 背包 | 包体/背带 |
| 02954340 | 帽子 | 帽檐/帽身 |
| 02958343 | 汽车 | 车身/车轮/车窗 |
| 03001627 | 椅子 | 椅背/椅座/扶手/椅腿 |
| 03261776 | 耳机 | 头梁/耳罩 |
| 03467517 | 吉他 | 琴头/琴颈/琴身 |
| 03513137 | 刀具 | 刀刃/刀柄 |
| 03642806 | 台灯 | 灯罩/灯柱/底座 |
| 03691459 | 笔记本 | 屏幕/键盘/机身 |
| 03790512 | 摩托车 | 车轮/车把/车身 |
| 03948459 | 马克杯 | 杯身/杯柄 |
| 03991062 | 手枪 | 枪管/握把/扳机 |
| 04256520 | 火箭 | 箭体/尾翼 |
| 04379243 | 滑板 | 板面/轮子 |
| 04530566 | 桌子 | 桌面/桌腿 |
拿到数据后,第一件事不是直接训练,而是按这个表去核对每个类别的实例数量和标签范围。我遇到过某些类别实例特别少的情况,如果不提前知道,训练时这一类的 IoU 会明显偏低,你还会误以为是模型没调好。类别不平衡在 3D 部件分割里不是一个可以忽略的小事,后面我会专门谈。
2.3 train/test split 和它的加载方式
这个基准版本附带了一份官方的 train/test 划分,通常是一个 JSON 文件,按类别给出训练、验证、测试的实例 ID 列表。我拆包后必做的一步是确认这个划分文件存在,并且统计每个类别的三元组数量。划分比例大约是训练占七成、验证一成、测试两成,但每个类别会略有出入。
import json with open("train_test_split.json", "r") as f: split = json.load(f) for cat_id, record in split.items(): train_ids = record.get("train", []) val_ids = record.get("val", []) test_ids = record.get("test", []) print(f"{cat_id}: train={len(train_ids)} val={len(val_ids)} test={len(test_ids)}")这段代码的逻辑是读取 JSON 划分文件,按类别输出三个集合的大小。参数说明:split.items()遍历所有类别;record是每个类别对应的字典,键名在部分公开版本里可能是train/val/test,也可能是train/test两项,具体以你解压出来的文件为准——如果键不存在,代码会返回空列表,不会报错,所以统计结果里出现 0 时要回头去看实际键名。确认划分合理之后,再写数据加载器才有意义,否则训练集和测试集混在一起,指标再高也是自欺欺人。
3. 把 mesh 变成模型能吃的点云:解析、采样、法线与标签对齐
3.1 读取 mesh 与坐标归一化
压缩包里存的是 mesh 模型,但绝大多数点云分割模型吃的是无序点集,所以第一步是把 mesh 转成点云。我惯用的做法是读入顶点和三角面,然后做坐标归一化:先把模型平移到原点,再缩放到单位空间。这样不同尺度的物体在进入网络前有了统一的坐标范围,训练更稳定。
import numpy as np import trimesh mesh = trimesh.load("model.obj") verts = np.array(mesh.vertices) # 顶点坐标 (N, 3) faces = np.array(mesh.faces) # 三角面索引 (M, 3) # 中心化:每个维度减去均值 center = verts.mean(axis=0) verts = verts - center # 缩放:以最大包围盒边长为基准归一化到 [-1, 1] scale = np.abs(verts).max() verts = verts / scale这段代码的逻辑是先用 trimesh 读取 mesh,拿到顶点数组和面索引数组,然后做中心化和缩放。参数说明:verts.mean(axis=0)是按列求均值,得到三维中心点;np.abs(verts).max()是所有坐标绝对值里的最大值,用这个值做缩放可以保证模型完全落在单位立方体内,而不是只按某一轴缩放。这里我建议统一按最大边缩放,而不是按每个轴分别归一化,因为点云模型通常希望保持形状的长宽比,各轴独立归一化会把椅背和椅座的比例关系破坏掉。
3.2 面积加权采样与部件标签映射
mesh 转点云不能均匀地随机取顶点,因为三角面的面积差异很大,如果按顶点均匀采样,大面上稀疏、小面上过密,点云分布就不代表真实几何。正确做法是按三角面面积做加权采样:面积大的面分配到更多点。
import numpy as np def sample_points_on_mesh(verts, faces, part_labels, num_points=2048): tri_verts = verts[faces] # (M, 3, 3) 每个三角面的三个顶点坐标 # 用叉积求每个三角形的面积 v0 = tri_verts[:, 1] - tri_verts[:, 0] v1 = tri_verts[:, 2] - tri_verts[:, 0] areas = 0.5 * np.linalg.norm(np.cross(v0, v1), axis=1) probs = areas / areas.sum() # 归一化成采样概率 chosen = np.random.choice(len(faces), size=num_points, p=probs) sampled_verts = [] sampled_labels = [] for idx in chosen: verts_of_face = tri_verts[idx] # 在三角面内做重心坐标随机采样 r1, r2 = np.random.rand(2) u = 1 - np.sqrt(r1) v = r2 * np.sqrt(r1) w = 1 - u - v point = u * verts_of_face[0] + v * verts_of_face[1] + w * verts_of_face[2] sampled_verts.append(point) sampled_labels.append(part_labels[idx]) return np.array(sampled_verts), np.array(sampled_labels)这段代码的核心是两条链路:一条是面积加权采样,np.cross求两个边的叉积,其模长等于平行四边形面积,乘以 0.5 得到三角形面积;另一条是面内均匀采样,用两个均匀随机数生成重心坐标,公式u = 1 - sqrt(r1)能保证点在三角形内服从均匀分布,而不是偏向某个角落。参数num_points=2048是行业里最常用的采样数,PointNet 的原始实验就用这个数字;内存紧张可以降到 1024,但分割精度会略有下降。这里最关键的一点是part_labels[idx]——这个标签来自 mesh 的面标签,采样点落在哪个三角面上,就继承哪个面的部件标签,保证点与标签严格对齐。
3.3 法线计算:normal 是怎么来的
标题里带着normal,说明这个版本已经做好了法线信息,但实际使用中我发现两种情况:一种是压缩包里每个模型直接给好了法线文件,另一种是只有 mesh,需要自己算。即便有现成法线,也要检查方向是否一致。
# 如果 mesh 对象自带顶点法线,直接取用 if mesh.vertex_normals is not None: vertex_normals = np.array(mesh.vertex_normals) else: # 计算面法线,再按面索引映射到采样点 face_normals = np.array(mesh.face_normals) normals_per_point = face_normals[chosen] # chosen 是上一节采样选中的面索引 # 归一化法线长度,防止数值不稳定 normals = normals_per_point / np.linalg.norm(normals_per_point, axis=1, keepdims=True)法线的逻辑说明:顶点法线适合直接插值给采样点,但并不是每个版本都提供;面法线则需要按采样时选中的面索引去取。chosen数组来自上一节的面积加权采样,这一步要把采样逻辑和法线逻辑对齐,否则点坐标来自一个面、法线来自另一个面,模型训练出来就是个黑匣子,错误完全不可见。参数说明:keepdims=True是为了保持维度为 (N, 1),方便逐行归一化,不加这个参数的话归一化结果会变成一维数组,广播时容易出错。法线方向的问题我单独在第 5 章细说,这里先留个心。
4. 把预处理后的数据喂给分割模型:训练闭环与评估指标
4.1 数据加载器与超参设定
把上一步的采样逻辑封装成一个 Dataset 类,是训练前最值得花时间的一步。我通常会预计算所有样本的点云并缓存成 npy 文件,而不是在每次训练 epoch 里重复解析 mesh,否则光 IO 就能拖慢整个训练两倍以上。
import torch from torch.utils.data import Dataset class ShapeNetPartDataset(Dataset): def __init__(self, split_list, cat_ids, num_points=2048, use_normal=True): self.samples = [] for cat_id in cat_ids: for model_id in split_list[cat_id]: self.samples.append((cat_id, model_id)) self.num_points = num_points self.use_normal = use_normal def __len__(self): return len(self.samples) def __getitem__(self, idx): cat_id, model_id = self.samples[idx] # 这里假设预处理阶段已经把每个模型存成了 {id}.npy data = np.load(f"preprocessed/{cat_id}_{model_id}.npy", allow_pickle=True).item() points = data["points"] # (2048, 3) normals = data["normals"] # (2048, 3) labels = data["labels"] # (2048,) int if self.use_normal: feats = np.concatenate([points, normals], axis=1) # (2048, 6) else: feats = points return torch.from_numpy(feats).float(), torch.from_numpy(labels).long()这段代码的逻辑是把上一步预处理好的点云、法线和标签从 npy 文件读出来,拼装成网络输入。参数说明:num_points=2048要和预处理时的采样数一致;use_normal决定输入是 6 维(坐标加法线)还是 3 维(只有坐标),根据你的模型设计来定。allow_pickle=True是因为 npy 里存的是字典,如果只存数组可以去掉这个参数,sco 在旧版 NumPy 里不开这个选项会读不了字典类型。
缓存成 npy 的做法也方便做数据增强,可以在__getitem__里加随机旋转和抖动,这里不展开。
4.2 PointNet 形状的最小训练脚本
这个基准数据集最常见的搭配模型是 PointNet 及其变体。PointNet 的结构核心是对每个点做共享 MLP,再用最大池化提取全局特征,适合处理无序点云。下面是一个极简训练循环,重点不在模型结构,而是验证数据加载和 loss 计算是否跑得通。
import torch.nn as nn import torch.optim as optim class SimplePointNet(nn.Module): def __init__(self, num_classes): super().__init__() self.mlp = nn.Sequential( nn.Conv1d(6, 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.classifier = nn.Sequential( nn.Conv1d(1024, 512, 1), nn.BatchNorm1d(512), nn.ReLU(), nn.Conv1d(512, 256, 1), nn.BatchNorm1d(256), nn.ReLU(), nn.Conv1d(256, num_classes, 1), ) def forward(self, x): x = self.mlp(x) # (B, 1024, N) x = x.max(dim=2, keepdim=True)[0] # 全局最大池化 (B, 1024, 1) x = self.classifier(x) # (B, num_classes, 1) return x.squeeze(-1) # (B, num_classes) model = SimplePointNet(num_classes=50) optimizer = optim.Adam(model.parameters(), lr=0.001) criterion = nn.CrossEntropyLoss() for epoch in range(200): model.train() for feats, labels in train_loader: optimizer.zero_grad() logits = model(feats.transpose(1, 2)) loss = criterion(logits, labels) loss.backward() optimizer.step()这段代码的逻辑是:输入张量形状为 (B, N, 6),先转置成 (B, 6, N) 适合 Conv1d 处理,经过三层共享 MLP 提取逐点特征,再走全局最大池化得到全局特征,最后用逐点分类器输出每个点的部件预测。参数说明:num_classes=50是这里的关键——整个基准的所有类别部件总数是 50,不是 16。16 是物体类别数,每个物体类别内部有不同数量的部件,比如飞机有 4 个部件键,椅子也是 4 个,但全部累加去重后是 50 个部件标签。如果你的模型输出类别数设置不对,训练时 CrossEntropyLoss 会直接报维度不匹配,或者是标签越界但不报错、只在评估时出现问题。
x.max(dim=2, keepdim=True)[0]里取[0]是因为max返回值和索引两个张量,我们只需要最大值。BatchNorm 在 PointNet 这类模型里几乎是必须的,去掉后收敛速度会肉眼可见地变慢。
4.3 mIoU 这个指标是怎么算的
分割模型训练完,不能只看 overall accuracy,因为部件标签不均衡。比如椅子的椅腿点远多于扶手点,模型全部预测成椅腿也能拿到很高的准确率,但这没有意义。行业里的标准评估指标是 per-category mIoU,也就是每个物体类别分别算 IoU,再取平均。
def compute_part_miou(pred, target, num_part_cats): ious = [] for part_id in range(num_part_cats): pred_mask = (pred == part_id) target_mask = (target == part_id) intersection = (pred_mask & target_mask).sum() union = (pred_mask | target_mask).sum() if union > 0: ious.append(intersection / union) # union 为 0 表示该部件在当前样本中未出现,跳过 return np.mean(ious) if ious else 0.0这段代码的逻辑是遍历所有部件 ID,对每个部件计算预测集合和真实集合的交并比。pred_mask & target_mask得到同时被预测为该部件且真实属于该部件的点,|得到的是两边任一命中的点。参数说明:num_part_cats是你的标签体系里的部件总数,在基准数据集里是 50。这种按部件算 IoU 再平均的方式,能避免某些部件点数多、某些点数少带来的评估失真。
real 使用中更常做的是 per-category mIoU:把测试样本按 16 个物体类别分组,每组内先算每个部件的 IoU,再取均值作为该类的 mIoU,最后 16 个类的 mIoU 取平均。这样做的原因是,不同物体的部件结构差异太大,混合在一起算总体 IoU 会让大类目主导结果。
5. 避坑与排查:我在这个数据集上翻过的车
5.1 法线方向不一致:模型学了半天学了个寂寞
现象:输入带法线训练,loss 下降正常,但验证 mIoU 比只用坐标还低一截。原因:从压缩包里解出来的模型,部分 mesh 的法线方向是乱的,有的面朝外、有的面朝内,采样点继承了混乱的法线方向,等于给模型喂了噪声特征。解决:在预处理阶段做一次法线统一,常见做法是以模型中心为参考点,判断法线与面中心到模型中心的方向是否同向,不同则翻转。
face_centers = tri_verts.mean(axis=1) # (M, 3) vec_from_center = face_centers - center # 面中心指向模型中心的向量 dot = (face_normals * vec_from_center).sum(axis=1) flip_mask = dot < 0 face_normals[flip_mask] = -face_normals[flip_mask]这段逻辑是:如果法线和指向外的方向点积为负,说明法线朝内,取反。注意center是全局归一化时的几何中心,不是原点。做完这一步再采样,法线特征才是可信的。我后来把这个检查加进了所有 mesh 数据集的通用预处理管线,因为不止这一个数据集存在法线方向不统一的问题。
5.2 类别不平衡导致某些类别 mIoU 接近 0
现象:训练完看 per-category 结果,背包、帽子的 mIoU 是个位数,其他类别倒是正常。原因:这两个类别的训练实例数量相对较少,模型没见过足够多样本,且部件数量少、几何结构简单,容易被其他类别的特征干扰。解决:一是按类别加权采样,每个 epoch 里让样本少的类别多出现几次;二是评估时重点观察这些类别的混淆矩阵,确认是错到背景还是错到其他部件。
做法是维护一个每个类别样本数量的表,采样时对样本数少的类别做重复采样或者设置更高的采样权重。我在一个跟某公司合作的项目里,把背包类别做了 3 倍过采样,mIoU 从 12% 提到了 38%,其他类别没有明显下降——这说明问题主要出在数据配比,而不是模型表达力不够。
5.3 采样点在部件边界上串标签
现象:训练时 loss 下降平缓,但可视化预测结果发现部件边界的点出现条状噪点。原因:mesh 的面标签在部件边界处并不干净,同一个三角面可能跨越两个部件的物理边界,而采样点又恰好落在边界附近,继承的面标签和实际几何归属不一致。解决:一种思路是预处理时把部件标签从面映射到顶点,再用顶点标签做插值,而不是直接继承面标签;另一种是采样时增加边界区域的点密度,但这会引入额外的复杂度。
多数公开实现直接按面继承标签也能拿到不错的分数,因为边界面的比例不大。但如果你的模型要做精细部件切割,这个问题就会成为精度天花板。
5.4 把整个数据集读进内存导致训练机直接 OOM
现象:复数模型训练脚本跑了几分钟,机器内存告警,然后进程被杀。原因:mesh 文件本身不小,加上解压后的法线、标签文件,全量加载进内存很容易超过几十 GB,16G 内存的开发机直接撑不住。解决:不要一次性把所有样本读进内存,用__getitem__按需读取;或者预处理时把每个模型采样成 2048 个点之后存成紧凑的 npy 文件,丢弃原始 mesh,这样单个样本只有几十 KB 到几百 KB。
我在早期用某实验室的服务器训练时,天真地写了一个把所有 mesh 读进列表的加载器,结果一个 epoch 还没跑完就 OOM。改成按需读取后,内存占用从 30GB 降到了 4GB,训练速度反而更快了,因为系统不再需要频繁 swap。
5.5 固定随机种子才能复现,否则指标忽高忽低
现象:同一个训练脚本跑两次,测试 mIoU 差了两个点,不知道以哪个为准。原因:采样过程、数据增强、模型初始化都涉及随机性,每次数据采样得到的点云都不一样。解决:在入口固定随机种子,包括 Python 的random、NumPy 的np.random和 PyTorch 的torch.manual_seed。同时建议把采样好的点云缓存到磁盘,让每次实验用的是完全一致的数据。
import random import numpy as np import torch random.seed(2024) np.random.seed(2024) torch.manual_seed(2024) torch.cuda.manual_seed_all(2024)这段逻辑是固定三层随机源,代码本身没有参数要调,但位置必须在任何采样和初始化之前。如果用了 DataLoader 的多进程加载,还要给每个 worker 单独设 seed,否则子进程的随机序列仍然不可控。这个习惯帮我省掉了大量「这次指标怎么又不一样」的排查时间。
6. 验证模型是否真的学会了部件:可视化与跨类别泛化测试
训练完不能只看 mIoU 数字,我习惯做两件额外的事情来确认模型真的有泛化能力。第一件事是可视化预测结果:把测试样本的点云按预测标签着色,和真实标签并排渲染,直接用 matplotlib 或者 open3d 就能做。这一步能直观看到错误集中在哪些区域,比如飞机的尾翼和机身在预测里粘连在一起,说明模型没有学好局部几何边界,这是 mIoU 数字看不出来的信息。
第二件事是跨类别泛化测试,用一个类别训练好的权重去预测另一个几何结构相似的类别。比如用椅子的权重去预测桌子,因为两者都有桌腿/椅腿结构,看看模型是否把「支撑结构」这个部件概念学到了。这个测试的思路是:如果 mIoU 高是因为记住了特定形状,而不是理解了部件语义,那么在形状分布外的类别上表现会崩掉。
关于输入特征,我也验证过是否要带法线。在完整训练下,坐标加法线的 6 维输入在多数类别上比纯坐标高出 3 到 5 个点的 mIoU;但法线方向如果没有做第 5 章说的统一,反而会掉点。我现在的习惯是把带法线的版本作为默认配置,并在预处理管线里固定法线朝向,把纯坐标版本保留作为对比实验。
最后补一个训练细节:不要一上来就把学习率设成 0.01 然后不管了。这个数据集上 Adam 加 0.001 起步比较稳,训练到 120 个 epoch 左右 loss 会出现一小段平台期,这时候把学习率降到 0.0001 再跑 80 个 epoch,mIoU 通常还能再涨一个点。早年我急着出结果,全程用一个学习率,模型最后的精度总差那么一两分——后来习惯用这个两段式策略,省心很多。
数据集的标签体系、mesh 质量、法线方向这些坑,几乎每个用这份数据的项目都会撞上一次。希望这些经验能帮你少走几步弯路,也希望这篇笔记能让你在这个数据集上的第一跑不再玄学。
本文还有配套的精品资源,点击获取