CGAL 提取中心线遇点离散,把 Codex Base URL 改到 TaoToken 后排查
2026/9/17 2:14:45 网站建设 项目流程

用 CGAL::extract_mean_curvature_flow_skeleton 提取中心线时,我遇到的症状是点突然离散。这次排障我打算让 Codex 跟着读代码,先把 Codex 接到 TaoToken,再去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 拿 Key,工具里统一填 https://taotoken.net/api 。这个现象不是整体偏移,而是血管中心线跑到半截会出现一小段乱跳。输入几何本身是血管表面,走完 vtkCleanPolyData、vtkTriangleFilter、vtkvmtkCapPolyData 封口,STL2OFF 写成 tmp.off,再由 Polyhedron 读入,结果 CGAL 细化出来的点找不到稳定的邻接关系。我一度怀疑是封口失败,后来怀疑 OFF 面索引错位,但拿不准,所以决定用 Codex 做一次逐段走查:先看 tmp.off,再检查代码里骨架图的输出方式。

1. 从报错现场说起:离散点出在 CGAL 输出,还是 vtkPoints 写入

1.1 我原来的预处理管线

使用 CGAL 提取中心线,先决条件是输入必须是封闭三角网格。VTK 管线的做法是先清理并三角化,然后用 vtkvmtkCapPolyData 将血管开口封住。封口之后为了不让 cap 上的三角形影响后续处理,我习惯再 clean 一次、再三角化一次,才把 polydata 交给 STL2OFF。

我按原始函数结构整理过的流程如下,省去了判空和分支处理,只留主链路:

vtkNew<vtkCleanPolyData> cleaner; cleaner->SetInputData(surface); cleaner->Update(); vtkNew<vtkTriangleFilter> tri; tri->SetInputConnection(cleaner->GetOutputPort()); tri->PassLinesOff(); tri->PassVertsOff(); tri->Update(); vtkNew<vtkvmtkCapPolyData> capper; capper->SetInputConnection(tri->GetOutputPort()); capper->SetDisplacement(0); capper->SetInPlaneDisplacement(0); capper->Update(); // 封口后再清理、再三角化 cleaner->SetInputData(capper->GetOutput()); cleaner->Update(); tri->SetInputConnection(cleaner->GetOutputPort()); tri->Update(); STL2OFF("tmp.off", tri->GetOutput()); Polyhedron tmesh; std::ifstream input("tmp.off"); input >> tmesh; if (!CGAL::is_triangle_mesh(tmesh)) { std::cerr << "not triangle mesh" << std::endl; return tri->GetOutput(); } Skeleton skeleton; CGAL::extract_mean_curvature_flow_skeleton(tmesh, skeleton); vtkNew<vtkPoints> pts; vtkNew<vtkCellArray> verts; for (quint32 i = 0; i < boost::num_edges(skeleton); ++i) { pts->InsertNextPoint(skeleton[i].point[0], skeleton[i].point[1], skeleton[i].point[2]); verts->InsertNextCell(1); verts->InsertCellPoint(i); }

这个流程能跑通,但中心线输出的点序和原始面片并没有约定,所以一旦 CGAL 的骨架图被直接转成 vtkPoints,就会丢失空间连续性。尤其要注意的是,CGAL 的 mean curvature flow skeleton 输出的是一个图,不是一段按顺序排列的折线。它由顶点和边构成,边表示拓扑连接,顶点坐标才是空间位置。你想得到的是“沿着血管走向的骨架线”,而不是“图中所有顶点的集合”。

1.2 先排查“点索引”和“边拓扑”混淆

查看代码中的这个循环:

for (quint32 i = 0; i < boost::num_edges(skeleton); ++i) { pts->InsertNextPoint(skeleton[i].point[0], skeleton[i].point[1], skeleton[i].point[2]); verts->InsertNextCell(1); verts->InsertCellPoint(i); }

