Files
cad-agent/ai.memory/project_agent_roadmap.md
T
gjm 6143bb9ab2 feat(review): PDF/图片离线源适配(C1)—— 无需 CAD 的整条审查链路
支持直接审查 PDF 与位图图纸,全链路离线(不连 CAD、不连 8080 端口)。
渲染在内存内完成,复用既有墨迹热力图 + 自适应瓦片规划。

- 新增 dwg_review/sources.py
  - detect_source / plan_file / collect 统一入口
  - PdfReader:pypdfium2 惰性导入,页序即图框序,处理 /Rotate 与 crop 边距
  - ImageReader:世界单位 = 源像素,snap_box 整数对齐保证窗口与像素严格一致
  - ink_grid:纯 PIL 灰度阈值 → 占用网格(不引入 numpy)
- ReviewContext.create(connect_cad=False):离线时 client/tools 均为 None
- 全链路 ctx.tools is None 守卫:review 只读子循环 / inspect_region / annotator /
  checks 的窗口类检查 / 绘图区最大化
- checklist.build_system_prompt(allow_tools=False, offline=True):离线提示词
- __main__:位置参数 source,按扩展名自动判定源类型并决定是否连 CAD
- config 新增 8 个常量(PDF_MAX_PAGES / 文本最小字数 / 各源缩放上限 / 墨迹阈值等)
- tiling.content_predicate 与 heatmap.cell_box 提升为公开函数

验证:
- pytest 457 passed
- verify_pdf_source.py:PDF 23/23、图片 9/9(不需 CAD),含实测瓦片字高像素与
  旋转映射自检(文本区墨迹 0.311 vs 忽略 /Rotate 反向对照 0.145)
- 真实 LLM 端到端:20 页 HVAC 图纸前 3 页跑通,结论具体可核验且未编造 handle

已知局限:页边线在墨迹网格里会把整页连成单簇,瓦片常退化为整页象限(CAD 侧
靠 drop_oversized 丢图框块,离线侧暂无等价手段),留待后续打磨。
2026-10-16 23:07:23 +08:00

28 KiB
Raw Blame History

name, description, type
name description type
Agent 路线与多源架构(2026-10-16) dwg_review agent 的总目标、多源图纸分层架构(视觉为最大公约数)、工具层与 agent 层的正交分工、多源证据层、截图去 UI、grounding 实测、热力图 + 自适应瓦片规划 + VLM 分辨率标定、绘图区最大化(set_drawing_area)与空瓦片过滤、审查阶段分辨率对齐、agent 侧虚拟工具 inspect_region(放大取证闭环)、几何干涉检查 find_collisions(BVH + 容器过滤)、PDF/图片离线源(pypdfium2 + 墨迹瓦片)、后续路线 project

Agent 路线与多源架构

总目标(用户 2026-10-15 明确)

实现 AI 自动识别 + 评审 DWG 图纸。分阶段:先 MCP(已完成),再 agent(进行中)。 最终要支持的图纸来源不限于自家自定义实体:

  1. 自家软件的自定义实体 DWG;
  2. 其他软件出的 DWG(各种自定义实体);
  3. 将来 PDF 图纸;
  4. 将来纯图片图纸。

架构分层(用户认可)

核心洞察:视觉模型(VLM)是"最大公约数"。DWG 自定义实体 / PDF / 图片本质是 "拿不到结构化数据时靠视觉理解"的同一件事——任何图纸只要能渲染成图,VLM 就能审。 所以结构化数据(ARX 读普通实体)是增强,视觉是兜底。

Agent 推理层(审查逻辑 / 清单 / 规则)
        ▲  统一 evidence schema
感知适配层(能力从高到低降级)
  CAD结构化(ARX) → CAD几何 → 离线解析(DWG/PDF) → 视觉(VLM)
  • MCP 工具层只管 CAD(进程内能力);PDF / 图片在 agent 侧(PyMuPDF / Pillow), 不塞进 MCP、不编 DLL。
  • 统一证据模型 + 多源适配 + 能力降级;agent 推理层不关心来源格式。

