- 数据集
- 计算机视觉
- 3D视觉
【免费下载链接】ScanNet
Segmentator 是 ScanNet 数据集中负责 3D 表面网格过分割(supersegmentation)的核心工具,它基于 Felzenswalb 与 Huttenlocher 的 Graph Based Image Segmentation 算法,在计算出的网格法线图上聚类出若干表面段(segments),为后续的实例级语义标注提供"标注单元"。本文将结合 Segmentator/segmentator.cpp 的完整实现,讲解该工具的构建方式、命令行参数含义、底层算法流程(法线图构建、并查集聚类、小段合并)以及输出文件*.segs.json的结构,并说明它在 ScanNet 数据流水线(服务器处理管线、语义标注工具、ScanNet200 基准预处理)中的具体作用。
读完本文,你将能够:独立完成 Segmentator 的构建(Make 或 CMake)、正确选取kThresh与segMinVerts参数、理解每个参数对分割粒度的影响,并能解读*.segs.json文件以便与下游的聚合标注文件(*.aggregation.json)对接。
工具定位:ScanNet 语义标注前的过分割步骤
ScanNet 数据集提供 RGB-D 视频、3D 相机位姿、表面重建网格以及实例级语义标注。其中实例标注并非直接对整张网格进行,而是先在网格上计算"超分段"(over-segmentation),再由标注者把若干小段聚合成一个物体实例。仓库主 README.md 将这段流程描述为:
"Mesh supersegment computation code which we use to preprocess meshes and prepare for semantic annotation."
也就是说,Segmentator 位于处理管线的中段:重建(BundleFusion 产生.ply网格)→ 网格清洗与降采样(MeshLab 脚本)→Segmentator 过分割→ 人工/工具聚合语义标注(.aggregation.json)。服务器端流水线配置 Server/config/scan_stages.json 中的segment阶段清晰地刻画了它的输入输出:
{ "name": "segment", "input": [ "${id}_vh_clean_2.ply" ], "output": [ "${id}_vh_clean_2.0.000100.segs.json", "${id}_vh_clean_2.0.001000.segs.json", "${id}_vh_clean_2.0.010000.segs.json" ] }可见服务端会对同一张降采样网格分别用三档阈值(0.0001、0.001、0.01)各跑一次分割,生成三档粒度的segs.json供标注流程选用。
构建:Make 与 CMake 两种方式
Segmentator 目录下的文件结构非常精简:
| 文件 | 作用 |
|---|---|
| Segmentator/segmentator.cpp | 主程序:网格加载、法线图构建、图分割、JSON 输出 |
| Segmentator/tinyply.h、Segmentator/tinyply.cpp | 轻量 PLY 读写库 |
| Segmentator/tiny_obj_loader.h | 轻量 OBJ 网格加载库(header-only) |
| Segmentator/Makefile | GNU Make 构建脚本 |
| Segmentator/CMakeLists.txt | CMake 构建脚本 |
按 Segmentator/README.md 的说明,直接执行make即可构建(或在其他系统上用cmake生成 makefile)。仓库提供的两种构建定义如下:
Segmentator/Makefile:
CXX = g++ FLAGS=-std=c++11 main: $(CXX) $(FLAGS) -o segmentator segmentator.cpp tinyply.cpp clean: rm -f *~ *.o segmentatorSegmentator/CMakeLists.txt:
cmake_minimum_required(VERSION 3.1 FATAL_ERROR) set(CMAKE_CXX_STANDARD 11) project(Segmentator) set(SOURCES segmentator.cpp tinyply.cpp) add_executable(segmentator ${SOURCES})两者本质一致:以 C++11 标准编译segmentator.cpp与tinyply.cpp两个源文件(tiny_obj_loader.h以TINYOBJLOADER_IMPLEMENTATION宏在 segmentator.cpp 中就地实例化),产出segmentator可执行文件。构建产物即主程序入口所在:
cd Segmentator make # 生成 ./segmentator # 或 mkdir build && cd build && cmake .. && make命令行用法与参数解析
构建完成后,按文档运行:
./segmentator input.ply [kThresh=0.01] [segMinVerts=20]- 第一个参数(必填):输入网格路径,支持 PLY 与 OBJ 两种格式;
- 第二个参数(可选):分割聚类阈值
kThresh,值越大产生的分段越大(segment 更少、更大); - 第三个参数(可选):每个分段的最少顶点数
segMinVerts,不足该数量的小区间会被强制并入相邻段。
参数解析逻辑对应 segmentator.cpp 的 main 函数:
int main(int argc, const char** argv) { if (argc < 2) { printf("Usage: ./segmentator input.ply [kThresh] [segMinVerts] (defaults: kThresh=0.01 segMinVerts=20)\n"); exit(-1); } else { const string plyFile = argv[1]; const float kthr = argc > 2 ? (float)atof(argv[2]) : 0.01f; // 默认 0.01 const int segMinVerts = argc > 3 ? atoi(argv[3]) : 20; // 默认 20 ... } }默认值为kThresh=0.01、segMinVerts=20。执行时程序会打印进度信息,例如:
Segmenting xxx.ply with kThresh=0.010000, segMinVerts=20 ... Read mesh with vertexCount 123456 123456, faceCount 246789 246789 Segmentation written to xxx.0.010000.segs.json with 852 segments输入网格格式的支持细节
segment()函数通过文件后缀区分格式(见 segmentator.cpp 第 130–172 行):
.ply(含大写.PLY):使用 tinyply 二进制读取vertex元素的x/y/z属性,以及face元素的vertex_indices属性;若文件中写的是vertex_index(另一常见命名),会自动回退重试。注意读取时按每个面 3 个索引处理,因此要求网格为纯三角形网格;.obj(含大写.OBJ):使用 tiny_obj_loader 读取。源码中保留了原始顶点数组(注释写明 "we don't want them duplicated"),并只处理第一个 shape;若 OBJ 内含多个 mesh 会打印 "only single mesh OBJ supported" 的警告。
程序会打印vertexCount与faceCount供核对,例如网格不合法(顶点/面为空)时可以直接从输出发现异常。
输出文件的命名规则
分割结果写到与输入网格同目录、同主名的 JSON 文件,文件名中嵌入阈值(std::to_string(kthr)的 float 输出形式):
const string baseName = plyFile.substr(0, plyFile.find_last_of(".")); const int lastslash = plyFile.find_last_of("/"); const string scanId = lastslash > 0 ? baseName.substr(lastslash) : baseName; string segFile = baseName + "." + std::to_string(kthr) + ".segs.json";因此./segmentator scanA_vh_clean_2.ply 0.01会生成scanA_vh_clean_2.0.010000.segs.json——这也解释了服务端三档输出文件名中0.000100/0.001000/0.010000的来历。scanId取路径末段(含开头斜符),写入 JSON 的sceneId字段。
算法原理:从网格到法线图,再到 Felzenswalb 分割
1. 面法线平滑为顶点法线
分割的"像素"是网格顶点,"像素间差异"由法线方向决定。第一步是计算每个面的法线并平滑到顶点。实现位于 segmentator.cpp 第 184–208 行:
// smoothly blend face normals into vertex normals vec3f normal = cross(p2 - p1, p3 - p1); normals[i1] = lerp(normals[i1], normal, 1.0f / (counts[i1] + 1.0f)); normals[i2] = lerp(normals[i2], normal, 1.0f / (counts[i2] + 1.0f)); normals[i3] = lerp(normals[i3], normal, 1.0f / (counts[i3] + 1.0f)); counts[i1]++; counts[i2]++; counts[i3]++;这里cross()返回归一化叉积(面法线),而lerp(a, b, 1/(n+1))配合counts计数器实现的是增量平均:当某顶点第n+1次被更新时,顶点法线向新的面法线方向移动1/(n+1)的距离,数学上等价于对已处理的所有面法线求均值。这种写法只需 O(1) 内存即可得到与"先累加再除"相同的平滑结果。
2. 构建以法线差异为权重的边图
每个三角形产生 3 条无向边(注意第 200 行(i3, i2)的反序写法,使每条边的方向与环绕一致),共3 × faceCount条边。随后计算每条边的权重(第 211–229 行):
float dot = n1.x*n2.x + n1.y*n2.y + n1.z*n2.z; // 两顶点法线夹角余弦 float dot2 = n2.x*dx + n2.y*dy + n2.z*dz; // 邻顶点法线与边方向的点积 float ww = 1.0f - dot; if (dot2 > 0) { ww = ww * ww; } // make it much less of a problem if convex regions have normal difference edges[i].w = ww;权重w = 1 - cos(法线夹角),范围约在 [0, 2]:法线越一致权重越小(越倾向于合并)。第二行的平方修正是本实现的一个针对性优化——当边的方向与邻顶点法线同向(dot2 > 0)时,说明该边位于凸出区域(如墙角、桌面边缘),两个顶点法线不同是正常的几何特征而非表面不连续,因此将差异平方以显著削弱其权重,避免把平滑凸面过度切碎。
3. Felzenswalb 图分割(并查集 + 动态阈值)
segment_graph()函数(第 71–92 行)实现了 Felzenswalb & Huttenlocher 算法的核心:
universe *segment_graph(int num_vertices, int num_edges, edge *edges, float c) { std::sort(edges, edges + num_edges); // sort edges by weight universe *u = new universe(num_vertices); // make a disjoint-set forest float *threshold = new float[num_vertices]; for (int i = 0; i < num_vertices; i++) { threshold[i] = c; } // for each edge, in non-decreasing weight order for (int i = 0; i < num_edges; i++) { edge *pedge = &edges[i]; int a = u->find(pedge->a); int b = u->find(pedge->b); if (a != b) { if ((pedge->w <= threshold[a]) && (pedge->w <= threshold[b])) { u->join(a, b); a = u->find(a); threshold[a] = pedge->w + (c / u->size(a)); } } } ... }算法步骤:
- 将所有边按权重升序排序;
- 用并查集(disjoint-set forest)表示当前分区,初始每个顶点自成一个集合;
- 按权重从小到大遍历边。设当前边连接的两个集合为 a、b,各自维护一个合并阈值
threshold;仅当边权同时不超过两侧阈值时才合并:w <= T(a) && w <= T(b); - 合并后新集合的阈值更新为
T' = w + c / |S'|,其中|S'|是合并后集合的顶点数,c即命令行参数kThresh。
这正是"值越大分段越大"的原因:c越大,T' = w + c/size越高,后续更多边能通过阈值检验而被合并。底层并查集实现为universe类(第 18–60 行),采用按秩合并(union-by-rank)加查找路径压缩,find/join近似常数时间,因此对数十万顶点的场景网格也能在秒级完成。
4. 小段合并:segMinVerts 的后处理
Felzenswalb 分割后可能残留许多小于segMinVerts的碎片段。程序遍历所有边,凡连接的两个集合中任一不满最小顶点数,就强制合并(第 236–243 行):
// Joining small segments for (int j = 0; j < edgesCount; j++) { int a = u->find(edges[j].a); int b = u->find(edges[j].b); if ((a != b) && ((u->size(a) < segMinVerts) || (u->size(b) < segMinVerts))) { u->join(a, b); } }由于边按权重排序过,小段会优先与其"法线差异最小"的邻段合并。注意这是一个单遍扫描,合并可能连锁触发(小段并入大段后大段不再小,停止),实际效果是把所有小于阈值的碎片吸收进邻居,保证最终每个 segment 至少segMinVerts个顶点(在网格连通的前提下)。
最后将每个顶点的集合根(u->find(q))作为该顶点的分段索引,组成outComps数组返回。
输出格式:*.segs.json及其下游消费
分割结果由writeToJSON()写出(第 253–266 行):
ofs << "{"; ofs << "\"params\":{\"kThresh\":" << kthr << ",\"segMinVerts\":" << segMinVerts << "},"; ofs << "\"sceneId\":\"" << scanId << "\","; ofs << "\"segIndices\":["; // ... 逐顶点写出 segIndices产出结构(与仓库主 README.md 中的*.segs.json格式说明一致):
{ "params": { "kThresh": 0.01, "segMinVerts": 20 }, "sceneId": "/xxx.ply", "segIndices": [1, 1, 1, 1, 3, 3, 15, 15, 15, 15] }字段含义:
params:本次运行实际使用的参数,便于下游记录分割配置;sceneId:被分割场景的标识(取网格文件名主部);segIndices:按顶点顺序排列的分段索引数组,segIndices[i]即第i个顶点所属的 segment 编号。相同编号的顶点构成同一个表面段。
这个格式在 ScanNet 生态中被多处直接消费,可以作为理解其"契约地位"的证据:
- 标注工具:AnnotationTools/common/Segmentation.h 中的
Segmentation::loadFromFile解析sceneId、segIndices与params(含kThresh、segMinVerts及minPoints、maxPoints、thinThresh、flatThresh、minLength、maxLength等扩展字段),并建立"段 id → 顶点列表"映射,还提供computeSurfaceAreaPerSegment按三角形面积统计每个段的表面积,用于标注时的段筛选; - 聚合标注文件:AnnotationTools/common/Aggregation.h 写入的
*.aggregation.json中segGroups[].segments引用segs.json的段号,segmentsFile字段指明所引用的分割文件名,其默认值_vh_clean_2.0.010000.segs.json正对应默认参数; - 基准数据预处理:BenchmarkScripts/ScanNet200/preprocess_scannet200.py 硬编码了
SEGMENTS_FILE_PFIX = '.0.010000.segs.json',读取segIndices后结合*.aggregation.json的segGroups,把段号映射为 nyu40 语义标签与实例 id,逐点写出带label属性的点云网格供 ScanNet200 训练使用; - 服务器管线:Server/scan_processor.py 的
PROCESSES列表包含segment,在decimate产出_vh_clean_2.ply之后调用SEGMENT_BIN(segmentor 可执行文件)完成分割,render/thumbnail阶段则复用同一张降采样网格。
参数选择与使用建议
- kThresh:控制分段粒度。
0.01(默认)适合大多数室内场景;服务端还会用0.001与0.0001生成更细粒度的分割——更小的kThresh产生更多、更小的段,有利于后续聚合成精细的物体实例(如椅子腿、扶手各成一段)。注意输出文件名会按std::to_string的浮点格式嵌入该值(如0.010000),下游脚本按此命名约定查找文件,自定义阈值时应留意命名一致性; - segMinVerts:控制碎片容忍度。
20(默认)意味着每段至少 20 个顶点。网格越稀疏(如经过多次simplify降采样),同样几何尺寸对应的顶点数越少,可适当调小该值以免过度合并; - 输入网格要求为三角形网格且顶点/面索引为 PLY 标准布局;从
Server/config/scan_stages.json的管线顺序看,ScanNet 官方流程使用improve(voxel hashing)+decimate后生成的_vh_clean_2.ply作为输入,这是最贴近数据集实际标注的输入形态; - 该工具只负责过分割,不产生语义标签。语义标签来自人工聚合标注(
*.aggregation.json),二者通过段号关联——这正是 ScanNet 实例级语义标注"过分割 + 聚合"两级设计的关键衔接点。
小结
Segmentator 用一个不到 300 行的 C++ 程序实现了 ScanNet 网格过分割的完整链路:tinyply/tiny_obj_loader 加载 PLY/OBJ 网格 → 增量平均计算平滑顶点法线 → 以"法线差异(含凸区平方修正)"为边权建图 → Felzenswalb 并查集聚类(kThresh控制粒度)→ 按segMinVerts合并碎片 → 输出按顶点排列段号的*.segs.json。它是 ScanNet 实例语义标注流程中承上启下的一步:上游对接 BundleFusion 重建与 MeshLab 清洗/降采样,下游对接标注工具的段聚合与 ScanNet200 基准的标签烘焙。掌握上述构建命令、参数含义与 JSON 字段结构后,即可在自己的网格复现或微调 ScanNet 风格的过分割结果。
- 数据集
- 计算机视觉
- 3D视觉
【免费下载链接】ScanNet
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考