这段代码的意图看起来是想遍历骨架图的每条边,把边上的点压入 vtkPoints。但skeleton的类型通常是boost::adjacency_list,它并没有operator[]的重载支持“第 i 条边”这种随机访问。你真正想取的是顶点描述符对应的坐标,却把它当成数组下标访问。即使编译侥幸通过,拿到的也不是按管腔走向排列的顶点。CGAL 的骨架细化不会保证顶点的存储顺序和中心线方向一致,这也是“离散”现象的天然来源。

更稳妥的遍历方式是使用 Boost.Graph 的迭代器:

for (auto v : vertices_range(skeleton)) { const auto &pt = skeleton[v].point; pts->InsertNextPoint(pt[0], pt[1], pt[2]); }

但这样仍然只是把图里的顶点倒进 vtkPoints,缺少按边序重新组织的步骤。后面 3.3 节会展开。

1.3 封口带来的“假封闭”,让细化过程跑偏

另一个隐患在vtkvmtkCapPolyData。该滤波器虽然能把开口边界补成一个个三角形,但它生成的 cap 只有几何,没有法线方向保证。如果 cap 三角形法线和血管表面不一致,CGAL 在收缩网格时会把开口误判成凹陷区域。实测起来就像是中心线从某个端口突然折出去,或者从某段开始点与点互相翻转。这个原因和索引无关,需要单独验证 tmp.off 是否真的水密。

判断方法很简单:统计 tmp.off 的边界边数量。如果边界边不为 0,说明封口后的网格在 CGAL 眼里仍是“漏的”,骨架自然跑偏。is_triangle_mesh只能判断面是不是三角形,不能判断网格是否封闭,这属于两个不同层面的检查。

2. 把 Codex 接到 TaoToken:Key、Base URL 和模型 ID

2.1 在控制台创建 Key,并从模型广场取模型 ID

为了让 Codex 能读 tmp.off 和检查 C++ 代码,我先把它的 API 端点切到 TaoToken。注册、登录、创建 API Key 都在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 完成。模型 ID 以当时模型广场列表为准,不要在网上随便抄一个“latest”或“gpt-5”字符串。收藏夹里的 Key 复制出来之后,暂时叫它YOUR_API_KEY,后续不会再从正文里出现第二个真实 Key。

这里要特别区分两个地址:网页控制台是带utm_source=taotoken_aicg_blog_end的落地页;Codex 填入的 Base URL 是https://taotoken.net/api,末尾不能带/v1,也不能带任何 UTM 参数。UTM 参数只用于网页端追踪,接口地址保持纯 URL。

2.2 Codex 的 config.toml 怎么写

打开~/.codex/config.toml(如果之前配置过别的 provider,先备份)。把 provider 指向 TaoToken:

model = "YOUR_MODEL_ID" # 以 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 模型广场为准 model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "CODEX_API_KEY" wire_api = "chat"

不要直接把YOUR_API_KEY写进 config.toml,而是通过环境变量CODEX_API_KEY注入,这样文件就算被同步到仓库,也不会泄漏 Key:

export CODEX_API_KEY=YOUR_API_KEY

如果之前设置过OPENAI_API_KEY,注意CODEX_API_KEY的优先级不一定覆盖它;最稳妥的做法是在终端里unset OPENAI_API_KEY,只留CODEX_API_KEY,再运行 codex。这样可以避免 Codex 误连到官方端点,导致 Base URL 改了却不生效。

还要确认wire_api的值。TaoToken 的兼容通道默认是 chat completions 风格,所以我写"chat"。如果 Codex 报unsupported endpoint,说明模型消费的是 responses 风格,再把它改成"responses"试试,模型 ID 同样以模型广场为准。

2.3 先用文件检查验证接入是否成功

配置完不要急着排查,先让 Codex 做一些轻量验证:

你现在能不能读取当前目录下的 tmp.off?读取后告诉我: 1. 文件头部的顶点数、面数是多少; 2. 面索引的取值是否都在顶点数范围内; 3. 用你理解的 OFF 格式检查,这个网格是否能被 CGAL 正确解析。

这一步能同时验证 Key、Base URL、模型 ID 三个变量。如果 TaoToken 的调用记录里能看见这条消息,说明链路是通的。后续所有排查都建立在这个链路之上。