How to apply:新增"支持某格式"时,优先在 agent 侧感知层加适配器;只有"CAD 进程内的 正交感知能力"才考虑加 MCP 工具。

第一步已落地(2026-10-15,提交 1c9fc72)

取证层多源化 + 视觉多图:

  • 新增 dwg_review/evidence_model.py:统一证据模型(source_type + images:[{b64,mime,label}]),访问器 evidence_images() 兼容旧 image_b64/image_mime 单图字段(历史 evidence.json 仍可读)。
  • phases/evidence.py:collect() 多源分发 seam(当前仅 CAD)、capture_images() 整体 + 等分象限多图(结束复位视口)、sub_windows()。
  • 默认 EVIDENCE_DETAIL_TILES=4 / EVIDENCE_MAX_IMAGES=5(每图框 5 图,默认开启)。
  • 实测:真实 Plant 3D 图纸上多图让审查更细(多报出线型误用 CENTER2、图签块属性为空、 按 handle 定位的图层混用等)。

截图去 CAD 界面元素(2026-10-15,提交 f8bd12d)

get_viewport_screenshot 原先截 adsw_acadDocWnd() 客户区,含视口控件、ViewCube、滚动条, 污染截图并误导 VLM(曾把视口标签当成"需放大的内容")。已修复:

  • 改用 acedGetAcadDwgView() 只截图形区(排除滚动条/状态栏)。
  • 截图期间临时置 NAVVCUBEDISPLAY=0(关 ViewCube)与 VPCONTROL=0(关视口控件), 用 RAII 守卫(TempSysVarOff)自动恢复原值,不影响用户 CAD 界面设置; 配合 RedrawWindow(RDW_UPDATENOW) 同步重绘。
  • 实测:三项 UI 均消失,VLM 不再提界面元素。

残留(无解,已知):绘图区右上角仍有绘图窗口的最小化/最大化/关闭按钮——由 AutoCAD 画在图形区内的固有 UI,无系统变量可关。它很小且在角落,实测未影响 VLM。 (区别于应用窗口的按钮;用户最初要清的是 视口控件/ViewCube/滚动条,不含此项。)

grounding 实测(2026-10-15)

用 verify_grounding.py 拿真实截图问 deepseek-flash"哪里要放大":grounding 可用—— 给出的归一化坐标区域级定位准确(放大后确实套住目标,如图签栏、图框角), 但语义描述略有偏差(把图签栏说成"状态栏")。结论:可支撑"按区域放大",但位置对、语义别全信。

后续路线(未做)

  1. VLM 主动放大(自适应取证):2026-10-16 已落地(见下节 inspect_region)。 剩余可选打磨:把「看清没」的判定进一步形式化(目前靠 agent 用标定数学构造保证可读 + 回合上限 + 截图预算兜底),以及 grounding→WCS 之外的其它指认方式。
  2. PDF / 图片源:2026-10-16 已落地(见下节「C1:PDF / 图片离线源」)。 剩余:真实 PDF 图纸量大时按需增量取页;图片源的低分辨率原图无法放大(设计如此)。
  3. 审图规范接入:清单升级为规范库。

热力图 → 自适应瓦片规划 → VLM 分辨率标定(2026-10-15,进行中)

动机:固定「整体 + 2x2 象限」截图对 A0/A1 大图幅读不清。用户拍板:驱动信号是实体分布 (热力图),不是文字(有的图没有文字),且实际工况下实体占据图框大部分,稀疏/稠密都要能处理。 用户方案:图框类型 → 纸张真实尺寸 + 比例 → CAD 世界尺寸 → 换算像素 → 知道 VLM 分辨率 → 算出一个图框要截几张、截哪里。

