☰
ScanNet Segmentator 实战:基于 Felzenswalb 图分割算法的网格过分割实现与构建详解
2026/10/14 17:21:33 网站建设 项目流程
  • 数据集
  • 计算机视觉
  • 3D视觉

【免费下载链接】ScanNet

项目地址:https://gitcode.com/gh_mirrors/sc/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/MakefileGNU Make 构建脚本
Segmentator/CMakeLists.txtCMake 构建脚本

按 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 segmentator

Segmentator/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)); } } } ... }

算法步骤:

  1. 将所有边按权重升序排序;
  2. 用并查集(disjoint-set forest)表示当前分区,初始每个顶点自成一个集合;
  3. 按权重从小到大遍历边。设当前边连接的两个集合为 a、b,各自维护一个合并阈值threshold;仅当边权同时不超过两侧阈值时才合并:w <= T(a) && w <= T(b);
  4. 合并后新集合的阈值更新为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

项目地址:https://gitcode.com/gh_mirrors/sc/ScanNet
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询