3. 让 Codex 沿着 RefinementComputingCenterLines 逐段走查

3.1 surface_cleaner → surface_triangulator → surface_capper 的管线顺序

现在把原始 C++ 函数RefinementComputingCenterLines粘给 Codex,要求它按阶段输出意见。Codex 会先注意到第二次cleaner->SetInputData(capper->GetOutput())重复清点。此时 capper 的输出已经被重新编号,cleaner 再跑一次,会再次合并并压缩点数量。如果 capper 内部生成了非流形边,cleaner 可能直接移除一部分点,面索引仍然有效,但原本沿血管方向排列的顶点被抽稀,中心线自然出现跳跃。

另一个值得检查的点是vtkTriangleFilterPassVertsOff()/PassLinesOff()。这两个方法确保输出里只有 polys,不混入顶点或线单元。如果 capper 输出里带了一些孤立点,三角化会直接丢弃它们,但丢弃的点不影响面索引,人眼很难察觉。你可以在两次三角化之间加一段调试打印:

std::cerr << "after clean: " << cleaner->GetOutput()->GetNumberOfPoints() << " points, " << cleaner->GetOutput()->GetNumberOfPolys() << " polys\n"; std::cerr << "after tri: " << tri->GetOutput()->GetNumberOfPoints() << " points, " << tri->GetOutput()->GetNumberOfPolys() << " polys\n";

把这两个数字贴给 Codex,它可以判断点序是否在 clean 阶段被大幅重排。如果点数没变,但顺序变了,问题通常在写入阶段;如果点数本身就少了,说明 clean 合并了重复点,可能吞掉了窄边上的顶点。

3.2 STL2OFF 的顶点/面索引是否错位

STL2OFF 的责任是把 vtkPolyData 写成 OFF 文本。OFF 的点块先写,面块后写,面块里的数字必须对应点块的位置。代码本身没问题,但你要注意传入的vtkPolyData是不是被其他过滤器继续改写。之前的写法里,centerline_input_surface = surface_triangulator->GetOutput(),然后再做其他处理,结果该 polydata 又被后续 filter 修改,导致 STL2OFF 拿到的和预期不一致。

Codex 建议在 STL2OFF 前用vtkPolyData::DeepCopy固化快照。也就是把surface_triangulator的输出深拷贝给一个专门用于写文件的变量:

vtkSmartPointer<vtkPolyData> off_snapshot = vtkSmartPointer<vtkPolyData>::New(); off_snapshot->DeepCopy(surface_triangulator->GetOutput()); STL2OFF("tmp.off", off_snapshot);

这样后续任何过滤都不会污染 off 文件。如果出问题,至少可以先排除“写文件时 polydata 已经变了”的干扰。

3.3 CGAL 与 VTK 对骨架边的输出差异

中心线问题的核心在这里。VTK 的经典思路是“最大内接球沿管腔滚动”,采样得到的点是空间连续轨迹。CGAL 的 mean curvature flow skeleton 不一样,它先把输入网格表示为带权曲面,再用曲率流逐步收缩;收缩到最后,边缘塌陷成一张图。所以输出的不是有序点列,而是一个由顶点和边组成的图结构。

因此,把骨架图丢给 vtkPoints 前,必须先做拓扑排序。正确做法是从骨架图任意顶点出发,做 BFS/DFS,把连通的边按访问顺序拆成若干条 polyline,再把 polyline 上每个点写入 vtkPoints,同时用 vtkCellArray 保存线段连接关系。如果只是把图里的顶点随机塞进 vtkPoints,那得到的就只是一堆互不连续的点,正是“点会突然离散”的画面。

Codex 给的排序思路类似这样:

std::vector<Point> ordered_points; std::map<Vertex_desc, bool> visited; for (auto vit = vertices(skeleton).first; vit != vertices(skeleton).second; ++vit) { Vertex_desc v = *vit; if (visited[v]) continue; std::queue<Vertex_desc> q; q.push(v); visited[v] = true; while (!q.empty()) { Vertex_desc cur = q.front(); q.pop(); ordered_points.push_back(skeleton[cur].point); for (auto e : out_edges(cur, skeleton)) { Vertex_desc nb = target(e, skeleton); if (!visited[nb]) { visited[nb] = true; q.push(nb); } } } }

