用户上传一张工程图,我们把图纸里的文字识别出来,塞进聊天上下文,让 Agent 靠这些文字回答问题。很常规的文档问答套路,一开始我们就是这么做的,但是问题在使用中暴露了出来。
用户把一张工程图上传到我们的数字孪生聊天里,然后问:
泵旁边那个标注写的是什么?
对工程师来说,这个问题几乎是扫一眼就答出来。图上有一个文本框,写着:
Replace seal kit, see WO-4451
文本框旁边还有一条细引线,连到 P-101 这台泵。工程师看到这里,基本就知道:这个标注是在说 P-101,要更换密封套件,细节去看 WO-4451 这个工单,但我们的系统没有这么理解。
它打开了 Python 沙盒,开始写解析代码。
这不是预期的结果。因为文字其实已经提取出来了,系统缺的不是那几个英文单词,真正缺的是图纸里的关系:这段文字是通过一条线指向 P-101 的。
我们手上其实什么都不缺:有一个基于 Cesium 的 3D 数字孪生场景,有一个可以控制场景的 Agent,也有大量沉在 2D 工程图里的现场知识。问题是,这三样东西并没有接起来。
Agent 能读文字,但不知道文字和图形之间是什么关系。3D 场景里有设备,但不知道图纸上的标注在说谁。工程图里本来有不少知识,但这些知识还停留在 2D 页面上。
所以后面回头看,我们做的是把工程图里的设备、标注、连接关系读出来,再把这些东西映射到 3D 场景里。
旧流程只留下了文字
工程图跟普通文档的思路完全不一样。
普通文档主要是文字内容,而工程图是文字、符号、线条和位置都可能重要。
一张 P&ID 或现场平面图里,通常同时有几类对象。
有设备,比如泵、储罐、管廊,它们会带工程标签,比如 P-101、TK-7。
有标注,比如说明框、尺寸、修订云线、检查备注。
还有关系。这个关系可能是一条引线,也可能是一段管线,也可能只是一个标签刚好落在某个设备轮廓里。
工程师看图的时候,不会把文字单独拎出来理解。他会顺手把几个信息一起看:这个标注在哪,线连到哪里,旁边是什么设备,符号又代表什么,但我们的旧流程,刚好把这些东西拆散了。
SVG 图纸上传之后,我们把它交给文件提取 API。API 返回一段文字,我们再把文字放进聊天上下文。
这样做的结果是,“Replace seal kit, see WO-4451” 被留下来了,但那条指向 P-101 的线没了。
对工程师来说,那条线就是答案的一部分。旧系统在提取文本那一步,已经顺手把它丢掉了。
所以问题其实不在 OCR。哪怕 OCR 完全正确,系统还是不知道这句话到底指向谁。
我们真正需要的是一个结构化结果:系统要知道哪些对象是设备,哪些对象是标注,哪些标注指向哪些设备,哪些设备之间有连接。再往后,如果要把这些东西带进 3D 场景,系统还得知道哪些 2D 对象能对应到场景里的对象。
如果场景里暂时还没有这些对象,系统也应该先把它们作为候选实体放进去,而不是继续把它们当成图纸上的一段文字。
SVG 不只是图
最开始我们其实试了两个方向。
一个方向是把图纸当图片看。
我们把 SVG 渲染成 PNG,交给视觉模型,让模型判断图里有什么。因为扫描版 PDF 本来也得这么处理,所以这条路很自然。
视觉模型在语义判断上确实有帮助。它能看出哪里像一台泵,哪里像一段管线,标题栏里大概写了什么。
但是它的问题是位置不够准。
如果只是问“图里有没有泵”,给一个大概区域就够了。可我们后面要做的事情没这么简单。我们要把 2D 图纸里的点映射到 3D 场景里,可能还要拿这个点做像素拾取,再通过深度缓冲区算出世界坐标,到了这一步,“大概在这里”就已经不够用了。
另一个方向是把 SVG 当 XML 解析。
这条路听上去没那么 AI,但非常实用。SVG 里本来就带着精确的几何信息。每个 <text> 元素有坐标,每条线有起点和终点,每个形状有边界框,translate、scale、rotate 这些变换也都写在结构里,其实SVG 表面是一张图,底下其实是一整套几何数据。
但 XML 也有自己的局限。它知道这里有几条线、几个圆、几个矩形之类的,但不知道这些图形组合起来是不是一台泵。
所以我选择把两边拼起来。
视觉模型负责回答“这是什么”,SVG/XML 负责回答“它在哪里”。
结构解析器先遍历 SVG,把文字位置、图形边界框、候选引线提取出来。当然这里还得处理各种 transform,不然坐标会直接错掉。
视觉模型再去看渲染后的 PNG,输出语义信息,比如这里是泵,那里是 DN50 管线,标题栏里有哪些元数据。
然后再做一层融合。
比如视觉模型说,(640, 360) 附近有一台泵;XML 解析器说,这里有一个带标签的图形组,边界框是 (632, 319, 96, 92)。最后得到的对象里,类型来自视觉模型,几何位置来自 XML。
文字也尽量从 XML 里拿,而不是从视觉 OCR 里拿。因为工程编号对精度很敏感。WO-4451 被 OCR 看成 W0-445I,人一眼能发现不对,系统却很可能把它当成另一个工单号。
这一步最后的输出是一个 JSON,我们把它叫作 DrawingGraph。
DrawingGraph 里有组件、标签、标注、标注和组件之间的链接,也有组件之间的连接关系。后面的流程都基于 DrawingGraph 做,不再直接读原始 SVG。
能用几何规则解决的,就不要先丢给 LLM
“标注应该关联到哪台设备”这个问题,一开始看上去挺像是 LLM 的活。
后来发现,真实图纸上其实没那么难读。大多数关联关系,本来就已经画在图上了。一个说明框旁边有一条引线,一头挨着文本框,另一头落在设备附近。
所以这件事更像几何问题而不是语义推理。
最后简化版的判断流程。
先看引线。如果有一条线从文本框连到设备框,这个关系的置信度就很高。
再看包含关系。如果文字本来就在某个设备或区域轮廓里,这个关系也比较可靠。
最后再看距离。如果前两种都找不到,就在合理半径内找最近的设备。这个结果只能算猜测,置信度会低很多。
LLM 被放在最后,只处理那些几何规则解决不了的少数情况。
原则是能用代码算清楚的事情,就不要让模型凭感觉猜。不然它很容易被拉去干一堆本来两步几何判断就能解决的活。
我们还会把每条关系的来源记下来。
一条关系是通过引线得到的,还是通过距离猜出来的,这两个结果不能被当成同一种东西。后面的流程在消费这些结果时,也应该按不同置信度处理。
Agent 为什么会打开 Python 沙盒
DrawingGraph 做出来之后,我们又碰到另一个问题。
用户问:
泵旁边那个标注是什么?
照理说,Agent 应该直接从摘要里回答。因为标注和设备之间的关系已经整理好了,也放进了上下文里。
但是Agent打开 Python 沙盒,开始写 json.load(...),准备自己去解析 DrawingGraph 文件。
看代码的时候发现,它在老老实实执行我们给它的提示。
我们当时在上下文里写了一句:
如果需要精确坐标,可以用文件工具读取 DrawingGraph JSON。
这句话太容易把模型带到错误路径上。
“泵旁边”听起来像个空间问题。Agent 很自然会判断自己可能需要坐标。再看工具箱,最像能处理嵌套 JSON 的工具就是通用代码执行,于是它就开始写代码了。
这个问题最后有两个修复。
一个是改提示。我们明确告诉 Agent,摘要里已经包含所有“标注到设备”的关系,回答这类问题时,不需要重新读取 JSON。
另一个更关键。我们给它加了一个更合适的工具,叫 get_drawing_graph。
这个工具不是通用代码执行,而是一个受控查询接口。Agent 可以按区域查、按标签查、按文本查。工具支持分页,也会截断过大的字段。
这个工具上线之后,Agent 基本就会优先用它,而不是动不动打开 Python 沙盒。
如果工具箱里只有一个通用代码执行工具,模型就会把很多本来不该写代码的事情也变成写代码。
然后是坐标配准
前面解决的是图纸理解问题,然后要做的是把图纸真正放进 3D 场景里。
DrawingGraph 知道对象在图纸坐标里的位置,3D 场景知道对象在真实世界里的位置,中间缺的是一个映射关系。
这个映射关系可以用仿射变换表示。
仿射变换可以处理比例尺、旋转、平移,也可以处理 y 轴方向相反的问题。SVG 的 y 轴向下增长,地图里的北向通常向上,这个差异不需要专门写特殊规则,拟合出来的系数会自己反映出来。
我们的流程大概是这样。
先把已经匹配上的世界坐标转到一个局部的 east/north 米制平面里。这里不能直接在经纬度上做最小二乘,因为经度对应的实际距离会随纬度变化。直接拿经纬度去算,结果很容易歪。
然后用最小二乘求 6 个仿射变换系数。理论上至少需要 3 个不共线的点,点越多,结果越稳。
如果有 4 个或更多匹配点,我们会跑 RANSAC。
这里的 RANSAC 没有做得特别复杂。因为匹配点数量通常不多,直接枚举所有 3 点组合就够了。每个组合拟合一个变换,再看哪个变换能解释最多匹配点。
这么做主要是为了防止一个错误匹配把整体带歪。
比如有人把 P-101 匹配到了 200 米外的另一台泵,RANSAC 会把这个点当成离群点,而不是让它把整套变换扭坏。
拟合完成后,我们会输出每个匹配点的残差,单位是米。
后来我们经常靠这个输出做 sanity check。
比如系统告诉用户:
TK-7 这个点偏了 38 米,其他点都在 2 米以内。
这种反馈比一句“配准失败”有用。用户可以直接回头检查 TK-7 这个匹配点,而不是先怀疑整套系统都不靠谱。
这里还有两个保护机制。
一个是共线检查。
储罐区里的罐经常排成一条线。如果用户只选了这一排罐,系统在垂直方向上其实没法稳定拟合。遇到这种情况,我们会直接拒绝输入,并提示用户补一个不在这条线上的匹配点。
另一个是外推标记。
如果某个预测点落在已匹配区域之外,我们会给它加上 extrapolated: true,同时降低置信度。原因很简单,仿射变换在锚点之间通常比较可信,越往外推,结果越像猜。
配准不需要已有实体
当时我们默认配准来自实体搜索。
图纸上有 P-101,数字孪生里也有一个叫 P-101 的实体。系统查到这个实体的位置,然后把它作为锚点。
如果场景里的实体数据足够完整,这当然是最稳的一种方式。
但后来我问了一个问题:如果场景里根本没有实体呢?
比如场景只是一个摄影测量生成的 3D 网格,里面没有设备对象,标签,也没有结构化资产数据。
配准算法真正需要的不是实体,而是一组点对:图纸上的 x/y 坐标,对应真实世界里的 lon/lat 坐标,实体只是拿到世界坐标的一种方式,不是配准数学本身的一部分。。
在 Cesium 这样的地理场景里,只要场景有地理参考,就可以通过深度缓冲区拿到世界坐标。用户点击一个像素,可以拿到世界位置;视觉模型在截图上指出一个位置,也可以转成世界位置。
所以后来我们把锚点来源做成了一个分层流程。
最理想的是实体搜索。图纸标签能直接匹配已有数字孪生实体。
下一层是图纸自带的地理信息,比如标题栏里的网格坐标、比例尺和北向信息,这块能力还在补。
再下一层是视觉识别,系统对 3D 场景截图做检测,再通过深度缓冲区把检测结果转成世界坐标。
最后一层是用户点击三个点。系统提示用户依次点击 P-101、VLV-23、E-401。
最后的方法听上去有点原始,但在各种奇怪场景里往往比自动识别更靠谱。点云能用,夜间场景能用,抽象示意图也能用。自动识别失败时就用它兜底,比想象中有用。
复盘时顺手挖出的 bug
当时我在写冷启动场景的流程文档。也就是 3D 场景里还没有结构化实体。用户上传图纸,点击三个锚点,系统完成配准,然后把图纸里的设备放进 3D 场景,突然发现一个逻辑不对。
当时的放置工具会跳过“已经匹配过”的组件。
这个逻辑在有实体的场景里说得通。一个组件如果已经匹配到已有实体,确实不需要再创建一个。
但冷启动场景不是这样。
在冷启动里,用户点击的三个锚点并不是已有实体。P-101 只是匹配到了用户点击的位置,它并没有匹配到一个已经存在的场景对象。
结果就变成了:系统会放置其他所有预测出来的组件,反而跳过用户亲手定位过的三个组件。
这个 bug 的根源,其实是一个词前后意思不一致。
一开始,“已匹配”的意思是:这个图纸对象已经有了一个世界坐标对应点。后来在某些代码路径里,“已匹配”又变成了:这个对象已经存在于场景中。
很有意思的是,单元测试没覆盖到。反而是在复盘的时候发现的,把流程从头到尾写一遍,本身就是一次验证。
最后形成的分工
这个功能做到后面,系统里的分工就清楚了。
Agent 主要做语义判断。比如哪个数字孪生实体才是图纸里的 P-101,比如某个客户的数据模型里,哪种关系表示“通过管线连接”。这类问题本身就比较模糊,也依赖上下文,适合交给模型。
一些有确定答案的事情,比如仿射拟合、RANSAC、引线几何判断、坐标转换,就交给工具和代码来做,不让模型瞎猜。
视觉模型也有它的边界。它可以判断“这里像不像一台泵”,但不应该负责精确测量。精确测量还是要落到 SVG 结构、几何算法和坐标工具上。
至于会改变系统状态的操作,比如创建新实体,最后拍板的还是人这个很重要。
如果一次配准错了,后果就不只是回答错一句话,系统可能在几十秒里创建出一批位置错误的实体。这个风险不能直接交给模型自动提交。
所以在每个阶段之间,我们都会保存中间结果。
DrawingGraph 记录图纸里有哪些设备、标注和关系。SceneMapping 记录坐标变换、匹配点和残差。
如果某个放置结果看起来不对,可以顺着这些中间结果往回查。问题可能出在图纸解析,可能出在实体匹配,也可能出在坐标拟合。
没有这些中间结果,系统就会更像一个黑盒。结果错了,也不知道该从哪一层开始排。
现在做到哪一步
现在完整流程已经跑通了。
用户上传一张 SVG 图纸,系统会先解析出图纸里的设备、标注和关系。用户可以直接用自然语言问图纸内容。系统也可以把图纸注册到 3D 场景里,注册方式可以是已有实体、视觉识别,也可以是用户手动点击三个点。
注册完成后,图纸里的组件会作为候选 3D 实体被放进场景,并带上对应标注。用户确认后,系统再创建这些实体,以及它们之间的数字孪生关系。
下一步我还想补两块。
一块是从标题栏里自动提取地理参考信息。如果现场平面图本身带有网格坐标、比例尺和北向,注册过程就有机会做到零交互。
另一块是空间模式匹配。
比如图纸上有六台长得很像的泵。单看某一台,系统可能不知道它对应场景里的哪一台。但如果把这六台泵的整体分布一起看,它们更像一组星座。系统可以利用这种空间模式,把一些原本模糊的对应关系自动分配出来。