用户已拍板的关键决定:

  • 热力图在 Python 侧(agent 层) 算(派生量,工具层只给原始事实)。密集图若受 query_entities 的 limit≤1000 / 帧上限拉不全 → 给现有 query_entities 加 brief 模式 (只回 handle/type/bbox、放开 limit),而不是新增"热力图工具"。
  • CollisionDetection.h/cpp(用户写的 BVH)本步不用,留作后续几何相交/干涉检查引擎。 其按质心中位分割 → 节点框可能重叠,不适合铺屏瓦片;瓦片切分用四叉树/网格(空间不相交)。 (2026-10-16 已落地:见下文「C2:几何干涉检查 find_collisions」——重写了 header-only 2D BVH。)
  • 标定判据:同时测「读出文字」与「分辨线细节」;标定字高 200 图形单位(1:100 = 纸面 2mm,国标最小)。

已落地(提交 9b8a232 热力图;cba05b0 瓦片规划 + 离线标定):

  • dwg_review/heatmap.py(纯函数):entity_boxes / build_grid(counts+coverage)/ cluster_cells(4-连通,可选膨胀)/ grid_to_windows / grid_stats / render_overlay。 drop_oversized:丢掉 bbox 占满图框的实体(实测每框恰 1 个,即图框块),否则热力图全满。
  • dwg_review/tiling.py(纯函数):frame_scale(sheet_scale>0 用之;否则由 bbox/纸张反算; 否则 default)/ world_per_px / is_text_type / plan_regions(文字簇优先、空白区不出图、 max_tiles 截断、窗口夹到图框)。
  • phases/evidence.capture_images:先截整体图并测绘图区像素 → 规划成功用规划窗口, 否则退回 sub_windows(行为零变化);任一信封 total>returned(截断)→ 退回固定策略。
  • verify_heatmap.py(不耗 token,出 PNG + bbox 缓存)、verify_vlm_resolution_offline.py、 verify_text_resolution.py(CAD 实测)。

标定结论:

  • 该模型无法可靠计数线条(6 条线在 2~10px 间距下答 4/10/4/5/4/4)→ 线条判据改用 「1 条 vs 2 条」二选一。
  • 可读下限与字体强相关:离线合成(Consolas 抗锯齿填充字体)12px(白底 10px); 真实 CAD 单线体 .shx(描边字体)6px 就能读对(3/3,两轮)。
  • 真实 CAD 线条:2/3/4px 失败,5/8/12px 成功 → R_line ≈ 5px。
  • 标定必须用 ACI 7(白/黑自适应):实测红字(ACI 1)12px 完全读不出——否则把 "对比度不足"误当"分辨率不足"。
  • 实测绘图区(窗口未最大化时)2110×462 → 有效 1280×280(长边 1280),视口宽高比 4.567。

用户拍板的默认值(2026-10-15):TEXT_READABLE_PX=8(折中,兼顾描边与 TrueType)、 LINE_READABLE_PX=6、EVIDENCE_MAX_IMAGES=12(原 5 会把规划结果截到只剩 4 张瓦片)。

瓦片粒度内容过滤:簇 bbox 是包围盒,L 形/斜向分布(如管道走向)含大片空白 → plan_regions(keep=...) 剔除空瓦片(实测有效)。

已知局限:本图纸的文字在块 / 自定义实体内,get_text_content 检不到 → 该簇会按 线条判据出图(6px),块内文字可能读不清。要更保险可把 LINE_READABLE_PX 调到 8 (等价于全用文字判据,代价是同样预算下覆盖面积减半)。

绘图区最大化 + 内容过滤覆盖回退路径(2026-10-15,本轮)

用户诉求(原话):「截屏之前,要把 CAD 铺满屏幕,并且把命令行也最小化,反正一句话, 让绘图区最大化,然后再去截屏。是不是更好?还有,我发现你有几次截屏区里面没有实体,浪费了。 截屏之前判断一下这个区域有没有实体。」