这个排序至少保证相邻访问的顶点在拓扑图上连通。如果顶点散落,再去结合 tmp.off 的边界检查,基本能定位问题源头。

4. 打印 skeleton 顶点并对照 vtkPoints,定位离散段

4.1 添加显式打印

为了把离散点位置钉死,我在本地加了这两段日志。注意程序还是在你的机器上跑,Codex 只负责读你贴回去的日志和代码上下文。

std::cerr << "CGAL skeleton vertices: " << boost::num_vertices(skeleton) << "\n"; std::cerr << "CGAL skeleton edges: " << boost::num_edges(skeleton) << "\n"; auto v_range = vertices(skeleton); for (auto vit = v_range.first; vit != v_range.second; ++vit) { auto v = *vit; const auto &p = skeleton[v].point; std::cerr << p[0] << " " << p[1] << " " << p[2] << "\n"; } std::cerr << "vtkPoints count: " << pts->GetNumberOfPoints() << "\n"; if (pts->GetNumberOfPoints() > 0) { double p0[3]; pts->GetPoint(0, p0); std::cerr << "first point: " << p0[0] << " " << p0[1] << " " << p0[2] << "\n"; }

把 stdout 之外的 stderr 收集到文件:

./build/centerline_test 2> skeleton.log

然后把 skeleton.log 贴回给 Codex。它可以从 tmp.off 的网格封闭与点序问题出发,对比 CGAL 骨架顶点列表和 vtkPoints 写入阶段,帮你定位离散点是出现在 CGAL 输出还是写入阶段。

4.2 从日志判断离散发生在哪一侧

拿到skeleton.log后,分三种情况对照:

  • 如果 CGAL 相邻顶点的坐标差超过血管直径,说明问题在网格封口或细化参数,先回头查vtkvmtkCapPolyDatais_triangle_mesh
  • 如果 CGAL 顶点本身连续,但 vtkPoints 里点的数量和顺序对不上,说明边索引和顶点索引混用,重点检查那个 for 循环。
  • 如果num_edges(skeleton)num_vertices(skeleton)统计结果差异很大,说明你在遍历边,却在按顶点取点,离散是必然的。

这次我跑下来的结果是:CGAL 顶点数量正常,但 vtkPoints 里多出了一些重复点,且顺序和图的 DFS 顺序完全不一致。Codex 顺着 tmp.off 的边界边索引,发现vtkCleanPolyData在封口后把端盖上的几个顶点合并到了相邻三角形的顶点位,导致 OFF 点表和面索引对不上。修法是 3.2 里的DeepCopy快照,以及在写 OFF 前对点索引做一次范围校验。

5. 排查完回控制台对一下账

5.1 看记录

排障结束,回到 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 控制台,检查这次对话产生的调用次数和 token 消耗,确认CODEX_API_KEY下的用量不是零。然后顺手测一下别的模型是否也走同一个 Base URL,避免以后切换模型时才发现配置只对当前模型生效。

控制台里能看到每次请求的模型、时间、输入输出 token。如果某个请求突然没有记录,优先查CODEX_API_KEY是否对应到正确账户,以及 config.toml 里的model_provider是否真的被 Codex 使用。可以运行codex --version或对应调试命令,确认加载的是~/.codex/config.toml而不是别的路径。

5.2 后续建议

如果你接下来要反复调这个 CGAL 管线,建议把“读取 tmp.off、检查边界边、核对 point order”这类问题做成固定提示词,放进本地项目文件,这样下次复用当前 Context。若需要换模型,先到 TaoToken 模型对话 里用同一把 Key 发一条测试消息,确认模型 ID 和 Base URL 没填错。若担心长期排障消耗过大,可以打开 Coding Plan 看套餐是否够用;Key 在 控制台 API Keys 创建。要是哪天把执行端换到 Claude Code,环境变量对应关系见 接入文档。

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

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

立即咨询