ScanNet点云数据处理实战:从PLY到JSON的完整解析指南(附避坑技巧)

如果你正在计算机视觉或三维重建领域深耕,尤其是涉足室内场景理解,那么ScanNet数据集几乎是一个绕不开的基石。这个包含了大量真实室内场景3D扫描与丰富标注的数据集,为从语义分割到实例分割的各类任务提供了宝贵的“燃料”。然而,当你兴冲冲地下载了那几十GB的数据,面对一堆.ply.json文件时,最初的兴奋感可能会迅速被困惑取代:这些文件具体是什么?它们之间如何关联?更重要的是,如何从中精准地提取出我需要的点云坐标、颜色和语义标签?

网上的许多教程往往止步于数据下载和简单可视化,对于核心的数据结构解析和实际处理中的“坑”语焉不详。结果就是,你可能花了好几天时间,代码跑通了,却发现标签对不上,或者大量点被错误归类。本文旨在充当你的实战地图,不仅带你厘清ScanNet v2数据集中关键文件(*_vh_clean_2.ply, *_vh_clean_2.labels.ply, *.aggregation.json, *_vh_clean_2.0.010000.segs.json)的结构与关联,更会分享从原始数据到最终可用标签映射的完整处理流程,并重点剖析那些容易导致数小时调试的典型陷阱。我们的目标很明确:让你能高效、准确地将ScanNet数据转化为适合自己模型训练的格式。

1. 解构ScanNet数据文件:每个文件扮演什么角色?

在动手写代码之前,我们必须像拆解精密仪器一样,理解每个数据文件的用途和内部构造。ScanNet的数据组织是模块化的,理解这种设计哲学能让你在后续处理中游刃有余。

1.1 核心四剑客:功能与关系全景图

ScanNet v2为每个扫描场景(scan)提供了一组核心文件,通常位于 scans/<scene_id>/scans_test/<scene_id>/ 目录下。测试集(scans_test)通常不包含标注文件。以下是四个关键文件:

  • <scene_id>_vh_clean_2.ply: 这是基础几何与颜色数据。它存储了经过预处理(如去噪、简化)后的三角网格(mesh)数据。每个顶点(vertex)不仅包含三维坐标 (x, y, z),还附带了从原始RGB-D图像映射而来的颜色信息 (r, g, b) 和一个透明度通道 (a)。对于大多数点云处理任务,我们主要关心其中的顶点信息,将其视为无结构的点云
  • <scene_id>_vh_clean_2.labels.ply: 这是直接的语义标签点云。它的格式与上述的 .ply 文件高度相似,也包含坐标和颜色。关键区别在于,它的颜色并非原始视觉颜色,而是根据NYU40标签体系渲染得到的颜色,并且每个顶点直接携带了一个表示NYU40类别ID的标签值(通常存储在某个属性中,或需从颜色反向映射)。它提供了逐点的语义标签,但无法区分同一类别下的不同物体实例。
  • <scene_id>_vh_clean_2.0.010000.segs.json: 这个文件是超点(superpoint)映射表。它不包含类别信息,只包含一个名为 segIndices 的数组。这个数组的长度与 _vh_clean_2.ply 中的顶点数严格一致。数组中的每个整数,表示对应顶点所属的“超点”编号。所谓“超点”,是过分割(over-segmentation)算法产生的小块区域,是构建实例的基础单元。
  • <scene_id>.aggregation.json: 这是实例与语义的聚合描述文件。它定义了如何将 segs.json 中的超点组合成有意义的物体实例,并赋予每个实例一个语义标签。其核心结构是 segGroups 列表,每个组(即一个实例)包含:
    • segments: 一个超点编号列表,指明哪些超点属于这个实例。
    • label: 该实例的原始类别名称(raw label),如 “chair”, “table”。
    • id: 实例的唯一ID,用于区分同一类别下的不同物体。