A. 新增 MCP 工具 set_drawing_area(mode="max"|"restore")(src/cad_mcp_plugins/dwg_review_ui.cpp)

  • max:保存 WINDOWPLACEMENT + CLEANSCREENSTATE(只存首次)→ ShowWindow(SW_MAXIMIZE) → sendStringToExecute("_.CLEANSCREENON")(隐藏功能区/命令行/面板)。
  • restore:CLEANSCREENOFF + 还原窗口布局。
  • 关键坑:CLEANSCREENOFF 是异步命令,会按「进入干净屏幕时的布局」重设窗口几何,把同步执行的 SetWindowPlacement 覆盖掉(表现为窗口停在最大化)。解法:restore 里用 SetTimer(NULL, 0, 700, delayedRestoreProc) 延迟 700ms 再压回布局。 实测窗口在 0.5~1.0s 之间收敛 → 调用方须等待 ≥1.5s。
  • 实测收益(屏幕 3440×1440):绘图区 2110×462 → 3310×1084(面积 3.68×), 窗口矩形 [68,0,2316,960] → [-4,-2,3444,1442],还原精确回位。
  • 验收脚本:verify_drawing_area.py(基线 → max → restore,断言矩形/最大化态/绘图区尺寸全回位)。
  • 工具注册:mcp_config.json 的 tools[](set_drawing_area 加入时 27 个;后加 find_collisions 现为 28 个), 工厂 CreateSetDrawingAreaTool。 set_drawing_area 以 set_ 开头 → 被 cad_tools.is_write_tool 判为写类(不重试), 且不在 READ_ONLY_TOOLS 里 → LLM 的受控子循环不能动用户界面。

B. 取证层接入(phases/evidence.py)

  • maximized_drawing_area(ctx) 上下文管理器:with 内 max、退出 finally 里 restore。 受 config.DRAWING_AREA_MAXIMIZE 控制;工具缺失 / 调用失败 → 静默降级; restore 失败不掩盖业务异常。__main__.run_review 用它包住整个取证循环(多图框只进出一次)。
  • 内容过滤统一到两条路径:原先只有规划路径过滤,回退路径(sub_windows 象限)不过滤。 现在 keep = _content_predicate(boxes) 一次构造,规划与回退共用; _apply_keep 在「过滤后为空」时退回原集合(宁可多拍不漏)。
  • 改用「实体 bbox」而非「占用格」判定(上一版用 heatmap.occupied_boxes): 格子量化会把跨界内容算进相邻格,反而更粗(实测某实体 bbox 正好压象限边界时 4 个象限全判有内容)。 → 直接 boxes_intersect(tile, entity_box),更精确也更简单;heatmap.occupied_boxes 随之删除。 语义:接触即算有内容(保守)。

实测(真实图纸,3 个图框 A0/A1/A2):截图张数 12/8/4 → 8/5/4(收益同时来自 绘图区变大 → 单张覆盖更多世界尺寸,与空瓦片被剔除)。端到端 --plan-only 跑通, 运行中窗口峰值 [-4,-2,3444,1442](面积 2.31×),结束精确还原 [68,0,2316,960]。

新增配置:DRAWING_AREA_MAXIMIZE(True) / DRAWING_AREA_ENTER_WAIT(1.5) / DRAWING_AREA_RESTORE_WAIT(1.5,必须 > 插件内 700ms 延迟还原)。

审查阶段分辨率对齐 + 放大取证闭环(2026-10-16,本轮)

发现的缺口:maximized_drawing_area 原先只包住取证循环,审查阶段没被包住 → LLM 在受控子循环里 zoom + 截图拿到的是未最大化的 2110×462(正是上一轮刚花力气解决的 低分辨率问题,在最需要放大看细节的审查阶段又出现了);且审查中的 zoom 不复位视口, 跑完把用户的 CAD 留在最后一次放大处。

三项改动:

  1. __main__.run_review:把 with evidence_phase.maximized_drawing_area(ctx) 从 「只包取证」扩展到「包取证 + 审查」(两者都要截图);report/annotate 留在 with 之外。
  2. phases/review.py:新增 _VIEW_TOOLS = {zoom_to_window, zoom_to_entity}; review_frame 拆成外壳 + _review_frame,外壳 try/finally 里仅当确实动过视口才 _reset_viewport(复位到该图框整幅视图 zoom_to_window(win, margin=0.02))。 —— 只在动过时复位,既省一次调用,也不影响"纯查结构化数据"的路径。
  3. checklist.build_system_prompt:把「可调用只读工具」改写成明确的放大取证协议: 截图看不清时用 zoom_to_window(坐标由图框/实体 bbox 算)或 zoom_to_entity, 再 get_viewport_screenshot 复核;并约束不要为看图而反复放大。

