舞台工艺深化图的标题栏抽取:模型空间与图纸空间两套排版,怎么用 Python 让 AI 智能体判准图签在哪一层
舞台工艺深化图的标题栏抽取:模型空间与图纸空间两套排版,怎么用 Python 让 AI 智能体判准图签在哪一层
图纸送到审图台,第一件事不是看内容,是建台账——这张图的图号是什么、图名叫什么、哪个专业、第几版。台账的每一行都从图签来。图签(标题栏)就是图纸右下角那块表格:工程名称、图名、图号、设计与审核签字、比例、日期、版次。舞台工艺深化图(音视频、灯光、舞台机械这几摊)尤其依赖它,因为一批几十张图的提资和齐套性核对,全都拿图号当主键——图号错了,后面所有的对应关系和版本对账全部串位。
问题出在图签不总是待在同一个地方。
一、背景:同一批深化图,摊着两套图签排版
舞台工艺深化图交付时常见的排版就两种,本文把它们称作排版甲与排版乙。
排版甲(出图规范那一套):整张图按 A3 或 A4 出图。外框线和标题栏放在图纸空间(paper space,即"打印出来的这一页"这个容器)的独立图层上,模型空间只装真正的绘图内容——设备、点位、管线、标注。图签在图纸空间,模型空间干干净净。
排版乙(老习惯那一套):外框、标题栏跟绘图内容一起画在模型空间里,图纸空间是空的,甚至根本没有建。图签在模型空间。
本批 10 张深化图全部是排版甲——图签落在图纸空间的图框层上。这 10 张图里,只要抽取器只遍历模型空间,10 个图号一个都抽不出来,抽取器还会安安静静地返回"成功",因为它确实读到了东西,只是读的不是图签。
于是问题的形状就清楚了:难点不是"遍历哪个空间",而是"怎么先判出图签在哪一层,再决定去哪儿抽"。遍历是体力活,判归属才是有技术含量的那一步。本文只讲这一个动作:DWG 两套空间的排版差异如何影响图签抽取,以及判据怎么设计、抽取器怎么写、结果怎么自证完整。图纸本身合不合规、审图意见怎么定,不在本文范围内——那套判缺逐关的流程另有文章,本文不碰。
对 AI 智能体来说,这一节的判据是整套抽取的地基。模型空间与图纸空间在 DXF 里是两套平行容器,图签在容器里的位置不由软件决定,由出图习惯决定;把"图签在哪一层"这个判断固化成可执行代码,AI 智能体才谈得上批量读图。本文把这条判断拆成六条判据,逐条给出判据、实测证据与实现位置。
顺带说清一个用词:本文说的"文字层",指的是 DWG 里以文字实体形式存在的可读取内容(TEXT、MTEXT、块属性这些),与图纸栅格化之后呈现的像素图像是两回事——本文全程只在文字层上做文章,不涉及栅格化读图。
二、环境准备:依赖、版本与目录约定
抽取链路只依赖一个第三方库 ezdxf,纯 Python 实现,不需要装 AutoCAD,也不碰任何 COM 组件,设计院给什么格式都能在任意一台开发机上跑。本文代码在 ezdxf 1.4.4、Python 3.13.14 上实测通过。
# 1) 安装与版本确认
pip install "ezdxf==1.4.4"
python -c "import ezdxf; print('ezdxf', ezdxf.__version__)"
# 2) DWG 是闭源格式,ezdxf 直接读 DXF,必须先转一道
# ODA File Converter 或 AutoCAD 的 accoreconsole 都行,二选一即可
# 转换后先抽查:布局数量对不对、图纸空间还在不在
python - <<'PY'
import ezdxf
doc = ezdxf.readfile("batch/dxf/sample.dxf")
for lay in doc.layouts:
print(repr(lay.name),
"model=", lay.is_modelspace,
"anyPaperspace=", lay.is_any_paperspace,
"activePaperspace=", lay.is_active_paperspace)
PY
上面这段抽查脚本是整条链路里最不该省的一步。DXF 转换环节如果把布局丢了(这在某些转换配置下确实会发生),图纸空间会整体消失,图签随之消失,而抽取器只会告诉你"这张图没有图签"——你根本分不清是图纸本来就没有,还是转换时弄丢了。先确认布局还在,再谈抽取。
目录约定保持简单,中间产物与图纸分离,便于事后复检:
batch/
├─ dxf/ 转换后的 DXF
├─ raw/ 抽取产物 JSON(每图一份 + 批级一份)
├─ tb_extract.py 抽取器
└─ run.log 批级运行日志
把抽取产物单独落出来,是为了让人能对着原图复核:脚本说图号是 A,它得给出"在哪个空间、哪一层、哪个实体类型上读到的"这三样,否则这个 A 没法采信。
三、判据设计:怎么判断图签在哪一层
这一节是本文的核心。五条判据按依赖顺序排,前一条不成立后面就不用看了。
3.1 判据一:空间身份用属性判定,绝不用布局名字
这是最容易被忽略、也最容易致命的一条。
不同 CAD、不同出图习惯写出的布局名完全不一样。实测一个样本文档,布局名是 ['Model', 'Layout1', '图纸-1']——模型空间叫 Model 不叫 *Model_Space,两个图纸空间一个英文一个中文。按 *Model_Space 这个字面量去取,会直接抛:
KeyError: '*MODEL_SPACE'
而按字面量匹配更危险的情况是:名字写对了但只匹配到一张布局,于是另外几张图纸空间被静默跳过——抽取器照常返回结果,只是少抽了一部分图。
正确做法是用 ezdxf 提供的三个属性:
layout.is_modelspace—— 是不是模型空间;layout.is_any_paperspace—— 是不是任意一张图纸空间;layout.is_active_paperspace—— 是不是当前激活的那张图纸空间。
这三个属性在实测样本上的表现:
'Model' model=True anyPS=False activePS=False
'Layout1' model=False anyPS=True activePS=True
'图纸-1' model=False anyPS=True activePS=False
3.2 判据二:不能只调一次 doc.query()
很多人在 ezdxf 上写的第一行是 doc.query("TEXT")。这个调用能用,但它回答不了本文要问的问题。
ezdxf 的 Drawing.query 源码是这么写的:
def query(self, query: str = "*") -> EntityQuery:
"""Entity query over all layouts and blocks, excluding the OBJECTS section and
the resource tables of the TABLES section."""
return EntityQuery(self.chain_layouts_and_blocks(), query)
作用域是"所有布局 + 所有块"。这意味着:doc.query("TEXT") 返回的文字,你能读到内容,但读不出它属于模型空间还是哪一张图纸空间。而"图签在哪一层"恰恰是本文要回答的核心问题。
实测对照(同一个文件,两种写法):
doc.query('TEXT') 总数 = 8
layout 'Model' -> 1
layout 'Layout1' -> 0
layout '图纸-1' -> 7
总数 8 看起来挺好,可这 8 条混在一锅里,你无法说出"图签在那 7 条里"。所以判定必须逐个 layout 迭代,每条文字都得带着"我来自哪个空间"这个出处。
3.3 判据三:只取激活的图纸空间,等于什么都没取
判据一里提到的 is_active_paperspace,是个陷阱。CAD 里"激活"的图纸空间指当前编辑的那张,跟"图签画在哪张上"没有必然关系。
实测同一份文件:
'Layout1' active=True TEXT数=0 <- 激活的这张,空的
'图纸-1' active=False TEXT数=7 <- 没激活的这张,图签在这
按激活布局取,等于精准地取到了那个空布局,然后一本正经地报告"该图未抽到图签"。图纸空间要全遍历,不筛激活态。
3.4 判据四:图签在哪一层 —— 字段集打分,三档候选
判归属不能靠坐标猜、也不能靠图层名猜,得靠内容特征。判据是字段词命中:图签里必然有若干固定字段名,扫到这些字段名,就说明这堆文字是标题栏候选。
字段分两档:
- 核心字段(2 个):图号、图名。核心字段缺一个,图签就不成立——图号是台账主键,图名是这张图的标题,两者是台账的最小可用集。
- 可选字段(14 个):工程名称、项目名称、专业、阶段、设计、制图、审核、校对、审定、日期、比例、版次、图幅、签字。
合计 16 个字段词。核心字段定得少,是为了收紧判定:可选字段越多,“命中"的含义越稀释,一个普通设备表里冒出"设计”"日期"是常事,但"图号"加"图名"同时出现,指向标题栏的把握就大得多。
按命中情况把每个空间分成三档:
- 强候选:核心字段全中。
- 弱候选:只中了部分字段。
- 无候选:一个字段都没中。
判定规则随之是四条:
- 恰好一个强候选 →
FOUND,图签就在这一层; - 多于一个强候选 →
AMBIGUOUS,不猜,交给人看。模型空间和图纸空间都放了图签的情况真会出现,程序替人二选一就是在制造假确定性; - 只有弱候选 →
MISSING,报出来但不硬判; - 全是弱候选也没有 →
MISSING,候选列表里把所有空间都列出来,让人一眼看出"每层各命中几个字段"。
3.5 判据五:字段名可能在 tag 上,不在 text 里
这一条是本文实测里最有价值的一条,也是"两套排版"之外的第三层变数:图签既可以用裸文字画,也可以做成块属性。
裸文字版是这样的:一条 TEXT,内容是 图号: XXX。字段名和值挤在同一个 text 里。
块属性版是这样的:一个图签块,块定义里有 ATTDEF,插入时挂 4 个属性。实测这个样本的读取结果:
layout.query('TEXT') = 0
layout.query('ATTRIB') = 0
layout.query('INSERT') = 1
INSERT.attribs:
tag='图号' text='ELV-07' layer='图框' insert=(305.0, 36.0)
tag='图名' text='小剧场音视频深化图' layer='图框' insert=(305.0, 29.0)
tag='设计' text='张某' layer='图框' insert=(305.0, 22.0)
tag='审核' text='李某' layer='图框' insert=(305.0, 15.0)
看清楚这三行零:layout.query('TEXT') 是 0,因为块属性在 DXF 里是独立的 ATTRIB 实体,附着在 INSERT 上;layout.query('ATTRIB') 也是 0,因为 ATTRIB 不在布局的实体空间里,它是块的子实体。
唯一能拿到它们的路子是 insert.attribs。 只按 TEXT MTEXT 过滤的抽取器,在这张图上抽到 0 条图签,而这张图明明有。
更要命的是字段名的位置:tag 上是"图号"“图名”,text 上是"ELV-07"“小剧场音视频深化图”。 字段名在 tag,值在 text。只扫 text 找"图号"两个字,永远找不到;只扫 tag,又拿不到图号的值。所以字段命中判定必须同时扫 tag 和 text,这一点直接决定了下一节实现的写法。
3.6 判据六:批级完整性自检(图号齐套性)
单图抽到不算完。批级还要核三件事:
- 每张图都必须是
FOUND——漏一张就报一张,不能糊过去; - 图号不能重复——两本图册共用一个图号,台账会串;
- 图号齐套性——编号序列有没有断号。齐套性是舞台这行验图的老规矩:图号连号才说明这一册没缺页。
这层只核对"抽到了没有、抽到的对不对得上",不碰图纸本身的内容合规性。职责边界要划清楚:抽图签的脚本不替审图专业意见。
判据六落到本批实测上就是一句话:这批 10 张深化图全部是排版甲,图签统一落在图纸空间的图框层上。只遍历模型空间的抽取器,这 10 张图会一张不落地漏掉全部 10 个图号——而且不报错,因为模型空间里确实有东西可读,它只是不是图签。图号一空,台账就没了主键;主键一空,这批图后续所有的提资对齐、齐套性核对、版本对账全都失去锚点。漏 10 个图号的后果,不是少读了 10 张图,是整批图失去索引。
四、实现:抽取器代码骨架
下面两块 Python 代码按顺序拼起来就是完整可运行的 tb_extract.py。第一块是数据结构和判据,第二块是判定、批级自检与命令行入口。
4.1 第一块:归一化、字段判定、逐空间采集
# -*- coding: utf-8 -*-
"""标题栏(图签)抽取器 —— 判据与采集层。
只回答「图签在哪一层、有没有抽到」,不回答图纸本身是否合规。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field, asdict
import ezdxf
# ===== 判据配置:核心字段 2 个 + 可选字段 14 个 = 16 个字段词 =====
CORE_FIELDS = ("图号", "图名")
OPT_FIELDS = ("工程名称", "项目名称", "专业", "阶段", "设计", "制图",
"审核", "校对", "审定", "日期", "比例", "版次", "图幅", "签字")
ALL_FIELDS = CORE_FIELDS + OPT_FIELDS
_CTRL_RE = re.compile(r"\\[A-Za-z][^;]*;?") # MTEXT 控制码:\P \f \H …
_WS_RE = re.compile(r"\s+")
def normalize(s: str) -> str:
"""文本归一化:去 MTEXT 控制码,去掉所有空白与全角空格。"""
s = _CTRL_RE.sub("", s or "")
s = s.replace("\r", " ").replace("\n", " ")
return _WS_RE.sub("", s).replace(" ", "")
@dataclass
class TextHit:
"""一条图签文字候选及其出处(可追溯到空间、层、实体类型)。"""
space: str # 模型空间 / 图纸空间:<布局名>
layer: str
source: str # TEXT / MTEXT / ATTRIB / BLOCK_TEXT
text: str
tag: str = "" # 块属性的 tag,ATTRIB 才有
x: float = 0.0
y: float = 0.0
def field_hits(self) -> list[str]:
"""字段命中判定。
★ 关键:块属性式图签里,字段名在 tag 上(图号/图名/设计…),
字段值在 text 上(ELV-07 / 某文化艺术中心…)。
只扫 text 会全漏,必须 tag + text 同扫。
"""
blob, tag = normalize(self.text), normalize(self.tag)
return [f for f in ALL_FIELDS if f in tag or f in blob]
@dataclass
class SpaceProbe:
"""单个空间(布局)的探查结果。"""
space: str
text_count: int = 0
hits: list[TextHit] = field(default_factory=list)
@property
def layers(self) -> list[str]:
return sorted({h.layer for h in self.hits if h.layer})
def field_hits(self) -> list[str]:
out: set[str] = set()
for h in self.hits:
out.update(h.field_hits())
return sorted(out)
def core_ok(self) -> bool:
"""核心字段齐 → 强候选。"""
f = set(self.field_hits())
return all(c in f for c in CORE_FIELDS)
def _entity_text(e) -> str:
if e.dxftype() == "TEXT":
return e.dxf.text or ""
if e.dxftype() == "MTEXT":
return e.plain_text() # ★ 用 plain_text,.text 还留着 \P 控制码
return ""
def _xy(e) -> tuple[float, float]:
try:
return float(e.dxf.insert[0]), float(e.dxf.insert[1])
except Exception:
return 0.0, 0.0
def space_name_of(layout) -> str:
"""给布局起稳定名字。
★ 绝不用 '*Model_Space' 这类字面量判断:各家 CAD 写出的布局名不统一,
按名字匹配会静默漏掉整个空间(实测 get('*Model_Space') 直接 KeyError)。
"""
if layout.is_modelspace:
return "模型空间"
if layout.is_any_paperspace:
return f"图纸空间:{layout.name}"
return f"其他布局:{layout.name}"
def collect_space_texts(layout, space: str) -> SpaceProbe:
"""遍历一个空间,收集全部文字候选。
三条来源都要进来,缺一条就漏图签:
① 布局里的裸 TEXT / MTEXT
② 块实例的属性 ATTRIB —— 块属性式图签的字段值就在这儿
③ 块定义里的裸文字 —— virtual_entities
"""
probe = SpaceProbe(space=space)
for e in layout.query("TEXT MTEXT"):
txt = _entity_text(e)
if not txt:
continue
x, y = _xy(e)
probe.hits.append(TextHit(space=space, layer=e.dxf.layer,
source=e.dxftype(), text=txt, x=x, y=y))
probe.text_count += 1
for e in layout.query("INSERT"):
bx, by = _xy(e)
# ② 块属性:tag 是字段名,text 是字段值
for a in getattr(e, "attribs", []):
txt = a.dxf.text or ""
if not txt:
continue
try:
ax, ay = float(a.dxf.insert[0]), float(a.dxf.insert[1])
except Exception:
ax, ay = bx, by
probe.hits.append(TextHit(space=space, layer=a.dxf.layer,
source="ATTRIB", text=txt,
tag=a.dxf.tag or "", x=ax, y=ay))
probe.text_count += 1
# ③ 块定义里的裸文字
try:
for ve in e.virtual_entities():
if ve.dxftype() not in ("TEXT", "MTEXT"):
continue
txt = _entity_text(ve)
if not txt:
continue
vx, vy = _xy(ve)
probe.hits.append(TextHit(space=space,
layer=getattr(ve.dxf, "layer", e.dxf.layer),
source="BLOCK_TEXT", text=txt, x=vx, y=vy))
probe.text_count += 1
except Exception:
pass
return probe
def probe_doc(path: str) -> list[SpaceProbe]:
"""逐布局探查。★ 不用 doc.query:它跨布局+跨块,拿不到「属于哪一层」。"""
doc = ezdxf.readfile(path)
return [collect_space_texts(lay, space_name_of(lay)) for lay in doc.layouts]
4.2 第二块:判定、图号提取、批级自检与退出码
# -*- coding: utf-8 -*-
"""标题栏抽取器 —— 判定层与批级自检(接上一块)"""
import ezdxf # noqa: F811 (与上一块同一文件时删除本行)
DOCNO_RE = re.compile(r"[A-Za-z]{1,6}[-_][A-Za-z0-9][A-Za-z0-9\-_]{1,}")
@dataclass
class Verdict:
file: str
verdict: str # FOUND / AMBIGUOUS / MISSING / ERROR
space: str = ""
fields: list[str] = field(default_factory=list)
doc_no: str = ""
candidates: list[str] = field(default_factory=list)
def extract_doc_no(probe: SpaceProbe) -> str:
"""从图签候选里取图号:先找带「图号」字段的那条,再退回通用匹配。"""
for h in probe.hits:
blob, tag = normalize(h.text), normalize(h.tag)
if "图号" in blob or "图号" in tag or "图纸编号" in tag:
m = DOCNO_RE.search(blob)
if m:
return m.group(0)
for h in probe.hits:
if h.field_hits():
m = DOCNO_RE.search(normalize(h.text))
if m:
return m.group(0)
return ""
def pick_titleblock(probes: list[SpaceProbe], name: str) -> Verdict:
"""判定图签落在哪一层。判据三档:
强候选:核心字段(图号 与 图名)同时命中
弱候选:只命中部分字段
无候选:该空间不含图签
"""
strong = [p for p in probes if p.core_ok()]
weak = [p for p in probes if not p.core_ok() and p.field_hits()]
def lab(p):
return f"{p.space}({len(p.field_hits())}字段)"
if len(strong) == 1:
p = strong[0]
return Verdict(file=name, verdict="FOUND", space=p.space,
fields=p.field_hits(), doc_no=extract_doc_no(p),
candidates=[lab(x) for x in strong + weak])
if len(strong) > 1:
# 两个空间都有图签 → 不猜,交给人
return Verdict(file=name, verdict="AMBIGUOUS",
candidates=[lab(x) for x in strong + weak])
if weak:
return Verdict(file=name, verdict="MISSING",
candidates=[lab(x) for x in weak])
return Verdict(file=name, verdict="MISSING",
candidates=[lab(x) for x in probes])
def scan(paths: list[str]) -> list[Verdict]:
out = []
for p in paths:
try:
probes = probe_doc(p)
except Exception as exc: # 读不动就是读不动,不猜
out.append(Verdict(file=p, verdict="ERROR",
candidates=[f"{type(exc).__name__}: {exc}"]))
continue
out.append(pick_titleblock(probes, p))
return out
# ===== 批级自检与退出码 =====
EXIT_OK, EXIT_MISSING, EXIT_AMBIGUOUS, EXIT_ERROR = 0, 1, 2, 3
def batch_selfcheck(verdicts: list[Verdict]) -> tuple[int, list[str]]:
"""批级完整性自检:每张图都要有图签、图号不能重、齐套性不能断号。
这里只做「抽到了没有」的核对,不碰图纸本身是否合规。
"""
problems: list[str] = []
for v in verdicts:
if v.verdict == "MISSING":
problems.append(f"{v.file}: 未抽到图签(候选 {v.candidates})")
elif v.verdict == "AMBIGUOUS":
problems.append(f"{v.file}: 图签层不唯一(候选 {v.candidates})")
elif v.verdict == "ERROR":
problems.append(f"{v.file}: 文件读取失败({v.candidates})")
seen: dict[str, str] = {}
for v in verdicts:
if v.verdict == "FOUND" and v.doc_no:
if v.doc_no in seen:
problems.append(f"{v.file}: 图号 {v.doc_no} 与 {seen[v.doc_no]} 重复")
else:
seen[v.doc_no] = v.file
if any(v.verdict == "ERROR" for v in verdicts):
return EXIT_ERROR, problems
if any(v.verdict == "AMBIGUOUS" for v in verdicts):
return EXIT_AMBIGUOUS, problems
if problems:
return EXIT_MISSING, problems
return EXIT_OK, problems
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="DWG/DXF 标题栏抽取与批级自检")
ap.add_argument("files", nargs="+", help="DXF 文件路径")
ap.add_argument("--json", action="store_true", help="输出 JSON 详情")
args = ap.parse_args(argv)
verdicts = scan(args.files)
if args.json:
print(json.dumps([asdict(v) for v in verdicts], ensure_ascii=False, indent=2))
else:
for v in verdicts:
print(f"[{v.verdict:9s}] {v.file} | 图签层={v.space or '-'} | "
f"图号={v.doc_no or '-'} | 字段={','.join(v.fields) or '-'}")
code, problems = batch_selfcheck(verdicts)
for p in problems:
print(f" ! {p}")
print(f"EXIT={code}")
return code
if __name__ == "__main__":
sys.exit(main())
4.3 实际跑出来的样子
我造了六份样本把两种排版各走一遍,输出是这样:
[FOUND ] fixture_A.dxf | 图签层=图纸空间:图纸-1 | 图号=ELV-01 | 字段=图号,图名,审核,工程名称,日期,比例,设计
[FOUND ] fixture_B.dxf | 图签层=模型空间 | 图号=ELV-01 | 字段=图号,图名,审核,工程名称,日期,比例,设计
[MISSING ] fixture_BAD.dxf | 图签层=- | 图号=- | 字段=-
! fixture_BAD.dxf: 未抽到图签(候选 ['模型空间(0字段)', '图纸空间:Layout1(0字段)', '图纸空间:图纸-1(0字段)'])
! fixture_B.dxf: 图号 ELV-01 与 fixture_A.dxf 重复
EXIT=1
排版甲被判成图纸空间,排版乙被判成模型空间,坏样本如实报缺,重复图号被拦下。退出码分四档,语义清晰:
EXIT=0—— 全部抽到,图号无重复;EXIT=1—— 有图没抽到图签,或图号重复;EXIT=2—— 图签层不唯一(模型空间与图纸空间都放了图签);EXIT=3—— 文件读不动。
这个退出码是给流水线用的。批处理里 EXIT=1 就该停下来查,EXIT=2 该叫人来看,EXIT=3 该查文件转换环节——三种失败的处置方式完全不同,所以退出码不能揉成一个"失败"。AI 智能体接这条流水线时,退出码就是它判断"这批能不能进下一道"的唯一依据,因此语义必须写死在代码里、不能靠人记。本文只做"抽到了没有"的核对,图签字段齐不齐、图号与图册对不对得上,是专业意见范畴,不在脚本里定。
五、踩坑与边界
下面七条全部是本轮实测撞出来的,不是转述的经验。
坑一:布局名硬匹配。 按 *Model_Space 取布局,实测直接 KeyError: '*MODEL_SPACE';就算碰巧对上了,也只能覆盖一张图纸空间。正确做法是用 is_modelspace / is_any_paperspace / is_active_paperspace 三个属性。
坑二:拿 doc.query() 当全图搜索。 它的作用域是所有布局加所有块,能读内容但读不出层级归属。实测 doc.query('TEXT') 返回 8 条,分布在三个布局上(1 / 0 / 7),你没法从返回值里知道哪 7 条才是图签。必须逐 layout 迭代。
坑三:只取激活的图纸空间。 实测文件里激活的是 Layout1(TEXT 数 0),图签在没激活的 图纸-1(TEXT 数 7)。取激活的那张,恰好取到空布局,然后一本正经地报"未抽到图签"。图纸空间要全遍历。
坑四:只按 TEXT MTEXT 过滤。 块属性式图签实测 layout.query('TEXT') 与 layout.query('ATTRIB') 双双为 0,全部内容藏在 insert.attribs 里。少写这一段,整批图号全丢。
坑五:字段名在 tag 上,值在 text 上。 同一个坑的深一层。tag='图号' text='ELV-07',扫 text 找"图号"两个字永远找不到;只扫 tag 又拿不到图号的值。字段判定必须 tag 与 text 同扫。
坑六:MTEXT 的 .text 还留着控制码。 实测 .text 返回 '工程名称: 某文化艺术中心\\P图名: 小剧场音视频深化图',\\P 是 DXF 里的换行控制符。拿它去匹配字段还好,落到台账里就是一串乱码。plain_text() 才是给人看的文本,返回 '工程名称: 某文化艺术中心\n图名: 小剧场音视频深化图'。
坑七:块内文字的坐标已经是图纸坐标。 实测一个 INSERT 的插入点是 (300, 10),而它 virtual_entities() 出来的块内文字坐标是 (305, 43)——已经是图纸坐标,不需要再乘块变换矩阵。ATTRIB 的坐标同理((305, 36) 等)。但要注意块内文字与块属性是两套坐标来源,做图签包围盒时得统一到同一个基准,否则框出来的范围会偏。
至于边界,有三条要划清楚:
- 判据只回答"抽到了没有、在哪一层",不回答图纸本身是否合规。 图签字段缺了、图号与图册对不上、版本不对应,这些都需要专业判断,不该由一个抽取器悄悄下结论。
- 字段词表是配置项,不是硬编码。 本文这 16 个词覆盖的是国内设计院常见的标题栏习惯,但不同院所用词不同——有的写"图别"、有的写"图纸编号"、有的写"图纸名"。上线前拿自己这批的样本补词表,并把词表挪到配置里,改词不用改代码。
- DXF 转换丢布局要单独抽查。 这一步的失败后果是"全部图都没有图签",症状和"这批图本来就没画标题栏"完全一样。转换完先抽查布局数量,别把转换问题当成图纸问题查半天。
六、总结:把「图签在哪一层」变成可执行动作
图签抽取看着是"读个表格",实际上是 DWG 结构里最容易踩坑的一步。四个变量凑在一起就足以把整批图号抽空:两套排版决定图签落在哪一层,三条文字来源决定内容从哪读,两个字段载体决定字段名在哪。
本文给的判据顺序是有讲究的:先用属性判空间身份(不用名字),再用字段集判图签归属(三档候选,不确定就报 AMBIGUOUS),最后用批级齐套性自检兜底(每张图都抽到、图号不重、序列不断号)。 三步互相独立,每一步都能单独测:第一步错了后面全错,第二步不确定就主动交给人,第三步兜住批级漏网。
对 AI 智能体来说,这套判据的价值恰恰在于它的确定性——“图签在哪一层"原本是个需要人眼在几十张图里比对的事,现在被固化成了可以写进代码、可以批量跑、可以用退出码汇报的动作。AI 智能体拿到的不是一句"这批图我看过”,而是每张图一行:图签层、图号、命中字段数。人只需要看两种输出:AMBIGUOUS 的那几张,和自检报出来的缺号与重号。
值得说明的是,判据里没有一条是"看图的样子"。全部判据只吃 DXF 里的结构化事实:布局属性、实体类型、图层名、字段词、坐标。这不是偷懒,是刻意的取舍——结构化判据能被写进代码、能被单元测试钉死、能被退出码汇报;而"看着像不像标题栏"这类判断,没法进流水线,每次都要人重来一遍。
图纸的排版习惯会变,院所的字段词会改,转换工具的版本会升。把这三样都挪进配置层、把判据写成可执行的断言,图签抽取这一环就不再依赖"这次记得遍历图纸空间"——而是无论谁跑、什么时候跑,结果都可复现、可核对、可追责。
更多推荐

所有评论(0)