它们的关系可以概括为:.ply 提供几何基础,.segs.json 提供过分割单元,.aggregation.json 将这些单元聚合为带标签的实例,而 .labels.ply 则提供了直接的(非实例的)语义标签视图。

为了更清晰地对比,我们将其总结如下表:

文件核心内容输出维度主要用途
*_vh_clean_2.ply顶点坐标 (x,y,z), 颜色 (r,g,b), 透明度 (a)几何+外观获取原始点云、网格重建
*_vh_clean_2.labels.ply顶点坐标, 语义标签颜色, NYU40标签值几何+语义快速获取逐点语义标签(非实例)
*.segs.jsonsegIndices: 每个顶点对应的超点ID顶点->超点映射连接顶点与实例聚合的桥梁
*.aggregation.jsonsegGroups: [超点列表, 原始标签, 实例ID]超点->实例/标签映射生成实例分割与语义标签

提示scannetv2-labels.combined.tsv 文件存在于各目录的上级,它提供了从“原始类别名”(raw label)到多种标准标签体系(如NYU40、NYU13)的映射关系,是理解标签含义的关键字典。

1.2 深入PLY文件:不止是点坐标

虽然我们常把PLY文件当点云用,但ScanNet的 _vh_clean_2.ply 本质上是一个三角网格文件。用文本编辑器打开它,在文件头你会看到类似这样的定义:

ply
format ascii 1.0
element vertex 60000
property float x
property float y
property float z
property uchar red
property uchar green
property uchar blue
property uchar alpha
element face 120000
property list uchar int vertex_indices
end_header

文件头之后,先是6万个顶点的数据(每行7个值:x, y, z, red, green, blue, alpha),接着是12万个面的数据(每个面由3个顶点索引构成)。对于点云任务,我们通常只读取 element vertex 部分。需要特别注意两点:

  1. 颜色通道red, green, blue, alpha 的值域是0-255。在 _vh_clean_2.ply 中,alpha 通常恒为255(完全不透明)。
  2. 坐标单位:坐标值是浮点数,单位是米,它们定义在一个场景相关的坐标系中。如果你需要将多个场景对齐或使用绝对尺度,可能需要额外的变换信息(通常由数据集提供的 *.txt 变换矩阵文件定义)。

读取PLY文件,我强烈建议使用成熟的库,如 open3dplyfile,它们能帮你省去解析文件头的麻烦。

import open3d as o3d
import numpy as np

# 使用Open3D读取PLY文件(这里读取的是基础几何文件)
pcd = o3d.io.read_point_cloud("./scene0000_00_vh_clean_2.ply")
# pcd.points 是 (N, 3) 的numpy数组,包含坐标
# pcd.colors 是 (N, 3) 的numpy数组,包含归一化到[0,1]的RGB值
points = np.asarray(pcd.points)
colors = np.asarray(pcd.colors) * 255  # 如果需要0-255范围的整数

print(f"点云数量: {points.shape[0]}")
print(f"颜色范围: [{colors.min():.1f}, {colors.max():.1f}]")

2. 实战解析:从原始文件到结构化标签

理解了文件结构后,我们来动手实现一个完整的流程:给定一个场景ID,如何生成一个包含每个点的坐标、颜色、NYU40语义标签和实例标签的数组?这是训练大多数3D理解模型所需的基础数据准备。

2.1 步骤一:提取基础点云与超点映射

首先,我们从 _vh_clean_2.ply_vh_clean_2.0.010000.segs.json 开始。

import json
import numpy as np
from plyfile import PlyData