实测(真实图纸,1 图框,verify_review_maximized.py):

  • 取证阶段采样 [-4,-2,3444,1442]、审查阶段采样同样是 [-4,-2,3444,1442](最大化态), 结束精确还原 [68,0,2316,960] → 三项全 PASS。
  • 子循环里 LLM 真的连发 8 组 zoom_to_window+get_viewport_screenshot (提示词生效,"看不清→放大重看"闭环跑通)。

同一轮发现的次生问题:_MAX_OBS_IMAGES 原为硬编码 3,而模型一轮内会发 8 次截图 → 拍了 8 张只回灌 3 张(其余被"已省略"占位)。改为配置项 REVIEW_OBS_IMAGES (默认 6,config.py),并把预算耗尽时的占位文案改成明确告知模型"不要再请求截图, 请给出结论"(原先只说"已省略")。实测模型截图次数不变(8),但能看到 6 张而非 3 张。

A 收尾:agent 侧虚拟工具 inspect_region(2026-10-16)

要解决的三个缺口:① 放大没复用标定/瓦片数学(LLM 一次 zoom 只出一张,宽区域仍读不清); ② 「看清没」由 VLM 自评(易过度自信/幻觉);③ 只能按 handle / 手算 bbox 指认, 图上看到但没 handle 的东西指认不了。

核心思路:把「放大」升级成一个语义化工具——LLM 只指认「要看哪里」, 由 agent 复用 tiling.py 的标定数学算出该切几张、每张覆盖多大,用构造保证可读。

新增 dwg_review/inspect.py(agent 侧,不编 DLL):

  • TOOL_SCHEMA:inspect_region(target, purpose)。target 三形态任选其一—— {handle} / {window} / {label, cx, cy, span};purpose = text(8px) / line(6px)。
  • InspectState:单图框内的「截图标签 → 世界可见范围」登记表。初始登记取证阶段的 overall/tile-*,每次放大再追加 inspect-1、inspect-2… → LLM 可用上一轮返回的标签 继续下钻(逐级放大)。
  • resolve_target / _point_box:label+cx+cy → WCS。cy 是图像坐标(上→下), 世界 y 必须翻转(y = y2 - cy*H)——最容易写错的一处,单测专门钉住。
  • plan_tiles:复用 tiling.world_per_px + tiles_for_box + subdivide。 超出 INSPECT_MAX_TILES(默认 4)→ 硬失败并给出「缩小 target / 分多次请求」的具体指引 (不静默降级,否则等于给一堆读不清的图);拿不到比例/像素 → 退回单张并如实告知。

关键几何:tiling.visible_box——CAD 的 _.ZOOM _W 是等比适配(不拉伸), 指定窗口按视口宽高比放大到刚好装下,多余一维露出更多内容。所以「截图上的归一化坐标」 要准确映射回 WCS,必须按 max(W/ew, H/eh) 反算真实可见范围,不能直接用送进去的窗口。 (zoom_to_window 返回的 view 是加过 margin 的窗口,不是可见范围,别混用。)

证据层配套:evidence_model.make_image(..., window=) 记录每张图的可见世界范围; capture_images 用 view_px(overall 图的原始像素)+ visible_box 逐张登记。

子循环接入(phases/review.py):_run_readonly_tool 在只读白名单校验之前拦截 inspect_region(它不在 READ_ONLY_TOOLS 里,内部只调白名单内的只读 MCP 工具); _review_tools 启用时用 inspect_region 取代三个裸视口工具(zoom_to_window / zoom_to_entity / get_viewport_screenshot)——一个语义化入口比三个裸工具更好用; inspect_region 计入 _VIEW_TOOLS → 结束时复位视口。 配置:INSPECT_ENABLED(True) / INSPECT_MAX_TILES(4)。

