舞台工艺深化图的标题栏抽取:模型空间与图纸空间两套排版,怎么用 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 里的结构化事实:布局属性、实体类型、图层名、字段词、坐标。这不是偷懒,是刻意的取舍——结构化判据能被写进代码、能被单元测试钉死、能被退出码汇报;而"看着像不像标题栏"这类判断,没法进流水线,每次都要人重来一遍。

图纸的排版习惯会变,院所的字段词会改,转换工具的版本会升。把这三样都挪进配置层、把判据写成可执行的断言,图签抽取这一环就不再依赖"这次记得遍历图纸空间"——而是无论谁跑、什么时候跑,结果都可复现、可核对、可追责。

Logo

更多推荐