def load_ply_and_segs(scene_path, scene_id):
    """加载基础点云和超点索引"""
    # 1. 加载PLY文件
    ply_path = f"{scene_path}/{scene_id}_vh_clean_2.ply"
    ply_data = PlyData.read(ply_path)
    
    vertex = ply_data['vertex']
    # 提取坐标 (N, 3) 和颜色 (N, 3),注意颜色是0-255的整数
    points = np.vstack([vertex['x'], vertex['y'], vertex['z']]).T
    colors = np.vstack([vertex['red'], vertex['green'], vertex['blue']]).T
    # 注意:这里忽略了alpha通道和face信息
    
    # 2. 加载超点索引
    segs_path = f"{scene_path}/{scene_id}_vh_clean_2.0.010000.segs.json"
    with open(segs_path, 'r') as f:
        segs_data = json.load(f)
    seg_indices = np.array(segs_data['segIndices'])  # 形状 (N,)
    
    # 验证数量是否一致
    assert len(points) == len(seg_indices), f"点云数({len(points)})与超点索引数({len(seg_indices)})不匹配!"
    
    return points, colors, seg_indices

这个函数返回了我们需要的基础几何信息,以及每个点属于哪个超点的“身份证号” seg_indices

2.2 步骤二:解析聚合文件,构建映射表

接下来,处理 .aggregation.json 文件。我们的目标是建立一个从 超点ID语义标签实例ID 的映射。

def load_aggregation_and_build_maps(scene_path, scene_id, label_map):
    """加载聚合文件,构建超点->(标签, 实例)的映射字典"""
    agg_path = f"{scene_path}/{scene_id}.aggregation.json"
    with open(agg_path, 'r') as f:
        agg_data = json.load(f)
    
    # label_map 是从原始标签名到NYU40 ID的字典,需要从 scannetv2-labels.combined.tsv 提前加载
    seg_group_to_label = {}  # 超点ID -> NYU40标签
    seg_group_to_instance = {} # 超点ID -> 实例ID
    
    for group in agg_data['segGroups']:
        raw_label = group['label']
        instance_id = group['id']
        nyu40_id = label_map.get(raw_label, 0)  # 未映射的标签默认为0
        
        for seg_id in group['segments']:
            seg_group_to_label[seg_id] = nyu40_id
            seg_group_to_instance[seg_id] = instance_id
    
    return seg_group_to_label, seg_group_to_instance

这里出现了一个关键依赖:label_map。它需要从 scannetv2-labels.combined.tsv 文件解析而来。这个TSV文件包含了原始类别名到多种标签体系的映射。我们需要提取 nyu40id 这一列。

def load_label_mapping(tsv_path):
    """从 combined.tsv 文件加载原始标签到NYU40 ID的映射"""
    mapping = {}
    with open(tsv_path, 'r') as f:
        lines = f.readlines()
    # 通常第一行是表头
    for line in lines[1:]:
        parts = line.strip().split('\t')
        raw_label = parts[0]
        nyu40_id = int(parts[4])  # 假设第5列是nyu40id,请根据实际文件确认
        mapping[raw_label] = nyu40_id
    # 为未标注点添加一个映射
    mapping['unannotated'] = 0
    return mapping

2.3 步骤三:融合数据,生成最终标签

现在,我们将前两步的产出结合起来。对于每一个点,我们通过 seg_indices 找到它所属的超点ID,然后通过映射字典找到该超点对应的语义标签和实例ID。

def generate_point_labels(points, seg_indices, seg_to_label, seg_to_instance):
    """为每个点生成语义标签和实例标签"""
    num_points = points.shape[0]
    semantic_labels = np.zeros(num_points, dtype=np.int32)
    instance_labels = np.zeros(num_points, dtype=np.int32)
    
    for i in range(num_points):
        seg_id = seg_indices[i]
        # 获取语义标签,如果超点未在聚合文件中出现,则标签为0(未标注)
        semantic_labels[i] = seg_to_label.get(seg_id, 0)
        # 获取实例标签
        instance_labels[i] = seg_to_instance.get(seg_id, -1)  # -1表示未标注或不属于任何实例
    
    return semantic_labels, instance_labels

至此,我们得到了 points(坐标), colors(原始RGB颜色), semantic_labels(NYU40标签), instance_labels(实例ID)。这四者构成了一个完整、结构化的点云标注数据。

3. 避坑指南:处理那些“消失”的点和标签

如果你严格按照上述流程操作,很快会遇到一些令人头疼的问题。以下是几个最常见的“坑”及其解决方案。