端到端实测(真实图纸 1 图框,verify_review_maximized.py):

  • 取证与审查两阶段均在最大化态、结束精确还原 → 4 项全 PASS。
  • LLM 的 11 次取证调用里 2 次是 inspect_region,且都显式给了 window + purpose:"text": x[20000,115000] y[-6500,3000] 与 x[-4000,30000] y[-6500,3000]——A0 图框 (y[-6434,77666])的底部横条,即标题栏所在。模型自己把范围收窄到横条再放大。
  • 验收口径达成:报告报出标题栏乱码/占位字符 ???????????????, 而 get_text_content 对全图返回 0 条文字(本图文字全在块/自定义实体内)—— 即块内文字是被视觉放大读出来的,不是结构化数据给的。

C2:几何干涉检查 find_collisions(2026-10-16)

方向选择:用户拍板「C 两个都做,C2 先(BVH → MCP 工具)」;并说明目录里的 CollisionDetection.h/cpp 是参考例子(可用可重写)。分析后:其中纯 ObjectARX 的 AABB/EntityProxy/BVHTree(约 1~367 行)可移植,其余(needCD 领域过滤 + MFC 对话框) 依赖用户 App 私有头文件(不在仓库),故只重写 BVH 内核。

新增(C++,src/cad_mcp_plugins/):

  • bvh.h:header-only 2D BVH(namespace dwg_bvh)。质心中位分割(取较长轴, std::nth_element),叶 ≤ kLeafSize=4;SelfPairs 枚举相交对(叶内 i<j + 子树交叉)。
  • find_collisions.cpp:mcp_Tool_FindCollisions,EXPORT_MCP_TOOL 导出。 参数:window/layer_filter/limit/offset(通用)+ min_overlap / narrow / include_same_layer / exclude_oversized / exclude_container_deg / exclude_container_frac。
    • ratio = 逐轴重叠比之积;某轴重叠比 = 该轴重叠长 / 两实体该轴较短长, 任一方该轴退化为 0 时取 1.0。于是共线相接→0、横管穿阀门→≈1。
    • 按 ratio 降序 + 分页;narrow=true 只对返回页跑 AcDbEntity::intersectWith 细筛(附 exact)。
    • 返回 {total, returned, offset, entity_count, items[], excluded_containers[]}。

关键调参发现:容器(图框块)过滤必须用「重叠度」而非「面积」。 初版用面积(exclude_oversized 相对实体并集)——3 张图框并排在模型空间里, union 面积极大,图框只占 27%/13%/7%,全部漏过 → 全模型 86 实体产生 163 对候选, top 全是 INSERT/40B × 一切 且 ratio=1.0(图框完全包住小实体),min_overlap=0.99 仍剩 83 对。 实测重叠度分布:图框 deg=35/25/23,其余实体全部 deg≤3(分离极干净)。 故新增容器过滤:deg >= max(exclude_container_deg=8, exclude_container_frac=0.25 × 其他实体数) → 剔除其全部配对并在 excluded_containers 里如实报告(含 reason=oversized/container)。 与图幅/缩放无关,窗口态与非窗口态都生效。

实测(真实 Plant 3D 图纸 agent_test.dwg,86 实体,verify_collisions.py 17/17 PASS):

  • 全模型候选 163 → 80,且 80 对全部 ratio=0.000(仅管线接口相接); min_overlap=0.5 → 0 对;逐图框(window)同样 0 对 → 本图无真实干涉(真阴性)。
  • 容器过滤剔除 3 个 INSERT(deg 35/25/23,reason=container)。
  • narrow 细筛:top-5 里 4 条 exact=true(接口处 pipe/fitting 真的几何相交)—— 与 ratio=0.0 一致:相交但未深度重叠。
  • 注入式正例(--inject,画 2 个深重叠矩形于图框外 → 应报出 → 删除): 报出 ratio=0.902(= (380/400)²,与理论一致),清理 2/2 成功。

agent 侧接入(Python):

  • cad_tools.READ_ONLY_TOOLS 加 find_collisions → LLM 受控子循环可调用(写类判定不受影响)。
  • checks.check_collisions + run_deterministic_checks:受 CHECK_COLLISIONS 开关控制, 默认关闭(AABB 相交噪声大,平面图里管线穿墙/引线跨图元属正常相交), 改由 LLM 按「干涉/碰撞」清单条目按需调用;COLLISION_MIN_OVERLAP 默认 0.5。 产出的 issue category="干涉"(清单新增类别,输出枚举同步加「干涉」)。
  • checklist.py / review_checklist.json:新增「干涉/碰撞」类别(severity=high), 提示词写明「先 find_collisions 拿候选 → 对可疑 handle 用 inspect_region 目视确认, AABB 相交 ≠ 真干涉」。

端到端实测(python -m dwg_review --frames 1):清单显示 6 类(含干涉/碰撞); LLM 在受控子循环里主动调用 find_collisions {'min_overlap': 0.2, 'narrow': True, 'limit': 60, 'window': <图框>},并结合 inspect_region;最终未误报干涉(正确真阴性)。

待办:无(C1 / C2 均已完成)。

C1:PDF / 图片离线源(2026-10-16)

用户拍板的关键决定:

  • 支持 PDF + 图片两种源;PDF 先做,图片「顺手做了」。
  • 完全离线,不连 CAD(connect_cad=False)。
  • 不做 OCR:「都有 VLM 了,还 OCR 实在是画蛇添足」。
  • 文本抽取只是次要补充:国标图纸的文字多为 SHX 描边(导出成线条),文本层几乎为空 (实测该暖通 PDF:正文页 0 个文本矩形;有文本层的是目录页/表格页)。
  • PDF 在内存渲染,复用既有瓦片规划能力。
  • 后端选 pypdfium2(Apache-2.0/BSD、py3-none-<platform> wheel 与 Python 版本无关、 ~4MB)而非 PyMuPDF(AGPL-3.0 有商业约束 + cpXX wheel)。文本抽取已降级为次要, 瓦片规划改用像素墨迹密度而非矢量路径,故 pypdfium2 足够。

新增 dwg_review/sources.py(agent 侧,不编 DLL):

  • 世界坐标系统一(都 y 向上,与 heatmap/tiling 一致):PDF = 页内点(已应用 /Rotate)、 图片 = 源像素。故 window/bbox 与 CAD 侧同形,直接复用 tiling/heatmap 纯函数。
  • detect_source(path)(扩展名 → cad/pdf/image)、plan_file(ctx)(每页一个「图框」, 保持原始页序——不能再按 bbox 重排)、collect(ctx, index, unit)(同形 evidence)。
  • 墨迹网格 ink_grid(image, box)(纯 PIL,无 numpy):灰度 → 缩略 → 阈值二值化 → Image.BOX 块均值降到网格 → 与 heatmap.build_grid 同形的 grid + 占用格 bbox。 于是 heatmap.cluster_cells → tiling.plan_regions 原样复用。
  • 每张瓦片的渲染倍率由瓦片世界尺寸反推:zoom = IMAGE_MAX_EDGE / max(w,h),夹到源上限 (PDF PDF_TILE_MAX_ZOOM;图片 IMAGE_MAX_ZOOM=1 不放大)。这与 plan_regions 的 s_text/s_line 是同一套数学(zoom = 1/s):规划出的文字簇瓦片恰好落在 zoom_text = TEXT_READABLE_PX / (TEXT_HEIGHT_MM · PT_PER_MM)。
  • PdfReader / ImageReader 读取器(PDF 惰性 import pypdfium2);open_reader/close_reader 把读取器缓存在 ctx.source_reader(避免每页重复打开)。