3.1 坑一:未标注点(Label 0)的处理

这是新手最容易困惑的地方。你会发现,生成的 semantic_labels 中有大量标签为0的点。这正常吗?

  • 原因:在ScanNet中,不是场景中的每一个点都被标注了。标注者主要标注了清晰可辨的物体表面。天花板、远处墙壁、杂乱区域或难以辨认的部分可能被留白,这些点在 .aggregation.jsonsegGroups 中没有对应的条目,因此其超点ID在 seg_to_label 字典中找不到,被赋予默认值0。
  • _vh_clean_2.labels.ply 的提示:这个文件直接给出了每个点的NYU40标签(存储在顶点属性或通过颜色映射)。其中标签为0的点,就是未标注点。你可以用它来验证你的解析结果:比较从 .aggregation.json 解析出的标签与 .labels.ply 中的标签,在已标注区域应该一致,而标签为0的区域也应大致对应。
  • 如何处理
    • 训练时:通常,在计算损失函数时,会忽略(ignore) 标签为0的点。这意味着这些点不参与梯度计算。
    • 评估时:标准评估协议(如ScanNet官方基准)通常也只考虑已标注的点(NYU40标签 1~40)。在提交结果或内部评估时,需要过滤掉预测标签为0或对应真值为0的点。

注意:切勿简单地将所有标签0的点归为某个背景类(如“other”)。因为“未标注”不等于“背景”,它可能包含任何东西,强行归类会引入大量噪声,严重损害模型性能。

3.2 坑二:标签映射表(TSV)的准确使用

scannetv2-labels.combined.tsv 文件是动态更新的,并且不同版本的ScanNet可能略有不同。一个常见的错误是错误地索引了列。

  • 验证映射:在编写 load_label_mapping 函数后,务必打印出几个关键类别的映射进行验证,例如:
    label_map = load_label_mapping("path/to/scannetv2-labels.combined.tsv")
    print("chair ->", label_map.get("chair"))
    print("table ->", label_map.get("table"))
    print("wall ->", label_map.get("wall"))
    
    确保输出的NYU40 ID符合预期(例如,墙通常是2)。
  • 处理未知原始标签:聚合文件中的 label 字段是原始字符串。极少数情况下,可能会出现TSV文件中没有的原始标签。一个好的实践是记录下这些“未知”标签,并将其统一映射到0(未标注),避免程序因KeyError而中断。

3.3 坑三:内存与效率优化

ScanNet单个场景的点数可能从几万到几十万不等,批量处理多个场景时,内存和速度会成为问题。

  • 选择性加载:如果只需要坐标和标签,可以不加载颜色和面信息。使用 plyfile 库时,可以指定 vertex 属性来减少内存占用。
  • 使用高效的数据结构:在 generate_point_labels 函数中,我们使用了循环。对于大规模点云,可以使用NumPy的向量化操作来加速,尽管逻辑会稍微复杂一些。
    # 向量化版本示例 (思路)
    # 首先创建一个默认值为0和-1的数组
    semantic_labels = np.full_like(seg_indices, 0, dtype=np.int32)
    instance_labels = np.full_like(seg_indices, -1, dtype=np.int32)
    
    # 获取所有在映射字典中的超点ID
    valid_seg_ids = np.array(list(seg_to_label.keys()))
    # 创建一个从超点ID到数组索引的映射(需要一些技巧)
    # ... 然后使用 np.isin 和 fancy indexing 进行批量赋值
    
  • 数据格式转换:最终生成的数据(坐标、颜色、语义标签、实例标签)可以考虑保存为更高效的二进制格式,如 .bin (Numpy数组) 或 .h5 (HDF5),以便后续快速加载。

4. 进阶应用与验证

掌握了基础解析流程后,我们可以探索一些更深入的应用和必要的验证步骤。

4.1 可视化:检验解析正确性的最佳手段