pypdfium2 的关键 API 事实(踩过的坑):

  • page.render(scale, rotation, crop) 的 crop 是「要裁掉的边距」(left,bottom,right,top), 不是绝对矩形,单位是点、且在旋转后的页面上生效 → crop_of(box, W, H) = (x0, y0, W-x1, H-y1)。 写成绝对矩形会报 Crop exceeds page dimensions(或裁错区域)。
  • page.get_size() 已应用 /Rotate,page.get_mediabox() 未应用。
  • tp.get_rect(i) 前必须先调一次 tp.count_rects(),否则 PdfiumError: Failed to get rectangle。
  • 文本 rect 是未旋转坐标 → /Rotate 90 映射 (x,y) → (y, mw - x)(实测验证: 文本区墨迹命中率 0.335 vs 其它假设 ≤0.064,并目视确认「暖通专业图纸」正向)。
  • 退化 bbox(宽/高 ≤ 0)会让 crop 边距之和等于页尺寸 → 提前拒绝,别交给 pdfium。

离线链路打通(守卫 ctx.tools is None): context.ReviewContext.create(connect_cad=False) → client/tools 均为 None; phases/evidence.maximized_drawing_area、phases/review._review_tools/_run_readonly_tool/ _reset_viewport、checks.run_deterministic_checks、annotator.annotate/clear 全部先判 None。 checklist.build_system_prompt(offline=True) 换成纯视觉提示词(无工具协议、明确要求 不要输出 handle、说明文本层为空属正常)。

CLI:python -m dwg_review <source>(位置参数);.pdf/图片 → 离线模式, .dwg/.dxf → 提示后按 CAD 在线模式(Python 打不开 DWG),未知格式/文件不存在 → exit 2。 --list-tools/--check/--clear/--restore 在离线源下被拒绝。

实测(verify_pdf_source.py,20 页暖通图纸):

  • PDF 前 4 页 20/20 PASS;图片源(由该 PDF 第 3 页渲染成 2717×1945 PNG)9/9 PASS。
  • 分辨率自检(实测而非推导):每张瓦片的「纸面字高像素」≥ 线条下限 6px, 典型瓦片达 8.5~13.9px(文字档 8px);整体图 ~4.3px(整页缩略,设计如此,细节由瓦片负责)。
  • 旋转自检自动化:文本区墨迹 0.311 vs 整页平均 0.012;反向对照(忽略 /Rotate)0.145 → 映射正确。
  • 真实 LLM 端到端(python -m dwg_review <pdf> --frames 3):VLM 读懂了真实工程图—— 报出「设备标注框叠压家具/洁具/门扇弧线」「设备平面定位尺寸缺失」「标高以『梁底』代替数值不统一」 「1-1 剖面室外机未表达与墙柱间距」「材料表单重/共重两列全空」「审定未签署」等具体且可核验的问题, 且未编造 handle(离线证据无 handle,全部 issue 无 handle 字段)。

新增配置:PDF_MAX_PAGES(0=全部) / PDF_TEXT_MIN_CHARS(2) / PDF_OVERVIEW_MAX_ZOOM(4.0) / PDF_TILE_MAX_ZOOM(6.0) / IMAGE_MAX_ZOOM(1.0) / INK_ANALYSIS_MAX_EDGE(1024) / INK_THRESHOLD(200) / INK_MIN_CELL_FRAC(0.01);模块级 PT_PER_MM = 72/25.4。

已知局限:

  • 图框/页边线是细线,在墨迹网格里把整页连成一个大连通簇 → 瓦片常退化为 「整页 2×2 象限 + 少量密集小岛」。「空白区不出图」的收益因此打折(CAD 侧靠 drop_oversized 丢掉图框块,离线侧暂无等价手段)。成本由 TILE_MAX_COUNT/EVIDENCE_MAX_IMAGES 兜底。
  • 图片源不能放大(IMAGE_MAX_ZOOM=1):低分辨率扫描件无法凭空提升可读性。
  • 文本层对国标图纸基本无用(正文页 0 个文本矩形),这是预期,主力仍是 VLM 看图。
  • 依赖新增:requirements.txt(openai / Pillow / pypdfium2)。