“一图胜千言”。将解析出的语义标签或实例标签可视化,是检查流程是否正确的最直观方法。

import open3d as o3d
import matplotlib.pyplot as plt

def visualize_semantic_points(points, semantic_labels, colormap='tab20'):
    """根据语义标签着色并可视化点云"""
    # 创建一个颜色映射 (NYU40有40类,加上未标注的0)
    cmap = plt.get_cmap(colormap)
    norm_labels = semantic_labels / 40.0  # 归一化到[0,1]
    # 为每个点生成颜色
    colors = cmap(norm_labels)[:, :3]  # 取RGB,忽略Alpha
    
    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points)
    pcd.colors = o3d.utility.Vector3dVector(colors)
    
    o3d.visualization.draw_geometries([pcd])

运行这个函数,你应该能看到不同颜色的点簇,对应不同的物体类别(如椅子、桌子、墙)。如果大部分点都是同一种颜色(比如默认的0对应的颜色),那很可能你的标签映射出错了。

4.2 与 labels.ply 进行交叉验证

_vh_clean_2.labels.ply 文件可以作为我们解析结果的“参考答案”。我们可以读取它自带的标签,并与我们从 .aggregation.json.segs.json 推导出的标签进行比较。

def load_labels_from_label_ply(label_ply_path):
    """从 _vh_clean_2.labels.ply 中提取NYU40标签"""
    # 这个文件通常将标签存储在 'label' 属性中,或者需要从颜色反推。
    # 这里假设标签存储在顶点的 'label' 属性(具体属性名需查看文件头)
    ply_data = PlyData.read(label_ply_path)
    vertex = ply_data['vertex']
    if 'label' in vertex:
        labels = vertex['label']
    else:
        # 如果没有label属性,可能需要从颜色映射。ScanNet的labels.ply颜色是固定的。
        # 这里需要实现一个颜色到NYU40 ID的查找表(LUT),较为复杂。
        # 一个简单的方法是直接使用官方提供的工具函数。
        raise ValueError("未找到label属性,需实现颜色映射逻辑。")
    return np.array(labels)

# 比较两个标签数组
labels_from_agg = semantic_labels  # 我们之前解析的
labels_from_ply = load_labels_from_label_ply("./scene0000_00_vh_clean_2.labels.ply")

# 计算在已标注区域(labels_from_ply != 0)的一致性
mask = labels_from_ply != 0
accuracy = np.sum(labels_from_agg[mask] == labels_from_ply[mask]) / np.sum(mask)
print(f"与labels.ply在已标注点上的标签一致率为: {accuracy:.4f}")

如果一致率接近100%,恭喜你,解析流程基本正确。如果差异很大,请回头检查标签映射和超点映射的步骤。

4.3 为特定任务准备数据

不同的模型可能需要不同的输入格式。以下是一些常见需求的转换思路:

  • 仅需语义分割:使用 points, colors, semantic_labels 即可。实例标签可以丢弃。
  • 需要实例分割:需要 points, colors, instance_labels。同时,通常需要知道每个实例对应的语义类别,这可以通过实例ID反向查找得到。
  • 需要颜色信息吗? 许多基于几何的模型(如PointNet++)可能只使用坐标和法线。颜色信息(RGB)可以作为额外的通道输入,有时能提升性能,尤其是在纹理丰富的区域。
  • 体素化或投影:如果你的模型需要体素网格或多视图,那么需要在解析出点云后,再进行相应的空间离散化或投影操作。

处理ScanNet数据是一个从混乱到有序的过程,初期可能会被复杂的文件关系和未标注点困扰。但一旦你清晰地掌握了PLY与JSON文件之间的“对话”机制,并成功绕过了那几个典型的陷阱,这个数据集就会变成一个强大而顺手的工具。我自己的经验是,在项目开始阶段,花时间编写一个稳健、可复用的数据加载模块是绝对值得的,它能避免在后续训练和调试中无数个小时的浪费。最后,多利用可视化来验证中间结果,你的眼睛是最好的调试器。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